Files
memind/excel-analysis-engine.mjs
T
john 0c1c0c3b48
Memind CI / Test, build, and release guards (push) Has been cancelled
feat: add pluggable Excel analyst report guard
2026-07-17 12:05:51 +08:00

772 lines
38 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import fs from 'node:fs/promises';
import { constants as fsConstants } from 'node:fs';
import path from 'node:path';
import ExcelJS from 'exceljs';
import JSZip from 'jszip';
const DEFAULT_MAX_FILE_BYTES = 30 * 1024 * 1024;
const DEFAULT_MAX_SCAN_CELLS = 200_000;
const DEFAULT_MAX_DATA_ROWS = 20_000;
const DEFAULT_SAMPLE_ROWS = 8;
const DEFAULT_MAX_UNCOMPRESSED_BYTES = 200 * 1024 * 1024;
const DEFAULT_MAX_ZIP_ENTRIES = 5_000;
function asPositiveInteger(value, fallback, max = Number.MAX_SAFE_INTEGER) {
const number = Number(value);
if (!Number.isFinite(number) || number <= 0) return fallback;
return Math.min(Math.floor(number), max);
}
function normalizeText(value) {
return String(value ?? '').normalize('NFKC').trim();
}
function cellValue(cell) {
const value = cell?.value;
if (value == null) return null;
if (value instanceof Date) return value.toISOString();
if (typeof value !== 'object') return value;
if (Array.isArray(value.richText)) return value.richText.map((item) => item.text ?? '').join('');
if ('formula' in value || 'sharedFormula' in value) return value.result ?? null;
if ('text' in value) return value.text;
if ('hyperlink' in value) return value.text ?? value.hyperlink;
if ('error' in value) return value.error;
return String(value);
}
function cellFormula(cell) {
const value = cell?.value;
if (!value || typeof value !== 'object') return null;
return value.formula ?? value.sharedFormula ?? null;
}
function isBlank(value) {
return value == null || (typeof value === 'string' && !value.trim());
}
function asNumber(value) {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value !== 'string') return null;
const normalized = value.replace(/[,%¥¥$,\s]/g, '');
if (!normalized || !/^-?\d+(?:\.\d+)?$/.test(normalized)) return null;
const number = Number(normalized);
if (!Number.isFinite(number)) return null;
return value.includes('%') ? number / 100 : number;
}
function safeRelativeWorkbookPath(workspaceRoot, requestedPath) {
const relative = normalizeText(requestedPath).replace(/\\/g, '/');
if (!relative || path.posix.isAbsolute(relative) || relative.split('/').includes('..')) {
throw Object.assign(new Error('Excel 文件路径必须是当前用户工作区内的相对路径'), { code: 'INVALID_PATH' });
}
if (path.extname(relative).toLowerCase() !== '.xlsx') {
throw Object.assign(new Error('Phase 1 只支持 .xlsx 文件'), { code: 'UNSUPPORTED_FORMAT' });
}
const root = path.resolve(workspaceRoot);
const absolute = path.resolve(root, relative);
if (absolute !== root && !absolute.startsWith(`${root}${path.sep}`)) {
throw Object.assign(new Error('Excel 文件路径越出当前用户工作区'), { code: 'INVALID_PATH' });
}
return { absolute, relative };
}
function isWithinRoot(root, candidate) {
return candidate === root || candidate.startsWith(`${root}${path.sep}`);
}
function validateZipBudget(buffer, {
maxUncompressedBytes = DEFAULT_MAX_UNCOMPRESSED_BYTES,
maxEntries = DEFAULT_MAX_ZIP_ENTRIES,
} = {}) {
const eocdSignature = Buffer.from([0x50, 0x4b, 0x05, 0x06]);
const minimumEocdOffset = Math.max(0, buffer.length - 65_557);
const eocdOffset = buffer.lastIndexOf(eocdSignature);
if (eocdOffset < minimumEocdOffset || eocdOffset + 22 > buffer.length) {
throw Object.assign(new Error('文件不是有效的 .xlsx ZIP 工作簿'), { code: 'INVALID_WORKBOOK' });
}
const declaredEntries = buffer.readUInt16LE(eocdOffset + 10);
const centralDirectoryOffset = buffer.readUInt32LE(eocdOffset + 16);
if (declaredEntries === 0xffff || centralDirectoryOffset === 0xffffffff) {
throw Object.assign(new Error('Phase 1 不支持 ZIP64 Excel 文件'), { code: 'UNSUPPORTED_FORMAT' });
}
let entries = 0;
let uncompressedBytes = 0;
let offset = centralDirectoryOffset;
while (entries < declaredEntries) {
if (offset + 46 > eocdOffset || buffer.readUInt32LE(offset) !== 0x02014b50) {
throw Object.assign(new Error('Excel ZIP 中央目录损坏'), { code: 'INVALID_WORKBOOK' });
}
const size = buffer.readUInt32LE(offset + 24);
const nameLength = buffer.readUInt16LE(offset + 28);
const extraLength = buffer.readUInt16LE(offset + 30);
const commentLength = buffer.readUInt16LE(offset + 32);
if (size === 0xffffffff) {
throw Object.assign(new Error('Phase 1 不支持 ZIP64 Excel 文件'), { code: 'UNSUPPORTED_FORMAT' });
}
entries += 1;
uncompressedBytes += size;
if (entries > maxEntries || uncompressedBytes > maxUncompressedBytes) {
throw Object.assign(
new Error(`Excel 解压规模超过安全上限:${entries} entries, ${uncompressedBytes} bytes`),
{ code: 'WORKBOOK_TOO_LARGE', detail: { entries, uncompressedBytes, maxEntries, maxUncompressedBytes } },
);
}
offset += 46 + nameLength + extraLength + commentLength;
}
if (!entries || offset > eocdOffset) {
throw Object.assign(new Error('文件不是有效的 .xlsx ZIP 工作簿'), { code: 'INVALID_WORKBOOK' });
}
return { entries, uncompressedBytes };
}
async function normalizeMainNamespacePrefixes(buffer) {
const zip = await JSZip.loadAsync(buffer);
let changed = false;
const mainNamespace = 'http://schemas.openxmlformats.org/spreadsheetml/2006/main';
for (const entry of Object.values(zip.files)) {
if (entry.dir) continue;
if (/^xl\/worksheets\/_rels\/.*\.rels$/u.test(entry.name)) {
const relationships = await entry.async('string');
const normalizedRelationships = relationships
.replace(/<Relationship\b[^>]*Type=["'][^"']*(?:comments|vmlDrawing|threadedComment)[^"']*["'][^>]*\/>/giu, '')
.replace(/Target=(["'])\/xl\//g, 'Target=$1../');
if (normalizedRelationships !== relationships) {
zip.file(entry.name, normalizedRelationships);
changed = true;
}
continue;
}
if (!entry.name.endsWith('.xml')) continue;
const xml = await entry.async('string');
const match = xml.match(new RegExp(`xmlns:([A-Za-z_][\\w.-]*)=["']${mainNamespace}["']`));
if (!match) continue;
const prefix = match[1];
const normalized = xml
.replace(new RegExp(`<(/?)${prefix}:`, 'g'), '<$1')
.replace(new RegExp(`xmlns:${prefix}=`, 'g'), 'xmlns=');
if (normalized !== xml) {
zip.file(entry.name, normalized);
changed = true;
}
}
return changed ? zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' }) : buffer;
}
async function assertRealPathWithinWorkspace(workspaceRoot, candidate) {
const [realRoot, realCandidate] = await Promise.all([
fs.realpath(path.resolve(workspaceRoot)),
fs.realpath(candidate),
]);
if (!isWithinRoot(realRoot, realCandidate)) {
throw Object.assign(new Error('Excel 文件路径通过符号链接越出当前用户工作区'), { code: 'INVALID_PATH' });
}
}
async function prepareSafeOutput(workspaceRoot, outputPath) {
const root = path.resolve(workspaceRoot);
const realRoot = await fs.realpath(root);
let existingAncestor = path.dirname(outputPath);
while (existingAncestor !== root) {
try {
await fs.lstat(existingAncestor);
break;
} catch (error) {
if (error?.code !== 'ENOENT') throw error;
existingAncestor = path.dirname(existingAncestor);
}
}
const realAncestor = await fs.realpath(existingAncestor);
if (!isWithinRoot(realRoot, realAncestor)) {
throw Object.assign(new Error('图表输出目录通过符号链接越出当前用户工作区'), { code: 'INVALID_OUTPUT_PATH' });
}
await fs.mkdir(path.dirname(outputPath), { recursive: true });
const realParent = await fs.realpath(path.dirname(outputPath));
if (!isWithinRoot(realRoot, realParent)) {
throw Object.assign(new Error('图表输出目录越出当前用户工作区'), { code: 'INVALID_OUTPUT_PATH' });
}
}
function safeChartOutputPath(workspaceRoot, requestedPath, sourcePath) {
const sourceStem = path.basename(sourcePath, path.extname(sourcePath)).replace(/[^\p{L}\p{N}._-]+/gu, '-');
const fallback = `oa/excel-analysis/${sourceStem || 'workbook'}-chart.svg`;
const relative = normalizeText(requestedPath || fallback).replace(/\\/g, '/');
if (
!relative ||
path.posix.isAbsolute(relative) ||
relative.split('/').includes('..') ||
!/^(?:oa|public)\//.test(relative) ||
path.extname(relative).toLowerCase() !== '.svg'
) {
throw Object.assign(new Error('图表输出必须是当前工作区 oa/ 或 public/ 下的 .svg 相对路径'), { code: 'INVALID_OUTPUT_PATH' });
}
const root = path.resolve(workspaceRoot);
const absolute = path.resolve(root, relative);
if (!absolute.startsWith(`${root}${path.sep}`)) {
throw Object.assign(new Error('图表输出路径越出当前用户工作区'), { code: 'INVALID_OUTPUT_PATH' });
}
return { absolute, relative };
}
function safeReportOutputPath(workspaceRoot, requestedPath, sourcePath) {
const sourceStem = path.basename(sourcePath, path.extname(sourcePath)).replace(/[^\p{L}\p{N}._-]+/gu, '-');
const fallback = `public/${sourceStem || 'workbook'}-analysis.html`;
const relative = normalizeText(requestedPath || fallback).replace(/\\/g, '/');
if (
!relative ||
path.posix.isAbsolute(relative) ||
relative.split('/').includes('..') ||
!/^(?:oa|public)\//.test(relative) ||
path.extname(relative).toLowerCase() !== '.html'
) {
throw Object.assign(new Error('报告输出必须是当前工作区 oa/ 或 public/ 下的 .html 相对路径'), { code: 'INVALID_OUTPUT_PATH' });
}
const root = path.resolve(workspaceRoot);
const absolute = path.resolve(root, relative);
if (!absolute.startsWith(`${root}${path.sep}`)) {
throw Object.assign(new Error('报告输出路径越出当前用户工作区'), { code: 'INVALID_OUTPUT_PATH' });
}
return { absolute, relative };
}
function detectHeaderRow(worksheet, explicitHeaderRow = null) {
if (explicitHeaderRow != null) {
const row = asPositiveInteger(explicitHeaderRow, 1, Math.max(1, worksheet.rowCount));
return row;
}
const upper = Math.min(Math.max(worksheet.rowCount, 1), 20);
for (let rowNumber = 1; rowNumber <= upper; rowNumber += 1) {
const row = worksheet.getRow(rowNumber);
let nonEmpty = 0;
const uniqueValues = new Set();
for (let column = 1; column <= Math.max(worksheet.columnCount, 1); column += 1) {
const value = cellValue(row.getCell(column));
if (!isBlank(value)) {
nonEmpty += 1;
uniqueValues.add(normalizeText(value));
}
}
// ExcelJS exposes a merged title value through every cell in the merge.
// Requiring more than one distinct value prevents title bands from being
// mistaken for tabular headers.
if (nonEmpty >= 2 && uniqueValues.size >= 2) return rowNumber;
}
return 1;
}
function buildHeaders(worksheet, headerRow) {
const used = new Map();
const headers = [];
const width = Math.max(worksheet.columnCount, 1);
for (let column = 1; column <= width; column += 1) {
const raw = normalizeText(cellValue(worksheet.getRow(headerRow).getCell(column)));
const base = raw || `column_${column}`;
const seen = (used.get(base) ?? 0) + 1;
used.set(base, seen);
headers.push(seen === 1 ? base : `${base}_${seen}`);
}
return headers;
}
function worksheetByName(workbook, sheetName) {
if (sheetName) {
const worksheet = workbook.getWorksheet(sheetName);
if (!worksheet) throw Object.assign(new Error(`Sheet 不存在:${sheetName}`), { code: 'SHEET_NOT_FOUND' });
return worksheet;
}
const worksheet = workbook.worksheets[0];
if (!worksheet) throw Object.assign(new Error('工作簿没有可分析的 Sheet'), { code: 'EMPTY_WORKBOOK' });
return worksheet;
}
function loadRows(worksheet, { headerRow: explicitHeaderRow, maxRows, maxCells } = {}) {
const headerRow = detectHeaderRow(worksheet, explicitHeaderRow);
const headers = buildHeaders(worksheet, headerRow);
const rowLimit = asPositiveInteger(maxRows, DEFAULT_MAX_DATA_ROWS, DEFAULT_MAX_DATA_ROWS);
const cellLimit = asPositiveInteger(maxCells, DEFAULT_MAX_SCAN_CELLS, DEFAULT_MAX_SCAN_CELLS);
const possibleCells = Math.max(0, worksheet.rowCount - headerRow) * headers.length;
if (possibleCells > cellLimit) {
throw Object.assign(
new Error(`Sheet 数据区域超过 Phase 1 扫描上限:${possibleCells}/${cellLimit} 单元格`),
{ code: 'WORKBOOK_TOO_LARGE', detail: { possibleCells, maxCells: cellLimit } },
);
}
const rows = [];
let formulaCount = 0;
for (let rowNumber = headerRow + 1; rowNumber <= worksheet.rowCount && rows.length < rowLimit; rowNumber += 1) {
const row = worksheet.getRow(rowNumber);
const record = {};
let nonEmpty = false;
for (let column = 1; column <= headers.length; column += 1) {
const cell = row.getCell(column);
const value = cellValue(cell);
if (!isBlank(value)) nonEmpty = true;
if (cellFormula(cell)) formulaCount += 1;
record[headers[column - 1]] = value;
}
if (nonEmpty) rows.push(record);
}
return { headerRow, headers, rows, formulaCount, truncated: worksheet.rowCount - headerRow > rowLimit };
}
function profileColumn(rows, name) {
const values = rows.map((row) => row[name]);
const present = values.filter((value) => !isBlank(value));
const numeric = present.map(asNumber).filter((value) => value != null);
const dates = present.filter((value) => typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(value));
const unique = new Set(present.map((value) => JSON.stringify(value)));
let inferredType = 'string';
if (present.length && numeric.length / present.length >= 0.8) inferredType = 'number';
else if (present.length && dates.length / present.length >= 0.8) inferredType = 'date';
else if (present.length && present.every((value) => typeof value === 'boolean')) inferredType = 'boolean';
const sorted = [...numeric].sort((a, b) => a - b);
const sum = numeric.reduce((total, value) => total + value, 0);
return {
name,
inferredType,
nonEmpty: present.length,
missing: values.length - present.length,
missingRate: values.length ? Number(((values.length - present.length) / values.length).toFixed(4)) : 0,
unique: unique.size,
...(numeric.length
? {
numericCount: numeric.length,
min: sorted[0],
max: sorted.at(-1),
mean: Number((sum / numeric.length).toFixed(6)),
sum: Number(sum.toFixed(6)),
}
: {}),
samples: [...unique].slice(0, 5).map((value) => JSON.parse(value)),
};
}
function aggregateRows(rows, {
groupBy,
metric,
aggregation = 'sum',
limit = 20,
sort = 'value_desc',
} = {}) {
if (!['sum', 'avg', 'min', 'max', 'count'].includes(aggregation)) {
throw Object.assign(new Error(`不支持的聚合方式:${aggregation}`), { code: 'INVALID_AGGREGATION' });
}
if (!groupBy || !rows.length || !(groupBy in rows[0])) {
throw Object.assign(new Error(`分组字段不存在:${groupBy ?? ''}`), { code: 'COLUMN_NOT_FOUND' });
}
if (aggregation !== 'count' && (!metric || !(metric in rows[0]))) {
throw Object.assign(new Error(`指标字段不存在:${metric ?? ''}`), { code: 'COLUMN_NOT_FOUND' });
}
const groups = new Map();
for (const row of rows) {
const key = normalizeText(row[groupBy]) || '(空)';
const current = groups.get(key) ?? { key, values: [], count: 0 };
current.count += 1;
const number = asNumber(row[metric]);
if (number != null) current.values.push(number);
groups.set(key, current);
}
const items = [...groups.values()].map((group) => {
let value = group.count;
if (aggregation !== 'count') {
if (!group.values.length) value = null;
else if (aggregation === 'avg') value = group.values.reduce((a, b) => a + b, 0) / group.values.length;
else if (aggregation === 'min') value = Math.min(...group.values);
else if (aggregation === 'max') value = Math.max(...group.values);
else value = group.values.reduce((a, b) => a + b, 0);
}
return { group: group.key, value: value == null ? null : Number(value.toFixed(6)), rows: group.count };
});
if (sort === 'group_asc') {
items.sort((a, b) => a.group.localeCompare(b.group, 'zh-CN', { numeric: true }));
} else {
items.sort((a, b) => (b.value ?? Number.NEGATIVE_INFINITY) - (a.value ?? Number.NEGATIVE_INFINITY));
}
return items.slice(0, asPositiveInteger(limit, 20, 100));
}
function correlation(rows, xName, yName) {
if (!xName || !yName || !rows.length || !(xName in rows[0]) || !(yName in rows[0])) {
throw Object.assign(new Error('相关性分析要求两个存在的数值字段'), { code: 'COLUMN_NOT_FOUND' });
}
const pairs = rows
.map((row) => [asNumber(row[xName]), asNumber(row[yName])])
.filter(([x, y]) => x != null && y != null);
if (pairs.length < 2) return { x: xName, y: yName, pairs: pairs.length, correlation: null };
const xMean = pairs.reduce((sum, [x]) => sum + x, 0) / pairs.length;
const yMean = pairs.reduce((sum, [, y]) => sum + y, 0) / pairs.length;
let numerator = 0;
let xSquared = 0;
let ySquared = 0;
for (const [x, y] of pairs) {
numerator += (x - xMean) * (y - yMean);
xSquared += (x - xMean) ** 2;
ySquared += (y - yMean) ** 2;
}
const denominator = Math.sqrt(xSquared * ySquared);
return {
x: xName,
y: yName,
pairs: pairs.length,
correlation: denominator ? Number((numerator / denominator).toFixed(6)) : null,
};
}
function escapeXml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function chartSvg(items, { type = 'bar', title = 'Excel 分析图表' } = {}) {
const width = 960;
const height = 540;
const margin = { top: 70, right: 40, bottom: 110, left: 90 };
const plotWidth = width - margin.left - margin.right;
const plotHeight = height - margin.top - margin.bottom;
const values = items.map((item) => Number(item.value ?? 0));
const max = Math.max(...values.map((value) => Math.abs(value)), 1);
const points = items.map((item, index) => {
const x = margin.left + ((index + 0.5) * plotWidth) / Math.max(items.length, 1);
const y = margin.top + plotHeight - (Math.max(0, Number(item.value ?? 0)) / max) * plotHeight;
return { ...item, x, y };
});
const marks = type === 'line'
? `<polyline fill="none" stroke="#2563eb" stroke-width="4" points="${points.map((point) => `${point.x},${point.y}`).join(' ')}"/>${points.map((point) => `<circle cx="${point.x}" cy="${point.y}" r="5" fill="#2563eb"/>`).join('')}`
: points.map((point, index) => {
const slot = plotWidth / Math.max(items.length, 1);
const barWidth = Math.max(8, slot * 0.68);
return `<rect x="${point.x - barWidth / 2}" y="${point.y}" width="${barWidth}" height="${margin.top + plotHeight - point.y}" rx="4" fill="${index % 2 ? '#60a5fa' : '#2563eb'}"/>`;
}).join('');
const labels = points.map((point) => `<text x="${point.x}" y="${margin.top + plotHeight + 22}" text-anchor="end" transform="rotate(-35 ${point.x} ${margin.top + plotHeight + 22})" font-size="13" fill="#334155">${escapeXml(point.group).slice(0, 24)}</text><text x="${point.x}" y="${Math.max(margin.top + 14, point.y - 8)}" text-anchor="middle" font-size="12" fill="#0f172a">${escapeXml(point.value)}</text>`).join('');
return `<?xml version="1.0" encoding="UTF-8"?>\n<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><rect width="100%" height="100%" fill="#fff"/><text x="${width / 2}" y="38" text-anchor="middle" font-size="24" font-family="sans-serif" fill="#0f172a">${escapeXml(title)}</text><line x1="${margin.left}" y1="${margin.top + plotHeight}" x2="${margin.left + plotWidth}" y2="${margin.top + plotHeight}" stroke="#94a3b8"/><line x1="${margin.left}" y1="${margin.top}" x2="${margin.left}" y2="${margin.top + plotHeight}" stroke="#94a3b8"/>${marks}${labels}</svg>\n`;
}
function roundNumber(value, digits = 6) {
return Number(Number(value || 0).toFixed(digits));
}
function requireColumn(loaded, name, label = '字段') {
const normalized = normalizeText(name);
if (!normalized || !loaded.headers.includes(normalized)) {
throw Object.assign(new Error(`${label}不存在:${normalized}`), { code: 'COLUMN_NOT_FOUND' });
}
return normalized;
}
function sumColumn(rows, name) {
return rows.reduce((sum, row) => sum + (asNumber(row[name]) ?? 0), 0);
}
function buildReportModel(workbook, worksheet, loaded, args = {}) {
if (loaded.truncated) {
throw Object.assign(new Error('报告禁止基于截断数据生成'), { code: 'REPORT_SOURCE_TRUNCATED' });
}
const metric = requireColumn(loaded, args.metric, '主指标');
const profitMetric = args.profit_metric ? requireColumn(loaded, args.profit_metric, '利润指标') : null;
const quantityMetric = args.quantity_metric ? requireColumn(loaded, args.quantity_metric, '数量指标') : null;
const orderId = args.order_id ? requireColumn(loaded, args.order_id, '订单 ID 字段') : null;
const dimensions = [...new Set((Array.isArray(args.dimensions) ? args.dimensions : []).map(normalizeText).filter(Boolean))];
if (!dimensions.length || dimensions.length > 6) {
throw Object.assign(new Error('报告要求 1-6 个分析维度'), { code: 'INVALID_REPORT_DIMENSIONS' });
}
dimensions.forEach((name) => requireColumn(loaded, name, '分析维度'));
const errors = [];
const warnings = [];
const joins = new Map();
for (const join of Array.isArray(args.joins) ? args.joins : []) {
const dimension = normalizeText(join?.dimension);
if (!dimensions.includes(dimension)) {
throw Object.assign(new Error(`主数据关联维度未被选中:${dimension}`), { code: 'INVALID_REPORT_JOIN' });
}
const joinSheet = normalizeText(join?.sheet);
if (!joinSheet) {
throw Object.assign(new Error(`主数据关联 ${dimension} 缺少 Sheet`), { code: 'INVALID_REPORT_JOIN' });
}
const masterSheet = worksheetByName(workbook, joinSheet);
const master = loadRows(masterSheet, { headerRow: join?.header_row });
const key = requireColumn(master, join?.key, '主数据键');
const fields = [...new Set((Array.isArray(join?.fields) ? join.fields : []).map(normalizeText).filter(Boolean))];
if (!fields.length || fields.length > 6) {
throw Object.assign(new Error(`主数据关联 ${dimension} 要求 1-6 个返回字段`), { code: 'INVALID_REPORT_JOIN' });
}
fields.forEach((field) => requireColumn(master, field, '主数据字段'));
const records = new Map();
for (const row of master.rows) {
const id = normalizeText(row[key]);
if (!id) continue;
if (records.has(id)) {
errors.push({ code: 'DUPLICATE_MASTER_KEY', dimension, key: id, sheet: masterSheet.name });
continue;
}
records.set(id, Object.fromEntries(fields.map((field) => [field, row[field]])));
}
joins.set(dimension, { sheet: masterSheet.name, key, fields, records });
}
const totalMetric = sumColumn(loaded.rows, metric);
const totalProfit = profitMetric ? sumColumn(loaded.rows, profitMetric) : null;
const totalQuantity = quantityMetric ? sumColumn(loaded.rows, quantityMetric) : null;
const uniqueOrders = orderId
? new Set(loaded.rows.map((row) => normalizeText(row[orderId])).filter(Boolean)).size
: loaded.rows.length;
const sections = [];
const topAverage = {};
for (const dimension of dimensions) {
const grouped = new Map();
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() };
current.value += asNumber(row[metric]) ?? 0;
if (profitMetric) current.profit += asNumber(row[profitMetric]) ?? 0;
if (quantityMetric) current.quantity += asNumber(row[quantityMetric]) ?? 0;
current.rows += 1;
if (orderId && !isBlank(row[orderId])) current.orderIds.add(normalizeText(row[orderId]));
grouped.set(key, current);
}
if (grouped.size > 200) {
throw Object.assign(new Error(`维度 ${dimension} 的分组数 ${grouped.size} 超过报告上限 200`), { code: 'REPORT_DIMENSION_TOO_LARGE' });
}
const join = joins.get(dimension);
const items = [...grouped.values()].map((item) => {
const orderCount = orderId ? item.orderIds.size : item.rows;
const masterData = join?.records.get(item.group) ?? null;
if (join && item.group !== '(空)' && !masterData) {
errors.push({ code: 'MISSING_MASTER_DATA', dimension, key: item.group, sheet: join.sheet });
}
return {
group: item.group,
value: roundNumber(item.value),
...(profitMetric ? { profit: roundNumber(item.profit), margin: item.value ? roundNumber(item.profit / item.value) : null } : {}),
...(quantityMetric ? { quantity: roundNumber(item.quantity) } : {}),
rows: item.rows,
orders: orderCount,
averagePerOrder: orderCount ? roundNumber(item.value / orderCount) : null,
...(masterData ? { masterData } : {}),
};
}).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);
if (Math.abs(reconciled - totalMetric) > Math.max(0.01, Math.abs(totalMetric) * 1e-9)) {
errors.push({ code: 'DIMENSION_TOTAL_MISMATCH', dimension, expected: roundNumber(totalMetric), actual: roundNumber(reconciled) });
}
const averageWinner = [...items].sort((a, b) => (b.averagePerOrder ?? -Infinity) - (a.averagePerOrder ?? -Infinity))[0] ?? null;
if (averageWinner) topAverage[dimension] = { group: averageWinner.group, value: averageWinner.averagePerOrder };
sections.push({ dimension, items, total: roundNumber(reconciled) });
}
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) {
warnings.push({ code: 'DUPLICATE_ORDER_IDS', field: orderId, rows: loaded.rows.length, unique: uniqueOrders });
}
const validation = { valid: errors.length === 0, errors, warnings };
return {
schemaVersion: 1,
source: { path: args.path, sheet: worksheet.name, headerRow: loaded.headerRow, rows: loaded.rows.length },
fields: { metric, profitMetric, quantityMetric, orderId, dimensions },
totals: {
metric: roundNumber(totalMetric),
...(profitMetric ? { profit: roundNumber(totalProfit), margin: totalMetric ? roundNumber(totalProfit / totalMetric) : null } : {}),
...(quantityMetric ? { quantity: roundNumber(totalQuantity) } : {}),
rows: loaded.rows.length,
orders: uniqueOrders,
},
sections,
facts: { topAverage },
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');
async function loadWorkbook(requestedPath) {
const resolved = safeRelativeWorkbookPath(workspaceRoot, requestedPath);
const stat = await fs.stat(resolved.absolute).catch(() => null);
if (!stat?.isFile()) throw Object.assign(new Error(`Excel 文件不存在:${resolved.relative}`), { code: 'FILE_NOT_FOUND' });
await assertRealPathWithinWorkspace(workspaceRoot, resolved.absolute);
if (stat.size > maxFileBytes) {
throw Object.assign(new Error(`Excel 文件超过大小上限:${stat.size}/${maxFileBytes}`), { code: 'FILE_TOO_LARGE' });
}
const buffer = await fs.readFile(resolved.absolute);
validateZipBudget(buffer);
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(await normalizeMainNamespacePrefixes(buffer));
return { workbook, source: resolved, sizeBytes: stat.size };
}
return {
async inspect(args = {}) {
const { workbook, source, sizeBytes } = await loadWorkbook(args.path);
const sampleRows = asPositiveInteger(args.sample_rows, DEFAULT_SAMPLE_ROWS, 20);
const sheets = workbook.worksheets.map((worksheet) => {
const loaded = loadRows(worksheet, { headerRow: args.header_row });
const profiles = loaded.headers.map((name) => profileColumn(loaded.rows, name));
return {
name: worksheet.name,
state: worksheet.state ?? 'visible',
rowCount: worksheet.rowCount,
columnCount: worksheet.columnCount,
headerRow: loaded.headerRow,
headers: loaded.headers,
formulaCount: loaded.formulaCount,
mergedRanges: worksheet.model?.merges?.length ?? 0,
hiddenRows: Array.from({ length: worksheet.rowCount }, (_, index) => worksheet.getRow(index + 1)).filter((row) => row.hidden).length,
hiddenColumns: Array.from({ length: worksheet.columnCount }, (_, index) => worksheet.getColumn(index + 1)).filter((column) => column.hidden).length,
dimensions: profiles.filter((item) => item.inferredType !== 'number' && item.unique <= Math.max(50, Math.ceil(loaded.rows.length * 0.8))).map((item) => item.name),
metrics: profiles.filter((item) => item.inferredType === 'number').map((item) => item.name),
profiles,
samples: loaded.rows.slice(0, sampleRows),
truncated: loaded.truncated,
};
});
return { format: 'xlsx', path: source.relative, sizeBytes, sheets };
},
async analyze(args = {}) {
const { workbook, source } = await loadWorkbook(args.path);
const worksheet = worksheetByName(workbook, args.sheet);
const loaded = loadRows(worksheet, { headerRow: args.header_row });
const operation = normalizeText(args.operation || 'summary').toLowerCase();
let result;
if (operation === 'summary') {
result = { columns: loaded.headers.map((name) => profileColumn(loaded.rows, name)) };
} else if (operation === 'correlation') {
result = correlation(loaded.rows, args.x, args.y);
} else if (['group_by', 'top_n', 'trend'].includes(operation)) {
result = {
groupBy: args.group_by,
metric: args.metric ?? null,
aggregation: args.aggregation ?? 'sum',
items: aggregateRows(loaded.rows, {
groupBy: args.group_by,
metric: args.metric,
aggregation: args.aggregation,
limit: args.limit,
sort: operation === 'trend' ? 'group_asc' : 'value_desc',
}),
};
} else {
throw Object.assign(new Error(`不支持的分析操作:${operation}`), { code: 'INVALID_OPERATION' });
}
return { path: source.relative, sheet: worksheet.name, operation, rowCount: loaded.rows.length, truncated: loaded.truncated, result };
},
async chart(args = {}) {
const { workbook, source } = await loadWorkbook(args.path);
const worksheet = worksheetByName(workbook, args.sheet);
const loaded = loadRows(worksheet, { headerRow: args.header_row });
const type = normalizeText(args.type || 'bar').toLowerCase();
if (!['bar', 'line'].includes(type)) {
throw Object.assign(new Error('Phase 1 图表仅支持 bar 或 line'), { code: 'INVALID_CHART_TYPE' });
}
const items = aggregateRows(loaded.rows, {
groupBy: args.group_by,
metric: args.metric,
aggregation: args.aggregation,
limit: args.limit,
sort: type === 'line' ? 'group_asc' : 'value_desc',
});
const output = safeChartOutputPath(workspaceRoot, args.output_path, source.relative);
await prepareSafeOutput(workspaceRoot, output.absolute);
const handle = await fs.open(
output.absolute,
fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_TRUNC | fsConstants.O_NOFOLLOW,
0o600,
);
try {
await handle.writeFile(chartSvg(items, { type, title: args.title || `${worksheet.name} 分析` }), 'utf8');
} finally {
await handle.close();
}
return { path: source.relative, sheet: worksheet.name, type, outputPath: output.relative, items };
},
async report(args = {}) {
const { workbook, source } = await loadWorkbook(args.path);
const worksheet = worksheetByName(workbook, args.sheet);
const loaded = loadRows(worksheet, { headerRow: args.header_row });
const model = buildReportModel(workbook, worksheet, loaded, { ...args, path: source.relative });
if (!model.validation.valid) {
throw Object.assign(new Error('Excel 报告事实校验失败,未生成报告'), {
code: 'REPORT_VALIDATION_FAILED',
detail: model.validation,
});
}
const output = safeReportOutputPath(workspaceRoot, args.output_path, source.relative);
await prepareSafeOutput(workspaceRoot, output.absolute);
const handle = await fs.open(
output.absolute,
fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_TRUNC | fsConstants.O_NOFOLLOW,
0o600,
);
try {
await handle.writeFile(reportHtml(model, { title: args.title }), 'utf8');
} finally {
await handle.close();
}
return { path: source.relative, sheet: worksheet.name, outputPath: output.relative, model };
},
};
}
export const excelAnalysisInternals = {
asNumber,
cellValue,
detectHeaderRow,
profileColumn,
buildReportModel,
safeRelativeWorkbookPath,
safeChartOutputPath,
safeReportOutputPath,
normalizeMainNamespacePrefixes,
validateZipBudget,
};