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