Replace pandas+openpyxl with exceljs for Excel file processing. This migration enables seamless integration with the Electron main process and maintains 1:1 functional parity with the Python implementation. Key changes: - Add exceljs dependency (v4.4.0) - Implement ExcelConverter class in TypeScript with strict types - Add comprehensive unit tests (13 test cases, all passing) - Support field name mapping, order parsing, and material extraction - Handle empty data tables and file conflicts gracefully Verification: - Successfully tested with sample Excel file (99 orders, 625 records) - All unit tests passing - Output format matches Python version Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
424 lines
12 KiB
TypeScript
424 lines
12 KiB
TypeScript
/**
|
||
* 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<string, string>;
|
||
materials: Partial<MaterialRow>[];
|
||
}
|
||
|
||
/**
|
||
* 转换器配置选项
|
||
*/
|
||
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<string, string> = {
|
||
计划数量: '产品计划数量',
|
||
单位: '产品单位',
|
||
};
|
||
|
||
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<ConverterResult> {
|
||
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<string, string> = {};
|
||
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<MaterialRow>[] = [];
|
||
const footerInfo: Record<string, string> = {};
|
||
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<MaterialRow>[] = [];
|
||
const footerInfo: Record<string, string> = {};
|
||
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<MaterialRow> = {
|
||
序号: 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<string, string>): 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;
|
||
}
|
||
}
|