feat(cleaner): add automatic retry mechanism for failed orders

- Add retry logic with max 2 attempts per failed order
- Track retry statistics (retriedOrders, successfulRetries)
- Generate detailed retry report section in execution reports
- Display retry metrics in ExecutionReportDialog UI

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Misaka_Company
2026-03-13 15:59:10 +08:00
parent a25ffd75c5
commit 715dfb4d71
9 changed files with 325 additions and 21 deletions

View File

@@ -296,7 +296,8 @@ export function registerValidationHandlers(): void {
if (sourceNumbers.length === 0) { if (sourceNumbers.length === 0) {
return { return {
success: false, success: false,
error: '共享的 Production ID 没有找到对应的订单数据。请确保在数据提取页面输入了有效的 Production ID 并成功获取了订单数据。', error:
'共享的 Production ID 没有找到对应的订单数据。请确保在数据提取页面输入了有效的 Production ID 并成功获取了订单数据。',
stats: { stats: {
totalRecords: 0, totalRecords: 0,
matchedCount: 0, matchedCount: 0,
@@ -315,7 +316,8 @@ export function registerValidationHandlers(): void {
if (sourceNumbers.length === 0) { if (sourceNumbers.length === 0) {
return { return {
success: false, success: false,
error: '文件中的 Production ID 没有找到对应的订单数据。请检查 Production ID 是否正确,或确保数据库中有对应的订单数据。', error:
'文件中的 Production ID 没有找到对应的订单数据。请检查 Production ID 是否正确,或确保数据库中有对应的订单数据。',
stats: { stats: {
totalRecords: 0, totalRecords: 0,
matchedCount: 0, matchedCount: 0,

View File

@@ -7,6 +7,12 @@ import { createLogger } from '../logger'
const log = createLogger('CleanerService') const log = createLogger('CleanerService')
interface RetryResult {
retriedOrders: number
successfulRetries: number
updatedDetails: OrderCleanDetail[]
}
/** /**
* Cleaner Service Options * Cleaner Service Options
*/ */
@@ -102,7 +108,9 @@ export class CleanerService {
materialsDeleted: 0, materialsDeleted: 0,
materialsSkipped: 0, materialsSkipped: 0,
errors: [], errors: [],
details: [] details: [],
retriedOrders: 0,
successfulRetries: 0
} }
const totalOrders = input.orderNumbers.length const totalOrders = input.orderNumbers.length
@@ -158,7 +166,11 @@ export class CleanerService {
materialsDeleted: 0, materialsDeleted: 0,
materialsSkipped: 0, materialsSkipped: 0,
errors: [message], errors: [message],
skippedMaterials: [] skippedMaterials: [],
retryCount: 0,
retryAttempts: [],
retriedAt: undefined,
retrySuccess: false
}) })
} }
} }
@@ -171,6 +183,36 @@ export class CleanerService {
materialsSkipped: result.materialsSkipped, materialsSkipped: result.materialsSkipped,
errorCount: result.errors.length errorCount: result.errors.length
}) })
// Retry failed orders
const retryResult = await this.retryFailedOrders({
workFrame,
popupPage,
failedDetails: result.details.filter((d) => d.errors.length > 0),
deleteSet,
dryRun,
onProgress: input.onProgress
})
// Merge retry results
result.retriedOrders = retryResult.retriedOrders
result.successfulRetries = retryResult.successfulRetries
// Update details with retry information
retryResult.updatedDetails.forEach((updatedDetail) => {
const index = result.details.findIndex((d) => d.orderNumber === updatedDetail.orderNumber)
if (index !== -1) {
result.details[index] = updatedDetail
}
})
// Clear errors for successfully retried orders
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 ', ''))
)
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error' const message = error instanceof Error ? error.message : 'Unknown error'
log.error('Cleaner failed', { error: message }) log.error('Cleaner failed', { error: message })
@@ -265,7 +307,11 @@ export class CleanerService {
materialsDeleted: 0, materialsDeleted: 0,
materialsSkipped: 0, materialsSkipped: 0,
errors: [], errors: [],
skippedMaterials: [] skippedMaterials: [],
retryCount: 0,
retryAttempts: [],
retriedAt: undefined,
retrySuccess: false
} }
// Query the order (Python lines 187-189) // Query the order (Python lines 187-189)
@@ -531,4 +577,121 @@ export class CleanerService {
private delay(ms: number): Promise<void> { private delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms)) return new Promise((resolve) => setTimeout(resolve, ms))
} }
/**
* Retry failed orders from the initial execution
* Maximum 2 retry attempts per failed order
*/
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) {
return result
}
log.info('Starting retry for failed orders', { count: failedDetails.length })
const MAX_RETRIES = 2
for (const failedDetail of failedDetails) {
const orderNumber = failedDetail.orderNumber
const retryAttempts: import('../../types/cleaner.types').RetryAttempt[] = []
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
log.info(`Retrying order ${orderNumber} (attempt ${attempt}/${MAX_RETRIES})`)
// Create a new detail for retry
const retryDetail: OrderCleanDetail = {
orderNumber,
materialsDeleted: 0,
materialsSkipped: 0,
errors: [],
skippedMaterials: [],
retryCount: attempt,
retryAttempts: [],
retriedAt: undefined,
retrySuccess: false
}
// Re-run the order processing
await this.processOrder({
workFrame,
popupPage,
orderNumber,
orderIndex: 0, // Not used for retry
totalOrders: failedDetails.length,
deleteSet,
dryRun,
onProgress: (message, progress, extra) => {
onProgress?.(
`[重试 ${attempt}/${MAX_RETRIES}] ${message}`,
progress,
extra ? { ...extra, phase: 'processing' as const } : undefined
)
}
})
// If we reach here, retry succeeded
result.successfulRetries++
log.info(`Retry succeeded for order ${orderNumber}`)
// Merge the successful retry detail
result.updatedDetails.push({
...retryDetail,
retriedAt: Date.now(),
retrySuccess: true
})
result.retriedOrders++
break // Exit retry loop for this order
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
log.warn(`Retry attempt ${attempt} failed for order ${orderNumber}: ${message}`)
retryAttempts.push({
attempt,
error: message,
timestamp: Date.now()
})
if (attempt === MAX_RETRIES) {
// All retries exhausted
log.error(`All retries failed for order ${orderNumber}`)
result.updatedDetails.push({
...failedDetail,
retryCount: MAX_RETRIES,
retryAttempts,
retriedAt: Date.now(),
retrySuccess: false
})
result.retriedOrders++
}
}
}
}
log.info('Retry process completed', {
retriedOrders: result.retriedOrders,
successfulRetries: result.successfulRetries
})
return result
}
} }

View File

@@ -89,6 +89,10 @@ export class CleanerReportGenerator {
lines.push(`| **删除物料数** | \`${result.materialsDeleted}\``) lines.push(`| **删除物料数** | \`${result.materialsDeleted}\``)
lines.push(`| **跳过物料数** | \`${result.materialsSkipped}\``) lines.push(`| **跳过物料数** | \`${result.materialsSkipped}\``)
lines.push(`| **错误数量** | \`${result.errors.length}\``) lines.push(`| **错误数量** | \`${result.errors.length}\``)
if (result.retriedOrders > 0) {
lines.push(`| **重试订单数** | \`${result.retriedOrders}\``)
lines.push(`| **成功重试数** | \`${result.successfulRetries}\``)
}
lines.push(`| **执行耗时** | \`${this.formatDuration(options.startTime, options.endTime)}\``) lines.push(`| **执行耗时** | \`${this.formatDuration(options.startTime, options.endTime)}\``)
lines.push('') lines.push('')
lines.push('---') lines.push('---')
@@ -100,6 +104,12 @@ export class CleanerReportGenerator {
lines.push('| ----------- | ---- | ------ |') lines.push('| ----------- | ---- | ------ |')
lines.push(`| ✅ 成功订单 | ${stats.successCount} | ${stats.successRate.toFixed(1)}% |`) lines.push(`| ✅ 成功订单 | ${stats.successCount} | ${stats.successRate.toFixed(1)}% |`)
lines.push(`| ❌ 失败订单 | ${stats.failureCount} | ${(100 - stats.successRate).toFixed(1)}% |`) lines.push(`| ❌ 失败订单 | ${stats.failureCount} | ${(100 - stats.successRate).toFixed(1)}% |`)
if (result.retriedOrders > 0) {
const retrySuccessRate =
result.retriedOrders > 0 ? (result.successfulRetries / result.retriedOrders) * 100 : 0
lines.push(`| 🔄 重试订单 | ${result.retriedOrders} | 100% |`)
lines.push(`| ✅ 成功重试 | ${result.successfulRetries} | ${retrySuccessRate.toFixed(1)}% |`)
}
lines.push('') lines.push('')
lines.push('---') lines.push('---')
lines.push('') lines.push('')
@@ -111,10 +121,19 @@ export class CleanerReportGenerator {
result.details.forEach((detail, index) => { result.details.forEach((detail, index) => {
const orderNum = index + 1 const orderNum = index + 1
const status = detail.errors.length > 0 ? '❌ 失败' : '✅ 成功' let status = detail.errors.length > 0 ? '❌ 失败' : '✅ 成功'
// Override status if retry was successful
if (detail.retrySuccess) {
status = '✅ 重试成功'
} else if (detail.retryCount > 0 && !detail.retrySuccess) {
status = '❌ 重试失败'
}
const errorMsg = detail.errors.length > 0 ? detail.errors[0] : '-' const errorMsg = detail.errors.length > 0 ? detail.errors[0] : '-'
const retryInfo = detail.retryCount > 0 ? ` [重试${detail.retryCount}次]` : ''
lines.push( lines.push(
`| ${orderNum} | \`${detail.orderNumber}\` | ${detail.materialsDeleted} | ${detail.materialsSkipped} | ${status} | \`${errorMsg}\` |` `| ${orderNum} | \`${detail.orderNumber}\` | ${detail.materialsDeleted} | ${detail.materialsSkipped} | ${status}${retryInfo} | \`${errorMsg}\` |`
) )
}) })
@@ -175,6 +194,62 @@ export class CleanerReportGenerator {
lines.push('') lines.push('')
} }
// Add retry details section
if (result.retriedOrders > 0) {
lines.push('## 重试执行详情')
lines.push('')
lines.push(
`**重试订单总数**: \`${result.retriedOrders}\` | **成功**: \`${result.successfulRetries}\` | **失败**: \`${result.retriedOrders - result.successfulRetries}\``
)
lines.push('')
const retriedDetails = result.details.filter((d) => d.retryCount > 0)
if (retriedDetails.length > 0) {
lines.push('### 重试订单列表')
lines.push('')
lines.push('| 订单号 | 重试次数 | 重试结果 | 重试时间 |')
lines.push('| -------- | -------- | -------- | ------------ |')
retriedDetails.forEach((detail) => {
const retryStatus = detail.retrySuccess ? '✅ 成功' : '❌ 失败'
const retryTime = detail.retriedAt ? this.formatDateTime(detail.retriedAt) : '-'
lines.push(
`| \`${detail.orderNumber}\` | ${detail.retryCount} | ${retryStatus} | ${retryTime} |`
)
})
lines.push('')
lines.push('### 重试尝试详细记录')
lines.push('')
retriedDetails.forEach((detail) => {
lines.push(`#### \`${detail.orderNumber}\``)
lines.push('')
lines.push(`- **重试次数**: ${detail.retryCount}`)
lines.push(`- **最终结果**: ${detail.retrySuccess ? '✅ 成功' : '❌ 失败'}`)
if (detail.retryAttempts && detail.retryAttempts.length > 0) {
lines.push('')
lines.push('**重试尝试记录**:')
lines.push('')
detail.retryAttempts.forEach((attempt, idx) => {
lines.push(
`${idx + 1}. **第${attempt.attempt}次尝试** - ${this.formatDateTime(attempt.timestamp)}`
)
lines.push(` - 错误:${attempt.error}`)
})
lines.push('')
}
lines.push('---')
lines.push('')
})
}
lines.push('')
}
lines.push(`**报告生成时间**: \`${this.formatDateTime(options.endTime)}\``) lines.push(`**报告生成时间**: \`${this.formatDateTime(options.endTime)}\``)
lines.push('**报表版本**: `v1.0`') lines.push('**报表版本**: `v1.0`')

View File

@@ -25,6 +25,9 @@ export interface CleanerResult {
materialsSkipped: number materialsSkipped: number
errors: string[] errors: string[]
details: OrderCleanDetail[] details: OrderCleanDetail[]
// Retry statistics
retriedOrders: number
successfulRetries: number
} }
export interface SkippedMaterial { export interface SkippedMaterial {
@@ -34,12 +37,23 @@ export interface SkippedMaterial {
reason: string reason: string
} }
export interface RetryAttempt {
attempt: number
error: string
timestamp: number
}
export interface OrderCleanDetail { export interface OrderCleanDetail {
orderNumber: string orderNumber: string
materialsDeleted: number materialsDeleted: number
materialsSkipped: number materialsSkipped: number
errors: string[] errors: string[]
skippedMaterials: SkippedMaterial[] skippedMaterials: SkippedMaterial[]
// Retry-related fields
retryCount: number
retryAttempts?: RetryAttempt[]
retriedAt?: number
retrySuccess?: boolean
} }
/** /**

View File

@@ -34,6 +34,9 @@ interface ExecutionReportDialogProps {
progress?: CleanerProgress | null progress?: CleanerProgress | null
startTime?: number | null startTime?: number | null
triggerRef?: React.RefObject<HTMLElement | null> triggerRef?: React.RefObject<HTMLElement | null>
// Retry-related props
retriedOrders?: number
successfulRetries?: number
} }
export const ExecutionReportDialog: React.FC<ExecutionReportDialogProps> = ({ export const ExecutionReportDialog: React.FC<ExecutionReportDialogProps> = ({
@@ -47,11 +50,14 @@ export const ExecutionReportDialog: React.FC<ExecutionReportDialogProps> = ({
isExecuting = false, isExecuting = false,
progress = null, progress = null,
startTime = null, startTime = null,
triggerRef triggerRef,
retriedOrders = 0,
successfulRetries = 0
}) => { }) => {
const [now, setNow] = React.useState(() => Date.now()) const [now, setNow] = React.useState(() => Date.now())
const hasErrors = errors.length > 0 const hasErrors = errors.length > 0
const hasRetries = retriedOrders > 0
const showProgress = isExecuting && progress const showProgress = isExecuting && progress
const isProgressing = !!showProgress const isProgressing = !!showProgress
@@ -259,6 +265,46 @@ export const ExecutionReportDialog: React.FC<ExecutionReportDialogProps> = ({
</div> </div>
</div> </div>
)} )}
{hasRetries && (
<>
<div className="bg-gray-50 rounded-lg p-3 flex items-center gap-3 border border-gray-200">
<div className="w-9 h-9 rounded-lg bg-purple-50 flex items-center justify-center flex-shrink-0">
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-purple-600"
>
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
<path d="M16 21h5v-5" />
</svg>
</div>
<div className="flex-1 min-w-0">
<div className="text-xs text-gray-600"></div>
<div className="text-xl font-semibold text-gray-900">{retriedOrders}</div>
</div>
</div>
<div className="bg-gray-50 rounded-lg p-3 flex items-center gap-3 border border-gray-200">
<div className="w-9 h-9 rounded-lg bg-emerald-50 flex items-center justify-center flex-shrink-0">
<CheckCircle size={20} className="text-emerald-600" />
</div>
<div className="flex-1 min-w-0">
<div className="text-xs text-gray-600"></div>
<div className="text-xl font-semibold text-gray-900">{successfulRetries}</div>
</div>
</div>
</>
)}
</div> </div>
{hasErrors && ( {hasErrors && (

View File

@@ -107,11 +107,7 @@ export function ConfirmDialog({
<Button variant="secondary" onClick={onCancel}> <Button variant="secondary" onClick={onCancel}>
{cancelText} {cancelText}
</Button> </Button>
<Button <Button data-autofocus="true" variant={styles.buttonVariant} onClick={onConfirm}>
data-autofocus="true"
variant={styles.buttonVariant}
onClick={onConfirm}
>
{confirmText} {confirmText}
</Button> </Button>
</div> </div>
@@ -125,14 +121,14 @@ export function ConfirmDialog({
*/ */
export function useConfirmDialog() { export function useConfirmDialog() {
const [config, setConfig] = useState< const [config, setConfig] = useState<
(Omit<ConfirmDialogProps, 'isOpen' | 'onConfirm' | 'onCancel'> & { | (Omit<ConfirmDialogProps, 'isOpen' | 'onConfirm' | 'onCancel'> & {
resolve: (value: boolean) => void resolve: (value: boolean) => void
}) | null>(null) })
| null
>(null)
const confirm = useCallback( const confirm = useCallback(
( (options: Omit<ConfirmDialogProps, 'isOpen' | 'onConfirm' | 'onCancel'>): Promise<boolean> => {
options: Omit<ConfirmDialogProps, 'isOpen' | 'onConfirm' | 'onCancel'>
): Promise<boolean> => {
return new Promise<boolean>((resolve) => { return new Promise<boolean>((resolve) => {
setConfig({ setConfig({
...options, ...options,

View File

@@ -65,6 +65,8 @@ export function useCleaner() {
materialsDeleted: number materialsDeleted: number
materialsSkipped: number materialsSkipped: number
errors: string[] errors: string[]
retriedOrders?: number
successfulRetries?: number
} | null>(null) } | null>(null)
// Progress state // Progress state
@@ -426,7 +428,9 @@ export function useCleaner() {
ordersProcessed: cleanerRunData.ordersProcessed, ordersProcessed: cleanerRunData.ordersProcessed,
materialsDeleted: cleanerRunData.materialsDeleted, materialsDeleted: cleanerRunData.materialsDeleted,
materialsSkipped: cleanerRunData.materialsSkipped, materialsSkipped: cleanerRunData.materialsSkipped,
errors: cleanerRunData.errors errors: cleanerRunData.errors,
retriedOrders: cleanerRunData.retriedOrders,
successfulRetries: cleanerRunData.successfulRetries
}) })
} else { } else {
throw new Error(response.error || '清理失败') throw new Error(response.error || '清理失败')

View File

@@ -239,7 +239,9 @@ export function useDialogFocus(options: UseDialogFocusOptions): UseDialogFocusRe
if (style.visibility === 'hidden') { if (style.visibility === 'hidden') {
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
console.warn('[useDialogFocus] Trigger element is visibility: hidden, cannot restore focus') console.warn(
'[useDialogFocus] Trigger element is visibility: hidden, cannot restore focus'
)
} }
return return
} }

View File

@@ -496,6 +496,8 @@ const CleanerPage: React.FC = () => {
progress={progress} progress={progress}
startTime={startTime} startTime={startTime}
triggerRef={executeButtonRef} triggerRef={executeButtonRef}
retriedOrders={reportData?.retriedOrders}
successfulRetries={reportData?.successfulRetries}
/> />
{/* Confirmation Dialog */} {/* Confirmation Dialog */}