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>
375 lines
10 KiB
TypeScript
375 lines
10 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
|
import { DeletionErrorCategory, DeletionOutcome } from '../../../../src/main/types/cleaner.types'
|
|
import {
|
|
CleanerService,
|
|
createBatches,
|
|
getMissingOrders,
|
|
runWithConcurrency
|
|
} from '../../../../src/main/services/erp/cleaner'
|
|
|
|
// TODO: clean() method tests need integration test setup with full page mock
|
|
|
|
describe('CleanerService - Helper Methods', () => {
|
|
const createCleanerService = (dryRun = false): CleanerService => {
|
|
return new CleanerService({} as any, { dryRun })
|
|
}
|
|
|
|
describe('shouldDeleteMaterial()', () => {
|
|
const cleaner = createCleanerService()
|
|
const deleteSet = new Set(['MAT001', 'MAT002'])
|
|
|
|
it('should return true when material matches all deletion criteria', () => {
|
|
const result = cleaner.shouldDeleteMaterial({
|
|
rowNumber: 100,
|
|
pendingQty: '',
|
|
materialCode: 'MAT001',
|
|
deleteSet
|
|
})
|
|
|
|
expect(result).toBe(true)
|
|
})
|
|
|
|
it('should return false when material is not in delete set', () => {
|
|
const result = cleaner.shouldDeleteMaterial({
|
|
rowNumber: 100,
|
|
pendingQty: '',
|
|
materialCode: 'MAT999',
|
|
deleteSet
|
|
})
|
|
|
|
expect(result).toBe(false)
|
|
})
|
|
|
|
it('should return false when row is in protected range (2000-7999)', () => {
|
|
const result = cleaner.shouldDeleteMaterial({
|
|
rowNumber: 5000,
|
|
pendingQty: '',
|
|
materialCode: 'MAT001',
|
|
deleteSet
|
|
})
|
|
|
|
expect(result).toBe(false)
|
|
})
|
|
|
|
it('should return false when pendingQty is not empty', () => {
|
|
const result = cleaner.shouldDeleteMaterial({
|
|
rowNumber: 100,
|
|
pendingQty: '5',
|
|
materialCode: 'MAT001',
|
|
deleteSet
|
|
})
|
|
|
|
expect(result).toBe(false)
|
|
})
|
|
|
|
it('should return false when pendingQty has only whitespace', () => {
|
|
const result = cleaner.shouldDeleteMaterial({
|
|
rowNumber: 100,
|
|
pendingQty: ' ',
|
|
materialCode: 'MAT001',
|
|
deleteSet
|
|
})
|
|
|
|
expect(result).toBe(true) // whitespace-only is treated as empty after trim
|
|
})
|
|
|
|
it('should respect boundary row numbers', () => {
|
|
// Row 1999: can delete
|
|
expect(
|
|
cleaner.shouldDeleteMaterial({
|
|
rowNumber: 1999,
|
|
pendingQty: '',
|
|
materialCode: 'MAT001',
|
|
deleteSet
|
|
})
|
|
).toBe(true)
|
|
|
|
// Row 2000: protected
|
|
expect(
|
|
cleaner.shouldDeleteMaterial({
|
|
rowNumber: 2000,
|
|
pendingQty: '',
|
|
materialCode: 'MAT001',
|
|
deleteSet
|
|
})
|
|
).toBe(false)
|
|
|
|
// Row 7999: protected
|
|
expect(
|
|
cleaner.shouldDeleteMaterial({
|
|
rowNumber: 7999,
|
|
pendingQty: '',
|
|
materialCode: 'MAT001',
|
|
deleteSet
|
|
})
|
|
).toBe(false)
|
|
|
|
// Row 8000: can delete
|
|
expect(
|
|
cleaner.shouldDeleteMaterial({
|
|
rowNumber: 8000,
|
|
pendingQty: '',
|
|
materialCode: 'MAT001',
|
|
deleteSet
|
|
})
|
|
).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('getSkipReason()', () => {
|
|
const cleaner = createCleanerService()
|
|
const deleteSet = new Set(['MAT001'])
|
|
|
|
it('should return correct skip reason for protected row', () => {
|
|
const reason = cleaner.getSkipReason({
|
|
rowNumber: 3000,
|
|
pendingQty: '',
|
|
materialCode: 'MAT001',
|
|
deleteSet
|
|
})
|
|
|
|
expect(reason).toBe('行号在 2000-7999 范围内(受保护)')
|
|
})
|
|
|
|
it('should return "unknown reason" when no skip conditions match', () => {
|
|
const result = cleaner.shouldDeleteMaterial({
|
|
rowNumber: 100,
|
|
pendingQty: '',
|
|
materialCode: 'MAT001',
|
|
deleteSet
|
|
})
|
|
const reason = cleaner.getSkipReason({
|
|
rowNumber: 100,
|
|
pendingQty: '',
|
|
materialCode: 'MAT001',
|
|
deleteSet
|
|
})
|
|
|
|
expect(result).toBe(true)
|
|
expect(reason).toBe('未知原因')
|
|
})
|
|
|
|
it('should return correct reason for material not in delete set', () => {
|
|
const reason = cleaner.getSkipReason({
|
|
rowNumber: 100,
|
|
pendingQty: '',
|
|
materialCode: 'MAT999',
|
|
deleteSet
|
|
})
|
|
|
|
expect(reason).toBe('物料不在删除清单中')
|
|
})
|
|
|
|
it('should return correct reason for non-empty pendingQty', () => {
|
|
const reason = cleaner.getSkipReason({
|
|
rowNumber: 100,
|
|
pendingQty: '10',
|
|
materialCode: 'MAT001',
|
|
deleteSet
|
|
})
|
|
|
|
expect(reason).toBe('累计待发数量不为空')
|
|
})
|
|
})
|
|
|
|
describe('createBatches()', () => {
|
|
it('should split array into correct batch sizes', () => {
|
|
const items = [1, 2, 3, 4, 5, 6, 7]
|
|
const batches = createBatches(items, 3)
|
|
|
|
expect(batches).toEqual([[1, 2, 3], [4, 5, 6], [7]])
|
|
})
|
|
|
|
it('should handle edge cases (empty array, single item, batchSize larger than array)', () => {
|
|
expect(createBatches([], 5)).toEqual([])
|
|
expect(createBatches([1], 5)).toEqual([[1]])
|
|
expect(createBatches([1, 2], 10)).toEqual([[1, 2]])
|
|
})
|
|
|
|
it('should handle batchSize of 1', () => {
|
|
const items = [1, 2, 3]
|
|
const batches = createBatches(items, 1)
|
|
|
|
expect(batches).toEqual([[1], [2], [3]])
|
|
})
|
|
})
|
|
|
|
describe('runWithConcurrency()', () => {
|
|
it('should limit parallelism to specified concurrency', async () => {
|
|
const items = [1, 2, 3, 4, 5, 6]
|
|
let running = 0
|
|
let peak = 0
|
|
|
|
await runWithConcurrency(items, 2, async () => {
|
|
running += 1
|
|
peak = Math.max(peak, running)
|
|
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
running -= 1
|
|
return true
|
|
})
|
|
|
|
expect(peak).toBeLessThanOrEqual(2)
|
|
expect(peak).toBe(2)
|
|
})
|
|
|
|
it('should complete all items successfully', async () => {
|
|
const items = ['a', 'b', 'c']
|
|
const results = await runWithConcurrency(items, 2, async (item, index) => {
|
|
return `${item}-${index}`
|
|
})
|
|
|
|
expect(results).toEqual(['a-0', 'b-1', 'c-2'])
|
|
expect(results).toHaveLength(items.length)
|
|
})
|
|
})
|
|
|
|
describe('isDryRun()', () => {
|
|
it('should return correct dry run mode from constructor options', () => {
|
|
const dryRunService = createCleanerService(true)
|
|
const normalService = createCleanerService(false)
|
|
|
|
expect(dryRunService.isDryRun()).toBe(true)
|
|
expect(normalService.isDryRun()).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('getMissingOrders()', () => {
|
|
it('should return orders not in processed set', () => {
|
|
const inputOrders = ['ORD001', 'ORD002', 'ORD003']
|
|
const processedOrders = new Set(['ORD001', 'ORD003'])
|
|
|
|
const missing = getMissingOrders(inputOrders, processedOrders)
|
|
|
|
expect(missing).toEqual(['ORD002'])
|
|
})
|
|
|
|
it('should return empty when all orders processed', () => {
|
|
const inputOrders = ['ORD001', 'ORD002']
|
|
const processedOrders = new Set(['ORD001', 'ORD002'])
|
|
|
|
const missing = getMissingOrders(inputOrders, processedOrders)
|
|
|
|
expect(missing).toEqual([])
|
|
})
|
|
|
|
it('should deduplicate input orders', () => {
|
|
const inputOrders = ['ORD001', 'ORD001', 'ORD002']
|
|
const processedOrders = new Set(['ORD002'])
|
|
|
|
const missing = getMissingOrders(inputOrders, processedOrders)
|
|
|
|
expect(missing).toEqual(['ORD001'])
|
|
})
|
|
|
|
it('should return all orders when none processed', () => {
|
|
const inputOrders = ['ORD001', 'ORD002']
|
|
const processedOrders = new Set<string>()
|
|
|
|
const missing = getMissingOrders(inputOrders, processedOrders)
|
|
|
|
expect(missing).toEqual(['ORD001', 'ORD002'])
|
|
})
|
|
})
|
|
})
|
|
|
|
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)
|
|
})
|
|
})
|