31 Commits

Author SHA1 Message Date
Misaka_Company
d5028bfcf4 1.12.1 2026-04-14 14:54:29 +08:00
Misaka_Company
b2b29e9754 docs: add release notes for version 1.12.1
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 14:53:23 +08:00
Misaka_Company
cba3c8c4f0 feat(ui): use icons with tooltips for material results
- Replace text badges with icons for cleaner UI
- Map database values: 'success'→deleted, 'skipped', 'uncertain', 'failed_*'→failed
- Add hover tooltip showing status name (Deleted, Skipped, Uncertain, Failed)
- Icons: CheckCircle (green), CircleMinus (gray), AlertTriangle (amber), XCircle (red)
- Add cursor-help to indicate hoverable elements
2026-04-14 14:34:32 +08:00
Misaka_Company
343cb24234 chore: run pretier format across project
- Format TypeScript source files
- Format documentation files
- Update eslint config formatting
2026-04-14 14:03:58 +08:00
Misaka_Company
4ce5b91340 feat(ui): add serial number columns to cleaner operation history
- Add order-level serial number column in order table
- Add material-level serial number column in material details table
- Update colSpan from 10 to 11 to accommodate new column
2026-04-14 14:01:21 +08:00
Misaka_Company
1f033eb315 docs: add plans/ directory naming conventions
- Add date-prefixed naming format: YYYY-MM-DD-description-type.md
- Document -plan.md and -design.md type suffixes
- Add examples from existing plan files
- Update classification examples to include plans/ naming
2026-04-14 12:25:46 +08:00
Misaka_Company
681f3ba517 refactor(docs): reorganize documentation directory structure
- Create user/ - User guides and configuration documentation
- Create features/ - Feature specifications and business flows
- Create debugging/ - Debug guides and quick references
- Create testing/ - Test infrastructure, reports, and plans
- Create internal/ - Internal plans, analyses, and templates
- Move cleaner/*.md to cleaner/ directory
- Move LOGGING_*.md to developer/guides/

Add docs/README.md as documentation index with category navigation
and quick lookup guide.

The reorganized structure makes it easier for users and developers
to quickly locate relevant documentation.
2026-04-14 12:15:09 +08:00
Misaka_Company
0c6bb85e67 1.12.0 2026-04-14 10:51:21 +08:00
Misaka_Company
1a48dca57c docs: add release notes for version 1.12.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 10:50:05 +08:00
Misaka_Company
e91b7308a7 fix(tests): sync test expectations with current implementation
Update dialect tests for UTC timestamp functions and cleaner-handler
test for runCleaner's extended parameter signature.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 10:47:04 +08:00
Misaka_Company
35cad8baa9 style(cleaner-history): widen operation history modal to 140%
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 10:29:54 +08:00
Misaka_Company
6f49596467 feat(cleaner-history): record missing orders with production ID tracking
Record ALL input orders in history, including resolution failures (not_found)
and ERP query misses (erp_not_found). Add ProductionId column to track original
总排号 input. Add 总排号 column and new status styles to the history UI. Fix
empty result caching that prevented retry on transient query failures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 10:16:49 +08:00
Misaka
95eb44979a refactor(cleaner-history): extract BatchItem with React.memo and fix colSpan bug
- Fix colSpan mismatch: material detail row now correctly spans 9 columns
- Use lazy state initialization for Set/Map useState to avoid re-creation
- Remove data-duplicating refs (batchExecutionsRef, batchOrdersRef, orderMaterialsRef)
  and replace with lightweight tracking refs (detailsLoadedRef, loadedMaterialsRef)
- Extract per-batch rendering into BatchItem with React.memo to prevent
  sibling re-renders when expanding/collapsing one batch
- Reduce parent component state from 13 to 5 variables

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 23:09:18 +08:00
Misaka_Company
420f811488 fix(timezone): use UTC for database storage and local time for UI display
Backend changes:
- SQL Server dialect: GETDATE() → SYSUTCDATETIME()
- MySQL dialect: NOW() → UTC_TIMESTAMP()
- Ensures OperationTime and EndTime use consistent UTC timezone

Frontend changes:
- formatDateTime: display UTC timestamps in user's local timezone
- Uses getFullYear/getMonth/getDate/getHours (local) instead of UTC methods

Data migration:
- Executed migration script to fix historical OperationTime records
- All existing records now have correct UTC timestamps
- Execution duration now accurate (minutes, not hours)

Impact:
- New executions store UTC timestamps correctly
- UI displays times in user's local timezone (UTC+8 for CN users)
- Historical data corrected via migration
- Time difference between OperationTime and EndTime now accurate
2026-04-13 17:52:50 +08:00
Misaka_Company
6aa1fc29e5 feat(cleaner-history): display retry information in order history UI
- Add 'Retry' column to order history table
- Show retry count badge with refresh icon
- Display retry success/failure status with visual indicators
- Purple badge for retry count, green/red for success/failure
2026-04-13 16:09:39 +08:00
Misaka_Company
151485caed feat(cleaner): track skipped materials and skip DB writes on dry run
Record materials not in the deletion list as "skipped" with reason
instead of just logging them. Skip inserting material details to
database during dry runs to avoid phantom records.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 15:58:00 +08:00
Misaka_Company
116539ff42 feat(cleaner): record all material operations in database, including successful deletions
Previously only skipped and failed materials were persisted. Now every
material (deleted, uncertain, skipped, failed) is recorded in
CleanerMaterialDetail for full audit traceability.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 14:24:18 +08:00
Misaka_Company
0286df94dd fix(cleaner): cast BIT to INT for MAX() in getBatches query
SQL Server does not support MAX() on BIT columns, causing the
getBatches query to fail silently and return empty results.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 14:17:26 +08:00
Misaka_Company
32931cecad style: format changed files with prettier
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 12:35:31 +08:00
Misaka_Company
b95f12fca1 chore(cleaner): clean up legacy report viewer references
Remove ReportViewerDialog and ReportAnalysisDialog lazy imports, state
variables, Suspense wrappers, and the "查看报告" toolbar button. These
components were for the old Markdown file-based report viewer which has
been replaced by database-backed operation history.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 12:30:10 +08:00
Misaka_Company
4c40457c71 feat(cleaner): add operation history modal with database-backed records
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 12:23:57 +08:00
Misaka_Company
bd68444a74 feat(cleaner): add renderer types for cleaner operation history
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 12:09:47 +08:00
Misaka_Company
7dfa88c2a3 refactor(cleaner): remove Markdown report generator
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 12:06:19 +08:00
Misaka_Company
998edb4b84 refactor(cleaner): replace report generation with database persistence
Remove generateExecutionId(), generateAndUploadReport(), and all
executionId references from CleanerApplicationService. The service
now accepts batchId, historyDao, and appVersion from the IPC handler
and writes execution/order/material records to the database via
CleanerOperationHistoryDAO instead of generating Markdown reports.

All execution paths (success, failure, outer retry, retry-login-failure)
persist their results to the database with appropriate status tracking.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 12:02:43 +08:00
Misaka_Company
74096fbfa0 feat(cleaner): add preload API for cleaner operation history
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 11:52:14 +08:00
Misaka_Company
73656dada8 feat(cleaner): add IPC handlers for cleaner operation history
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 11:38:54 +08:00
Misaka_Company
9a91658121 feat(cleaner): add IPC channels for cleaner operation history
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 11:33:16 +08:00
Misaka_Company
a924e8a4e8 feat(cleaner): add CleanerOperationHistoryDAO for three-table persistence
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 11:29:54 +08:00
Misaka_Company
b363a53d8a feat(cleaner): add type definitions for cleaner operation history
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 11:18:56 +08:00
Misaka_Company
e1d55b8b39 feat(cleaner): add outer-level retry on fatal crash with execution ID
When CleanerService hits a fatal error (browser crash, timeout), the
outer catch now sets result.crashed=true. CleanerApplicationService
detects this, closes the dead browser session, re-logs into ERP, and
re-runs all orders once. An execution ID (CLN-yyyyMMddHHmmss-XXXX)
generated at startup ensures report files are deduplicated across
retries. Reports now display execution ID and app version.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 10:07:29 +08:00
Misaka
8b173890fa feat(cleaner): add multi-signal deletion verification with material-level retry
Replace fragile single-signal (row change only) deletion verification
with a robust multi-signal approach using row change + material count +
ERP message detection. Add material-level retry (up to 3 attempts) for
transient failures, with detailed tracking of failed/uncertain deletions.

- Add DeletionOutcome/DeletionErrorCategory enums and FailedMaterial type
- Add deleteWithVerification() core method with retry logic
- Add evaluateDeletionSignals() pure logic (unit tested, 9 cases)
- Add helper methods: readMaterialCount, checkErpMessages, handleConfirmDialog
- Extend CleanerResult/OrderCleanDetail with failed/uncertain tracking
- Update report generator with failed materials detail section
- Update ExecutionReportDialog to display failed/uncertain stats

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 21:55:47 +08:00
72 changed files with 5459 additions and 543 deletions

4
.gitignore vendored
View File

@@ -45,3 +45,7 @@ nul
# TypeScript incremental compilation cache
*.tsbuildinfo
# temporary files
tmp/
temp/

288
docs/README.md Normal file
View 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_

View File

@@ -78,7 +78,7 @@ flowchart TB
**差异总结**:
| 维度 | Admin | User |
|------|-------|------|
| ---------- | ----------------- | ------ |
| 侧边栏 | 有 CleanerSidebar | 无 |
| 管理员列表 | 查询全部负责人 | 不查询 |
| 默认选中 | 所有负责人 | 仅自己 |
@@ -120,7 +120,7 @@ flowchart TB
**匹配优先级说明**:
| 优先级 | 数据源 | 匹配方式 | 适用角色 |
|--------|--------|----------|----------|
| -------------- | -------------------------- | --------------------- | -------- |
| 1最高 | `MaterialsToBeDeleted` | MaterialCode 精确匹配 | 全部 |
| 2 | `MaterialsTypeToBeDeleted` | MaterialName 包含匹配 | 全部 |
| 3User 覆盖) | 当前用户的类型关键词 | MaterialName 包含匹配 | 仅 User |
@@ -186,7 +186,7 @@ const resultsToProcess = isAdmin ? validationResults : filteredResults
**差异总结**:
| 维度 | Admin | User |
|------|-------|------|
| ---------------- | --------------------------- | -------------------------------------- |
| 处理范围 | `validationResults`(全部) | `filteredResults`(自己的+无负责人的) |
| 可操作物料 | 所有负责人的物料 | 仅自己的 + 无负责人的 |
| 能否修改他人数据 | 是 | 否 |
@@ -196,6 +196,7 @@ const resultsToProcess = isAdmin ? 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`
@@ -258,7 +259,7 @@ flowchart TB
**差异总结**:
| 维度 | Admin有 selectedManagers | Admin无 selectedManagers | User |
|------|---------------------------|----------------------------|------|
| ---------- | ---------------------------- | -------------------------------------- | ------------------------------- |
| 数据源 | `MaterialsToBeDeleted` | `DiscreteMaterialPlanData` | `MaterialsToBeDeleted` |
| 查询条件 | `WHERE ManagerName IN (...)` | `WHERE SourceNumber IN (orderNumbers)` | `WHERE ManagerName = @username` |
| 可删除物料 | 选中负责人的物料 | 订单关联的全部物料 | 仅自己标记的物料 |
@@ -296,7 +297,7 @@ flowchart TB
## 涉及文件索引
| 文件 | 关键函数/逻辑 | 行号 |
|------|---------------|------|
| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------- |
| `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 |

View 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()
→ ② 插入 CleanerExecutionStatus=pending
→ ③ 插入 CleanerOrderHistory所有订单Status=pending
→ ④ 执行清理CleanerApplicationService.runCleaner
→ ⑤ 更新 CleanerExecutionStatus=success/failed/partial/crashed
→ ⑥ 更新 CleanerOrderHistory每个订单的结果
→ ⑦ 插入 CleanerMaterialDetail每个物料的操作明细
→ ⑧ 如果 crashed → 外层重试
→ 插入新的 CleanerExecutionAttemptNumber=2, Status=pending
→ 插入新的 CleanerOrderHistoryAttemptNumber=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 不再使用
- 后续可基于数据库重新实现统计分析
## 移除的概念
| 概念 | 原因 |
| ------------------------------ | --------------------------------- |
| ExecutionIdCLN-时间戳-随机) | 为文件名设计,数据库用 UUID |
| generateExecutionId() | 随 ExecutionId 一起移除 |
| CleanerReportGenerator | Markdown 报告生成器,被数据库替代 |
| generateAndUploadReport() | RustFS 上传链路,被数据库写入替代 |
| 报告文件名去重 | 数据库 UUID 天然唯一 |
| 重试覆盖旧报告 | 数据库保留所有尝试记录 |
## 不涉及的部分
- Extractor 的持久化逻辑不变
- 数据库 schema 迁移(需 DBA 创建表,应用层只做 CRUD
- 后续智能跳过功能(基于已有 success 记录跳过已成功的订单)
- 内层重试逻辑(订单级/物料级)不变

View 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 之后。

View 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 变更

19
docs/releases/1.12.0.md Normal file
View File

@@ -0,0 +1,19 @@
# 1.12.0
## 清理操作历史
- 新增操作历史面板,每次清理的执行记录、订单结果、物料明细均可回溯查看。
- 历史记录按批次归档,支持管理员查看所有用户记录、普通用户查看自己的记录。
- 批次支持展开查看多层详情:执行概况、订单状态、物料操作明细。
## 订单追踪
- 所有输入的订单(含总排号)均会记录在历史中,不再遗漏未找到或未匹配的订单。
- 总排号与订单号并列显示,未匹配的总排号标注为"未找到"ERP 中不存在的订单标注为"ERP 不存在"。
- 内层重试和外层崩溃重试信息在订单详情中完整展示。
## 改进
- 数据库时间统一使用 UTC 存储,界面显示本地时间。
- 试运行模式下跳过物料级别的数据库写入,避免产生无效记录。
- 操作历史面板加宽至 140%,改善订单表格的阅读体验。

6
docs/releases/1.12.1.md Normal file
View File

@@ -0,0 +1,6 @@
# 1.12.1
## 界面与交互
- 操作历史面板新增序号列,订单和物料明细表均可直观查看行号。
- 物料操作结果改用图标显示(已删除 / 已跳过 / 不确定 / 失败),悬停可查看状态名称。

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "erpauto",
"version": "1.11.1",
"version": "1.12.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "erpauto",
"version": "1.11.1",
"version": "1.12.1",
"hasInstallScript": true,
"dependencies": {
"@aws-sdk/client-s3": "^3.929.0",

View File

@@ -1,6 +1,6 @@
{
"name": "erpauto",
"version": "1.11.1",
"version": "1.12.1",
"description": "An Electron application with React and TypeScript",
"main": "./out/main/index.js",
"author": "example.com",

View File

@@ -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')
}
)

View 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')
}

View File

@@ -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')
}

View File

@@ -8,8 +8,6 @@ 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'
@@ -17,6 +15,7 @@ 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,
@@ -24,12 +23,19 @@ 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')
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: IDatabaseService | null = null
@@ -67,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,
@@ -104,7 +156,6 @@ export class CleanerApplicationService {
totalMaterialsInOrder: 0
})
const cleaner = new CleanerService(authService)
const modifiedInput: CleanerInput = {
...input,
orderNumbers: validOrderNumbers,
@@ -114,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]
@@ -134,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 {
@@ -269,6 +402,38 @@ export class CleanerApplicationService {
}
}
/**
* Build order inputs from ALL mappings, including resolution failures.
* Deduplicates by orderNumber for resolved mappings, includes all failed mappings.
*/
private buildOrderInputs(mappings: OrderMapping[]): InsertOrderInput[] {
const inputs: InsertOrderInput[] = []
const seenOrderNumbers = new Set<string>()
for (const mapping of mappings) {
if (mapping.resolved && mapping.orderNumber) {
// Deduplicate resolved mappings by order number
if (!seenOrderNumbers.has(mapping.orderNumber)) {
seenOrderNumbers.add(mapping.orderNumber)
inputs.push({
orderNumber: mapping.orderNumber,
productionId: mapping.productionId
})
}
} else {
// Resolution failure: use original input as orderNumber identifier
inputs.push({
orderNumber: mapping.input,
productionId: mapping.productionId,
initialStatus: 'not_found',
errorMessage: mapping.error || '未在数据库中找到对应的订单号'
})
}
}
return inputs
}
private async recordCleanupAudit(
orderCount: number,
input: CleanerInput,
@@ -297,69 +462,128 @@ export class CleanerApplicationService {
})
}
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
})
}
} catch (reportError) {
log.warn('Failed to generate report', {
error: reportError instanceof Error ? reportError.message : String(reportError)
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)
}
}
// 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
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -27,7 +27,7 @@ export class MySqlDialect implements SqlDialect {
}
currentTimestamp(): string {
return 'NOW()'
return 'UTC_TIMESTAMP()'
}
upsert(params: {

View File

@@ -26,7 +26,7 @@ export class SqlServerDialect implements SqlDialect {
}
currentTimestamp(): string {
return 'GETDATE()'
return 'SYSUTCDATETIME()'
}
upsert(params: {

View File

@@ -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,19 +304,74 @@ 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)
// Phase 1: Query orders
await trackDuration(async () => await this.queryOrders(workFrame, batchOrders), {
operationName: 'query',
message: '执行订单查询',
slowThresholdMs: 3000,
context: { orderCount: batchOrders.length }
})
const queriedRows = await this.collectQueryResultRows(workFrame)
// 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 () => {
return await this.openDetailPageFromRow(workFrame, popupPage!, rowIndex)
// [新增] Mutex 等待追踪
tracker.waitingForMutex()
const page = await this.openDetailPageFromRow(workFrame, popupPage!, rowIndex)
// [新增] Mutex 获取追踪
tracker.acquiredMutex()
return page
})
let detail: OrderCleanDetail
@@ -278,7 +388,10 @@ export class CleanerService {
const message = error instanceof Error ? error.message : 'Unknown error'
detail = this.createErrorDetail(orderNumber, message)
} finally {
progressState.completedOrders += 1
progressState.ordersStarted += 1
progressState.ordersCompleted += 1
progressState.lastCompletedOrder = orderNumber
lastActivityTime = Date.now() // [新增] 健康检查:更新活动时间
}
result.details.push(detail)
@@ -291,15 +404,32 @@ export class CleanerService {
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))
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`,
message: `Batch ${batchIndex + 1}/${orderBatches.length}`,
@@ -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) => {

View File

@@ -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}`)
}

View File

@@ -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

View File

@@ -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')
}

View File

@@ -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}`
}
}

View File

@@ -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)
})

View 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[]
}

View File

@@ -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
}
/**

View File

@@ -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 }>>
}
/**

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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 && (

View File

@@ -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>
</>

View File

@@ -15,6 +15,8 @@ interface ModalProps {
title?: string
children: React.ReactNode
size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl'
/** Additional CSS classes for the modal panel */
className?: string
showCloseButton?: boolean
/** Ref to the element that triggered opening the modal (for focus restoration) */
triggerRef?: React.RefObject<HTMLElement | null>
@@ -47,6 +49,7 @@ export function Modal({
title,
children,
size = 'md',
className,
showCloseButton = true,
triggerRef,
titleId,
@@ -98,7 +101,7 @@ export function Modal({
<div className="flex min-h-full items-center justify-center p-4">
<div
ref={dialogRef}
className={`relative w-full ${sizeStyles[size]} bg-white rounded-lg shadow-xl transform transition-all`}
className={`relative w-full ${sizeStyles[size]} bg-white rounded-lg shadow-xl transform transition-all ${className || ''}`}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}

View File

@@ -20,6 +20,8 @@ interface CleanerRunPayload {
errors: string[]
retriedOrders: number
successfulRetries: number
materialsFailed?: number
uncertainDeletions?: number
}
export async function initializeCleanerPage(): Promise<CleanerInitializationResult> {
@@ -161,7 +163,9 @@ export async function runCleanerExecution(params: {
materialsSkipped: cleanerRunData.materialsSkipped,
errors: cleanerRunData.errors,
retriedOrders: cleanerRunData.retriedOrders,
successfulRetries: cleanerRunData.successfulRetries
successfulRetries: cleanerRunData.successfulRetries,
materialsFailed: cleanerRunData.materialsFailed ?? 0,
uncertainDeletions: cleanerRunData.uncertainDeletions ?? 0
}
}

View File

@@ -35,7 +35,7 @@ export interface CleanerProgress {
currentMaterialIndex: number
totalMaterialsInOrder: number
currentOrderNumber?: string
phase: 'login' | 'processing' | 'complete'
phase: 'login' | 'processing' | 'complete' | 'retry'
}
export interface CleanerReportData {
@@ -45,6 +45,9 @@ export interface CleanerReportData {
errors: string[]
retriedOrders?: number
successfulRetries?: number
materialsFailed?: number
uncertainDeletions?: number
crashed?: boolean
}
export interface CleanerInitializationResult {
@@ -58,3 +61,71 @@ export interface CleanerConfigResult {
queryBatchSize: number
processConcurrency: number
}
// Cleaner operation history types (mirrors preload/index.d.ts)
export interface CleanerHistoryBatchStats {
batchId: string
userId: number
username: string
operationTime: string
status: string
totalAttempts: number
totalOrders: number
ordersProcessed: number
totalMaterialsDeleted: number
totalMaterialsFailed: number
successCount: number
failedCount: number
isDryRun: boolean
}
export interface CleanerHistoryExecutionRecord {
id?: number
batchId: string
attemptNumber: number
userId: number
username: string
operationTime: string
endTime: string | null
status: string
isDryRun: boolean
totalOrders: number
ordersProcessed: number
totalMaterialsDeleted: number
totalMaterialsSkipped: number
totalMaterialsFailed: number
totalUncertainDeletions: number
errorMessage: string | null
appVersion: string | null
}
export interface CleanerHistoryOrderRecord {
id?: number
batchId: string
attemptNumber: number
orderNumber: string
productionId: string | null
status: string
materialsDeleted: number
materialsSkipped: number
materialsFailed: number
uncertainDeletions: number
retryCount: number
retrySuccess: boolean
errorMessage: string | null
}
export interface CleanerHistoryMaterialRecord {
id?: number
batchId: string
attemptNumber: number
orderNumber: string
materialCode: string
materialName: string
rowNumber: number
result: string
reason: string | null
attemptCount: number
finalErrorCategory: string | null
}

View File

@@ -10,8 +10,9 @@ const MaterialTypeManagementDialog = React.lazy(
() => import('../components/MaterialTypeManagementDialog')
)
const ExecutionReportDialog = React.lazy(() => import('../components/ExecutionReportDialog'))
const ReportViewerDialog = React.lazy(() => import('../components/ReportViewerDialog'))
const ReportAnalysisDialog = React.lazy(() => import('../components/ReportAnalysisDialog'))
const CleanerOperationHistoryModal = React.lazy(
() => import('../components/CleanerOperationHistoryModal')
)
const CleanerPage: React.FC = () => {
const typeManagementButtonRef = React.useRef<HTMLButtonElement>(null)
@@ -66,8 +67,7 @@ const CleanerPage: React.FC = () => {
confirmDialog
} = useCleaner()
const [isReportViewerOpen, setIsReportViewerOpen] = React.useState(false)
const [isReportAnalysisOpen, setIsReportAnalysisOpen] = React.useState(false)
const [showHistoryModal, setShowHistoryModal] = React.useState(false)
return (
<div className="h-full flex flex-col xl:flex-row gap-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
@@ -96,7 +96,7 @@ const CleanerPage: React.FC = () => {
handleValidation={handleValidation}
handleConfirmDeletion={handleConfirmDeletion}
handleExportResults={handleExportResults}
setIsReportViewerOpen={setIsReportViewerOpen}
setShowHistoryModal={setShowHistoryModal}
typeManagementButtonRef={typeManagementButtonRef}
/>
@@ -162,24 +162,20 @@ const CleanerPage: React.FC = () => {
triggerRef={executeButtonRef}
retriedOrders={reportData?.retriedOrders}
successfulRetries={reportData?.successfulRetries}
materialsFailed={reportData?.materialsFailed}
uncertainDeletions={reportData?.uncertainDeletions}
/>
</Suspense>
<Suspense fallback={null}>
<ReportViewerDialog
isOpen={isReportViewerOpen}
onClose={() => setIsReportViewerOpen(false)}
isAdmin={isAdmin}
currentUsername={currentUsername}
onOpenAnalysis={() => setIsReportAnalysisOpen(true)}
/>
</Suspense>
<Suspense fallback={null}>
<ReportAnalysisDialog
isOpen={isReportAnalysisOpen}
onClose={() => setIsReportAnalysisOpen(false)}
isAdmin={isAdmin}
<CleanerOperationHistoryModal
isOpen={showHistoryModal}
onClose={() => setShowHistoryModal(false)}
user={
currentUsername
? { username: currentUsername, userType: isAdmin ? 'Admin' : 'User' }
: null
}
/>
</Suspense>

View File

@@ -21,6 +21,12 @@ export const IPC_CHANNELS = {
CLEANER_EXPORT_RESULTS: 'cleaner:exportResults',
CLEANER_PROGRESS: 'cleaner:progress',
// Cleaner operation history
CLEANER_HISTORY_GET_BATCHES: 'cleanerHistory:getBatches',
CLEANER_HISTORY_GET_BATCH_DETAILS: 'cleanerHistory:getBatchDetails',
CLEANER_HISTORY_GET_MATERIAL_DETAILS: 'cleanerHistory:getMaterialDetails',
CLEANER_HISTORY_DELETE_BATCH: 'cleanerHistory:deleteBatch',
// Database service - MySQL
DATABASE_MYSQL_CONNECT: 'database:mysql:connect',
DATABASE_MYSQL_DISCONNECT: 'database:mysql:disconnect',

View File

@@ -8,11 +8,23 @@ const withErrorHandlingMock = vi.fn(async (handler: () => Promise<unknown>) => {
})
const serviceMethods = {
runCleaner: vi.fn(async (_sender: unknown, input: unknown) => ({ success: true, input })),
runCleaner: vi.fn(
async (
_sender: unknown,
input: unknown,
_batchId: string,
_historyDao: unknown,
_appVersion: string
) => ({ success: true, input })
),
exportResults: vi.fn(async (items: unknown[]) => ({ success: true, total: items.length }))
}
vi.mock('electron', () => ({
app: {
isPackaged: false,
getVersion: () => 'test-version'
},
ipcMain: {
handle: handleMock
}
@@ -51,7 +63,14 @@ describe('registerCleanerHandlers', () => {
const result = await runHandler?.(event, input)
expect(serviceMethods.runCleaner).toHaveBeenCalledWith(sender, input)
expect(serviceMethods.runCleaner).toHaveBeenCalledTimes(1)
const actualCall = serviceMethods.runCleaner.mock.calls[0]
expect(actualCall[0]).toBe(sender)
expect(actualCall[1]).toEqual(input)
expect(typeof actualCall[2]).toBe('string') // batchId is UUID
expect(actualCall[3]).toBeDefined() // historyDao
expect(actualCall[4]).toBe('test-version') // appVersion
expect(result).toEqual({
success: true,
data: { success: true, input }

View File

@@ -46,8 +46,8 @@ describe('MySqlDialect', () => {
})
describe('currentTimestamp', () => {
it('should return NOW()', () => {
expect(dialect.currentTimestamp()).toBe('NOW()')
it('should return UTC_TIMESTAMP()', () => {
expect(dialect.currentTimestamp()).toBe('UTC_TIMESTAMP()')
})
})

View File

@@ -45,8 +45,8 @@ describe('SqlServerDialect', () => {
})
describe('currentTimestamp', () => {
it('should return GETDATE()', () => {
expect(dialect.currentTimestamp()).toBe('GETDATE()')
it('should return SYSUTCDATETIME()', () => {
expect(dialect.currentTimestamp()).toBe('SYSUTCDATETIME()')
})
})

View File

@@ -37,6 +37,34 @@ vi.mock('../../../../src/main/services/erp/order-resolver', () => {
}
})
// Mock CleanerOperationHistoryDAO
vi.mock('../../../../src/main/services/database/cleaner-operation-history-dao', () => {
return {
CleanerOperationHistoryDAO: class {
async insertExecution() {
return true
}
async insertOrderRecords() {
return true
}
async updateOrderStatus() {
return true
}
async insertMaterialDetails() {
return true
}
async updateExecutionStatus() {
return true
}
async getBatchDetails() {
return {
executions: [{ attemptNumber: 1, isDryRun: false }]
}
}
}
}
})
// Control whether CleanerService.clean should throw
let cleanerShouldThrow = false
let cleanerError: Error = new Error('cleaner crashed')
@@ -108,6 +136,16 @@ function setupServiceMocks(service: CleanerApplicationService) {
})
;(service as any).recordCleanupAudit = vi.fn().mockResolvedValue(undefined)
;(service as any).generateAndUploadReport = vi.fn().mockResolvedValue(undefined)
;(service as any).getHistoryDao = vi.fn().mockReturnValue({
insertExecution: vi.fn().mockResolvedValue(true),
insertOrderRecords: vi.fn().mockResolvedValue(true),
updateOrderStatus: vi.fn().mockResolvedValue(true),
insertMaterialDetails: vi.fn().mockResolvedValue(true),
updateExecutionStatus: vi.fn().mockResolvedValue(true),
getBatchDetails: vi.fn().mockResolvedValue({
executions: [{ attemptNumber: 1, isDryRun: false }]
})
})
}
function makeInput(overrides: Record<string, any> = {}) {
@@ -204,7 +242,7 @@ describe('CleanerApplicationService', () => {
).rejects.toBeInstanceOf(DatabaseQueryError)
})
it('should reject with ValidationError when no valid order numbers are provided', async () => {
it('should return empty result when no valid order numbers are provided', async () => {
;(service as any).getErpConfig = vi
.fn()
.mockResolvedValue({ url: 'u', username: 'x', password: 'p' })
@@ -212,9 +250,13 @@ describe('CleanerApplicationService', () => {
disconnect: vi.fn().mockResolvedValue(undefined)
})
await expect(
service.runCleaner({ send: vi.fn() } as any, makeInput({ orderNumbers: [] }))
).rejects.toThrow('没有有效的生产订单号可处理')
const result = await service.runCleaner(
{ send: vi.fn() } as any,
makeInput({ orderNumbers: [] })
)
expect(result.ordersProcessed).toBe(0)
expect(result.materialsDeleted).toBe(0)
expect(result.details).toEqual([])
})
it('should process orders containing empty strings without crashing', async () => {

View File

@@ -0,0 +1,203 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { CleanerApplicationService } from '../../../../src/main/services/cleaner/cleaner-application-service'
import { CleanerOperationHistoryDAO } from '../../../../src/main/services/database/cleaner-operation-history-dao'
// Mock 所有依赖
vi.mock('../../../../src/main/services/config/config-manager', () => ({
ConfigManager: {
getInstance: () => ({
getDatabaseType: () => 'mysql',
getConfig: () => ({ database: { activeType: 'mysql' } })
})
}
}))
vi.mock('../../../../src/main/services/erp/order-resolver', () => ({
OrderNumberResolver: class {
constructor(_dbService: any) {}
async resolve(orderNumbers: string[]) {
return orderNumbers
}
getValidOrderNumbers(mappings: string[]) {
return mappings
}
getWarnings(_mappings: any[]) {
return []
}
}
}))
let mockInsertedMaterials: any[] = []
vi.mock('../../../../src/main/services/erp/cleaner', () => ({
CleanerService: class {
constructor(_erpAuth: any) {}
async clean(input: any) {
// 模拟一个订单有 deleted 和 skipped 物料
return {
ordersProcessed: 1,
materialsDeleted: 1,
materialsSkipped: 1,
materialsFailed: 0,
uncertainDeletions: 0,
errors: [],
details: [
{
orderNumber: 'SC70202604080195',
materialsDeleted: 1,
materialsSkipped: 1,
materialsFailed: 0,
uncertainDeletions: 0,
errors: [],
deletedMaterials: [
{
materialCode: 'MAT001',
materialName: '测试物料 1',
rowNumber: 10,
outcome: 'success'
}
],
skippedMaterials: [
{
materialCode: 'MAT002',
materialName: '测试物料 2',
rowNumber: 2500,
reason: '行号在 2000-7999 范围内(受保护)'
}
],
failedMaterials: [],
retryCount: 0,
retrySuccess: false
}
],
retriedOrders: 0,
successfulRetries: 0,
crashed: false
} as any
}
}
}))
vi.mock('../../../../src/main/services/erp/erp-auth', () => ({
ErpAuthService: class {
constructor(_config: any) {}
async login() {
return Promise.resolve(undefined)
}
async close() {
return Promise.resolve(undefined)
}
}
}))
vi.mock('../../../../src/main/services/database/cleaner-operation-history-dao', () => ({
CleanerOperationHistoryDAO: class {
async getBatchDetails(batchId: string) {
return { executions: [{ attemptNumber: 1, isDryRun: false }], orders: [] }
}
async insertExecution(input: any) {
return true
}
async insertOrderRecords(batchId: string, attemptNumber: number, orders: any[]) {
return true
}
async updateOrderStatus(
batchId: string,
attemptNumber: number,
orderNumber: string,
status: string,
materialsDeleted: number,
materialsSkipped: number,
materialsFailed: number,
uncertainDeletions: number,
retryCount: number,
retrySuccess: boolean,
errorMessage?: string
) {
return true
}
async insertMaterialDetails(batchId: string, attemptNumber: number, details: any[]) {
mockInsertedMaterials = [...details]
return true
}
async updateExecutionStatus(
batchId: string,
attemptNumber: number,
status: string,
ordersProcessed: number,
materialsDeleted: number,
materialsSkipped: number,
materialsFailed: number,
uncertainDeletions: number,
endTime: Date,
errorMessage?: string
) {
return true
}
}
}))
vi.mock('../../../../src/main/services/logger/audit-logger', () => ({
logAuditWithCurrentUser: () => {}
}))
vi.mock('../../../../src/main/services/user/session-manager', () => ({
SessionManager: {
getInstance: () => ({
getUserInfo: () => ({ id: 1, username: 'test' })
})
}
}))
function setupServiceMocks(service: CleanerApplicationService) {
;(service as any).getErpConfig = vi
.fn()
.mockResolvedValue({ url: 'http://erp', username: 'u', password: 'p' })
;(service as any).getDatabaseService = vi.fn().mockResolvedValue({ disconnect: vi.fn() })
;(service as any).recordCleanupAudit = vi.fn()
}
function makeInput(overrides: any = {}) {
return {
orderNumbers: ['SC70202604080195'],
materialCodes: ['MAT001', 'MAT002'],
dryRun: false,
queryBatchSize: 100,
processConcurrency: 1,
onProgress: vi.fn(),
...overrides
}
}
describe('CleanerApplicationService - Skipped Materials', () => {
let service: CleanerApplicationService
let historyDao: CleanerOperationHistoryDAO
beforeEach(() => {
service = new CleanerApplicationService()
historyDao = new CleanerOperationHistoryDAO()
mockInsertedMaterials = []
setupServiceMocks(service)
})
it('should save skipped materials to database', async () => {
const eventSender: any = { send: vi.fn() }
const result = await service.runCleaner(
eventSender,
makeInput(),
'TEST-BATCH',
historyDao,
'1.0.0'
)
expect(result.materialsSkipped).toBe(1)
expect(mockInsertedMaterials.length).toBe(2) // 1 deleted + 1 skipped
const skippedMaterial = mockInsertedMaterials.find((m) => m.result === 'skipped')
expect(skippedMaterial).toBeDefined()
expect(skippedMaterial!.materialCode).toBe('MAT002')
expect(skippedMaterial!.materialName).toBe('测试物料 2')
expect(skippedMaterial!.rowNumber).toBe(2500)
expect(skippedMaterial!.reason).toBe('行号在 2000-7999 范围内(受保护)')
})
})

View File

@@ -1,4 +1,5 @@
import { describe, it, expect } from 'vitest'
import { DeletionErrorCategory, DeletionOutcome } from '../../../../src/main/types/cleaner.types'
import {
CleanerService,
createBatches,
@@ -270,3 +271,104 @@ describe('CleanerService - Helper Methods', () => {
})
})
})
describe('CleanerService - evaluateDeletionSignals()', () => {
const cleaner = new CleanerService({} as any, { dryRun: false })
it('should return FailedErpError when error is detected', () => {
const result = cleaner.evaluateDeletionSignals({
rowChanged: false,
countDecreased: false,
hasError: true,
errorText: '物料已被引用'
})
expect(result.outcome).toBe(DeletionOutcome.FailedErpError)
expect(result.errorCategory).toBe(DeletionErrorCategory.ErpRejection)
expect(result.errorMessage).toBe('物料已被引用')
})
it('should return FailedErpError with default message when no errorText', () => {
const result = cleaner.evaluateDeletionSignals({
rowChanged: false,
countDecreased: false,
hasError: true
})
expect(result.outcome).toBe(DeletionOutcome.FailedErpError)
expect(result.errorMessage).toBe('ERP returned an error')
})
it('should return Success when row changed and count decreased (true)', () => {
const result = cleaner.evaluateDeletionSignals({
rowChanged: true,
countDecreased: true,
hasError: false
})
expect(result.outcome).toBe(DeletionOutcome.Success)
expect(result.errorCategory).toBeUndefined()
})
it('should return Success when row changed and count is unreadable (null)', () => {
const result = cleaner.evaluateDeletionSignals({
rowChanged: true,
countDecreased: null,
hasError: false
})
expect(result.outcome).toBe(DeletionOutcome.Success)
})
it('should return Uncertain when row changed but count did not decrease', () => {
const result = cleaner.evaluateDeletionSignals({
rowChanged: true,
countDecreased: false,
hasError: false
})
expect(result.outcome).toBe(DeletionOutcome.Uncertain)
})
it('should return Success when row did not change but count decreased', () => {
const result = cleaner.evaluateDeletionSignals({
rowChanged: false,
countDecreased: true,
hasError: false
})
expect(result.outcome).toBe(DeletionOutcome.Success)
})
it('should return FailedNoChange when nothing changed', () => {
const result = cleaner.evaluateDeletionSignals({
rowChanged: false,
countDecreased: false,
hasError: false
})
expect(result.outcome).toBe(DeletionOutcome.FailedNoChange)
expect(result.errorCategory).toBe(DeletionErrorCategory.Unknown)
})
it('should return FailedNoChange when row did not change and count is null', () => {
const result = cleaner.evaluateDeletionSignals({
rowChanged: false,
countDecreased: null,
hasError: false
})
expect(result.outcome).toBe(DeletionOutcome.FailedNoChange)
})
it('should prioritize error signal over all others', () => {
const result = cleaner.evaluateDeletionSignals({
rowChanged: true,
countDecreased: true,
hasError: true,
errorText: 'Some error'
})
expect(result.outcome).toBe(DeletionOutcome.FailedErpError)
})
})