Files
first-electron-app/src/main/utils/excelConverter.ts
Misaka_Company 9db298c027 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>
2026-02-28 16:48:28 +08:00

424 lines
12 KiB
TypeScript
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.
/**
* 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;
}
}