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:
@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user