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:
@@ -78,7 +78,7 @@ flowchart TB
|
|||||||
**差异总结**:
|
**差异总结**:
|
||||||
|
|
||||||
| 维度 | Admin | User |
|
| 维度 | Admin | User |
|
||||||
|------|-------|------|
|
| ---------- | ----------------- | ------ |
|
||||||
| 侧边栏 | 有 CleanerSidebar | 无 |
|
| 侧边栏 | 有 CleanerSidebar | 无 |
|
||||||
| 管理员列表 | 查询全部负责人 | 不查询 |
|
| 管理员列表 | 查询全部负责人 | 不查询 |
|
||||||
| 默认选中 | 所有负责人 | 仅自己 |
|
| 默认选中 | 所有负责人 | 仅自己 |
|
||||||
@@ -120,7 +120,7 @@ flowchart TB
|
|||||||
**匹配优先级说明**:
|
**匹配优先级说明**:
|
||||||
|
|
||||||
| 优先级 | 数据源 | 匹配方式 | 适用角色 |
|
| 优先级 | 数据源 | 匹配方式 | 适用角色 |
|
||||||
|--------|--------|----------|----------|
|
| -------------- | -------------------------- | --------------------- | -------- |
|
||||||
| 1(最高) | `MaterialsToBeDeleted` | MaterialCode 精确匹配 | 全部 |
|
| 1(最高) | `MaterialsToBeDeleted` | MaterialCode 精确匹配 | 全部 |
|
||||||
| 2 | `MaterialsTypeToBeDeleted` | MaterialName 包含匹配 | 全部 |
|
| 2 | `MaterialsTypeToBeDeleted` | MaterialName 包含匹配 | 全部 |
|
||||||
| 3(User 覆盖) | 当前用户的类型关键词 | MaterialName 包含匹配 | 仅 User |
|
| 3(User 覆盖) | 当前用户的类型关键词 | MaterialName 包含匹配 | 仅 User |
|
||||||
@@ -186,7 +186,7 @@ const resultsToProcess = isAdmin ? validationResults : filteredResults
|
|||||||
**差异总结**:
|
**差异总结**:
|
||||||
|
|
||||||
| 维度 | Admin | User |
|
| 维度 | Admin | User |
|
||||||
|------|-------|------|
|
| ---------------- | --------------------------- | -------------------------------------- |
|
||||||
| 处理范围 | `validationResults`(全部) | `filteredResults`(自己的+无负责人的) |
|
| 处理范围 | `validationResults`(全部) | `filteredResults`(自己的+无负责人的) |
|
||||||
| 可操作物料 | 所有负责人的物料 | 仅自己的 + 无负责人的 |
|
| 可操作物料 | 所有负责人的物料 | 仅自己的 + 无负责人的 |
|
||||||
| 能否修改他人数据 | 是 | 否 |
|
| 能否修改他人数据 | 是 | 否 |
|
||||||
@@ -196,6 +196,7 @@ const resultsToProcess = isAdmin ? validationResults : filteredResults
|
|||||||
## 阶段三:执行清理(ERP 删除)
|
## 阶段三:执行清理(ERP 删除)
|
||||||
|
|
||||||
**源码位置**:
|
**源码位置**:
|
||||||
|
|
||||||
- 前端调用: `src/renderer/src/hooks/cleaner/api.ts:116-166`
|
- 前端调用: `src/renderer/src/hooks/cleaner/api.ts:116-166`
|
||||||
- 获取数据: `src/main/services/validation/validation-application-service.ts:497-655`
|
- 获取数据: `src/main/services/validation/validation-application-service.ts:497-655`
|
||||||
- 执行删除: `src/main/services/cleaner/cleaner-application-service.ts`
|
- 执行删除: `src/main/services/cleaner/cleaner-application-service.ts`
|
||||||
@@ -258,7 +259,7 @@ flowchart TB
|
|||||||
**差异总结**:
|
**差异总结**:
|
||||||
|
|
||||||
| 维度 | Admin(有 selectedManagers) | Admin(无 selectedManagers) | User |
|
| 维度 | Admin(有 selectedManagers) | Admin(无 selectedManagers) | User |
|
||||||
|------|---------------------------|----------------------------|------|
|
| ---------- | ---------------------------- | -------------------------------------- | ------------------------------- |
|
||||||
| 数据源 | `MaterialsToBeDeleted` | `DiscreteMaterialPlanData` | `MaterialsToBeDeleted` |
|
| 数据源 | `MaterialsToBeDeleted` | `DiscreteMaterialPlanData` | `MaterialsToBeDeleted` |
|
||||||
| 查询条件 | `WHERE ManagerName IN (...)` | `WHERE SourceNumber IN (orderNumbers)` | `WHERE ManagerName = @username` |
|
| 查询条件 | `WHERE ManagerName IN (...)` | `WHERE SourceNumber IN (orderNumbers)` | `WHERE ManagerName = @username` |
|
||||||
| 可删除物料 | 选中负责人的物料 | 订单关联的全部物料 | 仅自己标记的物料 |
|
| 可删除物料 | 选中负责人的物料 | 订单关联的全部物料 | 仅自己标记的物料 |
|
||||||
@@ -296,7 +297,7 @@ flowchart TB
|
|||||||
## 涉及文件索引
|
## 涉及文件索引
|
||||||
|
|
||||||
| 文件 | 关键函数/逻辑 | 行号 |
|
| 文件 | 关键函数/逻辑 | 行号 |
|
||||||
|------|---------------|------|
|
| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------- |
|
||||||
| `src/renderer/src/hooks/cleaner/api.ts` | `initializeCleanerPage()`, `runCleanerExecution()` | 25-52, 116-166 |
|
| `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/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/helpers.ts` | `filterValidationResults()`, `buildDeletionPlan()` | 34-57, 59-92 |
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { ERP_LOCATORS } from './locators'
|
import { ERP_LOCATORS } from './locators'
|
||||||
import { ErpAuthService } from './erp-auth'
|
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 { ErpSession } from '../../types/erp.types'
|
||||||
import type { FrameLocator, Locator, Page } from 'playwright'
|
import type { FrameLocator, Locator, Page } from 'playwright'
|
||||||
import { createLogger, run, trackDuration } from '../logger'
|
import { createLogger, run, trackDuration } from '../logger'
|
||||||
@@ -190,7 +196,9 @@ export class CleanerService {
|
|||||||
errors: [],
|
errors: [],
|
||||||
details: [],
|
details: [],
|
||||||
retriedOrders: 0,
|
retriedOrders: 0,
|
||||||
successfulRetries: 0
|
successfulRetries: 0,
|
||||||
|
materialsFailed: 0,
|
||||||
|
uncertainDeletions: 0
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalOrders = input.orderNumbers.length
|
const totalOrders = input.orderNumbers.length
|
||||||
@@ -291,6 +299,8 @@ export class CleanerService {
|
|||||||
result.ordersProcessed += 1
|
result.ordersProcessed += 1
|
||||||
result.materialsDeleted += detail.materialsDeleted
|
result.materialsDeleted += detail.materialsDeleted
|
||||||
result.materialsSkipped += detail.materialsSkipped
|
result.materialsSkipped += detail.materialsSkipped
|
||||||
|
result.materialsFailed += detail.materialsFailed
|
||||||
|
result.uncertainDeletions += detail.uncertainDeletions
|
||||||
})
|
})
|
||||||
|
|
||||||
const missingOrders = getMissingOrders(batchOrders, queriedOrderNumbersInBatch)
|
const missingOrders = getMissingOrders(batchOrders, queriedOrderNumbersInBatch)
|
||||||
@@ -337,6 +347,8 @@ export class CleanerService {
|
|||||||
result.ordersProcessed += 1
|
result.ordersProcessed += 1
|
||||||
result.materialsDeleted += updatedDetail.materialsDeleted
|
result.materialsDeleted += updatedDetail.materialsDeleted
|
||||||
result.materialsSkipped += updatedDetail.materialsSkipped
|
result.materialsSkipped += updatedDetail.materialsSkipped
|
||||||
|
result.materialsFailed += updatedDetail.materialsFailed
|
||||||
|
result.uncertainDeletions += updatedDetail.uncertainDeletions
|
||||||
}
|
}
|
||||||
result.details[index] = updatedDetail
|
result.details[index] = updatedDetail
|
||||||
}
|
}
|
||||||
@@ -353,6 +365,8 @@ export class CleanerService {
|
|||||||
ordersProcessed: result.ordersProcessed,
|
ordersProcessed: result.ordersProcessed,
|
||||||
materialsDeleted: result.materialsDeleted,
|
materialsDeleted: result.materialsDeleted,
|
||||||
materialsSkipped: result.materialsSkipped,
|
materialsSkipped: result.materialsSkipped,
|
||||||
|
materialsFailed: result.materialsFailed,
|
||||||
|
uncertainDeletions: result.uncertainDeletions,
|
||||||
errorCount: result.errors.length,
|
errorCount: result.errors.length,
|
||||||
totalOrders,
|
totalOrders,
|
||||||
totalMaterials,
|
totalMaterials,
|
||||||
@@ -746,7 +760,10 @@ export class CleanerService {
|
|||||||
retryCount: 0,
|
retryCount: 0,
|
||||||
retryAttempts: [],
|
retryAttempts: [],
|
||||||
retriedAt: undefined,
|
retriedAt: undefined,
|
||||||
retrySuccess: false
|
retrySuccess: false,
|
||||||
|
materialsFailed: 0,
|
||||||
|
failedMaterials: [],
|
||||||
|
uncertainDeletions: 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 5: Get material counts and status
|
// Step 5: Get material counts and status
|
||||||
@@ -879,33 +896,61 @@ export class CleanerService {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (shouldDelete && !dryRun) {
|
if (shouldDelete && !dryRun) {
|
||||||
log.info('[物料操作] 执行删除操作', {
|
log.info('[物料操作] 执行删除操作(多信号验证)', {
|
||||||
orderNumber,
|
orderNumber,
|
||||||
materialIdx,
|
materialIdx,
|
||||||
materialCode,
|
materialCode,
|
||||||
materialName,
|
materialName,
|
||||||
rowNumber: currentRow,
|
rowNumber: currentRow,
|
||||||
|
materialCount: detailCount,
|
||||||
dryRun: false
|
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
|
const deleteElapsed = Date.now() - materialStartTime
|
||||||
|
|
||||||
if (deleteSuccess) {
|
if (
|
||||||
|
deleteResult.outcome === DeletionOutcome.Success ||
|
||||||
|
deleteResult.outcome === DeletionOutcome.Uncertain
|
||||||
|
) {
|
||||||
detail.materialsDeleted += 1
|
detail.materialsDeleted += 1
|
||||||
log.info('[物料操作完成] 物料已成功删除', {
|
if (deleteResult.outcome === DeletionOutcome.Uncertain) {
|
||||||
|
detail.uncertainDeletions += 1
|
||||||
|
}
|
||||||
|
log.info('[物料操作完成] 物料删除结果', {
|
||||||
orderNumber,
|
orderNumber,
|
||||||
materialCode,
|
materialCode,
|
||||||
rowNumber: currentRow,
|
outcome: deleteResult.outcome,
|
||||||
|
attempts: deleteResult.attempts.length,
|
||||||
elapsedMs: deleteElapsed
|
elapsedMs: deleteElapsed
|
||||||
})
|
})
|
||||||
} else {
|
} 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,
|
orderNumber,
|
||||||
materialCode,
|
materialCode,
|
||||||
rowNumber: currentRow,
|
outcome: deleteResult.outcome,
|
||||||
|
errorCategory: deleteResult.errorCategory,
|
||||||
|
errorMessage: deleteResult.errorMessage,
|
||||||
|
attempts: deleteResult.attempts.length,
|
||||||
elapsedMs: deleteElapsed
|
elapsedMs: deleteElapsed
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1063,7 +1108,10 @@ export class CleanerService {
|
|||||||
retryCount: 0,
|
retryCount: 0,
|
||||||
retryAttempts: [],
|
retryAttempts: [],
|
||||||
retriedAt: undefined,
|
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(
|
private async waitForRowChange(
|
||||||
childForm: FrameLocator | Locator,
|
childForm: FrameLocator | Locator,
|
||||||
oldRowNumber: string,
|
oldRowNumber: string,
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ import path from 'path'
|
|||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
import { createLogger } from '../logger'
|
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')
|
const log = createLogger('CleanerReportGenerator')
|
||||||
|
|
||||||
@@ -88,6 +93,12 @@ export class CleanerReportGenerator {
|
|||||||
lines.push(`| **处理订单数** | \`${result.ordersProcessed}\``)
|
lines.push(`| **处理订单数** | \`${result.ordersProcessed}\``)
|
||||||
lines.push(`| **删除物料数** | \`${result.materialsDeleted}\``)
|
lines.push(`| **删除物料数** | \`${result.materialsDeleted}\``)
|
||||||
lines.push(`| **跳过物料数** | \`${result.materialsSkipped}\``)
|
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}\``)
|
lines.push(`| **错误数量** | \`${result.errors.length}\``)
|
||||||
if (result.retriedOrders > 0) {
|
if (result.retriedOrders > 0) {
|
||||||
lines.push(`| **重试订单数** | \`${result.retriedOrders}\``)
|
lines.push(`| **重试订单数** | \`${result.retriedOrders}\``)
|
||||||
@@ -159,6 +170,53 @@ export class CleanerReportGenerator {
|
|||||||
lines.push('')
|
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) {
|
if (result.errors.length > 0) {
|
||||||
lines.push('## 错误详情')
|
lines.push('## 错误详情')
|
||||||
lines.push('')
|
lines.push('')
|
||||||
@@ -275,6 +333,25 @@ export class CleanerReportGenerator {
|
|||||||
return result
|
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[] {
|
private extractErrorOrders(details: OrderCleanDetail[]): string[] {
|
||||||
return details.filter((d) => d.errors.length > 0).map((d) => d.orderNumber)
|
return details.filter((d) => d.errors.length > 0).map((d) => d.orderNumber)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ export interface CleanerResult {
|
|||||||
// Retry statistics
|
// Retry statistics
|
||||||
retriedOrders: number
|
retriedOrders: number
|
||||||
successfulRetries: number
|
successfulRetries: number
|
||||||
|
// Deletion verification statistics
|
||||||
|
materialsFailed: number
|
||||||
|
uncertainDeletions: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SkippedMaterial {
|
export interface SkippedMaterial {
|
||||||
@@ -56,6 +59,50 @@ export interface OrderCleanDetail {
|
|||||||
retryAttempts?: RetryAttempt[]
|
retryAttempts?: RetryAttempt[]
|
||||||
retriedAt?: number
|
retriedAt?: number
|
||||||
retrySuccess?: boolean
|
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 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 { Modal } from './ui/Modal'
|
||||||
import type { CleanerProgress } from '../hooks/cleaner/types'
|
import type { CleanerProgress } from '../hooks/cleaner/types'
|
||||||
|
|
||||||
@@ -27,6 +27,9 @@ interface ExecutionReportDialogProps {
|
|||||||
// Retry-related props
|
// Retry-related props
|
||||||
retriedOrders?: number
|
retriedOrders?: number
|
||||||
successfulRetries?: number
|
successfulRetries?: number
|
||||||
|
// Deletion verification props
|
||||||
|
materialsFailed?: number
|
||||||
|
uncertainDeletions?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ExecutionReportDialog: React.FC<ExecutionReportDialogProps> = ({
|
export const ExecutionReportDialog: React.FC<ExecutionReportDialogProps> = ({
|
||||||
@@ -42,12 +45,15 @@ export const ExecutionReportDialog: React.FC<ExecutionReportDialogProps> = ({
|
|||||||
startTime = null,
|
startTime = null,
|
||||||
triggerRef,
|
triggerRef,
|
||||||
retriedOrders = 0,
|
retriedOrders = 0,
|
||||||
successfulRetries = 0
|
successfulRetries = 0,
|
||||||
|
materialsFailed = 0,
|
||||||
|
uncertainDeletions = 0
|
||||||
}) => {
|
}) => {
|
||||||
const [now, setNow] = React.useState(() => Date.now())
|
const [now, setNow] = React.useState(() => Date.now())
|
||||||
|
|
||||||
const hasErrors = errors.length > 0
|
const hasErrors = errors.length > 0
|
||||||
const hasRetries = retriedOrders > 0
|
const hasRetries = retriedOrders > 0
|
||||||
|
const hasFailedMaterials = materialsFailed > 0 || uncertainDeletions > 0
|
||||||
const showProgress = isExecuting && progress
|
const showProgress = isExecuting && progress
|
||||||
const isProgressing = !!showProgress
|
const isProgressing = !!showProgress
|
||||||
|
|
||||||
@@ -327,6 +333,36 @@ export const ExecutionReportDialog: React.FC<ExecutionReportDialogProps> = ({
|
|||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{elapsedTime && (
|
{elapsedTime && (
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ interface CleanerRunPayload {
|
|||||||
errors: string[]
|
errors: string[]
|
||||||
retriedOrders: number
|
retriedOrders: number
|
||||||
successfulRetries: number
|
successfulRetries: number
|
||||||
|
materialsFailed?: number
|
||||||
|
uncertainDeletions?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function initializeCleanerPage(): Promise<CleanerInitializationResult> {
|
export async function initializeCleanerPage(): Promise<CleanerInitializationResult> {
|
||||||
@@ -161,7 +163,9 @@ export async function runCleanerExecution(params: {
|
|||||||
materialsSkipped: cleanerRunData.materialsSkipped,
|
materialsSkipped: cleanerRunData.materialsSkipped,
|
||||||
errors: cleanerRunData.errors,
|
errors: cleanerRunData.errors,
|
||||||
retriedOrders: cleanerRunData.retriedOrders,
|
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[]
|
errors: string[]
|
||||||
retriedOrders?: number
|
retriedOrders?: number
|
||||||
successfulRetries?: number
|
successfulRetries?: number
|
||||||
|
materialsFailed?: number
|
||||||
|
uncertainDeletions?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CleanerInitializationResult {
|
export interface CleanerInitializationResult {
|
||||||
|
|||||||
@@ -162,6 +162,8 @@ const CleanerPage: React.FC = () => {
|
|||||||
triggerRef={executeButtonRef}
|
triggerRef={executeButtonRef}
|
||||||
retriedOrders={reportData?.retriedOrders}
|
retriedOrders={reportData?.retriedOrders}
|
||||||
successfulRetries={reportData?.successfulRetries}
|
successfulRetries={reportData?.successfulRetries}
|
||||||
|
materialsFailed={reportData?.materialsFailed}
|
||||||
|
uncertainDeletions={reportData?.uncertainDeletions}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { DeletionErrorCategory, DeletionOutcome } from '../../../../src/main/types/cleaner.types'
|
||||||
import {
|
import {
|
||||||
CleanerService,
|
CleanerService,
|
||||||
createBatches,
|
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