diff --git a/inventory-backend/app/api/v1/inbound/stock.py b/inventory-backend/app/api/v1/inbound/stock.py index d7c3a3d..db2b5fc 100644 --- a/inventory-backend/app/api/v1/inbound/stock.py +++ b/inventory-backend/app/api/v1/inbound/stock.py @@ -308,8 +308,8 @@ def get_stock_list(): if is_aggregated: grouped_dict = {} for item in all_items: - # 核心聚合键:类型 + 规格型号 + 库位 - group_key = f"{item.get('type')}_{item.get('standard')}_{item.get('warehouse_location', '')}" + # 核心聚合键:类型 + 规格型号 + 库位 + base_id(含 base_id 防止不同物料被错误合并) + group_key = f"{item.get('type')}_{item.get('standard')}_{item.get('warehouse_location', '')}_{item.get('base_id', '')}" if group_key in grouped_dict: # 累加数量 diff --git a/inventory-backend/app/services/bom_service.py b/inventory-backend/app/services/bom_service.py index 7f565b9..a488e73 100644 --- a/inventory-backend/app/services/bom_service.py +++ b/inventory-backend/app/services/bom_service.py @@ -388,25 +388,31 @@ class BomService: # 1. 提取所有子件的 ID 列表 child_ids = [child['child_id'] for child in detail['children']] - # 2. 用一条 IN 语句批量查出所有相关子件的库存和库位 - stock_stats = db.session.query( - StockBuy.base_id, - func.coalesce(func.sum(StockBuy.available_quantity), 0).label('total_qty'), - func.string_agg(distinct(StockBuy.warehouse_location), ', ').label('locations') - ).filter( - StockBuy.base_id.in_(child_ids), - StockBuy.available_quantity > 0 - ).group_by( - StockBuy.base_id - ).all() + # 2. 分别查三张库存表,Python 侧合并聚合(避免 UNION ALL 子查询列名问题) + from collections import defaultdict + stock_agg = defaultdict(lambda: {'qty': 0.0, 'locs': set()}) - # 3. 将查询结果转换为字典 (Map),方便后续 O(1) 极速匹配 + for model in (StockBuy, StockSemi, StockProduct): + rows = db.session.query( + model.base_id, + model.available_quantity, + model.warehouse_location + ).filter( + model.base_id.in_(child_ids), + model.available_quantity > 0 + ).all() + for base_id, qty, loc in rows: + stock_agg[base_id]['qty'] += float(qty or 0) + if loc: + stock_agg[base_id]['locs'].add(loc) + + # 3. 将聚合结果转换为字典 (Map),方便后续 O(1) 极速匹配 stock_map = { - stat.base_id: { - 'qty': stat.total_qty, - 'loc': stat.locations if stat.locations else '' + base_id: { + 'qty': agg['qty'], + 'loc': ', '.join(sorted(agg['locs'])) if agg['locs'] else '' } - for stat in stock_stats + for base_id, agg in stock_agg.items() } # 4. 遍历组装数据(纯内存操作,极快) @@ -480,17 +486,23 @@ class BomService: parent_name = detail.get('parent_name', '') - # 2. 提取所有子件 ID,查询采购库存(stock_buy) + # 2. 提取所有子件 ID,分别查三张库存表,Python 侧合并聚合 child_ids = [child['child_id'] for child in detail['children']] - buy_stats = db.session.query( - StockBuy.base_id, - func.coalesce(func.sum(StockBuy.available_quantity), 0).label('total_qty') - ).filter( - StockBuy.base_id.in_(child_ids) - ).group_by(StockBuy.base_id).all() + from collections import defaultdict + stock_agg = defaultdict(float) - buy_map = {stat.base_id: float(stat.total_qty) for stat in buy_stats} + for model in (StockBuy, StockSemi, StockProduct): + rows = db.session.query( + model.base_id, + model.available_quantity + ).filter( + model.base_id.in_(child_ids) + ).all() + for base_id, qty in rows: + stock_agg[base_id] += float(qty or 0) + + stock_map = dict(stock_agg) # 3. 提取所有子件的基础物料信息(名称/规格/类型) materials = db.session.query( @@ -511,7 +523,7 @@ class BomService: dosage = float(child.get('dosage') or 0) need_qty = dosage * order_qty - available_stock = buy_map.get(child_id, 0) + available_stock = stock_map.get(child_id, 0) suggested_qty = max(0.0, min(need_qty, available_stock)) gap = available_stock - need_qty diff --git a/inventory-web/src/App.vue b/inventory-web/src/App.vue index 0398e87..46cfbb1 100644 --- a/inventory-web/src/App.vue +++ b/inventory-web/src/App.vue @@ -239,7 +239,7 @@ const handleLogout = () => { diff --git a/inventory-web/src/api/bom.ts b/inventory-web/src/api/bom.ts index 6dd2e98..8771232 100644 --- a/inventory-web/src/api/bom.ts +++ b/inventory-web/src/api/bom.ts @@ -9,6 +9,16 @@ export function getBomList(params?: any) { }) } +// 获取BOM详情(含库存信息) +export function getBomWithStock(bomNo: string) { + const trimmed = bomNo.replace(/^\/+|\/+$/g, ''); + const encoded = encodeURIComponent(trimmed); + return request({ + url: `/v1/bom/stock/${encoded}`, + method: 'get' + }) +} + // 获取BOM详情 export function getBomDetail(bomNo: string, version?: string) { // 去除首尾斜杠,保留中间斜杠并进行 URL 编码 diff --git a/inventory-web/src/views/borrow/apply/index.vue b/inventory-web/src/views/borrow/apply/index.vue index ca33b68..ac8a493 100644 --- a/inventory-web/src/views/borrow/apply/index.vue +++ b/inventory-web/src/views/borrow/apply/index.vue @@ -437,7 +437,7 @@ import { getStockList, printSelectionList } from '@/api/inbound/stock' import { useUserStore } from '@/stores/user' import { submitBorrowRequest } from '@/api/transaction' import { getApproversList } from '@/api/auth' -import { getBomList, getBomDetail } from '@/api/bom' +import { getBomList, getBomDetail, getBomWithStock } from '@/api/bom' const userStore = useUserStore() @@ -496,7 +496,7 @@ const treeData = computed(() => { disabled: true, // 禁止选中分类本身 children: (group.items || []).map((b: any) => ({ value: b.bom_no, - label: `${b.parent_name} - ${b.version}` + label: `${b.bom_no} - ${b.parent_name} - ${b.version}` })) })) }) @@ -592,6 +592,37 @@ const loadStockList = async () => { } } +// BOM 匹配用:全量加载库存(不分页),确保所有物料都能匹配到 +const loadAllStockForBom = async () => { + stockLoading.value = true + try { + let allItems: any[] = [] + let page = 1 + const pageSize = 200 + while (true) { + const res: any = await getStockList({ + page, + pageSize, + is_aggregated: true + }) + const list = (res.data?.list || []).map((item: any) => ({ + ...item, + uniqueKey: `${item.type}_${item.id}`, + warehouse_location: item.warehouse_location || item.warehouse_loc || item.full_path || '' + })) + allItems = allItems.concat(list) + if (list.length < pageSize) break + page++ + } + stockList.value = allItems + stockTotal.value = allItems.length + } catch (e) { + ElMessage.error('加载库存列表失败') + } finally { + stockLoading.value = false + } +} + const openManualSelect = async () => { manualDialogVisible.value = true stockPage.value = 1 @@ -674,14 +705,14 @@ const openBomSelect = async () => { } } -// 监听 BOM 选择变化,自动加载明细并计算齐套性 +// 监听 BOM 选择变化,自动加载明细(含库存)并计算齐套性 watch(selectedBomNo, async (newBomNo) => { if (!newBomNo) { currentBomDetail.value = [] return } try { - const detailRes: any = await getBomDetail(newBomNo) + const detailRes: any = await getBomWithStock(newBomNo) currentBomDetail.value = detailRes.data?.children || [] } catch (e) { ElMessage.error('加载 BOM 明细失败') @@ -693,12 +724,12 @@ const confirmBomAdd = async () => { if (!selectedBomNo.value) return ElMessage.warning('请选择 BOM') if (stockList.value.length === 0) { - await loadStockList() + await loadAllStockForBom() } if (currentBomDetail.value.length === 0) { try { - const detailRes: any = await getBomDetail(selectedBomNo.value) + const detailRes: any = await getBomWithStock(selectedBomNo.value) currentBomDetail.value = detailRes.data?.children || [] } catch (e) { ElMessage.error('获取 BOM 详情失败') @@ -719,7 +750,7 @@ const confirmBomAdd = async () => { ) if (stockCandidate) { - const availableQty = stockCandidate.availableCount || 0 + const availableQty = stockCandidate.available_quantity || 0 const actualAddQty = Math.min(needQty, availableQty) if (actualAddQty > 0) { diff --git a/inventory-web/src/views/outbound/Selection.vue b/inventory-web/src/views/outbound/Selection.vue index 43959fe..c3c05cf 100644 --- a/inventory-web/src/views/outbound/Selection.vue +++ b/inventory-web/src/views/outbound/Selection.vue @@ -433,7 +433,7 @@ import { ref, computed, watch } from 'vue' import { Printer, Search, Plus, Download, List } from '@element-plus/icons-vue' import { ElMessage, ElTable, ElMessageBox } from 'element-plus' import { getStockList, printSelectionList } from '@/api/inbound/stock' -import { getBomList, getBomDetail } from '@/api/bom' +import { getBomList, getBomDetail, getBomWithStock } from '@/api/bom' import { useUserStore } from '@/stores/user' import { submitOutboundRequest } from '@/api/outbound' import { getApproversList } from '@/api/auth' @@ -494,7 +494,7 @@ const treeData = computed(() => { disabled: true, // 禁止选中分类本身 children: (group.items || []).map((b: any) => ({ value: b.bom_no, - label: `${b.parent_name} - ${b.version}` + label: `${b.bom_no} - ${b.parent_name} - ${b.version}` })) })) }) @@ -595,6 +595,37 @@ const loadStockList = async () => { } } +// BOM 匹配用:全量加载库存(不分页),确保所有物料都能匹配到 +const loadAllStockForBom = async () => { + stockLoading.value = true + try { + let allItems: any[] = [] + let page = 1 + const pageSize = 200 + while (true) { + const res: any = await getStockList({ + page, + pageSize, + is_aggregated: true + }) + const list = (res.data?.list || []).map((item: any) => ({ + ...item, + uniqueKey: `${item.type}_${item.id}`, + warehouse_location: item.warehouse_location || item.warehouse_loc || item.full_path || '' + })) + allItems = allItems.concat(list) + if (list.length < pageSize) break + page++ + } + stockList.value = allItems + stockTotal.value = allItems.length + } catch (e) { + ElMessage.error('加载库存列表失败') + } finally { + stockLoading.value = false + } +} + // 手动选库存弹窗:加载服务端分页数据 + BOM 用全量数据 const openManualSelect = async () => { manualDialogVisible.value = true @@ -707,14 +738,14 @@ const openBomSelect = async () => { } } -// 监听 BOM 选择变化,自动加载明细并计算齐套性 +// 监听 BOM 选择变化,自动加载明细(含库存)并计算齐套性 watch(selectedBomNo, async (newBomNo) => { if (!newBomNo) { currentBomDetail.value = [] return } try { - const detailRes: any = await getBomDetail(newBomNo) + const detailRes: any = await getBomWithStock(newBomNo) currentBomDetail.value = detailRes.data?.children || [] } catch (e) { ElMessage.error('加载 BOM 明细失败') @@ -726,12 +757,12 @@ const confirmBomAdd = async () => { if (!selectedBomNo.value) return ElMessage.warning('请选择 BOM') if (stockList.value.length === 0) { - await loadStockList() + await loadAllStockForBom() } if (currentBomDetail.value.length === 0) { try { - const detailRes: any = await getBomDetail(selectedBomNo.value) + const detailRes: any = await getBomWithStock(selectedBomNo.value) currentBomDetail.value = detailRes.data?.children || [] } catch (e) { ElMessage.error('获取 BOM 详情失败') @@ -752,7 +783,7 @@ const confirmBomAdd = async () => { ) if (stockCandidate) { - const availableQty = stockCandidate.availableCount || 0 + const availableQty = stockCandidate.available_quantity || 0 const actualAddQty = Math.min(needQty, availableQty) if (actualAddQty > 0) { diff --git a/inventory-web/src/views/purchase/index.vue b/inventory-web/src/views/purchase/index.vue index e606891..3816807 100644 --- a/inventory-web/src/views/purchase/index.vue +++ b/inventory-web/src/views/purchase/index.vue @@ -187,7 +187,7 @@ {{ detail.approver_name || '-' }} {{ detail.approved_at || '-' }} - + {{ detail.supplier_link }} - @@ -330,6 +330,7 @@ const statusTagType = (status: number) => { const getImageUrl = (url: string) => { if (!url) return '' if (url.startsWith('http')) return url + if (url.startsWith('/api/v1/common/files/')) return url return `/api/v1/common/files/${url}` }