Files
BIPMaterialManager/src/main/services/erp/cleaner.ts
2026-04-28 21:16:28 +08:00

1511 lines
52 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { ErpAuthService } from './erp-auth'
import { 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 { createLogger, run, trackDuration } from '../logger'
import { capturePageContext } from './erp-error-context'
import { capturePageState } from './page-state'
import { AsyncMutex, ConcurrencyTracker } from './cleaner/concurrency'
import { evaluateDeletionSignals as evaluateDeletionSignalsHelper } from './cleaner/deletion-signals'
import { MaterialDeletionVerifier } from './cleaner/material-deletion-verifier'
import { CleanerNavigation } from './cleaner/navigation'
import { getSkipReason, shouldDeleteMaterial } from './cleaner/rules'
import {
clampNumber,
createBatches,
delay,
getMissingOrders,
runWithConcurrency
} from './cleaner/utils'
import type {
CleanerOptions,
ProgressState,
QueryResultRow,
RetryResult,
ShouldDeleteParams
} from './cleaner/types'
const log = createLogger('CleanerService')
const DEFAULT_QUERY_BATCH_SIZE = 100
const MAX_QUERY_BATCH_SIZE = 100
const DEFAULT_PROCESS_CONCURRENCY = 1
const MAX_PROCESS_CONCURRENCY = 20
const DEFAULT_SESSION_REFRESH_ORDER_THRESHOLD = 160
export type { CleanerOptions, ShouldDeleteParams } from './cleaner/types'
export { createBatches, getMissingOrders, runWithConcurrency } from './cleaner/utils'
/**
* ERP Cleaner Service
* Deletes specified materials from production orders in ERP system
*/
export class CleanerService {
private authService: ErpAuthService
private dryRun: boolean
private deletionVerifier: MaterialDeletionVerifier
private navigation: CleanerNavigation
constructor(authService: ErpAuthService, options: CleanerOptions = {}) {
this.authService = authService
this.dryRun = options.dryRun ?? false
this.deletionVerifier = new MaterialDeletionVerifier(log)
this.navigation = new CleanerNavigation(log, (page, step, snapshotOptions) =>
this.logPageStateSnapshot(page, step, snapshotOptions)
)
}
/**
* Check if dry-run mode is enabled
*/
isDryRun(): boolean {
return this.dryRun
}
/**
* Determine if a material should be deleted
*/
shouldDeleteMaterial(params: ShouldDeleteParams): boolean {
return shouldDeleteMaterial(params)
}
getSkipReason(params: ShouldDeleteParams): string {
return getSkipReason(params)
}
async clean(input: CleanerInput): Promise<CleanerResult> {
return run(
async () => {
return await this.performCleanup(input)
},
{ operation: 'cleaner' }
)
}
private async performCleanup(input: CleanerInput): Promise<CleanerResult> {
const result: CleanerResult = {
ordersProcessed: 0,
materialsDeleted: 0,
materialsSkipped: 0,
errors: [],
details: [],
retriedOrders: 0,
successfulRetries: 0,
materialsFailed: 0,
uncertainDeletions: 0
}
const totalOrders = input.orderNumbers.length
const totalMaterials = input.materialCodes.length
const dryRun = input.dryRun ?? this.dryRun
const queryBatchSize = clampNumber(
input.queryBatchSize,
DEFAULT_QUERY_BATCH_SIZE,
1,
MAX_QUERY_BATCH_SIZE
)
const processConcurrency = clampNumber(
input.processConcurrency,
DEFAULT_PROCESS_CONCURRENCY,
1,
MAX_PROCESS_CONCURRENCY
)
const sessionRefreshOrderThreshold =
input.sessionRefreshOrderThreshold && input.sessionRefreshOrderThreshold > 0
? Math.trunc(input.sessionRefreshOrderThreshold)
: DEFAULT_SESSION_REFRESH_ORDER_THRESHOLD
log.info('Starting cleaner', {
totalOrders,
totalMaterials,
dryRun,
queryBatchSize,
processConcurrency,
sessionRefreshOrderThreshold,
orderNumbers: input.orderNumbers,
materialCodes: input.materialCodes
})
const deleteSet = new Set(input.materialCodes)
let popupPage: Page | null = null
try {
const session = this.authService.getSession()
const navigation = await this.navigateToCleanerPage(session)
popupPage = navigation.popupPage
let { workFrame } = navigation
await this.setupQueryInterface(workFrame, popupPage)
const orderBatches = createBatches(input.orderNumbers, queryBatchSize)
const popupMutex = new AsyncMutex()
let ordersProcessedSinceLogin = 0
const progressState: ProgressState = {
ordersStarted: 0,
ordersCompleted: 0,
totalOrders
}
for (let batchIndex = 0; batchIndex < orderBatches.length; batchIndex++) {
const batchOrders = orderBatches[batchIndex]
log.info('Processing cleaner batch', {
batchIndex: batchIndex + 1,
totalBatches: orderBatches.length,
batchSize: batchOrders.length,
totalOrders,
totalMaterials
})
// [新增] 健康检查定时器 - 检测长时间无进展
let lastActivityTime = Date.now()
progressState.lastActivityTime = lastActivityTime
const healthCheckInterval = setInterval(() => {
const secondsSinceLastActivity = (Date.now() - lastActivityTime) / 1000
if (secondsSinceLastActivity > 60) {
log.warn('[HEALTH_CHECK] 长时间无进展', {
ordersCompleted: progressState.ordersCompleted,
totalOrders: progressState.totalOrders,
lastCompletedOrder: progressState.lastCompletedOrder,
noProgressSeconds: secondsSinceLastActivity,
suspectedStuck: secondsSinceLastActivity > 180,
healthStatus: secondsSinceLastActivity > 180 ? 'critical' : 'warning'
})
}
}, 30000) // 每 30 秒检查一次
// [新增] 为每个 batch 创建并发追踪器
const tracker = new ConcurrencyTracker(log)
try {
// Track batch processing duration with 5s slow threshold
await trackDuration(
async () => {
// Phase 1: Query orders
await trackDuration(
async () => await this.queryOrders(workFrame, popupPage!, batchOrders),
{
operationName: 'query',
message: '执行订单查询',
slowThresholdMs: 3000,
context: { orderCount: batchOrders.length }
}
)
// Phase 2: Wait for loading complete
await trackDuration(async () => await this.waitForLoading(workFrame), {
operationName: 'wait_loading',
message: '等待加载完成',
slowThresholdMs: 5000
})
// Phase 3: Collect query results
const collectResult = await trackDuration(
async () => await this.collectQueryResultRows(workFrame, popupPage!),
{
operationName: 'collect_results',
message: '收集查询结果',
slowThresholdMs: 2000
}
)
const queriedRows = collectResult.result
const queriedOrderNumbersInBatch = new Set(queriedRows.map((row) => row.orderNumber))
// Phase 4: Process all orders in batch
await trackDuration(
async () => {
await runWithConcurrency(
queriedRows,
processConcurrency,
async (row, _index, workerId) => {
const { rowIndex, orderNumber } = row
// [新增] Worker 开始追踪
tracker.workerStarted(workerId, orderNumber)
try {
// [新增] Mutex 等待追踪
tracker.waitingForMutex(workerId, orderNumber)
const openedDetailPage = await popupMutex.runExclusive(async () => {
// [新增] Mutex 获取追踪
tracker.acquiredMutex(workerId, orderNumber)
try {
return await this.openDetailPageFromRow(
workFrame,
popupPage!,
rowIndex,
{
orderNumber,
orderIndex: progressState.ordersStarted,
orderPosition: `${progressState.ordersStarted + 1}/${progressState.totalOrders}`,
workerId
}
)
} finally {
tracker.releasedMutex(workerId, orderNumber)
}
})
let detail: OrderCleanDetail
try {
detail = await this.processDetailPage({
detailPage: openedDetailPage,
deleteSet,
dryRun,
expectedOrderNumber: orderNumber,
progressState,
onProgress: input.onProgress
})
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
detail = this.createErrorDetail(orderNumber, message)
} finally {
progressState.ordersStarted += 1
progressState.ordersCompleted += 1
progressState.lastCompletedOrder = orderNumber
lastActivityTime = Date.now() // [新增] 健康检查:更新活动时间
}
result.details.push(detail)
if (detail.errors.length > 0) {
result.errors.push(
`Order ${detail.orderNumber}: ${detail.errors.join('; ')}`
)
return
}
result.ordersProcessed += 1
result.materialsDeleted += detail.materialsDeleted
result.materialsSkipped += detail.materialsSkipped
result.materialsFailed += detail.materialsFailed
result.uncertainDeletions += detail.uncertainDeletions
} finally {
// [新增] Worker 完成追踪
tracker.workerCompleted(workerId, orderNumber)
}
}
)
// Handle missing orders
const missingOrders = getMissingOrders(batchOrders, queriedOrderNumbersInBatch)
for (const missingOrder of missingOrders) {
const missingMessage = '订单未出现在查询结果中'
result.errors.push(`Order ${missingOrder}: ${missingMessage}`)
result.details.push(this.createErrorDetail(missingOrder, missingMessage, true))
}
},
{
operationName: 'process_all_orders_in_batch',
message: `处理批次中所有${queriedRows.length}个订单`,
slowThresholdMs: 10000
}
)
// Phase 5: Save batch results (already included in process_all_orders_in_batch)
// No separate save step needed as results are accumulated in result object
},
{
operationName: `batch-${batchIndex + 1}-${orderBatches[batchIndex].length}-orders`,
message: `Batch ${batchIndex + 1}/${orderBatches.length}`,
slowThresholdMs: 5000,
context: {
batchIndex: batchIndex + 1,
totalBatches: orderBatches.length,
batchSize: batchOrders.length,
totalOrders,
totalMaterials
}
}
)
} finally {
// [新增] 清理健康检查定时器
clearInterval(healthCheckInterval)
}
ordersProcessedSinceLogin += batchOrders.length
const remainingBatches = orderBatches.length - (batchIndex + 1)
log.info('[SESSION_REFRESH_CHECK] 批次完成,检查是否需要重建会话', {
batchIndex: batchIndex + 1,
totalBatches: orderBatches.length,
batchSize: batchOrders.length,
ordersProcessedSinceLogin,
threshold: sessionRefreshOrderThreshold,
remainingBatches
})
if (remainingBatches > 0 && ordersProcessedSinceLogin >= sessionRefreshOrderThreshold) {
log.info('[SESSION_REFRESH_TRIGGERED] 达到阈值,准备重建会话', {
batchIndex: batchIndex + 1,
totalBatches: orderBatches.length,
batchSize: batchOrders.length,
ordersProcessedSinceLogin,
threshold: sessionRefreshOrderThreshold,
remainingBatches
})
const refreshedNavigation = await this.refreshSessionAtBatchBoundary({
batchIndex: batchIndex + 1,
totalBatches: orderBatches.length,
batchSize: batchOrders.length,
threshold: sessionRefreshOrderThreshold,
ordersProcessedSinceLogin,
totalOrders,
completedOrders: progressState.ordersCompleted
})
popupPage = refreshedNavigation.popupPage
workFrame = refreshedNavigation.workFrame
ordersProcessedSinceLogin = 0
} else if (
remainingBatches === 0 &&
ordersProcessedSinceLogin >= sessionRefreshOrderThreshold
) {
log.info('[SESSION_REFRESH_SKIPPED] 已达到阈值但无剩余批次,跳过重建', {
batchIndex: batchIndex + 1,
totalBatches: orderBatches.length,
batchSize: batchOrders.length,
ordersProcessedSinceLogin,
threshold: sessionRefreshOrderThreshold,
remainingBatches
})
}
}
const retryResult = await this.retryFailedOrders({
workFrame,
popupPage,
failedDetails: result.details.filter(
(d) => d.errors.length > 0 && this.isOrderNumber(d.orderNumber)
),
deleteSet,
dryRun,
onProgress: input.onProgress
})
result.retriedOrders = retryResult.retriedOrders
result.successfulRetries = retryResult.successfulRetries
retryResult.updatedDetails.forEach((updatedDetail) => {
const index = result.details.findIndex((d) => d.orderNumber === updatedDetail.orderNumber)
if (index !== -1) {
const previousDetail = result.details[index]
if (updatedDetail.retrySuccess && previousDetail.errors.length > 0) {
result.ordersProcessed += 1
result.materialsDeleted += updatedDetail.materialsDeleted
result.materialsSkipped += updatedDetail.materialsSkipped
result.materialsFailed += updatedDetail.materialsFailed
result.uncertainDeletions += updatedDetail.uncertainDeletions
}
result.details[index] = updatedDetail
}
})
const successfulRetryOrders = new Set(
retryResult.updatedDetails.filter((d) => d.retrySuccess).map((d) => d.orderNumber)
)
result.errors = result.errors.filter(
(err) => !successfulRetryOrders.has(err.split(':')[0].replace('Order ', ''))
)
log.info('Cleaner completed', {
ordersProcessed: result.ordersProcessed,
materialsDeleted: result.materialsDeleted,
materialsSkipped: result.materialsSkipped,
materialsFailed: result.materialsFailed,
uncertainDeletions: result.uncertainDeletions,
errorCount: result.errors.length,
totalOrders,
totalMaterials,
dryRun
})
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
log.error('Cleaner failed', {
error: message,
totalOrders,
totalMaterials,
dryRun,
orderNumbers: input.orderNumbers,
materialCodes: input.materialCodes,
...(popupPage
? await capturePageContext(
popupPage,
undefined,
'cleaner.outerCatch',
undefined,
undefined,
'outer_catch'
)
: {})
})
result.errors.push(`Clean failed: ${message}`)
result.crashed = true
} finally {
if (popupPage) {
try {
await popupPage.close()
} catch {
// Ignore close errors
}
}
}
return result
}
async navigateToCleanerPage(
session: ErpSession
): Promise<{ popupPage: Page; workFrame: FrameLocator }> {
return this.navigation.navigateToCleanerPage(session)
}
private async setupQueryInterface(innerFrame: FrameLocator, popupPage: Page): Promise<void> {
await this.navigation.setupQueryInterface(innerFrame, popupPage)
}
private async queryOrders(
workFrame: FrameLocator,
popupPage: Page,
orderNumbers: string[]
): Promise<void> {
await this.navigation.queryOrders(workFrame, popupPage, orderNumbers)
}
private async collectQueryResultRows(
workFrame: FrameLocator,
popupPage: Page
): Promise<QueryResultRow[]> {
return this.navigation.collectQueryResultRows(workFrame, popupPage)
}
private async openDetailPageFromRow(
workFrame: FrameLocator,
popupPage: Page,
rowIndex: number,
options?: {
orderNumber?: string
orderIndex?: number
orderPosition?: string
workerId?: number
}
): Promise<Page> {
return this.navigation.openDetailPageFromRow(workFrame, popupPage, rowIndex, options)
}
private async openDetailPageFromCurrentQuery(
workFrame: FrameLocator,
popupPage: Page,
orderNumber?: string
): Promise<Page> {
return this.navigation.openDetailPageFromCurrentQuery(workFrame, popupPage, orderNumber)
}
private getBrowserContext(): BrowserContext | undefined {
try {
return this.authService.getSession().context
} catch {
return undefined
}
}
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'
log[level]('[PAGE_STATE] 页面状态快照', 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: {
batchIndex: number
totalBatches: number
batchSize: number
threshold: number
ordersProcessedSinceLogin: number
totalOrders: number
completedOrders: number
}): Promise<{ popupPage: Page; workFrame: FrameLocator }> {
const refreshStartTime = Date.now()
log.info('[SESSION_REFRESH_START] 开始关闭浏览器并重新登录', {
...params
})
await this.authService.close()
const session = await this.authService.login()
const navigation = await this.navigateToCleanerPage(session)
await this.setupQueryInterface(navigation.workFrame, navigation.popupPage)
log.info('[SESSION_REFRESH_SUCCESS] 会话重建成功', {
...params,
elapsedMs: Date.now() - refreshStartTime
})
return navigation
}
private async processDetailPage(params: {
detailPage: Page
deleteSet: Set<string>
dryRun: boolean
progressState: ProgressState
expectedOrderNumber?: string
onProgress?: (
message: string,
progress?: number,
extra?: Partial<import('../../types/cleaner.types').CleanerProgress>
) => void
}): Promise<OrderCleanDetail> {
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('[详情页面清理完成] 详情页已关闭')
}
}
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 {
return /^SC\d{14}$/.test(value)
}
private createErrorDetail(
orderNumber: string,
message: string,
notFound: boolean = false
): OrderCleanDetail {
return {
orderNumber,
materialsDeleted: 0,
materialsSkipped: 0,
errors: [message],
skippedMaterials: [],
deletedMaterials: [],
retryCount: 0,
retryAttempts: [],
retriedAt: undefined,
retrySuccess: false,
materialsFailed: 0,
failedMaterials: [],
uncertainDeletions: 0,
notFound
}
}
private async waitForLoading(frame: FrameLocator): Promise<void> {
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.
* No Playwright dependencies — easy to unit test.
*/
evaluateDeletionSignals(params: {
rowChanged: boolean
countDecreased: boolean | null
hasError: boolean
errorText?: string
}): {
outcome: DeletionOutcome
errorCategory?: DeletionErrorCategory
errorMessage?: string
} {
return evaluateDeletionSignalsHelper(params)
}
private delay(ms: number): Promise<void> {
return delay(ms)
}
private async retryFailedOrders(params: {
workFrame: FrameLocator
popupPage: Page
failedDetails: OrderCleanDetail[]
deleteSet: Set<string>
dryRun: boolean
onProgress?: (
message: string,
progress?: number,
extra?: Partial<import('../../types/cleaner.types').CleanerProgress>
) => void
}): Promise<RetryResult> {
const { workFrame, popupPage, failedDetails, deleteSet, dryRun, onProgress } = params
const result: RetryResult = {
retriedOrders: 0,
successfulRetries: 0,
updatedDetails: []
}
if (failedDetails.length === 0) {
log.info('[重试机制] 没有需要重试的订单', { checkedCount: failedDetails.length })
return result
}
const retryStartTime = Date.now()
log.info('[重试机制开始] 准备重试失败的订单', {
totalFailures: failedDetails.length,
failedOrders: failedDetails
.map((d) => d.orderNumber)
.slice(0, 5)
.concat(failedDetails.length > 5 ? [`... (${failedDetails.length - 5} more)`] : [])
})
const MAX_RETRIES = 2
// Track overall retry process duration
const trackedResult = await trackDuration(
async () => {
const retryResult: RetryResult = {
retriedOrders: 0,
successfulRetries: 0,
updatedDetails: []
}
for (let detailIndex = 0; detailIndex < failedDetails.length; detailIndex++) {
const failedDetail = failedDetails[detailIndex]
const orderNumber = failedDetail.orderNumber
const retryAttempts: import('../../types/cleaner.types').RetryAttempt[] = []
const retryLoopStartTime = Date.now()
log.info('[重试订单] 开始处理失败订单的重试', {
orderNumber,
failureReason: failedDetail.errors.join('; '),
index: detailIndex + 1,
total: failedDetails.length
})
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
const attemptStartTime = Date.now()
log.info('[重试尝试] 开始第 {attempt} 次重试', {
orderNumber,
attempt,
maxRetries: MAX_RETRIES,
elapsedMs: attemptStartTime - retryLoopStartTime,
interpolatedAttempt: attempt
})
try {
// Step 1: Re-query order
log.debug('[重试查询] 重新查询订单', { orderNumber, attempt })
await this.queryOrders(workFrame, popupPage, [orderNumber])
await this.waitForLoading(workFrame)
log.debug('[重试查询完成] 查询加载完成', {
orderNumber,
attempt,
elapsedMs: Date.now() - attemptStartTime
})
// Step 2: Check query results
const rows = workFrame.locator('tbody tr')
const rowCount = await rows.count()
if (rowCount === 0) {
const errorMsg = '订单重试查询无结果'
log.error('[重试失败] 重试查询返回空结果', {
orderNumber,
attempt,
rowCount,
elapsedMs: Date.now() - attemptStartTime
})
throw new Error(errorMsg)
}
log.debug('[重试查询] 查询到 {rowCount} 行结果', { orderNumber, attempt, rowCount })
// Step 3: Open detail page
log.debug('[重试详情] 打开订单详情页', { orderNumber, attempt })
const detailPage = await this.openDetailPageFromCurrentQuery(
workFrame,
popupPage,
orderNumber
)
log.debug('[重试详情] 详情页已打开', {
orderNumber,
attempt,
elapsedMs: Date.now() - attemptStartTime
})
// Step 4: Process detail page
log.debug('[重试处理] 开始处理详情页', { orderNumber, attempt })
const retryDetail = await this.processDetailPage({
detailPage,
deleteSet,
dryRun,
expectedOrderNumber: orderNumber,
progressState: {
ordersStarted: detailIndex,
ordersCompleted: 0,
totalOrders: failedDetails.length
},
onProgress: (message, progress, extra) => {
onProgress?.(
`[重试 ${attempt}/${MAX_RETRIES}] ${message}`,
progress,
extra ? { ...extra, phase: 'processing' as const } : undefined
)
}
})
log.info('[重试处理完成] 详情页处理完毕', {
orderNumber,
attempt,
deleted: retryDetail.materialsDeleted,
skipped: retryDetail.materialsSkipped,
elapsedMs: Date.now() - attemptStartTime
})
// Success - update counters
retryResult.successfulRetries += 1
retryResult.updatedDetails.push({
...retryDetail,
retryCount: attempt,
retriedAt: Date.now(),
retrySuccess: true,
retryAttempts
})
retryResult.retriedOrders += 1
log.info('[重试成功] 订单重试成功', {
orderNumber,
attempt,
cumulativeSuccesses: retryResult.successfulRetries,
cumulativeRetried: retryResult.retriedOrders,
deletedMaterials: retryDetail.materialsDeleted,
elapsedMs: Date.now() - retryLoopStartTime
})
break
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
const attemptElapsed = Date.now() - attemptStartTime
log.warn('[重试失败] 当前重试尝试失败', {
orderNumber,
attempt,
maxRetries: MAX_RETRIES,
error: message,
elapsedMs: attemptElapsed,
remainingAttempts: MAX_RETRIES - attempt
})
retryAttempts.push({
attempt,
error: message,
timestamp: Date.now()
})
if (attempt === MAX_RETRIES) {
log.error('[重试彻底失败] 所有重试均已失败', {
orderNumber,
totalAttempts: MAX_RETRIES,
errors: retryAttempts.map((a) => a.error),
elapsedMs: Date.now() - retryLoopStartTime,
finalOutcome: 'exhausted_all_retries',
successRate: `${((retryResult.successfulRetries / (detailIndex + 1)) * 100).toFixed(1)}%`
})
retryResult.updatedDetails.push({
...failedDetail,
retryCount: MAX_RETRIES,
retryAttempts,
retriedAt: Date.now(),
retrySuccess: false
})
retryResult.retriedOrders += 1
}
}
}
}
const totalRetryTime = Date.now() - retryStartTime
const finalSuccessRate =
failedDetails.length > 0
? ((retryResult.successfulRetries / failedDetails.length) * 100).toFixed(1) + '%'
: 'N/A'
const avgTimePerRetry = totalRetryTime / (failedDetails.length || 1)
log.info('[重试机制完成] 所有重试订单处理完毕', {
totalRetryOrders: failedDetails.length,
successfulRetries: retryResult.successfulRetries,
failedRetries: failedDetails.length - retryResult.successfulRetries,
successRate: finalSuccessRate,
totalElapsedTimeMs: totalRetryTime,
avgTimePerRetry,
isSlow: totalRetryTime > 30000
})
return retryResult
},
{
operationName: 'retry-failed-orders',
message: 'Retry failed orders',
slowThresholdMs: 5000,
context: {
totalRetryOrders: failedDetails.length,
dryRun
}
}
)
return trackedResult.result
}
}