diff --git a/src/main/services/erp/cleaner.ts b/src/main/services/erp/cleaner.ts index 3c27d16..0fd9522 100644 --- a/src/main/services/erp/cleaner.ts +++ b/src/main/services/erp/cleaner.ts @@ -1,8 +1,8 @@ import { ErpAuthService } from './erp-auth' -import { DeletionErrorCategory, DeletionOutcome } from '../../types/cleaner.types' +import type { DeletionErrorCategory, DeletionOutcome } from '../../types/cleaner.types' import type { CleanerInput, CleanerResult, OrderCleanDetail } from '../../types/cleaner.types' import type { ErpSession } from '../../types/erp.types' -import type { BrowserContext, FrameLocator, Locator, Page } from 'playwright' +import type { BrowserContext, FrameLocator, Page } from 'playwright' import { createLogger, run, trackDuration } from '../logger' import { capturePageContext } from './erp-error-context' import { capturePageState } from './page-state' @@ -10,15 +10,10 @@ 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 { CleanerOrderDetailProcessor } from './cleaner/order-detail-processor' import { CleanerRetryHandler } from './cleaner/retry-handler' import { getSkipReason, shouldDeleteMaterial } from './cleaner/rules' -import { - clampNumber, - createBatches, - delay, - getMissingOrders, - runWithConcurrency -} from './cleaner/utils' +import { clampNumber, createBatches, getMissingOrders, runWithConcurrency } from './cleaner/utils' import type { CleanerOptions, ProgressState, @@ -47,6 +42,7 @@ export class CleanerService { private dryRun: boolean private deletionVerifier: MaterialDeletionVerifier private navigation: CleanerNavigation + private orderDetailProcessor: CleanerOrderDetailProcessor private retryHandler: CleanerRetryHandler constructor(authService: ErpAuthService, options: CleanerOptions = {}) { @@ -56,6 +52,9 @@ export class CleanerService { this.navigation = new CleanerNavigation(log, (page, step, snapshotOptions) => this.logPageStateSnapshot(page, step, snapshotOptions) ) + this.orderDetailProcessor = new CleanerOrderDetailProcessor(log, this.deletionVerifier, () => + this.getBrowserContext() + ) this.retryHandler = new CleanerRetryHandler(log, { queryOrders: (workFrame, popupPage, orderNumbers) => this.queryOrders(workFrame, popupPage, orderNumbers), @@ -556,35 +555,6 @@ export class CleanerService { return payload } - private async ensureNotRedirectedToLoginPage( - page: Page, - step: string, - expectedOrderNumber: string | undefined, - progressState: ProgressState, - elapsedMs: number - ): Promise { - const pageState = await capturePageState(page, this.getBrowserContext(), { - includeFrameHierarchy: true, - includeBodyTextPreview: true - }) - - if (!pageState.isCasLoginRedirect && pageState.pageKind !== 'login') { - return - } - - log.error('[SESSION_LOST] 会话跳转到 CAS 登录页', { - step, - orderNumber: expectedOrderNumber, - orderIndex: progressState.ordersStarted, - orderPosition: `${progressState.ordersStarted + 1}/${progressState.totalOrders}`, - elapsedMs, - detectedBy: pageState.isCasLoginRedirect ? 'url_match' : 'login_form_detected', - ...pageState - }) - - throw new Error('ERP 会话已跳转到登录页') - } - private async refreshSessionAtBatchBoundary(params: { batchIndex: number totalBatches: number @@ -626,592 +596,8 @@ export class CleanerService { extra?: Partial ) => void }): Promise { - const { detailPage, deleteSet, dryRun, progressState, expectedOrderNumber, onProgress } = params - const processStartTime = Date.now() - - log.info('[ORDER_START] 开始处理订单', { - orderIndex: progressState.ordersStarted, - orderPosition: `${progressState.ordersStarted + 1}/${progressState.totalOrders}`, - orderNumber: expectedOrderNumber, - phase: 'starting', - elapsedMs: Date.now() - processStartTime - }) - - log.info('[订单详情处理开始]', { - expectedOrderNumber, - dryRun, - deleteSetSize: deleteSet.size, - ordersStarted: progressState.ordersStarted, - ordersCompleted: progressState.ordersCompleted, - totalOrders: progressState.totalOrders - }) - - let detailCount = 0 - const detail: OrderCleanDetail = { - orderNumber: expectedOrderNumber || 'UNKNOWN', - materialsDeleted: 0, - materialsSkipped: 0, - errors: [], - skippedMaterials: [], - deletedMaterials: [], - retryCount: 0, - retryAttempts: [], - retriedAt: undefined, - retrySuccess: false, - materialsFailed: 0, - failedMaterials: [], - uncertainDeletions: 0 - } - - try { - await this.logPageStateSnapshot(detailPage, 'detail.page.opened', { - level: 'debug', - orderNumber: expectedOrderNumber, - orderIndex: progressState.ordersStarted, - orderPosition: `${progressState.ordersStarted + 1}/${progressState.totalOrders}`, - elapsedMs: Date.now() - processStartTime - }) - - // Step 1: Access forward frame - log.debug('[详情页面 Step 1] 准备访问 forwardFrame') - await this.ensureNotRedirectedToLoginPage( - detailPage, - 'detail.step1.forwardFrame', - expectedOrderNumber, - progressState, - Date.now() - processStartTime - ) - const detailMainFrame = detailPage.locator('#forwardFrame') - const dFrame = await detailMainFrame.contentFrame() - - if (!dFrame) { - const pageState = await this.logPageStateSnapshot(detailPage, 'detail.step1.forwardFrame', { - level: 'error', - orderNumber: expectedOrderNumber, - orderIndex: progressState.ordersStarted, - orderPosition: `${progressState.ordersStarted + 1}/${progressState.totalOrders}`, - elapsedMs: Date.now() - processStartTime, - includeFrameHierarchy: true, - includeBodyTextPreview: true - }) - const errorMsg = '无法访问详情页面的 forwardFrame' - log.error('[DETAIL_PAGE_INVALID] 详情页未建立', { - failureKind: 'forward_frame_missing', - ...pageState, - contextData: await capturePageContext( - detailPage, - undefined, - 'processDetail.forwardFrame', - expectedOrderNumber, - undefined, - 'process_detail_forward_frame' - ) - }) - throw new Error(errorMsg) - } - log.debug('[详情页面 Step 1 完成] forwardFrame 已获取', { - elapsedMs: Date.now() - processStartTime - }) - - // Step 2: Access inner frame - log.debug('[详情页面 Step 2] 等待并获取 mainiframe 内部框架', { timeout: 30000 }) - await this.ensureNotRedirectedToLoginPage( - detailPage, - 'detail.step2.mainiframe', - expectedOrderNumber, - progressState, - Date.now() - processStartTime - ) - const detailInnerLocator = dFrame.locator('#mainiframe') - try { - await detailInnerLocator.waitFor({ state: 'visible', timeout: 30000 }) - } catch (error) { - const pageState = await this.logPageStateSnapshot(detailPage, 'detail.step2.mainiframe', { - level: 'error', - orderNumber: expectedOrderNumber, - orderIndex: progressState.ordersStarted, - orderPosition: `${progressState.ordersStarted + 1}/${progressState.totalOrders}`, - elapsedMs: Date.now() - processStartTime, - includeFrameHierarchy: true, - includeBodyTextPreview: true - }) - log.error('[DETAIL_PAGE_TIMEOUT] 详情页等待超时', { - failureKind: pageState.isCasLoginRedirect ? 'redirected_to_cas' : 'mainiframe_missing', - ...pageState, - error: error instanceof Error ? error.message : String(error) - }) - throw error - } - const detailInnerFrame = await detailInnerLocator.contentFrame() - - if (!detailInnerFrame) { - const pageState = await this.logPageStateSnapshot( - detailPage, - 'detail.step2.detailInnerFrame', - { - level: 'error', - orderNumber: expectedOrderNumber, - orderIndex: progressState.ordersStarted, - orderPosition: `${progressState.ordersStarted + 1}/${progressState.totalOrders}`, - elapsedMs: Date.now() - processStartTime, - includeFrameHierarchy: true, - includeBodyTextPreview: true - } - ) - const errorMsg = '无法访问详情页面的内部框架' - log.error('[DETAIL_PAGE_INVALID] 详情页未建立', { - failureKind: 'mainiframe_missing', - ...pageState, - contextData: await capturePageContext( - detailPage, - undefined, - 'processDetail.detailInnerFrame', - expectedOrderNumber, - undefined, - 'process_detail_inner_frame' - ) - }) - throw new Error(errorMsg) - } - log.debug('[详情页面 Step 2 完成] 内部框架已获取', { - elapsedMs: Date.now() - processStartTime - }) - - // Step 3: Wait for page header - log.debug('[详情页面 Step 3] 等待页面标题显示', { - selector: '离散备料计划维护', - timeout: 30000 - }) - await this.ensureNotRedirectedToLoginPage( - detailPage, - 'detail.step3.header', - expectedOrderNumber, - progressState, - Date.now() - processStartTime - ) - try { - await detailInnerFrame.getByText(/^离散备料计划维护:/).waitFor({ - state: 'visible', - timeout: 30000 - }) - } catch (error) { - const pageState = await this.logPageStateSnapshot(detailPage, 'detail.step3.header', { - level: 'error', - orderNumber: expectedOrderNumber, - orderIndex: progressState.ordersStarted, - orderPosition: `${progressState.ordersStarted + 1}/${progressState.totalOrders}`, - elapsedMs: Date.now() - processStartTime, - includeFrameHierarchy: true, - includeBodyTextPreview: true - }) - log.error('[DETAIL_PAGE_TIMEOUT] 详情页等待超时', { - failureKind: pageState.isCasLoginRedirect ? 'redirected_to_cas' : 'detail_header_missing', - expectedMarker: '离散备料计划维护:', - ...pageState, - error: error instanceof Error ? error.message : String(error) - }) - throw error - } - log.debug('[详情页面 Step 3 完成] 页面标题已显示', { - elapsedMs: Date.now() - processStartTime - }) - - // Step 4: Extract order number - log.debug('[详情页面 Step 4] 提取源订单号') - const sourceOrderNumber = await this.extractSourceOrderNumber(detailInnerFrame) - const orderNumber = sourceOrderNumber || expectedOrderNumber || 'UNKNOWN_ORDER' - log.debug('[详情页面 Step 4 完成] 订单号已确认', { - extractedOrderNumber: orderNumber, - sourceOrderNumber, - usedFallback: !sourceOrderNumber && !!expectedOrderNumber - }) - - // 更新 orderNumber 为实际提取的值或 fallback - detail.orderNumber = orderNumber - - // Step 5: Get material counts and status - log.debug('[详情页面 Step 5] 读取物料数量和状态') - const detailCountText = await detailInnerFrame.getByText(/^详细信息 \(\d+\)$/).innerText() - const detailCountMatch = detailCountText.match(/\((\d+)\)/) - const detailCount = detailCountMatch ? parseInt(detailCountMatch[1], 10) : 0 - - const statusText = await detailInnerFrame.getByText(/^备料状态:.+$/).innerText() - const statusMatch = statusText.replace(/\n/g, '').match(/备料状态:(.+)$/) - const detailStatus = statusMatch ? statusMatch[1].trim() : '' - - log.info('[详情页面] 订单状态已读取', { - orderNumber, - detailStatus, - totalMaterials: detailCount, - elapsedMs: Date.now() - processStartTime - }) - - onProgress?.( - `开始处理订单:${orderNumber}`, - this.calculateProgress( - progressState.ordersStarted, - 0, - detailCount, - progressState.totalOrders - ), - { - currentOrderIndex: progressState.ordersStarted + 1, - totalOrders: progressState.totalOrders, - currentMaterialIndex: 0, - totalMaterialsInOrder: detailCount, - currentOrderNumber: orderNumber - } - ) - - // Step 6: Process materials if status is "审批通过" - if (detailStatus === '审批通过' && detailCount > 0) { - log.debug('[详情页面 Step 6] 订单状态为"审批通过",开始修改流程', { - materialCount: detailCount - }) - await detailInnerFrame.getByRole('button', { name: '修改' }).click() - log.debug('[详情页面 Step 6.1] 已点击"修改"按钮', { - elapsedMs: Date.now() - processStartTime - }) - - const saveButtonLocator = detailInnerFrame.getByRole('button', { name: '保存' }) - await saveButtonLocator.waitFor({ state: 'visible', timeout: 30000 }) - log.debug('[详情页面 Step 6.2] "保存"按钮已就绪', { - elapsedMs: Date.now() - processStartTime - }) - - await detailInnerFrame.getByText('展开').first().click() - log.debug('[详情页面 Step 6.3] 已展开明细卡片', { - elapsedMs: Date.now() - processStartTime - }) - - const childForm = detailInnerFrame.locator('.card-table-side-box') - const buttonWrapper = childForm.locator('.button-wrapper') - const deleteRowBtn = buttonWrapper.getByRole('button', { name: '删行' }) - const nextBtn = buttonWrapper.locator('.icon-jiantouyou') - const collapseBtn = buttonWrapper.locator('.icon-celashouqi') - - let lastRowNumber = '' - let materialIdx = 0 - const processingStartTime = Date.now() - - log.info('[物料循环开始] 准备遍历物料明细', { - orderNumber, - totalMaterials: detailCount, - deleteSetSize: deleteSet.size - }) - - while (true) { - materialIdx += 1 - const materialStartTime = Date.now() - - const currentRow = await this.getInputValue(childForm, /^行号$/) - const rowNumInt = parseInt(currentRow, 10) - - if (currentRow === lastRowNumber) { - log.debug('[物料循环] 检测到重复行号,等待 500ms', { - orderNumber, - materialIdx, - rowNumber: currentRow, - elapsedMs: Date.now() - processingStartTime - }) - await this.delay(500) - } - - const materialCode = await this.getInputValue(childForm, /^材料编码/) - const materialName = await this.getInputValue(childForm, /^材料名称/) - const pendingQty = await this.getInputValue(childForm, /^累计待发数量$/) - - const progress = this.calculateProgress( - progressState.ordersStarted, - materialIdx, - detailCount, - progressState.totalOrders - ) - - onProgress?.( - `订单 ${orderNumber} - 物料 ${materialIdx}/${detailCount}: ${materialName}`, - progress, - { - currentOrderIndex: progressState.ordersStarted + 1, - totalOrders: progressState.totalOrders, - currentMaterialIndex: materialIdx, - totalMaterialsInOrder: detailCount, - currentOrderNumber: orderNumber - } - ) - - if (deleteSet.has(materialCode)) { - log.debug('[物料判断] 物料在删除清单中,进行评估', { - orderNumber, - materialIdx, - materialCode, - materialName, - rowNumber: rowNumInt, - pendingQty: pendingQty || '(empty)', - deleteSetSize: deleteSet.size - }) - - const shouldDelete = this.shouldDeleteMaterial({ - rowNumber: rowNumInt, - pendingQty, - materialCode, - deleteSet - }) - - if (shouldDelete) { - if (dryRun) { - detail.materialsDeleted += 1 - detail.deletedMaterials.push({ - materialCode, - materialName, - rowNumber: rowNumInt, - outcome: 'dry_run' - }) - log.info('[物料操作预演] 符合删除条件,dry-run 模式不执行删除', { - orderNumber, - materialIdx, - materialCode, - materialName, - rowNumber: currentRow - }) - continue - } - - log.info('[物料操作] 执行删除操作(多信号验证)', { - orderNumber, - materialIdx, - materialCode, - materialName, - rowNumber: currentRow, - materialCount: detailCount, - dryRun: false - }) - - const currentMaterialCount = - await this.deletionVerifier.readMaterialCount(detailInnerFrame) - const deleteResult = await this.deletionVerifier.deleteWithVerification({ - childForm, - detailInnerFrame, - deleteRowBtn, - materialCode, - materialName, - currentRowNumber: currentRow, - materialCountBefore: currentMaterialCount ?? detailCount - }) - - const deleteElapsed = Date.now() - materialStartTime - - if ( - deleteResult.outcome === DeletionOutcome.Success || - deleteResult.outcome === DeletionOutcome.Uncertain - ) { - detail.materialsDeleted += 1 - detail.deletedMaterials.push({ - materialCode, - materialName, - rowNumber: rowNumInt, - outcome: deleteResult.outcome - }) - if (deleteResult.outcome === DeletionOutcome.Uncertain) { - detail.uncertainDeletions += 1 - } - log.info('[物料操作完成] 物料删除结果', { - orderNumber, - materialCode, - outcome: deleteResult.outcome, - attempts: deleteResult.attempts.length, - elapsedMs: deleteElapsed - }) - } else { - detail.materialsFailed += 1 - detail.failedMaterials.push({ - materialCode, - materialName, - rowNumber: rowNumInt, - attempts: deleteResult.attempts, - finalOutcome: deleteResult.outcome, - finalErrorCategory: deleteResult.errorCategory - }) - log.error('[物料操作失败] 物料删除失败', { - orderNumber, - materialCode, - outcome: deleteResult.outcome, - errorCategory: deleteResult.errorCategory, - errorMessage: deleteResult.errorMessage, - attempts: deleteResult.attempts.length, - elapsedMs: deleteElapsed - }) - } - continue - } - - if (!shouldDelete) { - detail.materialsSkipped += 1 - const reason = this.getSkipReason({ - rowNumber: rowNumInt, - pendingQty, - materialCode, - deleteSet - }) - log.debug('[物料跳过] 物料不满足删除条件', { - orderNumber, - materialIdx, - materialCode, - materialName, - rowNumber: rowNumInt, - reason, - elapsedMs: Date.now() - materialStartTime - }) - detail.skippedMaterials.push({ - materialCode, - materialName, - rowNumber: rowNumInt, - reason - }) - } - } else { - // 物料不在删除清单中,记录为 skipped - detail.materialsSkipped += 1 - detail.skippedMaterials.push({ - materialCode, - materialName, - rowNumber: rowNumInt, - reason: '不在删除清单中' - }) - log.verbose('[物料跳过] 物料不在删除清单中', { - orderNumber, - materialIdx, - materialCode, - materialName, - rowNumber: rowNumInt - }) - } - - const isNextEnabled = await this.isButtonEnabled(nextBtn) - if (isNextEnabled) { - lastRowNumber = currentRow - log.verbose('[物料循环] 点击"下一行"按钮', { - orderNumber, - materialIdx, - currentRow, - hasNext: true - }) - await nextBtn.click() - } else { - log.info('[物料循环结束] 已到达最后一行', { - orderNumber, - totalMaterials: materialIdx, - deleted: detail.materialsDeleted, - skipped: detail.materialsSkipped, - elapsedMs: Date.now() - processingStartTime - }) - break - } - } - - log.debug('[详情页面 Step 7] 折叠明细卡片') - await collapseBtn.click() - - if (!dryRun && detail.materialsDeleted > 0) { - log.info('[详情页面 Step 8] 保存订单修改', { - orderNumber, - materialsDeleted: detail.materialsDeleted, - materialsSkipped: detail.materialsSkipped - }) - await saveButtonLocator.click() - await saveButtonLocator.waitFor({ state: 'hidden', timeout: 60000 }) - log.info('[详情页面保存完成] 订单修改已保存', { - orderNumber, - materialsDeleted: detail.materialsDeleted, - elapsedMs: Date.now() - processStartTime - }) - } else if (dryRun) { - log.info('[详情页面干运行] 模拟模式下不保存修改', { - orderNumber, - wouldDeleteMaterials: detail.materialsDeleted - }) - } - } else { - log.info('[详情页面跳过] 订单状态不是"审批通过"或无物料', { - orderNumber, - detailStatus, - detailCount, - reason: detailStatus !== '审批通过' ? '状态不符' : '无物料明细' - }) - } - - const totalProcessTime = Date.now() - processStartTime - log.info('[详情页面处理完成]', { - orderNumber, - totalMaterials: detailCount, - deleted: detail.materialsDeleted, - skipped: detail.materialsSkipped, - elapsedMs: totalProcessTime, - isSlow: totalProcessTime > 30000, - dryRun - }) - - return detail - } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error' - log.error('[详情页面处理失败]', { - orderNumber: expectedOrderNumber || 'UNKNOWN', - error: message, - elapsedMs: Date.now() - processStartTime, - contextData: await capturePageContext( - detailPage, - undefined, - 'processDetail.error', - expectedOrderNumber, - undefined, - 'process_detail_error' - ) - }) - throw error - } finally { - const totalOrderTime = Date.now() - processStartTime - log.info('[ORDER_COMPLETE] 订单处理完成', { - orderIndex: progressState.ordersStarted, - orderPosition: `${progressState.ordersStarted + 1}/${progressState.totalOrders}`, - ordersCompleted: progressState.ordersCompleted, - orderNumber: expectedOrderNumber || 'UNKNOWN', - totalMaterials: detailCount, - deleted: detail?.materialsDeleted ?? 0, - skipped: detail?.materialsSkipped ?? 0, - failed: detail?.materialsFailed ?? 0, - totalOrderTimeMs: totalOrderTime, - isSlow: totalOrderTime > 30000 - }) - - log.debug('[详情页面清理] 准备关闭详情页面', { pageUrl: detailPage.url() }) - await detailPage.close() - log.debug('[详情页面清理完成] 详情页已关闭') - } + return this.orderDetailProcessor.processDetailPage(params) } - - private calculateProgress( - completedOrders: number, - materialIdx: number, - detailCount: number, - totalOrders: number - ): number { - const materialRatio = detailCount > 0 ? materialIdx / detailCount : 0 - return ((1 + completedOrders + materialRatio) / (1 + totalOrders)) * 100 - } - - private async extractSourceOrderNumber(frame: FrameLocator): Promise { - try { - const sourceOrder = await frame - .locator('.vsourcebillcode .code-detail-link') - .first() - .innerText() - const match = sourceOrder.match(/SC\d{14}/) - return match ? match[0] : sourceOrder.trim() - } catch { - return '' - } - } - private isOrderNumber(value: string): boolean { return /^SC\d{14}$/.test(value) } @@ -1243,30 +629,6 @@ export class CleanerService { await this.navigation.waitForLoading(frame) } - private async getInputValue( - container: FrameLocator | Locator, - labelRegex: RegExp - ): Promise { - try { - return await container - .locator('div') - .filter({ hasText: labelRegex }) - .locator('input') - .first() - .inputValue() - } catch { - return '' - } - } - - private async isButtonEnabled(button: Locator): Promise { - try { - return await button.isEnabled() - } catch { - return false - } - } - /** * Pure logic: evaluate deletion signals and determine outcome. * No Playwright dependencies — easy to unit test. @@ -1284,10 +646,6 @@ export class CleanerService { return evaluateDeletionSignalsHelper(params) } - private delay(ms: number): Promise { - return delay(ms) - } - private async retryFailedOrders(params: { workFrame: FrameLocator popupPage: Page diff --git a/src/main/services/erp/cleaner/order-detail-processor.ts b/src/main/services/erp/cleaner/order-detail-processor.ts new file mode 100644 index 0000000..21df9b4 --- /dev/null +++ b/src/main/services/erp/cleaner/order-detail-processor.ts @@ -0,0 +1,788 @@ +import type { BrowserContext, FrameLocator, Locator, Page } from 'playwright' +import { + DeletionOutcome, + type CleanerProgress, + type OrderCleanDetail +} from '../../../types/cleaner.types' +import { capturePageContext } from '../erp-error-context' +import { capturePageState } from '../page-state' +import type { MaterialDeletionVerifier } from './material-deletion-verifier' +import { getSkipReason, shouldDeleteMaterial } from './rules' +import type { ProgressState } from './types' +import { delay } from './utils' + +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 class CleanerOrderDetailProcessor { + constructor( + private readonly log: CleanerLogger, + private readonly deletionVerifier: MaterialDeletionVerifier, + private readonly getBrowserContext: () => BrowserContext | undefined + ) {} + + async processDetailPage(params: { + detailPage: Page + deleteSet: Set + dryRun: boolean + progressState: ProgressState + expectedOrderNumber?: string + onProgress?: (message: string, progress?: number, extra?: Partial) => void + }): Promise { + const { detailPage, deleteSet, dryRun, progressState, expectedOrderNumber, onProgress } = params + const processStartTime = Date.now() + + this.log.info('[ORDER_START] 开始处理订单', { + orderIndex: progressState.ordersStarted, + orderPosition: `${progressState.ordersStarted + 1}/${progressState.totalOrders}`, + orderNumber: expectedOrderNumber, + phase: 'starting', + elapsedMs: Date.now() - processStartTime + }) + + this.log.info('[订单详情处理开始]', { + expectedOrderNumber, + dryRun, + deleteSetSize: deleteSet.size, + ordersStarted: progressState.ordersStarted, + ordersCompleted: progressState.ordersCompleted, + totalOrders: progressState.totalOrders + }) + + let detailCount = 0 + const detail: OrderCleanDetail = { + orderNumber: expectedOrderNumber || 'UNKNOWN', + materialsDeleted: 0, + materialsSkipped: 0, + errors: [], + skippedMaterials: [], + deletedMaterials: [], + retryCount: 0, + retryAttempts: [], + retriedAt: undefined, + retrySuccess: false, + materialsFailed: 0, + failedMaterials: [], + uncertainDeletions: 0 + } + + try { + await this.logPageStateSnapshot(detailPage, 'detail.page.opened', { + level: 'debug', + orderNumber: expectedOrderNumber, + orderIndex: progressState.ordersStarted, + orderPosition: `${progressState.ordersStarted + 1}/${progressState.totalOrders}`, + elapsedMs: Date.now() - processStartTime + }) + + this.log.debug('[详情页面 Step 1] 准备访问 forwardFrame') + await this.ensureNotRedirectedToLoginPage( + detailPage, + 'detail.step1.forwardFrame', + expectedOrderNumber, + progressState, + Date.now() - processStartTime + ) + const detailMainFrame = detailPage.locator('#forwardFrame') + const dFrame = await detailMainFrame.contentFrame() + + if (!dFrame) { + const pageState = await this.logPageStateSnapshot(detailPage, 'detail.step1.forwardFrame', { + level: 'error', + orderNumber: expectedOrderNumber, + orderIndex: progressState.ordersStarted, + orderPosition: `${progressState.ordersStarted + 1}/${progressState.totalOrders}`, + elapsedMs: Date.now() - processStartTime, + includeFrameHierarchy: true, + includeBodyTextPreview: true + }) + const errorMsg = '无法访问详情页面的 forwardFrame' + this.log.error('[DETAIL_PAGE_INVALID] 详情页未建立', { + failureKind: 'forward_frame_missing', + ...pageState, + contextData: await capturePageContext( + detailPage, + undefined, + 'processDetail.forwardFrame', + expectedOrderNumber, + undefined, + 'process_detail_forward_frame' + ) + }) + throw new Error(errorMsg) + } + this.log.debug('[详情页面 Step 1 完成] forwardFrame 已获取', { + elapsedMs: Date.now() - processStartTime + }) + + this.log.debug('[详情页面 Step 2] 等待并获取 mainiframe 内部框架', { timeout: 30000 }) + await this.ensureNotRedirectedToLoginPage( + detailPage, + 'detail.step2.mainiframe', + expectedOrderNumber, + progressState, + Date.now() - processStartTime + ) + const detailInnerLocator = dFrame.locator('#mainiframe') + try { + await detailInnerLocator.waitFor({ state: 'visible', timeout: 30000 }) + } catch (error) { + const pageState = await this.logPageStateSnapshot(detailPage, 'detail.step2.mainiframe', { + level: 'error', + orderNumber: expectedOrderNumber, + orderIndex: progressState.ordersStarted, + orderPosition: `${progressState.ordersStarted + 1}/${progressState.totalOrders}`, + elapsedMs: Date.now() - processStartTime, + includeFrameHierarchy: true, + includeBodyTextPreview: true + }) + this.log.error('[DETAIL_PAGE_TIMEOUT] 详情页等待超时', { + failureKind: pageState.isCasLoginRedirect ? 'redirected_to_cas' : 'mainiframe_missing', + ...pageState, + error: error instanceof Error ? error.message : String(error) + }) + throw error + } + const detailInnerFrame = await detailInnerLocator.contentFrame() + + if (!detailInnerFrame) { + const pageState = await this.logPageStateSnapshot( + detailPage, + 'detail.step2.detailInnerFrame', + { + level: 'error', + orderNumber: expectedOrderNumber, + orderIndex: progressState.ordersStarted, + orderPosition: `${progressState.ordersStarted + 1}/${progressState.totalOrders}`, + elapsedMs: Date.now() - processStartTime, + includeFrameHierarchy: true, + includeBodyTextPreview: true + } + ) + const errorMsg = '无法访问详情页面的内部框架' + this.log.error('[DETAIL_PAGE_INVALID] 详情页未建立', { + failureKind: 'mainiframe_missing', + ...pageState, + contextData: await capturePageContext( + detailPage, + undefined, + 'processDetail.detailInnerFrame', + expectedOrderNumber, + undefined, + 'process_detail_inner_frame' + ) + }) + throw new Error(errorMsg) + } + this.log.debug('[详情页面 Step 2 完成] 内部框架已获取', { + elapsedMs: Date.now() - processStartTime + }) + + this.log.debug('[详情页面 Step 3] 等待页面标题显示', { + selector: '离散备料计划维护', + timeout: 30000 + }) + await this.ensureNotRedirectedToLoginPage( + detailPage, + 'detail.step3.header', + expectedOrderNumber, + progressState, + Date.now() - processStartTime + ) + try { + await detailInnerFrame.getByText(/^离散备料计划维护:/).waitFor({ + state: 'visible', + timeout: 30000 + }) + } catch (error) { + const pageState = await this.logPageStateSnapshot(detailPage, 'detail.step3.header', { + level: 'error', + orderNumber: expectedOrderNumber, + orderIndex: progressState.ordersStarted, + orderPosition: `${progressState.ordersStarted + 1}/${progressState.totalOrders}`, + elapsedMs: Date.now() - processStartTime, + includeFrameHierarchy: true, + includeBodyTextPreview: true + }) + this.log.error('[DETAIL_PAGE_TIMEOUT] 详情页等待超时', { + failureKind: pageState.isCasLoginRedirect ? 'redirected_to_cas' : 'detail_header_missing', + expectedMarker: '离散备料计划维护:', + ...pageState, + error: error instanceof Error ? error.message : String(error) + }) + throw error + } + this.log.debug('[详情页面 Step 3 完成] 页面标题已显示', { + elapsedMs: Date.now() - processStartTime + }) + + this.log.debug('[详情页面 Step 4] 提取源订单号') + const sourceOrderNumber = await this.extractSourceOrderNumber(detailInnerFrame) + const orderNumber = sourceOrderNumber || expectedOrderNumber || 'UNKNOWN_ORDER' + this.log.debug('[详情页面 Step 4 完成] 订单号已确认', { + extractedOrderNumber: orderNumber, + sourceOrderNumber, + usedFallback: !sourceOrderNumber && !!expectedOrderNumber + }) + + detail.orderNumber = orderNumber + + this.log.debug('[详情页面 Step 5] 读取物料数量和状态') + const detailCountText = await detailInnerFrame.getByText(/^详细信息 \(\d+\)$/).innerText() + const detailCountMatch = detailCountText.match(/\((\d+)\)/) + detailCount = detailCountMatch ? parseInt(detailCountMatch[1], 10) : 0 + + const statusText = await detailInnerFrame.getByText(/^备料状态:.+$/).innerText() + const statusMatch = statusText.replace(/\n/g, '').match(/备料状态:(.+)$/) + const detailStatus = statusMatch ? statusMatch[1].trim() : '' + + this.log.info('[详情页面] 订单状态已读取', { + orderNumber, + detailStatus, + totalMaterials: detailCount, + elapsedMs: Date.now() - processStartTime + }) + + onProgress?.( + `开始处理订单:${orderNumber}`, + this.calculateProgress( + progressState.ordersStarted, + 0, + detailCount, + progressState.totalOrders + ), + { + currentOrderIndex: progressState.ordersStarted + 1, + totalOrders: progressState.totalOrders, + currentMaterialIndex: 0, + totalMaterialsInOrder: detailCount, + currentOrderNumber: orderNumber + } + ) + + if (detailStatus === '审批通过' && detailCount > 0) { + await this.processApprovedOrderMaterials({ + detailInnerFrame, + deleteSet, + dryRun, + progressState, + onProgress, + detail, + detailCount, + orderNumber, + processStartTime + }) + } else { + this.log.info('[详情页面跳过] 订单状态不是"审批通过"或无物料', { + orderNumber, + detailStatus, + detailCount, + reason: detailStatus !== '审批通过' ? '状态不符' : '无物料明细' + }) + } + + const totalProcessTime = Date.now() - processStartTime + this.log.info('[详情页面处理完成]', { + orderNumber, + totalMaterials: detailCount, + deleted: detail.materialsDeleted, + skipped: detail.materialsSkipped, + elapsedMs: totalProcessTime, + isSlow: totalProcessTime > 30000, + dryRun + }) + + return detail + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + this.log.error('[详情页面处理失败]', { + orderNumber: expectedOrderNumber || 'UNKNOWN', + error: message, + elapsedMs: Date.now() - processStartTime, + contextData: await capturePageContext( + detailPage, + undefined, + 'processDetail.error', + expectedOrderNumber, + undefined, + 'process_detail_error' + ) + }) + throw error + } finally { + const totalOrderTime = Date.now() - processStartTime + this.log.info('[ORDER_COMPLETE] 订单处理完成', { + orderIndex: progressState.ordersStarted, + orderPosition: `${progressState.ordersStarted + 1}/${progressState.totalOrders}`, + ordersCompleted: progressState.ordersCompleted, + orderNumber: expectedOrderNumber || 'UNKNOWN', + totalMaterials: detailCount, + deleted: detail?.materialsDeleted ?? 0, + skipped: detail?.materialsSkipped ?? 0, + failed: detail?.materialsFailed ?? 0, + totalOrderTimeMs: totalOrderTime, + isSlow: totalOrderTime > 30000 + }) + + this.log.debug('[详情页面清理] 准备关闭详情页面', { pageUrl: detailPage.url() }) + await detailPage.close() + this.log.debug('[详情页面清理完成] 详情页已关闭') + } + } + + private async processApprovedOrderMaterials(params: { + detailInnerFrame: FrameLocator + deleteSet: Set + dryRun: boolean + progressState: ProgressState + onProgress?: (message: string, progress?: number, extra?: Partial) => void + detail: OrderCleanDetail + detailCount: number + orderNumber: string + processStartTime: number + }): Promise { + const { + detailInnerFrame, + deleteSet, + dryRun, + progressState, + onProgress, + detail, + detailCount, + orderNumber, + processStartTime + } = params + + this.log.debug('[详情页面 Step 6] 订单状态为"审批通过",开始修改流程', { + materialCount: detailCount + }) + await detailInnerFrame.getByRole('button', { name: '修改' }).click() + this.log.debug('[详情页面 Step 6.1] 已点击"修改"按钮', { + elapsedMs: Date.now() - processStartTime + }) + + const saveButtonLocator = detailInnerFrame.getByRole('button', { name: '保存' }) + await saveButtonLocator.waitFor({ state: 'visible', timeout: 30000 }) + this.log.debug('[详情页面 Step 6.2] "保存"按钮已就绪', { + elapsedMs: Date.now() - processStartTime + }) + + await detailInnerFrame.getByText('展开').first().click() + this.log.debug('[详情页面 Step 6.3] 已展开明细卡片', { + elapsedMs: Date.now() - processStartTime + }) + + const childForm = detailInnerFrame.locator('.card-table-side-box') + const buttonWrapper = childForm.locator('.button-wrapper') + const deleteRowBtn = buttonWrapper.getByRole('button', { name: '删行' }) + const nextBtn = buttonWrapper.locator('.icon-jiantouyou') + const collapseBtn = buttonWrapper.locator('.icon-celashouqi') + + let lastRowNumber = '' + let materialIdx = 0 + const processingStartTime = Date.now() + + this.log.info('[物料循环开始] 准备遍历物料明细', { + orderNumber, + totalMaterials: detailCount, + deleteSetSize: deleteSet.size + }) + + while (true) { + materialIdx += 1 + const materialStartTime = Date.now() + + const currentRow = await this.getInputValue(childForm, /^行号$/) + const rowNumInt = parseInt(currentRow, 10) + + if (currentRow === lastRowNumber) { + this.log.debug('[物料循环] 检测到重复行号,等待 500ms', { + orderNumber, + materialIdx, + rowNumber: currentRow, + elapsedMs: Date.now() - processingStartTime + }) + await delay(500) + } + + const materialCode = await this.getInputValue(childForm, /^材料编码/) + const materialName = await this.getInputValue(childForm, /^材料名称/) + const pendingQty = await this.getInputValue(childForm, /^累计待发数量$/) + + const progress = this.calculateProgress( + progressState.ordersStarted, + materialIdx, + detailCount, + progressState.totalOrders + ) + + onProgress?.( + `订单 ${orderNumber} - 物料 ${materialIdx}/${detailCount}: ${materialName}`, + progress, + { + currentOrderIndex: progressState.ordersStarted + 1, + totalOrders: progressState.totalOrders, + currentMaterialIndex: materialIdx, + totalMaterialsInOrder: detailCount, + currentOrderNumber: orderNumber + } + ) + + if (deleteSet.has(materialCode)) { + await this.handleMaterialInDeleteSet({ + detail, + detailInnerFrame, + childForm, + deleteRowBtn, + deleteSet, + dryRun, + materialCode, + materialName, + materialIdx, + currentRow, + rowNumInt, + pendingQty, + orderNumber, + detailCount, + materialStartTime + }) + } else { + detail.materialsSkipped += 1 + detail.skippedMaterials.push({ + materialCode, + materialName, + rowNumber: rowNumInt, + reason: '不在删除清单中' + }) + this.log.verbose('[物料跳过] 物料不在删除清单中', { + orderNumber, + materialIdx, + materialCode, + materialName, + rowNumber: rowNumInt + }) + } + + const isNextEnabled = await this.isButtonEnabled(nextBtn) + if (isNextEnabled) { + lastRowNumber = currentRow + this.log.verbose('[物料循环] 点击"下一行"按钮', { + orderNumber, + materialIdx, + currentRow, + hasNext: true + }) + await nextBtn.click() + } else { + this.log.info('[物料循环结束] 已到达最后一行', { + orderNumber, + totalMaterials: materialIdx, + deleted: detail.materialsDeleted, + skipped: detail.materialsSkipped, + elapsedMs: Date.now() - processingStartTime + }) + break + } + } + + this.log.debug('[详情页面 Step 7] 折叠明细卡片') + await collapseBtn.click() + + if (!dryRun && detail.materialsDeleted > 0) { + this.log.info('[详情页面 Step 8] 保存订单修改', { + orderNumber, + materialsDeleted: detail.materialsDeleted, + materialsSkipped: detail.materialsSkipped + }) + await saveButtonLocator.click() + await saveButtonLocator.waitFor({ state: 'hidden', timeout: 60000 }) + this.log.info('[详情页面保存完成] 订单修改已保存', { + orderNumber, + materialsDeleted: detail.materialsDeleted, + elapsedMs: Date.now() - processStartTime + }) + } else if (dryRun) { + this.log.info('[详情页面干运行] 模拟模式下不保存修改', { + orderNumber, + wouldDeleteMaterials: detail.materialsDeleted + }) + } + } + + private async handleMaterialInDeleteSet(params: { + detail: OrderCleanDetail + detailInnerFrame: FrameLocator + childForm: Locator + deleteRowBtn: Locator + deleteSet: Set + dryRun: boolean + materialCode: string + materialName: string + materialIdx: number + currentRow: string + rowNumInt: number + pendingQty: string + orderNumber: string + detailCount: number + materialStartTime: number + }): Promise { + const { + detail, + detailInnerFrame, + childForm, + deleteRowBtn, + deleteSet, + dryRun, + materialCode, + materialName, + materialIdx, + currentRow, + rowNumInt, + pendingQty, + orderNumber, + detailCount, + materialStartTime + } = params + + this.log.debug('[物料判断] 物料在删除清单中,进行评估', { + orderNumber, + materialIdx, + materialCode, + materialName, + rowNumber: rowNumInt, + pendingQty: pendingQty || '(empty)', + deleteSetSize: deleteSet.size + }) + + const shouldDelete = shouldDeleteMaterial({ + rowNumber: rowNumInt, + pendingQty, + materialCode, + deleteSet + }) + + if (shouldDelete) { + if (dryRun) { + detail.materialsDeleted += 1 + detail.deletedMaterials.push({ + materialCode, + materialName, + rowNumber: rowNumInt, + outcome: 'dry_run' + }) + this.log.info('[物料操作预演] 符合删除条件,dry-run 模式不执行删除', { + orderNumber, + materialIdx, + materialCode, + materialName, + rowNumber: currentRow + }) + return + } + + this.log.info('[物料操作] 执行删除操作(多信号验证)', { + orderNumber, + materialIdx, + materialCode, + materialName, + rowNumber: currentRow, + materialCount: detailCount, + dryRun: false + }) + + const currentMaterialCount = await this.deletionVerifier.readMaterialCount(detailInnerFrame) + const deleteResult = await this.deletionVerifier.deleteWithVerification({ + childForm, + detailInnerFrame, + deleteRowBtn, + materialCode, + materialName, + currentRowNumber: currentRow, + materialCountBefore: currentMaterialCount ?? detailCount + }) + + const deleteElapsed = Date.now() - materialStartTime + + if ( + deleteResult.outcome === DeletionOutcome.Success || + deleteResult.outcome === DeletionOutcome.Uncertain + ) { + detail.materialsDeleted += 1 + detail.deletedMaterials.push({ + materialCode, + materialName, + rowNumber: rowNumInt, + outcome: deleteResult.outcome + }) + if (deleteResult.outcome === DeletionOutcome.Uncertain) { + detail.uncertainDeletions += 1 + } + this.log.info('[物料操作完成] 物料删除结果', { + orderNumber, + materialCode, + outcome: deleteResult.outcome, + attempts: deleteResult.attempts.length, + elapsedMs: deleteElapsed + }) + } else { + detail.materialsFailed += 1 + detail.failedMaterials.push({ + materialCode, + materialName, + rowNumber: rowNumInt, + attempts: deleteResult.attempts, + finalOutcome: deleteResult.outcome, + finalErrorCategory: deleteResult.errorCategory + }) + this.log.error('[物料操作失败] 物料删除失败', { + orderNumber, + materialCode, + outcome: deleteResult.outcome, + errorCategory: deleteResult.errorCategory, + errorMessage: deleteResult.errorMessage, + attempts: deleteResult.attempts.length, + elapsedMs: deleteElapsed + }) + } + return + } + + detail.materialsSkipped += 1 + const reason = getSkipReason({ + rowNumber: rowNumInt, + pendingQty, + materialCode, + deleteSet + }) + this.log.debug('[物料跳过] 物料不满足删除条件', { + orderNumber, + materialIdx, + materialCode, + materialName, + rowNumber: rowNumInt, + reason, + elapsedMs: Date.now() - materialStartTime + }) + detail.skippedMaterials.push({ + materialCode, + materialName, + rowNumber: rowNumInt, + reason + }) + } + + private async ensureNotRedirectedToLoginPage( + page: Page, + step: string, + expectedOrderNumber: string | undefined, + progressState: ProgressState, + elapsedMs: number + ): Promise { + const pageState = await capturePageState(page, this.getBrowserContext(), { + includeFrameHierarchy: true, + includeBodyTextPreview: true + }) + + if (!pageState.isCasLoginRedirect && pageState.pageKind !== 'login') { + return + } + + this.log.error('[SESSION_LOST] 会话跳转到 CAS 登录页', { + step, + orderNumber: expectedOrderNumber, + orderIndex: progressState.ordersStarted, + orderPosition: `${progressState.ordersStarted + 1}/${progressState.totalOrders}`, + elapsedMs, + detectedBy: pageState.isCasLoginRedirect ? 'url_match' : 'login_form_detected', + ...pageState + }) + + throw new Error('ERP 会话已跳转到登录页') + } + + private async logPageStateSnapshot( + page: Page, + step: string, + options: { + level?: 'info' | 'warn' | 'error' | 'debug' + orderNumber?: string + orderIndex?: number + orderPosition?: string + workerId?: number + elapsedMs?: number + includeFrameHierarchy?: boolean + includeBodyTextPreview?: boolean + } = {} + ) { + const pageState = await capturePageState(page, this.getBrowserContext(), { + includeFrameHierarchy: options.includeFrameHierarchy, + includeBodyTextPreview: options.includeBodyTextPreview + }) + + const payload = { + step, + orderNumber: options.orderNumber, + orderIndex: options.orderIndex, + orderPosition: options.orderPosition, + workerId: options.workerId, + elapsedMs: options.elapsedMs, + ...pageState + } + + const level = options.level ?? 'info' + this.log[level]('[PAGE_STATE] 页面状态快照', payload) + return payload + } + + private calculateProgress( + completedOrders: number, + materialIdx: number, + detailCount: number, + totalOrders: number + ): number { + const materialRatio = detailCount > 0 ? materialIdx / detailCount : 0 + return ((1 + completedOrders + materialRatio) / (1 + totalOrders)) * 100 + } + + private async extractSourceOrderNumber(frame: FrameLocator): Promise { + try { + const sourceOrder = await frame + .locator('.vsourcebillcode .code-detail-link') + .first() + .innerText() + const match = sourceOrder.match(/SC\d{14}/) + return match ? match[0] : sourceOrder.trim() + } catch { + return '' + } + } + + private async getInputValue( + container: FrameLocator | Locator, + labelRegex: RegExp + ): Promise { + try { + return await container + .locator('div') + .filter({ hasText: labelRegex }) + .locator('input') + .first() + .inputValue() + } catch { + return '' + } + } + + private async isButtonEnabled(button: Locator): Promise { + try { + return await button.isEnabled() + } catch { + return false + } + } +}