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>
388 lines
12 KiB
TypeScript
388 lines
12 KiB
TypeScript
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)
|
|
});
|