From b22f4995ba1a4d80ec0c776fcdf995dedde4aeb9 Mon Sep 17 00:00:00 2001 From: Misaka Date: Sun, 1 Mar 2026 14:22:14 +0800 Subject: [PATCH] feat: implement ERP authentication, data extraction, and Excel parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit completes the core ERP automation functionality, migrating from Python Playwright to TypeScript while maintaining full compatibility with the original implementation. **ERP Authentication Service (erp-auth.ts):** - Implement login() with role-based locators for form elements - Add SSL certificate bypass for internal VPN network - Handle force login confirmation dialogs - Return session with mainFrame reference for subsequent operations - Add session lifecycle management (close, getSession, isActive) **Data Extractor Service (extractor.ts):** - Implement precise nested iframe navigation (#forwardFrame → #mainiframe) - Add batch processing support for multiple order numbers - Implement order number filling with comma separation - Handle material selection and download workflows - Successfully tested with 300 orders in 5 batches **Excel Parser Service (excel-parser.ts):** - Fix ExcelJS 1-indexed array access (row[1] for 序号, row[2] for 材料编码) - Add dynamic table header search to handle empty row skipping - Add field mapping: "来源单号" → "productionOrder" - Implement saveAsExcel() method compatible with Python format - Validate compatibility: 527 rows, 69 orders matching Python output **Type Definitions (erp.types.ts):** - Add headless property to ErpConfig for browser mode control - Add mainFrame reference to ErpSession for frame reuse **Integration Tests (extractor.test.ts):** - Modify tests to use independent auth services for isolation - Add test with 300 orders and batch size 70 - All tests passing with real ERP data **Test Configuration (vitest.config.ts):** - Add setupFiles configuration for environment variable loading **Testing Results:** - ✅ Successfully logs in to ERP system - ✅ Processes 300 orders in 5 batches (43.59 seconds) - ✅ Downloads 5 Excel files (347.62 KB total) - ✅ Parses 2,131 material plans from 280 unique orders - ✅ Excel output matches Python format exactly Co-Authored-By: Claude Sonnet 4.5 --- src/main/services/erp/erp-auth.ts | 94 +++++++++-- src/main/services/erp/extractor.ts | 95 +++++------ src/main/services/excel/excel-parser.ts | 200 +++++++++++++++++++----- src/main/types/erp.types.ts | 2 + tests/integration/extractor.test.ts | 122 ++++++++++++--- vitest.config.ts | 1 + 6 files changed, 399 insertions(+), 115 deletions(-) diff --git a/src/main/services/erp/erp-auth.ts b/src/main/services/erp/erp-auth.ts index 2ded586..a1aaee0 100644 --- a/src/main/services/erp/erp-auth.ts +++ b/src/main/services/erp/erp-auth.ts @@ -9,9 +9,11 @@ import type { ErpConfig, ErpSession } from '../../types/erp.types'; export class ErpAuthService { private config: ErpConfig; private session: ErpSession | null = null; + private ignoreHTTPSErrors: boolean; constructor(config: ErpConfig) { this.config = config; + this.ignoreHTTPSErrors = process.env.ERP_IGNORE_HTTPS_ERRORS === 'true'; } /** @@ -22,41 +24,103 @@ export class ErpAuthService { return this.session; } - // Launch browser + // Launch browser with SSL certificate errors ignored const browser = await chromium.launch({ - headless: false, // Set to true for production + headless: this.config.headless ?? false, // Use config or default to false slowMo: 100, // Slow down for debugging + ignoreHTTPSErrors: true, // Ignore SSL certificate errors + args: [ + '--ignore-certificate-errors', + '--ignore-ssl-errors', + '--ignore-certificate-errors-spki-list', + '--disable-web-security', // Disable web security for internal VPN + ], }); const context = await browser.newContext({ acceptDownloads: true, viewport: { width: 1920, height: 1080 }, + ignoreHTTPSErrors: true, // Ignore SSL certificate errors + acceptAllDownloads: true, // Accept all downloads + // Disable web security for internal VPN + javaScriptEnabled: true, }); const page = await context.newPage(); - // Navigate to login page - await page.goto(this.config.url); + // Navigate to login page (use actual login URL from Python code) + const loginUrl = `${this.config.url}/yonbip/resources/uap/rbac/login/main/index.html`; + await page.goto(loginUrl); - // Wait for login form - await page.waitForSelector(ERP_LOCATORS.login.usernameInput); + // Wait for page to load + await page.waitForLoadState('domcontentloaded', { timeout: 10000 }); - // Fill credentials - await page.fill(ERP_LOCATORS.login.usernameInput, this.config.username); - await page.fill(ERP_LOCATORS.login.passwordInput, this.config.password); + // Wait for iframe to be present + await page.waitForSelector('#forwardFrame', { state: 'attached', timeout: 15000 }); - // Submit login - await page.click(ERP_LOCATORS.login.submitButton); + // Extract forwardFrame (Python: main_frame = page.locator("#forwardFrame").content_frame) + // This is the main working frame for all subsequent operations + const frameLocator = page.locator('#forwardFrame'); + const contentFrame = await frameLocator.contentFrame(); - // Wait for main page to load - await page.waitForURL(`${this.config.url}/**`); - await page.waitForSelector(ERP_LOCATORS.main.mainIframe, { timeout: 10000 }); + if (!contentFrame) { + throw new Error('Failed to access forwardFrame content frame'); + } - // Create session + // Store reference to main frame for later use (Python returns this as main_frame) + const mainFrame = contentFrame; + + // Fill username using role-based locator (Python: get_by_role("textbox", name="用户名")) + try { + await contentFrame.getByRole('textbox', { name: '用户名' }).fill(this.config.username); + } catch (e) { + throw new Error(`Failed to find username input: ${e}`); + } + + // Fill password using role-based locator (Python: get_by_role("textbox", name="密码")) + try { + await contentFrame.getByRole('textbox', { name: '密码' }).fill(this.config.password); + } catch (e) { + throw new Error(`Failed to find password input: ${e}`); + } + + // Click login button using role-based locator (Python: get_by_role("button", name="登录")) + try { + await contentFrame.getByRole('button', { name: '登录' }).click(); + } catch (e) { + throw new Error(`Failed to click login button: ${e}`); + } + + // Wait for navigation after login + // The login will redirect to the main page which has a different structure + try { + await page.waitForLoadState('domcontentloaded', { timeout: 10000 }); + } catch (e) { + console.log('Page load state check timed out, continuing...'); + } + + // Handle force login confirmation dialog if present (Python: get_by_role("button", name="确定")) + try { + const confirmBtn = mainFrame.getByRole('button', { name: '确定' }); + const count = await confirmBtn.count(); + if (count > 0) { + console.log('Force login detected, clicking confirm button'); + await confirmBtn.first().click(); + await page.waitForTimeout(2000); + } else { + console.log('Normal login, no confirmation dialog'); + } + } catch (e) { + // No force login dialog, continue + console.log('Normal login, no confirmation dialog'); + } + + // Create session with mainFrame (Python returns main_frame as part of login result) this.session = { browser, context, page, + mainFrame, // Store forwardFrame content frame for subsequent operations isLoggedIn: true, }; diff --git a/src/main/services/erp/extractor.ts b/src/main/services/erp/extractor.ts index e30bcb2..6254c7c 100644 --- a/src/main/services/erp/extractor.ts +++ b/src/main/services/erp/extractor.ts @@ -37,8 +37,8 @@ export class ExtractorService { try { const session = this.authService.getSession(); - // Navigate to extractor page - const popupPage = await this.navigateToExtractorPage(session); + // Navigate to extractor page and get popup page + work frame + const { popupPage, workFrame } = await this.navigateToExtractorPage(session); // Process orders in batches const batchSize = input.batchSize || 100; @@ -51,7 +51,7 @@ export class ExtractorService { input.onProgress?.(`Processing batch ${i + 1}/${batches.length}`, progress); try { - const filePath = await this.downloadBatch(session, popupPage, batch, i, batches.length); + const filePath = await this.downloadBatch(session, popupPage, workFrame, batch, i, batches.length); result.downloadedFiles.push(filePath); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; @@ -72,36 +72,48 @@ export class ExtractorService { /** * Navigate to extractor/query page - * Reference: Python extract() method lines 266-276 + * Reference: Python extract() method lines 266-278 + * + * Python workflow: + * 1. main_frame.locator("i").first.click() - Click menu icon + * 2. page.expect_popup() + get_by_title("离散备料计划维护").click() - Click menu item and wait for popup + * 3. page1.locator("#forwardFrame").content_frame - Get popup's forward frame + * 4. f_frame.locator("#mainiframe").content_frame - Get nested inner frame + * 5. setup_query_interface(work_frame) - Setup query interface */ - private async navigateToExtractorPage(session: ErpSession): Promise { - const { page } = session; + private async navigateToExtractorPage(session: ErpSession): Promise<{ popupPage: any; workFrame: any }> { + const { page, mainFrame } = session; - // Wait for main iframe - await page.waitForSelector(ERP_LOCATORS.main.mainIframe); - - // Get main frame - const mainFrame = page.frameLocator(ERP_LOCATORS.main.mainIframe); - - // Click icon to open menu (line 266) + // Step 1: Click menu icon (Python line 266) + // main_frame is #forwardFrame.content_frame returned from login await mainFrame.locator('i').first().click(); - // Click discrete material plan menu item and expect popup (lines 267-271) + // Step 2: Click discrete material plan menu item and expect popup (Python lines 267-271) const popupPromise = page.waitForEvent('popup'); await mainFrame.getByTitle('离散备料计划维护', { exact: true }).first().click(); const popupPage = await popupPromise; - // Get nested frame structure (lines 273-276) - const forwardFrameLocator = popupPage.locator(ERP_LOCATORS.main.forwardFrame); - const innerFrameLocator = forwardFrameLocator.frameLocator(ERP_LOCATORS.main.innerIframe); + // Step 3 & 4: Get nested frame structure in popup window (Python lines 273-276) + // popup page contains #forwardFrame, which contains #mainiframe + const forwardFrameLocator = popupPage.locator('#forwardFrame'); + const fFrame = await forwardFrameLocator.contentFrame(); - // Wait for inner iframe to be visible + if (!fFrame) { + throw new Error('Failed to access popup forward frame'); + } + + const innerFrameLocator = fFrame.locator('#mainiframe'); await innerFrameLocator.waitFor({ state: 'visible', timeout: 15000 }); + const workFrame = await innerFrameLocator.contentFrame(); - // Setup query interface (line 278) - await this.setupQueryInterface(innerFrameLocator); + if (!workFrame) { + throw new Error('Failed to access inner work frame'); + } - return popupPage; + // Step 5: Setup query interface (Python line 278) + await this.setupQueryInterface(workFrame); + + return { popupPage, workFrame }; } /** @@ -109,17 +121,17 @@ export class ExtractorService { * Reference: Python setup_query_interface() method lines 231-239 */ private async setupQueryInterface(innerFrame: any): Promise { - // Click search icon (line 233) - await innerFrame.locator(ERP_LOCATORS.menu.searchIcon).click(); + // Click search icon (Python line 233) + await innerFrame.locator('.search-name-wrapper > .iconfont').click(); - // Click "订单号查询" menu item (line 234) - await innerFrame.locator(ERP_LOCATORS.menu.orderQuery).click(); + // Click "订单号查询" menu item (Python line 234) + await innerFrame.getByText('订单号查询').click(); - // Click "全部" tab (line 235) + // Click "全部" tab (Python line 235) await innerFrame.getByRole('tab', { name: '全部' }).click(); - // Set limit to 5000 (lines 237-239) - const inputBox = innerFrame.locator(ERP_LOCATORS.menu.selectInput); + // Set limit to 5000 (Python lines 237-239) + const inputBox = innerFrame.locator('#rc_select_0'); await inputBox.fill('5000'); await inputBox.press('Enter'); } @@ -131,42 +143,37 @@ export class ExtractorService { private async downloadBatch( session: ErpSession, popupPage: any, + workFrame: any, orderNumbers: string[], batchIndex: number, totalBatches: number ): Promise { - const forwardFrameLocator = popupPage.locator(ERP_LOCATORS.main.forwardFrame); - const workFrame = forwardFrameLocator.frameLocator(ERP_LOCATORS.main.innerIframe); - - // Fill order numbers (lines 143-145) - const textbox = workFrame.getByRole('textbox', { name: ERP_LOCATORS.extractor.orderNumberInputRole }); + // Fill order numbers (Python lines 143-145) + const textbox = workFrame.getByRole('textbox', { name: '来源生产订单号' }); await textbox.fill(''); await textbox.fill(orderNumbers.join(',')); - // Click search button (line 147) - await workFrame.locator(ERP_LOCATORS.extractor.queryButton).click(); + // Click search button (Python line 147) + await workFrame.locator('.search-component-searchBtn').click(); - // Wait for loading (lines 148-153) + // Wait for loading (Python lines 148-153) await this.waitForLoading(workFrame); - // Click first row checkbox (line 155) - await workFrame - .locator(ERP_LOCATORS.extractor.firstRowSelector) - .getByLabel('') - .click(); + // Click first row checkbox (Python line 155) + await workFrame.getByRole('row', { name: '序号' }).getByLabel('').click(); - // Hover and click "更多" button (lines 156-157) + // Hover and click "更多" button (Python lines 156-157) await workFrame.getByRole('button', { name: '更多' }).hover(); await workFrame.getByText('输出', { exact: true }).click(); - // Set threshold (lines 159-164) + // Set threshold (Python lines 159-164) const thresholdBox = workFrame .locator('div') .filter({ hasText: /^行数阈值$/ }) .locator('input[type="text"]'); await thresholdBox.fill('300000'); - // Setup download handler and click confirm (lines 166-172) + // Setup download handler and click confirm (Python lines 166-172) const downloadPath = path.join( this.downloadDir, `temp_batch_${batchIndex + 1}.xlsx` diff --git a/src/main/services/excel/excel-parser.ts b/src/main/services/excel/excel-parser.ts index 8452456..7bce32b 100644 --- a/src/main/services/excel/excel-parser.ts +++ b/src/main/services/excel/excel-parser.ts @@ -28,6 +28,7 @@ export class ExcelParser { private CHINESE_TO_ENGLISH_MAPPING: Record = { 生产部门: 'productionDepartment', 生产订单: 'productionOrder', + 来源单号: 'productionOrder', // This is the order number we need! 产品编码: 'productCode', 产品名称: 'productName', 产品规格: 'productSpecification', @@ -76,9 +77,14 @@ export class ExcelParser { allRows.push(row.values as any[]); }); + this.log(`Total rows read: ${allRows.length} (worksheet has ${worksheet.rowCount} rows)`); + // Parse orders from rows const orders = this.parseOrders(allRows); + // Store orders for potential Excel export + (this as any).lastOrders = orders; + // Flatten orders into material plans for (const order of orders) { const { orderInfo, materials } = order; @@ -117,6 +123,106 @@ export class ExcelParser { return plans; } + /** + * Save parsed orders to Excel file + * Compatible with Python excel_converter.py output format + * Uses the last parsed orders data + * + * @param outputPath - Output Excel file path + */ + async saveAsExcel(outputPath: string): Promise { + const orders = (this as any).lastOrders; + if (!orders) { + throw new Error('No parsed data available. Call parse() first.'); + } + + this.log('Saving parsed data to Excel:', outputPath); + + const workbook = new ExcelJS.Workbook(); + const worksheet = workbook.addWorksheet('Data'); + + // Define columns matching Python excel_converter output format exactly + worksheet.columns = [ + { header: '工厂', key: 'factory', width: 25 }, + { header: '备料状态', key: 'materialStatus', width: 15 }, + { header: '备料计划单号', key: 'planNumber', width: 25 }, + { header: '来源单号', key: 'productionOrder', width: 20 }, + { header: '备料类型', key: 'materialType', width: 15 }, + { header: '产品编码', key: 'productCode', width: 15 }, + { header: '产品名称', key: 'productName', width: 30 }, + { header: '产品计划数量', key: 'productPlannedQuantity', width: 15 }, + { header: '单位', key: 'productUnit', width: 10 }, + { header: '用料部门', key: 'department', width: 15 }, + { header: '备注', key: 'remark', width: 20 }, + { header: '制单人', key: 'creator', width: 15 }, + { header: '制单日期', key: 'createDate', width: 15 }, + { header: '审批人', key: 'approver', width: 15 }, + { header: '审批日期', key: 'approveDate', width: 15 }, + { header: '序号', key: 'sequence', width: 10 }, + { header: '材料编码', key: 'materialCode', width: 15 }, + { header: '材料名称', key: 'materialName', width: 30 }, + { header: '规格', key: 'specification', width: 30 }, + { header: '型号', key: 'model', width: 20 }, + { header: '图号', key: 'drawingNumber', width: 20 }, + { header: '物料材质', key: 'material', width: 15 }, + { header: '计划数量', key: 'quantity', width: 12 }, + { header: '单位', key: 'unit', width: 10 }, + { header: '需用日期', key: 'requiredDate', width: 15 }, + { header: '发料仓库', key: 'warehouse', width: 15 }, + { header: '单位用量', key: 'unitUsage', width: 12 }, + { header: '累计出库数量', key: 'cumulativeOutboundQty', width: 15 }, + { header: '打印人', key: 'printer', width: 15 }, + { header: '打印日期', key: 'printDate', width: 20 }, + ]; + + // Add data rows - merge orderInfo with each material + for (const order of orders) { + const { orderInfo, materials } = order; + + for (const material of materials) { + worksheet.addRow({ + // Order info (first 14 columns) + factory: orderInfo.factory || '', + materialStatus: orderInfo.materialStatus || '', + planNumber: orderInfo.planNumber || '', + productionOrder: orderInfo.productionOrder || '', + materialType: orderInfo.materialType || '', + productCode: orderInfo.productCode || '', + productName: orderInfo.productName || '', + productPlannedQuantity: orderInfo.plannedQuantity || '', + unit: orderInfo.unit || '', + department: orderInfo.department || '', + remark: orderInfo.remark || '', + creator: orderInfo.creator || '', + createDate: orderInfo.createDate || '', + approver: orderInfo.approver || '', + approveDate: orderInfo.approveDate || '', + // Material data (columns 15-28) + sequence: material.sequence || '', + 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 || 0, + cumulativeOutboundQty: material.cumulativeOutboundQty || 0, + // Footer info (last 2 columns) + printer: orderInfo.printer || '', + printDate: orderInfo.printDate || '', + }); + } + } + + // Save workbook + await workbook.xlsx.writeFile(outputPath); + this.log(`Excel file saved: ${outputPath} (${orders.length} orders, ${worksheet.rowCount - 1} data rows)`); + } + /** * Parse orders from all rows * Reference: _parse_sheet() in Python code @@ -130,8 +236,6 @@ export class ExcelParser { // 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++) { @@ -140,20 +244,32 @@ export class ExcelParser { } } - // Skip empty rows to find table header - let tableRow = i + 5; + // Debug: check productionOrder extraction + this.log(`Order ${orders.length + 1}: productionOrder="${orderInfo.productionOrder}"`); + + // Find table header row dynamically (look for "序号" in index 1) + // Note: worksheet.eachRow() skips empty rows, so we can't use fixed offsets + let tableRow = i + 1; while ( tableRow < allRows.length && - (!allRows[tableRow] || !allRows[tableRow][2]) + allRows[tableRow] && + allRows[tableRow][1] !== '序号' ) { tableRow++; } + if (tableRow >= allRows.length || !allRows[tableRow]) { + this.log(' ⚠️ Table header not found, skipping this order'); + i++; + continue; + } + // Check if this is the table header row + // ExcelJS is 1-indexed: index 0=null, index 1=序号, index 2=材料编码 if ( tableRow < allRows.length && allRows[tableRow] && - allRows[tableRow][2] === '序号' + allRows[tableRow][1] === '序号' ) { // Check if next row is empty (no data) const nextRow = tableRow + 1; @@ -256,6 +372,8 @@ export class ExcelParser { materials, }); } + } else { + this.log(` ⚠️ Table header check failed at row ${tableRow + 1}, value="${allRows[tableRow] ? allRows[tableRow][1] : 'null'}"`); } // Move to next row after this order @@ -265,6 +383,7 @@ export class ExcelParser { } } + this.log(`parseOrders: Returning ${orders.length} orders`); return orders; } @@ -312,25 +431,30 @@ export class ExcelParser { /** * 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 + * ExcelJS row.values arrays are 1-indexed: + * - Index 0: null + * - Index 1: 序号 (sequence) + * - Index 2: 材料编码 (materialCode) + * - Index 3: 材料名称 (materialName) + * - etc. * NOTE: This is an internal method that returns raw data structure */ private parseMaterialRowInternal(row: any[], rowNumber: number): any | null { - // Extract 13 fields from material row (starting at index 2) + // Extract 13 fields from material row (ExcelJS is 1-indexed, so data starts at index 1) 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]), + sequence: row[1], + materialCode: row[2], + materialName: row[3], + specification: row[4], + model: row[5], + drawingNumber: row[6], + material: row[7], + quantity: this.parseFloat(row[8]), + unit: row[9], + requiredDate: row[10], + warehouse: row[11], + unitUsage: this.parseFloat(row[12]), + cumulativeOutboundQty: this.parseFloat(row[13]), rowNumber, }; @@ -390,7 +514,7 @@ export class ExcelParser { * Parse material row * (Spec-compliant method for parsing material data) * - * @param values - Row values array from ExcelJS + * @param values - Row values array from ExcelJS (1-indexed, index 0 is null) * @param orderNumber - Order number for this material * @param productionId - Production ID for this material * @param rowNumber - Row number in Excel file @@ -402,19 +526,25 @@ export class ExcelParser { productionId: string, rowNumber: number ): DiscreteMaterialPlan | null { - // ExcelJS arrays are 1-indexed, data starts at index 2 - const materialCode = values[3]?.toString().trim(); - const materialName = values[4]?.toString().trim(); - const specification = values[5]?.toString().trim(); - const model = values[6]?.toString().trim(); - const drawingNumber = values[7]?.toString().trim(); - const material = values[8]?.toString().trim(); - const quantity = this.parseFloat(values[9]) || 0; - const unit = values[10]?.toString().trim() || ''; - const requiredDate = values[11]?.toString().trim(); - const warehouse = values[12]?.toString().trim(); - const unitUsage = this.parseFloat(values[13]); - const cumulativeOutboundQty = this.parseFloat(values[14]); + // ExcelJS arrays are 1-indexed: + // Index 0: null + // Index 1: 序号 + // Index 2: 材料编码 + // Index 3: 材料名称 + // Index 4: 规格 + // etc. + const materialCode = values[2]?.toString().trim(); + const materialName = values[3]?.toString().trim(); + const specification = values[4]?.toString().trim(); + const model = values[5]?.toString().trim(); + const drawingNumber = values[6]?.toString().trim(); + const material = values[7]?.toString().trim(); + const quantity = this.parseFloat(values[8]) || 0; + const unit = values[9]?.toString().trim() || ''; + const requiredDate = values[10]?.toString().trim(); + const warehouse = values[11]?.toString().trim(); + const unitUsage = this.parseFloat(values[12]); + const cumulativeOutboundQty = this.parseFloat(values[13]); // Skip if no material code if (!materialCode) { diff --git a/src/main/types/erp.types.ts b/src/main/types/erp.types.ts index b57ad5a..cb4dee5 100644 --- a/src/main/types/erp.types.ts +++ b/src/main/types/erp.types.ts @@ -2,12 +2,14 @@ export interface ErpConfig { url: string; username: string; password: string; + headless?: boolean; // Optional: override default headless setting } export interface ErpSession { browser: import('playwright').Browser; context: import('playwright').BrowserContext; page: import('playwright').Page; + mainFrame: import('playwright').Frame; // #forwardFrame content frame - main working frame after login isLoggedIn: boolean; } diff --git a/tests/integration/extractor.test.ts b/tests/integration/extractor.test.ts index f883d6f..8ddbf7d 100644 --- a/tests/integration/extractor.test.ts +++ b/tests/integration/extractor.test.ts @@ -6,8 +6,6 @@ import fs from 'fs/promises'; import path from 'path'; describe('Extractor Service (Integration)', () => { - let authService: ErpAuthService; - let extractor: ExtractorService; const config: ErpConfig = { url: process.env.ERP_URL || '', username: process.env.ERP_USERNAME || '', @@ -19,23 +17,18 @@ describe('Extractor Service (Integration)', () => { // Check if we have ERP credentials const hasCredentials = !!(config.url && config.username && config.password); - beforeAll(async () => { - if (!hasCredentials) { - return; - } - - authService = new ErpAuthService(config); - await authService.login(); - - extractor = new ExtractorService(authService); - }); - it('should extract data for single order number', async () => { if (!hasCredentials) { console.warn('Skipping test: ERP credentials not configured'); return; } + // Create fresh auth service for this test + const authService = new ErpAuthService(config); + await authService.login(); + + const extractor = new ExtractorService(authService); + const result = await extractor.extract({ orderNumbers: [testOrderNumber], }); @@ -47,6 +40,9 @@ describe('Extractor Service (Integration)', () => { const filePath = result.downloadedFiles[0]; const stats = await fs.stat(filePath); expect(stats.size).toBeGreaterThan(0); + + // Clean up + await authService.close(); }, 60000); it('should extract data for multiple order numbers', async () => { @@ -55,16 +51,100 @@ describe('Extractor Service (Integration)', () => { return; } + // Create fresh auth service for this test + const authService = new ErpAuthService(config); + await authService.login(); + + const extractor = new ExtractorService(authService); + + // Read order numbers from productionID.txt file + const fs = await import('fs/promises'); + const path = await import('path'); + // productionID.txt is at: D:\FileLib\Projects\CodeMigration\references\demo\productionID.txt + // test runs at: D:\FileLib\Projects\CodeMigration\ERPAuto + const productionIdFile = path.join(process.cwd(), '../references/demo/productionID.txt'); + const content = await fs.readFile(productionIdFile, 'utf-8'); + const orderNumbers = content.split('\n') + .map(line => line.trim()) + .filter(line => line.length > 0) + .slice(0, 5); // Test first 5 orders + + console.log(`Testing with ${orderNumbers.length} order numbers:`, orderNumbers); + const result = await extractor.extract({ - orderNumbers: ['SC70202602120085', 'SC70202602120120'], + orderNumbers, + batchSize: 100, // Process all in one batch }); - expect(result.downloadedFiles.length).toBeGreaterThanOrEqual(1); - }, 90000); - - afterAll(async () => { - if (hasCredentials && authService) { - await authService.close(); + console.log(`Downloaded ${result.downloadedFiles.length} files`); + if (result.errors.length > 0) { + console.log('Errors:', result.errors); } - }); + + expect(result.downloadedFiles.length).toBeGreaterThanOrEqual(1); + + // Clean up + await authService.close(); + }, 120000); // Increase timeout to 2 minutes + + it('should extract data for 300 orders with batch size 70', async () => { + if (!hasCredentials) { + console.warn('Skipping test: ERP credentials not configured'); + return; + } + + // Create fresh auth service for this test + const authService = new ErpAuthService(config); + await authService.login(); + + const extractor = new ExtractorService(authService); + + // Read all order numbers from productionID.txt file + const fs = await import('fs/promises'); + const path = await import('path'); + const productionIdFile = path.join(process.cwd(), '../references/demo/productionID.txt'); + const content = await fs.readFile(productionIdFile, 'utf-8'); + const orderNumbers = content.split('\n') + .map(line => line.trim()) + .filter(line => line.length > 0); + + console.log(`Testing with ${orderNumbers.length} order numbers`); + console.log(`Batch size: 70, Expected batches: ${Math.ceil(orderNumbers.length / 70)}`); + + const startTime = Date.now(); + + const result = await extractor.extract({ + orderNumbers, + batchSize: 70, // Process 70 orders per batch + }); + + const endTime = Date.now(); + const duration = ((endTime - startTime) / 1000).toFixed(2); + + console.log(`\n=== Extraction Summary ===`); + console.log(`Total orders: ${orderNumbers.length}`); + console.log(`Batch size: 70`); + console.log(`Expected batches: ${Math.ceil(orderNumbers.length / 70)}`); + console.log(`Downloaded files: ${result.downloadedFiles.length}`); + console.log(`Total duration: ${duration}s`); + console.log(`Average time per batch: ${(duration / result.downloadedFiles.length).toFixed(2)}s`); + + if (result.errors.length > 0) { + console.log(`\nErrors encountered: ${result.errors.length}`); + result.errors.forEach((err, idx) => console.log(` ${idx + 1}. ${err}`)); + } + + // Verify results + expect(result.downloadedFiles.length).toBeGreaterThanOrEqual(1); + + // Verify each downloaded file exists and has content + for (const filePath of result.downloadedFiles) { + const stats = await fs.stat(filePath); + console.log(` - ${path.basename(filePath)}: ${(stats.size / 1024).toFixed(2)} KB`); + expect(stats.size).toBeGreaterThan(0); + } + + // Clean up + await authService.close(); + }, 600000); // 10 minutes timeout for large batch test }); diff --git a/vitest.config.ts b/vitest.config.ts index 733a8ba..db19750 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ environment: 'node', include: ['tests/**/*.{test,spec}.{ts,tsx}'], exclude: ['node_modules', 'dist', 'out'], + setupFiles: ['tests/setup.ts'], coverage: { provider: 'v8', reporter: ['text', 'json', 'html'],