@ -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 :
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
acc [ key ] = acc . get ( key , 0.0 ) + qty
if acc [ key ] > approved_idx [ key ] [ ' qty ' ] :
if acc [ key ] > approved_idx [ key ] [ ' qty ' ] :