fix(outbound): 库存分配按 base_id 合并需求,修复重复行导致的假性库存不足

_allocate_bom_requirements 在整轮 for req 循环中复用同一份可用量快照
(rows_by_base 建好后不再更新),而 for 循环本身不按 base_id 去重。同一
物料以多行进入时,每行都从同一份快照重新分配一遍,把同一个 stock_id
重复分配 N 次,累计分配量可超过该行真实可用量。

超配在分配阶段不会暴露,直到 reserve_for_items 的二次校验
(take > avail)才抛错,报「可用库存不足(需 X,实剩 Y)」,而实际库存
充足。实剩为 0.0 是当最大候选批次的可用量恰好等于单行需求时的收尾形态。

重复行来自正常业务,非脏数据:
  - 购物车里同一物料的多批次就是多行(Selection.vue 提交时只带
    base_id + quantity,stock_id/source_table 被丢弃);
  - BOM 明细里同一子件被多处引用(前端 requirements 按 child_id 不去重);
  - 调拨 / 补发等拆行场景。

修复:在函数入口按 base_id 合并 reqs、required_qty 求和。分配语义不变
—— 分配器本就按 base_id 拉全量批次行、降序分配,输入里的 stock_id 从来
就被忽略。

选在此处收口而非合并调用方的 items:_allocate_bom_requirements 是全系统
库存分配的唯一权威入口(出库与借库都经 reserve_for_items 走到这里),
一处修改同时覆盖两条链路。

实测(base_id=711,四个批次可用量合计 124):
  三行各 30  修复前 ERROR(需30/实剩10) → 修复后 OK,2117 出 70 + 1182 出 20
  两行各 30  修复前静默超配(两行都绑 2117) → 修复后 OK,合并为 2117 出 60
  十行各 30  修复后报真实缺口「需 300,可用 124」(走正常缺料分支)
This commit is contained in:
yueli
2026-09-20 15:09:14 +08:00
parent 9ea79a55f8
commit d7f7548cee

View File

@ -448,7 +448,22 @@ def _allocate_bom_requirements(requirements, company_limit,
from sqlalchemy.orm import joinedload # ★ 必须在此导入:本函数模块级作用域不可见
# 归一化需求,容忍字符串数字
#
# ★ 必须按 base_id 合并。同一物料会以多行进入本函数,来源都是正常业务:
# · 购物车里同一物料的多批次就是多行Selection.vue 提交时只带
# base_id + quantitystock_id 被丢弃);
# · BOM 明细里同一子件被多处引用(前端 requirements 按 child_id 不去重);
# · 调拨 / 补发等拆行场景。
# 而下方分配是「按 base_id 拉全量批次行、降序分配」,且候选快照在整轮
# for req 循环里**不更新** —— 同一个 base_id 出现 N 行,每行都会从同一份
# 快照重新分配一遍,把同一个 stock_id 重复分配 N 次。
# 超配在分配阶段不会暴露,直到 reserve_for_items 的二次校验
# take > avail才炸报「可用库存不足」而实际库存充足。
# 合并后 required_qty 求和,分配语义不变(输入里的 stock_id 本就被忽略)。
# ★ 不采用「合并入参 items」的方案那是症状侧_allocate_bom_requirements
# 才是全系统库存分配的唯一权威入口,在此收口可同时覆盖出库与借库。
reqs = []
merged = {}
for r in requirements:
try:
bid = int(r.get('base_id'))
@ -460,8 +475,13 @@ def _allocate_bom_requirements(requirements, company_limit,
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 ''})
hit = merged.get(bid)
if hit is not None:
hit['required_qty'] += need
else:
merged[bid] = {'base_id': bid, 'required_qty': need,
'name': r.get('name') or '', 'spec_model': r.get('spec_model') or ''}
reqs = list(merged.values())
if not reqs:
return jsonify({'code': 400, 'msg': 'requirements 中无有效的 base_id/required_qty'}), 400