feat(cleaner): add multi-signal deletion verification with material-level retry

Replace fragile single-signal (row change only) deletion verification
with a robust multi-signal approach using row change + material count +
ERP message detection. Add material-level retry (up to 3 attempts) for
transient failures, with detailed tracking of failed/uncertain deletions.

- Add DeletionOutcome/DeletionErrorCategory enums and FailedMaterial type
- Add deleteWithVerification() core method with retry logic
- Add evaluateDeletionSignals() pure logic (unit tested, 9 cases)
- Add helper methods: readMaterialCount, checkErpMessages, handleConfirmDialog
- Extend CleanerResult/OrderCleanDetail with failed/uncertain tracking
- Update report generator with failed materials detail section
- Update ExecutionReportDialog to display failed/uncertain stats

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Misaka
2026-04-07 21:55:47 +08:00
parent b065e23306
commit 8b173890fa
9 changed files with 647 additions and 48 deletions

View File

@@ -77,11 +77,11 @@ flowchart TB
**差异总结**:
| 维度 | Admin | User |
|------|-------|------|
| 侧边栏 | 有 CleanerSidebar | 无 |
| 管理员列表 | 查询全部负责人 | 不查询 |
| 默认选中 | 所有负责人 | 仅自己 |
| 维度 | Admin | User |
| ---------- | ----------------- | ------ |
| 侧边栏 | 有 CleanerSidebar | 无 |
| 管理员列表 | 查询全部负责人 | 不查询 |
| 默认选中 | 所有负责人 | 仅自己 |
---
@@ -119,11 +119,11 @@ flowchart TB
**匹配优先级说明**:
| 优先级 | 数据源 | 匹配方式 | 适用角色 |
|--------|--------|----------|----------|
| 1最高 | `MaterialsToBeDeleted` | MaterialCode 精确匹配 | 全部 |
| 2 | `MaterialsTypeToBeDeleted` | MaterialName 包含匹配 | 全部 |
| 3User 覆盖) | 当前用户的类型关键词 | MaterialName 包含匹配 | 仅 User |
| 优先级 | 数据源 | 匹配方式 | 适用角色 |
| -------------- | -------------------------- | --------------------- | -------- |
| 1最高 | `MaterialsToBeDeleted` | MaterialCode 精确匹配 | 全部 |
| 2 | `MaterialsTypeToBeDeleted` | MaterialName 包含匹配 | 全部 |
| 3User 覆盖) | 当前用户的类型关键词 | MaterialName 包含匹配 | 仅 User |
> **优先级 3 的作用**:当某个物料按优先级 2 被分配给其他负责人,但当前 User 有匹配的类型关键词时,会强制覆盖为自己的。这确保 User 不会为他人操作物料。
@@ -185,17 +185,18 @@ const resultsToProcess = isAdmin ? validationResults : filteredResults
**差异总结**:
| 维度 | Admin | User |
|------|-------|------|
| 处理范围 | `validationResults`(全部) | `filteredResults`(自己的+无负责人的) |
| 可操作物料 | 所有负责人的物料 | 仅自己的 + 无负责人的 |
| 能否修改他人数据 | 是 | 否 |
| 维度 | Admin | User |
| ---------------- | --------------------------- | -------------------------------------- |
| 处理范围 | `validationResults`(全部) | `filteredResults`(自己的+无负责人的) |
| 可操作物料 | 所有负责人的物料 | 仅自己的 + 无负责人的 |
| 能否修改他人数据 | 是 | 否 |
---
## 阶段三执行清理ERP 删除)
**源码位置**:
- 前端调用: `src/renderer/src/hooks/cleaner/api.ts:116-166`
- 获取数据: `src/main/services/validation/validation-application-service.ts:497-655`
- 执行删除: `src/main/services/cleaner/cleaner-application-service.ts`
@@ -257,12 +258,12 @@ flowchart TB
**差异总结**:
| 维度 | Admin有 selectedManagers | Admin无 selectedManagers | User |
|------|---------------------------|----------------------------|------|
| 数据源 | `MaterialsToBeDeleted` | `DiscreteMaterialPlanData` | `MaterialsToBeDeleted` |
| 查询条件 | `WHERE ManagerName IN (...)` | `WHERE SourceNumber IN (orderNumbers)` | `WHERE ManagerName = @username` |
| 可删除物料 | 选中负责人的物料 | 订单关联的全部物料 | 仅自己标记的物料 |
| 无订单号时 | — | 返回空数组 | — |
| 维度 | Admin有 selectedManagers | Admin无 selectedManagers | User |
| ---------- | ---------------------------- | -------------------------------------- | ------------------------------- |
| 数据源 | `MaterialsToBeDeleted` | `DiscreteMaterialPlanData` | `MaterialsToBeDeleted` |
| 查询条件 | `WHERE ManagerName IN (...)` | `WHERE SourceNumber IN (orderNumbers)` | `WHERE ManagerName = @username` |
| 可删除物料 | 选中负责人的物料 | 订单关联的全部物料 | 仅自己标记的物料 |
| 无订单号时 | — | 返回空数组 | — |
---
@@ -295,14 +296,14 @@ flowchart TB
## 涉及文件索引
| 文件 | 关键函数/逻辑 | 行号 |
|------|---------------|------|
| `src/renderer/src/hooks/cleaner/api.ts` | `initializeCleanerPage()`, `runCleanerExecution()` | 25-52, 116-166 |
| `src/renderer/src/hooks/useCleaner.ts` | `handleConfirmDeletion()`, 初始化逻辑 | 98-120, 289-345 |
| `src/renderer/src/hooks/cleaner/helpers.ts` | `filterValidationResults()`, `buildDeletionPlan()` | 34-57, 59-92 |
| 文件 | 关键函数/逻辑 | 行号 |
| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------- |
| `src/renderer/src/hooks/cleaner/api.ts` | `initializeCleanerPage()`, `runCleanerExecution()` | 25-52, 116-166 |
| `src/renderer/src/hooks/useCleaner.ts` | `handleConfirmDeletion()`, 初始化逻辑 | 98-120, 289-345 |
| `src/renderer/src/hooks/cleaner/helpers.ts` | `filterValidationResults()`, `buildDeletionPlan()` | 34-57, 59-92 |
| `src/main/services/validation/validation-application-service.ts` | `getCleanerData()`, `loadMaterialCodesForCleaner()`, `queryMaterialCodesByManagers()` | 232-305, 497-604, 606-655 |
| `src/main/services/cleaner/cleaner-application-service.ts` | `runCleaner()` | 31-168 |
| `src/main/ipc/cleaner-handler.ts` | `CLEANER_RUN` handler | 16-22 |
| `src/main/ipc/validation-handler.ts` | `getCleanerData` handler | 194-223 |
| `src/preload/api/validation.ts` | `getCleanerData()` IPC 桥接 | 11-12 |
| `src/renderer/src/pages/CleanerPage.tsx` | 页面组件,条件渲染侧边栏 | 74-82 |
| `src/main/services/cleaner/cleaner-application-service.ts` | `runCleaner()` | 31-168 |
| `src/main/ipc/cleaner-handler.ts` | `CLEANER_RUN` handler | 16-22 |
| `src/main/ipc/validation-handler.ts` | `getCleanerData` handler | 194-223 |
| `src/preload/api/validation.ts` | `getCleanerData()` IPC 桥接 | 11-12 |
| `src/renderer/src/pages/CleanerPage.tsx` | 页面组件,条件渲染侧边栏 | 74-82 |

View File

@@ -1,6 +1,12 @@
import { ERP_LOCATORS } from './locators'
import { ErpAuthService } from './erp-auth'
import type { CleanerInput, CleanerResult, OrderCleanDetail } from '../../types/cleaner.types'
import { DeletionErrorCategory, DeletionOutcome } from '../../types/cleaner.types'
import type {
CleanerInput,
CleanerResult,
MaterialDeletionAttempt,
OrderCleanDetail
} from '../../types/cleaner.types'
import type { ErpSession } from '../../types/erp.types'
import type { FrameLocator, Locator, Page } from 'playwright'
import { createLogger, run, trackDuration } from '../logger'
@@ -190,7 +196,9 @@ export class CleanerService {
errors: [],
details: [],
retriedOrders: 0,
successfulRetries: 0
successfulRetries: 0,
materialsFailed: 0,
uncertainDeletions: 0
}
const totalOrders = input.orderNumbers.length
@@ -291,6 +299,8 @@ export class CleanerService {
result.ordersProcessed += 1
result.materialsDeleted += detail.materialsDeleted
result.materialsSkipped += detail.materialsSkipped
result.materialsFailed += detail.materialsFailed
result.uncertainDeletions += detail.uncertainDeletions
})
const missingOrders = getMissingOrders(batchOrders, queriedOrderNumbersInBatch)
@@ -337,6 +347,8 @@ export class CleanerService {
result.ordersProcessed += 1
result.materialsDeleted += updatedDetail.materialsDeleted
result.materialsSkipped += updatedDetail.materialsSkipped
result.materialsFailed += updatedDetail.materialsFailed
result.uncertainDeletions += updatedDetail.uncertainDeletions
}
result.details[index] = updatedDetail
}
@@ -353,6 +365,8 @@ export class CleanerService {
ordersProcessed: result.ordersProcessed,
materialsDeleted: result.materialsDeleted,
materialsSkipped: result.materialsSkipped,
materialsFailed: result.materialsFailed,
uncertainDeletions: result.uncertainDeletions,
errorCount: result.errors.length,
totalOrders,
totalMaterials,
@@ -746,7 +760,10 @@ export class CleanerService {
retryCount: 0,
retryAttempts: [],
retriedAt: undefined,
retrySuccess: false
retrySuccess: false,
materialsFailed: 0,
failedMaterials: [],
uncertainDeletions: 0
}
// Step 5: Get material counts and status
@@ -879,33 +896,61 @@ export class CleanerService {
})
if (shouldDelete && !dryRun) {
log.info('[物料操作] 执行删除操作', {
log.info('[物料操作] 执行删除操作(多信号验证)', {
orderNumber,
materialIdx,
materialCode,
materialName,
rowNumber: currentRow,
materialCount: detailCount,
dryRun: false
})
const oldRowNumber = currentRow
await deleteRowBtn.click()
const deleteSuccess = await this.waitForRowChange(childForm, oldRowNumber, 10000)
const currentMaterialCount = await this.readMaterialCount(detailInnerFrame)
const deleteResult = await this.deleteWithVerification({
childForm,
detailInnerFrame,
deleteRowBtn,
materialCode,
materialName,
currentRowNumber: currentRow,
materialCountBefore: currentMaterialCount ?? detailCount
})
const deleteElapsed = Date.now() - materialStartTime
if (deleteSuccess) {
if (
deleteResult.outcome === DeletionOutcome.Success ||
deleteResult.outcome === DeletionOutcome.Uncertain
) {
detail.materialsDeleted += 1
log.info('[物料操作完成] 物料已成功删除', {
if (deleteResult.outcome === DeletionOutcome.Uncertain) {
detail.uncertainDeletions += 1
}
log.info('[物料操作完成] 物料删除结果', {
orderNumber,
materialCode,
rowNumber: currentRow,
outcome: deleteResult.outcome,
attempts: deleteResult.attempts.length,
elapsedMs: deleteElapsed
})
} else {
log.warn('[物料操作警告] 删除操作后行号未改变', {
detail.materialsFailed += 1
detail.failedMaterials.push({
materialCode,
materialName,
rowNumber: rowNumInt,
attempts: deleteResult.attempts,
finalOutcome: deleteResult.outcome,
finalErrorCategory: deleteResult.errorCategory
})
log.error('[物料操作失败] 物料删除失败', {
orderNumber,
materialCode,
rowNumber: currentRow,
outcome: deleteResult.outcome,
errorCategory: deleteResult.errorCategory,
errorMessage: deleteResult.errorMessage,
attempts: deleteResult.attempts.length,
elapsedMs: deleteElapsed
})
}
@@ -1063,7 +1108,10 @@ export class CleanerService {
retryCount: 0,
retryAttempts: [],
retriedAt: undefined,
retrySuccess: false
retrySuccess: false,
materialsFailed: 0,
failedMaterials: [],
uncertainDeletions: 0
}
}
@@ -1105,6 +1153,286 @@ export class CleanerService {
}
}
// --- Deletion verification constants ---
private static readonly MATERIAL_RETRY_MAX_ATTEMPTS = 3
private static readonly MATERIAL_RETRY_DELAY_MS = 1000
private static readonly VERIFICATION_TIMEOUT_MS = 8000
private static readonly ERROR_CHECK_WINDOW_MS = 2000
/**
* Read current material count from the "详细信息 (N)" text.
*/
private async readMaterialCount(
detailInnerFrame: FrameLocator | Locator
): Promise<number | null> {
try {
const text = await detailInnerFrame.getByText(/^详细信息 \(\d+\)$/).innerText()
const match = text.match(/\((\d+)\)/)
return match ? parseInt(match[1], 10) : null
} catch {
return null
}
}
/**
* Check for ERP error messages or confirm dialogs.
*/
private async checkErpMessages(
container: FrameLocator | Locator | Page
): Promise<{ hasError: boolean; errorText?: string; hasConfirmDialog: boolean }> {
try {
const errorLocator = container.locator(ERP_LOCATORS.common.errorMessage)
const hasError = await errorLocator.isVisible().catch(() => false)
let errorText: string | undefined
if (hasError) {
errorText = (await errorLocator.textContent().catch(() => undefined)) ?? undefined
}
const confirmLocator = container.locator(ERP_LOCATORS.common.confirmDialog)
const hasConfirmDialog = await confirmLocator.isVisible().catch(() => false)
return { hasError, errorText, hasConfirmDialog }
} catch {
return { hasError: false, hasConfirmDialog: false }
}
}
/**
* Auto-handle a confirm dialog by clicking the confirm button.
*/
private async handleConfirmDialog(container: FrameLocator | Locator | Page): Promise<void> {
try {
const confirmBtn = container.locator(ERP_LOCATORS.common.confirmButton).first()
if (await confirmBtn.isVisible().catch(() => false)) {
await confirmBtn.click()
log.debug('[确认对话框] 已自动点击确定')
await this.delay(500)
}
} catch {
log.warn('[确认对话框] 处理确认对话框失败')
}
}
/**
* 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
} {
const { rowChanged, countDecreased, hasError, errorText } = params
if (hasError) {
return {
outcome: DeletionOutcome.FailedErpError,
errorCategory: DeletionErrorCategory.ErpRejection,
errorMessage: errorText || 'ERP returned an error'
}
}
if (rowChanged && countDecreased !== false) {
// countDecreased === true or null (unreadable) — both acceptable
return { outcome: DeletionOutcome.Success }
}
if (rowChanged && countDecreased === false) {
return { outcome: DeletionOutcome.Uncertain }
}
if (!rowChanged && countDecreased === true) {
return { outcome: DeletionOutcome.Success }
}
// Neither row changed nor count decreased
return {
outcome: DeletionOutcome.FailedNoChange,
errorCategory: DeletionErrorCategory.Unknown
}
}
/**
* Core method: delete a single material with multi-signal verification and retry.
*/
private async deleteWithVerification(params: {
childForm: FrameLocator | Locator
detailInnerFrame: FrameLocator | Locator
deleteRowBtn: Locator
materialCode: string
materialName: string
currentRowNumber: string
materialCountBefore: number
maxAttempts?: number
attemptDelayMs?: number
}): Promise<{
outcome: DeletionOutcome
errorCategory?: DeletionErrorCategory
errorMessage?: string
attempts: MaterialDeletionAttempt[]
}> {
const {
childForm,
detailInnerFrame,
deleteRowBtn,
materialCode,
materialName,
currentRowNumber,
materialCountBefore,
maxAttempts = CleanerService.MATERIAL_RETRY_MAX_ATTEMPTS,
attemptDelayMs = CleanerService.MATERIAL_RETRY_DELAY_MS
} = params
const attempts: MaterialDeletionAttempt[] = []
for (let attemptNum = 1; attemptNum <= maxAttempts; attemptNum++) {
const attemptStart = Date.now()
// Check button state before clicking
const btnEnabled = await this.isButtonEnabled(deleteRowBtn)
if (!btnEnabled) {
attempts.push({
attempt: attemptNum,
outcome: DeletionOutcome.FailedButtonDisabled,
errorCategory: DeletionErrorCategory.UiUnexpected,
errorMessage: '删行按钮不可用',
rowNumberBefore: currentRowNumber,
rowNumberAfter: currentRowNumber,
materialCountBefore,
materialCountAfter: materialCountBefore,
timestamp: attemptStart,
durationMs: Date.now() - attemptStart
})
return {
outcome: DeletionOutcome.FailedButtonDisabled,
errorCategory: DeletionErrorCategory.UiUnexpected,
errorMessage: '删行按钮不可用',
attempts
}
}
// Click delete button
await deleteRowBtn.click()
// Check for ERP messages (error / confirm dialog)
const msgCheck = await this.checkErpMessages(detailInnerFrame)
if (msgCheck.hasConfirmDialog) {
await this.handleConfirmDialog(detailInnerFrame)
}
if (msgCheck.hasError) {
const attempt: MaterialDeletionAttempt = {
attempt: attemptNum,
outcome: DeletionOutcome.FailedErpError,
errorCategory: DeletionErrorCategory.ErpRejection,
errorMessage: msgCheck.errorText,
rowNumberBefore: currentRowNumber,
rowNumberAfter: currentRowNumber,
materialCountBefore,
materialCountAfter: materialCountBefore,
timestamp: attemptStart,
durationMs: Date.now() - attemptStart
}
attempts.push(attempt)
// No retry for erp_rejection
return {
outcome: DeletionOutcome.FailedErpError,
errorCategory: DeletionErrorCategory.ErpRejection,
errorMessage: msgCheck.errorText,
attempts
}
}
// Wait and poll for verification signals
const verificationStart = Date.now()
const timeout = CleanerService.VERIFICATION_TIMEOUT_MS
let rowNumberAfter = currentRowNumber
let materialCountAfter: number | null = materialCountBefore
while (Date.now() - verificationStart < timeout) {
rowNumberAfter = await this.getInputValue(childForm, /^行号$/)
materialCountAfter = await this.readMaterialCount(detailInnerFrame)
const rowChanged = rowNumberAfter !== currentRowNumber
const countDecreased =
materialCountAfter !== null ? materialCountAfter < materialCountBefore : null
if (rowChanged || countDecreased === true) {
break
}
await this.delay(300)
}
// Final read if we didn't update in the loop
if (rowNumberAfter === currentRowNumber && materialCountAfter === materialCountBefore) {
rowNumberAfter = await this.getInputValue(childForm, /^行号$/)
materialCountAfter = await this.readMaterialCount(detailInnerFrame)
}
const rowChanged = rowNumberAfter !== currentRowNumber
const countDecreased =
materialCountAfter !== null ? materialCountAfter < materialCountBefore : null
const evaluation = this.evaluateDeletionSignals({
rowChanged,
countDecreased,
hasError: false
})
const attempt: MaterialDeletionAttempt = {
attempt: attemptNum,
outcome: evaluation.outcome,
errorCategory: evaluation.errorCategory,
errorMessage: evaluation.errorMessage,
rowNumberBefore: currentRowNumber,
rowNumberAfter,
materialCountBefore,
materialCountAfter: materialCountAfter ?? materialCountBefore,
timestamp: attemptStart,
durationMs: Date.now() - attemptStart
}
attempts.push(attempt)
if (
evaluation.outcome === DeletionOutcome.Success ||
evaluation.outcome === DeletionOutcome.Uncertain
) {
return {
outcome: evaluation.outcome,
errorCategory: evaluation.errorCategory,
errorMessage: evaluation.errorMessage,
attempts
}
}
// Retryable failure — wait before next attempt
if (attemptNum < maxAttempts) {
log.info('[物料删除重试] 等待后重试', {
materialCode,
materialName,
attempt: attemptNum,
nextAttempt: attemptNum + 1,
delayMs: attemptDelayMs
})
await this.delay(attemptDelayMs)
}
}
// All attempts exhausted
return {
outcome: DeletionOutcome.FailedNoChange,
errorCategory: DeletionErrorCategory.VerificationTimeout,
errorMessage: `${maxAttempts} 次尝试后仍无法确认删除`,
attempts
}
}
private async waitForRowChange(
childForm: FrameLocator | Locator,
oldRowNumber: string,

View File

@@ -2,7 +2,12 @@ import path from 'path'
import fs from 'fs'
import { app } from 'electron'
import { createLogger } from '../logger'
import type { CleanerResult, OrderCleanDetail, SkippedMaterial } from '../../types/cleaner.types'
import type {
CleanerResult,
FailedMaterial,
OrderCleanDetail,
SkippedMaterial
} from '../../types/cleaner.types'
const log = createLogger('CleanerReportGenerator')
@@ -88,6 +93,12 @@ export class CleanerReportGenerator {
lines.push(`| **处理订单数** | \`${result.ordersProcessed}\``)
lines.push(`| **删除物料数** | \`${result.materialsDeleted}\``)
lines.push(`| **跳过物料数** | \`${result.materialsSkipped}\``)
if (result.materialsFailed > 0) {
lines.push(`| **删除失败物料数** | \`${result.materialsFailed}\``)
}
if (result.uncertainDeletions > 0) {
lines.push(`| **不确定删除数** | \`${result.uncertainDeletions}\``)
}
lines.push(`| **错误数量** | \`${result.errors.length}\``)
if (result.retriedOrders > 0) {
lines.push(`| **重试订单数** | \`${result.retriedOrders}\``)
@@ -159,6 +170,53 @@ export class CleanerReportGenerator {
lines.push('')
}
// Failed materials section
const allFailedMaterials = this.collectAllFailedMaterials(result.details)
if (allFailedMaterials.length > 0) {
lines.push('## 删除失败的物料详情')
lines.push('')
lines.push(`**失败物料总数**: \`${allFailedMaterials.length}\``)
lines.push('')
lines.push(
'| 订单号 | 物料代码 | 物料名称 | 行号 | 最终结果 | 失败原因类别 | 尝试次数 |'
)
lines.push(
'| -------- | -------- | -------- | ---- | -------------- | ------------------ | -------- |'
)
allFailedMaterials.forEach((failed) => {
lines.push(
`| \`${failed.orderNumber}\` | \`${failed.materialCode}\` | \`${failed.materialName}\` | ${failed.rowNumber} | ${failed.finalOutcome} | ${failed.finalErrorCategory ?? '-'} | ${failed.attempts.length} |`
)
})
lines.push('')
// Detailed attempt records
lines.push('### 失败物料尝试记录')
lines.push('')
allFailedMaterials.forEach((failed) => {
lines.push(
`#### \`${failed.materialCode}\` (${failed.materialName}) — 订单 \`${failed.orderNumber}\``
)
lines.push('')
failed.attempts.forEach((attempt, idx) => {
lines.push(`${idx + 1}. **第${attempt.attempt}次尝试** - 结果: ${attempt.outcome}`)
if (attempt.errorMessage) {
lines.push(` - 错误: ${attempt.errorMessage}`)
}
lines.push(
` - 行号: ${attempt.rowNumberBefore}${attempt.rowNumberAfter} | 物料数: ${attempt.materialCountBefore}${attempt.materialCountAfter} | 耗时: ${attempt.durationMs}ms`
)
})
lines.push('')
})
lines.push('---')
lines.push('')
}
if (result.errors.length > 0) {
lines.push('## 错误详情')
lines.push('')
@@ -275,6 +333,25 @@ export class CleanerReportGenerator {
return result
}
private collectAllFailedMaterials(
details: OrderCleanDetail[]
): Array<FailedMaterial & { orderNumber: string }> {
const result: Array<FailedMaterial & { orderNumber: string }> = []
details.forEach((detail) => {
if (detail.failedMaterials && detail.failedMaterials.length > 0) {
detail.failedMaterials.forEach((failed) => {
result.push({
...failed,
orderNumber: detail.orderNumber
})
})
}
})
return result
}
private extractErrorOrders(details: OrderCleanDetail[]): string[] {
return details.filter((d) => d.errors.length > 0).map((d) => d.orderNumber)
}

View File

@@ -30,6 +30,9 @@ export interface CleanerResult {
// Retry statistics
retriedOrders: number
successfulRetries: number
// Deletion verification statistics
materialsFailed: number
uncertainDeletions: number
}
export interface SkippedMaterial {
@@ -56,6 +59,50 @@ export interface OrderCleanDetail {
retryAttempts?: RetryAttempt[]
retriedAt?: number
retrySuccess?: boolean
// Deletion verification fields
materialsFailed: number
failedMaterials: FailedMaterial[]
uncertainDeletions: number
}
export enum DeletionOutcome {
Success = 'success',
FailedErpError = 'failed_erp_error',
FailedNoChange = 'failed_no_change',
FailedTimeout = 'failed_timeout',
FailedButtonDisabled = 'failed_button_disabled',
Uncertain = 'uncertain'
}
export enum DeletionErrorCategory {
ErpRejection = 'erp_rejection',
ErpBusy = 'erp_busy',
NetworkLag = 'network_lag',
UiUnexpected = 'ui_unexpected',
VerificationTimeout = 'verification_timeout',
Unknown = 'unknown'
}
export interface MaterialDeletionAttempt {
attempt: number
outcome: DeletionOutcome
errorCategory?: DeletionErrorCategory
errorMessage?: string
rowNumberBefore: string
rowNumberAfter: string
materialCountBefore: number
materialCountAfter: number
timestamp: number
durationMs: number
}
export interface FailedMaterial {
materialCode: string
materialName: string
rowNumber: number
attempts: MaterialDeletionAttempt[]
finalOutcome: DeletionOutcome
finalErrorCategory?: DeletionErrorCategory
}
/**

View File

@@ -8,7 +8,7 @@
*/
import React from 'react'
import { CheckCircle, XCircle, SkipForward, Package, Loader2 } from 'lucide-react'
import { CheckCircle, XCircle, SkipForward, Package, Loader2, AlertTriangle } from 'lucide-react'
import { Modal } from './ui/Modal'
import type { CleanerProgress } from '../hooks/cleaner/types'
@@ -27,6 +27,9 @@ interface ExecutionReportDialogProps {
// Retry-related props
retriedOrders?: number
successfulRetries?: number
// Deletion verification props
materialsFailed?: number
uncertainDeletions?: number
}
export const ExecutionReportDialog: React.FC<ExecutionReportDialogProps> = ({
@@ -42,12 +45,15 @@ export const ExecutionReportDialog: React.FC<ExecutionReportDialogProps> = ({
startTime = null,
triggerRef,
retriedOrders = 0,
successfulRetries = 0
successfulRetries = 0,
materialsFailed = 0,
uncertainDeletions = 0
}) => {
const [now, setNow] = React.useState(() => Date.now())
const hasErrors = errors.length > 0
const hasRetries = retriedOrders > 0
const hasFailedMaterials = materialsFailed > 0 || uncertainDeletions > 0
const showProgress = isExecuting && progress
const isProgressing = !!showProgress
@@ -327,6 +333,36 @@ export const ExecutionReportDialog: React.FC<ExecutionReportDialogProps> = ({
</div>
</>
)}
{hasFailedMaterials && (
<>
{materialsFailed > 0 && (
<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-red-50 flex items-center justify-center flex-shrink-0">
<XCircle size={20} className="text-red-600" />
</div>
<div className="flex-1 min-w-0">
<div className="text-xs text-gray-600"></div>
<div className="text-xl font-semibold text-red-600">{materialsFailed}</div>
</div>
</div>
)}
{uncertainDeletions > 0 && (
<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-yellow-50 flex items-center justify-center flex-shrink-0">
<AlertTriangle size={20} className="text-yellow-600" />
</div>
<div className="flex-1 min-w-0">
<div className="text-xs text-gray-600"></div>
<div className="text-xl font-semibold text-yellow-600">
{uncertainDeletions}
</div>
</div>
</div>
)}
</>
)}
</div>
{elapsedTime && (

View File

@@ -20,6 +20,8 @@ interface CleanerRunPayload {
errors: string[]
retriedOrders: number
successfulRetries: number
materialsFailed?: number
uncertainDeletions?: number
}
export async function initializeCleanerPage(): Promise<CleanerInitializationResult> {
@@ -161,7 +163,9 @@ export async function runCleanerExecution(params: {
materialsSkipped: cleanerRunData.materialsSkipped,
errors: cleanerRunData.errors,
retriedOrders: cleanerRunData.retriedOrders,
successfulRetries: cleanerRunData.successfulRetries
successfulRetries: cleanerRunData.successfulRetries,
materialsFailed: cleanerRunData.materialsFailed ?? 0,
uncertainDeletions: cleanerRunData.uncertainDeletions ?? 0
}
}

View File

@@ -45,6 +45,8 @@ export interface CleanerReportData {
errors: string[]
retriedOrders?: number
successfulRetries?: number
materialsFailed?: number
uncertainDeletions?: number
}
export interface CleanerInitializationResult {

View File

@@ -162,6 +162,8 @@ const CleanerPage: React.FC = () => {
triggerRef={executeButtonRef}
retriedOrders={reportData?.retriedOrders}
successfulRetries={reportData?.successfulRetries}
materialsFailed={reportData?.materialsFailed}
uncertainDeletions={reportData?.uncertainDeletions}
/>
</Suspense>

View File

@@ -1,4 +1,5 @@
import { describe, it, expect } from 'vitest'
import { DeletionErrorCategory, DeletionOutcome } from '../../../../src/main/types/cleaner.types'
import {
CleanerService,
createBatches,
@@ -270,3 +271,104 @@ describe('CleanerService - Helper Methods', () => {
})
})
})
describe('CleanerService - evaluateDeletionSignals()', () => {
const cleaner = new CleanerService({} as any, { dryRun: false })
it('should return FailedErpError when error is detected', () => {
const result = cleaner.evaluateDeletionSignals({
rowChanged: false,
countDecreased: false,
hasError: true,
errorText: '物料已被引用'
})
expect(result.outcome).toBe(DeletionOutcome.FailedErpError)
expect(result.errorCategory).toBe(DeletionErrorCategory.ErpRejection)
expect(result.errorMessage).toBe('物料已被引用')
})
it('should return FailedErpError with default message when no errorText', () => {
const result = cleaner.evaluateDeletionSignals({
rowChanged: false,
countDecreased: false,
hasError: true
})
expect(result.outcome).toBe(DeletionOutcome.FailedErpError)
expect(result.errorMessage).toBe('ERP returned an error')
})
it('should return Success when row changed and count decreased (true)', () => {
const result = cleaner.evaluateDeletionSignals({
rowChanged: true,
countDecreased: true,
hasError: false
})
expect(result.outcome).toBe(DeletionOutcome.Success)
expect(result.errorCategory).toBeUndefined()
})
it('should return Success when row changed and count is unreadable (null)', () => {
const result = cleaner.evaluateDeletionSignals({
rowChanged: true,
countDecreased: null,
hasError: false
})
expect(result.outcome).toBe(DeletionOutcome.Success)
})
it('should return Uncertain when row changed but count did not decrease', () => {
const result = cleaner.evaluateDeletionSignals({
rowChanged: true,
countDecreased: false,
hasError: false
})
expect(result.outcome).toBe(DeletionOutcome.Uncertain)
})
it('should return Success when row did not change but count decreased', () => {
const result = cleaner.evaluateDeletionSignals({
rowChanged: false,
countDecreased: true,
hasError: false
})
expect(result.outcome).toBe(DeletionOutcome.Success)
})
it('should return FailedNoChange when nothing changed', () => {
const result = cleaner.evaluateDeletionSignals({
rowChanged: false,
countDecreased: false,
hasError: false
})
expect(result.outcome).toBe(DeletionOutcome.FailedNoChange)
expect(result.errorCategory).toBe(DeletionErrorCategory.Unknown)
})
it('should return FailedNoChange when row did not change and count is null', () => {
const result = cleaner.evaluateDeletionSignals({
rowChanged: false,
countDecreased: null,
hasError: false
})
expect(result.outcome).toBe(DeletionOutcome.FailedNoChange)
})
it('should prioritize error signal over all others', () => {
const result = cleaner.evaluateDeletionSignals({
rowChanged: true,
countDecreased: true,
hasError: true,
errorText: 'Some error'
})
expect(result.outcome).toBe(DeletionOutcome.FailedErpError)
})
})