From a906f34ac21ea397d3ebdf2a70b7cdbc84e4dea9 Mon Sep 17 00:00:00 2001 From: Misaka_Company Date: Thu, 5 Mar 2026 16:19:40 +0800 Subject: [PATCH] feat: auto-generate markdown report after cleaner execution - Add CleanerReportGenerator service to create execution reports - Extend OrderCleanDetail with skippedMaterials for detailed tracking - Record skip reasons for each material (protected range, pending qty, etc.) - Generate timestamped markdown reports in logs/reports/ directory - Reports include execution summary, order details, skip reasons, and errors --- src/main/ipc/cleaner-handler.ts | 23 ++ src/main/services/erp/cleaner.ts | 33 ++- .../report/cleaner-report-generator.ts | 224 ++++++++++++++++++ src/main/types/cleaner.types.ts | 8 + 4 files changed, 286 insertions(+), 2 deletions(-) create mode 100644 src/main/services/report/cleaner-report-generator.ts diff --git a/src/main/ipc/cleaner-handler.ts b/src/main/ipc/cleaner-handler.ts index 10b2a3e..56808fb 100644 --- a/src/main/ipc/cleaner-handler.ts +++ b/src/main/ipc/cleaner-handler.ts @@ -5,6 +5,8 @@ import { OrderNumberResolver } from '../services/erp/order-resolver' import { MySqlService } from '../services/database/mysql' import { SqlServerService } from '../services/database/sql-server' import { ResultExporter } from '../services/excel/result-exporter' +import { CleanerReportGenerator } from '../services/report/cleaner-report-generator' +import { SessionManager } from '../services/user/session-manager' import { createLogger } from '../services/logger' import { withErrorHandling, type IpcResult } from './index' import { ErpConnectionError, ValidationError, DatabaseQueryError } from '../types/errors' @@ -78,6 +80,7 @@ export function registerCleanerHandlers(): void { 'cleaner:run', async (event, input: CleanerInput): Promise> => { const windowId = event.sender.id + const startTime = Date.now() return withErrorHandling(async () => { let authService: ErpAuthService | null = null @@ -195,6 +198,26 @@ export function registerCleanerHandlers(): void { errorCount: result.errors.length }) + // Generate report (silent, user unaware) + 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 + }) + log.info('Report generated', { path: reportPath }) + } catch (reportError) { + log.warn('Failed to generate report', { + error: reportError instanceof Error ? reportError.message : String(reportError) + }) + } + return result } finally { if (authService) { diff --git a/src/main/services/erp/cleaner.ts b/src/main/services/erp/cleaner.ts index 3267a10..4d71592 100644 --- a/src/main/services/erp/cleaner.ts +++ b/src/main/services/erp/cleaner.ts @@ -74,6 +74,21 @@ export class CleanerService { return true } + getSkipReason(params: ShouldDeleteParams): string { + const { rowNumber, pendingQty, materialCode, deleteSet } = params + + if (!deleteSet.has(materialCode)) { + return '物料不在删除清单中' + } + if (rowNumber >= 7000 && rowNumber < 8000) { + return '行号在 7000-7999 范围内(受保护)' + } + if (pendingQty && pendingQty.trim() !== '') { + return '累计待发数量不为空' + } + return '未知原因' + } + /** * Execute the cleaning process * Reference: Python clean() method lines 456-525 @@ -130,7 +145,8 @@ export class CleanerService { orderNumber, materialsDeleted: 0, materialsSkipped: 0, - errors: [message] + errors: [message], + skippedMaterials: [] }) } } @@ -229,7 +245,8 @@ export class CleanerService { orderNumber, materialsDeleted: 0, materialsSkipped: 0, - errors: [] + errors: [], + skippedMaterials: [] } // Query the order (Python lines 187-189) @@ -371,6 +388,18 @@ export class CleanerService { continue } else if (!shouldDelete) { detail.materialsSkipped++ + const reason = this.getSkipReason({ + rowNumber: rowNumInt, + pendingQty, + materialCode, + deleteSet + }) + detail.skippedMaterials.push({ + materialCode, + materialName, + rowNumber: rowNumInt, + reason + }) } } diff --git a/src/main/services/report/cleaner-report-generator.ts b/src/main/services/report/cleaner-report-generator.ts new file mode 100644 index 0000000..6f7ce10 --- /dev/null +++ b/src/main/services/report/cleaner-report-generator.ts @@ -0,0 +1,224 @@ +import path from 'path' +import fs from 'fs' +import { app } from 'electron' +import { createLogger } from '../logger' +import type { CleanerResult, OrderCleanDetail, SkippedMaterial } from '../../types/cleaner.types' + +const log = createLogger('CleanerReportGenerator') + +export interface ReportOptions { + dryRun: boolean + username: string + startTime: number + endTime: number +} + +interface OrderStats { + successCount: number + failureCount: number + successRate: number +} + +export class CleanerReportGenerator { + private readonly reportDir: string + + constructor() { + const logDir = app.isReady() ? app.getPath('logs') : path.join(process.cwd(), 'logs') + this.reportDir = path.join(logDir, 'reports') + this.ensureReportDir() + } + + private ensureReportDir(): void { + if (!fs.existsSync(this.reportDir)) { + fs.mkdirSync(this.reportDir, { recursive: true }) + log.info('Created report directory', { path: this.reportDir }) + } + } + + async generateReport(result: CleanerResult, options: ReportOptions): Promise { + const filePath = this.getReportFilePath() + log.info('Generating cleaner report', { path: filePath }) + + const stats = this.calculateOrderStats(result) + const content = this.buildReportContent(result, options, stats) + + await fs.promises.writeFile(filePath, content, 'utf-8') + log.info('Report generated successfully', { path: filePath }) + + return filePath + } + + private getReportFilePath(): string { + const now = new Date() + const timestamp = now.toISOString().replace(/[:.]/g, '-').slice(0, -5).replace('T', '-') + const fileName = `cleaner-report-${timestamp}.md` + return path.join(this.reportDir, fileName) + } + + private calculateOrderStats(result: CleanerResult): OrderStats { + const totalOrders = result.details.length + const failureCount = result.details.filter((d) => d.errors.length > 0).length + const successCount = totalOrders - failureCount + const successRate = totalOrders > 0 ? (successCount / totalOrders) * 100 : 0 + + return { + successCount, + failureCount, + successRate + } + } + + private buildReportContent( + result: CleanerResult, + options: ReportOptions, + stats: OrderStats + ): string { + const lines: string[] = [] + + lines.push('# ERP 物料清理执行报告') + lines.push('') + + lines.push('## 执行摘要') + lines.push('') + lines.push('| 项目 | 值 |') + lines.push('| -------------- | --------------------------------- |') + lines.push(`| **执行时间** | \`${this.formatDateTime(options.endTime)}\``) + lines.push(`| **执行模式** | \`${options.dryRun ? '模拟运行 (Dry Run)' : '正式执行'}\``) + lines.push(`| **操作用户** | \`${options.username}\``) + lines.push(`| **处理订单数** | \`${result.ordersProcessed}\``) + lines.push(`| **删除物料数** | \`${result.materialsDeleted}\``) + lines.push(`| **跳过物料数** | \`${result.materialsSkipped}\``) + lines.push(`| **错误数量** | \`${result.errors.length}\``) + lines.push(`| **执行耗时** | \`${this.formatDuration(options.startTime, options.endTime)}\``) + lines.push('') + lines.push('---') + lines.push('') + + lines.push('## 执行状态') + lines.push('') + lines.push('| 状态 | 数量 | 百分比 |') + lines.push('| ----------- | ---- | ------ |') + lines.push(`| ✅ 成功订单 | ${stats.successCount} | ${stats.successRate.toFixed(1)}% |`) + lines.push(`| ❌ 失败订单 | ${stats.failureCount} | ${(100 - stats.successRate).toFixed(1)}% |`) + lines.push('') + lines.push('---') + lines.push('') + + lines.push('## 订单处理详情') + lines.push('') + lines.push('| # | 订单号 | 删除数 | 跳过数 | 状态 | 错误信息 |') + lines.push('| --- | -------- | ------ | ------ | ------- | ------------------------ |') + + result.details.forEach((detail, index) => { + const orderNum = index + 1 + const status = detail.errors.length > 0 ? '❌ 失败' : '✅ 成功' + const errorMsg = detail.errors.length > 0 ? detail.errors[0] : '-' + lines.push( + `| ${orderNum} | \`${detail.orderNumber}\` | ${detail.materialsDeleted} | ${detail.materialsSkipped} | ${status} | \`${errorMsg}\` |` + ) + }) + + lines.push('') + lines.push('---') + lines.push('') + + const allSkippedMaterials = this.collectAllSkippedMaterials(result.details) + if (allSkippedMaterials.length > 0) { + lines.push('## 跳过的物料原因说明') + lines.push('') + lines.push('| 订单号 | 物料代码 | 物料名称 | 行号 | 跳过原因 |') + lines.push('| -------- | -------- | -------- | ---- | --------------------------------- |') + + allSkippedMaterials.forEach((skipped) => { + lines.push( + `| \`${skipped.orderNumber}\` | \`${skipped.materialCode}\` | \`${skipped.materialName}\` | ${skipped.rowNumber} | ${skipped.reason} |` + ) + }) + + lines.push('') + lines.push('---') + lines.push('') + } + + if (result.errors.length > 0) { + lines.push('## 错误详情') + lines.push('') + lines.push(`**错误总数**: \`${result.errors.length}\``) + lines.push('') + lines.push('### 错误订单列表') + lines.push('') + + const errorOrders = this.extractErrorOrders(result.details) + errorOrders.forEach((order) => { + lines.push(`- \`${order}\``) + }) + + lines.push('') + lines.push('### 错误详细信息') + lines.push('') + + result.details + .filter((d) => d.errors.length > 0) + .forEach((detail) => { + lines.push(`#### \`${detail.orderNumber}\``) + lines.push('') + lines.push('```') + lines.push(`订单号:${detail.orderNumber}`) + detail.errors.forEach((error) => { + lines.push(`错误:${error}`) + }) + lines.push('```') + lines.push('') + }) + + lines.push('---') + lines.push('') + } + + lines.push(`**报告生成时间**: \`${this.formatDateTime(options.endTime)}\``) + lines.push('**报表版本**: `v1.0`') + + return lines.join('\n') + } + + private collectAllSkippedMaterials( + details: OrderCleanDetail[] + ): Array { + const result: Array = [] + + details.forEach((detail) => { + if (detail.skippedMaterials && detail.skippedMaterials.length > 0) { + detail.skippedMaterials.forEach((skipped) => { + result.push({ + ...skipped, + orderNumber: detail.orderNumber + }) + }) + } + }) + + return result + } + + private extractErrorOrders(details: OrderCleanDetail[]): string[] { + return details.filter((d) => d.errors.length > 0).map((d) => d.orderNumber) + } + + private formatDateTime(timestamp: number): string { + const date = new Date(timestamp) + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + const hours = String(date.getHours()).padStart(2, '0') + const minutes = String(date.getMinutes()).padStart(2, '0') + const seconds = String(date.getSeconds()).padStart(2, '0') + return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}` + } + + private formatDuration(startTime: number, endTime: number): string { + const durationMs = endTime - startTime + const minutes = Math.floor(durationMs / 60000) + const seconds = Math.floor((durationMs % 60000) / 1000) + return `${minutes}分${seconds}秒` + } +} diff --git a/src/main/types/cleaner.types.ts b/src/main/types/cleaner.types.ts index 5f487f0..bcc255c 100644 --- a/src/main/types/cleaner.types.ts +++ b/src/main/types/cleaner.types.ts @@ -27,11 +27,19 @@ export interface CleanerResult { details: OrderCleanDetail[] } +export interface SkippedMaterial { + materialCode: string + materialName: string + rowNumber: number + reason: string +} + export interface OrderCleanDetail { orderNumber: string materialsDeleted: number materialsSkipped: number errors: string[] + skippedMaterials: SkippedMaterial[] } /**