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 = ` +
同步并查看钉钉部门与人员信息
| 物料编号 | '; html += '物料名称 | '; html += '下单数量 | '; html += '剩余包材 | '; + html += '下单进度 | '; html += '包材下单人 | '; html += '供应商 | '; html += '单价 | '; @@ -20959,6 +21219,7 @@ window.viewBackupDetail = function(bid, detailItem) { html += '' + escHtml(b.material_name || '-') + ' | '; html += '' + qty + ' | '; html += '' + remainingPack.toLocaleString('zh-CN', {maximumFractionDigits: 2}) + ' | '; + html += '' + escHtml(b.order_progress || '-') + ' | '; html += '' + escHtml(b.packaging_orderer || '-') + ' | '; html += '' + escHtml(supplierShortName) + ' | '; html += '' + unitPrice.toFixed(4) + ' | '; 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()
|---|