From 0a70e5688af04aa04e7797e5cdc1515cd079239b Mon Sep 17 00:00:00 2001 From: yueli Date: Thu, 10 Sep 2026 14:16:52 +0800 Subject: [PATCH] =?UTF-8?q?refactor(bom):=20=E5=89=8D=E7=AB=AF=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E6=B6=88=E8=B4=B9=E5=90=8E=E7=AB=AF=E5=88=86=E9=85=8D?= =?UTF-8?q?=E7=BB=93=E6=9E=9C=EF=BC=8C=E5=88=A0=E9=99=A4=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E5=88=86=E9=85=8D=E4=B8=8E=E8=B7=A8=E9=A1=B5=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E6=B1=A1=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一、删除前端分配算法 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 处告警为既有问题)。 --- inventory-web/src/api/outbound.ts | 24 +++- .../src/views/borrow/apply/index.vue | 122 ++++++++-------- .../src/views/outbound/Selection.vue | 133 ++++++++++-------- 3 files changed, 158 insertions(+), 121 deletions(-) diff --git a/inventory-web/src/api/outbound.ts b/inventory-web/src/api/outbound.ts index 153af15..d859278 100644 --- a/inventory-web/src/api/outbound.ts +++ b/inventory-web/src/api/outbound.ts @@ -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 子件的 base_id 列表,服务端精确查询有库存的记录 + * BOM 库存匹配 / 分配 + * + * 两种用法: + * 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({ url: '/v1/outbound/bom-match-stock', method: 'post', - data: { child_ids: childIds } + data }) } \ No newline at end of file diff --git a/inventory-web/src/views/borrow/apply/index.vue b/inventory-web/src/views/borrow/apply/index.vue index e5ac3fe..0904702 100644 --- a/inventory-web/src/views/borrow/apply/index.vue +++ b/inventory-web/src/views/borrow/apply/index.vue @@ -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') diff --git a/inventory-web/src/views/outbound/Selection.vue b/inventory-web/src/views/outbound/Selection.vue index efd2c6d..a74e9f8 100644 --- a/inventory-web/src/views/outbound/Selection.vue +++ b/inventory-web/src/views/outbound/Selection.vue @@ -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)) } }