report-html.js 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140
  1. 'use strict';
  2. /**
  3. * 数据报表 · 经营周报导出
  4. * ----------------------------------------------------------------------------
  5. * 参考 DataAgent ReportGeneratorNode 的「自包含 HTML 报告」产物形态:
  6. * 单文件、内联 ECharts、离线可打开,适合发群/汇报。
  7. */
  8. const fs = require('fs');
  9. const path = require('path');
  10. const svc = require('./napos-service.js');
  11. const ECHARTS_SRC = path.join(__dirname, 'public', 'vendor', 'echarts.min.js');
  12. function esc(v) {
  13. return String(v == null ? '' : v)
  14. .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
  15. .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
  16. }
  17. function fmtMoney(v) { return v == null ? '—' : Number(v).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); }
  18. function fmtInt(v) { return v == null ? '—' : Number(v).toLocaleString('zh-CN', { maximumFractionDigits: 0 }); }
  19. function fmtPct(v) { return v == null || v === '' ? '—' : (Number(v) * 100).toFixed(2) + '%'; }
  20. function fmtByType(type, v) {
  21. if (type === 'money') return fmtMoney(v);
  22. if (type === 'int') return fmtInt(v);
  23. if (type === 'pct') return fmtPct(v);
  24. if (type === 'num') return v == null ? '—' : Number(v).toFixed(2);
  25. if (type === 'date') return /^\d{8}$/.test(String(v)) ? `${v.slice(0, 4)}-${v.slice(4, 6)}-${v.slice(6, 8)}` : (v || '—');
  26. return v == null ? '—' : String(v);
  27. }
  28. function buildReportHtml({ entity, from, to }) {
  29. const meta = svc.store.getEntity(entity) || { key: entity, name: entity, shops: [] };
  30. const shopNameById = new Map((meta.shops || []).map((s) => [String(s.shopId), s.shopName]));
  31. const daily = svc.queryReport({ entity, from, to, scope: 'shop' });
  32. const aggDate = svc.aggregateReport({ entity, from, to, group: 'date' });
  33. const aggShop = svc.aggregateReport({ entity, from, to, group: 'shop' });
  34. const s = daily.summary || {};
  35. // 图表数据:日趋势(营业额/有效订单 双轴)+ 门店排行 Top15
  36. const trendDates = aggDate.rows.map((r) => fmtByType('date', r.date));
  37. const trendGross = aggDate.rows.map((r) => Number(r.grossAmt) || 0);
  38. const trendOrders = aggDate.rows.map((r) => Number(r.validOrderCnt) || 0);
  39. const topShops = aggShop.rows.slice(0, 15);
  40. const topNames = topShops.map((r) => shopNameById.get(String(r.shopId)) || r.shopName || r.shopId);
  41. const topGross = topShops.map((r) => Number(r.grossAmt) || 0);
  42. const tableRows = aggShop.rows.map((r, i) => {
  43. const name = esc(shopNameById.get(String(r.shopId)) || r.shopName || r.shopId);
  44. return `<tr><td class="rank">${i + 1}</td><td class="shop">${name}</td><td class="num">${fmtMoney(r.grossAmt)}</td><td class="num">${fmtInt(r.validOrderCnt)}</td><td class="num">${fmtMoney(r.incomeAmt)}</td><td class="num">${fmtInt(r.expUv)}</td><td class="num">${fmtInt(r.orderUserCnt)}</td><td class="num">${fmtPct(r.clkExpRate)}</td><td class="num">${fmtMoney(r.perActualPay)}</td></tr>`;
  45. }).join('');
  46. const kpi = [
  47. ['营业额(元)', fmtMoney(s.grossAmt), '#ff6b35'],
  48. ['收入(元)', fmtMoney(s.incomeAmt), '#1a8a4c'],
  49. ['有效订单', fmtInt(s.validOrderCnt), '#ff6b35'],
  50. ['曝光人数', fmtInt(s.expUv), '#6b6560'],
  51. ['下单人数', fmtInt(s.orderUserCnt), '#6b6560'],
  52. ['进店转化率', fmtPct(s.clkExpRate), '#b8860b'],
  53. ['客单价(元)', fmtMoney(s.perActualPay), '#1a8a4c'],
  54. ].map(([label, value, color]) => `<div class="kpi"><span class="kpi-label">${label}</span><span class="kpi-value" style="color:${color}">${value}</span></div>`).join('');
  55. const echartsJs = fs.existsSync(ECHARTS_SRC) ? fs.readFileSync(ECHARTS_SRC, 'utf8') : '';
  56. return `<!DOCTYPE html>
  57. <html lang="zh-CN">
  58. <head>
  59. <meta charset="UTF-8">
  60. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  61. <title>经营周报 · ${esc(meta.name)} · ${esc(from)}~${esc(to)}</title>
  62. <style>
  63. * { box-sizing: border-box; }
  64. body { margin: 0; padding: 28px; background: #f4f2ef; color: #2b2a28; font: 14px/1.5 -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; }
  65. .wrap { max-width: 1200px; margin: 0 auto; }
  66. h1 { font-size: 20px; margin: 0 0 4px; }
  67. .sub { color: #a5a09a; font-size: 12px; margin-bottom: 20px; }
  68. .kpis { display: grid; grid-template-columns: repeat(7, 1fr); gap: 10px; margin-bottom: 16px; }
  69. .kpi { background: #fff; border: 1px solid #e8e4df; border-radius: 12px; padding: 12px 14px; display: flex; flex-direction: column; gap: 4px; }
  70. .kpi-label { font-size: 11.5px; color: #6b6560; }
  71. .kpi-value { font-size: 18px; font-weight: 700; font-variant-numeric: tabular-nums; }
  72. .card { background: #fff; border: 1px solid #e8e4df; border-radius: 14px; padding: 16px; margin-bottom: 16px; }
  73. .card h2 { font-size: 14px; margin: 0 0 10px; }
  74. .chart { width: 100%; height: 320px; }
  75. table { width: 100%; border-collapse: collapse; font-size: 13px; }
  76. th { text-align: left; padding: 8px 10px; background: #f4f2ef; color: #6b6560; font-size: 11.5px; border-bottom: 1px solid #e8e4df; }
  77. td { padding: 8px 10px; border-bottom: 1px solid #f0ede8; }
  78. td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
  79. td.rank { color: #a5a09a; width: 40px; }
  80. td.shop { font-weight: 600; }
  81. @media (max-width: 900px) { .kpis { grid-template-columns: repeat(2, 1fr); } }
  82. </style>
  83. </head>
  84. <body>
  85. <div class="wrap">
  86. <h1>经营周报 · ${esc(meta.name)}</h1>
  87. <div class="sub">区间 ${esc(from)} ~ ${esc(to)} · 覆盖 ${s.shopCount || 0} 家门店 · ${s.dayCount || 0} 天 · 生成于 ${new Date().toLocaleString('zh-CN')} · 智慧运营中台</div>
  88. <div class="kpis">${kpi}</div>
  89. <div class="card"><h2>日趋势(营业额 / 有效订单)</h2><div class="chart" id="trend"></div></div>
  90. <div class="card"><h2>门店排行 · 营业额 Top ${topNames.length}</h2><div class="chart" id="rank"></div></div>
  91. <div class="card"><h2>门店汇总(按营业额降序)</h2>
  92. <table><thead><tr><th>#</th><th>门店</th><th class="num">营业额(元)</th><th class="num">有效订单</th><th class="num">收入(元)</th><th class="num">曝光人数</th><th class="num">下单人数</th><th class="num">进店转化率</th><th class="num">客单价(元)</th></tr></thead>
  93. <tbody>${tableRows || '<tr><td colspan="9">区间内无数据</td></tr>'}</tbody></table>
  94. </div>
  95. </div>
  96. <script>${echartsJs}</script>
  97. <script>
  98. var AX = { axisLabel: { color: '#6b6560' }, axisLine: { lineStyle: { color: '#e8e4df' } } };
  99. var trend = echarts.init(document.getElementById('trend'));
  100. trend.setOption({
  101. tooltip: { trigger: 'axis' },
  102. legend: { data: ['营业额(元)', '有效订单'] },
  103. grid: { left: 60, right: 60, top: 40, bottom: 30 },
  104. xAxis: { type: 'category', data: ${JSON.stringify(trendDates)}, ...AX },
  105. yAxis: [
  106. { type: 'value', name: '营业额', ...AX },
  107. { type: 'value', name: '订单', ...AX }
  108. ],
  109. series: [
  110. { name: '营业额(元)', type: 'line', smooth: true, data: ${JSON.stringify(trendGross)}, itemStyle: { color: '#ff6b35' }, areaStyle: { color: 'rgba(255,107,53,.08)' } },
  111. { name: '有效订单', type: 'line', smooth: true, yAxisIndex: 1, data: ${JSON.stringify(trendOrders)}, itemStyle: { color: '#2563eb' } }
  112. ]
  113. });
  114. var rank = echarts.init(document.getElementById('rank'));
  115. rank.setOption({
  116. tooltip: { trigger: 'axis' },
  117. grid: { left: 10, right: 30, top: 10, bottom: 30, containLabel: true },
  118. xAxis: { type: 'value', ...AX },
  119. yAxis: { type: 'category', data: ${JSON.stringify(topNames)}, axisLabel: { color: '#2b2a28', width: 150, overflow: 'truncate' }, axisLine: { lineStyle: { color: '#e8e4df' } } },
  120. series: [{ type: 'bar', data: ${JSON.stringify(topGross)}, itemStyle: { color: '#ff6b35', borderRadius: [0, 6, 6, 0] } }]
  121. });
  122. window.addEventListener('resize', function () { trend.resize(); rank.resize(); });
  123. </script>
  124. </body>
  125. </html>`;
  126. }
  127. module.exports = { buildReportHtml };