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

489 lines
19 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 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