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 { ipcMain } from 'electron'
import { withErrorHandling, type IpcResult } from './index' import { withErrorHandling, type IpcResult } from './index'
import type { import type {
@@ -8,6 +10,7 @@ import type {
} from '../types/cleaner.types' } from '../types/cleaner.types'
import { IPC_CHANNELS } from '../../shared/ipc-channels' import { IPC_CHANNELS } from '../../shared/ipc-channels'
import { CleanerApplicationService } from '../services/cleaner/cleaner-application-service' import { CleanerApplicationService } from '../services/cleaner/cleaner-application-service'
import { CleanerOperationHistoryDAO } from '../services/database/cleaner-operation-history-dao'
export function registerCleanerHandlers(): void { export function registerCleanerHandlers(): void {
const cleanerService = new CleanerApplicationService() const cleanerService = new CleanerApplicationService()
@@ -15,10 +18,21 @@ export function registerCleanerHandlers(): void {
ipcMain.handle( ipcMain.handle(
IPC_CHANNELS.CLEANER_RUN, IPC_CHANNELS.CLEANER_RUN,
async (event, input: CleanerInput): Promise<IpcResult<CleanerResult>> => { async (event, input: CleanerInput): Promise<IpcResult<CleanerResult>> => {
return withErrorHandling( return withErrorHandling(async () => {
async () => cleanerService.runCleaner(event.sender, input), const batchId = randomUUID()
'cleaner:run' 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 type { WebContents } from 'electron'
import { app } from 'electron'
import type { IDatabaseService } from '../../types/database.types' import type { IDatabaseService } from '../../types/database.types'
import { ErpAuthService } from '../erp/erp-auth' import { ErpAuthService } from '../erp/erp-auth'
import { CleanerService } from '../erp/cleaner' 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 { PostgreSqlService as PostgreSqlServiceImpl } from '../database/postgresql'
import { ConfigManager } from '../config/config-manager' import { ConfigManager } from '../config/config-manager'
import { ResultExporter } from '../excel/result-exporter' import { ResultExporter } from '../excel/result-exporter'
import { CleanerReportGenerator } from '../report/cleaner-report-generator'
import { RustfsService } from '../rustfs'
import { SessionManager } from '../user/session-manager' import { SessionManager } from '../user/session-manager'
import { UserErpConfigService } from '../user/user-erp-config-service' import { UserErpConfigService } from '../user/user-erp-config-service'
import { createLogger } from '../logger' import { createLogger } from '../logger'
@@ -18,6 +15,7 @@ import { logAuditWithCurrentUser } from '../logger/audit-logger'
import { AuditAction, AuditStatus } from '../../types/audit.types' import { AuditAction, AuditStatus } from '../../types/audit.types'
import { IPC_CHANNELS } from '../../../shared/ipc-channels' import { IPC_CHANNELS } from '../../../shared/ipc-channels'
import { DatabaseQueryError, ErpConnectionError, ValidationError } from '../../types/errors' import { DatabaseQueryError, ErpConnectionError, ValidationError } from '../../types/errors'
import { CleanerOperationHistoryDAO } from '../database/cleaner-operation-history-dao'
import type { import type {
CleanerInput, CleanerInput,
CleanerProgress, CleanerProgress,
@@ -25,25 +23,18 @@ import type {
ExportResultItem, ExportResultItem,
ExportResultResponse ExportResultResponse
} from '../../types/cleaner.types' } from '../../types/cleaner.types'
import type { InsertMaterialDetailInput } from '../../types/cleaner-history.types'
const log = createLogger('CleanerApplicationService') 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 { export class CleanerApplicationService {
async runCleaner(eventSender: WebContents, input: CleanerInput): Promise<CleanerResult> { async runCleaner(
const executionId = generateExecutionId() eventSender: WebContents,
const startTime = Date.now() input: CleanerInput,
batchId: string,
historyDao: CleanerOperationHistoryDAO,
appVersion: string
): Promise<CleanerResult> {
let authService: ErpAuthService | null = null let authService: ErpAuthService | null = null
let dbService: IDatabaseService | null = null let dbService: IDatabaseService | null = null
@@ -90,6 +81,25 @@ export class CleanerApplicationService {
log.info('Resolved order numbers', { count: validOrderNumbers.length }) 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({ authService = new ErpAuthService({
url: erpConfig.url, url: erpConfig.url,
username: erpConfig.username, username: erpConfig.username,
@@ -127,7 +137,7 @@ export class CleanerApplicationService {
} }
log.info('Starting cleaning', { log.info('Starting cleaning', {
executionId, batchId,
orderCount: validOrderNumbers.length, orderCount: validOrderNumbers.length,
queryBatchSize: input.queryBatchSize ?? 100, queryBatchSize: input.queryBatchSize ?? 100,
processConcurrency: input.processConcurrency ?? 1 processConcurrency: input.processConcurrency ?? 1
@@ -138,7 +148,10 @@ export class CleanerApplicationService {
// Outer retry: re-login and re-run all orders on fatal crash // Outer retry: re-login and re-run all orders on fatal crash
if (result.crashed) { 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, { this.sendProgress(eventSender, '流程崩溃,正在重新登录并重试...', 0, {
phase: 'retry', phase: 'retry',
@@ -164,30 +177,57 @@ export class CleanerApplicationService {
try { try {
await authService.login() await authService.login()
log.info('Outer retry: re-login successful', { executionId }) log.info('Outer retry: re-login successful', { batchId })
} catch (loginError) { } catch (loginError) {
log.error('Outer retry: re-login failed', { log.error('Outer retry: re-login failed', {
executionId, batchId,
error: loginError instanceof Error ? loginError.message : String(loginError) error: loginError instanceof Error ? loginError.message : String(loginError)
}) })
// Return the original crash result if re-login fails // 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) { if (warnings.length > 0) {
result.errors = [...warnings, ...result.errors] result.errors = [...warnings, ...result.errors]
} }
await this.recordCleanupAudit(validOrderNumbers.length, input, result) await this.recordCleanupAudit(validOrderNumbers.length, input, result)
await this.generateAndUploadReport(input, result, startTime, executionId) // Attempt 1 already saved as crashed above
return result 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) cleaner = new CleanerService(authService)
result = await cleaner.clean(modifiedInput) result = await cleaner.clean(modifiedInput)
// Save attempt 2 result
await this.saveAttemptToDatabase(historyDao, batchId, 2, result)
log.info('Outer retry completed', { log.info('Outer retry completed', {
executionId, batchId,
processedCount: result.ordersProcessed, processedCount: result.ordersProcessed,
errorCount: result.errors.length, errorCount: result.errors.length,
crashed: result.crashed crashed: result.crashed
}) })
} else {
// No crash — save attempt 1 result
await this.saveAttemptToDatabase(historyDao, batchId, 1, result)
} }
if (warnings.length > 0) { if (warnings.length > 0) {
@@ -203,13 +243,12 @@ export class CleanerApplicationService {
}) })
log.info('Cleaning completed', { log.info('Cleaning completed', {
executionId, batchId,
processedCount: result.ordersProcessed, processedCount: result.ordersProcessed,
errorCount: result.errors.length errorCount: result.errors.length
}) })
await this.recordCleanupAudit(validOrderNumbers.length, input, result) await this.recordCleanupAudit(validOrderNumbers.length, input, result)
await this.generateAndUploadReport(input, result, startTime, executionId)
return result return result
} finally { } finally {
@@ -367,72 +406,111 @@ export class CleanerApplicationService {
}) })
} }
private async generateAndUploadReport( /**
input: CleanerInput, * Save attempt results to database: update order statuses, insert material details,
result: CleanerResult, * and update execution status.
startTime: number, */
executionId: string private async saveAttemptToDatabase(
historyDao: CleanerOperationHistoryDAO,
batchId: string,
attemptNumber: number,
result: CleanerResult
): Promise<void> { ): Promise<void> {
try { try {
const endTime = Date.now() // Update order statuses and insert material details
const currentUser = SessionManager.getInstance().getUserInfo() for (const detail of result.details) {
const username = currentUser?.username ?? 'unknown' await historyDao.updateOrderStatus(
batchId,
const reportGenerator = new CleanerReportGenerator() attemptNumber,
const reportPath = await reportGenerator.generateReport(result, { detail.orderNumber,
dryRun: input.dryRun ?? false, detail.errors.length > 0 ? 'failed' : 'success',
username, detail.materialsDeleted,
startTime, detail.materialsSkipped,
endTime, detail.materialsFailed,
executionId, detail.uncertainDeletions,
appVersion: app.getVersion() detail.retryCount,
}) detail.retrySuccess ?? false,
log.info('Report generated', { path: reportPath }) detail.errors.length > 0 ? detail.errors.join('\n') : undefined
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'
) )
if (uploadResult.success) { // Insert material details for skipped and failed materials
log.info('Report uploaded to RustFS successfully', { const materialDetails: InsertMaterialDetailInput[] = []
key: storageKey,
etag: uploadResult.etag for (const skipped of detail.skippedMaterials) {
}) materialDetails.push({
} else { orderNumber: detail.orderNumber,
log.warn('Failed to upload report to RustFS', { materialCode: skipped.materialCode,
error: uploadResult.error, materialName: skipped.materialName,
key: storageKey rowNumber: skipped.rowNumber,
result: 'skipped',
reason: skipped.reason,
attemptCount: 0,
finalErrorCategory: null
}) })
} }
} catch (rustfsError) {
log.error('RustFS upload failed', { for (const failed of detail.failedMaterials) {
error: rustfsError instanceof Error ? rustfsError.message : String(rustfsError) 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', { // Determine execution status
error: reportError instanceof Error ? reportError.message : String(reportError) 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
} }
} }
} }