""" 库存预占 / 释放 / 执行覆盖 —— 出库与借库共用的通用服务层。 背景:库存超卖 -------------- 改造前,出库/借库申请只记录「要什么、要多少」,不绑定具体库存行, 真正的 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