From e7b2dd64f195589ee3c2348e9759b9f51c56bf49 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 29 Oct 2026 16:33:09 +0800 Subject: [PATCH] =?UTF-8?q?=E5=85=A8=E9=83=A8=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 + app.py | 2 +- deploy/dy-dingtalk-org-sync.service | 17 ++ deploy/dy-dingtalk-org-sync.timer | 10 + routes/__init__.py | 5 +- routes/auth.py | 1 + routes/dashboard.py | 8 + routes/organization.py | 37 +++ routes/purchase.py | 48 +++- scripts/sync_dingtalk_organization.py | 29 ++ services/dingtalk_organization.py | 340 +++++++++++++++++++++++ static/js/app.js | 281 ++++++++++++++++++- templates/base.html | 6 +- tests/test_backup_remaining_packaging.py | 27 +- tests/test_navigation_order.py | 6 + 15 files changed, 801 insertions(+), 19 deletions(-) create mode 100644 deploy/dy-dingtalk-org-sync.service create mode 100644 deploy/dy-dingtalk-org-sync.timer create mode 100644 routes/organization.py create mode 100755 scripts/sync_dingtalk_organization.py create mode 100644 services/dingtalk_organization.py diff --git a/.gitignore b/.gitignore index 6cd654e..710e806 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,6 @@ tmp_cache/ # 合同生成输出文件 static/uploads/contract_output/ ai_config.py + +# 本机/云端运行密钥(不进入版本库) +.secrets/ diff --git a/app.py b/app.py index 775c52c..7f082a4 100644 --- a/app.py +++ b/app.py @@ -137,7 +137,7 @@ def spa_fallback(path): SPA_PATHS = { 'knowledge', 'planning', 'products', 'bom', 'purchase', 'orders', 'inventory', 'warehouse', 'suppliers', - 'users', 'data', 'config', 'logs', 'permissions', + 'users', 'organization', 'data', 'config', 'logs', 'permissions', 'login-records', 'contract-templates', 'personnel', 'collaboration', 'spot', 'ai-analysis', 'product-order', diff --git a/deploy/dy-dingtalk-org-sync.service b/deploy/dy-dingtalk-org-sync.service new file mode 100644 index 0000000..d0289fc --- /dev/null +++ b/deploy/dy-dingtalk-org-sync.service @@ -0,0 +1,17 @@ +[Unit] +Description=Synchronize DingTalk organization structure +After=network-online.target mysql.service +Wants=network-online.target + +[Service] +Type=oneshot +User=ubuntu +Group=ubuntu +WorkingDirectory=/opt/dy-dashboard +Environment=HOME=/home/ubuntu +Environment=TZ=Asia/Shanghai +Environment=PATH=/usr/local/bin:/usr/bin:/bin +ExecStart=/opt/dy-dashboard/venv/bin/python /opt/dy-dashboard/scripts/sync_dingtalk_organization.py +TimeoutStartSec=30min +UMask=0077 +NoNewPrivileges=true diff --git a/deploy/dy-dingtalk-org-sync.timer b/deploy/dy-dingtalk-org-sync.timer new file mode 100644 index 0000000..b8e7b8d --- /dev/null +++ b/deploy/dy-dingtalk-org-sync.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Synchronize DingTalk organization structure every day at 02:00 + +[Timer] +OnCalendar=*-*-* 02:00:00 +Persistent=true +Unit=dy-dingtalk-org-sync.service + +[Install] +WantedBy=timers.target diff --git a/routes/__init__.py b/routes/__init__.py index 5608718..97f7b31 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -14,6 +14,7 @@ from routes.contract import contract_bp from routes.ai import ai_bp from routes.sku_forecast import sku_forecast_bp from routes.spot import spot_bp +from routes.organization import organization_bp def register_blueprints(app): app.register_blueprint(auth_bp) @@ -30,7 +31,9 @@ def register_blueprints(app): app.register_blueprint(ai_bp) app.register_blueprint(sku_forecast_bp) app.register_blueprint(spot_bp) + app.register_blueprint(organization_bp) __all__ = ['register_blueprints', 'auth_bp', 'dashboard_bp', 'products_bp', 'inventory_bp', 'planning_bp', 'purchase_bp', 'warehouse_bp', - 'users_bp', 'data_bp', 'knowledge_bp', 'contract_bp', 'ai_bp', 'sku_forecast_bp', 'spot_bp'] + 'users_bp', 'data_bp', 'knowledge_bp', 'contract_bp', 'ai_bp', 'sku_forecast_bp', 'spot_bp', + 'organization_bp'] diff --git a/routes/auth.py b/routes/auth.py index af1f230..1959ec4 100644 --- a/routes/auth.py +++ b/routes/auth.py @@ -109,6 +109,7 @@ PAGE_FEATURES = { 'warehouse': [{'id':'view','name':'查看'},{'id':'create','name':'新增仓库'},{'id':'edit','name':'编辑仓库'},{'id':'delete','name':'删除仓库'}], 'suppliers': [{'id':'view','name':'查看'},{'id':'edit','name':'编辑'},{'id':'create','name':'新增供应商'}], 'knowledge': [{'id':'view','name':'查看'},{'id':'create','name':'新建文章'},{'id':'edit','name':'编辑文章'},{'id':'delete','name':'删除文章'},{'id':'import','name':'导入'},{'id':'export','name':'导出'},{'id':'lint','name':'运行检查'},{'id':'graph','name':'关系图谱'}], + 'organization': [{'id':'view','name':'查看'},{'id':'sync','name':'同步钉钉组织架构'}], 'users': [{'id':'view','name':'查看'},{'id':'create','name':'新增'},{'id':'edit','name':'编辑'},{'id':'delete','name':'删除'},{'id':'import','name':'导入'}], 'permissions': [{'id':'view','name':'查看'},{'id':'edit','name':'编辑'}], 'logs': [{'id':'view','name':'查看'},{'id':'export','name':'导出'},{'id':'verify','name':'完整性校验'},{'id':'manage','name':'日志治理'}], diff --git a/routes/dashboard.py b/routes/dashboard.py index 3f2e9fb..a3d0739 100644 --- a/routes/dashboard.py +++ b/routes/dashboard.py @@ -49,6 +49,14 @@ def settings_page(): return render_template('base.html', page='settings') +@dashboard_bp.route('/organization') +@login_required +def organization_page(): + if not role_required('organization'): + return redirect(url_for('auth.index')) + return render_template('base.html', page='organization') + + @dashboard_bp.route('/favicon.ico') def favicon(): from flask import redirect as _rd diff --git a/routes/organization.py b/routes/organization.py new file mode 100644 index 0000000..ed6881c --- /dev/null +++ b/routes/organization.py @@ -0,0 +1,37 @@ +"""钉钉组织架构页面 API。""" +from flask import Blueprint, jsonify + +from routes.auth import login_required, operation_required, role_required +from services.db import add_operation_log +from services.dingtalk_organization import organization_snapshot, sync_organization + + +organization_bp = Blueprint('organization', __name__) + + +@organization_bp.route('/api/organization/tree') +@login_required +def organization_tree(): + if not role_required('organization'): + return jsonify({'code': 403, 'msg': '无组织架构查看权限'}), 403 + try: + return jsonify({'code': 0, 'data': organization_snapshot()}) + except Exception as exc: + return jsonify({'code': 500, 'msg': str(exc)}), 500 + + +@organization_bp.route('/api/organization/sync', methods=['POST']) +@login_required +def organization_sync(): + if not operation_required('organization', 'sync'): + return jsonify({'code': 403, 'msg': '无组织架构同步权限'}), 403 + try: + result = sync_organization('manual') + add_operation_log( + '同步组织架构', + f"部门{result['department_count']}个,人员{result['user_count']}人,关联账号{result['matched_count']}人", + page='organization', + ) + return jsonify({'code': 0, 'msg': '组织架构同步成功', 'data': result}) + except Exception as exc: + return jsonify({'code': 500, 'msg': str(exc)}), 500 diff --git a/routes/purchase.py b/routes/purchase.py index e4264ef..7fc0de9 100644 --- a/routes/purchase.py +++ b/routes/purchase.py @@ -37,7 +37,7 @@ PLANNING_SALES_SNAPSHOT_FIELDS = ( def _build_planning_sales_snapshot(product_code, product_name='', product_type=''): - """按计划预测默认数据口径生成一次性销量快照。""" + """按计划预测“云仓数据”页签口径生成一次性销量快照。""" where = ['p.product_code=%s', 'p.deleted_at IS NULL'] params = [product_code] if product_name: @@ -69,6 +69,7 @@ def _build_planning_sales_snapshot(product_code, product_name='', product_type=' planning_row.get('product_name', ''), planning_row.get('brand', ''), planning_row.get('product_type')) planning_row['批注'] = planning_row.get('remark') or '' + extended_sales = compute_product_sales_extended_cached() planning_row = _enrich_row_with_planning_data( planning_row, compute_product_sales_cached(), @@ -79,9 +80,42 @@ def _build_planning_sales_snapshot(product_code, product_name='', product_type=' get_global_config(), cloud_inv_map=compute_cloud_inventory_cached(), ext_inv_map=compute_external_inventory_cached(), - extended_sales=compute_product_sales_extended_cached(), + extended_sales=extended_sales, ) - return {field: planning_row.get(field, '-') for field in PLANNING_SALES_SNAPSHOT_FIELDS} + # 与“计划预测 → 详情 → 销量信息 → 云仓数据”保持同一取数口径。 + cloud_sales = extended_sales.get('cloud', {}).get(product_code, {}) + cloud_inventory = planning_row.get('云仓库存', 0) + cloud_3 = cloud_sales.get('3天平均销量', 0) + cloud_7 = cloud_sales.get('7天平均销量', 0) + cloud_14 = cloud_sales.get('14天平均销量', 0) + cloud_forecast = planning_row.get('云仓日销量预测', 0) + try: + cloud_forecast_number = float(cloud_forecast) if cloud_forecast not in ('近期无销售', '', None) else 0 + except (TypeError, ValueError): + cloud_forecast_number = 0 + cloud_snapshot = { + '产品分级': planning_row.get('产品分级', ''), + '昨日销量': cloud_sales.get('昨日销量', 0), + '销售趋势': _determine_trend(cloud_3, cloud_7, cloud_14), + '库存': cloud_inventory, + '加工厂库存': planning_row.get('加工厂库存', 0), + '生产在途': planning_row.get('生产在途', 0), + '采购在途': planning_row.get('采购在途', 0), + '日销量预测': cloud_forecast, + '3天平均销量': cloud_3, + '7天平均销量': cloud_7, + '14天平均销量': cloud_14, + '库存可支撑天数': round(float(cloud_inventory or 0) / cloud_forecast_number, 1) + if cloud_forecast_number else 999, + '加工厂库存支撑天数': round(float(planning_row.get('加工厂库存', 0) or 0) / cloud_forecast_number, 1) + if cloud_forecast_number else 999, + '总支撑天数': planning_row.get('云仓支撑天数', 999), + '30天平均销量': cloud_sales.get('30天平均销量', 0), + '极值判断': '-', + } + return {'__scope': 'cloud', **{ + field: cloud_snapshot.get(field, '-') for field in PLANNING_SALES_SNAPSHOT_FIELDS + }} # 海外产品清单专属备货通知:名单是非敏感业务配置,Webhook/加签只从运行环境读取, @@ -557,13 +591,17 @@ def _normalize_backup_bom(items, enrich=True, source_product_code='', source_ord if source_product_code and source_ordered_at: orderer_rows = query( """SELECT GROUP_CONCAT(DISTINCT NULLIF(`下单人`, '') - ORDER BY id SEPARATOR '、') AS packaging_orderer + ORDER BY id SEPARATOR '、') AS packaging_orderer, + SUBSTRING_INDEX(GROUP_CONCAT(COALESCE(`进度`, '') + ORDER BY id DESC SEPARATOR '|||DY_PROGRESS|||'), '|||DY_PROGRESS|||', 1) AS order_progress FROM packaging_orders WHERE BINARY `编码`=BINARY %s AND BINARY `来源产品编码`=BINARY %s AND `下单时间`=%s""", (code, source_product_code, source_ordered_at) ) current['packaging_orderer'] = (orderer_rows[0].get('packaging_orderer') if orderer_rows else '') or '' + current['order_progress'] = (orderer_rows[0].get('order_progress') + if orderer_rows else '') or '' qty = int(current.get('actual_quantity') or 0) current.setdefault('price_audit_status', '未审核') current['amount'] = round(qty * float(current.get('unit_price') or 0), 2) @@ -920,6 +958,8 @@ def purchase_backup_detail(): row['sales_info'] = {} except (TypeError, ValueError, json.JSONDecodeError): row['sales_info'] = {} + # 旧历史快照没有口径标记,按原“默认数据”解释;新快照明确标记为云仓。 + row['sales_snapshot_scope'] = row['sales_info'].pop('__scope', 'default') if row['sales_info'] else '' row['sales_snapshot_missing'] = not bool(row['sales_info']) return jsonify({"code": 0, "data": row}) diff --git a/scripts/sync_dingtalk_organization.py b/scripts/sync_dingtalk_organization.py new file mode 100755 index 0000000..737bd54 --- /dev/null +++ b/scripts/sync_dingtalk_organization.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +"""每日同步钉钉组织架构。""" +import json +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +import config + +local_password = ROOT / '.local-test-db-password' +if local_password.exists(): + config.DB_CONFIG.update({ + 'user': 'dy_test', + 'password': local_password.read_text(encoding='ascii').strip(), + 'unix_socket': '/run/mysqld/mysqld.sock', + }) + +from services.dingtalk_organization import sync_organization + + +if __name__ == '__main__': + try: + print(json.dumps(sync_organization('scheduled'), ensure_ascii=False)) + except Exception as exc: + print(json.dumps({'status': 'failed', 'message': str(exc)}, ensure_ascii=False), file=sys.stderr) + raise SystemExit(1) diff --git a/services/dingtalk_organization.py b/services/dingtalk_organization.py new file mode 100644 index 0000000..b541bb6 --- /dev/null +++ b/services/dingtalk_organization.py @@ -0,0 +1,340 @@ +"""钉钉组织架构只读同步服务。""" +from __future__ import annotations + +import json +import os +from datetime import datetime +from pathlib import Path +from urllib.parse import urlencode +from urllib.request import Request, urlopen + +from services.db import get_db, query + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +SECRET_FILE = PROJECT_ROOT / '.secrets' / 'dingtalk_organization.json' + + +def load_dingtalk_config(): + config = { + 'app_key': os.environ.get('DINGTALK_ORG_APP_KEY', '').strip(), + 'app_secret': os.environ.get('DINGTALK_ORG_APP_SECRET', '').strip(), + 'corp_id': os.environ.get('DINGTALK_ORG_CORP_ID', '').strip(), + } + if SECRET_FILE.exists(): + with SECRET_FILE.open(encoding='utf-8') as handle: + saved = json.load(handle) + for key in config: + config[key] = config[key] or str(saved.get(key) or '').strip() + missing = [key for key, value in config.items() if not value] + if missing: + raise RuntimeError('钉钉组织架构配置不完整:' + ', '.join(missing)) + return config + + +def ensure_organization_schema(): + statements = [ + """CREATE TABLE IF NOT EXISTS dingtalk_org_departments ( + dept_id BIGINT NOT NULL PRIMARY KEY, + parent_id BIGINT NOT NULL DEFAULT 0, + name VARCHAR(200) NOT NULL DEFAULT '', + sort_order BIGINT NOT NULL DEFAULT 0, + active TINYINT NOT NULL DEFAULT 1, + synced_at DATETIME NOT NULL, + KEY idx_dingtalk_dept_parent (parent_id, active, sort_order) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""", + """CREATE TABLE IF NOT EXISTS dingtalk_org_users ( + userid VARCHAR(128) NOT NULL PRIMARY KEY, + unionid VARCHAR(128) NOT NULL DEFAULT '', + name VARCHAR(100) NOT NULL DEFAULT '', + mobile VARCHAR(32) NOT NULL DEFAULT '', + title VARCHAR(200) NOT NULL DEFAULT '', + job_number VARCHAR(100) NOT NULL DEFAULT '', + avatar VARCHAR(1000) NOT NULL DEFAULT '', + active TINYINT NOT NULL DEFAULT 1, + is_admin TINYINT NOT NULL DEFAULT 0, + is_boss TINYINT NOT NULL DEFAULT 0, + hired_date BIGINT NULL, + matched_dashboard_user_id INT NULL, + synced_at DATETIME NOT NULL, + KEY idx_dingtalk_user_name (name), + KEY idx_dingtalk_user_mobile (mobile), + KEY idx_dingtalk_user_match (matched_dashboard_user_id), + CONSTRAINT fk_dingtalk_user_match FOREIGN KEY (matched_dashboard_user_id) + REFERENCES dashboard_users(id) ON DELETE SET NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""", + """CREATE TABLE IF NOT EXISTS dingtalk_org_memberships ( + dept_id BIGINT NOT NULL, + userid VARCHAR(128) NOT NULL, + is_leader TINYINT NOT NULL DEFAULT 0, + dept_order BIGINT NOT NULL DEFAULT 0, + PRIMARY KEY (dept_id, userid), + KEY idx_dingtalk_membership_user (userid), + CONSTRAINT fk_dingtalk_membership_dept FOREIGN KEY (dept_id) + REFERENCES dingtalk_org_departments(dept_id) ON DELETE CASCADE, + CONSTRAINT fk_dingtalk_membership_user FOREIGN KEY (userid) + REFERENCES dingtalk_org_users(userid) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""", + """CREATE TABLE IF NOT EXISTS dingtalk_org_sync_runs ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + trigger_type VARCHAR(20) NOT NULL DEFAULT 'manual', + status VARCHAR(20) NOT NULL DEFAULT 'running', + department_count INT NOT NULL DEFAULT 0, + user_count INT NOT NULL DEFAULT 0, + matched_count INT NOT NULL DEFAULT 0, + message VARCHAR(1000) NOT NULL DEFAULT '', + started_at DATETIME NOT NULL, + finished_at DATETIME NULL, + KEY idx_dingtalk_sync_started (started_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""", + ] + for statement in statements: + query(statement) + + +def _request_json(url, payload=None): + body = None if payload is None else json.dumps(payload, ensure_ascii=False).encode('utf-8') + request = Request(url, data=body, headers={'Content-Type': 'application/json'}) + with urlopen(request, timeout=30) as response: + result = json.loads(response.read().decode('utf-8')) + if int(result.get('errcode') or 0) != 0: + raise RuntimeError(f"钉钉接口错误 {result.get('errcode')}:{result.get('errmsg') or '未知错误'}") + return result + + +def _access_token(config): + result = _request_json( + 'https://oapi.dingtalk.com/gettoken?' + urlencode({ + 'appkey': config['app_key'], + 'appsecret': config['app_secret'], + }) + ) + token = str(result.get('access_token') or '') + if not token: + raise RuntimeError('钉钉未返回 access_token') + return token + + +def _post(token, path, payload): + return _request_json(f'https://oapi.dingtalk.com/{path}?access_token={token}', payload) + + +def _fetch_departments(token): + departments = {} + root = _post(token, 'topapi/v2/department/get', {'dept_id': 1, 'language': 'zh_CN'}).get('result') or {} + departments[1] = { + 'dept_id': 1, + 'parent_id': 0, + 'name': str(root.get('name') or '企业组织架构'), + 'sort_order': int(root.get('order') or 0), + } + stack = [1] + while stack: + parent_id = stack.pop() + result = _post(token, 'topapi/v2/department/listsub', {'dept_id': parent_id, 'language': 'zh_CN'}) + for row in result.get('result') or []: + dept_id = int(row.get('dept_id') or 0) + if not dept_id or dept_id in departments: + continue + departments[dept_id] = { + 'dept_id': dept_id, + 'parent_id': int(row.get('parent_id') or parent_id), + 'name': str(row.get('name') or ''), + 'sort_order': int(row.get('order') or 0), + } + stack.append(dept_id) + return departments + + +def _fetch_userids(token, department_ids): + userids = set() + for dept_id in department_ids: + cursor = 0 + while True: + result = _post(token, 'topapi/user/listsimple', { + 'dept_id': dept_id, + 'cursor': cursor, + 'size': 100, + 'contain_access_limit': False, + 'language': 'zh_CN', + }).get('result') or {} + for row in result.get('list') or []: + userid = str(row.get('userid') or '').strip() + if userid: + userids.add(userid) + if not result.get('has_more'): + break + cursor = int(result.get('next_cursor') or 0) + return userids + + +def _fetch_users(token, userids): + users = {} + for userid in sorted(userids): + detail = _post(token, 'topapi/v2/user/get', {'userid': userid, 'language': 'zh_CN'}).get('result') or {} + detail['userid'] = userid + users[userid] = detail + return users + + +def _dashboard_user_matches(conn): + with conn.cursor() as cursor: + cursor.execute("SELECT id, real_name, phone FROM dashboard_users") + rows = cursor.fetchall() + by_phone = {} + by_name = {} + for row in rows: + phone = str(row.get('phone') or '').strip() + name = str(row.get('real_name') or '').strip() + if phone: + by_phone.setdefault(phone, []).append(int(row['id'])) + if name: + by_name.setdefault(name, []).append(int(row['id'])) + return by_phone, by_name + + +def _match_dashboard_user(detail, by_phone, by_name): + mobile = str(detail.get('mobile') or '').strip() + name = str(detail.get('name') or '').strip() + if mobile and len(by_phone.get(mobile, [])) == 1: + return by_phone[mobile][0] + if name and len(by_name.get(name, [])) == 1: + return by_name[name][0] + return None + + +def sync_organization(trigger_type='manual'): + """全量同步钉钉部门和人员;不自动修改 dashboard_users。""" + ensure_organization_schema() + lock_conn = get_db() + run_id = None + try: + with lock_conn.cursor() as cursor: + cursor.execute("SELECT GET_LOCK('dy_dingtalk_org_sync', 0) AS acquired") + if not int((cursor.fetchone() or {}).get('acquired') or 0): + raise RuntimeError('组织架构同步正在执行,请稍后再试') + started_at = datetime.now() + run_id = query( + "INSERT INTO dingtalk_org_sync_runs(trigger_type,status,started_at) VALUES (%s,'running',%s)", + (trigger_type, started_at), + ) + config = load_dingtalk_config() + token = _access_token(config) + departments = _fetch_departments(token) + userids = _fetch_userids(token, departments.keys()) + users = _fetch_users(token, userids) + synced_at = datetime.now() + + conn = get_db() + try: + by_phone, by_name = _dashboard_user_matches(conn) + matched_count = 0 + with conn.cursor() as cursor: + cursor.execute("UPDATE dingtalk_org_departments SET active=0") + cursor.execute("UPDATE dingtalk_org_users SET active=0") + for department in departments.values(): + cursor.execute( + """INSERT INTO dingtalk_org_departments + (dept_id,parent_id,name,sort_order,active,synced_at) + VALUES (%s,%s,%s,%s,1,%s) + ON DUPLICATE KEY UPDATE parent_id=VALUES(parent_id),name=VALUES(name), + sort_order=VALUES(sort_order),active=1,synced_at=VALUES(synced_at)""", + (department['dept_id'], department['parent_id'], department['name'], + department['sort_order'], synced_at), + ) + for userid, detail in users.items(): + matched_id = _match_dashboard_user(detail, by_phone, by_name) + if matched_id: + matched_count += 1 + cursor.execute( + """INSERT INTO dingtalk_org_users + (userid,unionid,name,mobile,title,job_number,avatar,active,is_admin,is_boss, + hired_date,matched_dashboard_user_id,synced_at) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) + ON DUPLICATE KEY UPDATE unionid=VALUES(unionid),name=VALUES(name), + mobile=VALUES(mobile),title=VALUES(title),job_number=VALUES(job_number), + avatar=VALUES(avatar),active=VALUES(active),is_admin=VALUES(is_admin), + is_boss=VALUES(is_boss),hired_date=VALUES(hired_date), + matched_dashboard_user_id=VALUES(matched_dashboard_user_id),synced_at=VALUES(synced_at)""", + (userid, str(detail.get('unionid') or ''), str(detail.get('name') or ''), + str(detail.get('mobile') or ''), str(detail.get('title') or ''), + str(detail.get('job_number') or ''), str(detail.get('avatar') or ''), + 1 if detail.get('active', True) else 0, 1 if detail.get('admin') else 0, + 1 if detail.get('boss') else 0, detail.get('hired_date'), matched_id, synced_at), + ) + cursor.execute("DELETE FROM dingtalk_org_memberships") + for userid, detail in users.items(): + leader_map = {int(x.get('dept_id') or 0): bool(x.get('leader')) + for x in detail.get('leader_in_dept') or []} + order_map = {int(x.get('dept_id') or 0): int(x.get('order') or 0) + for x in detail.get('dept_order_list') or []} + for dept_id in detail.get('dept_id_list') or []: + dept_id = int(dept_id or 0) + if dept_id not in departments: + continue + cursor.execute( + "INSERT INTO dingtalk_org_memberships(dept_id,userid,is_leader,dept_order) VALUES (%s,%s,%s,%s)", + (dept_id, userid, 1 if leader_map.get(dept_id) else 0, order_map.get(dept_id, 0)), + ) + cursor.execute( + """UPDATE dingtalk_org_sync_runs SET status='success',department_count=%s, + user_count=%s,matched_count=%s,message=%s,finished_at=%s WHERE id=%s""", + (len(departments), len(users), matched_count, '同步成功', synced_at, run_id), + ) + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + return { + 'run_id': run_id, + 'department_count': len(departments), + 'user_count': len(users), + 'matched_count': matched_count, + 'synced_at': synced_at.strftime('%Y-%m-%d %H:%M:%S'), + } + except Exception as exc: + if run_id: + try: + query( + "UPDATE dingtalk_org_sync_runs SET status='failed',message=%s,finished_at=NOW() WHERE id=%s", + (str(exc)[:1000], run_id), + ) + except Exception: + pass + raise + finally: + try: + with lock_conn.cursor() as cursor: + cursor.execute("SELECT RELEASE_LOCK('dy_dingtalk_org_sync')") + finally: + lock_conn.close() + + +def organization_snapshot(): + ensure_organization_schema() + departments = query( + """SELECT dept_id,parent_id,name,sort_order,synced_at + FROM dingtalk_org_departments WHERE active=1 ORDER BY parent_id,sort_order,dept_id""" + ) + members = query( + """SELECT m.dept_id,u.userid,u.name,u.mobile,u.title,u.job_number,u.avatar, + u.active,m.is_leader,m.dept_order,u.matched_dashboard_user_id, + du.username AS matched_username,du.real_name AS matched_real_name + FROM dingtalk_org_memberships m + JOIN dingtalk_org_users u ON u.userid=m.userid AND u.active=1 + LEFT JOIN dashboard_users du ON du.id=u.matched_dashboard_user_id + JOIN dingtalk_org_departments d ON d.dept_id=m.dept_id AND d.active=1 + ORDER BY m.dept_id,m.is_leader DESC,m.dept_order,u.name""" + ) + last_runs = query( + """SELECT id,trigger_type,status,department_count,user_count,matched_count,message, + started_at,finished_at FROM dingtalk_org_sync_runs ORDER BY id DESC LIMIT 1""" + ) + for collection in (departments, members, last_runs): + for row in collection: + for key, value in list(row.items()): + if isinstance(value, datetime): + row[key] = value.strftime('%Y-%m-%d %H:%M:%S') + return {'departments': departments, 'members': members, 'last_sync': last_runs[0] if last_runs else None} diff --git a/static/js/app.js b/static/js/app.js index df3b093..5ed475c 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -1,7 +1,7 @@ // 蝶雅采购智能体平台 - 前端逻辑 let charts = {}; // 页面缓存:切换菜单时不销毁页面状态 -var CACHE_VERSION = 94; +var CACHE_VERSION = 100; if (!window._pageCache) window._pageCache = {}; if (!window._pageDataCache) window._pageDataCache = {}; if (!window._pageReloaders) window._pageReloaders = {}; @@ -33,6 +33,7 @@ const PAGE_LOADERS = { 'data_outbound': loadDataPage, 'products': loadProductsPage, 'brand_management': loadBrandManagementPage, + 'organization': loadOrganizationPage, 'users': loadUsersPage, 'logs': loadLogsPage, 'login_records': loadLoginRecordsPage, @@ -6058,6 +6059,7 @@ var PAGE_MAP = { '/orders/overseas': 'orders_overseas', '/orders/packaging': 'orders_packaging', '/orders/finished': 'orders_finished', + '/organization': 'organization', '/users': 'users', '/login-records': 'login_records', '/logs': 'logs', @@ -6197,6 +6199,7 @@ window._hideNoPermNavItems = function() { {page:'personnel', sel:'a.nav-sub-item[href="/personnel"]', sub:true}, {page:'knowledge', sel:'a.nav-item[href="/knowledge"]', sub:false}, {page:'contract_templates', sel:'a.nav-sub-item[href="/contract-templates"]', sub:true}, + {page:'organization', sel:'a.nav-sub-item[href="/organization"]', sub:true}, {page:'users', sel:'a.nav-sub-item[href="/users"]', sub:true}, {page:'permissions', sel:'a.nav-sub-item[href="/permissions"]', sub:true}, {page:'login_records', sel:'a.nav-sub-item[href="/login-records"]', sub:true}, @@ -12049,6 +12052,258 @@ function savePersonnelGroup(payload) { }).catch(function(e) { alert('保存失败:' + e.message); }); } +// ─── 组织架构 ─── +function loadOrganizationPage(container) { + container.style.cssText = 'display:flex;flex-direction:column;flex:1;min-height:0;overflow:auto'; + container.innerHTML = ` + +
+
+
+ +
+
+ 全部人员 + + + +
+
+ + + +
姓名部门职位手机号工号系统账号关联状态
正在加载...
+
+
+
+
+
`; + var syncBtn = document.getElementById('organizationSyncBtn'); + if (syncBtn && window._hasPagePerm('organization', 'sync')) syncBtn.style.display = ''; + fetchOrganizationData(); +} + +window._organizationSelectedDept = 0; +window._organizationData = null; +window._organizationExpandedDepts = {'1': true}; +window._organizationMemberPage = 1; +window._organizationMemberPageSize = 20; + +function fetchOrganizationData() { + fetch('/api/organization/tree', {credentials:'same-origin'}) + .then(function(response) { return response.json(); }) + .then(function(res) { + if (res.code !== 0) throw new Error(res.msg || '组织架构加载失败'); + window._organizationData = res.data || {departments:[], members:[], last_sync:null}; + renderOrganizationSummary(); + renderOrganizationDepartments(); + renderOrganizationMembers(); + }) + .catch(function(error) { + var tree = document.getElementById('organizationDeptTree'); + var body = document.getElementById('organizationMemberBody'); + if (tree) tree.innerHTML = '
' + escHtml(error.message) + '
'; + if (body) body.innerHTML = '' + escHtml(error.message) + ''; + }); +} + +function renderOrganizationSummary() { + var data = window._organizationData || {}; + var departments = data.departments || [], members = data.members || [], last = data.last_sync || {}; + var uniqueUsers = {}; + members.forEach(function(member) { uniqueUsers[member.userid] = member; }); + var matched = Object.values(uniqueUsers).filter(function(member) { return !!member.matched_dashboard_user_id; }).length; + var cards = [ + ['部门数量', Math.max(0, departments.length - 1), '#1677ff'], + ['人员数量', Object.keys(uniqueUsers).length, '#52c41a'], + ['已关联系统账号', matched, '#722ed1'], + ['最近同步', last.finished_at || '尚未同步', last.status === 'failed' ? '#f5222d' : '#fa8c16'] + ]; + var summary = document.getElementById('organizationSummary'); + if (!summary) return; + summary.innerHTML = cards.map(function(card) { + return '
' + card[0] + '
' + escHtml(String(card[1])) + '
'; + }).join(''); +} + +function organizationDepartmentMap() { + var map = {}; + ((window._organizationData || {}).departments || []).forEach(function(dept) { map[String(dept.dept_id)] = dept; }); + return map; +} + +function renderOrganizationDepartments() { + var data = window._organizationData || {}, departments = data.departments || [], members = data.members || []; + var tree = document.getElementById('organizationDeptTree'); + if (!tree) return; + if (!departments.length) { + tree.innerHTML = '
暂无组织架构数据
请点击右上角同步按钮
'; + return; + } + var children = {}, directCount = {}; + departments.forEach(function(dept) { (children[String(dept.parent_id)] = children[String(dept.parent_id)] || []).push(dept); }); + members.forEach(function(member) { directCount[String(member.dept_id)] = (directCount[String(member.dept_id)] || 0) + 1; }); + Object.keys(children).forEach(function(key) { children[key].sort(function(a,b) { return (a.sort_order || 0) - (b.sort_order || 0) || Number(a.dept_id) - Number(b.dept_id); }); }); + function branch(parentId, depth) { + return (children[String(parentId)] || []).map(function(dept) { + var selected = Number(window._organizationSelectedDept) === Number(dept.dept_id); + var deptKey = String(dept.dept_id), hasChildren = !!(children[deptKey] && children[deptKey].length); + var expanded = !!window._organizationExpandedDepts[deptKey]; + var toggle = hasChildren + ? '' + (expanded ? '▾' : '▸') + '' + : '●'; + var childHtml = hasChildren && expanded ? branch(dept.dept_id, depth + 1) : ''; + return '
' + toggle + '' + escHtml(dept.name) + '' + (directCount[deptKey] || 0) + '
' + childHtml; + }).join(''); + } + var root = departments.find(function(dept) { return Number(dept.dept_id) === 1; }); + var rootExpanded = window._organizationExpandedDepts['1'] !== false; + tree.innerHTML = '
' + (rootExpanded ? '▾' : '▸') + '' + escHtml(root ? root.name : '全部部门') + '
' + (rootExpanded ? branch(1, 1) : ''); +} + +function toggleOrganizationDepartment(deptId) { + var key = String(Number(deptId)); + window._organizationExpandedDepts[key] = !window._organizationExpandedDepts[key]; + renderOrganizationDepartments(); +} + +function expandAllOrganizationDepartments() { + (((window._organizationData || {}).departments) || []).forEach(function(dept) { + window._organizationExpandedDepts[String(dept.dept_id)] = true; + }); + renderOrganizationDepartments(); +} + +function collapseAllOrganizationDepartments() { + window._organizationExpandedDepts = {'1': false}; + renderOrganizationDepartments(); +} + +function organizationDescendantIds(deptId) { + var departments = ((window._organizationData || {}).departments || []), result = {}, queue = [Number(deptId)]; + while (queue.length) { + var current = queue.shift(); result[current] = true; + departments.forEach(function(dept) { if (Number(dept.parent_id) === current && !result[Number(dept.dept_id)]) queue.push(Number(dept.dept_id)); }); + } + return result; +} + +function selectOrganizationDepartment(deptId) { + window._organizationSelectedDept = Number(deptId) || 0; + window._organizationMemberPage = 1; + renderOrganizationDepartments(); + renderOrganizationMembers(); +} + +function renderOrganizationMembers() { + var data = window._organizationData || {}, members = data.members || [], deptMap = organizationDepartmentMap(); + var selected = Number(window._organizationSelectedDept) || 0; + var allowed = selected ? organizationDescendantIds(selected) : null; + var keyword = (document.getElementById('organizationSearch')?.value || '').trim().toLowerCase(); + var users = {}; + members.forEach(function(member) { + if (allowed && !allowed[Number(member.dept_id)]) return; + var item = users[member.userid] || Object.assign({}, member, {department_names:[]}); + var dept = deptMap[String(member.dept_id)]; + if (dept && item.department_names.indexOf(dept.name) < 0) item.department_names.push(dept.name); + users[member.userid] = item; + }); + var rows = Object.values(users).filter(function(member) { + if (!keyword) return true; + return [member.name, member.mobile, member.title, member.job_number, (member.department_names || []).join(' '), member.matched_real_name, member.matched_username].join(' ').toLowerCase().indexOf(keyword) >= 0; + }).sort(function(a,b) { return String(a.name || '').localeCompare(String(b.name || ''), 'zh-CN'); }); + var pageSize = window._organizationMemberPageSize || 20; + var totalPages = Math.max(1, Math.ceil(rows.length / pageSize)); + var currentPage = Math.min(Math.max(1, Number(window._organizationMemberPage) || 1), totalPages); + window._organizationMemberPage = currentPage; + var pageRows = rows.slice((currentPage - 1) * pageSize, currentPage * pageSize); + var title = document.getElementById('organizationMemberTitle'); + if (title) title.textContent = (selected && deptMap[String(selected)] ? deptMap[String(selected)].name : '全部人员') + '(' + rows.length + '人)'; + var body = document.getElementById('organizationMemberBody'); + if (!body) return; + if (!rows.length) { + body.innerHTML = '暂无匹配人员'; + renderOrganizationPagination(0, 1, 1); + return; + } + body.innerHTML = pageRows.map(function(member) { + var matched = member.matched_dashboard_user_id ? escHtml((member.matched_real_name || member.matched_username || '') + (member.matched_username ? '(' + member.matched_username + ')' : '')) : '未关联'; + return '' + escHtml(member.name || '-') + '' + escHtml((member.department_names || []).join(' / ') || '-') + '' + escHtml(member.title || '-') + '' + escHtml(member.mobile || '-') + '' + escHtml(member.job_number || '-') + '' + matched + '在职'; + }).join(''); + renderOrganizationPagination(rows.length, currentPage, totalPages); +} + +function renderOrganizationPagination(total, currentPage, totalPages) { + var container = document.getElementById('organizationPagination'); + if (!container) return; + if (!total) { + container.innerHTML = ''; + return; + } + var start = (currentPage - 1) * 20 + 1; + var end = Math.min(total, currentPage * 20); + var buttons = ''; + var first = Math.max(1, currentPage - 2), last = Math.min(totalPages, first + 4); + first = Math.max(1, last - 4); + for (var page = first; page <= last; page++) { + buttons += ''; + } + container.innerHTML = '
' + + '共 ' + total + ' 人,当前 ' + start + '-' + end + '' + + '' + + buttons + + '' + + '第 ' + currentPage + '/' + totalPages + ' 页' + + '' + + '
'; +} + +function goOrganizationMemberPage(page) { + window._organizationMemberPage = Math.max(1, Number(page) || 1); + renderOrganizationMembers(); +} + +function jumpOrganizationMemberPage() { + var input = document.getElementById('organizationPageJump'); + goOrganizationMemberPage(input ? input.value : 1); +} + +function searchOrganizationMembers() { + window._organizationMemberPage = 1; + renderOrganizationMembers(); +} + +function resetOrganizationSearch() { + var input = document.getElementById('organizationSearch'); + if (input) input.value = ''; + window._organizationMemberPage = 1; + renderOrganizationMembers(); +} + +function syncDingtalkOrganization() { + var button = document.getElementById('organizationSyncBtn'); + if (button) { button.disabled = true; button.textContent = '同步中...'; } + fetch('/api/organization/sync', {method:'POST', credentials:'same-origin', headers:{'Content-Type':'application/json'}, body:'{}'}) + .then(function(response) { return response.json(); }) + .then(function(res) { + if (res.code !== 0) throw new Error(res.msg || '同步失败'); + alert('同步成功:部门 ' + res.data.department_count + ' 个,人员 ' + res.data.user_count + ' 人,已关联系统账号 ' + res.data.matched_count + ' 人'); + fetchOrganizationData(); + }) + .catch(function(error) { alert('同步失败:' + error.message); }) + .finally(function() { if (button) { button.disabled = false; button.textContent = '同步钉钉组织架构'; } }); +} + // ─── 用户管理 ─── function downloadUserTemplate() { fetch('/api/users/template', {credentials: 'same-origin'}) @@ -14140,6 +14395,7 @@ window._permPageFields = { // 知识库 'knowledge': ['标题','主题分类','创建人','创建时间','更新时间','状态'], // 系统设置 + 'organization': [], 'users': ['用户名','姓名','角色','岗位','用户级别','手机号','邮箱','状态','最后登录'], 'permissions': ['用户名','姓名','角色','岗位'], 'login_records': ['用户名','登录时间','IP地址'], @@ -20858,6 +21114,7 @@ window.viewBackupDetail = function(bid, detailItem) { ['货品编号', item.product_code], ['货品名称', item.product_name], ['供应商名称', item.supplier_name], + ['货期', (item.lead_time === null || item.lead_time === undefined || item.lead_time === '') ? '-' : item.lead_time + '天'], ['采购计划', item.procurement_planner], ['采购跟单', item.procurement_follower], ['成品下单人', item.product_orderer] @@ -20894,15 +21151,17 @@ window.viewBackupDetail = function(bid, detailItem) { } html += ''; - // ─── 板块2:销量信息(与计划预测详情默认数据完全同源)─── + // ─── 板块2:销量信息(新订单与计划预测详情云仓数据完全同源)─── var salesInfo = item.sales_info || {}; + var salesSnapshotScope = item.sales_snapshot_scope || 'default'; + var salesLabelPrefix = salesSnapshotScope === 'cloud' ? '云仓' : ''; var salesInfoFields = [ - ['产品分级', '产品分级'], ['昨日销量', '昨日销量'], ['销售趋势', '销售趋势'], - ['库存', '库存'], ['加工厂库存', '加工厂库存'], ['生产在途', '生产在途'], ['采购在途', '采购在途'], - ['日销量预测', '日销量预测'], ['3天平均销量', '3天平均销量'], ['7天平均销量', '7天平均销量'], - ['14天平均销量', '14天平均销量'], ['库存可支撑天数', '库存可支撑天数'], - ['加工厂库存支撑天数', '加工厂库存支撑天数'], ['总支撑天数', '总支撑天数'], - ['30天平均销量', '30天平均销量'], ['极值判断', '极值判断'] + ['产品分级', salesLabelPrefix + '产品分级'], ['昨日销量', salesLabelPrefix + '昨日销量'], ['销售趋势', salesLabelPrefix + '销售趋势'], + ['库存', salesLabelPrefix + '库存'], ['加工厂库存', salesLabelPrefix + '加工厂库存'], ['生产在途', salesLabelPrefix + '生产在途'], ['采购在途', salesLabelPrefix + '采购在途'], + ['日销量预测', salesLabelPrefix + '日销量预测'], ['3天平均销量', salesLabelPrefix + '3天平均销量'], ['7天平均销量', salesLabelPrefix + '7天平均销量'], + ['14天平均销量', salesLabelPrefix + '14天平均销量'], ['库存可支撑天数', salesLabelPrefix + '库存可支撑天数'], + ['加工厂库存支撑天数', salesLabelPrefix + '加工厂库存支撑天数'], ['总支撑天数', salesLabelPrefix + '总支撑天数'], + ['30天平均销量', salesLabelPrefix + '30天平均销量'], ['极值判断', salesLabelPrefix + '极值判断'] ]; html += '
'; html += '
' + @@ -20911,7 +21170,7 @@ window.viewBackupDetail = function(bid, detailItem) { '销量信息
'; if (Object.keys(salesInfo).length) { html += '
'; - html += '
计划预测点击下单时的默认销量数据快照' + (item.sales_snapshot_at ? ',快照时间:' + escHtml(String(item.sales_snapshot_at).slice(0, 19)) : '') + '。
'; + html += '
计划预测点击下单时的' + (salesSnapshotScope === 'cloud' ? '云仓数据' : '默认数据(历史)') + '快照' + (item.sales_snapshot_at ? ',快照时间:' + escHtml(String(item.sales_snapshot_at).slice(0, 19)) : '') + '。
'; html += '
'; salesInfoFields.forEach(function(field) { var value = salesInfo[field[0]]; @@ -20931,12 +21190,13 @@ window.viewBackupDetail = function(bid, detailItem) { html += '

📦 包材信息

'; if (bomList.length > 0) { html += '
'; - html += ''; + html += '
'; html += ''; html += ''; html += ''; html += ''; html += ''; + html += ''; html += ''; html += ''; html += ''; @@ -20959,6 +21219,7 @@ window.viewBackupDetail = function(bid, detailItem) { html += ''; html += ''; html += ''; + html += ''; html += ''; html += ''; html += ''; diff --git a/templates/base.html b/templates/base.html index 56db1ce..aa65c0b 100644 --- a/templates/base.html +++ b/templates/base.html @@ -393,6 +393,10 @@ ▾', frontend) self.assertIn('class="pd-fields pd-fields-4col"', frontend) self.assertIn("formatPopupFieldValue(field[0], value, salesInfo)", frontend) - self.assertIn("['库存可支撑天数', '库存可支撑天数']", frontend) + self.assertIn("['库存可支撑天数', salesLabelPrefix + '库存可支撑天数']", frontend) self.assertIn('历史订单未保存销量快照', frontend) diff --git a/tests/test_navigation_order.py b/tests/test_navigation_order.py index ed13033..b57a7ef 100644 --- a/tests/test_navigation_order.py +++ b/tests/test_navigation_order.py @@ -69,6 +69,12 @@ class NavigationOrderTest(unittest.TestCase): self.assertIn('.nav-order-11 { order: 11; }', html) self.assertNotIn('测试问题', html) + def test_system_settings_contains_organization_page(self): + html = BASE_TEMPLATE.read_text(encoding='utf-8') + self.assertIn('href="/organization"', html) + self.assertIn('data-page="organization"', html) + self.assertIn('组织架构', html) + if __name__ == '__main__': unittest.main()
物料编号物料名称下单数量剩余包材下单进度包材下单人供应商单价' + escHtml(b.material_name || '-') + '' + qty + '' + remainingPack.toLocaleString('zh-CN', {maximumFractionDigits: 2}) + '' + escHtml(b.order_progress || '-') + '' + escHtml(b.packaging_orderer || '-') + '' + escHtml(supplierShortName) + '' + unitPrice.toFixed(4) + '