diff --git a/src/main/services/erp/cleaner.ts b/src/main/services/erp/cleaner.ts index 2ab3e3c..f6f8b02 100644 --- a/src/main/services/erp/cleaner.ts +++ b/src/main/services/erp/cleaner.ts @@ -1,4 +1,3 @@ -import { ERP_LOCATORS } from './locators' import { ErpAuthService } from './erp-auth' import { DeletionErrorCategory, DeletionOutcome } from '../../types/cleaner.types' import type { CleanerInput, CleanerResult, OrderCleanDetail } from '../../types/cleaner.types' @@ -10,6 +9,7 @@ import { capturePageState } from './page-state' import { AsyncMutex, ConcurrencyTracker } from './cleaner/concurrency' import { evaluateDeletionSignals as evaluateDeletionSignalsHelper } from './cleaner/deletion-signals' import { MaterialDeletionVerifier } from './cleaner/material-deletion-verifier' +import { CleanerNavigation } from './cleaner/navigation' import { getSkipReason, shouldDeleteMaterial } from './cleaner/rules' import { clampNumber, @@ -45,11 +45,15 @@ export class CleanerService { private authService: ErpAuthService private dryRun: boolean private deletionVerifier: MaterialDeletionVerifier + private navigation: CleanerNavigation constructor(authService: ErpAuthService, options: CleanerOptions = {}) { this.authService = authService this.dryRun = options.dryRun ?? false this.deletionVerifier = new MaterialDeletionVerifier(log) + this.navigation = new CleanerNavigation(log, (page, step, snapshotOptions) => + this.logPageStateSnapshot(page, step, snapshotOptions) + ) } /** @@ -456,141 +460,11 @@ export class CleanerService { async navigateToCleanerPage( session: ErpSession ): Promise<{ popupPage: Page; workFrame: FrameLocator }> { - const { page, mainFrame } = session - const navStartTime = Date.now() - - log.info('开始导航到清理页面', { - sessionAvailable: !!session, - hasPage: !!page, - hasMainFrame: !!mainFrame - }) - - // Step 1: Click menu icon - log.debug('[导航 Step 1] 准备点击菜单图标') - await mainFrame.locator('i').first().click() - log.debug('[导航 Step 1 完成] 菜单图标已点击', { elapsedMs: Date.now() - navStartTime }) - - // Step 2: Wait for popup and click menu item - log.debug('[导航 Step 2] 等待弹出窗口并点击菜单项') - const popupPromise = page.waitForEvent('popup') - await mainFrame.getByTitle('离散生产订单维护', { exact: true }).first().click() - const popupPage = await popupPromise - log.debug('[导航 Step 2 完成] 弹出窗口已打开', { - elapsedMs: Date.now() - navStartTime, - popupOpened: !!popupPage - }) - await this.logPageStateSnapshot(popupPage, 'nav.popup_opened', { - level: 'info', - elapsedMs: Date.now() - navStartTime - }) - - // Step 3: Get forward frame - log.debug('[导航 Step 3] 获取 forwardFrame 框架') - const forwardFrameLocator = popupPage.locator('#forwardFrame') - const fFrame = forwardFrameLocator.contentFrame() - log.debug('[导航 Step 3 完成] forwardFrame 已获取', { - elapsedMs: Date.now() - navStartTime, - frameExists: !!fFrame - }) - - if (!fFrame) { - log.error('[导航失败] forwardFrame 为空', { - elapsedMs: Date.now() - navStartTime, - pageUrl: popupPage.url(), - contextData: await capturePageContext( - popupPage, - undefined, - 'nav.forwardFrame', - undefined, - undefined, - 'nav_forward_frame' - ) - }) - throw new Error('无法访问弹出窗口的 forwardFrame') - } - - // Step 4: Get inner work frame - log.debug('[导航 Step 4] 等待并获取 mainiframe 内部框架', { timeout: 30000 }) - const innerFrameLocator = fFrame.locator('#mainiframe') - await innerFrameLocator.waitFor({ state: 'visible', timeout: 30000 }) - const workFrame = innerFrameLocator.contentFrame() - log.debug('[导航 Step 4 完成] 内部工作框架已获取', { - elapsedMs: Date.now() - navStartTime, - frameExists: !!workFrame - }) - - if (!workFrame) { - log.error('[导航失败] workFrame 为空', { - elapsedMs: Date.now() - navStartTime, - pageUrl: popupPage.url(), - contextData: await capturePageContext( - popupPage, - undefined, - 'nav.workFrame', - undefined, - undefined, - 'nav_work_frame' - ) - }) - throw new Error('无法访问内部工作框架') - } - - // Step 5: Wait for page ready - log.debug('[导航 Step 5] 等待页面就绪标志', { selector: '#hot-key-head_list', timeout: 30000 }) - await workFrame.locator('#hot-key-head_list').waitFor({ state: 'visible', timeout: 30000 }) - const totalNavTime = Date.now() - navStartTime - await this.logPageStateSnapshot(popupPage, 'nav.cleaner_page_ready', { - level: 'info', - elapsedMs: totalNavTime - }) - log.info('[导航完成] 已导航到清理页面', { - totalNavTimeMs: totalNavTime, - isSlow: totalNavTime > 5000 - }) - - return { popupPage, workFrame } + return this.navigation.navigateToCleanerPage(session) } private async setupQueryInterface(innerFrame: FrameLocator, popupPage: Page): Promise { - const setupStartTime = Date.now() - log.debug('[查询界面设置开始] 准备配置查询界面') - await this.logPageStateSnapshot(popupPage, 'query.setup.start', { - level: 'debug', - elapsedMs: 0 - }) - - // Step 1: Click search icon - log.debug('[查询设置 Step 1] 点击搜索图标') - await innerFrame.locator('.search-name-wrapper > .iconfont').click() - log.debug('[查询设置 Step 1 完成] 搜索图标已点击', { elapsedMs: Date.now() - setupStartTime }) - - // Step 2: Select order number query mode - log.debug('[查询设置 Step 2] 选择订单号查询模式') - await innerFrame.getByText('订单号查询').click() - log.debug('[查询设置 Step 2 完成] 已切换到订单号查询', { - elapsedMs: Date.now() - setupStartTime - }) - - // Step 3: Switch to "All" tab - log.debug('[查询设置 Step 3] 切换到全部标签页') - await innerFrame.getByRole('tab', { name: '全部' }).click() - log.debug('[查询设置 Step 3 完成] 已全部标签页激活', { elapsedMs: Date.now() - setupStartTime }) - - // Step 4: Set query limit to 5000 - log.debug('[查询设置 Step 4] 设置查询数量限制为 5000') - const inputEl = innerFrame.locator('#rc_select_0') - await inputEl.fill('5000') - await inputEl.press('Enter') - const totalSetupTime = Date.now() - setupStartTime - await this.logPageStateSnapshot(popupPage, 'query.setup.ready', { - level: 'info', - elapsedMs: totalSetupTime - }) - log.debug('[查询设置完成] 查询界面配置完毕', { - totalSetupTimeMs: totalSetupTime, - queryLimit: 5000, - isSlow: totalSetupTime > 2000 - }) + await this.navigation.setupQueryInterface(innerFrame, popupPage) } private async queryOrders( @@ -598,115 +472,14 @@ export class CleanerService { popupPage: Page, orderNumbers: string[] ): Promise { - const queryStartTime = Date.now() - log.debug('[订单查询开始]', { - orderCount: orderNumbers.length, - orderNumbers: orderNumbers - .slice(0, 5) - .concat(orderNumbers.length > 5 ? [`... (${orderNumbers.length - 5} more)`] : []), - isPreview: orderNumbers.length > 5 - }) - await this.logPageStateSnapshot(popupPage, 'query.before_submit', { - level: 'debug', - elapsedMs: 0 - }) - - const textbox = workFrame.getByRole('textbox', { name: '生产订单号' }) - log.debug('[订单查询] 准备填入订单号') - await textbox.fill(orderNumbers.join(',')) - log.debug('[订单查询] 订单号已填入', { - elapsedMs: Date.now() - queryStartTime, - charCount: orderNumbers.join(',').length - }) - - log.debug('[订单查询] 点击搜索按钮') - await workFrame.locator('.search-component-searchBtn').click() - log.info('[订单查询完成] 查询请求已发送', { - elapsedMs: Date.now() - queryStartTime, - orderCount: orderNumbers.length - }) - await this.logPageStateSnapshot(popupPage, 'query.after_submit', { - level: 'info', - elapsedMs: Date.now() - queryStartTime - }) + await this.navigation.queryOrders(workFrame, popupPage, orderNumbers) } private async collectQueryResultRows( workFrame: FrameLocator, popupPage: Page ): Promise { - const collectStartTime = Date.now() - log.debug('[查询结果收集开始] 准备读取查询结果表格') - - const rows = workFrame.locator('tbody tr') - const rowCount = await rows.count() - log.debug('[查询结果收集] 检测到表格行数', { rowCount }) - - const result: QueryResultRow[] = [] - let validOrderCount = 0 - let invalidOrderCount = 0 - - for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) { - const row = rows.nth(rowIndex) - const orderNumber = await this.extractOrderNumberFromQueryRow(row) - if (!this.isOrderNumber(orderNumber)) { - invalidOrderCount++ - if (invalidOrderCount <= 3) { - log.warn('[查询结果] 跳过无效订单号行', { rowIndex, extractedValue: orderNumber }) - } - continue - } - validOrderCount++ - result.push({ rowIndex, orderNumber }) - } - - const totalCollectTime = Date.now() - collectStartTime - await this.logPageStateSnapshot(popupPage, 'query.results.collected', { - level: 'info', - elapsedMs: totalCollectTime - }) - log.info('[查询结果收集完成]', { - totalRowsScanned: rowCount, - validOrderCount, - invalidOrderCount, - elapsedMs: totalCollectTime, - isSlow: totalCollectTime > 3000 - }) - - return result - } - - private async extractOrderNumberFromQueryRow(row: Locator): Promise { - try { - const cell = row.locator('td[colkey="vbillcode"]') - const codeLink = cell.locator('.code-detail-link').first() - const linkCount = await codeLink.count() - - log.verbose('[提取订单号] 尝试从行提取订单号', { - hasLink: linkCount > 0, - extractionMethod: linkCount > 0 ? 'link' : 'cellText' - }) - - const rawValue = linkCount > 0 ? await codeLink.innerText() : await cell.innerText() - const value = rawValue.trim() - const match = value.match(/SC\d{14}/) - const extracted = match ? match[0] : value - - log.verbose('[提取订单号完成]', { - rawValue, - extracted, - matched: !!match - }) - - return extracted - } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error' - log.warn('[提取订单号失败] 提取过程发生错误', { - error: message, - fallback: 'empty string' - }) - return '' - } + return this.navigation.collectQueryResultRows(workFrame, popupPage) } private async openDetailPageFromRow( @@ -720,53 +493,7 @@ export class CleanerService { workerId?: number } ): Promise { - const openStartTime = Date.now() - const row = workFrame.locator('tbody tr').nth(rowIndex) - await row.waitFor({ state: 'visible', timeout: 15000 }) - log.info('[NAV_EVENT] 准备从查询结果打开详情页', { - step: 'detail.open.from_query_row', - rowIndex, - orderNumber: options?.orderNumber, - orderIndex: options?.orderIndex, - orderPosition: options?.orderPosition, - workerId: options?.workerId - }) - await this.logPageStateSnapshot(popupPage, 'detail.open.from_query_row.before_click', { - level: 'debug', - orderNumber: options?.orderNumber, - orderIndex: options?.orderIndex, - orderPosition: options?.orderPosition, - workerId: options?.workerId, - elapsedMs: Date.now() - openStartTime - }) - - const moreButton = row.locator('a.row-more').first() - await moreButton.scrollIntoViewIfNeeded() - - const detailPagePromise = popupPage.waitForEvent('popup') - await moreButton.click() - await this.clickMaterialPlanMenu(workFrame, popupPage, options) - - const detailPage = await detailPagePromise - log.info('[POPUP_EVENT] 详情页弹窗已创建', { - step: 'detail.popup.opened', - rowIndex, - orderNumber: options?.orderNumber, - orderIndex: options?.orderIndex, - orderPosition: options?.orderPosition, - workerId: options?.workerId, - popupUrl: detailPage.url(), - elapsedMs: Date.now() - openStartTime - }) - await this.logPageStateSnapshot(detailPage, 'detail.popup.opened', { - level: 'info', - orderNumber: options?.orderNumber, - orderIndex: options?.orderIndex, - orderPosition: options?.orderPosition, - workerId: options?.workerId, - elapsedMs: Date.now() - openStartTime - }) - return detailPage + return this.navigation.openDetailPageFromRow(workFrame, popupPage, rowIndex, options) } private async openDetailPageFromCurrentQuery( @@ -774,87 +501,7 @@ export class CleanerService { popupPage: Page, orderNumber?: string ): Promise { - const openStartTime = Date.now() - const firstRow = workFrame.locator('tbody tr').first() - await firstRow.waitFor({ state: 'visible', timeout: 10000 }) - log.info('[NAV_EVENT] 准备从当前查询结果打开详情页', { - step: 'detail.open.from_current_query', - orderNumber - }) - await this.logPageStateSnapshot(popupPage, 'detail.open.from_current_query.before_click', { - level: 'debug', - orderNumber, - elapsedMs: Date.now() - openStartTime - }) - - const moreButton = firstRow.locator('a.row-more').first() - const detailPagePromise = popupPage.waitForEvent('popup') - await moreButton.click() - await this.clickMaterialPlanMenu(workFrame, popupPage, { orderNumber }) - - const detailPage = await detailPagePromise - log.info('[POPUP_EVENT] 详情页弹窗已创建', { - step: 'detail.popup.opened.retry', - orderNumber, - popupUrl: detailPage.url(), - elapsedMs: Date.now() - openStartTime - }) - await this.logPageStateSnapshot(detailPage, 'detail.popup.opened.retry', { - level: 'info', - orderNumber, - elapsedMs: Date.now() - openStartTime - }) - return detailPage - } - - private async clickMaterialPlanMenu( - workFrame: FrameLocator, - popupPage: Page, - options?: { - orderNumber?: string - orderIndex?: number - orderPosition?: string - workerId?: number - } - ): Promise { - const candidates = [ - workFrame.locator('li:visible, a:visible, span:visible, div:visible').filter({ - hasText: /^备料计划$/ - }), - workFrame.getByRole('menuitem', { name: '备料计划' }), - workFrame.getByText('备料计划', { exact: true }), - workFrame.getByText('备料计划') - ] - - for (const candidate of candidates) { - const target = candidate.last() - try { - await target.waitFor({ state: 'visible', timeout: 2000 }) - log.info('[NAV_EVENT] 点击备料计划菜单', { - step: 'detail.menu.material_plan', - orderNumber: options?.orderNumber, - orderIndex: options?.orderIndex, - orderPosition: options?.orderPosition, - workerId: options?.workerId - }) - await target.click() - await this.logPageStateSnapshot(popupPage, 'detail.menu.material_plan.clicked', { - level: 'debug', - orderNumber: options?.orderNumber, - orderIndex: options?.orderIndex, - orderPosition: options?.orderPosition, - workerId: options?.workerId - }) - return - } catch { - // Try next locator candidate - } - } - - log.error('Failed to locate material plan menu item', { - selectorsAttempted: candidates.length - }) - throw new Error('无法定位”备料计划”菜单项(可能菜单结构已变化)') + return this.navigation.openDetailPageFromCurrentQuery(workFrame, popupPage, orderNumber) } private getBrowserContext(): BrowserContext | undefined { @@ -1583,17 +1230,7 @@ export class CleanerService { } private async waitForLoading(frame: FrameLocator): Promise { - const loadingLocator = frame - .locator('div') - .filter({ hasText: ERP_LOCATORS.extractor.loadingText }) - .nth(1) - - try { - await loadingLocator.waitFor({ state: 'visible', timeout: 3000 }) - await loadingLocator.waitFor({ state: 'hidden', timeout: 60000 }) - } catch { - // Loading completed quickly or never appeared - } + await this.navigation.waitForLoading(frame) } private async getInputValue( diff --git a/src/main/services/erp/cleaner/navigation.ts b/src/main/services/erp/cleaner/navigation.ts new file mode 100644 index 0000000..4f2bf02 --- /dev/null +++ b/src/main/services/erp/cleaner/navigation.ts @@ -0,0 +1,450 @@ +import type { FrameLocator, Locator, Page } from 'playwright' +import type { ErpSession } from '../../../types/erp.types' +import { ERP_LOCATORS } from '../locators' +import { capturePageContext } from '../erp-error-context' +import type { QueryResultRow } from './types' + +type CleanerLogger = { + debug: (message: string, meta?: Record) => void + info: (message: string, meta?: Record) => void + warn: (message: string, meta?: Record) => void + error: (message: string, meta?: Record) => void + verbose: (message: string, meta?: Record) => void +} + +export interface DetailOpenOptions { + orderNumber?: string + orderIndex?: number + orderPosition?: string + workerId?: number +} + +export interface PageStateSnapshotOptions extends DetailOpenOptions { + level?: 'info' | 'warn' | 'error' | 'debug' + elapsedMs?: number +} + +type LogPageStateSnapshot = ( + page: Page, + step: string, + options?: PageStateSnapshotOptions +) => Promise + +export class CleanerNavigation { + constructor( + private readonly log: CleanerLogger, + private readonly logPageStateSnapshot: LogPageStateSnapshot + ) {} + + async navigateToCleanerPage( + session: ErpSession + ): Promise<{ popupPage: Page; workFrame: FrameLocator }> { + const { page, mainFrame } = session + const navStartTime = Date.now() + + this.log.info('开始导航到清理页面', { + sessionAvailable: !!session, + hasPage: !!page, + hasMainFrame: !!mainFrame + }) + + this.log.debug('[导航 Step 1] 准备点击菜单图标') + await mainFrame.locator('i').first().click() + this.log.debug('[导航 Step 1 完成] 菜单图标已点击', { + elapsedMs: Date.now() - navStartTime + }) + + this.log.debug('[导航 Step 2] 等待弹出窗口并点击菜单项') + const popupPromise = page.waitForEvent('popup') + await mainFrame.getByTitle('离散生产订单维护', { exact: true }).first().click() + const popupPage = await popupPromise + this.log.debug('[导航 Step 2 完成] 弹出窗口已打开', { + elapsedMs: Date.now() - navStartTime, + popupOpened: !!popupPage + }) + await this.logPageStateSnapshot(popupPage, 'nav.popup_opened', { + level: 'info', + elapsedMs: Date.now() - navStartTime + }) + + this.log.debug('[导航 Step 3] 获取 forwardFrame 框架') + const forwardFrameLocator = popupPage.locator('#forwardFrame') + const fFrame = forwardFrameLocator.contentFrame() + this.log.debug('[导航 Step 3 完成] forwardFrame 已获取', { + elapsedMs: Date.now() - navStartTime, + frameExists: !!fFrame + }) + + if (!fFrame) { + this.log.error('[导航失败] forwardFrame 为空', { + elapsedMs: Date.now() - navStartTime, + pageUrl: popupPage.url(), + contextData: await capturePageContext( + popupPage, + undefined, + 'nav.forwardFrame', + undefined, + undefined, + 'nav_forward_frame' + ) + }) + throw new Error('无法访问弹出窗口的 forwardFrame') + } + + this.log.debug('[导航 Step 4] 等待并获取 mainiframe 内部框架', { timeout: 30000 }) + const innerFrameLocator = fFrame.locator('#mainiframe') + await innerFrameLocator.waitFor({ state: 'visible', timeout: 30000 }) + const workFrame = innerFrameLocator.contentFrame() + this.log.debug('[导航 Step 4 完成] 内部工作框架已获取', { + elapsedMs: Date.now() - navStartTime, + frameExists: !!workFrame + }) + + if (!workFrame) { + this.log.error('[导航失败] workFrame 为空', { + elapsedMs: Date.now() - navStartTime, + pageUrl: popupPage.url(), + contextData: await capturePageContext( + popupPage, + undefined, + 'nav.workFrame', + undefined, + undefined, + 'nav_work_frame' + ) + }) + throw new Error('无法访问内部工作框架') + } + + this.log.debug('[导航 Step 5] 等待页面就绪标志', { + selector: '#hot-key-head_list', + timeout: 30000 + }) + await workFrame.locator('#hot-key-head_list').waitFor({ state: 'visible', timeout: 30000 }) + const totalNavTime = Date.now() - navStartTime + await this.logPageStateSnapshot(popupPage, 'nav.cleaner_page_ready', { + level: 'info', + elapsedMs: totalNavTime + }) + this.log.info('[导航完成] 已导航到清理页面', { + totalNavTimeMs: totalNavTime, + isSlow: totalNavTime > 5000 + }) + + return { popupPage, workFrame } + } + + async setupQueryInterface(innerFrame: FrameLocator, popupPage: Page): Promise { + const setupStartTime = Date.now() + this.log.debug('[查询界面设置开始] 准备配置查询界面') + await this.logPageStateSnapshot(popupPage, 'query.setup.start', { + level: 'debug', + elapsedMs: 0 + }) + + this.log.debug('[查询设置 Step 1] 点击搜索图标') + await innerFrame.locator('.search-name-wrapper > .iconfont').click() + this.log.debug('[查询设置 Step 1 完成] 搜索图标已点击', { + elapsedMs: Date.now() - setupStartTime + }) + + this.log.debug('[查询设置 Step 2] 选择订单号查询模式') + await innerFrame.getByText('订单号查询').click() + this.log.debug('[查询设置 Step 2 完成] 已切换到订单号查询', { + elapsedMs: Date.now() - setupStartTime + }) + + this.log.debug('[查询设置 Step 3] 切换到全部标签页') + await innerFrame.getByRole('tab', { name: '全部' }).click() + this.log.debug('[查询设置 Step 3 完成] 已全部标签页激活', { + elapsedMs: Date.now() - setupStartTime + }) + + this.log.debug('[查询设置 Step 4] 设置查询数量限制为 5000') + const inputEl = innerFrame.locator('#rc_select_0') + await inputEl.fill('5000') + await inputEl.press('Enter') + const totalSetupTime = Date.now() - setupStartTime + await this.logPageStateSnapshot(popupPage, 'query.setup.ready', { + level: 'info', + elapsedMs: totalSetupTime + }) + this.log.debug('[查询设置完成] 查询界面配置完毕', { + totalSetupTimeMs: totalSetupTime, + queryLimit: 5000, + isSlow: totalSetupTime > 2000 + }) + } + + async queryOrders( + workFrame: FrameLocator, + popupPage: Page, + orderNumbers: string[] + ): Promise { + const queryStartTime = Date.now() + this.log.debug('[订单查询开始]', { + orderCount: orderNumbers.length, + orderNumbers: orderNumbers + .slice(0, 5) + .concat(orderNumbers.length > 5 ? [`... (${orderNumbers.length - 5} more)`] : []), + isPreview: orderNumbers.length > 5 + }) + await this.logPageStateSnapshot(popupPage, 'query.before_submit', { + level: 'debug', + elapsedMs: 0 + }) + + const textbox = workFrame.getByRole('textbox', { name: '生产订单号' }) + this.log.debug('[订单查询] 准备填入订单号') + await textbox.fill(orderNumbers.join(',')) + this.log.debug('[订单查询] 订单号已填入', { + elapsedMs: Date.now() - queryStartTime, + charCount: orderNumbers.join(',').length + }) + + this.log.debug('[订单查询] 点击搜索按钮') + await workFrame.locator('.search-component-searchBtn').click() + this.log.info('[订单查询完成] 查询请求已发送', { + elapsedMs: Date.now() - queryStartTime, + orderCount: orderNumbers.length + }) + await this.logPageStateSnapshot(popupPage, 'query.after_submit', { + level: 'info', + elapsedMs: Date.now() - queryStartTime + }) + } + + async collectQueryResultRows( + workFrame: FrameLocator, + popupPage: Page + ): Promise { + const collectStartTime = Date.now() + this.log.debug('[查询结果收集开始] 准备读取查询结果表格') + + const rows = workFrame.locator('tbody tr') + const rowCount = await rows.count() + this.log.debug('[查询结果收集] 检测到表格行数', { rowCount }) + + const result: QueryResultRow[] = [] + let validOrderCount = 0 + let invalidOrderCount = 0 + + for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) { + const row = rows.nth(rowIndex) + const orderNumber = await this.extractOrderNumberFromQueryRow(row) + if (!this.isOrderNumber(orderNumber)) { + invalidOrderCount++ + if (invalidOrderCount <= 3) { + this.log.warn('[查询结果] 跳过无效订单号行', { rowIndex, extractedValue: orderNumber }) + } + continue + } + validOrderCount++ + result.push({ rowIndex, orderNumber }) + } + + const totalCollectTime = Date.now() - collectStartTime + await this.logPageStateSnapshot(popupPage, 'query.results.collected', { + level: 'info', + elapsedMs: totalCollectTime + }) + this.log.info('[查询结果收集完成]', { + totalRowsScanned: rowCount, + validOrderCount, + invalidOrderCount, + elapsedMs: totalCollectTime, + isSlow: totalCollectTime > 3000 + }) + + return result + } + + async openDetailPageFromRow( + workFrame: FrameLocator, + popupPage: Page, + rowIndex: number, + options?: DetailOpenOptions + ): Promise { + const openStartTime = Date.now() + const row = workFrame.locator('tbody tr').nth(rowIndex) + await row.waitFor({ state: 'visible', timeout: 15000 }) + this.log.info('[NAV_EVENT] 准备从查询结果打开详情页', { + step: 'detail.open.from_query_row', + rowIndex, + orderNumber: options?.orderNumber, + orderIndex: options?.orderIndex, + orderPosition: options?.orderPosition, + workerId: options?.workerId + }) + await this.logPageStateSnapshot(popupPage, 'detail.open.from_query_row.before_click', { + level: 'debug', + orderNumber: options?.orderNumber, + orderIndex: options?.orderIndex, + orderPosition: options?.orderPosition, + workerId: options?.workerId, + elapsedMs: Date.now() - openStartTime + }) + + const moreButton = row.locator('a.row-more').first() + await moreButton.scrollIntoViewIfNeeded() + + const detailPagePromise = popupPage.waitForEvent('popup') + await moreButton.click() + await this.clickMaterialPlanMenu(workFrame, popupPage, options) + + const detailPage = await detailPagePromise + this.log.info('[POPUP_EVENT] 详情页弹窗已创建', { + step: 'detail.popup.opened', + rowIndex, + orderNumber: options?.orderNumber, + orderIndex: options?.orderIndex, + orderPosition: options?.orderPosition, + workerId: options?.workerId, + popupUrl: detailPage.url(), + elapsedMs: Date.now() - openStartTime + }) + await this.logPageStateSnapshot(detailPage, 'detail.popup.opened', { + level: 'info', + orderNumber: options?.orderNumber, + orderIndex: options?.orderIndex, + orderPosition: options?.orderPosition, + workerId: options?.workerId, + elapsedMs: Date.now() - openStartTime + }) + return detailPage + } + + async openDetailPageFromCurrentQuery( + workFrame: FrameLocator, + popupPage: Page, + orderNumber?: string + ): Promise { + const openStartTime = Date.now() + const firstRow = workFrame.locator('tbody tr').first() + await firstRow.waitFor({ state: 'visible', timeout: 10000 }) + this.log.info('[NAV_EVENT] 准备从当前查询结果打开详情页', { + step: 'detail.open.from_current_query', + orderNumber + }) + await this.logPageStateSnapshot(popupPage, 'detail.open.from_current_query.before_click', { + level: 'debug', + orderNumber, + elapsedMs: Date.now() - openStartTime + }) + + const moreButton = firstRow.locator('a.row-more').first() + const detailPagePromise = popupPage.waitForEvent('popup') + await moreButton.click() + await this.clickMaterialPlanMenu(workFrame, popupPage, { orderNumber }) + + const detailPage = await detailPagePromise + this.log.info('[POPUP_EVENT] 详情页弹窗已创建', { + step: 'detail.popup.opened.retry', + orderNumber, + popupUrl: detailPage.url(), + elapsedMs: Date.now() - openStartTime + }) + await this.logPageStateSnapshot(detailPage, 'detail.popup.opened.retry', { + level: 'info', + orderNumber, + elapsedMs: Date.now() - openStartTime + }) + return detailPage + } + + async waitForLoading(frame: FrameLocator): Promise { + const loadingLocator = frame + .locator('div') + .filter({ hasText: ERP_LOCATORS.extractor.loadingText }) + .nth(1) + + try { + await loadingLocator.waitFor({ state: 'visible', timeout: 3000 }) + await loadingLocator.waitFor({ state: 'hidden', timeout: 60000 }) + } catch { + // Loading completed quickly or never appeared + } + } + + private async extractOrderNumberFromQueryRow(row: Locator): Promise { + try { + const cell = row.locator('td[colkey="vbillcode"]') + const codeLink = cell.locator('.code-detail-link').first() + const linkCount = await codeLink.count() + + this.log.verbose('[提取订单号] 尝试从行提取订单号', { + hasLink: linkCount > 0, + extractionMethod: linkCount > 0 ? 'link' : 'cellText' + }) + + const rawValue = linkCount > 0 ? await codeLink.innerText() : await cell.innerText() + const value = rawValue.trim() + const match = value.match(/SC\d{14}/) + const extracted = match ? match[0] : value + + this.log.verbose('[提取订单号完成]', { + rawValue, + extracted, + matched: !!match + }) + + return extracted + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + this.log.warn('[提取订单号失败] 提取过程发生错误', { + error: message, + fallback: 'empty string' + }) + return '' + } + } + + private async clickMaterialPlanMenu( + workFrame: FrameLocator, + popupPage: Page, + options?: DetailOpenOptions + ): Promise { + const candidates = [ + workFrame.locator('li:visible, a:visible, span:visible, div:visible').filter({ + hasText: /^备料计划$/ + }), + workFrame.getByRole('menuitem', { name: '备料计划' }), + workFrame.getByText('备料计划', { exact: true }), + workFrame.getByText('备料计划') + ] + + for (const candidate of candidates) { + const target = candidate.last() + try { + await target.waitFor({ state: 'visible', timeout: 2000 }) + this.log.info('[NAV_EVENT] 点击备料计划菜单', { + step: 'detail.menu.material_plan', + orderNumber: options?.orderNumber, + orderIndex: options?.orderIndex, + orderPosition: options?.orderPosition, + workerId: options?.workerId + }) + await target.click() + await this.logPageStateSnapshot(popupPage, 'detail.menu.material_plan.clicked', { + level: 'debug', + orderNumber: options?.orderNumber, + orderIndex: options?.orderIndex, + orderPosition: options?.orderPosition, + workerId: options?.workerId + }) + return + } catch { + // Try next locator candidate + } + } + + this.log.error('Failed to locate material plan menu item', { + selectorsAttempted: candidates.length + }) + throw new Error('无法定位”备料计划”菜单项(可能菜单结构已变化)') + } + + private isOrderNumber(value: string): boolean { + return /^SC\d{14}$/.test(value) + } +}