Files
Neo-ZQYY/scripts/ops/churned_customer_report.py
Neo 2a7a5d68aa feat: 2026-04-15~04-20 累积变更基线 — 多主线合流
主线 1: rns1-customer-coach-api + 04-miniapp-core-business 后端实施
  - 新增 GET /xcx/coaches/{id}/banner 轻量接口
  - performance/records 加 coach_id 参数 + view_board_coach 权限分流
  - coach/customer/performance/board/task 服务层重构
  - fdw_queries 结算单粒度聚合 + consumption_summary 视图统一
  - task_generator 回访宽限 72h + UPSERT 替代策略 + Step 5 保底清理
  - recall_detector settle_type=3 双重限制 + 门店级 resolved

主线 2: 小程序权限分流 + 新增 coach-service-records 管理者视角业绩明细页
  - perf-progress 共享模块去重 task-list/coach-detail 动画逻辑
  - isScattered 散客标记端到端
  - foodDetail/phoneFull/creator* 字段透传

主线 3: P19 指数回测框架 Phase 1+2
  - 3 个指数表 stat_date 日快照模式
  - 新增 DWS_INDEX_BACKFILL / DWS_TASK_SIMULATION 工具任务
  - task_engine 升级 HTTP 实时 + 推演回测双模式

主线 4: Core 维度层启用
  - 新增 CORE_DIM_SYNC 任务(DWD → core 4 维度表)
  - 修复 app 视图空查询问题

主线 5: member_project_tag 改为 LAST_30_VISITS 消费次数窗口

主线 6: 2 个迁移 SQL 已执行(stat_date + member_project_tag 新窗口)
  - schema 基线与 DDL 快照同步

主线 7: 开发机路径迁移 C:\NeoZQYY → C:\Project\NeoZQYY(约 95% 改动量)

附带: 新建运维脚本(churned_customer_report / simulate_historical_tasks /
      backfill_index_snapshots)+ tools/task-analysis/ 任务分析工具

合计 157 文件。未包含中间产物(tmp/ .playwright-mcp/ inspect-* excel/sheet 分析 txt)。
审计记录见下一个 commit。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 06:32:07 +08:00

1138 lines
50 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""
流失客户回访报告生成脚本。
功能:
- 从 Excel2025年1-6月和测试数据库2025年7月起提取客户数据
- 按手机号归并筛选月消费≥3000 且最后到店<2026-04-01 的流失客户
- 生成打印优化的 HTML 报告(汇总首页 + 客户详情页)
用法:
cd c:/Project/NeoZQYY
python scripts/ops/churned_customer_report.py
"""
from __future__ import annotations
import os
import re
import sys
from collections import defaultdict
from datetime import datetime, date
from pathlib import Path
# 加载环境变量
sys.path.insert(0, str(Path(__file__).resolve().parent))
from _env_paths import ensure_repo_root
ensure_repo_root()
import pandas as pd
import psycopg2
# ─── 配置 ───────────────────────────────────────────────
EXCEL_PATH = Path("tmp/2025年1-6月.xlsx")
OUTPUT_PATH = Path("tmp/churned_customer_report.html")
PG_DSN = os.environ["PG_DSN"]
# 筛选条件
MIN_MONTHLY_CONSUME = 3000 # 任意月消费 ≥ 此值
LAST_VISIT_BEFORE = date(2026, 4, 1) # 最后到店早于此日期
REPORT_DATE = date(2026, 4, 11) # 报告日期(计算距今天数)
# 横轴范围
MONTHS = pd.period_range("2025-01", "2026-03", freq="M")
MONTH_LABELS = [p.strftime("%Y-%m") for p in MONTHS]
# ─── 助教名归一化 ─────────────────────────────────────────
def normalize_assistant_name(name: str) -> str:
"""去掉 Excel 助教名中的编号前缀,如 '38号橙子''橙子', '17小爱''小爱'"""
if not name or not isinstance(name, str):
return str(name) if name else ""
name = name.strip()
# 去掉前导数字+可选"号"
cleaned = re.sub(r"^\d+号?", "", name).strip()
return cleaned if cleaned else name
# ═══════════════════════════════════════════════════════════
# 第一部分Excel 数据提取
# ═══════════════════════════════════════════════════════════
def load_excel_data() -> dict:
"""从 Excel 提取消费、充值、助教服务数据,按手机号组织。"""
print("[Excel] 读取文件...")
xls = pd.ExcelFile(EXCEL_PATH)
# 用户表ID → 手机号/姓名
df_user = pd.read_excel(xls, sheet_name="用户表")
user_id_to_phone = dict(zip(df_user["用户表.ID"], df_user["用户表.手机号"].astype(str)))
user_id_to_name = dict(zip(df_user["用户表.ID"], df_user["用户表.姓名"].astype(str)))
# 订单表订单ID → 用户ID用于助教服务关联
df_orders = pd.read_excel(xls, sheet_name="订单表")
order_to_user = dict(zip(df_orders["订单表.ID"], df_orders["订单表.用户 ID"]))
# ── 1. 消费数据(流水构成)──
df_flow = pd.read_excel(xls, sheet_name="流水构成")
df_flow["支付时间"] = pd.to_datetime(df_flow["支付时间"], errors="coerce")
df_flow = df_flow[df_flow["用户ID"] > 0].copy()
df_flow["手机号"] = df_flow["用户ID"].map(user_id_to_phone)
df_flow["月份"] = df_flow["支付时间"].dt.to_period("M").astype(str)
# items_sum 口径
df_flow["items_sum"] = (
df_flow["开台台费小计"].fillna(0)
+ df_flow["商品总额"].fillna(0)
+ df_flow["助教在店费"].fillna(0)
+ df_flow["助教超休费"].fillna(0)
)
# 按手机号+月份聚合消费
consume_monthly = (
df_flow.groupby(["手机号", "月份"])
.agg(
items_sum=("items_sum", "sum"),
table_fee=("开台台费小计", "sum"),
goods_amount=("商品总额", "sum"),
assistant_amount=("助教在店费", lambda x: x.fillna(0).sum() + 0), # 陪打
cx_amount=("助教超休费", lambda x: x.fillna(0).sum()),
)
.reset_index()
)
# 助教总额 = 陪打 + 超休
consume_monthly["assistant_total"] = (
consume_monthly["assistant_amount"] + consume_monthly["cx_amount"]
)
# 到店记录(每笔订单算一次到店)
visit_records = []
for _, row in df_flow.iterrows():
if pd.notna(row["支付时间"]) and row.get("手机号"):
visit_records.append({
"手机号": row["手机号"],
"visit_date": row["支付时间"].date(),
"visit_time": row["支付时间"],
"items_sum": row["items_sum"],
"table_fee": row.get("开台台费小计", 0) or 0,
"goods_amount": row.get("商品总额", 0) or 0,
"assistant_amount": (row.get("助教在店费", 0) or 0)
+ (row.get("助教超休费", 0) or 0),
"duration_min": 0, # Excel 无精确时长
"assistant_names": [],
})
# ── 2. 充值数据 ──
df_rc = pd.read_excel(xls, sheet_name="会员卡充值记录表")
df_rc["时间"] = pd.to_datetime(df_rc["会员卡充值记录表.时间"], errors="coerce")
df_rc["手机号"] = df_rc["会员卡充值记录表.用户 ID"].map(user_id_to_phone)
df_rc["月份"] = df_rc["时间"].dt.to_period("M").astype(str)
recharge_monthly = (
df_rc.groupby(["手机号", "月份"])["会员卡充值记录表.充值金额"]
.sum()
.reset_index()
.rename(columns={"会员卡充值记录表.充值金额": "recharge_amount"})
)
# ── 3. 助教陪打服务 ──
df_ast = pd.read_excel(xls, sheet_name="助教服务记录表")
df_ast["用户ID"] = df_ast["助教服务记录表.订单 ID"].map(order_to_user)
df_ast = df_ast[df_ast["用户ID"] > 0].copy()
df_ast["手机号"] = df_ast["用户ID"].map(user_id_to_phone)
df_ast["支付时间"] = pd.to_datetime(df_ast["助教服务记录表.支付时间"], errors="coerce")
df_ast["月份"] = df_ast["支付时间"].dt.to_period("M").astype(str)
df_ast["助教名"] = df_ast["助教服务记录表.助教用户名"].apply(normalize_assistant_name)
df_ast["时长小时"] = df_ast["助教服务记录表.助教时长秒"].fillna(0) / 3600.0
df_ast["加权时长"] = df_ast["时长小时"] * 1.0 # 陪打权重 1.0
# ── 4. 助教超休服务 ──
df_cx = pd.read_excel(xls, sheet_name="助教超休记录表")
df_cx["用户ID"] = df_cx["助教超休记录表.订单 ID"].map(order_to_user)
df_cx = df_cx[df_cx["用户ID"] > 0].copy()
df_cx["手机号"] = df_cx["用户ID"].map(user_id_to_phone)
df_cx["支付时间"] = pd.to_datetime(df_cx["助教超休记录表.支付时间"], errors="coerce")
df_cx["月份"] = df_cx["支付时间"].dt.to_period("M").astype(str)
df_cx["助教名"] = df_cx["助教超休记录表.助教用户名"].apply(normalize_assistant_name)
df_cx["时长小时"] = df_cx["助教超休记录表.超休小时数"].fillna(0)
df_cx["加权时长"] = df_cx["时长小时"] * 1.5 # 超休权重 1.5
# 合并助教服务
ast_all = pd.concat([
df_ast[["手机号", "月份", "助教名", "时长小时", "加权时长"]],
df_cx[["手机号", "月份", "助教名", "时长小时", "加权时长"]],
], ignore_index=True)
# 客户姓名映射
phone_to_names: dict[str, set] = defaultdict(set)
for uid, phone in user_id_to_phone.items():
name = user_id_to_name.get(uid, "")
if name and name != "nan":
phone_to_names[phone].add(name)
# 到店时间偏好(补充助教名到 visit_records
# 为 visit_records 补充助教名
order_assistants: dict[int, list[str]] = defaultdict(list)
for _, row in df_ast.iterrows():
oid = row["助教服务记录表.订单 ID"]
order_assistants[oid].append(row["助教名"])
for _, row in df_cx.iterrows():
oid = row["助教超休记录表.订单 ID"]
order_assistants[oid].append(row["助教名"])
# 重建 visit_records按订单去重
visit_by_order: dict[tuple, dict] = {}
for _, row in df_flow.iterrows():
if pd.isna(row["支付时间"]) or not row.get("手机号"):
continue
# 用流水构成中的 订单ID 关联助教
oid = row.get("订单ID", 0)
key = (row["手机号"], oid if oid else id(row))
ast_names = order_assistants.get(oid, [])
visit_by_order[key] = {
"手机号": row["手机号"],
"visit_date": row["支付时间"].date(),
"visit_time": row["支付时间"],
"items_sum": row["items_sum"],
"table_fee": row.get("开台台费小计", 0) or 0,
"goods_amount": row.get("商品总额", 0) or 0,
"assistant_amount": (row.get("助教在店费", 0) or 0)
+ (row.get("助教超休费", 0) or 0),
"duration_min": 0,
"assistant_names": ast_names,
}
visit_records = list(visit_by_order.values())
print(f"[Excel] 消费记录: {len(consume_monthly)} 条, 充值记录: {len(recharge_monthly)} 条, "
f"助教服务: {len(ast_all)} 条, 到店记录: {len(visit_records)}")
return {
"consume_monthly": consume_monthly,
"recharge_monthly": recharge_monthly,
"assistant_services": ast_all,
"visit_records": visit_records,
"phone_to_names": phone_to_names,
}
# ═══════════════════════════════════════════════════════════
# 第二部分:数据库数据提取
# ═══════════════════════════════════════════════════════════
def load_db_data() -> dict:
"""从数据库提取消费、充值、助教服务、余额数据。"""
print("[DB] 连接数据库...")
conn = psycopg2.connect(PG_DSN)
cur = conn.cursor()
# ── 1. 月度消费items_sum 口径)──
cur.execute("""
SELECT
dm.mobile AS phone,
DATE_TRUNC('month', sh.pay_time)::date AS month,
SUM(COALESCE(sh.table_charge_money,0) + COALESCE(sh.goods_money,0)
+ COALESCE(sh.assistant_pd_money,0) + COALESCE(sh.assistant_cx_money,0)) AS items_sum,
SUM(COALESCE(sh.table_charge_money,0)) AS table_fee,
SUM(COALESCE(sh.goods_money,0)) AS goods_amount,
SUM(COALESCE(sh.assistant_pd_money,0)) AS assistant_pd,
SUM(COALESCE(sh.assistant_cx_money,0)) AS assistant_cx
FROM dwd.dwd_settlement_head sh
JOIN dwd.dim_member dm ON sh.member_id = dm.member_id AND dm.scd2_is_current = 1
WHERE sh.settle_type IN (1, 3)
AND dm.mobile IS NOT NULL AND dm.mobile != ''
GROUP BY dm.mobile, DATE_TRUNC('month', sh.pay_time)
""")
rows = cur.fetchall()
consume_monthly = pd.DataFrame(rows, columns=[
"手机号", "month_date", "items_sum", "table_fee", "goods_amount",
"assistant_pd", "assistant_cx",
])
consume_monthly["月份"] = consume_monthly["month_date"].apply(
lambda d: d.strftime("%Y-%m"))
consume_monthly["assistant_total"] = (
consume_monthly["assistant_pd"] + consume_monthly["assistant_cx"]
)
# 转 float
for col in ["items_sum", "table_fee", "goods_amount", "assistant_pd",
"assistant_cx", "assistant_total"]:
consume_monthly[col] = consume_monthly[col].astype(float)
# ── 2. 到店记录(每笔结算单 = 一次到店)──
cur.execute("""
SELECT
dm.mobile AS phone,
sh.pay_time::date AS visit_date,
sh.pay_time AS visit_time,
COALESCE(sh.table_charge_money,0) + COALESCE(sh.goods_money,0)
+ COALESCE(sh.assistant_pd_money,0) + COALESCE(sh.assistant_cx_money,0) AS items_sum,
COALESCE(sh.table_charge_money,0) AS table_fee,
COALESCE(sh.goods_money,0) AS goods_amount,
COALESCE(sh.assistant_pd_money,0) + COALESCE(sh.assistant_cx_money,0) AS assistant_amount,
sh.order_settle_id
FROM dwd.dwd_settlement_head sh
JOIN dwd.dim_member dm ON sh.member_id = dm.member_id AND dm.scd2_is_current = 1
WHERE sh.settle_type IN (1, 3)
AND dm.mobile IS NOT NULL AND dm.mobile != ''
ORDER BY dm.mobile, sh.pay_time
""")
visit_rows = cur.fetchall()
# 获取每个结算单的助教名
cur.execute("""
SELECT
asl.order_settle_id,
asl.nickname
FROM dwd.dwd_assistant_service_log asl
LEFT JOIN dwd.dwd_assistant_service_log_ex ex
ON asl.assistant_service_id = ex.assistant_service_id
WHERE asl.is_delete = 0 AND (ex.is_trash IS NULL OR ex.is_trash = 0)
""")
settle_assistants: dict[int, list[str]] = defaultdict(list)
for sid, name in cur.fetchall():
if name:
settle_assistants[sid].append(name)
visit_records = []
for phone, vdate, vtime, isum, tfee, goods, ast_amt, sid in visit_rows:
visit_records.append({
"手机号": phone,
"visit_date": vdate,
"visit_time": vtime,
"items_sum": float(isum),
"table_fee": float(tfee),
"goods_amount": float(goods),
"assistant_amount": float(ast_amt),
"duration_min": 0,
"assistant_names": settle_assistants.get(sid, []),
})
# ── 3. 充值数据 ──
cur.execute("""
SELECT
dm.mobile AS phone,
DATE_TRUNC('month', ro.pay_time)::date AS month,
SUM(COALESCE(ro.pay_amount, 0)) AS recharge_amount
FROM dwd.dwd_recharge_order ro
JOIN dwd.dim_member dm ON ro.member_id = dm.member_id AND dm.scd2_is_current = 1
WHERE dm.mobile IS NOT NULL AND dm.mobile != ''
GROUP BY dm.mobile, DATE_TRUNC('month', ro.pay_time)
""")
recharge_rows = cur.fetchall()
recharge_monthly = pd.DataFrame(recharge_rows, columns=["手机号", "month_date", "recharge_amount"])
recharge_monthly["月份"] = recharge_monthly["month_date"].apply(lambda d: d.strftime("%Y-%m"))
recharge_monthly["recharge_amount"] = recharge_monthly["recharge_amount"].astype(float)
# ── 4. 助教服务(带类型区分)──
cur.execute("""
SELECT
dm.mobile AS phone,
DATE_TRUNC('month', asl.create_time)::date AS month,
asl.nickname AS assistant_name,
asl.order_assistant_type,
asl.income_seconds
FROM dwd.dwd_assistant_service_log asl
JOIN dwd.dim_member dm ON asl.tenant_member_id = dm.member_id AND dm.scd2_is_current = 1
LEFT JOIN dwd.dwd_assistant_service_log_ex ex
ON asl.assistant_service_id = ex.assistant_service_id
WHERE asl.is_delete = 0
AND (ex.is_trash IS NULL OR ex.is_trash = 0)
AND dm.mobile IS NOT NULL AND dm.mobile != ''
""")
ast_rows = cur.fetchall()
ast_data = []
for phone, mdate, aname, atype, secs in ast_rows:
hours = (secs or 0) / 3600.0
weight = 1.5 if atype == 2 else 1.0
ast_data.append({
"手机号": phone,
"月份": mdate.strftime("%Y-%m"),
"助教名": aname or "",
"时长小时": hours,
"加权时长": hours * weight,
})
assistant_services = pd.DataFrame(ast_data) if ast_data else pd.DataFrame(
columns=["手机号", "月份", "助教名", "时长小时", "加权时长"])
# ── 5. 客户姓名dim_member──
cur.execute("""
SELECT mobile, nickname FROM dwd.dim_member
WHERE scd2_is_current = 1 AND mobile IS NOT NULL AND mobile != ''
""")
phone_to_names: dict[str, set] = defaultdict(set)
for phone, name in cur.fetchall():
if name:
phone_to_names[phone].add(name)
# ── 6. 会员卡余额 ──
cur.execute("""
SELECT
member_mobile,
member_card_type_name,
member_card_grade_code_name,
balance,
principal_balance
FROM dwd.dim_member_card_account
WHERE scd2_is_current = 1
AND member_mobile IS NOT NULL AND member_mobile != ''
""")
card_info: dict[str, list[dict]] = defaultdict(list)
for phone, ctype, grade, bal, pbal in cur.fetchall():
card_info[phone].append({
"card_type": ctype or "",
"grade": grade or "",
"balance": float(bal or 0),
"principal": float(pbal or 0),
})
conn.close()
print(f"[DB] 消费记录: {len(consume_monthly)} 条, 充值记录: {len(recharge_monthly)} 条, "
f"助教服务: {len(assistant_services)} 条, 到店记录: {len(visit_records)}")
return {
"consume_monthly": consume_monthly[["手机号", "月份", "items_sum", "table_fee",
"goods_amount", "assistant_total"]],
"recharge_monthly": recharge_monthly[["手机号", "月份", "recharge_amount"]],
"assistant_services": assistant_services,
"visit_records": visit_records,
"phone_to_names": phone_to_names,
"card_info": card_info,
}
# ═══════════════════════════════════════════════════════════
# 第三部分:数据合并与筛选
# ═══════════════════════════════════════════════════════════
def merge_and_filter(excel_data: dict, db_data: dict) -> list[dict]:
"""合并两个数据源,筛选流失客户,计算所有指标。"""
print("[合并] 按手机号合并数据...")
# 合并月度消费
consume_all = pd.concat([
excel_data["consume_monthly"][["手机号", "月份", "items_sum", "table_fee",
"goods_amount", "assistant_total"]],
db_data["consume_monthly"],
], ignore_index=True)
consume_all = consume_all.groupby(["手机号", "月份"]).sum(numeric_only=True).reset_index()
# 合并充值
recharge_all = pd.concat([
excel_data["recharge_monthly"],
db_data["recharge_monthly"],
], ignore_index=True)
recharge_all = recharge_all.groupby(["手机号", "月份"]).sum(numeric_only=True).reset_index()
# 合并助教服务
ast_all = pd.concat([
excel_data["assistant_services"],
db_data["assistant_services"],
], ignore_index=True)
# 合并到店记录
visits_all = excel_data["visit_records"] + db_data["visit_records"]
# 合并姓名
phone_names: dict[str, set] = defaultdict(set)
for src in [excel_data["phone_to_names"], db_data["phone_to_names"]]:
for phone, names in src.items():
phone_names[phone].update(names)
# 会员卡信息(仅数据库有当前值)
card_info = db_data.get("card_info", {})
# ── 筛选:任意月消费 ≥ 3000 ──
max_monthly = consume_all.groupby("手机号")["items_sum"].max().reset_index()
max_monthly.columns = ["手机号", "max_monthly"]
qualified_phones = set(
max_monthly[max_monthly["max_monthly"] >= MIN_MONTHLY_CONSUME]["手机号"]
)
print(f"[筛选] 任意月消费≥{MIN_MONTHLY_CONSUME}: {len(qualified_phones)}")
# ── 筛选:最后到店 < LAST_VISIT_BEFORE ──
visits_by_phone: dict[str, list[dict]] = defaultdict(list)
for v in visits_all:
visits_by_phone[v["手机号"]].append(v)
last_visit: dict[str, date] = {}
for phone, vlist in visits_by_phone.items():
last_visit[phone] = max(v["visit_date"] for v in vlist)
churned_phones = set()
for phone in qualified_phones:
lv = last_visit.get(phone)
if lv and lv < LAST_VISIT_BEFORE:
churned_phones.add(phone)
print(f"[筛选] 最后到店<{LAST_VISIT_BEFORE}: {len(churned_phones)}")
# ── 构建客户记录 ──
customers = []
for phone in churned_phones:
# 月度消费序列
c_df = consume_all[consume_all["手机号"] == phone].set_index("月份")
monthly_consume = {}
for m in MONTH_LABELS:
monthly_consume[m] = float(c_df.loc[m, "items_sum"]) if m in c_df.index else 0
# 月度充值序列
r_df = recharge_all[recharge_all["手机号"] == phone].set_index("月份")
monthly_recharge = {}
for m in MONTH_LABELS:
monthly_recharge[m] = float(r_df.loc[m, "recharge_amount"]) if m in r_df.index else 0
# 消费构成
total_table = float(c_df["table_fee"].sum()) if "table_fee" in c_df.columns else 0
total_goods = float(c_df["goods_amount"].sum()) if "goods_amount" in c_df.columns else 0
total_ast = float(c_df["assistant_total"].sum()) if "assistant_total" in c_df.columns else 0
total_consume = sum(monthly_consume.values())
# 评分
max_m = max(monthly_consume.values()) if monthly_consume else 0
active_months = sum(1 for v in monthly_consume.values() if v > 0)
avg_m = total_consume / active_months if active_months > 0 else 0
score = max_m * 0.6 + avg_m * 0.4
# 到店统计
vlist = sorted(visits_by_phone.get(phone, []), key=lambda x: x["visit_date"])
total_visits = len(vlist)
last_v = last_visit[phone]
days_since = (REPORT_DATE - last_v).days
avg_ticket = total_consume / total_visits if total_visits > 0 else 0
# 月度到店次数
monthly_visits: dict[str, int] = defaultdict(int)
for v in vlist:
m = v["visit_date"].strftime("%Y-%m")
if m in MONTH_LABELS:
monthly_visits[m] += 1
# 到店时段偏好
weekday_count = 0
weekend_count = 0
day_count = 0
night_count = 0
for v in vlist:
vt = v["visit_time"]
if hasattr(vt, "weekday"):
wd = vt.weekday()
else:
wd = vt.date().weekday() if hasattr(vt, "date") else 0
if wd < 5:
weekday_count += 1
else:
weekend_count += 1
hour = vt.hour if hasattr(vt, "hour") else 12
if 6 <= hour < 18:
day_count += 1
else:
night_count += 1
# 最近 3 次到店
recent_3 = vlist[-3:] if len(vlist) >= 3 else vlist[:]
recent_3.reverse() # 最近的排前面
# 助教偏好(加权时长)
ast_df = ast_all[ast_all["手机号"] == phone].copy() if len(ast_all) > 0 else pd.DataFrame()
assistant_monthly: dict[str, dict[str, float]] = defaultdict(lambda: defaultdict(float))
assistant_totals: dict[str, float] = defaultdict(float)
if len(ast_df) > 0:
for _, row in ast_df.iterrows():
aname = row["助教名"]
month = row["月份"]
wt = float(row["加权时长"])
assistant_monthly[aname][month] += wt
assistant_totals[aname] += wt
# 助教按总时长排序
sorted_assistants = sorted(assistant_totals.keys(), key=lambda a: -assistant_totals[a])
# 会员卡
cards = card_info.get(phone, [])
total_balance = sum(c["balance"] for c in cards)
customers.append({
"phone": phone,
"names": " / ".join(sorted(phone_names.get(phone, {"未知"}))),
"score": score,
"max_monthly": max_m,
"avg_monthly": avg_m,
"total_consume": total_consume,
"total_visits": total_visits,
"last_visit": last_v,
"days_since": days_since,
"avg_ticket": avg_ticket,
"monthly_consume": monthly_consume,
"monthly_recharge": monthly_recharge,
"monthly_visits": {m: monthly_visits.get(m, 0) for m in MONTH_LABELS},
"total_recharge": sum(monthly_recharge.values()),
"total_table_fee": total_table,
"total_goods": total_goods,
"total_assistant": total_ast,
"weekday_count": weekday_count,
"weekend_count": weekend_count,
"day_count": day_count,
"night_count": night_count,
"recent_3_visits": recent_3,
"all_visits": vlist, # 全部到店记录(用于时间段统计)
"assistant_monthly": dict(assistant_monthly),
"assistant_totals": assistant_totals,
"sorted_assistants": sorted_assistants,
"cards": cards,
"total_balance": total_balance,
})
# 按评分降序
customers.sort(key=lambda c: -c["score"])
# 添加排名
for i, c in enumerate(customers):
c["rank"] = i + 1
print(f"[结果] 最终流失客户: {len(customers)}")
return customers
# ═══════════════════════════════════════════════════════════
# 第四部分HTML 报告生成v5 重新设计排版)
# ═══════════════════════════════════════════════════════════
def generate_html(customers: list[dict]) -> str:
"""生成完整的 HTML 报告。"""
def fm(v: float) -> str:
return "-" if v == 0 else f"¥{v:,.0f}"
def fmn(v: float) -> str:
"""纯数字格式(无¥前缀,用于非金额场景)"""
return "-" if v == 0 else f"{v:,.0f}"
def fm2(v: float) -> str:
return "-" if v == 0 else f"¥{v:,.2f}"
# 横坐标25/01 02 03 ... 12 26/01 02 03
short_labels = []
for m in MONTH_LABELS:
y, mo = m.split("-")
if mo == "01":
short_labels.append(f"{y[2:]}/{mo}")
else:
short_labels.append(mo)
short_labels_js = str(short_labels)
month_nums = [m.split("-")[1].lstrip("0") for m in MONTH_LABELS]
# ── 汇总表(去掉评分列)──
summary_rows = ""
for c in customers:
bal = fm(c["total_balance"]) if c["total_balance"] > 0 else "-"
summary_rows += (
f"<tr>"
f"<td>{c['rank']}</td>"
f"<td class='phone'>{c['phone']}</td>"
f"<td>{c['names']}</td>"
f"<td class='r'>{fm(c['max_monthly'])}</td>"
f"<td class='r'>{fm(c['avg_monthly'])}</td>"
f"<td class='r'>{fm(c['total_consume'])}</td>"
f"<td class='r'>{c['total_visits']}</td>"
f"<td>{c['last_visit'].strftime('%Y-%m-%d')}</td>"
f"<td class='r hl'>{c['days_since']}天</td>"
f"<td class='r'>{bal}</td>"
f"</tr>"
)
# ── 客户详情页 ──
detail_pages = ""
for c in customers:
consume_data = [round(c["monthly_consume"].get(m, 0), 0) for m in MONTH_LABELS]
visit_data = [c["monthly_visits"].get(m, 0) for m in MONTH_LABELS]
recharge_data = [round(c["monthly_recharge"].get(m, 0), 0) for m in MONTH_LABELS]
# 消费趋势
nz = [(m, v) for m, v in c["monthly_consume"].items() if v > 0]
if nz:
mx = max(nz, key=lambda x: x[1])
mn = min(nz, key=lambda x: x[1])
h1 = sum(v for m, v in nz if m < "2025-07")
h2 = sum(v for m, v in nz if m >= "2025-07")
trend = ("上升" if h2 > h1 * 1.2 else ("下降" if h2 < h1 * 0.8 else "平稳")) if h1 > 0 and h2 > 0 else ("仅上半年" if h1 > 0 else "下半年开始")
c_note = f"最高月消费 {mx[0]}{fm(mx[1])}元),最低 {mn[0]}{fm(mn[1])}元),整体{trend}"
else:
c_note = "无消费记录"
# 充值说明
nzr = [(m, v) for m, v in c["monthly_recharge"].items() if v > 0]
r_note = f"累计充值 {fm(c['total_recharge'])}元,当前卡余额 {fm(c['total_balance'])}" if nzr else f"无充值记录,当前卡余额 {fm(c['total_balance'])}"
# 时段偏好2小时时间段取前3
tv = c["weekday_count"] + c["weekend_count"]
if tv > 0:
wd_p = c["weekday_count"] / tv * 100
we_p = c["weekend_count"] / tv * 100
day_tag = f"工作日 {c['weekday_count']}次({wd_p:.0f}%) / 周末 {c['weekend_count']}次({we_p:.0f}%)"
# 按2小时时间段统计取前4
slot_counts: dict[str, int] = defaultdict(int)
vlist = c.get("all_visits", c["recent_3_visits"])
for v in vlist:
vt = v["visit_time"]
h = vt.hour if hasattr(vt, "hour") else 12
slot_start = (h // 2) * 2
slot_end = slot_start + 2
slot_key = f"{slot_start}-{slot_end}"
slot_counts[slot_key] += 1
top_slots = sorted(slot_counts.items(), key=lambda x: -x[1])[:4]
if top_slots:
# 两组一行
pairs = []
for i in range(0, len(top_slots), 2):
row_items = [f"{s}:{n}次({n/tv*100:.0f}%)" for s, n in top_slots[i:i+2]]
pairs.append(" | ".join(row_items))
time_tag = "<br>".join(pairs)
else:
time_tag = "数据不足"
else:
day_tag = time_tag = "数据不足"
# 消费构成
ct = c["total_table_fee"] + c["total_goods"] + c["total_assistant"]
pt = c["total_table_fee"] / ct * 100 if ct > 0 else 0
pg = c["total_goods"] / ct * 100 if ct > 0 else 0
pa = c["total_assistant"] / ct * 100 if ct > 0 else 0
# 会员卡
if c["cards"]:
ac = [cd for cd in c["cards"] if cd["balance"] > 0]
card_rows = "".join(
f"<tr><td>{cd['card_type']}</td><td class='r'>{fm2(cd['balance'])}元</td></tr>"
for cd in sorted(ac, key=lambda x: -x["balance"])
) if ac else "<tr><td colspan='2'>余额已清零</td></tr>"
else:
card_rows = "<tr><td colspan='2'>无会员卡</td></tr>"
# 最近3次到店两列双行左列=日期时间+金额,右列=构成+助教)
recent_html = ""
for v in c["recent_3_visits"]:
vt = v["visit_time"]
d = v["visit_date"].strftime("%Y-%m-%d")
t = vt.strftime("%H:%M") if hasattr(vt, "strftime") else ""
ast_names = "".join(v["assistant_names"][:3]) if v["assistant_names"] else ""
parts = []
if v["table_fee"] and v["table_fee"] > 0:
parts.append(f"台费{fm(v['table_fee'])}")
if v["goods_amount"] and v["goods_amount"] > 0:
parts.append(f"商品{fm(v['goods_amount'])}")
if v["assistant_amount"] and v["assistant_amount"] > 0:
parts.append(f"助教{fm(v['assistant_amount'])}")
detail = " · ".join(parts) if parts else "-"
recent_html += (
f"<tr>"
f"<td><div class='rc-date'>{d} {t}</div><div class='rc-amt'>总消费 {fm(v['items_sum'])}</div></td>"
f"<td><div>{detail}</div><div class='rc-ast'>助教:{ast_names}</div></td>"
f"</tr>"
)
if not recent_html:
recent_html = "<tr><td colspan='2'>无到店记录</td></tr>"
# 助教表格(含合计行)
ast_count = len(c["sorted_assistants"])
ast_table_rows = ast_count + 2 # 表头+数据行+合计行
ast_on_page1 = ast_table_rows <= 20 # 含合计行≤20则放第一页
def build_ast_table(assistants):
if not assistants:
return "<p class='empty'>无助教服务记录</p>"
html = "<table class='ast-tbl'><thead><tr><th>助教</th>"
for mn in month_nums:
html += f"<th>{mn}月</th>"
html += "<th>合计</th></tr></thead><tbody>"
month_totals = {m: 0.0 for m in MONTH_LABELS}
grand_total = 0.0
for aname in assistants:
html += f"<tr><td class='name-col'>{aname}</td>"
for m in MONTH_LABELS:
val = c["assistant_monthly"].get(aname, {}).get(m, 0)
month_totals[m] += val
html += f"<td class='r'>{val:.1f}</td>" if val > 0 else "<td>-</td>"
t = c['assistant_totals'][aname]
grand_total += t
html += f"<td class='r sum-col'>{t:.1f}</td></tr>"
# 合计行
html += "<tr class='total-row'><td class='name-col'>合计</td>"
for m in MONTH_LABELS:
v = month_totals[m]
html += f"<td class='r'>{v:.1f}</td>" if v > 0 else "<td>-</td>"
html += f"<td class='r sum-col'>{grand_total:.1f}</td></tr>"
html += "</tbody></table>"
return html
# 计算图表高度A4 可用约 1030px CSS
# 固定部分头部35 + 三栏120 + 消费标题24 + 充值标题24 + 助教标题24
# + card间距(5×8=40) + card内padding(5×16=80) + 页padding16 = ~363px
page_h = 1030
fixed_h = 363
if ast_on_page1:
ast_h = ast_table_rows * 15 # 每行约15px
else:
ast_h = 25 # 提示文字
chart_total = page_h - fixed_h - ast_h
consume_h = max(int(chart_total * 0.63), 130)
recharge_h = max(chart_total - consume_h, 80)
detail_pages += f"""
<div class="cust-page page-start">
<div class="sec-a">
<span class="badge">#{c['rank']}</span>
<span class="phone">{c['phone']}</span>
<span class="name">{c['names']}</span>
<span class="sep"></span>
<span class="kv"><b>最后到店</b> <em>{c['last_visit'].strftime('%Y-%m-%d')}{c['days_since']}天前)</em></span>
<span class="kv"><b>累计到店</b> {c['total_visits']}次</span>
<span class="kv"><b>客均消费</b> {fm(c['avg_ticket'])}</span>
<span class="kv"><b>累计消费</b> {fm(c['total_consume'])}</span>
</div>
<div class="row3">
<div class="card r3-left">
<div class="sec-hd">最近到店明细</div>
<table class="recent-tbl"><tbody>{recent_html}</tbody></table>
</div>
<div class="card r3-mid">
<div class="sec-hd">消费画像</div>
<div class="pie-row">
<div class="pie-box"><canvas id="pie-{c['rank']}"></canvas></div>
<div class="pie-info">
<div class="pi-row"><span class="dot" style="background:#3498db"></span>台费 {fm(c['total_table_fee'])}</div>
<div class="pi-row"><span class="dot" style="background:#2ecc71"></span>商品 {fm(c['total_goods'])}</div>
<div class="pi-row"><span class="dot" style="background:#e74c3c"></span>助教 {fm(c['total_assistant'])}</div>
</div>
</div>
<div class="pi-divider"></div>
<div class="pi-row muted-sm">按日:{day_tag}</div>
<div class="pi-row muted-sm">按时:{time_tag}</div>
</div>
<div class="card r3-right">
<div class="sec-hd">会员卡余额</div>
<table class="card-tbl"><tbody>{card_rows}</tbody></table>
</div>
</div>
<div class="card">
<div class="sec-hd">消费动态 <span class="sub"><span class="legend-line red"></span>消费额 <span class="legend-line blue"></span>到店次数</span> <span class="note-inline">{c_note}</span></div>
<div id="consume-{c['rank']}" style="width:100%;height:{consume_h}px"></div>
</div>
<div class="card">
<div class="sec-hd">充值动态 <span class="note-inline">{r_note}</span></div>
<div id="recharge-{c['rank']}" style="width:100%;height:{recharge_h}px"></div>
</div>
"""
if ast_on_page1:
detail_pages += f"""
<div class="card">
<div class="sec-hd">喜爱的助教 <span class="sub">加权等效时长小时超休×1.5</span></div>
{build_ast_table(c["sorted_assistants"])}
</div>
</div>
"""
else:
detail_pages += f"""
<div class="card">
<div class="sec-hd">喜爱的助教</div>
<p class="ast-overflow">服务过 {c['names']} 的助教有 {ast_count} 人,详情记录在下页。</p>
</div>
</div>
<div class="cust-page ast-page page-start">
<div class="sec-a">
<span class="badge">#{c['rank']}</span>
<span class="phone">{c['phone']}</span>
<span class="name">{c['names']}</span>
<span class="sep"></span>
<span class="kv"><b>喜爱的助教(续)</b> 共{ast_count}人</span>
</div>
<div class="card">
<div class="sec-hd">喜爱的助教 <span class="sub">加权等效时长小时超休×1.5</span></div>
{build_ast_table(c["sorted_assistants"])}
</div>
</div>
"""
# ── Chart.js 脚本 ──
chart_scripts = ""
for c in customers:
cd = [round(c["monthly_consume"].get(m, 0), 0) for m in MONTH_LABELS]
vd = [c["monthly_visits"].get(m, 0) for m in MONTH_LABELS]
rd = [round(c["monthly_recharge"].get(m, 0), 0) for m in MONTH_LABELS]
chart_scripts += f"""
try {{
var _vd{c['rank']} = {vd};
var ec{c['rank']} = echarts.init(document.getElementById('consume-{c['rank']}'));
ec{c['rank']}.setOption({{
grid: {{ left:45, right:55, top:30, bottom:25 }},
xAxis: {{ type:'category', data:{short_labels_js}, axisLabel:{{ fontSize:9 }},
boundaryGap:false }},
yAxis: [
{{ type:'value', name:'消费额(元)', nameTextStyle:{{ fontSize:8 }},
axisLabel:{{ fontSize:8 }}, splitLine:{{ lineStyle:{{ type:'dashed', color:'#eee' }} }} }},
{{ type:'value', name:'到店次数', nameTextStyle:{{ fontSize:8 }},
axisLabel:{{ fontSize:8 }}, splitLine:{{ show:false }} }}
],
series: [
{{ name:'消费', type:'line', data:{cd}, smooth:0.3, z:20, zlevel:1,
symbol:'circle', symbolSize:6,
itemStyle:{{ color:'#e74c3c' }},
lineStyle:{{ color:'#e74c3c', width:2 }},
areaStyle:{{ color:'rgba(231,76,60,0.06)' }},
label:{{ show:true, position:'top', distance:6,
backgroundColor:'rgba(255,255,255,0.9)',
borderRadius:3, padding:[2,4],
rich:{{
a:{{ color:'#c0392b', fontSize:9, fontWeight:'bold', lineHeight:14 }},
b:{{ color:'#2980b9', fontSize:8, lineHeight:12 }}
}},
formatter: function(p){{
var v = _vd{c['rank']}[p.dataIndex];
var lines = [];
if(p.value>0) lines.push('{{a|¥'+p.value.toLocaleString()+'}}');
if(v>0) lines.push('{{b|'+v+'}}');
return lines.join('\\n');
}}
}},
labelLine:{{ show:false }},
}},
{{ name:'到店', type:'line', data:{vd}, smooth:0.3, yAxisIndex:1, z:10,
symbol:'circle', symbolSize:5,
itemStyle:{{ color:'#3498db' }},
lineStyle:{{ color:'#3498db', width:1.5, type:'dashed' }},
label:{{ show:false }},
}}
],
labelLayout:{{ moveOverlap:'shiftY', hideOverlap:false }},
tooltip:{{ trigger:'axis' }},
}});
}} catch(e){{ console.warn('c{c['rank']}',e); }}
try {{ new Chart(document.getElementById('pie-{c['rank']}'), {{
type:'doughnut',
data:{{ labels:['台费','商品','助教'],
datasets:[{{ data:[{c['total_table_fee']:.0f},{c['total_goods']:.0f},{c['total_assistant']:.0f}],
backgroundColor:['#3498db','#2ecc71','#e74c3c'], borderWidth:1 }}]
}},
options:{{ responsive:true, maintainAspectRatio:true, animation:false, cutout:'30%',
plugins:{{ legend:{{ display:false }},
datalabels:{{
display:function(c){{ return c.parsed>0; }},
color:'#fff',
font:{{ size:11, weight:'bold' }},
textAlign:'center',
formatter:function(v,c){{
var t=c.dataset.data.reduce(function(a,b){{return a+b}},0);
var lbl=c.chart.data.labels[c.dataIndex];
return t>0? lbl+'\\n'+(v/t*100).toFixed(0)+'%' : '';
}}
}}
}}
}}, plugins:[ChartDataLabels]
}}); }} catch(e){{ console.warn('p{c['rank']}',e); }}
try {{
var er{c['rank']} = echarts.init(document.getElementById('recharge-{c['rank']}'));
er{c['rank']}.setOption({{
grid: {{ left:45, right:55, top:25, bottom:20 }},
xAxis: {{ type:'category', data:{short_labels_js}, axisLabel:{{ fontSize:8 }},
boundaryGap:false }},
yAxis: {{ type:'value', axisLabel:{{ fontSize:8 }},
splitLine:{{ lineStyle:{{ type:'dashed', color:'#eee' }} }} }},
series: [{{
name:'充值', type:'line', data:{rd}, smooth:0.3,
symbol:'circle', symbolSize:6,
itemStyle:{{ color:'#27ae60' }},
lineStyle:{{ color:'#27ae60', width:2 }},
areaStyle:{{ color:'rgba(39,174,96,0.06)' }},
label:{{ show:true, position:'top', color:'#1e8449',
fontSize:9, fontWeight:'bold',
backgroundColor:'rgba(255,255,255,0.85)',
borderRadius:3, padding:[2,4],
formatter: function(p){{ return p.value>0? '¥'+p.value.toLocaleString():''; }}
}},
labelLine:{{ show:false }},
}}],
labelLayout:{{ moveOverlap:'shiftY', hideOverlap:false }},
tooltip:{{ trigger:'axis' }},
}});
}} catch(e){{ console.warn('r{c['rank']}',e); }}
"""
html = f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>流失客户回访报告 — {REPORT_DATE.strftime('%Y-%m-%d')}</title>
<script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2"></script>
<style>
@page {{ size: A4 portrait; margin: 10mm 8mm; }}
@media print {{
body {{ background:#fff; -webkit-print-color-adjust:exact; print-color-adjust:exact; }}
.cover-page {{ page-break-after: always; }}
.page-start {{ page-break-before: always; }}
.sec-b, .sec-c, .sec-d, .sec-e {{ page-break-inside: avoid; }}
}}
*{{ margin:0; padding:0; box-sizing:border-box; }}
body {{ font-family:"Microsoft YaHei","PingFang SC",sans-serif; font-size:11px; color:#333; line-height:1.5; background:#e8e8e8; }}
.report-container {{ max-width:200mm; margin:0 auto; background:#fff; }}
/* ── 汇总首页 ── */
.cover-page {{ padding:20px; }}
.cover-page h1 {{ font-size:22px; text-align:center; color:#2c3e50; border-bottom:3px solid #c0392b; padding-bottom:10px; margin-bottom:6px; }}
.cover-meta {{ text-align:center; color:#888; font-size:11px; margin-bottom:14px; }}
.stbl {{ width:100%; border-collapse:collapse; font-size:10px; }}
.stbl th {{ background:#2c3e50; color:#fff; padding:5px 4px; text-align:center; font-weight:500; white-space:nowrap; position:sticky; top:0; }}
.stbl td {{ padding:4px 3px; border-bottom:1px solid #eee; text-align:center; white-space:nowrap; }}
.stbl .phone {{ font-size:9px; letter-spacing:-0.2px; }}
.stbl tr:nth-child(even) {{ background:#fafafa; }}
.r {{ text-align:right!important; font-variant-numeric:tabular-nums; }}
.hl {{ color:#c0392b; font-weight:bold; }}
/* ── 客户详情页 ── */
.cust-page {{
padding:8px 14px 6px;
}}
.ast-overflow {{ font-size:11px; color:#555; padding:6px 0; }}
.cust-page.ast-page {{ }}
/* 头部:单行身份条 */
.sec-a {{
display:flex; align-items:center; gap:8px;
background:#f5f7fa; border:1px solid #dce1e8; color:#2c3e50;
padding:6px 12px; border-radius:5px; margin-bottom:8px;
white-space:nowrap; overflow:hidden;
}}
.sec-a .badge {{ background:#c0392b; color:#fff; font-size:10px; font-weight:bold; width:22px; height:22px; border-radius:50%; display:flex; align-items:center; justify-content:center; flex-shrink:0; }}
.sec-a .phone {{ font-size:13px; font-weight:bold; color:#2c3e50; }}
.sec-a .name {{ font-size:11px; color:#7f8c8d; }}
.sec-a .sep {{ flex-grow:1; }}
.sec-a .kv {{ font-size:9px; color:#555; white-space:nowrap; }}
.sec-a .kv b {{ color:#999; font-weight:500; margin-right:2px; }}
.sec-a .kv em {{ font-style:normal; color:#c0392b; font-weight:bold; }}
/* 通用段落标题 */
.sec-hd {{ font-size:11px; font-weight:bold; color:#2c3e50; border-left:3px solid #c0392b; padding-left:6px; margin-bottom:6px; display:flex; align-items:baseline; gap:6px; }}
.sec-hd .sub {{ font-weight:normal; color:#aaa; font-size:8px; }}
.sec-hd .note-inline {{ font-weight:normal; color:#888; font-size:8px; margin-left:auto; white-space:nowrap; }}
.legend-line {{ display:inline-block; width:16px; height:0; border-top:2px solid; vertical-align:middle; margin:0 3px 0 6px; }}
.legend-line.red {{ border-color:#e74c3c; }}
.legend-line.blue {{ border-color:#3498db; border-top-style:dashed; }}
.note {{ font-size:9px; color:#666; background:#fef9e7; padding:2px 6px; border-radius:2px; margin-top:3px; line-height:1.4; }}
.empty {{ color:#bbb; font-size:9px; }}
/* 板块浅背景卡片 */
.card {{ background:#fafbfc; border:1px solid #e8ecf0; border-radius:5px; padding:8px 10px; margin-bottom:8px; overflow:hidden; }}
/* 第一行三栏 */
.row3 {{ display:flex; gap:8px; margin-bottom:8px; }}
.r3-left {{ flex:1 0 auto; min-width:0; }}
.r3-mid {{ flex:1 0 auto; min-width:0; }}
.r3-right {{ flex:1 0 auto; min-width:0; }}
.mt4 {{ margin-top:4px; }}
.muted-sm {{ color:#888; font-size:9px; }}
/* 图表容器高度由内联 style 控制 */
/* 消费画像 */
.pie-row {{ display:flex; gap:6px; align-items:center; }}
.pie-box {{ width:65px; height:65px; flex-shrink:0; }}
.pie-box canvas {{ width:65px!important; height:65px!important; }}
.pie-info {{ font-size:9px; color:#555; }}
.pi-row {{ margin-bottom:1px; }}
.pi-divider {{ border-top:1px dashed #ddd; margin:3px 0; }}
.dot {{ display:inline-block; width:6px; height:6px; border-radius:2px; margin-right:3px; vertical-align:middle; }}
/* 最近到店表 */
.recent-tbl {{ width:100%; border-collapse:collapse; font-size:9px; }}
.recent-tbl td {{ padding:2px 4px; border-bottom:1px solid #eee; vertical-align:top; }}
.recent-tbl td:first-child {{ width:70px; white-space:nowrap; }}
.rc-date {{ font-weight:600; color:#2c3e50; font-size:9px; }}
.rc-amt {{ color:#c0392b; font-weight:bold; font-size:9px; }}
.rc-ast {{ color:#888; font-size:8px; }}
/* 会员卡表 */
.card-tbl {{ width:100%; border-collapse:collapse; font-size:10px; }}
.card-tbl td {{ padding:4px 6px; border-bottom:1px solid #eee; }}
/* E区助教偏好 */
.sec-e {{ margin-bottom:8px; }}
.ast-tbl {{ width:100%; border-collapse:collapse; font-size:8px; table-layout:auto; }}
.ast-tbl th {{ background:#5a6c7d; color:#fff; padding:1px 2px; text-align:center; font-weight:500; font-size:7px; white-space:nowrap; }}
.ast-tbl td {{ padding:1px 2px; border-bottom:1px solid #eee; text-align:center; white-space:nowrap; line-height:1.2; }}
.ast-tbl .name-col {{ text-align:left; padding-left:4px; font-weight:600; color:#2c3e50; }}
.ast-tbl .sum-col {{ background:#edf2f7; font-weight:bold; }}
.ast-tbl .total-row {{ border-top:2px solid #5a6c7d; }}
.ast-tbl .total-row td {{ font-weight:bold; background:#f0f4f8; }}
</style>
</head>
<body>
<div class="report-container">
<div class="cover-page">
<h1>流失客户回访报告</h1>
<div class="cover-meta">
报告日期:{REPORT_DATE.strftime('%Y-%m-%d')}
筛选条件:任意月消费 ≥ {MIN_MONTHLY_CONSUME:,} 元 且 最后到店早于 {LAST_VISIT_BEFORE.strftime('%Y-%m-%d')}
{len(customers)}
</div>
<table class="stbl">
<thead><tr>
<th>#</th><th>手机号</th><th>姓名</th>
<th>最高月消费</th><th>月均消费</th><th>累计消费</th>
<th>到店</th><th>最后到店</th><th>距今</th><th>卡余额</th>
</tr></thead>
<tbody>{summary_rows}</tbody>
</table>
</div>
{detail_pages}
</div>
<script>
Chart.defaults.font.family='"Microsoft YaHei","PingFang SC",sans-serif';
Chart.defaults.font.size=9;
{chart_scripts}
// 等 flex 布局完成后,让所有 ECharts 实例重新适配容器大小
setTimeout(function() {{
if(typeof echarts !== 'undefined') {{
var containers = document.querySelectorAll('[id^="consume-"], [id^="recharge-"]');
containers.forEach(function(el) {{
var inst = echarts.getInstanceByDom(el);
if(inst) inst.resize();
}});
}}
}}, 500);
</script>
</body>
</html>"""
return html
# ═══════════════════════════════════════════════════════════
# 主入口
# ═══════════════════════════════════════════════════════════
def main():
print("=" * 60)
print("流失客户回访报告生成")
print("=" * 60)
excel_data = load_excel_data()
db_data = load_db_data()
customers = merge_and_filter(excel_data, db_data)
if not customers:
print("[警告] 无符合条件的流失客户!")
return
html = generate_html(customers)
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
OUTPUT_PATH.write_text(html, encoding="utf-8")
print(f"\n[完成] 报告已生成: {OUTPUT_PATH.resolve()}")
print(f"{len(customers)} 位流失客户")
if __name__ == "__main__":
main()