From f702a70fe591f374e7a15826a3c4c05b8d9df2ff Mon Sep 17 00:00:00 2001 From: yueli Date: Fri, 11 Sep 2026 09:39:20 +0800 Subject: [PATCH] =?UTF-8?q?fix(borrow):=20=E5=80=9F=E5=87=BA=E5=8F=AA?= =?UTF-8?q?=E5=86=BB=E7=BB=93=E5=8F=AF=E7=94=A8=E6=95=B0=EF=BC=8C=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E6=89=A3=E5=87=8F=E5=AE=9E=E7=89=A9=E5=BA=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题 ---- 上一轮库存预占改造(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) 实物减一次,不重复 --- .../app/services/inventory_reservation.py | 39 +++++++++++++------ .../app/services/trans_service.py | 10 ++++- 2 files changed, 36 insertions(+), 13 deletions(-) diff --git a/inventory-backend/app/services/inventory_reservation.py b/inventory-backend/app/services/inventory_reservation.py index 07b6eb3..1d6ae82 100644 --- a/inventory-backend/app/services/inventory_reservation.py +++ b/inventory-backend/app/services/inventory_reservation.py @@ -356,7 +356,7 @@ def verify_scanned(scanned_items, approved_items): return normalized -def restore_then_deduct(scanned_items, approved_items): +def restore_then_deduct(scanned_items, approved_items, deduct_stock=True): """ ★ Phase 3 再平衡:先释放全部预占,再对实扫行扣减。 @@ -366,12 +366,27 @@ def restore_then_deduct(scanned_items, approved_items): 逻辑要分情况讨论、极易出错。统一走 「全量释放 → 全量扣减」,路径单一,且与批次是否相同无关。 - 净效果:available -= 实扫量,stock -= 实扫量。 + 参数 + ---- + deduct_stock: + True (默认,出库)—— 物品**永久离开**仓库,实物数与可用数同时扣。 + False(借库) —— 借出是**可逆的**(会归还),只冻结可用数, + 实物数不动。理由: + · 归还只加 available(现有实现),若借出扣了 stock, + 一借一还后 stock 会永久少一份; + · 借库转报废(scrap_borrow)在确认损失时扣 stock, + 其注释明确假设「可用库存已在借出时冻结」, + 若借出已扣 stock 会重复扣减。 + 语义:stock = 账面实物(含借出未还), + available = 实际可取用。 + + 净效果: + 出库 —— available -= 实扫量,stock -= 实扫量 + 借库 —— available -= 实扫量,stock 不变 并发与一致性说明: · 释放与扣减都在同一事务内,行级锁由 with_for_update 保证; - · 释放后重新读取行对象,避免使用到已过期的内存快照; - · stock_quantity 只在此处扣减一次(货真正离开仓库)。 + · 释放后重新读取行对象,避免使用到已过期的内存快照。 """ # 1. 释放申请时锁定的全部批次(available_quantity 归还池子) release_reserved(approved_items) @@ -443,14 +458,16 @@ def restore_then_deduct(scanned_items, approved_items): 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}),无法出库" - ) + 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 - row.stock_quantity = stock - qty def _sum_available_for_identity(key): diff --git a/inventory-backend/app/services/trans_service.py b/inventory-backend/app/services/trans_service.py index 30d2a58..bdcf5d7 100644 --- a/inventory-backend/app/services/trans_service.py +++ b/inventory-backend/app/services/trans_service.py @@ -92,8 +92,14 @@ class TransService: # 借库申请阶段已预占具体批次;工人实扫的可能是同物料的另一批次。 # 1. 校验实扫身份/数量未超批准范围(base_id 主键,允许换批次) # 2. 释放全部预占 - # 3. 对实扫批次扣减 available_quantity 与 stock_quantity + # 3. 对实扫批次扣减 available_quantity(★ 不动 stock_quantity) # 下方主循环只写 TransBorrow 流水,不再重复扣库存。 + # + # ★ deduct_stock=False:借出是**可逆的**(会归还),只冻结可用数。 + # 若此处扣了实物,会与两处现有实现冲突: + # · 归还(process_return)只加 available —— 一借一还后 stock 永久少一份; + # · 借库转报废(scrap_borrow)在确认损失时扣 stock,其注释明确假设 + # 「可用库存已在借出时冻结」—— 若借出已扣会重复扣减。 # ============================================================== _scanned_for_check = [ {'source_table': i.get('source_table'), 'stock_id': i.get('id'), @@ -102,7 +108,7 @@ class TransService: ] if _scanned_for_check: verify_scanned(_scanned_for_check, approved_items) - restore_then_deduct(_scanned_for_check, approved_items) + restore_then_deduct(_scanned_for_check, approved_items, deduct_stock=False) # 累计本次扫码出库量(用于下方防线4的二次校验) dispatch_acc = {}