在准备环境前提交次全部更改。
This commit is contained in:
21
scripts/ops/_run_dataflow.py
Normal file
21
scripts/ops/_run_dataflow.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""临时包装脚本:确保从项目根目录运行 analyze_dataflow.py"""
|
||||
import os
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
# 切换到项目根目录
|
||||
root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
os.chdir(root)
|
||||
print(f"[wrapper] cwd = {os.getcwd()}", flush=True)
|
||||
|
||||
# 将 scripts/ops 加入 sys.path
|
||||
scripts_ops = os.path.join(root, "scripts", "ops")
|
||||
if scripts_ops not in sys.path:
|
||||
sys.path.insert(0, scripts_ops)
|
||||
|
||||
try:
|
||||
from analyze_dataflow import main
|
||||
main()
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
18
scripts/ops/_run_report.py
Normal file
18
scripts/ops/_run_report.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""临时包装脚本:确保从项目根目录运行 gen_dataflow_report.py"""
|
||||
import os
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
os.chdir(root)
|
||||
|
||||
scripts_ops = os.path.join(root, "scripts", "ops")
|
||||
if scripts_ops not in sys.path:
|
||||
sys.path.insert(0, scripts_ops)
|
||||
|
||||
try:
|
||||
from gen_dataflow_report import main
|
||||
main()
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
152
scripts/ops/analyze_dataflow.py
Normal file
152
scripts/ops/analyze_dataflow.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""
|
||||
数据流结构分析 — CLI 入口
|
||||
|
||||
用法:
|
||||
python scripts/ops/analyze_dataflow.py
|
||||
python scripts/ops/analyze_dataflow.py --date-from 2025-01-01 --date-to 2025-01-15
|
||||
python scripts/ops/analyze_dataflow.py --limit 100 --tables settlement_records,payment_transactions
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
"""
|
||||
构造 CLI 参数解析器。
|
||||
|
||||
参数:
|
||||
--date-from 数据获取起始日期 (YYYY-MM-DD)
|
||||
--date-to 数据获取截止日期 (YYYY-MM-DD)
|
||||
--limit 每端点最大记录数 (默认 200)
|
||||
--tables 要分析的表名列表 (逗号分隔,缺省=全部)
|
||||
"""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="数据流结构分析 — 采集 API JSON 和 DB 表结构",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--date-from",
|
||||
type=str,
|
||||
default=None,
|
||||
help="数据获取起始日期 (YYYY-MM-DD)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--date-to",
|
||||
type=str,
|
||||
default=None,
|
||||
help="数据获取截止日期 (YYYY-MM-DD)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--limit",
|
||||
type=int,
|
||||
default=200,
|
||||
help="每端点最大记录数 (默认 200)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--tables",
|
||||
type=str,
|
||||
default=None,
|
||||
help="要分析的表名列表 (逗号分隔,缺省=全部)",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def resolve_output_dir() -> Path:
|
||||
"""
|
||||
确定输出目录:
|
||||
1. 优先读取环境变量 SYSTEM_ANALYZE_ROOT
|
||||
2. 回退到 docs/reports/
|
||||
3. 确保目录存在(自动创建)
|
||||
"""
|
||||
env_root = os.environ.get("SYSTEM_ANALYZE_ROOT")
|
||||
if env_root:
|
||||
out = Path(env_root)
|
||||
else:
|
||||
out = Path("docs/reports")
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
return out
|
||||
|
||||
|
||||
def generate_output_filename(dt: "datetime") -> str:
|
||||
"""生成输出文件名:dataflow_YYYY-MM-DD_HHMMSS.md"""
|
||||
return f"dataflow_{dt.strftime('%Y-%m-%d_%H%M%S')}.md"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""
|
||||
串联采集流程:
|
||||
1. 解析 CLI 参数
|
||||
2. 加载环境变量(.env 分层叠加)
|
||||
3. 构造 AnalyzerConfig
|
||||
4. 调用 collect_all_tables() 执行采集
|
||||
5. 调用 dump_collection_results() 落盘
|
||||
6. 输出采集摘要到 stdout
|
||||
"""
|
||||
from datetime import date as _date, datetime as _datetime
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# ── 1. 解析 CLI 参数 ──
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
# ── 2. 加载环境变量(分层叠加:根 .env < ETL .env < 环境变量) ──
|
||||
# override=False 保证后加载的不覆盖先加载的环境变量
|
||||
# 先加载根 .env(最低优先级)
|
||||
load_dotenv(Path(".env"), override=False)
|
||||
# 再加载 ETL 专属 .env(中优先级)
|
||||
load_dotenv(Path("apps/etl/connectors/feiqiu/.env"), override=False)
|
||||
# 真实环境变量(最高优先级)已自动存在于 os.environ
|
||||
|
||||
# ── 3. 构造 AnalyzerConfig ──
|
||||
date_from = _date.fromisoformat(args.date_from) if args.date_from else None
|
||||
date_to = _date.fromisoformat(args.date_to) if args.date_to else None
|
||||
tables = [t.strip() for t in args.tables.split(",")] if args.tables else None
|
||||
output_dir = resolve_output_dir()
|
||||
|
||||
from dataflow_analyzer import AnalyzerConfig, ODS_SPECS, collect_all_tables, dump_collection_results
|
||||
|
||||
config = AnalyzerConfig(
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
limit=args.limit,
|
||||
tables=tables,
|
||||
output_dir=output_dir,
|
||||
pg_dsn=os.environ.get("DATABASE_URL") or os.environ.get("PG_DSN", ""),
|
||||
api_base=os.environ.get("API_BASE", ""),
|
||||
api_token=os.environ.get("API_TOKEN", ""),
|
||||
store_id=os.environ.get("STORE_ID", ""),
|
||||
)
|
||||
|
||||
# ── 4. 执行采集(使用本模块的 ODS_SPECS) ──
|
||||
results = collect_all_tables(config, specs=ODS_SPECS)
|
||||
|
||||
# ── 5. 落盘 ──
|
||||
paths = dump_collection_results(results, output_dir)
|
||||
|
||||
# ── 6. 输出采集摘要 ──
|
||||
now = _datetime.now()
|
||||
filename = generate_output_filename(now)
|
||||
ok = sum(1 for r in results if r.error is None)
|
||||
fail = len(results) - ok
|
||||
total_records = sum(r.record_count for r in results)
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f"数据流结构分析完成")
|
||||
print(f"{'='*60}")
|
||||
print(f" 输出目录: {output_dir}")
|
||||
print(f" 报告文件名: {filename}")
|
||||
print(f" 分析表数: {len(results)} ({ok} 成功, {fail} 失败)")
|
||||
print(f" 总记录数: {total_records}")
|
||||
print(f" 落盘路径:")
|
||||
for category, p in paths.items():
|
||||
print(f" {category}: {p}")
|
||||
print(f"{'='*60}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
75
scripts/ops/batch_h5_updates.py
Normal file
75
scripts/ops/batch_h5_updates.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""批量更新 H5 原型页面"""
|
||||
import re
|
||||
|
||||
def read(path):
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
def write(path, content):
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
print(f" ✅ {path}")
|
||||
|
||||
# ============================================================
|
||||
# 1. notes.html — 合并"任务备注"和"客户备注"为"客户备注"
|
||||
# ============================================================
|
||||
print("1. notes.html — 合并备注类型")
|
||||
c = read("docs/h5_ui/pages/notes.html")
|
||||
# 把标题改为"客户备注"
|
||||
c = c.replace(">备注记录</h1>", ">客户备注</h1>")
|
||||
c = c.replace("<title>备注记录 - 球房运营助手</title>", "<title>客户备注 - 球房运营助手</title>")
|
||||
# 把所有"任务:xxx"标签改为"客户:xxx"样式
|
||||
c = c.replace('bg-gradient-to-r from-orange-50 to-amber-50 text-warning', 'bg-gradient-to-r from-blue-50 to-indigo-50 text-primary')
|
||||
c = c.replace('border border-orange-100">任务:高优先召回', 'border border-blue-100">客户:王先生')
|
||||
c = c.replace('border border-orange-100">任务:关系构建', 'border border-blue-100">客户:张先生')
|
||||
# 把"助教:xxx"标签也改为客户标签
|
||||
c = c.replace('bg-gradient-to-r from-green-50 to-emerald-50 text-success', 'bg-gradient-to-r from-blue-50 to-indigo-50 text-primary')
|
||||
c = c.replace('border border-green-100">助教:泡芙', 'border border-blue-100">客户:陈女士')
|
||||
c = c.replace('border border-green-100">助教:Amy', 'border border-blue-100">客户:李女士')
|
||||
write("docs/h5_ui/pages/notes.html", c)
|
||||
|
||||
# ============================================================
|
||||
# 2. performance-records.html — 统计概览改为:总记录|总业绩时长|收入
|
||||
# ============================================================
|
||||
print("2. performance-records.html — 统计概览")
|
||||
c = read("docs/h5_ui/pages/performance-records.html")
|
||||
# 替换"折算扣减"为"收入"
|
||||
old_stats = ''' <div class="text-center flex-1">
|
||||
<p class="text-[10px] text-gray-6 mb-0.5">折算扣减</p>
|
||||
<p class="text-lg font-bold text-error perf-value" id="totalPenalty">-1.5h</p>
|
||||
</div>'''
|
||||
new_stats = ''' <div class="text-center flex-1">
|
||||
<p class="text-[10px] text-gray-6 mb-0.5">收入</p>
|
||||
<p class="text-lg font-bold text-success perf-value" id="totalIncome">¥4,720</p>
|
||||
<p class="text-[10px] text-warning">预估</p>
|
||||
</div>'''
|
||||
c = c.replace(old_stats, new_stats)
|
||||
# 在"总业绩时长"数据旁标注折算前时长
|
||||
old_hours = ''' <div class="text-center flex-1">
|
||||
<p class="text-[10px] text-gray-6 mb-0.5">总业绩时长</p>
|
||||
<p class="text-lg font-bold text-primary perf-value" id="totalMinutes">59.0h</p>
|
||||
</div>'''
|
||||
new_hours = ''' <div class="text-center flex-1">
|
||||
<p class="text-[10px] text-gray-6 mb-0.5">总业绩时长</p>
|
||||
<p class="text-lg font-bold text-primary perf-value" id="totalMinutes">59.0h</p>
|
||||
<p class="text-[10px] text-gray-5">折算前 60.5h</p>
|
||||
<p class="text-[10px] text-warning">预估</p>
|
||||
</div>'''
|
||||
c = c.replace(old_hours, new_hours)
|
||||
write("docs/h5_ui/pages/performance-records.html", c)
|
||||
|
||||
# ============================================================
|
||||
# 3. performance.html — 所有灰色字黑30%
|
||||
# ============================================================
|
||||
print("3. performance.html — 灰色字加深")
|
||||
c = read("docs/h5_ui/pages/performance.html")
|
||||
# text-gray-5 → text-gray-7, text-gray-6 → text-gray-8, text-gray-7 → text-gray-9
|
||||
# 只替换非 CSS 定义部分(即 HTML body 中的 class 引用)
|
||||
# 用正则精确替换 class 属性中的灰色值
|
||||
c = c.replace('text-gray-5 ', 'text-gray-7 ')
|
||||
c = c.replace('text-gray-5"', 'text-gray-7"')
|
||||
c = c.replace('text-gray-6 ', 'text-gray-8 ')
|
||||
c = c.replace('text-gray-6"', 'text-gray-8"')
|
||||
write("docs/h5_ui/pages/performance.html", c)
|
||||
|
||||
print("\n批量更新完成!")
|
||||
59
scripts/ops/check_and_refresh_audit.py
Normal file
59
scripts/ops/check_and_refresh_audit.py
Normal file
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env python3
|
||||
"""一次性脚本:检查 ETL 审计记录迁移状态 + 刷新项目级一览表。"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(r"C:\NeoZQYY")
|
||||
ETL_AUDIT = ROOT / "apps" / "etl" / "pipelines" / "feiqiu" / "docs" / "audit" / "changes"
|
||||
ROOT_AUDIT = ROOT / "docs" / "audit" / "changes"
|
||||
|
||||
|
||||
def check_migration():
|
||||
"""检查 ETL 审计记录是否全部迁移到根目录。"""
|
||||
if not ETL_AUDIT.is_dir():
|
||||
print(f"ETL 审计目录不存在: {ETL_AUDIT}")
|
||||
return
|
||||
|
||||
etl_files = {f.name for f in ETL_AUDIT.iterdir() if f.suffix == ".md"}
|
||||
root_files = {f.name for f in ROOT_AUDIT.iterdir() if f.suffix == ".md"}
|
||||
|
||||
missing = etl_files - root_files
|
||||
print(f"ETL: {len(etl_files)} 条, 根目录: {len(root_files)} 条")
|
||||
|
||||
if missing:
|
||||
print(f"\n根目录缺失 {len(missing)} 条,正在复制:")
|
||||
for fname in sorted(missing):
|
||||
src = ETL_AUDIT / fname
|
||||
dst = ROOT_AUDIT / fname
|
||||
shutil.copy2(src, dst)
|
||||
print(f" 已复制: {fname}")
|
||||
print("迁移补全完成。")
|
||||
else:
|
||||
print("所有 ETL 审计记录已迁移到根目录,无需补充。")
|
||||
|
||||
extra = root_files - etl_files
|
||||
if extra:
|
||||
print(f"\n根目录独有 {len(extra)} 条(monorepo 新增):")
|
||||
for f in sorted(extra):
|
||||
print(f" - {f}")
|
||||
|
||||
|
||||
def refresh_dashboard():
|
||||
"""刷新项目级审计一览表。"""
|
||||
import sys
|
||||
sys.path.insert(0, str(ROOT / "apps" / "etl" / "pipelines" / "feiqiu"))
|
||||
from scripts.gen_audit_dashboard import scan_audit_dir, render_dashboard
|
||||
|
||||
entries = scan_audit_dir(ROOT_AUDIT)
|
||||
content = render_dashboard(entries)
|
||||
|
||||
output = ROOT / "docs" / "audit" / "audit_dashboard.md"
|
||||
output.write_text(content, encoding="utf-8")
|
||||
print(f"\n已刷新一览表: {len(entries)} 条记录 → {output}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
check_migration()
|
||||
refresh_dashboard()
|
||||
65
scripts/ops/check_fdw_prereqs.py
Normal file
65
scripts/ops/check_fdw_prereqs.py
Normal file
@@ -0,0 +1,65 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""检查 FDW 前提条件:角色是否存在、app schema 是否有视图/表"""
|
||||
import psycopg2
|
||||
|
||||
CONN = dict(host="100.64.0.4", port=5432, user="local-Python", password="Neo-local-1991125")
|
||||
|
||||
print("=== 1. 检查角色 ===")
|
||||
c = psycopg2.connect(**CONN, dbname="postgres")
|
||||
cur = c.cursor()
|
||||
for role in ["app_reader", "app_user"]:
|
||||
cur.execute("SELECT 1 FROM pg_roles WHERE rolname = %s", (role,))
|
||||
exists = cur.fetchone() is not None
|
||||
print(f" {role}: {'OK' if exists else 'MISSING'}")
|
||||
c.close()
|
||||
|
||||
print("\n=== 2. 检查 etl_feiqiu.app schema 对象 ===")
|
||||
c = psycopg2.connect(**CONN, dbname="etl_feiqiu")
|
||||
cur = c.cursor()
|
||||
cur.execute(
|
||||
"SELECT tablename FROM pg_tables WHERE schemaname = 'app' ORDER BY tablename"
|
||||
)
|
||||
tables = [r[0] for r in cur.fetchall()]
|
||||
cur.execute(
|
||||
"SELECT viewname FROM pg_views WHERE schemaname = 'app' ORDER BY viewname"
|
||||
)
|
||||
views = [r[0] for r in cur.fetchall()]
|
||||
cur.execute(
|
||||
"SELECT matviewname FROM pg_matviews WHERE schemaname = 'app' ORDER BY matviewname"
|
||||
)
|
||||
mvs = [r[0] for r in cur.fetchall()]
|
||||
print(f" tables: {len(tables)} {tables[:5]}{'...' if len(tables)>5 else ''}")
|
||||
print(f" views: {len(views)} {views[:5]}{'...' if len(views)>5 else ''}")
|
||||
print(f" matviews: {len(mvs)} {mvs[:5]}{'...' if len(mvs)>5 else ''}")
|
||||
c.close()
|
||||
|
||||
print("\n=== 3. 检查 test_etl_feiqiu.app schema 对象 ===")
|
||||
c = psycopg2.connect(**CONN, dbname="test_etl_feiqiu")
|
||||
cur = c.cursor()
|
||||
cur.execute(
|
||||
"SELECT tablename FROM pg_tables WHERE schemaname = 'app' ORDER BY tablename"
|
||||
)
|
||||
tables2 = [r[0] for r in cur.fetchall()]
|
||||
cur.execute(
|
||||
"SELECT viewname FROM pg_views WHERE schemaname = 'app' ORDER BY viewname"
|
||||
)
|
||||
views2 = [r[0] for r in cur.fetchall()]
|
||||
cur.execute(
|
||||
"SELECT matviewname FROM pg_matviews WHERE schemaname = 'app' ORDER BY matviewname"
|
||||
)
|
||||
mvs2 = [r[0] for r in cur.fetchall()]
|
||||
print(f" tables: {len(tables2)} {tables2[:5]}{'...' if len(tables2)>5 else ''}")
|
||||
print(f" views: {len(views2)} {views2[:5]}{'...' if len(views2)>5 else ''}")
|
||||
print(f" matviews: {len(mvs2)} {mvs2[:5]}{'...' if len(mvs2)>5 else ''}")
|
||||
c.close()
|
||||
|
||||
print("\n=== 4. 检查 postgres_fdw 扩展可用性 ===")
|
||||
for db in ["zqyy_app", "test_zqyy_app"]:
|
||||
c = psycopg2.connect(**CONN, dbname=db)
|
||||
cur = c.cursor()
|
||||
cur.execute("SELECT 1 FROM pg_available_extensions WHERE name = 'postgres_fdw'")
|
||||
avail = cur.fetchone() is not None
|
||||
cur.execute("SELECT 1 FROM pg_extension WHERE extname = 'postgres_fdw'")
|
||||
installed = cur.fetchone() is not None
|
||||
print(f" {db}: available={avail}, installed={installed}")
|
||||
c.close()
|
||||
102
scripts/ops/check_ods_indexes.py
Normal file
102
scripts/ops/check_ods_indexes.py
Normal file
@@ -0,0 +1,102 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""查询 ODS schema 的表、索引、关键列现状,输出到控制台。"""
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
import psycopg2
|
||||
|
||||
# 加载 ETL .env
|
||||
env_path = Path(__file__).resolve().parents[2] / "apps" / "etl" / "pipelines" / "feiqiu" / ".env"
|
||||
load_dotenv(env_path)
|
||||
|
||||
dsn = os.environ.get("PG_DSN")
|
||||
if not dsn:
|
||||
print("ERROR: PG_DSN 未配置")
|
||||
sys.exit(1)
|
||||
|
||||
conn = psycopg2.connect(dsn, connect_timeout=10)
|
||||
cur = conn.cursor()
|
||||
|
||||
print("=" * 80)
|
||||
print("1. ODS 所有表及行数")
|
||||
print("=" * 80)
|
||||
cur.execute("""
|
||||
SELECT t.tablename,
|
||||
pg_stat_user_tables.n_live_tup AS approx_rows
|
||||
FROM pg_tables t
|
||||
LEFT JOIN pg_stat_user_tables
|
||||
ON pg_stat_user_tables.schemaname = t.schemaname
|
||||
AND pg_stat_user_tables.relname = t.tablename
|
||||
WHERE t.schemaname = 'ods'
|
||||
ORDER BY t.tablename
|
||||
""")
|
||||
tables = cur.fetchall()
|
||||
for tbl, rows in tables:
|
||||
print(f" {tbl:50s} ~{rows or 0} rows")
|
||||
|
||||
print(f"\n 共 {len(tables)} 张表")
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print("2. ODS 所有索引")
|
||||
print("=" * 80)
|
||||
cur.execute("""
|
||||
SELECT tablename, indexname, indexdef
|
||||
FROM pg_indexes
|
||||
WHERE schemaname = 'ods'
|
||||
ORDER BY tablename, indexname
|
||||
""")
|
||||
indexes = cur.fetchall()
|
||||
for tbl, idx_name, idx_def in indexes:
|
||||
print(f" [{tbl}] {idx_name}")
|
||||
print(f" {idx_def}")
|
||||
|
||||
print(f"\n 共 {len(indexes)} 个索引")
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print("3. 各表是否有 id / fetched_at / is_delete / content_hash 列")
|
||||
print("=" * 80)
|
||||
cur.execute("""
|
||||
SELECT table_name, column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'ods'
|
||||
AND column_name IN ('id', 'fetched_at', 'is_delete', 'content_hash')
|
||||
ORDER BY table_name, column_name
|
||||
""")
|
||||
col_rows = cur.fetchall()
|
||||
# 按表聚合
|
||||
from collections import defaultdict
|
||||
col_map = defaultdict(set)
|
||||
for tbl, col in col_rows:
|
||||
col_map[tbl].add(col)
|
||||
|
||||
check_cols = ['id', 'fetched_at', 'is_delete', 'content_hash']
|
||||
print(f" {'表名':50s} {'id':5s} {'fetched_at':12s} {'is_delete':10s} {'content_hash':13s}")
|
||||
print(f" {'-'*50} {'-'*5} {'-'*12} {'-'*10} {'-'*13}")
|
||||
for tbl, _ in tables:
|
||||
cols = col_map.get(tbl, set())
|
||||
flags = [('✓' if c in cols else '✗') for c in check_cols]
|
||||
print(f" {tbl:50s} {flags[0]:5s} {flags[1]:12s} {flags[2]:10s} {flags[3]:13s}")
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print("4. 各表主键定义")
|
||||
print("=" * 80)
|
||||
cur.execute("""
|
||||
SELECT tc.table_name,
|
||||
string_agg(kcu.column_name, ', ' ORDER BY kcu.ordinal_position) AS pk_cols
|
||||
FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.key_column_usage kcu
|
||||
ON tc.constraint_name = kcu.constraint_name
|
||||
AND tc.table_schema = kcu.table_schema
|
||||
WHERE tc.table_schema = 'ods'
|
||||
AND tc.constraint_type = 'PRIMARY KEY'
|
||||
GROUP BY tc.table_name
|
||||
ORDER BY tc.table_name
|
||||
""")
|
||||
pk_rows = cur.fetchall()
|
||||
for tbl, pk_cols in pk_rows:
|
||||
print(f" {tbl:50s} PK: ({pk_cols})")
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
print("\n完成。")
|
||||
61
scripts/ops/check_ods_latest_indexes.py
Normal file
61
scripts/ops/check_ods_latest_indexes.py
Normal file
@@ -0,0 +1,61 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""检查迁移脚本中定义的 (pk, fetched_at DESC) 索引是否存在于数据库中。"""
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
import psycopg2
|
||||
|
||||
env_path = Path(__file__).resolve().parents[2] / "apps" / "etl" / "pipelines" / "feiqiu" / ".env"
|
||||
load_dotenv(env_path)
|
||||
|
||||
dsn = os.environ.get("PG_DSN")
|
||||
if not dsn:
|
||||
print("ERROR: PG_DSN 未配置")
|
||||
sys.exit(1)
|
||||
|
||||
conn = psycopg2.connect(dsn, connect_timeout=10)
|
||||
cur = conn.cursor()
|
||||
|
||||
# 迁移脚本中定义的 23 个 _latest 索引
|
||||
expected = [
|
||||
"idx_ods_assistant_accounts_master_latest",
|
||||
"idx_ods_settlement_records_latest",
|
||||
"idx_ods_table_fee_transactions_latest",
|
||||
"idx_ods_assistant_service_records_latest",
|
||||
"idx_ods_assistant_cancellation_records_latest",
|
||||
"idx_ods_store_goods_sales_records_latest",
|
||||
"idx_ods_payment_transactions_latest",
|
||||
"idx_ods_refund_transactions_latest",
|
||||
"idx_ods_platform_coupon_redemption_records_latest",
|
||||
"idx_ods_member_profiles_latest",
|
||||
"idx_ods_member_stored_value_cards_latest",
|
||||
"idx_ods_member_balance_changes_latest",
|
||||
"idx_ods_recharge_settlements_latest",
|
||||
"idx_ods_group_buy_packages_latest",
|
||||
"idx_ods_group_buy_redemption_records_latest",
|
||||
"idx_ods_goods_stock_summary_latest",
|
||||
"idx_ods_goods_stock_movements_latest",
|
||||
"idx_ods_site_tables_master_latest",
|
||||
"idx_ods_stock_goods_category_tree_latest",
|
||||
"idx_ods_store_goods_master_latest",
|
||||
"idx_ods_table_fee_discount_records_latest",
|
||||
"idx_ods_tenant_goods_master_latest",
|
||||
"idx_ods_settlement_ticket_details_latest",
|
||||
]
|
||||
|
||||
cur.execute("""
|
||||
SELECT indexname FROM pg_indexes WHERE schemaname = 'ods'
|
||||
""")
|
||||
existing = {row[0] for row in cur.fetchall()}
|
||||
|
||||
print("迁移脚本 (pk, fetched_at DESC) 索引检查:")
|
||||
for idx in expected:
|
||||
status = "✓ 存在" if idx in existing else "✗ 缺失"
|
||||
print(f" {status} {idx}")
|
||||
|
||||
missing = [idx for idx in expected if idx not in existing]
|
||||
print(f"\n共 {len(expected)} 个,缺失 {len(missing)} 个")
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
BIN
scripts/ops/clone_output.txt
Normal file
BIN
scripts/ops/clone_output.txt
Normal file
Binary file not shown.
334
scripts/ops/clone_to_test_db.py
Normal file
334
scripts/ops/clone_to_test_db.py
Normal file
@@ -0,0 +1,334 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
从正式库完整镜像到测试库:
|
||||
etl_feiqiu → test_etl_feiqiu(六层 schema + 数据 + 索引 + 物化视图)
|
||||
zqyy_app → test_zqyy_app(全部表 + 数据 + 索引)
|
||||
|
||||
策略:先用 init_databases.py 的 DDL 建表,再用 COPY 协议迁移数据,
|
||||
最后迁移自定义索引和物化视图。
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import io
|
||||
import psycopg2
|
||||
|
||||
if sys.platform == "win32":
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
DB_HOST = "100.64.0.4"
|
||||
DB_PORT = 5432
|
||||
DB_USER = "local-Python"
|
||||
DB_PASS = "Neo-local-1991125"
|
||||
DB_OPTS = "-c client_encoding=UTF8"
|
||||
|
||||
# 源库 → 测试库
|
||||
CLONE_PAIRS = [
|
||||
("etl_feiqiu", "test_etl_feiqiu"),
|
||||
("zqyy_app", "test_zqyy_app"),
|
||||
]
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
def conn_to(dbname):
|
||||
return psycopg2.connect(
|
||||
host=DB_HOST, port=DB_PORT, dbname=dbname,
|
||||
user=DB_USER, password=DB_PASS, options=DB_OPTS)
|
||||
|
||||
|
||||
def execute_sql_file(conn, filepath, label=""):
|
||||
full = os.path.join(BASE_DIR, filepath)
|
||||
if not os.path.exists(full):
|
||||
print(f" [SKIP] 不存在: {filepath}")
|
||||
return False
|
||||
with open(full, "r", encoding="utf-8") as f:
|
||||
sql = f.read()
|
||||
if not sql.strip():
|
||||
return False
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(sql)
|
||||
conn.commit()
|
||||
print(f" [OK] {label or filepath}")
|
||||
return True
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
print(f" [FAIL] {label or filepath}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def get_schemas(conn):
|
||||
"""获取用户自定义 schema 列表。"""
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
SELECT nspname FROM pg_namespace
|
||||
WHERE nspname NOT LIKE 'pg_%' AND nspname != 'information_schema'
|
||||
ORDER BY nspname
|
||||
""")
|
||||
return [r[0] for r in cur.fetchall()]
|
||||
|
||||
|
||||
def get_tables(conn, schema):
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT tablename FROM pg_tables WHERE schemaname = %s ORDER BY tablename", (schema,))
|
||||
return [r[0] for r in cur.fetchall()]
|
||||
|
||||
|
||||
def get_columns(conn, schema, table):
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
SELECT column_name FROM information_schema.columns
|
||||
WHERE table_schema = %s AND table_name = %s
|
||||
ORDER BY ordinal_position
|
||||
""", (schema, table))
|
||||
return [r[0] for r in cur.fetchall()]
|
||||
|
||||
|
||||
def count_rows(conn, schema, table):
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(f'SELECT COUNT(*) FROM "{schema}"."{table}"')
|
||||
return cur.fetchone()[0]
|
||||
|
||||
|
||||
def copy_table(src, dst, schema, table):
|
||||
"""用 COPY 协议迁移单表数据。"""
|
||||
src_cols = get_columns(src, schema, table)
|
||||
dst_cols = get_columns(dst, schema, table)
|
||||
if not src_cols or not dst_cols:
|
||||
return 0
|
||||
common = [c for c in dst_cols if c in src_cols]
|
||||
if not common:
|
||||
return 0
|
||||
cols_sql = ", ".join(f'"{c}"' for c in common)
|
||||
|
||||
buf = io.BytesIO()
|
||||
with src.cursor() as cur:
|
||||
cur.copy_expert(f'COPY (SELECT {cols_sql} FROM "{schema}"."{table}") TO STDOUT WITH (FORMAT binary)', buf)
|
||||
buf.seek(0)
|
||||
if buf.getbuffer().nbytes <= 11:
|
||||
return 0
|
||||
with dst.cursor() as cur:
|
||||
cur.copy_expert(f'COPY "{schema}"."{table}" ({cols_sql}) FROM STDIN WITH (FORMAT binary)', buf)
|
||||
dst.commit()
|
||||
return count_rows(dst, schema, table)
|
||||
|
||||
|
||||
def migrate_indexes(src, dst, schema):
|
||||
"""迁移用户自定义索引。"""
|
||||
with src.cursor() as cur:
|
||||
cur.execute("""
|
||||
SELECT indexname, indexdef FROM pg_indexes
|
||||
WHERE schemaname = %s
|
||||
AND indexname NOT IN (
|
||||
SELECT conname FROM pg_constraint
|
||||
WHERE connamespace = (SELECT oid FROM pg_namespace WHERE nspname = %s))
|
||||
ORDER BY indexname
|
||||
""", (schema, schema))
|
||||
indexes = cur.fetchall()
|
||||
ok = 0
|
||||
for name, defn in indexes:
|
||||
new_def = defn.replace("CREATE INDEX", "CREATE INDEX IF NOT EXISTS", 1)
|
||||
new_def = new_def.replace("CREATE UNIQUE INDEX", "CREATE UNIQUE INDEX IF NOT EXISTS", 1)
|
||||
try:
|
||||
with dst.cursor() as cur:
|
||||
cur.execute(new_def)
|
||||
dst.commit()
|
||||
ok += 1
|
||||
except Exception as e:
|
||||
dst.rollback()
|
||||
# 物化视图索引可能因视图不存在而失败,后面会处理
|
||||
if "不存在" not in str(e) and "does not exist" not in str(e):
|
||||
print(f" 索引 {name}: {e}")
|
||||
return ok, len(indexes)
|
||||
|
||||
|
||||
def migrate_matviews(src, dst, schema):
|
||||
"""迁移物化视图(从源库获取定义,替换 schema 后在目标库创建)。"""
|
||||
with src.cursor() as cur:
|
||||
cur.execute("SELECT matviewname, definition FROM pg_matviews WHERE schemaname = %s ORDER BY matviewname", (schema,))
|
||||
mvs = cur.fetchall()
|
||||
if not mvs:
|
||||
return 0, 0
|
||||
ok = 0
|
||||
for name, defn in mvs:
|
||||
# 检查目标库是否已存在
|
||||
with dst.cursor() as cur:
|
||||
cur.execute("SELECT 1 FROM pg_matviews WHERE schemaname = %s AND matviewname = %s", (schema, name))
|
||||
if cur.fetchone():
|
||||
ok += 1
|
||||
continue
|
||||
try:
|
||||
# pg_matviews.definition 末尾可能带分号,需去掉后再拼 WITH DATA
|
||||
clean_def = defn.rstrip().rstrip(";").rstrip()
|
||||
with dst.cursor() as cur:
|
||||
cur.execute(f'CREATE MATERIALIZED VIEW "{schema}"."{name}" AS {clean_def} WITH DATA')
|
||||
dst.commit()
|
||||
ok += 1
|
||||
except Exception as e:
|
||||
dst.rollback()
|
||||
print(f" 物化视图 {name}: {e}")
|
||||
return ok, len(mvs)
|
||||
|
||||
|
||||
def init_test_etl_feiqiu(conn):
|
||||
"""用 DDL 文件初始化 test_etl_feiqiu 的六层 schema。"""
|
||||
print(" 初始化 DDL...")
|
||||
files = [
|
||||
("db/etl_feiqiu/schemas/meta.sql", "meta"),
|
||||
("db/etl_feiqiu/schemas/ods.sql", "ods"),
|
||||
("db/etl_feiqiu/schemas/dwd.sql", "dwd"),
|
||||
("db/etl_feiqiu/schemas/core.sql", "core"),
|
||||
("db/etl_feiqiu/schemas/dws.sql", "dws"),
|
||||
("db/etl_feiqiu/schemas/app.sql", "app"),
|
||||
]
|
||||
for fp, label in files:
|
||||
execute_sql_file(conn, fp, label)
|
||||
# 种子数据不导入——后面会从正式库 COPY 全量数据
|
||||
|
||||
|
||||
def init_test_zqyy_app(conn):
|
||||
"""用 DDL 文件初始化 test_zqyy_app。"""
|
||||
print(" 初始化 DDL...")
|
||||
files = [
|
||||
("db/zqyy_app/schemas/init.sql", "zqyy_app schema"),
|
||||
("db/zqyy_app/migrations/20250715_create_admin_web_tables.sql", "admin_web 迁移"),
|
||||
]
|
||||
for fp, label in files:
|
||||
execute_sql_file(conn, fp, label)
|
||||
|
||||
|
||||
def clone_database(src_name, dst_name):
|
||||
"""完整镜像一个数据库。"""
|
||||
print(f"\n{'='*60}")
|
||||
print(f"镜像: {src_name} → {dst_name}")
|
||||
print(f"{'='*60}")
|
||||
|
||||
src = conn_to(src_name)
|
||||
dst = conn_to(dst_name)
|
||||
|
||||
# 步骤 1: 初始化 DDL
|
||||
if dst_name == "test_etl_feiqiu":
|
||||
init_test_etl_feiqiu(dst)
|
||||
elif dst_name == "test_zqyy_app":
|
||||
init_test_zqyy_app(dst)
|
||||
|
||||
# 步骤 2: 迁移数据
|
||||
print("\n 迁移数据...")
|
||||
schemas = get_schemas(src)
|
||||
# 只迁移源库中有表的 schema
|
||||
total_rows = 0
|
||||
total_tables = 0
|
||||
for schema in schemas:
|
||||
tables = get_tables(src, schema)
|
||||
if not tables:
|
||||
continue
|
||||
# 确保目标库有这个 schema
|
||||
with dst.cursor() as cur:
|
||||
cur.execute(f'CREATE SCHEMA IF NOT EXISTS "{schema}"')
|
||||
dst.commit()
|
||||
|
||||
for t in tables:
|
||||
s_cnt = count_rows(src, schema, t)
|
||||
if s_cnt == 0:
|
||||
continue
|
||||
# 检查目标表是否存在
|
||||
dst_cols = get_columns(dst, schema, t)
|
||||
if not dst_cols:
|
||||
continue
|
||||
# 检查是否已有数据
|
||||
d_cnt = count_rows(dst, schema, t)
|
||||
if d_cnt >= s_cnt:
|
||||
total_rows += d_cnt
|
||||
total_tables += 1
|
||||
continue
|
||||
if d_cnt > 0:
|
||||
with dst.cursor() as cur:
|
||||
cur.execute(f'TRUNCATE "{schema}"."{t}" CASCADE')
|
||||
dst.commit()
|
||||
try:
|
||||
migrated = copy_table(src, dst, schema, t)
|
||||
total_rows += migrated
|
||||
total_tables += 1
|
||||
if migrated != s_cnt:
|
||||
print(f" ⚠ {schema}.{t}: src={s_cnt} dst={migrated}")
|
||||
except Exception as e:
|
||||
dst.rollback()
|
||||
print(f" ✗ {schema}.{t}: {e}")
|
||||
|
||||
print(f" 数据: {total_tables} 表, {total_rows} 行")
|
||||
|
||||
# 步骤 3: 物化视图
|
||||
print("\n 迁移物化视图...")
|
||||
for schema in schemas:
|
||||
ok, total = migrate_matviews(src, dst, schema)
|
||||
if total > 0:
|
||||
print(f" {schema}: {ok}/{total}")
|
||||
|
||||
# 步骤 4: 索引
|
||||
print("\n 迁移索引...")
|
||||
total_idx = 0
|
||||
for schema in schemas:
|
||||
ok, total = migrate_indexes(src, dst, schema)
|
||||
total_idx += ok
|
||||
if total > 0:
|
||||
print(f" {schema}: {ok}/{total}")
|
||||
print(f" 索引: {total_idx} 个")
|
||||
|
||||
# 步骤 5: ANALYZE
|
||||
print("\n ANALYZE...")
|
||||
dst.autocommit = True
|
||||
with dst.cursor() as cur:
|
||||
for schema in schemas:
|
||||
cur.execute(f"""
|
||||
SELECT tablename FROM pg_tables WHERE schemaname = '{schema}'
|
||||
UNION ALL
|
||||
SELECT matviewname FROM pg_matviews WHERE schemaname = '{schema}'
|
||||
""")
|
||||
for (obj,) in cur.fetchall():
|
||||
cur.execute(f'ANALYZE "{schema}"."{obj}"')
|
||||
dst.autocommit = False
|
||||
print(" ANALYZE 完成")
|
||||
|
||||
# 步骤 6: 验证
|
||||
print("\n 验证...")
|
||||
all_ok = True
|
||||
for schema in schemas:
|
||||
tables = get_tables(src, schema)
|
||||
for t in tables:
|
||||
s = count_rows(src, schema, t)
|
||||
if s == 0:
|
||||
continue
|
||||
dst_cols = get_columns(dst, schema, t)
|
||||
if not dst_cols:
|
||||
print(f" MISS {schema}.{t}")
|
||||
all_ok = False
|
||||
continue
|
||||
d = count_rows(dst, schema, t)
|
||||
if d != s:
|
||||
print(f" FAIL {schema}.{t}: src={s} dst={d}")
|
||||
all_ok = False
|
||||
|
||||
if all_ok:
|
||||
print(" ✓ 全部一致")
|
||||
else:
|
||||
print(" ✗ 存在不一致")
|
||||
|
||||
src.close()
|
||||
dst.close()
|
||||
return all_ok
|
||||
|
||||
|
||||
def main():
|
||||
results = {}
|
||||
for src_name, dst_name in CLONE_PAIRS:
|
||||
results[dst_name] = clone_database(src_name, dst_name)
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
for db, ok in results.items():
|
||||
print(f" {db}: {'OK' if ok else 'FAIL'}")
|
||||
print(f"{'='*60}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1354
scripts/ops/dataflow_analyzer.py
Normal file
1354
scripts/ops/dataflow_analyzer.py
Normal file
File diff suppressed because it is too large
Load Diff
43
scripts/ops/fix_admin_site_id.py
Normal file
43
scripts/ops/fix_admin_site_id.py
Normal file
@@ -0,0 +1,43 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""修复 admin_users 表中 admin 用户的 site_id(从 1 → 2790685415443269)。
|
||||
|
||||
根因:admin 用户创建时 site_id 被设为 1,但 meta.etl_task 中任务注册的
|
||||
store_id 是 2790685415443269。JWT 中的 site_id 会被注入到 CLI --store-id,
|
||||
导致 _load_task_config 查询不到任何任务。
|
||||
"""
|
||||
import psycopg2
|
||||
|
||||
DSN = "postgresql://local-Python:Neo-local-1991125@100.64.0.4:5432/test_zqyy_app"
|
||||
CORRECT_SITE_ID = 2790685415443269
|
||||
|
||||
def main():
|
||||
conn = psycopg2.connect(DSN, connect_timeout=10)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# 先查看当前状态
|
||||
cur.execute("SELECT id, username, site_id FROM admin_users")
|
||||
rows = cur.fetchall()
|
||||
print("修复前:")
|
||||
for r in rows:
|
||||
print(f" id={r[0]} username={r[1]} site_id={r[2]}")
|
||||
|
||||
# 更新 site_id
|
||||
cur.execute(
|
||||
"UPDATE admin_users SET site_id = %s WHERE site_id = 1",
|
||||
(CORRECT_SITE_ID,),
|
||||
)
|
||||
updated = cur.rowcount
|
||||
conn.commit()
|
||||
print(f"\n已更新 {updated} 条记录的 site_id → {CORRECT_SITE_ID}")
|
||||
|
||||
# 验证
|
||||
cur.execute("SELECT id, username, site_id FROM admin_users")
|
||||
rows = cur.fetchall()
|
||||
print("\n修复后:")
|
||||
for r in rows:
|
||||
print(f" id={r[0]} username={r[1]} site_id={r[2]}")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
292
scripts/ops/fix_board_coach_dims.py
Normal file
292
scripts/ops/fix_board_coach_dims.py
Normal file
@@ -0,0 +1,292 @@
|
||||
"""
|
||||
board-coach.html: 将单一助教列表替换为按筛选分类的多个 dim-container,
|
||||
并添加 JS 切换逻辑。同时修复 board-customer.html 各维度指数标签。
|
||||
"""
|
||||
import re
|
||||
|
||||
# ============================================================
|
||||
# 1. board-coach.html — 多维度切换
|
||||
# ============================================================
|
||||
filepath = "docs/h5_ui/pages/board-coach.html"
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
# 定位旧的助教列表 + 隐藏样式区域
|
||||
old_start = ' <!-- 助教列表'
|
||||
old_end = ' <!-- 悬浮助手按钮 -->'
|
||||
start_idx = content.index(old_start)
|
||||
end_idx = content.index(old_end)
|
||||
|
||||
# 6 位助教的基础信息(复用)
|
||||
coaches = [
|
||||
("小", "小燕", "from-blue-400 to-indigo-500", "星级", "bg-gradient-to-r from-amber-400 to-orange-400 text-white", "中🎱", "bg-primary/10 text-primary", ""),
|
||||
("泡", "泡芙", "from-green-400 to-emerald-500", "高级", "bg-gradient-to-r from-purple-400 to-violet-400 text-white", "斯诺克", "bg-success/10 text-success", ""),
|
||||
("A", "Amy", "from-pink-400 to-rose-500", "星级", "bg-gradient-to-r from-amber-400 to-orange-400 text-white", "中🎱", "bg-primary/10 text-primary", '<span class="px-1.5 py-0.5 bg-success/10 text-success text-xs rounded flex-shrink-0">斯诺克</span>'),
|
||||
("M", "Mia", "from-amber-400 to-orange-500", "中级", "bg-gradient-to-r from-blue-400 to-indigo-400 text-white", "麻将", "bg-warning/10 text-warning", ""),
|
||||
("糖", "糖糖", "from-purple-400 to-violet-500", "初级", "bg-gradient-to-r from-gray-400 to-gray-500 text-white", "中🎱", "bg-primary/10 text-primary", ""),
|
||||
("露", "露露", "from-cyan-400 to-teal-500", "中级", "bg-gradient-to-r from-blue-400 to-indigo-400 text-white", "团建", "bg-error/10 text-error", ""),
|
||||
]
|
||||
|
||||
clients = [
|
||||
("💖 王先生", "💖 李女士", "💛 赵总"),
|
||||
("💖 陈先生", "💛 刘女士", "💛 黄总"),
|
||||
("💖 张先生", "💛 周女士", "💛 吴总"),
|
||||
("💛 赵先生", "💛 吴女士", "💛 孙总"),
|
||||
("💛 钱先生", "💛 孙女士", "💛 周总"),
|
||||
("💛 郑先生", "💛 冯女士", "💛 陈总"),
|
||||
]
|
||||
|
||||
def coach_header(i):
|
||||
"""生成助教卡片的头像+昵称+标签行(通用)"""
|
||||
ch, name, grad, level, lvl_cls, skill, sk_cls, extra = coaches[i]
|
||||
return f''' <div class="w-11 h-11 rounded-full bg-gradient-to-br {grad} flex items-center justify-center flex-shrink-0">
|
||||
<span class="text-white font-semibold text-base">{ch}</span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-1.5 flex-wrap">
|
||||
<span class="text-base font-semibold text-gray-13">{name}</span>
|
||||
<span class="px-1.5 py-0.5 {lvl_cls} text-xs rounded flex-shrink-0">{level}</span>
|
||||
<span class="px-1.5 py-0.5 {sk_cls} text-xs rounded flex-shrink-0">{skill}</span>
|
||||
{extra}
|
||||
</div>'''
|
||||
|
||||
def coach_row2(i, right_html):
|
||||
"""生成第二行:客户 + 右侧数据"""
|
||||
c1, c2, c3 = clients[i]
|
||||
return f''' <div class="flex items-center justify-between text-xs pt-2 border-t border-gray-100">
|
||||
<div class="flex items-center gap-2 text-gray-6 truncate">
|
||||
<span>{c1}</span><span>{c2}</span><span>{c3}</span>
|
||||
</div>
|
||||
{right_html}
|
||||
</div>'''
|
||||
|
||||
# 定档业绩数据
|
||||
perf_data = [
|
||||
("86.2", "13.8", "92.0"),
|
||||
("72.5", "7.5", "78.0"),
|
||||
("68.0", "32.0", "72.5"),
|
||||
("55.0", "5.0", ""),
|
||||
("42.0", "达标", "45.0"),
|
||||
("38.0", "22.0", ""),
|
||||
]
|
||||
perf_levels = [
|
||||
("星级", "王牌"),
|
||||
("高级", "星级"),
|
||||
("星级", "王牌"),
|
||||
("中级", "高级"),
|
||||
("初级", "中级"),
|
||||
("中级", "高级"),
|
||||
]
|
||||
|
||||
def make_perf_card(i):
|
||||
"""定档业绩最高/最低 卡片"""
|
||||
h, need, pre = perf_data[i]
|
||||
cur, nxt = perf_levels[i]
|
||||
if need == "达标":
|
||||
data_line = f''' <div class="mt-1 flex items-center gap-2 text-xs">
|
||||
<span class="font-bold text-success text-sm">{h}h</span>
|
||||
<span class="text-success font-medium">✅ 已达标</span>
|
||||
</div>'''
|
||||
else:
|
||||
data_line = f''' <div class="mt-1 flex items-center gap-2 text-xs">
|
||||
<span class="font-bold text-primary text-sm">{h}h</span>
|
||||
<span class="text-gray-7">下一档还需 <span class="text-warning font-medium">{need}h</span></span>
|
||||
</div>'''
|
||||
pre_html = f'<span class="text-gray-5">|</span>\n <span>折前 <b class="text-gray-10">{pre}h</b></span>' if pre else ''
|
||||
right = f'''<div class="flex items-center gap-2 text-gray-7 flex-shrink-0">
|
||||
<span>定档 <b class="text-gray-10">{h}h</b></span>
|
||||
{pre_html}
|
||||
</div>'''
|
||||
return f''' <a href="coach-detail.html" class="block bg-white rounded-2xl p-4 shadow-sm coach-card">
|
||||
<div class="flex items-start gap-3 mb-2.5">
|
||||
{coach_header(i)}
|
||||
{data_line}
|
||||
</div>
|
||||
</div>
|
||||
{coach_row2(i, right)}
|
||||
</a>'''
|
||||
|
||||
# 工资数据
|
||||
salary_data = ["¥12,680", "¥10,200", "¥9,800", "¥7,500", "¥6,200", "¥5,100"]
|
||||
|
||||
def make_salary_card(i):
|
||||
"""工资最高/最低 卡片"""
|
||||
sal = salary_data[i]
|
||||
h = perf_data[i][0]
|
||||
pre = perf_data[i][2]
|
||||
pre_html = f'<span class="text-gray-5">|</span>\n <span>折前 <b class="text-gray-10">{pre}h</b></span>' if pre else ''
|
||||
right = f'''<div class="flex items-center gap-2 text-gray-7 flex-shrink-0">
|
||||
<span>定档 <b class="text-gray-10">{h}h</b></span>
|
||||
{pre_html}
|
||||
</div>'''
|
||||
return f''' <a href="coach-detail.html" class="block bg-white rounded-2xl p-4 shadow-sm coach-card">
|
||||
<div class="flex items-start gap-3 mb-2.5">
|
||||
{coach_header(i)}
|
||||
<div class="mt-1 flex items-center gap-2">
|
||||
<span class="text-lg font-semibold text-gray-13">{sal}</span>
|
||||
<span class="px-1.5 py-0.5 bg-warning/10 text-warning text-xs rounded">预估</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{coach_row2(i, right)}
|
||||
</a>'''
|
||||
|
||||
# 客源储值数据
|
||||
sv_balance = ["¥45,200", "¥38,600", "¥32,100", "¥28,500", "¥22,000", "¥18,300"]
|
||||
sv_consume = ["¥8,600", "¥6,200", "¥5,800", "¥4,100", "¥3,500", "¥2,800"]
|
||||
|
||||
def make_sv_card(i):
|
||||
"""客源储值最高 卡片"""
|
||||
right = f'''<div class="flex items-center gap-2 text-gray-7 flex-shrink-0">
|
||||
<span>周期消耗 <b class="text-gray-10">{sv_consume[i]}</b></span>
|
||||
</div>'''
|
||||
return f''' <a href="coach-detail.html" class="block bg-white rounded-2xl p-4 shadow-sm coach-card">
|
||||
<div class="flex items-start gap-3 mb-2.5">
|
||||
{coach_header(i)}
|
||||
<div class="mt-1">
|
||||
<span class="text-lg font-semibold text-gray-13">{sv_balance[i]}</span>
|
||||
<span class="text-xs text-gray-6 ml-1">客户储值余额</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{coach_row2(i, right)}
|
||||
</a>'''
|
||||
|
||||
# 任务完成数据
|
||||
task_counts = [(32, 18), (28, 15), (25, 14), (20, 12), (18, 10), (15, 9)]
|
||||
|
||||
def make_task_card(i):
|
||||
"""任务完成最多 卡片"""
|
||||
tc, cc = task_counts[i]
|
||||
h = perf_data[i][0]
|
||||
pre = perf_data[i][2]
|
||||
pre_html = f'<span class="text-gray-5">|</span>\n <span>折前 <b class="text-gray-10">{pre}h</b></span>' if pre else ''
|
||||
right = f'''<div class="flex items-center gap-2 text-gray-7 flex-shrink-0">
|
||||
<span>定档 <b class="text-gray-10">{h}h</b></span>
|
||||
{pre_html}
|
||||
</div>'''
|
||||
return f''' <a href="coach-detail.html" class="block bg-white rounded-2xl p-4 shadow-sm coach-card">
|
||||
<div class="flex items-start gap-3 mb-2.5">
|
||||
{coach_header(i)}
|
||||
<div class="mt-1 flex items-center gap-3">
|
||||
<div><span class="text-lg font-semibold text-primary">{tc}</span><span class="text-xs text-gray-6 ml-0.5">个任务</span></div>
|
||||
<div class="w-px h-4 bg-gray-3"></div>
|
||||
<div><span class="text-lg font-semibold text-gray-13">{cc}</span><span class="text-xs text-gray-6 ml-0.5">位客户</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{coach_row2(i, right)}
|
||||
</a>'''
|
||||
|
||||
# 生成各维度容器
|
||||
dims = []
|
||||
|
||||
# 定档业绩最高
|
||||
cards = "\n\n".join(make_perf_card(i) for i in range(6))
|
||||
dims.append(f''' <!-- ====== 定档业绩最高/最低 ====== -->
|
||||
<div id="dim-perf" class="dim-container active p-4 space-y-3">
|
||||
{cards}
|
||||
</div>''')
|
||||
|
||||
# 工资最高
|
||||
cards = "\n\n".join(make_salary_card(i) for i in range(6))
|
||||
dims.append(f''' <!-- ====== 工资最高/最低 ====== -->
|
||||
<div id="dim-salary" class="dim-container p-4 space-y-3">
|
||||
{cards}
|
||||
</div>''')
|
||||
|
||||
# 客源储值最高
|
||||
cards = "\n\n".join(make_sv_card(i) for i in range(6))
|
||||
dims.append(f''' <!-- ====== 客源储值最高 ====== -->
|
||||
<div id="dim-sv" class="dim-container p-4 space-y-3">
|
||||
{cards}
|
||||
</div>''')
|
||||
|
||||
# 任务完成最多
|
||||
cards = "\n\n".join(make_task_card(i) for i in range(6))
|
||||
dims.append(f''' <!-- ====== 任务完成最多 ====== -->
|
||||
<div id="dim-task" class="dim-container p-4 space-y-3">
|
||||
{cards}
|
||||
</div>''')
|
||||
|
||||
new_section = "\n\n".join(dims) + "\n\n"
|
||||
|
||||
content = content[:start_idx] + new_section + content[end_idx:]
|
||||
|
||||
# 添加 dim-container CSS(如果不存在)
|
||||
if '.dim-container' not in content:
|
||||
content = content.replace(
|
||||
'.coach-card:active {',
|
||||
'.dim-container { display: none; }\n .dim-container.active { display: block; }\n .coach-card:active {'
|
||||
)
|
||||
|
||||
# 替换 selectSort JS 函数,添加维度切换逻辑
|
||||
old_select_sort = ''' function selectSort(value) {
|
||||
document.getElementById('sortLabel').textContent = value;
|
||||
closeAllFilters();
|
||||
}'''
|
||||
|
||||
new_select_sort = ''' function selectSort(value) {
|
||||
document.getElementById('sortLabel').textContent = value;
|
||||
closeAllFilters();
|
||||
// 切换维度容器
|
||||
var dimMap = {
|
||||
'定档业绩最高': 'dim-perf',
|
||||
'定档业绩最低': 'dim-perf',
|
||||
'工资最高': 'dim-salary',
|
||||
'工资最低': 'dim-salary',
|
||||
'客源储值最高': 'dim-sv',
|
||||
'任务完成最多': 'dim-task'
|
||||
};
|
||||
document.querySelectorAll('.dim-container').forEach(function(el) { el.classList.remove('active'); });
|
||||
var id = dimMap[value];
|
||||
if (id) document.getElementById(id).classList.add('active');
|
||||
}'''
|
||||
|
||||
content = content.replace(old_select_sort, new_select_sort)
|
||||
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
print("OK — board-coach.html: 4 dim-containers + JS 切换")
|
||||
|
||||
# ============================================================
|
||||
# 2. board-customer.html — 修正各维度的指数标签
|
||||
# ============================================================
|
||||
filepath2 = "docs/h5_ui/pages/board-customer.html"
|
||||
with open(filepath2, "r", encoding="utf-8") as f:
|
||||
c2 = f.read()
|
||||
|
||||
# 最高余额维度:指数标签应该是"余额排名"而不是"消费潜力指数"
|
||||
# 最频繁维度:应该是"到店频率"
|
||||
# 最近到店:应该是"到店新鲜度"
|
||||
# 最专一:应该是"专一指数"
|
||||
# 最高消费近60天:应该是"消费力指数"
|
||||
|
||||
replacements = [
|
||||
# dim-balance 区域
|
||||
('id="dim-balance"', '消费潜力指数', '余额排名'),
|
||||
# dim-freq60 区域
|
||||
('id="dim-freq60"', '消费潜力指数', '到店频率'),
|
||||
# dim-recent 区域
|
||||
('id="dim-recent"', '消费潜力指数', '到店新鲜度'),
|
||||
# dim-loyal 区域
|
||||
('id="dim-loyal"', '消费潜力指数', '专一指数'),
|
||||
# dim-spend60 区域
|
||||
('id="dim-spend60"', '消费潜力指数', '消费力指数'),
|
||||
]
|
||||
|
||||
for dim_id, old_label, new_label in replacements:
|
||||
# 找到该维度区域的起始位置
|
||||
dim_start = c2.index(dim_id)
|
||||
# 找到下一个维度或文件末尾
|
||||
next_dim = c2.find('dim-container', dim_start + 50)
|
||||
if next_dim == -1:
|
||||
next_dim = len(c2)
|
||||
# 在该区域内替换标签
|
||||
section = c2[dim_start:next_dim]
|
||||
section = section.replace(old_label, new_label)
|
||||
c2 = c2[:dim_start] + section + c2[next_dim:]
|
||||
|
||||
with open(filepath2, "w", encoding="utf-8") as f:
|
||||
f.write(c2)
|
||||
print("OK — board-customer.html: 各维度指数标签已修正")
|
||||
55
scripts/ops/fix_date_dividers.py
Normal file
55
scripts/ops/fix_date_dividers.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""批量替换 performance.html 中的日期分割线,加上时长和收入信息"""
|
||||
import re, random
|
||||
|
||||
filepath = "docs/h5_ui/pages/performance.html"
|
||||
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
# 2月日期 → 当月,显示"预估收入"
|
||||
# 1月日期 → 上月,显示"收入"
|
||||
feb_stats = {
|
||||
"2月7日": "时长 4.0h · 预估收入 ¥350",
|
||||
"2月6日": "时长 3.5h · 预估收入 ¥280",
|
||||
"2月5日": "时长 4.0h · 预估收入 ¥320",
|
||||
"2月4日": "时长 4.0h · 预估收入 ¥350",
|
||||
"2月3日": "时长 3.5h · 预估收入 ¥280",
|
||||
"2月2日": "时长 4.0h · 预估收入 ¥350",
|
||||
"2月1日": "时长 6.0h · 预估收入 ¥510",
|
||||
}
|
||||
jan_stats = {
|
||||
"1月31日": "时长 5.5h · 收入 ¥470",
|
||||
"1月30日": "时长 3.5h · 收入 ¥280",
|
||||
"1月29日": "时长 4.0h · 收入 ¥320",
|
||||
"1月28日": "时长 4.0h · 收入 ¥350",
|
||||
"1月27日": "时长 4.0h · 收入 ¥350",
|
||||
"1月26日": "时长 2.0h · 收入 ¥160",
|
||||
"1月25日": "时长 2.0h · 收入 ¥160",
|
||||
"1月24日": "时长 1.5h · 收入 ¥120",
|
||||
"1月23日": "时长 2.0h · 收入 ¥160",
|
||||
"1月22日": "时长 2.0h · 收入 ¥190",
|
||||
"1月21日": "时长 2.0h · 收入 ¥160",
|
||||
"1月20日": "时长 3.5h · 收入 ¥280",
|
||||
"1月19日": "时长 4.0h · 收入 ¥350",
|
||||
"1月18日": "时长 2.0h · 收入 ¥160",
|
||||
"1月17日": "时长 3.5h · 收入 ¥280",
|
||||
"1月16日": "时长 4.0h · 收入 ¥350",
|
||||
"1月15日": "时长 2.0h · 收入 ¥190",
|
||||
}
|
||||
all_stats = {**feb_stats, **jan_stats}
|
||||
|
||||
# 匹配 <div class="date-divider" ...><span>日期</span></div>
|
||||
pattern = r'<div class="date-divider"([^>]*)><span>([^<]+)</span></div>'
|
||||
|
||||
def replacer(m):
|
||||
attrs = m.group(1)
|
||||
date_text = m.group(2)
|
||||
stats = all_stats.get(date_text, f"时长 2.0h · 收入 ¥160")
|
||||
return f'<div class="date-divider"{attrs}><span class="dd-date">{date_text}</span><div class="dd-line"></div><span class="dd-stats">{stats}</span></div>'
|
||||
|
||||
new_content = re.sub(pattern, replacer, content)
|
||||
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
f.write(new_content)
|
||||
|
||||
print(f"Done. Replaced date dividers in {filepath}")
|
||||
64
scripts/ops/fix_fdw_test.py
Normal file
64
scripts/ops/fix_fdw_test.py
Normal file
@@ -0,0 +1,64 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
修复 test_zqyy_app 的 FDW:
|
||||
1. 为 local-Python 添加 user mapping(IMPORT 需要当前用户有映射)
|
||||
2. 重新 IMPORT FOREIGN SCHEMA
|
||||
"""
|
||||
import psycopg2
|
||||
|
||||
CONN = dict(host="100.64.0.4", port=5432, user="local-Python", password="Neo-local-1991125")
|
||||
|
||||
conn = psycopg2.connect(**CONN, dbname="test_zqyy_app")
|
||||
conn.autocommit = True
|
||||
cur = conn.cursor()
|
||||
|
||||
steps = [
|
||||
# 为执行用户添加 user mapping(用超级用户身份连远程库)
|
||||
(
|
||||
"CREATE USER MAPPING IF NOT EXISTS FOR \"local-Python\" "
|
||||
"SERVER test_etl_feiqiu_server "
|
||||
"OPTIONS (user 'local-Python', password 'Neo-local-1991125')",
|
||||
"添加 local-Python user mapping"
|
||||
),
|
||||
# 导入外部表
|
||||
(
|
||||
"IMPORT FOREIGN SCHEMA app FROM SERVER test_etl_feiqiu_server INTO fdw_etl",
|
||||
"导入 test_etl_feiqiu.app 外部表"
|
||||
),
|
||||
]
|
||||
|
||||
for sql, label in steps:
|
||||
try:
|
||||
cur.execute(sql)
|
||||
print(f"[OK] {label}")
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
msg = str(e).strip().split("\n")[0]
|
||||
print(f"[FAIL] {label}: {msg}")
|
||||
|
||||
# 验证
|
||||
cur.execute(
|
||||
"SELECT count(*) FROM information_schema.tables "
|
||||
"WHERE table_schema = 'fdw_etl'"
|
||||
)
|
||||
count = cur.fetchone()[0]
|
||||
print(f"\n验证: fdw_etl 外部表数 = {count}")
|
||||
|
||||
# 同时给 zqyy_app 也加上 local-Python mapping(方便调试)
|
||||
conn.close()
|
||||
conn = psycopg2.connect(**CONN, dbname="zqyy_app")
|
||||
conn.autocommit = True
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
cur.execute(
|
||||
"CREATE USER MAPPING IF NOT EXISTS FOR \"local-Python\" "
|
||||
"SERVER etl_feiqiu_server "
|
||||
"OPTIONS (user 'local-Python', password 'Neo-local-1991125')"
|
||||
)
|
||||
print("[OK] zqyy_app: 添加 local-Python user mapping")
|
||||
except Exception as e:
|
||||
msg = str(e).strip().split("\n")[0]
|
||||
print(f"[SKIP] zqyy_app: {msg}")
|
||||
|
||||
conn.close()
|
||||
print("\n完成!")
|
||||
174
scripts/ops/fix_test_db.py
Normal file
174
scripts/ops/fix_test_db.py
Normal file
@@ -0,0 +1,174 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
修复 test_etl_feiqiu:补齐 meta 数据 + 创建物化视图 + 索引 + ANALYZE
|
||||
"""
|
||||
import sys
|
||||
import io
|
||||
import psycopg2
|
||||
|
||||
if sys.platform == "win32":
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
DB = dict(host="100.64.0.4", port=5432, user="local-Python",
|
||||
password="Neo-local-1991125", options="-c client_encoding=UTF8")
|
||||
|
||||
|
||||
def conn(dbname):
|
||||
return psycopg2.connect(dbname=dbname, **DB)
|
||||
|
||||
|
||||
def count(c, schema, table):
|
||||
with c.cursor() as cur:
|
||||
cur.execute(f'SELECT COUNT(*) FROM "{schema}"."{table}"')
|
||||
return cur.fetchone()[0]
|
||||
|
||||
|
||||
def get_columns(c, schema, table):
|
||||
with c.cursor() as cur:
|
||||
cur.execute("""SELECT column_name FROM information_schema.columns
|
||||
WHERE table_schema=%s AND table_name=%s
|
||||
ORDER BY ordinal_position""", (schema, table))
|
||||
return [r[0] for r in cur.fetchall()]
|
||||
|
||||
|
||||
def copy_table(src, dst, schema, table):
|
||||
src_cols = get_columns(src, schema, table)
|
||||
dst_cols = get_columns(dst, schema, table)
|
||||
common = [c for c in dst_cols if c in src_cols]
|
||||
if not common:
|
||||
return 0
|
||||
cols = ", ".join(f'"{c}"' for c in common)
|
||||
# TRUNCATE 先清空
|
||||
with dst.cursor() as cur:
|
||||
cur.execute(f'TRUNCATE "{schema}"."{table}" CASCADE')
|
||||
dst.commit()
|
||||
# COPY
|
||||
buf = io.BytesIO()
|
||||
with src.cursor() as cur:
|
||||
cur.copy_expert(f'COPY (SELECT {cols} FROM "{schema}"."{table}") TO STDOUT WITH (FORMAT binary)', buf)
|
||||
buf.seek(0)
|
||||
if buf.getbuffer().nbytes <= 11:
|
||||
return 0
|
||||
with dst.cursor() as cur:
|
||||
cur.copy_expert(f'COPY "{schema}"."{table}" ({cols}) FROM STDIN WITH (FORMAT binary)', buf)
|
||||
dst.commit()
|
||||
return count(dst, schema, table)
|
||||
|
||||
|
||||
def main():
|
||||
src = conn("etl_feiqiu")
|
||||
dst = conn("test_etl_feiqiu")
|
||||
|
||||
# ── 1. 补齐 meta 数据 ──
|
||||
print("=== 补齐 meta 数据 ===")
|
||||
for t in ["etl_cursor", "etl_run", "etl_task"]:
|
||||
s = count(src, "meta", t)
|
||||
d = count(dst, "meta", t)
|
||||
if d >= s and s > 0:
|
||||
print(f" {t}: 已一致 ({d} 行)")
|
||||
continue
|
||||
if s == 0:
|
||||
print(f" {t}: 源为空,跳过")
|
||||
continue
|
||||
rows = copy_table(src, dst, "meta", t)
|
||||
print(f" {t}: {s} → {rows}")
|
||||
|
||||
# ── 2. 创建物化视图 ──
|
||||
print("\n=== 创建物化视图 ===")
|
||||
with src.cursor() as cur:
|
||||
cur.execute("SELECT matviewname, definition FROM pg_matviews WHERE schemaname='dws' ORDER BY 1")
|
||||
mvs = cur.fetchall()
|
||||
|
||||
for name, defn in mvs:
|
||||
with dst.cursor() as cur:
|
||||
cur.execute("SELECT 1 FROM pg_matviews WHERE schemaname='dws' AND matviewname=%s", (name,))
|
||||
if cur.fetchone():
|
||||
print(f" {name}: 已存在")
|
||||
continue
|
||||
# 去掉末尾分号
|
||||
clean = defn.rstrip().rstrip(";").rstrip()
|
||||
try:
|
||||
with dst.cursor() as cur:
|
||||
cur.execute(f'CREATE MATERIALIZED VIEW dws."{name}" AS {clean} WITH DATA')
|
||||
dst.commit()
|
||||
rows = count(dst, "dws", name)
|
||||
print(f" {name}: 创建成功 ({rows} 行)")
|
||||
except Exception as e:
|
||||
dst.rollback()
|
||||
print(f" {name}: 失败 - {e}")
|
||||
|
||||
# ── 3. 物化视图索引 ──
|
||||
print("\n=== 物化视图索引 ===")
|
||||
mv_indexes = [
|
||||
"CREATE INDEX IF NOT EXISTS idx_mv_assistant_daily_l1 ON dws.mv_dws_assistant_daily_detail_l1 (site_id, stat_date, assistant_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_mv_assistant_daily_l2 ON dws.mv_dws_assistant_daily_detail_l2 (site_id, stat_date, assistant_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_mv_assistant_daily_l3 ON dws.mv_dws_assistant_daily_detail_l3 (site_id, stat_date, assistant_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_mv_assistant_daily_l4 ON dws.mv_dws_assistant_daily_detail_l4 (site_id, stat_date, assistant_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_mv_finance_daily_l1 ON dws.mv_dws_finance_daily_summary_l1 (site_id, stat_date)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_mv_finance_daily_l2 ON dws.mv_dws_finance_daily_summary_l2 (site_id, stat_date)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_mv_finance_daily_l3 ON dws.mv_dws_finance_daily_summary_l3 (site_id, stat_date)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_mv_finance_daily_l4 ON dws.mv_dws_finance_daily_summary_l4 (site_id, stat_date)",
|
||||
]
|
||||
for sql in mv_indexes:
|
||||
idx = sql.split("EXISTS ")[1].split(" ON ")[0]
|
||||
try:
|
||||
with dst.cursor() as cur:
|
||||
cur.execute(sql)
|
||||
dst.commit()
|
||||
print(f" {idx}: OK")
|
||||
except Exception as e:
|
||||
dst.rollback()
|
||||
print(f" {idx}: {e}")
|
||||
|
||||
# ── 4. ANALYZE ──
|
||||
print("\n=== ANALYZE ===")
|
||||
dst.autocommit = True
|
||||
with dst.cursor() as cur:
|
||||
for schema in ["ods", "dwd", "dws", "meta", "core", "app"]:
|
||||
cur.execute(f"""
|
||||
SELECT tablename FROM pg_tables WHERE schemaname='{schema}'
|
||||
UNION ALL
|
||||
SELECT matviewname FROM pg_matviews WHERE schemaname='{schema}'
|
||||
""")
|
||||
objs = [r[0] for r in cur.fetchall()]
|
||||
for o in objs:
|
||||
cur.execute(f'ANALYZE "{schema}"."{o}"')
|
||||
print(f" {schema}: {len(objs)} 个对象")
|
||||
dst.autocommit = False
|
||||
|
||||
# ── 5. 最终验证 ──
|
||||
print("\n=== 最终验证 ===")
|
||||
ok = True
|
||||
for schema in ["ods", "dwd", "dws", "meta"]:
|
||||
with src.cursor() as cur:
|
||||
cur.execute("SELECT tablename FROM pg_tables WHERE schemaname=%s ORDER BY 1", (schema,))
|
||||
tables = [r[0] for r in cur.fetchall()]
|
||||
for t in tables:
|
||||
s = count(src, schema, t)
|
||||
if s == 0:
|
||||
continue
|
||||
d = count(dst, schema, t)
|
||||
tag = "OK" if d == s else "FAIL"
|
||||
if tag == "FAIL":
|
||||
ok = False
|
||||
print(f" {tag:4s} {schema}.{t}: src={s} dst={d}")
|
||||
|
||||
# 物化视图
|
||||
with dst.cursor() as cur:
|
||||
cur.execute("SELECT matviewname FROM pg_matviews WHERE schemaname='dws' ORDER BY 1")
|
||||
mv_names = [r[0] for r in cur.fetchall()]
|
||||
print(f"\n 物化视图: {len(mv_names)} 个")
|
||||
for n in mv_names:
|
||||
r = count(dst, "dws", n)
|
||||
print(f" {n}: {r} 行")
|
||||
|
||||
print(f"\n{'='*50}")
|
||||
print("全部通过" if ok else "存在不一致")
|
||||
|
||||
src.close()
|
||||
dst.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
398
scripts/ops/gen_api_field_mapping.py
Normal file
398
scripts/ops/gen_api_field_mapping.py
Normal file
@@ -0,0 +1,398 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
从数据库 payload 字段提取 API 原始 JSON 字段,生成 API 源字段 → ODS 映射文档。
|
||||
直接从 API 返回的 JSON 分析,不依赖处理代码。
|
||||
|
||||
用法: python scripts/ops/gen_api_field_mapping.py
|
||||
输出: 在 docs/reports/dataflow_api_ods_dwd.md 的每个表章节中插入 API 源字段小节
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from collections import OrderedDict
|
||||
from pathlib import Path
|
||||
|
||||
import psycopg2
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
INPUT_DOC = ROOT / "docs" / "reports" / "dataflow_api_ods_dwd.md"
|
||||
OUTPUT_DOC = INPUT_DOC # 原地更新
|
||||
|
||||
# ODS schema 名(从数据库动态检测)
|
||||
ODS_SCHEMA = None # 运行时自动检测
|
||||
|
||||
# ODS 表列表(与文档中的顺序一致)
|
||||
ODS_TABLES = [
|
||||
"assistant_accounts_master",
|
||||
"assistant_cancellation_records",
|
||||
"assistant_service_records",
|
||||
"goods_stock_movements",
|
||||
"goods_stock_summary",
|
||||
"group_buy_packages",
|
||||
"group_buy_redemption_records",
|
||||
"member_balance_changes",
|
||||
"member_profiles",
|
||||
"member_stored_value_cards",
|
||||
"payment_transactions",
|
||||
"platform_coupon_redemption_records",
|
||||
"recharge_settlements",
|
||||
"refund_transactions",
|
||||
"settlement_records",
|
||||
"settlement_ticket_details",
|
||||
"site_tables_master",
|
||||
"stock_goods_category_tree",
|
||||
"store_goods_master",
|
||||
"store_goods_sales_records",
|
||||
"table_fee_discount_records",
|
||||
"table_fee_transactions",
|
||||
"tenant_goods_master",
|
||||
]
|
||||
|
||||
# ETL 元数据列(不来自 API)
|
||||
ETL_META_COLS = {
|
||||
"content_hash", "source_file", "source_endpoint",
|
||||
"fetched_at", "payload", "record_index",
|
||||
}
|
||||
|
||||
# 需要展平的嵌套层(merge_record_layers 逻辑)
|
||||
FLATTEN_KEYS = {"data", "settleList"}
|
||||
|
||||
|
||||
def get_db_dsn() -> str:
|
||||
"""从 .env 文件读取数据库连接串。"""
|
||||
from dotenv import load_dotenv
|
||||
env_path = ROOT / "apps" / "etl" / "pipelines" / "feiqiu" / ".env"
|
||||
if env_path.exists():
|
||||
load_dotenv(env_path, override=True)
|
||||
load_dotenv(ROOT / ".env")
|
||||
dsn = os.environ.get("PG_DSN") or os.environ.get("DB_DSN") or os.environ.get("DATABASE_URL")
|
||||
if not dsn:
|
||||
print("错误: 未找到 PG_DSN / DB_DSN / DATABASE_URL 环境变量", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return dsn
|
||||
|
||||
|
||||
def flatten_json_keys(obj: dict, prefix: str = "") -> list[tuple[str, str]]:
|
||||
"""
|
||||
递归提取 JSON 对象的所有叶子键及其值类型。
|
||||
返回 [(key_path, value_type), ...]
|
||||
对于嵌套对象,用 "." 连接路径。
|
||||
对于数组,标记为 array 并递归展开元素。
|
||||
"""
|
||||
results = []
|
||||
if not isinstance(obj, dict):
|
||||
return results
|
||||
for k, v in obj.items():
|
||||
full_key = f"{prefix}.{k}" if prefix else k
|
||||
if v is None:
|
||||
results.append((full_key, "null"))
|
||||
elif isinstance(v, bool):
|
||||
results.append((full_key, "boolean"))
|
||||
elif isinstance(v, int):
|
||||
results.append((full_key, "integer"))
|
||||
elif isinstance(v, float):
|
||||
results.append((full_key, "number"))
|
||||
elif isinstance(v, str):
|
||||
results.append((full_key, "string"))
|
||||
elif isinstance(v, list):
|
||||
results.append((full_key, "array"))
|
||||
# 递归展开数组中的第一个对象元素
|
||||
for item in v:
|
||||
if isinstance(item, dict):
|
||||
results.extend(flatten_json_keys(item, f"{full_key}[]"))
|
||||
break
|
||||
elif isinstance(v, dict):
|
||||
results.append((full_key, "object"))
|
||||
results.extend(flatten_json_keys(v, full_key))
|
||||
return results
|
||||
|
||||
|
||||
def get_top_level_keys(obj: dict) -> list[tuple[str, str]]:
|
||||
"""
|
||||
提取 JSON 对象的顶层键及其值类型(merge_record_layers 展平后的视角)。
|
||||
模拟 ETL 的 merge_record_layers:展平 data 和 settleList 嵌套层。
|
||||
"""
|
||||
merged = dict(obj)
|
||||
# 展平 data 层
|
||||
data_part = merged.get("data")
|
||||
while isinstance(data_part, dict):
|
||||
merged = {**data_part, **merged}
|
||||
data_part = data_part.get("data")
|
||||
# 展平 settleList 层
|
||||
settle_inner = merged.get("settleList")
|
||||
if isinstance(settle_inner, dict):
|
||||
merged = {**settle_inner, **merged}
|
||||
|
||||
results = []
|
||||
for k, v in merged.items():
|
||||
if v is None:
|
||||
vtype = "null"
|
||||
elif isinstance(v, bool):
|
||||
vtype = "boolean"
|
||||
elif isinstance(v, int):
|
||||
vtype = "integer"
|
||||
elif isinstance(v, float):
|
||||
vtype = "number"
|
||||
elif isinstance(v, str):
|
||||
vtype = "string"
|
||||
elif isinstance(v, list):
|
||||
vtype = "array"
|
||||
elif isinstance(v, dict):
|
||||
vtype = "object"
|
||||
else:
|
||||
vtype = type(v).__name__
|
||||
results.append((k, vtype))
|
||||
return results
|
||||
|
||||
|
||||
def fetch_sample_payloads(conn, table: str, sample_count: int = 5) -> list[dict]:
|
||||
"""从 ODS 表获取多条 payload 样本,合并字段以覆盖更多字段。"""
|
||||
sql = f"""
|
||||
SELECT payload
|
||||
FROM {ODS_SCHEMA}.{table}
|
||||
WHERE payload IS NOT NULL
|
||||
ORDER BY fetched_at DESC
|
||||
LIMIT {sample_count}
|
||||
"""
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(sql)
|
||||
rows = cur.fetchall()
|
||||
payloads = []
|
||||
for row in rows:
|
||||
p = row[0]
|
||||
if isinstance(p, str):
|
||||
p = json.loads(p)
|
||||
if isinstance(p, dict):
|
||||
payloads.append(p)
|
||||
return payloads
|
||||
|
||||
|
||||
def merge_payloads_keys(payloads: list[dict]) -> OrderedDict[str, str]:
|
||||
"""合并多条 payload 的键,保留第一次出现的顺序和非 null 类型。"""
|
||||
merged = OrderedDict()
|
||||
for p in payloads:
|
||||
keys = get_top_level_keys(p)
|
||||
for k, vtype in keys:
|
||||
if k not in merged:
|
||||
merged[k] = vtype
|
||||
elif merged[k] == "null" and vtype != "null":
|
||||
merged[k] = vtype
|
||||
return merged
|
||||
|
||||
|
||||
def get_ods_columns(conn, table: str) -> list[tuple[str, str]]:
|
||||
"""从数据库获取 ODS 表的列名和类型。"""
|
||||
sql = """
|
||||
SELECT column_name, data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = %s AND table_name = %s
|
||||
ORDER BY ordinal_position
|
||||
"""
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(sql, (ODS_SCHEMA, table))
|
||||
return [(r[0], r[1]) for r in cur.fetchall()]
|
||||
|
||||
|
||||
def compute_mapping(api_keys: OrderedDict, ods_cols: list[tuple[str, str]]) -> list[dict]:
|
||||
"""
|
||||
计算 API 字段 → ODS 列的映射关系。
|
||||
ETL 使用大小写不敏感匹配(_get_value_case_insensitive)。
|
||||
"""
|
||||
# 构建 ODS 列名的小写查找表
|
||||
ods_by_lower = {}
|
||||
for col_name, col_type in ods_cols:
|
||||
ods_by_lower[col_name.lower()] = (col_name, col_type)
|
||||
|
||||
mappings = []
|
||||
matched_ods = set()
|
||||
|
||||
for api_key, api_type in api_keys.items():
|
||||
api_lower = api_key.lower()
|
||||
# 跳过嵌套对象键(siteProfile, tableProfile 等)
|
||||
if api_type == "object" and api_lower in ("siteprofile", "tableprofile", "data", "settlelist"):
|
||||
mappings.append({
|
||||
"api_field": api_key,
|
||||
"api_type": api_type,
|
||||
"ods_column": "—",
|
||||
"ods_type": "—",
|
||||
"mapping": "嵌套对象,展平后各字段独立映射" if api_lower in ("data", "settlelist") else "嵌套对象,不直接映射到列",
|
||||
})
|
||||
continue
|
||||
|
||||
if api_lower in ods_by_lower:
|
||||
ods_col, ods_type = ods_by_lower[api_lower]
|
||||
matched_ods.add(ods_col.lower())
|
||||
note = "同名映射" if api_key == ods_col else "大小写不敏感匹配"
|
||||
mappings.append({
|
||||
"api_field": api_key,
|
||||
"api_type": api_type,
|
||||
"ods_column": ods_col,
|
||||
"ods_type": ods_type,
|
||||
"mapping": note,
|
||||
})
|
||||
else:
|
||||
mappings.append({
|
||||
"api_field": api_key,
|
||||
"api_type": api_type,
|
||||
"ods_column": "—",
|
||||
"ods_type": "—",
|
||||
"mapping": "未入 ODS 列(仅存于 payload)",
|
||||
})
|
||||
|
||||
# 找出 ODS 中有但 API 中没有的列(ETL 元数据列)
|
||||
for col_name, col_type in ods_cols:
|
||||
if col_name.lower() not in matched_ods and col_name.lower() not in ETL_META_COLS:
|
||||
# 可能是从嵌套对象中提取的
|
||||
mappings.append({
|
||||
"api_field": "—",
|
||||
"api_type": "—",
|
||||
"ods_column": col_name,
|
||||
"ods_type": col_type,
|
||||
"mapping": "ETL 派生/嵌套提取",
|
||||
})
|
||||
|
||||
return mappings
|
||||
|
||||
|
||||
def generate_api_section(table: str, api_keys: OrderedDict, ods_cols: list[tuple[str, str]], mappings: list[dict]) -> str:
|
||||
"""生成单个表的 API 源字段小节 Markdown。"""
|
||||
lines = []
|
||||
|
||||
# API 源字段列表
|
||||
lines.append(f"### API 源字段({len(api_keys)} 个)")
|
||||
lines.append("")
|
||||
lines.append("> 以下字段从 `payload` JSONB 中提取,展示 API 返回 JSON 的顶层结构(经 `merge_record_layers` 展平后)。")
|
||||
lines.append("")
|
||||
lines.append("| # | API 字段名 | JSON 类型 | 映射到 ODS 列 | 说明 |")
|
||||
lines.append("|---|-----------|-----------|--------------|------|")
|
||||
|
||||
for idx, m in enumerate(mappings, 1):
|
||||
api_f = m["api_field"]
|
||||
api_t = m["api_type"]
|
||||
ods_c = m["ods_column"]
|
||||
note = m["mapping"]
|
||||
if api_f == "—":
|
||||
continue # 跳过 ETL 派生列,在下面单独说明
|
||||
ods_display = f"`{ods_c}`" if ods_c != "—" else "—"
|
||||
lines.append(f"| {idx} | `{api_f}` | {api_t} | {ods_display} | {note} |")
|
||||
|
||||
# 统计
|
||||
mapped_count = sum(1 for m in mappings if m["ods_column"] != "—" and m["api_field"] != "—")
|
||||
unmapped_count = sum(1 for m in mappings if m["ods_column"] == "—" and m["api_field"] != "—" and m["api_type"] not in ("object",))
|
||||
payload_only = [m["api_field"] for m in mappings if m["mapping"] == "未入 ODS 列(仅存于 payload)"]
|
||||
|
||||
lines.append("")
|
||||
if payload_only:
|
||||
lines.append(f"> 映射统计:{mapped_count} 个字段映射到 ODS 列,{len(payload_only)} 个字段仅存于 `payload` JSONB 中。")
|
||||
lines.append(f"> 仅存于 payload 的字段:{', '.join(f'`{f}`' for f in payload_only)}")
|
||||
else:
|
||||
lines.append(f"> 映射统计:{mapped_count} 个字段全部映射到 ODS 列。")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def insert_sections_into_doc(doc_text: str, sections: dict[str, str]) -> str:
|
||||
"""
|
||||
在现有文档的每个表章节中,在 "### ODS 表" 之前插入 API 源字段小节。
|
||||
如果已存在 "### API 源字段" 则替换。
|
||||
"""
|
||||
lines = doc_text.split("\n")
|
||||
result = []
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
|
||||
# 检测 "## table_name" 章节标题
|
||||
m = re.match(r"^## (\w+)\s*$", line)
|
||||
if m:
|
||||
table_name = m.group(1)
|
||||
result.append(line)
|
||||
i += 1
|
||||
|
||||
if table_name in sections:
|
||||
# 跳过空行
|
||||
while i < len(lines) and lines[i].strip() == "":
|
||||
result.append(lines[i])
|
||||
i += 1
|
||||
|
||||
# 如果已存在 "### API 源字段",跳过旧内容直到下一个 ### 或 ##
|
||||
if i < len(lines) and lines[i].startswith("### API 源字段"):
|
||||
# 跳过旧的 API 源字段小节
|
||||
i += 1
|
||||
while i < len(lines):
|
||||
if lines[i].startswith("### ") or lines[i].startswith("## "):
|
||||
break
|
||||
i += 1
|
||||
|
||||
# 插入新的 API 源字段小节
|
||||
result.append(sections[table_name])
|
||||
result.append("")
|
||||
continue
|
||||
|
||||
result.append(line)
|
||||
i += 1
|
||||
|
||||
return "\n".join(result)
|
||||
|
||||
|
||||
def detect_ods_schema(conn) -> str:
|
||||
"""自动检测 ODS schema 名(可能是 ods 或 billiards_ods)。"""
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
SELECT schema_name FROM information_schema.schemata
|
||||
WHERE schema_name IN ('ods', 'billiards_ods')
|
||||
ORDER BY schema_name
|
||||
""")
|
||||
rows = cur.fetchall()
|
||||
for row in rows:
|
||||
if row[0] == "ods":
|
||||
return "ods"
|
||||
for row in rows:
|
||||
if row[0] == "billiards_ods":
|
||||
return "billiards_ods"
|
||||
print("错误: 未找到 ods 或 billiards_ods schema", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
global ODS_SCHEMA
|
||||
dsn = get_db_dsn()
|
||||
conn = psycopg2.connect(dsn)
|
||||
conn.set_client_encoding("UTF8")
|
||||
|
||||
ODS_SCHEMA = detect_ods_schema(conn)
|
||||
print(f"检测到 ODS schema: {ODS_SCHEMA}")
|
||||
|
||||
print("正在从数据库提取 API 原始字段...")
|
||||
sections = {}
|
||||
|
||||
for table in ODS_TABLES:
|
||||
print(f" 处理: {table}")
|
||||
payloads = fetch_sample_payloads(conn, table, sample_count=10)
|
||||
if not payloads:
|
||||
print(f" 警告: {table} 无 payload 数据,跳过")
|
||||
continue
|
||||
|
||||
api_keys = merge_payloads_keys(payloads)
|
||||
ods_cols = get_ods_columns(conn, table)
|
||||
mappings = compute_mapping(api_keys, ods_cols)
|
||||
section_text = generate_api_section(table, api_keys, ods_cols, mappings)
|
||||
sections[table] = section_text
|
||||
|
||||
conn.close()
|
||||
|
||||
print(f"\n读取现有文档: {INPUT_DOC}")
|
||||
doc_text = INPUT_DOC.read_text(encoding="utf-8")
|
||||
|
||||
print("插入 API 源字段小节...")
|
||||
new_doc = insert_sections_into_doc(doc_text, sections)
|
||||
|
||||
OUTPUT_DOC.write_text(new_doc, encoding="utf-8")
|
||||
print(f"文档已更新: {OUTPUT_DOC}")
|
||||
print(f" 处理了 {len(sections)} 个表的 API 源字段映射")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
339
scripts/ops/gen_dataflow_doc.py
Normal file
339
scripts/ops/gen_dataflow_doc.py
Normal file
@@ -0,0 +1,339 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
从源代码和 DDL 中提取 API → ODS → DWD 数据流映射,生成 Markdown 文档。
|
||||
用法: python scripts/ops/gen_dataflow_doc.py
|
||||
输出: docs/reports/dataflow_api_ods_dwd.md
|
||||
"""
|
||||
import re
|
||||
import ast
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
from collections import OrderedDict
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
ETL = ROOT / "apps" / "etl" / "pipelines" / "feiqiu"
|
||||
DB = ROOT / "db" / "etl_feiqiu" / "schemas"
|
||||
OUT = ROOT / "docs" / "reports" / "dataflow_api_ods_dwd.md"
|
||||
|
||||
|
||||
# ── 1. 从 DDL 解析表结构 ──────────────────────────────────────────
|
||||
def parse_ddl_tables(sql_path: Path, schema: str) -> dict[str, list[dict]]:
|
||||
"""解析 CREATE TABLE 语句,返回 {schema.table: [{col, type}, ...]}"""
|
||||
text = sql_path.read_text(encoding="utf-8")
|
||||
tables: dict[str, list[dict]] = {}
|
||||
# 匹配 CREATE TABLE IF NOT EXISTS table_name (...)
|
||||
pattern = re.compile(
|
||||
r"CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\s+"
|
||||
r"(?:(\w+)\.)?(\w+)\s*\((.*?)\)\s*;",
|
||||
re.DOTALL | re.IGNORECASE,
|
||||
)
|
||||
for m in pattern.finditer(text):
|
||||
s = m.group(1) or schema
|
||||
tname = m.group(2)
|
||||
body = m.group(3)
|
||||
cols = []
|
||||
for line in body.split("\n"):
|
||||
line = line.strip().rstrip(",")
|
||||
if not line or line.upper().startswith("PRIMARY") or line.startswith("--"):
|
||||
continue
|
||||
# 跳过约束行
|
||||
if re.match(r"^(CONSTRAINT|UNIQUE|CHECK|FOREIGN|EXCLUDE)\b", line, re.I):
|
||||
continue
|
||||
parts = line.split()
|
||||
if len(parts) >= 2:
|
||||
col_name = parts[0].strip('"')
|
||||
col_type = parts[1]
|
||||
# 合并类型修饰符
|
||||
if len(parts) > 2 and parts[2].startswith("("):
|
||||
col_type += parts[2]
|
||||
cols.append({"col": col_name, "type": col_type})
|
||||
full = f"{s}.{tname}"
|
||||
tables[full] = cols
|
||||
return tables
|
||||
|
||||
|
||||
# ── 2. 从 Python 源码解析 TABLE_MAP ──────────────────────────────
|
||||
def parse_table_map(py_path: Path) -> dict[str, str]:
|
||||
"""解析 TABLE_MAP: dict[str, str] = {...}"""
|
||||
text = py_path.read_text(encoding="utf-8")
|
||||
# 找到 TABLE_MAP 字典
|
||||
match = re.search(
|
||||
r"TABLE_MAP\s*(?::\s*dict\[.*?\])?\s*=\s*\{(.*?)\}",
|
||||
text,
|
||||
re.DOTALL,
|
||||
)
|
||||
if not match:
|
||||
return {}
|
||||
body = match.group(1)
|
||||
result = {}
|
||||
for m in re.finditer(r'"([^"]+)"\s*:\s*"([^"]+)"', body):
|
||||
result[m.group(1)] = m.group(2)
|
||||
return result
|
||||
|
||||
|
||||
# ── 3. 从 Python 源码解析 FACT_MAPPINGS ──────────────────────────
|
||||
def parse_fact_mappings(py_path: Path) -> dict[str, list[tuple]]:
|
||||
"""解析 FACT_MAPPINGS 字典,返回 {dwd_table: [(dwd_col, ods_expr, cast), ...]}"""
|
||||
text = py_path.read_text(encoding="utf-8")
|
||||
# 找到 FACT_MAPPINGS 块
|
||||
start = text.find("FACT_MAPPINGS")
|
||||
if start < 0:
|
||||
return {}
|
||||
# 找到第一个 { 后的内容
|
||||
brace_start = text.find("{", start)
|
||||
if brace_start < 0:
|
||||
return {}
|
||||
|
||||
# 手动匹配大括号
|
||||
depth = 0
|
||||
end = brace_start
|
||||
for i in range(brace_start, len(text)):
|
||||
if text[i] == "{":
|
||||
depth += 1
|
||||
elif text[i] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
end = i + 1
|
||||
break
|
||||
|
||||
block = text[brace_start:end]
|
||||
result = {}
|
||||
# 匹配每个表的映射列表
|
||||
table_pattern = re.compile(r'"([^"]+)"\s*:\s*\[', re.DOTALL)
|
||||
for tm in table_pattern.finditer(block):
|
||||
table_name = tm.group(1)
|
||||
list_start = tm.end()
|
||||
# 找到对应的 ]
|
||||
bracket_depth = 1
|
||||
list_end = list_start
|
||||
for i in range(list_start, len(block)):
|
||||
if block[i] == "[":
|
||||
bracket_depth += 1
|
||||
elif block[i] == "]":
|
||||
bracket_depth -= 1
|
||||
if bracket_depth == 0:
|
||||
list_end = i
|
||||
break
|
||||
list_body = block[list_start:list_end]
|
||||
# 匹配 (dwd_col, ods_expr, cast|None)
|
||||
tuples = []
|
||||
tuple_pattern = re.compile(
|
||||
r'\(\s*"([^"]+)"\s*,\s*"([^"]+)"\s*,\s*(?:"([^"]+)"|None)\s*\)'
|
||||
)
|
||||
for tp in tuple_pattern.finditer(list_body):
|
||||
tuples.append((tp.group(1), tp.group(2), tp.group(3)))
|
||||
result[table_name] = tuples
|
||||
return result
|
||||
|
||||
|
||||
# ── 4. 从 Python 源码解析 ODS_TASK_SPECS ─────────────────────────
|
||||
def parse_ods_specs(py_path: Path) -> list[dict]:
|
||||
"""解析 ODS_TASK_SPECS,提取 code, table_name, endpoint, list_key, description"""
|
||||
text = py_path.read_text(encoding="utf-8")
|
||||
specs = []
|
||||
# 匹配每个 OdsTaskSpec(...)
|
||||
pattern = re.compile(r"OdsTaskSpec\s*\((.*?)\)\s*,", re.DOTALL)
|
||||
for m in pattern.finditer(text):
|
||||
body = m.group(1)
|
||||
spec = {}
|
||||
for key in ("code", "table_name", "endpoint", "list_key", "description"):
|
||||
km = re.search(rf'{key}\s*=\s*"([^"]*)"', body)
|
||||
if km:
|
||||
spec[key] = km.group(1)
|
||||
if "code" in spec:
|
||||
specs.append(spec)
|
||||
return specs
|
||||
|
||||
|
||||
# ── 5. 生成文档 ──────────────────────────────────────────────────
|
||||
def generate_doc():
|
||||
ods_ddl = parse_ddl_tables(DB / "ods.sql", "ods")
|
||||
dwd_ddl = parse_ddl_tables(DB / "dwd.sql", "dwd")
|
||||
|
||||
dwd_task_py = ETL / "tasks" / "dwd" / "dwd_load_task.py"
|
||||
table_map = parse_table_map(dwd_task_py)
|
||||
fact_mappings = parse_fact_mappings(dwd_task_py)
|
||||
|
||||
ods_specs = parse_ods_specs(ETL / "tasks" / "ods" / "ods_tasks.py")
|
||||
# ODS 表 → API 端点映射
|
||||
ods_to_api: dict[str, dict] = {}
|
||||
for spec in ods_specs:
|
||||
tn = spec.get("table_name", "")
|
||||
ods_to_api[tn] = spec
|
||||
|
||||
lines = []
|
||||
lines.append("# API → ODS → DWD 数据流对比文档")
|
||||
lines.append("")
|
||||
lines.append("> 自动生成于 `scripts/ops/gen_dataflow_doc.py`,基于 DDL 和 ETL 源码解析。")
|
||||
lines.append("")
|
||||
lines.append("## 概览")
|
||||
lines.append("")
|
||||
lines.append(f"- ODS 表数量: {len(ods_ddl)}")
|
||||
lines.append(f"- DWD 表数量: {len(dwd_ddl)}")
|
||||
lines.append(f"- TABLE_MAP 映射条目: {len(table_map)}")
|
||||
lines.append(f"- ODS 任务数量: {len(ods_specs)}")
|
||||
lines.append("")
|
||||
|
||||
# ── 按 ODS 表分组 ──
|
||||
# 先建立 ODS 表 → DWD 表列表的反向映射
|
||||
ods_to_dwd: dict[str, list[str]] = {}
|
||||
for dwd_t, ods_t in table_map.items():
|
||||
ods_to_dwd.setdefault(ods_t, []).append(dwd_t)
|
||||
|
||||
# 收集所有涉及的 ODS 表(去重、排序)
|
||||
all_ods = sorted(set(list(ods_to_dwd.keys()) + [s.get("table_name", "") for s in ods_specs]))
|
||||
|
||||
lines.append("## 目录")
|
||||
lines.append("")
|
||||
for i, ods_t in enumerate(all_ods, 1):
|
||||
anchor = ods_t.replace(".", "").replace("_", "-")
|
||||
short = ods_t.split(".")[-1] if "." in ods_t else ods_t
|
||||
lines.append(f"{i}. [{short}](#{anchor})")
|
||||
lines.append("")
|
||||
lines.append("---")
|
||||
lines.append("")
|
||||
|
||||
# ── 逐表详情 ──
|
||||
for ods_t in all_ods:
|
||||
short = ods_t.split(".")[-1] if "." in ods_t else ods_t
|
||||
lines.append(f"## {short}")
|
||||
lines.append("")
|
||||
|
||||
# API 信息
|
||||
api_info = ods_to_api.get(ods_t, {})
|
||||
if api_info:
|
||||
lines.append("### API 端点")
|
||||
lines.append("")
|
||||
lines.append(f"- 任务编码: `{api_info.get('code', 'N/A')}`")
|
||||
lines.append(f"- 端点: `{api_info.get('endpoint', 'N/A')}`")
|
||||
lk = api_info.get("list_key")
|
||||
if lk:
|
||||
lines.append(f"- 数据路径: `data.{lk}`")
|
||||
desc = api_info.get("description", "")
|
||||
if desc:
|
||||
lines.append(f"- 说明: {desc}")
|
||||
lines.append("")
|
||||
|
||||
# ODS 表字段
|
||||
ods_cols = ods_ddl.get(ods_t, [])
|
||||
if ods_cols:
|
||||
lines.append(f"### ODS 表: `{ods_t}` ({len(ods_cols)} 列)")
|
||||
lines.append("")
|
||||
lines.append("| # | 列名 | 类型 |")
|
||||
lines.append("|---|------|------|")
|
||||
for idx, c in enumerate(ods_cols, 1):
|
||||
lines.append(f"| {idx} | `{c['col']}` | {c['type']} |")
|
||||
lines.append("")
|
||||
|
||||
# DWD 表
|
||||
dwd_tables = ods_to_dwd.get(ods_t, [])
|
||||
if dwd_tables:
|
||||
for dwd_t in sorted(dwd_tables):
|
||||
dwd_cols = dwd_ddl.get(dwd_t, [])
|
||||
is_dim = "dim_" in dwd_t
|
||||
is_ex = dwd_t.endswith("_ex")
|
||||
table_type = "维度" if is_dim else "事实"
|
||||
if is_ex:
|
||||
table_type += "(扩展)"
|
||||
|
||||
mappings = fact_mappings.get(dwd_t, [])
|
||||
|
||||
lines.append(f"### DWD 表: `{dwd_t}` — {table_type} ({len(dwd_cols)} 列)")
|
||||
lines.append("")
|
||||
|
||||
# 字段对比表
|
||||
lines.append("| # | DWD 列名 | DWD 类型 | ODS 来源表达式 | 转换 | 备注 |")
|
||||
lines.append("|---|----------|----------|----------------|------|------|")
|
||||
|
||||
# 建立映射查找
|
||||
mapping_dict = {m[0]: (m[1], m[2]) for m in mappings}
|
||||
|
||||
for idx, c in enumerate(dwd_cols, 1):
|
||||
col_name = c["col"]
|
||||
col_type = c["type"]
|
||||
|
||||
# SCD2 列
|
||||
scd2_cols = {"scd2_start_time", "scd2_end_time", "scd2_is_current", "scd2_version"}
|
||||
if col_name.lower().replace("scd2_", "scd2_") in scd2_cols or col_name.lower() in scd2_cols:
|
||||
lines.append(f"| {idx} | `{col_name}` | {col_type} | — | — | DWD 慢变元数据 |")
|
||||
continue
|
||||
|
||||
if col_name in mapping_dict:
|
||||
ods_expr, cast = mapping_dict[col_name]
|
||||
cast_str = f"CAST → {cast}" if cast else "直接映射"
|
||||
# 判断是否为 JSONB 提取
|
||||
note = ""
|
||||
if "->>" in ods_expr:
|
||||
note = "JSONB 提取"
|
||||
elif "CASE" in ods_expr.upper():
|
||||
note = "派生计算"
|
||||
elif ods_expr != col_name:
|
||||
note = "字段重命名"
|
||||
lines.append(f"| {idx} | `{col_name}` | {col_type} | `{ods_expr}` | {cast_str} | {note} |")
|
||||
else:
|
||||
# 同名直传
|
||||
ods_col_names = {oc["col"].lower() for oc in ods_cols}
|
||||
if col_name.lower() in ods_col_names:
|
||||
lines.append(f"| {idx} | `{col_name}` | {col_type} | `{col_name}` | 直接映射 | 同名直传 |")
|
||||
else:
|
||||
lines.append(f"| {idx} | `{col_name}` | {col_type} | — | — | 未在 FACT_MAPPINGS 中显式映射 |")
|
||||
|
||||
lines.append("")
|
||||
else:
|
||||
lines.append(f"*该 ODS 表暂无 DWD 映射(仅用于 DWS 或其他下游)*")
|
||||
lines.append("")
|
||||
|
||||
lines.append("---")
|
||||
lines.append("")
|
||||
|
||||
# ── 附录:ETL 元数据列说明 ──
|
||||
lines.append("## 附录:ETL 元数据列")
|
||||
lines.append("")
|
||||
lines.append("所有 ODS 表均包含以下 ETL 元数据列,不映射到 DWD:")
|
||||
lines.append("")
|
||||
lines.append("| 列名 | 类型 | 说明 |")
|
||||
lines.append("|------|------|------|")
|
||||
lines.append("| `content_hash` | TEXT | 记录内容哈希,用于去重和变更检测 |")
|
||||
lines.append("| `source_file` | TEXT | 原始导出文件名,用于数据追溯 |")
|
||||
lines.append("| `source_endpoint` | TEXT | 采集来源接口/文件路径 |")
|
||||
lines.append("| `fetched_at` | TIMESTAMPTZ | 采集/入库时间戳 |")
|
||||
lines.append("| `payload` | JSONB | 完整原始 JSON 记录快照 |")
|
||||
lines.append("")
|
||||
|
||||
lines.append("## 附录:DWD 维度表 SCD2 列")
|
||||
lines.append("")
|
||||
lines.append("所有 DWD 维度表(`dim_*`)均包含以下 SCD2 慢变维度列:")
|
||||
lines.append("")
|
||||
lines.append("| 列名 | 类型 | 说明 |")
|
||||
lines.append("|------|------|------|")
|
||||
lines.append("| `scd2_start_time` | TIMESTAMPTZ | 版本生效起点 |")
|
||||
lines.append("| `scd2_end_time` | TIMESTAMPTZ | 版本失效时间(9999-12-31 = 当前) |")
|
||||
lines.append("| `scd2_is_current` | INT | 当前版本标记(1=当前,0=历史) |")
|
||||
lines.append("| `scd2_version` | INT | 版本号(自增) |")
|
||||
lines.append("")
|
||||
|
||||
lines.append("## 附录:DWD 事实表增量策略")
|
||||
lines.append("")
|
||||
lines.append("事实表按时间窗口增量写入,优先使用以下业务时间列进行过滤(按优先级排序):")
|
||||
lines.append("")
|
||||
lines.append("1. `pay_time` — 支付时间")
|
||||
lines.append("2. `create_time` — 创建时间")
|
||||
lines.append("3. `update_time` — 更新时间")
|
||||
lines.append("4. `occur_time` — 发生时间")
|
||||
lines.append("5. `settle_time` — 结算时间")
|
||||
lines.append("6. `start_use_time` — 开始使用时间")
|
||||
lines.append("7. `fetched_at` — 入库时间(兜底)")
|
||||
lines.append("")
|
||||
|
||||
# 写入文件
|
||||
OUT.parent.mkdir(parents=True, exist_ok=True)
|
||||
OUT.write_text("\n".join(lines), encoding="utf-8")
|
||||
print(f"文档已生成: {OUT}")
|
||||
print(f" ODS 表: {len(ods_ddl)}, DWD 表: {len(dwd_ddl)}")
|
||||
print(f" TABLE_MAP: {len(table_map)} 条, FACT_MAPPINGS: {len(fact_mappings)} 条")
|
||||
print(f" ODS 任务: {len(ods_specs)} 个")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
generate_doc()
|
||||
787
scripts/ops/gen_dataflow_report.py
Normal file
787
scripts/ops/gen_dataflow_report.py
Normal file
@@ -0,0 +1,787 @@
|
||||
"""
|
||||
数据流结构分析报告生成器(v3)
|
||||
|
||||
读取 analyze_dataflow.py 采集的数据,生成带锚点链接、上下游映射列、
|
||||
业务描述、多示例值、字段差异报告的 Markdown 报告。
|
||||
|
||||
增强内容(v3):
|
||||
- 总览表增加 API JSON 字段数列
|
||||
- 覆盖率表增加业务描述列
|
||||
- 逐表详情增加业务描述列(来自 BD_manual 文档)
|
||||
- 说明+示例值合并,多示例展示,枚举值解释
|
||||
- 总览章节增加 API↔ODS↔DWD 字段对比差异报告
|
||||
|
||||
用法:
|
||||
python scripts/ops/gen_dataflow_report.py
|
||||
python scripts/ops/gen_dataflow_report.py --output-dir export/dataflow_analysis
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
|
||||
def load_json(path: Path) -> dict | list | None:
|
||||
if not path.exists():
|
||||
return None
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="生成数据流结构分析 Markdown 报告")
|
||||
parser.add_argument("--output-dir", type=str, default=None,
|
||||
help="输出目录(默认读取 SYSTEM_ANALYZE_ROOT 或 export/dataflow_analysis)")
|
||||
return parser
|
||||
|
||||
|
||||
def resolve_data_dir(override: str | None = None) -> Path:
|
||||
if override:
|
||||
return Path(override)
|
||||
env_root = os.environ.get("SYSTEM_ANALYZE_ROOT")
|
||||
if env_root:
|
||||
return Path(env_root)
|
||||
return Path("export/dataflow_analysis")
|
||||
|
||||
|
||||
def _esc(s: str) -> str:
|
||||
"""转义 Markdown 表格中的管道符"""
|
||||
return str(s).replace("|", "\\|").replace("\n", " ") if s else ""
|
||||
|
||||
|
||||
# ── 字段用途推测规则 ──
|
||||
# 基于字段名模式 + 表名上下文推断字段可能的业务含义
|
||||
# 置信度:高(≥80%) / 中(50-79%) / 低(<50%)
|
||||
import re as _re
|
||||
|
||||
_FIELD_GUESS_RULES: list[tuple[str, str, str]] = [
|
||||
# (字段名模式正则, 推测用途, 置信度)
|
||||
# ── SCD2 / ETL 元数据 ──
|
||||
(r"^scd2_", "SCD2 缓慢变化维度元数据", "高"),
|
||||
(r"^etl_", "ETL 流程元数据", "高"),
|
||||
(r"^dw_insert", "数仓装载时间戳", "高"),
|
||||
(r"^content_hash$", "数据变更检测哈希", "高"),
|
||||
(r"^source_file$", "ETL 来源文件标识", "高"),
|
||||
(r"^source_endpoint$", "ETL 来源接口标识", "高"),
|
||||
(r"^fetched_at$", "ETL 抓取时间", "高"),
|
||||
(r"^payload$", "原始 JSON 全量存储", "高"),
|
||||
# ── 主键 / 外键 ──
|
||||
(r"^id$", "主键标识", "高"),
|
||||
# ── 门店 / 组织(放在通用 _id$ 之前) ──
|
||||
(r"^(site_id|shop_id|store_id)$", "门店标识", "高"),
|
||||
(r"^(tenant_id|org_id)$", "租户/组织标识", "高"),
|
||||
(r"(shop_name|site_name|store_name)", "门店名称", "高"),
|
||||
# ── 时间类 ──
|
||||
(r"(^|_)(create|created)(_at|_time|_date)$", "记录创建时间", "高"),
|
||||
(r"(^|_)(update|updated|modify)(_at|_time|_date)$", "记录更新时间", "高"),
|
||||
(r"(^|_)(delete|deleted)(_at|_time|_date)$", "逻辑删除时间", "高"),
|
||||
(r"(^|_)(start|begin)(_at|_time|_date)$", "起始时间", "中"),
|
||||
(r"(^|_)(end|expire)(_at|_time|_date)$", "结束/过期时间", "中"),
|
||||
(r"(^|_)entry_time$", "入职/入场时间", "中"),
|
||||
(r"(^|_)resign_time$", "离职时间", "中"),
|
||||
(r"_time$", "时间戳字段", "中"),
|
||||
(r"_date$", "日期字段", "中"),
|
||||
# ── 通用派生(放在标志位之前,确保 derived_flag 等优先匹配派生) ──
|
||||
(r"^derived_", "ETL 派生计算列", "高"),
|
||||
(r"^calc_", "计算字段", "中"),
|
||||
# ── 状态 / 标志 ──
|
||||
(r"(^|_)is_delete$", "逻辑删除标志", "高"),
|
||||
(r"^is_", "布尔标志位", "中"),
|
||||
(r"(^|_)status$", "状态码", "中"),
|
||||
(r"_status$", "状态字段", "中"),
|
||||
(r"_enabled$", "启用/禁用开关", "中"),
|
||||
(r"_flag$", "标志位", "中"),
|
||||
# ── 金额 / 价格 ──
|
||||
(r"(price|amount|fee|cost|money|balance|total)", "金额/价格相关", "中"),
|
||||
(r"(discount|coupon|refund)", "优惠/退款相关", "中"),
|
||||
# ── 人员 ──
|
||||
(r"(real_name|nickname|^name$)", "姓名/昵称", "中"),
|
||||
(r"(mobile|phone|tel)", "联系电话", "中"),
|
||||
(r"(avatar|photo|image)", "头像/图片 URL", "中"),
|
||||
(r"(gender|sex)", "性别", "高"),
|
||||
(r"(birth|birthday)", "出生日期", "高"),
|
||||
(r"(height|weight)", "身高/体重", "高"),
|
||||
# ── 嵌套对象常见前缀 ──
|
||||
(r"^siteProfile\.", "门店档案嵌套属性", "高"),
|
||||
(r"^memberInfo\.", "会员信息嵌套属性", "中"),
|
||||
(r"^assistantInfo\.", "助教信息嵌套属性", "中"),
|
||||
(r"^tableInfo\.", "台桌信息嵌套属性", "中"),
|
||||
(r"^orderInfo\.", "订单信息嵌套属性", "中"),
|
||||
(r"^payInfo\.", "支付信息嵌套属性", "中"),
|
||||
# ── 排序 / 显示 ──
|
||||
(r"(sort|order|rank|seq)", "排序/序号", "低"),
|
||||
(r"(remark|memo|note|comment|introduce)", "备注/说明文本", "中"),
|
||||
(r"(url|link|qrcode|qr_code)", "链接/二维码", "中"),
|
||||
# ── 通用 ID 后缀(放在具体 ID 规则之后) ──
|
||||
(r"_id$", "关联实体 ID(外键)", "中"),
|
||||
]
|
||||
|
||||
|
||||
def _guess_field_purpose(field_name: str, table_name: str, layer: str) -> tuple[str, str]:
|
||||
"""根据字段名和表上下文推测用途,返回 (推测用途, 置信度)。"""
|
||||
fn_lower = field_name.lower()
|
||||
for pattern, purpose, confidence in _FIELD_GUESS_RULES:
|
||||
if _re.search(pattern, fn_lower):
|
||||
return purpose, confidence
|
||||
return f"待分析({layer}层字段)", "低"
|
||||
|
||||
|
||||
def _format_samples(samples: list[str], max_show: int = 5) -> str:
|
||||
"""格式化多示例值,截断过长的值"""
|
||||
if not samples:
|
||||
return ""
|
||||
shown = []
|
||||
for s in samples[:max_show]:
|
||||
s = _esc(s)
|
||||
if len(s) > 30:
|
||||
s = s[:27] + "..."
|
||||
shown.append(f"`{s}`")
|
||||
result = ", ".join(shown)
|
||||
if len(samples) > max_show:
|
||||
result += f" …共{len(samples)}种"
|
||||
return result
|
||||
|
||||
|
||||
def _is_enum_like(samples: list[str], total_records: int) -> bool:
|
||||
"""判断字段是否像枚举(不同值少且记录数足够多)"""
|
||||
if total_records < 5:
|
||||
return False
|
||||
return 1 < len(samples) <= 8
|
||||
|
||||
|
||||
def generate_report(data_dir: Path) -> str:
|
||||
"""生成完整的 Markdown 报告"""
|
||||
manifest = load_json(data_dir / "collection_manifest.json")
|
||||
if not manifest:
|
||||
raise FileNotFoundError(f"找不到 collection_manifest.json: {data_dir}")
|
||||
|
||||
tables = manifest["tables"]
|
||||
now = datetime.now()
|
||||
lines: list[str] = []
|
||||
|
||||
def w(s: str = ""):
|
||||
lines.append(s)
|
||||
|
||||
# ── 报告头 ──
|
||||
w("# 飞球连接器 — 数据流结构分析报告")
|
||||
w()
|
||||
w(f"> 生成时间:{now.strftime('%Y-%m-%d %H:%M:%S')} CST")
|
||||
w(f"> 分析范围:飞球(feiqiu)连接器,共 {len(tables)} 张 ODS 表")
|
||||
w("> 数据来源:API JSON 采样 + PostgreSQL ODS/DWD 表结构 + 三层字段映射 + BD_manual 业务文档")
|
||||
w()
|
||||
|
||||
# ── 1. 总览表(增加 API JSON 字段数列) ──
|
||||
w("## 1. 总览")
|
||||
w()
|
||||
w("| # | ODS 表名 | 业务描述 | 采样记录数 | API JSON 字段数 | ODS 列数 | DWD 目标表 | DWD 总列数 |")
|
||||
w("|---|---------|---------|-----------|---------------|---------|-----------|-----------|")
|
||||
total_records = 0
|
||||
total_ods_cols = 0
|
||||
total_dwd_cols = 0
|
||||
total_json_fields = 0
|
||||
for i, t in enumerate(tables, 1):
|
||||
dwd_names = ", ".join(t["dwd_tables"]) if t["dwd_tables"] else "—"
|
||||
json_fc = t.get("json_field_count", 0)
|
||||
w(f"| {i} | `{t['table']}` | {t['description']} | {t['record_count']} | {json_fc} | {t['ods_column_count']} | {dwd_names} | {t['dwd_column_count']} |")
|
||||
total_records += t["record_count"]
|
||||
total_ods_cols += t["ods_column_count"]
|
||||
total_dwd_cols += t["dwd_column_count"]
|
||||
total_json_fields += json_fc
|
||||
w(f"| | **合计** | | **{total_records}** | **{total_json_fields}** | **{total_ods_cols}** | | **{total_dwd_cols}** |")
|
||||
w()
|
||||
|
||||
# ── 1.1 字段对比差异报告 ──
|
||||
_write_field_diff_report(w, data_dir, tables)
|
||||
|
||||
# ── 2. 全局统计 ──
|
||||
w("## 2. 全局统计")
|
||||
w()
|
||||
|
||||
# 2.1 JSON→ODS 映射覆盖
|
||||
total_json = 0
|
||||
total_mapped = 0
|
||||
per_table_stats: list[dict] = []
|
||||
for t in tables:
|
||||
fm = load_json(data_dir / "field_mappings" / f"{t['table']}.json")
|
||||
if not fm or "json_to_ods" not in fm:
|
||||
per_table_stats.append({
|
||||
"table": t["table"], "description": t["description"],
|
||||
"json_count": 0, "mapped": 0, "unmapped": 0, "pct": "—",
|
||||
})
|
||||
continue
|
||||
j2o = fm["json_to_ods"]
|
||||
json_count = len(j2o)
|
||||
mapped = sum(1 for m in j2o if m.get("ods_col") is not None)
|
||||
unmapped = json_count - mapped
|
||||
pct = f"{mapped / json_count * 100:.1f}%" if json_count > 0 else "—"
|
||||
per_table_stats.append({
|
||||
"table": t["table"], "description": t["description"],
|
||||
"json_count": json_count, "mapped": mapped, "unmapped": unmapped, "pct": pct,
|
||||
})
|
||||
total_json += json_count
|
||||
total_mapped += mapped
|
||||
|
||||
total_unmapped = total_json - total_mapped
|
||||
w("### 2.1 JSON→ODS 映射覆盖")
|
||||
w()
|
||||
w(f"- JSON 字段总数:{total_json}")
|
||||
if total_json > 0:
|
||||
w(f"- 已映射到 ODS 列:{total_mapped}({total_mapped / total_json * 100:.1f}%)")
|
||||
w(f"- 仅存于 payload:{total_unmapped}({total_unmapped / total_json * 100:.1f}%)")
|
||||
else:
|
||||
w("- 已映射到 ODS 列:0")
|
||||
w("- 仅存于 payload:0")
|
||||
w()
|
||||
|
||||
# 2.2 ODS→DWD 映射覆盖
|
||||
w("### 2.2 ODS→DWD 映射覆盖")
|
||||
w()
|
||||
w(f"- DWD 列总数:{total_dwd_cols}")
|
||||
w()
|
||||
|
||||
# 2.3 各表覆盖率(增加业务描述列)
|
||||
w("### 2.3 各表 JSON→ODS 映射覆盖率")
|
||||
w()
|
||||
w("| ODS 表名 | 业务描述 | JSON 字段数 | 已映射 | 仅 payload | 覆盖率 |")
|
||||
w("|---------|---------|-----------|-------|-----------|-------|")
|
||||
sorted_stats = sorted(per_table_stats, key=lambda x: (0 if x["pct"] == "—" else -float(x["pct"].rstrip("%"))))
|
||||
for s in sorted_stats:
|
||||
w(f"| `{s['table']}` | {s['description']} | {s['json_count']} | {s['mapped']} | {s['unmapped']} | {s['pct']} |")
|
||||
w()
|
||||
|
||||
# ── 3. 逐表详情 ──
|
||||
w("## 3. 逐表详情")
|
||||
w()
|
||||
|
||||
for idx, t in enumerate(tables, 1):
|
||||
table_name = t["table"]
|
||||
fm = load_json(data_dir / "field_mappings" / f"{table_name}.json")
|
||||
jt = load_json(data_dir / "json_trees" / f"{table_name}.json")
|
||||
ods_schema = load_json(data_dir / "db_schemas" / f"ods_{table_name}.json")
|
||||
bd = load_json(data_dir / "bd_descriptions" / f"{table_name}.json")
|
||||
|
||||
# 锚点 ID
|
||||
anchors = fm.get("anchors", {}) if fm else {}
|
||||
api_anchor = anchors.get("api", f"api-{table_name}")
|
||||
ods_anchor = anchors.get("ods", f"ods-{table_name}")
|
||||
dwd_anchors = anchors.get("dwd", {})
|
||||
|
||||
dwd_tables_list = t.get("dwd_tables", [])
|
||||
json_fc = t.get("json_field_count", 0)
|
||||
|
||||
w(f"### 3.{idx} {table_name}({t['description']})")
|
||||
w()
|
||||
w(f"- 任务代码:`{t['task_code']}`")
|
||||
w(f"- 采样记录数:{t['record_count']}")
|
||||
w(f"- API JSON 字段数:{json_fc}")
|
||||
w(f"- ODS 列数:{t['ods_column_count']}")
|
||||
if dwd_tables_list:
|
||||
w(f"- DWD 目标表:{', '.join(dwd_tables_list)}")
|
||||
else:
|
||||
w("- DWD 目标表:—(仅 ODS 落地)")
|
||||
w()
|
||||
|
||||
# ── API 源字段区块 ──
|
||||
_write_api_section(w, fm, jt, bd, table_name, api_anchor, ods_anchor)
|
||||
|
||||
# ── ODS 表结构区块 ──
|
||||
_write_ods_section(w, fm, ods_schema, bd, table_name, ods_anchor, api_anchor, dwd_anchors)
|
||||
|
||||
# ── DWD 表结构区块 ──
|
||||
for dwd_name in dwd_tables_list:
|
||||
dwd_anchor = dwd_anchors.get(dwd_name, f"dwd-{dwd_name}")
|
||||
dwd_schema = load_json(data_dir / "db_schemas" / f"dwd_{dwd_name}.json")
|
||||
_write_dwd_section(w, fm, dwd_schema, bd, dwd_name, dwd_anchor, ods_anchor, table_name)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
|
||||
def _write_field_diff_report(w, data_dir: Path, tables: list[dict]):
|
||||
"""生成 API↔ODS↔DWD 字段对比差异报告(汇总表 + 逐表分表)"""
|
||||
w("### 1.1 API↔ODS↔DWD 字段对比差异")
|
||||
w()
|
||||
w("以下汇总各表在三层之间的字段差异(点击数字跳转至分表详情):")
|
||||
w()
|
||||
w("| ODS 表名 | API→ODS 未映射 | ODS 无 JSON 源 | ODS→DWD 未映射 | DWD 无 ODS 源 | 主要差异原因 |")
|
||||
w("|---------|--------------|--------------|--------------|-------------|------------|")
|
||||
|
||||
# 收集每表差异数据,用于汇总表和分表
|
||||
etl_meta_cols = {"source_file", "source_endpoint", "fetched_at", "payload", "content_hash"}
|
||||
diff_rows: list[dict] = []
|
||||
|
||||
for t in tables:
|
||||
table_name = t["table"]
|
||||
fm = load_json(data_dir / "field_mappings" / f"{table_name}.json")
|
||||
if not fm:
|
||||
w(f"| `{table_name}` | — | — | — | — | 无映射数据 |")
|
||||
diff_rows.append(None)
|
||||
continue
|
||||
|
||||
anchors = fm.get("anchors", {})
|
||||
api_anchor = anchors.get("api", f"api-{table_name.replace('_', '-')}")
|
||||
ods_anchor = anchors.get("ods", f"ods-{table_name.replace('_', '-')}")
|
||||
dwd_anchors = anchors.get("dwd", {})
|
||||
diff_anchor = f"diff-{table_name.replace('_', '-')}"
|
||||
|
||||
j2o = fm.get("json_to_ods", [])
|
||||
o2d = fm.get("ods_to_dwd", {})
|
||||
d2o = fm.get("dwd_to_ods", {})
|
||||
|
||||
# ── API→ODS 未映射字段 ──
|
||||
api_unmapped_flat: list[str] = []
|
||||
api_unmapped_nested: list[str] = []
|
||||
for m in j2o:
|
||||
if m.get("ods_col") is None:
|
||||
jp = m.get("json_path", "")
|
||||
if "." in jp:
|
||||
api_unmapped_nested.append(jp)
|
||||
else:
|
||||
api_unmapped_flat.append(jp)
|
||||
api_unmapped_total = len(api_unmapped_flat) + len(api_unmapped_nested)
|
||||
|
||||
# ── ODS 无 JSON 源 ──
|
||||
ods_schema = load_json(data_dir / "db_schemas" / f"ods_{table_name}.json")
|
||||
ods_mapped_cols = {m["ods_col"] for m in j2o if m.get("ods_col")}
|
||||
ods_no_json_fields: list[str] = []
|
||||
if ods_schema and "columns" in ods_schema:
|
||||
for col in ods_schema["columns"]:
|
||||
if col["name"] not in ods_mapped_cols and col["name"] not in etl_meta_cols:
|
||||
ods_no_json_fields.append(col["name"])
|
||||
|
||||
# ── ODS→DWD 未映射 ──
|
||||
ods_cols_with_dwd = set(o2d.keys())
|
||||
ods_no_dwd_fields: list[str] = []
|
||||
if ods_schema and "columns" in ods_schema:
|
||||
for col in ods_schema["columns"]:
|
||||
if col["name"] not in ods_cols_with_dwd and col["name"] not in etl_meta_cols:
|
||||
ods_no_dwd_fields.append(col["name"])
|
||||
|
||||
# ── DWD 无 ODS 源 ──
|
||||
dwd_no_ods_fields: list[tuple[str, str]] = [] # (dwd_table, dwd_col)
|
||||
for dwd_name, entries in d2o.items():
|
||||
for entry in entries:
|
||||
if entry.get("ods_source") == "—":
|
||||
dwd_no_ods_fields.append((dwd_name, entry["dwd_col"]))
|
||||
|
||||
# 差异原因
|
||||
reasons: list[str] = []
|
||||
if api_unmapped_nested:
|
||||
reasons.append(f"嵌套对象 {len(api_unmapped_nested)} 个")
|
||||
if api_unmapped_flat:
|
||||
reasons.append(f"平层未映射 {len(api_unmapped_flat)} 个")
|
||||
if dwd_no_ods_fields:
|
||||
reasons.append(f"SCD2/派生列 {len(dwd_no_ods_fields)} 个")
|
||||
reason_str = ";".join(reasons) if reasons else "—"
|
||||
|
||||
# 汇总表单元格:数量 + 跳转链接
|
||||
def _cell(count: int) -> str:
|
||||
if count == 0:
|
||||
return "0"
|
||||
return f"[{count}](#{diff_anchor})"
|
||||
|
||||
w(f"| `{table_name}` | {_cell(api_unmapped_total)} | {_cell(len(ods_no_json_fields))} | {_cell(len(ods_no_dwd_fields))} | {_cell(len(dwd_no_ods_fields))} | {reason_str} |")
|
||||
|
||||
diff_rows.append({
|
||||
"table_name": table_name,
|
||||
"diff_anchor": diff_anchor,
|
||||
"api_anchor": api_anchor,
|
||||
"ods_anchor": ods_anchor,
|
||||
"dwd_anchors": dwd_anchors,
|
||||
"api_unmapped_flat": api_unmapped_flat,
|
||||
"api_unmapped_nested": api_unmapped_nested,
|
||||
"ods_no_json_fields": ods_no_json_fields,
|
||||
"ods_no_dwd_fields": ods_no_dwd_fields,
|
||||
"dwd_no_ods_fields": dwd_no_ods_fields,
|
||||
})
|
||||
|
||||
w()
|
||||
|
||||
# ── 逐表差异分表 ──
|
||||
sub_idx = 0
|
||||
for row in diff_rows:
|
||||
if row is None:
|
||||
continue
|
||||
has_any = (row["api_unmapped_flat"] or row["api_unmapped_nested"]
|
||||
or row["ods_no_json_fields"] or row["ods_no_dwd_fields"]
|
||||
or row["dwd_no_ods_fields"])
|
||||
if not has_any:
|
||||
continue
|
||||
|
||||
sub_idx += 1
|
||||
table_name = row["table_name"]
|
||||
w(f'<a id="{row["diff_anchor"]}"></a>')
|
||||
w()
|
||||
w(f"#### 1.1.{sub_idx} {table_name} 字段差异明细")
|
||||
w()
|
||||
|
||||
api_anchor = row["api_anchor"]
|
||||
ods_anchor = row["ods_anchor"]
|
||||
dwd_anchors = row["dwd_anchors"]
|
||||
|
||||
# 加载辅助数据:json_trees(示例值)、bd_descriptions(业务说明)
|
||||
jt = load_json(data_dir / "json_trees" / f"{table_name}.json")
|
||||
bd = load_json(data_dir / "bd_descriptions" / f"{table_name}.json")
|
||||
jt_lookup: dict[str, dict] = {}
|
||||
if jt and "fields" in jt:
|
||||
for fld in jt["fields"]:
|
||||
jt_lookup[fld["path"]] = fld
|
||||
ods_descs = bd.get("ods_fields", {}) if bd else {}
|
||||
dwd_descs_all = bd.get("dwd_fields", {}) if bd else {}
|
||||
|
||||
def _sample_str(field_name: str, layer: str, dwd_tbl: str = "") -> str:
|
||||
"""从 json_trees 或 bd_descriptions 获取示例值字符串"""
|
||||
if layer == "API":
|
||||
entry = jt_lookup.get(field_name, {})
|
||||
samples = entry.get("samples", [])
|
||||
total_recs = entry.get("total_records", 0)
|
||||
if not samples:
|
||||
single = entry.get("sample", "")
|
||||
if single:
|
||||
samples = [str(single)]
|
||||
if _is_enum_like(samples, total_recs):
|
||||
return ", ".join(f"`{_esc(s)}`" for s in samples[:5])
|
||||
if samples:
|
||||
return _format_samples(samples, max_show=3)
|
||||
return ""
|
||||
|
||||
def _desc_str(field_name: str, layer: str, dwd_tbl: str = "") -> str:
|
||||
"""从 bd_descriptions 获取业务说明"""
|
||||
key = field_name.split(".")[-1].replace("[]", "").lower()
|
||||
if layer in ("ODS", "API"):
|
||||
desc = ods_descs.get(key, "")
|
||||
elif layer == "DWD" and dwd_tbl:
|
||||
desc = dwd_descs_all.get(dwd_tbl, {}).get(key, "")
|
||||
else:
|
||||
desc = ""
|
||||
if desc and len(desc) > 40:
|
||||
desc = desc[:37] + "..."
|
||||
return _esc(desc)
|
||||
|
||||
# ── API→ODS 未映射(平层) ──
|
||||
if row["api_unmapped_flat"]:
|
||||
w(f"**API→ODS 未映射(平层)** — {len(row['api_unmapped_flat'])} 个")
|
||||
w()
|
||||
w("| # | JSON 字段 | 推测用途 | 置信度 | 示例值 | 说明 | 状态 |")
|
||||
w("|---|----------|---------|-------|-------|------|------|")
|
||||
for i, f in enumerate(row["api_unmapped_flat"], 1):
|
||||
purpose, conf = _guess_field_purpose(f, table_name, "API")
|
||||
sample = _sample_str(f, "API")
|
||||
desc = _desc_str(f, "API")
|
||||
w(f"| {i} | **[`{_esc(f)}`](#{api_anchor})** | {_esc(purpose)} | {conf} | {sample} | {desc} | **⚠️ 未映射** |")
|
||||
w()
|
||||
|
||||
# ── API→ODS 未映射(嵌套对象) ──
|
||||
if row["api_unmapped_nested"]:
|
||||
w(f"<details><summary>API→ODS 未映射(嵌套对象)— {len(row['api_unmapped_nested'])} 个</summary>")
|
||||
w()
|
||||
w("| # | JSON 字段 | 推测用途 | 置信度 | 示例值 | 说明 | 状态 |")
|
||||
w("|---|----------|---------|-------|-------|------|------|")
|
||||
for i, f in enumerate(row["api_unmapped_nested"], 1):
|
||||
purpose, conf = _guess_field_purpose(f, table_name, "API")
|
||||
sample = _sample_str(f, "API")
|
||||
desc = _desc_str(f, "API")
|
||||
w(f"| {i} | [`{_esc(f)}`](#{api_anchor}) | {_esc(purpose)} | {conf} | {sample} | {desc} | 📦 嵌套 |")
|
||||
w()
|
||||
w("</details>")
|
||||
w()
|
||||
|
||||
# ── ODS 无 JSON 源 ──
|
||||
if row["ods_no_json_fields"]:
|
||||
w(f"**ODS 无 JSON 源** — {len(row['ods_no_json_fields'])} 个")
|
||||
w()
|
||||
w("| # | ODS 列 | 推测用途 | 置信度 | 说明 | 状态 |")
|
||||
w("|---|-------|---------|-------|------|------|")
|
||||
for i, f in enumerate(row["ods_no_json_fields"], 1):
|
||||
purpose, conf = _guess_field_purpose(f, table_name, "ODS")
|
||||
desc = _desc_str(f, "ODS")
|
||||
w(f"| {i} | **[`{_esc(f)}`](#{ods_anchor})** | {_esc(purpose)} | {conf} | {desc} | **⚠️ 无 JSON 源** |")
|
||||
w()
|
||||
|
||||
# ── ODS→DWD 未映射 ──
|
||||
if row["ods_no_dwd_fields"]:
|
||||
w(f"**ODS→DWD 未映射** — {len(row['ods_no_dwd_fields'])} 个")
|
||||
w()
|
||||
w("| # | ODS 列 | 推测用途 | 置信度 | 说明 | 状态 |")
|
||||
w("|---|-------|---------|-------|------|------|")
|
||||
for i, f in enumerate(row["ods_no_dwd_fields"], 1):
|
||||
purpose, conf = _guess_field_purpose(f, table_name, "ODS")
|
||||
desc = _desc_str(f, "ODS")
|
||||
w(f"| {i} | **[`{_esc(f)}`](#{ods_anchor})** | {_esc(purpose)} | {conf} | {desc} | **⚠️ 无 DWD 目标** |")
|
||||
w()
|
||||
|
||||
# ── DWD 无 ODS 源 ──
|
||||
if row["dwd_no_ods_fields"]:
|
||||
w(f"**DWD 无 ODS 源** — {len(row['dwd_no_ods_fields'])} 个")
|
||||
w()
|
||||
w("| # | DWD 表 | DWD 列 | 推测用途 | 置信度 | 说明 | 状态 |")
|
||||
w("|---|-------|-------|---------|-------|------|------|")
|
||||
for i, (dwd_name, dwd_col) in enumerate(row["dwd_no_ods_fields"], 1):
|
||||
dwd_a = dwd_anchors.get(dwd_name, f"dwd-{dwd_name.replace('_', '-')}")
|
||||
purpose, conf = _guess_field_purpose(dwd_col, table_name, "DWD")
|
||||
desc = _desc_str(dwd_col, "DWD", dwd_tbl=dwd_name)
|
||||
w(f"| {i} | {dwd_name} | **[`{_esc(dwd_col)}`](#{dwd_a})** | {_esc(purpose)} | {conf} | {desc} | **⚠️ 无 ODS 源** |")
|
||||
w()
|
||||
|
||||
w()
|
||||
|
||||
|
||||
|
||||
|
||||
def _write_api_section(w, fm, jt, bd, table_name, api_anchor, ods_anchor):
|
||||
"""生成 API 源字段区块(增加业务描述列,合并说明+示例值)"""
|
||||
w(f'<a id="{api_anchor}"></a>')
|
||||
w()
|
||||
w(f"#### API 源字段 — {table_name} [🔗 ODS](#{ods_anchor})")
|
||||
w()
|
||||
|
||||
if not fm or "json_to_ods" not in fm:
|
||||
w("_无 field_mappings 数据_")
|
||||
w()
|
||||
return
|
||||
|
||||
j2o = fm["json_to_ods"]
|
||||
# 构建 json_tree 查找表(含 samples)
|
||||
jt_lookup: dict[str, dict] = {}
|
||||
if jt and "fields" in jt:
|
||||
for f in jt["fields"]:
|
||||
jt_lookup[f["path"]] = f
|
||||
|
||||
# BD_manual ODS 描述(用于交叉引用 JSON 字段的业务含义)
|
||||
ods_descs = bd.get("ods_fields", {}) if bd else {}
|
||||
|
||||
mapped_count = sum(1 for m in j2o if m.get("ods_col") is not None)
|
||||
total_count = len(j2o)
|
||||
if total_count > 0:
|
||||
w(f"已映射 {mapped_count}/{total_count},覆盖率 {mapped_count / total_count * 100:.1f}%")
|
||||
else:
|
||||
w("无字段")
|
||||
w()
|
||||
w("| # | JSON 字段 | 类型 | → ODS 列 | 业务描述 | 示例值与说明 |")
|
||||
w("|---|----------|------|---------|---------|------------|")
|
||||
|
||||
for i, m in enumerate(j2o, 1):
|
||||
json_path = m["json_path"]
|
||||
json_type = m.get("json_type", "")
|
||||
ods_col = m.get("ods_col")
|
||||
match_type = m.get("match_type", "")
|
||||
occurrence_pct = m.get("occurrence_pct", 0)
|
||||
|
||||
# 从 json_tree 获取示例值(优先用 samples 多示例)
|
||||
jt_entry = jt_lookup.get(json_path, {})
|
||||
samples = jt_entry.get("samples", [])
|
||||
total_recs = jt_entry.get("total_records", 0)
|
||||
if not samples:
|
||||
single = jt_entry.get("sample", "")
|
||||
if single:
|
||||
samples = [str(single)]
|
||||
|
||||
# 构建 ODS 列链接
|
||||
if ods_col:
|
||||
ods_link = f"[`{ods_col}`](#{ods_anchor})"
|
||||
else:
|
||||
ods_link = "⚠️ 未映射"
|
||||
|
||||
# 业务描述(从 BD_manual 查找,用 ODS 列名或 JSON 叶子名)
|
||||
leaf = json_path.split(".")[-1].replace("[]", "").lower()
|
||||
biz_desc = ods_descs.get(leaf, "")
|
||||
if biz_desc and len(biz_desc) > 60:
|
||||
biz_desc = biz_desc[:57] + "..."
|
||||
biz_desc = _esc(biz_desc)
|
||||
|
||||
# 合并说明+示例值
|
||||
notes_parts: list[str] = []
|
||||
if json_path.startswith("siteProfile.") or ("." in json_path and match_type == "unmapped"):
|
||||
notes_parts.append("📦 嵌套对象")
|
||||
if match_type == "case_insensitive":
|
||||
notes_parts.append("大小写匹配")
|
||||
if occurrence_pct < 100:
|
||||
notes_parts.append(f"出现率 {occurrence_pct:.0f}%")
|
||||
|
||||
# 示例值展示
|
||||
if _is_enum_like(samples, total_recs):
|
||||
notes_parts.append(f"枚举值: {', '.join(f'`{_esc(s)}`' for s in samples[:8])}")
|
||||
elif samples:
|
||||
notes_parts.append(f"示例: {_format_samples(samples)}")
|
||||
|
||||
note_str = ";".join(notes_parts) if notes_parts else ""
|
||||
|
||||
w(f"| {i} | `{_esc(json_path)}` | {json_type} | {ods_link} | {biz_desc} | {note_str} |")
|
||||
|
||||
w()
|
||||
|
||||
|
||||
def _write_ods_section(w, fm, ods_schema, bd, table_name, ods_anchor, api_anchor, dwd_anchors):
|
||||
"""生成 ODS 表结构区块(含上下游双向映射列 + 业务描述)"""
|
||||
w(f'<a id="{ods_anchor}"></a>')
|
||||
w()
|
||||
w(f"#### ODS 表结构 — ods.{table_name} [🔗 API](#{api_anchor})")
|
||||
w()
|
||||
|
||||
if not ods_schema or "columns" not in ods_schema:
|
||||
w("_无 DB schema 数据_")
|
||||
w()
|
||||
return
|
||||
|
||||
# 构建 json_to_ods 反向查找:ods_col → json_path
|
||||
ods_to_json: dict[str, str] = {}
|
||||
if fm and "json_to_ods" in fm:
|
||||
for m in fm["json_to_ods"]:
|
||||
if m.get("ods_col"):
|
||||
ods_to_json.setdefault(m["ods_col"], m["json_path"])
|
||||
|
||||
# 构建 ods_to_dwd 查找
|
||||
ods_to_dwd: dict[str, list[dict]] = {}
|
||||
if fm and "ods_to_dwd" in fm:
|
||||
ods_to_dwd = fm["ods_to_dwd"]
|
||||
|
||||
# BD_manual ODS 描述
|
||||
ods_descs = bd.get("ods_fields", {}) if bd else {}
|
||||
|
||||
cols = ods_schema["columns"]
|
||||
w(f"共 {len(cols)} 列")
|
||||
w()
|
||||
w("| # | ODS 列名 | 类型 | ← JSON 源 | → DWD 目标 | 业务描述 |")
|
||||
w("|---|---------|------|----------|-----------|---------|")
|
||||
|
||||
for i, col in enumerate(cols, 1):
|
||||
col_name = col["name"]
|
||||
col_type = col["data_type"]
|
||||
|
||||
# ← JSON 源
|
||||
json_src = ods_to_json.get(col_name)
|
||||
if json_src:
|
||||
json_link = f"[`{_esc(json_src)}`](#{api_anchor})"
|
||||
else:
|
||||
json_link = "—"
|
||||
|
||||
# → DWD 目标
|
||||
dwd_targets = ods_to_dwd.get(col_name, [])
|
||||
if dwd_targets:
|
||||
dwd_links = []
|
||||
for dt in dwd_targets:
|
||||
dwd_tbl = dt["dwd_table"]
|
||||
dwd_col = dt["dwd_col"]
|
||||
dwd_anc = dwd_anchors.get(dwd_tbl, f"dwd-{dwd_tbl}")
|
||||
dwd_links.append(f"[`{dwd_tbl}.{dwd_col}`](#{dwd_anc})")
|
||||
dwd_link = ", ".join(dwd_links)
|
||||
else:
|
||||
dwd_link = "—"
|
||||
|
||||
# 业务描述
|
||||
biz_desc = ods_descs.get(col_name.lower(), "")
|
||||
if biz_desc and len(biz_desc) > 60:
|
||||
biz_desc = biz_desc[:57] + "..."
|
||||
biz_desc = _esc(biz_desc)
|
||||
|
||||
w(f"| {i} | `{col_name}` | {col_type} | {json_link} | {dwd_link} | {biz_desc} |")
|
||||
|
||||
w()
|
||||
|
||||
|
||||
def _write_dwd_section(w, fm, dwd_schema, bd, dwd_name, dwd_anchor, ods_anchor, table_name):
|
||||
"""生成 DWD 表结构区块(增加业务描述列)"""
|
||||
w(f'<a id="{dwd_anchor}"></a>')
|
||||
w()
|
||||
w(f"#### DWD 表结构 — dwd.{dwd_name} [🔗 ODS](#{ods_anchor})")
|
||||
w()
|
||||
|
||||
if not dwd_schema or "columns" not in dwd_schema:
|
||||
w("_无 DB schema 数据_")
|
||||
w()
|
||||
return
|
||||
|
||||
# 构建 dwd_to_ods 查找
|
||||
dwd_to_ods_map: dict[str, dict] = {}
|
||||
if fm and "dwd_to_ods" in fm and dwd_name in fm["dwd_to_ods"]:
|
||||
for entry in fm["dwd_to_ods"][dwd_name]:
|
||||
dwd_to_ods_map[entry["dwd_col"]] = entry
|
||||
|
||||
# BD_manual DWD 描述
|
||||
dwd_descs = {}
|
||||
if bd and "dwd_fields" in bd:
|
||||
dwd_descs = bd["dwd_fields"].get(dwd_name, {})
|
||||
|
||||
cols = dwd_schema["columns"]
|
||||
w(f"共 {len(cols)} 列")
|
||||
w()
|
||||
w("| # | DWD 列名 | 类型 | ← ODS 来源 | 转换 | 业务描述 |")
|
||||
w("|---|---------|------|----------|------|---------|")
|
||||
|
||||
for i, col in enumerate(cols, 1):
|
||||
col_name = col["name"]
|
||||
col_type = col["data_type"]
|
||||
|
||||
mapping = dwd_to_ods_map.get(col_name)
|
||||
if mapping:
|
||||
ods_src = mapping.get("ods_source", "")
|
||||
ods_link = f"[`{ods_src}`](#{ods_anchor})" if ods_src and ods_src != "—" else "—"
|
||||
transform = mapping.get("mapping_type", "")
|
||||
note = mapping.get("note", "")
|
||||
else:
|
||||
ods_link = "—"
|
||||
transform = ""
|
||||
note = ""
|
||||
if col_name in ("valid_from", "valid_to", "is_current", "etl_loaded_at", "etl_batch_id"):
|
||||
transform = "ETL 生成"
|
||||
|
||||
# 业务描述(优先 BD_manual,其次 mapping note,最后 DB comment)
|
||||
biz_desc = dwd_descs.get(col_name.lower(), "")
|
||||
if not biz_desc and note:
|
||||
biz_desc = note
|
||||
if not biz_desc:
|
||||
db_comment = col.get("comment", "")
|
||||
if db_comment:
|
||||
if "【说明】" in db_comment:
|
||||
desc_part = db_comment.split("【说明】")[1]
|
||||
if "【" in desc_part:
|
||||
desc_part = desc_part.split("【")[0]
|
||||
biz_desc = desc_part.strip().rstrip("。").strip()
|
||||
else:
|
||||
biz_desc = db_comment
|
||||
if biz_desc and len(biz_desc) > 60:
|
||||
biz_desc = biz_desc[:57] + "..."
|
||||
biz_desc = _esc(biz_desc)
|
||||
|
||||
w(f"| {i} | `{col_name}` | {col_type} | {ods_link} | {_esc(transform)} | {biz_desc} |")
|
||||
|
||||
w()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
load_dotenv(Path(".env"), override=False)
|
||||
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
data_dir = resolve_data_dir(args.output_dir)
|
||||
if not data_dir.exists():
|
||||
print(f"错误:数据目录不存在: {data_dir}")
|
||||
return
|
||||
|
||||
print(f"读取数据目录: {data_dir}")
|
||||
report = generate_report(data_dir)
|
||||
|
||||
now = datetime.now()
|
||||
filename = f"dataflow_{now.strftime('%Y-%m-%d_%H%M%S')}.md"
|
||||
output_path = data_dir / filename
|
||||
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
f.write(report)
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f"报告生成完成")
|
||||
print(f"{'='*60}")
|
||||
print(f" 输出路径: {output_path}")
|
||||
print(f" 文件大小: {output_path.stat().st_size / 1024:.1f} KB")
|
||||
print(f"{'='*60}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1204
scripts/ops/gen_full_dataflow_doc.py
Normal file
1204
scripts/ops/gen_full_dataflow_doc.py
Normal file
File diff suppressed because it is too large
Load Diff
121
scripts/ops/init_databases.py
Normal file
121
scripts/ops/init_databases.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""
|
||||
初始化 etl_feiqiu 和 zqyy_app 数据库的 DDL 执行脚本。
|
||||
通过 psycopg2 直接连接新库并执行 SQL 文件。
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import psycopg2
|
||||
|
||||
DB_HOST = "100.64.0.4"
|
||||
DB_PORT = 5432
|
||||
DB_USER = "local-Python"
|
||||
DB_PASSWORD = "Neo-local-1991125"
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
def execute_sql_file(conn, filepath, label=""):
|
||||
"""读取并执行一个 SQL 文件"""
|
||||
full_path = os.path.join(BASE_DIR, filepath)
|
||||
if not os.path.exists(full_path):
|
||||
print(f" [SKIP] 文件不存在: {filepath}")
|
||||
return False
|
||||
with open(full_path, "r", encoding="utf-8") as f:
|
||||
sql = f.read()
|
||||
if not sql.strip():
|
||||
print(f" [SKIP] 文件为空: {filepath}")
|
||||
return False
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(sql)
|
||||
conn.commit()
|
||||
print(f" [OK] {label or filepath}")
|
||||
return True
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
print(f" [FAIL] {label or filepath}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def init_etl_feiqiu():
|
||||
"""初始化 etl_feiqiu 数据库:六层 schema DDL + 种子数据"""
|
||||
print("\n=== 初始化 etl_feiqiu 数据库 ===")
|
||||
conn = psycopg2.connect(
|
||||
host=DB_HOST, port=DB_PORT, user=DB_USER,
|
||||
password=DB_PASSWORD, dbname="etl_feiqiu"
|
||||
)
|
||||
conn.autocommit = False
|
||||
|
||||
# 六层 schema DDL(按依赖顺序)
|
||||
schema_files = [
|
||||
("db/etl_feiqiu/schemas/meta.sql", "meta schema(调度元数据)"),
|
||||
("db/etl_feiqiu/schemas/ods.sql", "ods schema(原始数据)"),
|
||||
("db/etl_feiqiu/schemas/dwd.sql", "dwd schema(明细数据)"),
|
||||
("db/etl_feiqiu/schemas/core.sql", "core schema(跨门店标准化)"),
|
||||
("db/etl_feiqiu/schemas/dws.sql", "dws schema(汇总数据)"),
|
||||
("db/etl_feiqiu/schemas/app.sql", "app schema(RLS 视图层)"),
|
||||
]
|
||||
|
||||
success_count = 0
|
||||
fail_count = 0
|
||||
for filepath, label in schema_files:
|
||||
if execute_sql_file(conn, filepath, label):
|
||||
success_count += 1
|
||||
else:
|
||||
fail_count += 1
|
||||
|
||||
# 种子数据
|
||||
seed_files = [
|
||||
("db/etl_feiqiu/seeds/seed_ods_tasks.sql", "种子:ODS 任务"),
|
||||
("db/etl_feiqiu/seeds/seed_scheduler_tasks.sql", "种子:调度任务"),
|
||||
("db/etl_feiqiu/seeds/seed_dws_config.sql", "种子:DWS 配置"),
|
||||
("db/etl_feiqiu/seeds/seed_index_parameters.sql", "种子:指数参数"),
|
||||
]
|
||||
for filepath, label in seed_files:
|
||||
if execute_sql_file(conn, filepath, label):
|
||||
success_count += 1
|
||||
else:
|
||||
fail_count += 1
|
||||
|
||||
conn.close()
|
||||
print(f"\netl_feiqiu 完成: {success_count} 成功, {fail_count} 失败")
|
||||
return fail_count == 0
|
||||
|
||||
|
||||
def init_zqyy_app():
|
||||
"""初始化 zqyy_app 数据库:schema + 迁移 + 种子"""
|
||||
print("\n=== 初始化 zqyy_app 数据库 ===")
|
||||
conn = psycopg2.connect(
|
||||
host=DB_HOST, port=DB_PORT, user=DB_USER,
|
||||
password=DB_PASSWORD, dbname="zqyy_app"
|
||||
)
|
||||
conn.autocommit = False
|
||||
|
||||
files = [
|
||||
("db/zqyy_app/schemas/init.sql", "zqyy_app schema(用户/RBAC)"),
|
||||
("db/zqyy_app/migrations/20250715_create_admin_web_tables.sql", "迁移:admin_web 表"),
|
||||
("db/zqyy_app/seeds/admin_web_seed.sql", "种子:默认管理员"),
|
||||
]
|
||||
|
||||
success_count = 0
|
||||
fail_count = 0
|
||||
for filepath, label in files:
|
||||
if execute_sql_file(conn, filepath, label):
|
||||
success_count += 1
|
||||
else:
|
||||
fail_count += 1
|
||||
|
||||
conn.close()
|
||||
print(f"\nzqyy_app 完成: {success_count} 成功, {fail_count} 失败")
|
||||
return fail_count == 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("开始初始化数据库...")
|
||||
r1 = init_etl_feiqiu()
|
||||
r2 = init_zqyy_app()
|
||||
if r1 and r2:
|
||||
print("\n✓ 全部初始化成功")
|
||||
else:
|
||||
print("\n✗ 部分初始化失败,请检查上方错误信息")
|
||||
sys.exit(1)
|
||||
89
scripts/ops/mvp_db_setup.py
Normal file
89
scripts/ops/mvp_db_setup.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""
|
||||
MVP 数据库准备脚本
|
||||
|
||||
1. 检查 test_zqyy_app 库中 test schema 和 xcx-test 表是否存在
|
||||
2. 如果不存在则创建
|
||||
3. 插入测试数据 "t91"
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# 加载根 .env
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv(Path(__file__).resolve().parents[2] / ".env", override=False)
|
||||
|
||||
import psycopg2
|
||||
|
||||
DB_HOST = os.getenv("DB_HOST", "localhost")
|
||||
DB_PORT = os.getenv("DB_PORT", "5432")
|
||||
DB_USER = os.getenv("DB_USER", "")
|
||||
DB_PASSWORD = os.getenv("DB_PASSWORD", "")
|
||||
APP_DB_NAME = os.getenv("APP_DB_NAME", "test_zqyy_app")
|
||||
|
||||
|
||||
def main():
|
||||
print(f"连接数据库: {DB_HOST}:{DB_PORT}/{APP_DB_NAME} (用户: {DB_USER})")
|
||||
|
||||
conn = psycopg2.connect(
|
||||
host=DB_HOST,
|
||||
port=DB_PORT,
|
||||
user=DB_USER,
|
||||
password=DB_PASSWORD,
|
||||
dbname=APP_DB_NAME,
|
||||
)
|
||||
conn.autocommit = True
|
||||
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# 1. 检查 test schema
|
||||
cur.execute(
|
||||
"SELECT 1 FROM information_schema.schemata WHERE schema_name = 'test'"
|
||||
)
|
||||
if cur.fetchone():
|
||||
print("✓ test schema 已存在")
|
||||
else:
|
||||
cur.execute("CREATE SCHEMA test")
|
||||
print("✓ test schema 已创建")
|
||||
|
||||
# 2. 检查 xcx-test 表(注意表名含连字符,需要双引号)
|
||||
cur.execute("""
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'test' AND table_name = 'xcx-test'
|
||||
""")
|
||||
if cur.fetchone():
|
||||
print('✓ test."xcx-test" 表已存在')
|
||||
else:
|
||||
cur.execute("""
|
||||
CREATE TABLE test."xcx-test" (
|
||||
ti TEXT
|
||||
)
|
||||
""")
|
||||
print('✓ test."xcx-test" 表已创建')
|
||||
|
||||
# 3. 查看现有数据
|
||||
cur.execute('SELECT ti FROM test."xcx-test" LIMIT 5')
|
||||
rows = cur.fetchall()
|
||||
if rows:
|
||||
print(f" 现有数据: {[r[0] for r in rows]}")
|
||||
|
||||
# 4. 插入 "t91"
|
||||
cur.execute(
|
||||
'INSERT INTO test."xcx-test" (ti) VALUES (%s)', ("t91",)
|
||||
)
|
||||
print('✓ 已插入 ti = "t91"')
|
||||
|
||||
# 5. 验证
|
||||
cur.execute('SELECT ti FROM test."xcx-test" ORDER BY ti')
|
||||
rows = cur.fetchall()
|
||||
print(f" 当前全部数据: {[r[0] for r in rows]}")
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
print("\n数据库准备完成。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
688
scripts/ops/redesign_board_customer.py
Normal file
688
scripts/ops/redesign_board_customer.py
Normal file
@@ -0,0 +1,688 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""重新设计 board-customer.html 各维度的差异化卡片布局。
|
||||
每个维度有独特的视觉重点和数据展示方式。"""
|
||||
|
||||
import pathlib, re
|
||||
|
||||
TARGET = pathlib.Path("docs/h5_ui/pages/board-customer.html")
|
||||
html = TARGET.read_text(encoding="utf-8")
|
||||
|
||||
# ── 助教行模板(复用) ──
|
||||
def coach_row(coaches_html):
|
||||
return f''' <div class="text-xs border-t border-gray-1 pt-2 ml-11">
|
||||
<span class="text-gray-6">助教:</span>
|
||||
{coaches_html}
|
||||
</div>'''
|
||||
|
||||
# 常用助教组合
|
||||
COACHES_A = '''<span class="assistant-tag">❤️ <span class="assistant-assignee">小燕</span><span class="assistant-badge assistant-badge-follow">跟</span></span>
|
||||
<span class="assistant-sep">|</span>
|
||||
<span class="assistant-tag">❤️ <span class="assistant-normal">泡芙</span></span>
|
||||
<span class="assistant-sep">|</span>
|
||||
<span class="assistant-tag">❤️ <span class="assistant-abandoned">Amy</span><span class="assistant-badge assistant-badge-drop">弃</span></span>'''
|
||||
|
||||
COACHES_B = '''<span class="assistant-tag">❤️ <span class="assistant-normal">Amy</span></span>
|
||||
<span class="assistant-sep">|</span>
|
||||
<span class="assistant-tag">❤️ <span class="assistant-normal">小燕</span></span>'''
|
||||
|
||||
COACHES_C = '''<span class="assistant-tag">❤️ <span class="assistant-assignee">泡芙</span><span class="assistant-badge assistant-badge-follow">跟</span></span>
|
||||
<span class="assistant-sep">|</span>
|
||||
<span class="assistant-tag">❤️ <span class="assistant-normal">小燕</span></span>'''
|
||||
|
||||
COACHES_D = '''<span class="assistant-tag">❤️ <span class="assistant-normal">泡芙</span></span>
|
||||
<span class="assistant-sep">|</span>
|
||||
<span class="assistant-tag">❤️ <span class="assistant-assignee">小燕</span><span class="assistant-badge assistant-badge-follow">跟</span></span>'''
|
||||
|
||||
COACHES_E = '''<span class="assistant-tag">❤️ <span class="assistant-normal">Amy</span></span>
|
||||
<span class="assistant-sep">|</span>
|
||||
<span class="assistant-tag">❤️ <span class="assistant-abandoned">泡芙</span><span class="assistant-badge assistant-badge-drop">弃</span></span>'''
|
||||
|
||||
COACHES_F = '''<span class="assistant-tag">❤️ <span class="assistant-normal">小燕</span></span>
|
||||
<span class="assistant-sep">|</span>
|
||||
<span class="assistant-tag">❤️ <span class="assistant-normal">泡芙</span></span>
|
||||
<span class="assistant-sep">|</span>
|
||||
<span class="assistant-tag">❤️ <span class="assistant-abandoned">Amy</span><span class="assistant-badge assistant-badge-drop">弃</span></span>'''
|
||||
|
||||
COACHES_G = '''<span class="assistant-tag">❤️ <span class="assistant-assignee">小燕</span><span class="assistant-badge assistant-badge-follow">跟</span></span>
|
||||
<span class="assistant-sep">|</span>
|
||||
<span class="assistant-tag">❤️ <span class="assistant-normal">泡芙</span></span>'''
|
||||
|
||||
COACHES_H = '''<span class="assistant-tag">❤️ <span class="assistant-normal">泡芙</span></span>
|
||||
<span class="assistant-sep">|</span>
|
||||
<span class="assistant-tag">❤️ <span class="assistant-normal">Amy</span></span>'''
|
||||
|
||||
COACHES_I = '''<span class="assistant-tag">❤️ <span class="assistant-assignee">小燕</span><span class="assistant-badge assistant-badge-follow">跟</span></span>
|
||||
<span class="assistant-sep">|</span>
|
||||
<span class="assistant-tag">❤️ <span class="assistant-abandoned">泡芙</span><span class="assistant-badge assistant-badge-drop">弃</span></span>'''
|
||||
|
||||
COACHES_J = '''<span class="assistant-tag">❤️ <span class="assistant-assignee">Amy</span><span class="assistant-badge assistant-badge-follow">跟</span></span>
|
||||
<span class="assistant-sep">|</span>
|
||||
<span class="assistant-tag">❤️ <span class="assistant-normal">小燕</span></span>'''
|
||||
|
||||
# ── 头像模板 ──
|
||||
def avatar(color_from, color_to, char):
|
||||
return f'''<div class="w-9 h-9 rounded-xl bg-gradient-to-br from-{color_from} to-{color_to} flex items-center justify-center shrink-0">
|
||||
<span class="text-white font-semibold text-sm">{char}</span>
|
||||
</div>'''
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# 维度1: 最应召回 — 突出超期天数红色警告 + 进度条
|
||||
# ══════════════════════════════════════════════════════════
|
||||
DIM_RECALL = '''
|
||||
<!-- ==================== 最应召回(默认) ==================== -->
|
||||
<div id="dim-recall" class="dim-container active p-4 pt-1 space-y-3">
|
||||
<a href="customer-detail.html" class="block bg-white rounded-2xl p-4 shadow-sm customer-card">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
''' + avatar("amber-400","orange-500","王") + '''
|
||||
<span class="font-semibold text-gray-13">王先生</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="px-2 py-0.5 bg-error/10 text-error text-xs font-bold rounded-full">超期 8天</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 召回进度条:理想间隔 vs 实际 -->
|
||||
<div class="ml-11 mb-2">
|
||||
<div class="flex items-center justify-between text-[10px] text-gray-6 mb-1">
|
||||
<span>理想间隔 7天</span>
|
||||
<span class="text-error font-medium">已过 15天</span>
|
||||
</div>
|
||||
<div class="h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div class="h-full rounded-full bg-gradient-to-r from-warning to-error" style="width:100%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-1 text-xs ml-11 mb-2">
|
||||
<div class="text-gray-6">30天到店 <span class="text-gray-11 font-medium">5次</span></div>
|
||||
<div class="text-gray-6">余额 <span class="text-gray-11 font-medium">¥8,000</span></div>
|
||||
<div class="text-gray-6">召回指数 <span class="text-primary font-bold">0.92</span></div>
|
||||
</div>
|
||||
''' + coach_row(COACHES_A) + '''
|
||||
</a>
|
||||
|
||||
<a href="customer-detail.html" class="block bg-white rounded-2xl p-4 shadow-sm customer-card">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
''' + avatar("pink-400","rose-500","李") + '''
|
||||
<span class="font-semibold text-gray-13">李女士</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="px-2 py-0.5 bg-warning/10 text-warning text-xs font-bold rounded-full">超期 10天</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-11 mb-2">
|
||||
<div class="flex items-center justify-between text-[10px] text-gray-6 mb-1">
|
||||
<span>理想间隔 10天</span>
|
||||
<span class="text-warning font-medium">已过 20天</span>
|
||||
</div>
|
||||
<div class="h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div class="h-full rounded-full bg-gradient-to-r from-warning to-error" style="width:100%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-1 text-xs ml-11 mb-2">
|
||||
<div class="text-gray-6">30天到店 <span class="text-gray-11 font-medium">3次</span></div>
|
||||
<div class="text-gray-6">余额 <span class="text-gray-11 font-medium">¥12,500</span></div>
|
||||
<div class="text-gray-6">召回指数 <span class="text-primary font-bold">0.88</span></div>
|
||||
</div>
|
||||
''' + coach_row(COACHES_B) + '''
|
||||
</a>
|
||||
|
||||
<a href="customer-detail.html" class="block bg-white rounded-2xl p-4 shadow-sm customer-card">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
''' + avatar("blue-400","indigo-500","张") + '''
|
||||
<span class="font-semibold text-gray-13">张先生</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="px-2 py-0.5 bg-error/10 text-error text-xs font-bold rounded-full">超期 11天</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-11 mb-2">
|
||||
<div class="flex items-center justify-between text-[10px] text-gray-6 mb-1">
|
||||
<span>理想间隔 7天</span>
|
||||
<span class="text-error font-medium">已过 18天</span>
|
||||
</div>
|
||||
<div class="h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div class="h-full rounded-full bg-gradient-to-r from-warning to-error" style="width:100%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-1 text-xs ml-11 mb-2">
|
||||
<div class="text-gray-6">30天到店 <span class="text-gray-11 font-medium">2次</span></div>
|
||||
<div class="text-gray-6">余额 <span class="text-gray-11 font-medium">¥3,200</span></div>
|
||||
<div class="text-gray-6">召回指数 <span class="text-primary font-bold">0.85</span></div>
|
||||
</div>
|
||||
''' + coach_row(COACHES_C) + '''
|
||||
</a>
|
||||
</div>
|
||||
'''
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# 维度2: 最大消费潜力 — 突出潜力评级 + 消费趋势
|
||||
# ══════════════════════════════════════════════════════════
|
||||
DIM_POTENTIAL = '''
|
||||
<!-- ==================== 最大消费潜力 ==================== -->
|
||||
<div id="dim-potential" class="dim-container p-4 pt-1 space-y-3">
|
||||
<a href="customer-detail.html" class="block bg-white rounded-2xl p-4 shadow-sm customer-card">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
''' + avatar("rose-400","pink-500","赵") + '''
|
||||
<span class="font-semibold text-gray-13">赵女士</span>
|
||||
</div>
|
||||
<div class="text-right shrink-0">
|
||||
<div class="text-primary font-bold">0.95</div>
|
||||
<div class="text-[10px] text-gray-6 -mt-0.5">消费潜力</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 潜力评级条 -->
|
||||
<div class="ml-11 mb-2 bg-gradient-to-r from-primary/5 to-blue-50 rounded-lg p-2.5">
|
||||
<div class="flex items-center gap-2 mb-1.5">
|
||||
<span class="text-xs font-bold text-primary">🔥 S级潜力</span>
|
||||
<span class="text-[10px] text-gray-6">高频 · 高客单 · 高余额</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2 text-center">
|
||||
<div><p class="text-xs font-bold text-gray-13">¥4,800</p><p class="text-[10px] text-gray-6">近30天消费</p></div>
|
||||
<div><p class="text-xs font-bold text-gray-13">8次</p><p class="text-[10px] text-gray-6">月均到店</p></div>
|
||||
<div><p class="text-xs font-bold text-success">¥15,000</p><p class="text-[10px] text-gray-6">余额</p></div>
|
||||
</div>
|
||||
</div>
|
||||
''' + coach_row(COACHES_D) + '''
|
||||
</a>
|
||||
|
||||
<a href="customer-detail.html" class="block bg-white rounded-2xl p-4 shadow-sm customer-card">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
''' + avatar("green-400","emerald-500","刘") + '''
|
||||
<span class="font-semibold text-gray-13">刘先生</span>
|
||||
</div>
|
||||
<div class="text-right shrink-0">
|
||||
<div class="text-primary font-bold">0.87</div>
|
||||
<div class="text-[10px] text-gray-6 -mt-0.5">消费潜力</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-11 mb-2 bg-gradient-to-r from-primary/5 to-blue-50 rounded-lg p-2.5">
|
||||
<div class="flex items-center gap-2 mb-1.5">
|
||||
<span class="text-xs font-bold text-primary">⭐ A级潜力</span>
|
||||
<span class="text-[10px] text-gray-6">中频 · 高客单</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2 text-center">
|
||||
<div><p class="text-xs font-bold text-gray-13">¥3,500</p><p class="text-[10px] text-gray-6">近30天消费</p></div>
|
||||
<div><p class="text-xs font-bold text-gray-13">5次</p><p class="text-[10px] text-gray-6">月均到店</p></div>
|
||||
<div><p class="text-xs font-bold text-success">¥6,800</p><p class="text-[10px] text-gray-6">余额</p></div>
|
||||
</div>
|
||||
</div>
|
||||
''' + coach_row(COACHES_E) + '''
|
||||
</a>
|
||||
</div>
|
||||
'''
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# 维度3: 最高余额 — 突出余额大字 + 消耗速率
|
||||
# ══════════════════════════════════════════════════════════
|
||||
DIM_BALANCE = '''
|
||||
<!-- ==================== 最高余额 ==================== -->
|
||||
<div id="dim-balance" class="dim-container p-4 pt-1 space-y-3">
|
||||
<a href="customer-detail.html" class="block bg-white rounded-2xl p-4 shadow-sm customer-card">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<div class="flex items-center gap-2">
|
||||
''' + avatar("rose-400","pink-500","赵") + '''
|
||||
<span class="font-semibold text-gray-13">赵女士</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 余额突出展示 -->
|
||||
<div class="ml-11 mb-2">
|
||||
<div class="flex items-baseline gap-1.5">
|
||||
<span class="text-2xl font-bold text-warning">¥25,000</span>
|
||||
<span class="text-[10px] text-gray-6">余额</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mt-1.5 text-xs">
|
||||
<div class="text-gray-6">60天消费 <span class="text-gray-11 font-medium">¥6,200</span></div>
|
||||
<div class="text-gray-6">月均消耗 <span class="text-gray-11 font-medium">¥3,100</span></div>
|
||||
</div>
|
||||
<!-- 余额消耗预估 -->
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
<div class="flex-1 h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div class="h-full rounded-full bg-gradient-to-r from-success to-emerald-400" style="width:25%"></div>
|
||||
</div>
|
||||
<span class="text-[10px] text-gray-6 shrink-0">预计可用 <span class="text-success font-medium">8个月</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-1 text-xs ml-11 mb-2">
|
||||
<div class="text-gray-6">最近到店 <span class="text-gray-11 font-medium">12天前</span></div>
|
||||
<div class="text-gray-6">理想间隔 <span class="text-gray-11 font-medium">10天</span></div>
|
||||
</div>
|
||||
''' + coach_row(COACHES_F) + '''
|
||||
</a>
|
||||
|
||||
<a href="customer-detail.html" class="block bg-white rounded-2xl p-4 shadow-sm customer-card">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<div class="flex items-center gap-2">
|
||||
''' + avatar("purple-400","violet-500","陈") + '''
|
||||
<span class="font-semibold text-gray-13">陈先生</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-11 mb-2">
|
||||
<div class="flex items-baseline gap-1.5">
|
||||
<span class="text-2xl font-bold text-warning">¥18,500</span>
|
||||
<span class="text-[10px] text-gray-6">余额</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mt-1.5 text-xs">
|
||||
<div class="text-gray-6">60天消费 <span class="text-gray-11 font-medium">¥3,800</span></div>
|
||||
<div class="text-gray-6">月均消耗 <span class="text-gray-11 font-medium">¥1,900</span></div>
|
||||
</div>
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
<div class="flex-1 h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div class="h-full rounded-full bg-gradient-to-r from-success to-emerald-400" style="width:10%"></div>
|
||||
</div>
|
||||
<span class="text-[10px] text-gray-6 shrink-0">预计可用 <span class="text-success font-medium">9.7个月</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-1 text-xs ml-11 mb-2">
|
||||
<div class="text-gray-6">最近到店 <span class="text-gray-11 font-medium">8天前</span></div>
|
||||
<div class="text-gray-6">理想间隔 <span class="text-gray-11 font-medium">14天</span></div>
|
||||
</div>
|
||||
''' + coach_row(COACHES_G) + '''
|
||||
</a>
|
||||
</div>
|
||||
'''
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# 维度4: 最近充值 — 时间线样式 + 充值金额突出
|
||||
# ══════════════════════════════════════════════════════════
|
||||
DIM_RECHARGE = '''
|
||||
<!-- ==================== 最近充值 ==================== -->
|
||||
<div id="dim-recharge" class="dim-container p-4 pt-1 space-y-3">
|
||||
<a href="customer-detail.html" class="block bg-white rounded-2xl p-4 shadow-sm customer-card">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
''' + avatar("indigo-400","blue-500","吴") + '''
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="font-semibold text-gray-13">吴先生</span>
|
||||
</div>
|
||||
<span class="text-[10px] text-gray-6">2天前充值</span>
|
||||
</div>
|
||||
<!-- 充值信息突出 -->
|
||||
<div class="ml-11 mb-2 bg-gradient-to-r from-emerald-50 to-green-50 rounded-lg p-2.5 border border-emerald-100/50">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<span class="text-lg font-bold text-success">+¥5,000</span>
|
||||
<span class="text-[10px] text-gray-6 ml-1">充值</span>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-xs text-gray-6">充后余额</div>
|
||||
<div class="text-sm font-bold text-gray-13">¥8,200</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-1 text-xs ml-11 mb-2">
|
||||
<div class="text-gray-6">最近到店 <span class="text-gray-11 font-medium">2天前</span></div>
|
||||
<div class="text-gray-6">理想间隔 <span class="text-gray-11 font-medium">7天</span></div>
|
||||
<div class="text-gray-6">本年充值 <span class="text-success font-medium">3次</span></div>
|
||||
</div>
|
||||
''' + coach_row(COACHES_B) + '''
|
||||
</a>
|
||||
|
||||
<a href="customer-detail.html" class="block bg-white rounded-2xl p-4 shadow-sm customer-card">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
''' + avatar("orange-400","amber-500","郑") + '''
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="font-semibold text-gray-13">郑女士</span>
|
||||
</div>
|
||||
<span class="text-[10px] text-gray-6">5天前充值</span>
|
||||
</div>
|
||||
<div class="ml-11 mb-2 bg-gradient-to-r from-emerald-50 to-green-50 rounded-lg p-2.5 border border-emerald-100/50">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<span class="text-lg font-bold text-success">+¥3,000</span>
|
||||
<span class="text-[10px] text-gray-6 ml-1">充值</span>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-xs text-gray-6">充后余额</div>
|
||||
<div class="text-sm font-bold text-gray-13">¥6,500</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-1 text-xs ml-11 mb-2">
|
||||
<div class="text-gray-6">最近到店 <span class="text-gray-11 font-medium">5天前</span></div>
|
||||
<div class="text-gray-6">理想间隔 <span class="text-gray-11 font-medium">10天</span></div>
|
||||
<div class="text-gray-6">本年充值 <span class="text-success font-medium">2次</span></div>
|
||||
</div>
|
||||
''' + coach_row(COACHES_C) + '''
|
||||
</a>
|
||||
</div>
|
||||
'''
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# 维度5: 最高消费 近60天 — 消费金额突出 + 排名徽章
|
||||
# ══════════════════════════════════════════════════════════
|
||||
DIM_SPEND60 = '''
|
||||
<!-- ==================== 最高消费 近60天 ==================== -->
|
||||
<div id="dim-spend60" class="dim-container p-4 pt-1 space-y-3">
|
||||
<a href="customer-detail.html" class="block bg-white rounded-2xl p-4 shadow-sm customer-card">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
''' + avatar("pink-400","rose-500","李") + '''
|
||||
<span class="font-semibold text-gray-13">李女士</span>
|
||||
<span class="w-5 h-5 bg-gradient-to-br from-amber-400 to-orange-500 rounded-full flex items-center justify-center text-white text-[10px] font-bold">1</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 消费金额突出 -->
|
||||
<div class="ml-11 mb-2">
|
||||
<div class="flex items-baseline gap-1">
|
||||
<span class="text-2xl font-bold text-warning">¥12,800</span>
|
||||
<span class="text-[10px] text-gray-6">近60天消费</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2 mt-2 text-center">
|
||||
<div class="bg-gray-50 rounded-lg py-1.5">
|
||||
<p class="text-xs font-bold text-gray-13">18次</p>
|
||||
<p class="text-[10px] text-gray-6">到店</p>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-lg py-1.5">
|
||||
<p class="text-xs font-bold text-gray-13">¥711</p>
|
||||
<p class="text-[10px] text-gray-6">次均消费</p>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-lg py-1.5">
|
||||
<p class="text-xs font-bold text-success">¥8,200</p>
|
||||
<p class="text-[10px] text-gray-6">余额</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
''' + coach_row(COACHES_A) + '''
|
||||
</a>
|
||||
|
||||
<a href="customer-detail.html" class="block bg-white rounded-2xl p-4 shadow-sm customer-card">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
''' + avatar("amber-400","orange-500","王") + '''
|
||||
<span class="font-semibold text-gray-13">王先生</span>
|
||||
<span class="w-5 h-5 bg-gradient-to-br from-gray-300 to-gray-400 rounded-full flex items-center justify-center text-white text-[10px] font-bold">2</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-11 mb-2">
|
||||
<div class="flex items-baseline gap-1">
|
||||
<span class="text-2xl font-bold text-warning">¥9,500</span>
|
||||
<span class="text-[10px] text-gray-6">近60天消费</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2 mt-2 text-center">
|
||||
<div class="bg-gray-50 rounded-lg py-1.5">
|
||||
<p class="text-xs font-bold text-gray-13">12次</p>
|
||||
<p class="text-[10px] text-gray-6">到店</p>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-lg py-1.5">
|
||||
<p class="text-xs font-bold text-gray-13">¥792</p>
|
||||
<p class="text-[10px] text-gray-6">次均消费</p>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-lg py-1.5">
|
||||
<p class="text-xs font-bold text-success">¥5,000</p>
|
||||
<p class="text-[10px] text-gray-6">余额</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
''' + coach_row(COACHES_H) + '''
|
||||
</a>
|
||||
</div>
|
||||
'''
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# 维度6: 最频繁 近60天 — 到店天数突出 + 频率柱状图
|
||||
# ══════════════════════════════════════════════════════════
|
||||
DIM_FREQ60 = '''
|
||||
<!-- ==================== 最频繁 近60天 ==================== -->
|
||||
<div id="dim-freq60" class="dim-container p-4 pt-1 space-y-3">
|
||||
<a href="customer-detail.html" class="block bg-white rounded-2xl p-4 shadow-sm customer-card">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
''' + avatar("blue-400","indigo-500","张") + '''
|
||||
<span class="font-semibold text-gray-13">张先生</span>
|
||||
</div>
|
||||
<div class="text-right shrink-0">
|
||||
<div class="text-primary font-bold text-lg">18<span class="text-xs font-normal text-gray-6">天</span></div>
|
||||
<div class="text-[10px] text-gray-6 -mt-0.5">60天到店</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 频率可视化:简易柱状图 -->
|
||||
<div class="ml-11 mb-2">
|
||||
<div class="flex items-end gap-0.5 h-6">
|
||||
<div class="flex-1 bg-primary/20 rounded-t" style="height:60%"></div>
|
||||
<div class="flex-1 bg-primary/30 rounded-t" style="height:80%"></div>
|
||||
<div class="flex-1 bg-primary/40 rounded-t" style="height:50%"></div>
|
||||
<div class="flex-1 bg-primary/50 rounded-t" style="height:100%"></div>
|
||||
<div class="flex-1 bg-primary/40 rounded-t" style="height:70%"></div>
|
||||
<div class="flex-1 bg-primary/60 rounded-t" style="height:90%"></div>
|
||||
<div class="flex-1 bg-primary/30 rounded-t" style="height:40%"></div>
|
||||
<div class="flex-1 bg-primary/50 rounded-t" style="height:75%"></div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-[10px] text-gray-5 mt-0.5">
|
||||
<span>8周前</span>
|
||||
<span>本周</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-1 text-xs ml-11 mb-2">
|
||||
<div class="text-gray-6">平均间隔 <span class="text-primary font-medium">3.3天</span></div>
|
||||
<div class="text-gray-6">60天消费 <span class="text-gray-11 font-medium">¥8,600</span></div>
|
||||
<div class="text-gray-6">余额 <span class="text-gray-11 font-medium">¥4,200</span></div>
|
||||
</div>
|
||||
''' + coach_row(COACHES_F) + '''
|
||||
</a>
|
||||
|
||||
<a href="customer-detail.html" class="block bg-white rounded-2xl p-4 shadow-sm customer-card">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
''' + avatar("purple-400","violet-500","陈") + '''
|
||||
<span class="font-semibold text-gray-13">陈先生</span>
|
||||
</div>
|
||||
<div class="text-right shrink-0">
|
||||
<div class="text-primary font-bold text-lg">15<span class="text-xs font-normal text-gray-6">天</span></div>
|
||||
<div class="text-[10px] text-gray-6 -mt-0.5">60天到店</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-11 mb-2">
|
||||
<div class="flex items-end gap-0.5 h-6">
|
||||
<div class="flex-1 bg-primary/20 rounded-t" style="height:40%"></div>
|
||||
<div class="flex-1 bg-primary/30 rounded-t" style="height:60%"></div>
|
||||
<div class="flex-1 bg-primary/40 rounded-t" style="height:70%"></div>
|
||||
<div class="flex-1 bg-primary/30 rounded-t" style="height:50%"></div>
|
||||
<div class="flex-1 bg-primary/50 rounded-t" style="height:80%"></div>
|
||||
<div class="flex-1 bg-primary/40 rounded-t" style="height:65%"></div>
|
||||
<div class="flex-1 bg-primary/60 rounded-t" style="height:90%"></div>
|
||||
<div class="flex-1 bg-primary/50 rounded-t" style="height:70%"></div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-[10px] text-gray-5 mt-0.5">
|
||||
<span>8周前</span>
|
||||
<span>本周</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-1 text-xs ml-11 mb-2">
|
||||
<div class="text-gray-6">平均间隔 <span class="text-primary font-medium">4天</span></div>
|
||||
<div class="text-gray-6">60天消费 <span class="text-gray-11 font-medium">¥6,200</span></div>
|
||||
<div class="text-gray-6">余额 <span class="text-gray-11 font-medium">¥7,800</span></div>
|
||||
</div>
|
||||
''' + coach_row(COACHES_J) + '''
|
||||
</a>
|
||||
</div>
|
||||
'''
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# 维度7: 最近到店 — 突出"X天前"大字 + 新鲜度色彩
|
||||
# ══════════════════════════════════════════════════════════
|
||||
DIM_RECENT = '''
|
||||
<!-- ==================== 最近到店 ==================== -->
|
||||
<div id="dim-recent" class="dim-container p-4 pt-1 space-y-3">
|
||||
<a href="customer-detail.html" class="block bg-white rounded-2xl p-4 shadow-sm customer-card">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
''' + avatar("green-400","emerald-500","刘") + '''
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="font-semibold text-gray-13">刘先生</span>
|
||||
</div>
|
||||
<!-- 新鲜度大字 -->
|
||||
<div class="flex items-baseline gap-0.5 shrink-0">
|
||||
<span class="text-2xl font-bold text-success">1</span>
|
||||
<span class="text-xs text-gray-6">天前到店</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-11 mb-2">
|
||||
<!-- 新鲜度条 -->
|
||||
<div class="flex items-center gap-2 mb-1.5">
|
||||
<div class="flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div class="h-full rounded-full bg-gradient-to-r from-success to-emerald-300" style="width:95%"></div>
|
||||
</div>
|
||||
<span class="text-[10px] text-success font-medium shrink-0">极新鲜</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
||||
<div class="text-gray-6">理想间隔 <span class="text-gray-11 font-medium">7天</span></div>
|
||||
<div class="text-gray-6">60天到店 <span class="text-gray-11 font-medium">12天</span></div>
|
||||
<div class="text-gray-6">余额 <span class="text-gray-11 font-medium">¥9,200</span></div>
|
||||
</div>
|
||||
</div>
|
||||
''' + coach_row(COACHES_H) + '''
|
||||
</a>
|
||||
|
||||
<a href="customer-detail.html" class="block bg-white rounded-2xl p-4 shadow-sm customer-card">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
''' + avatar("cyan-400","teal-500","周") + '''
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="font-semibold text-gray-13">周先生</span>
|
||||
</div>
|
||||
<div class="flex items-baseline gap-0.5 shrink-0">
|
||||
<span class="text-2xl font-bold text-success">2</span>
|
||||
<span class="text-xs text-gray-6">天前到店</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-11 mb-2">
|
||||
<div class="flex items-center gap-2 mb-1.5">
|
||||
<div class="flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div class="h-full rounded-full bg-gradient-to-r from-success to-emerald-300" style="width:85%"></div>
|
||||
</div>
|
||||
<span class="text-[10px] text-success font-medium shrink-0">很新鲜</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
||||
<div class="text-gray-6">理想间隔 <span class="text-gray-11 font-medium">10天</span></div>
|
||||
<div class="text-gray-6">60天到店 <span class="text-gray-11 font-medium">8天</span></div>
|
||||
<div class="text-gray-6">余额 <span class="text-gray-11 font-medium">¥5,500</span></div>
|
||||
</div>
|
||||
</div>
|
||||
''' + coach_row(COACHES_I) + '''
|
||||
</a>
|
||||
</div>
|
||||
'''
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# 维度8: 最专一 — 助教关系优先 + 关系指数条
|
||||
# ══════════════════════════════════════════════════════════
|
||||
DIM_LOYAL = '''
|
||||
<!-- ==================== 最专一 ==================== -->
|
||||
<div id="dim-loyal" class="dim-container p-4 pt-1 space-y-3">
|
||||
<a href="customer-detail.html" class="block bg-white rounded-2xl p-4 shadow-sm customer-card">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
''' + avatar("teal-400","cyan-500","孙") + '''
|
||||
<span class="font-semibold text-gray-13">孙先生</span>
|
||||
</div>
|
||||
<div class="text-right shrink-0">
|
||||
<div class="text-primary font-bold">0.81</div>
|
||||
<div class="text-[10px] text-gray-6 -mt-0.5">专一指数</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 助教关系指数条 -->
|
||||
<div class="ml-11 mb-2 space-y-1.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs font-medium text-gray-13 w-8 shrink-0">小燕</span>
|
||||
<div class="flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div class="h-full rounded-full bg-gradient-to-r from-error to-pink-400" style="width:95%"></div>
|
||||
</div>
|
||||
<span class="text-[10px] text-gray-9 font-medium w-7 text-right">0.95</span>
|
||||
<span class="assistant-badge assistant-badge-follow text-[9px]">跟</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs font-medium text-gray-13 w-8 shrink-0">泡芙</span>
|
||||
<div class="flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div class="h-full rounded-full bg-gradient-to-r from-warning to-amber-400" style="width:68%"></div>
|
||||
</div>
|
||||
<span class="text-[10px] text-gray-9 font-medium w-7 text-right">0.68</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs font-medium text-gray-7 w-8 shrink-0">Amy</span>
|
||||
<div class="flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div class="h-full rounded-full bg-gray-300" style="width:32%"></div>
|
||||
</div>
|
||||
<span class="text-[10px] text-gray-5 font-medium w-7 text-right">0.32</span>
|
||||
<span class="assistant-badge assistant-badge-drop text-[9px]">弃</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-1 text-xs ml-11">
|
||||
<div class="text-gray-6">最近到店 <span class="text-gray-11 font-medium">6天前</span></div>
|
||||
<div class="text-gray-6">60天到店 <span class="text-gray-11 font-medium">10天</span></div>
|
||||
<div class="text-gray-6">余额 <span class="text-gray-11 font-medium">¥7,200</span></div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="customer-detail.html" class="block bg-white rounded-2xl p-4 shadow-sm customer-card">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
''' + avatar("rose-400","pink-500","赵") + '''
|
||||
<span class="font-semibold text-gray-13">赵女士</span>
|
||||
</div>
|
||||
<div class="text-right shrink-0">
|
||||
<div class="text-primary font-bold">0.90</div>
|
||||
<div class="text-[10px] text-gray-6 -mt-0.5">专一指数</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-11 mb-2 space-y-1.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs font-medium text-gray-13 w-8 shrink-0">Amy</span>
|
||||
<div class="flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div class="h-full rounded-full bg-gradient-to-r from-error to-pink-400" style="width:88%"></div>
|
||||
</div>
|
||||
<span class="text-[10px] text-gray-9 font-medium w-7 text-right">0.88</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs font-medium text-gray-13 w-8 shrink-0">泡芙</span>
|
||||
<div class="flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div class="h-full rounded-full bg-gradient-to-r from-warning to-amber-400" style="width:72%"></div>
|
||||
</div>
|
||||
<span class="text-[10px] text-gray-9 font-medium w-7 text-right">0.72</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-1 text-xs ml-11">
|
||||
<div class="text-gray-6">最近到店 <span class="text-gray-11 font-medium">4天前</span></div>
|
||||
<div class="text-gray-6">60天到店 <span class="text-gray-11 font-medium">14天</span></div>
|
||||
<div class="text-gray-6">余额 <span class="text-gray-11 font-medium">¥12,000</span></div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
'''
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# 执行替换
|
||||
# ══════════════════════════════════════════════════════════
|
||||
|
||||
ALL_DIMS = DIM_RECALL + DIM_POTENTIAL + DIM_BALANCE + DIM_RECHARGE + DIM_SPEND60 + DIM_FREQ60 + DIM_RECENT + DIM_LOYAL
|
||||
|
||||
# 定位:从第一个 dim-container 到悬浮助手按钮之前
|
||||
pattern = re.compile(
|
||||
r'(<!-- ={5,} 最应召回.*?</div>\s*\n)' # 不用这个
|
||||
r'|'
|
||||
r'(<div id="dim-recall".*?)(?=\s*<!-- 悬浮助手按钮)',
|
||||
re.DOTALL
|
||||
)
|
||||
|
||||
# 更简单的方式:找到所有 dim-container 块并替换
|
||||
start_marker = ' <!-- ==================== 最应召回(默认) ==================== -->'
|
||||
end_marker = ' <!-- 悬浮助手按钮 -->'
|
||||
|
||||
start_idx = html.find(start_marker)
|
||||
end_idx = html.find(end_marker)
|
||||
|
||||
if start_idx == -1 or end_idx == -1:
|
||||
# 尝试备选标记
|
||||
start_marker2 = '<div id="dim-recall"'
|
||||
end_marker2 = '<!-- 悬浮助手按钮'
|
||||
start_idx = html.find(start_marker2)
|
||||
end_idx = html.find(end_marker2)
|
||||
if start_idx == -1 or end_idx == -1:
|
||||
print(f"ERROR: 找不到替换标记 start={start_idx} end={end_idx}")
|
||||
import sys; sys.exit(1)
|
||||
# 回退到行首
|
||||
start_idx = html.rfind('\n', 0, start_idx) + 1
|
||||
|
||||
new_html = html[:start_idx] + ALL_DIMS + '\n' + html[end_idx:]
|
||||
|
||||
TARGET.write_text(new_html, encoding="utf-8")
|
||||
print(f"OK: board-customer.html 已重写,共 {len(new_html)} 字符")
|
||||
585
scripts/ops/refine_board_customer.py
Normal file
585
scripts/ops/refine_board_customer.py
Normal file
@@ -0,0 +1,585 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""board-customer.html 各维度精细调整 v2"""
|
||||
import pathlib, re
|
||||
|
||||
TARGET = pathlib.Path(__file__).resolve().parents[2] / "docs/h5_ui/pages/board-customer.html"
|
||||
html = TARGET.read_text(encoding="utf-8")
|
||||
|
||||
# ── 通用:跟/弃 badge 向下偏移 ──
|
||||
# 在 CSS 中把 assistant-badge 的 transform 改为向下多移一点
|
||||
html = html.replace(
|
||||
"transform: translateY(-0.5px);",
|
||||
"transform: translateY(1.5px);"
|
||||
)
|
||||
|
||||
def avatar(cf, ct, ch):
|
||||
return f'''<div class="w-9 h-9 rounded-xl bg-gradient-to-br from-{cf} to-{ct} flex items-center justify-center shrink-0">
|
||||
<span class="text-white font-semibold text-sm">{ch}</span>
|
||||
</div>'''
|
||||
|
||||
# 助教行
|
||||
def cr(c):
|
||||
return f''' <div class="text-xs border-t border-gray-1 pt-2 ml-11">
|
||||
<span class="text-gray-6">助教:</span>
|
||||
{c}
|
||||
</div>'''
|
||||
|
||||
CA = '''<span class="assistant-tag">❤️ <span class="assistant-assignee">小燕</span><span class="assistant-badge assistant-badge-follow">跟</span></span>
|
||||
<span class="assistant-sep">|</span>
|
||||
<span class="assistant-tag">❤️ <span class="assistant-normal">泡芙</span></span>
|
||||
<span class="assistant-sep">|</span>
|
||||
<span class="assistant-tag">❤️ <span class="assistant-abandoned">Amy</span><span class="assistant-badge assistant-badge-drop">弃</span></span>'''
|
||||
CB = '''<span class="assistant-tag">❤️ <span class="assistant-normal">Amy</span></span>
|
||||
<span class="assistant-sep">|</span>
|
||||
<span class="assistant-tag">❤️ <span class="assistant-normal">小燕</span></span>'''
|
||||
CC = '''<span class="assistant-tag">❤️ <span class="assistant-assignee">泡芙</span><span class="assistant-badge assistant-badge-follow">跟</span></span>
|
||||
<span class="assistant-sep">|</span>
|
||||
<span class="assistant-tag">❤️ <span class="assistant-normal">小燕</span></span>'''
|
||||
CD = '''<span class="assistant-tag">❤️ <span class="assistant-normal">泡芙</span></span>
|
||||
<span class="assistant-sep">|</span>
|
||||
<span class="assistant-tag">❤️ <span class="assistant-assignee">小燕</span><span class="assistant-badge assistant-badge-follow">跟</span></span>'''
|
||||
CE = '''<span class="assistant-tag">❤️ <span class="assistant-normal">Amy</span></span>
|
||||
<span class="assistant-sep">|</span>
|
||||
<span class="assistant-tag">❤️ <span class="assistant-abandoned">泡芙</span><span class="assistant-badge assistant-badge-drop">弃</span></span>'''
|
||||
CF = '''<span class="assistant-tag">❤️ <span class="assistant-normal">小燕</span></span>
|
||||
<span class="assistant-sep">|</span>
|
||||
<span class="assistant-tag">❤️ <span class="assistant-normal">泡芙</span></span>
|
||||
<span class="assistant-sep">|</span>
|
||||
<span class="assistant-tag">❤️ <span class="assistant-abandoned">Amy</span><span class="assistant-badge assistant-badge-drop">弃</span></span>'''
|
||||
CG = '''<span class="assistant-tag">❤️ <span class="assistant-assignee">小燕</span><span class="assistant-badge assistant-badge-follow">跟</span></span>
|
||||
<span class="assistant-sep">|</span>
|
||||
<span class="assistant-tag">❤️ <span class="assistant-normal">泡芙</span></span>'''
|
||||
CH = '''<span class="assistant-tag">❤️ <span class="assistant-normal">泡芙</span></span>
|
||||
<span class="assistant-sep">|</span>
|
||||
<span class="assistant-tag">❤️ <span class="assistant-normal">Amy</span></span>'''
|
||||
CI = '''<span class="assistant-tag">❤️ <span class="assistant-assignee">小燕</span><span class="assistant-badge assistant-badge-follow">跟</span></span>
|
||||
<span class="assistant-sep">|</span>
|
||||
<span class="assistant-tag">❤️ <span class="assistant-abandoned">泡芙</span><span class="assistant-badge assistant-badge-drop">弃</span></span>'''
|
||||
CJ = '''<span class="assistant-tag">❤️ <span class="assistant-assignee">Amy</span><span class="assistant-badge assistant-badge-follow">跟</span></span>
|
||||
<span class="assistant-sep">|</span>
|
||||
<span class="assistant-tag">❤️ <span class="assistant-normal">小燕</span></span>'''
|
||||
|
||||
# ══ 维度1: 最应召回 — 去掉进度条,重新设计 ══
|
||||
DIM_RECALL = '''
|
||||
<!-- ==================== 最应召回(默认) ==================== -->
|
||||
<div id="dim-recall" class="dim-container active p-4 pt-1 space-y-3">
|
||||
<a href="customer-detail.html" class="block bg-white rounded-2xl p-4 shadow-sm customer-card">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
''' + avatar("amber-400","orange-500","王") + '''
|
||||
<div>
|
||||
<span class="font-semibold text-gray-13">王先生</span>
|
||||
<div class="flex items-center gap-1.5 mt-0.5">
|
||||
<span class="text-xs text-gray-6">理想 <span class="text-gray-11 font-medium">7天</span></span>
|
||||
<span class="text-xs text-gray-6">已过 <span class="text-error font-bold">15天</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="px-2.5 py-1 bg-error/10 text-error text-sm font-bold rounded-lg">超期 8天</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-1 text-sm ml-11 mb-2">
|
||||
<div class="text-gray-6">30天到店 <span class="text-gray-11 font-medium">5次</span></div>
|
||||
<div class="text-gray-6">余额 <span class="text-gray-11 font-medium">¥8,000</span></div>
|
||||
<div class="text-gray-6">召回指数 <span class="text-primary font-bold">0.92</span></div>
|
||||
</div>
|
||||
''' + cr(CA) + '''
|
||||
</a>
|
||||
|
||||
<a href="customer-detail.html" class="block bg-white rounded-2xl p-4 shadow-sm customer-card">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
''' + avatar("pink-400","rose-500","李") + '''
|
||||
<div>
|
||||
<span class="font-semibold text-gray-13">李女士</span>
|
||||
<div class="flex items-center gap-1.5 mt-0.5">
|
||||
<span class="text-xs text-gray-6">理想 <span class="text-gray-11 font-medium">10天</span></span>
|
||||
<span class="text-xs text-gray-6">已过 <span class="text-error font-bold">20天</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="px-2.5 py-1 bg-warning/10 text-warning text-sm font-bold rounded-lg">超期 10天</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-1 text-sm ml-11 mb-2">
|
||||
<div class="text-gray-6">30天到店 <span class="text-gray-11 font-medium">3次</span></div>
|
||||
<div class="text-gray-6">余额 <span class="text-gray-11 font-medium">¥12,500</span></div>
|
||||
<div class="text-gray-6">召回指数 <span class="text-primary font-bold">0.88</span></div>
|
||||
</div>
|
||||
''' + cr(CB) + '''
|
||||
</a>
|
||||
|
||||
<a href="customer-detail.html" class="block bg-white rounded-2xl p-4 shadow-sm customer-card">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
''' + avatar("blue-400","indigo-500","张") + '''
|
||||
<div>
|
||||
<span class="font-semibold text-gray-13">张先生</span>
|
||||
<div class="flex items-center gap-1.5 mt-0.5">
|
||||
<span class="text-xs text-gray-6">理想 <span class="text-gray-11 font-medium">7天</span></span>
|
||||
<span class="text-xs text-gray-6">已过 <span class="text-error font-bold">18天</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="px-2.5 py-1 bg-error/10 text-error text-sm font-bold rounded-lg">超期 11天</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-1 text-sm ml-11 mb-2">
|
||||
<div class="text-gray-6">30天到店 <span class="text-gray-11 font-medium">2次</span></div>
|
||||
<div class="text-gray-6">余额 <span class="text-gray-11 font-medium">¥3,200</span></div>
|
||||
<div class="text-gray-6">召回指数 <span class="text-primary font-bold">0.85</span></div>
|
||||
</div>
|
||||
''' + cr(CC) + '''
|
||||
</a>
|
||||
</div>
|
||||
'''
|
||||
|
||||
# ══ 维度2: 最大消费潜力 — 卡片内文字增大 ══
|
||||
DIM_POTENTIAL = '''
|
||||
<!-- ==================== 最大消费潜力 ==================== -->
|
||||
<div id="dim-potential" class="dim-container p-4 pt-1 space-y-3">
|
||||
<a href="customer-detail.html" class="block bg-white rounded-2xl p-4 shadow-sm customer-card">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
''' + avatar("rose-400","pink-500","赵") + '''
|
||||
<span class="font-semibold text-gray-13">赵女士</span>
|
||||
</div>
|
||||
<div class="text-right shrink-0">
|
||||
<div class="text-primary font-bold">0.95</div>
|
||||
<div class="text-[10px] text-gray-6 -mt-0.5">消费潜力</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-11 mb-2 bg-gradient-to-r from-primary/5 to-blue-50 rounded-lg p-3">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="text-sm font-bold text-primary">🔥 S级潜力</span>
|
||||
<span class="text-xs text-gray-6">高频 · 高客单 · 高余额</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2 text-center">
|
||||
<div><p class="text-sm font-bold text-gray-13">¥4,800</p><p class="text-xs text-gray-6">近30天消费</p></div>
|
||||
<div><p class="text-sm font-bold text-gray-13">8次</p><p class="text-xs text-gray-6">月均到店</p></div>
|
||||
<div><p class="text-sm font-bold text-success">¥15,000</p><p class="text-xs text-gray-6">余额</p></div>
|
||||
</div>
|
||||
</div>
|
||||
''' + cr(CD) + '''
|
||||
</a>
|
||||
|
||||
<a href="customer-detail.html" class="block bg-white rounded-2xl p-4 shadow-sm customer-card">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
''' + avatar("green-400","emerald-500","刘") + '''
|
||||
<span class="font-semibold text-gray-13">刘先生</span>
|
||||
</div>
|
||||
<div class="text-right shrink-0">
|
||||
<div class="text-primary font-bold">0.87</div>
|
||||
<div class="text-[10px] text-gray-6 -mt-0.5">消费潜力</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-11 mb-2 bg-gradient-to-r from-primary/5 to-blue-50 rounded-lg p-3">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="text-sm font-bold text-primary">⭐ A级潜力</span>
|
||||
<span class="text-xs text-gray-6">中频 · 高客单</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2 text-center">
|
||||
<div><p class="text-sm font-bold text-gray-13">¥3,500</p><p class="text-xs text-gray-6">近30天消费</p></div>
|
||||
<div><p class="text-sm font-bold text-gray-13">5次</p><p class="text-xs text-gray-6">月均到店</p></div>
|
||||
<div><p class="text-sm font-bold text-success">¥6,800</p><p class="text-xs text-gray-6">余额</p></div>
|
||||
</div>
|
||||
</div>
|
||||
''' + cr(CE) + '''
|
||||
</a>
|
||||
</div>
|
||||
'''
|
||||
|
||||
# ══ 维度3: 最高余额 — 余额小2号,去进度条,预计可用放余额右侧,其他大2号 ══
|
||||
DIM_BALANCE = '''
|
||||
<!-- ==================== 最高余额 ==================== -->
|
||||
<div id="dim-balance" class="dim-container p-4 pt-1 space-y-3">
|
||||
<a href="customer-detail.html" class="block bg-white rounded-2xl p-4 shadow-sm customer-card">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<div class="flex items-center gap-2">
|
||||
''' + avatar("rose-400","pink-500","赵") + '''
|
||||
<span class="font-semibold text-gray-13">赵女士</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-11 mb-2">
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="text-lg font-bold text-warning">¥25,000</span>
|
||||
<span class="text-xs text-gray-6">余额</span>
|
||||
<span class="text-xs text-gray-5">·</span>
|
||||
<span class="text-sm font-medium text-success">可用约8个月</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4 mt-2 text-sm">
|
||||
<div class="text-gray-6">60天消费 <span class="text-gray-11 font-medium">¥6,200</span></div>
|
||||
<div class="text-gray-6">月均消耗 <span class="text-gray-11 font-medium">¥3,100</span></div>
|
||||
</div>
|
||||
<div class="flex items-center gap-4 mt-1 text-sm">
|
||||
<div class="text-gray-6">最近到店 <span class="text-gray-11 font-medium">12天前</span></div>
|
||||
<div class="text-gray-6">理想间隔 <span class="text-gray-11 font-medium">10天</span></div>
|
||||
</div>
|
||||
</div>
|
||||
''' + cr(CF) + '''
|
||||
</a>
|
||||
|
||||
<a href="customer-detail.html" class="block bg-white rounded-2xl p-4 shadow-sm customer-card">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<div class="flex items-center gap-2">
|
||||
''' + avatar("purple-400","violet-500","陈") + '''
|
||||
<span class="font-semibold text-gray-13">陈先生</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-11 mb-2">
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="text-lg font-bold text-warning">¥18,500</span>
|
||||
<span class="text-xs text-gray-6">余额</span>
|
||||
<span class="text-xs text-gray-5">·</span>
|
||||
<span class="text-sm font-medium text-success">可用约9.7个月</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4 mt-2 text-sm">
|
||||
<div class="text-gray-6">60天消费 <span class="text-gray-11 font-medium">¥3,800</span></div>
|
||||
<div class="text-gray-6">月均消耗 <span class="text-gray-11 font-medium">¥1,900</span></div>
|
||||
</div>
|
||||
<div class="flex items-center gap-4 mt-1 text-sm">
|
||||
<div class="text-gray-6">最近到店 <span class="text-gray-11 font-medium">8天前</span></div>
|
||||
<div class="text-gray-6">理想间隔 <span class="text-gray-11 font-medium">14天</span></div>
|
||||
</div>
|
||||
</div>
|
||||
''' + cr(CG) + '''
|
||||
</a>
|
||||
</div>
|
||||
'''
|
||||
|
||||
# ══ 维度4: 最近充值 — 数据字号大2号,本年充值→最近3个月充值 ══
|
||||
DIM_RECHARGE = '''
|
||||
<!-- ==================== 最近充值 ==================== -->
|
||||
<div id="dim-recharge" class="dim-container p-4 pt-1 space-y-3">
|
||||
<a href="customer-detail.html" class="block bg-white rounded-2xl p-4 shadow-sm customer-card">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
''' + avatar("indigo-400","blue-500","吴") + '''
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="font-semibold text-gray-13">吴先生</span>
|
||||
</div>
|
||||
<span class="text-[10px] text-gray-6">2天前充值</span>
|
||||
</div>
|
||||
<div class="ml-11 mb-2 bg-gradient-to-r from-emerald-50 to-green-50 rounded-lg p-2.5 border border-emerald-100/50">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<span class="text-lg font-bold text-success">+¥5,000</span>
|
||||
<span class="text-[10px] text-gray-6 ml-1">充值</span>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-xs text-gray-6">充后余额</div>
|
||||
<div class="text-sm font-bold text-gray-13">¥8,200</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-1 text-sm ml-11 mb-2">
|
||||
<div class="text-gray-6">最近到店 <span class="text-gray-11 font-medium">2天前</span></div>
|
||||
<div class="text-gray-6">理想间隔 <span class="text-gray-11 font-medium">7天</span></div>
|
||||
<div class="text-gray-6">近3月充值 <span class="text-success font-medium">3次</span></div>
|
||||
</div>
|
||||
''' + cr(CB) + '''
|
||||
</a>
|
||||
|
||||
<a href="customer-detail.html" class="block bg-white rounded-2xl p-4 shadow-sm customer-card">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
''' + avatar("orange-400","amber-500","郑") + '''
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="font-semibold text-gray-13">郑女士</span>
|
||||
</div>
|
||||
<span class="text-[10px] text-gray-6">5天前充值</span>
|
||||
</div>
|
||||
<div class="ml-11 mb-2 bg-gradient-to-r from-emerald-50 to-green-50 rounded-lg p-2.5 border border-emerald-100/50">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<span class="text-lg font-bold text-success">+¥3,000</span>
|
||||
<span class="text-[10px] text-gray-6 ml-1">充值</span>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-xs text-gray-6">充后余额</div>
|
||||
<div class="text-sm font-bold text-gray-13">¥6,500</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-1 text-sm ml-11 mb-2">
|
||||
<div class="text-gray-6">最近到店 <span class="text-gray-11 font-medium">5天前</span></div>
|
||||
<div class="text-gray-6">理想间隔 <span class="text-gray-11 font-medium">10天</span></div>
|
||||
<div class="text-gray-6">近3月充值 <span class="text-success font-medium">2次</span></div>
|
||||
</div>
|
||||
''' + cr(CC) + '''
|
||||
</a>
|
||||
</div>
|
||||
'''
|
||||
|
||||
# ══ 维度5: 最高消费 近60天 — 消费金额小2号,去余额,到店+次均放一行,去排名icon ══
|
||||
DIM_SPEND60 = '''
|
||||
<!-- ==================== 最高消费 近60天 ==================== -->
|
||||
<div id="dim-spend60" class="dim-container p-4 pt-1 space-y-3">
|
||||
<a href="customer-detail.html" class="block bg-white rounded-2xl p-4 shadow-sm customer-card">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
''' + avatar("pink-400","rose-500","李") + '''
|
||||
<span class="font-semibold text-gray-13">李女士</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-11 mb-2">
|
||||
<div class="flex items-baseline gap-3">
|
||||
<div>
|
||||
<span class="text-lg font-bold text-warning">¥12,800</span>
|
||||
<span class="text-xs text-gray-6 ml-1">近60天消费</span>
|
||||
</div>
|
||||
<span class="text-xs text-gray-5">·</span>
|
||||
<span class="text-sm text-gray-9">18次到店</span>
|
||||
<span class="text-xs text-gray-5">·</span>
|
||||
<span class="text-sm text-gray-9">次均¥711</span>
|
||||
</div>
|
||||
</div>
|
||||
''' + cr(CA) + '''
|
||||
</a>
|
||||
|
||||
<a href="customer-detail.html" class="block bg-white rounded-2xl p-4 shadow-sm customer-card">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
''' + avatar("amber-400","orange-500","王") + '''
|
||||
<span class="font-semibold text-gray-13">王先生</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-11 mb-2">
|
||||
<div class="flex items-baseline gap-3">
|
||||
<div>
|
||||
<span class="text-lg font-bold text-warning">¥9,500</span>
|
||||
<span class="text-xs text-gray-6 ml-1">近60天消费</span>
|
||||
</div>
|
||||
<span class="text-xs text-gray-5">·</span>
|
||||
<span class="text-sm text-gray-9">12次到店</span>
|
||||
<span class="text-xs text-gray-5">·</span>
|
||||
<span class="text-sm text-gray-9">次均¥792</span>
|
||||
</div>
|
||||
</div>
|
||||
''' + cr(CH) + '''
|
||||
</a>
|
||||
</div>
|
||||
'''
|
||||
|
||||
# ══ 维度6: 最频繁 近60天 — 左侧放平均间隔+60天消费,去余额,柱状图上标本周天数 ══
|
||||
DIM_FREQ60 = '''
|
||||
<!-- ==================== 最频繁 近60天 ==================== -->
|
||||
<div id="dim-freq60" class="dim-container p-4 pt-1 space-y-3">
|
||||
<a href="customer-detail.html" class="block bg-white rounded-2xl p-4 shadow-sm customer-card">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
''' + avatar("blue-400","indigo-500","张") + '''
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="font-semibold text-gray-13">张先生</span>
|
||||
<div class="flex items-center gap-3 mt-0.5 text-xs">
|
||||
<span class="text-gray-6">平均间隔 <span class="text-primary font-medium">3.3天</span></span>
|
||||
<span class="text-gray-6">60天消费 <span class="text-gray-11 font-medium">¥8,600</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right shrink-0">
|
||||
<div class="text-primary font-bold text-lg">18<span class="text-xs font-normal text-gray-6">天</span></div>
|
||||
<div class="text-[10px] text-gray-6 -mt-0.5">60天到店</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-11 mb-2">
|
||||
<div class="flex items-center justify-between text-[10px] text-gray-5 mb-0.5">
|
||||
<span>8周前</span>
|
||||
<span>本周 <span class="text-primary font-bold text-xs">3</span></span>
|
||||
</div>
|
||||
<div class="flex items-end gap-0.5 h-6">
|
||||
<div class="flex-1 bg-primary/20 rounded-t" style="height:60%"></div>
|
||||
<div class="flex-1 bg-primary/30 rounded-t" style="height:80%"></div>
|
||||
<div class="flex-1 bg-primary/40 rounded-t" style="height:50%"></div>
|
||||
<div class="flex-1 bg-primary/50 rounded-t" style="height:100%"></div>
|
||||
<div class="flex-1 bg-primary/40 rounded-t" style="height:70%"></div>
|
||||
<div class="flex-1 bg-primary/60 rounded-t" style="height:90%"></div>
|
||||
<div class="flex-1 bg-primary/30 rounded-t" style="height:40%"></div>
|
||||
<div class="flex-1 bg-primary/50 rounded-t" style="height:75%"></div>
|
||||
</div>
|
||||
</div>
|
||||
''' + cr(CF) + '''
|
||||
</a>
|
||||
|
||||
<a href="customer-detail.html" class="block bg-white rounded-2xl p-4 shadow-sm customer-card">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
''' + avatar("purple-400","violet-500","陈") + '''
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="font-semibold text-gray-13">陈先生</span>
|
||||
<div class="flex items-center gap-3 mt-0.5 text-xs">
|
||||
<span class="text-gray-6">平均间隔 <span class="text-primary font-medium">4天</span></span>
|
||||
<span class="text-gray-6">60天消费 <span class="text-gray-11 font-medium">¥6,200</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right shrink-0">
|
||||
<div class="text-primary font-bold text-lg">15<span class="text-xs font-normal text-gray-6">天</span></div>
|
||||
<div class="text-[10px] text-gray-6 -mt-0.5">60天到店</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-11 mb-2">
|
||||
<div class="flex items-center justify-between text-[10px] text-gray-5 mb-0.5">
|
||||
<span>8周前</span>
|
||||
<span>本周 <span class="text-primary font-bold text-xs">2</span></span>
|
||||
</div>
|
||||
<div class="flex items-end gap-0.5 h-6">
|
||||
<div class="flex-1 bg-primary/20 rounded-t" style="height:40%"></div>
|
||||
<div class="flex-1 bg-primary/30 rounded-t" style="height:60%"></div>
|
||||
<div class="flex-1 bg-primary/40 rounded-t" style="height:70%"></div>
|
||||
<div class="flex-1 bg-primary/30 rounded-t" style="height:50%"></div>
|
||||
<div class="flex-1 bg-primary/50 rounded-t" style="height:80%"></div>
|
||||
<div class="flex-1 bg-primary/40 rounded-t" style="height:65%"></div>
|
||||
<div class="flex-1 bg-primary/60 rounded-t" style="height:90%"></div>
|
||||
<div class="flex-1 bg-primary/50 rounded-t" style="height:70%"></div>
|
||||
</div>
|
||||
</div>
|
||||
''' + cr(CJ) + '''
|
||||
</a>
|
||||
</div>
|
||||
'''
|
||||
|
||||
# ══ 维度7: 最近到店 — 去进度条,数据大2号,余额→次均消费金额 ══
|
||||
DIM_RECENT = '''
|
||||
<!-- ==================== 最近到店 ==================== -->
|
||||
<div id="dim-recent" class="dim-container p-4 pt-1 space-y-3">
|
||||
<a href="customer-detail.html" class="block bg-white rounded-2xl p-4 shadow-sm customer-card">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
''' + avatar("green-400","emerald-500","刘") + '''
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="font-semibold text-gray-13">刘先生</span>
|
||||
</div>
|
||||
<div class="flex items-baseline gap-0.5 shrink-0">
|
||||
<span class="text-2xl font-bold text-success">1</span>
|
||||
<span class="text-xs text-gray-6">天前到店</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-1 text-sm ml-11 mb-2">
|
||||
<div class="text-gray-6">理想间隔 <span class="text-gray-11 font-medium">7天</span></div>
|
||||
<div class="text-gray-6">60天到店 <span class="text-gray-11 font-medium">12天</span></div>
|
||||
<div class="text-gray-6">次均消费 <span class="text-gray-11 font-medium">¥450</span></div>
|
||||
</div>
|
||||
''' + cr(CH) + '''
|
||||
</a>
|
||||
|
||||
<a href="customer-detail.html" class="block bg-white rounded-2xl p-4 shadow-sm customer-card">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
''' + avatar("cyan-400","teal-500","周") + '''
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="font-semibold text-gray-13">周先生</span>
|
||||
</div>
|
||||
<div class="flex items-baseline gap-0.5 shrink-0">
|
||||
<span class="text-2xl font-bold text-success">2</span>
|
||||
<span class="text-xs text-gray-6">天前到店</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-1 text-sm ml-11 mb-2">
|
||||
<div class="text-gray-6">理想间隔 <span class="text-gray-11 font-medium">10天</span></div>
|
||||
<div class="text-gray-6">60天到店 <span class="text-gray-11 font-medium">8天</span></div>
|
||||
<div class="text-gray-6">次均消费 <span class="text-gray-11 font-medium">¥475</span></div>
|
||||
</div>
|
||||
''' + cr(CI) + '''
|
||||
</a>
|
||||
</div>
|
||||
'''
|
||||
|
||||
# ══ 维度8: 最专一 — 去右上角专一指数,去进度条,爱心+昵称+数据表格式,去底部数据 ══
|
||||
DIM_LOYAL = '''
|
||||
<!-- ==================== 最专一 ==================== -->
|
||||
<div id="dim-loyal" class="dim-container p-4 pt-1 space-y-3">
|
||||
<a href="customer-detail.html" class="block bg-white rounded-2xl p-4 shadow-sm customer-card">
|
||||
<div class="flex items-center gap-2 mb-2.5">
|
||||
''' + avatar("teal-400","cyan-500","孙") + '''
|
||||
<span class="font-semibold text-gray-13">孙先生</span>
|
||||
</div>
|
||||
<div class="ml-11 mb-1 space-y-1.5">
|
||||
<!-- 表头 -->
|
||||
<div class="flex items-center text-[10px] text-gray-5 gap-1">
|
||||
<span class="w-14"></span>
|
||||
<span class="flex-1"></span>
|
||||
<span class="w-12 text-right">次均时长</span>
|
||||
<span class="w-12 text-right">60天时长</span>
|
||||
<span class="w-10 text-right">服务次</span>
|
||||
</div>
|
||||
<!-- 小燕 -->
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="w-14 flex items-center gap-0.5 shrink-0"><span class="text-xs">❤️</span><span class="text-xs font-medium text-error">小燕</span></span>
|
||||
<span class="text-[10px] text-gray-5 shrink-0">0.95</span>
|
||||
<span class="assistant-badge assistant-badge-follow text-[9px] shrink-0">跟</span>
|
||||
<span class="flex-1"></span>
|
||||
<span class="w-12 text-right text-xs font-medium text-gray-11">2.1h</span>
|
||||
<span class="w-12 text-right text-xs font-medium text-gray-11">25.2h</span>
|
||||
<span class="w-10 text-right text-xs font-medium text-gray-11">12</span>
|
||||
</div>
|
||||
<!-- 泡芙 -->
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="w-14 flex items-center gap-0.5 shrink-0"><span class="text-xs">❤️</span><span class="text-xs font-medium text-gray-13">泡芙</span></span>
|
||||
<span class="text-[10px] text-gray-5 shrink-0">0.68</span>
|
||||
<span class="flex-1"></span>
|
||||
<span class="w-12 text-right text-xs font-medium text-gray-11">1.8h</span>
|
||||
<span class="w-12 text-right text-xs font-medium text-gray-11">9.0h</span>
|
||||
<span class="w-10 text-right text-xs font-medium text-gray-11">5</span>
|
||||
</div>
|
||||
<!-- Amy -->
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="w-14 flex items-center gap-0.5 shrink-0"><span class="text-xs">❤️</span><span class="text-xs font-medium text-gray-5">Amy</span></span>
|
||||
<span class="text-[10px] text-gray-5 shrink-0">0.32</span>
|
||||
<span class="assistant-badge assistant-badge-drop text-[9px] shrink-0">弃</span>
|
||||
<span class="flex-1"></span>
|
||||
<span class="w-12 text-right text-xs text-gray-7">1.2h</span>
|
||||
<span class="w-12 text-right text-xs text-gray-7">3.6h</span>
|
||||
<span class="w-10 text-right text-xs text-gray-7">3</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="customer-detail.html" class="block bg-white rounded-2xl p-4 shadow-sm customer-card">
|
||||
<div class="flex items-center gap-2 mb-2.5">
|
||||
''' + avatar("rose-400","pink-500","赵") + '''
|
||||
<span class="font-semibold text-gray-13">赵女士</span>
|
||||
</div>
|
||||
<div class="ml-11 mb-1 space-y-1.5">
|
||||
<div class="flex items-center text-[10px] text-gray-5 gap-1">
|
||||
<span class="w-14"></span>
|
||||
<span class="flex-1"></span>
|
||||
<span class="w-12 text-right">次均时长</span>
|
||||
<span class="w-12 text-right">60天时长</span>
|
||||
<span class="w-10 text-right">服务次</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="w-14 flex items-center gap-0.5 shrink-0"><span class="text-xs">❤️</span><span class="text-xs font-medium text-gray-13">Amy</span></span>
|
||||
<span class="text-[10px] text-gray-5 shrink-0">0.88</span>
|
||||
<span class="flex-1"></span>
|
||||
<span class="w-12 text-right text-xs font-medium text-gray-11">2.3h</span>
|
||||
<span class="w-12 text-right text-xs font-medium text-gray-11">32.2h</span>
|
||||
<span class="w-10 text-right text-xs font-medium text-gray-11">14</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="w-14 flex items-center gap-0.5 shrink-0"><span class="text-xs">❤️</span><span class="text-xs font-medium text-gray-13">泡芙</span></span>
|
||||
<span class="text-[10px] text-gray-5 shrink-0">0.72</span>
|
||||
<span class="flex-1"></span>
|
||||
<span class="w-12 text-right text-xs font-medium text-gray-11">1.5h</span>
|
||||
<span class="w-12 text-right text-xs font-medium text-gray-11">12.0h</span>
|
||||
<span class="w-10 text-right text-xs font-medium text-gray-11">8</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
'''
|
||||
|
||||
# ══ 执行替换 ══
|
||||
ALL_DIMS = DIM_RECALL + DIM_POTENTIAL + DIM_BALANCE + DIM_RECHARGE + DIM_SPEND60 + DIM_FREQ60 + DIM_RECENT + DIM_LOYAL
|
||||
|
||||
start_marker = '<div id="dim-recall"'
|
||||
end_marker = '<!-- 悬浮助手按钮'
|
||||
|
||||
start_idx = html.find(start_marker)
|
||||
end_idx = html.find(end_marker)
|
||||
|
||||
if start_idx == -1 or end_idx == -1:
|
||||
print(f"ERROR: 找不到替换标记 start={start_idx} end={end_idx}")
|
||||
import sys; sys.exit(1)
|
||||
|
||||
# 回退到行首
|
||||
start_idx = html.rfind('\n', 0, start_idx) + 1
|
||||
|
||||
new_html = html[:start_idx] + ALL_DIMS + '\n ' + html[end_idx:]
|
||||
|
||||
TARGET.write_text(new_html, encoding="utf-8")
|
||||
print(f"OK: board-customer.html v2 已重写,共 {len(new_html)} 字符")
|
||||
98
scripts/ops/set_default_pwsh7.py
Normal file
98
scripts/ops/set_default_pwsh7.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""
|
||||
一次性脚本:将 Windows 系统级默认 shell 切换为 PowerShell 7
|
||||
1. Windows Terminal 默认 profile → pwsh.exe
|
||||
2. OpenSSH DefaultShell 注册表 → pwsh.exe
|
||||
3. 系统 ComSpec 环境变量不动(保持 cmd.exe 兼容)
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import winreg
|
||||
|
||||
PWSH7 = r"C:\Program Files\PowerShell\7\pwsh.exe"
|
||||
|
||||
# ── 1. Windows Terminal settings.json ──
|
||||
wt_settings = os.path.join(
|
||||
os.environ["LOCALAPPDATA"],
|
||||
"Packages",
|
||||
"Microsoft.WindowsTerminal_8wekyb3d8bbwe",
|
||||
"LocalState",
|
||||
"settings.json",
|
||||
)
|
||||
wt_preview = os.path.join(
|
||||
os.environ["LOCALAPPDATA"],
|
||||
"Packages",
|
||||
"Microsoft.WindowsTerminalPreview_8wekyb3d8bbwe",
|
||||
"LocalState",
|
||||
"settings.json",
|
||||
)
|
||||
|
||||
# Windows Terminal 的 PowerShell 7 profile GUID(官方固定值)
|
||||
PWSH7_GUID = "{574e775e-4f2a-5b96-ac1e-a2962a402336}"
|
||||
|
||||
for path in [wt_settings, wt_preview]:
|
||||
if os.path.isfile(path):
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
# 去掉 JSON 注释行(Windows Terminal 允许 // 注释)
|
||||
lines = []
|
||||
for line in content.splitlines():
|
||||
stripped = line.lstrip()
|
||||
if stripped.startswith("//"):
|
||||
continue
|
||||
lines.append(line)
|
||||
clean = "\n".join(lines)
|
||||
try:
|
||||
cfg = json.loads(clean)
|
||||
except json.JSONDecodeError:
|
||||
print(f"跳过(JSON 解析失败): {path}")
|
||||
continue
|
||||
cfg["defaultProfile"] = PWSH7_GUID
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(cfg, f, indent=4, ensure_ascii=False)
|
||||
print(f"已更新 Windows Terminal defaultProfile: {path}")
|
||||
else:
|
||||
print(f"未找到 Windows Terminal 配置: {path}")
|
||||
|
||||
# ── 2. OpenSSH DefaultShell 注册表 ──
|
||||
try:
|
||||
key = winreg.CreateKeyEx(
|
||||
winreg.HKEY_LOCAL_MACHINE,
|
||||
r"SOFTWARE\OpenSSH",
|
||||
0,
|
||||
winreg.KEY_SET_VALUE,
|
||||
)
|
||||
winreg.SetValueEx(key, "DefaultShell", 0, winreg.REG_SZ, PWSH7)
|
||||
winreg.CloseKey(key)
|
||||
print(f"已设置 OpenSSH DefaultShell → {PWSH7}")
|
||||
except PermissionError:
|
||||
print("OpenSSH 注册表写入需要管理员权限,跳过(可手动以管理员运行)")
|
||||
except Exception as e:
|
||||
print(f"OpenSSH 注册表写入失败: {e}")
|
||||
|
||||
# ── 3. 将 pwsh.exe 所在目录加到 PATH 最前面(如果不在的话)──
|
||||
pwsh_dir = os.path.dirname(PWSH7)
|
||||
current_path = os.environ.get("PATH", "")
|
||||
if pwsh_dir.lower() not in current_path.lower():
|
||||
# 写入用户级 PATH
|
||||
try:
|
||||
key = winreg.OpenKeyEx(
|
||||
winreg.HKEY_CURRENT_USER,
|
||||
r"Environment",
|
||||
0,
|
||||
winreg.KEY_READ | winreg.KEY_SET_VALUE,
|
||||
)
|
||||
user_path, _ = winreg.QueryValueEx(key, "Path")
|
||||
if pwsh_dir.lower() not in user_path.lower():
|
||||
new_path = pwsh_dir + ";" + user_path
|
||||
winreg.SetValueEx(key, "Path", 0, winreg.REG_EXPAND_SZ, new_path)
|
||||
print(f"已将 {pwsh_dir} 添加到用户 PATH 最前面")
|
||||
else:
|
||||
print(f"{pwsh_dir} 已在用户 PATH 中")
|
||||
winreg.CloseKey(key)
|
||||
except Exception as e:
|
||||
print(f"PATH 更新失败: {e}")
|
||||
else:
|
||||
print(f"{pwsh_dir} 已在 PATH 中")
|
||||
|
||||
print("\n完成。新开的终端窗口将默认使用 PowerShell 7。")
|
||||
83
scripts/ops/setup_fdw_both.py
Normal file
83
scripts/ops/setup_fdw_both.py
Normal file
@@ -0,0 +1,83 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
在 zqyy_app 和 test_zqyy_app 中执行 FDW 配置。
|
||||
- zqyy_app -> setup_fdw.sql (指向 etl_feiqiu)
|
||||
- test_zqyy_app -> setup_fdw_test.sql (指向 test_etl_feiqiu)
|
||||
"""
|
||||
import os
|
||||
import psycopg2
|
||||
|
||||
CONN = dict(host="100.64.0.4", port=5432, user="local-Python", password="Neo-local-1991125")
|
||||
BASE = r"C:\NeoZQYY"
|
||||
|
||||
# 实际密码替换占位符 '***'
|
||||
APP_READER_PWD = "AppR3ad_2026!"
|
||||
|
||||
TARGETS = [
|
||||
("zqyy_app", os.path.join(BASE, "db", "fdw", "setup_fdw.sql")),
|
||||
("test_zqyy_app", os.path.join(BASE, "db", "fdw", "setup_fdw_test.sql")),
|
||||
]
|
||||
|
||||
for dbname, sql_path in TARGETS:
|
||||
print(f"\n{'='*60}")
|
||||
print(f"执行 FDW 配置: {dbname} <- {os.path.basename(sql_path)}")
|
||||
print(f"{'='*60}")
|
||||
|
||||
sql = open(sql_path, encoding="utf-8").read()
|
||||
# 替换密码占位符
|
||||
sql = sql.replace("password '***'", f"password '{APP_READER_PWD}'")
|
||||
|
||||
conn = psycopg2.connect(**CONN, dbname=dbname)
|
||||
conn.autocommit = True
|
||||
cur = conn.cursor()
|
||||
|
||||
# 逐条执行(按分号拆分,跳过注释和空行)
|
||||
statements = []
|
||||
current = []
|
||||
for line in sql.split("\n"):
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("--") or not stripped:
|
||||
continue
|
||||
current.append(line)
|
||||
if stripped.endswith(";"):
|
||||
statements.append("\n".join(current))
|
||||
current = []
|
||||
|
||||
success = 0
|
||||
skip = 0
|
||||
fail = 0
|
||||
for stmt in statements:
|
||||
try:
|
||||
cur.execute(stmt)
|
||||
first_line = stmt.strip().split("\n")[0][:80]
|
||||
print(f" [OK] {first_line}")
|
||||
success += 1
|
||||
except psycopg2.errors.DuplicateObject as e:
|
||||
conn.rollback()
|
||||
print(f" [SKIP] 已存在: {str(e).strip().split(chr(10))[0]}")
|
||||
skip += 1
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
print(f" [FAIL] {str(e).strip().split(chr(10))[0]}")
|
||||
print(f" SQL: {stmt[:100]}")
|
||||
fail += 1
|
||||
|
||||
# 验证
|
||||
cur.execute("SELECT 1 FROM pg_extension WHERE extname = 'postgres_fdw'")
|
||||
fdw_ext = cur.fetchone() is not None
|
||||
|
||||
cur.execute("SELECT srvname FROM pg_foreign_server")
|
||||
servers = [r[0] for r in cur.fetchall()]
|
||||
|
||||
cur.execute(
|
||||
"SELECT count(*) FROM information_schema.tables "
|
||||
"WHERE table_schema = 'fdw_etl'"
|
||||
)
|
||||
fdw_tables = cur.fetchone()[0]
|
||||
|
||||
print(f"\n 结果: {success} OK, {skip} SKIP, {fail} FAIL")
|
||||
print(f" 验证: fdw扩展={fdw_ext}, servers={servers}, fdw_etl表数={fdw_tables}")
|
||||
|
||||
conn.close()
|
||||
|
||||
print("\n完成!")
|
||||
110
scripts/ops/start-admin.ps1
Normal file
110
scripts/ops/start-admin.ps1
Normal file
@@ -0,0 +1,110 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# 启动管理后台(后端 + 前端)
|
||||
# 服务成功启动后自动打开浏览器
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
try {
|
||||
# 定位项目根目录:脚本在 scripts/ops/ 下,向上三级
|
||||
if ($MyInvocation.MyCommand.Path) {
|
||||
$ProjectRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path))
|
||||
} else {
|
||||
$ProjectRoot = $PWD.Path
|
||||
}
|
||||
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host " NeoZQYY 管理后台启动脚本" -ForegroundColor Cyan
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
Write-Host "项目根目录: $ProjectRoot"
|
||||
Write-Host ""
|
||||
|
||||
$backendDir = Join-Path $ProjectRoot "apps\backend"
|
||||
$frontendDir = Join-Path $ProjectRoot "apps\admin-web"
|
||||
if (-not (Test-Path $backendDir)) { throw "后端目录不存在: $backendDir" }
|
||||
if (-not (Test-Path $frontendDir)) { throw "前端目录不存在: $frontendDir" }
|
||||
|
||||
# 前端日志文件(每次用唯一文件名,避免上次进程锁定旧文件)
|
||||
$ts = Get-Date -Format "yyyyMMdd_HHmmss"
|
||||
$frontendLog = Join-Path $env:TEMP "neozqyy_fe_${ts}.log"
|
||||
|
||||
# 选择 PowerShell 可执行文件:优先 pwsh (7+),回退 powershell (5.1)
|
||||
$psExe = if (Get-Command pwsh -ErrorAction SilentlyContinue) { "pwsh" } else { "powershell" }
|
||||
|
||||
# ── 生成临时启动脚本 ──
|
||||
$beTmp = Join-Path $env:TEMP "neozqyy_start_be.ps1"
|
||||
$feTmp = Join-Path $env:TEMP "neozqyy_start_fe.ps1"
|
||||
|
||||
$q = [char]39
|
||||
@(
|
||||
"Set-Location -LiteralPath ${q}${backendDir}${q}"
|
||||
"Write-Host ${q}=== 后端 FastAPI ===${q} -ForegroundColor Green"
|
||||
"uv run uvicorn app.main:app --reload --port 8000"
|
||||
"Write-Host ${q}后端已退出,按任意键关闭...${q} -ForegroundColor Red"
|
||||
"`$null = `$Host.UI.RawUI.ReadKey(${q}NoEcho,IncludeKeyDown${q})"
|
||||
) | Set-Content -Path $beTmp -Encoding UTF8
|
||||
|
||||
@(
|
||||
"Set-Location -LiteralPath ${q}${frontendDir}${q}"
|
||||
"Write-Host ${q}=== 前端 Vite ===${q} -ForegroundColor Green"
|
||||
"pnpm dev 2>&1 | Tee-Object -FilePath ${q}${frontendLog}${q}"
|
||||
"Write-Host ${q}前端已退出,按任意键关闭...${q} -ForegroundColor Red"
|
||||
"`$null = `$Host.UI.RawUI.ReadKey(${q}NoEcho,IncludeKeyDown${q})"
|
||||
) | Set-Content -Path $feTmp -Encoding UTF8
|
||||
|
||||
# ── 启动后端 ──
|
||||
Write-Host "[1/2] 启动后端 FastAPI (http://localhost:8000) ..." -ForegroundColor Yellow
|
||||
Start-Process $psExe -ArgumentList "-NoExit", "-ExecutionPolicy", "Bypass", "-File", $beTmp
|
||||
|
||||
Start-Sleep -Seconds 2
|
||||
|
||||
# ── 启动前端 ──
|
||||
Write-Host "[2/2] 启动前端 Vite (http://localhost:5173) ..." -ForegroundColor Yellow
|
||||
Start-Process $psExe -ArgumentList "-NoExit", "-ExecutionPolicy", "Bypass", "-File", $feTmp
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "========================================" -ForegroundColor Green
|
||||
Write-Host " 两个服务已在新窗口中启动" -ForegroundColor Green
|
||||
Write-Host " 后端: http://localhost:8000" -ForegroundColor Green
|
||||
Write-Host " 前端: http://localhost:5173" -ForegroundColor Green
|
||||
Write-Host "========================================" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
|
||||
# ── 检测前端就绪(匹配 Vite 输出中的 localhost:5173,忽略 ANSI 转义码) ──
|
||||
Write-Host "等待前端 Vite 就绪..." -ForegroundColor Yellow
|
||||
$timeout = 45
|
||||
$elapsed = 0
|
||||
$ready = $false
|
||||
|
||||
while ($elapsed -lt $timeout) {
|
||||
Start-Sleep -Seconds 1
|
||||
$elapsed++
|
||||
if (Test-Path $frontendLog) {
|
||||
$raw = Get-Content $frontendLog -Raw -ErrorAction SilentlyContinue
|
||||
if ($raw) {
|
||||
# 去掉 ANSI 转义序列后再匹配
|
||||
$clean = $raw -replace '\x1b\[[0-9;]*m', ''
|
||||
if ($clean -match "localhost:5173" -or $clean -match "ready in") {
|
||||
$ready = $true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($ready) {
|
||||
Write-Host "前端已就绪(${elapsed}s),打开浏览器..." -ForegroundColor Green
|
||||
Start-Process "http://localhost:5173"
|
||||
} else {
|
||||
Write-Host "等待超时(${timeout}s),请手动打开 http://localhost:5173" -ForegroundColor Red
|
||||
}
|
||||
|
||||
} catch {
|
||||
Write-Host ""
|
||||
Write-Host "启动失败: $_" -ForegroundColor Red
|
||||
Write-Host $_.ScriptStackTrace -ForegroundColor DarkRed
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "按任意键关闭此窗口..." -ForegroundColor DarkGray
|
||||
$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
|
||||
19
scripts/ops/test_mvp_api.py
Normal file
19
scripts/ops/test_mvp_api.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""快速验证 MVP API 接口"""
|
||||
import urllib.request
|
||||
import json
|
||||
|
||||
url = "http://127.0.0.1:8000/api/xcx-test"
|
||||
print(f"请求: GET {url}")
|
||||
|
||||
try:
|
||||
req = urllib.request.Request(url)
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
data = json.loads(resp.read().decode())
|
||||
print(f"状态码: {resp.status}")
|
||||
print(f"响应: {data}")
|
||||
if data.get("ti") == "t91":
|
||||
print("✓ MVP API 验证通过!")
|
||||
else:
|
||||
print(f"✗ 期望 ti='t91',实际 ti='{data.get('ti')}'")
|
||||
except Exception as e:
|
||||
print(f"✗ 请求失败: {e}")
|
||||
345
scripts/ops/update_board_coach.py
Normal file
345
scripts/ops/update_board_coach.py
Normal file
@@ -0,0 +1,345 @@
|
||||
"""替换 board-coach.html 中的助教卡片区域为新版本"""
|
||||
import re
|
||||
|
||||
filepath = "docs/h5_ui/pages/board-coach.html"
|
||||
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
# 定位旧的助教列表区域
|
||||
old_start = " <!-- 助教列表 -->"
|
||||
old_end_marker = " <!-- 悬浮助手按钮 -->"
|
||||
|
||||
start_idx = content.index(old_start)
|
||||
end_idx = content.index(old_end_marker)
|
||||
|
||||
new_coach_section = ''' <!-- 助教列表 — 默认展示"定档业绩最高"视图 -->
|
||||
<div class="p-4 space-y-3" id="coachList">
|
||||
|
||||
<!-- ====== 助教卡片 1 — 小燕 ====== -->
|
||||
<a href="coach-detail.html" class="block bg-white rounded-2xl p-4 shadow-sm coach-card">
|
||||
<!-- 第一行:头像 + 信息 + 右侧数据 -->
|
||||
<div class="flex items-start gap-3 mb-2.5">
|
||||
<div class="w-11 h-11 rounded-full bg-gradient-to-br from-blue-400 to-indigo-500 flex items-center justify-center flex-shrink-0">
|
||||
<span class="text-white font-semibold text-base">小</span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-1.5 flex-wrap">
|
||||
<span class="text-base font-semibold text-gray-13">小燕</span>
|
||||
<span class="px-1.5 py-0.5 bg-gradient-to-r from-amber-400 to-orange-400 text-white text-xs rounded flex-shrink-0">星级</span>
|
||||
<span class="px-1.5 py-0.5 bg-primary/10 text-primary text-xs rounded flex-shrink-0">中🎱</span>
|
||||
</div>
|
||||
<!-- 定档业绩:到下一档进度(本月时展示) -->
|
||||
<div class="mt-1.5">
|
||||
<div class="flex items-center justify-between text-xs text-gray-7 mb-1">
|
||||
<span>星级 → 王牌</span>
|
||||
<span class="text-primary font-medium">86.2h / 100h</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-2 rounded-full h-1.5">
|
||||
<div class="bg-gradient-to-r from-primary to-blue-400 h-1.5 rounded-full" style="width:86%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 第二行:客户 + 业绩时长 -->
|
||||
<div class="flex items-center justify-between text-xs pt-2 border-t border-gray-100">
|
||||
<div class="flex items-center gap-2 text-gray-6 truncate">
|
||||
<span>💖 王先生</span><span>💖 李女士</span><span>💛 赵总</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-gray-7 flex-shrink-0">
|
||||
<span>定档 <b class="text-gray-10">86.2h</b></span>
|
||||
<span class="text-gray-5">|</span>
|
||||
<span>折前 <b class="text-gray-10">92.0h</b></span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- ====== 助教卡片 2 — 泡芙 ====== -->
|
||||
<a href="coach-detail.html" class="block bg-white rounded-2xl p-4 shadow-sm coach-card">
|
||||
<div class="flex items-start gap-3 mb-2.5">
|
||||
<div class="w-11 h-11 rounded-full bg-gradient-to-br from-green-400 to-emerald-500 flex items-center justify-center flex-shrink-0">
|
||||
<span class="text-white font-semibold text-base">泡</span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-1.5 flex-wrap">
|
||||
<span class="text-base font-semibold text-gray-13">泡芙</span>
|
||||
<span class="px-1.5 py-0.5 bg-gradient-to-r from-purple-400 to-violet-400 text-white text-xs rounded flex-shrink-0">高级</span>
|
||||
<span class="px-1.5 py-0.5 bg-success/10 text-success text-xs rounded flex-shrink-0">斯诺克</span>
|
||||
</div>
|
||||
<div class="mt-1.5">
|
||||
<div class="flex items-center justify-between text-xs text-gray-7 mb-1">
|
||||
<span>高级 → 星级</span>
|
||||
<span class="text-primary font-medium">72.5h / 80h</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-2 rounded-full h-1.5">
|
||||
<div class="bg-gradient-to-r from-primary to-blue-400 h-1.5 rounded-full" style="width:90%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-xs pt-2 border-t border-gray-100">
|
||||
<div class="flex items-center gap-2 text-gray-6 truncate">
|
||||
<span>💖 陈先生</span><span>💛 刘女士</span><span>💛 黄总</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-gray-7 flex-shrink-0">
|
||||
<span>定档 <b class="text-gray-10">72.5h</b></span>
|
||||
<span class="text-gray-5">|</span>
|
||||
<span>折前 <b class="text-gray-10">78.0h</b></span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- ====== 助教卡片 3 — Amy ====== -->
|
||||
<a href="coach-detail.html" class="block bg-white rounded-2xl p-4 shadow-sm coach-card">
|
||||
<div class="flex items-start gap-3 mb-2.5">
|
||||
<div class="w-11 h-11 rounded-full bg-gradient-to-br from-pink-400 to-rose-500 flex items-center justify-center flex-shrink-0">
|
||||
<span class="text-white font-semibold text-base">A</span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-1.5 flex-wrap">
|
||||
<span class="text-base font-semibold text-gray-13">Amy</span>
|
||||
<span class="px-1.5 py-0.5 bg-gradient-to-r from-amber-400 to-orange-400 text-white text-xs rounded flex-shrink-0">星级</span>
|
||||
<span class="px-1.5 py-0.5 bg-primary/10 text-primary text-xs rounded flex-shrink-0">中🎱</span>
|
||||
<span class="px-1.5 py-0.5 bg-success/10 text-success text-xs rounded flex-shrink-0">斯诺克</span>
|
||||
</div>
|
||||
<div class="mt-1.5">
|
||||
<div class="flex items-center justify-between text-xs text-gray-7 mb-1">
|
||||
<span>星级 → 王牌</span>
|
||||
<span class="text-primary font-medium">68.0h / 100h</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-2 rounded-full h-1.5">
|
||||
<div class="bg-gradient-to-r from-primary to-blue-400 h-1.5 rounded-full" style="width:68%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-xs pt-2 border-t border-gray-100">
|
||||
<div class="flex items-center gap-2 text-gray-6 truncate">
|
||||
<span>💖 张先生</span><span>💛 周女士</span><span>💛 吴总</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-gray-7 flex-shrink-0">
|
||||
<span>定档 <b class="text-gray-10">68.0h</b></span>
|
||||
<span class="text-gray-5">|</span>
|
||||
<span>折前 <b class="text-gray-10">72.5h</b></span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- ====== 助教卡片 4 — Mia ====== -->
|
||||
<a href="coach-detail.html" class="block bg-white rounded-2xl p-4 shadow-sm coach-card">
|
||||
<div class="flex items-start gap-3 mb-2.5">
|
||||
<div class="w-11 h-11 rounded-full bg-gradient-to-br from-amber-400 to-orange-500 flex items-center justify-center flex-shrink-0">
|
||||
<span class="text-white font-semibold text-base">M</span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-1.5 flex-wrap">
|
||||
<span class="text-base font-semibold text-gray-13">Mia</span>
|
||||
<span class="px-1.5 py-0.5 bg-gradient-to-r from-blue-400 to-indigo-400 text-white text-xs rounded flex-shrink-0">中级</span>
|
||||
<span class="px-1.5 py-0.5 bg-warning/10 text-warning text-xs rounded flex-shrink-0">麻将</span>
|
||||
</div>
|
||||
<div class="mt-1.5">
|
||||
<div class="flex items-center justify-between text-xs text-gray-7 mb-1">
|
||||
<span>中级 → 高级</span>
|
||||
<span class="text-primary font-medium">55.0h / 60h</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-2 rounded-full h-1.5">
|
||||
<div class="bg-gradient-to-r from-primary to-blue-400 h-1.5 rounded-full" style="width:92%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-xs pt-2 border-t border-gray-100">
|
||||
<div class="flex items-center gap-2 text-gray-6 truncate">
|
||||
<span>💛 赵先生</span><span>💛 吴女士</span><span>💛 孙总</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-gray-7 flex-shrink-0">
|
||||
<span>定档 <b class="text-gray-10">55.0h</b></span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- ====== 助教卡片 5 — 糖糖 ====== -->
|
||||
<a href="coach-detail.html" class="block bg-white rounded-2xl p-4 shadow-sm coach-card">
|
||||
<div class="flex items-start gap-3 mb-2.5">
|
||||
<div class="w-11 h-11 rounded-full bg-gradient-to-br from-purple-400 to-violet-500 flex items-center justify-center flex-shrink-0">
|
||||
<span class="text-white font-semibold text-base">糖</span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-1.5 flex-wrap">
|
||||
<span class="text-base font-semibold text-gray-13">糖糖</span>
|
||||
<span class="px-1.5 py-0.5 bg-gradient-to-r from-gray-400 to-gray-500 text-white text-xs rounded flex-shrink-0">初级</span>
|
||||
<span class="px-1.5 py-0.5 bg-primary/10 text-primary text-xs rounded flex-shrink-0">中🎱</span>
|
||||
</div>
|
||||
<div class="mt-1.5">
|
||||
<div class="flex items-center justify-between text-xs text-gray-7 mb-1">
|
||||
<span>初级 → 中级</span>
|
||||
<span class="text-primary font-medium">42.0h / 40h ✅</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-2 rounded-full h-1.5">
|
||||
<div class="bg-gradient-to-r from-success to-emerald-400 h-1.5 rounded-full" style="width:100%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-xs pt-2 border-t border-gray-100">
|
||||
<div class="flex items-center gap-2 text-gray-6 truncate">
|
||||
<span>💛 钱先生</span><span>💛 孙女士</span><span>💛 周总</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-gray-7 flex-shrink-0">
|
||||
<span>定档 <b class="text-gray-10">42.0h</b></span>
|
||||
<span class="text-gray-5">|</span>
|
||||
<span>折前 <b class="text-gray-10">45.0h</b></span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- ====== 助教卡片 6 — 露露 ====== -->
|
||||
<a href="coach-detail.html" class="block bg-white rounded-2xl p-4 shadow-sm coach-card">
|
||||
<div class="flex items-start gap-3 mb-2.5">
|
||||
<div class="w-11 h-11 rounded-full bg-gradient-to-br from-cyan-400 to-teal-500 flex items-center justify-center flex-shrink-0">
|
||||
<span class="text-white font-semibold text-base">露</span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-1.5 flex-wrap">
|
||||
<span class="text-base font-semibold text-gray-13">露露</span>
|
||||
<span class="px-1.5 py-0.5 bg-gradient-to-r from-blue-400 to-indigo-400 text-white text-xs rounded flex-shrink-0">中级</span>
|
||||
<span class="px-1.5 py-0.5 bg-error/10 text-error text-xs rounded flex-shrink-0">团建</span>
|
||||
</div>
|
||||
<div class="mt-1.5">
|
||||
<div class="flex items-center justify-between text-xs text-gray-7 mb-1">
|
||||
<span>中级 → 高级</span>
|
||||
<span class="text-primary font-medium">38.0h / 60h</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-2 rounded-full h-1.5">
|
||||
<div class="bg-gradient-to-r from-primary to-blue-400 h-1.5 rounded-full" style="width:63%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-xs pt-2 border-t border-gray-100">
|
||||
<div class="flex items-center gap-2 text-gray-6 truncate">
|
||||
<span>💛 郑先生</span><span>💛 冯女士</span><span>💛 陈总</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-gray-7 flex-shrink-0">
|
||||
<span>定档 <b class="text-gray-10">38.0h</b></span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- ====== 其他分类视图样式参考(隐藏,仅供原型演示切换参考) ====== -->
|
||||
<div id="styleExamples" class="hidden">
|
||||
|
||||
<!-- === 工资最高/最低 视图 — 单卡示例 === -->
|
||||
<div class="p-4" data-category="salary">
|
||||
<a href="coach-detail.html" class="block bg-white rounded-2xl p-4 shadow-sm coach-card">
|
||||
<div class="flex items-start gap-3 mb-2.5">
|
||||
<div class="w-11 h-11 rounded-full bg-gradient-to-br from-blue-400 to-indigo-500 flex items-center justify-center flex-shrink-0">
|
||||
<span class="text-white font-semibold text-base">小</span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-1.5 flex-wrap">
|
||||
<span class="text-base font-semibold text-gray-13">小燕</span>
|
||||
<span class="px-1.5 py-0.5 bg-gradient-to-r from-amber-400 to-orange-400 text-white text-xs rounded">星级</span>
|
||||
<span class="px-1.5 py-0.5 bg-primary/10 text-primary text-xs rounded">中🎱</span>
|
||||
</div>
|
||||
<!-- 工资合计(含"预估"标签) -->
|
||||
<div class="mt-1.5 flex items-center gap-2">
|
||||
<span class="text-lg font-semibold text-gray-13">¥12,680</span>
|
||||
<span class="px-1.5 py-0.5 bg-warning/10 text-warning text-xs rounded">预估</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-xs pt-2 border-t border-gray-100">
|
||||
<div class="flex items-center gap-2 text-gray-6 truncate">
|
||||
<span>💖 王先生</span><span>💖 李女士</span><span>💛 赵总</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-gray-7 flex-shrink-0">
|
||||
<span>定档 <b class="text-gray-10">86.2h</b></span>
|
||||
<span class="text-gray-5">|</span>
|
||||
<span>折前 <b class="text-gray-10">92.0h</b></span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- === 客源储值最高 视图 — 单卡示例 === -->
|
||||
<div class="p-4" data-category="stored-value">
|
||||
<a href="coach-detail.html" class="block bg-white rounded-2xl p-4 shadow-sm coach-card">
|
||||
<div class="flex items-start gap-3 mb-2.5">
|
||||
<div class="w-11 h-11 rounded-full bg-gradient-to-br from-blue-400 to-indigo-500 flex items-center justify-center flex-shrink-0">
|
||||
<span class="text-white font-semibold text-base">小</span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-1.5 flex-wrap">
|
||||
<span class="text-base font-semibold text-gray-13">小燕</span>
|
||||
<span class="px-1.5 py-0.5 bg-gradient-to-r from-amber-400 to-orange-400 text-white text-xs rounded">星级</span>
|
||||
<span class="px-1.5 py-0.5 bg-primary/10 text-primary text-xs rounded">中🎱</span>
|
||||
</div>
|
||||
<!-- 客户会员卡余额 -->
|
||||
<div class="mt-1.5">
|
||||
<span class="text-lg font-semibold text-gray-13">¥45,200</span>
|
||||
<span class="text-xs text-gray-6 ml-1">客户储值余额</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-xs pt-2 border-t border-gray-100">
|
||||
<div class="flex items-center gap-2 text-gray-6 truncate">
|
||||
<span>💖 王先生</span><span>💖 李女士</span><span>💛 赵总</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-gray-7 flex-shrink-0">
|
||||
<span>周期消耗 <b class="text-gray-10">¥8,600</b></span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- === 任务完成最多 视图 — 单卡示例 === -->
|
||||
<div class="p-4" data-category="tasks">
|
||||
<a href="coach-detail.html" class="block bg-white rounded-2xl p-4 shadow-sm coach-card">
|
||||
<div class="flex items-start gap-3 mb-2.5">
|
||||
<div class="w-11 h-11 rounded-full bg-gradient-to-br from-blue-400 to-indigo-500 flex items-center justify-center flex-shrink-0">
|
||||
<span class="text-white font-semibold text-base">小</span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-1.5 flex-wrap">
|
||||
<span class="text-base font-semibold text-gray-13">小燕</span>
|
||||
<span class="px-1.5 py-0.5 bg-gradient-to-r from-amber-400 to-orange-400 text-white text-xs rounded">星级</span>
|
||||
<span class="px-1.5 py-0.5 bg-primary/10 text-primary text-xs rounded">中🎱</span>
|
||||
</div>
|
||||
<!-- 任务数 + 客户数 -->
|
||||
<div class="mt-1.5 flex items-center gap-3">
|
||||
<div>
|
||||
<span class="text-lg font-semibold text-primary">32</span>
|
||||
<span class="text-xs text-gray-6 ml-0.5">个任务</span>
|
||||
</div>
|
||||
<div class="w-px h-4 bg-gray-3"></div>
|
||||
<div>
|
||||
<span class="text-lg font-semibold text-gray-13">18</span>
|
||||
<span class="text-xs text-gray-6 ml-0.5">位客户</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-xs pt-2 border-t border-gray-100">
|
||||
<div class="flex items-center gap-2 text-gray-6 truncate">
|
||||
<span>💖 王先生</span><span>💖 李女士</span><span>💛 赵总</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-gray-7 flex-shrink-0">
|
||||
<span>定档 <b class="text-gray-10">86.2h</b></span>
|
||||
<span class="text-gray-5">|</span>
|
||||
<span>折前 <b class="text-gray-10">92.0h</b></span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
'''
|
||||
|
||||
content = content[:start_idx] + new_coach_section + content[end_idx:]
|
||||
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
|
||||
print("OK — coach cards replaced successfully")
|
||||
119
scripts/ops/update_board_coach_v2.py
Normal file
119
scripts/ops/update_board_coach_v2.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""board-coach.html — 去掉进度条,改为文字展示业绩小时数+下一档还需小时数"""
|
||||
import re
|
||||
|
||||
filepath = "docs/h5_ui/pages/board-coach.html"
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
c = f.read()
|
||||
|
||||
# 定义每个助教的进度条区域和替换内容
|
||||
replacements = [
|
||||
# 小燕: 86.2h, 下一档100h, 还差13.8h
|
||||
(
|
||||
''' <!-- 定档业绩:到下一档进度(本月时展示) -->
|
||||
<div class="mt-1.5">
|
||||
<div class="flex items-center justify-between text-xs text-gray-7 mb-1">
|
||||
<span>星级 → 王牌</span>
|
||||
<span class="text-primary font-medium">86.2h / 100h</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-2 rounded-full h-1.5">
|
||||
<div class="bg-gradient-to-r from-primary to-blue-400 h-1.5 rounded-full" style="width:86%"></div>
|
||||
</div>
|
||||
</div>''',
|
||||
''' <div class="mt-1 flex items-center gap-2 text-xs">
|
||||
<span class="font-bold text-primary text-sm">86.2h</span>
|
||||
<span class="text-gray-7">下一档还需 <span class="text-warning font-medium">13.8h</span></span>
|
||||
</div>'''
|
||||
),
|
||||
# 泡芙: 72.5h, 下一档80h, 还差7.5h
|
||||
(
|
||||
''' <div class="mt-1.5">
|
||||
<div class="flex items-center justify-between text-xs text-gray-7 mb-1">
|
||||
<span>高级 → 星级</span>
|
||||
<span class="text-primary font-medium">72.5h / 80h</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-2 rounded-full h-1.5">
|
||||
<div class="bg-gradient-to-r from-primary to-blue-400 h-1.5 rounded-full" style="width:90%"></div>
|
||||
</div>
|
||||
</div>''',
|
||||
''' <div class="mt-1 flex items-center gap-2 text-xs">
|
||||
<span class="font-bold text-primary text-sm">72.5h</span>
|
||||
<span class="text-gray-7">下一档还需 <span class="text-warning font-medium">7.5h</span></span>
|
||||
</div>'''
|
||||
),
|
||||
# Amy: 68.0h, 下一档100h, 还差32h
|
||||
(
|
||||
''' <div class="mt-1.5">
|
||||
<div class="flex items-center justify-between text-xs text-gray-7 mb-1">
|
||||
<span>星级 → 王牌</span>
|
||||
<span class="text-primary font-medium">68.0h / 100h</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-2 rounded-full h-1.5">
|
||||
<div class="bg-gradient-to-r from-primary to-blue-400 h-1.5 rounded-full" style="width:68%"></div>
|
||||
</div>
|
||||
</div>''',
|
||||
''' <div class="mt-1 flex items-center gap-2 text-xs">
|
||||
<span class="font-bold text-primary text-sm">68.0h</span>
|
||||
<span class="text-gray-7">下一档还需 <span class="text-warning font-medium">32.0h</span></span>
|
||||
</div>'''
|
||||
),
|
||||
# Mia: 55.0h, 下一档60h, 还差5h
|
||||
(
|
||||
''' <div class="mt-1.5">
|
||||
<div class="flex items-center justify-between text-xs text-gray-7 mb-1">
|
||||
<span>中级 → 高级</span>
|
||||
<span class="text-primary font-medium">55.0h / 60h</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-2 rounded-full h-1.5">
|
||||
<div class="bg-gradient-to-r from-primary to-blue-400 h-1.5 rounded-full" style="width:92%"></div>
|
||||
</div>
|
||||
</div>''',
|
||||
''' <div class="mt-1 flex items-center gap-2 text-xs">
|
||||
<span class="font-bold text-primary text-sm">55.0h</span>
|
||||
<span class="text-gray-7">下一档还需 <span class="text-warning font-medium">5.0h</span></span>
|
||||
</div>'''
|
||||
),
|
||||
# 糖糖: 42.0h, 已达标
|
||||
(
|
||||
''' <div class="mt-1.5">
|
||||
<div class="flex items-center justify-between text-xs text-gray-7 mb-1">
|
||||
<span>初级 → 中级</span>
|
||||
<span class="text-primary font-medium">42.0h / 40h ✅</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-2 rounded-full h-1.5">
|
||||
<div class="bg-gradient-to-r from-success to-emerald-400 h-1.5 rounded-full" style="width:100%"></div>
|
||||
</div>
|
||||
</div>''',
|
||||
''' <div class="mt-1 flex items-center gap-2 text-xs">
|
||||
<span class="font-bold text-success text-sm">42.0h</span>
|
||||
<span class="text-success font-medium">✅ 已达标</span>
|
||||
</div>'''
|
||||
),
|
||||
# 露露: 38.0h, 下一档60h, 还差22h
|
||||
(
|
||||
''' <div class="mt-1.5">
|
||||
<div class="flex items-center justify-between text-xs text-gray-7 mb-1">
|
||||
<span>中级 → 高级</span>
|
||||
<span class="text-primary font-medium">38.0h / 60h</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-2 rounded-full h-1.5">
|
||||
<div class="bg-gradient-to-r from-primary to-blue-400 h-1.5 rounded-full" style="width:63%"></div>
|
||||
</div>
|
||||
</div>''',
|
||||
''' <div class="mt-1 flex items-center gap-2 text-xs">
|
||||
<span class="font-bold text-primary text-sm">38.0h</span>
|
||||
<span class="text-gray-7">下一档还需 <span class="text-warning font-medium">22.0h</span></span>
|
||||
</div>'''
|
||||
),
|
||||
]
|
||||
|
||||
for old, new in replacements:
|
||||
if old in c:
|
||||
c = c.replace(old, new)
|
||||
print(f" ✅ 替换成功")
|
||||
else:
|
||||
print(f" ❌ 未找到匹配")
|
||||
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
f.write(c)
|
||||
|
||||
print("board-coach.html 进度条已替换为文字")
|
||||
101
scripts/ops/update_board_customer.py
Normal file
101
scripts/ops/update_board_customer.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""board-customer.html — 各维度卡片差异化设计 + 跟/弃基线对齐"""
|
||||
|
||||
filepath = "docs/h5_ui/pages/board-customer.html"
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
c = f.read()
|
||||
|
||||
# 1. 修复"跟"和"弃"badge的基线对齐
|
||||
# 当前 transform: translateY(-0.5px) 导致偏移,改为 vertical-align: baseline
|
||||
c = c.replace(
|
||||
'transform: translateY(-0.5px);',
|
||||
'vertical-align: baseline;'
|
||||
)
|
||||
|
||||
# 2. 最应召回 — 突出召回指数,用大号数字+红色超期天数
|
||||
old_recall_card1 = ''' <!-- ==================== 最应召回(默认) ==================== -->
|
||||
<div id="dim-recall" class="dim-container active p-4 pt-1 space-y-3">
|
||||
<!-- Card 1: 演示 assignee (红色加粗+跟) + normal + abandoned (灰色+弃) -->
|
||||
<a href="customer-detail.html" class="block bg-white rounded-2xl p-4 shadow-sm customer-card">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-9 h-9 rounded-xl bg-gradient-to-br from-amber-400 to-orange-500 flex items-center justify-center shrink-0">
|
||||
<span class="text-white font-semibold text-sm">王</span>
|
||||
</div>
|
||||
<span class="font-semibold text-gray-13">王先生</span>
|
||||
</div>
|
||||
<div class="text-right shrink-0">
|
||||
<div class="text-primary font-bold">0.92</div>
|
||||
<div class="text-[10px] text-gray-6 -mt-0.5">召回指数</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-1.5 text-xs mb-2.5 ml-11">
|
||||
<div class="text-gray-6">30天到店 <span class="text-gray-11 font-medium">5次</span></div>
|
||||
<div class="text-gray-6">最近到店 <span class="text-gray-11 font-medium">15天前</span></div>
|
||||
<div class="text-gray-6">理想间隔 <span class="text-gray-11 font-medium">7天</span></div>
|
||||
<div class="text-gray-6">超过 <span class="text-error font-medium">8天</span></div>
|
||||
<div class="text-gray-6">余额合计 <span class="text-gray-11 font-medium">¥8,000</span></div>
|
||||
</div>
|
||||
<div class="text-xs border-t border-gray-1 pt-2 ml-11">
|
||||
<span class="text-gray-6">助教:</span>
|
||||
<span class="assistant-tag">❤️ <span class="assistant-assignee">小燕</span><span class="assistant-badge assistant-badge-follow">跟</span></span>
|
||||
<span class="assistant-sep">|</span>
|
||||
<span class="assistant-tag">❤️ <span class="assistant-normal">泡芙</span></span>
|
||||
<span class="assistant-sep">|</span>
|
||||
<span class="assistant-tag">❤️ <span class="assistant-abandoned">Amy</span><span class="assistant-badge assistant-badge-drop">弃</span></span>
|
||||
</div>
|
||||
</a>'''
|
||||
|
||||
new_recall_card1 = ''' <!-- ==================== 最应召回(默认) ==================== -->
|
||||
<div id="dim-recall" class="dim-container active p-4 pt-1 space-y-3">
|
||||
<!-- Card 1 -->
|
||||
<a href="customer-detail.html" class="block bg-white rounded-2xl p-4 shadow-sm customer-card">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-9 h-9 rounded-xl bg-gradient-to-br from-amber-400 to-orange-500 flex items-center justify-center shrink-0">
|
||||
<span class="text-white font-semibold text-sm">王</span>
|
||||
</div>
|
||||
<span class="font-semibold text-gray-13">王先生</span>
|
||||
</div>
|
||||
<!-- 召回指数突出展示 -->
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<div class="bg-error/10 rounded-lg px-2.5 py-1.5 text-center">
|
||||
<div class="text-error font-bold text-lg leading-none">+8天</div>
|
||||
<div class="text-[9px] text-error/70 mt-0.5">超期</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-primary font-bold text-lg leading-none">0.92</div>
|
||||
<div class="text-[9px] text-gray-6 mt-0.5">召回指数</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-xs mb-2.5 ml-11">
|
||||
<span class="text-gray-7">到店 <span class="text-gray-11 font-medium">5次/30天</span></span>
|
||||
<span class="text-gray-4">·</span>
|
||||
<span class="text-gray-7">间隔 <span class="text-gray-11 font-medium">7天</span></span>
|
||||
<span class="text-gray-4">·</span>
|
||||
<span class="text-gray-7">余额 <span class="text-gray-11 font-medium">¥8,000</span></span>
|
||||
</div>
|
||||
<div class="text-xs border-t border-gray-1 pt-2 ml-11">
|
||||
<span class="text-gray-6">助教:</span>
|
||||
<span class="assistant-tag">❤️ <span class="assistant-assignee">小燕</span><span class="assistant-badge assistant-badge-follow">跟</span></span>
|
||||
<span class="assistant-sep">|</span>
|
||||
<span class="assistant-tag">❤️ <span class="assistant-normal">泡芙</span></span>
|
||||
<span class="assistant-sep">|</span>
|
||||
<span class="assistant-tag">❤️ <span class="assistant-abandoned">Amy</span><span class="assistant-badge assistant-badge-drop">弃</span></span>
|
||||
</div>
|
||||
</a>'''
|
||||
|
||||
c = c.replace(old_recall_card1, new_recall_card1)
|
||||
|
||||
# 3. 最高余额 — 突出余额金额,用大号金色数字
|
||||
# 替换第一张卡的右上角指数为余额突出展示
|
||||
c = c.replace(
|
||||
''' <!-- ==================== 最高余额 ==================== -->
|
||||
<div id="dim-balance" class="dim-container p-4 pt-1 space-y-3">
|
||||
<a href="customer-detail.html" class="block bg-white rounded-2xl p-4 shadow-sm customer-card">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-9 h-9 rounded-xl bg-gradient-to-br from-rose-400 to-pink-500 flex items-center justify-center shrink-0">
|
||||
<span class="text-white font-semibold text-sm">赵</span>
|
||||
</div>
|
||||
<span clas
|
||||
288
scripts/ops/update_coach_detail.py
Normal file
288
scripts/ops/update_coach_detail.py
Normal file
@@ -0,0 +1,288 @@
|
||||
"""coach-detail.html 多项改动"""
|
||||
|
||||
filepath = "docs/h5_ui/pages/coach-detail.html"
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
c = f.read()
|
||||
|
||||
# 1. "当前档位:星级" → "绩效档位进度",去掉"星级""皇冠"字样
|
||||
c = c.replace(
|
||||
'<span class="text-xs text-gray-9 font-medium">当前档位:星级</span>',
|
||||
'<span class="text-xs text-gray-9 font-medium">绩效档位进度</span>'
|
||||
)
|
||||
c = c.replace(
|
||||
'<span>星级 80h</span>',
|
||||
'<span>当前 80h</span>'
|
||||
)
|
||||
c = c.replace(
|
||||
'<span>皇冠 100h</span>',
|
||||
'<span>目标 100h</span>'
|
||||
)
|
||||
|
||||
# 2. 工龄客户信息放大
|
||||
c = c.replace(
|
||||
''' <div class="flex-shrink-0 text-right space-y-1">
|
||||
<div class="text-white/60 text-[10px]">工龄 <span class="text-white font-medium text-xs">3年</span></div>
|
||||
<div class="text-white/60 text-[10px]">客户 <span class="text-white font-medium text-xs">68人</span></div>
|
||||
</div>''',
|
||||
''' <div class="flex-shrink-0 text-right space-y-1.5">
|
||||
<div class="text-white/70 text-xs">工龄 <span class="text-white font-bold text-base">3年</span></div>
|
||||
<div class="text-white/70 text-xs">客户 <span class="text-white font-bold text-base">68人</span></div>
|
||||
</div>'''
|
||||
)
|
||||
|
||||
# 3. 全页文字放大2-3号:把关键的 text-sm → text-base, text-xs → text-sm, text-[10px] → text-xs
|
||||
# 只在 body 内容区域做(不动 banner 和 style)
|
||||
# 用更精确的替换
|
||||
# 绩效概览 4-grid 数值放大
|
||||
c = c.replace('text-xl font-bold text-primary pv">87.5', 'text-2xl font-bold text-primary pv">87.5')
|
||||
c = c.replace('text-xl font-bold text-success pv">¥6,950', 'text-2xl font-bold text-success pv">¥6,950')
|
||||
c = c.replace('text-xl font-bold text-warning pv">¥86,200', 'text-2xl font-bold text-warning pv">¥86,200')
|
||||
c = c.replace('text-xl font-bold text-purple-600 pv">38', 'text-2xl font-bold text-purple-600 pv">38')
|
||||
# section 标题放大
|
||||
c = c.replace('text-sm font-semibold text-gray-13 mb-4">绩效概览', 'text-base font-semibold text-gray-13 mb-4">绩效概览')
|
||||
c = c.replace('text-sm font-semibold text-gray-13 mb-4">收入明细', 'text-base font-semibold text-gray-13 mb-4">收入明细')
|
||||
c = c.replace('text-sm font-semibold text-gray-13 mb-4">任务执行', 'text-base font-semibold text-gray-13 mb-4">任务执行')
|
||||
c = c.replace('text-sm font-semibold text-gray-13 mb-4">客户关系 TOP5', 'text-base font-semibold text-gray-13 mb-4">客户关系 TOP5')
|
||||
c = c.replace('text-sm font-semibold text-gray-13 mb-4">近期服务明细', 'text-base font-semibold text-gray-13 mb-4">近期服务明细')
|
||||
c = c.replace('text-sm font-semibold text-gray-13 mb-4">更多信息', 'text-base font-semibold text-gray-13 mb-4">更多信息')
|
||||
# 收入明细项目名放大
|
||||
c = c.replace('text-sm text-gray-9">基础课时费', 'text-base text-gray-9">基础课时费')
|
||||
c = c.replace('text-sm text-gray-9">激励课时费', 'text-base text-gray-9">激励课时费')
|
||||
c = c.replace('text-sm text-gray-9">充值提成', 'text-base text-gray-9">充值提成')
|
||||
c = c.replace('text-sm text-gray-9">酒水提成', 'text-base text-gray-9">酒水提成')
|
||||
c = c.replace('text-sm font-semibold text-gray-9">合计', 'text-base font-semibold text-gray-9">合计')
|
||||
# 收入金额放大
|
||||
c = c.replace('text-sm font-bold text-gray-13 pv">¥3,500', 'text-base font-bold text-gray-13 pv">¥3,500')
|
||||
c = c.replace('text-sm font-bold text-gray-13 pv">¥1,800', 'text-base font-bold text-gray-13 pv">¥1,800')
|
||||
c = c.replace('text-sm font-bold text-gray-13 pv">¥1,200', 'text-base font-bold text-gray-13 pv">¥1,200')
|
||||
c = c.replace('text-sm font-bold text-gray-13 pv">¥450', 'text-base font-bold text-gray-13 pv">¥450')
|
||||
# 更多信息行放大
|
||||
c = c.replace('text-sm text-gray-7">入职日期', 'text-base text-gray-7">入职日期')
|
||||
c = c.replace('text-sm text-gray-13">2023-03-15', 'text-base text-gray-13">2023-03-15')
|
||||
c = c.replace('text-sm text-gray-7">上月工资', 'text-base text-gray-7">上月工资')
|
||||
c = c.replace('text-sm font-medium text-gray-13 pv">¥7,200', 'text-base font-medium text-gray-13 pv">¥7,200')
|
||||
c = c.replace('text-sm text-gray-7">上月业绩时长', 'text-base text-gray-7">上月业绩时长')
|
||||
c = c.replace('text-sm text-gray-13 pv">92.0h', 'text-base text-gray-13 pv">92.0h')
|
||||
c = c.replace('text-sm text-gray-7">累计服务客户', 'text-base text-gray-7">累计服务客户')
|
||||
c = c.replace('text-sm text-gray-13 pv">68人', 'text-base text-gray-13 pv">68人')
|
||||
c = c.replace('text-sm text-gray-7">累计服务时长', 'text-base text-gray-7">累计服务时长')
|
||||
c = c.replace('text-sm text-gray-13 pv">2,860h', 'text-base text-gray-13 pv">2,860h')
|
||||
|
||||
# 4. 任务执行:替换进度条为任务简报
|
||||
old_task = ''' <!-- 任务执行 -->
|
||||
<div class="bg-white rounded-2xl p-5 shadow-sm">
|
||||
<h2 class="st orange text-base font-semibold text-gray-13 mb-4">任务执行</h2>
|
||||
<div class="flex items-center justify-between mb-3 text-xs text-gray-6">
|
||||
<span>本月完成 38 个任务</span>
|
||||
<span>待处理 12 个</span>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<!-- 回访任务 -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-xs text-gray-9">回访任务</span>
|
||||
<span class="text-xs text-gray-7 pv">15/18</span>
|
||||
</div>
|
||||
<div class="progress-sm">
|
||||
<div class="fill bg-gradient-to-r from-primary to-blue-400" style="width:83%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 充值任务 -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-xs text-gray-9">充值任务</span>
|
||||
<span class="text-xs text-gray-7 pv">8/10</span>
|
||||
</div>
|
||||
<div class="progress-sm">
|
||||
<div class="fill bg-gradient-to-r from-success to-emerald-400" style="width:80%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 激活任务 -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-xs text-gray-9">激活任务</span>
|
||||
<span class="text-xs text-gray-7 pv">10/15</span>
|
||||
</div>
|
||||
<div class="progress-sm">
|
||||
<div class="fill bg-gradient-to-r from-warning to-amber-400" style="width:67%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 关怀任务 -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-xs text-gray-9">关怀任务</span>
|
||||
<span class="text-xs text-gray-7 pv">5/7</span>
|
||||
</div>
|
||||
<div class="progress-sm">
|
||||
<div class="fill bg-gradient-to-r from-pink-400 to-rose-400" style="width:71%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>'''
|
||||
|
||||
# 检查是否已经被放大过(text-base)
|
||||
if old_task not in c:
|
||||
# 可能还是 text-sm 版本
|
||||
old_task = old_task.replace('text-base font-semibold', 'text-sm font-semibold')
|
||||
|
||||
new_task = ''' <!-- 任务执行 -->
|
||||
<div class="bg-white rounded-2xl p-5 shadow-sm">
|
||||
<h2 class="st orange text-base font-semibold text-gray-13 mb-4">任务执行</h2>
|
||||
<!-- 当前任务概况 -->
|
||||
<div class="grid grid-cols-3 gap-2 mb-4">
|
||||
<div class="bg-red-50 rounded-xl p-3 text-center border border-red-100/50">
|
||||
<p class="text-2xl font-bold text-error pv">3</p>
|
||||
<p class="text-xs text-gray-7 mt-0.5">置顶任务</p>
|
||||
</div>
|
||||
<div class="bg-blue-50 rounded-xl p-3 text-center border border-blue-100/50">
|
||||
<p class="text-2xl font-bold text-primary pv">9</p>
|
||||
<p class="text-xs text-gray-7 mt-0.5">普通待办</p>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-xl p-3 text-center border border-gray-200">
|
||||
<p class="text-2xl font-bold text-gray-5 pv">2</p>
|
||||
<p class="text-xs text-gray-7 mt-0.5">已放弃</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 已完成任务简报 -->
|
||||
<p class="text-sm font-medium text-gray-9 mb-2">已完成 <span class="text-primary font-bold">38</span> 个</p>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-3 p-2.5 bg-gray-50 rounded-lg border border-gray-100">
|
||||
<span class="text-xs">✅</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="text-sm text-gray-13">回访王先生</span>
|
||||
<span class="text-xs text-gray-6 ml-2">2月7日</span>
|
||||
</div>
|
||||
<span class="text-xs text-success font-medium">已完成</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 p-2.5 bg-gray-50 rounded-lg border border-gray-100">
|
||||
<span class="text-xs">✅</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="text-sm text-gray-13">充值跟进李女士</span>
|
||||
<span class="text-xs text-gray-6 ml-2">2月6日</span>
|
||||
</div>
|
||||
<span class="text-xs text-success font-medium">已完成</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 p-2.5 bg-gray-50 rounded-lg border border-gray-100">
|
||||
<span class="text-xs">✅</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="text-sm text-gray-13">激活陈女士</span>
|
||||
<span class="text-xs text-gray-6 ml-2">2月5日</span>
|
||||
</div>
|
||||
<span class="text-xs text-success font-medium">已完成</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 p-2.5 bg-gray-50 rounded-lg border border-gray-100">
|
||||
<span class="text-xs">✅</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="text-sm text-gray-13">关怀张先生</span>
|
||||
<span class="text-xs text-gray-6 ml-2">2月4日</span>
|
||||
</div>
|
||||
<span class="text-xs text-success font-medium">已完成</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 text-center">
|
||||
<button class="text-sm text-primary font-medium">查看全部任务 →</button>
|
||||
</div>
|
||||
</div>'''
|
||||
|
||||
if old_task in c:
|
||||
c = c.replace(old_task, new_task)
|
||||
print(" ✅ 任务执行替换成功")
|
||||
else:
|
||||
print(" ❌ 任务执行未找到匹配,尝试原始版本")
|
||||
|
||||
# 5. 更多信息:去掉擅长项目,服务客户/业绩时长/工资做成表格
|
||||
old_more = ''' <!-- 更多信息 -->
|
||||
<div class="bg-white rounded-2xl p-5 shadow-sm">
|
||||
<h2 class="st teal text-base font-semibold text-gray-13 mb-4">更多信息</h2>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between py-2 border-b border-gray-100">
|
||||
<span class="text-base text-gray-7">入职日期</span>
|
||||
<span class="text-base text-gray-13">2023-03-15</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-2 border-b border-gray-100">
|
||||
<span class="text-sm text-gray-7">擅长项目</span>
|
||||
<span class="text-sm text-gray-13">中🎱、斯诺克、花式</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-2 border-b border-gray-100">
|
||||
<span class="text-base text-gray-7">上月工资</span>
|
||||
<span class="text-base font-medium text-gray-13 pv">¥7,200</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-2 border-b border-gray-100">
|
||||
<span class="text-base text-gray-7">上月业绩时长</span>
|
||||
<span class="text-base text-gray-13 pv">92.0h</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-2 border-b border-gray-100">
|
||||
<span class="text-base text-gray-7">累计服务客户</span>
|
||||
<span class="text-base text-gray-13 pv">68人</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-2">
|
||||
<span class="text-base text-gray-7">累计服务时长</span>
|
||||
<span class="text-base text-gray-13 pv">2,860h</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>'''
|
||||
|
||||
new_more = ''' <!-- 更多信息 -->
|
||||
<div class="bg-white rounded-2xl p-5 shadow-sm">
|
||||
<h2 class="st teal text-base font-semibold text-gray-13 mb-4">更多信息</h2>
|
||||
<div class="flex items-center justify-between py-2 border-b border-gray-100 mb-4">
|
||||
<span class="text-base text-gray-7">入职日期</span>
|
||||
<span class="text-base text-gray-13">2023-03-15</span>
|
||||
</div>
|
||||
<!-- 月度数据表格 -->
|
||||
<div class="overflow-x-auto -mx-1">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200">
|
||||
<th class="text-left py-2 px-2 text-gray-7 font-medium text-xs">月份</th>
|
||||
<th class="text-right py-2 px-2 text-gray-7 font-medium text-xs">服务客户</th>
|
||||
<th class="text-right py-2 px-2 text-gray-7 font-medium text-xs">业绩时长</th>
|
||||
<th class="text-right py-2 px-2 text-gray-7 font-medium text-xs">工资</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="border-b border-gray-50 bg-blue-50/30">
|
||||
<td class="py-2.5 px-2 text-gray-13 font-medium">本月<span class="text-[10px] text-warning ml-1">预估</span></td>
|
||||
<td class="py-2.5 px-2 text-right text-gray-13 pv font-medium">22人</td>
|
||||
<td class="py-2.5 px-2 text-right text-primary pv font-bold">87.5h</td>
|
||||
<td class="py-2.5 px-2 text-right text-success pv font-bold">¥6,950</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-50">
|
||||
<td class="py-2.5 px-2 text-gray-13">上月</td>
|
||||
<td class="py-2.5 px-2 text-right text-gray-13 pv">25人</td>
|
||||
<td class="py-2.5 px-2 text-right text-gray-13 pv">92.0h</td>
|
||||
<td class="py-2.5 px-2 text-right text-gray-13 pv">¥7,200</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-50">
|
||||
<td class="py-2.5 px-2 text-gray-13">4月</td>
|
||||
<td class="py-2.5 px-2 text-right text-gray-13 pv">20人</td>
|
||||
<td class="py-2.5 px-2 text-right text-gray-13 pv">85.0h</td>
|
||||
<td class="py-2.5 px-2 text-right text-gray-13 pv">¥6,600</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-50">
|
||||
<td class="py-2.5 px-2 text-gray-13">3月</td>
|
||||
<td class="py-2.5 px-2 text-right text-gray-13 pv">18人</td>
|
||||
<td class="py-2.5 px-2 text-right text-gray-13 pv">78.5h</td>
|
||||
<td class="py-2.5 px-2 text-right text-gray-13 pv">¥6,100</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-2.5 px-2 text-gray-13">2月</td>
|
||||
<td class="py-2.5 px-2 text-right text-gray-13 pv">15人</td>
|
||||
<td class="py-2.5 px-2 text-right text-gray-13 pv">65.0h</td>
|
||||
<td class="py-2.5 px-2 text-right text-gray-13 pv">¥5,200</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>'''
|
||||
|
||||
if old_more in c:
|
||||
c = c.replace(old_more, new_more)
|
||||
print(" ✅ 更多信息替换成功")
|
||||
else:
|
||||
print(" ❌ 更多信息未找到匹配")
|
||||
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
f.write(c)
|
||||
|
||||
print("coach-detail.html 更新完成")
|
||||
55
scripts/ops/update_customer_detail.py
Normal file
55
scripts/ops/update_customer_detail.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""customer-detail.html 改动:灰色字加深、商城订单加总金额、台桌详情改台桌名称"""
|
||||
|
||||
filepath = "docs/h5_ui/pages/customer-detail.html"
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
c = f.read()
|
||||
|
||||
# 1. 灰色字颜色统一加深:text-gray-5 → text-gray-7, text-gray-6 → text-gray-8
|
||||
# 只在 body 内容中替换(不动 CSS 定义)
|
||||
c = c.replace('text-gray-5 ', 'text-gray-7 ')
|
||||
c = c.replace('text-gray-5"', 'text-gray-7"')
|
||||
c = c.replace('text-gray-6 ', 'text-gray-8 ')
|
||||
c = c.replace('text-gray-6"', 'text-gray-8"')
|
||||
# 但保留 orig-price 的 color(在 style 中定义的)
|
||||
# text-gray-6 在 style 中是 #a6a6a6,不受影响
|
||||
|
||||
# 2. "台桌详情" → 台桌名称
|
||||
c = c.replace(
|
||||
'<span class="text-xs font-semibold text-primary">台桌详情</span>\n </div>\n <span class="text-xs text-gray-8">2026-02-05</span>',
|
||||
'<span class="text-xs font-semibold text-primary">A12号台</span>\n </div>\n <span class="text-xs text-gray-8">2026-02-05</span>'
|
||||
)
|
||||
c = c.replace(
|
||||
'<span class="text-xs font-semibold text-primary">台桌详情</span>\n </div>\n <span class="text-xs text-gray-8">2026-02-01</span>',
|
||||
'<span class="text-xs font-semibold text-primary">888号台</span>\n </div>\n <span class="text-xs text-gray-8">2026-02-01</span>'
|
||||
)
|
||||
|
||||
# 3. 商城订单加总金额
|
||||
old_mall = ''' <!-- 食品酒水 -->
|
||||
<div class="flex items-center justify-between pt-2 border-t border-gray-100">
|
||||
<span class="text-xs text-gray-8">🍷 食品酒水</span>
|
||||
<span class="text-sm font-medium text-warning pv">¥180</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>'''
|
||||
new_mall = ''' <!-- 食品酒水 -->
|
||||
<div class="flex items-center justify-between pt-2 border-t border-gray-100">
|
||||
<span class="text-xs text-gray-8">🍷 食品酒水</span>
|
||||
<span class="text-sm font-medium text-warning pv">¥180</span>
|
||||
</div>
|
||||
<!-- 总金额 -->
|
||||
<div class="flex items-center justify-between pt-2 border-t border-gray-200">
|
||||
<span class="text-xs font-semibold text-gray-9">总金额</span>
|
||||
<span class="text-base font-bold text-error pv">¥280</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>'''
|
||||
c = c.replace(old_mall, new_mall)
|
||||
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
f.write(c)
|
||||
|
||||
print("✅ customer-detail.html 更新完成")
|
||||
44
scripts/ops/update_kiro_shell.py
Normal file
44
scripts/ops/update_kiro_shell.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""
|
||||
一次性脚本:更新 Kiro settings.json,将默认终端切换为 PowerShell 7 (pwsh.exe)
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
|
||||
settings_path = os.path.join(
|
||||
os.environ["APPDATA"], "Kiro", "User", "settings.json"
|
||||
)
|
||||
|
||||
# 备份
|
||||
backup_path = settings_path + ".bak"
|
||||
shutil.copy2(settings_path, backup_path)
|
||||
print(f"已备份: {backup_path}")
|
||||
|
||||
with open(settings_path, "r", encoding="utf-8") as f:
|
||||
settings = json.load(f)
|
||||
|
||||
# 配置 PowerShell 7 为默认终端 profile
|
||||
settings["terminal.integrated.profiles.windows"] = {
|
||||
"PowerShell 7": {
|
||||
"path": "C:\\Program Files\\PowerShell\\7\\pwsh.exe",
|
||||
"icon": "terminal-powershell",
|
||||
"args": ["-NoLogo"]
|
||||
},
|
||||
"PowerShell 5": {
|
||||
"path": "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
|
||||
"icon": "terminal-powershell"
|
||||
},
|
||||
"Command Prompt": {
|
||||
"path": "C:\\Windows\\System32\\cmd.exe",
|
||||
"icon": "terminal-cmd"
|
||||
}
|
||||
}
|
||||
settings["terminal.integrated.defaultProfile.windows"] = "PowerShell 7"
|
||||
|
||||
with open(settings_path, "w", encoding="utf-8") as f:
|
||||
json.dump(settings, f, indent=4, ensure_ascii=False)
|
||||
|
||||
print("已更新 Kiro settings.json:")
|
||||
print(" - 添加 PowerShell 7 profile")
|
||||
print(" - 默认终端设为 PowerShell 7 (pwsh.exe)")
|
||||
print("\n请重启 Kiro 使配置生效。")
|
||||
41
scripts/ops/verify_all_dbs.py
Normal file
41
scripts/ops/verify_all_dbs.py
Normal file
@@ -0,0 +1,41 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""验证四个数据库的状态:表数量、schema 分布"""
|
||||
import psycopg2
|
||||
|
||||
CONN = dict(host="100.64.0.4", port=5432, user="local-Python", password="Neo-local-1991125")
|
||||
DBS = ["etl_feiqiu", "test_etl_feiqiu", "zqyy_app", "test_zqyy_app"]
|
||||
|
||||
for db in DBS:
|
||||
try:
|
||||
c = psycopg2.connect(**CONN, dbname=db)
|
||||
cur = c.cursor()
|
||||
cur.execute(
|
||||
"SELECT schemaname, count(*) FROM pg_tables "
|
||||
"WHERE schemaname NOT IN ('pg_catalog','information_schema') "
|
||||
"GROUP BY schemaname ORDER BY schemaname"
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
total = sum(r[1] for r in rows)
|
||||
schemas = ", ".join(f"{r[0]}({r[1]})" for r in rows)
|
||||
print(f"[OK] {db}: {total} tables | {schemas}")
|
||||
|
||||
# 物化视图数量
|
||||
cur.execute(
|
||||
"SELECT count(*) FROM pg_matviews "
|
||||
"WHERE schemaname NOT IN ('pg_catalog','information_schema')"
|
||||
)
|
||||
mv_count = cur.fetchone()[0]
|
||||
if mv_count:
|
||||
print(f" matviews: {mv_count}")
|
||||
|
||||
c.close()
|
||||
except Exception as e:
|
||||
print(f"[FAIL] {db}: {e}")
|
||||
|
||||
print("\n--- 配置文件指向 ---")
|
||||
print("ETL .env PG_DSN -> test_etl_feiqiu (已确认)")
|
||||
print("根 .env -> PG_NAME=test_etl_feiqiu, APP_DB_NAME=test_zqyy_app")
|
||||
print("后端 .env.local -> APP_DB_NAME=test_zqyy_app, ETL_DB_NAME=test_etl_feiqiu")
|
||||
print("后端 config.py 默认值 -> test_zqyy_app / test_etl_feiqiu")
|
||||
print("FDW 生产 -> setup_fdw.sql (etl_feiqiu)")
|
||||
print("FDW 测试 -> setup_fdw_test.sql (test_etl_feiqiu)")
|
||||
Reference in New Issue
Block a user