analysis-templates.js 62 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083
  1. 'use strict';
  2. /**
  3. * 趋势图表 · 分析模板库
  4. * ----------------------------------------------------------------------------
  5. * 与页面主逻辑(reports.js)解耦:本文件只定义「模板 = 对比方式 + 指标组合 +
  6. * 图表布局」的预设。模板与看谁(整校/某食堂/某档口)正交,对象由主体选择器
  7. * 与门店筛选决定。
  8. *
  9. * 依赖注入:reports.js 调用 shanAnalysisTemplates(kit) 取回注册表。kit 提供
  10. * 格式化 / 面板与图表构造 / 逐日序列与数据质量标注 / 汇总辅助 / 颜色常量,
  11. * 以及当前主指标 metric() 与「对比上期」开关 comparePrev() 的读取函数。
  12. *
  13. * 新增模板:在此追加 render 函数并往返回数组注册一项,reports.js 无需改动。
  14. */
  15. (function () {
  16. window.shanAnalysisTemplates = function (k) {
  17. const {
  18. chartCol, metric, comparePrev,
  19. fmtNum, fmtMoney, fmtPct, fmtByCol, fmtCompact, ymdToDash, ymdDate, shiftYmd,
  20. makePanel, panelChart, panelNote, panelEmpty, panelEmptyIn,
  21. buildDailySeries, missingMarkAreas, hollowPoints, dataQualityNote,
  22. sumField, wAvgField, deltaBadge, rangeOverlapsVacation,
  23. benchData, crossData, shopFilterCount,
  24. RATE_THRESHOLDS, AXIS_TXT, SPLIT_LINE,
  25. ACCENT, COLOR_OK, COLOR_FAIL, COLOR_INFO, COLOR_PREV,
  26. } = k;
  27. const WEEKDAY_CN = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
  28. // ---------- 模板一:综合总览(原有四图,保持默认行为) ----------
  29. function renderOverviewTpl(grid, data) {
  30. const chartMetric = metric();
  31. const col = chartCol(chartMetric);
  32. const compareOn = comparePrev();
  33. const prevSorted = data.byDatePrev.slice().sort((a, b) => (a.date < b.date ? -1 : 1));
  34. const trendPanel = makePanel(grid, '', 2);
  35. const trendTitle = document.createElement('span');
  36. trendTitle.textContent = `主指标趋势 · ${col.label}(${ymdToDash(data.from)} ~ ${ymdToDash(data.to)})`;
  37. trendPanel.head.appendChild(trendTitle);
  38. dataQualityNote(trendPanel, buildDailySeries(data, chartMetric));
  39. const trend = panelChart(trendPanel, 'trend');
  40. if (trend) {
  41. const series = buildDailySeries(data, chartMetric);
  42. const xData = series.dates.map(ymdToDash);
  43. const lines = [{
  44. name: `本期·${col.label}`, type: 'line', smooth: true, symbolSize: 5,
  45. data: hollowPoints(series),
  46. itemStyle: { color: ACCENT }, lineStyle: { width: 2.5 },
  47. areaStyle: { color: 'rgba(255,107,53,.08)' },
  48. }];
  49. const areas = missingMarkAreas(series);
  50. if (areas.length) lines[0].markArea = { silent: true, data: areas };
  51. if (compareOn && prevSorted.length) {
  52. lines.push({
  53. name: '上期同期', type: 'line', smooth: true, symbol: 'none',
  54. data: series.dates.map((_, i) => (prevSorted[i] ? Number(prevSorted[i][chartMetric]) || 0 : null)),
  55. itemStyle: { color: COLOR_PREV }, lineStyle: { type: 'dashed', width: 1.6 },
  56. });
  57. }
  58. trend.setOption({
  59. tooltip: { trigger: 'axis', valueFormatter: (v) => fmtByCol(col, v) },
  60. legend: { data: lines.map((x) => x.name), top: 0, icon: 'roundRect', itemWidth: 14, itemHeight: 4, textStyle: { color: '#6b6560' } },
  61. grid: { left: 64, right: 24, top: 36, bottom: 30 },
  62. xAxis: { type: 'category', boundaryGap: false, data: xData, ...AXIS_TXT },
  63. yAxis: { type: 'value', axisLabel: { color: '#6b6560' }, splitLine: SPLIT_LINE },
  64. series: lines,
  65. }, true);
  66. }
  67. const rankPanel = makePanel(grid, '门店排行 Top 10', 1);
  68. const rank = panelChart(rankPanel, 'rank');
  69. if (rank) {
  70. const top = data.byShop.slice()
  71. .sort((a, b) => (Number(b[chartMetric]) || 0) - (Number(a[chartMetric]) || 0))
  72. .slice(0, 10).reverse();
  73. rank.setOption({
  74. tooltip: { trigger: 'axis', valueFormatter: (v) => fmtByCol(col, v) },
  75. grid: { left: 10, right: 34, top: 10, bottom: 24, containLabel: true },
  76. xAxis: { type: 'value', axisLabel: { color: '#6b6560' }, splitLine: SPLIT_LINE },
  77. yAxis: {
  78. type: 'category', data: top.map((r) => r.shopName || r.shopId),
  79. axisLabel: { color: '#2b2a28', width: 130, overflow: 'truncate' },
  80. axisLine: { lineStyle: { color: '#e8e4df' } },
  81. },
  82. series: [{ type: 'bar', barMaxWidth: 18, data: top.map((r) => Number(r[chartMetric]) || 0), itemStyle: { color: ACCENT, borderRadius: [0, 6, 6, 0] } }],
  83. }, true);
  84. }
  85. const funnelPanel = makePanel(grid, '转化漏斗(曝光 → 进店 → 下单)', 1);
  86. const funnel = panelChart(funnelPanel, 'funnel');
  87. if (funnel) {
  88. const expUv = sumField(data.byDate, 'expUv');
  89. const enter = data.byDate.reduce((a, r) => a + (Number(r.expUv) || 0) * (Number(r.clkExpRate) || 0), 0);
  90. const order = sumField(data.byDate, 'orderUserCnt');
  91. funnel.setOption({
  92. tooltip: { trigger: 'item', formatter: (p) => `${p.name}:${fmtNum(Math.round(p.value))} 人` },
  93. series: [{
  94. type: 'funnel', left: '8%', width: '84%', top: 12, bottom: 8, minSize: '20%',
  95. label: { color: '#fff', fontWeight: 600, formatter: (p) => `${p.name} ${fmtNum(Math.round(p.value))}` },
  96. data: [
  97. { name: '曝光人数', value: Math.round(expUv), itemStyle: { color: '#ffb199' } },
  98. { name: '进店人数', value: Math.round(enter), itemStyle: { color: '#ff8a5c' } },
  99. { name: '下单人数', value: Math.round(order), itemStyle: { color: ACCENT } },
  100. ],
  101. }],
  102. }, true);
  103. }
  104. const comparePanel = makePanel(grid, '', 2);
  105. const compareTitle = document.createElement('span');
  106. comparePanel.head.appendChild(compareTitle);
  107. const compare = panelChart(comparePanel, 'compare');
  108. const top5 = data.byShop.slice()
  109. .sort((a, b) => (Number(b[chartMetric]) || 0) - (Number(a[chartMetric]) || 0))
  110. .slice(0, 5);
  111. compareTitle.textContent = `门店对比 · Top ${top5.length}(${col.label})`;
  112. if (compare && top5.length) {
  113. const dates = [...new Set(data.topDaily.map((r) => r.date))].sort();
  114. const palette = ['#ff6b35', '#2563eb', '#1a8a4c', '#b8860b', '#8b5cf6'];
  115. const series = top5.map((shop, i) => ({
  116. name: shop.shopName || shop.shopId,
  117. type: 'line', smooth: true, symbolSize: 4,
  118. data: dates.map((d) => {
  119. const r = data.topDaily.find((x) => x.date === d && String(x.shopId) === String(shop.shopId));
  120. return r ? (Number(r[chartMetric]) || 0) : 0;
  121. }),
  122. itemStyle: { color: palette[i % palette.length] }, lineStyle: { width: 2 },
  123. }));
  124. compare.setOption({
  125. tooltip: { trigger: 'axis', valueFormatter: (v) => fmtByCol(col, v) },
  126. legend: { top: 0, type: 'scroll', icon: 'roundRect', itemWidth: 14, itemHeight: 4, textStyle: { color: '#6b6560' } },
  127. grid: { left: 64, right: 24, top: 36, bottom: 30 },
  128. xAxis: { type: 'category', boundaryGap: false, data: dates.map(ymdToDash), ...AXIS_TXT },
  129. yAxis: { type: 'value', axisLabel: { color: '#6b6560' }, splitLine: SPLIT_LINE },
  130. series,
  131. }, true);
  132. }
  133. }
  134. // ---------- 模板二:环比对照 ----------
  135. function buildMomTable(data) {
  136. const cur = data.byDate;
  137. const prev = data.byDatePrev;
  138. const curO = sumField(cur, 'validOrderCnt');
  139. const prevO = sumField(prev, 'validOrderCnt');
  140. const defs = [
  141. { label: '营业额', cur: sumField(cur, 'grossAmt'), prev: sumField(prev, 'grossAmt'), fmt: fmtMoney },
  142. { label: '收入', cur: sumField(cur, 'incomeAmt'), prev: sumField(prev, 'incomeAmt'), fmt: fmtMoney },
  143. { label: '支出', cur: sumField(cur, 'expenseAmt'), prev: sumField(prev, 'expenseAmt'), fmt: fmtMoney },
  144. { label: '有效订单', cur: curO, prev: prevO, fmt: fmtNum },
  145. { label: '曝光人数', cur: sumField(cur, 'expUv'), prev: sumField(prev, 'expUv'), fmt: fmtNum },
  146. { label: '下单人数', cur: sumField(cur, 'orderUserCnt'), prev: sumField(prev, 'orderUserCnt'), fmt: fmtNum },
  147. { label: '客单价', cur: curO > 0 ? sumField(cur, 'grossAmt') / curO : 0, prev: prevO > 0 ? sumField(prev, 'grossAmt') / prevO : 0, fmt: fmtMoney },
  148. { label: '进店转化率', cur: wAvgField(cur, 'clkExpRate', 'expUv'), prev: wAvgField(prev, 'clkExpRate', 'expUv'), fmt: fmtPct },
  149. { label: '下单转化率', cur: wAvgField(cur, 'ordClkRate', 'orderUserCnt'), prev: wAvgField(prev, 'ordClkRate', 'orderUserCnt'), fmt: fmtPct },
  150. { label: '活动订单', cur: sumField(cur, 'actOrdCnt'), prev: sumField(prev, 'actOrdCnt'), fmt: fmtNum },
  151. { label: '活动补贴', cur: sumField(cur, 'actSubsidyAmt'), prev: sumField(prev, 'actSubsidyAmt'), fmt: fmtMoney },
  152. { label: '30日复购率', cur: wAvgField(cur, 'rebuyRate30d', 'orderUserCnt'), prev: wAvgField(prev, 'rebuyRate30d', 'orderUserCnt'), fmt: fmtPct },
  153. ];
  154. const rowsHtml = defs.map((d) => `<tr>
  155. <td>${d.label}</td>
  156. <td>${d.fmt(Math.round(d.cur * 100) / 100)}</td>
  157. <td>${d.fmt(Math.round(d.prev * 100) / 100)}</td>
  158. <td>${deltaBadge(d.cur, d.prev)}</td>
  159. </tr>`).join('');
  160. return `<table class="ana-table">
  161. <thead><tr><th>指标</th><th>本期</th><th>上期</th><th>环比</th></tr></thead>
  162. <tbody>${rowsHtml}</tbody></table>`;
  163. }
  164. function renderMomTpl(grid, data) {
  165. const chartMetric = metric();
  166. const col = chartCol(chartMetric);
  167. const vacNames = [...new Set([...rangeOverlapsVacation(data.from, data.to), ...rangeOverlapsVacation(data.prevFrom, data.prevTo)])];
  168. const series = buildDailySeries(data, chartMetric);
  169. const prevSorted = data.byDatePrev.slice().sort((a, b) => (a.date < b.date ? -1 : 1));
  170. const prevVals = series.dates.map((_, i) => (prevSorted[i] ? Number(prevSorted[i][chartMetric]) || 0 : null));
  171. const diffs = series.values.map((v, i) => (v == null || prevVals[i] == null ? null : +(v - prevVals[i]).toFixed(2)));
  172. const head = makePanel(grid, '', 2);
  173. const title = document.createElement('span');
  174. title.textContent = `环比对照 · ${col.label}(本期 ${ymdToDash(data.from)}~${ymdToDash(data.to)} vs 上期 ${ymdToDash(data.prevFrom)}~${ymdToDash(data.prevTo)})`;
  175. head.head.appendChild(title);
  176. if (vacNames.length) {
  177. const chip = document.createElement('span');
  178. chip.className = 'badge badge-warn';
  179. chip.textContent = `对比区间跨${vacNames.join('、')}`;
  180. chip.title = '假期前后环比受学期节奏影响(寒/暑假营业基数骤降),涨跌请结合更长区间或去年同期判断';
  181. head.head.appendChild(chip);
  182. }
  183. dataQualityNote(head, series);
  184. const chart = panelChart(head, 'momTrend');
  185. if (chart) {
  186. const xData = series.dates.map(ymdToDash);
  187. const lines = [
  188. { name: `本期·${col.label}`, type: 'line', smooth: true, symbolSize: 5, data: hollowPoints(series), itemStyle: { color: ACCENT }, lineStyle: { width: 2.5 } },
  189. { name: '上期同期', type: 'line', smooth: true, symbol: 'none', data: prevVals, itemStyle: { color: COLOR_PREV }, lineStyle: { type: 'dashed', width: 1.6 } },
  190. ];
  191. const areas = missingMarkAreas(series);
  192. if (areas.length) lines[0].markArea = { silent: true, data: areas };
  193. chart.setOption({
  194. tooltip: { trigger: 'axis', valueFormatter: (v) => fmtByCol(col, v) },
  195. legend: { data: [...lines.map((x) => x.name), '逐日差值'], top: 0, icon: 'roundRect', itemWidth: 14, itemHeight: 4, textStyle: { color: '#6b6560' } },
  196. grid: { left: 64, right: 64, top: 36, bottom: 30 },
  197. xAxis: { type: 'category', data: xData, ...AXIS_TXT },
  198. yAxis: [
  199. { type: 'value', axisLabel: { color: '#6b6560' }, splitLine: SPLIT_LINE },
  200. { type: 'value', axisLabel: { color: '#6b6560' }, splitLine: { show: false } },
  201. ],
  202. series: [
  203. ...lines,
  204. {
  205. name: '逐日差值', type: 'bar', yAxisIndex: 1, barMaxWidth: 10,
  206. data: diffs.map((v) => (v == null ? null : { value: v, itemStyle: { color: v >= 0 ? 'rgba(26,138,76,.55)' : 'rgba(200,64,46,.55)' } })),
  207. },
  208. ],
  209. }, true);
  210. }
  211. const tbl = makePanel(grid, '指标环比一览', 1);
  212. const wrap = document.createElement('div');
  213. wrap.className = 'ana-table-scroll';
  214. wrap.innerHTML = buildMomTable(data);
  215. tbl.cell.appendChild(wrap);
  216. }
  217. // ---------- 模板三:量价归因 ----------
  218. // 营业额 = 曝光人数 × 进店转化 × 下单转化 × 人均单量 × 客单价(乘积链严格闭合)
  219. // 贡献拆解用 LMDI(对数平均迪氏分解):各因子贡献之和恰好等于总变化,无残差。
  220. function attrFactors(rows) {
  221. const E = sumField(rows, 'expUv');
  222. const enter = rows.reduce((a, r) => a + (Number(r.expUv) || 0) * (Number(r.clkExpRate) || 0), 0);
  223. const U = sumField(rows, 'orderUserCnt');
  224. const O = sumField(rows, 'validOrderCnt');
  225. const G = sumField(rows, 'grossAmt');
  226. return { E, C1: E > 0 ? enter / E : 0, C2: enter > 0 ? U / enter : 0, F: U > 0 ? O / U : 0, P: O > 0 ? G / O : 0, G };
  227. }
  228. function logMean(a, b) {
  229. if (a <= 0 || b <= 0) return (a + b) / 2;
  230. return a === b ? a : (a - b) / (Math.log(a) - Math.log(b));
  231. }
  232. function renderAttributionTpl(grid, data) {
  233. const f0 = attrFactors(data.byDatePrev);
  234. const f1 = attrFactors(data.byDate);
  235. const factors = [
  236. { label: '流量·曝光人数', f0: f0.E, f1: f1.E, fmt: fmtNum },
  237. { label: '进店转化率', f0: f0.C1, f1: f1.C1, fmt: fmtPct },
  238. { label: '下单转化率', f0: f0.C2, f1: f1.C2, fmt: fmtPct },
  239. { label: '人均单量', f0: f0.F, f1: f1.F, fmt: (v) => Number(v).toFixed(2) },
  240. { label: '客单价', f0: f0.P, f1: f1.P, fmt: fmtMoney },
  241. ];
  242. const decomposable = f0.G > 0 && f1.G > 0 && factors.every((x) => x.f0 > 0 && x.f1 > 0);
  243. if (decomposable) {
  244. const L = logMean(f1.G, f0.G);
  245. for (const x of factors) x.contrib = L * Math.log(x.f1 / x.f0);
  246. }
  247. const panel = makePanel(grid, '', 2);
  248. const title = document.createElement('span');
  249. title.textContent = `量价归因 · 营业额(本期 ${ymdToDash(data.from)}~${ymdToDash(data.to)} vs 上期)`;
  250. panel.head.appendChild(title);
  251. const chart = panelChart(panel, 'attrWf');
  252. if (chart) {
  253. if (decomposable) {
  254. const cats = ['上期营业额', ...factors.map((x) => x.label), '本期营业额'];
  255. let cum = f0.G;
  256. const bases = [0];
  257. const bars = [{ value: +f0.G.toFixed(2), itemStyle: { color: COLOR_PREV }, label: { show: true, position: 'top', fontSize: 10, color: '#6b6560', formatter: fmtCompact(f0.G) } }];
  258. for (const x of factors) {
  259. const c = x.contrib;
  260. bases.push(+Math.min(cum, cum + c).toFixed(2));
  261. bars.push({
  262. value: +c.toFixed(2),
  263. itemStyle: { color: c >= 0 ? COLOR_OK : COLOR_FAIL },
  264. // 贡献柱相对总额很细,靠标签读数(精确值见右侧因子明细表)
  265. label: { show: true, position: 'top', fontSize: 10, color: c >= 0 ? COLOR_OK : COLOR_FAIL, formatter: (c >= 0 ? '+' : '') + fmtCompact(c) },
  266. });
  267. cum += c;
  268. }
  269. bases.push(0);
  270. bars.push({ value: +f1.G.toFixed(2), itemStyle: { color: ACCENT }, label: { show: true, position: 'top', fontSize: 11, color: '#2b2a28', fontWeight: 600, formatter: fmtCompact(f1.G) } });
  271. chart.setOption({
  272. tooltip: {
  273. trigger: 'axis',
  274. formatter: (ps) => {
  275. const p = ps.find((x) => x.seriesName === '贡献');
  276. if (!p) return '';
  277. const v = Number(p.value);
  278. if (p.dataIndex === 0 || p.dataIndex === cats.length - 1) return `${cats[p.dataIndex]}:${fmtMoney(v)} 元`;
  279. return `${cats[p.dataIndex]}:贡献 ${v >= 0 ? '+' : ''}${fmtMoney(v)} 元`;
  280. },
  281. },
  282. grid: { left: 72, right: 20, top: 34, bottom: 52 },
  283. xAxis: { type: 'category', data: cats, ...AXIS_TXT, axisLabel: { ...AXIS_TXT.axisLabel, interval: 0, rotate: 18 } },
  284. yAxis: { type: 'value', axisLabel: { color: '#6b6560' }, splitLine: SPLIT_LINE },
  285. series: [
  286. { name: '底', type: 'bar', stack: 'wf', barWidth: 34, itemStyle: { color: 'transparent' }, emphasis: { itemStyle: { color: 'transparent' } }, tooltip: { show: false }, data: bases },
  287. { name: '贡献', type: 'bar', stack: 'wf', barWidth: 34, data: bars },
  288. ],
  289. }, true);
  290. } else {
  291. chart.setOption({
  292. tooltip: { trigger: 'axis', valueFormatter: (v) => fmtMoney(v) + ' 元' },
  293. grid: { left: 72, right: 20, top: 30, bottom: 30 },
  294. xAxis: { type: 'category', data: ['上期营业额', '本期营业额'], ...AXIS_TXT },
  295. yAxis: { type: 'value', axisLabel: { color: '#6b6560' }, splitLine: SPLIT_LINE },
  296. series: [{ type: 'bar', barWidth: 50, data: [
  297. { value: +f0.G.toFixed(2), itemStyle: { color: COLOR_PREV } },
  298. { value: +f1.G.toFixed(2), itemStyle: { color: ACCENT } },
  299. ] }],
  300. }, true);
  301. panelNote(panel, '上期或本期存在零值因子,无法做乘法拆解,仅展示两期总额');
  302. }
  303. }
  304. const tbl = makePanel(grid, '因子明细', 1);
  305. const wrap = document.createElement('div');
  306. wrap.className = 'ana-table-scroll';
  307. const dG = f1.G - f0.G;
  308. const rowsHtml = factors.map((x) => {
  309. const chg = x.f0 > 0 ? (x.f1 - x.f0) / x.f0 : null;
  310. const contrib = decomposable ? x.contrib : null;
  311. return `<tr>
  312. <td>${x.label}</td>
  313. <td>${x.fmt(x.f0)}</td>
  314. <td>${x.fmt(x.f1)}</td>
  315. <td>${chg == null ? '—' : `<span class="delta ${chg >= 0 ? 'up' : 'down'}">${chg >= 0 ? '↑' : '↓'} ${Math.abs(chg * 100).toFixed(1)}%</span>`}</td>
  316. <td>${contrib == null ? '—' : (contrib >= 0 ? '+' : '') + fmtMoney(contrib)}</td>
  317. <td>${contrib == null || !dG ? '—' : Math.round(Math.abs(contrib / dG) * 100) + '%'}</td>
  318. </tr>`;
  319. }).join('');
  320. wrap.innerHTML = `<table class="ana-table">
  321. <thead><tr><th>因子</th><th>上期</th><th>本期</th><th>变化</th><th>贡献(元)</th><th>占Δ</th></tr></thead>
  322. <tbody>${rowsHtml}
  323. <tr><td><b>营业额</b></td><td>${fmtMoney(f0.G)}</td><td>${fmtMoney(f1.G)}</td>
  324. <td>${deltaBadge(f1.G, f0.G)}</td>
  325. <td>${dG >= 0 ? '+' : ''}${fmtMoney(dG)}</td><td>100%</td></tr></tbody></table>`;
  326. tbl.cell.appendChild(wrap);
  327. }
  328. // ---------- 模板四:档口问责 ----------
  329. function renderAccountabilityTpl(grid, data) {
  330. const chartMetric = metric();
  331. const col = chartCol(chartMetric);
  332. const curMap = new Map(data.byShop.map((r) => [String(r.shopId), r]));
  333. const prevMap = new Map(data.byShopPrev.map((r) => [String(r.shopId), r]));
  334. const shops = [...new Set([...curMap.keys(), ...prevMap.keys()])].map((key) => {
  335. const cur = curMap.get(key) || null;
  336. const prev = prevMap.get(key) || null;
  337. const curV = cur ? Number(cur[chartMetric]) || 0 : null;
  338. const prevV = prev ? Number(prev[chartMetric]) || 0 : null;
  339. return {
  340. key,
  341. name: (cur && cur.shopName) || (prev && prev.shopName) || key,
  342. curV, prevV,
  343. delta: curV != null && prevV != null ? curV - prevV : null,
  344. pay: Number((cur || prev || {}).perActualPay) || 0,
  345. };
  346. });
  347. if (!shops.length) {
  348. panelEmpty(grid, '暂无档口明细数据(需按门店粒度同步)', 3);
  349. return;
  350. }
  351. const hasPrev = shops.some((s) => (s.prevV || 0) > 0);
  352. const newShops = shops.filter((s) => s.prevV == null && s.curV != null).length;
  353. // 涨跌榜(按 Δ 排序,而非绝对值——直接回答"谁在涨谁在跌")
  354. const rankPanel = makePanel(grid, hasPrev ? `档口涨跌榜 · ${col.label} 对上期` : `档口排行 · ${col.label}`, 1);
  355. if (!hasPrev) panelNote(rankPanel, '上期无有效数据,暂展示本期排行');
  356. else if (newShops) panelNote(rankPanel, `${newShops} 家档口上期无数据(新增),未纳入涨跌对比`);
  357. const rank = panelChart(rankPanel, 'accDelta');
  358. if (rank) {
  359. if (hasPrev) {
  360. let list = shops.filter((s) => s.delta != null).sort((a, b) => b.delta - a.delta);
  361. if (list.length > 14) list = [...list.slice(0, 7), ...list.slice(-7)];
  362. const ordered = list.slice().reverse(); // echarts category 第 0 项在底部
  363. rank.setOption({
  364. tooltip: { trigger: 'axis', valueFormatter: (v) => fmtByCol(col, v) },
  365. grid: { left: 10, right: 34, top: 10, bottom: 24, containLabel: true },
  366. xAxis: { type: 'value', axisLabel: { color: '#6b6560' }, splitLine: SPLIT_LINE },
  367. yAxis: {
  368. type: 'category', data: ordered.map((s) => s.name),
  369. axisLabel: { color: '#2b2a28', width: 96, overflow: 'truncate' },
  370. axisLine: { lineStyle: { color: '#e8e4df' } },
  371. },
  372. series: [{
  373. type: 'bar', barMaxWidth: 14,
  374. data: ordered.map((s) => ({
  375. value: +s.delta.toFixed(2),
  376. itemStyle: { color: s.delta >= 0 ? COLOR_OK : COLOR_FAIL, borderRadius: s.delta >= 0 ? [0, 6, 6, 0] : [6, 0, 0, 6] },
  377. })),
  378. }],
  379. }, true);
  380. } else {
  381. const top = shops.filter((s) => s.curV != null).sort((a, b) => b.curV - a.curV).slice(0, 10).reverse();
  382. rank.setOption({
  383. tooltip: { trigger: 'axis', valueFormatter: (v) => fmtByCol(col, v) },
  384. grid: { left: 10, right: 34, top: 10, bottom: 24, containLabel: true },
  385. xAxis: { type: 'value', axisLabel: { color: '#6b6560' }, splitLine: SPLIT_LINE },
  386. yAxis: {
  387. type: 'category', data: top.map((s) => s.name),
  388. axisLabel: { color: '#2b2a28', width: 96, overflow: 'truncate' },
  389. axisLine: { lineStyle: { color: '#e8e4df' } },
  390. },
  391. series: [{ type: 'bar', barMaxWidth: 14, data: top.map((s) => +s.curV.toFixed(2)), itemStyle: { color: ACCENT, borderRadius: [0, 6, 6, 0] } }],
  392. }, true);
  393. }
  394. }
  395. // 帕累托:档口贡献 + 累计占比(80/20 线)
  396. const parPanel = makePanel(grid, `档口贡献帕累托 · ${col.label}`, 2);
  397. const par = panelChart(parPanel, 'accPareto');
  398. if (par) {
  399. const sorted = shops.filter((s) => (s.curV || 0) > 0).sort((a, b) => b.curV - a.curV);
  400. const total = sorted.reduce((a, s) => a + s.curV, 0);
  401. let cum = 0;
  402. const cumPct = sorted.map((s) => { cum += s.curV; return total > 0 ? +(cum / total * 100).toFixed(1) : 0; });
  403. par.setOption({
  404. tooltip: {
  405. trigger: 'axis',
  406. formatter: (ps) => {
  407. const idx = ps[0].dataIndex;
  408. return `<b>${sorted[idx].name}</b><br>${col.label}:${fmtByCol(col, sorted[idx].curV)}<br>累计占比:${cumPct[idx]}%`;
  409. },
  410. },
  411. grid: { left: 10, right: 40, top: 30, bottom: 64, containLabel: true },
  412. xAxis: { type: 'category', data: sorted.map((s) => s.name), ...AXIS_TXT, axisLabel: { ...AXIS_TXT.axisLabel, interval: 0, rotate: 26, width: 100, overflow: 'truncate' } },
  413. yAxis: [
  414. { type: 'value', axisLabel: { color: '#6b6560' }, splitLine: SPLIT_LINE },
  415. { type: 'value', max: 100, axisLabel: { color: '#6b6560', formatter: '{value}%' }, splitLine: { show: false } },
  416. ],
  417. series: [
  418. { name: col.label, type: 'bar', barMaxWidth: 18, data: sorted.map((s) => +s.curV.toFixed(2)), itemStyle: { color: ACCENT, borderRadius: [6, 6, 0, 0] } },
  419. {
  420. name: '累计占比', type: 'line', yAxisIndex: 1, data: cumPct, symbolSize: 4,
  421. itemStyle: { color: COLOR_INFO }, lineStyle: { width: 1.8 },
  422. markLine: { symbol: 'none', data: [{ yAxis: 80 }], lineStyle: { type: 'dashed', color: COLOR_PREV }, label: { formatter: '80%', color: '#6b6560' } },
  423. },
  424. ],
  425. }, true);
  426. }
  427. // 四象限:体量 × 环比增速,气泡 = 客单价
  428. const quadPanel = makePanel(grid, '档口四象限(体量 × 环比增速)', 2);
  429. const pts = shops.filter((s) => s.prevV != null && s.prevV > 0 && s.curV != null);
  430. const excluded = shops.filter((s) => !((s.prevV || 0) > 0) && (s.curV || 0) > 0).length;
  431. if (excluded) panelNote(quadPanel, `${excluded} 家档口上期无有效数据,未纳入四象限`);
  432. const quad = panelChart(quadPanel, 'accQuad');
  433. if (quad && pts.length) {
  434. const ratio = (s) => (s.curV - s.prevV) / s.prevV;
  435. const CLAMP = 3; // 增速轴截断 ±300%,避免上期近零的档口把整张图压扁
  436. if (pts.some((s) => Math.abs(ratio(s)) > CLAMP)) panelNote(quadPanel, '增速轴截断于 ±300%,极值请悬停查看');
  437. const curVals = pts.map((s) => s.curV).sort((a, b) => a - b);
  438. const median = curVals.length % 2
  439. ? curVals[(curVals.length - 1) / 2]
  440. : (curVals[curVals.length / 2 - 1] + curVals[curVals.length / 2]) / 2;
  441. const avgPay = pts.reduce((a, s) => a + s.pay, 0) / pts.length;
  442. quad.setOption({
  443. tooltip: {
  444. formatter: (p) => {
  445. const s = p.data.meta;
  446. return `<b>${s.name}</b><br>本期 ${fmtByCol(col, s.curV)}<br>上期 ${fmtByCol(col, s.prevV)}<br>环比 ${(ratio(s) * 100).toFixed(1)}%${s.pay ? `<br>客单价 ${fmtMoney(s.pay)}` : ''}`;
  447. },
  448. },
  449. grid: { left: 64, right: 30, top: 30, bottom: 46 },
  450. xAxis: {
  451. type: 'value', name: `本期${col.label}`, nameLocation: 'middle', nameGap: 28, nameTextStyle: { color: '#6b6560' },
  452. axisLabel: { color: '#6b6560' }, splitLine: SPLIT_LINE, axisLine: { show: true, lineStyle: { color: '#e8e4df' } },
  453. },
  454. yAxis: { type: 'value', name: '环比', nameTextStyle: { color: '#6b6560' }, axisLabel: { color: '#6b6560', formatter: (v) => (v * 100).toFixed(0) + '%' }, splitLine: SPLIT_LINE },
  455. series: [{
  456. type: 'scatter',
  457. data: pts.map((s) => ({
  458. value: [s.curV, Math.max(-CLAMP, Math.min(CLAMP, ratio(s)))],
  459. meta: s,
  460. symbolSize: Math.max(8, Math.min(42, 8 + Math.sqrt(avgPay > 0 ? s.pay / avgPay : 1) * 10)),
  461. itemStyle: { color: 'rgba(255,107,53,.75)', borderColor: '#fff', borderWidth: 1 },
  462. })),
  463. markLine: {
  464. symbol: 'none', lineStyle: { color: COLOR_PREV, type: 'dashed' }, label: { color: '#6b6560' },
  465. data: [
  466. { xAxis: +median.toFixed(2), label: { formatter: '体量中位' } },
  467. { yAxis: 0, label: { formatter: '持平' } },
  468. ],
  469. },
  470. }],
  471. graphic: [
  472. { type: 'text', right: 40, top: 32, style: { text: '明星 · 量大在涨', fill: '#a5a09a', fontSize: 11 } },
  473. { type: 'text', left: 70, top: 32, style: { text: '潜力 · 量小在涨', fill: '#a5a09a', fontSize: 11 } },
  474. { type: 'text', right: 40, bottom: 50, style: { text: '现金牛 · 量大微跌', fill: '#a5a09a', fontSize: 11 } },
  475. { type: 'text', left: 70, bottom: 50, style: { text: '观察 · 量小在跌', fill: '#a5a09a', fontSize: 11 } },
  476. ],
  477. }, true);
  478. } else if (quad) {
  479. panelEmptyIn(quadPanel, '上期无有效数据,无法计算环比增速');
  480. }
  481. const helpPanel = makePanel(grid, '象限怎么用', 1);
  482. const helpBox = document.createElement('ul');
  483. helpBox.className = 'ana-help';
  484. helpBox.innerHTML = `
  485. <li><b>明星</b>(右上):体量 ≥ 中位且在涨 —— 保供给保体验,优先投资源</li>
  486. <li><b>潜力</b>(左上):量小在涨 —— 加曝光、试推广、扩菜品</li>
  487. <li><b>现金牛</b>(右下):量大但跌 —— 查菜品结构与评价维护</li>
  488. <li><b>观察</b>(左下):量小且跌 —— 评估调整或汰换</li>
  489. <li>气泡大小=客单价;虚线=体量中位数 / 持平线</li>`;
  490. helpPanel.cell.appendChild(helpBox);
  491. }
  492. // ---------- 模板五:流量卡点 ----------
  493. function renderFunnelTpl(grid, data) {
  494. const clkSeries = buildDailySeries(data, 'clkExpRate');
  495. const ordSeries = buildDailySeries(data, 'ordClkRate');
  496. const xData = clkSeries.dates.map(ymdToDash);
  497. const ratePanel = makePanel(grid, '转化率日趋势(虚线=健康基准,浅红区=偏低区)', 2);
  498. dataQualityNote(ratePanel, clkSeries);
  499. const rateChart = panelChart(ratePanel, 'funRates');
  500. if (rateChart) {
  501. const areas = [
  502. [{ xAxis: xData[0], yAxis: 0, itemStyle: { color: 'rgba(200,64,46,.04)' } }, { xAxis: xData[xData.length - 1], yAxis: RATE_THRESHOLDS.clkExpRate.bad }],
  503. ...missingMarkAreas(clkSeries),
  504. ];
  505. rateChart.setOption({
  506. tooltip: { trigger: 'axis', valueFormatter: (v) => fmtPct(v) },
  507. legend: { top: 0, icon: 'roundRect', itemWidth: 14, itemHeight: 4, textStyle: { color: '#6b6560' } },
  508. grid: { left: 56, right: 20, top: 36, bottom: 30 },
  509. xAxis: { type: 'category', boundaryGap: false, data: xData, ...AXIS_TXT },
  510. yAxis: { type: 'value', axisLabel: { color: '#6b6560', formatter: (v) => (v * 100).toFixed(1) + '%' }, splitLine: SPLIT_LINE },
  511. series: [
  512. {
  513. name: '进店转化率', type: 'line', smooth: true, symbolSize: 4, connectNulls: false,
  514. data: hollowPoints(clkSeries), itemStyle: { color: ACCENT }, lineStyle: { width: 2 },
  515. markLine: { symbol: 'none', lineStyle: { type: 'dashed', color: ACCENT, opacity: 0.55 }, label: { formatter: '健康线 9%', color: '#6b6560', position: 'insideEndTop' }, data: [{ yAxis: 0.09 }] },
  516. markArea: { silent: true, data: areas },
  517. },
  518. {
  519. name: '下单转化率', type: 'line', smooth: true, symbolSize: 4, connectNulls: false,
  520. data: hollowPoints(ordSeries), itemStyle: { color: COLOR_INFO }, lineStyle: { width: 2 },
  521. markLine: { symbol: 'none', lineStyle: { type: 'dashed', color: COLOR_INFO, opacity: 0.55 }, label: { formatter: '健康线 2.5%', color: '#6b6560', position: 'insideEndBottom' }, data: [{ yAxis: 0.025 }] },
  522. },
  523. ],
  524. }, true);
  525. }
  526. // 卡点诊断卡:率值对照健康基准,量值对照上期
  527. const diagPanel = makePanel(grid, '卡点诊断', 1);
  528. const box = document.createElement('div');
  529. box.className = 'diag-steps';
  530. diagPanel.cell.appendChild(box);
  531. const curClk = wAvgField(data.byDate, 'clkExpRate', 'expUv');
  532. const prevClk = wAvgField(data.byDatePrev, 'clkExpRate', 'expUv');
  533. const curOrd = wAvgField(data.byDate, 'ordClkRate', 'orderUserCnt');
  534. const prevOrd = wAvgField(data.byDatePrev, 'ordClkRate', 'orderUserCnt');
  535. const curExp = sumField(data.byDate, 'expUv');
  536. const prevExp = sumField(data.byDatePrev, 'expUv');
  537. function rateStatus(key, v) {
  538. const t = RATE_THRESHOLDS[key];
  539. if (!(v > 0)) return { cls: 'badge badge-info', text: '无数据' };
  540. if (v >= t.good) return { cls: 'badge badge-ok', text: '达标' };
  541. if (v < t.bad) return { cls: 'badge badge-fail', text: '明显偏低' };
  542. return { cls: 'badge badge-warn', text: '偏低' };
  543. }
  544. const expDelta = prevExp > 0 ? (curExp - prevExp) / prevExp : null;
  545. const expBadge = expDelta == null ? '<span class="badge badge-info">上期无数据</span>'
  546. : Math.abs(expDelta) < 0.02 ? '<span class="badge badge-ok">持平</span>'
  547. : `<span class="badge ${expDelta > 0 ? 'badge-ok' : 'badge-fail'}">${expDelta > 0 ? '↑' : '↓'} ${Math.abs(expDelta * 100).toFixed(1)}%</span>`;
  548. box.innerHTML = `
  549. <div class="diag-step">
  550. <div class="diag-step-head"><span class="diag-step-name">① 曝光人数(量)</span>${expBadge}</div>
  551. <div class="diag-step-val">${fmtNum(curExp)}</div>
  552. <div class="diag-step-meta">上期 ${fmtNum(prevExp)} —— 瓶颈在平台流量侧时看这里</div>
  553. </div>
  554. <div class="diag-step">
  555. <div class="diag-step-head"><span class="diag-step-name">② 进店转化率</span><span class="${rateStatus('clkExpRate', curClk).cls}">${rateStatus('clkExpRate', curClk).text}</span></div>
  556. <div class="diag-step-val">${fmtPct(curClk)}</div>
  557. <div class="diag-step-meta">健康线 ≥9% · 上期 ${fmtPct(prevClk)}</div>
  558. </div>
  559. <div class="diag-step">
  560. <div class="diag-step-head"><span class="diag-step-name">③ 下单转化率</span><span class="${rateStatus('ordClkRate', curOrd).cls}">${rateStatus('ordClkRate', curOrd).text}</span></div>
  561. <div class="diag-step-val">${fmtPct(curOrd)}</div>
  562. <div class="diag-step-meta">健康线 ≥2.5% · 上期 ${fmtPct(prevOrd)}</div>
  563. </div>`;
  564. const gaps = [
  565. { key: 'clkExpRate', label: '进店转化率', v: curClk, good: RATE_THRESHOLDS.clkExpRate.good },
  566. { key: 'ordClkRate', label: '下单转化率', v: curOrd, good: RATE_THRESHOLDS.ordClkRate.good },
  567. ].filter((g) => g.v > 0 && g.v < g.good);
  568. let conclusion;
  569. if (gaps.length) {
  570. gaps.sort((a, b) => a.v / a.good - b.v / b.good);
  571. const g = gaps[0];
  572. const advice = g.key === 'clkExpRate' ? '优先优化门头图/招牌/活动利益点' : '优先优化菜单结构、价格带与评价维护';
  573. conclusion = `最大卡点:${g.label}(${fmtPct(g.v)},距健康线还差 ${fmtPct(g.good - g.v)})—— ${advice}`;
  574. } else if (prevExp > 0 && curExp < prevExp * 0.9) {
  575. conclusion = `两步转化均达标,但曝光较上期降 ${Math.round((1 - curExp / prevExp) * 100)}% —— 瓶颈更可能在平台流量侧,关注排名/推广/评分`;
  576. } else {
  577. conclusion = '两步转化均达标、曝光未见明显下滑,暂无明显卡点';
  578. }
  579. const concl = document.createElement('div');
  580. concl.className = 'diag-conclusion';
  581. concl.textContent = conclusion;
  582. diagPanel.cell.appendChild(concl);
  583. // 三个量级日趋势:曝光 / 下单人数 / 客单价(含上期对照)
  584. const prevSorted = data.byDatePrev.slice().sort((a, b) => (a.date < b.date ? -1 : 1));
  585. const minis = [
  586. { field: 'expUv', label: '曝光人数', fmt: fmtNum },
  587. { field: 'orderUserCnt', label: '下单人数', fmt: fmtNum },
  588. { field: 'perActualPay', label: '客单价', fmt: fmtMoney },
  589. ];
  590. for (const m of minis) {
  591. const p = makePanel(grid, `${m.label}日趋势`, 1);
  592. const c = panelChart(p, 'funMini_' + m.field);
  593. if (!c) continue;
  594. const s = buildDailySeries(data, m.field);
  595. c.setOption({
  596. tooltip: { trigger: 'axis', valueFormatter: (v) => m.fmt(v) },
  597. legend: { top: 0, icon: 'roundRect', itemWidth: 14, itemHeight: 4, textStyle: { color: '#6b6560' } },
  598. grid: { left: 56, right: 16, top: 30, bottom: 26 },
  599. xAxis: { type: 'category', boundaryGap: false, data: s.dates.map(ymdToDash), ...AXIS_TXT },
  600. yAxis: { type: 'value', axisLabel: { color: '#6b6560' }, splitLine: SPLIT_LINE },
  601. series: [
  602. { name: '本期', type: 'line', smooth: true, symbolSize: 3, connectNulls: false, data: hollowPoints(s), itemStyle: { color: ACCENT }, lineStyle: { width: 2 } },
  603. { name: '上期', type: 'line', smooth: true, symbol: 'none', data: s.dates.map((_, i) => (prevSorted[i] ? Number(prevSorted[i][m.field]) || 0 : null)), itemStyle: { color: COLOR_PREV }, lineStyle: { type: 'dashed', width: 1.4 } },
  604. ],
  605. }, true);
  606. }
  607. }
  608. // ---------- 模板六:周内节律 ----------
  609. function renderRhythmTpl(grid, data) {
  610. const chartMetric = metric();
  611. const col = chartCol(chartMetric);
  612. const byDate = new Map(data.byDate.map((r) => [r.date, Number(r[chartMetric]) || 0]));
  613. // 周次行:区间内每个周一一行(首尾不足一周的照常显示,缺数留白)
  614. const weekStarts = [];
  615. const dowOf = (ymd) => (ymdDate(ymd).getDay() + 6) % 7; // 0=周一
  616. let ws = shiftYmd(data.from, -dowOf(data.from));
  617. for (; ws <= data.to; ws = shiftYmd(ws, 7)) weekStarts.push(ws);
  618. const weeks = weekStarts.slice().reverse(); // 最新一周显示在最上
  619. const rowOf = new Map(weekStarts.map((w, i) => [w, weeks.length - 1 - i]));
  620. const cells = [];
  621. for (const w of weekStarts) {
  622. for (let di = 0; di < 7; di++) {
  623. const d = shiftYmd(w, di);
  624. if (d < data.from || d > data.to) continue;
  625. if (!byDate.has(d)) continue;
  626. cells.push([di, rowOf.get(w), byDate.get(d)]);
  627. }
  628. }
  629. const max = Math.max(0, ...cells.map((c) => c[2]));
  630. const hmPanel = makePanel(grid, `周内节律热力图 · ${col.label}(色深=数值高,留白=缺数)`, 2);
  631. const hm = panelChart(hmPanel, 'rhythmHm');
  632. if (hm) {
  633. hm.setOption({
  634. tooltip: {
  635. formatter: (p) => {
  636. const d = shiftYmd(weeks[p.value[1]], p.value[0]);
  637. return `${ymdToDash(d)} ${WEEKDAY_CN[p.value[0]]}<br>${col.label}:${fmtByCol(col, p.value[2])}`;
  638. },
  639. },
  640. grid: { left: 62, right: 20, top: 12, bottom: 64 },
  641. xAxis: { type: 'category', data: WEEKDAY_CN, splitArea: { show: true }, ...AXIS_TXT },
  642. yAxis: { type: 'category', data: weeks.map(ymdToDash), ...AXIS_TXT },
  643. visualMap: {
  644. min: 0, max: max > 0 ? max : 1, calculable: false, orient: 'horizontal', left: 'center', bottom: 0,
  645. text: ['高', '低'], textStyle: { color: '#6b6560' },
  646. inRange: { color: ['#f0ede8', '#ffd2b8', '#ff8a5c', '#e0501c'] },
  647. },
  648. series: [{
  649. type: 'heatmap',
  650. data: cells,
  651. itemStyle: { borderColor: '#fff', borderWidth: 1, borderRadius: 2 },
  652. }],
  653. }, true);
  654. if (max <= 0) panelNote(hmPanel, '区间内无有效数据');
  655. }
  656. const dayPanel = makePanel(grid, '周内均值', 1);
  657. const dayChart = panelChart(dayPanel, 'rhythmDay');
  658. if (dayChart) {
  659. const buckets = Array.from({ length: 7 }, () => []);
  660. for (const [d, v] of byDate) buckets[dowOf(d)].push(v);
  661. const avg = buckets.map((list) => (list.length ? +(list.reduce((a, b) => a + b, 0) / list.length).toFixed(2) : 0));
  662. dayChart.setOption({
  663. tooltip: { trigger: 'axis', valueFormatter: (v) => fmtByCol(col, v) },
  664. grid: { left: 56, right: 16, top: 20, bottom: 30 },
  665. xAxis: { type: 'category', data: WEEKDAY_CN, ...AXIS_TXT },
  666. yAxis: { type: 'value', axisLabel: { color: '#6b6560' }, splitLine: SPLIT_LINE },
  667. series: [{ type: 'bar', barMaxWidth: 22, data: avg, itemStyle: { color: ACCENT, borderRadius: [6, 6, 0, 0] } }],
  668. }, true);
  669. }
  670. }
  671. // ---------- 模板七:收入结构 ----------
  672. function renderIncomeTpl(grid, data) {
  673. const incomeS = buildDailySeries(data, 'incomeAmt');
  674. const expenseS = buildDailySeries(data, 'expenseAmt');
  675. const subsidyS = buildDailySeries(data, 'actSubsidyAmt');
  676. const ordersS = buildDailySeries(data, 'validOrderCnt');
  677. const actOrdS = buildDailySeries(data, 'actOrdCnt');
  678. const prevSorted = data.byDatePrev.slice().sort((a, b) => (a.date < b.date ? -1 : 1));
  679. const prevGross = incomeS.dates.map((_, i) => (prevSorted[i] ? +(Number(prevSorted[i].incomeAmt) + Number(prevSorted[i].expenseAmt)).toFixed(2) : null));
  680. const xData = incomeS.dates.map(ymdToDash);
  681. const rate = (num, den) => (den != null && den > 0 ? num / den : null);
  682. // 流水构成:收入 + 支出 = 营业额(堆叠面积)
  683. const structPanel = makePanel(grid, '流水构成(收入 + 支出 = 营业额)', 2);
  684. dataQualityNote(structPanel, incomeS);
  685. const structChart = panelChart(structPanel, 'incStruct');
  686. if (structChart) {
  687. const areas = missingMarkAreas(incomeS);
  688. const series = [
  689. { name: '收入', type: 'line', stack: 'gross', smooth: true, symbol: 'none', areaStyle: { opacity: 0.85 }, data: incomeS.values, itemStyle: { color: ACCENT }, lineStyle: { width: 1.2 } },
  690. { name: '支出(平台扣费等)', type: 'line', stack: 'gross', smooth: true, symbol: 'none', areaStyle: { opacity: 0.85 }, data: expenseS.values, itemStyle: { color: '#b8860b' }, lineStyle: { width: 1.2 } },
  691. { name: '上期营业额', type: 'line', smooth: true, symbol: 'none', data: prevGross, itemStyle: { color: COLOR_PREV }, lineStyle: { type: 'dashed', width: 1.6 } },
  692. ];
  693. if (areas.length) series[0].markArea = { silent: true, data: areas };
  694. structChart.setOption({
  695. tooltip: { trigger: 'axis' },
  696. legend: { top: 0, icon: 'roundRect', itemWidth: 14, itemHeight: 4, textStyle: { color: '#6b6560' } },
  697. grid: { left: 64, right: 24, top: 36, bottom: 30 },
  698. xAxis: { type: 'category', boundaryGap: false, data: xData, ...AXIS_TXT },
  699. yAxis: { type: 'value', axisLabel: { color: '#6b6560', formatter: (v) => fmtCompact(v) }, splitLine: SPLIT_LINE },
  700. series,
  701. }, true);
  702. }
  703. // 收入率(左轴)与补贴率(右轴)
  704. const ratePanel = makePanel(grid, '收入率 与 补贴率', 1);
  705. const rateChart = panelChart(ratePanel, 'incRate');
  706. if (rateChart) {
  707. const incomeRate = incomeS.values.map((v, i) => (v == null ? null : rate(v, v + (expenseS.values[i] || 0))));
  708. const subsidyRate = subsidyS.values.map((v, i) => (v == null ? null : rate(v, incomeS.values[i])));
  709. rateChart.setOption({
  710. tooltip: { trigger: 'axis', valueFormatter: (v) => (v == null ? '—' : (v * 100).toFixed(1) + '%') },
  711. legend: { top: 0, icon: 'roundRect', itemWidth: 14, itemHeight: 4, textStyle: { color: '#6b6560' } },
  712. grid: { left: 48, right: 44, top: 36, bottom: 30 },
  713. xAxis: { type: 'category', boundaryGap: false, data: xData, ...AXIS_TXT },
  714. yAxis: [
  715. { type: 'value', axisLabel: { color: '#6b6560', formatter: (v) => (v * 100).toFixed(0) + '%' }, splitLine: SPLIT_LINE },
  716. { type: 'value', axisLabel: { color: '#6b6560', formatter: (v) => (v * 100).toFixed(1) + '%' }, splitLine: { show: false } },
  717. ],
  718. series: [
  719. { name: '收入率(收入/营业额)', type: 'line', smooth: true, symbolSize: 3, connectNulls: false, data: incomeRate, itemStyle: { color: COLOR_OK }, lineStyle: { width: 2 } },
  720. { name: '补贴率(补贴/收入)', type: 'line', yAxisIndex: 1, smooth: true, symbolSize: 3, connectNulls: false, data: subsidyRate, itemStyle: { color: '#b8860b' }, lineStyle: { width: 2 } },
  721. ],
  722. }, true);
  723. }
  724. // 活动强度:补贴额(柱,右轴)× 活动订单占比(线,左轴)
  725. const actPanel = makePanel(grid, '活动强度:补贴额 与 活动订单占比', 2);
  726. const actChart = panelChart(actPanel, 'incAct');
  727. if (actChart) {
  728. const actShare = actOrdS.values.map((v, i) => (v == null ? null : rate(v, ordersS.values[i])));
  729. actChart.setOption({
  730. tooltip: { trigger: 'axis' },
  731. legend: { top: 0, icon: 'roundRect', itemWidth: 14, itemHeight: 4, textStyle: { color: '#6b6560' } },
  732. grid: { left: 48, right: 56, top: 36, bottom: 30 },
  733. xAxis: { type: 'category', data: xData, ...AXIS_TXT },
  734. yAxis: [
  735. { type: 'value', axisLabel: { color: '#6b6560', formatter: (v) => (v * 100).toFixed(0) + '%' }, splitLine: SPLIT_LINE },
  736. { type: 'value', axisLabel: { color: '#6b6560', formatter: (v) => fmtCompact(v) }, splitLine: { show: false } },
  737. ],
  738. series: [
  739. { name: '活动订单占比', type: 'line', smooth: true, symbolSize: 3, connectNulls: false, data: actShare, itemStyle: { color: COLOR_INFO }, lineStyle: { width: 2 } },
  740. { name: '活动补贴(元)', type: 'bar', yAxisIndex: 1, barMaxWidth: 10, data: subsidyS.values, itemStyle: { color: 'rgba(184,134,11,.45)' } },
  741. ],
  742. }, true);
  743. }
  744. // 结构一览表
  745. const tbl = makePanel(grid, '结构一览', 1);
  746. const wrap = document.createElement('div');
  747. wrap.className = 'ana-table-scroll';
  748. const cur = data.byDate;
  749. const prev = data.byDatePrev;
  750. const pair = (label, c, p, fmt, isRate) => ({ label, c, p, fmt, isRate });
  751. const defs = [
  752. pair('营业额', sumField(cur, 'grossAmt'), sumField(prev, 'grossAmt'), fmtMoney),
  753. pair('收入', sumField(cur, 'incomeAmt'), sumField(prev, 'incomeAmt'), fmtMoney),
  754. pair('支出', sumField(cur, 'expenseAmt'), sumField(prev, 'expenseAmt'), fmtMoney),
  755. pair('收入率', rate(sumField(cur, 'incomeAmt'), sumField(cur, 'grossAmt')), rate(sumField(prev, 'incomeAmt'), sumField(prev, 'grossAmt')), fmtPct),
  756. pair('活动补贴', sumField(cur, 'actSubsidyAmt'), sumField(prev, 'actSubsidyAmt'), fmtMoney),
  757. pair('补贴率', rate(sumField(cur, 'actSubsidyAmt'), sumField(cur, 'incomeAmt')), rate(sumField(prev, 'actSubsidyAmt'), sumField(prev, 'incomeAmt')), fmtPct),
  758. pair('活动订单占比', rate(sumField(cur, 'actOrdCnt'), sumField(cur, 'validOrderCnt')), rate(sumField(prev, 'actOrdCnt'), sumField(prev, 'validOrderCnt')), fmtPct),
  759. ];
  760. wrap.innerHTML = `<table class="ana-table">
  761. <thead><tr><th>指标</th><th>本期</th><th>上期</th><th>环比</th></tr></thead>
  762. <tbody>${defs.map((d) => `<tr>
  763. <td>${d.label}</td>
  764. <td>${d.c == null ? '—' : d.fmt(d.c)}</td>
  765. <td>${d.p == null ? '—' : d.fmt(d.p)}</td>
  766. <td>${d.c == null || d.p == null ? '—' : deltaBadge(d.c, d.p)}</td>
  767. </tr>`).join('')}</tbody></table>`;
  768. tbl.cell.appendChild(wrap);
  769. }
  770. // ---------- 模板八:跨校对比 ----------
  771. // 读湖中全部账号(门店筛选与主体选择不作用于本模板),补上"集团层"视角。
  772. async function renderCrossTpl(grid, data) {
  773. const chartMetric = metric();
  774. const col = chartCol(chartMetric);
  775. const cross = await crossData(data);
  776. const ents = cross.entities;
  777. if (!ents.length) {
  778. panelEmpty(grid, '湖中暂无任何账号数据 —— 先在「数据采集」页完成同步', 3);
  779. return;
  780. }
  781. const palette = ['#ff6b35', '#2563eb', '#1a8a4c', '#b8860b', '#8b5cf6', '#0d9488', '#c2410c', '#7c3aed'];
  782. const stats = ents.map((e, idx) => {
  783. const cur = sumField(e.byDate, chartMetric);
  784. const prev = sumField(e.byDatePrev, chartMetric);
  785. return {
  786. ...e, color: palette[idx % palette.length], cur, prev,
  787. days: new Set(e.byDate.map((r) => r.date)).size,
  788. orders: sumField(e.byDate, 'validOrderCnt'),
  789. gross: sumField(e.byDate, 'grossAmt'),
  790. };
  791. });
  792. if (ents.length === 1) panelNote(makePanel(grid, '', 3), '湖中目前只有 1 个有数据的账号 —— 多账号同步后对比自动生效');
  793. // 各校日趋势
  794. const dates = [...new Set(ents.flatMap((e) => e.byDate.map((r) => r.date)))].sort();
  795. const trendPanel = makePanel(grid, `各账号日趋势 · ${col.label}(${ymdToDash(data.from)} ~ ${ymdToDash(data.to)})`, 2);
  796. const trendChart = panelChart(trendPanel, 'crossTrend');
  797. if (trendChart) {
  798. trendChart.setOption({
  799. tooltip: { trigger: 'axis', valueFormatter: (v) => fmtByCol(col, v) },
  800. legend: { top: 0, type: 'scroll', icon: 'roundRect', itemWidth: 14, itemHeight: 4, textStyle: { color: '#6b6560' } },
  801. grid: { left: 64, right: 24, top: 36, bottom: 30 },
  802. xAxis: { type: 'category', boundaryGap: false, data: dates.map(ymdToDash), ...AXIS_TXT },
  803. yAxis: { type: 'value', axisLabel: { color: '#6b6560', formatter: (v) => fmtCompact(v) }, splitLine: SPLIT_LINE },
  804. series: stats.map((e) => ({
  805. name: e.name, type: 'line', smooth: true, symbolSize: 3, connectNulls: false,
  806. data: dates.map((d) => { const r = e.byDate.find((x) => x.date === d); return r ? Number(r[chartMetric]) || 0 : null; }),
  807. itemStyle: { color: e.color }, lineStyle: { width: 2 },
  808. })),
  809. }, true);
  810. }
  811. // 本期合计
  812. const barPanel = makePanel(grid, `本期合计 · ${col.label}`, 1);
  813. const barChart = panelChart(barPanel, 'crossBar');
  814. if (barChart) {
  815. const sorted = stats.slice().sort((a, b) => b.cur - a.cur).reverse();
  816. barChart.setOption({
  817. tooltip: { trigger: 'axis', valueFormatter: (v) => fmtByCol(col, v) },
  818. grid: { left: 10, right: 40, top: 10, bottom: 24, containLabel: true },
  819. xAxis: { type: 'value', axisLabel: { color: '#6b6560', formatter: (v) => fmtCompact(v) }, splitLine: SPLIT_LINE },
  820. yAxis: {
  821. type: 'category', data: sorted.map((e) => e.name),
  822. axisLabel: { color: '#2b2a28', width: 96, overflow: 'truncate' },
  823. axisLine: { lineStyle: { color: '#e8e4df' } },
  824. },
  825. series: [{
  826. type: 'bar', barMaxWidth: 16,
  827. data: sorted.map((e) => ({ value: +e.cur.toFixed(2), itemStyle: { color: e.color, borderRadius: [0, 6, 6, 0] } })),
  828. label: { show: true, position: 'right', fontSize: 10, color: '#6b6560', formatter: (p) => fmtCompact(p.value) },
  829. }],
  830. }, true);
  831. }
  832. // 对比明细表
  833. const tbl = makePanel(grid, '账号对比一览', 2);
  834. const wrap = document.createElement('div');
  835. wrap.className = 'ana-table-scroll';
  836. wrap.innerHTML = `<table class="ana-table">
  837. <thead><tr><th>账号</th><th>本期</th><th>上期</th><th>环比</th><th>日均</th><th>有效订单</th><th>客单价</th><th>档口数</th></tr></thead>
  838. <tbody>${stats.map((e) => `<tr>
  839. <td>${e.name}</td>
  840. <td>${fmtByCol(col, e.cur)}</td>
  841. <td>${fmtByCol(col, e.prev)}</td>
  842. <td>${deltaBadge(e.cur, e.prev)}</td>
  843. <td>${e.days > 0 ? fmtByCol(col, e.cur / e.days) : '—'}</td>
  844. <td>${fmtNum(e.orders)}</td>
  845. <td>${e.orders > 0 ? fmtMoney(e.gross / e.orders) : '—'}</td>
  846. <td>${fmtNum(e.shopCount)}</td>
  847. </tr>`).join('')}</tbody></table>`;
  848. tbl.cell.appendChild(wrap);
  849. // 占比
  850. const piePanel = makePanel(grid, `本期占比 · ${col.label}`, 1);
  851. const pieChart = panelChart(piePanel, 'crossPie');
  852. if (pieChart) {
  853. pieChart.setOption({
  854. tooltip: { trigger: 'item', formatter: (p) => `${p.name}:${fmtByCol(col, p.value)}(${p.percent}%)` },
  855. series: [{
  856. type: 'pie', radius: ['42%', '70%'], center: ['50%', '52%'],
  857. itemStyle: { borderColor: '#fff', borderWidth: 2, borderRadius: 4 },
  858. label: { color: '#6b6560', formatter: '{b}\n{d}%' },
  859. data: stats.map((e) => ({ name: e.name, value: +e.cur.toFixed(2), itemStyle: { color: e.color } })),
  860. }],
  861. }, true);
  862. }
  863. }
  864. // ---------- 模板九:同行对标 ----------
  865. // 自身值 vs 商圈同行均值/排名(raw.*_mall_avg / _mall_avg_rank,全字段落库自带)。
  866. // 账号/多店看"合计 vs 同行均值合计";单店可看排名走势。
  867. async function renderBenchTpl(grid, data) {
  868. const chartMetric = metric();
  869. const col = chartCol(chartMetric);
  870. let bench;
  871. try { bench = await benchData(data); } catch {
  872. panelEmpty(grid, '同行数据加载失败,请稍后重试', 3);
  873. return;
  874. }
  875. const rows = bench.rows;
  876. if (!rows.length) {
  877. panelEmpty(grid, '暂无档口明细数据(需按门店粒度同步)', 3);
  878. return;
  879. }
  880. const isClk = chartMetric === 'clkExpRate';
  881. const isPct = col.type === 'pct';
  882. // 日聚合:加性指标求和;率指标按权重(进店率按曝光、其余按下单人数)加权
  883. const byDate = new Map();
  884. for (const r of rows) {
  885. let b = byDate.get(r.date);
  886. if (!b) { b = { vNum: 0, vDen: 0, aNum: 0, aDen: 0 }; byDate.set(r.date, b); }
  887. const w = isPct ? (isClk ? r.w1 : r.w2) : 1;
  888. b.vNum += r.v * w;
  889. b.vDen += w;
  890. if (r.avg != null && Number.isFinite(r.avg)) { b.aNum += r.avg * w; b.aDen += w; }
  891. }
  892. const dates = [...byDate.keys()].sort();
  893. const ownVals = dates.map((d) => { const b = byDate.get(d); return isPct ? (b.vDen > 0 ? b.vNum / b.vDen : null) : b.vNum; });
  894. const avgVals = dates.map((d) => { const b = byDate.get(d); return b.aDen > 0 ? b.aNum / b.aDen : null; });
  895. const hasAvg = avgVals.some((v) => v != null);
  896. const xData = dates.map(ymdToDash);
  897. const headPanel = makePanel(grid, `本店 vs 商圈同行均值 · ${col.label}`, 2);
  898. if (!hasAvg) panelNote(headPanel, '当前数据未含同行均值 —— 2026-10-16 起同步的数据全字段落库自带,重新同步即可补全');
  899. const headChart = panelChart(headPanel, 'benchTrend');
  900. if (headChart) {
  901. const series = [
  902. { name: `本店·${col.label}`, type: 'line', smooth: true, symbolSize: 4, connectNulls: false, data: ownVals, itemStyle: { color: ACCENT }, lineStyle: { width: 2.5 } },
  903. ];
  904. if (hasAvg) {
  905. series.push({
  906. name: '商圈同行均值', type: 'line', smooth: true, symbolSize: 3, connectNulls: false, data: avgVals,
  907. itemStyle: { color: COLOR_INFO }, lineStyle: { type: 'dashed', width: 1.8 },
  908. });
  909. }
  910. headChart.setOption({
  911. tooltip: { trigger: 'axis', valueFormatter: (v) => fmtByCol(col, v) },
  912. legend: { top: 0, icon: 'roundRect', itemWidth: 14, itemHeight: 4, textStyle: { color: '#6b6560' } },
  913. grid: { left: 64, right: 24, top: 36, bottom: 30 },
  914. xAxis: { type: 'category', boundaryGap: false, data: xData, ...AXIS_TXT },
  915. yAxis: { type: 'value', axisLabel: { color: '#6b6560', formatter: (v) => fmtCompact(v) }, splitLine: SPLIT_LINE },
  916. series,
  917. }, true);
  918. }
  919. // 超越率:本店 / 同行均值 - 1(逐日)
  920. const edgePanel = makePanel(grid, '超越同行幅度(逐日)', 1);
  921. const edgeChart = panelChart(edgePanel, 'benchEdge');
  922. if (edgeChart) {
  923. if (!hasAvg) panelEmptyIn(edgePanel, '无同行均值数据');
  924. else {
  925. const edges = dates.map((_, i) => (avgVals[i] != null && avgVals[i] > 0 ? ownVals[i] / avgVals[i] - 1 : null));
  926. edgeChart.setOption({
  927. tooltip: { trigger: 'axis', valueFormatter: (v) => (v == null ? '—' : (v >= 0 ? '+' : '') + (v * 100).toFixed(1) + '%') },
  928. grid: { left: 48, right: 16, top: 20, bottom: 30 },
  929. xAxis: { type: 'category', data: xData, ...AXIS_TXT },
  930. yAxis: { type: 'value', axisLabel: { color: '#6b6560', formatter: (v) => (v * 100).toFixed(0) + '%' }, splitLine: SPLIT_LINE },
  931. series: [{
  932. type: 'bar', barMaxWidth: 10,
  933. data: edges.map((v) => (v == null ? null : { value: +v.toFixed(4), itemStyle: { color: v >= 0 ? COLOR_OK : COLOR_FAIL } })),
  934. }],
  935. }, true);
  936. }
  937. }
  938. // 排名:单店 = 排名日走势(名次越小越靠上);多店 = 最新日各店排名
  939. const rankPanel = makePanel(grid, '商圈同行排名', 2);
  940. const rankRows = rows.filter((r) => r.rank != null && Number.isFinite(r.rank));
  941. const rankChart = panelChart(rankPanel, 'benchRank');
  942. if (rankChart && rankRows.length) {
  943. if (shopFilterCount() === 1) {
  944. const single = rankRows.slice().sort((a, b) => (a.date < b.date ? -1 : 1));
  945. rankChart.setOption({
  946. tooltip: { trigger: 'axis', formatter: (ps) => `${ps[0].axisValue}<br>商圈排名:第 ${Math.round(ps[0].value)} 名` },
  947. grid: { left: 56, right: 20, top: 20, bottom: 30 },
  948. xAxis: { type: 'category', boundaryGap: false, data: single.map((r) => ymdToDash(r.date)), ...AXIS_TXT },
  949. yAxis: { type: 'value', inverse: true, name: '名次', nameTextStyle: { color: '#6b6560' }, axisLabel: { color: '#6b6560' }, splitLine: SPLIT_LINE },
  950. series: [{ type: 'line', smooth: true, symbolSize: 4, data: single.map((r) => Math.round(r.rank)), itemStyle: { color: COLOR_INFO }, lineStyle: { width: 2 }, areaStyle: { color: 'rgba(37,99,235,.06)' } }],
  951. }, true);
  952. } else {
  953. const latest = dates.slice().reverse().find((d) => rows.some((r) => r.date === d && r.rank != null));
  954. const list = rows.filter((r) => r.date === latest && r.rank != null).sort((a, b) => a.rank - b.rank).reverse();
  955. panelNote(rankPanel, `取最新有排名日(${ymdToDash(latest)}),数字越小越好`);
  956. rankChart.setOption({
  957. tooltip: { trigger: 'axis', formatter: (ps) => `${ps[0].name}<br>商圈排名:第 ${Math.round(ps[0].value)} 名` },
  958. grid: { left: 10, right: 44, top: 10, bottom: 24, containLabel: true },
  959. xAxis: { type: 'value', inverse: true, axisLabel: { color: '#6b6560' }, splitLine: SPLIT_LINE },
  960. yAxis: {
  961. type: 'category', data: list.map((r) => r.shopName || r.shopId),
  962. axisLabel: { color: '#2b2a28', width: 110, overflow: 'truncate' },
  963. axisLine: { lineStyle: { color: '#e8e4df' } },
  964. },
  965. series: [{
  966. type: 'bar', barMaxWidth: 14,
  967. data: list.map((r) => ({ value: Math.round(r.rank), itemStyle: { color: r.rank <= 3 ? COLOR_OK : (r.rank >= list.length * 0.7 ? COLOR_FAIL : ACCENT) } })),
  968. label: { show: true, position: 'left', fontSize: 10, color: '#6b6560', formatter: (p) => '第' + Math.round(p.value) + '名' },
  969. }],
  970. }, true);
  971. }
  972. } else if (rankChart) {
  973. panelEmptyIn(rankPanel, '该指标暂无同行排名数据');
  974. }
  975. // 对标一览
  976. const tbl = makePanel(grid, '对标一览(本期合计)', 1);
  977. const ownTotal = isPct
  978. ? rows.reduce((a, r) => { const w = isClk ? r.w1 : r.w2; return a + r.v * w; }, 0) / Math.max(1, rows.reduce((a, r) => a + (isClk ? r.w1 : r.w2), 0))
  979. : rows.reduce((a, r) => a + r.v, 0);
  980. const avgTotal = isPct
  981. ? rows.reduce((a, r) => { if (r.avg == null) return a; const w = isClk ? r.w1 : r.w2; return a + r.avg * w; }, 0) / Math.max(1, rows.reduce((a, r) => (r.avg == null ? a : a + (isClk ? r.w1 : r.w2)), 0))
  982. : rows.reduce((a, r) => a + (r.avg != null ? r.avg : 0), 0);
  983. const shopTotals = new Map();
  984. for (const r of rows) shopTotals.set(r.shopName || r.shopId, (shopTotals.get(r.shopName || r.shopId) || 0) + r.v);
  985. const ranked = [...shopTotals.entries()].sort((a, b) => b[1] - a[1]);
  986. const wrap = document.createElement('div');
  987. wrap.className = 'ana-table-scroll';
  988. wrap.innerHTML = `<table class="ana-table">
  989. <tbody>
  990. <tr><td>本店(${isPct ? '加权均值' : '合计'})</td><td>${fmtByCol(col, ownTotal)}</td></tr>
  991. <tr><td>商圈同行均值</td><td>${hasAvg ? fmtByCol(col, avgTotal) : '—'}</td></tr>
  992. <tr><td>超越同行</td><td>${hasAvg && avgTotal > 0 ? `<span class="delta ${ownTotal >= avgTotal ? 'up' : 'down'}">${ownTotal >= avgTotal ? '↑' : '↓'} ${Math.abs((ownTotal / avgTotal - 1) * 100).toFixed(1)}%</span>` : '—'}</td></tr>
  993. <tr><td>最强档口</td><td>${ranked.length ? `${ranked[0][0]}(${fmtByCol(col, ranked[0][1])})` : '—'}</td></tr>
  994. <tr><td>最弱档口</td><td>${ranked.length > 1 ? `${ranked[ranked.length - 1][0]}(${fmtByCol(col, ranked[ranked.length - 1][1])})` : '—'}</td></tr>
  995. </tbody></table>`;
  996. tbl.cell.appendChild(wrap);
  997. }
  998. // ---------- 注册表 ----------
  999. return [
  1000. { id: 'overview', name: '综合总览', group: '经营视角', usesMetric: true, usesCompare: true,
  1001. desc: '主指标趋势 / 门店排行 / 转化漏斗 / 门店对比,一屏总览',
  1002. render: renderOverviewTpl },
  1003. { id: 'mom', name: '环比对照', group: '经营视角', usesMetric: true,
  1004. desc: '本期 vs 上期:逐日对照、逐日差值与指标环比一览',
  1005. render: renderMomTpl },
  1006. { id: 'attribution', name: '量价归因', group: '经营视角',
  1007. desc: '营业额涨跌拆解为 流量 × 进店率 × 下单率 × 人均单量 × 客单价 的贡献',
  1008. render: renderAttributionTpl },
  1009. { id: 'accountability', name: '档口问责', group: '经营视角', usesMetric: true,
  1010. desc: '涨跌榜 / 贡献帕累托 / 体量×增速四象限,直接回答"谁在涨、谁在跌"',
  1011. render: renderAccountabilityTpl },
  1012. { id: 'income', name: '收入结构', group: '经营视角',
  1013. desc: '收入 + 支出 = 营业额 的构成、收入率与补贴率、活动强度',
  1014. render: renderIncomeTpl },
  1015. { id: 'cross', name: '跨校对比', group: '经营视角', usesMetric: true,
  1016. desc: '湖中全部账号并排对比(趋势 / 合计 / 占比 / 一览表);主体与门店筛选不作用于本模板',
  1017. render: renderCrossTpl },
  1018. { id: 'funnel', name: '流量卡点', group: '运营视角',
  1019. desc: '进店率与下单率对照健康基准(9% / 2.5%),定位漏斗卡在哪一步',
  1020. render: renderFunnelTpl },
  1021. { id: 'rhythm', name: '周内节律', group: '运营视角', usesMetric: true,
  1022. desc: '星期 × 周次热力图与周内均值,辅助排班与备货',
  1023. render: renderRhythmTpl },
  1024. { id: 'bench', name: '同行对标', group: '运营视角', usesMetric: true,
  1025. desc: '本店 vs 商圈同行均值与排名(来自全字段落库的同行字段;单店可看排名走势)',
  1026. render: renderBenchTpl },
  1027. ];
  1028. };
  1029. })();