| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140 |
- 'use strict';
- /**
- * 数据报表 · 经营周报导出
- * ----------------------------------------------------------------------------
- * 参考 DataAgent ReportGeneratorNode 的「自包含 HTML 报告」产物形态:
- * 单文件、内联 ECharts、离线可打开,适合发群/汇报。
- */
- const fs = require('fs');
- const path = require('path');
- const svc = require('./napos-service.js');
- const ECHARTS_SRC = path.join(__dirname, 'public', 'vendor', 'echarts.min.js');
- function esc(v) {
- return String(v == null ? '' : v)
- .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
- .replace(/"/g, '"').replace(/'/g, ''');
- }
- function fmtMoney(v) { return v == null ? '—' : Number(v).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); }
- function fmtInt(v) { return v == null ? '—' : Number(v).toLocaleString('zh-CN', { maximumFractionDigits: 0 }); }
- function fmtPct(v) { return v == null || v === '' ? '—' : (Number(v) * 100).toFixed(2) + '%'; }
- function fmtByType(type, v) {
- if (type === 'money') return fmtMoney(v);
- if (type === 'int') return fmtInt(v);
- if (type === 'pct') return fmtPct(v);
- if (type === 'num') return v == null ? '—' : Number(v).toFixed(2);
- if (type === 'date') return /^\d{8}$/.test(String(v)) ? `${v.slice(0, 4)}-${v.slice(4, 6)}-${v.slice(6, 8)}` : (v || '—');
- return v == null ? '—' : String(v);
- }
- function buildReportHtml({ entity, from, to }) {
- const meta = svc.store.getEntity(entity) || { key: entity, name: entity, shops: [] };
- const shopNameById = new Map((meta.shops || []).map((s) => [String(s.shopId), s.shopName]));
- const daily = svc.queryReport({ entity, from, to, scope: 'shop' });
- const aggDate = svc.aggregateReport({ entity, from, to, group: 'date' });
- const aggShop = svc.aggregateReport({ entity, from, to, group: 'shop' });
- const s = daily.summary || {};
- // 图表数据:日趋势(营业额/有效订单 双轴)+ 门店排行 Top15
- const trendDates = aggDate.rows.map((r) => fmtByType('date', r.date));
- const trendGross = aggDate.rows.map((r) => Number(r.grossAmt) || 0);
- const trendOrders = aggDate.rows.map((r) => Number(r.validOrderCnt) || 0);
- const topShops = aggShop.rows.slice(0, 15);
- const topNames = topShops.map((r) => shopNameById.get(String(r.shopId)) || r.shopName || r.shopId);
- const topGross = topShops.map((r) => Number(r.grossAmt) || 0);
- const tableRows = aggShop.rows.map((r, i) => {
- const name = esc(shopNameById.get(String(r.shopId)) || r.shopName || r.shopId);
- 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>`;
- }).join('');
- const kpi = [
- ['营业额(元)', fmtMoney(s.grossAmt), '#ff6b35'],
- ['收入(元)', fmtMoney(s.incomeAmt), '#1a8a4c'],
- ['有效订单', fmtInt(s.validOrderCnt), '#ff6b35'],
- ['曝光人数', fmtInt(s.expUv), '#6b6560'],
- ['下单人数', fmtInt(s.orderUserCnt), '#6b6560'],
- ['进店转化率', fmtPct(s.clkExpRate), '#b8860b'],
- ['客单价(元)', fmtMoney(s.perActualPay), '#1a8a4c'],
- ].map(([label, value, color]) => `<div class="kpi"><span class="kpi-label">${label}</span><span class="kpi-value" style="color:${color}">${value}</span></div>`).join('');
- const echartsJs = fs.existsSync(ECHARTS_SRC) ? fs.readFileSync(ECHARTS_SRC, 'utf8') : '';
- return `<!DOCTYPE html>
- <html lang="zh-CN">
- <head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>经营周报 · ${esc(meta.name)} · ${esc(from)}~${esc(to)}</title>
- <style>
- * { box-sizing: border-box; }
- body { margin: 0; padding: 28px; background: #f4f2ef; color: #2b2a28; font: 14px/1.5 -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; }
- .wrap { max-width: 1200px; margin: 0 auto; }
- h1 { font-size: 20px; margin: 0 0 4px; }
- .sub { color: #a5a09a; font-size: 12px; margin-bottom: 20px; }
- .kpis { display: grid; grid-template-columns: repeat(7, 1fr); gap: 10px; margin-bottom: 16px; }
- .kpi { background: #fff; border: 1px solid #e8e4df; border-radius: 12px; padding: 12px 14px; display: flex; flex-direction: column; gap: 4px; }
- .kpi-label { font-size: 11.5px; color: #6b6560; }
- .kpi-value { font-size: 18px; font-weight: 700; font-variant-numeric: tabular-nums; }
- .card { background: #fff; border: 1px solid #e8e4df; border-radius: 14px; padding: 16px; margin-bottom: 16px; }
- .card h2 { font-size: 14px; margin: 0 0 10px; }
- .chart { width: 100%; height: 320px; }
- table { width: 100%; border-collapse: collapse; font-size: 13px; }
- th { text-align: left; padding: 8px 10px; background: #f4f2ef; color: #6b6560; font-size: 11.5px; border-bottom: 1px solid #e8e4df; }
- td { padding: 8px 10px; border-bottom: 1px solid #f0ede8; }
- td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
- td.rank { color: #a5a09a; width: 40px; }
- td.shop { font-weight: 600; }
- @media (max-width: 900px) { .kpis { grid-template-columns: repeat(2, 1fr); } }
- </style>
- </head>
- <body>
- <div class="wrap">
- <h1>经营周报 · ${esc(meta.name)}</h1>
- <div class="sub">区间 ${esc(from)} ~ ${esc(to)} · 覆盖 ${s.shopCount || 0} 家门店 · ${s.dayCount || 0} 天 · 生成于 ${new Date().toLocaleString('zh-CN')} · 智慧运营中台</div>
- <div class="kpis">${kpi}</div>
- <div class="card"><h2>日趋势(营业额 / 有效订单)</h2><div class="chart" id="trend"></div></div>
- <div class="card"><h2>门店排行 · 营业额 Top ${topNames.length}</h2><div class="chart" id="rank"></div></div>
- <div class="card"><h2>门店汇总(按营业额降序)</h2>
- <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>
- <tbody>${tableRows || '<tr><td colspan="9">区间内无数据</td></tr>'}</tbody></table>
- </div>
- </div>
- <script>${echartsJs}</script>
- <script>
- var AX = { axisLabel: { color: '#6b6560' }, axisLine: { lineStyle: { color: '#e8e4df' } } };
- var trend = echarts.init(document.getElementById('trend'));
- trend.setOption({
- tooltip: { trigger: 'axis' },
- legend: { data: ['营业额(元)', '有效订单'] },
- grid: { left: 60, right: 60, top: 40, bottom: 30 },
- xAxis: { type: 'category', data: ${JSON.stringify(trendDates)}, ...AX },
- yAxis: [
- { type: 'value', name: '营业额', ...AX },
- { type: 'value', name: '订单', ...AX }
- ],
- series: [
- { name: '营业额(元)', type: 'line', smooth: true, data: ${JSON.stringify(trendGross)}, itemStyle: { color: '#ff6b35' }, areaStyle: { color: 'rgba(255,107,53,.08)' } },
- { name: '有效订单', type: 'line', smooth: true, yAxisIndex: 1, data: ${JSON.stringify(trendOrders)}, itemStyle: { color: '#2563eb' } }
- ]
- });
- var rank = echarts.init(document.getElementById('rank'));
- rank.setOption({
- tooltip: { trigger: 'axis' },
- grid: { left: 10, right: 30, top: 10, bottom: 30, containLabel: true },
- xAxis: { type: 'value', ...AX },
- yAxis: { type: 'category', data: ${JSON.stringify(topNames)}, axisLabel: { color: '#2b2a28', width: 150, overflow: 'truncate' }, axisLine: { lineStyle: { color: '#e8e4df' } } },
- series: [{ type: 'bar', data: ${JSON.stringify(topGross)}, itemStyle: { color: '#ff6b35', borderRadius: [0, 6, 6, 0] } }]
- });
- window.addEventListener('resize', function () { trend.resize(); rank.resize(); });
- </script>
- </body>
- </html>`;
- }
- module.exports = { buildReportHtml };
|