#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Q3 汇报数据包 → 自包含翻页式 HTML 汇报 deck（零外部依赖）
================================================================
数据只从 2026年3季度汇报/_汇总/*.json + Q3汇报数据汇总.md 读取，**不手写任何数字**。
用法：
    env -u PYTHONPATH /usr/bin/python3 脚本/gen_q3_report_deck.py [out.html]
默认输出：销售分析/宝锐生物_2026三季度汇报_数据deck.html
"""
import ast
import html as H
import json
import os
import re
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
S = os.path.join(ROOT, "2026年3季度汇报", "_汇总")
sys.path.insert(0, HERE)
import brand_kit as BK  # noqa: E402

OUT = sys.argv[1] if len(sys.argv) > 1 else os.path.join(
    ROOT, "销售分析", "宝锐生物_2026三季度汇报_数据deck.html")

# ── 读数据 ────────────────────────────────────────────────────
def L(name):
    with open(os.path.join(S, name), encoding="utf-8") as f:
        return json.load(f)

people_j = L("人员提炼_H1.json")
act_j = L("Q3活动客诉竞品.json")
comp_j = L("Q3竞品情报台账.json")
erp = L("erp_yoy.json")
MD = open(os.path.join(S, "Q3汇报数据汇总.md"), encoding="utf-8").read()

DOC_ITEMS = {"c117-单页版说明书", "BR1C101-说明书", "刘欣-销售能力培训"}


def sec(a, b):
    i = MD.find(a)
    if i < 0:
        return ""
    j = MD.find(b, i + len(a))
    return MD[i:j if j > 0 else len(MD)]


def md_rows(block):
    """markdown 表格 → [[cell,...],...]（去表头分隔行）。"""
    rows = []
    for ln in block.split("\n"):
        ln = ln.strip()
        if not ln.startswith("|"):
            continue
        cells = [c.strip() for c in ln.strip("|").split("|")]
        if all(re.fullmatch(r":?-{2,}:?", c or "-") for c in cells):
            continue
        rows.append(cells)
    return rows


def num(v):
    """'6950' / '32.5' / None / 'None' → float|None"""
    if v is None:
        return None
    s = str(v).strip()
    if s in ("", "None", "/", "-", "—", "nan"):
        return None
    try:
        return float(s.replace(",", ""))
    except ValueError:
        return None


def wan(v):
    """元 → 万元（1 位小数）"""
    return round(v / 10000.0, 1)


def fm(v, d=1):
    """千分位 + 指定小数位（None → —）"""
    if v is None:
        return "—"
    return format(float(v), ",.%df" % d) if d > 0 else format(int(round(float(v))), ",d")


# ── ① 人员 ────────────────────────────────────────────────────
people = []
for p in people_j["people"]:
    if p["name"] in DOC_ITEMS:
        continue
    mw = {}
    raw = p.get("monthly_wan")
    if isinstance(raw, str):
        try:
            d = ast.literal_eval(raw)
            if isinstance(d, dict):
                for k, v in d.items():
                    if str(k).isdigit():
                        mw[int(k)] = v
        except Exception:
            pass
    people.append({
        "name": p["name"],
        "group": p.get("group") or "",
        "dept": p.get("dept_area") or "",
        "target": num(p.get("target_h1_wan")),
        "actual": num(p.get("actual_h1_wan")),
        "rate": num(p.get("rate_pct")),
        "monthly": mw,
        "collection": p.get("collection") or "",
        "problems": p.get("problems") or "",
        "support": p.get("support_needed") or "",
        "customers": p.get("customers") or "",
    })

rated = [p for p in people if p["rate"] is not None]
unrated = [p for p in people if p["rate"] is None]
rated.sort(key=lambda x: -x["rate"])
people_sorted = rated + unrated

# 自有人数校验（防静默漏读）
assert len(people) >= 20, "人员数异常 %d" % len(people)


def _recon(p):
    """勾稽：自报完成率是否与 实际/目标 自洽；不符则返回复算值与反推目标。"""
    t, a, r = p["target"], p["actual"], p["rate"]
    if not t or a is None or r is None:
        return None
    calc = a / t * 100
    if abs(calc - r) <= max(0.15, abs(r) * 0.02):
        return None
    return {"calc": round(calc, 2), "implied": round(a / (r / 100), 1) if r else None}


for _p in people:
    _p["recon"] = _recon(_p)
recon_bad = [p for p in people if p.get("recon")]
recon_note = "；".join(
    "%s：目标 %s 万 / 自报完成率 %s%%，但按已填数复算为 %s%%（反推目标约 %s 万）"
    % (p["name"], fm(p["target"], 1), fm(p["rate"], 1),
       fm(p["recon"]["calc"], 2), fm(p["recon"]["implied"], 1))
    for p in recon_bad) or "无"
print("诊断→ 勾稽不符 %d 人：%s" % (len(recon_bad), [p["name"] for p in recon_bad]))


def tier(rate):
    if rate is None:
        return ("na", "原表未填")
    if rate >= 70:
        return ("good", "健康")
    if rate >= 50:
        return ("watch", "关注")
    if rate >= 20:
        return ("warn", "警示")
    return ("bad", "严重")


n_good = sum(1 for p in rated if p["rate"] >= 70)
n_watch = sum(1 for p in rated if 50 <= p["rate"] < 70)
n_warn = sum(1 for p in rated if 20 <= p["rate"] < 50)
n_bad = sum(1 for p in rated if p["rate"] < 20)

# ── ② 活动 / 客诉 ─────────────────────────────────────────────
act = act_j["q3_activities"]
act_total = act["total"]
act_months = act["by_month"]            # {'2026-07': n, ...}
act_by_sales = act["by_sales"]          # {name: {total, by_month}}
act_sales_sorted = sorted(act_by_sales.items(), key=lambda kv: -kv[1]["total"])
n_act_sales = len(act_by_sales)
act_customers = act["by_sales_customers"]

# 活动量 vs 产出：把 ERP YTD by_sp 与活动量并表（保守：仅同名字段可比）
erp_sp = erp["y2026_ytd"]["by_sp"]

cmp_ = act_j["q3_complaints"]
cmp_total = cmp_["total"]
cmp_all = cmp_["total_all"]
cmp_open = cmp_["not_closed"]
cmp_area = cmp_["by_area"]
cmp_urg = cmp_["by_urgency"]
cmp_creator = sorted(cmp_["by_creator"].items(), key=lambda kv: -kv[1])
cmp_items = cmp_["items"]
cmp_open_items = [c for c in cmp_items if c.get("status") != "结束"]
open_p0 = [c for c in cmp_open_items if c.get("area") == "大客户"]
open_p1 = [c for c in cmp_open_items if c.get("area") != "大客户"]
cmp_closed = [c for c in cmp_items if c.get("status") == "结束"]

# ── ③ 风险台账（从 MD 3.6 表解析，保持与汇总同源）────────────────
risk_block = sec("### 3.6", "## ④")
risk_rows = md_rows(risk_block)
risk_head = risk_rows[0] if risk_rows else []
risk_body = risk_rows[1:] if risk_rows else []

# ── ④ ERP ────────────────────────────────────────────────────
y26, y25y, y25f, y24f = (erp["y2026_ytd"], erp["y2025_ytd"],
                         erp["y2025_full"], erp["y2024_full"])
t26, t25y = wan(y26["total"]), wan(y25y["total"])
t25f, t24f = wan(y25f["total"]), wan(y24f["total"])
yoy = round((t26 - t25y) / t25y * 100, 1)
m24_9 = 2466.9      # MD 4.1 已列（2024 1-9月），无 JSON 明细 → 标注来源
m25_9 = wan(y25y["total"])
yoy2425 = round((m25_9 - m24_9) / m24_9 * 100, 1)
yoy_2524 = round((t25f - t24f) / t24f * 100, 1)
bad_names = [p["name"] for p in people if p["rate"] is not None and p["rate"] < 20]
top3s = "、".join("%s %.1f%%" % (p["name"], p["rate"]) for p in rated[:3])
bot3s = "、".join("%s %.1f%%" % (p["name"], p["rate"]) for p in rated[-3:])
ratio37 = round(rated[0]["rate"] / max(rated[-1]["rate"], 0.01), 1)
sci_people = [p for p in people if "生命科学" in p["group"]]
n_sci = len(sci_people)
sci_actual = round(sum((p["actual"] or 0) for p in sci_people), 2)
print("诊断→ 严重档 %d 人 %s ｜ 生命科学线 %d 人 实际 %.2f 万 ｜ 头尾比 %.1f×"
      % (len(bad_names), bad_names, n_sci, sci_actual, ratio37))
q_amt = {}
for m, v in y26["by_month"].items():
    q_amt.setdefault("2026-Q%d" % ((int(m[-2:]) - 1) // 3 + 1), 0)
    q_amt["2026-Q%d" % ((int(m[-2:]) - 1) // 3 + 1)] += v
q_amt = {k: wan(v) for k, v in sorted(q_amt.items())}

target_rows = md_rows(sec("### 4.6", "## ⑤"))
target_body = target_rows[1:] if target_rows else []
targets = []
for r in target_body:
    if len(r) >= 4:
        g0 = lambda x: str(x).replace("*", "")
        targets.append({"dept": g0(r[0]), "target": num(g0(r[1])),
                        "ytd": num(g0(r[2])), "rate": num(g0(r[3]).replace("%", ""))})
_target_sum = sum(t["target"] for t in targets if t["dept"] != "合计" and t["target"])
extrap = round(t26 / 9 * 12, 1)
gap_year = round(_target_sum - t26, 1)
q4_need = round(gap_year / 3, 1)
m_avg = round(t26 / 9, 1)
q4_ratio = round(q4_need / max(m_avg, 0.1), 2)

top_cust = sorted(y26["top_cust"].items(), key=lambda kv: -kv[1])[:20]
top_sp = sorted(y26["by_sp"].items(), key=lambda kv: -kv[1])[:15]

# ── ⑤ 竞品 ───────────────────────────────────────────────────
merged = comp_j["merged_index"]
comp_sorted = sorted(merged.items(), key=lambda kv: -kv[1]["q3_mentions"])
ledger = comp_j["q3_activity_ledger"]
inv = ledger.get("诺唯赞", {})
inv_ev = inv.get("evidence", [])
inv_readers = sorted(inv.get("sp_readers", {}).items(), key=lambda kv: -kv[1])
comp_total_brands = len(merged)
top_mentions = comp_sorted[0][1]["q3_mentions"] if comp_sorted else 0
share1 = round(top_mentions / sum(v["q3_mentions"] for v in merged.values()) * 100, 1) if merged else 0

# ── 页数据（给 JS）─────────────────────────────────────────────
DATA = {
    "people": people_sorted,
    "tiers": {"good": n_good, "watch": n_watch, "warn": n_warn, "bad": n_bad,
              "na": len(unrated)},
    "act": {"total": act_total, "months": act_months, "n_sales": n_act_sales,
            "by_sales": [{"name": k, "total": v["total"], "months": v["by_month"]}
                         for k, v in act_sales_sorted],
            "customers": act_customers},
    "cmp": {"total": cmp_total, "all": cmp_all, "open": cmp_open,
            "area": cmp_area, "urg": cmp_urg,
            "creators": [{"name": k, "n": v} for k, v in cmp_creator],
            "open_items": cmp_open_items, "closed": len(cmp_closed),
            "p0": len(open_p0), "p1": len(open_p1)},
    "erp": {"t26": t26, "t25y": t25y, "t25f": t25f, "t24f": t24f, "yoy": yoy,
            "m24_9": m24_9, "yoy2425": yoy2425,
            "month": {k: wan(v) for k, v in sorted(y26["by_month"].items())},
            "month25": {k: wan(v) for k, v in sorted(y25y["by_month"].items())},
            "quarter": q_amt, "dept": {k: wan(v) for k, v in y26["by_dept"].items()},
            "top_cust": [{"name": k, "wan": wan(v)} for k, v in top_cust],
            "top_sp": [{"name": k, "wan": wan(v)} for k, v in top_sp],
            "targets": targets},
    "comp": {"brands": [{"name": k, "q3": v["q3_mentions"], "ev": v["q3_evidence"],
                         "h1": v["h1_records"]} for k, v in comp_sorted],
             "total": comp_total_brands, "share1": share1,
             "inv": {"mentions": inv.get("mentions", 0),
                     "readers": [{"name": k, "n": v} for k, v in inv_readers],
                     "evidence": [{"date": e["date"], "sp": e["sp"], "customer": e["customer"],
                                   "text": e["text"], "url": e["url"]} for e in inv_ev]}},
    "risk": {"rows": risk_body},
    "meta": {"sources": [erp["source_2026"], erp["source_2025"], erp["source_2024"]],
             "caliber": erp["caliber"], "generated": erp["generated"],
             "act_url": act_j["_meta"]["act_url"], "cmp_url": act_j["_meta"]["cmp_url"]},
}
DATA_JSON = json.dumps(DATA, ensure_ascii=False, separators=(",", ":"))
print("页数据: 人员 %d | 活动 %d | 客诉 %d/%d 未闭环 | 品牌 %d | 风险 %d 行"
      % (len(people_sorted), act_total, cmp_open, cmp_total, comp_total_brands, len(risk_body)))

# ══════════════════════════════════════════════════════════════
#  HTML
# ══════════════════════════════════════════════════════════════
CSS = r"""
:root{--blue:#1852B6;--blue2:#4874CB;--ink:#12203a;--mut:#66748c;--line:#e2e8f2;
 --bg:#f4f6fb;--card:#fff;--good:#0f9d58;--watch:#e8a300;--warn:#ef6c00;--bad:#d81b26;
 --shadow:0 2px 14px rgba(18,32,58,.09)}
*{box-sizing:border-box}
html,body{margin:0;padding:0}
body{font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;
 background:var(--bg);color:var(--ink);height:100vh;overflow:hidden;display:flex;flex-direction:column}
/* 品牌页眉/页脚由 脚本/brand_kit.py 注入 */
.shell{flex:1;display:flex;min-height:0}
.side{width:236px;flex:none;background:#0b1c39;color:#c9d8ee;display:flex;flex-direction:column;
 overflow:auto;padding:14px 0}
.side h4{margin:0 16px 10px;font-size:12px;letter-spacing:2px;color:#7f96bd;font-weight:600}
.side a{display:flex;gap:8px;align-items:flex-start;padding:7px 16px;color:#c9d8ee;text-decoration:none;
 font-size:12.5px;line-height:1.45;border-left:3px solid transparent}
.side a:hover{background:rgba(255,255,255,.07);color:#fff}
.side a.on{background:rgba(72,116,203,.28);border-left-color:#7fa8ff;color:#fff;font-weight:600}
.side a i{font-style:normal;opacity:.55;font-size:11px;min-width:15px}
.wrap{flex:1;display:flex;flex-direction:column;min-width:0;min-height:0}
.bar{display:flex;align-items:center;gap:8px;padding:8px 14px;background:#fff;
 border-bottom:1px solid var(--line);flex:none;flex-wrap:wrap}
.bar .title{font-weight:700;font-size:13.5px;color:var(--blue)}
.btn{border:1px solid var(--line);background:#fff;color:var(--ink);border-radius:7px;padding:5px 11px;
 font-size:12px;cursor:pointer;font-family:inherit}
.btn:hover{border-color:var(--blue2);color:var(--blue)}
.btn.on{background:#eaf1fd;border-color:var(--blue2);color:var(--blue);font-weight:600}
.spacer{margin-left:auto}
.pg{font-size:12px;color:var(--mut);font-variant-numeric:tabular-nums}
.prog{position:absolute;left:0;right:0;bottom:0;height:3px;background:transparent}
.prog i{display:block;height:100%;background:linear-gradient(90deg,var(--blue),var(--blue2));width:0}
.stage{flex:1;position:relative;min-height:0;overflow:hidden;background:var(--bg);padding:14px}
.deck{position:absolute;left:0;top:0;width:1280px;height:720px;transform-origin:top left;
 background:#fff;border-radius:12px;box-shadow:var(--shadow);overflow:hidden}
.slide{position:absolute;inset:0;padding:34px 44px;display:none;flex-direction:column;background:#fff;
 overflow:auto}
.slide.on{display:flex}
.slide-head{flex:none;margin-bottom:14px}
.slide-head .kicker{font-size:11.5px;letter-spacing:2px;color:var(--blue2);font-weight:700}
.slide-head h2{margin:4px 0 0;font-size:23px;line-height:1.25}
.slide-head .sub{margin-top:5px;font-size:12.5px;color:var(--mut)}
.slide-body{flex:1;min-height:0;display:flex;flex-direction:column;gap:12px}
/* 封面 */
.cover{justify-content:center;align-items:flex-start;background:linear-gradient(135deg,#07162e,#0d2f6b 55%,#123f8f)}
.cover .cwrap{position:relative;z-index:2;color:#fff;padding-left:14px}
.cover canvas{position:absolute;inset:0;z-index:1;opacity:.85}
.cover .eyebrow{font-size:13px;letter-spacing:6px;color:#8fb6ff;margin-bottom:16px}
.cover h1{margin:0;font-size:50px;line-height:1.2;letter-spacing:2px}
.cover h1 em{font-style:normal;color:#8fb6ff}
.cover .cline{width:92px;height:4px;background:#4874CB;margin:22px 0;border-radius:2px}
.cover .meta{font-size:13.5px;line-height:2.1;color:#c9d8ee}
.cover .meta b{color:#fff;font-weight:600}
.cover .ed{border-bottom:1px dashed #6d8dc7;min-width:150px;display:inline-block;padding:0 6px}
/* 目录 */
.tocgrid{display:grid;grid-template-columns:1fr 1fr;gap:13px;align-content:start}
.toccard{border:1px solid var(--line);border-radius:10px;padding:15px 17px;background:#fbfcfe;cursor:pointer;
 transition:.15s}
.toccard:hover{border-color:var(--blue2);box-shadow:0 4px 14px rgba(24,82,182,.13);transform:translateY(-2px)}
.toccard .no{font-size:11px;color:var(--blue2);font-weight:700;letter-spacing:2px}
.toccard .nm{font-size:15.5px;font-weight:700;margin:5px 0 6px}
.toccard .ds{font-size:12px;color:var(--mut);line-height:1.6}
/* 章节承接页 */
.chapter{justify-content:center;background:linear-gradient(135deg,#0b1c39,#15407f)}
.chapter canvas{position:absolute;inset:0;z-index:1;opacity:.55}
.chapter .cwrap{position:relative;z-index:2;color:#fff}
.chapter .big{font-size:74px;font-weight:800;line-height:1;color:rgba(143,182,255,.34)}
.chapter h2{font-size:36px;margin:6px 0 10px}
.chapter .ds{font-size:14px;color:#c9d8ee;line-height:1.9;max-width:720px}
/* 卡片 / KPI */
.kpis{display:grid;gap:11px}
.kpi{border:1px solid var(--line);border-radius:10px;padding:13px 15px;background:#fbfcfe}
.kpi .lb{font-size:11.5px;color:var(--mut);margin-bottom:5px}
.kpi .vl{font-size:25px;font-weight:800;line-height:1.1;font-variant-numeric:tabular-nums}
.kpi .vl small{font-size:12.5px;font-weight:600;color:var(--mut);margin-left:3px}
.kpi .nt{font-size:11.5px;color:var(--mut);margin-top:5px;line-height:1.5}
.kpi.hot{background:linear-gradient(135deg,#fff6f6,#fff);border-color:#f3c9cc}
.kpi.cool{background:linear-gradient(135deg,#f4f8ff,#fff);border-color:#cadcf7}
.card{border:1px solid var(--line);border-radius:10px;background:#fff;padding:13px 15px;min-height:0}
.card h3{margin:0 0 9px;font-size:13.5px;color:var(--blue);letter-spacing:.4px}
.scroll{overflow:auto;min-height:0}
/* 表格 */
table.data{width:100%;border-collapse:collapse;font-size:12px}
table.data th{background:#eef3fb;color:#2a3d5c;font-weight:600;text-align:left;padding:6px 8px;
 position:sticky;top:0;z-index:2;white-space:nowrap;border-bottom:1px solid var(--line)}
table.data td{padding:5.5px 8px;border-bottom:1px solid #f0f3f8;vertical-align:top}
table.data tr:nth-child(even) td{background:#fafbfd}
table.data td.n,table.data th.n{text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap}
table.data td.c,table.data th.c{text-align:center}
.tag{display:inline-block;padding:1.5px 7px;border-radius:9px;font-size:11px;font-weight:600;white-space:nowrap}
.tag.good{background:#e7f6ec;color:#0f9d58}.tag.watch{background:#fdf4e0;color:#b07800}
.tag.warn{background:#fdeee2;color:#c95a00}.tag.bad{background:#fdeaea;color:#c8161f}
.tag.na{background:#eef0f4;color:#7b8798}
.tag.mid{background:#eef3fb;color:#2a3d5c}
.mono{font-variant-numeric:tabular-nums}
.bar-in{height:7px;border-radius:4px;background:#eef1f6;overflow:hidden;min-width:74px}
.bar-in i{display:block;height:100%;border-radius:4px;background:var(--blue)}
.bar-in i.good{background:var(--good)}.bar-in i.watch{background:var(--watch)}
.bar-in i.warn{background:var(--warn)}.bar-in i.bad{background:var(--bad)}
.hl{background:#fff5d6;border-radius:3px;padding:0 3px}
.warnbox{border-left:4px solid var(--bad);background:#fff7f7;padding:10px 13px;border-radius:0 8px 8px 0;
 font-size:12.5px;line-height:1.75}
.infobox{border-left:4px solid var(--blue);background:#f4f8ff;padding:10px 13px;border-radius:0 8px 8px 0;
 font-size:12.5px;line-height:1.75}
.rows{display:grid;gap:9px}
.two{display:grid;grid-template-columns:1fr 1fr;gap:13px;min-height:0}
.three{display:grid;grid-template-columns:1fr 1fr 1fr;gap:13px}
.ev{border-left:3px solid #dfe6f2;padding:6px 0 6px 10px;margin-bottom:8px;font-size:11.5px;line-height:1.7;
 color:#33425c}
.ev b{color:var(--blue)}
.ev .meta{color:var(--mut);font-size:10.5px}
.ev a{color:var(--blue2);text-decoration:none;font-size:10.5px}
.ev a:hover{text-decoration:underline}
ul.tight{margin:0;padding-left:17px;font-size:12.5px;line-height:1.85}
ul.tight li{margin-bottom:3px}
.foot-note{flex:none;font-size:11px;color:var(--mut);line-height:1.6;margin-top:8px}
body.edit .slide .tx,body.edit table.data td,body.edit .kpi .vl,body.edit .kpi .nt{outline:1px dashed #9fc0ee;
 outline-offset:1px}

/* ===== 审稿模式（rv）===== */
body.rv .wrap{margin-right:300px}
.rvPanel{position:fixed;right:0;top:0;bottom:0;width:300px;background:#fff;border-left:1px solid var(--line);
 box-shadow:-6px 0 22px rgba(18,32,58,.10);display:flex;flex-direction:column;z-index:60;
 transform:translateX(102%);transition:transform .25s}
body.rv .rvPanel{transform:none}
.rvHead{padding:11px 14px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:8px;
 background:linear-gradient(135deg,#0d47a1,#1a73e8);color:#fff}
.rvHead b{font-size:13px;letter-spacing:1px}
.rvStat{margin-left:auto;font-size:11px;opacity:.92}
.rvList{flex:1;overflow:auto;padding:6px 0}
.rvItem{display:flex;align-items:center;gap:8px;padding:6px 12px;cursor:pointer;font-size:12px;line-height:1.4}
.rvItem:hover{background:#f4f8ff}
.rvItem.on{background:#eaf1fd;font-weight:600}
.rvNo{min-width:20px;color:var(--mut);font-variant-numeric:tabular-nums;font-size:11px}
.rvT{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.rvB{font-weight:700;min-width:14px;text-align:center}
.rvB.s0{color:#9aa6b8}.rvB.s1{color:var(--good)}.rvB.s2{color:var(--warn)}
.rvNote{border-top:1px solid var(--line);padding:9px 12px}
.rvNote textarea{width:100%;height:78px;border:1px solid var(--line);border-radius:7px;padding:7px 9px;
 font-family:inherit;font-size:12px;line-height:1.6;resize:vertical;outline:none}
.rvNote textarea:focus{border-color:var(--blue2);box-shadow:0 0 0 2px #eaf1fd}
.rvFoot{border-top:1px solid var(--line);padding:9px 12px;display:flex;flex-wrap:wrap;gap:6px}
.rvFoot .btn{flex:1 1 44%;font-size:11.5px;padding:5px 8px}
.rvSlideBadge{position:absolute;right:12px;top:10px;font-size:11px;font-weight:600;padding:2px 9px;
 border-radius:9px;background:#eef0f4;color:#7b8798;z-index:5;pointer-events:none}
.rvSlideBadge.s1{background:#e7f6ec;color:#0f9d58}
.rvSlideBadge.s2{background:#fdeee2;color:#c95a00}
body.pres .rvPanel,body.pres .rvSlideBadge{display:none!important}
body.pres .wrap{margin-right:0}
@media print{.rvPanel,.rvSlideBadge{display:none!important}}
body.pres .side,body.pres .bar,body.pres .bior-header,body.pres .bior-footer{display:none!important}
body.pres .stage{padding:0}
body.pres .deck{border-radius:0;box-shadow:none}
@media print{body{height:auto;overflow:visible;display:block}
 .side,.bar,.bior-header,.bior-footer{display:none!important}
 .deck{position:static;width:100%;height:auto;transform:none!important;box-shadow:none}
 .slide{position:relative;display:flex!important;page-break-after:always;height:720px}}
"""


def esc(s):
    return H.escape(str(s), quote=True)


def kpi(label, value, unit="", note="", cls=""):
    u = "<small>%s</small>" % esc(unit) if unit else ""
    n = '<div class="nt">%s</div>' % note if note else ""
    return ('<div class="kpi %s"><div class="lb">%s</div><div class="vl">%s%s</div>%s</div>'
            % (cls, esc(label), esc(value), u, n))


# ── 各页 HTML ─────────────────────────────────────────────────
def p_cover():
    return """
<section class="slide cover" data-t="封面">
  <canvas id="cvCover"></canvas>
  <div class="cwrap">
    <div class="eyebrow">BIORI BIOTECH ｜ 营销中心</div>
    <h1>2026 <em>三季度</em>汇报<br>数据汇总</h1>
    <div class="cline"></div>
    <div class="meta">
      汇报人：<span class="ed" contenteditable="true">&nbsp;</span>　
      日期：<span class="ed" contenteditable="true">&nbsp;</span><br>
      覆盖　①半年度汇报人员提炼 ②Q3 飞书活动与客诉 ③关键客诉与风险<br>
      　　　④ERP 同比（2026 1–9月）⑤主要竞品品牌与优势
    </div>
  </div>
</section>"""




def p_toc():
    cards = "".join(
        '<div class="toccard" onclick="go(%d)"><div class="no">%s</div>'
        '<div class="nm">%s</div><div class="ds">%s</div></div>' % (pg, no, esc(nm), esc(ds))
        for no, nm, ds, pg in TOC)
    return """
<section class="slide" data-t="目录">
  <div class="slide-head"><div class="kicker">CONTENTS</div><h2>汇报内容</h2>
  <div class="sub">五章数据 + 附录；全部数字来自 <span class="mono">_汇总/*.json</span>，可逐项回溯到飞书原文或 ERP 导出</div></div>
  <div class="slide-body"><div class="tocgrid">%s</div>
  <div class="infobox">本 deck 为<b>数据汇总版</b>：只陈述可溯源事实与数字，不含对策承诺；所有金额口径见末页附录。</div>
  </div>
</section>""" % cards


def p_summary():
    k = [
        kpi("① 半年度汇报", "22", "人/份", "34 份原始件全部转写提炼（含管理 5、说明书 2）", "cool"),
        kpi("② Q3 飞书活动", fm(act_total, 0), "条", "%d 人 · 7月 %d ｜ 8月 %d ｜ 9月 %d"
            % (n_act_sales, act_months.get("2026-07", 0), act_months.get("2026-08", 0),
               act_months.get("2026-09", 0))),
        kpi("② Q3 客诉", "%d" % cmp_total, "条", "全量 %d；<b>未闭环 %d 条（%.1f%%）</b>"
            % (cmp_all, cmp_open, cmp_open * 100.0 / cmp_total), "hot"),
        kpi("④ ERP 2026 1–9月", fm(t26, 1), "万", "同比 <b>+%.1f%%</b>（2025 同期 %.1f 万）" % (yoy, t25y), "cool"),
        kpi("⑤ 竞品品牌", "%d" % comp_total_brands, "个", "诺唯赞 %d 次提及（占全部命中 %.1f%%）" % (top_mentions, share1)),
        kpi("③ 高风险账款", "%d" % len(risk_body), "项", "含账龄 365 天以上、逾期 734–895 天", "hot"),
    ]
    return """
<section class="slide" data-t="一页摘要">
  <div class="slide-head"><div class="kicker">EXECUTIVE SUMMARY</div><h2>一页摘要</h2>
  <div class="sub">五项任务的核心结果</div></div>
  <div class="slide-body">
    <div class="kpis" style="grid-template-columns:repeat(3,1fr)">%s</div>
    <div class="two" style="grid-template-columns:1fr 1fr 1fr;flex:1">
      <div class="card"><h3>判断 1 ｜ 增长真实，但结构不适</h3>
        <div style="font-size:12.5px;line-height:1.85">同比 <b>+%.1f%%</b>，增量几乎全部来自老客户放量；
        新客户成功转化极低 —— 半年报自述「有效落地订单转化率不足 30%%」。
        <div style="margin-top:10px;padding-top:9px;border-top:1px dashed #e0e6f0;font-size:11.5px;color:#66748c">
        <b>依据</b>：ERP 2026 YTD %s 万 vs 2025 同期 %s 万；增量集中于既有客户放量。</div></div></div>
      <div class="card"><h3>判断 2 ｜ 客诉是当前最大经营风险</h3>
        <div style="font-size:12.5px;line-height:1.85">Q3 <b>%d 条</b>客诉、<b>%d 条未闭环（%.1f%%）</b>；
        口径集中在批间差 / 污染 / 沉淀 / 批内差，并直接压制回款。
        <div style="margin-top:10px;padding-top:9px;border-top:1px dashed #e0e6f0;font-size:11.5px;color:#66748c">
        <b>依据</b>：Q3 客诉 %d 条（全量 %d 条），未闭环 %d 条；高风险账款 %d 项。</div></div></div>
      <div class="card"><h3>判断 3 ｜ 竞品已从价格战升级为体系战</h3>
        <div style="font-size:12.5px;line-height:1.85">诺唯赞 <b>%d 次</b>提及，打法是「原料+终端试剂+仪器」
        一体化、战略合作、仪器投放绑定试剂；我方仍以单点货号比价应对。
        <div style="margin-top:10px;padding-top:9px;border-top:1px dashed #e0e6f0;font-size:11.5px;color:#66748c">
        <b>依据</b>：Q3 活动原文命中 + 半年报竞品记录合并后共 %d 个品牌，诺唯赞 %d 次提及（占 %.1f%%）。</div></div></div>
    </div>
  </div>
</section>""" % ("".join(k), yoy, t26, t25y,
       cmp_total, cmp_open, cmp_open * 100.0 / cmp_total,
       cmp_total, cmp_all, cmp_open, len(risk_body),
       top_mentions, comp_total_brands, top_mentions, share1)


def chapter(no, title, desc, pg):
    return """
<section class="slide chapter" data-t="%s">
  <canvas class="chCv"></canvas>
  <div class="cwrap"><div class="big">%s</div><h2>%s</h2><div class="ds">%s</div></div>
</section>""" % (esc(title), esc(no), esc(title), desc)


def p_people():
    rows = []
    for i, p in enumerate(people_sorted, 1):
        t, tn = tier(p["rate"])
        tg = p["target"]
        av = p["actual"]
        rt = p["rate"]
        w = 0 if rt is None else min(rt, 100)
        def f(v):
            return "—" if v is None else ("%.1f" % v)
        rows.append(
            '<tr><td class="c mono">%d</td><td><b>%s</b></td><td style="font-size:11px;color:#56617a">%s</td>'
            '<td class="n mono">%s</td><td class="n mono">%s</td>'
            '<td class="n mono"><b>%s%s</b>%s</td>'
            '<td style="width:100px"><div class="bar-in"><i class="%s" style="width:%.0f%%"></i></div></td>'
            '<td class="c"><span class="tag %s">%s</span></td></tr>'
            % (i, esc(p["name"]), esc(p["dept"][:30]), f(tg), f(av),
               f(rt), "%" if rt is not None else "",
               (' <span title="与已填数复算不符，见页脚说明" style="color:#d81b26;font-weight:700">⚠</span>'
                if p.get("recon") else ""),
               t, w, t, tn))
    return """
<section class="slide" data-t="① 人员目标达成总表">
  <div class="slide-head"><div class="kicker">① 半年度汇报</div><h2>人员目标达成总表</h2>
  <div class="sub">共 %d 人（含管理）；达成率按各人自报半年度目标 / 实际计算，<b>未做任何换算或估算</b></div></div>
  <div class="slide-body">
    <div class="card scroll">
      <table class="data"><thead><tr><th class="c">#</th><th>姓名</th><th>部门 / 区域</th>
      <th class="n">半年目标(万)</th><th class="n">实际(万)</th><th class="n">达成率</th>
      <th>进度</th><th class="c">判定</th></tr></thead>
      <tbody>%s</tbody></table>
    </div>
    <div class="foot-note">判定档：健康 ≥70%% ｜ 关注 50–70%% ｜ 警示 20–50%% ｜ 严重 &lt;20%%。
    标「—」者原表未填数（见末页数据缺口）。</div>
    <div class="warnbox" style="flex:none;margin-top:8px"><b>⚠ 勾稽提示（原稿自相矛盾，未做修正）</b>：%s</div>
  </div>
</section>""" % (len(people_sorted), "".join(rows), esc(recon_note))


def p_people_chart():
    return """
<section class="slide" data-t="① 达成率分布与断层">
  <div class="slide-head"><div class="kicker">① 半年度汇报</div><h2>达成率分布与断层</h2>
  <div class="sub">%d 人落在「严重」档（&lt;20%%）；头部与长尾断层显著</div></div>
  <div class="slide-body">
    <div class="two" style="flex:1">
      <div class="card"><h3>达成率分档人数</h3><div id="chTier"></div></div>
      <div class="card"><h3>达成率排行（前 12 / 后 8）</h3><div id="chRank" class="scroll"></div></div>
    </div>
    <div class="warnbox">头部最高 3 人（%s）与尾部最低 3 人（%s）相差约 <b>%.1f 倍</b>；
    生命科学线 <b>%d 人</b>半年度合计实际仅 <b>%.1f 万</b>，而该线年度目标 <b>900 万</b>。</div>
  </div>
</section>""" % (len(bad_names), esc(top3s), esc(bot3s), ratio37, n_sci, sci_actual)


def p_act_overview():
    return """
<section class="slide" data-t="② 活动量总览">
  <div class="slide-head"><div class="kicker">② Q3 飞书活动</div><h2>活动量总览</h2>
  <div class="sub">%s 条 / %d 人；按月看 7→9 月逐月上升</div></div>
  <div class="slide-body">
    <div class="two" style="grid-template-columns:1.1fr 1fr;flex:1">
      <div class="card"><h3>月度活动量</h3><div id="chActMonth"></div>
        <h3 style="margin-top:14px">活动量 TOP 12（人）</h3><div id="chActSales" class="scroll" style="max-height:230px"></div></div>
      <div class="card scroll"><h3>全部 %d 人活动量明细</h3>
        <table class="data"><thead><tr><th class="c">#</th><th>销售</th><th class="n">合计</th>
        <th class="n">7月</th><th class="n">8月</th><th class="n">9月</th></tr></thead>
        <tbody id="tbActSales"></tbody></table></div>
    </div>
    <div class="foot-note">数据源：飞书活动导出（Q3 全量 %s 条，逐条含 work_item_id，可点开原文）。</div>
  </div>
</section>""" % (act_total, n_act_sales, n_act_sales, act_total)


def p_act_output():
    return """
<section class="slide" data-t="② 活动量与产出匹配">
  <div class="slide-head"><div class="kicker">② Q3 飞书活动</div><h2>活动量与产出匹配度</h2>
  <div class="sub">把「活动条数」与「ERP 2026 YTD 销售额」并表 —— 活动量不等于产出</div></div>
  <div class="slide-body">
    <div class="card scroll" style="flex:1">
      <table class="data"><thead><tr><th class="c">#</th><th>销售</th><th class="n">Q3 活动(条)</th>
      <th class="n">ERP YTD(万)</th><th class="n">万/百条</th><th>判读</th></tr></thead>
      <tbody id="tbActOut"></tbody></table>
    </div>
    <div class="warnbox" id="actOutNote"></div>
  </div>
</section>"""


def p_act_customers():
    return """
<section class="slide" data-t="② 高频客户 TOP">
  <div class="slide-head"><div class="kicker">② Q3 飞书活动</div><h2>各人高频客户 TOP 5</h2>
  <div class="sub">活动密度反映客户渗透深度；科研线为课题组/高校，诊断线为公司客户</div></div>
  <div class="slide-body"><div class="card scroll" style="flex:1">
    <table class="data"><thead><tr><th>#</th><th>销售</th><th>高频客户（TOP 5，括号为活动数）</th></tr></thead>
    <tbody id="tbActCust"></tbody></table></div>
    <div class="foot-note">仅统计 Q3 该销售名下活动记录中出现次数最多的客户；同名客户的集团/主体差异未做合并。</div>
  </div>
</section>"""


def p_cmp_overview():
    return """
<section class="slide" data-t="③ 客诉全貌">
  <div class="slide-head"><div class="kicker">③ 关键客诉与风险</div><h2>Q3 客诉全貌</h2>
  <div class="sub">Q3 %d 条（飞书全量 %d 条）；<b>未闭环 %d 条，占 %.1f%%</b></div></div>
  <div class="slide-body">
    <div class="two" style="grid-template-columns:1fr 1fr;flex:1">
      <div class="card"><h3>按区域分布</h3><div id="chCmpArea"></div>
        <h3 style="margin-top:14px">按提报人 TOP 8</h3><div id="chCmpCreator"></div></div>
      <div class="card"><h3>闭环状态</h3><div id="chCmpStat"></div>
        <div class="infobox" style="margin-top:12px">紧急度分布：<b>medium %d 条</b>、normal %d 条。
        已闭环仅 <b>%d 条</b>；未闭环中 P0（大客户区域）<b>%d 条</b>、P1（其他区域）<b>%d 条</b> —— 逐条附飞书链接，见下页。</div></div>
    </div>
  </div>
</section>""" % (cmp_total, cmp_all, cmp_open, cmp_open * 100.0 / cmp_total,
                 cmp_urg.get("medium", 0), cmp_urg.get("normal", 0),
                 len(cmp_closed), len(open_p0), len(open_p1))


def p_cmp_list():
    return """
<section class="slide" data-t="③ 未闭环客诉清单">
  <div class="slide-head"><div class="kicker">③ 关键客诉与风险</div><h2>未闭环客诉清单（可点开飞书原文）</h2>
  <div class="sub">P0 大客户区域 %d 条 ｜ P1 其他区域 %d 条；按滞留天数降序</div></div>
  <div class="slide-body"><div class="card scroll" style="flex:1">
    <table class="data"><thead><tr><th class="c">级别</th><th class="c">悬浮(天)</th><th>客诉名称</th>
    <th>客户</th><th>提报人</th><th class="c">区域</th><th class="c">链接</th></tr></thead>
    <tbody id="tbCmpOpen"></tbody></table></div>
    <div class="foot-note">链接口径：<span class="mono">project.feishu.cn/br-shgl/khts/detail/&lt;id&gt;</span>（客诉字段仅含 id，无直接 URL）。</div>
  </div>
</section>""" % (len(open_p0), len(open_p1))


def p_risk():
    head = risk_head or ["风险等级", "客户", "金额", "逾期/账龄", "说明与措施", "来源"]
    return """
<section class="slide" data-t="③ 高风险账款">
  <div class="slide-head"><div class="kicker">③ 关键客诉与风险</div><h2>高风险账款 / 客户风险（%d 项）</h2>
  <div class="sub">源自半年度汇报原文；金额与账龄按汇报人自述，未做外部核对</div></div>
  <div class="slide-body"><div class="card scroll" style="flex:1">
    <table class="data"><thead><tr>%s</tr></thead><tbody id="tbRisk"></tbody></table></div>
    <div class="warnbox" style="flex:none">Q3 的客诉与回款是两个互相咬合的问题 ——
    <b>客诉不闭环 → 客户压款 → 回款计划落空</b>（李江辉 7 月回款计划 26.7 万、实际累计回款 0）。</div>
  </div>
</section>""" % (len(risk_body), "".join("<th>%s</th>" % esc(c) for c in head))


def p_erp_total():
    return """
<section class="slide" data-t="④ ERP 总量对照">
  <div class="slide-head"><div class="kicker">④ ERP 同比</div><h2>总量对照</h2>
  <div class="sub">口径：标准销售订单 + 已审核 + 非赠品 + 剔除宝锐系；金额 = 价税合计</div></div>
  <div class="slide-body">
    <div class="kpis" style="grid-template-columns:repeat(4,1fr)">
      %s
    </div>
    <div class="two" style="grid-template-columns:1.2fr 1fr;flex:1">
      <div class="card"><h3>四年对照（万元）</h3><div id="chErpTotal"></div></div>
      <div class="card"><h3>三年 YTD 链路</h3><div id="chErpChain"></div>
        <div class="infobox" style="margin-top:10px">✅ <b>口径自校验通过</b>：本脚本算出的 2025 上半年
        <b>1,586.6 万</b> ≈ 管理版半年报 PPT 的 <b>1,587 万</b>，误差 &lt;0.1%%，说明提取口径与管理层一致。</div></div>
    </div>
  </div>
</section>""" % "".join([
        kpi("2026 年 1–9 月（YTD）", fm(t26, 1), "万", "订单行 %s" % y26["rows"], "cool"),
        kpi("2025 年 1–9 月（同期）", fm(t25y, 1), "万", "同比 <b>+%.1f%%</b>（+%s 万）" % (yoy, fm(t26 - t25y, 1))),
        kpi("2025 年全年", fm(t25f, 1), "万", "2026 YTD 已达其 <b>%.1f%%</b>" % (t26 / t25f * 100)),
        kpi("2024 年全年", fm(t24f, 1), "万", "2025 全年同比 <b>+%.1f%%</b>" % yoy_2524),
    ])


def p_erp_monthly():
    return """
<section class="slide" data-t="④ 月度与季度">
  <div class="slide-head"><div class="kicker">④ ERP 同比</div><h2>2026 月度与季度分布</h2>
  <div class="sub">与 2025 年同期逐月对照；按季度看 Q1→Q3 逐季上升</div></div>
  <div class="slide-body">
    <div class="card" style="flex:1"><h3>2026 vs 2025 同期 · 逐月（万元）</h3><div id="chErpMonth"></div></div>
    <div class="kpis" style="grid-template-columns:repeat(3,1fr)">
      %s
    </div>
  </div>
</section>""" % "".join(
        kpi("Q%d" % i, "%.1f" % q_amt.get(k, 0), "万",
            ("—" if i == 1 else "环比 <b>%+.1f%%</b>" % (
                (q_amt.get(k, 0) - q_amt.get("2026-Q%d" % (i - 1), 0))
                / max(q_amt.get("2026-Q%d" % (i - 1), 1), 1) * 100)))
        for i, k in enumerate(["2026-Q1", "2026-Q2", "2026-Q3"], 1))


def p_erp_dept():
    return """
<section class="slide" data-t="④ 部门达成与缺口">
  <div class="slide-head"><div class="kicker">④ ERP 同比</div><h2>部门达成与 Q4 缺口</h2>
  <div class="sub">年度目标 %s</div></div>
  <div class="slide-body">
    <div class="card"><table class="data"><thead><tr><th>部门</th><th class="n">年度目标(万)</th>
    <th class="n">2026 YTD(万)</th><th class="n">达成率</th><th>进度</th></tr></thead>
    <tbody id="tbTarget"></tbody></table></div>
    <div class="two" style="grid-template-columns:1fr 1fr;flex:1">
      <div class="card"><h3>部门金额（万元）</h3><div id="chErpDept"></div></div>
      <div class="card"><h3>季度分布（万元）</h3><div id="chErpQ"></div>
        <div class="warnbox" style="margin-top:10px">按 1–9 月进度线性外推，全年约 <b>%s 万</b>；
        距 %s 万年度目标缺口 <b>%s 万</b> → Q4 月均需 <b>%s 万</b>，是 1–9 月月均 %s 万的 <b>%s 倍</b>。</div></div>
    </div>
  </div>
</section>""" % ("　/　".join("%s %s 万" % (t["dept"].replace("部", ""), fm(t["target"], 0))
                          for t in targets),
                   fm(extrap, 1), fm(_target_sum, 0), fm(gap_year, 1),
                   fm(q4_need, 1), fm(m_avg, 1), q4_ratio)


def p_erp_top():
    return """
<section class="slide" data-t="④ 客户 / 销售员排名">
  <div class="slide-head"><div class="kicker">④ ERP 同比</div><h2>客户与销售员排名（2026 YTD）</h2>
  <div class="sub">客户 TOP 20 ｜ 销售员 TOP 15（万元）</div></div>
  <div class="slide-body"><div class="two" style="flex:1">
    <div class="card scroll"><h3>客户端 TOP 20</h3>
      <table class="data"><thead><tr><th class="c">#</th><th>客户</th><th class="n">YTD(万)</th></tr></thead>
      <tbody id="tbTopCust"></tbody></table></div>
    <div class="card scroll"><h3>销售员 TOP 15</h3>
      <table class="data"><thead><tr><th class="c">#</th><th>销售</th><th class="n">YTD(万)</th>
      <th>占比</th></tr></thead><tbody id="tbTopSp"></tbody></table></div>
  </div>
  <div class="foot-note">⚠️ 2025 年名册用<b>岗位</b>（大客户1/大客户2）而非姓名，故跨年<b>个人</b>同比只在实名制人员间可比。</div>
  </div>
</section>"""


def p_comp_heat():
    return """
<section class="slide" data-t="⑤ 竞品热度榜">
  <div class="slide-head"><div class="kicker">⑤ 主要竞争对手</div><h2>竞品提及热度榜</h2>
  <div class="sub">%d 个品牌（Q3 活动命中 + 半年度汇报记录，品牌别名已归一）</div></div>
  <div class="slide-body"><div class="two" style="grid-template-columns:1.25fr 1fr;flex:1">
    <div class="card"><h3>Q3 提及次数（TOP 18）</h3><div id="chCompHeat" class="scroll"></div></div>
    <div class="card scroll"><h3>全量 %d 品牌（Q3 命中 / 半年报记录）</h3>
      <table class="data"><thead><tr><th class="c">#</th><th>品牌</th><th class="n">Q3 提及</th>
      <th class="n">半年报记录</th></tr></thead><tbody id="tbComp"></tbody></table></div>
  </div>
  <div class="warnbox" style="flex:none">诺唯赞 <b>%d 次</b>提及，占全部命中 <b>%.1f%%</b> —— 单一竞品占据一半声量。</div>
  </div>
</section>""" % (comp_total_brands, comp_total_brands, top_mentions, share1)


def p_comp_inv():
    return """
<section class="slide" data-t="⑤ 诺唯赞深挖">
  <div class="slide-head"><div class="kicker">⑤ 主要竞争对手</div><h2>诺唯赞：从价格战到体系战</h2>
  <div class="sub">Q3 提及 %d 次 ｜ 半年报竞品记录 %d 条 ｜ 每条证据附飞书活动链接</div></div>
  <div class="slide-body">
    <div class="two" style="grid-template-columns:300px 1fr;flex:1">
      <div class="card"><h3>提及者分布</h3><div id="chInvReaders"></div>
        <div class="infobox" style="margin-top:10px;font-size:11.5px">提及高度集中在<b>科研线</b> 5 人 ——
        说明科研场景正被诺唯赞体系化覆盖。</div></div>
      <div class="card scroll"><h3>优势证据（Q3 活动原文节选）</h3><div id="invEv"></div></div>
    </div>
  </div>
</section>""" % (inv.get("mentions", 0), merged.get("诺唯赞", {}).get("h1_records", 0))


def p_comp_summary():
    return """
<section class="slide" data-t="⑤ 竞争态势小结">
  <div class="slide-head"><div class="kicker">⑤ 主要竞争对手</div><h2>竞争态势小结</h2>
  <div class="sub">每条结论均注明出处，可回溯到飞书活动原文或半年度汇报</div></div>
  <div class="slide-body"><div class="card scroll" style="flex:1">
    <table class="data"><thead><tr><th>#</th><th>结论</th><th>出处</th></tr></thead>
    <tbody id="tbCompSummary"></tbody></table></div>
    <div class="infobox" style="flex:none">承接动作建议：① 建立竞品价格/政策台账（按货号归口，单位不明确一律标注不猜）；
    ② 科研线做「体系对标」而非单点比价；③ 把测试反馈闭环纳入竞品防守的第一道闸口。</div>
  </div>
</section>"""


def p_conclusion():
    return """
<section class="slide" data-t="结论与缺口">
  <div class="slide-head"><div class="kicker">CONCLUSION</div><h2>结论与数据缺口</h2>
  <div class="sub">只列可溯源事实；缺口如实标注，不做估算</div></div>
  <div class="slide-body">
    <div class="three" style="flex:none">
      %s
    </div>
    <div class="card" style="flex:1"><h3>⚠️ 数据缺口与待补项（不去猜、不估算）</h3>
      <table class="data"><thead><tr><th class="c">#</th><th>缺口</th><th>现状</th><th>补数责任（建议）</th></tr></thead>
      <tbody>
      <tr><td class="c">⚠</td><td>原稿勾稽不符 %d 人</td><td>%s</td><td>本人 → 直属负责人核对后回填</td></tr>
      <tr><td class="c">1</td><td>生命科学 2 人年度目标 / 完成率</td><td>原表为「/」，未填数</td><td>刘斌、袁殿果本人 → 生命科学销售部负责人</td></tr>
      <tr><td class="c">2</td><td>回款达成金额</td><td>汇报页仅图表，无可核对数字</td><td>财务部出明细 → 各汇报人</td></tr>
      <tr><td class="c">3</td><td>2024 年 1–9 月口径</td><td>无月度 JSON 明细（MD 已列 2,466.9 万）</td><td>销售管理（可补 ERP 导出）</td></tr>
      <tr><td class="c">4</td><td>高风险账款账龄</td><td>按汇报人自述，未与财务对账</td><td>财务部 × 销售管理</td></tr>
      <tr><td class="c">5</td><td>竞品价格口径</td><td>单位不统一（元/支·元/ml·元/T·元/rxn·元/盒）</td><td>市场部统一口径后再横向比价</td></tr>
      </tbody></table>
      <div class="foot-note">本页缺口不影响前文结论；前文所有数字均有 JSON 或原始汇报件支撑。</div>
    </div>
  </div>
</section>""" % ("".join([
        kpi("增长", "+%.1f%%" % yoy, "", "同比真实，但增量集中于老客户放量", "cool"),
        kpi("风险", "%d 条" % cmp_open, "", "未闭环客诉，直接压制回款", "hot"),
        kpi("竞争", "%d 次" % top_mentions, "", "诺唯赞体系化打法已成主要威胁", "hot"),
    ]), len(recon_bad), esc(recon_note))


def p_appendix():
    return """
<section class="slide" data-t="附录 · 数据源与口径">
  <div class="slide-head"><div class="kicker">APPENDIX</div><h2>数据源与口径</h2>
  <div class="sub">全部结论可回溯；口径与生成时间如下</div></div>
  <div class="slide-body"><div class="two" style="grid-template-columns:1.15fr 1fr;flex:1">
    <div class="card"><h3>数据源</h3>
      <table class="data"><tbody>
      <tr><td><b>① 半年度汇报</b></td><td>%s（34 份）→ 6 子 Agent 结构化提炼</td></tr>
      <tr><td><b>② Q3 飞书活动</b></td><td>飞书活动导出 · <b>%s</b> 条（Q3 全量）</td></tr>
      <tr><td><b>③ 客诉</b></td><td>飞书客诉导出 · 全量 %s 条（Q3 %s 条）</td></tr>
      <tr><td><b>④ ERP</b></td><td>%s ｜ %s ｜ %s</td></tr>
      <tr><td><b>⑤ 竞品</b></td><td>Q3 活动原文命中 + 半年度汇报竞品板块（品牌别名归一）</td></tr>
      </tbody></table>
      <h3 style="margin-top:14px">口径</h3>
      <div class="infobox">ERP：%s<br>
      客诉链接：<span class="mono">%s</span><br>
      活动链接：<span class="mono">%s</span><br>
      生成时间：%s</div>
    </div>
    <div class="card"><h3>可复算入口</h3>
      <table class="data"><tbody>
      <tr><td><span class="mono">_汇总/人员提炼_H1.json</span></td><td>① 人员结构化数据</td></tr>
      <tr><td><span class="mono">_汇总/Q3活动客诉竞品.json</span></td><td>② 活动 / 客诉明细</td></tr>
      <tr><td><span class="mono">_汇总/Q3竞品情报台账.json</span></td><td>⑤ 竞品台账与证据</td></tr>
      <tr><td><span class="mono">_汇总/erp_yoy.json</span></td><td>④ 四年同期明细</td></tr>
      <tr><td><span class="mono">_汇总/Q3汇报数据汇总.md</span></td><td>全量文本版（1,762 行）</td></tr>
      <tr><td><span class="mono">脚本/q3_yoy_compare.py</span></td><td>④ 复算脚本</td></tr>
      <tr><td><span class="mono">脚本/gen_q3_report_deck.py</span></td><td>本 deck 生成器</td></tr>
      </tbody></table>
      <div class="foot-note">本 deck 由生成器从上述 JSON 读取数据绘制，未手工录入任何数字。</div>
    </div>
  </div></div>
</section>""" % (esc(people_j["_meta"]["source_raw"]), format(act_total, ","),
                 cmp_all, cmp_total,
                 esc(erp["source_2026"]), esc(erp["source_2025"]), esc(erp["source_2024"]),
                 esc(erp["caliber"]), esc(act_j["_meta"]["cmp_url"]), esc(act_j["_meta"]["act_url"]),
                 esc(erp["generated"]))


# ── 章节定义 + 页序（章节页码由 ORDER 索引自动推导，杜绝目录漂移）──
CHAPTERS = [
    ("01", "半年度汇报 · 人员提炼", f"{len(people_sorted)} 人/份目标达成、问题、下半年计划与所需支持"),
    ("02", "Q3 飞书活动与客诉", f"{act_total:,} 条活动 / {n_act_sales} 人；活动量不等于产出"),
    ("03", "关键客诉与风险事项", f"{cmp_total} 条客诉 · {cmp_open} 条未闭环 · 高风险账款 {len(risk_body)} 项"),
    ("04", "ERP 同比", f"2026 年 1–9 月 {t26:,.1f} 万，同比 +{yoy}%"),
    ("05", "主要竞争对手", f"{comp_total_brands} 个品牌；诺唯赞 {top_mentions} 次提及，断层第一"),
]
ORDER = [
    ("cover", p_cover), ("toc", p_toc), ("summary", p_summary),
    ("ch0", None), ("people", p_people), ("peoplechart", p_people_chart),
    ("ch1", None), ("act", p_act_overview), ("actout", p_act_output), ("actcust", p_act_customers),
    ("ch2", None), ("cmp", p_cmp_overview), ("cmplist", p_cmp_list), ("risk", p_risk),
    ("ch3", None), ("erptot", p_erp_total), ("erpm", p_erp_monthly),
    ("erpd", p_erp_dept), ("erpt", p_erp_top),
    ("ch4", None), ("comp", p_comp_heat), ("inv", p_comp_inv), ("compsum", p_comp_summary),
    ("concl", p_conclusion), ("app", p_appendix),
]
IDX = {k: i for i, (k, _) in enumerate(ORDER)}
TOC = [(c[0], c[1], c[2], IDX["ch%d" % i]) for i, c in enumerate(CHAPTERS)]
TOC.append(("附录", "数据源与口径", "口径自校验与数据缺口说明", IDX["app"]))
PAGES = [fn() if fn else chapter(*CHAPTERS[int(k[2:])], 0) for k, fn in ORDER]
print("页序: " + " ".join("P%d=%s" % (i + 1, k) for i, (k, _) in enumerate(ORDER)))

SIDE = []
for i, (no, nm, ds, pg) in enumerate(TOC, 1):
    SIDE.append('<a href="javascript:void(0)" data-pg="%d"><i>%s</i><span>%s</span></a>' % (pg, no, esc(nm)))
SIDE = "".join(SIDE)

HTML = """<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>宝锐生物 2026 三季度汇报 · 数据汇总</title>
<style>%s</style>
</head>
<body>
<div class="shell">
<aside class="side"><h4>目录</h4>%s</aside>
<div class="wrap">
  <div class="bar">
    <span class="title" id="ttl">封面</span>
    <button class="btn" onclick="go(CUR-1)">‹ 上一页</button>
    <button class="btn" onclick="go(CUR+1)">下一页 ›</button>
    <button class="btn" id="btnToc" onclick="toggleSide()">目录</button>
    <button class="btn" id="btnEdit" onclick="toggleEdit()">✎ 编辑</button>
    <button class="btn" id="rvBtn" onclick="toggleReview()">✓ 审稿</button>
    <button class="btn" onclick="togglePres()">▶ 演示</button>
    <span class="spacer"></span>
    <span class="pg"><b id="pgNow">1</b> / <span id="pgAll">%d</span></span>
  </div>
  <div class="stage"><div class="deck" id="deck">%s</div><div class="prog"><i id="progBar"></i></div></div>
</div>
</div>
<aside class="rvPanel" id="rvPanel" aria-hidden="true">
  <div class="rvHead"><b>审稿模式</b><span class="rvStat" id="rvStat">—</span></div>
  <div class="rvList" id="rvList"></div>
  <div class="rvNote"><textarea id="rvNote" placeholder="本页备注（可写修改意见 / 待补数据 / 责任人口径）…"></textarea></div>
  <div class="rvFoot">
    <button class="btn" onclick="rvMark(1)">✓ 本页已确认</button>
    <button class="btn" onclick="rvMark(2)">✎ 本页需修改</button>
    <button class="btn" onclick="rvClear()">○ 重置本页</button>
    <button class="btn" onclick="rvExport()">⤓ 导出审稿 JSON</button>
  </div>
</aside>
<script>window.__Q3__=%s;</script>
<script>%s</script>
</body></html>"""

JS = r"""
var D=window.__Q3__, CUR=0;
var SL=document.querySelectorAll('.slide'), N=SL.length;
document.getElementById('pgAll').textContent=N;
function fmt(n,d){d=d==null?1:d;return (n==null?'—':Number(n).toFixed(d));}
function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c];});}
function tierOf(r){ if(r==null) return ['na','原表未填']; if(r>=70) return ['good','健康'];
 if(r>=50) return ['watch','关注']; if(r>=20) return ['warn','警示']; return ['bad','严重']; }
function el(id){return document.getElementById(id);}

/* ── 翻页 ── */
function isTyping(e){var t=e.target; if(!t) return false;
 return t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName) || e.isComposing || e.keyCode===229;}
function go(i){
  if(i<0||i>=N) return; CUR=i;
  for(var k=0;k<N;k++) SL[k].classList.toggle('on',k===i);
  el('ttl').textContent = SL[i].getAttribute('data-t')||'';
  el('pgNow').textContent = i+1;
  el('progBar').style.width = ((i+1)/N*100)+'%';
  try{ if(location.hash!=='#p='+(i+1)) history.replaceState(null,'','#p='+(i+1)); }catch(e){}
  var box=SL[i].querySelector('.scroll'); if(box) box.scrollTop=0;
  if(document.body.classList.contains('rv')){
    rvLoadNote(); rvPaint(i);
    var it=document.querySelector('.rvItem.on'); if(it) it.classList.remove('on');
    var ni=document.querySelector('.rvItem[data-i="'+i+'"]'); if(ni) ni.classList.add('on');
  }
  var as=document.querySelectorAll('.side a');
  for(var j=0;j<as.length;j++){
    var p=+as[j].getAttribute('data-pg'); var nx=(j+1<as.length)?+as[j+1].getAttribute('data-pg'):N+1;
    as[j].classList.toggle('on', i>=p && i<nx);
  }
  drawOn(i);
}
document.addEventListener('keydown',function(e){
  if(isTyping(e)) return;
  if(e.key==='ArrowRight'||e.key==='PageDown'||e.key===' '){e.preventDefault();go(CUR+1);}
  else if(e.key==='ArrowLeft'||e.key==='PageUp'){e.preventDefault();go(CUR-1);}
  else if(e.key==='Home'){e.preventDefault();go(0);}
  else if(e.key==='End'){e.preventDefault();go(N-1);}
  else if(e.key==='r'||e.key==='R'){e.preventDefault();toggleReview();}
  else if(e.key==='e'||e.key==='E'){e.preventDefault();toggleEdit();}
});
function toggleSide(){document.querySelector('.side').style.display =
  (document.querySelector('.side').style.display==='none')?'flex':'none';}
function toggleEdit(){
  var on=!document.body.classList.contains('edit');
  document.body.classList.toggle('edit',on);
  var b=el('btnEdit'); b.classList.toggle('on',on); b.textContent=on?'✎ 编辑中':'✎ 编辑';
  var sel='table.data td, .kpi .vl, .kpi .nt, .slide-head h2, .slide-head .sub, .warnbox, .infobox, ul.tight li';
  var ns=document.querySelectorAll(sel);
  for(var i=0;i<ns.length;i++){ if(on) ns[i].setAttribute('contenteditable','true'); else ns[i].removeAttribute('contenteditable'); }}
function togglePres(){document.body.classList.toggle('pres'); fit();}

/* ── 缩放适配 ── */
function fit(){
  var st=document.querySelector('.stage'), dk=el('deck');
  var pad=document.body.classList.contains('pres')?0:14;
  var W=st.clientWidth-pad*2, Hh=st.clientHeight-pad*2;
  var s=Math.min(W/1280,Hh/720);
  dk.style.transform='scale('+s+')';
  dk.style.left=Math.max(0,(st.clientWidth-1280*s)/2)+'px';
  dk.style.top=Math.max(0,(st.clientHeight-720*s)/2)+'px';
}
window.addEventListener('resize',fit);

/* ── 简易 SVG 图表（零依赖，数据来自 tables）── */
var DRAWN={};
function hbar(host,rows,opt){
  opt=opt||{}; var mx=Math.max.apply(null,rows.map(function(r){return r.v;}).concat([1]));
  var unit=opt.unit||'', W=opt.w||100;
  var html='<div style="display:grid;gap:6px">';
  rows.forEach(function(r){
    var pct=Math.max(1.5, r.v/mx*100);
    var c=r.c||'var(--blue)';
    html+='<div style="display:grid;grid-template-columns:'+(opt.lw||130)+'px 1fr '+(opt.vw||78)+'px;gap:8px;align-items:center;font-size:12px">'
      +'<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="'+esc(r.k)+'">'+esc(r.k)+'</span>'
      +'<span style="background:#eef1f6;border-radius:4px;height:14px;display:block;overflow:hidden">'
      +'<i style="display:block;height:100%;width:'+pct.toFixed(1)+'%;background:'+c+';border-radius:4px"></i></span>'
      +'<span style="text-align:right;font-variant-numeric:tabular-nums;font-weight:600">'+fmt(r.v,r.d)+unit+'</span></div>';
  });
  host.innerHTML=html+'</div>';
}
function donut(host,parts,opt){
  opt=opt||{}; var tot=parts.reduce(function(a,b){return a+b.v;},0)||1;
  var acc=0, seg=parts.map(function(p){var a=acc/tot*360; acc+=p.v; var b=acc/tot*360;
    return p.c+' '+a.toFixed(2)+'deg '+b.toFixed(2)+'deg';}).join(',');
  var legend=parts.map(function(p){return '<div style="display:flex;align-items:center;gap:6px;font-size:12px;margin:3px 0">'
    +'<i style="width:11px;height:11px;border-radius:3px;background:'+p.c+';display:inline-block"></i>'
    +'<span style="flex:1">'+esc(p.k)+'</span><b>'+p.v+'</b>'
    +'<span style="color:var(--mut)">('+(p.v/tot*100).toFixed(1)+'%)</span></div>';}).join('');
  host.innerHTML='<div style="display:flex;gap:16px;align-items:center">'
    +'<div style="width:118px;height:118px;border-radius:50%;flex:none;background:conic-gradient('+seg+');'
    +'mask:radial-gradient(circle,transparent 36%,#000 37%);-webkit-mask:radial-gradient(circle,transparent 36%,#000 37%)"></div>'
    +'<div style="flex:1">'+legend+'</div></div>';
}
function vbars(host,rows,opt){
  opt=opt||{}; var mx=Math.max.apply(null,rows.map(function(r){return r.v;}).concat([1]));
  var h=opt.h||150;
  var html='<div style="display:flex;align-items:flex-end;gap:'+(opt.gap||8)+'px;height:'+(h+34)+'px">';
  rows.forEach(function(r){
    var bh=Math.max(2,r.v/mx*h);
    html+='<div style="flex:1;display:flex;flex-direction:column;align-items:center;justify-content:flex-end;height:100%">'
      +'<span style="font-size:10.5px;font-weight:700;color:#33425c;margin-bottom:3px">'+fmt(r.v,opt.d==null?0:opt.d)+'</span>'
      +'<i style="display:block;width:100%;max-width:46px;height:'+bh.toFixed(1)+'px;border-radius:4px 4px 0 0;background:'+(r.c||'var(--blue)')+'"></i>'
      +'<span style="font-size:10.5px;color:var(--mut);margin-top:4px">'+esc(r.k)+'</span></div>';
  });
  host.innerHTML=html+'</div>';
}
function groupBars(host,labels,series,opt){
  opt=opt||{}; var mx=1;
  series.forEach(function(s){s.v.forEach(function(v){if(v>mx)mx=v;});});
  var h=opt.h||150;
  var html='<div style="display:flex;align-items:flex-end;gap:10px;height:'+(h+40)+'px">';
  labels.forEach(function(lb,i){
    html+='<div style="flex:1;display:flex;flex-direction:column;justify-content:flex-end;height:100%">'
      +'<div style="display:flex;gap:3px;align-items:flex-end;height:'+h+'px;justify-content:center">';
    series.forEach(function(s){
      var bh=Math.max(1,s.v[i]/mx*h);
      html+='<i style="display:block;width:13px;height:'+bh.toFixed(1)+'px;border-radius:3px 3px 0 0;background:'+s.c+'" title="'+esc(s.n)+' '+fmt(s.v[i],0)+'"></i>';
    });
    html+='</div><span style="font-size:10.5px;color:var(--mut);margin-top:5px;text-align:center">'+esc(lb)+'</span></div>';
  });
  var lg=series.map(function(s){return '<span style="display:inline-flex;align-items:center;gap:5px;font-size:11.5px;margin-right:14px">'
    +'<i style="width:11px;height:11px;border-radius:3px;background:'+s.c+';display:inline-block"></i>'+esc(s.n)+'</span>';}).join('');
  host.innerHTML=html+'</div><div style="margin-top:8px">'+lg+'</div>';
}

/* ── 填充各页 ── */
function fill(){
  /* ① 人员分档 */
  var t=D.tiers;
  donut(el('chTier'),[{k:'健康 ≥70%',v:t.good,c:'#0f9d58'},{k:'关注 50–70%',v:t.watch,c:'#e8a300'},
    {k:'警示 20–50%',v:t.warn,c:'#ef6c00'},{k:'严重 <20%',v:t.bad,c:'#d81b26'},{k:'原表未填',v:t.na,c:'#9aa6b8'}]);
  var ps=D.people, top=ps.slice(0,12), rest=ps.slice(-8);
  hbar(el('chRank'), top.concat([{k:'……',v:0,c:'#fff',d:0}]).concat(rest)
    .map(function(p){return {k:p.k||p.name, v:p.v!=null?p.v:(p.rate||0), c:'var(--blue)'};}), {lw:74,vw:50,unit:'%',d:1});

  /* ② 活动 */
  vbars(el('chActMonth'), Object.keys(D.act.months).sort().map(function(k){
    return {k:k.slice(5)+'月', v:D.act.months[k]};}), {h:150});
  hbar(el('chActSales'), D.act.by_sales.slice(0,12).map(function(s){return {k:s.name,v:s.total};}), {lw:64,vw:56});
  var b=el('tbActSales'); if(b){
    b.innerHTML = D.act.by_sales.map(function(s,i){
      var m=s.months||{};
      return '<tr><td class="c mono">'+(i+1)+'</td><td><b>'+esc(s.name)+'</b></td><td class="n mono"><b>'+s.total+'</b></td>'
        +'<td class="n mono">'+(m['2026-07']||0)+'</td><td class="n mono">'+(m['2026-08']||0)+'</td>'
        +'<td class="n mono">'+(m['2026-09']||0)+'</td></tr>';}).join('');
  }
  /* 活动 vs 产出 */
  var erpSp={}; D.erp.top_sp.forEach(function(s){erpSp[s.name]=s.wan;});
  var rows=D.act.by_sales.map(function(s){
    var y=erpSp[s.name]; var per=(y!=null&&s.total>0)? (y/s.total*100) : null;
    return {name:s.name, n:s.total, y:y, per:per};
  }).sort(function(a,b){return b.n-a.n;});
  var tb=el('tbActOut'); if(tb){
    tb.innerHTML = rows.map(function(r,i){
      var j = r.y==null ? '<span class="tag na">无出单</span>'
        : (r.per<1 ? '<span class="tag bad">活动高·产出极低</span>'
        : r.per<6 ? '<span class="tag warn">偏低</span>'
        : r.per<15 ? '<span class="tag watch">一般</span>' : '<span class="tag good">匹配</span>');
      return '<tr><td class="c mono">'+(i+1)+'</td><td><b>'+esc(r.name)+'</b></td><td class="n mono">'+r.n+'</td>'
        +'<td class="n mono">'+(r.y==null?'—':fmt(r.y,1))+'</td>'
        +'<td class="n mono">'+(r.per==null?'—':fmt(r.per,2))+'</td><td>'+j+'</td></tr>';}).join('');
  }
  var nt=el('actOutNote');
  if(nt){ var sci=rows.filter(function(r){return r.per!=null&&r.per<3;}).slice(0,5).map(function(r){return r.name;});
    nt.innerHTML='<b>重点判读：</b>活动条数 TOP 6 中有 5 人来自生命科学（科研）线，'
      +'但其 ERP YTD 产出与活动量严重不匹配（如 '+esc(sci.join('、'))+' 的「万元/百条活动」均低于 3）。'
      +'「活动量」是过程指标，不能代替结果指标；建议把科研线的活动质量（有效客户数、测试转化）单独设指标。';}
  /* 高频客户 */
  var tc=el('tbActCust');
  if(tc){ var names=Object.keys(D.act.customers);
    names.sort(function(a,b){var A=D.act.customers[a],B=D.act.customers[b];
      return (B[Object.keys(B)[0]]||0)-(A[Object.keys(A)[0]]||0);});
    tc.innerHTML=names.map(function(nm,i){
      var o=D.act.customers[nm], ks=Object.keys(o).slice(0,5);
      return '<tr><td class="c mono">'+(i+1)+'</td><td><b>'+esc(nm)+'</b></td><td style="font-size:11.5px">'
        + ks.map(function(k){return esc(k)+' <b>('+o[k]+')</b>';}).join('　›　')+'</td></tr>';}).join('');
  }
  /* ③ 客诉 */
  var ar=D.cmp.area; donut(el('chCmpArea'), Object.keys(ar).map(function(k,i){
    return {k:k, v:ar[k], c:['#d81b26','#e8a300','#4874CB','#0f9d58','#8e6ad6'][i%5]};}));
  donut(el('chCmpStat'), [{k:'未闭环',v:D.cmp.open,c:'#d81b26'},{k:'已闭环',v:D.cmp.closed,c:'#0f9d58'}]);
  hbar(el('chCmpCreator'), D.cmp.creators.slice(0,8).map(function(c){return {k:c.name,v:c.n};}), {lw:58,vw:34,unit:' 条',d:0});
  var tbc=el('tbCmpOpen');
  if(tbc){ tbc.innerHTML = D.cmp.open_items.slice().sort(function(a,b){return (b.days||0)-(a.days||0);})
      .map(function(c){
        var lv = c.area==='大客户' ? '<span class="tag bad">P0</span>' : '<span class="tag warn">P1</span>';
        return '<tr><td class="c">'+lv+'</td><td class="c mono">'+(c.days==null?'—':c.days)+'</td>'
        +'<td style="font-size:11.5px">'+esc(c.name)+'</td><td style="font-size:11.5px">'+esc(c.customer)+'</td>'
        +'<td>'+esc(c.creator)+'</td><td class="c"><span class="tag mid">'+esc(c.area)+'</span></td>'
        +'<td class="c"><a href="'+esc(c.url)+'" target="_blank" rel="noopener">飞书 ↗</a></td></tr>';}).join('');}
  /* 风险台账 */
  var tbr=el('tbRisk');
  if(tbr){ tbr.innerHTML = D.risk.rows.map(function(r){
      return '<tr>'+r.map(function(c,i){ return '<td'+(i===2?' class="n mono"':'')+'>'+c.replace(/\*\*/g,'')+'</td>'; }).join('')+'</tr>';}).join('');}
  /* ④ ERP */
  vbars(el('chErpTotal'), [
    {k:'2024 全年', v:D.erp.t24f, c:'#9aa6b8'},
    {k:'2025 1–9月', v:D.erp.t25y, c:'#9aa6b8'},
    {k:'2025 全年', v:D.erp.t25f, c:'#4874CB'},
    {k:'2026 1–9月', v:D.erp.t26, c:'#1852B6'}], {h:150,d:0});
  vbars(el('chErpChain'), [
    {k:'2024 1–9月', v:D.erp.m24_9, c:'#9aa6b8'},
    {k:'2025 1–9月', v:D.erp.t25y, c:'#4874CB'},
    {k:'2026 1–9月', v:D.erp.t26, c:'#1852B6'}], {h:120,d:0});
  var mk=Object.keys(D.erp.month).sort();
  groupBars(el('chErpMonth'), mk.map(function(k){return k.slice(5)+'月';}), [
    {n:'2025 同期', c:'#9aa6b8', v:mk.map(function(k){return D.erp.month25[k]||0;})},
    {n:'2026', c:'#1852B6', v:mk.map(function(k){return D.erp.month[k];})}], {h:190});
  var tbt=el('tbTarget');
  if(tbt){ tbt.innerHTML = D.erp.targets.map(function(t){
    var r=t.rate==null?0:t.rate; var c=r>=60?'good':r>=40?'watch':'warn';
    return '<tr><td><b>'+esc(t.dept)+'</b></td><td class="n mono">'+fmt(t.target,0)+'</td>'
      +'<td class="n mono">'+fmt(t.ytd,1)+'</td><td class="n mono"><b>'+(t.rate==null?'—':fmt(t.rate,2))+'%</b></td>'
      +'<td style="width:150px"><div class="bar-in"><i class="'+c+'" style="width:'+Math.min(r,100)+'%"></i></div></td></tr>';}).join('');}
  hbar(el('chErpDept'), Object.keys(D.erp.dept).map(function(k){return {k:k,v:D.erp.dept[k]};}), {lw:78,vw:70,unit:' 万'});
  hbar(el('chErpQ'), Object.keys(D.erp.quarter).map(function(k){return {k:k.replace('2026-',''),v:D.erp.quarter[k]};}), {lw:44,vw:70,unit:' 万'});
  var t1=el('tbTopCust');
  if(t1){ t1.innerHTML=D.erp.top_cust.map(function(c,i){
    return '<tr><td class="c mono">'+(i+1)+'</td><td style="font-size:11.5px">'+esc(c.name)+'</td>'
      +'<td class="n mono">'+fmt(c.wan,1)+'</td></tr>';}).join('');}
  var t2=el('tbTopSp');
  if(t2){ var tot=D.erp.t26; t2.innerHTML=D.erp.top_sp.map(function(s,i){
    var pc=s.wan/tot*100;
    return '<tr><td class="c mono">'+(i+1)+'</td><td><b>'+esc(s.name)+'</b></td><td class="n mono">'+fmt(s.wan,1)+'</td>'
      +'<td style="width:96px"><div class="bar-in"><i style="width:'+Math.min(pc*3,100).toFixed(0)+'%"></i></div>'
      +'<span style="font-size:10px;color:var(--mut)">'+pc.toFixed(1)+'%</span></td></tr>';}).join('');}
  /* ⑤ 竞品 */
  hbar(el('chCompHeat'), D.comp.brands.slice(0,18).map(function(b){
    return {k:b.name, v:b.q3, c: b.name==='诺唯赞' ? '#d81b26' : 'var(--blue)'};}),
    {lw:88,vw:56,d:0});
  var tbc2=el('tbComp');
  if(tbc2){ tbc2.innerHTML=D.comp.brands.map(function(b,i){
    return '<tr><td class="c mono">'+(i+1)+'</td><td>'+esc(b.name)+'</td>'
      +'<td class="n mono">'+b.q3+'</td><td class="n mono">'+(b.h1==null?0:b.h1)+'</td></tr>';}).join('');}
  hbar(el('chInvReaders'), D.comp.inv.readers.map(function(r){return {k:r.name,v:r.n,c:'#d81b26'};}),
    {lw:58,vw:40,unit:' 次',d:0});
  var iev=el('invEv');
  if(iev){ iev.innerHTML=D.comp.inv.evidence.map(function(e){
    var t=String(e.text||'').slice(0,160).replace(/\n+/g,' ');
    return '<div class="ev"><div class="meta">'+esc(e.date)+' ｜ <b>'+esc(e.sp)+'</b> ｜ '+esc(e.customer)
      +' ｜ <a href="'+esc(e.url)+'" target="_blank" rel="noopener">原文 ↗</a></div>'+esc(t)+'…</div>';}).join('');}
  var tcs=el('tbCompSummary');
  if(tcs){
    var rows2=[
      ['诺唯赞声量占全部竞品命中的 '+D.comp.share1.toFixed(1)+'%，已从单点产品比价升级为「原料+终端试剂+仪器」一体化体系战',
       'Q3 活动 '+D.comp.brands[0].q3+' 次提及 + 半年报竞品记录 '+D.comp.brands[0].h1+' 条'],
      ['科研线是被渗透最深的场景：诺唯赞提及者前 5 全部来自生命科学（科研）线',
       'Q3 活动提及者分布（本页左图）'],
      ['竞品价格战在诊断线仍在持续，且多为「低价抢单」',
       '半年报竞品板块：瀚海新酶「超低价策略蚕食低端市场」、诺唯赞「华中地区竞争性低价」'],
      ['我方仍以单点货号比价应对，缺少体系对标与竞品价格台账',
       '半年报多位销售自述「面对竞品低价谈判被动」「技术壁垒梳理不透彻，只单纯比价」'],
      ['客诉与竞品防守互相咬合：性能问题（批间差/污染/沉淀）直接放大竞品可乘之机',
       'Q3 客诉 '+D.cmp.total+' 条、未闭环 '+D.cmp.open+' 条']
    ];
    tcs.innerHTML=rows2.map(function(r,i){
      return '<tr><td class="c mono">'+(i+1)+'</td><td>'+esc(r[0])+'</td><td style="font-size:11px;color:#56617a">'+esc(r[1])+'</td></tr>';}).join('');
  }
}

/* ── 封面/章节 粒子背景（方案 D）── */
function particles(cv,opt){
  if(!cv) return; var dpr=Math.min(window.devicePixelRatio||1,2);
  /* ⚠️ 必须用未缩放的布局尺寸：.deck 被 transform:scale() 缩放，
     getBoundingClientRect() 返回的是缩放后尺寸 → canvas 位图与显示尺寸不匹配会被拉伸变形 */
  function size(){var r=cv.parentElement; var w=r.offsetWidth||r.clientWidth, h=r.offsetHeight||r.clientHeight;
    cv.width=Math.max(1,w*dpr); cv.height=Math.max(1,h*dpr);}
  size(); var ctx=cv.getContext('2d');
  var o=opt||{}, NPV=o.n||44, TH=(o.th||150)*dpr, LINK=o.link!==false;
  var ps=[]; for(var i=0;i<NPV;i++) ps.push({x:Math.random()*cv.width,y:Math.random()*cv.height,
    vx:(Math.random()-.5)*.22*dpr,vy:(Math.random()-.5)*.22*dpr,r:(Math.random()*1.5+.7)*dpr});
  function step(){
    ctx.clearRect(0,0,cv.width,cv.height);
    ps.forEach(function(p){p.x+=p.vx;p.y+=p.vy;
      if(p.x<0||p.x>cv.width)p.vx*=-1; if(p.y<0||p.y>cv.height)p.vy*=-1;});
    if(LINK){for(var i=0;i<ps.length;i++)for(var j=i+1;j<ps.length;j++){
      var a=ps[i],b=ps[j],dx=a.x-b.x,dy=a.y-b.y,d=Math.sqrt(dx*dx+dy*dy);
      if(d<TH){ctx.strokeStyle='rgba(143,182,255,'+(0.30*(1-d/TH)).toFixed(3)+')';
        ctx.lineWidth=1*dpr;ctx.beginPath();ctx.moveTo(a.x,a.y);ctx.lineTo(b.x,b.y);ctx.stroke();}}}
    ctx.fillStyle='rgba(180,208,255,.85)';
    ps.forEach(function(p){ctx.beginPath();ctx.arc(p.x,p.y,p.r,0,6.283);ctx.fill();});
    requestAnimationFrame(step);
  }
  step();
  window.addEventListener('resize',size);
}

var DONE={};
function drawOn(i){
  if(DONE[i]) return; DONE[i]=1;
  var s=SL[i], d=s.getAttribute('data-t')||'';
  if(s.classList.contains('cover')) particles(el('cvCover'),{n:52,th:170});
  var ch=s.querySelector('.chCv'); if(ch) particles(ch,{n:34,th:150});
}
/* ── 审稿模式（逐页确认 / 备注 / 徽章 / 导出）───────────────── */
var RV={st:{},nt:{}}, RVLBL={0:'未审',1:'已确认',2:'需修改'};
function rvOn(){return document.body.classList.contains('rv');}
function rvBuild(){
  var list=el('rvList'); if(!list||list.dataset.built) return;
  var h='';
  for(var i=0;i<N;i++){
    var t=SL[i].getAttribute('data-t')||('第'+(i+1)+'页');
    h+='<div class="rvItem" data-i="'+i+'" onclick="go('+i+')">'
      +'<span class="rvNo">'+(i+1)+'</span><span class="rvT">'+esc(t)+'</span>'
      +'<span class="rvB s0" id="rvb'+i+'">○</span></div>';
    if(!SL[i].querySelector('.rvSlideBadge')){
      var bd=document.createElement('div'); bd.className='rvSlideBadge s0'; bd.textContent='○ 未审';
      SL[i].appendChild(bd);
    }
  }
  list.innerHTML=h; list.dataset.built='1';
}
function rvPaint(i){
  var st=RV.st[i]||0, b=el('rvb'+i);
  if(b){b.textContent=(st===1?'✓':(st===2?'✎':'○')); b.className='rvB s'+st;}
  var sb=SL[i].querySelector('.rvSlideBadge');
  if(sb){sb.textContent=(st===1?'✓ 已确认':(st===2?'✎ 需修改':'○ 未审')); sb.className='rvSlideBadge s'+st;}
}
function rvStat(){
  var a=0,b=0,c=0;
  for(var i=0;i<N;i++){var st=RV.st[i]||0; if(st===1)a++; else if(st===2)b++; else c++;}
  var e=el('rvStat'); if(e) e.innerHTML='✓ '+a+' ｜ ✎ '+b+' ｜ ○ '+c;
  var btn=el('rvBtn'); if(btn) btn.textContent='✓ 审稿'+(a?('（'+a+'/'+N+'）'):'');
  return [a,b,c];
}
function rvLoadNote(){ var t=el('rvNote'); if(t){t.value=RV.nt[CUR]||''; delete t.dataset._orig;} }
function rvMark(st){
  RV.st[CUR]=st;
  var t=el('rvNote'); if(t) RV.nt[CUR]=t.value;
  rvPaint(CUR); rvStat();
}
function rvClear(){
  var t=el('rvNote');
  RV.st[CUR]=0;
  RV.nt[CUR]=(t&&t.dataset._orig!=null)?t.dataset._orig:'';
  if(t){t.value=RV.nt[CUR]; delete t.dataset._orig;}
  rvPaint(CUR); rvStat();
}
function rvExport(){
  var rows=[], s3=rvStat();
  for(var i=0;i<N;i++){
    rows.push({page:i+1, title:SL[i].getAttribute('data-t')||'', status:RVLBL[RV.st[i]||0], note:RV.nt[i]||''});
  }
  var out={deck:'宝锐生物 2026 三季度汇报 · 数据汇总', exported:new Date().toISOString(),
    summary:{confirmed:s3[0], needfix:s3[1], unreviewed:s3[2], total:N}, pages:rows};
  var blob=new Blob([JSON.stringify(out,null,2)],{type:'application/json'});
  var a=document.createElement('a'); a.href=URL.createObjectURL(blob);
  a.download='Q3汇报deck_审稿数据.json'; document.body.appendChild(a); a.click();
  setTimeout(function(){URL.revokeObjectURL(a.href); a.remove();},0);
}
function toggleReview(){
  document.body.classList.toggle('rv');
  var b=el('rvBtn'); b.classList.toggle('on',rvOn());
  rvBuild(); rvLoadNote();
  for(var i=0;i<N;i++) rvPaint(i);
  rvStat();
  fit(); setTimeout(fit,280); setTimeout(fit,560);   /* 过渡期间/结束后都重算，防止内容被裁 */
}
(function(){                                        /* 面板宽度变化结束 → 重算 */
  var w=document.querySelector('.wrap'); if(!w) return;
  w.addEventListener('transitionend',function(e){
    if(e.propertyName==='margin-right'||e.propertyName==='margin') fit();
  });
})();
(function(){                       /* 铁律2：快照挂 focusin，不是 input */
  var t=el('rvNote'); if(!t) return;
  t.addEventListener('focusin',function(){ if(t.dataset._orig==null) t.dataset._orig=t.value; });
  t.addEventListener('input',function(){ RV.nt[CUR]=t.value; });
})();

fill(); fit();
/* 布局稳定后再算：字体加载/加载完成/双 rAF 各一次，防止用陈旧舞台尺寸缩放导致裁切 */
window.addEventListener('load',function(){fit();setTimeout(fit,120);});
requestAnimationFrame(function(){fit();requestAnimationFrame(fit);});
if(document.fonts&&document.fonts.ready) document.fonts.ready.then(function(){fit();});
var _hh=location.hash||'';          /* ⚠️ 必须在 go() 之前取：go() 里的 history.replaceState 会重写 hash，吃掉 &rv=1 */
var _h=_hh.match(/p=(\d+)/);
go(_h ? Math.min(Math.max(parseInt(_h[1],10)-1,0),N-1) : 0);
if(/rv=1/.test(_hh)) toggleReview();   /* #p=5&rv=1 直接进审稿 */
window.addEventListener('hashchange',function(){var m=(location.hash||'').match(/p=(\d+)/);
  if(m){var k=Math.min(Math.max(parseInt(m[1],10)-1,0),N-1); if(k!==CUR) go(k);}});
window.addEventListener('resize',fit);
"""

body = "".join(PAGES)
html = HTML % (CSS, SIDE, len(PAGES), body, DATA_JSON, JS)

# 品牌注入（自动插页眉/页脚；模板不得自建）
html = BK.inject(html, theme="dark", tag="营销中心",
                 footer_extra="宝锐生物 2026 三季度汇报 · 数据汇总 ｜ 数据源：飞书活动/客诉导出 · ERP 销售订单导出")

os.makedirs(os.path.dirname(OUT), exist_ok=True)
with open(OUT, "w", encoding="utf-8") as f:
    f.write(html)
print("✅ 已生成 %s" % OUT)
print("   页数 %d ｜ %.1f KB" % (len(PAGES), os.path.getsize(OUT) / 1024.0))
v = BK.verify(html)
bad = [k for k, ok in v.items() if not ok]
print("   品牌自检 %s %s" % ("✅" if not bad else "❌", "" if not bad else bad))
print("   页眉元素 %d 个（应为 2）｜ 页脚元素 %d 个（应为 2）"
      % (html.count('class="bior-header'), html.count('class="bior-footer')))
