# 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 记录跳过已成功的订单）
- 内层重试逻辑（订单级/物料级）不变
