feat(scrap): 报废执行改为按单扫码校验,废弃盲执行

execute() 原先只吃 request_id,按申请单快照全量扣减,用户反馈「扫码与报废脱节」。
现改为接收实扫明细 scanned_items:

  · 强制按单:未提交实扫明细一律拒绝执行;
  · 键为 (source_table, stock_id),与申请单 items_json 同口径,
    同一物品多次扫码自动累加,兼容「逐件扫」与「输数量」两种操作;
  · 校验实扫项必须落在批准明细内,且累计量不得超批准量,否则整单拒绝;
  · 允许合法子集(少扫 = 本次不报废该行);
  · 扣减以实扫量为准,同时扣 available_quantity 与 stock_quantity
    (报废即实物销毁,上一版只扣可用库存会让总库存虚挂)。

接口 POST /scrap/request/<id>/execute 由无 body 改为接收 {items:[...]}。
This commit is contained in:
yueli
2026-09-10 10:14:40 +08:00
parent b4b79c68a9
commit 7719943779
2 changed files with 108 additions and 21 deletions

View File

@ -610,12 +610,22 @@ def approve_scrap_request(request_id):
@jwt_required()
@permission_required('scrap_execute')
def execute_scrap_request(request_id):
"""按单报废:有 scrap_execute 权限者执行,扣减实物库存并写报废流水"""
"""
按单报废:有 scrap_execute 权限者执行,扣减实物库存并写报废流水。
Body: { "items": [{"source_table", "stock_id", "quantity", "name"/"sku"(可选)}, ...] }
实扫明细必须是该申请单批准明细的子集,且累计数量不得超过批准数量。
"""
try:
from app.services.scrap_approval_service import ScrapApprovalService
identity = int(get_jwt_identity())
u = SysUser.query.get(identity)
req = ScrapApprovalService.execute(request_id, operator_name=(u.username if u else str(identity)))
data = request.get_json(silent=True) or {}
req = ScrapApprovalService.execute(
request_id,
operator_name=(u.username if u else str(identity)),
scanned_items=data.get('items') or [],
)
return jsonify({'code': 200, 'msg': '已执行报废并扣减库存', 'data': req.to_dict()}), 200
except ValueError as e:
return jsonify({'code': 400, 'msg': str(e)}), 400

View File

@ -195,7 +195,58 @@ class ScrapApprovalService:
# 执行(按单报废:扣减实物库存 + 写报废流水)
# ------------------------------------------------------------------
@staticmethod
def execute(request_id, operator_name='System'):
def _to_int(v):
try:
return int(v)
except (TypeError, ValueError):
return None
@staticmethod
def _build_approved_index(items):
"""把申请单明细整理成 (source_table, stock_id) -> 批准数量 的索引"""
index = {}
for it in items:
sid = ScrapApprovalService._to_int(it.get('stock_id'))
st = str(it.get('source_table') or '').strip()
if not st or sid is None:
continue
entry = index.setdefault((st, sid), {
'qty': 0.0,
'label': it.get('name') or it.get('sku') or f"{st}#{sid}",
})
entry['qty'] += float(it.get('scrap_qty') or 0)
return index
@staticmethod
def _build_scanned_index(scanned_items, models):
"""把前端实际扫码明细整理成 (source_table, stock_id) -> 累计扫码数量 的索引"""
index = {}
for idx, s in enumerate(scanned_items):
st = str(s.get('source_table') or '').strip()
sid = ScrapApprovalService._to_int(s.get('stock_id'))
if not st or sid is None:
raise ValueError(f"{idx + 1} 条扫码明细缺少有效的 source_table / stock_id")
if st not in models:
raise ValueError(f"{idx + 1} 条扫码来源不支持:{st}")
qty = float(s.get('quantity') or 0)
if qty <= 0:
raise ValueError(f"{idx + 1} 条扫码数量必须大于 0")
entry = index.setdefault((st, sid), {
'qty': 0.0,
'label': s.get('name') or s.get('sku') or f"{st}#{sid}",
})
entry['qty'] += qty
return index
@staticmethod
def execute(request_id, operator_name='System', scanned_items=None):
"""
按单执行报废:以「实际扫码明细」为准扣减库存。
scanned_items: [{'source_table', 'stock_id', 'quantity', 'name'/'sku'(可选)}]
· 必须是申请单 items_json 的子集(允许少扫,不允许扫单外物品)
· 同一物品多次扫码会累加,累计不得超过批准数量
"""
models = _stock_models()
req = db.session.get(ScrapApproval, request_id)
if not req:
@ -203,31 +254,54 @@ class ScrapApprovalService:
if req.status != 1:
raise ValueError("仅“已通过(待执行)”的报废单可执行")
items = req.get_items()
if not items:
approved_items = req.get_items()
if not approved_items:
raise ValueError("报废明细为空,无法执行")
for it in items:
model = models.get(it.get('source_table'))
if not model:
raise ValueError(f"不支持的报废来源:{it.get('source_table')}")
row = model.query.with_for_update().get(int(it['stock_id']))
if not row:
raise ValueError(f"库存记录已不存在({it.get('sku')}")
qty = float(it.get('scrap_qty') or 0)
avail = float(getattr(row, 'available_quantity', 0) or 0)
if qty <= 0 or qty > avail:
raise ValueError(f"库存 {it.get('sku') or ''} 可用不足(剩 {avail}),无法报废 {qty}")
# ★ 强制按单扫码:未提交实扫明细不允许执行
if not scanned_items:
raise ValueError("请先扫码并提交实际报废物料,再执行报废")
# ★ 真正扣减可用库存
approved = ScrapApprovalService._build_approved_index(approved_items)
scanned = ScrapApprovalService._build_scanned_index(scanned_items, models)
# ★ 校验:扫码明细必须落在批准明细内,且累计量不得超批准量
for key, acc in scanned.items():
appr = approved.get(key)
if not appr:
raise ValueError(
f"扫码物品【{acc['label']}】不在该报废申请单的明细中,禁止报废"
)
if acc['qty'] > appr['qty']:
raise ValueError(
f"{acc['label']}】扫码数量({acc['qty']})超出批准数量({appr['qty']}),禁止报废"
)
# ★ 扣减:只扣实际扫码量(未扫到的批准项视为本次不报废)
for (st, sid), acc in scanned.items():
model = models[st]
row = model.query.with_for_update().get(sid)
if not row:
raise ValueError(f"库存记录已不存在({acc['label']}")
qty = acc['qty']
avail = float(getattr(row, 'available_quantity', 0) or 0)
stock = float(getattr(row, 'stock_quantity', 0) or 0)
if qty > avail:
raise ValueError(f"库存 {acc['label']} 可用不足(剩 {avail}),无法报废 {qty}")
if qty > stock:
raise ValueError(f"库存 {acc['label']} 实物不足(剩 {stock}),无法报废 {qty}")
# ★ 真正扣减:报废 = 实物销毁,实物库存与可用库存需同时扣减
row.available_quantity = avail - qty
row.stock_quantity = stock - qty
db.session.flush()
# 写报废流水(台账)
db.session.add(TransScrap(
sku=it.get('sku') or getattr(row, 'sku', '') or '',
source_table=it.get('source_table'),
stock_id=int(it['stock_id']),
sku=getattr(row, 'sku', '') or '',
source_table=st,
stock_id=sid,
quantity=qty,
reason=req.remark or '',
operator_name=operator_name,
@ -240,5 +314,8 @@ class ScrapApprovalService:
req.executed_at = _beijing()
req.executor_name = operator_name
db.session.commit()
logger.info(f"[ScrapApproval] {req.request_no} 执行报废完成 by {operator_name}")
logger.info(
f"[ScrapApproval] {req.request_no} 执行报废完成 by {operator_name} "
f"(批准 {len(approved)} 项 / 实扫 {len(scanned)} 项)"
)
return req