feat(bom): 后端承接 BOM 库存分配,消除多批次物料只能加 1 件的缺陷

问题现象
--------
BOM 选单中物料显示需求 10、聚合可用 839,加入购物车却只剩 1 件,
并提示库存不足。

根因
----
两个接口口径不一致:
  · GET  /bom/stock/<bom_no>      按 base_id 聚合 → current_stock=839
  · POST /outbound/bom-match-stock 不聚合,每批次一行 → 某行只有 1
前端用 stockList.find(s => s.base_id == child_id) 只取第一条库存行,
若首行恰好只剩 1 件,需求量又被 Math.min 压到 1,现象即如此。

为何必须放在后端
----------------
前端 stockList 由多个入口写入(手动选单/搜索/BOM),随时可能被覆盖;
且 base_id 与 stock_id 的类型差异会让匹配静默落空,表现同样是「库存不足」。
更关键的是:分配需要「该 base_id 全部可用库存行」的完整视图,
而这必须与出库扣减(create_outbound_batch 按 stock_id 逐行加锁扣减)
使用同一份数据源。

改动
----
bom-match-stock 新增分配模式:
  请求 { requirements: [{base_id, required_qty, name, spec_model}] }
  响应 { items: [...已分配行], shortages: [...缺料明细] }

_allocate_bom_requirements() 在 DB 层完成:
  1. 三张库存表按 base_id 一次性取全部 available_quantity > 0 的行
     (带公司隔离,join base 取名称规格);
  2. 可用量降序排序 —— 优先进大行,减少购物车拆分行数;
  3. 逐物料扣减 required_qty,产出真实 stock_id + source_table + allocated_qty;
  4. 分配不足记录 shortage 但不阻断其它物料。
  每行仍携带 uniqueKey,前端可直接入购物车。
  返回前剥离价格成本字段(Fail-Closed)。

旧查询模式(child_ids)保留,兼容未改造的调用方。

顺带修复一处静默失败
--------------------
查询块的 except 原为直接 continue,会把 NameError 等错误吞成「该物料无库存」。
改为 logger.error 输出,避免同类问题再次以业务结论的形式出现。

实测(base_id=2405,聚合 841,需 10):分配 1 行 stock_id=1961 分配 10,无短缺;
base_id=2963(9 行各 1),需 5 → 5 行各 1 合计 5;
需 20(聚合仅 9)→ 9 行合计 9,短缺 11。
This commit is contained in:
yueli
2026-09-10 14:16:47 +08:00
parent 6068625562
commit a910a6ea72

View File

@ -209,27 +209,199 @@ def get_outbound_list():
return jsonify({'code': 500, 'msg': str(e)}), 500
def _allocate_bom_requirements(requirements, company_limit,
StockBuy, StockSemi, StockProduct, MaterialBase):
"""
★ BOM 需求分配核心
对每个 base_id
1. 直接查库取该物料的**全部可用库存行**available_quantity > 0
2. 按库位优先、库存量降序排序(大行优先,减少拆分行数);
3. 依次扣减 required_qty为每一行产出 (stock_id, source_table, allocated_qty)
4. 分配不足时记录缺口,供前端提示,但不阻断其它物料的分配。
返回的每一行都携带真实 stock_id 与 source_table可直接入购物车
因为这些数字直接来自 DB前端无需也不应再做任何分配运算。
并发说明:此处只读取快照用于装配购物车,真正扣减在提交出库时由
create_outbound_batch 以 with_for_update 加锁并二次校验可用量。
"""
from flask import jsonify
from sqlalchemy.orm import joinedload # ★ 必须在此导入:本函数模块级作用域不可见
# 归一化需求,容忍字符串数字
reqs = []
for r in requirements:
try:
bid = int(r.get('base_id'))
except (TypeError, ValueError):
continue
try:
need = float(r.get('required_qty') or 0)
except (TypeError, ValueError):
need = 0.0
if bid <= 0 or need <= 0:
continue
reqs.append({'base_id': bid, 'required_qty': need,
'name': r.get('name') or '', 'spec_model': r.get('spec_model') or ''})
if not reqs:
return jsonify({'code': 400, 'msg': 'requirements 中无有效的 base_id/required_qty'}), 400
base_ids = list({r['base_id'] for r in reqs})
# ---- 一次性拉取全部候选库存行(三表)----
# 按 (base_id, source_table) 归集
rows_by_base = {}
for model, source_table, type_label, type_key in (
(StockBuy, 'stock_buy', '采购件', 'material'),
(StockSemi, 'stock_semi', '半成品', 'semi'),
(StockProduct, 'stock_product', '成品', 'product'),
):
try:
q = model.query.filter(
model.base_id.in_(base_ids),
model.available_quantity > 0, # ★ 只取真正可用的行
)
if company_limit is not None:
q = q.filter(model.base.has(MaterialBase.company_name == company_limit))
rows = q.options(joinedload(model.base)).all()
except Exception as e:
# 不静默:某张表查询失败会直接表现为"该物料无库存",极难排查
current_app.logger.error(
f"[bom-allocate] {source_table} 查询失败: {type(e).__name__}: {e}"
)
continue
for s in rows:
bid = int(s.base_id)
rows_by_base.setdefault(bid, []).append(
(float(s.available_quantity or 0), source_table, type_key, type_label, s)
)
# ---- 逐物料分配 ----
allocated_items = []
shortages = []
for req in reqs:
bid = req['base_id']
remaining = req['required_qty']
# 可用量降序:优先进大行,减少购物车拆分行数
candidates = sorted(rows_by_base.get(bid, []), key=lambda x: -x[0])
if not candidates:
shortages.append({
'base_id': bid, 'name': req['name'], 'spec_model': req['spec_model'],
'required_qty': remaining, 'allocated_qty': 0, 'missing': remaining,
})
continue
for avail, source_table, type_key, type_label, s in candidates:
if remaining <= 0:
break
take = min(remaining, avail)
if take <= 0:
continue
d = s.to_dict()
d['stock_id'] = s.id
d['source_table'] = source_table
d['type'] = type_key
d['stock_type'] = type_key
d['typeLabel'] = type_label
d['uniqueKey'] = f"{type_key}_{s.id}"
d['name'] = d.get('material_name') or (s.base.name if s.base else '') or ''
d['standard'] = d.get('spec_model') or (s.base.spec_model if s.base else '') or ''
d['warehouse_location'] = getattr(s, 'warehouse_location', '') or ''
d['available_quantity'] = float(s.available_quantity or 0)
d['allocated_qty'] = take # ★ 本次分配给该行的数量
d['export_quantity'] = take # 兼容购物车字段名
# Fail-Closed: 剥离价格成本字段
for k in ('unit_price', 'post_tax_unit_price', 'pre_tax_unit_price', 'total_price',
'tax_rate', 'currency', 'exchange_rate', 'sale_price',
'raw_material_cost', 'manual_cost', 'unit_total_cost'):
d.pop(k, None)
allocated_items.append(d)
remaining -= take
if remaining > 0:
shortages.append({
'base_id': bid, 'name': req['name'], 'spec_model': req['spec_model'],
'required_qty': req['required_qty'],
'allocated_qty': req['required_qty'] - remaining,
'missing': remaining,
})
return jsonify({
'code': 200,
'msg': 'success',
'data': {
'items': allocated_items,
'shortages': shortages,
'summary': {
'requested': len(reqs),
'allocated_kinds': len({i['base_id'] for i in allocated_items}),
'shortage_kinds': len(shortages),
},
}
}), 200
# ==============================================================================
# BOM 匹配库存接口 (POST /api/v1/outbound/bom-match-stock)
# 替代前端 while(true) 全量加载:服务端按 child_ids 精确查询匹配库存
#
# ★ 两种用法:
# 1) 分配模式(推荐):传 requirements=[{base_id, required_qty, ...}]
# 后端在 DB 层完成「跨批次分配」,返回精确的 (stock_id, source_table, allocated_qty)
# 2) 查询模式(兼容旧调用):传 child_ids=[...],返回该批 base_id 的全部库存行
#
# 为什么分配必须在后端做
# ----------------------
# 分配需要「该 base_id 的全部可用库存行」这一完整视图,且必须与出库扣减
# outbound_service.create_outbound_batch 按 stock_id 逐行 with_for_update 扣减)
# 使用同一套数据。放在前端会引入两类必然故障:
# · 前端 stockList 由多个入口写入(手动选单/搜索/BOM随时可能被覆盖
# · base_id 与 stock_id 的类型/精度差异会导致匹配落空,静默算成"缺料"。
# 后端直接查库分配,从根本上消除上述不确定性。
# ==============================================================================
@outbound_bp.route('/bom-match-stock', methods=['POST'])
@jwt_required()
def bom_match_stock():
"""
根据 BOM 子件 base_id 列表,查询三张库存表中有库存匹配记录
BOM 库存匹配 / 分配
Body: { "child_ids": [1, 2, 3, ...] }
Returns: { "code": 200, "data": { "items": [...] } }
分配模式 Body:
{
"requirements": [
{"base_id": 123, "required_qty": 10, "name": "...", "spec_model": "..."},
...
]
}
Returns:
{
"code": 200,
"data": {
"items": [ # 已分配好的库存行,前端可直接入购物车
{"base_id", "stock_id", "source_table", "allocated_qty",
"available_quantity", "sku", "name", ..., "shortage": 0}
],
"shortages": [{"base_id", "name", "required_qty", "allocated_qty", "missing"}]
}
}
查询模式 Body: { "child_ids": [1, 2, 3] } → 返回全部匹配库存行(旧行为)
"""
try:
data = request.get_json() or {}
requirements = data.get('requirements')
child_ids = data.get('child_ids', [])
if not child_ids:
return jsonify({'code': 400, 'msg': 'child_ids 不能为空'}), 400
# 去重
child_ids = list(set(int(x) for x in child_ids))
if not requirements and not child_ids:
return jsonify({'code': 400, 'msg': 'requirements 或 child_ids 不能为空'}), 400
# ★ 行级公司隔离:普通用户只能匹配本公司的库存(超管/跨域不受限)
from app.utils.decorators import get_current_company_filter
@ -241,6 +413,21 @@ def bom_match_stock():
from app.models.inbound.product import StockProduct
from sqlalchemy.orm import joinedload
# ------------------------------------------------------------------
# ★ 分配模式:后端完成跨批次分配
# ------------------------------------------------------------------
if requirements:
return _allocate_bom_requirements(
requirements, company_limit,
StockBuy, StockSemi, StockProduct, MaterialBase,
)
# ------------------------------------------------------------------
# 查询模式(兼容旧调用):返回全部匹配库存行
# ------------------------------------------------------------------
# 去重
child_ids = list(set(int(x) for x in child_ids))
all_items = []
# 采购件