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 0000000..dd2596f Binary files /dev/null and b/tests/fixtures/test-empty-orders.xlsx differ diff --git a/tests/fixtures/test-export.xlsx b/tests/fixtures/test-export.xlsx new file mode 100644 index 0000000..0753ffe Binary files /dev/null and b/tests/fixtures/test-export.xlsx differ 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); + }); +});