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:
@ -216,6 +216,29 @@ class OutboundService:
|
||||
# ★ Track 联动收集:(serial_number, source_table, quantity)
|
||||
track_notifications = []
|
||||
|
||||
# ==================================================================
|
||||
# ★ Phase 3:预占再平衡(仅针对关联审批单的出库)
|
||||
#
|
||||
# 关联审批单时,申请阶段已把货预占在「申请时选定的批次」上。
|
||||
# 工人实际扫的可能是同物料的**另一个批次**(物理覆盖),因此这里:
|
||||
# 1. 校验实扫的身份/数量未超出批准范围(base_id 主键,允许换批次)
|
||||
# 2. 释放全部预占
|
||||
# 3. 对实扫批次扣减 available_quantity 与 stock_quantity
|
||||
# 之后主循环只写 TransOutbound 流水,不再重复扣库存。
|
||||
#
|
||||
# 无关联审批单(散单)时跳过,走原有逐行扣减逻辑。
|
||||
# ==================================================================
|
||||
if approval is not None:
|
||||
from app.services.inventory_reservation import (
|
||||
verify_scanned, restore_then_deduct,
|
||||
)
|
||||
_approved = approval.get_items()
|
||||
# 仅处理库存类来源;维修单(trans_repair)不走库存预占
|
||||
_scanned = [i for i in items if i.get('source_table') != 'trans_repair']
|
||||
if _scanned:
|
||||
verify_scanned(_scanned, _approved)
|
||||
restore_then_deduct(_scanned, _approved)
|
||||
|
||||
try:
|
||||
for item in items:
|
||||
source_table = item.get('source_table')
|
||||
@ -257,16 +280,20 @@ class OutboundService:
|
||||
if not ModelClass:
|
||||
continue
|
||||
|
||||
stock_record = ModelClass.query.with_for_update().get(stock_id)
|
||||
# ==========================================================
|
||||
# ★ Phase 3:库存扣减已由「预占 + 再平衡」统一处理
|
||||
#
|
||||
# 流程(在下方 _apply_reservation_override 中完成):
|
||||
# 1. 校验实扫身份/数量落在批准范围内(base_id 主键匹配,允许换批次)
|
||||
# 2. 释放申请时锁定的全部批次(available_quantity 还回池子)
|
||||
# 3. 对实扫批次扣减 available_quantity 与 stock_quantity
|
||||
#
|
||||
# 因此此处**不再**直接扣减库存,避免与再平衡逻辑重复扣两次。
|
||||
# ==========================================================
|
||||
stock_record = ModelClass.query.get(stock_id)
|
||||
if not stock_record:
|
||||
raise ValueError(f"库存记录不存在 (ID: {stock_id})")
|
||||
|
||||
if float(stock_record.available_quantity) < quantity:
|
||||
raise ValueError(f"SKU {stock_record.sku} 库存不足,当前可用: {stock_record.available_quantity}")
|
||||
|
||||
stock_record.stock_quantity = float(stock_record.stock_quantity) - quantity
|
||||
stock_record.available_quantity = float(stock_record.available_quantity) - quantity
|
||||
|
||||
# 收集 Track 联动信息(库存表 serial_number = Track 身份证)
|
||||
track_notifications.append((getattr(stock_record, 'serial_number', None), source_table, quantity))
|
||||
|
||||
@ -592,7 +619,10 @@ class OutboundService:
|
||||
# ★ 数据权限:普通用户只看“领用人=本人姓名(不含账号前缀)”的出库记录;
|
||||
# 同时兼容库里存成“姓名/xiaolongxia”全名的记录(姓名 + '/' 前缀也命中)
|
||||
if consumer_name:
|
||||
from sqlalchemy import or_
|
||||
# 注意:or_ 已在模块顶部导入,此处绝不可再写 `from sqlalchemy import or_`
|
||||
# —— 函数内出现对 or_ 的赋值(import 即赋值)会让 Python 把 or_ 视为
|
||||
# 整个函数的局部变量,导致本函数中**位于该行之前**的所有 or_ 调用
|
||||
# (keyword 搜索分支)抛 UnboundLocalError: referenced before assignment。
|
||||
_own_out_nos = (
|
||||
db.session.query(TransOutbound.outbound_no)
|
||||
.filter(or_(
|
||||
@ -874,8 +904,25 @@ class OutboundApprovalService:
|
||||
approved_at=beijing_time(),
|
||||
)
|
||||
|
||||
# 直接存储前端传来的物料信息快照,不查询/不关联具体库存记录
|
||||
approval.set_items(items)
|
||||
# ==================================================================
|
||||
# ★ Phase 1:库存预占
|
||||
#
|
||||
# 改造前此处只存「要什么、要多少」的快照,不绑库存行,真正的
|
||||
# available_quantity 扣减留到执行阶段 —— 于是多张申请可以同时
|
||||
# claim 同一批货,等到扫码时才发现已被领走(超卖)。
|
||||
#
|
||||
# 现在提交即预占:分配器把需求落到具体库存行、立即扣减
|
||||
# available_quantity,并把 (stock_id, source_table, allocated_qty)
|
||||
# 连同 reserved 标记写进 items_json,驳回/撤回时据此原样归还。
|
||||
# ==================================================================
|
||||
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:
|
||||
@ -1071,7 +1118,12 @@ class OutboundApprovalService:
|
||||
approval.status = 1 # 已通过
|
||||
approval.actual_approver_id = user_id
|
||||
approval.approved_at = current_time
|
||||
# 通过后预占继续保留:货已被本单锁定,直到扫码执行时才释放并扣减
|
||||
elif action == 'reject':
|
||||
# ★ Phase 2:驳回即释放预占,把 available_quantity 还回池子,
|
||||
# 否则这批货会被一张永远不会执行的单永久占住。
|
||||
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