feat(inventory): 库存预占生命周期,消除出库/借库超卖

问题:库存超卖
--------------
改造前出库/借库申请只记录「要什么、要多少」,不绑定具体库存行,
真正的 available_quantity 扣减发生在执行阶段。于是多张申请可以同时
claim 同一批货,等到工人拿扫码枪时才发现货已被别人领走。

生命周期(三阶段)
------------------
  提交申请(预占)  reserve_for_items()
     用分配器把需求落到具体库存行,立即扣减 available_quantity,
     并把 (stock_id, source_table, allocated_qty, reserved) 写回 items_json。

  驳回(释放)      release_reserved()
     遍历 items_json 把预占量还回池子,避免货被永不执行的单永久占住。

  扫码执行(覆盖)  verify_scanned() + restore_then_deduct()
     校验实扫身份/数量未超批准范围 → 释放全部预占 → 对实扫批次
     同时扣减 available_quantity 与 stock_quantity。

身份键:base_id 主键 + SKU 兜底(重要设计决策)
-----------------------------------------------
本系统中 SKU 是**批次级**编号:同一 base_id 下每个入库批次各有不同的
SKU(实测 stock_buy 有 183 个物料是多批次的,如 base_id=2405 下有
0000001685 与 0000001974 两个 SKU)。

若以 SKU 作为身份主键,「申请时锁定 A 批、工人现场改扫 B 批」会被判为
身份不符而拒绝 —— 恰好否定了「物理覆盖」这个核心能力。
故改用 base_id(物料级、跨批次稳定,spec_model 由其唯一确定),
历史数据无 base_id 时降级为 (name, spec_model)。

可用量校验按物料汇总,而非按单批次
----------------------------------
开发中修正的一处缺陷:若逐行要求「该批次可用量 >= 该批次扫码量」,
工人改扫小批次时会被误拒。例如本单预占 A 批 5 件,改扫 B 批 2 件 +
C 批 3 件,B 批自身只有 2 件可用,逐行校验即失败。实际这 5 件都是本单
锁定的货,理应允许。现按物料汇总校验可用量,按行校验实物库存。

改动文件
--------
· 新增 app/services/inventory_reservation.py(通用服务层)
· outbound_service.create_request     —— Phase 1 预占
· outbound_service.approve(reject)    —— Phase 2 释放
· outbound_service.create_outbound_batch —— Phase 3 覆盖(移除原逐行扣减)
· borrow_service.submit_approval      —— Phase 1
· borrow_service.approve(reject)      —— Phase 2
· trans_service.execute_dispatch      —— Phase 3,并用统一身份键替换
                                         原有的 (name, spec_model) 字符串匹配

实测(真实 HTTP 全链路)
------------------------
初始 available=10
  ① 提交申请(需5)   → 200,available 10→5    预占生效
  ② 审批通过        → available 仍为 5        预占保留
  ③ 扫码执行(改扫另一批次 4 件) → 200
     原批次恢复满额、实扫批次扣减(0,0),available=6, stock=6

单场景验证:预占 A 批改扫 B 批放行;驳回后可用量完全恢复;
           扫其他物料被拒;批准 6 扫 8 被拒。
This commit is contained in:
yueli
2026-09-10 14:59:10 +08:00
parent 0a70e5688a
commit b57c21a4cd
4 changed files with 591 additions and 52 deletions

View File

@ -64,23 +64,22 @@ class TransService:
raise ValueError("审批单中未记录借库人姓名,请联系管理员补录")
# ==============================================
# ★ 防线2:构建审批上限字典(按 名称+规格 聚合,strip 防止匹配失败)
# Key = (name, spec_model),Value = 该物料累计允许借出数量
# ★ 防线2:构建审批上限字典
#
# 改造说明:原先以 (name, spec_model) 聚合,但本系统中 SKU 是**批次级**
# 编号(同一物料不同批次 SKU 不同),而 name+spec 又是字符串比较,
# 易受空格/别名影响。现统一改用 identity_key(base_id 主键 +
# name/spec 兜底),与出库、报废三个模块共用同一套身份语义。
# ==============================================
approved_items = approval.get_items()
if not approved_items:
raise ValueError("审批单中无物料明细,请联系管理员检查")
approval_limits = {}
for ai in approved_items:
key = (
(ai.get('name') or '').strip(),
(ai.get('spec_model') or '').strip()
)
approval_limits[key] = approval_limits.get(key, 0) + float(ai.get('quantity', 0))
from app.services.inventory_reservation import (
build_approval_index, verify_scanned, restore_then_deduct, identity_label,
)
# 累计本次扫码出库量(key 与 approval_limits 完全一致)
dispatch_acc = {}
approval_idx = build_approval_index(approved_items)
borrow_no = TransService.generate_borrow_no()
model_map = {'stock_buy': StockBuy, 'stock_semi': StockSemi, 'stock_product': StockProduct}
@ -88,6 +87,26 @@ class TransService:
# ★ 防止死锁:按 (source_table, id) 排序,保证所有并发请求以相同顺序获取行锁
items.sort(key=lambda x: (x.get('source_table', ''), x.get('id', 0)))
# ==============================================================
# ★ Phase 3:预占再平衡
# 借库申请阶段已预占具体批次;工人实扫的可能是同物料的另一批次。
# 1. 校验实扫身份/数量未超批准范围(base_id 主键,允许换批次)
# 2. 释放全部预占
# 3. 对实扫批次扣减 available_quantity 与 stock_quantity
# 下方主循环只写 TransBorrow 流水,不再重复扣库存。
# ==============================================================
_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
]
if _scanned_for_check:
verify_scanned(_scanned_for_check, approved_items)
restore_then_deduct(_scanned_for_check, approved_items)
# 累计本次扫码出库量(用于下方防线4的二次校验)
dispatch_acc = {}
try:
for item in items:
source_table = item.get('source_table')
@ -107,40 +126,20 @@ class TransService:
if not stock: raise ValueError(f"库存不存在 ID:{stock_id}")
# ==============================================
# ★ 防线4:名称+规格 超额校验(动态累加、即时拦截)
# 库存表本身没有 name/spec_model 字段,通过 base 关联到 material_base
# ★ 身份与数量校验已由上方 verify_scanned() 统一完成
# (base_id 主键匹配,允许同物料换批次;累计量不得超批准量)
#
# 此处仅做一次「本次扫码累计」的防御性复核,防止并发下
# 同一请求内重复 stock_id 被重复计数。
# 库存扣减也已在 restore_then_deduct() 完成 ——
# 下方**不再**扣减 available_quantity,否则会扣两次。
# ==============================================
if stock.base:
stock_name = (stock.base.name or '').strip()
stock_spec = (stock.base.spec_model or '').strip()
else:
stock_name = ''
stock_spec = ''
stock_name = (stock.base.name or '').strip() if stock.base else ''
stock_spec = (stock.base.spec_model or '').strip() if stock.base else ''
key = (stock_name, stock_spec)
limit = approval_limits.get(key)
if limit is None:
raise ValueError(
f"扫码物料【{stock_name} / {stock_spec}】不在审批单允许范围内,"
f"请检查审批单明细或重新发起申请"
)
dispatch_acc[key] = dispatch_acc.get(key, 0) + qty
current_total = dispatch_acc[key]
if current_total > limit:
raise ValueError(
f"实际出库数量超出了审批单允许的上限: "
f"物料={stock_name}({stock_spec}) "
f"审批上限={limit}, 实际扫码={current_total}"
)
if float(stock.available_quantity) < qty:
raise ValueError(f"物料【{stock_name} / {stock_spec}】可用库存不足")
# 1. 冻结库存 (只减可用)
stock.available_quantity = float(stock.available_quantity) - qty
# 2. 创建借用记录
# 创建借用记录
record = TransBorrow(
borrow_no=borrow_no,
sku=stock.sku,