fix(outbound): 扫码/备选库位回加本单预占,修正可用数重复计数

出库选单提交申请时 reserve_for_items() 会立即扣减 available_quantity(预占,
防超卖),但扫码页拿到的仍是这个已被本单扣过的值,并当作「本单能扫多少」的
上限。对本单而言它自己锁掉的货当然该能扫,于是同一批货被算了两次:

  · 某行被本单占满时 available=0,工人直接扫不进去,提示「库存不足或已出库」
  · /alternatives 按 available_quantity > 0 过滤,被本单占满的行从列表消失
  · 草稿恢复时刷新实时库存,误报「实际库存已少于你扫的数量」

改法:扫码阶段的可用量 = 实时可用量 + 本单在该行的预占量。别人单子的预占
不回加,防超卖能力不丢。该值与后端 restore_then_deduct() 释放预占后用于校验
的数字精确相等,是同一口径而非近似。

后端:
- inventory_reservation.py 新增 reserved_index()/reserved_qty() 纯读工具
- outbound.py 新增 _own_reserved_index(),改造 /scan 与 /alternatives
- outbound_service.py 的 _format_scan_result 返回归一化可用量

两道门禁:
- biz_type 区分出库/借库两张审批单(独立表、ID 空间独立,而两个端点被
  出库页与借库页共用),否则借库单 ID 会命中另一张出库单
- 单据状态仅放行 status ∈ {0,1}。set_items() 只在创建时调用,执行/驳回后
  items_json 里的 reserved=True 仍原样保留而库存早已归还,门禁一松就会
  二次回加 → 真超卖(现有 3 张已完成单据即属此形态)

不满足门禁时静默降级为不回加(fail-closed),并回传 reservation_applied。
This commit is contained in:
yueli
2026-09-11 10:34:00 +08:00
parent b8561f69f7
commit cbcedecba2
3 changed files with 196 additions and 16 deletions

View File

@ -257,6 +257,60 @@ def release_reserved(items):
return restored
# =============================================================================
# 预占查询(只读)
# =============================================================================
def reserved_index(approval_items):
"""
★ 本单的预占索引:{(source_table, stock_id): 预占总量}
用途:扫码执行阶段,工人应该能以「实时可用量 + 本单自己锁掉的量」为上限
—— 本单预占的货当然应该能扫。别人的预占不回加,防超卖能力不丢。
这与 restore_then_deduct() 的口径精确对齐:后者先 release_reserved()
把本单预占还回池子,再用 _sum_available_for_identity() 校验。
即「释放后的可用总量」恒等于「各行实时可用量 + 本单预占量」之和。
⚠ 调用方必须先做**单据状态门禁**(仅放行 status ∈ {0, 1})。
执行(3)/驳回(2)/完结(4)后 items_json 里的 reserved=True 与
allocated_qty 仍原样保留(set_items 只在创建时调用,
release_reserved 只归还库存、不重写 items_json),
此时库存早已归还,再回加就是凭空多出一份可用量 → 真超卖。
同一 (source_table, stock_id) 在 items_json 中出现多行时**累加**。
"""
index = {}
for it in approval_items or []:
if not it.get('reserved'):
continue
try:
qty = float(it.get('allocated_qty') or 0)
except (TypeError, ValueError):
continue
if qty <= 0:
continue
st = norm_text(it.get('source_table'))
try:
sid = int(it.get('stock_id'))
except (TypeError, ValueError):
continue
if not st:
continue
key = (st, sid)
index[key] = index.get(key, 0.0) + qty
return index
def reserved_qty(approval_items, source_table, stock_id):
"""本单在某库存行上的预占量(reserved_index 的便捷封装)"""
try:
sid = int(stock_id)
except (TypeError, ValueError):
return 0.0
return reserved_index(approval_items).get((norm_text(source_table), sid), 0.0)
# =============================================================================
# 执行阶段:身份校验
# =============================================================================