| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115 |
- 'use strict';
- /**
- * 智能问数 · 深度分析报告(确定性生成,零模型)
- * ----------------------------------------------------------------------------
- * GET /api/agent/report-deep?entity&from&to → 自包含 HTML:
- * KPI 概览 / 逐日序列 / 门店排行 / 环比归因 / 数据体检 verdicts / 统计异常 / 同行对标。
- * 全部章节复用问数工具的确定性口径(同源不打折),build_report 工具的 deep 旗指向这里。
- */
- const nlQuery = require('../../nl-query.js');
- const lake = require('./tools/lake.js');
- const cross = require('./tools/cross.js');
- const compare = require('./tools/compare.js');
- const anomaly = require('./tools/anomaly.js');
- const attribution = require('./tools/attribution.js');
- const peer = require('./tools/peer.js');
- function esc(v) {
- return String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
- }
- function buildDeepReportHtml({ entity, from, to, svc }) {
- const key = svc.store.sanitizeKey(entity);
- const meta = svc.store.getEntity(key) || {};
- const dateRange = nlQuery.lakeDateRange(key, svc);
- const f = from && /^\d{8}$/.test(String(from)) ? String(from) : dateRange.from;
- const t = to && /^\d{8}$/.test(String(to)) ? String(to) : dateRange.to;
- const ctx = { svc, entityKey: key, meta, shops: meta.shops || [], dateRange };
- const safe = (fn) => { try { return fn(); } catch (e) { return { summary: String(e.message || e), data: null }; } };
- const quality = safe(() => lake.qualityHandler(ctx, lake.qualitySanitize({ from: f, to: t }, ctx)));
- const crossr = safe(() => cross.crossHandler(ctx, cross.crossSanitize({ from: f, to: t }, ctx)));
- const cmp = safe(() => compare.compareHandler(ctx, compare.compareSanitize({ from: f, to: t }, ctx)));
- const an = safe(() => anomaly.anomalyHandler(ctx, anomaly.anomalySanitize({ metric: 'grossAmt', from: f, to: t }, ctx)));
- const attr = safe(() => attribution.attributionHandler(ctx, attribution.attributionSanitize({ from: f, to: t }, ctx)));
- const bench = safe(() => peer.benchHandler(ctx, peer.benchSanitize({ field: 'gross_amt', from: f, to: t }, ctx)));
- const trend = safe(() => svc.aggregateReport({ entity: key, from: f, to: t, group: 'date' }));
- const byShop = safe(() => svc.aggregateReport({ entity: key, from: f, to: t, group: 'shop' }));
- const statusColor = { ok: '#16a34a', warn: '#d97706', fail: '#dc2626' };
- const verdictRows = (verdicts) => (verdicts || []).map((v) => `
- <tr><td><span style="color:${statusColor[v.status] || '#334155'};font-weight:700">${esc(v.status === 'ok' ? '通过' : v.status === 'warn' ? '警告' : '不一致')}</span></td>
- <td>${esc(v.check)}</td><td>${esc(v.detail || '')}</td></tr>`).join('');
- const dayRows = ((trend && trend.rows) || []).map((r) => `
- <tr><td>${esc(String(r.date).replace(/^(\d{4})(\d{2})(\d{2})$/, '$1-$2-$3'))}</td>
- <td class="num">${Math.round(r.grossAmt || 0).toLocaleString('zh-CN')}</td>
- <td class="num">${Math.round(r.validOrderCnt || 0).toLocaleString('zh-CN')}</td>
- <td class="num">${Math.round(r.expUv || 0).toLocaleString('zh-CN')}</td>
- <td class="num">${((r.clkExpRate || 0) * 100).toFixed(2)}%</td></tr>`).join('');
- const shopRows = ((byShop && byShop.rows) || []).slice(0, 20).map((r, i) => `
- <tr><td>${i + 1}</td><td>${esc(r.shopName || r.shopId)}</td>
- <td class="num">${Math.round(r.grossAmt || 0).toLocaleString('zh-CN')}</td>
- <td class="num">${Math.round(r.validOrderCnt || 0).toLocaleString('zh-CN')}</td>
- <td class="num">${((r.clkExpRate || 0) * 100).toFixed(2)}%</td></tr>`).join('');
- const cmpRows = ((cmp.data && cmp.data.deltas) || []).map((d) => `
- <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>
- <td class="num">${d.deltaPct == null ? '—' : (d.deltaPct >= 0 ? '+' : '') + (d.deltaPct * 100).toFixed(1) + '%'}</td></tr>`).join('');
- const attrLines = attr && attr.summary ? attr.summary.split('\n').slice(1).map((l) => `<li>${esc(l)}</li>`).join('') : '<li>归因不可用</li>';
- const anomalyLines = an && an.summary ? an.summary.split('\n').slice(1).map((l) => `<li>${esc(l)}</li>`).join('') : '<li>不可用</li>';
- const benchLines = bench && bench.summary ? bench.summary.split('\n').slice(1).map((l) => `<li>${esc(l)}</li>`).join('') : '<li>不可用</li>';
- const qualityList = (quality.data && quality.data.findings || []).map((fd) => `<li>[${esc(fd.level)}] ${esc(fd.check)}:${esc(fd.detail)}</li>`).join('');
- 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 || key)} ${f}-${t}</title>
- <style>
- 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; }
- .wrap { max-width: 980px; margin: 0 auto; }
- h1 { font-size: 22px; margin: 0 0 4px; } h2 { font-size: 17px; margin: 28px 0 10px; padding-bottom: 6px; border-bottom: 2px solid #e2e8f0; }
- .meta { color: #64748b; font-size: 12px; margin-bottom: 20px; }
- section { background: #fff; border: 1px solid #e2e8f0; border-radius: 10px; padding: 18px 20px; margin-bottom: 14px; }
- table { width: 100%; border-collapse: collapse; font-size: 13px; }
- th { text-align: left; background: #f1f5f9; padding: 7px 10px; border-bottom: 2px solid #e2e8f0; }
- td { padding: 6px 10px; border-bottom: 1px solid #e2e8f0; }
- td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
- ul { margin: 6px 0; padding-left: 20px; } li { margin: 3px 0; color: #334155; }
- .warn { color: #d97706; } @media print { body { background: #fff; padding: 0; } section { break-inside: avoid; } }
- </style></head><body><div class="wrap">
- <h1>深度分析报告 · ${esc(meta.name || key)}</h1>
- <div class="meta">区间 ${f}-${t}(生成于 ${new Date().toLocaleString('zh-CN')})· 确定性口径生成,数字与问数工具同源</div>
- <section><h2 style="margin-top:0">一、数据体检</h2><ul>${qualityList || '<li>不可用</li>'}</ul></section>
- <section><h2 style="margin-top:0">二、核心指标与环比</h2>
- <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>
- <section><h2 style="margin-top:0">三、营业额归因(LMDI)</h2><ul>${attrLines}</ul></section>
- <section><h2 style="margin-top:0">四、统计异常检测</h2><ul>${anomalyLines}</ul></section>
- <section><h2 style="margin-top:0">五、同行对标(营业额)</h2><ul>${benchLines}</ul></section>
- <section><h2 style="margin-top:0">六、逐日明细</h2>
- <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>
- <section><h2 style="margin-top:0">七、门店排行(Top 20)</h2>
- <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>
- <section><h2 style="margin-top:0">八、校验结论汇总</h2>
- <table><tr><th>状态</th><th>校验</th><th>说明</th></tr>
- ${verdictRows((quality.data && quality.data.verdicts) || [])}
- ${verdictRows((crossr.data && crossr.data.verdicts) || [])}
- ${verdictRows((cmp.data && cmp.data.verdicts) || [])}
- ${verdictRows((an.data && an.data.verdicts) || [])}
- </table></section>
- </div></body></html>`;
- }
- module.exports = { buildDeepReportHtml };
|