diff --git a/src/main/services/erp/extractor-core.ts b/src/main/services/erp/extractor-core.ts new file mode 100644 index 0000000..d33ee34 --- /dev/null +++ b/src/main/services/erp/extractor-core.ts @@ -0,0 +1,204 @@ +import path from 'path' +import { ERP_LOCATORS } from './locators' +import type { ErpSession } from '../../types/erp.types' +import type { ExtractorCoreInput, ExtractorCoreResult } from '../../types/extractor.types' + +/** + * ExtractorCore - Handles all web page operations for data extraction + * This class is responsible only for web interactions, not file processing + * + * Note: Uses 'any' for Frame types to maintain compatibility with Playwright's + * frame handling API, matching the original implementation. + */ +export class ExtractorCore { + /** + * Execute all web page operations and return downloaded file paths + * @param input - Contains session, order numbers, download directory, batch size, and progress callback + * @returns List of downloaded file paths and any errors encountered + */ + async downloadAllBatches(input: ExtractorCoreInput): Promise { + const result: ExtractorCoreResult = { + downloadedFiles: [], + errors: [] + } + + // Navigate to extractor page and get popup page + work frame + const { popupPage, workFrame } = await this.navigateToExtractorPage(input.session) + + // Process orders in batches + const batches = this.createBatches(input.orderNumbers, input.batchSize) + + for (let i = 0; i < batches.length; i++) { + const batch = batches[i] + const progress = ((i + 1) / batches.length) * 100 + + input.onProgress?.(`Processing batch ${i + 1}/${batches.length}`, progress) + + try { + const filePath = await this.downloadBatch( + input.session, + popupPage, + workFrame, + batch, + i, + batches.length, + input.downloadDir + ) + result.downloadedFiles.push(filePath) + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + result.errors.push(`Batch ${i + 1}: ${message}`) + } + } + + return result + } + + /** + * Navigate to extractor/query page + * 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<{ popupPage: any; workFrame: any }> { + const { page, mainFrame } = session + + // Step 1: Click menu icon (Python line 266) + // main_frame is #forwardFrame.content_frame returned from login + await mainFrame.locator('i').first().click() + + // 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 + + // 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() + + 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() + + if (!workFrame) { + throw new Error('Failed to access inner work frame') + } + + // Step 5: Setup query interface (Python line 278) + await this.setupQueryInterface(workFrame) + + return { popupPage, workFrame } + } + + /** + * Setup query interface + * Reference: Python setup_query_interface() method lines 231-239 + */ + private async setupQueryInterface(innerFrame: any): Promise { + // Click search icon (Python line 233) + await innerFrame.locator('.search-name-wrapper > .iconfont').click() + + // Click "订单号查询" menu item (Python line 234) + await innerFrame.getByText('订单号查询').click() + + // Click "全部" tab (Python line 235) + await innerFrame.getByRole('tab', { name: '全部' }).click() + + // Set limit to 5000 (Python lines 237-239) + const inputBox = innerFrame.locator('#rc_select_0') + await inputBox.fill('5000') + await inputBox.press('Enter') + } + + /** + * Download a single batch of orders + * Reference: Python download_batch() method lines 133-175 + */ + private async downloadBatch( + session: ErpSession, + popupPage: any, + workFrame: any, + orderNumbers: string[], + batchIndex: number, + totalBatches: number, + downloadDir: string + ): Promise { + // Fill order numbers (Python lines 143-145) + const textbox = workFrame.getByRole('textbox', { name: '来源生产订单号' }) + await textbox.fill('') + await textbox.fill(orderNumbers.join(',')) + + // Click search button (Python line 147) + await workFrame.locator('.search-component-searchBtn').click() + + // Wait for loading (Python lines 148-153) + await this.waitForLoading(workFrame) + + // Click first row checkbox (Python line 155) + await workFrame.getByRole('row', { name: '序号' }).getByLabel('').click() + + // Hover and click "更多" button (Python lines 156-157) + await workFrame.getByRole('button', { name: '更多' }).hover() + await workFrame.getByText('输出', { exact: true }).click() + + // 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 (Python lines 166-172) + const downloadPath = path.join(downloadDir, `temp_batch_${batchIndex + 1}.xlsx`) + + const downloadPromise = popupPage.waitForEvent('download') + await workFrame.getByRole('button', { name: '确定(Y)' }).click() + + const download = await downloadPromise + await download.saveAs(downloadPath) + + return downloadPath + } + + /** + * Wait for loading overlay to disappear + * Reference: Python lines 148-153 + */ + private async waitForLoading(workFrame: any): Promise { + const loadingLocator = workFrame + .locator('div') + .filter({ hasText: ERP_LOCATORS.extractor.loadingText }) + .nth(1) + + try { + await loadingLocator.waitFor({ state: 'visible', timeout: 3000 }) + await loadingLocator.waitFor({ state: 'hidden', timeout: 0 }) + } catch { + // Loading completed quickly or never appeared + } + } + + /** + * Split array into batches + * Reference: Python group_order_ids() method lines 128-131 + */ + private createBatches(items: T[], batchSize: number): T[][] { + const batches: T[][] = [] + for (let i = 0; i < items.length; i += batchSize) { + batches.push(items.slice(i, i + batchSize)) + } + return batches + } +} \ No newline at end of file diff --git a/src/main/services/erp/extractor.ts b/src/main/services/erp/extractor.ts index 71d29a1..bafbea3 100644 --- a/src/main/services/erp/extractor.ts +++ b/src/main/services/erp/extractor.ts @@ -1,16 +1,18 @@ import path from 'path' import fs from 'fs/promises' -import { ERP_LOCATORS } from './locators' +import { ExtractorCore } from './extractor-core' import { ErpAuthService } from './erp-auth' import { ExcelParser } from '../excel/excel-parser' import type { ExtractorInput, ExtractorResult } from '../../types/extractor.types' -import type { ErpSession } from '../../types/erp.types' -import type { DiscreteMaterialPlan } from '../../types/excel.types' /** * ERP Data Extractor Service * Downloads material plan data for given order numbers * + * This service orchestrates the extraction process: + * - Uses ExtractorCore for web page operations + * - Handles file merging and cleanup + * * Reference: playwrite/utils/discrete_material_plan_extractor.py */ export class ExtractorService { @@ -27,6 +29,8 @@ export class ExtractorService { /** * Extract data for given order numbers + * Orchestrates the extraction process by delegating web operations to ExtractorCore + * and handling file merging/cleanup */ async extract(input: ExtractorInput): Promise { const result: ExtractorResult = { @@ -39,36 +43,20 @@ export class ExtractorService { try { const session = this.authService.getSession() - // Navigate to extractor page and get popup page + work frame - const { popupPage, workFrame } = await this.navigateToExtractorPage(session) + // Call ExtractorCore to execute web page operations + const core = new ExtractorCore() + const coreResult = await core.downloadAllBatches({ + session, + orderNumbers: input.orderNumbers, + downloadDir: this.downloadDir, + batchSize: input.batchSize || 100, + onProgress: input.onProgress + }) - // Process orders in batches - const batchSize = input.batchSize || 100 - const batches = this.createBatches(input.orderNumbers, batchSize) + result.downloadedFiles = coreResult.downloadedFiles + result.errors = coreResult.errors - for (let i = 0; i < batches.length; i++) { - const batch = batches[i] - const progress = ((i + 1) / batches.length) * 100 - - input.onProgress?.(`Processing batch ${i + 1}/${batches.length}`, progress) - - try { - 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' - result.errors.push(`Batch ${i + 1}: ${message}`) - } - } - - // Merge downloaded files into a single Excel file + // Merge downloaded files (original logic preserved) if (result.downloadedFiles.length > 0) { input.onProgress?.('正在合并文件...', 95) const mergeResult = await this.mergeFiles(result.downloadedFiles) @@ -91,153 +79,6 @@ export class ExtractorService { return result } - /** - * Navigate to extractor/query page - * 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<{ popupPage: any; workFrame: any }> { - const { page, mainFrame } = session - - // Step 1: Click menu icon (Python line 266) - // main_frame is #forwardFrame.content_frame returned from login - await mainFrame.locator('i').first().click() - - // 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 - - // 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() - - 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() - - if (!workFrame) { - throw new Error('Failed to access inner work frame') - } - - // Step 5: Setup query interface (Python line 278) - await this.setupQueryInterface(workFrame) - - return { popupPage, workFrame } - } - - /** - * Setup query interface - * Reference: Python setup_query_interface() method lines 231-239 - */ - private async setupQueryInterface(innerFrame: any): Promise { - // Click search icon (Python line 233) - await innerFrame.locator('.search-name-wrapper > .iconfont').click() - - // Click "订单号查询" menu item (Python line 234) - await innerFrame.getByText('订单号查询').click() - - // Click "全部" tab (Python line 235) - await innerFrame.getByRole('tab', { name: '全部' }).click() - - // Set limit to 5000 (Python lines 237-239) - const inputBox = innerFrame.locator('#rc_select_0') - await inputBox.fill('5000') - await inputBox.press('Enter') - } - - /** - * Download a single batch of orders - * Reference: Python download_batch() method lines 133-175 - */ - private async downloadBatch( - session: ErpSession, - popupPage: any, - workFrame: any, - orderNumbers: string[], - batchIndex: number, - totalBatches: number - ): Promise { - // Fill order numbers (Python lines 143-145) - const textbox = workFrame.getByRole('textbox', { name: '来源生产订单号' }) - await textbox.fill('') - await textbox.fill(orderNumbers.join(',')) - - // Click search button (Python line 147) - await workFrame.locator('.search-component-searchBtn').click() - - // Wait for loading (Python lines 148-153) - await this.waitForLoading(workFrame) - - // Click first row checkbox (Python line 155) - await workFrame.getByRole('row', { name: '序号' }).getByLabel('').click() - - // Hover and click "更多" button (Python lines 156-157) - await workFrame.getByRole('button', { name: '更多' }).hover() - await workFrame.getByText('输出', { exact: true }).click() - - // 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 (Python lines 166-172) - const downloadPath = path.join(this.downloadDir, `temp_batch_${batchIndex + 1}.xlsx`) - - const downloadPromise = popupPage.waitForEvent('download') - await workFrame.getByRole('button', { name: '确定(Y)' }).click() - - const download = await downloadPromise - await download.saveAs(downloadPath) - - return downloadPath - } - - /** - * Wait for loading overlay to disappear - * Reference: Python lines 148-153 - */ - private async waitForLoading(workFrame: any): Promise { - const loadingLocator = workFrame - .locator('div') - .filter({ hasText: ERP_LOCATORS.extractor.loadingText }) - .nth(1) - - try { - await loadingLocator.waitFor({ state: 'visible', timeout: 3000 }) - await loadingLocator.waitFor({ state: 'hidden', timeout: 0 }) - } catch { - // Loading completed quickly or never appeared - } - } - - /** - * Split array into batches - * Reference: Python group_order_ids() method lines 128-131 - */ - private createBatches(items: T[], batchSize: number): T[][] { - const batches: T[][] = [] - for (let i = 0; i < items.length; i += batchSize) { - batches.push(items.slice(i, i + batchSize)) - } - return batches - } - /** * Merge downloaded Excel files into a single file * Uses ExcelParser to parse and combine all material plans @@ -428,4 +269,4 @@ export class ExtractorService { } } } -} +} \ No newline at end of file diff --git a/src/main/services/erp/order-resolver.ts b/src/main/services/erp/order-resolver.ts index eab0a71..44198ea 100644 --- a/src/main/services/erp/order-resolver.ts +++ b/src/main/services/erp/order-resolver.ts @@ -12,6 +12,19 @@ */ import type { IDatabaseService } from '../database' +import { SqlServerService } from '../database/sql-server' +import { createLogger } from '../logger' + +const log = createLogger('OrderResolver') + +// Dynamically import mssql for SQL Server parameter types +let mssql: typeof import('mssql') | null = null +async function getMssql() { + if (!mssql) { + mssql = await import('mssql') + } + return mssql +} /** * Order mapping result @@ -77,6 +90,24 @@ export class OrderNumberResolver { this.dbService = dbService } + /** + * Get table name based on database type + * Converts MySQL schema_tablename format to SQL Server [schema].[tablename] format + * e.g., productionContractData_26年压力表合同数据 -> [productionContractData].[26年压力表合同数据] + */ + private getTableName(mysqlTableName: string): string { + if (this.dbService.type === 'sqlserver') { + const firstUnderscoreIndex = mysqlTableName.indexOf('_') + if (firstUnderscoreIndex > 0) { + const schema = mysqlTableName.substring(0, firstUnderscoreIndex) + const tableName = mysqlTableName.substring(firstUnderscoreIndex + 1) + return `[${schema}].[${tableName}]` + } + return `[dbo].[${mysqlTableName}]` + } + return mysqlTableName + } + /** * Recognize the type of an input string * @param input - The input string to recognize @@ -217,35 +248,56 @@ export class OrderNumberResolver { } try { - // Build query - use different placeholder style based on database type const isSqlServer = this.dbService.type === 'sqlserver' - const placeholders = isSqlServer - ? productionIds.map((_, idx) => `@p${idx}`).join(', ') - : productionIds.map(() => '?').join(', ') + const tableName = this.getTableName(DB_CONFIG.TABLE_NAME) - // Use appropriate quoting for table/field names - const quote = isSqlServer ? '' : '`' - const query = ` - SELECT ${quote}${DB_CONFIG.FIELD_PRODUCTION_ID}${quote}, ${quote}${DB_CONFIG.FIELD_ORDER_NUMBER}${quote} - FROM ${quote}${DB_CONFIG.TABLE_NAME}${quote} - WHERE ${quote}${DB_CONFIG.FIELD_PRODUCTION_ID}${quote} IN (${placeholders}) - ` + log.debug('Resolving production IDs', { count: productionIds.length, dbType: this.dbService.type }) - const result = await this.dbService.query(query, productionIds) + let result - // Create a map for quick lookup + if (isSqlServer) { + // Use queryWithParams for SQL Server with explicit parameter types + const sql = await getMssql() + const placeholders = productionIds.map((_, idx) => `@p${idx}`).join(', ') + const params: Record = {} + + productionIds.forEach((id, idx) => { + params[`p${idx}`] = { value: id, type: sql.NVarChar } + }) + + const query = ` + SELECT ${DB_CONFIG.FIELD_PRODUCTION_ID}, ${DB_CONFIG.FIELD_ORDER_NUMBER} + FROM ${tableName} + WHERE ${DB_CONFIG.FIELD_PRODUCTION_ID} IN (${placeholders}) + ` + + result = await (this.dbService as SqlServerService).queryWithParams(query, params) + } else { + // Use standard query for MySQL + const placeholders = productionIds.map(() => '?').join(', ') + const query = ` + SELECT \`${DB_CONFIG.FIELD_PRODUCTION_ID}\`, \`${DB_CONFIG.FIELD_ORDER_NUMBER}\` + FROM ${tableName} + WHERE \`${DB_CONFIG.FIELD_PRODUCTION_ID}\` IN (${placeholders}) + ` + result = await this.dbService.query(query, productionIds) + } + + // Create a map for quick lookup (use lowercase key for case-insensitive matching) const resultMap = new Map() for (const row of result.rows) { const prodId = row[DB_CONFIG.FIELD_PRODUCTION_ID] as string const orderNum = row[DB_CONFIG.FIELD_ORDER_NUMBER] as string if (prodId && orderNum) { - resultMap.set(prodId, orderNum) + // Store with lowercase key for case-insensitive matching + resultMap.set(prodId.toLowerCase(), orderNum) } } // Build mappings for (const pid of productionIds) { - const orderNumber = resultMap.get(pid) + // Use lowercase for case-insensitive lookup + const orderNumber = resultMap.get(pid.toLowerCase()) if (orderNumber) { mappings.push({ @@ -292,21 +344,39 @@ export class OrderNumberResolver { } try { - // Build query - use different placeholder style based on database type const isSqlServer = this.dbService.type === 'sqlserver' - const placeholders = isSqlServer - ? orderNumbers.map((_, idx) => `@p${idx}`).join(', ') - : orderNumbers.map(() => '?').join(', ') + const tableName = this.getTableName(DB_CONFIG.TABLE_NAME) - // Use appropriate quoting for table/field names - const quote = isSqlServer ? '' : '`' - const query = ` - SELECT ${quote}${DB_CONFIG.FIELD_ORDER_NUMBER}${quote} - FROM ${quote}${DB_CONFIG.TABLE_NAME}${quote} - WHERE ${quote}${DB_CONFIG.FIELD_ORDER_NUMBER}${quote} IN (${placeholders}) - ` + let result + + if (isSqlServer) { + // Use queryWithParams for SQL Server with explicit parameter types + const sql = await getMssql() + const placeholders = orderNumbers.map((_, idx) => `@p${idx}`).join(', ') + const params: Record = {} + + orderNumbers.forEach((id, idx) => { + params[`p${idx}`] = { value: id, type: sql.NVarChar } + }) + + const query = ` + SELECT ${DB_CONFIG.FIELD_ORDER_NUMBER} + FROM ${tableName} + WHERE ${DB_CONFIG.FIELD_ORDER_NUMBER} IN (${placeholders}) + ` + + result = await (this.dbService as SqlServerService).queryWithParams(query, params) + } else { + // Use standard query for MySQL + const placeholders = orderNumbers.map(() => '?').join(', ') + const query = ` + SELECT \`${DB_CONFIG.FIELD_ORDER_NUMBER}\` + FROM ${tableName} + WHERE \`${DB_CONFIG.FIELD_ORDER_NUMBER}\` IN (${placeholders}) + ` + result = await this.dbService.query(query, orderNumbers) + } - const result = await this.dbService.query(query, orderNumbers) return result.rows.map((row) => row[DB_CONFIG.FIELD_ORDER_NUMBER] as string) } catch (error) { console.warn('[OrderResolver] Failed to verify order numbers:', error) diff --git a/src/main/types/extractor.types.ts b/src/main/types/extractor.types.ts index 16fcdb2..3d8e667 100644 --- a/src/main/types/extractor.types.ts +++ b/src/main/types/extractor.types.ts @@ -1,3 +1,5 @@ +import type { ErpSession } from './erp.types' + export interface ExtractorInput { orderNumbers: string[] batchSize?: number @@ -15,3 +17,22 @@ export interface OrderInfo { orderNumber: string productionId: string } + +/** + * Input for ExtractorCore - handles web page operations + */ +export interface ExtractorCoreInput { + session: ErpSession + orderNumbers: string[] + downloadDir: string + batchSize: number + onProgress?: (message: string, progress: number) => void +} + +/** + * Result from ExtractorCore - list of downloaded file paths + */ +export interface ExtractorCoreResult { + downloadedFiles: string[] + errors: string[] +}