Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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` 的优先级顺序。
|
||||||
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 环境下物料数据写入失败的问题,支持无唯一约束的表。
|
||||||
|
|
||||||
|
## 改进
|
||||||
|
|
||||||
|
- 优化清理器操作历史的分页加载和执行报告展示,提升大数据量下的响应速度。
|
||||||
|
- 统一清理器和提取器的操作历史删除确认交互,保持一致的体验。
|
||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "erpauto",
|
"name": "erpauto",
|
||||||
"version": "1.11.1",
|
"version": "1.12.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "erpauto",
|
"name": "erpauto",
|
||||||
"version": "1.11.1",
|
"version": "1.12.4",
|
||||||
"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.12.4",
|
||||||
"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')
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
156
src/main/ipc/cleaner-history-handler.ts
Normal file
156
src/main/ipc/cleaner-history-handler.ts
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
/**
|
||||||
|
* 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
|
||||||
|
} 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')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
995
src/main/services/database/cleaner-operation-history-dao.ts
Normal file
995
src/main/services/database/cleaner-operation-history-dao.ts
Normal file
@@ -0,0 +1,995 @@
|
|||||||
|
/**
|
||||||
|
* Data Access Object for Cleaner operation history (three-table design)
|
||||||
|
*
|
||||||
|
* Handles database operations for tracking cleaner operation history across:
|
||||||
|
* - CleanerExecution: execution-level records (one per attempt)
|
||||||
|
* - CleanerOrderHistory: order-level records (one per order per attempt)
|
||||||
|
* - CleanerMaterialDetail: material-level records (one per material per order per attempt)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { create, type IDatabaseService } from './index'
|
||||||
|
import { createDialect, type SqlDialect } from './dialects'
|
||||||
|
import { createLogger, getRequestId, trackDuration } from '../logger'
|
||||||
|
import type {
|
||||||
|
CleanerExecutionRecord,
|
||||||
|
CleanerOrderRecord,
|
||||||
|
CleanerMaterialRecord,
|
||||||
|
CleanerBatchStats,
|
||||||
|
InsertCleanerExecutionInput,
|
||||||
|
InsertOrderInput,
|
||||||
|
InsertMaterialDetailInput,
|
||||||
|
GetCleanerBatchesOptions
|
||||||
|
} from '../../types/cleaner-history.types'
|
||||||
|
|
||||||
|
const log = createLogger('CleanerOperationHistoryDAO')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format datetime value from database to ISO string
|
||||||
|
* mssql driver returns Date objects in UTC format
|
||||||
|
*/
|
||||||
|
function formatDateTime(value: unknown): string {
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return value.toISOString()
|
||||||
|
}
|
||||||
|
return value ? String(value) : new Date().toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration constants for all three Cleaner history tables
|
||||||
|
*/
|
||||||
|
export const CLEANER_EXECUTION_CONFIG = {
|
||||||
|
COLUMNS: {
|
||||||
|
ID: 'ID',
|
||||||
|
BATCH_ID: 'BatchId',
|
||||||
|
ATTEMPT_NUMBER: 'AttemptNumber',
|
||||||
|
USER_ID: 'UserId',
|
||||||
|
USERNAME: 'Username',
|
||||||
|
OPERATION_TIME: 'OperationTime',
|
||||||
|
END_TIME: 'EndTime',
|
||||||
|
STATUS: 'Status',
|
||||||
|
IS_DRY_RUN: 'IsDryRun',
|
||||||
|
TOTAL_ORDERS: 'TotalOrders',
|
||||||
|
ORDERS_PROCESSED: 'OrdersProcessed',
|
||||||
|
TOTAL_MATERIALS_DELETED: 'TotalMaterialsDeleted',
|
||||||
|
TOTAL_MATERIALS_SKIPPED: 'TotalMaterialsSkipped',
|
||||||
|
TOTAL_MATERIALS_FAILED: 'TotalMaterialsFailed',
|
||||||
|
TOTAL_UNCERTAIN_DELETIONS: 'TotalUncertainDeletions',
|
||||||
|
ERROR_MESSAGE: 'ErrorMessage',
|
||||||
|
APP_VERSION: 'AppVersion'
|
||||||
|
}
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const CLEANER_ORDER_CONFIG = {
|
||||||
|
COLUMNS: {
|
||||||
|
ID: 'ID',
|
||||||
|
BATCH_ID: 'BatchId',
|
||||||
|
ATTEMPT_NUMBER: 'AttemptNumber',
|
||||||
|
ORDER_NUMBER: 'OrderNumber',
|
||||||
|
PRODUCTION_ID: 'ProductionId',
|
||||||
|
STATUS: 'Status',
|
||||||
|
MATERIALS_DELETED: 'MaterialsDeleted',
|
||||||
|
MATERIALS_SKIPPED: 'MaterialsSkipped',
|
||||||
|
MATERIALS_FAILED: 'MaterialsFailed',
|
||||||
|
UNCERTAIN_DELETIONS: 'UncertainDeletions',
|
||||||
|
RETRY_COUNT: 'RetryCount',
|
||||||
|
RETRY_SUCCESS: 'RetrySuccess',
|
||||||
|
ERROR_MESSAGE: 'ErrorMessage'
|
||||||
|
}
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const CLEANER_MATERIAL_CONFIG = {
|
||||||
|
COLUMNS: {
|
||||||
|
ID: 'ID',
|
||||||
|
BATCH_ID: 'BatchId',
|
||||||
|
ATTEMPT_NUMBER: 'AttemptNumber',
|
||||||
|
ORDER_NUMBER: 'OrderNumber',
|
||||||
|
MATERIAL_CODE: 'MaterialCode',
|
||||||
|
MATERIAL_NAME: 'MaterialName',
|
||||||
|
ROW_NUMBER: 'RowNumber',
|
||||||
|
RESULT: 'Result',
|
||||||
|
REASON: 'Reason',
|
||||||
|
ATTEMPT_COUNT: 'AttemptCount',
|
||||||
|
FINAL_ERROR_CATEGORY: 'FinalErrorCategory'
|
||||||
|
}
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CleanerOperationHistory DAO Class
|
||||||
|
*/
|
||||||
|
export class CleanerOperationHistoryDAO {
|
||||||
|
private dbService: IDatabaseService | null = null
|
||||||
|
private dialect: SqlDialect | null = null
|
||||||
|
|
||||||
|
private getDialect(): SqlDialect {
|
||||||
|
if (!this.dialect) {
|
||||||
|
this.dialect = createDialect(this.dbService!.type)
|
||||||
|
}
|
||||||
|
return this.dialect
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the appropriate table name based on database type
|
||||||
|
*/
|
||||||
|
private getExecutionTableName(): string {
|
||||||
|
return this.getDialect().quoteTableName('ERPAuto', 'CleanerExecution')
|
||||||
|
}
|
||||||
|
|
||||||
|
private getOrderTableName(): string {
|
||||||
|
return this.getDialect().quoteTableName('ERPAuto', 'CleanerOrderHistory')
|
||||||
|
}
|
||||||
|
|
||||||
|
private getMaterialTableName(): string {
|
||||||
|
return this.getDialect().quoteTableName('ERPAuto', 'CleanerMaterialDetail')
|
||||||
|
}
|
||||||
|
|
||||||
|
private getIsDryRunAggregateSql(): string {
|
||||||
|
const dialect = this.getDialect()
|
||||||
|
if (dialect.dbType === 'postgresql') {
|
||||||
|
return `MAX(CASE WHEN e.IsDryRun THEN 1 ELSE 0 END)`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `MAX(CAST(e.IsDryRun AS INT))`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get database service instance using DatabaseFactory
|
||||||
|
*/
|
||||||
|
private async getDatabaseService(): Promise<IDatabaseService> {
|
||||||
|
if (this.dbService && this.dbService.isConnected()) {
|
||||||
|
return this.dbService
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dbService = await create()
|
||||||
|
return this.dbService
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== EXECUTION INSERT ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert a new execution record
|
||||||
|
* @param input - Execution input data
|
||||||
|
* @returns True if successful
|
||||||
|
*/
|
||||||
|
async insertExecution(input: InsertCleanerExecutionInput): Promise<boolean> {
|
||||||
|
const requestId = getRequestId() || `insert-exec-${Date.now()}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dbService = await this.getDatabaseService()
|
||||||
|
const tableName = this.getExecutionTableName()
|
||||||
|
const dialect = this.getDialect()
|
||||||
|
|
||||||
|
log.info('Execution insertion started', {
|
||||||
|
tableName,
|
||||||
|
operationType: 'INSERT',
|
||||||
|
requestId,
|
||||||
|
batchId: input.batchId,
|
||||||
|
attemptNumber: input.attemptNumber,
|
||||||
|
userId: input.userId,
|
||||||
|
username: input.username
|
||||||
|
})
|
||||||
|
|
||||||
|
const sqlString = `
|
||||||
|
INSERT INTO ${tableName}
|
||||||
|
(BatchId, AttemptNumber, UserId, Username, OperationTime, Status, IsDryRun,
|
||||||
|
TotalOrders, OrdersProcessed, TotalMaterialsDeleted, TotalMaterialsSkipped,
|
||||||
|
TotalMaterialsFailed, TotalUncertainDeletions, ErrorMessage, AppVersion)
|
||||||
|
VALUES
|
||||||
|
(${dialect.param(0)}, ${dialect.param(1)}, ${dialect.param(2)}, ${dialect.param(3)},
|
||||||
|
${dialect.currentTimestamp()}, 'pending', ${dialect.param(4)},
|
||||||
|
${dialect.param(5)}, 0, 0, 0, 0, 0, NULL, ${dialect.param(6)})
|
||||||
|
`
|
||||||
|
|
||||||
|
const params = [
|
||||||
|
input.batchId,
|
||||||
|
input.attemptNumber,
|
||||||
|
input.userId,
|
||||||
|
input.username,
|
||||||
|
input.isDryRun ? 1 : 0,
|
||||||
|
input.totalOrders,
|
||||||
|
input.appVersion
|
||||||
|
]
|
||||||
|
|
||||||
|
await trackDuration(async () => await dbService.query(sqlString, params), {
|
||||||
|
operationName: 'CleanerOperationHistoryDAO.insertExecution',
|
||||||
|
context: { tableName, operationType: 'INSERT', batchId: input.batchId }
|
||||||
|
})
|
||||||
|
|
||||||
|
log.info('Execution inserted', {
|
||||||
|
tableName,
|
||||||
|
operationType: 'INSERT',
|
||||||
|
requestId,
|
||||||
|
batchId: input.batchId,
|
||||||
|
attemptNumber: input.attemptNumber
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
log.error('Insert execution error', {
|
||||||
|
tableName: this.getExecutionTableName(),
|
||||||
|
operationType: 'INSERT',
|
||||||
|
requestId,
|
||||||
|
batchId: input.batchId,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== EXECUTION UPDATE ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update execution status and counters
|
||||||
|
*/
|
||||||
|
async updateExecutionStatus(
|
||||||
|
batchId: string,
|
||||||
|
attemptNumber: number,
|
||||||
|
status: string,
|
||||||
|
ordersProcessed: number,
|
||||||
|
materialsDeleted: number,
|
||||||
|
materialsSkipped: number,
|
||||||
|
materialsFailed: number,
|
||||||
|
uncertainDeletions: number,
|
||||||
|
endTime: Date,
|
||||||
|
errorMessage?: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const requestId = getRequestId() || `update-exec-${Date.now()}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dbService = await this.getDatabaseService()
|
||||||
|
const tableName = this.getExecutionTableName()
|
||||||
|
const dialect = this.getDialect()
|
||||||
|
|
||||||
|
const sqlString = `
|
||||||
|
UPDATE ${tableName}
|
||||||
|
SET Status = ${dialect.param(0)},
|
||||||
|
OrdersProcessed = ${dialect.param(1)},
|
||||||
|
TotalMaterialsDeleted = ${dialect.param(2)},
|
||||||
|
TotalMaterialsSkipped = ${dialect.param(3)},
|
||||||
|
TotalMaterialsFailed = ${dialect.param(4)},
|
||||||
|
TotalUncertainDeletions = ${dialect.param(5)},
|
||||||
|
EndTime = ${dialect.param(6)},
|
||||||
|
ErrorMessage = ${dialect.param(7)}
|
||||||
|
WHERE BatchId = ${dialect.param(8)}
|
||||||
|
AND AttemptNumber = ${dialect.param(9)}
|
||||||
|
`
|
||||||
|
|
||||||
|
const params = [
|
||||||
|
status,
|
||||||
|
ordersProcessed,
|
||||||
|
materialsDeleted,
|
||||||
|
materialsSkipped,
|
||||||
|
materialsFailed,
|
||||||
|
uncertainDeletions,
|
||||||
|
endTime,
|
||||||
|
errorMessage || null,
|
||||||
|
batchId,
|
||||||
|
attemptNumber
|
||||||
|
]
|
||||||
|
|
||||||
|
await trackDuration(async () => await dbService.query(sqlString, params), {
|
||||||
|
operationName: 'CleanerOperationHistoryDAO.updateExecutionStatus',
|
||||||
|
context: { tableName, operationType: 'UPDATE', batchId, attemptNumber }
|
||||||
|
})
|
||||||
|
|
||||||
|
log.info('Execution status updated', {
|
||||||
|
tableName,
|
||||||
|
operationType: 'UPDATE',
|
||||||
|
requestId,
|
||||||
|
batchId,
|
||||||
|
attemptNumber,
|
||||||
|
status
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
log.error('Update execution status error', {
|
||||||
|
tableName: this.getExecutionTableName(),
|
||||||
|
operationType: 'UPDATE',
|
||||||
|
requestId,
|
||||||
|
batchId,
|
||||||
|
attemptNumber,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== ORDER INSERT ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert order records for a specific execution
|
||||||
|
* @param batchId - Batch identifier
|
||||||
|
* @param attemptNumber - Attempt number
|
||||||
|
* @param orders - Array of order inputs
|
||||||
|
* @returns True if successful
|
||||||
|
*/
|
||||||
|
async insertOrderRecords(
|
||||||
|
batchId: string,
|
||||||
|
attemptNumber: number,
|
||||||
|
orders: InsertOrderInput[]
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!orders || orders.length === 0) {
|
||||||
|
log.warn('No orders to insert', {
|
||||||
|
batchId,
|
||||||
|
attemptNumber,
|
||||||
|
tableName: this.getOrderTableName(),
|
||||||
|
requestId: getRequestId()
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = getRequestId() || `insert-orders-${Date.now()}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dbService = await this.getDatabaseService()
|
||||||
|
const tableName = this.getOrderTableName()
|
||||||
|
const dialect = this.getDialect()
|
||||||
|
|
||||||
|
log.info('Order records insertion started', {
|
||||||
|
tableName,
|
||||||
|
operationType: 'INSERT',
|
||||||
|
requestId,
|
||||||
|
batchId,
|
||||||
|
attemptNumber,
|
||||||
|
orderCount: orders.length
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const order of orders) {
|
||||||
|
try {
|
||||||
|
const status = order.initialStatus || 'pending'
|
||||||
|
const sqlString = `
|
||||||
|
INSERT INTO ${tableName}
|
||||||
|
(BatchId, AttemptNumber, OrderNumber, ProductionId, Status,
|
||||||
|
MaterialsDeleted, MaterialsSkipped, MaterialsFailed,
|
||||||
|
UncertainDeletions, RetryCount, RetrySuccess, ErrorMessage)
|
||||||
|
VALUES
|
||||||
|
(${dialect.param(0)}, ${dialect.param(1)}, ${dialect.param(2)}, ${dialect.param(3)}, ${dialect.param(4)},
|
||||||
|
0, 0, 0, 0, 0, 0, ${dialect.param(5)})
|
||||||
|
`
|
||||||
|
await trackDuration(
|
||||||
|
async () =>
|
||||||
|
await dbService.query(sqlString, [
|
||||||
|
batchId,
|
||||||
|
attemptNumber,
|
||||||
|
order.orderNumber,
|
||||||
|
order.productionId || null,
|
||||||
|
status,
|
||||||
|
order.errorMessage || null
|
||||||
|
]),
|
||||||
|
{
|
||||||
|
operationName: 'CleanerOperationHistoryDAO.insertOrderRecords',
|
||||||
|
context: { tableName, operationType: 'INSERT', batchId, attemptNumber }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
log.error('Error inserting individual order record', {
|
||||||
|
tableName,
|
||||||
|
operationType: 'INSERT',
|
||||||
|
requestId,
|
||||||
|
batchId,
|
||||||
|
attemptNumber,
|
||||||
|
orderNumber: order.orderNumber,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info('Order records inserted', {
|
||||||
|
tableName,
|
||||||
|
operationType: 'INSERT',
|
||||||
|
requestId,
|
||||||
|
batchId,
|
||||||
|
attemptNumber,
|
||||||
|
count: orders.length
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
log.error('Insert order records error', {
|
||||||
|
tableName: this.getOrderTableName(),
|
||||||
|
operationType: 'INSERT',
|
||||||
|
requestId,
|
||||||
|
batchId,
|
||||||
|
attemptNumber,
|
||||||
|
orderCount: orders.length,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== ORDER UPDATE ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update order status and counters
|
||||||
|
*/
|
||||||
|
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> {
|
||||||
|
const requestId = getRequestId() || `update-order-${Date.now()}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dbService = await this.getDatabaseService()
|
||||||
|
const tableName = this.getOrderTableName()
|
||||||
|
const dialect = this.getDialect()
|
||||||
|
|
||||||
|
const sqlString = `
|
||||||
|
UPDATE ${tableName}
|
||||||
|
SET Status = ${dialect.param(0)},
|
||||||
|
MaterialsDeleted = ${dialect.param(1)},
|
||||||
|
MaterialsSkipped = ${dialect.param(2)},
|
||||||
|
MaterialsFailed = ${dialect.param(3)},
|
||||||
|
UncertainDeletions = ${dialect.param(4)},
|
||||||
|
RetryCount = ${dialect.param(5)},
|
||||||
|
RetrySuccess = ${dialect.param(6)},
|
||||||
|
ErrorMessage = ${dialect.param(7)}
|
||||||
|
WHERE BatchId = ${dialect.param(8)}
|
||||||
|
AND AttemptNumber = ${dialect.param(9)}
|
||||||
|
AND OrderNumber = ${dialect.param(10)}
|
||||||
|
`
|
||||||
|
|
||||||
|
const params = [
|
||||||
|
status,
|
||||||
|
materialsDeleted,
|
||||||
|
materialsSkipped,
|
||||||
|
materialsFailed,
|
||||||
|
uncertainDeletions,
|
||||||
|
retryCount,
|
||||||
|
retrySuccess ? 1 : 0,
|
||||||
|
errorMessage || null,
|
||||||
|
batchId,
|
||||||
|
attemptNumber,
|
||||||
|
orderNumber
|
||||||
|
]
|
||||||
|
|
||||||
|
await trackDuration(async () => await dbService.query(sqlString, params), {
|
||||||
|
operationName: 'CleanerOperationHistoryDAO.updateOrderStatus',
|
||||||
|
context: {
|
||||||
|
tableName,
|
||||||
|
operationType: 'UPDATE',
|
||||||
|
batchId,
|
||||||
|
attemptNumber,
|
||||||
|
orderNumber
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
log.info('Order status updated', {
|
||||||
|
tableName,
|
||||||
|
operationType: 'UPDATE',
|
||||||
|
requestId,
|
||||||
|
batchId,
|
||||||
|
attemptNumber,
|
||||||
|
orderNumber,
|
||||||
|
status
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
log.error('Update order status error', {
|
||||||
|
tableName: this.getOrderTableName(),
|
||||||
|
operationType: 'UPDATE',
|
||||||
|
requestId,
|
||||||
|
batchId,
|
||||||
|
attemptNumber,
|
||||||
|
orderNumber,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== MATERIAL INSERT ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert material detail records for a specific order
|
||||||
|
* @param batchId - Batch identifier
|
||||||
|
* @param attemptNumber - Attempt number
|
||||||
|
* @param details - Array of material detail inputs
|
||||||
|
* @returns True if successful
|
||||||
|
*/
|
||||||
|
async insertMaterialDetails(
|
||||||
|
batchId: string,
|
||||||
|
attemptNumber: number,
|
||||||
|
details: InsertMaterialDetailInput[]
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!details || details.length === 0) {
|
||||||
|
log.warn('No material details to insert', {
|
||||||
|
batchId,
|
||||||
|
attemptNumber,
|
||||||
|
tableName: this.getMaterialTableName(),
|
||||||
|
requestId: getRequestId()
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = getRequestId() || `insert-materials-${Date.now()}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dbService = await this.getDatabaseService()
|
||||||
|
const tableName = this.getMaterialTableName()
|
||||||
|
const dialect = this.getDialect()
|
||||||
|
|
||||||
|
log.info('Material details insertion started', {
|
||||||
|
tableName,
|
||||||
|
operationType: 'INSERT',
|
||||||
|
requestId,
|
||||||
|
batchId,
|
||||||
|
attemptNumber,
|
||||||
|
detailCount: details.length
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const detail of details) {
|
||||||
|
try {
|
||||||
|
const sqlString = `
|
||||||
|
INSERT INTO ${tableName}
|
||||||
|
(BatchId, AttemptNumber, OrderNumber, MaterialCode, MaterialName,
|
||||||
|
RowNumber, Result, Reason, AttemptCount, FinalErrorCategory)
|
||||||
|
VALUES
|
||||||
|
(${dialect.param(0)}, ${dialect.param(1)}, ${dialect.param(2)},
|
||||||
|
${dialect.param(3)}, ${dialect.param(4)}, ${dialect.param(5)},
|
||||||
|
${dialect.param(6)}, ${dialect.param(7)}, ${dialect.param(8)},
|
||||||
|
${dialect.param(9)})
|
||||||
|
`
|
||||||
|
await trackDuration(
|
||||||
|
async () =>
|
||||||
|
await dbService.query(sqlString, [
|
||||||
|
batchId,
|
||||||
|
attemptNumber,
|
||||||
|
detail.orderNumber,
|
||||||
|
detail.materialCode,
|
||||||
|
detail.materialName,
|
||||||
|
detail.rowNumber,
|
||||||
|
detail.result,
|
||||||
|
detail.reason || null,
|
||||||
|
detail.attemptCount,
|
||||||
|
detail.finalErrorCategory || null
|
||||||
|
]),
|
||||||
|
{
|
||||||
|
operationName: 'CleanerOperationHistoryDAO.insertMaterialDetails',
|
||||||
|
context: {
|
||||||
|
tableName,
|
||||||
|
operationType: 'INSERT',
|
||||||
|
batchId,
|
||||||
|
attemptNumber,
|
||||||
|
materialCode: detail.materialCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
log.error('Error inserting individual material detail', {
|
||||||
|
tableName,
|
||||||
|
operationType: 'INSERT',
|
||||||
|
requestId,
|
||||||
|
batchId,
|
||||||
|
attemptNumber,
|
||||||
|
orderNumber: detail.orderNumber,
|
||||||
|
materialCode: detail.materialCode,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info('Material details inserted', {
|
||||||
|
tableName,
|
||||||
|
operationType: 'INSERT',
|
||||||
|
requestId,
|
||||||
|
batchId,
|
||||||
|
attemptNumber,
|
||||||
|
count: details.length
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
log.error('Insert material details error', {
|
||||||
|
tableName: this.getMaterialTableName(),
|
||||||
|
operationType: 'INSERT',
|
||||||
|
requestId,
|
||||||
|
batchId,
|
||||||
|
attemptNumber,
|
||||||
|
detailCount: details.length,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== QUERY: GET BATCHES ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get batch statistics with optional user filtering
|
||||||
|
* @param userId - Optional user ID for filtering
|
||||||
|
* @param options - Query options (limit, offset, usernames)
|
||||||
|
* @returns Array of batch statistics
|
||||||
|
*/
|
||||||
|
async getBatches(
|
||||||
|
userId?: number,
|
||||||
|
options?: GetCleanerBatchesOptions
|
||||||
|
): Promise<CleanerBatchStats[]> {
|
||||||
|
try {
|
||||||
|
const dbService = await this.getDatabaseService()
|
||||||
|
const execTable = this.getExecutionTableName()
|
||||||
|
const orderTable = this.getOrderTableName()
|
||||||
|
const dialect = this.getDialect()
|
||||||
|
|
||||||
|
let sqlString = `
|
||||||
|
SELECT
|
||||||
|
e.BatchId,
|
||||||
|
e.UserId,
|
||||||
|
e.Username,
|
||||||
|
MIN(e.OperationTime) as OperationTime,
|
||||||
|
MAX(CASE WHEN e.AttemptNumber = latest.max_attempt THEN e.Status ELSE NULL END) as Status,
|
||||||
|
COUNT(DISTINCT e.AttemptNumber) as TotalAttempts,
|
||||||
|
MAX(e.TotalOrders) as TotalOrders,
|
||||||
|
MAX(CASE WHEN e.AttemptNumber = latest.max_attempt THEN e.OrdersProcessed ELSE 0 END) as OrdersProcessed,
|
||||||
|
MAX(CASE WHEN e.AttemptNumber = latest.max_attempt THEN e.TotalMaterialsDeleted ELSE 0 END) as TotalMaterialsDeleted,
|
||||||
|
MAX(CASE WHEN e.AttemptNumber = latest.max_attempt THEN e.TotalMaterialsFailed ELSE 0 END) as TotalMaterialsFailed,
|
||||||
|
${this.getIsDryRunAggregateSql()} as IsDryRun,
|
||||||
|
COALESCE(SUM(CASE WHEN o.Status = 'success' THEN 1 ELSE 0 END), 0) as SuccessCount,
|
||||||
|
COALESCE(SUM(CASE WHEN o.Status = 'failed' THEN 1 ELSE 0 END), 0) as FailedCount
|
||||||
|
FROM ${execTable} e
|
||||||
|
INNER JOIN (
|
||||||
|
SELECT BatchId, MAX(AttemptNumber) as max_attempt
|
||||||
|
FROM ${execTable}
|
||||||
|
GROUP BY BatchId
|
||||||
|
) latest ON e.BatchId = latest.BatchId
|
||||||
|
LEFT JOIN ${orderTable} o ON e.BatchId = o.BatchId AND e.AttemptNumber = latest.max_attempt
|
||||||
|
`
|
||||||
|
|
||||||
|
const params: (number | string)[] = []
|
||||||
|
|
||||||
|
if (userId !== undefined) {
|
||||||
|
sqlString += ` WHERE e.UserId = ${dialect.param(params.length)} `
|
||||||
|
params.push(userId)
|
||||||
|
} else if (options?.usernames && options.usernames.length > 0) {
|
||||||
|
sqlString += ` WHERE e.Username IN (${dialect.params(options.usernames.length)}) `
|
||||||
|
params.push(...options.usernames)
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlString += `
|
||||||
|
GROUP BY e.BatchId, e.UserId, e.Username, latest.max_attempt
|
||||||
|
ORDER BY OperationTime DESC
|
||||||
|
`
|
||||||
|
|
||||||
|
if (options?.limit) {
|
||||||
|
const safeLimit = Math.floor(options.limit)
|
||||||
|
const safeOffset = options.offset !== undefined ? Math.floor(options.offset) : undefined
|
||||||
|
|
||||||
|
const result = dialect.paginate({
|
||||||
|
sql: sqlString,
|
||||||
|
limit: safeLimit,
|
||||||
|
offset: safeOffset,
|
||||||
|
paramIndex: params.length
|
||||||
|
})
|
||||||
|
sqlString = result.sql
|
||||||
|
|
||||||
|
if (dialect.dbType === 'sqlserver') {
|
||||||
|
if (safeOffset !== undefined) {
|
||||||
|
params.push(safeOffset)
|
||||||
|
}
|
||||||
|
params.push(safeLimit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await trackDuration(async () => await dbService.query(sqlString, params), {
|
||||||
|
operationName: 'CleanerOperationHistoryDAO.getBatches',
|
||||||
|
context: { operationType: 'SELECT', userId }
|
||||||
|
})
|
||||||
|
|
||||||
|
return result.result.rows.map((row) => ({
|
||||||
|
batchId: row.BatchId as string,
|
||||||
|
userId: row.UserId as number,
|
||||||
|
username: row.Username as string,
|
||||||
|
operationTime: formatDateTime(row.OperationTime),
|
||||||
|
status: row.Status as string,
|
||||||
|
totalAttempts: row.TotalAttempts as number,
|
||||||
|
totalOrders: (row.TotalOrders as number) || 0,
|
||||||
|
ordersProcessed: (row.OrdersProcessed as number) || 0,
|
||||||
|
totalMaterialsDeleted: (row.TotalMaterialsDeleted as number) || 0,
|
||||||
|
totalMaterialsFailed: (row.TotalMaterialsFailed as number) || 0,
|
||||||
|
successCount: (row.SuccessCount as number) || 0,
|
||||||
|
failedCount: (row.FailedCount as number) || 0,
|
||||||
|
isDryRun: !!(row.IsDryRun as number)
|
||||||
|
}))
|
||||||
|
} catch (error) {
|
||||||
|
log.error('Get batches error', {
|
||||||
|
operationType: 'SELECT',
|
||||||
|
requestId: getRequestId(),
|
||||||
|
userId,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
})
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== QUERY: GET BATCH DETAILS ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get detailed records for a specific batch
|
||||||
|
* @param batchId - Batch identifier
|
||||||
|
* @returns Object with executions and orders arrays
|
||||||
|
*/
|
||||||
|
async getBatchDetails(
|
||||||
|
batchId: string
|
||||||
|
): Promise<{ executions: CleanerExecutionRecord[]; orders: CleanerOrderRecord[] }> {
|
||||||
|
const requestId = getRequestId() || `details-${Date.now()}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dbService = await this.getDatabaseService()
|
||||||
|
const execTable = this.getExecutionTableName()
|
||||||
|
const orderTable = this.getOrderTableName()
|
||||||
|
const dialect = this.getDialect()
|
||||||
|
|
||||||
|
// Query executions
|
||||||
|
const execSql = `
|
||||||
|
SELECT
|
||||||
|
ID, BatchId, AttemptNumber, UserId, Username,
|
||||||
|
OperationTime, EndTime, Status, IsDryRun,
|
||||||
|
TotalOrders, OrdersProcessed,
|
||||||
|
TotalMaterialsDeleted, TotalMaterialsSkipped, TotalMaterialsFailed,
|
||||||
|
TotalUncertainDeletions, ErrorMessage, AppVersion
|
||||||
|
FROM ${execTable}
|
||||||
|
WHERE BatchId = ${dialect.param(0)}
|
||||||
|
ORDER BY AttemptNumber
|
||||||
|
`
|
||||||
|
|
||||||
|
const execResult = await trackDuration(
|
||||||
|
async () => await dbService.query(execSql, [batchId]),
|
||||||
|
{
|
||||||
|
operationName: 'CleanerOperationHistoryDAO.getBatchDetails.executions',
|
||||||
|
context: { operationType: 'SELECT', batchId }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Query orders
|
||||||
|
const orderSql = `
|
||||||
|
SELECT
|
||||||
|
ID, BatchId, AttemptNumber, OrderNumber, ProductionId, Status,
|
||||||
|
MaterialsDeleted, MaterialsSkipped, MaterialsFailed,
|
||||||
|
UncertainDeletions, RetryCount, RetrySuccess, ErrorMessage
|
||||||
|
FROM ${orderTable}
|
||||||
|
WHERE BatchId = ${dialect.param(0)}
|
||||||
|
ORDER BY AttemptNumber, ID
|
||||||
|
`
|
||||||
|
|
||||||
|
const orderResult = await trackDuration(
|
||||||
|
async () => await dbService.query(orderSql, [batchId]),
|
||||||
|
{
|
||||||
|
operationName: 'CleanerOperationHistoryDAO.getBatchDetails.orders',
|
||||||
|
context: { operationType: 'SELECT', batchId }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const executions: CleanerExecutionRecord[] = execResult.result.rows.map((row) => ({
|
||||||
|
id: row.ID as number,
|
||||||
|
batchId: row.BatchId as string,
|
||||||
|
attemptNumber: row.AttemptNumber as number,
|
||||||
|
userId: row.UserId as number,
|
||||||
|
username: row.Username as string,
|
||||||
|
operationTime:
|
||||||
|
row.OperationTime instanceof Date
|
||||||
|
? row.OperationTime
|
||||||
|
: new Date(row.OperationTime as string),
|
||||||
|
endTime: row.EndTime
|
||||||
|
? row.EndTime instanceof Date
|
||||||
|
? row.EndTime
|
||||||
|
: new Date(row.EndTime as string)
|
||||||
|
: null,
|
||||||
|
status: row.Status as string,
|
||||||
|
isDryRun: !!(row.IsDryRun as number),
|
||||||
|
totalOrders: row.TotalOrders as number,
|
||||||
|
ordersProcessed: row.OrdersProcessed as number,
|
||||||
|
totalMaterialsDeleted: row.TotalMaterialsDeleted as number,
|
||||||
|
totalMaterialsSkipped: row.TotalMaterialsSkipped as number,
|
||||||
|
totalMaterialsFailed: row.TotalMaterialsFailed as number,
|
||||||
|
totalUncertainDeletions: row.TotalUncertainDeletions as number,
|
||||||
|
errorMessage: row.ErrorMessage as string | null,
|
||||||
|
appVersion: row.AppVersion as string | null
|
||||||
|
}))
|
||||||
|
|
||||||
|
const orders: CleanerOrderRecord[] = orderResult.result.rows.map((row) => ({
|
||||||
|
id: row.ID as number,
|
||||||
|
batchId: row.BatchId as string,
|
||||||
|
attemptNumber: row.AttemptNumber as number,
|
||||||
|
orderNumber: row.OrderNumber as string,
|
||||||
|
productionId: (row.ProductionId as string) || null,
|
||||||
|
status: row.Status as string,
|
||||||
|
materialsDeleted: row.MaterialsDeleted as number,
|
||||||
|
materialsSkipped: row.MaterialsSkipped as number,
|
||||||
|
materialsFailed: row.MaterialsFailed as number,
|
||||||
|
uncertainDeletions: row.UncertainDeletions as number,
|
||||||
|
retryCount: row.RetryCount as number,
|
||||||
|
retrySuccess: !!(row.RetrySuccess as number),
|
||||||
|
errorMessage: row.ErrorMessage as string | null
|
||||||
|
}))
|
||||||
|
|
||||||
|
log.info('Batch details retrieved', {
|
||||||
|
operationType: 'SELECT',
|
||||||
|
requestId,
|
||||||
|
batchId,
|
||||||
|
executionCount: executions.length,
|
||||||
|
orderCount: orders.length
|
||||||
|
})
|
||||||
|
|
||||||
|
return { executions, orders }
|
||||||
|
} catch (error) {
|
||||||
|
log.error('Get batch details error', {
|
||||||
|
operationType: 'SELECT',
|
||||||
|
requestId,
|
||||||
|
batchId,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
})
|
||||||
|
return { executions: [], orders: [] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== QUERY: GET MATERIAL DETAILS ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get material details for a specific order in an attempt
|
||||||
|
* @param batchId - Batch identifier
|
||||||
|
* @param attemptNumber - Attempt number
|
||||||
|
* @param orderNumber - Order number
|
||||||
|
* @returns Array of material records
|
||||||
|
*/
|
||||||
|
async getMaterialDetails(
|
||||||
|
batchId: string,
|
||||||
|
attemptNumber: number,
|
||||||
|
orderNumber: string
|
||||||
|
): Promise<CleanerMaterialRecord[]> {
|
||||||
|
try {
|
||||||
|
const dbService = await this.getDatabaseService()
|
||||||
|
const tableName = this.getMaterialTableName()
|
||||||
|
const dialect = this.getDialect()
|
||||||
|
|
||||||
|
const sqlString = `
|
||||||
|
SELECT
|
||||||
|
ID, BatchId, AttemptNumber, OrderNumber,
|
||||||
|
MaterialCode, MaterialName, RowNumber,
|
||||||
|
Result, Reason, AttemptCount, FinalErrorCategory
|
||||||
|
FROM ${tableName}
|
||||||
|
WHERE BatchId = ${dialect.param(0)}
|
||||||
|
AND AttemptNumber = ${dialect.param(1)}
|
||||||
|
AND OrderNumber = ${dialect.param(2)}
|
||||||
|
`
|
||||||
|
|
||||||
|
const result = await trackDuration(
|
||||||
|
async () => await dbService.query(sqlString, [batchId, attemptNumber, orderNumber]),
|
||||||
|
{
|
||||||
|
operationName: 'CleanerOperationHistoryDAO.getMaterialDetails',
|
||||||
|
context: { operationType: 'SELECT', batchId, attemptNumber, orderNumber }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return result.result.rows.map((row) => ({
|
||||||
|
id: row.ID as number,
|
||||||
|
batchId: row.BatchId as string,
|
||||||
|
attemptNumber: row.AttemptNumber as number,
|
||||||
|
orderNumber: row.OrderNumber as string,
|
||||||
|
materialCode: row.MaterialCode as string,
|
||||||
|
materialName: row.MaterialName as string,
|
||||||
|
rowNumber: row.RowNumber as number,
|
||||||
|
result: row.Result as string,
|
||||||
|
reason: row.Reason as string | null,
|
||||||
|
attemptCount: row.AttemptCount as number,
|
||||||
|
finalErrorCategory: row.FinalErrorCategory as string | null
|
||||||
|
}))
|
||||||
|
} catch (error) {
|
||||||
|
log.error('Get material details error', {
|
||||||
|
tableName: this.getMaterialTableName(),
|
||||||
|
operationType: 'SELECT',
|
||||||
|
requestId: getRequestId(),
|
||||||
|
batchId,
|
||||||
|
attemptNumber,
|
||||||
|
orderNumber,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
})
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== DELETE ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a batch with permission checking across all three tables
|
||||||
|
* @param batchId - Batch identifier
|
||||||
|
* @param requestingUserId - User ID requesting the deletion
|
||||||
|
* @param isAdmin - Whether the requesting user is an admin
|
||||||
|
* @returns Result with success flag and optional error message
|
||||||
|
*/
|
||||||
|
async deleteBatch(
|
||||||
|
batchId: string,
|
||||||
|
requestingUserId: number,
|
||||||
|
isAdmin: boolean
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const requestId = getRequestId() || `delete-${Date.now()}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dbService = await this.getDatabaseService()
|
||||||
|
const execTable = this.getExecutionTableName()
|
||||||
|
const orderTable = this.getOrderTableName()
|
||||||
|
const materialTable = this.getMaterialTableName()
|
||||||
|
const dialect = this.getDialect()
|
||||||
|
|
||||||
|
const details = await this.getBatchDetails(batchId)
|
||||||
|
if (details.executions.length === 0) {
|
||||||
|
return { success: false, error: '批次不存在' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Permission check: non-admin can only delete own batches
|
||||||
|
const batchUserId = details.executions[0].userId
|
||||||
|
if (!isAdmin && batchUserId !== requestingUserId) {
|
||||||
|
return { success: false, error: '没有权限删除此批次' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete from CleanerMaterialDetail first (potential FK)
|
||||||
|
const deleteMaterialSql = `
|
||||||
|
DELETE FROM ${materialTable}
|
||||||
|
WHERE BatchId = ${dialect.param(0)}
|
||||||
|
`
|
||||||
|
await trackDuration(async () => await dbService.query(deleteMaterialSql, [batchId]), {
|
||||||
|
operationName: 'CleanerOperationHistoryDAO.deleteBatch.materials',
|
||||||
|
context: { operationType: 'DELETE', batchId }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Delete from CleanerOrderHistory
|
||||||
|
const deleteOrderSql = `
|
||||||
|
DELETE FROM ${orderTable}
|
||||||
|
WHERE BatchId = ${dialect.param(0)}
|
||||||
|
`
|
||||||
|
await trackDuration(async () => await dbService.query(deleteOrderSql, [batchId]), {
|
||||||
|
operationName: 'CleanerOperationHistoryDAO.deleteBatch.orders',
|
||||||
|
context: { operationType: 'DELETE', batchId }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Delete from CleanerExecution
|
||||||
|
const deleteExecSql = `
|
||||||
|
DELETE FROM ${execTable}
|
||||||
|
WHERE BatchId = ${dialect.param(0)}
|
||||||
|
`
|
||||||
|
const deleteResult = await trackDuration(
|
||||||
|
async () => await dbService.query(deleteExecSql, [batchId]),
|
||||||
|
{
|
||||||
|
operationName: 'CleanerOperationHistoryDAO.deleteBatch.executions',
|
||||||
|
context: { operationType: 'DELETE', batchId, requestingUserId }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
log.info('Batch deleted', {
|
||||||
|
operationType: 'DELETE',
|
||||||
|
requestId,
|
||||||
|
batchId,
|
||||||
|
rowCount: deleteResult.result.rowCount
|
||||||
|
})
|
||||||
|
return { success: true }
|
||||||
|
} catch (error) {
|
||||||
|
log.error('Delete batch error', {
|
||||||
|
operationType: 'DELETE',
|
||||||
|
requestId,
|
||||||
|
batchId,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== UTILITIES ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disconnect from database
|
||||||
|
*/
|
||||||
|
async disconnect(): Promise<void> {
|
||||||
|
if (this.dbService) {
|
||||||
|
await this.dbService.disconnect()
|
||||||
|
this.dbService = null
|
||||||
|
this.dialect = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
113
src/main/types/cleaner-history.types.ts
Normal file
113
src/main/types/cleaner-history.types.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
// 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[]
|
||||||
|
}
|
||||||
@@ -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,12 @@ import type {
|
|||||||
ExportResultItem,
|
ExportResultItem,
|
||||||
ExportResultResponse
|
ExportResultResponse
|
||||||
} from './cleaner.types'
|
} from './cleaner.types'
|
||||||
|
import type {
|
||||||
|
CleanerBatchStats,
|
||||||
|
CleanerExecutionRecord,
|
||||||
|
CleanerOrderRecord,
|
||||||
|
CleanerMaterialRecord
|
||||||
|
} from './cleaner-history.types'
|
||||||
import type { IpcResult } from './ipc.types'
|
import type { IpcResult } from './ipc.types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -113,6 +119,45 @@ 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 }>>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,6 +3,14 @@ import type {
|
|||||||
CleanerProgress,
|
CleanerProgress,
|
||||||
ExportResultItem
|
ExportResultItem
|
||||||
} from '../../main/types/cleaner.types'
|
} from '../../main/types/cleaner.types'
|
||||||
|
import type {
|
||||||
|
CleanerBatchStats,
|
||||||
|
CleanerExecutionRecord,
|
||||||
|
CleanerOrderRecord,
|
||||||
|
CleanerMaterialRecord,
|
||||||
|
GetCleanerBatchesOptions
|
||||||
|
} 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 +23,35 @@ 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)
|
||||||
} 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
|
||||||
|
|||||||
890
src/renderer/src/components/CleanerOperationHistoryModal.tsx
Normal file
890
src/renderer/src/components/CleanerOperationHistoryModal.tsx
Normal file
@@ -0,0 +1,890 @@
|
|||||||
|
/**
|
||||||
|
* Cleaner Operation History Modal
|
||||||
|
*
|
||||||
|
* Displays cleaner operation history with batch statistics, execution records,
|
||||||
|
* order details, and material-level details.
|
||||||
|
* Admin users see all users' records, regular users see only their own.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback, useTransition } from 'react'
|
||||||
|
import { Modal } from './ui/Modal'
|
||||||
|
import { ConfirmDialog } from './ui/ConfirmDialog'
|
||||||
|
import { useConfirmDialog } from './ui/useConfirmDialog'
|
||||||
|
import { useLogger } from '../hooks/useLogger'
|
||||||
|
import {
|
||||||
|
RefreshCw,
|
||||||
|
Trash2,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
|
Copy,
|
||||||
|
FlaskConical
|
||||||
|
} from 'lucide-react'
|
||||||
|
import type { UserInfo } from './UserSelectionDialog'
|
||||||
|
import type {
|
||||||
|
CleanerHistoryBatchStats,
|
||||||
|
CleanerHistoryOrderRecord,
|
||||||
|
CleanerHistoryMaterialRecord
|
||||||
|
} from '../hooks/cleaner/types'
|
||||||
|
import {
|
||||||
|
canStartHistoryLoad,
|
||||||
|
getNextHistoryLoadState,
|
||||||
|
type HistoryLoadState
|
||||||
|
} from './cleaner-history-load-state'
|
||||||
|
import {
|
||||||
|
getCleanerHistoryStatusDisplay,
|
||||||
|
getCleanerMaterialResultDisplay
|
||||||
|
} from './cleaner-history-status'
|
||||||
|
|
||||||
|
// The preload API returns Date for time fields, but IPC serialization converts them to strings.
|
||||||
|
// Use a local type that accommodates both to satisfy TypeScript.
|
||||||
|
interface ExecutionRecord {
|
||||||
|
id?: number
|
||||||
|
batchId: string
|
||||||
|
attemptNumber: number
|
||||||
|
userId: number
|
||||||
|
username: string
|
||||||
|
operationTime: string | Date
|
||||||
|
endTime: string | Date | null
|
||||||
|
status: string
|
||||||
|
isDryRun: boolean
|
||||||
|
totalOrders: number
|
||||||
|
ordersProcessed: number
|
||||||
|
totalMaterialsDeleted: number
|
||||||
|
totalMaterialsSkipped: number
|
||||||
|
totalMaterialsFailed: number
|
||||||
|
totalUncertainDeletions: number
|
||||||
|
errorMessage: string | null
|
||||||
|
appVersion: string | null
|
||||||
|
}
|
||||||
|
import { showSuccess, showError, showWarning } from '../stores/useAppStore'
|
||||||
|
|
||||||
|
interface CleanerOperationHistoryModalProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
user?: { username: string; userType: string } | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BatchItemProps {
|
||||||
|
batch: CleanerHistoryBatchStats
|
||||||
|
isAdmin: boolean
|
||||||
|
onDelete: (batchId: string) => void
|
||||||
|
onRequestDelete: (batchId: string) => Promise<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
const BATCH_PAGE_SIZE = 5
|
||||||
|
|
||||||
|
const formatDateTime = (dateStr: string | Date | null | undefined): string => {
|
||||||
|
if (!dateStr) return '-'
|
||||||
|
const date = typeof dateStr === 'string' ? new Date(dateStr) : dateStr
|
||||||
|
|
||||||
|
if (isNaN(date.getTime())) {
|
||||||
|
return String(dateStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use local time instead of UTC for display
|
||||||
|
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}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDuration = (startTime: string | Date | null, endTime: string | Date | null) => {
|
||||||
|
if (!startTime || !endTime) return '-'
|
||||||
|
const start = typeof startTime === 'string' ? new Date(startTime) : startTime
|
||||||
|
const end = typeof endTime === 'string' ? new Date(endTime) : endTime
|
||||||
|
if (isNaN(start.getTime()) || isNaN(end.getTime())) return '-'
|
||||||
|
|
||||||
|
const diffMs = end.getTime() - start.getTime()
|
||||||
|
const seconds = Math.floor(diffMs / 1000)
|
||||||
|
if (seconds < 60) return `${seconds}秒`
|
||||||
|
|
||||||
|
const minutes = Math.floor(seconds / 60)
|
||||||
|
const remainSeconds = seconds % 60
|
||||||
|
if (minutes < 60) return `${minutes}分${remainSeconds}秒`
|
||||||
|
|
||||||
|
const hours = Math.floor(minutes / 60)
|
||||||
|
const remainMinutes = minutes % 60
|
||||||
|
return `${hours}时${remainMinutes}分`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== BatchItem Component ======
|
||||||
|
// Extracted from the modal so that expanding one batch doesn't re-render siblings.
|
||||||
|
// Each BatchItem manages its own details, orders, and material state locally.
|
||||||
|
const BatchItem = React.memo(({ batch, isAdmin, onDelete, onRequestDelete }: BatchItemProps) => {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false)
|
||||||
|
const [executions, setExecutions] = useState<ExecutionRecord[]>([])
|
||||||
|
const [orders, setOrders] = useState<CleanerHistoryOrderRecord[]>([])
|
||||||
|
const [currentAttempt, setCurrentAttempt] = useState<number | undefined>()
|
||||||
|
const [expandedOrders, setExpandedOrders] = useState<Set<string>>(() => new Set())
|
||||||
|
const [orderMaterials, setOrderMaterials] = useState<Map<string, CleanerHistoryMaterialRecord[]>>(
|
||||||
|
() => new Map()
|
||||||
|
)
|
||||||
|
const [loadingMaterials, setLoadingMaterials] = useState<Set<string>>(() => new Set())
|
||||||
|
const [materialLoadStates, setMaterialLoadStates] = useState<Map<string, HistoryLoadState>>(
|
||||||
|
() => new Map()
|
||||||
|
)
|
||||||
|
const [detailsLoadState, setDetailsLoadState] = useState<HistoryLoadState>('idle')
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false)
|
||||||
|
|
||||||
|
const logger = useLogger('BatchItem')
|
||||||
|
|
||||||
|
const fetchDetails = useCallback(async () => {
|
||||||
|
if (!canStartHistoryLoad(detailsLoadState)) return
|
||||||
|
|
||||||
|
setDetailsLoadState((prev) => getNextHistoryLoadState(prev, 'start'))
|
||||||
|
try {
|
||||||
|
const result = await window.electron.cleaner.getHistoryBatchDetails(batch.batchId)
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setExecutions(result.data.executions)
|
||||||
|
setOrders(result.data.orders)
|
||||||
|
|
||||||
|
const execs = result.data.executions
|
||||||
|
if (execs.length > 0) {
|
||||||
|
setCurrentAttempt(Math.max(...execs.map((e) => e.attemptNumber)))
|
||||||
|
}
|
||||||
|
|
||||||
|
setDetailsLoadState('success')
|
||||||
|
} else {
|
||||||
|
setDetailsLoadState('error')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setDetailsLoadState('error')
|
||||||
|
logger.error('Failed to fetch batch details', {
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
batchId: batch.batchId
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [batch.batchId, detailsLoadState, logger])
|
||||||
|
|
||||||
|
const fetchMaterials = useCallback(
|
||||||
|
async (attemptNumber: number, orderNumber: string) => {
|
||||||
|
const cacheKey = `${attemptNumber}:${orderNumber}`
|
||||||
|
const loadState = materialLoadStates.get(cacheKey) ?? 'idle'
|
||||||
|
if (!canStartHistoryLoad(loadState)) return
|
||||||
|
|
||||||
|
setMaterialLoadStates((prev) => {
|
||||||
|
const next = new Map(prev)
|
||||||
|
next.set(cacheKey, getNextHistoryLoadState(prev.get(cacheKey) ?? 'idle', 'start'))
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
setLoadingMaterials((prev) => new Set(prev).add(cacheKey))
|
||||||
|
try {
|
||||||
|
const result = await window.electron.cleaner.getHistoryMaterialDetails(
|
||||||
|
batch.batchId,
|
||||||
|
attemptNumber,
|
||||||
|
orderNumber
|
||||||
|
)
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setOrderMaterials((prev) => new Map(prev).set(cacheKey, result.data!))
|
||||||
|
setMaterialLoadStates((prev) => {
|
||||||
|
const next = new Map(prev)
|
||||||
|
next.set(cacheKey, 'success')
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setMaterialLoadStates((prev) => {
|
||||||
|
const next = new Map(prev)
|
||||||
|
next.set(cacheKey, 'error')
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setMaterialLoadStates((prev) => {
|
||||||
|
const next = new Map(prev)
|
||||||
|
next.set(cacheKey, 'error')
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
logger.error('Failed to fetch material details', {
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
batchId: batch.batchId,
|
||||||
|
attemptNumber,
|
||||||
|
orderNumber
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setLoadingMaterials((prev) => {
|
||||||
|
const newSet = new Set(prev)
|
||||||
|
newSet.delete(cacheKey)
|
||||||
|
return newSet
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[batch.batchId, logger, materialLoadStates]
|
||||||
|
)
|
||||||
|
|
||||||
|
const toggleBatchExpansion = () => {
|
||||||
|
const nextExpanded = !isExpanded
|
||||||
|
setIsExpanded(nextExpanded)
|
||||||
|
if (nextExpanded) {
|
||||||
|
void fetchDetails()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleOrderExpansion = (attemptNumber: number, orderNumber: string) => {
|
||||||
|
const cacheKey = `${attemptNumber}:${orderNumber}`
|
||||||
|
const isCurrentlyExpanded = expandedOrders.has(cacheKey)
|
||||||
|
setExpandedOrders((prev) => {
|
||||||
|
const newSet = new Set(prev)
|
||||||
|
if (newSet.has(cacheKey)) {
|
||||||
|
newSet.delete(cacheKey)
|
||||||
|
} else {
|
||||||
|
newSet.add(cacheKey)
|
||||||
|
}
|
||||||
|
return newSet
|
||||||
|
})
|
||||||
|
if (!isCurrentlyExpanded) {
|
||||||
|
void fetchMaterials(attemptNumber, orderNumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (isDeleting) return
|
||||||
|
const confirmed = await onRequestDelete(batch.batchId)
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
setIsDeleting(true)
|
||||||
|
try {
|
||||||
|
const result = await window.electron.cleaner.deleteHistoryBatch(batch.batchId)
|
||||||
|
if (result.success) {
|
||||||
|
onDelete(batch.batchId)
|
||||||
|
} else {
|
||||||
|
alert(result.error || '删除失败')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert(err instanceof Error ? err.message : '删除失败')
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredOrders =
|
||||||
|
currentAttempt !== undefined ? orders.filter((order) => order.attemptNumber === currentAttempt) : orders
|
||||||
|
const visibleExecutions =
|
||||||
|
currentAttempt !== undefined
|
||||||
|
? executions.filter((execution) => execution.attemptNumber === currentAttempt)
|
||||||
|
: executions
|
||||||
|
const batchStatusDisplay = getCleanerHistoryStatusDisplay(batch.status)
|
||||||
|
|
||||||
|
const handleCopyColumn = (field: keyof CleanerHistoryOrderRecord) => {
|
||||||
|
const values = filteredOrders
|
||||||
|
.map((o) => String(o[field] ?? ''))
|
||||||
|
.filter((v) => v && v !== '-')
|
||||||
|
.join('\n')
|
||||||
|
|
||||||
|
if (!values) {
|
||||||
|
showWarning('没有可复制的数据')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
navigator.clipboard
|
||||||
|
.writeText(values)
|
||||||
|
.then(() => showSuccess(`已复制 ${values.split('\n').length} 条数据`))
|
||||||
|
.catch(() => showError('复制失败,请手动复制'))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||||||
|
{/* Batch summary */}
|
||||||
|
<div
|
||||||
|
className={`flex items-center justify-between p-4 cursor-pointer transition-colors ${
|
||||||
|
isExpanded ? 'bg-gray-50' : 'hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
onClick={toggleBatchExpansion}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4 flex-1">
|
||||||
|
<button className="p-1 hover:bg-gray-200 rounded">
|
||||||
|
{isExpanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex-1 grid grid-cols-7 gap-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<div className="text-gray-500 text-xs">操作时间</div>
|
||||||
|
<div className="font-medium text-gray-900">{formatDateTime(batch.operationTime)}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-gray-500 text-xs">操作用户</div>
|
||||||
|
<div className="font-medium text-gray-900">{batch.username}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-gray-500 text-xs">状态</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{batchStatusDisplay.icon}
|
||||||
|
<span
|
||||||
|
className={`px-2 py-0.5 rounded text-xs font-medium ${batchStatusDisplay.badgeClassName}`}
|
||||||
|
>
|
||||||
|
{batchStatusDisplay.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-gray-500 text-xs">订单数</div>
|
||||||
|
<div className="font-medium text-gray-900">{batch.totalOrders}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-gray-500 text-xs">已删除</div>
|
||||||
|
<div className="font-medium text-green-600">{batch.totalMaterialsDeleted}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-gray-500 text-xs">失败</div>
|
||||||
|
<div className="font-medium text-gray-900">
|
||||||
|
{batch.totalMaterialsFailed > 0 ? (
|
||||||
|
<span className="text-red-600">{batch.totalMaterialsFailed}</span>
|
||||||
|
) : (
|
||||||
|
'0'
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{batch.isDryRun && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-700">
|
||||||
|
<FlaskConical size={12} />
|
||||||
|
试运行
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{batch.totalAttempts > 1 && (
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-700">
|
||||||
|
{batch.totalAttempts}次尝试
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isAdmin && (
|
||||||
|
<button
|
||||||
|
className="p-2 hover:bg-red-50 text-gray-400 hover:text-red-600 rounded transition-colors disabled:opacity-50"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
void handleDelete()
|
||||||
|
}}
|
||||||
|
disabled={isDeleting}
|
||||||
|
title="删除批次"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} className={isDeleting ? 'animate-pulse' : ''} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Batch details */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="border-t border-gray-200 bg-white">
|
||||||
|
{/* Execution records */}
|
||||||
|
{executions.length > 0 && (
|
||||||
|
<div className="px-4 py-3 bg-gray-50/50 border-b border-gray-100">
|
||||||
|
<div className="text-xs font-medium text-gray-500 mb-2">执行记录</div>
|
||||||
|
{executions.length > 1 && (
|
||||||
|
<div className="flex gap-1 mb-2">
|
||||||
|
{executions.map((exec) => (
|
||||||
|
<button
|
||||||
|
key={exec.attemptNumber}
|
||||||
|
className={`px-3 py-1 rounded text-xs font-medium transition-colors ${
|
||||||
|
currentAttempt === exec.attemptNumber
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||||
|
}`}
|
||||||
|
onClick={() => setCurrentAttempt(exec.attemptNumber)}
|
||||||
|
>
|
||||||
|
第{exec.attemptNumber}次
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap gap-4 text-xs text-gray-600">
|
||||||
|
{visibleExecutions.map((exec) => (
|
||||||
|
<React.Fragment key={exec.attemptNumber}>
|
||||||
|
<span>耗时:{formatDuration(exec.operationTime, exec.endTime)}</span>
|
||||||
|
<span>
|
||||||
|
订单:{exec.ordersProcessed}/{exec.totalOrders}
|
||||||
|
</span>
|
||||||
|
<span>删除:{exec.totalMaterialsDeleted}</span>
|
||||||
|
{exec.totalMaterialsFailed > 0 && (
|
||||||
|
<span className="text-red-600">失败:{exec.totalMaterialsFailed}</span>
|
||||||
|
)}
|
||||||
|
{exec.totalUncertainDeletions > 0 && (
|
||||||
|
<span className="text-amber-600">
|
||||||
|
不确定:{exec.totalUncertainDeletions}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{exec.errorMessage && (
|
||||||
|
<span className="text-red-600" title={exec.errorMessage}>
|
||||||
|
错误:{exec.errorMessage.substring(0, 80)}
|
||||||
|
{exec.errorMessage.length > 80 ? '...' : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{exec.appVersion && <span className="text-gray-400">v{exec.appVersion}</span>}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Order table */}
|
||||||
|
{detailsLoadState === 'loading' && executions.length === 0 && orders.length === 0 ? (
|
||||||
|
<div className="px-4 py-6 text-center text-sm text-gray-500">加载详情中...</div>
|
||||||
|
) : detailsLoadState === 'error' && executions.length === 0 && orders.length === 0 ? (
|
||||||
|
<div className="px-4 py-6 text-center text-sm text-red-600">
|
||||||
|
加载详情失败,请折叠后重新展开重试
|
||||||
|
</div>
|
||||||
|
) : filteredOrders.length > 0 ? (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-2 text-left font-medium text-gray-600 w-8" />
|
||||||
|
<th className="px-4 py-2 text-left font-medium text-gray-600 w-12 text-center">
|
||||||
|
序号
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2 text-left font-medium text-gray-600">总排号</th>
|
||||||
|
<th className="px-4 py-2 text-left font-medium text-gray-600">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
订单号
|
||||||
|
<button
|
||||||
|
className="p-1 hover:bg-gray-200 rounded transition-colors"
|
||||||
|
onClick={() => handleCopyColumn('orderNumber')}
|
||||||
|
title="复制所有订单号"
|
||||||
|
>
|
||||||
|
<Copy size={14} className="text-gray-500 hover:text-gray-700" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2 text-left font-medium text-gray-600">状态</th>
|
||||||
|
<th className="px-4 py-2 text-left font-medium text-gray-600">重试</th>
|
||||||
|
<th className="px-4 py-2 text-left font-medium text-gray-600">已删除</th>
|
||||||
|
<th className="px-4 py-2 text-left font-medium text-gray-600">已跳过</th>
|
||||||
|
<th className="px-4 py-2 text-left font-medium text-gray-600">失败</th>
|
||||||
|
<th className="px-4 py-2 text-left font-medium text-gray-600">不确定</th>
|
||||||
|
<th className="px-4 py-2 text-left font-medium text-gray-600">错误信息</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{filteredOrders.map((order, index) => {
|
||||||
|
const orderKey = `${currentAttempt ?? order.attemptNumber}:${order.orderNumber}`
|
||||||
|
const isOrderExpanded = expandedOrders.has(orderKey)
|
||||||
|
const materials = orderMaterials.get(orderKey) || []
|
||||||
|
const isLoadingMaterials = loadingMaterials.has(orderKey)
|
||||||
|
const materialLoadState = materialLoadStates.get(orderKey) ?? 'idle'
|
||||||
|
const orderStatusDisplay = getCleanerHistoryStatusDisplay(order.status)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key={orderKey}>
|
||||||
|
<tr
|
||||||
|
className="hover:bg-gray-50 cursor-pointer"
|
||||||
|
onClick={() =>
|
||||||
|
toggleOrderExpansion(
|
||||||
|
currentAttempt ?? order.attemptNumber,
|
||||||
|
order.orderNumber
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
{isOrderExpanded ? (
|
||||||
|
<ChevronDown size={14} className="text-gray-400" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight size={14} className="text-gray-400" />
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-gray-500 font-medium text-xs text-center">
|
||||||
|
{index + 1}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-gray-900 font-mono text-xs">
|
||||||
|
{order.productionId || '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-gray-900 font-mono text-xs">
|
||||||
|
{order.status === 'not_found' ? '-' : order.orderNumber}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium ${orderStatusDisplay.badgeClassName}`}
|
||||||
|
>
|
||||||
|
{orderStatusDisplay.icon}
|
||||||
|
{orderStatusDisplay.label}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
{order.retryCount > 0 ? (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-700">
|
||||||
|
<RefreshCw size={10} />
|
||||||
|
重试{order.retryCount}次
|
||||||
|
</span>
|
||||||
|
{order.retrySuccess && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-700">
|
||||||
|
<CheckCircle size={10} />
|
||||||
|
成功
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!order.retrySuccess && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-700">
|
||||||
|
<XCircle size={10} />
|
||||||
|
失败
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400 text-xs">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-green-600">{order.materialsDeleted}</td>
|
||||||
|
<td className="px-4 py-2 text-gray-500">{order.materialsSkipped}</td>
|
||||||
|
<td className="px-4 py-2 text-red-600">{order.materialsFailed || '-'}</td>
|
||||||
|
<td className="px-4 py-2 text-amber-600">
|
||||||
|
{order.uncertainDeletions || '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-red-600 text-xs max-w-xs truncate">
|
||||||
|
{order.errorMessage || '-'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{/* Material details */}
|
||||||
|
{isOrderExpanded && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={11} className="bg-gray-50/50 px-8 py-3">
|
||||||
|
{isLoadingMaterials ? (
|
||||||
|
<div className="text-xs text-gray-500">加载物料详情...</div>
|
||||||
|
) : materialLoadState === 'error' ? (
|
||||||
|
<div className="text-xs text-red-600">
|
||||||
|
加载物料详情失败,请折叠后重新展开重试
|
||||||
|
</div>
|
||||||
|
) : materials.length > 0 ? (
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-gray-500">
|
||||||
|
<th className="px-3 py-1.5 text-left font-medium w-12 text-center">
|
||||||
|
序号
|
||||||
|
</th>
|
||||||
|
<th className="px-3 py-1.5 text-left font-medium">
|
||||||
|
物料编码
|
||||||
|
</th>
|
||||||
|
<th className="px-3 py-1.5 text-left font-medium">
|
||||||
|
物料名称
|
||||||
|
</th>
|
||||||
|
<th className="px-3 py-1.5 text-left font-medium">行号</th>
|
||||||
|
<th className="px-3 py-1.5 text-left font-medium">结果</th>
|
||||||
|
<th className="px-3 py-1.5 text-left font-medium">原因</th>
|
||||||
|
<th className="px-3 py-1.5 text-left font-medium">
|
||||||
|
尝试次数
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{materials.map((mat, idx) => (
|
||||||
|
<MaterialDetailRow key={idx} index={idx} material={mat} />
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-gray-500">暂无物料详情</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="px-4 py-6 text-center text-sm text-gray-500">暂无订单记录</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
BatchItem.displayName = 'BatchItem'
|
||||||
|
|
||||||
|
interface MaterialDetailRowProps {
|
||||||
|
index: number
|
||||||
|
material: CleanerHistoryMaterialRecord
|
||||||
|
}
|
||||||
|
|
||||||
|
const MaterialDetailRow = ({ index, material }: MaterialDetailRowProps): React.JSX.Element => {
|
||||||
|
const resultDisplay = getCleanerMaterialResultDisplay(material.result)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr className="hover:bg-gray-50">
|
||||||
|
<td className="px-3 py-1.5 text-gray-500 font-medium text-xs text-center">{index + 1}</td>
|
||||||
|
<td className="px-3 py-1.5 font-mono text-gray-700">{material.materialCode}</td>
|
||||||
|
<td className="px-3 py-1.5 text-gray-700">{material.materialName}</td>
|
||||||
|
<td className="px-3 py-1.5 text-gray-600">{material.rowNumber}</td>
|
||||||
|
<td className="px-3 py-1.5">
|
||||||
|
{resultDisplay.icon ? (
|
||||||
|
<span className="inline-block cursor-help" title={resultDisplay.title}>
|
||||||
|
{resultDisplay.icon}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-500">{resultDisplay.title}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5 text-gray-600 max-w-xs truncate">{material.reason || '-'}</td>
|
||||||
|
<td className="px-3 py-1.5 text-gray-600">
|
||||||
|
{material.attemptCount > 1 ? (
|
||||||
|
<span className="text-amber-600">{material.attemptCount}</span>
|
||||||
|
) : (
|
||||||
|
'1'
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== Main Modal Component ======
|
||||||
|
export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
user
|
||||||
|
}) => {
|
||||||
|
const [batches, setBatches] = useState<CleanerHistoryBatchStats[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [allUsers, setAllUsers] = useState<string[]>([])
|
||||||
|
const [selectedUsers, setSelectedUsers] = useState<string[]>([])
|
||||||
|
const [currentPage, setCurrentPage] = useState(0)
|
||||||
|
const [isFilterPending, startFilterTransition] = useTransition()
|
||||||
|
const { confirm, dialog: confirmDialog } = useConfirmDialog()
|
||||||
|
const logger = useLogger('CleanerOperationHistory')
|
||||||
|
|
||||||
|
const isAdmin = user?.userType === 'Admin'
|
||||||
|
const hasPreviousPage = currentPage > 0
|
||||||
|
const hasNextPage = batches.length === BATCH_PAGE_SIZE
|
||||||
|
|
||||||
|
const fetchBatches = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const options =
|
||||||
|
isAdmin && selectedUsers.length > 0
|
||||||
|
? {
|
||||||
|
limit: BATCH_PAGE_SIZE,
|
||||||
|
offset: currentPage * BATCH_PAGE_SIZE,
|
||||||
|
usernames: selectedUsers
|
||||||
|
}
|
||||||
|
: { limit: BATCH_PAGE_SIZE, offset: currentPage * BATCH_PAGE_SIZE }
|
||||||
|
|
||||||
|
const result = await window.electron.cleaner.getHistoryBatches(options)
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setBatches(result.data)
|
||||||
|
} else {
|
||||||
|
setError(result.error || '获取历史记录失败')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '获取历史记录失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [currentPage, isAdmin, selectedUsers])
|
||||||
|
|
||||||
|
const fetchAllUsers = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const result = await window.electron.auth.getAllUsers()
|
||||||
|
if (result.success && result.data) {
|
||||||
|
const usernames = result.data.map((u: UserInfo) => u.username)
|
||||||
|
setAllUsers(usernames)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to fetch users list', {
|
||||||
|
error: err instanceof Error ? err.message : String(err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [logger])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
void fetchBatches()
|
||||||
|
if (isAdmin) {
|
||||||
|
void fetchAllUsers()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isOpen, fetchBatches, fetchAllUsers, isAdmin])
|
||||||
|
|
||||||
|
const handleDeleteBatch = useCallback((batchId: string) => {
|
||||||
|
setBatches((prev) => prev.filter((b) => b.batchId !== batchId))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const requestDeleteConfirmation = useCallback(
|
||||||
|
async (batchId: string) => {
|
||||||
|
const batch = batches.find((item) => item.batchId === batchId)
|
||||||
|
const ownerLabel = batch ? `操作人:${batch.username}` : '此操作不可撤销。'
|
||||||
|
|
||||||
|
return confirm({
|
||||||
|
title: '确认删除历史批次',
|
||||||
|
message: (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-gray-700">确定要删除这条清理操作历史吗?</p>
|
||||||
|
<div className="rounded-lg border border-red-100 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||||
|
<div>该操作会一并删除批次、订单和物料明细记录。</div>
|
||||||
|
<div className="mt-1 text-red-600/90">{ownerLabel}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
confirmText: '删除',
|
||||||
|
cancelText: '取消',
|
||||||
|
variant: 'danger'
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[batches, confirm]
|
||||||
|
)
|
||||||
|
|
||||||
|
const toggleUserFilter = (username: string) => {
|
||||||
|
startFilterTransition(() => {
|
||||||
|
setCurrentPage(0)
|
||||||
|
setSelectedUsers((prev) =>
|
||||||
|
prev.includes(username) ? prev.filter((u) => u !== username) : [...prev, username]
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearUserFilters = () => {
|
||||||
|
startFilterTransition(() => {
|
||||||
|
setCurrentPage(0)
|
||||||
|
setSelectedUsers([])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const goToPreviousPage = () => {
|
||||||
|
setCurrentPage((prev) => Math.max(0, prev - 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
const goToNextPage = () => {
|
||||||
|
if (!hasNextPage) return
|
||||||
|
setCurrentPage((prev) => prev + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isOpen) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title="清理操作历史"
|
||||||
|
size="3xl"
|
||||||
|
className="!max-w-[68rem]"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col h-[70vh]">
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex items-start justify-between mb-4 pb-4 border-b border-gray-200">
|
||||||
|
<div className="flex-1">
|
||||||
|
{isAdmin && allUsers.length > 0 && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="text-xs text-gray-500 mb-2">筛选用户:</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{allUsers.map((username) => {
|
||||||
|
const isSelected = selectedUsers.includes(username)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={username}
|
||||||
|
onClick={() => toggleUserFilter(username)}
|
||||||
|
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium transition-all ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-blue-600 text-white shadow-sm'
|
||||||
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{username}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{selectedUsers.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={clearUserFilters}
|
||||||
|
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium bg-red-50 text-red-600 hover:bg-red-100 transition-all"
|
||||||
|
>
|
||||||
|
清空筛选
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="text-sm text-gray-600">
|
||||||
|
{isAdmin ? (
|
||||||
|
<span className="text-amber-600 font-medium">
|
||||||
|
管理员模式:
|
||||||
|
{selectedUsers.length > 0
|
||||||
|
? `已选择 ${selectedUsers.length} 个用户`
|
||||||
|
: '显示所有用户记录'}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span>仅显示您的操作记录</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{batches.length > 0 && (
|
||||||
|
<span className="text-sm text-gray-500">本页 {batches.length} 条批次</span>
|
||||||
|
)}
|
||||||
|
{isFilterPending && <span className="text-sm text-blue-600">正在更新筛选...</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="p-2 hover:bg-gray-100 rounded-lg transition-colors disabled:opacity-50 flex-shrink-0"
|
||||||
|
onClick={() => void fetchBatches()}
|
||||||
|
disabled={loading}
|
||||||
|
title="刷新"
|
||||||
|
>
|
||||||
|
<RefreshCw size={18} className={loading ? 'animate-spin' : ''} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error message */}
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Batch list */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{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>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="pt-4 border-t border-gray-200 flex justify-center">
|
||||||
|
<div className="inline-flex items-center rounded-full border border-slate-200 bg-white p-1 shadow-sm">
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center rounded-full px-4 py-2 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-100 disabled:cursor-not-allowed disabled:text-slate-300"
|
||||||
|
onClick={goToPreviousPage}
|
||||||
|
disabled={loading || !hasPreviousPage}
|
||||||
|
>
|
||||||
|
上一页
|
||||||
|
</button>
|
||||||
|
<div className="mx-1 min-w-[5.5rem] rounded-full bg-slate-900 px-4 py-2 text-center text-sm font-semibold text-white">
|
||||||
|
第 {currentPage + 1} 页
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center rounded-full px-4 py-2 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-100 disabled:cursor-not-allowed disabled:text-slate-300"
|
||||||
|
onClick={goToNextPage}
|
||||||
|
disabled={loading || !hasNextPage}
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{confirmDialog && <ConfirmDialog {...confirmDialog} />}
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CleanerOperationHistoryModal
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
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,71 @@ 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,12 @@ 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',
|
||||||
|
|
||||||
// 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