/** * 状态-结果数据报表 * * 入口:data.json * 每条数据: * - tag:类型标识(不同 tag 的 state 项数量与内容不同) * - state:该类型下的状态参数(name / label / value) * - data:该状态下的结果类型及数量({ label, count }) * * 按 tag 分组输出 Word(.docx)报表 */ import * as fs from 'fs'; import * as path from 'path'; import AdmZip = require('adm-zip'); // --------------------------------------------------------------------------- // 数据结构 // --------------------------------------------------------------------------- /** 状态参数项 */ export interface StateItem { name: string; label: string; value?: string | number; } /** 结果类型及数量 */ export interface ResultDataItem { label: string; count: number; } /** 单条原始数据 */ export interface StateDataRecord { /** 类型标识,决定 state 结构 */ tag: string; state: StateItem[]; data: ResultDataItem[]; } /** 解析后的结果类型数量 */ export interface ResultTypeCount { type: string; count: number; } /** 单条报表行 */ export interface ReportRow { index: number; /** 状态:label → 显示值 */ state: Record; /** 结果类型 → 数量 */ results: Record; /** 本条结果合计 */ resultTotal: number; } /** 单个 tag 的分组报表 */ export interface TagGroupReport { tag: string; recordCount: number; /** 本 tag 的状态列(按出现顺序,使用 label) */ stateLabels: string[]; /** 本 tag 的结果类型列 */ resultTypes: string[]; /** 本 tag 按结果类型汇总 */ resultTotals: Record; /** 本 tag 明细行 */ rows: ReportRow[]; } /** 全量报表(按 tag 分组) */ export interface StateDataReport { generatedAt: string; /** 总记录数 */ recordCount: number; /** tag 种数 */ tagCount: number; /** 全量按结果类型汇总 */ resultTotals: Record; /** 按 tag 分组(保持首次出现顺序) */ byTag: TagGroupReport[]; } // --------------------------------------------------------------------------- // 工具 // --------------------------------------------------------------------------- function escapeXml(text: string | number): string { return String(text) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } /** 归一化结果项 → { type, count }(兼容旧字符串格式 "压力结果: 1") */ export function parseResultItem(raw: ResultDataItem | string): ResultTypeCount { if (typeof raw === 'string') { const idx = raw.lastIndexOf(':'); if (idx < 0) { return { type: raw.trim(), count: 0 }; } const type = raw.slice(0, idx).trim(); const count = Number(raw.slice(idx + 1).trim()); return { type, count: Number.isFinite(count) ? count : 0 }; } const count = Number(raw.count); return { type: String(raw.label ?? '').trim(), count: Number.isFinite(count) ? count : 0, }; } function formatStateValue(value: string | number | undefined): string { if (value === undefined || value === null || value === '') { return '-'; } return String(value); } function sumRecord(map: Record): number { return Object.values(map).reduce((a, b) => a + b, 0); } // --------------------------------------------------------------------------- // 核心统计 // --------------------------------------------------------------------------- /** * 构建单个 tag 分组报表 */ function buildTagGroup(tag: string, records: StateDataRecord[]): TagGroupReport { const stateLabelOrder: string[] = []; const stateLabelSet = new Set(); const resultTypeOrder: string[] = []; const resultTypeSet = new Set(); const resultTotals: Record = {}; const rows: ReportRow[] = []; for (let i = 0; i < records.length; i++) { const record = records[i]; const state: Record = {}; const results: Record = {}; let resultTotal = 0; for (const item of record.state ?? []) { if (!stateLabelSet.has(item.label)) { stateLabelSet.add(item.label); stateLabelOrder.push(item.label); } state[item.label] = formatStateValue(item.value); } for (const raw of record.data ?? []) { const { type, count } = parseResultItem(raw); if (!type) { continue; } if (!resultTypeSet.has(type)) { resultTypeSet.add(type); resultTypeOrder.push(type); } results[type] = (results[type] ?? 0) + count; resultTotals[type] = (resultTotals[type] ?? 0) + count; resultTotal += count; } rows.push({ index: i + 1, state, results, resultTotal, }); } return { tag, recordCount: records.length, stateLabels: stateLabelOrder, resultTypes: resultTypeOrder, resultTotals, rows, }; } /** * 由 data.json 原始数组构建报表(按 tag 分组) */ export function buildStateDataReport(records: StateDataRecord[]): StateDataReport { const tagOrder: string[] = []; const tagMap = new Map(); for (const record of records) { const tag = record.tag != null && String(record.tag) !== '' ? String(record.tag) : '(未标注)'; if (!tagMap.has(tag)) { tagMap.set(tag, []); tagOrder.push(tag); } tagMap.get(tag)!.push(record); } const byTag = tagOrder.map((tag) => buildTagGroup(tag, tagMap.get(tag)!)); const resultTotals: Record = {}; for (const group of byTag) { for (const [type, count] of Object.entries(group.resultTotals)) { resultTotals[type] = (resultTotals[type] ?? 0) + count; } } return { generatedAt: new Date().toISOString(), recordCount: records.length, tagCount: byTag.length, resultTotals, byTag, }; } /** * 从 data.json 文件路径加载并构建报表 */ // export function buildReportFromFile(jsonPath: string): StateDataReport { // const abs = path.resolve(jsonPath); // const raw = fs.readFileSync(abs, 'utf8'); // const data = JSON.parse(raw) as StateDataRecord[]; // if (!Array.isArray(data)) { // throw new Error(`期望 data.json 为数组,实际为: ${typeof data}`); // } // return buildStateDataReport(data); // } /** * 文本格式输出(控制台 / 日志) */ export function formatStateDataReport(report: StateDataReport): string { const lines: string[] = [ '========== 状态结果数据报表 ==========', `生成时间: ${report.generatedAt}`, `总记录数: ${report.recordCount}`, `类型(tag)数: ${report.tagCount}`, '', '--- 全量结果类型汇总 ---', ]; for (const [type, count] of Object.entries(report.resultTotals)) { lines.push(` ${type}: ${count}`); } for (const group of report.byTag) { lines.push( '', `--- [${group.tag}] 记录数=${group.recordCount}, 状态列=${group.stateLabels.length} ---`, ); lines.push(` 状态列: ${group.stateLabels.join('、')}`); lines.push(' 结果汇总:'); for (const type of group.resultTypes) { lines.push(` ${type}: ${group.resultTotals[type] ?? 0}`); } lines.push(' 明细(前 3 条):'); for (const row of group.rows.slice(0, 3)) { const stateStr = group.stateLabels .map((l) => `${l}=${row.state[l] ?? '-'}`) .join(', '); const resultStr = group.resultTypes .map((t) => `${t}=${row.results[t] ?? 0}`) .join(', '); lines.push(` #${row.index} | ${stateStr} | ${resultStr}`); } if (group.rows.length > 3) { lines.push(` ... 共 ${group.rows.length} 条`); } } lines.push('', '======================================'); return lines.join('\n'); } // --------------------------------------------------------------------------- // Word (.docx) 导出 // --------------------------------------------------------------------------- function wParagraph( text: string, opts?: { bold?: boolean; size?: number; center?: boolean; spacingAfter?: number; }, ): string { const size = opts?.size ?? 21; const bold = opts?.bold ? '' : ''; const align = opts?.center ? '' : ''; const spacing = opts?.spacingAfter != null ? `` : ''; return ` ${align}${spacing} ${bold} ${escapeXml(text)} `; } function wTableCell( text: string, opts?: { bold?: boolean; width?: number; shade?: string; center?: boolean; }, ): string { const width = opts?.width ?? 1000; const bold = opts?.bold ? '' : ''; const shade = opts?.shade ? `` : ''; const align = opts?.center ? '' : ''; return ` ${shade} ${align} ${bold} ${escapeXml(text)} `; } function wTableRow(cells: string[]): string { return `${cells.join('')}`; } function wTable(rows: string[], totalWidth: number): string { return ` ${rows.join('')} `; } /** 生成某个 tag 的明细表 */ function buildTagDetailTable(group: TagGroupReport): string { const indexW = 600; const usable = 15000; const remain = usable - indexW; const colCount = group.stateLabels.length + group.resultTypes.length + 1; const colW = Math.max(700, Math.floor(remain / Math.max(colCount, 1))); const totalW = indexW + colW * colCount; const headerCells = [ wTableCell('序号', { bold: true, shade: 'E2EFDA', center: true, width: indexW, }), ...group.stateLabels.map((label) => wTableCell(label, { bold: true, shade: 'D9E2F3', center: true, width: colW, }), ), ...group.resultTypes.map((type) => wTableCell(type, { bold: true, shade: 'FCE4D6', center: true, width: colW, }), ), wTableCell('合计', { bold: true, shade: 'FFF2CC', center: true, width: colW, }), ]; const body: string[] = []; for (const row of group.rows) { body.push( wTableRow([ wTableCell(String(row.index), { center: true, width: indexW }), ...group.stateLabels.map((label) => wTableCell(row.state[label] ?? '-', { center: true, width: colW }), ), ...group.resultTypes.map((type) => wTableCell(String(row.results[type] ?? 0), { center: true, width: colW, }), ), wTableCell(String(row.resultTotal), { center: true, width: colW }), ]), ); } const grandTotal = sumRecord(group.resultTotals); body.push( wTableRow([ wTableCell('合计', { bold: true, shade: 'FFF2CC', center: true, width: indexW, }), ...group.stateLabels.map(() => wTableCell('', { shade: 'FFF2CC', width: colW }), ), ...group.resultTypes.map((type) => wTableCell(String(group.resultTotals[type] ?? 0), { bold: true, shade: 'FFF2CC', center: true, width: colW, }), ), wTableCell(String(grandTotal), { bold: true, shade: 'FFF2CC', center: true, width: colW, }), ]), ); return wTable([wTableRow(headerCells), ...body], totalW); } function buildDocumentXml(report: StateDataReport): string { const parts: string[] = []; const resultSum = sumRecord(report.resultTotals); parts.push( wParagraph('状态结果数据报表', { bold: true, size: 36, center: true, spacingAfter: 200, }), ); parts.push( wParagraph(`生成时间:${report.generatedAt}`, { center: true, size: 18, spacingAfter: 300, }), ); // 一、总体概况 parts.push(wParagraph('一、总体概况', { bold: true, size: 26, spacingAfter: 160 })); parts.push( wTable( [ wTableRow([ wTableCell('总记录数', { bold: true, shade: 'D9E2F3', center: true, width: 2700, }), wTableCell('状态类型数', { bold: true, shade: 'D9E2F3', center: true, width: 2700, }), wTableCell('状态类型种数', { bold: true, shade: 'D9E2F3', center: true, width: 2700, }), wTableCell('结果数量合计', { bold: true, shade: 'D9E2F3', center: true, width: 2700, }), ]), wTableRow([ wTableCell(String(report.recordCount), { center: true, width: 2700 }), wTableCell(String(report.tagCount), { center: true, width: 2700 }), wTableCell(String(Object.keys(report.resultTotals).length), { center: true, width: 2700, }), wTableCell(String(resultSum), { center: true, width: 2700 }), ]), ], 10800, ), ); parts.push(wParagraph('', { spacingAfter: 200 })); // 二、按 tag 汇总 parts.push( wParagraph('二、按状态类型汇总', { bold: true, size: 26, spacingAfter: 160 }), ); { const header = wTableRow([ wTableCell('状态类型', { bold: true, shade: 'D9E2F3', center: true, width: 2200, }), wTableCell('记录数', { bold: true, shade: 'D9E2F3', center: true, width: 1200, }), wTableCell('状态项数', { bold: true, shade: 'D9E2F3', center: true, width: 1200, }), wTableCell('状态项', { bold: true, shade: 'D9E2F3', center: true, width: 3200, }), wTableCell('结果分布', { bold: true, shade: 'D9E2F3', center: true, width: 3000, }), ]); const body = report.byTag.map((g) => { const dist = g.resultTypes .map((t) => `${t}: ${g.resultTotals[t] ?? 0}`) .join(';'); return wTableRow([ wTableCell(g.tag, { center: true, width: 2200 }), wTableCell(String(g.recordCount), { center: true, width: 1200 }), wTableCell(String(g.stateLabels.length), { center: true, width: 1200 }), wTableCell(g.stateLabels.join('、'), { width: 3200 }), wTableCell(dist || '-', { width: 3000 }), ]); }); parts.push(wTable([header, ...body], 10800)); } parts.push(wParagraph('', { spacingAfter: 200 })); // 三、全量结果类型汇总 parts.push( wParagraph('三、全量结果类型汇总', { bold: true, size: 26, spacingAfter: 160 }), ); { const types = Object.keys(report.resultTotals); const header = wTableRow([ wTableCell('序号', { bold: true, shade: 'D9E2F3', center: true, width: 1500 }), wTableCell('结果类型', { bold: true, shade: 'D9E2F3', center: true, width: 4650, }), wTableCell('数量合计', { bold: true, shade: 'D9E2F3', center: true, width: 4650, }), ]); const body = types.map((type, i) => wTableRow([ wTableCell(String(i + 1), { center: true, width: 1500 }), wTableCell(type, { center: true, width: 4650 }), wTableCell(String(report.resultTotals[type] ?? 0), { center: true, width: 4650, }), ]), ); parts.push(wTable([header, ...body], 10800)); } parts.push(wParagraph('', { spacingAfter: 200 })); // 四、按 tag 明细(各自独立表头/列) parts.push( wParagraph('四、按状态类型明细', { bold: true, size: 26, spacingAfter: 160 }), ); report.byTag.forEach((group, idx) => { parts.push( wParagraph( `${idx + 1}. ${group.tag}(${group.recordCount} 条,状态项 ${group.stateLabels.length} 个)`, { bold: true, size: 22, spacingAfter: 120 }, ), ); parts.push( wParagraph(`状态项:${group.stateLabels.join('、')}`, { size: 16, spacingAfter: 80, }), ); parts.push(buildTagDetailTable(group)); parts.push(wParagraph('', { spacingAfter: 240 })); }); return ` ${parts.join('\n')} `; } const CONTENT_TYPES_XML = ` `; const RELS_XML = ` `; const DOCUMENT_RELS_XML = ` `; /** * 将报表打包为 Word (.docx) 二进制 Buffer */ export function buildStateDataReportDocx(report: StateDataReport): Buffer { const zip = new AdmZip(); zip.addFile('[Content_Types].xml', Buffer.from(CONTENT_TYPES_XML, 'utf8')); zip.addFile('_rels/.rels', Buffer.from(RELS_XML, 'utf8')); zip.addFile('word/document.xml', Buffer.from(buildDocumentXml(report), 'utf8')); zip.addFile('word/_rels/document.xml.rels', Buffer.from(DOCUMENT_RELS_XML, 'utf8')); return zip.toBuffer(); } /** * 生成并保存 Word 报表 * @returns 写入的绝对路径 */ export function saveStateDataReportDocx( report: StateDataReport, outputPath?: string, ): string { const filePath = outputPath ?? path.resolve( process.cwd(), `状态结果数据报表_${new Date().toISOString().slice(0, 10)}.docx`, ); const buf = buildStateDataReportDocx(report); fs.writeFileSync(filePath, buf); return filePath; }