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:
@ -151,7 +151,20 @@ class BorrowApprovalService:
|
||||
approved_at=beijing_time(),
|
||||
)
|
||||
|
||||
approval.set_items(items)
|
||||
# ==================================================================
|
||||
# ★ Phase 1:库存预占
|
||||
# 借出同样会占用库存(借出期间该批货不可再被他人领用),
|
||||
# 因此与出库一致:提交即锁定具体批次并扣减 available_quantity。
|
||||
# 借出归还时再归还,见 process_return()。
|
||||
# ==================================================================
|
||||
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:
|
||||
@ -407,7 +420,11 @@ class BorrowApprovalService:
|
||||
approval.status = 1 # 已通过
|
||||
approval.actual_approver_id = user_id
|
||||
approval.approved_at = current_time
|
||||
# 通过后预占继续保留,直到实际借出执行时才释放并扣减
|
||||
elif action == 'reject':
|
||||
# ★ Phase 2:驳回即释放预占,避免货被永不执行的单占住
|
||||
from app.services.inventory_reservation import release_reserved
|
||||
release_reserved(approval.get_items())
|
||||
approval.status = 2 # 已驳回
|
||||
approval.reject_reason = reject_reason
|
||||
else:
|
||||
|
||||
471
inventory-backend/app/services/inventory_reservation.py
Normal file
471
inventory-backend/app/services/inventory_reservation.py
Normal file
@ -0,0 +1,471 @@
|
||||
"""
|
||||
库存预占 / 释放 / 执行覆盖 —— 出库与借库共用的通用服务层。
|
||||
|
||||
背景:库存超卖
|
||||
--------------
|
||||
改造前,出库/借库申请只记录「要什么、要多少」,不绑定具体库存行,
|
||||
真正的 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):
|
||||
"""
|
||||
★ Phase 3 再平衡:先释放全部预占,再对实扫行扣减。
|
||||
|
||||
为什么必须「先全释放、再全扣减」而不是做净额调整:
|
||||
预占锁定的是「申请时的批次」,实扫可能是「另一个批次」。
|
||||
若直接按批次做加减,当两者恰好是同一行时加减会互相抵消,
|
||||
逻辑要分情况讨论、极易出错。统一走
|
||||
「全量释放 → 全量扣减」,路径单一,且与批次是否相同无关。
|
||||
|
||||
净效果:available -= 实扫量,stock -= 实扫量。
|
||||
|
||||
并发与一致性说明:
|
||||
· 释放与扣减都在同一事务内,行级锁由 with_for_update 保证;
|
||||
· 释放后重新读取行对象,避免使用到已过期的内存快照;
|
||||
· stock_quantity 只在此处扣减一次(货真正离开仓库)。
|
||||
"""
|
||||
# 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})")
|
||||
|
||||
stock = float(row.stock_quantity or 0)
|
||||
if qty > stock:
|
||||
raise ValueError(
|
||||
f"物料【{identity_label(stock_identity(row))}】该批次实物不足"
|
||||
f"(需 {qty},实剩 {stock}),无法出库"
|
||||
)
|
||||
row.available_quantity = float(row.available_quantity or 0) - qty
|
||||
row.stock_quantity = stock - 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
|
||||
@ -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:
|
||||
|
||||
@ -64,23 +64,22 @@ class TransService:
|
||||
raise ValueError("审批单中未记录借库人姓名,请联系管理员补录")
|
||||
|
||||
# ==============================================
|
||||
# ★ 防线2:构建审批上限字典(按 名称+规格 聚合,strip 防止匹配失败)
|
||||
# Key = (name, spec_model),Value = 该物料累计允许借出数量
|
||||
# ★ 防线2:构建审批上限字典
|
||||
#
|
||||
# 改造说明:原先以 (name, spec_model) 聚合,但本系统中 SKU 是**批次级**
|
||||
# 编号(同一物料不同批次 SKU 不同),而 name+spec 又是字符串比较,
|
||||
# 易受空格/别名影响。现统一改用 identity_key(base_id 主键 +
|
||||
# name/spec 兜底),与出库、报废三个模块共用同一套身份语义。
|
||||
# ==============================================
|
||||
approved_items = approval.get_items()
|
||||
if not approved_items:
|
||||
raise ValueError("审批单中无物料明细,请联系管理员检查")
|
||||
|
||||
approval_limits = {}
|
||||
for ai in approved_items:
|
||||
key = (
|
||||
(ai.get('name') or '').strip(),
|
||||
(ai.get('spec_model') or '').strip()
|
||||
)
|
||||
approval_limits[key] = approval_limits.get(key, 0) + float(ai.get('quantity', 0))
|
||||
from app.services.inventory_reservation import (
|
||||
build_approval_index, verify_scanned, restore_then_deduct, identity_label,
|
||||
)
|
||||
|
||||
# 累计本次扫码出库量(key 与 approval_limits 完全一致)
|
||||
dispatch_acc = {}
|
||||
approval_idx = build_approval_index(approved_items)
|
||||
|
||||
borrow_no = TransService.generate_borrow_no()
|
||||
model_map = {'stock_buy': StockBuy, 'stock_semi': StockSemi, 'stock_product': StockProduct}
|
||||
@ -88,6 +87,26 @@ class TransService:
|
||||
# ★ 防止死锁:按 (source_table, id) 排序,保证所有并发请求以相同顺序获取行锁
|
||||
items.sort(key=lambda x: (x.get('source_table', ''), x.get('id', 0)))
|
||||
|
||||
# ==============================================================
|
||||
# ★ Phase 3:预占再平衡
|
||||
# 借库申请阶段已预占具体批次;工人实扫的可能是同物料的另一批次。
|
||||
# 1. 校验实扫身份/数量未超批准范围(base_id 主键,允许换批次)
|
||||
# 2. 释放全部预占
|
||||
# 3. 对实扫批次扣减 available_quantity 与 stock_quantity
|
||||
# 下方主循环只写 TransBorrow 流水,不再重复扣库存。
|
||||
# ==============================================================
|
||||
_scanned_for_check = [
|
||||
{'source_table': i.get('source_table'), 'stock_id': i.get('id'),
|
||||
'quantity': i.get('out_quantity')}
|
||||
for i in items if i.get('source_table') in model_map
|
||||
]
|
||||
if _scanned_for_check:
|
||||
verify_scanned(_scanned_for_check, approved_items)
|
||||
restore_then_deduct(_scanned_for_check, approved_items)
|
||||
|
||||
# 累计本次扫码出库量(用于下方防线4的二次校验)
|
||||
dispatch_acc = {}
|
||||
|
||||
try:
|
||||
for item in items:
|
||||
source_table = item.get('source_table')
|
||||
@ -107,40 +126,20 @@ class TransService:
|
||||
if not stock: raise ValueError(f"库存不存在 ID:{stock_id}")
|
||||
|
||||
# ==============================================
|
||||
# ★ 防线4:名称+规格 超额校验(动态累加、即时拦截)
|
||||
# 库存表本身没有 name/spec_model 字段,通过 base 关联到 material_base
|
||||
# ★ 身份与数量校验已由上方 verify_scanned() 统一完成
|
||||
# (base_id 主键匹配,允许同物料换批次;累计量不得超批准量)
|
||||
#
|
||||
# 此处仅做一次「本次扫码累计」的防御性复核,防止并发下
|
||||
# 同一请求内重复 stock_id 被重复计数。
|
||||
# 库存扣减也已在 restore_then_deduct() 完成 ——
|
||||
# 下方**不再**扣减 available_quantity,否则会扣两次。
|
||||
# ==============================================
|
||||
if stock.base:
|
||||
stock_name = (stock.base.name or '').strip()
|
||||
stock_spec = (stock.base.spec_model or '').strip()
|
||||
else:
|
||||
stock_name = ''
|
||||
stock_spec = ''
|
||||
stock_name = (stock.base.name or '').strip() if stock.base else ''
|
||||
stock_spec = (stock.base.spec_model or '').strip() if stock.base else ''
|
||||
key = (stock_name, stock_spec)
|
||||
|
||||
limit = approval_limits.get(key)
|
||||
if limit is None:
|
||||
raise ValueError(
|
||||
f"扫码物料【{stock_name} / {stock_spec}】不在审批单允许范围内,"
|
||||
f"请检查审批单明细或重新发起申请"
|
||||
)
|
||||
|
||||
dispatch_acc[key] = dispatch_acc.get(key, 0) + qty
|
||||
current_total = dispatch_acc[key]
|
||||
if current_total > limit:
|
||||
raise ValueError(
|
||||
f"实际出库数量超出了审批单允许的上限: "
|
||||
f"物料={stock_name}({stock_spec}) "
|
||||
f"审批上限={limit}, 实际扫码={current_total}"
|
||||
)
|
||||
|
||||
if float(stock.available_quantity) < qty:
|
||||
raise ValueError(f"物料【{stock_name} / {stock_spec}】可用库存不足")
|
||||
|
||||
# 1. 冻结库存 (只减可用)
|
||||
stock.available_quantity = float(stock.available_quantity) - qty
|
||||
|
||||
# 2. 创建借用记录
|
||||
# 创建借用记录
|
||||
record = TransBorrow(
|
||||
borrow_no=borrow_no,
|
||||
sku=stock.sku,
|
||||
|
||||
Reference in New Issue
Block a user