fix(scrap): 修复扫码时库存行遮蔽在管不良品导致的「不在批准明细中」

现象
----
报废申请单指向在管不良品时,待执行清单里明明能看到该 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 分毫未动**(未误扣错误实物)
This commit is contained in:
yueli
2026-09-16 17:22:51 +08:00
parent 6520f1c97c
commit d1337d12e1
3 changed files with 79 additions and 17 deletions

View File

@ -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):

View File

@ -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 }
})
}

View File

@ -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