From 151485caedbd9c64a71dcd472d0dbdd0874ca251 Mon Sep 17 00:00:00 2001 From: Misaka_Company Date: Mon, 13 Apr 2026 15:58:00 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(cleaner):=20track=20skipped=20?= =?UTF-8?q?materials=20and=20skip=20DB=20writes=20on=20dry=20run?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record materials not in the deletion list as "skipped" with reason instead of just logging them. Skip inserting material details to database during dry runs to avoid phantom records. Co-Authored-By: Claude Opus 4.6 --- .../cleaner/cleaner-application-service.ts | 7 +- src/main/services/erp/cleaner.ts | 13 +- .../cleaner-application-service.test.ts | 38 ++++ .../cleaner/cleaner-skipped-materials.test.ts | 203 ++++++++++++++++++ 4 files changed, 258 insertions(+), 3 deletions(-) create mode 100644 tests/unit/services/cleaner/cleaner-skipped-materials.test.ts 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 范围内(受保护)') + }) +})