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

@ -191,11 +191,11 @@
</el-tag>
</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 === 'qty_available'">
<span class="avail-num">{{ scope.row.qty_available }}</span>
<template #default="scope" v-else-if="col.prop === 'available_quantity'">
<span class="avail-num">{{ scope.row.available_quantity }}</span>
</template>
<template #default="scope" v-else-if="col.prop === 'tax_rate'">
@ -1000,9 +1000,9 @@ const fieldOptions = computed(() => {
{ value: 'warehouse_location', label: '库位', perm: 'inbound_buy:warehouse_loc' },
{ value: 'status', label: '状态', perm: 'inbound_buy:status' },
{ value: 'inspection_status', label: '到检状态', perm: 'inbound_buy:inspection_status' },
{ value: 'qty_inbound', label: '入库量', perm: 'inbound_buy:qty_inbound' },
{ value: 'qty_stock', label: '库存数', perm: 'inbound_buy:qty_stock' },
{ value: 'qty_available', label: '可用数', perm: 'inbound_buy:qty_available' },
{ value: 'in_quantity', label: '入库量', perm: 'inbound_buy:in_quantity' },
{ value: 'stock_quantity', label: '库存数', perm: 'inbound_buy:stock_quantity' },
{ value: 'available_quantity', label: '可用数', perm: 'inbound_buy:available_quantity' },
{ value: 'unit_price', label: '不含税单价', perm: 'inbound_buy:unit_price' },
{ value: 'total_price', label: '不含税总价', perm: 'inbound_buy:total_price' },
{ value: 'tax_rate', label: '税率', perm: 'inbound_buy:tax_rate' },
@ -1048,9 +1048,9 @@ const stockColumns = [
{prop: 'sn_bn', label: '序列号/批号', minWidth: '160'},
{prop: 'status', label: '状态', minWidth: '100'},
{prop: 'inspection_status', label: '到检', minWidth: '100'},
{prop: 'qty_inbound', label: '入库量', minWidth: '100'},
{prop: 'qty_stock', label: '库存数', minWidth: '100'},
{prop: 'qty_available', label: '可用数', minWidth: '100'},
{prop: 'in_quantity', label: '入库量', minWidth: '100'},
{prop: 'stock_quantity', label: '库存数', minWidth: '100'},
{prop: 'available_quantity', label: '可用数', minWidth: '100'},
{prop: 'warehouse_loc', label: '库位', minWidth: '120'},
{prop: 'tax_rate', label: '税率', minWidth: '80'},
@ -1086,9 +1086,9 @@ const permissionMap: Record<string, string> = {
sn_bn: 'inbound_buy:sn_bn',
status: 'inbound_buy:status',
inspection_status: 'inbound_buy:inspection_status',
qty_inbound: 'inbound_buy:qty_inbound',
qty_stock: 'inbound_buy:qty_stock',
qty_available: 'inbound_buy:qty_available',
in_quantity: 'inbound_buy:in_quantity',
stock_quantity: 'inbound_buy:stock_quantity',
available_quantity: 'inbound_buy:available_quantity',
warehouse_loc: 'inbound_buy:warehouse_loc',
tax_rate: 'inbound_buy:tax_rate',
unit_price: 'inbound_buy:unit_price',
@ -1132,10 +1132,9 @@ 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;
}
const newCols = allowedProps.filter(p => !filtered.includes(p));
visibleColumnProps.value = [...filtered, ...newCols];
return;
} catch (e) {
console.error('解析列缓存失败', e);
}
@ -1605,7 +1604,7 @@ const handleUpdate = (row: any) => {
material_name: row.material_name, spec_model: row.spec_model, category: row.category,
unit: row.unit, material_type: row.material_type, sku: row.sku, barcode: row.barcode, in_date: row.inbound_date,
warehouse_location: row.warehouse_loc, status: row.status, inspection_status: row.inspection_status,
in_quantity: Number(row.qty_inbound), stock_quantity: Number(row.qty_stock), available_quantity: Number(row.qty_available),
in_quantity: Number(row.in_quantity), stock_quantity: Number(row.stock_quantity), available_quantity: Number(row.available_quantity),
unit_price: (row.unit_price !== null && row.unit_price !== undefined) ? Number(row.unit_price) : undefined,
total_price: (row.total_price !== null && row.total_price !== undefined) ? Number(row.total_price) : undefined,
tax_rate: Number(row.tax_rate),
@ -2007,7 +2006,7 @@ const resetAdvancedFilter = () => {
fetchData()
}
const isColumnSortable = (prop: string) => {
const sortableColumns = ['company_name', 'material_name', 'material_type', 'category', 'spec_model', 'unit', 'sku', 'barcode', 'inbound_date', 'serial_number', 'batch_number', 'status', 'inspection_status', 'qty_inbound', 'qty_stock', 'qty_available', 'warehouse_loc', 'unit_price', 'total_price', 'tax_rate', 'currency', 'exchange_rate', 'supplier_name', 'purchaser', 'purchaser_email', 'source_link', 'detail_link']
const sortableColumns = ['company_name', 'material_name', 'material_type', 'category', 'spec_model', 'unit', 'sku', 'barcode', 'inbound_date', 'serial_number', 'batch_number', 'status', 'inspection_status', 'in_quantity', 'stock_quantity', 'available_quantity', 'warehouse_loc', 'unit_price', 'total_price', 'tax_rate', 'currency', 'exchange_rate', 'supplier_name', 'purchaser', 'purchaser_email', 'source_link', 'detail_link']
return sortableColumns.includes(prop)
}
const handleSortChange = ({ column, prop, order }: any) => {

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

View File

@ -205,12 +205,12 @@
<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 === 'qty_available'">
<span class="avail-num">{{ scope.row.qty_available }}</span>
<template #default="scope" v-else-if="col.prop === 'available_quantity'">
<span class="avail-num">{{ scope.row.available_quantity }}</span>
</template>
<template #default="scope" v-else-if="col.prop === 'status'">
@ -753,9 +753,9 @@ const fieldOptions = computed(() => {
{ value: 'batch_number', label: '批号', perm: 'inbound_semi:sn_bn' },
{ value: 'serial_number', label: '序列号', perm: 'inbound_semi:sn_bn' },
{ value: 'warehouse_location', label: '库位', perm: 'inbound_semi:warehouse_loc' },
{ value: 'qty_inbound', label: '入库数量', perm: 'inbound_semi:qty_inbound' },
{ value: 'qty_stock', label: '当前库存', perm: 'inbound_semi:qty_stock' },
{ value: 'qty_available', label: '当前可用', perm: 'inbound_semi:qty_available' },
{ value: 'in_quantity', label: '入库数量', perm: 'inbound_semi:in_quantity' },
{ value: 'stock_quantity', label: '当前库存', perm: 'inbound_semi:stock_quantity' },
{ value: 'available_quantity', label: '当前可用', perm: 'inbound_semi:available_quantity' },
{ value: 'status', label: '库存状态', perm: 'inbound_semi:status' },
{ value: 'quality_status', label: '质量状态', perm: 'inbound_semi:quality_status' },
{ value: 'bom_code', label: 'BOM编号', perm: 'inbound_semi:bom_code' },
@ -836,9 +836,9 @@ const stockColumns = [
{prop: 'sn_bn', label: '序列号/批号', minWidth: '160', sortable: false},
{prop: 'status', label: '状态', minWidth: '100', sortable: true},
{prop: 'quality_status', label: '质量状态', minWidth: '100', sortable: true},
{prop: 'qty_inbound', label: '入库量', minWidth: '100', sortable: true},
{prop: 'qty_stock', label: '库存数', minWidth: '100', sortable: true},
{prop: 'qty_available', label: '可用数', minWidth: '100', sortable: true},
{prop: 'in_quantity', label: '入库量', minWidth: '100', sortable: true},
{prop: 'stock_quantity', label: '库存数', minWidth: '100', sortable: true},
{prop: 'available_quantity', label: '可用数', minWidth: '100', sortable: true},
{prop: 'warehouse_loc', label: '库位', minWidth: '120', sortable: true},
{prop: 'bom_code', label: 'BOM编号', minWidth: '120', sortable: true},
{prop: 'bom_version', label: 'BOM版本', minWidth: '90', sortable: true},
@ -874,11 +874,11 @@ const permissionMap: Record<string, string> = {
status: 'inbound_semi:status',
quality_status: 'inbound_semi:quality_status',
in_quantity: 'inbound_semi:in_quantity',
qty_inbound: 'inbound_semi:qty_inbound',
in_quantity: 'inbound_semi:in_quantity',
stock_quantity: 'inbound_semi:stock_quantity',
stock_quantity: 'inbound_semi:stock_quantity',
qty_stock: 'inbound_semi:qty_stock',
available_quantity: 'inbound_semi:available_quantity',
qty_available: 'inbound_semi:qty_available',
available_quantity: 'inbound_semi:available_quantity',
warehouse_location: 'inbound_semi:warehouse_location',
warehouse_loc: 'inbound_semi:warehouse_loc',
bom_code: 'inbound_semi:bom_code',
@ -923,10 +923,9 @@ 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;
}
const newCols = allowedProps.filter(p => !filtered.includes(p));
visibleColumnProps.value = [...filtered, ...newCols];
return;
} catch (e) {
console.error('解析列缓存失败', e);
}
@ -959,7 +958,7 @@ const handleCheckAllChange = (val: boolean) => {
}
};
const defaultColumns = ['company_name', 'material_name', 'spec_model', 'unit', 'inbound_date', 'sn_bn', 'status', 'quality_status', 'bom_code', 'work_order_code', 'qty_stock', 'qty_available', 'unit_total_cost', 'arrival_photo', 'quality_report_link']
const defaultColumns = ['company_name', 'material_name', 'spec_model', 'unit', 'inbound_date', 'sn_bn', 'status', 'quality_status', 'bom_code', 'work_order_code', 'stock_quantity', 'available_quantity', 'unit_total_cost', 'arrival_photo', 'quality_report_link']
const form = reactive({
id: undefined, base_id: undefined as number | undefined,
@ -1352,7 +1351,7 @@ const handleUpdate = (row: any) => {
material_name: row.material_name, spec_model: row.spec_model, category: row.category,
unit: row.unit, material_type: row.material_type, sku: row.sku, barcode: row.barcode, in_date: row.inbound_date,
warehouse_location: row.warehouse_loc, status: row.status, quality_status: row.quality_status,
in_quantity: Number(row.qty_inbound), stock_quantity: Number(row.qty_stock), available_quantity: Number(row.qty_available),
in_quantity: Number(row.in_quantity), stock_quantity: Number(row.stock_quantity), available_quantity: Number(row.available_quantity),
bom_code: row.bom_code, bom_version: row.bom_version, work_order_code: row.work_order_code,
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,