Migrate ExcelConverter from Python to TypeScript

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>
This commit is contained in:
Misaka_Company
2026-02-28 16:48:28 +08:00
parent d8709d83ae
commit 9db298c027
7 changed files with 1752 additions and 25 deletions

View File

@@ -0,0 +1,423 @@
/**
* 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;
}
}