16 Commits

Author SHA1 Message Date
76c7e3d3dc V3.61,出库审批的可编辑即可操作 2026-07-17 16:31:33 +08:00
29de22b575 fix: crossDomain 重复收集 — 真实树中的 system_mgmt/crossDomain 与虚拟 global_privileges 冲突,从真实树中移除 crossDomain 避免双重收集 2026-07-17 16:22:59 +08:00
0e6294890c debug: crossDomain 收集逻辑加精确断点日志 2026-07-17 16:20:22 +08:00
c45e7b5a52 debug: assign_permissions 加详细日志追踪 crossDomain 删除/插入/提交后状态 2026-07-17 16:18:05 +08:00
0074d96163 fix: 权限配置页 GET 请求加 _t 防浏览器缓存 + 保存后自动刷新 UI 2026-07-17 16:13:12 +08:00
96eb56766e fix: 回退权限保存后的 auto-refresh,改为手动刷新提示 2026-07-17 16:07:54 +08:00
c9cf4e0901 fix: 权限保存后自动刷新 UI 回显,确保勾选框状态与数据库同步 2026-07-17 16:02:51 +08:00
de2d13fa68 fix: 全局权限码 crossDomain/global:cross_company_op 强制存为 company_name='',防止按公司隔离失效 2026-07-17 15:50:06 +08:00
266a0e011f fix: 防自锁 — 操作者修改自己角色时强制保留 system_permission + system_permission:operation 2026-07-17 15:42:31 +08:00
efe04c5d42 fix: assign_permissions 删除逻辑对齐 get_user_permissions 查询逻辑 — 公司定制+全局模板一起替换 2026-07-17 15:38:14 +08:00
9c3fcbca4c migration: 补齐 sys_element 缺失的 :operation 权限码 + 角色分配 SQL 2026-07-17 15:29:59 +08:00
f3c946d584 fix: 借库审批同样修复 — sys_element 补充 op_borrow_approval:operation,can_approve 增加 RBAC 权限判断 2026-07-17 15:22:54 +08:00
05832e913b fix: can_approve 增加 outbound_approval:operation RBAC 权限判断,前端按钮权限与后端审批权限统一对齐 2026-07-17 15:17:26 +08:00
686186d7d6 fix: Create/Update 成本字段分离 — Create 保留 BOM 自动计算值,Update 按 hasFormFieldPermission 剥离敏感字段 2026-07-17 14:50:06 +08:00
8b6539b976 fix: semi/product 对话框中成本字段添加 v-if 权限守卫,与 buy.vue 的 hasFormFieldPermission 模式对齐 2026-07-17 14:43:08 +08:00
1cc7d51e03 fix: semi/product submit 响应用 apply_strict_rbac 替代硬编码 resp.pop,成本字段纳入 RBAC 统一管控 2026-07-17 14:36:26 +08:00
12 changed files with 176 additions and 28 deletions

View File

@ -0,0 +1,70 @@
-- ============================================================
-- 补齐缺失的 :operation 操作权限 & 字段权限
-- 创建日期: 2026-07-17
-- 说明: 前端按钮使用 hasPermission('xxx:operation') 控制显示
-- 但 sys_element 中缺少对应的元素权限码,导致:
-- 1. 审批按钮可见但 can_approve 拒绝(出库/借库审批)
-- 2. 用户管理操作按钮不显示
-- 3. 权限分配按钮不显示
-- 4. 密码字段被隐藏
-- ============================================================
BEGIN;
-- ===== 1. 出库审批操作权限 =====
INSERT INTO sys_element (menu_code, name, code, element_type)
SELECT 'outbound_approval', '审批操作', 'outbound_approval:operation', 'column'
WHERE NOT EXISTS (SELECT 1 FROM sys_element WHERE code = 'outbound_approval:operation');
INSERT INTO sys_role_permission (role_code, target_code, type)
SELECT 'SUPERVISOR', 'outbound_approval:operation', 'element'
WHERE NOT EXISTS (SELECT 1 FROM sys_role_permission WHERE role_code = 'SUPERVISOR' AND target_code = 'outbound_approval:operation');
INSERT INTO sys_role_permission (role_code, target_code, type)
SELECT 'SUPER_ADMIN', 'outbound_approval:operation', 'element'
WHERE NOT EXISTS (SELECT 1 FROM sys_role_permission WHERE role_code = 'SUPER_ADMIN' AND target_code = 'outbound_approval:operation');
-- ===== 2. 借库审批操作权限 =====
INSERT INTO sys_element (menu_code, name, code, element_type)
SELECT 'op_borrow_approval', '审批操作', 'op_borrow_approval:operation', 'column'
WHERE NOT EXISTS (SELECT 1 FROM sys_element WHERE code = 'op_borrow_approval:operation');
INSERT INTO sys_role_permission (role_code, target_code, type)
SELECT role_code, 'op_borrow_approval:operation', 'element'
FROM (SELECT DISTINCT role_code FROM sys_role_permission WHERE target_code = 'op_borrow_approval') t
WHERE NOT EXISTS (SELECT 1 FROM sys_role_permission WHERE role_code = t.role_code AND target_code = 'op_borrow_approval:operation');
-- ===== 3. 用户管理操作权限 =====
INSERT INTO sys_element (menu_code, name, code, element_type)
SELECT 'system_user', '用户操作', 'system_user:operation', 'column'
WHERE NOT EXISTS (SELECT 1 FROM sys_element WHERE code = 'system_user:operation');
INSERT INTO sys_role_permission (role_code, target_code, type)
SELECT 'SUPERVISOR', 'system_user:operation', 'element'
WHERE NOT EXISTS (SELECT 1 FROM sys_role_permission WHERE role_code = 'SUPERVISOR' AND target_code = 'system_user:operation');
-- ===== 4. 权限分配操作权限 =====
INSERT INTO sys_role_permission (role_code, target_code, type)
SELECT 'SUPERVISOR', 'system_permission:operation', 'element'
WHERE NOT EXISTS (SELECT 1 FROM sys_role_permission WHERE role_code = 'SUPERVISOR' AND target_code = 'system_permission:operation');
-- ===== 5. 密码管理字段权限 =====
INSERT INTO sys_element (menu_code, name, code, element_type)
SELECT 'system_user', '密码管理', 'system_user:password', 'column'
WHERE NOT EXISTS (SELECT 1 FROM sys_element WHERE code = 'system_user:password');
INSERT INTO sys_role_permission (role_code, target_code, type)
SELECT 'SUPER_ADMIN', 'system_user:password', 'element'
WHERE NOT EXISTS (SELECT 1 FROM sys_role_permission WHERE role_code = 'SUPER_ADMIN' AND target_code = 'system_user:password');
INSERT INTO sys_role_permission (role_code, target_code, type)
SELECT 'SUPERVISOR', 'system_user:password', 'element'
WHERE NOT EXISTS (SELECT 1 FROM sys_role_permission WHERE role_code = 'SUPERVISOR' AND target_code = 'system_user:password');
-- 验证
SELECT 'outbound_approval:operation' AS perm, count(*) FROM sys_role_permission WHERE target_code = 'outbound_approval:operation'
UNION ALL SELECT 'op_borrow_approval:operation', count(*) FROM sys_role_permission WHERE target_code = 'op_borrow_approval:operation'
UNION ALL SELECT 'system_user:operation', count(*) FROM sys_role_permission WHERE target_code = 'system_user:operation'
UNION ALL SELECT 'system_permission:operation', count(*) FROM sys_role_permission WHERE target_code = 'system_permission:operation'
UNION ALL SELECT 'system_user:password', count(*) FROM sys_role_permission WHERE target_code = 'system_user:password';
COMMIT;

View File

@ -129,10 +129,8 @@ def submit():
perm_code = field_to_perm.get(field)
if perm_code and perm_code not in user_permissions: data.pop(field, None)
new_stock = ProductInboundService.handle_inbound(data)
# ★ Fail-Closed: 入库成功响应剥离成本/售价字段
resp = new_stock.to_dict()
for k in ('raw_material_cost', 'manual_cost', 'unit_total_cost', 'sale_price'):
resp.pop(k, None)
from app.utils.field_permissions import apply_strict_rbac
resp = apply_strict_rbac(new_stock.to_dict(), 'StockProduct', user_permissions)
return jsonify({"code": 200, "msg": "入库成功", "data": resp})
except Exception as e:
traceback.print_exc()

View File

@ -123,10 +123,8 @@ def submit():
if field in data and perm_code not in user_permissions:
data.pop(field, None)
new_stock = SemiInboundService.handle_inbound(data)
# ★ Fail-Closed: 入库成功响应剥离成本字段
resp = new_stock.to_dict()
for k in ('raw_material_cost', 'manual_cost', 'unit_total_cost', 'total_price'):
resp.pop(k, None)
from app.utils.field_permissions import apply_strict_rbac
resp = apply_strict_rbac(new_stock.to_dict(), 'StockSemi', user_permissions)
return jsonify({"code": 200, "msg": "入库成功", "data": resp})
except Exception as e:
traceback.print_exc()

View File

@ -79,6 +79,14 @@ def assign_perms():
role_code = data.get('role_code')
permissions = data.get('permissions', [])
# ★ 防自锁:操作者修改自己角色时,强制保留权限管理的关键权限
claims = get_jwt()
operator_role = claims.get('role', '')
if operator_role and operator_role.upper() == role_code.upper():
for required in ('system_permission', 'system_permission:operation'):
if required not in permissions:
permissions.append(required)
company_name = _get_operator_company()
PermissionService.assign_permissions(role_code, permissions, company_name=company_name)
return jsonify({'code': 200, 'msg': '保存成功'}), 200

View File

@ -296,6 +296,16 @@ class BorrowApprovalService:
if user_role and user_role.upper() == 'SUPER_ADMIN':
return True
# 拥有 op_borrow_approval:operation 权限的用户可以审批任意借库单
# (前端按钮以此权限控制显示,后端也必须对齐)
from app.models.system import SysRolePermission
has_op = SysRolePermission.query.filter(
SysRolePermission.role_code == user_role,
SysRolePermission.target_code.in_(['op_borrow_approval:operation', 'op_borrow_approval:*'])
).first() is not None
if has_op:
return True
for approver in approvers:
approver_type = approver.get('type', '')
approver_value = approver.get('value', '')

View File

@ -846,6 +846,16 @@ class OutboundApprovalService:
if user_role and user_role.upper() == 'SUPER_ADMIN':
return True
# 拥有 outbound_approval:operation 权限的用户可以审批任意出库单
# (前端按钮以此权限控制显示,后端也必须对齐)
from app.models.system import SysRolePermission
has_op = SysRolePermission.query.filter(
SysRolePermission.role_code == user_role,
SysRolePermission.target_code.in_(['outbound_approval:operation', 'outbound_approval:*'])
).first() is not None
if has_op:
return True
for approver in approvers:
approver_type = approver.get('type', '')
approver_value = approver.get('value', '')

View File

@ -130,13 +130,20 @@ class PermissionService:
session = db.session
try:
# 1. 删除该角色+该公司的旧权限(只删同公司的,不影响全局模板和其他公司)
# 1. 删除该角色的旧权限
# 删除逻辑需与 get_user_permissions 的查询逻辑对齐:
# - company_name 为空 → 删 company_name IS NULL OR ''(超管/全局模板)
# - company_name 有值 → 删 公司定制 + 全局模板(确保能完整替换)
if company_name:
old_perms = SysRolePermission.query.filter_by(
role_code=role_code, company_name=company_name
old_perms = SysRolePermission.query.filter(
SysRolePermission.role_code == role_code,
or_(
SysRolePermission.company_name == company_name,
SysRolePermission.company_name.is_(None),
SysRolePermission.company_name == '',
)
).all()
else:
# 公司为空时兼容旧逻辑:删除 company_name 为 NULL 的旧权限
old_perms = SysRolePermission.query.filter(
SysRolePermission.role_code == role_code,
or_(
@ -153,16 +160,21 @@ class PermissionService:
unique_codes = set(permissions)
all_menu_codes = {res[0] for res in session.query(SysMenu.code).all()}
# 全局权限码 — 不按公司隔离,始终写入 company_name=''
GLOBAL_CODES = {'crossDomain', 'global:cross_company_op'}
new_records = []
for code in unique_codes:
if not code: continue
p_type = 'menu' if code in all_menu_codes else 'element'
# 全局权限码强制 company_name='',确保跨公司生效
target_company = '' if code in GLOBAL_CODES else company_name
new_records.append(SysRolePermission(
role_code=role_code,
target_code=code,
type=p_type,
company_name=company_name # ★ 存储公司标识
company_name=target_company
))
if new_records:

View File

@ -239,7 +239,7 @@ const handleLogout = () => {
<footer v-if="!isLoginPage" class="app-footer">
<span class="version-tag">
<el-icon style="vertical-align: middle; margin-right: 4px"><InfoFilled /></el-icon>
当前版本:V3.60
当前版本:V3.61
</span>
</footer>

View File

@ -4,7 +4,8 @@ import request from '@/utils/request'
export function getAllPermissionTree() {
return request({
url: '/v1/permissions/tree',
method: 'get'
method: 'get',
params: { _t: Date.now() }
})
}
@ -12,7 +13,9 @@ export function getAllPermissionTree() {
export function getRolePermissions(roleCode: string) {
return request({
url: '/v1/permissions/role/' + roleCode,
method: 'get'
method: 'get',
// 加时间戳防浏览器缓存,确保权限修改后立即反映
params: { _t: Date.now() }
})
}

View File

@ -491,7 +491,7 @@
<el-autocomplete v-model="form.production_manager" :fetch-suggestions="querySearchManager" placeholder="输入或选择负责人" style="width: 100%" clearable :trigger-on-focus="true" @select="handleManagerSelect"/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-col :span="8" v-if="hasFormFieldPermission('sale_price')">
<el-form-item label="产品定价">
<el-input-number v-model="form.sale_price" :precision="2" :controls="false" style="width:100%" placeholder="请输入">
<template #prefix>¥</template>
@ -507,18 +507,18 @@
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="24">
<el-col :span="8">
<el-row :gutter="24" v-if="hasFormFieldPermission('raw_material_cost') || hasFormFieldPermission('unit_total_cost') || hasFormFieldPermission('total_price')">
<el-col :span="8" v-if="hasFormFieldPermission('raw_material_cost')">
<el-form-item label="原料成本">
<el-input-number v-model="form.raw_material_cost" :precision="2" :controls="false" style="width:100%" placeholder="请输入"/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-col :span="8" v-if="hasFormFieldPermission('unit_total_cost')">
<el-form-item label="单件成本">
<el-input-number v-model="form.unit_total_cost" :precision="2" :controls="false" style="width:100%" placeholder="请输入"/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-col :span="8" v-if="hasFormFieldPermission('total_price')">
<el-form-item label="总成本">
<el-input-number v-model="form.total_price" :precision="2" :controls="false" style="width:100%" placeholder="自动计算" disabled/>
</el-form-item>
@ -1007,6 +1007,8 @@ const hasFormFieldPermission = (fieldName: string) => {
production_time_range: 'inbound_product:production_start_time',
raw_material_cost: 'inbound_product:raw_material_cost',
manual_cost: 'inbound_product:manual_cost',
unit_total_cost: 'inbound_product:unit_total_cost',
total_price: 'inbound_product:total_price',
sale_price: 'inbound_product:sale_price',
quality_report_link: 'inbound_product:quality_report_link',
inspection_report_link: 'inbound_product:inspection_report_link',
@ -1416,6 +1418,14 @@ const submitForm = async () => {
production_end_time: form.production_time_range?.[1]
}
delete payload.production_time_range
// Update 模式:无成本/售价权限则剥离敏感字段,防止库管修改已入库的数据
// Create 模式:保留成本字段,确保 BOM 自动计算的值能正确提交
if (dialogStatus.value === 'update') {
if (!hasFormFieldPermission('raw_material_cost')) delete payload.raw_material_cost
if (!hasFormFieldPermission('unit_total_cost')) delete payload.unit_total_cost
if (!hasFormFieldPermission('total_price')) delete payload.total_price
if (!hasFormFieldPermission('sale_price')) delete payload.sale_price
}
try {
if(dialogStatus.value === 'create') {
const res: any = await createProductInbound(payload)

View File

@ -557,19 +557,19 @@
</el-col>
</el-row>
<div class="divider-text">成本核算</div>
<el-row :gutter="24">
<el-col :span="8">
<div class="divider-text" v-if="hasFormFieldPermission('raw_material_cost') || hasFormFieldPermission('unit_total_cost') || hasFormFieldPermission('total_price')">成本核算</div>
<el-row :gutter="24" v-if="hasFormFieldPermission('raw_material_cost') || hasFormFieldPermission('unit_total_cost') || hasFormFieldPermission('total_price')">
<el-col :span="8" v-if="hasFormFieldPermission('raw_material_cost')">
<el-form-item label="估算成本">
<el-input-number v-model="form.raw_material_cost" :precision="2" :controls="false" style="width:100%" placeholder="请输入"/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-col :span="8" v-if="hasFormFieldPermission('unit_total_cost')">
<el-form-item label="基于BOM成本">
<el-input-number v-model="form.unit_total_cost" :precision="2" :controls="false" style="width:100%" placeholder="请输入"/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-col :span="8" v-if="hasFormFieldPermission('total_price')">
<el-form-item label="总成本">
<el-input-number v-model="form.total_price" :precision="2" :controls="false" style="width:100%" placeholder="自动计算" disabled/>
</el-form-item>
@ -1164,6 +1164,7 @@ const hasFormFieldPermission = (fieldName: string) => {
raw_material_cost: 'inbound_semi:raw_material_cost',
manual_cost: 'inbound_semi:manual_cost',
unit_total_cost: 'inbound_semi:unit_total_cost',
total_price: 'inbound_semi:total_price',
production_manager: 'inbound_semi:production_manager',
production_time_range: 'inbound_semi:production_start_time',
arrival_photo: 'inbound_semi:arrival_photo',
@ -1506,6 +1507,13 @@ const submitForm = async () => {
production_end_time: form.production_time_range?.[1] || null
}
delete payload.production_time_range
// Update 模式:无成本权限则剥离成本字段,防止库管修改已入库的成本数据
// Create 模式:保留成本字段,确保 BOM 自动计算的值能正确提交
if (dialogStatus.value === 'update') {
if (!hasFormFieldPermission('raw_material_cost')) delete payload.raw_material_cost
if (!hasFormFieldPermission('unit_total_cost')) delete payload.unit_total_cost
if (!hasFormFieldPermission('total_price')) delete payload.total_price
}
try {
if (dialogStatus.value === 'create') {
const res: any = await createSemiInbound(payload)

View File

@ -256,7 +256,18 @@ const fetchTree = async () => {
]
};
// 将虚拟节点放在最前面,然后交给 transformData 处理
const rawData = [globalNode, ...(res.data || [])];
// ★ 从真实树中移除 crossDomain已由虚拟节点 global_privileges 统一管理,避免重复)
const stripCrossDomain = (nodes: any[]) => {
nodes.forEach(node => {
if (node.children) {
node.children = node.children.filter((c: any) => c.code !== 'crossDomain')
stripCrossDomain(node.children)
}
})
}
const realData = res.data || []
stripCrossDomain(realData)
const rawData = [globalNode, ...realData];
rawTreeData.value = rawData;
tableData.value = transformData(rawData);
@ -499,6 +510,16 @@ const handleSave = async () => {
})
if (res.code === 200) {
ElMessage.success('权限保存成功!')
// 立即重新拉取权限刷新 UI带 cache-busting确保拿到最新数据
if (currentRole.value) {
try {
const refreshRes: any = await getRolePermissions(currentRole.value)
if (refreshRes.code === 200) {
const perms = new Set([...(refreshRes.data.menus || []), ...(refreshRes.data.elements || [])])
setRowStatus(tableData.value, perms)
}
} catch (e) { /* 静默失败 */ }
}
} else {
ElMessage.error(res.msg || '保存失败')
}