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