From d1337d12e1cb4c4aeead709fc7e83cd50c9f0284 Mon Sep 17 00:00:00 2001 From: yueli Date: Wed, 16 Sep 2026 17:22:51 +0800 Subject: [PATCH] =?UTF-8?q?fix(scrap):=20=E4=BF=AE=E5=A4=8D=E6=89=AB?= =?UTF-8?q?=E7=A0=81=E6=97=B6=E5=BA=93=E5=AD=98=E8=A1=8C=E9=81=AE=E8=94=BD?= =?UTF-8?q?=E5=9C=A8=E7=AE=A1=E4=B8=8D=E8=89=AF=E5=93=81=E5=AF=BC=E8=87=B4?= =?UTF-8?q?=E7=9A=84=E3=80=8C=E4=B8=8D=E5=9C=A8=E6=89=B9=E5=87=86=E6=98=8E?= =?UTF-8?q?=E7=BB=86=E4=B8=AD=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 现象 ---- 报废申请单指向在管不良品时,待执行清单里明明能看到该 SKU,扫码却报 「不在该报废申请单的批准明细中,禁止报废」。 根因 ---- 在管不良品的 SKU 是从原库存行**复制**的(退回时 sku=getattr(stock_row,'sku','')), 因此同一个条码可能同时命中 stock_buy#M 与 trans_defective_goods#N —— 这是 **两个不同的实物**。 而 ScrapService.get_stock_by_barcode 原实现按固定顺序 (stock_product → stock_semi → stock_buy → trans_defective_goods)返回 **首个命中**。原库存行只要还在,就永远遮蔽在管不良品,扫码结果恒为 stock_buy。改造前双方都用 SKU 作为匹配键,遮蔽不暴露问题;上一轮给匹配键 加上来源表后,`sku:trans_defective_goods:X` ≠ `sku:stock_buy:X`, 问题才浮出水面。 实测复现(业务报障的 SKU 0000000590): stock_buy#589 在库 6 件 trans_defective_goods#24 在管 1 件(同一 SKU) 批准明细期望 -> ('trans_defective_goods', 24) 扫码实际返回 -> stock_buy#589 → 键不匹配 → 报错 修复 ---- 条码本身无法区分这两个实物,**只有单据上下文能决定该扫到哪一个**,故把 单据上下文引入扫码解析: - get_stock_by_barcode(barcode, prefer_pairs=None):改为**收集全部候选**, 再按 prefer_pairs(当前申请单批准明细的 (source_table, stock_id) 集合) 优先命中;无上下文时退回固定顺序,行为与改造前一致 - GET /scrap/scan 新增可选 request_id:据此加载该单的批准明细构造优先集。 优先集构造失败不阻断扫码,仅告警并回退默认选路 - 前端 scanBarcode(barcode, requestId) 与 create.vue 调用处带上当前申请单 id 验证(隔离数据端到端,非仅单元): 同一 SKU 建于 stock_buy#2202 与 trans_defective_goods#25 不带上下文扫码 -> stock_buy#2202 (命中批准明细? False ← 旧行为) 带上下文扫码 -> trans_defective_goods#25(命中批准明细? True) 执行后:在管 2→0、状态=已报废;**库存行 10/10 分毫未动**(未误扣错误实物) --- inventory-backend/app/api/v1/scrap.py | 82 ++++++++++++++++--- inventory-web/src/api/scrap.ts | 9 +- .../src/views/operation/scrap/create.vue | 5 +- 3 files changed, 79 insertions(+), 17 deletions(-) diff --git a/inventory-backend/app/api/v1/scrap.py b/inventory-backend/app/api/v1/scrap.py index 4032317..955a9a8 100644 --- a/inventory-backend/app/api/v1/scrap.py +++ b/inventory-backend/app/api/v1/scrap.py @@ -1,5 +1,5 @@ # inventory-backend/app/api/v1/scrap.py -from flask import Blueprint, request, jsonify +from flask import Blueprint, request, jsonify, current_app from flask_jwt_extended import jwt_required, get_jwt_identity, get_jwt from app.utils.decorators import permission_required, get_current_company_filter from app.services.auth_service import AuthService @@ -71,8 +71,34 @@ def scan_barcode(): if not barcode: return jsonify({'code': 400, 'msg': '请提供条码'}), 400 + # ★ 可选:当前正在执行的报废申请单 id。 + # 传入后,扫码命中多个来源时优先返回该单批准明细里指定的那一条。 + # 必要性:在管不良品的 SKU 复制自原库存行,同一码可能同时命中 + # stock_buy#M 与 trans_defective_goods#N,这是两个不同实物, + # 条码本身无法区分,只有单据上下文能决定该扫到哪个。 + request_id = request.args.get('request_id', type=int) + prefer_pairs = None + if request_id: + try: + from app.models.scrap_approval import ScrapApproval + req = db.session.get(ScrapApproval, request_id) + if req: + prefer_pairs = set() + for it in (req.get_items() or []): + try: + prefer_pairs.add(( + str(it.get('source_table') or '').strip(), + int(it.get('stock_id')), + )) + except (TypeError, ValueError): + continue + except Exception as e: + # 优先集构造失败不应阻断扫码 —— 退回固定顺序选路,行为与改造前一致 + current_app.logger.warning(f"[scrap/scan] 构造优先命中集失败: {e}") + prefer_pairs = None + try: - result = ScrapService.get_stock_by_barcode(barcode) + result = ScrapService.get_stock_by_barcode(barcode, prefer_pairs=prefer_pairs) if result: # ★ Fail-Closed: 扫码响应剥离价格字段 result.pop('price', None) @@ -174,8 +200,23 @@ def get_scrap_records(): class ScrapService: @staticmethod - def get_stock_by_barcode(barcode): - """根据条码查找库存""" + def get_stock_by_barcode(barcode, prefer_pairs=None): + """ + 根据条码查找库存实物。 + + prefer_pairs: {(source_table, row_id), ...} 可选 —— 「优先命中集」, + 由调用方从**当前报废申请单的批准明细**构造。 + + ★ 为什么需要它(这是必须的,不是优化): + 在管不良品的 SKU 是从原库存行**复制**的(退回时 + sku=getattr(stock_row,'sku','')),因此同一个条码可能同时命中 + stock_buy#M 与 trans_defective_goods#N —— 这是**两个不同的实物**。 + 仅凭条码无法区分,原实现「按固定顺序返回首个命中」会让库存行 + 永久遮蔽不良品,导致执行时报「不在批准明细中」。 + 只有单据上下文能决定该扫到哪一个,故由调用方传入优先集。 + + 返回值形状与改造前完全一致,前端无需按来源分支处理。 + """ if not barcode: return None clean_code = barcode.strip() @@ -187,32 +228,32 @@ class ScrapService: return float(item.pre_tax_unit_price) if item.pre_tax_unit_price else 0 return 0 - # 1. 查询成品 + # ---- 收集全部候选(不再首个即返回)---- + candidates = [] + prod = StockProduct.query.filter( db.or_(StockProduct.barcode == clean_code, StockProduct.sku == clean_code) ).first() if prod: res = ScrapService._format_stock(prod, 'stock_product') res['price'] = get_price(prod, 'stock_product') - return res + candidates.append(res) - # 2. 查询半成品 semi = StockSemi.query.filter( db.or_(StockSemi.barcode == clean_code, StockSemi.sku == clean_code) ).first() if semi: res = ScrapService._format_stock(semi, 'stock_semi') res['price'] = 0 - return res + candidates.append(res) - # 3. 查询原材料 buy = StockBuy.query.filter( db.or_(StockBuy.barcode == clean_code, StockBuy.sku == clean_code) ).first() if buy: res = ScrapService._format_stock(buy, 'stock_buy') res['price'] = get_price(buy, 'stock_buy') - return res + candidates.append(res) # 4. 查询在管不良品台账(逆向物流:坏件不入库存表,由独立台账承载) # @@ -231,7 +272,7 @@ class ScrapService: if defective: # 在管量即「可报废量」,回填到既有字段形状,前端无需按来源分支处理 remain = float(defective.remaining_qty or 0) - return { + candidates.append({ 'id': defective.id, 'sku': defective.sku, 'barcode': defective.sku, @@ -243,9 +284,24 @@ class ScrapService: 'stock_quantity': remain, 'available_quantity': remain, 'source_table': 'trans_defective_goods', - } + }) - return None + if not candidates: + return None + + # ---- 选路:单据上下文优先,否则维持改造前的固定顺序(首个命中)---- + # ★ 同一 SKU 同时存在于库存表与在管台账时,条码无法区分二者; + # 批准明细说该报废哪一个,就返回哪一个。 + if prefer_pairs: + for res in candidates: + try: + pair = (str(res.get('source_table') or '').strip(), int(res.get('id'))) + except (TypeError, ValueError): + continue + if pair in prefer_pairs: + return res + + return candidates[0] @staticmethod def _format_stock(item, table_type): diff --git a/inventory-web/src/api/scrap.ts b/inventory-web/src/api/scrap.ts index 468f1a1..a38ba98 100644 --- a/inventory-web/src/api/scrap.ts +++ b/inventory-web/src/api/scrap.ts @@ -1,11 +1,16 @@ import request from '@/utils/request' // 1. 扫码查询库存 -export function scanBarcode(barcode: string) { +// +// ★ requestId 必须传:在管不良品的 SKU 复制自原库存行,同一码可能同时 +// 命中 stock_buy#M 与 trans_defective_goods#N —— 这是两个不同实物, +// 条码本身无法区分。带上当前申请单 id 后,后端会优先返回该单批准明细 +// 指定的那一条,避免「清单里明明有、扫码却说不在批准明细中」。 +export function scanBarcode(barcode: string, requestId?: number) { return request({ url: '/v1/scrap/scan', method: 'get', - params: { barcode } + params: { barcode, request_id: requestId } }) } diff --git a/inventory-web/src/views/operation/scrap/create.vue b/inventory-web/src/views/operation/scrap/create.vue index a2918db..d820ad2 100644 --- a/inventory-web/src/views/operation/scrap/create.vue +++ b/inventory-web/src/views/operation/scrap/create.vue @@ -576,8 +576,9 @@ const handleManualInput = async () => { return } - // 2. 查库 - const res: any = await scanBarcode(code) + // 2. 查库(★ 必须带当前申请单 id:同一 SKU 可能同时存在于库存表与 + // 在管不良品台账,两者是不同实物,只有单据上下文能决定该扫到哪个) + const res: any = await scanBarcode(code, selectedRequest.value?.id) if (res.code === 200 && res.data) { const item = res.data