feat(excel-analyst): restore enhanced report HTML template for excel_report
Memind CI / Test, build, and release guards (push) Successful in 4m34s
Memind CI / Test, build, and release guards (push) Successful in 4m34s
Move report generation into excel-report-html.mjs so excel_report matches the hand-enhanced layout with KPI cards, charts, and insight cards, while keeping fact-model validation in the analysis engine. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+97
-45
@@ -3,6 +3,7 @@ import { constants as fsConstants } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import ExcelJS from 'exceljs';
|
||||
import JSZip from 'jszip';
|
||||
import { reportHtml } from './excel-report-html.mjs';
|
||||
|
||||
const DEFAULT_MAX_FILE_BYTES = 30 * 1024 * 1024;
|
||||
const DEFAULT_MAX_SCAN_CELLS = 200_000;
|
||||
@@ -473,6 +474,19 @@ function buildReportModel(workbook, worksheet, loaded, args = {}) {
|
||||
}
|
||||
dimensions.forEach((name) => requireColumn(loaded, name, '分析维度'));
|
||||
|
||||
const usedColumns = new Set([metric, profitMetric, quantityMetric, orderId, ...dimensions].filter(Boolean));
|
||||
const contextFields = loaded.headers.filter((header) => !usedColumns.has(header));
|
||||
const summaryMetrics = contextFields
|
||||
.filter((header) => profileColumn(loaded.rows, header).inferredType === 'number')
|
||||
.map((name) => ({ name, total: roundNumber(sumColumn(loaded.rows, name)) }));
|
||||
const monthField = contextFields.find((header) => /月/.test(header));
|
||||
let periodLabel = null;
|
||||
if (monthField) {
|
||||
const raw = normalizeText(loaded.rows.find((row) => !isBlank(row[monthField]))?.[monthField]);
|
||||
const match = raw?.match(/^(\d{4})-(\d{1,2})/);
|
||||
if (match) periodLabel = `${match[1]}年${Number(match[2])}月`;
|
||||
}
|
||||
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
const joins = new Map();
|
||||
@@ -517,9 +531,28 @@ function buildReportModel(workbook, worksheet, loaded, args = {}) {
|
||||
|
||||
for (const dimension of dimensions) {
|
||||
const grouped = new Map();
|
||||
const rowContextFields = loaded.headers.filter((header) => (
|
||||
![metric, profitMetric, quantityMetric, orderId, dimension].filter(Boolean).includes(header)
|
||||
));
|
||||
for (const row of loaded.rows) {
|
||||
const key = normalizeText(row[dimension]) || '(空)';
|
||||
const current = grouped.get(key) ?? { group: key, value: 0, profit: 0, quantity: 0, rows: 0, orderIds: new Set() };
|
||||
let current = grouped.get(key);
|
||||
if (!current) {
|
||||
current = {
|
||||
group: key,
|
||||
value: 0,
|
||||
profit: 0,
|
||||
quantity: 0,
|
||||
rows: 0,
|
||||
orderIds: new Set(),
|
||||
context: {},
|
||||
};
|
||||
for (const field of rowContextFields) {
|
||||
const profile = profileColumn(loaded.rows, field);
|
||||
const raw = row[field];
|
||||
current.context[field] = profile.inferredType === 'number' ? asNumber(raw) : normalizeText(raw) || '';
|
||||
}
|
||||
}
|
||||
current.value += asNumber(row[metric]) ?? 0;
|
||||
if (profitMetric) current.profit += asNumber(row[profitMetric]) ?? 0;
|
||||
if (quantityMetric) current.quantity += asNumber(row[quantityMetric]) ?? 0;
|
||||
@@ -546,6 +579,7 @@ function buildReportModel(workbook, worksheet, loaded, args = {}) {
|
||||
orders: orderCount,
|
||||
averagePerOrder: orderCount ? roundNumber(item.value / orderCount) : null,
|
||||
...(masterData ? { masterData } : {}),
|
||||
context: item.context,
|
||||
};
|
||||
}).sort((a, b) => b.value - a.value || a.group.localeCompare(b.group, 'zh-CN', { numeric: true }));
|
||||
const reconciled = items.reduce((sum, item) => sum + item.value, 0);
|
||||
@@ -557,6 +591,59 @@ function buildReportModel(workbook, worksheet, loaded, args = {}) {
|
||||
sections.push({ dimension, items, total: roundNumber(reconciled) });
|
||||
}
|
||||
|
||||
const regionField = dimensions.includes('区域') ? '区域' : null;
|
||||
const cityField = dimensions.includes('城市') ? '城市' : null;
|
||||
const productField = dimensions.includes('产品') ? '产品' : null;
|
||||
const regionCityCounts = {};
|
||||
const productRegionCounts = {};
|
||||
const productCityCounts = {};
|
||||
const productOnlyCity = {};
|
||||
if (regionField && cityField) {
|
||||
const regionCitySets = new Map();
|
||||
for (const row of loaded.rows) {
|
||||
const region = normalizeText(row[regionField]);
|
||||
const city = normalizeText(row[cityField]);
|
||||
if (!region || !city) continue;
|
||||
const set = regionCitySets.get(region) ?? new Set();
|
||||
set.add(city);
|
||||
regionCitySets.set(region, set);
|
||||
}
|
||||
for (const [region, cities] of regionCitySets.entries()) {
|
||||
regionCityCounts[region] = cities.size;
|
||||
}
|
||||
}
|
||||
if (productField) {
|
||||
const productRegions = new Map();
|
||||
const productCities = new Map();
|
||||
for (const row of loaded.rows) {
|
||||
const product = normalizeText(row[productField]);
|
||||
if (!product) continue;
|
||||
if (regionField) {
|
||||
const region = normalizeText(row[regionField]);
|
||||
if (region) {
|
||||
const set = productRegions.get(product) ?? new Set();
|
||||
set.add(region);
|
||||
productRegions.set(product, set);
|
||||
}
|
||||
}
|
||||
if (cityField) {
|
||||
const city = normalizeText(row[cityField]);
|
||||
if (city) {
|
||||
const set = productCities.get(product) ?? new Set();
|
||||
set.add(city);
|
||||
productCities.set(product, set);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [product, regions] of productRegions.entries()) {
|
||||
productRegionCounts[product] = regions.size;
|
||||
}
|
||||
for (const [product, cities] of productCities.entries()) {
|
||||
productCityCounts[product] = cities.size;
|
||||
if (cities.size === 1) productOnlyCity[product] = [...cities][0];
|
||||
}
|
||||
}
|
||||
|
||||
const missingMetric = loaded.rows.filter((row) => asNumber(row[metric]) == null).length;
|
||||
if (missingMetric) warnings.push({ code: 'MISSING_METRIC_VALUES', metric, rows: missingMetric });
|
||||
if (orderId && uniqueOrders < loaded.rows.length) {
|
||||
@@ -576,54 +663,19 @@ function buildReportModel(workbook, worksheet, loaded, args = {}) {
|
||||
},
|
||||
sections,
|
||||
facts: { topAverage },
|
||||
meta: {
|
||||
periodLabel,
|
||||
summaryMetrics,
|
||||
contextFields,
|
||||
regionCityCounts,
|
||||
productRegionCounts,
|
||||
productCityCounts,
|
||||
productOnlyCity,
|
||||
},
|
||||
validation,
|
||||
};
|
||||
}
|
||||
|
||||
function formatReportNumber(value) {
|
||||
if (value == null) return '—';
|
||||
return new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 2 }).format(value);
|
||||
}
|
||||
|
||||
function formatReportWarning(warning) {
|
||||
if (warning.code === 'DUPLICATE_ORDER_IDS') {
|
||||
return `${warning.field} 存在重复:${warning.rows} 行仅有 ${warning.unique} 个唯一值`;
|
||||
}
|
||||
if (warning.code === 'MISSING_METRIC_VALUES') {
|
||||
return `${warning.metric} 有 ${warning.rows} 行缺失或不是数值`;
|
||||
}
|
||||
return warning.code;
|
||||
}
|
||||
|
||||
function reportHtml(model, { title = '' } = {}) {
|
||||
const safeTitle = escapeXml(title || `${model.source.sheet} 数据分析报告`);
|
||||
const cards = [
|
||||
[model.fields.metric, formatReportNumber(model.totals.metric)],
|
||||
...(model.fields.profitMetric ? [[model.fields.profitMetric, formatReportNumber(model.totals.profit)], ['综合比率', `${(model.totals.margin * 100).toFixed(1)}%`]] : []),
|
||||
...(model.fields.quantityMetric ? [[model.fields.quantityMetric, formatReportNumber(model.totals.quantity)]] : []),
|
||||
[model.fields.orderId ? '唯一订单数' : '记录数', formatReportNumber(model.totals.orders)],
|
||||
];
|
||||
const sections = model.sections.map((section) => {
|
||||
const joinFields = [...new Set(section.items.flatMap((item) => Object.keys(item.masterData ?? {})))];
|
||||
const header = [section.dimension, ...joinFields, model.fields.metric, ...(model.fields.profitMetric ? [model.fields.profitMetric, '比率'] : []), ...(model.fields.quantityMetric ? [model.fields.quantityMetric] : []), '订单数', '单均'];
|
||||
const rows = section.items.map((item) => [
|
||||
item.group,
|
||||
...joinFields.map((field) => item.masterData?.[field] ?? ''),
|
||||
formatReportNumber(item.value),
|
||||
...(model.fields.profitMetric ? [formatReportNumber(item.profit), item.margin == null ? '—' : `${(item.margin * 100).toFixed(1)}%`] : []),
|
||||
...(model.fields.quantityMetric ? [formatReportNumber(item.quantity)] : []),
|
||||
formatReportNumber(item.orders),
|
||||
formatReportNumber(item.averagePerOrder),
|
||||
]);
|
||||
const winner = model.facts.topAverage[section.dimension];
|
||||
return `<section><h2>${escapeXml(section.dimension)}分析</h2><div class="table-wrap"><table><thead><tr>${header.map((cell) => `<th>${escapeXml(cell)}</th>`).join('')}</tr></thead><tbody>${rows.map((row) => `<tr>${row.map((cell) => `<td>${escapeXml(cell)}</td>`).join('')}</tr>`).join('')}</tbody></table></div>${winner ? `<p class="fact">单均最高:<strong>${escapeXml(winner.group)}</strong>(${escapeXml(formatReportNumber(winner.value))} / 单)</p>` : ''}</section>`;
|
||||
}).join('');
|
||||
const warnings = model.validation.warnings.length
|
||||
? `<ul class="warnings">${model.validation.warnings.map((warning) => `<li>${escapeXml(formatReportWarning(warning))}</li>`).join('')}</ul>`
|
||||
: '';
|
||||
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="excel-report-valid" content="true"><title>${safeTitle}</title><style>:root{color-scheme:light;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#172033;background:#f4f7fb}*{box-sizing:border-box}body{margin:0}.wrap{max-width:1200px;margin:auto;padding:32px 20px 56px}header,section{background:#fff;border:1px solid #dbe4f0;border-radius:16px;padding:24px;margin-bottom:20px;box-shadow:0 8px 24px rgba(31,55,84,.06)}h1{margin:0 0 8px;color:#183a5a}h2{margin:0 0 18px;color:#183a5a}.source{color:#61758a}.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;margin-top:22px}.card{background:#edf5ff;border-radius:12px;padding:16px}.card b{display:block;font-size:24px;margin-top:5px}.table-wrap{overflow:auto}table{width:100%;border-collapse:collapse}th,td{padding:10px 12px;border-bottom:1px solid #e5ebf2;text-align:right;white-space:nowrap}th{background:#183a5a;color:#fff}th:first-child,td:first-child{text-align:left}.fact{margin:16px 0 0;color:#42566c}.ok{color:#147d4f;font-weight:700}.warnings{color:#9a5b00;background:#fff7e6;border-radius:10px;padding:12px 12px 12px 32px}</style></head><body><main class="wrap"><header><h1>${safeTitle}</h1><div class="source">来源:${escapeXml(model.source.path)} · ${escapeXml(model.source.sheet)} · ${model.source.rows} 行</div><div class="cards">${cards.map(([label, value]) => `<div class="card">${escapeXml(label)}<b>${escapeXml(value)}</b></div>`).join('')}</div><p class="ok">✓ 已通过 Excel 事实模型一致性校验</p>${warnings}</header>${sections}</main></body></html>`;
|
||||
}
|
||||
|
||||
export function createExcelAnalysisEngine({ workspaceRoot, maxFileBytes = DEFAULT_MAX_FILE_BYTES } = {}) {
|
||||
if (!workspaceRoot) throw new Error('Excel engine requires workspaceRoot');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user