diff --git a/inventory-backend/app/api/v1/inbound/buy.py b/inventory-backend/app/api/v1/inbound/buy.py index a614676..e3caec3 100644 --- a/inventory-backend/app/api/v1/inbound/buy.py +++ b/inventory-backend/app/api/v1/inbound/buy.py @@ -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 diff --git a/inventory-backend/app/api/v1/inbound/product.py b/inventory-backend/app/api/v1/inbound/product.py index 61c7554..d92a8a9 100644 --- a/inventory-backend/app/api/v1/inbound/product.py +++ b/inventory-backend/app/api/v1/inbound/product.py @@ -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 diff --git a/inventory-backend/app/api/v1/inbound/semi.py b/inventory-backend/app/api/v1/inbound/semi.py index 10d8130..eeb1d87 100644 --- a/inventory-backend/app/api/v1/inbound/semi.py +++ b/inventory-backend/app/api/v1/inbound/semi.py @@ -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 diff --git a/inventory-backend/app/services/inbound/buy_service.py b/inventory-backend/app/services/inbound/buy_service.py index e3a886b..b2f14ef 100644 --- a/inventory-backend/app/services/inbound/buy_service.py +++ b/inventory-backend/app/services/inbound/buy_service.py @@ -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 diff --git a/inventory-backend/app/services/inbound/product_service.py b/inventory-backend/app/services/inbound/product_service.py index 530a870..98af718 100644 --- a/inventory-backend/app/services/inbound/product_service.py +++ b/inventory-backend/app/services/inbound/product_service.py @@ -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 diff --git a/inventory-backend/app/services/inbound/semi_service.py b/inventory-backend/app/services/inbound/semi_service.py index e4d95b6..20a4e79 100644 --- a/inventory-backend/app/services/inbound/semi_service.py +++ b/inventory-backend/app/services/inbound/semi_service.py @@ -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 diff --git a/inventory-backend/app/services/inventory_reservation.py b/inventory-backend/app/services/inventory_reservation.py index 9cad24a..3405884 100644 --- a/inventory-backend/app/services/inventory_reservation.py +++ b/inventory-backend/app/services/inventory_reservation.py @@ -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):