From 8b173890fa6f4a5afdaf1f765b4491441d92fe11 Mon Sep 17 00:00:00 2001 From: Misaka Date: Tue, 7 Apr 2026 21:55:47 +0800 Subject: [PATCH] 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 --- docs/cleaner-role-based-flow.md | 63 ++-- src/main/services/erp/cleaner.ts | 354 +++++++++++++++++- .../report/cleaner-report-generator.ts | 79 +++- src/main/types/cleaner.types.ts | 47 +++ .../src/components/ExecutionReportDialog.tsx | 40 +- src/renderer/src/hooks/cleaner/api.ts | 6 +- src/renderer/src/hooks/cleaner/types.ts | 2 + src/renderer/src/pages/CleanerPage.tsx | 2 + tests/unit/services/erp/cleaner.test.ts | 102 +++++ 9 files changed, 647 insertions(+), 48 deletions(-) diff --git a/docs/cleaner-role-based-flow.md b/docs/cleaner-role-based-flow.md index 5a7c9a6..2b2eff2 100644 --- a/docs/cleaner-role-based-flow.md +++ b/docs/cleaner-role-based-flow.md @@ -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 | diff --git a/src/main/services/erp/cleaner.ts b/src/main/services/erp/cleaner.ts index 3c00700..dccb6f3 100644 --- a/src/main/services/erp/cleaner.ts +++ b/src/main/services/erp/cleaner.ts @@ -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 { + 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 { + 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, diff --git a/src/main/services/report/cleaner-report-generator.ts b/src/main/services/report/cleaner-report-generator.ts index 4c3e4d1..4b8f1a2 100644 --- a/src/main/services/report/cleaner-report-generator.ts +++ b/src/main/services/report/cleaner-report-generator.ts @@ -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 { + const result: Array = [] + + 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) } diff --git a/src/main/types/cleaner.types.ts b/src/main/types/cleaner.types.ts index 576bf5b..6d85394 100644 --- a/src/main/types/cleaner.types.ts +++ b/src/main/types/cleaner.types.ts @@ -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 } /** diff --git a/src/renderer/src/components/ExecutionReportDialog.tsx b/src/renderer/src/components/ExecutionReportDialog.tsx index 3798597..421ed2d 100644 --- a/src/renderer/src/components/ExecutionReportDialog.tsx +++ b/src/renderer/src/components/ExecutionReportDialog.tsx @@ -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 = ({ @@ -42,12 +45,15 @@ export const ExecutionReportDialog: React.FC = ({ 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 = ({ )} + + {hasFailedMaterials && ( + <> + {materialsFailed > 0 && ( +
+
+ +
+
+
删除失败
+
{materialsFailed}
+
+
+ )} + + {uncertainDeletions > 0 && ( +
+
+ +
+
+
不确定删除
+
+ {uncertainDeletions} +
+
+
+ )} + + )} {elapsedTime && ( diff --git a/src/renderer/src/hooks/cleaner/api.ts b/src/renderer/src/hooks/cleaner/api.ts index 5840423..28df043 100644 --- a/src/renderer/src/hooks/cleaner/api.ts +++ b/src/renderer/src/hooks/cleaner/api.ts @@ -20,6 +20,8 @@ interface CleanerRunPayload { errors: string[] retriedOrders: number successfulRetries: number + materialsFailed?: number + uncertainDeletions?: number } export async function initializeCleanerPage(): Promise { @@ -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 } } diff --git a/src/renderer/src/hooks/cleaner/types.ts b/src/renderer/src/hooks/cleaner/types.ts index 6cc8691..6039893 100644 --- a/src/renderer/src/hooks/cleaner/types.ts +++ b/src/renderer/src/hooks/cleaner/types.ts @@ -45,6 +45,8 @@ export interface CleanerReportData { errors: string[] retriedOrders?: number successfulRetries?: number + materialsFailed?: number + uncertainDeletions?: number } export interface CleanerInitializationResult { diff --git a/src/renderer/src/pages/CleanerPage.tsx b/src/renderer/src/pages/CleanerPage.tsx index 334181a..082eeff 100644 --- a/src/renderer/src/pages/CleanerPage.tsx +++ b/src/renderer/src/pages/CleanerPage.tsx @@ -162,6 +162,8 @@ const CleanerPage: React.FC = () => { triggerRef={executeButtonRef} retriedOrders={reportData?.retriedOrders} successfulRetries={reportData?.successfulRetries} + materialsFailed={reportData?.materialsFailed} + uncertainDeletions={reportData?.uncertainDeletions} /> diff --git a/tests/unit/services/erp/cleaner.test.ts b/tests/unit/services/erp/cleaner.test.ts index d320fa8..7a2200e 100644 --- a/tests/unit/services/erp/cleaner.test.ts +++ b/tests/unit/services/erp/cleaner.test.ts @@ -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) + }) +})