#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Q3 汇报数据包 → 自包含翻页式 HTML 汇报 deck（零外部依赖）
================================================================
数据只从 2026年3季度汇报/_汇总/*.json + Q3汇报数据汇总.md 读取，**不手写任何数字**。
新增「客户集中度与同比桥」页的数据只从 数据/三部门逐月_2026.json（真实 ERP 现算、
已过 4 道门禁）与 数据/diag_mappings.json（集团成员映射 cust_to_group）读取。
用法：
    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 = json.load(open(os.path.join(ROOT, "数据", "2026Q3人员提炼.json"), encoding="utf-8"))   # 2026-10-15 换源：原为 人员提炼_H1.json（2026 半年度口径）
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()

# ── 客户集中度与同比桥（实体 / 集团口径）新增数据源 ──────────────
# 权威源：数据/三部门逐月_2026.json（真实 ERP 现算，已过 4 道门禁）
#         数据/diag_mappings.json → cust_to_group（官方《诊断原料&生命科学客户清单》）
DJ = os.path.join(ROOT, "数据")
d3 = json.load(open(os.path.join(DJ, "三部门逐月_2026.json"), encoding="utf-8"))
GRP_MAP = json.load(open(os.path.join(DJ, "diag_mappings.json"), encoding="utf-8"))["cust_to_group"]
GSSET = {v for k, v in GRP_MAP.items() if v and v != k}   # 真实集团名集合（排除自身映射）
Q3M = ["2026-07", "2026-08", "2026-09"]


def _agg_q3(rows):
    """按 Q3(7–9月) 汇总每客户 2026 / 2025 同期金额（万元）。"""
    cur, prev = {}, {}
    for r in rows:
        if r.get("month") in Q3M:
            c = r["cust"]
            cur[c] = cur.get(c, 0.0) + (r.get("amt") or 0.0)
            prev[c] = prev.get(c, 0.0) + (r.get("amt_2025") or 0.0)
    return cur, prev


def _caliber(rows):
    """hybrid 口径汇总：起点/存量/新增/流失 同比桥 + Top 榜 + Top12 集中度。"""
    cur, prev = _agg_q3(rows)
    names = set(cur) | set(prev)
    both = [n for n in names if cur.get(n, 0) > 0 and prev.get(n, 0) > 0]
    new = [n for n in names if cur.get(n, 0) > 0 and prev.get(n, 0) <= 0]
    lost = [n for n in names if prev.get(n, 0) > 0 and cur.get(n, 0) <= 0]
    tot = round(sum(cur.values()), 1)
    s = sorted(cur.values(), reverse=True)
    return {
        "top": [{"name": k, "wan": round(v, 1)}
                for k, v in sorted(cur.items(), key=lambda kv: -kv[1])[:15]],
        "total": tot,
        "n_cust": len([1 for v in cur.values() if v > 0]),
        "conc12": round(sum(s[:12]) / sum(s) * 100, 1) if s else 0.0,
        "bridge": {"start": round(sum(prev.values()), 1),
                   "store": round(sum(cur[n] - prev[n] for n in both), 1),
                   "add": round(sum(cur[n] for n in new), 1),
                   "lose": round(-sum(prev[n] for n in lost), 1),
                   "end": tot,
                   "n_store": len(both), "n_add": len(new), "n_lost": len(lost)},
    }


CONC = {"ent": _caliber(d3["customers"]), "grp": _caliber(d3["customers_grp"])}
_grp_names = {r["cust"] for r in d3["customers_grp"]}          # 集团口径出现过的全部名称
CONC["n_groups"] = len([g for g in _grp_names if g in GSSET])  # 其中真正归集到集团的个数
assert CONC["n_groups"] == 59, CONC["n_groups"]                # 锚点：Q3 已归集 59 个集团
_q3_act = sum(d3["total"]["actual_monthly"][m] for m in Q3M)
_q3_tgt = sum(d3["total"]["target_monthly"][m] for m in Q3M)
_q3_prev = sum(d3["total"]["actual_2025"][m] for m in Q3M)
CONC["q3"] = {"total": round(_q3_act, 1), "target": round(_q3_tgt, 1),
              "rate": round(_q3_act / _q3_tgt * 100, 1),
              "yoy": round((_q3_act - _q3_prev) / _q3_prev * 100, 1)}
# 起点/终点统一取「三部门合计」口径（与 ④ 页同源），避免逐客户浮点加总造成末位漂移
for _k in ("ent", "grp"):
    CONC[_k]["total"] = CONC["q3"]["total"]
    CONC[_k]["bridge"]["start"] = round(_q3_prev, 1)
    CONC[_k]["bridge"]["end"] = CONC["q3"]["total"]
assert abs(CONC["ent"]["total"] - 1173.8) < 0.15, CONC["ent"]["total"]
assert abs(CONC["ent"]["conc12"] - 43.9) < 0.05 and abs(CONC["grp"]["conc12"] - 47.2) < 0.05, \
    (CONC["ent"]["conc12"], CONC["grp"]["conc12"])
print("诊断→ 集中度/桥：实体 %d 户 / 集团 %d 户｜Top12 %.1f%%→%.1f%%｜已归集 %d 个集团｜"
      "Q3 %.1f 万 达成 %.1f%% 同比 %+.1f%%"
      % (CONC["ent"]["n_cust"], CONC["grp"]["n_cust"], CONC["ent"]["conc12"],
         CONC["grp"]["conc12"], CONC["n_groups"], CONC["q3"]["total"],
         CONC["q3"]["rate"], CONC["q3"]["yoy"]))

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"]

# ── 销售员合并（用户 2026-10-05 定）────────────────────────────────────────
# 大客户1岗交接后，黄明月 / 张立娅 的业绩并入 刘子研，**按一个人统计**。
# 为什么在 by_sp 源头合并而不是只改榜单：by_sp 同时喂给「销售员 Top15 榜」和
# 「活动量 vs 产出」并表，只改一处会让人名在不同表里出现两次、口径打架。
# 对所有年份的 by_sp 都合并，保证同比口径一致。
SP_ALIAS = {"黄明月": "刘子研", "张立娅": "刘子研"}


def merge_sp_by(d):
    """把 d 里的销售员键按 SP_ALIAS 归并（金额相加）。"""
    out = {}
    for k, v in (d or {}).items():
        t = SP_ALIAS.get(k, k)
        out[t] = out.get(t, 0) + v
    return out


_merged_n = 0
for _k, _v in list(erp.items()):
    if isinstance(_v, dict) and isinstance(_v.get("by_sp"), dict):
        _before = len(_v["by_sp"])
        _v["by_sp"] = merge_sp_by(_v["by_sp"])
        _merged_n += _before - len(_v["by_sp"])
if _merged_n:
    print(f"销售员合并: 黄明月/张立娅 → 刘子研（共并入 {_merged_n} 人次）")

# 活动量 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 = {
    "conc": CONC,
    "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}
/* 客户集中度与同比桥（实体/集团口径）*/
.calbtn{padding:3px 11px;font-size:11.5px}
table.data tr.hlrow td{background:#fff6da!important}
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)}.rvB.s3{color:#b26a00}
.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}
.rvSlideBadge.s3{background:#fdf1dc;color:#b26a00}
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 人员目标达成提炼 ②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("① Q3 人员目标达成", "22", "人/份",
            "21 份 Q3 汇报原件结构化提炼（大客户部 2 / 销售拓展部 11 / 生命科学销售部 8；源件为 2026 三季度汇报）", "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">① Q3 人员目标达成</div><h2>人员目标达成总表</h2>
  <div class="sub">共 %d 人（Q3 实际报送：大客户部 2 / 销售拓展部 11 / 生命科学销售部 8）；达成率 = <b>3 季实绩 ÷ 2026 年度目标</b>（同 H1 版算法，仅换数据源），<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">2026目标(万)</th><th class="n">3季实绩(万)</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">① Q3 人员目标达成</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(
        # 空季度不写 0（保持空白），已有季度沿用原 "%.1f" 格式，避免无谓的呈现变更
        kpi("Q%d" % i, ("%.1f" % q_amt[k]) if q_amt.get(k) is not None else "—", "万",
            ("—" if (i == 1 or q_amt.get(k) is None or q_amt.get("2026-Q%d" % (i - 1)) is None)
             else "环比 <b>%+.1f%%</b>" % (
                (q_amt[k] - q_amt["2026-Q%d" % (i - 1)])
                / max(q_amt["2026-Q%d" % (i - 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>同比只在实名制人员间可比。<br>
  ℹ️ <b>销售员口径</b>：第 1 名<b>刘子研</b>为「<b>大客户1岗</b>」合并口径 —— 已并入<b>黄明月</b>、<b>张立娅</b>两人业绩（大客户1岗交接后按一个人统计）。其金额<b>不可与未合并人员</b>的 YTD 直接横向比较；如需分人明细请另见 ERP 原始归集。</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>① Q3 人员目标达成</b></td><td>数据处理层 2026Q3人员提炼.json（21 人 Q3 报送）→ 结构化提炼</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>数据/2026Q3汇报_汇总.json（月度目标 / 重点课题组 / 四季度预测）＋ 数据/2026Q3_ERP对账.json</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>
      本 deck 生成时间：%s　｜　ERP 数据生成：%s</div>
    </div>
    <div class="card"><h3>可复算入口</h3>
      <table class="data"><tbody>
      <tr><td><span class="mono">数据/2026Q3人员提炼.json</span></td><td>① 人员结构化数据（Q3 口径）</td></tr><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">数据/2026Q3汇报_汇总.json</span></td><td>⑤ 生命科学部三张表（原表直读）</td></tr>
      <tr><td><span class="mono">数据/2026Q3_ERP对账.json</span></td><td>⑤ 生命科学部 ERP 实绩对照</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>""" % (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(DECK_GEN), esc(erp["generated"]))


def p_conc():
    """客户集中度与同比桥 —— 实体 / 集团口径切换（新页，其余页不动）。"""
    q = CONC["q3"]
    return """
<section class="slide" data-t="④ 客户集中度与同比桥 · 实体 vs 集团口径">
  <div class="slide-head"><div class="kicker">④ ERP 同比 · 客户结构</div>
  <h2>客户集中度与同比桥 · 实体 vs 集团口径</h2>
  <div class="sub">Q3（7–9 月）合计 <b>%s 万</b>（目标 %s 万 · 达成 <b>%s%%</b> · 同比 <b>+%s%%</b>）
   ｜ 口径切换：<button class="btn on calbtn" id="calEnt" onclick="setCal('ent')">实体口径</button>
   <button class="btn calbtn" id="calGrp" onclick="setCal('grp')">集团口径</button>
   <span class="pg" id="calHint" style="margin-left:6px;color:var(--blue2);font-weight:600"></span></div></div>
  <div class="slide-body">
    <div class="two" style="grid-template-columns:1fr 1.12fr;flex:1">
      <div class="card scroll"><h3 id="topTitle">Q3 客户 Top10 榜单（实体口径）</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="tbConcTop"></tbody></table>
        <div class="foot-note" id="concTip" style="margin-top:8px"></div>
      </div>
      <div style="display:flex;flex-direction:column;gap:12px;min-height:0">
        <div class="card"><h3>Q3 Top12 集中度对比</h3><div id="chConc"></div></div>
        <div class="card" style="flex:1;min-height:0"><h3>Q3 同比桥（万元）</h3><div id="chBridge"></div></div>
      </div>
    </div>
    <div class="foot-note">口径：<b>hybrid</b> —— 有集团归属的客户并入集团，其余保持独立实体（只合并不丢弃）；
    映射源＝官方《诊断原料&amp;生命科学客户清单》→ <span class="mono">数据/diag_mappings.json</span> 的
    <span class="mono">cust_to_group</span>，Q3 已归集 <b>%d 个集团</b>；两口径合计<b>完全相等</b>
    （起点/终点均 %s 万）。「保密客户」为 ERP 中真实存在的匿名归集记录，非脱敏错误。</div>
  </div>
</section>""" % (fm(q["total"], 1), fm(q["target"], 1), fm(q["rate"], 1), fm(q["yoy"], 1),
                 CONC["n_groups"], fm(q["total"], 1))


# ── 章节定义 + 页序（章节页码由 ORDER 索引自动推导，杜绝目录漂移）──
# ══════════════════════════════════════════════════════════════════════════
# 「生命科学部 Q3 报送专项」新增章节（本轮）
#   数据源：数据/2026Q3汇报_汇总.json  → tables 三张 LS 表（原表直读）
#           数据/2026Q3_ERP对账.json   → ERP 实绩（YTD 1–9 月，工作台归并口径）
#           数据/三部门逐月_2026.json  → ERP 逐人逐月实际（取 7–9 月做 Q3 对照）
#   口径铁律：不做换算、不做跨人总额（月度目标表各人填报单位可能不一致），
#             凡汇总一律标注单位与覆盖行数；对照页必须写明两口径不可直接加减。
# ══════════════════════════════════════════════════════════════════════════
LS8 = ["黄建华", "王诗香", "陈彩红", "叶信福", "侯海瑜", "袁殿果", "吴云", "韩远怀"]
LS_SRC = json.load(open(os.path.join(DJ, "2026Q3汇报_汇总.json"), encoding="utf-8"))
ERPJ = json.load(open(os.path.join(DJ, "2026Q3_ERP对账.json"), encoding="utf-8"))
LST = LS_SRC.get("tables") or {}
FC_PROD = ["建库试剂盒", "提取试剂", "反转录", "qcpr", "高保真pcr", "普通pcr酶", "蛋白marker",
           "同源重组酶", "其他"]
TGT_TC = "background:#FCFDFF;font-size:11px;line-height:1.5;white-space:normal;word-break:break-word"


def _ls_p(s):
    for n in LS8:
        if n in str(s):
            return n
    return str(s or "")


def _fn(v):
    try:
        return float(str(v).replace(",", "").strip())
    except Exception:
        return None


def _lsum(rows, f):
    vs = [x for x in (_fn(r.get(f)) for r in rows) if x is not None]
    return (round(sum(vs), 2), len(vs))


ls_tgt = LST.get("生命科学_月度目标") or []
ls_kt = LST.get("生命科学_课题组") or []
ls_fc = LST.get("生命科学_四季度预测") or []

# 月度目标：只做「按人」小计（跨人单位可能不一致）
ls_by = {n: [0.0, 0.0, 0, 0] for n in LS8}     # [目标, 实际, 目标行数, 实际行数]
for r in ls_tgt:
    p = _ls_p(r.get("汇报人") or r.get("来源文件"))
    if p in ls_by:
        a, b = _fn(r.get("销售目标")), _fn(r.get("实际完成"))
        if a is not None:
            ls_by[p][0] += a; ls_by[p][2] += 1
        if b is not None:
            ls_by[p][1] += b; ls_by[p][3] += 1

# 自报 Q3 达成（四季度预测表 · 单位随原表，多为万元）
ls_q3 = {}
for r in ls_fc:
    p = _ls_p(r.get("汇报人") or r.get("来源文件"))
    v = _fn(r.get("3季度达成"))
    if v is not None:
        ls_q3[p] = round(ls_q3.get(p, 0.0) + v, 2)

# ERP：Q3 实际（7–9 月）与 YTD（1–9 月）
erp_q3 = {}
for r in (d3.get("salespersons") or []):
    if str(r.get("dept")) == "生命科学部" and str(r.get("month")) in Q3M:
        erp_q3[r.get("sp")] = round(erp_q3.get(r.get("sp"), 0.0) + (r.get("amt") or 0.0), 2)
erp_ytd = {r.get("人"): r for r in (ERPJ.get("rows") or [])}

ls_tgt_people = sum(1 for n in LS8 if ls_by[n][2] or ls_by[n][3])
ls_kt_state = {}
ls_kt_mode = {}
for r in ls_kt:
    ls_kt_state[str(r.get("商机状态") or "未填")] = ls_kt_state.get(str(r.get("商机状态") or "未填"), 0) + 1
    ls_kt_mode[str(r.get("课题组模式") or "未填")] = ls_kt_mode.get(str(r.get("课题组模式") or "未填"), 0) + 1
ls_kt_goal, ls_kt_goal_n = _lsum(ls_kt, "年度目标（w）")
ls_fc_tot, _ = _lsum(ls_fc, "合计")
ls_fc_q3, _ = _lsum(ls_fc, "3季度达成")
ls_fc_q4, _ = _lsum(ls_fc, "4季度预测")
ls_fc_prod = [(c, _lsum(ls_fc, c)[0]) for c in FC_PROD]
DECK_GEN = __import__("datetime").datetime.now().strftime("%Y-%m-%d %H:%M")
LS_CAVEAT = ("⚠️ 口径：①「自报」= 各人 PPT 内嵌表原值（单位随原表，未做换算）；"
             "②「ERP」= 标准销售订单 · 已审核 · 非赠品 · 剔除关联方，按工作台归并口径；"
             "③ 两者统计范围不同（自报含未过账商机），<b>不可直接相减当缺口</b>；"
             "④ 生命科学部为 Q3 起单独报送，<b>不并入前两季度口径合计</b>。")


def _cell(v, num=False, long=False):
    st = "text-align:right;white-space:nowrap;font-variant-numeric:tabular-nums" if num else (
        TGT_TC if long else "")
    return '<td style="padding:5px 7px;border-bottom:1px solid #F1F4F9;%s">%s</td>' % (st, esc(v))


def _ls_table(cols, rows, num_idx=(), long_idx=(), maxh="50vh"):
    """cols: [(标题, 取值函数)]；宽表放可滚动容器，长文本列自动换行、不截断。"""
    h = ['<div style="overflow:auto;max-height:%s;border:1px solid #E3EAF4;border-radius:9px">' % maxh,
         '<table style="width:100%%;border-collapse:collapse;font-size:11.5px;table-layout:fixed">',
         '<colgroup>']
    base = [9 if i in num_idx else (17 if i in long_idx else 12) for i in range(len(cols))]
    s = float(sum(base)) or 1
    h += ['<col style="width:%.2f%%">' % (b / s * 100) for b in base]
    h.append('</colgroup><thead><tr>')
    for i, (t, _) in enumerate(cols):
        st = "text-align:right" if i in num_idx else ""
        h.append('<th style="position:sticky;top:0;background:#F7FAFF;padding:6px 7px;font-size:11px;'
                 'border-bottom:1px solid #E3EAF4;white-space:normal;%s">%s</th>' % (st, esc(t)))
    h.append('</tr></thead><tbody>')
    for r in rows:
        h.append('<tr>' + "".join(_cell(fn(r), i in num_idx, i in long_idx) for i, (_, fn) in enumerate(cols)) + '</tr>')
    h.append('</tbody></table></div>')
    return "".join(h)


def _stat(label, value, unit="", note=""):
    return ('<div style="flex:1;min-width:132px;background:#fff;border:1px solid #E3EAF4;border-left:4px solid #2F7BC4;'
            'border-radius:10px;padding:9px 12px">'
            '<div style="font-size:11px;color:#7A8699">%s</div>'
            '<div style="font-size:19px;font-weight:800;color:#1F4E9C;margin-top:2px">%s<small style="font-size:11px;'
            'font-weight:600;color:#7A8699"> %s</small></div>'
            '<div style="font-size:10.5px;color:#9AA5B4;margin-top:2px">%s</div></div>') % (
        esc(label), esc(value), esc(unit), esc(note))


def p_ls_summary():
    rows = []
    for n in LS8:
        e = erp_ytd.get(n) or {}
        rows.append(lambda r, n=n, e=e: None)      # 占位（下面直接渲染静态行）
    body = []
    for n in LS8:
        e = erp_ytd.get(n) or {}
        body.append('<tr>' + "".join([
            _cell(n), _cell("生命科学部"),
            _cell("已报送" if (ls_by[n][2] or ls_by[n][3] or ls_q3.get(n) is not None) else "—"),
            _cell("%.2f" % ls_q3[n] if n in ls_q3 else "—", num=True),
            _cell("%.2f" % erp_q3[n] if n in erp_q3 else "—", num=True),
            _cell(("%.2f" % round(ls_q3.get(n, 0.0) - erp_q3.get(n, 0.0), 2)) if (n in ls_q3 and n in erp_q3) else "—", num=True),
            _cell("%.2f" % (e.get("ERP实绩_万") or 0) if e else "—", num=True),
            _cell("%s" % (e.get("ERP客户数") if e else "—"), num=True),
        ]) + '</tr>')
    tot_ls = round(sum(ls_q3.values()), 2)
    tot_erp3 = round(sum(erp_q3.values()), 2)
    return """
<section class="slide" data-t="⑤ 生命科学部 · 报送概览">
  <div class="slide-head"><div class="kicker">⑤ 生命科学部 Q3 报送专项</div><h2>报送概览与实绩对照</h2>
  <div class="sub">8 人全部报送 ｜ 月度目标 %d 行 / 重点课题组 %d 条 / 四季度预测 %d 条</div></div>
  <div class="slide-body">
    <div style="display:flex;gap:10px;flex-wrap:wrap">
      %s%s%s%s
    </div>
    <div class="card" style="margin-top:10px"><h3>逐人对照（单位：万元 · 原表口径优先）</h3>
      <div style="overflow:auto;max-height:44vh;border:1px solid #E3EAF4;border-radius:9px">
      <table style="width:100%%;border-collapse:collapse;font-size:11.5px">
        <thead><tr>
          <th style="position:sticky;top:0;background:#F7FAFF;padding:6px 7px;font-size:11px">销售员</th>
          <th style="position:sticky;top:0;background:#F7FAFF;padding:6px 7px;font-size:11px">部门</th>
          <th style="position:sticky;top:0;background:#F7FAFF;padding:6px 7px;font-size:11px">报送</th>
          <th style="position:sticky;top:0;background:#F7FAFF;padding:6px 7px;font-size:11px;text-align:right">自报 3 季达成</th>
          <th style="position:sticky;top:0;background:#F7FAFF;padding:6px 7px;font-size:11px;text-align:right">ERP Q3(7–9月)</th>
          <th style="position:sticky;top:0;background:#F7FAFF;padding:6px 7px;font-size:11px;text-align:right">差额</th>
          <th style="position:sticky;top:0;background:#F7FAFF;padding:6px 7px;font-size:11px;text-align:right">ERP YTD(1–9月)</th>
          <th style="position:sticky;top:0;background:#F7FAFF;padding:6px 7px;font-size:11px;text-align:right">ERP 客户数</th>
        </tr></thead><tbody>%s</tbody>
        <tfoot><tr>
          <td style="padding:6px 7px;font-weight:800" colspan="3">合计（口径见下）</td>
          <td style="padding:6px 7px;font-weight:800;text-align:right">%.2f</td>
          <td style="padding:6px 7px;font-weight:800;text-align:right">%.2f</td>
          <td style="padding:6px 7px;font-weight:800;text-align:right">%.2f</td>
          <td style="padding:6px 7px;font-weight:800;text-align:right">%.2f</td>
          <td style="padding:6px 7px;font-weight:800;text-align:right">%s</td>
        </tr></tfoot>
      </table></div></div>
    <div class="warnbox" style="margin-top:9px;flex:none">%s</div>
  </div>
</section>""" % (len(ls_tgt), len(ls_kt), len(ls_fc),
                 _stat("已报送", "%d/%d" % (ls_tgt_people or len(LS8), len(LS8)), "人", "生命科学部全体"),
                 _stat("重点课题组", "%d" % len(ls_kt), "条", "课题组＝商机单位"),
                 _stat("课题组年度目标", "%.1f" % ls_kt_goal, "万元", "%d 条有值" % ls_kt_goal_n),
                 _stat("四季度预测合计", "%.1f" % ls_fc_tot, "万元", "原表口径"),
                 "".join(body), tot_ls, tot_erp3, round(tot_ls - tot_erp3, 2),
                 round(sum((erp_ytd.get(n, {}) or {}).get("ERP实绩_万") or 0 for n in LS8), 2),
                 sum((erp_ytd.get(n, {}) or {}).get("ERP客户数") or 0 for n in LS8), LS_CAVEAT)


def p_ls_target():
    cols = [("销售员", lambda r: _ls_p(r.get("汇报人") or r.get("来源文件"))),
            ("月份", lambda r: r.get("月份")), ("销售目标", lambda r: r.get("销售目标")),
            ("实际完成", lambda r: r.get("实际完成")), ("完成率", lambda r: r.get("完成率")),
            ("来源工作表", lambda r: r.get("工作表") or "")]
    smry = "".join(_stat(n, "%.2f / %.2f" % (ls_by[n][0], ls_by[n][1]), "", "目标/实际（单位随原表）")
                   for n in LS8 if ls_by[n][2] or ls_by[n][3]) or _stat("无数据", "—")
    return """
<section class="slide" data-t="⑤ 生命科学部 · 月度目标">
  <div class="slide-head"><div class="kicker">⑤ 生命科学部 Q3 报送专项</div><h2>① 月度目标与实绩（摘要 + 全量 %d 行）</h2>
  <div class="sub">按人 × 月；原表直读，未做换算 · <b>各人填报单位可能不同，故只做按人小计、不做跨人总额</b></div></div>
  <div class="slide-body">
    <div style="display:flex;gap:8px;flex-wrap:wrap">%s</div>
    <div class="card" style="margin-top:10px"><h3>全量明细（%d 行 · 表内滚动）</h3>%s</div>
  </div>
</section>""" % (len(ls_tgt), smry, len(ls_tgt), _ls_table(cols, ls_tgt, num_idx=(1, 2, 3, 4)))


def p_ls_kt():
    cols = [("销售员", lambda r: _ls_p(r.get("汇报人") or r.get("来源文件"))),
            ("学校", lambda r: r.get("学校")), ("课题组", lambda r: str(r.get("名称") or "").replace("\n", " ")),
            ("状态", lambda r: r.get("商机状态")), ("意向产品", lambda r: r.get("意向产品")),
            ("决策人", lambda r: r.get("决策人")), ("模式", lambda r: r.get("课题组模式")),
            ("年度目标(w)", lambda r: r.get("年度目标（w）")), ("合作产品", lambda r: r.get("合作产品")),
            ("痛点", lambda r: r.get("痛点")), ("研究方向", lambda r: r.get("导师研究方向")),
            ("上半年开发计划", lambda r: r.get("上半年开发计划")),
            ("上半年异常复盘", lambda r: r.get("上半年异常原因复盘")),
            ("下半年难点与措施", lambda r: r.get("下半年目标保障难点及措施"))]
    st = "　".join("%s %d 条" % (k, v) for k, v in sorted(ls_kt_state.items(), key=lambda kv: -kv[1]))
    md = "　".join("%s %d 条" % (k, v) for k, v in sorted(ls_kt_mode.items(), key=lambda kv: -kv[1]))
    return """
<section class="slide" data-t="⑤ 生命科学部 · 重点课题组">
  <div class="slide-head"><div class="kicker">⑤ 生命科学部 Q3 报送专项</div><h2>② 重点课题组（摘要 + 全量 %d 条）</h2>
  <div class="sub">课题组＝商机单位；决策人 / 痛点 / 研究方向 / 计划与难点按原文完整展示（与数据台同口径）</div></div>
  <div class="slide-body">
    <div style="display:flex;gap:8px;flex-wrap:wrap">
      %s%s%s
    </div>
    <div class="infobox" style="margin-top:9px">商机状态：%s<br>课题组模式：%s</div>
    <div class="card" style="margin-top:9px"><h3>全量明细（%d 条 · 14 列 · 表内滚动）</h3>%s</div>
  </div>
</section>""" % (len(ls_kt),
                 _stat("课题组总数", "%d" % len(ls_kt), "条", "8 人报送汇总"),
                 _stat("年度目标合计", "%.1f" % ls_kt_goal, "万元", "%d 条有值" % ls_kt_goal_n),
                 _stat("覆盖学校", "%d" % len({str(r.get("学校") or "") for r in ls_kt if r.get("学校")}), "所", "去重"),
                 esc(st), esc(md), len(ls_kt), _ls_table(cols, ls_kt, num_idx=(7,), long_idx=(8, 9, 10, 11, 12, 13)))


def p_ls_fc():
    cols = [("销售员", lambda r: _ls_p(r.get("汇报人") or r.get("来源文件"))),
            ("客户", lambda r: r.get("客户名称")), ("课题组", lambda r: r.get("课题组")),
            ("研究方向", lambda r: r.get("研究方向")), ("人数", lambda r: r.get("课题组人数")),
            ("采购模式", lambda r: r.get("采购模式")), ("重点任务", lambda r: r.get("当前重点任务")),
            ("合计", lambda r: r.get("合计")), ("上半年完成", lambda r: r.get("上半年完成")),
            ("3 季达成", lambda r: r.get("3季度达成")), ("4 季预测", lambda r: r.get("4季度预测")),
            ("4 季达成", lambda r: r.get("4季度达成")), ("全年完成", lambda r: r.get("全年完成"))]
    prod = "".join(_stat(c, "%.2f" % v, "万", "产品线分列") for c, v in ls_fc_prod[:6])
    return """
<section class="slide" data-t="⑤ 生命科学部 · 四季度预测">
  <div class="slide-head"><div class="kicker">⑤ 生命科学部 Q3 报送专项</div><h2>③ 四季度预测（摘要 + 全量 %d 条）</h2>
  <div class="sub">来源：各人 PPT 内嵌「2026年销售预测（四季度/下半年）」表；金额单位随原表</div></div>
  <div class="slide-body">
    <div style="display:flex;gap:8px;flex-wrap:wrap">
      %s%s%s%s%s%s
    </div>
    <div class="card" style="margin-top:10px"><h3>全量明细（%d 条 · 表内滚动）</h3>%s</div>
  </div>
</section>""" % (len(ls_fc),
                 _stat("合计", "%.2f" % ls_fc_tot, "万元", "60 条汇总"),
                 _stat("3 季达成", "%.2f" % ls_fc_q3, "万元", "原表口径"),
                 _stat("4 季预测", "%.2f" % ls_fc_q4, "万元", "原表口径"),
                 _stat("课题组数", "%d" % len({str(r.get("课题组") or "") for r in ls_fc}), "个", "去重"),
                 _stat("客户数", "%d" % len({str(r.get("客户名称") or "") for r in ls_fc}), "家", "去重"),
                 prod,
                 len(ls_fc), _ls_table(cols, ls_fc, num_idx=(7, 8, 9, 10, 11, 12), long_idx=(1, 3, 6)))


CHAPTERS = [
    ("01", "Q3 人员目标达成 · 提炼", 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", "生命科学部 Q3 报送专项",
     f"8 人全部报送 · 月度目标 {len(ls_tgt)} 行 / 课题组 {len(ls_kt)} 条 / 四季度预测 {len(ls_fc)} 条"),
    ("06", "主要竞争对手", 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), ("conc", p_conc),
    ("ch4", None), ("lsa", p_ls_summary), ("lst", p_ls_target), ("lsk", p_ls_kt), ("lsf", p_ls_fc),
    ("ch5", 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="rvMark(3)">⏳ 本页待确认</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==null?0: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 v=(r.v==null?null:r.v);                       /* 空值 → 不画柱、不写 0 */
    var bh=v==null?0:Math.max(2,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">'+(v==null?'—':fmt(v,opt.d==null?0:opt.d))+'</span>'
      +'<i style="'+(v==null?'display:none;':'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!=null&&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 v=s.v[i];                                   /* 空月份 → 不画柱，绝不补 0 */
      if(v==null){ html+='<i style="display:none" title="'+esc(s.n)+' 空月份·无数据"></i>'; return; }
      var bh=Math.max(1,v/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(v,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 cellN(s){var v=parseFloat(String(s==null?'':s).replace(/[,\s]/g,'')); return isNaN(v)?null:v;}
function mv(o,k){ var v=o?o[k]:null; return (v==null||v==='')?'—':v; }   /* 空月份保持空白，不写 0 */
function tableRows(id,pick){
  var host=el(id); if(!host) return [];
  var trs=host.querySelectorAll('tbody tr'), out=[];
  for(var i=0;i<trs.length;i++){
    var tds=trs[i].querySelectorAll('td'), cs=[];
    for(var j=0;j<tds.length;j++) cs.push(tds[j].textContent.trim());
    out.push(pick(cs,i));
  }
  return out;
}

/* ── 填充各页 ── */
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});
  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">'+mv(m,'2026-07')+'</td><td class="n mono">'+mv(m,'2026-08')+'</td>'
        +'<td class="n mono">'+mv(m,'2026-09')+'</td></tr>';}).join('');
  }
  /* 图表②从本页 #tbActSales 表体渲染（先表后图）*/
  hbar(el('chActSales'), tableRows('tbActSales', function(c){return {k:c[1], v:cellN(c[2])};})
    .filter(function(r){return r.v!=null;}).slice(0,12), {lw:64,vw:56});
  /* 活动 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]==null?null:D.erp.month25[k];})},
    {n:'2026', c:'#1852B6', v:mk.map(function(k){return D.erp.month[k]==null?null: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('');}
  /* ⑤ 竞品 */
  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?'—':b.h1)+'</td></tr>';}).join('');}
  /* 图表⑤从本页 #tbComp 表体渲染（TOP18 取表前 18 行，先表后图）*/
  hbar(el('chCompHeat'), tableRows('tbComp', function(c){
    return {k:c[1], v:cellN(c[2]), c: c[1]==='诺唯赞' ? '#d81b26' : 'var(--blue)'};})
    .filter(function(r){return r.v!=null;}).slice(0,18), {lw:88,vw:56,d:0});
  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||'').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('');
  }
  fillConc();
}

/* ── 客户集中度与同比桥（实体 / 集团口径，纯 JS 切数据）── */
var CAL='ent';
function syIdx(list){for(var i=0;i<list.length;i++){if(/圣湘/.test(list[i].name))return i;}return -1;}
function renderBridge(b){
  var host=el('chBridge'); if(!host) return;
  var mx=Math.max(b.start,b.end,Math.abs(b.add),Math.abs(b.lose),1);
  var rows=[
    {k:'起点 · 2025 Q3',v:b.start,c:'#9aa6b8',full:1},
    {k:'存量客户增量',v:b.store,c:(b.store>=0?'#0f9d58':'#d81b26')},
    {k:'新增客户',v:b.add,c:'#0f9d58'},
    {k:'流失客户',v:b.lose,c:'#d81b26'},
    {k:'终点 · 2026 Q3',v:b.end,c:'#1852B6',full:1}
  ];
  var html='<div style="display:grid;gap:7px">';
  rows.forEach(function(r){
    var pct=r.full?100:Math.max(1.5,Math.abs(r.v)/mx*100);
    var vt=(!r.full&&r.v>=0?'+':'')+fmt(r.v,1);
    html+='<div style="display:grid;grid-template-columns:104px 1fr 78px;gap:8px;align-items:center;font-size:11.5px">'
      +'<span>'+esc(r.k)+'</span>'
      +'<span style="background:#eef1f6;border-radius:4px;height:15px;display:block;overflow:hidden">'
      +'<i style="display:block;height:100%;width:'+pct.toFixed(1)+'%;background:'+r.c+';border-radius:4px"></i></span>'
      +'<span style="text-align:right;font-variant-numeric:tabular-nums;font-weight:700;color:'+(r.full?'#33425c':r.c)+'">'+vt+'</span></div>';
  });
  html+='</div><div class="foot-note" style="margin-top:8px">桥式：'
    +fmt(b.start,1)+' ⇒ 存量 '+fmt(b.store,1)+' ⇒ 新增 '+fmt(b.add,1)+' ⇒ 流失 '+fmt(b.lose,1)
    +' ⇒ '+fmt(b.end,1)+' 万（单位万元；分项逐户加总，末位 ±0.1 舍入）｜ 客户：存量 '+b.n_store
    +' 户 · 新增 '+b.n_add+' 户 · 流失 '+b.n_lost+' 户</div>';
  host.innerHTML=html;
}
function fillConc(){
  var d=D.conc[CAL], tot=d.total||1;
  el('calEnt').classList.toggle('on',CAL==='ent');
  el('calGrp').classList.toggle('on',CAL==='grp');
  el('topTitle').textContent='Q3 客户 Top10 榜单（'+(CAL==='ent'?'实体':'集团')+'口径）';
  var tb=el('tbConcTop');
  if(tb){ tb.innerHTML=d.top.slice(0,10).map(function(c,i){
    var hl=/圣湘/.test(c.name);
    return '<tr'+(hl?' class="hlrow"':'')+'><td class="c mono">'+(i+1)+'</td>'
      +'<td style="font-size:11.5px">'+(hl?'<b>'+esc(c.name)+'</b>':esc(c.name))+'</td>'
      +'<td class="n mono"><b>'+fmt(c.wan,1)+'</b></td>'
      +'<td class="n mono">'+(c.wan/tot*100).toFixed(1)+'%</td></tr>';}).join('');}
  hbar(el('chConc'),[{k:'实体口径 Top12',v:D.conc.ent.conc12,c:'#9aa6b8'},
    {k:'集团口径 Top12',v:D.conc.grp.conc12,c:'#1852B6'}],{lw:100,vw:56,unit:'%',d:1});
  var r1=syIdx(D.conc.ent.top), r2=syIdx(D.conc.grp.top), tip='';
  if(r1>=0&&r2>=0){ tip='同一客户群：'+esc(D.conc.ent.top[r1].name)+' 实体口径第 '+(r1+1)+' 名（'
    +fmt(D.conc.ent.top[r1].wan,1)+' 万）→ 归集为「'+esc(D.conc.grp.top[r2].name)+'」集团口径第 '+(r2+1)
    +' 名（'+fmt(D.conc.grp.top[r2].wan,1)+' 万）。'; }
  el('concTip').innerHTML=tip+'Top12 集中度 实体 '+D.conc.ent.conc12+'% → 集团 '+D.conc.grp.conc12
    +'%；客户数 '+D.conc.ent.n_cust+' → '+D.conc.grp.n_cust+' 户。';
  el('calHint').textContent='当前：'+(CAL==='ent'?'实体':'集团')+'口径';
  renderBridge(d.bridge);
}
function setCal(c){CAL=c;fillConc();}

/* ── 封面/章节 粒子背景（方案 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:'需修改',3:'待确认'};
/* 本 deck 的「待确认」清单 —— Q3 口径改造时按规则【保留原位】的 H1 源标注 / 数据刷新点。
   键＝页索引；进入审稿模式即在列表、徽章与备注里带出原因；导出审稿 JSON 一并带出。 */
var PENDING={
 '2':'待确认（源标注保留）：本页「半年报自述」引自 2026 半年度汇报原文，属来源引用，未改成 Q3 口径。',
 '4':'已换 Q3 口径（2026-10-15）：①章数据源改为 数据/2026Q3人员提炼.json（21 人 Q3 报送）；达成率 = 3季实绩 ÷ 2026年度目标；表头改为「2026目标(万)/3季实绩(万)」。',
 '5':'已换 Q3 口径（2026-10-15）：同 P4，源件为 2026 三季度汇报；生命科学部 8 人取自其四季度预测表（原表单位）。',
 '13':'待确认（源标注保留）：高风险账款源自 2026 半年度汇报原文，属来源引用，未改成 Q3 口径。',
 '15':'待确认（口径）：本页保留 H1 口径自校验「2025 上半年 1,586.6 万 ≈ 管理版半年报 1,587 万」，数字未动；Q3 deck 是否仍需保留该自校验待定。',
 '19':'待确认（数据刷新）：本页 Q3 目标 1,641.8→1,559.3 万、达成率 71.5%→75.3%，系 数据/三部门逐月_2026.json 于 2026-10-06 21:02 刷新所致，非本次改口径产生；Q3 实际 1,173.8 万、集中度 43.9%/47.2%、同比 +1.0% 均未变。',
 '21':'待确认（源标注保留）：列头「半年报记录」为各品牌在 2026 半年度汇报中的记录条数，属数字来源标注，保留。',
 '22':'待确认（源标注保留）：本页「半年报竞品记录 59 条」同上，属来源标注，保留。',
 '23':'待确认（源标注保留）：本页「出处」列含「半年报竞品板块 / 半年报多位销售自述」，为可溯源的引用出处，保留。',
 '25':'待确认（源标注保留）：附录数据源行保留真实源件路径 人员提炼_H1.json 与 2026半年度汇报/，不可改为 Q3。'
};
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';
  for(var k in PENDING){ if(!RV.st[k]) RV.st[k]=3; if(!RV.nt[k]) RV.nt[k]=PENDING[k]; }
}
function rvPaint(i){
  var st=RV.st[i]||0, b=el('rvb'+i);
  if(b){b.textContent=(st===1?'✓':(st===2?'✎':(st===3?'⏳':'○'))); b.className='rvB s'+st;}
  var sb=SL[i].querySelector('.rvSlideBadge');
  if(sb){sb.textContent=(st===1?'✓ 已确认':(st===2?'✎ 需修改':(st===3?'⏳ 待确认':'○ 未审'))); sb.className='rvSlideBadge s'+st;}
}
function rvStat(){
  var a=0,b=0,c=0,d=0;
  for(var i=0;i<N;i++){var st=RV.st[i]||0; if(st===1)a++; else if(st===2)b++; else if(st===3)d++; else c++;}
  var e=el('rvStat'); if(e) e.innerHTML='✓ '+a+' ｜ ✎ '+b+' ｜ ⏳ '+d+' ｜ ○ '+c;
  var btn=el('rvBtn'); if(btn) btn.textContent='✓ 审稿'+(a?('（'+a+'/'+N+'）'):'');
  return [a,b,c,d];
}
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], pending:s3[3], 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 直接进审稿 */
if(/cal=(ent|grp)/.test(_hh)){ CAL=RegExp.$1; fillConc(); }   /* #p=20&cal=grp 直连集团口径 */
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);}
  var c=(location.hash||'').match(/cal=(ent|grp)/); if(c){ CAL=c[1]; fillConc(); }});
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 ｜ 页脚 %d"
      % (html.count('data-bior-brand="1"'), html.count('class="bior-header"'),
         html.count('class="bior-footer"')))
