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

@ -9,6 +9,15 @@ export function getBomList(params?: any) {
})
}
// 获取BOM分组摘要懒加载用轻量 GROUP BY category + COUNT
export function getBomSummary(params?: { keyword?: string }) {
return request({
url: '/v1/bom/summary',
method: 'get',
params
})
}
// 获取BOM详情含库存信息
export function getBomWithStock(bomNo: string) {
const trimmed = bomNo.replace(/^\/+|\/+$/g, '');

View File

@ -94,4 +94,13 @@ export function getMaterialUnitsAPI() {
url: '/inbound/base/units',
method: 'get'
})
}
// 10. Odoo 分组摘要(轻量 GROUP BY category + COUNT不 JOIN 库存表)
export function getOdooSummary(params?: { keyword?: string; isEnabled?: boolean }) {
return request({
url: '/inbound/base/odoo-summary',
method: 'get',
params
})
}

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>

View File

@ -708,8 +708,18 @@ const openBomSelect = async () => {
selectedBomNo.value = ''
currentBomDetail.value = []
try {
const res = await getBomList({ active_only: true })
bomOptions.value = res.data || []
// ★ 适配新 API 格式 { items: [...], total, pages } → 前端按 parent_category 分组
const res = await getBomList({ active_only: true, pageSize: 9999 })
const flatItems = res.data?.items ?? []
const groupMap = new Map<string, any[]>()
for (const item of flatItems) {
const cat = item.parent_category || '未分类'
if (!groupMap.has(cat)) groupMap.set(cat, [])
groupMap.get(cat)!.push(item)
}
bomOptions.value = Array.from(groupMap.entries()).map(([category, items]) => ({
category, count: items.length, items
}))
} catch (e) {
ElMessage.error('加载 BOM 列表失败')
}

View File

@ -204,7 +204,11 @@
</div>
<div v-loading="loading" class="odoo-view-container">
<el-collapse v-model="activeCategories" class="odoo-collapse">
<el-collapse
v-model="activeCategories"
class="odoo-collapse"
@change="handleCollapseChange"
>
<el-collapse-item
v-for="group in groupedData"
:key="group.category"
@ -213,7 +217,8 @@
<template #title>
<div class="odoo-group-header">
<span class="category-name">
{{ group.category || '未分类' }} ({{ group.items.length }})
{{ group.category || '未分类' }} ({{ group.count }})
<el-icon v-if="groupLoadingMap.get(group.category)" class="is-loading" style="margin-left:6px;font-size:14px;"><Loading /></el-icon>
</span>
</div>
</template>
@ -698,7 +703,7 @@
<script setup lang="ts">
import { ref, reactive, onMounted, nextTick, computed, watch } from 'vue';
import { Plus, Document, DocumentCopy, Refresh, Setting, Rank, Camera, Download, Bell, CircleCheck, Files, ZoomIn, Delete, Picture, FolderOpened } from '@element-plus/icons-vue';
import { Plus, Document, DocumentCopy, Refresh, Setting, Rank, Camera, Download, Bell, CircleCheck, Files, ZoomIn, Delete, Picture, FolderOpened, Loading } from '@element-plus/icons-vue';
import { ElMessage, ElMessageBox, ElLoading } from 'element-plus';
import type { FormInstance, FormRules } from 'element-plus';
import { useUserStore } from '@/stores/user';
@ -716,7 +721,8 @@ import {
batchSetWarning,
batchSetInspection,
markWarningOrdered,
getMaterialUnitsAPI
getMaterialUnitsAPI,
getOdooSummary
} from '@/api/material_base';
import { uploadFile, deleteFile } from '@/api/common/upload';
import { usePasteUpload } from '@/hooks/usePasteUpload';
@ -821,11 +827,10 @@ const currentCameraField = ref<'generalImage' | 'generalManual'>('generalImage')
const originalForm = ref<any>(null);
// ================= Odoo 分组核心逻辑 =================
// 强行关闭分页,使用超大 pageSize
// ================= Odoo 分组核心逻辑(懒加载架构 v2 =================
const queryParams = reactive<QueryParams>({
pageNum: 1,
pageSize: 9999,
pageSize: 50, // ★ 恢复正常分页大小,不再用 9999
keyword: '',
searchField: 'all',
category: '',
@ -838,25 +843,50 @@ const queryParams = reactive<QueryParams>({
has_stock: ''
});
// 计算属性:前端内存分组
const groupedData = computed(() => {
if (!tableData.value || !tableData.value.length) return [];
const groupMap = new Map<string, { category: string; items: MaterialBaseVO[] }>();
// ★ 新增Odoo 分组摘要(来自 /odoo-summary API
interface GroupSummary {
category: string;
count: number;
}
const groupSummary = ref<GroupSummary[]>([]);
tableData.value.forEach(item => {
const cat = item.category || '未分类';
if (!groupMap.has(cat)) {
groupMap.set(cat, { category: cat, items: [] });
}
groupMap.get(cat)!.items.push(item);
// ★ 新增:分组数据缓存 Map<category, {items, total}>
const groupCache = ref<Map<string, { items: MaterialBaseVO[]; total: number }>>(new Map());
// ★ 新增:分组加载状态
const groupLoadingMap = ref<Map<string, boolean>>(new Map());
// 当前展开的分类(支持搜索全局过滤时自动全部折叠)
const lastKeyword = ref('');
// 计算属性:基于缓存构建分组数据
const groupedData = computed(() => {
if (!groupSummary.value.length) return [];
return groupSummary.value.map(summary => {
const cached = groupCache.value.get(summary.category);
return {
category: summary.category,
count: summary.count,
items: cached?.items ?? [],
total: cached?.total ?? summary.count,
loaded: !!cached
};
});
return Array.from(groupMap.values()).sort((a, b) => a.category.localeCompare(b.category));
});
// 折叠面板展开状态
const activeCategories = ref<string[]>([]);
const expandAllGroups = () => { activeCategories.value = groupedData.value.map(g => g.category); };
const collapseAllGroups = () => { activeCategories.value = []; };
const expandAllGroups = () => {
// 展开全部 → 触发所有分组的懒加载
activeCategories.value = groupSummary.value.map(g => g.category);
groupSummary.value.forEach(g => loadGroupItems(g.category));
};
const collapseAllGroups = () => {
activeCategories.value = [];
};
// ================= 跨组表格批量选中处理 =================
const isBatchMode = ref(false);
@ -1118,27 +1148,78 @@ const querySearchType = (queryString: string, cb: any) => {
cb(results.map(item => ({ value: item })));
};
const getList = () => {
// ★ 新增:挂载时调用 - 获取分组摘要
const fetchOdooSummary = async () => {
loading.value = true;
queryParams.enableWarningSort = userStore.hasPermission('material_list:view_warning') && !queryParams.orderByColumn;
const params = {
...queryParams,
advancedFilters: JSON.stringify(queryParams.advancedFilters || [])
};
// 兼容旧版后端company=ALL 时不传过滤参数
if (params.company === 'ALL') {
delete params.company
}
listMaterialBase(params).then((response: any) => {
if (response && response.data) {
tableData.value = response.data.items;
total.value = response.data.total;
} else {
tableData.value = [];
total.value = 0;
try {
const params: any = {};
if (queryParams.keyword) params.keyword = queryParams.keyword;
if (queryParams.isEnabled !== undefined) params.isEnabled = queryParams.isEnabled;
const res: any = await getOdooSummary(params);
if (res?.code === 200) {
groupSummary.value = res.data ?? [];
// 搜索条件变更 → 清除旧缓存,折叠所有分组
groupCache.value = new Map();
groupLoadingMap.value = new Map();
if (queryParams.keyword !== lastKeyword.value) {
activeCategories.value = [];
lastKeyword.value = queryParams.keyword;
}
}
}).catch((err) => { console.error(err); tableData.value = []; })
.finally(() => { loading.value = false; });
} catch (err) {
console.error('获取 Odoo 摘要失败', err);
} finally {
loading.value = false;
}
};
// ★ 新增:展开分组时懒加载该分类下的数据
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 = {
pageNum: 1,
pageSize: 9999, // 单分类全量加载(分类内数据量通常可控)
category: category,
};
if (queryParams.keyword) params.keyword = queryParams.keyword;
if (queryParams.type) params.type = queryParams.type;
if (queryParams.isEnabled !== undefined) params.isEnabled = queryParams.isEnabled;
if (queryParams.company && queryParams.company !== 'ALL') params.company = queryParams.company;
const res: any = await listMaterialBase(params);
if (res?.code === 200 && res.data) {
groupCache.value.set(category, {
items: res.data.items ?? [],
total: res.data.total ?? 0
});
}
} catch (err) {
console.error(`加载分类 [${category}] 失败`, err);
} finally {
groupLoadingMap.value.set(category, false);
}
};
// ★ 监听 collapse 展开事件 → 触发懒加载
const handleCollapseChange = (val: string | string[]) => {
// val 是当前所有展开的分类名数组
if (Array.isArray(val)) {
val.forEach(cat => loadGroupItems(cat));
} else if (val) {
loadGroupItems(val);
}
};
const getList = () => {
// Odoo 页面不再一次性全量加载,改为 fetchOdooSummary
fetchOdooSummary();
};
const handleExport = () => {
@ -1476,7 +1557,7 @@ watch(
onMounted(() => {
initColumnPermissions();
if (!route.query.keyword) getList();
if (!route.query.keyword) fetchOdooSummary();
getOptionsList(); fetchUnitList();
if (route.query.edit_id) {

View File

@ -741,8 +741,18 @@ const openBomSelect = async () => {
selectedBomNo.value = ''
currentBomDetail.value = []
try {
const res = await getBomList({ active_only: true })
bomOptions.value = res.data || []
// ★ 适配新 API 格式 { items: [...], total, pages } → 前端按 parent_category 分组
const res = await getBomList({ active_only: true, pageSize: 9999 })
const flatItems = res.data?.items ?? []
const groupMap = new Map<string, any[]>()
for (const item of flatItems) {
const cat = item.parent_category || '未分类'
if (!groupMap.has(cat)) groupMap.set(cat, [])
groupMap.get(cat)!.push(item)
}
bomOptions.value = Array.from(groupMap.entries()).map(([category, items]) => ({
category, count: items.length, items
}))
} catch (e) {
ElMessage.error('加载 BOM 列表失败')
}