Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb495c7a93 | ||
|
|
33ffc0406d | ||
|
|
8fa4d6c16d | ||
|
|
cb59dda727 | ||
|
|
fbcaa11b1c | ||
|
|
b5b8af078d | ||
|
|
5b43d5a60c | ||
|
|
936c98a023 | ||
|
|
c661a12287 | ||
|
|
1cd6660774 | ||
|
|
1f06fd275e | ||
|
|
c86508989b | ||
|
|
838783e384 | ||
|
|
d5028bfcf4 | ||
|
|
b2b29e9754 | ||
|
|
cba3c8c4f0 | ||
|
|
343cb24234 | ||
|
|
4ce5b91340 | ||
|
|
1f033eb315 | ||
|
|
681f3ba517 |
288
docs/README.md
Normal file
288
docs/README.md
Normal file
@@ -0,0 +1,288 @@
|
||||
# ERPAuto 文档指南
|
||||
|
||||
本文档是 ERPAuto 项目文档的**分类指南和编写规范**,用于:
|
||||
|
||||
- 指导文档的分类和归档
|
||||
- 规范新文档的命名和格式
|
||||
- 帮助开发者快速定位应创建的文档类型
|
||||
|
||||
---
|
||||
|
||||
## 📚 文档分类体系
|
||||
|
||||
### 一、按受众分类
|
||||
|
||||
| 分类 | 目录 | 受众 | 内容示例 |
|
||||
| -------------- | ------------ | ---------- | ---------------------------- |
|
||||
| **用户文档** | `user/` | 最终用户 | 使用指南、配置说明、迁移指南 |
|
||||
| **开发者文档** | `developer/` | 开发人员 | 架构设计、开发指南、模块说明 |
|
||||
| **内部文档** | `internal/` | 项目维护者 | 分析报告、优化计划、模板 |
|
||||
|
||||
### 二、按内容类型分类
|
||||
|
||||
| 分类 | 目录 | 内容特点 |
|
||||
| ------------ | ----------------------------------- | -------------------------------- |
|
||||
| **功能特性** | `features/` | 功能说明、业务流程、重构概览 |
|
||||
| **调试指南** | `debugging/` | 调试指南、快速参考、故障排查 |
|
||||
| **测试文档** | `testing/` | 测试计划、测试报告、测试基础设施 |
|
||||
| **模块文档** | `cleaner/`, `browser/`, `database/` | 特定模块的详细文档 |
|
||||
| **计划文档** | `plans/` | 设计方案、实施计划 |
|
||||
| **发布说明** | `releases/` | 版本发布记录 |
|
||||
|
||||
---
|
||||
|
||||
## 📝 文档命名规范
|
||||
|
||||
### 文件名格式
|
||||
|
||||
```
|
||||
<主题>-<子主题>-<类型>.md
|
||||
```
|
||||
|
||||
**规则:**
|
||||
|
||||
- 使用**小写字母**和**连字符** (`-`)
|
||||
- 不使用空格、下划线或大写字母
|
||||
- 保持简短但有描述性
|
||||
|
||||
**示例:**
|
||||
|
||||
```
|
||||
✅ user-override-match-feature.md
|
||||
✅ settings-partial-save.md
|
||||
✅ cleaner-validation-flow.md
|
||||
✅ test-improvement-plan.md
|
||||
|
||||
❌ UserOverrideMatchFeature.md # 驼峰命名
|
||||
❌ user_override_match.md # 下划线
|
||||
❌ user override match.md # 空格
|
||||
```
|
||||
|
||||
### 类型后缀约定
|
||||
|
||||
| 后缀 | 用途 | 示例 |
|
||||
| -------------- | ---------- | ----------------------------------------- |
|
||||
| `-guide.md` | 指南类文档 | `erp-login-debug-guide.md` |
|
||||
| `-quickref.md` | 快速参考 | `erp-login-debug-quickref.md` |
|
||||
| `-flow.md` | 流程说明 | `settings-save-button-flow.md` |
|
||||
| `-feature.md` | 功能特性 | `user-override-match-feature.md` |
|
||||
| `-plan.md` | 计划方案 | `test-improvement-plan.md` |
|
||||
| `-report.md` | 报告总结 | `TEST_REVIEW_REPORT.md` |
|
||||
| `-template.md` | 模板文件 | `cleaner-execution-report-template.md` |
|
||||
| `-overview.md` | 概览说明 | `validation-handler-refactor-overview.md` |
|
||||
|
||||
### Plans 路径专用命名规范
|
||||
|
||||
`plans/` 目录使用**日期前缀**命名法,便于按时间排序和管理:
|
||||
|
||||
```
|
||||
<YYYY-MM-DD>-<描述>-<类型>.md
|
||||
```
|
||||
|
||||
**类型标识:**
|
||||
|
||||
| 类型后缀 | 用途 | 内容重点 |
|
||||
| ------------ | -------- | -------------------------------------- |
|
||||
| `-plan.md` | 实施计划 | 任务分解、时间线、资源分配、风险评估 |
|
||||
| `-design.md` | 设计方案 | 技术架构、接口设计、数据模型、决策理由 |
|
||||
|
||||
**示例:**
|
||||
|
||||
```
|
||||
✅ 2026-04-13-cleaner-db-persistence-plan.md
|
||||
✅ 2026-04-13-cleaner-db-persistence-design.md
|
||||
✅ 2026-04-05-postgresql-integration-plan.md
|
||||
✅ 2026-04-05-postgresql-integration-design.md
|
||||
|
||||
❌ cleaner-db-plan.md # 缺少日期
|
||||
❌ 2026-4-13-cleaner-db-plan.md # 日期格式不正确(应为 2026-04-13)
|
||||
❌ 2026-04-13-plan-cleaner-db.md # 类型应在最后
|
||||
```
|
||||
|
||||
**相关文件对:**
|
||||
同一个项目通常会有配对的计划和设计文档:
|
||||
|
||||
- `2026-04-13-cleaner-db-persistence-plan.md` - 实施计划
|
||||
- `2026-04-13-cleaner-db-persistence-design.md` - 设计方案
|
||||
|
||||
使用相同的日期和描述,便于关联查找。
|
||||
|
||||
---
|
||||
|
||||
## 🗂️ 分类决策流程
|
||||
|
||||
创建新文档时,按以下流程确定分类:
|
||||
|
||||
```
|
||||
1. 文档的读者是谁?
|
||||
├─ 最终用户 → user/
|
||||
├─ 开发者 → developer/
|
||||
└─ 项目维护者 → internal/ 或其他专业目录
|
||||
|
||||
2. 文档的内容类型是什么?
|
||||
├─ 功能说明 → features/
|
||||
├─ 调试帮助 → debugging/
|
||||
├─ 测试相关 → testing/
|
||||
├─ 模块特定 → cleaner/, browser/, database/
|
||||
├─ 设计计划 → plans/
|
||||
└─ 发布记录 → releases/
|
||||
|
||||
3. 是否需要快速参考?
|
||||
└─ 是 → 使用 -quickref.md 后缀,放入 debugging/
|
||||
```
|
||||
|
||||
### 分类示例
|
||||
|
||||
| 文档主题 | 正确分类 | 理由 |
|
||||
| ----------------- | ----------------------------------------------------- | ------------ |
|
||||
| 如何配置 ERP 连接 | `user/config-erp-guide.md` | 用户操作指南 |
|
||||
| 日志系统设计 | `developer/architecture/logging-design.md` | 架构设计 |
|
||||
| 登录失败排查 | `debugging/erp-login-quickref.md` | 调试快速参考 |
|
||||
| 测试覆盖率分析 | `testing/coverage-analysis-report.md` | 测试报告 |
|
||||
| 物料清理模块说明 | `cleaner/module-overview.md` | 模块文档 |
|
||||
| 新功能实施计划 | `plans/2026-04-14-new-feature-implementation-plan.md` | 实施计划 |
|
||||
| 数据库设计文档 | `plans/2026-04-14-database-schema-design.md` | 设计方案 |
|
||||
|
||||
---
|
||||
|
||||
## 📋 文档模板
|
||||
|
||||
### 指南类文档模板
|
||||
|
||||
```markdown
|
||||
# <功能> 指南
|
||||
|
||||
## 概述
|
||||
|
||||
简要说明文档目的和适用范围。
|
||||
|
||||
## 前置条件
|
||||
|
||||
列出使用该功能的前提条件。
|
||||
|
||||
## 操作步骤
|
||||
|
||||
1. 步骤一
|
||||
2. 步骤二
|
||||
3. 步骤三
|
||||
|
||||
## 常见问题
|
||||
|
||||
- Q: 问题描述
|
||||
- A: 解决方案
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [相关文档 1](link)
|
||||
- [相关文档 2](link)
|
||||
```
|
||||
|
||||
### 功能特性文档模板
|
||||
|
||||
```markdown
|
||||
# <功能名称> 特性说明
|
||||
|
||||
## 背景
|
||||
|
||||
为什么需要这个功能。
|
||||
|
||||
## 功能描述
|
||||
|
||||
功能的具体行为和预期结果。
|
||||
|
||||
## 用户流程
|
||||
|
||||
用户使用该功能的完整流程。
|
||||
|
||||
## 技术实现
|
||||
|
||||
关键实现细节(可选)。
|
||||
|
||||
## 影响范围
|
||||
|
||||
对其他模块的影响。
|
||||
```
|
||||
|
||||
### 计划文档模板
|
||||
|
||||
```markdown
|
||||
# <项目名称> 实施计划
|
||||
|
||||
## 目标
|
||||
|
||||
项目要达成的目标。
|
||||
|
||||
## 范围
|
||||
|
||||
包含和不包含的内容。
|
||||
|
||||
## 任务分解
|
||||
|
||||
- [ ] 任务 1
|
||||
- [ ] 任务 2
|
||||
- [ ] 任务 3
|
||||
|
||||
## 时间线
|
||||
|
||||
预计开始和结束时间。
|
||||
|
||||
## 风险
|
||||
|
||||
可能的风险和应对措施。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 文档维护
|
||||
|
||||
### 文档更新
|
||||
|
||||
- **功能变更时**:同步更新相关文档
|
||||
- **发现错误时**:立即修正并提交
|
||||
- **版本发布时**:更新 `releases/` 中的发布说明
|
||||
|
||||
### 文档审查
|
||||
|
||||
新文档创建后,应检查:
|
||||
|
||||
- [ ] 分类是否正确
|
||||
- [ ] 命名是否符合规范
|
||||
- [ ] 是否使用了模板
|
||||
- [ ] 链接是否有效
|
||||
- [ ] 是否添加到相关索引
|
||||
|
||||
### 废弃文档
|
||||
|
||||
过时的文档应:
|
||||
|
||||
1. 在文件顶部添加 `> ⚠️ 已废弃` 标记
|
||||
2. 说明废弃原因和替代文档
|
||||
3. 在下一个版本发布时移至 `archive/` 目录
|
||||
|
||||
---
|
||||
|
||||
## 📖 根目录文档
|
||||
|
||||
`docs/` 根目录仅保留**跨category的项目级文档**:
|
||||
|
||||
| 文档 | 用途 |
|
||||
| -------------------------------------- | ----------------- |
|
||||
| `README.md` | 本文档 - 分类指南 |
|
||||
| `build-and-release-guide.md` | 构建和发布流程 |
|
||||
| `portable-auto-update-architecture.md` | 便携版更新架构 |
|
||||
|
||||
**原则**:如果文档不属于特定分类,且对项目整体重要,可放在根目录。
|
||||
|
||||
---
|
||||
|
||||
## 🔍 找不到合适的分类?
|
||||
|
||||
如果现有分类无法容纳你的文档:
|
||||
|
||||
1. 检查是否可以归入 `internal/`(内部文档)
|
||||
2. 考虑是否应该创建新的子目录
|
||||
3. 在提交 PR 时说明分类理由
|
||||
|
||||
---
|
||||
|
||||
_最后更新:2026-04-14_
|
||||
@@ -13,14 +13,14 @@ Extractor 已有成熟的数据库持久化模式(`ExtractorOperationHistory`
|
||||
|
||||
## 设计决策
|
||||
|
||||
| 决策项 | 选择 | 理由 |
|
||||
|--------|------|------|
|
||||
| 表结构 | 独立建表,不与 Extractor 共用 | Cleaner 数据结构差异大(双层、物料级详情),独立更清晰 |
|
||||
| 记录粒度 | 执行 + 订单 + 物料三层 | 执行表存全局信息,订单表存订单汇总,物料表存操作明细 |
|
||||
| 批次标识 | `BatchId`(UUID),与 Extractor 一致 | 标准、简洁,不需要嵌入时间戳 |
|
||||
| 重试记录 | 不覆盖,每次尝试独立写入,用 `AttemptNumber` 区分 | 保留完整审计链,为后续智能跳过提供数据基础 |
|
||||
| 报告文件 | 移除 Markdown 报告和 RustFS 上传 | 数据库完全替代,报告相关代码(CleanerReportGenerator、generateAndUploadReport)删除 |
|
||||
| 前端历史 | 独立 CleanerOperationHistoryModal,复用 Extractor 的 UI 模式 | 放在 CleanerPage 上,与 Extractor 的"操作历史"按钮对齐 |
|
||||
| 决策项 | 选择 | 理由 |
|
||||
| -------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------- |
|
||||
| 表结构 | 独立建表,不与 Extractor 共用 | Cleaner 数据结构差异大(双层、物料级详情),独立更清晰 |
|
||||
| 记录粒度 | 执行 + 订单 + 物料三层 | 执行表存全局信息,订单表存订单汇总,物料表存操作明细 |
|
||||
| 批次标识 | `BatchId`(UUID),与 Extractor 一致 | 标准、简洁,不需要嵌入时间戳 |
|
||||
| 重试记录 | 不覆盖,每次尝试独立写入,用 `AttemptNumber` 区分 | 保留完整审计链,为后续智能跳过提供数据基础 |
|
||||
| 报告文件 | 移除 Markdown 报告和 RustFS 上传 | 数据库完全替代,报告相关代码(CleanerReportGenerator、generateAndUploadReport)删除 |
|
||||
| 前端历史 | 独立 CleanerOperationHistoryModal,复用 Extractor 的 UI 模式 | 放在 CleanerPage 上,与 Extractor 的"操作历史"按钮对齐 |
|
||||
|
||||
## 数据库表结构
|
||||
|
||||
@@ -32,25 +32,25 @@ Extractor 已有成熟的数据库持久化模式(`ExtractorOperationHistory`
|
||||
|
||||
一次清理操作(含重试)的全局信息。每次尝试一行记录。
|
||||
|
||||
| 列名 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| 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) | 应用版本号 |
|
||||
| 列名 | 类型 | 说明 |
|
||||
| ----------------------- | ---------------- | ---------------------------------------------- |
|
||||
| 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`(订单级)
|
||||
|
||||
@@ -58,20 +58,20 @@ Extractor 已有成熟的数据库持久化模式(`ExtractorOperationHistory`
|
||||
|
||||
每个订单在每次尝试中的执行结果。每个订单每次尝试一行记录。
|
||||
|
||||
| 列名 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| 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) | 错误信息 |
|
||||
| 列名 | 类型 | 说明 |
|
||||
| ------------------ | ---------------- | -------------------------- |
|
||||
| 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` 关联执行表。
|
||||
|
||||
@@ -81,19 +81,19 @@ Extractor 已有成熟的数据库持久化模式(`ExtractorOperationHistory`
|
||||
|
||||
每个物料在每次尝试中的操作明细。
|
||||
|
||||
| 列名 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| 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) | 最终错误分类 |
|
||||
| 列名 | 类型 | 说明 |
|
||||
| ------------------ | ---------------- | -------------------------------------- |
|
||||
| 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` 关联订单表。
|
||||
|
||||
@@ -102,18 +102,21 @@ Extractor 已有成熟的数据库持久化模式(`ExtractorOperationHistory`
|
||||
首次执行到第 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
|
||||
@@ -121,6 +124,7 @@ 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=不可删除
|
||||
@@ -218,14 +222,14 @@ BatchId=uuid-1, Attempt=2, Order=SC080, Material=MAT-002, Result=skipped, Reason
|
||||
|
||||
## 移除的概念
|
||||
|
||||
| 概念 | 原因 |
|
||||
|------|------|
|
||||
| ExecutionId(CLN-时间戳-随机) | 为文件名设计,数据库用 UUID |
|
||||
| generateExecutionId() | 随 ExecutionId 一起移除 |
|
||||
| CleanerReportGenerator | Markdown 报告生成器,被数据库替代 |
|
||||
| generateAndUploadReport() | RustFS 上传链路,被数据库写入替代 |
|
||||
| 报告文件名去重 | 数据库 UUID 天然唯一 |
|
||||
| 重试覆盖旧报告 | 数据库保留所有尝试记录 |
|
||||
| 概念 | 原因 |
|
||||
| ------------------------------ | --------------------------------- |
|
||||
| ExecutionId(CLN-时间戳-随机) | 为文件名设计,数据库用 UUID |
|
||||
| generateExecutionId() | 随 ExecutionId 一起移除 |
|
||||
| CleanerReportGenerator | Markdown 报告生成器,被数据库替代 |
|
||||
| generateAndUploadReport() | RustFS 上传链路,被数据库写入替代 |
|
||||
| 报告文件名去重 | 数据库 UUID 天然唯一 |
|
||||
| 重试覆盖旧报告 | 数据库保留所有尝试记录 |
|
||||
|
||||
## 不涉及的部分
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
## Task 1: 新增类型定义
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `src/main/types/cleaner-history.types.ts`
|
||||
|
||||
**Step 1: 创建类型文件**
|
||||
@@ -146,6 +147,7 @@ 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 文件**
|
||||
@@ -165,47 +167,101 @@ export class CleanerOperationHistoryDAO {
|
||||
}
|
||||
|
||||
async insertExecution(input: InsertCleanerExecutionInput): Promise<boolean>
|
||||
async updateExecutionStatus(batchId: string, attemptNumber: number, status: string, ordersProcessed: number, materialsDeleted: number, materialsSkipped: number, materialsFailed: number, uncertainDeletions: number, endTime: Date, errorMessage?: string): Promise<boolean>
|
||||
async updateExecutionStatus(
|
||||
batchId: string,
|
||||
attemptNumber: number,
|
||||
status: string,
|
||||
ordersProcessed: number,
|
||||
materialsDeleted: number,
|
||||
materialsSkipped: number,
|
||||
materialsFailed: number,
|
||||
uncertainDeletions: number,
|
||||
endTime: Date,
|
||||
errorMessage?: string
|
||||
): Promise<boolean>
|
||||
|
||||
// ===== 订单表 =====
|
||||
private getOrderTableName(): string {
|
||||
return this.getDialect().quoteTableName('ERPAuto', 'CleanerOrderHistory')
|
||||
}
|
||||
|
||||
async insertOrderRecords(batchId: string, attemptNumber: number, orders: InsertOrderInput[]): Promise<boolean>
|
||||
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<boolean>
|
||||
async insertOrderRecords(
|
||||
batchId: string,
|
||||
attemptNumber: number,
|
||||
orders: InsertOrderInput[]
|
||||
): Promise<boolean>
|
||||
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<boolean>
|
||||
|
||||
// ===== 物料表 =====
|
||||
private getMaterialTableName(): string {
|
||||
return this.getDialect().quoteTableName('ERPAuto', 'CleanerMaterialDetail')
|
||||
}
|
||||
|
||||
async insertMaterialDetails(batchId: string, attemptNumber: number, details: InsertMaterialDetailInput[]): Promise<boolean>
|
||||
async insertMaterialDetails(
|
||||
batchId: string,
|
||||
attemptNumber: number,
|
||||
details: InsertMaterialDetailInput[]
|
||||
): Promise<boolean>
|
||||
|
||||
// ===== 查询 =====
|
||||
async getBatches(userId?: number, options?: GetCleanerBatchesOptions): Promise<CleanerBatchStats[]>
|
||||
async getBatchDetails(batchId: string): Promise<{ executions: CleanerExecutionRecord[]; orders: CleanerOrderRecord[] }>
|
||||
async getMaterialDetails(batchId: string, attemptNumber: number, orderNumber: string): Promise<CleanerMaterialRecord[]>
|
||||
async getBatches(
|
||||
userId?: number,
|
||||
options?: GetCleanerBatchesOptions
|
||||
): Promise<CleanerBatchStats[]>
|
||||
async getBatchDetails(
|
||||
batchId: string
|
||||
): Promise<{ executions: CleanerExecutionRecord[]; orders: CleanerOrderRecord[] }>
|
||||
async getMaterialDetails(
|
||||
batchId: string,
|
||||
attemptNumber: number,
|
||||
orderNumber: string
|
||||
): Promise<CleanerMaterialRecord[]>
|
||||
|
||||
// ===== 删除 =====
|
||||
async deleteBatch(batchId: string, requestingUserId: number, isAdmin: boolean): Promise<{ success: boolean; error?: string }>
|
||||
async deleteBatch(
|
||||
batchId: string,
|
||||
requestingUserId: number,
|
||||
isAdmin: boolean
|
||||
): Promise<{ success: boolean; error?: string }>
|
||||
|
||||
// ===== 列询执行级记录 =====
|
||||
async getMaterialDetails(batchId: string, attemptNumber: number, orderNumber: string): Promise<CleanerMaterialRecord[]>
|
||||
async getMaterialDetails(
|
||||
batchId: string,
|
||||
attemptNumber: number,
|
||||
orderNumber: string
|
||||
): Promise<CleanerMaterialRecord[]>
|
||||
|
||||
// ===== 删除 =====
|
||||
async deleteBatch(batchId: string, requestingUserId: number, isAdmin: boolean): Promise<{ success: boolean; error?: string }>
|
||||
async deleteBatch(
|
||||
batchId: string,
|
||||
requestingUserId: number,
|
||||
isAdmin: boolean
|
||||
): Promise<{ success: boolean; error?: string }>
|
||||
async disconnect(): Promise<void>
|
||||
}
|
||||
```
|
||||
|
||||
`getBatches` 查询逻辑:
|
||||
|
||||
- `GROUP BY BatchId`,取 `MAX(AttemptNumber)` 对应的执行记录状态作为最终状态
|
||||
- 汇总订单级的 success/failed 计数
|
||||
- 支持 userId 过滤(普通用户)和 usernames 过滤(管理员)
|
||||
- 支持分页
|
||||
|
||||
`getBatchDetails` 查询逻辑:
|
||||
|
||||
- 返回某 BatchId 下所有 execution 记录 + order 记录
|
||||
- 前端用 attemptNumber 区分不同尝试
|
||||
|
||||
@@ -227,6 +283,7 @@ feat(cleaner): add CleanerOperationHistoryDAO for three-table persistence
|
||||
## Task 3: 新增 IPC channels
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/shared/ipc-channels.ts`
|
||||
|
||||
**Step 1: 添加 cleaner history channels**
|
||||
@@ -252,6 +309,7 @@ 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
|
||||
|
||||
@@ -282,14 +340,24 @@ export function registerCleanerHistoryHandlers(): void {
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CLEANER_HISTORY_GET_BATCH_DETAILS,
|
||||
async (event, batchId: string): Promise<IpcResult<{ executions: CleanerExecutionRecord[]; orders: CleanerOrderRecord[] }>> => {
|
||||
async (
|
||||
event,
|
||||
batchId: string
|
||||
): Promise<
|
||||
IpcResult<{ executions: CleanerExecutionRecord[]; orders: CleanerOrderRecord[] }>
|
||||
> => {
|
||||
// ... 与 operation-history-handler 的 getBatchDetails 模式一致
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CLEANER_HISTORY_GET_MATERIAL_DETAILS,
|
||||
async (event, batchId: string, attemptNumber: number, orderNumber: string): Promise<IpcResult<CleanerMaterialRecord[]>> => {
|
||||
async (
|
||||
event,
|
||||
batchId: string,
|
||||
attemptNumber: number,
|
||||
orderNumber: string
|
||||
): Promise<IpcResult<CleanerMaterialRecord[]>> => {
|
||||
// ...
|
||||
}
|
||||
)
|
||||
@@ -323,6 +391,7 @@ 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` — 新增类型声明
|
||||
|
||||
@@ -374,6 +443,7 @@ feat(cleaner): add preload API for cleaner operation history
|
||||
## Task 6: 改造 CleanerApplicationService — 写入数据库记录
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/main/services/cleaner/cleaner-application-service.ts`
|
||||
|
||||
这是核心变更。`runCleaner` 方法需要:
|
||||
@@ -403,6 +473,7 @@ async runCleaner(
|
||||
**Step 4: 执行后更新订单记录和写入物料明细**
|
||||
|
||||
清理完成后遍历 `result.details`(`OrderCleanDetail[]`),对每个订单:
|
||||
|
||||
- 调用 `historyDao.updateOrderStatus(...)` 更新订单结果
|
||||
- 调用 `historyDao.insertMaterialDetails(...)` 写入物料明细(skipped + failed 材料全部写入)
|
||||
|
||||
@@ -413,6 +484,7 @@ async runCleaner(
|
||||
**Step 6: 外层重试改造**
|
||||
|
||||
当 `result.crashed` 时:
|
||||
|
||||
1. 调用 `historyDao.updateExecutionStatus(batchId, 1, 'crashed', ...)` 标记首次尝试为 crashed
|
||||
2. 调用 `historyDao.insertExecution({ batchId, attemptNumber: 2, ... })` 创建第二次尝试
|
||||
3. 调用 `historyDao.insertOrderRecords(batchId, 2, orders)` 写入第二次尝试的 pending 订单
|
||||
@@ -435,11 +507,13 @@ 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` 实例
|
||||
@@ -469,6 +543,7 @@ 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: 删除文件**
|
||||
@@ -495,6 +570,7 @@ refactor(cleaner): remove Markdown report generator
|
||||
## Task 9: 前端 — 新增操作历史弹窗
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `src/renderer/src/components/CleanerOperationHistoryModal.tsx`
|
||||
- Modify: `src/renderer/src/pages/CleanerPage.tsx`
|
||||
|
||||
@@ -531,6 +607,7 @@ 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 相关类型**
|
||||
@@ -553,6 +630,7 @@ 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 使用
|
||||
|
||||
@@ -564,6 +642,7 @@ feat(cleaner): add renderer types for cleaner operation history
|
||||
**Step 2: 评估 ReportViewerDialog 和 ReportAnalysisDialog**
|
||||
|
||||
这两个组件目前用于查看 Markdown 报告文件。如果 Cleaner 不再使用它们:
|
||||
|
||||
- 在 CleanerPage 中移除相关按钮和引用
|
||||
- 不删除组件本身(Extractor 可能仍在使用,后续统一清理)
|
||||
|
||||
|
||||
@@ -8,13 +8,13 @@
|
||||
|
||||
## 设计决策
|
||||
|
||||
| 决策项 | 选择 | 理由 |
|
||||
|---|---|---|
|
||||
| 重试层级 | CleanerApplicationService | 崩溃后浏览器不可用,必须重新登录 |
|
||||
| 重试范围 | 全部订单重新跑 | 简单可靠,物料删除是幂等操作 |
|
||||
| 最大重试次数 | 1 次 | 覆盖瞬态故障,不过度消耗时间 |
|
||||
| 触发条件 | result.crashed === true | 仅 outer catch 触发时才重试 |
|
||||
| 报告去重 | 执行 ID | 用户点击执行时生成,重试不变 |
|
||||
| 决策项 | 选择 | 理由 |
|
||||
| ------------ | ------------------------- | -------------------------------- |
|
||||
| 重试层级 | CleanerApplicationService | 崩溃后浏览器不可用,必须重新登录 |
|
||||
| 重试范围 | 全部订单重新跑 | 简单可靠,物料删除是幂等操作 |
|
||||
| 最大重试次数 | 1 次 | 覆盖瞬态故障,不过度消耗时间 |
|
||||
| 触发条件 | result.crashed === true | 仅 outer catch 触发时才重试 |
|
||||
| 报告去重 | 执行 ID | 用户点击执行时生成,重试不变 |
|
||||
|
||||
## 变更清单
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
```typescript
|
||||
export interface CleanerResult {
|
||||
// ... 现有字段
|
||||
crashed?: boolean // true = outer catch triggered, 流程级崩溃
|
||||
crashed?: boolean // true = outer catch triggered, 流程级崩溃
|
||||
}
|
||||
```
|
||||
|
||||
@@ -87,6 +87,7 @@ runCleaner(eventSender, input) {
|
||||
生成时机: `runCleaner()` 入口处,在 ERP 登录之前。重试时同一个 executionId 不变。
|
||||
|
||||
用途:
|
||||
|
||||
- 报告文件名: `cleaner-report-CLN-20260410112930-A7FK.md`
|
||||
- RustFS 存储路径中包含该 ID,重试时覆盖同一文件
|
||||
- 报告内容中显示该 ID
|
||||
@@ -98,13 +99,13 @@ runCleaner(eventSender, input) {
|
||||
在执行摘要表格中新增字段:
|
||||
|
||||
```markdown
|
||||
| 项目 | 值 |
|
||||
| ---------------- | --------------------------------- |
|
||||
| **执行 ID** | `CLN-20260410112930-A7FK` | ← 新增
|
||||
| **应用版本** | `1.11.1` | ← 新增
|
||||
| **执行时间** | `2026-04-10 11:29:30` |
|
||||
| **执行模式** | `正式执行` |
|
||||
| ... | ... |
|
||||
| 项目 | 值 |
|
||||
| ------------ | ------------------------- | ------ |
|
||||
| **执行 ID** | `CLN-20260410112930-A7FK` | ← 新增 |
|
||||
| **应用版本** | `1.11.1` | ← 新增 |
|
||||
| **执行时间** | `2026-04-10 11:29:30` |
|
||||
| **执行模式** | `正式执行` |
|
||||
| ... | ... |
|
||||
```
|
||||
|
||||
- **执行 ID**: 从 ReportOptions 传入
|
||||
@@ -118,8 +119,8 @@ export interface ReportOptions {
|
||||
username: string
|
||||
startTime: number
|
||||
endTime: number
|
||||
executionId: string // ← 新增
|
||||
appVersion: string // ← 新增
|
||||
executionId: string // ← 新增
|
||||
appVersion: string // ← 新增
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
292
docs/plans/2026-04-14-cleaner-post-1.11.1-improvement-plan.md
Normal file
292
docs/plans/2026-04-14-cleaner-post-1.11.1-improvement-plan.md
Normal file
@@ -0,0 +1,292 @@
|
||||
# Cleaner v1.11.1 之后更新内容改进计划
|
||||
|
||||
本文档基于 `v1.11.1..v1.12.3` 区间内已完成的前端审查结果整理而成,目标不是重复提交记录,而是为后续实现人员提供一份可以直接排期和落地的改进路线图。计划范围仅覆盖 Cleaner 相关前端改进,不扩展到主进程 DAO、IPC 或数据库结构重构。
|
||||
|
||||
## 1. 背景与范围
|
||||
|
||||
本计划覆盖 `v1.11.1` 之后到当前最新版本 `v1.12.3` 的 Cleaner 前端相关更新,重点关注以下变化:
|
||||
|
||||
- 新增 Cleaner 操作历史弹窗
|
||||
- 用数据库持久化替代原有 Markdown 报告查看路径
|
||||
- 为执行结果补充失败与不确定删除统计
|
||||
- 引入 `React.lazy` 和 `BatchItem` 拆分来降低页面负担
|
||||
|
||||
本次计划的核心目标是:
|
||||
|
||||
- 先修复当前历史弹窗与执行结果展示中的稳定性问题
|
||||
- 再优化首屏加载和复杂列表交互性能
|
||||
- 最后补齐长期可维护性和可扩展性基础
|
||||
|
||||
默认审查区间固定为 `v1.11.1..v1.12.3`,默认文档语言为中文,默认落点为 `docs/plans/`。
|
||||
|
||||
## 2. 当前状态总结
|
||||
|
||||
这轮更新已经做对了几件重要的事情:
|
||||
|
||||
- Cleaner 历史记录已经完成数据库化,前端不再依赖旧的 Markdown 报告浏览流
|
||||
- `CleanerOperationHistoryModal` 被独立成单独组件,并通过 `BatchItem` 局部拆分降低兄弟节点联动重渲染
|
||||
- `CleanerPage` 已经开始使用 `React.lazy` 引入历史弹窗与执行报告相关组件
|
||||
- `ExecutionReportDialog` 已经补充 `materialsFailed` 和 `uncertainDeletions` 的展示能力
|
||||
|
||||
这些改动说明整体方向是正确的,但从 React 最佳实践和后续维护成本看,当前实现仍然存在几个明确的改进空间:异步缓存策略不够稳、按需加载没有完全生效、复杂列表的扩展能力有限、前端回归保护不足。
|
||||
|
||||
## 3. 主要改进项
|
||||
|
||||
### P0 立刻修
|
||||
|
||||
#### 3.1 修正历史详情与物料详情的缓存时机
|
||||
|
||||
问题:
|
||||
|
||||
- 当前历史批次详情和物料详情会在请求发起前就标记为“已加载”
|
||||
- 如果首次请求失败,后续再次展开不会重试,用户会长期看到空详情或误导性空状态
|
||||
|
||||
目标:
|
||||
|
||||
- 只在请求成功后写入缓存
|
||||
- 失败后允许再次展开重新请求
|
||||
- 在 UI 上保留现有交互风格,不做视觉重设计
|
||||
|
||||
建议方向:
|
||||
|
||||
- 将详情加载状态拆成 `idle / loading / success / error`
|
||||
- `detailsLoadedRef` 和 `loadedMaterialsRef` 只在成功后更新
|
||||
- 对失败场景提供自然重试路径,优先采用“再次展开即重试”的方式
|
||||
|
||||
预期收益:
|
||||
|
||||
- 避免瞬时请求失败被错误地永久缓存
|
||||
- 提高历史查看功能的稳定性和用户信任感
|
||||
|
||||
#### 3.2 将 Cleaner 历史弹窗改成真正条件挂载
|
||||
|
||||
问题:
|
||||
|
||||
- 当前 `CleanerPage` 虽然使用了 `React.lazy`,但历史弹窗组件仍然会在页面渲染时被挂入树中
|
||||
- 这会导致对应 chunk 仍在首屏阶段就被加载,未达到真正按需加载的效果
|
||||
|
||||
目标:
|
||||
|
||||
- 历史弹窗只在用户打开时才参与渲染和加载
|
||||
- 避免进入 Cleaner 页面就提前下载历史功能代码
|
||||
|
||||
建议方向:
|
||||
|
||||
- 采用条件渲染而不是仅保留 `isOpen` 控制
|
||||
- 延续当前交互样式和打开方式,不调整页面布局
|
||||
|
||||
预期收益:
|
||||
|
||||
- 降低 Cleaner 页面的首屏负担
|
||||
- 更符合 `bundle-conditional` 类最佳实践
|
||||
|
||||
#### 3.3 补最小前端回归测试
|
||||
|
||||
问题:
|
||||
|
||||
- 本轮新增了历史弹窗、异步详情展开和执行结果增强,但前端侧缺少对应测试保护
|
||||
|
||||
目标:
|
||||
|
||||
- 为关键行为建立最小可行回归测试
|
||||
- 优先补组件/行为测试,不新增端到端测试要求
|
||||
|
||||
建议方向:
|
||||
|
||||
- 覆盖历史弹窗未打开时不触发懒加载模块请求
|
||||
- 覆盖批次详情和物料详情首次失败后再次展开可重试
|
||||
- 覆盖管理员筛选切换后请求参数与结果一致
|
||||
|
||||
预期收益:
|
||||
|
||||
- 降低后续修复和优化时的回归风险
|
||||
- 为后续分页、交互优化提供安全网
|
||||
|
||||
### P1 本周优化
|
||||
|
||||
#### 3.4 为历史列表增加分页能力
|
||||
|
||||
问题:
|
||||
|
||||
- 当前历史列表和明细表格按全量数据渲染,随着批次数量、订单数量和物料数量增加,性能风险会上升
|
||||
|
||||
目标:
|
||||
|
||||
- 让历史列表在数据增长后仍保持可接受的打开和滚动体验
|
||||
|
||||
建议方向:
|
||||
|
||||
- 默认优先采用分页,不先引入虚拟列表库
|
||||
- 先做批次列表分页,再评估是否需要对订单或物料明细做进一步优化
|
||||
|
||||
预期收益:
|
||||
|
||||
- 控制渲染体量
|
||||
- 降低复杂列表在中等数据规模下的卡顿风险
|
||||
|
||||
#### 3.5 管理员筛选切换使用 `startTransition`
|
||||
|
||||
问题:
|
||||
|
||||
- 管理员切换用户筛选时会立即触发批次列表刷新,后续数据量增长后可能影响点击反馈
|
||||
|
||||
目标:
|
||||
|
||||
- 保持筛选按钮点击响应流畅
|
||||
- 将非紧急更新降级处理
|
||||
|
||||
建议方向:
|
||||
|
||||
- 将筛选触发的列表刷新包装到 `startTransition`
|
||||
- 保持现有筛选交互模型不变
|
||||
|
||||
预期收益:
|
||||
|
||||
- 降低筛选切换时的阻塞感
|
||||
- 更符合 React 对非紧急更新的建议用法
|
||||
|
||||
#### 3.6 收敛重复派生计算
|
||||
|
||||
问题:
|
||||
|
||||
- 当前实现中存在多处基于 `orders` 和 `currentAttempt` 的重复 `filter/map`
|
||||
- 数据规模扩大后,这些重复遍历会逐步放大渲染成本
|
||||
|
||||
目标:
|
||||
|
||||
- 让渲染中的数据派生更集中、更可读
|
||||
|
||||
建议方向:
|
||||
|
||||
- 将当前 attempt 对应订单集合收敛成单一派生结果
|
||||
- 复制列内容等行为复用同一份派生数据
|
||||
|
||||
预期收益:
|
||||
|
||||
- 降低不必要的重复计算
|
||||
- 让 `BatchItem` 的渲染路径更容易维护
|
||||
|
||||
#### 3.7 优化执行报告的结果语义
|
||||
|
||||
问题:
|
||||
|
||||
- 当前执行报告的标题和成功态仍主要依赖 `errors`
|
||||
- 当存在 `materialsFailed` 或 `uncertainDeletions` 时,结果表达仍可能显得过于乐观
|
||||
|
||||
目标:
|
||||
|
||||
- 让执行结果清楚区分成功、部分成功、失败、需人工确认
|
||||
|
||||
建议方向:
|
||||
|
||||
- 重新定义结果态判定优先级
|
||||
- 在不重做 UI 视觉设计的前提下,优化标题、说明文案和结果提示条
|
||||
|
||||
预期收益:
|
||||
|
||||
- 降低误判执行结果的风险
|
||||
- 让失败和不确定删除场景更容易被用户注意到
|
||||
|
||||
### P2 后续演进
|
||||
|
||||
#### 3.8 统一状态映射定义
|
||||
|
||||
问题:
|
||||
|
||||
- 当前状态的 label、icon、style 已有集中趋势,但仍是组件内局部定义
|
||||
- 后续新增状态时容易出现展示不一致
|
||||
|
||||
目标:
|
||||
|
||||
- 用统一的受类型约束的映射管理状态展示
|
||||
|
||||
建议方向:
|
||||
|
||||
- 抽离共享状态映射
|
||||
- 覆盖 batch、execution、order、material 这几类状态展示
|
||||
|
||||
预期收益:
|
||||
|
||||
- 降低重复定义
|
||||
- 提高新增状态时的一致性和可维护性
|
||||
|
||||
#### 3.9 补无障碍语义
|
||||
|
||||
问题:
|
||||
|
||||
- 当前批次展开和订单展开更多依赖点击容器,语义和键盘可达性还有提升空间
|
||||
|
||||
目标:
|
||||
|
||||
- 让复杂历史弹窗具备更清晰的交互语义
|
||||
|
||||
建议方向:
|
||||
|
||||
- 使用真实按钮作为展开触发器
|
||||
- 增加 `aria-expanded`、`aria-controls` 等属性
|
||||
|
||||
预期收益:
|
||||
|
||||
- 提升键盘交互和屏幕阅读器兼容性
|
||||
- 为后续复杂交互维护提供更稳定语义基础
|
||||
|
||||
#### 3.10 规划历史查询的扩展能力
|
||||
|
||||
问题:
|
||||
|
||||
- 当前查询能力主要围绕固定数量批次列表和基础筛选
|
||||
- 如果历史功能继续增强,前端会越来越依赖更丰富的查询条件
|
||||
|
||||
目标:
|
||||
|
||||
- 为后续历史功能演进预留明确方向
|
||||
|
||||
建议方向:
|
||||
|
||||
- 预留时间范围筛选
|
||||
- 预留状态筛选
|
||||
- 延续服务端分页方向,而不是继续扩大前端一次性加载量
|
||||
|
||||
预期收益:
|
||||
|
||||
- 让后续功能迭代有稳定扩展路径
|
||||
- 避免复杂度持续堆积在当前单一弹窗实现中
|
||||
|
||||
## 4. 推荐执行顺序
|
||||
|
||||
建议按以下顺序推进:
|
||||
|
||||
1. 先修 `P0`,优先处理缓存时机错误和按需加载未完全生效的问题
|
||||
2. 在 `P0` 修复完成后补最小前端回归测试,锁住关键行为
|
||||
3. 再做 `P1`,先分页,再处理 `startTransition` 和重复派生计算
|
||||
4. 最后进入 `P2`,统一状态映射、补无障碍语义,并规划历史查询扩展能力
|
||||
|
||||
这个顺序的原则是:先修稳定性,再做性能,再做长期演进。
|
||||
|
||||
## 5. 完成标准
|
||||
|
||||
本计划相关改进完成后,至少应满足以下验收标准:
|
||||
|
||||
- 历史弹窗未打开时,不触发对应懒加载模块请求
|
||||
- 批次详情或物料详情首次请求失败后,用户再次展开可重新请求
|
||||
- 用户筛选切换后,列表数据与筛选条件一致
|
||||
- 执行报告在存在 `materialsFailed` 或 `uncertainDeletions` 时,不再展示为完全成功
|
||||
- `npm run typecheck` 通过
|
||||
- 相关前端测试通过
|
||||
- Cleaner 页面关键路径手工验证通过,包括:
|
||||
- 打开历史弹窗
|
||||
- 展开批次详情
|
||||
- 展开订单物料详情
|
||||
- 切换管理员筛选
|
||||
- 查看执行结果提示
|
||||
|
||||
## 6. 默认方案与实施约束
|
||||
|
||||
为避免后续实现阶段再次做不必要决策,本计划固定以下默认方案:
|
||||
|
||||
- 历史列表优先采用分页,不先引入虚拟列表库
|
||||
- 历史弹窗继续保留现有交互样式,不做视觉重设计
|
||||
- 测试优先补组件/行为测试,不新增端到端测试要求
|
||||
- 本计划只覆盖 Cleaner 相关前端改进,不扩展到主进程 DAO、IPC、数据库结构重构
|
||||
|
||||
如果后续版本继续围绕 Cleaner 历史功能扩展,可以在本计划基础上继续追加更细的实施文档,但不应改变本计划中 `P0 / P1 / P2` 的优先级顺序。
|
||||
6
docs/releases/1.12.1.md
Normal file
6
docs/releases/1.12.1.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# 1.12.1
|
||||
|
||||
## 界面与交互
|
||||
|
||||
- 操作历史面板新增序号列,订单和物料明细表均可直观查看行号。
|
||||
- 物料操作结果改用图标显示(已删除 / 已跳过 / 不确定 / 失败),悬停可查看状态名称。
|
||||
5
docs/releases/1.12.2.md
Normal file
5
docs/releases/1.12.2.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# 1.12.2
|
||||
|
||||
## 问题修复
|
||||
|
||||
- 修复管理员切换用户后登出,再次选择用户无法进入应用的问题。
|
||||
5
docs/releases/1.12.3.md
Normal file
5
docs/releases/1.12.3.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# 1.12.3
|
||||
|
||||
## 内部优化
|
||||
|
||||
- 清理项目根目录无用文件,移除已弃用的 Playwright 配置和调试脚本。
|
||||
11
docs/releases/1.12.4.md
Normal file
11
docs/releases/1.12.4.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# 1.12.4
|
||||
|
||||
## 问题修复
|
||||
|
||||
- 修复清理器操作历史在 PostgreSQL 数据库下无法正常加载的问题。
|
||||
- 修复 PostgreSQL 环境下物料数据写入失败的问题,支持无唯一约束的表。
|
||||
|
||||
## 改进
|
||||
|
||||
- 优化清理器操作历史的分页加载和执行报告展示,提升大数据量下的响应速度。
|
||||
- 统一清理器和提取器的操作历史删除确认交互,保持一致的体验。
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "erpauto",
|
||||
"version": "1.12.0",
|
||||
"version": "1.12.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "erpauto",
|
||||
"version": "1.12.0",
|
||||
"version": "1.12.4",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.929.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "erpauto",
|
||||
"version": "1.12.0",
|
||||
"version": "1.12.4",
|
||||
"description": "An Electron application with React and TypeScript",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "example.com",
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { defineConfig } from '@playwright/test'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
timeout: 120000,
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: 1,
|
||||
reporter: 'html',
|
||||
use: {
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure'
|
||||
},
|
||||
|
||||
// Test configuration for Electron
|
||||
projects: [
|
||||
{
|
||||
name: 'electron',
|
||||
testMatch: '**/*.test.ts'
|
||||
}
|
||||
]
|
||||
})
|
||||
@@ -215,6 +215,7 @@ export class AuthApplicationService {
|
||||
}
|
||||
|
||||
this.sessionManager.logout()
|
||||
this.silentLoginPromise = null
|
||||
await this.updateService.setUserContext(null)
|
||||
},
|
||||
{ operation: 'logout' }
|
||||
|
||||
@@ -484,11 +484,7 @@ export class CleanerApplicationService {
|
||||
batchId,
|
||||
attemptNumber,
|
||||
detail.orderNumber,
|
||||
detail.notFound
|
||||
? 'erp_not_found'
|
||||
: detail.errors.length > 0
|
||||
? 'failed'
|
||||
: 'success',
|
||||
detail.notFound ? 'erp_not_found' : detail.errors.length > 0 ? 'failed' : 'success',
|
||||
detail.materialsDeleted,
|
||||
detail.materialsSkipped,
|
||||
detail.materialsFailed,
|
||||
|
||||
@@ -122,6 +122,15 @@ export class CleanerOperationHistoryDAO {
|
||||
return this.getDialect().quoteTableName('ERPAuto', 'CleanerMaterialDetail')
|
||||
}
|
||||
|
||||
private getIsDryRunAggregateSql(): string {
|
||||
const dialect = this.getDialect()
|
||||
if (dialect.dbType === 'postgresql') {
|
||||
return `MAX(CASE WHEN e.IsDryRun THEN 1 ELSE 0 END)`
|
||||
}
|
||||
|
||||
return `MAX(CAST(e.IsDryRun AS INT))`
|
||||
}
|
||||
|
||||
/**
|
||||
* Get database service instance using DatabaseFactory
|
||||
*/
|
||||
@@ -619,9 +628,9 @@ export class CleanerOperationHistoryDAO {
|
||||
MAX(CASE WHEN e.AttemptNumber = latest.max_attempt THEN e.OrdersProcessed ELSE 0 END) as OrdersProcessed,
|
||||
MAX(CASE WHEN e.AttemptNumber = latest.max_attempt THEN e.TotalMaterialsDeleted ELSE 0 END) as TotalMaterialsDeleted,
|
||||
MAX(CASE WHEN e.AttemptNumber = latest.max_attempt THEN e.TotalMaterialsFailed ELSE 0 END) as TotalMaterialsFailed,
|
||||
MAX(CAST(e.IsDryRun AS INT)) as IsDryRun,
|
||||
ISNULL(SUM(CASE WHEN o.Status = 'success' THEN 1 ELSE 0 END), 0) as SuccessCount,
|
||||
ISNULL(SUM(CASE WHEN o.Status = 'failed' THEN 1 ELSE 0 END), 0) as FailedCount
|
||||
${this.getIsDryRunAggregateSql()} as IsDryRun,
|
||||
COALESCE(SUM(CASE WHEN o.Status = 'success' THEN 1 ELSE 0 END), 0) as SuccessCount,
|
||||
COALESCE(SUM(CASE WHEN o.Status = 'failed' THEN 1 ELSE 0 END), 0) as FailedCount
|
||||
FROM ${execTable} e
|
||||
INNER JOIN (
|
||||
SELECT BatchId, MAX(AttemptNumber) as max_attempt
|
||||
@@ -906,27 +915,13 @@ export class CleanerOperationHistoryDAO {
|
||||
const materialTable = this.getMaterialTableName()
|
||||
const dialect = this.getDialect()
|
||||
|
||||
// Check if batch exists
|
||||
const checkSql = `
|
||||
SELECT TOP 1 UserId
|
||||
FROM ${execTable}
|
||||
WHERE BatchId = ${dialect.param(0)}
|
||||
`
|
||||
|
||||
const checkResult = await trackDuration(
|
||||
async () => await dbService.query(checkSql, [batchId]),
|
||||
{
|
||||
operationName: 'CleanerOperationHistoryDAO.deleteBatch.check',
|
||||
context: { operationType: 'SELECT', batchId }
|
||||
}
|
||||
)
|
||||
|
||||
if (checkResult.result.rows.length === 0) {
|
||||
const details = await this.getBatchDetails(batchId)
|
||||
if (details.executions.length === 0) {
|
||||
return { success: false, error: '批次不存在' }
|
||||
}
|
||||
|
||||
// Permission check: non-admin can only delete own batches
|
||||
const batchUserId = checkResult.result.rows[0].UserId as number
|
||||
const batchUserId = details.executions[0].userId
|
||||
if (!isAdmin && batchUserId !== requestingUserId) {
|
||||
return { success: false, error: '没有权限删除此批次' }
|
||||
}
|
||||
|
||||
@@ -110,6 +110,58 @@ export class MaterialsToBeDeletedDAO {
|
||||
const manager = managerName?.trim() || null
|
||||
const dialect = this.getDialect()
|
||||
|
||||
if (dbService.type === 'postgresql') {
|
||||
const updateSql = `
|
||||
UPDATE ${tableName}
|
||||
SET ManagerName = ${dialect.param(0)}
|
||||
WHERE MaterialCode = ${dialect.param(1)}
|
||||
`
|
||||
|
||||
const updateResult = await trackDuration(
|
||||
async () => await dbService.query(updateSql, [manager, code]),
|
||||
{
|
||||
operationName: 'MaterialsToBeDeletedDAO.upsertMaterial',
|
||||
context: { tableName, operationType: 'UPSERT_UPDATE_FIRST' }
|
||||
}
|
||||
)
|
||||
|
||||
if (updateResult.result.rowCount > 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
const insertSql = `
|
||||
INSERT INTO ${tableName} (MaterialCode, ManagerName)
|
||||
SELECT ${dialect.param(0)}, ${dialect.param(1)}
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM ${tableName}
|
||||
WHERE MaterialCode = ${dialect.param(0)}
|
||||
)
|
||||
`
|
||||
|
||||
const insertResult = await trackDuration(
|
||||
async () => await dbService.query(insertSql, [code, manager]),
|
||||
{
|
||||
operationName: 'MaterialsToBeDeletedDAO.upsertMaterial',
|
||||
context: { tableName, operationType: 'UPSERT_INSERT_FALLBACK' }
|
||||
}
|
||||
)
|
||||
|
||||
if (insertResult.result.rowCount > 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
const retryUpdateResult = await trackDuration(
|
||||
async () => await dbService.query(updateSql, [manager, code]),
|
||||
{
|
||||
operationName: 'MaterialsToBeDeletedDAO.upsertMaterial',
|
||||
context: { tableName, operationType: 'UPSERT_UPDATE_RETRY' }
|
||||
}
|
||||
)
|
||||
|
||||
return retryUpdateResult.result.rowCount > 0
|
||||
}
|
||||
|
||||
const { sql: sqlString } = dialect.upsert({
|
||||
table: tableName,
|
||||
keyColumns: ['MaterialCode'],
|
||||
@@ -177,22 +229,12 @@ export class MaterialsToBeDeletedDAO {
|
||||
}
|
||||
|
||||
try {
|
||||
const { sql: sqlString } = dialect.upsert({
|
||||
table: tableName,
|
||||
keyColumns: ['MaterialCode'],
|
||||
allColumns: ['MaterialCode', 'ManagerName'],
|
||||
startParamIndex: 0
|
||||
})
|
||||
|
||||
await trackDuration(
|
||||
async () => await dbService.query(sqlString, [materialCode, managerName || null]),
|
||||
{
|
||||
operationName: 'MaterialsToBeDeletedDAO.upsertBatch',
|
||||
context: { tableName, operationType: 'UPSERT', batchId }
|
||||
}
|
||||
)
|
||||
|
||||
stats.success++
|
||||
const success = await this.upsertMaterial(materialCode, managerName)
|
||||
if (success) {
|
||||
stats.success++
|
||||
} else {
|
||||
stats.failed++
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Error upserting material', {
|
||||
tableName,
|
||||
@@ -201,7 +243,6 @@ export class MaterialsToBeDeletedDAO {
|
||||
materialCode,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
stats.failed++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,19 +279,8 @@ export class MaterialsToBeDeletedDAO {
|
||||
managerName: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const dialect = this.getDialect()
|
||||
|
||||
const { sql: sqlString } = dialect.upsert({
|
||||
table: tableName,
|
||||
keyColumns: ['MaterialCode'],
|
||||
allColumns: ['MaterialCode', 'ManagerName'],
|
||||
startParamIndex: 0
|
||||
})
|
||||
await dbService.query(sqlString, [materialCode, managerName || null])
|
||||
|
||||
return { success: true }
|
||||
const success = await this.upsertMaterial(materialCode, managerName)
|
||||
return { success }
|
||||
} catch (error) {
|
||||
log.error('Update manager error', {
|
||||
materialCode,
|
||||
|
||||
@@ -210,6 +210,58 @@ export class MaterialsTypeToBeDeletedDAO {
|
||||
const manager = managerName?.trim() || null
|
||||
const dialect = this.getDialect()
|
||||
|
||||
if (dbService.type === 'postgresql') {
|
||||
const updateSql = `
|
||||
UPDATE ${tableName}
|
||||
SET ManagerName = ${dialect.param(0)}
|
||||
WHERE MaterialName = ${dialect.param(1)}
|
||||
`
|
||||
|
||||
const updateResult = await trackDuration(
|
||||
async () => await dbService.query(updateSql, [manager, name]),
|
||||
{
|
||||
operationName: 'MaterialsTypeToBeDeletedDAO.upsertMaterial',
|
||||
context: { tableName, operationType: 'UPSERT_UPDATE_FIRST' }
|
||||
}
|
||||
)
|
||||
|
||||
if (updateResult.result.rowCount > 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
const insertSql = `
|
||||
INSERT INTO ${tableName} (MaterialName, ManagerName)
|
||||
SELECT ${dialect.param(0)}, ${dialect.param(1)}
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM ${tableName}
|
||||
WHERE MaterialName = ${dialect.param(0)}
|
||||
)
|
||||
`
|
||||
|
||||
const insertResult = await trackDuration(
|
||||
async () => await dbService.query(insertSql, [name, manager]),
|
||||
{
|
||||
operationName: 'MaterialsTypeToBeDeletedDAO.upsertMaterial',
|
||||
context: { tableName, operationType: 'UPSERT_INSERT_FALLBACK' }
|
||||
}
|
||||
)
|
||||
|
||||
if (insertResult.result.rowCount > 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
const retryUpdateResult = await trackDuration(
|
||||
async () => await dbService.query(updateSql, [manager, name]),
|
||||
{
|
||||
operationName: 'MaterialsTypeToBeDeletedDAO.upsertMaterial',
|
||||
context: { tableName, operationType: 'UPSERT_UPDATE_RETRY' }
|
||||
}
|
||||
)
|
||||
|
||||
return retryUpdateResult.result.rowCount > 0
|
||||
}
|
||||
|
||||
const { sql: sqlString } = dialect.upsert({
|
||||
table: tableName,
|
||||
keyColumns: ['MaterialName'],
|
||||
|
||||
@@ -26,8 +26,12 @@ interface RetryResult {
|
||||
}
|
||||
|
||||
interface ProgressState {
|
||||
completedOrders: number
|
||||
ordersStarted: number // 开始处理的订单数
|
||||
ordersCompleted: number // 已完成订单数
|
||||
totalOrders: number
|
||||
progressText?: string // "ordersCompleted/totalOrders (xx%)"
|
||||
lastCompletedOrder?: string // 最后完成的订单号
|
||||
lastActivityTime?: number // 最后活动时间戳(健康检查用)
|
||||
}
|
||||
|
||||
interface QueryResultRow {
|
||||
@@ -56,6 +60,48 @@ class AsyncMutex {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 并发追踪器 - 监控 worker 状态和 mutex 等待情况
|
||||
*/
|
||||
class ConcurrencyTracker {
|
||||
private activeWorkers = 0
|
||||
private waitQueue = 0
|
||||
private mutexWaitCount = 0
|
||||
|
||||
workerStarted() {
|
||||
this.activeWorkers++
|
||||
log.verbose('[CONCURRENCY] Worker started', {
|
||||
activeWorkers: this.activeWorkers,
|
||||
waitQueue: this.waitQueue,
|
||||
waitingForPopupMutex: this.mutexWaitCount > 0
|
||||
})
|
||||
}
|
||||
|
||||
workerCompleted() {
|
||||
this.activeWorkers--
|
||||
log.verbose('[CONCURRENCY] Worker completed', {
|
||||
activeWorkers: this.activeWorkers,
|
||||
queueRemaining: this.waitQueue
|
||||
})
|
||||
}
|
||||
|
||||
waitingForMutex() {
|
||||
this.mutexWaitCount++
|
||||
log.warn('[CONCURRENCY] Worker waiting for popup mutex', {
|
||||
mutexWaitCount: this.mutexWaitCount,
|
||||
activeWorkers: this.activeWorkers
|
||||
})
|
||||
}
|
||||
|
||||
acquiredMutex() {
|
||||
this.mutexWaitCount--
|
||||
log.verbose('[CONCURRENCY] Worker acquired popup mutex', {
|
||||
mutexWaitCount: this.mutexWaitCount,
|
||||
activeWorkers: this.activeWorkers
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleaner Service Options
|
||||
*/
|
||||
@@ -242,7 +288,8 @@ export class CleanerService {
|
||||
const orderBatches = createBatches(input.orderNumbers, queryBatchSize)
|
||||
const popupMutex = new AsyncMutex()
|
||||
const progressState: ProgressState = {
|
||||
completedOrders: 0,
|
||||
ordersStarted: 0,
|
||||
ordersCompleted: 0,
|
||||
totalOrders
|
||||
}
|
||||
|
||||
@@ -257,58 +304,131 @@ export class CleanerService {
|
||||
totalMaterials
|
||||
})
|
||||
|
||||
// [新增] 健康检查定时器 - 检测长时间无进展
|
||||
let lastActivityTime = Date.now()
|
||||
progressState.lastActivityTime = lastActivityTime
|
||||
const healthCheckInterval = setInterval(() => {
|
||||
const secondsSinceLastActivity = (Date.now() - lastActivityTime) / 1000
|
||||
|
||||
if (secondsSinceLastActivity > 60) {
|
||||
log.warn('[HEALTH_CHECK] 长时间无进展', {
|
||||
ordersCompleted: progressState.ordersCompleted,
|
||||
totalOrders: progressState.totalOrders,
|
||||
lastCompletedOrder: progressState.lastCompletedOrder,
|
||||
noProgressSeconds: secondsSinceLastActivity,
|
||||
suspectedStuck: secondsSinceLastActivity > 180,
|
||||
healthStatus: secondsSinceLastActivity > 180 ? 'critical' : 'warning'
|
||||
})
|
||||
}
|
||||
}, 30000) // 每 30 秒检查一次
|
||||
|
||||
// [新增] 为每个 batch 创建并发追踪器
|
||||
const tracker = new ConcurrencyTracker()
|
||||
|
||||
// Track batch processing duration with 5s slow threshold
|
||||
await trackDuration(
|
||||
async () => {
|
||||
await this.queryOrders(workFrame, batchOrders)
|
||||
await this.waitForLoading(workFrame)
|
||||
|
||||
const queriedRows = await this.collectQueryResultRows(workFrame)
|
||||
const queriedOrderNumbersInBatch = new Set(queriedRows.map((row) => row.orderNumber))
|
||||
|
||||
await runWithConcurrency(queriedRows, processConcurrency, async (row) => {
|
||||
const { rowIndex, orderNumber } = row
|
||||
const openedDetailPage = await popupMutex.runExclusive(async () => {
|
||||
return await this.openDetailPageFromRow(workFrame, popupPage!, rowIndex)
|
||||
})
|
||||
|
||||
let detail: OrderCleanDetail
|
||||
try {
|
||||
detail = await this.processDetailPage({
|
||||
detailPage: openedDetailPage,
|
||||
deleteSet,
|
||||
dryRun,
|
||||
expectedOrderNumber: orderNumber,
|
||||
progressState,
|
||||
onProgress: input.onProgress
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
detail = this.createErrorDetail(orderNumber, message)
|
||||
} finally {
|
||||
progressState.completedOrders += 1
|
||||
}
|
||||
|
||||
result.details.push(detail)
|
||||
|
||||
if (detail.errors.length > 0) {
|
||||
result.errors.push(`Order ${detail.orderNumber}: ${detail.errors.join('; ')}`)
|
||||
return
|
||||
}
|
||||
|
||||
result.ordersProcessed += 1
|
||||
result.materialsDeleted += detail.materialsDeleted
|
||||
result.materialsSkipped += detail.materialsSkipped
|
||||
result.materialsFailed += detail.materialsFailed
|
||||
result.uncertainDeletions += detail.uncertainDeletions
|
||||
// Phase 1: Query orders
|
||||
await trackDuration(async () => await this.queryOrders(workFrame, batchOrders), {
|
||||
operationName: 'query',
|
||||
message: '执行订单查询',
|
||||
slowThresholdMs: 3000,
|
||||
context: { orderCount: batchOrders.length }
|
||||
})
|
||||
|
||||
const missingOrders = getMissingOrders(batchOrders, queriedOrderNumbersInBatch)
|
||||
for (const missingOrder of missingOrders) {
|
||||
const missingMessage = '订单未出现在查询结果中'
|
||||
result.errors.push(`Order ${missingOrder}: ${missingMessage}`)
|
||||
result.details.push(this.createErrorDetail(missingOrder, missingMessage, true))
|
||||
}
|
||||
// Phase 2: Wait for loading complete
|
||||
await trackDuration(async () => await this.waitForLoading(workFrame), {
|
||||
operationName: 'wait_loading',
|
||||
message: '等待加载完成',
|
||||
slowThresholdMs: 5000
|
||||
})
|
||||
|
||||
// Phase 3: Collect query results
|
||||
const collectResult = await trackDuration(
|
||||
async () => await this.collectQueryResultRows(workFrame),
|
||||
{
|
||||
operationName: 'collect_results',
|
||||
message: '收集查询结果',
|
||||
slowThresholdMs: 2000
|
||||
}
|
||||
)
|
||||
const queriedRows = collectResult.result
|
||||
const queriedOrderNumbersInBatch = new Set(queriedRows.map((row) => row.orderNumber))
|
||||
|
||||
// Phase 4: Process all orders in batch
|
||||
await trackDuration(
|
||||
async () => {
|
||||
await runWithConcurrency(queriedRows, processConcurrency, async (row) => {
|
||||
const { rowIndex, orderNumber } = row
|
||||
|
||||
// [新增] Worker 开始追踪
|
||||
tracker.workerStarted()
|
||||
|
||||
try {
|
||||
const openedDetailPage = await popupMutex.runExclusive(async () => {
|
||||
// [新增] Mutex 等待追踪
|
||||
tracker.waitingForMutex()
|
||||
const page = await this.openDetailPageFromRow(workFrame, popupPage!, rowIndex)
|
||||
// [新增] Mutex 获取追踪
|
||||
tracker.acquiredMutex()
|
||||
return page
|
||||
})
|
||||
|
||||
let detail: OrderCleanDetail
|
||||
try {
|
||||
detail = await this.processDetailPage({
|
||||
detailPage: openedDetailPage,
|
||||
deleteSet,
|
||||
dryRun,
|
||||
expectedOrderNumber: orderNumber,
|
||||
progressState,
|
||||
onProgress: input.onProgress
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
detail = this.createErrorDetail(orderNumber, message)
|
||||
} finally {
|
||||
progressState.ordersStarted += 1
|
||||
progressState.ordersCompleted += 1
|
||||
progressState.lastCompletedOrder = orderNumber
|
||||
lastActivityTime = Date.now() // [新增] 健康检查:更新活动时间
|
||||
}
|
||||
|
||||
result.details.push(detail)
|
||||
|
||||
if (detail.errors.length > 0) {
|
||||
result.errors.push(`Order ${detail.orderNumber}: ${detail.errors.join('; ')}`)
|
||||
return
|
||||
}
|
||||
|
||||
result.ordersProcessed += 1
|
||||
result.materialsDeleted += detail.materialsDeleted
|
||||
result.materialsSkipped += detail.materialsSkipped
|
||||
result.materialsFailed += detail.materialsFailed
|
||||
result.uncertainDeletions += detail.uncertainDeletions
|
||||
} finally {
|
||||
// [新增] Worker 完成追踪
|
||||
tracker.workerCompleted()
|
||||
}
|
||||
})
|
||||
|
||||
// Handle missing orders
|
||||
const missingOrders = getMissingOrders(batchOrders, queriedOrderNumbersInBatch)
|
||||
for (const missingOrder of missingOrders) {
|
||||
const missingMessage = '订单未出现在查询结果中'
|
||||
result.errors.push(`Order ${missingOrder}: ${missingMessage}`)
|
||||
result.details.push(this.createErrorDetail(missingOrder, missingMessage, true))
|
||||
}
|
||||
},
|
||||
{
|
||||
operationName: 'process_all_orders_in_batch',
|
||||
message: `处理批次中所有${queriedRows.length}个订单`,
|
||||
slowThresholdMs: 10000
|
||||
}
|
||||
)
|
||||
|
||||
// Phase 5: Save batch results (already included in process_all_orders_in_batch)
|
||||
// No separate save step needed as results are accumulated in result object
|
||||
},
|
||||
{
|
||||
operationName: `batch-${batchIndex + 1}-${orderBatches[batchIndex].length}-orders`,
|
||||
@@ -323,6 +443,9 @@ export class CleanerService {
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// [新增] 清理健康检查定时器
|
||||
clearInterval(healthCheckInterval)
|
||||
}
|
||||
|
||||
const retryResult = await this.retryFailedOrders({
|
||||
@@ -381,7 +504,16 @@ export class CleanerService {
|
||||
dryRun,
|
||||
orderNumbers: input.orderNumbers,
|
||||
materialCodes: input.materialCodes,
|
||||
...(popupPage ? await capturePageContext(popupPage, undefined, 'cleaner.outerCatch') : {})
|
||||
...(popupPage
|
||||
? await capturePageContext(
|
||||
popupPage,
|
||||
undefined,
|
||||
'cleaner.outerCatch',
|
||||
undefined,
|
||||
undefined,
|
||||
'outer_catch'
|
||||
)
|
||||
: {})
|
||||
})
|
||||
result.errors.push(`Clean failed: ${message}`)
|
||||
result.crashed = true
|
||||
@@ -438,7 +570,14 @@ export class CleanerService {
|
||||
log.error('[导航失败] forwardFrame 为空', {
|
||||
elapsedMs: Date.now() - navStartTime,
|
||||
pageUrl: popupPage.url(),
|
||||
contextData: await capturePageContext(popupPage, undefined, 'nav.forwardFrame')
|
||||
contextData: await capturePageContext(
|
||||
popupPage,
|
||||
undefined,
|
||||
'nav.forwardFrame',
|
||||
undefined,
|
||||
undefined,
|
||||
'nav_forward_frame'
|
||||
)
|
||||
})
|
||||
throw new Error('无法访问弹出窗口的 forwardFrame')
|
||||
}
|
||||
@@ -457,7 +596,14 @@ export class CleanerService {
|
||||
log.error('[导航失败] workFrame 为空', {
|
||||
elapsedMs: Date.now() - navStartTime,
|
||||
pageUrl: popupPage.url(),
|
||||
contextData: await capturePageContext(popupPage, undefined, 'nav.workFrame')
|
||||
contextData: await capturePageContext(
|
||||
popupPage,
|
||||
undefined,
|
||||
'nav.workFrame',
|
||||
undefined,
|
||||
undefined,
|
||||
'nav_work_frame'
|
||||
)
|
||||
})
|
||||
throw new Error('无法访问内部工作框架')
|
||||
}
|
||||
@@ -680,14 +826,40 @@ export class CleanerService {
|
||||
const { detailPage, deleteSet, dryRun, progressState, expectedOrderNumber, onProgress } = params
|
||||
const processStartTime = Date.now()
|
||||
|
||||
log.info('[ORDER_START] 开始处理订单', {
|
||||
orderIndex: progressState.ordersStarted,
|
||||
orderPosition: `${progressState.ordersStarted + 1}/${progressState.totalOrders}`,
|
||||
orderNumber: expectedOrderNumber,
|
||||
phase: 'starting',
|
||||
elapsedMs: Date.now() - processStartTime
|
||||
})
|
||||
|
||||
log.info('[订单详情处理开始]', {
|
||||
expectedOrderNumber,
|
||||
dryRun,
|
||||
deleteSetSize: deleteSet.size,
|
||||
completedOrders: progressState.completedOrders,
|
||||
ordersStarted: progressState.ordersStarted,
|
||||
ordersCompleted: progressState.ordersCompleted,
|
||||
totalOrders: progressState.totalOrders
|
||||
})
|
||||
|
||||
let detailCount = 0
|
||||
const detail: OrderCleanDetail = {
|
||||
orderNumber: expectedOrderNumber || 'UNKNOWN',
|
||||
materialsDeleted: 0,
|
||||
materialsSkipped: 0,
|
||||
errors: [],
|
||||
skippedMaterials: [],
|
||||
deletedMaterials: [],
|
||||
retryCount: 0,
|
||||
retryAttempts: [],
|
||||
retriedAt: undefined,
|
||||
retrySuccess: false,
|
||||
materialsFailed: 0,
|
||||
failedMaterials: [],
|
||||
uncertainDeletions: 0
|
||||
}
|
||||
|
||||
try {
|
||||
// Step 1: Access forward frame
|
||||
log.debug('[详情页面 Step 1] 准备访问 forwardFrame')
|
||||
@@ -699,7 +871,14 @@ export class CleanerService {
|
||||
log.error('[详情页面失败] forwardFrame 访问失败', {
|
||||
elapsedMs: Date.now() - processStartTime,
|
||||
pageUrl: detailPage.url(),
|
||||
contextData: await capturePageContext(detailPage)
|
||||
contextData: await capturePageContext(
|
||||
detailPage,
|
||||
undefined,
|
||||
'processDetail.forwardFrame',
|
||||
expectedOrderNumber,
|
||||
undefined,
|
||||
'process_detail_forward_frame'
|
||||
)
|
||||
})
|
||||
throw new Error(errorMsg)
|
||||
}
|
||||
@@ -721,7 +900,10 @@ export class CleanerService {
|
||||
contextData: await capturePageContext(
|
||||
detailPage,
|
||||
undefined,
|
||||
'processDetail.detailInnerFrame'
|
||||
'processDetail.detailInnerFrame',
|
||||
expectedOrderNumber,
|
||||
undefined,
|
||||
'process_detail_inner_frame'
|
||||
)
|
||||
})
|
||||
throw new Error(errorMsg)
|
||||
@@ -752,21 +934,8 @@ export class CleanerService {
|
||||
usedFallback: !sourceOrderNumber && !!expectedOrderNumber
|
||||
})
|
||||
|
||||
const detail: OrderCleanDetail = {
|
||||
orderNumber,
|
||||
materialsDeleted: 0,
|
||||
materialsSkipped: 0,
|
||||
errors: [],
|
||||
skippedMaterials: [],
|
||||
deletedMaterials: [],
|
||||
retryCount: 0,
|
||||
retryAttempts: [],
|
||||
retriedAt: undefined,
|
||||
retrySuccess: false,
|
||||
materialsFailed: 0,
|
||||
failedMaterials: [],
|
||||
uncertainDeletions: 0
|
||||
}
|
||||
// 更新 orderNumber 为实际提取的值或 fallback
|
||||
detail.orderNumber = orderNumber
|
||||
|
||||
// Step 5: Get material counts and status
|
||||
log.debug('[详情页面 Step 5] 读取物料数量和状态')
|
||||
@@ -788,13 +957,13 @@ export class CleanerService {
|
||||
onProgress?.(
|
||||
`开始处理订单:${orderNumber}`,
|
||||
this.calculateProgress(
|
||||
progressState.completedOrders,
|
||||
progressState.ordersStarted,
|
||||
0,
|
||||
detailCount,
|
||||
progressState.totalOrders
|
||||
),
|
||||
{
|
||||
currentOrderIndex: progressState.completedOrders + 1,
|
||||
currentOrderIndex: progressState.ordersStarted + 1,
|
||||
totalOrders: progressState.totalOrders,
|
||||
currentMaterialIndex: 0,
|
||||
totalMaterialsInOrder: detailCount,
|
||||
@@ -861,7 +1030,7 @@ export class CleanerService {
|
||||
const pendingQty = await this.getInputValue(childForm, /^累计待发数量$/)
|
||||
|
||||
const progress = this.calculateProgress(
|
||||
progressState.completedOrders,
|
||||
progressState.ordersStarted,
|
||||
materialIdx,
|
||||
detailCount,
|
||||
progressState.totalOrders
|
||||
@@ -871,7 +1040,7 @@ export class CleanerService {
|
||||
`订单 ${orderNumber} - 物料 ${materialIdx}/${detailCount}: ${materialName}`,
|
||||
progress,
|
||||
{
|
||||
currentOrderIndex: progressState.completedOrders + 1,
|
||||
currentOrderIndex: progressState.ordersStarted + 1,
|
||||
totalOrders: progressState.totalOrders,
|
||||
currentMaterialIndex: materialIdx,
|
||||
totalMaterialsInOrder: detailCount,
|
||||
@@ -1078,10 +1247,31 @@ export class CleanerService {
|
||||
orderNumber: expectedOrderNumber || 'UNKNOWN',
|
||||
error: message,
|
||||
elapsedMs: Date.now() - processStartTime,
|
||||
contextData: await capturePageContext(detailPage, undefined, 'processDetail.error')
|
||||
contextData: await capturePageContext(
|
||||
detailPage,
|
||||
undefined,
|
||||
'processDetail.error',
|
||||
expectedOrderNumber,
|
||||
undefined,
|
||||
'process_detail_error'
|
||||
)
|
||||
})
|
||||
throw error
|
||||
} finally {
|
||||
const totalOrderTime = Date.now() - processStartTime
|
||||
log.info('[ORDER_COMPLETE] 订单处理完成', {
|
||||
orderIndex: progressState.ordersStarted,
|
||||
orderPosition: `${progressState.ordersStarted + 1}/${progressState.totalOrders}`,
|
||||
ordersCompleted: progressState.ordersCompleted,
|
||||
orderNumber: expectedOrderNumber || 'UNKNOWN',
|
||||
totalMaterials: detailCount,
|
||||
deleted: detail?.materialsDeleted ?? 0,
|
||||
skipped: detail?.materialsSkipped ?? 0,
|
||||
failed: detail?.materialsFailed ?? 0,
|
||||
totalOrderTimeMs: totalOrderTime,
|
||||
isSlow: totalOrderTime > 30000
|
||||
})
|
||||
|
||||
log.debug('[详情页面清理] 准备关闭详情页面', { pageUrl: detailPage.url() })
|
||||
await detailPage.close()
|
||||
log.debug('[详情页面清理完成] 详情页已关闭')
|
||||
@@ -1115,7 +1305,11 @@ export class CleanerService {
|
||||
return /^SC\d{14}$/.test(value)
|
||||
}
|
||||
|
||||
private createErrorDetail(orderNumber: string, message: string, notFound: boolean = false): OrderCleanDetail {
|
||||
private createErrorDetail(
|
||||
orderNumber: string,
|
||||
message: string,
|
||||
notFound: boolean = false
|
||||
): OrderCleanDetail {
|
||||
return {
|
||||
orderNumber,
|
||||
materialsDeleted: 0,
|
||||
@@ -1589,7 +1783,8 @@ export class CleanerService {
|
||||
dryRun,
|
||||
expectedOrderNumber: orderNumber,
|
||||
progressState: {
|
||||
completedOrders: detailIndex,
|
||||
ordersStarted: detailIndex,
|
||||
ordersCompleted: 0,
|
||||
totalOrders: failedDetails.length
|
||||
},
|
||||
onProgress: (message, progress, extra) => {
|
||||
|
||||
@@ -82,7 +82,14 @@ export class ErpAuthService {
|
||||
|
||||
if (!contentFrame) {
|
||||
log.error('Failed to access forwardFrame content frame', {
|
||||
...(await capturePageContext(page))
|
||||
...(await capturePageContext(
|
||||
page,
|
||||
undefined,
|
||||
'auth.forwardFrame',
|
||||
undefined,
|
||||
undefined,
|
||||
'auth_forward_frame'
|
||||
))
|
||||
})
|
||||
throw new Error('Failed to access forwardFrame content frame')
|
||||
}
|
||||
@@ -96,7 +103,14 @@ export class ErpAuthService {
|
||||
} catch (e) {
|
||||
log.error('Failed to find username input', {
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
...(await capturePageContext(page, undefined, 'login.username'))
|
||||
...(await capturePageContext(
|
||||
page,
|
||||
undefined,
|
||||
'login.username',
|
||||
undefined,
|
||||
undefined,
|
||||
'login_username'
|
||||
))
|
||||
})
|
||||
throw new Error(`Failed to find username input: ${e}`)
|
||||
}
|
||||
@@ -107,7 +121,14 @@ export class ErpAuthService {
|
||||
} catch (e) {
|
||||
log.error('Failed to find password input', {
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
...(await capturePageContext(page, undefined, 'login.password'))
|
||||
...(await capturePageContext(
|
||||
page,
|
||||
undefined,
|
||||
'login.password',
|
||||
undefined,
|
||||
undefined,
|
||||
'login_password'
|
||||
))
|
||||
})
|
||||
throw new Error(`Failed to find password input: ${e}`)
|
||||
}
|
||||
@@ -118,7 +139,14 @@ export class ErpAuthService {
|
||||
} catch (e) {
|
||||
log.error('Failed to click login button', {
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
...(await capturePageContext(page, undefined, 'login.button'))
|
||||
...(await capturePageContext(
|
||||
page,
|
||||
undefined,
|
||||
'login.button',
|
||||
undefined,
|
||||
undefined,
|
||||
'login_button'
|
||||
))
|
||||
})
|
||||
throw new Error(`Failed to click login button: ${e}`)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,9 @@ export interface ErpErrorContext {
|
||||
targetSelector?: string
|
||||
step?: string
|
||||
screenshotPath?: string
|
||||
orderId?: string
|
||||
materialCode?: string
|
||||
errorStage?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -69,11 +72,18 @@ async function captureScreenshot(page: Page, step?: string): Promise<string | un
|
||||
*
|
||||
* @param page - The Playwright page to inspect
|
||||
* @param targetSelector - Optional selector that was being targeted
|
||||
* @param step - Optional step name for context
|
||||
* @param orderId - Optional order ID for error correlation
|
||||
* @param materialCode - Optional material code for error correlation
|
||||
* @param stage - Optional stage name (defaults to 'unknown')
|
||||
*/
|
||||
export async function capturePageContext(
|
||||
page: Page,
|
||||
targetSelector?: string,
|
||||
step?: string
|
||||
step?: string,
|
||||
orderId?: string,
|
||||
materialCode?: string,
|
||||
stage: string = 'unknown'
|
||||
): Promise<ErpErrorContext> {
|
||||
const ctx: ErpErrorContext = {}
|
||||
|
||||
@@ -98,6 +108,16 @@ export async function capturePageContext(
|
||||
ctx.step = step
|
||||
}
|
||||
|
||||
if (orderId) {
|
||||
ctx.orderId = orderId
|
||||
}
|
||||
|
||||
if (materialCode) {
|
||||
ctx.materialCode = materialCode
|
||||
}
|
||||
|
||||
ctx.errorStage = stage
|
||||
|
||||
ctx.screenshotPath = await captureScreenshot(page, step)
|
||||
|
||||
return ctx
|
||||
|
||||
@@ -116,7 +116,14 @@ export class ExtractorCore {
|
||||
|
||||
if (!fFrame) {
|
||||
log.error('Failed to access popup forward frame', {
|
||||
...(await capturePageContext(popupPage, undefined, 'navigate.forwardFrame'))
|
||||
...(await capturePageContext(
|
||||
popupPage,
|
||||
undefined,
|
||||
'navigate.forwardFrame',
|
||||
undefined,
|
||||
undefined,
|
||||
'navigate_forward_frame'
|
||||
))
|
||||
})
|
||||
throw new Error('Failed to access popup forward frame')
|
||||
}
|
||||
@@ -128,7 +135,14 @@ export class ExtractorCore {
|
||||
|
||||
if (!workFrame) {
|
||||
log.error('Failed to access inner work frame', {
|
||||
...(await capturePageContext(popupPage, undefined, 'navigate.innerFrame'))
|
||||
...(await capturePageContext(
|
||||
popupPage,
|
||||
undefined,
|
||||
'navigate.innerFrame',
|
||||
undefined,
|
||||
undefined,
|
||||
'navigate_inner_frame'
|
||||
))
|
||||
})
|
||||
throw new Error('Failed to access inner work frame')
|
||||
}
|
||||
|
||||
@@ -42,9 +42,7 @@ async function runMySQLMigration(): Promise<void> {
|
||||
return
|
||||
}
|
||||
|
||||
await service.query(
|
||||
`ALTER TABLE CleanerOrderHistory ADD COLUMN ProductionId VARCHAR(50) NULL`
|
||||
)
|
||||
await service.query(`ALTER TABLE CleanerOrderHistory ADD COLUMN ProductionId VARCHAR(50) NULL`)
|
||||
console.log('Added ProductionId column to CleanerOrderHistory.')
|
||||
} finally {
|
||||
if (service.isConnected()) await service.disconnect()
|
||||
|
||||
@@ -6,8 +6,10 @@
|
||||
* Admin users see all users' records, regular users see only their own.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import React, { useState, useEffect, useCallback, useTransition } from 'react'
|
||||
import { Modal } from './ui/Modal'
|
||||
import { ConfirmDialog } from './ui/ConfirmDialog'
|
||||
import { useConfirmDialog } from './ui/useConfirmDialog'
|
||||
import { useLogger } from '../hooks/useLogger'
|
||||
import {
|
||||
RefreshCw,
|
||||
@@ -16,7 +18,6 @@ import {
|
||||
ChevronRight,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock,
|
||||
Copy,
|
||||
FlaskConical
|
||||
} from 'lucide-react'
|
||||
@@ -26,6 +27,15 @@ import type {
|
||||
CleanerHistoryOrderRecord,
|
||||
CleanerHistoryMaterialRecord
|
||||
} from '../hooks/cleaner/types'
|
||||
import {
|
||||
canStartHistoryLoad,
|
||||
getNextHistoryLoadState,
|
||||
type HistoryLoadState
|
||||
} from './cleaner-history-load-state'
|
||||
import {
|
||||
getCleanerHistoryStatusDisplay,
|
||||
getCleanerMaterialResultDisplay
|
||||
} from './cleaner-history-status'
|
||||
|
||||
// The preload API returns Date for time fields, but IPC serialization converts them to strings.
|
||||
// Use a local type that accommodates both to satisfy TypeScript.
|
||||
@@ -60,37 +70,10 @@ interface BatchItemProps {
|
||||
batch: CleanerHistoryBatchStats
|
||||
isAdmin: boolean
|
||||
onDelete: (batchId: string) => void
|
||||
onRequestDelete: (batchId: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
const statusStyles: Record<string, string> = {
|
||||
success: 'bg-green-100 text-green-700',
|
||||
partial: 'bg-amber-100 text-amber-700',
|
||||
failed: 'bg-red-100 text-red-700',
|
||||
crashed: 'bg-red-100 text-red-700',
|
||||
pending: 'bg-gray-100 text-gray-700',
|
||||
not_found: 'bg-orange-100 text-orange-700',
|
||||
erp_not_found: 'bg-orange-100 text-orange-700'
|
||||
}
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
success: '成功',
|
||||
partial: '部分成功',
|
||||
failed: '失败',
|
||||
crashed: '崩溃',
|
||||
pending: '进行中',
|
||||
not_found: '未找到',
|
||||
erp_not_found: 'ERP不存在'
|
||||
}
|
||||
|
||||
const statusIcons: Record<string, React.ReactNode> = {
|
||||
success: <CheckCircle size={16} className="text-green-600" />,
|
||||
partial: <Clock size={16} className="text-amber-600" />,
|
||||
failed: <XCircle size={16} className="text-red-600" />,
|
||||
crashed: <XCircle size={16} className="text-red-600" />,
|
||||
pending: <Clock size={16} className="text-gray-500" />,
|
||||
not_found: <XCircle size={16} className="text-orange-600" />,
|
||||
erp_not_found: <XCircle size={16} className="text-orange-600" />
|
||||
}
|
||||
const BATCH_PAGE_SIZE = 5
|
||||
|
||||
const formatDateTime = (dateStr: string | Date | null | undefined): string => {
|
||||
if (!dateStr) return '-'
|
||||
@@ -133,7 +116,7 @@ const formatDuration = (startTime: string | Date | null, endTime: string | Date
|
||||
// ====== BatchItem Component ======
|
||||
// Extracted from the modal so that expanding one batch doesn't re-render siblings.
|
||||
// Each BatchItem manages its own details, orders, and material state locally.
|
||||
const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => {
|
||||
const BatchItem = React.memo(({ batch, isAdmin, onDelete, onRequestDelete }: BatchItemProps) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [executions, setExecutions] = useState<ExecutionRecord[]>([])
|
||||
const [orders, setOrders] = useState<CleanerHistoryOrderRecord[]>([])
|
||||
@@ -143,46 +126,53 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => {
|
||||
() => new Map()
|
||||
)
|
||||
const [loadingMaterials, setLoadingMaterials] = useState<Set<string>>(() => new Set())
|
||||
const [materialLoadStates, setMaterialLoadStates] = useState<Map<string, HistoryLoadState>>(
|
||||
() => new Map()
|
||||
)
|
||||
const [detailsLoadState, setDetailsLoadState] = useState<HistoryLoadState>('idle')
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
const detailsLoadedRef = useRef(false)
|
||||
const loadedMaterialsRef = useRef<Set<string>>(new Set())
|
||||
const logger = useLogger('BatchItem')
|
||||
|
||||
// Fetch batch details when first expanded
|
||||
useEffect(() => {
|
||||
if (!isExpanded || detailsLoadedRef.current) return
|
||||
detailsLoadedRef.current = true
|
||||
const fetchDetails = useCallback(async () => {
|
||||
if (!canStartHistoryLoad(detailsLoadState)) return
|
||||
|
||||
const fetchDetails = async () => {
|
||||
try {
|
||||
const result = await window.electron.cleaner.getHistoryBatchDetails(batch.batchId)
|
||||
if (result.success && result.data) {
|
||||
setExecutions(result.data.executions)
|
||||
setOrders(result.data.orders)
|
||||
setDetailsLoadState((prev) => getNextHistoryLoadState(prev, 'start'))
|
||||
try {
|
||||
const result = await window.electron.cleaner.getHistoryBatchDetails(batch.batchId)
|
||||
if (result.success && result.data) {
|
||||
setExecutions(result.data.executions)
|
||||
setOrders(result.data.orders)
|
||||
|
||||
const execs = result.data.executions
|
||||
if (execs.length > 0) {
|
||||
setCurrentAttempt(Math.max(...execs.map((e) => e.attemptNumber)))
|
||||
}
|
||||
const execs = result.data.executions
|
||||
if (execs.length > 0) {
|
||||
setCurrentAttempt(Math.max(...execs.map((e) => e.attemptNumber)))
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Failed to fetch batch details', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
batchId: batch.batchId
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
void fetchDetails()
|
||||
}, [isExpanded, batch.batchId, logger])
|
||||
setDetailsLoadState('success')
|
||||
} else {
|
||||
setDetailsLoadState('error')
|
||||
}
|
||||
} catch (err) {
|
||||
setDetailsLoadState('error')
|
||||
logger.error('Failed to fetch batch details', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
batchId: batch.batchId
|
||||
})
|
||||
}
|
||||
}, [batch.batchId, detailsLoadState, logger])
|
||||
|
||||
const fetchMaterials = useCallback(
|
||||
async (attemptNumber: number, orderNumber: string) => {
|
||||
const cacheKey = `${attemptNumber}:${orderNumber}`
|
||||
if (loadedMaterialsRef.current.has(cacheKey)) return
|
||||
loadedMaterialsRef.current.add(cacheKey)
|
||||
const loadState = materialLoadStates.get(cacheKey) ?? 'idle'
|
||||
if (!canStartHistoryLoad(loadState)) return
|
||||
|
||||
setMaterialLoadStates((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(cacheKey, getNextHistoryLoadState(prev.get(cacheKey) ?? 'idle', 'start'))
|
||||
return next
|
||||
})
|
||||
setLoadingMaterials((prev) => new Set(prev).add(cacheKey))
|
||||
try {
|
||||
const result = await window.electron.cleaner.getHistoryMaterialDetails(
|
||||
@@ -192,8 +182,24 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => {
|
||||
)
|
||||
if (result.success && result.data) {
|
||||
setOrderMaterials((prev) => new Map(prev).set(cacheKey, result.data!))
|
||||
setMaterialLoadStates((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(cacheKey, 'success')
|
||||
return next
|
||||
})
|
||||
} else {
|
||||
setMaterialLoadStates((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(cacheKey, 'error')
|
||||
return next
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
setMaterialLoadStates((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(cacheKey, 'error')
|
||||
return next
|
||||
})
|
||||
logger.error('Failed to fetch material details', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
batchId: batch.batchId,
|
||||
@@ -208,9 +214,17 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => {
|
||||
})
|
||||
}
|
||||
},
|
||||
[batch.batchId, logger]
|
||||
[batch.batchId, logger, materialLoadStates]
|
||||
)
|
||||
|
||||
const toggleBatchExpansion = () => {
|
||||
const nextExpanded = !isExpanded
|
||||
setIsExpanded(nextExpanded)
|
||||
if (nextExpanded) {
|
||||
void fetchDetails()
|
||||
}
|
||||
}
|
||||
|
||||
const toggleOrderExpansion = (attemptNumber: number, orderNumber: string) => {
|
||||
const cacheKey = `${attemptNumber}:${orderNumber}`
|
||||
const isCurrentlyExpanded = expandedOrders.has(cacheKey)
|
||||
@@ -230,7 +244,7 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => {
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (isDeleting) return
|
||||
const confirmed = confirm('确定要删除此批次记录吗?此操作不可撤销。')
|
||||
const confirmed = await onRequestDelete(batch.batchId)
|
||||
if (!confirmed) return
|
||||
|
||||
setIsDeleting(true)
|
||||
@@ -248,12 +262,16 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => {
|
||||
}
|
||||
}
|
||||
|
||||
const filteredOrders =
|
||||
currentAttempt !== undefined ? orders.filter((order) => order.attemptNumber === currentAttempt) : orders
|
||||
const visibleExecutions =
|
||||
currentAttempt !== undefined
|
||||
? executions.filter((execution) => execution.attemptNumber === currentAttempt)
|
||||
: executions
|
||||
const batchStatusDisplay = getCleanerHistoryStatusDisplay(batch.status)
|
||||
|
||||
const handleCopyColumn = (field: keyof CleanerHistoryOrderRecord) => {
|
||||
const filtered =
|
||||
currentAttempt !== undefined
|
||||
? orders.filter((o) => o.attemptNumber === currentAttempt)
|
||||
: orders
|
||||
const values = filtered
|
||||
const values = filteredOrders
|
||||
.map((o) => String(o[field] ?? ''))
|
||||
.filter((v) => v && v !== '-')
|
||||
.join('\n')
|
||||
@@ -269,11 +287,6 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => {
|
||||
.catch(() => showError('复制失败,请手动复制'))
|
||||
}
|
||||
|
||||
const filteredOrders =
|
||||
currentAttempt !== undefined
|
||||
? orders.filter((o) => o.attemptNumber === currentAttempt)
|
||||
: orders
|
||||
|
||||
return (
|
||||
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||||
{/* Batch summary */}
|
||||
@@ -281,7 +294,7 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => {
|
||||
className={`flex items-center justify-between p-4 cursor-pointer transition-colors ${
|
||||
isExpanded ? 'bg-gray-50' : 'hover:bg-gray-50'
|
||||
}`}
|
||||
onClick={() => setIsExpanded((prev) => !prev)}
|
||||
onClick={toggleBatchExpansion}
|
||||
>
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<button className="p-1 hover:bg-gray-200 rounded">
|
||||
@@ -291,9 +304,7 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => {
|
||||
<div className="flex-1 grid grid-cols-7 gap-3 text-sm">
|
||||
<div>
|
||||
<div className="text-gray-500 text-xs">操作时间</div>
|
||||
<div className="font-medium text-gray-900">
|
||||
{formatDateTime(batch.operationTime)}
|
||||
</div>
|
||||
<div className="font-medium text-gray-900">{formatDateTime(batch.operationTime)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500 text-xs">操作用户</div>
|
||||
@@ -302,13 +313,11 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => {
|
||||
<div>
|
||||
<div className="text-gray-500 text-xs">状态</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{statusIcons[batch.status] || statusIcons.pending}
|
||||
{batchStatusDisplay.icon}
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded text-xs font-medium ${
|
||||
statusStyles[batch.status] || statusStyles.pending
|
||||
}`}
|
||||
className={`px-2 py-0.5 rounded text-xs font-medium ${batchStatusDisplay.badgeClassName}`}
|
||||
>
|
||||
{statusLabels[batch.status] || batch.status}
|
||||
{batchStatusDisplay.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -318,9 +327,7 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => {
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500 text-xs">已删除</div>
|
||||
<div className="font-medium text-green-600">
|
||||
{batch.totalMaterialsDeleted}
|
||||
</div>
|
||||
<div className="font-medium text-green-600">{batch.totalMaterialsDeleted}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500 text-xs">失败</div>
|
||||
@@ -388,56 +395,51 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => {
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-4 text-xs text-gray-600">
|
||||
{executions
|
||||
.filter(
|
||||
(e) =>
|
||||
currentAttempt === undefined ||
|
||||
e.attemptNumber === currentAttempt
|
||||
)
|
||||
.map((exec) => (
|
||||
<React.Fragment key={exec.attemptNumber}>
|
||||
<span>
|
||||
耗时:{formatDuration(exec.operationTime, exec.endTime)}
|
||||
{visibleExecutions.map((exec) => (
|
||||
<React.Fragment key={exec.attemptNumber}>
|
||||
<span>耗时:{formatDuration(exec.operationTime, exec.endTime)}</span>
|
||||
<span>
|
||||
订单:{exec.ordersProcessed}/{exec.totalOrders}
|
||||
</span>
|
||||
<span>删除:{exec.totalMaterialsDeleted}</span>
|
||||
{exec.totalMaterialsFailed > 0 && (
|
||||
<span className="text-red-600">失败:{exec.totalMaterialsFailed}</span>
|
||||
)}
|
||||
{exec.totalUncertainDeletions > 0 && (
|
||||
<span className="text-amber-600">
|
||||
不确定:{exec.totalUncertainDeletions}
|
||||
</span>
|
||||
<span>
|
||||
订单:{exec.ordersProcessed}/{exec.totalOrders}
|
||||
)}
|
||||
{exec.errorMessage && (
|
||||
<span className="text-red-600" title={exec.errorMessage}>
|
||||
错误:{exec.errorMessage.substring(0, 80)}
|
||||
{exec.errorMessage.length > 80 ? '...' : ''}
|
||||
</span>
|
||||
<span>删除:{exec.totalMaterialsDeleted}</span>
|
||||
{exec.totalMaterialsFailed > 0 && (
|
||||
<span className="text-red-600">
|
||||
失败:{exec.totalMaterialsFailed}
|
||||
</span>
|
||||
)}
|
||||
{exec.totalUncertainDeletions > 0 && (
|
||||
<span className="text-amber-600">
|
||||
不确定:{exec.totalUncertainDeletions}
|
||||
</span>
|
||||
)}
|
||||
{exec.errorMessage && (
|
||||
<span className="text-red-600" title={exec.errorMessage}>
|
||||
错误:{exec.errorMessage.substring(0, 80)}
|
||||
{exec.errorMessage.length > 80 ? '...' : ''}
|
||||
</span>
|
||||
)}
|
||||
{exec.appVersion && (
|
||||
<span className="text-gray-400">v{exec.appVersion}</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
)}
|
||||
{exec.appVersion && <span className="text-gray-400">v{exec.appVersion}</span>}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Order table */}
|
||||
{filteredOrders.length > 0 ? (
|
||||
{detailsLoadState === 'loading' && executions.length === 0 && orders.length === 0 ? (
|
||||
<div className="px-4 py-6 text-center text-sm text-gray-500">加载详情中...</div>
|
||||
) : detailsLoadState === 'error' && executions.length === 0 && orders.length === 0 ? (
|
||||
<div className="px-4 py-6 text-center text-sm text-red-600">
|
||||
加载详情失败,请折叠后重新展开重试
|
||||
</div>
|
||||
) : filteredOrders.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left font-medium text-gray-600 w-8" />
|
||||
<th className="px-4 py-2 text-left font-medium text-gray-600">
|
||||
总排号
|
||||
<th className="px-4 py-2 text-left font-medium text-gray-600 w-12 text-center">
|
||||
序号
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left font-medium text-gray-600">总排号</th>
|
||||
<th className="px-4 py-2 text-left font-medium text-gray-600">
|
||||
<div className="flex items-center gap-2">
|
||||
订单号
|
||||
@@ -446,42 +448,27 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => {
|
||||
onClick={() => handleCopyColumn('orderNumber')}
|
||||
title="复制所有订单号"
|
||||
>
|
||||
<Copy
|
||||
size={14}
|
||||
className="text-gray-500 hover:text-gray-700"
|
||||
/>
|
||||
<Copy size={14} className="text-gray-500 hover:text-gray-700" />
|
||||
</button>
|
||||
</div>
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left font-medium text-gray-600">
|
||||
状态
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left font-medium text-gray-600">
|
||||
重试
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left font-medium text-gray-600">
|
||||
已删除
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left font-medium text-gray-600">
|
||||
已跳过
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left font-medium text-gray-600">
|
||||
失败
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left font-medium text-gray-600">
|
||||
不确定
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left font-medium text-gray-600">
|
||||
错误信息
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left font-medium text-gray-600">状态</th>
|
||||
<th className="px-4 py-2 text-left font-medium text-gray-600">重试</th>
|
||||
<th className="px-4 py-2 text-left font-medium text-gray-600">已删除</th>
|
||||
<th className="px-4 py-2 text-left font-medium text-gray-600">已跳过</th>
|
||||
<th className="px-4 py-2 text-left font-medium text-gray-600">失败</th>
|
||||
<th className="px-4 py-2 text-left font-medium text-gray-600">不确定</th>
|
||||
<th className="px-4 py-2 text-left font-medium text-gray-600">错误信息</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{filteredOrders.map((order) => {
|
||||
{filteredOrders.map((order, index) => {
|
||||
const orderKey = `${currentAttempt ?? order.attemptNumber}:${order.orderNumber}`
|
||||
const isOrderExpanded = expandedOrders.has(orderKey)
|
||||
const materials = orderMaterials.get(orderKey) || []
|
||||
const isLoadingMaterials = loadingMaterials.has(orderKey)
|
||||
const materialLoadState = materialLoadStates.get(orderKey) ?? 'idle'
|
||||
const orderStatusDisplay = getCleanerHistoryStatusDisplay(order.status)
|
||||
|
||||
return (
|
||||
<React.Fragment key={orderKey}>
|
||||
@@ -501,6 +488,9 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => {
|
||||
<ChevronRight size={14} className="text-gray-400" />
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-500 font-medium text-xs text-center">
|
||||
{index + 1}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-900 font-mono text-xs">
|
||||
{order.productionId || '-'}
|
||||
</td>
|
||||
@@ -509,12 +499,10 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => {
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium ${
|
||||
statusStyles[order.status] || statusStyles.pending
|
||||
}`}
|
||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium ${orderStatusDisplay.badgeClassName}`}
|
||||
>
|
||||
{statusIcons[order.status]}
|
||||
{statusLabels[order.status] || order.status}
|
||||
{orderStatusDisplay.icon}
|
||||
{orderStatusDisplay.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
@@ -541,15 +529,9 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => {
|
||||
<span className="text-gray-400 text-xs">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-green-600">
|
||||
{order.materialsDeleted}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-500">
|
||||
{order.materialsSkipped}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-red-600">
|
||||
{order.materialsFailed || '-'}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-green-600">{order.materialsDeleted}</td>
|
||||
<td className="px-4 py-2 text-gray-500">{order.materialsSkipped}</td>
|
||||
<td className="px-4 py-2 text-red-600">{order.materialsFailed || '-'}</td>
|
||||
<td className="px-4 py-2 text-amber-600">
|
||||
{order.uncertainDeletions || '-'}
|
||||
</td>
|
||||
@@ -561,30 +543,29 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => {
|
||||
{/* Material details */}
|
||||
{isOrderExpanded && (
|
||||
<tr>
|
||||
<td colSpan={10} className="bg-gray-50/50 px-8 py-3">
|
||||
<td colSpan={11} className="bg-gray-50/50 px-8 py-3">
|
||||
{isLoadingMaterials ? (
|
||||
<div className="text-xs text-gray-500">
|
||||
加载物料详情...
|
||||
<div className="text-xs text-gray-500">加载物料详情...</div>
|
||||
) : materialLoadState === 'error' ? (
|
||||
<div className="text-xs text-red-600">
|
||||
加载物料详情失败,请折叠后重新展开重试
|
||||
</div>
|
||||
) : materials.length > 0 ? (
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-gray-500">
|
||||
<th className="px-3 py-1.5 text-left font-medium w-12 text-center">
|
||||
序号
|
||||
</th>
|
||||
<th className="px-3 py-1.5 text-left font-medium">
|
||||
物料编码
|
||||
</th>
|
||||
<th className="px-3 py-1.5 text-left font-medium">
|
||||
物料名称
|
||||
</th>
|
||||
<th className="px-3 py-1.5 text-left font-medium">
|
||||
行号
|
||||
</th>
|
||||
<th className="px-3 py-1.5 text-left font-medium">
|
||||
结果
|
||||
</th>
|
||||
<th className="px-3 py-1.5 text-left font-medium">
|
||||
原因
|
||||
</th>
|
||||
<th className="px-3 py-1.5 text-left font-medium">行号</th>
|
||||
<th className="px-3 py-1.5 text-left font-medium">结果</th>
|
||||
<th className="px-3 py-1.5 text-left font-medium">原因</th>
|
||||
<th className="px-3 py-1.5 text-left font-medium">
|
||||
尝试次数
|
||||
</th>
|
||||
@@ -592,61 +573,12 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => {
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{materials.map((mat, idx) => (
|
||||
<tr key={idx} className="hover:bg-gray-50">
|
||||
<td className="px-3 py-1.5 font-mono text-gray-700">
|
||||
{mat.materialCode}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-gray-700">
|
||||
{mat.materialName}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-gray-600">
|
||||
{mat.rowNumber}
|
||||
</td>
|
||||
<td className="px-3 py-1.5">
|
||||
<span
|
||||
className={`px-1.5 py-0.5 rounded text-xs font-medium ${
|
||||
mat.result === 'deleted'
|
||||
? 'bg-green-100 text-green-700'
|
||||
: mat.result === 'skipped'
|
||||
? 'bg-gray-100 text-gray-700'
|
||||
: mat.result === 'failed'
|
||||
? 'bg-red-100 text-red-700'
|
||||
: mat.result === 'uncertain'
|
||||
? 'bg-amber-100 text-amber-700'
|
||||
: 'bg-gray-100 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{mat.result === 'deleted'
|
||||
? '已删除'
|
||||
: mat.result === 'skipped'
|
||||
? '已跳过'
|
||||
: mat.result === 'failed'
|
||||
? '失败'
|
||||
: mat.result === 'uncertain'
|
||||
? '不确定'
|
||||
: mat.result}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-gray-600 max-w-xs truncate">
|
||||
{mat.reason || '-'}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-gray-600">
|
||||
{mat.attemptCount > 1 ? (
|
||||
<span className="text-amber-600">
|
||||
{mat.attemptCount}
|
||||
</span>
|
||||
) : (
|
||||
'1'
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
<MaterialDetailRow key={idx} index={idx} material={mat} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<div className="text-xs text-gray-500">
|
||||
暂无物料详情
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">暂无物料详情</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -658,9 +590,7 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => {
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-4 py-6 text-center text-sm text-gray-500">
|
||||
暂无订单记录
|
||||
</div>
|
||||
<div className="px-4 py-6 text-center text-sm text-gray-500">暂无订单记录</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -670,6 +600,41 @@ const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => {
|
||||
|
||||
BatchItem.displayName = 'BatchItem'
|
||||
|
||||
interface MaterialDetailRowProps {
|
||||
index: number
|
||||
material: CleanerHistoryMaterialRecord
|
||||
}
|
||||
|
||||
const MaterialDetailRow = ({ index, material }: MaterialDetailRowProps): React.JSX.Element => {
|
||||
const resultDisplay = getCleanerMaterialResultDisplay(material.result)
|
||||
|
||||
return (
|
||||
<tr className="hover:bg-gray-50">
|
||||
<td className="px-3 py-1.5 text-gray-500 font-medium text-xs text-center">{index + 1}</td>
|
||||
<td className="px-3 py-1.5 font-mono text-gray-700">{material.materialCode}</td>
|
||||
<td className="px-3 py-1.5 text-gray-700">{material.materialName}</td>
|
||||
<td className="px-3 py-1.5 text-gray-600">{material.rowNumber}</td>
|
||||
<td className="px-3 py-1.5">
|
||||
{resultDisplay.icon ? (
|
||||
<span className="inline-block cursor-help" title={resultDisplay.title}>
|
||||
{resultDisplay.icon}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-500">{resultDisplay.title}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-gray-600 max-w-xs truncate">{material.reason || '-'}</td>
|
||||
<td className="px-3 py-1.5 text-gray-600">
|
||||
{material.attemptCount > 1 ? (
|
||||
<span className="text-amber-600">{material.attemptCount}</span>
|
||||
) : (
|
||||
'1'
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
// ====== Main Modal Component ======
|
||||
export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModalProps> = ({
|
||||
isOpen,
|
||||
@@ -681,9 +646,14 @@ export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModal
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [allUsers, setAllUsers] = useState<string[]>([])
|
||||
const [selectedUsers, setSelectedUsers] = useState<string[]>([])
|
||||
const [currentPage, setCurrentPage] = useState(0)
|
||||
const [isFilterPending, startFilterTransition] = useTransition()
|
||||
const { confirm, dialog: confirmDialog } = useConfirmDialog()
|
||||
const logger = useLogger('CleanerOperationHistory')
|
||||
|
||||
const isAdmin = user?.userType === 'Admin'
|
||||
const hasPreviousPage = currentPage > 0
|
||||
const hasNextPage = batches.length === BATCH_PAGE_SIZE
|
||||
|
||||
const fetchBatches = useCallback(async () => {
|
||||
setLoading(true)
|
||||
@@ -691,8 +661,12 @@ export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModal
|
||||
try {
|
||||
const options =
|
||||
isAdmin && selectedUsers.length > 0
|
||||
? { limit: 100, usernames: selectedUsers }
|
||||
: { limit: 100 }
|
||||
? {
|
||||
limit: BATCH_PAGE_SIZE,
|
||||
offset: currentPage * BATCH_PAGE_SIZE,
|
||||
usernames: selectedUsers
|
||||
}
|
||||
: { limit: BATCH_PAGE_SIZE, offset: currentPage * BATCH_PAGE_SIZE }
|
||||
|
||||
const result = await window.electron.cleaner.getHistoryBatches(options)
|
||||
if (result.success && result.data) {
|
||||
@@ -705,7 +679,7 @@ export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModal
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [isAdmin, selectedUsers])
|
||||
}, [currentPage, isAdmin, selectedUsers])
|
||||
|
||||
const fetchAllUsers = useCallback(async () => {
|
||||
try {
|
||||
@@ -721,8 +695,6 @@ export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModal
|
||||
}
|
||||
}, [logger])
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
void fetchBatches()
|
||||
@@ -736,20 +708,65 @@ export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModal
|
||||
setBatches((prev) => prev.filter((b) => b.batchId !== batchId))
|
||||
}, [])
|
||||
|
||||
const requestDeleteConfirmation = useCallback(
|
||||
async (batchId: string) => {
|
||||
const batch = batches.find((item) => item.batchId === batchId)
|
||||
const ownerLabel = batch ? `操作人:${batch.username}` : '此操作不可撤销。'
|
||||
|
||||
return confirm({
|
||||
title: '确认删除历史批次',
|
||||
message: (
|
||||
<div className="space-y-3">
|
||||
<p className="text-gray-700">确定要删除这条清理操作历史吗?</p>
|
||||
<div className="rounded-lg border border-red-100 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
<div>该操作会一并删除批次、订单和物料明细记录。</div>
|
||||
<div className="mt-1 text-red-600/90">{ownerLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
confirmText: '删除',
|
||||
cancelText: '取消',
|
||||
variant: 'danger'
|
||||
})
|
||||
},
|
||||
[batches, confirm]
|
||||
)
|
||||
|
||||
const toggleUserFilter = (username: string) => {
|
||||
setSelectedUsers((prev) =>
|
||||
prev.includes(username) ? prev.filter((u) => u !== username) : [...prev, username]
|
||||
)
|
||||
startFilterTransition(() => {
|
||||
setCurrentPage(0)
|
||||
setSelectedUsers((prev) =>
|
||||
prev.includes(username) ? prev.filter((u) => u !== username) : [...prev, username]
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const clearUserFilters = () => {
|
||||
setSelectedUsers([])
|
||||
startFilterTransition(() => {
|
||||
setCurrentPage(0)
|
||||
setSelectedUsers([])
|
||||
})
|
||||
}
|
||||
|
||||
const goToPreviousPage = () => {
|
||||
setCurrentPage((prev) => Math.max(0, prev - 1))
|
||||
}
|
||||
|
||||
const goToNextPage = () => {
|
||||
if (!hasNextPage) return
|
||||
setCurrentPage((prev) => prev + 1)
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="清理操作历史" size="3xl" className="!max-w-[68rem]">
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="清理操作历史"
|
||||
size="3xl"
|
||||
className="!max-w-[68rem]"
|
||||
>
|
||||
<div className="flex flex-col h-[70vh]">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-start justify-between mb-4 pb-4 border-b border-gray-200">
|
||||
@@ -799,8 +816,9 @@ export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModal
|
||||
)}
|
||||
</span>
|
||||
{batches.length > 0 && (
|
||||
<span className="text-sm text-gray-500">共 {batches.length} 条批次</span>
|
||||
<span className="text-sm text-gray-500">本页 {batches.length} 条批次</span>
|
||||
)}
|
||||
{isFilterPending && <span className="text-sm text-blue-600">正在更新筛选...</span>}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -834,6 +852,7 @@ export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModal
|
||||
batch={batch}
|
||||
isAdmin={isAdmin}
|
||||
onDelete={handleDeleteBatch}
|
||||
onRequestDelete={requestDeleteConfirmation}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -841,16 +860,29 @@ export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModal
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{/* Footer */}
|
||||
<div className="pt-4 border-t border-gray-200 flex justify-end">
|
||||
<button
|
||||
className="px-6 py-2 rounded-md bg-gray-100 hover:bg-gray-200 text-gray-700 font-medium transition-colors"
|
||||
onClick={onClose}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
<div className="pt-4 border-t border-gray-200 flex justify-center">
|
||||
<div className="inline-flex items-center rounded-full border border-slate-200 bg-white p-1 shadow-sm">
|
||||
<button
|
||||
className="inline-flex items-center rounded-full px-4 py-2 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-100 disabled:cursor-not-allowed disabled:text-slate-300"
|
||||
onClick={goToPreviousPage}
|
||||
disabled={loading || !hasPreviousPage}
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<div className="mx-1 min-w-[5.5rem] rounded-full bg-slate-900 px-4 py-2 text-center text-sm font-semibold text-white">
|
||||
第 {currentPage + 1} 页
|
||||
</div>
|
||||
<button
|
||||
className="inline-flex items-center rounded-full px-4 py-2 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-100 disabled:cursor-not-allowed disabled:text-slate-300"
|
||||
onClick={goToNextPage}
|
||||
disabled={loading || !hasNextPage}
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{confirmDialog && <ConfirmDialog {...confirmDialog} />}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import React from 'react'
|
||||
import { CheckCircle, XCircle, SkipForward, Package, Loader2, AlertTriangle } from 'lucide-react'
|
||||
import { Modal } from './ui/Modal'
|
||||
import type { CleanerProgress } from '../hooks/cleaner/types'
|
||||
import { getExecutionReportState } from './execution-report-state'
|
||||
|
||||
interface ExecutionReportDialogProps {
|
||||
isOpen: boolean
|
||||
@@ -56,6 +57,12 @@ export const ExecutionReportDialog: React.FC<ExecutionReportDialogProps> = ({
|
||||
const hasFailedMaterials = materialsFailed > 0 || uncertainDeletions > 0
|
||||
const showProgress = isExecuting && progress
|
||||
const isProgressing = !!showProgress
|
||||
const reportState = getExecutionReportState({
|
||||
dryRun,
|
||||
errors,
|
||||
materialsFailed,
|
||||
uncertainDeletions
|
||||
})
|
||||
|
||||
// Update timer during progress
|
||||
React.useEffect(() => {
|
||||
@@ -123,11 +130,7 @@ export const ExecutionReportDialog: React.FC<ExecutionReportDialogProps> = ({
|
||||
title={
|
||||
isProgressing
|
||||
? '正在执行清理...'
|
||||
: dryRun
|
||||
? '预览执行报告'
|
||||
: hasErrors
|
||||
? '执行完成 (有错误)'
|
||||
: '执行完成'
|
||||
: reportState.title
|
||||
}
|
||||
size={isProgressing ? 'lg' : 'md'}
|
||||
showCloseButton={!isExecuting}
|
||||
@@ -161,19 +164,17 @@ export const ExecutionReportDialog: React.FC<ExecutionReportDialogProps> = ({
|
||||
<div className="flex justify-center mb-4">
|
||||
{dryRun ? (
|
||||
<Package className="w-12 h-12 text-amber-500" />
|
||||
) : hasErrors ? (
|
||||
) : reportState.state === 'failure' ? (
|
||||
<XCircle className="w-12 h-12 text-red-500" />
|
||||
) : reportState.state === 'partial_success' ? (
|
||||
<AlertTriangle className="w-12 h-12 text-amber-500" />
|
||||
) : reportState.state === 'manual_review' ? (
|
||||
<AlertTriangle className="w-12 h-12 text-yellow-500" />
|
||||
) : (
|
||||
<CheckCircle className="w-12 h-12 text-green-500" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">
|
||||
{dryRun
|
||||
? '预览模式 - 未实际删除数据'
|
||||
: hasErrors
|
||||
? '部分操作未能完成,请查看下方错误信息'
|
||||
: '所有操作已成功完成'}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">{reportState.summary}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -405,19 +406,33 @@ export const ExecutionReportDialog: React.FC<ExecutionReportDialogProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasErrors && !dryRun && (
|
||||
{reportState.showSuccessBanner && (
|
||||
<div className="flex items-center justify-center gap-2 p-3 bg-green-50 rounded-lg border border-green-200 text-green-700 text-sm">
|
||||
<CheckCircle size={16} className="flex-shrink-0" />
|
||||
<span>操作已成功完成,数据已同步到 ERP 系统</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasErrors && dryRun && (
|
||||
{reportState.showPreviewBanner && (
|
||||
<div className="flex items-center justify-center gap-2 p-3 bg-amber-50 rounded-lg border border-amber-200 text-amber-700 text-sm">
|
||||
<Package size={16} className="flex-shrink-0" />
|
||||
<span>预览模式结束,数据未实际修改。确认无误后可正式执行。</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{reportState.state === 'partial_success' && (
|
||||
<div className="flex items-center justify-center gap-2 p-3 bg-amber-50 rounded-lg border border-amber-200 text-amber-700 text-sm">
|
||||
<AlertTriangle size={16} className="flex-shrink-0" />
|
||||
<span>存在删除失败的物料,本次执行未完全成功,建议结合历史记录继续排查。</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{reportState.state === 'manual_review' && (
|
||||
<div className="flex items-center justify-center gap-2 p-3 bg-yellow-50 rounded-lg border border-yellow-200 text-yellow-700 text-sm">
|
||||
<AlertTriangle size={16} className="flex-shrink-0" />
|
||||
<span>存在不确定删除结果,请人工确认 ERP 中的最终状态后再继续后续操作。</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { Modal } from './ui/Modal'
|
||||
import { ConfirmDialog } from './ui/ConfirmDialog'
|
||||
import { useConfirmDialog } from './ui/useConfirmDialog'
|
||||
import { useLogger } from '../hooks/useLogger'
|
||||
import {
|
||||
RefreshCw,
|
||||
@@ -83,6 +85,7 @@ export const ExtractorOperationHistoryModal: React.FC<ExtractorOperationHistoryM
|
||||
const [deleting, setDeleting] = useState<Set<string>>(new Set())
|
||||
const [allUsers, setAllUsers] = useState<string[]>([])
|
||||
const [selectedUsers, setSelectedUsers] = useState<string[]>([])
|
||||
const { confirm, dialog: confirmDialog } = useConfirmDialog()
|
||||
const logger = useLogger('OperationHistory')
|
||||
|
||||
const isAdmin = user?.userType === 'Admin'
|
||||
@@ -172,7 +175,24 @@ export const ExtractorOperationHistoryModal: React.FC<ExtractorOperationHistoryM
|
||||
const handleDeleteBatch = async (batchId: string) => {
|
||||
if (deleting.has(batchId)) return
|
||||
|
||||
const confirmed = confirm('确定要删除此批次记录吗?此操作不可撤销。')
|
||||
const batch = batches.find((item) => item.batchId === batchId)
|
||||
const ownerLabel = batch ? `操作人:${batch.username}` : '此操作不可撤销。'
|
||||
|
||||
const confirmed = await confirm({
|
||||
title: '确认删除提取历史',
|
||||
message: (
|
||||
<div className="space-y-3">
|
||||
<p className="text-gray-700">确定要删除这条提取操作历史吗?</p>
|
||||
<div className="rounded-lg border border-red-100 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
<div>该操作会删除当前批次下的提取记录明细。</div>
|
||||
<div className="mt-1 text-red-600/90">{ownerLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
confirmText: '删除',
|
||||
cancelText: '取消',
|
||||
variant: 'danger'
|
||||
})
|
||||
if (!confirmed) return
|
||||
|
||||
setDeleting((prev) => new Set(prev).add(batchId))
|
||||
@@ -503,6 +523,7 @@ export const ExtractorOperationHistoryModal: React.FC<ExtractorOperationHistoryM
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{confirmDialog && <ConfirmDialog {...confirmDialog} />}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
19
src/renderer/src/components/cleaner-history-load-state.ts
Normal file
19
src/renderer/src/components/cleaner-history-load-state.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export type HistoryLoadState = 'idle' | 'loading' | 'success' | 'error'
|
||||
|
||||
export const canStartHistoryLoad = (state: HistoryLoadState): boolean =>
|
||||
state === 'idle' || state === 'error'
|
||||
|
||||
export const getNextHistoryLoadState = (
|
||||
currentState: HistoryLoadState,
|
||||
event: 'start' | 'success' | 'error'
|
||||
): HistoryLoadState => {
|
||||
if (event === 'start') {
|
||||
return canStartHistoryLoad(currentState) ? 'loading' : currentState
|
||||
}
|
||||
|
||||
if (event === 'success') {
|
||||
return 'success'
|
||||
}
|
||||
|
||||
return 'error'
|
||||
}
|
||||
119
src/renderer/src/components/cleaner-history-status.tsx
Normal file
119
src/renderer/src/components/cleaner-history-status.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import React from 'react'
|
||||
import { AlertTriangle, CheckCircle, CircleMinus, Clock, XCircle } from 'lucide-react'
|
||||
|
||||
export type CleanerHistoryStatus =
|
||||
| 'success'
|
||||
| 'partial'
|
||||
| 'failed'
|
||||
| 'crashed'
|
||||
| 'pending'
|
||||
| 'not_found'
|
||||
| 'erp_not_found'
|
||||
|
||||
export interface CleanerHistoryStatusDisplay {
|
||||
label: string
|
||||
badgeClassName: string
|
||||
icon: React.ReactNode
|
||||
}
|
||||
|
||||
export interface CleanerMaterialResultDisplay {
|
||||
title: string
|
||||
icon: React.ReactNode | null
|
||||
}
|
||||
|
||||
const HISTORY_STATUS_META: Record<
|
||||
CleanerHistoryStatus,
|
||||
{ label: string; badgeClassName: string; iconClassName: string; icon: typeof CheckCircle }
|
||||
> = {
|
||||
success: {
|
||||
label: '成功',
|
||||
badgeClassName: 'bg-green-100 text-green-700',
|
||||
iconClassName: 'text-green-600',
|
||||
icon: CheckCircle
|
||||
},
|
||||
partial: {
|
||||
label: '部分成功',
|
||||
badgeClassName: 'bg-amber-100 text-amber-700',
|
||||
iconClassName: 'text-amber-600',
|
||||
icon: Clock
|
||||
},
|
||||
failed: {
|
||||
label: '失败',
|
||||
badgeClassName: 'bg-red-100 text-red-700',
|
||||
iconClassName: 'text-red-600',
|
||||
icon: XCircle
|
||||
},
|
||||
crashed: {
|
||||
label: '崩溃',
|
||||
badgeClassName: 'bg-red-100 text-red-700',
|
||||
iconClassName: 'text-red-600',
|
||||
icon: XCircle
|
||||
},
|
||||
pending: {
|
||||
label: '进行中',
|
||||
badgeClassName: 'bg-gray-100 text-gray-700',
|
||||
iconClassName: 'text-gray-500',
|
||||
icon: Clock
|
||||
},
|
||||
not_found: {
|
||||
label: '未找到',
|
||||
badgeClassName: 'bg-orange-100 text-orange-700',
|
||||
iconClassName: 'text-orange-600',
|
||||
icon: XCircle
|
||||
},
|
||||
erp_not_found: {
|
||||
label: 'ERP不存在',
|
||||
badgeClassName: 'bg-orange-100 text-orange-700',
|
||||
iconClassName: 'text-orange-600',
|
||||
icon: XCircle
|
||||
}
|
||||
}
|
||||
|
||||
export function getCleanerHistoryStatusDisplay(status: string, size = 16): CleanerHistoryStatusDisplay {
|
||||
const meta = HISTORY_STATUS_META[(status in HISTORY_STATUS_META ? status : 'pending') as CleanerHistoryStatus]
|
||||
const Icon = meta.icon
|
||||
|
||||
return {
|
||||
label: status in HISTORY_STATUS_META ? meta.label : status,
|
||||
badgeClassName: meta.badgeClassName,
|
||||
icon: <Icon size={size} className={meta.iconClassName} />
|
||||
}
|
||||
}
|
||||
|
||||
export function getCleanerMaterialResultDisplay(
|
||||
result: string | null | undefined,
|
||||
size = 16
|
||||
): CleanerMaterialResultDisplay {
|
||||
if (result === 'success' || result === 'deleted') {
|
||||
return {
|
||||
title: 'Deleted',
|
||||
icon: <CheckCircle size={size} className="text-green-600 flex-shrink-0" aria-label="Deleted" />
|
||||
}
|
||||
}
|
||||
|
||||
if (result === 'skipped') {
|
||||
return {
|
||||
title: 'Skipped',
|
||||
icon: <CircleMinus size={size} className="text-gray-400 flex-shrink-0" aria-label="Skipped" />
|
||||
}
|
||||
}
|
||||
|
||||
if (result === 'uncertain') {
|
||||
return {
|
||||
title: 'Uncertain',
|
||||
icon: <AlertTriangle size={size} className="text-amber-600 flex-shrink-0" aria-label="Uncertain" />
|
||||
}
|
||||
}
|
||||
|
||||
if (result?.startsWith('failed')) {
|
||||
return {
|
||||
title: 'Failed',
|
||||
icon: <XCircle size={size} className="text-red-600 flex-shrink-0" aria-label="Failed" />
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title: result || 'Unknown',
|
||||
icon: null
|
||||
}
|
||||
}
|
||||
80
src/renderer/src/components/execution-report-state.ts
Normal file
80
src/renderer/src/components/execution-report-state.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
export type ExecutionReportResultState =
|
||||
| 'preview'
|
||||
| 'success'
|
||||
| 'partial_success'
|
||||
| 'manual_review'
|
||||
| 'failure'
|
||||
|
||||
export interface ExecutionReportStateInput {
|
||||
dryRun?: boolean
|
||||
errors?: string[]
|
||||
materialsFailed?: number
|
||||
uncertainDeletions?: number
|
||||
}
|
||||
|
||||
export interface ExecutionReportStateDescriptor {
|
||||
state: ExecutionReportResultState
|
||||
title: string
|
||||
summary: string
|
||||
showSuccessBanner: boolean
|
||||
showPreviewBanner: boolean
|
||||
}
|
||||
|
||||
export function getExecutionReportState(
|
||||
input: ExecutionReportStateInput
|
||||
): ExecutionReportStateDescriptor {
|
||||
const {
|
||||
dryRun = false,
|
||||
errors = [],
|
||||
materialsFailed = 0,
|
||||
uncertainDeletions = 0
|
||||
} = input
|
||||
|
||||
if (dryRun) {
|
||||
return {
|
||||
state: 'preview',
|
||||
title: '预览执行报告',
|
||||
summary: '预览模式 - 未实际删除数据',
|
||||
showSuccessBanner: false,
|
||||
showPreviewBanner: true
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return {
|
||||
state: 'failure',
|
||||
title: '执行完成 (失败)',
|
||||
summary: '执行过程中出现错误,请先处理错误后再继续。',
|
||||
showSuccessBanner: false,
|
||||
showPreviewBanner: false
|
||||
}
|
||||
}
|
||||
|
||||
if (materialsFailed > 0) {
|
||||
return {
|
||||
state: 'partial_success',
|
||||
title: '执行完成 (部分成功)',
|
||||
summary: '部分物料删除失败,请结合下方统计和历史记录继续排查。',
|
||||
showSuccessBanner: false,
|
||||
showPreviewBanner: false
|
||||
}
|
||||
}
|
||||
|
||||
if (uncertainDeletions > 0) {
|
||||
return {
|
||||
state: 'manual_review',
|
||||
title: '执行完成 (需人工确认)',
|
||||
summary: '存在不确定删除结果,请人工复核后再判断是否完成。',
|
||||
showSuccessBanner: false,
|
||||
showPreviewBanner: false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
state: 'success',
|
||||
title: '执行完成',
|
||||
summary: '所有操作已成功完成',
|
||||
showSuccessBanner: true,
|
||||
showPreviewBanner: false
|
||||
}
|
||||
}
|
||||
@@ -167,17 +167,19 @@ const CleanerPage: React.FC = () => {
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
<Suspense fallback={null}>
|
||||
<CleanerOperationHistoryModal
|
||||
isOpen={showHistoryModal}
|
||||
onClose={() => setShowHistoryModal(false)}
|
||||
user={
|
||||
currentUsername
|
||||
? { username: currentUsername, userType: isAdmin ? 'Admin' : 'User' }
|
||||
: null
|
||||
}
|
||||
/>
|
||||
</Suspense>
|
||||
{showHistoryModal ? (
|
||||
<Suspense fallback={null}>
|
||||
<CleanerOperationHistoryModal
|
||||
isOpen={showHistoryModal}
|
||||
onClose={() => setShowHistoryModal(false)}
|
||||
user={
|
||||
currentUsername
|
||||
? { username: currentUsername, userType: isAdmin ? 'Admin' : 'User' }
|
||||
: null
|
||||
}
|
||||
/>
|
||||
</Suspense>
|
||||
) : null}
|
||||
|
||||
{/* Confirmation Dialog */}
|
||||
{confirmDialog && <ConfirmDialog {...confirmDialog} />}
|
||||
|
||||
29
tests/unit/cleaner-history-load-state.test.ts
Normal file
29
tests/unit/cleaner-history-load-state.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
canStartHistoryLoad,
|
||||
getNextHistoryLoadState
|
||||
} from '../../src/renderer/src/components/cleaner-history-load-state'
|
||||
|
||||
describe('cleaner history load state helpers', () => {
|
||||
it('allows initial and failed loads to retry', () => {
|
||||
expect(canStartHistoryLoad('idle')).toBe(true)
|
||||
expect(canStartHistoryLoad('error')).toBe(true)
|
||||
})
|
||||
|
||||
it('prevents duplicate requests after loading starts or succeeds', () => {
|
||||
expect(canStartHistoryLoad('loading')).toBe(false)
|
||||
expect(canStartHistoryLoad('success')).toBe(false)
|
||||
})
|
||||
|
||||
it('retries after an error but keeps successful loads cached', () => {
|
||||
const failedState = getNextHistoryLoadState('loading', 'error')
|
||||
const retryState = getNextHistoryLoadState(failedState, 'start')
|
||||
const successState = getNextHistoryLoadState(retryState, 'success')
|
||||
const blockedState = getNextHistoryLoadState(successState, 'start')
|
||||
|
||||
expect(failedState).toBe('error')
|
||||
expect(retryState).toBe('loading')
|
||||
expect(successState).toBe('success')
|
||||
expect(blockedState).toBe('success')
|
||||
})
|
||||
})
|
||||
42
tests/unit/cleaner-history-status.test.tsx
Normal file
42
tests/unit/cleaner-history-status.test.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
getCleanerHistoryStatusDisplay,
|
||||
getCleanerMaterialResultDisplay
|
||||
} from '../../src/renderer/src/components/cleaner-history-status'
|
||||
|
||||
describe('cleaner history status helpers', () => {
|
||||
it('returns shared label, badge class and icon for known statuses', () => {
|
||||
const display = getCleanerHistoryStatusDisplay('erp_not_found')
|
||||
|
||||
expect(display.label).toBe('ERP不存在')
|
||||
expect(display.badgeClassName).toBe('bg-orange-100 text-orange-700')
|
||||
expect(renderToStaticMarkup(React.createElement(React.Fragment, null, display.icon))).toContain(
|
||||
'text-orange-600'
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to pending style for unknown statuses while preserving text', () => {
|
||||
const display = getCleanerHistoryStatusDisplay('custom_status')
|
||||
|
||||
expect(display.label).toBe('custom_status')
|
||||
expect(display.badgeClassName).toBe('bg-gray-100 text-gray-700')
|
||||
})
|
||||
|
||||
it('maps failed material outcomes through the shared helper', () => {
|
||||
const display = getCleanerMaterialResultDisplay('failed_timeout')
|
||||
|
||||
expect(display.title).toBe('Failed')
|
||||
expect(renderToStaticMarkup(React.createElement(React.Fragment, null, display.icon))).toContain(
|
||||
'text-red-600'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns plain text for unknown material outcomes', () => {
|
||||
const display = getCleanerMaterialResultDisplay('needs_manual_check')
|
||||
|
||||
expect(display.title).toBe('needs_manual_check')
|
||||
expect(display.icon).toBeNull()
|
||||
})
|
||||
})
|
||||
107
tests/unit/cleaner-page-history-lazy.test.tsx
Normal file
107
tests/unit/cleaner-page-history-lazy.test.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import React from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
|
||||
const { mockHistoryModalModuleLoad } = vi.hoisted(() => ({
|
||||
mockHistoryModalModuleLoad: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../src/renderer/src/hooks/useCleaner', () => ({
|
||||
useCleaner: () => ({
|
||||
isAdmin: false,
|
||||
currentUsername: 'tester',
|
||||
dryRun: false,
|
||||
setDryRun: vi.fn(),
|
||||
valMode: 'database_full',
|
||||
setValMode: vi.fn(),
|
||||
validationResults: [],
|
||||
selectedItems: new Set<string>(),
|
||||
setSelectedItems: vi.fn(),
|
||||
setHiddenItems: vi.fn(),
|
||||
managers: [],
|
||||
selectedManagers: [],
|
||||
setSelectedManagers: vi.fn(),
|
||||
isRunning: false,
|
||||
isExecuting: false,
|
||||
isValidationRunning: false,
|
||||
isExporting: false,
|
||||
isTypeDialogOpen: false,
|
||||
setIsTypeDialogOpen: vi.fn(),
|
||||
headless: false,
|
||||
setHeadless: vi.fn(),
|
||||
processConcurrency: 1,
|
||||
updateProcessConcurrency: vi.fn(),
|
||||
showSettingsMenu: false,
|
||||
setShowSettingsMenu: vi.fn(),
|
||||
filteredResults: [],
|
||||
isReportDialogOpen: false,
|
||||
setIsReportDialogOpen: vi.fn(),
|
||||
reportData: null,
|
||||
editingCell: null,
|
||||
editValue: '',
|
||||
setEditValue: vi.fn(),
|
||||
inputRef: { current: null },
|
||||
startEdit: vi.fn(),
|
||||
saveEdit: vi.fn(),
|
||||
cancelEdit: vi.fn(),
|
||||
handleAssignManagerOnSelect: vi.fn(),
|
||||
progress: null,
|
||||
startTime: null,
|
||||
resetStartTime: vi.fn(),
|
||||
handleValidation: vi.fn(),
|
||||
handleCheckboxToggle: vi.fn(),
|
||||
handleConfirmDeletion: vi.fn(),
|
||||
handleExecuteDeletion: vi.fn(),
|
||||
handleExportResults: vi.fn(),
|
||||
confirmDialog: null
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('../../src/renderer/src/components/cleaner/CleanerExecutionBar', () => ({
|
||||
CleanerExecutionBar: () => React.createElement('div', null, 'execution-bar')
|
||||
}))
|
||||
|
||||
vi.mock('../../src/renderer/src/components/cleaner/CleanerResultsTable', () => ({
|
||||
CleanerResultsTable: () => React.createElement('div', null, 'results-table')
|
||||
}))
|
||||
|
||||
vi.mock('../../src/renderer/src/components/cleaner/CleanerSidebar', () => ({
|
||||
CleanerSidebar: () => React.createElement('aside', null, 'sidebar')
|
||||
}))
|
||||
|
||||
vi.mock('../../src/renderer/src/components/cleaner/CleanerToolbar', () => ({
|
||||
CleanerToolbar: () => React.createElement('div', null, 'toolbar')
|
||||
}))
|
||||
|
||||
vi.mock('../../src/renderer/src/components/ui/ConfirmDialog', () => ({
|
||||
ConfirmDialog: () => React.createElement('div', null, 'confirm-dialog')
|
||||
}))
|
||||
|
||||
vi.mock('../../src/renderer/src/components/MaterialTypeManagementDialog', () => ({
|
||||
default: () => React.createElement('div', null, 'type-dialog')
|
||||
}))
|
||||
|
||||
vi.mock('../../src/renderer/src/components/ExecutionReportDialog', () => ({
|
||||
default: () => React.createElement('div', null, 'report-dialog')
|
||||
}))
|
||||
|
||||
vi.mock('../../src/renderer/src/components/CleanerOperationHistoryModal', () => {
|
||||
mockHistoryModalModuleLoad()
|
||||
return {
|
||||
default: () => React.createElement('div', null, 'history-dialog')
|
||||
}
|
||||
})
|
||||
|
||||
import CleanerPage from '../../src/renderer/src/pages/CleanerPage'
|
||||
|
||||
describe('CleanerPage history modal lazy loading', () => {
|
||||
beforeEach(() => {
|
||||
mockHistoryModalModuleLoad.mockClear()
|
||||
})
|
||||
|
||||
it('does not load the history modal module on the initial render', () => {
|
||||
renderToStaticMarkup(React.createElement(CleanerPage))
|
||||
|
||||
expect(mockHistoryModalModuleLoad).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
60
tests/unit/execution-report-state.test.ts
Normal file
60
tests/unit/execution-report-state.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getExecutionReportState } from '../../src/renderer/src/components/execution-report-state'
|
||||
|
||||
describe('execution report state helpers', () => {
|
||||
it('treats dry-run as preview regardless of counters', () => {
|
||||
const state = getExecutionReportState({
|
||||
dryRun: true,
|
||||
errors: ['should be ignored'],
|
||||
materialsFailed: 1,
|
||||
uncertainDeletions: 1
|
||||
})
|
||||
|
||||
expect(state.state).toBe('preview')
|
||||
expect(state.title).toBe('预览执行报告')
|
||||
})
|
||||
|
||||
it('treats runtime errors as failure', () => {
|
||||
const state = getExecutionReportState({
|
||||
errors: ['boom'],
|
||||
materialsFailed: 0,
|
||||
uncertainDeletions: 0
|
||||
})
|
||||
|
||||
expect(state.state).toBe('failure')
|
||||
expect(state.title).toBe('执行完成 (失败)')
|
||||
})
|
||||
|
||||
it('treats failed materials as partial success when there are no runtime errors', () => {
|
||||
const state = getExecutionReportState({
|
||||
errors: [],
|
||||
materialsFailed: 3,
|
||||
uncertainDeletions: 0
|
||||
})
|
||||
|
||||
expect(state.state).toBe('partial_success')
|
||||
expect(state.showSuccessBanner).toBe(false)
|
||||
})
|
||||
|
||||
it('treats uncertain deletions as manual review when everything else succeeded', () => {
|
||||
const state = getExecutionReportState({
|
||||
errors: [],
|
||||
materialsFailed: 0,
|
||||
uncertainDeletions: 2
|
||||
})
|
||||
|
||||
expect(state.state).toBe('manual_review')
|
||||
expect(state.showSuccessBanner).toBe(false)
|
||||
})
|
||||
|
||||
it('treats clean completion as success', () => {
|
||||
const state = getExecutionReportState({
|
||||
errors: [],
|
||||
materialsFailed: 0,
|
||||
uncertainDeletions: 0
|
||||
})
|
||||
|
||||
expect(state.state).toBe('success')
|
||||
expect(state.showSuccessBanner).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -91,7 +91,7 @@ vi.mock('../../../../src/main/services/erp/erp-auth', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../../../../src/main/services/database/cleaner-operation-history-dao', () => ({
|
||||
CleanerOperationHistoryDAO: class {
|
||||
CleanerOperationHistoryDAO: class {
|
||||
async getBatchDetails(batchId: string) {
|
||||
return { executions: [{ attemptNumber: 1, isDryRun: false }], orders: [] }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const queryMock = vi.fn()
|
||||
const createMock = vi.fn()
|
||||
const trackDurationMock = vi.fn(async (fn: () => Promise<unknown>) => ({ result: await fn() }))
|
||||
|
||||
vi.mock('../../../../src/main/services/logger', () => ({
|
||||
createLogger: () => ({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn()
|
||||
}),
|
||||
getRequestId: () => 'test-request-id',
|
||||
trackDuration: trackDurationMock
|
||||
}))
|
||||
|
||||
vi.mock('../../../../src/main/services/database/index', () => ({
|
||||
create: createMock
|
||||
}))
|
||||
|
||||
describe('CleanerOperationHistoryDAO (PostgreSQL compatibility)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
createMock.mockResolvedValue({
|
||||
type: 'postgresql',
|
||||
isConnected: () => true,
|
||||
query: queryMock,
|
||||
disconnect: vi.fn()
|
||||
})
|
||||
})
|
||||
|
||||
it('uses PostgreSQL-compatible aggregation in getBatches', async () => {
|
||||
queryMock.mockResolvedValue({
|
||||
rows: [],
|
||||
columns: [],
|
||||
rowCount: 0
|
||||
})
|
||||
|
||||
const { CleanerOperationHistoryDAO } = await import(
|
||||
'../../../../src/main/services/database/cleaner-operation-history-dao'
|
||||
)
|
||||
const dao = new CleanerOperationHistoryDAO()
|
||||
|
||||
await dao.getBatches(undefined, { limit: 10 })
|
||||
|
||||
expect(queryMock).toHaveBeenCalledTimes(1)
|
||||
const sql = queryMock.mock.calls[0][0] as string
|
||||
|
||||
expect(sql).toContain('COALESCE(SUM(CASE WHEN o.Status = \'success\' THEN 1 ELSE 0 END), 0)')
|
||||
expect(sql).toContain('COALESCE(SUM(CASE WHEN o.Status = \'failed\' THEN 1 ELSE 0 END), 0)')
|
||||
expect(sql).toContain('MAX(CASE WHEN e.IsDryRun THEN 1 ELSE 0 END) as IsDryRun')
|
||||
expect(sql).not.toContain('ISNULL(')
|
||||
})
|
||||
|
||||
it('avoids SQL Server TOP syntax when checking delete permissions', async () => {
|
||||
queryMock
|
||||
.mockResolvedValueOnce({
|
||||
rows: [
|
||||
{
|
||||
ID: 1,
|
||||
BatchId: 'batch-1',
|
||||
AttemptNumber: 1,
|
||||
UserId: 7,
|
||||
Username: 'tester',
|
||||
OperationTime: new Date('2026-04-14T10:00:00.000Z'),
|
||||
EndTime: null,
|
||||
Status: 'success',
|
||||
IsDryRun: false,
|
||||
TotalOrders: 1,
|
||||
OrdersProcessed: 1,
|
||||
TotalMaterialsDeleted: 1,
|
||||
TotalMaterialsSkipped: 0,
|
||||
TotalMaterialsFailed: 0,
|
||||
TotalUncertainDeletions: 0,
|
||||
ErrorMessage: null,
|
||||
AppVersion: '1.12.3'
|
||||
}
|
||||
],
|
||||
columns: [],
|
||||
rowCount: 1
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
rows: [],
|
||||
columns: [],
|
||||
rowCount: 0
|
||||
})
|
||||
.mockResolvedValue({
|
||||
rows: [],
|
||||
columns: [],
|
||||
rowCount: 1
|
||||
})
|
||||
|
||||
const { CleanerOperationHistoryDAO } = await import(
|
||||
'../../../../src/main/services/database/cleaner-operation-history-dao'
|
||||
)
|
||||
const dao = new CleanerOperationHistoryDAO()
|
||||
|
||||
const result = await dao.deleteBatch('batch-1', 7, false)
|
||||
|
||||
expect(result).toEqual({ success: true })
|
||||
const executedSql = queryMock.mock.calls.map(([sql]) => sql as string).join('\n')
|
||||
expect(executedSql).not.toContain('TOP 1')
|
||||
expect(executedSql).toContain('FROM "ERPAuto"."CleanerExecution"')
|
||||
expect(executedSql).toContain('DELETE FROM "ERPAuto"."CleanerMaterialDetail"')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const queryMock = vi.fn()
|
||||
const createMock = vi.fn()
|
||||
const trackDurationMock = vi.fn(async (fn: () => Promise<unknown>) => ({ result: await fn() }))
|
||||
|
||||
vi.mock('../../../../src/main/services/logger', () => ({
|
||||
createLogger: () => ({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn()
|
||||
}),
|
||||
getRequestId: () => 'test-request-id',
|
||||
trackDuration: trackDurationMock
|
||||
}))
|
||||
|
||||
vi.mock('../../../../src/main/services/database/index', () => ({
|
||||
create: createMock
|
||||
}))
|
||||
|
||||
describe('MaterialsToBeDeletedDAO (PostgreSQL compatibility)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
createMock.mockResolvedValue({
|
||||
type: 'postgresql',
|
||||
isConnected: () => true,
|
||||
query: queryMock,
|
||||
disconnect: vi.fn()
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to update-then-insert instead of ON CONFLICT for PostgreSQL inserts', async () => {
|
||||
queryMock
|
||||
.mockResolvedValueOnce({
|
||||
rows: [],
|
||||
columns: [],
|
||||
rowCount: 0
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
rows: [],
|
||||
columns: [],
|
||||
rowCount: 1
|
||||
})
|
||||
|
||||
const { MaterialsToBeDeletedDAO } = await import(
|
||||
'../../../../src/main/services/database/materials-to-be-deleted-dao'
|
||||
)
|
||||
const dao = new MaterialsToBeDeletedDAO()
|
||||
|
||||
const result = await dao.upsertMaterial('M-001', 'tester')
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(queryMock).toHaveBeenCalledTimes(2)
|
||||
|
||||
const [updateSql, updateParams] = queryMock.mock.calls[0]
|
||||
const [insertSql, insertParams] = queryMock.mock.calls[1]
|
||||
|
||||
expect(updateSql).toContain('UPDATE "dbo"."MaterialsToBeDeleted"')
|
||||
expect(updateSql).toContain('WHERE MaterialCode = $2')
|
||||
expect(updateParams).toEqual(['tester', 'M-001'])
|
||||
|
||||
expect(insertSql).toContain('INSERT INTO "dbo"."MaterialsToBeDeleted" (MaterialCode, ManagerName)')
|
||||
expect(insertSql).toContain('WHERE NOT EXISTS')
|
||||
expect(insertSql).not.toContain('ON CONFLICT')
|
||||
expect(insertParams).toEqual(['M-001', 'tester'])
|
||||
})
|
||||
|
||||
it('reuses the PostgreSQL-safe path in updateManager', async () => {
|
||||
queryMock.mockResolvedValueOnce({
|
||||
rows: [],
|
||||
columns: [],
|
||||
rowCount: 1
|
||||
})
|
||||
|
||||
const { MaterialsToBeDeletedDAO } = await import(
|
||||
'../../../../src/main/services/database/materials-to-be-deleted-dao'
|
||||
)
|
||||
const dao = new MaterialsToBeDeletedDAO()
|
||||
|
||||
const result = await dao.updateManager('M-001', 'tester')
|
||||
|
||||
expect(result).toEqual({ success: true })
|
||||
expect(queryMock).toHaveBeenCalledTimes(1)
|
||||
expect((queryMock.mock.calls[0][0] as string)).toContain('UPDATE "dbo"."MaterialsToBeDeleted"')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const queryMock = vi.fn()
|
||||
const createMock = vi.fn()
|
||||
const trackDurationMock = vi.fn(async (fn: () => Promise<unknown>) => ({ result: await fn() }))
|
||||
|
||||
vi.mock('../../../../src/main/services/logger', () => ({
|
||||
createLogger: () => ({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn()
|
||||
}),
|
||||
getRequestId: () => 'test-request-id',
|
||||
trackDuration: trackDurationMock
|
||||
}))
|
||||
|
||||
vi.mock('../../../../src/main/services/database/index', () => ({
|
||||
create: createMock
|
||||
}))
|
||||
|
||||
describe('MaterialsTypeToBeDeletedDAO (PostgreSQL compatibility)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
createMock.mockResolvedValue({
|
||||
type: 'postgresql',
|
||||
isConnected: () => true,
|
||||
query: queryMock,
|
||||
disconnect: vi.fn()
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to update-then-insert instead of ON CONFLICT for PostgreSQL inserts', async () => {
|
||||
queryMock
|
||||
.mockResolvedValueOnce({
|
||||
rows: [],
|
||||
columns: [],
|
||||
rowCount: 0
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
rows: [],
|
||||
columns: [],
|
||||
rowCount: 1
|
||||
})
|
||||
|
||||
const { MaterialsTypeToBeDeletedDAO } = await import(
|
||||
'../../../../src/main/services/database/materials-type-to-be-deleted-dao'
|
||||
)
|
||||
const dao = new MaterialsTypeToBeDeletedDAO()
|
||||
|
||||
const result = await dao.upsertMaterial('测试物料', 'tester')
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(queryMock).toHaveBeenCalledTimes(2)
|
||||
|
||||
const [updateSql, updateParams] = queryMock.mock.calls[0]
|
||||
const [insertSql, insertParams] = queryMock.mock.calls[1]
|
||||
|
||||
expect(updateSql).toContain('UPDATE "dbo"."MaterialsTypeToBeDeleted"')
|
||||
expect(updateSql).toContain('WHERE MaterialName = $2')
|
||||
expect(updateParams).toEqual(['tester', '测试物料'])
|
||||
|
||||
expect(insertSql).toContain('INSERT INTO "dbo"."MaterialsTypeToBeDeleted" (MaterialName, ManagerName)')
|
||||
expect(insertSql).toContain('WHERE NOT EXISTS')
|
||||
expect(insertSql).not.toContain('ON CONFLICT')
|
||||
expect(insertParams).toEqual(['测试物料', 'tester'])
|
||||
})
|
||||
|
||||
it('returns after the first update when the material already exists', async () => {
|
||||
queryMock.mockResolvedValueOnce({
|
||||
rows: [],
|
||||
columns: [],
|
||||
rowCount: 1
|
||||
})
|
||||
|
||||
const { MaterialsTypeToBeDeletedDAO } = await import(
|
||||
'../../../../src/main/services/database/materials-type-to-be-deleted-dao'
|
||||
)
|
||||
const dao = new MaterialsTypeToBeDeletedDAO()
|
||||
|
||||
const result = await dao.upsertMaterial('测试物料', 'tester')
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(queryMock).toHaveBeenCalledTimes(1)
|
||||
expect((queryMock.mock.calls[0][0] as string)).toContain('UPDATE "dbo"."MaterialsTypeToBeDeleted"')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user