diff --git a/src/main/services/cleaner/cleaner-application-service.ts b/src/main/services/cleaner/cleaner-application-service.ts index 8c2f6f3..b09d504 100644 --- a/src/main/services/cleaner/cleaner-application-service.ts +++ b/src/main/services/cleaner/cleaner-application-service.ts @@ -417,6 +417,11 @@ export class CleanerApplicationService { result: CleanerResult ): Promise { try { + // Query execution record to determine if this is a dry run + const batchDetails = await historyDao.getBatchDetails(batchId) + const execution = batchDetails.executions.find((e) => e.attemptNumber === attemptNumber) + const isDryRun = execution?.isDryRun ?? false + // Update order statuses and insert material details for (const detail of result.details) { await historyDao.updateOrderStatus( @@ -479,7 +484,7 @@ export class CleanerApplicationService { }) } - if (materialDetails.length > 0) { + if (materialDetails.length > 0 && !isDryRun) { await historyDao.insertMaterialDetails(batchId, attemptNumber, materialDetails) } } diff --git a/src/main/services/erp/cleaner.ts b/src/main/services/erp/cleaner.ts index 14694ff..493ecb6 100644 --- a/src/main/services/erp/cleaner.ts +++ b/src/main/services/erp/cleaner.ts @@ -990,11 +990,20 @@ export class CleanerService { }) } } else { - log.verbose('[物料判断] 物料不在删除清单中,跳过', { + // 物料不在删除清单中,记录为 skipped + detail.materialsSkipped += 1 + detail.skippedMaterials.push({ + materialCode, + materialName, + rowNumber: rowNumInt, + reason: '不在删除清单中' + }) + log.verbose('[物料跳过] 物料不在删除清单中', { orderNumber, materialIdx, materialCode, - materialName + materialName, + rowNumber: rowNumInt }) } diff --git a/tests/unit/services/cleaner/cleaner-application-service.test.ts b/tests/unit/services/cleaner/cleaner-application-service.test.ts index 75d28a5..0df2c9b 100644 --- a/tests/unit/services/cleaner/cleaner-application-service.test.ts +++ b/tests/unit/services/cleaner/cleaner-application-service.test.ts @@ -37,6 +37,34 @@ vi.mock('../../../../src/main/services/erp/order-resolver', () => { } }) +// Mock CleanerOperationHistoryDAO +vi.mock('../../../../src/main/services/database/cleaner-operation-history-dao', () => { + return { + CleanerOperationHistoryDAO: class { + async insertExecution() { + return true + } + async insertOrderRecords() { + return true + } + async updateOrderStatus() { + return true + } + async insertMaterialDetails() { + return true + } + async updateExecutionStatus() { + return true + } + async getBatchDetails() { + return { + executions: [{ attemptNumber: 1, isDryRun: false }] + } + } + } + } +}) + // Control whether CleanerService.clean should throw let cleanerShouldThrow = false let cleanerError: Error = new Error('cleaner crashed') @@ -108,6 +136,16 @@ function setupServiceMocks(service: CleanerApplicationService) { }) ;(service as any).recordCleanupAudit = vi.fn().mockResolvedValue(undefined) ;(service as any).generateAndUploadReport = vi.fn().mockResolvedValue(undefined) + ;(service as any).getHistoryDao = vi.fn().mockReturnValue({ + insertExecution: vi.fn().mockResolvedValue(true), + insertOrderRecords: vi.fn().mockResolvedValue(true), + updateOrderStatus: vi.fn().mockResolvedValue(true), + insertMaterialDetails: vi.fn().mockResolvedValue(true), + updateExecutionStatus: vi.fn().mockResolvedValue(true), + getBatchDetails: vi.fn().mockResolvedValue({ + executions: [{ attemptNumber: 1, isDryRun: false }] + }) + }) } function makeInput(overrides: Record = {}) { diff --git a/tests/unit/services/cleaner/cleaner-skipped-materials.test.ts b/tests/unit/services/cleaner/cleaner-skipped-materials.test.ts new file mode 100644 index 0000000..ec2775b --- /dev/null +++ b/tests/unit/services/cleaner/cleaner-skipped-materials.test.ts @@ -0,0 +1,203 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { CleanerApplicationService } from '../../../../src/main/services/cleaner/cleaner-application-service' +import { CleanerOperationHistoryDAO } from '../../../../src/main/services/database/cleaner-operation-history-dao' + +// Mock 所有依赖 +vi.mock('../../../../src/main/services/config/config-manager', () => ({ + ConfigManager: { + getInstance: () => ({ + getDatabaseType: () => 'mysql', + getConfig: () => ({ database: { activeType: 'mysql' } }) + }) + } +})) + +vi.mock('../../../../src/main/services/erp/order-resolver', () => ({ + OrderNumberResolver: class { + constructor(_dbService: any) {} + async resolve(orderNumbers: string[]) { + return orderNumbers + } + getValidOrderNumbers(mappings: string[]) { + return mappings + } + getWarnings(_mappings: any[]) { + return [] + } + } +})) + +let mockInsertedMaterials: any[] = [] + +vi.mock('../../../../src/main/services/erp/cleaner', () => ({ + CleanerService: class { + constructor(_erpAuth: any) {} + async clean(input: any) { + // 模拟一个订单有 deleted 和 skipped 物料 + return { + ordersProcessed: 1, + materialsDeleted: 1, + materialsSkipped: 1, + materialsFailed: 0, + uncertainDeletions: 0, + errors: [], + details: [ + { + orderNumber: 'SC70202604080195', + materialsDeleted: 1, + materialsSkipped: 1, + materialsFailed: 0, + uncertainDeletions: 0, + errors: [], + deletedMaterials: [ + { + materialCode: 'MAT001', + materialName: '测试物料 1', + rowNumber: 10, + outcome: 'success' + } + ], + skippedMaterials: [ + { + materialCode: 'MAT002', + materialName: '测试物料 2', + rowNumber: 2500, + reason: '行号在 2000-7999 范围内(受保护)' + } + ], + failedMaterials: [], + retryCount: 0, + retrySuccess: false + } + ], + retriedOrders: 0, + successfulRetries: 0, + crashed: false + } as any + } + } +})) + +vi.mock('../../../../src/main/services/erp/erp-auth', () => ({ + ErpAuthService: class { + constructor(_config: any) {} + async login() { + return Promise.resolve(undefined) + } + async close() { + return Promise.resolve(undefined) + } + } +})) + +vi.mock('../../../../src/main/services/database/cleaner-operation-history-dao', () => ({ +CleanerOperationHistoryDAO: class { + async getBatchDetails(batchId: string) { + return { executions: [{ attemptNumber: 1, isDryRun: false }], orders: [] } + } + async insertExecution(input: any) { + return true + } + async insertOrderRecords(batchId: string, attemptNumber: number, orders: any[]) { + return true + } + async updateOrderStatus( + batchId: string, + attemptNumber: number, + orderNumber: string, + status: string, + materialsDeleted: number, + materialsSkipped: number, + materialsFailed: number, + uncertainDeletions: number, + retryCount: number, + retrySuccess: boolean, + errorMessage?: string + ) { + return true + } + async insertMaterialDetails(batchId: string, attemptNumber: number, details: any[]) { + mockInsertedMaterials = [...details] + return true + } + async updateExecutionStatus( + batchId: string, + attemptNumber: number, + status: string, + ordersProcessed: number, + materialsDeleted: number, + materialsSkipped: number, + materialsFailed: number, + uncertainDeletions: number, + endTime: Date, + errorMessage?: string + ) { + return true + } + } +})) + +vi.mock('../../../../src/main/services/logger/audit-logger', () => ({ + logAuditWithCurrentUser: () => {} +})) + +vi.mock('../../../../src/main/services/user/session-manager', () => ({ + SessionManager: { + getInstance: () => ({ + getUserInfo: () => ({ id: 1, username: 'test' }) + }) + } +})) + +function setupServiceMocks(service: CleanerApplicationService) { + ;(service as any).getErpConfig = vi + .fn() + .mockResolvedValue({ url: 'http://erp', username: 'u', password: 'p' }) + ;(service as any).getDatabaseService = vi.fn().mockResolvedValue({ disconnect: vi.fn() }) + ;(service as any).recordCleanupAudit = vi.fn() +} + +function makeInput(overrides: any = {}) { + return { + orderNumbers: ['SC70202604080195'], + materialCodes: ['MAT001', 'MAT002'], + dryRun: false, + queryBatchSize: 100, + processConcurrency: 1, + onProgress: vi.fn(), + ...overrides + } +} + +describe('CleanerApplicationService - Skipped Materials', () => { + let service: CleanerApplicationService + let historyDao: CleanerOperationHistoryDAO + + beforeEach(() => { + service = new CleanerApplicationService() + historyDao = new CleanerOperationHistoryDAO() + mockInsertedMaterials = [] + setupServiceMocks(service) + }) + + it('should save skipped materials to database', async () => { + const eventSender: any = { send: vi.fn() } + const result = await service.runCleaner( + eventSender, + makeInput(), + 'TEST-BATCH', + historyDao, + '1.0.0' + ) + + expect(result.materialsSkipped).toBe(1) + expect(mockInsertedMaterials.length).toBe(2) // 1 deleted + 1 skipped + + const skippedMaterial = mockInsertedMaterials.find((m) => m.result === 'skipped') + expect(skippedMaterial).toBeDefined() + expect(skippedMaterial!.materialCode).toBe('MAT002') + expect(skippedMaterial!.materialName).toBe('测试物料 2') + expect(skippedMaterial!.rowNumber).toBe(2500) + expect(skippedMaterial!.reason).toBe('行号在 2000-7999 范围内(受保护)') + }) +})