Files
KCGL/inventory-backend/app/services/inventory_reservation.py

636 lines
26 KiB
Python
Raw Normal View History

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 被拒。
2026-09-10 14:59:10 +08:00
"""
库存预占 / 释放 / 执行覆盖 —— 出库与借库共用的通用服务层。
背景:库存超卖
--------------
改造前,出库/借库申请只记录「要什么、要多少」,不绑定具体库存行,
真正的 available_quantity 扣减发生在执行阶段。于是多张申请可以同时
claim 同一批库存,等到工人拿扫码枪时才发现货已被别人领走。
生命周期
--------
提交申请(预占) reserve()
└ 用分配器把需求落到具体库存行,立即扣减 available_quantity,
并把 (stock_id, source_table, allocated_qty) 写回 items_json。
available_quantity 代表「可被新申请占用的量」,预占即从池子里拿走。
驳回 / 撤回(释放) release()
└ 遍历 items_json 把预占的量还回去(available_quantity += allocated_qty)。
扫码执行(覆盖) verify_scanned() + restore_then_deduct()
└ 校验实扫物料的身份与数量不得超出批准范围;
先释放全部预占(把软锁还回池子),再对**实际扫码**的库存行
同时扣减 available_quantity 与 stock_quantity。
这样既允许工人扫到与申请时不同的批次("物理覆盖"),
又保证账面上「该物料净减少 = 实扫数量」。
身份键(关键设计)
------------------
本系统中 SKU 是**批次级**编号:同一物料(base_id)的每个入库批次各有
一个不同的 SKU(实测 stock_buy 有 183 个物料是多批次的)。因此:
✗ 不能用 SKU 标识物料 —— 换了批次就会被误判为「身份不符」
✓ 主键用 base_id(物料级,跨批次稳定,spec_model 由其唯一确定)
✓ 兜底用 (name, spec_model) —— 历史数据的 items_json 可能没有 base_id
这既满足「严格校验规格型号一致」,又允许合法的批次替换。
"""
import logging
from sqlalchemy import and_
from app.extensions import db
logger = logging.getLogger(__name__)
# =============================================================================
# 身份键
# =============================================================================
def norm_text(v):
return str(v or '').strip()
def identity_key(base_id=None, name=None, spec_model=None, sku=None):
"""
物料身份键(跨批次稳定)。
优先级:
1. base_id —— 物料级主键,最可靠
2. (name, spec_model) —— 历史数据无 base_id 时的兜底
注意:**刻意不使用 SKU 作为主键**。SKU 是批次级编号,
用它匹配会导致「同一物料换批次」被误判为身份不符。
SKU 仅作为展示与最终写流的字段。
"""
if base_id:
try:
return ('base', int(base_id))
except (TypeError, ValueError):
pass
return ('name', norm_text(name), norm_text(spec_model))
def identity_label(key):
"""身份键 → 可读文案(用于错误提示)"""
if not key:
return '未知物料'
if key[0] == 'base':
return f"物料#{key[1]}"
return f"{key[1]}({key[2] or '-'})"
# =============================================================================
# 库存状态门槛(硬隔离)
# =============================================================================
# ★ 背景:三张库存表(stock_buy / stock_semi / stock_product)建表起就带
# status 列,但历史实现**只写不读** —— status 仅在入库时写一次 '在库',
# 此后全仓再无任何代码更新它;分配器更是只看 available_quantity。
# 后果:被标成「冻结 / 不良品」的库存行,只要可用量不为 0 就会被正常
# 分配出货,坏件因此可以反复流出。
#
# 此处把 status 变成**真正的准入门槛**:只有白名单内的状态才参与分配。
# 配套两件事,缺一不可:
# · 历史数据洗刷 —— db_migrations/fill_missing_stock_status.sql
# (存量行的 status 若为空/异常,上线本门槛后会集体无法出库)
# · 状态变更入口 —— POST /api/v1/inbound/stock/<id>/change-status
# (否则状态只能靠手工改库,逆向物流无入口)
STOCK_STATUS_IN_STOCK = '在库' # ★ 唯一允许被分配出货的状态
STOCK_STATUS_FROZEN = '冻结' # 盘查/争议期间临时锁定,货还在但要先查清
STOCK_STATUS_DEFECTIVE = '不良品' # 坏件:待维修或待报废,绝不允许再发出
# 允许写入库存行的状态全集(变更接口据此校验,防止脏值从接口进入)
VALID_STOCK_STATUSES = (
STOCK_STATUS_IN_STOCK,
STOCK_STATUS_FROZEN,
STOCK_STATUS_DEFECTIVE,
)
# 「可被分配」的状态白名单。
# ★ 用元组而非单值比较:将来若要放行更多状态(如「待检」),只改这里一处。
ALLOCATABLE_STATUSES = (STOCK_STATUS_IN_STOCK,)
def allocatable_filter(model):
"""
库存行的「可被分配出货」SQL 条件,供分配器拼进 WHERE。
★ Fail-Closed:status 为 NULL 的行**不会**被选中(NULL IN (...) 求值为
NULL,非真)。这正是我们要的语义 —— 状态不明的货宁可不出,但代价是
上线前必须先把存量数据洗刷干净,否则全库无法出库。
洗刷脚本见 db_migrations/fill_missing_stock_status.sql。
"""
return model.status.in_(ALLOCATABLE_STATUSES)
def is_allocatable(row):
"""
单行版判断:该库存记录当前是否可被分配。
供扫码(get_stock_by_barcode)与执行阶段(restore_then_deduct)等
不走 SQL 过滤的路径复用,保证全链路用的是**同一套**状态语义。
"""
return norm_text(getattr(row, 'status', None)) in ALLOCATABLE_STATUSES
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 被拒。
2026-09-10 14:59:10 +08:00
# =============================================================================
# 库存行动态解析
# =============================================================================
def stock_model_map():
"""延迟导入三张库存表,避免循环依赖"""
from app.models.inbound.buy import StockBuy
from app.models.inbound.semi import StockSemi
from app.models.inbound.product import StockProduct
return {
'stock_buy': StockBuy,
'stock_semi': StockSemi,
'stock_product': StockProduct,
}
def stock_identity(model_row):
"""
库存行 → 身份键。
库存表本身没有 name/spec_model,需经 base 关联 material_base 取。
"""
base = getattr(model_row, 'base', None)
base_id = getattr(model_row, 'base_id', None)
name = base.name if base else ''
spec = base.spec_model if base else ''
return identity_key(base_id, name, spec, getattr(model_row, 'sku', ''))
# =============================================================================
# 预占 / 释放
# =============================================================================
def reserve_for_items(items, company_limit=None, strict=True):
"""
★ Phase 1:为一组申请明细做库存预占。
入参 items: [{'base_id', 'name', 'spec_model', 'quantity', ...}]
返回 (reserved_items, shortages)
reserved_items: 原明细的副本,额外带 source_table / stock_id / allocated_qty
shortages : 未能足额分配的明细
扣减语义:available_quantity -= allocated_qty(不动 stock_quantity,
因为货还没走,只是被锁定)。
strict=True 时,任一明细分配不足即抛 ValueError(整单失败)。
strict=False 时按可用量部分预占,把缺口放进 shortages 返回。
本函数**不做 commit**,由调用方在事务边界统一提交。
"""
from app.api.v1.outbound import _allocate_bom_requirements
from app.models.inbound.buy import StockBuy
from app.models.inbound.semi import StockSemi
from app.models.inbound.product import StockProduct
from app.models.base import MaterialBase
if not items:
raise ValueError('申请明细不能为空')
# 复用已建成的分配器:它按 base_id 拉全量可用行、降序分配、产出
# (stock_id, source_table, allocated_qty)。改造前它只服务 BOM,
# 现在成为预占的公共基础设施。
requirements = []
for it in items:
bid = it.get('base_id')
qty = it.get('quantity')
try:
qty = float(qty or 0)
except (TypeError, ValueError):
qty = 0
if not bid or qty <= 0:
continue
requirements.append({
'base_id': bid,
'required_qty': qty,
'name': it.get('name') or '',
'spec_model': it.get('spec_model') or '',
})
if not requirements:
raise ValueError('申请明细缺少有效的 base_id / quantity,无法预占库存')
resp = _allocate_bom_requirements(
requirements, company_limit, StockBuy, StockSemi, StockProduct, MaterialBase,
)
payload = resp[0].get_json() if isinstance(resp, tuple) else resp.get_json()
alloc_items = (payload or {}).get('data', {}).get('items', []) or []
shortages = (payload or {}).get('data', {}).get('shortages', []) or []
if strict and shortages:
detail = ';'.join(
f"{s.get('name') or ('物料#' + str(s.get('base_id')))}"
f"(需 {s.get('required_qty')},可用 {s.get('allocated_qty')})"
for s in shortages
)
raise ValueError(f"以下物料可用库存不足,无法提交申请:{detail}")
# ---- 立即扣减 available_quantity(软锁入池)----
models = stock_model_map()
reserved = []
for a in alloc_items:
take = float(a.get('allocated_qty') or 0)
if take <= 0:
continue
st = a.get('source_table')
sid = a.get('stock_id')
model = models.get(st)
if not model or sid is None:
continue
row = model.query.with_for_update().get(sid)
if not row:
raise ValueError(f"库存记录已不存在({a.get('name') or sid})")
avail = float(row.available_quantity or 0)
if take > avail:
# 并发下别处刚占走,宁可整单失败也不超卖
raise ValueError(
f"物料【{a.get('name') or ''}】可用库存不足"
f"(需 {take},实剩 {avail}),请刷新后重试"
)
row.available_quantity = avail - take
reserved.append({
'base_id': a.get('base_id'),
'name': a.get('name') or '',
'spec_model': a.get('standard') or a.get('spec_model') or '',
'sku': a.get('sku') or '',
'source_table': st,
'stock_id': sid,
'warehouse_location': a.get('warehouse_location') or '',
'allocated_qty': take,
'quantity': take, # 兼容既有字段名(申请量=预占量)
'reserved': True, # ★ 标记该行已预占,释放时据此还原
})
return reserved, shortages
def release_reserved(items):
"""
★ Phase 2:释放 items_json 中已预占的库存(驳回 / 撤回 / 执行前解锁)。
只处理带 reserved=True 的行;老数据(无该标记)跳过,实现平滑兼容。
返回实际归还的行数。不做 commit。
"""
if not items:
return 0
models = stock_model_map()
restored = 0
for it in items:
if not it.get('reserved'):
continue
qty = it.get('allocated_qty')
try:
qty = float(qty or 0)
except (TypeError, ValueError):
continue
if qty <= 0:
continue
model = models.get(it.get('source_table'))
sid = it.get('stock_id')
if not model or sid is None:
continue
row = model.query.with_for_update().get(sid)
if not row:
# 库存行已被删除:无法归还,记日志但不阻断(否则单据永远无法驳回)
logger.warning(
f"[reservation] 释放失败,库存行不存在 {it.get('source_table')}#{sid}"
)
continue
row.available_quantity = float(row.available_quantity or 0) + qty
restored += 1
return restored
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。
2026-09-11 10:34:00 +08:00
# =============================================================================
# 预占查询(只读)
# =============================================================================
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)
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 被拒。
2026-09-10 14:59:10 +08:00
# =============================================================================
# 执行阶段:身份校验
# =============================================================================
def build_approval_index(approved_items):
"""
批准明细 → {身份键: {'qty': 批准总量, 'label': 展示名}}
同一身份在 items_json 中出现多行(多批次)时数量累加 ——
审批维度是「物料总量」,不是「单批次数」。
"""
index = {}
for it in approved_items or []:
key = identity_key(it.get('base_id'), it.get('name'), it.get('spec_model'))
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):
"""
★ Phase 3 校验:实扫明细的身份与数量必须落在批准范围内。
scanned_items: [{'source_table','stock_id','quantity'}]
身份由 stock_id 回查库存行后推导(前端不必传 SKU/base_id)
approved_items: items_json
返回归一化后的实扫明细 [{'source_table','stock_id','quantity','key','label'}, ...];
不满足即抛 ValueError(由调用方回滚整单)。
校验两件事:
1. 身份必须匹配 —— 扫的物料必须是批准单里有的
(用 base_id 主键,允许同物料换批次)
2. 同一身份的累计实扫量不得超过批准量
注意:本函数**只读校验**,不加锁、不改库存。真正的扣减在
restore_then_deduct 中独立加锁完成 —— 避免"校验时锁住的行对象"
与"释放后已变化的状态"之间出现不一致。
"""
models = stock_model_map()
approved_idx = build_approval_index(approved_items)
if not approved_idx:
raise ValueError('审批单明细为空,无法执行')
acc = {}
normalized = []
for idx, s in enumerate(scanned_items or []):
st = norm_text(s.get('source_table'))
sid = s.get('stock_id')
try:
qty = float(s.get('quantity') or 0)
except (TypeError, ValueError):
raise ValueError(f"第 {idx + 1} 条扫码数量无效")
if qty <= 0:
raise ValueError(f"第 {idx + 1} 条扫码数量必须大于 0")
model = models.get(st)
if not model:
raise ValueError(f"第 {idx + 1} 条扫码来源不支持:{st or '(空)'}")
try:
row = model.query.get(int(sid))
except (TypeError, ValueError):
raise ValueError(f"第 {idx + 1} 条扫码的库存行无效")
if not row:
raise ValueError(f"第 {idx + 1} 条扫码的库存记录已不存在")
key = stock_identity(row)
label = identity_label(key)
# ★ 状态准入(最终防线):状态异常的实物整单拒绝。
#
# 与分配器 allocatable_filter()、扫码入口 _assert_scan_allocatable()
# 共用同一个 is_allocatable() 判定,三处语义不会分叉。
#
# 这道覆盖的是「申请已通过 → 工人正在扫」这段窗口内被冻结/标不良的
# 情形 —— 分配器管不到(那是申请时刻),扫码入口也可能被绕过
# (前端可被绕过、草稿可陈旧)。这里是写库前的最后一道。
#
# 抛错即整单回滚(调用方负责),预占由 restore_then_deduct 的调用方
# 统一处理 —— 实际语义见 create_outbound_batch 里的 rollback 兜底:
# 单据保持 status=1,可重试或走撤回/驳回,不会留下半扣留状态。
if not is_allocatable(row):
status = (getattr(row, 'status', None) or '').strip() or '未设置'
raise ValueError(f"物料【{label}】状态异常(当前为 {status}),禁止出库")
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 被拒。
2026-09-10 14:59:10 +08:00
if key not in approved_idx:
raise ValueError(
f"扫码物料【{label}】不在该申请单的批准明细中,禁止出库"
)
acc[key] = acc.get(key, 0.0) + qty
if acc[key] > approved_idx[key]['qty']:
raise ValueError(
f"物料【{label}】扫码数量({acc[key]})超出批准数量"
f"({approved_idx[key]['qty']}),禁止出库"
)
normalized.append({
'source_table': st,
'stock_id': int(sid),
'quantity': qty,
'key': key,
'label': label,
})
if not normalized:
raise ValueError('请先扫码并提交实际出库物料')
return normalized
fix(borrow): 借出只冻结可用数,不再扣减实物库存 问题 ---- 上一轮库存预占改造(b57c21a)把借库执行改用了 restore_then_deduct(), 而该函数是按**出库语义**设计的(物品永久离开仓库),会同时扣减 available_quantity 与 stock_quantity。借库是可逆的,于是产生两个缺陷: 1) 借出再归还后,实物库存永久少一份 初始 (stock=20, avail=20) 借出5 (stock=15, avail=15) 归还5 (stock=15, avail=20) ← 实物没回来,丢了 5 2) 借出后转报废会重复扣减 scrap_borrow 的注释明确写着「扣减总库存(该物品确认损失); 可用库存已在借出时冻结,无需重复扣」—— 它假设借出**没动实物**。 借出已扣 stock 后,转报废再扣一次: 借出5 stock=15 → 转报废 stock=10(正确应为 15) 修复 ---- restore_then_deduct 增加 deduct_stock 参数: 出库 deduct_stock=True (默认)—— 可用数、实物数同时扣 借库 deduct_stock=False —— 只冻结可用数,实物数不动 ★ 这是**恢复原有设计**,不是新设计。git 历史显示从最初的 04ee938 借库逻辑实现 起,历经 7ef22a3 / 83b3db6 / b79b0f9 / 2556b77 / 1527d55 六次提交,一直是: stock.available_quantity = float(stock.available_quantity) - qty (只减可用,不动实物)。b57c21a 把它替换掉了。 盘点的差异计算也印证了该口径的正确性: adjusted_stock_qty = stock_quantity - 借出未还 diff_qty = 实盘数 - adjusted_stock_qty 这段逻辑要求 stock_quantity **包含**借出未还的实物,否则会重复扣减。 最终语义 -------- stock_quantity = 账面实物总数(含借出未还) available_quantity = 实际可取用数 出库申请(预占) — available ↓ 出库执行 stock ↓ available ↓ 借库申请(预占) — available ↓ 借库执行 — available ↓ 借库归还 — available ↑ 借库转报废 stock ↓ — 实测(stock, available) ------------------------ 借还循环:初始(20,20) → 借出5(20,15) → 归还5(20,20) 完全可逆 出库: 预占4(20,16) → 执行(16,16) 实物确实减 转报废: 借出3(20,17) → 转报废(17,17) 实物减一次,不重复
2026-09-11 09:39:20 +08:00
def restore_then_deduct(scanned_items, approved_items, deduct_stock=True):
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 被拒。
2026-09-10 14:59:10 +08:00
"""
★ Phase 3 再平衡:先释放全部预占,再对实扫行扣减。
为什么必须「先全释放、再全扣减」而不是做净额调整:
预占锁定的是「申请时的批次」,实扫可能是「另一个批次」。
若直接按批次做加减,当两者恰好是同一行时加减会互相抵消,
逻辑要分情况讨论、极易出错。统一走
「全量释放 → 全量扣减」,路径单一,且与批次是否相同无关。
fix(borrow): 借出只冻结可用数,不再扣减实物库存 问题 ---- 上一轮库存预占改造(b57c21a)把借库执行改用了 restore_then_deduct(), 而该函数是按**出库语义**设计的(物品永久离开仓库),会同时扣减 available_quantity 与 stock_quantity。借库是可逆的,于是产生两个缺陷: 1) 借出再归还后,实物库存永久少一份 初始 (stock=20, avail=20) 借出5 (stock=15, avail=15) 归还5 (stock=15, avail=20) ← 实物没回来,丢了 5 2) 借出后转报废会重复扣减 scrap_borrow 的注释明确写着「扣减总库存(该物品确认损失); 可用库存已在借出时冻结,无需重复扣」—— 它假设借出**没动实物**。 借出已扣 stock 后,转报废再扣一次: 借出5 stock=15 → 转报废 stock=10(正确应为 15) 修复 ---- restore_then_deduct 增加 deduct_stock 参数: 出库 deduct_stock=True (默认)—— 可用数、实物数同时扣 借库 deduct_stock=False —— 只冻结可用数,实物数不动 ★ 这是**恢复原有设计**,不是新设计。git 历史显示从最初的 04ee938 借库逻辑实现 起,历经 7ef22a3 / 83b3db6 / b79b0f9 / 2556b77 / 1527d55 六次提交,一直是: stock.available_quantity = float(stock.available_quantity) - qty (只减可用,不动实物)。b57c21a 把它替换掉了。 盘点的差异计算也印证了该口径的正确性: adjusted_stock_qty = stock_quantity - 借出未还 diff_qty = 实盘数 - adjusted_stock_qty 这段逻辑要求 stock_quantity **包含**借出未还的实物,否则会重复扣减。 最终语义 -------- stock_quantity = 账面实物总数(含借出未还) available_quantity = 实际可取用数 出库申请(预占) — available ↓ 出库执行 stock ↓ available ↓ 借库申请(预占) — available ↓ 借库执行 — available ↓ 借库归还 — available ↑ 借库转报废 stock ↓ — 实测(stock, available) ------------------------ 借还循环:初始(20,20) → 借出5(20,15) → 归还5(20,20) 完全可逆 出库: 预占4(20,16) → 执行(16,16) 实物确实减 转报废: 借出3(20,17) → 转报废(17,17) 实物减一次,不重复
2026-09-11 09:39:20 +08:00
参数
----
deduct_stock:
True (默认,出库)—— 物品**永久离开**仓库,实物数与可用数同时扣。
False(借库) —— 借出是**可逆的**(会归还),只冻结可用数,
实物数不动。理由:
· 归还只加 available(现有实现),若借出扣了 stock,
一借一还后 stock 会永久少一份;
· 借库转报废(scrap_borrow)在确认损失时扣 stock,
其注释明确假设「可用库存已在借出时冻结」,
若借出已扣 stock 会重复扣减。
语义:stock = 账面实物(含借出未还),
available = 实际可取用。
净效果:
出库 —— available -= 实扫量,stock -= 实扫量
借库 —— available -= 实扫量,stock 不变
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 被拒。
2026-09-10 14:59:10 +08:00
并发与一致性说明:
· 释放与扣减都在同一事务内,行级锁由 with_for_update 保证;
fix(borrow): 借出只冻结可用数,不再扣减实物库存 问题 ---- 上一轮库存预占改造(b57c21a)把借库执行改用了 restore_then_deduct(), 而该函数是按**出库语义**设计的(物品永久离开仓库),会同时扣减 available_quantity 与 stock_quantity。借库是可逆的,于是产生两个缺陷: 1) 借出再归还后,实物库存永久少一份 初始 (stock=20, avail=20) 借出5 (stock=15, avail=15) 归还5 (stock=15, avail=20) ← 实物没回来,丢了 5 2) 借出后转报废会重复扣减 scrap_borrow 的注释明确写着「扣减总库存(该物品确认损失); 可用库存已在借出时冻结,无需重复扣」—— 它假设借出**没动实物**。 借出已扣 stock 后,转报废再扣一次: 借出5 stock=15 → 转报废 stock=10(正确应为 15) 修复 ---- restore_then_deduct 增加 deduct_stock 参数: 出库 deduct_stock=True (默认)—— 可用数、实物数同时扣 借库 deduct_stock=False —— 只冻结可用数,实物数不动 ★ 这是**恢复原有设计**,不是新设计。git 历史显示从最初的 04ee938 借库逻辑实现 起,历经 7ef22a3 / 83b3db6 / b79b0f9 / 2556b77 / 1527d55 六次提交,一直是: stock.available_quantity = float(stock.available_quantity) - qty (只减可用,不动实物)。b57c21a 把它替换掉了。 盘点的差异计算也印证了该口径的正确性: adjusted_stock_qty = stock_quantity - 借出未还 diff_qty = 实盘数 - adjusted_stock_qty 这段逻辑要求 stock_quantity **包含**借出未还的实物,否则会重复扣减。 最终语义 -------- stock_quantity = 账面实物总数(含借出未还) available_quantity = 实际可取用数 出库申请(预占) — available ↓ 出库执行 stock ↓ available ↓ 借库申请(预占) — available ↓ 借库执行 — available ↓ 借库归还 — available ↑ 借库转报废 stock ↓ — 实测(stock, available) ------------------------ 借还循环:初始(20,20) → 借出5(20,15) → 归还5(20,20) 完全可逆 出库: 预占4(20,16) → 执行(16,16) 实物确实减 转报废: 借出3(20,17) → 转报废(17,17) 实物减一次,不重复
2026-09-11 09:39:20 +08:00
· 释放后重新读取行对象,避免使用到已过期的内存快照。
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 被拒。
2026-09-10 14:59:10 +08:00
"""
# 1. 释放申请时锁定的全部批次(available_quantity 归还池子)
release_reserved(approved_items)
# 2. 先按「物料」汇总本次实扫量,再逐行扣减
#
# ★ 为什么按物料(而非单批次)校验可用量:
# 预占是把货锁给「本单」的,同物料下各批次的可用量对本单而言是
# 共享的。若逐行要求 "该行可用量 >= 该行扫码量",则当工人改扫一个
# 小批次时会被误拒 —— 例如本单预占了 A 批 5 件,工人改扫 B 批
# 2 件 + C 批 3 件,B 批自身只有 2 件可用,逐行校验就会失败。
# 实际业务中这 5 件都是本单锁定的货,理应允许。
models = stock_model_map()
# 物料级可用量汇总(释放后重新读取,拿到最新值)
avail_by_key = {}
for s in scanned_items or []:
st = norm_text(s.get('source_table'))
sid = s.get('stock_id')
model = models.get(st)
if not model or sid is None:
continue
row = model.query.get(int(sid))
if not row:
raise ValueError(f"库存记录已不存在({st}#{sid})")
key = stock_identity(row)
if key not in avail_by_key:
# 同物料全部批次的可用量之和
avail_by_key[key] = _sum_available_for_identity(key)
scan_by_key = {}
for s in scanned_items or []:
try:
qty = float(s.get('quantity') or 0)
except (TypeError, ValueError):
continue
if qty > 0:
st = norm_text(s.get('source_table'))
sid = s.get('stock_id')
model = models.get(st)
row = model.query.get(int(sid)) if (model and sid is not None) else None
if row:
key = stock_identity(row)
scan_by_key[key] = scan_by_key.get(key, 0.0) + qty
for key, need in scan_by_key.items():
have = avail_by_key.get(key, 0.0)
if need > have:
raise ValueError(
f"物料【{identity_label(key)}】可用库存不足"
f"(需 {need},实剩 {have}),无法出库"
)
fix(stock): 补行级防穿仓与入库改量下限,杜绝可用数变负 available_quantity 一旦为负,预占/释放/盘点的全部算术都会失真。此前有两处 缺口,本次一并堵上,使 available_quantity >= 0 成为不变量。 1) 执行阶段逐行扣减无下限校验(inventory_reservation.restore_then_deduct) 物料级校验只保证「Σ实扫 ≤ Σ可用」这一总量关系,拦不住「总量守恒但单行 穿仓」:同物料下 A 批可用 2、B 批可用 8,工人把 5 件全压在 A 批上,总量 5 ≤ 10 通过,A 批却被扣成 -3。借库走 deduct_stock=False,连实物数校验都 跳过,是裸扣。 已实测复现:借库与出库路径均可把单行扣成 -3。 校验放在 release_reserved() 之后,故不会误拒合法的换批次(物理覆盖): 释放后每行 available 已含本单预占,扫自己预占过的批次时 raw >= 0 保证 必然放行;改扫其它批次时,该批次实时可用量就是它自己的上限。 2) 下调入库数量可把可用数压成负(buy/product/semi 三处 update_inbound) 按 diff 同步增减 stock/available,但无任何下限检查。该批次若已有部分被 预占/出库/借出,向下调整即产生负可用数。现在下调前校验可用数是否够扣。 正常数据下 stock >= available 恒成立,故守住 available 同时守住 stock。 配套:三个 update_* 端点此前只捕获 Exception → 500,没有 ValueError 分支 (同文件的 delete_* 早就有)。补上 400 分支,使业务校验失败不再被记成 服务端故障。 验证(事务内执行并回滚,未落库):跨批次穿仓被拦、合法换批次放行、全额执行 本单预占批次放行、下调击穿被拦(API 返回 400,三个端点一致)、上调不受影响、 真实借库单 52 行全额执行正常、全库无负可用数。
2026-09-11 10:34:15 +08:00
# 3. 逐行扣减(行级校验,避免把某批次扣成负数)
#
# ★ 为什么这里必须**逐行**校验可用量,而上方还要保留物料级校验:
# 物料级校验(第 2 步)只保证「Σ实扫 ≤ Σ可用」这一总量关系,
# 拦不住「总量守恒但单行穿仓」——例如同物料下 A 批可用 2、B 批可用 8,
# 工人把 5 件全压在 A 批上,总量 5 ≤ 10 通过,A 批却被扣成 -3。
# available_quantity 一旦为负,预占/释放/盘点的全部算术都失去意义。
#
# ★ 为什么放在 release_reserved() **之后**才不会误拒合法换批次:
# 释放后每行 available = 实时可用量 + 本单在该行的预占量。工人扫自己
# 预占过的批次时 raw >= 0 保证 available >= 本单分配量,必然放行;
# 换批次时各批次的实时可用量就是它自己的上限。下方注释 2 中「A 批预占 5、
# 改扫 B 批 2 + C 批 3」的例子,只要 B、C 各有 2、3 件可用,同样通过。
#
# 由此确立不变量:available_quantity >= 0 在扣减后恒成立,
# 不再依赖前端 :max 的约束(前端可被绕过,陈旧草稿也不会夹取数量)。
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 被拒。
2026-09-10 14:59:10 +08:00
for s in scanned_items or []:
st = norm_text(s.get('source_table'))
sid = s.get('stock_id')
try:
qty = float(s.get('quantity') or 0)
except (TypeError, ValueError):
continue
if qty <= 0 or sid is None:
continue
model = models.get(st)
if not model:
continue
row = model.query.with_for_update().get(int(sid))
if not row:
raise ValueError(f"库存记录已不存在({st}#{sid})")
fix(borrow): 借出只冻结可用数,不再扣减实物库存 问题 ---- 上一轮库存预占改造(b57c21a)把借库执行改用了 restore_then_deduct(), 而该函数是按**出库语义**设计的(物品永久离开仓库),会同时扣减 available_quantity 与 stock_quantity。借库是可逆的,于是产生两个缺陷: 1) 借出再归还后,实物库存永久少一份 初始 (stock=20, avail=20) 借出5 (stock=15, avail=15) 归还5 (stock=15, avail=20) ← 实物没回来,丢了 5 2) 借出后转报废会重复扣减 scrap_borrow 的注释明确写着「扣减总库存(该物品确认损失); 可用库存已在借出时冻结,无需重复扣」—— 它假设借出**没动实物**。 借出已扣 stock 后,转报废再扣一次: 借出5 stock=15 → 转报废 stock=10(正确应为 15) 修复 ---- restore_then_deduct 增加 deduct_stock 参数: 出库 deduct_stock=True (默认)—— 可用数、实物数同时扣 借库 deduct_stock=False —— 只冻结可用数,实物数不动 ★ 这是**恢复原有设计**,不是新设计。git 历史显示从最初的 04ee938 借库逻辑实现 起,历经 7ef22a3 / 83b3db6 / b79b0f9 / 2556b77 / 1527d55 六次提交,一直是: stock.available_quantity = float(stock.available_quantity) - qty (只减可用,不动实物)。b57c21a 把它替换掉了。 盘点的差异计算也印证了该口径的正确性: adjusted_stock_qty = stock_quantity - 借出未还 diff_qty = 实盘数 - adjusted_stock_qty 这段逻辑要求 stock_quantity **包含**借出未还的实物,否则会重复扣减。 最终语义 -------- stock_quantity = 账面实物总数(含借出未还) available_quantity = 实际可取用数 出库申请(预占) — available ↓ 出库执行 stock ↓ available ↓ 借库申请(预占) — available ↓ 借库执行 — available ↓ 借库归还 — available ↑ 借库转报废 stock ↓ — 实测(stock, available) ------------------------ 借还循环:初始(20,20) → 借出5(20,15) → 归还5(20,20) 完全可逆 出库: 预占4(20,16) → 执行(16,16) 实物确实减 转报废: 借出3(20,17) → 转报废(17,17) 实物减一次,不重复
2026-09-11 09:39:20 +08:00
if deduct_stock:
stock = float(row.stock_quantity or 0)
if qty > stock:
raise ValueError(
f"物料【{identity_label(stock_identity(row))}】该批次实物不足"
f"(需 {qty},实剩 {stock}),无法出库"
)
row.stock_quantity = stock - qty
fix(stock): 补行级防穿仓与入库改量下限,杜绝可用数变负 available_quantity 一旦为负,预占/释放/盘点的全部算术都会失真。此前有两处 缺口,本次一并堵上,使 available_quantity >= 0 成为不变量。 1) 执行阶段逐行扣减无下限校验(inventory_reservation.restore_then_deduct) 物料级校验只保证「Σ实扫 ≤ Σ可用」这一总量关系,拦不住「总量守恒但单行 穿仓」:同物料下 A 批可用 2、B 批可用 8,工人把 5 件全压在 A 批上,总量 5 ≤ 10 通过,A 批却被扣成 -3。借库走 deduct_stock=False,连实物数校验都 跳过,是裸扣。 已实测复现:借库与出库路径均可把单行扣成 -3。 校验放在 release_reserved() 之后,故不会误拒合法的换批次(物理覆盖): 释放后每行 available 已含本单预占,扫自己预占过的批次时 raw >= 0 保证 必然放行;改扫其它批次时,该批次实时可用量就是它自己的上限。 2) 下调入库数量可把可用数压成负(buy/product/semi 三处 update_inbound) 按 diff 同步增减 stock/available,但无任何下限检查。该批次若已有部分被 预占/出库/借出,向下调整即产生负可用数。现在下调前校验可用数是否够扣。 正常数据下 stock >= available 恒成立,故守住 available 同时守住 stock。 配套:三个 update_* 端点此前只捕获 Exception → 500,没有 ValueError 分支 (同文件的 delete_* 早就有)。补上 400 分支,使业务校验失败不再被记成 服务端故障。 验证(事务内执行并回滚,未落库):跨批次穿仓被拦、合法换批次放行、全额执行 本单预占批次放行、下调击穿被拦(API 返回 400,三个端点一致)、上调不受影响、 真实借库单 52 行全额执行正常、全库无负可用数。
2026-09-11 10:34:15 +08:00
# ★ 行级下限:不要把该批次扣成负可用量
avail = float(row.available_quantity or 0)
if qty > avail:
raise ValueError(
f"物料【{identity_label(stock_identity(row))}】该批次可用不足"
f"(需 {qty},实剩 {avail}),无法{'出库' if deduct_stock else '借出'}。"
f"请按实际库存拆分扫码。"
)
row.available_quantity = avail - qty
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 被拒。
2026-09-10 14:59:10 +08:00
def _sum_available_for_identity(key):
"""该身份(物料)名下所有库存行的可用量汇总"""
models = stock_model_map()
total = 0.0
for st, model in models.items():
if key[0] == 'base':
rows = model.query.filter(
and_(model.base_id == key[1], model.available_quantity > 0)
).all()
else:
# name+spec 兜底:无法直接 SQL 过滤,遍历后按身份比较
rows = [r for r in model.query.filter(model.available_quantity > 0).all()
if stock_identity(r) == key]
for r in rows:
total += float(r.available_quantity or 0)
return total