${safeTitle}
✓ 已通过 Excel 事实模型一致性校验
${warnings}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(winner.group)}(${escapeXml(formatReportNumber(winner.value))} / 单)${escapeXml(section.dimension)}分析
${header.map((cell) => ` ${rows.map((row) => `${escapeXml(cell)} `).join('')}${row.map((cell) => ` `).join('')}${escapeXml(cell)} `).join('')}
✓ 已通过 Excel 事实模型一致性校验
${warnings}${item.text}