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:
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user