fix(reservation): 实扫来源不可识别时整单失败,避免预占永久锁死

出库与借库两条执行路径原先写作:

    _scanned = [i for i in items if i.get('source_table') != 'trans_repair']
    if _scanned:
        verify_scanned(...); restore_then_deduct(...)

`if _scanned:` 为假时校验与释放被整体跳过,而下方仍无条件把单据置为
status=3(已完成)。此时申请阶段 reserve_for_items() 扣掉的 available_quantity
无人归还,且 status=3 之后 /close(要求 status==1)与 /withdraw(要求
status∈(0,1))都拒绝再释放 —— 预占就此永久锁死,成为谁也领不走的幽灵库存,
且没有流水可供追溯。

改为 Fail-Closed:实扫明细的来源必须全部落在 model_map 内,否则整单失败。
事务回滚后单据保持 status=1、预占原样保留,库管可重试执行,或走驳回/撤回 ——
任一路径都能正常归还预占。

- outbound_service.create_outbound_batch:以 model_map 白名单过滤,
  并要求 _scanned 非空
- trans_service.execute_dispatch:先显式校验来源合法性并列出非法值,
  再要求 _scanned 非空
This commit is contained in:
yueli
2026-09-11 10:34:55 +08:00
parent b313fefdfa
commit 06cb5f1cac
2 changed files with 43 additions and 9 deletions

View File

@ -258,11 +258,22 @@ class OutboundService:
verify_scanned, restore_then_deduct,
)
_approved = approval.get_items()
# 仅处理库存类来源维修单trans_repair不走库存预占
_scanned = [i for i in items if i.get('source_table') != 'trans_repair']
if _scanned:
verify_scanned(_scanned, _approved)
restore_then_deduct(_scanned, _approved)
# ★ Fail-Closed只认库存类来源。只要一条库存物料都没扫到就整单失败。
#
# 旧代码写作 `_scanned = [... if source_table != 'trans_repair']`
# 再 `if _scanned:` —— 一旦为假,校验与释放被整体跳过,而下方仍
# 无条件执行 approval.status = 3。此时申请阶段 reserve_for_items()
# 扣掉的 available_quantity 无人归还,且 status=3 没有任何释放入口
# /close 要求 status==1、/withdraw 要求 status∈(0,1)),预占即
# 永久锁死 —— 幽灵库存。
# 现在直接要求 _scanned 非空:维修单等非库存来源一并被挡在门外。
_scanned = [i for i in items if i.get('source_table') in model_map]
if not _scanned:
raise ValueError("未扫描到有效的库存物料,出库失败,请检查扫码内容")
verify_scanned(_scanned, _approved)
restore_then_deduct(_scanned, _approved)
try:
for item in items:

View File

@ -101,14 +101,37 @@ class TransService:
# · 借库转报废scrap_borrow在确认损失时扣 stock其注释明确假设
# 「可用库存已在借出时冻结」—— 若借出已扣会重复扣减。
# ==============================================================
# ★ 防线 2.5Fail-Closed实扫明细的 source_table 必须全部可识别。
#
# 原先此处写作 `if _scanned_for_check:` —— 不在 model_map 的明细会被
# 静默过滤。若整批明细都不可识别,校验与释放被整体跳过,下方主循环
# 也逐条 `continue`,末尾却照常把 approval.status 置为 3于是
# · 申请阶段预占的 available_quantity 无人释放 → 幽灵库存永久锁死;
# · 单据已非 status=1/close 与 /withdraw 都拒绝再释放;
# · 且没有任何 TransBorrow 流水可供追溯。
# 现在改为整单失败:事务回滚,单据保持 status=1、预占原样保留
# 库管可重试执行,或走驳回/撤回 —— 任一路径都能正常归还预占。
# ==============================================================
unknown_source = sorted({
str(i.get('source_table')) for i in items
if not isinstance(i.get('source_table'), str)
or i.get('source_table') not in model_map
})
if unknown_source:
raise ValueError(
f"实扫明细的库存来源不合法:{''.join(unknown_source)}"
f"仅支持 {''.join(sorted(model_map))}"
)
# 经上方校验后 source_table 必然合法,故本列表与 items 一一对应,
# 不存在「被过滤掉的明细」。空列表由 verify_scanned 兜底报错。
_scanned_for_check = [
{'source_table': i.get('source_table'), 'stock_id': i.get('id'),
'quantity': i.get('out_quantity')}
for i in items if i.get('source_table') in model_map
for i in items
]
if _scanned_for_check:
verify_scanned(_scanned_for_check, approved_items)
restore_then_deduct(_scanned_for_check, approved_items, deduct_stock=False)
verify_scanned(_scanned_for_check, approved_items)
restore_then_deduct(_scanned_for_check, approved_items, deduct_stock=False)
# 累计本次扫码出库量用于下方防线4的二次校验
dispatch_acc = {}