全部优化
This commit is contained in:
parent
1eb96ece68
commit
e7b2dd64f1
3
.gitignore
vendored
3
.gitignore
vendored
@ -26,3 +26,6 @@ tmp_cache/
|
||||
# 合同生成输出文件
|
||||
static/uploads/contract_output/
|
||||
ai_config.py
|
||||
|
||||
# 本机/云端运行密钥(不进入版本库)
|
||||
.secrets/
|
||||
|
||||
2
app.py
2
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',
|
||||
|
||||
17
deploy/dy-dingtalk-org-sync.service
Normal file
17
deploy/dy-dingtalk-org-sync.service
Normal file
@ -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
|
||||
10
deploy/dy-dingtalk-org-sync.timer
Normal file
10
deploy/dy-dingtalk-org-sync.timer
Normal file
@ -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
|
||||
@ -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']
|
||||
|
||||
@ -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':'日志治理'}],
|
||||
|
||||
@ -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
|
||||
|
||||
37
routes/organization.py
Normal file
37
routes/organization.py
Normal file
@ -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
|
||||
@ -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})
|
||||
|
||||
|
||||
29
scripts/sync_dingtalk_organization.py
Executable file
29
scripts/sync_dingtalk_organization.py
Executable file
@ -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)
|
||||
340
services/dingtalk_organization.py
Normal file
340
services/dingtalk_organization.py
Normal file
@ -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}
|
||||
281
static/js/app.js
281
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 = `
|
||||
<div class="page-header" style="display:flex;align-items:flex-start;justify-content:space-between;gap:16px">
|
||||
<div><h1>组织架构</h1><p class="page-desc">同步并查看钉钉部门与人员信息</p></div>
|
||||
<button class="btn btn-primary" id="organizationSyncBtn" onclick="syncDingtalkOrganization()" style="display:none">同步钉钉组织架构</button>
|
||||
</div>
|
||||
<div id="organizationSummary" style="display:grid;grid-template-columns:repeat(4,minmax(150px,1fr));gap:12px;margin-bottom:14px"></div>
|
||||
<div class="card" style="flex:none;min-height:0;overflow:hidden">
|
||||
<div class="card-body" style="height:auto;min-height:0;padding:0;display:grid;grid-template-columns:300px minmax(0,1fr);align-items:start">
|
||||
<aside style="border-right:1px solid var(--border);height:calc(100vh - 245px);min-height:520px;max-height:760px;overflow:auto;padding:14px 12px;box-sizing:border-box">
|
||||
<div style="display:flex;align-items:center;gap:6px;margin:0 6px 10px">
|
||||
<div style="font-size:14px;font-weight:600">部门架构</div>
|
||||
<button class="btn btn-sm" style="margin-left:auto;padding:2px 7px;font-size:11px" onclick="expandAllOrganizationDepartments()">全部展开</button>
|
||||
<button class="btn btn-sm" style="padding:2px 7px;font-size:11px" onclick="collapseAllOrganizationDepartments()">全部收起</button>
|
||||
</div>
|
||||
<div id="organizationDeptTree"><div class="page-loading"><div class="loading-spinner"></div></div></div>
|
||||
</aside>
|
||||
<section style="min-width:0;height:clamp(620px,calc(100vh - 225px),820px);display:flex;flex-direction:column;align-self:start">
|
||||
<div style="padding:12px 16px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;flex-wrap:wrap">
|
||||
<strong id="organizationMemberTitle">全部人员</strong>
|
||||
<input id="organizationSearch" class="form-input" style="width:240px;margin-left:auto" placeholder="姓名/手机号/职位/工号" autocomplete="off" onkeydown="if(event.key==='Enter')searchOrganizationMembers()">
|
||||
<button class="btn btn-primary" onclick="searchOrganizationMembers()">搜索</button>
|
||||
<button class="btn" onclick="resetOrganizationSearch()">重置</button>
|
||||
</div>
|
||||
<div style="flex:1;min-height:0;overflow:auto">
|
||||
<table class="data-table" style="width:100%;min-width:820px">
|
||||
<thead style="position:sticky;top:0;z-index:2"><tr><th>姓名</th><th>部门</th><th>职位</th><th>手机号</th><th>工号</th><th>系统账号关联</th><th>状态</th></tr></thead>
|
||||
<tbody id="organizationMemberBody"><tr><td colspan="7" style="text-align:center;padding:50px;color:var(--text-light)">正在加载...</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="organizationPagination" style="padding:10px 16px;border-top:1px solid var(--border);text-align:center"></div>
|
||||
</section>
|
||||
</div>
|
||||
</div>`;
|
||||
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 = '<div style="padding:25px 8px;text-align:center;color:#f5222d">' + escHtml(error.message) + '</div>';
|
||||
if (body) body.innerHTML = '<tr><td colspan="7" style="text-align:center;padding:50px;color:#f5222d">' + escHtml(error.message) + '</td></tr>';
|
||||
});
|
||||
}
|
||||
|
||||
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 '<div class="card" style="margin:0;border-top:3px solid ' + card[2] + '"><div class="card-body" style="padding:14px 16px"><div style="font-size:12px;color:var(--text-light);margin-bottom:7px">' + card[0] + '</div><div style="font-size:' + (card[0] === '最近同步' ? '15px' : '24px') + ';font-weight:700;color:#333">' + escHtml(String(card[1])) + '</div></div></div>';
|
||||
}).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 = '<div style="padding:35px 8px;text-align:center;color:var(--text-light)">暂无组织架构数据<br><span style="font-size:12px">请点击右上角同步按钮</span></div>';
|
||||
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
|
||||
? '<span onclick="event.stopPropagation();toggleOrganizationDepartment(' + Number(dept.dept_id) + ')" title="' + (expanded ? '收起' : '展开') + '" style="width:18px;text-align:center;cursor:pointer;font-size:14px;user-select:none">' + (expanded ? '▾' : '▸') + '</span>'
|
||||
: '<span style="width:18px;text-align:center;color:#bbb;font-size:10px">●</span>';
|
||||
var childHtml = hasChildren && expanded ? branch(dept.dept_id, depth + 1) : '';
|
||||
return '<div class="organization-dept-node' + (selected ? ' selected' : '') + '" onclick="selectOrganizationDepartment(' + Number(dept.dept_id) + ')" style="padding:8px 8px 8px ' + (8 + depth * 18) + 'px;border-radius:6px;cursor:pointer;display:flex;align-items:center;gap:4px;margin:2px 0;background:' + (selected ? '#e6f4ff' : 'transparent') + ';color:' + (selected ? '#1677ff' : '#333') + '">' + toggle + '<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + escHtml(dept.name) + '</span><span style="font-size:11px;color:#999">' + (directCount[deptKey] || 0) + '</span></div>' + childHtml;
|
||||
}).join('');
|
||||
}
|
||||
var root = departments.find(function(dept) { return Number(dept.dept_id) === 1; });
|
||||
var rootExpanded = window._organizationExpandedDepts['1'] !== false;
|
||||
tree.innerHTML = '<div class="organization-dept-node" onclick="selectOrganizationDepartment(0)" style="padding:8px;border-radius:6px;cursor:pointer;display:flex;gap:4px;background:' + (Number(window._organizationSelectedDept) === 0 ? '#e6f4ff' : 'transparent') + ';color:' + (Number(window._organizationSelectedDept) === 0 ? '#1677ff' : '#333') + '"><span onclick="event.stopPropagation();toggleOrganizationDepartment(1)" title="' + (rootExpanded ? '收起' : '展开') + '" style="width:18px;text-align:center;cursor:pointer;user-select:none">' + (rootExpanded ? '▾' : '▸') + '</span><strong style="flex:1">' + escHtml(root ? root.name : '全部部门') + '</strong></div>' + (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 = '<tr><td colspan="7" style="text-align:center;padding:50px;color:var(--text-light)">暂无匹配人员</td></tr>';
|
||||
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 + ')' : '')) : '<span style="color:#fa8c16">未关联</span>';
|
||||
return '<tr><td style="font-weight:600">' + escHtml(member.name || '-') + '</td><td>' + escHtml((member.department_names || []).join(' / ') || '-') + '</td><td>' + escHtml(member.title || '-') + '</td><td>' + escHtml(member.mobile || '-') + '</td><td>' + escHtml(member.job_number || '-') + '</td><td>' + matched + '</td><td><span class="tag tag-green">在职</span></td></tr>';
|
||||
}).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 += '<button class="btn btn-sm' + (page === currentPage ? ' btn-primary' : '') + '" style="min-width:32px" onclick="goOrganizationMemberPage(' + page + ')">' + page + '</button>';
|
||||
}
|
||||
container.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;gap:6px;flex-wrap:wrap">' +
|
||||
'<span style="font-size:12px;color:var(--text-light);margin-right:8px">共 ' + total + ' 人,当前 ' + start + '-' + end + '</span>' +
|
||||
'<button class="btn btn-sm" onclick="goOrganizationMemberPage(' + (currentPage - 1) + ')"' + (currentPage <= 1 ? ' disabled' : '') + '>上一页</button>' +
|
||||
buttons +
|
||||
'<button class="btn btn-sm" onclick="goOrganizationMemberPage(' + (currentPage + 1) + ')"' + (currentPage >= totalPages ? ' disabled' : '') + '>下一页</button>' +
|
||||
'<span style="font-size:12px;color:var(--text-light);margin-left:8px">第 ' + currentPage + '/' + totalPages + ' 页</span>' +
|
||||
'<input id="organizationPageJump" type="number" min="1" max="' + totalPages + '" class="form-input" style="width:64px;height:30px" placeholder="页码" onkeydown="if(event.key===\'Enter\')jumpOrganizationMemberPage()">' +
|
||||
'<button class="btn btn-sm" onclick="jumpOrganizationMemberPage()">跳转</button></div>';
|
||||
}
|
||||
|
||||
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 += '</table></div>';
|
||||
|
||||
// ─── 板块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 += '<div class="pd-card" style="margin-bottom:16px">';
|
||||
html += '<div class="pd-card-header">' +
|
||||
@ -20911,7 +21170,7 @@ window.viewBackupDetail = function(bid, detailItem) {
|
||||
'</svg><span>销量信息</span></div>';
|
||||
if (Object.keys(salesInfo).length) {
|
||||
html += '<div class="pd-card-body">';
|
||||
html += '<div style="font-size:12px;color:#8c8c8c;background:#fafafa;border:1px solid #f0f0f0;border-radius:6px;padding:7px 10px;margin-bottom:10px">计划预测点击下单时的默认销量数据快照' + (item.sales_snapshot_at ? ',快照时间:' + escHtml(String(item.sales_snapshot_at).slice(0, 19)) : '') + '。</div>';
|
||||
html += '<div style="font-size:12px;color:#8c8c8c;background:#fafafa;border:1px solid #f0f0f0;border-radius:6px;padding:7px 10px;margin-bottom:10px">计划预测点击下单时的' + (salesSnapshotScope === 'cloud' ? '云仓数据' : '默认数据(历史)') + '快照' + (item.sales_snapshot_at ? ',快照时间:' + escHtml(String(item.sales_snapshot_at).slice(0, 19)) : '') + '。</div>';
|
||||
html += '<div class="pd-fields pd-fields-4col">';
|
||||
salesInfoFields.forEach(function(field) {
|
||||
var value = salesInfo[field[0]];
|
||||
@ -20931,12 +21190,13 @@ window.viewBackupDetail = function(bid, detailItem) {
|
||||
html += '<h4 style="font-size:14px;color:#333;margin:12px 0 10px 0;padding:8px 12px;background:#fff7e6;border-radius:6px">📦 包材信息</h4>';
|
||||
if (bomList.length > 0) {
|
||||
html += '<div style="border:1px solid var(--border);border-radius:8px;overflow-x:auto">';
|
||||
html += '<table style="width:100%;min-width:1600px;border-collapse:collapse;font-size:13px;white-space:nowrap">';
|
||||
html += '<table style="width:100%;min-width:1740px;border-collapse:collapse;font-size:13px;white-space:nowrap">';
|
||||
html += '<thead><tr style="background:#555;color:#fff">';
|
||||
html += '<th style="padding:8px 12px;border:1px solid #666;text-align:left">物料编号</th>';
|
||||
html += '<th style="padding:8px 12px;border:1px solid #666;text-align:left">物料名称</th>';
|
||||
html += '<th style="padding:8px 6px;border:1px solid #666;text-align:center;width:76px;min-width:76px;white-space:nowrap">下单数量</th>';
|
||||
html += '<th style="padding:8px 6px;border:1px solid #666;text-align:center;width:88px;min-width:88px;white-space:nowrap">剩余包材</th>';
|
||||
html += '<th style="padding:8px 8px;border:1px solid #666;text-align:left;width:140px;min-width:140px;white-space:nowrap">下单进度</th>';
|
||||
html += '<th style="padding:8px 6px;border:1px solid #666;text-align:left;width:110px;min-width:110px;white-space:nowrap">包材下单人</th>';
|
||||
html += '<th style="padding:8px 6px;border:1px solid #666;text-align:left;width:110px;min-width:110px;white-space:nowrap">供应商</th>';
|
||||
html += '<th style="padding:8px 12px;border:1px solid #666;text-align:left">单价</th>';
|
||||
@ -20959,6 +21219,7 @@ window.viewBackupDetail = function(bid, detailItem) {
|
||||
html += '<td style="padding:8px 12px;border:1px solid #eee">' + escHtml(b.material_name || '-') + '</td>';
|
||||
html += '<td style="padding:8px 6px;border:1px solid #eee;width:76px;min-width:76px;text-align:center;white-space:nowrap">' + qty + '</td>';
|
||||
html += '<td style="padding:8px 6px;border:1px solid #eee;width:88px;min-width:88px;text-align:center;white-space:nowrap;font-weight:600;color:' + (remainingPack < 0 ? '#cf1322' : '#333') + '">' + remainingPack.toLocaleString('zh-CN', {maximumFractionDigits: 2}) + '</td>';
|
||||
html += '<td style="padding:8px 8px;border:1px solid #eee;width:140px;min-width:140px;text-align:left;white-space:pre-wrap;word-break:break-word">' + escHtml(b.order_progress || '-') + '</td>';
|
||||
html += '<td style="padding:8px 6px;border:1px solid #eee;width:110px;min-width:110px;white-space:nowrap">' + escHtml(b.packaging_orderer || '-') + '</td>';
|
||||
html += '<td style="padding:8px 6px;border:1px solid #eee;width:110px;min-width:110px;white-space:nowrap">' + escHtml(supplierShortName) + '</td>';
|
||||
html += '<td style="padding:8px 12px;border:1px solid #eee">' + unitPrice.toFixed(4) + '</td>';
|
||||
|
||||
@ -393,6 +393,10 @@
|
||||
<span class="nav-arrow">▾</span>
|
||||
</a>
|
||||
<div class="nav-submenu" id="submenu-sysconfig">
|
||||
<a href="/organization" class="nav-sub-item {% if page == 'organization' %}active{% endif %}" data-page="organization">
|
||||
<span class="nav-sub-icon">●</span>
|
||||
<span>组织架构</span>
|
||||
</a>
|
||||
<a href="/users" class="nav-sub-item {% if page == 'users' %}active{% endif %}" data-page="users">
|
||||
<span class="nav-sub-icon">●</span>
|
||||
<span>用户管理</span>
|
||||
@ -650,7 +654,7 @@
|
||||
var pagePerms = perms[page] || (page === 'product_packaging' ? (perms.product_new || {}) : {});
|
||||
if (pagePerms['view'] === 0 || (page === 'contract_templates' && pagePerms['view'] !== 1)) {
|
||||
// 找第一个有权限的页面
|
||||
var accessiblePages = ['planning','purchase','products','bom','warehouse','suppliers','knowledge','users','permissions','logs','config','login_records','personnel','contract_templates','purchase_ready','orders_domestic','orders_overseas','orders_packaging','orders_finished','inventory_finished','inventory_packaging','data_summary','data_detail','product_new','product_packaging','product_category','brand_management'];
|
||||
var accessiblePages = ['planning','purchase','products','bom','warehouse','suppliers','knowledge','organization','users','permissions','logs','config','login_records','personnel','contract_templates','purchase_ready','orders_domestic','orders_overseas','orders_packaging','orders_finished','inventory_finished','inventory_packaging','data_summary','data_detail','product_new','product_packaging','product_category','brand_management'];
|
||||
for (var pi = 0; pi < accessiblePages.length; pi++) {
|
||||
var ap = accessiblePages[pi];
|
||||
var apPerms = perms[ap] || {};
|
||||
|
||||
@ -22,6 +22,26 @@ class BackupRemainingPackagingTest(unittest.TestCase):
|
||||
purchase_source = (Path(__file__).resolve().parents[1] / 'routes/purchase.py').read_text(encoding='utf-8')
|
||||
self.assertIn("bm['post_pack_qty'] = post_pack_qty", purchase_source)
|
||||
|
||||
def test_backup_detail_includes_lead_time_and_packaging_progress(self):
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
frontend = (root / 'static/js/app.js').read_text(encoding='utf-8')
|
||||
backend = (root / 'routes/purchase.py').read_text(encoding='utf-8')
|
||||
|
||||
self.assertIn("['货期', (item.lead_time", frontend)
|
||||
self.assertIn("item.lead_time + '天'", frontend)
|
||||
self.assertIn('>下单进度</th>', frontend)
|
||||
self.assertIn("b.order_progress || '-'", frontend)
|
||||
|
||||
normalize_source = backend[
|
||||
backend.index('def _normalize_backup_bom('):
|
||||
backend.index("# ─── API: 采购订单 ───")
|
||||
]
|
||||
self.assertIn("AS order_progress", normalize_source)
|
||||
self.assertIn("BINARY `编码`=BINARY %s", normalize_source)
|
||||
self.assertIn("BINARY `来源产品编码`=BINARY %s", normalize_source)
|
||||
self.assertIn("`下单时间`=%s", normalize_source)
|
||||
self.assertIn("current['order_progress']", normalize_source)
|
||||
|
||||
def test_backup_detail_reads_saved_planning_sales_snapshot(self):
|
||||
source = purchase_source = (Path(__file__).resolve().parents[1] / 'routes/purchase.py').read_text(encoding='utf-8')
|
||||
frontend = (Path(__file__).resolve().parents[1] / 'static/js/app.js').read_text(encoding='utf-8')
|
||||
@ -30,11 +50,14 @@ class BackupRemainingPackagingTest(unittest.TestCase):
|
||||
self.assertIn("raw_snapshot = row.pop('sales_snapshot', None)", detail_source)
|
||||
self.assertNotIn('_build_planning_sales_snapshot(', detail_source)
|
||||
self.assertIn('sales_snapshot, sales_snapshot_at', source)
|
||||
self.assertIn('与计划预测详情默认数据完全同源', frontend)
|
||||
self.assertIn('与计划预测详情云仓数据完全同源', frontend)
|
||||
self.assertIn("return {'__scope': 'cloud'", source)
|
||||
self.assertIn("row['sales_snapshot_scope']", source)
|
||||
self.assertIn("salesSnapshotScope === 'cloud' ? '云仓数据' : '默认数据(历史)'", frontend)
|
||||
self.assertIn('<span>销量信息</span></div>', 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)
|
||||
|
||||
|
||||
|
||||
@ -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('<span>组织架构</span>', html)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user