From 7719943779bb4a6909075e270930d449b31eef48 Mon Sep 17 00:00:00 2001 From: yueli Date: Thu, 10 Sep 2026 10:14:40 +0800 Subject: [PATCH] =?UTF-8?q?feat(scrap):=20=E6=8A=A5=E5=BA=9F=E6=89=A7?= =?UTF-8?q?=E8=A1=8C=E6=94=B9=E4=B8=BA=E6=8C=89=E5=8D=95=E6=89=AB=E7=A0=81?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=EF=BC=8C=E5=BA=9F=E5=BC=83=E7=9B=B2=E6=89=A7?= =?UTF-8?q?=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit execute() 原先只吃 request_id,按申请单快照全量扣减,用户反馈「扫码与报废脱节」。 现改为接收实扫明细 scanned_items: · 强制按单:未提交实扫明细一律拒绝执行; · 键为 (source_table, stock_id),与申请单 items_json 同口径, 同一物品多次扫码自动累加,兼容「逐件扫」与「输数量」两种操作; · 校验实扫项必须落在批准明细内,且累计量不得超批准量,否则整单拒绝; · 允许合法子集(少扫 = 本次不报废该行); · 扣减以实扫量为准,同时扣 available_quantity 与 stock_quantity (报废即实物销毁,上一版只扣可用库存会让总库存虚挂)。 接口 POST /scrap/request//execute 由无 body 改为接收 {items:[...]}。 --- inventory-backend/app/api/v1/scrap.py | 14 ++- .../app/services/scrap_approval_service.py | 115 +++++++++++++++--- 2 files changed, 108 insertions(+), 21 deletions(-) diff --git a/inventory-backend/app/api/v1/scrap.py b/inventory-backend/app/api/v1/scrap.py index f3e6f36..faef9a3 100644 --- a/inventory-backend/app/api/v1/scrap.py +++ b/inventory-backend/app/api/v1/scrap.py @@ -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 diff --git a/inventory-backend/app/services/scrap_approval_service.py b/inventory-backend/app/services/scrap_approval_service.py index 731cccf..11e7c3b 100644 --- a/inventory-backend/app/services/scrap_approval_service.py +++ b/inventory-backend/app/services/scrap_approval_service.py @@ -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