refactor(scrap): 扫码校验改用 SKU 主键,与后端同口径

前端 validateAgainstPlan 由 (source_table, stock_id) 改为 SKU 主键:
  · normSku() 去首尾空白,与后端 _norm_sku 一致;
  · matchKey() 与后端 _match_key 完全同构(sku: 前缀 / row: 回退);
  · approvedQtyByKey() 聚合同一 SKU 的批准总量,兼容批准单同 SKU 多行;
  · 扫码不在批准明细内、或该 SKU 累计量超批准量时,红色 toast + 震动阻断,
    不入购物车;
  · 购物车去重与 maxScrapFor 上限同步改为按 SKU 计算。

已通过 SFC 编译与 vue-tsc(无新增类型错误)。
This commit is contained in:
yueli
2026-09-10 10:21:53 +08:00
parent ed351f96ec
commit f556514e72

View File

@ -305,32 +305,80 @@ const scanScannedQty = computed(() =>
const scanScannedTypes = computed(() => cartItems.value.length)
const scanTotalTypes = computed(() => plannedItems.value.length)
// --- 按 (source_table, stock_id) 匹配计划项 ---
const matchPlan = (sourceTable: string, stockId: any) =>
plannedItems.value.find(
(p: any) => String(p.source_table) === String(sourceTable) && String(p.stock_id) === String(stockId)
)
// ============================================================
// ★ SKU 主键匹配(与后端 _match_key 完全同口径)
//
// SKU 在同一库存表内唯一、且不存在跨表重名,故以 SKU 作为扫码匹配的唯一标识。
// 个别历史库存行 SKU 为空,这类行回退为 source_table + stock_id 复合键;
// 前缀区分sku: / row:)确保空 SKU 行绝不会与正常 SKU 串键。
// ============================================================
const normSku = (sku: any) => String(sku ?? '').trim()
// 某计划项已扫数量
const scannedQtyOf = (plan: any) =>
const matchKey = (sourceTable: any, stockId: any, sku: any) => {
const s = normSku(sku)
return s ? `sku:${s}` : `row:${String(sourceTable)}#${String(stockId)}`
}
// 按匹配键找批准明细行
const findPlanByKey = (key: string) =>
plannedItems.value.find((p: any) => matchKey(p.source_table, p.stock_id, p.sku) === key)
// 某匹配键的批准总量(同一 SKU 在批准单中出现多行时累加)
const approvedQtyByKey = (key: string) =>
plannedItems.value
.filter((p: any) => matchKey(p.source_table, p.stock_id, p.sku) === key)
.reduce((sum: number, p: any) => sum + (Number(p.scrap_qty) || 0), 0)
// 某匹配键在购物车中的已扫总量
const scannedQtyByKey = (key: string) =>
cartItems.value
.filter(ci => String(ci.source_table) === String(plan.source_table)
&& String(ci.stock_id) === String(plan.stock_id))
.reduce((sum, ci) => sum + (Number(ci.scrap_quantity) || 0), 0)
.filter((ci: any) => matchKey(ci.source_table, ci.id, ci.sku) === key)
.reduce((sum: number, ci: any) => sum + (Number(ci.scrap_quantity) || 0), 0)
// 购物车某行还能再报废多少(批准量 - 其余行已占用量)
// 展示用:计划行的已扫数量
const scannedQtyOf = (plan: any) =>
scannedQtyByKey(matchKey(plan.source_table, plan.stock_id, plan.sku))
// 购物车某行还能再报废多少(该 SKU 批准量 - 其余行已占用量,且不超当前可用库存)
const maxScrapFor = (row: any) => {
const plan = matchPlan(row.source_table, row.stock_id)
const approved = Number(plan?.scrap_qty) || 0
const key = matchKey(row.source_table, row.id, row.sku)
const approved = approvedQtyByKey(key)
const others = cartItems.value
.filter(ci => ci !== row
&& String(ci.source_table) === String(row.source_table)
&& String(ci.stock_id) === String(row.stock_id))
.reduce((sum, ci) => sum + (Number(ci.scrap_quantity) || 0), 0)
.filter((ci: any) => ci !== row && matchKey(ci.source_table, ci.id, ci.sku) === key)
.reduce((sum: number, ci: any) => sum + (Number(ci.scrap_quantity) || 0), 0)
const avail = parseFloat(row.available_quantity) || 0
return Math.max(0, Math.min(approved - others, avail))
}
/**
* ★ 扫码校验SKU 主键)
*
* 返回 null 表示放行;返回字符串表示拦截原因(调用方以红色 toast 提示并阻断入车)。
* · 扫码 SKU 不在批准明细中 → 拦截
* · 该 SKU 累计扫码量 + 本次数量 > 批准量 → 拦截
*/
const validateAgainstPlan = (item: any, addQty: number): string | null => {
const key = matchKey(item.source_table, item.id, item.sku)
const plan = findPlanByKey(key)
if (!plan) {
const s = normSku(item.sku)
return s
? `SKU【${s}】不在该报废申请单的批准明细中,禁止报废`
: `该物料【${item.name || ''}】不在申请单的批准明细中,禁止报废`
}
const approved = approvedQtyByKey(key)
const already = scannedQtyByKey(key)
if (already + addQty > approved) {
const label = normSku(plan.sku) || plan.name || ''
return `SKU【${label}】超出批准数量(批准: ${approved},已扫: ${already},本次: ${addQty}`
}
return null
}
// 未扫清单
const unscannedList = computed(() =>
plannedItems.value.map((plan: any) => {
@ -385,27 +433,6 @@ const handleRequestChange = (val: number | null) => {
barcodeInput.value = ''
}
/**
* 校验扫码物品是否属于本申请单的批准明细,且累计不超批准量。
* 返回 null 表示通过,否则返回错误文案。
*/
const validateAgainstPlan = (item: any, addQty: number): string | null => {
const plan = matchPlan(item.source_table, item.id)
if (!plan) {
return `该物料【${item.name || item.sku}】不在申请单的批准明细中,禁止报废`
}
const approved = Number(plan.scrap_qty) || 0
const alreadyScanned = scannedQtyOf(plan)
if (alreadyScanned + addQty > approved) {
return `${plan.name || plan.sku}】超出批准数量(批准: ${approved},已扫: ${alreadyScanned},本次: ${addQty}`
}
return null
}
// --- 扫码逻辑 ---
const onScanSuccess = (code: string) => {
if (!code) return
@ -444,10 +471,9 @@ const handleManualInput = async () => {
try {
loading.value = true
// 1. 购物车已有该库存行 → 累加(按 source_table + id 去重,与申请单明细同口径
// 1. 购物车已有该 SKU → 累加(与批准明细同口径,按 SKU 去重
const existIndex = cartItems.value.findIndex(
item => String(item.source_table) === String(item.source_table) &&
(item.barcode === code || item.sku === code || String(item.id) === String(code))
item => normSku(item.sku) === code || item.barcode === code || String(item.id) === code
)
if (existIndex > -1) {
const item = cartItems.value[existIndex]