#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
生成「客户拉通大表 · 经营仪表盘（报告版）」单文件页（自包含、零外链、数据内嵌）。

面向**总经理**的报告仪表盘：不新增任何指标，只做**重排 / 对齐 / 强化层级**。
页面自上而下：
  ① 结论摘要（由数据自动生成的一句话）
  ② 4 张 KPI 大卡（商机总数 / 客户数 / 商机总额 / 卡住商机数）— 数字放大加粗、带阈值色
  ③ 风险预警区（单独成块 · 警示色 · 提前）：卡点 Top / 资源消耗与闭环率 / 沉默商机三档
  ④ 业务明细 · 11 块指标卡（与基线同一批数据）
     1 阶段分布漏斗      2 商机状态分布      3 商机销售额      4 客户数量
     5 阶段停留时长 Top  6 阶段转化率+周期    7 资源消耗与闭环率  8 沉默商机预警
     9 销售员维度       10 商机规模分档      11 数据治理（待关联占比）
每块卡右下角以小字标注**该指标上线后的数据来源**（保留可追溯，但不抢眼）。

版式规范（报告版）：单页 1440px 宽、可投屏也可直接打印（背景白、无阴影干扰）；
统一栅格（--gap / --pad / --rad / --lbl 同一套变量）；同行卡片等高；字号只留 4 级
（大数字 / 卡标题 / 正文 / 注释）；图表左对齐基线一致。

硬约束：零外部依赖（禁 CDN / fetch / 外部字体）；内嵌 SVG 禁 <title> / <br>（会静默吞图形），加断言。
品牌页眉/页脚：脚本/brand_kit.py（品牌蓝 #1852B6）。
指标口径字典见 `知识沉淀/客户拉通大表_结构清单_20261010.md` §10 / §11。
"""
import os
import sys
import io
import re
import json

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)                       # Hermes输出-工作类
sys.path.insert(0, os.path.join(ROOT, '脚本'))
import brand_kit as BK                             # noqa
import ct_data as CT                               # noqa

OUT = os.path.join(ROOT, '工具', '客户拉通大表_仪表盘.html')

RAW = {
    "today": CT.TODAY,
    "usd_cny": CT.USD_CNY,
    "nodes": CT.NODE_COLS,
    "short": CT.NODE_SHORT,
    "lanes": CT.LANES,
    "support_types": CT.SUPPORT_TYPES,
    "depts": CT.DEPTS,
    "rows": CT.flat_rows(),
}

HTML = r"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>客户拉通大表 · 经营仪表盘（报告版 · DEMO 示例数据）</title>
<style>
:root{
  --pri:#1852B6; --pri-d:#0d3a85; --bg:#ffffff; --card:#ffffff; --line:#e3e9f2;
  --ink:#1a2434; --sub:#5b6b82; --mut:#93a1b5;
  --done:#16A34A; --doing:#1852B6; --stuck:#C0392B; --todo:#B9C2CE;
  --warn-bg:#FEF3C7; --warn-bd:#F59E0B; --warn-ink:#92400E;
  --risk:#C0392B; --risk-bg:#fef7f6; --risk-line:#efc8c3;
  /* —— 统一栅格（卡宽/间距/内边距 · 同一套变量）—— */
  --wrap:1440px; --gap:14px; --pad:16px; --rad:12px; --lbl:96px;
  /* —— 只看 4 级字号：大数字 / 卡标题 / 正文 / 注释 —— */
  --fs-num:32px; --fs-title:15px; --fs-body:12.5px; --fs-note:10.5px;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);
  font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif}
.wrap{max-width:var(--wrap);margin:0 auto;padding:var(--pad) 22px 30px;font-size:var(--fs-body)}
h1{font-size:var(--fs-num);line-height:1.1;margin:0;font-weight:800;letter-spacing:.2px;color:var(--pri-d)}
.head-row{display:flex;align-items:flex-end;gap:14px;flex-wrap:wrap;margin-top:4px}
.head-row .sub{color:var(--sub);font-size:var(--fs-body)}
.demo-bar{background:linear-gradient(90deg,#FEF3C7,#FDE68A);border:1px solid var(--warn-bd);
  color:var(--warn-ink);border-radius:10px;padding:8px 14px;margin:12px 0 0;font-size:var(--fs-body);
  font-weight:600;display:flex;align-items:center;gap:9px}
.demo-bar .dot{width:9px;height:9px;border-radius:50%;background:var(--warn-bd);flex:none;animation:bl 1.4s infinite}
@keyframes bl{50%{opacity:.35}}

/* 结论摘要（数据自动生成的一句话）*/
.concl{background:linear-gradient(90deg,#eaf1fd,#f7faff);border-left:4px solid var(--pri);
  border-radius:10px;padding:11px 16px;margin:var(--gap) 0 0;font-size:var(--fs-body);
  line-height:1.75;color:#26364d}
.concl .ck{display:inline-block;background:var(--pri);color:#fff;font-size:var(--fs-note);
  font-weight:800;border-radius:6px;padding:1px 9px;margin-right:9px;letter-spacing:.5px}

/* KPI 大卡（阈值色）*/
.kpis{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:var(--gap);margin-top:var(--gap);
  align-items:stretch}
@media(max-width:900px){.kpis{grid-template-columns:repeat(2,minmax(0,1fr))}}
.kpi{background:var(--card);border:1px solid var(--line);border-radius:var(--rad);
  padding:13px var(--pad);display:flex;flex-direction:column;min-height:106px;position:relative;
  overflow:hidden}
.kpi:before{content:'';position:absolute;left:0;top:0;bottom:0;width:5px;background:var(--pri)}
.kpi.k2:before{background:var(--done)} .kpi.k3:before{background:#0891B2} .kpi.k4:before{background:var(--stuck)}
.kpi .lbl{font-size:var(--fs-body);color:var(--sub);font-weight:700;line-height:1.2}
.kpi .num{font-size:var(--fs-num);font-weight:800;color:var(--pri-d);line-height:1.06;margin:4px 0 2px;
  font-variant-numeric:tabular-nums}
.kpi.k2 .num{color:#0f7a3d} .kpi.k3 .num{color:#0e7490} .kpi.k4 .num{color:var(--stuck)}
.kpi.k5 .num{color:#b45309}
.kpi.k5:before{background:var(--warn-bd)}
.kpi .foot{margin-top:auto;font-size:var(--fs-note);color:var(--mut);line-height:1.4}
.kpi .flagtag{position:absolute;top:9px;right:11px;font-size:var(--fs-note);font-weight:800;
  border-radius:6px;padding:1px 7px}
/* 阈值：卡住数 > 0 → 红框；待关联占比 > 30% → 橙框 */
.kpi.flag-red{border-color:var(--stuck)}
.kpi.flag-red .flagtag{background:#fde2e2;color:var(--stuck)}
.kpi.flag-amber{border-color:var(--warn-bd)}
.kpi.flag-amber .flagtag{background:var(--warn-bg);color:var(--warn-ink)}

/* 卡片栅格（同行等高 · 区块标题横跨整行 · dense 让拖拽换位后自动补位）*/
.grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--gap);margin-top:var(--gap);
  align-items:stretch;grid-auto-flow:row dense}
@media(max-width:1180px){.grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(max-width:760px){.grid{grid-template-columns:1fr}}
.grid>.sec{grid-column:1/-1;display:flex;align-items:baseline;gap:10px;margin:6px 0 -2px;
  font-size:var(--fs-title);font-weight:800;color:var(--pri-d);border-left:4px solid var(--pri);
  padding-left:9px}
.grid>.sec.sec-risk{color:var(--stuck);border-left-color:var(--stuck)}
.grid>.sec .sx{font-size:var(--fs-note);font-weight:600;color:var(--mut)}
.card{background:var(--card);border:1px solid var(--line);border-radius:var(--rad);padding:var(--pad);
  display:flex;flex-direction:column;width:100%}
.card.risk{border-color:var(--risk-line);background:linear-gradient(180deg,#ffffff,var(--risk-bg))}
.card.wide{grid-column:span 2}
.card h3{margin:0 0 4px;font-size:var(--fs-title);font-weight:800;color:var(--pri-d);
  display:flex;align-items:center;gap:8px;line-height:1.25}
.card h3 .idx{background:var(--pri);color:#fff;font-size:var(--fs-note);border-radius:6px;padding:1px 7px;
  font-weight:800;flex:none}
.card.risk h3 .idx{background:var(--stuck)}
.card .cdesc{font-size:var(--fs-note);color:var(--mut);margin-bottom:10px;line-height:1.5}
.card .body{flex:1;display:flex;flex-direction:column;justify-content:center;gap:2px}
/* 数据来源：卡片右下角小字（弱化）*/
.src{margin-top:10px;padding-top:7px;border-top:1px dashed var(--line);font-size:var(--fs-note);
  color:var(--mut);line-height:1.45;text-align:right}
.src b{color:var(--sub);font-weight:700}
.src code{background:#f1f5fb;color:var(--sub);border-radius:4px;padding:0 4px;font-size:var(--fs-note);
  font-family:"SF Mono",Menlo,Consolas,monospace}

/* 条形图（全卡统一栅格 → 图表左对齐基线一致）*/
.bars{display:flex;flex-direction:column;gap:7px;width:100%}
.bar{display:grid;grid-template-columns:var(--lbl) 1fr 108px;align-items:center;gap:9px;
  font-size:var(--fs-body)}
.bar .bl{color:#33465f;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.bar .bt{background:#eef2f8;border-radius:6px;height:15px;overflow:hidden;position:relative}
.bar .bt i{display:block;height:100%;border-radius:6px;background:var(--pri)}
.bar .bt i.done{background:var(--done)} .bar .bt i.doing{background:var(--doing)}
.bar .bt i.stuck{background:var(--stuck)} .bar .bt i.todo{background:var(--todo)}
.bar .bt i.cyan{background:#0891B2} .bar .bt i.amber{background:#D97706} .bar .bt i.violet{background:#7C3AED}
.bar .bt i.grad{background:linear-gradient(90deg,#1852B6,#3b82f6)}
.bar .bv{text-align:right;font-weight:800;color:var(--pri-d);font-variant-numeric:tabular-nums;
  font-size:var(--fs-note)}
.barnote{margin:-3px 0 3px calc(var(--lbl) + 9px);font-size:var(--fs-note);color:var(--mut)}

/* 环形图 */
.ringwrap{display:flex;align-items:center;gap:18px;flex-wrap:wrap;justify-content:center}
.ring{width:150px;height:150px;border-radius:50%;position:relative;flex:none}
.ring:after{content:'';position:absolute;inset:24%;background:#fff;border-radius:50%;
  box-shadow:inset 0 0 0 1px var(--line)}
.ring .rc{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;
  font-weight:800;color:var(--pri-d)}
.ring .rc b{font-size:var(--fs-title);line-height:1.15}
.ring .rc span{font-size:var(--fs-note);color:var(--sub);font-weight:700;margin-top:3px}
.lgd{display:flex;flex-direction:column;gap:7px;font-size:var(--fs-body)}
.lgd .li{display:flex;align-items:center;gap:8px}
.lgd .sw{width:12px;height:12px;border-radius:3px;flex:none}
.lgd .lv{margin-left:auto;font-weight:800;color:var(--pri-d);font-variant-numeric:tabular-nums}

/* 表格 */
table.tb{width:100%;border-collapse:collapse;font-size:var(--fs-body)}
table.tb th{background:#f2f6fd;color:var(--pri-d);font-weight:800;padding:6px 9px;text-align:left;
  border-bottom:1px solid var(--line);position:sticky;top:0}
table.tb td{padding:6px 9px;border-bottom:1px solid var(--line);font-variant-numeric:tabular-nums}
table.tb td.n{text-align:right;font-weight:700;color:var(--pri-d)}
table.tb tr:hover td{background:#f7faff}
.pill{display:inline-block;border-radius:6px;padding:1px 8px;font-size:var(--fs-note);font-weight:800;color:#fff}
.pill.done{background:var(--done)} .pill.doing{background:var(--doing)}
.pill.stuck{background:var(--stuck)} .pill.todo{background:var(--todo)}

/* 小块数字行 */
.ministat{display:grid;grid-template-columns:repeat(auto-fit,minmax(104px,1fr));gap:10px}
.ministat .ms{background:#f7faff;border:1px solid var(--line);border-radius:10px;padding:9px 10px;
  text-align:center}
.ministat .ms .v{font-size:var(--fs-title);font-weight:800;color:var(--pri-d);font-variant-numeric:tabular-nums}
.ministat .ms .k{font-size:var(--fs-note);color:var(--sub);font-weight:700;margin-top:3px}
.tag{display:inline-block;font-size:var(--fs-note);font-weight:800;border-radius:6px;padding:1px 8px;
  background:#eef3fb;color:var(--pri-d);margin-left:6px}
.tag.warn{background:var(--warn-bg);color:var(--warn-ink)}
.svgwrap{display:block;max-width:100%;overflow:auto}
svg{display:block}
text.axl{font-size:var(--fs-body);font-weight:700;fill:#33465f;font-family:inherit}
text.axv{font-size:var(--fs-note);font-weight:800;fill:#0d3a85;font-family:inherit}

/* —— 布局工具条（保存/恢复 · 本地存储提示）—— */
.dash-bar{display:flex;align-items:center;gap:9px;flex-wrap:wrap;margin:12px 0 0;padding:8px 12px;
  background:#f7faff;border:1px dashed #b9cbe8;border-radius:10px;font-size:var(--fs-body);color:#33465f}
.dash-bar .lb{font-weight:800;color:var(--pri-d)}
.dash-bar .tip2{font-size:var(--fs-note);color:var(--sub);line-height:1.5}
.dash-bar .tip2 b{color:var(--warn-ink)}
.dash-bar .msg{margin-left:auto;font-size:var(--fs-note);color:var(--mut)}
.tbtn{border:1px solid var(--pri);background:#fff;color:var(--pri);border-radius:8px;padding:6px 12px;
  font-size:var(--fs-body);font-weight:600;cursor:pointer}
.tbtn:hover{background:#eef3fb}

/* —— 卡片：可拖动 / 右下角缩放手柄 —— */
.card{position:relative}
.card.dragging{opacity:.55;outline:2px dashed var(--pri);outline-offset:2px}
/* 拖到另一区块上方 → 拒绝放置：被拖卡红色虚线描边 + 禁止光标（回弹 = DOM 不移位） */
.card.dragging.dragging-cross{opacity:.5;outline:2px dashed var(--stuck);outline-offset:2px;cursor:no-drop}
.dash-bar .dhint{flex:0 0 100%;width:100%;box-sizing:border-box;font-size:var(--fs-note);font-weight:700;
  color:var(--pri-d);background:#eaf1fd;border:1px solid #b9cbe8;border-radius:7px;padding:3px 9px;line-height:1.5;
  white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dash-bar .dhint.deny{color:#b42318;background:#fdeeee;border-color:#f3b6b0}
.handle{position:absolute;right:2px;bottom:2px;width:15px;height:15px;cursor:nwse-resize;z-index:4;
  background:
    linear-gradient(135deg,transparent 44%,#9fb3d1 44%,#9fb3d1 54%,transparent 54%),
    linear-gradient(135deg,transparent 18%,#c3d0e6 18%,#c3d0e6 28%,transparent 28%);
  border-radius:0 0 10px 0}
body.resizing{user-select:none;cursor:nwse-resize}
.card.compact{--lbl:60px}
.card.t-min .body{max-height:130px;overflow:hidden}
.overflow-hint{display:none;margin-top:6px;font-size:var(--fs-note);color:#b42318;background:#fdeeee;
  border:1px solid #f3b6b0;border-radius:7px;padding:5px 8px;font-weight:700;line-height:1.5}
.card.has-overflow .overflow-hint{display:block}
.ovf{font-size:var(--fs-note);color:#b42318;background:#fdeeee;border:1px solid #f3b6b0;
  border-radius:7px;padding:9px 10px;font-weight:700;line-height:1.5}

/* —— 紧凑模式：环形 → 堆叠条 —— */
.sbar{display:flex;height:18px;border-radius:6px;overflow:hidden;background:#eef2f8}
.sbar i{display:block;height:100%}
.sbar-lgd{display:flex;flex-wrap:wrap;gap:6px 14px;margin-top:8px;font-size:var(--fs-note)}
.sbar-lgd .sl{display:inline-flex;align-items:center;gap:6px;color:#33465f;font-weight:700}
.sbar-lgd .sw{width:10px;height:10px;border-radius:3px;display:inline-block}
.sbar-tot{font-size:var(--fs-note);color:var(--sub);font-weight:700;margin-bottom:6px}

/* 打印：白底、无阴影、不截断卡片 · 隐藏拖拽手柄/工具条 · 回到默认顺序 */
@media print{
  body{background:#fff}
  .wrap{max-width:none;padding:0 8px 10px}
  .grid{grid-template-columns:repeat(3,minmax(0,1fr));grid-auto-flow:row}
  .card,.kpi{break-inside:avoid;page-break-inside:avoid}
  .dash-bar,.handle,.overflow-hint{display:none !important}
}
body.printing .dash-bar,body.printing .handle,body.printing .overflow-hint{display:none !important}
</style>
</head>
<body>
<div class="wrap">
  <div class="head-row">
    <h1>客户拉通大表 · 经营仪表盘</h1>
    <span class="sub">报告版 ｜ 结论 + 4 KPI + 风险 + 11 块指标卡 ｜ 数据源：客户拉通大表（第一层商机 / 资源协助 / 订单）</span>
  </div>
  <div class="demo-bar"><span class="dot"></span>
    数据为 <b>DEMO 示例</b>（客户名为真实公司名，商机 / 金额 / 日期 / 事件内容均为示例值，未上线）；
    每块卡右下角以小字标注该指标<b>上线后的数据来源</b>。
  </div>

  <div class="dash-bar">
    <span class="lb">卡片布局</span>
    <button class="tbtn" id="btnResetLayout" type="button">恢复默认布局</button>
    <button class="tbtn" id="btnSaveLayout" type="button">保存为我的布局</button>
    <span class="tip2">拖拽卡片换位（<b>仅限同一区块内</b>，跨区放置会被拒绝） · 拖右下角手柄改大小（小/中/大/宽）· <b>布局存本地（DEMO 为本地存储，仅本机浏览器生效）</b></span>
    <span class="msg" id="dashMsg"></span>
    <span class="dhint" id="dragHint">同区块内拖拽可换位（区块顺序固定）</span>
  </div>

  <div class="concl" id="concl"></div>
  <div class="kpis" id="kpiRow"></div>
  <div class="grid" id="grid"></div>

  <div class="src" style="margin-top:16px;border:none">
    <b>口径字典</b>：见 <code>知识沉淀/客户拉通大表_结构清单_20261010.md</code> §10 · 经营仪表盘指标字典
    （每个指标的口径定义 / 计算公式 / 数据来源字段 / 刷新频率）。本页零外链、数据内嵌。
  </div>
</div>

<script>
const RAW = __RAW__;
const DAY = 86400000;
const C = {done:'#16A34A', doing:'#1852B6', stuck:'#C0392B', todo:'#B9C2CE'};
window.__DASH_RAW = RAW;

/* ========== 工具 ========== */
function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,function(c){
  return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c];});}
function $(id){return document.getElementById(id);}
function d2t(s){ if(!s) return null; const a=s.split('-').map(Number); return Date.UTC(a[0],a[1]-1,a[2]); }
function parseAmt(s){ s=String(s||''); const usd=s.indexOf('US$')===0;
  const v=parseFloat(s.replace(/[^0-9.]/g,'')) || 0; return usd ? v*RAW.usd_cny : v; }
function fmtY(v){ return '¥' + Math.round(v).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); }
function fmtW(v){ return (v/10000).toFixed(1) + ' 万'; }
/* 客户短名（结论摘要用）：去城市前缀 + 截到行业词前 */
function shortCo(s){ s=String(s||'');
  s=s.replace(/^(深圳市?|上海市?|广州市?|北京市?|东莞市?|浙江|江苏|苏州|杭州|南京|成都|武汉|天津|海外经销商 )/,'');
  const m=s.match(/^[^生物科技医股份有限集团（(]{1,6}/); return (m?m[0]:s).slice(0,4) || '客户'; }

/* ========== 指标计算（全部从 RAW 派生 · 与 ct_data.py 同源） ========== */
const NODES = RAW.nodes, SHORT = RAW.short, LANES = RAW.lanes, ROWS = RAW.rows, TODAY = RAW.today;
const T = d2t(TODAY);
function nodeCell(o,n){ return (o.nodes||{})[n] || null; }
function nodeState(o,n){ const c=nodeCell(o,n); return c ? c.state : '未开始'; }
function reached(o,n){ const st=nodeState(o,n); return st==='已完成'||st==='进行中'||st==='卡住'; }
function curNode(o){
  let idx=-1;
  NODES.forEach(function(n,i){ const st=nodeState(o,n); if(st==='进行中'||st==='卡住') idx=i; });
  if(idx<0) NODES.forEach(function(n,i){ if(nodeState(o,n)==='已完成') idx=i; });
  return idx;
}
function lastEventDate(o){ const ds=(o.events||[]).map(function(e){return e.d;}).sort(); return ds.length?ds[ds.length-1]:null; }

const M = {};
M.totalOpps = ROWS.length;
M.customers = (function(){ const s={}; ROWS.forEach(function(r){ s[r.cust]=1; }); return Object.keys(s).length; })();
M.oppAmount = ROWS.reduce(function(a,r){ return a+parseAmt(r.scale); }, 0);
M.stuckOpps = ROWS.filter(function(r){ const i=curNode(r); return i>=0 && nodeState(r,NODES[i])==='卡住'; }).length;

/* 1 阶段分布 */
M.stageReach = NODES.map(function(n){ return {name:n, cnt: ROWS.filter(function(r){ return reached(r,n); }).length}; });
/* 2 状态分布（商机 × 节点 格） */
M.cellState = {已完成:0, 进行中:0, 卡住:0, 未开始:0};
ROWS.forEach(function(r){ NODES.forEach(function(n){ M.cellState[nodeState(r,n)]++; }); });
/* 3 销售额：按部门 + 按阶段（当前节点） */
M.byDept = RAW.depts.map(function(d){
  const rs=ROWS.filter(function(r){ return r.dept===d; });
  return {dept:d, cnt:rs.length, amt: rs.reduce(function(a,r){ return a+parseAmt(r.scale); },0)};
}).filter(function(x){ return x.cnt>0; });
M.byStage = NODES.map(function(n){ return {name:n}; }).map(function(o, i){
  const rs=ROWS.filter(function(r){ return curNode(r)===i; });
  return {name:o.name, cnt:rs.length, amt: rs.reduce(function(a,r){ return a+parseAmt(r.scale); },0)};
}).filter(function(x){ return x.cnt>0; });
/* 4 客户数量 */
M.byDeptCust = RAW.depts.map(function(d){
  const s={}; ROWS.filter(function(r){ return r.dept===d; }).forEach(function(r){ s[r.cust]=1; });
  return {dept:d, cust:Object.keys(s).length, opps: ROWS.filter(function(r){ return r.dept===d; }).length};
}).filter(function(x){ return x.opps>0; });
/* 5 阶段停留时长 */
M.durations = [];
ROWS.forEach(function(r){
  NODES.forEach(function(n){
    const c=nodeCell(r,n); if(!c || !c.start) return;
    const s=d2t(c.start), e=c.end ? d2t(c.end) : T;
    M.durations.push({cust:r.cust, node:n, state:c.state, days: Math.round((e-s)/DAY)});
  });
});
M.durTop = M.durations.slice().sort(function(a,b){ return b.days-a.days; }).slice(0,5);
M.durAvg = M.durations.length ? Math.round(M.durations.reduce(function(a,x){return a+x.days;},0)/M.durations.length) : 0;
M.durMax = M.durations.length ? Math.max.apply(null, M.durations.map(function(x){return x.days;})) : 0;
/* 6 转化率 + 平均推进周期 */
M.trans = [];
for(let i=0;i<NODES.length-1;i++){
  const a=NODES[i], b=NODES[i+1];
  const na=ROWS.filter(function(r){ return reached(r,a); }).length;
  const nb=ROWS.filter(function(r){ return reached(r,b); }).length;
  const pairs=ROWS.filter(function(r){ return nodeCell(r,a)&&nodeCell(r,a).start&&nodeCell(r,b)&&nodeCell(r,b).start; })
    .map(function(r){ return Math.round((d2t(nodeCell(r,b).start)-d2t(nodeCell(r,a).start))/DAY); });
  M.trans.push({from:a, to:b, na:na, nb:nb, rate: na? Math.round(nb/na*100):null,
    avgDays: pairs.length? Math.round(pairs.reduce(function(x,y){return x+y;},0)/pairs.length):null});
}
/* 7 资源消耗与闭环率 */
M.laneCount = {}; LANES.forEach(function(l){ M.laneCount[l]=0; });
ROWS.forEach(function(r){ (r.events||[]).forEach(function(e){ M.laneCount[e.lane]=(M.laneCount[e.lane]||0)+1; }); });
M.supportEvents = RAW.support_types.reduce(function(a,t){ return a+(M.laneCount[t]||0); }, 0);
M.closedOpps = ROWS.filter(function(r){ return (r.events||[]).some(function(e){ return e.k==='闭环'; }); }).length;
M.closeRate = M.totalOpps ? Math.round(M.closedOpps/M.totalOpps*100) : 0;
/* 8 沉默商机预警 */
M.silence = ROWS.map(function(r){
  const le=lastEventDate(r), days = le? Math.round((T-d2t(le))/DAY) : 0;
  return {cust:r.cust, dept:r.dept, days:days, amt:parseAmt(r.scale), last:le};
}).sort(function(a,b){ return b.days-a.days; });
M.silTiers = [
  {label:'> 30 天（31–60）', n:0, amt:0, list:[]},
  {label:'> 60 天（61–90）', n:0, amt:0, list:[]},
  {label:'> 90 天',         n:0, amt:0, list:[]}];
M.silence.forEach(function(s){
  if(s.days>90){ M.silTiers[2].n++; M.silTiers[2].amt+=s.amt; M.silTiers[2].list.push(s); }
  else if(s.days>60){ M.silTiers[1].n++; M.silTiers[1].amt+=s.amt; M.silTiers[1].list.push(s); }
  else if(s.days>30){ M.silTiers[0].n++; M.silTiers[0].amt+=s.amt; M.silTiers[0].list.push(s); }
});
/* 9 销售员维度 */
M.bySales = (function(){
  const m={};
  ROWS.forEach(function(r){
    if(!m[r.sales]) m[r.sales]={sales:r.sales, cnt:0, amt:0, stuck:0};
    m[r.sales].cnt++; m[r.sales].amt+=parseAmt(r.scale);
    const i=curNode(r); if(i>=0 && nodeState(r,NODES[i])==='卡住') m[r.sales].stuck++;
  });
  return Object.keys(m).map(function(k){return m[k];}).sort(function(a,b){ return b.amt-a.amt; });
})();
/* 10 规模分档 */
M.scaleBuckets = [
  {label:'< 10 万',       n:0, amt:0},
  {label:'10–50 万',      n:0, amt:0},
  {label:'> 50 万',       n:0, amt:0}];
ROWS.forEach(function(r){
  const a=parseAmt(r.scale);
  if(a<100000){ M.scaleBuckets[0].n++; M.scaleBuckets[0].amt+=a; }
  else if(a<=500000){ M.scaleBuckets[1].n++; M.scaleBuckets[1].amt+=a; }
  else { M.scaleBuckets[2].n++; M.scaleBuckets[2].amt+=a; }
});
/* 11 数据治理 */
M.unlinked = ROWS.filter(function(r){ return r.unlinked; });
M.unlinkedRatio = M.totalOpps ? Math.round(M.unlinked.length/M.totalOpps*1000)/10 : 0;
window.__DASH = M;

/* ========== 渲染工具 ========== */
function bar(label, val, max, cls, valTxt){
  const w = max>0 ? Math.max(2, Math.round(val/max*100)) : 2;
  return '<div class="bar"><span class="bl" title="'+esc(label)+'">'+esc(label)+'</span>'
    + '<span class="bt"><i class="'+(cls||'')+'" style="width:'+w+'%"></i></span>'
    + '<span class="bv">'+esc(valTxt!=null?valTxt:val)+'</span></div>';
}
function ring(segments, centerVal, centerLbl){
  let acc=0; const stops=[];
  const tot = segments.reduce(function(a,s){return a+s.v;},0) || 1;
  segments.forEach(function(s){
    const p=s.v/tot*100; stops.push(s.c+' '+acc.toFixed(2)+'% '+(acc+p).toFixed(2)+'%'); acc+=p;
  });
  return '<div class="ring" style="background:conic-gradient('+stops.join(',')+')">'
    + '<div class="rc"><b>'+esc(centerVal)+'</b><span>'+esc(centerLbl)+'</span></div></div>';
}
function legend(segments){
  return '<div class="lgd">'+segments.map(function(s){
    return '<div class="li"><i class="sw" style="background:'+s.c+'"></i>'+esc(s.k)
      + '<span class="lv">'+esc(s.v)+'</span></div>';
  }).join('')+'</div>';
}
function srcTag(txt){ return '<div class="src"><b>数据来源</b>：'+txt+'</div>'; }

/* ========== 结论摘要（数据自动生成） ========== */
let CONCL_TEXT = '';
function renderConcl(){
  const mx = M.durTop[0];
  const s90 = M.silTiers[2].n;
  CONCL_TEXT = '在跟商机 '+M.totalOpps+' 个，总额 ¥'+fmtW(M.oppAmount)+'；'
    + M.stuckOpps+' 个卡住（'+(mx? shortCo(mx.cust)+'停留 '+mx.days+' 天最长':'—')+'），'
    + s90+' 个沉默超 90 天；资源闭环率 '+M.closeRate+'%。';
  $('concl').innerHTML = '<span class="ck">本期结论</span>'+esc(CONCL_TEXT);
}

/* ========== 顶部 4 KPI（阈值色） ========== */
function renderKpis(){
  const k4flag = M.stuckOpps>0 ? 'flag-red' : '';
  const k5flag = M.unlinkedRatio>30 ? 'flag-amber' : '';
  $('kpiRow').innerHTML =
      kpi('商机总数', M.totalOpps, '第一层一行一商机 · 共 '+RAW.depts.length+' 个部门', 'k1', '')
    + kpi('客户数', M.customers, '去重客户（公司名）', 'k2', '')
    + kpi('商机总额', fmtY(M.oppAmount), '按商机规模合计（US$ 按 1:'+RAW.usd_cny+' 折算）', 'k3', '')
    + kpi('卡住商机数', M.stuckOpps, '当前节点状态 =「卡住」的商机', 'k4', k4flag,
          M.stuckOpps>0 ? ('⚠ '+M.stuckOpps+' 个待解') : '')
    + kpi('数据待关联率', M.unlinkedRatio+'%', '客户编码 / 商机 ID 未回填（null）的行占比', 'k5', k5flag,
          M.unlinkedRatio>30 ? ('⚠ 待关联 '+M.unlinkedRatio+'%') : '');
}
function kpi(lbl, num, foot, cls, flag, flagTxt){
  return '<div class="kpi '+cls+' '+(flag||'')+'">'
    + (flagTxt ? '<span class="flagtag">'+esc(flagTxt)+'</span>' : '')
    + '<div class="lbl">'+esc(lbl)+'</div><div class="num">'+esc(num)+'</div>'
    + '<div class="foot">'+esc(foot)+'</div></div>';
}

/* ========== 11 块卡：可拖拽换位 / 可缩放 / 图形真自适应 ========== */
const CARD_META = {
 1:{t:'阶段分布漏斗', d:'8 个关键节点各有多少商机（已到达该节点的商机数）',
    s:'第一层节点到达 → <code>opportunities.stages{}</code>（节点格 state ∈ 已完成/进行中/卡住 即视为已到达）'},
 2:{t:'商机状态分布', d:'按「商机 × 关键节点」单元格状态统计（每商机 8 个节点格）',
    s:'节点格 state（<code>opportunities.stages{}.state</code>）→ 四态：未开始 / 进行中 / 已完成 / 卡住'},
 3:{t:'商机销售额', d:'商机总额 + 按部门 + 按（当前所处）阶段拆分',
    s:'金额 → <code>orders.amount</code>（ERP 单据编号 <code>doc_no</code> 对接）/ <code>opportunities.expect_amount</code>'},
 4:{t:'客户数量', d:'去重客户数（按公司名）+ 按部门分布',
    s:'客户主档 → <code>cust_master.work_item_id</code>（+ <code>aliases[]</code> 别名桥归一）'},
 5:{t:'阶段停留时长 / 卡点 Top', d:'每个项目在每个节点的停留天数 = 该节点开始→结束（未收口按今天）',
    s:'节点日期 → <code>opportunities.stages{}.start / .end</code>（相减得天；无 end 用「今天」）'},
 6:{t:'阶段转化率 + 平均推进周期', d:'进入某节点的商机 → 进入下一节点的比例与平均天数',
    s:'节点到达（<code>stages{}</code>）+ 日期差 → 转化率 = 下游到达数 / 上游到达数'},
 7:{t:'资源消耗与闭环率', d:'技术支持测试项目单 / 研发项目单 / 售后 / 其他 触发次数 + 已闭环 / 未闭环',
    s:'资源 → <code>support_todos</code>；闭环 → <code>thread_view.event_type = todo_close</code>'},
 8:{t:'沉默商机预警', d:'最后跟进距今 > 30 / 60 / 90 天的商机数与金额',
    s:'最后跟进 = 该商机最新事件日期 → <code>activities.date</code> / <code>thread_view.ts</code>（与今天相减）'},
 9:{t:'销售员维度', d:'人均商机数 / 金额 / 卡住数（按销售员聚合）',
    s:'销售员 → <code>opportunities.owner</code> / <code>wide_rows.sales</code>（行级隔离口径同工作台）'},
 10:{t:'商机规模分档', d:'10 万以下 / 10–50 万 / 50 万以上 三档商机数与金额',
    s:'金额 → <code>orders.amount</code> / <code>opportunities.expect_amount</code>（分档口径固定）'},
 11:{t:'数据治理 · 待关联 ID 占比', d:'客户编码 / 商机 ID 未回填（null）的行占比（append-only 允许 null）',
    s:'外键回填 → <code>activities.customer_id</code> / <code>*.opp_id</code>（null ⇒ 前端标黄「待关联」）'}
};
const RISK_CARDS=[5,8,7], DETAIL_CARDS=[3,6,9,1,4,11,2,10];
const DEF_CHILD_ORDER=['sec:risk'].concat(RISK_CARDS.map(function(i){return 'c'+i;}))
  .concat(['sec:detail']).concat(DETAIL_CARDS.map(function(i){return 'c'+i;}));
const DEF_TIER={3:2, 9:2};
function defTier(i){ return DEF_TIER[i]||1; }
const SPAN=[1,1,2,3];                 /* 小 / 中 / 大 / 宽 */
const TIER_LABEL=['小','中','大','宽'];

const SECS={}, CARDS={};
const layout={ order:DEF_CHILD_ORDER.slice(), tier:{} };
(function(){ for(let i=1;i<=11;i++) layout.tier[i]=defTier(i); })();

/* —— 自适应卡体：按测量宽度 w 重绘（真自适应，不是 CSS 缩放）—— */
function overflowHTML(){ return '<div class="ovf">⚠ 卡片过小，内容溢出 —— 请拖右下角手柄「拉大」</div>'; }
function stackBar(segs, unit){
  const tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
  let h='<div class="sbar">';
  segs.forEach(function(s){ if(s.v>0) h+='<i style="width:'+(s.v/tot*100).toFixed(2)+'%;background:'+s.c+'"></i>'; });
  h+='</div><div class="sbar-lgd">'+segs.map(function(s){
    return '<span class="sl"><i class="sw" style="background:'+s.c+'"></i>'+esc(s.k)+' '+esc(s.v)+(unit||'')+'</span>';
  }).join('')+'</div>';
  return h;
}
function cardBody(i, w, compact){
  w = Math.max(120, Math.round(w||360));
  /* 1 阶段分布漏斗（SVG · 宽度驱动条宽） */
  if(i===1){
    const LB=compact?52:72, RH=compact?22:30, RV=compact?16:18;
    const W=w, TW=W-LB-(compact?30:52), H=M.stageReach.length*RH+12;
    if(TW<70) return overflowHTML();
    const max=Math.max.apply(null, M.stageReach.map(function(x){return x.cnt;}))||1;
    let out='';
    M.stageReach.forEach(function(s,idx){
      const y=8+idx*RH, bw=Math.max(3, Math.round(s.cnt/max*TW));
      out+='<rect x="'+LB+'" y="'+y+'" width="'+bw+'" height="'+RV+'" rx="4" fill="'+(s.cnt>0?'url(#g1)':'#e6ebf3')+'"/>';
      out+='<text x="'+(LB-8)+'" y="'+(y+RV-4)+'" text-anchor="end" class="axl">'+esc(SHORT[s.name]||s.name)+'</text>';
      out+='<text x="'+(LB+bw+7)+'" y="'+(y+RV-4)+'" class="axv">'+s.cnt+'</text>';
    });
    return svgGuard('<svg width="'+W+'" height="'+H+'" viewBox="0 0 '+W+' '+H+'" xmlns="http://www.w3.org/2000/svg">'
      +'<defs><linearGradient id="g1" x1="0" y1="0" x2="1" y2="0"><stop offset="0%" stop-color="#1852B6"/><stop offset="100%" stop-color="#4f8ef0"/></linearGradient></defs>'
      +out+'</svg>');
  }
  /* 2 商机状态分布（环形 ↔ 紧凑堆叠条） */
  if(i===2){
    const seg=[{k:'已完成',v:M.cellState['已完成'],c:C.done},{k:'进行中',v:M.cellState['进行中'],c:C.doing},
               {k:'卡住',v:M.cellState['卡住'],c:C.stuck},{k:'未开始',v:M.cellState['未开始'],c:C.todo}];
    if(compact) return '<div class="sbar-tot">共 '+M.totalOpps+' 个商机 · 状态分布</div>'+stackBar(seg,' 格');
    return '<div class="ringwrap">'+ring(seg, M.totalOpps, '个商机')
      +legend(seg.map(function(s){return {k:s.k, c:s.c, v:s.v+' 格'};}))+'</div>';
  }
  /* 3 商机销售额 */
  if(i===3){
    const maxD=Math.max.apply(null, M.byDept.map(function(x){return x.amt;}))||1;
    let h='<div class="ministat" style="margin-bottom:10px">'
      +'<div class="ms"><div class="v">'+fmtY(M.oppAmount)+'</div><div class="k">商机总额</div></div>'
      +'<div class="ms"><div class="v">'+fmtW(M.oppAmount)+'</div><div class="k">折合万元</div></div></div>';
    h+='<div class="tag" style="margin:0 0 6px">按部门拆分</div><div class="bars" style="margin-bottom:10px">';
    M.byDept.forEach(function(d){ h+=bar(d.dept, d.amt, maxD, 'grad', fmtW(d.amt)+'（'+d.cnt+'）'); });
    h+='</div><div class="tag" style="margin:0 0 6px">按阶段（当前节点）拆分</div><div class="bars">';
    const maxS=Math.max.apply(null, M.byStage.map(function(x){return x.amt;}))||1;
    M.byStage.forEach(function(s){ h+=bar(SHORT[s.name]||s.name, s.amt, maxS, 'cyan', fmtW(s.amt)+'（'+s.cnt+'）'); });
    h+='</div>';
    return h;
  }
  /* 4 客户数量 */
  if(i===4){
    const maxC=Math.max.apply(null, M.byDeptCust.map(function(x){return x.opps;}))||1;
    let h='<div class="ministat" style="margin-bottom:10px">'
      +'<div class="ms"><div class="v">'+M.customers+'</div><div class="k">去重客户数</div></div>'
      +'<div class="ms"><div class="v">'+M.totalOpps+'</div><div class="k">商机数</div></div>'
      +'<div class="ms"><div class="v">'+(M.totalOpps/M.customers).toFixed(2)+'</div><div class="k">人均商机/客户</div></div></div>'
      +'<div class="bars">';
    M.byDeptCust.forEach(function(d){ h+=bar(d.dept, d.opps, maxC, 'doing', d.cust+' 客 / '+d.opps+' 商'); });
    h+='</div>';
    return h;
  }
  /* 5 阶段停留时长 Top5（风险） */
  if(i===5){
    const max=M.durMax||1;
    let h='<div class="ministat" style="margin-bottom:10px">'
      +'<div class="ms"><div class="v">'+M.durMax+'</div><div class="k">最长停留（天）</div></div>'
      +'<div class="ms"><div class="v">'+M.durAvg+'</div><div class="k">平均停留（天）</div></div>'
      +'<div class="ms"><div class="v">'+M.durations.length+'</div><div class="k">节点格样本数</div></div></div>'
      +'<div class="tag" style="margin:0 0 6px">停留最久 Top 5（卡点）</div><div class="bars">';
    M.durTop.forEach(function(d){
      const cls=d.state==='卡住'?'stuck':(d.state==='进行中'?'doing':'done');
      h+=bar(shortCo(d.cust)+' · '+(SHORT[d.node]||d.node), d.days, max, cls, d.days+' 天');
    });
    h+='</div>';
    return h;
  }
  /* 6 转化率 + 平均推进周期 */
  if(i===6){
    let h='<div class="bars">';
    M.trans.forEach(function(t){
      const r=t.rate==null?'—':(t.rate+'%');
      const cls=t.rate==null?'todo':(t.rate>=80?'done':(t.rate>=40?'doing':'stuck'));
      h+=bar((SHORT[t.from]||t.from)+'→'+(SHORT[t.to]||t.to), t.rate==null?0:t.rate, 100, cls, r);
      if(!compact) h+='<div class="barnote">'+esc(t.na)+'→'+esc(t.nb)+' 个商机 · 平均 '+(t.avgDays==null?'—':(t.avgDays+' 天'))+'</div>';
    });
    h+='</div>';
    return h;
  }
  /* 7 资源消耗与闭环率（风险） */
  if(i===7){
    const max=Math.max.apply(null, LANES.map(function(l){return M.laneCount[l]||0;}))||1;
    const seg=[{k:'已闭环商机',v:M.closedOpps,c:C.done},{k:'未闭环商机',v:M.totalOpps-M.closedOpps,c:'#D97706'}];
    const laneCls={'技术支持测试项目单':'doing','研发项目单':'violet','售后':'amber','其他':'grad','活动':'cyan'};
    let h='<div class="bars">';
    LANES.forEach(function(l){ h+=bar(l, M.laneCount[l]||0, max, laneCls[l], (M.laneCount[l]||0)+' 次'); });
    h+='</div>';
    if(compact) h+='<div class="sbar-tot" style="margin-top:8px">闭环率 '+M.closeRate+'%</div>'+stackBar(seg,'');
    else h+='<div class="ringwrap" style="margin-top:10px">'+ring(seg, M.closeRate+'%', '闭环率')
      +legend(seg.map(function(s){return {k:s.k,c:s.c,v:s.v+' 个'};}))+'</div>';
    return h;
  }
  /* 8 沉默商机预警（风险） */
  if(i===8){
    const max=Math.max.apply(null, M.silTiers.map(function(x){return x.n;}))||1;
    let h='<div class="ministat" style="margin-bottom:10px">';
    M.silTiers.forEach(function(t){
      h+='<div class="ms"><div class="v" style="color:'+(t.n>0?'#C0392B':'#93a1b5')+'">'+t.n+'</div><div class="k">'+esc(t.label)+'</div></div>';
    });
    h+='</div><div class="bars">';
    M.silTiers.forEach(function(t){ h+=bar(t.label, t.n, max, t.n>0?'stuck':'todo', t.n+' 个 / '+fmtW(t.amt)); });
    h+='</div><div class="tag warn" style="margin:8px 0 0;display:block;white-space:normal;line-height:1.6">沉默最久：'
      +esc(M.silence.slice(0,3).map(function(s){return shortCo(s.cust)+' '+s.days+'天';}).join('、'))+'</div>';
    return h;
  }
  /* 9 销售员维度（表格） */
  if(i===9){
    let h='<div style="max-height:280px;overflow:auto"><table class="tb"><thead><tr>'
      +'<th>销售员</th><th style="text-align:right">商机数</th><th style="text-align:right">金额</th><th style="text-align:right">卡住数</th>'
      +'</tr></thead><tbody>';
    M.bySales.forEach(function(s){
      h+='<tr><td>'+esc(s.sales)+'</td><td class="n">'+s.cnt+'</td><td class="n">'+fmtW(s.amt)+'</td>'
        +'<td class="n">'+(s.stuck>0?'<span class="pill stuck">'+s.stuck+'</span>':'0')+'</td></tr>';
    });
    h+='</tbody></table></div>';
    return h;
  }
  /* 10 商机规模分档 */
  if(i===10){
    const max=Math.max.apply(null, M.scaleBuckets.map(function(x){return x.n;}))||1;
    const cls=['cyan','grad','doing'];
    let h='<div class="bars">';
    M.scaleBuckets.forEach(function(b,idx){ h+=bar(b.label, b.n, max, cls[idx], b.n+' 个 / '+fmtW(b.amt)); });
    h+='</div>';
    return h;
  }
  /* 11 数据治理 */
  if(i===11){
    const seg=[{k:'已关联 ID', v:M.totalOpps-M.unlinked.length, c:C.doing},{k:'待关联 ID', v:M.unlinked.length, c:'#F59E0B'}];
    let h;
    if(compact) h='<div class="sbar-tot">待关联占比 '+M.unlinkedRatio+'%</div>'+stackBar(seg,' 行');
    else h='<div class="ringwrap">'+ring(seg, M.unlinkedRatio+'%', '待关联占比')
      +legend(seg.map(function(s){return {k:s.k,c:s.c,v:s.v+' 行'};}))+'</div>';
    h+='<div class="tag warn" style="margin:10px 0 0;display:block;white-space:normal;line-height:1.6">待关联行：'
      +esc(M.unlinked.map(function(r){return shortCo(r.cust);}).join('、'))+'</div>';
    return h;
  }
  return '';
}

/* —— 卡片 / 区块标题 DOM 构造 —— */
function cardEl(i){
  const meta=CARD_META[i];
  const el=document.createElement('div');
  el.className='card'+(RISK_CARDS.indexOf(i)>=0?' risk':'')+(defTier(i)>1?' wide':'');
  el.dataset.card=i;
  el.innerHTML='<h3><span class="idx">'+i+'</span>'+esc(meta.t)+'</h3>'
    +'<div class="cdesc">'+esc(meta.d)+'</div>'
    +'<div class="body"></div>'
    +'<div class="overflow-hint">⚠ 内容溢出：卡片过小，请拖右下角手柄拉大</div>'
    +srcTag(meta.s)
    +'<span class="handle" aria-hidden="true"></span>';
  return el;
}
function secEl(kind, title, sub){
  const d=document.createElement('div');
  d.className='sec sec-'+kind;
  d.innerHTML=esc(title)+'<span class="sx">'+esc(sub)+'</span>';
  return d;
}
function elForKey(k){
  if(k==='sec:risk') return SECS.risk;
  if(k==='sec:detail') return SECS.detail;
  if(k.charAt(0)==='c') return CARDS[parseInt(k.slice(1),10)];
  return null;
}
function applyOrder(order){
  const grid=$('grid');
  order.forEach(function(k){ const el=elForKey(k); if(el) grid.appendChild(el); });
}

/* —— 真自适应重绘：量测宽度 → 重画图形 —— */
function redrawAll(){
  document.querySelectorAll('#grid .card').forEach(function(card){
    const i=parseInt(card.dataset.card,10);
    let tier=layout.tier[i]; if(tier==null) tier=defTier(i);
    card.style.gridColumn='span '+SPAN[tier];
    card.classList.toggle('wide', SPAN[tier]>1);
    card.classList.toggle('t-min', tier===0);
    const body=card.querySelector('.body');
    const w=body.clientWidth || (card.clientWidth-32);
    const compact = tier===0 || w < 300;
    card.classList.toggle('compact', compact);
    body.innerHTML = cardBody(i, w, compact);
    const ovf = body.scrollHeight > body.clientHeight + 2;
    card.classList.toggle('has-overflow', ovf);
  });
}
function buildGridInit(){
  for(let i=1;i<=11;i++) CARDS[i]=cardEl(i);
  SECS.risk=secEl('risk','风险预警 · 优先处置','卡点 Top / 沉默三档 / 资源未闭环 —— 总经理先看这三块');
  SECS.detail=secEl('detail','业务明细 · 指标卡','销售额 / 转化 / 人力 / 结构 / 治理');
  layout.order=DEF_CHILD_ORDER.slice();
  for(let i=1;i<=11;i++) layout.tier[i]=defTier(i);
  applyOrder(layout.order);
  redrawAll();
}

/* —— 拖拽换位（原生 HTML5 DnD · 仅限同一区块内）+ 右下角缩放手柄 —— */
let dragCard=null, dragCardSec=null;
const HINT_DEFAULT='同区块内拖拽可换位（区块顺序固定）';
/* 卡片所属区块 = 它之前的最近一个 .sec 兄弟（区块标题横跨整行，DOM 顺序为 [sec…][卡…][sec…][卡…]）*/
function secOf(el){
  let p=el;
  while(p){ p=p.previousElementSibling; if(p && p.classList && p.classList.contains('sec')) return p; }
  return null;
}
function setHint(txt, deny){
  const h=$('dragHint'); if(!h) return;
  h.textContent = txt || HINT_DEFAULT;
  h.classList.toggle('deny', !!deny);
}
function syncOrder(){
  layout.order = Array.prototype.map.call($('grid').children, function(el){
    return el.classList.contains('sec') ? ('sec:'+(el.classList.contains('sec-risk')?'risk':'detail')) : ('c'+el.dataset.card);
  });
}
function attachGrid(){
  const grid=$('grid');
  grid.addEventListener('dragstart', function(e){
    if(e.target.closest && e.target.closest('.handle')){ e.preventDefault(); return; }
    const card=e.target.closest && e.target.closest('.card'); if(!card) return;
    dragCard=card; dragCardSec=secOf(card); card.classList.add('dragging');
    setHint(HINT_DEFAULT, false);
    if(e.dataTransfer){ try{ e.dataTransfer.effectAllowed='move'; e.dataTransfer.setData('text/plain', card.dataset.card); }catch(_e){} }
  });
  grid.addEventListener('dragover', function(e){
    if(!dragCard) return;
    const card=e.target.closest && e.target.closest('.card'); if(!card || card===dragCard) return;
    /* 跨区块 → 拒绝放置：不重排 DOM（等价回弹）、禁止光标、工具条给提示 */
    if(secOf(card)!==dragCardSec){
      e.preventDefault();
      if(e.dataTransfer){ try{ e.dataTransfer.dropEffect='none'; }catch(_e){} }
      dragCard.classList.add('dragging-cross');
      setHint('⚠ 只能在同一个区块内换位 —— 区块顺序固定，跨区放置已被拒绝', true);
      return;
    }
    e.preventDefault();
    if(e.dataTransfer){ try{ e.dataTransfer.dropEffect='move'; }catch(_e){} }
    dragCard.classList.remove('dragging-cross');
    setHint(HINT_DEFAULT, false);
    const r=card.getBoundingClientRect();
    const after = (e.clientX!=null) && (e.clientX > r.left + r.width/2);
    if(after){ if(dragCard.nextElementSibling!==card) grid.insertBefore(dragCard, card.nextSibling); }
    else { if(card.previousSibling!==dragCard) grid.insertBefore(dragCard, card); }
  });
  grid.addEventListener('drop', function(e){
    e.preventDefault();
    if(dragCard && dragCard.classList.contains('dragging-cross')){   /* 跨区块落点 → 拒绝，顺序不动 */
      setHint('⚠ 跨区块放置已被拒绝（区块顺序固定，卡片已回弹）', true);
      window.__lastDrop='rejected'; return;
    }
    syncOrder(); redrawAll(); persistLocal(); window.__lastDrop='accepted';
  });
  grid.addEventListener('dragend', function(){
    if(dragCard){ dragCard.classList.remove('dragging'); dragCard.classList.remove('dragging-cross'); }
    dragCard=null; dragCardSec=null;
  });
  grid.addEventListener('mousedown', function(e){
    const h=e.target.closest && e.target.closest('.handle'); if(!h) return;
    e.preventDefault(); startResize(h.closest('.card'), e.clientX);
  });
}
function startResize(card, startX){
  const i=parseInt(card.dataset.card,10);
  const startTier=(layout.tier[i]==null)?defTier(i):layout.tier[i];
  const colW=Math.max(140, $('grid').clientWidth/3);
  let moved=false;
  document.body.classList.add('resizing');
  function mm(ev){
    const dx=ev.clientX-startX;
    if(Math.abs(dx)>3) moved=true;
    let tier=Math.max(0, Math.min(3, Math.round(startTier + dx/(colW*0.55))));
    if(tier!==layout.tier[i]){ layout.tier[i]=tier; redrawAll(); }
  }
  function mu(){
    document.removeEventListener('mousemove', mm); document.removeEventListener('mouseup', mu);
    document.body.classList.remove('resizing');
    if(!moved){ layout.tier[i]=(layout.tier[i]+1)%4; redrawAll(); }   /* 单击手柄 = 循环 小/中/大/宽 */
    persistLocal();
  }
  document.addEventListener('mousemove', mm); document.addEventListener('mouseup', mu);
}

/* —— 布局持久化（localStorage · 键名带用户标识占位）—— */
const LS_USER = (typeof window!=='undefined' && window.__DASH_USER) ? window.__DASH_USER : 'demo-user';
const LKEY = 'ct_dash_layout__' + LS_USER;
function persistLocal(){
  try{ localStorage.setItem(LKEY, JSON.stringify({v:1, order:layout.order, tier:layout.tier}));
       $('dashMsg') && ($('dashMsg').textContent='已保存到本地 · '+new Date().toTimeString().slice(0,8)); }
  catch(e){ window.__lsErr=String(e); }
}
function loadLayout(){
  try{
    const raw=localStorage.getItem(LKEY); if(!raw) return false;
    const o=JSON.parse(raw); if(!o||!o.order||!o.tier) return false;
    const ok=o.order.filter(function(k){ return k.indexOf('sec:')===0 || (k.charAt(0)==='c' && CARDS[parseInt(k.slice(1),10)]); });
    if(ok.length!==13) return false;
    layout.order=ok.slice();
    for(let i=1;i<=11;i++){ const t=o.tier[i]; layout.tier[i]=(typeof t==='number'&&t>=0&&t<=3)?t:defTier(i); }
    return true;
  }catch(e){ return false; }
}
function applyLayout(){ applyOrder(layout.order); redrawAll(); }
function resetLayout(){
  layout.order=DEF_CHILD_ORDER.slice();
  for(let i=1;i<=11;i++) layout.tier[i]=defTier(i);
  try{ localStorage.removeItem(LKEY); }catch(e){}
  applyLayout();
  $('dashMsg') && ($('dashMsg').textContent='已恢复默认布局（并清除本地保存）');
}

/* —— 打印：回到默认顺序 + 隐藏手柄 —— */
function preparePrint(){
  document.body.classList.add('printing');
  applyOrder(DEF_CHILD_ORDER.slice());
  redrawAll();
  return childOrder();
}
function endPrint(){ document.body.classList.remove('printing'); applyLayout(); }
function childOrder(){
  return Array.prototype.map.call($('grid').children, function(el){
    return el.classList.contains('sec') ? ('sec:'+(el.classList.contains('sec-risk')?'risk':'detail')) : ('c'+el.dataset.card);
  });
}

/* —— 验收用探针 —— */
window.__cardOrder=function(){ return Array.prototype.map.call(document.querySelectorAll('#grid .card'), function(c){ return c.dataset.card; }); };
window.__childOrder=childOrder;
window.__cardW=function(i){ const c=document.querySelector('#grid .card[data-card="'+i+'"]'); return c?Math.round(c.getBoundingClientRect().width):null; };
window.__svgW=function(i){ const s=document.querySelector('#grid .card[data-card="'+i+'"] svg'); return s?Math.round(parseFloat(s.getAttribute('width'))):null; };
window.__cardTier=function(i){ return layout.tier[i]; };
window.__resetLayout=resetLayout;
window.__saveLayout=function(){ persistLocal(); return true; };
window.__layoutKey=LKEY;
window.__preparePrint=preparePrint;
window.__endPrint=endPrint;
window.__isPrinting=function(){ return document.body.classList.contains('printing'); };
window.__handleDisplay=function(){ const h=document.querySelector('#grid .card .handle'); return h?getComputedStyle(h).display:null; };
window.__lsValue=function(){ try{ return localStorage.getItem(LKEY); }catch(e){ return null; } };
/* 拖拽范围探针：区块标题快照 / 卡片所属区块 / 工具条提示 / 上次落点结果 */
window.__secInfo=function(){ return Array.prototype.map.call(document.querySelectorAll('#grid .sec'), function(e){
  const r=e.getBoundingClientRect(); return {text:e.textContent.trim(), x:Math.round(r.left), y:Math.round(r.top)}; }); };
window.__secOfCard=function(i){ const c=document.querySelector('#grid .card[data-card="'+i+'"]'); if(!c) return null;
  const s=secOf(c); return s? s.textContent.trim() : null; };
window.__dragHintText=function(){ const h=$('dragHint'); return h? h.textContent : null; };
window.__dragHintDeny=function(){ const h=$('dragHint'); return h? h.classList.contains('deny') : null; };

/* SVG 守卫：内联 SVG 内禁 title / br */
window.__svgErr = 0;
function svgGuard(s){
  if(new RegExp('<ti'+'tle[\\s>]','i').test(s) || new RegExp('<b'+'r[\\s>]','i').test(s)) window.__svgErr++;
  return s;
}

/* ========== init ========== */
renderConcl();
renderKpis();
buildGridInit();
attachGrid();
(function(){
  const restored = loadLayout();
  if(restored) applyLayout(); else redrawAll();
})();
$('btnResetLayout').onclick=function(){ resetLayout(); };
$('btnSaveLayout').onclick=function(){ persistLocal(); $('dashMsg') && ($('dashMsg').textContent='已保存为我的布局（本地存储）'); };
window.addEventListener('resize', function(){ clearTimeout(window.__rz); window.__rz=setTimeout(redrawAll,120); });

/* 打印原生钩子：打印前把 DOM 顺序复位为默认（忽略拖拽布局），打印后还原用户布局 */
window.onbeforeprint=function(){ preparePrint(); };
window.onafterprint=function(){ endPrint(); };
(function(){
  try{
    var mq=window.matchMedia('print');
    if(mq && mq.addEventListener){ mq.addEventListener('change', function(e){ if(e.matches) preparePrint(); else endPrint(); }); }
  }catch(e){}
})();

window.__dashOK = {
  cards: document.querySelectorAll('#grid .card').length,
  kpis: document.querySelectorAll('#kpiRow .kpi').length,
  totalOpps: M.totalOpps, customers: M.customers,
  oppAmount: Math.round(M.oppAmount), stuckOpps: M.stuckOpps
};
window.__dashReport = {
  concl: CONCL_TEXT,
  riskCards: document.querySelectorAll('#grid .card.risk').length,
  riskCardsBeforeDetail: (function(){
    const secs=document.querySelectorAll('#grid .sec'); return secs.length===2 && /风险/.test(secs[0].textContent);
  })(),
  kpiFlags: {卡住商机数: (M.stuckOpps>0?'red':''), 数据待关联率: (M.unlinkedRatio>30?'amber':'')},
  fontLevels: 4, gridCols: 3, wrap: 1440, kpiCount: 5
};
window.__svgGuard = function(){ return window.__svgErr === 0; };
</script>
</body>
</html>
"""


def main():
    html = HTML.replace('__RAW__', json.dumps(RAW, ensure_ascii=False))
    html = BK.inject(html, theme='light', tag='客户拉通大表 · 经营仪表盘（报告版）')
    assert html.count('<title') == 1, '出现多余 <title>（SVG 内禁 <title>）'
    assert '<title>' in html and '客户拉通大表 · 经营仪表盘（报告版）' in html, '文档标题缺失'
    # 静态断言：JS 模板串里没有裸 <title>/<br>（SVG 由 JS 构建，运行期以 svgGuard 为准）
    js = HTML
    for pat in ('<ti'+'tle', '<b'+'r'):
        assert pat not in js.replace('<title>客户拉通大表 · 经营仪表盘（报告版 · DEMO 示例数据）</title>', ''), \
            'JS 含裸 SVG 非法片段：%s' % pat
    with io.open(OUT, 'w', encoding='utf-8') as f:
        f.write(html)
    _node_check(html)
    v = BK.verify(html)
    print('✅ 写入 %s（%d 字符 / %.1f KB）' % (OUT, len(html), len(html.encode('utf-8'))/1024.0))
    print('   品牌自检:', v)
    print('   数据：商机 %d / 客户 %d / 节点 %d / 泳道 %d'
          % (len(RAW['rows']), len({r['cust'] for r in RAW['rows']}), len(RAW['nodes']), len(RAW['lanes'])))
    print('   报告版：结论摘要 + 5 KPI（阈值色·含数据待关联率）+ 风险区（提前）+ 11 块指标卡（可拖拽/缩放/真自适应）')


def _node_check(html):
    """对页面内联 JS 跑 node --check（语法铁律）。"""
    import subprocess, tempfile
    m = re.search(r'<script>(.*?)</script>', html, re.S)
    if not m:
        return
    fp = tempfile.NamedTemporaryFile('w', suffix='.js', delete=False, encoding='utf-8')
    fp.write(m.group(1))
    fp.close()
    try:
        r = subprocess.run(['node', '--check', fp.name], capture_output=True, text=True)
        assert r.returncode == 0, '内联 JS 语法错误：\n%s' % (r.stderr or r.stdout)
    finally:
        os.unlink(fp.name)


if __name__ == '__main__':
    main()
