deep-report.js 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115
  1. 'use strict';
  2. /**
  3. * 智能问数 · 深度分析报告(确定性生成,零模型)
  4. * ----------------------------------------------------------------------------
  5. * GET /api/agent/report-deep?entity&from&to → 自包含 HTML:
  6. * KPI 概览 / 逐日序列 / 门店排行 / 环比归因 / 数据体检 verdicts / 统计异常 / 同行对标。
  7. * 全部章节复用问数工具的确定性口径(同源不打折),build_report 工具的 deep 旗指向这里。
  8. */
  9. const nlQuery = require('../../nl-query.js');
  10. const lake = require('./tools/lake.js');
  11. const cross = require('./tools/cross.js');
  12. const compare = require('./tools/compare.js');
  13. const anomaly = require('./tools/anomaly.js');
  14. const attribution = require('./tools/attribution.js');
  15. const peer = require('./tools/peer.js');
  16. function esc(v) {
  17. return String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
  18. }
  19. function buildDeepReportHtml({ entity, from, to, svc }) {
  20. const key = svc.store.sanitizeKey(entity);
  21. const meta = svc.store.getEntity(key) || {};
  22. const dateRange = nlQuery.lakeDateRange(key, svc);
  23. const f = from && /^\d{8}$/.test(String(from)) ? String(from) : dateRange.from;
  24. const t = to && /^\d{8}$/.test(String(to)) ? String(to) : dateRange.to;
  25. const ctx = { svc, entityKey: key, meta, shops: meta.shops || [], dateRange };
  26. const safe = (fn) => { try { return fn(); } catch (e) { return { summary: String(e.message || e), data: null }; } };
  27. const quality = safe(() => lake.qualityHandler(ctx, lake.qualitySanitize({ from: f, to: t }, ctx)));
  28. const crossr = safe(() => cross.crossHandler(ctx, cross.crossSanitize({ from: f, to: t }, ctx)));
  29. const cmp = safe(() => compare.compareHandler(ctx, compare.compareSanitize({ from: f, to: t }, ctx)));
  30. const an = safe(() => anomaly.anomalyHandler(ctx, anomaly.anomalySanitize({ metric: 'grossAmt', from: f, to: t }, ctx)));
  31. const attr = safe(() => attribution.attributionHandler(ctx, attribution.attributionSanitize({ from: f, to: t }, ctx)));
  32. const bench = safe(() => peer.benchHandler(ctx, peer.benchSanitize({ field: 'gross_amt', from: f, to: t }, ctx)));
  33. const trend = safe(() => svc.aggregateReport({ entity: key, from: f, to: t, group: 'date' }));
  34. const byShop = safe(() => svc.aggregateReport({ entity: key, from: f, to: t, group: 'shop' }));
  35. const statusColor = { ok: '#16a34a', warn: '#d97706', fail: '#dc2626' };
  36. const verdictRows = (verdicts) => (verdicts || []).map((v) => `
  37. <tr><td><span style="color:${statusColor[v.status] || '#334155'};font-weight:700">${esc(v.status === 'ok' ? '通过' : v.status === 'warn' ? '警告' : '不一致')}</span></td>
  38. <td>${esc(v.check)}</td><td>${esc(v.detail || '')}</td></tr>`).join('');
  39. const dayRows = ((trend && trend.rows) || []).map((r) => `
  40. <tr><td>${esc(String(r.date).replace(/^(\d{4})(\d{2})(\d{2})$/, '$1-$2-$3'))}</td>
  41. <td class="num">${Math.round(r.grossAmt || 0).toLocaleString('zh-CN')}</td>
  42. <td class="num">${Math.round(r.validOrderCnt || 0).toLocaleString('zh-CN')}</td>
  43. <td class="num">${Math.round(r.expUv || 0).toLocaleString('zh-CN')}</td>
  44. <td class="num">${((r.clkExpRate || 0) * 100).toFixed(2)}%</td></tr>`).join('');
  45. const shopRows = ((byShop && byShop.rows) || []).slice(0, 20).map((r, i) => `
  46. <tr><td>${i + 1}</td><td>${esc(r.shopName || r.shopId)}</td>
  47. <td class="num">${Math.round(r.grossAmt || 0).toLocaleString('zh-CN')}</td>
  48. <td class="num">${Math.round(r.validOrderCnt || 0).toLocaleString('zh-CN')}</td>
  49. <td class="num">${((r.clkExpRate || 0) * 100).toFixed(2)}%</td></tr>`).join('');
  50. const cmpRows = ((cmp.data && cmp.data.deltas) || []).map((d) => `
  51. <tr><td>${esc(d.label)}</td><td class="num">${Math.round(d.cur * 100) / 100}</td><td class="num">${Math.round(d.prev * 100) / 100}</td>
  52. <td class="num">${d.deltaPct == null ? '—' : (d.deltaPct >= 0 ? '+' : '') + (d.deltaPct * 100).toFixed(1) + '%'}</td></tr>`).join('');
  53. const attrLines = attr && attr.summary ? attr.summary.split('\n').slice(1).map((l) => `<li>${esc(l)}</li>`).join('') : '<li>归因不可用</li>';
  54. const anomalyLines = an && an.summary ? an.summary.split('\n').slice(1).map((l) => `<li>${esc(l)}</li>`).join('') : '<li>不可用</li>';
  55. const benchLines = bench && bench.summary ? bench.summary.split('\n').slice(1).map((l) => `<li>${esc(l)}</li>`).join('') : '<li>不可用</li>';
  56. const qualityList = (quality.data && quality.data.findings || []).map((fd) => `<li>[${esc(fd.level)}] ${esc(fd.check)}:${esc(fd.detail)}</li>`).join('');
  57. return `<!DOCTYPE html>
  58. <html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
  59. <title>深度分析报告 · ${esc(meta.name || key)} ${f}-${t}</title>
  60. <style>
  61. body { font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif; background: #f8fafc; color: #0f172a; margin: 0; padding: 24px; font-size: 14px; line-height: 1.65; }
  62. .wrap { max-width: 980px; margin: 0 auto; }
  63. h1 { font-size: 22px; margin: 0 0 4px; } h2 { font-size: 17px; margin: 28px 0 10px; padding-bottom: 6px; border-bottom: 2px solid #e2e8f0; }
  64. .meta { color: #64748b; font-size: 12px; margin-bottom: 20px; }
  65. section { background: #fff; border: 1px solid #e2e8f0; border-radius: 10px; padding: 18px 20px; margin-bottom: 14px; }
  66. table { width: 100%; border-collapse: collapse; font-size: 13px; }
  67. th { text-align: left; background: #f1f5f9; padding: 7px 10px; border-bottom: 2px solid #e2e8f0; }
  68. td { padding: 6px 10px; border-bottom: 1px solid #e2e8f0; }
  69. td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
  70. ul { margin: 6px 0; padding-left: 20px; } li { margin: 3px 0; color: #334155; }
  71. .warn { color: #d97706; } @media print { body { background: #fff; padding: 0; } section { break-inside: avoid; } }
  72. </style></head><body><div class="wrap">
  73. <h1>深度分析报告 · ${esc(meta.name || key)}</h1>
  74. <div class="meta">区间 ${f}-${t}(生成于 ${new Date().toLocaleString('zh-CN')})· 确定性口径生成,数字与问数工具同源</div>
  75. <section><h2 style="margin-top:0">一、数据体检</h2><ul>${qualityList || '<li>不可用</li>'}</ul></section>
  76. <section><h2 style="margin-top:0">二、核心指标与环比</h2>
  77. <table><tr><th>指标</th><th class="num">本期</th><th class="num">上期</th><th class="num">变化</th></tr>${cmpRows || '<tr><td colspan="4">不可用</td></tr>'}</table></section>
  78. <section><h2 style="margin-top:0">三、营业额归因(LMDI)</h2><ul>${attrLines}</ul></section>
  79. <section><h2 style="margin-top:0">四、统计异常检测</h2><ul>${anomalyLines}</ul></section>
  80. <section><h2 style="margin-top:0">五、同行对标(营业额)</h2><ul>${benchLines}</ul></section>
  81. <section><h2 style="margin-top:0">六、逐日明细</h2>
  82. <table><tr><th>日期</th><th class="num">营业额</th><th class="num">有效订单</th><th class="num">曝光</th><th class="num">进店率</th></tr>${dayRows || '<tr><td colspan="5">无数据</td></tr>'}</table></section>
  83. <section><h2 style="margin-top:0">七、门店排行(Top 20)</h2>
  84. <table><tr><th>#</th><th>门店</th><th class="num">营业额</th><th class="num">有效订单</th><th class="num">进店率</th></tr>${shopRows || '<tr><td colspan="5">无数据</td></tr>'}</table></section>
  85. <section><h2 style="margin-top:0">八、校验结论汇总</h2>
  86. <table><tr><th>状态</th><th>校验</th><th>说明</th></tr>
  87. ${verdictRows((quality.data && quality.data.verdicts) || [])}
  88. ${verdictRows((crossr.data && crossr.data.verdicts) || [])}
  89. ${verdictRows((cmp.data && cmp.data.verdicts) || [])}
  90. ${verdictRows((an.data && an.data.verdicts) || [])}
  91. </table></section>
  92. </div></body></html>`;
  93. }
  94. module.exports = { buildDeepReportHtml };