fix(outbound): 执行校验兼容无 base_id 的历史单据,修复老单必然被拒
2026-09-10 预占改造(b57c21a)之前建的单,items_json 里没有 base_id。
这类单只要在改造上线时仍处于「已通过待执行」状态,就再也执行不了:
批准侧 build_approval_index() → identity_key(base_id, name, spec)
无 base_id → 退化成 ('name', 名称, 规格)
扫码侧 stock_identity() → ('base', id)
两侧键不同构,**永远不相等**,verify_scanned() 必然抛
「扫码物料【…】不在该申请单的批准明细中,禁止出库」——批的就是这件货。
identity_key() 的文档本就把 (name, spec_model) 写作「历史数据的兜底」,
只是校验时只有批准侧降了级、扫码侧没有,两侧因此错开。
实测(APR-OUT-20260909-1313-0007,建于 09-09,改造上线前一天):
批准侧 ('name', '派里肯安全箱1600(黑色)', 'PS-9640B001-black')
扫码侧 ('base', 3012)
修复:
· 新增 stock_name_spec() —— 库存行 → 名称型身份键,刻意丢掉 base_id
· 新增 build_legacy_approval_index() —— 无 base_id 的历史明细旁路索引
· verify_scanned() —— 主索引落空时用库存行名称+规格再比一次;命中则
改用**批准侧的键**继续,使 acc 累计与 approved_idx 的批准量同口径
安全边界(关键):降级只对真正来自老单据的名称型键放行,白名单是
legacy_idx 而非 approved_idx 本身——build_legacy_approval_index() 只收
identity_key() 产出名称型键的明细,有 base_id 的一律跳过;名称与规格皆空
的明细直接丢弃,绝不退化成「任意物料都能匹配」。无老单时该索引为空集,
降级分支恒不命中,行为与改造前逐字节一致。
影响范围:当时处于 status=1 的老单全库仅 1 张(即上述单号)。其余 358 张
老单(已完成 327 / 已驳回 9 / 已完结 22)均为终态,不受影响——它们在改造
上线前就已执行完毕。
实测验证:
T1 老单 360 + 扫批准范围内物料 修复前必拒 → 修复后通过
T2 老单 360 + 扫单外物料 仍拒绝
T3 老单 360 + 数量超批准 仍拒绝
T4 现代单 + 扫批准范围内物料 通过(旁路索引 0 键,未受影响)
T5 现代单 + 扫单外物料 仍拒绝
副作用改善:降级后标签改用批准侧的键,超量报错从「物料#3012」变为
「派里肯安全箱1600(黑色)(PS-9640B001-black)」,可读性提升。
This commit is contained in:
@ -163,6 +163,23 @@ def stock_identity(model_row):
|
|||||||
return identity_key(base_id, name, spec, getattr(model_row, 'sku', ''))
|
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
|
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):
|
def verify_scanned(scanned_items, approved_items):
|
||||||
"""
|
"""
|
||||||
★ Phase 3 校验:实扫明细的身份与数量必须落在批准范围内。
|
★ Phase 3 校验:实扫明细的身份与数量必须落在批准范围内。
|
||||||
@ -460,6 +519,12 @@ def verify_scanned(scanned_items, approved_items):
|
|||||||
if not approved_idx:
|
if not approved_idx:
|
||||||
raise ValueError('审批单明细为空,无法执行')
|
raise ValueError('审批单明细为空,无法执行')
|
||||||
|
|
||||||
|
# ★ 无 base_id 的历史明细旁路(说明见 build_legacy_approval_index)。
|
||||||
|
# 这里只把它当作「哪些名称型身份键确实来自老单据」的白名单 ——
|
||||||
|
# 数量口径仍以 approved_idx 为准,两者对同一身份给出相同的批准量。
|
||||||
|
# 无老单时为空集,下方降级分支恒不命中,行为与改造前完全一致。
|
||||||
|
legacy_idx = build_legacy_approval_index(approved_items)
|
||||||
|
|
||||||
acc = {}
|
acc = {}
|
||||||
normalized = []
|
normalized = []
|
||||||
|
|
||||||
@ -503,9 +568,22 @@ def verify_scanned(scanned_items, approved_items):
|
|||||||
raise ValueError(f"物料【{label}】状态异常(当前为 {status}),禁止出库")
|
raise ValueError(f"物料【{label}】状态异常(当前为 {status}),禁止出库")
|
||||||
|
|
||||||
if key not in approved_idx:
|
if key not in approved_idx:
|
||||||
|
# ★ 降级匹配(老单专用旁路)
|
||||||
|
#
|
||||||
|
# 无 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(
|
raise ValueError(
|
||||||
f"扫码物料【{label}】不在该申请单的批准明细中,禁止出库"
|
f"扫码物料【{label}】不在该申请单的批准明细中,禁止出库"
|
||||||
)
|
)
|
||||||
|
key = alt
|
||||||
|
label = identity_label(key)
|
||||||
|
|
||||||
acc[key] = acc.get(key, 0.0) + qty
|
acc[key] = acc.get(key, 0.0) + qty
|
||||||
if acc[key] > approved_idx[key]['qty']:
|
if acc[key] > approved_idx[key]['qty']:
|
||||||
|
|||||||
Reference in New Issue
Block a user