## permission_service.py - init_all_menus: menu_defs 新增 inbound_purchase(采购申请) 菜单项 - init_all_menus: 创建 inbound_purchase:operation 操作元素 - 新增 ensure_default_permissions(): 启动时从 sys_user 提取活跃角色, 对空权限角色自动补全默认菜单(按角色类型分级) - 自动迁移: 已有 inbound_buy 权限的角色 → 自动获得 inbound_purchase - SUPERVISOR 启动时自动获得 inbound_purchase 菜单权限 ## decorators.py - _expand_operation_perms 改为双向桥接: 正向(inbound_buy:operation←inbound_buy:write)→通过 反向(inbound_buy←inbound_buy:operation)→通过(可编辑隐含可读) - 新增详细诊断日志,记录展开路径和失败原因 ## __init__.py - 启动时调用 PermissionService.ensure_default_permissions()
816 lines
37 KiB
Python
816 lines
37 KiB
Python
from app.models.system import SysMenu, SysElement, SysRolePermission, SysUser
|
||
from app.extensions import db
|
||
from sqlalchemy.exc import SQLAlchemyError
|
||
from sqlalchemy import func, or_
|
||
|
||
|
||
class PermissionService:
|
||
|
||
@staticmethod
|
||
def get_permission_tree():
|
||
"""
|
||
获取完整的权限树(菜单嵌套菜单 + 菜单包含元素)
|
||
供前端权限配置页面展示
|
||
"""
|
||
# 1. 获取所有可见菜单 (按 parent_id 和 sort_order 排序,保证父子处理顺序)
|
||
menus = SysMenu.query.filter(SysMenu.is_visible == True).order_by(SysMenu.parent_id, SysMenu.sort_order).all()
|
||
# 2. 获取所有元素
|
||
elements = SysElement.query.all()
|
||
|
||
# --- 核心逻辑:构建树形结构 ---
|
||
|
||
# 3. 创建一个 lookup 字典,方便通过 ID 查找菜单节点
|
||
# 同时将 SQLAlchemy 对象转为字典,方便后续操作
|
||
menu_map = {}
|
||
for m in menus:
|
||
m_dict = m.to_dict()
|
||
m_dict['children'] = [] # 初始化 children
|
||
menu_map[m.id] = m_dict
|
||
|
||
# 4. 创建 code 到 id 的映射,用于把 element 挂载到 menu 上
|
||
# 因为 SysElement 关联的是 menu_code,而不是 menu_id
|
||
code_to_id = {m.code: m.id for m in menus}
|
||
|
||
# 5. 将元素 (Elements) 挂载到对应的菜单 (Menu) 下
|
||
for el in elements:
|
||
# 找到该元素所属菜单的 ID
|
||
parent_menu_id = code_to_id.get(el.menu_code)
|
||
if parent_menu_id and parent_menu_id in menu_map:
|
||
el_dict = el.to_dict()
|
||
# 标记类型为 element,前端 transformData 需要用到
|
||
el_dict['type'] = 'element'
|
||
menu_map[parent_menu_id]['children'].append(el_dict)
|
||
|
||
# 6. 将子菜单挂载到父菜单下,并构建最终的树
|
||
tree_data = []
|
||
for m in menus:
|
||
current_node = menu_map[m.id]
|
||
|
||
if m.parent_id == 0 or m.parent_id is None:
|
||
# 如果是顶级菜单,直接放入结果集
|
||
tree_data.append(current_node)
|
||
else:
|
||
# 如果是子菜单,找到它的父级,把它塞进父级的 children 里
|
||
if m.parent_id in menu_map:
|
||
menu_map[m.parent_id]['children'].append(current_node)
|
||
else:
|
||
# 如果找不到父级(比如父级被删了),为了防止数据丢失,暂时作为顶级显示
|
||
tree_data.append(current_node)
|
||
|
||
return tree_data
|
||
|
||
@staticmethod
|
||
def get_role_permissions(role_code, company_name=None):
|
||
"""
|
||
获取指定角色拥有的所有权限Code(支持公司级别隔离)
|
||
|
||
参数:
|
||
company_name — None=不过滤, 具体值=查公司定制+全局模板权限
|
||
"""
|
||
try:
|
||
# === 超级管理员上帝模式 ===
|
||
if role_code == 'SUPER_ADMIN':
|
||
all_menus = [m.code for m in SysMenu.query.filter(SysMenu.is_visible == True).all()]
|
||
all_elements = [e.code for e in SysElement.query.all()]
|
||
return {
|
||
'menus': all_menus,
|
||
'elements': all_elements
|
||
}
|
||
|
||
# 构建公司过滤条件
|
||
if company_name:
|
||
company_filter = or_(
|
||
SysRolePermission.company_name == company_name,
|
||
SysRolePermission.company_name.is_(None),
|
||
SysRolePermission.company_name == '',
|
||
SysRolePermission.company_name == 'System'
|
||
)
|
||
else:
|
||
company_filter = True
|
||
|
||
perms = SysRolePermission.query.filter(
|
||
SysRolePermission.role_code == role_code,
|
||
company_filter
|
||
).all()
|
||
|
||
menu_codes = []
|
||
element_codes = []
|
||
|
||
for p in perms:
|
||
if p.type == 'menu':
|
||
menu_codes.append(p.target_code)
|
||
else:
|
||
element_codes.append(p.target_code)
|
||
|
||
return {
|
||
'menus': menu_codes,
|
||
'elements': element_codes
|
||
}
|
||
except Exception as e:
|
||
print(f"Error fetching role permissions: {e}")
|
||
return {'menus': [], 'elements': []}
|
||
|
||
@staticmethod
|
||
def assign_permissions(role_code, permissions, company_name=None):
|
||
"""
|
||
保存角色的权限(支持公司级别隔离)
|
||
|
||
参数:
|
||
role_code — 角色代码
|
||
permissions — 前端传来的 list,混合了 menu_code 和 element_code
|
||
company_name — 所属公司(None=全局模板,具体值=公司定制权限)
|
||
"""
|
||
if not role_code:
|
||
raise ValueError("角色代码不能为空")
|
||
|
||
session = db.session
|
||
try:
|
||
# 1. 删除该角色+该公司的旧权限(只删同公司的,不影响全局模板和其他公司)
|
||
if company_name:
|
||
old_perms = SysRolePermission.query.filter_by(
|
||
role_code=role_code, company_name=company_name
|
||
).all()
|
||
else:
|
||
# 公司为空时兼容旧逻辑:删除 company_name 为 NULL 的旧权限
|
||
old_perms = SysRolePermission.query.filter(
|
||
SysRolePermission.role_code == role_code,
|
||
or_(
|
||
SysRolePermission.company_name.is_(None),
|
||
SysRolePermission.company_name == ''
|
||
)
|
||
).all()
|
||
|
||
for p in old_perms:
|
||
db.session.delete(p)
|
||
|
||
# 2. 准备新数据
|
||
if permissions:
|
||
unique_codes = set(permissions)
|
||
all_menu_codes = {res[0] for res in session.query(SysMenu.code).all()}
|
||
|
||
new_records = []
|
||
for code in unique_codes:
|
||
if not code: continue
|
||
p_type = 'menu' if code in all_menu_codes else 'element'
|
||
|
||
new_records.append(SysRolePermission(
|
||
role_code=role_code,
|
||
target_code=code,
|
||
type=p_type,
|
||
company_name=company_name # ★ 存储公司标识
|
||
))
|
||
|
||
if new_records:
|
||
session.add_all(new_records)
|
||
|
||
# 4. 提交
|
||
session.commit()
|
||
return True
|
||
|
||
except SQLAlchemyError as e:
|
||
session.rollback()
|
||
raise e
|
||
except Exception as e:
|
||
session.rollback()
|
||
raise e
|
||
|
||
|
||
@staticmethod
|
||
def init_audit_menu():
|
||
"""
|
||
初始化审计日志菜单和超级管理员权限
|
||
防重复:只插入不存在的记录
|
||
"""
|
||
try:
|
||
# 1. 检查并创建审计日志菜单
|
||
menu_code = 'system_audit'
|
||
existing_menu = SysMenu.query.filter_by(code=menu_code).first()
|
||
|
||
if not existing_menu:
|
||
new_menu = SysMenu(
|
||
parent_id=0,
|
||
name='审计日志',
|
||
code=menu_code,
|
||
path='/system/audit',
|
||
sort_order=110,
|
||
is_visible=True
|
||
)
|
||
db.session.add(new_menu)
|
||
db.session.flush() # 获取新插入的 ID
|
||
print(f"✅ 审计日志菜单已创建 (code: {menu_code})")
|
||
else:
|
||
# ★★★ Dirty Check:仅当字段真正变化时才 add,避免触发 UPDATE 事件
|
||
is_dirty = False
|
||
if existing_menu.parent_id != 0:
|
||
existing_menu.parent_id = 0
|
||
is_dirty = True
|
||
if existing_menu.name != '审计日志':
|
||
existing_menu.name = '审计日志'
|
||
is_dirty = True
|
||
if existing_menu.path != '/system/audit':
|
||
existing_menu.path = '/system/audit'
|
||
is_dirty = True
|
||
if existing_menu.sort_order != 110:
|
||
existing_menu.sort_order = 110
|
||
is_dirty = True
|
||
if existing_menu.is_visible != True:
|
||
existing_menu.is_visible = True
|
||
is_dirty = True
|
||
if is_dirty:
|
||
db.session.add(existing_menu)
|
||
print(f"🔄 审计日志菜单已更新 (code: {menu_code})")
|
||
else:
|
||
print(f"ℹ️ 审计日志菜单已存在,无需更新 (code: {menu_code})")
|
||
|
||
# 2. 为超级管理员赋予审计日志菜单权限
|
||
role_code = 'SUPER_ADMIN'
|
||
existing_perm = SysRolePermission.query.filter_by(
|
||
role_code=role_code,
|
||
target_code=menu_code
|
||
).first()
|
||
|
||
if not existing_perm:
|
||
new_perm = SysRolePermission(
|
||
role_code=role_code,
|
||
target_code=menu_code,
|
||
type='menu'
|
||
)
|
||
db.session.add(new_perm)
|
||
print(f"✅ 超级管理员已赋予审计日志权限")
|
||
else:
|
||
print(f"ℹ️ 超级管理员已拥有审计日志权限")
|
||
|
||
# 3. 提交
|
||
db.session.commit()
|
||
return True
|
||
|
||
except Exception as e:
|
||
db.session.rollback()
|
||
print(f"❌ 初始化审计日志菜单失败: {str(e)}")
|
||
raise e
|
||
|
||
@staticmethod
|
||
def init_stocktake_menus():
|
||
"""
|
||
初始化盘点管理菜单和权限
|
||
包括:顶级菜单、盲盘作业、盈亏调整、以及操作权限元素
|
||
"""
|
||
try:
|
||
role_code = 'SUPER_ADMIN'
|
||
|
||
# 1. 创建顶级菜单:盘点管理
|
||
stocktake_mgmt_code = 'stocktake_mgmt'
|
||
stocktake_menu = SysMenu.query.filter_by(code=stocktake_mgmt_code).first()
|
||
if not stocktake_menu:
|
||
stocktake_menu = SysMenu(
|
||
parent_id=0,
|
||
name='盘点管理',
|
||
code=stocktake_mgmt_code,
|
||
path='/stocktake',
|
||
sort_order=30,
|
||
is_visible=True
|
||
)
|
||
db.session.add(stocktake_menu)
|
||
db.session.flush()
|
||
print(f"✅ 盘点管理顶级菜单已创建")
|
||
else:
|
||
is_dirty = False
|
||
if stocktake_menu.parent_id != 0: stocktake_menu.parent_id = 0; is_dirty = True
|
||
if stocktake_menu.name != '盘点管理': stocktake_menu.name = '盘点管理'; is_dirty = True
|
||
if stocktake_menu.path != '/stocktake': stocktake_menu.path = '/stocktake'; is_dirty = True
|
||
if stocktake_menu.sort_order != 30: stocktake_menu.sort_order = 30; is_dirty = True
|
||
if stocktake_menu.is_visible != True: stocktake_menu.is_visible = True; is_dirty = True
|
||
if is_dirty: db.session.add(stocktake_menu); print(f"🔄 盘点管理顶级菜单已更新")
|
||
else: print(f"ℹ️ 盘点管理顶级菜单已存在")
|
||
|
||
# 2. 创建子菜单:盲盘作业
|
||
stocktake_op_code = 'inventory_stocktake'
|
||
stocktake_op_menu = SysMenu.query.filter_by(code=stocktake_op_code).first()
|
||
if not stocktake_op_menu:
|
||
stocktake_op_menu = SysMenu(
|
||
parent_id=stocktake_menu.id,
|
||
name='盲盘作业',
|
||
code=stocktake_op_code,
|
||
path='/stocktake/operation',
|
||
sort_order=1,
|
||
is_visible=True
|
||
)
|
||
db.session.add(stocktake_op_menu)
|
||
db.session.flush()
|
||
print(f"✅ 盲盘作业菜单已创建")
|
||
else:
|
||
is_dirty = False
|
||
if stocktake_op_menu.name != '盲盘作业': stocktake_op_menu.name = '盲盘作业'; is_dirty = True
|
||
if stocktake_op_menu.path != '/stocktake/operation': stocktake_op_menu.path = '/stocktake/operation'; is_dirty = True
|
||
if stocktake_op_menu.sort_order != 1: stocktake_op_menu.sort_order = 1; is_dirty = True
|
||
if stocktake_op_menu.is_visible != True: stocktake_op_menu.is_visible = True; is_dirty = True
|
||
if is_dirty: db.session.add(stocktake_op_menu); print(f"🔄 盲盘作业菜单已更新")
|
||
else: print(f"ℹ️ 盲盘作业菜单已存在")
|
||
|
||
# 3. 为盲盘作业添加操作权限元素
|
||
stocktake_op_element = SysElement.query.filter_by(
|
||
menu_code=stocktake_op_code,
|
||
code='inventory_stocktake:operation'
|
||
).first()
|
||
if not stocktake_op_element:
|
||
stocktake_op_element = SysElement(
|
||
menu_code=stocktake_op_code,
|
||
name='盲盘操作',
|
||
code='inventory_stocktake:operation',
|
||
element_type='operation'
|
||
)
|
||
db.session.add(stocktake_op_element)
|
||
print(f"✅ 盲盘作业操作权限已创建")
|
||
else:
|
||
is_dirty = False
|
||
if stocktake_op_element.name != '盲盘操作': stocktake_op_element.name = '盲盘操作'; is_dirty = True
|
||
if stocktake_op_element.element_type != 'operation': stocktake_op_element.element_type = 'operation'; is_dirty = True
|
||
if is_dirty: db.session.add(stocktake_op_element); print(f"🔄 盲盘作业操作权限已更新")
|
||
else: print(f"ℹ️ 盲盘作业操作权限已存在")
|
||
|
||
# 4. 创建子菜单:盈亏调整
|
||
adjustment_code = 'stock_adjustment'
|
||
adjustment_menu = SysMenu.query.filter_by(code=adjustment_code).first()
|
||
if not adjustment_menu:
|
||
adjustment_menu = SysMenu(
|
||
parent_id=stocktake_menu.id,
|
||
name='盈亏调整',
|
||
code=adjustment_code,
|
||
path='/stocktake/adjustment',
|
||
sort_order=2,
|
||
is_visible=True
|
||
)
|
||
db.session.add(adjustment_menu)
|
||
db.session.flush()
|
||
print(f"✅ 盈亏调整菜单已创建")
|
||
else:
|
||
is_dirty = False
|
||
if adjustment_menu.name != '盈亏调整': adjustment_menu.name = '盈亏调整'; is_dirty = True
|
||
if adjustment_menu.path != '/stocktake/adjustment': adjustment_menu.path = '/stocktake/adjustment'; is_dirty = True
|
||
if adjustment_menu.sort_order != 2: adjustment_menu.sort_order = 2; is_dirty = True
|
||
if adjustment_menu.is_visible != True: adjustment_menu.is_visible = True; is_dirty = True
|
||
if is_dirty: db.session.add(adjustment_menu); print(f"🔄 盈亏调整菜单已更新")
|
||
else: print(f"ℹ️ 盈亏调整菜单已存在")
|
||
|
||
# 5. 为盈亏调整添加列表权限元素 (stock_adjustment:list)
|
||
adjustment_list_element = SysElement.query.filter_by(
|
||
menu_code=adjustment_code,
|
||
code='stock_adjustment:list'
|
||
).first()
|
||
if not adjustment_list_element:
|
||
adjustment_list_element = SysElement(
|
||
menu_code=adjustment_code,
|
||
name='盈亏列表',
|
||
code='stock_adjustment:list',
|
||
element_type='element'
|
||
)
|
||
db.session.add(adjustment_list_element)
|
||
print(f"✅ 盈亏调整列表权限已创建")
|
||
else:
|
||
is_dirty = False
|
||
if adjustment_list_element.name != '盈亏列表': adjustment_list_element.name = '盈亏列表'; is_dirty = True
|
||
if adjustment_list_element.element_type != 'element': adjustment_list_element.element_type = 'element'; is_dirty = True
|
||
if is_dirty: db.session.add(adjustment_list_element); print(f"🔄 盈亏调整列表权限已更新")
|
||
else: print(f"ℹ️ 盈亏调整列表权限已存在")
|
||
|
||
# 6. 为盈亏调整添加操作权限元素 (stock_adjustment:operation)
|
||
adjustment_op_element = SysElement.query.filter_by(
|
||
menu_code=adjustment_code,
|
||
code='stock_adjustment:operation'
|
||
).first()
|
||
if not adjustment_op_element:
|
||
adjustment_op_element = SysElement(
|
||
menu_code=adjustment_code,
|
||
name='盈亏操作',
|
||
code='stock_adjustment:operation',
|
||
element_type='operation'
|
||
)
|
||
db.session.add(adjustment_op_element)
|
||
print(f"✅ 盈亏调整操作权限已创建")
|
||
else:
|
||
is_dirty = False
|
||
if adjustment_op_element.name != '盈亏操作': adjustment_op_element.name = '盈亏操作'; is_dirty = True
|
||
if adjustment_op_element.element_type != 'operation': adjustment_op_element.element_type = 'operation'; is_dirty = True
|
||
if is_dirty: db.session.add(adjustment_op_element); print(f"🔄 盈亏调整操作权限已更新")
|
||
else: print(f"ℹ️ 盈亏调整操作权限已存在")
|
||
|
||
# 7. 为超级管理员分配所有盘点相关权限
|
||
menu_codes = [stocktake_mgmt_code, stocktake_op_code, adjustment_code]
|
||
for mc in menu_codes:
|
||
existing_perm = SysRolePermission.query.filter_by(
|
||
role_code=role_code,
|
||
target_code=mc,
|
||
type='menu'
|
||
).first()
|
||
if not existing_perm:
|
||
new_perm = SysRolePermission(
|
||
role_code=role_code,
|
||
target_code=mc,
|
||
type='menu'
|
||
)
|
||
db.session.add(new_perm)
|
||
print(f"✅ 超级管理员已赋予 {mc} 菜单权限")
|
||
|
||
# 8. 分配操作权限
|
||
op_codes = ['inventory_stocktake:operation', 'stock_adjustment:list', 'stock_adjustment:operation']
|
||
for oc in op_codes:
|
||
existing_perm = SysRolePermission.query.filter_by(
|
||
role_code=role_code,
|
||
target_code=oc,
|
||
type='element'
|
||
).first()
|
||
if not existing_perm:
|
||
new_perm = SysRolePermission(
|
||
role_code=role_code,
|
||
target_code=oc,
|
||
type='element'
|
||
)
|
||
db.session.add(new_perm)
|
||
print(f"✅ 超级管理员已赋予 {oc} 操作权限")
|
||
|
||
# 9. 提交
|
||
db.session.commit()
|
||
return True
|
||
|
||
except Exception as e:
|
||
db.session.rollback()
|
||
print(f"❌ 初始化盘点管理菜单失败: {str(e)}")
|
||
raise e
|
||
|
||
@staticmethod
|
||
def cleanup_legacy_stocktake_menus():
|
||
"""
|
||
清理残留的旧版库存盘点菜单
|
||
如果数据库中存在挂在入库管理下的旧版库存盘点菜单,清理掉
|
||
"""
|
||
try:
|
||
# 查找可能存在的旧版库存盘点菜单(入库管理下的 stocktake)
|
||
# 旧版可能在入库管理 (path like '%inventory%stocktake') 或者 code 包含 stocktake 但不是新的
|
||
legacy_menus = SysMenu.query.filter(
|
||
SysMenu.code.in_(['stocktake', 'inventory_stocktake_old'])
|
||
).all()
|
||
|
||
for menu in legacy_menus:
|
||
# 删除关联的权限(改为对象级删除以触发审计事件)
|
||
old_perms = SysRolePermission.query.filter_by(target_code=menu.code).all()
|
||
for p in old_perms:
|
||
db.session.delete(p)
|
||
# 删除关联的元素(改为对象级删除以触发审计事件)
|
||
old_elements = SysElement.query.filter_by(menu_code=menu.code).all()
|
||
for e in old_elements:
|
||
db.session.delete(e)
|
||
# 删除菜单
|
||
db.session.delete(menu)
|
||
print(f"🗑️ 已清理旧版库存盘点菜单: {menu.code} ({menu.name})")
|
||
|
||
db.session.commit()
|
||
return True
|
||
|
||
except Exception as e:
|
||
db.session.rollback()
|
||
print(f"⚠️ 清理旧版菜单失败: {str(e)}")
|
||
return False
|
||
|
||
@staticmethod
|
||
def init_all_menus():
|
||
"""
|
||
初始化所有菜单的层级结构,确保权限配置页面显示正确的树形结构
|
||
按照侧边栏顺序:基础信息 -> 入库 -> 盘点 -> 出库 -> BOM -> 借库 -> 报废 -> 系统
|
||
"""
|
||
try:
|
||
role_code = 'SUPER_ADMIN'
|
||
|
||
# 定义菜单结构 (code, name, path, parent_code, sort_order)
|
||
menu_defs = [
|
||
# 顶级菜单 (按侧边栏顺序)
|
||
('material_mgmt', '基础信息管理', '/material', None, 10),
|
||
('inventory_mgmt', '入库管理', '/inventory', None, 20),
|
||
('stocktake_mgmt', '盘点管理', '/stocktake', None, 30),
|
||
('outbound_mgmt', '出库管理', '/outbound', None, 40),
|
||
('bom_mgmt', 'BOM管理', '/bom', None, 50),
|
||
('operation_mgmt', '借库管理', '/operation', None, 60),
|
||
('scrap_mgmt', '报废管理', '/scrap', None, 70),
|
||
('system_mgmt', '系统管理', '/system', None, 80),
|
||
|
||
# 基础信息子菜单
|
||
('material_base', '基础信息', '/material/index', 'material_mgmt', 1),
|
||
|
||
# 入库管理子菜单
|
||
('inbound_purchase', '采购申请', '/purchase', 'inventory_mgmt', 0),
|
||
('inbound_buy', '采购入库', '/inventory/buy', 'inventory_mgmt', 1),
|
||
('inbound_semi', '半成品入库', '/inventory/semi', 'inventory_mgmt', 2),
|
||
('inbound_product', '成品入库', '/inventory/product', 'inventory_mgmt', 3),
|
||
('inbound_service', '服务权益', '/inventory/service', 'inventory_mgmt', 4),
|
||
('inbound_summary', '入库记录', '/inventory/summary', 'inventory_mgmt', 5),
|
||
|
||
# 盘点管理子菜单
|
||
('inventory_stocktake', '盲盘作业', '/stocktake/operation', 'stocktake_mgmt', 1),
|
||
('stock_adjustment', '盈亏调整', '/stocktake/adjustment', 'stocktake_mgmt', 2),
|
||
|
||
# 出库管理子菜单
|
||
('outbound_selection', '出库选单', '/outbound/selection', 'outbound_mgmt', 1),
|
||
('outbound_create', '扫码出库', '/outbound/create', 'outbound_mgmt', 2),
|
||
('outbound_list', '出库记录', '/outbound/index', 'outbound_mgmt', 3),
|
||
('outbound_approval', '出库审批', '/outbound/approval', 'outbound_mgmt', 4),
|
||
|
||
# BOM管理子菜单
|
||
('bom_manage', 'BOM配方管理', '/bom/manage', 'bom_mgmt', 1),
|
||
|
||
# 借库管理子菜单
|
||
('op_borrow', '借库操作', '/operation/borrow', 'operation_mgmt', 1),
|
||
('op_return', '归还操作', '/operation/repair', 'operation_mgmt', 2),
|
||
('op_records', '借还记录', '/operation/records', 'operation_mgmt', 3),
|
||
|
||
# 报废管理子菜单
|
||
('scrap_create', '新建报废', '/scrap/create', 'scrap_mgmt', 1),
|
||
('scrap_list', '报废记录', '/scrap/index', 'scrap_mgmt', 2),
|
||
|
||
# 系统管理子菜单
|
||
('system_user', '员工账号管理', '/system/user-create', 'system_mgmt', 1),
|
||
('system_permission', '权限分配', '/system/permission', 'system_mgmt', 2),
|
||
('system_audit', '审计日志', '/system/audit', 'system_mgmt', 3),
|
||
]
|
||
|
||
# 第一步:清理根级别的冗余子菜单(这些本应是子节点,但可能之前被错误地创建为根节点)
|
||
child_codes = [m[0] for m in menu_defs if m[3] is not None] # 所有子菜单的code
|
||
orphaned_menus = SysMenu.query.filter(
|
||
SysMenu.code.in_(child_codes),
|
||
(SysMenu.parent_id == 0) | (SysMenu.parent_id.is_(None))
|
||
).all()
|
||
for menu in orphaned_menus:
|
||
print(f"🗑️ 清理根级别冗余菜单: {menu.code} ({menu.name})")
|
||
# 删除关联的权限(改为对象级删除以触发审计事件)
|
||
old_perms = SysRolePermission.query.filter_by(target_code=menu.code).all()
|
||
for p in old_perms:
|
||
db.session.delete(p)
|
||
db.session.delete(menu)
|
||
|
||
# 第二步:清理重复菜单(同一个 code 存在多条记录,保留 ID 最小的)
|
||
# 先找出所有可能重复的 code
|
||
duplicate_check = db.session.query(
|
||
SysMenu.code,
|
||
func.count(SysMenu.id).label('cnt')
|
||
).group_by(SysMenu.code).having(func.count(SysMenu.id) > 1).all()
|
||
|
||
for code, cnt in duplicate_check:
|
||
# 获取该 code 的所有记录,按 id 排序,保留第一条,删除其余
|
||
duplicates = SysMenu.query.filter_by(code=code).order_by(SysMenu.id).all()
|
||
# 保留第一条,删除其他
|
||
for dup in duplicates[1:]:
|
||
print(f"🗑️ 清理重复菜单: {dup.code} (id={dup.id}, name={dup.name})")
|
||
# 改为对象级删除以触发审计事件
|
||
old_perms = SysRolePermission.query.filter_by(target_code=dup.code).all()
|
||
for p in old_perms:
|
||
db.session.delete(p)
|
||
old_elements = SysElement.query.filter_by(menu_code=dup.code).all()
|
||
for e in old_elements:
|
||
db.session.delete(e)
|
||
db.session.delete(dup)
|
||
|
||
# 第三步:仅当子菜单 parent_id 有误时才更新(Dirty Check)
|
||
# 遍历所有子菜单,只在 parent_id 需要修正时才触碰对象
|
||
child_codes = [m[0] for m in menu_defs if m[3] is not None]
|
||
child_menus = SysMenu.query.filter(SysMenu.code.in_(child_codes)).all()
|
||
for m in child_menus:
|
||
# 只有 parent_id 为 0 或 None(即没有正确挂载父菜单)时才更新
|
||
if m.parent_id == 0 or m.parent_id is None:
|
||
m.parent_id = None # SQLAlchemy 设为 None 表示挂载到根(parent_id=None)
|
||
db.session.add(m)
|
||
print(f"🔧 修正子菜单 parent_id: {m.code} (parent_id → None)")
|
||
|
||
# 第四步:创建或更新菜单(带字段级 Dirty Check)
|
||
# 只有至少有一个字段真正变化了才 add,避免 SQLAlchemy 产生 UPDATE 事件
|
||
menu_map = {} # code -> menu obj
|
||
|
||
for code, name, path, parent_code, sort_order in menu_defs:
|
||
menu = SysMenu.query.filter_by(code=code).first()
|
||
if not menu:
|
||
menu = SysMenu(code=code, name=name, path=path, sort_order=sort_order, is_visible=True)
|
||
db.session.add(menu)
|
||
db.session.flush()
|
||
print(f"✅ 菜单已创建: {name} ({code})")
|
||
else:
|
||
# ★★★ 字段级 Dirty Check:逐字段比较,仅在值真正变化时赋值
|
||
is_dirty = False
|
||
|
||
if menu.name != name:
|
||
menu.name = name
|
||
is_dirty = True
|
||
if menu.path != path:
|
||
menu.path = path
|
||
is_dirty = True
|
||
if menu.sort_order != sort_order:
|
||
menu.sort_order = sort_order
|
||
is_dirty = True
|
||
|
||
# 只有至少一个字段变化了才 add(触发 UPDATE)
|
||
if is_dirty:
|
||
db.session.add(menu)
|
||
print(f"🔄 菜单已更新: {name} ({code})")
|
||
|
||
menu_map[code] = menu
|
||
|
||
# 第五步:设置 parent_id(带 Dirty Check,只在值真正变化时更新)
|
||
for code, name, path, parent_code, sort_order in menu_defs:
|
||
if parent_code and parent_code in menu_map:
|
||
menu = menu_map[code]
|
||
parent = menu_map[parent_code]
|
||
# 只有 parent_id 实际变化了才赋值,避免重复触发 UPDATE
|
||
if menu.parent_id != parent.id:
|
||
menu.parent_id = parent.id
|
||
db.session.add(menu)
|
||
print(f"🔗 菜单 {code} 已挂载到父菜单 {parent_code} (id={parent.id})")
|
||
|
||
# 第六步:为超级管理员分配顶级菜单权限(只做 INSERT,不触碰已存在的记录)
|
||
for code, name, path, parent_code, sort_order in menu_defs:
|
||
if parent_code is None: # 只分配顶级菜单
|
||
existing_perm = SysRolePermission.query.filter_by(
|
||
role_code=role_code,
|
||
target_code=code,
|
||
type='menu'
|
||
).first()
|
||
if not existing_perm:
|
||
new_perm = SysRolePermission(
|
||
role_code=role_code,
|
||
target_code=code,
|
||
type='menu'
|
||
)
|
||
db.session.add(new_perm)
|
||
|
||
db.session.commit()
|
||
# ★ 采购申请操作权限元素
|
||
purchase_op = SysElement.query.filter_by(
|
||
menu_code='inbound_purchase',
|
||
code='inbound_purchase:operation'
|
||
).first()
|
||
if not purchase_op:
|
||
db.session.add(SysElement(
|
||
menu_code='inbound_purchase',
|
||
name='可编辑',
|
||
code='inbound_purchase:operation',
|
||
element_type='operation'
|
||
))
|
||
print(f"✅ 采购申请操作权限元素已创建")
|
||
|
||
print(f"✅ 所有菜单初始化完成")
|
||
return True
|
||
|
||
except Exception as e:
|
||
db.session.rollback()
|
||
print(f"❌ 初始化菜单失败: {str(e)}")
|
||
raise e
|
||
|
||
@staticmethod
|
||
def ensure_default_permissions():
|
||
"""
|
||
启动时自动为所有已知角色补充默认权限(仅补充,不覆盖已有权限)。
|
||
|
||
解决的问题:
|
||
- 重新部署/重启后,非超级管理员角色的 sys_role_permission 表为空
|
||
- 管理员不必每次都手动重新分配权限
|
||
|
||
默认分配策略(保守,仅给菜单访问权,不给操作/编辑权):
|
||
- 遍历 sys_user 表中所有非空 role 值
|
||
- 对每个角色,赋予其对应模块的顶级菜单权限
|
||
- 已有权限的角色不会被覆盖
|
||
"""
|
||
try:
|
||
# 1. 收集所有已知角色(从 sys_user.distinct role)
|
||
roles = db.session.query(SysUser.role).filter(
|
||
SysUser.role.isnot(None),
|
||
SysUser.role != '',
|
||
SysUser.status == 'active'
|
||
).distinct().all()
|
||
known_roles = [r[0] for r in roles if r[0]]
|
||
|
||
if not known_roles:
|
||
print("[默认权限] 未找到活跃角色,跳过")
|
||
return
|
||
|
||
# 2. 角色 → 应拥有的菜单列表
|
||
ROLE_DEFAULT_MENUS = {
|
||
'WAREHOUSE_MGR': [
|
||
'material_mgmt', 'inventory_mgmt', 'stocktake_mgmt',
|
||
'outbound_mgmt', 'bom_mgmt', 'operation_mgmt', 'scrap_mgmt',
|
||
'system_mgmt',
|
||
],
|
||
'WAREHOUSE_ADMIN': [
|
||
'material_mgmt', 'inventory_mgmt', 'stocktake_mgmt',
|
||
'outbound_mgmt', 'bom_mgmt', 'operation_mgmt', 'scrap_mgmt',
|
||
'system_mgmt',
|
||
],
|
||
'SUPERVISOR': [
|
||
'material_mgmt', 'inventory_mgmt', 'stocktake_mgmt',
|
||
'outbound_mgmt', 'bom_mgmt', 'operation_mgmt', 'scrap_mgmt',
|
||
'system_mgmt',
|
||
],
|
||
'PURCHASER': ['material_mgmt', 'inventory_mgmt'],
|
||
'INBOUND': ['inventory_mgmt'],
|
||
'SALES': ['outbound_mgmt'],
|
||
'OUTBOUND': ['outbound_mgmt'],
|
||
'WAREHOUSE_OP': ['inventory_mgmt', 'outbound_mgmt'],
|
||
}
|
||
|
||
# ★ SUPERVISOR 主管默认拥有采购申请的子菜单权限(确保能看到所有人的申请)
|
||
SUPERVISOR_PURCHASE_MENUS = ['inbound_purchase']
|
||
|
||
total_inserted = 0
|
||
|
||
for role_code in known_roles:
|
||
# 跳过超级管理员(已有全权限)
|
||
if role_code.upper() == 'SUPER_ADMIN':
|
||
continue
|
||
|
||
# 检查该角色是否已有权限
|
||
existing_count = SysRolePermission.query.filter_by(
|
||
role_code=role_code
|
||
).count()
|
||
|
||
if existing_count > 0:
|
||
print(f"[默认权限] {role_code} 已有 {existing_count} 条权限,跳过")
|
||
continue
|
||
|
||
# 获取该角色应获得的默认菜单
|
||
default_menus = ROLE_DEFAULT_MENUS.get(
|
||
role_code,
|
||
['material_mgmt', 'inventory_mgmt', 'outbound_mgmt'] # 未知角色:只给基础三模块
|
||
)
|
||
|
||
for menu_code in default_menus:
|
||
# 验证菜单存在
|
||
menu = SysMenu.query.filter_by(code=menu_code).first()
|
||
if not menu:
|
||
continue
|
||
|
||
existing = SysRolePermission.query.filter_by(
|
||
role_code=role_code,
|
||
target_code=menu_code,
|
||
type='menu'
|
||
).first()
|
||
if existing:
|
||
continue
|
||
|
||
db.session.add(SysRolePermission(
|
||
role_code=role_code,
|
||
target_code=menu_code,
|
||
type='menu'
|
||
))
|
||
total_inserted += 1
|
||
|
||
print(f"[默认权限] {role_code} ← 已补充 {total_inserted} 条默认菜单权限")
|
||
|
||
# ★ 自动迁移:已有 inbound_buy 权限的角色 → 自动获得 inbound_purchase
|
||
for role_code in known_roles:
|
||
if role_code.upper() == 'SUPER_ADMIN':
|
||
continue
|
||
has_inbound_buy = SysRolePermission.query.filter_by(
|
||
role_code=role_code, target_code='inbound_buy', type='menu'
|
||
).first()
|
||
if has_inbound_buy:
|
||
has_purchase = SysRolePermission.query.filter_by(
|
||
role_code=role_code, target_code='inbound_purchase', type='menu'
|
||
).first()
|
||
if not has_purchase:
|
||
db.session.add(SysRolePermission(
|
||
role_code=role_code, target_code='inbound_purchase', type='menu'
|
||
))
|
||
# 同时迁移操作权限
|
||
has_buy_op = SysRolePermission.query.filter_by(
|
||
role_code=role_code, target_code='inbound_buy:operation', type='element'
|
||
).first()
|
||
if has_buy_op:
|
||
has_purchase_op = SysRolePermission.query.filter_by(
|
||
role_code=role_code, target_code='inbound_purchase:operation', type='element'
|
||
).first()
|
||
if not has_purchase_op:
|
||
db.session.add(SysRolePermission(
|
||
role_code=role_code, target_code='inbound_purchase:operation', type='element'
|
||
))
|
||
print(f"[权限迁移] {role_code}: inbound_buy → inbound_purchase 已自动迁移")
|
||
total_inserted += 1
|
||
|
||
# ★ SUPERVISOR 主管默认获得采购申请子菜单权限
|
||
for role_code in known_roles:
|
||
if role_code.upper() == 'SUPERVISOR':
|
||
for menu_code in SUPERVISOR_PURCHASE_MENUS:
|
||
menu = SysMenu.query.filter_by(code=menu_code).first()
|
||
if not menu:
|
||
continue
|
||
existing = SysRolePermission.query.filter_by(
|
||
role_code=role_code, target_code=menu_code, type='menu'
|
||
).first()
|
||
if not existing:
|
||
db.session.add(SysRolePermission(
|
||
role_code=role_code, target_code=menu_code, type='menu'
|
||
))
|
||
total_inserted += 1
|
||
print(f"[默认权限] SUPERVISOR ← 已补充采购申请菜单权限")
|
||
|
||
if total_inserted > 0:
|
||
db.session.commit()
|
||
print(f"[默认权限] 总计为 {sum(1 for r in known_roles if r.upper() != 'SUPER_ADMIN')} 个角色补充了默认权限")
|
||
|
||
except Exception as e:
|
||
db.session.rollback()
|
||
print(f"[默认权限] 补充失败(不阻断启动): {e}")
|