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:
@ -151,7 +151,20 @@ class BorrowApprovalService:
|
||||
approved_at=beijing_time(),
|
||||
)
|
||||
|
||||
approval.set_items(items)
|
||||
# ==================================================================
|
||||
# ★ Phase 1:库存预占
|
||||
# 借出同样会占用库存(借出期间该批货不可再被他人领用),
|
||||
# 因此与出库一致:提交即锁定具体批次并扣减 available_quantity。
|
||||
# 借出归还时再归还,见 process_return()。
|
||||
# ==================================================================
|
||||
from app.services.inventory_reservation import reserve_for_items
|
||||
from app.utils.decorators import get_current_company_filter
|
||||
|
||||
reserved_items, _shortages = reserve_for_items(
|
||||
items, company_limit=get_current_company_filter(), strict=True,
|
||||
)
|
||||
|
||||
approval.set_items(reserved_items)
|
||||
if allowed_approvers:
|
||||
approval.set_allowed_approvers(allowed_approvers)
|
||||
else:
|
||||
@ -407,7 +420,11 @@ class BorrowApprovalService:
|
||||
approval.status = 1 # 已通过
|
||||
approval.actual_approver_id = user_id
|
||||
approval.approved_at = current_time
|
||||
# 通过后预占继续保留,直到实际借出执行时才释放并扣减
|
||||
elif action == 'reject':
|
||||
# ★ Phase 2:驳回即释放预占,避免货被永不执行的单占住
|
||||
from app.services.inventory_reservation import release_reserved
|
||||
release_reserved(approval.get_items())
|
||||
approval.status = 2 # 已驳回
|
||||
approval.reject_reason = reject_reason
|
||||
else:
|
||||
|
||||
Reference in New Issue
Block a user