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:
@@ -77,11 +77,11 @@ flowchart TB
|
||||
|
||||
**差异总结**:
|
||||
|
||||
| 维度 | Admin | User |
|
||||
|------|-------|------|
|
||||
| 侧边栏 | 有 CleanerSidebar | 无 |
|
||||
| 管理员列表 | 查询全部负责人 | 不查询 |
|
||||
| 默认选中 | 所有负责人 | 仅自己 |
|
||||
| 维度 | Admin | User |
|
||||
| ---------- | ----------------- | ------ |
|
||||
| 侧边栏 | 有 CleanerSidebar | 无 |
|
||||
| 管理员列表 | 查询全部负责人 | 不查询 |
|
||||
| 默认选中 | 所有负责人 | 仅自己 |
|
||||
|
||||
---
|
||||
|
||||
@@ -119,11 +119,11 @@ flowchart TB
|
||||
|
||||
**匹配优先级说明**:
|
||||
|
||||
| 优先级 | 数据源 | 匹配方式 | 适用角色 |
|
||||
|--------|--------|----------|----------|
|
||||
| 1(最高) | `MaterialsToBeDeleted` | MaterialCode 精确匹配 | 全部 |
|
||||
| 2 | `MaterialsTypeToBeDeleted` | MaterialName 包含匹配 | 全部 |
|
||||
| 3(User 覆盖) | 当前用户的类型关键词 | MaterialName 包含匹配 | 仅 User |
|
||||
| 优先级 | 数据源 | 匹配方式 | 适用角色 |
|
||||
| -------------- | -------------------------- | --------------------- | -------- |
|
||||
| 1(最高) | `MaterialsToBeDeleted` | MaterialCode 精确匹配 | 全部 |
|
||||
| 2 | `MaterialsTypeToBeDeleted` | MaterialName 包含匹配 | 全部 |
|
||||
| 3(User 覆盖) | 当前用户的类型关键词 | 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 |
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,8 @@ export interface CleanerReportData {
|
||||
errors: string[]
|
||||
retriedOrders?: number
|
||||
successfulRetries?: number
|
||||
materialsFailed?: number
|
||||
uncertainDeletions?: number
|
||||
}
|
||||
|
||||
export interface CleanerInitializationResult {
|
||||
|
||||
@@ -162,6 +162,8 @@ const CleanerPage: React.FC = () => {
|
||||
triggerRef={executeButtonRef}
|
||||
retriedOrders={reportData?.retriedOrders}
|
||||
successfulRetries={reportData?.successfulRetries}
|
||||
materialsFailed={reportData?.materialsFailed}
|
||||
uncertainDeletions={reportData?.uncertainDeletions}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user