diff --git a/inventory-backend/app/services/outbound_service.py b/inventory-backend/app/services/outbound_service.py index a65831c..e7000f9 100644 --- a/inventory-backend/app/services/outbound_service.py +++ b/inventory-backend/app/services/outbound_service.py @@ -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: diff --git a/inventory-backend/app/services/trans_service.py b/inventory-backend/app/services/trans_service.py index bdcf5d7..c4f0076 100644 --- a/inventory-backend/app/services/trans_service.py +++ b/inventory-backend/app/services/trans_service.py @@ -101,14 +101,37 @@ class TransService: # · 借库转报废(scrap_borrow)在确认损失时扣 stock,其注释明确假设 # 「可用库存已在借出时冻结」—— 若借出已扣会重复扣减。 # ============================================================== + # ★ 防线 2.5(Fail-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 = {}