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

@ -144,14 +144,30 @@ export function closeRequest(id: number) {
}) })
} }
/** BOM 需求项:后端据此在 DB 层完成跨批次分配 */
export interface BomRequirement {
base_id: number
required_qty: number
name?: string
spec_model?: string
}
/** /**
* BOM 匹配库存(替代前端 while(true) 全量加载) * BOM 库存匹配 / 分配
* 根据 BOM 子件的 base_id 列表,服务端精确查询有库存的记录 *
* 两种用法:
* 1) 分配模式(推荐):传 { requirements },后端直接返回已分配好的库存行
* (含 stock_id / source_table / allocated_qty)与 shortages 缺料明细,
* 前端直接入购物车即可,不做任何分配运算。
* 2) 查询模式(兼容旧调用):传 base_id 数组,返回全部匹配库存行。
*/ */
export function bomMatchStock(childIds: number[]) { export function bomMatchStock(payload: BomRequirement[] | { requirements: BomRequirement[] }) {
const data = Array.isArray(payload)
? { child_ids: payload } // 旧签名兼容
: { requirements: payload.requirements || [] }
return request({ return request({
url: '/v1/outbound/bom-match-stock', url: '/v1/outbound/bom-match-stock',
method: 'post', method: 'post',
data: { child_ids: childIds } data
}) })
} }

View File

@ -708,24 +708,9 @@ const loadStockList = async () => {
} }
} }
// BOM 匹配用:服务端精确查询匹配库存(替代前端 while(true) 全量加载) // [已移除] loadStockForBom
const loadStockForBom = async (childIds: number[]) => { // BOM 加入清单已改为后端分配(传 requirements、收 items + shortages),
stockLoading.value = true // 不再把匹配库存灌进 stockList,避免干扰手动选择弹窗的数据源。
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
}
}
const openManualSelect = async () => { const openManualSelect = async () => {
manualDialogVisible.value = true manualDialogVisible.value = true
@ -855,48 +840,71 @@ const confirmBomAdd = async () => {
const bomRows = currentBomDetail.value const bomRows = currentBomDetail.value
// ★ 服务端精确查询 BOM 子件匹配库存(替代前端 while(true) 全量加载) // ==========================================================================
const childIds = bomRows.map((b: any) => b.child_id).filter(Boolean) // ★ BOM 加入清单:分配完全交给后端
await loadStockForBom(childIds) //
let addedCount = 0 // 原实现用 stockList.find(s => s.base_id == child_id) 只取第一条库存行,
let skippedCount = 0 // 多批次物料会因此只加到 1 件(与出库选单页此前的缺陷同源)。
// 现改为传需求、收结果,分配在 DB 层完成,与出库/借库扣减同源。
bomRows.forEach((bomItem: any) => { // ==========================================================================
const dosage = parseFloat(bomItem.dosage) || 0 const requirements = bomRows
const needQty = dosage * bomSets.value .map((b: any) => {
const dosage = parseFloat(b.dosage) || 0
const stockCandidate = stockList.value.find(s => return {
(s.base_id && s.base_id == bomItem.child_id) base_id: b.child_id,
) required_qty: dosage * bomSets.value,
name: b.child_name || b.name || '',
if (stockCandidate) { spec_model: b.child_spec || b.spec_model || ''
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 })
.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) selectedItems.value.push(newItem)
} }
addedCount++ addedCount++
} else {
skippedCount++
}
} else {
skippedCount++
}
}) })
} 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) { if (addedCount > 0) {
const tip = skippedCount > 0 ? `(跳过 ${skippedCount} 种缺货物料)` : '' if (shortages.length === 0) {
ElMessage.success(`成功添加 ${addedCount} 类物料${tip}`) ElMessage.success(`成功添加 ${addedCount} 行物料`)
}
bomSelectVisible.value = false bomSelectVisible.value = false
} else { } else {
ElMessage.warning('该 BOM 所有物料库存均为 0') ElMessage.warning('该 BOM 所有物料库存均为 0')

View File

@ -713,26 +713,12 @@ const loadStockList = async () => {
} }
} }
// BOM 匹配用:服务端精确查询匹配库存(替代前端 while(true) 全量加载) // [已移除] loadStockForBom
const loadStockForBom = async (childIds: number[]) => { // BOM 加入购物车已改为后端分配:直接把 requirements 传给 bomMatchStock,
stockLoading.value = true // 取回已分配好的库存行直接入车,无需再把匹配库存灌进 stockList
try { // (避免 stockList 被 BOM 流程覆盖而干扰手动选单弹窗)。
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
}
}
// 手动选库存弹窗:加载服务端分页数据 + BOM 用全量数据 // 手动选库存弹窗:加载服务端分页数据
const openManualSelect = async () => { const openManualSelect = async () => {
manualDialogVisible.value = true manualDialogVisible.value = true
stockPage.value = 1 stockPage.value = 1
@ -912,55 +898,82 @@ const confirmBomAdd = async () => {
const bomRows = currentBomDetail.value const bomRows = currentBomDetail.value
// ★ 服务端精确查询 BOM 子件匹配库存(替代前端 while(true) 全量加载) // ==========================================================================
const childIds = bomRows.map((b: any) => b.child_id).filter(Boolean) // ★ BOM 加入购物车:分配完全交给后端
await loadStockForBom(childIds) //
let addedCount = 0 // 演进说明:
let skippedCount = 0 // 最初前端用 .find() 取第一条库存行 → 遇到多批次物料只加到 1 件;
// 改为前端跨行分配后仍有隐患 —— 前端 stockList 由多个入口写入,且
bomRows.forEach((bomItem: any) => { // base_id/stock_id 的类型差异会让匹配静默落空,表现同样是"库存不足"。
const dosage = parseFloat(bomItem.dosage) || 0 // 现在只负责「传需求」和「收结果」,分配在 DB 层完成,与出库扣减同源。
const needQty = dosage * bomSets.value // ==========================================================================
const requirements = bomRows
const stockCandidate = stockList.value.find(s => .map((b: any) => {
(s.base_id && s.base_id == bomItem.child_id) const dosage = parseFloat(b.dosage) || 0
) return {
base_id: b.child_id,
if (stockCandidate) { required_qty: dosage * bomSets.value,
const availableQty = stockCandidate.available_quantity || 0 name: b.child_name || b.name || '',
const actualAddQty = Math.min(needQty, availableQty) spec_model: b.child_spec || b.spec_model || ''
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 })
.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) selectedItems.value.push(newItem)
} }
addedCount++ addedCount++
} else {
skippedCount++
}
} else {
skippedCount++
}
}) })
} 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) { if (addedCount > 0) {
const tip = skippedCount > 0 ? `(跳过 ${skippedCount} 种缺货物料)` : '' if (shortages.length === 0) {
ElMessage.success(`成功添加 ${addedCount} 类物料${tip}`) ElMessage.success(`成功添加 ${addedCount} 行物料`)
}
// ★ 记录本次缺货清单(持久化,防止下次重复出/漏出) // ★ 记录本次缺货清单(持久化,防止下次重复出/漏出)
saveBomShortage(selectedBomNo.value, bomDetailList.value.filter(i => i.shortage > 0)) saveBomShortage(selectedBomNo.value, bomDetailList.value.filter(i => i.shortage > 0))
bomSelectVisible.value = false bomSelectVisible.value = false
} else { } else {
ElMessage.warning('该 BOM 所有物料库存均为 0') ElMessage.warning('该 BOM 所有物料库存均为 0')
// 全部缺货也要记录
saveBomShortage(selectedBomNo.value, bomDetailList.value.filter(i => i.shortage > 0)) saveBomShortage(selectedBomNo.value, bomDetailList.value.filter(i => i.shortage > 0))
} }
} }