fix(stock): 补行级防穿仓与入库改量下限,杜绝可用数变负
available_quantity 一旦为负,预占/释放/盘点的全部算术都会失真。此前有两处 缺口,本次一并堵上,使 available_quantity >= 0 成为不变量。 1) 执行阶段逐行扣减无下限校验(inventory_reservation.restore_then_deduct) 物料级校验只保证「Σ实扫 ≤ Σ可用」这一总量关系,拦不住「总量守恒但单行 穿仓」:同物料下 A 批可用 2、B 批可用 8,工人把 5 件全压在 A 批上,总量 5 ≤ 10 通过,A 批却被扣成 -3。借库走 deduct_stock=False,连实物数校验都 跳过,是裸扣。 已实测复现:借库与出库路径均可把单行扣成 -3。 校验放在 release_reserved() 之后,故不会误拒合法的换批次(物理覆盖): 释放后每行 available 已含本单预占,扫自己预占过的批次时 raw >= 0 保证 必然放行;改扫其它批次时,该批次实时可用量就是它自己的上限。 2) 下调入库数量可把可用数压成负(buy/product/semi 三处 update_inbound) 按 diff 同步增减 stock/available,但无任何下限检查。该批次若已有部分被 预占/出库/借出,向下调整即产生负可用数。现在下调前校验可用数是否够扣。 正常数据下 stock >= available 恒成立,故守住 available 同时守住 stock。 配套:三个 update_* 端点此前只捕获 Exception → 500,没有 ValueError 分支 (同文件的 delete_* 早就有)。补上 400 分支,使业务校验失败不再被记成 服务端故障。 验证(事务内执行并回滚,未落库):跨批次穿仓被拦、合法换批次放行、全额执行 本单预占批次放行、下调击穿被拦(API 返回 400,三个端点一致)、上调不受影响、 真实借库单 52 行全额执行正常、全库无负可用数。
This commit is contained in:
@ -225,6 +225,9 @@ def update_buy(id):
|
||||
|
||||
BuyInboundService.update_inbound(id, data)
|
||||
return jsonify({"code": 200, "msg": "更新成功"})
|
||||
except ValueError as ve:
|
||||
# 业务校验失败(如下调数量会击穿可用库存)→ 400,非服务端故障
|
||||
return jsonify({"code": 400, "msg": str(ve)}), 400
|
||||
except Exception as e:
|
||||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||
|
||||
|
||||
@ -227,6 +227,9 @@ def update(id):
|
||||
data.pop(field, None)
|
||||
ProductInboundService.update_inbound(id, data)
|
||||
return jsonify({"code": 200, "msg": "更新成功"})
|
||||
except ValueError as ve:
|
||||
# 业务校验失败(如下调数量会击穿可用库存)→ 400,非服务端故障
|
||||
return jsonify({"code": 400, "msg": str(ve)}), 400
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||
|
||||
@ -238,6 +238,9 @@ def update_semi(id):
|
||||
data.pop(field, None)
|
||||
SemiInboundService.update_inbound(id, data)
|
||||
return jsonify({"code": 200, "msg": "更新成功"})
|
||||
except ValueError as ve:
|
||||
# 业务校验失败(如下调数量会击穿可用库存)→ 400,非服务端故障
|
||||
return jsonify({"code": 400, "msg": str(ve)}), 400
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||
|
||||
@ -327,6 +327,20 @@ class BuyInboundService:
|
||||
if 'in_quantity' in data:
|
||||
diff = float(data['in_quantity']) - float(stock.in_quantity)
|
||||
if diff != 0:
|
||||
# ★ 下调数量前校验可用数下限。
|
||||
# 该批次可能已有部分被预占/出库/借出,硬扣会让
|
||||
# available_quantity 变成负数 —— 而它一旦为负,
|
||||
# 预占释放、行级防穿仓校验、盘点平账的全部算术都会失真。
|
||||
# 注意:stock >= available 恒成立(正常数据下),
|
||||
# 故守住 available 同时也就守住了 stock 不会为负。
|
||||
if diff < 0:
|
||||
avail_now = float(stock.available_quantity or 0)
|
||||
if -diff > avail_now:
|
||||
raise ValueError(
|
||||
f"无法下调入库数量:该批次已有部分被预占/出库/借出,"
|
||||
f"当前可用数({avail_now})不足以支持向下调整 {-diff}。"
|
||||
f"请先处理相关单据或改为调整盘点差异。"
|
||||
)
|
||||
stock.in_quantity = float(data['in_quantity'])
|
||||
stock.stock_quantity = float(stock.stock_quantity) + diff
|
||||
stock.available_quantity = float(stock.available_quantity) + diff
|
||||
|
||||
@ -289,6 +289,17 @@ class ProductInboundService:
|
||||
if 'in_quantity' in data:
|
||||
new_qty = float(data['in_quantity'])
|
||||
diff = new_qty - float(stock.in_quantity)
|
||||
# ★ 下调数量前校验可用数下限(同 buy_service):
|
||||
# 该批次可能已有部分被预占/出库/借出,硬扣会让 available
|
||||
# 变成负数,破坏预占释放与盘点的算术。
|
||||
if diff < 0:
|
||||
avail_now = float(stock.available_quantity or 0)
|
||||
if -diff > avail_now:
|
||||
raise ValueError(
|
||||
f"无法下调入库数量:该批次已有部分被预占/出库/借出,"
|
||||
f"当前可用数({avail_now})不足以支持向下调整 {-diff}。"
|
||||
f"请先处理相关单据或改为调整盘点差异。"
|
||||
)
|
||||
stock.in_quantity = new_qty
|
||||
stock.stock_quantity = float(stock.stock_quantity) + diff
|
||||
stock.available_quantity = float(stock.available_quantity) + diff
|
||||
|
||||
@ -367,6 +367,17 @@ class SemiInboundService:
|
||||
new_qty = float(data['in_quantity'])
|
||||
diff = new_qty - float(stock.in_quantity)
|
||||
if diff != 0:
|
||||
# ★ 下调数量前校验可用数下限(同 buy_service):
|
||||
# 该批次可能已有部分被预占/出库/借出,硬扣会让 available
|
||||
# 变成负数,破坏预占释放与盘点的算术。
|
||||
if diff < 0:
|
||||
avail_now = float(stock.available_quantity or 0)
|
||||
if -diff > avail_now:
|
||||
raise ValueError(
|
||||
f"无法下调入库数量:该批次已有部分被预占/出库/借出,"
|
||||
f"当前可用数({avail_now})不足以支持向下调整 {-diff}。"
|
||||
f"请先处理相关单据或改为调整盘点差异。"
|
||||
)
|
||||
stock.in_quantity = new_qty
|
||||
stock.stock_quantity = float(stock.stock_quantity) + diff
|
||||
stock.available_quantity = float(stock.available_quantity) + diff
|
||||
|
||||
@ -494,7 +494,22 @@ def restore_then_deduct(scanned_items, approved_items, deduct_stock=True):
|
||||
f"(需 {need},实剩 {have}),无法出库"
|
||||
)
|
||||
|
||||
# 3. 逐行扣减(行级校验实物库存,避免把某批次扣成负数)
|
||||
# 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')
|
||||
@ -521,7 +536,15 @@ def restore_then_deduct(scanned_items, approved_items, deduct_stock=True):
|
||||
)
|
||||
row.stock_quantity = stock - qty
|
||||
|
||||
row.available_quantity = float(row.available_quantity or 0) - 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):
|
||||
|
||||
Reference in New Issue
Block a user