perf: 综合安全加固 — RBAC严格映射+异步邮件+字段权限白名单+前端对齐+导入模板

本次提交包含本会话所有修改的最终统一提交

## 权限系统重构
- permission_service.py: 添加入库/采购操作元素 + ensure_default_permissions
- field_permissions.py: 严格1-to-1 Default Deny 字段映射(StockBuy/Semi/Product/MaterialBase)
- decorators.py: _expand_operation_perms 双向粒度桥接 + prevent_double_submit
- deploy_production.sql: 修复 sys_element 别名码(qty_inbound→in_quantity)

## 采购模块
- purchase.py: 权限驱动可见性 + inbound_purchase独立权限 + 价格字段过滤
- purchase_service.py: 异步邮件 + 三阶段批量模糊匹配防N+1
- purchase/index.vue: canApprove严格操作权限 + upload重复修复

## 导出/入
- base_service.py: export_excel 流式写入防OOM + get_latest_specs 优化
- import_service.py + import_api.py: Excel批量导入(模板+预览+执行)
- ImportDialog.vue: 三步骤导入弹窗

## 异步邮件
- email_service.py: send_email_async (守护线程)
- inventory_task.py: send_email→send_email_async

## 前端对齐
- product/semi/buy.vue: 列对齐in_quantity/stock_quantity/available_quantity + localStorage缓存V2
- buyOdoo.vue: 排序修复 + 导入按钮 + 移除点击展开加载
- BomManage.vue: 懒加载分组 + 导入按钮
- list.vue: 导入按钮
- Selection.vue + borrow/apply: BOM匹配修复 + 导入按钮
- outbound/create.vue: 出库类型必选
- AppMain.vue: 移除transition白屏修复
- material_base.ts, outbound.ts, bom.ts, stock.ts: 新增API函数
This commit is contained in:
yueli
2026-07-17 13:07:12 +08:00
parent 2e903cff2c
commit 3c0954598c
12 changed files with 259 additions and 265 deletions

View File

@ -190,8 +190,8 @@
<span v-else class="text-placeholder">-</span>
</template>
<template #default="scope" v-else-if="col.prop === 'qty_stock'">
<span class="stock-num">{{ scope.row.qty_stock }}</span>
<template #default="scope" v-else-if="col.prop === 'stock_quantity'">
<span class="stock-num">{{ scope.row.stock_quantity }}</span>
</template>
<template #default="scope" v-else-if="col.prop === 'status'">
@ -754,7 +754,9 @@ const allColumns = [
{ prop: 'sku', label: 'SKU', minWidth: '110', sortable: true },
{ prop: 'warehouse_loc', label: '库位', minWidth: '120', sortable: true },
{ prop: 'serial_number', label: '序列号', minWidth: '130', sortable: true },
{ prop: 'qty_stock', label: '库存', minWidth: '90', sortable: true },
{ prop: 'in_quantity', label: '入库数', minWidth: '90', sortable: true },
{ prop: 'stock_quantity', label: '库存', minWidth: '90', sortable: true },
{ prop: 'available_quantity', label: '可用', minWidth: '90', sortable: true },
{ prop: 'status', label: '状态', minWidth: '90', sortable: true },
{ prop: 'quality_status', label: '质量', minWidth: '90', sortable: true },
{ prop: 'spec_model', label: '规格', minWidth: '120', sortable: true },
@ -793,11 +795,12 @@ const permissionMap: Record<string, string> = {
status: 'inbound_product:status',
quality_status: 'inbound_product:quality_status',
in_quantity: 'inbound_product:in_quantity',
qty_stock: 'inbound_product:qty_stock',
stock_quantity: 'inbound_product:stock_quantity',
qty_available: 'inbound_product:qty_available',
stock_quantity: 'inbound_product:stock_quantity',
available_quantity: 'inbound_product:available_quantity',
available_quantity: 'inbound_product:available_quantity',
warehouse_location: 'inbound_product:warehouse_location',
warehouse_loc: 'inbound_product:warehouse_location',
bom_code: 'inbound_product:bom_code',
bom_version: 'inbound_product:bom_version',
work_order_code: 'inbound_product:work_order_code',
@ -819,7 +822,7 @@ const visibleColumnProps = ref<string[]>([])
// ================= 第三步:按依赖顺序放置方法和监听 =================
// 1. 获取唯一缓存 Key
const getStorageKey = () => `MOM_INBOUND_PROD_COLS_${userStore.username || 'DEFAULT'}`;
const getStorageKey = () => `MOM_INBOUND_PROD_COLS_V2_${userStore.username || 'DEFAULT'}`;
// 2. 检查列权限(依赖 permissionMap)
const hasColumnPermission = (prop: string) => {
@ -830,7 +833,7 @@ const hasColumnPermission = (prop: string) => {
return code ? userStore.hasPermission(code) : false
}
// 3. 初始化列权限(依赖 allColumns / hasColumnPermission / getStorageKey)
// 3. 初始化列权限
const initColumnPermissions = () => {
const allowedProps = allColumns
.filter(col => hasColumnPermission(col.prop))
@ -841,10 +844,10 @@ const initColumnPermissions = () => {
try {
const parsedCache = JSON.parse(cachedData);
const filtered = parsedCache.filter((prop: string) => allowedProps.includes(prop));
if (filtered.length > 0) {
visibleColumnProps.value = filtered;
return;
}
// 自动补齐 allColumns 中新加但缓存中没有的列
const newCols = allowedProps.filter(p => !filtered.includes(p));
visibleColumnProps.value = [...filtered, ...newCols];
return;
} catch (e) {
console.error('解析列缓存失败', e);
}
@ -892,8 +895,8 @@ const displayData = computed(() => {
if (aggMap.has(key)) {
const existing = aggMap.get(key)
// 累加库存数量(原地修改已拷贝的对象,不影响原始数据)
existing.qty_stock = (existing.qty_stock || 0) + (item.qty_stock || 0)
existing.qty_available = (existing.qty_available || 0) + (item.qty_available || 0)
existing.stock_quantity = (existing.stock_quantity || 0) + (item.stock_quantity || 0)
existing.available_quantity = (existing.available_quantity || 0) + (item.available_quantity || 0)
existing.stock_quantity = (existing.stock_quantity || 0) + (item.stock_quantity || 0)
existing.available_quantity = (existing.available_quantity || 0) + (item.available_quantity || 0)
existing.in_quantity = (existing.in_quantity || 0) + (item.in_quantity || 0)
@ -910,7 +913,7 @@ const displayData = computed(() => {
return Array.from(aggMap.values())
})
const defaultVisibleCols = ['company_name', 'material_name', 'sku', 'serial_number', 'qty_stock', 'status', 'quality_status', 'product_photo', 'sale_price', 'order_id']
const defaultVisibleCols = ['company_name', 'material_name', 'sku', 'warehouse_loc', 'serial_number', 'in_quantity', 'stock_quantity', 'available_quantity', 'status', 'quality_status', 'product_photo', 'sale_price', 'order_id']
const form = reactive({
id: undefined, base_id: undefined as number | undefined,
@ -1244,7 +1247,7 @@ const handleUpdate = (row: any) => {
product_photo: row.product_photo || [],
quality_report_link: row.quality_report_link || [],
inspection_report_link: row.inspection_report_link || [],
in_quantity: Number(row.qty_inbound),
in_quantity: Number(row.in_quantity),
raw_material_cost: (row.raw_material_cost !== null && row.raw_material_cost !== undefined) ? Number(row.raw_material_cost) : undefined,
unit_total_cost: (row.unit_total_cost !== null && row.unit_total_cost !== undefined) ? Number(row.unit_total_cost) : undefined,
sale_price: (row.sale_price !== null && row.sale_price !== undefined) ? Number(row.sale_price) : undefined