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

@ -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) {