Compare commits
70 Commits
0956bf907f
...
v1.12.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
b065e23306 | ||
|
|
1c0a000a67 | ||
|
|
6b3c62268a | ||
|
|
bb86208d32 | ||
|
|
3be7959067 | ||
|
|
91f29a1167 | ||
|
|
abad61758c | ||
|
|
d0c745e243 | ||
|
|
fe6cdbf076 | ||
|
|
188117e5ce | ||
|
|
0560b3c84a | ||
|
|
fb3bdbc493 | ||
|
|
c6f67e49a4 | ||
|
|
d16f2d1af0 | ||
|
|
4150a13175 | ||
|
|
d0f8ad0fef | ||
|
|
4a7c220baa | ||
|
|
f51cae0f6f | ||
|
|
7601b5f176 | ||
|
|
e2669af870 | ||
|
|
e54d94fce2 | ||
|
|
9791a84047 | ||
|
|
b5ba18b595 | ||
|
|
13fb7bcf46 | ||
|
|
0ca17a1807 | ||
|
|
54a3ac680a | ||
|
|
16b2882729 | ||
|
|
e97ec63433 | ||
|
|
9556891dea | ||
|
|
fa57f9e564 | ||
|
|
9300f3455f | ||
|
|
7e521da3f1 | ||
|
|
130e0602d1 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -45,3 +45,7 @@ nul
|
||||
|
||||
# TypeScript incremental compilation cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# temporary files
|
||||
tmp/
|
||||
temp/
|
||||
@@ -4,7 +4,7 @@
|
||||
# 部署说明:
|
||||
# 1. 复制此文件为 config.yaml
|
||||
# 2. 根据实际环境修改配置值
|
||||
# 3. 设置 database.activeType 为 mysql 或 sqlserver
|
||||
# 3. 设置 database.activeType 为 mysql、sqlserver 或 postgresql
|
||||
# ================================
|
||||
# 注意:ERP 认证信息存储在数据库 (dbo_BIPUsers) 中,按用户管理
|
||||
# ================================
|
||||
@@ -29,6 +29,14 @@ database:
|
||||
driver: 'ODBC Driver 18 for SQL Server'
|
||||
trustServerCertificate: true
|
||||
|
||||
postgresql:
|
||||
host: <PG_HOST>
|
||||
port: 5432
|
||||
database: <DATABASE_NAME>
|
||||
username: <USERNAME>
|
||||
password: <PASSWORD>
|
||||
maxPoolSize: 10
|
||||
|
||||
paths:
|
||||
dataDir: './data/'
|
||||
defaultOutput: 'output.xlsx'
|
||||
|
||||
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_
|
||||
309
docs/cleaner/cleaner-role-based-flow.md
Normal file
309
docs/cleaner/cleaner-role-based-flow.md
Normal file
@@ -0,0 +1,309 @@
|
||||
# 清理器角色差异流程 — Admin vs User
|
||||
|
||||
**文档版本**: 1.1
|
||||
**创建日期**: 2026-04-06
|
||||
**面向对象**: 开发人员
|
||||
|
||||
## 概述
|
||||
|
||||
清理器(Cleaner)在决定"哪些物料需要被清除"时,Admin 和 User 两个角色存在系统性的差异。这些差异贯穿三个阶段:**初始化 → 校验确认 → 执行清理**。
|
||||
|
||||
本文档使用 Mermaid 图表说明每个阶段的角色分支逻辑。
|
||||
|
||||
---
|
||||
|
||||
## 全局流程概览
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph init["阶段一:页面初始化"]
|
||||
I1([页面加载]) --> I2{角色判断}
|
||||
I2 -->|Admin| I3["管理员列表 ← 全部负责人<br/>默认选中全部"]
|
||||
I2 -->|User| I4["管理员列表 ← 空<br/>默认选中仅自己"]
|
||||
end
|
||||
|
||||
subgraph validate["阶段二:校验 → 勾选 → 同步数据库"]
|
||||
V1([点击校验]) --> V2["后端查询物料<br/>(不区分角色)"]
|
||||
V2 --> V3["物料匹配算法<br/>(User 有覆盖匹配)"]
|
||||
V3 --> V4{角色判断}
|
||||
V4 -->|Admin| V5["显示全部物料<br/>侧边栏可按负责人筛选"]
|
||||
V4 -->|User| V6["仅显示自己的物料<br/>+ 无负责人的物料"]
|
||||
V5 --> V7["用户勾选/取消勾选"]
|
||||
V6 --> V7
|
||||
V7 --> V8{点击同步数据库}
|
||||
V8 --> V9{角色判断}
|
||||
V9 -->|Admin| V10["处理范围:全部校验结果"]
|
||||
V9 -->|User| V11["处理范围:仅筛选后结果"]
|
||||
end
|
||||
|
||||
subgraph execute["阶段三:执行清理(ERP 删除)"]
|
||||
E1([点击执行清理]) --> E2["getCleanerData(selectedManagers)<br/>获取物料代码"]
|
||||
E2 --> E3{角色判断}
|
||||
E3 -->|Admin| E3a{selectedManagers<br/>非空?}
|
||||
E3a -->|"是"| E4["SQL WHERE ManagerName IN (选中)<br/>从 MaterialsToBeDeleted 获取"]
|
||||
E3a -->|"否"| E4b["从 DiscreteMaterialPlanData<br/>按 orderNumbers 获取"]
|
||||
E3 -->|User| E5["SQL WHERE ManagerName = 用户<br/>仅获取自己的物料代码"]
|
||||
E4 --> E6["传递给 runCleaner 执行"]
|
||||
E4b --> E6
|
||||
E5 --> E6
|
||||
E6 --> E7([在 ERP 中删除物料])
|
||||
end
|
||||
|
||||
init --> validate --> execute
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 阶段一:页面初始化
|
||||
|
||||
**源码位置**: `src/renderer/src/hooks/cleaner/api.ts:25-52` 与 `src/renderer/src/hooks/useCleaner.ts:98-112`
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
Start([页面加载]) --> GetAdmin["调用 auth:isAdmin<br/>判断是否管理员"]
|
||||
GetAdmin --> GetUser["调用 auth:getCurrentUser<br/>获取当前用户名"]
|
||||
GetUser --> RoleCheck{isAdmin?}
|
||||
|
||||
RoleCheck -->|Admin| GetManagers["调用 materials:getManagers<br/>获取全部负责人列表"]
|
||||
GetManagers --> SelectAll["selectedManagers ← 全部负责人<br/>(默认全选)"]
|
||||
SelectAll --> RenderSidebar["渲染 CleanerSidebar<br/>显示负责人复选框"]
|
||||
|
||||
RoleCheck -->|User| SetSelf["selectedManagers ← {currentUsername}<br/>(仅选中自己)"]
|
||||
SetSelf --> NoSidebar["不渲染 CleanerSidebar<br/>无侧边栏"]
|
||||
|
||||
RenderSidebar --> Ready([就绪])
|
||||
NoSidebar --> Ready
|
||||
```
|
||||
|
||||
**差异总结**:
|
||||
|
||||
| 维度 | Admin | User |
|
||||
| ---------- | ----------------- | ------ |
|
||||
| 侧边栏 | 有 CleanerSidebar | 无 |
|
||||
| 管理员列表 | 查询全部负责人 | 不查询 |
|
||||
| 默认选中 | 所有负责人 | 仅自己 |
|
||||
|
||||
---
|
||||
|
||||
## 阶段二:校验 → 勾选 → 同步数据库
|
||||
|
||||
### 2.1 物料校验(后端,不区分角色)
|
||||
|
||||
**源码位置**: `src/main/services/validation/validation-application-service.ts`
|
||||
|
||||
校验阶段后端查询不区分角色,Admin 和 User 拿到相同的物料数据。区别在于**匹配算法**:
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
Start([遍历每条物料记录]) --> P1{"优先级1<br/>MaterialsToBeDeleted<br/>精确匹配 MaterialCode?"}
|
||||
|
||||
P1 -->|"匹配"| SetManager["managerName ← 表中记录<br/>isMarkedForDeletion = true"]
|
||||
P1 -->|"未匹配"| P2{"优先级2<br/>MaterialsTypeToBeDeleted<br/>MaterialName 包含匹配?"}
|
||||
|
||||
P2 -->|"匹配"| SetType["managerName ← 类型关键词负责人<br/>matchedTypeKeyword ← 匹配项"]
|
||||
P2 -->|"未匹配"| SetNull["managerName = null"]
|
||||
|
||||
SetManager --> RoleCheck{角色?}
|
||||
SetType --> RoleCheck
|
||||
SetNull --> RoleCheck
|
||||
|
||||
RoleCheck -->|"Admin"| Skip["跳过覆盖<br/>使用当前结果"]
|
||||
RoleCheck -->|"User"| P3{"优先级3(User 覆盖)<br/>自己的类型关键词匹配?"}
|
||||
|
||||
P3 -->|"匹配"| Override["强制覆盖<br/>managerName ← 当前用户"]
|
||||
P3 -->|"未匹配"| Keep["保持当前结果"]
|
||||
Skip --> Next(["下一条物料"])
|
||||
Override --> Next
|
||||
Keep --> Next
|
||||
```
|
||||
|
||||
**匹配优先级说明**:
|
||||
|
||||
| 优先级 | 数据源 | 匹配方式 | 适用角色 |
|
||||
| -------------- | -------------------------- | --------------------- | -------- |
|
||||
| 1(最高) | `MaterialsToBeDeleted` | MaterialCode 精确匹配 | 全部 |
|
||||
| 2 | `MaterialsTypeToBeDeleted` | MaterialName 包含匹配 | 全部 |
|
||||
| 3(User 覆盖) | 当前用户的类型关键词 | MaterialName 包含匹配 | 仅 User |
|
||||
|
||||
> **优先级 3 的作用**:当某个物料按优先级 2 被分配给其他负责人,但当前 User 有匹配的类型关键词时,会强制覆盖为自己的。这确保 User 不会为他人操作物料。
|
||||
|
||||
### 2.2 前端显示过滤
|
||||
|
||||
**源码位置**: `src/renderer/src/hooks/cleaner/helpers.ts:34-57`
|
||||
|
||||
校验结果返回前端后,会根据角色进行显示过滤:
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
Input([校验结果 validationResults]) --> RoleCheck{角色判断}
|
||||
|
||||
RoleCheck -->|Admin| FilterManagers["按侧边栏选中的负责人过滤<br/>selectedManagers.has(managerName)<br/>|| !managerName"]
|
||||
RoleCheck -->|User| FilterSelf["仅显示自己的 + 无负责人的<br/>managerName === currentUsername<br/>|| !managerName"]
|
||||
|
||||
FilterManagers --> FilterHidden["排除已隐藏的物料<br/>!hiddenItems.has(materialCode)"]
|
||||
FilterSelf --> FilterHidden
|
||||
|
||||
FilterHidden --> Output([filteredResults<br/>用于表格显示])
|
||||
```
|
||||
|
||||
### 2.3 确认删除(同步数据库)
|
||||
|
||||
**源码位置**: `src/renderer/src/hooks/useCleaner.ts:289-344`
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
Start([点击确认删除]) --> RoleScope{角色判断}
|
||||
|
||||
RoleScope -->|Admin| UseAll["resultsToProcess = validationResults<br/>处理全部校验结果"]
|
||||
RoleScope -->|User| UseFiltered["resultsToProcess = filteredResults<br/>仅处理筛选后结果"]
|
||||
|
||||
UseAll --> BuildPlan["buildDeletionPlan(resultsToProcess, selectedItems)"]
|
||||
UseFiltered --> BuildPlan
|
||||
|
||||
BuildPlan --> Loop["遍历 resultsToProcess"]
|
||||
Loop --> Check{物料是否勾选?}
|
||||
|
||||
Check -->|"已勾选"| HasManager{有负责人?}
|
||||
Check -->|"未勾选"| ToDelete["加入 materialsToDelete<br/>从数据库移除标记"]
|
||||
|
||||
HasManager -->|"有"| ToUpsert["加入 materialsToUpsert<br/>写入/更新到数据库"]
|
||||
HasManager -->|"无"| Missing["加入 missingManager<br/>阻止操作"]
|
||||
|
||||
ToUpsert --> Save["调用 materials:upsertBatch"]
|
||||
ToDelete --> Del["调用 materials:delete"]
|
||||
Missing --> Warn(["弹窗警告:缺少负责人"])
|
||||
Save --> Done([完成])
|
||||
Del --> Done
|
||||
```
|
||||
|
||||
**关键代码**:
|
||||
|
||||
```typescript
|
||||
// Admin 处理全部结果,User 只处理筛选后的结果
|
||||
const resultsToProcess = isAdmin ? validationResults : filteredResults
|
||||
```
|
||||
|
||||
**差异总结**:
|
||||
|
||||
| 维度 | Admin | User |
|
||||
| ---------------- | --------------------------- | -------------------------------------- |
|
||||
| 处理范围 | `validationResults`(全部) | `filteredResults`(自己的+无负责人的) |
|
||||
| 可操作物料 | 所有负责人的物料 | 仅自己的 + 无负责人的 |
|
||||
| 能否修改他人数据 | 是 | 否 |
|
||||
|
||||
---
|
||||
|
||||
## 阶段三:执行清理(ERP 删除)
|
||||
|
||||
**源码位置**:
|
||||
|
||||
- 前端调用: `src/renderer/src/hooks/cleaner/api.ts:116-166`
|
||||
- 获取数据: `src/main/services/validation/validation-application-service.ts:497-655`
|
||||
- 执行删除: `src/main/services/cleaner/cleaner-application-service.ts`
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant UI as 前端 useCleaner
|
||||
participant API as api.ts
|
||||
participant Main as 主进程
|
||||
participant DB as 数据库
|
||||
participant ERP as ERP 系统
|
||||
|
||||
UI->>API: runCleanerExecution({ dryRun, selectedManagers, ... })
|
||||
API->>Main: getCleanerData({ selectedManagers })
|
||||
|
||||
alt Admin + selectedManagers 非空
|
||||
Main->>DB: SELECT MaterialCode FROM MaterialsToBeDeleted<br/>WHERE ManagerName IN (@manager0, @manager1, ...)
|
||||
Note over Main,DB: 按选中的负责人过滤<br/>从 MaterialsToBeDeleted 获取
|
||||
else Admin + selectedManagers 为空
|
||||
Main->>DB: SELECT DISTINCT MaterialCode FROM DiscreteMaterialPlanData<br/>WHERE SourceNumber IN (orderNumbers)
|
||||
Note over Main,DB: 按订单号查询<br/>从 DiscreteMaterialPlanData 获取
|
||||
else User
|
||||
Main->>DB: SELECT MaterialCode FROM MaterialsToBeDeleted<br/>WHERE ManagerName = @username
|
||||
Note over Main,DB: 按 ManagerName 过滤<br/>仅获取自己的物料代码
|
||||
end
|
||||
|
||||
DB-->>Main: materialCodes[]
|
||||
Main-->>API: { orderNumbers, materialCodes }
|
||||
|
||||
Note over API: 传入角色过滤后的 materialCodes
|
||||
API->>Main: cleaner.runCleaner({ orderNumbers, materialCodes, ... })
|
||||
|
||||
Main->>ERP: 按订单遍历,删除指定物料
|
||||
ERP-->>Main: 删除结果
|
||||
Main-->>API: CleanerResult
|
||||
API-->>UI: 显示执行报告
|
||||
```
|
||||
|
||||
**SQL 差异**:
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph AdminWithMgr["Admin + selectedManagers 非空"]
|
||||
A1["SELECT MaterialCode<br/>FROM MaterialsToBeDeleted<br/>WHERE ManagerName IN (@manager0, ...)<br/>AND MaterialCode IS NOT NULL"]
|
||||
end
|
||||
|
||||
subgraph AdminNoMgr["Admin + selectedManagers 为空"]
|
||||
A2["SELECT DISTINCT MaterialCode<br/>FROM DiscreteMaterialPlanData<br/>WHERE SourceNumber IN (orderNumbers)"]
|
||||
end
|
||||
|
||||
subgraph User["User 查询"]
|
||||
U1["SELECT MaterialCode<br/>FROM MaterialsToBeDeleted<br/>WHERE ManagerName = @username<br/>AND MaterialCode IS NOT NULL"]
|
||||
end
|
||||
|
||||
AdminWithMgr --> |"按选中负责人过滤"| Result([传入 runCleaner])
|
||||
AdminNoMgr --> |"按订单号查 DiscreteMaterialPlanData"| Result
|
||||
User --> |"仅返回自己的物料代码"| Result
|
||||
```
|
||||
|
||||
**差异总结**:
|
||||
|
||||
| 维度 | Admin(有 selectedManagers) | Admin(无 selectedManagers) | User |
|
||||
| ---------- | ---------------------------- | -------------------------------------- | ------------------------------- |
|
||||
| 数据源 | `MaterialsToBeDeleted` | `DiscreteMaterialPlanData` | `MaterialsToBeDeleted` |
|
||||
| 查询条件 | `WHERE ManagerName IN (...)` | `WHERE SourceNumber IN (orderNumbers)` | `WHERE ManagerName = @username` |
|
||||
| 可删除物料 | 选中负责人的物料 | 订单关联的全部物料 | 仅自己标记的物料 |
|
||||
| 无订单号时 | — | 返回空数组 | — |
|
||||
|
||||
---
|
||||
|
||||
## 数据安全边界
|
||||
|
||||
角色隔离在**三个层面**同时生效,形成纵深防御:
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph layer1["第一层:前端过滤"]
|
||||
L1["filterValidationResults()<br/>User 仅看到自己的物料"]
|
||||
end
|
||||
|
||||
subgraph layer2["第二层:同步范围"]
|
||||
L2["handleConfirmDeletion()<br/>User 仅同步 filteredResults"]
|
||||
end
|
||||
|
||||
subgraph layer3["第三层:后端查询"]
|
||||
L3["loadMaterialCodesForCleaner()<br/>Admin: WHERE ManagerName IN (selectedManagers)<br/>User: SQL WHERE ManagerName = user"]
|
||||
end
|
||||
|
||||
L1 -->|"防止误操作"| L2
|
||||
L2 -->|"缩小同步范围"| L3
|
||||
L3 -->|"最终保证"| Safe([User 无法删除他人物料])
|
||||
```
|
||||
|
||||
> **注意**:`runCleaner()` 本身不做角色过滤,它信任上游传入的 `materialCodes` 已经过角色过滤。安全性由 `getCleanerData()` 的 SQL 查询保证。
|
||||
|
||||
---
|
||||
|
||||
## 涉及文件索引
|
||||
|
||||
| 文件 | 关键函数/逻辑 | 行号 |
|
||||
| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------- |
|
||||
| `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/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/cleaner/cleaner-application-service.ts` | `runCleaner()` | 31-168 |
|
||||
| `src/main/ipc/cleaner-handler.ts` | `CLEANER_RUN` handler | 16-22 |
|
||||
| `src/main/ipc/validation-handler.ts` | `getCleanerData` handler | 194-223 |
|
||||
| `src/preload/api/validation.ts` | `getCleanerData()` IPC 桥接 | 11-12 |
|
||||
| `src/renderer/src/pages/CleanerPage.tsx` | 页面组件,条件渲染侧边栏 | 74-82 |
|
||||
@@ -44,12 +44,10 @@ export interface SqlDialect {
|
||||
}): string
|
||||
|
||||
// 分页
|
||||
paginate(p: {
|
||||
paginate(p: { sql: string; limit: number; offset?: number; paramIndex: number }): {
|
||||
sql: string
|
||||
limit: number
|
||||
offset?: number
|
||||
paramIndex: number
|
||||
}): { sql: string; paramIndex: number }
|
||||
}
|
||||
|
||||
// 批量限制
|
||||
maxBatchRows(columnsPerRow: number): number
|
||||
@@ -60,13 +58,14 @@ export interface SqlDialect {
|
||||
|
||||
新建 `src/main/services/database/dialects/` 目录:
|
||||
|
||||
| 文件 | 数据库 | param(n) | quoteTableName | currentTimestamp | upsert | paginate |
|
||||
|------|--------|----------|---------------|-----------------|--------|----------|
|
||||
| `mysql-dialect.ts` | MySQL | `?` | `dbo_Table` | `NOW()` | `ON DUPLICATE KEY` | `LIMIT x OFFSET y` |
|
||||
| `sqlserver-dialect.ts` | SQL Server | `@p{n}` | `[dbo].[Table]` | `GETDATE()` | `MERGE` | `OFFSET/FETCH` |
|
||||
| `postgresql-dialect.ts` | PostgreSQL | `${n+1}` | `"dbo"."Table"` | `CURRENT_TIMESTAMP` | `ON CONFLICT` | `LIMIT x OFFSET y` |
|
||||
| 文件 | 数据库 | param(n) | quoteTableName | currentTimestamp | upsert | paginate |
|
||||
| ----------------------- | ---------- | -------- | --------------- | ------------------- | ------------------ | ------------------ |
|
||||
| `mysql-dialect.ts` | MySQL | `?` | `dbo_Table` | `NOW()` | `ON DUPLICATE KEY` | `LIMIT x OFFSET y` |
|
||||
| `sqlserver-dialect.ts` | SQL Server | `@p{n}` | `[dbo].[Table]` | `GETDATE()` | `MERGE` | `OFFSET/FETCH` |
|
||||
| `postgresql-dialect.ts` | PostgreSQL | `${n+1}` | `"dbo"."Table"` | `CURRENT_TIMESTAMP` | `ON CONFLICT` | `LIMIT x OFFSET y` |
|
||||
|
||||
方言工厂 `dialects/index.ts`:
|
||||
|
||||
```typescript
|
||||
export function createDialect(type: DatabaseType): SqlDialect
|
||||
```
|
||||
@@ -76,16 +75,19 @@ export function createDialect(type: DatabaseType): SqlDialect
|
||||
每个 DAO 新增 `dialect` 成员,替代原有的 `getTableName()`、`buildPlaceholders()` 和所有 `isSqlServer` 分支:
|
||||
|
||||
**删除:**
|
||||
|
||||
- `getTableName()` 私有方法
|
||||
- `buildPlaceholders()` 私有方法
|
||||
- 所有 `isSqlServer` 局部变量和条件分支
|
||||
- `*_CONFIG` 中的 `TABLE_NAME_SQLSERVER` / `TABLE_NAME_MYSQL` → 合并为 `TABLE_SCHEMA` + `TABLE_NAME`
|
||||
|
||||
**新增:**
|
||||
|
||||
- `private dialect: SqlDialect | null = null`
|
||||
- `private getDialect(): SqlDialect`
|
||||
|
||||
**涉及 DAO:**
|
||||
|
||||
- `DiscreteMaterialPlanDAO` — 占位符、表名、批量大小
|
||||
- `MaterialsToBeDeletedDAO` — 占位符、表名、MERGE/ON DUPLICATE KEY → `upsert()`
|
||||
- `MaterialsTypeToBeDeletedDAO` — 同上
|
||||
@@ -94,6 +96,7 @@ export function createDialect(type: DatabaseType): SqlDialect
|
||||
### 4. PostgreSQL 服务层
|
||||
|
||||
新建 `src/main/services/database/postgresql.ts`:
|
||||
|
||||
- 使用 `pg` 驱动,`Pool` 连接池
|
||||
- 实现 `IDatabaseService` 接口
|
||||
- `query()` 直接传递参数数组给 `pg`
|
||||
@@ -113,22 +116,22 @@ export function createDialect(type: DatabaseType): SqlDialect
|
||||
|
||||
## 改动范围
|
||||
|
||||
| 层 | 文件 | 动作 |
|
||||
|---|---|---|
|
||||
| 类型 | `types/database.types.ts` | 修改 |
|
||||
| 方言 | `database/dialects/index.ts` | 新建 |
|
||||
| 方言 | `database/dialects/mysql-dialect.ts` | 新建 |
|
||||
| 方言 | `database/dialects/sqlserver-dialect.ts` | 新建 |
|
||||
| 方言 | `database/dialects/postgresql-dialect.ts` | 新建 |
|
||||
| 服务 | `database/postgresql.ts` | 新建 |
|
||||
| 工厂 | `database/index.ts` | 修改 |
|
||||
| TypeORM | `database/data-source.ts` | 修改 |
|
||||
| DAO | `database/discrete-material-plan-dao.ts` | 重构 |
|
||||
| DAO | `database/materials-to-be-deleted-dao.ts` | 重构 |
|
||||
| DAO | `database/materials-type-to-be-deleted-dao.ts` | 重构 |
|
||||
| DAO | `database/extractor-operation-history-dao.ts` | 重构 |
|
||||
| 配置 | `config.template.yaml` | 修改 |
|
||||
| 依赖 | `package.json` | 修改 |
|
||||
| 层 | 文件 | 动作 |
|
||||
| ------- | ---------------------------------------------- | ---- |
|
||||
| 类型 | `types/database.types.ts` | 修改 |
|
||||
| 方言 | `database/dialects/index.ts` | 新建 |
|
||||
| 方言 | `database/dialects/mysql-dialect.ts` | 新建 |
|
||||
| 方言 | `database/dialects/sqlserver-dialect.ts` | 新建 |
|
||||
| 方言 | `database/dialects/postgresql-dialect.ts` | 新建 |
|
||||
| 服务 | `database/postgresql.ts` | 新建 |
|
||||
| 工厂 | `database/index.ts` | 修改 |
|
||||
| TypeORM | `database/data-source.ts` | 修改 |
|
||||
| DAO | `database/discrete-material-plan-dao.ts` | 重构 |
|
||||
| DAO | `database/materials-to-be-deleted-dao.ts` | 重构 |
|
||||
| DAO | `database/materials-type-to-be-deleted-dao.ts` | 重构 |
|
||||
| DAO | `database/extractor-operation-history-dao.ts` | 重构 |
|
||||
| 配置 | `config.template.yaml` | 修改 |
|
||||
| 依赖 | `package.json` | 修改 |
|
||||
|
||||
共 **4 个新文件 + 10 个修改文件**。
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
## Task 1: SqlDialect Interface & Type Definitions
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `src/main/types/sql-dialect.types.ts`
|
||||
- Modify: `src/main/types/database.types.ts:11` (extend `DatabaseType`)
|
||||
- Modify: `src/main/types/config.schema.ts:15` (extend Zod enum)
|
||||
@@ -20,11 +21,13 @@
|
||||
**Step 1: Add `'postgresql'` to `DatabaseType`**
|
||||
|
||||
In `src/main/types/database.types.ts:11`, change:
|
||||
|
||||
```typescript
|
||||
export type DatabaseType = 'mysql' | 'sqlserver' | 'postgresql'
|
||||
```
|
||||
|
||||
Add after line 104 (after `SqlServerConfig`):
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* PostgreSQL-specific configuration
|
||||
@@ -44,11 +47,13 @@ export interface PostgreSqlConfig extends DatabaseConfig {
|
||||
In `src/main/types/config.schema.ts`:
|
||||
|
||||
Change line 15:
|
||||
|
||||
```typescript
|
||||
export const databaseTypeSchema = z.enum(['mysql', 'sqlserver', 'postgresql'])
|
||||
```
|
||||
|
||||
Add after `sqlServerConfigSchema` (after line 58):
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* PostgreSQL 配置 Schema
|
||||
@@ -65,6 +70,7 @@ export type PostgreSqlConfig = z.infer<typeof postgresqlConfigSchema>
|
||||
```
|
||||
|
||||
Change `databaseConfigSchema` (line 63):
|
||||
|
||||
```typescript
|
||||
export const databaseConfigSchema = z.object({
|
||||
activeType: databaseTypeSchema.default('mysql'),
|
||||
@@ -77,6 +83,7 @@ export const databaseConfigSchema = z.object({
|
||||
**Step 3: Create `SqlDialect` interface**
|
||||
|
||||
Create `src/main/types/sql-dialect.types.ts`:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* SQL Dialect Abstraction
|
||||
@@ -152,12 +159,10 @@ export interface SqlDialect {
|
||||
*
|
||||
* @returns Object with modified sql and next param index
|
||||
*/
|
||||
paginate(params: {
|
||||
paginate(params: { sql: string; limit: number; offset?: number; paramIndex: number }): {
|
||||
sql: string
|
||||
limit: number
|
||||
offset?: number
|
||||
paramIndex: number
|
||||
}): { sql: string; nextParamIndex: number }
|
||||
nextParamIndex: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Maximum rows per batch given columns per row
|
||||
@@ -185,6 +190,7 @@ git commit -m "feat(db): add SqlDialect interface and PostgreSQL type definition
|
||||
## Task 2: Implement Three Dialect Classes
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `src/main/services/database/dialects/mysql-dialect.ts`
|
||||
- Create: `src/main/services/database/dialects/sqlserver-dialect.ts`
|
||||
- Create: `src/main/services/database/dialects/postgresql-dialect.ts`
|
||||
@@ -196,6 +202,7 @@ git commit -m "feat(db): add SqlDialect interface and PostgreSQL type definition
|
||||
**Step 1: Write tests for all three dialects**
|
||||
|
||||
Create `tests/unit/dialects/mysql-dialect.test.ts`:
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { MySqlDialect } from '@main/services/database/dialects/mysql-dialect'
|
||||
@@ -255,6 +262,7 @@ describe('MySqlDialect', () => {
|
||||
```
|
||||
|
||||
Create `tests/unit/dialects/sqlserver-dialect.test.ts`:
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { SqlServerDialect } from '@main/services/database/dialects/sqlserver-dialect'
|
||||
@@ -327,6 +335,7 @@ describe('SqlServerDialect', () => {
|
||||
```
|
||||
|
||||
Create `tests/unit/dialects/postgresql-dialect.test.ts`:
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { PostgreSqlDialect } from '@main/services/database/dialects/postgresql-dialect'
|
||||
@@ -393,6 +402,7 @@ Expected: FAIL — modules not found
|
||||
**Step 3: Implement MySqlDialect**
|
||||
|
||||
Create `src/main/services/database/dialects/mysql-dialect.ts`:
|
||||
|
||||
```typescript
|
||||
import type { SqlDialect } from '../../../types/sql-dialect.types'
|
||||
import type { DatabaseType } from '../../../types/database.types'
|
||||
@@ -437,12 +447,10 @@ export class MySqlDialect implements SqlDialect {
|
||||
return { sql, nextParamIndex: p.startParamIndex + p.allColumns.length }
|
||||
}
|
||||
|
||||
paginate(p: {
|
||||
paginate(p: { sql: string; limit: number; offset?: number; paramIndex: number }): {
|
||||
sql: string
|
||||
limit: number
|
||||
offset?: number
|
||||
paramIndex: number
|
||||
}): { sql: string; nextParamIndex: number } {
|
||||
nextParamIndex: number
|
||||
} {
|
||||
let sql = p.sql
|
||||
if (p.offset !== undefined) {
|
||||
sql += ` LIMIT ${p.limit} OFFSET ${p.offset}`
|
||||
@@ -461,6 +469,7 @@ export class MySqlDialect implements SqlDialect {
|
||||
**Step 4: Implement SqlServerDialect**
|
||||
|
||||
Create `src/main/services/database/dialects/sqlserver-dialect.ts`:
|
||||
|
||||
```typescript
|
||||
import type { SqlDialect } from '../../../types/sql-dialect.types'
|
||||
import type { DatabaseType } from '../../../types/database.types'
|
||||
@@ -490,21 +499,15 @@ export class SqlServerDialect implements SqlDialect {
|
||||
allColumns: string[]
|
||||
startParamIndex: number
|
||||
}): { sql: string; nextParamIndex: number } {
|
||||
const sourceValues = p.allColumns
|
||||
.map((_, i) => `@p${p.startParamIndex + i}`)
|
||||
.join(', ')
|
||||
const sourceValues = p.allColumns.map((_, i) => `@p${p.startParamIndex + i}`).join(', ')
|
||||
const sourceColumns = p.allColumns.join(', ')
|
||||
const keyMatch = p.keyColumns
|
||||
.map((col) => `target.${col} = source.${col}`)
|
||||
.join(' AND ')
|
||||
const keyMatch = p.keyColumns.map((col) => `target.${col} = source.${col}`).join(' AND ')
|
||||
const updateSet = p.allColumns
|
||||
.filter((col) => !p.keyColumns.includes(col))
|
||||
.map((col) => `${col} = source.${col}`)
|
||||
.join(', ')
|
||||
const insertColumns = p.allColumns.join(', ')
|
||||
const insertValues = p.allColumns
|
||||
.map((col) => `source.${col}`)
|
||||
.join(', ')
|
||||
const insertValues = p.allColumns.map((col) => `source.${col}`).join(', ')
|
||||
|
||||
const sql = `
|
||||
MERGE ${p.table} AS target
|
||||
@@ -516,12 +519,10 @@ export class SqlServerDialect implements SqlDialect {
|
||||
return { sql, nextParamIndex: p.startParamIndex + p.allColumns.length }
|
||||
}
|
||||
|
||||
paginate(p: {
|
||||
paginate(p: { sql: string; limit: number; offset?: number; paramIndex: number }): {
|
||||
sql: string
|
||||
limit: number
|
||||
offset?: number
|
||||
paramIndex: number
|
||||
}): { sql: string; nextParamIndex: number } {
|
||||
nextParamIndex: number
|
||||
} {
|
||||
let sql = p.sql
|
||||
let nextIndex = p.paramIndex
|
||||
|
||||
@@ -547,6 +548,7 @@ export class SqlServerDialect implements SqlDialect {
|
||||
**Step 5: Implement PostgreSqlDialect**
|
||||
|
||||
Create `src/main/services/database/dialects/postgresql-dialect.ts`:
|
||||
|
||||
```typescript
|
||||
import type { SqlDialect } from '../../../types/sql-dialect.types'
|
||||
import type { DatabaseType } from '../../../types/database.types'
|
||||
@@ -577,9 +579,7 @@ export class PostgreSqlDialect implements SqlDialect {
|
||||
allColumns: string[]
|
||||
startParamIndex: number
|
||||
}): { sql: string; nextParamIndex: number } {
|
||||
const placeholders = p.allColumns
|
||||
.map((_, i) => `$${p.startParamIndex + i + 1}`)
|
||||
.join(', ')
|
||||
const placeholders = p.allColumns.map((_, i) => `$${p.startParamIndex + i + 1}`).join(', ')
|
||||
const columns = p.allColumns.join(', ')
|
||||
const conflictKeys = p.keyColumns.map((k) => `"${k}"`).join(', ')
|
||||
const updateSet = p.allColumns
|
||||
@@ -595,12 +595,10 @@ export class PostgreSqlDialect implements SqlDialect {
|
||||
return { sql, nextParamIndex: p.startParamIndex + p.allColumns.length }
|
||||
}
|
||||
|
||||
paginate(p: {
|
||||
paginate(p: { sql: string; limit: number; offset?: number; paramIndex: number }): {
|
||||
sql: string
|
||||
limit: number
|
||||
offset?: number
|
||||
paramIndex: number
|
||||
}): { sql: string; nextParamIndex: number } {
|
||||
nextParamIndex: number
|
||||
} {
|
||||
let sql = p.sql
|
||||
if (p.offset !== undefined) {
|
||||
sql += ` LIMIT ${p.limit} OFFSET ${p.offset}`
|
||||
@@ -619,6 +617,7 @@ export class PostgreSqlDialect implements SqlDialect {
|
||||
**Step 6: Create dialect factory index**
|
||||
|
||||
Create `src/main/services/database/dialects/index.ts`:
|
||||
|
||||
```typescript
|
||||
export { MySqlDialect } from './mysql-dialect'
|
||||
export { SqlServerDialect } from './sqlserver-dialect'
|
||||
@@ -659,12 +658,14 @@ git commit -m "feat(db): implement SqlDialect abstraction with MySQL, SQL Server
|
||||
## Task 3: PostgreSqlService Implementation
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `src/main/services/database/postgresql.ts`
|
||||
- Test: `tests/unit/postgresql.test.ts`
|
||||
|
||||
**Step 1: Write tests**
|
||||
|
||||
Create `tests/unit/postgresql.test.ts`:
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { PostgreSqlService } from '@main/services/database/postgresql'
|
||||
@@ -743,6 +744,7 @@ Run: `npm install pg && npm install -D @types/pg`
|
||||
**Step 4: Implement PostgreSqlService**
|
||||
|
||||
Create `src/main/services/database/postgresql.ts`:
|
||||
|
||||
```typescript
|
||||
import { Pool } from 'pg'
|
||||
import type {
|
||||
@@ -902,6 +904,7 @@ git commit -m "feat(db): add PostgreSqlService with pg driver"
|
||||
## Task 4: Refactor DiscreteMaterialPlanDAO
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/main/services/database/discrete-material-plan-dao.ts`
|
||||
- Modify: `src/main/services/database/materials-to-be-deleted-dao.ts`
|
||||
- Modify: `src/main/services/database/materials-type-to-be-deleted-dao.ts`
|
||||
@@ -914,11 +917,13 @@ This is the largest task. Each DAO follows the same pattern — replace `isSqlSe
|
||||
**Step 1: Add dialect import and member**
|
||||
|
||||
At top of file, add import:
|
||||
|
||||
```typescript
|
||||
import { createDialect, type SqlDialect } from './dialects'
|
||||
```
|
||||
|
||||
In class body, add:
|
||||
|
||||
```typescript
|
||||
private dialect: SqlDialect | null = null
|
||||
|
||||
@@ -933,6 +938,7 @@ private getDialect(): SqlDialect {
|
||||
**Step 2: Remove `getTableName()` and `buildPlaceholders()` methods**
|
||||
|
||||
Replace with:
|
||||
|
||||
```typescript
|
||||
private getTableName(): string {
|
||||
return this.getDialect().quoteTableName('dbo', 'DiscreteMaterialPlanData')
|
||||
@@ -944,11 +950,14 @@ Delete `buildPlaceholders()` entirely — replaced by `dialect.params()`.
|
||||
**Step 3: Replace all `isSqlServer` local variables**
|
||||
|
||||
Replace patterns like:
|
||||
|
||||
```typescript
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
const placeholder = isSqlServer ? '@p0' : '?'
|
||||
```
|
||||
|
||||
With:
|
||||
|
||||
```typescript
|
||||
const dialect = this.getDialect()
|
||||
const placeholder = dialect.param(0)
|
||||
@@ -961,6 +970,7 @@ Replace `isSqlServer ? '@p0' : '?'` single placeholders with `dialect.param(0)`.
|
||||
**Step 4: Refactor `buildRowValues()`**
|
||||
|
||||
Change from:
|
||||
|
||||
```typescript
|
||||
if (isSqlServer) {
|
||||
return `@p${values.length - 1}`
|
||||
@@ -968,7 +978,9 @@ if (isSqlServer) {
|
||||
return '?'
|
||||
}
|
||||
```
|
||||
|
||||
To:
|
||||
|
||||
```typescript
|
||||
return this.getDialect().param(values.length - 1)
|
||||
```
|
||||
@@ -976,13 +988,16 @@ return this.getDialect().param(values.length - 1)
|
||||
**Step 5: Refactor `batchInsert()` batch size**
|
||||
|
||||
Change from:
|
||||
|
||||
```typescript
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
const effectiveBatchSize = isSqlServer
|
||||
? Math.min(batchSize, Math.floor(sqlServerMaxParams / columnsPerRow))
|
||||
: batchSize
|
||||
```
|
||||
|
||||
To:
|
||||
|
||||
```typescript
|
||||
const dialect = this.getDialect()
|
||||
const effectiveBatchSize = Math.min(batchSize, dialect.maxBatchRows(columnsPerRow))
|
||||
@@ -995,6 +1010,7 @@ Same pattern as above, plus refactor UPSERT methods:
|
||||
**Step 6: Replace MERGE/ON DUPLICATE KEY with `dialect.upsert()`**
|
||||
|
||||
In `upsertMaterial()`, replace the entire if/else block:
|
||||
|
||||
```typescript
|
||||
if (isSqlServer) {
|
||||
// MERGE ...
|
||||
@@ -1002,7 +1018,9 @@ if (isSqlServer) {
|
||||
// ON DUPLICATE KEY UPDATE ...
|
||||
}
|
||||
```
|
||||
|
||||
With:
|
||||
|
||||
```typescript
|
||||
const dialect = this.getDialect()
|
||||
const { sql: sqlString } = dialect.upsert({
|
||||
@@ -1034,6 +1052,7 @@ Additionally, refactor `updateMaterial()` — the entire if/else block that dupl
|
||||
Plus specific changes:
|
||||
|
||||
In `insertBatchRecords()`, replace the if/else with:
|
||||
|
||||
```typescript
|
||||
const dialect = this.getDialect()
|
||||
const sqlString = `
|
||||
@@ -1045,6 +1064,7 @@ const sqlString = `
|
||||
```
|
||||
|
||||
In `getBatches()`, replace pagination logic with:
|
||||
|
||||
```typescript
|
||||
if (options?.limit) {
|
||||
const result = dialect.paginate({
|
||||
@@ -1075,6 +1095,7 @@ git commit -m "refactor(db): replace isSqlServer checks with SqlDialect abstract
|
||||
## Task 5: Database Factory & Config Integration
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/main/services/database/index.ts`
|
||||
- Modify: `src/main/services/config/config-manager.ts:47-69,302-308`
|
||||
- Modify: `src/main/services/database/data-source.ts`
|
||||
@@ -1085,12 +1106,14 @@ git commit -m "refactor(db): replace isSqlServer checks with SqlDialect abstract
|
||||
In `src/main/services/database/index.ts`:
|
||||
|
||||
Add imports:
|
||||
|
||||
```typescript
|
||||
import { PostgreSqlService } from './postgresql'
|
||||
import type { PostgreSqlConfig } from '../../types/database.types'
|
||||
```
|
||||
|
||||
Add config factory:
|
||||
|
||||
```typescript
|
||||
export function createPostgreSqlConfig(): PostgreSqlConfig {
|
||||
const configManager = ConfigManager.getInstance()
|
||||
@@ -1107,6 +1130,7 @@ export function createPostgreSqlConfig(): PostgreSqlConfig {
|
||||
```
|
||||
|
||||
Update `create()` function (line 89):
|
||||
|
||||
```typescript
|
||||
if (dbType === 'postgresql') {
|
||||
log.info('Creating PostgreSQL database service')
|
||||
@@ -1121,6 +1145,7 @@ if (dbType === 'postgresql') {
|
||||
```
|
||||
|
||||
Add to re-exports:
|
||||
|
||||
```typescript
|
||||
export { PostgreSqlService } from './postgresql'
|
||||
```
|
||||
@@ -1128,6 +1153,7 @@ export { PostgreSqlService } from './postgresql'
|
||||
**Step 2: Update ConfigManager defaults**
|
||||
|
||||
In `src/main/services/config/config-manager.ts`, add to `DEFAULT_CONFIG.database` (after line 69):
|
||||
|
||||
```typescript
|
||||
postgresql: {
|
||||
host: 'localhost',
|
||||
@@ -1140,6 +1166,7 @@ postgresql: {
|
||||
```
|
||||
|
||||
Update `getActiveDatabaseConfig()` (line 302-308):
|
||||
|
||||
```typescript
|
||||
public getActiveDatabaseConfig(): MySqlConfig | SqlServerConfig | PostgreSqlConfig {
|
||||
const { activeType, mysql, sqlserver, postgresql } = this.config.database
|
||||
@@ -1156,19 +1183,24 @@ public getActiveDatabaseConfig(): MySqlConfig | SqlServerConfig | PostgreSqlConf
|
||||
In `src/main/services/database/data-source.ts`:
|
||||
|
||||
Update `getDatabaseType()`:
|
||||
|
||||
```typescript
|
||||
function getDatabaseType(): 'mysql' | 'mssql' | 'postgres' {
|
||||
const configManager = ConfigManager.getInstance()
|
||||
const dbType = configManager.getDatabaseType()
|
||||
switch (dbType) {
|
||||
case 'sqlserver': return 'mssql'
|
||||
case 'postgresql': return 'postgres'
|
||||
default: return 'mysql'
|
||||
case 'sqlserver':
|
||||
return 'mssql'
|
||||
case 'postgresql':
|
||||
return 'postgres'
|
||||
default:
|
||||
return 'mysql'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Add postgres branch in `buildDataSourceOptions()`:
|
||||
|
||||
```typescript
|
||||
if (type === 'mssql') {
|
||||
// ... existing mssql config
|
||||
@@ -1191,17 +1223,19 @@ if (type === 'mssql') {
|
||||
**Step 4: Update config template**
|
||||
|
||||
In `config.template.yaml`, add after sqlserver section:
|
||||
|
||||
```yaml
|
||||
postgresql:
|
||||
host: <PG_HOST>
|
||||
port: 5432
|
||||
database: <DATABASE_NAME>
|
||||
username: <USERNAME>
|
||||
password: <PASSWORD>
|
||||
maxPoolSize: 10
|
||||
postgresql:
|
||||
host: <PG_HOST>
|
||||
port: 5432
|
||||
database: <DATABASE_NAME>
|
||||
username: <USERNAME>
|
||||
password: <PASSWORD>
|
||||
maxPoolSize: 10
|
||||
```
|
||||
|
||||
Update header comment to mention postgresql:
|
||||
|
||||
```yaml
|
||||
# 3. 设置 database.activeType 为 mysql、sqlserver 或 postgresql
|
||||
```
|
||||
@@ -1228,6 +1262,7 @@ git commit -m "feat(db): integrate PostgreSQL into factory, config, and TypeORM
|
||||
## Task 6: Verification & Smoke Test
|
||||
|
||||
**Files:**
|
||||
|
||||
- Test: Manual integration verification
|
||||
|
||||
**Step 1: Run full typecheck**
|
||||
@@ -1252,6 +1287,7 @@ Run: `npm run format`
|
||||
**Step 5: Verify config.yaml can be parsed with postgresql section**
|
||||
|
||||
Create a temporary test that validates the config schema accepts postgresql:
|
||||
|
||||
```typescript
|
||||
// In a scratch test file
|
||||
import { validateConfig } from '../../src/main/types/config.schema'
|
||||
@@ -1262,7 +1298,13 @@ const config = {
|
||||
activeType: 'postgresql',
|
||||
mysql: { host: 'localhost', port: 3306, database: 'test', username: 'root', password: '' },
|
||||
sqlserver: { server: 'localhost', port: 1433, database: 'test', username: 'sa', password: '' },
|
||||
postgresql: { host: '192.168.31.83', port: 5432, database: 'CompanyDB', username: 'admin', password: 'test' }
|
||||
postgresql: {
|
||||
host: '192.168.31.83',
|
||||
port: 5432,
|
||||
database: 'CompanyDB',
|
||||
username: 'admin',
|
||||
password: 'test'
|
||||
}
|
||||
},
|
||||
paths: { dataDir: './data/' },
|
||||
extraction: {},
|
||||
@@ -1287,13 +1329,13 @@ git commit -m "chore: format and verify PostgreSQL integration"
|
||||
|
||||
## Summary
|
||||
|
||||
| Task | Description | New Files | Modified Files |
|
||||
|------|-------------|-----------|---------------|
|
||||
| 1 | Types & SqlDialect interface | 1 | 2 |
|
||||
| 2 | Three dialect implementations + tests | 4 + 3 tests | 0 |
|
||||
| 3 | PostgreSqlService + test | 1 + 1 test | 1 (package.json) |
|
||||
| 4 | Refactor all 4 DAOs | 0 | 4 |
|
||||
| 5 | Factory, config, TypeORM integration | 0 | 4 |
|
||||
| 6 | Verification | 0 | 0 |
|
||||
| Task | Description | New Files | Modified Files |
|
||||
| ---- | ------------------------------------- | ----------- | ---------------- |
|
||||
| 1 | Types & SqlDialect interface | 1 | 2 |
|
||||
| 2 | Three dialect implementations + tests | 4 + 3 tests | 0 |
|
||||
| 3 | PostgreSqlService + test | 1 + 1 test | 1 (package.json) |
|
||||
| 4 | Refactor all 4 DAOs | 0 | 4 |
|
||||
| 5 | Factory, config, TypeORM integration | 0 | 4 |
|
||||
| 6 | Verification | 0 | 0 |
|
||||
|
||||
**Total:** 6 tasks, ~14 files touched (9 new, 10 modified), 5 commits
|
||||
|
||||
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 变更
|
||||
16
docs/releases/1.10.0.md
Normal file
16
docs/releases/1.10.0.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# 1.10.0
|
||||
|
||||
## 数据库
|
||||
|
||||
- **新增 PostgreSQL 支持**:应用现可连接 PostgreSQL 数据库,与 MySQL、SQL Server 并列可选。
|
||||
- 数据库方言自动适配,SQL 语句根据数据库类型生成正确的标识符引用格式。
|
||||
|
||||
## 稳定性
|
||||
|
||||
- 修复 PostgreSQL 环境下表名双引号导致的 SQL 语法错误。
|
||||
- 修复 PostgreSQL 关键字冲突和大小写敏感问题,自动处理标识符转义。
|
||||
|
||||
## 质量改进
|
||||
|
||||
- 扩展核心业务模块(认证、清理、校验)的单元测试覆盖,提升回归检测能力。
|
||||
- 改进测试隔离性,减少跨用例状态泄漏和测试日志噪音。
|
||||
16
docs/releases/1.11.0.md
Normal file
16
docs/releases/1.11.0.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# 1.11.0
|
||||
|
||||
## 物料清理
|
||||
|
||||
- 管理员执行清理时可按负责人筛选物料,仅处理指定负责人的数据,避免误删其他人的标记。
|
||||
- 未选择负责人时自动按订单号关联查询物料,保证清理范围准确。
|
||||
|
||||
## 审计日志
|
||||
|
||||
- 统一审计记录中的计算机名称来源,消除多来源不一致的情况。
|
||||
- 增强审计日志的类型安全性和覆盖范围,异常情况下不再丢失日志。
|
||||
|
||||
## 质量改进
|
||||
|
||||
- 端到端测试迁移至 Playwright 框架,提升测试稳定性和执行效率。
|
||||
- 改进单元测试的隔离性和模拟驱动覆盖,减少跨用例状态干扰。
|
||||
5
docs/releases/1.11.1.md
Normal file
5
docs/releases/1.11.1.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# 1.11.1
|
||||
|
||||
## 问题修复
|
||||
|
||||
- 修复管理员按负责人筛选清理时,因类型声明缺失导致构建失败的问题。
|
||||
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,20 +11,20 @@
|
||||
|
||||
### 失败测试分布
|
||||
|
||||
| 测试文件 | 失败数量 | 根因分类 | 预计工时 |
|
||||
|---------|---------|---------|---------|
|
||||
| `logger.test.ts` | 11 failures | Winston format mock + 循环依赖 | 2-3h |
|
||||
| `update-service.test.ts` | 1 failure | Mock 参数不匹配 | 30min |
|
||||
| `update-installer.test.ts` | 1 failure | 路径断言错误 | 15min |
|
||||
| **总计** | **13 failures** | - | **~3-4h** |
|
||||
| 测试文件 | 失败数量 | 根因分类 | 预计工时 |
|
||||
| -------------------------- | --------------- | ------------------------------ | --------- |
|
||||
| `logger.test.ts` | 11 failures | Winston format mock + 循环依赖 | 2-3h |
|
||||
| `update-service.test.ts` | 1 failure | Mock 参数不匹配 | 30min |
|
||||
| `update-installer.test.ts` | 1 failure | 路径断言错误 | 15min |
|
||||
| **总计** | **13 failures** | - | **~3-4h** |
|
||||
|
||||
### 测试通过率
|
||||
|
||||
| 指标 | 当前 | 修复后 |
|
||||
|------|------|--------|
|
||||
| 失败套件 | 3 suites | 0 suites |
|
||||
| 失败测试 | 13 tests | 0 tests |
|
||||
| 通过率 | 95% (311/327) | 100% (327/327) |
|
||||
| 指标 | 当前 | 修复后 |
|
||||
| -------- | ------------- | -------------- |
|
||||
| 失败套件 | 3 suites | 0 suites |
|
||||
| 失败测试 | 13 tests | 0 tests |
|
||||
| 通过率 | 95% (311/327) | 100% (327/327) |
|
||||
|
||||
---
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
#### 问题诊断
|
||||
|
||||
**失败模式**:
|
||||
|
||||
```
|
||||
TypeError: __vite_ssr_import_0__.default.format(...) is not a function
|
||||
at src/main/services/logger/index.ts:114:4
|
||||
@@ -54,6 +55,7 @@ TypeError: __vite_ssr_import_0__.default.format(...) is not a function
|
||||
3. **具体表现**: 第 114 行的 `winston.format()` 链式调用在 mock 环境中返回 undefined
|
||||
|
||||
**调用栈**:
|
||||
|
||||
```
|
||||
logger.test.ts
|
||||
→ imports logger.ts
|
||||
@@ -63,6 +65,7 @@ logger.test.ts
|
||||
```
|
||||
|
||||
**文件位置**:
|
||||
|
||||
- 测试文件:`tests/unit/logger.test.ts`
|
||||
- 被 mock 文件:`src/main/services/logger/index.ts:100-116`
|
||||
- Setup mock: `tests/setup.ts` (无 winston mock 冲突)
|
||||
@@ -135,11 +138,13 @@ vi.mock('winston', () => ({
|
||||
**方案 B: 将 logger.test.ts 转为集成测试 (2 小时)**
|
||||
|
||||
如果 mock 过于复杂,可以考虑:
|
||||
|
||||
- 使用 vi.resetModules() 确保每次测试都重新加载
|
||||
- 使用 vi.mock(importOriginal) 混合真实模块
|
||||
- 或完全重写测试,只测试 logger 的公共 API
|
||||
|
||||
**预期结果**:
|
||||
|
||||
- ✅ 18/18 tests passing
|
||||
- ✅ format().combine().timestamp().printf() 链式调用正常工作
|
||||
- ✅ logger 创建、子 logger、日志输出测试全部通过
|
||||
@@ -165,8 +170,9 @@ vi.mock('winston', () => ({
|
||||
**失败测试**: `checks updates for user and auto-downloads available recommendation`
|
||||
|
||||
**错误信息**:
|
||||
|
||||
```
|
||||
AssertionError: expected "vi.fn()" to be called with arguments:
|
||||
AssertionError: expected "vi.fn()" to be called with arguments:
|
||||
['stable/1.1.0.exe', 'preview/1.1.0.exe']
|
||||
|
||||
Number of calls: 0
|
||||
@@ -175,6 +181,7 @@ Number of calls: 0
|
||||
**根因**: Mock 调用参数与实际调用不匹配
|
||||
|
||||
**代码位置**:
|
||||
|
||||
- 测试文件:`tests/unit/update-service.test.ts:165-175`
|
||||
- 被测文件:`src/main/services/update/update-service.ts`
|
||||
|
||||
@@ -200,12 +207,14 @@ it('checks updates for user and auto-downloads available recommendation', async
|
||||
**步骤 2.2.3**: 更新测试断言
|
||||
|
||||
**选项 A: 匹配实际调用**
|
||||
|
||||
```typescript
|
||||
// 如果实际只调用了一个参数
|
||||
expect(mockDownload).toHaveBeenCalledWith('stable/1.1.0.exe')
|
||||
```
|
||||
|
||||
**选项 B: 使用更松散的断言**
|
||||
|
||||
```typescript
|
||||
// 如果参数顺序或数量有变化
|
||||
expect(mockDownload).toHaveBeenCalled()
|
||||
@@ -213,14 +222,12 @@ expect(mockDownload.mock.calls[0]).toContain('stable/1.1.0.exe')
|
||||
```
|
||||
|
||||
**选项 C: 调整 mock 设置**
|
||||
|
||||
```typescript
|
||||
// 确保 mock 正确设置
|
||||
mockDownload.mockClear()
|
||||
// ... 触发动作 ...
|
||||
expect(mockDownload).toHaveBeenCalledWith(
|
||||
expect.stringContaining('stable'),
|
||||
expect.any(String)
|
||||
)
|
||||
expect(mockDownload).toHaveBeenCalledWith(expect.stringContaining('stable'), expect.any(String))
|
||||
```
|
||||
|
||||
#### 成功标准
|
||||
@@ -243,8 +250,9 @@ expect(mockDownload).toHaveBeenCalledWith(
|
||||
**失败测试**: `builds downloaded package path under userData pending-update`
|
||||
|
||||
**错误信息**:
|
||||
|
||||
```
|
||||
AssertionError: expected 'D:\...\test-user-data\pending-update\stable-1.2.3.exe'
|
||||
AssertionError: expected 'D:\...\test-user-data\pending-update\stable-1.2.3.exe'
|
||||
to contain 'logs\pending-update'
|
||||
|
||||
Expected: "logs\pending-update"
|
||||
@@ -254,6 +262,7 @@ Received: "D:\...\test-user-data\pending-update\stable-1.2.3.exe"
|
||||
**根因**: Electron mock 的 `app.getPath('userData')` 返回 `test-user-data`,但测试期望路径包含 `logs`
|
||||
|
||||
**代码位置**:
|
||||
|
||||
- 测试文件:`tests/unit/update-installer.test.ts:13-16`
|
||||
- Setup mock: `tests/setup.ts:17-24`
|
||||
|
||||
@@ -286,6 +295,7 @@ userData: path.join(process.cwd(), 'logs')
|
||||
```
|
||||
|
||||
**推荐**: 方案 2.3.1 (测试适应 mock)
|
||||
|
||||
- 理由:mock 是为了测试隔离,测试应该适应 mock 环境
|
||||
|
||||
#### 成功标准
|
||||
@@ -310,6 +320,7 @@ npm run test:run tests/unit/logger.test.ts
|
||||
```
|
||||
|
||||
**失败时排查**:
|
||||
|
||||
1. 检查 vi.mock 是否在文件顶部 (hoisted)
|
||||
2. 清除 vitest 缓存:`npx vitest --clearCache`
|
||||
3. 检查是否有多个 winston mock 冲突
|
||||
@@ -354,11 +365,11 @@ npm run test:run 2>&1 | Select-String "Test Files.*failed"
|
||||
|
||||
### 技术指标
|
||||
|
||||
| 指标 | 修复前 | 修复后 | 验证命令 |
|
||||
|------|-------|-------|---------|
|
||||
| 失败套件 | 3 suites | 0 suites | `npm run test:run` |
|
||||
| 失败测试 | 13 tests | 0 tests | `npm run test:run` |
|
||||
| 通过率 | 95% (311/327) | **100%** (327/327) | 测试报告 |
|
||||
| 指标 | 修复前 | 修复后 | 验证命令 |
|
||||
| -------- | ------------- | ------------------ | ------------------ |
|
||||
| 失败套件 | 3 suites | 0 suites | `npm run test:run` |
|
||||
| 失败测试 | 13 tests | 0 tests | `npm run test:run` |
|
||||
| 通过率 | 95% (311/327) | **100%** (327/327) | 测试报告 |
|
||||
|
||||
### 验收条件
|
||||
|
||||
@@ -373,11 +384,11 @@ npm run test:run 2>&1 | Select-String "Test Files.*failed"
|
||||
|
||||
### 技术风险
|
||||
|
||||
| 风险 | 可能性 | 影响 | 缓解措施 |
|
||||
|------|--------|------|---------|
|
||||
| logger mock 实现复杂 | 中 | 高 | 采用延迟 mock,先跑通一部分测试 |
|
||||
| 链式调用 mock 不完整 | 高 | 中 | 使用 createFormatFn 工厂函数 |
|
||||
| 循环依赖难解耦 | 低 | 高 | 只修复 mock,不重构依赖关系 |
|
||||
| 风险 | 可能性 | 影响 | 缓解措施 |
|
||||
| -------------------- | ------ | ---- | ------------------------------- |
|
||||
| logger mock 实现复杂 | 中 | 高 | 采用延迟 mock,先跑通一部分测试 |
|
||||
| 链式调用 mock 不完整 | 高 | 中 | 使用 createFormatFn 工厂函数 |
|
||||
| 循环依赖难解耦 | 低 | 高 | 只修复 mock,不重构依赖关系 |
|
||||
|
||||
### 时间风险
|
||||
|
||||
@@ -386,6 +397,7 @@ npm run test:run 2>&1 | Select-String "Test Files.*failed"
|
||||
- **保守估计**: 6 小时 (遇到意外问题)
|
||||
|
||||
**风险缓解**: 如果 logger mock 问题超过 3 小时无法解决,考虑:
|
||||
|
||||
1. 暂时跳过 logger.test.ts (保持 95% 通过率)
|
||||
2. 先修复简单的 update 测试 (13 failures → 2 failures)
|
||||
3. 记录问题,后续专门花精力解决
|
||||
@@ -401,6 +413,7 @@ npm run test:run 2>&1 | Select-String "Test Files.*failed"
|
||||
**实际工时**: X 小时
|
||||
|
||||
**修复步骤**:
|
||||
|
||||
1. [ ] 诊断 mock 问题
|
||||
2. [ ] 实现 formatFn 工厂
|
||||
3. [ ] 添加所有链式方法
|
||||
@@ -408,10 +421,12 @@ npm run test:run 2>&1 | Select-String "Test Files.*failed"
|
||||
5. [ ] 验证测试通过
|
||||
|
||||
**遇到的问题**:
|
||||
|
||||
- 问题 1: [描述] → 解决方案: [方案]
|
||||
- 问题 2: [描述] → 解决方案: [方案]
|
||||
|
||||
**关键代码**:
|
||||
|
||||
```typescript
|
||||
// 最终有效的 mock 实现
|
||||
```
|
||||
@@ -424,7 +439,8 @@ npm run test:run 2>&1 | Select-String "Test Files.*failed"
|
||||
**结束时间**: HH:MM
|
||||
**实际工时**: X 分钟
|
||||
|
||||
**修复方式**:
|
||||
**修复方式**:
|
||||
|
||||
- [ ] 修改断言
|
||||
- [ ] 修改 mock 参数
|
||||
- [ ] 其他: [描述]
|
||||
@@ -439,7 +455,8 @@ npm run test:run 2>&1 | Select-String "Test Files.*failed"
|
||||
**结束时间**: HH:MM
|
||||
**实际工时**: X 分钟
|
||||
|
||||
**修复方式**:
|
||||
**修复方式**:
|
||||
|
||||
- [ ] 修改断言
|
||||
- [ ] 修改 mock
|
||||
- [ ] 其他: [描述]
|
||||
781
docs/testing/TEST_COVERAGE_IMPROVEMENT_PLAN.md
Normal file
781
docs/testing/TEST_COVERAGE_IMPROVEMENT_PLAN.md
Normal file
@@ -0,0 +1,781 @@
|
||||
# ERPAuto 测试覆盖率提升计划
|
||||
|
||||
## 1. 执行摘要
|
||||
|
||||
### 1.1 当前状态评估
|
||||
|
||||
| 指标 | 当前值 | 目标值 | 差距 |
|
||||
| ------------------ | ------ | ------ | ------- |
|
||||
| **总体行覆盖率** | 11.36% | 70% | -58.64% |
|
||||
| **总体函数覆盖率** | 21.29% | 70% | -48.71% |
|
||||
| **总体分支覆盖率** | 10.08% | 60% | -49.92% |
|
||||
| **测试文件总数** | 54 | 100+ | -46+ |
|
||||
|
||||
**关键模块覆盖率差距:**
|
||||
|
||||
| 模块 | 当前覆盖率 | 要求阈值 | 优先级 |
|
||||
| ---------------------------------------- | ---------- | -------- | ------------- |
|
||||
| ERP 服务 (`src/main/services/erp/**`) | 11.68% | 80% | P0 |
|
||||
| 更新服务 (`src/main/services/update/**`) | 42.45% | 80% | P0 |
|
||||
| 数据库服务 | 17.24% | 70% | P1 |
|
||||
| 配置管理 | 20.56% | 70% | P1 |
|
||||
| 日志服务 | 70.67% | 70% | P2 (已达标的) |
|
||||
|
||||
### 1.2 提升目标
|
||||
|
||||
**阶段性目标:**
|
||||
|
||||
- **Phase 1 (4 周)**:ERP 服务达到 60%,更新服务达到 70%
|
||||
- **Phase 2 (4 周)**:数据库服务达到 60%,配置管理达到 60%
|
||||
- **Phase 3 (4 周)**:所有关键模块达到目标阈值,总体覆盖率达到 70%
|
||||
|
||||
**最终目标:**
|
||||
|
||||
- 全局覆盖率:70% 行 / 70% 函数 / 60% 分支
|
||||
- ERP 服务:80% 行 / 80% 函数 / 70% 分支
|
||||
- 更新服务:80% 行 / 80% 函数 / 70% 分支
|
||||
|
||||
### 1.3 时间线估算
|
||||
|
||||
| 阶段 | 持续时间 | 里程碑 |
|
||||
| -------- | --------- | ---------------------- |
|
||||
| Phase 1 | 4 周 | ERP 核心服务测试完成 |
|
||||
| Phase 2 | 4 周 | 数据层与配置层测试完成 |
|
||||
| Phase 3 | 4 周 | 集成测试与 E2E 补全 |
|
||||
| 缓冲期 | 2 周 | 修复与优化 |
|
||||
| **总计** | **14 周** | **达到目标覆盖率** |
|
||||
|
||||
---
|
||||
|
||||
## 2. 分阶段提升计划
|
||||
|
||||
### Phase 1: ERP 核心服务测试攻坚(第 1-4 周)
|
||||
|
||||
**目标:** ERP 服务覆盖率从 11.68% 提升至 60%
|
||||
|
||||
**工作内容:**
|
||||
|
||||
| 模块 | 文件数 | 新增测试数 | 优先级 |
|
||||
| ---------------------- | ------ | ---------- | ------ |
|
||||
| `erp-auth.ts` | 1 | 15 | P0 |
|
||||
| `extractor.ts` | 1 | 20 | P0 |
|
||||
| `extractor-core.ts` | 1 | 15 | P0 |
|
||||
| `cleaner.ts` | 1 | 12 | P0 |
|
||||
| `ErpBrowserManager.ts` | 1 | 10 | P1 |
|
||||
| `order-resolver.ts` | 1 | 8 | P1 |
|
||||
| `page-diagnostics.ts` | 1 | 6 | P2 |
|
||||
| `erp-error-context.ts` | 1 | 5 | P2 |
|
||||
| `locators.ts` | 1 | 8 | P1 |
|
||||
|
||||
**预计投入:** 80-100 小时
|
||||
|
||||
**成功标准:**
|
||||
|
||||
- [ ] ERP 服务行覆盖率 ≥ 60%
|
||||
- [ ] ERP 服务函数覆盖率 ≥ 70%
|
||||
- [ ] 新增测试文件:9 个
|
||||
- [ ] 所有 P0 模块有完整测试覆盖
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: 数据层与配置层测试(第 5-8 周)
|
||||
|
||||
**目标:** 数据库服务与配置管理覆盖率达标
|
||||
|
||||
**工作内容:**
|
||||
|
||||
#### 2.1 数据库服务(17.24% → 60%)
|
||||
|
||||
| 模块 | 文件数 | 新增测试数 | 优先级 |
|
||||
| ---------------------------------------------- | ------ | ---------- | ------ |
|
||||
| `mysql.ts` / `sql-server.ts` / `postgresql.ts` | 3 | 18 | P0 |
|
||||
| `data-source.ts` | 1 | 8 | P0 |
|
||||
| `data-importer.ts` | 1 | 10 | P0 |
|
||||
| DAO 层文件 | 4 | 16 | P1 |
|
||||
| Repository 层 | 2 | 8 | P1 |
|
||||
| 数据库实体 | 2 | 6 | P2 |
|
||||
|
||||
#### 2.2 配置管理(20.56% → 60%)
|
||||
|
||||
| 模块 | 文件数 | 新增测试数 | 优先级 |
|
||||
| ------------------- | ------ | ---------- | ------ |
|
||||
| `config-manager.ts` | 1 | 20 | P0 |
|
||||
| 配置 Schema 验证 | 1 | 10 | P1 |
|
||||
|
||||
#### 2.3 用户服务(新增)
|
||||
|
||||
| 模块 | 文件数 | 新增测试数 | 优先级 |
|
||||
| ---------------------------- | ------ | ---------- | ------ |
|
||||
| `session-manager.ts` | 1 | 8 | P1 |
|
||||
| `user-erp-config-service.ts` | 1 | 10 | P1 |
|
||||
| `bip-users-dao.ts` | 1 | 6 | P2 |
|
||||
|
||||
**预计投入:** 100-120 小时
|
||||
|
||||
**成功标准:**
|
||||
|
||||
- [ ] 数据库服务行覆盖率 ≥ 60%
|
||||
- [ ] 配置管理行覆盖率 ≥ 60%
|
||||
- [ ] 新增测试文件:15 个
|
||||
- [ ] 所有数据库方言有完整测试
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: 更新服务与其他模块补全(第 9-12 周)
|
||||
|
||||
**目标:** 更新服务达到 80%,其他服务达到 70%
|
||||
|
||||
**工作内容:**
|
||||
|
||||
#### 3.1 更新服务(42.45% → 80%)
|
||||
|
||||
| 模块 | 文件数 | 新增测试数 | 优先级 |
|
||||
| ---------------------------- | ------ | ---------- | ------ |
|
||||
| `update-service.ts` | 1 | 15 | P0 |
|
||||
| `update-catalog-service.ts` | 1 | 12 | P0 |
|
||||
| `update-installer.ts` | 1 | 10 | P0 |
|
||||
| `update-storage-client.ts` | 1 | 10 | P0 |
|
||||
| `update-status-publisher.ts` | 1 | 6 | P1 |
|
||||
| `update-support.ts` | 1 | 5 | P1 |
|
||||
| `update-utils.ts` | 1 | 5 | P2 |
|
||||
|
||||
#### 3.2 其他关键服务
|
||||
|
||||
| 模块 | 文件数 | 新增测试数 | 优先级 |
|
||||
| -------------------------- | ------ | ---------- | ------ |
|
||||
| 验证服务 (`validation/**`) | 3 | 15 | P1 |
|
||||
| 清理服务 (`cleaner/**`) | 2 | 10 | P1 |
|
||||
| Excel 服务 | 2 | 8 | P2 |
|
||||
| 报告生成 | 1 | 6 | P2 |
|
||||
| Playwright 浏览器服务 | 2 | 10 | P1 |
|
||||
| RustFS 服务 | 2 | 8 | P2 |
|
||||
|
||||
**预计投入:** 100-120 小时
|
||||
|
||||
**成功标准:**
|
||||
|
||||
- [ ] 更新服务行覆盖率 ≥ 80%
|
||||
- [ ] 更新服务函数覆盖率 ≥ 80%
|
||||
- [ ] 新增测试文件:17 个
|
||||
- [ ] 所有 P0/P1 模块覆盖率达标
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: 集成测试与 E2E 强化(第 13-14 周)
|
||||
|
||||
**目标:** 强化集成测试与端到端测试
|
||||
|
||||
**工作内容:**
|
||||
|
||||
#### 4.1 集成测试扩展(7 → 20 个)
|
||||
|
||||
| 测试场景 | 优先级 | 描述 |
|
||||
| -------------------------- | ------ | ---------------------- |
|
||||
| ERP 登录 + 提取完整流程 | P0 | 验证认证与数据提取集成 |
|
||||
| 数据库事务完整流程 | P0 | 验证 TypeORM 事务边界 |
|
||||
| 配置热加载与验证 | P1 | 验证配置更新传播 |
|
||||
| 更新检查 + 下载 + 安装流程 | P0 | 验证更新完整链路 |
|
||||
| 日志异步写入与轮转 | P1 | 验证日志系统 |
|
||||
| 用户会话切换流程 | P1 | 验证多用户场景 |
|
||||
| Excel 导入导出完整流程 | P2 | 验证文件处理链 |
|
||||
|
||||
#### 4.2 E2E 测试扩展(3 → 15 个)
|
||||
|
||||
| 用户旅程 | 优先级 | 描述 |
|
||||
| -------------------- | ------ | -------------------------------- |
|
||||
| 管理员完整工作流程 | P0 | 登录 → 提取 → 清理 → 验证 → 登出 |
|
||||
| 普通用户数据提取流程 | P0 | 登录 → 提取 → 查看结果 |
|
||||
| Guest 只读访问流程 | P1 | 登录 → 查看历史记录 |
|
||||
| 配置管理流程 | P1 | 修改配置 → 保存 → 验证生效 |
|
||||
| 自动更新流程 | P0 | 检查更新 → 下载 → 安装 → 重启 |
|
||||
| 错误恢复流程 | P1 | 断网重连、会话过期恢复 |
|
||||
| 批量处理流程 | P1 | 大批量订单处理性能验证 |
|
||||
|
||||
**预计投入:** 60-80 小时
|
||||
|
||||
**成功标准:**
|
||||
|
||||
- [ ] 集成测试文件:20 个
|
||||
- [ ] E2E 测试文件:15 个
|
||||
- [ ] 关键用户旅程 100% 覆盖
|
||||
- [ ] 整体覆盖率达到 70%
|
||||
|
||||
---
|
||||
|
||||
## 3. 逐模块测试计划
|
||||
|
||||
### 3.1 ERP 服务模块
|
||||
|
||||
#### 3.1.1 `erp-auth.ts` (P0)
|
||||
|
||||
**当前覆盖率:** < 20%
|
||||
**目标覆盖率:** 80%
|
||||
|
||||
| 测试场景 | 测试类型 | Mock 对象 | 预期结果 |
|
||||
| ------------------- | -------- | ------------------------------- | ------------------- |
|
||||
| 成功登录流程 | 单元 | Playwright Browser/Context/Page | 返回有效 ErpSession |
|
||||
| 登录失败 - 网络错误 | 单元 | Playwright + 模拟网络错误 | 抛出连接错误 |
|
||||
| 登录失败 - 凭证错误 | 单元 | Page + 模拟错误消息 | 抛出认证错误 |
|
||||
| 会话复用 - 已登录 | 单元 | Session Mock | 直接返回现有会话 |
|
||||
| 登出流程 | 单元 | Browser/Context Mock | 资源正确释放 |
|
||||
| 会话超时检测 | 单元 | Page + 超时 Mock | 返回未登录状态 |
|
||||
| 页面元素定位失败 | 单元 | Page + Selector 失败 | 抛出元素未找到错误 |
|
||||
| SSL 证书错误处理 | 集成 | 真实 Browser + 自签名证书 | 成功建立连接 |
|
||||
|
||||
**预计测试数:** 15
|
||||
|
||||
---
|
||||
|
||||
#### 3.1.2 `extractor.ts` (P0)
|
||||
|
||||
**当前覆盖率:** ~30%
|
||||
**目标覆盖率:** 80%
|
||||
|
||||
| 测试场景 | 测试类型 | Mock 对象 | 预期结果 |
|
||||
| -------------- | -------- | -------------------------- | -------------------- |
|
||||
| 单订单提取成功 | 单元 | ErpAuthService + Page | 返回 ExtractorResult |
|
||||
| 批量订单提取 | 单元 | ErpAuthService + 循环 Mock | 正确分批处理 |
|
||||
| 订单号无效处理 | 单元 | Page + 错误响应 | 记录错误,继续处理 |
|
||||
| 下载文件合并 | 单元 | ExcelJS + fs Mock | 生成合并文件 |
|
||||
| 数据库持久化 | 集成 | DatabaseService Mock | 记录成功导入 |
|
||||
| 并发限制控制 | 单元 | 信号量 Mock | 不超过并发上限 |
|
||||
| 提取中断恢复 | 集成 | 模拟中断 + 恢复 | 从断点继续 |
|
||||
| 结果统计准确性 | 单元 | 完整 Mock 链 | 统计数字准确 |
|
||||
|
||||
**预计测试数:** 20
|
||||
|
||||
---
|
||||
|
||||
#### 3.1.3 `extractor-core.ts` (P0)
|
||||
|
||||
**当前覆盖率:** < 10%
|
||||
**目标覆盖率:** 80%
|
||||
|
||||
| 测试场景 | 测试类型 | Mock 对象 | 预期结果 |
|
||||
| ---------------- | -------- | ---------------- | ------------ |
|
||||
| 页面导航到列表页 | 单元 | Page + Frame | 成功导航 |
|
||||
| 订单号输入 | 单元 | Locator Mock | 正确填充 |
|
||||
| 查询按钮点击 | 单元 | Locator Mock | 触发查询 |
|
||||
| 表格数据解析 | 单元 | Table Locator | 返回物料列表 |
|
||||
| 分页处理 | 单元 | Page + 多页 Mock | 遍历所有页 |
|
||||
| 下载按钮点击 | 单元 | Locator + Dialog | 触发下载 |
|
||||
| 下载完成等待 | 单元 | fs + 文件事件 | 文件落地 |
|
||||
| 错误弹窗检测 | 单元 | Page + 错误元素 | 捕获错误消息 |
|
||||
|
||||
**预计测试数:** 15
|
||||
|
||||
---
|
||||
|
||||
#### 3.1.4 `cleaner.ts` (P0)
|
||||
|
||||
**当前覆盖率:** ~25%
|
||||
**目标覆盖率:** 80%
|
||||
|
||||
| 测试场景 | 测试类型 | Mock 对象 | 预期结果 |
|
||||
| ---------------- | -------- | ------------------ | ------------ |
|
||||
| 单物料删除成功 | 单元 | Page + Locator | 删除成功 |
|
||||
| 批量物料删除 | 单元 | 循环删除 Mock | 全部删除 |
|
||||
| 物料不存在处理 | 单元 | Page + 空结果 | 跳过并记录 |
|
||||
| 删除按钮失效处理 | 单元 | Locator + disabled | 跳过该物料 |
|
||||
| 干运行模式 | 单元 | 不执行实际删除 | 返回预览结果 |
|
||||
| 并发控制 | 单元 | 信号量 Mock | 限制并发数 |
|
||||
| 错误重试机制 | 集成 | 失败→成功 Mock | 重试成功 |
|
||||
| 删除结果统计 | 单元 | 完整 Mock 链 | 统计准确 |
|
||||
|
||||
**预计测试数:** 12
|
||||
|
||||
---
|
||||
|
||||
### 3.2 数据库服务模块
|
||||
|
||||
#### 3.2.1 数据库连接服务 (P0)
|
||||
|
||||
**文件:** `mysql.ts`, `sql-server.ts`, `postgresql.ts`
|
||||
|
||||
**当前覆盖率:** ~20%
|
||||
**目标覆盖率:** 70%
|
||||
|
||||
| 测试场景 | 测试类型 | Mock 对象 | 预期结果 |
|
||||
| ------------------- | -------- | ----------------------- | ------------ |
|
||||
| MySQL 连接成功 | 单元 | mysql2 Pool Mock | 返回连接实例 |
|
||||
| SQL Server 连接成功 | 单元 | mssql Connection Mock | 返回连接实例 |
|
||||
| PostgreSQL 连接成功 | 单元 | pg Pool Mock | 返回连接实例 |
|
||||
| 连接失败处理 | 单元 | 模拟连接拒绝 | 抛出错误 |
|
||||
| 查询执行成功 | 集成 | 数据库 Mock + 返回结果 | 正确返回数据 |
|
||||
| 事务提交 | 集成 | Transaction Mock | 成功提交 |
|
||||
| 事务回滚 | 集成 | Transaction Mock + 错误 | 正确回滚 |
|
||||
| 连接池释放 | 单元 | Pool Mock | 正确关闭 |
|
||||
|
||||
**预计测试数:** 18 (3 个数据库 × 6 场景)
|
||||
|
||||
---
|
||||
|
||||
#### 3.2.2 数据源管理 (P0)
|
||||
|
||||
**文件:** `data-source.ts`
|
||||
|
||||
**当前覆盖率:** < 10%
|
||||
**目标覆盖率:** 70%
|
||||
|
||||
| 测试场景 | 测试类型 | Mock 对象 | 预期结果 |
|
||||
| --------------- | -------- | --------------- | -------------- |
|
||||
| TypeORM 初始化 | 单元 | DataSource Mock | 成功初始化 |
|
||||
| 数据源销毁 | 单元 | DataSource Mock | 正确释放 |
|
||||
| Repository 获取 | 单元 | Repository Mock | 返回对应仓库 |
|
||||
| 实体注册验证 | 单元 | Entity Mock | 所有实体已注册 |
|
||||
| 多次初始化防护 | 单元 | 状态检查 Mock | 不重复初始化 |
|
||||
|
||||
**预计测试数:** 8
|
||||
|
||||
---
|
||||
|
||||
#### 3.2.3 数据导入器 (P0)
|
||||
|
||||
**文件:** `data-importer.ts`
|
||||
|
||||
**当前覆盖率:** < 15%
|
||||
**目标覆盖率:** 70%
|
||||
|
||||
| 测试场景 | 测试类型 | Mock 对象 | 预期结果 |
|
||||
| -------------- | -------- | ------------------------ | ------------ |
|
||||
| Excel 读取成功 | 集成 | ExcelJS + 测试文件 | 解析数据结构 |
|
||||
| 数据验证通过 | 单元 | Schema 验证 Mock | 数据合法 |
|
||||
| 数据验证失败 | 单元 | Schema 验证 Mock | 抛出验证错误 |
|
||||
| 批量插入 | 集成 | Repository Mock | 正确分批插入 |
|
||||
| 重复数据处理 | 单元 | Repository + exists 检查 | 跳过或更新 |
|
||||
| 插入失败回滚 | 集成 | Transaction Mock + 错误 | 全部回滚 |
|
||||
| 导入进度追踪 | 单元 | EventEmitter Mock | 发送进度事件 |
|
||||
| 导入结果统计 | 单元 | 完整 Mock 链 | 统计准确 |
|
||||
|
||||
**预计测试数:** 10
|
||||
|
||||
---
|
||||
|
||||
### 3.3 配置管理模块
|
||||
|
||||
#### 3.3.1 `config-manager.ts` (P0)
|
||||
|
||||
**当前覆盖率:** ~25%
|
||||
**目标覆盖率:** 70%
|
||||
|
||||
| 测试场景 | 测试类型 | Mock 对象 | 预期结果 |
|
||||
| ---------------- | -------- | -------------------- | ------------ |
|
||||
| 配置文件加载成功 | 单元 | fs + yaml Mock | 返回有效配置 |
|
||||
| 配置文件不存在 | 单元 | fs Mock + 不存在 | 使用默认配置 |
|
||||
| 配置文件格式错误 | 单元 | yaml Mock + 解析失败 | 抛出解析错误 |
|
||||
| Zod 验证失败 | 单元 | 无效配置数据 | 抛出验证错误 |
|
||||
| 配置更新 | 单元 | fs + yaml Mock | 文件正确写入 |
|
||||
| 重置为默认值 | 单元 | 完整 Mock 链 | 恢复默认 |
|
||||
| 导出为 YAML | 单元 | yaml.stringify Mock | 格式正确 |
|
||||
| 数据库类型切换 | 单元 | 状态 Mock | 返回正确配置 |
|
||||
| 日志配置应用 | 集成 | Winston Mock | 日志级别生效 |
|
||||
| 审计配置应用 | 集成 | AuditLogger Mock | 审计配置生效 |
|
||||
| 单例模式验证 | 单元 | 多次 getInstance | 返回同一实例 |
|
||||
| 并发读取安全 | 集成 | 并发 Mock + 竞争 | 数据一致 |
|
||||
|
||||
**预计测试数:** 20
|
||||
|
||||
---
|
||||
|
||||
### 3.4 更新服务模块
|
||||
|
||||
#### 3.4.1 `update-service.ts` (P0)
|
||||
|
||||
**当前覆盖率:** ~50%
|
||||
**目标覆盖率:** 80%
|
||||
|
||||
| 测试场景 | 测试类型 | Mock 对象 | 预期结果 |
|
||||
| ------------------- | -------- | ------------------------- | ------------ |
|
||||
| 服务初始化 | 单元 | ConfigManager + 依赖 Mock | 服务就绪 |
|
||||
| 获取更新状态 | 单元 | 状态 Mock | 返回当前状态 |
|
||||
| 获取更新目录 | 单元 | CatalogService Mock | 返回目录结构 |
|
||||
| 检查更新 - 有新版本 | 集成 | S3Client Mock + 新版本 | 返回更新列表 |
|
||||
| 检查更新 - 无新版本 | 集成 | S3Client Mock + 最新版 | 返回空列表 |
|
||||
| 下载更新 - 成功 | 集成 | S3Client + fs Mock | 文件下载成功 |
|
||||
| 下载更新 - 失败 | 集成 | S3Client + 网络错误 | 抛出错误 |
|
||||
| 校验 SHA256 - 通过 | 单元 | crypto Mock | 校验通过 |
|
||||
| 校验 SHA256 - 失败 | 单元 | crypto Mock + 不匹配 | 抛出校验错误 |
|
||||
| 安装更新 | 集成 | child_process Mock | 启动安装器 |
|
||||
| 用户权限检查 | 单元 | UserType Mock | 正确过滤 |
|
||||
| 定期自动检查 | 集成 | setInterval Mock | 按时检查 |
|
||||
|
||||
**预计测试数:** 15
|
||||
|
||||
---
|
||||
|
||||
#### 3.4.2 `update-catalog-service.ts` (P0)
|
||||
|
||||
**当前覆盖率:** ~40%
|
||||
**目标覆盖率:** 80%
|
||||
|
||||
| 测试场景 | 测试类型 | Mock 对象 | 预期结果 |
|
||||
| ------------ | -------- | ------------------ | ------------ |
|
||||
| 构建更新目录 | 单元 | StorageClient Mock | 返回分类目录 |
|
||||
| 稳定版过滤 | 单元 | UserType + 目录 | 只看 stable |
|
||||
| 管理员全访问 | 单元 | AdminType + 目录 | 看全部通道 |
|
||||
| 更新历史记录 | 单元 | Repository Mock | 返回历史记录 |
|
||||
| 限制记录数量 | 单元 | 数据截断 | 不超过上限 |
|
||||
|
||||
**预计测试数:** 12
|
||||
|
||||
---
|
||||
|
||||
#### 3.4.3 `update-storage-client.ts` (P0)
|
||||
|
||||
**当前覆盖率:** ~35%
|
||||
**目标覆盖率:** 80%
|
||||
|
||||
| 测试场景 | 测试类型 | Mock 对象 | 预期结果 |
|
||||
| --------------- | -------- | ------------------- | -------------- |
|
||||
| S3 客户端初始化 | 单元 | AWS SDK Mock | 客户端创建成功 |
|
||||
| 列出更新包 | 单元 | S3 listObjects Mock | 返回对象列表 |
|
||||
| 下载文件 | 单元 | S3 getObject Mock | 返回文件流 |
|
||||
| 下载失败处理 | 单元 | S3 + 网络错误 | 抛出错误 |
|
||||
| 计算 SHA256 | 单元 | crypto Mock | 哈希值正确 |
|
||||
| 重试机制 | 集成 | 失败→成功 Mock | 重试成功 |
|
||||
|
||||
**预计测试数:** 10
|
||||
|
||||
---
|
||||
|
||||
## 4. 测试类别实施指南
|
||||
|
||||
### 4.1 单元测试
|
||||
|
||||
**适用范围:**
|
||||
|
||||
- 服务类(Service)的业务逻辑
|
||||
- 工具函数(Utility Functions)
|
||||
- 数据处理函数
|
||||
- 类型转换函数
|
||||
|
||||
**Mock 策略:**
|
||||
|
||||
```typescript
|
||||
// 使用现有 Mock 库
|
||||
import {
|
||||
createMockLogger,
|
||||
createMockConfigManager,
|
||||
createMockErpAuthService,
|
||||
createMockDatabaseService,
|
||||
createMockDataSource,
|
||||
createMockRepository
|
||||
} from '@/tests/mocks'
|
||||
|
||||
// 示例:ERP Auth 测试
|
||||
describe('ErpAuthService', () => {
|
||||
const mockConfig = { url: 'https://test.com', username: 'test', password: 'test' }
|
||||
const mockPage = createMockPage() // 来自 mocks/index.ts
|
||||
|
||||
it('should login successfully', async () => {
|
||||
mockPage.goto.mockResolvedValue(undefined)
|
||||
mockPage.waitForSelector.mockResolvedValue(undefined)
|
||||
|
||||
const authService = new ErpAuthService(mockConfig)
|
||||
// 注入 mock (需要构造函数支持或使用 vi.mock)
|
||||
const session = await authService.login()
|
||||
|
||||
expect(session.isLoggedIn).toBe(true)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
**测试覆盖重点:**
|
||||
|
||||
1. **正常路径:** 主要业务流程成功执行
|
||||
2. **异常路径:** 错误处理、回滚、重试
|
||||
3. **边界条件:** 空输入、极大值、极小值
|
||||
4. **分支覆盖:** if/else、switch/case 所有分支
|
||||
|
||||
---
|
||||
|
||||
### 4.2 集成测试
|
||||
|
||||
**适用范围:**
|
||||
|
||||
- 多服务协作场景
|
||||
- 数据库事务边界
|
||||
- 文件系统交互
|
||||
- 外部服务调用(需 Stub)
|
||||
|
||||
**测试模式:**
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { DatabaseService } from '@/main/services/database'
|
||||
import { ConfigManager } from '@/main/services/config'
|
||||
|
||||
describe('Database + Config Integration', () => {
|
||||
let db: DatabaseService
|
||||
let configManager: ConfigManager
|
||||
|
||||
beforeEach(async () => {
|
||||
// 使用内存数据库或测试配置
|
||||
configManager = ConfigManager.getInstance()
|
||||
db = new DatabaseService(configManager)
|
||||
await db.connect()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await db.disconnect()
|
||||
})
|
||||
|
||||
it('should persist and retrieve data', async () => {
|
||||
// 实际数据库操作
|
||||
await db.query('INSERT INTO ...')
|
||||
const result = await db.query('SELECT ...')
|
||||
|
||||
expect(result.rows).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
**集成测试清单:**
|
||||
|
||||
| 集成场景 | 涉及模块 | 预期时间 |
|
||||
| --------------- | --------------------------- | -------- |
|
||||
| ERP 登录 + 提取 | ErpAuth + Extractor | < 5s |
|
||||
| 数据库事务 | DataSource + Repository | < 2s |
|
||||
| 配置更新传播 | ConfigManager + Logger | < 1s |
|
||||
| 文件导入导出 | ExcelParser + fs | < 3s |
|
||||
| 更新下载校验 | UpdateService + S3 + crypto | < 10s |
|
||||
|
||||
---
|
||||
|
||||
### 4.3 E2E 测试
|
||||
|
||||
**适用范围:**
|
||||
|
||||
- 完整用户旅程
|
||||
- UI 交互验证
|
||||
- 真实浏览器行为
|
||||
- 跨进程通信
|
||||
|
||||
**Playwright 测试模式:**
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test('complete extraction workflow', async ({ page }) => {
|
||||
// 1. 导航到登录页
|
||||
await page.goto('http://localhost:5173/login')
|
||||
|
||||
// 2. 登录
|
||||
await page.getByPlaceholder('用户名').fill('admin')
|
||||
await page.getByPlaceholder('密码').fill('admin123')
|
||||
await page.getByRole('button', { name: '登录' }).click()
|
||||
|
||||
// 3. 等待跳转
|
||||
await expect(page).toHaveURL(/dashboard/)
|
||||
|
||||
// 4. 进入提取页面
|
||||
await page.getByText('数据提取').click()
|
||||
|
||||
// 5. 输入订单号
|
||||
await page.getByPlaceholder('请输入订单号').fill('SC202601001')
|
||||
|
||||
// 6. 开始提取
|
||||
await page.getByRole('button', { name: '开始提取' }).click()
|
||||
|
||||
// 7. 等待完成
|
||||
await expect(page.getByText('提取完成')).toBeVisible({ timeout: 30000 })
|
||||
|
||||
// 8. 验证结果
|
||||
await expect(page.getByText('记录数:')).toBeVisible()
|
||||
})
|
||||
```
|
||||
|
||||
**E2E 测试关键场景:**
|
||||
|
||||
| 用户旅程 | 步骤数 | 预期时间 | 优先级 |
|
||||
| ---------------- | ------ | -------- | ------ |
|
||||
| 管理员完整工作流 | 15 | < 60s | P0 |
|
||||
| 普通用户提取 | 8 | < 45s | P0 |
|
||||
| 配置管理 | 10 | < 30s | P1 |
|
||||
| 自动更新 | 8 | < 90s | P0 |
|
||||
| 错误恢复 | 6 | < 40s | P1 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 资源与工作量估算
|
||||
|
||||
### 5.1 人员配置建议
|
||||
|
||||
| 角色 | 人数 | 职责 |
|
||||
| -------------- | -------- | ----------------------- |
|
||||
| 测试开发工程师 | 2 人 | 单元测试、集成测试编写 |
|
||||
| 全栈工程师 | 1 人 | E2E 测试、Mock 基础设施 |
|
||||
| 代码审查员 | 1 人 | 测试代码质量审查 |
|
||||
| **总计** | **4 人** | **14 周完成** |
|
||||
|
||||
**单人模式调整:**
|
||||
|
||||
若只有 1 人负责,时间调整为:
|
||||
|
||||
- 周投入:20-25 小时
|
||||
- 总周期:20-24 周
|
||||
- 优先级:P0 → P1 → P2
|
||||
|
||||
---
|
||||
|
||||
### 5.2 工作量分解
|
||||
|
||||
| 阶段 | 任务 | 估算小时 |
|
||||
| -------- | ----------------- | ---------------- |
|
||||
| Phase 1 | ERP 服务单元测试 | 80-100 |
|
||||
| | Mock 基础设施优化 | 10-15 |
|
||||
| Phase 2 | 数据库单元测试 | 60-80 |
|
||||
| | 配置单元测试 | 20-30 |
|
||||
| | 集成测试 | 20-30 |
|
||||
| Phase 3 | 更新服务测试 | 60-80 |
|
||||
| | 其他服务测试 | 40-50 |
|
||||
| Phase 4 | E2E 测试 | 40-60 |
|
||||
| | 覆盖率优化 | 20-30 |
|
||||
| **总计** | | **350-475 小时** |
|
||||
|
||||
---
|
||||
|
||||
### 5.3 风险因素
|
||||
|
||||
| 风险 | 可能性 | 影响 | 缓解措施 |
|
||||
| --------------------------- | ------ | ---- | ------------------------ |
|
||||
| Playwright 浏览器兼容性问题 | 中 | 高 | 提前验证浏览器版本 |
|
||||
| 数据库连接不稳定 | 低 | 中 | 使用内存数据库或容器 |
|
||||
| Mock 与实现不同步 | 高 | 中 | 定期同步,添加类型检查 |
|
||||
| 测试维护成本过高 | 中 | 中 | 使用工厂模式,避免硬编码 |
|
||||
| 覆盖率工具性能影响 | 低 | 低 | CI 中仅对变更文件检查 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 成功度量标准
|
||||
|
||||
### 6.1 覆盖率指标
|
||||
|
||||
| 里程碑 | 总体行覆盖率 | ERP 服务 | 更新服务 | 数据库 |
|
||||
| ------------ | ------------ | -------- | -------- | ------- |
|
||||
| Phase 1 完成 | 25% | 60% | 50% | 25% |
|
||||
| Phase 2 完成 | 45% | 65% | 60% | 60% |
|
||||
| Phase 3 完成 | 65% | 75% | 80% | 65% |
|
||||
| Phase 4 完成 | **70%** | **80%** | **80%** | **70%** |
|
||||
|
||||
---
|
||||
|
||||
### 6.2 测试数量目标
|
||||
|
||||
| 类型 | 当前 | Phase 1 | Phase 2 | Phase 3 | Phase 4 |
|
||||
| -------------- | ------ | ------- | ------- | ------- | ------- |
|
||||
| 单元测试文件 | 40 | 50 | 60 | 75 | 85 |
|
||||
| 集成测试文件 | 7 | 8 | 12 | 15 | 20 |
|
||||
| E2E 测试文件 | 3 | 3 | 3 | 5 | 15 |
|
||||
| **总测试文件** | **50** | **61** | **75** | **95** | **120** |
|
||||
|
||||
---
|
||||
|
||||
### 6.3 质量门禁
|
||||
|
||||
**每个 PR 必须满足:**
|
||||
|
||||
1. **新增代码覆盖率 ≥ 80%** (使用 `vitest --coverage --changed`)
|
||||
2. **无测试失败**
|
||||
3. **测试执行时间 < 30s** (单元测试) / < 120s (集成) / < 5min (E2E)
|
||||
4. **无 Mock 滥用** (真实逻辑必须有真实测试)
|
||||
|
||||
**CI/CD 检查:**
|
||||
|
||||
```yaml
|
||||
# GitHub Actions 示例
|
||||
- name: Test & Coverage
|
||||
run: |
|
||||
npm run test:coverage
|
||||
# 检查覆盖率阈值
|
||||
npx vitest --coverage --thresholds
|
||||
# 生成报告
|
||||
npx vitest --coverage --reporter=html
|
||||
# 上传覆盖率
|
||||
uses: codecov/codecov-action@v4
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 立即行动项(本周)
|
||||
|
||||
### 7.1 优先级 P0 - 必须完成
|
||||
|
||||
| 任务 | 负责人 | 截止日期 | 状态 |
|
||||
| -------------------------------- | ------ | -------- | ---- |
|
||||
| 创建 ERP Auth 测试文件框架 | - | Day 2 | ☐ |
|
||||
| 创建 Extractor Core 测试文件框架 | - | Day 3 | ☐ |
|
||||
| 扩展现有 Mock 库支持新增场景 | - | Day 4 | ☐ |
|
||||
| 运行首次覆盖率基准测试 | - | Day 1 | ☐ |
|
||||
|
||||
### 7.2 优先级 P1 - 建议完成
|
||||
|
||||
| 任务 | 负责人 | 截止日期 | 状态 |
|
||||
| -------------------------- | ------ | -------- | ---- |
|
||||
| 整理现有测试文件结构 | - | Day 3 | ☐ |
|
||||
| 创建测试模板和最佳实践文档 | - | Day 5 | ☐ |
|
||||
| 设置覆盖率 CI 报告 | - | Day 5 | ☐ |
|
||||
|
||||
### 7.3 技术准备清单
|
||||
|
||||
```bash
|
||||
# 1. 安装覆盖率报告工具
|
||||
npm install --save-dev @vitest/coverage-v8
|
||||
|
||||
# 2. 运行基准测试
|
||||
npm run test:coverage
|
||||
|
||||
# 3. 查看 HTML 报告
|
||||
npm run test:coverage
|
||||
# 打开 coverage/index.html
|
||||
|
||||
# 4. 按文件查看详细覆盖率
|
||||
npx vitest --coverage --reporter=verbose
|
||||
```
|
||||
|
||||
### 7.4 第一个 Sprint 目标(Week 1-2)
|
||||
|
||||
**目标:ERP Auth 测试完成 50%**
|
||||
|
||||
- [ ] `tests/unit/services/erp/erp-auth.test.ts` 创建
|
||||
- [ ] 成功登录场景测试(3 个)
|
||||
- [ ] 失败场景测试(5 个)
|
||||
- [ ] 会话管理测试(3 个)
|
||||
- [ ] Mock 优化支持 Page 生命周期事件
|
||||
- [ ] 运行测试,覆盖率 ≥ 40%
|
||||
|
||||
---
|
||||
|
||||
## 附录
|
||||
|
||||
### A. 现有测试资源
|
||||
|
||||
| 资源 | 路径 | 状态 |
|
||||
| --------- | ---------------------------- | ------------------ |
|
||||
| 测试设置 | `tests/setup.ts` | 完整 Electron Mock |
|
||||
| 测试工厂 | `tests/fixtures/factory.ts` | 8 个工厂类 |
|
||||
| Mock 库 | `tests/mocks/index.ts` | 15+ Mock 函数 |
|
||||
| 测试文档 | `docs/TEST_FACTORY_USAGE.md` | 工厂使用指南 |
|
||||
| Mock 文档 | `docs/MOCK_LIBRARY_USAGE.md` | Mock 使用指南 |
|
||||
|
||||
### B. 推荐测试工具
|
||||
|
||||
| 工具 | 用途 |
|
||||
| ---------------------- | ------------- |
|
||||
| `vitest` | 单元测试框架 |
|
||||
| `@playwright/test` | E2E 测试框架 |
|
||||
| `@vitest/coverage-v8` | V8 覆盖率引擎 |
|
||||
| `vitest-html-reporter` | HTML 报告生成 |
|
||||
|
||||
### C. 相关文件
|
||||
|
||||
- `vitest.config.ts` - Vitest 配置与覆盖率阈值
|
||||
- `package.json` - 测试脚本定义
|
||||
- `.github/workflows/test.yml` - CI 测试工作流
|
||||
|
||||
---
|
||||
|
||||
**文档版本:** 1.0
|
||||
**创建日期:** 2026-04-05
|
||||
**最后更新:** 2026-04-05
|
||||
**维护者:** ERPAuto 开发团队
|
||||
931
docs/testing/TEST_QUALITY_REVIEW_REPORT.md
Normal file
931
docs/testing/TEST_QUALITY_REVIEW_REPORT.md
Normal file
@@ -0,0 +1,931 @@
|
||||
# ERPAuto 测试质量审查报告
|
||||
|
||||
**审查日期**: 2026-04-05
|
||||
**审查范围**: 新增的 ERP 服务单元测试文件
|
||||
**审查者**: AI Code Review Agent
|
||||
|
||||
---
|
||||
|
||||
## 执行摘要
|
||||
|
||||
本次审查覆盖了 6 个新增的 ERP 服务单元测试文件,共计 **117 个测试用例**(114 个通过,3 个待实现)。测试整体质量**优秀**,符合企业级测试标准。
|
||||
|
||||
### 总体评分:**A (90/100)**
|
||||
|
||||
| 评估维度 | 得分 | 权重 | 加权分 |
|
||||
| ------------ | ------ | -------- | -------- |
|
||||
| 测试覆盖率 | 85/100 | 30% | 25.5 |
|
||||
| 测试设计质量 | 92/100 | 25% | 23.0 |
|
||||
| Mock 策略 | 90/100 | 20% | 18.0 |
|
||||
| 可维护性 | 88/100 | 15% | 13.2 |
|
||||
| 错误处理测试 | 95/100 | 10% | 9.5 |
|
||||
| **总计** | | **100%** | **89.2** |
|
||||
|
||||
---
|
||||
|
||||
## 1. 测试文件概览
|
||||
|
||||
### 1.1 文件统计
|
||||
|
||||
| 测试文件 | 测试用例数 | 通过 | 失败 | 跳过/Todo | 行数 |
|
||||
| --------------------------- | ---------- | ------- | ----- | --------- | -------- |
|
||||
| `erp-auth.test.ts` | 11 | 11 | 0 | 0 | 216 |
|
||||
| `cleaner.test.ts` | 20 | 20 | 0 | 0 | 272 |
|
||||
| `ErpBrowserManager.test.ts` | 20 | 20 | 0 | 0 | 252 |
|
||||
| `extractor-core.test.ts` | 11 | 8 | 0 | 3 | 265 |
|
||||
| `extractor.test.ts` | 17 | 17 | 0 | 0 | 350 |
|
||||
| `order-resolver.test.ts` | 26 | 26 | 0 | 0 | 363 |
|
||||
| `page-diagnostics.test.ts` | 6 | 6 | 0 | 0 | - |
|
||||
| `erp-error-context.test.ts` | 7 | 7 | 0 | 0 | - |
|
||||
| **总计** | **118** | **115** | **0** | **3** | **1718** |
|
||||
|
||||
### 1.2 测试执行结果
|
||||
|
||||
```
|
||||
✓ 8 个测试文件全部通过
|
||||
✓ 114 个测试用例通过
|
||||
✓ 0 个测试失败
|
||||
⚠ 3 个测试标记为 todo(需要集成测试环境)
|
||||
✓ 执行时间:< 1.5 秒(优秀)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 详细质量评估
|
||||
|
||||
### 2.1 `erp-auth.test.ts` - **A+ (95/100)**
|
||||
|
||||
**测试对象**: `ErpAuthService` - ERP 认证服务
|
||||
|
||||
#### 优点 ✅
|
||||
|
||||
1. **完整的生命周期测试**
|
||||
- 构造函数初始化验证
|
||||
- 登录流程(成功/失败)
|
||||
- 会话复用机制
|
||||
- 登出/关闭处理
|
||||
|
||||
2. **优秀的 Mock 策略**
|
||||
|
||||
```typescript
|
||||
vi.mock('playwright', () => ({
|
||||
chromium: { launch: vi.fn() }
|
||||
}))
|
||||
```
|
||||
|
||||
- 外部依赖完全隔离
|
||||
- 模拟对象结构清晰
|
||||
|
||||
3. **边界条件覆盖**
|
||||
- `contentFrame` 返回 `null` 的异常处理
|
||||
- 重复登录的会话复用
|
||||
- 未登录时调用 `getSession()` 的错误处理
|
||||
|
||||
4. **测试命名规范**
|
||||
- 使用 `should/could` 语义
|
||||
- 清晰表达测试意图
|
||||
|
||||
#### 改进建议 🔧
|
||||
|
||||
1. **缺少真实场景集成测试**
|
||||
|
||||
```typescript
|
||||
// TODO: 添加集成测试
|
||||
it('should login with real browser (integration)', async () => {
|
||||
// 使用真实 Playwright 浏览器测试
|
||||
})
|
||||
```
|
||||
|
||||
2. **错误消息验证不够精确**
|
||||
|
||||
```typescript
|
||||
// 当前
|
||||
expect(() => service.getSession()).toThrow('Not logged in')
|
||||
|
||||
// 建议
|
||||
expect(() => service.getSession()).toThrow('Not logged in. Call login() first.')
|
||||
```
|
||||
|
||||
3. **缺少性能测试**
|
||||
```typescript
|
||||
it('should complete login within 5 seconds', async () => {
|
||||
const start = Date.now()
|
||||
await service.login()
|
||||
expect(Date.now() - start).toBeLessThan(5000)
|
||||
})
|
||||
```
|
||||
|
||||
#### 覆盖率评估
|
||||
|
||||
| 方法 | 测试覆盖 | 评价 |
|
||||
| --------------- | ----------------- | ---- |
|
||||
| `constructor()` | ✓ 完全覆盖 | 优秀 |
|
||||
| `login()` | ✓ 主要路径 + 异常 | 优秀 |
|
||||
| `getSession()` | ✓ 覆盖 | 良好 |
|
||||
| `isActive()` | ✓ 覆盖 | 良好 |
|
||||
| `close()` | ✓ 覆盖 | 良好 |
|
||||
|
||||
---
|
||||
|
||||
### 2.2 `cleaner.test.ts` - **A (90/100)**
|
||||
|
||||
**测试对象**: `CleanerService` - 物料清理服务
|
||||
|
||||
#### 优点 ✅
|
||||
|
||||
1. **纯函数测试设计优秀**
|
||||
|
||||
```typescript
|
||||
describe('shouldDeleteMaterial()', () => {
|
||||
it('should return true when material matches all deletion criteria', () => {
|
||||
const result = cleaner.shouldDeleteMaterial({...})
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- 无副作用,易于测试
|
||||
- 输入输出明确
|
||||
|
||||
2. **边界值测试完备**
|
||||
|
||||
```typescript
|
||||
it('should respect boundary row numbers', () => {
|
||||
// Row 1999: can delete
|
||||
expect(...).toBe(true)
|
||||
// Row 2000: protected
|
||||
expect(...).toBe(false)
|
||||
// Row 7999: protected
|
||||
expect(...).toBe(false)
|
||||
// Row 8000: can delete
|
||||
expect(...).toBe(true)
|
||||
})
|
||||
```
|
||||
|
||||
3. **辅助函数测试充分**
|
||||
- `createBatches()`: 数组分批逻辑
|
||||
- `runWithConcurrency()`: 并发控制验证
|
||||
- `getMissingOrders()`: 集合差集计算
|
||||
|
||||
4. **并发测试验证**
|
||||
```typescript
|
||||
it('should limit parallelism to specified concurrency', async () => {
|
||||
let running = 0
|
||||
let peak = 0
|
||||
await runWithConcurrency(items, 2, async () => {
|
||||
running += 1
|
||||
peak = Math.max(peak, running)
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
running -= 1
|
||||
})
|
||||
expect(peak).toBeLessThanOrEqual(2)
|
||||
expect(peak).toBe(2)
|
||||
})
|
||||
```
|
||||
|
||||
#### 改进建议 🔧
|
||||
|
||||
1. **缺少 `clean()` 主方法测试**
|
||||
- 文件顶部有 TODO 注释说明需要集成测试
|
||||
- 建议补充:
|
||||
|
||||
```typescript
|
||||
describe('clean() - Integration', () => {
|
||||
it('should complete full cleanup workflow', async () => {
|
||||
// 完整流程集成测试
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
2. **错误场景测试不足**
|
||||
|
||||
```typescript
|
||||
// 建议添加
|
||||
it('should handle page navigation failure', async () => {
|
||||
// Mock 导航失败场景
|
||||
})
|
||||
```
|
||||
|
||||
3. **干运行模式测试可以更详细**
|
||||
```typescript
|
||||
it('should not delete materials in dry-run mode', async () => {
|
||||
// 验证 dryRun=true 时不执行实际删除
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.3 `ErpBrowserManager.test.ts` - **A+ (95/100)**
|
||||
|
||||
**测试对象**: `ErpBrowserManager` - 浏览器管理器
|
||||
|
||||
#### 优点 ✅
|
||||
|
||||
1. **状态管理测试完备**
|
||||
|
||||
```typescript
|
||||
it('should return existing browser if running', async () => {
|
||||
const firstBrowser = await manager.launch()
|
||||
const secondBrowser = await manager.launch()
|
||||
expect(firstBrowser).toBe(secondBrowser)
|
||||
expect(chromium.launch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
```
|
||||
|
||||
2. **参数化测试**
|
||||
|
||||
```typescript
|
||||
it.each([true, false])('should launch with headless=%s', async (headless) => {
|
||||
const manager = new ErpBrowserManager({ headless })
|
||||
await manager.launch()
|
||||
expect(chromium.launch).toHaveBeenCalledWith(expect.objectContaining({ headless }))
|
||||
})
|
||||
```
|
||||
|
||||
3. **错误恢复测试**
|
||||
|
||||
```typescript
|
||||
it('should close browser even if context.close fails', async () => {
|
||||
mockContext.close.mockRejectedValue(new Error('Context close error'))
|
||||
await manager.close()
|
||||
expect(mockBrowser.close).toHaveBeenCalled()
|
||||
})
|
||||
```
|
||||
|
||||
4. **生命周期覆盖全面**
|
||||
- 启动 → 初始化 → 导航 → 创建上下文 → 关闭
|
||||
- 所有公开方法都有测试
|
||||
|
||||
#### 改进建议 🔧
|
||||
|
||||
1. **缺少超时测试**
|
||||
|
||||
```typescript
|
||||
it('should timeout on slow page navigation', async () => {
|
||||
mockPage.goto.mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 60000)))
|
||||
await expect(manager.navigate('http://slow.com')).rejects.toThrow('timeout')
|
||||
})
|
||||
```
|
||||
|
||||
2. **可以添加内存泄漏检测**
|
||||
```typescript
|
||||
it('should release all resources after close', async () => {
|
||||
await manager.launch()
|
||||
await manager.close()
|
||||
// 验证没有悬空引用
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.4 `extractor-core.test.ts` - **B+ (85/100)**
|
||||
|
||||
**测试对象**: `ExtractorCore` - 提取核心逻辑
|
||||
|
||||
#### 优点 ✅
|
||||
|
||||
1. **私有方法测试策略合理**
|
||||
|
||||
```typescript
|
||||
// @ts-ignore - accessing private method for testing
|
||||
await extractorCore.waitForLoading(mockWorkFrame)
|
||||
```
|
||||
|
||||
- 使用 `@ts-ignore` 测试私有方法是可接受的
|
||||
- 避免了为了测试而暴露内部实现
|
||||
|
||||
2. **进度回调测试精确**
|
||||
|
||||
```typescript
|
||||
it('should calculate progress correctly', async () => {
|
||||
await extractorCore.downloadAllBatches(input)
|
||||
expect(progressCallback).toHaveBeenNthCalledWith(1, '处理批次 1/2', 40, {...})
|
||||
expect(progressCallback).toHaveBeenNthCalledWith(2, '处理批次 2/2', 60, {...})
|
||||
})
|
||||
```
|
||||
|
||||
3. **错误处理验证**
|
||||
|
||||
```typescript
|
||||
it('should handle errors in batch download gracefully', async () => {
|
||||
vi.spyOn(extractorCore as any, 'downloadBatch')
|
||||
.mockResolvedValueOnce('/path/file1.xlsx')
|
||||
.mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
const result = await extractorCore.downloadAllBatches(input)
|
||||
expect(result.errors).toHaveLength(1)
|
||||
})
|
||||
```
|
||||
|
||||
#### 不足 ⚠️
|
||||
|
||||
1. **3 个测试标记为 TODO**
|
||||
|
||||
```typescript
|
||||
it.todo('TODO: needs integration test setup - should handle complete navigation flow')
|
||||
it.todo('TODO: needs integration test setup - should handle download events correctly')
|
||||
it.todo('TODO: needs integration test setup - should verify locator interactions')
|
||||
```
|
||||
|
||||
- **影响**: 核心功能缺少完整流程测试
|
||||
- **建议**: 优先级 P0,尽快补充集成测试
|
||||
|
||||
2. **Mock 过于复杂**
|
||||
- `navigateToExtractorPage` 和 `downloadBatch` 都被 Mock
|
||||
- 实际只测试了流程编排,未测试真实逻辑
|
||||
|
||||
#### 改进建议 🔧
|
||||
|
||||
**高优先级**:
|
||||
|
||||
```typescript
|
||||
// 集成测试示例
|
||||
describe('ExtractorCore - Integration', () => {
|
||||
it('should handle real iframe navigation', async () => {
|
||||
// 使用真实 Playwright 浏览器
|
||||
// 测试完整的 iframe 查找和内容帧获取
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.5 `extractor.test.ts` - **A (90/100)**
|
||||
|
||||
**测试对象**: `ExtractorService` - 提取服务
|
||||
|
||||
#### 优点 ✅
|
||||
|
||||
1. **依赖注入测试**
|
||||
|
||||
```typescript
|
||||
beforeEach(() => {
|
||||
mockExcelParserInstance = { parse: vi.fn().mockResolvedValue(undefined) }
|
||||
mockDataImportInstance = { importFromExcel: vi.fn().mockResolvedValue({...}) }
|
||||
mockExtractorCoreInstance = { downloadAllBatches: vi.fn().mockResolvedValue({...}) }
|
||||
})
|
||||
```
|
||||
|
||||
2. **私有方法测试合理**
|
||||
|
||||
```typescript
|
||||
// @ts-ignore - accessing private method for testing
|
||||
const result = await service.mergeFiles(['./file1.xlsx'], ['ORD001'])
|
||||
```
|
||||
|
||||
3. **错误传播测试**
|
||||
|
||||
```typescript
|
||||
it('should handle extraction errors gracefully', async () => {
|
||||
mockExtractorCoreInstance.downloadAllBatches.mockRejectedValue(new Error('Network error'))
|
||||
const result = await service.extract({ orderNumbers: ['ORD001'] })
|
||||
expect(Array.isArray(result.errors)).toBe(true)
|
||||
})
|
||||
```
|
||||
|
||||
4. **性能监控集成测试**
|
||||
```typescript
|
||||
it('should wrap import in trackDuration', async () => {
|
||||
await service.importToDatabaseWithLogging('./merged.xlsx', onLog)
|
||||
expect(trackDuration).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
expect.objectContaining({ operationName: 'Database Import' })
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
#### 改进建议 🔧
|
||||
|
||||
1. **缺少 `extract()` 主方法完整流程测试**
|
||||
- 只有基础行为测试
|
||||
- 建议添加完整 E2E 流程
|
||||
|
||||
2. **Mock 重置策略可以更清晰**
|
||||
```typescript
|
||||
// 建议在每个测试前明确重置所有 Mock
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockExcelParserInstance.lastOrders = [] // 显式清空
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.6 `order-resolver.test.ts` - **A+ (95/100)**
|
||||
|
||||
**测试对象**: `OrderNumberResolver` - 订单号解析器
|
||||
|
||||
#### 优点 ✅
|
||||
|
||||
1. **测试覆盖率最高**
|
||||
- 26 个测试用例,覆盖所有公开方法
|
||||
- 包含性能测试
|
||||
|
||||
2. **类型识别测试完备**
|
||||
|
||||
```typescript
|
||||
describe('isProductionId()', () => {
|
||||
it('should recognize valid production IDs', () => {
|
||||
expect(resolver.isProductionId('22A1')).toBe(true)
|
||||
expect(resolver.isProductionId('26B10617')).toBe(true)
|
||||
})
|
||||
it('should reject invalid formats', () => {
|
||||
expect(resolver.isProductionId('SC70202602120085')).toBe(false)
|
||||
expect(resolver.isProductionId('abc')).toBe(false)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
3. **去重逻辑测试**
|
||||
|
||||
```typescript
|
||||
it('deduplicates identical inputs', async () => {
|
||||
const results = await resolver.resolve(['22A1', '22A1', '22A1'])
|
||||
expect(results).toHaveLength(1) // deduplicated
|
||||
})
|
||||
```
|
||||
|
||||
4. **性能测试**
|
||||
|
||||
```typescript
|
||||
it('performance with large order sets', async () => {
|
||||
const largeInput = Array.from({ length: 100 }, (_, i) => `22A${i}`)
|
||||
const startTime = Date.now()
|
||||
const results = await resolver.resolve(largeInput)
|
||||
const elapsed = Date.now() - startTime
|
||||
expect(elapsed).toBeLessThan(5000)
|
||||
})
|
||||
```
|
||||
|
||||
5. **统计和报告测试**
|
||||
- `getStats()`: 统计数据准确性
|
||||
- `getWarnings()`: 警告消息格式化
|
||||
- `getDeduplicationReport()`: 去重报告生成
|
||||
|
||||
#### 改进建议 🔧
|
||||
|
||||
1. **可以添加数据库连接失败的重试测试**
|
||||
|
||||
```typescript
|
||||
it('should retry on transient database errors', async () => {
|
||||
// Mock 第一次失败,第二次成功
|
||||
// 验证重试逻辑
|
||||
})
|
||||
```
|
||||
|
||||
2. **缓存策略测试可以更详细**
|
||||
```typescript
|
||||
it('should cache resolved mappings', async () => {
|
||||
// 验证相同输入不会重复查询数据库
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 共性问题与建议
|
||||
|
||||
### 3.1 Mock 策略优化
|
||||
|
||||
**当前做法**:
|
||||
|
||||
```typescript
|
||||
vi.mock('playwright', () => ({
|
||||
chromium: { launch: vi.fn() }
|
||||
}))
|
||||
```
|
||||
|
||||
**建议改进**:
|
||||
|
||||
```typescript
|
||||
// 使用工厂函数创建可重置的 Mock
|
||||
const createMockPlaywright = () => ({
|
||||
chromium: {
|
||||
launch: vi.fn().mockResolvedValue(createMockBrowser()),
|
||||
connect: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(chromium.launch).mockResolvedValue(createMockBrowser())
|
||||
})
|
||||
```
|
||||
|
||||
**好处**:
|
||||
|
||||
- 每个测试独立的 Mock 状态
|
||||
- 避免测试间的相互影响
|
||||
- 更易维护
|
||||
|
||||
### 3.2 测试数据工厂
|
||||
|
||||
**当前**: 手动创建测试数据
|
||||
|
||||
```typescript
|
||||
const config = {
|
||||
url: 'https://test-erp.com',
|
||||
username: 'testuser',
|
||||
password: 'testpass',
|
||||
headless: true
|
||||
}
|
||||
```
|
||||
|
||||
**建议**: 使用工厂函数
|
||||
|
||||
```typescript
|
||||
// tests/fixtures/factory.ts
|
||||
const ErpConfigFactory = {
|
||||
create: (overrides?: Partial<ErpConfig>) => ({
|
||||
url: 'https://test-erp.com',
|
||||
username: 'testuser',
|
||||
password: 'testpass',
|
||||
headless: true,
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
// 测试中
|
||||
const config = ErpConfigFactory.create({ headless: false })
|
||||
```
|
||||
|
||||
### 3.3 错误消息断言
|
||||
|
||||
**当前**:
|
||||
|
||||
```typescript
|
||||
await expect(service.login()).rejects.toThrow('Failed to access')
|
||||
```
|
||||
|
||||
**建议**: 使用更精确的匹配
|
||||
|
||||
```typescript
|
||||
await expect(service.login()).rejects.toThrow(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining('Failed to access forwardFrame')
|
||||
})
|
||||
)
|
||||
```
|
||||
|
||||
### 3.4 集成测试缺失
|
||||
|
||||
**问题**: 多个文件有 TODO 注释说明需要集成测试
|
||||
|
||||
**建议优先级**:
|
||||
|
||||
1. **P0**: `extractor-core.test.ts` - 3 个 TODO
|
||||
2. **P1**: `extractor.test.ts` - `extract()` 完整流程
|
||||
3. **P1**: `cleaner.test.ts` - `clean()` 完整流程
|
||||
|
||||
**集成测试框架建议**:
|
||||
|
||||
```typescript
|
||||
// tests/integration/erp/extractor.integration.test.ts
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test('complete extraction workflow', async () => {
|
||||
// 使用真实浏览器
|
||||
// 测试完整提取流程
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 测试设计模式评估
|
||||
|
||||
### 4.1 AAA 模式 (Arrange-Act-Assert)
|
||||
|
||||
**评分**: **优秀** ✅
|
||||
|
||||
所有测试都遵循 AAA 模式:
|
||||
|
||||
```typescript
|
||||
it('should create session on successful login', async () => {
|
||||
// Arrange
|
||||
service = new ErpAuthService(config)
|
||||
|
||||
// Act
|
||||
const session = await service.login()
|
||||
|
||||
// Assert
|
||||
expect(chromium.launch).toHaveBeenCalledWith(...)
|
||||
expect(session.isLoggedIn).toBe(true)
|
||||
})
|
||||
```
|
||||
|
||||
### 4.2 测试独立性
|
||||
|
||||
**评分**: **良好** ⚠️
|
||||
|
||||
**优点**:
|
||||
|
||||
- 每个测试使用 `beforeEach` 重置状态
|
||||
- `vi.clearAllMocks()` 调用普遍
|
||||
|
||||
**改进点**:
|
||||
|
||||
- 部分测试依赖前一个测试的 Mock 状态
|
||||
- 建议在每个测试中完全独立设置 Mock
|
||||
|
||||
### 4.3 测试可读性
|
||||
|
||||
**评分**: **优秀** ✅
|
||||
|
||||
- 测试命名清晰:`should/could` 语义
|
||||
- 分组合理:`describe` 层次分明
|
||||
- 注释充分:关键步骤有说明
|
||||
|
||||
### 4.4 测试可维护性
|
||||
|
||||
**评分**: **良好** ⚠️
|
||||
|
||||
**优点**:
|
||||
|
||||
- 代码结构清晰
|
||||
- 重复代码较少
|
||||
|
||||
**改进点**:
|
||||
|
||||
- 缺少测试数据工厂
|
||||
- Mock 设置代码重复
|
||||
- 魔法数字(如 `40`, `60` 进度值)缺少常量定义
|
||||
|
||||
---
|
||||
|
||||
## 5. 覆盖率分析
|
||||
|
||||
### 5.1 方法覆盖率
|
||||
|
||||
| 服务 | 公开方法 | 已测试 | 覆盖率 |
|
||||
| --------------------- | -------- | ------ | ------ |
|
||||
| `ErpAuthService` | 5 | 5 | 100% |
|
||||
| `CleanerService` | 7 | 4 | 57% ⚠️ |
|
||||
| `ErpBrowserManager` | 9 | 9 | 100% |
|
||||
| `ExtractorCore` | 3 | 2 | 67% ⚠️ |
|
||||
| `ExtractorService` | 5 | 4 | 80% |
|
||||
| `OrderNumberResolver` | 10 | 10 | 100% |
|
||||
|
||||
### 5.2 分支覆盖率估算
|
||||
|
||||
| 服务 | 条件分支 | 已覆盖 | 估算覆盖率 |
|
||||
| --------------------- | -------- | ------ | ---------- |
|
||||
| `ErpAuthService` | 8 | 7 | 87% |
|
||||
| `CleanerService` | 15 | 12 | 80% |
|
||||
| `ErpBrowserManager` | 10 | 9 | 90% |
|
||||
| `ExtractorCore` | 12 | 8 | 67% |
|
||||
| `ExtractorService` | 14 | 11 | 78% |
|
||||
| `OrderNumberResolver` | 20 | 18 | 90% |
|
||||
|
||||
### 5.3 未覆盖的关键路径
|
||||
|
||||
1. **CleanerService**
|
||||
- `clean()` 主方法的完整流程
|
||||
- 重试机制 (`retryFailedOrders`)
|
||||
- 进度发布 (`publishProgress`)
|
||||
|
||||
2. **ExtractorCore**
|
||||
- `navigateToExtractorPage()` 完整导航逻辑
|
||||
- `downloadBatch()` 实际下载流程
|
||||
- iframe 交互的真实场景
|
||||
|
||||
3. **ExtractorService**
|
||||
- `extract()` 方法的完整编排流程
|
||||
- 并发控制在实际场景中的表现
|
||||
|
||||
---
|
||||
|
||||
## 6. 性能测试评估
|
||||
|
||||
### 6.1 现有性能测试
|
||||
|
||||
**优秀示例**:
|
||||
|
||||
```typescript
|
||||
it('performance with large order sets', async () => {
|
||||
const largeInput = Array.from({ length: 100 }, (_, i) => `22A${i}`)
|
||||
const startTime = Date.now()
|
||||
const results = await resolver.resolve(largeInput)
|
||||
const elapsed = Date.now() - startTime
|
||||
expect(elapsed).toBeLessThan(5000)
|
||||
})
|
||||
```
|
||||
|
||||
### 6.2 缺失的性能测试
|
||||
|
||||
1. **并发性能**
|
||||
|
||||
```typescript
|
||||
it('should handle 1000 concurrent orders', async () => {
|
||||
const orders = Array.from({ length: 1000 }, (_, i) => `ORD${i}`)
|
||||
const start = Date.now()
|
||||
await resolver.resolve(orders)
|
||||
expect(Date.now() - start).toBeLessThan(10000)
|
||||
})
|
||||
```
|
||||
|
||||
2. **内存使用**
|
||||
```typescript
|
||||
it('should not leak memory on repeated calls', async () => {
|
||||
const initialMemory = process.memoryUsage().heapUsed
|
||||
for (let i = 0; i < 100; i++) {
|
||||
await service.extract({ orderNumbers: ['ORD001'] })
|
||||
}
|
||||
const finalMemory = process.memoryUsage().heapUsed
|
||||
expect(finalMemory - initialMemory).toBeLessThan(10 * 1024 * 1024) // < 10MB
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 错误处理测试评估
|
||||
|
||||
### 7.1 优秀实践 ✅
|
||||
|
||||
1. **网络错误处理**
|
||||
|
||||
```typescript
|
||||
mockExtractorCoreInstance.downloadAllBatches.mockRejectedValue(new Error('Network error'))
|
||||
```
|
||||
|
||||
2. **数据库连接失败**
|
||||
|
||||
```typescript
|
||||
vi.mocked(mockDbService.query).mockRejectedValue(new Error('Database connection failed'))
|
||||
```
|
||||
|
||||
3. **元素未找到**
|
||||
```typescript
|
||||
mockPage.locator = vi.fn().mockReturnValue({
|
||||
contentFrame: vi.fn().mockResolvedValue(null)
|
||||
})
|
||||
await expect(service.login()).rejects.toThrow('Failed to access')
|
||||
```
|
||||
|
||||
### 7.2 改进建议 🔧
|
||||
|
||||
1. **添加错误类型验证**
|
||||
|
||||
```typescript
|
||||
it('should throw specific error types', async () => {
|
||||
await expect(service.login()).rejects.toThrow(ErpAuthenticationError)
|
||||
})
|
||||
```
|
||||
|
||||
2. **错误上下文验证**
|
||||
```typescript
|
||||
it('should include context in error messages', async () => {
|
||||
try {
|
||||
await service.login()
|
||||
} catch (error) {
|
||||
expect(error.context).toEqual({
|
||||
url: 'https://test-erp.com',
|
||||
step: 'login'
|
||||
})
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 与测试覆盖率提升计划对标
|
||||
|
||||
### 8.1 计划目标回顾
|
||||
|
||||
根据 `TEST_COVERAGE_IMPROVEMENT_PLAN.md`:
|
||||
|
||||
| 模块 | 当前覆盖率 | 目标覆盖率 | 优先级 |
|
||||
| ---------------------- | ---------- | ---------- | ------ |
|
||||
| `erp-auth.ts` | < 20% | 80% | P0 |
|
||||
| `extractor.ts` | ~30% | 80% | P0 |
|
||||
| `extractor-core.ts` | < 10% | 80% | P0 |
|
||||
| `cleaner.ts` | ~25% | 80% | P0 |
|
||||
| `ErpBrowserManager.ts` | N/A | 80% | P1 |
|
||||
| `order-resolver.ts` | N/A | 80% | P1 |
|
||||
|
||||
### 8.2 当前进展
|
||||
|
||||
**估算覆盖率提升**:
|
||||
|
||||
| 模块 | 测试前 | 测试后(估算) | 提升 | 达标状态 |
|
||||
| ---------------------- | ------ | -------------- | ---- | ------------------- |
|
||||
| `erp-auth.ts` | < 20% | ~75% | +55% | ⚠️ 接近达标 |
|
||||
| `extractor.ts` | ~30% | ~70% | +40% | ⚠️ 接近达标 |
|
||||
| `extractor-core.ts` | < 10% | ~55% | +45% | ❌ 需补充集成测试 |
|
||||
| `cleaner.ts` | ~25% | ~65% | +40% | ⚠️ 需补充主方法测试 |
|
||||
| `ErpBrowserManager.ts` | N/A | ~85% | N/A | ✅ 已达标 |
|
||||
| `order-resolver.ts` | N/A | ~90% | N/A | ✅ 已达标 |
|
||||
|
||||
### 8.3 下一步行动
|
||||
|
||||
**P0 - 立即执行**:
|
||||
|
||||
1. 补充 `extractor-core.test.ts` 的 3 个 TODO 测试
|
||||
2. 添加 `cleaner.ts` 的 `clean()` 方法集成测试
|
||||
3. 补充 `extractor.ts` 的 `extract()` 完整流程测试
|
||||
|
||||
**P1 - 本周执行**:
|
||||
|
||||
1. 为所有错误路径添加断言
|
||||
2. 添加性能测试覆盖关键路径
|
||||
3. 创建测试数据工厂减少重复代码
|
||||
|
||||
---
|
||||
|
||||
## 9. 总体评价与建议
|
||||
|
||||
### 9.1 优点总结
|
||||
|
||||
1. **测试设计优秀**
|
||||
- AAA 模式遵循良好
|
||||
- 测试命名清晰
|
||||
- 分组合理
|
||||
|
||||
2. **Mock 策略成熟**
|
||||
- 外部依赖完全隔离
|
||||
- Mock 对象结构清晰
|
||||
- 参数化测试使用得当
|
||||
|
||||
3. **错误处理充分**
|
||||
- 主要错误场景都有覆盖
|
||||
- 异常传播验证到位
|
||||
|
||||
4. **边界条件重视**
|
||||
- 边界值测试普遍
|
||||
- 特殊情况考虑周全
|
||||
|
||||
### 9.2 改进优先级
|
||||
|
||||
**P0 - 必须完成(本周)**:
|
||||
|
||||
1. ✅ 补充 `extractor-core.test.ts` 的集成测试
|
||||
2. ✅ 添加 `cleaner()` 主方法测试
|
||||
3. ✅ 完成 `extractor.extract()` 完整流程测试
|
||||
|
||||
**P1 - 强烈建议(下周)**:
|
||||
|
||||
1. 创建测试数据工厂
|
||||
2. 统一 Mock 设置模式
|
||||
3. 添加性能基准测试
|
||||
|
||||
**P2 - 建议(本月)**:
|
||||
|
||||
1. 添加内存泄漏检测测试
|
||||
2. 补充错误类型验证
|
||||
3. 完善并发场景测试
|
||||
|
||||
### 9.3 测试文化建议
|
||||
|
||||
1. **测试审查流程**
|
||||
- 将测试审查纳入 PR 必选项
|
||||
- 使用本报告的评分标准
|
||||
|
||||
2. **测试文档**
|
||||
- 编写《测试最佳实践》文档
|
||||
- 建立测试模式库
|
||||
|
||||
3. **覆盖率门禁**
|
||||
- CI/CD 中设置覆盖率阈值
|
||||
- 新增代码覆盖率要求 ≥ 80%
|
||||
|
||||
---
|
||||
|
||||
## 10. 结论
|
||||
|
||||
本次审查的测试文件整体质量**优秀**,展现了团队对测试工作的重视和高超的测试设计能力。主要优势在于:
|
||||
|
||||
- ✅ 测试设计模式成熟(AAA 模式)
|
||||
- ✅ Mock 策略合理,依赖隔离充分
|
||||
- ✅ 错误处理和边界条件覆盖全面
|
||||
- ✅ 测试可读性和可维护性良好
|
||||
|
||||
需要改进的方面:
|
||||
|
||||
- ⚠️ 集成测试缺失(3 个 TODO 待实现)
|
||||
- ⚠️ 部分主方法测试不完整
|
||||
- ⚠️ 缺少性能基准测试
|
||||
- ⚠️ 测试数据工厂可进一步优化
|
||||
|
||||
**总体评分:A (90/100)**
|
||||
|
||||
按照本报告的改进建议执行后,预计可将 ERP 服务模块的测试覆盖率提升至 **75-85%**,达到项目设定的阶段性目标。
|
||||
|
||||
---
|
||||
|
||||
**附录 A: 测试运行统计**
|
||||
|
||||
```
|
||||
Test Files: 8 passed (8)
|
||||
Tests: 114 passed | 3 todo (117)
|
||||
Duration: ~1.0s
|
||||
Setup: ~259ms
|
||||
Transform: ~708ms
|
||||
```
|
||||
|
||||
**附录 B: 审查工具**
|
||||
|
||||
- Vitest 测试运行器
|
||||
- Playwright Mock 库
|
||||
- TypeScript 类型检查
|
||||
- ESLint 代码规范检查
|
||||
|
||||
---
|
||||
|
||||
**报告结束**
|
||||
165
package-lock.json
generated
165
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "erpauto",
|
||||
"version": "1.9.0",
|
||||
"version": "1.12.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "erpauto",
|
||||
"version": "1.9.0",
|
||||
"version": "1.12.3",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.929.0",
|
||||
@@ -24,6 +24,7 @@
|
||||
"lucide-react": "^0.575.0",
|
||||
"mssql": "^12.2.0",
|
||||
"mysql2": "^3.18.2",
|
||||
"pg": "^8.20.0",
|
||||
"playwright": "^1.58.2",
|
||||
"playwright-core": "^1.58.2",
|
||||
"react-focus-lock": "^2.13.7",
|
||||
@@ -48,6 +49,7 @@
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@types/mssql": "^9.1.9",
|
||||
"@types/node": "^22.19.13",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/uuid": "^10.0.0",
|
||||
@@ -5020,6 +5022,18 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/pg": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
|
||||
"integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"pg-protocol": "*",
|
||||
"pg-types": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/plist": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/plist/-/plist-3.0.5.tgz",
|
||||
@@ -13532,6 +13546,96 @@
|
||||
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
|
||||
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.12.0",
|
||||
"pg-pool": "^3.13.0",
|
||||
"pg-protocol": "^1.13.0",
|
||||
"pg-types": "2.2.0",
|
||||
"pgpass": "1.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"pg-cloudflare": "^1.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pg-native": ">=3.0.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"pg-native": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pg-cloudflare": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
|
||||
"integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/pg-connection-string": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz",
|
||||
"integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-int8": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-pool": {
|
||||
"version": "3.13.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz",
|
||||
"integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"pg": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-protocol": {
|
||||
"version": "1.13.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz",
|
||||
"integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-types": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-int8": "1.0.1",
|
||||
"postgres-array": "~2.0.0",
|
||||
"postgres-bytea": "~1.0.0",
|
||||
"postgres-date": "~1.0.4",
|
||||
"postgres-interval": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/pgpass": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"split2": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -13641,6 +13745,45 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/postgres-array": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-bytea": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
||||
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-date": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-interval": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xtend": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postject": {
|
||||
"version": "1.0.0-alpha.6",
|
||||
"resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz",
|
||||
@@ -14912,6 +15055,15 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/split2": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/sprintf-js": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
|
||||
@@ -17836,6 +17988,15 @@
|
||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "erpauto",
|
||||
"version": "1.9.0",
|
||||
"version": "1.12.3",
|
||||
"description": "An Electron application with React and TypeScript",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "example.com",
|
||||
@@ -48,6 +48,7 @@
|
||||
"lucide-react": "^0.575.0",
|
||||
"mssql": "^12.2.0",
|
||||
"mysql2": "^3.18.2",
|
||||
"pg": "^8.20.0",
|
||||
"playwright": "^1.58.2",
|
||||
"playwright-core": "^1.58.2",
|
||||
"react-focus-lock": "^2.13.7",
|
||||
@@ -72,6 +73,7 @@
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@types/mssql": "^9.1.9",
|
||||
"@types/node": "^22.19.13",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/uuid": "^10.0.0",
|
||||
|
||||
@@ -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,34 +1,42 @@
|
||||
import { app } from 'electron'
|
||||
import logger from '../services/logger/index'
|
||||
import { logAudit, closeAuditLogger } from '../services/logger/audit-logger'
|
||||
import { logAudit, closeAuditLogger, cachedHostname } from '../services/logger/audit-logger'
|
||||
import { AuditAction, AuditStatus } from '../types/audit.types'
|
||||
import { serializeError } from '../services/logger/error-utils'
|
||||
|
||||
export function setupProcessGuards(): void {
|
||||
process.on('uncaughtException', (err) => {
|
||||
logger.error('Uncaught exception', { error: err })
|
||||
logAudit('SYSTEM_CRASH', 'system', {
|
||||
username: 'system',
|
||||
computerName: process.env.COMPUTERNAME || 'unknown',
|
||||
resource: 'main-process',
|
||||
status: 'failure',
|
||||
metadata: { error: err.message, stack: err.stack }
|
||||
})
|
||||
setTimeout(() => process.exit(1), 1000)
|
||||
try {
|
||||
logAudit(AuditAction.SYSTEM_CRASH, 'system', {
|
||||
username: 'system',
|
||||
computerName: cachedHostname,
|
||||
resource: 'main-process',
|
||||
status: AuditStatus.FAILURE,
|
||||
metadata: { error: err.message, stack: err.stack }
|
||||
})
|
||||
} catch (auditError) {
|
||||
logger.error('Failed to write crash audit log', { error: auditError })
|
||||
} finally {
|
||||
setTimeout(() => process.exit(1), 1000)
|
||||
}
|
||||
})
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
const errorMeta =
|
||||
reason instanceof Error
|
||||
? { error: serializeError(reason) }
|
||||
: { reason: String(reason) }
|
||||
reason instanceof Error ? { error: serializeError(reason) } : { reason: String(reason) }
|
||||
logger.error('Unhandled Rejection', errorMeta)
|
||||
logAudit('SYSTEM_ERROR', 'system', {
|
||||
username: 'system',
|
||||
computerName: process.env.COMPUTERNAME || 'unknown',
|
||||
resource: 'main-process',
|
||||
status: 'failure',
|
||||
metadata: errorMeta
|
||||
})
|
||||
try {
|
||||
logAudit(AuditAction.SYSTEM_ERROR, 'system', {
|
||||
username: 'system',
|
||||
computerName: cachedHostname,
|
||||
resource: 'main-process',
|
||||
status: AuditStatus.FAILURE,
|
||||
metadata: errorMeta
|
||||
})
|
||||
} catch (auditError) {
|
||||
logger.error('Failed to write unhandled rejection audit log', { error: auditError })
|
||||
}
|
||||
})
|
||||
|
||||
app.on('render-process-gone', (_, webContents, details) => {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { app } from 'electron'
|
||||
import { ipcMain } from 'electron'
|
||||
import { withErrorHandling, type IpcResult } from './index'
|
||||
import type {
|
||||
@@ -8,6 +10,7 @@ import type {
|
||||
} from '../types/cleaner.types'
|
||||
import { IPC_CHANNELS } from '../../shared/ipc-channels'
|
||||
import { CleanerApplicationService } from '../services/cleaner/cleaner-application-service'
|
||||
import { CleanerOperationHistoryDAO } from '../services/database/cleaner-operation-history-dao'
|
||||
|
||||
export function registerCleanerHandlers(): void {
|
||||
const cleanerService = new CleanerApplicationService()
|
||||
@@ -15,10 +18,21 @@ export function registerCleanerHandlers(): void {
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CLEANER_RUN,
|
||||
async (event, input: CleanerInput): Promise<IpcResult<CleanerResult>> => {
|
||||
return withErrorHandling(
|
||||
async () => cleanerService.runCleaner(event.sender, input),
|
||||
'cleaner:run'
|
||||
)
|
||||
return withErrorHandling(async () => {
|
||||
const batchId = randomUUID()
|
||||
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')
|
||||
}
|
||||
@@ -5,7 +5,8 @@ import { OrderNumberResolver } from '../services/erp/order-resolver'
|
||||
import { create, type IDatabaseService } from '../services/database'
|
||||
import { ExtractorOperationHistoryDAO } from '../services/database/extractor-operation-history-dao'
|
||||
import { createLogger } from '../services/logger'
|
||||
import { logAudit } from '../services/logger/audit-logger'
|
||||
import { logAuditWithCurrentUser } from '../services/logger/audit-logger'
|
||||
import { AuditAction, AuditStatus } from '../types/audit.types'
|
||||
import { SessionManager } from '../services/user/session-manager'
|
||||
import { withErrorHandling, type IpcResult } from './index'
|
||||
import { ErpConnectionError, ValidationError, DatabaseQueryError } from '../types/errors'
|
||||
@@ -278,24 +279,17 @@ export function registerExtractorHandlers(): void {
|
||||
}
|
||||
|
||||
// Audit log: EXTRACT (non-blocking)
|
||||
const os = await import('os')
|
||||
if (currentUser) {
|
||||
const auditStatus: 'success' | 'failure' | 'partial' =
|
||||
const auditStatus: AuditStatus =
|
||||
result.errors.length > 0 && result.recordCount > 0
|
||||
? 'partial'
|
||||
? AuditStatus.PARTIAL
|
||||
: result.errors.length > 0
|
||||
? 'failure'
|
||||
: 'success'
|
||||
logAudit('EXTRACT', String(currentUser.id), {
|
||||
username: currentUser.username,
|
||||
computerName: os.hostname(),
|
||||
resource: 'MATERIAL_PLAN',
|
||||
status: auditStatus,
|
||||
metadata: {
|
||||
orderCount: validOrderNumbers.length,
|
||||
recordCount: result.recordCount,
|
||||
errorCount: result.errors.length
|
||||
}
|
||||
? AuditStatus.FAILURE
|
||||
: AuditStatus.SUCCESS
|
||||
logAuditWithCurrentUser(AuditAction.EXTRACT, 'MATERIAL_PLAN', auditStatus, {
|
||||
orderCount: validOrderNumbers.length,
|
||||
recordCount: result.recordCount,
|
||||
errorCount: result.errors.length
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import { registerReportHandlers } from './report-handler'
|
||||
import { registerUpdateHandlers } from './update-handler'
|
||||
import { registerPlaywrightBrowserHandlers } from './playwright-browser'
|
||||
import { registerOperationHistoryHandlers } from './operation-history-handler'
|
||||
import { registerCleanerHistoryHandlers } from './cleaner-history-handler'
|
||||
import { createLogger, logError } from '../services/logger'
|
||||
import { serializeError, sanitizeError } from '../services/logger/error-utils'
|
||||
import { getErrorMessage, getErrorCode, isBaseError } from '../types/errors'
|
||||
@@ -115,5 +116,6 @@ export function registerIpcHandlers(): void {
|
||||
registerUpdateHandlers()
|
||||
registerPlaywrightBrowserHandlers()
|
||||
registerOperationHistoryHandlers()
|
||||
registerCleanerHistoryHandlers()
|
||||
log.info('All IPC handlers registered')
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ import { UserErpConfigService } from '../services/user/user-erp-config-service'
|
||||
import { MySqlService } from '../services/database/mysql'
|
||||
import { SqlServerService } from '../services/database/sql-server'
|
||||
import { createLogger } from '../services/logger'
|
||||
import { logAudit } from '../services/logger/audit-logger'
|
||||
import { logAuditWithCurrentUser } from '../services/logger/audit-logger'
|
||||
import { AuditAction, AuditStatus } from '../types/audit.types'
|
||||
import type { UserType, ConnectionTestResult, SaveSettingsResult } from '../types/settings.types'
|
||||
import { IPC_CHANNELS } from '../../shared/ipc-channels'
|
||||
import { ValidationError } from '../types/errors'
|
||||
@@ -67,13 +68,9 @@ export function registerSettingsHandlers(): void {
|
||||
})
|
||||
|
||||
// Audit log: SETTINGS_CHANGE (non-blocking)
|
||||
const os = await import('os')
|
||||
logAudit('SETTINGS_CHANGE', String(currentUser.id), {
|
||||
username: currentUser.username,
|
||||
computerName: os.hostname(),
|
||||
resource: 'ERP_CONFIG',
|
||||
status: 'success',
|
||||
metadata: { changeType: 'erp_credentials', usernameChanged: !!settings.erp.username }
|
||||
logAuditWithCurrentUser(AuditAction.SETTINGS_CHANGE, 'ERP_CONFIG', AuditStatus.SUCCESS, {
|
||||
changeType: 'erp_credentials',
|
||||
usernameChanged: !!settings.erp.username
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -194,7 +194,8 @@ export function registerValidationHandlers(): void {
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.VALIDATION_GET_CLEANER_DATA,
|
||||
async (
|
||||
event
|
||||
event,
|
||||
params?: { selectedManagers?: string[] }
|
||||
): Promise<{
|
||||
success: boolean
|
||||
orderNumbers?: string[]
|
||||
@@ -213,7 +214,11 @@ export function registerValidationHandlers(): void {
|
||||
}
|
||||
}
|
||||
|
||||
return validationApplicationService.getCleanerData(userInfo, event.sender.id)
|
||||
return validationApplicationService.getCleanerData(
|
||||
userInfo,
|
||||
event.sender.id,
|
||||
params?.selectedManagers ?? []
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { SessionManager } from '../user/session-manager'
|
||||
import { UpdateService } from '../update/update-service'
|
||||
import { createLogger, run, getRequestId, getContext } from '../logger'
|
||||
import { logAudit } from '../logger/audit-logger'
|
||||
import { AuditAction, AuditStatus } from '../../types/audit.types'
|
||||
import { ValidationError } from '../../types/errors'
|
||||
import type { UserInfo } from '../../types/user.types'
|
||||
import type {
|
||||
@@ -73,11 +74,11 @@ export class AuthApplicationService {
|
||||
userId: userInfo.id
|
||||
})
|
||||
|
||||
this.writeAuditLog('LOGIN', String(userInfo.id), {
|
||||
this.writeAuditLog(AuditAction.LOGIN, String(userInfo.id), {
|
||||
username: userInfo.username,
|
||||
computerName: hostname(),
|
||||
resource: 'ERP_SYSTEM',
|
||||
status: 'success',
|
||||
status: AuditStatus.SUCCESS,
|
||||
metadata: { loginType: 'silent', userType: userInfo.userType }
|
||||
})
|
||||
|
||||
@@ -127,11 +128,11 @@ export class AuthApplicationService {
|
||||
const userInfo = this.sessionManager.getUserInfo()
|
||||
|
||||
if (!success || !userInfo) {
|
||||
this.writeAuditLog('LOGIN', '0', {
|
||||
this.writeAuditLog(AuditAction.LOGIN, '0', {
|
||||
username,
|
||||
computerName: hostname(),
|
||||
resource: 'ERP_SYSTEM',
|
||||
status: 'failure',
|
||||
status: AuditStatus.FAILURE,
|
||||
metadata: { loginType: 'credentials', reason: 'invalid_credentials' }
|
||||
})
|
||||
|
||||
@@ -155,11 +156,11 @@ export class AuthApplicationService {
|
||||
})
|
||||
await this.updateService.setUserContext(userInfo.userType)
|
||||
|
||||
this.writeAuditLog('LOGIN', String(userInfo.id), {
|
||||
this.writeAuditLog(AuditAction.LOGIN, String(userInfo.id), {
|
||||
username: userInfo.username,
|
||||
computerName: hostname(),
|
||||
resource: 'ERP_SYSTEM',
|
||||
status: 'success',
|
||||
status: AuditStatus.SUCCESS,
|
||||
metadata: { loginType: 'credentials', userType: userInfo.userType }
|
||||
})
|
||||
|
||||
@@ -204,16 +205,17 @@ export class AuthApplicationService {
|
||||
})
|
||||
|
||||
if (userInfo) {
|
||||
this.writeAuditLog('LOGOUT', String(userInfo.id), {
|
||||
this.writeAuditLog(AuditAction.LOGOUT, String(userInfo.id), {
|
||||
username: userInfo.username,
|
||||
computerName: hostname(),
|
||||
resource: 'ERP_SYSTEM',
|
||||
status: 'success',
|
||||
status: AuditStatus.SUCCESS,
|
||||
metadata: { userType: userInfo.userType }
|
||||
})
|
||||
}
|
||||
|
||||
this.sessionManager.logout()
|
||||
this.silentLoginPromise = null
|
||||
await this.updateService.setUserContext(null)
|
||||
},
|
||||
{ operation: 'logout' }
|
||||
@@ -310,7 +312,7 @@ export class AuthApplicationService {
|
||||
}
|
||||
|
||||
private writeAuditLog(
|
||||
action: 'LOGIN' | 'LOGOUT',
|
||||
action: AuditAction.LOGIN | AuditAction.LOGOUT,
|
||||
actorId: string,
|
||||
payload: Parameters<typeof logAudit>[2]
|
||||
): void {
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import type { WebContents } from 'electron'
|
||||
import type { MySqlService } from '../database/mysql'
|
||||
import type { SqlServerService } from '../database/sql-server'
|
||||
import type { IDatabaseService } from '../../types/database.types'
|
||||
import { ErpAuthService } from '../erp/erp-auth'
|
||||
import { CleanerService } from '../erp/cleaner'
|
||||
import { OrderNumberResolver } from '../erp/order-resolver'
|
||||
import { MySqlService as MySqlServiceImpl } from '../database/mysql'
|
||||
import { SqlServerService as SqlServerServiceImpl } from '../database/sql-server'
|
||||
import { PostgreSqlService as PostgreSqlServiceImpl } from '../database/postgresql'
|
||||
import { ConfigManager } from '../config/config-manager'
|
||||
import { ResultExporter } from '../excel/result-exporter'
|
||||
import { CleanerReportGenerator } from '../report/cleaner-report-generator'
|
||||
import { RustfsService } from '../rustfs'
|
||||
import { SessionManager } from '../user/session-manager'
|
||||
import { UserErpConfigService } from '../user/user-erp-config-service'
|
||||
import { createLogger } from '../logger'
|
||||
import { logAudit } from '../logger/audit-logger'
|
||||
import { logAuditWithCurrentUser } from '../logger/audit-logger'
|
||||
import { AuditAction, AuditStatus } from '../../types/audit.types'
|
||||
import { IPC_CHANNELS } from '../../../shared/ipc-channels'
|
||||
import { DatabaseQueryError, ErpConnectionError, ValidationError } from '../../types/errors'
|
||||
import { CleanerOperationHistoryDAO } from '../database/cleaner-operation-history-dao'
|
||||
import type {
|
||||
CleanerInput,
|
||||
CleanerProgress,
|
||||
@@ -23,16 +23,21 @@ import type {
|
||||
ExportResultItem,
|
||||
ExportResultResponse
|
||||
} 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')
|
||||
|
||||
type DatabaseService = MySqlService | SqlServerService
|
||||
|
||||
export class CleanerApplicationService {
|
||||
async runCleaner(eventSender: WebContents, input: CleanerInput): Promise<CleanerResult> {
|
||||
const startTime = Date.now()
|
||||
async runCleaner(
|
||||
eventSender: WebContents,
|
||||
input: CleanerInput,
|
||||
batchId: string,
|
||||
historyDao: CleanerOperationHistoryDAO,
|
||||
appVersion: string
|
||||
): Promise<CleanerResult> {
|
||||
let authService: ErpAuthService | null = null
|
||||
let dbService: DatabaseService | null = null
|
||||
let dbService: IDatabaseService | null = null
|
||||
|
||||
try {
|
||||
log.info('Fetching ERP configuration from database...')
|
||||
@@ -46,7 +51,7 @@ export class CleanerApplicationService {
|
||||
const configManager = ConfigManager.getInstance()
|
||||
const dbType = configManager.getDatabaseType()
|
||||
log.info(
|
||||
`Connecting to ${dbType === 'sqlserver' ? 'SQL Server' : 'MySQL'} for order resolution...`
|
||||
`Connecting to ${dbType === 'sqlserver' ? 'SQL Server' : dbType === 'postgresql' ? 'PostgreSQL' : 'MySQL'} for order resolution...`
|
||||
)
|
||||
|
||||
try {
|
||||
@@ -68,14 +73,60 @@ export class CleanerApplicationService {
|
||||
log.warn('Resolution warnings', { warnings })
|
||||
}
|
||||
|
||||
if (validOrderNumbers.length === 0) {
|
||||
throw new ValidationError(
|
||||
'没有有效的生产订单号可处理。请检查输入的格式或数据库连接。',
|
||||
'VAL_INVALID_INPUT'
|
||||
)
|
||||
// Build order inputs from ALL mappings (including resolution failures)
|
||||
const orderInputs = this.buildOrderInputs(mappings)
|
||||
|
||||
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({
|
||||
url: erpConfig.url,
|
||||
@@ -105,7 +156,6 @@ export class CleanerApplicationService {
|
||||
totalMaterialsInOrder: 0
|
||||
})
|
||||
|
||||
const cleaner = new CleanerService(authService)
|
||||
const modifiedInput: CleanerInput = {
|
||||
...input,
|
||||
orderNumbers: validOrderNumbers,
|
||||
@@ -115,12 +165,94 @@ export class CleanerApplicationService {
|
||||
}
|
||||
|
||||
log.info('Starting cleaning', {
|
||||
batchId,
|
||||
orderCount: validOrderNumbers.length,
|
||||
queryBatchSize: input.queryBatchSize ?? 100,
|
||||
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) {
|
||||
result.errors = [...warnings, ...result.errors]
|
||||
@@ -135,12 +267,12 @@ export class CleanerApplicationService {
|
||||
})
|
||||
|
||||
log.info('Cleaning completed', {
|
||||
batchId,
|
||||
processedCount: result.ordersProcessed,
|
||||
errorCount: result.errors.length
|
||||
})
|
||||
|
||||
await this.recordCleanupAudit(validOrderNumbers.length, input, result)
|
||||
await this.generateAndUploadReport(input, result, startTime)
|
||||
|
||||
return result
|
||||
} finally {
|
||||
@@ -201,7 +333,7 @@ export class CleanerApplicationService {
|
||||
}
|
||||
}
|
||||
|
||||
private async getDatabaseService(): Promise<DatabaseService> {
|
||||
private async getDatabaseService(): Promise<IDatabaseService> {
|
||||
const configManager = ConfigManager.getInstance()
|
||||
const config = configManager.getConfig()
|
||||
const dbType = configManager.getDatabaseType()
|
||||
@@ -223,6 +355,19 @@ export class CleanerApplicationService {
|
||||
return sqlServerService
|
||||
}
|
||||
|
||||
if (dbType === 'postgresql') {
|
||||
const dbConfig = config.database.postgresql
|
||||
const pgService = new PostgreSqlServiceImpl({
|
||||
host: dbConfig.host,
|
||||
port: dbConfig.port,
|
||||
user: dbConfig.username,
|
||||
password: dbConfig.password,
|
||||
database: dbConfig.database
|
||||
})
|
||||
await pgService.connect()
|
||||
return pgService
|
||||
}
|
||||
|
||||
const dbConfig = config.database.mysql
|
||||
const mysqlService = new MySqlServiceImpl({
|
||||
host: dbConfig.host,
|
||||
@@ -257,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(
|
||||
orderCount: number,
|
||||
input: CleanerInput,
|
||||
@@ -267,93 +444,146 @@ export class CleanerApplicationService {
|
||||
return
|
||||
}
|
||||
|
||||
const status: 'success' | 'failure' | 'partial' =
|
||||
const status: AuditStatus =
|
||||
result.errors.length > 0 && result.materialsDeleted > 0
|
||||
? 'partial'
|
||||
? AuditStatus.PARTIAL
|
||||
: result.errors.length > 0
|
||||
? 'failure'
|
||||
: 'success'
|
||||
? AuditStatus.FAILURE
|
||||
: AuditStatus.SUCCESS
|
||||
|
||||
logAudit('CLEAN', String(currentUser.id), {
|
||||
username: currentUser.username,
|
||||
computerName: (await import('os')).hostname(),
|
||||
resource: 'MATERIAL_PLAN',
|
||||
status,
|
||||
metadata: {
|
||||
orderCount,
|
||||
dryRun: input.dryRun ?? false,
|
||||
queryBatchSize: input.queryBatchSize ?? 100,
|
||||
processConcurrency: input.processConcurrency ?? 1,
|
||||
materialsDeleted: result.materialsDeleted,
|
||||
materialsSkipped: result.materialsSkipped,
|
||||
errorCount: result.errors.length
|
||||
}
|
||||
logAuditWithCurrentUser(AuditAction.CLEAN, 'MATERIAL_PLAN', status, {
|
||||
orderCount,
|
||||
dryRun: input.dryRun ?? false,
|
||||
queryBatchSize: input.queryBatchSize ?? 100,
|
||||
processConcurrency: input.processConcurrency ?? 1,
|
||||
materialsDeleted: result.materialsDeleted,
|
||||
materialsSkipped: result.materialsSkipped,
|
||||
errorCount: result.errors.length
|
||||
})
|
||||
}
|
||||
|
||||
private async generateAndUploadReport(
|
||||
input: CleanerInput,
|
||||
result: CleanerResult,
|
||||
startTime: number
|
||||
/**
|
||||
* Save attempt results to database: update order statuses, insert material details,
|
||||
* and update execution status.
|
||||
*/
|
||||
private async saveAttemptToDatabase(
|
||||
historyDao: CleanerOperationHistoryDAO,
|
||||
batchId: string,
|
||||
attemptNumber: number,
|
||||
result: CleanerResult
|
||||
): Promise<void> {
|
||||
try {
|
||||
const endTime = Date.now()
|
||||
const currentUser = SessionManager.getInstance().getUserInfo()
|
||||
const username = currentUser?.username ?? 'unknown'
|
||||
// Query execution record to determine if this is a dry run
|
||||
const batchDetails = await historyDao.getBatchDetails(batchId)
|
||||
const execution = batchDetails.executions.find((e) => e.attemptNumber === attemptNumber)
|
||||
const isDryRun = execution?.isDryRun ?? false
|
||||
|
||||
const reportGenerator = new CleanerReportGenerator()
|
||||
const reportPath = await reportGenerator.generateReport(result, {
|
||||
dryRun: input.dryRun ?? false,
|
||||
username,
|
||||
startTime,
|
||||
endTime
|
||||
})
|
||||
log.info('Report generated', { path: reportPath })
|
||||
|
||||
const configManager = ConfigManager.getInstance()
|
||||
const config = configManager.getConfig()
|
||||
|
||||
if (!config.rustfs?.enabled || !config.rustfs.endpoint) {
|
||||
log.debug('RustFS is not enabled, skipping upload')
|
||||
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'
|
||||
// Update order statuses and insert material details
|
||||
for (const detail of result.details) {
|
||||
await historyDao.updateOrderStatus(
|
||||
batchId,
|
||||
attemptNumber,
|
||||
detail.orderNumber,
|
||||
detail.notFound ? 'erp_not_found' : detail.errors.length > 0 ? 'failed' : 'success',
|
||||
detail.materialsDeleted,
|
||||
detail.materialsSkipped,
|
||||
detail.materialsFailed,
|
||||
detail.uncertainDeletions,
|
||||
detail.retryCount,
|
||||
detail.retrySuccess ?? false,
|
||||
detail.errors.length > 0 ? detail.errors.join('\n') : undefined
|
||||
)
|
||||
|
||||
if (uploadResult.success) {
|
||||
log.info('Report uploaded to RustFS successfully', {
|
||||
key: storageKey,
|
||||
etag: uploadResult.etag
|
||||
})
|
||||
} else {
|
||||
log.warn('Failed to upload report to RustFS', {
|
||||
error: uploadResult.error,
|
||||
key: storageKey
|
||||
// Insert material details for all materials
|
||||
const materialDetails: InsertMaterialDetailInput[] = []
|
||||
|
||||
for (const deleted of detail.deletedMaterials) {
|
||||
materialDetails.push({
|
||||
orderNumber: detail.orderNumber,
|
||||
materialCode: deleted.materialCode,
|
||||
materialName: deleted.materialName,
|
||||
rowNumber: deleted.rowNumber,
|
||||
result: deleted.outcome,
|
||||
reason: null,
|
||||
attemptCount: 1,
|
||||
finalErrorCategory: null
|
||||
})
|
||||
}
|
||||
} catch (rustfsError) {
|
||||
log.error('RustFS upload failed', {
|
||||
error: rustfsError instanceof Error ? rustfsError.message : String(rustfsError)
|
||||
})
|
||||
|
||||
for (const skipped of detail.skippedMaterials) {
|
||||
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', {
|
||||
error: reportError instanceof Error ? reportError.message : String(reportError)
|
||||
|
||||
// Determine execution status
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import { dirname } from 'path'
|
||||
import { app } from 'electron'
|
||||
import yaml from 'js-yaml'
|
||||
import { z } from 'zod'
|
||||
import { createLogger, applyLoggingConfig, trackDuration } from '../logger'
|
||||
import { createLogger, applyLoggingConfig } from '../logger'
|
||||
import { applyAuditConfig } from '../logger/audit-logger'
|
||||
import {
|
||||
fullConfigSchema,
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
type DatabaseType,
|
||||
type MySqlConfig,
|
||||
type SqlServerConfig,
|
||||
type PostgreSqlConfig,
|
||||
type LoggingConfig
|
||||
} from '../../types/config.schema'
|
||||
|
||||
@@ -66,6 +67,14 @@ const DEFAULT_CONFIG: FullConfig = {
|
||||
password: '',
|
||||
driver: 'ODBC Driver 18 for SQL Server',
|
||||
trustServerCertificate: true
|
||||
},
|
||||
postgresql: {
|
||||
host: 'localhost',
|
||||
port: 5432,
|
||||
database: 'erp_db',
|
||||
username: 'postgres',
|
||||
password: '',
|
||||
maxPoolSize: 10
|
||||
}
|
||||
},
|
||||
paths: {
|
||||
@@ -299,13 +308,20 @@ export class ConfigManager {
|
||||
/**
|
||||
* 获取当前激活的数据库配置
|
||||
*/
|
||||
public getActiveDatabaseConfig(): MySqlConfig | SqlServerConfig {
|
||||
public getActiveDatabaseConfig(): MySqlConfig | SqlServerConfig | PostgreSqlConfig {
|
||||
if (!this.config) {
|
||||
throw new Error('Configuration not initialized')
|
||||
}
|
||||
|
||||
const { activeType, mysql, sqlserver } = this.config.database
|
||||
return activeType === 'mysql' ? mysql : sqlserver
|
||||
const { activeType, mysql, sqlserver, postgresql } = this.config.database
|
||||
switch (activeType) {
|
||||
case 'postgresql':
|
||||
return postgresql
|
||||
case 'sqlserver':
|
||||
return sqlserver
|
||||
default:
|
||||
return mysql
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
1000
src/main/services/database/cleaner-operation-history-dao.ts
Normal file
1000
src/main/services/database/cleaner-operation-history-dao.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,8 @@
|
||||
*/
|
||||
|
||||
import { createLogger } from '../logger'
|
||||
import { logAuditWithCurrentUser } from '../logger/audit-logger'
|
||||
import { AuditAction, AuditStatus } from '../../types/audit.types'
|
||||
import { DiscreteMaterialPlanDAO, type MaterialPlanRecord } from './discrete-material-plan-dao'
|
||||
|
||||
const log = createLogger('DataImportService')
|
||||
@@ -148,6 +150,20 @@ export class DataImportService {
|
||||
}
|
||||
}
|
||||
|
||||
// Audit log: DATA_IMPORT
|
||||
logAuditWithCurrentUser(
|
||||
AuditAction.DATA_IMPORT,
|
||||
'MATERIAL_PLAN',
|
||||
result.success ? AuditStatus.SUCCESS : AuditStatus.FAILURE,
|
||||
{
|
||||
recordsRead: result.recordsRead,
|
||||
recordsDeleted: result.recordsDeleted,
|
||||
recordsImported: result.recordsImported,
|
||||
uniqueSourceNumbers: result.uniqueSourceNumbers,
|
||||
errorCount: result.errors.length
|
||||
}
|
||||
)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* TypeORM Data Source Configuration
|
||||
*
|
||||
* Provides a centralized database connection for TypeORM entities.
|
||||
* Supports both MySQL and SQL Server based on configuration.
|
||||
* Supports MySQL, SQL Server, and PostgreSQL based on configuration.
|
||||
*
|
||||
* Note: Configuration is now loaded from config.yaml via ConfigManager,
|
||||
* not from environment variables.
|
||||
@@ -18,10 +18,17 @@ const log = createLogger('DataSource')
|
||||
/**
|
||||
* Get database type from config manager
|
||||
*/
|
||||
function getDatabaseType(): 'mysql' | 'mssql' {
|
||||
function getDatabaseType(): 'mysql' | 'mssql' | 'postgres' {
|
||||
const configManager = ConfigManager.getInstance()
|
||||
const dbType = configManager.getDatabaseType()
|
||||
return dbType === 'sqlserver' ? 'mssql' : 'mysql'
|
||||
switch (dbType) {
|
||||
case 'sqlserver':
|
||||
return 'mssql'
|
||||
case 'postgresql':
|
||||
return 'postgres'
|
||||
default:
|
||||
return 'mysql'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -54,6 +61,17 @@ function buildDataSourceOptions(): DataSourceOptions {
|
||||
},
|
||||
...commonOptions
|
||||
} as DataSourceOptions
|
||||
} else if (type === 'postgres') {
|
||||
const dbConfig = config.database.postgresql
|
||||
return {
|
||||
type: 'postgres',
|
||||
host: dbConfig.host,
|
||||
port: dbConfig.port,
|
||||
username: dbConfig.username,
|
||||
password: dbConfig.password,
|
||||
database: dbConfig.database,
|
||||
...commonOptions
|
||||
} as DataSourceOptions
|
||||
}
|
||||
|
||||
const dbConfig = config.database.mysql
|
||||
|
||||
28
src/main/services/database/dialects/index.ts
Normal file
28
src/main/services/database/dialects/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* SQL Dialect Factory
|
||||
*
|
||||
* Creates the appropriate SqlDialect implementation based on database type.
|
||||
*/
|
||||
|
||||
import type { DatabaseType } from '../../../types/database.types'
|
||||
import type { SqlDialect } from '../../../types/sql-dialect.types'
|
||||
|
||||
import { MySqlDialect } from './mysql-dialect'
|
||||
import { PostgreSqlDialect } from './postgresql-dialect'
|
||||
import { SqlServerDialect } from './sqlserver-dialect'
|
||||
|
||||
export { MySqlDialect } from './mysql-dialect'
|
||||
export { PostgreSqlDialect } from './postgresql-dialect'
|
||||
export { SqlServerDialect } from './sqlserver-dialect'
|
||||
export type { SqlDialect } from '../../../types/sql-dialect.types'
|
||||
|
||||
export function createDialect(type: DatabaseType): SqlDialect {
|
||||
switch (type) {
|
||||
case 'sqlserver':
|
||||
return new SqlServerDialect()
|
||||
case 'postgresql':
|
||||
return new PostgreSqlDialect()
|
||||
default:
|
||||
return new MySqlDialect()
|
||||
}
|
||||
}
|
||||
71
src/main/services/database/dialects/mysql-dialect.ts
Normal file
71
src/main/services/database/dialects/mysql-dialect.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* MySQL SQL Dialect Implementation
|
||||
*
|
||||
* Encapsulates MySQL-specific SQL syntax for:
|
||||
* - Table name quoting (underscore-separated)
|
||||
* - Positional parameter placeholders (?)
|
||||
* - INSERT ... ON DUPLICATE KEY UPDATE upsert
|
||||
* - LIMIT/OFFSET pagination
|
||||
*/
|
||||
|
||||
import type { SqlDialect } from '../../../types/sql-dialect.types'
|
||||
|
||||
export class MySqlDialect implements SqlDialect {
|
||||
readonly dbType = 'mysql' as const
|
||||
|
||||
quoteTableName(schema: string, table: string): string {
|
||||
return `${schema}_${table}`
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
param(_index: number): string {
|
||||
return '?'
|
||||
}
|
||||
|
||||
params(count: number): string {
|
||||
return Array.from({ length: count }, () => '?').join(',')
|
||||
}
|
||||
|
||||
currentTimestamp(): string {
|
||||
return 'UTC_TIMESTAMP()'
|
||||
}
|
||||
|
||||
upsert(params: {
|
||||
table: string
|
||||
keyColumns: string[]
|
||||
allColumns: string[]
|
||||
startParamIndex: number
|
||||
}): { sql: string; nextParamIndex: number } {
|
||||
const { table, keyColumns, allColumns, startParamIndex } = params
|
||||
|
||||
const columns = allColumns.join(', ')
|
||||
const placeholders = allColumns.map(() => '?').join(', ')
|
||||
|
||||
const nonKeyColumns = allColumns.filter((col) => !keyColumns.includes(col))
|
||||
const updateClause = nonKeyColumns.map((col) => `${col} = VALUES(${col})`).join(', ')
|
||||
|
||||
const sql = `INSERT INTO ${table} (${columns}) VALUES (${placeholders}) ON DUPLICATE KEY UPDATE ${updateClause}`
|
||||
|
||||
return {
|
||||
sql,
|
||||
nextParamIndex: startParamIndex + allColumns.length
|
||||
}
|
||||
}
|
||||
|
||||
paginate(params: { sql: string; limit: number; offset?: number; paramIndex: number }): {
|
||||
sql: string
|
||||
nextParamIndex: number
|
||||
} {
|
||||
const { sql, limit, offset, paramIndex } = params
|
||||
|
||||
return {
|
||||
sql: `${sql} LIMIT ${limit} OFFSET ${offset ?? 0}`,
|
||||
nextParamIndex: paramIndex
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
maxBatchRows(_columnsPerRow: number): number {
|
||||
return 1000
|
||||
}
|
||||
}
|
||||
76
src/main/services/database/dialects/postgresql-dialect.ts
Normal file
76
src/main/services/database/dialects/postgresql-dialect.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* PostgreSQL Dialect Implementation
|
||||
*
|
||||
* Encapsulates PostgreSQL-specific SQL syntax for:
|
||||
* - Table name quoting (double-quoted "schema"."table")
|
||||
* - Positional parameter placeholders ($1, $2, ...) — 1-based
|
||||
* - INSERT ... ON CONFLICT ... DO UPDATE SET upsert
|
||||
* - LIMIT/OFFSET pagination
|
||||
*/
|
||||
|
||||
import type { SqlDialect } from '../../../types/sql-dialect.types'
|
||||
|
||||
export class PostgreSqlDialect implements SqlDialect {
|
||||
readonly dbType = 'postgresql' as const
|
||||
|
||||
quoteTableName(schema: string, table: string): string {
|
||||
return `"${schema}"."${table}"`
|
||||
}
|
||||
|
||||
param(index: number): string {
|
||||
return `$${index + 1}`
|
||||
}
|
||||
|
||||
params(count: number): string {
|
||||
return Array.from({ length: count }, (_, i) => `$${i + 1}`).join(',')
|
||||
}
|
||||
|
||||
currentTimestamp(): string {
|
||||
return 'CURRENT_TIMESTAMP'
|
||||
}
|
||||
|
||||
upsert(params: {
|
||||
table: string
|
||||
keyColumns: string[]
|
||||
allColumns: string[]
|
||||
startParamIndex: number
|
||||
}): { sql: string; nextParamIndex: number } {
|
||||
const { table, keyColumns, allColumns, startParamIndex } = params
|
||||
|
||||
const columns = allColumns.join(', ')
|
||||
const placeholders = allColumns.map((_, i) => `$${startParamIndex + i + 1}`).join(', ')
|
||||
|
||||
const conflictKeys = keyColumns.map((col) => `"${col}"`).join(', ')
|
||||
|
||||
const nonKeyColumns = allColumns.filter((col) => !keyColumns.includes(col))
|
||||
const updateSet = nonKeyColumns.map((col) => `"${col}" = EXCLUDED."${col}"`).join(', ')
|
||||
|
||||
const sql = [
|
||||
`INSERT INTO ${table} (${columns}) VALUES (${placeholders})`,
|
||||
`ON CONFLICT (${conflictKeys})`,
|
||||
`DO UPDATE SET ${updateSet}`
|
||||
].join(' ')
|
||||
|
||||
return {
|
||||
sql,
|
||||
nextParamIndex: startParamIndex + allColumns.length
|
||||
}
|
||||
}
|
||||
|
||||
paginate(params: { sql: string; limit: number; offset?: number; paramIndex: number }): {
|
||||
sql: string
|
||||
nextParamIndex: number
|
||||
} {
|
||||
const { sql, limit, offset, paramIndex } = params
|
||||
|
||||
return {
|
||||
sql: `${sql} LIMIT ${limit} OFFSET ${offset ?? 0}`,
|
||||
nextParamIndex: paramIndex
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
maxBatchRows(_columnsPerRow: number): number {
|
||||
return 1000
|
||||
}
|
||||
}
|
||||
88
src/main/services/database/dialects/sqlserver-dialect.ts
Normal file
88
src/main/services/database/dialects/sqlserver-dialect.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* SQL Server Dialect Implementation
|
||||
*
|
||||
* Encapsulates SQL Server-specific SQL syntax for:
|
||||
* - Table name quoting (bracket notation [schema].[table])
|
||||
* - Named parameter placeholders (@p0, @p1, ...)
|
||||
* - MERGE ... USING upsert
|
||||
* - OFFSET/FETCH pagination
|
||||
*/
|
||||
|
||||
import type { SqlDialect } from '../../../types/sql-dialect.types'
|
||||
|
||||
export class SqlServerDialect implements SqlDialect {
|
||||
readonly dbType = 'sqlserver' as const
|
||||
|
||||
quoteTableName(schema: string, table: string): string {
|
||||
return `[${schema}].[${table}]`
|
||||
}
|
||||
|
||||
param(index: number): string {
|
||||
return `@p${index}`
|
||||
}
|
||||
|
||||
params(count: number): string {
|
||||
return Array.from({ length: count }, (_, i) => `@p${i}`).join(',')
|
||||
}
|
||||
|
||||
currentTimestamp(): string {
|
||||
return 'SYSUTCDATETIME()'
|
||||
}
|
||||
|
||||
upsert(params: {
|
||||
table: string
|
||||
keyColumns: string[]
|
||||
allColumns: string[]
|
||||
startParamIndex: number
|
||||
}): { sql: string; nextParamIndex: number } {
|
||||
const { table, keyColumns, allColumns, startParamIndex } = params
|
||||
|
||||
const valueParams = allColumns.map((_, i) => `@p${startParamIndex + i}`).join(', ')
|
||||
const sourceColumns = allColumns.join(', ')
|
||||
|
||||
const joinCondition = keyColumns.map((col) => `target.${col} = source.${col}`).join(' AND ')
|
||||
|
||||
const nonKeyColumns = allColumns.filter((col) => !keyColumns.includes(col))
|
||||
const updateSet = nonKeyColumns.map((col) => `target.${col} = source.${col}`).join(', ')
|
||||
|
||||
const insertColumns = allColumns.join(', ')
|
||||
const insertValues = allColumns.map((col) => `source.${col}`).join(', ')
|
||||
|
||||
const sql = [
|
||||
`MERGE ${table} AS target`,
|
||||
`USING (VALUES (${valueParams})) AS source (${sourceColumns})`,
|
||||
`ON ${joinCondition}`,
|
||||
`WHEN MATCHED THEN UPDATE SET ${updateSet}`,
|
||||
`WHEN NOT MATCHED THEN INSERT (${insertColumns}) VALUES (${insertValues});`
|
||||
].join(' ')
|
||||
|
||||
return {
|
||||
sql,
|
||||
nextParamIndex: startParamIndex + allColumns.length
|
||||
}
|
||||
}
|
||||
|
||||
paginate(params: { sql: string; limit: number; offset?: number; paramIndex: number }): {
|
||||
sql: string
|
||||
nextParamIndex: number
|
||||
} {
|
||||
const { sql, offset, paramIndex } = params
|
||||
void params.limit // used by caller to push param values
|
||||
|
||||
if (offset !== undefined) {
|
||||
return {
|
||||
sql: `${sql} OFFSET @p${paramIndex} ROWS FETCH NEXT @p${paramIndex + 1} ROWS ONLY`,
|
||||
nextParamIndex: paramIndex + 2
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sql: `${sql} OFFSET 0 ROWS FETCH NEXT @p${paramIndex} ROWS ONLY`,
|
||||
nextParamIndex: paramIndex + 1
|
||||
}
|
||||
}
|
||||
|
||||
maxBatchRows(columnsPerRow: number): number {
|
||||
return Math.floor(2000 / columnsPerRow)
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,8 @@
|
||||
*/
|
||||
|
||||
import { create, type IDatabaseService } from './index'
|
||||
import { createLogger, run, getRequestId, trackDuration } from '../logger'
|
||||
import { createDialect, type SqlDialect } from './dialects'
|
||||
import { createLogger, getRequestId, trackDuration } from '../logger'
|
||||
|
||||
const log = createLogger('DiscreteMaterialPlanDAO')
|
||||
|
||||
@@ -53,8 +54,6 @@ export interface MaterialPlanRecord {
|
||||
* Configuration for DiscreteMaterialPlanData table
|
||||
*/
|
||||
export const DISCRETE_MATERIAL_PLAN_CONFIG = {
|
||||
TABLE_NAME_SQLSERVER: '[dbo].[DiscreteMaterialPlanData]',
|
||||
TABLE_NAME_MYSQL: 'dbo_DiscreteMaterialPlanData',
|
||||
COLUMNS: {
|
||||
ID: 'ID',
|
||||
FACTORY: 'Factory',
|
||||
@@ -94,15 +93,20 @@ export const DISCRETE_MATERIAL_PLAN_CONFIG = {
|
||||
*/
|
||||
export class DiscreteMaterialPlanDAO {
|
||||
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 getTableName(): string {
|
||||
const isSqlServer = this.dbService?.type === 'sqlserver'
|
||||
return isSqlServer
|
||||
? DISCRETE_MATERIAL_PLAN_CONFIG.TABLE_NAME_SQLSERVER
|
||||
: DISCRETE_MATERIAL_PLAN_CONFIG.TABLE_NAME_MYSQL
|
||||
return this.getDialect().quoteTableName('dbo', 'DiscreteMaterialPlanData')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -117,15 +121,6 @@ export class DiscreteMaterialPlanDAO {
|
||||
return this.dbService
|
||||
}
|
||||
|
||||
/**
|
||||
* Build placeholders for IN clause based on database type
|
||||
*/
|
||||
private buildPlaceholders(count: number, isSqlServer: boolean): string {
|
||||
return isSqlServer
|
||||
? Array.from({ length: count }, (_, idx) => `@p${idx}`).join(',')
|
||||
: Array.from({ length: count }, () => '?').join(',')
|
||||
}
|
||||
|
||||
// ==================== QUERY ALL ====================
|
||||
|
||||
/**
|
||||
@@ -219,13 +214,13 @@ export class DiscreteMaterialPlanDAO {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
const dialect = this.getDialect()
|
||||
const batchSize = 1500
|
||||
const allResults: any[] = []
|
||||
|
||||
for (let i = 0; i < sourceNumbers.length; i += batchSize) {
|
||||
const batch = sourceNumbers.slice(i, i + batchSize)
|
||||
const placeholders = this.buildPlaceholders(batch.length, isSqlServer)
|
||||
const placeholders = dialect.params(batch.length)
|
||||
|
||||
const sqlString = `
|
||||
SELECT *
|
||||
@@ -273,13 +268,13 @@ export class DiscreteMaterialPlanDAO {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
const dialect = this.getDialect()
|
||||
const batchSize = 1500
|
||||
const allResults: any[] = []
|
||||
|
||||
for (let i = 0; i < sourceNumbers.length; i += batchSize) {
|
||||
const batch = sourceNumbers.slice(i, i + batchSize)
|
||||
const placeholders = this.buildPlaceholders(batch.length, isSqlServer)
|
||||
const placeholders = dialect.params(batch.length)
|
||||
|
||||
const sqlString = `
|
||||
WITH RankedRecords AS (
|
||||
@@ -338,9 +333,9 @@ export class DiscreteMaterialPlanDAO {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
const dialect = this.getDialect()
|
||||
|
||||
const placeholder = isSqlServer ? '@p0' : '?'
|
||||
const placeholder = dialect.param(0)
|
||||
const sqlString = `
|
||||
SELECT *
|
||||
FROM ${tableName}
|
||||
@@ -377,9 +372,9 @@ export class DiscreteMaterialPlanDAO {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
const dialect = this.getDialect()
|
||||
|
||||
const placeholder = isSqlServer ? '@p0' : '?'
|
||||
const placeholder = dialect.param(0)
|
||||
const sqlString = `
|
||||
SELECT *
|
||||
FROM ${tableName}
|
||||
@@ -418,13 +413,13 @@ export class DiscreteMaterialPlanDAO {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
const dialect = this.getDialect()
|
||||
const batchSize = 1500
|
||||
const allResults: any[] = []
|
||||
|
||||
for (let i = 0; i < planNumbers.length; i += batchSize) {
|
||||
const batch = planNumbers.slice(i, i + batchSize)
|
||||
const placeholders = this.buildPlaceholders(batch.length, isSqlServer)
|
||||
const placeholders = dialect.params(batch.length)
|
||||
|
||||
const sqlString = `
|
||||
SELECT *
|
||||
@@ -476,7 +471,7 @@ export class DiscreteMaterialPlanDAO {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
const dialect = this.getDialect()
|
||||
const batchSize = 2000
|
||||
|
||||
// Get unique source numbers
|
||||
@@ -495,7 +490,7 @@ export class DiscreteMaterialPlanDAO {
|
||||
for (let i = 0; i < uniqueSourceNumbers.length; i += batchSize) {
|
||||
const batch = uniqueSourceNumbers.slice(i, i + batchSize)
|
||||
const batchNumber = Math.floor(i / batchSize) + 1
|
||||
const placeholders = this.buildPlaceholders(batch.length, isSqlServer)
|
||||
const placeholders = dialect.params(batch.length)
|
||||
|
||||
const sqlString = `
|
||||
DELETE FROM ${tableName}
|
||||
@@ -565,23 +560,19 @@ export class DiscreteMaterialPlanDAO {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
const dialect = this.getDialect()
|
||||
|
||||
// SQL Server has a limit of 2100 parameters per query
|
||||
// Each record has 28 columns, so max rows per batch = 2100 / 28 = 75
|
||||
// Leave some margin for query overhead
|
||||
const columnsPerRow = 28
|
||||
const sqlServerMaxParams = 2000
|
||||
const effectiveBatchSize = isSqlServer
|
||||
? Math.min(batchSize, Math.floor(sqlServerMaxParams / columnsPerRow))
|
||||
: batchSize
|
||||
const effectiveBatchSize = Math.min(batchSize, dialect.maxBatchRows(columnsPerRow))
|
||||
const totalBatches = Math.ceil(records.length / effectiveBatchSize)
|
||||
|
||||
log.info('Batch insert started', {
|
||||
tableName,
|
||||
operationType: 'INSERT',
|
||||
requestId: batchId,
|
||||
isSqlServer,
|
||||
dbType: dbService.type,
|
||||
columnsPerRow,
|
||||
effectiveBatchSize,
|
||||
@@ -598,7 +589,6 @@ export class DiscreteMaterialPlanDAO {
|
||||
dbService,
|
||||
tableName,
|
||||
batch,
|
||||
isSqlServer,
|
||||
batchId,
|
||||
batchNumber,
|
||||
totalBatches
|
||||
@@ -643,7 +633,6 @@ export class DiscreteMaterialPlanDAO {
|
||||
dbService: IDatabaseService,
|
||||
tableName: string,
|
||||
records: MaterialPlanRecord[],
|
||||
isSqlServer: boolean,
|
||||
batchId: string,
|
||||
batchNumber: number,
|
||||
totalBatches: number
|
||||
@@ -689,7 +678,7 @@ export class DiscreteMaterialPlanDAO {
|
||||
const rowPlaceholders: string[] = []
|
||||
|
||||
records.forEach((record, rowIndex) => {
|
||||
const rowValues = this.buildRowValues(record, columns, rowIndex, isSqlServer, values)
|
||||
const rowValues = this.buildRowValues(record, columns, rowIndex, values)
|
||||
rowPlaceholders.push(`(${rowValues.join(',')})`)
|
||||
})
|
||||
|
||||
@@ -718,10 +707,9 @@ export class DiscreteMaterialPlanDAO {
|
||||
private async insertBatch(
|
||||
dbService: IDatabaseService,
|
||||
tableName: string,
|
||||
records: MaterialPlanRecord[],
|
||||
isSqlServer: boolean
|
||||
records: MaterialPlanRecord[]
|
||||
): Promise<number> {
|
||||
return this.insertBatchWithTracking(dbService, tableName, records, isSqlServer, 'unknown', 1, 1)
|
||||
return this.insertBatchWithTracking(dbService, tableName, records, 'unknown', 1, 1)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -731,18 +719,14 @@ export class DiscreteMaterialPlanDAO {
|
||||
record: MaterialPlanRecord,
|
||||
columns: string[],
|
||||
_rowIndex: number,
|
||||
isSqlServer: boolean,
|
||||
values: any[]
|
||||
): string[] {
|
||||
const dialect = this.getDialect()
|
||||
return columns.map((col) => {
|
||||
const value = this.getColumnValue(record, col)
|
||||
values.push(value)
|
||||
|
||||
if (isSqlServer) {
|
||||
return `@p${values.length - 1}`
|
||||
} else {
|
||||
return '?'
|
||||
}
|
||||
return dialect.param(values.length - 1)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -839,9 +823,9 @@ export class DiscreteMaterialPlanDAO {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
const dialect = this.getDialect()
|
||||
|
||||
const placeholder = isSqlServer ? '@p0' : '?'
|
||||
const placeholder = dialect.param(0)
|
||||
const sqlString = `
|
||||
SELECT COUNT(*) as count
|
||||
FROM ${tableName}
|
||||
@@ -876,7 +860,7 @@ export class DiscreteMaterialPlanDAO {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
const dialect = this.getDialect()
|
||||
|
||||
if (sourceNumbers && sourceNumbers.length > 0) {
|
||||
const batchSize = 1500
|
||||
@@ -884,7 +868,7 @@ export class DiscreteMaterialPlanDAO {
|
||||
|
||||
for (let i = 0; i < sourceNumbers.length; i += batchSize) {
|
||||
const batch = sourceNumbers.slice(i, i + batchSize)
|
||||
const placeholders = this.buildPlaceholders(batch.length, isSqlServer)
|
||||
const placeholders = dialect.params(batch.length)
|
||||
|
||||
const sqlString = `
|
||||
SELECT DISTINCT MaterialName
|
||||
@@ -975,6 +959,7 @@ export class DiscreteMaterialPlanDAO {
|
||||
if (this.dbService) {
|
||||
await this.dbService.disconnect()
|
||||
this.dbService = null
|
||||
this.dialect = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
*/
|
||||
|
||||
import { create, type IDatabaseService } from './index'
|
||||
import { createLogger, run, getRequestId, trackDuration } from '../logger'
|
||||
import { createDialect, type SqlDialect } from './dialects'
|
||||
import { createLogger, getRequestId, trackDuration } from '../logger'
|
||||
import type {
|
||||
OperationHistoryRecord,
|
||||
BatchStats,
|
||||
@@ -36,8 +37,6 @@ function formatDateTime(value: unknown): string {
|
||||
* Configuration for ExtractorOperationHistory table
|
||||
*/
|
||||
export const EXTRACTOR_OPERATION_HISTORY_CONFIG = {
|
||||
TABLE_NAME_SQLSERVER: '[dbo].[ExtractorOperationHistory]',
|
||||
TABLE_NAME_MYSQL: 'dbo_ExtractorOperationHistory',
|
||||
COLUMNS: {
|
||||
ID: 'ID',
|
||||
BATCH_ID: 'BatchId',
|
||||
@@ -57,15 +56,20 @@ export const EXTRACTOR_OPERATION_HISTORY_CONFIG = {
|
||||
*/
|
||||
export class ExtractorOperationHistoryDAO {
|
||||
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 getTableName(): string {
|
||||
const isSqlServer = this.dbService?.type === 'sqlserver'
|
||||
return isSqlServer
|
||||
? EXTRACTOR_OPERATION_HISTORY_CONFIG.TABLE_NAME_SQLSERVER
|
||||
: EXTRACTOR_OPERATION_HISTORY_CONFIG.TABLE_NAME_MYSQL
|
||||
return this.getDialect().quoteTableName('dbo', 'ExtractorOperationHistory')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,15 +84,6 @@ export class ExtractorOperationHistoryDAO {
|
||||
return this.dbService
|
||||
}
|
||||
|
||||
/**
|
||||
* Build placeholders for IN clause based on database type
|
||||
*/
|
||||
private buildPlaceholders(count: number, isSqlServer: boolean): string {
|
||||
return isSqlServer
|
||||
? Array.from({ length: count }, (_, idx) => `@p${idx}`).join(',')
|
||||
: Array.from({ length: count }, () => '?').join(',')
|
||||
}
|
||||
|
||||
// ==================== INSERT ====================
|
||||
|
||||
/**
|
||||
@@ -119,7 +114,7 @@ export class ExtractorOperationHistoryDAO {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
const dialect = this.getDialect()
|
||||
|
||||
log.info('Batch records insertion started', {
|
||||
tableName,
|
||||
@@ -133,49 +128,26 @@ export class ExtractorOperationHistoryDAO {
|
||||
|
||||
for (const record of records) {
|
||||
try {
|
||||
if (isSqlServer) {
|
||||
const sqlString = `
|
||||
INSERT INTO ${tableName}
|
||||
(BatchId, UserId, Username, ProductionId, OrderNumber, OperationTime, Status)
|
||||
VALUES
|
||||
(@p0, @p1, @p2, @p3, @p4, GETDATE(), 'pending')
|
||||
`
|
||||
await trackDuration(
|
||||
async () =>
|
||||
await dbService.query(sqlString, [
|
||||
batchId,
|
||||
userId,
|
||||
username,
|
||||
record.productionId || null,
|
||||
record.orderNumber
|
||||
]),
|
||||
{
|
||||
operationName: 'ExtractorOperationHistoryDAO.insertBatchRecords',
|
||||
context: { tableName, operationType: 'INSERT', batchId }
|
||||
}
|
||||
)
|
||||
} else {
|
||||
const sqlString = `
|
||||
INSERT INTO ${tableName}
|
||||
(BatchId, UserId, Username, ProductionId, OrderNumber, OperationTime, Status)
|
||||
VALUES
|
||||
(?, ?, ?, ?, ?, NOW(), 'pending')
|
||||
`
|
||||
await trackDuration(
|
||||
async () =>
|
||||
await dbService.query(sqlString, [
|
||||
batchId,
|
||||
userId,
|
||||
username,
|
||||
record.productionId || null,
|
||||
record.orderNumber
|
||||
]),
|
||||
{
|
||||
operationName: 'ExtractorOperationHistoryDAO.insertBatchRecords',
|
||||
context: { tableName, operationType: 'INSERT', batchId }
|
||||
}
|
||||
)
|
||||
}
|
||||
const sqlString = `
|
||||
INSERT INTO ${tableName}
|
||||
(BatchId, UserId, Username, ProductionId, OrderNumber, OperationTime, Status)
|
||||
VALUES
|
||||
(${dialect.param(0)}, ${dialect.param(1)}, ${dialect.param(2)}, ${dialect.param(3)}, ${dialect.param(4)}, ${dialect.currentTimestamp()}, 'pending')
|
||||
`
|
||||
await trackDuration(
|
||||
async () =>
|
||||
await dbService.query(sqlString, [
|
||||
batchId,
|
||||
userId,
|
||||
username,
|
||||
record.productionId || null,
|
||||
record.orderNumber
|
||||
]),
|
||||
{
|
||||
operationName: 'ExtractorOperationHistoryDAO.insertBatchRecords',
|
||||
context: { tableName, operationType: 'INSERT', batchId }
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
log.error('Error inserting individual record', {
|
||||
tableName,
|
||||
@@ -221,16 +193,16 @@ export class ExtractorOperationHistoryDAO {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
const dialect = this.getDialect()
|
||||
|
||||
const sqlString = `
|
||||
UPDATE ${tableName}
|
||||
SET Status = ${isSqlServer ? '@p0' : '?'}
|
||||
WHERE BatchId = ${isSqlServer ? '@p1' : '?'}
|
||||
SET Status = ${dialect.param(0)}
|
||||
WHERE BatchId = ${dialect.param(1)}
|
||||
`
|
||||
const params = [status, batchId]
|
||||
|
||||
const result = await trackDuration(async () => await dbService.query(sqlString, params), {
|
||||
await trackDuration(async () => await dbService.query(sqlString, params), {
|
||||
operationName: 'ExtractorOperationHistoryDAO.updateBatchStatus',
|
||||
context: { tableName, operationType: 'UPDATE', batchId }
|
||||
})
|
||||
@@ -274,7 +246,7 @@ export class ExtractorOperationHistoryDAO {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
const dialect = this.getDialect()
|
||||
|
||||
let sqlString: string
|
||||
let params: (string | number | null)[]
|
||||
@@ -282,20 +254,20 @@ export class ExtractorOperationHistoryDAO {
|
||||
if (recordCount !== undefined) {
|
||||
sqlString = `
|
||||
UPDATE ${tableName}
|
||||
SET Status = ${isSqlServer ? '@p0' : '?'},
|
||||
ErrorMessage = ${isSqlServer ? '@p1' : '?'},
|
||||
RecordCount = ${isSqlServer ? '@p2' : '?'}
|
||||
WHERE BatchId = ${isSqlServer ? '@p3' : '?'}
|
||||
AND OrderNumber = ${isSqlServer ? '@p4' : '?'}
|
||||
SET Status = ${dialect.param(0)},
|
||||
ErrorMessage = ${dialect.param(1)},
|
||||
RecordCount = ${dialect.param(2)}
|
||||
WHERE BatchId = ${dialect.param(3)}
|
||||
AND OrderNumber = ${dialect.param(4)}
|
||||
`
|
||||
params = [status, errorMessage || null, recordCount, batchId, orderNumber]
|
||||
} else {
|
||||
sqlString = `
|
||||
UPDATE ${tableName}
|
||||
SET Status = ${isSqlServer ? '@p0' : '?'},
|
||||
ErrorMessage = ${isSqlServer ? '@p1' : '?'}
|
||||
WHERE BatchId = ${isSqlServer ? '@p2' : '?'}
|
||||
AND OrderNumber = ${isSqlServer ? '@p3' : '?'}
|
||||
SET Status = ${dialect.param(0)},
|
||||
ErrorMessage = ${dialect.param(1)}
|
||||
WHERE BatchId = ${dialect.param(2)}
|
||||
AND OrderNumber = ${dialect.param(3)}
|
||||
`
|
||||
params = [status, errorMessage || null, batchId, orderNumber]
|
||||
}
|
||||
@@ -331,7 +303,7 @@ export class ExtractorOperationHistoryDAO {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
const dialect = this.getDialect()
|
||||
|
||||
let sqlString = `
|
||||
SELECT
|
||||
@@ -350,11 +322,10 @@ export class ExtractorOperationHistoryDAO {
|
||||
const params: (number | string)[] = []
|
||||
|
||||
if (userId !== undefined) {
|
||||
sqlString += isSqlServer ? ` WHERE UserId = @p0 ` : ` WHERE UserId = ? `
|
||||
sqlString += ` WHERE UserId = ${dialect.param(params.length)} `
|
||||
params.push(userId)
|
||||
} else if (options?.usernames && options.usernames.length > 0) {
|
||||
const placeholders = this.buildPlaceholders(options.usernames.length, isSqlServer)
|
||||
sqlString += ` WHERE Username IN (${placeholders}) `
|
||||
sqlString += ` WHERE Username IN (${dialect.params(options.usernames.length)}) `
|
||||
params.push(...options.usernames)
|
||||
}
|
||||
|
||||
@@ -367,24 +338,19 @@ export class ExtractorOperationHistoryDAO {
|
||||
const safeLimit = Math.floor(options.limit)
|
||||
const safeOffset = options.offset !== undefined ? Math.floor(options.offset) : undefined
|
||||
|
||||
if (isSqlServer) {
|
||||
const offsetIndex = params.length
|
||||
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)
|
||||
|
||||
if (safeOffset !== undefined) {
|
||||
sqlString += ` OFFSET @p${offsetIndex} ROWS FETCH NEXT @p${offsetIndex + 1} ROWS ONLY`
|
||||
} else {
|
||||
sqlString += ` OFFSET 0 ROWS FETCH NEXT @p${offsetIndex} ROWS ONLY`
|
||||
}
|
||||
} else {
|
||||
if (safeOffset !== undefined) {
|
||||
sqlString += ` LIMIT ${safeLimit} OFFSET ${safeOffset}`
|
||||
} else {
|
||||
sqlString += ` LIMIT ${safeLimit}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -425,9 +391,9 @@ export class ExtractorOperationHistoryDAO {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
const dialect = this.getDialect()
|
||||
|
||||
const placeholder = isSqlServer ? '@p0' : '?'
|
||||
const placeholder = dialect.param(0)
|
||||
const sqlString = `
|
||||
SELECT
|
||||
ID,
|
||||
@@ -483,9 +449,9 @@ export class ExtractorOperationHistoryDAO {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
const dialect = this.getDialect()
|
||||
|
||||
const placeholder = isSqlServer ? '@p0' : '?'
|
||||
const placeholder = dialect.param(0)
|
||||
const sqlString = `
|
||||
SELECT
|
||||
BatchId,
|
||||
@@ -554,7 +520,7 @@ export class ExtractorOperationHistoryDAO {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
const dialect = this.getDialect()
|
||||
|
||||
// First check if the batch exists and if the user has permission
|
||||
const batchStats = await this.getBatchStats(batchId)
|
||||
@@ -569,7 +535,7 @@ export class ExtractorOperationHistoryDAO {
|
||||
}
|
||||
|
||||
// Delete the batch
|
||||
const placeholder = isSqlServer ? '@p0' : '?'
|
||||
const placeholder = dialect.param(0)
|
||||
const sqlString = `
|
||||
DELETE FROM ${tableName}
|
||||
WHERE BatchId = ${placeholder}
|
||||
@@ -612,9 +578,9 @@ export class ExtractorOperationHistoryDAO {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
const dialect = this.getDialect()
|
||||
|
||||
const placeholder = isSqlServer ? '@p0' : '?'
|
||||
const placeholder = dialect.param(0)
|
||||
const sqlString = `
|
||||
DELETE FROM ${tableName}
|
||||
WHERE UserId = ${placeholder}
|
||||
@@ -648,9 +614,9 @@ export class ExtractorOperationHistoryDAO {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
const dialect = this.getDialect()
|
||||
|
||||
const placeholder = isSqlServer ? '@p0' : '?'
|
||||
const placeholder = dialect.param(0)
|
||||
const sqlString = `
|
||||
SELECT COUNT(*) as count
|
||||
FROM ${tableName}
|
||||
@@ -684,7 +650,7 @@ export class ExtractorOperationHistoryDAO {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
const dialect = this.getDialect()
|
||||
|
||||
let sqlString = `
|
||||
SELECT COUNT(DISTINCT BatchId) as count
|
||||
@@ -694,11 +660,10 @@ export class ExtractorOperationHistoryDAO {
|
||||
const params: (number | string)[] = []
|
||||
|
||||
if (userId !== undefined) {
|
||||
sqlString += isSqlServer ? ` WHERE UserId = @p0 ` : ` WHERE UserId = ? `
|
||||
sqlString += ` WHERE UserId = ${dialect.param(params.length)} `
|
||||
params.push(userId)
|
||||
} else if (usernames && usernames.length > 0) {
|
||||
const placeholders = this.buildPlaceholders(usernames.length, isSqlServer)
|
||||
sqlString += ` WHERE Username IN (${placeholders}) `
|
||||
sqlString += ` WHERE Username IN (${dialect.params(usernames.length)}) `
|
||||
params.push(...usernames)
|
||||
}
|
||||
|
||||
@@ -725,6 +690,7 @@ export class ExtractorOperationHistoryDAO {
|
||||
if (this.dbService) {
|
||||
await this.dbService.disconnect()
|
||||
this.dbService = null
|
||||
this.dialect = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,17 +2,19 @@
|
||||
* Database Factory
|
||||
*
|
||||
* Creates and manages database service instances based on configuration.
|
||||
* Supports both MySQL and SQL Server databases.
|
||||
* Supports MySQL, SQL Server, and PostgreSQL databases.
|
||||
*/
|
||||
|
||||
import { ConfigManager } from '../config/config-manager'
|
||||
import { MySqlService } from './mysql'
|
||||
import { SqlServerService } from './sql-server'
|
||||
import { PostgreSqlService } from './postgresql'
|
||||
import type {
|
||||
IDatabaseService,
|
||||
DatabaseType,
|
||||
MySqlConfig,
|
||||
SqlServerConfig
|
||||
SqlServerConfig,
|
||||
PostgreSqlConfig
|
||||
} from '../../types/database.types'
|
||||
import { createLogger } from '../logger'
|
||||
|
||||
@@ -65,6 +67,22 @@ export function createSqlServerConfig(): SqlServerConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create PostgreSQL configuration from config manager
|
||||
*/
|
||||
export function createPostgreSqlConfig(): PostgreSqlConfig {
|
||||
const configManager = ConfigManager.getInstance()
|
||||
const dbConfig = configManager.getConfig().database.postgresql
|
||||
return {
|
||||
host: dbConfig.host,
|
||||
port: dbConfig.port,
|
||||
user: dbConfig.username,
|
||||
password: dbConfig.password,
|
||||
database: dbConfig.database,
|
||||
maxPoolSize: dbConfig.maxPoolSize
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a database service instance
|
||||
*
|
||||
@@ -86,7 +104,10 @@ export async function create(type?: DatabaseType): Promise<IDatabaseService> {
|
||||
// Create new instance
|
||||
let service: IDatabaseService
|
||||
|
||||
if (dbType === 'sqlserver') {
|
||||
if (dbType === 'postgresql') {
|
||||
log.info('Creating PostgreSQL database service')
|
||||
service = new PostgreSqlService(createPostgreSqlConfig())
|
||||
} else if (dbType === 'sqlserver') {
|
||||
log.info('Creating SQL Server database service')
|
||||
service = new SqlServerService(createSqlServerConfig())
|
||||
} else {
|
||||
@@ -175,10 +196,12 @@ export function isConnected(type?: DatabaseType): boolean {
|
||||
// Re-export types and services
|
||||
export { MySqlService } from './mysql'
|
||||
export { SqlServerService } from './sql-server'
|
||||
export { PostgreSqlService } from './postgresql'
|
||||
export type {
|
||||
IDatabaseService,
|
||||
DatabaseType,
|
||||
QueryResult,
|
||||
MySqlConfig,
|
||||
SqlServerConfig
|
||||
SqlServerConfig,
|
||||
PostgreSqlConfig
|
||||
} from '../../types/database.types'
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
*/
|
||||
|
||||
import { create, type IDatabaseService } from './index'
|
||||
import { createLogger, run, getRequestId, trackDuration } from '../logger'
|
||||
import { createDialect, type SqlDialect } from './dialects'
|
||||
import { createLogger, getRequestId, trackDuration } from '../logger'
|
||||
|
||||
const log = createLogger('MaterialsToBeDeletedDAO')
|
||||
|
||||
@@ -44,8 +45,6 @@ export interface MaterialStats {
|
||||
* Configuration for MaterialsToBeDeleted table
|
||||
*/
|
||||
export const MATERIALS_TO_BE_DELETED_CONFIG = {
|
||||
TABLE_NAME_SQLSERVER: '[dbo].[MaterialsToBeDeleted]',
|
||||
TABLE_NAME_MYSQL: 'dbo_MaterialsToBeDeleted',
|
||||
COLUMNS: {
|
||||
ID: 'ID',
|
||||
MATERIAL_CODE: 'MaterialCode',
|
||||
@@ -58,15 +57,20 @@ export const MATERIALS_TO_BE_DELETED_CONFIG = {
|
||||
*/
|
||||
export class MaterialsToBeDeletedDAO {
|
||||
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 getTableName(): string {
|
||||
const isSqlServer = this.dbService?.type === 'sqlserver'
|
||||
return isSqlServer
|
||||
? MATERIALS_TO_BE_DELETED_CONFIG.TABLE_NAME_SQLSERVER
|
||||
: MATERIALS_TO_BE_DELETED_CONFIG.TABLE_NAME_MYSQL
|
||||
return this.getDialect().quoteTableName('dbo', 'MaterialsToBeDeleted')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -81,15 +85,6 @@ export class MaterialsToBeDeletedDAO {
|
||||
return this.dbService
|
||||
}
|
||||
|
||||
/**
|
||||
* Build placeholders for IN clause based on database type
|
||||
*/
|
||||
private buildPlaceholders(count: number, isSqlServer: boolean): string {
|
||||
return isSqlServer
|
||||
? Array.from({ length: count }, (_, idx) => `@p${idx}`).join(',')
|
||||
: Array.from({ length: count }, () => '?').join(',')
|
||||
}
|
||||
|
||||
// ==================== UPSERT (MERGE) ====================
|
||||
|
||||
/**
|
||||
@@ -113,33 +108,19 @@ export class MaterialsToBeDeletedDAO {
|
||||
const tableName = this.getTableName()
|
||||
const code = materialCode.trim()
|
||||
const manager = managerName?.trim() || null
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
const dialect = this.getDialect()
|
||||
|
||||
if (isSqlServer) {
|
||||
const sqlString = `
|
||||
MERGE ${tableName} AS target
|
||||
USING (VALUES (@p0, @p1)) AS source (MaterialCode, ManagerName)
|
||||
ON target.MaterialCode = source.MaterialCode
|
||||
WHEN MATCHED THEN UPDATE SET ManagerName = source.ManagerName
|
||||
WHEN NOT MATCHED THEN INSERT (MaterialCode, ManagerName) VALUES (source.MaterialCode, source.ManagerName);
|
||||
`
|
||||
const { sql: sqlString } = dialect.upsert({
|
||||
table: tableName,
|
||||
keyColumns: ['MaterialCode'],
|
||||
allColumns: ['MaterialCode', 'ManagerName'],
|
||||
startParamIndex: 0
|
||||
})
|
||||
|
||||
await trackDuration(async () => await dbService.query(sqlString, [code, manager]), {
|
||||
operationName: 'MaterialsToBeDeletedDAO.upsertMaterial',
|
||||
context: { tableName, operationType: 'MERGE' }
|
||||
})
|
||||
} else {
|
||||
const sqlString = `
|
||||
INSERT INTO ${tableName} (MaterialCode, ManagerName)
|
||||
VALUES (?, ?)
|
||||
ON DUPLICATE KEY UPDATE ManagerName = VALUES(ManagerName)
|
||||
`
|
||||
|
||||
await trackDuration(async () => await dbService.query(sqlString, [code, manager]), {
|
||||
operationName: 'MaterialsToBeDeletedDAO.upsertMaterial',
|
||||
context: { tableName, operationType: 'INSERT' }
|
||||
})
|
||||
}
|
||||
await trackDuration(async () => await dbService.query(sqlString, [code, manager]), {
|
||||
operationName: 'MaterialsToBeDeletedDAO.upsertMaterial',
|
||||
context: { tableName, operationType: 'UPSERT' }
|
||||
})
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
@@ -176,7 +157,7 @@ export class MaterialsToBeDeletedDAO {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
const dialect = this.getDialect()
|
||||
|
||||
log.info('Batch upsert started', {
|
||||
tableName,
|
||||
@@ -196,37 +177,20 @@ export class MaterialsToBeDeletedDAO {
|
||||
}
|
||||
|
||||
try {
|
||||
if (isSqlServer) {
|
||||
const sqlString = `
|
||||
MERGE ${tableName} AS target
|
||||
USING (VALUES (@p0, @p1)) AS source (MaterialCode, ManagerName)
|
||||
ON target.MaterialCode = source.MaterialCode
|
||||
WHEN MATCHED THEN UPDATE SET ManagerName = source.ManagerName
|
||||
WHEN NOT MATCHED THEN INSERT (MaterialCode, ManagerName) VALUES (source.MaterialCode, source.ManagerName);
|
||||
`
|
||||
const { sql: sqlString } = dialect.upsert({
|
||||
table: tableName,
|
||||
keyColumns: ['MaterialCode'],
|
||||
allColumns: ['MaterialCode', 'ManagerName'],
|
||||
startParamIndex: 0
|
||||
})
|
||||
|
||||
await trackDuration(
|
||||
async () => await dbService.query(sqlString, [materialCode, managerName || null]),
|
||||
{
|
||||
operationName: 'MaterialsToBeDeletedDAO.upsertBatch',
|
||||
context: { tableName, operationType: 'MERGE', batchId }
|
||||
}
|
||||
)
|
||||
} else {
|
||||
const sqlString = `
|
||||
INSERT INTO ${tableName} (MaterialCode, ManagerName)
|
||||
VALUES (?, ?)
|
||||
ON DUPLICATE KEY UPDATE ManagerName = VALUES(ManagerName)
|
||||
`
|
||||
|
||||
await trackDuration(
|
||||
async () => await dbService.query(sqlString, [materialCode, managerName || null]),
|
||||
{
|
||||
operationName: 'MaterialsToBeDeletedDAO.upsertBatch',
|
||||
context: { tableName, operationType: 'INSERT', batchId }
|
||||
}
|
||||
)
|
||||
}
|
||||
await trackDuration(
|
||||
async () => await dbService.query(sqlString, [materialCode, managerName || null]),
|
||||
{
|
||||
operationName: 'MaterialsToBeDeletedDAO.upsertBatch',
|
||||
context: { tableName, operationType: 'UPSERT', batchId }
|
||||
}
|
||||
)
|
||||
|
||||
stats.success++
|
||||
} catch (error) {
|
||||
@@ -276,25 +240,15 @@ export class MaterialsToBeDeletedDAO {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
const dialect = this.getDialect()
|
||||
|
||||
if (isSqlServer) {
|
||||
const sqlString = `
|
||||
MERGE ${tableName} AS target
|
||||
USING (VALUES (@p0, @p1)) AS source (MaterialCode, ManagerName)
|
||||
ON target.MaterialCode = source.MaterialCode
|
||||
WHEN MATCHED THEN UPDATE SET ManagerName = source.ManagerName
|
||||
WHEN NOT MATCHED THEN INSERT (MaterialCode, ManagerName) VALUES (source.MaterialCode, source.ManagerName);
|
||||
`
|
||||
await dbService.query(sqlString, [materialCode, managerName || null])
|
||||
} else {
|
||||
const sqlString = `
|
||||
INSERT INTO ${tableName} (MaterialCode, ManagerName)
|
||||
VALUES (?, ?)
|
||||
ON DUPLICATE KEY UPDATE ManagerName = VALUES(ManagerName)
|
||||
`
|
||||
await dbService.query(sqlString, [materialCode, managerName || null])
|
||||
}
|
||||
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) {
|
||||
@@ -387,9 +341,9 @@ export class MaterialsToBeDeletedDAO {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
const dialect = this.getDialect()
|
||||
|
||||
const placeholder = isSqlServer ? '@p0' : '?'
|
||||
const placeholder = dialect.param(0)
|
||||
const sqlString = `
|
||||
SELECT ID, MaterialCode, ManagerName
|
||||
FROM ${tableName}
|
||||
@@ -462,9 +416,9 @@ export class MaterialsToBeDeletedDAO {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const code = materialCode.trim()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
const dialect = this.getDialect()
|
||||
|
||||
const placeholder = isSqlServer ? '@p0' : '?'
|
||||
const placeholder = dialect.param(0)
|
||||
const sqlString = `
|
||||
SELECT ID, MaterialCode, ManagerName
|
||||
FROM ${tableName}
|
||||
@@ -509,9 +463,9 @@ export class MaterialsToBeDeletedDAO {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const code = materialCode.trim()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
const dialect = this.getDialect()
|
||||
|
||||
const placeholder = isSqlServer ? '@p0' : '?'
|
||||
const placeholder = dialect.param(0)
|
||||
const sqlString = `
|
||||
DELETE FROM ${tableName}
|
||||
WHERE MaterialCode = ${placeholder}
|
||||
@@ -542,9 +496,9 @@ export class MaterialsToBeDeletedDAO {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
const dialect = this.getDialect()
|
||||
|
||||
const placeholder = isSqlServer ? '@p0' : '?'
|
||||
const placeholder = dialect.param(0)
|
||||
const sqlString = `
|
||||
DELETE FROM ${tableName}
|
||||
WHERE ManagerName = ${placeholder}
|
||||
@@ -612,7 +566,7 @@ export class MaterialsToBeDeletedDAO {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
const dialect = this.getDialect()
|
||||
const totalBatches = Math.ceil(materialCodes.length / batchSize)
|
||||
|
||||
log.info('Batch delete started', {
|
||||
@@ -627,7 +581,7 @@ export class MaterialsToBeDeletedDAO {
|
||||
for (let i = 0; i < materialCodes.length; i += batchSize) {
|
||||
const batch = materialCodes.slice(i, i + batchSize)
|
||||
const batchNumber = Math.floor(i / batchSize) + 1
|
||||
const placeholders = this.buildPlaceholders(batch.length, isSqlServer)
|
||||
const placeholders = dialect.params(batch.length)
|
||||
|
||||
const sqlString = `
|
||||
DELETE FROM ${tableName}
|
||||
@@ -688,9 +642,9 @@ export class MaterialsToBeDeletedDAO {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const code = materialCode.trim()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
const dialect = this.getDialect()
|
||||
|
||||
const placeholder = isSqlServer ? '@p0' : '?'
|
||||
const placeholder = dialect.param(0)
|
||||
const sqlString = `
|
||||
SELECT COUNT(*) as count
|
||||
FROM ${tableName}
|
||||
@@ -749,9 +703,9 @@ export class MaterialsToBeDeletedDAO {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
const dialect = this.getDialect()
|
||||
|
||||
const placeholder = isSqlServer ? '@p0' : '?'
|
||||
const placeholder = dialect.param(0)
|
||||
const sqlString = `
|
||||
SELECT COUNT(*) as count
|
||||
FROM ${tableName}
|
||||
@@ -845,6 +799,7 @@ export class MaterialsToBeDeletedDAO {
|
||||
if (this.dbService) {
|
||||
await this.dbService.disconnect()
|
||||
this.dbService = null
|
||||
this.dialect = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
*/
|
||||
|
||||
import { create, type IDatabaseService } from './index'
|
||||
import { createLogger, run, getRequestId, trackDuration } from '../logger'
|
||||
import { createDialect, type SqlDialect } from './dialects'
|
||||
import { createLogger, getRequestId, trackDuration } from '../logger'
|
||||
|
||||
const log = createLogger('MaterialsTypeToBeDeletedDAO')
|
||||
|
||||
@@ -32,8 +33,6 @@ export interface MaterialTypeBatchRequest {
|
||||
* Configuration for MaterialsTypeToBeDeleted table
|
||||
*/
|
||||
export const MATERIALS_TYPE_TO_BE_DELETED_CONFIG = {
|
||||
TABLE_NAME_SQLSERVER: '[dbo].[MaterialsTypeToBeDeleted]',
|
||||
TABLE_NAME_MYSQL: 'dbo_MaterialsTypeToBeDeleted',
|
||||
COLUMNS: {
|
||||
ID: 'ID',
|
||||
MATERIAL_NAME: 'MaterialName',
|
||||
@@ -46,15 +45,20 @@ export const MATERIALS_TYPE_TO_BE_DELETED_CONFIG = {
|
||||
*/
|
||||
export class MaterialsTypeToBeDeletedDAO {
|
||||
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 getTableName(): string {
|
||||
const isSqlServer = this.dbService?.type === 'sqlserver'
|
||||
return isSqlServer
|
||||
? MATERIALS_TYPE_TO_BE_DELETED_CONFIG.TABLE_NAME_SQLSERVER
|
||||
: MATERIALS_TYPE_TO_BE_DELETED_CONFIG.TABLE_NAME_MYSQL
|
||||
return this.getDialect().quoteTableName('dbo', 'MaterialsTypeToBeDeleted')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -116,9 +120,9 @@ export class MaterialsTypeToBeDeletedDAO {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
const dialect = this.getDialect()
|
||||
|
||||
const placeholder = isSqlServer ? '@p0' : '?'
|
||||
const placeholder = dialect.param(0)
|
||||
const sqlString = `
|
||||
SELECT ID, MaterialName, ManagerName
|
||||
FROM ${tableName}
|
||||
@@ -204,33 +208,19 @@ export class MaterialsTypeToBeDeletedDAO {
|
||||
const tableName = this.getTableName()
|
||||
const name = materialName.trim()
|
||||
const manager = managerName?.trim() || null
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
const dialect = this.getDialect()
|
||||
|
||||
if (isSqlServer) {
|
||||
const sqlString = `
|
||||
MERGE ${tableName} AS target
|
||||
USING (VALUES (@p0, @p1)) AS source (MaterialName, ManagerName)
|
||||
ON target.MaterialName = source.MaterialName
|
||||
WHEN MATCHED THEN UPDATE SET ManagerName = source.ManagerName
|
||||
WHEN NOT MATCHED THEN INSERT (MaterialName, ManagerName) VALUES (source.MaterialName, source.ManagerName);
|
||||
`
|
||||
const { sql: sqlString } = dialect.upsert({
|
||||
table: tableName,
|
||||
keyColumns: ['MaterialName'],
|
||||
allColumns: ['MaterialName', 'ManagerName'],
|
||||
startParamIndex: 0
|
||||
})
|
||||
|
||||
await trackDuration(async () => await dbService.query(sqlString, [name, manager]), {
|
||||
operationName: 'MaterialsTypeToBeDeletedDAO.upsertMaterial',
|
||||
context: { tableName, operationType: 'MERGE' }
|
||||
})
|
||||
} else {
|
||||
const sqlString = `
|
||||
INSERT INTO ${tableName} (MaterialName, ManagerName)
|
||||
VALUES (?, ?)
|
||||
ON DUPLICATE KEY UPDATE ManagerName = VALUES(ManagerName)
|
||||
`
|
||||
|
||||
await trackDuration(async () => await dbService.query(sqlString, [name, manager]), {
|
||||
operationName: 'MaterialsTypeToBeDeletedDAO.upsertMaterial',
|
||||
context: { tableName, operationType: 'INSERT' }
|
||||
})
|
||||
}
|
||||
await trackDuration(async () => await dbService.query(sqlString, [name, manager]), {
|
||||
operationName: 'MaterialsTypeToBeDeletedDAO.upsertMaterial',
|
||||
context: { tableName, operationType: 'UPSERT' }
|
||||
})
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
@@ -257,25 +247,16 @@ export class MaterialsTypeToBeDeletedDAO {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const name = materialName.trim()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
const dialect = this.getDialect()
|
||||
|
||||
let sqlString: string
|
||||
let params: (string | null)[]
|
||||
|
||||
if (managerName) {
|
||||
const placeholder1 = isSqlServer ? '@p0' : '?'
|
||||
const placeholder2 = isSqlServer ? '@p1' : '?'
|
||||
sqlString = `
|
||||
DELETE FROM ${tableName}
|
||||
WHERE MaterialName = ${placeholder1} AND ManagerName = ${placeholder2}
|
||||
`
|
||||
sqlString = `DELETE FROM ${tableName} WHERE MaterialName = ${dialect.param(0)} AND ManagerName = ${dialect.param(1)}`
|
||||
params = [name, managerName.trim()]
|
||||
} else {
|
||||
const placeholder = isSqlServer ? '@p0' : '?'
|
||||
sqlString = `
|
||||
DELETE FROM ${tableName}
|
||||
WHERE MaterialName = ${placeholder}
|
||||
`
|
||||
sqlString = `DELETE FROM ${tableName} WHERE MaterialName = ${dialect.param(0)}`
|
||||
params = [name]
|
||||
}
|
||||
|
||||
@@ -314,49 +295,27 @@ export class MaterialsTypeToBeDeletedDAO {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
const dialect = this.getDialect()
|
||||
|
||||
if (isSqlServer) {
|
||||
const sqlString = `
|
||||
UPDATE ${tableName}
|
||||
SET MaterialName = @p0, ManagerName = @p1
|
||||
WHERE MaterialName = @p2 AND ManagerName = @p3
|
||||
`
|
||||
const result = await trackDuration(
|
||||
async () =>
|
||||
await dbService.query(sqlString, [
|
||||
newName.trim(),
|
||||
newManager.trim(),
|
||||
oldName.trim(),
|
||||
oldManager.trim()
|
||||
]),
|
||||
{
|
||||
operationName: 'MaterialsTypeToBeDeletedDAO.updateMaterial',
|
||||
context: { tableName, operationType: 'UPDATE' }
|
||||
}
|
||||
)
|
||||
return result.result.rowCount > 0
|
||||
} else {
|
||||
const sqlString = `
|
||||
UPDATE ${tableName}
|
||||
SET MaterialName = ?, ManagerName = ?
|
||||
WHERE MaterialName = ? AND ManagerName = ?
|
||||
`
|
||||
const result = await trackDuration(
|
||||
async () =>
|
||||
await dbService.query(sqlString, [
|
||||
newName.trim(),
|
||||
newManager.trim(),
|
||||
oldName.trim(),
|
||||
oldManager.trim()
|
||||
]),
|
||||
{
|
||||
operationName: 'MaterialsTypeToBeDeletedDAO.updateMaterial',
|
||||
context: { tableName, operationType: 'UPDATE' }
|
||||
}
|
||||
)
|
||||
return result.result.rowCount > 0
|
||||
}
|
||||
const sqlString = `
|
||||
UPDATE ${tableName}
|
||||
SET MaterialName = ${dialect.param(0)}, ManagerName = ${dialect.param(1)}
|
||||
WHERE MaterialName = ${dialect.param(2)} AND ManagerName = ${dialect.param(3)}
|
||||
`
|
||||
const result = await trackDuration(
|
||||
async () =>
|
||||
await dbService.query(sqlString, [
|
||||
newName.trim(),
|
||||
newManager.trim(),
|
||||
oldName.trim(),
|
||||
oldManager.trim()
|
||||
]),
|
||||
{
|
||||
operationName: 'MaterialsTypeToBeDeletedDAO.updateMaterial',
|
||||
context: { tableName, operationType: 'UPDATE' }
|
||||
}
|
||||
)
|
||||
return result.result.rowCount > 0
|
||||
} catch (error) {
|
||||
log.error('Update material error', {
|
||||
tableName: this.getTableName(),
|
||||
@@ -456,6 +415,7 @@ export class MaterialsTypeToBeDeletedDAO {
|
||||
if (this.dbService) {
|
||||
await this.dbService.disconnect()
|
||||
this.dbService = null
|
||||
this.dialect = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
641
src/main/services/database/postgresql.ts
Normal file
641
src/main/services/database/postgresql.ts
Normal file
@@ -0,0 +1,641 @@
|
||||
import { Pool } from 'pg'
|
||||
import type {
|
||||
IDatabaseService,
|
||||
DatabaseType,
|
||||
QueryResult,
|
||||
PostgreSqlConfig
|
||||
} from '../../types/database.types'
|
||||
import { createLogger, trackDuration } from '../logger'
|
||||
|
||||
const log = createLogger('PostgreSqlService')
|
||||
|
||||
export type { PostgreSqlConfig } from '../../types/database.types'
|
||||
|
||||
/**
|
||||
* SQL keywords that should NOT be double-quoted during identifier preprocessing.
|
||||
* PostgreSQL lowercases unquoted identifiers, but SSMA-migrated databases
|
||||
* have uppercase column names that require double-quoting to preserve case.
|
||||
*
|
||||
* This list covers PostgreSQL reserved words across multiple categories:
|
||||
* - DML (Data Manipulation Language)
|
||||
* - DDL (Data Definition Language)
|
||||
* - Window functions
|
||||
* - CTEs (Common Table Expressions)
|
||||
* - Advanced GROUP BY clauses
|
||||
* - JSON operations
|
||||
* - Type system
|
||||
* - Table sampling
|
||||
* - Transaction control
|
||||
*/
|
||||
const SQL_KEYWORDS = new Set([
|
||||
// ==================== DML (Data Manipulation Language) ====================
|
||||
'SELECT',
|
||||
'FROM',
|
||||
'WHERE',
|
||||
'AND',
|
||||
'OR',
|
||||
'NOT',
|
||||
'IN',
|
||||
'IS',
|
||||
'NULL',
|
||||
'INSERT',
|
||||
'INTO',
|
||||
'VALUES',
|
||||
'UPDATE',
|
||||
'SET',
|
||||
'DELETE',
|
||||
|
||||
// ==================== Ordering & Limiting ====================
|
||||
'ORDER',
|
||||
'BY',
|
||||
'ASC',
|
||||
'DESC',
|
||||
'LIMIT',
|
||||
'OFFSET',
|
||||
'FETCH',
|
||||
'NEXT',
|
||||
'ROWS',
|
||||
'ONLY',
|
||||
|
||||
// ==================== Joins ====================
|
||||
'JOIN',
|
||||
'LEFT',
|
||||
'RIGHT',
|
||||
'INNER',
|
||||
'OUTER',
|
||||
'CROSS',
|
||||
'FULL',
|
||||
'ON',
|
||||
'NATURAL',
|
||||
'LATERAL',
|
||||
|
||||
// ==================== Set Operations ====================
|
||||
'UNION',
|
||||
'ALL',
|
||||
'INTERSECT',
|
||||
'EXCEPT',
|
||||
|
||||
// ==================== Grouping & Aggregation ====================
|
||||
'GROUP',
|
||||
'HAVING',
|
||||
'DISTINCT',
|
||||
'GROUPING',
|
||||
'SETS',
|
||||
'ROLLUP',
|
||||
'CUBE',
|
||||
'FILTER',
|
||||
'WITHIN',
|
||||
|
||||
// ==================== Window Functions ====================
|
||||
'OVER',
|
||||
'PARTITION',
|
||||
'WINDOW',
|
||||
'RANGE',
|
||||
'UNBOUNDED',
|
||||
'PRECEDING',
|
||||
'FOLLOWING',
|
||||
'CURRENT',
|
||||
'ROW',
|
||||
'GROUPS',
|
||||
'EXCLUDE',
|
||||
'TIES',
|
||||
'RANK',
|
||||
'DENSE_RANK',
|
||||
'ROW_NUMBER',
|
||||
'NTILE',
|
||||
'LAG',
|
||||
'LEAD',
|
||||
'FIRST_VALUE',
|
||||
'LAST_VALUE',
|
||||
'NTH_VALUE',
|
||||
|
||||
// ==================== CTE (Common Table Expressions) ====================
|
||||
'WITH',
|
||||
'RECURSIVE',
|
||||
'MATERIALIZED',
|
||||
'SEARCH',
|
||||
'CYCLE',
|
||||
'PATH',
|
||||
'ROOT',
|
||||
'SIBLINGS',
|
||||
|
||||
// ==================== CASE Expressions ====================
|
||||
'CASE',
|
||||
'WHEN',
|
||||
'THEN',
|
||||
'ELSE',
|
||||
'END',
|
||||
|
||||
// ==================== DDL (Data Definition Language) ====================
|
||||
'CREATE',
|
||||
'ALTER',
|
||||
'DROP',
|
||||
'TABLE',
|
||||
'INDEX',
|
||||
'COLUMN',
|
||||
'ADD',
|
||||
'MODIFY',
|
||||
'RENAME',
|
||||
'TO',
|
||||
'GENERATED',
|
||||
'ALWAYS',
|
||||
'IDENTITY',
|
||||
'INCLUDE',
|
||||
'TEMP',
|
||||
'TEMPORARY',
|
||||
'UNLOGGED',
|
||||
|
||||
// ==================== PostgreSQL Specific - UPSERT/MERGE ====================
|
||||
'CONFLICT',
|
||||
'DO',
|
||||
'NOTHING',
|
||||
'EXCLUDED',
|
||||
'RETURNING',
|
||||
'MERGE',
|
||||
'USING',
|
||||
'MATCHED',
|
||||
'TARGET',
|
||||
'SOURCE',
|
||||
|
||||
// ==================== Aggregate Functions ====================
|
||||
'COUNT',
|
||||
'SUM',
|
||||
'AVG',
|
||||
'MIN',
|
||||
'MAX',
|
||||
'EXISTS',
|
||||
'COALESCE',
|
||||
'NULLIF',
|
||||
'CAST',
|
||||
'AS',
|
||||
|
||||
// ==================== JSON Operations ====================
|
||||
'JSON',
|
||||
'JSONB',
|
||||
'JSON_ARRAY',
|
||||
'JSON_OBJECT',
|
||||
'JSON_AGG',
|
||||
'JSONB_AGG',
|
||||
'JSONB_OBJECT_AGG',
|
||||
|
||||
// ==================== Types & Casting ====================
|
||||
'DECIMAL',
|
||||
'NUMERIC',
|
||||
'BOOLEAN',
|
||||
'CHARACTER',
|
||||
'VARYING',
|
||||
'PRECISION',
|
||||
'REAL',
|
||||
'DOUBLE',
|
||||
'FLOAT',
|
||||
'TEXT',
|
||||
'INTEGER',
|
||||
'SERIAL',
|
||||
'BIGINT',
|
||||
'SMALLINT',
|
||||
'DATE',
|
||||
'TIME',
|
||||
'TIMESTAMP',
|
||||
'TIMESTAMPTZ',
|
||||
'TIMEZONE',
|
||||
'INTERVAL',
|
||||
'BIGSERIAL',
|
||||
'SMALLSERIAL',
|
||||
|
||||
// ==================== Table Sampling ====================
|
||||
'TABLESAMPLE',
|
||||
'BERNOULLI',
|
||||
'SYSTEM',
|
||||
'REPEATABLE',
|
||||
'SEED',
|
||||
|
||||
// ==================== Transaction Control ====================
|
||||
'BEGIN',
|
||||
'COMMIT',
|
||||
'ROLLBACK',
|
||||
'SAVEPOINT',
|
||||
'WORK',
|
||||
'ISOLATION',
|
||||
'LEVEL',
|
||||
'READ',
|
||||
'WRITE',
|
||||
'COMMITTED',
|
||||
'REPEATABLE',
|
||||
'SERIALIZABLE',
|
||||
|
||||
// ==================== Types & Values ====================
|
||||
'TRUE',
|
||||
'FALSE',
|
||||
'DEFAULT',
|
||||
'PRIMARY',
|
||||
'KEY',
|
||||
'REFERENCES',
|
||||
'FOREIGN',
|
||||
'CONSTRAINT',
|
||||
'UNIQUE',
|
||||
'CHECK',
|
||||
'NULLS',
|
||||
'FIRST',
|
||||
'LAST',
|
||||
|
||||
// ==================== Scalar & String Functions ====================
|
||||
'UPPER',
|
||||
'LOWER',
|
||||
'TRIM',
|
||||
'LTRIM',
|
||||
'RTRIM',
|
||||
'BTRIM',
|
||||
'SUBSTRING',
|
||||
'CONCAT',
|
||||
'LENGTH',
|
||||
'CHAR_LENGTH',
|
||||
'CHARACTER_LENGTH',
|
||||
'REPLACE',
|
||||
'POSITION',
|
||||
'OVERLAY',
|
||||
'LPAD',
|
||||
'RPAD',
|
||||
'REPEAT',
|
||||
'REVERSE',
|
||||
'SPLIT_PART',
|
||||
'INITCAP',
|
||||
'NORMALIZE',
|
||||
'CHR',
|
||||
'ASCII',
|
||||
'FORMAT',
|
||||
|
||||
// ==================== Numeric Functions ====================
|
||||
'ABS',
|
||||
'CEIL',
|
||||
'CEILING',
|
||||
'FLOOR',
|
||||
'ROUND',
|
||||
'POWER',
|
||||
'SQRT',
|
||||
'MOD',
|
||||
'SIGN',
|
||||
'TRUNC',
|
||||
|
||||
// ==================== Date/Time Functions ====================
|
||||
'EXTRACT',
|
||||
'DATE_TRUNC',
|
||||
'TO_CHAR',
|
||||
'TO_DATE',
|
||||
'TO_TIMESTAMP',
|
||||
'TO_NUMBER',
|
||||
'AGE',
|
||||
|
||||
// ==================== Pattern Matching ====================
|
||||
'BETWEEN',
|
||||
'LIKE',
|
||||
'ILIKE',
|
||||
'SIMILAR',
|
||||
'ESCAPE',
|
||||
'ANY',
|
||||
'SOME',
|
||||
|
||||
// ==================== Functions & Procedures ====================
|
||||
'AFTER',
|
||||
'BEFORE',
|
||||
'EACH',
|
||||
'STATEMENT',
|
||||
'TRIGGER',
|
||||
'FUNCTION',
|
||||
'PROCEDURE',
|
||||
'LANGUAGE',
|
||||
'SQL',
|
||||
'PLPGSQL',
|
||||
'RETURNS',
|
||||
'CALLED',
|
||||
'STRICT',
|
||||
'SECURITY',
|
||||
'INVOKER',
|
||||
'DEFINER',
|
||||
'VOLATILE',
|
||||
'STABLE',
|
||||
'IMMUTABLE',
|
||||
'PARALLEL',
|
||||
'SAFE',
|
||||
'RESTRICTED',
|
||||
'UNSAFE',
|
||||
|
||||
// ==================== Utility Commands ====================
|
||||
'CONCURRENTLY',
|
||||
'REINDEX',
|
||||
'VACUUM',
|
||||
'ANALYZE',
|
||||
'EXPLAIN',
|
||||
'LOCAL',
|
||||
'GLOBAL',
|
||||
'ORDINALITY',
|
||||
'FREEZE',
|
||||
'VERBOSE',
|
||||
'BUFFERS',
|
||||
'FORMAT',
|
||||
'XML',
|
||||
'YAML',
|
||||
|
||||
// ==================== Additional Reserved Words ====================
|
||||
'IF',
|
||||
'CURRENT_TIMESTAMP',
|
||||
'NOW',
|
||||
'GETDATE'
|
||||
])
|
||||
|
||||
/**
|
||||
* Prepare SQL for PostgreSQL execution by quoting unquoted identifiers.
|
||||
*
|
||||
* PostgreSQL lowercases unquoted identifiers, but SSMA-migrated tables
|
||||
* have uppercase column names (e.g., "UserName", "ID") that require
|
||||
* double-quoting to preserve case.
|
||||
*
|
||||
* This function:
|
||||
* - Preserves string literals ('...')
|
||||
* - Preserves already-quoted identifiers ("...")
|
||||
* - Preserves parameter placeholders ($1, $2, ...)
|
||||
* - Preserves SQL keywords
|
||||
* - Double-quotes remaining identifiers
|
||||
*/
|
||||
export function prepareSql(sql: string): string {
|
||||
const result: string[] = []
|
||||
let i = 0
|
||||
const len = sql.length
|
||||
|
||||
while (i < len) {
|
||||
const ch = sql[i]
|
||||
|
||||
// Skip whitespace
|
||||
if (/\s/.test(ch)) {
|
||||
result.push(ch)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip single-line comments (--)
|
||||
if (ch === '-' && i + 1 < len && sql[i + 1] === '-') {
|
||||
while (i < len && sql[i] !== '\n') {
|
||||
result.push(sql[i++])
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Preserve string literals ('...')
|
||||
if (ch === "'") {
|
||||
result.push(ch)
|
||||
i++
|
||||
while (i < len) {
|
||||
if (sql[i] === "'") {
|
||||
result.push(sql[i++])
|
||||
// Handle escaped quotes ('')
|
||||
if (i < len && sql[i] === "'") {
|
||||
result.push(sql[i++])
|
||||
} else {
|
||||
break
|
||||
}
|
||||
} else {
|
||||
result.push(sql[i++])
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Preserve already-quoted identifiers ("...")
|
||||
if (ch === '"') {
|
||||
result.push(ch)
|
||||
i++
|
||||
while (i < len && sql[i] !== '"') {
|
||||
result.push(sql[i++])
|
||||
}
|
||||
if (i < len) {
|
||||
result.push(sql[i++])
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Preserve parameter placeholders ($N)
|
||||
if (ch === '$') {
|
||||
result.push(ch)
|
||||
i++
|
||||
while (i < len && /\d/.test(sql[i])) {
|
||||
result.push(sql[i++])
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Preserve @param placeholders
|
||||
if (ch === '@') {
|
||||
result.push(ch)
|
||||
i++
|
||||
while (i < len && /\w/.test(sql[i])) {
|
||||
result.push(sql[i++])
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Preserve ? placeholders
|
||||
if (ch === '?') {
|
||||
result.push(ch)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// Collect word tokens (identifiers and keywords)
|
||||
if (/[a-zA-Z_]/.test(ch)) {
|
||||
let word = ''
|
||||
while (i < len && /\w/.test(sql[i])) {
|
||||
word += sql[i++]
|
||||
}
|
||||
|
||||
// Check if it's a SQL keyword (case-insensitive)
|
||||
if (SQL_KEYWORDS.has(word.toUpperCase())) {
|
||||
result.push(word)
|
||||
} else {
|
||||
// Quote the identifier to preserve case
|
||||
result.push(`"${word}"`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Everything else (operators, punctuation, numbers): pass through
|
||||
result.push(ch)
|
||||
i++
|
||||
}
|
||||
|
||||
return result.join('')
|
||||
}
|
||||
|
||||
export class PostgreSqlService implements IDatabaseService {
|
||||
/** Database type identifier */
|
||||
readonly type: DatabaseType = 'postgresql'
|
||||
|
||||
private pool: Pool | null = null
|
||||
private config: PostgreSqlConfig
|
||||
|
||||
constructor(config: PostgreSqlConfig) {
|
||||
this.config = config
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to PostgreSQL database
|
||||
*/
|
||||
async connect(): Promise<void> {
|
||||
if (this.pool) {
|
||||
log.warn('Already connected to PostgreSQL')
|
||||
throw new Error('Already connected to PostgreSQL')
|
||||
}
|
||||
|
||||
try {
|
||||
this.pool = new Pool({
|
||||
host: this.config.host,
|
||||
port: this.config.port,
|
||||
user: this.config.user,
|
||||
password: this.config.password,
|
||||
database: this.config.database,
|
||||
max: this.config.maxPoolSize ?? 10,
|
||||
/**
|
||||
* Connection timeout in milliseconds.
|
||||
* Time to wait when connecting to PostgreSQL before failing.
|
||||
* Prevents hanging during network issues or server overload.
|
||||
*/
|
||||
connectionTimeoutMillis: 10000,
|
||||
/**
|
||||
* PostgreSQL statement timeout in milliseconds.
|
||||
* Limits execution time for individual SQL statements.
|
||||
* Prevents long-running queries from blocking the connection pool.
|
||||
*/
|
||||
statement_timeout: 30000,
|
||||
/**
|
||||
* Idle connection timeout in milliseconds.
|
||||
* Closes connections that have been idle for this duration.
|
||||
* Frees up pool resources and prevents stale connections.
|
||||
*/
|
||||
idleTimeoutMillis: 30000,
|
||||
/**
|
||||
* Query timeout in milliseconds (pg driver level).
|
||||
* Fallback protection to abort queries that exceed this duration.
|
||||
* Should be longer than statement_timeout to allow PG to handle first.
|
||||
*/
|
||||
query_timeout: 60000
|
||||
})
|
||||
|
||||
// Test connection
|
||||
const client = await this.pool.connect()
|
||||
client.release()
|
||||
|
||||
log.info('Connected to PostgreSQL', {
|
||||
host: this.config.host,
|
||||
port: this.config.port,
|
||||
database: this.config.database
|
||||
})
|
||||
} catch (error) {
|
||||
this.pool = null
|
||||
log.error('Failed to connect to PostgreSQL', {
|
||||
host: this.config.host,
|
||||
port: this.config.port,
|
||||
database: this.config.database,
|
||||
error
|
||||
})
|
||||
throw new Error(`Failed to connect to PostgreSQL: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from PostgreSQL database
|
||||
*/
|
||||
async disconnect(): Promise<void> {
|
||||
if (!this.pool) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await this.pool.end()
|
||||
this.pool = null
|
||||
log.info('Disconnected from PostgreSQL')
|
||||
} catch (error) {
|
||||
log.error('Failed to disconnect from PostgreSQL', { error })
|
||||
throw new Error(`Failed to disconnect from PostgreSQL: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if connected to PostgreSQL database
|
||||
*/
|
||||
isConnected(): boolean {
|
||||
return this.pool !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a query and return results
|
||||
*/
|
||||
async query(sql: string, params?: any[]): Promise<QueryResult> {
|
||||
if (!this.pool) {
|
||||
throw new Error('Not connected to PostgreSQL. Call connect() first.')
|
||||
}
|
||||
|
||||
// Quote unquoted identifiers to preserve case for SSMA-migrated columns
|
||||
const preparedSql = prepareSql(sql)
|
||||
const sqlPreview = preparedSql.substring(0, 100)
|
||||
const paramCount = params?.length ?? 0
|
||||
|
||||
try {
|
||||
const { result: queryResult } = await trackDuration(
|
||||
async () => {
|
||||
const result = await this.pool!.query(preparedSql, params)
|
||||
|
||||
// Extract column names from fields
|
||||
const columns = result.fields ? result.fields.map((field) => field.name) : []
|
||||
|
||||
// Result rows
|
||||
const rows = (result.rows as Record<string, unknown>[]) || []
|
||||
const rowCount = result.rowCount ?? rows.length
|
||||
|
||||
return { rows, columns, rowCount }
|
||||
},
|
||||
{ operationName: 'PostgreSqlService.query' }
|
||||
)
|
||||
|
||||
log.debug('Query executed', { sqlPreview, rowCount: queryResult.rowCount, paramCount })
|
||||
return queryResult
|
||||
} catch (error) {
|
||||
log.error('PostgreSQL query failed', { sqlPreview, paramCount, error })
|
||||
throw new Error(`PostgreSQL query failed: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute multiple queries in a transaction
|
||||
*/
|
||||
async transaction(queries: { sql: string; params?: any[] }[]): Promise<void> {
|
||||
if (!this.pool) {
|
||||
throw new Error('Not connected to PostgreSQL. Call connect() first.')
|
||||
}
|
||||
|
||||
const queryCount = queries.length
|
||||
log.info('Transaction started', { queryCount })
|
||||
|
||||
const client = await this.pool.connect()
|
||||
|
||||
try {
|
||||
await client.query('BEGIN')
|
||||
|
||||
for (let i = 0; i < queries.length; i++) {
|
||||
const { sql, params } = queries[i]
|
||||
const preparedSql = prepareSql(sql)
|
||||
await client.query(preparedSql, params)
|
||||
log.debug('Transaction query executed', {
|
||||
index: i,
|
||||
sqlPreview: preparedSql.substring(0, 100)
|
||||
})
|
||||
}
|
||||
|
||||
await client.query('COMMIT')
|
||||
log.info('Transaction committed', { queryCount })
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK')
|
||||
log.warn('Transaction rolled back', { queryCount, error })
|
||||
throw new Error(`PostgreSQL transaction failed: ${(error as Error).message}`)
|
||||
} finally {
|
||||
client.release()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
import { ERP_LOCATORS } from './locators'
|
||||
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 { FrameLocator, Locator, Page } from 'playwright'
|
||||
import { createLogger, run, trackDuration } from '../logger'
|
||||
@@ -20,8 +26,12 @@ interface RetryResult {
|
||||
}
|
||||
|
||||
interface ProgressState {
|
||||
completedOrders: number
|
||||
ordersStarted: number // 开始处理的订单数
|
||||
ordersCompleted: number // 已完成订单数
|
||||
totalOrders: number
|
||||
progressText?: string // "ordersCompleted/totalOrders (xx%)"
|
||||
lastCompletedOrder?: string // 最后完成的订单号
|
||||
lastActivityTime?: number // 最后活动时间戳(健康检查用)
|
||||
}
|
||||
|
||||
interface QueryResultRow {
|
||||
@@ -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
|
||||
*/
|
||||
@@ -190,7 +242,9 @@ export class CleanerService {
|
||||
errors: [],
|
||||
details: [],
|
||||
retriedOrders: 0,
|
||||
successfulRetries: 0
|
||||
successfulRetries: 0,
|
||||
materialsFailed: 0,
|
||||
uncertainDeletions: 0
|
||||
}
|
||||
|
||||
const totalOrders = input.orderNumbers.length
|
||||
@@ -234,7 +288,8 @@ export class CleanerService {
|
||||
const orderBatches = createBatches(input.orderNumbers, queryBatchSize)
|
||||
const popupMutex = new AsyncMutex()
|
||||
const progressState: ProgressState = {
|
||||
completedOrders: 0,
|
||||
ordersStarted: 0,
|
||||
ordersCompleted: 0,
|
||||
totalOrders
|
||||
}
|
||||
|
||||
@@ -249,56 +304,131 @@ export class CleanerService {
|
||||
totalMaterials
|
||||
})
|
||||
|
||||
// [新增] 健康检查定时器 - 检测长时间无进展
|
||||
let lastActivityTime = Date.now()
|
||||
progressState.lastActivityTime = lastActivityTime
|
||||
const healthCheckInterval = setInterval(() => {
|
||||
const secondsSinceLastActivity = (Date.now() - lastActivityTime) / 1000
|
||||
|
||||
if (secondsSinceLastActivity > 60) {
|
||||
log.warn('[HEALTH_CHECK] 长时间无进展', {
|
||||
ordersCompleted: progressState.ordersCompleted,
|
||||
totalOrders: progressState.totalOrders,
|
||||
lastCompletedOrder: progressState.lastCompletedOrder,
|
||||
noProgressSeconds: secondsSinceLastActivity,
|
||||
suspectedStuck: secondsSinceLastActivity > 180,
|
||||
healthStatus: secondsSinceLastActivity > 180 ? 'critical' : 'warning'
|
||||
})
|
||||
}
|
||||
}, 30000) // 每 30 秒检查一次
|
||||
|
||||
// [新增] 为每个 batch 创建并发追踪器
|
||||
const tracker = new ConcurrencyTracker()
|
||||
|
||||
// Track batch processing duration with 5s slow threshold
|
||||
await trackDuration(
|
||||
async () => {
|
||||
await this.queryOrders(workFrame, batchOrders)
|
||||
await this.waitForLoading(workFrame)
|
||||
|
||||
const queriedRows = await this.collectQueryResultRows(workFrame)
|
||||
const queriedOrderNumbersInBatch = new Set(queriedRows.map((row) => row.orderNumber))
|
||||
|
||||
await runWithConcurrency(queriedRows, processConcurrency, async (row) => {
|
||||
const { rowIndex, orderNumber } = row
|
||||
const openedDetailPage = await popupMutex.runExclusive(async () => {
|
||||
return await this.openDetailPageFromRow(workFrame, popupPage!, rowIndex)
|
||||
})
|
||||
|
||||
let detail: OrderCleanDetail
|
||||
try {
|
||||
detail = await this.processDetailPage({
|
||||
detailPage: openedDetailPage,
|
||||
deleteSet,
|
||||
dryRun,
|
||||
expectedOrderNumber: orderNumber,
|
||||
progressState,
|
||||
onProgress: input.onProgress
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
detail = this.createErrorDetail(orderNumber, message)
|
||||
} finally {
|
||||
progressState.completedOrders += 1
|
||||
}
|
||||
|
||||
result.details.push(detail)
|
||||
|
||||
if (detail.errors.length > 0) {
|
||||
result.errors.push(`Order ${detail.orderNumber}: ${detail.errors.join('; ')}`)
|
||||
return
|
||||
}
|
||||
|
||||
result.ordersProcessed += 1
|
||||
result.materialsDeleted += detail.materialsDeleted
|
||||
result.materialsSkipped += detail.materialsSkipped
|
||||
// Phase 1: Query orders
|
||||
await trackDuration(async () => await this.queryOrders(workFrame, batchOrders), {
|
||||
operationName: 'query',
|
||||
message: '执行订单查询',
|
||||
slowThresholdMs: 3000,
|
||||
context: { orderCount: batchOrders.length }
|
||||
})
|
||||
|
||||
const missingOrders = getMissingOrders(batchOrders, queriedOrderNumbersInBatch)
|
||||
for (const missingOrder of missingOrders) {
|
||||
const missingMessage = '订单未出现在查询结果中'
|
||||
result.errors.push(`Order ${missingOrder}: ${missingMessage}`)
|
||||
result.details.push(this.createErrorDetail(missingOrder, missingMessage))
|
||||
}
|
||||
// Phase 2: Wait for loading complete
|
||||
await trackDuration(async () => await this.waitForLoading(workFrame), {
|
||||
operationName: 'wait_loading',
|
||||
message: '等待加载完成',
|
||||
slowThresholdMs: 5000
|
||||
})
|
||||
|
||||
// Phase 3: Collect query results
|
||||
const collectResult = await trackDuration(
|
||||
async () => await this.collectQueryResultRows(workFrame),
|
||||
{
|
||||
operationName: 'collect_results',
|
||||
message: '收集查询结果',
|
||||
slowThresholdMs: 2000
|
||||
}
|
||||
)
|
||||
const queriedRows = collectResult.result
|
||||
const queriedOrderNumbersInBatch = new Set(queriedRows.map((row) => row.orderNumber))
|
||||
|
||||
// Phase 4: Process all orders in batch
|
||||
await trackDuration(
|
||||
async () => {
|
||||
await runWithConcurrency(queriedRows, processConcurrency, async (row) => {
|
||||
const { rowIndex, orderNumber } = row
|
||||
|
||||
// [新增] Worker 开始追踪
|
||||
tracker.workerStarted()
|
||||
|
||||
try {
|
||||
const openedDetailPage = await popupMutex.runExclusive(async () => {
|
||||
// [新增] Mutex 等待追踪
|
||||
tracker.waitingForMutex()
|
||||
const page = await this.openDetailPageFromRow(workFrame, popupPage!, rowIndex)
|
||||
// [新增] Mutex 获取追踪
|
||||
tracker.acquiredMutex()
|
||||
return page
|
||||
})
|
||||
|
||||
let detail: OrderCleanDetail
|
||||
try {
|
||||
detail = await this.processDetailPage({
|
||||
detailPage: openedDetailPage,
|
||||
deleteSet,
|
||||
dryRun,
|
||||
expectedOrderNumber: orderNumber,
|
||||
progressState,
|
||||
onProgress: input.onProgress
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
detail = this.createErrorDetail(orderNumber, message)
|
||||
} finally {
|
||||
progressState.ordersStarted += 1
|
||||
progressState.ordersCompleted += 1
|
||||
progressState.lastCompletedOrder = orderNumber
|
||||
lastActivityTime = Date.now() // [新增] 健康检查:更新活动时间
|
||||
}
|
||||
|
||||
result.details.push(detail)
|
||||
|
||||
if (detail.errors.length > 0) {
|
||||
result.errors.push(`Order ${detail.orderNumber}: ${detail.errors.join('; ')}`)
|
||||
return
|
||||
}
|
||||
|
||||
result.ordersProcessed += 1
|
||||
result.materialsDeleted += detail.materialsDeleted
|
||||
result.materialsSkipped += detail.materialsSkipped
|
||||
result.materialsFailed += detail.materialsFailed
|
||||
result.uncertainDeletions += detail.uncertainDeletions
|
||||
} finally {
|
||||
// [新增] Worker 完成追踪
|
||||
tracker.workerCompleted()
|
||||
}
|
||||
})
|
||||
|
||||
// Handle missing orders
|
||||
const missingOrders = getMissingOrders(batchOrders, queriedOrderNumbersInBatch)
|
||||
for (const missingOrder of missingOrders) {
|
||||
const missingMessage = '订单未出现在查询结果中'
|
||||
result.errors.push(`Order ${missingOrder}: ${missingMessage}`)
|
||||
result.details.push(this.createErrorDetail(missingOrder, missingMessage, true))
|
||||
}
|
||||
},
|
||||
{
|
||||
operationName: 'process_all_orders_in_batch',
|
||||
message: `处理批次中所有${queriedRows.length}个订单`,
|
||||
slowThresholdMs: 10000
|
||||
}
|
||||
)
|
||||
|
||||
// Phase 5: Save batch results (already included in process_all_orders_in_batch)
|
||||
// No separate save step needed as results are accumulated in result object
|
||||
},
|
||||
{
|
||||
operationName: `batch-${batchIndex + 1}-${orderBatches[batchIndex].length}-orders`,
|
||||
@@ -313,6 +443,9 @@ export class CleanerService {
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// [新增] 清理健康检查定时器
|
||||
clearInterval(healthCheckInterval)
|
||||
}
|
||||
|
||||
const retryResult = await this.retryFailedOrders({
|
||||
@@ -337,6 +470,8 @@ export class CleanerService {
|
||||
result.ordersProcessed += 1
|
||||
result.materialsDeleted += updatedDetail.materialsDeleted
|
||||
result.materialsSkipped += updatedDetail.materialsSkipped
|
||||
result.materialsFailed += updatedDetail.materialsFailed
|
||||
result.uncertainDeletions += updatedDetail.uncertainDeletions
|
||||
}
|
||||
result.details[index] = updatedDetail
|
||||
}
|
||||
@@ -353,6 +488,8 @@ export class CleanerService {
|
||||
ordersProcessed: result.ordersProcessed,
|
||||
materialsDeleted: result.materialsDeleted,
|
||||
materialsSkipped: result.materialsSkipped,
|
||||
materialsFailed: result.materialsFailed,
|
||||
uncertainDeletions: result.uncertainDeletions,
|
||||
errorCount: result.errors.length,
|
||||
totalOrders,
|
||||
totalMaterials,
|
||||
@@ -367,9 +504,19 @@ export class CleanerService {
|
||||
dryRun,
|
||||
orderNumbers: input.orderNumbers,
|
||||
materialCodes: input.materialCodes,
|
||||
...(popupPage ? await capturePageContext(popupPage, undefined, 'cleaner.outerCatch') : {})
|
||||
...(popupPage
|
||||
? await capturePageContext(
|
||||
popupPage,
|
||||
undefined,
|
||||
'cleaner.outerCatch',
|
||||
undefined,
|
||||
undefined,
|
||||
'outer_catch'
|
||||
)
|
||||
: {})
|
||||
})
|
||||
result.errors.push(`Clean failed: ${message}`)
|
||||
result.crashed = true
|
||||
} finally {
|
||||
if (popupPage) {
|
||||
try {
|
||||
@@ -423,7 +570,14 @@ export class CleanerService {
|
||||
log.error('[导航失败] forwardFrame 为空', {
|
||||
elapsedMs: Date.now() - navStartTime,
|
||||
pageUrl: popupPage.url(),
|
||||
contextData: await capturePageContext(popupPage, undefined, 'nav.forwardFrame')
|
||||
contextData: await capturePageContext(
|
||||
popupPage,
|
||||
undefined,
|
||||
'nav.forwardFrame',
|
||||
undefined,
|
||||
undefined,
|
||||
'nav_forward_frame'
|
||||
)
|
||||
})
|
||||
throw new Error('无法访问弹出窗口的 forwardFrame')
|
||||
}
|
||||
@@ -442,7 +596,14 @@ export class CleanerService {
|
||||
log.error('[导航失败] workFrame 为空', {
|
||||
elapsedMs: Date.now() - navStartTime,
|
||||
pageUrl: popupPage.url(),
|
||||
contextData: await capturePageContext(popupPage, undefined, 'nav.workFrame')
|
||||
contextData: await capturePageContext(
|
||||
popupPage,
|
||||
undefined,
|
||||
'nav.workFrame',
|
||||
undefined,
|
||||
undefined,
|
||||
'nav_work_frame'
|
||||
)
|
||||
})
|
||||
throw new Error('无法访问内部工作框架')
|
||||
}
|
||||
@@ -665,14 +826,40 @@ export class CleanerService {
|
||||
const { detailPage, deleteSet, dryRun, progressState, expectedOrderNumber, onProgress } = params
|
||||
const processStartTime = Date.now()
|
||||
|
||||
log.info('[ORDER_START] 开始处理订单', {
|
||||
orderIndex: progressState.ordersStarted,
|
||||
orderPosition: `${progressState.ordersStarted + 1}/${progressState.totalOrders}`,
|
||||
orderNumber: expectedOrderNumber,
|
||||
phase: 'starting',
|
||||
elapsedMs: Date.now() - processStartTime
|
||||
})
|
||||
|
||||
log.info('[订单详情处理开始]', {
|
||||
expectedOrderNumber,
|
||||
dryRun,
|
||||
deleteSetSize: deleteSet.size,
|
||||
completedOrders: progressState.completedOrders,
|
||||
ordersStarted: progressState.ordersStarted,
|
||||
ordersCompleted: progressState.ordersCompleted,
|
||||
totalOrders: progressState.totalOrders
|
||||
})
|
||||
|
||||
let detailCount = 0
|
||||
const detail: OrderCleanDetail = {
|
||||
orderNumber: expectedOrderNumber || 'UNKNOWN',
|
||||
materialsDeleted: 0,
|
||||
materialsSkipped: 0,
|
||||
errors: [],
|
||||
skippedMaterials: [],
|
||||
deletedMaterials: [],
|
||||
retryCount: 0,
|
||||
retryAttempts: [],
|
||||
retriedAt: undefined,
|
||||
retrySuccess: false,
|
||||
materialsFailed: 0,
|
||||
failedMaterials: [],
|
||||
uncertainDeletions: 0
|
||||
}
|
||||
|
||||
try {
|
||||
// Step 1: Access forward frame
|
||||
log.debug('[详情页面 Step 1] 准备访问 forwardFrame')
|
||||
@@ -684,7 +871,14 @@ export class CleanerService {
|
||||
log.error('[详情页面失败] forwardFrame 访问失败', {
|
||||
elapsedMs: Date.now() - processStartTime,
|
||||
pageUrl: detailPage.url(),
|
||||
contextData: await capturePageContext(detailPage)
|
||||
contextData: await capturePageContext(
|
||||
detailPage,
|
||||
undefined,
|
||||
'processDetail.forwardFrame',
|
||||
expectedOrderNumber,
|
||||
undefined,
|
||||
'process_detail_forward_frame'
|
||||
)
|
||||
})
|
||||
throw new Error(errorMsg)
|
||||
}
|
||||
@@ -706,7 +900,10 @@ export class CleanerService {
|
||||
contextData: await capturePageContext(
|
||||
detailPage,
|
||||
undefined,
|
||||
'processDetail.detailInnerFrame'
|
||||
'processDetail.detailInnerFrame',
|
||||
expectedOrderNumber,
|
||||
undefined,
|
||||
'process_detail_inner_frame'
|
||||
)
|
||||
})
|
||||
throw new Error(errorMsg)
|
||||
@@ -737,17 +934,8 @@ export class CleanerService {
|
||||
usedFallback: !sourceOrderNumber && !!expectedOrderNumber
|
||||
})
|
||||
|
||||
const detail: OrderCleanDetail = {
|
||||
orderNumber,
|
||||
materialsDeleted: 0,
|
||||
materialsSkipped: 0,
|
||||
errors: [],
|
||||
skippedMaterials: [],
|
||||
retryCount: 0,
|
||||
retryAttempts: [],
|
||||
retriedAt: undefined,
|
||||
retrySuccess: false
|
||||
}
|
||||
// 更新 orderNumber 为实际提取的值或 fallback
|
||||
detail.orderNumber = orderNumber
|
||||
|
||||
// Step 5: Get material counts and status
|
||||
log.debug('[详情页面 Step 5] 读取物料数量和状态')
|
||||
@@ -769,13 +957,13 @@ export class CleanerService {
|
||||
onProgress?.(
|
||||
`开始处理订单:${orderNumber}`,
|
||||
this.calculateProgress(
|
||||
progressState.completedOrders,
|
||||
progressState.ordersStarted,
|
||||
0,
|
||||
detailCount,
|
||||
progressState.totalOrders
|
||||
),
|
||||
{
|
||||
currentOrderIndex: progressState.completedOrders + 1,
|
||||
currentOrderIndex: progressState.ordersStarted + 1,
|
||||
totalOrders: progressState.totalOrders,
|
||||
currentMaterialIndex: 0,
|
||||
totalMaterialsInOrder: detailCount,
|
||||
@@ -842,7 +1030,7 @@ export class CleanerService {
|
||||
const pendingQty = await this.getInputValue(childForm, /^累计待发数量$/)
|
||||
|
||||
const progress = this.calculateProgress(
|
||||
progressState.completedOrders,
|
||||
progressState.ordersStarted,
|
||||
materialIdx,
|
||||
detailCount,
|
||||
progressState.totalOrders
|
||||
@@ -852,7 +1040,7 @@ export class CleanerService {
|
||||
`订单 ${orderNumber} - 物料 ${materialIdx}/${detailCount}: ${materialName}`,
|
||||
progress,
|
||||
{
|
||||
currentOrderIndex: progressState.completedOrders + 1,
|
||||
currentOrderIndex: progressState.ordersStarted + 1,
|
||||
totalOrders: progressState.totalOrders,
|
||||
currentMaterialIndex: materialIdx,
|
||||
totalMaterialsInOrder: detailCount,
|
||||
@@ -879,33 +1067,67 @@ export class CleanerService {
|
||||
})
|
||||
|
||||
if (shouldDelete && !dryRun) {
|
||||
log.info('[物料操作] 执行删除操作', {
|
||||
log.info('[物料操作] 执行删除操作(多信号验证)', {
|
||||
orderNumber,
|
||||
materialIdx,
|
||||
materialCode,
|
||||
materialName,
|
||||
rowNumber: currentRow,
|
||||
materialCount: detailCount,
|
||||
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
|
||||
|
||||
if (deleteSuccess) {
|
||||
if (
|
||||
deleteResult.outcome === DeletionOutcome.Success ||
|
||||
deleteResult.outcome === DeletionOutcome.Uncertain
|
||||
) {
|
||||
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,
|
||||
materialCode,
|
||||
rowNumber: currentRow,
|
||||
outcome: deleteResult.outcome,
|
||||
attempts: deleteResult.attempts.length,
|
||||
elapsedMs: deleteElapsed
|
||||
})
|
||||
} 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,
|
||||
materialCode,
|
||||
rowNumber: currentRow,
|
||||
outcome: deleteResult.outcome,
|
||||
errorCategory: deleteResult.errorCategory,
|
||||
errorMessage: deleteResult.errorMessage,
|
||||
attempts: deleteResult.attempts.length,
|
||||
elapsedMs: deleteElapsed
|
||||
})
|
||||
}
|
||||
@@ -937,11 +1159,20 @@ export class CleanerService {
|
||||
})
|
||||
}
|
||||
} else {
|
||||
log.verbose('[物料判断] 物料不在删除清单中,跳过', {
|
||||
// 物料不在删除清单中,记录为 skipped
|
||||
detail.materialsSkipped += 1
|
||||
detail.skippedMaterials.push({
|
||||
materialCode,
|
||||
materialName,
|
||||
rowNumber: rowNumInt,
|
||||
reason: '不在删除清单中'
|
||||
})
|
||||
log.verbose('[物料跳过] 物料不在删除清单中', {
|
||||
orderNumber,
|
||||
materialIdx,
|
||||
materialCode,
|
||||
materialName
|
||||
materialName,
|
||||
rowNumber: rowNumInt
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1016,10 +1247,31 @@ export class CleanerService {
|
||||
orderNumber: expectedOrderNumber || 'UNKNOWN',
|
||||
error: message,
|
||||
elapsedMs: Date.now() - processStartTime,
|
||||
contextData: await capturePageContext(detailPage, undefined, 'processDetail.error')
|
||||
contextData: await capturePageContext(
|
||||
detailPage,
|
||||
undefined,
|
||||
'processDetail.error',
|
||||
expectedOrderNumber,
|
||||
undefined,
|
||||
'process_detail_error'
|
||||
)
|
||||
})
|
||||
throw error
|
||||
} finally {
|
||||
const totalOrderTime = Date.now() - processStartTime
|
||||
log.info('[ORDER_COMPLETE] 订单处理完成', {
|
||||
orderIndex: progressState.ordersStarted,
|
||||
orderPosition: `${progressState.ordersStarted + 1}/${progressState.totalOrders}`,
|
||||
ordersCompleted: progressState.ordersCompleted,
|
||||
orderNumber: expectedOrderNumber || 'UNKNOWN',
|
||||
totalMaterials: detailCount,
|
||||
deleted: detail?.materialsDeleted ?? 0,
|
||||
skipped: detail?.materialsSkipped ?? 0,
|
||||
failed: detail?.materialsFailed ?? 0,
|
||||
totalOrderTimeMs: totalOrderTime,
|
||||
isSlow: totalOrderTime > 30000
|
||||
})
|
||||
|
||||
log.debug('[详情页面清理] 准备关闭详情页面', { pageUrl: detailPage.url() })
|
||||
await detailPage.close()
|
||||
log.debug('[详情页面清理完成] 详情页已关闭')
|
||||
@@ -1053,17 +1305,26 @@ export class CleanerService {
|
||||
return /^SC\d{14}$/.test(value)
|
||||
}
|
||||
|
||||
private createErrorDetail(orderNumber: string, message: string): OrderCleanDetail {
|
||||
private createErrorDetail(
|
||||
orderNumber: string,
|
||||
message: string,
|
||||
notFound: boolean = false
|
||||
): OrderCleanDetail {
|
||||
return {
|
||||
orderNumber,
|
||||
materialsDeleted: 0,
|
||||
materialsSkipped: 0,
|
||||
errors: [message],
|
||||
skippedMaterials: [],
|
||||
deletedMaterials: [],
|
||||
retryCount: 0,
|
||||
retryAttempts: [],
|
||||
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(
|
||||
childForm: FrameLocator | Locator,
|
||||
oldRowNumber: string,
|
||||
@@ -1242,7 +1783,8 @@ export class CleanerService {
|
||||
dryRun,
|
||||
expectedOrderNumber: orderNumber,
|
||||
progressState: {
|
||||
completedOrders: detailIndex,
|
||||
ordersStarted: detailIndex,
|
||||
ordersCompleted: 0,
|
||||
totalOrders: failedDetails.length
|
||||
},
|
||||
onProgress: (message, progress, extra) => {
|
||||
|
||||
@@ -82,7 +82,14 @@ export class ErpAuthService {
|
||||
|
||||
if (!contentFrame) {
|
||||
log.error('Failed to access forwardFrame content frame', {
|
||||
...(await capturePageContext(page))
|
||||
...(await capturePageContext(
|
||||
page,
|
||||
undefined,
|
||||
'auth.forwardFrame',
|
||||
undefined,
|
||||
undefined,
|
||||
'auth_forward_frame'
|
||||
))
|
||||
})
|
||||
throw new Error('Failed to access forwardFrame content frame')
|
||||
}
|
||||
@@ -96,7 +103,14 @@ export class ErpAuthService {
|
||||
} catch (e) {
|
||||
log.error('Failed to find username input', {
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
...(await capturePageContext(page, undefined, 'login.username'))
|
||||
...(await capturePageContext(
|
||||
page,
|
||||
undefined,
|
||||
'login.username',
|
||||
undefined,
|
||||
undefined,
|
||||
'login_username'
|
||||
))
|
||||
})
|
||||
throw new Error(`Failed to find username input: ${e}`)
|
||||
}
|
||||
@@ -107,7 +121,14 @@ export class ErpAuthService {
|
||||
} catch (e) {
|
||||
log.error('Failed to find password input', {
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
...(await capturePageContext(page, undefined, 'login.password'))
|
||||
...(await capturePageContext(
|
||||
page,
|
||||
undefined,
|
||||
'login.password',
|
||||
undefined,
|
||||
undefined,
|
||||
'login_password'
|
||||
))
|
||||
})
|
||||
throw new Error(`Failed to find password input: ${e}`)
|
||||
}
|
||||
@@ -118,7 +139,14 @@ export class ErpAuthService {
|
||||
} catch (e) {
|
||||
log.error('Failed to click login button', {
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
...(await capturePageContext(page, undefined, 'login.button'))
|
||||
...(await capturePageContext(
|
||||
page,
|
||||
undefined,
|
||||
'login.button',
|
||||
undefined,
|
||||
undefined,
|
||||
'login_button'
|
||||
))
|
||||
})
|
||||
throw new Error(`Failed to click login button: ${e}`)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,9 @@ export interface ErpErrorContext {
|
||||
targetSelector?: string
|
||||
step?: string
|
||||
screenshotPath?: string
|
||||
orderId?: string
|
||||
materialCode?: string
|
||||
errorStage?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -69,11 +72,18 @@ async function captureScreenshot(page: Page, step?: string): Promise<string | un
|
||||
*
|
||||
* @param page - The Playwright page to inspect
|
||||
* @param targetSelector - Optional selector that was being targeted
|
||||
* @param step - Optional step name for context
|
||||
* @param orderId - Optional order ID for error correlation
|
||||
* @param materialCode - Optional material code for error correlation
|
||||
* @param stage - Optional stage name (defaults to 'unknown')
|
||||
*/
|
||||
export async function capturePageContext(
|
||||
page: Page,
|
||||
targetSelector?: string,
|
||||
step?: string
|
||||
step?: string,
|
||||
orderId?: string,
|
||||
materialCode?: string,
|
||||
stage: string = 'unknown'
|
||||
): Promise<ErpErrorContext> {
|
||||
const ctx: ErpErrorContext = {}
|
||||
|
||||
@@ -98,6 +108,16 @@ export async function capturePageContext(
|
||||
ctx.step = step
|
||||
}
|
||||
|
||||
if (orderId) {
|
||||
ctx.orderId = orderId
|
||||
}
|
||||
|
||||
if (materialCode) {
|
||||
ctx.materialCode = materialCode
|
||||
}
|
||||
|
||||
ctx.errorStage = stage
|
||||
|
||||
ctx.screenshotPath = await captureScreenshot(page, step)
|
||||
|
||||
return ctx
|
||||
|
||||
@@ -116,7 +116,14 @@ export class ExtractorCore {
|
||||
|
||||
if (!fFrame) {
|
||||
log.error('Failed to access popup forward frame', {
|
||||
...(await capturePageContext(popupPage, undefined, 'navigate.forwardFrame'))
|
||||
...(await capturePageContext(
|
||||
popupPage,
|
||||
undefined,
|
||||
'navigate.forwardFrame',
|
||||
undefined,
|
||||
undefined,
|
||||
'navigate_forward_frame'
|
||||
))
|
||||
})
|
||||
throw new Error('Failed to access popup forward frame')
|
||||
}
|
||||
@@ -128,7 +135,14 @@ export class ExtractorCore {
|
||||
|
||||
if (!workFrame) {
|
||||
log.error('Failed to access inner work frame', {
|
||||
...(await capturePageContext(popupPage, undefined, 'navigate.innerFrame'))
|
||||
...(await capturePageContext(
|
||||
popupPage,
|
||||
undefined,
|
||||
'navigate.innerFrame',
|
||||
undefined,
|
||||
undefined,
|
||||
'navigate_inner_frame'
|
||||
))
|
||||
})
|
||||
throw new Error('Failed to access inner work frame')
|
||||
}
|
||||
|
||||
@@ -58,22 +58,33 @@ export class OrderNumberResolver {
|
||||
|
||||
/**
|
||||
* Get table name based on database type
|
||||
* Converts MySQL schema_tablename format to SQL Server [schema].[tablename] format
|
||||
* e.g., productionContractData_26年压力表合同数据 -> [productionContractData].[26年压力表合同数据]
|
||||
* dbo_MaterialsToBeDeleted -> [dbo].[MaterialsToBeDeleted]
|
||||
* Converts schema_tablename format to database-specific quoting:
|
||||
* - SQL Server: [schema].[tablename]
|
||||
* - PostgreSQL: "schema"."tablename"
|
||||
* - MySQL: schema_tablename (as-is)
|
||||
* e.g., productionContractData_26年压力表合同数据 ->
|
||||
* SQL Server: [productionContractData].[26年压力表合同数据]
|
||||
* PostgreSQL: "productionContractData"."26年压力表合同数据"
|
||||
* MySQL: productionContractData_26年压力表合同数据
|
||||
*/
|
||||
private getTableName(tableName: string): string {
|
||||
if (this.dbService.type === 'sqlserver') {
|
||||
if (this.dbService.type === 'sqlserver' || this.dbService.type === 'postgresql') {
|
||||
// Find the FIRST underscore to split schema and table name
|
||||
// This handles patterns like: schema_tablename
|
||||
const firstUnderscoreIndex = tableName.indexOf('_')
|
||||
if (firstUnderscoreIndex > 0) {
|
||||
const schema = tableName.substring(0, firstUnderscoreIndex)
|
||||
const actualTableName = tableName.substring(firstUnderscoreIndex + 1)
|
||||
return `[${schema}].[${actualTableName}]`
|
||||
if (this.dbService.type === 'sqlserver') {
|
||||
return `[${schema}].[${actualTableName}]`
|
||||
}
|
||||
return `"${schema}"."${actualTableName}"`
|
||||
}
|
||||
// If no underscore found, default to dbo schema
|
||||
return `[dbo].[${tableName}]`
|
||||
// If no underscore found, default schema
|
||||
if (this.dbService.type === 'sqlserver') {
|
||||
return `[dbo].[${tableName}]`
|
||||
}
|
||||
return `"public"."${tableName}"`
|
||||
}
|
||||
return tableName
|
||||
}
|
||||
@@ -107,6 +118,12 @@ export class OrderNumberResolver {
|
||||
// 使用 COLLATE 指定不区分大小写的排序规则
|
||||
sql = `SELECT TOP 1 [${dbConfig.FIELD_ORDER_NUMBER}] FROM ${tableName} WHERE [${dbConfig.FIELD_PRODUCTION_ID}] COLLATE SQL_Latin1_General_CP1_CI_AS = @p0`
|
||||
params = [productionId]
|
||||
} else if (this.dbService.type === 'postgresql') {
|
||||
// PostgreSQL: 使用双引号保护中文标识符,UPPER 实现不区分大小写
|
||||
// prepareSql() 会保留已双引号包裹的标识符
|
||||
// 注意:getTableName() 已返回带双引号的表名,不应再加引号
|
||||
sql = `SELECT DISTINCT "${dbConfig.FIELD_PRODUCTION_ID}", "${dbConfig.FIELD_ORDER_NUMBER}" FROM ${tableName} WHERE UPPER("${dbConfig.FIELD_PRODUCTION_ID}") = UPPER($1) LIMIT 1`
|
||||
params = [productionId]
|
||||
} else {
|
||||
// MySQL 默认不区分大小写,但显式使用 UPPER 确保一致性
|
||||
sql = `SELECT \`${dbConfig.FIELD_ORDER_NUMBER}\` FROM \`${tableName}\` WHERE UPPER(\`${dbConfig.FIELD_PRODUCTION_ID}\`) = UPPER(?) LIMIT 1`
|
||||
@@ -155,6 +172,11 @@ export class OrderNumberResolver {
|
||||
// P0: Use DISTINCT to prevent duplicates from one-to-many relationships
|
||||
// 使用 COLLATE 指定不区分大小写的排序规则
|
||||
sql = `SELECT DISTINCT [${dbConfig.FIELD_PRODUCTION_ID}], [${dbConfig.FIELD_ORDER_NUMBER}] FROM ${tableName} WHERE [${dbConfig.FIELD_PRODUCTION_ID}] COLLATE SQL_Latin1_General_CP1_CI_AS IN (${placeholders})`
|
||||
} else if (this.dbService.type === 'postgresql') {
|
||||
// PostgreSQL: 使用双引号保护中文标识符,UPPER 实现不区分大小写
|
||||
// 注意:getTableName() 已返回带双引号的表名,不应再加引号
|
||||
const pgPlaceholders = uniqueProductionIds.map((_, i) => `UPPER($${i + 1})`).join(', ')
|
||||
sql = `SELECT DISTINCT "${dbConfig.FIELD_PRODUCTION_ID}", "${dbConfig.FIELD_ORDER_NUMBER}" FROM ${tableName} WHERE UPPER("${dbConfig.FIELD_PRODUCTION_ID}") IN (${pgPlaceholders})`
|
||||
} else {
|
||||
const idPlaceholders = uniqueProductionIds.map(() => 'UPPER(?)').join(', ')
|
||||
// P0: Use DISTINCT to prevent duplicates from one-to-many relationships
|
||||
|
||||
@@ -3,6 +3,8 @@ import path from 'path'
|
||||
import { app } from 'electron'
|
||||
import fs from 'fs'
|
||||
import { createLogger } from '../logger'
|
||||
import { logAuditWithCurrentUser } from '../logger/audit-logger'
|
||||
import { AuditAction, AuditStatus } from '../../types/audit.types'
|
||||
import type { ExportResultItem, ExportResultResponse } from '../../types/cleaner.types'
|
||||
|
||||
const log = createLogger('ResultExporter')
|
||||
@@ -37,8 +39,8 @@ export class ResultExporter {
|
||||
* @returns Export result with file path or error
|
||||
*/
|
||||
async exportValidationResults(items: ExportResultItem[]): Promise<ExportResultResponse> {
|
||||
const filePath = path.join(this.exportDir, this.fileName)
|
||||
try {
|
||||
const filePath = path.join(this.exportDir, this.fileName)
|
||||
log.info('Exporting validation results', { count: items.length, path: filePath })
|
||||
|
||||
const workbook = new ExcelJS.Workbook()
|
||||
@@ -97,6 +99,12 @@ export class ResultExporter {
|
||||
await workbook.xlsx.writeFile(filePath)
|
||||
log.info('Export completed', { path: filePath, rows: items.length })
|
||||
|
||||
// Audit log: RESULT_EXPORT success
|
||||
logAuditWithCurrentUser(AuditAction.RESULT_EXPORT, 'VALIDATION_RESULT', AuditStatus.SUCCESS, {
|
||||
itemCount: items.length,
|
||||
filePath
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
filePath
|
||||
@@ -104,6 +112,14 @@ export class ResultExporter {
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
log.error('Export failed', { error: errorMessage })
|
||||
|
||||
// Audit log: RESULT_EXPORT failure
|
||||
logAuditWithCurrentUser(AuditAction.RESULT_EXPORT, 'VALIDATION_RESULT', AuditStatus.FAILURE, {
|
||||
itemCount: items.length,
|
||||
filePath,
|
||||
error: errorMessage
|
||||
})
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage
|
||||
|
||||
@@ -6,33 +6,12 @@
|
||||
import winston from 'winston'
|
||||
import DailyRotateFile from 'winston-daily-rotate-file'
|
||||
import path from 'path'
|
||||
import { hostname } from 'os'
|
||||
import { app } from 'electron'
|
||||
import { getLogDir } from './shared'
|
||||
|
||||
/**
|
||||
* Audit log entry structure
|
||||
* All 8 required fields for comprehensive audit tracking
|
||||
*/
|
||||
export interface AuditEntry {
|
||||
/** ISO 8601 timestamp of the audit event */
|
||||
timestamp: string
|
||||
/** The action that was performed (e.g., 'LOGIN', 'EXTRACT', 'DELETE') */
|
||||
action: string
|
||||
/** User ID who performed the action */
|
||||
userId: string
|
||||
/** Username of the user who performed the action */
|
||||
username: string
|
||||
/** Computer name from which the action was performed */
|
||||
computerName: string
|
||||
/** Application version when the action was performed */
|
||||
appVersion: string
|
||||
/** The resource that was affected (e.g., table name, file path) */
|
||||
resource: string
|
||||
/** Status of the action: 'success' | 'failure' | 'partial' */
|
||||
status: 'success' | 'failure' | 'partial'
|
||||
/** Additional metadata about the audit event */
|
||||
metadata: Record<string, unknown>
|
||||
}
|
||||
import { SessionManager } from '../user/session-manager'
|
||||
import type { AuditEntry } from '../../types/audit.types'
|
||||
import { AuditAction, AuditStatus } from '../../types/audit.types'
|
||||
|
||||
/**
|
||||
* JSONL formatter - outputs one JSON object per line
|
||||
@@ -91,31 +70,58 @@ export function applyAuditConfig(retentionDays: number): void {
|
||||
* @param details - Additional details including username, computerName, resource, status, and optional metadata
|
||||
*/
|
||||
export function logAudit(
|
||||
action: string,
|
||||
action: AuditAction,
|
||||
userId: string,
|
||||
details: {
|
||||
username: string
|
||||
computerName: string
|
||||
resource: string
|
||||
status: 'success' | 'failure' | 'partial'
|
||||
status: AuditStatus
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
): void {
|
||||
const entry: AuditEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
action,
|
||||
userId,
|
||||
username: details.username,
|
||||
computerName: details.computerName,
|
||||
appVersion: app.getVersion(),
|
||||
resource: details.resource,
|
||||
status: details.status,
|
||||
metadata: details.metadata || {}
|
||||
}
|
||||
try {
|
||||
const entry: AuditEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
action,
|
||||
userId,
|
||||
username: details.username,
|
||||
computerName: details.computerName,
|
||||
appVersion: app.getVersion(),
|
||||
resource: details.resource,
|
||||
status: details.status,
|
||||
metadata: details.metadata ?? {}
|
||||
}
|
||||
|
||||
// Write as JSONL - one JSON object per line
|
||||
// Using info level with the entry stringified as the message
|
||||
auditLogger.info(JSON.stringify(entry))
|
||||
// Write as JSONL - one JSON object per line
|
||||
// Using info level with the entry stringified as the message
|
||||
auditLogger.info(JSON.stringify(entry))
|
||||
} catch (error) {
|
||||
console.error('Audit logging failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/** Cached hostname — invariant for the app lifecycle */
|
||||
export const cachedHostname = hostname()
|
||||
|
||||
/**
|
||||
* Audit log shortcut that auto-resolves the current user context.
|
||||
* Falls back to 'anonymous' if no user is logged in, so the record is always written.
|
||||
*/
|
||||
export function logAuditWithCurrentUser(
|
||||
action: AuditAction,
|
||||
resource: string,
|
||||
status: AuditStatus,
|
||||
metadata?: Record<string, unknown>
|
||||
): void {
|
||||
const user = SessionManager.getInstance().getUserInfo()
|
||||
logAudit(action, user ? String(user.id) : 'anonymous', {
|
||||
username: user?.username ?? 'anonymous',
|
||||
computerName: cachedHostname,
|
||||
resource,
|
||||
status,
|
||||
metadata: metadata ?? {}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -119,7 +119,6 @@ export async function trackDuration<T>(
|
||||
return { result, durationMs, isSlow }
|
||||
} catch (error) {
|
||||
const durationMs = performance.now() - startTime
|
||||
const isSlow = durationMs > slowThresholdMs
|
||||
|
||||
// Log the error with duration
|
||||
logger.error(`${message} failed after ${durationMs.toFixed(2)}ms`, {
|
||||
|
||||
@@ -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}秒`
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
type GetObjectCommandInput,
|
||||
type DeleteObjectCommandInput
|
||||
} from '@aws-sdk/client-s3'
|
||||
import { createLogger, run, trackDuration, PerformanceTracker } from '../logger'
|
||||
import { createLogger } from '../logger'
|
||||
import type { RustfsConfig } from '../../types/config.schema'
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import * as fs from 'fs'
|
||||
import { ConfigManager } from '../config/config-manager'
|
||||
import { createLogger, run, trackDuration, PerformanceTracker } from '../logger'
|
||||
import { createLogger } from '../logger'
|
||||
import { logAuditWithCurrentUser } from '../logger/audit-logger'
|
||||
import { AuditAction, AuditStatus } from '../../types/audit.types'
|
||||
import type { UpdateConfig } from '../../types/config.schema'
|
||||
import type { UserType } from '../../types/user.types'
|
||||
import type {
|
||||
@@ -281,10 +283,21 @@ export class UpdateService {
|
||||
log.error('Update package hash mismatch', {
|
||||
version: request.version,
|
||||
channel: request.channel,
|
||||
expectedHash: request.sha256,
|
||||
actualHash: hash
|
||||
expectedHash: request.sha256.substring(0, 16),
|
||||
actualHash: hash.substring(0, 16)
|
||||
})
|
||||
await fs.promises.rm(downloadPath, { force: true })
|
||||
|
||||
// Audit log: APP_UPDATE download hash mismatch
|
||||
logAuditWithCurrentUser(AuditAction.APP_UPDATE, 'UPDATE_PACKAGE', AuditStatus.FAILURE, {
|
||||
version: request.version,
|
||||
channel: request.channel,
|
||||
phase: 'download',
|
||||
error: 'Hash mismatch',
|
||||
expectedHash: request.sha256.substring(0, 16),
|
||||
actualHash: hash.substring(0, 16)
|
||||
})
|
||||
|
||||
throw new Error('更新包校验失败,文件哈希不匹配')
|
||||
}
|
||||
|
||||
@@ -294,6 +307,13 @@ export class UpdateService {
|
||||
downloadPath
|
||||
})
|
||||
|
||||
// Audit log: APP_UPDATE download success
|
||||
logAuditWithCurrentUser(AuditAction.APP_UPDATE, 'UPDATE_PACKAGE', AuditStatus.SUCCESS, {
|
||||
version: request.version,
|
||||
channel: request.channel,
|
||||
phase: 'download'
|
||||
})
|
||||
|
||||
this.publishStatus({
|
||||
phase: 'downloaded',
|
||||
progress: 100,
|
||||
@@ -336,11 +356,29 @@ export class UpdateService {
|
||||
error: undefined
|
||||
})
|
||||
|
||||
await this.installer.installDownloadedRelease(downloaded)
|
||||
log.info('Update installation completed', {
|
||||
version: downloaded.version,
|
||||
channel: downloaded.channel
|
||||
})
|
||||
try {
|
||||
await this.installer.installDownloadedRelease(downloaded)
|
||||
log.info('Update installation completed', {
|
||||
version: downloaded.version,
|
||||
channel: downloaded.channel
|
||||
})
|
||||
|
||||
// Audit log: APP_UPDATE install success
|
||||
logAuditWithCurrentUser(AuditAction.APP_UPDATE, 'UPDATE_PACKAGE', AuditStatus.SUCCESS, {
|
||||
version: downloaded.version,
|
||||
channel: downloaded.channel,
|
||||
phase: 'install'
|
||||
})
|
||||
} catch (installError) {
|
||||
const msg = installError instanceof Error ? installError.message : String(installError)
|
||||
logAuditWithCurrentUser(AuditAction.APP_UPDATE, 'UPDATE_PACKAGE', AuditStatus.FAILURE, {
|
||||
version: downloaded.version,
|
||||
channel: downloaded.channel,
|
||||
phase: 'install',
|
||||
error: msg
|
||||
})
|
||||
throw installError
|
||||
}
|
||||
}
|
||||
|
||||
private ensureInitialized(): void {
|
||||
|
||||
@@ -8,10 +8,8 @@
|
||||
* - Create, update, delete users
|
||||
*/
|
||||
|
||||
import { MySqlService } from '../database/mysql'
|
||||
import { SqlServerService } from '../database/sql-server'
|
||||
import { ConfigManager } from '../config/config-manager'
|
||||
import sql from 'mssql'
|
||||
import { create, type IDatabaseService } from '../database/index'
|
||||
import { createDialect, type SqlDialect } from '../database/dialects'
|
||||
import type { UserInfo } from '../../types/user.types'
|
||||
import { createLogger, logError } from '../logger'
|
||||
|
||||
@@ -21,10 +19,6 @@ const log = createLogger('BipUsersDao')
|
||||
* Database configuration for BIPUsers table
|
||||
*/
|
||||
export const BIP_USERS_CONFIG = {
|
||||
/** Table name in SQL Server: [dbo].[BIPUsers] */
|
||||
TABLE_NAME_SQLSERVER: '[dbo].[BIPUsers]',
|
||||
/** Table name in MySQL: dbo_BIPUsers */
|
||||
TABLE_NAME_MYSQL: 'dbo_BIPUsers',
|
||||
/** Column names */
|
||||
COLUMNS: {
|
||||
ID: 'ID',
|
||||
@@ -44,71 +38,37 @@ export const BIP_USERS_CONFIG = {
|
||||
* BIPUsers DAO Class
|
||||
*/
|
||||
export class BIPUsersDAO {
|
||||
private mysqlService: MySqlService | null = null
|
||||
private sqlServerService: SqlServerService | null = null
|
||||
private dbType: 'mysql' | 'sqlserver' = 'mysql'
|
||||
private configManager: ConfigManager
|
||||
|
||||
/**
|
||||
* Constructor - get database type from ConfigManager
|
||||
*/
|
||||
constructor() {
|
||||
this.configManager = ConfigManager.getInstance()
|
||||
this.dbType = this.configManager.getDatabaseType()
|
||||
}
|
||||
private dbService: IDatabaseService | null = null
|
||||
private dialect: SqlDialect | null = null
|
||||
|
||||
/**
|
||||
* Get the appropriate table name based on database type
|
||||
*/
|
||||
private getTableName(): string {
|
||||
return this.dbType === 'sqlserver'
|
||||
? BIP_USERS_CONFIG.TABLE_NAME_SQLSERVER
|
||||
: BIP_USERS_CONFIG.TABLE_NAME_MYSQL
|
||||
return this.getDialect().quoteTableName('dbo', 'BIPUsers')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get database service instance (MySQL or SQL Server)
|
||||
* Get dialect instance
|
||||
*/
|
||||
private async getDatabaseService(): Promise<MySqlService | SqlServerService> {
|
||||
const config = this.configManager.getConfig()
|
||||
|
||||
if (this.dbType === 'sqlserver') {
|
||||
if (this.sqlServerService && this.sqlServerService.isConnected()) {
|
||||
return this.sqlServerService
|
||||
}
|
||||
|
||||
const dbConfig = config.database.sqlserver
|
||||
this.sqlServerService = new SqlServerService({
|
||||
server: dbConfig.server,
|
||||
port: dbConfig.port,
|
||||
user: dbConfig.username,
|
||||
password: dbConfig.password,
|
||||
database: dbConfig.database,
|
||||
options: {
|
||||
encrypt: false,
|
||||
trustServerCertificate: dbConfig.trustServerCertificate
|
||||
}
|
||||
})
|
||||
|
||||
await this.sqlServerService.connect()
|
||||
return this.sqlServerService
|
||||
} else {
|
||||
if (this.mysqlService && this.mysqlService.isConnected()) {
|
||||
return this.mysqlService
|
||||
}
|
||||
|
||||
const dbConfig = config.database.mysql
|
||||
this.mysqlService = new MySqlService({
|
||||
host: dbConfig.host,
|
||||
port: dbConfig.port,
|
||||
user: dbConfig.username,
|
||||
password: dbConfig.password,
|
||||
database: dbConfig.database
|
||||
})
|
||||
|
||||
await this.mysqlService.connect()
|
||||
return this.mysqlService
|
||||
private getDialect(): SqlDialect {
|
||||
if (!this.dialect) {
|
||||
this.dialect = createDialect(this.dbService!.type)
|
||||
}
|
||||
return this.dialect
|
||||
}
|
||||
|
||||
/**
|
||||
* Get database service instance via DatabaseFactory
|
||||
*/
|
||||
private async getDatabaseService(): Promise<IDatabaseService> {
|
||||
if (this.dbService && this.dbService.isConnected()) {
|
||||
return this.dbService
|
||||
}
|
||||
|
||||
this.dbService = await create()
|
||||
this.dialect = null // Reset dialect when service changes
|
||||
return this.dbService
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -121,52 +81,30 @@ export class BIPUsersDAO {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const dialect = this.getDialect()
|
||||
|
||||
if (this.dbType === 'sqlserver') {
|
||||
const sqlString = `
|
||||
SELECT ID, UserName, UserType
|
||||
FROM ${tableName}
|
||||
WHERE UserName = @username AND Password = @password
|
||||
`
|
||||
const sqlString = `
|
||||
SELECT ID, UserName, UserType
|
||||
FROM ${tableName}
|
||||
WHERE UserName = ${dialect.param(0)} AND Password = ${dialect.param(1)}
|
||||
`
|
||||
|
||||
const result = await (dbService as SqlServerService).queryWithParams(sqlString, {
|
||||
username: { value: username, type: sql.NVarChar(255) },
|
||||
password: { value: password, type: sql.NVarChar(255) }
|
||||
})
|
||||
const result = await dbService.query(sqlString, [username, password])
|
||||
|
||||
if (result.rows.length > 0) {
|
||||
const row = result.rows[0]
|
||||
return {
|
||||
id: row.ID as number,
|
||||
username: row.UserName as string,
|
||||
userType: row.UserType as 'Admin' | 'User'
|
||||
}
|
||||
if (result.rows.length > 0) {
|
||||
const row = result.rows[0]
|
||||
return {
|
||||
id: row.ID as number,
|
||||
username: row.UserName as string,
|
||||
userType: row.UserType as 'Admin' | 'User'
|
||||
}
|
||||
return null
|
||||
} else {
|
||||
const sqlString = `
|
||||
SELECT ID, UserName, UserType
|
||||
FROM ${tableName}
|
||||
WHERE UserName = ? AND Password = ?
|
||||
`
|
||||
|
||||
const result = await (dbService as MySqlService).query(sqlString, [username, password])
|
||||
|
||||
if (result.rows.length > 0) {
|
||||
const row = result.rows[0]
|
||||
return {
|
||||
id: row.ID as number,
|
||||
username: row.UserName as string,
|
||||
userType: row.UserType as 'Admin' | 'User'
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
return null
|
||||
} catch (error) {
|
||||
logError(log, error, {
|
||||
message: 'Authenticate failed',
|
||||
operation: 'authenticate',
|
||||
context: { username, dbType: this.dbType }
|
||||
context: { username, dbType: this.dbService?.type }
|
||||
})
|
||||
return null
|
||||
}
|
||||
@@ -181,51 +119,30 @@ export class BIPUsersDAO {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const dialect = this.getDialect()
|
||||
|
||||
if (this.dbType === 'sqlserver') {
|
||||
const sqlString = `
|
||||
SELECT ID, UserName, UserType
|
||||
FROM ${tableName}
|
||||
WHERE ComputerName = @computerName
|
||||
`
|
||||
const sqlString = `
|
||||
SELECT ID, UserName, UserType
|
||||
FROM ${tableName}
|
||||
WHERE ComputerName = ${dialect.param(0)}
|
||||
`
|
||||
|
||||
const result = await (dbService as SqlServerService).queryWithParams(sqlString, {
|
||||
computerName: { value: computerName, type: sql.NVarChar(255) }
|
||||
})
|
||||
const result = await dbService.query(sqlString, [computerName])
|
||||
|
||||
if (result.rows.length > 0) {
|
||||
const row = result.rows[0]
|
||||
return {
|
||||
id: row.ID as number,
|
||||
username: row.UserName as string,
|
||||
userType: row.UserType as 'Admin' | 'User'
|
||||
}
|
||||
if (result.rows.length > 0) {
|
||||
const row = result.rows[0]
|
||||
return {
|
||||
id: row.ID as number,
|
||||
username: row.UserName as string,
|
||||
userType: row.UserType as 'Admin' | 'User'
|
||||
}
|
||||
return null
|
||||
} else {
|
||||
const sqlString = `
|
||||
SELECT ID, UserName, UserType
|
||||
FROM ${tableName}
|
||||
WHERE ComputerName = ?
|
||||
`
|
||||
|
||||
const result = await (dbService as MySqlService).query(sqlString, [computerName])
|
||||
|
||||
if (result.rows.length > 0) {
|
||||
const row = result.rows[0]
|
||||
return {
|
||||
id: row.ID as number,
|
||||
username: row.UserName as string,
|
||||
userType: row.UserType as 'Admin' | 'User'
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
return null
|
||||
} catch (error) {
|
||||
logError(log, error, {
|
||||
message: 'Silent login failed',
|
||||
operation: 'authenticateByComputerName',
|
||||
context: { computerName, dbType: this.dbType }
|
||||
context: { computerName, dbType: this.dbService?.type }
|
||||
})
|
||||
return null
|
||||
}
|
||||
@@ -246,10 +163,7 @@ export class BIPUsersDAO {
|
||||
ORDER BY UserName
|
||||
`
|
||||
|
||||
const result =
|
||||
this.dbType === 'sqlserver'
|
||||
? await (dbService as SqlServerService).query(sqlString)
|
||||
: await (dbService as MySqlService).query(sqlString)
|
||||
const result = await dbService.query(sqlString)
|
||||
|
||||
return result.rows.map((row) => ({
|
||||
id: row.ID as number,
|
||||
@@ -261,7 +175,7 @@ export class BIPUsersDAO {
|
||||
logError(log, error, {
|
||||
message: 'Get all users failed',
|
||||
operation: 'getAllUsers',
|
||||
context: { dbType: this.dbType }
|
||||
context: { dbType: this.dbService?.type }
|
||||
})
|
||||
return []
|
||||
}
|
||||
@@ -284,72 +198,34 @@ export class BIPUsersDAO {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const dialect = this.getDialect()
|
||||
|
||||
if (this.dbType === 'sqlserver') {
|
||||
let sqlString: string
|
||||
let params: Record<
|
||||
string,
|
||||
{
|
||||
value: unknown
|
||||
type?: sql.ISqlType | sql.ISqlTypeFactoryWithLength | sql.ISqlTypeWithLength
|
||||
}
|
||||
>
|
||||
let sqlString: string
|
||||
let params: string[]
|
||||
|
||||
if (computerName) {
|
||||
sqlString = `
|
||||
INSERT INTO ${tableName}
|
||||
(UserName, Password, UserType, ComputerName)
|
||||
VALUES (@username, @password, @userType, @computerName)
|
||||
`
|
||||
params = {
|
||||
username: { value: username, type: sql.NVarChar(255) },
|
||||
password: { value: password, type: sql.NVarChar(255) },
|
||||
userType: { value: userType, type: sql.NVarChar(255) },
|
||||
computerName: { value: computerName, type: sql.NVarChar(255) }
|
||||
}
|
||||
} else {
|
||||
sqlString = `
|
||||
INSERT INTO ${tableName}
|
||||
(UserName, Password, UserType)
|
||||
VALUES (@username, @password, @userType)
|
||||
`
|
||||
params = {
|
||||
username: { value: username, type: sql.NVarChar(255) },
|
||||
password: { value: password, type: sql.NVarChar(255) },
|
||||
userType: { value: userType, type: sql.NVarChar(255) }
|
||||
}
|
||||
}
|
||||
|
||||
await (dbService as SqlServerService).queryWithParams(sqlString, params)
|
||||
return true
|
||||
if (computerName) {
|
||||
sqlString = `
|
||||
INSERT INTO ${tableName}
|
||||
(UserName, Password, UserType, ComputerName)
|
||||
VALUES (${dialect.param(0)}, ${dialect.param(1)}, ${dialect.param(2)}, ${dialect.param(3)})
|
||||
`
|
||||
params = [username, password, userType, computerName]
|
||||
} else {
|
||||
let sqlString: string
|
||||
let params: unknown[]
|
||||
|
||||
if (computerName) {
|
||||
sqlString = `
|
||||
INSERT INTO ${tableName}
|
||||
(UserName, Password, UserType, ComputerName)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`
|
||||
params = [username, password, userType, computerName]
|
||||
} else {
|
||||
sqlString = `
|
||||
INSERT INTO ${tableName}
|
||||
(UserName, Password, UserType)
|
||||
VALUES (?, ?, ?)
|
||||
`
|
||||
params = [username, password, userType]
|
||||
}
|
||||
|
||||
await (dbService as MySqlService).query(sqlString, params)
|
||||
return true
|
||||
sqlString = `
|
||||
INSERT INTO ${tableName}
|
||||
(UserName, Password, UserType)
|
||||
VALUES (${dialect.param(0)}, ${dialect.param(1)}, ${dialect.param(2)})
|
||||
`
|
||||
params = [username, password, userType]
|
||||
}
|
||||
|
||||
await dbService.query(sqlString, params)
|
||||
return true
|
||||
} catch (error) {
|
||||
logError(log, error, {
|
||||
message: 'Create user failed',
|
||||
operation: 'createUser',
|
||||
context: { username, userType, dbType: this.dbType }
|
||||
context: { username, userType, dbType: this.dbService?.type }
|
||||
})
|
||||
return false
|
||||
}
|
||||
@@ -365,34 +241,21 @@ export class BIPUsersDAO {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const dialect = this.getDialect()
|
||||
|
||||
if (this.dbType === 'sqlserver') {
|
||||
const sqlString = `
|
||||
UPDATE ${tableName}
|
||||
SET UserType = @userType
|
||||
WHERE UserName = @username
|
||||
`
|
||||
const sqlString = `
|
||||
UPDATE ${tableName}
|
||||
SET UserType = ${dialect.param(0)}
|
||||
WHERE UserName = ${dialect.param(1)}
|
||||
`
|
||||
|
||||
await (dbService as SqlServerService).queryWithParams(sqlString, {
|
||||
username: { value: username, type: sql.NVarChar(255) },
|
||||
userType: { value: userType, type: sql.NVarChar(255) }
|
||||
})
|
||||
return true
|
||||
} else {
|
||||
const sqlString = `
|
||||
UPDATE ${tableName}
|
||||
SET UserType = ?
|
||||
WHERE UserName = ?
|
||||
`
|
||||
|
||||
await (dbService as MySqlService).query(sqlString, [userType, username])
|
||||
return true
|
||||
}
|
||||
await dbService.query(sqlString, [userType, username])
|
||||
return true
|
||||
} catch (error) {
|
||||
logError(log, error, {
|
||||
message: 'Update user type failed',
|
||||
operation: 'updateUserType',
|
||||
context: { username, userType, dbType: this.dbType }
|
||||
context: { username, userType, dbType: this.dbService?.type }
|
||||
})
|
||||
return false
|
||||
}
|
||||
@@ -408,34 +271,21 @@ export class BIPUsersDAO {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const dialect = this.getDialect()
|
||||
|
||||
if (this.dbType === 'sqlserver') {
|
||||
const sqlString = `
|
||||
UPDATE ${tableName}
|
||||
SET Password = @newPassword
|
||||
WHERE UserName = @username
|
||||
`
|
||||
const sqlString = `
|
||||
UPDATE ${tableName}
|
||||
SET Password = ${dialect.param(0)}
|
||||
WHERE UserName = ${dialect.param(1)}
|
||||
`
|
||||
|
||||
await (dbService as SqlServerService).queryWithParams(sqlString, {
|
||||
username: { value: username, type: sql.NVarChar(255) },
|
||||
newPassword: { value: newPassword, type: sql.NVarChar(255) }
|
||||
})
|
||||
return true
|
||||
} else {
|
||||
const sqlString = `
|
||||
UPDATE ${tableName}
|
||||
SET Password = ?
|
||||
WHERE UserName = ?
|
||||
`
|
||||
|
||||
await (dbService as MySqlService).query(sqlString, [newPassword, username])
|
||||
return true
|
||||
}
|
||||
await dbService.query(sqlString, [newPassword, username])
|
||||
return true
|
||||
} catch (error) {
|
||||
logError(log, error, {
|
||||
message: 'Update password failed',
|
||||
operation: 'updatePassword',
|
||||
context: { username, dbType: this.dbType }
|
||||
context: { username, dbType: this.dbService?.type }
|
||||
})
|
||||
return false
|
||||
}
|
||||
@@ -450,31 +300,20 @@ export class BIPUsersDAO {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const dialect = this.getDialect()
|
||||
|
||||
if (this.dbType === 'sqlserver') {
|
||||
const sqlString = `
|
||||
DELETE FROM ${tableName}
|
||||
WHERE UserName = @username
|
||||
`
|
||||
const sqlString = `
|
||||
DELETE FROM ${tableName}
|
||||
WHERE UserName = ${dialect.param(0)}
|
||||
`
|
||||
|
||||
await (dbService as SqlServerService).queryWithParams(sqlString, {
|
||||
username: { value: username, type: sql.NVarChar(255) }
|
||||
})
|
||||
return true
|
||||
} else {
|
||||
const sqlString = `
|
||||
DELETE FROM ${tableName}
|
||||
WHERE UserName = ?
|
||||
`
|
||||
|
||||
await (dbService as MySqlService).query(sqlString, [username])
|
||||
return true
|
||||
}
|
||||
await dbService.query(sqlString, [username])
|
||||
return true
|
||||
} catch (error) {
|
||||
logError(log, error, {
|
||||
message: 'Delete user failed',
|
||||
operation: 'deleteUser',
|
||||
context: { username, dbType: this.dbType }
|
||||
context: { username, dbType: this.dbService?.type }
|
||||
})
|
||||
return false
|
||||
}
|
||||
@@ -489,33 +328,21 @@ export class BIPUsersDAO {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const dialect = this.getDialect()
|
||||
|
||||
if (this.dbType === 'sqlserver') {
|
||||
const sqlString = `
|
||||
SELECT COUNT(*) as count
|
||||
FROM ${tableName}
|
||||
WHERE UserName = @username
|
||||
`
|
||||
const sqlString = `
|
||||
SELECT COUNT(*) as count
|
||||
FROM ${tableName}
|
||||
WHERE UserName = ${dialect.param(0)}
|
||||
`
|
||||
|
||||
const result = await (dbService as SqlServerService).queryWithParams(sqlString, {
|
||||
username: { value: username, type: sql.NVarChar(255) }
|
||||
})
|
||||
return result.rows.length > 0 && (result.rows[0].count as number) > 0
|
||||
} else {
|
||||
const sqlString = `
|
||||
SELECT COUNT(*) as count
|
||||
FROM ${tableName}
|
||||
WHERE UserName = ?
|
||||
`
|
||||
|
||||
const result = await (dbService as MySqlService).query(sqlString, [username])
|
||||
return result.rows.length > 0 && (result.rows[0].count as number) > 0
|
||||
}
|
||||
const result = await dbService.query(sqlString, [username])
|
||||
return result.rows.length > 0 && (result.rows[0].count as number) > 0
|
||||
} catch (error) {
|
||||
logError(log, error, {
|
||||
message: 'Check user exists failed',
|
||||
operation: 'userExists',
|
||||
context: { username, dbType: this.dbType }
|
||||
context: { username, dbType: this.dbService?.type }
|
||||
})
|
||||
return false
|
||||
}
|
||||
@@ -533,50 +360,30 @@ export class BIPUsersDAO {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const dialect = this.getDialect()
|
||||
const cols = BIP_USERS_CONFIG.COLUMNS
|
||||
|
||||
if (this.dbType === 'sqlserver') {
|
||||
const sqlString = `
|
||||
SELECT ${cols.ERP_USERNAME}, ${cols.ERP_PASSWORD}
|
||||
FROM ${tableName}
|
||||
WHERE UserName = @username
|
||||
`
|
||||
const sqlString = `
|
||||
SELECT ${cols.ERP_USERNAME}, ${cols.ERP_PASSWORD}
|
||||
FROM ${tableName}
|
||||
WHERE UserName = ${dialect.param(0)}
|
||||
`
|
||||
|
||||
const result = await (dbService as SqlServerService).queryWithParams(sqlString, {
|
||||
username: { value: username, type: sql.NVarChar(255) }
|
||||
})
|
||||
const result = await dbService.query(sqlString, [username])
|
||||
|
||||
if (result.rows.length > 0) {
|
||||
const row = result.rows[0]
|
||||
return {
|
||||
username: (row[cols.ERP_USERNAME] as string) || '',
|
||||
password: (row[cols.ERP_PASSWORD] as string) || ''
|
||||
}
|
||||
if (result.rows.length > 0) {
|
||||
const row = result.rows[0]
|
||||
return {
|
||||
username: (row[cols.ERP_USERNAME] as string) || '',
|
||||
password: (row[cols.ERP_PASSWORD] as string) || ''
|
||||
}
|
||||
return null
|
||||
} else {
|
||||
const sqlString = `
|
||||
SELECT ${cols.ERP_USERNAME}, ${cols.ERP_PASSWORD}
|
||||
FROM ${tableName}
|
||||
WHERE UserName = ?
|
||||
`
|
||||
|
||||
const result = await (dbService as MySqlService).query(sqlString, [username])
|
||||
|
||||
if (result.rows.length > 0) {
|
||||
const row = result.rows[0]
|
||||
return {
|
||||
username: (row[cols.ERP_USERNAME] as string) || '',
|
||||
password: (row[cols.ERP_PASSWORD] as string) || ''
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
return null
|
||||
} catch (error) {
|
||||
logError(log, error, {
|
||||
message: 'Get user ERP credentials failed',
|
||||
operation: 'getUserErpCredentials',
|
||||
context: { username, dbType: this.dbType }
|
||||
context: { username, dbType: this.dbService?.type }
|
||||
})
|
||||
return null
|
||||
}
|
||||
@@ -597,38 +404,23 @@ export class BIPUsersDAO {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const dialect = this.getDialect()
|
||||
const cols = BIP_USERS_CONFIG.COLUMNS
|
||||
|
||||
if (this.dbType === 'sqlserver') {
|
||||
const sqlString = `
|
||||
UPDATE ${tableName}
|
||||
SET ${cols.ERP_USERNAME} = @erpUsername,
|
||||
${cols.ERP_PASSWORD} = @erpPassword
|
||||
WHERE UserName = @username
|
||||
`
|
||||
const sqlString = `
|
||||
UPDATE ${tableName}
|
||||
SET ${cols.ERP_USERNAME} = ${dialect.param(0)},
|
||||
${cols.ERP_PASSWORD} = ${dialect.param(1)}
|
||||
WHERE UserName = ${dialect.param(2)}
|
||||
`
|
||||
|
||||
await (dbService as SqlServerService).queryWithParams(sqlString, {
|
||||
username: { value: username, type: sql.NVarChar(255) },
|
||||
erpUsername: { value: erpUsername, type: sql.NVarChar(255) },
|
||||
erpPassword: { value: erpPassword, type: sql.NVarChar(255) }
|
||||
})
|
||||
return true
|
||||
} else {
|
||||
const sqlString = `
|
||||
UPDATE ${tableName}
|
||||
SET ${cols.ERP_USERNAME} = ?,
|
||||
${cols.ERP_PASSWORD} = ?
|
||||
WHERE UserName = ?
|
||||
`
|
||||
|
||||
await (dbService as MySqlService).query(sqlString, [erpUsername, erpPassword, username])
|
||||
return true
|
||||
}
|
||||
await dbService.query(sqlString, [erpUsername, erpPassword, username])
|
||||
return true
|
||||
} catch (error) {
|
||||
logError(log, error, {
|
||||
message: 'Update user ERP credentials failed',
|
||||
operation: 'updateUserErpCredentials',
|
||||
context: { username, dbType: this.dbType }
|
||||
context: { username, dbType: this.dbService?.type }
|
||||
})
|
||||
return false
|
||||
}
|
||||
@@ -656,10 +448,7 @@ export class BIPUsersDAO {
|
||||
ORDER BY ${cols.USERNAME}
|
||||
`
|
||||
|
||||
const result =
|
||||
this.dbType === 'sqlserver'
|
||||
? await (dbService as SqlServerService).query(sqlString)
|
||||
: await (dbService as MySqlService).query(sqlString)
|
||||
const result = await dbService.query(sqlString)
|
||||
|
||||
return result.rows.map((row) => ({
|
||||
username: row[cols.USERNAME] as string,
|
||||
@@ -670,7 +459,7 @@ export class BIPUsersDAO {
|
||||
logError(log, error, {
|
||||
message: 'Get all users ERP config failed',
|
||||
operation: 'getAllUsersErpConfig',
|
||||
context: { dbType: this.dbType }
|
||||
context: { dbType: this.dbService?.type }
|
||||
})
|
||||
return []
|
||||
}
|
||||
@@ -680,13 +469,10 @@ export class BIPUsersDAO {
|
||||
* Disconnect from database
|
||||
*/
|
||||
async disconnect(): Promise<void> {
|
||||
if (this.mysqlService) {
|
||||
await this.mysqlService.disconnect()
|
||||
this.mysqlService = null
|
||||
}
|
||||
if (this.sqlServerService) {
|
||||
await this.sqlServerService.disconnect()
|
||||
this.sqlServerService = null
|
||||
if (this.dbService) {
|
||||
await this.dbService.disconnect()
|
||||
this.dbService = null
|
||||
this.dialect = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -14,6 +14,8 @@
|
||||
import { BIPUsersDAO } from './bip-users-dao'
|
||||
import { SessionManager } from './session-manager'
|
||||
import { createLogger } from '../logger'
|
||||
import { logAuditWithCurrentUser } from '../logger/audit-logger'
|
||||
import { AuditAction, AuditStatus } from '../../types/audit.types'
|
||||
|
||||
const log = createLogger('UserErpConfigService')
|
||||
|
||||
@@ -131,9 +133,22 @@ export class UserErpConfigService {
|
||||
log.error('Failed to update ERP credentials', { username: currentUser.username })
|
||||
}
|
||||
|
||||
logAuditWithCurrentUser(
|
||||
AuditAction.ERP_CREDENTIALS_UPDATE,
|
||||
'ERP_CREDENTIALS',
|
||||
success ? AuditStatus.SUCCESS : AuditStatus.FAILURE,
|
||||
{ targetUsername: currentUser.username, updateType: 'self' }
|
||||
)
|
||||
|
||||
return success
|
||||
} catch (error) {
|
||||
log.error('Error updating current user ERP credentials', { error })
|
||||
logAuditWithCurrentUser(
|
||||
AuditAction.ERP_CREDENTIALS_UPDATE,
|
||||
'ERP_CREDENTIALS',
|
||||
AuditStatus.FAILURE,
|
||||
{ targetUsername: 'unknown', updateType: 'self', error: String(error) }
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -159,9 +174,22 @@ export class UserErpConfigService {
|
||||
log.error('Failed to update ERP credentials', { username })
|
||||
}
|
||||
|
||||
logAuditWithCurrentUser(
|
||||
AuditAction.ERP_CREDENTIALS_UPDATE,
|
||||
'ERP_CREDENTIALS',
|
||||
success ? AuditStatus.SUCCESS : AuditStatus.FAILURE,
|
||||
{ targetUsername: username, updateType: 'admin' }
|
||||
)
|
||||
|
||||
return success
|
||||
} catch (error) {
|
||||
log.error('Error updating user ERP credentials', { error })
|
||||
logAuditWithCurrentUser(
|
||||
AuditAction.ERP_CREDENTIALS_UPDATE,
|
||||
'ERP_CREDENTIALS',
|
||||
AuditStatus.FAILURE,
|
||||
{ targetUsername: username, updateType: 'admin', error: String(error) }
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export async function getSourceNumbersFromInputs(
|
||||
const productionIds: string[] = []
|
||||
const orderNumbers: string[] = []
|
||||
const configManager = ConfigManager.getInstance()
|
||||
const isSqlServer = configManager.getDatabaseType() === 'sqlserver'
|
||||
const dbType = configManager.getDatabaseType()
|
||||
|
||||
for (const item of inputs) {
|
||||
const type = identifyInputType(item)
|
||||
@@ -44,7 +44,7 @@ export async function getSourceNumbersFromInputs(
|
||||
const contractTableName = getValidationTableName('productionContractData_26年压力表合同数据')
|
||||
const batchSize = 2000
|
||||
|
||||
if (isSqlServer) {
|
||||
if (dbType === 'sqlserver') {
|
||||
const sql = await import('mssql')
|
||||
const allOrderNumbers: string[] = []
|
||||
|
||||
@@ -71,6 +71,22 @@ export async function getSourceNumbersFromInputs(
|
||||
)
|
||||
}
|
||||
|
||||
orderNumbers.push(...allOrderNumbers)
|
||||
} else if (dbType === 'postgresql') {
|
||||
const allOrderNumbers: string[] = []
|
||||
|
||||
for (let i = 0; i < productionIds.length; i += batchSize) {
|
||||
const batch = productionIds.slice(i, i + batchSize)
|
||||
const placeholders = batch.map((_, idx) => `$${idx + 1}`).join(',')
|
||||
const contractSql = `
|
||||
SELECT DISTINCT "生产订单号"
|
||||
FROM ${contractTableName}
|
||||
WHERE "总排号" IN (${placeholders})
|
||||
`
|
||||
const contractResult = await dbService.query(contractSql, batch)
|
||||
allOrderNumbers.push(...contractResult.rows.map((row) => row.生产订单号 as string))
|
||||
}
|
||||
|
||||
orderNumbers.push(...allOrderNumbers)
|
||||
} else {
|
||||
const allOrderNumbers: string[] = []
|
||||
|
||||
@@ -231,7 +231,8 @@ export class ValidationApplicationService {
|
||||
|
||||
async getCleanerData(
|
||||
userInfo: UserInfo,
|
||||
senderId: number
|
||||
senderId: number,
|
||||
selectedManagers: string[] = []
|
||||
): Promise<{
|
||||
success: boolean
|
||||
orderNumbers?: string[]
|
||||
@@ -250,6 +251,7 @@ export class ValidationApplicationService {
|
||||
userId: userInfo.id,
|
||||
username,
|
||||
isAdmin,
|
||||
selectedManagers,
|
||||
requestId
|
||||
})
|
||||
|
||||
@@ -270,7 +272,13 @@ export class ValidationApplicationService {
|
||||
})
|
||||
}
|
||||
|
||||
const materialCodes = await this.loadMaterialCodesForCleaner(dbService, username, isAdmin)
|
||||
const materialCodes = await this.loadMaterialCodesForCleaner(
|
||||
dbService,
|
||||
username,
|
||||
isAdmin,
|
||||
selectedManagers,
|
||||
orderNumbers
|
||||
)
|
||||
log.info('Cleaner data retrieved', {
|
||||
userId: userInfo.id,
|
||||
orderCount: orderNumbers.length,
|
||||
@@ -463,6 +471,18 @@ export class ValidationApplicationService {
|
||||
)
|
||||
}
|
||||
|
||||
if (dbService.type === 'postgresql') {
|
||||
return dbService.query(
|
||||
`
|
||||
SELECT "MaterialName", "Specification", "Model"
|
||||
FROM ${detailTableName}
|
||||
WHERE "MaterialCode" = $1
|
||||
LIMIT 1
|
||||
`,
|
||||
[materialCode]
|
||||
)
|
||||
}
|
||||
|
||||
return dbService.query(
|
||||
`
|
||||
SELECT MaterialName, Specification, Model
|
||||
@@ -477,27 +497,54 @@ export class ValidationApplicationService {
|
||||
private async loadMaterialCodesForCleaner(
|
||||
dbService: ValidationDatabaseService,
|
||||
username: string,
|
||||
isAdmin: boolean
|
||||
isAdmin: boolean,
|
||||
selectedManagers: string[],
|
||||
orderNumbers: string[]
|
||||
): Promise<string[]> {
|
||||
const markedTableName = getValidationTableName('dbo_MaterialsToBeDeleted')
|
||||
|
||||
if (isAdmin) {
|
||||
const result = await dbService.query(
|
||||
`
|
||||
SELECT MaterialCode
|
||||
FROM ${markedTableName}
|
||||
WHERE MaterialCode IS NOT NULL
|
||||
`
|
||||
// Admin with selected managers: filter MaterialsToBeDeleted by ManagerName IN (selectedManagers)
|
||||
if (isAdmin && selectedManagers && selectedManagers.length > 0) {
|
||||
const materialCodes = await this.queryMaterialCodesByManagers(
|
||||
dbService,
|
||||
markedTableName,
|
||||
selectedManagers
|
||||
)
|
||||
const materialCodes = result.rows.map((row) => row.MaterialCode as string).filter(Boolean)
|
||||
log.info(`Admin user: got ${materialCodes.length} materials`, {
|
||||
log.info(`Admin with selected managers: got ${materialCodes.length} materials`, {
|
||||
userId: username,
|
||||
isAdmin: true,
|
||||
selectedManagers,
|
||||
materialCount: materialCodes.length
|
||||
})
|
||||
return materialCodes
|
||||
}
|
||||
|
||||
// Admin without selected managers: query all materials from DiscreteMaterialPlanData by orderNumbers
|
||||
if (isAdmin) {
|
||||
if (orderNumbers.length === 0) {
|
||||
log.warn('Admin without selected managers but no orderNumbers available', {
|
||||
userId: username
|
||||
})
|
||||
return []
|
||||
}
|
||||
const materialDao = new DiscreteMaterialPlanDAO()
|
||||
const records = await materialDao.queryBySourceNumbersDistinct(orderNumbers)
|
||||
const materialCodes = [
|
||||
...new Set(records.map((r) => r.MaterialCode as string).filter(Boolean))
|
||||
]
|
||||
log.info(
|
||||
`Admin without selected managers: got ${materialCodes.length} materials from DiscreteMaterialPlanData`,
|
||||
{
|
||||
userId: username,
|
||||
isAdmin: true,
|
||||
orderCount: orderNumbers.length,
|
||||
materialCount: materialCodes.length
|
||||
}
|
||||
)
|
||||
return materialCodes
|
||||
}
|
||||
|
||||
// Regular user: filter MaterialsToBeDeleted by ManagerName = username
|
||||
if (dbService.type === 'sqlserver') {
|
||||
const sql = await import('mssql')
|
||||
const result = await (dbService as SqlServerService).queryWithParams(
|
||||
@@ -521,6 +568,24 @@ export class ValidationApplicationService {
|
||||
return materialCodes
|
||||
}
|
||||
|
||||
if (dbService.type === 'postgresql') {
|
||||
const result = await dbService.query(
|
||||
`
|
||||
SELECT "MaterialCode"
|
||||
FROM ${markedTableName}
|
||||
WHERE "ManagerName" = $1 AND "MaterialCode" IS NOT NULL
|
||||
`,
|
||||
[username]
|
||||
)
|
||||
const materialCodes = result.rows.map((row) => row.MaterialCode as string).filter(Boolean)
|
||||
log.info(`Regular user: got ${materialCodes.length} materials`, {
|
||||
userId: username,
|
||||
isAdmin: false,
|
||||
materialCount: materialCodes.length
|
||||
})
|
||||
return materialCodes
|
||||
}
|
||||
|
||||
const result = await dbService.query(
|
||||
`
|
||||
SELECT MaterialCode
|
||||
@@ -538,6 +603,57 @@ export class ValidationApplicationService {
|
||||
return materialCodes
|
||||
}
|
||||
|
||||
private async queryMaterialCodesByManagers(
|
||||
dbService: ValidationDatabaseService,
|
||||
tableName: string,
|
||||
managers: string[]
|
||||
): Promise<string[]> {
|
||||
if (dbService.type === 'sqlserver') {
|
||||
const sql = await import('mssql')
|
||||
const params: Record<string, { value: string; type: any }> = {}
|
||||
const paramNames = managers.map((m, i) => {
|
||||
const name = `@manager${i}`
|
||||
params[`manager${i}`] = { value: m, type: sql.default.NVarChar }
|
||||
return name
|
||||
})
|
||||
const result = await (dbService as SqlServerService).queryWithParams(
|
||||
`
|
||||
SELECT MaterialCode
|
||||
FROM ${tableName}
|
||||
WHERE ManagerName IN (${paramNames.join(', ')}) AND MaterialCode IS NOT NULL
|
||||
`,
|
||||
params
|
||||
)
|
||||
return result.rows
|
||||
.map((row: Record<string, unknown>) => row.MaterialCode as string)
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
if (dbService.type === 'postgresql') {
|
||||
const placeholders = managers.map((_, i) => `$${i + 1}`).join(', ')
|
||||
const result = await dbService.query(
|
||||
`
|
||||
SELECT "MaterialCode"
|
||||
FROM ${tableName}
|
||||
WHERE "ManagerName" IN (${placeholders}) AND "MaterialCode" IS NOT NULL
|
||||
`,
|
||||
managers
|
||||
)
|
||||
return result.rows.map((row) => row.MaterialCode as string).filter(Boolean)
|
||||
}
|
||||
|
||||
const placeholders = managers.map(() => '?').join(', ')
|
||||
const result = await dbService.query(
|
||||
`
|
||||
SELECT MaterialCode
|
||||
FROM ${tableName}
|
||||
WHERE ManagerName IN (${placeholders}) AND MaterialCode IS NOT NULL
|
||||
`,
|
||||
managers
|
||||
)
|
||||
return result.rows.map((row) => row.MaterialCode as string).filter(Boolean)
|
||||
}
|
||||
|
||||
private async disconnectQuietly(dbService: ValidationDatabaseService): Promise<void> {
|
||||
try {
|
||||
await dbService.disconnect()
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { ConfigManager } from '../config/config-manager'
|
||||
import { MySqlService } from '../database/mysql'
|
||||
import { SqlServerService } from '../database/sql-server'
|
||||
import { PostgreSqlService } from '../database/postgresql'
|
||||
|
||||
export type ValidationDatabaseService = MySqlService | SqlServerService
|
||||
export type ValidationDatabaseService = MySqlService | SqlServerService | PostgreSqlService
|
||||
|
||||
export async function createValidationDatabaseService(): Promise<ValidationDatabaseService> {
|
||||
const configManager = ConfigManager.getInstance()
|
||||
@@ -26,6 +27,19 @@ export async function createValidationDatabaseService(): Promise<ValidationDatab
|
||||
return sqlServerService
|
||||
}
|
||||
|
||||
if (dbType === 'postgresql') {
|
||||
const dbConfig = config.database.postgresql
|
||||
const pgService = new PostgreSqlService({
|
||||
host: dbConfig.host,
|
||||
port: dbConfig.port,
|
||||
user: dbConfig.username,
|
||||
password: dbConfig.password,
|
||||
database: dbConfig.database
|
||||
})
|
||||
await pgService.connect()
|
||||
return pgService
|
||||
}
|
||||
|
||||
const dbConfig = config.database.mysql
|
||||
const mysqlService = new MySqlService({
|
||||
host: dbConfig.host,
|
||||
@@ -42,14 +56,20 @@ export function getValidationTableName(mysqlTableName: string): string {
|
||||
const configManager = ConfigManager.getInstance()
|
||||
const dbType = configManager.getDatabaseType()
|
||||
|
||||
if (dbType === 'sqlserver') {
|
||||
if (dbType === 'sqlserver' || dbType === 'postgresql') {
|
||||
const firstUnderscoreIndex = mysqlTableName.indexOf('_')
|
||||
if (firstUnderscoreIndex > 0) {
|
||||
const schema = mysqlTableName.substring(0, firstUnderscoreIndex)
|
||||
const tableName = mysqlTableName.substring(firstUnderscoreIndex + 1)
|
||||
return `[${schema}].[${tableName}]`
|
||||
if (dbType === 'sqlserver') {
|
||||
return `[${schema}].[${tableName}]`
|
||||
}
|
||||
return `"${schema}"."${tableName}"`
|
||||
}
|
||||
return `[dbo].[${mysqlTableName}]`
|
||||
if (dbType === 'sqlserver') {
|
||||
return `[dbo].[${mysqlTableName}]`
|
||||
}
|
||||
return `"public"."${mysqlTableName}"`
|
||||
}
|
||||
|
||||
return mysqlTableName
|
||||
|
||||
@@ -10,23 +10,31 @@ export enum AuditAction {
|
||||
LOGOUT = 'LOGOUT',
|
||||
EXTRACT = 'EXTRACT',
|
||||
CLEAN = 'CLEAN',
|
||||
SETTINGS_CHANGE = 'SETTINGS_CHANGE'
|
||||
SETTINGS_CHANGE = 'SETTINGS_CHANGE',
|
||||
SYSTEM_CRASH = 'SYSTEM_CRASH',
|
||||
SYSTEM_ERROR = 'SYSTEM_ERROR',
|
||||
DATA_IMPORT = 'DATA_IMPORT',
|
||||
RESULT_EXPORT = 'RESULT_EXPORT',
|
||||
APP_UPDATE = 'APP_UPDATE',
|
||||
ERP_CREDENTIALS_UPDATE = 'ERP_CREDENTIALS_UPDATE'
|
||||
}
|
||||
|
||||
/**
|
||||
* Audit status enumeration
|
||||
* Note: Values are lowercase to match JSON logging conventions
|
||||
*/
|
||||
export enum AuditStatus {
|
||||
SUCCESS = 'SUCCESS',
|
||||
FAILURE = 'FAILURE'
|
||||
SUCCESS = 'success',
|
||||
FAILURE = 'failure',
|
||||
PARTIAL = 'partial'
|
||||
}
|
||||
|
||||
/**
|
||||
* Audit entry interface
|
||||
*/
|
||||
export interface AuditEntry {
|
||||
/** Timestamp of the action */
|
||||
timestamp: Date
|
||||
/** ISO timestamp of the action */
|
||||
timestamp: string
|
||||
/** Action performed */
|
||||
action: AuditAction
|
||||
/** User ID who performed the action */
|
||||
@@ -38,9 +46,9 @@ export interface AuditEntry {
|
||||
/** Application version when action was performed */
|
||||
appVersion: string
|
||||
/** Resource affected by the action */
|
||||
resource?: string
|
||||
resource: string
|
||||
/** Status of the action */
|
||||
status: AuditStatus
|
||||
/** Additional metadata in JSON format */
|
||||
metadata?: string
|
||||
/** Additional metadata */
|
||||
metadata: Record<string, unknown>
|
||||
}
|
||||
|
||||
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 {
|
||||
message: string
|
||||
@@ -30,6 +30,11 @@ export interface CleanerResult {
|
||||
// Retry statistics
|
||||
retriedOrders: 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 {
|
||||
@@ -39,6 +44,13 @@ export interface SkippedMaterial {
|
||||
reason: string
|
||||
}
|
||||
|
||||
export interface DeletedMaterial {
|
||||
materialCode: string
|
||||
materialName: string
|
||||
rowNumber: number
|
||||
outcome: string
|
||||
}
|
||||
|
||||
export interface RetryAttempt {
|
||||
attempt: number
|
||||
error: string
|
||||
@@ -51,11 +63,58 @@ export interface OrderCleanDetail {
|
||||
materialsSkipped: number
|
||||
errors: string[]
|
||||
skippedMaterials: SkippedMaterial[]
|
||||
deletedMaterials: DeletedMaterial[]
|
||||
// Retry-related fields
|
||||
retryCount: number
|
||||
retryAttempts?: RetryAttempt[]
|
||||
retriedAt?: number
|
||||
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,
|
||||
ExportResultResponse
|
||||
} from './cleaner.types'
|
||||
import type {
|
||||
CleanerBatchStats,
|
||||
CleanerExecutionRecord,
|
||||
CleanerOrderRecord,
|
||||
CleanerMaterialRecord
|
||||
} from './cleaner-history.types'
|
||||
import type { IpcResult } from './ipc.types'
|
||||
|
||||
/**
|
||||
@@ -113,6 +119,45 @@ export interface CleanerAPI {
|
||||
* @returns Unsubscribe function
|
||||
*/
|
||||
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 }>>
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -64,12 +64,10 @@ export interface SqlDialect {
|
||||
* MySQL/PostgreSQL: LIMIT x OFFSET y
|
||||
* SQL Server: OFFSET x ROWS FETCH NEXT y ROWS ONLY
|
||||
*/
|
||||
paginate(params: {
|
||||
paginate(params: { sql: string; limit: number; offset?: number; paramIndex: number }): {
|
||||
sql: string
|
||||
limit: number
|
||||
offset?: number
|
||||
paramIndex: number
|
||||
}): { sql: string; nextParamIndex: number }
|
||||
nextParamIndex: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Maximum rows per batch given columns per row
|
||||
|
||||
@@ -3,6 +3,14 @@ import type {
|
||||
CleanerProgress,
|
||||
ExportResultItem
|
||||
} 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 { invokeIpc, ipcRenderer } from '../lib/ipc'
|
||||
|
||||
@@ -15,5 +23,35 @@ export const cleanerApi = {
|
||||
callback(data)
|
||||
ipcRenderer.on(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
|
||||
|
||||
@@ -8,5 +8,6 @@ export const validationApi = {
|
||||
invokeIpc(IPC_CHANNELS.VALIDATION_SET_SHARED_PRODUCTION_IDS, productionIds),
|
||||
getSharedProductionIds: () => invokeIpc(IPC_CHANNELS.VALIDATION_GET_SHARED_PRODUCTION_IDS),
|
||||
clearSharedProductionIds: () => invokeIpc(IPC_CHANNELS.VALIDATION_CLEAR_SHARED_PRODUCTION_IDS),
|
||||
getCleanerData: () => invokeIpc(IPC_CHANNELS.VALIDATION_GET_CLEANER_DATA)
|
||||
getCleanerData: (params?: { selectedManagers?: string[] }) =>
|
||||
invokeIpc(IPC_CHANNELS.VALIDATION_GET_CLEANER_DATA, params ?? { selectedManagers: [] })
|
||||
} as const
|
||||
|
||||
87
src/preload/index.d.ts
vendored
87
src/preload/index.d.ts
vendored
@@ -59,7 +59,7 @@ export interface ValidationAPI {
|
||||
setSharedProductionIds: (productionIds: string[]) => Promise<IpcResult<void>>
|
||||
getSharedProductionIds: () => Promise<IpcResult<{ productionIds: string[] }>>
|
||||
clearSharedProductionIds: () => Promise<IpcResult<void>>
|
||||
getCleanerData: () => Promise<
|
||||
getCleanerData: (params?: { selectedManagers?: string[] }) => Promise<
|
||||
IpcResult<{
|
||||
orderNumbers: string[]
|
||||
materialCodes: string[]
|
||||
@@ -190,6 +190,91 @@ export interface OperationHistoryRecord {
|
||||
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 {
|
||||
versions: {
|
||||
electron: string
|
||||
|
||||
845
src/renderer/src/components/CleanerOperationHistoryModal.tsx
Normal file
845
src/renderer/src/components/CleanerOperationHistoryModal.tsx
Normal file
@@ -0,0 +1,845 @@
|
||||
/**
|
||||
* 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, useRef } from 'react'
|
||||
import { Modal } from './ui/Modal'
|
||||
import { useLogger } from '../hooks/useLogger'
|
||||
import {
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
CircleMinus,
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
Copy,
|
||||
FlaskConical
|
||||
} from 'lucide-react'
|
||||
import type { UserInfo } from './UserSelectionDialog'
|
||||
import type {
|
||||
CleanerHistoryBatchStats,
|
||||
CleanerHistoryOrderRecord,
|
||||
CleanerHistoryMaterialRecord
|
||||
} from '../hooks/cleaner/types'
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
const statusStyles: Record<string, string> = {
|
||||
success: 'bg-green-100 text-green-700',
|
||||
partial: 'bg-amber-100 text-amber-700',
|
||||
failed: 'bg-red-100 text-red-700',
|
||||
crashed: 'bg-red-100 text-red-700',
|
||||
pending: 'bg-gray-100 text-gray-700',
|
||||
not_found: 'bg-orange-100 text-orange-700',
|
||||
erp_not_found: 'bg-orange-100 text-orange-700'
|
||||
}
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
success: '成功',
|
||||
partial: '部分成功',
|
||||
failed: '失败',
|
||||
crashed: '崩溃',
|
||||
pending: '进行中',
|
||||
not_found: '未找到',
|
||||
erp_not_found: 'ERP不存在'
|
||||
}
|
||||
|
||||
const statusIcons: Record<string, React.ReactNode> = {
|
||||
success: <CheckCircle size={16} className="text-green-600" />,
|
||||
partial: <Clock size={16} className="text-amber-600" />,
|
||||
failed: <XCircle size={16} className="text-red-600" />,
|
||||
crashed: <XCircle size={16} className="text-red-600" />,
|
||||
pending: <Clock size={16} className="text-gray-500" />,
|
||||
not_found: <XCircle size={16} className="text-orange-600" />,
|
||||
erp_not_found: <XCircle size={16} className="text-orange-600" />
|
||||
}
|
||||
|
||||
const 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 }: 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 [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
const detailsLoadedRef = useRef(false)
|
||||
const loadedMaterialsRef = useRef<Set<string>>(new Set())
|
||||
const logger = useLogger('BatchItem')
|
||||
|
||||
// Fetch batch details when first expanded
|
||||
useEffect(() => {
|
||||
if (!isExpanded || detailsLoadedRef.current) return
|
||||
detailsLoadedRef.current = true
|
||||
|
||||
const fetchDetails = async () => {
|
||||
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)))
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Failed to fetch batch details', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
batchId: batch.batchId
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
void fetchDetails()
|
||||
}, [isExpanded, batch.batchId, logger])
|
||||
|
||||
const fetchMaterials = useCallback(
|
||||
async (attemptNumber: number, orderNumber: string) => {
|
||||
const cacheKey = `${attemptNumber}:${orderNumber}`
|
||||
if (loadedMaterialsRef.current.has(cacheKey)) return
|
||||
loadedMaterialsRef.current.add(cacheKey)
|
||||
|
||||
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!))
|
||||
}
|
||||
} catch (err) {
|
||||
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]
|
||||
)
|
||||
|
||||
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 = confirm('确定要删除此批次记录吗?此操作不可撤销。')
|
||||
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 handleCopyColumn = (field: keyof CleanerHistoryOrderRecord) => {
|
||||
const filtered =
|
||||
currentAttempt !== undefined
|
||||
? orders.filter((o) => o.attemptNumber === currentAttempt)
|
||||
: orders
|
||||
const values = filtered
|
||||
.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('复制失败,请手动复制'))
|
||||
}
|
||||
|
||||
const filteredOrders =
|
||||
currentAttempt !== undefined ? orders.filter((o) => o.attemptNumber === currentAttempt) : orders
|
||||
|
||||
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={() => setIsExpanded((prev) => !prev)}
|
||||
>
|
||||
<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">
|
||||
{statusIcons[batch.status] || statusIcons.pending}
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded text-xs font-medium ${
|
||||
statusStyles[batch.status] || statusStyles.pending
|
||||
}`}
|
||||
>
|
||||
{statusLabels[batch.status] || batch.status}
|
||||
</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">
|
||||
{executions
|
||||
.filter((e) => currentAttempt === undefined || e.attemptNumber === currentAttempt)
|
||||
.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 */}
|
||||
{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)
|
||||
|
||||
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 ${
|
||||
statusStyles[order.status] || statusStyles.pending
|
||||
}`}
|
||||
>
|
||||
{statusIcons[order.status]}
|
||||
{statusLabels[order.status] || order.status}
|
||||
</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>
|
||||
) : 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) => (
|
||||
<tr key={idx} className="hover:bg-gray-50">
|
||||
<td className="px-3 py-1.5 text-gray-500 font-medium text-xs text-center">
|
||||
{idx + 1}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 font-mono text-gray-700">
|
||||
{mat.materialCode}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-gray-700">
|
||||
{mat.materialName}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-gray-600">
|
||||
{mat.rowNumber}
|
||||
</td>
|
||||
<td className="px-3 py-1.5">
|
||||
{mat.result === 'success' || mat.result === 'deleted' ? (
|
||||
<span
|
||||
className="inline-block cursor-help"
|
||||
title="Deleted"
|
||||
>
|
||||
<CheckCircle
|
||||
size={16}
|
||||
className="text-green-600 flex-shrink-0"
|
||||
aria-label="Deleted"
|
||||
/>
|
||||
</span>
|
||||
) : mat.result === 'skipped' ? (
|
||||
<span
|
||||
className="inline-block cursor-help"
|
||||
title="Skipped"
|
||||
>
|
||||
<CircleMinus
|
||||
size={16}
|
||||
className="text-gray-400 flex-shrink-0"
|
||||
aria-label="Skipped"
|
||||
/>
|
||||
</span>
|
||||
) : mat.result === 'uncertain' ? (
|
||||
<span
|
||||
className="inline-block cursor-help"
|
||||
title="Uncertain"
|
||||
>
|
||||
<AlertTriangle
|
||||
size={16}
|
||||
className="text-amber-600 flex-shrink-0"
|
||||
aria-label="Uncertain"
|
||||
/>
|
||||
</span>
|
||||
) : mat.result?.startsWith('failed') ? (
|
||||
<span
|
||||
className="inline-block cursor-help"
|
||||
title="Failed"
|
||||
>
|
||||
<XCircle
|
||||
size={16}
|
||||
className="text-red-600 flex-shrink-0"
|
||||
aria-label="Failed"
|
||||
/>
|
||||
</span>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-gray-600 max-w-xs truncate">
|
||||
{mat.reason || '-'}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-gray-600">
|
||||
{mat.attemptCount > 1 ? (
|
||||
<span className="text-amber-600">
|
||||
{mat.attemptCount}
|
||||
</span>
|
||||
) : (
|
||||
'1'
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</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'
|
||||
|
||||
// ====== 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 logger = useLogger('CleanerOperationHistory')
|
||||
|
||||
const isAdmin = user?.userType === 'Admin'
|
||||
|
||||
const fetchBatches = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const options =
|
||||
isAdmin && selectedUsers.length > 0
|
||||
? { limit: 100, usernames: selectedUsers }
|
||||
: { limit: 100 }
|
||||
|
||||
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)
|
||||
}
|
||||
}, [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 toggleUserFilter = (username: string) => {
|
||||
setSelectedUsers((prev) =>
|
||||
prev.includes(username) ? prev.filter((u) => u !== username) : [...prev, username]
|
||||
)
|
||||
}
|
||||
|
||||
const clearUserFilters = () => {
|
||||
setSelectedUsers([])
|
||||
}
|
||||
|
||||
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>
|
||||
)}
|
||||
</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}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{/* Footer */}
|
||||
<div className="pt-4 border-t border-gray-200 flex justify-end">
|
||||
<button
|
||||
className="px-6 py-2 rounded-md bg-gray-100 hover:bg-gray-200 text-gray-700 font-medium transition-colors"
|
||||
onClick={onClose}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default CleanerOperationHistoryModal
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
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 type { CleanerProgress } from '../hooks/cleaner/types'
|
||||
|
||||
@@ -27,6 +27,9 @@ interface ExecutionReportDialogProps {
|
||||
// Retry-related props
|
||||
retriedOrders?: number
|
||||
successfulRetries?: number
|
||||
// Deletion verification props
|
||||
materialsFailed?: number
|
||||
uncertainDeletions?: number
|
||||
}
|
||||
|
||||
export const ExecutionReportDialog: React.FC<ExecutionReportDialogProps> = ({
|
||||
@@ -42,12 +45,15 @@ export const ExecutionReportDialog: React.FC<ExecutionReportDialogProps> = ({
|
||||
startTime = null,
|
||||
triggerRef,
|
||||
retriedOrders = 0,
|
||||
successfulRetries = 0
|
||||
successfulRetries = 0,
|
||||
materialsFailed = 0,
|
||||
uncertainDeletions = 0
|
||||
}) => {
|
||||
const [now, setNow] = React.useState(() => Date.now())
|
||||
|
||||
const hasErrors = errors.length > 0
|
||||
const hasRetries = retriedOrders > 0
|
||||
const hasFailedMaterials = materialsFailed > 0 || uncertainDeletions > 0
|
||||
const showProgress = isExecuting && progress
|
||||
const isProgressing = !!showProgress
|
||||
|
||||
@@ -327,6 +333,36 @@ export const ExecutionReportDialog: React.FC<ExecutionReportDialogProps> = ({
|
||||
</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>
|
||||
|
||||
{elapsedTime && (
|
||||
|
||||
@@ -4,8 +4,8 @@ import {
|
||||
Eye,
|
||||
EyeOff,
|
||||
FileSpreadsheet,
|
||||
FileText,
|
||||
HardDrive,
|
||||
History,
|
||||
Search,
|
||||
Settings2,
|
||||
Square
|
||||
@@ -26,7 +26,7 @@ interface CleanerToolbarProps {
|
||||
handleValidation: () => Promise<void>
|
||||
handleConfirmDeletion: () => Promise<void>
|
||||
handleExportResults: () => Promise<void>
|
||||
setIsReportViewerOpen: (open: boolean) => void
|
||||
setShowHistoryModal: (open: boolean) => void
|
||||
typeManagementButtonRef: React.RefObject<HTMLButtonElement | null>
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ export function CleanerToolbar({
|
||||
handleValidation,
|
||||
handleConfirmDeletion,
|
||||
handleExportResults,
|
||||
setIsReportViewerOpen,
|
||||
setShowHistoryModal,
|
||||
typeManagementButtonRef
|
||||
}: CleanerToolbarProps): React.JSX.Element {
|
||||
return (
|
||||
@@ -123,6 +123,12 @@ export function CleanerToolbar({
|
||||
>
|
||||
<Settings2 size={14} /> 类型管理
|
||||
</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
|
||||
onClick={() => void handleExportResults()}
|
||||
disabled={isExporting || filteredResults.length === 0}
|
||||
@@ -130,12 +136,6 @@ export function CleanerToolbar({
|
||||
>
|
||||
<FileSpreadsheet size={14} /> {isExporting ? '导出中...' : '导出结果'}
|
||||
</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>
|
||||
</>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user