Files
KCGL/inventory-backend/app/services/inventory_reservation.py
yueli 58fa42bff3 feat(scrap): 报废全链路收口到审批流
系统自陈的规则是「报废一律需审批」(SCRAP_ALWAYS_REQUIRES_APPROVAL = True),
但实际有 4 条写 TransScrap 的路径,其中 3 条绕过审批。本提交把三条旁路
全部收口,只保留「申请 → 审批 → 执行」一条写入路径。

【删除】直接报废 POST /api/v1/scrap
  同时移除 ScrapService.process_scrap()。该路径的一个连带影响是
  「维修件报废」能力随之消失 —— process_scrap 的 trans_repair 分支是
  repair_status='报废转出' 的唯一写入点。实测该能力零使用(报废转出 0 条、
  trans_scrap 来源 0 条),且早已半死:审批流的扫码校验会拒绝 trans_*
  来源。repair_service.py 的误导文案(原文指引操作员「前往报废管理进行
  扫码操作」,而那条路根本不通)已改为「维修件报废暂未开放」。
  权限元素 scrap_create:operation 保留不删 —— add_scrap_perm.sql 以它为
  scrap_apply/scrap_execute 的授权来源。

【改造】借库转报废 POST /borrow/scrap → POST /borrow/scrap-request
  TransService.scrap_borrow() 删除,逻辑迁入 BorrowScrapAdapter。
  沿用 op_return:operation 权限(零授权变更)。已归还/已报废的记录改为
  **直接报错**,不再静默 continue 返回 count=0(原缺陷:用户以为成功)。

【改造】不良品报废 POST /defective/<id>/scrap → POST /defective/<id>/scrap-request
  申请时**不预占** remaining_qty(与报废模块「仅锁定意向,不扣库存」的
  既有哲学一致)。副作用:同一批坏件可重复提交多张申请单,执行期由适配器
  按 Fail-Closed 拒绝超额的那几张。已在该接口注释与前端提示中写明。

【服务层】ScrapApprovalService 接入来源适配层
  - submit_approval 改由 get_adapter() 分派,并新增整单 scrap_mode 一致性
    校验(混合「需扫码/免扫码」两类来源直接拒绝,让 execute 分流保持简单)
  - _build_scanned_index 的来源校验改为 is_scan_source():语义上是**收窄**
    而非放宽,扫码通道永远不接纳 trans_* 来源
  - execute 按模式分流:scan 项走原有扫码匹配(索引只由 scan 项构建),
    auto 项按批准量执行。签名与调用契约不变。
  - _match_key 加入来源表:不良品 SKU 是从原库存行复制的,不带来源时
    同一张单里的两者会**必然串键**,扫码量算到错误对象上。纯库存单两侧
    同源、键仍匹配,对既有流程零行为变更。

【修复】approve() 的 fail-open
  原实现 `if user_entries and str(operator_id) not in user_entries` ——
  allowed_approvers 为空时条件短路为假,**任何登录用户都能审批**。
  这与「报废一律需审批」直接矛盾,留这扇门等于没有审批。改为无名单即拒绝。
  已实测存量「无审批人」的在途单为 0 张,不会卡死历史数据。

【扫码通道】scrap/scan 的 trans_repair 分支改为 trans_defective_goods
  在管不良品按 SKU 匹配,在管量回填到既有字段形状,前端无需按来源分支。

【清理】scrap_approval_service 的 _stock_models 与 TransScrap 导入已移除
  (来源差异全部收敛到适配层);trans_service / inventory_reservation 中
  指向已删方法的注释已更新指向 BorrowScrapAdapter。
2026-09-16 17:14:17 +08:00

637 lines
26 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 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)
# ★ 状态准入(最终防线):状态异常的实物整单拒绝。
#
# 与分配器 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:
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 会永久少一份;
· 借库转报废在确认损失时扣 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