From 6a94176f8a75d608e6067d3e3fe2f847e59923ac Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 12 Oct 2026 10:17:24 +0800 Subject: [PATCH] feat: allow deleting unused brands --- routes/auth.py | 2021 ++++++++++++++ routes/products.py | 3609 +++++++++++++++++++++++++ static/js/app.js | 5 +- tests/test_brand_management_delete.py | 61 + 4 files changed, 5694 insertions(+), 2 deletions(-) create mode 100644 routes/auth.py create mode 100644 routes/products.py create mode 100644 tests/test_brand_management_delete.py diff --git a/routes/auth.py b/routes/auth.py new file mode 100644 index 0000000..2aad9ad --- /dev/null +++ b/routes/auth.py @@ -0,0 +1,2021 @@ +"""认证/登录/权限管理路由""" +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'], +} + +# 只对无歧义的现有页面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':'删除申请'}], + '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':'删除'}], + 'feedback': [{'id':'view','name':'查看'},{'id':'submit','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':'关系图谱'}], + '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':'删除'}], +} +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 + }}) diff --git a/routes/products.py b/routes/products.py new file mode 100644 index 0000000..50f29df --- /dev/null +++ b/routes/products.py @@ -0,0 +1,3609 @@ +"""产品管理路由""" +import os +import uuid +from datetime import datetime +from flask import Blueprint, request, jsonify, session, redirect, url_for, render_template +from services.db import query, add_operation_log, get_db +from services.product_type import get_product_type_cached +from services.sales_calc import (compute_product_sales_cached, + compute_product_sales_extended_cached) +from services.inventory_calc import (compute_product_inventory_cached, + compute_product_inventory_15, + compute_cloud_inventory_cached, + compute_external_inventory_cached, + compute_product_in_transit_cached, + _determine_trend) +from services.forecast import (is_new_product, get_product_grade, + compute_daily_forecast, + sales_predict_config_cached) +from routes.auth import login_required, role_required, operation_required +from services.field_perms import filter_rows as _filter_field_rows + +products_bp = Blueprint('products', __name__) + +# 计划预测下单信息弹窗显示内料的货品名称关键字:海外品牌还要命中国家名才显示内料 +INNER_MATERIAL_NAME_KEYS = ('新西兰', '新加坡', '韩国') + +# BOM 的 supplier 字段历史上混入过产品类型(例如“原料”),不能作为供应商名称展示。 +# 这些值只在 BOM 供应商字段中视为无效,随后回退到供应商主表或物料基础资料。 +BOM_INVALID_SUPPLIER_VALUES = frozenset(('原料', '包材', '成品', '其他')) + + +def _normalize_bom_supplier(value): + """清理 BOM 供应商输入,避免把产品类型写入供应商字段。""" + value = str(value or '').strip() + return '' if value in BOM_INVALID_SUPPLIER_VALUES else value + + +def _bom_material_supplier_sql(b_alias='b', material_alias='m', supplier_alias='ms'): + """返回 BOM 物料供应商的统一 SQL 表达式。 + + 优先使用 BOM 中明确填写的真实供应商;历史无效类型值(如“原料”) + 视为空,再回退到供应商主表,最后回退到物料基础资料中的匹配名称。 + """ + invalid = ', '.join("'%s'" % value for value in sorted(BOM_INVALID_SUPPLIER_VALUES)) + return ( + f"COALESCE(NULLIF(CASE WHEN TRIM({b_alias}.supplier) IN ({invalid}) " + f"THEN '' ELSE TRIM({b_alias}.supplier) END, ''), " + f"NULLIF(TRIM({supplier_alias}.supplier_name), ''), " + f"NULLIF(TRIM({material_alias}.supplier_name_match), ''), '')" + ) + + +def _check_product_edit_perm(): + """统一检查产品编辑权限,角色默认权限与用户覆盖规则一致。""" + return operation_required('products', 'edit') + + +@products_bp.route('/products') +@login_required +def products_page(): + if not role_required('products'): + return redirect(url_for('auth.index')) + return render_template('base.html', page='products') + + +@products_bp.route('/brand-management') +@login_required +def brand_management_page(): + if not role_required('products'): + return redirect(url_for('auth.index')) + return render_template('base.html', page='brand_management') + + +@products_bp.route('/api/brand-management/list') +@login_required +def brand_management_list(): + if not role_required('products'): + return jsonify({"code": 403, "msg": "权限不足"}) + search = (request.args.get('search') or '').strip() + category = (request.args.get('category') or '').strip() + page = max(int(request.args.get('page', 1)), 1) + size = 10 + # 产品表中的品牌 + 手动维护的品牌合并展示;产品数量不再作为页面字段。 + product_rows = query("""SELECT TRIM(brand) AS brand FROM products + WHERE deleted_at IS NULL AND brand IS NOT NULL AND TRIM(brand) <> '' + GROUP BY TRIM(brand) ORDER BY TRIM(brand)""") + saved_rows = query("SELECT brand, category, updated_at FROM brand_information ORDER BY brand") + saved = {r['brand']: r for r in saved_rows} + names = {r['brand'] for r in product_rows} + names.update(saved.keys()) + rows = [] + for brand in sorted(names, key=lambda x: x.casefold()): + if search and search.casefold() not in brand.casefold(): + continue + info = saved.get(brand) or {} + cat = info.get('category') or '' + if category == '未分类' and cat: + continue + if category in ('国内', '海外') and cat != category: + continue + updated = info.get('updated_at') + rows.append({'brand': brand, 'category': cat, + 'updated_at': updated.strftime('%Y-%m-%d %H:%M:%S') if updated and hasattr(updated, 'strftime') else (str(updated) if updated else '')}) + total = len(rows) + return jsonify({"code": 0, "data": rows[(page - 1) * size:page * size], "total": total, "page": page, "size": size}) + + +@products_bp.route('/api/brand-management/create', methods=['POST']) +@login_required +def brand_management_create(): + if not _check_product_edit_perm(): + return jsonify({"code": 403, "msg": "当前账号没有编辑权限"}) + data = request.get_json(silent=True) or {} + brand = (data.get('brand') or '').strip() + category = (data.get('category') or '').strip() + if not brand: + return jsonify({"code": 400, "msg": "品牌名称不能为空"}) + if category not in ('', '国内', '海外'): + return jsonify({"code": 400, "msg": "品牌分类只能是国内或海外"}) + exists = query("SELECT 1 FROM brand_information WHERE brand=%s LIMIT 1", (brand,)) + product_exists = query("SELECT 1 FROM products WHERE deleted_at IS NULL AND TRIM(brand)=%s LIMIT 1", (brand,)) + if exists or product_exists: + return jsonify({"code": 400, "msg": "该品牌已存在"}) + query("INSERT INTO brand_information (brand, category) VALUES (%s, %s)", (brand, category)) + add_operation_log('新增品牌', f"新增品牌「{brand}」", page='品牌信息管理', field='品牌名称') + return jsonify({"code": 0, "msg": "新增成功"}) + + +@products_bp.route('/api/brand-management/update', methods=['POST']) +@login_required +def brand_management_update(): + if not _check_product_edit_perm(): + return jsonify({"code": 403, "msg": "当前账号没有编辑权限"}) + data = request.get_json(silent=True) or {} + old_brand = (data.get('old_brand') or data.get('brand') or '').strip() + new_brand = (data.get('new_brand') or old_brand).strip() + category = data.get('category') + category = (category if category is not None else '').strip() + if not old_brand or not new_brand: + return jsonify({"code": 400, "msg": "品牌名称不能为空"}) + if category not in ('', '国内', '海外'): + return jsonify({"code": 400, "msg": "品牌分类只能是国内或海外"}) + if old_brand != new_brand: + conflict = query("SELECT 1 FROM brand_information WHERE brand=%s LIMIT 1", (new_brand,)) + product_conflict = query("SELECT 1 FROM products WHERE deleted_at IS NULL AND TRIM(brand)=%s LIMIT 1", (new_brand,)) + if conflict or product_conflict: + return jsonify({"code": 400, "msg": "新的品牌名称已存在"}) + has_product = query("SELECT 1 FROM products WHERE deleted_at IS NULL AND TRIM(brand)=%s LIMIT 1", (old_brand,)) + has_saved = query("SELECT 1 FROM brand_information WHERE brand=%s LIMIT 1", (old_brand,)) + if not has_product and not has_saved: + return jsonify({"code": 404, "msg": "未找到该品牌"}) + if has_product and old_brand != new_brand: + query("UPDATE products SET brand=%s WHERE deleted_at IS NULL AND TRIM(brand)=%s", (new_brand, old_brand)) + if has_saved: + query("UPDATE brand_information SET brand=%s, category=%s, updated_at=CURRENT_TIMESTAMP WHERE brand=%s", (new_brand, category, old_brand)) + else: + query("INSERT INTO brand_information (brand, category) VALUES (%s, %s)", (new_brand, category)) + add_operation_log('编辑品牌', f"将品牌「{old_brand}」修改为「{new_brand}」", page='品牌信息管理', field='品牌名称') + return jsonify({"code": 0, "msg": "保存成功"}) + + +@products_bp.route('/api/brand-management/delete', methods=['POST']) +@login_required +def brand_management_delete(): + """删除误添加且未被有效产品使用的品牌。""" + if not operation_required('brand_management', 'delete'): + return jsonify({"code": 403, "msg": "当前账号没有删除品牌权限"}) + data = request.get_json(silent=True) or {} + brand = (data.get('brand') or '').strip() + if not brand: + return jsonify({"code": 400, "msg": "品牌名称不能为空"}) + + product_rows = query( + "SELECT COUNT(*) AS cnt FROM products " + "WHERE deleted_at IS NULL AND TRIM(brand)=%s", + (brand,), + ) + product_count = int(product_rows[0].get('cnt') or 0) if product_rows else 0 + if product_count: + return jsonify({ + "code": 409, + "msg": f"品牌「{brand}」正在被{product_count}个有效产品使用,请先修改这些产品的品牌后再删除", + "product_count": product_count, + }) + + saved = query("SELECT brand, category FROM brand_information WHERE brand=%s LIMIT 1", (brand,)) + if not saved: + return jsonify({"code": 404, "msg": "未找到可删除的品牌记录"}) + query("DELETE FROM brand_information WHERE brand=%s", (brand,)) + add_operation_log( + '删除品牌', f"删除品牌「{brand}」", page='品牌信息管理', field='品牌名称', + object_type='brand', object_id=brand, old_values=saved[0], + ) + return jsonify({"code": 0, "msg": "删除成功"}) + + +@products_bp.route('/bom') +@login_required +def bom_page(): + if not role_required('bom'): + return redirect(url_for('auth.index')) + return render_template('base.html', page='bom') + + +@products_bp.route('/suppliers') +@login_required +def suppliers_page(): + if not role_required('suppliers'): + return redirect(url_for('auth.index')) + return render_template('base.html', page='suppliers') + + +# ─── API: 产品基础表 ─── +@products_bp.route('/api/products/list') +@login_required +def products_list(): + page = int(request.args.get('page', 1)) + size = int(request.args.get('size', 50)) + offset = (page - 1) * size + + search = request.args.get('search', '') + unified_search = request.args.get('unified_search', '') + prod_code = request.args.get('prod_code', '') + prod_name = request.args.get('prod_name', '') + supplier_filter = request.args.get('supplier', '') + brand_filter = request.args.get('brand', '') + category1_filter = request.args.get('category1', '') + product_type_filter = request.args.get('product_type', '') + sort_by = request.args.get('sort_by', 'id') + sort_order = request.args.get('sort_order', 'desc') + + # Whitelist sort columns to prevent SQL injection + allowed_sort = ['id', 'goods_code', 'product_code', 'product_name', 'brand', 'cost_price', 'category1', 'category2', 'category3', 'created_at', 'import_date'] + if sort_by not in allowed_sort: + sort_by = 'id' + if sort_order.lower() not in ('asc', 'desc'): + sort_order = 'desc' + + where = [] + params = [] + + if search or unified_search: + search_value = search or unified_search + where.append("(p.product_name LIKE %s OR p.product_code LIKE %s)") + params.extend([f"%{search_value}%", f"%{search_value}%"]) + if prod_code: + where.append("p.product_code LIKE %s") + params.append(f"%{prod_code}%") + if prod_name: + where.append("p.product_name LIKE %s") + params.append(f"%{prod_name}%") + if supplier_filter: + where.append("(s.supplier_name LIKE %s OR p.supplier_name_match LIKE %s)") + params.extend([f"%{supplier_filter}%", f"%{supplier_filter}%"]) + if brand_filter: + where.append("p.brand=%s") + params.append(brand_filter) + if category1_filter: + where.append("p.category1=%s") + params.append(category1_filter) + + where_clause = " AND ".join(where) if where else "1=1" + + # 加载供应商列表用于自动匹配编码 + _suppliers = query("SELECT supplier_code, supplier_name FROM suppliers") + + def _format_product_row(row): + if 'created_at' in row and row['created_at']: + row['created_at'] = row['created_at'].strftime('%Y-%m-%d %H:%M:%S') if hasattr(row['created_at'], 'strftime') else str(row['created_at'])[:19] + if 'import_date' in row and row['import_date']: + row['import_date'] = row['import_date'].strftime('%Y-%m-%d') if hasattr(row['import_date'], 'strftime') else str(row['import_date'])[:10] + if 'cost_price' in row and row['cost_price'] is not None: + row['cost_price'] = float(row['cost_price']) + if row.get('logistics_time') is None: + row['logistics_time'] = 0 + if row.get('lead_time') is None: + row['lead_time'] = 0 + row['是否新品'] = is_new_product(row.get('created_at', '')) + if not row.get('product_type'): + row['product_type'] = get_product_type_cached(row.get('product_name', ''), row.get('brand'), row.get('product_type')) + # 自动匹配供应商编码(如果supplier_code为空但有supplier_name_match) + pname = (row.get('supplier_name_match') or '').strip() + pcode = (row.get('supplier_code') or '').strip() + if pname and (not pcode or pcode == ''): + for s in _suppliers: + sname = (s['supplier_name'] or '').strip() + if pname in sname or sname in pname: + row['supplier_code'] = s['supplier_code'] + row['supplier_name_match'] = s['supplier_name'] + try: + query("UPDATE products SET supplier_code=%s, supplier_name_match=%s WHERE product_code=%s", + (s['supplier_code'], s['supplier_name'], row['product_code'])) + except: + pass + break + return row + + # 如果有类型筛选,需要全量查出来再在 Python 中过滤 + if product_type_filter: + all_rows = query(f""" + SELECT p.id, p.goods_code, p.product_code, p.product_name, p.brand, p.cost_price, + p.product_type, p.specification, p.lead_time, p.logistics_time, p.min_order_qty, + p.supplier_code, p.supplier_name_match, + p.procurement_follower, p.procurement_planner, p.platform, p.operator, p.developer, + p.category1, p.category2, p.category3, p.created_at, p.import_date, + p.custom_field1, p.custom_field2, p.custom_field3, p.custom_field4, p.custom_field5, + COALESCE(s.supplier_name, p.supplier_name_match) AS supplier_name + FROM products p + LEFT JOIN suppliers s ON p.supplier_code = s.supplier_code + WHERE {where_clause} AND p.deleted_at IS NULL + ORDER BY p.id DESC + """, params) + + filtered = [] + for row in all_rows: + row = _format_product_row(row) + if product_type_filter == '非成品': + # 非成品:排除成品 + if row['product_type'] != '成品': + filtered.append(row) + elif product_type_filter == '非包材': + # 非包材:排除包材和其他类 + if row['product_type'] != '包材' and row['product_type'] != '其他': + filtered.append(row) + elif product_type_filter == '非成品专用': + # 非成品专用:仅包材和其他 + if row['product_type'] == '包材' or row['product_type'] == '其他': + filtered.append(row) + else: + if row['product_type'] == product_type_filter: + filtered.append(row) + + reverse = sort_order.lower() == 'desc' + if sort_by in ['cost_price', 'id']: + filtered.sort(key=lambda x: x.get(sort_by, 0) if x.get(sort_by) else 0, reverse=reverse) + else: + filtered.sort(key=lambda x: str(x.get(sort_by, '') or '').lower(), reverse=reverse) + + total = len(filtered) + rows = filtered[offset:offset + size] + else: + total = query(f"""SELECT COUNT(*) AS cnt FROM products p + LEFT JOIN suppliers s ON p.supplier_code = s.supplier_code + WHERE {where_clause} AND p.deleted_at IS NULL""", params)[0]['cnt'] + rows = query(f""" + SELECT p.id, p.goods_code, p.product_code, p.product_name, p.brand, p.cost_price, + p.product_type, p.specification, p.lead_time, p.logistics_time, p.min_order_qty, + p.supplier_code, p.supplier_name_match, + p.procurement_follower, p.procurement_planner, p.platform, p.operator, p.developer, + p.category1, p.category2, p.category3, p.created_at, p.import_date, + p.custom_field1, p.custom_field2, p.custom_field3, p.custom_field4, p.custom_field5, + COALESCE(s.supplier_name, p.supplier_name_match) AS supplier_name + FROM products p + LEFT JOIN suppliers s ON p.supplier_code = s.supplier_code + WHERE {where_clause} AND p.deleted_at IS NULL + ORDER BY p.{sort_by} {sort_order} + LIMIT {size} OFFSET {offset} + """, params) + + for row in rows: + _format_product_row(row) + + _filter_field_rows('products', rows, session.get('user_id'), session.get('role'), + page_view=bool(role_required('products')), + page_edit=operation_required('products', 'edit')) + return jsonify({ + "code": 0, + "data": { + "total": total, + "page": page, + "size": size, + "rows": rows + } + }) + + +@products_bp.route('/api/products/filters') +@login_required +def products_filters(): + brands = query("SELECT DISTINCT brand FROM products WHERE brand IS NOT NULL AND brand != '' ORDER BY brand") + categories = query("SELECT DISTINCT category1 FROM products WHERE category1 IS NOT NULL AND category1 != '' ORDER BY category1") + return jsonify({ + "code": 0, + "data": { + "brands": [b['brand'] for b in brands], + "categories": [c['category1'] for c in categories] + } + }) + + +@products_bp.route('/api/products/detail') +@login_required +def products_detail(): + pid = request.args.get('id', 0) + row = query(""" + SELECT p.*, COALESCE(s.supplier_name, p.supplier_name_match) AS supplier_name + FROM products p + LEFT JOIN suppliers s ON p.supplier_code = s.supplier_code + WHERE p.id=%s + """, (pid,)) + if not row: + return jsonify({"code": 404, "msg": "未找到该产品"}) + row = row[0] + if 'created_at' in row and row['created_at']: + row['created_at'] = row['created_at'].strftime('%Y-%m-%d %H:%M:%S') if hasattr(row['created_at'], 'strftime') else str(row['created_at'])[:19] + if 'import_date' in row and row['import_date']: + row['import_date'] = row['import_date'].strftime('%Y-%m-%d') if hasattr(row['import_date'], 'strftime') else str(row['import_date'])[:10] + if 'cost_price' in row and row['cost_price'] is not None: + row['cost_price'] = float(row['cost_price']) + row['product_type'] = get_product_type_cached(row.get('product_name', ''), row.get('brand', ''), row.get('product_type')) + return jsonify({"code": 0, "data": row}) + + +@products_bp.route('/api/product/update_fields', methods=['POST']) +@login_required +def product_update_fields(): + """更新产品字段(仅管理员)""" + if not _check_product_edit_perm(): + return jsonify({"code": 403, "msg": "仅管理员可操作"}) + + data = request.json + product_code = data.get('product_code', '') + field = data.get('field', '') + value = data.get('value', '') + + allowed_fields = ['lead_time', 'logistics_time', 'min_order_qty', 'developer'] + if field not in allowed_fields: + return jsonify({"code": 400, "msg": "不允许的字段"}) + + try: + query(f"UPDATE products SET {field}=%s WHERE product_code=%s", (value, product_code)) + add_operation_log('产品编辑', f'修改 {product_code} 的 {field} 为 {value}') + return jsonify({"code": 0, "msg": "更新成功"}) + except Exception as e: + return jsonify({"code": 500, "msg": str(e)}) + + +@products_bp.route('/api/product/batch_update', methods=['POST']) +@login_required +def product_batch_update(): + """批量更新产品字段(编辑弹窗用)""" + if not _check_product_edit_perm(): + return jsonify({"code": 403, "msg": "仅管理员可操作"}) + + data = request.json + product_code = data.get('product_code', '') + fields = data.get('fields', {}) + + if not product_code or not fields: + return jsonify({"code": 400, "msg": "参数不完整"}) + + allowed_fields = [ + 'lead_time', 'logistics_time', 'min_order_qty', 'developer', + 'cost_price', 'brand', 'specification', 'product_type', + 'category1', 'category2', 'category3', + 'supplier_code', 'supplier_name_match', + 'procurement_follower', 'procurement_planner', + 'operator', 'platform', 'product_name', 'created_at', + 'custom_field1', 'custom_field2', 'custom_field3', 'custom_field4', 'custom_field5', + 'remark', + 'product_code' + ] + + old_code = product_code + new_code = fields.get('product_code', old_code) + + set_parts = [] + params = [] + for field, value in fields.items(): + if field not in allowed_fields: + continue + if value == '' or value is None: + value = None + # 数值字段范围校验 + if field in ('lead_time', 'logistics_time') and value is not None: + try: + if int(value) > 999: + return jsonify({"code": 400, "msg": f"{field} 数值不能超过 999"}) + except (ValueError, TypeError): + pass + set_parts.append(f"{field}=%s") + params.append(value) + + if not set_parts: + return jsonify({"code": 400, "msg": "没有可更新的字段"}) + + set_clause = ", ".join(set_parts) + params.append(old_code) + + try: + # 如果货品编号变更 + if new_code != old_code and new_code: + # 检查目标编号是否已被其他产品使用 + existing_owner = query("SELECT id FROM products WHERE product_code=%s AND product_code!=%s", (new_code, old_code)) + if existing_owner: + # 目标编号已存在 → 合并:更新关联表引用,删除旧产品 + for table_name, col_name in [ + ('bom', 'product_code'), + ('pending_orders', 'product_code'), + ('purchase_order_items', 'product_code'), + ('plan_hidden', 'product_code'), + ]: + query(f"UPDATE IGNORE {table_name} SET {col_name}=%s WHERE {col_name}=%s", (new_code, old_code)) + # 删除旧产品记录 + query("DELETE FROM products WHERE product_code=%s", (old_code,)) + # 更新已存在的目标产品(合并表单数据) + target_params = params[:-1] # 参数(不含old_code) + target_params.append(new_code) + query(f"UPDATE products SET {set_clause} WHERE product_code=%s", target_params) + change_info = f'{old_code}→{new_code}(已覆盖)' + else: + # 目标编号不存在,直接更新关联表和产品 + for table_name, col_name in [ + ('bom', 'product_code'), + ('pending_orders', 'product_code'), + ('purchase_order_items', 'product_code'), + ('plan_hidden', 'product_code'), + ]: + query(f"UPDATE IGNORE {table_name} SET {col_name}=%s WHERE {col_name}=%s", (new_code, old_code)) + query(f"UPDATE products SET {set_clause} WHERE product_code=%s", params) + change_info = f'{old_code}→{new_code}' + else: + query(f"UPDATE products SET {set_clause} WHERE product_code=%s", params) + change_info = old_code + # 产品名称变更时同步更新BOM的名称 + if 'product_name' in fields and fields['product_name']: + new_name = fields['product_name'] + final_code = new_code if new_code != old_code and new_code else old_code + query("UPDATE bom SET product_name=%s WHERE product_code=%s", (new_name, final_code)) + query("UPDATE bom SET material_name=%s WHERE material_code=%s", (new_name, final_code)) + + changed = ', '.join([f'{k}={v}' for k, v in fields.items() if v is not None]) + add_operation_log('产品批量编辑', f'{change_info}: {changed}') + return jsonify({"code": 0, "msg": "更新成功"}) + except Exception as e: + return jsonify({"code": 500, "msg": f"操作失败: {str(e)}"}) + + +@products_bp.route('/api/product/full_detail') +@login_required +def product_full_detail(): + code = request.args.get('code', '') + if not code: + return jsonify({"code": 400, "msg": "缺少货品编号"}) + + row = query(""" + SELECT p.*, COALESCE(s.supplier_name, p.supplier_name_match) AS supplier_name + FROM products p + LEFT JOIN suppliers s ON p.supplier_code = s.supplier_code + WHERE p.product_code=%s + """, (code,)) + if not row: + return jsonify({"code": 404, "msg": "未找到该产品"}) + product = row[0] + + if product.get('created_at'): + product['created_at'] = product['created_at'].strftime('%Y-%m-%d %H:%M:%S') if hasattr(product['created_at'], 'strftime') else str(product['created_at'])[:19] + if product.get('import_date'): + product['import_date'] = product['import_date'].strftime('%Y-%m-%d') if hasattr(product['import_date'], 'strftime') else str(product['import_date'])[:10] + if product.get('cost_price') is not None: + product['cost_price'] = float(product['cost_price']) + product['产品种类'] = get_product_type_cached(product.get('product_name', ''), product.get('brand', ''), product.get('product_type')) + + sales = query(""" + SELECT 日期, SUM(货品数量) AS 日销量 + FROM daily_outbound + WHERE 货品编号=%s + GROUP BY 日期 + ORDER BY 日期 ASC + """, (code,)) + + sales_data = [] + for s in sales: + d = s['日期'].strftime('%Y-%m-%d') if hasattr(s['日期'], 'strftime') else str(s['日期'])[:10] + sales_data.append({ + 'date': d, + 'qty': int(s['日销量']) if s['日销量'] else 0 + }) + + return jsonify({ + "code": 0, + "data": { + "product": product, + "sales": sales_data + } + }) + + +@products_bp.route('/api/product/detail_with_bom') +@login_required +def product_detail_with_bom(): + """产品详情 + BOM物料清单 + 订单数量""" + code = request.args.get('code', '') + include_inner = request.args.get('include_inner', '').lower() in ('1', 'true', 'yes') + if not code: + return jsonify({"code": 400, "msg": "缺少货品编号"}) + + # 1. 产品基础信息 + row = query(""" + SELECT p.*, COALESCE(s.supplier_name, p.supplier_name_match) AS supplier_name + FROM products p + LEFT JOIN suppliers s ON p.supplier_code = s.supplier_code + WHERE p.product_code=%s + """, (code,)) + if not row: + # BOM 管理列表可能包含仅存在于 bom 表、但产品基础资料尚未建立的历史产品。 + # 编辑弹窗需要先读出这类产品的 BOM;保存接口会负责补建产品记录。 + if include_inner: + bom_product = query(""" + SELECT product_code, MAX(product_name) AS product_name + FROM bom + WHERE product_code=%s + GROUP BY product_code + """, (code,)) + if bom_product: + product = { + 'product_code': bom_product[0]['product_code'], + 'product_name': bom_product[0].get('product_name') or code, + 'brand': '', + 'product_type': '成品', + 'supplier_name_match': '', + 'supplier_name': '', + 'cost_price': 0, + 'remark': '', + } + else: + return jsonify({"code": 404, "msg": "未找到该产品"}) + else: + return jsonify({"code": 404, "msg": "未找到该产品"}) + else: + product = row[0] + + if product.get('created_at'): + product['created_at'] = product['created_at'].strftime('%Y-%m-%d %H:%M:%S') if hasattr(product['created_at'], 'strftime') else str(product['created_at'])[:19] + if product.get('import_date'): + product['import_date'] = product['import_date'].strftime('%Y-%m-%d') if hasattr(product['import_date'], 'strftime') else str(product['import_date'])[:10] + if product.get('cost_price') is not None: + product['cost_price'] = float(product['cost_price']) + product['产品种类'] = get_product_type_cached(product.get('product_name', ''), product.get('brand', ''), product.get('product_type')) + product['批注'] = product.get('remark') or '' + + # 计划预测下单信息弹窗:include_inner=auto 时按品牌分类 + 货品名称自动决定是否包含内料。 + # 海外品牌商品还要看货品名称是否含新西兰/新加坡/韩国,命中才显示内料(并同步到包材订单管理); + # 其余海外品与国内品一致,保持原逻辑不含内料。 + if request.args.get('include_inner', '').lower() == 'auto': + _brand = (product.get('brand') or '').strip() + _brand_rows = query(""" + SELECT category FROM brand_information + WHERE CONVERT(brand USING utf8mb4) COLLATE utf8mb4_unicode_ci = CONVERT(%s USING utf8mb4) COLLATE utf8mb4_unicode_ci + LIMIT 1 + """, (_brand,)) if _brand else [] + _is_overseas_brand = bool(_brand_rows and (_brand_rows[0].get('category') or '').strip() == '海外') + _product_name = product.get('product_name') or '' + include_inner = _is_overseas_brand and any( + _key in _product_name for _key in INNER_MATERIAL_NAME_KEYS + ) + + # 2. 成品订单数量 + order = query(""" + SELECT COALESCE(SUM(order_qty), 0) AS total_order_qty, + COALESCE(SUM(suggested_qty), 0) AS total_suggested_qty + FROM purchase_order_items + WHERE product_code=%s + """, (code,)) + order_data = order[0] if order else {'total_order_qty': 0, 'total_suggested_qty': 0} + + # 3. BOM物料清单 + latest_inv = query("SELECT MAX(data_date) AS md FROM inventory_detail")[0]['md'] + bom_supplier_expr = _bom_material_supplier_sql() + bom_sql = f""" + SELECT b.material_code, b.material_name, b.quantity AS bom_qty, + m.cost_price AS material_cost, + {bom_supplier_expr} AS material_supplier, + COALESCE(inv.available_qty, 0) AS available_qty, + COALESCE(inv.production_in_transit, 0) AS production_in_transit, + COALESCE(inv.purchase_in_transit, 0) AS purchase_in_transit + FROM bom b + LEFT JOIN products m ON b.material_code = m.product_code + LEFT JOIN suppliers ms ON m.supplier_code = ms.supplier_code + LEFT JOIN ( + SELECT product_code, + SUM(available_qty) AS available_qty, + SUM(production_in_transit) AS production_in_transit, + SUM(purchase_in_transit) AS purchase_in_transit + FROM inventory_detail + WHERE data_date=%s + GROUP BY product_code + ) inv ON b.material_code = inv.product_code + WHERE b.product_code=%s + """ + if not include_inner: + bom_sql += """ + AND b.material_name NOT LIKE '%%内料%%' + AND b.material_code NOT LIKE '%%-NL%%' + """ + bom_sql += " ORDER BY b.id" + bom_materials = query(bom_sql, (latest_inv, code)) + + # 补充 BOM 用量计算的订单数量 + order_qty = int(order_data['total_order_qty'] or 0) + # 从 bom_packaging 表读取下单前/后包材数据(独立于 pending_orders) + pkg_rows = query(""" + SELECT material_code, pre_pack_qty, post_pack_qty, pkg_remark + FROM bom_packaging WHERE product_code=%s + """, (code,)) + pkg_map = {} + for pr in pkg_rows: + mc = str(pr['material_code'] or '').strip() + pkg_map[mc] = { + 'pre_pack_qty': int(pr['pre_pack_qty'] or 0), + 'post_pack_qty': int(pr['post_pack_qty'] or 0), + 'pkg_remark': pr.get('pkg_remark') or '', + } + for mat in bom_materials: + mat['bom_qty'] = float(mat['bom_qty']) if mat['bom_qty'] else 0 + mat['material_cost'] = float(mat['material_cost']) if mat['material_cost'] else 0 + mat['required_qty'] = order_qty * mat['bom_qty'] + mat['total_cost'] = round(mat['required_qty'] * mat['material_cost'], 2) + mat['available_qty'] = int(mat['available_qty']) + mat['production_in_transit'] = int(mat['production_in_transit']) + mat['purchase_in_transit'] = int(mat['purchase_in_transit']) + saved = pkg_map.get(str(mat['material_code'] or '').strip(), {}) + mat['pre_pack_qty'] = int(saved.get('pre_pack_qty', 0) or 0) + mat['post_pack_qty'] = int(saved.get('post_pack_qty', 0) or 0) + mat['pkg_remark'] = saved.get('pkg_remark', '') or '' + + # 4. 销量数据 + sales = query(""" + SELECT 日期, SUM(货品数量) AS 日销量 + FROM daily_outbound + WHERE 货品编号=%s + GROUP BY 日期 + ORDER BY 日期 ASC + """, (code,)) + sales_data = [] + for s in sales: + d = s['日期'].strftime('%Y-%m-%d') if hasattr(s['日期'], 'strftime') else str(s['日期'])[:10] + sales_data.append({ + 'date': d, + 'qty': int(s['日销量']) if s['日销量'] else 0 + }) + + # 5. 在途订单(从各订单表查询未完结的订单,增加字段) + orders_in_transit = query(""" + SELECT '国内' AS 订单类型, 日期 AS 下单时间, + 货品编号 AS 编码, 货品名称, 数量 AS 下单数量, + COALESCE(未到货数, 数量 - COALESCE(实际到货数量, 0)) AS 未到数量, + COALESCE(进度, '') AS 下单进度, + COALESCE(预计到货日期, '') AS 预计到货时间, + 金蝶入库 AS 入库单号 + FROM domestic_orders WHERE 货品编号=%s AND COALESCE(状态, '') != '已完结' + UNION ALL + SELECT '海外' AS 订单类型, 计划下单时间 AS 下单时间, + 产品编码 AS 编码, 货品名称, 数量 AS 下单数量, + COALESCE(未到货数, 数量 - COALESCE(实际到货数量, 0)) AS 未到数量, + COALESCE(进度, '') AS 下单进度, + COALESCE(预计到货日期, '') AS 预计到货时间, + '' AS 入库单号 + FROM overseas_orders WHERE 产品编码=%s AND COALESCE(状态, '') != '已完结' + UNION ALL + SELECT '包材' AS 订单类型, 下单时间, + 编码, 名称 AS 货品名称, 数量 AS 下单数量, + 0 AS 未到数量, + COALESCE(进度, '') AS 下单进度, + COALESCE(到货日期, '') AS 预计到货时间, + '' AS 入库单号 + FROM packaging_orders WHERE 编码=%s AND COALESCE(状态, '') != '已完结' + ORDER BY 下单时间 DESC + """, (code, code, code)) + for o in orders_in_transit: + if o.get('下单时间') and hasattr(o.get('下单时间'), 'strftime'): + o['下单时间'] = o['下单时间'].strftime('%Y-%m-%d') + if o.get('预计到货日期') and hasattr(o.get('预计到货日期'), 'strftime'): + o['预计到货日期'] = o['预计到货日期'].strftime('%Y-%m-%d') + if o.get('未到货数') is None: + o['未到货数'] = 0 + + return jsonify({ + "code": 0, + "data": { + "product": product, + "order": { + "total_order_qty": order_qty, + "total_suggested_qty": int(order_data['total_suggested_qty'] or 0) + }, + "bom_materials": bom_materials, + "sales": sales_data, + "orders_in_transit": orders_in_transit + } + }) + + +@products_bp.route('/api/product/planning_detail') +@login_required +def product_planning_detail(): + """产品详情 + 计划预测全量计算字段(用于弹窗展示)""" + code = request.args.get('code', '') + if not code: + return jsonify({"code": 400, "msg": "缺少货品编号"}) + + # 产品基础信息 + row = query(""" + SELECT p.*, COALESCE(s.supplier_name, p.supplier_name_match) AS supplier_name + FROM products p + LEFT JOIN suppliers s ON p.supplier_code = s.supplier_code + WHERE p.product_code=%s + """, (code,)) + if not row: + return jsonify({"code": 404, "msg": "未找到该产品"}) + product = row[0] + + if product.get('created_at'): + product['created_at'] = product['created_at'].strftime('%Y-%m-%d') if hasattr(product['created_at'], 'strftime') else str(product['created_at'])[:10] + if product.get('cost_price') is not None: + product['cost_price'] = float(product['cost_price']) + if product.get('lead_time') is None: + product['lead_time'] = 0 + if product.get('logistics_time') is None: + product['logistics_time'] = 0 + # 字段映射:后端英文名 → 前端中文名 + product['供应商名称'] = product.get('supplier_name', '') + product['生产货期'] = product.get('lead_time', 0) + product['物流时间'] = product.get('logistics_time', 0) + product['创建时间'] = product.get('created_at', '') + product['产品种类'] = get_product_type_cached(product.get('product_name', ''), product.get('brand', ''), product.get('product_type')) + product['批注'] = product.get('remark') or '' + + # 计算计划预测字段 + sales = compute_product_sales_cached() + inv_map = compute_product_inventory_cached() + inv15_map = compute_product_inventory_15() + transit_map = compute_product_in_transit_cached() + sp_map = {r['trend_type']: r for r in sales_predict_config_cached()} + + row = product + pc = row.get('product_code', '') + row['库存'] = inv_map.get(pc, 0) + row['加工厂库存'] = inv15_map.get(pc, 0) + t = transit_map.get(pc, {}) + row['生产在途'] = t.get('生产在途', 0) + row['采购在途'] = t.get('采购在途', 0) + + if pc in sales: + s = sales[pc] + for k in ['昨日销量', '3天平均销量', '7天平均销量', '14天平均销量', + '30天平均销量', '60天平均销量', '3天最高销量', '7天最高销量', + '14天最高销量', '30天最高销量', '14天最低销量', '14天标准差', + '最低销量偏差率', '最高销量偏差率', '极值判断', '14天波动率', '波动风险']: + row[k] = s.get(k, 0) + row['销售趋势'] = _determine_trend(s['3天平均销量'], s['7天平均销量'], s['14天平均销量']) + else: + for k in ['昨日销量', '3天平均销量', '7天平均销量', '14天平均销量', + '30天平均销量', '60天平均销量', '3天最高销量', '7天最高销量', + '14天最高销量', '30天最高销量', '14天最低销量', '14天标准差', + '最低销量偏差率', '最高销量偏差率', '极值判断', '14天波动率', '波动风险']: + row[k] = 0 + row['销售趋势'] = _determine_trend(0, 0, 0) + + row['产品分级'] = get_product_grade(row.get('30天平均销量', 0) * 30, + row.get('product_name', ''), + row.get('产品种类', '')) + row['是否新品'] = is_new_product(row.get('created_at', '')) + row['日销量预测'] = compute_daily_forecast(row, sp_map) + + # 支撑天数 + from services.forecast import get_global_config + gc = get_global_config() + safety_days = int(gc.get('safety_stock_days', '7')) + return_days = int(gc.get('suggested_return_days', '15')) + forecast_val = row.get('日销量预测', 0) + fcast = float(forecast_val) if forecast_val not in ('近期无销售', 0, '0') else 0 + inv_val = float(row.get('库存', 0) or 0) + inv15_val = float(row.get('加工厂库存', 0) or 0) + prod_transit = float(row.get('生产在途', 0) or 0) + purch_transit = float(row.get('采购在途', 0) or 0) + + if fcast == 0: + row['库存可支撑天数'] = 999 + row['库存工厂可支撑天数'] = 999 + row['库存工厂待生产可支撑天数'] = 999 + row['加工厂库存支撑天数'] = 999 + row['总支撑天数'] = 999 + else: + row['库存可支撑天数'] = round(inv_val / fcast, 1) if fcast > 0 else 999 + row['库存工厂可支撑天数'] = round(purch_transit / fcast, 1) if fcast > 0 else 999 + row['库存工厂待生产可支撑天数'] = round(prod_transit / fcast, 1) if fcast > 0 else 999 + row['加工厂库存支撑天数'] = round(inv15_val / fcast, 1) if fcast > 0 else 999 + total_inv = prod_transit + inv15_val + row['总支撑天数'] = round(total_inv / fcast, 1) if fcast > 0 else 999 + + lead_h = float(row.get('lead_time', 0) or 0) + logis_h = float(row.get('logistics_time', 0) or 0) + sup_days = row.get('库存可支撑天数', 999) + sup_days_val = float(sup_days) if isinstance(sup_days, (int, float)) and sup_days != 999 else None + if sup_days_val is not None and (lead_h + logis_h) > 0: + row['库存健康指数'] = round(min(sup_days_val / (lead_h + logis_h), 999), 1) + else: + row['库存健康指数'] = 999 + + created = row.get('created_at', '') + days_since_created = 999 + try: + if created: + created_dt = datetime.strptime(created[:10], '%Y-%m-%d') if isinstance(created, str) else created + days_since_created = (datetime.now() - created_dt).days + except: + pass + sup_days_val_f = float(sup_days) if isinstance(sup_days, (int, float)) and sup_days != 999 else 999 + if days_since_created < 90: + row['风险把控'] = '新品' + elif days_since_created > 180 and sup_days_val_f > 240: + row['风险把控'] = '需启动清库存' + elif days_since_created > 90 and sup_days_val_f > 90: + row['风险把控'] = '呆滞品' + else: + row['风险把控'] = '正常' + + if fcast == 0 and inv_val > 0: + row['建议下单数量'] = 0 + else: + row['建议下单数量'] = int(fcast * (safety_days + lead_h + logis_h)) + row['实际下单数量'] = ((row['建议下单数量'] + 999) // 1000) * 1000 if row['建议下单数量'] > 0 else 0 + + total_transit = prod_transit + purch_transit + if fcast > 0 and total_transit > 0: + calc_val = fcast * return_days - inv_val + round_up = max(0, int(calc_val * 10 + 0.99999) / 10) + row['建议回货数量'] = round_up if round_up <= total_transit else total_transit + else: + row['建议回货数量'] = 0 + + # 云仓页签沿用默认数据的字段顺序,数值改用云仓库存和云仓销量口径。 + cloud_sales = compute_product_sales_extended_cached().get('cloud', {}).get(pc, {}) + cloud_inventory = compute_cloud_inventory_cached().get(pc, 0) + cloud_3 = cloud_sales.get('3天平均销量', 0) + cloud_7 = cloud_sales.get('7天平均销量', 0) + cloud_14 = cloud_sales.get('14天平均销量', 0) + cloud_30 = cloud_sales.get('30天平均销量', 0) + cloud_trend = _determine_trend(cloud_3, cloud_7, cloud_14) + cloud_forecast_row = { + '3天平均销量': cloud_3, + '7天平均销量': cloud_7, + '14天平均销量': cloud_14, + '30天最高销量': cloud_sales.get('30天最高销量', 0), + '销售趋势': cloud_trend, + } + cloud_forecast = compute_daily_forecast(cloud_forecast_row, sp_map) + cloud_fcast = float(cloud_forecast) if cloud_forecast not in ('近期无销售', 0, '0') else 0 + row['云仓数据'] = { + '产品分级': row.get('产品分级', ''), + '昨日销量': cloud_sales.get('昨日销量', 0), + '销售趋势': cloud_trend, + '库存': cloud_inventory, + '加工厂库存': row.get('加工厂库存', 0), + '生产在途': row.get('生产在途', 0), + '采购在途': row.get('采购在途', 0), + '日销量预测': cloud_forecast, + '3天平均销量': cloud_3, + '7天平均销量': cloud_7, + '14天平均销量': cloud_14, + '库存可支撑天数': round(float(cloud_inventory or 0) / cloud_fcast, 1) if cloud_fcast else 999, + '加工厂库存支撑天数': round(inv15_val / cloud_fcast, 1) if cloud_fcast else 999, + '总支撑天数': round((prod_transit + inv15_val) / cloud_fcast, 1) if cloud_fcast else 999, + '30天平均销量': cloud_30, + '极值判断': '-', + } + + external_sales = compute_product_sales_extended_cached().get('external', {}).get(pc, {}) + external_inventory = compute_external_inventory_cached().get(pc, 0) + external_3 = external_sales.get('3天平均销量', 0) + external_7 = external_sales.get('7天平均销量', 0) + external_14 = external_sales.get('14天平均销量', 0) + external_30 = external_sales.get('30天平均销量', 0) + external_trend = _determine_trend(external_3, external_7, external_14) + external_forecast_row = { + '3天平均销量': external_3, + '7天平均销量': external_7, + '14天平均销量': external_14, + '30天最高销量': external_sales.get('30天最高销量', 0), + '销售趋势': external_trend, + } + external_forecast = compute_daily_forecast(external_forecast_row, sp_map) + external_fcast = float(external_forecast) if external_forecast not in ('近期无销售', 0, '0') else 0 + row['外仓数据'] = { + '产品分级': row.get('产品分级', ''), + '昨日销量': external_sales.get('昨日销量', 0), + '销售趋势': external_trend, + '库存': external_inventory, + '加工厂库存': row.get('加工厂库存', 0), + '生产在途': row.get('生产在途', 0), + '采购在途': row.get('采购在途', 0), + '日销量预测': external_forecast, + '3天平均销量': external_3, + '7天平均销量': external_7, + '14天平均销量': external_14, + '库存可支撑天数': round(float(external_inventory or 0) / external_fcast, 1) if external_fcast else 999, + '加工厂库存支撑天数': round(inv15_val / external_fcast, 1) if external_fcast else 999, + '总支撑天数': round((prod_transit + inv15_val) / external_fcast, 1) if external_fcast else 999, + '30天平均销量': external_30, + '极值判断': '-', + } + + return jsonify({"code": 0, "data": row}) + + +@products_bp.route('/api/bom/list') +@login_required +def bom_list(): + """获取BOM列表""" + page = int(request.args.get('page', 1)) + size = int(request.args.get('size', 20)) + offset = (page - 1) * size + search = request.args.get('search', '') + maint_filter = request.args.get('maint_filter', '') + supplier_filter = request.args.get('supplier', '') + + where_parts = [] + params = [] + if search: + where_parts.append('(b.product_code LIKE %s OR b.product_name LIKE %s OR b.material_code LIKE %s OR b.material_name LIKE %s)') + params.extend([f'%{search}%', f'%{search}%', f'%{search}%', f'%{search}%']) + + having_clause = '' + if maint_filter == '有BOM': + having_clause = 'HAVING SUM(CASE WHEN b.material_code IS NOT NULL AND b.material_code != \'\' THEN 1 ELSE 0 END) > 0' + elif maint_filter == '无BOM': + having_clause = 'HAVING SUM(CASE WHEN b.material_code IS NOT NULL AND b.material_code != \'\' THEN 1 ELSE 0 END) = 0' + + # 供应商筛选:优先使用BOM导入的供应商,未填写时回退到物料基础资料 + supplier_join = '' + supplier_where = '' + if supplier_filter: + supplier_join = ''' + LEFT JOIN products m ON (b.material_code = m.product_code) + LEFT JOIN suppliers ms ON (m.supplier_code = ms.supplier_code) + ''' + supplier_where = f"AND {_bom_material_supplier_sql()} LIKE %s" + params.append(f'%{supplier_filter}%') + + where_sql = ('WHERE ' + ' AND '.join(where_parts) + supplier_where) if where_parts else ('WHERE 1=1 ' + supplier_where) if supplier_filter else '' + + # 总数查询 + total = query(f""" + SELECT COUNT(*) AS cnt FROM ( + SELECT b.product_code FROM bom b + {supplier_join} + {where_sql} + GROUP BY b.product_code + {having_clause} + ) AS t + """, params)[0]['cnt'] + + product_codes = query(f""" + SELECT b.product_code FROM bom b + {supplier_join} + {where_sql} + GROUP BY b.product_code + {having_clause} + ORDER BY MAX(b.id) DESC + LIMIT %s OFFSET %s + """, params + [size, offset]) + + if not product_codes: + return jsonify({"code": 0, "data": {"total": 0, "rows": [], "product_count": 0}}) + + codes = [p['product_code'] for p in product_codes] + placeholders = ','.join(['%s'] * len(codes)) + latest_inv = query("SELECT MAX(data_date) AS md FROM inventory_detail")[0]['md'] + + # 成品库存映射(按 product_code) + prod_inv = {} + prod_inv_rows = query(f""" + SELECT product_code, + SUM(available_qty) AS available_qty, + SUM(production_in_transit) AS production_in_transit, + SUM(purchase_in_transit) AS purchase_in_transit + FROM inventory_detail + WHERE data_date='{latest_inv}' AND product_code IN ({placeholders}) + GROUP BY product_code + """, codes) + for r in prod_inv_rows: + prod_inv[r['product_code']] = r + + bom_supplier_expr = _bom_material_supplier_sql() + sql = f""" + SELECT b.*, + p.cost_price AS product_cost_price, + p.supplier_name_match AS product_supplier, + {bom_supplier_expr} AS material_supplier, + COALESCE(m.cost_price, 0) AS material_cost_price, + inv.available_qty AS mat_available_qty, + inv.production_in_transit AS mat_prod_transit, + inv.purchase_in_transit AS mat_purch_transit + FROM bom b + LEFT JOIN products p ON b.product_code = p.product_code + LEFT JOIN products m ON b.material_code = m.product_code + LEFT JOIN suppliers ms ON m.supplier_code = ms.supplier_code + LEFT JOIN ( + SELECT product_code, + SUM(available_qty) AS available_qty, + SUM(production_in_transit) AS production_in_transit, + SUM(purchase_in_transit) AS purchase_in_transit + FROM inventory_detail + WHERE data_date='{latest_inv}' + GROUP BY product_code + ) inv ON b.material_code = inv.product_code + WHERE b.product_code IN ({placeholders}) + ORDER BY b.id DESC + """ + rows = query(sql, codes) + + # 为每行补充成品库存 + for r in rows: + pinv = prod_inv.get(r['product_code'], {}) + r['product_available_qty'] = int(pinv.get('available_qty') or 0) + r['product_production_in_transit'] = int(pinv.get('production_in_transit') or 0) + r['product_purchase_in_transit'] = int(pinv.get('purchase_in_transit') or 0) + + return jsonify({"code": 0, "data": {"total": total, "rows": rows, "product_count": total}}) + + +# ─── API: BOM供应商列表 ─── +@products_bp.route('/api/bom/suppliers') +@login_required +def bom_suppliers(): + """获取BOM中用到的所有物料供应商名称""" + bom_supplier_expr = _bom_material_supplier_sql() + rows = query(f""" + SELECT DISTINCT {bom_supplier_expr} AS supplier_name + FROM bom b + LEFT JOIN products m ON b.material_code = m.product_code + LEFT JOIN suppliers ms ON m.supplier_code = ms.supplier_code + WHERE {bom_supplier_expr} <> '' + ORDER BY supplier_name + """) + suppliers = [r['supplier_name'] for r in rows] + return jsonify({"code": 0, "data": suppliers}) + + + +# ─── API: 产品新增 ─── +@products_bp.route('/api/product/create', methods=['POST']) +@login_required +def product_create(): + """新增产品""" + if not operation_required('products', 'create') and not operation_required('products', 'add'): + return jsonify({"code": 403, "msg": "没有新增产品权限"}) + data = request.json or {} + product_code = data.get('product_code', '').strip() + product_name = data.get('product_name', '').strip() + if not product_code or not product_name: + return jsonify({"code": 400, "msg": "货品编号和货品名称不能为空"}) + existing = query("SELECT id FROM products WHERE product_code=%s", (product_code,)) + if existing: + return jsonify({"code": 400, "msg": f"货品编号 {product_code} 已存在"}) + try: + query(""" + INSERT INTO products (product_code, product_name, brand, product_type, + lead_time, logistics_time, + supplier_code, supplier_name_match, + procurement_follower, procurement_planner, operator, platform, + developer, created_at, import_date, + custom_field1, custom_field2, custom_field3, custom_field4, custom_field5) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) + """, ( + product_code, product_name, + data.get('brand', ''), data.get('product_type', '成品'), + int(data.get('lead_time', 0)), min(int(data.get('logistics_time', 0)), 999), + data.get('supplier_code', ''), data.get('supplier_name', ''), + data.get('procurement_follower', ''), data.get('procurement_planner', ''), + data.get('operator', ''), data.get('platform', ''), + data.get('developer', ''), + data.get('created_at', '').replace('T', ' ') if data.get('created_at') else None, datetime.now(), + data.get('custom_field1', ''), data.get('custom_field2', ''), + data.get('custom_field3', ''), data.get('custom_field4', ''), + data.get('custom_field5', '') + )) + add_operation_log('产品创建', f'新建产品 {product_code} - {product_name}') + return jsonify({"code": 0, "msg": "创建成功"}) + except Exception as e: + return jsonify({"code": 500, "msg": str(e)}) + + +# ─── API: 产品导出(CSV)─── +@products_bp.route('/api/products/export') +@login_required +def products_export(): + """导出产品数据为 CSV(支持选字段)""" + product_type = request.args.get('product_type', '') + fields_param = request.args.get('fields', '') + from flask import Response + from urllib.parse import quote + import csv, io + where = [] + params = [] + if product_type: + if product_type == '非成品': + where.append("p.product_type != '成品'") + elif product_type == '非包材': + where.append("p.product_type NOT IN ('包材','其他')") + elif product_type == '非成品专用': + where.append("p.product_type IN ('包材','其他')") + else: + where.append("p.product_type=%s") + params.append(product_type) + where_clause = " AND ".join(where) if where else "1=1" + rows = query(f""" + SELECT p.product_code, p.product_name, p.brand, p.product_type, p.specification, + p.cost_price, p.lead_time, p.logistics_time, p.min_order_qty, + p.supplier_code, COALESCE(s.supplier_name, p.supplier_name_match) AS supplier_name, + p.procurement_follower, p.procurement_planner, p.operator, p.developer, + p.platform, p.category1, p.category2, p.category3, + p.created_at, p.import_date + FROM products p + LEFT JOIN suppliers s ON p.supplier_code = s.supplier_code + WHERE {where_clause} + ORDER BY p.id DESC + """, params) + + # 字段映射:前端字段名 → 显示名 + FIELD_MAP = [ + ('product_code', '货品编号'), + ('product_name', '货品名称'), + ('product_type', '货品类型'), + ('brand', '品牌'), + ('supplier_code', '供应商编码'), + ('supplier_name', '供应商名称'), + ('lead_time', '货期(天)'), + ('logistics_time', '物流时间(天)'), + ('procurement_follower', '采购跟单'), + ('procurement_planner', '采购计划'), + ('platform', '平台'), + ('operator', '运营'), + ('developer', '开发专员'), + ('created_at', '创建时间'), + ('import_date', '导入日期'), + ] + + # 解析选中的字段 + if fields_param: + selected_fields = [f.strip() for f in fields_param.split(',') if f.strip()] + else: + selected_fields = [f[0] for f in FIELD_MAP] + + # 按选中顺序过滤 + headers = [] + getters = [] + for f, label in FIELD_MAP: + if f in selected_fields: + headers.append(label) + getters.append(f) + + if not headers: + return jsonify({"code": 400, "msg": "未选择导出字段"}) + + output = io.StringIO() + writer = csv.writer(output) + writer.writerow(headers) + for r in rows: + row_vals = [] + for f in getters: + val = r.get(f, '') + if f == 'created_at' and val: + val = val.strftime('%Y-%m-%d %H:%M:%S') if hasattr(val, 'strftime') else str(val)[:19] + elif f == 'import_date' and val: + val = val.strftime('%Y-%m-%d') if hasattr(val, 'strftime') else str(val)[:10] + elif val is None: + val = '' + row_vals.append(val) + writer.writerow(row_vals) + + name_part = '全部' + if product_type == '成品': + name_part = '成品' + elif product_type in ('非包材',): + name_part = '成品' + elif product_type: + name_part = '非成品' + filename = f"{name_part}基础表_{datetime.now().strftime('%Y%m%d')}.csv" + encoded = quote(filename) + return Response( + output.getvalue(), + mimetype='text/csv; charset=utf-8-sig', + headers={'Content-Disposition': f"attachment; filename*=UTF-8''{encoded}"} + ) + + +# ─── API: 产品导入模板下载 ─── +@products_bp.route('/api/products/template') +@login_required +def products_template(): + """下载导入模板 CSV(与当前字段一致) + ?type=包材 时生成包材导入模板(含绑定成品编码、用量) + """ + from flask import Response, request + from urllib.parse import quote + import csv, io + output = io.StringIO() + writer = csv.writer(output) + is_packaging = request.args.get('type', '') == '包材' + if is_packaging: + writer.writerow(['绑定成品编码', '货品编号', '货品名称', '用量', + '产品类型', + '成本价', '生产货期', '物流时间', '最低起订量', + '供应商编号', '供应商名称']) + writer.writerow(['DEMO-FG-001', 'BZ-DEMO001', '示例包材名称', '2', + '包材', + '5.00', '5', '2', '500', + 'SUP001', '示例供应商']) + filename = '包材导入模板.csv' + else: + writer.writerow(['货品编号', '货品名称', '品牌', '产品类型', + '成本价', '生产货期', '物流时间', '最低起订量', + '供应商编号', '供应商名称', + '采购跟单', '采购计划', '运营专员', '开发专员', + '归属平台', '创建时间']) + writer.writerow(['DEMO001', '示例产品名称', '品牌A', '成品', + '15.50', '7', '3', '100', + 'SUP001', '示例供应商', + '张三', '李四', '王五', '赵六', + '天猫', '2026-07-15']) + filename = '产品导入模板.csv' + encoded = quote(filename) + return Response( + output.getvalue(), + mimetype='text/csv; charset=utf-8-sig', + headers={'Content-Disposition': f"attachment; filename*=UTF-8''{encoded}"} + ) + + +# ─── API: 产品导入(CSV上传)─── +@products_bp.route('/api/products/import', methods=['POST']) +@login_required +def products_import(): + """通过 CSV 导入产品""" + if not operation_required('products', 'import'): + return jsonify({"code": 403, "msg": "无操作权限"}) + file = request.files.get('file') + if not file: + return jsonify({"code": 400, "msg": "请选择文件"}) + import csv, io + raw = file.read() + # 尝试多种编码,兼容 Excel 导出的 GBK 等格式 + for enc in ('utf-8-sig', 'gbk', 'gb2312', 'utf-8'): + try: + content = raw.decode(enc) + break + except (UnicodeDecodeError, LookupError): + continue + else: + return jsonify({"code": 400, "msg": "无法识别文件编码,请确认 CSV 文件为 UTF-8 或 GBK 格式"}) + reader = csv.DictReader(io.StringIO(content)) + # 判断是否为包材导入(含 BOM 绑定列) + has_bom_bind = '绑定成品编码' in (reader.fieldnames or []) + field_map = { + '货品编号': 'product_code', '货品名称': 'product_name', + '品牌': 'brand', '产品类型': 'product_type', + '成本价': 'cost_price', + '生产货期': 'lead_time', '物流时间': 'logistics_time', + '最低起订量': 'min_order_qty', '供应商编号': 'supplier_code', + '供应商名称': 'supplier_name', + '采购跟单': 'procurement_follower', '采购计划': 'procurement_planner', + '运营专员': 'operator', '开发专员': 'developer', + '归属平台': 'platform' + } + imported = 0 + updated = 0 + bom_count = 0 + errors = [] + # 已存在的货品编号按文件内容覆盖更新;空白单元格不改动原值(与供应商导入口径一致) + def _as_text(v): + return v + + def _as_float(v): + try: + return float(v) + except (TypeError, ValueError): + return 0 + + def _as_int(v): + try: + return int(float(v)) + except (TypeError, ValueError): + return 0 + + update_field_map = { + '货品名称': ('product_name', _as_text), + '品牌': ('brand', _as_text), + '产品类型': ('product_type', _as_text), + '成本价': ('cost_price', _as_float), + '生产货期': ('lead_time', _as_int), + '物流时间': ('logistics_time', _as_int), + '最低起订量': ('min_order_qty', _as_int), + '供应商编号': ('supplier_code', _as_text), + '供应商名称': ('supplier_name_match', _as_text), + '采购跟单': ('procurement_follower', _as_text), + '采购计划': ('procurement_planner', _as_text), + '运营专员': ('operator', _as_text), + '开发专员': ('developer', _as_text), + '归属平台': ('platform', _as_text), + } + try: + for i, row in enumerate(reader, 2): + pc = row.get('货品编号', '').strip() + pn = row.get('货品名称', '').strip() + if not pc: + errors.append(f"第{i}行:货品编号为空,已跳过") + continue + existing = query("SELECT id FROM products WHERE product_code=%s", (pc,)) + if existing: + set_parts = [] + set_params = [] + for header, (col, conv) in update_field_map.items(): + if header not in (reader.fieldnames or []): + continue + raw_val = (row.get(header) or '').strip() + if raw_val == '': + continue + set_parts.append(f"{col}=%s") + set_params.append(conv(raw_val)) + if not set_parts: + errors.append(f"第{i}行:货品编号 {pc} 已存在,但文件内无任何可更新内容,已跳过") + continue + set_params.append(pc) + query(f"UPDATE products SET {', '.join(set_parts)} WHERE product_code=%s", set_params) + updated += 1 + else: + if not pn: + errors.append(f"第{i}行:货品编号 {pc} 不存在且未填写货品名称,无法新增,已跳过") + continue + query(""" + INSERT INTO products (product_code, product_name, brand, product_type, + cost_price, lead_time, logistics_time, min_order_qty, + supplier_code, supplier_name_match, + procurement_follower, procurement_planner, operator, platform, + developer, created_at, import_date) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,CURDATE()) + """, ( + pc, pn, + row.get('品牌', '').strip(), + row.get('产品类型', '包材' if has_bom_bind else '成品').strip(), + (lambda v: float(v) if v else 0)(row.get('成本价', '') or 0), + (lambda v: int(float(v)) if v else 0)(row.get('生产货期', '') or 0), + (lambda v: int(float(v)) if v else 0)(row.get('物流时间', '') or 0), + (lambda v: int(float(v)) if v else 0)(row.get('最低起订量', '') or 0), + row.get('供应商编号', '').strip(), + row.get('供应商名称', '').strip(), + row.get('采购跟单', '').strip(), + row.get('采购计划', '').strip(), + row.get('运营专员', '').strip(), + row.get('归属平台', '').strip(), + row.get('开发专员', '').strip(), + row.get('创建时间', '').strip() or None + )) + imported += 1 + + # BOM 绑定:如果导入的是包材且有绑定成品编码 + if has_bom_bind: + bind_code = row.get('绑定成品编码', '').strip() + qty = row.get('用量', '').strip() + if bind_code: + # 查成品是否存在(不存在也跳过,仅提示) + prod_row = query("SELECT id, product_name FROM products WHERE product_code=%s", (bind_code,)) + if not prod_row: + errors.append(f"第{i}行:绑定的成品编码 {bind_code} 不存在,跳过 BOM 绑定") + else: + qty_val = float(qty) if qty else 1 + # 查是否已有该 BOM 记录,有则更新,无则插入 + existing_bom = query( + "SELECT id FROM bom WHERE product_code=%s AND material_code=%s", + (bind_code, pc)) + # 获取包材成本价 + mat_cost = float(row.get('成本价', 0)) if row.get('成本价') else 0 + if existing_bom: + query("UPDATE bom SET quantity=%s, material_cost_price=%s WHERE id=%s", + (qty_val, mat_cost, existing_bom[0]['id'])) + else: + # 更新场景下文件可能未提供货品名称,回退到库中已有名称 + mat_name = pn or (query("SELECT product_name FROM products WHERE product_code=%s", (pc,)) or [{}])[0].get('product_name', '') or '' + query( + "INSERT INTO bom (product_code, product_name, material_code, material_name, quantity, material_cost_price) VALUES (%s,%s,%s,%s,%s,%s)", + (bind_code, prod_row[0]['product_name'], pc, mat_name, qty_val, mat_cost)) + bom_count += 1 + + log_detail = f'CSV批量导入产品:新增{imported}条,更新{updated}条' + if bom_count: + log_detail += f',自动创建/更新BOM绑定{bom_count}条' + add_operation_log('产品导入', log_detail) + msg = f"导入完成:新增 {imported} 条,更新 {updated} 条" + if bom_count: + msg += f",自动绑定BOM {bom_count}条" + if errors: + msg += "。以下行已跳过:" + "; ".join(errors[:10]) + if len(errors) > 10: + msg += f"...(共{len(errors)}个错误)" + return jsonify({"code": 0, "msg": msg}) + except Exception as e: + import traceback + traceback.print_exc() + return jsonify({"code": 500, "msg": f"导入失败:{str(e)}"}) + + +# ─── API: BOM导入模板下载 ─── +@products_bp.route('/api/bom/import_template') +@login_required +def bom_import_template(): + """下载BOM导入模板CSV""" + from flask import Response + from urllib.parse import quote + template_csv = '货品编号,货品名称,物料编号,物料名称,用量,供应商,成本价\n' + encoded_filename = quote('物料清单导入模板.csv') + return Response( + template_csv, + mimetype='text/csv', + headers={'Content-Disposition': f"attachment; filename*=UTF-8''{encoded_filename}"} + ) + + +@products_bp.route('/api/bom/import', methods=['POST']) +@login_required +def bom_import(): + """导入BOM数据(按产品覆盖,非全量替换)""" + data = request.json + items = data.get('items', []) + if not items: + return jsonify({"code": 400, "msg": "数据为空"}) + try: + # 收集要更新的产品编码,只删除这些产品的旧BOM数据 + product_codes = set(item.get('product_code', '') for item in items if item.get('product_code')) + if product_codes: + placeholders = ','.join(['%s'] * len(product_codes)) + query(f"DELETE FROM bom WHERE product_code IN ({placeholders})", tuple(product_codes)) + for item in items: + query( + "INSERT INTO bom (product_code, product_name, material_code, material_name, quantity, supplier, material_cost_price) VALUES (%s,%s,%s,%s,%s,%s,%s) ON DUPLICATE KEY UPDATE product_name=VALUES(product_name), material_name=VALUES(material_name), quantity=VALUES(quantity), supplier=VALUES(supplier), material_cost_price=VALUES(material_cost_price)", + (item.get('product_code', ''), item.get('product_name', ''), + item.get('material_code', ''), item.get('material_name', ''), + float(item.get('quantity', 1)), + _normalize_bom_supplier(item.get('supplier', '')), + float(item.get('cost_price', 0))) + ) + add_operation_log('BOM导入', f'导入BOM,共{len(items)}条') + return jsonify({"code": 0, "msg": f"导入成功,共{len(items)}条"}) + except Exception as e: + return jsonify({"code": 500, "msg": str(e)}) + + +# ─── API: 供应商管理 ─── +@products_bp.route('/api/suppliers/list') +@login_required +def suppliers_list(): + page = int(request.args.get('page', 1)) + size = int(request.args.get('size', 50)) + offset = (page - 1) * size + search = request.args.get('search', '') + code = request.args.get('code', '') + name = request.args.get('name', '') + + where = [] + params = [] + if search: + where.append("(supplier_name LIKE %s OR short_name LIKE %s OR contract_short_name LIKE %s OR supplier_code LIKE %s OR production_type LIKE %s)") + params.extend([f"%{search}%", f"%{search}%", f"%{search}%", f"%{search}%", f"%{search}%"]) + if code: + where.append("supplier_code LIKE %s") + params.append(f"%{code}%") + if name: + where.append("supplier_name LIKE %s") + params.append(f"%{name}%") + + where_clause = " AND ".join(where) if where else "1=1" + + total = query(f"SELECT COUNT(*) AS cnt FROM suppliers WHERE {where_clause}", params)[0]['cnt'] + rows = query(f"SELECT * FROM suppliers WHERE {where_clause} ORDER BY supplier_code ASC LIMIT {size} OFFSET {offset}", params) + + # 日期转字符串,避免Flask序列化为 "Wed, 18 Mar 2026 00:00:00 GMT" 格式 + for r in rows: + if r.get('review_date') and hasattr(r['review_date'], 'isoformat'): + r['review_date'] = r['review_date'].isoformat() + + return jsonify({"code": 0, "data": {"total": total, "page": page, "size": size, "rows": rows}}) + + +@products_bp.route('/api/supplier/update', methods=['POST']) +@login_required +def supplier_update(): + if not operation_required('suppliers', 'edit'): + return jsonify({"code": 403, "msg": "无操作权限"}) + data = request.json + code = data.get('supplier_code', '') + fields = data.get('fields', {}) + if not code or not fields: + return jsonify({"code": 400, "msg": "参数不完整"}) + allowed = ['supplier_name', 'production_type', 'short_name', 'contract_short_name', 'supplier_group', 'procurement_follower', 'procurement_planner', 'reviewer', 'review_date', 'data_status', 'disabled_status'] + set_parts = [] + params = [] + for field, value in fields.items(): + if field in allowed: + set_parts.append(f"{field}=%s") + params.append(value if value != '' else None) + if not set_parts: + return jsonify({"code": 400, "msg": "没有可更新的字段"}) + set_clause = ", ".join(set_parts) + params.append(code) + try: + query(f"UPDATE suppliers SET {set_clause} WHERE supplier_code=%s", params) + return jsonify({"code": 0, "msg": "更新成功"}) + except Exception as e: + return jsonify({"code": 500, "msg": str(e)}) + + +@products_bp.route('/api/brands/all') +@login_required +def brands_all(): + """品牌下拉/模糊查询用:品牌信息管理维护的品牌 + 产品表中已存在的品牌""" + rows = query("""SELECT TRIM(brand) AS brand FROM products + WHERE deleted_at IS NULL AND brand IS NOT NULL AND TRIM(brand) <> '' + GROUP BY TRIM(brand)""") + saved = query("SELECT brand FROM brand_information") + names = {r['brand'] for r in rows} | {(r.get('brand') or '').strip() for r in saved} + names.discard('') + return jsonify({"code": 0, "data": sorted(names, key=lambda x: x.casefold())}) + + +@products_bp.route('/api/brands/overseas') +@login_required +def brands_overseas(): + """新增产品弹窗用:海外品品牌清单(来源:品牌管理的品牌分类)""" + rows = query("SELECT brand FROM brand_information WHERE category='海外' ORDER BY brand") + return jsonify({"code": 0, "data": [r['brand'] for r in rows]}) + + +@products_bp.route('/api/supplier/bound_fields') +@login_required +def supplier_bound_fields(): + """新增产品弹窗用:按供应商解析跟单小组与采购计划。 + + 优先取供应商管理里该供应商绑定的值;管理里未维护时回退到该供应商已有产品的对应值。 + """ + code = (request.args.get('supplier_code') or '').strip() + name = (request.args.get('supplier_name') or '').strip() + follower = '' + planner = '' + follower_source = '' + planner_source = '' + supplier = None + if code: + rows = query("SELECT supplier_name, procurement_follower, procurement_planner FROM suppliers WHERE supplier_code=%s", (code,)) + supplier = rows[0] if rows else None + if not supplier and name: + rows = query("SELECT supplier_name, procurement_follower, procurement_planner FROM suppliers WHERE supplier_name=%s", (name,)) + supplier = rows[0] if rows else None + if not supplier and name: + rows = query("SELECT supplier_name, procurement_follower, procurement_planner FROM suppliers WHERE supplier_name LIKE %s ORDER BY id LIMIT 1", (f"%{name}%",)) + supplier = rows[0] if rows else None + if supplier: + bound_follower = (supplier.get('procurement_follower') or '').strip() + bound_planner = (supplier.get('procurement_planner') or '').strip() + if bound_follower: + follower = bound_follower + follower_source = 'supplier' + if bound_planner: + planner = bound_planner + planner_source = 'supplier' + # 回退查询要用供应商全称,避免传入片段名时匹配不到产品 + name = (supplier.get('supplier_name') or '').strip() or name + if name and (not follower or not planner): + rows = query(""" + SELECT procurement_follower, procurement_planner, COUNT(*) AS cnt FROM products + WHERE supplier_name_match=%s + AND ((procurement_follower IS NOT NULL AND procurement_follower<>'') + OR (procurement_planner IS NOT NULL AND procurement_planner<>'')) + GROUP BY procurement_follower, procurement_planner + ORDER BY cnt DESC LIMIT 50 + """, (name,)) + # 分别取出现次数最多的非空值 + follower_counts = {} + planner_counts = {} + for r in rows: + f = (r.get('procurement_follower') or '').strip() + p = (r.get('procurement_planner') or '').strip() + if f: + follower_counts[f] = follower_counts.get(f, 0) + int(r.get('cnt') or 0) + if p: + planner_counts[p] = planner_counts.get(p, 0) + int(r.get('cnt') or 0) + if not follower and follower_counts: + follower = max(follower_counts, key=follower_counts.get) + follower_source = 'product_fallback' + if not planner and planner_counts: + planner = max(planner_counts, key=planner_counts.get) + planner_source = 'product_fallback' + return jsonify({"code": 0, "data": { + "follower": follower, "follower_source": follower_source, + "planner": planner, "planner_source": planner_source, + "supplier_name": name, + }}) + + +@products_bp.route('/api/supplier/follower_lookup') +@login_required +def supplier_follower_lookup(): + """兼容旧调用:仅返回跟单小组""" + payload = supplier_bound_fields().get_json()['data'] + return jsonify({"code": 0, "data": { + "follower": payload['follower'], "source": payload['follower_source'], + "supplier_name": payload['supplier_name'], + }}) + + +@products_bp.route('/api/supplier/delete', methods=['POST']) +@login_required +def supplier_delete(): + """删除供应商""" + if not operation_required('suppliers', 'delete'): + return jsonify({"code": 403, "msg": "无操作权限"}) + data = request.json or {} + code = data.get('supplier_code', '').strip() + if not code: + return jsonify({"code": 400, "msg": "缺少供应商编码"}) + try: + query("DELETE FROM suppliers WHERE supplier_code=%s", (code,)) + return jsonify({"code": 0, "msg": f"供应商 [{code}] 已删除"}) + except Exception as e: + return jsonify({"code": 500, "msg": str(e)}) + + +@products_bp.route('/api/suppliers/export') +@login_required +def suppliers_export(): + """导出供应商数据为xlsx""" + from flask import send_file + import openpyxl + from openpyxl.styles import Font, Alignment, Border, Side, PatternFill + import tempfile + + search = request.args.get('search', '') + code = request.args.get('code', '') + name = request.args.get('name', '') + + where = [] + params = [] + if search: + where.append("(supplier_name LIKE %s OR supplier_code LIKE %s OR production_type LIKE %s)") + params.extend([f"%{search}%", f"%{search}%", f"%{search}%"]) + if code: + where.append("supplier_code LIKE %s") + params.append(f"%{code}%") + if name: + where.append("supplier_name LIKE %s") + params.append(f"%{name}%") + + where_clause = " AND ".join(where) if where else "1=1" + rows = query(f"SELECT * FROM suppliers WHERE {where_clause} ORDER BY supplier_code ASC", params) + + wb = openpyxl.Workbook() + ws = wb.active + ws.title = "供应商列表" + + # 样式 + header_font = Font(name='微软雅黑', bold=True, size=11, color='FFFFFF') + header_fill = PatternFill(start_color='1890FF', end_color='1890FF', fill_type='solid') + header_align = Alignment(horizontal='center', vertical='center') + thin_border = Border( + left=Side(style='thin'), + right=Side(style='thin'), + top=Side(style='thin'), + bottom=Side(style='thin') + ) + cell_align = Alignment(horizontal='center', vertical='center') + + headers = ['序号', '供应商编码', '供应商名称', '做货类型', '简称', '合同简称', + '供应商分组', '审核人', '审核日期', '数据状态', '禁用状态'] + for col_idx, h in enumerate(headers, 1): + cell = ws.cell(row=1, column=col_idx, value=h) + cell.font = header_font + cell.fill = header_fill + cell.alignment = header_align + cell.border = thin_border + + for i, r in enumerate(rows, 1): + row_data = [ + i, + r.get('supplier_code', ''), + r.get('supplier_name', ''), + r.get('production_type', '') or '-', + r.get('short_name', '') or '-', + r.get('contract_short_name', '') or '-', + r.get('supplier_group', '') or '-', + r.get('reviewer', '') or '-', + (r.get('review_date') or '-') if r.get('review_date') else '-', + r.get('data_status', '') or '-', + r.get('disabled_status', '') or '-', + ] + for col_idx, val in enumerate(row_data, 1): + cell = ws.cell(row=i + 1, column=col_idx, value=str(val)) + cell.alignment = cell_align + cell.border = thin_border + + # 调整列宽 + col_widths = [6, 16, 36, 20, 12, 12, 16, 10, 14, 12, 12] + for col_idx, w in enumerate(col_widths, 1): + ws.column_dimensions[openpyxl.utils.get_column_letter(col_idx)].width = w + + # 冻结首行 + ws.freeze_panes = 'A2' + + tmp = tempfile.NamedTemporaryFile(suffix='.xlsx', delete=False) + wb.save(tmp.name) + tmp.close() + + return send_file( + tmp.name, + as_attachment=True, + download_name='供应商列表.xlsx', + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + ) + + +@products_bp.route('/api/suppliers/import_template') +@login_required +def suppliers_import_template(): + """下载供应商导入模板""" + from flask import send_file + import openpyxl + import tempfile + + wb = openpyxl.Workbook() + ws = wb.active + ws.title = "供应商导入模板" + + headers = ['供应商名称', '简称', '合同简称', '跟单小组', '采购计划'] + for col_idx, h in enumerate(headers, 1): + cell = ws.cell(row=1, column=col_idx, value=h) + cell.font = openpyxl.styles.Font(name='微软雅黑', bold=True, size=11, color='FFFFFF') + cell.fill = openpyxl.styles.PatternFill(start_color='1890FF', end_color='1890FF', fill_type='solid') + cell.alignment = openpyxl.styles.Alignment(horizontal='center', vertical='center') + cell.border = openpyxl.styles.Border( + left=openpyxl.styles.Side(style='thin'), + right=openpyxl.styles.Side(style='thin'), + top=openpyxl.styles.Side(style='thin'), + bottom=openpyxl.styles.Side(style='thin')) + + # 添加示例行 + examples = [ + ['广州市靓鑫精细化工有限公司', '靓鑫', 'LX', '跟单一组', '陈瑜'], + ['广州市巧能塑料制品有限公司', '巧能', 'QN', '跟单二组', '李艳艳'], + ] + for i, ex in enumerate(examples, 2): + for col_idx, val in enumerate(ex, 1): + cell = ws.cell(row=i, column=col_idx, value=val) + cell.alignment = openpyxl.styles.Alignment(horizontal='center', vertical='center') + cell.border = openpyxl.styles.Border( + left=openpyxl.styles.Side(style='thin'), + right=openpyxl.styles.Side(style='thin'), + top=openpyxl.styles.Side(style='thin'), + bottom=openpyxl.styles.Side(style='thin')) + + ws.column_dimensions['A'].width = 36 + ws.column_dimensions['B'].width = 14 + ws.column_dimensions['C'].width = 14 + ws.column_dimensions['D'].width = 16 + ws.column_dimensions['E'].width = 16 + ws.freeze_panes = 'A2' + + tmp = tempfile.NamedTemporaryFile(suffix='.xlsx', delete=False) + wb.save(tmp.name) + tmp.close() + + return send_file( + tmp.name, + as_attachment=True, + download_name='供应商导入模板.xlsx', + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + ) + + +@products_bp.route('/api/suppliers/import', methods=['POST']) +@login_required +def suppliers_import(): + """导入xlsx更新供应商简称和合同简称""" + import openpyxl + + if 'file' not in request.files: + return jsonify({"code": 400, "msg": "未上传文件"}) + + f = request.files['file'] + if not f.filename: + return jsonify({"code": 400, "msg": "文件名为空"}) + + try: + wb = openpyxl.load_workbook(f) + ws = wb.active + except Exception as e: + return jsonify({"code": 400, "msg": f"无法读取Excel文件:{str(e)}"}) + + if ws.max_row < 2: + return jsonify({"code": 400, "msg": "Excel文件中没有数据"}) + + # 读取表头确定列位置 + headers = {} + for col_idx in range(1, ws.max_column + 1): + val = str(ws.cell(row=1, column=col_idx).value or '').strip() + headers[val] = col_idx + + name_col = headers.get('供应商名称') or headers.get('供应商') + short_col = headers.get('简称') or headers.get('供应商简称') + contract_col = headers.get('合同简称') + follower_col = headers.get('跟单小组') or headers.get('采购跟单') + planner_col = headers.get('采购计划') + + if not name_col: + return jsonify({"code": 400, "msg": "未找到供应商名称列,表头需包含'供应商名称'"}) + if not short_col and not contract_col and not follower_col and not planner_col: + return jsonify({"code": 400, "msg": "未找到简称、合同简称、跟单小组或采购计划列"}) + + # 全量加载供应商 + all_suppliers = query("SELECT id, supplier_code, supplier_name, short_name, contract_short_name FROM suppliers") + name_map = {} + for s in all_suppliers: + key = (s['supplier_name'] or '').strip() + name_map[key] = s + + # 预校验:合同简称必须全大写 + if contract_col: + for row_idx in range(2, ws.max_row + 1): + v = ws.cell(row=row_idx, column=contract_col).value + if v is not None: + val = str(v).strip() + if val and val != val.upper(): + supplier_name = str(ws.cell(row=row_idx, column=name_col).value or '').strip() + return jsonify({"code": 400, "msg": f"合同简称必须全大写(行{row_idx},供应商「{supplier_name}」的合同简称为「{val}」)"}) + + updated = [] + not_found = [] + skipped = [] + + for row_idx in range(2, ws.max_row + 1): + supplier_name = str(ws.cell(row=row_idx, column=name_col).value or '').strip() + if not supplier_name: + continue + + short_val = None + contract_val = None + follower_val = None + planner_val = None + if short_col: + v = ws.cell(row=row_idx, column=short_col).value + short_val = str(v).strip() if v is not None else None + if contract_col: + v = ws.cell(row=row_idx, column=contract_col).value + contract_val = str(v).strip() if v is not None else None + if follower_col: + v = ws.cell(row=row_idx, column=follower_col).value + follower_val = str(v).strip() if v is not None else None + if planner_col: + v = ws.cell(row=row_idx, column=planner_col).value + planner_val = str(v).strip() if v is not None else None + + if not short_val and not contract_val and not follower_val and not planner_val: + skipped.append(supplier_name) + continue + + match = name_map.get(supplier_name) + if not match: + not_found.append(supplier_name) + continue + + # 更新 + update_fields = [] + update_params = [] + if short_val: + update_fields.append("short_name=%s") + update_params.append(short_val) + if contract_val: + update_fields.append("contract_short_name=%s") + update_params.append(contract_val) + if follower_val: + update_fields.append("procurement_follower=%s") + update_params.append(follower_val) + if planner_val: + update_fields.append("procurement_planner=%s") + update_params.append(planner_val) + if update_fields: + update_params.append(match['id']) + query(f"UPDATE suppliers SET {', '.join(update_fields)} WHERE id=%s", update_params) + updated.append(supplier_name) + + return jsonify({"code": 0, "data": { + "updated": len(updated), + "not_found": len(not_found), + "skipped": len(skipped), + "not_found_list": not_found[:20], + "updated_list": updated, + "message": f"成功更新 {len(updated)} 条" + (f",{len(not_found)} 条未匹配" if not_found else "") + (f",{len(skipped)} 条跳过(无数据)" if skipped else "") + }}) + + +@products_bp.route('/api/supplier/create', methods=['POST']) +@login_required +def supplier_create(): + """新增供应商""" + if not operation_required('suppliers', 'create'): + return jsonify({"code": 403, "msg": "无操作权限"}) + data = request.json or {} + code = (data.get('supplier_code') or '').strip() + name = (data.get('supplier_name') or '').strip() + if not code or not name: + return jsonify({"code": 400, "msg": "供应商编码和名称不能为空"}) + exist = query("SELECT COUNT(*) AS c FROM suppliers WHERE supplier_code=%s", (code,)) + if exist and exist[0]['c'] > 0: + return jsonify({"code": 400, "msg": f"供应商编码 [{code}] 已存在"}) + try: + query("""INSERT INTO suppliers (supplier_code, supplier_name, production_type, short_name, contract_short_name, supplier_group, procurement_follower, procurement_planner, reviewer, review_date, data_status, disabled_status) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""", + (code, name, + data.get('production_type', '') or None, + data.get('short_name', '') or None, + data.get('contract_short_name', '') or None, + data.get('supplier_group', '') or None, + data.get('procurement_follower', '') or None, + data.get('procurement_planner', '') or None, + data.get('reviewer', '') or None, + data.get('review_date', '') or None, + data.get('data_status', '') or None, + data.get('disabled_status', '') or None)) + try: + add_operation_log('新增供应商', f'新增供应商 {code} - {name}') + except: + pass + return jsonify({"code": 0, "msg": "新增成功"}) + except Exception as e: + return jsonify({"code": 500, "msg": str(e)}) + + +@products_bp.route('/api/product/delete', methods=['POST']) +@login_required +def product_delete(): + """软删除产品(移入回收站)""" + if not operation_required('products', 'delete'): + return jsonify({"code": 403, "msg": "无操作权限"}) + data = request.json + code = data.get('product_code', '') + if not code: + return jsonify({"code": 400, "msg": "参数错误"}) + try: + query("UPDATE products SET deleted_at=NOW() WHERE product_code=%s", (code,)) + # 级联删除:物料清单 + # 先查出成品绑定的包材(需在 BOM DELETE 之前查询) + materials = query("SELECT material_code FROM bom WHERE product_code=%s", (code,)) + query("DELETE FROM bom WHERE product_code=%s", (code,)) + query("DELETE FROM bom WHERE material_code=%s", (code,)) + # 级联删除:成品绑定的包材产品(软删除) + for m in materials: + query("UPDATE products SET deleted_at=NOW() WHERE product_code=%s", (m['material_code'],)) + # 级联删除:计划预测隐藏记录 + query("DELETE FROM plan_hidden WHERE product_code=%s", (code,)) + return jsonify({"code": 0, "msg": "已移入回收站"}) + except Exception as e: + return jsonify({"code": 500, "msg": str(e)}) + + +@products_bp.route('/api/product/recycle', methods=['POST']) +@login_required +def product_recycle(): + """将成品及其BOM包材移入回收站,并删除该成品的BOM。""" + if not operation_required('products', 'delete'): + return jsonify({"code": 403, "msg": "无操作权限"}) + data = request.json or {} + code = (data.get('product_code') or '').strip() + if not code: + return jsonify({"code": 400, "msg": "缺少货品编号"}) + + conn = get_db() + try: + conn.begin() + with conn.cursor() as cursor: + cursor.execute( + "SELECT product_name FROM products WHERE product_code=%s AND deleted_at IS NULL", + (code,) + ) + product = cursor.fetchone() + if not product: + conn.rollback() + return jsonify({"code": 404, "msg": "未找到可回收的产品"}) + + cursor.execute( + "SELECT DISTINCT b.material_code FROM bom b " + "JOIN products m ON m.product_code=b.material_code " + "WHERE b.product_code=%s AND b.material_code IS NOT NULL " + "AND b.material_code != '' AND m.product_type IN ('包材', '其他')", + (code,) + ) + material_codes = [row['material_code'] for row in cursor.fetchall()] + + cursor.execute("UPDATE products SET deleted_at=NOW() WHERE product_code=%s", (code,)) + if material_codes: + placeholders = ','.join(['%s'] * len(material_codes)) + cursor.execute( + f"UPDATE products SET deleted_at=NOW() WHERE product_code IN ({placeholders})", + material_codes + ) + cursor.execute("DELETE FROM bom WHERE product_code=%s", (code,)) + conn.commit() + + # 计划预测通过 deleted_at 过滤;保留用户原有的手工隐藏状态。 + try: + add_operation_log( + '产品回收', + f"回收产品 {code} - {product.get('product_name', '')}," + f"同步回收包材 {len(material_codes)} 个并删除BOM" + ) + except Exception: + pass + return jsonify({ + "code": 0, + "msg": f"已回收成品及{len(material_codes)}个关联包材,并删除该成品BOM", + "data": {"recycled_materials": len(material_codes)} + }) + except Exception as e: + conn.rollback() + return jsonify({"code": 500, "msg": str(e)}) + finally: + conn.close() + + +@products_bp.route('/api/products/recycle_bin', methods=['GET']) +@login_required +def products_recycle_bin(): + """获取回收站产品列表""" + page = int(request.args.get('page', 1)) + size = int(request.args.get('size', 50)) + offset = (page - 1) * size + + search = request.args.get('search', '') + prod_code = request.args.get('prod_code', '') + prod_name = request.args.get('prod_name', '') + unified_search = request.args.get('search', '') + supplier_filter = request.args.get('supplier', '') + product_type = request.args.get('product_type', '') + + where = ["p.deleted_at IS NOT NULL"] + params = [] + + if search: + where.append("(p.product_name LIKE %s OR p.product_code LIKE %s)") + params.extend([f"%{search}%", f"%{search}%"]) + if prod_code: + where.append("p.product_code LIKE %s") + params.append(f"%{prod_code}%") + if prod_name: + where.append("p.product_name LIKE %s") + params.append(f"%{prod_name}%") + if supplier_filter: + where.append("(p.supplier_name_match LIKE %s OR s.supplier_name LIKE %s)") + params.extend([f"%{supplier_filter}%", f"%{supplier_filter}%"]) + if product_type: + where.append("p.product_type=%s") + params.append(product_type) + + where_clause = " AND ".join(where) + + total = query(f"""SELECT COUNT(*) AS cnt FROM products p + LEFT JOIN suppliers s ON p.supplier_code = s.supplier_code + WHERE {where_clause}""", params)[0]['cnt'] + + rows = query(f""" + SELECT p.id, p.goods_code, p.product_code, p.product_name, p.brand, p.cost_price, + p.product_type, p.specification, p.lead_time, p.logistics_time, + p.supplier_code, p.supplier_name_match, + p.procurement_follower, p.procurement_planner, p.platform, p.operator, p.developer, + p.category1, p.category2, p.category3, p.created_at, p.import_date, p.deleted_at, + p.custom_field1, p.custom_field2, p.custom_field3, p.custom_field4, p.custom_field5 + FROM products p + LEFT JOIN suppliers s ON p.supplier_code = s.supplier_code + WHERE {where_clause} + ORDER BY p.deleted_at DESC + LIMIT {size} OFFSET {offset} + """, params) + + for row in rows: + if row.get('logistics_time') is None: + row['logistics_time'] = 0 + if row.get('lead_time') is None: + row['lead_time'] = 0 + if 'created_at' in row and row['created_at']: + row['created_at'] = row['created_at'].strftime('%Y-%m-%d %H:%M:%S') if hasattr(row['created_at'], 'strftime') else str(row['created_at'])[:19] + if 'import_date' in row and row['import_date']: + row['import_date'] = row['import_date'].strftime('%Y-%m-%d') if hasattr(row['import_date'], 'strftime') else str(row['import_date'])[:10] + if row.get('deleted_at'): + row['deleted_at'] = row['deleted_at'].strftime('%Y-%m-%d %H:%M:%S') if hasattr(row['deleted_at'], 'strftime') else str(row['deleted_at'])[:19] + if not row.get('product_type'): + row['product_type'] = get_product_type_cached(row.get('product_name', ''), row.get('brand', ''), row.get('product_type')) + + return jsonify({ + "code": 0, + "data": { + "total": total, + "page": page, + "size": size, + "rows": rows + } + }) + + +@products_bp.route('/api/product/restore', methods=['POST']) +@login_required +def product_restore(): + """从回收站恢复产品""" + if not operation_required('products', 'edit'): + return jsonify({"code": 403, "msg": "无操作权限"}) + data = request.json + code = data.get('product_code', '') + if not code: + return jsonify({"code": 400, "msg": "参数错误"}) + try: + query("UPDATE products SET deleted_at=NULL WHERE product_code=%s", (code,)) + add_operation_log('产品恢复', f'恢复产品 {code}') + return jsonify({"code": 0, "msg": "已恢复到成品基础信息表"}) + except Exception as e: + return jsonify({"code": 500, "msg": str(e)}) + + +@products_bp.route('/api/product/batch_restore', methods=['POST']) +@login_required +def product_batch_restore(): + """批量恢复回收站产品。""" + if not operation_required('products', 'edit'): + return jsonify({"code": 403, "msg": "无操作权限"}) + data = request.json or {} + codes = list(dict.fromkeys(str(code).strip() for code in data.get('product_codes', []) if str(code).strip())) + if not codes: + return jsonify({"code": 400, "msg": "请选择需要恢复的产品"}) + if len(codes) > 500: + return jsonify({"code": 400, "msg": "单次最多恢复500个产品"}) + + try: + placeholders = ','.join(['%s'] * len(codes)) + rows = query( + f"SELECT product_code FROM products WHERE deleted_at IS NOT NULL AND product_code IN ({placeholders})", + codes + ) + valid_codes = [row['product_code'] for row in rows] + if not valid_codes: + return jsonify({"code": 404, "msg": "所选产品均不在回收站中"}) + valid_placeholders = ','.join(['%s'] * len(valid_codes)) + query( + f"UPDATE products SET deleted_at=NULL WHERE deleted_at IS NOT NULL AND product_code IN ({valid_placeholders})", + valid_codes + ) + add_operation_log('产品批量恢复', f"批量恢复 {len(valid_codes)} 个产品:{','.join(valid_codes)}") + return jsonify({"code": 0, "msg": f"已恢复{len(valid_codes)}个产品", "data": {"restored": len(valid_codes)}}) + except Exception as e: + return jsonify({"code": 500, "msg": str(e)}) + + +@products_bp.route('/api/bom/update_materials', methods=['POST']) +@login_required +def bom_update_materials(): + if not _check_product_edit_perm(): + return jsonify({"code": 403, "msg": "仅管理员可操作"}) + data = request.json + materials = data.get('materials', []) + if not materials: + return jsonify({"code": 400, "msg": "参数不完整"}) + allowed = ['material_name'] + try: + for m in materials: + code = m.get('material_code', '') + fields = m.get('fields', {}) + if not code: + continue + set_parts = [] + params = [] + for field, value in fields.items(): + if field in allowed: + set_parts.append(f"{field}=%s") + params.append(value if value != '' else None) + if set_parts: + set_clause = ", ".join(set_parts) + params.append(code) + query(f"UPDATE bom SET {set_clause} WHERE material_code=%s", params) + # Also update the main product info in products table + main_product_code = data.get('product_code', '') + main_fields = data.get('main_fields', {}) + if main_product_code and main_fields: + product_allowed = ['product_name', 'cost_price', 'supplier_name'] + set_parts = [] + params = [] + for field, value in main_fields.items(): + if field in product_allowed: + set_parts.append(f"{field}=%s") + params.append(value if value != '' else None) + if set_parts: + set_clause = ", ".join(set_parts) + params.append(main_product_code) + query(f"UPDATE products SET {set_clause} WHERE product_code=%s", params) + return jsonify({"code": 0, "msg": "更新成功"}) + except Exception as e: + return jsonify({"code": 500, "msg": str(e)}) + + +@products_bp.route('/api/bom/create', methods=['POST']) +@login_required +def bom_create(): + """新增BOM条目""" + if not _check_product_edit_perm(): + return jsonify({"code": 403, "msg": "仅管理员可操作"}) + data = request.json or {} + product_code = data.get('product_code', '').strip() + material_code = data.get('material_code', '').strip() + material_name = data.get('material_name', '').strip() + quantity = float(data.get('quantity', 1)) + if not product_code or not material_code: + return jsonify({"code": 400, "msg": "货品编号和物料编号不能为空"}) + existing = query("SELECT id FROM bom WHERE product_code=%s AND material_code=%s", (product_code, material_code)) + if existing: + return jsonify({"code": 400, "msg": "该BOM条目已存在"}) + try: + prod = query("SELECT product_name FROM products WHERE product_code=%s", (product_code,)) + product_name = prod[0]['product_name'] if prod else product_code + mat = query("SELECT product_name FROM products WHERE product_code=%s", (material_code,)) + if not material_name and mat: + material_name = mat[0]['product_name'] + query("INSERT INTO bom (product_code, product_name, material_code, material_name, quantity) VALUES (%s,%s,%s,%s,%s)", + (product_code, product_name, material_code, material_name, quantity)) + return jsonify({"code": 0, "msg": "创建成功"}) + except Exception as e: + return jsonify({"code": 500, "msg": str(e)}) + + +@products_bp.route('/api/bom/export') +@login_required +def bom_export(): + """导出产品BOM状态(含有无BOM标记)""" + rows = query(""" + SELECT t.product_code, t.product_name, t.has_bom, + p.product_type, p.supplier_name_match + FROM ( + SELECT b.product_code, MAX(b.product_name) as product_name, + CASE WHEN SUM(CASE WHEN b.material_code IS NOT NULL AND b.material_code != '' THEN 1 ELSE 0 END) > 0 THEN '有' ELSE '无' END AS has_bom + FROM bom b + GROUP BY b.product_code + ) t + LEFT JOIN products p ON t.product_code = p.product_code + ORDER BY t.product_code + """) + import csv, io + output = io.StringIO() + writer = csv.writer(output) + writer.writerow(['货品编号', '货品名称', '有无BOM', '类型', '供应商']) + for r in rows: + writer.writerow([ + r['product_code'], r['product_name'], r['has_bom'], + r['product_type'] or '', r['supplier_name_match'] or '' + ]) + csv_data = output.getvalue() + output.close() + from flask import Response + return Response( + csv_data, + mimetype='text/csv', + headers={'Content-Disposition': 'attachment; filename=bom_export.csv'} + ) + + +@products_bp.route('/api/bom/delete', methods=['POST']) +@login_required +def bom_delete(): + """删除成品BOM数据(含所有物料行)""" + if not _check_product_edit_perm(): + return jsonify({"code": 403, "msg": "仅管理员可操作"}) + data = request.json or {} + product_code = data.get('product_code', '').strip() + if not product_code: + return jsonify({"code": 400, "msg": "缺少成品编号"}) + try: + query("DELETE FROM bom WHERE product_code=%s", (product_code,)) + return jsonify({"code": 0, "msg": f"已删除 [{product_code}] 的物料清单"}) + except Exception as e: + return jsonify({"code": 500, "msg": str(e)}) + + +@products_bp.route('/api/bom/create_with_materials', methods=['POST']) +@login_required +def bom_create_with_materials(): + """新建成品BOM并自动创建包材产品""" + if not _check_product_edit_perm(): + return jsonify({"code": 403, "msg": "仅管理员可操作"}) + data = request.json or {} + product_code = data.get('product_code', '').strip() + materials = data.get('materials', []) + + if not product_code: + return jsonify({"code": 400, "msg": "缺少成品编号"}) + + created = 0 + total_cost = 0 + + # 自动创建成品产品(如果不存在) + existing_product = query("SELECT id, product_name, product_type FROM products WHERE product_code=%s", (product_code,)) + if not existing_product: + prod_supplier = data.get('product_supplier', '').strip() + query("""INSERT INTO products (product_code, product_name, brand, product_type, supplier_name_match, import_date) + VALUES (%s,%s,'','成品',%s,NOW())""", + (product_code, data.get('product_name', product_code), prod_supplier)) + product_name = data.get('product_name', product_code) + else: + product_name = existing_product[0]['product_name'] or product_code + + for m in materials: + mat_code = m.get('material_code', '').strip() + mat_name = m.get('material_name', '').strip() + qty = float(m.get('quantity', 1)) + cost_price = float(m.get('cost_price', 0)) if m.get('cost_price') else 0 + sup_code = m.get('supplier_code', '').strip() + sup_name = _normalize_bom_supplier(m.get('supplier_name', '')) + mat_type = m.get('product_type', '包材').strip() or '包材' + if not mat_code: + continue + + # 累加成本(成本价 × 用量) + total_cost += qty * cost_price + + # 自动创建/更新包材产品 + existing = query("SELECT id FROM products WHERE product_code=%s", (mat_code,)) + if not existing: + query("""INSERT INTO products (product_code, product_name, brand, product_type, supplier_code, supplier_name_match, import_date) + VALUES (%s,%s,'',%s,%s,%s,NOW())""", + (mat_code, mat_name or mat_code, mat_type, sup_code, sup_name)) + else: + # 已有物料更新供应商和名称 + update_parts = [] + update_params = [] + if sup_name: + update_parts.append("supplier_name_match=%s") + update_params.append(sup_name) + if sup_code: + update_parts.append("supplier_code=%s") + update_params.append(sup_code) + if mat_name: + update_parts.append("product_name=%s") + update_params.append(mat_name) + if mat_type: + update_parts.append("product_type=%s") + update_params.append(mat_type) + if update_parts: + update_params.append(mat_code) + query(f"UPDATE products SET {', '.join(update_parts)} WHERE product_code=%s", update_params) + + # 新增/更新 BOM(含成本价) + bom_exists = query("SELECT id FROM bom WHERE product_code=%s AND material_code=%s", + (product_code, mat_code)) + if bom_exists: + query("UPDATE bom SET quantity=%s, material_name=%s, supplier=%s, material_cost_price=%s WHERE product_code=%s AND material_code=%s", + (qty, mat_name, sup_name, cost_price, product_code, mat_code)) + else: + query("""INSERT INTO bom (product_code, product_name, material_code, material_name, quantity, supplier, material_cost_price) + VALUES (%s,%s,%s,%s,%s,%s,%s)""", + (product_code, product_name, mat_code, mat_name, qty, sup_name, cost_price)) + created += 1 + + # 更新成品成本价 = 所有物料成本之和 + if created > 0: + query("UPDATE products SET cost_price=%s WHERE product_code=%s", (total_cost, product_code)) + + return jsonify({"code": 0, "msg": f"绑定完成,共处理{created}条BOM,成品成本价:¥{total_cost:.2f}"}) + + +@products_bp.route('/api/product/sync_bom', methods=['POST']) +@login_required +def product_sync_bom(): + """将成品BOM包材数据同步到物料清单管理(bom表)""" + data = request.json or {} + product_code = data.get('product_code', '').strip() + if not product_code: + return jsonify({"code": 400, "msg": "缺少成品编号"}) + + # 获取当前产品已有的BOM数据 + bom_materials = query(""" + SELECT material_code, material_name, quantity AS bom_qty + FROM bom WHERE product_code=%s + """, (product_code,)) + + if not bom_materials: + return jsonify({"code": 0, "msg": "暂无包材数据可同步", "synced": 0}) + + prod = query("SELECT product_name FROM products WHERE product_code=%s", (product_code,)) + product_name = prod[0]['product_name'] if prod else product_code + + synced = 0 + for m in bom_materials: + mat_code = m['material_code'] + mat_name = m['material_name'] or mat_code + qty = float(m['bom_qty']) if m['bom_qty'] else 1 + + # 确保物料已作为包材产品存在 + existing = query("SELECT id FROM products WHERE product_code=%s", (mat_code,)) + if not existing: + query("""INSERT INTO products (product_code, product_name, brand, product_type, import_date) + VALUES (%s,%s,'','包材',NOW())""", + (mat_code, mat_name)) + + # 确保BOM条目存在 + bom_exists = query("SELECT id FROM bom WHERE product_code=%s AND material_code=%s", + (product_code, mat_code)) + if bom_exists: + query("UPDATE bom SET quantity=%s, material_name=%s WHERE product_code=%s AND material_code=%s", + (qty, mat_name, product_code, mat_code)) + else: + query("""INSERT INTO bom (product_code, product_name, material_code, material_name, quantity) + VALUES (%s,%s,%s,%s,%s)""", + (product_code, product_name, mat_code, mat_name, qty)) + synced += 1 + + return jsonify({"code": 0, "msg": f"同步完成,更新{len(bom_materials)}条,新增{synced}条", "synced": synced}) + + +@products_bp.route('/api/products/match_supplier', methods=['POST']) +@login_required +def products_match_supplier(): + """批量匹配包材产品的供应商名称,补全全称和编码""" + if session.get("role") not in ("admin", "super_admin"): + return jsonify({"code": 403, "msg": "仅管理员可操作"}) + + product_type = request.json.get('product_type', '') if request.json else '' + if not product_type: + return jsonify({"code": 400, "msg": "缺少产品类型"}) + + # 获取所有供应商 + suppliers = query("SELECT supplier_code, supplier_name FROM suppliers") + if not suppliers: + return jsonify({"code": 0, "msg": "供应商列表为空", "matched": 0}) + + # 获取指定类型的产品(如果supplier_name_match与supplier对不上则更新) + products = query(""" + SELECT product_code, supplier_code, supplier_name_match + FROM products WHERE product_type=%s AND deleted_at IS NULL + """, (product_type,)) + + matched = 0 + for p in products: + pname = (p.get('supplier_name_match') or '').strip() + if not pname: + continue + best = None + # 模糊匹配供应商名称 + for s in suppliers: + sname = (s['supplier_name'] or '').strip() + if not sname: + continue + # 如果产品供应商名包含在供应商全称中,或供应商全称包含产品供应商名 + if pname in sname or sname in pname: + best = s + break + if best: + # 如果已是最佳匹配则跳过 + if p.get('supplier_code') == best['supplier_code'] and p.get('supplier_name_match') == best['supplier_name']: + continue + query("UPDATE products SET supplier_code=%s, supplier_name_match=%s WHERE product_code=%s", + (best['supplier_code'], best['supplier_name'], p['product_code'])) + matched += 1 + + # 也匹配 supplier_code 为空但有 supplier_name_match 的包材产品 + unmatched = query(""" + SELECT product_code, supplier_name_match FROM products + WHERE product_type=%s AND deleted_at IS NULL AND (supplier_code IS NULL OR supplier_code='') + """, (product_type,)) + for p in unmatched: + pname = (p.get('supplier_name_match') or '').strip() + if not pname: + continue + for s in suppliers: + sname = (s['supplier_name'] or '').strip() + if pname in sname or sname in pname: + query("UPDATE products SET supplier_code=%s, supplier_name_match=%s WHERE product_code=%s", + (s['supplier_code'], s['supplier_name'], p['product_code'])) + matched += 1 + break + + return jsonify({"code": 0, "msg": f"匹配完成,共更新{matched}条"}) + + +@products_bp.route('/api/product/permanent_delete', methods=['POST']) +@login_required +def product_permanent_delete(): + """从回收站彻底删除产品(仅管理员)""" + if session.get("role") not in ("admin", "super_admin"): + return jsonify({"code": 403, "msg": "仅管理员可操作"}) + data = request.json + code = data.get('product_code', '') + if not code: + return jsonify({"code": 400, "msg": "参数错误"}) + try: + product = query( + "SELECT product_name FROM products WHERE product_code=%s AND deleted_at IS NOT NULL", + (code,) + ) + if not product: + return jsonify({"code": 404, "msg": "该产品不在回收站中,无法彻底删除"}) + + # 只删除当前产品及其关联记录,不连带删除其他产品。 + query("DELETE FROM bom WHERE product_code=%s OR material_code=%s", (code, code)) + query("DELETE FROM plan_hidden WHERE product_code=%s", (code,)) + query("DELETE FROM products WHERE product_code=%s AND deleted_at IS NOT NULL", (code,)) + add_operation_log('产品彻底删除', f"彻底删除产品 {code} - {product[0].get('product_name', '')}") + return jsonify({"code": 0, "msg": "已彻底删除,无法恢复"}) + except Exception as e: + return jsonify({"code": 500, "msg": str(e)}) + + +@products_bp.route('/api/product/batch_permanent_delete', methods=['POST']) +@login_required +def product_batch_permanent_delete(): + """批量彻底删除回收站产品(仅管理员)。""" + if session.get("role") not in ("admin", "super_admin"): + return jsonify({"code": 403, "msg": "仅管理员可操作"}) + data = request.json or {} + codes = list(dict.fromkeys(str(code).strip() for code in data.get('product_codes', []) if str(code).strip())) + if not codes: + return jsonify({"code": 400, "msg": "请选择需要删除的产品"}) + if len(codes) > 500: + return jsonify({"code": 400, "msg": "单次最多删除500个产品"}) + + try: + placeholders = ','.join(['%s'] * len(codes)) + rows = query( + f"SELECT product_code FROM products WHERE deleted_at IS NOT NULL AND product_code IN ({placeholders})", + codes + ) + valid_codes = [row['product_code'] for row in rows] + if not valid_codes: + return jsonify({"code": 404, "msg": "所选产品均不在回收站中"}) + valid_placeholders = ','.join(['%s'] * len(valid_codes)) + query( + f"DELETE FROM bom WHERE product_code IN ({valid_placeholders}) OR material_code IN ({valid_placeholders})", + valid_codes + valid_codes + ) + query(f"DELETE FROM plan_hidden WHERE product_code IN ({valid_placeholders})", valid_codes) + query( + f"DELETE FROM products WHERE deleted_at IS NOT NULL AND product_code IN ({valid_placeholders})", + valid_codes + ) + add_operation_log('产品批量彻底删除', f"批量彻底删除 {len(valid_codes)} 个产品:{','.join(valid_codes)}") + return jsonify({"code": 0, "msg": f"已彻底删除{len(valid_codes)}个产品,无法恢复", "data": {"deleted": len(valid_codes)}}) + except Exception as e: + return jsonify({"code": 500, "msg": str(e)}) + + +@products_bp.route('/api/product_categories/list') +@login_required +def product_categories_list(): + if not operation_required('product_category', 'view'): + return jsonify({"code": 403, "msg": "权限不足,当前账号没有该操作权限"}) + """获取产品类目列表(按一级/二级/三级分组,最新在前)""" + try: + rows = query( + "SELECT id, category1, category2, category3, category4 FROM product_categories ORDER BY id DESC" + ) + # 按一级类目分组(保持插入顺序,最新一组在前) + grouped = {} + for r in rows: + c1 = r['category1'] or '' + grouped.setdefault(c1, []).append({ + 'id': r['id'], + 'category1': c1, + 'category2': r['category2'] or '', + 'category3': r['category3'] or '', + 'category4': r['category4'] or '' + }) + result = [] + for c1 in grouped.keys(): + items = grouped[c1] + # 二级类目分组(保持插入顺序,最新在前) + subs = {} + for it in items: + c2 = it['category2'] or '' + subs.setdefault(c2, []).append(it) + result.append({ + 'category1': c1, + 'count': len(items), + 'children': [ + {'category2': c2, 'count': len(sub), 'items': sub} + for c2, sub in subs.items() + ] + }) + return jsonify({"code": 0, "data": result, "total": len(rows)}) + except Exception as e: + return jsonify({"code": 500, "msg": str(e)}) + + +@products_bp.route('/api/product_categories/update', methods=['POST']) +@login_required +def product_categories_update(): + if not operation_required('product_category', 'edit'): + return jsonify({"code": 403, "msg": "权限不足,当前账号没有该操作权限"}) + """编辑保存类目(四列可改)""" + data = request.get_json(silent=True) or {} + cid = data.get('id') + c1 = (data.get('category1') or '').strip() + c2 = (data.get('category2') or '').strip() + c3 = (data.get('category3') or '').strip() + c4 = (data.get('category4') or '').strip() + if not cid: + return jsonify({"code": 400, "msg": "缺少类目ID"}) + if not c1: + return jsonify({"code": 400, "msg": "一级类目不能为空"}) + try: + cur = query( + "UPDATE product_categories SET category1=%s, category2=%s, category3=%s, category4=%s WHERE id=%s", + (c1, c2, c3, c4, cid) + ) + return jsonify({"code": 0, "msg": "保存成功"}) + except Exception as e: + return jsonify({"code": 500, "msg": str(e)}) + + +@products_bp.route('/api/product_categories/create', methods=['POST']) +@login_required +def product_categories_create(): + if not operation_required('product_category', 'create'): + return jsonify({"code": 403, "msg": "权限不足,当前账号没有该操作权限"}) + """新增类目(四列)""" + data = request.get_json(silent=True) or {} + c1 = (data.get('category1') or '').strip() + c2 = (data.get('category2') or '').strip() + c3 = (data.get('category3') or '').strip() + c4 = (data.get('category4') or '').strip() + if not c1: + return jsonify({"code": 400, "msg": "一级类目不能为空"}) + try: + # 新建前判断四级类目是否已存在(按完整名称:一级/二级/三级/四级组合) + exists = query( + "SELECT id FROM product_categories WHERE category1=%s AND category2=%s AND category3=%s AND category4=%s LIMIT 1", + (c1, c2, c3, c4) + ) + if exists: + full_name = '/'.join([x for x in (c1, c2, c3, c4) if x]) + return jsonify({"code": 400, "msg": f"该四级类目已存在(完整名称:{full_name}),请勿重复新增"}) + cur = query( + "INSERT INTO product_categories (category1, category2, category3, category4) VALUES (%s, %s, %s, %s)", + (c1, c2, c3, c4) + ) + return jsonify({"code": 0, "msg": "新增成功", "data": {"id": cur.lastrowid if hasattr(cur, 'lastrowid') else None}}) + except Exception as e: + return jsonify({"code": 500, "msg": str(e)}) + + +@products_bp.route('/api/product_categories/delete', methods=['POST']) +@login_required +def product_categories_delete(): + if not operation_required('product_category', 'delete'): + return jsonify({"code": 403, "msg": "权限不足,当前账号没有该操作权限"}) + """删除类目""" + data = request.get_json(silent=True) or {} + cid = data.get('id') + if not cid: + return jsonify({"code": 400, "msg": "缺少类目ID"}) + try: + query("DELETE FROM product_categories WHERE id=%s", (cid,)) + return jsonify({"code": 0, "msg": "删除成功"}) + except Exception as e: + return jsonify({"code": 500, "msg": str(e)}) + + +# ─── 新品管理 API ─── +NEW_PRODUCT_FIELDS = ['category1', 'category2', 'category3', 'product_type', 'brand', 'product_code', 'product_name', 'barcode', 'capacity', 'effect_img', 'remark', 'total_cost', 'total_payment', 'procurement_planner', 'product_dev', 'package_dev', 'designer', 'operator', 'upgrade_img', 'confirm_img', 'quote_inner_img', 'quote_pack_img', 'freight_included'] + +_NEW_PRODUCT_FREIGHT_READY = False + +def _ensure_new_product_freight_column(): + """兼容旧环境:幂等补充新品是否含运字段。""" + global _NEW_PRODUCT_FREIGHT_READY + if _NEW_PRODUCT_FREIGHT_READY: + return + rows = query("SELECT COUNT(*) AS cnt FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='new_products' AND column_name='freight_included'") + if not rows or int(rows[0].get('cnt') or 0) == 0: + query("ALTER TABLE new_products ADD COLUMN freight_included VARCHAR(10) NOT NULL DEFAULT '' COMMENT '是否含运' AFTER quote_pack_img") + _NEW_PRODUCT_FREIGHT_READY = True + + + +def _is_inner_material(material_name, material_code): + """内料判定:名称含「内料」或编码含 -NL(与系统其他位置口径一致)。""" + return ('内料' in (material_name or '')) or ('-NL' in (material_code or '')) + + +def resolve_new_product_planner(product_id): + """按品牌与 BOM 内料供应商匹配新品采购计划。 + + 第一层:品牌为海外品时不动(留空或保留人工维护值); + 第二层:取该新品 BOM 中内料物料的供应商,匹配供应商管理里绑定的采购计划。 + 匹配到时更新,匹配不到不覆盖原值。 + """ + rows = query("SELECT brand FROM new_products WHERE id=%s", (product_id,)) + if not rows: + return None + brand = (rows[0].get('brand') or '').strip() + if brand: + cat = query("SELECT category FROM brand_information WHERE brand=%s LIMIT 1", (brand,)) + if cat and (cat[0].get('category') or '').strip() == '海外': + return None + materials = query(""" + SELECT material_code, material_name, supplier_code, supplier + FROM new_product_bom WHERE product_id=%s ORDER BY sort_order, id + """, (product_id,)) + for m in materials: + if not _is_inner_material(m.get('material_name'), m.get('material_code')): + continue + supplier_code = (m.get('supplier_code') or '').strip() + supplier_name = (m.get('supplier') or '').strip() + hit = None + if supplier_code: + r = query("SELECT procurement_planner FROM suppliers WHERE supplier_code=%s", (supplier_code,)) + hit = r[0] if r else None + if not hit and supplier_name: + r = query("SELECT procurement_planner FROM suppliers WHERE supplier_name=%s", (supplier_name,)) + hit = r[0] if r else None + if not hit and supplier_name: + r = query("SELECT procurement_planner FROM suppliers WHERE supplier_name LIKE %s ORDER BY id LIMIT 1", (f"%{supplier_name}%",)) + hit = r[0] if r else None + planner = (hit.get('procurement_planner') or '').strip() if hit else '' + if planner: + query("UPDATE new_products SET procurement_planner=%s WHERE id=%s", (planner, product_id)) + return planner + return None + + +def _sync_new_product_to_product_info(conn, product_id): + """在同一事务中同步新品成品、包材和 BOM,不删除目标表已有记录。""" + with conn.cursor() as cursor: + cursor.execute("SELECT * FROM new_products WHERE id=%s FOR UPDATE", (product_id,)) + product = cursor.fetchone() + if not product: + raise ValueError("新品数据不存在,请刷新后重试") + + product_code = (product.get('product_code') or '').strip() + product_name = (product.get('product_name') or '').strip() + if not product_code or not product_name: + raise ValueError("产品编码和产品名称不能为空") + + cursor.execute(""" + SELECT material_code, material_name, supplier_code, supplier, lead_time, + unit_price, quantity, usage_quantity, common_code, common_name + FROM new_product_bom + WHERE product_id=%s + ORDER BY sort_order, id + """, (product_id,)) + source_materials = cursor.fetchall() + + materials_by_code = {} + for material in source_materials: + # 物料编号/物料名称对应新品成品管理BOM里的物料编码/物料名称, + # 通用物料编码/名称不参与物料清单的编号名称映射。 + material_code = (material.get('material_code') or '').strip() + if not material_code: + continue + material_name = (material.get('material_name') or '').strip() + materials_by_code[material_code] = { + 'material_code': material_code, + 'material_name': material_name, + 'supplier_code': (material.get('supplier_code') or '').strip(), + 'supplier_name': (material.get('supplier') or '').strip(), + 'lead_time': int(material.get('lead_time') or 0), + 'unit_price': float(material.get('unit_price') or 0), + # 新品BOM“数量”是新品采购数量;同步到现有BOM的用量使用独立字段。 + 'quantity': float(material.get('usage_quantity')) if material.get('usage_quantity') is not None else 1.0, + } + materials = list(materials_by_code.values()) + + # ─── 计算成品同步属性(供应商取内料、货期=内料货期+其他物料货期最大值、成本价=物料成本价之和)─── + inner_material = None + other_leads = [] + total_material_cost = 0.0 + for m in materials: + total_material_cost += m['unit_price'] + if '内料' in (m['material_name'] or ''): + inner_material = m + else: + other_leads.append(m['lead_time']) + if inner_material: + prod_supplier_code = inner_material['supplier_code'] + prod_supplier_name = inner_material['supplier_name'] + prod_lead_time = inner_material['lead_time'] + (max(other_leads) if other_leads else 0) + else: + prod_supplier_code = next((m['supplier_code'] for m in materials if m['supplier_code']), '') + prod_supplier_name = next((m['supplier_name'] for m in materials if m['supplier_name']), '') + prod_lead_time = max((m['lead_time'] for m in materials), default=0) + prod_cost_price = round(total_material_cost, 4) + + all_codes = [product_code] + [material['material_code'] for material in materials] + placeholders = ','.join(['%s'] * len(all_codes)) + cursor.execute( + f"SELECT product_code, product_name FROM products WHERE product_code IN ({placeholders})", + tuple(all_codes) + ) + existing_product_rows = cursor.fetchall() + existing_product_names = { + row['product_code']: row.get('product_name') or '' for row in existing_product_rows + } + existing_product_codes = set(existing_product_names) + + cursor.execute( + "SELECT material_code FROM bom WHERE product_code=%s", + (product_code,) + ) + existing_bom_codes = {row['material_code'] for row in cursor.fetchall()} + + cursor.execute(""" + INSERT INTO products + (product_code, product_name, specification, brand, product_type, + category1, category2, category3, operator, developer, + cost_price, lead_time, supplier_code, supplier_name_match, + created_at, import_date, deleted_at) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,CURDATE(),NULL) + ON DUPLICATE KEY UPDATE + product_name=VALUES(product_name), + specification=IF(VALUES(specification)='', specification, VALUES(specification)), + brand=IF(VALUES(brand)='', brand, VALUES(brand)), + product_type=VALUES(product_type), + category1=IF(VALUES(category1)='', category1, VALUES(category1)), + category2=IF(VALUES(category2)='', category2, VALUES(category2)), + category3=IF(VALUES(category3)='', category3, VALUES(category3)), + operator=IF(VALUES(operator)='', operator, VALUES(operator)), + developer=IF(VALUES(developer)='', developer, VALUES(developer)), + cost_price=IF(VALUES(cost_price)>0, VALUES(cost_price), cost_price), + lead_time=IF(VALUES(lead_time)>0, VALUES(lead_time), lead_time), + supplier_code=IF(VALUES(supplier_code)='', supplier_code, VALUES(supplier_code)), + supplier_name_match=IF(VALUES(supplier_name_match)='', supplier_name_match, VALUES(supplier_name_match)), + deleted_at=NULL + """, ( + product_code, + product_name, + (product.get('capacity') or '').strip(), + (product.get('brand') or '').strip(), + (product.get('product_type') or '成品').strip(), + (product.get('category1') or '').strip(), + (product.get('category2') or '').strip(), + (product.get('category3') or '').strip(), + (product.get('operator') or '').strip(), + (product.get('product_dev') or '').strip(), + prod_cost_price, + prod_lead_time, + prod_supplier_code, + prod_supplier_name, + product.get('created_at'), + )) + + for material in materials: + material_code = material['material_code'] + material_name = material['material_name'] or existing_product_names.get(material_code) or material_code + cursor.execute(""" + INSERT INTO products + (product_code, product_name, product_type, cost_price, lead_time, + supplier_code, supplier_name_match, developer, import_date, deleted_at) + VALUES (%s,%s,'包材',%s,%s,%s,%s,%s,CURDATE(),NULL) + ON DUPLICATE KEY UPDATE + product_name=VALUES(product_name), + product_type='包材', + cost_price=IF(VALUES(cost_price)>0, VALUES(cost_price), cost_price), + lead_time=IF(VALUES(lead_time)>0, VALUES(lead_time), lead_time), + supplier_code=IF(VALUES(supplier_code)='', supplier_code, VALUES(supplier_code)), + supplier_name_match=IF(VALUES(supplier_name_match)='', supplier_name_match, VALUES(supplier_name_match)), + developer=IF(VALUES(developer)='', developer, VALUES(developer)), + import_date=CURDATE(), + deleted_at=NULL + """, ( + material_code, + material_name, + material['unit_price'], + material['lead_time'], + material['supplier_code'], + material['supplier_name'], + (product.get('package_dev') or '').strip(), + )) + cursor.execute(""" + INSERT INTO bom + (product_code, product_name, material_code, material_name, + supplier, quantity, material_cost_price) + VALUES (%s,%s,%s,%s,%s,%s,%s) + ON DUPLICATE KEY UPDATE + product_name=VALUES(product_name), + material_name=VALUES(material_name), + supplier=IF(VALUES(supplier)='', supplier, VALUES(supplier)), + quantity=VALUES(quantity), + material_cost_price=IF(VALUES(material_cost_price)>0, VALUES(material_cost_price), material_cost_price), + updated_at=CURRENT_TIMESTAMP + """, ( + product_code, + product_name, + material_code, + material_name, + material['supplier_name'], + material['quantity'], + material['unit_price'], + )) + + material_codes = {material['material_code'] for material in materials} + return { + 'product_created': 0 if product_code in existing_product_codes else 1, + 'product_updated': 1 if product_code in existing_product_codes else 0, + 'materials_total': len(materials), + 'materials_created': len(material_codes - existing_product_codes), + 'materials_updated': len(material_codes & existing_product_codes), + 'bom_total': len(materials), + 'bom_created': len(material_codes - existing_bom_codes), + 'bom_updated': len(material_codes & existing_bom_codes), + } + + +@products_bp.route('/api/new_products/list') +@login_required +def new_products_list(): + _ensure_new_product_freight_column() + if not operation_required('product_new', 'view'): + return jsonify({"code": 403, "msg": "权限不足,当前账号没有该操作权限"}) + """获取新品列表(最新在前),支持编码、名称和BOM供应商筛选""" + product_code = (request.args.get('product_code') or '').strip() + product_name = (request.args.get('product_name') or '').strip() + unified_search = (request.args.get('search') or '').strip() + supplier = (request.args.get('supplier') or '').strip() + where = [] + params = [] + if unified_search: + where.append("(product_code LIKE %s OR product_name LIKE %s)") + params.extend([f"%{unified_search}%", f"%{unified_search}%"]) + if product_code: + where.append("product_code LIKE %s") + params.append(f"%{product_code}%") + if product_name: + where.append("product_name LIKE %s") + params.append(f"%{product_name}%") + if supplier: + where.append("EXISTS (SELECT 1 FROM new_product_bom npb WHERE npb.product_id = new_products.id AND (npb.supplier LIKE %s OR npb.supplier_code LIKE %s))") + params.extend([f"%{supplier}%", f"%{supplier}%"]) + where_clause = " AND ".join(where) if where else "1=1" + try: + rows = query("SELECT id, " + ",".join(NEW_PRODUCT_FIELDS) + " FROM new_products WHERE " + where_clause + " ORDER BY id DESC", tuple(params)) + return jsonify({"code": 0, "data": rows, "total": len(rows)}) + except Exception as e: + return jsonify({"code": 500, "msg": str(e)}) + + +def _new_product_image_file(image_value, project_root): + """把新品图片字段中的静态资源地址解析为本地文件。""" + raw_value = str(image_value or '').strip() + if not raw_value: + return None + # 图片字段通常保存 /static/...,同时兼容历史记录中的相对路径、绝对路径和带域名地址。 + if raw_value.startswith(('http://', 'https://')): + return None + if raw_value.startswith('/static/'): + local_path = os.path.join(project_root, raw_value.lstrip('/')) + elif raw_value.startswith('static/'): + local_path = os.path.join(project_root, raw_value) + elif os.path.isabs(raw_value): + local_path = raw_value + else: + local_path = os.path.join(project_root, raw_value) + return local_path if os.path.isfile(local_path) else None + + +@products_bp.route('/api/new_products/export', methods=['POST']) +@login_required +def new_products_export(): + _ensure_new_product_freight_column() + """导出新品成品管理数据,字段由前端选择。""" + if not operation_required('product_new', 'view'): + return jsonify({"code": 403, "msg": "权限不足,当前账号没有该操作权限"}) + data = request.get_json(silent=True) or {} + fields = data.get('fields') or [] + code_filter = (data.get('product_code') or '').strip() + name_filter = (data.get('product_name') or '').strip() + unified_search = (data.get('search') or '').strip() + supplier_filter = (data.get('supplier') or '').strip() + field_map = { + 'category1': ('category1', '一级类目'), 'category2': ('category2', '二级类目'), + 'category3': ('category3', '三级类目'), 'product_type': ('product_type', '类别'), + 'brand': ('brand', '品牌'), 'product_code': ('product_code', '产品编码'), + 'product_name': ('product_name', '产品名称'), 'barcode': ('barcode', '条形码'), + 'capacity': ('capacity', '容量'), 'remark': ('remark', '成品备注'), + 'total_cost': ('total_cost', '产品总成本'), 'total_payment': ('total_payment', '产品总货款'), + 'effect_img': ('effect_img', '产品效果图(内外包)'), 'upgrade_img': ('upgrade_img', '升级前的'), + 'confirm_img': ('confirm_img', '确版内料图凭证'), 'quote_inner_img': ('quote_inner_img', '内料报价单'), + 'quote_pack_img': ('quote_pack_img', '包材报价单'), 'freight_included': ('freight_included', '是否含运'), 'product_dev': ('product_dev', '产品开发'), + 'package_dev': ('package_dev', '包材开发'), 'designer': ('designer', '设计师'), + 'operator': ('operator', '负责运营') + } + selected = [f for f in fields if f in field_map] + if not selected: + return jsonify({"code": 400, "msg": "请至少选择一个导出字段"}) + where = [] + params = [] + if unified_search: + where.append('(product_code LIKE %s OR product_name LIKE %s)'); params.extend([f'%{unified_search}%', f'%{unified_search}%']) + if code_filter: + where.append('product_code LIKE %s'); params.append(f'%{code_filter}%') + if name_filter: + where.append('product_name LIKE %s'); params.append(f'%{name_filter}%') + if supplier_filter: + where.append("EXISTS (SELECT 1 FROM new_product_bom npb WHERE npb.product_id=new_products.id AND (npb.supplier LIKE %s OR npb.supplier_code LIKE %s))") + params.extend([f'%{supplier_filter}%', f'%{supplier_filter}%']) + where_clause = ' AND '.join(where) if where else '1=1' + try: + rows = query( + 'SELECT ' + ','.join(f'{field_map[f][0]} AS `{f}`' for f in selected) + + f' FROM new_products WHERE {where_clause} ORDER BY id DESC', params) + import io + import openpyxl + from openpyxl.drawing.image import Image as ExcelImage + from openpyxl.styles import Font, PatternFill, Alignment, Border, Side + from openpyxl.utils import get_column_letter + wb = openpyxl.Workbook(); ws = wb.active; ws.title = '新品成品管理' + headers = [field_map[f][1] for f in selected] + ws.append(headers) + thin = Side(style='thin', color='D9D9D9') + for cell in ws[1]: + cell.font = Font(bold=True, color='FFFFFF'); cell.fill = PatternFill('solid', fgColor='1890FF') + cell.alignment = Alignment(horizontal='center'); cell.border = Border(left=thin, right=thin, top=thin, bottom=thin) + + image_fields = {'effect_img', 'upgrade_img', 'confirm_img', 'quote_inner_img', 'quote_pack_img'} + project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + for row_index, row in enumerate(rows, 2): + row_has_embedded_image = False + vals = [] + for f in selected: + value = row.get(f, '') + if value is None: value = '' + if f in ('total_cost', 'total_payment') and value != '': + try: value = f'{float(value):.3f}' + except (TypeError, ValueError): pass + # 图片字段改为 Excel 内嵌图片,不再把 /static/... 路径作为普通文本写入。 + vals.append('' if f in image_fields else value) + ws.append(vals) + for col_index, f in enumerate(selected, 1): + if f not in image_fields or not row.get(f): + continue + image_paths = [str(p).strip() for p in str(row[f]).split(',') if str(p).strip()] + for image_path in image_paths: + local_path = _new_product_image_file(image_path, project_root) + if not local_path: + # 原图不在测试环境时不输出静态路径,避免把不可用地址误当成图片内容。 + cell = ws.cell(row=row_index, column=col_index) + cell.value = '图片缺失' + continue + try: + image = ExcelImage(local_path) + max_width, max_height = 100, 72 + ratio = min(max_width / image.width, max_height / image.height, 1) + image.width = max(1, int(image.width * ratio)) + image.height = max(1, int(image.height * ratio)) + image.anchor = f'{get_column_letter(col_index)}{row_index}' + ws.add_image(image) + row_has_embedded_image = True + break + except Exception: + continue + if row_has_embedded_image: + ws.row_dimensions[row_index].height = 60 + for row in ws.iter_rows(): + for cell in row: + cell.border = Border(left=thin, right=thin, top=thin, bottom=thin) + for i, header in enumerate(headers, 1): + ws.column_dimensions[get_column_letter(i)].width = 15 if selected[i - 1] in image_fields else min(max(len(str(header)) * 1.8, 12), 32) + output = io.BytesIO(); wb.save(output); output.seek(0) + from flask import send_file + return send_file(output, as_attachment=True, download_name='新品成品管理.xlsx', + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') + except Exception as e: + return jsonify({"code": 500, "msg": str(e)}) + + +@products_bp.route('/api/new_products/create', methods=['POST']) +@login_required +def new_products_create(): + _ensure_new_product_freight_column() + if not operation_required('product_new', 'create'): + return jsonify({"code": 403, "msg": "权限不足,当前账号没有该操作权限"}) + """新增新品(全部字段)""" + data = request.get_json(silent=True) or {} + vals = {f: (data.get(f) or '').strip() for f in NEW_PRODUCT_FIELDS} + if not vals['product_name'] and not vals['product_code']: + return jsonify({"code": 400, "msg": "产品名称或产品编码至少填写一项"}) + try: + placeholders = ",".join(["%s"] * len(NEW_PRODUCT_FIELDS)) + query( + "INSERT INTO new_products (" + ",".join(NEW_PRODUCT_FIELDS) + ") VALUES (" + placeholders + ")", + tuple(vals[f] for f in NEW_PRODUCT_FIELDS) + ) + return jsonify({"code": 0, "msg": "新增成功"}) + except Exception as e: + return jsonify({"code": 500, "msg": str(e)}) + + +@products_bp.route('/api/new_products/update', methods=['POST']) +@login_required +def new_products_update(): + _ensure_new_product_freight_column() + if not operation_required('product_new', 'edit'): + return jsonify({"code": 403, "msg": "权限不足,当前账号没有该操作权限"}) + """编辑保存新品""" + data = request.get_json(silent=True) or {} + pid = data.get('id') + if not pid: + return jsonify({"code": 400, "msg": "缺少ID"}) + vals = {f: (data.get(f) or '').strip() for f in NEW_PRODUCT_FIELDS} + try: + sets = ",".join([f + "=%s" for f in NEW_PRODUCT_FIELDS]) + query( + "UPDATE new_products SET " + sets + " WHERE id=%s", + tuple(vals[f] for f in NEW_PRODUCT_FIELDS) + (pid,) + ) + return jsonify({"code": 0, "msg": "保存成功"}) + except Exception as e: + return jsonify({"code": 500, "msg": str(e)}) + + +@products_bp.route('/api/new_products/delete', methods=['POST']) +@login_required +def new_products_delete(): + if not operation_required('product_new', 'delete'): + return jsonify({"code": 403, "msg": "权限不足,当前账号没有该操作权限"}) + """删除新品""" + data = request.get_json(silent=True) or {} + pid = data.get('id') + if not pid: + return jsonify({"code": 400, "msg": "缺少ID"}) + try: + query("DELETE FROM new_products WHERE id=%s", (pid,)) + return jsonify({"code": 0, "msg": "删除成功"}) + except Exception as e: + return jsonify({"code": 500, "msg": str(e)}) + + +NEW_UPLOAD_FOLDER = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'static', 'uploads', 'new_products') +os.makedirs(NEW_UPLOAD_FOLDER, exist_ok=True) + + +@products_bp.route('/api/new_products/upload_image', methods=['POST']) +@login_required +def new_products_upload_image(): + if not operation_required('product_new', 'edit'): + return jsonify({"code": 403, "msg": "权限不足,当前账号没有该操作权限"}) + """上传新品图片(产品效果图/升级前内料图/确版内料图凭证)""" + file = request.files.get('file') + if not file or not file.filename: + return jsonify({"code": 400, "msg": "请选择文件"}) + ext = os.path.splitext(file.filename)[1].lower() + if ext not in ('.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp'): + return jsonify({"code": 400, "msg": "仅支持图片格式(jpg/png/gif/webp/bmp)"}) + safe_name = f"{uuid.uuid4().hex}{ext}" + file.save(os.path.join(NEW_UPLOAD_FOLDER, safe_name)) + return jsonify({"code": 0, "msg": "上传成功", "data": {"url": f"/static/uploads/new_products/{safe_name}"}}) + + +@products_bp.route('/api/new_products/sync', methods=['POST']) +@login_required +def new_products_sync(): + if not operation_required('product_new', 'sync'): + return jsonify({"code": 403, "msg": "权限不足,当前账号没有该操作权限"}) + """同步新品成品信息、包材信息和 BOM 到产品信息管理。""" + if not _check_product_edit_perm(): + return jsonify({"code": 403, "msg": "无产品信息编辑权限"}) + data = request.get_json(silent=True) or {} + product_id = data.get('product_id') + if not product_id: + return jsonify({"code": 400, "msg": "缺少新品ID"}) + try: + product_id = int(product_id) + except (TypeError, ValueError): + return jsonify({"code": 400, "msg": "新品ID格式错误"}) + + conn = get_db() + try: + conn.begin() + result = _sync_new_product_to_product_info(conn, product_id) + conn.commit() + add_operation_log( + '新品资料同步', + f"新品ID {product_id}:成品{result['product_created']}条新增/{result['product_updated']}条更新," + f"包材{result['materials_created']}条新增/{result['materials_updated']}条更新," + f"BOM{result['bom_created']}条新增/{result['bom_updated']}条更新" + ) + return jsonify({"code": 0, "msg": "同步成功", "data": result}) + except ValueError as error: + conn.rollback() + return jsonify({"code": 400, "msg": str(error)}) + except Exception as error: + conn.rollback() + return jsonify({"code": 500, "msg": f"同步失败:{error}"}) + finally: + conn.close() + + +@products_bp.route('/api/new_products/category_search') +@login_required +def new_products_category_search(): + if not operation_required('product_new', 'view'): + return jsonify({"code": 403, "msg": "权限不足,当前账号没有该操作权限"}) + """三级类目模糊搜索:根据三级类目匹配对应一级/二级类目""" + q = (request.args.get('q') or '').strip() + if not q: + return jsonify({"code": 0, "data": []}) + rows = query( + "SELECT DISTINCT category1, category2, category3 FROM product_categories WHERE category3 LIKE %s ORDER BY category3 LIMIT 20", + (f"%{q}%",) + ) + return jsonify({"code": 0, "data": rows}) + + +def _tax_included_value(value): + """新品BOM税点展示规则:税点非0=是,税点为0或空=否。""" + raw = str(value or '').strip().replace('%', '') + try: + return '是' if float(raw or 0) != 0 else '否' + except (TypeError, ValueError): + return '否' + + +@products_bp.route('/api/new_products/bom') +@login_required +def new_products_bom(): + if not operation_required('product_new', 'view'): + return jsonify({"code": 403, "msg": "权限不足,当前账号没有该操作权限"}) + """新品BOM清单:查独立的新品BOM表 new_product_bom(不复用现有物料清单 bom 表)""" + pid = (request.args.get('product_id') or '').strip() + if not pid: + return jsonify({"code": 400, "msg": "缺少新品ID"}) + try: + rows = query(""" + SELECT id, material_code, material_name, usage_quantity, supplier_code, supplier, lead_time, tax_rate, + unit_price, quantity, amount, payment_method, can_order, remark, + common_code, common_name, order_date + FROM new_product_bom WHERE product_id=%s ORDER BY sort_order, id + """, (int(pid),)) + data = [] + for r in rows: + data.append({ + 'id': r['id'], + 'material_code': r['material_code'] or '', + 'material_name': r['material_name'] or '', + 'usage_quantity': float(r['usage_quantity']) if r['usage_quantity'] is not None else 1.0, + 'supplier_code': r['supplier_code'] or '', + 'supplier': r['supplier'] or '', + 'lead_time': r['lead_time'] if r['lead_time'] is not None else '', + 'tax_rate': r['tax_rate'] or '', + 'tax_included': _tax_included_value(r['tax_rate']), + 'unit_price': float(r['unit_price'] or 0), + 'quantity': float(r['quantity'] or 0), + 'amount': float(r['amount'] or 0), + 'payment_method': r['payment_method'] or '', + 'can_order': r['can_order'] or '', + 'remark': r['remark'] or '', + 'common_code': r['common_code'] or '', + 'common_name': r['common_name'] or '', + 'order_date': str(r['order_date']) if r['order_date'] else '' + }) + return jsonify({"code": 0, "data": data}) + except Exception as e: + return jsonify({"code": 500, "msg": str(e)}) + + +@products_bp.route('/api/new_products/bom/save', methods=['POST']) +@login_required +def new_products_bom_save(): + if not operation_required('product_new', 'edit'): + return jsonify({"code": 403, "msg": "权限不足,当前账号没有该操作权限"}) + """保存新品BOM清单(全量替换该新品的物料行)""" + data = request.get_json(silent=True) or {} + pid = data.get('product_id') + rows = data.get('rows') or [] + if not pid: + return jsonify({"code": 400, "msg": "缺少新品ID"}) + try: + pid = int(pid) + # 过滤全空行(物料编码和包材明细都为空则跳过) + valid = [] + for r in rows: + if not (r.get('material_code') or '').strip() and not (r.get('material_name') or '').strip(): + continue + valid.append(r) + query("DELETE FROM new_product_bom WHERE product_id=%s", (pid,)) + total_cost = 0.0 + total_payment = 0.0 + for i, r in enumerate(valid): + unit_price = float(r.get('unit_price') or 0) + quantity = float(r.get('quantity') or 0) + raw_usage_quantity = str(r.get('usage_quantity') if r.get('usage_quantity') is not None else '').strip() + usage_quantity = float(raw_usage_quantity) if raw_usage_quantity else 1.0 + amount = round(unit_price * quantity, 2) + total_cost += unit_price + total_payment += amount + order_date = (r.get('order_date') or '').strip() or None + query(""" + INSERT INTO new_product_bom + (product_id, material_code, material_name, usage_quantity, supplier_code, supplier, lead_time, tax_rate, + unit_price, quantity, amount, payment_method, can_order, remark, + common_code, common_name, order_date, sort_order) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) + """, (pid, + (r.get('material_code') or '').strip(), + (r.get('material_name') or '').strip(), + usage_quantity, + (r.get('supplier_code') or '').strip(), + (r.get('supplier') or '').strip(), + int(r.get('lead_time') or 0), + (r.get('tax_rate') or '').strip(), + unit_price, quantity, amount, + (r.get('payment_method') or '').strip(), + (r.get('can_order') or '').strip(), + (r.get('remark') or '').strip(), + (r.get('common_code') or '').strip(), + (r.get('common_name') or '').strip(), + order_date, i)) + # 同步更新新品表:产品总成本=物料单价之和,产品总货款=物料货款之和(保留3位小数) + total_cost = round(total_cost, 3) + total_payment = round(total_payment, 3) + query("UPDATE new_products SET total_cost=%s, total_payment=%s WHERE id=%s", + (f"{total_cost:.3f}", f"{total_payment:.3f}", pid)) + # 绑定BOM后按品牌与内料供应商匹配采购计划(海外品不动,由人工维护) + matched_planner = resolve_new_product_planner(pid) + return jsonify({"code": 0, "msg": "保存成功", "data": {"count": len(valid), "total_cost": total_cost, "total_payment": total_payment, "procurement_planner": matched_planner or ''}}) + except Exception as e: + return jsonify({"code": 500, "msg": str(e)}) + + +def _new_packaging_where(product_filter='', material_filter='', supplier_filter=''): + """新品包材列表与导出共用同一套筛选条件,确保两处数据口径一致。""" + where = [] + params = [] + if product_filter: + where.append('(p.product_code LIKE %s OR p.product_name LIKE %s)') + params.extend([f'%{product_filter}%', f'%{product_filter}%']) + if material_filter: + where.append('(b.material_code LIKE %s OR b.material_name LIKE %s OR b.common_code LIKE %s OR b.common_name LIKE %s)') + params.extend([f'%{material_filter}%'] * 4) + if supplier_filter: + where.append('(b.supplier LIKE %s OR b.supplier_code LIKE %s)') + params.extend([f'%{supplier_filter}%', f'%{supplier_filter}%']) + return (' AND '.join(where) if where else '1=1'), params + + +@products_bp.route('/api/new_products/packaging') +@login_required +def new_products_packaging(): + """新品包材管理:直接读取新品BOM弹窗保存的全部物料字段。""" + if not operation_required('product_packaging', 'view'): + return jsonify({"code": 403, "msg": "权限不足,当前账号没有该操作权限"}) + + page = max(int(request.args.get('page', 1)), 1) + size = min(max(int(request.args.get('size', 50)), 1), 200) + offset = (page - 1) * size + product_filter = (request.args.get('product') or '').strip() + material_filter = (request.args.get('material') or '').strip() + supplier_filter = (request.args.get('supplier') or '').strip() + where_clause, params = _new_packaging_where(product_filter, material_filter, supplier_filter) + try: + total = query(f""" + SELECT COUNT(*) AS cnt + FROM new_product_bom b JOIN new_products p ON p.id = b.product_id + WHERE {where_clause} + """, params)[0]['cnt'] + rows = query(f""" + SELECT p.product_code AS finished_product_code, p.product_name AS finished_product_name, + b.material_code, b.material_name, b.usage_quantity, b.supplier_code, b.supplier, + b.lead_time, b.tax_rate, b.unit_price, b.quantity, b.amount, + b.payment_method, b.can_order, b.remark, b.common_code, b.common_name, b.order_date + FROM new_product_bom b JOIN new_products p ON p.id = b.product_id + WHERE {where_clause} + ORDER BY p.id DESC, b.sort_order ASC, b.id ASC + LIMIT {size} OFFSET {offset} + """, params) + for row in rows: + for key in ('unit_price', 'quantity', 'amount'): + if row.get(key) is not None: + row[key] = float(row[key]) + row['usage_quantity'] = float(row['usage_quantity']) if row['usage_quantity'] is not None else 1.0 + if row.get('order_date'): + row['order_date'] = row['order_date'].isoformat() if hasattr(row['order_date'], 'isoformat') else str(row['order_date'])[:10] + row['tax_included'] = _tax_included_value(row.get('tax_rate')) + _filter_field_rows('product_packaging', rows, session.get('user_id'), session.get('role'), + page_view=operation_required('product_packaging', 'view'), + page_edit=operation_required('product_packaging', 'edit')) + return jsonify({"code": 0, "data": {"rows": rows, "total": total, "page": page, "size": size}}) + except Exception as e: + return jsonify({"code": 500, "msg": str(e)}) + + +@products_bp.route('/api/new_products/packaging/export', methods=['POST']) +@login_required +def new_products_packaging_export(): + """导出新品包材管理数据,字段由前端选择。""" + if not operation_required('product_packaging', 'view'): + return jsonify({"code": 403, "msg": "权限不足,当前账号没有该操作权限"}) + data = request.get_json(silent=True) or {} + fields = data.get('fields') or [] + product_filter = (data.get('product') or '').strip() + material_filter = (data.get('material') or '').strip() + supplier_filter = (data.get('supplier') or '').strip() + where_clause, params = _new_packaging_where(product_filter, material_filter, supplier_filter) + field_map = { + 'finished_product_code': ('p.product_code', '成品编码'), 'finished_product_name': ('p.product_name', '成品名称'), + 'material_code': ('b.material_code', '物料编码'), 'material_name': ('b.material_name', '物料名称'), + 'usage_quantity': ('b.usage_quantity', '用量'), + 'supplier_code': ('b.supplier_code', '供应商编码'), 'supplier': ('b.supplier', '供应商'), + 'lead_time': ('b.lead_time', '货期/天'), 'tax_rate': ('b.tax_rate', '税点'), + # PyMySQL 会先对 SQL 做 %-格式化,SQL 字面量百分号必须写成 %%。 + 'tax_included': ("CASE WHEN CAST(REPLACE(TRIM(COALESCE(b.tax_rate, '')), '%%', '') AS DECIMAL(12,4)) <> 0 THEN '是' ELSE '否' END", '是否含税'), + 'unit_price': ('b.unit_price', '单价/元'), 'quantity': ('b.quantity', '数量'), 'amount': ('b.amount', '货款/元'), + 'payment_method': ('b.payment_method', '结款方式'), 'can_order': ('b.can_order', '是否可下单'), + 'remark': ('b.remark', '下单备注'), 'common_code': ('b.common_code', '通用物料编码'), + 'common_name': ('b.common_name', '通用物料名称'), 'order_date': ('b.order_date', '下单日期') + } + selected = [f for f in fields if f in field_map] + if not selected: + return jsonify({"code": 400, "msg": "请至少选择一个导出字段"}) + try: + rows = query( + 'SELECT ' + ','.join(f'{field_map[f][0]} AS `{f}`' for f in selected) + + f' FROM new_product_bom b JOIN new_products p ON p.id=b.product_id' + + f' WHERE {where_clause} ORDER BY p.id DESC,b.sort_order ASC,b.id ASC', + tuple(params) + ) + import io + import openpyxl + from openpyxl.styles import Font, PatternFill, Alignment, Border, Side + from openpyxl.utils import get_column_letter + wb = openpyxl.Workbook(); ws = wb.active; ws.title = '新品包材管理' + headers = [field_map[f][1] for f in selected]; ws.append(headers) + thin = Side(style='thin', color='D9D9D9') + for cell in ws[1]: + cell.font = Font(bold=True, color='FFFFFF'); cell.fill = PatternFill('solid', fgColor='1890FF') + cell.alignment = Alignment(horizontal='center'); cell.border = Border(left=thin, right=thin, top=thin, bottom=thin) + for row in rows: + vals = [] + for f in selected: + value = row.get(f, '') + if value is None: value = '' + if f in ('unit_price', 'quantity', 'amount') and value != '': + try: value = float(value) + except (TypeError, ValueError): pass + vals.append(value) + ws.append(vals) + for row in ws.iter_rows(): + for cell in row: + cell.border = Border(left=thin, right=thin, top=thin, bottom=thin) + for i, header in enumerate(headers, 1): + ws.column_dimensions[get_column_letter(i)].width = min(max(len(str(header)) * 1.8, 12), 32) + output = io.BytesIO(); wb.save(output); output.seek(0) + from flask import send_file + return send_file(output, as_attachment=True, download_name='新品包材管理.xlsx', + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') + except Exception as e: + return jsonify({"code": 500, "msg": str(e)}) + + +@products_bp.route('/api/new_products/brands') +@login_required +def new_products_brands(): + if not operation_required('product_new', 'view'): + return jsonify({"code": 403, "msg": "权限不足,当前账号没有该操作权限"}) + """品牌列表(现有品牌下拉)""" + rows = query("SELECT DISTINCT brand FROM products WHERE brand IS NOT NULL AND brand != '' ORDER BY brand") + return jsonify({"code": 0, "data": [r['brand'] for r in rows]}) + + +@products_bp.route('/api/product/remark/save', methods=['POST']) +@login_required +def product_remark_save(): + """保存产品批注(下单信息弹窗批注编辑框用,权限与下单信息编辑一致)""" + if not operation_required('purchase', 'edit'): + return jsonify({"code": 403, "msg": "权限不足,当前账号没有编辑下单信息权限"}) + data = request.get_json(silent=True) or {} + product_code = (data.get('product_code') or '').strip() + remark = (data.get('remark') or '').strip() + if not product_code: + return jsonify({"code": 400, "msg": "缺少货品编号"}) + query("UPDATE products SET remark=%s WHERE product_code=%s", (remark, product_code)) + return jsonify({"code": 0, "msg": "保存成功"}) + + +@products_bp.route('/api/bom_packaging/save', methods=['POST']) +@login_required +def bom_packaging_save(): + """保存下单前/后包材数据到 bom_packaging 表""" + if not (operation_required('planning', 'edit') or operation_required('purchase', 'edit')): + return jsonify({"code": 403, "msg": "权限不足,当前账号没有编辑计划预测或待下单信息权限"}) + data = request.get_json(silent=True) + if not data: + return jsonify({"code": 400, "msg": "缺少请求数据"}) + product_code = data.get('product_code', '').strip() + materials = data.get('materials', []) + if not product_code or not materials: + return jsonify({"code": 400, "msg": "缺少产品编号或物料数据"}) + try: + for m in materials: + mc = (m.get('material_code') or '').strip() + if not mc: + continue + pre_pack = int(m.get('pre_pack_qty') or 0) + # 下单后包材由确认下单时按滚动逻辑计算,前端传入值不允许覆盖。 + pkg_remark = str(m.get('pkg_remark') or '').strip() + query("""INSERT INTO bom_packaging + (product_code, material_code, pre_pack_qty, post_pack_qty, pkg_remark) + VALUES (%s,%s,%s,0,%s) + ON DUPLICATE KEY UPDATE pre_pack_qty=%s, pkg_remark=%s, updated_at=NOW()""", + (product_code, mc, pre_pack, pkg_remark, pre_pack, pkg_remark)) + return jsonify({"code": 0, "msg": "保存成功"}) + except Exception as e: + return jsonify({"code": 500, "msg": str(e)}) diff --git a/static/js/app.js b/static/js/app.js index f02578a..d395943 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -3364,7 +3364,7 @@ function previewNewImage(url) { // ─── 品牌信息管理页面 ─── function loadBrandManagementPage(container) { container.innerHTML = ` - +
@@ -3381,7 +3381,7 @@ function loadBrandManagementData(page) { if(res.code!==0){body.innerHTML=''+escHtml(res.msg||'加载失败')+'';return;} var rows=res.data||[], total=document.getElementById('brandManageTotal'); if(total) total.textContent=res.total||0; if(!rows.length){body.innerHTML='暂无品牌';document.getElementById('brandManagePagination').innerHTML='';return;} - body.innerHTML=rows.map(function(r,i){var cat=r.category||'';var cls=cat==='国内'?'brand-category-domestic':(cat==='海外'?'brand-category-overseas':'');var select='';return ''+((window._brandManagePage-1)*10+i+1)+''+escHtml(r.brand)+''+select+''+escHtml(r.updated_at||'-')+'';}).join(''); + body.innerHTML=rows.map(function(r,i){var cat=r.category||'';var cls=cat==='国内'?'brand-category-domestic':(cat==='海外'?'brand-category-overseas':'');var select='';var deleteButton=window._hasPagePerm('brand_management','delete')?' ':'';return ''+((window._brandManagePage-1)*10+i+1)+''+escHtml(r.brand)+''+select+''+escHtml(r.updated_at||'-')+''+deleteButton+'';}).join(''); renderBrandManagementPagination(res.total||0,10,window._brandManagePage); }).catch(function(){body.innerHTML='网络异常,请稍后重试';}); } @@ -3391,6 +3391,7 @@ function showBrandEdit(brand){fetch('/api/brand-management/list?search='+encodeU function showBrandModal(title,brand,category){var old=document.getElementById('brandManageModal');if(old)old.remove();var overlay=document.createElement('div');overlay.id='brandManageModal';overlay.className='modal-overlay';overlay.style.display='flex';overlay.onclick=function(e){if(e.target===overlay){}};overlay.innerHTML='';document.body.appendChild(overlay);document.getElementById('brandManageModalCategory').value=category||'';document.getElementById('brandManageName').focus();} function submitBrandModal(oldBrand){var name=(document.getElementById('brandManageName').value||'').trim(),category=document.getElementById('brandManageModalCategory').value||'';if(!name){alert('品牌名称不能为空');return;}var isEdit=!!oldBrand;fetch(isEdit?'/api/brand-management/update':'/api/brand-management/create',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(isEdit?{old_brand:oldBrand,new_brand:name,category:category}:{brand:name,category:category})}).then(function(r){return r.json();}).then(function(res){if(res.code!==0){alert(res.msg||'保存失败');return;}document.getElementById('brandManageModal').remove();loadBrandManagementData(window._brandManagePage||1);}).catch(function(){alert('网络异常,保存失败');});} function saveBrandCategory(brand,category){fetch('/api/brand-management/update',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({old_brand:brand,new_brand:brand,category:category})}).then(function(r){return r.json();}).then(function(res){if(res.code!==0){alert(res.msg||'保存失败');}else{loadBrandManagementData(window._brandManagePage||1);}}).catch(function(){alert('网络异常,保存失败');});} +function deleteBrand(brand){showCenteredConfirm('确认删除','确定删除品牌“'+brand+'”吗?删除后不可恢复;正在被产品使用的品牌将受到保护,无法删除。').then(function(ok){if(!ok)return;fetch('/api/brand-management/delete',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({brand:brand})}).then(function(r){return r.json();}).then(function(res){if(res.code!==0){showCenteredAlert('删除失败',res.msg||'删除失败');return;}showCenteredAlert('删除成功',res.msg||'品牌已删除');loadBrandManagementData(window._brandManagePage||1);}).catch(function(){showCenteredAlert('删除失败','网络异常,请稍后重试');});});} // ─── 类目管理页面 ─── function loadProductCategoryPage(container) { diff --git a/tests/test_brand_management_delete.py b/tests/test_brand_management_delete.py new file mode 100644 index 0000000..ddae6f5 --- /dev/null +++ b/tests/test_brand_management_delete.py @@ -0,0 +1,61 @@ +"""品牌信息管理删除功能回归测试。""" + +import unittest +from unittest import mock + +import test_wsgi # noqa: F401 +from app import app +from routes import products + + +class BrandManagementDeleteTest(unittest.TestCase): + def setUp(self): + app.config.update(TESTING=True) + self.client = app.test_client() + with self.client.session_transaction() as session: + session.update(user_id=1, username='brand-delete-test', role='super_admin') + + def test_delete_manual_brand(self): + with mock.patch.object(products, 'operation_required', return_value=True), \ + mock.patch.object(products, 'query', side_effect=[ + [{'cnt': 0}], + [{'brand': '误添加品牌', 'category': '国内'}], + 0, + ]) as query, \ + mock.patch.object(products, 'add_operation_log') as operation_log: + response = self.client.post( + '/api/brand-management/delete', json={'brand': ' 误添加品牌 '} + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.get_json()['code'], 0) + self.assertIn('DELETE FROM brand_information', query.call_args_list[2].args[0]) + operation_log.assert_called_once() + + def test_reject_delete_when_brand_is_used_by_products(self): + with mock.patch.object(products, 'operation_required', return_value=True), \ + mock.patch.object(products, 'query', return_value=[{'cnt': 3}]) as query, \ + mock.patch.object(products, 'add_operation_log') as operation_log: + response = self.client.post( + '/api/brand-management/delete', json={'brand': '在用品牌'} + ) + + body = response.get_json() + self.assertEqual(body['code'], 409) + self.assertEqual(body['product_count'], 3) + self.assertEqual(query.call_count, 1) + operation_log.assert_not_called() + + def test_reject_delete_without_edit_permission(self): + with mock.patch.object(products, 'operation_required', return_value=False), \ + mock.patch.object(products, 'query') as query: + response = self.client.post( + '/api/brand-management/delete', json={'brand': '无权限品牌'} + ) + + self.assertEqual(response.get_json()['code'], 403) + query.assert_not_called() + + +if __name__ == '__main__': + unittest.main()