#!/usr/bin/env python3
"""
周报收入趋势图一键自动化脚本
=================================
功能：odpscmd 执行 SQL → 解析结果 → 生成双 panel 图表（左堆积+右YoY）

使用方式：
  1. 从 ODPS 直接取数（默认）：
     python3 weekly_revenue_chart.py [-o ~/Desktop/周报收入趋势图.png]

  2. 从本地 Excel 取数（备用）：
     python3 weekly_revenue_chart.py --file /path/to/data.xlsx [-o output.png]

  3. 从本地 CSV/TSV 取数（备用）：
     python3 weekly_revenue_chart.py --csv /path/to/data.csv [-o output.png]

依赖：
    - odpscmd（已配置于 ~/.odpscmd/）
    - Python 3.8+ with matplotlib, numpy, openpyxl(Excel模式)

数据源：kbreport.dws_gd_income_bybiz_ds（低频，ds >= 20240401）
图表口径：
    - Python 端过滤掉 sale_srvline/team_type 为 'all'/'-' 的汇总行
    - 按日期聚合所有行业（医疗+教培）、所有渠道（直营+渠道+电销）
    - 4项堆叠：新签年费、续签年费、广告现金收入、品牌广告收入
    - YoY = 日均同比 = (本期日均 / 去年同期日均) - 1
"""

import argparse
import csv
import os
import subprocess
import sys
import tempfile
from collections import defaultdict
from datetime import datetime

import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
import numpy as np


# ============================================================
# 配置区
# ============================================================
ODPSCMD = os.path.expanduser('~/.odpscmd/bin/odpscmd')

# 金额单位换算：ODPS 字段为元，图表展示为万元
ODPS_UNIT_DIVISOR = 10000.0

# SQL：取图表所需字段，Python 端做聚合与过滤
SQL_QUERY_RAW = """SELECT
    ds,
    sale_srvline,
    team_type,
    total_income_amt_1d,
    new_sign_income_amt_1d,
    renew_sign_income_amt_1d,
    ad_cash_income_amt_1d,
    fb_income_amt_1d
FROM kbreport.dws_gd_income_bybiz_ds
WHERE ds >= '20240401'
  AND frequency = '低频'
  AND cate_name = 'all'
  AND key_cate_name = 'all'
  AND ka_level = 'all'
;
"""

# 堆积图配色（底→顶：新签→品牌→效果→续签）
COLORS = {
    '新签年费': '#3FA85C',
    '品牌广告收入': '#F4C842',
    '广告现金收入': '#2F6FBF',
    '续签年费': '#E89A3C',
}
YOY_COLORS = {
    '总收入': '#222222',
    '新签年费': '#3FA85C',
    '续签年费': '#E89A3C',
    '广告现金收入': '#2F6FBF',
}
YOY_MARKERS = {'总收入': 'o', '新签年费': 's', '续签年费': '^', '广告现金收入': 'D'}

# 团队配色（图2用）
TEAMS = ['直营', '渠道', '电销']
TEAM_COLORS = {
    '直营': '#2F6FBF',  # 蓝
    '渠道': '#3FA85C',  # 绿
    '电销': '#E89A3C',  # 橙
}
TEAM_MARKERS = {'直营': 'D', '渠道': 's', '电销': '^'}

OVERLAP_THRESH = 12  # YoY 标注重叠阈值（百分点）

N_QUARTERS = 4
N_MONTHS = 4
N_WEEKS = 4


# ============================================================
# 工具函数
# ============================================================
def num(v):
    """安全转 float，'-' 或无效值返回 0.0"""
    try:
        return float(v)
    except (TypeError, ValueError):
        return 0.0


def date_to_fy_quarter(d_int):
    y, m = d_int // 10000, (d_int % 10000) // 100
    if 4 <= m <= 6:    return f'FY{y - 2000 + 1}-Q1'
    elif 7 <= m <= 9:  return f'FY{y - 2000 + 1}-Q2'
    elif 10 <= m <= 12: return f'FY{y - 2000 + 1}-Q3'
    else:               return f'FY{y - 2000}-Q4'


def date_to_month(d_int):
    return (d_int // 10000) * 100 + (d_int % 10000) // 100


def date_to_iso_week(d_int):
    y, m, day = d_int // 10000, (d_int % 10000) // 100, d_int % 100
    return datetime(y, m, day).isocalendar()[:2]


def get_prev_fy_quarter(q):
    fy = int(q[2:4])
    return f'FY{fy - 1}-{q.split("-")[1]}'


def get_prev_month(m):
    return m - 100


def get_prev_iso_week(iw):
    return (iw[0] - 1, iw[1])


# ============================================================
# 1. 数据获取（三种模式）
# ============================================================
def fetch_data_from_odps():
    """通过 odpscmd -f 执行 SQL 文件（避免中文编码问题）并返回解析后的行列表"""
    print('[1/4] 执行 ODPS SQL 拉取数据...')

    # 写入临时 SQL 文件（-f 模式，中文安全）
    sql_file = tempfile.NamedTemporaryFile(
        mode='w', suffix='.sql', delete=False, encoding='utf-8'
    )
    sql_file.write(SQL_QUERY_RAW)
    sql_file.close()

    try:
        # 先 source 环境变量（确保 odpscmd 和 JAVA_HOME 在 PATH 中）
        # macOS 子进程不会继承父 shell 的 JAVA_HOME，必须显式 export
        shell_cmd = (
            f'source ~/.odpscmd_env 2>/dev/null; '
            f'source ~/.zshrc 2>/dev/null; '
            f'source ~/.bash_profile 2>/dev/null; '
            f'if [ -z "$JAVA_HOME" ]; then '
            f'  if command -v /usr/libexec/java_home &>/dev/null; then '
            f'    export JAVA_HOME=$(/usr/libexec/java_home 2>/dev/null); '
            f'  fi; '
            f'  if [ -z "$JAVA_HOME" ] && [ -d "/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home" ]; then '
            f'    export JAVA_HOME="/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home"; '
            f'  fi; '
            f'fi; '
            f'export PATH="$JAVA_HOME/bin:$PATH"; '
            f'{ODPSCMD} -f {sql_file.name}'
        )
        print(f'      SQL 文件: {sql_file.name}')
        result = subprocess.run(
            ['bash', '-c', shell_cmd],
            capture_output=True, text=True, timeout=600
        )

        if result.returncode != 0:
            stderr = result.stderr.strip()
            print(f'ERROR: odpscmd 执行失败 (exit={result.returncode}):')
            print(stderr[:2000])
            sys.exit(1)

        rows = _parse_odpscmd_output(result.stdout)
        # 单位换算：元 → 万元（仅 ODPS 数据需要）
        for r in rows:
            for i in range(3, 8):
                try:
                    r[i] = float(r[i]) / ODPS_UNIT_DIVISOR
                except (ValueError, TypeError):
                    r[i] = 0.0
        print(f'      获取 {len(rows)} 行数据（金额已转万元）')
        return rows

    finally:
        os.unlink(sql_file.name)


def _parse_odpscmd_output(output):
    """解析 odpscmd 的 pipe 分隔表格输出
    格式示例:
    +----------+----------------+...
    | ds       | sale_srvline   |...
    +----------+----------------+...
    | 20240401 | 医疗           |...
    +----------+----------------+...
    """
    lines = output.strip().split('\n')
    rows = []
    header_found = False

    for line in lines:
        line = line.strip()
        # 跳过空行、边框行、状态信息行
        if not line:
            continue
        if line.startswith('+'):
            continue
        if not line.startswith('|'):
            # 可能是 OK / Warning / Job Queueing 等状态行，跳过
            continue

        # pipe 分隔的数据行: | col1 | col2 | ... |
        parts = [p.strip() for p in line.split('|')]
        # split('|') 会在首尾产生空字符串，去掉
        parts = [p for p in parts if p != '' or line.count('|') <= 2]
        # 更简洁：直接取 [1:-1]
        parts = line.split('|')[1:-1]
        parts = [p.strip() for p in parts]

        if not header_found:
            # 第一个 | 行是表头，跳过
            header_found = True
            print(f'      表头: {parts[:5]}...')
            continue

        if len(parts) >= 8:
            rows.append(parts[:8])

    return rows


def fetch_data_from_excel(xlsx_path):
    """从 Excel 文件加载，按表头自动识别列位置（兼容多版本结构）"""
    import openpyxl
    print(f'[1/4] 从 Excel 加载: {xlsx_path}')
    wb = openpyxl.load_workbook(xlsx_path, data_only=True)
    # 优先使用收入数据 sheet（兼容多种命名）
    data_sheet_names = ['收入数据', '收入by日', '收入']
    ws = None
    for sn in data_sheet_names:
        if sn in wb.sheetnames:
            ws = wb[sn]
            break
    if ws is None:
        # fallback: 取第一个非'目标&达成'的 sheet
        for sn in wb.sheetnames:
            if '目标' not in sn:
                ws = wb[sn]
                break
    if ws is None:
        ws = wb.active
    rows_raw = list(ws.iter_rows(values_only=True))
    wb.close()

    if not rows_raw:
        return []

    # 表头映射：列名 → 索引
    header = [str(c).strip() if c else '' for c in rows_raw[0]]

    def find_col(candidates):
        for name in candidates:
            if name in header:
                return header.index(name)
        return None

    idx_ds = find_col(['日', 'ds', '日期'])
    idx_srv = find_col(['行业', 'sale_srvline'])
    idx_team = find_col(['直渠', 'team_type', '渠道'])
    idx_total = find_col(['总收入', 'total_income_amt_1d'])
    idx_xq = find_col(['新签年费', 'new_sign_income_amt_1d'])
    idx_xx = find_col(['续签年费', 'renew_sign_income_amt_1d'])
    idx_gg = find_col(['广告现金收入', '广告现金', 'ad_cash_income_amt_1d'])
    idx_pp = find_col(['品牌广告收入', '品牌广告', 'fb_income_amt_1d'])

    if idx_ds is None or idx_srv is None or idx_team is None or idx_total is None:
        # 兜底：按 ODPS 8 列布局（ds/srvline/team/total/xq/xx/gg/pp）
        idx_ds, idx_srv, idx_team = 0, 1, 2
        idx_total, idx_xq, idx_xx, idx_gg, idx_pp = 3, 4, 5, 6, 7
        print(f'      [警告] 未识别表头，按默认 8 列布局解析')
    else:
        print(f'      表头识别: 日={idx_ds} 行业={idx_srv} 直渠={idx_team} '
              f'总收入={idx_total} 新签={idx_xq} 续签={idx_xx} 广告现金={idx_gg} 品牌={idx_pp}')

    data_rows = []
    for row in rows_raw[1:]:
        if not row or row[idx_ds] is None:
            continue
        ds_val = row[idx_ds]
        ds = str(int(ds_val)) if isinstance(ds_val, (int, float)) else str(ds_val).strip()
        srvline = str(row[idx_srv]).strip() if row[idx_srv] else ''
        team = str(row[idx_team]).strip() if row[idx_team] else ''
        total_r = num(row[idx_total]) if idx_total is not None else 0.0
        xq = num(row[idx_xq]) if idx_xq is not None else 0.0
        xx = num(row[idx_xx]) if idx_xx is not None else 0.0
        gg = num(row[idx_gg]) if idx_gg is not None else 0.0
        pp = num(row[idx_pp]) if idx_pp is not None else 0.0
        data_rows.append([ds, srvline, team, total_r, xq, xx, gg, pp])

    print(f'      获取 {len(data_rows)} 行数据')
    return data_rows


def fetch_data_from_csv(csv_path):
    """从本地 CSV/TSV 文件加载（备用模式）"""
    print(f'[1/4] 从 CSV 加载: {csv_path}')
    rows = []
    with open(csv_path, 'r', encoding='utf-8') as f:
        sample = f.read(4096)
        f.seek(0)
        try:
            dialect = csv.Sniffer().sniff(sample, delimiters=',\t|')
        except csv.Error:
            dialect = csv.excel  # fallback to comma
        reader = csv.reader(f, dialect)
        header = next(reader)
        for row in reader:
            if len(row) >= 8:
                rows.append(row[:8])
    print(f'      获取 {len(rows)} 行数据')
    return rows


def read_target_sheet(xlsx_path):
    """读取 '目标&达成' sheet，自动检测是否含月份列，取最新月份数据。
    返回 (lookup, month_label) 或 (None, None)。
    lookup[(period, industry, team)] = row（已归一化为旧列布局，月份列剥离）。"""
    import openpyxl
    try:
        wb = openpyxl.load_workbook(xlsx_path, data_only=True)
        if '目标&达成' not in wb.sheetnames:
            wb.close()
            return None, None
        ws = wb['目标&达成']
        rows = list(ws.iter_rows(values_only=True))
        wb.close()
    except Exception as e:
        print(f'[警告] 读取目标&达成 sheet 失败: {e}')
        return None, None

    if len(rows) < 3:
        return None, None

    # 检测是否含月份列：第一行第0列为'月份' 或 第3行第0列为 int (如 202605)
    has_month_col = False
    header_r1 = rows[0] if rows[0] else []
    if len(header_r1) > 0 and str(header_r1[0]).strip() == '月份':
        has_month_col = True
    elif len(rows) > 2 and rows[2] and isinstance(rows[2][0], (int, float)) and rows[2][0] > 200000:
        has_month_col = True

    # 收集所有数据行及其月份
    data_rows = []
    for r in rows[2:]:  # 跳过两行表头
        if not r or len(r) < 4:
            continue
        if has_month_col:
            month_val = r[0]
            if not isinstance(month_val, (int, float)):
                continue
            month_int = int(month_val)
            rest = r[1:]  # 剥离月份列，后续列索引与旧版一致
        else:
            month_int = 0  # 无月份列时全取
            rest = r
        if len(rest) < 4:
            continue
        period = rest[0] if isinstance(rest[0], str) else ''
        ind = rest[1] if isinstance(rest[1], str) else ''
        team = rest[2] if isinstance(rest[2], str) else ''
        if period and ind and team:
            data_rows.append((month_int, period, ind, team, rest))

    if not data_rows:
        return None, None

    # 取最新月份
    latest_month = max(r[0] for r in data_rows)
    month_label = f'{latest_month % 100}月' if latest_month > 0 else ''

    # 构建 lookup（仅最新月）
    lookup = {}
    for month_int, period, ind, team, rest in data_rows:
        if month_int != latest_month:
            continue
        key = (period, ind, team)
        if key not in lookup:
            lookup[key] = rest
    return lookup, month_label


# ============================================================
# 类目数据加载与聚合（Group 6 用）
# ============================================================
CATEGORY_METRICS = ['总收入', '新签年费', '续签年费', '广告现金收入', '品牌广告收入', '佣金收入']


def fetch_category_data_from_excel(xlsx_path):
    """从'类目收入数据.xlsx'加载明细：[ds, ind, team, l1, l2, total, xq, xx, gg, pp, cps]"""
    import openpyxl
    print(f'[类目] 从 Excel 加载: {xlsx_path}')
    wb = openpyxl.load_workbook(xlsx_path, data_only=True)
    sheet_names = ['类目数据', 'Sheet1']
    ws = None
    for sn in sheet_names:
        if sn in wb.sheetnames:
            ws = wb[sn]
            break
    if ws is None:
        ws = wb.active
    rows_raw = list(ws.iter_rows(values_only=True))
    wb.close()
    if not rows_raw:
        return []
    header = [str(c).strip() if c else '' for c in rows_raw[0]]

    def find_col(candidates):
        for name in candidates:
            if name in header:
                return header.index(name)
        return None

    idx_ds = find_col(['日', 'ds', '日期'])
    idx_ind = find_col(['行业', 'sale_srvline'])
    idx_team = find_col(['直渠', 'team_type', '渠道'])
    idx_l1 = find_col(['一级类目', 'cate_name'])
    idx_l2 = find_col(['二级类目', 'key_cate_name', '类目'])
    idx_total = find_col(['总收入', 'total_income_amt_1d'])
    idx_xq = find_col(['新签年费', 'new_sign_income_amt_1d'])
    idx_xx = find_col(['续签年费', 'renew_sign_income_amt_1d'])
    idx_gg = find_col(['广告现金收入', '广告现金', 'ad_cash_income_amt_1d'])
    idx_pp = find_col(['品牌广告收入', '品牌广告', 'fb_income_amt_1d'])
    idx_cps = find_col(['佣金收入', 'cps_income_amt_1d', '佣金'])

    if any(x is None for x in [idx_ds, idx_ind, idx_team, idx_l1, idx_l2]):
        print('      [警告] 类目数据表头缺失关键列，跳过')
        return []

    out = []
    for row in rows_raw[1:]:
        if not row or row[idx_ds] is None:
            continue
        ds_val = row[idx_ds]
        try:
            ds = int(ds_val) if isinstance(ds_val, (int, float)) else int(str(ds_val).strip())
        except (ValueError, TypeError):
            continue
        ind = str(row[idx_ind]).strip() if row[idx_ind] else ''
        team = str(row[idx_team]).strip() if row[idx_team] else ''
        l1 = str(row[idx_l1]).strip() if row[idx_l1] else ''
        l2 = str(row[idx_l2]).strip() if row[idx_l2] else ''
        if not (ind and team and l1):
            continue
        if not l2:
            l2 = l1  # 二级缺失时回填为一级
        total_v = num(row[idx_total]) if idx_total is not None else 0.0
        xq_v = num(row[idx_xq]) if idx_xq is not None else 0.0
        xx_v = num(row[idx_xx]) if idx_xx is not None else 0.0
        gg_v = num(row[idx_gg]) if idx_gg is not None else 0.0
        pp_v = num(row[idx_pp]) if idx_pp is not None else 0.0
        cps_v = num(row[idx_cps]) if idx_cps is not None else 0.0
        out.append([ds, ind, team, l1, l2, total_v, xq_v, xx_v, gg_v, pp_v, cps_v])
    print(f'      获取 {len(out)} 行类目明细')
    return out


def aggregate_category_data(cat_rows, all_qs, all_ms, all_ws_complete, cutoff, q_days, m_days, w_days):
    """按 (ind, team, l1, l2) 维度预聚合 Q/M/W 当前期与匹配去年期的 6 指标和值。

    返回:
        records: [{ind, team, l1, l2, qC, qP, mC, mP, wC, wP}]
        每个 *C/*P 是 flat 数组: 6 metrics × num_periods（行优先：period_i 的 6 指标，紧接 period_i+1）
        全局 day-count 按 period 序：q_nd_cur/prev, m_nd_cur/prev, w_nd_cur/prev
    """
    print('[类目] 聚合数据...')

    NMETRIC = 6  # total, xq, xx, gg, pp, cps

    # 按 (ind,team,l1,l2) 收集 day → [6 metrics]
    detail = {}
    all_days_set = set()
    for r in cat_rows:
        d_int, ind, team, l1, l2 = r[0], r[1], r[2], r[3], r[4]
        if ind in ('all', '-', '') or team in ('all', '-', ''):
            continue
        key = (ind, team, l1, l2)
        if key not in detail:
            detail[key] = {}
        ds_dict = detail[key]
        if d_int not in ds_dict:
            ds_dict[d_int] = [0.0] * NMETRIC
        arr = ds_dict[d_int]
        for i in range(NMETRIC):
            arr[i] += r[5 + i]
        all_days_set.add(d_int)

    # 期 cur/prev 天集合（全局）：与现有 compute_yoy 口径一致
    def q_days_of(q):
        return sorted(q_days.get(q, set()))
    def m_days_of(mi):
        return sorted(m_days.get(mi, set()))
    def w_days_of(iw):
        return sorted(w_days.get(iw, set()))

    # 季度: cur restricted by cutoff; prev = matched prev-year days that exist in any record
    q_cur_days = []
    q_prev_days = []
    for q in all_qs:
        cd = [d for d in q_days_of(q) if (cutoff is None or d <= cutoff)]
        pd = [d - 10000 for d in cd if (d - 10000) in all_days_set]
        q_cur_days.append(cd)
        q_prev_days.append(pd)
    # 月度
    m_cur_days = []
    m_prev_days = []
    for mi in all_ms:
        cd = [d for d in m_days_of(mi) if (cutoff is None or d <= cutoff)]
        pd = [d - 10000 for d in cd if (d - 10000) in all_days_set]
        m_cur_days.append(cd)
        m_prev_days.append(pd)
    # 周度
    w_cur_days = []
    w_prev_days = []
    for iw in all_ws_complete:
        cd = w_days_of(iw)
        prev_iw = (iw[0] - 1, iw[1])
        pd = w_days_of(prev_iw)
        w_cur_days.append(cd)
        w_prev_days.append(pd)

    # 全局期天数
    q_nd_cur = [len(x) for x in q_cur_days]
    q_nd_prev = [len(x) for x in q_prev_days]
    m_nd_cur = [len(x) for x in m_cur_days]
    m_nd_prev = [len(x) for x in m_prev_days]
    w_nd_cur = [len(x) for x in w_cur_days]
    w_nd_prev = [len(x) for x in w_prev_days]

    def sum_period(ds_dict, day_list):
        """按 day_list 求和，返回 6 指标列表"""
        s = [0.0] * NMETRIC
        for d in day_list:
            arr = ds_dict.get(d)
            if arr:
                for i in range(NMETRIC):
                    s[i] += arr[i]
        return s

    records = []
    for (ind, team, l1, l2), ds_dict in detail.items():
        qC, qP, mC, mP, wC, wP = [], [], [], [], [], []
        for cd in q_cur_days:
            qC.extend(sum_period(ds_dict, cd))
        for pd in q_prev_days:
            qP.extend(sum_period(ds_dict, pd))
        for cd in m_cur_days:
            mC.extend(sum_period(ds_dict, cd))
        for pd in m_prev_days:
            mP.extend(sum_period(ds_dict, pd))
        for cd in w_cur_days:
            wC.extend(sum_period(ds_dict, cd))
        for pd in w_prev_days:
            wP.extend(sum_period(ds_dict, pd))
        records.append({
            'ind': ind, 'team': team, 'l1': l1, 'l2': l2,
            'qC': qC, 'qP': qP,
            'mC': mC, 'mP': mP,
            'wC': wC, 'wP': wP,
        })

    print(f'      记录数: {len(records)} (ind×team×l1×l2 组合)')
    return {
        'records': records,
        'qNdCur': q_nd_cur, 'qNdPrev': q_nd_prev,
        'mNdCur': m_nd_cur, 'mNdPrev': m_nd_prev,
        'wNdCur': w_nd_cur, 'wNdPrev': w_nd_prev,
        'nMetric': NMETRIC,
    }


# 列索引（0-based）：6 个收入指标
TARGET_METRIC_COLS = [
    # (label, prev_col, target_col, actual_yoy_col)
    ('总收入',   5,  6,  4),
    ('新签年费', 9,  10, 8),
    ('续签年费', 13, 14, 12),
    ('效果广告', 17, 18, 16),
    ('品牌广告', 21, 22, 20),
    ('佣金收入', 25, 26, 24),
]


def _num(v):
    try:
        return float(v)
    except Exception:
        return None


def build_metric_table(target_lookup, industry, team):
    """6-行收入类型表：返回 list[dict(label, yoy_target, yoy_actual)] 或 None。"""
    if not target_lookup:
        return None
    monthly = target_lookup.get(('整月', industry, team))
    progress = target_lookup.get(('时间进度', industry, team))
    if not monthly or not progress:
        return None
    out = []
    for label, prev_c, tgt_c, act_c in TARGET_METRIC_COLS:
        prev = _num(monthly[prev_c]) if prev_c < len(monthly) else None
        tgt = _num(monthly[tgt_c]) if tgt_c < len(monthly) else None
        act = _num(progress[act_c]) if act_c < len(progress) else None
        yt = (tgt / prev - 1.0) * 100.0 if (prev and tgt is not None) else None
        ya = act * 100.0 if act is not None else None
        out.append({'label': label, 'yoy_target': yt, 'yoy_actual': ya})
    return out


def build_team_table_for_metric(target_lookup, metric_label, industry='整体'):
    """3-行团队表 for 给定指标 + 行业：返回 list 或 None。
    industry='整体'走 'all'；'医疗'/'教培'走对应字符串。
    """
    if not target_lookup:
        return None
    metric_cols = {label: (prev_c, tgt_c, act_c)
                   for label, prev_c, tgt_c, act_c in TARGET_METRIC_COLS}
    cols = metric_cols.get(metric_label)
    if not cols:
        return None
    prev_c, tgt_c, act_c = cols
    ind_key = 'all' if industry == '整体' else industry
    teams = [('直营', '直营'), ('渠道', '渠道'), ('电销', '电销')]
    out = []
    for tlbl, tkey in teams:
        m = target_lookup.get(('整月', ind_key, tkey))
        p = target_lookup.get(('时间进度', ind_key, tkey))
        if not m or not p:
            out.append({'label': tlbl, 'yoy_target': None, 'yoy_actual': None})
            continue
        prev = _num(m[prev_c]) if prev_c < len(m) else None
        tgt = _num(m[tgt_c]) if tgt_c < len(m) else None
        act = _num(p[act_c]) if act_c < len(p) else None
        yt = (tgt / prev - 1.0) * 100.0 if (prev and tgt is not None) else None
        ya = act * 100.0 if act is not None else None
        out.append({'label': tlbl, 'yoy_target': yt, 'yoy_actual': ya})
    return out


# 子弹图数据列索引（raw row 含月份列时的绝对值列位）
# raw row: [月份, 周期, 行业, 团队, 总收入, yoy_diff, 年同比原始, 总收入目标, 新签, ...]
# actual_col = raw row中实际值列, target_col = raw row中月目标列
BULLET_METRIC_COLS = [
    # (label, actual_col_in_raw_row, target_col_in_raw_row)
    ('总收入',   4,  7),
    ('新签年费', 8,  11),
    ('续签年费', 12, 15),
    ('效果广告', 16, 19),
    ('品牌广告', 20, 23),
    ('佣金收入', 24, 27),
]


def build_bullet_data(target_lookup, xlsx_path, cutoff=None):
    """从目标&达成 sheet 直接读取绝对值，构建子弹图数据。
    返回 dict: {
      'raw': { 'ind|team': [[actual, monthly], ...], ... },
      'days': int (当月已过天数),
      'totalDays': int (当月总天数),
      'monthLabel': str
    } 或 None。
    cutoff: 数据 cutoff 日期（int YYYYMMDD），优先使用它计算 days_passed；否则用今天。
    """
    import openpyxl, calendar
    from datetime import date as _date
    try:
        wb = openpyxl.load_workbook(xlsx_path, data_only=True)
        if '目标&达成' not in wb.sheetnames:
            wb.close()
            return None
        ws = wb['目标&达成']
        rows = list(ws.iter_rows(values_only=True))
        wb.close()
    except Exception:
        return None

    if len(rows) < 3:
        return None

    # 收集数据行
    data_rows = []
    for r in rows[2:]:
        if not r or len(r) < 28:
            continue
        month_val = r[0]
        if not isinstance(month_val, (int, float)):
            continue
        month_int = int(month_val)
        period = r[1] if isinstance(r[1], str) else ''
        ind = r[2] if isinstance(r[2], str) else ''
        team = r[3] if isinstance(r[3], str) else ''
        if period and ind and team:
            data_rows.append((month_int, period, ind, team, r))

    if not data_rows:
        return None

    latest_month = max(r[0] for r in data_rows)
    year = latest_month // 100
    month = latest_month % 100
    total_days = calendar.monthrange(year, month)[1]

    # 计算已过天数：优先用数据 cutoff，其次用今天
    days_passed = None
    if cutoff is not None:
        try:
            cy = cutoff // 10000
            cm = (cutoff // 100) % 100
            cd = cutoff % 100
            if cy == year and cm == month:
                days_passed = cd
        except Exception:
            days_passed = None
    if days_passed is None:
        today = _date.today()
        if today.year == year and today.month == month:
            days_passed = today.day
        else:
            # 如果数据月已过完，按整月算
            days_passed = total_days

    # 构建 RAW
    raw = {}
    for month_int, period, ind, team, r in data_rows:
        if month_int != latest_month or period != '整月':
            continue
        key = f'{ind}|{team}'
        vals = []
        for label, act_col, tgt_col in BULLET_METRIC_COLS:
            actual = _num(r[act_col]) if act_col < len(r) else 0
            monthly = _num(r[tgt_col]) if tgt_col < len(r) else 0
            vals.append([actual or 0, monthly or 0])
        raw[key] = vals

    if not raw:
        return None

    return {
        'raw': raw,
        'days': days_passed,
        'totalDays': total_days,
        'monthLabel': f'{month}月',
    }


# 直营新签人效达成 子弹图：基础列索引（0-based）
# 新 sheet 结构（9 列）：月份(0) 日期(1) 行业线(2) 新签门店目标(3) 年费目标(4) 实际新签金额(5) 人力目标(6) 展业人数(7) 实际新签门店(8)
# 5 个指标：年费收入 / 展业人数 / 人效签约金额 / 人效签约量 / 年费ARPU
DIRECT_SALES_METRIC_NAMES = ['年费收入', '展业人数', '人效签约金额', '人效签约量', '年费ARPU']


def _ds_compute_metrics(shop_tgt, fee_tgt, fee_act, ppl_tgt, ppl_act, shop_act):
    """从 6 个基础值计算 5 个指标列表。
    metrics 1,2,5 返回 [actual, target] (2元素)
    metrics 3,4 返回 [actual, tgt_100pct, tgt_actual_staffing] (3元素):
      - tgt_100pct: 展业人数达成率100%情况下的目标 = 年费目标/人力目标 (or 门店目标/人力目标)
      - tgt_actual_staffing: 实际人力达成下的目标 = 年费目标/展业人数 (or 门店目标/展业人数)
    """
    # 1. 年费收入: actual=实际新签金额, target=年费目标
    m1 = [fee_act, fee_tgt]
    # 2. 展业人数: actual=展业人数, target=人力目标
    m2 = [ppl_act, ppl_tgt]
    # 3. 人效签约金额: actual=实际新签金额/展业人数
    #    tgt_100pct = 年费目标/人力目标 (人力100%达成)
    #    tgt_actual = 年费目标/展业人数 (实际人力达成)
    m3 = [
        (fee_act / ppl_act) if ppl_act else 0,
        (fee_tgt / ppl_tgt) if ppl_tgt else 0,
        (fee_tgt / ppl_act) if ppl_act else 0,
    ]
    # 4. 人效签约量: actual=实际新签门店/展业人数
    #    tgt_100pct = 新签门店目标/人力目标 (人力100%达成)
    #    tgt_actual = 新签门店目标/展业人数 (实际人力达成)
    m4 = [
        (shop_act / ppl_act) if ppl_act else 0,
        (shop_tgt / ppl_tgt) if ppl_tgt else 0,
        (shop_tgt / ppl_act) if ppl_act else 0,
    ]
    # 5. 年费ARPU: actual=实际新签金额/实际新签门店, target=年费目标/新签门店目标
    m5 = [
        (fee_act / shop_act) if shop_act else 0,
        (fee_tgt / shop_tgt) if shop_tgt else 0,
    ]
    return [m1, m2, m3, m4, m5]


def build_bullet_data_direct_sales(xlsx_path, cutoff=None):
    """读取 '直营新签人效达成--子弹图底数' sheet，构建直营新签年费达成子弹图数据。
    返回 dict:{
      'raw': {'医疗':[[act,tgt]*6], '教培':[...], '合计':[...]},
      'days': int, 'totalDays': int, 'monthLabel': str
    } 或 None。
    """
    import openpyxl, calendar
    from datetime import date as _date
    try:
        wb = openpyxl.load_workbook(xlsx_path, data_only=True)
        sn = '直营新签人效达成--子弹图底数'
        if sn not in wb.sheetnames:
            wb.close()
            return None
        ws = wb[sn]
        rows = list(ws.iter_rows(values_only=True))
        wb.close()
    except Exception as e:
        print(f'[警告] 读取 {sn} 失败: {e}')
        return None

    if len(rows) < 2:
        return None

    # 收集数据行（跳过表头）
    data_rows = []
    for r in rows[1:]:
        if not r or len(r) < 9:
            continue
        month_val = r[0]
        if not isinstance(month_val, (int, float)):
            continue
        month_int = int(month_val)
        ind = str(r[2]).strip() if r[2] else ''
        if not ind:
            continue
        data_rows.append((month_int, ind, r))

    if not data_rows:
        return None

    latest_month = max(r[0] for r in data_rows)
    year = latest_month // 100
    month = latest_month % 100
    total_days = calendar.monthrange(year, month)[1]

    # 计算已过天数
    days_passed = None
    if cutoff is not None:
        try:
            cy = cutoff // 10000
            cm = (cutoff // 100) % 100
            cd = cutoff % 100
            if cy == year and cm == month:
                days_passed = cd
        except Exception:
            days_passed = None
    if days_passed is None:
        today = _date.today()
        if today.year == year and today.month == month:
            days_passed = today.day
        else:
            days_passed = total_days

    # 按行业收集当月数据的基础列
    # col3=新签门店目标, col4=年费目标, col5=实际新签金额, col6=人力目标, col7=展业人数, col8=实际新签门店
    per_ind_base = {}  # {ind: (shop_tgt, fee_tgt, fee_act, ppl_tgt, ppl_act, shop_act)}
    for month_int, ind, r in data_rows:
        if month_int != latest_month:
            continue
        shop_tgt = _num(r[3]) or 0
        fee_tgt = _num(r[4]) or 0
        fee_act = _num(r[5]) or 0
        ppl_tgt = _num(r[6]) or 0
        ppl_act = _num(r[7]) or 0
        shop_act = _num(r[8]) or 0
        per_ind_base[ind] = (shop_tgt, fee_tgt, fee_act, ppl_tgt, ppl_act, shop_act)

    if not per_ind_base:
        return None

    # 每行业计算 5 指标
    raw = {}
    for ind_key, bases in per_ind_base.items():
        raw[ind_key] = _ds_compute_metrics(*bases)

    # 合计：对基础列求和后再算比率
    if len(per_ind_base) >= 2:
        all_bases = list(per_ind_base.values())
        agg_bases = (
            sum(b[0] for b in all_bases),  # shop_tgt
            sum(b[1] for b in all_bases),  # fee_tgt
            sum(b[2] for b in all_bases),  # fee_act
            sum(b[3] for b in all_bases),  # ppl_tgt
            sum(b[4] for b in all_bases),  # ppl_act
            sum(b[5] for b in all_bases),  # shop_act
        )
        raw['合计'] = _ds_compute_metrics(*agg_bases)

    # 行业列表（不含合计），供前端动态生成下拉
    industries = [k for k in raw.keys() if k != '合计']

    return {
        'raw': raw,
        'days': days_passed,
        'totalDays': total_days,
        'monthLabel': f'{month}月',
        'industries': industries,
    }


# ============================================================
# 2. 聚合数据
# ============================================================
def aggregate_data(rows):
    """聚合：日 / 季 / 月 / 周 × {整体, 教培, 医疗, 各团队}"""
    print('[2/4] 聚合数据...')

    metrics = ['总收入', '新签年费', '续签年费', '广告现金收入', '品牌广告收入']

    # 整体（全行业全团队）
    day_data = defaultdict(lambda: defaultdict(float))
    q_data = defaultdict(lambda: defaultdict(float))
    m_data = defaultdict(lambda: defaultdict(float))
    w_data = defaultdict(lambda: defaultdict(float))
    q_days = defaultdict(set); m_days = defaultdict(set); w_days = defaultdict(set)

    # 教培（行业 YoY 用 + 教培 by 收入类型 图用）
    day_data_jp = defaultdict(lambda: defaultdict(float))
    q_data_jp = defaultdict(lambda: defaultdict(float))
    m_data_jp = defaultdict(lambda: defaultdict(float))
    w_data_jp = defaultdict(lambda: defaultdict(float))

    # 医疗（图3 用）
    day_data_med = defaultdict(lambda: defaultdict(float))
    q_data_med = defaultdict(lambda: defaultdict(float))
    m_data_med = defaultdict(lambda: defaultdict(float))
    w_data_med = defaultdict(lambda: defaultdict(float))

    # 各团队（图2 用）
    day_data_team = {t: defaultdict(lambda: defaultdict(float)) for t in TEAMS}
    q_data_team = {t: defaultdict(lambda: defaultdict(float)) for t in TEAMS}
    m_data_team = {t: defaultdict(lambda: defaultdict(float)) for t in TEAMS}
    w_data_team = {t: defaultdict(lambda: defaultdict(float)) for t in TEAMS}

    # 各团队 × 行业（直营×医疗 / 直营×教培 等图用）
    INDS = ('医疗', '教培')
    day_data_ti = {t: {i: defaultdict(lambda: defaultdict(float)) for i in INDS} for t in TEAMS}
    q_data_ti = {t: {i: defaultdict(lambda: defaultdict(float)) for i in INDS} for t in TEAMS}
    m_data_ti = {t: {i: defaultdict(lambda: defaultdict(float)) for i in INDS} for t in TEAMS}
    w_data_ti = {t: {i: defaultdict(lambda: defaultdict(float)) for i in INDS} for t in TEAMS}

    skipped = 0
    for row in rows:
        ds_str = str(row[0]).strip()
        srvline = str(row[1]).strip()
        team = str(row[2]).strip()

        if srvline in ('all', '-', '', '小计') or team in ('all', '-', ''):
            skipped += 1
            continue

        try:
            d_int = int(float(ds_str))
        except (ValueError, TypeError):
            skipped += 1
            continue

        total_r = num(row[3])
        xq = num(row[4])
        xx = num(row[5])
        gg = num(row[6])
        pp = num(row[7])

        q = date_to_fy_quarter(d_int)
        month = date_to_month(d_int)
        iw = date_to_iso_week(d_int)

        vals = [total_r, xq, xx, gg, pp]
        is_jp = (srvline == '教培')
        is_med = (srvline == '医疗')
        for mk, val in zip(metrics, vals):
            q_data[q][mk] += val
            m_data[month][mk] += val
            w_data[iw][mk] += val
            day_data[d_int][mk] += val
            if is_jp:
                day_data_jp[d_int][mk] += val
                q_data_jp[q][mk] += val
                m_data_jp[month][mk] += val
                w_data_jp[iw][mk] += val
            if is_med:
                day_data_med[d_int][mk] += val
                q_data_med[q][mk] += val
                m_data_med[month][mk] += val
                w_data_med[iw][mk] += val
            if team in TEAMS:
                day_data_team[team][d_int][mk] += val
                q_data_team[team][q][mk] += val
                m_data_team[team][month][mk] += val
                w_data_team[team][iw][mk] += val
                if srvline in INDS:
                    day_data_ti[team][srvline][d_int][mk] += val
                    q_data_ti[team][srvline][q][mk] += val
                    m_data_ti[team][srvline][month][mk] += val
                    w_data_ti[team][srvline][iw][mk] += val
        q_days[q].add(d_int)
        m_days[month].add(d_int)
        w_days[iw].add(d_int)

    print(f'      有效行: {len(rows) - skipped}，跳过汇总行: {skipped}')
    print(f'      季度数: {len(q_data)}，月份数: {len(m_data)}，周数: {len(w_data)}')

    from types import SimpleNamespace
    return SimpleNamespace(
        # 日粒度
        day=day_data, day_jp=day_data_jp, day_med=day_data_med, day_team=day_data_team,
        day_ti=day_data_ti,  # team × industry
        # 整体
        q=q_data, m=m_data, w=w_data,
        # 医疗
        q_med=q_data_med, m_med=m_data_med, w_med=w_data_med,
        # 教培
        q_jp=q_data_jp, m_jp=m_data_jp, w_jp=w_data_jp,
        # 各团队
        q_team=q_data_team, m_team=m_data_team, w_team=w_data_team,
        # 各团队 × 行业
        q_ti=q_data_ti, m_ti=m_data_ti, w_ti=w_data_ti,
        # 天数集合
        q_days=q_days, m_days=m_days, w_days=w_days,
    )


# ============================================================
# 3. 确定展示范围 & 计算 YoY
# ============================================================
def determine_targets(q_days, m_days, w_days):
    def q_sort_key(q):
        fy = int(q[2:4]); qn = int(q[-1])
        return fy * 4 + qn

    all_qs = sorted(q_days.keys(), key=q_sort_key)
    target_qs = all_qs[-N_QUARTERS:]

    all_months = sorted(m_days.keys())
    target_ms = all_months[-N_MONTHS:]

    complete_weeks = sorted([w for w in w_days if len(w_days[w]) == 7])
    target_ws = complete_weeks[-N_WEEKS:]

    return (target_qs, [get_prev_fy_quarter(q) for q in target_qs],
            target_ms, [get_prev_month(m) for m in target_ms],
            target_ws, [get_prev_iso_week(w) for w in target_ws])


def compute_yoy_industry(cur_keys, prev_keys, days_store, day_data, day_data_jp,
                         industry, granularity='quarter', cutoff=None):
    """行业 YoY（仅总收入）等天数同比。
    - 教培: 直接累加 day_data_jp
    - 医疗: 整体 - 教培（day_data - day_data_jp），与用户口径对齐（含美业残值并入医疗）
    """
    result = []
    for cur, prev in zip(cur_keys, prev_keys):
        cur_days = sorted(days_store.get(cur, set()))
        if cutoff is not None and granularity != 'week':
            cur_days = [d for d in cur_days if d <= cutoff]
        if not cur_days:
            result.append(None); continue

        if granularity == 'week':
            prev_days = sorted(days_store.get(prev, set()))
        else:
            prev_days = [d - 10000 for d in cur_days if (d - 10000) in day_data]
        if not prev_days:
            result.append(None); continue

        if industry == '教培':
            cur_sum = sum(day_data_jp[d]['总收入'] for d in cur_days)
            prev_sum = sum(day_data_jp[d]['总收入'] for d in prev_days)
        elif industry == '医疗':
            cur_sum = sum(day_data[d]['总收入'] - day_data_jp[d]['总收入'] for d in cur_days)
            prev_sum = sum(day_data[d]['总收入'] - day_data_jp[d]['总收入'] for d in prev_days)
        else:
            result.append(None); continue
        result.append((cur_sum / prev_sum - 1) * 100 if prev_sum > 0 else None)
    return result


def compute_yoy(cur_keys, prev_keys, days_store, day_source,
                granularity='quarter', cutoff=None,
                metrics=('总收入', '新签年费', '续签年费', '广告现金收入')):
    """等天数同比：cur_sum / prev_same_days_sum - 1
    - day_source: dict d_int → metric → value（决定 YoY 取自哪份日粒度数据：整体/医疗/某团队等）
    - 季度/月度：cur 限定到 cutoff（如最新完整周末），prev 取去年同日历段（d-10000）
    - 周度：ISO 周对 ISO 周（均 7 天）
    """
    metrics = tuple(metrics)
    result = {m: [] for m in metrics}

    for cur, prev in zip(cur_keys, prev_keys):
        cur_days = sorted(days_store.get(cur, set()))
        if cutoff is not None and granularity != 'week':
            cur_days = [d for d in cur_days if d <= cutoff]

        if not cur_days:
            for m in metrics:
                result[m].append(None)
            continue

        if granularity == 'week':
            prev_days = sorted(days_store.get(prev, set()))
        else:
            prev_days = [d - 10000 for d in cur_days if (d - 10000) in day_source]

        if not prev_days:
            for m in metrics:
                result[m].append(None)
            continue

        for m in metrics:
            cur_sum = sum(day_source[d][m] for d in cur_days)
            prev_sum = sum(day_source[d][m] for d in prev_days)
            result[m].append((cur_sum / prev_sum - 1) * 100 if prev_sum > 0 else None)
    return result


# ============================================================
# 4. 绘图（config 驱动，支持多种 stack/yoy 组合）
# ============================================================
def _build_xtick_labels(labels, all_ndays, target_ws, w_days):
    xtick_labels = []
    for i, (lbl, nd) in enumerate(zip(labels, all_ndays)):
        if i < N_QUARTERS:
            # 季度：仅最后一个季度（最新且通常不满）显示 (N天)
            if i == N_QUARTERS - 1:
                xtick_labels.append(f'{lbl}\n({nd}天)')
            else:
                xtick_labels.append(lbl)
        elif i < N_QUARTERS + N_MONTHS:
            # 月份：仅显示「X月」
            xtick_labels.append(lbl)
        else:
            # 周：仅显示「Wxx」+ 周起日（不再加天数）
            wk = target_ws[i - N_QUARTERS - N_MONTHS]
            start_d = min(w_days[wk])
            mm = (start_d % 10000) // 100
            dd = start_d % 100
            xtick_labels.append(f'{lbl}\n{mm:02d}/{dd:02d}')
    return xtick_labels


def plot_chart(labels, all_ndays, target_ws, w_days,
               stack_layers, yoy_lines,
               title_left, title_right, output_path,
               left_ylabel='日均收入'):
    """通用双 panel 绘图。
    stack_layers: list of dicts {key, label, color, values:list[float]}（自底向上）
    yoy_lines: list of dicts {key, label, color, marker, linestyle, linewidth, primary:bool, values:list[float|None]}
    """
    print(f'[绘图] {output_path}')
    plt.rcParams['font.sans-serif'] = [
        'PingFang SC', 'Heiti SC', 'Songti SC',
        'Hiragino Sans GB', 'Arial Unicode MS'
    ]
    plt.rcParams['axes.unicode_minus'] = False

    n = len(labels)
    x = np.arange(n)
    xtick_labels = _build_xtick_labels(labels, all_ndays, target_ws, w_days)

    # 各期总额（=各 stack layer 之和）
    totals = [sum(layer['values'][i] for layer in stack_layers) for i in range(n)]

    fig, (ax1, ax2) = plt.subplots(
        1, 2, figsize=(20, 7.5), dpi=160,
        gridspec_kw={'width_ratios': [1.2, 1]}
    )
    fig.patch.set_facecolor('white')

    # --- 左图：堆积面积 ---
    ax1.stackplot(
        x,
        *[layer['values'] for layer in stack_layers],
        labels=[layer['label'] for layer in stack_layers],
        colors=[layer['color'] for layer in stack_layers],
        alpha=0.95, edgecolor='white', linewidth=1.2
    )

    # 各层标注：底层（黄色）用深色字，其余用白字；只显示占比
    # 防重叠：阈值提高到 4.5%；段高也太矮（绝对高度<总额*4%）也跳过
    # 边缘感知：首列 ha='left'，末列 ha='right'，避免被 ax 边界裁切
    cum = np.zeros(n)
    n_layers = len(stack_layers)
    max_t_for_layer = max(totals) if totals else 0
    min_visible_h = max_t_for_layer * 0.045  # 段绝对高度阈值
    for li, layer in enumerate(stack_layers):
        vals = layer['values']
        tc = '#222' if layer['color'] in ('#F4C842', '#FFD966') else 'white'
        for i, (v, t) in enumerate(zip(vals, totals)):
            pct = v / t * 100 if t > 0 else 0
            # 段太小（占比<4.5% 或 高度<最大总额的 4%）时不画文字，避免重叠
            if t > 0 and (pct < 4.5 or v < min_visible_h):
                cum[i] += v
                continue
            if i == 0:
                ha_s, x_off = 'left', 0.05
            elif i == n - 1:
                ha_s, x_off = 'right', -0.05
            else:
                ha_s, x_off = 'center', 0
            ax1.text(x[i] + x_off, cum[i] + v / 2,
                     f'{pct:.0f}%',
                     ha=ha_s, va='center', fontsize=10,
                     fontweight='bold', color=tc)
            cum[i] += v

    # 顶部总额标注（位置上抬 + 边缘感知对齐，避免末列被裁）
    if totals:
        max_t = max(totals)
        for i, t in enumerate(totals):
            if i == 0:
                ha_s, x_off = 'left', 0.0
            elif i == n - 1:
                ha_s, x_off = 'right', 0.0
            else:
                ha_s, x_off = 'center', 0
            ax1.text(x[i] + x_off, t + max_t * 0.035, f'{t:,.0f}',
                     ha=ha_s, va='bottom', fontsize=10, fontweight='bold', color='#222')
        ax1.set_ylim(0, max_t * 1.22)

    ax1.set_xticks(x)
    ax1.set_xticklabels(xtick_labels, fontsize=8.5, fontweight='bold')
    ax1.axvline(x=N_QUARTERS - 0.5, color='#999', linestyle='--', linewidth=1, alpha=0.6)
    ax1.axvline(x=N_QUARTERS + N_MONTHS - 0.5, color='#999', linestyle='--', linewidth=1, alpha=0.6)
    ax1.set_ylabel(left_ylabel, fontsize=11)
    ax1.set_title(title_left, fontsize=13, fontweight='bold', pad=12)
    ax1.spines['top'].set_visible(False)
    ax1.spines['right'].set_visible(False)
    ax1.spines['left'].set_color('#bbb')
    ax1.spines['bottom'].set_color('#bbb')
    ax1.set_xlim(-0.7, n - 0.3)
    ax1.legend(loc='upper center', bbox_to_anchor=(0.5, -0.1),
               ncol=len(stack_layers), frameon=False, fontsize=10)

    # --- 右图：YoY 折线（平滑曲线） ---
    from scipy.interpolate import PchipInterpolator
    for line in yoy_lines:
        vals = line['values']
        xs = [i for i, v in enumerate(vals) if v is not None]
        ys = [v for v in vals if v is not None]
        if not xs:
            continue
        # 数据点 marker
        ax2.plot(
            xs, ys, label=line['label'],
            color=line['color'], linestyle='none',
            marker=line.get('marker', 'o'),
            markersize=line.get('markersize', 6),
            zorder=10 if line.get('primary') else 8,
        )
        # 平滑曲线（PCHIP 不会过冲）
        if len(xs) >= 3:
            xs_arr = np.array(xs, dtype=float)
            ys_arr = np.array(ys, dtype=float)
            pchip = PchipInterpolator(xs_arr, ys_arr)
            xs_smooth = np.linspace(xs_arr[0], xs_arr[-1], 200)
            ys_smooth = pchip(xs_smooth)
            ax2.plot(
                xs_smooth, ys_smooth,
                color=line['color'], linestyle=line.get('linestyle', '-'),
                linewidth=line.get('linewidth', 2.0),
                zorder=9 if line.get('primary') else 7,
            )
        elif len(xs) >= 2:
            ax2.plot(
                xs, ys,
                color=line['color'], linestyle=line.get('linestyle', '-'),
                linewidth=line.get('linewidth', 2.0),
                zorder=9 if line.get('primary') else 7,
            )

    # 标注：primary 始终标；同列辅线按 y 分簇，簇内通过 xoff/yoff 错位避免重叠
    # 算法：
    # 1) 收集本列所有非 None 的 (line, v)
    # 2) 按 v 升序排列；相邻差 < CLUSTER_THRESH(8ppt) 视为同一簇
    # 3) primary 始终标注（yoff=14 居中）
    # 4) 辅线：
    #    - 若与 primary 距离 < CLOSE_TO_PRIMARY(8ppt)，仅显示一条（绝对值最大）并 xoff=18 让位
    #    - 簇内多个辅线时按 (xoff, yoff) 在 [(-18,16),(18,-16),(-18,-16),(18,18)] 错位
    #    - 簇大小 >= 3 时，仅保留绝对值最大的一条辅线，其余跳过
    CLUSTER_THRESH = 8  # 簇内 y 间距阈值
    CLOSE_TO_PRIMARY = 8

    for xi in range(n):
        present = [(li_idx, line, line['values'][xi])
                   for li_idx, line in enumerate(yoy_lines)
                   if xi < len(line['values']) and line['values'][xi] is not None]
        if not present:
            continue

        primary_items = [(idx, l, v) for (idx, l, v) in present if l.get('primary')]
        sec_items = [(idx, l, v) for (idx, l, v) in present if not l.get('primary')]

        # primary 标注（始终）
        for (_, line, v) in primary_items:
            ax2.annotate(
                f"{v:.0f}%", (xi, v),
                textcoords="offset points", xytext=(0, 14),
                ha='center', fontsize=10, fontweight='bold',
                color=line['color'], zorder=12,
            )

        # 辅线分簇（基于 y）
        sec_sorted = sorted(sec_items, key=lambda t: t[2])
        clusters = []
        cur = []
        for item in sec_sorted:
            if not cur:
                cur = [item]
            elif abs(item[2] - cur[-1][2]) < CLUSTER_THRESH:
                cur.append(item)
            else:
                clusters.append(cur)
                cur = [item]
        if cur:
            clusters.append(cur)

        # 簇内分配 (xoff, yoff)
        positions = [(-22, 14), (22, 14), (-22, -16), (22, -16)]
        for cluster in clusters:
            # 检查是否与 primary 太近：若是，整簇只保留绝对值最大的，并 xoff 让位
            close_primary = primary_items and any(
                abs(v - pv) < CLOSE_TO_PRIMARY for (_, _, pv) in primary_items
                for (_, _, v) in [cluster[0]]
            )
            # 簇大小 >=3 → 仅保留绝对值最大
            if len(cluster) >= 3:
                kept = max(cluster, key=lambda t: abs(t[2]))
                _, line, v = kept
                xoff = 22 if v >= (primary_items[0][2] if primary_items else 0) else -22
                yoff = 14 if v >= 0 else -16
                ax2.annotate(
                    f"{v:.0f}%", (xi, v),
                    textcoords="offset points", xytext=(xoff, yoff),
                    ha='left' if xoff > 0 else 'right',
                    fontsize=8.5, fontweight='bold',
                    color=line['color'], zorder=10,
                )
                continue
            # 簇大小 1~2：簇内每条都标，错位
            for k, (_, line, v) in enumerate(cluster):
                if close_primary and len(cluster) == 1:
                    # 单条且贴近 primary：左右让位（看符号）
                    xoff, yoff = (22, 0) if v >= 0 else (-22, 0)
                else:
                    xoff, yoff = positions[k % len(positions)]
                ax2.annotate(
                    f"{v:.0f}%", (xi, v),
                    textcoords="offset points", xytext=(xoff, yoff),
                    ha='left' if xoff > 0 else ('right' if xoff < 0 else 'center'),
                    fontsize=8.5, fontweight='bold',
                    color=line['color'], zorder=10,
                )

    ax2.axhline(y=0, color='#ccc', linewidth=0.8)
    ax2.axvline(x=N_QUARTERS - 0.5, color='#999', linestyle='--', linewidth=1, alpha=0.6)
    ax2.axvline(x=N_QUARTERS + N_MONTHS - 0.5, color='#999', linestyle='--', linewidth=1, alpha=0.6)

    # 区域标签
    ylim_top = ax2.get_ylim()[1]
    ax2.text((N_QUARTERS - 1) / 2, ylim_top * 0.97, '季度',
             ha='center', fontsize=10, color='#666', fontstyle='italic')
    ax2.text(N_QUARTERS + (N_MONTHS - 1) / 2, ylim_top * 0.97, '月度',
             ha='center', fontsize=10, color='#666', fontstyle='italic')
    ax2.text(N_QUARTERS + N_MONTHS + (N_WEEKS - 1) / 2, ylim_top * 0.97, '周度',
             ha='center', fontsize=10, color='#666', fontstyle='italic')

    ax2.set_xticks(x)
    ax2.set_xticklabels(xtick_labels, fontsize=8.5, fontweight='bold')
    ax2.set_ylabel('YoY (%)', fontsize=11)
    ax2.set_title(title_right, fontsize=13, fontweight='bold', pad=12)
    ax2.spines['top'].set_visible(False)
    ax2.spines['right'].set_visible(False)
    ax2.spines['left'].set_color('#bbb')
    ax2.spines['bottom'].set_color('#bbb')
    ax2.set_xlim(-0.5, n - 0.5)
    ax2.grid(axis='y', alpha=0.3)
    ax2.legend(loc='upper center', bbox_to_anchor=(0.5, -0.1),
               ncol=len(yoy_lines), frameon=False, fontsize=10)

    plt.tight_layout()
    plt.savefig(output_path, dpi=200, bbox_inches='tight', facecolor='white')
    plt.close()
    print(f'[OK] 图表已保存: {output_path}')


# ============================================================
# 主流程
# ============================================================
def _write_interactive_html(html_path, chart_groups, labels, all_ndays,
                            target_qs, target_ms, target_ws,
                            yoy_overall, yoy_med, yoy_jp, yoy_team_total,
                            all_ms=None, all_ws_complete=None,
                            m_days=None, w_days=None,
                            yoy_overall_full=None, yoy_med_full=None,
                            yoy_jp_full=None, yoy_team_full_full=None,
                            category_payload=None,
                            bullet_data=None,
                            direct_sales_bullet_data=None):
    """把 chart_groups 打成一份单文件 ECharts 交互 HTML（分组 + 筛选器 + 月/周范围筛选）"""
    import json
    from datetime import datetime as _dt

    # 季度部分（固定 N_QUARTERS 个）
    q_meta = []
    for i, q in enumerate(target_qs):
        q_meta.append({'label': q.replace('-', ''), 'ndays': all_ndays[i]})

    # 月度全量
    m_meta = []
    for mi in (all_ms or target_ms):
        nd = len(m_days[mi]) if (m_days and mi in m_days) else 0
        m_meta.append({'key': mi, 'label': f'{mi % 100}月', 'ndays': nd})

    # 周度全量
    w_meta = []
    for iw in (all_ws_complete or target_ws):
        nd = len(w_days[iw]) if (w_days and iw in w_days) else 7
        w_meta.append({'key': f'{iw[0]}-{iw[1]}', 'label': f'W{iw[1]}',
                       'year': iw[0], 'iso': iw[1], 'ndays': nd})

    # 默认月/周筛选范围 = 与 target_ms / target_ws 一致（最近 N 个）
    if all_ms:
        default_m_start = max(0, len(all_ms) - len(target_ms))
        default_m_end = len(all_ms) - 1
    else:
        default_m_start, default_m_end = 0, len(target_ms) - 1
    if all_ws_complete:
        default_w_start = max(0, len(all_ws_complete) - len(target_ws))
        default_w_end = len(all_ws_complete) - 1
    else:
        default_w_start, default_w_end = 0, len(target_ws) - 1

    # 序列化每个 group 的所有 options
    def _round_list(arr, nd=2):
        return [None if v is None else round(v, nd) for v in arr]

    groups_serial = []
    for grp in chart_groups:
        opts_serial = []
        # 归因文本：group ① 用 attr_text，其余用 attr_by_option[option_key]
        attr_text_default = grp.get('attr_text')  # [w_text, m_text]
        attr_by_option = grp.get('attr_by_option', {})
        target_by_option = grp.get('target_by_option', {})
        group_has_target = any(
            (target_by_option.get(opt['key']) or {}).get('rows') for opt in grp['options']
        )
        for opt in grp['options']:
            cfg = opt['cfg']
            opt_key = opt['key']
            attr_pair = attr_by_option.get(opt_key) or attr_text_default or ['', '']
            tgt_entry = target_by_option.get(opt_key) or {}
            opts_serial.append({
                'key': opt_key,
                'title_left': cfg['title_left'],
                'title_right': cfg['title_right'],
                'attrW': attr_pair[0] if attr_pair else '',
                'attrM': attr_pair[1] if attr_pair and len(attr_pair) > 1 else '',
                'targetCol1': tgt_entry.get('col1') or '',
                'targetRows': tgt_entry.get('rows') or [],
                'stack': [
                    {'name': L['label'], 'color': L['color'],
                     'qData': _round_list(L.get('q_values', []), 1),
                     'mData': _round_list(L.get('m_values', []), 1),
                     'wData': _round_list(L.get('w_values', []), 1)}
                    for L in cfg['stack_layers']
                ],
                'yoy': [
                    {'name': Y['label'], 'color': Y['color'],
                     'lineStyle': Y.get('linestyle', '-'),
                     'lineWidth': Y.get('linewidth', 2.0),
                     'primary': bool(Y.get('primary')),
                     'qData': _round_list(Y.get('q_values', []), 2),
                     'mData': _round_list(Y.get('m_values', []), 2),
                     'wData': _round_list(Y.get('w_values', []), 2)}
                    for Y in cfg['yoy_lines']
                ],
            })
        groups_serial.append({
            'id': grp['id'],
            'title': grp['title'],
            'filterLabel': grp.get('filter_label'),
            'filterLabel2': grp.get('filter_label2'),
            'filter2Options': grp.get('filter2_options') or [],
            'options': opts_serial,
            'hasTarget': bool(group_has_target),
            'targetMonthLabel': grp.get('target_month_label', ''),
        })

    # YoY 一览表
    def _row(name, vals):
        cells = ''.join(
            f'<td class="num">{v:.0f}%</td>' if v is not None else '<td class="num na">N/A</td>'
            for v in vals
        )
        return f'<tr><td class="lbl">{name}</td>{cells}</tr>'

    period_headers = (
        ''.join(f'<th>{q.replace("-", "")}</th>' for q in target_qs) +
        ''.join(f'<th>{mi % 100}月</th>' for mi in target_ms) +
        ''.join(f'<th>W{iw[1]}</th>' for iw in target_ws)
    )
    yoy_table_rows = [
        _row('整体', yoy_overall['总收入']),
        _row('医疗', yoy_med['总收入']),
        _row('教培', yoy_jp['总收入']),
    ]
    for t in ['直营', '渠道', '电销']:
        yoy_table_rows.append(_row(t, yoy_team_total[t]))

    # 构建 nav 和 chart_blocks
    nav_items = ''.join(
        f'<a href="#{g["id"]}">{g["title"]}</a>' for g in groups_serial
    )

    chart_blocks = ''
    for g in groups_serial:
        filter_html = ''
        if g['filterLabel']:
            if g.get('filterLabel2'):
                # 双下拉：第 1 个用 metric label（key 中 '|' 前段），第 2 个用 filter2Options
                seen = []
                for o in g['options']:
                    m_label = o['key'].split('|')[0]
                    if m_label not in seen:
                        seen.append(m_label)
                opt1_html = ''.join(f'<option value="{m}">{m}</option>' for m in seen)
                opt2_html = ''.join(f'<option value="{ind}">{ind}</option>' for ind in g['filter2Options'])
                filter_html = f'''<div class="group-filter">
  <label>{g["filterLabel"]}:</label>
  <select class="group-select" data-group="{g["id"]}" data-filter="1">{opt1_html}</select>
  <label style="margin-left:14px;">{g["filterLabel2"]}:</label>
  <select class="group-select" data-group="{g["id"]}" data-filter="2">{opt2_html}</select>
</div>'''
            else:
                options_html = ''.join(
                    f'<option value="{i}">{o["key"]}</option>'
                    for i, o in enumerate(g['options'])
                )
                filter_html = f'''<div class="group-filter">
  <label>{g["filterLabel"]}:</label>
  <select class="group-select" data-group="{g["id"]}">{options_html}</select>
</div>'''
        # 目标&达成表格容器（仅 hasTarget 的 group 渲染，JS 切换 option 时更新内容）
        target_html = ''
        layout_class = 'dual'
        if g.get('hasTarget'):
            layout_class = 'triple'
            target_html = f'<div class="target-panel" id="{g["id"]}_tgt"></div>'
        chart_blocks += f'''<section class="card" id="{g['id']}">
  <div class="card-header">
    <h2>{g['title']}</h2>
    {filter_html}
  </div>
  <div class="attribution" id="{g['id']}_attr"></div>
  <div class="{layout_class}">
    <div class="chart-left" id="{g['id']}_left"></div>
    <div class="chart-right" id="{g['id']}_right"></div>
    {target_html}
  </div>
</section>
'''

    # Group 6: 收入类型 × 类目（仅当 category_payload 存在时渲染）
    if category_payload:
        nav_items += '<a href="#group_6">⑥ 收入类型 × 类目</a>'
        chart_blocks += '''<section class="card" id="group_6">
  <div class="card-header">
    <h2>⑥ 收入类型 × 类目</h2>
    <div class="group-filter">
      <label>指标:</label>
      <select id="g6_metric"></select>
      <label style="margin-left:14px;">行业:</label>
      <select id="g6_industry"></select>
      <label style="margin-left:14px;">团队:</label>
      <select id="g6_team"></select>
      <label style="margin-left:14px;">一级类目:</label>
      <select id="g6_l1"></select>
    </div>
  </div>
  <div class="dual">
    <div class="chart-left" id="group_6_left"></div>
    <div class="chart-right" id="group_6_right"></div>
  </div>
</section>
'''

    # Group 7: 收入达成子弹图（仅当 bullet_data 存在时渲染）
    if bullet_data:
        nav_items += '<a href="#group_7">⑦ 收入达成</a>'
        chart_blocks += '''<section class="card" id="group_7">
  <div class="card-header">
    <h2>⑦ 收入达成子弹图</h2>
    <div class="g7-tabs">
      <button class="g7-tab active" data-panel="g7_panel_dim">维度视角</button>
      <button class="g7-tab" data-panel="g7_panel_metric">指标视角</button>
    </div>
  </div>
  <div class="bullet-legend">
    <span class="bl-item"><span class="bl-fill"></span>当前达成</span>
    <span class="bl-item"><span class="bl-dash"></span>序时目标</span>
  </div>

  <!-- 维度视角：某行业×团队 → 各指标达成 -->
  <div id="g7_panel_dim" class="g7-panel active">
    <div class="group-filter" style="margin: 8px 0 12px;">
      <label>行业:</label>
      <select id="g7_industry">
        <option value="all">全部</option>
        <option value="医疗">医疗</option>
        <option value="教培">教培</option>
      </select>
      <label style="margin-left:14px;">团队:</label>
      <select id="g7_team">
        <option value="all">全部</option>
        <option value="直营">直营</option>
        <option value="渠道">渠道</option>
        <option value="电销">电销</option>
      </select>
    </div>
    <div id="g7_title_dim" class="g7-subtitle">全部行业 · 全部团队 — 各指标达成</div>
    <div id="group_7_chart"></div>
  </div>

  <!-- 指标视角：某收入指标 → 各行业×团队达成 -->
  <div id="g7_panel_metric" class="g7-panel">
    <div class="group-filter" style="margin: 8px 0 12px;">
      <label>收入指标:</label>
      <select id="g7_metric">
        <option value="0">总收入</option>
        <option value="1">新签年费</option>
        <option value="2">续签年费</option>
        <option value="3">效果广告</option>
        <option value="4">品牌广告</option>
        <option value="5">佣金收入</option>
      </select>
    </div>
    <div id="g7_title_metric" class="g7-subtitle">总收入 — 团队×行业达成</div>
    <div id="group_7_chart_metric"></div>
  </div>
</section>
'''

    # Group 8: 直营新签年费达成子弹图
    if direct_sales_bullet_data:
        nav_items += '<a href="#group_8">⑧ 直营新签年费达成</a>'
        chart_blocks += '''<section class="card" id="group_8">
  <div class="card-header">
    <h2>⑧ 直营新签年费达成子弹图</h2>
  </div>
  <div class="bullet-legend">
    <span class="bl-item"><span class="bl-fill"></span>当前达成</span>
    <span class="bl-item"><span class="bl-dash"></span>序时目标</span>
    <span class="bl-item"><span style="display:inline-block;width:18px;height:0;border-top:2px dashed #E89A3C;vertical-align:middle;margin-right:4px;"></span><span style="color:#E89A3C;">100%人力目标</span></span>
    <span class="bl-item"><span style="display:inline-block;width:18px;height:0;border-top:2px dashed #D94A38;vertical-align:middle;margin-right:4px;"></span><span style="color:#D94A38;">实际人力目标</span></span>
  </div>
  <div class="group-filter" style="margin: 8px 0 12px;">
    <label>行业:</label>
    <select id="g8_industry"></select>
  </div>
  <div id="g8_title" class="g7-subtitle">合计 — 直营新签年费达成</div>
  <div id="group_8_chart"></div>
</section>
'''

    now = _dt.now().strftime('%Y-%m-%d %H:%M')
    payload_dict = {
        'qMeta': q_meta,
        'mMeta': m_meta,
        'wMeta': w_meta,
        'defaultMRange': [default_m_start, default_m_end],
        'defaultWRange': [default_w_start, default_w_end],
        'groups': groups_serial,
    }
    if category_payload:
        payload_dict['categoryData'] = category_payload
    # 子弹图数据
    if bullet_data:
        payload_dict['bulletData'] = bullet_data
    # 直营新签年费达成 子弹图数据
    if direct_sales_bullet_data:
        payload_dict['directSalesBulletData'] = direct_sales_bullet_data
    payload_json = json.dumps(payload_dict, ensure_ascii=False)

    html = '''<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>周报收入趋势汇总（交互版）</title>
<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script>
<style>
* { box-sizing: border-box; }
body { font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
       background: #f5f5f7; color: #222; margin: 0; padding: 24px; }
header { max-width: 1480px; margin: 0 auto 18px; }
header h1 { margin: 0 0 6px; font-size: 24px; }
header .meta { color: #888; font-size: 13px; }
nav { max-width: 1480px; margin: 0 auto 22px; background: white;
      border-radius: 10px; padding: 12px 16px; display: flex; flex-wrap: wrap; gap: 8px;
      box-shadow: 0 1px 4px rgba(0,0,0,0.05); }
nav a { display: inline-block; padding: 6px 12px; background: #eef2f7; color: #2F6FBF;
        border-radius: 6px; text-decoration: none; font-size: 13px; }
nav a:hover { background: #2F6FBF; color: white; }
.summary { max-width: 1480px; margin: 0 auto 22px; background: white;
           border-radius: 10px; padding: 18px 22px; box-shadow: 0 1px 4px rgba(0,0,0,0.05); }
.summary h2 { margin: 0 0 12px; font-size: 17px; color: #333; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th, td { padding: 8px 10px; text-align: center; border-bottom: 1px solid #eee; }
th { background: #fafafa; color: #555; font-weight: 600; }
td.lbl { text-align: left; font-weight: 600; color: #333; }
td.num { font-variant-numeric: tabular-nums; }
td.num.na { color: #bbb; }
.filters { max-width: 1480px; margin: 0 auto 18px; background: #fffbe6;
           border-radius: 10px; padding: 12px 16px; box-shadow: 0 1px 4px rgba(0,0,0,0.05);
           display: flex; flex-wrap: wrap; gap: 16px; align-items: center; }
.filters .group { display: flex; align-items: center; gap: 6px; }
.filters .group label { font-size: 13px; color: #555; font-weight: 600; }
.filters select { padding: 4px 8px; border: 1px solid #ccc; border-radius: 4px;
                  background: white; font-size: 13px; min-width: 70px; }
.filters .tip { color: #999; font-size: 12px; }
.filters button { padding: 6px 14px; border: none; border-radius: 4px;
                  background: #2F6FBF; color: white; font-size: 13px; cursor: pointer; }
.filters button:hover { background: #1f5394; }
.filters button.reset { background: #888; }
.filters button.dl { background: #27ae60; }
.filters button.dl:hover { background: #1e8449; }
.card { max-width: 1480px; margin: 0 auto 24px; background: white;
        border-radius: 10px; padding: 16px 20px; box-shadow: 0 1px 4px rgba(0,0,0,0.05); }
.card-header { display: flex; align-items: center; gap: 16px; margin-bottom: 12px; }
.card-header h2 { margin: 0; font-size: 17px; color: #222;
           border-left: 4px solid #2F6FBF; padding-left: 10px; }
.group-filter { display: flex; align-items: center; gap: 6px; }
.group-filter label { font-size: 13px; color: #555; font-weight: 600; }
.group-filter select { padding: 5px 10px; border: 1px solid #ccc; border-radius: 6px;
                       background: white; font-size: 13px; min-width: 80px; cursor: pointer; }
.attribution { background: #f0f7ff; border-left: 3px solid #2F6FBF;
               padding: 10px 14px; margin-bottom: 12px; border-radius: 4px;
               font-size: 13px; color: #333; line-height: 1.7; }
.attribution .row { display: flex; gap: 6px; align-items: baseline; }
.attribution .tag { display: inline-block; flex-shrink: 0; padding: 1px 8px;
                    background: #2F6FBF; color: white; border-radius: 10px;
                    font-size: 11px; font-weight: 600; min-width: 40px; text-align: center; }
.attribution .text { flex: 1; }
.attribution.empty { display: none; }
.dual { display: grid; grid-template-columns: 1.2fr 1fr; gap: 12px; }
.triple { display: grid; grid-template-columns: 1.05fr 0.85fr 320px; gap: 16px; align-items: start; }
.chart-left, .chart-right { width: 100%; height: 460px; }
.target-panel { background: linear-gradient(180deg, #fafdfb 0%, #ffffff 100%);
                border: 1px solid #d8e6dc; border-radius: 10px;
                padding: 14px 16px 12px; box-shadow: 0 2px 8px rgba(44, 85, 48, 0.06); }
.tgt-title { font-size: 13.5px; font-weight: 700; color: #1f4d2a;
             padding-bottom: 8px; margin-bottom: 10px;
             border-bottom: 2px solid #d4ebd9;
             display: flex; align-items: center; justify-content: space-between; }
.tgt-title .badge-month { background: #e8f5ed; color: #2c5530; font-size: 11px;
                          padding: 2px 8px; border-radius: 10px; font-weight: 600; }
.tgt-table { width: 100%; border-collapse: separate; border-spacing: 0;
             font-size: 12.5px; }
.tgt-table thead th { background: transparent; color: #6c7b7f; font-weight: 600;
                      padding: 4px 8px 8px; text-align: right; border: none;
                      font-size: 11px; letter-spacing: 0.3px; text-transform: uppercase;
                      border-bottom: 1px solid #e8edf2; }
.tgt-table thead th:first-child { text-align: left; padding-left: 4px; }
.tgt-table thead th.col-status { text-align: center; width: 56px; }
.tgt-table tbody td { padding: 9px 8px; border-bottom: 1px solid #f0f3f5;
                      font-variant-numeric: tabular-nums; vertical-align: middle; }
.tgt-table tbody tr:last-child td { border-bottom: none; }
.tgt-table tbody tr:hover td { background: #f5faf6; }
.tgt-table td.lbl { color: #2c3e50; font-weight: 600; padding-left: 4px;
                    white-space: nowrap; font-size: 12.5px; }
.tgt-table td.num { text-align: right; color: #34495e; font-weight: 600; }
.tgt-table td.num.target { color: #6c7b7f; font-weight: 500; }
.tgt-table td.num.actual.win { color: #1e8449; font-weight: 700; }
.tgt-table td.num.actual.lose { color: #c0392b; font-weight: 700; }
.tgt-table td.status { text-align: center; }
.tgt-table td.status .pill { display: inline-block; width: 22px; height: 22px;
                              line-height: 22px; border-radius: 50%; font-size: 12px;
                              font-weight: 700; color: #fff; }
.tgt-table td.status .pill.ok { background: linear-gradient(135deg, #27ae60, #1e8449); }
.tgt-table td.status .pill.bad { background: linear-gradient(135deg, #e74c3c, #c0392b); }
.tgt-table td.status .pill.na { background: #bdc3c7; }
.tgt-table tbody tr.total td { background: #f8fbf9; border-top: 2px solid #d4ebd9;
                                border-bottom: 1px solid #d4ebd9; }
.tgt-table tbody tr.total td.lbl { color: #1f4d2a; font-weight: 700; }
.tgt-foot { margin-top: 8px; font-size: 10.5px; color: #95a5a6; text-align: right; }
/* 子弹图 */
.bullet-legend { display: flex; gap: 24px; margin: 8px 0 14px 0; align-items: center; }
.bl-item { display: flex; align-items: center; gap: 6px; font-size: 12px; color: #555; }
.bl-fill { width: 22px; height: 12px; background: #6baed6; border-radius: 4px; }
.bl-dash { width: 20px; border-top: 2.5px dashed #e05555; }
.bt-row { display: flex; align-items: flex-start; padding: 7px 0; }
.bt-row + .bt-row { border-top: 1px solid #eee; }
.bt-name { width: 56px; flex-shrink: 0; font-weight: 600; color: #1a1a2e; text-align: center; padding-top: 10px; line-height: 1.25; font-size: 13px; }
.bt-bar { flex: 1; position: relative; height: 46px; }
.bt-track { position: absolute; top: 9px; left: 0; right: 0; height: 28px; background: #f0f0f0; border-radius: 6px; border: 1px solid #e0e0e0; overflow: hidden; }
.bt-fill { position: absolute; top: 0; left: 0; height: 100%; border-radius: 6px; display: flex; align-items: center; justify-content: center; z-index: 2; }
.bt-fill-txt { white-space: nowrap; font-size: 11.5px; font-weight: 700; color: #fff; text-shadow: 0 0 3px rgba(0,0,0,0.4); }
.bt-vline { position: absolute; top: 1px; height: 44px; border-left: 2.5px dashed #e05555; z-index: 3; }
.bt-seq-lbl { position: absolute; white-space: nowrap; z-index: 5; font-size: 10px; font-weight: 600; color: #c0392b; top: -2px; transform: translateX(-50%); }
.bt-gap { position: absolute; white-space: nowrap; z-index: 5; font-size: 10px; font-weight: 700; top: 16px; padding: 1px 4px; border-radius: 3px; transform: translateX(-50%); }
.bt-gap-pos { color: #16a34a; background: rgba(232,245,233,0.9); }
.bt-gap-neg { color: #dc2626; background: rgba(254,226,226,0.9); }
.bt-sum { width: 140px; flex-shrink: 0; padding-left: 12px; padding-top: 4px; }
.bt-sum-line { font-size: 11.5px; color: #777; line-height: 1.85; }
.bt-sum-line b { color: #1a1a2e; font-weight: 600; }
.bt-pct { display: inline-block; font-size: 10.5px; font-weight: 700; padding: 0 4px; border-radius: 3px; margin-left: 2px; }
.bt-pct-g { color: #16a34a; background: #e8f5e9; }
.bt-pct-w { color: #d97706; background: #fef3c7; }
.bt-pct-b { color: #dc2626; background: #fee2e2; }
.g7-tabs { display: flex; gap: 8px; }
.g7-tab { padding: 6px 14px; background: #f0f0f0; border: 1px solid #ddd; border-radius: 6px;
          cursor: pointer; font-size: 13px; color: #555; font-weight: 500; transition: all 0.15s; }
.g7-tab:hover { background: #e6f0f9; color: #1a75d2; }
.g7-tab.active { background: #1a75d2; color: #fff; border-color: #1a75d2; }
.g7-panel { display: none; }
.g7-panel.active { display: block; }
.g7-subtitle { font-size: 13px; color: #666; margin: 0 0 10px 0; font-weight: 600; }
.g7-section-label { font-size: 12px; font-weight: 700; color: #888; margin: 12px 0 4px 0;
                     padding-left: 4px; letter-spacing: 1px; }
footer { max-width: 1480px; margin: 24px auto 0; text-align: center; color: #aaa; font-size: 12px; }
</style>
</head>
<body>
<header>
  <h1>周报收入趋势汇总（交互版）</h1>
  <div class="meta">数据 cutoff: 截至 W''' + str(target_ws[-1][1] if target_ws else '--') + '''（最新完整周）&nbsp;·&nbsp;生成时间: ''' + now + '''&nbsp;·&nbsp;鼠标悬停可查看各系列数值</div>
</header>

<nav>''' + nav_items + '''</nav>

<section class="filters">
  <div class="group">
    <label>月度范围:</label>
    <select id="mStart"></select>
    <span>~</span>
    <select id="mEnd"></select>
  </div>
  <div class="group">
    <label>周度范围:</label>
    <select id="wStart"></select>
    <span>~</span>
    <select id="wEnd"></select>
  </div>
  <button id="applyBtn">应用筛选</button>
  <button id="resetBtn" class="reset">重置默认</button>
  <span class="tip">季度固定为最近 ''' + str(len(target_qs)) + ''' 个；筛选实时影响左右两图</span>
</section>

<section class="summary">
  <h2>YoY 同比一览（日均口径，等天数同比 · 默认范围）</h2>
  <table>
    <thead><tr><th>维度</th>''' + period_headers + '''</tr></thead>
    <tbody>''' + ''.join(yoy_table_rows) + '''</tbody>
  </table>
</section>

''' + chart_blocks + '''

<footer>由 weekly_revenue_chart.py 自动生成 · ECharts 5.5</footer>

<script>
const PAYLOAD = ''' + payload_json + ''';

let CURRENT_M_RANGE = PAYLOAD.defaultMRange.slice();
let CURRENT_W_RANGE = PAYLOAD.defaultWRange.slice();

function getXLabels() {
  const labels = [];
  const lastQ = PAYLOAD.qMeta.length - 1;
  PAYLOAD.qMeta.forEach((q, i) => {
    if (i === lastQ) {
      labels.push(q.label + '\\n(' + q.ndays + '天)');
    } else {
      labels.push(q.label);
    }
  });
  for (let i = CURRENT_M_RANGE[0]; i <= CURRENT_M_RANGE[1]; i++) {
    labels.push(PAYLOAD.mMeta[i].label);
  }
  for (let i = CURRENT_W_RANGE[0]; i <= CURRENT_W_RANGE[1]; i++) {
    labels.push(PAYLOAD.wMeta[i].label);
  }
  return labels;
}

function getNQ() { return PAYLOAD.qMeta.length; }
function getNM() { return CURRENT_M_RANGE[1] - CURRENT_M_RANGE[0] + 1; }
function getNW() { return CURRENT_W_RANGE[1] - CURRENT_W_RANGE[0] + 1; }

function sliceStackData(s) {
  const m = (s.mData || []).slice(CURRENT_M_RANGE[0], CURRENT_M_RANGE[1] + 1);
  const w = (s.wData || []).slice(CURRENT_W_RANGE[0], CURRENT_W_RANGE[1] + 1);
  return (s.qData || []).concat(m).concat(w);
}

function sliceYoyData(y) {
  const m = (y.mData || []).slice(CURRENT_M_RANGE[0], CURRENT_M_RANGE[1] + 1);
  const w = (y.wData || []).slice(CURRENT_W_RANGE[0], CURRENT_W_RANGE[1] + 1);
  return (y.qData || []).concat(m).concat(w);
}

function buildLeftOption(cfg) {
  const xLabels = getXLabels();
  const stackData = cfg.stack.map(s => ({ ...s, data: sliceStackData(s) }));
  const totals = xLabels.map((_, i) =>
    stackData.reduce((a, s) => a + (s.data[i] || 0), 0)
  );
  const n = xLabels.length;
  const cum = new Array(n).fill(0);
  const series = stackData.map((s) => ({
    name: s.name,
    type: 'line',
    stack: 'total',
    smooth: true,
    showSymbol: false,
    areaStyle: { color: s.color, opacity: 0.9 },
    lineStyle: { width: 1.2, color: '#fff' },
    itemStyle: { color: s.color },
    emphasis: { focus: 'series' },
    data: s.data
  }));
  stackData.forEach((s, idx) => {
    const isYellow = (s.color === '#F4C842' || s.color === '#FFD966');
    const labelColor = isYellow ? '#222' : '#fff';
    const data = [];
    for (let i = 0; i < n; i++) {
      const v = s.data[i] || 0;
      const tot = totals[i] || 0;
      const mid = cum[i] + v / 2;
      cum[i] += v;
      const pct = tot > 0 ? v / tot * 100 : 0;
      if (tot <= 0 || pct < 4.5) {
        data.push([i, mid, '']);
      } else {
        data.push([i, mid, pct.toFixed(0) + '%']);
      }
    }
    series.push({
      name: s.name + '_label',
      type: 'scatter',
      symbolSize: 0,
      silent: true,
      tooltip: { show: false },
      legendHoverLink: false,
      data: data,
      label: {
        show: true,
        position: 'inside',
        formatter: (p) => p.value[2],
        fontSize: 11,
        fontWeight: 'bold',
        lineHeight: 13,
        color: labelColor
      },
      z: 20
    });
  });
  if (series.length > 0) {
    series[stackData.length - 1].markPoint = {
      symbol: 'rect', symbolSize: [1, 1], silent: true,
      itemStyle: { color: 'transparent', borderColor: 'transparent' },
      label: {
        show: true,
        formatter: (p) => totals[p.data.coord[0]].toFixed(0),
        fontWeight: 'bold', fontSize: 11, color: '#222',
        offset: [0, -10]
      },
      data: xLabels.map((_, i) => ({ coord: [i, totals[i]] }))
    };
  }
  return {
    title: { text: cfg.title_left, left: 'center', textStyle: { fontSize: 14 } },
    tooltip: {
      trigger: 'axis',
      axisPointer: { type: 'cross', label: { backgroundColor: '#6a7985' } },
      formatter: (params) => {
        if (!params || !params.length) return '';
        const real = params.filter(p => !String(p.seriesName).endsWith('_label'));
        if (!real.length) return '';
        let total = 0;
        real.forEach(p => { total += (p.value || 0); });
        let html = '<b>' + real[0].axisValueLabel.replace(/\\n/g, ' \\u00b7 ') + '</b>';
        html += '<br/>合计: <b>' + total.toFixed(1) + ' 万元</b>';
        real.slice().reverse().forEach(p => {
          const pct = total > 0 ? (p.value / total * 100).toFixed(1) : '0.0';
          html += '<br/>' + p.marker + p.seriesName + ': ' +
                  (p.value || 0).toFixed(1) + ' (' + pct + '%)';
        });
        return html;
      }
    },
    legend: {
      bottom: 0, itemGap: 18, textStyle: { fontSize: 12 },
      data: stackData.map(s => s.name)
    },
    grid: { top: 50, left: 60, right: 30, bottom: 60 },
    xAxis: {
      type: 'category', data: xLabels, boundaryGap: false,
      axisLabel: { interval: 0, fontSize: 11, lineHeight: 14 },
      splitLine: { show: false }
    },
    yAxis: {
      type: 'value', name: '日均收入 (万元)', nameTextStyle: { fontSize: 11 },
      axisLine: { show: false }, splitLine: { lineStyle: { color: '#eee' } }
    },
    series: series
  };
}

function buildRightOption(cfg) {
  const xLabels = getXLabels();
  const series = cfg.yoy.map(y => ({
    name: y.name,
    type: 'line',
    smooth: true,
    showSymbol: true,
    symbol: 'circle',
    symbolSize: y.primary ? 9 : 6,
    lineStyle: {
      color: y.color,
      width: y.lineWidth,
      type: y.lineStyle === '--' ? 'dashed' : 'solid'
    },
    itemStyle: { color: y.color },
    z: y.primary ? 10 : 5,
    emphasis: { focus: 'series' },
    labelLayout: { hideOverlap: true, moveOverlap: 'shiftY' },
    label: {
      show: true,
      position: 'top',
      distance: y.primary ? 8 : 12,
      formatter: (p) => p.value === null || p.value === undefined ? '' : p.value.toFixed(0) + '%',
      fontSize: y.primary ? 11 : 9,
      fontWeight: y.primary ? 'bold' : 'normal',
      color: y.color
    },
    data: sliceYoyData(y)
  }));
  return {
    title: { text: cfg.title_right, left: 'center', textStyle: { fontSize: 14 } },
    tooltip: {
      trigger: 'axis',
      axisPointer: { type: 'cross', label: { backgroundColor: '#6a7985' } },
      formatter: (params) => {
        if (!params || !params.length) return '';
        let html = '<b>' + params[0].axisValueLabel.replace(/\\n/g, ' \\u00b7 ') + '</b>';
        params.forEach(p => {
          const v = (p.value === null || p.value === undefined) ? 'N/A' : p.value.toFixed(1) + '%';
          html += '<br/>' + p.marker + p.seriesName + ': <b>' + v + '</b>';
        });
        return html;
      }
    },
    legend: { bottom: 0, itemGap: 18, textStyle: { fontSize: 12 } },
    grid: { top: 50, left: 60, right: 30, bottom: 60 },
    xAxis: {
      type: 'category', data: xLabels, boundaryGap: true,
      axisLabel: { interval: 0, fontSize: 11, lineHeight: 14 },
      splitLine: { show: false }
    },
    yAxis: {
      type: 'value', name: 'YoY (%)', nameTextStyle: { fontSize: 11 },
      axisLine: { show: false }, splitLine: { lineStyle: { color: '#eee' } }
    },
    series: series
  };
}

function addSeparators(option) {
  if (!option.series || option.series.length === 0) return option;
  const xLabels = getXLabels();
  const nQ = getNQ();
  const nM = getNM();
  const lines = [];
  if (nQ > 0 && nQ <= xLabels.length) {
    lines.push({ xAxis: xLabels[nQ - 1] });
  }
  if (nQ + nM <= xLabels.length && nM > 0) {
    lines.push({ xAxis: xLabels[nQ + nM - 1] });
  }
  option.series[0].markLine = {
    silent: true, symbol: 'none',
    lineStyle: { color: '#bbb', type: 'dashed', width: 1 },
    label: { show: false },
    data: lines
  };
  return option;
}

// 每组一个 instance pair + 当前 activeIdx
const groupInstances = [];

function renderGroup(gi) {
  const { cL, cR, group, activeIdx } = groupInstances[gi];
  const cfg = group.options[activeIdx];
  cL.setOption(addSeparators(buildLeftOption(cfg)), true);
  cR.setOption(addSeparators(buildRightOption(cfg)), true);
  // 渲染归因文本
  const attrEl = document.getElementById(group.id + '_attr');
  if (attrEl) {
    const w = cfg.attrW || '';
    const m = cfg.attrM || '';
    if (!w && !m) {
      attrEl.classList.add('empty');
      attrEl.innerHTML = '';
    } else {
      attrEl.classList.remove('empty');
      let h = '';
      if (w) h += '<div class="row"><span class="tag">最新周</span><span class="text">' + w + '</span></div>';
      if (m) h += '<div class="row"><span class="tag">最新月</span><span class="text">' + m + '</span></div>';
      attrEl.innerHTML = h;
    }
  }
  // 渲染目标&达成表格
  const tgtEl = document.getElementById(group.id + '_tgt');
  if (tgtEl) {
    const rows = cfg.targetRows || [];
    const col1 = cfg.targetCol1 || '收入类型';
    if (!rows.length) {
      tgtEl.innerHTML = '';
    } else {
      const fmt = (v) => (v === null || v === undefined) ? '—' : (v.toFixed(1) + '%');
      let trs = '';
      for (const r of rows) {
        const yt = r.yoy_target;
        const ya = r.yoy_actual;
        const ytText = fmt(yt);
        const yaText = fmt(ya);
        let pill = '<span class="pill na">—</span>';
        let actCls = 'num actual';
        if (yt !== null && yt !== undefined && ya !== null && ya !== undefined) {
          if (ya >= yt) { pill = '<span class="pill ok">✓</span>'; actCls += ' win'; }
          else          { pill = '<span class="pill bad">✗</span>'; actCls += ' lose'; }
        }
        // 总收入行加重
        const isTotal = (r.label === '总收入');
        const trClass = isTotal ? ' class="total"' : '';
        trs += '<tr' + trClass + '>' +
               '<td class="lbl">' + r.label + '</td>' +
               '<td class="num target">' + ytText + '</td>' +
               '<td class="' + actCls + '">' + yaText + '</td>' +
               '<td class="status">' + pill + '</td></tr>';
      }
      const mLabel = group.targetMonthLabel || '';
      tgtEl.innerHTML =
        '<div class="tgt-title"><span>目标 vs 实际（YoY）</span>' +
        '<span class="badge-month">' + mLabel + ' 序时</span></div>' +
        '<table class="tgt-table">' +
        '<thead><tr><th>' + col1 + '</th><th>目标</th><th>实际</th><th class="col-status">达成</th></tr></thead>' +
        '<tbody>' + trs + '</tbody></table>' +
        '<div class="tgt-foot">绿色✓=达成 / 红色✗=未达成</div>';
    }
  }
}

function renderAll() {
  groupInstances.forEach((_, gi) => renderGroup(gi));
  if (typeof window.renderGroup6 === 'function') window.renderGroup6();
}

function fillSelect(elId, items, formatter, defaultIdx) {
  const el = document.getElementById(elId);
  el.innerHTML = '';
  items.forEach((it, i) => {
    const opt = document.createElement('option');
    opt.value = i;
    opt.textContent = formatter(it, i);
    el.appendChild(opt);
  });
  el.value = defaultIdx;
}

function refreshSelectsState() {
  document.getElementById('mStart').value = CURRENT_M_RANGE[0];
  document.getElementById('mEnd').value = CURRENT_M_RANGE[1];
  document.getElementById('wStart').value = CURRENT_W_RANGE[0];
  document.getElementById('wEnd').value = CURRENT_W_RANGE[1];
}

function setupFilters() {
  const fmtMonth = (m) => {
    const yy = String(m.key).slice(2, 4);
    const mm = String(m.key % 100).padStart(2, '0');
    return yy + '/' + mm + ' (' + m.label + ')';
  };
  const fmtWeek = (w) => 'W' + w.iso + ' (' + String(w.year).slice(2) + ')';

  fillSelect('mStart', PAYLOAD.mMeta, fmtMonth, CURRENT_M_RANGE[0]);
  fillSelect('mEnd',   PAYLOAD.mMeta, fmtMonth, CURRENT_M_RANGE[1]);
  fillSelect('wStart', PAYLOAD.wMeta, fmtWeek,  CURRENT_W_RANGE[0]);
  fillSelect('wEnd',   PAYLOAD.wMeta, fmtWeek,  CURRENT_W_RANGE[1]);

  document.getElementById('applyBtn').addEventListener('click', () => {
    let ms = parseInt(document.getElementById('mStart').value);
    let me = parseInt(document.getElementById('mEnd').value);
    let ws = parseInt(document.getElementById('wStart').value);
    let we = parseInt(document.getElementById('wEnd').value);
    if (ms > me) { [ms, me] = [me, ms]; }
    if (ws > we) { [ws, we] = [we, ws]; }
    CURRENT_M_RANGE = [ms, me];
    CURRENT_W_RANGE = [ws, we];
    refreshSelectsState();
    renderAll();
  });
  document.getElementById('resetBtn').addEventListener('click', () => {
    CURRENT_M_RANGE = PAYLOAD.defaultMRange.slice();
    CURRENT_W_RANGE = PAYLOAD.defaultWRange.slice();
    refreshSelectsState();
    renderAll();
  });
}

document.addEventListener('DOMContentLoaded', () => {
  setupFilters();
  PAYLOAD.groups.forEach((group, gi) => {
    const elL = document.getElementById(group.id + '_left');
    const elR = document.getElementById(group.id + '_right');
    if (!elL || !elR) return;
    const cL = echarts.init(elL);
    const cR = echarts.init(elR);
    groupInstances.push({ cL, cR, group, activeIdx: 0 });
    // 绑定筛选器事件（单/双 select 都支持）
    const sels = document.querySelectorAll('select[data-group="' + group.id + '"]');
    if (sels.length === 1) {
      const sel = sels[0];
      sel.addEventListener('change', () => {
        groupInstances[gi].activeIdx = parseInt(sel.value);
        renderGroup(gi);
      });
    } else if (sels.length >= 2) {
      // 双 select：拼接 key 'metric|industry'
      const resolve = () => {
        const v1 = document.querySelector('select[data-group="' + group.id + '"][data-filter="1"]').value;
        const v2 = document.querySelector('select[data-group="' + group.id + '"][data-filter="2"]').value;
        const composite = v1 + '|' + v2;
        const idx = group.options.findIndex(o => o.key === composite);
        if (idx >= 0) {
          groupInstances[gi].activeIdx = idx;
          renderGroup(gi);
        }
      };
      sels.forEach(s => s.addEventListener('change', resolve));
    }
  });
  renderAll();
  window.addEventListener('resize', () => {
    groupInstances.forEach(({ cL, cR }) => { cL.resize(); cR.resize(); });
    if (window._g6L) window._g6L.resize();
    if (window._g6R) window._g6R.resize();
  });

  // ========== Group 6: 收入类型 × 类目 ==========
  if (PAYLOAD.categoryData) {
    const CD = PAYLOAD.categoryData;
    const NMETRIC = 6;
    const G6_COLORS = [
      '#5470c6','#91cc75','#fac858','#ee6666','#73c0de','#3ba272',
      '#fc8452','#9a60b4','#ea7ccc','#4992ff','#7cffb2','#fddd60',
      '#ff6e76','#58d9f9','#05c091','#ff8a45'
    ];
    const elL = document.getElementById('group_6_left');
    const elR = document.getElementById('group_6_right');
    if (elL && elR) {
      window._g6L = echarts.init(elL, null, {devicePixelRatio: 2});
      window._g6R = echarts.init(elR, null, {devicePixelRatio: 2});
      // populate dropdowns
      const selMetric = document.getElementById('g6_metric');
      const selInd = document.getElementById('g6_industry');
      const selTeam = document.getElementById('g6_team');
      const selL1 = document.getElementById('g6_l1');
      CD.metricLabels.forEach((m, i) => {
        const o = document.createElement('option'); o.value = i; o.textContent = m;
        selMetric.appendChild(o);
      });
      ['全部'].concat(CD.industries).forEach(v => {
        const o = document.createElement('option'); o.value = v; o.textContent = v;
        selInd.appendChild(o);
      });
      ['全部'].concat(CD.teams).forEach(v => {
        const o = document.createElement('option'); o.value = v; o.textContent = v;
        selTeam.appendChild(o);
      });

      function refreshL1Options() {
        const ind = selInd.value;
        selL1.innerHTML = '';
        const o0 = document.createElement('option'); o0.value = '全部'; o0.textContent = '全部';
        selL1.appendChild(o0);
        let l1List = [];
        if (ind === '全部') {
          const s = new Set();
          Object.values(CD.indL1).forEach(arr => arr.forEach(x => s.add(x)));
          l1List = Array.from(s).sort();
        } else {
          l1List = CD.indL1[ind] || [];
        }
        l1List.forEach(l => {
          const o = document.createElement('option'); o.value = l; o.textContent = l;
          selL1.appendChild(o);
        });
      }
      refreshL1Options();

      function renderGroup6() {
        const mi = parseInt(selMetric.value);
        const ind = selInd.value;
        const team = selTeam.value;
        const l1Val = selL1.value;
        // Filter records
        let recs = CD.records;
        if (ind !== '全部') recs = recs.filter(r => r.ind === ind);
        if (team !== '全部') recs = recs.filter(r => r.team === team);
        // Determine stack dimension: l1Val='全部' → stack by L1; else stack by L2 within that L1
        let groupKey, groupLabel;
        if (l1Val === '全部') {
          groupKey = 'l1'; groupLabel = '一级类目';
        } else {
          recs = recs.filter(r => r.l1 === l1Val);
          groupKey = 'l2'; groupLabel = '二级类目';
        }
        // Determine granularity info
        const nQ = CD.qNdCur.length;
        const nM = CD.mNdCur.length;
        const nW = CD.wNdCur.length;
        // Build x-axis labels (Q + M slice + W slice)
        const mStart = CURRENT_M_RANGE[0], mEnd = CURRENT_M_RANGE[1];
        const wStart = CURRENT_W_RANGE[0], wEnd = CURRENT_W_RANGE[1];
        const xLabels = [];
        PAYLOAD.qMeta.forEach((q, i) => {
          if (i === nQ - 1) xLabels.push(q.label + '\\n(' + q.ndays + '天)');
          else xLabels.push(q.label);
        });
        for (let i = mStart; i <= mEnd; i++) xLabels.push(PAYLOAD.mMeta[i].label);
        for (let i = wStart; i <= wEnd; i++) xLabels.push(PAYLOAD.wMeta[i].label);
        const totalPts = xLabels.length;
        // nDays per x-point (cur)
        const ndCur = [];
        CD.qNdCur.forEach(n => ndCur.push(n));
        for (let i = mStart; i <= mEnd; i++) ndCur.push(CD.mNdCur[i]);
        for (let i = wStart; i <= wEnd; i++) ndCur.push(CD.wNdCur[i]);
        // nDays per x-point (prev)
        const ndPrev = [];
        CD.qNdPrev.forEach(n => ndPrev.push(n));
        for (let i = mStart; i <= mEnd; i++) ndPrev.push(CD.mNdPrev[i]);
        for (let i = wStart; i <= wEnd; i++) ndPrev.push(CD.wNdPrev[i]);
        // Group records by groupKey and sum metric values
        const groups = {};
        recs.forEach(r => {
          const gk = r[groupKey];
          if (!groups[gk]) groups[gk] = { cur: new Array(totalPts).fill(0), prev: new Array(totalPts).fill(0) };
          const g = groups[gk];
          // Q portion
          for (let qi = 0; qi < nQ; qi++) {
            g.cur[qi] += r.qC[qi * NMETRIC + mi];
            g.prev[qi] += r.qP[qi * NMETRIC + mi];
          }
          // M portion (sliced)
          for (let i = mStart; i <= mEnd; i++) {
            const xi = nQ + (i - mStart);
            g.cur[xi] += r.mC[i * NMETRIC + mi];
            g.prev[xi] += r.mP[i * NMETRIC + mi];
          }
          // W portion (sliced)
          for (let i = wStart; i <= wEnd; i++) {
            const xi = nQ + (mEnd - mStart + 1) + (i - wStart);
            g.cur[xi] += r.wC[i * NMETRIC + mi];
            g.prev[xi] += r.wP[i * NMETRIC + mi];
          }
        });
        // Convert to daily avg (万元)
        const groupNames = Object.keys(groups).sort((a, b) => {
          const sa = groups[a].cur.reduce((s, v) => s + v, 0);
          const sb = groups[b].cur.reduce((s, v) => s + v, 0);
          return sb - sa;
        });
        const stackSeries = [];
        const totals = new Array(totalPts).fill(0);
        groupNames.forEach((gn, idx) => {
          const daily = groups[gn].cur.map((v, i) => ndCur[i] > 0 ? +(v / ndCur[i] / 10000).toFixed(2) : 0);
          daily.forEach((v, i) => { totals[i] += v; });
          stackSeries.push({
            name: gn,
            type: 'line', stack: 'catTotal', smooth: true, showSymbol: false,
            areaStyle: { color: G6_COLORS[idx % G6_COLORS.length], opacity: 0.85 },
            lineStyle: { width: 1, color: '#fff' },
            itemStyle: { color: G6_COLORS[idx % G6_COLORS.length] },
            emphasis: { focus: 'series' },
            data: daily
          });
        });
        // Total line on top via markPoint
        if (stackSeries.length > 0) {
          stackSeries[stackSeries.length - 1].markPoint = {
            symbol: 'rect', symbolSize: [1,1], silent: true,
            itemStyle: { color: 'transparent', borderColor: 'transparent' },
            label: { show: true, formatter: (p) => totals[p.data.coord[0]].toFixed(0),
                     fontWeight: 'bold', fontSize: 11, color: '#222', offset: [0,-10] },
            data: xLabels.map((_, i) => ({ coord: [i, totals[i]] }))
          };
        }
        const metricName = CD.metricLabels[mi];
        const leftOption = {
          title: { text: metricName + '日均收入 · ' + groupLabel + '结构', left: 'center', textStyle: { fontSize: 14 } },
          tooltip: {
            trigger: 'axis', axisPointer: { type: 'cross', label: { backgroundColor: '#6a7985' } },
            formatter: (params) => {
              if (!params || !params.length) return '';
              let total = 0;
              params.forEach(p => { total += (p.value || 0); });
              let h = '<b>' + params[0].axisValueLabel.replace(/\\n/g,' · ') + '</b>';
              h += '<br/>合计: <b>' + total.toFixed(1) + ' 万元</b>';
              params.slice().reverse().forEach(p => {
                const pct = total > 0 ? (p.value / total * 100).toFixed(1) : '0.0';
                h += '<br/>' + p.marker + p.seriesName + ': ' + (p.value||0).toFixed(1) + ' (' + pct + '%)';
              });
              return h;
            }
          },
          legend: { bottom: 0, itemGap: 12, textStyle: { fontSize: 11 }, type: 'scroll' },
          grid: { top: 50, left: 60, right: 30, bottom: 80 },
          xAxis: { type: 'category', data: xLabels, boundaryGap: false,
                   axisLabel: { interval: 0, fontSize: 11, lineHeight: 14 }, splitLine: { show: false } },
          yAxis: { type: 'value', name: '日均 (万元)', nameTextStyle: { fontSize: 11 },
                   axisLine: { show: false }, splitLine: { lineStyle: { color: '#eee' } } },
          series: stackSeries
        };
        // Separators (Q|M|W boundaries)
        const nMSliced = mEnd - mStart + 1;
        const sepLines = [];
        if (nQ > 0 && nQ <= totalPts) sepLines.push({ xAxis: xLabels[nQ - 1] });
        if (nQ + nMSliced <= totalPts && nMSliced > 0) sepLines.push({ xAxis: xLabels[nQ + nMSliced - 1] });
        if (sepLines.length && stackSeries.length) {
          stackSeries[0].markLine = { silent: true, symbol: 'none',
            lineStyle: { color: '#bbb', type: 'dashed', width: 1 }, label: { show: false }, data: sepLines };
        }
        window._g6L.setOption(leftOption, true);

        // Right chart: YoY
        // Compute overall cur/prev totals per period, then YoY
        const overallCur = new Array(totalPts).fill(0);
        const overallPrev = new Array(totalPts).fill(0);
        groupNames.forEach(gn => {
          const g = groups[gn];
          for (let i = 0; i < totalPts; i++) {
            overallCur[i] += g.cur[i];
            overallPrev[i] += g.prev[i];
          }
        });
        const yoyData = overallCur.map((c, i) => {
          const p = overallPrev[i];
          if (p <= 0) return null;
          return +((c / p - 1) * 100).toFixed(1);
        });
        // Per-group YoY series
        const groupYoySeries = [];
        groupNames.forEach((gn, idx) => {
          const g = groups[gn];
          const data = g.cur.map((c, i) => {
            const p = g.prev[i];
            if (p <= 0) return null;
            return +((c / p - 1) * 100).toFixed(1);
          });
          groupYoySeries.push({
            name: gn,
            type: 'line', smooth: true, showSymbol: false, symbolSize: 5,
            lineStyle: { color: G6_COLORS[idx % G6_COLORS.length], width: 1.5, opacity: 0.85 },
            itemStyle: { color: G6_COLORS[idx % G6_COLORS.length] },
            label: { show: false },
            emphasis: { focus: 'series', lineStyle: { width: 3 } },
            data: data
          });
        });
        // Overall total YoY (emphasized, on top)
        const totalYoySeries = {
          name: '总体 YoY',
          type: 'line', smooth: true, showSymbol: true, symbol: 'circle', symbolSize: 8,
          z: 10,
          lineStyle: { color: '#1a1a2e', width: 3 },
          itemStyle: { color: '#1a1a2e' },
          label: { show: true, position: 'top', distance: 8,
                   formatter: (p) => p.value === null ? '' : p.value.toFixed(0) + '%',
                   fontSize: 11, fontWeight: 'bold', color: '#1a1a2e' },
          emphasis: { focus: 'series', lineStyle: { width: 4 } },
          data: yoyData
        };
        // Add separators to total series
        if (sepLines.length) {
          totalYoySeries.markLine = { silent: true, symbol: 'none',
            lineStyle: { color: '#bbb', type: 'dashed', width: 1 }, label: { show: false }, data: sepLines };
        }
        const allYoySeries = [totalYoySeries].concat(groupYoySeries);
        const rightOption = {
          title: { text: metricName + ' YoY 同比 · ' + groupLabel, left: 'center', textStyle: { fontSize: 14 } },
          tooltip: {
            trigger: 'axis', axisPointer: { type: 'cross', label: { backgroundColor: '#6a7985' } },
            formatter: (params) => {
              if (!params || !params.length) return '';
              let h = '<b>' + params[0].axisValueLabel.replace(/\\n/g,' · ') + '</b>';
              const sorted = params.slice().sort((a,b) => {
                const av = (a.value === null || a.value === undefined) ? -Infinity : a.value;
                const bv = (b.value === null || b.value === undefined) ? -Infinity : b.value;
                return bv - av;
              });
              sorted.forEach(p => {
                const v = (p.value === null || p.value === undefined) ? 'N/A' : p.value.toFixed(1) + '%';
                const isTotal = p.seriesName === '总体 YoY';
                h += '<br/>' + p.marker + (isTotal ? '<b>' + p.seriesName + '</b>' : p.seriesName) + ': <b>' + v + '</b>';
              });
              return h;
            }
          },
          legend: { bottom: 0, itemGap: 10, textStyle: { fontSize: 11 }, type: 'scroll' },
          grid: { top: 50, left: 60, right: 30, bottom: 80 },
          xAxis: { type: 'category', data: xLabels, boundaryGap: true,
                   axisLabel: { interval: 0, fontSize: 11, lineHeight: 14 }, splitLine: { show: false } },
          yAxis: { type: 'value', name: 'YoY (%)', nameTextStyle: { fontSize: 11 },
                   axisLine: { show: false }, splitLine: { lineStyle: { color: '#eee' } } },
          series: allYoySeries
        };
        window._g6R.setOption(rightOption, true);
      }

      window.renderGroup6 = renderGroup6;
      selInd.addEventListener('change', () => { refreshL1Options(); renderGroup6(); });
      selMetric.addEventListener('change', renderGroup6);
      selTeam.addEventListener('change', renderGroup6);
      selL1.addEventListener('change', renderGroup6);
      renderGroup6();
    }
  }

  // ========== Group 7 & 8: 收入达成子弹图 ==========
  if (PAYLOAD.bulletData || PAYLOAD.directSalesBulletData) {
    const _srcBD = PAYLOAD.bulletData || PAYLOAD.directSalesBulletData;
    const BT_DAYS = _srcBD.days, BT_TOTAL = _srcBD.totalDays;

    function btFmt(v) {
      return Math.round(v).toLocaleString('en-US');
    }

    function buildBtRow(nameHTML, nameClass, actual, monthly, color, fmtFn) {
      var fmt = fmtFn || btFmt;
      if (monthly <= 0) {
        return '<div class="bt-row">' +
          '<div class="bt-name ' + nameClass + '">' + nameHTML + '</div>' +
          '<div class="bt-bar"><div class="bt-track"></div>' +
            '<span style="position:absolute;left:8px;top:17px;font-size:11px;color:#999;">目标为 0</span>' +
          '</div>' +
          '<div class="bt-sum" style="opacity:0.4;">' +
            '<div class="bt-sum-line"><span style="color:#999;">月目标</span> <b>0</b></div>' +
            '<div class="bt-sum-line"><span style="color:#999;">达成</span> <b>' + fmt(actual) + '</b></div>' +
          '</div></div>';
      }
      var seq = monthly * BT_DAYS / BT_TOTAL;
      var actualPct = Math.min(actual / monthly * 100, 100);
      var seqPct = Math.min(seq / monthly * 100, 100);
      var achRate = (actual / seq * 100).toFixed(1);
      var cumRate = (actual / monthly * 100).toFixed(1);
      var cumCls = cumRate >= 100 ? 'bt-pct-g' : cumRate >= 80 ? 'bt-pct-w' : 'bt-pct-b';
      var gapVal = actual - seq;
      var gapStr = (gapVal >= 0 ? '+' : '') + fmt(gapVal);
      var gapCls = gapVal >= 0 ? 'bt-gap-pos' : 'bt-gap-neg';
      var midPct = (actualPct + seqPct) / 2;
      var fillText = fmt(actual) + ' (' + achRate + '%)';
      var fillStyle = actualPct < 15 ? 'color:#1a1a2e;text-shadow:none;position:absolute;left:calc(100% + 6px);' : '';

      return '<div class="bt-row">' +
        '<div class="bt-name ' + nameClass + '">' + nameHTML + '</div>' +
        '<div class="bt-bar">' +
          '<div class="bt-track">' +
            '<div class="bt-fill" style="width:' + actualPct + '%;background:' + color + ';">' +
              '<span class="bt-fill-txt" style="' + fillStyle + '">' + fillText + '</span>' +
            '</div>' +
          '</div>' +
          '<div class="bt-vline" style="left:' + seqPct + '%;"></div>' +
          '<div class="bt-seq-lbl" style="left:' + seqPct + '%;">' + fmt(seq) + '</div>' +
          '<div class="bt-gap ' + gapCls + '" style="left:' + midPct + '%;">' + gapStr + '</div>' +
        '</div>' +
        '<div class="bt-sum">' +
          '<div class="bt-sum-line"><span style="color:#999;">月目标</span> <b>' + fmt(monthly) + '</b></div>' +
          '<div class="bt-sum-line"><span style="color:#999;">累计达成</span> <b>' + fmt(actual) + '</b></div>' +
          '<div class="bt-sum-line"><span style="color:#999;">达成率</span><span class="bt-pct ' + cumCls + '">' + cumRate + '%</span></div>' +
        '</div></div>';
    }

    function buildBtRowDual(nameHTML, nameClass, actual, tgt100, tgtAct, color, fmtFn) {
      // Dual-target bullet row: tgt100=人力100%目标(较小), tgtAct=实际人力目标(较大)
      var fmt = fmtFn || btFmt;
      if (tgtAct <= 0 && tgt100 <= 0) {
        return '<div class="bt-row">' +
          '<div class="bt-name ' + nameClass + '">' + nameHTML + '</div>' +
          '<div class="bt-bar"><div class="bt-track"></div>' +
            '<span style="position:absolute;left:8px;top:17px;font-size:11px;color:#999;">目标为 0</span>' +
          '</div>' +
          '<div class="bt-sum" style="opacity:0.4;">' +
            '<div class="bt-sum-line"><span style="color:#999;">月目标</span> <b>0</b></div>' +
            '<div class="bt-sum-line"><span style="color:#999;">达成</span> <b>' + fmt(actual) + '</b></div>' +
          '</div></div>';
      }
      // 以较大目标(tgtAct)为100%刻度
      var maxTgt = Math.max(tgtAct, tgt100);
      var seq100 = tgt100 * BT_DAYS / BT_TOTAL;
      var seqAct = tgtAct * BT_DAYS / BT_TOTAL;
      var actualPct = Math.min(actual / maxTgt * 100, 100);
      var seq100Pct = Math.min(seq100 / maxTgt * 100, 100);
      var seqActPct = Math.min(seqAct / maxTgt * 100, 100);
      var achRate = seq100 > 0 ? (actual / seq100 * 100).toFixed(1) : '—';
      var cumRate = tgt100 > 0 ? (actual / tgt100 * 100).toFixed(1) : '—';
      var cumCls = parseFloat(cumRate) >= 100 ? 'bt-pct-g' : parseFloat(cumRate) >= 80 ? 'bt-pct-w' : 'bt-pct-b';
      var gapVal = actual - seq100;
      var gapStr = (gapVal >= 0 ? '+' : '') + fmt(gapVal);
      var gapCls = gapVal >= 0 ? 'bt-gap-pos' : 'bt-gap-neg';
      var midPct = (actualPct + seq100Pct) / 2;
      var fillText = fmt(actual) + ' (' + achRate + '%)';
      var fillStyle = actualPct < 15 ? 'color:#1a1a2e;text-shadow:none;position:absolute;left:calc(100% + 6px);' : '';

      return '<div class="bt-row" style="padding-top:18px;padding-bottom:18px;">' +
        '<div class="bt-name ' + nameClass + '">' + nameHTML + '</div>' +
        '<div class="bt-bar">' +
          '<div class="bt-track">' +
            '<div class="bt-fill" style="width:' + actualPct + '%;background:' + color + ';">' +
              '<span class="bt-fill-txt" style="' + fillStyle + '">' + fillText + '</span>' +
            '</div>' +
          '</div>' +
          /* 目标线1: 100%人力(橙色) — 标签在上方 */
          '<div class="bt-vline" style="left:' + seq100Pct + '%;border-left-color:#E89A3C;"></div>' +
          '<div style="position:absolute;left:' + seq100Pct + '%;top:-16px;transform:translateX(-50%);white-space:nowrap;font-size:10px;font-weight:600;color:#E89A3C;text-align:center;">' + fmt(seq100) + ' <span style="font-size:9px;opacity:0.8;">100%人力</span></div>' +
          /* 目标线2: 实际人力(红色) — 标签在下方 */
          '<div class="bt-vline" style="left:' + seqActPct + '%;border-left-color:#D94A38;"></div>' +
          '<div style="position:absolute;left:' + seqActPct + '%;top:34px;transform:translateX(-50%);white-space:nowrap;font-size:10px;font-weight:600;color:#D94A38;text-align:center;">' + fmt(seqAct) + ' <span style="font-size:9px;opacity:0.8;">实际人力</span></div>' +
          '<div class="bt-gap ' + gapCls + '" style="left:' + midPct + '%;">' + gapStr + '</div>' +
        '</div>' +
        '<div class="bt-sum">' +
          '<div class="bt-sum-line"><span style="color:#E89A3C;">100%人力目标</span> <b>' + fmt(tgt100) + '</b></div>' +
          '<div class="bt-sum-line"><span style="color:#D94A38;">实际人力目标</span> <b>' + fmt(tgtAct) + '</b></div>' +
          '<div class="bt-sum-line"><span style="color:#999;">累计达成</span> <b>' + fmt(actual) + '</b></div>' +
          '<div class="bt-sum-line"><span style="color:#999;">达成率</span><span class="bt-pct ' + cumCls + '">' + cumRate + '%</span></div>' +
        '</div></div>';
    }

    function buildBtRowNoSeq(nameHTML, nameClass, actual, monthly, color, fmtFn) {
      // No sequence target line — direct actual vs monthly comparison
      var fmt = fmtFn || btFmt;
      if (monthly <= 0) {
        return '<div class="bt-row">' +
          '<div class="bt-name ' + nameClass + '">' + nameHTML + '</div>' +
          '<div class="bt-bar"><div class="bt-track"></div>' +
            '<span style="position:absolute;left:8px;top:17px;font-size:11px;color:#999;">目标为 0</span>' +
          '</div>' +
          '<div class="bt-sum" style="opacity:0.4;">' +
            '<div class="bt-sum-line"><span style="color:#999;">月目标</span> <b>0</b></div>' +
            '<div class="bt-sum-line"><span style="color:#999;">达成</span> <b>' + fmt(actual) + '</b></div>' +
          '</div></div>';
      }
      var actualPct = Math.min(actual / monthly * 100, 100);
      var cumRate = (actual / monthly * 100).toFixed(1);
      var cumCls = cumRate >= 100 ? 'bt-pct-g' : cumRate >= 80 ? 'bt-pct-w' : 'bt-pct-b';
      var fillText = fmt(actual) + ' (' + cumRate + '%)';
      var fillStyle = actualPct < 15 ? 'color:#1a1a2e;text-shadow:none;position:absolute;left:calc(100% + 6px);' : '';

      return '<div class="bt-row">' +
        '<div class="bt-name ' + nameClass + '">' + nameHTML + '</div>' +
        '<div class="bt-bar">' +
          '<div class="bt-track">' +
            '<div class="bt-fill" style="width:' + actualPct + '%;background:' + color + ';">' +
              '<span class="bt-fill-txt" style="' + fillStyle + '">' + fillText + '</span>' +
            '</div>' +
          '</div>' +
        '</div>' +
        '<div class="bt-sum">' +
          '<div class="bt-sum-line"><span style="color:#999;">月目标</span> <b>' + fmt(monthly) + '</b></div>' +
          '<div class="bt-sum-line"><span style="color:#999;">累计达成</span> <b>' + fmt(actual) + '</b></div>' +
          '<div class="bt-sum-line"><span style="color:#999;">达成率</span><span class="bt-pct ' + cumCls + '">' + cumRate + '%</span></div>' +
        '</div></div>';
    }

    // ── Group 7: 收入达成 ──
    if (PAYLOAD.bulletData) {
    const BD = PAYLOAD.bulletData;
    const BT_METRICS = [
      { name: '总收入',   color: '#5b9bd5', twoLine: false },
      { name: '新签年费', color: '#6baed6', twoLine: true },
      { name: '续签年费', color: '#7bbde0', twoLine: true },
      { name: '效果广告', color: '#8bcbe8', twoLine: true },
      { name: '品牌广告', color: '#99d4ee', twoLine: true },
      { name: '佣金收入', color: '#a8ddf2', twoLine: true }
    ];
    // 行业×团队组合（用于指标视角）
    const BT_COMBOS = [
      { ind: '医疗', team: '直营' },
      { ind: '医疗', team: '渠道' },
      { ind: '医疗', team: '电销' },
      { ind: '教培', team: '直营' },
      { ind: '教培', team: '渠道' },
      { ind: '教培', team: '电销' }
    ];

    // ── 维度视角 ──
    const g7Chart = document.getElementById('group_7_chart');
    const g7Ind = document.getElementById('g7_industry');
    const g7Team = document.getElementById('g7_team');
    const g7TitleDim = document.getElementById('g7_title_dim');

    function renderBulletDim() {
      if (!g7Chart) return;
      var ind = g7Ind ? g7Ind.value : 'all';
      var team = g7Team ? g7Team.value : 'all';
      var key = ind + '|' + team;
      var vals = BD.raw[key];
      var indL = ind === 'all' ? '全部行业' : ind;
      var teamL = team === 'all' ? '全部团队' : team;
      if (g7TitleDim) g7TitleDim.textContent = indL + ' · ' + teamL + ' — 各指标达成';
      if (!vals) {
        g7Chart.innerHTML = '<p style="color:#999;padding:20px 0;">无此组合数据（' + key + '）</p>';
        return;
      }
      var html = '';
      BT_METRICS.forEach(function(m, i) {
        var nameHTML = m.twoLine ? m.name.slice(0,2) + '<br>' + m.name.slice(2) : m.name;
        html += buildBtRow(nameHTML, '', vals[i][0], vals[i][1], m.color);
      });
      g7Chart.innerHTML = html;
    }

    // ── 指标视角 ──
    const g7ChartMetric = document.getElementById('group_7_chart_metric');
    const g7Metric = document.getElementById('g7_metric');
    const g7TitleMetric = document.getElementById('g7_title_metric');

    function renderBulletMetric() {
      if (!g7ChartMetric) return;
      var mi = g7Metric ? parseInt(g7Metric.value) : 0;
      var m = BT_METRICS[mi];
      if (g7TitleMetric) g7TitleMetric.textContent = m.name + ' — 行业×团队达成';
      var html = '', lastInd = '';
      BT_COMBOS.forEach(function(c) {
        if (c.ind !== lastInd) {
          lastInd = c.ind;
          html += '<div class="g7-section-label">' + c.ind + '</div>';
        }
        var key = c.ind + '|' + c.team;
        var vals = BD.raw[key];
        var nameHTML = c.team;
        if (!vals) {
          html += '<div class="bt-row" style="opacity:0.4;">' +
            '<div class="bt-name">' + nameHTML + '</div>' +
            '<div class="bt-bar"><div class="bt-track"></div>' +
              '<span style="position:absolute;left:8px;top:17px;font-size:11px;color:#999;">无数据</span>' +
            '</div>' +
            '<div class="bt-sum"></div></div>';
          return;
        }
        html += buildBtRow(nameHTML, '', vals[mi][0], vals[mi][1], m.color);
      });
      g7ChartMetric.innerHTML = html;
    }

    // ── Tab 切换 ──
    document.querySelectorAll('.g7-tab').forEach(function(tab) {
      tab.addEventListener('click', function() {
        document.querySelectorAll('.g7-tab').forEach(function(t) { t.classList.remove('active'); });
        document.querySelectorAll('.g7-panel').forEach(function(p) { p.classList.remove('active'); });
        tab.classList.add('active');
        var panelId = tab.getAttribute('data-panel');
        var panel = document.getElementById(panelId);
        if (panel) panel.classList.add('active');
      });
    });

    if (g7Ind) g7Ind.addEventListener('change', renderBulletDim);
    if (g7Team) g7Team.addEventListener('change', renderBulletDim);
    if (g7Metric) g7Metric.addEventListener('change', renderBulletMetric);
    renderBulletDim();
    renderBulletMetric();
    }  // end if PAYLOAD.bulletData

    // ── Group 8: 直营新签年费达成 ──
    if (PAYLOAD.directSalesBulletData) {
      const BD8 = PAYLOAD.directSalesBulletData;
      const BT_METRICS_8 = [
        { nameHTML: '年费<br>收入',    color: '#5b9bd5' },
        { nameHTML: '展业<br>人数',    color: '#6baed6', noSeq: true },
        { nameHTML: '人效签<br>约金额', color: '#7bbde0' },
        { nameHTML: '人效<br>签约量',  color: '#8bcbe8', decimal: 1 },
        { nameHTML: '年费<br>ARPU',    color: '#99d4ee', noSeq: true }
      ];
      const g8Chart = document.getElementById('group_8_chart');
      const g8Ind = document.getElementById('g8_industry');
      const g8Title = document.getElementById('g8_title');

      // 动态填充行业下拉
      if (g8Ind) {
        var opt0 = document.createElement('option');
        opt0.value = '合计'; opt0.textContent = '合计';
        g8Ind.appendChild(opt0);
        (BD8.industries || []).forEach(function(ind) {
          var opt = document.createElement('option');
          opt.value = ind; opt.textContent = ind;
          g8Ind.appendChild(opt);
        });
      }

      function renderBulletDirect() {
        if (!g8Chart) return;
        var ind = g8Ind ? g8Ind.value : '合计';
        var vals = BD8.raw[ind];
        if (g8Title) g8Title.textContent = ind + ' — 直营新签年费达成';
        if (!vals) {
          g8Chart.innerHTML = '<p style="color:#999;padding:20px 0;">无此行业数据（' + ind + '）</p>';
          return;
        }
        var html = '';
        var fmtDec1 = function(v) { var s = v.toFixed(1).split('.'); s[0] = s[0].replace(/\B(?=(\d{3})+(?!\d))/g, ','); return s.join('.'); };
        BT_METRICS_8.forEach(function(m, i) {
          var fmt = m.decimal ? fmtDec1 : null;
          if (m.noSeq) {
            // 无序时进度的指标（展业人数、年费ARPU）
            html += buildBtRowNoSeq(m.nameHTML, '', vals[i][0], vals[i][1], m.color, fmt);
          } else if (vals[i].length === 3) {
            // 双目标: [actual, tgt_100pct, tgt_actual_staffing]
            html += buildBtRowDual(m.nameHTML, '', vals[i][0], vals[i][1], vals[i][2], m.color, fmt);
          } else {
            html += buildBtRow(m.nameHTML, '', vals[i][0], vals[i][1], m.color, fmt);
          }
        });
        g8Chart.innerHTML = html;
      }
      if (g8Ind) g8Ind.addEventListener('change', renderBulletDirect);
      renderBulletDirect();
    }  // end if PAYLOAD.directSalesBulletData
  }
});
</script>

</body>
</html>
'''
    with open(html_path, 'w', encoding='utf-8') as f:
        f.write(html)


def _build_period_axis(target_qs, target_ms, target_ws, q_days, m_days, w_days):
    """构建公共的 labels / all_ndays（季度+月度+周度）"""
    month_names = {i: f'{i}月' for i in range(1, 13)}
    labels, all_ndays = [], []
    for q in target_qs:
        labels.append(q.replace('-', '')); all_ndays.append(len(q_days[q]))
    for mi in target_ms:
        labels.append(month_names.get(mi % 100, f'{mi % 100}月'))
        all_ndays.append(len(m_days[mi]))
    for iw in target_ws:
        labels.append(f'W{iw[1]}'); all_ndays.append(len(w_days[iw]))
    return labels, all_ndays


def _avg_series(metric, target_qs, target_ms, target_ws,
                q_data, m_data, w_data, q_days, m_days, w_days):
    """从 q_data/m_data/w_data 抽 metric 的日均序列"""
    series = []
    for q in target_qs:
        nd = len(q_days[q]); series.append(q_data[q][metric] / nd if nd else 0)
    for mi in target_ms:
        nd = len(m_days[mi]); series.append(m_data[mi][metric] / nd if nd else 0)
    for iw in target_ws:
        nd = len(w_days[iw]); series.append(w_data[iw][metric] / nd if nd else 0)
    return series


def main():
    parser = argparse.ArgumentParser(
        description='周报收入趋势图一键生成（默认仅生成 1 份自包含交互 HTML）'
    )
    parser.add_argument(
        '--output-dir', '-d',
        default='/Users/huangwenwu/Desktop/工作/2026/周报数据AI',
        help='输出目录（默认：周报数据AI 文件夹）'
    )
    parser.add_argument(
        '--file', '-f', default=None,
        help='[可选] 从本地 Excel(.xlsx) 加载数据'
    )
    parser.add_argument(
        '--csv', '-c', default=None,
        help='[可选] 从本地 CSV/TSV 加载数据'
    )
    parser.add_argument(
        '--with-png', action='store_true',
        help='[可选] 同时生成 12 张 PNG（默认仅 HTML）'
    )
    parser.add_argument(
        '--cdn', action='store_true',
        help='[可选] HTML 用 CDN 引入 ECharts（默认内联，离线可用，~1MB）'
    )
    parser.add_argument(
        '--category-file', default=None,
        help='[可选] 类目收入数据 xlsx 路径（用于 Group 6 收入类型×类目）'
    )
    args = parser.parse_args()

    # 类目数据默认路径：与主收入数据同目录的「类目收入数据.xlsx」
    if not args.category_file:
        if args.file:
            cand = os.path.join(os.path.dirname(args.file), '类目收入数据.xlsx')
            if os.path.exists(cand):
                args.category_file = cand
        if not args.category_file:
            cand = os.path.join(args.output_dir, '类目收入数据.xlsx')
            if os.path.exists(cand):
                args.category_file = cand

    # Step 1: 获取数据
    if args.file:
        rows = fetch_data_from_excel(args.file)
        target_lookup, target_month_label = read_target_sheet(args.file)
    elif args.csv:
        rows = fetch_data_from_csv(args.csv)
        target_lookup, target_month_label = None, None
    else:
        rows = fetch_data_from_odps()
        target_lookup, target_month_label = None, None

    if not rows:
        print('ERROR: 未获取到任何数据')
        sys.exit(1)

    # Step 2: 聚合
    D = aggregate_data(rows)

    # Step 3: 确定范围 + cutoff
    print('[3/4] 计算 YoY...')
    (target_qs, prev_qs, target_ms, prev_ms, target_ws, prev_ws) = \
        determine_targets(D.q_days, D.m_days, D.w_days)
    cutoff = max(D.w_days[target_ws[-1]]) if target_ws else None
    print(f'      季度: {target_qs} | 月度: {target_ms} | 周度: {target_ws}')
    print(f'      YoY cutoff: {cutoff}')

    labels, all_ndays = _build_period_axis(
        target_qs, target_ms, target_ws, D.q_days, D.m_days, D.w_days
    )

    # ----------- 全量月/周（用于 HTML 交互筛选） -----------
    all_ms = sorted(D.m_days.keys())
    all_ws_complete = sorted([w for w in D.w_days if len(D.w_days[w]) == 7])
    prev_all_ms = [get_prev_month(m) for m in all_ms]
    prev_all_ws = [get_prev_iso_week(w) for w in all_ws_complete]

    # ----------- YoY 计算 -----------
    metrics_all = ('总收入', '新签年费', '续签年费', '广告现金收入', '品牌广告收入')

    def calc_yoy_all(day_src):
        # 默认 4Q+4M+4W（PNG 用）
        yq = compute_yoy(target_qs, prev_qs, D.q_days, day_src, 'quarter', cutoff, metrics_all)
        ym = compute_yoy(target_ms, prev_ms, D.m_days, day_src, 'month', cutoff, metrics_all)
        yw = compute_yoy(target_ws, prev_ws, D.w_days, day_src, 'week', cutoff, metrics_all)
        return {m: yq[m] + ym[m] + yw[m] for m in metrics_all}

    def calc_yoy_full(day_src):
        # 全量月/周（HTML 交互筛选用）
        yq = compute_yoy(target_qs, prev_qs, D.q_days, day_src, 'quarter', cutoff, metrics_all)
        ym = compute_yoy(all_ms, prev_all_ms, D.m_days, day_src, 'month', cutoff, metrics_all)
        yw = compute_yoy(all_ws_complete, prev_all_ws, D.w_days, day_src, 'week', cutoff, metrics_all)
        return {'q': yq, 'm': ym, 'w': yw}

    yoy_overall = calc_yoy_all(D.day)
    yoy_med = calc_yoy_all(D.day_med)
    yoy_jp = calc_yoy_all(D.day_jp)
    yoy_team_full = {t: calc_yoy_all(D.day_team[t]) for t in TEAMS}
    yoy_team_total = {t: yoy_team_full[t]['总收入'] for t in TEAMS}
    yoy_ti = {t: {i: calc_yoy_all(D.day_ti[t][i]) for i in ('医疗', '教培')} for t in TEAMS}

    # 全量版（HTML 用）
    yoy_overall_full = calc_yoy_full(D.day)
    yoy_med_full = calc_yoy_full(D.day_med)
    yoy_jp_full = calc_yoy_full(D.day_jp)
    yoy_team_full_full = {t: calc_yoy_full(D.day_team[t]) for t in TEAMS}
    yoy_ti_full = {t: {i: calc_yoy_full(D.day_ti[t][i]) for i in ('医疗', '教培')} for t in TEAMS}

    print('      整体 YoY:', [f'{v:.0f}%' if v else 'N/A' for v in yoy_overall['总收入']])
    print('      医疗 YoY:', [f'{v:.0f}%' if v else 'N/A' for v in yoy_med['总收入']])
    print('      教培 YoY:', [f'{v:.0f}%' if v else 'N/A' for v in yoy_jp['总收入']])
    for t in TEAMS:
        print(f'      {t} YoY:', [f'{v:.0f}%' if v else 'N/A' for v in yoy_team_total[t]])

    # ----------- 公共构造工具 -----------
    def avg_(metric, q_d, m_d, w_d):
        return _avg_series(metric, target_qs, target_ms, target_ws,
                           q_d, m_d, w_d, D.q_days, D.m_days, D.w_days)

    def avg_full(metric, q_d, m_d, w_d):
        qv = [q_d[q][metric] / len(D.q_days[q]) if D.q_days.get(q) else 0 for q in target_qs]
        mv = [m_d[mi][metric] / len(D.m_days[mi]) if D.m_days.get(mi) else 0 for mi in all_ms]
        wv = [w_d[iw][metric] / len(D.w_days[iw]) if D.w_days.get(iw) else 0 for iw in all_ws_complete]
        return qv, mv, wv

    def stack_by_metric(q_d, m_d, w_d):
        # 代码 bottom→top 顺序 = 新签→续签→品广→效果
        # 视觉 top→bottom 顺序 = 效果广告→品牌广告→续签年费→新签年费
        order = ['新签年费', '续签年费', '品牌广告收入', '广告现金收入']
        out = []
        for k in order:
            qv, mv, wv = avg_full(k, q_d, m_d, w_d)
            out.append({
                'key': k,
                'label': '效果广告' if k == '广告现金收入' else k,
                'color': COLORS[k],
                'values': avg_(k, q_d, m_d, w_d),  # 4Q+4M+4W（PNG 用）
                'q_values': qv,
                'm_values': mv,
                'w_values': wv,
            })
        return out

    def yoy_by_metric(yoy_dict, yoy_full_dict):
        styles = {
            '总收入':       dict(color='#222222', linestyle='--', linewidth=3.0, marker='o', markersize=8, primary=True),
            '新签年费':     dict(color='#3FA85C', linestyle='-',  linewidth=2.2, marker='s', markersize=6),
            '续签年费':     dict(color='#E89A3C', linestyle='-',  linewidth=2.2, marker='^', markersize=6),
            '广告现金收入': dict(color='#2F6FBF', linestyle='-',  linewidth=2.2, marker='D', markersize=5),
        }
        out = []
        for k in ['总收入', '新签年费', '续签年费', '广告现金收入']:
            cfg = dict(styles[k])
            cfg.update({
                'key': k,
                'label': '效果广告' if k == '广告现金收入' else k,
                'values': yoy_dict[k],
                'q_values': yoy_full_dict['q'][k],
                'm_values': yoy_full_dict['m'][k],
                'w_values': yoy_full_dict['w'][k],
            })
            out.append(cfg)
        return out

    # ----------- 构造 chart_configs -----------
    chart_configs = []

    # ① 整体 × 4 收入类型
    chart_configs.append({
        'fname': '周报趋势图_1_整体_收入类型.png',
        'title': '① 整体收入 × 收入类型',
        'title_left': '整体日均收入结构（季度/月度/周度）',
        'title_right': '整体 YoY 同比增长（日均口径）',
        'stack_layers': stack_by_metric(D.q, D.m, D.w),
        'yoy_lines': yoy_by_metric(yoy_overall, yoy_overall_full),
    })

    # ② 整体 × 3 团队（含整体基准）
    def _team_stack_full(metric, industry='整体'):
        out = []
        for t in ['直营', '渠道', '电销']:
            if industry == '整体':
                q_s, m_s, w_s = D.q_team[t], D.m_team[t], D.w_team[t]
            else:
                q_s = D.q_ti[t][industry]
                m_s = D.m_ti[t][industry]
                w_s = D.w_ti[t][industry]
            qv, mv, wv = avg_full(metric, q_s, m_s, w_s)
            out.append({
                'key': t, 'label': t, 'color': TEAM_COLORS[t],
                'values': avg_(metric, q_s, m_s, w_s),
                'q_values': qv, 'm_values': mv, 'w_values': wv,
            })
        return out

    def _team_yoy_full(metric, industry='整体'):
        if industry == '医疗':
            base_v, base_full = yoy_med, yoy_med_full
            base_label = '医疗整体'
        elif industry == '教培':
            base_v, base_full = yoy_jp, yoy_jp_full
            base_label = '教培整体'
        else:
            base_v, base_full = yoy_overall, yoy_overall_full
            base_label = '整体'
        out = [
            {'key': '整体', 'label': base_label, 'color': '#222222',
             'linestyle': '--', 'linewidth': 3.0, 'marker': 'o', 'markersize': 8,
             'primary': True,
             'values': base_v[metric],
             'q_values': base_full['q'][metric],
             'm_values': base_full['m'][metric],
             'w_values': base_full['w'][metric]},
        ]
        for t in TEAMS:
            if industry == '整体':
                tv = yoy_team_full[t][metric]
                tf = yoy_team_full_full[t]
            else:
                tv = yoy_ti[t][industry][metric]
                tf = yoy_ti_full[t][industry]
            out.append({
                'key': t, 'label': t, 'color': TEAM_COLORS[t],
                'linestyle': '-', 'linewidth': 2.4, 'marker': TEAM_MARKERS[t],
                'markersize': 6, 'primary': False,
                'values': tv,
                'q_values': tf['q'][metric],
                'm_values': tf['m'][metric],
                'w_values': tf['w'][metric],
            })
        return out

    chart_configs.append({
        'fname': '周报趋势图_2_整体_团队.png',
        'title': '② 整体收入 × 团队（电销/渠道/直营 + 整体基准）',
        'title_left': '整体日均收入结构 — 按团队',
        'title_right': 'YoY 同比增长 — 按团队（含整体基准）',
        'stack_layers': _team_stack_full('总收入'),
        'yoy_lines': _team_yoy_full('总收入'),
    })

    # ③ 医疗 × 4 收入类型
    chart_configs.append({
        'fname': '周报趋势图_3_医疗_收入类型.png',
        'title': '③ 医疗行业 × 收入类型',
        'title_left': '医疗行业日均收入结构',
        'title_right': '医疗行业 YoY 同比增长',
        'stack_layers': stack_by_metric(D.q_med, D.m_med, D.w_med),
        'yoy_lines': yoy_by_metric(yoy_med, yoy_med_full),
    })

    # ④ 教培 × 4 收入类型
    chart_configs.append({
        'fname': '周报趋势图_4_教培_收入类型.png',
        'title': '④ 教培行业 × 收入类型',
        'title_left': '教培行业日均收入结构',
        'title_right': '教培行业 YoY 同比增长',
        'stack_layers': stack_by_metric(D.q_jp, D.m_jp, D.w_jp),
        'yoy_lines': yoy_by_metric(yoy_jp, yoy_jp_full),
    })

    # ⑤⑥⑦⑧ 单收入类型 × 3 团队 × 3 行业（整体/医疗/教培）
    metric_label_map = {
        '总收入': '总收入',
        '新签年费': '新签年费', '续签年费': '续签年费',
        '广告现金收入': '效果广告', '品牌广告收入': '品牌广告',
    }
    metric_order_for_charts = ['总收入', '新签年费', '续签年费', '广告现金收入', '品牌广告收入']
    industry_order_for_g3 = ['整体', '医疗', '教培']
    g3_index_map = {}  # (mk, ind) -> chart_configs index
    for mk in metric_order_for_charts:
        disp = metric_label_map[mk]
        for ind in industry_order_for_g3:
            ind_label = '整体' if ind == '整体' else f'{ind}行业'
            chart_configs.append({
                'fname': f'周报趋势图_g3_{disp}_{ind}.png',
                'title': f'③ {disp} × 团队 ({ind_label})',
                'title_left': f'{ind_label} {disp} 日均收入 — 按团队',
                'title_right': f'{ind_label} {disp} YoY 同比增长 — 按团队',
                'stack_layers': _team_stack_full(mk, ind),
                'yoy_lines': _team_yoy_full(mk, ind),
            })
            g3_index_map[(mk, ind)] = len(chart_configs) - 1

    # 直营 × 医疗 / 教培（原 ⑨⑩）
    g910_idx = []
    for ind in ['医疗', '教培']:
        chart_configs.append({
            'fname': f'周报趋势图_直营_{ind}.png',
            'title': f'直营团队 × {ind}行业',
            'title_left': f'直营团队 × {ind} 日均收入结构',
            'title_right': f'直营团队 × {ind} YoY 同比增长',
            'stack_layers': stack_by_metric(D.q_ti['直营'][ind], D.m_ti['直营'][ind], D.w_ti['直营'][ind]),
            'yoy_lines': yoy_by_metric(yoy_ti['直营'][ind], yoy_ti_full['直营'][ind]),
        })
        g910_idx.append(len(chart_configs) - 1)

    # 直营 / 渠道 × 收入类型（原 ⑪⑫）
    g1112_idx = []
    for team in ['直营', '渠道']:
        chart_configs.append({
            'fname': f'周报趋势图_{team}_收入类型.png',
            'title': f'{team}团队 × 收入类型',
            'title_left': f'{team}团队日均收入结构',
            'title_right': f'{team}团队 YoY 同比增长',
            'stack_layers': stack_by_metric(D.q_team[team], D.m_team[team], D.w_team[team]),
            'yoy_lines': yoy_by_metric(yoy_team_full[team], yoy_team_full_full[team]),
        })
        g1112_idx.append(len(chart_configs) - 1)

    # ----------- HTML 分组（5 组带筛选器） -----------
    chart_groups = [
        {
            'id': 'group_1',
            'title': '① 整体收入 × 收入类型',
            'filter_label': None,  # 无筛选器
            'options': [{'key': '整体', 'cfg': chart_configs[0]}],
        },
        {
            'id': 'group_2',
            'title': '② 行业 × 收入类型',
            'filter_label': '行业',
            'options': [
                {'key': '医疗', 'cfg': chart_configs[2]},   # ③
                {'key': '教培', 'cfg': chart_configs[3]},   # ④
            ],
        },
        {
            'id': 'group_3',
            'title': '③ 收入类型 × 团队',
            'filter_label': '指标',
            'filter_label2': '行业',
            'filter2_options': industry_order_for_g3,  # ['整体','医疗','教培']
            'options': [
                {'key': f'{metric_label_map[mk]}|{ind}',
                 'cfg': chart_configs[g3_index_map[(mk, ind)]]}
                for mk in metric_order_for_charts
                for ind in industry_order_for_g3
            ],
        },
        {
            'id': 'group_4',
            'title': '④ 团队 × 收入类型',
            'filter_label': '团队',
            'options': [
                {'key': '直营', 'cfg': chart_configs[g1112_idx[0]]},
                {'key': '渠道', 'cfg': chart_configs[g1112_idx[1]]},
            ],
        },
        {
            'id': 'group_5',
            'title': '⑤ 直营团队 × 行业',
            'filter_label': '行业',
            'options': [
                {'key': '医疗', 'cfg': chart_configs[g910_idx[0]]},
                {'key': '教培', 'cfg': chart_configs[g910_idx[1]]},
            ],
        },
    ]

    # ----------- 归因计算（最新周 + 最新月 YoY 各维度贡献） -----------
    latest_w = target_ws[-1]   # e.g., (2026, 21)
    latest_m = target_ms[-1]   # e.g., 202605
    prev_w = get_prev_iso_week(latest_w)
    prev_m = get_prev_month(latest_m)

    def _contrib(cur_store, prev_store, cur_key, prev_key, sub_keys, label_map=None,
                 day_store_cur=None, day_store_prev=None):
        """计算各 sub_key 对总 YoY 的贡献 ppt。
        cur_store[key][metric] = total_sum; sub_keys 就是各 metric 名。
        day_store_cur/prev: 日级数据 dict[d_int][metric]，用于等天数同比。
        返回: (total_yoy_pct, [(label, contrib_ppt, cur_daily, prev_daily), ...] sorted by |contrib| desc)
        """
        is_week = isinstance(cur_key, tuple)
        n_cur = 7 if is_week else len(D.m_days.get(cur_key, []))
        if n_cur == 0:
            return None, []
        cur_all = cur_store.get(cur_key, {})

        # 月度归因：等天数同比（prev 只取对应的日历天）
        if not is_week and day_store_prev:
            cur_days = sorted(D.m_days.get(cur_key, []))
            prev_days = [d - 10000 for d in cur_days if (d - 10000) in day_store_prev]
            n_prev = len(prev_days)
            if n_prev == 0:
                return None, []
            # 从日级数据重新聚合 prev 的 sum
            prev_all = {}
            for k in sub_keys:
                prev_all[k] = sum(day_store_prev[d].get(k, 0) for d in prev_days)
        else:
            n_prev = 7 if is_week else len(D.m_days.get(prev_key, []))
            if n_prev == 0:
                return None, []
            prev_all = prev_store.get(prev_key, {})

        total_cur = sum(cur_all.get(k, 0) for k in sub_keys) / n_cur
        total_prev = sum(prev_all.get(k, 0) for k in sub_keys) / n_prev
        if total_prev == 0:
            return None, []
        total_yoy = (total_cur / total_prev - 1) * 100
        contribs = []
        for k in sub_keys:
            c = cur_all.get(k, 0) / n_cur
            p = prev_all.get(k, 0) / n_prev
            ppt = (c - p) / total_prev * 100
            lbl = (label_map or {}).get(k, k)
            contribs.append((lbl, ppt, c, p))
        contribs.sort(key=lambda x: abs(x[1]), reverse=True)
        return total_yoy, contribs

    def _team_contrib(cur_key, prev_key, metric, is_week=True, industry='整体'):
        """各团队对某指标总 YoY 的贡献"""
        n_cur = 7 if is_week else len(D.m_days.get(cur_key, []))
        if n_cur == 0:
            return None, []

        if industry == '整体':
            store_cur = {t: D.w_team[t] if is_week else D.m_team[t] for t in TEAMS}
            day_store = {t: D.day_team[t] for t in TEAMS}
        else:
            store_cur = {t: (D.w_ti[t][industry] if is_week else D.m_ti[t][industry]) for t in TEAMS}
            day_store = {t: D.day_ti[t][industry] for t in TEAMS}

        # 月度：等天数同比
        if not is_week:
            cur_days = sorted(D.m_days.get(cur_key, []))
            prev_days_map = {}
            for t in TEAMS:
                prev_days_map[t] = [d - 10000 for d in cur_days if (d - 10000) in day_store[t]]
            n_prev = len(prev_days_map[TEAMS[0]]) if prev_days_map[TEAMS[0]] else 0
            if n_prev == 0:
                return None, []
            total_prev = sum(
                sum(day_store[t][d].get(metric, 0) for d in prev_days_map[t]) for t in TEAMS
            ) / n_prev
            total_cur = sum(store_cur[t].get(cur_key, {}).get(metric, 0) for t in TEAMS) / n_cur
            if total_prev == 0:
                return None, []
            total_yoy = (total_cur / total_prev - 1) * 100
            contribs = []
            for t in TEAMS:
                c = store_cur[t].get(cur_key, {}).get(metric, 0) / n_cur
                p = sum(day_store[t][d].get(metric, 0) for d in prev_days_map[t]) / n_prev
                ppt = (c - p) / total_prev * 100
                contribs.append((t, ppt, c, p))
        else:
            n_prev = 7
            store_prev = store_cur
            total_prev = sum(store_prev[t].get(prev_key, {}).get(metric, 0) for t in TEAMS) / n_prev
            total_cur = sum(store_cur[t].get(cur_key, {}).get(metric, 0) for t in TEAMS) / n_cur
            if total_prev == 0:
                return None, []
            total_yoy = (total_cur / total_prev - 1) * 100
            contribs = []
            for t in TEAMS:
                c = store_cur[t].get(cur_key, {}).get(metric, 0) / n_cur
                p = store_prev[t].get(prev_key, {}).get(metric, 0) / n_prev
                ppt = (c - p) / total_prev * 100
                contribs.append((t, ppt, c, p))

        contribs.sort(key=lambda x: abs(x[1]), reverse=True)
        return total_yoy, contribs

    def _format_attr(total_yoy, contribs, period_label):
        """格式化归因为一行文字"""
        if total_yoy is None:
            return ''
        sign = '+' if total_yoy >= 0 else ''
        parts = [f'{period_label} YoY {sign}{total_yoy:.0f}%：']
        for lbl, ppt, c, p in contribs[:3]:  # 取 TOP3
            s = '+' if ppt >= 0 else ''
            parts.append(f'{lbl}{s}{ppt:.0f}ppt')
        return ''.join([parts[0]] + ['，'.join(parts[1:])])

    ml = {'广告现金收入': '效果广告', '品牌广告收入': '品牌广告',
          '新签年费': '新签年费', '续签年费': '续签年费', '总收入': '总收入'}
    revenue_metrics = ['新签年费', '续签年费', '广告现金收入', '品牌广告收入']

    # Group ① 整体×收入类型：各收入类型对整体 YoY 的贡献
    w_yoy1, w_c1 = _contrib(D.w, D.w, latest_w, prev_w, revenue_metrics, ml)
    m_yoy1, m_c1 = _contrib(D.m, D.m, latest_m, prev_m, revenue_metrics, ml,
                             day_store_prev=D.day)
    chart_groups[0]['attr_text'] = [
        _format_attr(w_yoy1, w_c1, f'W{latest_w[1]}'),
        _format_attr(m_yoy1, m_c1, f'{latest_m % 100}月'),
    ]

    # Group ② 行业×收入类型：option key=医疗/教培，各收入类型的贡献
    attr_g2 = {}
    for ind, store in [('医疗', D.w_med), ('教培', D.w_jp)]:
        m_store = D.m_med if ind == '医疗' else D.m_jp
        day_prev = D.day_med if ind == '医疗' else D.day_jp
        wy, wc = _contrib(store, store, latest_w, prev_w, revenue_metrics, ml)
        my, mc = _contrib(m_store, m_store, latest_m, prev_m, revenue_metrics, ml,
                          day_store_prev=day_prev)
        attr_g2[ind] = [
            _format_attr(wy, wc, f'W{latest_w[1]}'),
            _format_attr(my, mc, f'{latest_m % 100}月'),
        ]
    chart_groups[1]['attr_by_option'] = attr_g2

    # Group ③ 收入类型×团队×行业：option key=metric|industry
    attr_g3 = {}
    for mk in metric_order_for_charts:
        disp = metric_label_map[mk]
        for ind in industry_order_for_g3:
            wy, wc = _team_contrib(latest_w, prev_w, mk, is_week=True, industry=ind)
            my, mc = _team_contrib(latest_m, prev_m, mk, is_week=False, industry=ind)
            attr_g3[f'{disp}|{ind}'] = [
                _format_attr(wy, wc, f'W{latest_w[1]}'),
                _format_attr(my, mc, f'{latest_m % 100}月'),
            ]
    chart_groups[2]['attr_by_option'] = attr_g3

    # Group ④ 团队×收入类型：option key=直营/渠道，各收入类型的贡献
    attr_g4 = {}
    for team in ['直营', '渠道']:
        wy, wc = _contrib(D.w_team[team], D.w_team[team], latest_w, prev_w, revenue_metrics, ml)
        my, mc = _contrib(D.m_team[team], D.m_team[team], latest_m, prev_m, revenue_metrics, ml,
                          day_store_prev=D.day_team[team])
        attr_g4[team] = [
            _format_attr(wy, wc, f'W{latest_w[1]}'),
            _format_attr(my, mc, f'{latest_m % 100}月'),
        ]
    chart_groups[3]['attr_by_option'] = attr_g4

    # Group ⑤ 直营×行业：option key=医疗/教培，各收入类型的贡献
    attr_g5 = {}
    for ind in ['医疗', '教培']:
        w_s = D.w_ti['直营'][ind]
        m_s = D.m_ti['直营'][ind]
        wy, wc = _contrib(w_s, w_s, latest_w, prev_w, revenue_metrics, ml)
        my, mc = _contrib(m_s, m_s, latest_m, prev_m, revenue_metrics, ml,
                          day_store_prev=D.day_ti['直营'][ind])
        attr_g5[ind] = [
            _format_attr(wy, wc, f'W{latest_w[1]}'),
            _format_attr(my, mc, f'{latest_m % 100}月'),
        ]
    chart_groups[4]['attr_by_option'] = attr_g5

    # ----------- Group 6: 收入类型 × 类目（按需加载） -----------
    category_payload = None
    if args.category_file and os.path.exists(args.category_file):
        try:
            cat_rows = fetch_category_data_from_excel(args.category_file)
            if cat_rows:
                cat_agg = aggregate_category_data(
                    cat_rows, target_qs, all_ms, all_ws_complete, cutoff,
                    D.q_days, D.m_days, D.w_days
                )
                # 收集行业 → 一级类目映射（按行业聚合所有数据中出现过的 l1）
                ind_l1 = {}
                # 一级类目 → 二级类目映射（按行业|一级 维度，跨团队合并）
                l1_l2 = {}
                for rec in cat_agg['records']:
                    ind, l1, l2 = rec['ind'], rec['l1'], rec['l2']
                    ind_l1.setdefault(ind, set()).add(l1)
                    l1_l2.setdefault((ind, l1), set()).add(l2)
                # 序列化为可 JSON 化的结构
                ind_l1_serial = {k: sorted(v) for k, v in ind_l1.items()}
                l1_l2_serial = {f'{ind}||{l1}': sorted(v) for (ind, l1), v in l1_l2.items()}
                category_payload = {
                    'records': cat_agg['records'],
                    'qNdCur': cat_agg['qNdCur'], 'qNdPrev': cat_agg['qNdPrev'],
                    'mNdCur': cat_agg['mNdCur'], 'mNdPrev': cat_agg['mNdPrev'],
                    'wNdCur': cat_agg['wNdCur'], 'wNdPrev': cat_agg['wNdPrev'],
                    'metrics': CATEGORY_METRICS,
                    'metricLabels': ['总收入', '新签年费', '续签年费', '效果广告', '品牌广告', '佣金收入'],
                    'industries': sorted(ind_l1.keys()),
                    'teams': ['直营', '渠道', '电销'],
                    'indL1': ind_l1_serial,
                    'l1L2': l1_l2_serial,
                }
                print(f'      [类目] 维度: 行业{sorted(ind_l1.keys())} 一级类目数={sum(len(v) for v in ind_l1.values())}')
        except Exception as e:
            print(f'      [警告] 类目数据加载失败: {e}')
            category_payload = None

    # ----------- 输出 -----------
    os.makedirs(args.output_dir, exist_ok=True)

    if args.with_png:
        print(f'[4/4] 绘制 PNG（共 {len(chart_configs)} 张）+ 交互 HTML...')
        for cfg in chart_configs:
            out = os.path.join(args.output_dir, cfg['fname'])
            plot_chart(
                labels, all_ndays, target_ws, D.w_days,
                stack_layers=cfg['stack_layers'],
                yoy_lines=cfg['yoy_lines'],
                title_left=cfg['title_left'],
                title_right=cfg['title_right'],
                output_path=out,
            )
    else:
        print(f'[4/4] 仅生成交互 HTML（跳过 PNG，加 --with-png 可同时出图）...')

    # 交互 HTML
    html_path = os.path.join(args.output_dir, '周报趋势图_汇总.html')
    # 把 目标&达成 表格挂到每个 group 的对应 option 上
    if target_lookup:
        # Group ① 整体
        chart_groups[0]['target_by_option'] = {
            '整体': {'col1': '收入类型',
                     'rows': build_metric_table(target_lookup, 'all', 'all')},
        }
        # Group ② 行业 × 收入类型
        chart_groups[1]['target_by_option'] = {
            '医疗': {'col1': '收入类型',
                     'rows': build_metric_table(target_lookup, '医疗', 'all')},
            '教培': {'col1': '收入类型',
                     'rows': build_metric_table(target_lookup, '教培', 'all')},
        }
        # Group ③ 收入类型 × 团队 × 行业（每个 metric|industry 展示 3 团队的目标 vs 实际）
        g3_target = {}
        for mk in metric_order_for_charts:
            disp = metric_label_map[mk]
            for ind in industry_order_for_g3:
                g3_target[f'{disp}|{ind}'] = {
                    'col1': '团队',
                    'rows': build_team_table_for_metric(target_lookup, disp, industry=ind),
                }
        chart_groups[2]['target_by_option'] = g3_target
        # Group ④ 团队 × 收入类型
        chart_groups[3]['target_by_option'] = {
            '直营': {'col1': '收入类型',
                     'rows': build_metric_table(target_lookup, 'all', '直营')},
            '渠道': {'col1': '收入类型',
                     'rows': build_metric_table(target_lookup, 'all', '渠道')},
        }
        # Group ⑤ 直营团队 × 行业
        chart_groups[4]['target_by_option'] = {
            '医疗': {'col1': '收入类型',
                     'rows': build_metric_table(target_lookup, '医疗', '直营')},
            '教培': {'col1': '收入类型',
                     'rows': build_metric_table(target_lookup, '教培', '直营')},
        }
        # 把月份标签挂到每个 group，让前端显示
        for grp in chart_groups:
            grp['target_month_label'] = target_month_label or ''
        print(f'      已加载目标&达成表格：5 个 group（{target_month_label}）')

    # 子弹图数据
    bullet_data = build_bullet_data(target_lookup, args.file, cutoff=cutoff) if target_lookup and args.file else None
    direct_sales_bullet_data = build_bullet_data_direct_sales(args.file, cutoff=cutoff) if args.file else None
    if direct_sales_bullet_data:
        print(f'      已加载直营新签人效达成子弹图数据：行业 {list(direct_sales_bullet_data["raw"].keys())}')

    _write_interactive_html(
        html_path, chart_groups, labels, all_ndays,
        target_qs, target_ms, target_ws,
        yoy_overall, yoy_med, yoy_jp, yoy_team_total,
        all_ms=all_ms, all_ws_complete=all_ws_complete,
        m_days=D.m_days, w_days=D.w_days,
        yoy_overall_full=yoy_overall_full, yoy_med_full=yoy_med_full,
        yoy_jp_full=yoy_jp_full, yoy_team_full_full=yoy_team_full_full,
        category_payload=category_payload,
        bullet_data=bullet_data,
        direct_sales_bullet_data=direct_sales_bullet_data,
    )

    # 默认：内联 ECharts，生成自包含离线版 HTML（覆盖原文件）
    if not args.cdn:
        try:
            import urllib.request
            js_cache = '/tmp/echarts.min.js'
            if not os.path.exists(js_cache):
                print('[内联] 下载 ECharts...')
                urllib.request.urlretrieve(
                    'https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js',
                    js_cache,
                )
            with open(html_path, 'r', encoding='utf-8') as f:
                html = f.read()
            with open(js_cache, 'r', encoding='utf-8') as f:
                js = f.read()
            old_tag = '<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script>'
            html = html.replace(old_tag, '<script>\n' + js + '\n</script>', 1)
            with open(html_path, 'w', encoding='utf-8') as f:
                f.write(html)
            sz = os.path.getsize(html_path) / 1024 / 1024
            print(f'[OK] 离线交互 HTML（自包含）已生成: {html_path} ({sz:.1f}MB)')
        except Exception as e:
            print(f'[WARN] 内联 ECharts 失败（{e}），保留 CDN 版本: {html_path}')
    else:
        print(f'[OK] 交互 HTML（CDN 模式）已生成: {html_path}')


if __name__ == '__main__':
    main()
