refactor: extract order detail processor from cleaner service
Move processDetailPage and its helper methods into a dedicated CleanerOrderDetailProcessor class to reduce cleaner.ts size and improve maintainability. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,8 @@
|
|||||||
import { ErpAuthService } from './erp-auth'
|
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 { CleanerInput, CleanerResult, OrderCleanDetail } from '../../types/cleaner.types'
|
||||||
import type { ErpSession } from '../../types/erp.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 { createLogger, run, trackDuration } from '../logger'
|
||||||
import { capturePageContext } from './erp-error-context'
|
import { capturePageContext } from './erp-error-context'
|
||||||
import { capturePageState } from './page-state'
|
import { capturePageState } from './page-state'
|
||||||
@@ -10,15 +10,10 @@ import { AsyncMutex, ConcurrencyTracker } from './cleaner/concurrency'
|
|||||||
import { evaluateDeletionSignals as evaluateDeletionSignalsHelper } from './cleaner/deletion-signals'
|
import { evaluateDeletionSignals as evaluateDeletionSignalsHelper } from './cleaner/deletion-signals'
|
||||||
import { MaterialDeletionVerifier } from './cleaner/material-deletion-verifier'
|
import { MaterialDeletionVerifier } from './cleaner/material-deletion-verifier'
|
||||||
import { CleanerNavigation } from './cleaner/navigation'
|
import { CleanerNavigation } from './cleaner/navigation'
|
||||||
|
import { CleanerOrderDetailProcessor } from './cleaner/order-detail-processor'
|
||||||
import { CleanerRetryHandler } from './cleaner/retry-handler'
|
import { CleanerRetryHandler } from './cleaner/retry-handler'
|
||||||
import { getSkipReason, shouldDeleteMaterial } from './cleaner/rules'
|
import { getSkipReason, shouldDeleteMaterial } from './cleaner/rules'
|
||||||
import {
|
import { clampNumber, createBatches, getMissingOrders, runWithConcurrency } from './cleaner/utils'
|
||||||
clampNumber,
|
|
||||||
createBatches,
|
|
||||||
delay,
|
|
||||||
getMissingOrders,
|
|
||||||
runWithConcurrency
|
|
||||||
} from './cleaner/utils'
|
|
||||||
import type {
|
import type {
|
||||||
CleanerOptions,
|
CleanerOptions,
|
||||||
ProgressState,
|
ProgressState,
|
||||||
@@ -47,6 +42,7 @@ export class CleanerService {
|
|||||||
private dryRun: boolean
|
private dryRun: boolean
|
||||||
private deletionVerifier: MaterialDeletionVerifier
|
private deletionVerifier: MaterialDeletionVerifier
|
||||||
private navigation: CleanerNavigation
|
private navigation: CleanerNavigation
|
||||||
|
private orderDetailProcessor: CleanerOrderDetailProcessor
|
||||||
private retryHandler: CleanerRetryHandler
|
private retryHandler: CleanerRetryHandler
|
||||||
|
|
||||||
constructor(authService: ErpAuthService, options: CleanerOptions = {}) {
|
constructor(authService: ErpAuthService, options: CleanerOptions = {}) {
|
||||||
@@ -56,6 +52,9 @@ export class CleanerService {
|
|||||||
this.navigation = new CleanerNavigation(log, (page, step, snapshotOptions) =>
|
this.navigation = new CleanerNavigation(log, (page, step, snapshotOptions) =>
|
||||||
this.logPageStateSnapshot(page, step, snapshotOptions)
|
this.logPageStateSnapshot(page, step, snapshotOptions)
|
||||||
)
|
)
|
||||||
|
this.orderDetailProcessor = new CleanerOrderDetailProcessor(log, this.deletionVerifier, () =>
|
||||||
|
this.getBrowserContext()
|
||||||
|
)
|
||||||
this.retryHandler = new CleanerRetryHandler(log, {
|
this.retryHandler = new CleanerRetryHandler(log, {
|
||||||
queryOrders: (workFrame, popupPage, orderNumbers) =>
|
queryOrders: (workFrame, popupPage, orderNumbers) =>
|
||||||
this.queryOrders(workFrame, popupPage, orderNumbers),
|
this.queryOrders(workFrame, popupPage, orderNumbers),
|
||||||
@@ -556,35 +555,6 @@ export class CleanerService {
|
|||||||
return payload
|
return payload
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ensureNotRedirectedToLoginPage(
|
|
||||||
page: Page,
|
|
||||||
step: string,
|
|
||||||
expectedOrderNumber: string | undefined,
|
|
||||||
progressState: ProgressState,
|
|
||||||
elapsedMs: number
|
|
||||||
): Promise<void> {
|
|
||||||
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: {
|
private async refreshSessionAtBatchBoundary(params: {
|
||||||
batchIndex: number
|
batchIndex: number
|
||||||
totalBatches: number
|
totalBatches: number
|
||||||
@@ -626,592 +596,8 @@ export class CleanerService {
|
|||||||
extra?: Partial<import('../../types/cleaner.types').CleanerProgress>
|
extra?: Partial<import('../../types/cleaner.types').CleanerProgress>
|
||||||
) => void
|
) => void
|
||||||
}): Promise<OrderCleanDetail> {
|
}): Promise<OrderCleanDetail> {
|
||||||
const { detailPage, deleteSet, dryRun, progressState, expectedOrderNumber, onProgress } = params
|
return this.orderDetailProcessor.processDetailPage(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('[详情页面清理完成] 详情页已关闭')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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<string> {
|
|
||||||
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 {
|
private isOrderNumber(value: string): boolean {
|
||||||
return /^SC\d{14}$/.test(value)
|
return /^SC\d{14}$/.test(value)
|
||||||
}
|
}
|
||||||
@@ -1243,30 +629,6 @@ export class CleanerService {
|
|||||||
await this.navigation.waitForLoading(frame)
|
await this.navigation.waitForLoading(frame)
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getInputValue(
|
|
||||||
container: FrameLocator | Locator,
|
|
||||||
labelRegex: RegExp
|
|
||||||
): Promise<string> {
|
|
||||||
try {
|
|
||||||
return await container
|
|
||||||
.locator('div')
|
|
||||||
.filter({ hasText: labelRegex })
|
|
||||||
.locator('input')
|
|
||||||
.first()
|
|
||||||
.inputValue()
|
|
||||||
} catch {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async isButtonEnabled(button: Locator): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
return await button.isEnabled()
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pure logic: evaluate deletion signals and determine outcome.
|
* Pure logic: evaluate deletion signals and determine outcome.
|
||||||
* No Playwright dependencies — easy to unit test.
|
* No Playwright dependencies — easy to unit test.
|
||||||
@@ -1284,10 +646,6 @@ export class CleanerService {
|
|||||||
return evaluateDeletionSignalsHelper(params)
|
return evaluateDeletionSignalsHelper(params)
|
||||||
}
|
}
|
||||||
|
|
||||||
private delay(ms: number): Promise<void> {
|
|
||||||
return delay(ms)
|
|
||||||
}
|
|
||||||
|
|
||||||
private async retryFailedOrders(params: {
|
private async retryFailedOrders(params: {
|
||||||
workFrame: FrameLocator
|
workFrame: FrameLocator
|
||||||
popupPage: Page
|
popupPage: Page
|
||||||
|
|||||||
788
src/main/services/erp/cleaner/order-detail-processor.ts
Normal file
788
src/main/services/erp/cleaner/order-detail-processor.ts
Normal file
@@ -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<string, unknown>) => void
|
||||||
|
info: (message: string, meta?: Record<string, unknown>) => void
|
||||||
|
warn: (message: string, meta?: Record<string, unknown>) => void
|
||||||
|
error: (message: string, meta?: Record<string, unknown>) => void
|
||||||
|
verbose: (message: string, meta?: Record<string, unknown>) => 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<string>
|
||||||
|
dryRun: boolean
|
||||||
|
progressState: ProgressState
|
||||||
|
expectedOrderNumber?: string
|
||||||
|
onProgress?: (message: string, progress?: number, extra?: Partial<CleanerProgress>) => void
|
||||||
|
}): Promise<OrderCleanDetail> {
|
||||||
|
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<string>
|
||||||
|
dryRun: boolean
|
||||||
|
progressState: ProgressState
|
||||||
|
onProgress?: (message: string, progress?: number, extra?: Partial<CleanerProgress>) => void
|
||||||
|
detail: OrderCleanDetail
|
||||||
|
detailCount: number
|
||||||
|
orderNumber: string
|
||||||
|
processStartTime: number
|
||||||
|
}): Promise<void> {
|
||||||
|
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<string>
|
||||||
|
dryRun: boolean
|
||||||
|
materialCode: string
|
||||||
|
materialName: string
|
||||||
|
materialIdx: number
|
||||||
|
currentRow: string
|
||||||
|
rowNumInt: number
|
||||||
|
pendingQty: string
|
||||||
|
orderNumber: string
|
||||||
|
detailCount: number
|
||||||
|
materialStartTime: number
|
||||||
|
}): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<string> {
|
||||||
|
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<string> {
|
||||||
|
try {
|
||||||
|
return await container
|
||||||
|
.locator('div')
|
||||||
|
.filter({ hasText: labelRegex })
|
||||||
|
.locator('input')
|
||||||
|
.first()
|
||||||
|
.inputValue()
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async isButtonEnabled(button: Locator): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
return await button.isEnabled()
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user