"""认证/登录/权限管理路由""" from functools import wraps from decimal import Decimal, InvalidOperation from flask import (Blueprint, render_template, request, jsonify, session, redirect, url_for) from services.db import query, add_operation_log from services.passwords import hash_password, validate_password, verify_password from services.permission_model import ensure_position_permissions, user_position_code auth_bp = Blueprint('auth', __name__) def login_required(f): @wraps(f) def decorated(*args, **kwargs): # 判断是 AJAX/JSON 请求还是普通页面请求 # fetch() 默认 Accept: */*, 不设 Content-Type # 通过请求路径和 Accept 头综合判断 is_api = request.path.startswith('/api/') is_ajax = request.headers.get('X-Requested-With') == 'XMLHttpRequest' if 'user_id' not in session: if is_api or is_ajax or request.is_json: return jsonify({"code": 401, "msg": "未登录"}) return redirect(url_for('auth.login')) # 校验账号状态:被禁用(status=0)或已删除的用户强制登出(防止已登录会话继续使用) try: user_rows = query("SELECT status FROM dashboard_users WHERE id=%s", (session.get('user_id'),)) if not user_rows or user_rows[0].get('status') != 1: session.clear() if is_api or is_ajax or request.is_json: return jsonify({"code": 401, "msg": "账号已禁用,请联系管理员"}) return redirect(url_for('auth.login')) except Exception: pass if session.get('must_change_password') and request.endpoint != 'auth.change_password': return jsonify({"code": 428, "msg": "首次登录必须修改密码"}) return f(*args, **kwargs) return decorated # 权限配置统一标识。左侧为标准ID,列表内保留现有页面ID和历史父级ID兼容。 PERMISSION_PAGE_ALIASES = { 'purchase_pending': ['purchase_pending', 'purchase'], 'purchase_backup': ['purchase_backup', 'purchase_ready', 'purchase'], 'order_domestic': ['order_domestic', 'orders_domestic', 'orders'], 'order_overseas': ['order_overseas', 'orders_overseas', 'orders'], 'order_packaging': ['order_packaging', 'orders_packaging', 'orders'], 'order_finished': ['order_finished', 'orders_finished', 'orders'], 'data_summary': ['data_summary', 'data'], 'data_detail': ['data_detail', 'data'], 'inventory_finished': ['inventory_finished', 'inventory'], 'inventory_packaging': ['inventory_packaging', 'inventory'], 'product_packaging': ['product_packaging', 'product_new'], 'login_records': ['login_records', 'login-records'], 'spot_dashboard': ['spot_dashboard', 'spot'], 'spot_application': ['spot_application', 'spot'], 'spot_confirmation': ['spot_confirmation', 'spot'], 'spot_sales': ['spot_sales', 'spot'], 'spot_authorization': ['spot_authorization', 'spot'], } # 只对无歧义的现有页面ID做标准化;历史父级 orders 保留为兼容回退项。 PERMISSION_PAGE_CANONICAL = { 'purchase': 'purchase_pending', 'purchase_ready': 'purchase_backup', 'orders_domestic': 'order_domestic', 'orders_overseas': 'order_overseas', 'orders_packaging': 'order_packaging', 'orders_finished': 'order_finished', 'login-records': 'login_records', } for _canonical in PERMISSION_PAGE_ALIASES: PERMISSION_PAGE_CANONICAL[_canonical] = _canonical def normalize_permission_page(page): """把现有/历史页面ID转换为权限配置标准ID。""" return PERMISSION_PAGE_CANONICAL.get(page, page) def _permission_page_aliases(page): """返回标准页面ID及兼容别名,标准ID优先。""" canonical = normalize_permission_page(page) return PERMISSION_PAGE_ALIASES.get(canonical, [canonical]) # ─── 权限配置页面特征(操作)单一来源 ─── # 未列出的页面默认只有「查看」权限;页面ID为标准权限ID。 PAGE_FEATURES = { 'data_summary': [{'id':'view','name':'查看'},{'id':'query','name':'查询'},{'id':'export','name':'导出'}], 'data_detail': [{'id':'view','name':'查看'},{'id':'query','name':'查询'},{'id':'export','name':'导出'},{'id':'import','name':'导入'}], 'inventory_finished': [{'id':'view','name':'查看'},{'id':'edit_product','name':'编辑产品'},{'id':'export','name':'导出'},{'id':'import','name':'导入'}], 'inventory_packaging': [{'id':'view','name':'查看'},{'id':'edit_product','name':'编辑产品'},{'id':'export','name':'导出'},{'id':'import','name':'导入'}], 'planning': [{'id':'view','name':'查看'},{'id':'detail','name':'详情按钮'},{'id':'edit','name':'编辑按钮'},{'id':'delete','name':'删除按钮'},{'id':'field_setting','name':'字段设置'},{'id':'frozen','name':'冻结列'}], 'purchase_pending': [{'id':'view','name':'查看'},{'id':'detail','name':'详情按钮'},{'id':'add','name':'加入待下单'},{'id':'edit','name':'编辑下单信息'},{'id':'batch_confirm','name':'确认下单按钮'},{'id':'delete','name':'回退按钮'},{'id':'field_setting','name':'字段设置'},{'id':'frozen','name':'冻结列'}], 'purchase_backup': [{'id':'view','name':'查看'},{'id':'detail','name':'详情按钮'},{'id':'edit','name':'编辑订单'},{'id':'rollback','name':'回退按钮'},{'id':'delete','name':'删除按钮'},{'id':'export','name':'导出'},{'id':'field_setting','name':'字段设置'}], 'order_domestic': [{'id':'view','name':'查看'},{'id':'detail','name':'详情按钮'},{'id':'create','name':'新增订单'},{'id':'edit','name':'编辑订单'},{'id':'contract','name':'合同按钮'},{'id':'delete','name':'删除按钮'},{'id':'batch_contract','name':'批量生成合同'}], 'order_overseas': [{'id':'view','name':'查看'},{'id':'detail','name':'详情按钮'},{'id':'create','name':'新增订单'},{'id':'edit','name':'编辑订单'},{'id':'contract','name':'合同按钮'},{'id':'delete','name':'删除按钮'},{'id':'batch_contract','name':'批量生成合同'}], 'order_packaging': [{'id':'view','name':'查看'},{'id':'detail','name':'详情按钮'},{'id':'create','name':'新增订单'},{'id':'edit','name':'编辑订单'},{'id':'contract','name':'合同按钮'},{'id':'delete','name':'删除按钮'},{'id':'batch_contract','name':'批量生成合同'}], 'order_finished': [{'id':'view','name':'查看'},{'id':'detail','name':'详情按钮'},{'id':'delete','name':'删除按钮'},{'id':'export','name':'导出'},{'id':'field_setting','name':'字段设置'}], 'spot_application': [{'id':'view','name':'查看'},{'id':'create','name':'新增申请'},{'id':'edit','name':'审核通过/驳回'},{'id':'delete','name':'删除申请'}], 'spot_confirmation': [{'id':'view','name':'查看'},{'id':'edit','name':'编辑详情'},{'id':'import','name':'导入'},{'id':'delete','name':'删除记录'},{'id':'export','name':'导出'},{'id':'field_setting','name':'字段设置'},{'id':'frozen','name':'冻结字段'}], 'spot_authorization': [{'id':'view','name':'查看'},{'id':'create','name':'新增资料'}, {'id':'edit','name':'编辑'},{'id':'delete','name':'删除'}], 'contract_templates': [{'id':'view','name':'查看'},{'id':'edit','name':'编辑上传'},{'id':'delete','name':'删除'}], 'product_new': [{'id':'view','name':'查看'},{'id':'create','name':'新增'},{'id':'edit','name':'编辑'},{'id':'delete','name':'删除'},{'id':'sync','name':'同步产品信息'},{'id':'field_setting','name':'字段设置'}], 'product_category': [{'id':'view','name':'查看'},{'id':'create','name':'新增'},{'id':'edit','name':'编辑'},{'id':'delete','name':'删除'}], 'products': [{'id':'view','name':'查看'},{'id':'add','name':'新增产品'},{'id':'edit','name':'编辑产品'},{'id':'field_setting','name':'字段设置'}], 'brand_management': [{'id':'view','name':'查看'},{'id':'edit','name':'分类编辑'},{'id':'delete','name':'删除品牌'}], 'bom': [{'id':'view','name':'查看'},{'id':'edit','name':'编辑'}], 'warehouse': [{'id':'view','name':'查看'},{'id':'create','name':'新增仓库'},{'id':'edit','name':'编辑仓库'},{'id':'delete','name':'删除仓库'}], 'suppliers': [{'id':'view','name':'查看'},{'id':'edit','name':'编辑'},{'id':'create','name':'新增供应商'}], 'knowledge': [{'id':'view','name':'查看'},{'id':'create','name':'新建文章'},{'id':'edit','name':'编辑文章'},{'id':'delete','name':'删除文章'},{'id':'import','name':'导入'},{'id':'export','name':'导出'},{'id':'lint','name':'运行检查'},{'id':'graph','name':'关系图谱'}], 'organization': [{'id':'view','name':'查看'},{'id':'sync','name':'同步钉钉组织架构'}], 'users': [{'id':'view','name':'查看'},{'id':'create','name':'新增'},{'id':'edit','name':'编辑'},{'id':'delete','name':'删除'},{'id':'import','name':'导入'}], 'permissions': [{'id':'view','name':'查看'},{'id':'edit','name':'编辑'}], 'logs': [{'id':'view','name':'查看'},{'id':'export','name':'导出'},{'id':'verify','name':'完整性校验'},{'id':'manage','name':'日志治理'}], 'config': [{'id':'view','name':'查看'},{'id':'edit','name':'编辑'},{'id':'reset','name':'恢复默认'}], 'collaboration_sales_management': [{'id':'view','name':'查看'},{'id':'import','name':'导入'},{'id':'export','name':'导出'},{'id':'field_setting','name':'字段设置'},{'id':'delete','name':'删除'}], 'stagnant_inventory_plans': [{'id':'view','name':'查看'},{'id':'create','name':'新建方案'},{'id':'edit','name':'编辑方案'},{'id':'delete','name':'归档方案'},{'id':'export','name':'导出'}], } DEFAULT_FEATURES = [{'id': 'view', 'name': '查看'}] def _sidebar_menu_pages(): """从侧边栏模板解析页面列表(页面ID/名称/分组),作为权限配置的单一来源。 新增子菜单只需在 base.html 侧边栏添加,权限配置会自动同步。""" import os import re from flask import current_app path = os.path.join(current_app.root_path, 'templates', 'base.html') with open(path, encoding='utf-8') as f: html = f.read() entries = [] seen = set() def add(group, pid, name): pid = normalize_permission_page(pid.strip()) name = name.strip() if not pid or pid in seen: return seen.add(pid) entries.append({'id': pid, 'name': name, 'group': group or name}) # 顶层项(nav-item 带 data-page,非分组头) top_items = [(m.start(), m.group(1), m.group(2)) for m in re.finditer( r']*class="[^"]*nav-item[^"]*"[^>]*data-page="([^"]+)"[^>]*>.*?([^<]+)', html, re.S)] # 分组头(位置 + 分组名) group_headers = [(m.start(), m.group(1)) for m in re.finditer( r']*class="[^"]*nav-group-header[^"]*"[^>]*>.*?([^<]+)', html, re.S)] submenu_re = re.compile(r'', re.S) submenu_items = {} for pos, gname in group_headers: m = submenu_re.search(html, pos) if m: submenu_items[pos] = (gname, [(mm.group(1), mm.group(2)) for mm in re.finditer( r'data-page="([^"]+)"[^>]*>\s*[^<]*\s*([^<]+)', m.group(1))]) # 按文档顺序合并:顶层项与分组 order = [(pos, 'top', pid, name) for pos, pid, name in top_items] + \ [(pos, 'group', gname, None) for pos, gname in group_headers] order.sort() for pos, kind, a, b in order: if kind == 'top': add(None, a, b) else: gname, items = submenu_items.get(pos, (a, [])) for pid, name in items: add(gname, pid, name) return entries def _query_effective_operation_value(user_id, role, page, operation): """返回有效操作权限;未配置时返回 None,供新增按钮权限兼容旧配置。""" if role in ('admin', 'super_admin'): return True pages = _permission_page_aliases(page) placeholders = ','.join(['%s'] * len(pages)) query_role = 'viewer' if role == '查看者' else role user_rows = query( f"SELECT permission FROM user_permissions WHERE user_id=%s AND page IN ({placeholders}) AND operation=%s ORDER BY FIELD(page, {placeholders}) LIMIT 1", tuple([user_id] + pages + [operation] + pages) ) if user_rows: return int(user_rows[0].get('permission', 0)) == 1 ensure_position_permissions() position = user_position_code(user_id) if position: position_rows = query( f"SELECT permission FROM position_operation_permissions WHERE position_code=%s AND page IN ({placeholders}) AND operation=%s ORDER BY FIELD(page, {placeholders}) LIMIT 1", tuple([position] + pages + [operation] + pages) ) if position_rows: return int(position_rows[0].get('permission', 0)) == 1 role_rows = query( f"SELECT permission FROM role_operation_permissions WHERE role=%s AND page IN ({placeholders}) AND operation=%s ORDER BY FIELD(page, {placeholders}) LIMIT 1", tuple([query_role] + pages + [operation] + pages) ) if role_rows: return int(role_rows[0].get('permission', 0)) == 1 return None def _query_effective_operation(user_id, role, page, operation): return bool(_query_effective_operation_value(user_id, role, page, operation)) def _has_effective_page_permission(user_id, role, page): """页面访问权限统一走 view;管理员和超级管理员固定拥有访问权限。""" if role in ('admin', 'super_admin'): return True pages = _permission_page_aliases(page) placeholders = ','.join(['%s'] * len(pages)) user_rows = query( f"SELECT permission FROM user_permissions WHERE user_id=%s AND page IN ({placeholders}) AND operation='view' ORDER BY FIELD(page, {placeholders}) LIMIT 1", tuple([user_id] + pages + pages) ) if user_rows: return int(user_rows[0].get('permission', 0)) == 1 ensure_position_permissions() position = user_position_code(user_id) if position: position_rows = query( f"SELECT permission FROM position_operation_permissions WHERE position_code=%s AND page IN ({placeholders}) AND operation='view' ORDER BY FIELD(page, {placeholders}) LIMIT 1", tuple([position] + pages + pages) ) if position_rows: return int(position_rows[0].get('permission', 0)) == 1 role_rows = query( f"SELECT permission FROM role_operation_permissions WHERE role=%s AND page IN ({placeholders}) AND operation='view' ORDER BY FIELD(page, {placeholders}) LIMIT 1", tuple([role] + pages + pages) ) if role_rows: return int(role_rows[0].get('permission', 0)) == 1 rows = query( f"SELECT permission FROM role_permissions WHERE role=%s AND page IN ({placeholders}) LIMIT 1", tuple([role] + pages) ) return bool(rows and int(rows[0].get('permission', 0)) == 1) def role_required(page): """从统一的有效权限模型检查当前用户是否有权限访问页面。""" user_id = session.get('user_id') return bool(user_id and _has_effective_page_permission(user_id, session.get('role', ''), page)) def operation_required(page, operation): """检查最终生效的页面操作权限;用户级明确值覆盖岗位和角色配置。""" user_id = session.get('user_id') return bool(user_id and _query_effective_operation(user_id, session.get('role', ''), page, operation)) def operation_required_compat(page, operation, fallback_operation): """新增按钮权限未配置时继承旧权限;一旦明确配置则严格使用新权限。""" user_id = session.get('user_id') if not user_id: return False role = session.get('role', '') value = _query_effective_operation_value(user_id, role, page, operation) if value is not None: return bool(value) return _query_effective_operation(user_id, role, page, fallback_operation) # ─── 页面路由 ─── @auth_bp.route('/login', methods=['GET']) def login(): return render_template('login.html') @auth_bp.route('/api/login', methods=['POST']) def api_login(): data = request.get_json(silent=True) or {} username = data.get('username', '') password = data.get('password', '') users = query( "SELECT * FROM dashboard_users WHERE username=%s", (username,) ) if not users: return jsonify({"code": 401, "msg": "用户名或密码错误"}) if users[0].get('status') != 1: return jsonify({"code": 401, "msg": "账号已禁用,请联系管理员"}) if not verify_password(users[0]['password'], password): return jsonify({"code": 401, "msg": "用户名或密码错误"}) user = users[0] session.clear() session['user_id'] = user['id'] session['username'] = user['username'] session['real_name'] = user['real_name'] session['role'] = user['role'] session['user_level'] = user.get('user_level', 'employee') session['must_change_password'] = bool(user.get('must_change_password', 0)) if session['must_change_password']: return jsonify({"code": 0, "msg": "首次登录需要修改密码", "data": { "must_change_password": True, "username": user['username'] }}) # 更新最后登录时间 query( "UPDATE dashboard_users SET last_login=NOW() WHERE id=%s", (user['id'],) ) # 记录登录日志(含IP) client_ip = request.remote_addr or request.headers.get('X-Forwarded-For', '').split(',')[0].strip() or '' add_operation_log('登录', '用户登录系统', user_id=user['id'], ip_address=client_ip, page='登录', event_type='login', object_type='user', object_id=user['id']) return jsonify({"code": 0, "msg": "登录成功", "data": { "must_change_password": False, "username": user['username'], "real_name": user['real_name'], "role": user['role'], "user_level": user.get('user_level', 'employee') }}) @auth_bp.route('/api/change-password', methods=['POST']) @login_required def change_password(): """Change the current user's password before granting full access.""" data = request.get_json(silent=True) or {} new_password = data.get('new_password', '') confirm_password = data.get('confirm_password', '') if new_password != confirm_password: return jsonify({"code": 400, "msg": "两次输入的密码不一致"}) valid, message = validate_password(new_password) if not valid: return jsonify({"code": 400, "msg": message}) user_id = session['user_id'] rows = query("SELECT password FROM dashboard_users WHERE id=%s AND status=1", (user_id,)) if not rows: session.clear() return jsonify({"code": 401, "msg": "账号不存在或已停用"}) if verify_password(rows[0]['password'], new_password): return jsonify({"code": 400, "msg": "新密码不能与原密码相同"}) query( "UPDATE dashboard_users SET password=%s, must_change_password=0, last_login=NOW() WHERE id=%s", (hash_password(new_password), user_id) ) session['must_change_password'] = False add_operation_log('修改密码', '用户首次登录完成密码修改') return jsonify({"code": 0, "msg": "密码修改成功"}) @auth_bp.route('/api/logout', methods=['POST']) def api_logout(): session.clear() return jsonify({"code": 0, "msg": "已退出"}) @auth_bp.route('/') def index(): return render_template('login.html') @auth_bp.route('/dashboard') @login_required def dashboard_page(): return render_template('base.html', page='dashboard') @auth_bp.route('/api/profile') @login_required def profile(): user_id = session['user_id'] role = session['role'] # 角色默认权限 + 用户级覆盖,返回与模板注入一致的最终权限。 role_perms = query( "SELECT page, operation, permission FROM role_operation_permissions WHERE role=%s", ('viewer' if role == '查看者' else role,) ) ensure_position_permissions() position = user_position_code(user_id) position_perms = query( "SELECT page, operation, permission FROM position_operation_permissions WHERE position_code=%s", (position,) ) if position else [] user_perms = query( "SELECT page, operation, permission FROM user_permissions WHERE user_id=%s", (user_id,) ) runtime_page_aliases = { 'purchase_pending': 'purchase', 'purchase_backup': 'purchase_ready', 'order_domestic': 'orders_domestic', 'order_overseas': 'orders_overseas', 'order_packaging': 'orders_packaging', 'order_finished': 'orders_finished' } perm_map = {} for p in role_perms: runtime_page = runtime_page_aliases.get(p['page'], p['page']) perm_map.setdefault(runtime_page, {})[p['operation']] = int(p['permission']) for p in position_perms: runtime_page = runtime_page_aliases.get(p['page'], p['page']) perm_map.setdefault(runtime_page, {})[p['operation']] = int(p['permission']) for p in user_perms: runtime_page = runtime_page_aliases.get(p['page'], p['page']) perm_map.setdefault(runtime_page, {})[p['operation']] = int(p['permission']) # 管理员和超级管理员由后端统一视为全权限,前端保留空映射作为特殊标识。 if role in ('admin', 'super_admin'): perm_map = {} else: # 注入字段级权限显式值(敏感字段默认隐藏 + 角色/用户字段权限) try: from services.field_perms import effective_field_overrides for runtime_page, field_map in effective_field_overrides(user_id, role).items(): base = perm_map.setdefault(runtime_page, {}) for field_key, ops in field_map.items(): base['field:' + field_key + ':view'] = ops['view'] base['field:' + field_key + ':edit'] = ops['edit'] except Exception: pass return jsonify({ "code": 0, "data": { "id": user_id, "username": session['username'], "real_name": session['real_name'], "role": role, "user_level": session.get('user_level', 'employee'), "permissions": perm_map } }) # ─── P1 权限数据范围与变更审计 ─── _PERMISSION_P1_TABLES_READY = False def _ensure_permission_p1_tables(): """创建 P1 权限控制台所需的配置/审计表;幂等且不触碰业务数据。""" global _PERMISSION_P1_TABLES_READY if _PERMISSION_P1_TABLES_READY: return query("""CREATE TABLE IF NOT EXISTS permission_data_scopes ( id BIGINT AUTO_INCREMENT PRIMARY KEY, subject_type VARCHAR(20) NOT NULL, subject_id VARCHAR(100) NOT NULL, page VARCHAR(100) NOT NULL, scope_type VARCHAR(40) NOT NULL DEFAULT 'all', scope_value VARCHAR(500) DEFAULT '', can_view TINYINT NOT NULL DEFAULT 1, can_edit TINYINT NOT NULL DEFAULT 0, can_export TINYINT NOT NULL DEFAULT 0, created_by INT NULL, updated_by INT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uq_permission_scope (subject_type, subject_id, page), KEY idx_permission_scope_subject (subject_type, subject_id), KEY idx_permission_scope_page (page) )""") query("""CREATE TABLE IF NOT EXISTS permission_change_audits ( id BIGINT AUTO_INCREMENT PRIMARY KEY, actor_id INT NULL, target_type VARCHAR(20) NOT NULL, target_id VARCHAR(100) NOT NULL, action VARCHAR(80) NOT NULL, before_json LONGTEXT NULL, after_json LONGTEXT NULL, detail TEXT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, KEY idx_permission_audit_target (target_type, target_id), KEY idx_permission_audit_created (created_at) )""") ensure_position_permissions() query("""CREATE TABLE IF NOT EXISTS permission_versions ( id BIGINT AUTO_INCREMENT PRIMARY KEY, target_type VARCHAR(20) NOT NULL, target_id VARCHAR(100) NOT NULL, version_no INT NOT NULL, snapshot_json LONGTEXT NOT NULL, snapshot_hash CHAR(64) NOT NULL, summary VARCHAR(500) DEFAULT '', source_version_id BIGINT NULL, created_by INT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uq_permission_version (target_type, target_id, version_no), KEY idx_permission_version_target (target_type, target_id, id), KEY idx_permission_version_created (created_at) )""") query("""INSERT IGNORE INTO permission_data_scopes (subject_type,subject_id,page,scope_type,scope_value,can_view,can_edit,can_export,created_by,updated_by) SELECT 'position',p.position_code,s.page,s.scope_type,s.scope_value,s.can_view,s.can_edit,s.can_export,s.created_by,s.updated_by FROM permission_data_scopes s INNER JOIN user_positions p ON BINARY p.position_code=BINARY s.subject_id WHERE s.subject_type='role' AND s.subject_id NOT IN ('super_admin','admin','viewer','查看者')""") _PERMISSION_P1_TABLES_READY = True def _record_permission_change(target_type, target_id, action, before=None, after=None, detail=''): """记录权限控制台变更,失败不阻断已完成的权限写入。""" try: import json _ensure_permission_p1_tables() query( "INSERT INTO permission_change_audits " "(actor_id,target_type,target_id,action,before_json,after_json,detail) " "VALUES (%s,%s,%s,%s,%s,%s,%s)", ( session.get('user_id'), target_type, str(target_id), action, json.dumps(before, ensure_ascii=False, default=str) if before is not None else None, json.dumps(after, ensure_ascii=False, default=str) if after is not None else None, detail or '', ), ) except Exception: pass _PERMISSION_CONFLICT_RULES = [ { 'id': 'confirm_and_rollback', 'level': '高', 'name': '确认下单与订单回退同时授权', 'left': ['purchase_pending.batch_confirm'], 'right': ['purchase_backup.rollback'], 'advice': '建议由不同岗位分别负责确认下单与订单回退。', }, { 'id': 'user_and_permission_admin', 'level': '高', 'name': '用户管理与权限配置同时授权', 'left': ['users.edit', 'users.create', 'users.delete'], 'right': ['permissions.edit'], 'advice': '可修改账号又可修改权限,建议仅系统管理员持有。', }, { 'id': 'product_and_confirm', 'level': '中', 'name': '产品资料维护与确认下单同时授权', 'left': ['products.edit', 'bom.edit', 'suppliers.edit'], 'right': ['purchase_pending.batch_confirm'], 'advice': '存在修改基础资料后直接确认下单的风险。', }, { 'id': 'price_and_backup_edit', 'level': '高', 'name': '成本/单价编辑与订单备份编辑同时授权', 'left': [ 'planning.field:成本价:edit', 'products.field:成本价:edit', 'bom.field:成本价:edit', 'order_domestic.field:单价:edit', 'order_overseas.field:单价:edit', 'order_packaging.field:单价:edit', ], 'right': ['purchase_backup.edit'], 'advice': '建议价格维护与订单审核/编辑职责分离。', }, { 'id': 'domestic_create_delete', 'level': '中', 'name': '国内订单新增与删除同时授权', 'left': ['order_domestic.create'], 'right': ['order_domestic.delete'], 'advice': '建议限制删除权限或启用复核。', }, { 'id': 'overseas_create_delete', 'level': '中', 'name': '海外订单新增与删除同时授权', 'left': ['order_overseas.create'], 'right': ['order_overseas.delete'], 'advice': '建议限制删除权限或启用复核。', }, { 'id': 'packaging_create_delete', 'level': '中', 'name': '包材订单新增与删除同时授权', 'left': ['order_packaging.create'], 'right': ['order_packaging.delete'], 'advice': '建议限制删除权限或启用复核。', }, ] def _permission_snapshot(target_type, target_id): """读取角色/用户完整权限状态,作为版本与回滚的唯一快照。""" _ensure_permission_p1_tables() target_type = str(target_type) target_id = str(target_id) if target_type == 'role': legacy = query( "SELECT page,permission FROM role_permissions WHERE role=%s ORDER BY page", (target_id,), ) operations = query( "SELECT page,operation,permission FROM role_operation_permissions " "WHERE role=%s ORDER BY page,operation", (target_id,) ) elif target_type == 'position': ensure_position_permissions() legacy = [] operations = query( "SELECT page,operation,permission FROM position_operation_permissions " "WHERE position_code=%s ORDER BY page,operation", (target_id,) ) elif target_type == 'user': legacy = [] operations = query( "SELECT page,operation,permission FROM user_permissions " "WHERE user_id=%s ORDER BY page,operation", (int(target_id),) ) else: raise ValueError('不支持的权限对象类型') scopes = query( "SELECT page,scope_type,scope_value,can_view,can_edit,can_export " "FROM permission_data_scopes WHERE subject_type=%s AND subject_id=%s ORDER BY page", (target_type, target_id), ) return { 'target_type': target_type, 'target_id': target_id, 'legacy_pages': legacy, 'operations': operations, 'data_scopes': scopes, } def _create_permission_version(target_type, target_id, summary, snapshot=None, source_version_id=None, force=False): """保存权限快照;内容未变化时默认不重复生成版本。""" import hashlib import json _ensure_permission_p1_tables() snapshot = snapshot or _permission_snapshot(target_type, target_id) payload = json.dumps(snapshot, ensure_ascii=False, sort_keys=True, separators=(',', ':'), default=str) digest = hashlib.sha256(payload.encode('utf-8')).hexdigest() latest = query( "SELECT id,version_no,snapshot_hash FROM permission_versions " "WHERE target_type=%s AND target_id=%s ORDER BY version_no DESC LIMIT 1", (target_type, str(target_id)), ) if latest and latest[0].get('snapshot_hash') == digest and not force: return latest[0].get('id') version_no = int(latest[0].get('version_no') or 0) + 1 if latest else 1 return query( "INSERT INTO permission_versions " "(target_type,target_id,version_no,snapshot_json,snapshot_hash,summary,source_version_id,created_by) " "VALUES (%s,%s,%s,%s,%s,%s,%s,%s)", ( target_type, str(target_id), version_no, payload, digest, str(summary or '')[:500], source_version_id, session.get('user_id'), ), ) def _ensure_permission_baseline(target_type, target_id): rows = query( "SELECT id FROM permission_versions WHERE target_type=%s AND target_id=%s LIMIT 1", (target_type, str(target_id)), ) if not rows: return _create_permission_version(target_type, target_id, 'P2启用时的初始权限基线', force=True) return rows[0].get('id') def _allowed_pairs_from_items(items): result = set() for item in items or []: try: allowed = int(item.get('permission', 0)) == 1 except (TypeError, ValueError): allowed = False if not allowed: continue page = normalize_permission_page(str(item.get('page') or '').strip()) operation = str(item.get('operation') or '').strip() if page and operation: result.add(f'{page}.{operation}') return result def _effective_user_pairs(user_id, operation_items=None, field_items=None): users = query("SELECT role,position FROM dashboard_users WHERE id=%s LIMIT 1", (user_id,)) if not users: return set() role = users[0].get('role') or '' if role in ('admin', 'super_admin'): return {'permissions.edit', 'users.edit'} query_role = 'viewer' if role == '查看者' else role role_rows = query( "SELECT page,operation,permission FROM role_operation_permissions WHERE role=%s", (query_role,), ) user_rows = query( "SELECT page,operation,permission FROM user_permissions WHERE user_id=%s", (user_id,), ) merged = {} for row in role_rows: merged[(normalize_permission_page(row.get('page')), row.get('operation'))] = int(row.get('permission') or 0) ensure_position_permissions() position = str(users[0].get('position') or '').strip() if position: for row in query("SELECT page,operation,permission FROM position_operation_permissions WHERE position_code=%s", (position,)): merged[(normalize_permission_page(row.get('page')), row.get('operation'))] = int(row.get('permission') or 0) for row in user_rows: operation = str(row.get('operation') or '') if operation.startswith('field:'): if field_items is None: merged[(normalize_permission_page(row.get('page')), operation)] = int(row.get('permission') or 0) elif operation_items is None: merged[(normalize_permission_page(row.get('page')), operation)] = int(row.get('permission') or 0) for item in operation_items or []: page = normalize_permission_page(str(item.get('page') or '').strip()) operation = str(item.get('operation') or '').strip() if page and operation and not operation.startswith('field:'): merged[(page, operation)] = int(item.get('permission') or 0) for item in field_items or []: page = normalize_permission_page(str(item.get('page') or '').strip()) field = str(item.get('field') or '').strip() operation = str(item.get('operation') or '').strip() value = item.get('permission') if page and field and operation in ('view', 'edit'): key = (page, f'field:{field}:{operation}') if value is None or value == '': # 继承时移除用户覆盖,保留角色值。 role_value = next((int(r.get('permission') or 0) for r in role_rows if normalize_permission_page(r.get('page')) == page and r.get('operation') == key[1]), 0) merged[key] = role_value else: merged[key] = int(value) return {f'{page}.{operation}' for (page, operation), value in merged.items() if int(value) == 1} def _detect_permission_conflicts(allowed_pairs): conflicts = [] for rule in _PERMISSION_CONFLICT_RULES: left = next((item for item in rule['left'] if item in allowed_pairs), None) right = next((item for item in rule['right'] if item in allowed_pairs), None) if left and right: conflicts.append({ 'id': rule['id'], 'level': rule['level'], 'name': rule['name'], 'left': left, 'right': right, 'advice': rule['advice'], }) return conflicts def _restore_permission_snapshot(snapshot): """在事务中恢复单个角色或用户快照。""" from services.db import get_db target_type = snapshot.get('target_type') target_id = str(snapshot.get('target_id')) conn = get_db() try: with conn.cursor() as cur: if target_type == 'role': cur.execute("DELETE FROM role_permissions WHERE role=%s", (target_id,)) legacy = snapshot.get('legacy_pages') or [] if legacy: cur.executemany( "INSERT INTO role_permissions (role,page,permission) VALUES (%s,%s,%s)", [(target_id, x.get('page'), int(x.get('permission') or 0)) for x in legacy], ) cur.execute("DELETE FROM role_operation_permissions WHERE role=%s", (target_id,)) operations = snapshot.get('operations') or [] if operations: cur.executemany( "INSERT INTO role_operation_permissions (role,page,operation,permission) VALUES (%s,%s,%s,%s)", [(target_id, x.get('page'), x.get('operation'), int(x.get('permission') or 0)) for x in operations], ) elif target_type == 'position': ensure_position_permissions() cur.execute("DELETE FROM position_operation_permissions WHERE position_code=%s", (target_id,)) operations = snapshot.get('operations') or [] if operations: cur.executemany( "INSERT INTO position_operation_permissions (position_code,page,operation,permission) VALUES (%s,%s,%s,%s)", [(target_id, x.get('page'), x.get('operation'), int(x.get('permission') or 0)) for x in operations], ) elif target_type == 'user': user_id = int(target_id) cur.execute("DELETE FROM user_permissions WHERE user_id=%s", (user_id,)) operations = snapshot.get('operations') or [] if operations: cur.executemany( "INSERT INTO user_permissions (user_id,page,operation,permission) VALUES (%s,%s,%s,%s)", [(user_id, x.get('page'), x.get('operation'), int(x.get('permission') or 0)) for x in operations], ) else: raise ValueError('不支持的权限对象类型') cur.execute( "DELETE FROM permission_data_scopes WHERE subject_type=%s AND subject_id=%s", (target_type, target_id), ) scopes = snapshot.get('data_scopes') or [] if scopes: cur.executemany( "INSERT INTO permission_data_scopes " "(subject_type,subject_id,page,scope_type,scope_value,can_view,can_edit,can_export,created_by,updated_by) " "VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", [( target_type, target_id, normalize_permission_page(x.get('page')), x.get('scope_type') or 'all', x.get('scope_value') or '', int(x.get('can_view') or 0), int(x.get('can_edit') or 0), int(x.get('can_export') or 0), session.get('user_id'), session.get('user_id'), ) for x in scopes], ) conn.commit() except Exception: conn.rollback() raise finally: conn.close() def _flatten_permission_snapshot(snapshot): flat = {} for item in snapshot.get('legacy_pages') or []: flat[f"页面:{normalize_permission_page(item.get('page'))}"] = int(item.get('permission') or 0) for item in snapshot.get('operations') or []: flat[f"操作:{normalize_permission_page(item.get('page'))}:{item.get('operation')}"] = int(item.get('permission') or 0) for item in snapshot.get('data_scopes') or []: flat[f"范围:{normalize_permission_page(item.get('page'))}"] = { 'scope_type': item.get('scope_type') or 'all', 'scope_value': item.get('scope_value') or '', 'can_view': int(item.get('can_view') or 0), 'can_edit': int(item.get('can_edit') or 0), 'can_export': int(item.get('can_export') or 0), } return flat @auth_bp.route('/api/permissions/conflicts/check', methods=['POST']) @login_required def permission_conflicts_check(): """检查角色或用户拟议权限是否存在职责冲突。""" if session.get('role') not in ('admin', 'super_admin'): return jsonify({"code": 403, "msg": "仅管理员可操作"}) data = request.get_json(silent=True) or {} subject_type = str(data.get('subject_type') or 'role') subject_id = str(data.get('subject_id') or '') items = data.get('items') or [] field_items = data.get('field_items') or [] if subject_type in ('role', 'position'): pairs = _allowed_pairs_from_items(items) elif subject_type == 'user' and subject_id: pairs = _effective_user_pairs(int(subject_id), operation_items=items, field_items=field_items) else: return jsonify({"code": 400, "msg": "参数错误"}) conflicts = _detect_permission_conflicts(pairs) return jsonify({"code": 0, "data": {"conflicts": conflicts, "count": len(conflicts)}}) @auth_bp.route('/api/permissions/versions') @login_required def permission_versions(): """列出角色或用户权限版本;首次读取自动建立当前基线。""" if session.get('role') not in ('admin', 'super_admin'): return jsonify({"code": 403, "msg": "仅管理员可操作"}) _ensure_permission_p1_tables() target_type = str(request.args.get('target_type') or 'role') target_id = str(request.args.get('target_id') or '') if target_type not in ('role', 'position', 'user') or not target_id: return jsonify({"code": 400, "msg": "参数错误"}) _ensure_permission_baseline(target_type, target_id) rows = query( "SELECT v.id,v.target_type,v.target_id,v.version_no,v.summary,v.source_version_id,v.created_by,v.created_at,u.real_name AS creator_name " "FROM permission_versions v LEFT JOIN dashboard_users u ON u.id=v.created_by " "WHERE v.target_type=%s AND v.target_id=%s ORDER BY v.version_no DESC", (target_type, target_id), ) for row in rows: if row.get('created_at') and hasattr(row['created_at'], 'strftime'): row['created_at'] = row['created_at'].strftime('%Y-%m-%d %H:%M:%S') return jsonify({"code": 0, "data": rows}) @auth_bp.route('/api/permissions/versions/detail') @login_required def permission_version_detail(): if session.get('role') not in ('admin', 'super_admin'): return jsonify({"code": 403, "msg": "仅管理员可操作"}) import json version_id = request.args.get('id', type=int) rows = query( "SELECT v.*,u.real_name AS creator_name FROM permission_versions v " "LEFT JOIN dashboard_users u ON u.id=v.created_by WHERE v.id=%s LIMIT 1", (version_id,) ) if version_id else [] if not rows: return jsonify({"code": 404, "msg": "权限版本不存在"}) row = rows[0] if row.get('created_at') and hasattr(row['created_at'], 'strftime'): row['created_at'] = row['created_at'].strftime('%Y-%m-%d %H:%M:%S') snapshot = json.loads(row.get('snapshot_json') or '{}') return jsonify({"code": 0, "data": {"meta": {k: v for k, v in row.items() if k != 'snapshot_json'}, "snapshot": snapshot}}) @auth_bp.route('/api/permissions/versions/compare') @login_required def permission_versions_compare(): if session.get('role') not in ('admin', 'super_admin'): return jsonify({"code": 403, "msg": "仅管理员可操作"}) import json left_id = request.args.get('left', type=int) right_id = request.args.get('right', type=int) rows = query("SELECT id,snapshot_json FROM permission_versions WHERE id IN (%s,%s)", (left_id, right_id)) if left_id and right_id else [] mapping = {row['id']: json.loads(row.get('snapshot_json') or '{}') for row in rows} if left_id not in mapping or right_id not in mapping: return jsonify({"code": 404, "msg": "对比版本不存在"}) left = _flatten_permission_snapshot(mapping[left_id]) right = _flatten_permission_snapshot(mapping[right_id]) keys = sorted(set(left) | set(right)) changes = [] for key in keys: if left.get(key) != right.get(key): changes.append({'key': key, 'before': left.get(key), 'after': right.get(key)}) return jsonify({"code": 0, "data": {"changes": changes, "count": len(changes)}}) @auth_bp.route('/api/permissions/versions/rollback', methods=['POST']) @login_required def permission_version_rollback(): """将单个角色或用户权限回滚到指定版本,并保留回滚前/后的版本。""" if session.get('role') not in ('admin', 'super_admin'): return jsonify({"code": 403, "msg": "仅管理员可操作"}) import json data = request.get_json(silent=True) or {} version_id = int(data.get('version_id') or 0) rows = query("SELECT * FROM permission_versions WHERE id=%s LIMIT 1", (version_id,)) if version_id else [] if not rows: return jsonify({"code": 404, "msg": "权限版本不存在"}) row = rows[0] target_type, target_id = row['target_type'], str(row['target_id']) if target_type == 'role' and target_id == 'super_admin': return jsonify({"code": 403, "msg": "超级管理员角色不可回滚"}) if session.get('role') == 'admin' and ((target_type == 'role' and target_id == 'admin')): return jsonify({"code": 403, "msg": "管理员不能回滚管理员角色"}) if target_type == 'user': users = query("SELECT role FROM dashboard_users WHERE id=%s", (int(target_id),)) if not users or users[0].get('role') == 'super_admin': return jsonify({"code": 403, "msg": "目标用户不可回滚"}) if session.get('role') == 'admin' and users[0].get('role') == 'admin': return jsonify({"code": 403, "msg": "管理员不能回滚其他管理员"}) current = _permission_snapshot(target_type, target_id) _create_permission_version(target_type, target_id, '回滚前自动备份', snapshot=current, force=True) snapshot = json.loads(row.get('snapshot_json') or '{}') _restore_permission_snapshot(snapshot) new_id = _create_permission_version( target_type, target_id, f"回滚到 V{row['version_no']}", snapshot=_permission_snapshot(target_type, target_id), source_version_id=version_id, force=True, ) _record_permission_change(target_type, target_id, 'version_rollback', before=current, after=snapshot, detail=f"回滚到权限版本 V{row['version_no']}") add_operation_log('权限变更', f"{target_type}={target_id} 回滚到权限版本 V{row['version_no']}") return jsonify({"code": 0, "msg": "权限回滚成功", "data": {"version_id": new_id}}) # ─── API: 角色权限管理 ─── @auth_bp.route('/api/permissions/pages') @login_required def permission_pages(): """获取所有可配置的页面列表""" if session.get("role") not in ("admin", "super_admin"): return jsonify({"code": 403, "msg": "仅管理员可操作"}) pages = _sidebar_menu_pages() return jsonify({"code": 0, "data": pages}) @auth_bp.route('/api/permissions/menu-tree') @login_required def permission_menu_tree(): """权限配置菜单树:页面 + 分组 + 操作特征,由侧边栏自动生成。""" if session.get("role") not in ("admin", "super_admin"): return jsonify({"code": 403, "msg": "仅管理员可操作"}) tree = [] index = {} for pg in _sidebar_menu_pages(): g = pg['group'] if g not in index: index[g] = {'name': g, 'items': []} tree.append(index[g]) index[g]['items'].append({ 'page': pg['id'], 'name': pg['name'], 'features': PAGE_FEATURES.get(pg['id'], DEFAULT_FEATURES), }) return jsonify({"code": 0, "data": tree}) @auth_bp.route('/api/permissions/fields') @login_required def permission_fields(): """获取所有页面的字段清单(单一来源),含敏感字段标记,供权限配置矩阵渲染。""" if session.get("role") not in ("admin", "super_admin"): return jsonify({"code": 403, "msg": "仅管理员可操作"}) from services.field_perms import FIELD_REGISTRY, _FIELD_REGISTRY_ALIASES data = {} for page, fields in FIELD_REGISTRY.items(): data[page] = [ {'key': f['key'], 'label': f['key'], 'sensitive': bool(f.get('sensitive'))} for f in fields ] # 运行页面 ID 别名共享同一字段清单(如 product_packaging → product_new、purchase → purchase_pending) for runtime_id, canonical_id in _FIELD_REGISTRY_ALIASES.items(): if canonical_id in data: data.setdefault(runtime_id, data[canonical_id]) return jsonify({"code": 0, "data": data}) @auth_bp.route('/api/permissions/list') @login_required def permission_list(): """获取所有角色的权限配置""" if session.get("role") not in ("admin", "super_admin"): return jsonify({"code": 403, "msg": "仅管理员可操作"}) perms = query( "SELECT role, page, permission FROM role_permissions ORDER BY role, page" ) return jsonify({"code": 0, "data": perms}) @auth_bp.route('/api/permissions/roles') @login_required def permission_roles(): """获取所有可配置的角色列表""" if session.get("role") not in ("admin", "super_admin"): return jsonify({"code": 403, "msg": "仅管理员可操作"}) roles = [ {'id': 'super_admin', 'name': '超级管理员', 'system': True}, {'id': 'admin', 'name': '管理员', 'system': True}, {'id': 'viewer', 'name': '查看者', 'system': True}, ] return jsonify({"code": 0, "data": roles}) @auth_bp.route('/api/permissions/save', methods=['POST']) @login_required def permission_save(): """保存角色权限配置""" if session.get('role') not in ('admin', 'super_admin'): return jsonify({"code": 403, "msg": "仅管理员可操作"}) data = request.get_json(silent=True) or {} items = data.get('items', []) affected_roles = sorted({str(item.get('role') or '') for item in items if item.get('role')}) for role_id in affected_roles: _ensure_permission_baseline('role', role_id) try: for item in items: role = item.get('role') page = item.get('page') permission = int(item.get('permission', 0)) if permission: query( "INSERT INTO role_permissions (role, page, permission) VALUES (%s, %s, 1) " "ON DUPLICATE KEY UPDATE permission=1", (role, page) ) else: query( "DELETE FROM role_permissions WHERE role=%s AND page=%s", (role, page) ) for role_id in affected_roles: _create_permission_version('role', role_id, '保存角色页面权限') add_operation_log('权限变更', '保存角色权限配置') _record_permission_change('role', ','.join(affected_roles) or 'bulk', 'page_permission_save', after=items, detail='保存角色页面权限配置') return jsonify({"code": 0, "msg": "保存成功"}) except Exception as e: return jsonify({"code": 500, "msg": str(e)}) # ─── API: 用户权限管理 ─── OPERATIONS = [ {'id': 'view', 'name': '查看'}, {'id': 'create', 'name': '新增'}, {'id': 'edit', 'name': '编辑'}, {'id': 'delete', 'name': '删除'}, {'id': 'export', 'name': '导出'}, {'id': 'import', 'name': '导入'}, ] @auth_bp.route('/api/permissions/operations') @login_required def permission_operations(): """获取所有可配置的操作列表""" return jsonify({"code": 0, "data": OPERATIONS}) # ─── API: 角色操作权限管理 ─── @auth_bp.route('/api/permissions/role-operations') @login_required def permission_role_operations(): """获取所有角色的操作权限配置""" if session.get("role") not in ("admin", "super_admin"): return jsonify({"code": 403, "msg": "仅管理员可操作"}) perms = query( "SELECT role, page, operation, permission FROM role_operation_permissions ORDER BY role, page, operation" ) return jsonify({"code": 0, "data": perms}) @auth_bp.route('/api/permissions/role-operations/save', methods=['POST']) @login_required def permission_role_operations_save(): """保存角色操作权限配置,并执行职责冲突检查与版本留档。""" if session.get('role') not in ('admin', 'super_admin'): return jsonify({"code": 403, "msg": "仅管理员可操作"}) data = request.get_json(silent=True) or {} items = data.get('items', []) if session.get('role') == 'admin' and any(item.get('role') in ('admin', 'super_admin') for item in items): return jsonify({"code": 403, "msg": "管理员不能修改管理员或超级管理员的角色权限"}) affected_roles = sorted({str(item.get('role') or '') for item in items if item.get('role')}) all_conflicts = [] for role_id in affected_roles: role_items = [item for item in items if str(item.get('role') or '') == role_id] for conflict in _detect_permission_conflicts(_allowed_pairs_from_items(role_items)): current = dict(conflict) current['subject_id'] = role_id all_conflicts.append(current) if all_conflicts and not data.get('confirm_conflicts'): return jsonify({"code": 409, "msg": "检测到职责冲突,请确认后再保存", "data": {"conflicts": all_conflicts}}), 409 for role_id in affected_roles: _ensure_permission_baseline('role', role_id) try: from services.db import get_db conn = get_db() with conn.cursor() as cur: for item in items: role = item.get('role') page = normalize_permission_page(item.get('page')) operation = item.get('operation') permission = int(item.get('permission', 0)) cur.execute( "INSERT INTO role_operation_permissions (role, page, operation, permission) " "VALUES (%s, %s, %s, %s) ON DUPLICATE KEY UPDATE permission=%s", (role, page, operation, permission, permission) ) conn.commit() conn.close() for role_id in affected_roles: _create_permission_version('role', role_id, '保存角色操作/字段权限') add_operation_log('权限变更', '保存角色操作权限配置') _record_permission_change('role', ','.join(affected_roles) or 'bulk', 'role_operation_save', after=items, detail='保存角色操作权限配置') return jsonify({"code": 0, "msg": "保存成功", "data": {"conflicts": all_conflicts}}) except Exception as e: return jsonify({"code": 500, "msg": str(e)}) @auth_bp.route('/api/permissions/positions') @login_required def permission_positions(): """获取岗位权限矩阵使用的启用岗位。""" if session.get('role') not in ('admin', 'super_admin'): return jsonify({"code": 403, "msg": "仅管理员可操作"}) ensure_position_permissions() rows = query("""SELECT p.position_code AS id,p.position_name AS name,p.sort_order, COUNT(u.id) AS user_count FROM user_positions p LEFT JOIN dashboard_users u ON BINARY u.position=BINARY p.position_code WHERE p.status=1 GROUP BY p.id,p.position_code,p.position_name,p.sort_order ORDER BY p.sort_order,p.id""") return jsonify({"code": 0, "data": rows}) @auth_bp.route('/api/permissions/position-operations') @login_required def permission_position_operations(): if session.get('role') not in ('admin', 'super_admin'): return jsonify({"code": 403, "msg": "仅管理员可操作"}) ensure_position_permissions() rows = query("SELECT position_code AS role,page,operation,permission FROM position_operation_permissions ORDER BY position_code,page,operation") return jsonify({"code": 0, "data": rows}) @auth_bp.route('/api/permissions/position-operations/save', methods=['POST']) @login_required def permission_position_operations_save(): """保存岗位操作/字段权限。""" if session.get('role') not in ('admin', 'super_admin'): return jsonify({"code": 403, "msg": "仅管理员可操作"}) ensure_position_permissions() data = request.get_json(silent=True) or {} items = data.get('items') or [] positions = sorted({str(item.get('position_code') or item.get('role') or '') for item in items if item.get('position_code') or item.get('role')}) conflicts = [] for code in positions: proposed = [item for item in items if str(item.get('position_code') or item.get('role') or '') == code] for conflict in _detect_permission_conflicts(_allowed_pairs_from_items(proposed)): current = dict(conflict); current['subject_id'] = code; conflicts.append(current) if conflicts and not data.get('confirm_conflicts'): return jsonify({"code": 409, "msg": "检测到职责冲突,请确认后再保存", "data": {"conflicts": conflicts}}), 409 for code in positions: _ensure_permission_baseline('position', code) try: from services.db import get_db conn = get_db() with conn.cursor() as cur: for item in items: code = str(item.get('position_code') or item.get('role') or '') cur.execute("""INSERT INTO position_operation_permissions(position_code,page,operation,permission) VALUES (%s,%s,%s,%s) ON DUPLICATE KEY UPDATE permission=%s""", (code, normalize_permission_page(item.get('page')), item.get('operation'), int(item.get('permission') or 0), int(item.get('permission') or 0))) conn.commit() conn.close() for code in positions: _create_permission_version('position', code, '保存岗位操作/字段权限') _record_permission_change('position', ','.join(positions), 'position_operation_save', after=items, detail='保存岗位操作权限') add_operation_log('权限变更', '保存岗位操作权限配置') return jsonify({"code": 0, "msg": "保存成功", "data": {"conflicts": conflicts}}) except Exception as exc: return jsonify({"code": 500, "msg": str(exc)}) @auth_bp.route('/api/permissions/users') @login_required def permission_users(): """获取用户的权限配置(按用户分组)""" if session.get("role") not in ("admin", "super_admin"): return jsonify({"code": 403, "msg": "仅管理员可操作"}) users = query( "SELECT u.id, u.username, u.real_name, u.role, u.position, u.user_level, " "COALESCE(NULLIF(p.position_name, ''), NULLIF(u.position, ''), '未设置岗位') AS position_name " "FROM dashboard_users u " "LEFT JOIN user_positions p ON BINARY p.position_code=BINARY u.position " "ORDER BY FIELD(u.role, 'super_admin','admin'), u.real_name" ) # 获取所有用户权限设置 user_perms = query( "SELECT user_id, page, operation, permission FROM user_permissions ORDER BY user_id, page" ) # 构建用户权限字典 perm_map = {} for p in user_perms: uid = p['user_id'] if uid not in perm_map: perm_map[uid] = {} if p['page'] not in perm_map[uid]: perm_map[uid][p['page']] = {} perm_map[uid][p['page']][p['operation']] = p['permission'] for u in users: u['permissions'] = perm_map.get(u['id'], {}) return jsonify({"code": 0, "data": users}) @auth_bp.route('/api/permissions/user/save', methods=['POST']) @login_required def permission_user_save(): """保存单个用户的操作权限,执行职责冲突检查与版本留档。""" if session.get('role') not in ('admin', 'super_admin'): return jsonify({"code": 403, "msg": "仅管理员可操作"}) data = request.get_json(silent=True) or {} user_id = data.get('user_id') items = data.get('items', []) if not user_id: return jsonify({"code": 400, "msg": "参数错误"}) target = query("SELECT role FROM dashboard_users WHERE id=%s", (user_id,)) if not target: return jsonify({"code": 400, "msg": "用户不存在"}) if target[0]['role'] == 'super_admin': return jsonify({"code": 403, "msg": "超级管理员权限不可修改"}) if session.get('role') == 'admin' and target[0]['role'] == 'admin': return jsonify({"code": 403, "msg": "管理员不能修改其他管理员的个人权限"}) conflicts = _detect_permission_conflicts(_effective_user_pairs(int(user_id), operation_items=items)) if conflicts and not data.get('confirm_conflicts'): return jsonify({"code": 409, "msg": "检测到职责冲突,请确认后再保存", "data": {"conflicts": conflicts}}), 409 _ensure_permission_baseline('user', user_id) try: from services.db import get_db conn = get_db() with conn.cursor() as cur: upsert_map = {} for item in items: page = normalize_permission_page(str(item.get('page', '')).strip()) operation = str(item.get('operation', 'view')).strip() if not page or not operation or operation.startswith('field:'): continue upsert_map[(page, operation)] = 1 if int(item.get('permission', 0)) == 1 else 0 cur.execute("DELETE FROM user_permissions WHERE user_id=%s AND operation NOT LIKE 'field:%%'", (user_id,)) rows = [(user_id, page, operation, value) for (page, operation), value in upsert_map.items()] if rows: cur.executemany( "INSERT INTO user_permissions (user_id,page,operation,permission) VALUES (%s,%s,%s,%s)", rows ) conn.commit() conn.close() _create_permission_version('user', user_id, '保存用户操作权限') add_operation_log('权限变更', f'保存用户 #{user_id} 权限配置') _record_permission_change('user', user_id, 'user_operation_save', after=items, detail=f'保存用户 #{user_id} 操作权限') return jsonify({"code": 0, "msg": "保存成功", "data": {"conflicts": conflicts}}) except Exception as e: return jsonify({"code": 500, "msg": str(e)}) @auth_bp.route('/api/permissions/user/field-perms') @login_required def permission_user_field_perms(): """获取用户字段级权限 + 页面级有效权限(角色+用户合并)""" if session.get('role') not in ('admin', 'super_admin'): return jsonify({"code": 403, "msg": "仅管理员可操作"}) user_id = request.args.get('user_id', type=int) if not user_id: return jsonify({"code": 400, "msg": "参数错误"}) # 获取该用户信息 user = query("SELECT role, position FROM dashboard_users WHERE id=%s", (user_id,)) if not user: return jsonify({"code": 400, "msg": "用户不存在"}) user_role = user[0]['role'] # 获取角色级权限 role_perms = query( "SELECT page, operation, permission FROM role_operation_permissions WHERE role=%s", ('viewer' if user_role == '查看者' else user_role,) ) ensure_position_permissions() position_code = str(user[0].get('position') or '').strip() position_perms = query( "SELECT page,operation,permission FROM position_operation_permissions WHERE position_code=%s", (position_code,) ) if position_code else [] # 获取用户级字段权限 user_perms = query( "SELECT page, operation, permission FROM user_permissions WHERE user_id=%s", (user_id,) ) # 构建用户级权限字典 user_perm_map = {} user_field_map = {} # 字段级权限 user_op_map = {} # 操作级权限 for p in user_perms: page = normalize_permission_page(p['page']) op = p['operation'] if op.startswith('field:'): if page not in user_field_map: user_field_map[page] = {} parts = op.split(':', 2) if len(parts) == 3: fname, otype = parts[1], parts[2] else: fname, otype = parts[1], 'view' if fname not in user_field_map[page]: user_field_map[page][fname] = {} user_field_map[page][fname][otype] = p['permission'] else: if page not in user_op_map: user_op_map[page] = {} user_op_map[page][op] = p['permission'] # 角色权限作为默认值,用户级配置覆盖角色级配置;0 代表明确拒绝。 effective_op_perms = {} for p in role_perms: page = normalize_permission_page(p['page']) effective_op_perms.setdefault(page, {})[p['operation']] = int(p['permission']) for p in position_perms: page = normalize_permission_page(p['page']) effective_op_perms.setdefault(page, {})[p['operation']] = int(p['permission']) for p in user_perms: page = normalize_permission_page(p['page']) op = p['operation'] if op.startswith('field:'): continue effective_op_perms.setdefault(page, {})[op] = int(p['permission']) return jsonify({"code": 0, "data": { "field_perms": user_field_map, "role_perms": effective_op_perms, "role_field_perms": _inherited_field_perms_map(user_id, user_role), "role_defaults": { page: { p['operation']: int(p['permission']) for p in (role_perms + position_perms) if normalize_permission_page(p['page']) == page and not p['operation'].startswith('field:') } for page in {normalize_permission_page(p['page']) for p in (role_perms + position_perms)} }, "position_code": position_code, "user_overrides": user_op_map }}) @auth_bp.route('/api/permissions/data-scopes') @login_required def permission_data_scopes(): """读取角色/用户的数据范围配置。""" if session.get('role') not in ('admin', 'super_admin'): return jsonify({"code": 403, "msg": "仅管理员可操作"}) _ensure_permission_p1_tables() subject_type = (request.args.get('subject_type') or 'role').strip() subject_id = (request.args.get('subject_id') or '').strip() if subject_type not in ('role', 'position', 'user') or not subject_id: return jsonify({"code": 400, "msg": "参数错误"}) rows = query( "SELECT id,subject_type,subject_id,page,scope_type,scope_value,can_view,can_edit,can_export,updated_by,updated_at " "FROM permission_data_scopes WHERE subject_type=%s AND subject_id=%s ORDER BY page", (subject_type, subject_id), ) return jsonify({"code": 0, "data": rows}) @auth_bp.route('/api/permissions/data-scopes/save', methods=['POST']) @login_required def permission_data_scopes_save(): """保存角色/用户数据范围,并生成权限版本。""" if session.get('role') not in ('admin', 'super_admin'): return jsonify({"code": 403, "msg": "仅管理员可操作"}) _ensure_permission_p1_tables() data = request.get_json(silent=True) or {} subject_type = str(data.get('subject_type') or 'role').strip() subject_id = str(data.get('subject_id') or '').strip() items = data.get('items') or [] if subject_type not in ('role', 'position', 'user') or not subject_id: return jsonify({"code": 400, "msg": "参数错误"}) _ensure_permission_baseline(subject_type, subject_id) before = query( "SELECT page,scope_type,scope_value,can_view,can_edit,can_export FROM permission_data_scopes " "WHERE subject_type=%s AND subject_id=%s ORDER BY page", (subject_type, subject_id) ) conn = None try: from services.db import get_db conn = get_db() with conn.cursor() as cur: for item in items: page = normalize_permission_page(str(item.get('page') or '').strip()) if not page: continue scope_type = str(item.get('scope_type') or 'all').strip() if scope_type not in ('all', 'self', 'follower', 'planner', 'product_type'): raise ValueError('不支持的数据范围类型') values = ( subject_type, subject_id, page, scope_type, str(item.get('scope_value') or ''), int(bool(item.get('can_view', True))), int(bool(item.get('can_edit', False))), int(bool(item.get('can_export', False))), session.get('user_id'), session.get('user_id'), scope_type, str(item.get('scope_value') or ''), int(bool(item.get('can_view', True))), int(bool(item.get('can_edit', False))), int(bool(item.get('can_export', False))), session.get('user_id'), ) cur.execute( "INSERT INTO permission_data_scopes " "(subject_type,subject_id,page,scope_type,scope_value,can_view,can_edit,can_export,created_by,updated_by) " "VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) " "ON DUPLICATE KEY UPDATE scope_type=%s,scope_value=%s,can_view=%s,can_edit=%s,can_export=%s,updated_by=%s", values, ) conn.commit() conn.close() after = query( "SELECT page,scope_type,scope_value,can_view,can_edit,can_export FROM permission_data_scopes " "WHERE subject_type=%s AND subject_id=%s ORDER BY page", (subject_type, subject_id) ) _create_permission_version(subject_type, subject_id, '保存数据范围权限') _record_permission_change(subject_type, subject_id, 'data_scope_save', before=before, after=after, detail='保存数据范围权限') add_operation_log('权限变更', f'保存{subject_type}={subject_id}的数据范围权限') return jsonify({"code": 0, "msg": "数据范围保存成功", "data": after}) except Exception as exc: try: if conn: conn.rollback() conn.close() except Exception: pass return jsonify({"code": 500, "msg": str(exc)}) @auth_bp.route('/api/permissions/audits') @login_required def permission_audits(): """读取权限变更审计记录。""" if session.get('role') not in ('admin', 'super_admin'): return jsonify({"code": 403, "msg": "仅管理员可操作"}) _ensure_permission_p1_tables() try: page = max(request.args.get('page', 1, type=int), 1) size = min(max(request.args.get('size', 30, type=int), 1), 100) keyword = (request.args.get('keyword') or '').strip() where, params = [], [] if keyword: where.append('(target_id LIKE %s OR action LIKE %s OR detail LIKE %s)') params.extend([f'%{keyword}%', f'%{keyword}%', f'%{keyword}%']) clause = (' WHERE ' + ' AND '.join(where)) if where else '' total = query('SELECT COUNT(*) AS total FROM permission_change_audits' + clause, params)[0]['total'] rows = query( 'SELECT a.id,a.actor_id,a.target_type,a.target_id,a.action,a.before_json,a.after_json,a.detail,a.created_at,u.real_name AS actor_name ' 'FROM permission_change_audits a LEFT JOIN dashboard_users u ON u.id=a.actor_id' + clause + ' ORDER BY a.id DESC LIMIT %s OFFSET %s', params + [size, (page - 1) * size] ) for row in rows: if row.get('created_at') and hasattr(row['created_at'], 'strftime'): row['created_at'] = row['created_at'].strftime('%Y-%m-%d %H:%M:%S') return jsonify({"code": 0, "data": {"rows": rows, "total": total, "page": page, "size": size}}) except Exception as exc: return jsonify({"code": 500, "msg": str(exc)}) @auth_bp.route('/api/permissions/user/effective') @login_required def permission_user_effective(): """返回用户最终生效权限及来源,供权限预览/排查使用。""" if session.get('role') not in ('admin', 'super_admin'): return jsonify({"code": 403, "msg": "仅管理员可操作"}) user_id = request.args.get('user_id', type=int) if not user_id: return jsonify({"code": 400, "msg": "参数错误"}) users = query( "SELECT id, username, real_name, role, position, user_level, status " "FROM dashboard_users WHERE id=%s LIMIT 1", (user_id,) ) if not users: return jsonify({"code": 404, "msg": "用户不存在"}) user = users[0] role = user.get('role') or '' query_role = 'viewer' if role == '查看者' else role role_rows = query( "SELECT page, operation, permission FROM role_operation_permissions WHERE role=%s", (query_role,) ) ensure_position_permissions() position_code = str(user.get('position') or '').strip() position_rows = query( "SELECT page,operation,permission FROM position_operation_permissions WHERE position_code=%s", (position_code,) ) if position_code else [] user_rows = query( "SELECT page, operation, permission FROM user_permissions WHERE user_id=%s", (user_id,) ) role_page_rows = query( "SELECT page, permission FROM role_permissions WHERE role=%s", (role,) ) def _index(rows, include_field=False): result = {} for row in rows: page = normalize_permission_page(row.get('page')) operation = row.get('operation') if not include_field and str(operation or '').startswith('field:'): continue result.setdefault(page, {})[operation] = int(row.get('permission') or 0) return result role_map = _index(role_rows) position_map = _index(position_rows) user_map = _index(user_rows) role_page_map = { normalize_permission_page(row.get('page')): int(row.get('permission') or 0) for row in role_page_rows } def _lookup(mapping, page, operation): for alias in _permission_page_aliases(page): canonical = normalize_permission_page(alias) if canonical in mapping and operation in mapping[canonical]: return mapping[canonical][operation] return None pages = [] for menu_page in _sidebar_menu_pages(): page = normalize_permission_page(menu_page['id']) features = PAGE_FEATURES.get(page, DEFAULT_FEATURES) operations = [] for feature in features: op = feature['id'] if role in ('admin', 'super_admin'): allowed, source = True, '管理员全权限' else: user_value = _lookup(user_map, page, op) position_value = _lookup(position_map, page, op) role_value = _lookup(role_map, page, op) legacy_value = role_page_map.get(page) if op == 'view' else None if user_value is not None: allowed, source = bool(user_value), '用户级覆盖' elif position_value is not None: allowed, source = bool(position_value), '岗位权限' elif role_value is not None: allowed, source = bool(role_value), '角色默认' elif legacy_value is not None: allowed, source = bool(legacy_value), '历史页面权限' else: allowed, source = False, '未配置' operations.append({ 'id': op, 'name': feature.get('name') or op, 'allowed': bool(allowed), 'source': source, }) pages.append({ 'page': page, 'name': menu_page.get('name') or page, 'group': menu_page.get('group') or '其他', 'operations': operations, }) # 字段权限使用与运行时相同的优先级:用户覆盖 > 角色默认 > 页面权限继承。 from services.field_perms import FIELD_REGISTRY, _FIELD_REGISTRY_ALIASES fields = {} field_role_map = _index(role_rows, include_field=True) field_position_map = _index(position_rows, include_field=True) field_user_map = _index(user_rows, include_field=True) field_pages = dict(FIELD_REGISTRY) for runtime_id, canonical_id in _FIELD_REGISTRY_ALIASES.items(): if canonical_id in field_pages: field_pages.setdefault(runtime_id, field_pages[canonical_id]) for raw_page, field_list in field_pages.items(): page = normalize_permission_page(raw_page) page_view = next((x['allowed'] for x in next((p['operations'] for p in pages if p['page'] == page), []) if x['id'] == 'view'), False) page_edit = next((x['allowed'] for x in next((p['operations'] for p in pages if p['page'] == page), []) if x['id'] == 'edit'), False) page_fields = [] for field in field_list: key = field['key'] item = {'key': key, 'label': key, 'sensitive': bool(field.get('sensitive')), 'view': {}, 'edit': {}} for operation, default_value in (('view', page_view), ('edit', page_edit)): field_operation = f'field:{key}:{operation}' user_value = _lookup(field_user_map, page, field_operation) position_value = _lookup(field_position_map, page, field_operation) role_value = _lookup(field_role_map, page, field_operation) if role in ('admin', 'super_admin'): allowed, source = True, '管理员全权限' elif user_value is not None: allowed, source = bool(user_value), '用户级覆盖' elif position_value is not None: allowed, source = bool(position_value), '岗位权限' elif role_value is not None: allowed, source = bool(role_value), '角色默认' else: allowed, source = bool(default_value), '继承页面权限' item[operation] = {'allowed': allowed, 'source': source} page_fields.append(item) if page_fields: fields[page] = page_fields return jsonify({ 'code': 0, 'data': {'user': user, 'pages': pages, 'fields': fields}, }) def _inherited_field_perms_map(user_id, role): """返回角色+岗位字段继承值,供用户权限面板显示。""" from services.field_perms import FIELD_REGISTRY, inherited_field_perms_for_page result = {} for page in FIELD_REGISTRY: perms = inherited_field_perms_for_page(user_id, role, page) result[page] = { key: {'view': 1 if value['view'] else 0, 'edit': 1 if value['edit'] else 0} for key, value in perms.items() } return result @auth_bp.route('/api/permissions/user/field-perms/save', methods=['POST']) @login_required def permission_user_field_save(): """保存用户字段三态权限,执行职责冲突检查与版本留档。""" if session.get('role') not in ('admin', 'super_admin'): return jsonify({"code": 403, "msg": "仅管理员可操作"}) data = request.get_json(silent=True) or {} user_id = data.get('user_id') items = data.get('items', []) if not user_id: return jsonify({"code": 400, "msg": "参数错误"}) target = query("SELECT role FROM dashboard_users WHERE id=%s", (user_id,)) if not target: return jsonify({"code": 400, "msg": "用户不存在"}) if target[0]['role'] == 'super_admin': return jsonify({"code": 403, "msg": "超级管理员权限不可修改"}) if session.get('role') == 'admin' and target[0]['role'] == 'admin': return jsonify({"code": 403, "msg": "管理员不能修改其他管理员的个人权限"}) conflicts = _detect_permission_conflicts(_effective_user_pairs(int(user_id), field_items=items)) if conflicts and not data.get('confirm_conflicts'): return jsonify({"code": 409, "msg": "检测到职责冲突,请确认后再保存", "data": {"conflicts": conflicts}}), 409 _ensure_permission_baseline('user', user_id) try: from services.db import get_db conn = get_db() with conn.cursor() as cur: for item in items: page = normalize_permission_page(str(item.get('page', '')).strip()) field = str(item.get('field', '')).strip() op = str(item.get('operation', 'view')).strip() if not page or not field or op not in ('view', 'edit'): continue operation = f'field:{field}:{op}' perm = item.get('permission') if perm is None or perm == '': cur.execute("DELETE FROM user_permissions WHERE user_id=%s AND page=%s AND operation=%s", (user_id, page, operation)) else: value = 1 if int(perm) == 1 else 0 cur.execute( "INSERT INTO user_permissions (user_id,page,operation,permission) VALUES (%s,%s,%s,%s) " "ON DUPLICATE KEY UPDATE permission=%s", (user_id, page, operation, value, value) ) conn.commit() conn.close() _create_permission_version('user', user_id, '保存用户字段权限') add_operation_log('权限变更', f'保存用户 #{user_id} 字段级权限') _record_permission_change('user', user_id, 'user_field_save', after=items, detail=f'保存用户 #{user_id} 字段级权限') return jsonify({"code": 0, "msg": "保存成功", "data": {"conflicts": conflicts}}) except Exception as e: return jsonify({"code": 500, "msg": str(e)}) # ─── 人员信息管理 ─── _DEFAULT_PERSONNEL_GROUPS = [ {'name': '跟单一组', 'leader': '方园霞', 'members': ['方园霞', '邝碧君', '余瑞霞', '卢浩然'], 'color': '#e6f4ff', 'tagColor': '#1677ff', 'icon': '👥'}, {'name': '跟单二组', 'leader': '王加飞', 'members': ['许晓珊', '刘伟', '王加飞', '邓文婷'], 'color': '#f0f5ff', 'tagColor': '#722ed1', 'icon': '👥'}, {'name': '跟单三组', 'leader': '蔡少铃', 'members': ['闫伟华', '蔡少铃', '颜于凯', '张文菁'], 'color': '#e6fffb', 'tagColor': '#13c2c2', 'icon': '👥'}, {'name': '现货小组', 'leader': '', 'members': ['莫碧霞', '范亚欢'], 'color': '#fff7e6', 'tagColor': '#fa8c16', 'icon': '👥'}, {'name': '计划小组', 'leader': '汤定毅', 'members': ['汤定毅', '李媛', '黄静', '胡孟怡', '张莹纯', '范世澄', '陈瑜'], 'color': '#f6ffed', 'tagColor': '#52c41a', 'icon': '📋'} ] def _ensure_personnel_table(): query("""CREATE TABLE IF NOT EXISTS personnel_groups ( group_name VARCHAR(100) NOT NULL PRIMARY KEY, group_order INT NOT NULL DEFAULT 0, group_category VARCHAR(30) NOT NULL DEFAULT '采购跟单', leader_name VARCHAR(100) NOT NULL DEFAULT '', members_json TEXT NOT NULL, color VARCHAR(20) NOT NULL DEFAULT '#fafafa', tag_color VARCHAR(20) NOT NULL DEFAULT '#1677ff', icon VARCHAR(20) NOT NULL DEFAULT '👥', updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""") category_col = query("""SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='personnel_groups' AND COLUMN_NAME='group_category'""") if not category_col: query("ALTER TABLE personnel_groups ADD COLUMN group_category VARCHAR(30) NOT NULL DEFAULT '采购跟单' AFTER group_order") query("UPDATE personnel_groups SET group_category='采购计划', icon='📋' WHERE group_name='计划小组'") rows = query("SELECT group_name FROM personnel_groups") if rows: return import json for index, group in enumerate(_DEFAULT_PERSONNEL_GROUPS): category = '采购计划' if group['name'] == '计划小组' else '采购跟单' query("""INSERT INTO personnel_groups (group_name, group_order, group_category, leader_name, members_json, color, tag_color, icon) VALUES (%s,%s,%s,%s,%s,%s,%s,%s)""", ( group['name'], index, category, group['leader'], json.dumps(group['members'], ensure_ascii=False), group['color'], group['tagColor'], group['icon'])) @auth_bp.route('/api/personnel/groups') @login_required def personnel_groups(): import json _ensure_personnel_table() rows = query("SELECT group_name, group_category, leader_name, members_json, color, tag_color, icon FROM personnel_groups ORDER BY group_order, group_name") groups = [] for row in rows: try: members = json.loads(row['members_json'] or '[]') except (TypeError, ValueError): members = [] groups.append({ 'name': row['group_name'], 'category': row['group_category'] or '采购跟单', 'leader': row['leader_name'] or '', 'members': members, 'color': row['color'], 'tagColor': row['tag_color'], 'icon': row['icon'] }) return jsonify({'code': 0, 'data': groups}) @auth_bp.route('/api/personnel/groups/create', methods=['POST']) @login_required def personnel_group_create(): import json if session.get('role') not in ('admin', 'super_admin'): return jsonify({'code': 403, 'msg': '仅管理员可新增人员分组'}) data = request.get_json(silent=True) or {} name = str(data.get('name') or '').strip() category = str(data.get('category') or '').strip() if not name: return jsonify({'code': 400, 'msg': '组别名称不能为空'}) if category not in ('采购跟单', '采购计划'): return jsonify({'code': 400, 'msg': '请选择正确的组别类型'}) _ensure_personnel_table() if query("SELECT group_name FROM personnel_groups WHERE group_name=%s", (name,)): return jsonify({'code': 400, 'msg': '该组别名称已存在'}) order_row = query("SELECT COALESCE(MAX(group_order), -1) + 1 AS next_order FROM personnel_groups") next_order = int(order_row[0]['next_order']) if order_row else 0 icon = '📋' if category == '采购计划' else '👥' query("""INSERT INTO personnel_groups (group_name, group_order, group_category, leader_name, members_json, color, tag_color, icon) VALUES (%s,%s,%s,'',%s,%s,%s,%s)""", (name, next_order, category, json.dumps([], ensure_ascii=False), '#fafafa', '#1677ff', icon)) add_operation_log('人员信息管理', f'新增人员分组:{name}({category})') return jsonify({'code': 0, 'msg': '新增成功'}) @auth_bp.route('/api/personnel/groups/save', methods=['POST']) @login_required def personnel_group_save(): import json if session.get('role') not in ('admin', 'super_admin'): return jsonify({'code': 403, 'msg': '仅管理员可编辑人员分组'}) data = request.get_json(silent=True) or {} name = str(data.get('name') or '').strip() members = data.get('members') if isinstance(data.get('members'), list) else [] members = list(dict.fromkeys(str(member).strip() for member in members if str(member).strip())) leader = str(data.get('leader') or '').strip() if not name: return jsonify({'code': 400, 'msg': '组别名称不能为空'}) if leader and leader not in members: return jsonify({'code': 400, 'msg': '组长必须属于该组人员'}) _ensure_personnel_table() existing = query("SELECT group_name FROM personnel_groups WHERE group_name=%s", (name,)) if not existing: return jsonify({'code': 404, 'msg': '未找到该人员分组'}) other_rows = query("SELECT group_name, members_json FROM personnel_groups WHERE group_name<>%s", (name,)) for other in other_rows: try: other_members = json.loads(other['members_json'] or '[]') except (TypeError, ValueError): other_members = [] duplicated = next((member for member in members if member in other_members), '') if duplicated: return jsonify({'code': 400, 'msg': f'{duplicated} 已属于 {other["group_name"]},不能重复加入'}) query("""UPDATE personnel_groups SET leader_name=%s, members_json=%s WHERE group_name=%s""", (leader, json.dumps(members, ensure_ascii=False), name)) add_operation_log('人员信息管理', f'保存人员分组:{name},共{len(members)}人') return jsonify({'code': 0, 'msg': '保存成功'}) def _ensure_param_list_table(): """确保云仓外仓权重列表存在并带有初始参数(幂等,部署后不会出现空表)。""" query(""" CREATE TABLE IF NOT EXISTS param_list ( id INT AUTO_INCREMENT PRIMARY KEY, param_name VARCHAR(50) NOT NULL COMMENT '参数名称', param_logic VARCHAR(255) NOT NULL COMMENT '参数计算逻辑', sort_order INT DEFAULT 0 COMMENT '排序', updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='云仓外仓权重列表' """) rows = query("SELECT COUNT(*) AS cnt FROM param_list") if not rows or not rows[0]['cnt']: query("INSERT INTO param_list (param_name, param_logic, sort_order) VALUES (%s, %s, %s)", ('X', '云仓销量/昨日销量', 1)) query("INSERT INTO param_list (param_name, param_logic, sort_order) VALUES (%s, %s, %s)", ('Y', '外仓销量/昨日销量', 2)) @auth_bp.route('/api/config/list') @login_required def config_list(): _ensure_param_list_table() trend_rows = query("SELECT * FROM trend_config ORDER BY id") sales_rows = query("SELECT * FROM sales_predict_config ORDER BY id") global_rows = query("SELECT * FROM global_config ORDER BY config_key") param_rows = query("SELECT id, param_name, param_logic FROM param_list ORDER BY sort_order, id") return jsonify({ "code": 0, "data": { "trend_config": trend_rows, "sales_predict_config": sales_rows, "global_config": global_rows, "param_list": param_rows } }) @auth_bp.route('/api/config/save_global', methods=['POST']) @login_required def config_save_global(): if session.get('role') not in ('admin', 'super_admin'): return jsonify({"code": 403, "msg": "仅管理员可操作"}) data = request.json for item in data: if 'config_key' in item and 'config_value' in item: query("UPDATE global_config SET config_value=%s WHERE config_key=%s", (item['config_value'], item['config_key'])) add_operation_log('系统配置', f'保存全局配置:{len(data)}项') return jsonify({"code": 0, "msg": "保存成功"}) @auth_bp.route('/api/config/save_trend', methods=['POST']) @login_required def config_save_trend(): """保存趋势判断配置""" if session.get('role') not in ('admin', 'super_admin'): return jsonify({"code": 403, "msg": "仅管理员可操作"}) data = request.json for item in data: if 'id' in item: query("UPDATE trend_config SET trend_type=%s, condition_expr=%s, notes=%s WHERE id=%s", (item.get('trend_type', ''), item.get('condition_expr', ''), item.get('notes', ''), item['id'])) add_operation_log('系统配置', f'保存趋势判断配置:{len(data)}项') return jsonify({"code": 0, "msg": "保存成功"}) @auth_bp.route('/api/config/save_sales', methods=['POST']) @login_required def config_save_sales(): """保存销量预测配置""" if session.get('role') not in ('admin', 'super_admin'): return jsonify({"code": 403, "msg": "仅管理员可操作"}) data = request.get_json(silent=True) if not isinstance(data, list) or not data: return jsonify({"code": 400, "msg": "参数格式错误"}) # 先完整校验本次提交,避免部分行已写入、后续行校验失败。 limits = { 'avg_3d': Decimal('999.99'), 'avg_7d': Decimal('999.99'), 'avg_14d': Decimal('999.99'), 'adjust_factor': Decimal('99.99'), } parsed = [] try: for item in data: if not isinstance(item, dict) or 'id' not in item: raise ValueError('缺少配置行ID') try: row_id = int(item['id']) except (TypeError, ValueError): raise ValueError('配置行ID无效') values = {} for field, maximum in limits.items(): try: number = Decimal(str(item.get(field, '')).strip()) except (AttributeError, InvalidOperation, ValueError): raise ValueError(f'{field} 必须是数字') if not number.is_finite() or number < 0 or number > maximum: raise ValueError(f'{field} 必须在 0 至 {maximum} 之间') values[field] = number trend_type = str(item.get('trend_type') or '').strip() if not trend_type: raise ValueError('趋势类型不能为空') parsed.append((values['avg_3d'], values['avg_7d'], values['avg_14d'], values['adjust_factor'], trend_type, row_id)) except ValueError as exc: return jsonify({"code": 400, "msg": str(exc)}) for avg_3d, avg_7d, avg_14d, adjust_factor, trend_type, row_id in parsed: query("UPDATE sales_predict_config SET avg_3d=%s, avg_7d=%s, avg_14d=%s, adjust_factor=%s, trend_type=%s WHERE id=%s", (avg_3d, avg_7d, avg_14d, adjust_factor, trend_type, row_id)) from services.forecast import refresh_sales_predict_config refresh_sales_predict_config() add_operation_log('系统配置', f'保存销量预测配置:{len(parsed)}项') return jsonify({"code": 0, "msg": "保存成功"}) @auth_bp.route('/api/config/sandbox_save', methods=['POST']) @login_required def config_sandbox_save(): """保存校准配置(沙箱)""" data = request.json items = data.get('items', []) for item in items: ct = item.get('config_table', '') rid = item.get('row_id', 0) fn = item.get('field_name', '') sv = item.get('sandbox_value', '') ov = item.get('original_value', '') query("""INSERT INTO config_sandbox (config_table, row_id, field_name, original_value, sandbox_value) VALUES (%s, %s, %s, %s, %s) ON DUPLICATE KEY UPDATE sandbox_value=%s, original_value=%s""", (ct, rid, fn, ov, sv, sv, ov)) add_operation_log('系统配置', f'保存校准配置(沙箱):{len(items)}项') return jsonify({"code": 0, "msg": "保存成功"}) @auth_bp.route('/api/config/sandbox_load') @login_required def config_sandbox_load(): """加载校准配置""" rows = query("SELECT * FROM config_sandbox ORDER BY config_table, row_id, field_name") return jsonify({"code": 0, "data": rows}) @auth_bp.route('/api/config/sandbox_preview') @login_required def config_sandbox_preview(): """预览校准效果:返回少量产品在校准参数下的预测结果vs原始结果""" from services.forecast import compute_daily_forecast, get_global_config, sales_predict_config_cached from services.inventory_calc import compute_product_inventory_cached, compute_product_inventory_15, compute_product_in_transit_cached, _determine_trend from services.sales_calc import compute_product_sales_cached from services.product_type import get_product_type_cached from routes.planning import _enrich_row_with_planning_data from services.forecast import is_new_product, get_product_grade # 获取原始产品数据(取前10个有库存的产品) sample = query(""" SELECT p.product_code, p.product_name, p.brand, p.product_type, p.cost_price, p.lead_time AS 生产货期, p.logistics_time AS 物流时间, p.created_at AS 创建时间 FROM products p WHERE p.product_type != '包材' ORDER BY RAND() LIMIT 10 """) sales = compute_product_sales_cached() inv_map = compute_product_inventory_cached() inv15_map = compute_product_inventory_15() transit_map = compute_product_in_transit_cached() # 原始配置 gc = get_global_config() sp_map = {r['trend_type']: r for r in sales_predict_config_cached()} # 沙箱配置(覆盖) sandbox = query("SELECT * FROM config_sandbox") sb_by_table = {} for s in sandbox: key = (s['config_table'], s['row_id']) if key not in sb_by_table: sb_by_table[key] = {} sb_by_table[key][s['field_name']] = s['sandbox_value'] original_rows = [] sandbox_rows = [] for row in sample: if row.get('创建时间'): row['创建时间'] = row['创建时间'].strftime('%Y-%m-%d') if hasattr(row['创建时间'], 'strftime') else str(row['创建时间'])[:10] row['产品种类'] = get_product_type_cached(row.get('product_name', ''), row.get('brand', ''), row.get('product_type')) # 原始 r1 = dict(row) r1 = _enrich_row_with_planning_data(r1, sales, inv_map, inv15_map, transit_map, sp_map, gc) original_rows.append(r1) # 校准(待实现:用沙箱参数覆盖后再计算) r2 = dict(row) # 为简化,暂时用相同值 r2 = _enrich_row_with_planning_data(r2, sales, inv_map, inv15_map, transit_map, sp_map, gc) sandbox_rows.append(r2) return jsonify({"code": 0, "data": { "original": original_rows, "sandbox": sandbox_rows }})