/** * Excel 报表数据转换工具组件 * 将 Excel 报表数据转换为数据库记录形式 */ import * as fs from 'fs'; import * as path from 'path'; import * as ExcelJS from 'exceljs'; /** * 材料数据行结构 */ export interface MaterialRow { '序号': string | number; '材料编码': string; '材料名称': string; '规格': string; '型号': string; '图号': string; '物料材质': string; '计划数量': number; '单位': string; '需用日期': string; '发料仓库': string; '单位用量': number; '累计出库数量': number; [key: string]: any; // Allow order_info fields } /** * 订单数据结构 */ export interface OrderData { order_info: Record; materials: Partial[]; } /** * 转换器配置选项 */ export interface ConverterOptions { output?: string; verbose?: boolean; } /** * 转换结果 */ export interface ConverterResult { records: MaterialRow[]; orderCount: number; rowCount: number; outputFile?: string; } /** * Excel 报表数据转换器 */ export class ExcelConverter { /** * 字段名称映射(解决字段名冲突) */ private static readonly FIELD_NAME_MAPPING: Record = { 计划数量: '产品计划数量', 单位: '产品单位', }; private verbose: boolean; constructor(verbose: boolean = true) { this.verbose = verbose; } /** * 打印日志(如果 verbose=true) */ private print(...args: any[]): void { if (this.verbose) { console.log(...args); } } /** * 转换 Excel 文件 * * @param inputPath 输入文件路径 * @param options 转换选项 * @returns 转换结果 */ public async convert( inputPath: string, options: ConverterOptions = {} ): Promise { const verbose = options.verbose ?? this.verbose; const outputFile = options.output ? this.handleOutputFile(options.output) : undefined; // 读取工作簿 const workbook = new ExcelJS.Workbook(); await workbook.xlsx.readFile(inputPath); const worksheet = workbook.worksheets[0]; // 使用活动工作表 // 解析订单数据 const orders = this.parseSheet(worksheet); // 转换为扁平化记录 const records = this.convertToRecords(orders); // 保存文件 if (outputFile && records.length > 0) { const outputWorkbook = new ExcelJS.Workbook(); const outputWorksheet = outputWorkbook.addWorksheet('Sheet1'); // 添加标题行 if (records.length > 0) { const columns = Object.keys(records[0]); outputWorksheet.columns = columns.map((col) => ({ header: col, key: col, })); // 添加数据行 records.forEach((record) => { outputWorksheet.addRow(record); }); } await outputWorkbook.xlsx.writeFile(outputFile); } // 打印汇总报告 this.print('='.repeat(60)); this.print('转换完成'); this.print('='.repeat(60)); this.print(`订单数: ${orders.length}`); this.print(`数据行数: ${records.length}`); this.print(`输出文件: ${outputFile ?? 'N/A'}`); this.print('='.repeat(60)); return { records, orderCount: orders.length, rowCount: records.length, outputFile, }; } /** * 处理输出文件,如果文件存在则尝试删除 * * @param outputPath 输出文件路径 * @returns 实际使用的输出文件路径 */ private handleOutputFile(outputPath: string): string { if (fs.existsSync(outputPath)) { try { fs.unlinkSync(outputPath); } catch (error: any) { if (error.code === 'EPERM' || error.code === 'EACCES') { this.print( `警告: 无法删除 ${outputPath},可能文件被其他程序打开` ); // 修改文件名 const parsedPath = path.parse(outputPath); outputPath = path.join( parsedPath.dir, `${parsedPath.name}_new${parsedPath.ext}` ); } } } return outputPath; } /** * 解析一个工作表,返回所有订单的数据 * * 每个订单包含: * - order_info: 订单头信息(包括页脚) * - materials: 物料数据列表 * * @param worksheet ExcelJS 工作表对象 * @returns 订单列表 */ private parseSheet(worksheet: ExcelJS.Worksheet): OrderData[] { const orders: OrderData[] = []; // 获取所有行数据 const allRows: any[][] = []; worksheet.eachRow((row, rowNumber) => { const values = row.values as any[]; allRows.push(values); }); // 逐行扫描,按订单结构解析 let i = 0; while (i < allRows.length) { const row = allRows[i]; // 检查是否是订单标题行 // ExcelJS的row.values数组第一个元素(索引0)通常是undefined,实际数据从索引1开始 const firstCell = row[1]; if (firstCell && String(firstCell).includes('离散备料计划')) { this.print(`找到订单标题行,行号: ${i + 1}`); // 解析订单头信息(接下来的4行) const orderInfo: Record = {}; for (let j = 1; j <= 4; j++) { if (i + j < allRows.length && allRows[i + j]) { this.parseHeaderRow(allRows[i + j], orderInfo); } } this.print(`订单信息: ${JSON.stringify(orderInfo)}`); // 查找表格标题行(搜索"序号") let tableRow = i + 5; while ( tableRow < allRows.length && allRows[tableRow] && allRows[tableRow][1] !== '序号' ) { tableRow += 1; } this.print(`表格标题行: ${tableRow + 1}, 第2列值: "${allRows[tableRow]?.[1]}"`); // 检查是否是表格标题行 if ( tableRow < allRows.length && allRows[tableRow] && allRows[tableRow][1] === '序号' ) { // 检查表头下一行是否为空,判断是否存在数据 const nextRow = tableRow + 1; const nextRowData = nextRow < allRows.length ? allRows[nextRow] : null; const isEmptyRow = nextRowData && !this.isRowWithData(nextRowData); this.print(`下一行: ${nextRow + 1}, 是否为空: ${!!isEmptyRow}`); if (isEmptyRow) { // 没有数据,查找页脚信息 this.print('订单无物料数据(空表格)'); const materials: Partial[] = []; const footerInfo: Record = {}; let dataRow = nextRow + 1; while (dataRow < allRows.length && allRows[dataRow]) { const firstCell = allRows[dataRow][1]; if ( firstCell && (String(firstCell).includes('制单人') || String(firstCell).includes('打印人')) ) { this.parseHeaderRow(allRows[dataRow], footerInfo); if (dataRow + 1 < allRows.length && allRows[dataRow + 1]) { this.parseHeaderRow(allRows[dataRow + 1], footerInfo); } break; } dataRow += 1; } orders.push({ order_info: { ...orderInfo, ...footerInfo }, materials, }); } else { // 有数据,开始提取物料 this.print('开始提取物料数据...'); const materials: Partial[] = []; const footerInfo: Record = {}; let dataRow = tableRow + 1; while (dataRow < allRows.length && allRows[dataRow]) { const currentRow = allRows[dataRow]; const firstCell = currentRow[1]; // 检查是否是页脚信息(制单人、打印人) if (firstCell && String(firstCell).includes('制单人')) { // 解析页脚信息 this.parseHeaderRow(currentRow, footerInfo); if (dataRow + 1 < allRows.length && allRows[dataRow + 1]) { this.parseHeaderRow(allRows[dataRow + 1], footerInfo); } this.print('找到页脚信息,停止提取物料'); break; } // 提取物料数据 if (this.isRowWithData(currentRow)) { const material: Partial = { 序号: currentRow[1], 材料编码: currentRow[2], 材料名称: currentRow[3], 规格: currentRow[4], 型号: currentRow[5], 图号: currentRow[6], 物料材质: currentRow[7], 计划数量: currentRow[8], 单位: currentRow[9], 需用日期: currentRow[10], 发料仓库: currentRow[11], 单位用量: currentRow[12], 累计出库数量: currentRow[13], }; this.print(` 提取物料: ${material.序号} - ${material.材料名称}`); materials.push(material); } dataRow += 1; } this.print(`共提取 ${materials.length} 条物料数据`); orders.push({ order_info: { ...orderInfo, ...footerInfo }, materials, }); } } } i += 1; } return orders; } /** * 检查行是否有数据(非空) */ private isRowWithData(row: any[]): boolean { if (!row || row.length === 0) return false; // 检查是否有任何非undefined/null/空字符串的值 return row.some( (cell) => cell !== undefined && cell !== null && String(cell).trim() !== '' ); } /** * 解析订单头信息的一行(字段名和值交错排列) * * @param row 行数据 * @param info 存储解析结果的字典 */ private parseHeaderRow(row: any[], info: Record): void { let i = 0; while (i < row.length) { const cell = row[i]; if (cell && String(cell).trim() && String(cell).includes(':')) { // 找到字段名 let fieldName = String(cell).replace(/:/g, '').trim(); // 应用字段名映射 if (fieldName in ExcelConverter.FIELD_NAME_MAPPING) { fieldName = ExcelConverter.FIELD_NAME_MAPPING[fieldName]; } // 跳过空单元格,找到第一个非字段名的值 let j = i + 1; while ( j < row.length && (!row[j] || !String(row[j]).trim() || String(row[j]).includes(':')) ) { j += 1; } if ( j < row.length && row[j] && !String(row[j]).includes(':') ) { info[fieldName] = String(row[j]).trim(); } // 跳过已处理的值,继续找下一个字段名 i = j + 1; } else { i += 1; } } } /** * 将订单数据转换为扁平化的记录数组 * * @param orders 订单列表 * @returns 扁平化的记录数组 */ private convertToRecords(orders: OrderData[]): MaterialRow[] { const allRecords: MaterialRow[] = []; for (const order of orders) { const { order_info: orderInfo, materials } = order; for (const material of materials) { const record: MaterialRow = { 序号: '', 材料编码: '', 材料名称: '', 规格: '', 型号: '', 图号: '', 物料材质: '', 计划数量: 0, 单位: '', 需用日期: '', 发料仓库: '', 单位用量: 0, 累计出库数量: 0, ...orderInfo, ...material, } as MaterialRow; allRecords.push(record); } } return allRecords; } }