fix(借库扫码出库): 校验 key 从 (source_table, sku) 改为 (name, spec_model) + N+1 修复
- 借库申请按 (name, spec_model) 发起,审批明细无 sku 字段; 旧代码用 sku 做 key 会导致所有条目坍塌到同一桶,校验形同虚设 - 改为在扫码循环内即时累加、即时拦截: 防线4 锁定 stock 行后从 material_base 取真实 (name, spec_model), 与审批单按 strip 后的 (name, spec_model) 聚合比对 - 新增 joinedload(ModelClass.base) 一次 JOIN 加载 base, 避免循环内 stock.base 触发 N+1 - 修正 dispatch_borrow docstring 中"sku 用于超额交叉校验"的错误描述
This commit is contained in:
@ -285,10 +285,12 @@ def dispatch_borrow():
|
|||||||
{
|
{
|
||||||
id: int, // 库存主键(按 source_table 路由到 StockBuy/StockSemi/StockProduct)
|
id: int, // 库存主键(按 source_table 路由到 StockBuy/StockSemi/StockProduct)
|
||||||
source_table: str, // 'stock_buy' | 'stock_semi' | 'stock_product'
|
source_table: str, // 'stock_buy' | 'stock_semi' | 'stock_product'
|
||||||
sku: str, // 用于按 (source_table, sku) 与审批单做超额交叉校验
|
sku: str, // 可选;不参与审批上限校验
|
||||||
out_quantity: float
|
out_quantity: float
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
// ★ 审批上限校验在 service 层完成:以 (name, spec_model) 为物料维度聚合
|
||||||
|
// 锁定 stock 行后从 material_base 表取真实 (name, spec_model) 与审批单比对
|
||||||
borrower_name: str,
|
borrower_name: str,
|
||||||
signature_path: str,
|
signature_path: str,
|
||||||
remark: str,
|
remark: str,
|
||||||
|
|||||||
@ -34,7 +34,12 @@ class TransService:
|
|||||||
signature=None, remark=None, expected_return_time=None):
|
signature=None, remark=None, expected_return_time=None):
|
||||||
"""
|
"""
|
||||||
执行借库扣减(审批通过后调用)
|
执行借库扣减(审批通过后调用)
|
||||||
流程:锁审批单 → 超额校验 → 锁库存行 → 扣减库存 → 生成 TransBorrow 记录 → 标记审批单完成
|
流程:锁审批单 → 构建审批上限字典 → 锁库存行 → 名称规格校验 → 扣减库存 → 生成 TransBorrow 记录 → 标记审批单完成
|
||||||
|
|
||||||
|
★ 关键设计:审批维度是 (name, spec_model) 而非 SKU
|
||||||
|
借库申请是按【名称 + 规格型号】发起的(borrow_service 强制要求 name/spec_model/quantity 三字段),
|
||||||
|
申请时尚未绑定具体库存行;扫码出库时通过锁定 stock 行回查 material_base 表,
|
||||||
|
用 (name, spec_model) 与审批单做物料维度聚合比对,避免 sku 维度坍塌或绕过。
|
||||||
"""
|
"""
|
||||||
from app.models.borrow import BorrowApproval
|
from app.models.borrow import BorrowApproval
|
||||||
|
|
||||||
@ -58,36 +63,23 @@ class TransService:
|
|||||||
raise ValueError("审批单中未记录借库人姓名,请联系管理员补录")
|
raise ValueError("审批单中未记录借库人姓名,请联系管理员补录")
|
||||||
|
|
||||||
# ==============================================
|
# ==============================================
|
||||||
# ★ 防线2:超额篡改校验 - 交叉比对前端传来的实际扣减量与审批单上限
|
# ★ 防线2:构建审批上限字典(按 名称+规格 聚合,strip 防止匹配失败)
|
||||||
|
# Key = (name, spec_model),Value = 该物料累计允许借出数量
|
||||||
# ==============================================
|
# ==============================================
|
||||||
approved_items = approval.get_items()
|
approved_items = approval.get_items()
|
||||||
if not approved_items:
|
if not approved_items:
|
||||||
raise ValueError("审批单中无物料明细,请联系管理员检查")
|
raise ValueError("审批单中无物料明细,请联系管理员检查")
|
||||||
|
|
||||||
# 构建审批上限字典:key=(source_table, sku) → approved_total_qty
|
approval_limits = {}
|
||||||
approved_limit = {}
|
|
||||||
for ai in approved_items:
|
for ai in approved_items:
|
||||||
key = (ai.get('source_table', ''), ai.get('sku', ''))
|
key = (
|
||||||
qty = float(ai.get('quantity', 0))
|
(ai.get('name') or '').strip(),
|
||||||
approved_limit[key] = approved_limit.get(key, 0) + qty
|
(ai.get('spec_model') or '').strip()
|
||||||
|
|
||||||
# 汇总前端传来的实际出库量(按 source_table+sku 聚合)
|
|
||||||
dispatch_qty = {}
|
|
||||||
for item in items:
|
|
||||||
key = (item.get('source_table', ''), item.get('sku', ''))
|
|
||||||
qty = float(item.get('out_quantity', 0))
|
|
||||||
dispatch_qty[key] = dispatch_qty.get(key, 0) + qty
|
|
||||||
|
|
||||||
# 逐条比对:任意一条实际出库量 > 审批上限 → 直接拒绝
|
|
||||||
for key, actual_qty in dispatch_qty.items():
|
|
||||||
limit_qty = approved_limit.get(key, 0)
|
|
||||||
if actual_qty > limit_qty:
|
|
||||||
source_table, sku = key
|
|
||||||
raise ValueError(
|
|
||||||
f"实际出库数量超出了审批单允许的上限: "
|
|
||||||
f"SKU={sku or '(无)'}({source_table}) "
|
|
||||||
f"审批上限={limit_qty}, 实际出库={actual_qty}"
|
|
||||||
)
|
)
|
||||||
|
approval_limits[key] = approval_limits.get(key, 0) + float(ai.get('quantity', 0))
|
||||||
|
|
||||||
|
# 累计本次扫码出库量(key 与 approval_limits 完全一致)
|
||||||
|
dispatch_acc = {}
|
||||||
|
|
||||||
borrow_no = TransService.generate_borrow_no()
|
borrow_no = TransService.generate_borrow_no()
|
||||||
model_map = {'stock_buy': StockBuy, 'stock_semi': StockSemi, 'stock_product': StockProduct}
|
model_map = {'stock_buy': StockBuy, 'stock_semi': StockSemi, 'stock_product': StockProduct}
|
||||||
@ -104,11 +96,39 @@ class TransService:
|
|||||||
# ==============================================
|
# ==============================================
|
||||||
# ★ 防线3:并发超卖与负库存 - 锁行后再查可用库存
|
# ★ 防线3:并发超卖与负库存 - 锁行后再查可用库存
|
||||||
# ==============================================
|
# ==============================================
|
||||||
stock = ModelClass.query.with_for_update().get(stock_id)
|
stock = ModelClass.query.options(joinedload(ModelClass.base)).with_for_update().get(stock_id)
|
||||||
if not stock: raise ValueError(f"库存不存在 ID:{stock_id}")
|
if not stock: raise ValueError(f"库存不存在 ID:{stock_id}")
|
||||||
|
|
||||||
|
# ==============================================
|
||||||
|
# ★ 防线4:名称+规格 超额校验(动态累加、即时拦截)
|
||||||
|
# 库存表本身没有 name/spec_model 字段,通过 base 关联到 material_base
|
||||||
|
# ==============================================
|
||||||
|
if stock.base:
|
||||||
|
stock_name = (stock.base.name or '').strip()
|
||||||
|
stock_spec = (stock.base.spec_model or '').strip()
|
||||||
|
else:
|
||||||
|
stock_name = ''
|
||||||
|
stock_spec = ''
|
||||||
|
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:
|
if float(stock.available_quantity) < qty:
|
||||||
raise ValueError(f"SKU {stock.sku} 可用库存不足")
|
raise ValueError(f"物料【{stock_name} / {stock_spec}】可用库存不足")
|
||||||
|
|
||||||
# 1. 冻结库存 (只减可用)
|
# 1. 冻结库存 (只减可用)
|
||||||
stock.available_quantity = float(stock.available_quantity) - qty
|
stock.available_quantity = float(stock.available_quantity) - qty
|
||||||
|
|||||||
Reference in New Issue
Block a user