From 7dfa88c2a3429e48ebad8204a83ef03ed7517a4f Mon Sep 17 00:00:00 2001 From: Misaka_Company Date: Mon, 13 Apr 2026 12:06:19 +0800 Subject: [PATCH] refactor(cleaner): remove Markdown report generator Co-Authored-By: Claude Opus 4.6 --- ...026-04-13-cleaner-db-persistence-design.md | 235 +++++++ .../2026-04-13-cleaner-db-persistence-plan.md | 620 ++++++++++++++++++ .../report/cleaner-report-generator.ts | 378 ----------- 3 files changed, 855 insertions(+), 378 deletions(-) create mode 100644 docs/plans/2026-04-13-cleaner-db-persistence-design.md create mode 100644 docs/plans/2026-04-13-cleaner-db-persistence-plan.md delete mode 100644 src/main/services/report/cleaner-report-generator.ts diff --git a/docs/plans/2026-04-13-cleaner-db-persistence-design.md b/docs/plans/2026-04-13-cleaner-db-persistence-design.md new file mode 100644 index 0000000..37852b5 --- /dev/null +++ b/docs/plans/2026-04-13-cleaner-db-persistence-design.md @@ -0,0 +1,235 @@ +# Cleaner 数据库持久化设计 + +## 背景 + +Cleaner 当前使用 Markdown 文件做执行记录持久化,通过 RustFS 上传存储。存在以下问题: + +- 报告是非结构化文本,无法程序化查询和统计 +- 历史记录无法按用户、时间、状态筛选 +- 重试时依赖文件名去重,覆盖了首次执行的崩溃信息 +- 前端需要通过 RustFS 下载报告再解析展示,链路长且脆弱 + +Extractor 已有成熟的数据库持久化模式(`ExtractorOperationHistory` 表 + DAO + 前端弹窗),Cleaner 应复用相同模式。 + +## 设计决策 + +| 决策项 | 选择 | 理由 | +|--------|------|------| +| 表结构 | 独立建表,不与 Extractor 共用 | Cleaner 数据结构差异大(双层、物料级详情),独立更清晰 | +| 记录粒度 | 执行 + 订单 + 物料三层 | 执行表存全局信息,订单表存订单汇总,物料表存操作明细 | +| 批次标识 | `BatchId`(UUID),与 Extractor 一致 | 标准、简洁,不需要嵌入时间戳 | +| 重试记录 | 不覆盖,每次尝试独立写入,用 `AttemptNumber` 区分 | 保留完整审计链,为后续智能跳过提供数据基础 | +| 报告文件 | 移除 Markdown 报告和 RustFS 上传 | 数据库完全替代,报告相关代码(CleanerReportGenerator、generateAndUploadReport)删除 | +| 前端历史 | 独立 CleanerOperationHistoryModal,复用 Extractor 的 UI 模式 | 放在 CleanerPage 上,与 Extractor 的"操作历史"按钮对齐 | + +## 数据库表结构 + +所有表的 schema 为 `ERPAuto`。 + +### 1. `CleanerExecution`(执行级) + +全限定名:`ERPAuto.CleanerExecution` + +一次清理操作(含重试)的全局信息。每次尝试一行记录。 + +| 列名 | 类型 | 说明 | +|------|------|------| +| ID | INT IDENTITY | 自增主键 | +| BatchId | UNIQUEIDENTIFIER | 批次 ID,一次清理操作(含重试)共享 | +| AttemptNumber | INT | 第几次尝试(1=首次,2=外层重试) | +| UserId | INT | 操作用户 ID | +| Username | NVARCHAR(255) | 操作用户名 | +| OperationTime | DATETIME | 操作时间 | +| EndTime | DATETIME | 结束时间 | +| Status | NVARCHAR(50) | pending / success / failed / partial / crashed | +| IsDryRun | BIT | 是否模拟运行 | +| TotalOrders | INT | 订单总数 | +| OrdersProcessed | INT | 已处理订单数 | +| TotalMaterialsDeleted | INT | 总删除物料数 | +| TotalMaterialsSkipped | INT | 总跳过物料数 | +| TotalMaterialsFailed | INT | 总失败物料数 | +| TotalUncertainDeletions | INT | 总不确定删除数 | +| ErrorMessage | NVARCHAR(MAX) | 全局错误信息(如外层崩溃原因) | +| AppVersion | NVARCHAR(20) | 应用版本号 | + +### 2. `CleanerOrderHistory`(订单级) + +全限定名:`ERPAuto.CleanerOrderHistory` + +每个订单在每次尝试中的执行结果。每个订单每次尝试一行记录。 + +| 列名 | 类型 | 说明 | +|------|------|------| +| ID | INT IDENTITY | 自增主键 | +| BatchId | UNIQUEIDENTIFIER | 关联执行表 BatchId | +| AttemptNumber | INT | 关联执行表 AttemptNumber | +| OrderNumber | NVARCHAR(255) | 订单号 | +| Status | NVARCHAR(50) | pending / success / failed | +| MaterialsDeleted | INT | 删除物料数 | +| MaterialsSkipped | INT | 跳过物料数 | +| MaterialsFailed | INT | 删除失败物料数 | +| UncertainDeletions | INT | 不确定删除数 | +| RetryCount | INT | 内层重试次数 | +| RetrySuccess | BIT | 内层重试是否成功 | +| ErrorMessage | NVARCHAR(MAX) | 错误信息 | + +关联方式:`BatchId + AttemptNumber` 关联执行表。 + +### 3. `CleanerMaterialDetail`(物料级) + +全限定名:`ERPAuto.CleanerMaterialDetail` + +每个物料在每次尝试中的操作明细。 + +| 列名 | 类型 | 说明 | +|------|------|------| +| ID | INT IDENTITY | 自增主键 | +| BatchId | UNIQUEIDENTIFIER | 关联执行表 BatchId | +| AttemptNumber | INT | 关联执行表 AttemptNumber | +| OrderNumber | NVARCHAR(255) | 所属订单号 | +| MaterialCode | NVARCHAR(255) | 物料代码 | +| MaterialName | NVARCHAR(255) | 物料名称 | +| RowNumber | INT | 行号 | +| Result | NVARCHAR(50) | deleted / skipped / failed / uncertain | +| Reason | NVARCHAR(MAX) | 跳过/失败原因 | +| AttemptCount | INT | 删除尝试次数 | +| FinalErrorCategory | NVARCHAR(50) | 最终错误分类 | + +关联方式:`BatchId + AttemptNumber + OrderNumber` 关联订单表。 + +### 数据示例 + +首次执行到第 80 个订单时崩溃,外层重试成功完成全部 211 个订单: + +**CleanerExecution** +``` +BatchId=uuid-1, Attempt=1, Status=crashed, TotalOrders=211, Processed=80, ... +BatchId=uuid-1, Attempt=2, Status=success, TotalOrders=211, Processed=211, ... +``` + +**CleanerOrderHistory**(Attempt=1 中部分记录) +``` +BatchId=uuid-1, Attempt=1, Order=SC001, Status=success, Deleted=5, Skipped=1 +BatchId=uuid-1, Attempt=1, Order=SC080, Status=crashed, Error=查询超时 +``` + +**CleanerOrderHistory**(Attempt=2 中部分记录) +``` +BatchId=uuid-1, Attempt=2, Order=SC001, Status=success, Deleted=5, Skipped=1 +BatchId=uuid-1, Attempt=2, Order=SC080, Status=success, Deleted=3, Skipped=0 +BatchId=uuid-1, Attempt=2, Order=SC211, Status=success, Deleted=2, Skipped=0 +``` + +**CleanerMaterialDetail**(SC080 在 Attempt=2 中的物料) +``` +BatchId=uuid-1, Attempt=2, Order=SC080, Material=MAT-001, Result=deleted +BatchId=uuid-1, Attempt=2, Order=SC080, Material=MAT-002, Result=skipped, Reason=不可删除 +``` + +## 写入时机 + +``` +用户点击"执行清理" + → IPC: cleaner:run + → cleaner-handler.ts + → ① BatchId = randomUUID() + → ② 插入 CleanerExecution(Status=pending) + → ③ 插入 CleanerOrderHistory(所有订单,Status=pending) + → ④ 执行清理(CleanerApplicationService.runCleaner) + → ⑤ 更新 CleanerExecution(Status=success/failed/partial/crashed) + → ⑥ 更新 CleanerOrderHistory(每个订单的结果) + → ⑦ 插入 CleanerMaterialDetail(每个物料的操作明细) + → ⑧ 如果 crashed → 外层重试 + → 插入新的 CleanerExecution(AttemptNumber=2, Status=pending) + → 插入新的 CleanerOrderHistory(AttemptNumber=2, Status=pending) + → 重新执行 + → 更新执行表和订单表状态 + → 插入物料明细 +``` + +- 步骤 ②③:在 `cleaner-handler.ts` 中,执行前写入,记录操作人、全局配置、待处理订单 +- 步骤 ⑤⑥⑦:在 `CleanerApplicationService` 中,执行完成后回调 DAO 写入结果 +- 步骤 ⑧:外层重试时,三张表都新增 AttemptNumber=2 的记录,首次尝试的数据完整保留 + +## 变更清单 + +### 新增文件 + +1. **`src/main/services/database/cleaner-operation-history-dao.ts`** + - `CleanerOperationHistoryDAO` 类 + - 执行表操作:insertExecution、updateExecutionStatus + - 订单表操作:insertOrderRecords、updateOrderStatus + - 物料表操作:insertMaterialDetails + - 查询操作:getBatches、getBatchDetails(含订单+物料)、deleteBatch + - 参考 `ExtractorOperationHistoryDAO` 的模式,表名使用 `ERPAuto.CleanerExecution`、`ERPAuto.CleanerOrderHistory`、`ERPAuto.CleanerMaterialDetail` + +2. **`src/main/types/cleaner-history.types.ts`** + - `CleanerExecutionRecord`、`CleanerOrderRecord`、`CleanerMaterialRecord` + - `CleanerBatchStats`、`InsertCleanerExecutionInput`、`InsertOrderInput`、`InsertMaterialDetailInput` + +3. **`src/renderer/src/components/CleanerOperationHistoryModal.tsx`** + - 操作历史弹窗,复用 ExtractorOperationHistoryModal 的 UI 模式 + - 批次列表(按 BatchId 聚合,显示操作时间、用户、状态、成功/失败数,区分多次尝试) + - 展开明细(订单列表,每订单的删除/跳过/失败数) + - 物料级详情(第二层展开,显示每个物料的操作结果) + - 管理员可按用户筛选、可删除批次 + +### 修改文件 + +4. **`src/main/ipc/cleaner-handler.ts`** + - `CLEANER_RUN` handler 中:执行前插入 execution + order 的 pending 记录,执行后更新结果 + - 新增 IPC handlers:`CLEANER_HISTORY_BATCHES`、`CLEANER_HISTORY_DETAILS`、`CLEANER_HISTORY_DELETE` + +5. **`src/main/services/cleaner/cleaner-application-service.ts`** + - `runCleaner` 接收 `batchId` 参数 + - 移除 `generateExecutionId()` 函数 + - 移除 `generateAndUploadReport()` 方法 + - 移除 `executionId` 相关逻辑 + - 外层重试时,通过 DAO 写入 AttemptNumber=2 的执行记录和订单记录,不覆盖首次尝试 + - 执行完成后回调 DAO 写入订单结果和物料明细 + +6. **`src/main/ipc/index.ts`** + - 注册新的 cleaner history IPC handlers + +7. **`src/preload/api/cleaner.ts`** + - 新增 IPC 调用方法:getBatches、getBatchDetails、deleteBatch + +8. **`src/preload/index.d.ts`** + - `CleanerAPI` 接口新增 getBatches、getBatchDetails、deleteBatch 类型声明 + +9. **`src/renderer/src/pages/CleanerPage.tsx`** + - 新增"操作历史"按钮 + - 引入 CleanerOperationHistoryModal + +### 删除文件 + +10. **`src/main/services/report/cleaner-report-generator.ts`** + - 整个文件删除,报告生成逻辑不再需要 + +### 可选清理 + +11. **`src/renderer/src/components/ReportViewerDialog.tsx`** + - 基于 RustFS 文件的报告查看器,Cleaner 不再使用 + - 如果 Extractor 不共用此组件,可删除 + +12. **`src/renderer/src/components/ReportAnalysisDialog.tsx`** + - 基于报告文件的分析,Cleaner 不再使用 + - 后续可基于数据库重新实现统计分析 + +## 移除的概念 + +| 概念 | 原因 | +|------|------| +| ExecutionId(CLN-时间戳-随机) | 为文件名设计,数据库用 UUID | +| generateExecutionId() | 随 ExecutionId 一起移除 | +| CleanerReportGenerator | Markdown 报告生成器,被数据库替代 | +| generateAndUploadReport() | RustFS 上传链路,被数据库写入替代 | +| 报告文件名去重 | 数据库 UUID 天然唯一 | +| 重试覆盖旧报告 | 数据库保留所有尝试记录 | + +## 不涉及的部分 + +- Extractor 的持久化逻辑不变 +- 数据库 schema 迁移(需 DBA 创建表,应用层只做 CRUD) +- 后续智能跳过功能(基于已有 success 记录跳过已成功的订单) +- 内层重试逻辑(订单级/物料级)不变 diff --git a/docs/plans/2026-04-13-cleaner-db-persistence-plan.md b/docs/plans/2026-04-13-cleaner-db-persistence-plan.md new file mode 100644 index 0000000..c8b24ae --- /dev/null +++ b/docs/plans/2026-04-13-cleaner-db-persistence-plan.md @@ -0,0 +1,620 @@ +# Cleaner 数据库持久化实施计划 + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 将 Cleaner 的执行记录从 Markdown 文件持久化迁移到数据库(三张表:执行级、订单级、物料级),并在前端新增操作历史弹窗。 + +**Architecture:** 新建 `CleanerOperationHistoryDAO` 操作三张表(`ERPAuto.CleanerExecution`、`ERPAuto.CleanerOrderHistory`、`ERPAuto.CleanerMaterialDetail`),通过新增 IPC handlers 暴露给前端。执行前写入 pending 记录,执行后更新结果和物料明细。外层重试时新增 AttemptNumber=2 的记录,不覆盖首次尝试。移除 Markdown 报告生成和 RustFS 上传链路。 + +**Tech Stack:** TypeScript, Electron IPC, SQL (MySQL/SQL Server/PostgreSQL via existing DAO+dialect pattern), React + +--- + +## Task 1: 新增类型定义 + +**Files:** +- Create: `src/main/types/cleaner-history.types.ts` + +**Step 1: 创建类型文件** + +```typescript +// src/main/types/cleaner-history.types.ts + +/** + * Cleaner 操作历史类型定义 + */ + +/** 执行级记录 */ +export interface CleanerExecutionRecord { + id?: number + batchId: string + attemptNumber: number + userId: number + username: string + operationTime: Date + endTime: Date | null + status: string + isDryRun: boolean + totalOrders: number + ordersProcessed: number + totalMaterialsDeleted: number + totalMaterialsSkipped: number + totalMaterialsFailed: number + totalUncertainDeletions: number + errorMessage: string | null + appVersion: string | null +} + +/** 订单级记录 */ +export interface CleanerOrderRecord { + id?: number + batchId: string + attemptNumber: number + orderNumber: string + status: string + materialsDeleted: number + materialsSkipped: number + materialsFailed: number + uncertainDeletions: number + retryCount: number + retrySuccess: boolean + errorMessage: string | null +} + +/** 物料级记录 */ +export interface CleanerMaterialRecord { + id?: number + batchId: string + attemptNumber: number + orderNumber: string + materialCode: string + materialName: string + rowNumber: number + result: string + reason: string | null + attemptCount: number + finalErrorCategory: string | null +} + +/** 批次统计(前端列表展示用) */ +export interface CleanerBatchStats { + batchId: string + userId: number + username: string + operationTime: string + /** 最终一次尝试的状态 */ + status: string + totalAttempts: number + totalOrders: number + ordersProcessed: number + totalMaterialsDeleted: number + totalMaterialsFailed: number + successCount: number + failedCount: number + isDryRun: boolean +} + +/** 插入执行记录的输入 */ +export interface InsertCleanerExecutionInput { + batchId: string + attemptNumber: number + userId: number + username: string + isDryRun: boolean + totalOrders: number + appVersion: string +} + +/** 插入订单记录的输入 */ +export interface InsertOrderInput { + orderNumber: string +} + +/** 插入物料明细的输入 */ +export interface InsertMaterialDetailInput { + orderNumber: string + materialCode: string + materialName: string + rowNumber: number + result: string + reason: string | null + attemptCount: number + finalErrorCategory: string | null +} + +/** 查询批次的选项 */ +export interface GetCleanerBatchesOptions { + limit?: number + offset?: number + usernames?: string[] +} +``` + +**Step 2: 验证类型检查通过** + +Run: `npm run typecheck` +Expected: PASS(新文件不影响现有代码) + +**Step 3: Commit** + +``` +feat(cleaner): add type definitions for cleaner operation history +``` + +--- + +## Task 2: 新增 DAO 层 + +**Files:** +- Create: `src/main/services/database/cleaner-operation-history-dao.ts` + +**Step 1: 创建 DAO 文件** + +参考 `extractor-operation-history-dao.ts` 的模式(`create()` 获取数据库连接、`createDialect()` 处理 SQL 方言、`trackDuration()` 记录耗时)。表名使用 `ERPAuto` schema。 + +关键方法: + +```typescript +export class CleanerOperationHistoryDAO { + private dbService: IDatabaseService | null = null + private dialect: SqlDialect | null = null + + // ===== 执行表 ===== + private getExecutionTableName(): string { + return this.getDialect().quoteTableName('ERPAuto', 'CleanerExecution') + } + + async insertExecution(input: InsertCleanerExecutionInput): Promise + async updateExecutionStatus(batchId: string, attemptNumber: number, status: string, ordersProcessed: number, materialsDeleted: number, materialsSkipped: number, materialsFailed: number, uncertainDeletions: number, endTime: Date, errorMessage?: string): Promise + + // ===== 订单表 ===== + private getOrderTableName(): string { + return this.getDialect().quoteTableName('ERPAuto', 'CleanerOrderHistory') + } + + async insertOrderRecords(batchId: string, attemptNumber: number, orders: InsertOrderInput[]): Promise + async updateOrderStatus(batchId: string, attemptNumber: number, orderNumber: string, status: string, materialsDeleted: number, materialsSkipped: number, materialsFailed: number, uncertainDeletions: number, retryCount: number, retrySuccess: boolean, errorMessage?: string): Promise + + // ===== 物料表 ===== + private getMaterialTableName(): string { + return this.getDialect().quoteTableName('ERPAuto', 'CleanerMaterialDetail') + } + + async insertMaterialDetails(batchId: string, attemptNumber: number, details: InsertMaterialDetailInput[]): Promise + + // ===== 查询 ===== + async getBatches(userId?: number, options?: GetCleanerBatchesOptions): Promise + async getBatchDetails(batchId: string): Promise<{ executions: CleanerExecutionRecord[]; orders: CleanerOrderRecord[] }> + async getMaterialDetails(batchId: string, attemptNumber: number, orderNumber: string): Promise + + // ===== 删除 ===== + async deleteBatch(batchId: string, requestingUserId: number, isAdmin: boolean): Promise<{ success: boolean; error?: string }> + + // ===== 列询执行级记录 ===== + async getMaterialDetails(batchId: string, attemptNumber: number, orderNumber: string): Promise + + // ===== 删除 ===== + async deleteBatch(batchId: string, requestingUserId: number, isAdmin: boolean): Promise<{ success: boolean; error?: string }> + async disconnect(): Promise +} +``` + +`getBatches` 查询逻辑: +- `GROUP BY BatchId`,取 `MAX(AttemptNumber)` 对应的执行记录状态作为最终状态 +- 汇总订单级的 success/failed 计数 +- 支持 userId 过滤(普通用户)和 usernames 过滤(管理员) +- 支持分页 + +`getBatchDetails` 查询逻辑: +- 返回某 BatchId 下所有 execution 记录 + order 记录 +- 前端用 attemptNumber 区分不同尝试 + +每个 INSERT/UPDATE 使用 `trackDuration()` 包裹,error handling 与 Extractor DAO 一致。 + +**Step 2: 验证类型检查通过** + +Run: `npm run typecheck` +Expected: PASS + +**Step 3: Commit** + +``` +feat(cleaner): add CleanerOperationHistoryDAO for three-table persistence +``` + +--- + +## Task 3: 新增 IPC channels + +**Files:** +- Modify: `src/shared/ipc-channels.ts` + +**Step 1: 添加 cleaner history channels** + +在现有的 `CLEANER_PROGRESS` 之后添加: + +```typescript +// Cleaner history +CLEANER_HISTORY_GET_BATCHES: 'cleanerHistory:getBatches', +CLEANER_HISTORY_GET_BATCH_DETAILS: 'cleanerHistory:getBatchDetails', +CLEANER_HISTORY_GET_MATERIAL_DETAILS: 'cleanerHistory:getMaterialDetails', +CLEANER_HISTORY_DELETE_BATCH: 'cleanerHistory:deleteBatch', +``` + +**Step 2: Commit** + +``` +feat(cleaner): add IPC channels for cleaner operation history +``` + +--- + +## Task 4: 新增 IPC handler + +**Files:** +- Create: `src/main/ipc/cleaner-history-handler.ts` +- Modify: `src/main/ipc/index.ts` — 注册新 handler + +**Step 1: 创建 cleaner-history-handler.ts** + +参考 `operation-history-handler.ts` 的模式。四个 handler: + +- `CLEANER_HISTORY_GET_BATCHES`:获取批次列表,Admin 看全部,User 看自己的 +- `CLEANER_HISTORY_GET_BATCH_DETAILS`:获取某个批次的执行记录和订单记录 +- `CLEANER_HISTORY_GET_MATERIAL_DETAILS`:获取某个订单的物料明细 +- `CLEANER_HISTORY_DELETE_BATCH`:删除批次,权限校验与 Extractor 一致 + +```typescript +export function registerCleanerHistoryHandlers(): void { + const dao = new CleanerOperationHistoryDAO() + + ipcMain.handle( + IPC_CHANNELS.CLEANER_HISTORY_GET_BATCHES, + async (event, options?: GetCleanerBatchesOptions): Promise> => { + return withErrorHandling(async () => { + const currentUser = SessionManager.getInstance().getUserInfo() + if (!currentUser) throw new Error('用户未登录') + const userId = currentUser.userType === 'Admin' ? undefined : currentUser.id + return dao.getBatches(userId, options) + }, 'cleanerHistory:getBatches') + } + ) + + ipcMain.handle( + IPC_CHANNELS.CLEANER_HISTORY_GET_BATCH_DETAILS, + async (event, batchId: string): Promise> => { + // ... 与 operation-history-handler 的 getBatchDetails 模式一致 + } + ) + + ipcMain.handle( + IPC_CHANNELS.CLEANER_HISTORY_GET_MATERIAL_DETAILS, + async (event, batchId: string, attemptNumber: number, orderNumber: string): Promise> => { + // ... + } + ) + + ipcMain.handle( + IPC_CHANNELS.CLEANER_HISTORY_DELETE_BATCH, + async (event, batchId: string): Promise> => { + // ... 权限校验后删除三张表的记录 + } + ) +} +``` + +**Step 2: 在 index.ts 中注册** + +在 `registerIpcHandlers()` 中添加 `registerCleanerHistoryHandlers()` 调用,并在顶部添加 import。 + +**Step 3: 验证类型检查通过** + +Run: `npm run typecheck` +Expected: PASS + +**Step 4: Commit** + +``` +feat(cleaner): add IPC handlers for cleaner operation history +``` + +--- + +## Task 5: 新增 Preload API + +**Files:** +- Modify: `src/preload/api/cleaner.ts` — 新增 history 方法 +- Modify: `src/preload/index.d.ts` — 新增类型声明 + +**Step 1: 在 cleaner.ts 中新增 history 方法** + +```typescript +import type { + CleanerBatchStats, + CleanerExecutionRecord, + CleanerOrderRecord, + CleanerMaterialRecord, + GetCleanerBatchesOptions +} from '../../main/types/cleaner-history.types' + +// 在 cleanerApi 对象中追加: +getHistoryBatches: (options?: GetCleanerBatchesOptions): Promise> => + invokeIpc(IPC_CHANNELS.CLEANER_HISTORY_GET_BATCHES, options), + +getHistoryBatchDetails: (batchId: string): Promise> => + invokeIpc(IPC_CHANNELS.CLEANER_HISTORY_GET_BATCH_DETAILS, batchId), + +getHistoryMaterialDetails: (batchId: string, attemptNumber: number, orderNumber: string): Promise> => + invokeIpc(IPC_CHANNELS.CLEANER_HISTORY_GET_MATERIAL_DETAILS, batchId, attemptNumber, orderNumber), + +deleteHistoryBatch: (batchId: string): Promise> => + invokeIpc(IPC_CHANNELS.CLEANER_HISTORY_DELETE_BATCH, batchId), +``` + +**Step 2: 在 index.d.ts 中更新 CleanerAPI 接口** + +在 `CleanerAPI` 接口中添加对应的类型声明,与实际 API 对齐。 + +**Step 3: 验证类型检查通过** + +Run: `npm run typecheck` +Expected: PASS + +**Step 4: Commit** + +``` +feat(cleaner): add preload API for cleaner operation history +``` + +--- + +## Task 6: 改造 CleanerApplicationService — 写入数据库记录 + +**Files:** +- Modify: `src/main/services/cleaner/cleaner-application-service.ts` + +这是核心变更。`runCleaner` 方法需要: + +**Step 1: 修改 runCleaner 签名,接收 batchId 和 DAO** + +```typescript +async runCleaner( + eventSender: WebContents, + input: CleanerInput, + batchId: string, + historyDao: CleanerOperationHistoryDAO +): Promise +``` + +**Step 2: 移除报告相关代码** + +- 删除 `import { app } from 'electron'`(仅用于 `app.getVersion()`) +- 删除 `generateExecutionId()` 函数 +- 删除 `generateAndUploadReport()` 方法 +- 删除所有 `executionId` 相关变量和日志 + +**Step 3: 插入 pending 订单记录** + +在登录成功后、执行清理前,调用 `historyDao.insertOrderRecords(batchId, 1, orders)` 写入 pending 状态的订单记录。 + +**Step 4: 执行后更新订单记录和写入物料明细** + +清理完成后遍历 `result.details`(`OrderCleanDetail[]`),对每个订单: +- 调用 `historyDao.updateOrderStatus(...)` 更新订单结果 +- 调用 `historyDao.insertMaterialDetails(...)` 写入物料明细(skipped + failed 材料全部写入) + +**Step 5: 更新执行记录状态** + +调用 `historyDao.updateExecutionStatus(batchId, 1, ...)` 更新为最终状态。 + +**Step 6: 外层重试改造** + +当 `result.crashed` 时: +1. 调用 `historyDao.updateExecutionStatus(batchId, 1, 'crashed', ...)` 标记首次尝试为 crashed +2. 调用 `historyDao.insertExecution({ batchId, attemptNumber: 2, ... })` 创建第二次尝试 +3. 调用 `historyDao.insertOrderRecords(batchId, 2, orders)` 写入第二次尝试的 pending 订单 +4. 重新登录并执行 +5. 执行后更新 AttemptNumber=2 的订单和物料记录 + +**Step 7: 验证类型检查通过** + +Run: `npm run typecheck` +Expected: PASS + +**Step 8: Commit** + +``` +refactor(cleaner): replace report generation with database persistence +``` + +--- + +## Task 7: 改造 cleaner-handler.ts — 执行前后写入 + +**Files:** +- Modify: `src/main/ipc/cleaner-handler.ts` + +**Step 1: 修改 CLEANER_RUN handler** + +在调用 `cleanerService.runCleaner()` 之前: +1. 获取当前用户信息 +2. `batchId = randomUUID()` +3. 创建 `CleanerOperationHistoryDAO` 实例 +4. 调用 `dao.insertExecution({ batchId, attemptNumber: 1, userId, username, isDryRun, totalOrders, appVersion })` + +将 `batchId` 和 `dao` 传入 `runCleaner()`。 + +执行完成后(无论成功失败),更新执行记录的最终状态。 + +**Step 2: 移除 app.getVersion() 调用** + +`appVersion` 改为在 handler 层获取(因为 handler 已有 electron 访问权限),传给 DAO。 + +**Step 3: 验证类型检查通过** + +Run: `npm run typecheck` +Expected: PASS + +**Step 4: Commit** + +``` +refactor(cleaner): write execution records to database in IPC handler +``` + +--- + +## Task 8: 删除 Markdown 报告生成器 + +**Files:** +- Delete: `src/main/services/report/cleaner-report-generator.ts` + +**Step 1: 删除文件** + +删除 `cleaner-report-generator.ts`。 + +**Step 2: 检查是否有其他文件引用它** + +搜索 `cleaner-report-generator` 或 `CleanerReportGenerator`,如有引用则一并移除(主要是 `cleaner-application-service.ts` 中已删除的 import)。 + +**Step 3: 验证编译通过** + +Run: `npm run typecheck` +Expected: PASS + +**Step 4: Commit** + +``` +refactor(cleaner): remove Markdown report generator +``` + +--- + +## Task 9: 前端 — 新增操作历史弹窗 + +**Files:** +- Create: `src/renderer/src/components/CleanerOperationHistoryModal.tsx` +- Modify: `src/renderer/src/pages/CleanerPage.tsx` + +**Step 1: 创建 CleanerOperationHistoryModal** + +参考 `ExtractorOperationHistoryModal.tsx` 的 UI 模式和代码结构。关键差异: + +- 数据源使用 `window.electron.cleaner.getHistoryBatches()` 等新 API +- 批次列表增加"尝试次数"列和"模拟运行"标识 +- 展开明细时,顶部显示执行级信息(尝试次数、crashed 状态等) +- 订单表格增加 deleted/skipped/failed/uncertain 列 +- 订单行可再次展开查看物料明细(调用 `getHistoryMaterialDetails`) +- 管理员按用户筛选、删除功能与 Extractor 一致 + +**Step 2: 在 CleanerPage 中添加"操作历史"按钮和弹窗** + +- 在 `CleanerToolbar` 中添加"操作历史"按钮(或直接在 CleanerPage 添加) +- 引入 `CleanerOperationHistoryModal` 组件 +- 传入 `user` 和 `isOpen/onClose` 控制 + +**Step 3: 验证类型检查通过** + +Run: `npm run typecheck` +Expected: PASS + +**Step 4: Commit** + +``` +feat(cleaner): add operation history modal with database-backed records +``` + +--- + +## Task 10: 更新 renderer 类型定义 + +**Files:** +- Modify: `src/renderer/src/hooks/cleaner/types.ts` + +**Step 1: 添加 history 相关类型** + +在 types.ts 中添加前端需要的类型(或直接从 `cleaner-history.types.ts` import,根据项目的前端类型引用模式决定)。 + +**Step 2: 验证类型检查通过** + +Run: `npm run typecheck` +Expected: PASS + +**Step 3: Commit** + +``` +feat(cleaner): add renderer types for cleaner operation history +``` + +--- + +## Task 11: 清理旧代码 + +**Files:** +- Modify: `src/renderer/src/hooks/cleaner/types.ts` — 移除 `CleanerReportData.crashed`(如果不再需要) +- 检查 `ReportViewerDialog.tsx`、`ReportAnalysisDialog.tsx` 是否仍被 Cleaner 使用 + +**Step 1: 清理 renderer 中不再需要的类型** + +- `CleanerReportData` 中如果 `crashed` 字段已无用,移除 +- 确认 `CleanerPhase` 的 `'retry'` 值是否仍需要(前端进度通知仍在使用,保留) + +**Step 2: 评估 ReportViewerDialog 和 ReportAnalysisDialog** + +这两个组件目前用于查看 Markdown 报告文件。如果 Cleaner 不再使用它们: +- 在 CleanerPage 中移除相关按钮和引用 +- 不删除组件本身(Extractor 可能仍在使用,后续统一清理) + +**Step 3: 验证编译和类型检查通过** + +Run: `npm run typecheck && npm run lint` +Expected: PASS + +**Step 4: Commit** + +``` +chore(cleaner): clean up legacy report-related code +``` + +--- + +## Task 12: 集成测试 + +**Step 1: 运行完整类型检查** + +Run: `npm run typecheck` +Expected: PASS + +**Step 2: 运行 lint** + +Run: `npm run lint` +Expected: PASS + +**Step 3: 运行单元测试** + +Run: `npm run test` +Expected: PASS + +**Step 4: 手动验证** + +1. 启动 `npm run dev` +2. 在 Cleaner 页面执行一次清理(模拟运行) +3. 检查数据库三张表是否正确写入 +4. 点击"操作历史"按钮,验证批次列表和详情展示 +5. 模拟崩溃场景(如果可以),验证外层重试写入 AttemptNumber=2 的记录 +6. 用管理员账号验证用户筛选和删除功能 + +--- + +## 执行顺序 + +``` +Task 1 (types) → Task 2 (DAO) → Task 3 (IPC channels) → Task 4 (IPC handler) + → Task 5 (preload) → Task 6 (CleanerApplicationService) → Task 7 (cleaner-handler) + → Task 8 (删除报告生成器) → Task 10 (renderer types) → Task 9 (前端弹窗) + → Task 11 (清理) → Task 12 (集成测试) +``` + +Task 9 和 Task 10 可以并行。Task 8 必须在 Task 6、7 之后。 diff --git a/src/main/services/report/cleaner-report-generator.ts b/src/main/services/report/cleaner-report-generator.ts deleted file mode 100644 index 9b10e56..0000000 --- a/src/main/services/report/cleaner-report-generator.ts +++ /dev/null @@ -1,378 +0,0 @@ -import path from 'path' -import fs from 'fs' -import { app } from 'electron' -import { createLogger } from '../logger' -import type { - CleanerResult, - FailedMaterial, - OrderCleanDetail, - SkippedMaterial -} from '../../types/cleaner.types' - -const log = createLogger('CleanerReportGenerator') - -export interface ReportOptions { - dryRun: boolean - username: string - startTime: number - endTime: number - executionId: string - appVersion: string -} - -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(options.executionId) - 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(executionId: string): string { - const fileName = `cleaner-report-${executionId}.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(`| **执行 ID** | \`${options.executionId}\``) - lines.push(`| **应用版本** | \`${options.appVersion}\``) - 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}\``) - if (result.materialsFailed > 0) { - lines.push(`| **删除失败物料数** | \`${result.materialsFailed}\``) - } - if (result.uncertainDeletions > 0) { - lines.push(`| **不确定删除数** | \`${result.uncertainDeletions}\``) - } - lines.push(`| **错误数量** | \`${result.errors.length}\``) - if (result.retriedOrders > 0) { - lines.push(`| **重试订单数** | \`${result.retriedOrders}\``) - lines.push(`| **成功重试数** | \`${result.successfulRetries}\``) - } - 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)}% |`) - if (result.retriedOrders > 0) { - const retrySuccessRate = - result.retriedOrders > 0 ? (result.successfulRetries / result.retriedOrders) * 100 : 0 - lines.push(`| 🔄 重试订单 | ${result.retriedOrders} | 100% |`) - lines.push(`| ✅ 成功重试 | ${result.successfulRetries} | ${retrySuccessRate.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 - let status = detail.errors.length > 0 ? '❌ 失败' : '✅ 成功' - - // Override status if retry was successful - if (detail.retrySuccess) { - status = '✅ 重试成功' - } else if (detail.retryCount > 0 && !detail.retrySuccess) { - status = '❌ 重试失败' - } - - const errorMsg = detail.errors.length > 0 ? detail.errors[0] : '-' - const retryInfo = detail.retryCount > 0 ? ` [重试${detail.retryCount}次]` : '' - lines.push( - `| ${orderNum} | \`${detail.orderNumber}\` | ${detail.materialsDeleted} | ${detail.materialsSkipped} | ${status}${retryInfo} | \`${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('') - } - - // Failed materials section - const allFailedMaterials = this.collectAllFailedMaterials(result.details) - if (allFailedMaterials.length > 0) { - lines.push('## 删除失败的物料详情') - lines.push('') - lines.push(`**失败物料总数**: \`${allFailedMaterials.length}\``) - lines.push('') - lines.push( - '| 订单号 | 物料代码 | 物料名称 | 行号 | 最终结果 | 失败原因类别 | 尝试次数 |' - ) - lines.push( - '| -------- | -------- | -------- | ---- | -------------- | ------------------ | -------- |' - ) - - allFailedMaterials.forEach((failed) => { - lines.push( - `| \`${failed.orderNumber}\` | \`${failed.materialCode}\` | \`${failed.materialName}\` | ${failed.rowNumber} | ${failed.finalOutcome} | ${failed.finalErrorCategory ?? '-'} | ${failed.attempts.length} |` - ) - }) - - lines.push('') - - // Detailed attempt records - lines.push('### 失败物料尝试记录') - lines.push('') - - allFailedMaterials.forEach((failed) => { - lines.push( - `#### \`${failed.materialCode}\` (${failed.materialName}) — 订单 \`${failed.orderNumber}\`` - ) - lines.push('') - failed.attempts.forEach((attempt, idx) => { - lines.push(`${idx + 1}. **第${attempt.attempt}次尝试** - 结果: ${attempt.outcome}`) - if (attempt.errorMessage) { - lines.push(` - 错误: ${attempt.errorMessage}`) - } - lines.push( - ` - 行号: ${attempt.rowNumberBefore} → ${attempt.rowNumberAfter} | 物料数: ${attempt.materialCountBefore} → ${attempt.materialCountAfter} | 耗时: ${attempt.durationMs}ms` - ) - }) - 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('') - } - - // Add retry details section - if (result.retriedOrders > 0) { - lines.push('## 重试执行详情') - lines.push('') - lines.push( - `**重试订单总数**: \`${result.retriedOrders}\` | **成功**: \`${result.successfulRetries}\` | **失败**: \`${result.retriedOrders - result.successfulRetries}\`` - ) - lines.push('') - - const retriedDetails = result.details.filter((d) => d.retryCount > 0) - - if (retriedDetails.length > 0) { - lines.push('### 重试订单列表') - lines.push('') - lines.push('| 订单号 | 重试次数 | 重试结果 | 重试时间 |') - lines.push('| -------- | -------- | -------- | ------------ |') - - retriedDetails.forEach((detail) => { - const retryStatus = detail.retrySuccess ? '✅ 成功' : '❌ 失败' - const retryTime = detail.retriedAt ? this.formatDateTime(detail.retriedAt) : '-' - lines.push( - `| \`${detail.orderNumber}\` | ${detail.retryCount} | ${retryStatus} | ${retryTime} |` - ) - }) - - lines.push('') - lines.push('### 重试尝试详细记录') - lines.push('') - - retriedDetails.forEach((detail) => { - lines.push(`#### \`${detail.orderNumber}\``) - lines.push('') - lines.push(`- **重试次数**: ${detail.retryCount}`) - lines.push(`- **最终结果**: ${detail.retrySuccess ? '✅ 成功' : '❌ 失败'}`) - - if (detail.retryAttempts && detail.retryAttempts.length > 0) { - lines.push('') - lines.push('**重试尝试记录**:') - lines.push('') - detail.retryAttempts.forEach((attempt, idx) => { - lines.push( - `${idx + 1}. **第${attempt.attempt}次尝试** - ${this.formatDateTime(attempt.timestamp)}` - ) - lines.push(` - 错误:${attempt.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 collectAllFailedMaterials( - details: OrderCleanDetail[] - ): Array { - const result: Array = [] - - details.forEach((detail) => { - if (detail.failedMaterials && detail.failedMaterials.length > 0) { - detail.failedMaterials.forEach((failed) => { - result.push({ - ...failed, - 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}秒` - } -}