Files
KCGL/inventory-backend/app/services/inventory_reservation.py
yueli 3bb5fc6eb8 fix(outbound): 执行校验兼容无 base_id 的历史单据,修复老单必然被拒
2026-09-10 预占改造(b57c21a)之前建的单,items_json 里没有 base_id。
这类单只要在改造上线时仍处于「已通过待执行」状态,就再也执行不了:

  批准侧 build_approval_index() → identity_key(base_id, name, spec)
    无 base_id → 退化成 ('name', 名称, 规格)
  扫码侧 stock_identity()      → ('base', id)
两侧键不同构,**永远不相等**,verify_scanned() 必然抛
「扫码物料【…】不在该申请单的批准明细中,禁止出库」——批的就是这件货。

identity_key() 的文档本就把 (name, spec_model) 写作「历史数据的兜底」,
只是校验时只有批准侧降了级、扫码侧没有,两侧因此错开。

实测(APR-OUT-20260909-1313-0007,建于 09-09,改造上线前一天):
  批准侧 ('name', '派里肯安全箱1600(黑色)', 'PS-9640B001-black')
  扫码侧 ('base', 3012)

修复:
  · 新增 stock_name_spec() —— 库存行 → 名称型身份键,刻意丢掉 base_id
  · 新增 build_legacy_approval_index() —— 无 base_id 的历史明细旁路索引
  · verify_scanned() —— 主索引落空时用库存行名称+规格再比一次;命中则
    改用**批准侧的键**继续,使 acc 累计与 approved_idx 的批准量同口径

安全边界(关键):降级只对真正来自老单据的名称型键放行,白名单是
legacy_idx 而非 approved_idx 本身——build_legacy_approval_index() 只收
identity_key() 产出名称型键的明细,有 base_id 的一律跳过;名称与规格皆空
的明细直接丢弃,绝不退化成「任意物料都能匹配」。无老单时该索引为空集,
降级分支恒不命中,行为与改造前逐字节一致。

影响范围:当时处于 status=1 的老单全库仅 1 张(即上述单号)。其余 358 张
老单(已完成 327 / 已驳回 9 / 已完结 22)均为终态,不受影响——它们在改造
上线前就已执行完毕。

实测验证:
  T1 老单 360 + 扫批准范围内物料     修复前必拒 → 修复后通过
  T2 老单 360 + 扫单外物料           仍拒绝
  T3 老单 360 + 数量超批准           仍拒绝
  T4 现代单 + 扫批准范围内物料       通过(旁路索引 0 键,未受影响)
  T5 现代单 + 扫单外物料             仍拒绝

副作用改善:降级后标签改用批准侧的键,超量报错从「物料#3012」变为
「派里肯安全箱1600(黑色)(PS-9640B001-black)」,可读性提升。
2026-09-20 16:43:37 +08:00

763 lines
33 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 '-'}"
# =============================================================================
# 库存状态门槛(硬隔离)
# =============================================================================
# ★ 背景三张库存表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-Closedstatus 为 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
# =============================================================================
# 库存行动态解析
# =============================================================================
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 stock_name_spec(model_row):
"""
库存行 → **名称型**身份键 ('name', 名称, 规格)。
★ 与 stock_identity() 的区别:后者优先用 base_id 产出 ('base', id)
本函数**刻意丢掉 base_id**,强制走名称兜底。用途见
build_legacy_approval_index() —— 给没有 base_id 的历史批准明细
提供一条可比的旁路键。
"""
base = getattr(model_row, 'base', None)
return identity_key(
None,
base.name if base else '',
base.spec_model if base else '',
)
# =============================================================================
# 预占 / 释放
# =============================================================================
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 会被调用方**直接持久化** ——
# borrow_service.submit_approval 中 `approval.set_items(reserved_items)`
# 即审批单的 items_json 就是下面 reserved.append({...}) 的内容。
# 而该 append 是**重建**字典,只保留库存定位与数量,会把调用方传入的
# expected_return_time / is_indefinite / remark 全部丢弃 ——
# 执行页(扫码发货)选中审批单后因此带不出「预计归还日期」与「长期借用」,
# 库管只能每次手工补填。
#
# 实测证据2026-09-10 预占改造b57c21a上线后新建的审批单items_json
# 中这两个字段全部消失此前的老单据id ≤ 40仍保留。分界点与该提交的
# 上线时刻09-10 14:59完全吻合。
#
# 一条申请明细会被拆到多个批次行(同物料多批次),故按 base_id 建索引,
# 把同一份「意图」回填到它拆分出的每一行上。
# ==================================================================
intent_by_base = {}
intent_by_name = {}
for it in items:
if it.get('base_id') is not None:
intent_by_base.setdefault(it.get('base_id'), it)
intent_by_name.setdefault(
(str(it.get('name') or '').strip(),
str(it.get('spec_model') or it.get('standard') or '').strip()),
it,
)
# 仅透传「申请意图」。库存定位source_table/stock_id与数量由分配结果决定
# 绝不能被原明细覆盖 —— 实扫可能换批次,这里写什么,执行页就按什么释放。
INTENT_FIELDS = ('expected_return_time', 'is_indefinite', 'remark')
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
item = {
'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, # ★ 标记该行已预占,释放时据此还原
}
# ★ 回填申请意图(见上方 INTENT_FIELDS 说明)。
# 跳过 None无限期借用提交的 expected_return_time 就是 null
# 写入 null 无意义,前端读到的仍是 undefined行为一致。
origin = intent_by_base.get(a.get('base_id')) or intent_by_name.get(
(str(a.get('name') or '').strip(),
str(a.get('standard') or a.get('spec_model') or '').strip())
)
if origin is not None:
for f in INTENT_FIELDS:
if origin.get(f) is not None:
item[f] = origin.get(f)
reserved.append(item)
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 build_legacy_approval_index(approved_items):
"""
无 base_id 的历史明细 → {('name', 名称, 规格): {'qty', 'label'}}
★ 为什么需要这个旁路索引
------------------------
2026-09-10 预占改造b57c21a之前建的单items_json 里**没有 base_id**
(亦无 stock_id / source_table / reserved。build_approval_index() 对它
只能产出名称型身份键 ('name', 名称, 规格);而扫码侧 stock_identity()
拿的是库存行的 base_id产出 ('base', id)。两侧键不同构,**永远不相等**
于是这类老单只要还没执行完就必然被 verify_scanned() 拒绝,报
「扫码物料【…】不在该申请单的批准明细中」—— 明明批的就是这件货。
实测(单 APR-OUT-20260909-1313-0007建于 09-09、改造上线前一天
批准侧 ('name', '派里肯安全箱1600黑色', 'PS-9640B001-black')
扫码侧 ('base', 3012)
identity_key() 的文档本就把 (name, spec_model) 写作「历史数据的兜底」,
只是校验时只有批准侧降了级、扫码侧没有,两侧因此错开。
★ 严格限定适用范围(避免放宽现代单据的校验)
· 只收录身份键为名称型(即无有效 base_id的明细有 base_id 的一律
走 build_approval_index() 的主索引,此处跳过。
· 名称与规格都为空的明细直接丢弃 —— 无法兜底,且绝不能退化成
「任意物料都能匹配」。
本索引为空时无老单verify_scanned() 的降级分支恒不命中,
行为与改造前完全一致。
"""
index = {}
for it in approved_items or []:
key = identity_key(it.get('base_id'), it.get('name'), it.get('spec_model'))
if key[0] != 'name':
continue # 有 base_id → 归主索引,不放宽
if not key[1] and not key[2]:
continue # 名称规格皆空,无从兜底
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('审批单明细为空,无法执行')
# ★ 无 base_id 的历史明细旁路(说明见 build_legacy_approval_index
# 这里只把它当作「哪些名称型身份键确实来自老单据」的白名单 ——
# 数量口径仍以 approved_idx 为准,两者对同一身份给出相同的批准量。
# 无老单时为空集,下方降级分支恒不命中,行为与改造前完全一致。
legacy_idx = build_legacy_approval_index(approved_items)
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}),禁止出库")
if key not in approved_idx:
# ★ 降级匹配(老单专用旁路)
#
# 无 base_id 的历史批准明细身份键是名称型 ('name', 名称, 规格)
# 扫码侧是 ('base', id) —— 两者不同构,直接比永远落空,
# 批的就是这件货也会被判「不在批准明细中」。
# 此处用库存行的名称+规格再比一次;命中则**改用批准侧的键**
# 使 acc 累计与 approved_idx 的批准量保持同口径。
# 白名单是 legacy_idx 而非 approved_idx 本身:只有真正来自
# 老单据的名称型键才放行,杜绝这条旁路把现代单据的校验放宽。
alt = stock_name_spec(row)
if alt not in legacy_idx:
raise ValueError(
f"扫码物料【{label}】不在该申请单的批准明细中,禁止出库"
)
key = alt
label = identity_label(key)
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 会永久少一份;
· 借库转报废在确认损失时扣 stock其实现明确假设
「可用库存已在借出时冻结」,若借出已扣 stock
会重复扣减。(该实现现已迁入
app/services/scrap_sources.py 的 BorrowScrapAdapter
语义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. 逐行扣减(行级校验,避免把某批次扣成负数)
#
# ★ 为什么这里必须**逐行**校验可用量,而上方还要保留物料级校验:
# 物料级校验(第 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 的约束(前端可被绕过,陈旧草稿也不会夹取数量)。
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
# ★ 行级下限:不要把该批次扣成负可用量
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
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