V3.50
This commit is contained in:
@ -308,8 +308,8 @@ def get_stock_list():
|
|||||||
if is_aggregated:
|
if is_aggregated:
|
||||||
grouped_dict = {}
|
grouped_dict = {}
|
||||||
for item in all_items:
|
for item in all_items:
|
||||||
# 核心聚合键:类型 + 规格型号 + 库位
|
# 核心聚合键:类型 + 规格型号 + 库位 + base_id(含 base_id 防止不同物料被错误合并)
|
||||||
group_key = f"{item.get('type')}_{item.get('standard')}_{item.get('warehouse_location', '')}"
|
group_key = f"{item.get('type')}_{item.get('standard')}_{item.get('warehouse_location', '')}_{item.get('base_id', '')}"
|
||||||
|
|
||||||
if group_key in grouped_dict:
|
if group_key in grouped_dict:
|
||||||
# 累加数量
|
# 累加数量
|
||||||
|
|||||||
@ -388,25 +388,31 @@ class BomService:
|
|||||||
# 1. 提取所有子件的 ID 列表
|
# 1. 提取所有子件的 ID 列表
|
||||||
child_ids = [child['child_id'] for child in detail['children']]
|
child_ids = [child['child_id'] for child in detail['children']]
|
||||||
|
|
||||||
# 2. 用一条 IN 语句批量查出所有相关子件的库存和库位
|
# 2. 分别查三张库存表,Python 侧合并聚合(避免 UNION ALL 子查询列名问题)
|
||||||
stock_stats = db.session.query(
|
from collections import defaultdict
|
||||||
StockBuy.base_id,
|
stock_agg = defaultdict(lambda: {'qty': 0.0, 'locs': set()})
|
||||||
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()
|
|
||||||
|
|
||||||
# 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 = {
|
stock_map = {
|
||||||
stat.base_id: {
|
base_id: {
|
||||||
'qty': stat.total_qty,
|
'qty': agg['qty'],
|
||||||
'loc': stat.locations if stat.locations else ''
|
'loc': ', '.join(sorted(agg['locs'])) if agg['locs'] else ''
|
||||||
}
|
}
|
||||||
for stat in stock_stats
|
for base_id, agg in stock_agg.items()
|
||||||
}
|
}
|
||||||
|
|
||||||
# 4. 遍历组装数据(纯内存操作,极快)
|
# 4. 遍历组装数据(纯内存操作,极快)
|
||||||
@ -480,17 +486,23 @@ class BomService:
|
|||||||
|
|
||||||
parent_name = detail.get('parent_name', '')
|
parent_name = detail.get('parent_name', '')
|
||||||
|
|
||||||
# 2. 提取所有子件 ID,查询采购库存(stock_buy)
|
# 2. 提取所有子件 ID,分别查三张库存表,Python 侧合并聚合
|
||||||
child_ids = [child['child_id'] for child in detail['children']]
|
child_ids = [child['child_id'] for child in detail['children']]
|
||||||
|
|
||||||
buy_stats = db.session.query(
|
from collections import defaultdict
|
||||||
StockBuy.base_id,
|
stock_agg = defaultdict(float)
|
||||||
func.coalesce(func.sum(StockBuy.available_quantity), 0).label('total_qty')
|
|
||||||
).filter(
|
|
||||||
StockBuy.base_id.in_(child_ids)
|
|
||||||
).group_by(StockBuy.base_id).all()
|
|
||||||
|
|
||||||
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. 提取所有子件的基础物料信息(名称/规格/类型)
|
# 3. 提取所有子件的基础物料信息(名称/规格/类型)
|
||||||
materials = db.session.query(
|
materials = db.session.query(
|
||||||
@ -511,7 +523,7 @@ class BomService:
|
|||||||
dosage = float(child.get('dosage') or 0)
|
dosage = float(child.get('dosage') or 0)
|
||||||
need_qty = dosage * order_qty
|
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))
|
suggested_qty = max(0.0, min(need_qty, available_stock))
|
||||||
gap = available_stock - need_qty
|
gap = available_stock - need_qty
|
||||||
|
|
||||||
|
|||||||
@ -239,7 +239,7 @@ const handleLogout = () => {
|
|||||||
<footer v-if="!isLoginPage" class="app-footer">
|
<footer v-if="!isLoginPage" class="app-footer">
|
||||||
<span class="version-tag">
|
<span class="version-tag">
|
||||||
<el-icon style="vertical-align: middle; margin-right: 4px"><InfoFilled /></el-icon>
|
<el-icon style="vertical-align: middle; margin-right: 4px"><InfoFilled /></el-icon>
|
||||||
当前版本:V3.49
|
当前版本:V3.50
|
||||||
</span>
|
</span>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
|||||||
@ -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详情
|
// 获取BOM详情
|
||||||
export function getBomDetail(bomNo: string, version?: string) {
|
export function getBomDetail(bomNo: string, version?: string) {
|
||||||
// 去除首尾斜杠,保留中间斜杠并进行 URL 编码
|
// 去除首尾斜杠,保留中间斜杠并进行 URL 编码
|
||||||
|
|||||||
@ -437,7 +437,7 @@ import { getStockList, printSelectionList } from '@/api/inbound/stock'
|
|||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
import { submitBorrowRequest } from '@/api/transaction'
|
import { submitBorrowRequest } from '@/api/transaction'
|
||||||
import { getApproversList } from '@/api/auth'
|
import { getApproversList } from '@/api/auth'
|
||||||
import { getBomList, getBomDetail } from '@/api/bom'
|
import { getBomList, getBomDetail, getBomWithStock } from '@/api/bom'
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
@ -496,7 +496,7 @@ const treeData = computed(() => {
|
|||||||
disabled: true, // 禁止选中分类本身
|
disabled: true, // 禁止选中分类本身
|
||||||
children: (group.items || []).map((b: any) => ({
|
children: (group.items || []).map((b: any) => ({
|
||||||
value: b.bom_no,
|
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 () => {
|
const openManualSelect = async () => {
|
||||||
manualDialogVisible.value = true
|
manualDialogVisible.value = true
|
||||||
stockPage.value = 1
|
stockPage.value = 1
|
||||||
@ -674,14 +705,14 @@ const openBomSelect = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监听 BOM 选择变化,自动加载明细并计算齐套性
|
// 监听 BOM 选择变化,自动加载明细(含库存)并计算齐套性
|
||||||
watch(selectedBomNo, async (newBomNo) => {
|
watch(selectedBomNo, async (newBomNo) => {
|
||||||
if (!newBomNo) {
|
if (!newBomNo) {
|
||||||
currentBomDetail.value = []
|
currentBomDetail.value = []
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const detailRes: any = await getBomDetail(newBomNo)
|
const detailRes: any = await getBomWithStock(newBomNo)
|
||||||
currentBomDetail.value = detailRes.data?.children || []
|
currentBomDetail.value = detailRes.data?.children || []
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ElMessage.error('加载 BOM 明细失败')
|
ElMessage.error('加载 BOM 明细失败')
|
||||||
@ -693,12 +724,12 @@ const confirmBomAdd = async () => {
|
|||||||
if (!selectedBomNo.value) return ElMessage.warning('请选择 BOM')
|
if (!selectedBomNo.value) return ElMessage.warning('请选择 BOM')
|
||||||
|
|
||||||
if (stockList.value.length === 0) {
|
if (stockList.value.length === 0) {
|
||||||
await loadStockList()
|
await loadAllStockForBom()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentBomDetail.value.length === 0) {
|
if (currentBomDetail.value.length === 0) {
|
||||||
try {
|
try {
|
||||||
const detailRes: any = await getBomDetail(selectedBomNo.value)
|
const detailRes: any = await getBomWithStock(selectedBomNo.value)
|
||||||
currentBomDetail.value = detailRes.data?.children || []
|
currentBomDetail.value = detailRes.data?.children || []
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ElMessage.error('获取 BOM 详情失败')
|
ElMessage.error('获取 BOM 详情失败')
|
||||||
@ -719,7 +750,7 @@ const confirmBomAdd = async () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (stockCandidate) {
|
if (stockCandidate) {
|
||||||
const availableQty = stockCandidate.availableCount || 0
|
const availableQty = stockCandidate.available_quantity || 0
|
||||||
const actualAddQty = Math.min(needQty, availableQty)
|
const actualAddQty = Math.min(needQty, availableQty)
|
||||||
|
|
||||||
if (actualAddQty > 0) {
|
if (actualAddQty > 0) {
|
||||||
|
|||||||
@ -433,7 +433,7 @@ import { ref, computed, watch } from 'vue'
|
|||||||
import { Printer, Search, Plus, Download, List } from '@element-plus/icons-vue'
|
import { Printer, Search, Plus, Download, List } from '@element-plus/icons-vue'
|
||||||
import { ElMessage, ElTable, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElTable, ElMessageBox } from 'element-plus'
|
||||||
import { getStockList, printSelectionList } from '@/api/inbound/stock'
|
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 { useUserStore } from '@/stores/user'
|
||||||
import { submitOutboundRequest } from '@/api/outbound'
|
import { submitOutboundRequest } from '@/api/outbound'
|
||||||
import { getApproversList } from '@/api/auth'
|
import { getApproversList } from '@/api/auth'
|
||||||
@ -494,7 +494,7 @@ const treeData = computed(() => {
|
|||||||
disabled: true, // 禁止选中分类本身
|
disabled: true, // 禁止选中分类本身
|
||||||
children: (group.items || []).map((b: any) => ({
|
children: (group.items || []).map((b: any) => ({
|
||||||
value: b.bom_no,
|
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 用全量数据
|
// 手动选库存弹窗:加载服务端分页数据 + BOM 用全量数据
|
||||||
const openManualSelect = async () => {
|
const openManualSelect = async () => {
|
||||||
manualDialogVisible.value = true
|
manualDialogVisible.value = true
|
||||||
@ -707,14 +738,14 @@ const openBomSelect = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监听 BOM 选择变化,自动加载明细并计算齐套性
|
// 监听 BOM 选择变化,自动加载明细(含库存)并计算齐套性
|
||||||
watch(selectedBomNo, async (newBomNo) => {
|
watch(selectedBomNo, async (newBomNo) => {
|
||||||
if (!newBomNo) {
|
if (!newBomNo) {
|
||||||
currentBomDetail.value = []
|
currentBomDetail.value = []
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const detailRes: any = await getBomDetail(newBomNo)
|
const detailRes: any = await getBomWithStock(newBomNo)
|
||||||
currentBomDetail.value = detailRes.data?.children || []
|
currentBomDetail.value = detailRes.data?.children || []
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ElMessage.error('加载 BOM 明细失败')
|
ElMessage.error('加载 BOM 明细失败')
|
||||||
@ -726,12 +757,12 @@ const confirmBomAdd = async () => {
|
|||||||
if (!selectedBomNo.value) return ElMessage.warning('请选择 BOM')
|
if (!selectedBomNo.value) return ElMessage.warning('请选择 BOM')
|
||||||
|
|
||||||
if (stockList.value.length === 0) {
|
if (stockList.value.length === 0) {
|
||||||
await loadStockList()
|
await loadAllStockForBom()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentBomDetail.value.length === 0) {
|
if (currentBomDetail.value.length === 0) {
|
||||||
try {
|
try {
|
||||||
const detailRes: any = await getBomDetail(selectedBomNo.value)
|
const detailRes: any = await getBomWithStock(selectedBomNo.value)
|
||||||
currentBomDetail.value = detailRes.data?.children || []
|
currentBomDetail.value = detailRes.data?.children || []
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ElMessage.error('获取 BOM 详情失败')
|
ElMessage.error('获取 BOM 详情失败')
|
||||||
@ -752,7 +783,7 @@ const confirmBomAdd = async () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (stockCandidate) {
|
if (stockCandidate) {
|
||||||
const availableQty = stockCandidate.availableCount || 0
|
const availableQty = stockCandidate.available_quantity || 0
|
||||||
const actualAddQty = Math.min(needQty, availableQty)
|
const actualAddQty = Math.min(needQty, availableQty)
|
||||||
|
|
||||||
if (actualAddQty > 0) {
|
if (actualAddQty > 0) {
|
||||||
|
|||||||
@ -187,7 +187,7 @@
|
|||||||
<el-descriptions-item label="审批人">{{ detail.approver_name || '-' }}</el-descriptions-item>
|
<el-descriptions-item label="审批人">{{ detail.approver_name || '-' }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="审批时间">{{ detail.approved_at || '-' }}</el-descriptions-item>
|
<el-descriptions-item label="审批时间">{{ detail.approved_at || '-' }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="商家链接">
|
<el-descriptions-item label="商家链接">
|
||||||
<a v-if="detail.supplier_link" :href="detail.supplier_link" target="_blank" style="color: #409EFF;">
|
<a v-if="detail.supplier_link" :href="detail.supplier_link" target="_blank" style="color: #409EFF; word-break: break-all;">
|
||||||
{{ detail.supplier_link }}
|
{{ detail.supplier_link }}
|
||||||
</a>
|
</a>
|
||||||
<span v-else>-</span>
|
<span v-else>-</span>
|
||||||
@ -330,6 +330,7 @@ const statusTagType = (status: number) => {
|
|||||||
const getImageUrl = (url: string) => {
|
const getImageUrl = (url: string) => {
|
||||||
if (!url) return ''
|
if (!url) return ''
|
||||||
if (url.startsWith('http')) return url
|
if (url.startsWith('http')) return url
|
||||||
|
if (url.startsWith('/api/v1/common/files/')) return url
|
||||||
return `/api/v1/common/files/${url}`
|
return `/api/v1/common/files/${url}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user