2 Commits

Author SHA1 Message Date
dxc
eefd3d303c V3.39版本推送 2026-05-29 16:25:49 +08:00
DXC
ab42777e41 feat(bom): 新增动态级联扣减领料接口 cascade-inventory 2026-05-29 16:24:35 +08:00
3 changed files with 246 additions and 1 deletions

View File

@ -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

View File

@ -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,
}

View File

@ -239,7 +239,7 @@ const handleLogout = () => {
<footer v-if="!isLoginPage" class="app-footer">
<span class="version-tag">
<el-icon style="vertical-align: middle; margin-right: 4px"><InfoFilled /></el-icon>
当前版本:V3.38
当前版本:V3.39
</span>
</footer>