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:
@@ -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')
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch (reportError) {
|
|
||||||
log.warn('Failed to generate report', {
|
if (materialDetails.length > 0) {
|
||||||
error: reportError instanceof Error ? reportError.message : String(reportError)
|
await historyDao.insertMaterialDetails(batchId, attemptNumber, materialDetails)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user