- Format TypeScript source files - Format documentation files - Update eslint config formatting
590 lines
19 KiB
TypeScript
590 lines
19 KiB
TypeScript
import type { WebContents } from 'electron'
|
|
import type { IDatabaseService } from '../../types/database.types'
|
|
import { ErpAuthService } from '../erp/erp-auth'
|
|
import { CleanerService } from '../erp/cleaner'
|
|
import { OrderNumberResolver } from '../erp/order-resolver'
|
|
import { MySqlService as MySqlServiceImpl } from '../database/mysql'
|
|
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 { SessionManager } from '../user/session-manager'
|
|
import { UserErpConfigService } from '../user/user-erp-config-service'
|
|
import { createLogger } from '../logger'
|
|
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,
|
|
CleanerResult,
|
|
ExportResultItem,
|
|
ExportResultResponse
|
|
} from '../../types/cleaner.types'
|
|
import type { InsertMaterialDetailInput, InsertOrderInput } from '../../types/cleaner-history.types'
|
|
import type { OrderMapping } from '../../types/order-resolver.types'
|
|
|
|
const log = createLogger('CleanerApplicationService')
|
|
|
|
export class CleanerApplicationService {
|
|
async runCleaner(
|
|
eventSender: WebContents,
|
|
input: CleanerInput,
|
|
batchId: string,
|
|
historyDao: CleanerOperationHistoryDAO,
|
|
appVersion: string
|
|
): Promise<CleanerResult> {
|
|
let authService: ErpAuthService | null = null
|
|
let dbService: IDatabaseService | null = null
|
|
|
|
try {
|
|
log.info('Fetching ERP configuration from database...')
|
|
const erpConfig = await this.getErpConfig()
|
|
|
|
log.info('ERP config retrieved', {
|
|
url: erpConfig.url ? 'configured' : 'EMPTY',
|
|
username: erpConfig.username ? 'configured' : 'EMPTY'
|
|
})
|
|
|
|
const configManager = ConfigManager.getInstance()
|
|
const dbType = configManager.getDatabaseType()
|
|
log.info(
|
|
`Connecting to ${dbType === 'sqlserver' ? 'SQL Server' : dbType === 'postgresql' ? 'PostgreSQL' : 'MySQL'} for order resolution...`
|
|
)
|
|
|
|
try {
|
|
dbService = await this.getDatabaseService()
|
|
} catch (error) {
|
|
throw new DatabaseQueryError(
|
|
'数据库连接失败',
|
|
'DB_CONNECTION_FAILED',
|
|
error instanceof Error ? error : undefined
|
|
)
|
|
}
|
|
|
|
const resolver = new OrderNumberResolver(dbService)
|
|
const mappings = await resolver.resolve(input.orderNumbers)
|
|
const validOrderNumbers = resolver.getValidOrderNumbers(mappings)
|
|
const warnings = resolver.getWarnings(mappings)
|
|
|
|
if (warnings.length > 0) {
|
|
log.warn('Resolution warnings', { warnings })
|
|
}
|
|
|
|
// Build order inputs from ALL mappings (including resolution failures)
|
|
const orderInputs = this.buildOrderInputs(mappings)
|
|
|
|
log.info('Resolved order numbers', {
|
|
total: mappings.length,
|
|
resolved: validOrderNumbers.length,
|
|
failed: mappings.length - validOrderNumbers.length
|
|
})
|
|
|
|
// Insert execution record and ALL 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: orderInputs.length,
|
|
appVersion
|
|
})
|
|
await historyDao.insertOrderRecords(batchId, 1, orderInputs)
|
|
}
|
|
|
|
// If no valid order numbers, update execution to failed and return early
|
|
if (validOrderNumbers.length === 0) {
|
|
if (currentUser) {
|
|
await historyDao.updateExecutionStatus(
|
|
batchId,
|
|
1,
|
|
'failed',
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
new Date(),
|
|
warnings.join('\n') || '没有有效的生产订单号可处理'
|
|
)
|
|
}
|
|
const emptyResult: CleanerResult = {
|
|
ordersProcessed: 0,
|
|
materialsDeleted: 0,
|
|
materialsSkipped: 0,
|
|
errors: [...warnings],
|
|
details: [],
|
|
retriedOrders: 0,
|
|
successfulRetries: 0,
|
|
materialsFailed: 0,
|
|
uncertainDeletions: 0
|
|
}
|
|
await this.recordCleanupAudit(0, input, emptyResult)
|
|
return emptyResult
|
|
}
|
|
|
|
authService = new ErpAuthService({
|
|
url: erpConfig.url,
|
|
username: erpConfig.username,
|
|
password: erpConfig.password,
|
|
headless: input.headless ?? true
|
|
})
|
|
|
|
log.info('Logging in to ERP...')
|
|
try {
|
|
await authService.login()
|
|
} catch (error) {
|
|
throw new ErpConnectionError(
|
|
'ERP 登录失败',
|
|
'ERP_LOGIN_FAILED',
|
|
error instanceof Error ? error : undefined
|
|
)
|
|
}
|
|
log.info('Login successful')
|
|
|
|
const totalOrders = validOrderNumbers.length
|
|
this.sendProgress(eventSender, 'ERP 登录成功', (1 / (1 + totalOrders)) * 100, {
|
|
phase: 'login',
|
|
currentOrderIndex: 0,
|
|
totalOrders,
|
|
currentMaterialIndex: 0,
|
|
totalMaterialsInOrder: 0
|
|
})
|
|
|
|
const modifiedInput: CleanerInput = {
|
|
...input,
|
|
orderNumbers: validOrderNumbers,
|
|
onProgress: (message, progress, extra) => {
|
|
this.sendProgress(eventSender, message, progress ?? 0, extra)
|
|
}
|
|
}
|
|
|
|
log.info('Starting cleaning', {
|
|
batchId,
|
|
orderCount: validOrderNumbers.length,
|
|
queryBatchSize: input.queryBatchSize ?? 100,
|
|
processConcurrency: input.processConcurrency ?? 1
|
|
})
|
|
|
|
let cleaner = new CleanerService(authService)
|
|
let result = await cleaner.clean(modifiedInput)
|
|
|
|
// Outer retry: re-login and re-run all orders on fatal crash
|
|
if (result.crashed) {
|
|
log.warn('检测到流程级崩溃,准备外层重试', { batchId })
|
|
|
|
// Save attempt 1 result as crashed
|
|
await this.saveAttemptToDatabase(historyDao, batchId, 1, result)
|
|
|
|
this.sendProgress(eventSender, '流程崩溃,正在重新登录并重试...', 0, {
|
|
phase: 'retry',
|
|
currentOrderIndex: 0,
|
|
totalOrders,
|
|
currentMaterialIndex: 0,
|
|
totalMaterialsInOrder: 0
|
|
})
|
|
|
|
try {
|
|
await authService.close()
|
|
} catch {
|
|
// Browser may already be dead, ignore close errors
|
|
}
|
|
authService = null
|
|
|
|
authService = new ErpAuthService({
|
|
url: erpConfig.url,
|
|
username: erpConfig.username,
|
|
password: erpConfig.password,
|
|
headless: input.headless ?? true
|
|
})
|
|
|
|
try {
|
|
await authService.login()
|
|
log.info('Outer retry: re-login successful', { batchId })
|
|
} catch (loginError) {
|
|
log.error('Outer retry: re-login failed', {
|
|
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)}`
|
|
)
|
|
if (warnings.length > 0) {
|
|
result.errors = [...warnings, ...result.errors]
|
|
}
|
|
await this.recordCleanupAudit(validOrderNumbers.length, input, result)
|
|
// 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: orderInputs.length,
|
|
appVersion
|
|
})
|
|
await historyDao.insertOrderRecords(batchId, 2, orderInputs)
|
|
}
|
|
|
|
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', {
|
|
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) {
|
|
result.errors = [...warnings, ...result.errors]
|
|
}
|
|
|
|
this.sendProgress(eventSender, '清理完成', 100, {
|
|
phase: 'complete',
|
|
currentOrderIndex: totalOrders,
|
|
totalOrders,
|
|
currentMaterialIndex: 0,
|
|
totalMaterialsInOrder: 0
|
|
})
|
|
|
|
log.info('Cleaning completed', {
|
|
batchId,
|
|
processedCount: result.ordersProcessed,
|
|
errorCount: result.errors.length
|
|
})
|
|
|
|
await this.recordCleanupAudit(validOrderNumbers.length, input, result)
|
|
|
|
return result
|
|
} finally {
|
|
if (authService) {
|
|
try {
|
|
await authService.close()
|
|
log.debug('Browser closed')
|
|
} catch (closeError) {
|
|
log.warn('Error closing browser', {
|
|
error: closeError instanceof Error ? closeError.message : String(closeError)
|
|
})
|
|
}
|
|
}
|
|
|
|
if (dbService) {
|
|
try {
|
|
await dbService.disconnect()
|
|
log.debug('Database disconnected')
|
|
} catch (closeError) {
|
|
log.warn('Error disconnecting database', {
|
|
error: closeError instanceof Error ? closeError.message : String(closeError)
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async exportResults(items: ExportResultItem[]): Promise<ExportResultResponse> {
|
|
log.info('Exporting validation results', { count: items.length })
|
|
if (!items || items.length === 0) {
|
|
throw new ValidationError('没有数据可导出', 'VAL_INVALID_INPUT')
|
|
}
|
|
|
|
const exporter = new ResultExporter()
|
|
return exporter.exportValidationResults(items)
|
|
}
|
|
|
|
private sendProgress(
|
|
sender: WebContents,
|
|
message: string,
|
|
progress: number,
|
|
extra?: Partial<CleanerProgress>
|
|
): void {
|
|
try {
|
|
const progressData: CleanerProgress = {
|
|
message,
|
|
progress,
|
|
currentOrderIndex: extra?.currentOrderIndex ?? 0,
|
|
totalOrders: extra?.totalOrders ?? 0,
|
|
currentMaterialIndex: extra?.currentMaterialIndex ?? 0,
|
|
totalMaterialsInOrder: extra?.totalMaterialsInOrder ?? 0,
|
|
currentOrderNumber: extra?.currentOrderNumber,
|
|
phase: extra?.phase ?? 'processing'
|
|
}
|
|
sender.send(IPC_CHANNELS.CLEANER_PROGRESS, progressData)
|
|
} catch (error) {
|
|
log.warn('Failed to send progress event', { error })
|
|
}
|
|
}
|
|
|
|
private async getDatabaseService(): Promise<IDatabaseService> {
|
|
const configManager = ConfigManager.getInstance()
|
|
const config = configManager.getConfig()
|
|
const dbType = configManager.getDatabaseType()
|
|
|
|
if (dbType === 'sqlserver') {
|
|
const dbConfig = config.database.sqlserver
|
|
const sqlServerService = new SqlServerServiceImpl({
|
|
server: dbConfig.server,
|
|
port: dbConfig.port,
|
|
user: dbConfig.username,
|
|
password: dbConfig.password,
|
|
database: dbConfig.database,
|
|
options: {
|
|
encrypt: false,
|
|
trustServerCertificate: dbConfig.trustServerCertificate
|
|
}
|
|
})
|
|
await sqlServerService.connect()
|
|
return sqlServerService
|
|
}
|
|
|
|
if (dbType === 'postgresql') {
|
|
const dbConfig = config.database.postgresql
|
|
const pgService = new PostgreSqlServiceImpl({
|
|
host: dbConfig.host,
|
|
port: dbConfig.port,
|
|
user: dbConfig.username,
|
|
password: dbConfig.password,
|
|
database: dbConfig.database
|
|
})
|
|
await pgService.connect()
|
|
return pgService
|
|
}
|
|
|
|
const dbConfig = config.database.mysql
|
|
const mysqlService = new MySqlServiceImpl({
|
|
host: dbConfig.host,
|
|
port: dbConfig.port,
|
|
user: dbConfig.username,
|
|
password: dbConfig.password,
|
|
database: dbConfig.database
|
|
})
|
|
await mysqlService.connect()
|
|
return mysqlService
|
|
}
|
|
|
|
private async getErpConfig(): Promise<{ url: string; username: string; password: string }> {
|
|
const configManager = ConfigManager.getInstance()
|
|
const globalConfig = configManager.getConfig()
|
|
const erpUrl = globalConfig.erp.url
|
|
|
|
const erpConfigService = UserErpConfigService.getInstance()
|
|
const userConfig = await erpConfigService.getCurrentUserErpConfig()
|
|
|
|
if (!userConfig || !userConfig.username || !userConfig.password) {
|
|
throw new ValidationError(
|
|
'ERP 配置不完整。请在设置中配置 ERP 用户名和密码',
|
|
'VAL_MISSING_REQUIRED'
|
|
)
|
|
}
|
|
|
|
return {
|
|
url: erpUrl,
|
|
username: userConfig.username,
|
|
password: userConfig.password
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build order inputs from ALL mappings, including resolution failures.
|
|
* Deduplicates by orderNumber for resolved mappings, includes all failed mappings.
|
|
*/
|
|
private buildOrderInputs(mappings: OrderMapping[]): InsertOrderInput[] {
|
|
const inputs: InsertOrderInput[] = []
|
|
const seenOrderNumbers = new Set<string>()
|
|
|
|
for (const mapping of mappings) {
|
|
if (mapping.resolved && mapping.orderNumber) {
|
|
// Deduplicate resolved mappings by order number
|
|
if (!seenOrderNumbers.has(mapping.orderNumber)) {
|
|
seenOrderNumbers.add(mapping.orderNumber)
|
|
inputs.push({
|
|
orderNumber: mapping.orderNumber,
|
|
productionId: mapping.productionId
|
|
})
|
|
}
|
|
} else {
|
|
// Resolution failure: use original input as orderNumber identifier
|
|
inputs.push({
|
|
orderNumber: mapping.input,
|
|
productionId: mapping.productionId,
|
|
initialStatus: 'not_found',
|
|
errorMessage: mapping.error || '未在数据库中找到对应的订单号'
|
|
})
|
|
}
|
|
}
|
|
|
|
return inputs
|
|
}
|
|
|
|
private async recordCleanupAudit(
|
|
orderCount: number,
|
|
input: CleanerInput,
|
|
result: CleanerResult
|
|
): Promise<void> {
|
|
const currentUser = SessionManager.getInstance().getUserInfo()
|
|
if (!currentUser) {
|
|
return
|
|
}
|
|
|
|
const status: AuditStatus =
|
|
result.errors.length > 0 && result.materialsDeleted > 0
|
|
? AuditStatus.PARTIAL
|
|
: result.errors.length > 0
|
|
? AuditStatus.FAILURE
|
|
: AuditStatus.SUCCESS
|
|
|
|
logAuditWithCurrentUser(AuditAction.CLEAN, 'MATERIAL_PLAN', status, {
|
|
orderCount,
|
|
dryRun: input.dryRun ?? false,
|
|
queryBatchSize: input.queryBatchSize ?? 100,
|
|
processConcurrency: input.processConcurrency ?? 1,
|
|
materialsDeleted: result.materialsDeleted,
|
|
materialsSkipped: result.materialsSkipped,
|
|
errorCount: result.errors.length
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
// 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(
|
|
batchId,
|
|
attemptNumber,
|
|
detail.orderNumber,
|
|
detail.notFound ? 'erp_not_found' : 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
|
|
)
|
|
|
|
// Insert material details for all materials
|
|
const materialDetails: InsertMaterialDetailInput[] = []
|
|
|
|
for (const deleted of detail.deletedMaterials) {
|
|
materialDetails.push({
|
|
orderNumber: detail.orderNumber,
|
|
materialCode: deleted.materialCode,
|
|
materialName: deleted.materialName,
|
|
rowNumber: deleted.rowNumber,
|
|
result: deleted.outcome,
|
|
reason: null,
|
|
attemptCount: 1,
|
|
finalErrorCategory: null
|
|
})
|
|
}
|
|
|
|
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
|
|
})
|
|
}
|
|
|
|
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 && !isDryRun) {
|
|
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
|
|
}
|
|
}
|
|
}
|