fix(outbound): 序列号物料出库被误判超出计划数量

现象:申请单里同一个物料有 2 个,扫第 2 个时报
「超出计划数量(计划: 1,已扫: 1,本次: 1)」。

根因:计划侧与已扫侧用了**不同的聚合方式**。
  已扫侧 .filter().reduce()  → 把【所有】匹配行加总
  计划侧 .find()             → 只取【第一条】匹配行
序列号物料在申请单里是「一物一行、每行 quantity=1」(因为每个序列号对应
一条独立库存行),于是 planQty 只拿到 1,而 alreadyScanned 是全部之和,
1 + 1 > 1 直接判超发。普通数量制物料计划单只有一行,两边一致,所以一直
没暴露 —— 只有序列号物料会踩中。

同一缺陷还有第二处:unscannedList 逐行比较 planQty 与「累计已扫」,
导致两行各自都被同一笔扫码"满足",扫 1 个就把整个物料判为扫完、未扫清单漏报。

修复:
- validateAgainstPlan:计划侧改为 .filter().reduce() 汇总所有匹配行
- unscannedList:先按「名称+规格」合并计划行,再与同口径汇总的已扫数比较

用申请单 410(Opt1025 两行各 1 个)的真实数据模拟验证:
  修复前 扫第2个 → 超出计划数量(计划:1,已扫:1,本次:1)  ← 与现场报错逐字一致
  修复后 扫第2个 → 通过
  修复后 扫第3个 → 仍被拦(计划:2,已扫:2,本次:1),超发保护未失效
This commit is contained in:
yueli
2026-09-15 13:21:36 +08:00
parent b55119acbc
commit fe54d01391

View File

@ -498,23 +498,35 @@ const scanTotalTypes = computed(() => plannedItems.value.length)
// ★ 未扫清单:计划中还没扫满的物料
const unscannedList = computed(() => {
return plannedItems.value.map(plan => {
const planQty = Number(plan.quantity) || 0
// 已扫数量(按名称+规格匹配)
const scannedQty = cartItems.value
.filter(ci => {
const ciName = (ci.name || '').trim()
const ciSpec = (ci.spec_model || ci.standard || '').trim()
return ciName === (plan.name || '').trim() &&
ciSpec === (plan.spec_model || '').trim()
})
.reduce((sum, ci) => sum + (Number(ci.out_quantity) || 0), 0)
// ★ 先按「名称+规格」把计划行**合并**再比较。
// 序列号物料是一物一行(每行 quantity=1),若逐行比较,同一笔扫码会让
// 每一行都算作"已满足"(planQty 1 - scannedQty 1 = 0),于是扫了 1 个
// 就把整个物料判为扫完,未扫清单漏报。
const planGroups = new Map<string, { name: string; spec: string; planQty: number }>()
for (const plan of plannedItems.value) {
const name = (plan.name || '').trim()
const spec = (plan.spec_model || '').trim()
const key = `${name}|${spec}`
const g = planGroups.get(key) || { name, spec, planQty: 0 }
g.planQty += Number(plan.quantity) || 0
planGroups.set(key, g)
}
// 已扫数量同样按「名称+规格」汇总
const scannedByKey = new Map<string, number>()
for (const ci of cartItems.value) {
const key = `${(ci.name || '').trim()}|${(ci.spec_model || ci.standard || '').trim()}`
scannedByKey.set(key, (scannedByKey.get(key) || 0) + (Number(ci.out_quantity) || 0))
}
return Array.from(planGroups.entries()).map(([key, g]) => {
const scannedQty = scannedByKey.get(key) || 0
return {
name: plan.name || '',
spec: plan.spec_model || '',
planQty,
name: g.name,
spec: g.spec,
planQty: g.planQty,
scannedQty,
remaining: Math.max(0, planQty - scannedQty)
remaining: Math.max(0, g.planQty - scannedQty)
}
}).filter(item => item.remaining > 0) // 只保留未扫满的
})
@ -751,17 +763,21 @@ const validateAgainstPlan = (scannedName: string, scannedSpec: string, scannedQt
const normalizedName = scannedName.trim()
const normalizedSpec = (scannedSpec || '').trim()
const matchedPlan = plannedItems.value.find(plan => {
// ★ 计划侧必须**汇总所有匹配行**,不能只取第一行。
// 序列号物料在申请单里是「一物一行、每行 quantity=1」,用 .find() 只拿到 1,
// 而下面的已扫侧是 .reduce() 求总和 —— 两边聚合口径不一致,
// 扫到第 2 个就会误报「超出计划数量(计划: 1,已扫: 1,本次: 1)」。
const matchedPlans = plannedItems.value.filter((plan: any) => {
const planName = (plan.name || '').trim()
const planSpec = (plan.spec_model || '').trim()
return planName === normalizedName && planSpec === normalizedSpec
})
if (!matchedPlan) {
if (!matchedPlans.length) {
return `该物料【${normalizedName} × ${normalizedSpec}】不在计划清单中,请检查`
}
const planQty = matchedPlan.quantity ?? 0
const planQty = matchedPlans.reduce((sum: number, plan: any) => sum + (Number(plan.quantity) || 0), 0)
// 已扫数量(去重合并)
const alreadyScanned = cartItems.value