From b57c21a4cd421e80ea19abb10e38384918776c52 Mon Sep 17 00:00:00 2001 From: yueli Date: Thu, 10 Sep 2026 14:59:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(inventory):=20=E5=BA=93=E5=AD=98=E9=A2=84?= =?UTF-8?q?=E5=8D=A0=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F=EF=BC=8C=E6=B6=88?= =?UTF-8?q?=E9=99=A4=E5=87=BA=E5=BA=93/=E5=80=9F=E5=BA=93=E8=B6=85?= =?UTF-8?q?=E5=8D=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:库存超卖 -------------- 改造前出库/借库申请只记录「要什么、要多少」,不绑定具体库存行, 真正的 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 被拒。 --- .../app/services/borrow_service.py | 19 +- .../app/services/inventory_reservation.py | 471 ++++++++++++++++++ .../app/services/outbound_service.py | 72 ++- .../app/services/trans_service.py | 81 ++- 4 files changed, 591 insertions(+), 52 deletions(-) create mode 100644 inventory-backend/app/services/inventory_reservation.py diff --git a/inventory-backend/app/services/borrow_service.py b/inventory-backend/app/services/borrow_service.py index f3eb1a3..e04ae0d 100644 --- a/inventory-backend/app/services/borrow_service.py +++ b/inventory-backend/app/services/borrow_service.py @@ -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: diff --git a/inventory-backend/app/services/inventory_reservation.py b/inventory-backend/app/services/inventory_reservation.py new file mode 100644 index 0000000..07b6eb3 --- /dev/null +++ b/inventory-backend/app/services/inventory_reservation.py @@ -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 diff --git a/inventory-backend/app/services/outbound_service.py b/inventory-backend/app/services/outbound_service.py index d4cfa73..eac6bef 100644 --- a/inventory-backend/app/services/outbound_service.py +++ b/inventory-backend/app/services/outbound_service.py @@ -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: diff --git a/inventory-backend/app/services/trans_service.py b/inventory-backend/app/services/trans_service.py index 3684aed..30d2a58 100644 --- a/inventory-backend/app/services/trans_service.py +++ b/inventory-backend/app/services/trans_service.py @@ -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,