diff --git a/inventory-backend/app/services/inventory_reservation.py b/inventory-backend/app/services/inventory_reservation.py index 754c7a5..b969a97 100644 --- a/inventory-backend/app/services/inventory_reservation.py +++ b/inventory-backend/app/services/inventory_reservation.py @@ -163,6 +163,23 @@ def stock_identity(model_row): return identity_key(base_id, name, spec, getattr(model_row, 'sku', '')) +def stock_name_spec(model_row): + """ + 库存行 → **名称型**身份键 ('name', 名称, 规格)。 + + ★ 与 stock_identity() 的区别:后者优先用 base_id 产出 ('base', id), + 本函数**刻意丢掉 base_id**,强制走名称兜底。用途见 + build_legacy_approval_index() —— 给没有 base_id 的历史批准明细 + 提供一条可比的旁路键。 + """ + base = getattr(model_row, 'base', None) + return identity_key( + None, + base.name if base else '', + base.spec_model if base else '', + ) + + # ============================================================================= # 预占 / 释放 # ============================================================================= @@ -435,6 +452,48 @@ def build_approval_index(approved_items): return index +def build_legacy_approval_index(approved_items): + """ + 无 base_id 的历史明细 → {('name', 名称, 规格): {'qty', 'label'}} + + ★ 为什么需要这个旁路索引 + ------------------------ + 2026-09-10 预占改造(b57c21a)之前建的单,items_json 里**没有 base_id** + (亦无 stock_id / source_table / reserved)。build_approval_index() 对它 + 只能产出名称型身份键 ('name', 名称, 规格);而扫码侧 stock_identity() + 拿的是库存行的 base_id,产出 ('base', id)。两侧键不同构,**永远不相等**, + 于是这类老单只要还没执行完就必然被 verify_scanned() 拒绝,报 + 「扫码物料【…】不在该申请单的批准明细中」—— 明明批的就是这件货。 + + 实测(单 APR-OUT-20260909-1313-0007,建于 09-09、改造上线前一天): + 批准侧 ('name', '派里肯安全箱1600(黑色)', 'PS-9640B001-black') + 扫码侧 ('base', 3012) + identity_key() 的文档本就把 (name, spec_model) 写作「历史数据的兜底」, + 只是校验时只有批准侧降了级、扫码侧没有,两侧因此错开。 + + ★ 严格限定适用范围(避免放宽现代单据的校验) + · 只收录身份键为名称型(即无有效 base_id)的明细;有 base_id 的一律 + 走 build_approval_index() 的主索引,此处跳过。 + · 名称与规格都为空的明细直接丢弃 —— 无法兜底,且绝不能退化成 + 「任意物料都能匹配」。 + 本索引为空时(无老单),verify_scanned() 的降级分支恒不命中, + 行为与改造前完全一致。 + """ + index = {} + for it in approved_items or []: + key = identity_key(it.get('base_id'), it.get('name'), it.get('spec_model')) + if key[0] != 'name': + continue # 有 base_id → 归主索引,不放宽 + if not key[1] and not key[2]: + continue # 名称规格皆空,无从兜底 + entry = index.setdefault(key, {'qty': 0.0, 'label': identity_label(key)}) + try: + entry['qty'] += float(it.get('quantity') or it.get('allocated_qty') or 0) + except (TypeError, ValueError): + pass + return index + + def verify_scanned(scanned_items, approved_items): """ ★ Phase 3 校验:实扫明细的身份与数量必须落在批准范围内。 @@ -460,6 +519,12 @@ def verify_scanned(scanned_items, approved_items): if not approved_idx: raise ValueError('审批单明细为空,无法执行') + # ★ 无 base_id 的历史明细旁路(说明见 build_legacy_approval_index)。 + # 这里只把它当作「哪些名称型身份键确实来自老单据」的白名单 —— + # 数量口径仍以 approved_idx 为准,两者对同一身份给出相同的批准量。 + # 无老单时为空集,下方降级分支恒不命中,行为与改造前完全一致。 + legacy_idx = build_legacy_approval_index(approved_items) + acc = {} normalized = [] @@ -503,9 +568,22 @@ def verify_scanned(scanned_items, approved_items): raise ValueError(f"物料【{label}】状态异常(当前为 {status}),禁止出库") if key not in approved_idx: - raise ValueError( - f"扫码物料【{label}】不在该申请单的批准明细中,禁止出库" - ) + # ★ 降级匹配(老单专用旁路) + # + # 无 base_id 的历史批准明细身份键是名称型 ('name', 名称, 规格), + # 扫码侧是 ('base', id) —— 两者不同构,直接比永远落空, + # 批的就是这件货也会被判「不在批准明细中」。 + # 此处用库存行的名称+规格再比一次;命中则**改用批准侧的键**, + # 使 acc 累计与 approved_idx 的批准量保持同口径。 + # 白名单是 legacy_idx 而非 approved_idx 本身:只有真正来自 + # 老单据的名称型键才放行,杜绝这条旁路把现代单据的校验放宽。 + alt = stock_name_spec(row) + if alt not in legacy_idx: + raise ValueError( + f"扫码物料【{label}】不在该申请单的批准明细中,禁止出库" + ) + key = alt + label = identity_label(key) acc[key] = acc.get(key, 0.0) + qty if acc[key] > approved_idx[key]['qty']: