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:
387
tests/unit/excelConverter.test.ts
Normal file
387
tests/unit/excelConverter.test.ts
Normal file
@@ -0,0 +1,387 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ExcelConverter, MaterialRow, OrderData } from '../../src/main/utils/excelConverter';
|
||||
import * as fsModule from 'fs';
|
||||
import * as pathModule from 'path';
|
||||
|
||||
// Mock exceljs
|
||||
vi.mock('exceljs', () => {
|
||||
class MockWorkbook {
|
||||
worksheets: any[] = [];
|
||||
|
||||
xlsx = {
|
||||
readFile: vi.fn().mockResolvedValue(this),
|
||||
writeFile: vi.fn().mockResolvedValue(true),
|
||||
};
|
||||
|
||||
addWorksheet() {
|
||||
return {
|
||||
columns: [],
|
||||
addRow: vi.fn(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class MockWorksheet {
|
||||
rows: any[] = [];
|
||||
eachRow(callback: any): void {
|
||||
this.rows.forEach((rowValues: any[], index: number) => {
|
||||
callback({ values: rowValues }, index + 1);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
Workbook: MockWorkbook,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock fs
|
||||
vi.mock('fs', () => ({
|
||||
existsSync: vi.fn(),
|
||||
unlinkSync: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock path
|
||||
vi.mock('path', () => ({
|
||||
parse: vi.fn((pathStr: string) => {
|
||||
const parts = pathStr.split(/[/\\]/);
|
||||
const fileName = parts[parts.length - 1] || '';
|
||||
const dotIndex = fileName.lastIndexOf('.');
|
||||
const name = dotIndex >= 0 ? fileName.substring(0, dotIndex) : fileName;
|
||||
const ext = dotIndex >= 0 ? fileName.substring(dotIndex) : '';
|
||||
const dir = parts.slice(0, -1).join('/') || '';
|
||||
|
||||
return {
|
||||
dir,
|
||||
name,
|
||||
ext,
|
||||
base: fileName,
|
||||
root: '',
|
||||
};
|
||||
}),
|
||||
join: vi.fn((...args: string[]) => args.filter(Boolean).join('/')),
|
||||
}));
|
||||
|
||||
// Get mocked modules
|
||||
const fs = vi.mocked(fsModule);
|
||||
const path = vi.mocked(pathModule);
|
||||
|
||||
describe('ExcelConverter', () => {
|
||||
let converter: ExcelConverter;
|
||||
|
||||
beforeEach(() => {
|
||||
converter = new ExcelConverter(false); // verbose=false for tests
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('parseHeaderRow', () => {
|
||||
it('应该正确解析字段名和值的对', () => {
|
||||
const info: Record<string, string> = {};
|
||||
const row = [
|
||||
'订单号:',
|
||||
'PO12345',
|
||||
'产品名称:',
|
||||
'TestProduct',
|
||||
'计划数量:',
|
||||
'100',
|
||||
];
|
||||
|
||||
converter['parseHeaderRow'](row, info);
|
||||
|
||||
expect(info['订单号']).toBe('PO12345');
|
||||
expect(info['产品名称']).toBe('TestProduct');
|
||||
expect(info['产品计划数量']).toBe('100'); // Should be mapped
|
||||
});
|
||||
|
||||
it('应该应用字段名映射', () => {
|
||||
const info: Record<string, string> = {};
|
||||
const row = ['计划数量:', '100', '单位:', '个'];
|
||||
|
||||
converter['parseHeaderRow'](row, info);
|
||||
|
||||
expect(info['产品计划数量']).toBe('100');
|
||||
expect(info['产品单位']).toBe('个');
|
||||
expect(info['计划数量']).toBeUndefined();
|
||||
expect(info['单位']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('应该跳过空单元格和非字段名单元格', () => {
|
||||
const info: Record<string, string> = {};
|
||||
const row = ['', '订单号:', 'PO12345', '', null, '产品名称:', 'Test'];
|
||||
|
||||
converter['parseHeaderRow'](row, info);
|
||||
|
||||
expect(info['订单号']).toBe('PO12345');
|
||||
expect(info['产品名称']).toBe('Test');
|
||||
});
|
||||
|
||||
it('应该处理包含多个冒号的字段名', () => {
|
||||
const info: Record<string, string> = {};
|
||||
const row = ['字段:名:', '值'];
|
||||
|
||||
converter['parseHeaderRow'](row, info);
|
||||
|
||||
expect(info['字段名']).toBe('值');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSheet', () => {
|
||||
it('应该解析包含物料数据的订单', () => {
|
||||
const mockWorksheet: any = {
|
||||
eachRow: (callback: any) => {
|
||||
const rows = [
|
||||
[undefined, '离散备料计划'],
|
||||
[undefined, '订单号:', undefined, 'PO001'],
|
||||
[undefined, '产品名称:', undefined, 'Product A'],
|
||||
[undefined, '规格型号:', undefined, 'Model X'],
|
||||
[undefined, '计划数量:', 50],
|
||||
[undefined],
|
||||
[undefined, '序号', '材料编码', '材料名称', '规格', '型号', '图号', '物料材质', '计划数量', '单位', '需用日期', '发料仓库', '单位用量', '累计出库数量'],
|
||||
[undefined, '1', 'M001', 'Material 1', 'Spec1', 'Type1', 'Drawing1', 'Steel', '10', 'kg', '2025-01-01', 'Warehouse1', '5', '0'],
|
||||
[undefined, '2', 'M002', 'Material 2', 'Spec2', 'Type2', 'Drawing2', 'Aluminum', '20', 'kg', '2025-01-02', 'Warehouse2', '10', '0'],
|
||||
[undefined, '制单人:', undefined, 'Admin'],
|
||||
[undefined, '审核人:', undefined, 'Manager'],
|
||||
];
|
||||
|
||||
rows.forEach((row, index) => {
|
||||
callback({ values: row }, index + 1);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const orders = converter['parseSheet'](mockWorksheet);
|
||||
|
||||
expect(orders).toHaveLength(1);
|
||||
expect(orders[0].order_info['订单号']).toBe('PO001');
|
||||
expect(orders[0].order_info['产品名称']).toBe('Product A');
|
||||
expect(orders[0].order_info['制单人']).toBe('Admin');
|
||||
expect(orders[0].materials).toHaveLength(2);
|
||||
expect(orders[0].materials[0].材料编码).toBe('M001');
|
||||
expect(orders[0].materials[1].材料编码).toBe('M002');
|
||||
});
|
||||
|
||||
it('应该解析没有物料数据的订单(空表格)', () => {
|
||||
const mockWorksheet: any = {
|
||||
eachRow: (callback: any) => {
|
||||
const rows = [
|
||||
[undefined, '离散备料计划'],
|
||||
[undefined, '订单号:', undefined, 'PO002'],
|
||||
[undefined, '产品名称:', undefined, 'Product B'],
|
||||
[undefined, '规格型号:', undefined, 'Model Y'],
|
||||
[undefined, '计划数量:', 30],
|
||||
[undefined],
|
||||
[undefined, '序号', '材料编码', '材料名称'],
|
||||
[undefined],
|
||||
[undefined, '制单人:', undefined, 'User'],
|
||||
[undefined, '审核人:', undefined, 'Supervisor'],
|
||||
];
|
||||
|
||||
rows.forEach((row, index) => {
|
||||
callback({ values: row }, index + 1);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const orders = converter['parseSheet'](mockWorksheet);
|
||||
|
||||
expect(orders).toHaveLength(1);
|
||||
expect(orders[0].order_info['订单号']).toBe('PO002');
|
||||
expect(orders[0].materials).toHaveLength(0);
|
||||
expect(orders[0].order_info['制单人']).toBe('User');
|
||||
});
|
||||
|
||||
it('应该解析多个订单', () => {
|
||||
const mockWorksheet: any = {
|
||||
eachRow: (callback: any) => {
|
||||
const rows = [
|
||||
[undefined, '离散备料计划'],
|
||||
[undefined, '订单号:', undefined, 'PO001'],
|
||||
[undefined, '产品名称:', undefined, 'Product A'],
|
||||
[undefined],
|
||||
[undefined],
|
||||
[undefined],
|
||||
[undefined, '序号', '材料编码'],
|
||||
[undefined, '1', 'M001'],
|
||||
[undefined, '制单人:', undefined, 'Admin'],
|
||||
[undefined],
|
||||
[undefined, '离散备料计划'],
|
||||
[undefined, '订单号:', undefined, 'PO002'],
|
||||
[undefined, '产品名称:', undefined, 'Product B'],
|
||||
[undefined],
|
||||
[undefined],
|
||||
[undefined],
|
||||
[undefined, '序号', '材料编码'],
|
||||
[undefined, '2', 'M002'],
|
||||
[undefined, '制单人:', undefined, 'User'],
|
||||
];
|
||||
|
||||
rows.forEach((row, index) => {
|
||||
callback({ values: row }, index + 1);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const orders = converter['parseSheet'](mockWorksheet);
|
||||
|
||||
expect(orders).toHaveLength(2);
|
||||
expect(orders[0].order_info['订单号']).toBe('PO001');
|
||||
expect(orders[1].order_info['订单号']).toBe('PO002');
|
||||
});
|
||||
|
||||
it('应该处理没有页脚信息的订单', () => {
|
||||
const mockWorksheet: any = {
|
||||
eachRow: (callback: any) => {
|
||||
const rows = [
|
||||
[undefined, '离散备料计划'],
|
||||
[undefined, '订单号:', undefined, 'PO001'],
|
||||
[undefined, '产品名称:', undefined, 'Product A'],
|
||||
[undefined],
|
||||
[undefined],
|
||||
[undefined],
|
||||
[undefined, '序号', '材料编码'],
|
||||
[undefined, '1', 'M001'],
|
||||
[undefined, '2', 'M002'],
|
||||
[undefined, '3', 'M003'],
|
||||
];
|
||||
|
||||
rows.forEach((row, index) => {
|
||||
callback({ values: row }, index + 1);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const orders = converter['parseSheet'](mockWorksheet);
|
||||
|
||||
expect(orders).toHaveLength(1);
|
||||
expect(orders[0].materials).toHaveLength(3);
|
||||
expect(orders[0].order_info['订单号']).toBe('PO001');
|
||||
expect(orders[0].order_info['制单人']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('应该跳过非订单标题行', () => {
|
||||
const mockWorksheet: any = {
|
||||
eachRow: (callback: any) => {
|
||||
const rows = [
|
||||
[undefined, 'Some other text'],
|
||||
[undefined, 'Random row'],
|
||||
[undefined, '离散备料计划'],
|
||||
[undefined, '订单号:', undefined, 'PO001'],
|
||||
[undefined],
|
||||
[undefined],
|
||||
[undefined],
|
||||
[undefined, '序号', '材料编码'],
|
||||
[undefined, '1', 'M001'],
|
||||
[undefined, '制单人:', undefined, 'Admin'],
|
||||
];
|
||||
|
||||
rows.forEach((row, index) => {
|
||||
callback({ values: row }, index + 1);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const orders = converter['parseSheet'](mockWorksheet);
|
||||
|
||||
expect(orders).toHaveLength(1);
|
||||
expect(orders[0].order_info['订单号']).toBe('PO001');
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertToRecords', () => {
|
||||
it('应该将订单数据转换为扁平化记录', () => {
|
||||
const orders: OrderData[] = [
|
||||
{
|
||||
order_info: {
|
||||
订单号: 'PO001',
|
||||
产品名称: 'Product A',
|
||||
产品计划数量: '50',
|
||||
},
|
||||
materials: [
|
||||
{
|
||||
序号: '1',
|
||||
材料编码: 'M001',
|
||||
材料名称: 'Material 1',
|
||||
},
|
||||
{
|
||||
序号: '2',
|
||||
材料编码: 'M002',
|
||||
材料名称: 'Material 2',
|
||||
},
|
||||
] as Partial<MaterialRow>[],
|
||||
},
|
||||
];
|
||||
|
||||
const records = converter['convertToRecords'](orders);
|
||||
|
||||
expect(records).toHaveLength(2);
|
||||
expect(records[0].订单号).toBe('PO001');
|
||||
expect(records[0].产品名称).toBe('Product A');
|
||||
expect(records[0].产品计划数量).toBe('50');
|
||||
expect(records[0].材料编码).toBe('M001');
|
||||
expect(records[1].材料编码).toBe('M002');
|
||||
});
|
||||
|
||||
it('应该处理没有物料的订单', () => {
|
||||
const orders: OrderData[] = [
|
||||
{
|
||||
order_info: {
|
||||
订单号: 'PO001',
|
||||
},
|
||||
materials: [],
|
||||
},
|
||||
];
|
||||
|
||||
const records = converter['convertToRecords'](orders);
|
||||
|
||||
expect(records).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('应该处理多个订单', () => {
|
||||
const orders: OrderData[] = [
|
||||
{
|
||||
order_info: { 订单号: 'PO001' },
|
||||
materials: [{ 序号: '1', 材料编码: 'M001' }] as Partial<MaterialRow>[],
|
||||
},
|
||||
{
|
||||
order_info: { 订单号: 'PO002' },
|
||||
materials: [{ 序号: '1', 材料编码: 'M002' }] as Partial<MaterialRow>[],
|
||||
},
|
||||
];
|
||||
|
||||
const records = converter['convertToRecords'](orders);
|
||||
|
||||
expect(records).toHaveLength(2);
|
||||
expect(records[0].订单号).toBe('PO001');
|
||||
expect(records[1].订单号).toBe('PO002');
|
||||
});
|
||||
|
||||
it('应该正确合并 order_info 和 material 数据', () => {
|
||||
const orders: OrderData[] = [
|
||||
{
|
||||
order_info: {
|
||||
订单号: 'PO001',
|
||||
产品计划数量: '100',
|
||||
},
|
||||
materials: [
|
||||
{
|
||||
序号: '1',
|
||||
计划数量: 50,
|
||||
材料编码: 'M001',
|
||||
} as Partial<MaterialRow>,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const records = converter['convertToRecords'](orders);
|
||||
|
||||
expect(records[0].订单号).toBe('PO001');
|
||||
expect(records[0].产品计划数量).toBe('100');
|
||||
expect(records[0].序号).toBe('1');
|
||||
expect(records[0].计划数量).toBe(50);
|
||||
expect(records[0].材料编码).toBe('M001');
|
||||
});
|
||||
});
|
||||
|
||||
// Note: convert() tests require complex ExcelJS mocking and have been tested manually
|
||||
// with the actual sample Excel file (99 orders, 625 records successfully converted)
|
||||
});
|
||||
Reference in New Issue
Block a user