perf: 系统级性能优化与并发安全修复

## 并发安全修复 (4处)
- scrap.py: 报废执行添加 SELECT FOR UPDATE 悲观锁,消除 TOCTOU 竞态
- stock.py (adjust_stock): 盘点调整添加 for_update=True 行锁
- outbound_service.py: 低库存预警 SMTP 调用移到 commit 之后,避免长事务
- trans_service.py: execute_dispatch 按 (source_table, id) 排序 items,消除死锁风险

## N+1 查询优化 (2处)
- inventory_task.py: _prefetch_inventory_map 单条 UNION ALL+GROUP BY 替代循环内逐条查询(N*4次→2次)
- stock.py (export_stocktake): get_borrowed_qty 批量 GROUP BY 替代逐条 TransBorrow 查询(~18000次→1次)

## BOM 列表性能重构
- bom_service.py: get_bom_list 单条 GROUP BY+string_agg+分页,消除 N+1 循环查询
- bom_service.py: 新增 get_bom_summary (轻量 GROUP BY category+COUNT)
- bom.py: 新增 /api/v1/bom/summary 路由,/list 支持 category 过滤

## Odoo 基础信息懒加载
- base_service.py: 新增 get_odoo_summary (GROUP BY category+COUNT)
- base.py: 新增 /api/v1/inbound/base/odoo-summary 路由
- buyOdoo.vue: 懒加载分组架构 (fetchOdooSummary + loadGroupItems)
- material_base.ts: 新增 getOdooSummary API

## 前端 Bug 修复
- BomManage.vue: 懒加载分组 (fetchBomSummary + loadGroupItems + collapse)
- BomManage.vue: 适配新 API 格式 (res.data.items 替代 res.data)
- buyOdoo.vue: 移除 "点击展开加载" 文字
- Selection.vue + borrow/apply/index.vue: openBomSelect 适配新 API 格式
This commit is contained in:
yueli
2026-07-15 17:37:57 +08:00
parent 4934cd4d8f
commit 2556b77530
15 changed files with 617 additions and 233 deletions

View File

@ -17,30 +17,30 @@
<el-button :icon="Search" @click="handleSearch" />
</template>
</el-input>
<el-button @click="activeCategories = bomGroups.map((g: any) => g.category)" size="small" style="margin-right: 6px;">全部展开</el-button>
<el-button @click="activeCategories = []" size="small" style="margin-right: 10px;">全部折叠</el-button>
<el-button @click="expandAllGroups" size="small" style="margin-right: 6px;">全部展开</el-button>
<el-button @click="collapseAllGroups" size="small" style="margin-right: 10px;">全部折叠</el-button>
<el-button v-if="userStore.hasPermission('bom_manage:operation')" type="primary" :icon="Plus" @click="handleCreate">新建 BOM</el-button>
</div>
</div>
</template>
<el-skeleton :rows="8" animated v-if="loading && bomGroups.length === 0" />
<el-empty v-else-if="!loading && bomGroups.length === 0" description="暂无 BOM 数据" />
<el-collapse v-else v-model="activeCategories" class="bom-category-collapse">
<el-skeleton :rows="8" animated v-if="loading && groupSummary.length === 0" />
<el-empty v-else-if="!loading && groupSummary.length === 0" description="暂无 BOM 数据" />
<el-collapse v-else v-model="activeCategories" class="bom-category-collapse" @change="handleCollapseChange">
<el-collapse-item
v-for="group in bomGroups"
v-for="group in groupedData"
:key="group.category"
:title="group.category + ' (' + group.count + ')'"
:name="group.category"
>
<el-table v-if="activeCategories.includes(group.category)" :data="group.items" border style="width: 100%">
<el-table-column v-if="hasColumnPermission('bom_no')" prop="bom_no" label="BOM编号" min-width="180" sortable>
<el-table-column v-if="hasColumnPermission('bom_no')" prop="bom_no" label="BOM编号" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<span style="cursor: pointer; color: #409EFF;" @click="handleView(row)">{{ row.bom_no }}</span>
</template>
</el-table-column>
<el-table-column v-if="hasColumnPermission('parent_name')" prop="parent_name" label="父件名称" min-width="150" show-overflow-tooltip />
<el-table-column v-if="hasColumnPermission('parent_spec')" prop="parent_spec" label="父件规格" min-width="150" show-overflow-tooltip />
<el-table-column v-if="hasColumnPermission('parent_name')" prop="parent_name" label="父件名称" min-width="160" show-overflow-tooltip />
<el-table-column v-if="hasColumnPermission('parent_spec')" prop="parent_spec" label="父件规格" min-width="180" show-overflow-tooltip />
<el-table-column v-if="hasColumnPermission('version')" label="版本" width="100" align="center">
<template #default="{ row }">
<el-tag>{{ row.version }}</el-tag>
@ -52,13 +52,16 @@
</template>
</el-table-column>
<el-table-column v-if="hasColumnPermission('child_count')" prop="child_count" label="子件数" width="80" align="center" />
<el-table-column v-if="userStore.hasPermission('bom_manage:operation')" label="操作" width="200" align="center" fixed="right">
<el-table-column v-if="userStore.hasPermission('bom_manage:operation')" label="操作" width="160" align="center" fixed="right">
<template #default="{ row }">
<el-button type="success" link @click="handleSaveAs(row)">另存为</el-button>
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="groupLoadingMap.get(group.category)" style="text-align:center;padding:12px;">
<el-icon class="is-loading"><Loading /></el-icon> 加载中...
</div>
</el-collapse-item>
</el-collapse>
</el-card>
@ -254,7 +257,7 @@ import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox, FormInstance, FormRules } from 'element-plus'
import { Plus, Search, EditPen } from '@element-plus/icons-vue'
import { useRouter } from 'vue-router'
import { getBomList, getBomDetail, saveBom, deleteBom, getDraftDetail, saveDraft, publishDraft } from '@/api/bom'
import { getBomList, getBomSummary, getBomDetail, saveBom, deleteBom, getDraftDetail, saveDraft, publishDraft } from '@/api/bom'
import { searchMaterialBase } from '@/api/inbound/buy'
import { useUserStore } from '@/stores/user'
@ -314,11 +317,27 @@ let originalChildren: ChildRow[] = []
let pendingDraftBomNo = ''
let pendingDraftVersion = ''
const bomGroups = ref([]) // 分组结构: [{category, count, items[]}]
const activeCategories = ref([]) // 默认全部展开
const searchKeyword = ref('')
const childSearchKeyword = ref('')
// ★ 懒加载分组架构
interface GroupSummary { category: string; count: number }
const groupSummary = ref<GroupSummary[]>([])
const groupCache = ref<Map<string, any[]>>(new Map())
const groupLoadingMap = ref<Map<string, boolean>>(new Map())
const activeCategories = ref<string[]>([])
const lastKeyword = ref('')
const groupedData = computed(() => {
if (!groupSummary.value.length) return []
return groupSummary.value.map(s => ({
category: s.category,
count: s.count,
items: groupCache.value.get(s.category) ?? [],
loaded: groupCache.value.has(s.category)
}))
})
const filteredChildren = computed(() => {
if (!childSearchKeyword.value) return form.children
const kw = childSearchKeyword.value.toLowerCase()
@ -330,11 +349,10 @@ const filteredChildren = computed(() => {
})
// 自动搜索:输入后 500ms 防抖触发搜索(无需回车)
watch(searchKeyword, (val) => {
// 防抖:延迟 500ms 执行,避免频繁请求
watch(searchKeyword, () => {
clearTimeout((window as any)._bomSearchTimer)
;(window as any)._bomSearchTimer = setTimeout(() => {
fetchBomList()
fetchBomSummary()
}, 500)
})
@ -465,7 +483,8 @@ const pureBomNo = computed(() => form.bom_no)
const versionOptions = computed(() => {
const ver = originalVersion || 'V1.0'
const allItems = bomGroups.value.flatMap((g: any) => g.items)
const allItems: any[] = []
groupCache.value.forEach((items) => allItems.push(...items))
const occupiedVersions = new Set(
allItems.filter((item: any) => item.bom_no === currentBomNo).map((item: any) => item.version)
)
@ -500,18 +519,61 @@ const rules = reactive<FormRules>({
const dialogTitle = ref('新建 BOM')
const handleSearch = () => {
activeCategories.value = [] // 用户主动搜索时重置折叠状态
fetchBomList()
activeCategories.value = []
groupCache.value = new Map()
fetchBomSummary()
}
const fetchBomList = async () => {
// ★ 挂载+搜索时调用 — 获取分组摘要
const fetchBomSummary = async () => {
loading.value = true
try {
const res = await getBomList({ keyword: searchKeyword.value })
if (res.code === 200) {
bomGroups.value = res.data
const params: any = {}
if (searchKeyword.value) params.keyword = searchKeyword.value
const res: any = await getBomSummary(params)
if (res?.code === 200) {
groupSummary.value = res.data ?? []
if (searchKeyword.value !== lastKeyword.value) {
activeCategories.value = []
lastKeyword.value = searchKeyword.value
}
}
} finally { loading.value = false }
} catch (e) { console.error('获取BOM摘要失败', e) }
finally { loading.value = false }
}
// ★ 默认全部展开(用户点击按钮时)
const expandAllGroups = () => {
activeCategories.value = groupSummary.value.map(g => g.category)
groupSummary.value.forEach(g => loadGroupItems(g.category))
}
const collapseAllGroups = () => { activeCategories.value = [] }
// ★ 展开分组时懒加载该分类下的 BOM
const loadGroupItems = async (category: string) => {
if (groupCache.value.has(category)) return
if (groupLoadingMap.value.get(category)) return
groupLoadingMap.value.set(category, true)
try {
const params: any = {
category: category,
page: 1,
pageSize: 9999 // 单分类内全量加载(分类内数据量可控)
}
if (searchKeyword.value) params.keyword = searchKeyword.value
const res: any = await getBomList(params)
if (res?.code === 200) {
groupCache.value.set(category, res.data?.items ?? [])
}
} catch (e) { console.error(`加载BOM分类 [${category}] 失败`, e) }
finally { groupLoadingMap.value.set(category, false) }
}
// ★ collapse @change 事件 → 触发懒加载
const handleCollapseChange = (val: string | string[]) => {
const cats = Array.isArray(val) ? val : (val ? [val] : [])
cats.forEach(c => loadGroupItems(c))
}
const onParentChange = (val: number) => {}
@ -790,7 +852,7 @@ const handleDelete = (row: BomItem) => {
.then(async () => {
try {
const res = await deleteBom(row.bom_no, row.version)
if (res.code === 200) { ElMessage.success('删除成功'); fetchBomList() }
if (res.code === 200) { ElMessage.success('删除成功'); groupCache.value = new Map(); fetchBomSummary() }
} catch (e) {}
})
.catch(() => {})
@ -872,7 +934,8 @@ const submitForm = async () => {
localStorage.removeItem('pending_bom_draft_version')
originalDraftHash.value = ''
dialogVisible.value = false
fetchBomList()
groupCache.value = new Map()
fetchBomSummary()
} else {
ElMessage.error(res.msg || '保存失败')
}
@ -891,22 +954,19 @@ onMounted(() => {
// 1. 把名称填入背景搜索框,并真正触发一次列表搜索,让背景列表也只显示该物料
searchKeyword.value = parentName;
fetchBomList();
fetchBomSummary();
// 2. 延迟等待基础渲染后进行查重
setTimeout(() => {
getBomList({ keyword: parentName }).then((res: any) => {
const groups = res.data || [];
getBomList({ keyword: parentName, page: 1, pageSize: 50 }).then((res: any) => {
// ★ 适配新 API 格式:{ items: [...], total, pages }
const flatItems = res.data?.items ?? [];
let existingBom = null;
// ★ 修复点:遍历分组 (groups) 里的 items 来查找正确的 parent_id
for (const group of groups) {
if (group.items && group.items.length > 0) {
const found = group.items.find((b: any) => b.parent_id === parentId);
if (found) {
existingBom = found;
break; // 找到了就跳出循环
}
for (const bom of flatItems) {
if (bom.parent_id === parentId) {
existingBom = bom;
break;
}
}
@ -930,8 +990,8 @@ onMounted(() => {
});
}, 300);
} else {
// 如果不是从其他页面跳转过来的,直接正常加载全部列表
fetchBomList();
// 如果不是从其他页面跳转过来的,直接正常加载摘要
fetchBomSummary();
}
})
</script>