Files
KCGL/inventory-backend/app/services/inventory_reservation.py
yueli cbcedecba2 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

543 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
库存预占 / 释放 / 执行覆盖 —— 出库与借库共用的通用服务层。
背景:库存超卖
--------------
改造前,出库/借库申请只记录「要什么、要多少」,不绑定具体库存行,
真正的 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 '-'}"
# =============================================================================
# 库存行动态解析
# =============================================================================
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
# =============================================================================
# 预占查询(只读)
# =============================================================================
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)
# =============================================================================
# 执行阶段:身份校验
# =============================================================================
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)
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
def restore_then_deduct(scanned_items, approved_items, deduct_stock=True):
"""
★ Phase 3 再平衡:先释放全部预占,再对实扫行扣减。
为什么必须「先全释放、再全扣减」而不是做净额调整:
预占锁定的是「申请时的批次」,实扫可能是「另一个批次」。
若直接按批次做加减,当两者恰好是同一行时加减会互相抵消,
逻辑要分情况讨论、极易出错。统一走
「全量释放 → 全量扣减」,路径单一,且与批次是否相同无关。
参数
----
deduct_stock:
True (默认,出库)—— 物品**永久离开**仓库,实物数与可用数同时扣。
False借库 —— 借出是**可逆的**(会归还),只冻结可用数,
实物数不动。理由:
· 归还只加 available现有实现若借出扣了 stock
一借一还后 stock 会永久少一份;
· 借库转报废scrap_borrow在确认损失时扣 stock
其注释明确假设「可用库存已在借出时冻结」,
若借出已扣 stock 会重复扣减。
语义stock = 账面实物(含借出未还),
available = 实际可取用。
净效果:
出库 —— available -= 实扫量stock -= 实扫量
借库 —— available -= 实扫量stock 不变
并发与一致性说明:
· 释放与扣减都在同一事务内,行级锁由 with_for_update 保证;
· 释放后重新读取行对象,避免使用到已过期的内存快照。
"""
# 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}),无法出库"
)
# 3. 逐行扣减(行级校验实物库存,避免把某批次扣成负数)
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}")
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
row.available_quantity = float(row.available_quantity or 0) - qty
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