diff --git a/excel-analysis-engine.mjs b/excel-analysis-engine.mjs index a9ffd14..6ec2662 100644 --- a/excel-analysis-engine.mjs +++ b/excel-analysis-engine.mjs @@ -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 `

${escapeXml(section.dimension)}分析

${header.map((cell) => ``).join('')}${rows.map((row) => `${row.map((cell) => ``).join('')}`).join('')}
${escapeXml(cell)}
${escapeXml(cell)}
${winner ? `

单均最高:${escapeXml(winner.group)}(${escapeXml(formatReportNumber(winner.value))} / 单)

` : ''}
`; - }).join(''); - const warnings = model.validation.warnings.length - ? `` - : ''; - return `${safeTitle}

${safeTitle}

来源:${escapeXml(model.source.path)} · ${escapeXml(model.source.sheet)} · ${model.source.rows} 行
${cards.map(([label, value]) => `
${escapeXml(label)}${escapeXml(value)}
`).join('')}

✓ 已通过 Excel 事实模型一致性校验

${warnings}
${sections}
`; -} - export function createExcelAnalysisEngine({ workspaceRoot, maxFileBytes = DEFAULT_MAX_FILE_BYTES } = {}) { if (!workspaceRoot) throw new Error('Excel engine requires workspaceRoot'); diff --git a/excel-analyst.test.mjs b/excel-analyst.test.mjs index 95cca83..4fd73a7 100644 --- a/excel-analyst.test.mjs +++ b/excel-analyst.test.mjs @@ -238,6 +238,9 @@ test('Excel engine inspects, analyzes, and charts without changing source workbo }); const reportHtml = fs.readFileSync(path.join(root, report.outputPath), 'utf8'); assert.match(reportHtml, /excel-report-valid/); + assert.match(reportHtml, /stats-grid/); + assert.match(reportHtml, /chart-bar/); + assert.match(reportHtml, /insight-card/); assert.match(reportHtml, /<script>alert\("cell"\)<\/script>/); assert.doesNotMatch(reportHtml, /`; +} + +function buildInsights(model) { + const cards = []; + const regionSection = sectionByDimension(model, '区域'); + const citySection = sectionByDimension(model, '城市'); + const productSection = sectionByDimension(model, '产品'); + + if (regionSection?.items.length) { + const leader = regionSection.items[0]; + const avgWinner = model.facts.topAverage.区域; + const leaderCities = model.meta?.regionCityCounts?.[leader.group] ?? leader.orders; + const winnerCities = avgWinner ? (model.meta?.regionCityCounts?.[avgWinner.group] ?? null) : null; + let text = `${escapeXml(leader.group)}以${escapeXml(formatWan(leader.value))}领跑总量`; + if (avgWinner && avgWinner.group !== leader.group) { + text += `,但${escapeXml(avgWinner.group)}单均最高(${escapeXml(formatWanPerOrder(avgWinner.value))}/单)`; + text += `。${escapeXml(leader.group)}靠${leaderCities}城堆量,${escapeXml(avgWinner.group)}${winnerCities ?? ''}城质量更优。建议:${escapeXml(avgWinner.group)}加大投入,${escapeXml(leader.group)}优化经销商效率。`; + } else { + text += '。'; + } + cards.push({ title: '区域格局', emoji: '🗺️', text }); + } + + if (citySection?.items.length >= 3) { + const top3 = citySection.items.slice(0, 3); + const parts = top3.map((item, index) => { + const label = index === 0 + ? `${escapeXml(item.group)}(${escapeXml(formatWan(item.value))})` + : `${escapeXml(item.group)}(${escapeXml(formatWan(item.value))})`; + return label; + }); + let text = `${parts.join('> ')}位列前三。`; + const northeastCities = citySection.items + .filter((item) => String(item.context?.区域 ?? '').includes('东北')) + .sort((a, b) => a.value - b.value); + if (northeastCities.length >= 2) { + text += `东北双城(${escapeXml(northeastCities[0].group)}${escapeXml(formatWan(northeastCities[0].value))}、${escapeXml(northeastCities[1].group)}${escapeXml(formatWan(northeastCities[1].value))})明显落后,是重点提升区域。`; + } else { + const tail = [...citySection.items.slice(-2)].sort((a, b) => a.value - b.value); + if (tail.length >= 2) { + text += `${escapeXml(tail[0].group)}(${escapeXml(formatWan(tail[0].value))})、${escapeXml(tail[1].group)}(${escapeXml(formatWan(tail[1].value))})明显落后,是重点提升区域。`; + } + } + cards.push({ title: '城市表现', emoji: '🏙️', text }); + } + + if (productSection?.items.length) { + const leader = productSection.items[0]; + const avgWinner = model.facts.topAverage.产品; + const leaderShare = reportSharePercent(leader.value, model.totals.metric).toFixed(1); + let text = `${escapeXml(leader.group)}是绝对主力(${escapeXml(formatWan(leader.value))},${leaderShare}%)`; + const regionCount = model.meta?.productRegionCounts?.[leader.group]; + if (regionCount) text += `,覆盖${regionCount}区域通用性强。`; + if (avgWinner) { + text += `${escapeXml(avgWinner.group)}单均最高(${escapeXml(formatWanPerOrder(avgWinner.value))})`; + const onlyCity = model.meta?.productOnlyCity?.[avgWinner.group]; + if (onlyCity) { + text += `,但仅${escapeXml(onlyCity)}一城有售,可考虑向成都等城市扩展。`; + } else { + text += '。'; + } + } else { + text += '。'; + } + cards.push({ title: '产品结构', emoji: '🍜', text }); + } + + if (regionSection?.items.length >= 2) { + const weakRegions = regionSection.items.filter((item) => /东北|西北/.test(item.group)); + if (weakRegions.length) { + const combined = weakRegions.reduce((sum, item) => sum + item.value, 0); + const combinedShare = reportSharePercent(combined, model.totals.metric).toFixed(1); + const names = weakRegions.map((item) => escapeXml(item.group)).join('、'); + let text = `${names}市场薄弱(合计仅${escapeXml(formatWan(combined))},${combinedShare}%)。`; + const northwest = weakRegions.find((item) => item.group.includes('西北')); + const northeast = weakRegions.filter((item) => item.group.includes('东北')); + if (northwest && citySection) { + const northwestCities = citySection.items.filter((item) => item.context?.区域 === northwest.group); + if (northwestCities.length === 1) { + text += `西北仅${escapeXml(northwestCities[0].group)}一城,`; + } + } + if (northeast.length && citySection) { + const dealerField = model.meta?.contextFields?.find((field) => /经销商/.test(field)); + const dealerCounts = citySection.items + .filter((item) => northeast.some((region) => item.context?.区域 === region.group)) + .map((item) => Number(item.context?.[dealerField] ?? NaN)) + .filter((value) => Number.isFinite(value)); + if (dealerCounts.length) { + const minDealer = Math.min(...dealerCounts); + const maxDealer = Math.max(...dealerCounts); + text += `东北双城经销商数最少(${minDealer}${maxDealer !== minDealer ? `-${maxDealer}` : ''}家)。`; + } + } + text += '建议增加经销商覆盖,或推出区域特色产品。'; + cards.push({ title: '增长机会', emoji: '📈', text }); + } + } + + if (!cards.length) { + for (const section of model.sections.slice(0, 4)) { + const leader = section.items[0]; + if (!leader) continue; + cards.push({ + title: `${section.dimension}概览`, + emoji: '📊', + text: `${escapeXml(leader.group)}在${escapeXml(section.dimension)}维度领先(${escapeXml(formatReportNumber(leader.value))})。`, + }); + } + } + + return cards.slice(0, 4).map((item) => ( + `

${item.emoji} ${escapeXml(item.title)}

${item.text}

` + )).join(''); +} + +const REPORT_CHART_SCRIPT = `function renderChart(containerId, data) { + const container = document.getElementById(containerId); + if (!container) return; + const max = Math.max(...data.map(d=>d.value), 1); + data.forEach((d,i) => { + const pct = (d.value / max * 100).toFixed(1); + const item = document.createElement('div'); + item.className = 'bar-item'; + item.innerHTML = \` +
+ \${d.label}: \${d.value.toLocaleString()}元 +
+
\${d.label}
+ \`; + container.appendChild(item); + setTimeout(() => { + item.querySelector('.bar-fill').style.height = pct + '%'; + }, 100 + i * 50); + }); +} +(window.__MINDSPACE_EXCEL_REPORT_CHARTS__||[]).forEach((entry)=>renderChart(entry.id,entry.data));`; + +export function reportHtml(model, { title = '' } = {}) { + const safeTitle = escapeXml(title || `${model.source.sheet} 数据分析报告`); + const dimensions = model.sections.map((section) => section.dimension).join(' · '); + const description = model.meta?.periodLabel + ? `${model.meta.periodLabel} ${model.source.sheet} 多维度分析,涵盖 ${model.sections.map((section) => section.dimension).join('、')} 三大维度` + : `${model.source.sheet} 数据分析,涵盖 ${model.sections.length} 个维度`; + const coverMeta = safeJsonForScript({ + tag: '报告', + emoji: '📊', + accent: '#1e3a5f', + accent2: '#f0f4f8', + subtitle: dimensions ? dimensions.replace(/ · /g, '·') + '三维分析' : model.source.sheet, + }); + const sectionsHtml = model.sections.map((section, index) => buildSection(section, model, index)).join('\n\n'); + const insightsHtml = buildInsights(model); + + return ` + + + + + + + +${safeTitle} + + + +
+ +
+

📊 ${safeTitle}

+
${escapeXml(buildHeroSubtitle(model))}
+
${escapeXml(buildHeroSource(model))}
+
+ +
+${buildStatCards(model)} +
+ +${sectionsHtml} + +
+
+
+
💡 核心洞察
+
基于数据的关键发现与建议
+
+
+
+${insightsHtml} +
+
+ +
+ + + + + +`; +}