Files
KCGL/inventory-backend/app/services/permission_service.py
yueli dec33b685c feat(purchase): 新增待采购池接口(防重复采购)
GET /api/v1/purchase/pending-pool:找出「有效供给仍低于预警线」的物料。

  有效供给 = 物理总库存 + 在途量
  过滤规则 = 有效供给 <= 红/黄阈值 才进池
  建议采购量 = max(1, 目标阈值 - 有效供给)

★ 为什么不用「有活跃单就排除」:红线 10、库存 0、某采购员只建了一张
  数量 5 的单时,该物料会立刻从池中消失,剩下 5 个缺口永远无人认领。
  改为按量计算后它继续留池,suggested_qty 自动降到 5。

★ 用 <= 而非 < 是与业务方确认后刻意维持的口径(与物料列表预警、预警邮件
  同源),勿擅自改成严格小于 —— 只改本接口会造成「列表亮黄灯、池子却排除」
  的撕裂,真要改必须三处一起动。代价是恰好等于阈值时建议量落到保底的 1,
  前端 tooltip 已单独说明,不展示算不成立的错误算式。

权限:新增 inbound_purchase:pending_pool(注册为 sys_element 而非新建
SysMenu)。因 ensure_default_permissions 在角色已有权限时整段跳过,另写了
存量角色自动迁移,并按源行镜像 company_name 避免跨公司作用域泄漏。
2026-09-18 14:30:53 +08:00

908 lines
43 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
# ★ 诊断:打印入库操作权限
inbound_ops = [c for c in element_codes if 'inbound_buy' in c or 'inbound_semi' in c or 'inbound_product' in c]
if inbound_ops:
print(f"[诊断] {role_code} 拥有的入库操作权限: {inbound_ops}")
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. 删除该角色的旧权限
# 删除逻辑需与 get_user_permissions 的查询逻辑对齐:
# - company_name 为空 → 删 company_name IS NULL OR ''(超管/全局模板)
# - company_name 有值 → 删 公司定制 + 全局模板(确保能完整替换)
if 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:
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()}
# 全局权限码 — 不按公司隔离,始终写入 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=target_company
))
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)
# ★ 入库模块操作权限元素(之前缺失导致"可编辑"勾了也不能入库)
inbound_op_elements = [
('inbound_buy', 'inbound_buy:operation', '可编辑', 'operation'),
('inbound_semi', 'inbound_semi:operation', '可编辑', 'operation'),
('inbound_product', 'inbound_product:operation', '可编辑', 'operation'),
('inbound_service', 'inbound_service:operation', '可编辑', 'operation'),
]
for menu_code, code, name, etype in inbound_op_elements:
existing = SysElement.query.filter_by(menu_code=menu_code, code=code).first()
if not existing:
db.session.add(SysElement(menu_code=menu_code, name=name, code=code, element_type=etype))
print(f"✅ 入库操作元素已创建: {code}")
# ★ 采购申请权限元素
purchase_elements = [
('inbound_purchase:operation', '可编辑', 'operation'),
('inbound_purchase:unit_price', '采购单价', 'column'),
('inbound_purchase:total_price', '采购总价', 'column'),
('inbound_purchase:tax_rate', '税率', 'column'),
# ★ 待采购清单(防重复采购)菜单权限。
# 注册成 element 而非新建 SysMenu:侧边栏与路由守卫只读前端
# meta.permissions + sys_role_permission,从不读 sys_menu;
# 且 init_all_menus 的「冗余子菜单清理」会按 target_code 删权限行,
# 新增 SysMenu 等于多一个被该清理逻辑波及的面。
('inbound_purchase:pending_pool', '待采购清单', 'operation'),
]
for code, name, etype in purchase_elements:
existing = SysElement.query.filter_by(
menu_code='inbound_purchase', code=code
).first()
if not existing:
db.session.add(SysElement(
menu_code='inbound_purchase', name=name,
code=code, element_type=etype
))
print(f"✅ 采购申请元素已创建: {code}")
db.session.commit()
# ★ 诊断:打印入库模块的元素和权限状态
for mc in ('inbound_buy', 'inbound_semi', 'inbound_product', 'inbound_service'):
elems = SysElement.query.filter_by(menu_code=mc).all()
codes = [e.code for e in elems]
print(f"[诊断] {mc} 的 sys_element: {codes}")
role_count = SysRolePermission.query.filter_by(
target_code=f'{mc}:operation', type='element'
).count()
print(f"[诊断] {mc}:operation 已分配给 {role_count} 个角色")
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
# ★ 自动迁移:已有「采购申请」菜单权限的角色 → 自动获得「待采购清单」权限
#
# 为什么必须独立成段:上面的默认分配逻辑在角色**已有任何权限**时
# 就整段跳过(见上方 existing_count > 0 的分支),存量角色根本走不到
# 那里。不写这一段,除了超管之外没有任何角色能拿到新权限。
#
# 本段依赖 autoflush:上一个循环刚 add 的 inbound_purchase 行尚未
# commit,这里的查询必须能看见它们(扩展默认 autoflush=True 保证了这点)。
for role_code in known_roles:
if role_code.upper() == 'SUPER_ADMIN':
continue
# 以「采购申请菜单权限」为门控 —— 能进采购页的角色就该看到待采购清单。
# ★ 同时镜像它的 company_name:若某角色的采购菜单是公司定制的
# (company_name='A'),补出的权限必须落在同一作用域;硬写 NULL
# 会让其他公司的同角色用户凭空多出这个菜单。
src_perm = SysRolePermission.query.filter_by(
role_code=role_code, target_code='inbound_purchase', type='menu'
).first()
if not src_perm:
continue
# 幂等检查也必须带 company_name,否则 A 公司已有行会跳过 B 公司那条
already = SysRolePermission.query.filter_by(
role_code=role_code,
target_code='inbound_purchase:pending_pool',
type='element',
company_name=src_perm.company_name,
).first()
if already:
continue
db.session.add(SysRolePermission(
role_code=role_code,
target_code='inbound_purchase:pending_pool',
type='element',
company_name=src_perm.company_name,
))
total_inserted += 1
print(f"[权限迁移] {role_code}: 已补充「待采购清单」权限"
f"(作用域={src_perm.company_name or '全局'})")
# ★ 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}")