From 6d5d8a6aad1eb3983b40a527d9a29e3821169bbd Mon Sep 17 00:00:00 2001 From: dxc Date: Thu, 12 Feb 2026 09:37:27 +0800 Subject: [PATCH] feat: implement BOM versioning with bom_no and new management APIs Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) --- inventory-backend/app/api/v1/bom.py | 79 +++++- inventory-backend/app/services/bom_service.py | 225 +++++++++++++++--- 2 files changed, 264 insertions(+), 40 deletions(-) diff --git a/inventory-backend/app/api/v1/bom.py b/inventory-backend/app/api/v1/bom.py index 8a40388..ddbce5e 100644 --- a/inventory-backend/app/api/v1/bom.py +++ b/inventory-backend/app/api/v1/bom.py @@ -8,6 +8,81 @@ from sqlalchemy import distinct bom_bp = Blueprint('bom', __name__) +# ==================== 新版 BOM 接口(基于 bom_no) ==================== + +@bom_bp.route('/list', methods=['GET']) +@jwt_required() +def get_bom_list(): + """获取所有 BOM 配方列表(按 bom_no 分组)""" + try: + data = BomService.get_bom_list() + return jsonify({ + 'code': 200, + 'msg': 'success', + 'data': data + }) + except Exception as e: + current_app.logger.error(f'获取BOM列表失败: {str(e)}') + return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500 + +@bom_bp.route('/detail/', methods=['GET']) +@jwt_required() +def get_bom_detail(bom_no): + """根据 BOM 编号获取配方详情""" + try: + data = BomService.get_bom_detail(bom_no) + if not data: + return jsonify({'code': 404, 'msg': 'BOM 不存在'}), 404 + return jsonify({ + 'code': 200, + 'msg': 'success', + 'data': data + }) + except Exception as e: + current_app.logger.error(f'获取BOM详情失败: {str(e)}') + return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500 + +@bom_bp.route('/save', methods=['POST']) +@jwt_required() +def save_bom(): + """保存或更新 BOM 配方(支持新建和另存为新版本)""" + try: + req_data = request.get_json() + # 必需字段校验 + if 'parent_id' not in req_data or 'children' not in req_data: + return jsonify({'code': 400, 'msg': '缺少 parent_id 或 children 字段'}), 400 + + bom_no = BomService.save_bom(req_data) + return jsonify({ + 'code': 200, + 'msg': '保存成功', + 'data': {'bom_no': bom_no} + }) + except ValueError as e: + return jsonify({'code': 400, 'msg': str(e)}), 400 + except Exception as e: + current_app.logger.error(f'保存BOM失败: {str(e)}') + return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500 + +@bom_bp.route('/stock/', methods=['GET']) +@jwt_required() +def get_bom_with_stock_by_no(bom_no): + """根据 BOM 编号获取配方详情及库存信息""" + try: + data = BomService.get_bom_with_stock_by_bom_no(bom_no) + if not data: + return jsonify({'code': 404, 'msg': 'BOM 不存在'}), 404 + return jsonify({ + 'code': 200, + 'msg': 'success', + 'data': data + }) + except Exception as e: + current_app.logger.error(f'获取BOM库存信息失败: {str(e)}') + return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500 + +# ==================== 兼容旧接口(保留不改动现有前端) ==================== + @bom_bp.route('/', methods=['GET']) @jwt_required() def get_bom(parent_id): @@ -24,7 +99,7 @@ def get_bom(parent_id): @bom_bp.route('', methods=['POST']) @jwt_required() -def save_bom(): +def save_bom_legacy(): try: req_data = request.get_json() parent_id = req_data.get('parent_id') @@ -61,7 +136,7 @@ def get_material_base_list(): @bom_bp.route('/parents', methods=['GET']) @jwt_required() def get_bom_parents(): - """获取所有已定义BOM的父件物料列表""" + """获取所有已定义BOM的父件物料列表(兼容旧版)""" try: subq = db.session.query(BomTable.parent_id).distinct().subquery() parents = MaterialBase.query.join(subq, MaterialBase.id == subq.c.parent_id).all() diff --git a/inventory-backend/app/services/bom_service.py b/inventory-backend/app/services/bom_service.py index cc0ec52..500947e 100644 --- a/inventory-backend/app/services/bom_service.py +++ b/inventory-backend/app/services/bom_service.py @@ -2,66 +2,215 @@ from app.extensions import db from app.models.bom import BomTable from app.models.base import MaterialBase from app.models.inbound.buy import StockBuy -from sqlalchemy import func +from sqlalchemy import func, distinct +import uuid +from datetime import datetime class BomService: + # ====================== 新版 BOM 逻辑(基于 bom_no) ====================== + @staticmethod - def create_or_update_bom(parent_id, child_list): + def generate_bom_no(): + """生成唯一的 BOM 编号""" + timestamp = datetime.now().strftime('%Y%m%d%H%M%S') + unique = str(uuid.uuid4())[:8] + return f'BOM-{timestamp}-{unique}' + + @staticmethod + def get_bom_list(): """ - 保存/更新父件的BOM子件关系 - child_list: [{"child_id": int, "dosage": float, "remark": str}, ...] + 获取所有 BOM 配方(按 bom_no 分组) + 返回:每个 BOM 的编号、父件信息、版本、子件数量 + """ + subq = db.session.query( + BomTable.bom_no, + BomTable.parent_id, + BomTable.version, + func.count(BomTable.child_id).label('child_count') + ).group_by(BomTable.bom_no, BomTable.parent_id, BomTable.version).subquery() + + query = db.session.query( + subq.c.bom_no, + subq.c.parent_id, + subq.c.version, + subq.c.child_count, + MaterialBase.name.label('parent_name') + ).join(MaterialBase, subq.c.parent_id == MaterialBase.id) + + results = query.all() + return [{ + 'bom_no': row.bom_no, + 'parent_id': row.parent_id, + 'parent_name': row.parent_name, + 'version': row.version, + 'child_count': row.child_count + } for row in results] + + @staticmethod + def get_bom_detail(bom_no): + """ + 根据 bom_no 获取配方详情 + 返回包含父件信息和子件列表的对象 + """ + rows = db.session.query( + BomTable, + MaterialBase.name.label('child_name') + ).join( + MaterialBase, BomTable.child_id == MaterialBase.id + ).filter( + BomTable.bom_no == bom_no + ).all() + + if not rows: + return None + + first = rows[0] + parent_id = first.BomTable.parent_id + parent_name = db.session.query(MaterialBase.name)\ + .filter(MaterialBase.id == parent_id).scalar() or '' + + children = [] + for bom, child_name in rows: + children.append({ + 'child_id': bom.child_id, + 'child_name': child_name, + 'dosage': float(bom.dosage) if bom.dosage else 0.0, + 'remark': bom.remark or '' + }) + + return { + 'bom_no': bom_no, + 'parent_id': parent_id, + 'parent_name': parent_name, + 'version': first.BomTable.version, + 'children': children + } + + @staticmethod + def save_bom(data): + """ + 保存或更新一个 BOM 配方 + data 结构: + { + "bom_no": "可选,为空则新建", + "version": "版本号,默认v1", + "parent_id": 父件ID, + "children": [ + {"child_id": 1, "dosage": 2.5, "remark": ""}, + ... + ] + } + """ + bom_no = data.get('bom_no') + version = data.get('version', 'v1') + parent_id = data['parent_id'] + children = data['children'] + + # 校验父件不能与子件相同 + for child in children: + if child['child_id'] == parent_id: + raise ValueError('父件与子件不能是同一物料') + + # 如果未提供 bom_no,则生成一个新的 + if not bom_no: + bom_no = BomService.generate_bom_no() + else: + # 删除该 bom_no 下所有现有记录 + BomTable.query.filter_by(bom_no=bom_no).delete() + + # 插入新记录 + for child in children: + bom = BomTable( + bom_no=bom_no, + version=version, + parent_id=parent_id, + child_id=child['child_id'], + dosage=child.get('dosage', 0), + remark=child.get('remark', '') + ) + db.session.add(bom) + + db.session.commit() + return bom_no + + @staticmethod + def get_bom_with_stock_by_bom_no(bom_no): + """ + 根据 bom_no 获取配方详情,并计算每个子件的库存和最大可生产数量 + """ + detail = BomService.get_bom_detail(bom_no) + if not detail: + return None + + for child in detail['children']: + # 查询该子件在 StockBuy 中的可用库存总量 + stock_qty = db.session.query( + func.coalesce(func.sum(StockBuy.available_quantity), 0) + ).filter( + StockBuy.base_id == child['child_id'] + ).scalar() or 0 + + child['current_stock'] = float(stock_qty) + dosage = child['dosage'] + child['max_producible'] = int(stock_qty // dosage) if dosage > 0 else 0 + + return detail + + # ====================== 兼容旧接口(基于 parent_id) ====================== + + @staticmethod + def get_bom_no_by_parent(parent_id): + """ + 根据父件 ID 获取其最新的 BOM 编号(用于兼容旧接口) + """ + row = BomTable.query.filter_by(parent_id=parent_id)\ + .order_by(BomTable.version.desc()).first() + return row.bom_no if row else None + + @staticmethod + def create_or_update_bom(parent_id, child_list, bom_no=None, version='v1'): + """ + 兼容旧接口的保存方法(保留原有调用方式) + 如果提供了 bom_no,则更新该 bom_no;否则为父件创建新 BOM。 """ # 校验父件不能与子件相同 for item in child_list: if item['child_id'] == parent_id: raise ValueError('父件与子件不能是同一物料') - # 删除该父件原有的BOM记录 - BomTable.query.filter_by(parent_id=parent_id).delete() + + # 如果未提供 bom_no,尝试查找现有的,否则新建 + if not bom_no: + existing = BomTable.query.filter_by(parent_id=parent_id).first() + bom_no = existing.bom_no if existing else BomService.generate_bom_no() + + # 删除该 bom_no 下所有记录 + BomTable.query.filter_by(bom_no=bom_no).delete() + # 插入新的 for item in child_list: bom = BomTable( + bom_no=bom_no, + version=version, parent_id=parent_id, child_id=item['child_id'], dosage=item.get('dosage', 0), remark=item.get('remark', '') ) db.session.add(bom) + db.session.commit() return True @staticmethod def get_bom_with_stock(parent_id): """ - 查询父件的BOM结构及库存信息 + 兼容旧接口:根据父件 ID 获取 BOM 及库存信息 + (实际会找到对应的 bom_no 再调用新方法) """ - bom_items = db.session.query( - BomTable, - MaterialBase.name.label('child_name') - ).join( - MaterialBase, BomTable.child_id == MaterialBase.id - ).filter( - BomTable.parent_id == parent_id - ).all() - - result = [] - for bom, child_name in bom_items: - # 查询该子件在 StockBuy 中的可用库存总量 - stock_qty = db.session.query( - func.coalesce(func.sum(StockBuy.available_quantity), 0) - ).filter( - StockBuy.base_id == bom.child_id - ).scalar() or 0 - - # 计算最大可生产数量 - dosage = float(bom.dosage) if bom.dosage else 0 - max_producible = int(stock_qty // dosage) if dosage > 0 else 0 - - result.append({ - 'child_id': bom.child_id, - 'child_name': child_name, - 'dosage': dosage, - 'current_stock': float(stock_qty), - 'max_producible': max_producible, - 'remark': bom.remark or '' - }) - return result + bom_no = BomService.get_bom_no_by_parent(parent_id) + if not bom_no: + return [] + detail = BomService.get_bom_with_stock_by_bom_no(bom_no) + if not detail: + return [] + return detail['children']