import path from 'path' import { ERP_LOCATORS } from './locators' import type { ErpSession } from '../../types/erp.types' import type { ExtractorCoreInput, ExtractorCoreResult, ExtractionProgress } from '../../types/extractor.types' import { createLogger } from '../logger' import { capturePageContext } from './erp-error-context' const log = createLogger('ExtractorCore') /** * 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: [] } const totalBatches = this.createBatches(input.orderNumbers, input.batchSize).length log.info('开始下载所有批次', { totalOrders: input.orderNumbers.length, totalBatches, batchSize: input.batchSize }) const totalPoints = 1 + totalBatches + 2 const progressPerPoint = 100 / totalPoints const { popupPage, workFrame } = await this.navigateToExtractorPage(input.session) const batches = this.createBatches(input.orderNumbers, input.batchSize) for (let i = 0; i < batches.length; i++) { const batch = batches[i] const progress = (1 + (i + 1)) * progressPerPoint const progressExtra: Partial = { phase: 'downloading', currentBatch: i + 1, totalBatches } input.onProgress?.(`处理批次 ${i + 1}/${totalBatches}`, progress, progressExtra) 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' log.error('批次下载失败', { batchIndex: i, totalBatches, error: message }) result.errors.push(`Batch ${i + 1}: ${message}`) } } log.info('所有批次下载完成', { downloadedCount: result.downloadedFiles.length, errorCount: result.errors.length }) 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() log.debug('导航: 已点击菜单图标') // 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 log.debug('导航: 弹出窗口已打开') // 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() log.debug('导航: 已获取 forwardFrame') if (!fFrame) { log.error('Failed to access popup forward frame', { ...(await capturePageContext( popupPage, undefined, 'navigate.forwardFrame', undefined, undefined, 'navigate_forward_frame' )) }) 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() log.debug('导航: 已获取内部工作框架') if (!workFrame) { log.error('Failed to access inner work frame', { ...(await capturePageContext( popupPage, undefined, 'navigate.innerFrame', undefined, undefined, 'navigate_inner_frame' )) }) throw new Error('Failed to access inner work frame') } // Step 5: Setup query interface (Python line 278) await this.setupQueryInterface(workFrame) log.info('提取器页面导航完成') 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() log.debug('查询界面: 已点击搜索图标') // Click "订单号查询" menu item (Python line 234) await innerFrame.getByText('订单号查询').click() log.debug('查询界面: 已点击订单号查询') // Click "全部" tab (Python line 235) await innerFrame.getByRole('tab', { name: '全部' }).click() log.debug('查询界面: 已切换到全部标签页') // Set limit to 5000 (Python lines 237-239) const inputBox = innerFrame.locator('#rc_select_0') await inputBox.fill('5000') await inputBox.press('Enter') log.debug('查询界面: 已设置查询限制为5000') } /** * 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 { log.info('开始下载批次', { batchIndex: batchIndex + 1, orderCount: orderNumbers.length }) // Fill order numbers (Python lines 143-145) const textbox = workFrame.getByRole('textbox', { name: '来源生产订单号' }) await textbox.fill('') await textbox.fill(orderNumbers.join(',')) log.debug('已填入订单号', { orderCount: orderNumbers.length }) // Click search button (Python line 147) await workFrame.locator('.search-component-searchBtn').click() log.debug('已点击搜索按钮') // Wait for loading (Python lines 148-153) await this.waitForLoading(workFrame) log.debug('查询加载完成') // 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) log.info('批次下载完成', { batchIndex: batchIndex + 1, 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 } }