fix(borrow): 借出只冻结可用数,不再扣减实物库存

问题
----
上一轮库存预占改造(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)  实物减一次,不重复
This commit is contained in:
yueli
2026-09-11 09:39:20 +08:00
parent e5a7cb2736
commit f702a70fe5
2 changed files with 36 additions and 13 deletions

View File

@ -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,15 +458,17 @@ def restore_then_deduct(scanned_items, approved_items):
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.available_quantity = float(row.available_quantity or 0) - qty
row.stock_quantity = stock - qty
row.available_quantity = float(row.available_quantity or 0) - qty
def _sum_available_for_identity(key):
"""该身份(物料)名下所有库存行的可用量汇总"""

View File

@ -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 = {}