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)) } }