diff --git a/inventory-backend/app/api/v1/bom.py b/inventory-backend/app/api/v1/bom.py index 6c6ff23..46f46c3 100644 --- a/inventory-backend/app/api/v1/bom.py +++ b/inventory-backend/app/api/v1/bom.py @@ -1,6 +1,7 @@ from flask import Blueprint, request, jsonify, current_app from sqlalchemy import or_ from app.services.bom_service import BomService, _cache_delete +from app.services.cascade_service import CascadeService from app.models.base import MaterialBase from app.models.bom import BomTable from app.extensions import db @@ -382,3 +383,50 @@ def get_bom_parents(): except Exception as e: current_app.logger.error(f'获取BOM父件列表失败: {str(e)}') return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500 + + +@bom_bp.route('/cascade-inventory', methods=['GET']) +@jwt_required() +@permission_required('bom_manage') +def cascade_inventory(): + """ + 动态级联扣减领料接口。 + + 参数: + bom_no BOM 编号(必填) + order_qty 订单需求总量(必填,float) + version BOM 版本(可选,默认取最新) + + 返回: + 级联展开后的分层领料清单,含 allocated / shortage 缺口分析 + """ + try: + bom_no = request.args.get('bom_no', '').strip() + order_qty_str = request.args.get('order_qty', '').strip() + version = request.args.get('version') or None + + if not bom_no: + return jsonify({'code': 400, 'msg': 'bom_no 不能为空'}), 400 + if not order_qty_str: + return jsonify({'code': 400, 'msg': 'order_qty 不能为空'}), 400 + + try: + order_qty = float(order_qty_str) + except ValueError: + return jsonify({'code': 400, 'msg': 'order_qty 必须是数字'}), 400 + + if order_qty <= 0: + return jsonify({'code': 400, 'msg': 'order_qty 必须大于 0'}), 400 + + result = CascadeService.compute_cascade_result(bom_no, order_qty, version=version) + + return jsonify({ + 'code': 200, + 'msg': 'success', + 'data': result + }) + except ValueError as e: + return jsonify({'code': 404, 'msg': str(e)}), 404 + except Exception as e: + current_app.logger.error(f'级联扣减领料失败: {str(e)}') + return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500 diff --git a/inventory-backend/app/services/cascade_service.py b/inventory-backend/app/services/cascade_service.py new file mode 100644 index 0000000..1c8e650 --- /dev/null +++ b/inventory-backend/app/services/cascade_service.py @@ -0,0 +1,197 @@ +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 app.models.inbound.semi import StockSemi +from app.models.inbound.product import StockProduct +from sqlalchemy import func +import logging + +logger = logging.getLogger(__name__) + + +def _get_available_qty(material_type: str, base_id: int) -> float: + """ + 根据 material_type 查询对应库存表的 available_quantity。 + 若查不到记录或无可用量,返回 0.0。 + """ + qty = 0.0 + if material_type == 'product': + row = db.session.query( + func.coalesce(func.sum(StockProduct.available_quantity), 0) + ).filter(StockProduct.base_id == base_id).scalar() + qty = float(row) if row else 0.0 + elif material_type == 'semi': + row = db.session.query( + func.coalesce(func.sum(StockSemi.available_quantity), 0) + ).filter(StockSemi.base_id == base_id).scalar() + qty = float(row) if row else 0.0 + elif material_type == 'buy': + row = db.session.query( + func.coalesce(func.sum(StockBuy.available_quantity), 0) + ).filter(StockBuy.base_id == base_id).scalar() + qty = float(row) if row else 0.0 + else: + qty = 0.0 + return qty + + +def _cascade_allocate( + db_session, + material_id: int, + required_qty: float, + result_list: list, +) -> None: + """ + 递归级联扣减领料核心函数。 + + 算法: + 1. 根据 material_id 查出 MaterialBase 获取 material_type + 2. 查对应库存表 available_quantity + 3. 计算 allocated_qty = min(可用库存, required_qty) + 4. shortage_qty = required_qty - allocated_qty + 5. 将结果追加到 result_list + 6. 若 shortage_qty > 0,递归查 BomTable 展开子件, + 对每个子件用 shortage_qty * dosage 继续分配 + + Args: + db_session: SQLAlchemy db session + material_id: 当前物料 ID + required_qty: 当前层级需求数量 + result_list: 收集结果的列表(就地修改) + """ + if required_qty <= 0: + return + + material = db_session.query(MaterialBase).filter_by(id=material_id).first() + if not material: + logger.warning(f"[Cascade] 物料 {material_id} 在 material_base 中不存在,跳过") + return + + mat_type = material.material_type or '' + available = _get_available_qty(mat_type, material_id) + + allocated_qty = min(available, required_qty) + shortage_qty = required_qty - allocated_qty + + result_list.append({ + 'material_id': material_id, + 'material_name': material.name, + 'material_type': mat_type, + 'spec_model': material.spec_model or '', + 'unit': material.unit or '', + 'required_qty': required_qty, + 'available_qty': available, + 'allocated_qty': allocated_qty, + 'shortage_qty': shortage_qty, + }) + + if shortage_qty > 0: + child_rows = db_session.query(BomTable).filter( + BomTable.parent_id == material_id, + BomTable.is_enabled == True, + ).all() + + for row in child_rows: + child_id = row.child_id + dosage = float(row.dosage) if row.dosage else 0.0 + if dosage <= 0: + logger.warning( + f"[Cascade] 子件 child_id={child_id} dosage={dosage} 无效,跳过" + ) + continue + + child_required_qty = shortage_qty * dosage + _cascade_allocate(db_session, child_id, child_required_qty, result_list) + + +class CascadeService: + + @staticmethod + def compute_cascade_result(bom_no: str, order_qty: float, version: str = None) -> dict: + """ + 根据 bom_no 和订单总量执行级联扣减领料,返回完整的分层结果。 + + Args: + bom_no: BOM 编号 + order_qty: 订单需求总量(顶层成品需求数量) + version: BOM 版本(可选,默认取最新) + + Returns: + { + "bom_no": str, + "version": str, + "parent_id": int, + "parent_name": str, + "order_qty": float, + "total_allocated": float, # 累计已分配量(用于评估满足率) + "total_shortage": float, # 最终缺口(顶层 shortage_qty) + "items": [ + { + "level": int, # 递归层级(0 为顶层) + "material_id": int, + "material_name": str, + "material_type": str, + "spec_model": str, + "unit": str, + "required_qty": float, + "available_qty": float, + "allocated_qty": float, + "shortage_qty": float, + }, + ... + ] + } + """ + from app.services.bom_service import BomService + + detail = BomService.get_bom_detail(bom_no, version=version) + if not detail: + raise ValueError(f"BOM {bom_no} 不存在") + + parent_id = detail['parent_id'] + parent_name = detail.get('parent_name', '') + bom_version = detail.get('version', 'V1.0') + + result_list: list = [] + _cascade_allocate(db.session, parent_id, order_qty, result_list) + + # 汇总顶层成品allocated和shortage + parent_item = next( + (it for it in result_list if it['material_id'] == parent_id), None + ) + total_allocated = parent_item['allocated_qty'] if parent_item else 0.0 + total_shortage = parent_item['shortage_qty'] if parent_item else order_qty + + # 按 material_id 合并去重(同一物料被多个父件引用时合并 allocated_qty) + merged_map: dict = {} + for it in result_list: + mid = it['material_id'] + if mid not in merged_map: + merged_map[mid] = { + 'material_id': it['material_id'], + 'material_name': it['material_name'], + 'material_type': it['material_type'], + 'spec_model': it['spec_model'], + 'unit': it['unit'], + 'required_qty': 0.0, + 'available_qty': it['available_qty'], + 'allocated_qty': 0.0, + 'shortage_qty': 0.0, + } + merged_map[mid]['required_qty'] += it['required_qty'] + merged_map[mid]['allocated_qty'] += it['allocated_qty'] + merged_map[mid]['shortage_qty'] += it['shortage_qty'] + + merged_items = list(merged_map.values()) + + return { + 'bom_no': bom_no, + 'version': bom_version, + 'parent_id': parent_id, + 'parent_name': parent_name, + 'order_qty': order_qty, + 'total_allocated': total_allocated, + 'total_shortage': total_shortage, + 'items': merged_items, + } \ No newline at end of file