全部优化

This commit is contained in:
Codex 2026-10-29 16:33:09 +08:00
parent 1eb96ece68
commit e7b2dd64f1
15 changed files with 801 additions and 19 deletions

3
.gitignore vendored
View File

@ -26,3 +26,6 @@ tmp_cache/
# 合同生成输出文件 # 合同生成输出文件
static/uploads/contract_output/ static/uploads/contract_output/
ai_config.py ai_config.py
# 本机/云端运行密钥(不进入版本库)
.secrets/

2
app.py
View File

@ -137,7 +137,7 @@ def spa_fallback(path):
SPA_PATHS = { SPA_PATHS = {
'knowledge', 'planning', 'products', 'bom', 'purchase', 'knowledge', 'planning', 'products', 'bom', 'purchase',
'orders', 'inventory', 'warehouse', 'suppliers', 'orders', 'inventory', 'warehouse', 'suppliers',
'users', 'data', 'config', 'logs', 'permissions', 'users', 'organization', 'data', 'config', 'logs', 'permissions',
'login-records', 'login-records',
'contract-templates', 'personnel', 'contract-templates', 'personnel',
'collaboration', 'spot', 'ai-analysis', 'product-order', 'collaboration', 'spot', 'ai-analysis', 'product-order',

View 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

View 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

View File

@ -14,6 +14,7 @@ from routes.contract import contract_bp
from routes.ai import ai_bp from routes.ai import ai_bp
from routes.sku_forecast import sku_forecast_bp from routes.sku_forecast import sku_forecast_bp
from routes.spot import spot_bp from routes.spot import spot_bp
from routes.organization import organization_bp
def register_blueprints(app): def register_blueprints(app):
app.register_blueprint(auth_bp) app.register_blueprint(auth_bp)
@ -30,7 +31,9 @@ def register_blueprints(app):
app.register_blueprint(ai_bp) app.register_blueprint(ai_bp)
app.register_blueprint(sku_forecast_bp) app.register_blueprint(sku_forecast_bp)
app.register_blueprint(spot_bp) app.register_blueprint(spot_bp)
app.register_blueprint(organization_bp)
__all__ = ['register_blueprints', 'auth_bp', 'dashboard_bp', 'products_bp', __all__ = ['register_blueprints', 'auth_bp', 'dashboard_bp', 'products_bp',
'inventory_bp', 'planning_bp', 'purchase_bp', 'warehouse_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']

View File

@ -109,6 +109,7 @@ PAGE_FEATURES = {
'warehouse': [{'id':'view','name':'查看'},{'id':'create','name':'新增仓库'},{'id':'edit','name':'编辑仓库'},{'id':'delete','name':'删除仓库'}], 'warehouse': [{'id':'view','name':'查看'},{'id':'create','name':'新增仓库'},{'id':'edit','name':'编辑仓库'},{'id':'delete','name':'删除仓库'}],
'suppliers': [{'id':'view','name':'查看'},{'id':'edit','name':'编辑'},{'id':'create','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':'关系图谱'}], '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':'导入'}], 'users': [{'id':'view','name':'查看'},{'id':'create','name':'新增'},{'id':'edit','name':'编辑'},{'id':'delete','name':'删除'},{'id':'import','name':'导入'}],
'permissions': [{'id':'view','name':'查看'},{'id':'edit','name':'编辑'}], 'permissions': [{'id':'view','name':'查看'},{'id':'edit','name':'编辑'}],
'logs': [{'id':'view','name':'查看'},{'id':'export','name':'导出'},{'id':'verify','name':'完整性校验'},{'id':'manage','name':'日志治理'}], 'logs': [{'id':'view','name':'查看'},{'id':'export','name':'导出'},{'id':'verify','name':'完整性校验'},{'id':'manage','name':'日志治理'}],

View File

@ -49,6 +49,14 @@ def settings_page():
return render_template('base.html', page='settings') 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') @dashboard_bp.route('/favicon.ico')
def favicon(): def favicon():
from flask import redirect as _rd from flask import redirect as _rd

37
routes/organization.py Normal file
View 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

View File

@ -37,7 +37,7 @@ PLANNING_SALES_SNAPSHOT_FIELDS = (
def _build_planning_sales_snapshot(product_code, product_name='', product_type=''): def _build_planning_sales_snapshot(product_code, product_name='', product_type=''):
"""按计划预测默认数据口径生成一次性销量快照。""" """按计划预测“云仓数据”页签口径生成一次性销量快照。"""
where = ['p.product_code=%s', 'p.deleted_at IS NULL'] where = ['p.product_code=%s', 'p.deleted_at IS NULL']
params = [product_code] params = [product_code]
if product_name: 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_name', ''), planning_row.get('brand', ''),
planning_row.get('product_type')) planning_row.get('product_type'))
planning_row['批注'] = planning_row.get('remark') or '' planning_row['批注'] = planning_row.get('remark') or ''
extended_sales = compute_product_sales_extended_cached()
planning_row = _enrich_row_with_planning_data( planning_row = _enrich_row_with_planning_data(
planning_row, planning_row,
compute_product_sales_cached(), compute_product_sales_cached(),
@ -79,9 +80,42 @@ def _build_planning_sales_snapshot(product_code, product_name='', product_type='
get_global_config(), get_global_config(),
cloud_inv_map=compute_cloud_inventory_cached(), cloud_inv_map=compute_cloud_inventory_cached(),
ext_inv_map=compute_external_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/加签只从运行环境读取, # 海外产品清单专属备货通知:名单是非敏感业务配置,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: if source_product_code and source_ordered_at:
orderer_rows = query( orderer_rows = query(
"""SELECT GROUP_CONCAT(DISTINCT NULLIF(`下单人`, '') """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 FROM packaging_orders
WHERE BINARY `编码`=BINARY %s AND BINARY `来源产品编码`=BINARY %s AND `下单时间`=%s""", WHERE BINARY `编码`=BINARY %s AND BINARY `来源产品编码`=BINARY %s AND `下单时间`=%s""",
(code, source_product_code, source_ordered_at) (code, source_product_code, source_ordered_at)
) )
current['packaging_orderer'] = (orderer_rows[0].get('packaging_orderer') current['packaging_orderer'] = (orderer_rows[0].get('packaging_orderer')
if orderer_rows else '') or '' 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) qty = int(current.get('actual_quantity') or 0)
current.setdefault('price_audit_status', '未审核') current.setdefault('price_audit_status', '未审核')
current['amount'] = round(qty * float(current.get('unit_price') or 0), 2) current['amount'] = round(qty * float(current.get('unit_price') or 0), 2)
@ -920,6 +958,8 @@ def purchase_backup_detail():
row['sales_info'] = {} row['sales_info'] = {}
except (TypeError, ValueError, json.JSONDecodeError): except (TypeError, ValueError, json.JSONDecodeError):
row['sales_info'] = {} 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']) row['sales_snapshot_missing'] = not bool(row['sales_info'])
return jsonify({"code": 0, "data": row}) return jsonify({"code": 0, "data": row})

View 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)

View 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}

View File

@ -1,7 +1,7 @@
// 蝶雅采购智能体平台 - 前端逻辑 // 蝶雅采购智能体平台 - 前端逻辑
let charts = {}; let charts = {};
// 页面缓存:切换菜单时不销毁页面状态 // 页面缓存:切换菜单时不销毁页面状态
var CACHE_VERSION = 94; var CACHE_VERSION = 100;
if (!window._pageCache) window._pageCache = {}; if (!window._pageCache) window._pageCache = {};
if (!window._pageDataCache) window._pageDataCache = {}; if (!window._pageDataCache) window._pageDataCache = {};
if (!window._pageReloaders) window._pageReloaders = {}; if (!window._pageReloaders) window._pageReloaders = {};
@ -33,6 +33,7 @@ const PAGE_LOADERS = {
'data_outbound': loadDataPage, 'data_outbound': loadDataPage,
'products': loadProductsPage, 'products': loadProductsPage,
'brand_management': loadBrandManagementPage, 'brand_management': loadBrandManagementPage,
'organization': loadOrganizationPage,
'users': loadUsersPage, 'users': loadUsersPage,
'logs': loadLogsPage, 'logs': loadLogsPage,
'login_records': loadLoginRecordsPage, 'login_records': loadLoginRecordsPage,
@ -6058,6 +6059,7 @@ var PAGE_MAP = {
'/orders/overseas': 'orders_overseas', '/orders/overseas': 'orders_overseas',
'/orders/packaging': 'orders_packaging', '/orders/packaging': 'orders_packaging',
'/orders/finished': 'orders_finished', '/orders/finished': 'orders_finished',
'/organization': 'organization',
'/users': 'users', '/users': 'users',
'/login-records': 'login_records', '/login-records': 'login_records',
'/logs': 'logs', '/logs': 'logs',
@ -6197,6 +6199,7 @@ window._hideNoPermNavItems = function() {
{page:'personnel', sel:'a.nav-sub-item[href="/personnel"]', sub:true}, {page:'personnel', sel:'a.nav-sub-item[href="/personnel"]', sub:true},
{page:'knowledge', sel:'a.nav-item[href="/knowledge"]', sub:false}, {page:'knowledge', sel:'a.nav-item[href="/knowledge"]', sub:false},
{page:'contract_templates', sel:'a.nav-sub-item[href="/contract-templates"]', sub:true}, {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:'users', sel:'a.nav-sub-item[href="/users"]', sub:true},
{page:'permissions', sel:'a.nav-sub-item[href="/permissions"]', 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}, {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); }); }).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() { function downloadUserTemplate() {
fetch('/api/users/template', {credentials: 'same-origin'}) fetch('/api/users/template', {credentials: 'same-origin'})
@ -14140,6 +14395,7 @@ window._permPageFields = {
// 知识库 // 知识库
'knowledge': ['标题','主题分类','创建人','创建时间','更新时间','状态'], 'knowledge': ['标题','主题分类','创建人','创建时间','更新时间','状态'],
// 系统设置 // 系统设置
'organization': [],
'users': ['用户名','姓名','角色','岗位','用户级别','手机号','邮箱','状态','最后登录'], 'users': ['用户名','姓名','角色','岗位','用户级别','手机号','邮箱','状态','最后登录'],
'permissions': ['用户名','姓名','角色','岗位'], 'permissions': ['用户名','姓名','角色','岗位'],
'login_records': ['用户名','登录时间','IP地址'], 'login_records': ['用户名','登录时间','IP地址'],
@ -20858,6 +21114,7 @@ window.viewBackupDetail = function(bid, detailItem) {
['货品编号', item.product_code], ['货品编号', item.product_code],
['货品名称', item.product_name], ['货品名称', item.product_name],
['供应商名称', item.supplier_name], ['供应商名称', item.supplier_name],
['货期', (item.lead_time === null || item.lead_time === undefined || item.lead_time === '') ? '-' : item.lead_time + '天'],
['采购计划', item.procurement_planner], ['采购计划', item.procurement_planner],
['采购跟单', item.procurement_follower], ['采购跟单', item.procurement_follower],
['成品下单人', item.product_orderer] ['成品下单人', item.product_orderer]
@ -20894,15 +21151,17 @@ window.viewBackupDetail = function(bid, detailItem) {
} }
html += '</table></div>'; html += '</table></div>';
// ─── 板块2:销量信息(与计划预测详情默认数据完全同源)─── // ─── 板块2:销量信息(新订单与计划预测详情云仓数据完全同源)───
var salesInfo = item.sales_info || {}; var salesInfo = item.sales_info || {};
var salesSnapshotScope = item.sales_snapshot_scope || 'default';
var salesLabelPrefix = salesSnapshotScope === 'cloud' ? '云仓' : '';
var salesInfoFields = [ var salesInfoFields = [
['产品分级', '产品分级'], ['昨日销量', '昨日销量'], ['销售趋势', '销售趋势'], ['产品分级', salesLabelPrefix + '产品分级'], ['昨日销量', salesLabelPrefix + '昨日销量'], ['销售趋势', salesLabelPrefix + '销售趋势'],
['库存', '库存'], ['加工厂库存', '加工厂库存'], ['生产在途', '生产在途'], ['采购在途', '采购在途'], ['库存', salesLabelPrefix + '库存'], ['加工厂库存', salesLabelPrefix + '加工厂库存'], ['生产在途', salesLabelPrefix + '生产在途'], ['采购在途', salesLabelPrefix + '采购在途'],
['日销量预测', '日销量预测'], ['3天平均销量', '3天平均销量'], ['7天平均销量', '7天平均销量'], ['日销量预测', salesLabelPrefix + '日销量预测'], ['3天平均销量', salesLabelPrefix + '3天平均销量'], ['7天平均销量', salesLabelPrefix + '7天平均销量'],
['14天平均销量', '14天平均销量'], ['库存可支撑天数', '库存可支撑天数'], ['14天平均销量', salesLabelPrefix + '14天平均销量'], ['库存可支撑天数', salesLabelPrefix + '库存可支撑天数'],
['加工厂库存支撑天数', '加工厂库存支撑天数'], ['总支撑天数', '总支撑天数'], ['加工厂库存支撑天数', salesLabelPrefix + '加工厂库存支撑天数'], ['总支撑天数', salesLabelPrefix + '总支撑天数'],
['30天平均销量', '30天平均销量'], ['极值判断', '极值判断'] ['30天平均销量', salesLabelPrefix + '30天平均销量'], ['极值判断', salesLabelPrefix + '极值判断']
]; ];
html += '<div class="pd-card" style="margin-bottom:16px">'; html += '<div class="pd-card" style="margin-bottom:16px">';
html += '<div class="pd-card-header">' + html += '<div class="pd-card-header">' +
@ -20911,7 +21170,7 @@ window.viewBackupDetail = function(bid, detailItem) {
'</svg><span>销量信息</span></div>'; '</svg><span>销量信息</span></div>';
if (Object.keys(salesInfo).length) { if (Object.keys(salesInfo).length) {
html += '<div class="pd-card-body">'; 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">'; html += '<div class="pd-fields pd-fields-4col">';
salesInfoFields.forEach(function(field) { salesInfoFields.forEach(function(field) {
var value = salesInfo[field[0]]; 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>'; 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) { if (bomList.length > 0) {
html += '<div style="border:1px solid var(--border);border-radius:8px;overflow-x:auto">'; 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 += '<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 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: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 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 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>'; 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 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: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 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(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 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>'; html += '<td style="padding:8px 12px;border:1px solid #eee">' + unitPrice.toFixed(4) + '</td>';

View File

@ -393,6 +393,10 @@
<span class="nav-arrow">&#9662;</span> <span class="nav-arrow">&#9662;</span>
</a> </a>
<div class="nav-submenu" id="submenu-sysconfig"> <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">&#9679;</span>
<span>组织架构</span>
</a>
<a href="/users" class="nav-sub-item {% if page == 'users' %}active{% endif %}" data-page="users"> <a href="/users" class="nav-sub-item {% if page == 'users' %}active{% endif %}" data-page="users">
<span class="nav-sub-icon">&#9679;</span> <span class="nav-sub-icon">&#9679;</span>
<span>用户管理</span> <span>用户管理</span>
@ -650,7 +654,7 @@
var pagePerms = perms[page] || (page === 'product_packaging' ? (perms.product_new || {}) : {}); var pagePerms = perms[page] || (page === 'product_packaging' ? (perms.product_new || {}) : {});
if (pagePerms['view'] === 0 || (page === 'contract_templates' && pagePerms['view'] !== 1)) { 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++) { for (var pi = 0; pi < accessiblePages.length; pi++) {
var ap = accessiblePages[pi]; var ap = accessiblePages[pi];
var apPerms = perms[ap] || {}; var apPerms = perms[ap] || {};

View File

@ -22,6 +22,26 @@ class BackupRemainingPackagingTest(unittest.TestCase):
purchase_source = (Path(__file__).resolve().parents[1] / 'routes/purchase.py').read_text(encoding='utf-8') 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) 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): 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') 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') 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.assertIn("raw_snapshot = row.pop('sales_snapshot', None)", detail_source)
self.assertNotIn('_build_planning_sales_snapshot(', detail_source) self.assertNotIn('_build_planning_sales_snapshot(', detail_source)
self.assertIn('sales_snapshot, sales_snapshot_at', 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('<span>销量信息</span></div>', frontend)
self.assertIn('class="pd-fields pd-fields-4col"', frontend) self.assertIn('class="pd-fields pd-fields-4col"', frontend)
self.assertIn("formatPopupFieldValue(field[0], value, salesInfo)", frontend) self.assertIn("formatPopupFieldValue(field[0], value, salesInfo)", frontend)
self.assertIn("['库存可支撑天数', '库存可支撑天数']", frontend) self.assertIn("['库存可支撑天数', salesLabelPrefix + '库存可支撑天数']", frontend)
self.assertIn('历史订单未保存销量快照', frontend) self.assertIn('历史订单未保存销量快照', frontend)

View File

@ -69,6 +69,12 @@ class NavigationOrderTest(unittest.TestCase):
self.assertIn('.nav-order-11 { order: 11; }', html) self.assertIn('.nav-order-11 { order: 11; }', html)
self.assertNotIn('测试问题', 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__': if __name__ == '__main__':
unittest.main() unittest.main()