diff --git a/inventory-backend/app/services/scrap_approval_service.py b/inventory-backend/app/services/scrap_approval_service.py index 11e7c3b..5dd0f35 100644 --- a/inventory-backend/app/services/scrap_approval_service.py +++ b/inventory-backend/app/services/scrap_approval_service.py @@ -201,39 +201,75 @@ class ScrapApprovalService: except (TypeError, ValueError): return None + @staticmethod + def _norm_sku(sku): + """SKU 归一化:去首尾空白。空 SKU 返回 '',由调用方回退到行级匹配键。""" + return str(sku or '').strip() + + @staticmethod + def _match_key(source_table, stock_id, sku): + """ + ★ 扫码匹配键 —— SKU 优先。 + + 已核验:SKU 在同一库存表内唯一,且不存在跨表重名(stock_buy / + stock_semi / stock_product 三表交叉无冲突),故 SKU 可安全作为 + 跨来源的稳定标识。 + + 例外:个别历史库存行的 SKU 为空,这类行无法用 SKU 标识,回退为 + source_table + stock_id 复合键。前缀区分('sku:' / 'row:')保证 + 空 SKU 行绝不会与任何正常 SKU 串键。 + """ + s = ScrapApprovalService._norm_sku(sku) + if s: + return ('sku', s) + return ('row', f"{source_table}#{stock_id}") + @staticmethod def _build_approved_index(items): - """把申请单明细整理成 (source_table, stock_id) -> 批准数量 的索引""" + """ + 申请单明细 → {匹配键: 批准信息} + + 每项保留其 source_table + stock_id 清单(rows),执行时据此定位到 + 批准时指定的那条库存记录做加锁扣减。同一 SKU 若在批准单中出现多行, + 数量累加、rows 按批准顺序保留。 + """ 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), { + key = ScrapApprovalService._match_key(st, sid, it.get('sku')) + entry = index.setdefault(key, { 'qty': 0.0, - 'label': it.get('name') or it.get('sku') or f"{st}#{sid}", + 'label': ScrapApprovalService._norm_sku(it.get('sku')) + or it.get('name') or f"{st}#{sid}", + 'rows': [], }) - entry['qty'] += float(it.get('scrap_qty') or 0) + qty = float(it.get('scrap_qty') or 0) + entry['qty'] += qty + entry['rows'].append({'source_table': st, 'stock_id': sid, 'qty': qty}) return index @staticmethod def _build_scanned_index(scanned_items, models): - """把前端实际扫码明细整理成 (source_table, stock_id) -> 累计扫码数量 的索引""" + """前端实扫明细 → {匹配键: 累计扫码数量}""" index = {} for idx, s in enumerate(scanned_items): + sku = ScrapApprovalService._norm_sku(s.get('sku')) 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: + if not sku and (not st or sid is None): + raise ValueError(f"第 {idx + 1} 条扫码明细缺少 SKU,且无有效的 source_table / stock_id") + if st and 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), { + key = ScrapApprovalService._match_key(st, sid, sku) + entry = index.setdefault(key, { 'qty': 0.0, - 'label': s.get('name') or s.get('sku') or f"{st}#{sid}", + 'label': sku or s.get('name') or f"{st}#{sid}", }) entry['qty'] += qty return index @@ -241,11 +277,13 @@ class ScrapApprovalService: @staticmethod def execute(request_id, operator_name='System', scanned_items=None): """ - 按单执行报废:以「实际扫码明细」为准扣减库存。 + 按单执行报废:以「实际扫码明细」为准,按 SKU 匹配批准明细后扣减库存。 - scanned_items: [{'source_table', 'stock_id', 'quantity', 'name'/'sku'(可选)}] - · 必须是申请单 items_json 的子集(允许少扫,不允许扫单外物品) - · 同一物品多次扫码会累加,累计不得超过批准数量 + scanned_items: [{'sku', 'quantity', 'source_table', 'stock_id'(可选,空 SKU 时必填)}] + · 以 SKU 为主校验键:扫码 SKU 必须在申请单 items_json 中存在; + · 同一 SKU 多次扫码累加,累计不得超过该 SKU 的批准总量; + · 允许合法子集(少扫 = 本次不报废该行); + · 扣减时以批准单配对的 source_table + stock_id 定位库存行加锁。 """ models = _stock_models() req = db.session.get(ScrapApproval, request_id) @@ -265,50 +303,64 @@ class ScrapApprovalService: approved = ScrapApprovalService._build_approved_index(approved_items) scanned = ScrapApprovalService._build_scanned_index(scanned_items, models) - # ★ 校验:扫码明细必须落在批准明细内,且累计量不得超批准量 + # ★ 校验一:扫码 SKU 必须在批准明细内 for key, acc in scanned.items(): - appr = approved.get(key) - if not appr: + if key not in approved: raise ValueError( - f"扫码物品【{acc['label']}】不在该报废申请单的明细中,禁止报废" + f"SKU【{acc['label']}】不在该报废申请单的批准明细中(SKU 不匹配),禁止报废" ) + + # ★ 校验二:同一 SKU 的累计扫码量不得超过批准总量 + for key, acc in scanned.items(): + appr = approved[key] if acc['qty'] > appr['qty']: raise ValueError( - f"【{acc['label']}】扫码数量({acc['qty']})超出批准数量({appr['qty']}),禁止报废" + f"SKU【{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']})") + # ★ 扣减:按批准单配对的 source_table + stock_id 定位库存行,逐行加锁扣减 + for key, acc in scanned.items(): + appr = approved[key] + remaining = acc['qty'] - 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}") + for ref in appr['rows']: + if remaining <= 0: + break + take = min(remaining, ref['qty']) + if take <= 0: + continue - # ★ 真正扣减:报废 = 实物销毁,实物库存与可用库存需同时扣减 - row.available_quantity = avail - qty - row.stock_quantity = stock - qty - db.session.flush() + st, sid = ref['source_table'], ref['stock_id'] + row = models[st].query.with_for_update().get(sid) + if not row: + raise ValueError(f"库存记录已不存在({acc['label']})") - # 写报废流水(台账) - db.session.add(TransScrap( - sku=getattr(row, 'sku', '') or '', - source_table=st, - stock_id=sid, - quantity=qty, - reason=req.remark or '', - operator_name=operator_name, - approver_name=ScrapApproval._user_name(req.actual_approver_id), - approval_status='executed', - scrap_request_no=req.request_no, - )) + avail = float(getattr(row, 'available_quantity', 0) or 0) + stock = float(getattr(row, 'stock_quantity', 0) or 0) + if take > avail: + raise ValueError(f"库存 SKU【{acc['label']}】可用不足(剩 {avail}),无法报废 {take}") + if take > stock: + raise ValueError(f"库存 SKU【{acc['label']}】实物不足(剩 {stock}),无法报废 {take}") + + # ★ 真正扣减:报废 = 实物销毁,实物库存与可用库存需同时扣减 + row.available_quantity = avail - take + row.stock_quantity = stock - take + db.session.flush() + + # 写报废流水(台账) + db.session.add(TransScrap( + sku=getattr(row, 'sku', '') or acc['label'], + source_table=st, + stock_id=sid, + quantity=take, + reason=req.remark or '', + operator_name=operator_name, + approver_name=ScrapApproval._user_name(req.actual_approver_id), + approval_status='executed', + scrap_request_no=req.request_no, + )) + + remaining -= take req.status = 3 req.executed_at = _beijing()