refactor(bom): 前端改为消费后端分配结果,删除本地分配与跨页数据污染

一、删除前端分配算法
  Selection.vue 与 borrow/apply/index.vue 原先各自维护一套
  rowsByBaseId 归并 + 跨行分配循环(近 80 行),现全部移除。
  改为:构造 requirements → 调用 bomMatchStock → 直接 push 返回的 items。

二、缺料提示
  后端返回 shortages 时,用 ElMessageBox.alert 逐项列出
  「物料名:需 X,实配 Y,缺 Z」,替代原先笼统的「跳过 N 种缺货物料」。
  全部满足则不弹窗,仅提示添加成功。

三、API 封装支持双签名(api/outbound.ts)
  bomMatchStock(requirements | { requirements })
  同时兼容旧的 bomMatchStock(childIds),未改造的调用方不受影响。

四、清除 loadStockForBom(两个页面)
  该方法会把 BOM 匹配结果整体写入 stockList.value,而 stockList 同时是
  「手动选单弹窗」的数据源 —— 操作过 BOM 后再打开手动选单,看到的会是
  BOM 匹配结果而非库存列表。BOM 流程不再触碰 stockList 后,该交叉污染
  一并消除,方法随之删除。

注:借库选单页存在逐字相同的 .find() + Math.min 缺陷,本次一并修复,
使两个 BOM 入口行为一致。

SFC 编译通过;vue-tsc 无新增错误(残留 6 处告警为既有问题)。
This commit is contained in:
yueli
2026-09-10 14:16:52 +08:00
parent a910a6ea72
commit 0a70e5688a
3 changed files with 158 additions and 121 deletions

View File

@ -708,24 +708,9 @@ const loadStockList = async () => {
}
}
// BOM 匹配用:服务端精确查询匹配库存(替代前端 while(true) 全量加载)
const loadStockForBom = async (childIds: number[]) => {
stockLoading.value = true
try {
const res: any = await bomMatchStock(childIds)
const items = (res.data?.items || []).map((item: any) => ({
...item,
uniqueKey: item.uniqueKey || `${item.type}_${item.id}`,
warehouse_location: item.warehouse_location || item.warehouse_loc || item.full_path || ''
}))
stockList.value = items
stockTotal.value = items.length
} catch (e) {
ElMessage.error('加载 BOM 匹配库存失败')
} finally {
stockLoading.value = false
}
}
// [已移除] loadStockForBom
// BOM 加入清单已改为后端分配(传 requirements、收 items + shortages),
// 不再把匹配库存灌进 stockList,避免干扰手动选择弹窗的数据源。
const openManualSelect = async () => {
manualDialogVisible.value = true
@ -855,48 +840,71 @@ const confirmBomAdd = async () => {
const bomRows = currentBomDetail.value
// ★ 服务端精确查询 BOM 子件匹配库存(替代前端 while(true) 全量加载)
const childIds = bomRows.map((b: any) => b.child_id).filter(Boolean)
await loadStockForBom(childIds)
let addedCount = 0
let skippedCount = 0
bomRows.forEach((bomItem: any) => {
const dosage = parseFloat(bomItem.dosage) || 0
const needQty = dosage * bomSets.value
const stockCandidate = stockList.value.find(s =>
(s.base_id && s.base_id == bomItem.child_id)
)
if (stockCandidate) {
const availableQty = stockCandidate.available_quantity || 0
const actualAddQty = Math.min(needQty, availableQty)
if (actualAddQty > 0) {
const existing = selectedItems.value.find(e => e.uniqueKey === stockCandidate.uniqueKey)
if (existing) {
existing.export_quantity += actualAddQty
} else {
const newItem = JSON.parse(JSON.stringify(stockCandidate))
if (bomItem.warehouse_location) {
newItem.warehouse_location = bomItem.warehouse_location
}
newItem.export_quantity = actualAddQty
selectedItems.value.push(newItem)
}
addedCount++
} else {
skippedCount++
// ==========================================================================
// ★ BOM 加入清单:分配完全交给后端
//
// 原实现用 stockList.find(s => s.base_id == child_id) 只取第一条库存行,
// 多批次物料会因此只加到 1 件(与出库选单页此前的缺陷同源)。
// 现改为传需求、收结果,分配在 DB 层完成,与出库/借库扣减同源。
// ==========================================================================
const requirements = bomRows
.map((b: any) => {
const dosage = parseFloat(b.dosage) || 0
return {
base_id: b.child_id,
required_qty: dosage * bomSets.value,
name: b.child_name || b.name || '',
spec_model: b.child_spec || b.spec_model || ''
}
} else {
skippedCount++
}
})
})
.filter((r: any) => r.base_id && r.required_qty > 0)
if (requirements.length === 0) {
return ElMessage.warning('当前 BOM 无可借用的子件需求')
}
let addedCount = 0
let shortages: any[] = []
try {
const res: any = await bomMatchStock({ requirements })
const items = res?.data?.items || []
shortages = res?.data?.shortages || []
items.forEach((item: any) => {
const qty = Number(item.allocated_qty ?? item.export_quantity ?? 0)
if (qty <= 0) return
const existing = selectedItems.value.find(e => e.uniqueKey === item.uniqueKey)
if (existing) {
const cap = Number(item.available_quantity || 0)
existing.export_quantity = Math.min(existing.export_quantity + qty, cap)
} else {
const newItem = JSON.parse(JSON.stringify(item))
newItem.export_quantity = qty
selectedItems.value.push(newItem)
}
addedCount++
})
} catch (e: any) {
return ElMessage.error(e?.msg || 'BOM 物料分配失败,请重试')
}
// ★ 缺料提示:逐项列出未能满足的物料及缺口
if (shortages.length > 0) {
const detail = shortages
.map((s: any) => `${s.name || ('base_id ' + s.base_id)}:需 ${s.required_qty},实配 ${s.allocated_qty},缺 ${s.missing}`)
.join('\n')
ElMessageBox.alert(
`以下 ${shortages.length} 项物料库存不足,已按最大可用量分配:\n\n${detail}`,
'⚠️ 部分物料库存不足',
{ type: 'warning', confirmButtonText: '知道了' }
).catch(() => {})
}
if (addedCount > 0) {
const tip = skippedCount > 0 ? `(跳过 ${skippedCount} 种缺货物料)` : ''
ElMessage.success(`成功添加 ${addedCount} 类物料${tip}`)
if (shortages.length === 0) {
ElMessage.success(`成功添加 ${addedCount} 行物料`)
}
bomSelectVisible.value = false
} else {
ElMessage.warning('该 BOM 所有物料库存均为 0')

View File

@ -713,26 +713,12 @@ const loadStockList = async () => {
}
}
// BOM 匹配用:服务端精确查询匹配库存(替代前端 while(true) 全量加载)
const loadStockForBom = async (childIds: number[]) => {
stockLoading.value = true
try {
const res: any = await bomMatchStock(childIds)
const items = (res.data?.items || []).map((item: any) => ({
...item,
uniqueKey: item.uniqueKey || `${item.type}_${item.id}`,
warehouse_location: item.warehouse_location || item.warehouse_loc || item.full_path || ''
}))
stockList.value = items
stockTotal.value = items.length
} catch (e) {
ElMessage.error('加载 BOM 匹配库存失败')
} finally {
stockLoading.value = false
}
}
// [已移除] loadStockForBom
// BOM 加入购物车已改为后端分配:直接把 requirements 传给 bomMatchStock,
// 取回已分配好的库存行直接入车,无需再把匹配库存灌进 stockList
// (避免 stockList 被 BOM 流程覆盖而干扰手动选单弹窗)。
// 手动选库存弹窗:加载服务端分页数据 + BOM 用全量数据
// 手动选库存弹窗:加载服务端分页数据
const openManualSelect = async () => {
manualDialogVisible.value = true
stockPage.value = 1
@ -912,55 +898,82 @@ const confirmBomAdd = async () => {
const bomRows = currentBomDetail.value
// ★ 服务端精确查询 BOM 子件匹配库存(替代前端 while(true) 全量加载)
const childIds = bomRows.map((b: any) => b.child_id).filter(Boolean)
await loadStockForBom(childIds)
let addedCount = 0
let skippedCount = 0
bomRows.forEach((bomItem: any) => {
const dosage = parseFloat(bomItem.dosage) || 0
const needQty = dosage * bomSets.value
const stockCandidate = stockList.value.find(s =>
(s.base_id && s.base_id == bomItem.child_id)
)
if (stockCandidate) {
const availableQty = stockCandidate.available_quantity || 0
const actualAddQty = Math.min(needQty, availableQty)
if (actualAddQty > 0) {
const existing = selectedItems.value.find(e => e.uniqueKey === stockCandidate.uniqueKey)
if (existing) {
existing.export_quantity += actualAddQty
} else {
const newItem = JSON.parse(JSON.stringify(stockCandidate))
if (bomItem.warehouse_location) {
newItem.warehouse_location = bomItem.warehouse_location
}
newItem.export_quantity = actualAddQty
selectedItems.value.push(newItem)
}
addedCount++
} else {
skippedCount++
// ==========================================================================
// ★ BOM 加入购物车:分配完全交给后端
//
// 演进说明:
// 最初前端用 .find() 取第一条库存行 → 遇到多批次物料只加到 1 件;
// 改为前端跨行分配后仍有隐患 —— 前端 stockList 由多个入口写入,且
// base_id/stock_id 的类型差异会让匹配静默落空,表现同样是"库存不足"。
// 现在只负责「传需求」和「收结果」,分配在 DB 层完成,与出库扣减同源。
// ==========================================================================
const requirements = bomRows
.map((b: any) => {
const dosage = parseFloat(b.dosage) || 0
return {
base_id: b.child_id,
required_qty: dosage * bomSets.value,
name: b.child_name || b.name || '',
spec_model: b.child_spec || b.spec_model || ''
}
} else {
skippedCount++
}
})
})
.filter((r: any) => r.base_id && r.required_qty > 0)
if (requirements.length === 0) {
return ElMessage.warning('当前 BOM 无可出库的子件需求')
}
let addedCount = 0
let shortages: any[] = []
try {
// ★ 一次性把需求交给后端,取回已分配好的库存行
const res: any = await bomMatchStock({ requirements })
const items = res?.data?.items || []
shortages = res?.data?.shortages || []
// 直接入购物车:每行已带 stock_id / source_table / allocated_qty,无需再算
items.forEach((item: any) => {
const qty = Number(item.allocated_qty ?? item.export_quantity ?? 0)
if (qty <= 0) return
const existing = selectedItems.value.find(e => e.uniqueKey === item.uniqueKey)
if (existing) {
// 同款库存行已存在:累加,但仍以该行可用量为上限
const cap = Number(item.available_quantity || 0)
existing.export_quantity = Math.min(existing.export_quantity + qty, cap)
} else {
const newItem = JSON.parse(JSON.stringify(item))
newItem.export_quantity = qty
selectedItems.value.push(newItem)
}
addedCount++
})
} catch (e: any) {
return ElMessage.error(e?.msg || 'BOM 物料分配失败,请重试')
}
// ★ 缺料提示:逐项列出未能满足的物料及缺口
if (shortages.length > 0) {
const detail = shortages
.map((s: any) => `${s.name || ('base_id ' + s.base_id)}:需 ${s.required_qty},实配 ${s.allocated_qty},缺 ${s.missing}`)
.join('\n')
ElMessageBox.alert(
`以下 ${shortages.length} 项物料库存不足,已按最大可用量分配:\n\n${detail}`,
'⚠️ 部分物料库存不足',
{ type: 'warning', confirmButtonText: '知道了' }
).catch(() => {})
}
if (addedCount > 0) {
const tip = skippedCount > 0 ? `(跳过 ${skippedCount} 种缺货物料)` : ''
ElMessage.success(`成功添加 ${addedCount} 类物料${tip}`)
if (shortages.length === 0) {
ElMessage.success(`成功添加 ${addedCount} 行物料`)
}
// ★ 记录本次缺货清单(持久化,防止下次重复出/漏出)
saveBomShortage(selectedBomNo.value, bomDetailList.value.filter(i => i.shortage > 0))
bomSelectVisible.value = false
} else {
ElMessage.warning('该 BOM 所有物料库存均为 0')
// 全部缺货也要记录
saveBomShortage(selectedBomNo.value, bomDetailList.value.filter(i => i.shortage > 0))
}
}