Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
723d6de0ae | ||
|
|
9d7fe8f4e7 | ||
|
|
f49f99fc0c | ||
|
|
622543fff4 | ||
|
|
74d9b4042e | ||
|
|
ed9058c93d | ||
|
|
9167359c6e | ||
|
|
5faf26df3f | ||
|
|
3a30694684 | ||
|
|
c61d62fd98 | ||
|
|
aeb3595b36 | ||
|
|
b8925926cb | ||
|
|
2936f1fca3 | ||
|
|
f57fdf69f7 | ||
|
|
bb495c7a93 | ||
|
|
33ffc0406d | ||
|
|
8fa4d6c16d | ||
|
|
cb59dda727 | ||
|
|
fbcaa11b1c | ||
|
|
b5b8af078d | ||
|
|
5b43d5a60c | ||
|
|
936c98a023 | ||
|
|
c661a12287 | ||
|
|
1cd6660774 | ||
|
|
1f06fd275e | ||
|
|
c86508989b | ||
|
|
838783e384 | ||
|
|
d5028bfcf4 | ||
|
|
b2b29e9754 | ||
|
|
cba3c8c4f0 | ||
|
|
343cb24234 | ||
|
|
4ce5b91340 | ||
|
|
1f033eb315 | ||
|
|
681f3ba517 | ||
|
|
0c6bb85e67 | ||
|
|
1a48dca57c | ||
|
|
e91b7308a7 | ||
|
|
35cad8baa9 | ||
|
|
6f49596467 | ||
|
|
95eb44979a | ||
|
|
420f811488 | ||
|
|
6aa1fc29e5 | ||
|
|
151485caed | ||
|
|
116539ff42 | ||
|
|
0286df94dd | ||
|
|
32931cecad | ||
|
|
b95f12fca1 | ||
|
|
4c40457c71 | ||
|
|
bd68444a74 | ||
|
|
7dfa88c2a3 | ||
|
|
998edb4b84 | ||
|
|
74096fbfa0 | ||
|
|
73656dada8 | ||
|
|
9a91658121 | ||
|
|
a924e8a4e8 | ||
|
|
b363a53d8a | ||
|
|
e1d55b8b39 | ||
|
|
8b173890fa |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -45,3 +45,7 @@ nul
|
|||||||
|
|
||||||
# TypeScript incremental compilation cache
|
# TypeScript incremental compilation cache
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# temporary files
|
||||||
|
tmp/
|
||||||
|
temp/
|
||||||
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_
|
||||||
@@ -77,11 +77,11 @@ flowchart TB
|
|||||||
|
|
||||||
**差异总结**:
|
**差异总结**:
|
||||||
|
|
||||||
| 维度 | Admin | User |
|
| 维度 | Admin | User |
|
||||||
|------|-------|------|
|
| ---------- | ----------------- | ------ |
|
||||||
| 侧边栏 | 有 CleanerSidebar | 无 |
|
| 侧边栏 | 有 CleanerSidebar | 无 |
|
||||||
| 管理员列表 | 查询全部负责人 | 不查询 |
|
| 管理员列表 | 查询全部负责人 | 不查询 |
|
||||||
| 默认选中 | 所有负责人 | 仅自己 |
|
| 默认选中 | 所有负责人 | 仅自己 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -119,11 +119,11 @@ flowchart TB
|
|||||||
|
|
||||||
**匹配优先级说明**:
|
**匹配优先级说明**:
|
||||||
|
|
||||||
| 优先级 | 数据源 | 匹配方式 | 适用角色 |
|
| 优先级 | 数据源 | 匹配方式 | 适用角色 |
|
||||||
|--------|--------|----------|----------|
|
| -------------- | -------------------------- | --------------------- | -------- |
|
||||||
| 1(最高) | `MaterialsToBeDeleted` | MaterialCode 精确匹配 | 全部 |
|
| 1(最高) | `MaterialsToBeDeleted` | MaterialCode 精确匹配 | 全部 |
|
||||||
| 2 | `MaterialsTypeToBeDeleted` | MaterialName 包含匹配 | 全部 |
|
| 2 | `MaterialsTypeToBeDeleted` | MaterialName 包含匹配 | 全部 |
|
||||||
| 3(User 覆盖) | 当前用户的类型关键词 | MaterialName 包含匹配 | 仅 User |
|
| 3(User 覆盖) | 当前用户的类型关键词 | MaterialName 包含匹配 | 仅 User |
|
||||||
|
|
||||||
> **优先级 3 的作用**:当某个物料按优先级 2 被分配给其他负责人,但当前 User 有匹配的类型关键词时,会强制覆盖为自己的。这确保 User 不会为他人操作物料。
|
> **优先级 3 的作用**:当某个物料按优先级 2 被分配给其他负责人,但当前 User 有匹配的类型关键词时,会强制覆盖为自己的。这确保 User 不会为他人操作物料。
|
||||||
|
|
||||||
@@ -185,17 +185,18 @@ const resultsToProcess = isAdmin ? validationResults : filteredResults
|
|||||||
|
|
||||||
**差异总结**:
|
**差异总结**:
|
||||||
|
|
||||||
| 维度 | Admin | User |
|
| 维度 | Admin | User |
|
||||||
|------|-------|------|
|
| ---------------- | --------------------------- | -------------------------------------- |
|
||||||
| 处理范围 | `validationResults`(全部) | `filteredResults`(自己的+无负责人的) |
|
| 处理范围 | `validationResults`(全部) | `filteredResults`(自己的+无负责人的) |
|
||||||
| 可操作物料 | 所有负责人的物料 | 仅自己的 + 无负责人的 |
|
| 可操作物料 | 所有负责人的物料 | 仅自己的 + 无负责人的 |
|
||||||
| 能否修改他人数据 | 是 | 否 |
|
| 能否修改他人数据 | 是 | 否 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 阶段三:执行清理(ERP 删除)
|
## 阶段三:执行清理(ERP 删除)
|
||||||
|
|
||||||
**源码位置**:
|
**源码位置**:
|
||||||
|
|
||||||
- 前端调用: `src/renderer/src/hooks/cleaner/api.ts:116-166`
|
- 前端调用: `src/renderer/src/hooks/cleaner/api.ts:116-166`
|
||||||
- 获取数据: `src/main/services/validation/validation-application-service.ts:497-655`
|
- 获取数据: `src/main/services/validation/validation-application-service.ts:497-655`
|
||||||
- 执行删除: `src/main/services/cleaner/cleaner-application-service.ts`
|
- 执行删除: `src/main/services/cleaner/cleaner-application-service.ts`
|
||||||
@@ -257,12 +258,12 @@ flowchart TB
|
|||||||
|
|
||||||
**差异总结**:
|
**差异总结**:
|
||||||
|
|
||||||
| 维度 | Admin(有 selectedManagers) | Admin(无 selectedManagers) | User |
|
| 维度 | Admin(有 selectedManagers) | Admin(无 selectedManagers) | User |
|
||||||
|------|---------------------------|----------------------------|------|
|
| ---------- | ---------------------------- | -------------------------------------- | ------------------------------- |
|
||||||
| 数据源 | `MaterialsToBeDeleted` | `DiscreteMaterialPlanData` | `MaterialsToBeDeleted` |
|
| 数据源 | `MaterialsToBeDeleted` | `DiscreteMaterialPlanData` | `MaterialsToBeDeleted` |
|
||||||
| 查询条件 | `WHERE ManagerName IN (...)` | `WHERE SourceNumber IN (orderNumbers)` | `WHERE ManagerName = @username` |
|
| 查询条件 | `WHERE ManagerName IN (...)` | `WHERE SourceNumber IN (orderNumbers)` | `WHERE ManagerName = @username` |
|
||||||
| 可删除物料 | 选中负责人的物料 | 订单关联的全部物料 | 仅自己标记的物料 |
|
| 可删除物料 | 选中负责人的物料 | 订单关联的全部物料 | 仅自己标记的物料 |
|
||||||
| 无订单号时 | — | 返回空数组 | — |
|
| 无订单号时 | — | 返回空数组 | — |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -295,14 +296,14 @@ flowchart TB
|
|||||||
|
|
||||||
## 涉及文件索引
|
## 涉及文件索引
|
||||||
|
|
||||||
| 文件 | 关键函数/逻辑 | 行号 |
|
| 文件 | 关键函数/逻辑 | 行号 |
|
||||||
|------|---------------|------|
|
| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------- |
|
||||||
| `src/renderer/src/hooks/cleaner/api.ts` | `initializeCleanerPage()`, `runCleanerExecution()` | 25-52, 116-166 |
|
| `src/renderer/src/hooks/cleaner/api.ts` | `initializeCleanerPage()`, `runCleanerExecution()` | 25-52, 116-166 |
|
||||||
| `src/renderer/src/hooks/useCleaner.ts` | `handleConfirmDeletion()`, 初始化逻辑 | 98-120, 289-345 |
|
| `src/renderer/src/hooks/useCleaner.ts` | `handleConfirmDeletion()`, 初始化逻辑 | 98-120, 289-345 |
|
||||||
| `src/renderer/src/hooks/cleaner/helpers.ts` | `filterValidationResults()`, `buildDeletionPlan()` | 34-57, 59-92 |
|
| `src/renderer/src/hooks/cleaner/helpers.ts` | `filterValidationResults()`, `buildDeletionPlan()` | 34-57, 59-92 |
|
||||||
| `src/main/services/validation/validation-application-service.ts` | `getCleanerData()`, `loadMaterialCodesForCleaner()`, `queryMaterialCodesByManagers()` | 232-305, 497-604, 606-655 |
|
| `src/main/services/validation/validation-application-service.ts` | `getCleanerData()`, `loadMaterialCodesForCleaner()`, `queryMaterialCodesByManagers()` | 232-305, 497-604, 606-655 |
|
||||||
| `src/main/services/cleaner/cleaner-application-service.ts` | `runCleaner()` | 31-168 |
|
| `src/main/services/cleaner/cleaner-application-service.ts` | `runCleaner()` | 31-168 |
|
||||||
| `src/main/ipc/cleaner-handler.ts` | `CLEANER_RUN` handler | 16-22 |
|
| `src/main/ipc/cleaner-handler.ts` | `CLEANER_RUN` handler | 16-22 |
|
||||||
| `src/main/ipc/validation-handler.ts` | `getCleanerData` handler | 194-223 |
|
| `src/main/ipc/validation-handler.ts` | `getCleanerData` handler | 194-223 |
|
||||||
| `src/preload/api/validation.ts` | `getCleanerData()` IPC 桥接 | 11-12 |
|
| `src/preload/api/validation.ts` | `getCleanerData()` IPC 桥接 | 11-12 |
|
||||||
| `src/renderer/src/pages/CleanerPage.tsx` | 页面组件,条件渲染侧边栏 | 74-82 |
|
| `src/renderer/src/pages/CleanerPage.tsx` | 页面组件,条件渲染侧边栏 | 74-82 |
|
||||||
239
docs/plans/2026-04-13-cleaner-db-persistence-design.md
Normal file
239
docs/plans/2026-04-13-cleaner-db-persistence-design.md
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
# Cleaner 数据库持久化设计
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
Cleaner 当前使用 Markdown 文件做执行记录持久化,通过 RustFS 上传存储。存在以下问题:
|
||||||
|
|
||||||
|
- 报告是非结构化文本,无法程序化查询和统计
|
||||||
|
- 历史记录无法按用户、时间、状态筛选
|
||||||
|
- 重试时依赖文件名去重,覆盖了首次执行的崩溃信息
|
||||||
|
- 前端需要通过 RustFS 下载报告再解析展示,链路长且脆弱
|
||||||
|
|
||||||
|
Extractor 已有成熟的数据库持久化模式(`ExtractorOperationHistory` 表 + DAO + 前端弹窗),Cleaner 应复用相同模式。
|
||||||
|
|
||||||
|
## 设计决策
|
||||||
|
|
||||||
|
| 决策项 | 选择 | 理由 |
|
||||||
|
| -------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------- |
|
||||||
|
| 表结构 | 独立建表,不与 Extractor 共用 | Cleaner 数据结构差异大(双层、物料级详情),独立更清晰 |
|
||||||
|
| 记录粒度 | 执行 + 订单 + 物料三层 | 执行表存全局信息,订单表存订单汇总,物料表存操作明细 |
|
||||||
|
| 批次标识 | `BatchId`(UUID),与 Extractor 一致 | 标准、简洁,不需要嵌入时间戳 |
|
||||||
|
| 重试记录 | 不覆盖,每次尝试独立写入,用 `AttemptNumber` 区分 | 保留完整审计链,为后续智能跳过提供数据基础 |
|
||||||
|
| 报告文件 | 移除 Markdown 报告和 RustFS 上传 | 数据库完全替代,报告相关代码(CleanerReportGenerator、generateAndUploadReport)删除 |
|
||||||
|
| 前端历史 | 独立 CleanerOperationHistoryModal,复用 Extractor 的 UI 模式 | 放在 CleanerPage 上,与 Extractor 的"操作历史"按钮对齐 |
|
||||||
|
|
||||||
|
## 数据库表结构
|
||||||
|
|
||||||
|
所有表的 schema 为 `ERPAuto`。
|
||||||
|
|
||||||
|
### 1. `CleanerExecution`(执行级)
|
||||||
|
|
||||||
|
全限定名:`ERPAuto.CleanerExecution`
|
||||||
|
|
||||||
|
一次清理操作(含重试)的全局信息。每次尝试一行记录。
|
||||||
|
|
||||||
|
| 列名 | 类型 | 说明 |
|
||||||
|
| ----------------------- | ---------------- | ---------------------------------------------- |
|
||||||
|
| ID | INT IDENTITY | 自增主键 |
|
||||||
|
| BatchId | UNIQUEIDENTIFIER | 批次 ID,一次清理操作(含重试)共享 |
|
||||||
|
| AttemptNumber | INT | 第几次尝试(1=首次,2=外层重试) |
|
||||||
|
| UserId | INT | 操作用户 ID |
|
||||||
|
| Username | NVARCHAR(255) | 操作用户名 |
|
||||||
|
| OperationTime | DATETIME | 操作时间 |
|
||||||
|
| EndTime | DATETIME | 结束时间 |
|
||||||
|
| Status | NVARCHAR(50) | pending / success / failed / partial / crashed |
|
||||||
|
| IsDryRun | BIT | 是否模拟运行 |
|
||||||
|
| TotalOrders | INT | 订单总数 |
|
||||||
|
| OrdersProcessed | INT | 已处理订单数 |
|
||||||
|
| TotalMaterialsDeleted | INT | 总删除物料数 |
|
||||||
|
| TotalMaterialsSkipped | INT | 总跳过物料数 |
|
||||||
|
| TotalMaterialsFailed | INT | 总失败物料数 |
|
||||||
|
| TotalUncertainDeletions | INT | 总不确定删除数 |
|
||||||
|
| ErrorMessage | NVARCHAR(MAX) | 全局错误信息(如外层崩溃原因) |
|
||||||
|
| AppVersion | NVARCHAR(20) | 应用版本号 |
|
||||||
|
|
||||||
|
### 2. `CleanerOrderHistory`(订单级)
|
||||||
|
|
||||||
|
全限定名:`ERPAuto.CleanerOrderHistory`
|
||||||
|
|
||||||
|
每个订单在每次尝试中的执行结果。每个订单每次尝试一行记录。
|
||||||
|
|
||||||
|
| 列名 | 类型 | 说明 |
|
||||||
|
| ------------------ | ---------------- | -------------------------- |
|
||||||
|
| ID | INT IDENTITY | 自增主键 |
|
||||||
|
| BatchId | UNIQUEIDENTIFIER | 关联执行表 BatchId |
|
||||||
|
| AttemptNumber | INT | 关联执行表 AttemptNumber |
|
||||||
|
| OrderNumber | NVARCHAR(255) | 订单号 |
|
||||||
|
| Status | NVARCHAR(50) | pending / success / failed |
|
||||||
|
| MaterialsDeleted | INT | 删除物料数 |
|
||||||
|
| MaterialsSkipped | INT | 跳过物料数 |
|
||||||
|
| MaterialsFailed | INT | 删除失败物料数 |
|
||||||
|
| UncertainDeletions | INT | 不确定删除数 |
|
||||||
|
| RetryCount | INT | 内层重试次数 |
|
||||||
|
| RetrySuccess | BIT | 内层重试是否成功 |
|
||||||
|
| ErrorMessage | NVARCHAR(MAX) | 错误信息 |
|
||||||
|
|
||||||
|
关联方式:`BatchId + AttemptNumber` 关联执行表。
|
||||||
|
|
||||||
|
### 3. `CleanerMaterialDetail`(物料级)
|
||||||
|
|
||||||
|
全限定名:`ERPAuto.CleanerMaterialDetail`
|
||||||
|
|
||||||
|
每个物料在每次尝试中的操作明细。
|
||||||
|
|
||||||
|
| 列名 | 类型 | 说明 |
|
||||||
|
| ------------------ | ---------------- | -------------------------------------- |
|
||||||
|
| ID | INT IDENTITY | 自增主键 |
|
||||||
|
| BatchId | UNIQUEIDENTIFIER | 关联执行表 BatchId |
|
||||||
|
| AttemptNumber | INT | 关联执行表 AttemptNumber |
|
||||||
|
| OrderNumber | NVARCHAR(255) | 所属订单号 |
|
||||||
|
| MaterialCode | NVARCHAR(255) | 物料代码 |
|
||||||
|
| MaterialName | NVARCHAR(255) | 物料名称 |
|
||||||
|
| RowNumber | INT | 行号 |
|
||||||
|
| Result | NVARCHAR(50) | deleted / skipped / failed / uncertain |
|
||||||
|
| Reason | NVARCHAR(MAX) | 跳过/失败原因 |
|
||||||
|
| AttemptCount | INT | 删除尝试次数 |
|
||||||
|
| FinalErrorCategory | NVARCHAR(50) | 最终错误分类 |
|
||||||
|
|
||||||
|
关联方式:`BatchId + AttemptNumber + OrderNumber` 关联订单表。
|
||||||
|
|
||||||
|
### 数据示例
|
||||||
|
|
||||||
|
首次执行到第 80 个订单时崩溃,外层重试成功完成全部 211 个订单:
|
||||||
|
|
||||||
|
**CleanerExecution**
|
||||||
|
|
||||||
|
```
|
||||||
|
BatchId=uuid-1, Attempt=1, Status=crashed, TotalOrders=211, Processed=80, ...
|
||||||
|
BatchId=uuid-1, Attempt=2, Status=success, TotalOrders=211, Processed=211, ...
|
||||||
|
```
|
||||||
|
|
||||||
|
**CleanerOrderHistory**(Attempt=1 中部分记录)
|
||||||
|
|
||||||
|
```
|
||||||
|
BatchId=uuid-1, Attempt=1, Order=SC001, Status=success, Deleted=5, Skipped=1
|
||||||
|
BatchId=uuid-1, Attempt=1, Order=SC080, Status=crashed, Error=查询超时
|
||||||
|
```
|
||||||
|
|
||||||
|
**CleanerOrderHistory**(Attempt=2 中部分记录)
|
||||||
|
|
||||||
|
```
|
||||||
|
BatchId=uuid-1, Attempt=2, Order=SC001, Status=success, Deleted=5, Skipped=1
|
||||||
|
BatchId=uuid-1, Attempt=2, Order=SC080, Status=success, Deleted=3, Skipped=0
|
||||||
|
BatchId=uuid-1, Attempt=2, Order=SC211, Status=success, Deleted=2, Skipped=0
|
||||||
|
```
|
||||||
|
|
||||||
|
**CleanerMaterialDetail**(SC080 在 Attempt=2 中的物料)
|
||||||
|
|
||||||
|
```
|
||||||
|
BatchId=uuid-1, Attempt=2, Order=SC080, Material=MAT-001, Result=deleted
|
||||||
|
BatchId=uuid-1, Attempt=2, Order=SC080, Material=MAT-002, Result=skipped, Reason=不可删除
|
||||||
|
```
|
||||||
|
|
||||||
|
## 写入时机
|
||||||
|
|
||||||
|
```
|
||||||
|
用户点击"执行清理"
|
||||||
|
→ IPC: cleaner:run
|
||||||
|
→ cleaner-handler.ts
|
||||||
|
→ ① BatchId = randomUUID()
|
||||||
|
→ ② 插入 CleanerExecution(Status=pending)
|
||||||
|
→ ③ 插入 CleanerOrderHistory(所有订单,Status=pending)
|
||||||
|
→ ④ 执行清理(CleanerApplicationService.runCleaner)
|
||||||
|
→ ⑤ 更新 CleanerExecution(Status=success/failed/partial/crashed)
|
||||||
|
→ ⑥ 更新 CleanerOrderHistory(每个订单的结果)
|
||||||
|
→ ⑦ 插入 CleanerMaterialDetail(每个物料的操作明细)
|
||||||
|
→ ⑧ 如果 crashed → 外层重试
|
||||||
|
→ 插入新的 CleanerExecution(AttemptNumber=2, Status=pending)
|
||||||
|
→ 插入新的 CleanerOrderHistory(AttemptNumber=2, Status=pending)
|
||||||
|
→ 重新执行
|
||||||
|
→ 更新执行表和订单表状态
|
||||||
|
→ 插入物料明细
|
||||||
|
```
|
||||||
|
|
||||||
|
- 步骤 ②③:在 `cleaner-handler.ts` 中,执行前写入,记录操作人、全局配置、待处理订单
|
||||||
|
- 步骤 ⑤⑥⑦:在 `CleanerApplicationService` 中,执行完成后回调 DAO 写入结果
|
||||||
|
- 步骤 ⑧:外层重试时,三张表都新增 AttemptNumber=2 的记录,首次尝试的数据完整保留
|
||||||
|
|
||||||
|
## 变更清单
|
||||||
|
|
||||||
|
### 新增文件
|
||||||
|
|
||||||
|
1. **`src/main/services/database/cleaner-operation-history-dao.ts`**
|
||||||
|
- `CleanerOperationHistoryDAO` 类
|
||||||
|
- 执行表操作:insertExecution、updateExecutionStatus
|
||||||
|
- 订单表操作:insertOrderRecords、updateOrderStatus
|
||||||
|
- 物料表操作:insertMaterialDetails
|
||||||
|
- 查询操作:getBatches、getBatchDetails(含订单+物料)、deleteBatch
|
||||||
|
- 参考 `ExtractorOperationHistoryDAO` 的模式,表名使用 `ERPAuto.CleanerExecution`、`ERPAuto.CleanerOrderHistory`、`ERPAuto.CleanerMaterialDetail`
|
||||||
|
|
||||||
|
2. **`src/main/types/cleaner-history.types.ts`**
|
||||||
|
- `CleanerExecutionRecord`、`CleanerOrderRecord`、`CleanerMaterialRecord`
|
||||||
|
- `CleanerBatchStats`、`InsertCleanerExecutionInput`、`InsertOrderInput`、`InsertMaterialDetailInput`
|
||||||
|
|
||||||
|
3. **`src/renderer/src/components/CleanerOperationHistoryModal.tsx`**
|
||||||
|
- 操作历史弹窗,复用 ExtractorOperationHistoryModal 的 UI 模式
|
||||||
|
- 批次列表(按 BatchId 聚合,显示操作时间、用户、状态、成功/失败数,区分多次尝试)
|
||||||
|
- 展开明细(订单列表,每订单的删除/跳过/失败数)
|
||||||
|
- 物料级详情(第二层展开,显示每个物料的操作结果)
|
||||||
|
- 管理员可按用户筛选、可删除批次
|
||||||
|
|
||||||
|
### 修改文件
|
||||||
|
|
||||||
|
4. **`src/main/ipc/cleaner-handler.ts`**
|
||||||
|
- `CLEANER_RUN` handler 中:执行前插入 execution + order 的 pending 记录,执行后更新结果
|
||||||
|
- 新增 IPC handlers:`CLEANER_HISTORY_BATCHES`、`CLEANER_HISTORY_DETAILS`、`CLEANER_HISTORY_DELETE`
|
||||||
|
|
||||||
|
5. **`src/main/services/cleaner/cleaner-application-service.ts`**
|
||||||
|
- `runCleaner` 接收 `batchId` 参数
|
||||||
|
- 移除 `generateExecutionId()` 函数
|
||||||
|
- 移除 `generateAndUploadReport()` 方法
|
||||||
|
- 移除 `executionId` 相关逻辑
|
||||||
|
- 外层重试时,通过 DAO 写入 AttemptNumber=2 的执行记录和订单记录,不覆盖首次尝试
|
||||||
|
- 执行完成后回调 DAO 写入订单结果和物料明细
|
||||||
|
|
||||||
|
6. **`src/main/ipc/index.ts`**
|
||||||
|
- 注册新的 cleaner history IPC handlers
|
||||||
|
|
||||||
|
7. **`src/preload/api/cleaner.ts`**
|
||||||
|
- 新增 IPC 调用方法:getBatches、getBatchDetails、deleteBatch
|
||||||
|
|
||||||
|
8. **`src/preload/index.d.ts`**
|
||||||
|
- `CleanerAPI` 接口新增 getBatches、getBatchDetails、deleteBatch 类型声明
|
||||||
|
|
||||||
|
9. **`src/renderer/src/pages/CleanerPage.tsx`**
|
||||||
|
- 新增"操作历史"按钮
|
||||||
|
- 引入 CleanerOperationHistoryModal
|
||||||
|
|
||||||
|
### 删除文件
|
||||||
|
|
||||||
|
10. **`src/main/services/report/cleaner-report-generator.ts`**
|
||||||
|
- 整个文件删除,报告生成逻辑不再需要
|
||||||
|
|
||||||
|
### 可选清理
|
||||||
|
|
||||||
|
11. **`src/renderer/src/components/ReportViewerDialog.tsx`**
|
||||||
|
- 基于 RustFS 文件的报告查看器,Cleaner 不再使用
|
||||||
|
- 如果 Extractor 不共用此组件,可删除
|
||||||
|
|
||||||
|
12. **`src/renderer/src/components/ReportAnalysisDialog.tsx`**
|
||||||
|
- 基于报告文件的分析,Cleaner 不再使用
|
||||||
|
- 后续可基于数据库重新实现统计分析
|
||||||
|
|
||||||
|
## 移除的概念
|
||||||
|
|
||||||
|
| 概念 | 原因 |
|
||||||
|
| ------------------------------ | --------------------------------- |
|
||||||
|
| ExecutionId(CLN-时间戳-随机) | 为文件名设计,数据库用 UUID |
|
||||||
|
| generateExecutionId() | 随 ExecutionId 一起移除 |
|
||||||
|
| CleanerReportGenerator | Markdown 报告生成器,被数据库替代 |
|
||||||
|
| generateAndUploadReport() | RustFS 上传链路,被数据库写入替代 |
|
||||||
|
| 报告文件名去重 | 数据库 UUID 天然唯一 |
|
||||||
|
| 重试覆盖旧报告 | 数据库保留所有尝试记录 |
|
||||||
|
|
||||||
|
## 不涉及的部分
|
||||||
|
|
||||||
|
- Extractor 的持久化逻辑不变
|
||||||
|
- 数据库 schema 迁移(需 DBA 创建表,应用层只做 CRUD)
|
||||||
|
- 后续智能跳过功能(基于已有 success 记录跳过已成功的订单)
|
||||||
|
- 内层重试逻辑(订单级/物料级)不变
|
||||||
699
docs/plans/2026-04-13-cleaner-db-persistence-plan.md
Normal file
699
docs/plans/2026-04-13-cleaner-db-persistence-plan.md
Normal file
@@ -0,0 +1,699 @@
|
|||||||
|
# Cleaner 数据库持久化实施计划
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** 将 Cleaner 的执行记录从 Markdown 文件持久化迁移到数据库(三张表:执行级、订单级、物料级),并在前端新增操作历史弹窗。
|
||||||
|
|
||||||
|
**Architecture:** 新建 `CleanerOperationHistoryDAO` 操作三张表(`ERPAuto.CleanerExecution`、`ERPAuto.CleanerOrderHistory`、`ERPAuto.CleanerMaterialDetail`),通过新增 IPC handlers 暴露给前端。执行前写入 pending 记录,执行后更新结果和物料明细。外层重试时新增 AttemptNumber=2 的记录,不覆盖首次尝试。移除 Markdown 报告生成和 RustFS 上传链路。
|
||||||
|
|
||||||
|
**Tech Stack:** TypeScript, Electron IPC, SQL (MySQL/SQL Server/PostgreSQL via existing DAO+dialect pattern), React
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: 新增类型定义
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Create: `src/main/types/cleaner-history.types.ts`
|
||||||
|
|
||||||
|
**Step 1: 创建类型文件**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/main/types/cleaner-history.types.ts
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleaner 操作历史类型定义
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 执行级记录 */
|
||||||
|
export interface CleanerExecutionRecord {
|
||||||
|
id?: number
|
||||||
|
batchId: string
|
||||||
|
attemptNumber: number
|
||||||
|
userId: number
|
||||||
|
username: string
|
||||||
|
operationTime: Date
|
||||||
|
endTime: Date | null
|
||||||
|
status: string
|
||||||
|
isDryRun: boolean
|
||||||
|
totalOrders: number
|
||||||
|
ordersProcessed: number
|
||||||
|
totalMaterialsDeleted: number
|
||||||
|
totalMaterialsSkipped: number
|
||||||
|
totalMaterialsFailed: number
|
||||||
|
totalUncertainDeletions: number
|
||||||
|
errorMessage: string | null
|
||||||
|
appVersion: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 订单级记录 */
|
||||||
|
export interface CleanerOrderRecord {
|
||||||
|
id?: number
|
||||||
|
batchId: string
|
||||||
|
attemptNumber: number
|
||||||
|
orderNumber: string
|
||||||
|
status: string
|
||||||
|
materialsDeleted: number
|
||||||
|
materialsSkipped: number
|
||||||
|
materialsFailed: number
|
||||||
|
uncertainDeletions: number
|
||||||
|
retryCount: number
|
||||||
|
retrySuccess: boolean
|
||||||
|
errorMessage: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 物料级记录 */
|
||||||
|
export interface CleanerMaterialRecord {
|
||||||
|
id?: number
|
||||||
|
batchId: string
|
||||||
|
attemptNumber: number
|
||||||
|
orderNumber: string
|
||||||
|
materialCode: string
|
||||||
|
materialName: string
|
||||||
|
rowNumber: number
|
||||||
|
result: string
|
||||||
|
reason: string | null
|
||||||
|
attemptCount: number
|
||||||
|
finalErrorCategory: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批次统计(前端列表展示用) */
|
||||||
|
export interface CleanerBatchStats {
|
||||||
|
batchId: string
|
||||||
|
userId: number
|
||||||
|
username: string
|
||||||
|
operationTime: string
|
||||||
|
/** 最终一次尝试的状态 */
|
||||||
|
status: string
|
||||||
|
totalAttempts: number
|
||||||
|
totalOrders: number
|
||||||
|
ordersProcessed: number
|
||||||
|
totalMaterialsDeleted: number
|
||||||
|
totalMaterialsFailed: number
|
||||||
|
successCount: number
|
||||||
|
failedCount: number
|
||||||
|
isDryRun: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 插入执行记录的输入 */
|
||||||
|
export interface InsertCleanerExecutionInput {
|
||||||
|
batchId: string
|
||||||
|
attemptNumber: number
|
||||||
|
userId: number
|
||||||
|
username: string
|
||||||
|
isDryRun: boolean
|
||||||
|
totalOrders: number
|
||||||
|
appVersion: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 插入订单记录的输入 */
|
||||||
|
export interface InsertOrderInput {
|
||||||
|
orderNumber: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 插入物料明细的输入 */
|
||||||
|
export interface InsertMaterialDetailInput {
|
||||||
|
orderNumber: string
|
||||||
|
materialCode: string
|
||||||
|
materialName: string
|
||||||
|
rowNumber: number
|
||||||
|
result: string
|
||||||
|
reason: string | null
|
||||||
|
attemptCount: number
|
||||||
|
finalErrorCategory: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查询批次的选项 */
|
||||||
|
export interface GetCleanerBatchesOptions {
|
||||||
|
limit?: number
|
||||||
|
offset?: number
|
||||||
|
usernames?: string[]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: 验证类型检查通过**
|
||||||
|
|
||||||
|
Run: `npm run typecheck`
|
||||||
|
Expected: PASS(新文件不影响现有代码)
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(cleaner): add type definitions for cleaner operation history
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: 新增 DAO 层
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Create: `src/main/services/database/cleaner-operation-history-dao.ts`
|
||||||
|
|
||||||
|
**Step 1: 创建 DAO 文件**
|
||||||
|
|
||||||
|
参考 `extractor-operation-history-dao.ts` 的模式(`create()` 获取数据库连接、`createDialect()` 处理 SQL 方言、`trackDuration()` 记录耗时)。表名使用 `ERPAuto` schema。
|
||||||
|
|
||||||
|
关键方法:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export class CleanerOperationHistoryDAO {
|
||||||
|
private dbService: IDatabaseService | null = null
|
||||||
|
private dialect: SqlDialect | null = null
|
||||||
|
|
||||||
|
// ===== 执行表 =====
|
||||||
|
private getExecutionTableName(): string {
|
||||||
|
return this.getDialect().quoteTableName('ERPAuto', 'CleanerExecution')
|
||||||
|
}
|
||||||
|
|
||||||
|
async insertExecution(input: InsertCleanerExecutionInput): Promise<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>
|
||||||
|
|
||||||
|
// ===== 物料表 =====
|
||||||
|
private getMaterialTableName(): string {
|
||||||
|
return this.getDialect().quoteTableName('ERPAuto', 'CleanerMaterialDetail')
|
||||||
|
}
|
||||||
|
|
||||||
|
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 deleteBatch(
|
||||||
|
batchId: string,
|
||||||
|
requestingUserId: number,
|
||||||
|
isAdmin: boolean
|
||||||
|
): Promise<{ success: boolean; error?: string }>
|
||||||
|
|
||||||
|
// ===== 列询执行级记录 =====
|
||||||
|
async getMaterialDetails(
|
||||||
|
batchId: string,
|
||||||
|
attemptNumber: number,
|
||||||
|
orderNumber: string
|
||||||
|
): Promise<CleanerMaterialRecord[]>
|
||||||
|
|
||||||
|
// ===== 删除 =====
|
||||||
|
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 区分不同尝试
|
||||||
|
|
||||||
|
每个 INSERT/UPDATE 使用 `trackDuration()` 包裹,error handling 与 Extractor DAO 一致。
|
||||||
|
|
||||||
|
**Step 2: 验证类型检查通过**
|
||||||
|
|
||||||
|
Run: `npm run typecheck`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(cleaner): add CleanerOperationHistoryDAO for three-table persistence
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: 新增 IPC channels
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/shared/ipc-channels.ts`
|
||||||
|
|
||||||
|
**Step 1: 添加 cleaner history channels**
|
||||||
|
|
||||||
|
在现有的 `CLEANER_PROGRESS` 之后添加:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Cleaner history
|
||||||
|
CLEANER_HISTORY_GET_BATCHES: 'cleanerHistory:getBatches',
|
||||||
|
CLEANER_HISTORY_GET_BATCH_DETAILS: 'cleanerHistory:getBatchDetails',
|
||||||
|
CLEANER_HISTORY_GET_MATERIAL_DETAILS: 'cleanerHistory:getMaterialDetails',
|
||||||
|
CLEANER_HISTORY_DELETE_BATCH: 'cleanerHistory:deleteBatch',
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(cleaner): add IPC channels for cleaner operation history
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: 新增 IPC handler
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Create: `src/main/ipc/cleaner-history-handler.ts`
|
||||||
|
- Modify: `src/main/ipc/index.ts` — 注册新 handler
|
||||||
|
|
||||||
|
**Step 1: 创建 cleaner-history-handler.ts**
|
||||||
|
|
||||||
|
参考 `operation-history-handler.ts` 的模式。四个 handler:
|
||||||
|
|
||||||
|
- `CLEANER_HISTORY_GET_BATCHES`:获取批次列表,Admin 看全部,User 看自己的
|
||||||
|
- `CLEANER_HISTORY_GET_BATCH_DETAILS`:获取某个批次的执行记录和订单记录
|
||||||
|
- `CLEANER_HISTORY_GET_MATERIAL_DETAILS`:获取某个订单的物料明细
|
||||||
|
- `CLEANER_HISTORY_DELETE_BATCH`:删除批次,权限校验与 Extractor 一致
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function registerCleanerHistoryHandlers(): void {
|
||||||
|
const dao = new CleanerOperationHistoryDAO()
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
IPC_CHANNELS.CLEANER_HISTORY_GET_BATCHES,
|
||||||
|
async (event, options?: GetCleanerBatchesOptions): Promise<IpcResult<CleanerBatchStats[]>> => {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const currentUser = SessionManager.getInstance().getUserInfo()
|
||||||
|
if (!currentUser) throw new Error('用户未登录')
|
||||||
|
const userId = currentUser.userType === 'Admin' ? undefined : currentUser.id
|
||||||
|
return dao.getBatches(userId, options)
|
||||||
|
}, 'cleanerHistory:getBatches')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
IPC_CHANNELS.CLEANER_HISTORY_GET_BATCH_DETAILS,
|
||||||
|
async (
|
||||||
|
event,
|
||||||
|
batchId: string
|
||||||
|
): Promise<
|
||||||
|
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[]>> => {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
IPC_CHANNELS.CLEANER_HISTORY_DELETE_BATCH,
|
||||||
|
async (event, batchId: string): Promise<IpcResult<{ deleted: boolean }>> => {
|
||||||
|
// ... 权限校验后删除三张表的记录
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: 在 index.ts 中注册**
|
||||||
|
|
||||||
|
在 `registerIpcHandlers()` 中添加 `registerCleanerHistoryHandlers()` 调用,并在顶部添加 import。
|
||||||
|
|
||||||
|
**Step 3: 验证类型检查通过**
|
||||||
|
|
||||||
|
Run: `npm run typecheck`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(cleaner): add IPC handlers for cleaner operation history
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: 新增 Preload API
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/preload/api/cleaner.ts` — 新增 history 方法
|
||||||
|
- Modify: `src/preload/index.d.ts` — 新增类型声明
|
||||||
|
|
||||||
|
**Step 1: 在 cleaner.ts 中新增 history 方法**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type {
|
||||||
|
CleanerBatchStats,
|
||||||
|
CleanerExecutionRecord,
|
||||||
|
CleanerOrderRecord,
|
||||||
|
CleanerMaterialRecord,
|
||||||
|
GetCleanerBatchesOptions
|
||||||
|
} from '../../main/types/cleaner-history.types'
|
||||||
|
|
||||||
|
// 在 cleanerApi 对象中追加:
|
||||||
|
getHistoryBatches: (options?: GetCleanerBatchesOptions): Promise<IpcResult<CleanerBatchStats[]>> =>
|
||||||
|
invokeIpc(IPC_CHANNELS.CLEANER_HISTORY_GET_BATCHES, options),
|
||||||
|
|
||||||
|
getHistoryBatchDetails: (batchId: string): Promise<IpcResult<{
|
||||||
|
executions: CleanerExecutionRecord[]
|
||||||
|
orders: CleanerOrderRecord[]
|
||||||
|
}>> =>
|
||||||
|
invokeIpc(IPC_CHANNELS.CLEANER_HISTORY_GET_BATCH_DETAILS, batchId),
|
||||||
|
|
||||||
|
getHistoryMaterialDetails: (batchId: string, attemptNumber: number, orderNumber: string): Promise<IpcResult<CleanerMaterialRecord[]>> =>
|
||||||
|
invokeIpc(IPC_CHANNELS.CLEANER_HISTORY_GET_MATERIAL_DETAILS, batchId, attemptNumber, orderNumber),
|
||||||
|
|
||||||
|
deleteHistoryBatch: (batchId: string): Promise<IpcResult<{ deleted: boolean }>> =>
|
||||||
|
invokeIpc(IPC_CHANNELS.CLEANER_HISTORY_DELETE_BATCH, batchId),
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: 在 index.d.ts 中更新 CleanerAPI 接口**
|
||||||
|
|
||||||
|
在 `CleanerAPI` 接口中添加对应的类型声明,与实际 API 对齐。
|
||||||
|
|
||||||
|
**Step 3: 验证类型检查通过**
|
||||||
|
|
||||||
|
Run: `npm run typecheck`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(cleaner): add preload API for cleaner operation history
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: 改造 CleanerApplicationService — 写入数据库记录
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/main/services/cleaner/cleaner-application-service.ts`
|
||||||
|
|
||||||
|
这是核心变更。`runCleaner` 方法需要:
|
||||||
|
|
||||||
|
**Step 1: 修改 runCleaner 签名,接收 batchId 和 DAO**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async runCleaner(
|
||||||
|
eventSender: WebContents,
|
||||||
|
input: CleanerInput,
|
||||||
|
batchId: string,
|
||||||
|
historyDao: CleanerOperationHistoryDAO
|
||||||
|
): Promise<CleanerResult>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: 移除报告相关代码**
|
||||||
|
|
||||||
|
- 删除 `import { app } from 'electron'`(仅用于 `app.getVersion()`)
|
||||||
|
- 删除 `generateExecutionId()` 函数
|
||||||
|
- 删除 `generateAndUploadReport()` 方法
|
||||||
|
- 删除所有 `executionId` 相关变量和日志
|
||||||
|
|
||||||
|
**Step 3: 插入 pending 订单记录**
|
||||||
|
|
||||||
|
在登录成功后、执行清理前,调用 `historyDao.insertOrderRecords(batchId, 1, orders)` 写入 pending 状态的订单记录。
|
||||||
|
|
||||||
|
**Step 4: 执行后更新订单记录和写入物料明细**
|
||||||
|
|
||||||
|
清理完成后遍历 `result.details`(`OrderCleanDetail[]`),对每个订单:
|
||||||
|
|
||||||
|
- 调用 `historyDao.updateOrderStatus(...)` 更新订单结果
|
||||||
|
- 调用 `historyDao.insertMaterialDetails(...)` 写入物料明细(skipped + failed 材料全部写入)
|
||||||
|
|
||||||
|
**Step 5: 更新执行记录状态**
|
||||||
|
|
||||||
|
调用 `historyDao.updateExecutionStatus(batchId, 1, ...)` 更新为最终状态。
|
||||||
|
|
||||||
|
**Step 6: 外层重试改造**
|
||||||
|
|
||||||
|
当 `result.crashed` 时:
|
||||||
|
|
||||||
|
1. 调用 `historyDao.updateExecutionStatus(batchId, 1, 'crashed', ...)` 标记首次尝试为 crashed
|
||||||
|
2. 调用 `historyDao.insertExecution({ batchId, attemptNumber: 2, ... })` 创建第二次尝试
|
||||||
|
3. 调用 `historyDao.insertOrderRecords(batchId, 2, orders)` 写入第二次尝试的 pending 订单
|
||||||
|
4. 重新登录并执行
|
||||||
|
5. 执行后更新 AttemptNumber=2 的订单和物料记录
|
||||||
|
|
||||||
|
**Step 7: 验证类型检查通过**
|
||||||
|
|
||||||
|
Run: `npm run typecheck`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 8: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
refactor(cleaner): replace report generation with database persistence
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: 改造 cleaner-handler.ts — 执行前后写入
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/main/ipc/cleaner-handler.ts`
|
||||||
|
|
||||||
|
**Step 1: 修改 CLEANER_RUN handler**
|
||||||
|
|
||||||
|
在调用 `cleanerService.runCleaner()` 之前:
|
||||||
|
|
||||||
|
1. 获取当前用户信息
|
||||||
|
2. `batchId = randomUUID()`
|
||||||
|
3. 创建 `CleanerOperationHistoryDAO` 实例
|
||||||
|
4. 调用 `dao.insertExecution({ batchId, attemptNumber: 1, userId, username, isDryRun, totalOrders, appVersion })`
|
||||||
|
|
||||||
|
将 `batchId` 和 `dao` 传入 `runCleaner()`。
|
||||||
|
|
||||||
|
执行完成后(无论成功失败),更新执行记录的最终状态。
|
||||||
|
|
||||||
|
**Step 2: 移除 app.getVersion() 调用**
|
||||||
|
|
||||||
|
`appVersion` 改为在 handler 层获取(因为 handler 已有 electron 访问权限),传给 DAO。
|
||||||
|
|
||||||
|
**Step 3: 验证类型检查通过**
|
||||||
|
|
||||||
|
Run: `npm run typecheck`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
refactor(cleaner): write execution records to database in IPC handler
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: 删除 Markdown 报告生成器
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Delete: `src/main/services/report/cleaner-report-generator.ts`
|
||||||
|
|
||||||
|
**Step 1: 删除文件**
|
||||||
|
|
||||||
|
删除 `cleaner-report-generator.ts`。
|
||||||
|
|
||||||
|
**Step 2: 检查是否有其他文件引用它**
|
||||||
|
|
||||||
|
搜索 `cleaner-report-generator` 或 `CleanerReportGenerator`,如有引用则一并移除(主要是 `cleaner-application-service.ts` 中已删除的 import)。
|
||||||
|
|
||||||
|
**Step 3: 验证编译通过**
|
||||||
|
|
||||||
|
Run: `npm run typecheck`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
refactor(cleaner): remove Markdown report generator
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 9: 前端 — 新增操作历史弹窗
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Create: `src/renderer/src/components/CleanerOperationHistoryModal.tsx`
|
||||||
|
- Modify: `src/renderer/src/pages/CleanerPage.tsx`
|
||||||
|
|
||||||
|
**Step 1: 创建 CleanerOperationHistoryModal**
|
||||||
|
|
||||||
|
参考 `ExtractorOperationHistoryModal.tsx` 的 UI 模式和代码结构。关键差异:
|
||||||
|
|
||||||
|
- 数据源使用 `window.electron.cleaner.getHistoryBatches()` 等新 API
|
||||||
|
- 批次列表增加"尝试次数"列和"模拟运行"标识
|
||||||
|
- 展开明细时,顶部显示执行级信息(尝试次数、crashed 状态等)
|
||||||
|
- 订单表格增加 deleted/skipped/failed/uncertain 列
|
||||||
|
- 订单行可再次展开查看物料明细(调用 `getHistoryMaterialDetails`)
|
||||||
|
- 管理员按用户筛选、删除功能与 Extractor 一致
|
||||||
|
|
||||||
|
**Step 2: 在 CleanerPage 中添加"操作历史"按钮和弹窗**
|
||||||
|
|
||||||
|
- 在 `CleanerToolbar` 中添加"操作历史"按钮(或直接在 CleanerPage 添加)
|
||||||
|
- 引入 `CleanerOperationHistoryModal` 组件
|
||||||
|
- 传入 `user` 和 `isOpen/onClose` 控制
|
||||||
|
|
||||||
|
**Step 3: 验证类型检查通过**
|
||||||
|
|
||||||
|
Run: `npm run typecheck`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(cleaner): add operation history modal with database-backed records
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 10: 更新 renderer 类型定义
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/renderer/src/hooks/cleaner/types.ts`
|
||||||
|
|
||||||
|
**Step 1: 添加 history 相关类型**
|
||||||
|
|
||||||
|
在 types.ts 中添加前端需要的类型(或直接从 `cleaner-history.types.ts` import,根据项目的前端类型引用模式决定)。
|
||||||
|
|
||||||
|
**Step 2: 验证类型检查通过**
|
||||||
|
|
||||||
|
Run: `npm run typecheck`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(cleaner): add renderer types for cleaner operation history
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 11: 清理旧代码
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/renderer/src/hooks/cleaner/types.ts` — 移除 `CleanerReportData.crashed`(如果不再需要)
|
||||||
|
- 检查 `ReportViewerDialog.tsx`、`ReportAnalysisDialog.tsx` 是否仍被 Cleaner 使用
|
||||||
|
|
||||||
|
**Step 1: 清理 renderer 中不再需要的类型**
|
||||||
|
|
||||||
|
- `CleanerReportData` 中如果 `crashed` 字段已无用,移除
|
||||||
|
- 确认 `CleanerPhase` 的 `'retry'` 值是否仍需要(前端进度通知仍在使用,保留)
|
||||||
|
|
||||||
|
**Step 2: 评估 ReportViewerDialog 和 ReportAnalysisDialog**
|
||||||
|
|
||||||
|
这两个组件目前用于查看 Markdown 报告文件。如果 Cleaner 不再使用它们:
|
||||||
|
|
||||||
|
- 在 CleanerPage 中移除相关按钮和引用
|
||||||
|
- 不删除组件本身(Extractor 可能仍在使用,后续统一清理)
|
||||||
|
|
||||||
|
**Step 3: 验证编译和类型检查通过**
|
||||||
|
|
||||||
|
Run: `npm run typecheck && npm run lint`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
chore(cleaner): clean up legacy report-related code
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 12: 集成测试
|
||||||
|
|
||||||
|
**Step 1: 运行完整类型检查**
|
||||||
|
|
||||||
|
Run: `npm run typecheck`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 2: 运行 lint**
|
||||||
|
|
||||||
|
Run: `npm run lint`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 3: 运行单元测试**
|
||||||
|
|
||||||
|
Run: `npm run test`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 4: 手动验证**
|
||||||
|
|
||||||
|
1. 启动 `npm run dev`
|
||||||
|
2. 在 Cleaner 页面执行一次清理(模拟运行)
|
||||||
|
3. 检查数据库三张表是否正确写入
|
||||||
|
4. 点击"操作历史"按钮,验证批次列表和详情展示
|
||||||
|
5. 模拟崩溃场景(如果可以),验证外层重试写入 AttemptNumber=2 的记录
|
||||||
|
6. 用管理员账号验证用户筛选和删除功能
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 执行顺序
|
||||||
|
|
||||||
|
```
|
||||||
|
Task 1 (types) → Task 2 (DAO) → Task 3 (IPC channels) → Task 4 (IPC handler)
|
||||||
|
→ Task 5 (preload) → Task 6 (CleanerApplicationService) → Task 7 (cleaner-handler)
|
||||||
|
→ Task 8 (删除报告生成器) → Task 10 (renderer types) → Task 9 (前端弹窗)
|
||||||
|
→ Task 11 (清理) → Task 12 (集成测试)
|
||||||
|
```
|
||||||
|
|
||||||
|
Task 9 和 Task 10 可以并行。Task 8 必须在 Task 6、7 之后。
|
||||||
152
docs/plans/2026-04-13-cleaner-outer-retry-design.md
Normal file
152
docs/plans/2026-04-13-cleaner-outer-retry-design.md
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
# Cleaner 外层重试机制设计
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
当 CleanerService.performCleanup 的主循环抛出未捕获异常时(如查询超时、浏览器崩溃),代码进入 outer catch 块,直接返回 partial result。位于 try 块后半段的订单级重试逻辑(retryFailedOrders)永远没有机会执行。
|
||||||
|
|
||||||
|
典型场景:211 个订单中处理到第 80 个时,查询列表页等待表格行超时 → Cleaner failed → 浏览器被关闭 → 剩余 131 个订单未处理 → 无重试。
|
||||||
|
|
||||||
|
## 设计决策
|
||||||
|
|
||||||
|
| 决策项 | 选择 | 理由 |
|
||||||
|
| ------------ | ------------------------- | -------------------------------- |
|
||||||
|
| 重试层级 | CleanerApplicationService | 崩溃后浏览器不可用,必须重新登录 |
|
||||||
|
| 重试范围 | 全部订单重新跑 | 简单可靠,物料删除是幂等操作 |
|
||||||
|
| 最大重试次数 | 1 次 | 覆盖瞬态故障,不过度消耗时间 |
|
||||||
|
| 触发条件 | result.crashed === true | 仅 outer catch 触发时才重试 |
|
||||||
|
| 报告去重 | 执行 ID | 用户点击执行时生成,重试不变 |
|
||||||
|
|
||||||
|
## 变更清单
|
||||||
|
|
||||||
|
### 1. CleanerResult 新增字段
|
||||||
|
|
||||||
|
**文件**: `src/main/types/cleaner.types.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface CleanerResult {
|
||||||
|
// ... 现有字段
|
||||||
|
crashed?: boolean // true = outer catch triggered, 流程级崩溃
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
同步更新 `src/shared/types/cleaner.types.ts`(如有独立定义)和 preload 暴露的类型声明。
|
||||||
|
|
||||||
|
### 2. CleanerService 标记崩溃
|
||||||
|
|
||||||
|
**文件**: `src/main/services/erp/cleaner.ts`,line 375 的 catch 块
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
log.error('Cleaner failed', { ... })
|
||||||
|
result.errors.push(`Clean failed: ${message}`)
|
||||||
|
result.crashed = true // ← 新增
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. CleanerApplicationService 重试逻辑
|
||||||
|
|
||||||
|
**文件**: `src/main/services/cleaner/cleaner-application-service.ts`
|
||||||
|
|
||||||
|
在 `runCleaner()` 中,`cleaner.clean()` 返回后增加重试判断:
|
||||||
|
|
||||||
|
```
|
||||||
|
runCleaner(eventSender, input) {
|
||||||
|
const executionId = generateExecutionId() // 用户点击时生成
|
||||||
|
const startTime = Date.now()
|
||||||
|
|
||||||
|
// 1. 获取 ERP 配置、数据库连接、订单解析(不变)
|
||||||
|
// 2. 登录 ERP(不变)
|
||||||
|
|
||||||
|
let result = await cleaner.clean(modifiedInput)
|
||||||
|
|
||||||
|
// === 外层重试 ===
|
||||||
|
if (result.crashed) {
|
||||||
|
log.warn('检测到流程级崩溃,准备外层重试', { executionId })
|
||||||
|
|
||||||
|
await authService.close() // 关闭不可用的浏览器
|
||||||
|
authService = new ErpAuthService({...})
|
||||||
|
await authService.login() // 重新登录
|
||||||
|
|
||||||
|
cleaner = new CleanerService(authService)
|
||||||
|
result = await cleaner.clean(modifiedInput) // 全部订单重新跑
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 生成报告(使用 executionId 作为文件名一部分,避免重复)
|
||||||
|
await this.generateAndUploadReport(input, result, startTime, executionId)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 执行 ID 生成规则
|
||||||
|
|
||||||
|
格式: `CLN-{yyyyMMddHHmmss}-{4位随机字母}`
|
||||||
|
|
||||||
|
示例: `CLN-20260410112930-A7FK`
|
||||||
|
|
||||||
|
生成时机: `runCleaner()` 入口处,在 ERP 登录之前。重试时同一个 executionId 不变。
|
||||||
|
|
||||||
|
用途:
|
||||||
|
|
||||||
|
- 报告文件名: `cleaner-report-CLN-20260410112930-A7FK.md`
|
||||||
|
- RustFS 存储路径中包含该 ID,重试时覆盖同一文件
|
||||||
|
- 报告内容中显示该 ID
|
||||||
|
|
||||||
|
### 5. 报告增强
|
||||||
|
|
||||||
|
**文件**: `src/main/services/report/cleaner-report-generator.ts`
|
||||||
|
|
||||||
|
在执行摘要表格中新增字段:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
| 项目 | 值 |
|
||||||
|
| ------------ | ------------------------- | ------ |
|
||||||
|
| **执行 ID** | `CLN-20260410112930-A7FK` | ← 新增 |
|
||||||
|
| **应用版本** | `1.11.1` | ← 新增 |
|
||||||
|
| **执行时间** | `2026-04-10 11:29:30` |
|
||||||
|
| **执行模式** | `正式执行` |
|
||||||
|
| ... | ... |
|
||||||
|
```
|
||||||
|
|
||||||
|
- **执行 ID**: 从 ReportOptions 传入
|
||||||
|
- **应用版本**: `app.getVersion()`,沿用 logger 中已有的获取方式
|
||||||
|
|
||||||
|
**ReportOptions 变更**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface ReportOptions {
|
||||||
|
dryRun: boolean
|
||||||
|
username: string
|
||||||
|
startTime: number
|
||||||
|
endTime: number
|
||||||
|
executionId: string // ← 新增
|
||||||
|
appVersion: string // ← 新增
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**报告文件名变更**:
|
||||||
|
|
||||||
|
```
|
||||||
|
旧: cleaner-report-2026-04-10-03-30-12.md
|
||||||
|
新: cleaner-report-CLN-20260410112930-A7FK.md
|
||||||
|
```
|
||||||
|
|
||||||
|
重试时同一个 executionId 生成相同的文件名,本地文件和 RustFS 上传都会覆盖旧报告,无需额外去重逻辑。
|
||||||
|
|
||||||
|
### 6. 进度通知增强
|
||||||
|
|
||||||
|
重试时向前端发送进度通知,让用户知道正在重试:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
this.sendProgress(eventSender, '流程崩溃,正在重新登录并重试...', 0, {
|
||||||
|
phase: 'retry',
|
||||||
|
...
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## 不涉及的部分
|
||||||
|
|
||||||
|
- 前端 UI 变更(后续可单独做,展示重试状态)
|
||||||
|
- IPC channel 变更
|
||||||
|
- 内层重试逻辑(订单级/物料级)不变
|
||||||
|
- 数据库 schema 变更
|
||||||
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` 的优先级顺序。
|
||||||
94
docs/plans/2026-04-17-cleaner-history-search-design.md
Normal file
94
docs/plans/2026-04-17-cleaner-history-search-design.md
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# Cleaner Operation History - Full-Level Search Design
|
||||||
|
|
||||||
|
Date: 2026-04-17
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Add a full-level search feature to `CleanerOperationHistoryModal` that allows users to search across batches, orders, and materials by entering a single keyword. A new backend search API returns pre-joined three-level nested data, and the frontend renders it with keyword highlighting.
|
||||||
|
|
||||||
|
## Interaction Design
|
||||||
|
|
||||||
|
- **Search bar**: placed in the toolbar area, above the user filter chips, with a search icon and clear button.
|
||||||
|
- **Trigger**: press Enter or click the search button (no per-keystroke requests).
|
||||||
|
- **Search mode behavior**:
|
||||||
|
- Hides pagination controls (results are cross-page).
|
||||||
|
- Matching batches auto-expand with orders and materials displayed directly.
|
||||||
|
- Non-matching levels are hidden.
|
||||||
|
- Clearing the search box returns to normal browse mode.
|
||||||
|
- **Highlighting**: matched text wrapped in `<mark>` with yellow background.
|
||||||
|
- **Empty result**: shows "未找到匹配的记录" message.
|
||||||
|
- **Result cap**: backend limits to 20 batches; if truncated, shows a hint.
|
||||||
|
|
||||||
|
## Search Fields
|
||||||
|
|
||||||
|
| Level | Searchable fields |
|
||||||
|
|-------|-------------------|
|
||||||
|
| Batch | `batchId`, `username`, `status` |
|
||||||
|
| Order | `orderNumber`, `productionId` |
|
||||||
|
| Material | `materialCode`, `materialName` |
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
renderer: window.electron.cleaner.searchHistoryRecords(query, options)
|
||||||
|
→ preload: expose searchHistoryRecords
|
||||||
|
→ main IPC handler: cleaner:searchHistoryRecords
|
||||||
|
→ service/DAO: searchCleanerHistory(searchQuery, options)
|
||||||
|
→ DB query (JOIN batches + orders + materials, LIKE filter)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Input Types
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface SearchCleanerHistoryOptions {
|
||||||
|
query: string
|
||||||
|
usernames?: string[] // admin-only user scope
|
||||||
|
limit?: number // default 20
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response Type
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface CleanerHistorySearchResult {
|
||||||
|
batches: Array<{
|
||||||
|
batch: CleanerHistoryBatchStats
|
||||||
|
executions: ExecutionRecord[]
|
||||||
|
orders: Array<{
|
||||||
|
order: CleanerHistoryOrderRecord
|
||||||
|
materials: CleanerHistoryMaterialRecord[]
|
||||||
|
}>
|
||||||
|
}>
|
||||||
|
totalMatches: number
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Frontend Changes
|
||||||
|
|
||||||
|
1. **Modal top-level**: add `searchMode` / `searchQuery` state; switch data source between search API and paginated API.
|
||||||
|
2. **Toolbar**: add search input with Search icon and clear button.
|
||||||
|
3. **BatchItem**: accept optional pre-loaded `orders` + `materials` props; skip lazy-loading in search mode.
|
||||||
|
4. **Highlight utility**: `highlightText(text: string, query: string)` wraps matches in `<mark>` tags.
|
||||||
|
5. **Footer**: hide pagination in search mode; show "找到 X 个批次" + "清除搜索" button.
|
||||||
|
|
||||||
|
## Backend Changes
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `src/main/types/cleaner-history.types.ts` | Add `SearchCleanerHistoryOptions`, `CleanerHistorySearchResult` types |
|
||||||
|
| DAO (cleaner history) | Add `searchCleanerHistory` method with SQL LIKE across joined tables |
|
||||||
|
| Service (cleaner) | Add `searchHistoryRecords` method |
|
||||||
|
| IPC handler | Register `cleaner:searchHistoryRecords` channel |
|
||||||
|
| Preload | Expose `searchHistoryRecords` method |
|
||||||
|
| `src/renderer/src/hooks/cleaner/types.ts` | Sync search result types |
|
||||||
|
|
||||||
|
## Affected Files
|
||||||
|
|
||||||
|
- `src/main/types/cleaner-history.types.ts` — new types
|
||||||
|
- `src/main/services/database/cleaner-history-dao.ts` (or similar) — new search method
|
||||||
|
- `src/main/services/cleaner-service.ts` (or similar) — new search method
|
||||||
|
- `src/main/ipc/cleaner-handler.ts` (or similar) — new IPC channel
|
||||||
|
- `src/preload/index.ts` (or cleaner-specific) — expose search API
|
||||||
|
- `src/renderer/src/hooks/cleaner/types.ts` — sync types
|
||||||
|
- `src/renderer/src/components/CleanerOperationHistoryModal.tsx` — search UI + state
|
||||||
|
- `src/renderer/src/components/cleaner-history-highlight.ts` — highlight utility (new file)
|
||||||
675
docs/plans/2026-04-17-cleaner-history-search-plan.md
Normal file
675
docs/plans/2026-04-17-cleaner-history-search-plan.md
Normal file
@@ -0,0 +1,675 @@
|
|||||||
|
# Cleaner History Full-Level Search Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Add full-level search (batch + order + material) to the CleanerOperationHistoryModal, using a new backend search API that returns pre-joined three-level nested data, with keyword highlighting in the frontend.
|
||||||
|
|
||||||
|
**Architecture:** New `searchHistoryRecords` IPC channel goes through the existing DAO pattern. The DAO method performs a UNION-based SQL query across all three tables to find matching BatchIds, then fetches the full nested data for those batches. The frontend switches between browse mode (paginated) and search mode (full results) based on whether a search query is active.
|
||||||
|
|
||||||
|
**Tech Stack:** TypeScript, React, SQL (MySQL/PostgreSQL/SQL Server via dialect abstraction), Electron IPC
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Add search types to main process
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/main/types/cleaner-history.types.ts` (append at end)
|
||||||
|
- Modify: `src/shared/ipc-channels.ts` (add new channel)
|
||||||
|
|
||||||
|
**Step 1: Add search types to cleaner-history.types.ts**
|
||||||
|
|
||||||
|
Append after the existing `GetCleanerBatchesOptions` interface:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/** Search options for full-level history search */
|
||||||
|
export interface SearchCleanerHistoryOptions {
|
||||||
|
query: string
|
||||||
|
usernames?: string[]
|
||||||
|
limit?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A single batch's full nested data for search results */
|
||||||
|
export interface CleanerSearchBatchResult {
|
||||||
|
batch: CleanerBatchStats
|
||||||
|
executions: CleanerExecutionRecord[]
|
||||||
|
orders: Array<{
|
||||||
|
order: CleanerOrderRecord
|
||||||
|
materials: CleanerMaterialRecord[]
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Search response */
|
||||||
|
export interface CleanerHistorySearchResult {
|
||||||
|
batches: CleanerSearchBatchResult[]
|
||||||
|
totalMatches: number
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Add IPC channel to ipc-channels.ts**
|
||||||
|
|
||||||
|
In the `// Cleaner operation history` section, after `CLEANER_HISTORY_DELETE_BATCH`, add:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
CLEANER_HISTORY_SEARCH: 'cleanerHistory:search',
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Verify TypeScript compiles**
|
||||||
|
|
||||||
|
Run: `npx tsc --noEmit --project src/main/tsconfig.json 2>&1 | head -20`
|
||||||
|
Expected: No new errors related to these types
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/main/types/cleaner-history.types.ts src/shared/ipc-channels.ts
|
||||||
|
git commit -m "feat(cleaner-history): add search types and IPC channel"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Add search DAO method
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/main/services/database/cleaner-operation-history-dao.ts`
|
||||||
|
|
||||||
|
**Step 1: Add imports for new types**
|
||||||
|
|
||||||
|
At the top of the file, add to the existing import from `../../types/cleaner-history.types`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type {
|
||||||
|
// ... existing imports ...
|
||||||
|
SearchCleanerHistoryOptions,
|
||||||
|
CleanerSearchBatchResult,
|
||||||
|
CleanerHistorySearchResult
|
||||||
|
} from '../../types/cleaner-history.types'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Add the searchBatches method to the DAO class**
|
||||||
|
|
||||||
|
Add this method after the `getBatches` method (around line 707). The strategy:
|
||||||
|
|
||||||
|
1. First, find matching BatchIds via a UNION query across all three tables using LIKE.
|
||||||
|
2. Then fetch full nested data (batch stats, executions, orders, materials) for those batch IDs.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ==================== QUERY: SEARCH ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-level search across batches, orders, and materials
|
||||||
|
* Uses UNION to find matching BatchIds, then fetches full nested data
|
||||||
|
*/
|
||||||
|
async searchBatches(
|
||||||
|
userId: number | undefined,
|
||||||
|
options: SearchCleanerHistoryOptions
|
||||||
|
): Promise<CleanerHistorySearchResult> {
|
||||||
|
try {
|
||||||
|
const dbService = await this.getDatabaseService()
|
||||||
|
const execTable = this.getExecutionTableName()
|
||||||
|
const orderTable = this.getOrderTableName()
|
||||||
|
const materialTable = this.getMaterialTableName()
|
||||||
|
const dialect = this.getDialect()
|
||||||
|
|
||||||
|
const likeValue = `%${options.query}%`
|
||||||
|
const limit = options.limit ?? 20
|
||||||
|
|
||||||
|
// Step 1: Find distinct BatchIds matching the query across all tables
|
||||||
|
const batchIdSql = `
|
||||||
|
SELECT DISTINCT BatchId FROM (
|
||||||
|
SELECT e.BatchId FROM ${execTable} e
|
||||||
|
WHERE e.BatchId LIKE ${dialect.param(0)}
|
||||||
|
OR e.Username LIKE ${dialect.param(0)}
|
||||||
|
OR e.Status LIKE ${dialect.param(0)}
|
||||||
|
UNION ALL
|
||||||
|
SELECT o.BatchId FROM ${orderTable} o
|
||||||
|
WHERE o.OrderNumber LIKE ${dialect.param(0)}
|
||||||
|
OR o.ProductionId LIKE ${dialect.param(0)}
|
||||||
|
UNION ALL
|
||||||
|
SELECT m.BatchId FROM ${materialTable} m
|
||||||
|
WHERE m.MaterialCode LIKE ${dialect.param(0)}
|
||||||
|
OR m.MaterialName LIKE ${dialect.param(0)}
|
||||||
|
) AS matched
|
||||||
|
${userId !== undefined ? `WHERE BatchId IN (SELECT BatchId FROM ${execTable} WHERE UserId = ${dialect.param(1)})` : ''}
|
||||||
|
${options.usernames && options.usernames.length > 0 ? `WHERE BatchId IN (SELECT BatchId FROM ${execTable} WHERE Username IN (${dialect.params(options.usernames.length)}))` : ''}
|
||||||
|
`
|
||||||
|
|
||||||
|
const batchIdParams: (string | number)[] = [likeValue]
|
||||||
|
if (userId !== undefined) {
|
||||||
|
batchIdParams.push(userId)
|
||||||
|
}
|
||||||
|
if (options.usernames && options.usernames.length > 0) {
|
||||||
|
batchIdParams.push(...options.usernames)
|
||||||
|
}
|
||||||
|
|
||||||
|
const batchIdResult = await trackDuration(
|
||||||
|
async () => await dbService.query(batchIdSql, batchIdParams),
|
||||||
|
{
|
||||||
|
operationName: 'CleanerOperationHistoryDAO.searchBatches.batchIds',
|
||||||
|
context: { operationType: 'SELECT', query: options.query }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const matchedBatchIds = batchIdResult.result.rows.map((r) => r.BatchId as string)
|
||||||
|
|
||||||
|
if (matchedBatchIds.length === 0) {
|
||||||
|
return { batches: [], totalMatches: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply limit
|
||||||
|
const limitedBatchIds = matchedBatchIds.slice(0, limit)
|
||||||
|
|
||||||
|
// Step 2: For each batch, fetch full nested data in parallel
|
||||||
|
const batches: CleanerSearchBatchResult[] = []
|
||||||
|
|
||||||
|
for (const batchId of limitedBatchIds) {
|
||||||
|
// Fetch batch stats
|
||||||
|
const batchStatsArr = await this.getBatches(userId, { limit: 1 })
|
||||||
|
const batchStats = batchStatsArr.find((b) => b.batchId === batchId)
|
||||||
|
if (!batchStats) continue
|
||||||
|
|
||||||
|
// Fetch executions + orders
|
||||||
|
const details = await this.getBatchDetails(batchId)
|
||||||
|
|
||||||
|
// Fetch materials for all orders
|
||||||
|
const ordersWithMaterials = await Promise.all(
|
||||||
|
details.orders.map(async (order) => {
|
||||||
|
const materials = await this.getMaterialDetails(
|
||||||
|
batchId,
|
||||||
|
order.attemptNumber,
|
||||||
|
order.orderNumber
|
||||||
|
)
|
||||||
|
return { order, materials }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
batches.push({
|
||||||
|
batch: batchStats,
|
||||||
|
executions: details.executions,
|
||||||
|
orders: ordersWithMaterials
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
batches,
|
||||||
|
totalMatches: matchedBatchIds.length
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.error('Search batches error', {
|
||||||
|
operationType: 'SELECT',
|
||||||
|
requestId: getRequestId(),
|
||||||
|
query: options.query,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
})
|
||||||
|
return { batches: [], totalMatches: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Verify TypeScript compiles**
|
||||||
|
|
||||||
|
Run: `npx tsc --noEmit --project src/main/tsconfig.json 2>&1 | head -20`
|
||||||
|
Expected: No errors related to the DAO
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/main/services/database/cleaner-operation-history-dao.ts
|
||||||
|
git commit -m "feat(cleaner-history): add searchBatches DAO method"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Add IPC handler for search
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/main/ipc/cleaner-history-handler.ts`
|
||||||
|
|
||||||
|
**Step 1: Add new IPC handler**
|
||||||
|
|
||||||
|
In `registerCleanerHistoryHandlers()`, after the `CLEANER_HISTORY_DELETE_BATCH` handler (before the closing log statement at the end), add:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/**
|
||||||
|
* Search across all history levels (batches, orders, materials)
|
||||||
|
* Admin users search all records, regular users search only their own
|
||||||
|
*/
|
||||||
|
ipcMain.handle(
|
||||||
|
IPC_CHANNELS.CLEANER_HISTORY_SEARCH,
|
||||||
|
async (
|
||||||
|
_event,
|
||||||
|
options: SearchCleanerHistoryOptions
|
||||||
|
): Promise<IpcResult<CleanerHistorySearchResult>> => {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const currentUser = SessionManager.getInstance().getUserInfo()
|
||||||
|
|
||||||
|
if (!currentUser) {
|
||||||
|
throw new Error('用户未登录')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!options.query || options.query.trim().length === 0) {
|
||||||
|
return { batches: [], totalMatches: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = currentUser.userType === 'Admin' ? undefined : currentUser.id
|
||||||
|
|
||||||
|
log.info('Searching cleaner history', {
|
||||||
|
userId: currentUser.id,
|
||||||
|
userType: currentUser.userType,
|
||||||
|
query: options.query
|
||||||
|
})
|
||||||
|
|
||||||
|
return await dao.searchBatches(userId, {
|
||||||
|
...options,
|
||||||
|
query: options.query.trim()
|
||||||
|
})
|
||||||
|
}, 'cleanerHistory:search')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Update imports in cleaner-history-handler.ts**
|
||||||
|
|
||||||
|
Add to the existing import from `../../types/cleaner-history.types`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type {
|
||||||
|
// ... existing imports ...
|
||||||
|
SearchCleanerHistoryOptions,
|
||||||
|
CleanerHistorySearchResult
|
||||||
|
} from '../types/cleaner-history.types'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Verify TypeScript compiles**
|
||||||
|
|
||||||
|
Run: `npx tsc --noEmit --project src/main/tsconfig.json 2>&1 | head -20`
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/main/ipc/cleaner-history-handler.ts
|
||||||
|
git commit -m "feat(cleaner-history): add search IPC handler"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Expose search API in preload
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/preload/api/cleaner.ts`
|
||||||
|
|
||||||
|
**Step 1: Add search method to cleanerApi**
|
||||||
|
|
||||||
|
After the `deleteHistoryBatch` method, add:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
searchHistoryRecords: (
|
||||||
|
options: SearchCleanerHistoryOptions
|
||||||
|
): Promise<IpcResult<CleanerHistorySearchResult>> =>
|
||||||
|
invokeIpc(IPC_CHANNELS.CLEANER_HISTORY_SEARCH, options),
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Add imports**
|
||||||
|
|
||||||
|
Add to the existing import from `../../main/types/cleaner-history.types`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type {
|
||||||
|
// ... existing imports ...
|
||||||
|
SearchCleanerHistoryOptions,
|
||||||
|
CleanerHistorySearchResult
|
||||||
|
} from '../../main/types/cleaner-history.types'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Verify TypeScript compiles**
|
||||||
|
|
||||||
|
Run: `npx tsc --noEmit --project src/preload/tsconfig.json 2>&1 | head -20`
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/preload/api/cleaner.ts
|
||||||
|
git commit -m "feat(cleaner-history): expose search API in preload"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Add renderer-side types and highlight utility
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/renderer/src/hooks/cleaner/types.ts` (add search result types)
|
||||||
|
- Create: `src/renderer/src/components/cleaner-history-highlight.tsx`
|
||||||
|
|
||||||
|
**Step 1: Add search result types to renderer types**
|
||||||
|
|
||||||
|
Append to `src/renderer/src/hooks/cleaner/types.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Search result types (mirrors main process types)
|
||||||
|
|
||||||
|
export interface CleanerHistorySearchOrderResult {
|
||||||
|
order: CleanerHistoryOrderRecord
|
||||||
|
materials: CleanerHistoryMaterialRecord[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CleanerHistorySearchBatchResult {
|
||||||
|
batch: CleanerHistoryBatchStats
|
||||||
|
executions: CleanerHistoryExecutionRecord[]
|
||||||
|
orders: CleanerHistorySearchOrderResult[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CleanerHistorySearchResult {
|
||||||
|
batches: CleanerHistorySearchBatchResult[]
|
||||||
|
totalMatches: number
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Create highlight utility**
|
||||||
|
|
||||||
|
Create `src/renderer/src/components/cleaner-history-highlight.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Highlight matching text with a <mark> tag
|
||||||
|
* Case-insensitive matching of the query within text
|
||||||
|
*/
|
||||||
|
export function highlightText(text: string, query: string): React.ReactNode {
|
||||||
|
if (!query || !text) return text
|
||||||
|
|
||||||
|
const lowerText = text.toLowerCase()
|
||||||
|
const lowerQuery = query.toLowerCase()
|
||||||
|
|
||||||
|
const index = lowerText.indexOf(lowerQuery)
|
||||||
|
if (index === -1) return text
|
||||||
|
|
||||||
|
const before = text.substring(0, index)
|
||||||
|
const match = text.substring(index, index + query.length)
|
||||||
|
const after = text.substring(index + query.length)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{before}
|
||||||
|
<mark className="bg-yellow-200 text-inherit rounded px-0.5">{match}</mark>
|
||||||
|
{highlightText(after, query)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/renderer/src/hooks/cleaner/types.ts src/renderer/src/components/cleaner-history-highlight.tsx
|
||||||
|
git commit -m "feat(cleaner-history): add renderer search types and highlight utility"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Integrate search into CleanerOperationHistoryModal
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/renderer/src/components/CleanerOperationHistoryModal.tsx`
|
||||||
|
|
||||||
|
This is the largest task. The changes are:
|
||||||
|
|
||||||
|
1. Add search state variables
|
||||||
|
2. Add search bar UI to the toolbar
|
||||||
|
3. Modify BatchItem to accept pre-loaded data in search mode
|
||||||
|
4. Switch footer between pagination and search result summary
|
||||||
|
|
||||||
|
**Step 1: Add new imports**
|
||||||
|
|
||||||
|
Add to the lucide-react import:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Search, X } from 'lucide-react'
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the highlight utility and new types:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { highlightText } from './cleaner-history-highlight'
|
||||||
|
import type { CleanerHistorySearchResult } from '../hooks/cleaner/types'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Add search state in the main modal component**
|
||||||
|
|
||||||
|
After the existing state declarations (around line 651), add:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const [searchInput, setSearchInput] = useState('')
|
||||||
|
const [searchResult, setSearchResult] = useState<CleanerHistorySearchResult | null>(null)
|
||||||
|
const [isSearching, setIsSearching] = useState(false)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Add the search execution function**
|
||||||
|
|
||||||
|
Add after `clearUserFilters`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const executeSearch = useCallback(async () => {
|
||||||
|
const trimmed = searchInput.trim()
|
||||||
|
if (!trimmed) {
|
||||||
|
setSearchQuery('')
|
||||||
|
setSearchResult(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSearching(true)
|
||||||
|
setSearchQuery(trimmed)
|
||||||
|
try {
|
||||||
|
const options =
|
||||||
|
isAdmin && selectedUsers.length > 0
|
||||||
|
? { query: trimmed, usernames: selectedUsers }
|
||||||
|
: { query: trimmed }
|
||||||
|
|
||||||
|
const result = await window.electron.cleaner.searchHistoryRecords(options)
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setSearchResult(result.data)
|
||||||
|
} else {
|
||||||
|
setSearchResult({ batches: [], totalMatches: 0 })
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setSearchResult({ batches: [], totalMatches: 0 })
|
||||||
|
} finally {
|
||||||
|
setIsSearching(false)
|
||||||
|
}
|
||||||
|
}, [searchInput, isAdmin, selectedUsers])
|
||||||
|
|
||||||
|
const clearSearch = () => {
|
||||||
|
setSearchInput('')
|
||||||
|
setSearchQuery('')
|
||||||
|
setSearchResult(null)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Add search bar UI**
|
||||||
|
|
||||||
|
In the toolbar section, before the user filter `<div className="mb-3">` block, add the search input:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{/* Search bar */}
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchInput}
|
||||||
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') void executeSearch()
|
||||||
|
}}
|
||||||
|
placeholder="搜索批次ID、订单号、物料编码/名称..."
|
||||||
|
className="w-full pl-9 pr-8 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
{searchInput && (
|
||||||
|
<button
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||||
|
onClick={clearSearch}
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||||
|
onClick={() => void executeSearch()}
|
||||||
|
disabled={isSearching || !searchInput.trim()}
|
||||||
|
>
|
||||||
|
{isSearching ? '搜索中...' : '搜索'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 5: Modify the batch list section to support search mode**
|
||||||
|
|
||||||
|
Replace the batch list section (the `<div className="flex-1 overflow-y-auto">` block) with conditional rendering:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{/* Batch list */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{searchQuery ? (
|
||||||
|
// Search mode
|
||||||
|
isSearching ? (
|
||||||
|
<div className="flex items-center justify-center h-32 text-gray-500">搜索中...</div>
|
||||||
|
) : searchResult && searchResult.batches.length > 0 ? (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{searchResult.batches.map((result) => (
|
||||||
|
<BatchItem
|
||||||
|
key={result.batch.batchId}
|
||||||
|
batch={result.batch}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
onDelete={handleDeleteBatch}
|
||||||
|
onRequestDelete={requestDeleteConfirmation}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
preloadedExecutions={result.executions}
|
||||||
|
preloadedOrders={result.orders}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-32 text-gray-500">
|
||||||
|
未找到匹配「{searchQuery}」的记录
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
// Browse mode (existing logic)
|
||||||
|
<>
|
||||||
|
{loading && batches.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-32 text-gray-500">加载中...</div>
|
||||||
|
) : batches.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-32 text-gray-500">暂无操作记录</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{batches.map((batch) => (
|
||||||
|
<BatchItem
|
||||||
|
key={batch.batchId}
|
||||||
|
batch={batch}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
onDelete={handleDeleteBatch}
|
||||||
|
onRequestDelete={requestDeleteConfirmation}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 6: Modify footer for search mode**
|
||||||
|
|
||||||
|
Replace the footer section to conditionally show pagination or search summary:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="pt-4 border-t border-gray-200 flex justify-center">
|
||||||
|
{searchQuery && searchResult ? (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="text-sm text-gray-600">
|
||||||
|
找到 {searchResult.totalMatches} 个匹配批次
|
||||||
|
{searchResult.totalMatches > (searchResult.batches.length) &&
|
||||||
|
`(显示前 ${searchResult.batches.length} 个)`}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 underline"
|
||||||
|
onClick={clearSearch}
|
||||||
|
>
|
||||||
|
清除搜索
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="inline-flex items-center rounded-full border border-slate-200 bg-white p-1 shadow-sm">
|
||||||
|
{/* ... existing pagination buttons ... */}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 7: Update BatchItem props and search mode rendering**
|
||||||
|
|
||||||
|
Update `BatchItemProps` interface to support optional preloaded data:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface BatchItemProps {
|
||||||
|
batch: CleanerHistoryBatchStats
|
||||||
|
isAdmin: boolean
|
||||||
|
onDelete: (batchId: string) => void
|
||||||
|
onRequestDelete: (batchId: string) => Promise<boolean>
|
||||||
|
searchQuery?: string
|
||||||
|
preloadedExecutions?: ExecutionRecord[]
|
||||||
|
preloadedOrders?: Array<{
|
||||||
|
order: CleanerHistoryOrderRecord
|
||||||
|
materials: CleanerHistoryMaterialRecord[]
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In the BatchItem component, when `searchQuery` is set and preloaded data is available:
|
||||||
|
- Start expanded (`isExpanded` initial state: `!!searchQuery`)
|
||||||
|
- Use preloaded executions/orders directly instead of fetching
|
||||||
|
- Pass `searchQuery` to text rendering for highlighting
|
||||||
|
|
||||||
|
**Step 8: Verify typecheck**
|
||||||
|
|
||||||
|
Run: `npm run typecheck`
|
||||||
|
|
||||||
|
**Step 9: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/renderer/src/components/CleanerOperationHistoryModal.tsx
|
||||||
|
git commit -m "feat(cleaner-history): integrate search UI into history modal"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Final verification
|
||||||
|
|
||||||
|
**Step 1: Run full typecheck**
|
||||||
|
|
||||||
|
Run: `npm run typecheck`
|
||||||
|
|
||||||
|
**Step 2: Run linter**
|
||||||
|
|
||||||
|
Run: `npm run lint`
|
||||||
|
|
||||||
|
**Step 3: Build the project**
|
||||||
|
|
||||||
|
Run: `npm run build`
|
||||||
|
|
||||||
|
**Step 4: Manual test checklist**
|
||||||
|
|
||||||
|
- [ ] Open the Cleaner Operation History Modal
|
||||||
|
- [ ] Verify search bar appears at top of toolbar
|
||||||
|
- [ ] Type a keyword and press Enter — results should load
|
||||||
|
- [ ] Matching batches auto-expand with orders and materials
|
||||||
|
- [ ] Highlighted text appears with yellow background
|
||||||
|
- [ ] Clear button (X) resets to browse mode
|
||||||
|
- [ ] Pagination hidden during search, shown after clearing
|
||||||
|
- [ ] Admin: user filter combined with search works
|
||||||
|
- [ ] Regular user: only their own records searched
|
||||||
|
- [ ] Empty search query does nothing
|
||||||
|
- [ ] Non-matching query shows "未找到匹配" message
|
||||||
19
docs/releases/1.12.0.md
Normal file
19
docs/releases/1.12.0.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# 1.12.0
|
||||||
|
|
||||||
|
## 清理操作历史
|
||||||
|
|
||||||
|
- 新增操作历史面板,每次清理的执行记录、订单结果、物料明细均可回溯查看。
|
||||||
|
- 历史记录按批次归档,支持管理员查看所有用户记录、普通用户查看自己的记录。
|
||||||
|
- 批次支持展开查看多层详情:执行概况、订单状态、物料操作明细。
|
||||||
|
|
||||||
|
## 订单追踪
|
||||||
|
|
||||||
|
- 所有输入的订单(含总排号)均会记录在历史中,不再遗漏未找到或未匹配的订单。
|
||||||
|
- 总排号与订单号并列显示,未匹配的总排号标注为"未找到",ERP 中不存在的订单标注为"ERP 不存在"。
|
||||||
|
- 内层重试和外层崩溃重试信息在订单详情中完整展示。
|
||||||
|
|
||||||
|
## 改进
|
||||||
|
|
||||||
|
- 数据库时间统一使用 UTC 存储,界面显示本地时间。
|
||||||
|
- 试运行模式下跳过物料级别的数据库写入,避免产生无效记录。
|
||||||
|
- 操作历史面板加宽至 140%,改善订单表格的阅读体验。
|
||||||
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 环境下物料数据写入失败的问题,支持无唯一约束的表。
|
||||||
|
|
||||||
|
## 改进
|
||||||
|
|
||||||
|
- 优化清理器操作历史的分页加载和执行报告展示,提升大数据量下的响应速度。
|
||||||
|
- 统一清理器和提取器的操作历史删除确认交互,保持一致的体验。
|
||||||
18
docs/releases/1.13.0.md
Normal file
18
docs/releases/1.13.0.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# 1.13.0
|
||||||
|
|
||||||
|
## 清理操作历史
|
||||||
|
|
||||||
|
- 新增历史记录全局搜索,支持按批次 ID、订单号、物料编码/名称、用户名、状态等关键字检索。
|
||||||
|
- 搜索结果高亮显示匹配关键字,快速定位目标记录。
|
||||||
|
- 搜索结果自动展开批次详情、订单和物料明细,无需逐层手动点击。
|
||||||
|
- 搜索支持与用户筛选联动,管理员可限定搜索范围到指定用户。
|
||||||
|
|
||||||
|
## 界面与交互
|
||||||
|
|
||||||
|
- 物料类型管理面板样式更新,改善整体视觉一致性。
|
||||||
|
- 修复管理员模式下物料类型管理面板的悬浮重叠问题。
|
||||||
|
|
||||||
|
## 改进
|
||||||
|
|
||||||
|
- 优化历史搜索查询性能,多个批次数据并行获取,减少等待时间。
|
||||||
|
- 物料类型管理面板加载速度优化,减少不必要的重渲染。
|
||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "erpauto",
|
"name": "erpauto",
|
||||||
"version": "1.11.1",
|
"version": "1.13.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "erpauto",
|
"name": "erpauto",
|
||||||
"version": "1.11.1",
|
"version": "1.13.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.929.0",
|
"@aws-sdk/client-s3": "^3.929.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "erpauto",
|
"name": "erpauto",
|
||||||
"version": "1.11.1",
|
"version": "1.13.0",
|
||||||
"description": "An Electron application with React and TypeScript",
|
"description": "An Electron application with React and TypeScript",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": "example.com",
|
"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'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
})
|
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { randomUUID } from 'crypto'
|
||||||
|
import { app } from 'electron'
|
||||||
import { ipcMain } from 'electron'
|
import { ipcMain } from 'electron'
|
||||||
import { withErrorHandling, type IpcResult } from './index'
|
import { withErrorHandling, type IpcResult } from './index'
|
||||||
import type {
|
import type {
|
||||||
@@ -8,6 +10,7 @@ import type {
|
|||||||
} from '../types/cleaner.types'
|
} from '../types/cleaner.types'
|
||||||
import { IPC_CHANNELS } from '../../shared/ipc-channels'
|
import { IPC_CHANNELS } from '../../shared/ipc-channels'
|
||||||
import { CleanerApplicationService } from '../services/cleaner/cleaner-application-service'
|
import { CleanerApplicationService } from '../services/cleaner/cleaner-application-service'
|
||||||
|
import { CleanerOperationHistoryDAO } from '../services/database/cleaner-operation-history-dao'
|
||||||
|
|
||||||
export function registerCleanerHandlers(): void {
|
export function registerCleanerHandlers(): void {
|
||||||
const cleanerService = new CleanerApplicationService()
|
const cleanerService = new CleanerApplicationService()
|
||||||
@@ -15,10 +18,21 @@ export function registerCleanerHandlers(): void {
|
|||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
IPC_CHANNELS.CLEANER_RUN,
|
IPC_CHANNELS.CLEANER_RUN,
|
||||||
async (event, input: CleanerInput): Promise<IpcResult<CleanerResult>> => {
|
async (event, input: CleanerInput): Promise<IpcResult<CleanerResult>> => {
|
||||||
return withErrorHandling(
|
return withErrorHandling(async () => {
|
||||||
async () => cleanerService.runCleaner(event.sender, input),
|
const batchId = randomUUID()
|
||||||
'cleaner:run'
|
const historyDao = new CleanerOperationHistoryDAO()
|
||||||
)
|
const appVersion = app.getVersion()
|
||||||
|
|
||||||
|
const result = await cleanerService.runCleaner(
|
||||||
|
event.sender,
|
||||||
|
input,
|
||||||
|
batchId,
|
||||||
|
historyDao,
|
||||||
|
appVersion
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
}, 'cleaner:run')
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
195
src/main/ipc/cleaner-history-handler.ts
Normal file
195
src/main/ipc/cleaner-history-handler.ts
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
/**
|
||||||
|
* IPC Handler for Cleaner Operation History
|
||||||
|
*
|
||||||
|
* Handles IPC requests for cleaner operation history management:
|
||||||
|
* - Get batch list (filtered by user for non-admin users)
|
||||||
|
* - Get batch details (executions + orders)
|
||||||
|
* - Get material details for a specific order
|
||||||
|
* - Delete batches
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ipcMain } from 'electron'
|
||||||
|
import { CleanerOperationHistoryDAO } from '../services/database/cleaner-operation-history-dao'
|
||||||
|
import { SessionManager } from '../services/user/session-manager'
|
||||||
|
import { withErrorHandling, type IpcResult } from './index'
|
||||||
|
import { IPC_CHANNELS } from '../../shared/ipc-channels'
|
||||||
|
import { createLogger } from '../services/logger'
|
||||||
|
import type {
|
||||||
|
CleanerBatchStats,
|
||||||
|
CleanerExecutionRecord,
|
||||||
|
CleanerOrderRecord,
|
||||||
|
CleanerMaterialRecord,
|
||||||
|
GetCleanerBatchesOptions,
|
||||||
|
SearchCleanerHistoryOptions,
|
||||||
|
CleanerHistorySearchResult
|
||||||
|
} from '../types/cleaner-history.types'
|
||||||
|
|
||||||
|
const log = createLogger('CleanerHistoryHandler')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register IPC handlers for cleaner operation history
|
||||||
|
*/
|
||||||
|
export function registerCleanerHistoryHandlers(): void {
|
||||||
|
const dao = new CleanerOperationHistoryDAO()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get batches list
|
||||||
|
* Admin users get all batches, regular users get only their own
|
||||||
|
*/
|
||||||
|
ipcMain.handle(
|
||||||
|
IPC_CHANNELS.CLEANER_HISTORY_GET_BATCHES,
|
||||||
|
async (_event, options?: GetCleanerBatchesOptions): Promise<IpcResult<CleanerBatchStats[]>> => {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const currentUser = SessionManager.getInstance().getUserInfo()
|
||||||
|
|
||||||
|
if (!currentUser) {
|
||||||
|
throw new Error('用户未登录')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin gets all batches, User gets only their own
|
||||||
|
const userId = currentUser.userType === 'Admin' ? undefined : currentUser.id
|
||||||
|
|
||||||
|
log.info('Getting cleaner history batches', {
|
||||||
|
userId: currentUser.id,
|
||||||
|
userType: currentUser.userType,
|
||||||
|
filtered: userId !== undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
return await dao.getBatches(userId, options)
|
||||||
|
}, 'cleanerHistory:getBatches')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get batch details (executions + orders)
|
||||||
|
* Users can only view their own batch details, admins can view all
|
||||||
|
*/
|
||||||
|
ipcMain.handle(
|
||||||
|
IPC_CHANNELS.CLEANER_HISTORY_GET_BATCH_DETAILS,
|
||||||
|
async (
|
||||||
|
_event,
|
||||||
|
batchId: string
|
||||||
|
): Promise<
|
||||||
|
IpcResult<{ executions: CleanerExecutionRecord[]; orders: CleanerOrderRecord[] }>
|
||||||
|
> => {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const currentUser = SessionManager.getInstance().getUserInfo()
|
||||||
|
|
||||||
|
if (!currentUser) {
|
||||||
|
throw new Error('用户未登录')
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info('Getting cleaner batch details', { batchId, userId: currentUser.id })
|
||||||
|
|
||||||
|
const details = await dao.getBatchDetails(batchId)
|
||||||
|
|
||||||
|
// For non-admin users, verify they own this batch
|
||||||
|
if (currentUser.userType !== 'Admin' && details.executions.length > 0) {
|
||||||
|
const batchOwnerId = details.executions[0].userId
|
||||||
|
if (batchOwnerId !== currentUser.id) {
|
||||||
|
throw new Error('没有权限查看此批次详情')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return details
|
||||||
|
}, 'cleanerHistory:getBatchDetails')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get material details for a specific order
|
||||||
|
*/
|
||||||
|
ipcMain.handle(
|
||||||
|
IPC_CHANNELS.CLEANER_HISTORY_GET_MATERIAL_DETAILS,
|
||||||
|
async (
|
||||||
|
_event,
|
||||||
|
batchId: string,
|
||||||
|
attemptNumber: number,
|
||||||
|
orderNumber: string
|
||||||
|
): Promise<IpcResult<CleanerMaterialRecord[]>> => {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const currentUser = SessionManager.getInstance().getUserInfo()
|
||||||
|
|
||||||
|
if (!currentUser) {
|
||||||
|
throw new Error('用户未登录')
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info('Getting cleaner material details', { batchId, attemptNumber, orderNumber })
|
||||||
|
|
||||||
|
return await dao.getMaterialDetails(batchId, attemptNumber, orderNumber)
|
||||||
|
}, 'cleanerHistory:getMaterialDetails')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a batch
|
||||||
|
* Users can only delete their own batches, admins can delete any
|
||||||
|
*/
|
||||||
|
ipcMain.handle(
|
||||||
|
IPC_CHANNELS.CLEANER_HISTORY_DELETE_BATCH,
|
||||||
|
async (_event, batchId: string): Promise<IpcResult<{ deleted: boolean }>> => {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const currentUser = SessionManager.getInstance().getUserInfo()
|
||||||
|
|
||||||
|
if (!currentUser) {
|
||||||
|
throw new Error('用户未登录')
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAdmin = currentUser.userType === 'Admin'
|
||||||
|
|
||||||
|
log.info('Deleting cleaner batch', {
|
||||||
|
batchId,
|
||||||
|
userId: currentUser.id,
|
||||||
|
isAdmin
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await dao.deleteBatch(batchId, currentUser.id, isAdmin)
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || '删除批次失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return { deleted: true }
|
||||||
|
}, 'cleanerHistory:deleteBatch')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search across all history levels (batches, orders, materials)
|
||||||
|
* Admin users search all records, regular users search only their own
|
||||||
|
*/
|
||||||
|
ipcMain.handle(
|
||||||
|
IPC_CHANNELS.CLEANER_HISTORY_SEARCH,
|
||||||
|
async (
|
||||||
|
_event,
|
||||||
|
options: SearchCleanerHistoryOptions
|
||||||
|
): Promise<IpcResult<CleanerHistorySearchResult>> => {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const currentUser = SessionManager.getInstance().getUserInfo()
|
||||||
|
|
||||||
|
if (!currentUser) {
|
||||||
|
throw new Error('用户未登录')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!options.query || options.query.trim().length === 0) {
|
||||||
|
return { batches: [], totalMatches: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = currentUser.userType === 'Admin' ? undefined : currentUser.id
|
||||||
|
|
||||||
|
log.info('Searching cleaner history', {
|
||||||
|
userId: currentUser.id,
|
||||||
|
userType: currentUser.userType,
|
||||||
|
query: options.query
|
||||||
|
})
|
||||||
|
|
||||||
|
return await dao.searchBatches(userId, {
|
||||||
|
...options,
|
||||||
|
query: options.query.trim()
|
||||||
|
})
|
||||||
|
}, 'cleanerHistory:search')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
log.info('Cleaner history IPC handlers registered')
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import { registerReportHandlers } from './report-handler'
|
|||||||
import { registerUpdateHandlers } from './update-handler'
|
import { registerUpdateHandlers } from './update-handler'
|
||||||
import { registerPlaywrightBrowserHandlers } from './playwright-browser'
|
import { registerPlaywrightBrowserHandlers } from './playwright-browser'
|
||||||
import { registerOperationHistoryHandlers } from './operation-history-handler'
|
import { registerOperationHistoryHandlers } from './operation-history-handler'
|
||||||
|
import { registerCleanerHistoryHandlers } from './cleaner-history-handler'
|
||||||
import { createLogger, logError } from '../services/logger'
|
import { createLogger, logError } from '../services/logger'
|
||||||
import { serializeError, sanitizeError } from '../services/logger/error-utils'
|
import { serializeError, sanitizeError } from '../services/logger/error-utils'
|
||||||
import { getErrorMessage, getErrorCode, isBaseError } from '../types/errors'
|
import { getErrorMessage, getErrorCode, isBaseError } from '../types/errors'
|
||||||
@@ -115,5 +116,6 @@ export function registerIpcHandlers(): void {
|
|||||||
registerUpdateHandlers()
|
registerUpdateHandlers()
|
||||||
registerPlaywrightBrowserHandlers()
|
registerPlaywrightBrowserHandlers()
|
||||||
registerOperationHistoryHandlers()
|
registerOperationHistoryHandlers()
|
||||||
|
registerCleanerHistoryHandlers()
|
||||||
log.info('All IPC handlers registered')
|
log.info('All IPC handlers registered')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -215,6 +215,7 @@ export class AuthApplicationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.sessionManager.logout()
|
this.sessionManager.logout()
|
||||||
|
this.silentLoginPromise = null
|
||||||
await this.updateService.setUserContext(null)
|
await this.updateService.setUserContext(null)
|
||||||
},
|
},
|
||||||
{ operation: 'logout' }
|
{ operation: 'logout' }
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ import { SqlServerService as SqlServerServiceImpl } from '../database/sql-server
|
|||||||
import { PostgreSqlService as PostgreSqlServiceImpl } from '../database/postgresql'
|
import { PostgreSqlService as PostgreSqlServiceImpl } from '../database/postgresql'
|
||||||
import { ConfigManager } from '../config/config-manager'
|
import { ConfigManager } from '../config/config-manager'
|
||||||
import { ResultExporter } from '../excel/result-exporter'
|
import { ResultExporter } from '../excel/result-exporter'
|
||||||
import { CleanerReportGenerator } from '../report/cleaner-report-generator'
|
|
||||||
import { RustfsService } from '../rustfs'
|
|
||||||
import { SessionManager } from '../user/session-manager'
|
import { SessionManager } from '../user/session-manager'
|
||||||
import { UserErpConfigService } from '../user/user-erp-config-service'
|
import { UserErpConfigService } from '../user/user-erp-config-service'
|
||||||
import { createLogger } from '../logger'
|
import { createLogger } from '../logger'
|
||||||
@@ -17,6 +15,7 @@ import { logAuditWithCurrentUser } from '../logger/audit-logger'
|
|||||||
import { AuditAction, AuditStatus } from '../../types/audit.types'
|
import { AuditAction, AuditStatus } from '../../types/audit.types'
|
||||||
import { IPC_CHANNELS } from '../../../shared/ipc-channels'
|
import { IPC_CHANNELS } from '../../../shared/ipc-channels'
|
||||||
import { DatabaseQueryError, ErpConnectionError, ValidationError } from '../../types/errors'
|
import { DatabaseQueryError, ErpConnectionError, ValidationError } from '../../types/errors'
|
||||||
|
import { CleanerOperationHistoryDAO } from '../database/cleaner-operation-history-dao'
|
||||||
import type {
|
import type {
|
||||||
CleanerInput,
|
CleanerInput,
|
||||||
CleanerProgress,
|
CleanerProgress,
|
||||||
@@ -24,12 +23,19 @@ import type {
|
|||||||
ExportResultItem,
|
ExportResultItem,
|
||||||
ExportResultResponse
|
ExportResultResponse
|
||||||
} from '../../types/cleaner.types'
|
} from '../../types/cleaner.types'
|
||||||
|
import type { InsertMaterialDetailInput, InsertOrderInput } from '../../types/cleaner-history.types'
|
||||||
|
import type { OrderMapping } from '../../types/order-resolver.types'
|
||||||
|
|
||||||
const log = createLogger('CleanerApplicationService')
|
const log = createLogger('CleanerApplicationService')
|
||||||
|
|
||||||
export class CleanerApplicationService {
|
export class CleanerApplicationService {
|
||||||
async runCleaner(eventSender: WebContents, input: CleanerInput): Promise<CleanerResult> {
|
async runCleaner(
|
||||||
const startTime = Date.now()
|
eventSender: WebContents,
|
||||||
|
input: CleanerInput,
|
||||||
|
batchId: string,
|
||||||
|
historyDao: CleanerOperationHistoryDAO,
|
||||||
|
appVersion: string
|
||||||
|
): Promise<CleanerResult> {
|
||||||
let authService: ErpAuthService | null = null
|
let authService: ErpAuthService | null = null
|
||||||
let dbService: IDatabaseService | null = null
|
let dbService: IDatabaseService | null = null
|
||||||
|
|
||||||
@@ -67,14 +73,60 @@ export class CleanerApplicationService {
|
|||||||
log.warn('Resolution warnings', { warnings })
|
log.warn('Resolution warnings', { warnings })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (validOrderNumbers.length === 0) {
|
// Build order inputs from ALL mappings (including resolution failures)
|
||||||
throw new ValidationError(
|
const orderInputs = this.buildOrderInputs(mappings)
|
||||||
'没有有效的生产订单号可处理。请检查输入的格式或数据库连接。',
|
|
||||||
'VAL_INVALID_INPUT'
|
log.info('Resolved order numbers', {
|
||||||
)
|
total: mappings.length,
|
||||||
|
resolved: validOrderNumbers.length,
|
||||||
|
failed: mappings.length - validOrderNumbers.length
|
||||||
|
})
|
||||||
|
|
||||||
|
// Insert execution record and ALL order records into database
|
||||||
|
const currentUser = SessionManager.getInstance().getUserInfo()
|
||||||
|
if (currentUser) {
|
||||||
|
await historyDao.insertExecution({
|
||||||
|
batchId,
|
||||||
|
attemptNumber: 1,
|
||||||
|
userId: currentUser.id,
|
||||||
|
username: currentUser.username,
|
||||||
|
isDryRun: input.dryRun ?? false,
|
||||||
|
totalOrders: orderInputs.length,
|
||||||
|
appVersion
|
||||||
|
})
|
||||||
|
await historyDao.insertOrderRecords(batchId, 1, orderInputs)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info('Resolved order numbers', { count: validOrderNumbers.length })
|
// If no valid order numbers, update execution to failed and return early
|
||||||
|
if (validOrderNumbers.length === 0) {
|
||||||
|
if (currentUser) {
|
||||||
|
await historyDao.updateExecutionStatus(
|
||||||
|
batchId,
|
||||||
|
1,
|
||||||
|
'failed',
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
new Date(),
|
||||||
|
warnings.join('\n') || '没有有效的生产订单号可处理'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const emptyResult: CleanerResult = {
|
||||||
|
ordersProcessed: 0,
|
||||||
|
materialsDeleted: 0,
|
||||||
|
materialsSkipped: 0,
|
||||||
|
errors: [...warnings],
|
||||||
|
details: [],
|
||||||
|
retriedOrders: 0,
|
||||||
|
successfulRetries: 0,
|
||||||
|
materialsFailed: 0,
|
||||||
|
uncertainDeletions: 0
|
||||||
|
}
|
||||||
|
await this.recordCleanupAudit(0, input, emptyResult)
|
||||||
|
return emptyResult
|
||||||
|
}
|
||||||
|
|
||||||
authService = new ErpAuthService({
|
authService = new ErpAuthService({
|
||||||
url: erpConfig.url,
|
url: erpConfig.url,
|
||||||
@@ -104,7 +156,6 @@ export class CleanerApplicationService {
|
|||||||
totalMaterialsInOrder: 0
|
totalMaterialsInOrder: 0
|
||||||
})
|
})
|
||||||
|
|
||||||
const cleaner = new CleanerService(authService)
|
|
||||||
const modifiedInput: CleanerInput = {
|
const modifiedInput: CleanerInput = {
|
||||||
...input,
|
...input,
|
||||||
orderNumbers: validOrderNumbers,
|
orderNumbers: validOrderNumbers,
|
||||||
@@ -114,12 +165,94 @@ export class CleanerApplicationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
log.info('Starting cleaning', {
|
log.info('Starting cleaning', {
|
||||||
|
batchId,
|
||||||
orderCount: validOrderNumbers.length,
|
orderCount: validOrderNumbers.length,
|
||||||
queryBatchSize: input.queryBatchSize ?? 100,
|
queryBatchSize: input.queryBatchSize ?? 100,
|
||||||
processConcurrency: input.processConcurrency ?? 1
|
processConcurrency: input.processConcurrency ?? 1
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = await cleaner.clean(modifiedInput)
|
let cleaner = new CleanerService(authService)
|
||||||
|
let result = await cleaner.clean(modifiedInput)
|
||||||
|
|
||||||
|
// Outer retry: re-login and re-run all orders on fatal crash
|
||||||
|
if (result.crashed) {
|
||||||
|
log.warn('检测到流程级崩溃,准备外层重试', { batchId })
|
||||||
|
|
||||||
|
// Save attempt 1 result as crashed
|
||||||
|
await this.saveAttemptToDatabase(historyDao, batchId, 1, result)
|
||||||
|
|
||||||
|
this.sendProgress(eventSender, '流程崩溃,正在重新登录并重试...', 0, {
|
||||||
|
phase: 'retry',
|
||||||
|
currentOrderIndex: 0,
|
||||||
|
totalOrders,
|
||||||
|
currentMaterialIndex: 0,
|
||||||
|
totalMaterialsInOrder: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await authService.close()
|
||||||
|
} catch {
|
||||||
|
// Browser may already be dead, ignore close errors
|
||||||
|
}
|
||||||
|
authService = null
|
||||||
|
|
||||||
|
authService = new ErpAuthService({
|
||||||
|
url: erpConfig.url,
|
||||||
|
username: erpConfig.username,
|
||||||
|
password: erpConfig.password,
|
||||||
|
headless: input.headless ?? true
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await authService.login()
|
||||||
|
log.info('Outer retry: re-login successful', { batchId })
|
||||||
|
} catch (loginError) {
|
||||||
|
log.error('Outer retry: re-login failed', {
|
||||||
|
batchId,
|
||||||
|
error: loginError instanceof Error ? loginError.message : String(loginError)
|
||||||
|
})
|
||||||
|
// Return the original crash result if re-login fails
|
||||||
|
result.errors.push(
|
||||||
|
`外层重试登录失败: ${loginError instanceof Error ? loginError.message : String(loginError)}`
|
||||||
|
)
|
||||||
|
if (warnings.length > 0) {
|
||||||
|
result.errors = [...warnings, ...result.errors]
|
||||||
|
}
|
||||||
|
await this.recordCleanupAudit(validOrderNumbers.length, input, result)
|
||||||
|
// Attempt 1 already saved as crashed above
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert execution and order records for attempt 2
|
||||||
|
if (currentUser) {
|
||||||
|
await historyDao.insertExecution({
|
||||||
|
batchId,
|
||||||
|
attemptNumber: 2,
|
||||||
|
userId: currentUser.id,
|
||||||
|
username: currentUser.username,
|
||||||
|
isDryRun: input.dryRun ?? false,
|
||||||
|
totalOrders: orderInputs.length,
|
||||||
|
appVersion
|
||||||
|
})
|
||||||
|
await historyDao.insertOrderRecords(batchId, 2, orderInputs)
|
||||||
|
}
|
||||||
|
|
||||||
|
cleaner = new CleanerService(authService)
|
||||||
|
result = await cleaner.clean(modifiedInput)
|
||||||
|
|
||||||
|
// Save attempt 2 result
|
||||||
|
await this.saveAttemptToDatabase(historyDao, batchId, 2, result)
|
||||||
|
|
||||||
|
log.info('Outer retry completed', {
|
||||||
|
batchId,
|
||||||
|
processedCount: result.ordersProcessed,
|
||||||
|
errorCount: result.errors.length,
|
||||||
|
crashed: result.crashed
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// No crash — save attempt 1 result
|
||||||
|
await this.saveAttemptToDatabase(historyDao, batchId, 1, result)
|
||||||
|
}
|
||||||
|
|
||||||
if (warnings.length > 0) {
|
if (warnings.length > 0) {
|
||||||
result.errors = [...warnings, ...result.errors]
|
result.errors = [...warnings, ...result.errors]
|
||||||
@@ -134,12 +267,12 @@ export class CleanerApplicationService {
|
|||||||
})
|
})
|
||||||
|
|
||||||
log.info('Cleaning completed', {
|
log.info('Cleaning completed', {
|
||||||
|
batchId,
|
||||||
processedCount: result.ordersProcessed,
|
processedCount: result.ordersProcessed,
|
||||||
errorCount: result.errors.length
|
errorCount: result.errors.length
|
||||||
})
|
})
|
||||||
|
|
||||||
await this.recordCleanupAudit(validOrderNumbers.length, input, result)
|
await this.recordCleanupAudit(validOrderNumbers.length, input, result)
|
||||||
await this.generateAndUploadReport(input, result, startTime)
|
|
||||||
|
|
||||||
return result
|
return result
|
||||||
} finally {
|
} finally {
|
||||||
@@ -269,6 +402,38 @@ export class CleanerApplicationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build order inputs from ALL mappings, including resolution failures.
|
||||||
|
* Deduplicates by orderNumber for resolved mappings, includes all failed mappings.
|
||||||
|
*/
|
||||||
|
private buildOrderInputs(mappings: OrderMapping[]): InsertOrderInput[] {
|
||||||
|
const inputs: InsertOrderInput[] = []
|
||||||
|
const seenOrderNumbers = new Set<string>()
|
||||||
|
|
||||||
|
for (const mapping of mappings) {
|
||||||
|
if (mapping.resolved && mapping.orderNumber) {
|
||||||
|
// Deduplicate resolved mappings by order number
|
||||||
|
if (!seenOrderNumbers.has(mapping.orderNumber)) {
|
||||||
|
seenOrderNumbers.add(mapping.orderNumber)
|
||||||
|
inputs.push({
|
||||||
|
orderNumber: mapping.orderNumber,
|
||||||
|
productionId: mapping.productionId
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Resolution failure: use original input as orderNumber identifier
|
||||||
|
inputs.push({
|
||||||
|
orderNumber: mapping.input,
|
||||||
|
productionId: mapping.productionId,
|
||||||
|
initialStatus: 'not_found',
|
||||||
|
errorMessage: mapping.error || '未在数据库中找到对应的订单号'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return inputs
|
||||||
|
}
|
||||||
|
|
||||||
private async recordCleanupAudit(
|
private async recordCleanupAudit(
|
||||||
orderCount: number,
|
orderCount: number,
|
||||||
input: CleanerInput,
|
input: CleanerInput,
|
||||||
@@ -297,69 +462,128 @@ export class CleanerApplicationService {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private async generateAndUploadReport(
|
/**
|
||||||
input: CleanerInput,
|
* Save attempt results to database: update order statuses, insert material details,
|
||||||
result: CleanerResult,
|
* and update execution status.
|
||||||
startTime: number
|
*/
|
||||||
|
private async saveAttemptToDatabase(
|
||||||
|
historyDao: CleanerOperationHistoryDAO,
|
||||||
|
batchId: string,
|
||||||
|
attemptNumber: number,
|
||||||
|
result: CleanerResult
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const endTime = Date.now()
|
// Query execution record to determine if this is a dry run
|
||||||
const currentUser = SessionManager.getInstance().getUserInfo()
|
const batchDetails = await historyDao.getBatchDetails(batchId)
|
||||||
const username = currentUser?.username ?? 'unknown'
|
const execution = batchDetails.executions.find((e) => e.attemptNumber === attemptNumber)
|
||||||
|
const isDryRun = execution?.isDryRun ?? false
|
||||||
|
|
||||||
const reportGenerator = new CleanerReportGenerator()
|
// Update order statuses and insert material details
|
||||||
const reportPath = await reportGenerator.generateReport(result, {
|
for (const detail of result.details) {
|
||||||
dryRun: input.dryRun ?? false,
|
await historyDao.updateOrderStatus(
|
||||||
username,
|
batchId,
|
||||||
startTime,
|
attemptNumber,
|
||||||
endTime
|
detail.orderNumber,
|
||||||
})
|
detail.notFound ? 'erp_not_found' : detail.errors.length > 0 ? 'failed' : 'success',
|
||||||
log.info('Report generated', { path: reportPath })
|
detail.materialsDeleted,
|
||||||
|
detail.materialsSkipped,
|
||||||
const configManager = ConfigManager.getInstance()
|
detail.materialsFailed,
|
||||||
const config = configManager.getConfig()
|
detail.uncertainDeletions,
|
||||||
|
detail.retryCount,
|
||||||
if (!config.rustfs?.enabled || !config.rustfs.endpoint) {
|
detail.retrySuccess ?? false,
|
||||||
log.debug('RustFS is not enabled, skipping upload')
|
detail.errors.length > 0 ? detail.errors.join('\n') : undefined
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const rustfs = new RustfsService({ config: config.rustfs })
|
|
||||||
const reportFileName = reportPath.split(/[\\/]/).pop() || 'report.md'
|
|
||||||
const storageKey = rustfs.generateReportKey(reportFileName, username)
|
|
||||||
|
|
||||||
log.info('Uploading report to RustFS', {
|
|
||||||
localPath: reportPath,
|
|
||||||
storageKey
|
|
||||||
})
|
|
||||||
|
|
||||||
const uploadResult = await rustfs.uploadFile(
|
|
||||||
reportPath,
|
|
||||||
storageKey,
|
|
||||||
'text/markdown; charset=utf-8'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if (uploadResult.success) {
|
// Insert material details for all materials
|
||||||
log.info('Report uploaded to RustFS successfully', {
|
const materialDetails: InsertMaterialDetailInput[] = []
|
||||||
key: storageKey,
|
|
||||||
etag: uploadResult.etag
|
for (const deleted of detail.deletedMaterials) {
|
||||||
})
|
materialDetails.push({
|
||||||
} else {
|
orderNumber: detail.orderNumber,
|
||||||
log.warn('Failed to upload report to RustFS', {
|
materialCode: deleted.materialCode,
|
||||||
error: uploadResult.error,
|
materialName: deleted.materialName,
|
||||||
key: storageKey
|
rowNumber: deleted.rowNumber,
|
||||||
|
result: deleted.outcome,
|
||||||
|
reason: null,
|
||||||
|
attemptCount: 1,
|
||||||
|
finalErrorCategory: null
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch (rustfsError) {
|
|
||||||
log.error('RustFS upload failed', {
|
for (const skipped of detail.skippedMaterials) {
|
||||||
error: rustfsError instanceof Error ? rustfsError.message : String(rustfsError)
|
materialDetails.push({
|
||||||
})
|
orderNumber: detail.orderNumber,
|
||||||
|
materialCode: skipped.materialCode,
|
||||||
|
materialName: skipped.materialName,
|
||||||
|
rowNumber: skipped.rowNumber,
|
||||||
|
result: 'skipped',
|
||||||
|
reason: skipped.reason,
|
||||||
|
attemptCount: 0,
|
||||||
|
finalErrorCategory: null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const failed of detail.failedMaterials) {
|
||||||
|
materialDetails.push({
|
||||||
|
orderNumber: detail.orderNumber,
|
||||||
|
materialCode: failed.materialCode,
|
||||||
|
materialName: failed.materialName,
|
||||||
|
rowNumber: failed.rowNumber,
|
||||||
|
result: failed.finalOutcome,
|
||||||
|
reason:
|
||||||
|
failed.attempts
|
||||||
|
.map((a) => a.errorMessage)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('; ') || null,
|
||||||
|
attemptCount: failed.attempts.length,
|
||||||
|
finalErrorCategory: failed.finalErrorCategory ?? null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (materialDetails.length > 0 && !isDryRun) {
|
||||||
|
await historyDao.insertMaterialDetails(batchId, attemptNumber, materialDetails)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (reportError) {
|
|
||||||
log.warn('Failed to generate report', {
|
// Determine execution status
|
||||||
error: reportError instanceof Error ? reportError.message : String(reportError)
|
const execStatus = result.crashed
|
||||||
|
? 'crashed'
|
||||||
|
: result.errors.length > 0 && result.materialsDeleted > 0
|
||||||
|
? 'partial'
|
||||||
|
: result.errors.length > 0
|
||||||
|
? 'failed'
|
||||||
|
: 'success'
|
||||||
|
|
||||||
|
// Build error message from execution-level errors (excluding per-order errors)
|
||||||
|
const execErrorMessage = result.errors.length > 0 ? result.errors.join('\n') : undefined
|
||||||
|
|
||||||
|
// Update execution status
|
||||||
|
await historyDao.updateExecutionStatus(
|
||||||
|
batchId,
|
||||||
|
attemptNumber,
|
||||||
|
execStatus,
|
||||||
|
result.ordersProcessed,
|
||||||
|
result.materialsDeleted,
|
||||||
|
result.materialsSkipped,
|
||||||
|
result.materialsFailed,
|
||||||
|
result.uncertainDeletions,
|
||||||
|
new Date(),
|
||||||
|
execErrorMessage
|
||||||
|
)
|
||||||
|
|
||||||
|
log.info('Attempt results saved to database', {
|
||||||
|
batchId,
|
||||||
|
attemptNumber,
|
||||||
|
execStatus,
|
||||||
|
ordersProcessed: result.ordersProcessed
|
||||||
})
|
})
|
||||||
|
} catch (dbError) {
|
||||||
|
log.error('Failed to save attempt results to database', {
|
||||||
|
batchId,
|
||||||
|
attemptNumber,
|
||||||
|
error: dbError instanceof Error ? dbError.message : String(dbError)
|
||||||
|
})
|
||||||
|
// Don't throw — database save failure should not affect the main result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1164
src/main/services/database/cleaner-operation-history-dao.ts
Normal file
1164
src/main/services/database/cleaner-operation-history-dao.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -27,7 +27,7 @@ export class MySqlDialect implements SqlDialect {
|
|||||||
}
|
}
|
||||||
|
|
||||||
currentTimestamp(): string {
|
currentTimestamp(): string {
|
||||||
return 'NOW()'
|
return 'UTC_TIMESTAMP()'
|
||||||
}
|
}
|
||||||
|
|
||||||
upsert(params: {
|
upsert(params: {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export class SqlServerDialect implements SqlDialect {
|
|||||||
}
|
}
|
||||||
|
|
||||||
currentTimestamp(): string {
|
currentTimestamp(): string {
|
||||||
return 'GETDATE()'
|
return 'SYSUTCDATETIME()'
|
||||||
}
|
}
|
||||||
|
|
||||||
upsert(params: {
|
upsert(params: {
|
||||||
|
|||||||
@@ -110,6 +110,58 @@ export class MaterialsToBeDeletedDAO {
|
|||||||
const manager = managerName?.trim() || null
|
const manager = managerName?.trim() || null
|
||||||
const dialect = this.getDialect()
|
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({
|
const { sql: sqlString } = dialect.upsert({
|
||||||
table: tableName,
|
table: tableName,
|
||||||
keyColumns: ['MaterialCode'],
|
keyColumns: ['MaterialCode'],
|
||||||
@@ -177,22 +229,12 @@ export class MaterialsToBeDeletedDAO {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { sql: sqlString } = dialect.upsert({
|
const success = await this.upsertMaterial(materialCode, managerName)
|
||||||
table: tableName,
|
if (success) {
|
||||||
keyColumns: ['MaterialCode'],
|
stats.success++
|
||||||
allColumns: ['MaterialCode', 'ManagerName'],
|
} else {
|
||||||
startParamIndex: 0
|
stats.failed++
|
||||||
})
|
}
|
||||||
|
|
||||||
await trackDuration(
|
|
||||||
async () => await dbService.query(sqlString, [materialCode, managerName || null]),
|
|
||||||
{
|
|
||||||
operationName: 'MaterialsToBeDeletedDAO.upsertBatch',
|
|
||||||
context: { tableName, operationType: 'UPSERT', batchId }
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
stats.success++
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error('Error upserting material', {
|
log.error('Error upserting material', {
|
||||||
tableName,
|
tableName,
|
||||||
@@ -201,7 +243,6 @@ export class MaterialsToBeDeletedDAO {
|
|||||||
materialCode,
|
materialCode,
|
||||||
error: error instanceof Error ? error.message : String(error)
|
error: error instanceof Error ? error.message : String(error)
|
||||||
})
|
})
|
||||||
stats.failed++
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,19 +279,8 @@ export class MaterialsToBeDeletedDAO {
|
|||||||
managerName: string
|
managerName: string
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
try {
|
try {
|
||||||
const dbService = await this.getDatabaseService()
|
const success = await this.upsertMaterial(materialCode, managerName)
|
||||||
const tableName = this.getTableName()
|
return { success }
|
||||||
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 }
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error('Update manager error', {
|
log.error('Update manager error', {
|
||||||
materialCode,
|
materialCode,
|
||||||
|
|||||||
@@ -210,6 +210,58 @@ export class MaterialsTypeToBeDeletedDAO {
|
|||||||
const manager = managerName?.trim() || null
|
const manager = managerName?.trim() || null
|
||||||
const dialect = this.getDialect()
|
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({
|
const { sql: sqlString } = dialect.upsert({
|
||||||
table: tableName,
|
table: tableName,
|
||||||
keyColumns: ['MaterialName'],
|
keyColumns: ['MaterialName'],
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { ERP_LOCATORS } from './locators'
|
import { ERP_LOCATORS } from './locators'
|
||||||
import { ErpAuthService } from './erp-auth'
|
import { ErpAuthService } from './erp-auth'
|
||||||
import type { CleanerInput, CleanerResult, OrderCleanDetail } from '../../types/cleaner.types'
|
import { DeletionErrorCategory, DeletionOutcome } from '../../types/cleaner.types'
|
||||||
|
import type {
|
||||||
|
CleanerInput,
|
||||||
|
CleanerResult,
|
||||||
|
MaterialDeletionAttempt,
|
||||||
|
OrderCleanDetail
|
||||||
|
} from '../../types/cleaner.types'
|
||||||
import type { ErpSession } from '../../types/erp.types'
|
import type { ErpSession } from '../../types/erp.types'
|
||||||
import type { FrameLocator, Locator, Page } from 'playwright'
|
import type { FrameLocator, Locator, Page } from 'playwright'
|
||||||
import { createLogger, run, trackDuration } from '../logger'
|
import { createLogger, run, trackDuration } from '../logger'
|
||||||
@@ -20,8 +26,12 @@ interface RetryResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ProgressState {
|
interface ProgressState {
|
||||||
completedOrders: number
|
ordersStarted: number // 开始处理的订单数
|
||||||
|
ordersCompleted: number // 已完成订单数
|
||||||
totalOrders: number
|
totalOrders: number
|
||||||
|
progressText?: string // "ordersCompleted/totalOrders (xx%)"
|
||||||
|
lastCompletedOrder?: string // 最后完成的订单号
|
||||||
|
lastActivityTime?: number // 最后活动时间戳(健康检查用)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface QueryResultRow {
|
interface QueryResultRow {
|
||||||
@@ -50,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
|
* Cleaner Service Options
|
||||||
*/
|
*/
|
||||||
@@ -190,7 +242,9 @@ export class CleanerService {
|
|||||||
errors: [],
|
errors: [],
|
||||||
details: [],
|
details: [],
|
||||||
retriedOrders: 0,
|
retriedOrders: 0,
|
||||||
successfulRetries: 0
|
successfulRetries: 0,
|
||||||
|
materialsFailed: 0,
|
||||||
|
uncertainDeletions: 0
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalOrders = input.orderNumbers.length
|
const totalOrders = input.orderNumbers.length
|
||||||
@@ -234,7 +288,8 @@ export class CleanerService {
|
|||||||
const orderBatches = createBatches(input.orderNumbers, queryBatchSize)
|
const orderBatches = createBatches(input.orderNumbers, queryBatchSize)
|
||||||
const popupMutex = new AsyncMutex()
|
const popupMutex = new AsyncMutex()
|
||||||
const progressState: ProgressState = {
|
const progressState: ProgressState = {
|
||||||
completedOrders: 0,
|
ordersStarted: 0,
|
||||||
|
ordersCompleted: 0,
|
||||||
totalOrders
|
totalOrders
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,56 +304,131 @@ export class CleanerService {
|
|||||||
totalMaterials
|
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
|
// Track batch processing duration with 5s slow threshold
|
||||||
await trackDuration(
|
await trackDuration(
|
||||||
async () => {
|
async () => {
|
||||||
await this.queryOrders(workFrame, batchOrders)
|
// Phase 1: Query orders
|
||||||
await this.waitForLoading(workFrame)
|
await trackDuration(async () => await this.queryOrders(workFrame, batchOrders), {
|
||||||
|
operationName: 'query',
|
||||||
const queriedRows = await this.collectQueryResultRows(workFrame)
|
message: '执行订单查询',
|
||||||
const queriedOrderNumbersInBatch = new Set(queriedRows.map((row) => row.orderNumber))
|
slowThresholdMs: 3000,
|
||||||
|
context: { orderCount: batchOrders.length }
|
||||||
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
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const missingOrders = getMissingOrders(batchOrders, queriedOrderNumbersInBatch)
|
// Phase 2: Wait for loading complete
|
||||||
for (const missingOrder of missingOrders) {
|
await trackDuration(async () => await this.waitForLoading(workFrame), {
|
||||||
const missingMessage = '订单未出现在查询结果中'
|
operationName: 'wait_loading',
|
||||||
result.errors.push(`Order ${missingOrder}: ${missingMessage}`)
|
message: '等待加载完成',
|
||||||
result.details.push(this.createErrorDetail(missingOrder, missingMessage))
|
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`,
|
operationName: `batch-${batchIndex + 1}-${orderBatches[batchIndex].length}-orders`,
|
||||||
@@ -313,6 +443,9 @@ export class CleanerService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// [新增] 清理健康检查定时器
|
||||||
|
clearInterval(healthCheckInterval)
|
||||||
}
|
}
|
||||||
|
|
||||||
const retryResult = await this.retryFailedOrders({
|
const retryResult = await this.retryFailedOrders({
|
||||||
@@ -337,6 +470,8 @@ export class CleanerService {
|
|||||||
result.ordersProcessed += 1
|
result.ordersProcessed += 1
|
||||||
result.materialsDeleted += updatedDetail.materialsDeleted
|
result.materialsDeleted += updatedDetail.materialsDeleted
|
||||||
result.materialsSkipped += updatedDetail.materialsSkipped
|
result.materialsSkipped += updatedDetail.materialsSkipped
|
||||||
|
result.materialsFailed += updatedDetail.materialsFailed
|
||||||
|
result.uncertainDeletions += updatedDetail.uncertainDeletions
|
||||||
}
|
}
|
||||||
result.details[index] = updatedDetail
|
result.details[index] = updatedDetail
|
||||||
}
|
}
|
||||||
@@ -353,6 +488,8 @@ export class CleanerService {
|
|||||||
ordersProcessed: result.ordersProcessed,
|
ordersProcessed: result.ordersProcessed,
|
||||||
materialsDeleted: result.materialsDeleted,
|
materialsDeleted: result.materialsDeleted,
|
||||||
materialsSkipped: result.materialsSkipped,
|
materialsSkipped: result.materialsSkipped,
|
||||||
|
materialsFailed: result.materialsFailed,
|
||||||
|
uncertainDeletions: result.uncertainDeletions,
|
||||||
errorCount: result.errors.length,
|
errorCount: result.errors.length,
|
||||||
totalOrders,
|
totalOrders,
|
||||||
totalMaterials,
|
totalMaterials,
|
||||||
@@ -367,9 +504,19 @@ export class CleanerService {
|
|||||||
dryRun,
|
dryRun,
|
||||||
orderNumbers: input.orderNumbers,
|
orderNumbers: input.orderNumbers,
|
||||||
materialCodes: input.materialCodes,
|
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.errors.push(`Clean failed: ${message}`)
|
||||||
|
result.crashed = true
|
||||||
} finally {
|
} finally {
|
||||||
if (popupPage) {
|
if (popupPage) {
|
||||||
try {
|
try {
|
||||||
@@ -423,7 +570,14 @@ export class CleanerService {
|
|||||||
log.error('[导航失败] forwardFrame 为空', {
|
log.error('[导航失败] forwardFrame 为空', {
|
||||||
elapsedMs: Date.now() - navStartTime,
|
elapsedMs: Date.now() - navStartTime,
|
||||||
pageUrl: popupPage.url(),
|
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')
|
throw new Error('无法访问弹出窗口的 forwardFrame')
|
||||||
}
|
}
|
||||||
@@ -442,7 +596,14 @@ export class CleanerService {
|
|||||||
log.error('[导航失败] workFrame 为空', {
|
log.error('[导航失败] workFrame 为空', {
|
||||||
elapsedMs: Date.now() - navStartTime,
|
elapsedMs: Date.now() - navStartTime,
|
||||||
pageUrl: popupPage.url(),
|
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('无法访问内部工作框架')
|
throw new Error('无法访问内部工作框架')
|
||||||
}
|
}
|
||||||
@@ -665,14 +826,40 @@ export class CleanerService {
|
|||||||
const { detailPage, deleteSet, dryRun, progressState, expectedOrderNumber, onProgress } = params
|
const { detailPage, deleteSet, dryRun, progressState, expectedOrderNumber, onProgress } = params
|
||||||
const processStartTime = Date.now()
|
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('[订单详情处理开始]', {
|
log.info('[订单详情处理开始]', {
|
||||||
expectedOrderNumber,
|
expectedOrderNumber,
|
||||||
dryRun,
|
dryRun,
|
||||||
deleteSetSize: deleteSet.size,
|
deleteSetSize: deleteSet.size,
|
||||||
completedOrders: progressState.completedOrders,
|
ordersStarted: progressState.ordersStarted,
|
||||||
|
ordersCompleted: progressState.ordersCompleted,
|
||||||
totalOrders: progressState.totalOrders
|
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 {
|
try {
|
||||||
// Step 1: Access forward frame
|
// Step 1: Access forward frame
|
||||||
log.debug('[详情页面 Step 1] 准备访问 forwardFrame')
|
log.debug('[详情页面 Step 1] 准备访问 forwardFrame')
|
||||||
@@ -684,7 +871,14 @@ export class CleanerService {
|
|||||||
log.error('[详情页面失败] forwardFrame 访问失败', {
|
log.error('[详情页面失败] forwardFrame 访问失败', {
|
||||||
elapsedMs: Date.now() - processStartTime,
|
elapsedMs: Date.now() - processStartTime,
|
||||||
pageUrl: detailPage.url(),
|
pageUrl: detailPage.url(),
|
||||||
contextData: await capturePageContext(detailPage)
|
contextData: await capturePageContext(
|
||||||
|
detailPage,
|
||||||
|
undefined,
|
||||||
|
'processDetail.forwardFrame',
|
||||||
|
expectedOrderNumber,
|
||||||
|
undefined,
|
||||||
|
'process_detail_forward_frame'
|
||||||
|
)
|
||||||
})
|
})
|
||||||
throw new Error(errorMsg)
|
throw new Error(errorMsg)
|
||||||
}
|
}
|
||||||
@@ -706,7 +900,10 @@ export class CleanerService {
|
|||||||
contextData: await capturePageContext(
|
contextData: await capturePageContext(
|
||||||
detailPage,
|
detailPage,
|
||||||
undefined,
|
undefined,
|
||||||
'processDetail.detailInnerFrame'
|
'processDetail.detailInnerFrame',
|
||||||
|
expectedOrderNumber,
|
||||||
|
undefined,
|
||||||
|
'process_detail_inner_frame'
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
throw new Error(errorMsg)
|
throw new Error(errorMsg)
|
||||||
@@ -737,17 +934,8 @@ export class CleanerService {
|
|||||||
usedFallback: !sourceOrderNumber && !!expectedOrderNumber
|
usedFallback: !sourceOrderNumber && !!expectedOrderNumber
|
||||||
})
|
})
|
||||||
|
|
||||||
const detail: OrderCleanDetail = {
|
// 更新 orderNumber 为实际提取的值或 fallback
|
||||||
orderNumber,
|
detail.orderNumber = orderNumber
|
||||||
materialsDeleted: 0,
|
|
||||||
materialsSkipped: 0,
|
|
||||||
errors: [],
|
|
||||||
skippedMaterials: [],
|
|
||||||
retryCount: 0,
|
|
||||||
retryAttempts: [],
|
|
||||||
retriedAt: undefined,
|
|
||||||
retrySuccess: false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 5: Get material counts and status
|
// Step 5: Get material counts and status
|
||||||
log.debug('[详情页面 Step 5] 读取物料数量和状态')
|
log.debug('[详情页面 Step 5] 读取物料数量和状态')
|
||||||
@@ -769,13 +957,13 @@ export class CleanerService {
|
|||||||
onProgress?.(
|
onProgress?.(
|
||||||
`开始处理订单:${orderNumber}`,
|
`开始处理订单:${orderNumber}`,
|
||||||
this.calculateProgress(
|
this.calculateProgress(
|
||||||
progressState.completedOrders,
|
progressState.ordersStarted,
|
||||||
0,
|
0,
|
||||||
detailCount,
|
detailCount,
|
||||||
progressState.totalOrders
|
progressState.totalOrders
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
currentOrderIndex: progressState.completedOrders + 1,
|
currentOrderIndex: progressState.ordersStarted + 1,
|
||||||
totalOrders: progressState.totalOrders,
|
totalOrders: progressState.totalOrders,
|
||||||
currentMaterialIndex: 0,
|
currentMaterialIndex: 0,
|
||||||
totalMaterialsInOrder: detailCount,
|
totalMaterialsInOrder: detailCount,
|
||||||
@@ -842,7 +1030,7 @@ export class CleanerService {
|
|||||||
const pendingQty = await this.getInputValue(childForm, /^累计待发数量$/)
|
const pendingQty = await this.getInputValue(childForm, /^累计待发数量$/)
|
||||||
|
|
||||||
const progress = this.calculateProgress(
|
const progress = this.calculateProgress(
|
||||||
progressState.completedOrders,
|
progressState.ordersStarted,
|
||||||
materialIdx,
|
materialIdx,
|
||||||
detailCount,
|
detailCount,
|
||||||
progressState.totalOrders
|
progressState.totalOrders
|
||||||
@@ -852,7 +1040,7 @@ export class CleanerService {
|
|||||||
`订单 ${orderNumber} - 物料 ${materialIdx}/${detailCount}: ${materialName}`,
|
`订单 ${orderNumber} - 物料 ${materialIdx}/${detailCount}: ${materialName}`,
|
||||||
progress,
|
progress,
|
||||||
{
|
{
|
||||||
currentOrderIndex: progressState.completedOrders + 1,
|
currentOrderIndex: progressState.ordersStarted + 1,
|
||||||
totalOrders: progressState.totalOrders,
|
totalOrders: progressState.totalOrders,
|
||||||
currentMaterialIndex: materialIdx,
|
currentMaterialIndex: materialIdx,
|
||||||
totalMaterialsInOrder: detailCount,
|
totalMaterialsInOrder: detailCount,
|
||||||
@@ -879,33 +1067,67 @@ export class CleanerService {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (shouldDelete && !dryRun) {
|
if (shouldDelete && !dryRun) {
|
||||||
log.info('[物料操作] 执行删除操作', {
|
log.info('[物料操作] 执行删除操作(多信号验证)', {
|
||||||
orderNumber,
|
orderNumber,
|
||||||
materialIdx,
|
materialIdx,
|
||||||
materialCode,
|
materialCode,
|
||||||
materialName,
|
materialName,
|
||||||
rowNumber: currentRow,
|
rowNumber: currentRow,
|
||||||
|
materialCount: detailCount,
|
||||||
dryRun: false
|
dryRun: false
|
||||||
})
|
})
|
||||||
const oldRowNumber = currentRow
|
|
||||||
await deleteRowBtn.click()
|
|
||||||
|
|
||||||
const deleteSuccess = await this.waitForRowChange(childForm, oldRowNumber, 10000)
|
const currentMaterialCount = await this.readMaterialCount(detailInnerFrame)
|
||||||
|
const deleteResult = await this.deleteWithVerification({
|
||||||
|
childForm,
|
||||||
|
detailInnerFrame,
|
||||||
|
deleteRowBtn,
|
||||||
|
materialCode,
|
||||||
|
materialName,
|
||||||
|
currentRowNumber: currentRow,
|
||||||
|
materialCountBefore: currentMaterialCount ?? detailCount
|
||||||
|
})
|
||||||
|
|
||||||
const deleteElapsed = Date.now() - materialStartTime
|
const deleteElapsed = Date.now() - materialStartTime
|
||||||
|
|
||||||
if (deleteSuccess) {
|
if (
|
||||||
|
deleteResult.outcome === DeletionOutcome.Success ||
|
||||||
|
deleteResult.outcome === DeletionOutcome.Uncertain
|
||||||
|
) {
|
||||||
detail.materialsDeleted += 1
|
detail.materialsDeleted += 1
|
||||||
log.info('[物料操作完成] 物料已成功删除', {
|
detail.deletedMaterials.push({
|
||||||
|
materialCode,
|
||||||
|
materialName,
|
||||||
|
rowNumber: rowNumInt,
|
||||||
|
outcome: deleteResult.outcome
|
||||||
|
})
|
||||||
|
if (deleteResult.outcome === DeletionOutcome.Uncertain) {
|
||||||
|
detail.uncertainDeletions += 1
|
||||||
|
}
|
||||||
|
log.info('[物料操作完成] 物料删除结果', {
|
||||||
orderNumber,
|
orderNumber,
|
||||||
materialCode,
|
materialCode,
|
||||||
rowNumber: currentRow,
|
outcome: deleteResult.outcome,
|
||||||
|
attempts: deleteResult.attempts.length,
|
||||||
elapsedMs: deleteElapsed
|
elapsedMs: deleteElapsed
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
log.warn('[物料操作警告] 删除操作后行号未改变', {
|
detail.materialsFailed += 1
|
||||||
|
detail.failedMaterials.push({
|
||||||
|
materialCode,
|
||||||
|
materialName,
|
||||||
|
rowNumber: rowNumInt,
|
||||||
|
attempts: deleteResult.attempts,
|
||||||
|
finalOutcome: deleteResult.outcome,
|
||||||
|
finalErrorCategory: deleteResult.errorCategory
|
||||||
|
})
|
||||||
|
log.error('[物料操作失败] 物料删除失败', {
|
||||||
orderNumber,
|
orderNumber,
|
||||||
materialCode,
|
materialCode,
|
||||||
rowNumber: currentRow,
|
outcome: deleteResult.outcome,
|
||||||
|
errorCategory: deleteResult.errorCategory,
|
||||||
|
errorMessage: deleteResult.errorMessage,
|
||||||
|
attempts: deleteResult.attempts.length,
|
||||||
elapsedMs: deleteElapsed
|
elapsedMs: deleteElapsed
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -937,11 +1159,20 @@ export class CleanerService {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log.verbose('[物料判断] 物料不在删除清单中,跳过', {
|
// 物料不在删除清单中,记录为 skipped
|
||||||
|
detail.materialsSkipped += 1
|
||||||
|
detail.skippedMaterials.push({
|
||||||
|
materialCode,
|
||||||
|
materialName,
|
||||||
|
rowNumber: rowNumInt,
|
||||||
|
reason: '不在删除清单中'
|
||||||
|
})
|
||||||
|
log.verbose('[物料跳过] 物料不在删除清单中', {
|
||||||
orderNumber,
|
orderNumber,
|
||||||
materialIdx,
|
materialIdx,
|
||||||
materialCode,
|
materialCode,
|
||||||
materialName
|
materialName,
|
||||||
|
rowNumber: rowNumInt
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1016,10 +1247,31 @@ export class CleanerService {
|
|||||||
orderNumber: expectedOrderNumber || 'UNKNOWN',
|
orderNumber: expectedOrderNumber || 'UNKNOWN',
|
||||||
error: message,
|
error: message,
|
||||||
elapsedMs: Date.now() - processStartTime,
|
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
|
throw error
|
||||||
} finally {
|
} 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() })
|
log.debug('[详情页面清理] 准备关闭详情页面', { pageUrl: detailPage.url() })
|
||||||
await detailPage.close()
|
await detailPage.close()
|
||||||
log.debug('[详情页面清理完成] 详情页已关闭')
|
log.debug('[详情页面清理完成] 详情页已关闭')
|
||||||
@@ -1053,17 +1305,26 @@ export class CleanerService {
|
|||||||
return /^SC\d{14}$/.test(value)
|
return /^SC\d{14}$/.test(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
private createErrorDetail(orderNumber: string, message: string): OrderCleanDetail {
|
private createErrorDetail(
|
||||||
|
orderNumber: string,
|
||||||
|
message: string,
|
||||||
|
notFound: boolean = false
|
||||||
|
): OrderCleanDetail {
|
||||||
return {
|
return {
|
||||||
orderNumber,
|
orderNumber,
|
||||||
materialsDeleted: 0,
|
materialsDeleted: 0,
|
||||||
materialsSkipped: 0,
|
materialsSkipped: 0,
|
||||||
errors: [message],
|
errors: [message],
|
||||||
skippedMaterials: [],
|
skippedMaterials: [],
|
||||||
|
deletedMaterials: [],
|
||||||
retryCount: 0,
|
retryCount: 0,
|
||||||
retryAttempts: [],
|
retryAttempts: [],
|
||||||
retriedAt: undefined,
|
retriedAt: undefined,
|
||||||
retrySuccess: false
|
retrySuccess: false,
|
||||||
|
materialsFailed: 0,
|
||||||
|
failedMaterials: [],
|
||||||
|
uncertainDeletions: 0,
|
||||||
|
notFound
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1105,6 +1366,286 @@ export class CleanerService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Deletion verification constants ---
|
||||||
|
private static readonly MATERIAL_RETRY_MAX_ATTEMPTS = 3
|
||||||
|
private static readonly MATERIAL_RETRY_DELAY_MS = 1000
|
||||||
|
private static readonly VERIFICATION_TIMEOUT_MS = 8000
|
||||||
|
private static readonly ERROR_CHECK_WINDOW_MS = 2000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read current material count from the "详细信息 (N)" text.
|
||||||
|
*/
|
||||||
|
private async readMaterialCount(
|
||||||
|
detailInnerFrame: FrameLocator | Locator
|
||||||
|
): Promise<number | null> {
|
||||||
|
try {
|
||||||
|
const text = await detailInnerFrame.getByText(/^详细信息 \(\d+\)$/).innerText()
|
||||||
|
const match = text.match(/\((\d+)\)/)
|
||||||
|
return match ? parseInt(match[1], 10) : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check for ERP error messages or confirm dialogs.
|
||||||
|
*/
|
||||||
|
private async checkErpMessages(
|
||||||
|
container: FrameLocator | Locator | Page
|
||||||
|
): Promise<{ hasError: boolean; errorText?: string; hasConfirmDialog: boolean }> {
|
||||||
|
try {
|
||||||
|
const errorLocator = container.locator(ERP_LOCATORS.common.errorMessage)
|
||||||
|
const hasError = await errorLocator.isVisible().catch(() => false)
|
||||||
|
let errorText: string | undefined
|
||||||
|
if (hasError) {
|
||||||
|
errorText = (await errorLocator.textContent().catch(() => undefined)) ?? undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmLocator = container.locator(ERP_LOCATORS.common.confirmDialog)
|
||||||
|
const hasConfirmDialog = await confirmLocator.isVisible().catch(() => false)
|
||||||
|
|
||||||
|
return { hasError, errorText, hasConfirmDialog }
|
||||||
|
} catch {
|
||||||
|
return { hasError: false, hasConfirmDialog: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-handle a confirm dialog by clicking the confirm button.
|
||||||
|
*/
|
||||||
|
private async handleConfirmDialog(container: FrameLocator | Locator | Page): Promise<void> {
|
||||||
|
try {
|
||||||
|
const confirmBtn = container.locator(ERP_LOCATORS.common.confirmButton).first()
|
||||||
|
if (await confirmBtn.isVisible().catch(() => false)) {
|
||||||
|
await confirmBtn.click()
|
||||||
|
log.debug('[确认对话框] 已自动点击确定')
|
||||||
|
await this.delay(500)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
log.warn('[确认对话框] 处理确认对话框失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure logic: evaluate deletion signals and determine outcome.
|
||||||
|
* No Playwright dependencies — easy to unit test.
|
||||||
|
*/
|
||||||
|
evaluateDeletionSignals(params: {
|
||||||
|
rowChanged: boolean
|
||||||
|
countDecreased: boolean | null
|
||||||
|
hasError: boolean
|
||||||
|
errorText?: string
|
||||||
|
}): {
|
||||||
|
outcome: DeletionOutcome
|
||||||
|
errorCategory?: DeletionErrorCategory
|
||||||
|
errorMessage?: string
|
||||||
|
} {
|
||||||
|
const { rowChanged, countDecreased, hasError, errorText } = params
|
||||||
|
|
||||||
|
if (hasError) {
|
||||||
|
return {
|
||||||
|
outcome: DeletionOutcome.FailedErpError,
|
||||||
|
errorCategory: DeletionErrorCategory.ErpRejection,
|
||||||
|
errorMessage: errorText || 'ERP returned an error'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rowChanged && countDecreased !== false) {
|
||||||
|
// countDecreased === true or null (unreadable) — both acceptable
|
||||||
|
return { outcome: DeletionOutcome.Success }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rowChanged && countDecreased === false) {
|
||||||
|
return { outcome: DeletionOutcome.Uncertain }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rowChanged && countDecreased === true) {
|
||||||
|
return { outcome: DeletionOutcome.Success }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neither row changed nor count decreased
|
||||||
|
return {
|
||||||
|
outcome: DeletionOutcome.FailedNoChange,
|
||||||
|
errorCategory: DeletionErrorCategory.Unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core method: delete a single material with multi-signal verification and retry.
|
||||||
|
*/
|
||||||
|
private async deleteWithVerification(params: {
|
||||||
|
childForm: FrameLocator | Locator
|
||||||
|
detailInnerFrame: FrameLocator | Locator
|
||||||
|
deleteRowBtn: Locator
|
||||||
|
materialCode: string
|
||||||
|
materialName: string
|
||||||
|
currentRowNumber: string
|
||||||
|
materialCountBefore: number
|
||||||
|
maxAttempts?: number
|
||||||
|
attemptDelayMs?: number
|
||||||
|
}): Promise<{
|
||||||
|
outcome: DeletionOutcome
|
||||||
|
errorCategory?: DeletionErrorCategory
|
||||||
|
errorMessage?: string
|
||||||
|
attempts: MaterialDeletionAttempt[]
|
||||||
|
}> {
|
||||||
|
const {
|
||||||
|
childForm,
|
||||||
|
detailInnerFrame,
|
||||||
|
deleteRowBtn,
|
||||||
|
materialCode,
|
||||||
|
materialName,
|
||||||
|
currentRowNumber,
|
||||||
|
materialCountBefore,
|
||||||
|
maxAttempts = CleanerService.MATERIAL_RETRY_MAX_ATTEMPTS,
|
||||||
|
attemptDelayMs = CleanerService.MATERIAL_RETRY_DELAY_MS
|
||||||
|
} = params
|
||||||
|
|
||||||
|
const attempts: MaterialDeletionAttempt[] = []
|
||||||
|
|
||||||
|
for (let attemptNum = 1; attemptNum <= maxAttempts; attemptNum++) {
|
||||||
|
const attemptStart = Date.now()
|
||||||
|
|
||||||
|
// Check button state before clicking
|
||||||
|
const btnEnabled = await this.isButtonEnabled(deleteRowBtn)
|
||||||
|
if (!btnEnabled) {
|
||||||
|
attempts.push({
|
||||||
|
attempt: attemptNum,
|
||||||
|
outcome: DeletionOutcome.FailedButtonDisabled,
|
||||||
|
errorCategory: DeletionErrorCategory.UiUnexpected,
|
||||||
|
errorMessage: '删行按钮不可用',
|
||||||
|
rowNumberBefore: currentRowNumber,
|
||||||
|
rowNumberAfter: currentRowNumber,
|
||||||
|
materialCountBefore,
|
||||||
|
materialCountAfter: materialCountBefore,
|
||||||
|
timestamp: attemptStart,
|
||||||
|
durationMs: Date.now() - attemptStart
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
outcome: DeletionOutcome.FailedButtonDisabled,
|
||||||
|
errorCategory: DeletionErrorCategory.UiUnexpected,
|
||||||
|
errorMessage: '删行按钮不可用',
|
||||||
|
attempts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click delete button
|
||||||
|
await deleteRowBtn.click()
|
||||||
|
|
||||||
|
// Check for ERP messages (error / confirm dialog)
|
||||||
|
const msgCheck = await this.checkErpMessages(detailInnerFrame)
|
||||||
|
if (msgCheck.hasConfirmDialog) {
|
||||||
|
await this.handleConfirmDialog(detailInnerFrame)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msgCheck.hasError) {
|
||||||
|
const attempt: MaterialDeletionAttempt = {
|
||||||
|
attempt: attemptNum,
|
||||||
|
outcome: DeletionOutcome.FailedErpError,
|
||||||
|
errorCategory: DeletionErrorCategory.ErpRejection,
|
||||||
|
errorMessage: msgCheck.errorText,
|
||||||
|
rowNumberBefore: currentRowNumber,
|
||||||
|
rowNumberAfter: currentRowNumber,
|
||||||
|
materialCountBefore,
|
||||||
|
materialCountAfter: materialCountBefore,
|
||||||
|
timestamp: attemptStart,
|
||||||
|
durationMs: Date.now() - attemptStart
|
||||||
|
}
|
||||||
|
attempts.push(attempt)
|
||||||
|
// No retry for erp_rejection
|
||||||
|
return {
|
||||||
|
outcome: DeletionOutcome.FailedErpError,
|
||||||
|
errorCategory: DeletionErrorCategory.ErpRejection,
|
||||||
|
errorMessage: msgCheck.errorText,
|
||||||
|
attempts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait and poll for verification signals
|
||||||
|
const verificationStart = Date.now()
|
||||||
|
const timeout = CleanerService.VERIFICATION_TIMEOUT_MS
|
||||||
|
let rowNumberAfter = currentRowNumber
|
||||||
|
let materialCountAfter: number | null = materialCountBefore
|
||||||
|
|
||||||
|
while (Date.now() - verificationStart < timeout) {
|
||||||
|
rowNumberAfter = await this.getInputValue(childForm, /^行号$/)
|
||||||
|
materialCountAfter = await this.readMaterialCount(detailInnerFrame)
|
||||||
|
|
||||||
|
const rowChanged = rowNumberAfter !== currentRowNumber
|
||||||
|
const countDecreased =
|
||||||
|
materialCountAfter !== null ? materialCountAfter < materialCountBefore : null
|
||||||
|
|
||||||
|
if (rowChanged || countDecreased === true) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.delay(300)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final read if we didn't update in the loop
|
||||||
|
if (rowNumberAfter === currentRowNumber && materialCountAfter === materialCountBefore) {
|
||||||
|
rowNumberAfter = await this.getInputValue(childForm, /^行号$/)
|
||||||
|
materialCountAfter = await this.readMaterialCount(detailInnerFrame)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowChanged = rowNumberAfter !== currentRowNumber
|
||||||
|
const countDecreased =
|
||||||
|
materialCountAfter !== null ? materialCountAfter < materialCountBefore : null
|
||||||
|
|
||||||
|
const evaluation = this.evaluateDeletionSignals({
|
||||||
|
rowChanged,
|
||||||
|
countDecreased,
|
||||||
|
hasError: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const attempt: MaterialDeletionAttempt = {
|
||||||
|
attempt: attemptNum,
|
||||||
|
outcome: evaluation.outcome,
|
||||||
|
errorCategory: evaluation.errorCategory,
|
||||||
|
errorMessage: evaluation.errorMessage,
|
||||||
|
rowNumberBefore: currentRowNumber,
|
||||||
|
rowNumberAfter,
|
||||||
|
materialCountBefore,
|
||||||
|
materialCountAfter: materialCountAfter ?? materialCountBefore,
|
||||||
|
timestamp: attemptStart,
|
||||||
|
durationMs: Date.now() - attemptStart
|
||||||
|
}
|
||||||
|
attempts.push(attempt)
|
||||||
|
|
||||||
|
if (
|
||||||
|
evaluation.outcome === DeletionOutcome.Success ||
|
||||||
|
evaluation.outcome === DeletionOutcome.Uncertain
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
outcome: evaluation.outcome,
|
||||||
|
errorCategory: evaluation.errorCategory,
|
||||||
|
errorMessage: evaluation.errorMessage,
|
||||||
|
attempts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retryable failure — wait before next attempt
|
||||||
|
if (attemptNum < maxAttempts) {
|
||||||
|
log.info('[物料删除重试] 等待后重试', {
|
||||||
|
materialCode,
|
||||||
|
materialName,
|
||||||
|
attempt: attemptNum,
|
||||||
|
nextAttempt: attemptNum + 1,
|
||||||
|
delayMs: attemptDelayMs
|
||||||
|
})
|
||||||
|
await this.delay(attemptDelayMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// All attempts exhausted
|
||||||
|
return {
|
||||||
|
outcome: DeletionOutcome.FailedNoChange,
|
||||||
|
errorCategory: DeletionErrorCategory.VerificationTimeout,
|
||||||
|
errorMessage: `${maxAttempts} 次尝试后仍无法确认删除`,
|
||||||
|
attempts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async waitForRowChange(
|
private async waitForRowChange(
|
||||||
childForm: FrameLocator | Locator,
|
childForm: FrameLocator | Locator,
|
||||||
oldRowNumber: string,
|
oldRowNumber: string,
|
||||||
@@ -1242,7 +1783,8 @@ export class CleanerService {
|
|||||||
dryRun,
|
dryRun,
|
||||||
expectedOrderNumber: orderNumber,
|
expectedOrderNumber: orderNumber,
|
||||||
progressState: {
|
progressState: {
|
||||||
completedOrders: detailIndex,
|
ordersStarted: detailIndex,
|
||||||
|
ordersCompleted: 0,
|
||||||
totalOrders: failedDetails.length
|
totalOrders: failedDetails.length
|
||||||
},
|
},
|
||||||
onProgress: (message, progress, extra) => {
|
onProgress: (message, progress, extra) => {
|
||||||
|
|||||||
@@ -82,7 +82,14 @@ export class ErpAuthService {
|
|||||||
|
|
||||||
if (!contentFrame) {
|
if (!contentFrame) {
|
||||||
log.error('Failed to access forwardFrame content frame', {
|
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')
|
throw new Error('Failed to access forwardFrame content frame')
|
||||||
}
|
}
|
||||||
@@ -96,7 +103,14 @@ export class ErpAuthService {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.error('Failed to find username input', {
|
log.error('Failed to find username input', {
|
||||||
error: e instanceof Error ? e.message : String(e),
|
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}`)
|
throw new Error(`Failed to find username input: ${e}`)
|
||||||
}
|
}
|
||||||
@@ -107,7 +121,14 @@ export class ErpAuthService {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.error('Failed to find password input', {
|
log.error('Failed to find password input', {
|
||||||
error: e instanceof Error ? e.message : String(e),
|
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}`)
|
throw new Error(`Failed to find password input: ${e}`)
|
||||||
}
|
}
|
||||||
@@ -118,7 +139,14 @@ export class ErpAuthService {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.error('Failed to click login button', {
|
log.error('Failed to click login button', {
|
||||||
error: e instanceof Error ? e.message : String(e),
|
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}`)
|
throw new Error(`Failed to click login button: ${e}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ export interface ErpErrorContext {
|
|||||||
targetSelector?: string
|
targetSelector?: string
|
||||||
step?: string
|
step?: string
|
||||||
screenshotPath?: 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 page - The Playwright page to inspect
|
||||||
* @param targetSelector - Optional selector that was being targeted
|
* @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(
|
export async function capturePageContext(
|
||||||
page: Page,
|
page: Page,
|
||||||
targetSelector?: string,
|
targetSelector?: string,
|
||||||
step?: string
|
step?: string,
|
||||||
|
orderId?: string,
|
||||||
|
materialCode?: string,
|
||||||
|
stage: string = 'unknown'
|
||||||
): Promise<ErpErrorContext> {
|
): Promise<ErpErrorContext> {
|
||||||
const ctx: ErpErrorContext = {}
|
const ctx: ErpErrorContext = {}
|
||||||
|
|
||||||
@@ -98,6 +108,16 @@ export async function capturePageContext(
|
|||||||
ctx.step = step
|
ctx.step = step
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (orderId) {
|
||||||
|
ctx.orderId = orderId
|
||||||
|
}
|
||||||
|
|
||||||
|
if (materialCode) {
|
||||||
|
ctx.materialCode = materialCode
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.errorStage = stage
|
||||||
|
|
||||||
ctx.screenshotPath = await captureScreenshot(page, step)
|
ctx.screenshotPath = await captureScreenshot(page, step)
|
||||||
|
|
||||||
return ctx
|
return ctx
|
||||||
|
|||||||
@@ -116,7 +116,14 @@ export class ExtractorCore {
|
|||||||
|
|
||||||
if (!fFrame) {
|
if (!fFrame) {
|
||||||
log.error('Failed to access popup forward frame', {
|
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')
|
throw new Error('Failed to access popup forward frame')
|
||||||
}
|
}
|
||||||
@@ -128,7 +135,14 @@ export class ExtractorCore {
|
|||||||
|
|
||||||
if (!workFrame) {
|
if (!workFrame) {
|
||||||
log.error('Failed to access inner work frame', {
|
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')
|
throw new Error('Failed to access inner work frame')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,299 +0,0 @@
|
|||||||
import path from 'path'
|
|
||||||
import fs from 'fs'
|
|
||||||
import { app } from 'electron'
|
|
||||||
import { createLogger } from '../logger'
|
|
||||||
import type { CleanerResult, OrderCleanDetail, SkippedMaterial } from '../../types/cleaner.types'
|
|
||||||
|
|
||||||
const log = createLogger('CleanerReportGenerator')
|
|
||||||
|
|
||||||
export interface ReportOptions {
|
|
||||||
dryRun: boolean
|
|
||||||
username: string
|
|
||||||
startTime: number
|
|
||||||
endTime: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OrderStats {
|
|
||||||
successCount: number
|
|
||||||
failureCount: number
|
|
||||||
successRate: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CleanerReportGenerator {
|
|
||||||
private readonly reportDir: string
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
const logDir = app.isReady() ? app.getPath('logs') : path.join(process.cwd(), 'logs')
|
|
||||||
this.reportDir = path.join(logDir, 'reports')
|
|
||||||
this.ensureReportDir()
|
|
||||||
}
|
|
||||||
|
|
||||||
private ensureReportDir(): void {
|
|
||||||
if (!fs.existsSync(this.reportDir)) {
|
|
||||||
fs.mkdirSync(this.reportDir, { recursive: true })
|
|
||||||
log.info('Created report directory', { path: this.reportDir })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async generateReport(result: CleanerResult, options: ReportOptions): Promise<string> {
|
|
||||||
const filePath = this.getReportFilePath()
|
|
||||||
log.info('Generating cleaner report', { path: filePath })
|
|
||||||
|
|
||||||
const stats = this.calculateOrderStats(result)
|
|
||||||
const content = this.buildReportContent(result, options, stats)
|
|
||||||
|
|
||||||
await fs.promises.writeFile(filePath, content, 'utf-8')
|
|
||||||
log.info('Report generated successfully', { path: filePath })
|
|
||||||
|
|
||||||
return filePath
|
|
||||||
}
|
|
||||||
|
|
||||||
private getReportFilePath(): string {
|
|
||||||
const now = new Date()
|
|
||||||
const timestamp = now.toISOString().replace(/[:.]/g, '-').slice(0, -5).replace('T', '-')
|
|
||||||
const fileName = `cleaner-report-${timestamp}.md`
|
|
||||||
return path.join(this.reportDir, fileName)
|
|
||||||
}
|
|
||||||
|
|
||||||
private calculateOrderStats(result: CleanerResult): OrderStats {
|
|
||||||
const totalOrders = result.details.length
|
|
||||||
const failureCount = result.details.filter((d) => d.errors.length > 0).length
|
|
||||||
const successCount = totalOrders - failureCount
|
|
||||||
const successRate = totalOrders > 0 ? (successCount / totalOrders) * 100 : 0
|
|
||||||
|
|
||||||
return {
|
|
||||||
successCount,
|
|
||||||
failureCount,
|
|
||||||
successRate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildReportContent(
|
|
||||||
result: CleanerResult,
|
|
||||||
options: ReportOptions,
|
|
||||||
stats: OrderStats
|
|
||||||
): string {
|
|
||||||
const lines: string[] = []
|
|
||||||
|
|
||||||
lines.push('# ERP 物料清理执行报告')
|
|
||||||
lines.push('')
|
|
||||||
|
|
||||||
lines.push('## 执行摘要')
|
|
||||||
lines.push('')
|
|
||||||
lines.push('| 项目 | 值 |')
|
|
||||||
lines.push('| -------------- | --------------------------------- |')
|
|
||||||
lines.push(`| **执行时间** | \`${this.formatDateTime(options.endTime)}\``)
|
|
||||||
lines.push(`| **执行模式** | \`${options.dryRun ? '模拟运行 (Dry Run)' : '正式执行'}\``)
|
|
||||||
lines.push(`| **操作用户** | \`${options.username}\``)
|
|
||||||
lines.push(`| **处理订单数** | \`${result.ordersProcessed}\``)
|
|
||||||
lines.push(`| **删除物料数** | \`${result.materialsDeleted}\``)
|
|
||||||
lines.push(`| **跳过物料数** | \`${result.materialsSkipped}\``)
|
|
||||||
lines.push(`| **错误数量** | \`${result.errors.length}\``)
|
|
||||||
if (result.retriedOrders > 0) {
|
|
||||||
lines.push(`| **重试订单数** | \`${result.retriedOrders}\``)
|
|
||||||
lines.push(`| **成功重试数** | \`${result.successfulRetries}\``)
|
|
||||||
}
|
|
||||||
lines.push(`| **执行耗时** | \`${this.formatDuration(options.startTime, options.endTime)}\``)
|
|
||||||
lines.push('')
|
|
||||||
lines.push('---')
|
|
||||||
lines.push('')
|
|
||||||
|
|
||||||
lines.push('## 执行状态')
|
|
||||||
lines.push('')
|
|
||||||
lines.push('| 状态 | 数量 | 百分比 |')
|
|
||||||
lines.push('| ----------- | ---- | ------ |')
|
|
||||||
lines.push(`| ✅ 成功订单 | ${stats.successCount} | ${stats.successRate.toFixed(1)}% |`)
|
|
||||||
lines.push(`| ❌ 失败订单 | ${stats.failureCount} | ${(100 - stats.successRate).toFixed(1)}% |`)
|
|
||||||
if (result.retriedOrders > 0) {
|
|
||||||
const retrySuccessRate =
|
|
||||||
result.retriedOrders > 0 ? (result.successfulRetries / result.retriedOrders) * 100 : 0
|
|
||||||
lines.push(`| 🔄 重试订单 | ${result.retriedOrders} | 100% |`)
|
|
||||||
lines.push(`| ✅ 成功重试 | ${result.successfulRetries} | ${retrySuccessRate.toFixed(1)}% |`)
|
|
||||||
}
|
|
||||||
lines.push('')
|
|
||||||
lines.push('---')
|
|
||||||
lines.push('')
|
|
||||||
|
|
||||||
lines.push('## 订单处理详情')
|
|
||||||
lines.push('')
|
|
||||||
lines.push('| # | 订单号 | 删除数 | 跳过数 | 状态 | 错误信息 |')
|
|
||||||
lines.push('| --- | -------- | ------ | ------ | ------- | ------------------------ |')
|
|
||||||
|
|
||||||
result.details.forEach((detail, index) => {
|
|
||||||
const orderNum = index + 1
|
|
||||||
let status = detail.errors.length > 0 ? '❌ 失败' : '✅ 成功'
|
|
||||||
|
|
||||||
// Override status if retry was successful
|
|
||||||
if (detail.retrySuccess) {
|
|
||||||
status = '✅ 重试成功'
|
|
||||||
} else if (detail.retryCount > 0 && !detail.retrySuccess) {
|
|
||||||
status = '❌ 重试失败'
|
|
||||||
}
|
|
||||||
|
|
||||||
const errorMsg = detail.errors.length > 0 ? detail.errors[0] : '-'
|
|
||||||
const retryInfo = detail.retryCount > 0 ? ` [重试${detail.retryCount}次]` : ''
|
|
||||||
lines.push(
|
|
||||||
`| ${orderNum} | \`${detail.orderNumber}\` | ${detail.materialsDeleted} | ${detail.materialsSkipped} | ${status}${retryInfo} | \`${errorMsg}\` |`
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
lines.push('')
|
|
||||||
lines.push('---')
|
|
||||||
lines.push('')
|
|
||||||
|
|
||||||
const allSkippedMaterials = this.collectAllSkippedMaterials(result.details)
|
|
||||||
if (allSkippedMaterials.length > 0) {
|
|
||||||
lines.push('## 跳过的物料原因说明')
|
|
||||||
lines.push('')
|
|
||||||
lines.push('| 订单号 | 物料代码 | 物料名称 | 行号 | 跳过原因 |')
|
|
||||||
lines.push('| -------- | -------- | -------- | ---- | --------------------------------- |')
|
|
||||||
|
|
||||||
allSkippedMaterials.forEach((skipped) => {
|
|
||||||
lines.push(
|
|
||||||
`| \`${skipped.orderNumber}\` | \`${skipped.materialCode}\` | \`${skipped.materialName}\` | ${skipped.rowNumber} | ${skipped.reason} |`
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
lines.push('')
|
|
||||||
lines.push('---')
|
|
||||||
lines.push('')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.errors.length > 0) {
|
|
||||||
lines.push('## 错误详情')
|
|
||||||
lines.push('')
|
|
||||||
lines.push(`**错误总数**: \`${result.errors.length}\``)
|
|
||||||
lines.push('')
|
|
||||||
lines.push('### 错误订单列表')
|
|
||||||
lines.push('')
|
|
||||||
|
|
||||||
const errorOrders = this.extractErrorOrders(result.details)
|
|
||||||
errorOrders.forEach((order) => {
|
|
||||||
lines.push(`- \`${order}\``)
|
|
||||||
})
|
|
||||||
|
|
||||||
lines.push('')
|
|
||||||
lines.push('### 错误详细信息')
|
|
||||||
lines.push('')
|
|
||||||
|
|
||||||
result.details
|
|
||||||
.filter((d) => d.errors.length > 0)
|
|
||||||
.forEach((detail) => {
|
|
||||||
lines.push(`#### \`${detail.orderNumber}\``)
|
|
||||||
lines.push('')
|
|
||||||
lines.push('```')
|
|
||||||
lines.push(`订单号:${detail.orderNumber}`)
|
|
||||||
detail.errors.forEach((error) => {
|
|
||||||
lines.push(`错误:${error}`)
|
|
||||||
})
|
|
||||||
lines.push('```')
|
|
||||||
lines.push('')
|
|
||||||
})
|
|
||||||
|
|
||||||
lines.push('---')
|
|
||||||
lines.push('')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add retry details section
|
|
||||||
if (result.retriedOrders > 0) {
|
|
||||||
lines.push('## 重试执行详情')
|
|
||||||
lines.push('')
|
|
||||||
lines.push(
|
|
||||||
`**重试订单总数**: \`${result.retriedOrders}\` | **成功**: \`${result.successfulRetries}\` | **失败**: \`${result.retriedOrders - result.successfulRetries}\``
|
|
||||||
)
|
|
||||||
lines.push('')
|
|
||||||
|
|
||||||
const retriedDetails = result.details.filter((d) => d.retryCount > 0)
|
|
||||||
|
|
||||||
if (retriedDetails.length > 0) {
|
|
||||||
lines.push('### 重试订单列表')
|
|
||||||
lines.push('')
|
|
||||||
lines.push('| 订单号 | 重试次数 | 重试结果 | 重试时间 |')
|
|
||||||
lines.push('| -------- | -------- | -------- | ------------ |')
|
|
||||||
|
|
||||||
retriedDetails.forEach((detail) => {
|
|
||||||
const retryStatus = detail.retrySuccess ? '✅ 成功' : '❌ 失败'
|
|
||||||
const retryTime = detail.retriedAt ? this.formatDateTime(detail.retriedAt) : '-'
|
|
||||||
lines.push(
|
|
||||||
`| \`${detail.orderNumber}\` | ${detail.retryCount} | ${retryStatus} | ${retryTime} |`
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
lines.push('')
|
|
||||||
lines.push('### 重试尝试详细记录')
|
|
||||||
lines.push('')
|
|
||||||
|
|
||||||
retriedDetails.forEach((detail) => {
|
|
||||||
lines.push(`#### \`${detail.orderNumber}\``)
|
|
||||||
lines.push('')
|
|
||||||
lines.push(`- **重试次数**: ${detail.retryCount}`)
|
|
||||||
lines.push(`- **最终结果**: ${detail.retrySuccess ? '✅ 成功' : '❌ 失败'}`)
|
|
||||||
|
|
||||||
if (detail.retryAttempts && detail.retryAttempts.length > 0) {
|
|
||||||
lines.push('')
|
|
||||||
lines.push('**重试尝试记录**:')
|
|
||||||
lines.push('')
|
|
||||||
detail.retryAttempts.forEach((attempt, idx) => {
|
|
||||||
lines.push(
|
|
||||||
`${idx + 1}. **第${attempt.attempt}次尝试** - ${this.formatDateTime(attempt.timestamp)}`
|
|
||||||
)
|
|
||||||
lines.push(` - 错误:${attempt.error}`)
|
|
||||||
})
|
|
||||||
lines.push('')
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push('---')
|
|
||||||
lines.push('')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push('')
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push(`**报告生成时间**: \`${this.formatDateTime(options.endTime)}\``)
|
|
||||||
lines.push('**报表版本**: `v1.0`')
|
|
||||||
|
|
||||||
return lines.join('\n')
|
|
||||||
}
|
|
||||||
|
|
||||||
private collectAllSkippedMaterials(
|
|
||||||
details: OrderCleanDetail[]
|
|
||||||
): Array<SkippedMaterial & { orderNumber: string }> {
|
|
||||||
const result: Array<SkippedMaterial & { orderNumber: string }> = []
|
|
||||||
|
|
||||||
details.forEach((detail) => {
|
|
||||||
if (detail.skippedMaterials && detail.skippedMaterials.length > 0) {
|
|
||||||
detail.skippedMaterials.forEach((skipped) => {
|
|
||||||
result.push({
|
|
||||||
...skipped,
|
|
||||||
orderNumber: detail.orderNumber
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
private extractErrorOrders(details: OrderCleanDetail[]): string[] {
|
|
||||||
return details.filter((d) => d.errors.length > 0).map((d) => d.orderNumber)
|
|
||||||
}
|
|
||||||
|
|
||||||
private formatDateTime(timestamp: number): string {
|
|
||||||
const date = new Date(timestamp)
|
|
||||||
const year = date.getFullYear()
|
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
|
||||||
const day = String(date.getDate()).padStart(2, '0')
|
|
||||||
const hours = String(date.getHours()).padStart(2, '0')
|
|
||||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
|
||||||
const seconds = String(date.getSeconds()).padStart(2, '0')
|
|
||||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
|
||||||
}
|
|
||||||
|
|
||||||
private formatDuration(startTime: number, endTime: number): string {
|
|
||||||
const durationMs = endTime - startTime
|
|
||||||
const minutes = Math.floor(durationMs / 60000)
|
|
||||||
const seconds = Math.floor((durationMs % 60000) / 1000)
|
|
||||||
return `${minutes}分${seconds}秒`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
/**
|
||||||
|
* Migration Script: Add ProductionId column to CleanerOrderHistory table
|
||||||
|
*
|
||||||
|
* This script adds the ProductionId column to the ERPAuto.CleanerOrderHistory
|
||||||
|
* table for tracking the original production ID (总排号) input.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* npx tsx src/main/services/user/migration/add-productionid-to-cleaner-order.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ConfigManager } from '../../config/config-manager'
|
||||||
|
import { MySqlService } from '../../database/mysql'
|
||||||
|
import { SqlServerService } from '../../database/sql-server'
|
||||||
|
import { PostgreSqlService } from '../../database/postgresql'
|
||||||
|
import { createLogger } from '../../logger'
|
||||||
|
|
||||||
|
const log = createLogger('Migration')
|
||||||
|
|
||||||
|
async function runMySQLMigration(): Promise<void> {
|
||||||
|
const configManager = ConfigManager.getInstance()
|
||||||
|
await configManager.initialize()
|
||||||
|
const dbConfig = configManager.getConfig().database.mysql
|
||||||
|
|
||||||
|
const service = new MySqlService({
|
||||||
|
host: dbConfig.host,
|
||||||
|
port: dbConfig.port,
|
||||||
|
user: dbConfig.username,
|
||||||
|
password: dbConfig.password,
|
||||||
|
database: dbConfig.database
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await service.connect()
|
||||||
|
console.log('Connected to MySQL')
|
||||||
|
|
||||||
|
// Check if column already exists
|
||||||
|
const check = await service.query(
|
||||||
|
`SELECT COUNT(*) as cnt FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'CleanerOrderHistory' AND COLUMN_NAME = 'ProductionId'`
|
||||||
|
)
|
||||||
|
if ((check.rows[0]?.cnt as number) > 0) {
|
||||||
|
console.log('Column ProductionId already exists, skipping.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runSqlServerMigration(): Promise<void> {
|
||||||
|
const configManager = ConfigManager.getInstance()
|
||||||
|
await configManager.initialize()
|
||||||
|
const dbConfig = configManager.getConfig().database.sqlserver
|
||||||
|
|
||||||
|
const service = new SqlServerService({
|
||||||
|
server: dbConfig.server,
|
||||||
|
port: dbConfig.port,
|
||||||
|
user: dbConfig.username,
|
||||||
|
password: dbConfig.password,
|
||||||
|
database: dbConfig.database,
|
||||||
|
options: { trustServerCertificate: dbConfig.trustServerCertificate }
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await service.connect()
|
||||||
|
console.log('Connected to SQL Server')
|
||||||
|
|
||||||
|
// Check if column already exists
|
||||||
|
const check = await service.query(
|
||||||
|
`SELECT COUNT(*) as cnt FROM sys.columns WHERE OBJECT_ID = OBJECT_ID('ERPAuto.CleanerOrderHistory') AND name = 'ProductionId'`
|
||||||
|
)
|
||||||
|
if ((check.rows[0]?.cnt as number) > 0) {
|
||||||
|
console.log('Column ProductionId already exists, skipping.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await service.query(
|
||||||
|
`ALTER TABLE [ERPAuto].[CleanerOrderHistory] ADD ProductionId NVARCHAR(50) NULL`
|
||||||
|
)
|
||||||
|
console.log('Added ProductionId column to CleanerOrderHistory.')
|
||||||
|
} finally {
|
||||||
|
if (service.isConnected()) await service.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runPostgreSqlMigration(): Promise<void> {
|
||||||
|
const configManager = ConfigManager.getInstance()
|
||||||
|
await configManager.initialize()
|
||||||
|
const dbConfig = configManager.getConfig().database.postgresql
|
||||||
|
|
||||||
|
const service = new PostgreSqlService({
|
||||||
|
host: dbConfig.host,
|
||||||
|
port: dbConfig.port,
|
||||||
|
user: dbConfig.username,
|
||||||
|
password: dbConfig.password,
|
||||||
|
database: dbConfig.database
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await service.connect()
|
||||||
|
console.log('Connected to PostgreSQL')
|
||||||
|
|
||||||
|
await service.query(
|
||||||
|
`ALTER TABLE "ERPAuto"."CleanerOrderHistory" ADD COLUMN IF NOT EXISTS "ProductionId" VARCHAR(50) NULL`
|
||||||
|
)
|
||||||
|
console.log('Added ProductionId column to CleanerOrderHistory.')
|
||||||
|
} finally {
|
||||||
|
if (service.isConnected()) await service.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
console.log('Migration: Add ProductionId to CleanerOrderHistory')
|
||||||
|
|
||||||
|
const configManager = ConfigManager.getInstance()
|
||||||
|
await configManager.initialize()
|
||||||
|
const dbType = configManager.getDatabaseType()
|
||||||
|
|
||||||
|
console.log(`Database type: ${dbType}`)
|
||||||
|
|
||||||
|
if (dbType === 'mysql') {
|
||||||
|
await runMySQLMigration()
|
||||||
|
} else if (dbType === 'sqlserver') {
|
||||||
|
await runSqlServerMigration()
|
||||||
|
} else if (dbType === 'postgresql') {
|
||||||
|
await runPostgreSqlMigration()
|
||||||
|
} else {
|
||||||
|
console.error(`Unsupported database type: ${dbType}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Done.')
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('Migration failed:', err)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
136
src/main/types/cleaner-history.types.ts
Normal file
136
src/main/types/cleaner-history.types.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
// src/main/types/cleaner-history.types.ts
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleaner 操作历史类型定义
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 执行级记录 */
|
||||||
|
export interface CleanerExecutionRecord {
|
||||||
|
id?: number
|
||||||
|
batchId: string
|
||||||
|
attemptNumber: number
|
||||||
|
userId: number
|
||||||
|
username: string
|
||||||
|
operationTime: Date
|
||||||
|
endTime: Date | null
|
||||||
|
status: string
|
||||||
|
isDryRun: boolean
|
||||||
|
totalOrders: number
|
||||||
|
ordersProcessed: number
|
||||||
|
totalMaterialsDeleted: number
|
||||||
|
totalMaterialsSkipped: number
|
||||||
|
totalMaterialsFailed: number
|
||||||
|
totalUncertainDeletions: number
|
||||||
|
errorMessage: string | null
|
||||||
|
appVersion: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 订单级记录 */
|
||||||
|
export interface CleanerOrderRecord {
|
||||||
|
id?: number
|
||||||
|
batchId: string
|
||||||
|
attemptNumber: number
|
||||||
|
orderNumber: string
|
||||||
|
productionId: string | null
|
||||||
|
status: string
|
||||||
|
materialsDeleted: number
|
||||||
|
materialsSkipped: number
|
||||||
|
materialsFailed: number
|
||||||
|
uncertainDeletions: number
|
||||||
|
retryCount: number
|
||||||
|
retrySuccess: boolean
|
||||||
|
errorMessage: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 物料级记录 */
|
||||||
|
export interface CleanerMaterialRecord {
|
||||||
|
id?: number
|
||||||
|
batchId: string
|
||||||
|
attemptNumber: number
|
||||||
|
orderNumber: string
|
||||||
|
materialCode: string
|
||||||
|
materialName: string
|
||||||
|
rowNumber: number
|
||||||
|
result: string
|
||||||
|
reason: string | null
|
||||||
|
attemptCount: number
|
||||||
|
finalErrorCategory: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批次统计(前端列表展示用) */
|
||||||
|
export interface CleanerBatchStats {
|
||||||
|
batchId: string
|
||||||
|
userId: number
|
||||||
|
username: string
|
||||||
|
operationTime: string
|
||||||
|
status: string
|
||||||
|
totalAttempts: number
|
||||||
|
totalOrders: number
|
||||||
|
ordersProcessed: number
|
||||||
|
totalMaterialsDeleted: number
|
||||||
|
totalMaterialsFailed: number
|
||||||
|
successCount: number
|
||||||
|
failedCount: number
|
||||||
|
isDryRun: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 插入执行记录的输入 */
|
||||||
|
export interface InsertCleanerExecutionInput {
|
||||||
|
batchId: string
|
||||||
|
attemptNumber: number
|
||||||
|
userId: number
|
||||||
|
username: string
|
||||||
|
isDryRun: boolean
|
||||||
|
totalOrders: number
|
||||||
|
appVersion: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 插入订单记录的输入 */
|
||||||
|
export interface InsertOrderInput {
|
||||||
|
orderNumber: string
|
||||||
|
productionId?: string
|
||||||
|
initialStatus?: string
|
||||||
|
errorMessage?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 插入物料明细的输入 */
|
||||||
|
export interface InsertMaterialDetailInput {
|
||||||
|
orderNumber: string
|
||||||
|
materialCode: string
|
||||||
|
materialName: string
|
||||||
|
rowNumber: number
|
||||||
|
result: string
|
||||||
|
reason: string | null
|
||||||
|
attemptCount: number
|
||||||
|
finalErrorCategory: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查询批次的选项 */
|
||||||
|
export interface GetCleanerBatchesOptions {
|
||||||
|
limit?: number
|
||||||
|
offset?: number
|
||||||
|
usernames?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Search options for full-level history search */
|
||||||
|
export interface SearchCleanerHistoryOptions {
|
||||||
|
query: string
|
||||||
|
usernames?: string[]
|
||||||
|
limit?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A single batch's full nested data for search results */
|
||||||
|
export interface CleanerSearchBatchResult {
|
||||||
|
batch: CleanerBatchStats
|
||||||
|
executions: CleanerExecutionRecord[]
|
||||||
|
orders: Array<{
|
||||||
|
order: CleanerOrderRecord
|
||||||
|
materials: CleanerMaterialRecord[]
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Search response */
|
||||||
|
export interface CleanerHistorySearchResult {
|
||||||
|
batches: CleanerSearchBatchResult[]
|
||||||
|
totalMatches: number
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
export type CleanerPhase = 'login' | 'processing' | 'complete'
|
export type CleanerPhase = 'login' | 'processing' | 'complete' | 'retry'
|
||||||
|
|
||||||
export interface CleanerProgress {
|
export interface CleanerProgress {
|
||||||
message: string
|
message: string
|
||||||
@@ -30,6 +30,11 @@ export interface CleanerResult {
|
|||||||
// Retry statistics
|
// Retry statistics
|
||||||
retriedOrders: number
|
retriedOrders: number
|
||||||
successfulRetries: number
|
successfulRetries: number
|
||||||
|
// Deletion verification statistics
|
||||||
|
materialsFailed: number
|
||||||
|
uncertainDeletions: number
|
||||||
|
// Outer retry: true when outer catch triggered (browser crash / fatal timeout)
|
||||||
|
crashed?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SkippedMaterial {
|
export interface SkippedMaterial {
|
||||||
@@ -39,6 +44,13 @@ export interface SkippedMaterial {
|
|||||||
reason: string
|
reason: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DeletedMaterial {
|
||||||
|
materialCode: string
|
||||||
|
materialName: string
|
||||||
|
rowNumber: number
|
||||||
|
outcome: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface RetryAttempt {
|
export interface RetryAttempt {
|
||||||
attempt: number
|
attempt: number
|
||||||
error: string
|
error: string
|
||||||
@@ -51,11 +63,58 @@ export interface OrderCleanDetail {
|
|||||||
materialsSkipped: number
|
materialsSkipped: number
|
||||||
errors: string[]
|
errors: string[]
|
||||||
skippedMaterials: SkippedMaterial[]
|
skippedMaterials: SkippedMaterial[]
|
||||||
|
deletedMaterials: DeletedMaterial[]
|
||||||
// Retry-related fields
|
// Retry-related fields
|
||||||
retryCount: number
|
retryCount: number
|
||||||
retryAttempts?: RetryAttempt[]
|
retryAttempts?: RetryAttempt[]
|
||||||
retriedAt?: number
|
retriedAt?: number
|
||||||
retrySuccess?: boolean
|
retrySuccess?: boolean
|
||||||
|
// Deletion verification fields
|
||||||
|
materialsFailed: number
|
||||||
|
failedMaterials: FailedMaterial[]
|
||||||
|
uncertainDeletions: number
|
||||||
|
// Missing order flag: true when order not found in ERP query results
|
||||||
|
notFound?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum DeletionOutcome {
|
||||||
|
Success = 'success',
|
||||||
|
FailedErpError = 'failed_erp_error',
|
||||||
|
FailedNoChange = 'failed_no_change',
|
||||||
|
FailedTimeout = 'failed_timeout',
|
||||||
|
FailedButtonDisabled = 'failed_button_disabled',
|
||||||
|
Uncertain = 'uncertain'
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum DeletionErrorCategory {
|
||||||
|
ErpRejection = 'erp_rejection',
|
||||||
|
ErpBusy = 'erp_busy',
|
||||||
|
NetworkLag = 'network_lag',
|
||||||
|
UiUnexpected = 'ui_unexpected',
|
||||||
|
VerificationTimeout = 'verification_timeout',
|
||||||
|
Unknown = 'unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MaterialDeletionAttempt {
|
||||||
|
attempt: number
|
||||||
|
outcome: DeletionOutcome
|
||||||
|
errorCategory?: DeletionErrorCategory
|
||||||
|
errorMessage?: string
|
||||||
|
rowNumberBefore: string
|
||||||
|
rowNumberAfter: string
|
||||||
|
materialCountBefore: number
|
||||||
|
materialCountAfter: number
|
||||||
|
timestamp: number
|
||||||
|
durationMs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FailedMaterial {
|
||||||
|
materialCode: string
|
||||||
|
materialName: string
|
||||||
|
rowNumber: number
|
||||||
|
attempts: MaterialDeletionAttempt[]
|
||||||
|
finalOutcome: DeletionOutcome
|
||||||
|
finalErrorCategory?: DeletionErrorCategory
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -11,6 +11,14 @@ import type {
|
|||||||
ExportResultItem,
|
ExportResultItem,
|
||||||
ExportResultResponse
|
ExportResultResponse
|
||||||
} from './cleaner.types'
|
} from './cleaner.types'
|
||||||
|
import type {
|
||||||
|
CleanerBatchStats,
|
||||||
|
CleanerExecutionRecord,
|
||||||
|
CleanerOrderRecord,
|
||||||
|
CleanerMaterialRecord,
|
||||||
|
SearchCleanerHistoryOptions,
|
||||||
|
CleanerHistorySearchResult
|
||||||
|
} from './cleaner-history.types'
|
||||||
import type { IpcResult } from './ipc.types'
|
import type { IpcResult } from './ipc.types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -113,6 +121,53 @@ export interface CleanerAPI {
|
|||||||
* @returns Unsubscribe function
|
* @returns Unsubscribe function
|
||||||
*/
|
*/
|
||||||
onProgress: (callback: (data: CleanerProgress) => void) => () => void
|
onProgress: (callback: (data: CleanerProgress) => void) => () => void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cleaner operation history batches
|
||||||
|
* @param options - Query options (limit, offset, usernames)
|
||||||
|
*/
|
||||||
|
getHistoryBatches: (options?: {
|
||||||
|
limit?: number
|
||||||
|
offset?: number
|
||||||
|
usernames?: string[]
|
||||||
|
}) => Promise<IpcResult<CleanerBatchStats[]>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get detailed execution and order records for a specific batch
|
||||||
|
* @param batchId - Batch ID
|
||||||
|
*/
|
||||||
|
getHistoryBatchDetails: (batchId: string) => Promise<
|
||||||
|
IpcResult<{
|
||||||
|
executions: CleanerExecutionRecord[]
|
||||||
|
orders: CleanerOrderRecord[]
|
||||||
|
}>
|
||||||
|
>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get material-level details for a specific order in a batch attempt
|
||||||
|
* @param batchId - Batch ID
|
||||||
|
* @param attemptNumber - Attempt number
|
||||||
|
* @param orderNumber - Order number
|
||||||
|
*/
|
||||||
|
getHistoryMaterialDetails: (
|
||||||
|
batchId: string,
|
||||||
|
attemptNumber: number,
|
||||||
|
orderNumber: string
|
||||||
|
) => Promise<IpcResult<CleanerMaterialRecord[]>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a cleaner history batch
|
||||||
|
* @param batchId - Batch ID
|
||||||
|
*/
|
||||||
|
deleteHistoryBatch: (batchId: string) => Promise<IpcResult<{ deleted: boolean }>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search cleaner history records by keyword
|
||||||
|
* @param options - Search options (query, usernames, limit)
|
||||||
|
*/
|
||||||
|
searchHistoryRecords: (
|
||||||
|
options: SearchCleanerHistoryOptions
|
||||||
|
) => Promise<IpcResult<CleanerHistorySearchResult>>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,6 +3,16 @@ import type {
|
|||||||
CleanerProgress,
|
CleanerProgress,
|
||||||
ExportResultItem
|
ExportResultItem
|
||||||
} from '../../main/types/cleaner.types'
|
} from '../../main/types/cleaner.types'
|
||||||
|
import type {
|
||||||
|
CleanerBatchStats,
|
||||||
|
CleanerExecutionRecord,
|
||||||
|
CleanerOrderRecord,
|
||||||
|
CleanerMaterialRecord,
|
||||||
|
GetCleanerBatchesOptions,
|
||||||
|
SearchCleanerHistoryOptions,
|
||||||
|
CleanerHistorySearchResult
|
||||||
|
} from '../../main/types/cleaner-history.types'
|
||||||
|
import type { IpcResult } from '../../main/types/ipc.types'
|
||||||
import { IPC_CHANNELS } from '../../shared/ipc-channels'
|
import { IPC_CHANNELS } from '../../shared/ipc-channels'
|
||||||
import { invokeIpc, ipcRenderer } from '../lib/ipc'
|
import { invokeIpc, ipcRenderer } from '../lib/ipc'
|
||||||
|
|
||||||
@@ -15,5 +25,40 @@ export const cleanerApi = {
|
|||||||
callback(data)
|
callback(data)
|
||||||
ipcRenderer.on(IPC_CHANNELS.CLEANER_PROGRESS, subscription)
|
ipcRenderer.on(IPC_CHANNELS.CLEANER_PROGRESS, subscription)
|
||||||
return () => ipcRenderer.removeListener(IPC_CHANNELS.CLEANER_PROGRESS, subscription)
|
return () => ipcRenderer.removeListener(IPC_CHANNELS.CLEANER_PROGRESS, subscription)
|
||||||
}
|
},
|
||||||
|
|
||||||
|
// Cleaner operation history
|
||||||
|
getHistoryBatches: (
|
||||||
|
options?: GetCleanerBatchesOptions
|
||||||
|
): Promise<IpcResult<CleanerBatchStats[]>> =>
|
||||||
|
invokeIpc(IPC_CHANNELS.CLEANER_HISTORY_GET_BATCHES, options),
|
||||||
|
|
||||||
|
getHistoryBatchDetails: (
|
||||||
|
batchId: string
|
||||||
|
): Promise<
|
||||||
|
IpcResult<{
|
||||||
|
executions: CleanerExecutionRecord[]
|
||||||
|
orders: CleanerOrderRecord[]
|
||||||
|
}>
|
||||||
|
> => invokeIpc(IPC_CHANNELS.CLEANER_HISTORY_GET_BATCH_DETAILS, batchId),
|
||||||
|
|
||||||
|
getHistoryMaterialDetails: (
|
||||||
|
batchId: string,
|
||||||
|
attemptNumber: number,
|
||||||
|
orderNumber: string
|
||||||
|
): Promise<IpcResult<CleanerMaterialRecord[]>> =>
|
||||||
|
invokeIpc(
|
||||||
|
IPC_CHANNELS.CLEANER_HISTORY_GET_MATERIAL_DETAILS,
|
||||||
|
batchId,
|
||||||
|
attemptNumber,
|
||||||
|
orderNumber
|
||||||
|
),
|
||||||
|
|
||||||
|
deleteHistoryBatch: (batchId: string): Promise<IpcResult<{ deleted: boolean }>> =>
|
||||||
|
invokeIpc(IPC_CHANNELS.CLEANER_HISTORY_DELETE_BATCH, batchId),
|
||||||
|
|
||||||
|
searchHistoryRecords: (
|
||||||
|
options: SearchCleanerHistoryOptions
|
||||||
|
): Promise<IpcResult<CleanerHistorySearchResult>> =>
|
||||||
|
invokeIpc(IPC_CHANNELS.CLEANER_HISTORY_SEARCH, options)
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
85
src/preload/index.d.ts
vendored
85
src/preload/index.d.ts
vendored
@@ -190,6 +190,91 @@ export interface OperationHistoryRecord {
|
|||||||
errorMessage: string | null
|
errorMessage: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CleanerHistoryBatchStats {
|
||||||
|
batchId: string
|
||||||
|
userId: number
|
||||||
|
username: string
|
||||||
|
operationTime: string
|
||||||
|
status: string
|
||||||
|
totalAttempts: number
|
||||||
|
totalOrders: number
|
||||||
|
ordersProcessed: number
|
||||||
|
totalMaterialsDeleted: number
|
||||||
|
totalMaterialsFailed: number
|
||||||
|
successCount: number
|
||||||
|
failedCount: number
|
||||||
|
isDryRun: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CleanerHistoryExecutionRecord {
|
||||||
|
id?: number
|
||||||
|
batchId: string
|
||||||
|
attemptNumber: number
|
||||||
|
userId: number
|
||||||
|
username: string
|
||||||
|
operationTime: Date
|
||||||
|
endTime: Date | null
|
||||||
|
status: string
|
||||||
|
isDryRun: boolean
|
||||||
|
totalOrders: number
|
||||||
|
ordersProcessed: number
|
||||||
|
totalMaterialsDeleted: number
|
||||||
|
totalMaterialsSkipped: number
|
||||||
|
totalMaterialsFailed: number
|
||||||
|
totalUncertainDeletions: number
|
||||||
|
errorMessage: string | null
|
||||||
|
appVersion: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CleanerHistoryOrderRecord {
|
||||||
|
id?: number
|
||||||
|
batchId: string
|
||||||
|
attemptNumber: number
|
||||||
|
orderNumber: string
|
||||||
|
status: string
|
||||||
|
materialsDeleted: number
|
||||||
|
materialsSkipped: number
|
||||||
|
materialsFailed: number
|
||||||
|
uncertainDeletions: number
|
||||||
|
retryCount: number
|
||||||
|
retrySuccess: boolean
|
||||||
|
errorMessage: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CleanerHistoryMaterialRecord {
|
||||||
|
id?: number
|
||||||
|
batchId: string
|
||||||
|
attemptNumber: number
|
||||||
|
orderNumber: string
|
||||||
|
materialCode: string
|
||||||
|
materialName: string
|
||||||
|
rowNumber: number
|
||||||
|
result: string
|
||||||
|
reason: string | null
|
||||||
|
attemptCount: number
|
||||||
|
finalErrorCategory: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CleanerHistoryAPI {
|
||||||
|
getHistoryBatches: (options?: {
|
||||||
|
limit?: number
|
||||||
|
offset?: number
|
||||||
|
usernames?: string[]
|
||||||
|
}) => Promise<IpcResult<CleanerHistoryBatchStats[]>>
|
||||||
|
getHistoryBatchDetails: (batchId: string) => Promise<
|
||||||
|
IpcResult<{
|
||||||
|
executions: CleanerHistoryExecutionRecord[]
|
||||||
|
orders: CleanerHistoryOrderRecord[]
|
||||||
|
}>
|
||||||
|
>
|
||||||
|
getHistoryMaterialDetails: (
|
||||||
|
batchId: string,
|
||||||
|
attemptNumber: number,
|
||||||
|
orderNumber: string
|
||||||
|
) => Promise<IpcResult<CleanerHistoryMaterialRecord[]>>
|
||||||
|
deleteHistoryBatch: (batchId: string) => Promise<IpcResult<{ deleted: boolean }>>
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProcessAPI {
|
export interface ProcessAPI {
|
||||||
versions: {
|
versions: {
|
||||||
electron: string
|
electron: string
|
||||||
|
|||||||
1068
src/renderer/src/components/CleanerOperationHistoryModal.tsx
Normal file
1068
src/renderer/src/components/CleanerOperationHistoryModal.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -8,9 +8,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { CheckCircle, XCircle, SkipForward, Package, Loader2 } from 'lucide-react'
|
import { CheckCircle, XCircle, SkipForward, Package, Loader2, AlertTriangle } from 'lucide-react'
|
||||||
import { Modal } from './ui/Modal'
|
import { Modal } from './ui/Modal'
|
||||||
import type { CleanerProgress } from '../hooks/cleaner/types'
|
import type { CleanerProgress } from '../hooks/cleaner/types'
|
||||||
|
import { getExecutionReportState } from './execution-report-state'
|
||||||
|
|
||||||
interface ExecutionReportDialogProps {
|
interface ExecutionReportDialogProps {
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
@@ -27,6 +28,9 @@ interface ExecutionReportDialogProps {
|
|||||||
// Retry-related props
|
// Retry-related props
|
||||||
retriedOrders?: number
|
retriedOrders?: number
|
||||||
successfulRetries?: number
|
successfulRetries?: number
|
||||||
|
// Deletion verification props
|
||||||
|
materialsFailed?: number
|
||||||
|
uncertainDeletions?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ExecutionReportDialog: React.FC<ExecutionReportDialogProps> = ({
|
export const ExecutionReportDialog: React.FC<ExecutionReportDialogProps> = ({
|
||||||
@@ -42,14 +46,23 @@ export const ExecutionReportDialog: React.FC<ExecutionReportDialogProps> = ({
|
|||||||
startTime = null,
|
startTime = null,
|
||||||
triggerRef,
|
triggerRef,
|
||||||
retriedOrders = 0,
|
retriedOrders = 0,
|
||||||
successfulRetries = 0
|
successfulRetries = 0,
|
||||||
|
materialsFailed = 0,
|
||||||
|
uncertainDeletions = 0
|
||||||
}) => {
|
}) => {
|
||||||
const [now, setNow] = React.useState(() => Date.now())
|
const [now, setNow] = React.useState(() => Date.now())
|
||||||
|
|
||||||
const hasErrors = errors.length > 0
|
const hasErrors = errors.length > 0
|
||||||
const hasRetries = retriedOrders > 0
|
const hasRetries = retriedOrders > 0
|
||||||
|
const hasFailedMaterials = materialsFailed > 0 || uncertainDeletions > 0
|
||||||
const showProgress = isExecuting && progress
|
const showProgress = isExecuting && progress
|
||||||
const isProgressing = !!showProgress
|
const isProgressing = !!showProgress
|
||||||
|
const reportState = getExecutionReportState({
|
||||||
|
dryRun,
|
||||||
|
errors,
|
||||||
|
materialsFailed,
|
||||||
|
uncertainDeletions
|
||||||
|
})
|
||||||
|
|
||||||
// Update timer during progress
|
// Update timer during progress
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -117,11 +130,7 @@ export const ExecutionReportDialog: React.FC<ExecutionReportDialogProps> = ({
|
|||||||
title={
|
title={
|
||||||
isProgressing
|
isProgressing
|
||||||
? '正在执行清理...'
|
? '正在执行清理...'
|
||||||
: dryRun
|
: reportState.title
|
||||||
? '预览执行报告'
|
|
||||||
: hasErrors
|
|
||||||
? '执行完成 (有错误)'
|
|
||||||
: '执行完成'
|
|
||||||
}
|
}
|
||||||
size={isProgressing ? 'lg' : 'md'}
|
size={isProgressing ? 'lg' : 'md'}
|
||||||
showCloseButton={!isExecuting}
|
showCloseButton={!isExecuting}
|
||||||
@@ -155,19 +164,17 @@ export const ExecutionReportDialog: React.FC<ExecutionReportDialogProps> = ({
|
|||||||
<div className="flex justify-center mb-4">
|
<div className="flex justify-center mb-4">
|
||||||
{dryRun ? (
|
{dryRun ? (
|
||||||
<Package className="w-12 h-12 text-amber-500" />
|
<Package className="w-12 h-12 text-amber-500" />
|
||||||
) : hasErrors ? (
|
) : reportState.state === 'failure' ? (
|
||||||
<XCircle className="w-12 h-12 text-red-500" />
|
<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" />
|
<CheckCircle className="w-12 h-12 text-green-500" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600">{reportState.summary}</p>
|
||||||
{dryRun
|
|
||||||
? '预览模式 - 未实际删除数据'
|
|
||||||
: hasErrors
|
|
||||||
? '部分操作未能完成,请查看下方错误信息'
|
|
||||||
: '所有操作已成功完成'}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -327,6 +334,36 @@ export const ExecutionReportDialog: React.FC<ExecutionReportDialogProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{hasFailedMaterials && (
|
||||||
|
<>
|
||||||
|
{materialsFailed > 0 && (
|
||||||
|
<div className="bg-gray-50 rounded-lg p-3 flex items-center gap-3 border border-gray-200">
|
||||||
|
<div className="w-9 h-9 rounded-lg bg-red-50 flex items-center justify-center flex-shrink-0">
|
||||||
|
<XCircle size={20} className="text-red-600" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-xs text-gray-600">删除失败</div>
|
||||||
|
<div className="text-xl font-semibold text-red-600">{materialsFailed}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{uncertainDeletions > 0 && (
|
||||||
|
<div className="bg-gray-50 rounded-lg p-3 flex items-center gap-3 border border-gray-200">
|
||||||
|
<div className="w-9 h-9 rounded-lg bg-yellow-50 flex items-center justify-center flex-shrink-0">
|
||||||
|
<AlertTriangle size={20} className="text-yellow-600" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-xs text-gray-600">不确定删除</div>
|
||||||
|
<div className="text-xl font-semibold text-yellow-600">
|
||||||
|
{uncertainDeletions}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{elapsedTime && (
|
{elapsedTime && (
|
||||||
@@ -369,19 +406,33 @@ export const ExecutionReportDialog: React.FC<ExecutionReportDialogProps> = ({
|
|||||||
</div>
|
</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">
|
<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" />
|
<CheckCircle size={16} className="flex-shrink-0" />
|
||||||
<span>操作已成功完成,数据已同步到 ERP 系统</span>
|
<span>操作已成功完成,数据已同步到 ERP 系统</span>
|
||||||
</div>
|
</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">
|
<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" />
|
<Package size={16} className="flex-shrink-0" />
|
||||||
<span>预览模式结束,数据未实际修改。确认无误后可正式执行。</span>
|
<span>预览模式结束,数据未实际修改。确认无误后可正式执行。</span>
|
||||||
</div>
|
</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>
|
</Modal>
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect, useCallback } from 'react'
|
import React, { useState, useEffect, useCallback } from 'react'
|
||||||
import { Modal } from './ui/Modal'
|
import { Modal } from './ui/Modal'
|
||||||
|
import { ConfirmDialog } from './ui/ConfirmDialog'
|
||||||
|
import { useConfirmDialog } from './ui/useConfirmDialog'
|
||||||
import { useLogger } from '../hooks/useLogger'
|
import { useLogger } from '../hooks/useLogger'
|
||||||
import {
|
import {
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
@@ -83,6 +85,7 @@ export const ExtractorOperationHistoryModal: React.FC<ExtractorOperationHistoryM
|
|||||||
const [deleting, setDeleting] = useState<Set<string>>(new Set())
|
const [deleting, setDeleting] = useState<Set<string>>(new Set())
|
||||||
const [allUsers, setAllUsers] = useState<string[]>([])
|
const [allUsers, setAllUsers] = useState<string[]>([])
|
||||||
const [selectedUsers, setSelectedUsers] = useState<string[]>([])
|
const [selectedUsers, setSelectedUsers] = useState<string[]>([])
|
||||||
|
const { confirm, dialog: confirmDialog } = useConfirmDialog()
|
||||||
const logger = useLogger('OperationHistory')
|
const logger = useLogger('OperationHistory')
|
||||||
|
|
||||||
const isAdmin = user?.userType === 'Admin'
|
const isAdmin = user?.userType === 'Admin'
|
||||||
@@ -172,7 +175,24 @@ export const ExtractorOperationHistoryModal: React.FC<ExtractorOperationHistoryM
|
|||||||
const handleDeleteBatch = async (batchId: string) => {
|
const handleDeleteBatch = async (batchId: string) => {
|
||||||
if (deleting.has(batchId)) return
|
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
|
if (!confirmed) return
|
||||||
|
|
||||||
setDeleting((prev) => new Set(prev).add(batchId))
|
setDeleting((prev) => new Set(prev).add(batchId))
|
||||||
@@ -503,6 +523,7 @@ export const ExtractorOperationHistoryModal: React.FC<ExtractorOperationHistoryM
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{confirmDialog && <ConfirmDialog {...confirmDialog} />}
|
||||||
</Modal>
|
</Modal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,30 @@
|
|||||||
/**
|
/**
|
||||||
* Material Type Management Dialog
|
* Material Type Management Dialog (Modern UI Refactor)
|
||||||
*
|
*
|
||||||
* Provides a dialog for managing material type keywords used to identify
|
* Provides a dialog for managing material type keywords used to identify
|
||||||
* materials for deletion. Admin users can see all records and filter by manager.
|
* materials for deletion. Admin users can see all records and filter by manager.
|
||||||
* Regular users can only see and edit their own records.
|
* Regular users can only see and edit their own records.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
import React, { memo, useState, useEffect, useCallback, useRef, useMemo } from 'react'
|
||||||
import { Plus, Trash2, Save, RotateCcw, Users } from 'lucide-react'
|
import {
|
||||||
import { Modal } from './ui/Modal'
|
X,
|
||||||
|
Plus,
|
||||||
|
RotateCcw,
|
||||||
|
Search,
|
||||||
|
Trash2,
|
||||||
|
User,
|
||||||
|
CloudUpload,
|
||||||
|
CheckCircle2,
|
||||||
|
AlertCircle,
|
||||||
|
Users
|
||||||
|
} from 'lucide-react'
|
||||||
import { showSuccess, showError, showInfo } from '../stores/useAppStore'
|
import { showSuccess, showError, showInfo } from '../stores/useAppStore'
|
||||||
import { ConfirmDialog } from './ui/ConfirmDialog'
|
import { ConfirmDialog } from './ui/ConfirmDialog'
|
||||||
import { useConfirmDialog } from './ui/useConfirmDialog'
|
import { useConfirmDialog } from './ui/useConfirmDialog'
|
||||||
import { useLogger } from '../hooks/useLogger'
|
import { useLogger } from '../hooks/useLogger'
|
||||||
|
|
||||||
|
// --- Interfaces ---
|
||||||
interface MaterialTypeRecord {
|
interface MaterialTypeRecord {
|
||||||
id?: number
|
id?: number
|
||||||
materialName: string
|
materialName: string
|
||||||
@@ -21,6 +32,7 @@ interface MaterialTypeRecord {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface RowState {
|
interface RowState {
|
||||||
|
localId: string // Stable ID for React mapping during edits/filters
|
||||||
record: MaterialTypeRecord
|
record: MaterialTypeRecord
|
||||||
state: 'original' | 'new' | 'modified' | 'deleted'
|
state: 'original' | 'new' | 'modified' | 'deleted'
|
||||||
originalRecord?: MaterialTypeRecord
|
originalRecord?: MaterialTypeRecord
|
||||||
@@ -34,40 +46,179 @@ interface MaterialTypeManagementDialogProps {
|
|||||||
triggerRef?: React.RefObject<HTMLButtonElement | null>
|
triggerRef?: React.RefObject<HTMLButtonElement | null>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stable ID generator for React keys
|
||||||
|
const generateId = () => Math.random().toString(36).substring(2, 9) + Date.now().toString(36);
|
||||||
|
|
||||||
|
// --- KeywordCard Component (Handles individual items) ---
|
||||||
|
interface KeywordCardProps {
|
||||||
|
item: RowState
|
||||||
|
isAdmin: boolean
|
||||||
|
managers: string[]
|
||||||
|
onUpdate: (localId: string, newRecord: Partial<MaterialTypeRecord>) => void
|
||||||
|
onDelete: (localId: string) => void
|
||||||
|
onRestore: (localId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const KeywordCard = memo(function KeywordCard({ item, isAdmin, managers, onUpdate, onDelete, onRestore }: KeywordCardProps) {
|
||||||
|
const [isEditing, setIsEditing] = useState(item.state === 'new');
|
||||||
|
const [text, setText] = useState(item.record.materialName);
|
||||||
|
const [manager, setManager] = useState(item.record.managerName);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEditing && inputRef.current) {
|
||||||
|
inputRef.current.focus();
|
||||||
|
if (item.state !== 'new') {
|
||||||
|
inputRef.current.select();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isEditing, item.state]);
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
const trimmedText = text.trim();
|
||||||
|
if (!trimmedText) {
|
||||||
|
onDelete(item.localId); // Delete if empty
|
||||||
|
} else {
|
||||||
|
setIsEditing(false);
|
||||||
|
if (trimmedText !== item.record.materialName || manager !== item.record.managerName) {
|
||||||
|
onUpdate(item.localId, { materialName: trimmedText, managerName: manager });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter') handleSave();
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setText(item.record.materialName);
|
||||||
|
setManager(item.record.managerName);
|
||||||
|
setIsEditing(false);
|
||||||
|
if (item.state === 'new') onDelete(item.localId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Deleted State View
|
||||||
|
if (item.state === 'deleted') {
|
||||||
|
return (
|
||||||
|
<div className="group relative flex items-center h-10 px-3 bg-red-50/50 border border-red-200 rounded-lg transition-all">
|
||||||
|
<span className="flex-1 truncate text-sm text-red-700/60 font-medium line-through mr-6">
|
||||||
|
{item.record.materialName}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onRestore(item.localId); }}
|
||||||
|
className="absolute right-2 p-1.5 text-red-400 hover:text-red-600 hover:bg-red-100 rounded-md transition-all"
|
||||||
|
title="撤销删除"
|
||||||
|
>
|
||||||
|
<RotateCcw size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Editing State View
|
||||||
|
if (isEditing) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative flex items-center h-10 px-3 bg-indigo-50 border border-indigo-400 rounded-lg shadow-sm ring-2 ring-indigo-100 transition-all"
|
||||||
|
onBlur={(e) => {
|
||||||
|
// Delay save to allow dropdown clicks. Checks if new focus is outside this container.
|
||||||
|
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
|
||||||
|
handleSave();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="w-1.5 h-1.5 rounded-full bg-indigo-500 mr-2 flex-shrink-0"></div>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="关键词..."
|
||||||
|
className="flex-1 w-full min-w-[80px] bg-transparent outline-none text-sm font-medium text-indigo-900 placeholder-indigo-300"
|
||||||
|
/>
|
||||||
|
{isAdmin && (
|
||||||
|
<select
|
||||||
|
value={manager}
|
||||||
|
onChange={(e) => setManager(e.target.value)}
|
||||||
|
className="ml-2 bg-white outline-none text-xs text-indigo-700 border border-indigo-200 rounded px-1 py-0.5 focus:ring-2 focus:ring-indigo-200"
|
||||||
|
>
|
||||||
|
<option value="">负责人</option>
|
||||||
|
{managers.map(m => <option key={m} value={m}>{m}</option>)}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal / Modified State View
|
||||||
|
const isModified = item.state === 'modified' || item.state === 'new';
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={() => setIsEditing(true)}
|
||||||
|
className={`group relative flex items-center h-10 px-3 bg-white border rounded-lg cursor-pointer transition-all duration-200 hover:shadow-md hover:border-indigo-300
|
||||||
|
${isModified ? 'border-amber-300 bg-amber-50/30' : 'border-slate-200'}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className={`w-1.5 h-1.5 rounded-full mr-2 flex-shrink-0 transition-colors
|
||||||
|
${isModified ? 'bg-amber-400' : 'bg-slate-300 group-hover:bg-indigo-400'}
|
||||||
|
`}></div>
|
||||||
|
|
||||||
|
<span className="flex-1 truncate text-sm text-slate-700 font-medium group-hover:text-indigo-700 transition-colors mr-2">
|
||||||
|
{item.record.materialName}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="flex-shrink-0 flex items-center">
|
||||||
|
{isAdmin && (
|
||||||
|
<span className="text-[10px] bg-slate-100 text-slate-500 px-1.5 py-0.5 rounded truncate max-w-[60px] group-hover:bg-indigo-50 group-hover:text-indigo-500 group-hover:-translate-x-2 transition-all duration-300 ease-in-out">
|
||||||
|
{item.record.managerName || '未指定'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onDelete(item.localId); }}
|
||||||
|
className="p-1.5 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-md max-w-0 overflow-hidden opacity-0 group-hover:max-w-8 group-hover:opacity-100 transition-all duration-200"
|
||||||
|
title="删除"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// --- Main Dialog Component ---
|
||||||
export const MaterialTypeManagementDialog: React.FC<MaterialTypeManagementDialogProps> = ({
|
export const MaterialTypeManagementDialog: React.FC<MaterialTypeManagementDialogProps> = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
currentUsername,
|
currentUsername,
|
||||||
triggerRef
|
// triggerRef // Retained for compatibility, but layout uses custom overlay
|
||||||
}) => {
|
}) => {
|
||||||
const [rows, setRows] = useState<RowState[]>([])
|
const [rows, setRows] = useState<RowState[]>([])
|
||||||
const [managers, setManagers] = useState<string[]>([])
|
const [managers, setManagers] = useState<string[]>([])
|
||||||
const [selectedManagers, setSelectedManagers] = useState<Set<string>>(new Set())
|
const [selectedManagers, setSelectedManagers] = useState<Set<string>>(new Set())
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [editingCell, setEditingCell] = useState<{ rowIndex: number; field: string } | null>(null)
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [editValue, setEditValue] = useState('')
|
|
||||||
const [selectedRowIndex, setSelectedRowIndex] = useState<number | null>(null)
|
|
||||||
const logger = useLogger('MaterialType')
|
const logger = useLogger('MaterialType')
|
||||||
|
|
||||||
const tableRef = useRef<HTMLTableElement>(null)
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
|
||||||
const selectRef = useRef<HTMLSelectElement>(null)
|
|
||||||
|
|
||||||
// Confirmation dialog hook
|
|
||||||
const { confirm, dialog: confirmDialog } = useConfirmDialog()
|
const { confirm, dialog: confirmDialog } = useConfirmDialog()
|
||||||
|
|
||||||
// Calculate pending changes count
|
|
||||||
const pendingCount = rows.filter(
|
const pendingCount = rows.filter(
|
||||||
(r) => r.state === 'new' || r.state === 'modified' || r.state === 'deleted'
|
(r) => r.state === 'new' || r.state === 'modified' || r.state === 'deleted'
|
||||||
).length
|
).length
|
||||||
|
const isSynced = pendingCount === 0;
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
// Load managers list
|
const [managersResult, recordsResult] = await Promise.all([
|
||||||
const managersResult = await window.electron.materialType.getManagers()
|
window.electron.materialType.getManagers(),
|
||||||
|
isAdmin
|
||||||
|
? window.electron.materialType.getAll()
|
||||||
|
: window.electron.materialType.getByManager(currentUsername)
|
||||||
|
])
|
||||||
|
|
||||||
if (managersResult.success && managersResult.data) {
|
if (managersResult.success && managersResult.data) {
|
||||||
setManagers(managersResult.data)
|
setManagers(managersResult.data)
|
||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
@@ -75,28 +226,17 @@ export const MaterialTypeManagementDialog: React.FC<MaterialTypeManagementDialog
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load records
|
|
||||||
let records: MaterialTypeRecord[] = []
|
let records: MaterialTypeRecord[] = []
|
||||||
if (isAdmin) {
|
if (recordsResult.success && recordsResult.data) records = recordsResult.data
|
||||||
const result = await window.electron.materialType.getAll()
|
|
||||||
if (result.success && result.data) {
|
|
||||||
records = result.data
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const result = await window.electron.materialType.getByManager(currentUsername)
|
|
||||||
if (result.success && result.data) {
|
|
||||||
records = result.data
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setRows(
|
setRows(
|
||||||
records.map((record) => ({
|
records.map((record) => ({
|
||||||
|
localId: generateId(),
|
||||||
record,
|
record,
|
||||||
state: 'original' as const,
|
state: 'original' as const,
|
||||||
originalRecord: { ...record }
|
originalRecord: { ...record }
|
||||||
}))
|
}))
|
||||||
)
|
)
|
||||||
setSelectedRowIndex(null)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to load material types', {
|
logger.error('Failed to load material types', {
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error: error instanceof Error ? error.message : String(error),
|
||||||
@@ -108,137 +248,73 @@ export const MaterialTypeManagementDialog: React.FC<MaterialTypeManagementDialog
|
|||||||
}
|
}
|
||||||
}, [currentUsername, isAdmin, logger])
|
}, [currentUsername, isAdmin, logger])
|
||||||
|
|
||||||
// Load data when dialog opens
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) void loadData()
|
||||||
void loadData()
|
|
||||||
}
|
|
||||||
}, [isOpen, loadData])
|
}, [isOpen, loadData])
|
||||||
|
|
||||||
// Focus input when editing starts
|
const filteredRows = useMemo(() => {
|
||||||
useEffect(() => {
|
let result = rows;
|
||||||
const activeElement = inputRef.current ?? selectRef.current
|
if (isAdmin && selectedManagers.size > 0) {
|
||||||
if (editingCell && activeElement) {
|
result = result.filter(row => selectedManagers.has(row.record.managerName) || row.state === 'new');
|
||||||
activeElement.focus()
|
|
||||||
if (activeElement instanceof HTMLInputElement) {
|
|
||||||
activeElement.select()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [editingCell])
|
if (searchQuery) {
|
||||||
|
const lowerQuery = searchQuery.toLowerCase();
|
||||||
|
result = result.filter(row => row.record.materialName.toLowerCase().includes(lowerQuery));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [rows, isAdmin, selectedManagers, searchQuery])
|
||||||
|
|
||||||
// Filter rows by selected managers (admin only)
|
|
||||||
const filteredRows = React.useMemo(() => {
|
|
||||||
if (!isAdmin) return rows
|
|
||||||
if (selectedManagers.size === 0) return rows
|
|
||||||
return rows.filter((row) => selectedManagers.has(row.record.managerName) || row.state === 'new')
|
|
||||||
}, [rows, isAdmin, selectedManagers])
|
|
||||||
|
|
||||||
// Insert new row
|
// --- Row Actions ---
|
||||||
const insertNewRow = useCallback(() => {
|
const handleAdd = useCallback(() => {
|
||||||
const newRow: RowState = {
|
const newRow: RowState = {
|
||||||
record: {
|
localId: generateId(),
|
||||||
materialName: '',
|
record: { materialName: '', managerName: isAdmin ? '' : currentUsername },
|
||||||
managerName: isAdmin ? '' : currentUsername
|
|
||||||
},
|
|
||||||
state: 'new'
|
state: 'new'
|
||||||
}
|
}
|
||||||
|
setRows(prev => [newRow, ...prev])
|
||||||
// 在 setRows 回调中计算索引并设置编辑状态
|
setSearchQuery('')
|
||||||
setRows((prev) => {
|
|
||||||
const newIndex = prev.length
|
|
||||||
setTimeout(() => {
|
|
||||||
setEditingCell({ rowIndex: newIndex, field: 'materialName' })
|
|
||||||
setEditValue('')
|
|
||||||
}, 0)
|
|
||||||
return [...prev, newRow]
|
|
||||||
})
|
|
||||||
}, [isAdmin, currentUsername])
|
}, [isAdmin, currentUsername])
|
||||||
|
|
||||||
// Delete row
|
const handleUpdate = useCallback((localId: string, updates: Partial<MaterialTypeRecord>) => {
|
||||||
const deleteRow = useCallback((index: number) => {
|
setRows(prev => prev.map(row => {
|
||||||
setRows((prev) => {
|
if (row.localId !== localId) return row;
|
||||||
const newRows = [...prev]
|
return {
|
||||||
const row = newRows[index]
|
|
||||||
if (row.state === 'new') {
|
|
||||||
// Remove new rows directly
|
|
||||||
newRows.splice(index, 1)
|
|
||||||
} else {
|
|
||||||
// Mark existing rows as deleted
|
|
||||||
newRows[index] = { ...row, state: 'deleted' }
|
|
||||||
}
|
|
||||||
return newRows
|
|
||||||
})
|
|
||||||
setSelectedRowIndex(null)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Start editing a cell
|
|
||||||
const startEdit = useCallback(
|
|
||||||
(rowIndex: number, field: string) => {
|
|
||||||
const row = rows[rowIndex]
|
|
||||||
if (row.state === 'deleted') return
|
|
||||||
|
|
||||||
setEditingCell({ rowIndex, field })
|
|
||||||
setEditValue(row.record[field as keyof MaterialTypeRecord] as string)
|
|
||||||
},
|
|
||||||
[rows]
|
|
||||||
)
|
|
||||||
|
|
||||||
// Save edit
|
|
||||||
const saveEdit = useCallback(() => {
|
|
||||||
if (!editingCell) return
|
|
||||||
|
|
||||||
const { rowIndex, field } = editingCell
|
|
||||||
setRows((prev) => {
|
|
||||||
const newRows = [...prev]
|
|
||||||
const row = newRows[rowIndex]
|
|
||||||
const newValue = editValue.trim()
|
|
||||||
|
|
||||||
// Update the record
|
|
||||||
newRows[rowIndex] = {
|
|
||||||
...row,
|
...row,
|
||||||
record: {
|
record: { ...row.record, ...updates },
|
||||||
...row.record,
|
|
||||||
[field]: newValue
|
|
||||||
},
|
|
||||||
state: row.state === 'new' ? 'new' : 'modified'
|
state: row.state === 'new' ? 'new' : 'modified'
|
||||||
}
|
}
|
||||||
return newRows
|
}))
|
||||||
})
|
|
||||||
|
|
||||||
setEditingCell(null)
|
|
||||||
setEditValue('')
|
|
||||||
}, [editingCell, editValue])
|
|
||||||
|
|
||||||
// Cancel edit
|
|
||||||
const cancelEdit = useCallback(() => {
|
|
||||||
setEditingCell(null)
|
|
||||||
setEditValue('')
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Handle keyboard events
|
const handleDelete = useCallback((localId: string) => {
|
||||||
const handleKeyDown = useCallback(
|
setRows(prev => {
|
||||||
(event: React.KeyboardEvent) => {
|
const row = prev.find(r => r.localId === localId);
|
||||||
if (editingCell) {
|
if (!row) return prev;
|
||||||
if (event.key === 'Enter') {
|
if (row.state === 'new') return prev.filter(r => r.localId !== localId);
|
||||||
saveEdit()
|
return prev.map(r => r.localId === localId ? { ...r, state: 'deleted' } : r);
|
||||||
} else if (event.key === 'Escape') {
|
})
|
||||||
cancelEdit()
|
}, [])
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.key === 'Insert') {
|
const handleRestore = useCallback((localId: string) => {
|
||||||
event.preventDefault()
|
setRows(prev => prev.map(r => {
|
||||||
insertNewRow()
|
if (r.localId !== localId) return r;
|
||||||
} else if (event.key === 'Delete' && selectedRowIndex !== null) {
|
const wasModified = r.originalRecord?.materialName !== r.record.materialName ||
|
||||||
event.preventDefault()
|
r.originalRecord?.managerName !== r.record.managerName;
|
||||||
deleteRow(selectedRowIndex)
|
return { ...r, state: wasModified ? 'modified' : 'original' }
|
||||||
}
|
}))
|
||||||
},
|
}, [])
|
||||||
[editingCell, selectedRowIndex, insertNewRow, deleteRow, saveEdit, cancelEdit]
|
|
||||||
)
|
const handleReset = async () => {
|
||||||
|
if (pendingCount === 0) return
|
||||||
|
const confirmed = await confirm({
|
||||||
|
title: '确认重置',
|
||||||
|
message: '确定要放弃所有未保存的更改吗?',
|
||||||
|
variant: 'warning'
|
||||||
|
})
|
||||||
|
if (confirmed) void loadData()
|
||||||
|
}
|
||||||
|
|
||||||
// Save all changes
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
const toInsert: MaterialTypeRecord[] = []
|
const toInsert: MaterialTypeRecord[] = []
|
||||||
const toUpdate: { old: MaterialTypeRecord; new: MaterialTypeRecord }[] = []
|
const toUpdate: { old: MaterialTypeRecord; new: MaterialTypeRecord }[] = []
|
||||||
@@ -273,19 +349,11 @@ export const MaterialTypeManagementDialog: React.FC<MaterialTypeManagementDialog
|
|||||||
|
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
const result = await window.electron.materialType.upsertBatch({
|
const result = await window.electron.materialType.upsertBatch({ toInsert, toUpdate, toDelete })
|
||||||
toInsert,
|
const payload = result.success ? (result.data as { stats?: { success?: number; failed?: number } } | undefined) : undefined
|
||||||
toUpdate,
|
|
||||||
toDelete
|
|
||||||
})
|
|
||||||
const payload = result.success
|
|
||||||
? (result.data as { stats?: { success?: number; failed?: number } } | undefined)
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
showSuccess(
|
showSuccess(`保存完成!\n成功:${payload?.stats?.success || 0} 条\n失败:${payload?.stats?.failed || 0} 条`)
|
||||||
`保存完成!\n成功:${payload?.stats?.success || 0} 条\n失败:${payload?.stats?.failed || 0} 条`
|
|
||||||
)
|
|
||||||
await loadData()
|
await loadData()
|
||||||
} else {
|
} else {
|
||||||
showError(`保存失败:${result.error || '未知错误'}`)
|
showError(`保存失败:${result.error || '未知错误'}`)
|
||||||
@@ -294,29 +362,13 @@ export const MaterialTypeManagementDialog: React.FC<MaterialTypeManagementDialog
|
|||||||
showError(`保存失败:${error instanceof Error ? error.message : '未知错误'}`)
|
showError(`保存失败:${error instanceof Error ? error.message : '未知错误'}`)
|
||||||
logger.error('Failed to save material types', {
|
logger.error('Failed to save material types', {
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error: error instanceof Error ? error.message : String(error),
|
||||||
inserts: toInsert.length,
|
inserts: toInsert.length, updates: toUpdate.length, deletes: toDelete.length
|
||||||
updates: toUpdate.length,
|
|
||||||
deletes: toDelete.length
|
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset changes
|
|
||||||
const handleReset = async () => {
|
|
||||||
if (pendingCount === 0) return
|
|
||||||
const confirmed = await confirm({
|
|
||||||
title: '确认重置',
|
|
||||||
message: '确定要放弃所有未保存的更改吗?',
|
|
||||||
variant: 'warning'
|
|
||||||
})
|
|
||||||
if (confirmed) {
|
|
||||||
void loadData()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle close with unsaved changes warning
|
|
||||||
const handleClose = async () => {
|
const handleClose = async () => {
|
||||||
if (pendingCount > 0) {
|
if (pendingCount > 0) {
|
||||||
const confirmed = await confirm({
|
const confirmed = await confirm({
|
||||||
@@ -329,253 +381,170 @@ export const MaterialTypeManagementDialog: React.FC<MaterialTypeManagementDialog
|
|||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get row background color
|
if (!isOpen) return null;
|
||||||
const getRowStyle = (row: RowState): React.CSSProperties => {
|
|
||||||
if (row.state === 'deleted') {
|
|
||||||
return { backgroundColor: '#fee2e2', textDecoration: 'line-through', opacity: 0.6 }
|
|
||||||
}
|
|
||||||
if (row.state === 'new') {
|
|
||||||
return { backgroundColor: '#dcfce7' }
|
|
||||||
}
|
|
||||||
if (row.state === 'modified') {
|
|
||||||
return { backgroundColor: '#fef9c3' }
|
|
||||||
}
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-slate-900/40 backdrop-blur-sm p-4 md:p-8 font-sans">
|
||||||
isOpen={isOpen}
|
<div className="w-full max-w-5xl bg-white rounded-2xl shadow-xl border border-slate-200/60 overflow-hidden flex flex-col h-[85vh] animate-in fade-in zoom-in-95 duration-200">
|
||||||
onClose={handleClose}
|
|
||||||
title="物料类型管理"
|
{/* --- Header --- */}
|
||||||
size="2xl"
|
<header className="px-6 py-5 border-b border-slate-100 flex justify-between items-start bg-slate-50/50">
|
||||||
triggerRef={triggerRef}
|
<div>
|
||||||
>
|
<div className="flex items-center space-x-2 text-xs font-semibold text-slate-400 tracking-wider mb-1 uppercase">
|
||||||
<div onKeyDown={handleKeyDown}>
|
<span>Material Type Management</span>
|
||||||
{/* Manager filter (admin only) */}
|
</div>
|
||||||
{isAdmin && (
|
<h1 className="text-xl font-bold text-slate-800 tracking-tight">物料类型管理</h1>
|
||||||
<div className="mb-4 p-3 bg-slate-50 rounded-lg border border-slate-200">
|
<p className="text-sm text-slate-500 mt-1">
|
||||||
<div className="flex items-center justify-between mb-2">
|
{isAdmin ? '集中维护和管理所有负责人的物料关键词。' : '管理属于您的物料关键词。'}
|
||||||
<div className="flex items-center gap-2 text-sm font-medium text-slate-700">
|
</p>
|
||||||
<Users size={16} />
|
</div>
|
||||||
按负责人筛选
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-full transition-colors"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* --- Toolbar --- */}
|
||||||
|
<div className="px-6 py-4 border-b border-slate-100 bg-white flex flex-col sm:flex-row justify-between items-center gap-6 z-10">
|
||||||
|
<div className="flex items-center space-x-6 w-full sm:w-auto">
|
||||||
|
<button
|
||||||
|
onClick={handleAdd}
|
||||||
|
className="flex items-center justify-center px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium rounded-lg shadow-sm shadow-indigo-200 transition-all active:scale-95"
|
||||||
|
>
|
||||||
|
<Plus size={16} className="mr-1.5" /> 新增
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleReset}
|
||||||
|
disabled={pendingCount === 0 || loading}
|
||||||
|
className="flex items-center justify-center px-3 py-2 bg-white border border-slate-200 hover:bg-slate-50 text-slate-600 text-sm font-medium rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<RotateCcw size={16} className="mr-1.5 text-slate-400" /> 重置
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="relative hidden sm:block">
|
||||||
|
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<Search size={14} className="text-slate-400" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<input
|
||||||
<button
|
type="text"
|
||||||
onClick={() => setSelectedManagers(new Set(managers))}
|
placeholder="搜索关键词..."
|
||||||
className="text-xs text-blue-600 hover:underline"
|
value={searchQuery}
|
||||||
>
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
全选
|
className="pl-9 pr-4 py-2 bg-slate-50 border border-transparent outline-none focus:border-indigo-200 focus:bg-white rounded-lg text-sm text-slate-700 w-56 transition-all"
|
||||||
</button>
|
/>
|
||||||
<button
|
</div>
|
||||||
onClick={() => setSelectedManagers(new Set())}
|
</div>
|
||||||
className="text-xs text-slate-500 hover:underline"
|
|
||||||
>
|
<div className="flex items-center space-x-6 w-full sm:w-auto justify-end">
|
||||||
取消全选
|
<div className="flex items-center bg-slate-50 rounded-lg p-1 border border-slate-100">
|
||||||
</button>
|
<div className="flex items-center px-4 py-1.5 text-xs text-slate-600 border-r border-slate-200">
|
||||||
|
<span className="text-slate-400 mr-1.5">记录</span>
|
||||||
|
<span className="font-semibold text-slate-800">{filteredRows.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center px-4 py-1.5 text-xs text-slate-600 border-r border-slate-200">
|
||||||
|
<User size={12} className="mr-1.5 text-slate-400" />
|
||||||
|
<span className="truncate max-w-[80px]">{isAdmin ? '管理员视图' : currentUsername}</span>
|
||||||
|
</div>
|
||||||
|
<div className={`flex items-center px-4 py-1.5 text-xs font-medium ${isSynced ? 'text-emerald-600' : 'text-amber-600'}`}>
|
||||||
|
{isSynced ? <><CheckCircle2 size={14} className="mr-1" /> 已同步</> : <><AlertCircle size={14} className="mr-1" /> 待保存 {pendingCount}</>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{managers.map((manager) => (
|
<button
|
||||||
<label
|
onClick={handleSave}
|
||||||
key={manager}
|
disabled={pendingCount === 0 || saving}
|
||||||
className="flex items-center gap-1.5 text-xs text-slate-600 cursor-pointer hover:bg-white px-2 py-1 rounded"
|
className={`flex items-center justify-center px-5 py-2 text-sm font-medium rounded-lg transition-all duration-300
|
||||||
>
|
${pendingCount > 0 ? 'bg-slate-800 hover:bg-slate-900 text-white shadow-md' : 'bg-slate-100 text-slate-400 cursor-not-allowed'}
|
||||||
<input
|
`}
|
||||||
type="checkbox"
|
>
|
||||||
className="rounded text-blue-600"
|
{saving ? <RotateCcw size={16} className="mr-2 animate-spin" /> : <CloudUpload size={16} className="mr-2" />}
|
||||||
checked={selectedManagers.has(manager)}
|
{saving ? '保存中...' : '保存更改'}
|
||||||
onChange={(e) => {
|
</button>
|
||||||
setSelectedManagers((prev) => {
|
</div>
|
||||||
const newSet = new Set(prev)
|
</div>
|
||||||
if (e.target.checked) newSet.add(manager)
|
|
||||||
else newSet.delete(manager)
|
{/* --- Admin Manager Filter --- */}
|
||||||
return newSet
|
{isAdmin && managers.length > 0 && (
|
||||||
})
|
<div className="px-6 py-3 bg-slate-50/80 border-b border-slate-100 flex items-center overflow-x-auto hide-scrollbar">
|
||||||
|
<div className="flex items-center gap-3 text-xs font-medium text-slate-500 mr-8 flex-shrink-0">
|
||||||
|
<Users size={14} /> 负责人筛选
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 flex-nowrap">
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedManagers(new Set(managers))}
|
||||||
|
className={`px-3 py-1 rounded-full text-xs font-medium whitespace-nowrap transition-colors ${
|
||||||
|
selectedManagers.size === managers.length ? 'bg-slate-800 text-white' : 'bg-white border border-slate-200 text-slate-600 hover:bg-slate-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
全选
|
||||||
|
</button>
|
||||||
|
{managers.map((manager) => {
|
||||||
|
const isSelected = selectedManagers.has(manager);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={manager}
|
||||||
|
onClick={() => {
|
||||||
|
const newSet = new Set(selectedManagers);
|
||||||
|
isSelected ? newSet.delete(manager) : newSet.add(manager);
|
||||||
|
setSelectedManagers(newSet);
|
||||||
}}
|
}}
|
||||||
/>
|
className={`px-3 py-1 rounded-full text-xs whitespace-nowrap transition-colors ${
|
||||||
{manager}
|
isSelected ? 'bg-indigo-100 text-indigo-700 border border-indigo-200' : 'bg-white border border-slate-200 text-slate-600 hover:bg-slate-100'
|
||||||
</label>
|
}`}
|
||||||
))}
|
>
|
||||||
|
{manager}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Toolbar */}
|
{/* --- Main Content Grid --- */}
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex-1 overflow-y-auto bg-slate-50/50 p-6 relative">
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={insertNewRow}
|
|
||||||
className="flex items-center gap-1.5 text-xs bg-green-50 border border-green-200 text-green-700 px-3 py-1.5 rounded hover:bg-green-100"
|
|
||||||
>
|
|
||||||
<Plus size={14} /> 新增 (Insert)
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => selectedRowIndex !== null && deleteRow(selectedRowIndex)}
|
|
||||||
disabled={selectedRowIndex === null}
|
|
||||||
className="flex items-center gap-1.5 text-xs bg-red-50 border border-red-200 text-red-700 px-3 py-1.5 rounded hover:bg-red-100 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
<Trash2 size={14} /> 删除 (Delete)
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleReset}
|
|
||||||
disabled={pendingCount === 0}
|
|
||||||
className="flex items-center gap-1.5 text-xs bg-slate-50 border border-slate-200 text-slate-700 px-3 py-1.5 rounded hover:bg-slate-100 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
<RotateCcw size={14} /> 重置
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{pendingCount > 0 && (
|
|
||||||
<span className="text-xs text-amber-600 bg-amber-50 px-2 py-1 rounded">
|
|
||||||
{pendingCount} 项待保存
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={saving || pendingCount === 0}
|
|
||||||
className="flex items-center gap-1.5 text-xs bg-blue-500 text-white px-3 py-1.5 rounded hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
<Save size={14} /> {saving ? '保存中...' : '保存'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Table */}
|
|
||||||
<div className="border border-slate-200 rounded-lg overflow-hidden max-h-[400px] overflow-y-auto">
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center py-12 text-slate-500">加载中...</div>
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="flex flex-col items-center text-slate-400">
|
||||||
|
<RotateCcw size={24} className="animate-spin mb-3 opacity-50" />
|
||||||
|
<span className="text-sm">加载数据中...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : filteredRows.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-3 auto-rows-max">
|
||||||
|
{filteredRows.map(row => (
|
||||||
|
<KeywordCard
|
||||||
|
key={row.localId}
|
||||||
|
item={row}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
managers={managers}
|
||||||
|
onUpdate={handleUpdate}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onRestore={handleRestore}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<table ref={tableRef} className="w-full text-sm">
|
<div className="h-full flex flex-col items-center justify-center text-slate-400 space-y-3">
|
||||||
<thead className="bg-slate-100 sticky top-0">
|
<Search size={32} className="opacity-20" />
|
||||||
<tr>
|
<p className="text-sm">未找到匹配的关键词记录</p>
|
||||||
<th className="px-4 py-2 text-left font-medium text-slate-700 w-64">
|
</div>
|
||||||
物料名称关键词
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-2 text-left font-medium text-slate-700">负责人</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-slate-100">
|
|
||||||
{filteredRows.filter((r) => r.state !== 'deleted').length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={2} className="px-4 py-8 text-center text-slate-400">
|
|
||||||
暂无数据,点击"新增"按钮添加物料类型关键词
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
filteredRows
|
|
||||||
.filter((r) => r.state !== 'deleted')
|
|
||||||
.map((row, index) => {
|
|
||||||
const originalIndex = rows.indexOf(row)
|
|
||||||
const isSelected = selectedRowIndex === originalIndex
|
|
||||||
const isEditingMaterial =
|
|
||||||
editingCell?.rowIndex === originalIndex &&
|
|
||||||
editingCell?.field === 'materialName'
|
|
||||||
const isEditingManager =
|
|
||||||
editingCell?.rowIndex === originalIndex &&
|
|
||||||
editingCell?.field === 'managerName'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<tr
|
|
||||||
key={index}
|
|
||||||
style={getRowStyle(row)}
|
|
||||||
className={`${isSelected ? 'ring-2 ring-blue-300 ring-inset' : ''} hover:bg-slate-50 cursor-pointer`}
|
|
||||||
onClick={() => setSelectedRowIndex(originalIndex)}
|
|
||||||
>
|
|
||||||
<td className="px-4 py-2 border-r border-slate-100">
|
|
||||||
{isEditingMaterial ? (
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
type="text"
|
|
||||||
value={editValue}
|
|
||||||
onChange={(e) => setEditValue(e.target.value)}
|
|
||||||
onBlur={saveEdit}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') saveEdit()
|
|
||||||
if (e.key === 'Escape') cancelEdit()
|
|
||||||
}}
|
|
||||||
className="w-full px-2 py-1 border border-blue-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className="min-h-[24px] cursor-text"
|
|
||||||
onDoubleClick={() => startEdit(originalIndex, 'materialName')}
|
|
||||||
>
|
|
||||||
{row.record.materialName || (
|
|
||||||
<span className="text-slate-400 italic">双击编辑</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2">
|
|
||||||
{isEditingManager ? (
|
|
||||||
isAdmin ? (
|
|
||||||
<select
|
|
||||||
ref={selectRef}
|
|
||||||
value={editValue}
|
|
||||||
onChange={(e) => setEditValue(e.target.value)}
|
|
||||||
onBlur={saveEdit}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') saveEdit()
|
|
||||||
if (e.key === 'Escape') cancelEdit()
|
|
||||||
}}
|
|
||||||
className="w-full px-2 py-1 border border-blue-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
>
|
|
||||||
<option value="">选择负责人</option>
|
|
||||||
{managers.map((m) => (
|
|
||||||
<option key={m} value={m}>
|
|
||||||
{m}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
) : (
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
type="text"
|
|
||||||
value={editValue}
|
|
||||||
onChange={(e) => setEditValue(e.target.value)}
|
|
||||||
onBlur={saveEdit}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') saveEdit()
|
|
||||||
if (e.key === 'Escape') cancelEdit()
|
|
||||||
}}
|
|
||||||
className="w-full px-2 py-1 border border-blue-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className="min-h-[24px] cursor-text"
|
|
||||||
onDoubleClick={() => startEdit(originalIndex, 'managerName')}
|
|
||||||
>
|
|
||||||
{row.record.managerName || (
|
|
||||||
<span className="text-slate-400 italic">双击编辑</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer info */}
|
{/* --- Footer --- */}
|
||||||
<div className="mt-3 text-xs text-slate-500 flex justify-between">
|
<footer className="px-6 py-3 bg-white border-t border-slate-100 flex justify-between items-center text-xs text-slate-400">
|
||||||
<span>
|
<p>支持直接点击卡片编辑名称,回车快速保存,清空内容即为删除。</p>
|
||||||
双击单元格编辑 | Insert 新增 | Delete 删除
|
<p>展示 <span className="font-semibold text-slate-600">{filteredRows.length}</span> / {rows.length} 条记录</p>
|
||||||
{isAdmin && ' | 绿色=新增 | 黄色=已修改'}
|
</footer>
|
||||||
</span>
|
|
||||||
<span>共 {filteredRows.filter((r) => r.state !== 'deleted').length} 条记录</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Confirmation Dialog */}
|
</div>
|
||||||
|
|
||||||
|
{/* Retain the original confirmation dialog */}
|
||||||
{confirmDialog && <ConfirmDialog {...confirmDialog} />}
|
{confirmDialog && <ConfirmDialog {...confirmDialog} />}
|
||||||
</Modal>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default MaterialTypeManagementDialog
|
export default MaterialTypeManagementDialog
|
||||||
27
src/renderer/src/components/cleaner-history-highlight.tsx
Normal file
27
src/renderer/src/components/cleaner-history-highlight.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Highlight matching text with a <mark> tag
|
||||||
|
* Case-insensitive matching of the query within text
|
||||||
|
*/
|
||||||
|
export function highlightText(text: string, query: string): React.ReactNode {
|
||||||
|
if (!query || !text) return text
|
||||||
|
|
||||||
|
const lowerText = text.toLowerCase()
|
||||||
|
const lowerQuery = query.toLowerCase()
|
||||||
|
|
||||||
|
const index = lowerText.indexOf(lowerQuery)
|
||||||
|
if (index === -1) return text
|
||||||
|
|
||||||
|
const before = text.substring(0, index)
|
||||||
|
const match = text.substring(index, index + query.length)
|
||||||
|
const after = text.substring(index + query.length)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{before}
|
||||||
|
<mark className="bg-yellow-200 text-inherit rounded px-0.5">{match}</mark>
|
||||||
|
{highlightText(after, query)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,8 +4,8 @@ import {
|
|||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
FileSpreadsheet,
|
FileSpreadsheet,
|
||||||
FileText,
|
|
||||||
HardDrive,
|
HardDrive,
|
||||||
|
History,
|
||||||
Search,
|
Search,
|
||||||
Settings2,
|
Settings2,
|
||||||
Square
|
Square
|
||||||
@@ -26,7 +26,7 @@ interface CleanerToolbarProps {
|
|||||||
handleValidation: () => Promise<void>
|
handleValidation: () => Promise<void>
|
||||||
handleConfirmDeletion: () => Promise<void>
|
handleConfirmDeletion: () => Promise<void>
|
||||||
handleExportResults: () => Promise<void>
|
handleExportResults: () => Promise<void>
|
||||||
setIsReportViewerOpen: (open: boolean) => void
|
setShowHistoryModal: (open: boolean) => void
|
||||||
typeManagementButtonRef: React.RefObject<HTMLButtonElement | null>
|
typeManagementButtonRef: React.RefObject<HTMLButtonElement | null>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ export function CleanerToolbar({
|
|||||||
handleValidation,
|
handleValidation,
|
||||||
handleConfirmDeletion,
|
handleConfirmDeletion,
|
||||||
handleExportResults,
|
handleExportResults,
|
||||||
setIsReportViewerOpen,
|
setShowHistoryModal,
|
||||||
typeManagementButtonRef
|
typeManagementButtonRef
|
||||||
}: CleanerToolbarProps): React.JSX.Element {
|
}: CleanerToolbarProps): React.JSX.Element {
|
||||||
return (
|
return (
|
||||||
@@ -123,6 +123,12 @@ export function CleanerToolbar({
|
|||||||
>
|
>
|
||||||
<Settings2 size={14} /> 类型管理
|
<Settings2 size={14} /> 类型管理
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowHistoryModal(true)}
|
||||||
|
className="text-xs bg-violet-50 border border-violet-200 text-violet-700 px-3 py-1.5 rounded shadow-sm hover:bg-violet-100 flex items-center gap-1.5 font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<History size={14} /> 操作历史
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => void handleExportResults()}
|
onClick={() => void handleExportResults()}
|
||||||
disabled={isExporting || filteredResults.length === 0}
|
disabled={isExporting || filteredResults.length === 0}
|
||||||
@@ -130,12 +136,6 @@ export function CleanerToolbar({
|
|||||||
>
|
>
|
||||||
<FileSpreadsheet size={14} /> {isExporting ? '导出中...' : '导出结果'}
|
<FileSpreadsheet size={14} /> {isExporting ? '导出中...' : '导出结果'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
onClick={() => setIsReportViewerOpen(true)}
|
|
||||||
className="text-xs bg-emerald-50 border border-emerald-200 text-emerald-700 px-3 py-1.5 rounded shadow-sm hover:bg-emerald-100 flex items-center gap-1.5 font-medium transition-colors"
|
|
||||||
>
|
|
||||||
<FileText size={14} /> 查看报告
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,8 @@ interface ModalProps {
|
|||||||
title?: string
|
title?: string
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl'
|
size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl'
|
||||||
|
/** Additional CSS classes for the modal panel */
|
||||||
|
className?: string
|
||||||
showCloseButton?: boolean
|
showCloseButton?: boolean
|
||||||
/** Ref to the element that triggered opening the modal (for focus restoration) */
|
/** Ref to the element that triggered opening the modal (for focus restoration) */
|
||||||
triggerRef?: React.RefObject<HTMLElement | null>
|
triggerRef?: React.RefObject<HTMLElement | null>
|
||||||
@@ -47,6 +49,7 @@ export function Modal({
|
|||||||
title,
|
title,
|
||||||
children,
|
children,
|
||||||
size = 'md',
|
size = 'md',
|
||||||
|
className,
|
||||||
showCloseButton = true,
|
showCloseButton = true,
|
||||||
triggerRef,
|
triggerRef,
|
||||||
titleId,
|
titleId,
|
||||||
@@ -98,7 +101,7 @@ export function Modal({
|
|||||||
<div className="flex min-h-full items-center justify-center p-4">
|
<div className="flex min-h-full items-center justify-center p-4">
|
||||||
<div
|
<div
|
||||||
ref={dialogRef}
|
ref={dialogRef}
|
||||||
className={`relative w-full ${sizeStyles[size]} bg-white rounded-lg shadow-xl transform transition-all`}
|
className={`relative w-full ${sizeStyles[size]} bg-white rounded-lg shadow-xl transform transition-all ${className || ''}`}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ interface CleanerRunPayload {
|
|||||||
errors: string[]
|
errors: string[]
|
||||||
retriedOrders: number
|
retriedOrders: number
|
||||||
successfulRetries: number
|
successfulRetries: number
|
||||||
|
materialsFailed?: number
|
||||||
|
uncertainDeletions?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function initializeCleanerPage(): Promise<CleanerInitializationResult> {
|
export async function initializeCleanerPage(): Promise<CleanerInitializationResult> {
|
||||||
@@ -161,7 +163,9 @@ export async function runCleanerExecution(params: {
|
|||||||
materialsSkipped: cleanerRunData.materialsSkipped,
|
materialsSkipped: cleanerRunData.materialsSkipped,
|
||||||
errors: cleanerRunData.errors,
|
errors: cleanerRunData.errors,
|
||||||
retriedOrders: cleanerRunData.retriedOrders,
|
retriedOrders: cleanerRunData.retriedOrders,
|
||||||
successfulRetries: cleanerRunData.successfulRetries
|
successfulRetries: cleanerRunData.successfulRetries,
|
||||||
|
materialsFailed: cleanerRunData.materialsFailed ?? 0,
|
||||||
|
uncertainDeletions: cleanerRunData.uncertainDeletions ?? 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export interface CleanerProgress {
|
|||||||
currentMaterialIndex: number
|
currentMaterialIndex: number
|
||||||
totalMaterialsInOrder: number
|
totalMaterialsInOrder: number
|
||||||
currentOrderNumber?: string
|
currentOrderNumber?: string
|
||||||
phase: 'login' | 'processing' | 'complete'
|
phase: 'login' | 'processing' | 'complete' | 'retry'
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CleanerReportData {
|
export interface CleanerReportData {
|
||||||
@@ -45,6 +45,9 @@ export interface CleanerReportData {
|
|||||||
errors: string[]
|
errors: string[]
|
||||||
retriedOrders?: number
|
retriedOrders?: number
|
||||||
successfulRetries?: number
|
successfulRetries?: number
|
||||||
|
materialsFailed?: number
|
||||||
|
uncertainDeletions?: number
|
||||||
|
crashed?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CleanerInitializationResult {
|
export interface CleanerInitializationResult {
|
||||||
@@ -58,3 +61,89 @@ export interface CleanerConfigResult {
|
|||||||
queryBatchSize: number
|
queryBatchSize: number
|
||||||
processConcurrency: number
|
processConcurrency: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cleaner operation history types (mirrors preload/index.d.ts)
|
||||||
|
|
||||||
|
export interface CleanerHistoryBatchStats {
|
||||||
|
batchId: string
|
||||||
|
userId: number
|
||||||
|
username: string
|
||||||
|
operationTime: string
|
||||||
|
status: string
|
||||||
|
totalAttempts: number
|
||||||
|
totalOrders: number
|
||||||
|
ordersProcessed: number
|
||||||
|
totalMaterialsDeleted: number
|
||||||
|
totalMaterialsFailed: number
|
||||||
|
successCount: number
|
||||||
|
failedCount: number
|
||||||
|
isDryRun: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CleanerHistoryExecutionRecord {
|
||||||
|
id?: number
|
||||||
|
batchId: string
|
||||||
|
attemptNumber: number
|
||||||
|
userId: number
|
||||||
|
username: string
|
||||||
|
operationTime: string
|
||||||
|
endTime: string | null
|
||||||
|
status: string
|
||||||
|
isDryRun: boolean
|
||||||
|
totalOrders: number
|
||||||
|
ordersProcessed: number
|
||||||
|
totalMaterialsDeleted: number
|
||||||
|
totalMaterialsSkipped: number
|
||||||
|
totalMaterialsFailed: number
|
||||||
|
totalUncertainDeletions: number
|
||||||
|
errorMessage: string | null
|
||||||
|
appVersion: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CleanerHistoryOrderRecord {
|
||||||
|
id?: number
|
||||||
|
batchId: string
|
||||||
|
attemptNumber: number
|
||||||
|
orderNumber: string
|
||||||
|
productionId: string | null
|
||||||
|
status: string
|
||||||
|
materialsDeleted: number
|
||||||
|
materialsSkipped: number
|
||||||
|
materialsFailed: number
|
||||||
|
uncertainDeletions: number
|
||||||
|
retryCount: number
|
||||||
|
retrySuccess: boolean
|
||||||
|
errorMessage: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CleanerHistoryMaterialRecord {
|
||||||
|
id?: number
|
||||||
|
batchId: string
|
||||||
|
attemptNumber: number
|
||||||
|
orderNumber: string
|
||||||
|
materialCode: string
|
||||||
|
materialName: string
|
||||||
|
rowNumber: number
|
||||||
|
result: string
|
||||||
|
reason: string | null
|
||||||
|
attemptCount: number
|
||||||
|
finalErrorCategory: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search result types (mirrors main process types)
|
||||||
|
|
||||||
|
export interface CleanerHistorySearchOrderResult {
|
||||||
|
order: CleanerHistoryOrderRecord
|
||||||
|
materials: CleanerHistoryMaterialRecord[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CleanerHistorySearchBatchResult {
|
||||||
|
batch: CleanerHistoryBatchStats
|
||||||
|
executions: CleanerHistoryExecutionRecord[]
|
||||||
|
orders: CleanerHistorySearchOrderResult[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CleanerHistorySearchResult {
|
||||||
|
batches: CleanerHistorySearchBatchResult[]
|
||||||
|
totalMatches: number
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ const MaterialTypeManagementDialog = React.lazy(
|
|||||||
() => import('../components/MaterialTypeManagementDialog')
|
() => import('../components/MaterialTypeManagementDialog')
|
||||||
)
|
)
|
||||||
const ExecutionReportDialog = React.lazy(() => import('../components/ExecutionReportDialog'))
|
const ExecutionReportDialog = React.lazy(() => import('../components/ExecutionReportDialog'))
|
||||||
const ReportViewerDialog = React.lazy(() => import('../components/ReportViewerDialog'))
|
const CleanerOperationHistoryModal = React.lazy(
|
||||||
const ReportAnalysisDialog = React.lazy(() => import('../components/ReportAnalysisDialog'))
|
() => import('../components/CleanerOperationHistoryModal')
|
||||||
|
)
|
||||||
|
|
||||||
const CleanerPage: React.FC = () => {
|
const CleanerPage: React.FC = () => {
|
||||||
const typeManagementButtonRef = React.useRef<HTMLButtonElement>(null)
|
const typeManagementButtonRef = React.useRef<HTMLButtonElement>(null)
|
||||||
@@ -66,8 +67,7 @@ const CleanerPage: React.FC = () => {
|
|||||||
confirmDialog
|
confirmDialog
|
||||||
} = useCleaner()
|
} = useCleaner()
|
||||||
|
|
||||||
const [isReportViewerOpen, setIsReportViewerOpen] = React.useState(false)
|
const [showHistoryModal, setShowHistoryModal] = React.useState(false)
|
||||||
const [isReportAnalysisOpen, setIsReportAnalysisOpen] = React.useState(false)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col xl:flex-row gap-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
<div className="h-full flex flex-col xl:flex-row gap-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
@@ -96,7 +96,7 @@ const CleanerPage: React.FC = () => {
|
|||||||
handleValidation={handleValidation}
|
handleValidation={handleValidation}
|
||||||
handleConfirmDeletion={handleConfirmDeletion}
|
handleConfirmDeletion={handleConfirmDeletion}
|
||||||
handleExportResults={handleExportResults}
|
handleExportResults={handleExportResults}
|
||||||
setIsReportViewerOpen={setIsReportViewerOpen}
|
setShowHistoryModal={setShowHistoryModal}
|
||||||
typeManagementButtonRef={typeManagementButtonRef}
|
typeManagementButtonRef={typeManagementButtonRef}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -162,26 +162,24 @@ const CleanerPage: React.FC = () => {
|
|||||||
triggerRef={executeButtonRef}
|
triggerRef={executeButtonRef}
|
||||||
retriedOrders={reportData?.retriedOrders}
|
retriedOrders={reportData?.retriedOrders}
|
||||||
successfulRetries={reportData?.successfulRetries}
|
successfulRetries={reportData?.successfulRetries}
|
||||||
|
materialsFailed={reportData?.materialsFailed}
|
||||||
|
uncertainDeletions={reportData?.uncertainDeletions}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
<Suspense fallback={null}>
|
{showHistoryModal ? (
|
||||||
<ReportViewerDialog
|
<Suspense fallback={null}>
|
||||||
isOpen={isReportViewerOpen}
|
<CleanerOperationHistoryModal
|
||||||
onClose={() => setIsReportViewerOpen(false)}
|
isOpen={showHistoryModal}
|
||||||
isAdmin={isAdmin}
|
onClose={() => setShowHistoryModal(false)}
|
||||||
currentUsername={currentUsername}
|
user={
|
||||||
onOpenAnalysis={() => setIsReportAnalysisOpen(true)}
|
currentUsername
|
||||||
/>
|
? { username: currentUsername, userType: isAdmin ? 'Admin' : 'User' }
|
||||||
</Suspense>
|
: null
|
||||||
|
}
|
||||||
<Suspense fallback={null}>
|
/>
|
||||||
<ReportAnalysisDialog
|
</Suspense>
|
||||||
isOpen={isReportAnalysisOpen}
|
) : null}
|
||||||
onClose={() => setIsReportAnalysisOpen(false)}
|
|
||||||
isAdmin={isAdmin}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
|
|
||||||
{/* Confirmation Dialog */}
|
{/* Confirmation Dialog */}
|
||||||
{confirmDialog && <ConfirmDialog {...confirmDialog} />}
|
{confirmDialog && <ConfirmDialog {...confirmDialog} />}
|
||||||
|
|||||||
@@ -21,6 +21,13 @@ export const IPC_CHANNELS = {
|
|||||||
CLEANER_EXPORT_RESULTS: 'cleaner:exportResults',
|
CLEANER_EXPORT_RESULTS: 'cleaner:exportResults',
|
||||||
CLEANER_PROGRESS: 'cleaner:progress',
|
CLEANER_PROGRESS: 'cleaner:progress',
|
||||||
|
|
||||||
|
// Cleaner operation history
|
||||||
|
CLEANER_HISTORY_GET_BATCHES: 'cleanerHistory:getBatches',
|
||||||
|
CLEANER_HISTORY_GET_BATCH_DETAILS: 'cleanerHistory:getBatchDetails',
|
||||||
|
CLEANER_HISTORY_GET_MATERIAL_DETAILS: 'cleanerHistory:getMaterialDetails',
|
||||||
|
CLEANER_HISTORY_DELETE_BATCH: 'cleanerHistory:deleteBatch',
|
||||||
|
CLEANER_HISTORY_SEARCH: 'cleanerHistory:search',
|
||||||
|
|
||||||
// Database service - MySQL
|
// Database service - MySQL
|
||||||
DATABASE_MYSQL_CONNECT: 'database:mysql:connect',
|
DATABASE_MYSQL_CONNECT: 'database:mysql:connect',
|
||||||
DATABASE_MYSQL_DISCONNECT: 'database:mysql:disconnect',
|
DATABASE_MYSQL_DISCONNECT: 'database:mysql:disconnect',
|
||||||
|
|||||||
@@ -8,11 +8,23 @@ const withErrorHandlingMock = vi.fn(async (handler: () => Promise<unknown>) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const serviceMethods = {
|
const serviceMethods = {
|
||||||
runCleaner: vi.fn(async (_sender: unknown, input: unknown) => ({ success: true, input })),
|
runCleaner: vi.fn(
|
||||||
|
async (
|
||||||
|
_sender: unknown,
|
||||||
|
input: unknown,
|
||||||
|
_batchId: string,
|
||||||
|
_historyDao: unknown,
|
||||||
|
_appVersion: string
|
||||||
|
) => ({ success: true, input })
|
||||||
|
),
|
||||||
exportResults: vi.fn(async (items: unknown[]) => ({ success: true, total: items.length }))
|
exportResults: vi.fn(async (items: unknown[]) => ({ success: true, total: items.length }))
|
||||||
}
|
}
|
||||||
|
|
||||||
vi.mock('electron', () => ({
|
vi.mock('electron', () => ({
|
||||||
|
app: {
|
||||||
|
isPackaged: false,
|
||||||
|
getVersion: () => 'test-version'
|
||||||
|
},
|
||||||
ipcMain: {
|
ipcMain: {
|
||||||
handle: handleMock
|
handle: handleMock
|
||||||
}
|
}
|
||||||
@@ -51,7 +63,14 @@ describe('registerCleanerHandlers', () => {
|
|||||||
|
|
||||||
const result = await runHandler?.(event, input)
|
const result = await runHandler?.(event, input)
|
||||||
|
|
||||||
expect(serviceMethods.runCleaner).toHaveBeenCalledWith(sender, input)
|
expect(serviceMethods.runCleaner).toHaveBeenCalledTimes(1)
|
||||||
|
const actualCall = serviceMethods.runCleaner.mock.calls[0]
|
||||||
|
expect(actualCall[0]).toBe(sender)
|
||||||
|
expect(actualCall[1]).toEqual(input)
|
||||||
|
expect(typeof actualCall[2]).toBe('string') // batchId is UUID
|
||||||
|
expect(actualCall[3]).toBeDefined() // historyDao
|
||||||
|
expect(actualCall[4]).toBe('test-version') // appVersion
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
success: true,
|
success: true,
|
||||||
data: { success: true, input }
|
data: { success: true, input }
|
||||||
|
|||||||
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -46,8 +46,8 @@ describe('MySqlDialect', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('currentTimestamp', () => {
|
describe('currentTimestamp', () => {
|
||||||
it('should return NOW()', () => {
|
it('should return UTC_TIMESTAMP()', () => {
|
||||||
expect(dialect.currentTimestamp()).toBe('NOW()')
|
expect(dialect.currentTimestamp()).toBe('UTC_TIMESTAMP()')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -45,8 +45,8 @@ describe('SqlServerDialect', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('currentTimestamp', () => {
|
describe('currentTimestamp', () => {
|
||||||
it('should return GETDATE()', () => {
|
it('should return SYSUTCDATETIME()', () => {
|
||||||
expect(dialect.currentTimestamp()).toBe('GETDATE()')
|
expect(dialect.currentTimestamp()).toBe('SYSUTCDATETIME()')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -37,6 +37,34 @@ vi.mock('../../../../src/main/services/erp/order-resolver', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Mock CleanerOperationHistoryDAO
|
||||||
|
vi.mock('../../../../src/main/services/database/cleaner-operation-history-dao', () => {
|
||||||
|
return {
|
||||||
|
CleanerOperationHistoryDAO: class {
|
||||||
|
async insertExecution() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
async insertOrderRecords() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
async updateOrderStatus() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
async insertMaterialDetails() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
async updateExecutionStatus() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
async getBatchDetails() {
|
||||||
|
return {
|
||||||
|
executions: [{ attemptNumber: 1, isDryRun: false }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Control whether CleanerService.clean should throw
|
// Control whether CleanerService.clean should throw
|
||||||
let cleanerShouldThrow = false
|
let cleanerShouldThrow = false
|
||||||
let cleanerError: Error = new Error('cleaner crashed')
|
let cleanerError: Error = new Error('cleaner crashed')
|
||||||
@@ -108,6 +136,16 @@ function setupServiceMocks(service: CleanerApplicationService) {
|
|||||||
})
|
})
|
||||||
;(service as any).recordCleanupAudit = vi.fn().mockResolvedValue(undefined)
|
;(service as any).recordCleanupAudit = vi.fn().mockResolvedValue(undefined)
|
||||||
;(service as any).generateAndUploadReport = vi.fn().mockResolvedValue(undefined)
|
;(service as any).generateAndUploadReport = vi.fn().mockResolvedValue(undefined)
|
||||||
|
;(service as any).getHistoryDao = vi.fn().mockReturnValue({
|
||||||
|
insertExecution: vi.fn().mockResolvedValue(true),
|
||||||
|
insertOrderRecords: vi.fn().mockResolvedValue(true),
|
||||||
|
updateOrderStatus: vi.fn().mockResolvedValue(true),
|
||||||
|
insertMaterialDetails: vi.fn().mockResolvedValue(true),
|
||||||
|
updateExecutionStatus: vi.fn().mockResolvedValue(true),
|
||||||
|
getBatchDetails: vi.fn().mockResolvedValue({
|
||||||
|
executions: [{ attemptNumber: 1, isDryRun: false }]
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeInput(overrides: Record<string, any> = {}) {
|
function makeInput(overrides: Record<string, any> = {}) {
|
||||||
@@ -204,7 +242,7 @@ describe('CleanerApplicationService', () => {
|
|||||||
).rejects.toBeInstanceOf(DatabaseQueryError)
|
).rejects.toBeInstanceOf(DatabaseQueryError)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should reject with ValidationError when no valid order numbers are provided', async () => {
|
it('should return empty result when no valid order numbers are provided', async () => {
|
||||||
;(service as any).getErpConfig = vi
|
;(service as any).getErpConfig = vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValue({ url: 'u', username: 'x', password: 'p' })
|
.mockResolvedValue({ url: 'u', username: 'x', password: 'p' })
|
||||||
@@ -212,9 +250,13 @@ describe('CleanerApplicationService', () => {
|
|||||||
disconnect: vi.fn().mockResolvedValue(undefined)
|
disconnect: vi.fn().mockResolvedValue(undefined)
|
||||||
})
|
})
|
||||||
|
|
||||||
await expect(
|
const result = await service.runCleaner(
|
||||||
service.runCleaner({ send: vi.fn() } as any, makeInput({ orderNumbers: [] }))
|
{ send: vi.fn() } as any,
|
||||||
).rejects.toThrow('没有有效的生产订单号可处理')
|
makeInput({ orderNumbers: [] })
|
||||||
|
)
|
||||||
|
expect(result.ordersProcessed).toBe(0)
|
||||||
|
expect(result.materialsDeleted).toBe(0)
|
||||||
|
expect(result.details).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should process orders containing empty strings without crashing', async () => {
|
it('should process orders containing empty strings without crashing', async () => {
|
||||||
|
|||||||
203
tests/unit/services/cleaner/cleaner-skipped-materials.test.ts
Normal file
203
tests/unit/services/cleaner/cleaner-skipped-materials.test.ts
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
|
import { CleanerApplicationService } from '../../../../src/main/services/cleaner/cleaner-application-service'
|
||||||
|
import { CleanerOperationHistoryDAO } from '../../../../src/main/services/database/cleaner-operation-history-dao'
|
||||||
|
|
||||||
|
// Mock 所有依赖
|
||||||
|
vi.mock('../../../../src/main/services/config/config-manager', () => ({
|
||||||
|
ConfigManager: {
|
||||||
|
getInstance: () => ({
|
||||||
|
getDatabaseType: () => 'mysql',
|
||||||
|
getConfig: () => ({ database: { activeType: 'mysql' } })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../../../src/main/services/erp/order-resolver', () => ({
|
||||||
|
OrderNumberResolver: class {
|
||||||
|
constructor(_dbService: any) {}
|
||||||
|
async resolve(orderNumbers: string[]) {
|
||||||
|
return orderNumbers
|
||||||
|
}
|
||||||
|
getValidOrderNumbers(mappings: string[]) {
|
||||||
|
return mappings
|
||||||
|
}
|
||||||
|
getWarnings(_mappings: any[]) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
let mockInsertedMaterials: any[] = []
|
||||||
|
|
||||||
|
vi.mock('../../../../src/main/services/erp/cleaner', () => ({
|
||||||
|
CleanerService: class {
|
||||||
|
constructor(_erpAuth: any) {}
|
||||||
|
async clean(input: any) {
|
||||||
|
// 模拟一个订单有 deleted 和 skipped 物料
|
||||||
|
return {
|
||||||
|
ordersProcessed: 1,
|
||||||
|
materialsDeleted: 1,
|
||||||
|
materialsSkipped: 1,
|
||||||
|
materialsFailed: 0,
|
||||||
|
uncertainDeletions: 0,
|
||||||
|
errors: [],
|
||||||
|
details: [
|
||||||
|
{
|
||||||
|
orderNumber: 'SC70202604080195',
|
||||||
|
materialsDeleted: 1,
|
||||||
|
materialsSkipped: 1,
|
||||||
|
materialsFailed: 0,
|
||||||
|
uncertainDeletions: 0,
|
||||||
|
errors: [],
|
||||||
|
deletedMaterials: [
|
||||||
|
{
|
||||||
|
materialCode: 'MAT001',
|
||||||
|
materialName: '测试物料 1',
|
||||||
|
rowNumber: 10,
|
||||||
|
outcome: 'success'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
skippedMaterials: [
|
||||||
|
{
|
||||||
|
materialCode: 'MAT002',
|
||||||
|
materialName: '测试物料 2',
|
||||||
|
rowNumber: 2500,
|
||||||
|
reason: '行号在 2000-7999 范围内(受保护)'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
failedMaterials: [],
|
||||||
|
retryCount: 0,
|
||||||
|
retrySuccess: false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
retriedOrders: 0,
|
||||||
|
successfulRetries: 0,
|
||||||
|
crashed: false
|
||||||
|
} as any
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../../../src/main/services/erp/erp-auth', () => ({
|
||||||
|
ErpAuthService: class {
|
||||||
|
constructor(_config: any) {}
|
||||||
|
async login() {
|
||||||
|
return Promise.resolve(undefined)
|
||||||
|
}
|
||||||
|
async close() {
|
||||||
|
return Promise.resolve(undefined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../../../src/main/services/database/cleaner-operation-history-dao', () => ({
|
||||||
|
CleanerOperationHistoryDAO: class {
|
||||||
|
async getBatchDetails(batchId: string) {
|
||||||
|
return { executions: [{ attemptNumber: 1, isDryRun: false }], orders: [] }
|
||||||
|
}
|
||||||
|
async insertExecution(input: any) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
async insertOrderRecords(batchId: string, attemptNumber: number, orders: any[]) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
async updateOrderStatus(
|
||||||
|
batchId: string,
|
||||||
|
attemptNumber: number,
|
||||||
|
orderNumber: string,
|
||||||
|
status: string,
|
||||||
|
materialsDeleted: number,
|
||||||
|
materialsSkipped: number,
|
||||||
|
materialsFailed: number,
|
||||||
|
uncertainDeletions: number,
|
||||||
|
retryCount: number,
|
||||||
|
retrySuccess: boolean,
|
||||||
|
errorMessage?: string
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
async insertMaterialDetails(batchId: string, attemptNumber: number, details: any[]) {
|
||||||
|
mockInsertedMaterials = [...details]
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
async updateExecutionStatus(
|
||||||
|
batchId: string,
|
||||||
|
attemptNumber: number,
|
||||||
|
status: string,
|
||||||
|
ordersProcessed: number,
|
||||||
|
materialsDeleted: number,
|
||||||
|
materialsSkipped: number,
|
||||||
|
materialsFailed: number,
|
||||||
|
uncertainDeletions: number,
|
||||||
|
endTime: Date,
|
||||||
|
errorMessage?: string
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../../../src/main/services/logger/audit-logger', () => ({
|
||||||
|
logAuditWithCurrentUser: () => {}
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../../../src/main/services/user/session-manager', () => ({
|
||||||
|
SessionManager: {
|
||||||
|
getInstance: () => ({
|
||||||
|
getUserInfo: () => ({ id: 1, username: 'test' })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
function setupServiceMocks(service: CleanerApplicationService) {
|
||||||
|
;(service as any).getErpConfig = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ url: 'http://erp', username: 'u', password: 'p' })
|
||||||
|
;(service as any).getDatabaseService = vi.fn().mockResolvedValue({ disconnect: vi.fn() })
|
||||||
|
;(service as any).recordCleanupAudit = vi.fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeInput(overrides: any = {}) {
|
||||||
|
return {
|
||||||
|
orderNumbers: ['SC70202604080195'],
|
||||||
|
materialCodes: ['MAT001', 'MAT002'],
|
||||||
|
dryRun: false,
|
||||||
|
queryBatchSize: 100,
|
||||||
|
processConcurrency: 1,
|
||||||
|
onProgress: vi.fn(),
|
||||||
|
...overrides
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CleanerApplicationService - Skipped Materials', () => {
|
||||||
|
let service: CleanerApplicationService
|
||||||
|
let historyDao: CleanerOperationHistoryDAO
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
service = new CleanerApplicationService()
|
||||||
|
historyDao = new CleanerOperationHistoryDAO()
|
||||||
|
mockInsertedMaterials = []
|
||||||
|
setupServiceMocks(service)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should save skipped materials to database', async () => {
|
||||||
|
const eventSender: any = { send: vi.fn() }
|
||||||
|
const result = await service.runCleaner(
|
||||||
|
eventSender,
|
||||||
|
makeInput(),
|
||||||
|
'TEST-BATCH',
|
||||||
|
historyDao,
|
||||||
|
'1.0.0'
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.materialsSkipped).toBe(1)
|
||||||
|
expect(mockInsertedMaterials.length).toBe(2) // 1 deleted + 1 skipped
|
||||||
|
|
||||||
|
const skippedMaterial = mockInsertedMaterials.find((m) => m.result === 'skipped')
|
||||||
|
expect(skippedMaterial).toBeDefined()
|
||||||
|
expect(skippedMaterial!.materialCode).toBe('MAT002')
|
||||||
|
expect(skippedMaterial!.materialName).toBe('测试物料 2')
|
||||||
|
expect(skippedMaterial!.rowNumber).toBe(2500)
|
||||||
|
expect(skippedMaterial!.reason).toBe('行号在 2000-7999 范围内(受保护)')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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"')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { DeletionErrorCategory, DeletionOutcome } from '../../../../src/main/types/cleaner.types'
|
||||||
import {
|
import {
|
||||||
CleanerService,
|
CleanerService,
|
||||||
createBatches,
|
createBatches,
|
||||||
@@ -270,3 +271,104 @@ describe('CleanerService - Helper Methods', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('CleanerService - evaluateDeletionSignals()', () => {
|
||||||
|
const cleaner = new CleanerService({} as any, { dryRun: false })
|
||||||
|
|
||||||
|
it('should return FailedErpError when error is detected', () => {
|
||||||
|
const result = cleaner.evaluateDeletionSignals({
|
||||||
|
rowChanged: false,
|
||||||
|
countDecreased: false,
|
||||||
|
hasError: true,
|
||||||
|
errorText: '物料已被引用'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.outcome).toBe(DeletionOutcome.FailedErpError)
|
||||||
|
expect(result.errorCategory).toBe(DeletionErrorCategory.ErpRejection)
|
||||||
|
expect(result.errorMessage).toBe('物料已被引用')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return FailedErpError with default message when no errorText', () => {
|
||||||
|
const result = cleaner.evaluateDeletionSignals({
|
||||||
|
rowChanged: false,
|
||||||
|
countDecreased: false,
|
||||||
|
hasError: true
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.outcome).toBe(DeletionOutcome.FailedErpError)
|
||||||
|
expect(result.errorMessage).toBe('ERP returned an error')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return Success when row changed and count decreased (true)', () => {
|
||||||
|
const result = cleaner.evaluateDeletionSignals({
|
||||||
|
rowChanged: true,
|
||||||
|
countDecreased: true,
|
||||||
|
hasError: false
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.outcome).toBe(DeletionOutcome.Success)
|
||||||
|
expect(result.errorCategory).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return Success when row changed and count is unreadable (null)', () => {
|
||||||
|
const result = cleaner.evaluateDeletionSignals({
|
||||||
|
rowChanged: true,
|
||||||
|
countDecreased: null,
|
||||||
|
hasError: false
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.outcome).toBe(DeletionOutcome.Success)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return Uncertain when row changed but count did not decrease', () => {
|
||||||
|
const result = cleaner.evaluateDeletionSignals({
|
||||||
|
rowChanged: true,
|
||||||
|
countDecreased: false,
|
||||||
|
hasError: false
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.outcome).toBe(DeletionOutcome.Uncertain)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return Success when row did not change but count decreased', () => {
|
||||||
|
const result = cleaner.evaluateDeletionSignals({
|
||||||
|
rowChanged: false,
|
||||||
|
countDecreased: true,
|
||||||
|
hasError: false
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.outcome).toBe(DeletionOutcome.Success)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return FailedNoChange when nothing changed', () => {
|
||||||
|
const result = cleaner.evaluateDeletionSignals({
|
||||||
|
rowChanged: false,
|
||||||
|
countDecreased: false,
|
||||||
|
hasError: false
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.outcome).toBe(DeletionOutcome.FailedNoChange)
|
||||||
|
expect(result.errorCategory).toBe(DeletionErrorCategory.Unknown)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return FailedNoChange when row did not change and count is null', () => {
|
||||||
|
const result = cleaner.evaluateDeletionSignals({
|
||||||
|
rowChanged: false,
|
||||||
|
countDecreased: null,
|
||||||
|
hasError: false
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.outcome).toBe(DeletionOutcome.FailedNoChange)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should prioritize error signal over all others', () => {
|
||||||
|
const result = cleaner.evaluateDeletionSignals({
|
||||||
|
rowChanged: true,
|
||||||
|
countDecreased: true,
|
||||||
|
hasError: true,
|
||||||
|
errorText: 'Some error'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.outcome).toBe(DeletionOutcome.FailedErpError)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user