From d9e0091602e378ba009e256d63c782be409e3652 Mon Sep 17 00:00:00 2001 From: Misaka Date: Sun, 1 Mar 2026 13:01:50 +0800 Subject: [PATCH] feat: implement Excel parser service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement Excel parsing module for ERP exported files following TDD principles. Key features: - Parse Excel files with multiple orders per file - Extract order header information (production order, product code, etc.) - Extract material data rows with 13 fields - Handle empty orders gracefully - Detect footer rows (制单人/打印人) - Map Chinese field names to English property names - Support field name mapping from Python reference Implementation: - ExcelParser class with parse() method - DiscreteMaterialPlan and ExcelParseOptions types - OrderHeader interface for order metadata - Test fixtures with realistic Excel structure - Comprehensive unit tests (3 tests, all passing) Reference: playwrite/utils/excel_converter.py Co-Authored-By: Claude Sonnet 4.5 --- src/main/services/excel/excel-parser.ts | 354 ++++++++++++++++++++++++ src/main/types/excel.types.ts | 113 ++++++++ tests/fixtures/create-fixtures.ts | 156 +++++++++++ tests/fixtures/test-empty-orders.xlsx | Bin 0 -> 6918 bytes tests/fixtures/test-export.xlsx | Bin 0 -> 7182 bytes tests/unit/excel-parser.test.ts | 59 ++++ 6 files changed, 682 insertions(+) create mode 100644 src/main/services/excel/excel-parser.ts create mode 100644 src/main/types/excel.types.ts create mode 100644 tests/fixtures/create-fixtures.ts create mode 100644 tests/fixtures/test-empty-orders.xlsx create mode 100644 tests/fixtures/test-export.xlsx create mode 100644 tests/unit/excel-parser.test.ts diff --git a/src/main/services/excel/excel-parser.ts b/src/main/services/excel/excel-parser.ts new file mode 100644 index 0000000..2f87a57 --- /dev/null +++ b/src/main/services/excel/excel-parser.ts @@ -0,0 +1,354 @@ +import ExcelJS from 'exceljs'; +import type { + DiscreteMaterialPlan, + ExcelParseOptions, + OrderHeader, +} from '../../types/excel.types'; +import path from 'path'; + +/** + * Excel Parser Service + * Parses exported ERP Excel files into structured data + * + * Reference: playwrite/utils/excel_converter.py + * + * Excel Structure: + * - Multiple orders per file (each starting with "离散备料计划") + * - Each order has: header info (4 lines) + table header + data rows + footer + * - Material rows have 13 columns from "序号" to "累计出库数量" + */ +export class ExcelParser { + // Field name mapping for Python compatibility (from Python code) + private FIELD_NAME_MAPPING: Record = { + 计划数量: '产品计划数量', + 单位: '产品单位', + }; + + // Mapping from Chinese field names to English property names + private CHINESE_TO_ENGLISH_MAPPING: Record = { + 生产部门: 'productionDepartment', + 生产订单: 'productionOrder', + 产品编码: 'productCode', + 产品名称: 'productName', + 产品规格: 'productSpecification', + 计划数量: 'plannedQuantity', + 单位: 'unit', + 需用日期: 'requiredDate', + 制单人: 'creator', + 打印人: 'printer', + 打印日期: 'printDate', + // Mapped fields (after FIELD_NAME_MAPPING) + 产品计划数量: 'plannedQuantity', + 产品单位: 'unit', + }; + + private verbose: boolean; + + constructor(options: ExcelParseOptions = {}) { + this.verbose = options.verbose || false; + } + + private log(...args: any[]): void { + if (this.verbose) { + console.log('[ExcelParser]', ...args); + } + } + + /** + * Parse Excel file and extract material plans + */ + async parse(filePath: string, options: ExcelParseOptions = {}): Promise { + this.log('Parsing Excel file:', filePath); + + const workbook = new ExcelJS.Workbook(); + await workbook.xlsx.readFile(filePath); + + const worksheet = workbook.worksheets[0]; + if (!worksheet) { + throw new Error('No worksheet found in file'); + } + + const plans: DiscreteMaterialPlan[] = []; + const allRows: any[][] = []; + + // Read all rows into memory + worksheet.eachRow((row, rowNumber) => { + allRows.push(row.values as any[]); + }); + + // Parse orders from rows + const orders = this.parseOrders(allRows); + + // Flatten orders into material plans + for (const order of orders) { + const { orderInfo, materials } = order; + + // Skip empty orders if option is set + if (options.skipEmptyOrders && materials.length === 0) { + this.log('Skipping empty order:', orderInfo.productionOrder); + continue; + } + + // Create a material plan for each material row + for (const material of materials) { + const plan: DiscreteMaterialPlan = { + orderNumber: orderInfo.productionOrder || '', + productionId: orderInfo.productCode || '', + materialCode: material.materialCode || '', + materialName: material.materialName || '', + specification: material.specification, + model: material.model, + drawingNumber: material.drawingNumber, + material: material.material, + quantity: material.quantity || 0, + unit: material.unit || '', + requiredDate: material.requiredDate, + warehouse: material.warehouse, + unitUsage: material.unitUsage, + cumulativeOutboundQty: material.cumulativeOutboundQty, + rowNumber: material.rowNumber, + }; + plans.push(plan); + } + } + + this.log(`Parsed ${plans.length} material plans from ${orders.length} orders`); + + return plans; + } + + /** + * Parse orders from all rows + * Reference: _parse_sheet() in Python code + */ + private parseOrders(allRows: any[][]): Array<{ orderInfo: OrderHeader; materials: any[] }> { + const orders: Array<{ orderInfo: OrderHeader; materials: any[] }> = []; + let i = 0; + + while (i < allRows.length) { + const row = allRows[i]; + + // Check if this is an order title row + if (row && row[2] && String(row[2]).includes('离散备料计划')) { + this.log('Found order at row', i + 1); + + // Parse order header info (next 4 rows) + const orderInfo: OrderHeader = {}; + for (let j = 1; j <= 4; j++) { + if (i + j < allRows.length && allRows[i + j]) { + this.parseHeaderRow(allRows[i + j], orderInfo); + } + } + + // Skip empty rows to find table header + let tableRow = i + 5; + while ( + tableRow < allRows.length && + (!allRows[tableRow] || !allRows[tableRow][2]) + ) { + tableRow++; + } + + // Check if this is the table header row + if ( + tableRow < allRows.length && + allRows[tableRow] && + allRows[tableRow][2] === '序号' + ) { + // Check if next row is empty (no data) + const nextRow = tableRow + 1; + const isEmptyRow = + nextRow < allRows.length && + allRows[nextRow] && + allRows[nextRow].every( + (cell: any) => cell === null || String(cell).trim() === '' + ); + + if (isEmptyRow) { + // No data, find footer info + this.log('Order has no material data'); + const materials: any[] = []; + const footerInfo: OrderHeader = {}; + let dataRow = nextRow + 1; + + while (dataRow < allRows.length && allRows[dataRow]) { + if ( + allRows[dataRow][2] && + (String(allRows[dataRow][2]).includes('制单人') || + String(allRows[dataRow][2]).includes('打印人')) + ) { + this.parseHeaderRow(allRows[dataRow], footerInfo); + if (dataRow + 1 < allRows.length && allRows[dataRow + 1]) { + this.parseHeaderRow(allRows[dataRow + 1], footerInfo); + } + break; + } + dataRow++; + } + + orders.push({ + orderInfo: { ...orderInfo, ...footerInfo }, + materials, + }); + } else { + // Has data, extract materials + this.log('Order has material data'); + const materials: any[] = []; + const footerInfo: OrderHeader = {}; + let dataRow = tableRow + 1; + + while (dataRow < allRows.length && allRows[dataRow]) { + // Check if CURRENT row is footer info (制单人/打印人) + const isCurrentRowFooter = + allRows[dataRow][2] && + (String(allRows[dataRow][2]).includes('制单人') || + String(allRows[dataRow][2]).includes('打印人')); + + // Check if NEXT row is footer info (to handle empty row before footer) + const isNextRowFooter = + dataRow + 1 < allRows.length && + allRows[dataRow + 1] && + allRows[dataRow + 1][2] && + (String(allRows[dataRow + 1][2]).includes('制单人') || + String(allRows[dataRow + 1][2]).includes('打印人')); + + if (isCurrentRowFooter) { + // Current row is footer, parse it and next row if exists + this.parseHeaderRow(allRows[dataRow], footerInfo); + if (dataRow + 1 < allRows.length && allRows[dataRow + 1]) { + this.parseHeaderRow(allRows[dataRow + 1], footerInfo); + } + this.log(' Found footer row, stopping material parsing'); + break; + } + + if (isNextRowFooter) { + // Next row is footer, parse current row as material first + const material = this.parseMaterialRow(allRows[dataRow], dataRow + 1); + if (material) { + this.log(' Parsed material:', material.materialCode); + materials.push(material); + } + + // Then parse footer rows + this.parseHeaderRow(allRows[dataRow + 1], footerInfo); + if (dataRow + 2 < allRows.length && allRows[dataRow + 2]) { + this.parseHeaderRow(allRows[dataRow + 2], footerInfo); + } + this.log(' Found footer in next row, stopping material parsing'); + break; + } + + // Extract material data + const material = this.parseMaterialRow(allRows[dataRow], dataRow + 1); + if (material) { + this.log(' Parsed material:', material.materialCode); + materials.push(material); + } else { + this.log(' Skipped material row at', dataRow + 1); + } + + dataRow++; + } + + orders.push({ + orderInfo: { ...orderInfo, ...footerInfo }, + materials, + }); + } + } + + // Move to next row after this order + i = tableRow + 1; + } else { + i++; + } + } + + return orders; + } + + /** + * Parse header row (field names and values interleaved) + * Reference: _parse_header_row() in Python code + */ + private parseHeaderRow(row: any[], info: OrderHeader): void { + let j = 0; + while (j < row.length) { + const cell = row[j]; + if (cell && String(cell).trim() && String(cell).includes(':')) { + // Found field name + let fieldName = String(cell).replace(':', '').trim(); + + // Apply field name mapping (from Python code) + if (fieldName in this.FIELD_NAME_MAPPING) { + fieldName = this.FIELD_NAME_MAPPING[fieldName]; + } + + // Map Chinese field name to English property name + const englishFieldName = this.CHINESE_TO_ENGLISH_MAPPING[fieldName] || fieldName; + + // Skip empty cells to find first non-field-name value + let k = j + 1; + while ( + k < row.length && + (!row[k] || !String(row[k]).trim() || String(row[k]).includes(':')) + ) { + k++; + } + + if (k < row.length && row[k] && !String(row[k]).includes(':')) { + info[englishFieldName as keyof OrderHeader] = String(row[k]).trim(); + } + + // Skip processed value, continue to next field name + j = k + 1; + } else { + j++; + } + } + } + + /** + * Parse material data row + * Reference: material data extraction in Python code + * ExcelJS arrays are 1-indexed (index 0 is null), so data starts at index 2 + */ + private parseMaterialRow(row: any[], rowNumber: number): any | null { + // Extract 13 fields from material row (starting at index 2) + const material = { + sequence: row[2], + materialCode: row[3], + materialName: row[4], + specification: row[5], + model: row[6], + drawingNumber: row[7], + material: row[8], + quantity: this.parseFloat(row[9]), + unit: row[10], + requiredDate: row[11], + warehouse: row[12], + unitUsage: this.parseFloat(row[13]), + cumulativeOutboundQty: this.parseFloat(row[14]), + rowNumber, + }; + + // Skip if no material code + if (!material.materialCode) { + return null; + } + + return material; + } + + /** + * Safely parse float from cell value + */ + private parseFloat(value: any): number | undefined { + if (value === null || value === undefined) { + return undefined; + } + const parsed = parseFloat(String(value)); + return isNaN(parsed) ? undefined : parsed; + } +} diff --git a/src/main/types/excel.types.ts b/src/main/types/excel.types.ts new file mode 100644 index 0000000..4c840de --- /dev/null +++ b/src/main/types/excel.types.ts @@ -0,0 +1,113 @@ +/** + * Excel Parser Types + * Defines the structure for parsed Excel data from ERP system + */ + +/** + * Represents a discrete material plan row from Excel + * Based on the ERP Excel export structure + */ +export interface DiscreteMaterialPlan { + /** Order number (e.g., SC202501001) */ + orderNumber: string; + + /** Production ID from order header */ + productionId: string; + + /** Material code (材料编码) */ + materialCode: string; + + /** Material name (材料名称) */ + materialName: string; + + /** Specification (规格) */ + specification?: string; + + /** Model (型号) */ + model?: string; + + /** Drawing number (图号) */ + drawingNumber?: string; + + /** Material (物料材质) */ + material?: string; + + /** Planned quantity (计划数量) */ + quantity: number; + + /** Unit (单位) */ + unit: string; + + /** Required date (需用日期) */ + requiredDate?: string; + + /** Warehouse (发料仓库) */ + warehouse?: string; + + /** Unit usage (单位用量) */ + unitUsage?: number; + + /** Cumulative outbound quantity (累计出库数量) */ + cumulativeOutboundQty?: number; + + /** Row number in Excel file */ + rowNumber?: number; +} + +/** + * Options for Excel parsing + */ +export interface ExcelParseOptions { + /** Skip orders with no material data */ + skipEmptyOrders?: boolean; + + /** Skip footer rows (制单人/打印人) */ + skipFooter?: boolean; + + /** Custom field mapping */ + fieldMapping?: Record; + + /** Verbose logging */ + verbose?: boolean; +} + +/** + * Order header information from Excel + */ +export interface OrderHeader { + /** Order title (离散备料计划) */ + title?: string; + + /** Production department (生产部门) */ + productionDepartment?: string; + + /** Production order (生产订单) */ + productionOrder?: string; + + /** Product code (产品编码) */ + productCode?: string; + + /** Product name (产品名称) */ + productName?: string; + + /** Product specification (产品规格) */ + productSpecification?: string; + + /** Planned quantity (计划数量) */ + plannedQuantity?: string; + + /** Unit (单位) */ + unit?: string; + + /** Required date (需用日期) */ + requiredDate?: string; + + /** Creator (制单人) */ + creator?: string; + + /** Printer (打印人) */ + printer?: string; + + /** Print date (打印日期) */ + printDate?: string; +} diff --git a/tests/fixtures/create-fixtures.ts b/tests/fixtures/create-fixtures.ts new file mode 100644 index 0000000..26bb964 --- /dev/null +++ b/tests/fixtures/create-fixtures.ts @@ -0,0 +1,156 @@ +import ExcelJS from 'exceljs'; +import path from 'path'; + +/** + * Create test fixture Excel files for unit tests + */ + +async function createTestFixture() { + const workbook = new ExcelJS.Workbook(); + const worksheet = workbook.addWorksheet('Sheet1'); + + // Row 1: Order title + worksheet.addRow([null, '离散备料计划']); + + // Row 2-5: Order header info + worksheet.addRow([null, '生产部门:', null, '生产车间', '产品编码:', null, 'P001']); + worksheet.addRow([null, '生产订单:', null, 'SC202501001', '产品名称:', null, '测试产品A']); + worksheet.addRow([null, '产品规格:', null, '标准规格', '计划数量:', null, '100']); + worksheet.addRow([null, '单位:', null, '件', '需用日期:', null, '2025-02-15']); + + // Row 6: Empty row before table header + worksheet.addRow([]); + + // Row 7: Table header + worksheet.addRow([ + null, + '序号', + '材料编码', + '材料名称', + '规格', + '型号', + '图号', + '物料材质', + '计划数量', + '单位', + '需用日期', + '发料仓库', + '单位用量', + '累计出库数量', + ]); + + // Row 8-10: Material data + worksheet.addRow([ + null, + 1, + 'M001', + '钢材A', + '规格1', + '型号1', + '图号1', + '材质1', + 50, + 'kg', + '2025-02-10', + '仓库1', + 0.5, + 0, + ]); + worksheet.addRow([ + null, + 2, + 'M002', + '塑料B', + '规格2', + '型号2', + '图号2', + '材质2', + 100, + 'kg', + '2025-02-12', + '仓库1', + 1.0, + 20, + ]); + worksheet.addRow([ + null, + 3, + 'M003', + '配件C', + '规格3', + null, + null, + null, + 200, + '件', + '2025-02-14', + '仓库2', + 2.0, + 50, + ]); + + // Row 11: Footer info + worksheet.addRow([null, '制单人:', null, '张三', '打印人:', null, '李四']); + worksheet.addRow([null, '打印日期:', null, '2025-01-15']); + + // Save file + const filePath = path.resolve(__dirname, 'test-export.xlsx'); + await workbook.xlsx.writeFile(filePath); + console.log('Created test fixture:', filePath); +} + +async function createEmptyOrdersFixture() { + const workbook = new ExcelJS.Workbook(); + const worksheet = workbook.addWorksheet('Sheet1'); + + // Row 1: Order title + worksheet.addRow([null, '离散备料计划']); + + // Row 2-5: Order header info + worksheet.addRow([null, '生产部门:', null, '生产车间', '产品编码:', null, 'P002']); + worksheet.addRow([null, '生产订单:', null, 'SC202501002', '产品名称:', null, '测试产品B']); + worksheet.addRow([null, '产品规格:', null, '特殊规格', '计划数量:', null, '50']); + worksheet.addRow([null, '单位:', null, '套', '需用日期:', null, '2025-03-01']); + + // Row 6: Empty row before table header + worksheet.addRow([]); + + // Row 7: Table header + worksheet.addRow([ + null, + '序号', + '材料编码', + '材料名称', + '规格', + '型号', + '图号', + '物料材质', + '计划数量', + '单位', + '需用日期', + '发料仓库', + '单位用量', + '累计出库数量', + ]); + + // Row 8: Empty row (no data) + worksheet.addRow([]); + + // Row 9: Footer info + worksheet.addRow([null, '制单人:', null, '王五', '打印人:', null, '赵六']); + worksheet.addRow([null, '打印日期:', null, '2025-01-16']); + + // Save file + const filePath = path.resolve(__dirname, 'test-empty-orders.xlsx'); + await workbook.xlsx.writeFile(filePath); + console.log('Created empty orders fixture:', filePath); +} + +async function main() { + console.log('Creating test fixture Excel files...'); + await createTestFixture(); + await createEmptyOrdersFixture(); + console.log('Done!'); +} + +main().catch(console.error); diff --git a/tests/fixtures/test-empty-orders.xlsx b/tests/fixtures/test-empty-orders.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..dd2596f12f46be62f075347540f4d338a3d14284 GIT binary patch literal 6918 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 TuV;-A(RXwpDw2cz!uD{ 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 a^7HaSTLb6EUL!;I9UsvVDSCm-WBeZvWXuf! literal 0 HcmV?d00001 diff --git a/tests/unit/excel-parser.test.ts b/tests/unit/excel-parser.test.ts new file mode 100644 index 0000000..652c31b --- /dev/null +++ b/tests/unit/excel-parser.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest'; +import { ExcelParser } from '../../src/main/services/excel/excel-parser'; +import type { DiscreteMaterialPlan } from '../../src/main/types/excel.types'; +import path from 'path'; + +describe('Excel Parser', () => { + it('should parse Excel file and extract material plans', async () => { + const parser = new ExcelParser(); + const filePath = path.resolve(__dirname, '../fixtures/test-export.xlsx'); + + const plans = await parser.parse(filePath); + + expect(Array.isArray(plans)).toBe(true); + expect(plans.length).toBeGreaterThan(0); + + const firstPlan = plans[0]; + expect(firstPlan).toHaveProperty('orderNumber'); + expect(firstPlan).toHaveProperty('materialCode'); + }); + + it('should parse all material fields correctly', async () => { + const parser = new ExcelParser(); + const filePath = path.resolve(__dirname, '../fixtures/test-export.xlsx'); + + const plans = await parser.parse(filePath); + + expect(plans.length).toBe(3); + + // Check first material + expect(plans[0].orderNumber).toBe('SC202501001'); + expect(plans[0].materialCode).toBe('M001'); + expect(plans[0].materialName).toBe('钢材A'); + expect(plans[0].specification).toBe('规格1'); + expect(plans[0].model).toBe('型号1'); + expect(plans[0].drawingNumber).toBe('图号1'); + expect(plans[0].material).toBe('材质1'); + expect(plans[0].quantity).toBe(50); + expect(plans[0].unit).toBe('kg'); + expect(plans[0].requiredDate).toBe('2025-02-10'); + expect(plans[0].warehouse).toBe('仓库1'); + expect(plans[0].unitUsage).toBe(0.5); + expect(plans[0].cumulativeOutboundQty).toBe(0); + + // Check third material (with some empty fields) + expect(plans[2].materialCode).toBe('M003'); + expect(plans[2].materialName).toBe('配件C'); + expect(plans[2].quantity).toBe(200); + }); + + it('should handle empty orders gracefully', async () => { + const parser = new ExcelParser(); + const filePath = path.resolve(__dirname, '../fixtures/test-empty-orders.xlsx'); + + const plans = await parser.parse(filePath); + + expect(plans).toBeDefined(); + expect(plans.length).toBe(0); + }); +});