feat(cleaner): track skipped materials and skip DB writes on dry run

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 <noreply@anthropic.com>
This commit is contained in:
Misaka_Company
2026-04-13 15:58:00 +08:00
parent 116539ff42
commit 151485caed
4 changed files with 258 additions and 3 deletions

View File

@@ -417,6 +417,11 @@ export class CleanerApplicationService {
result: CleanerResult
): Promise<void> {
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)
}
}

View File

@@ -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
})
}

View File

@@ -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<string, any> = {}) {

View File

@@ -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 范围内(受保护)')
})
})