refactor(cleaner): replace report generation with database persistence

Remove generateExecutionId(), generateAndUploadReport(), and all
executionId references from CleanerApplicationService. The service
now accepts batchId, historyDao, and appVersion from the IPC handler
and writes execution/order/material records to the database via
CleanerOperationHistoryDAO instead of generating Markdown reports.

All execution paths (success, failure, outer retry, retry-login-failure)
persist their results to the database with appropriate status tracking.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Misaka_Company
2026-04-13 12:02:43 +08:00
parent 74096fbfa0
commit 998edb4b84
2 changed files with 181 additions and 89 deletions

View File

@@ -1,3 +1,5 @@
import { randomUUID } from 'crypto'
import { app } from 'electron'
import { ipcMain } from 'electron'
import { withErrorHandling, type IpcResult } from './index'
import type {
@@ -8,6 +10,7 @@ import type {
} from '../types/cleaner.types'
import { IPC_CHANNELS } from '../../shared/ipc-channels'
import { CleanerApplicationService } from '../services/cleaner/cleaner-application-service'
import { CleanerOperationHistoryDAO } from '../services/database/cleaner-operation-history-dao'
export function registerCleanerHandlers(): void {
const cleanerService = new CleanerApplicationService()
@@ -15,10 +18,21 @@ export function registerCleanerHandlers(): void {
ipcMain.handle(
IPC_CHANNELS.CLEANER_RUN,
async (event, input: CleanerInput): Promise<IpcResult<CleanerResult>> => {
return withErrorHandling(
async () => cleanerService.runCleaner(event.sender, input),
'cleaner:run'
)
return withErrorHandling(async () => {
const batchId = randomUUID()
const historyDao = new CleanerOperationHistoryDAO()
const appVersion = app.getVersion()
const result = await cleanerService.runCleaner(
event.sender,
input,
batchId,
historyDao,
appVersion
)
return result
}, 'cleaner:run')
}
)

View File

@@ -1,5 +1,4 @@
import type { WebContents } from 'electron'
import { app } from 'electron'
import type { IDatabaseService } from '../../types/database.types'
import { ErpAuthService } from '../erp/erp-auth'
import { CleanerService } from '../erp/cleaner'
@@ -9,8 +8,6 @@ import { SqlServerService as SqlServerServiceImpl } from '../database/sql-server
import { PostgreSqlService as PostgreSqlServiceImpl } from '../database/postgresql'
import { ConfigManager } from '../config/config-manager'
import { ResultExporter } from '../excel/result-exporter'
import { CleanerReportGenerator } from '../report/cleaner-report-generator'
import { RustfsService } from '../rustfs'
import { SessionManager } from '../user/session-manager'
import { UserErpConfigService } from '../user/user-erp-config-service'
import { createLogger } from '../logger'
@@ -18,6 +15,7 @@ import { logAuditWithCurrentUser } from '../logger/audit-logger'
import { AuditAction, AuditStatus } from '../../types/audit.types'
import { IPC_CHANNELS } from '../../../shared/ipc-channels'
import { DatabaseQueryError, ErpConnectionError, ValidationError } from '../../types/errors'
import { CleanerOperationHistoryDAO } from '../database/cleaner-operation-history-dao'
import type {
CleanerInput,
CleanerProgress,
@@ -25,25 +23,18 @@ import type {
ExportResultItem,
ExportResultResponse
} from '../../types/cleaner.types'
import type { InsertMaterialDetailInput } from '../../types/cleaner-history.types'
const log = createLogger('CleanerApplicationService')
function generateExecutionId(): string {
const now = new Date()
const date = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`
const time = `${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}`
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
let suffix = ''
for (let i = 0; i < 4; i++) {
suffix += chars.charAt(Math.floor(Math.random() * chars.length))
}
return `CLN-${date}${time}-${suffix}`
}
export class CleanerApplicationService {
async runCleaner(eventSender: WebContents, input: CleanerInput): Promise<CleanerResult> {
const executionId = generateExecutionId()
const startTime = Date.now()
async runCleaner(
eventSender: WebContents,
input: CleanerInput,
batchId: string,
historyDao: CleanerOperationHistoryDAO,
appVersion: string
): Promise<CleanerResult> {
let authService: ErpAuthService | null = null
let dbService: IDatabaseService | null = null
@@ -90,6 +81,25 @@ export class CleanerApplicationService {
log.info('Resolved order numbers', { count: validOrderNumbers.length })
// Insert execution record and order records into database
const currentUser = SessionManager.getInstance().getUserInfo()
if (currentUser) {
await historyDao.insertExecution({
batchId,
attemptNumber: 1,
userId: currentUser.id,
username: currentUser.username,
isDryRun: input.dryRun ?? false,
totalOrders: validOrderNumbers.length,
appVersion
})
await historyDao.insertOrderRecords(
batchId,
1,
validOrderNumbers.map((order) => ({ orderNumber: order }))
)
}
authService = new ErpAuthService({
url: erpConfig.url,
username: erpConfig.username,
@@ -127,7 +137,7 @@ export class CleanerApplicationService {
}
log.info('Starting cleaning', {
executionId,
batchId,
orderCount: validOrderNumbers.length,
queryBatchSize: input.queryBatchSize ?? 100,
processConcurrency: input.processConcurrency ?? 1
@@ -138,7 +148,10 @@ export class CleanerApplicationService {
// Outer retry: re-login and re-run all orders on fatal crash
if (result.crashed) {
log.warn('检测到流程级崩溃,准备外层重试', { executionId })
log.warn('检测到流程级崩溃,准备外层重试', { batchId })
// Save attempt 1 result as crashed
await this.saveAttemptToDatabase(historyDao, batchId, 1, result)
this.sendProgress(eventSender, '流程崩溃,正在重新登录并重试...', 0, {
phase: 'retry',
@@ -164,30 +177,57 @@ export class CleanerApplicationService {
try {
await authService.login()
log.info('Outer retry: re-login successful', { executionId })
log.info('Outer retry: re-login successful', { batchId })
} catch (loginError) {
log.error('Outer retry: re-login failed', {
executionId,
batchId,
error: loginError instanceof Error ? loginError.message : String(loginError)
})
// Return the original crash result if re-login fails
result.errors.push(`外层重试登录失败: ${loginError instanceof Error ? loginError.message : String(loginError)}`)
result.errors.push(
`外层重试登录失败: ${loginError instanceof Error ? loginError.message : String(loginError)}`
)
if (warnings.length > 0) {
result.errors = [...warnings, ...result.errors]
}
await this.recordCleanupAudit(validOrderNumbers.length, input, result)
await this.generateAndUploadReport(input, result, startTime, executionId)
// Attempt 1 already saved as crashed above
return result
}
// Insert execution and order records for attempt 2
if (currentUser) {
await historyDao.insertExecution({
batchId,
attemptNumber: 2,
userId: currentUser.id,
username: currentUser.username,
isDryRun: input.dryRun ?? false,
totalOrders: validOrderNumbers.length,
appVersion
})
await historyDao.insertOrderRecords(
batchId,
2,
validOrderNumbers.map((order) => ({ orderNumber: order }))
)
}
cleaner = new CleanerService(authService)
result = await cleaner.clean(modifiedInput)
// Save attempt 2 result
await this.saveAttemptToDatabase(historyDao, batchId, 2, result)
log.info('Outer retry completed', {
executionId,
batchId,
processedCount: result.ordersProcessed,
errorCount: result.errors.length,
crashed: result.crashed
})
} else {
// No crash — save attempt 1 result
await this.saveAttemptToDatabase(historyDao, batchId, 1, result)
}
if (warnings.length > 0) {
@@ -203,13 +243,12 @@ export class CleanerApplicationService {
})
log.info('Cleaning completed', {
executionId,
batchId,
processedCount: result.ordersProcessed,
errorCount: result.errors.length
})
await this.recordCleanupAudit(validOrderNumbers.length, input, result)
await this.generateAndUploadReport(input, result, startTime, executionId)
return result
} finally {
@@ -367,72 +406,111 @@ export class CleanerApplicationService {
})
}
private async generateAndUploadReport(
input: CleanerInput,
result: CleanerResult,
startTime: number,
executionId: string
/**
* Save attempt results to database: update order statuses, insert material details,
* and update execution status.
*/
private async saveAttemptToDatabase(
historyDao: CleanerOperationHistoryDAO,
batchId: string,
attemptNumber: number,
result: CleanerResult
): Promise<void> {
try {
const endTime = Date.now()
const currentUser = SessionManager.getInstance().getUserInfo()
const username = currentUser?.username ?? 'unknown'
const reportGenerator = new CleanerReportGenerator()
const reportPath = await reportGenerator.generateReport(result, {
dryRun: input.dryRun ?? false,
username,
startTime,
endTime,
executionId,
appVersion: app.getVersion()
})
log.info('Report generated', { path: reportPath })
const configManager = ConfigManager.getInstance()
const config = configManager.getConfig()
if (!config.rustfs?.enabled || !config.rustfs.endpoint) {
log.debug('RustFS is not enabled, skipping upload')
return
}
try {
const rustfs = new RustfsService({ config: config.rustfs })
const reportFileName = reportPath.split(/[\\/]/).pop() || 'report.md'
const storageKey = rustfs.generateReportKey(reportFileName, username)
log.info('Uploading report to RustFS', {
localPath: reportPath,
storageKey
})
const uploadResult = await rustfs.uploadFile(
reportPath,
storageKey,
'text/markdown; charset=utf-8'
// Update order statuses and insert material details
for (const detail of result.details) {
await historyDao.updateOrderStatus(
batchId,
attemptNumber,
detail.orderNumber,
detail.errors.length > 0 ? 'failed' : 'success',
detail.materialsDeleted,
detail.materialsSkipped,
detail.materialsFailed,
detail.uncertainDeletions,
detail.retryCount,
detail.retrySuccess ?? false,
detail.errors.length > 0 ? detail.errors.join('\n') : undefined
)
if (uploadResult.success) {
log.info('Report uploaded to RustFS successfully', {
key: storageKey,
etag: uploadResult.etag
})
} else {
log.warn('Failed to upload report to RustFS', {
error: uploadResult.error,
key: storageKey
// Insert material details for skipped and failed materials
const materialDetails: InsertMaterialDetailInput[] = []
for (const skipped of detail.skippedMaterials) {
materialDetails.push({
orderNumber: detail.orderNumber,
materialCode: skipped.materialCode,
materialName: skipped.materialName,
rowNumber: skipped.rowNumber,
result: 'skipped',
reason: skipped.reason,
attemptCount: 0,
finalErrorCategory: null
})
}
} catch (rustfsError) {
log.error('RustFS upload failed', {
error: rustfsError instanceof Error ? rustfsError.message : String(rustfsError)
})
for (const failed of detail.failedMaterials) {
materialDetails.push({
orderNumber: detail.orderNumber,
materialCode: failed.materialCode,
materialName: failed.materialName,
rowNumber: failed.rowNumber,
result: failed.finalOutcome,
reason:
failed.attempts
.map((a) => a.errorMessage)
.filter(Boolean)
.join('; ') || null,
attemptCount: failed.attempts.length,
finalErrorCategory: failed.finalErrorCategory ?? null
})
}
if (materialDetails.length > 0) {
await historyDao.insertMaterialDetails(batchId, attemptNumber, materialDetails)
}
}
} catch (reportError) {
log.warn('Failed to generate report', {
error: reportError instanceof Error ? reportError.message : String(reportError)
// Determine execution status
const execStatus = result.crashed
? 'crashed'
: result.errors.length > 0 && result.materialsDeleted > 0
? 'partial'
: result.errors.length > 0
? 'failed'
: 'success'
// Build error message from execution-level errors (excluding per-order errors)
const execErrorMessage =
result.errors.length > 0 ? result.errors.join('\n') : undefined
// Update execution status
await historyDao.updateExecutionStatus(
batchId,
attemptNumber,
execStatus,
result.ordersProcessed,
result.materialsDeleted,
result.materialsSkipped,
result.materialsFailed,
result.uncertainDeletions,
new Date(),
execErrorMessage
)
log.info('Attempt results saved to database', {
batchId,
attemptNumber,
execStatus,
ordersProcessed: result.ordersProcessed
})
} catch (dbError) {
log.error('Failed to save attempt results to database', {
batchId,
attemptNumber,
error: dbError instanceof Error ? dbError.message : String(dbError)
})
// Don't throw — database save failure should not affect the main result
}
}
}