diff --git a/docs/extractor-start-button-flow.md b/docs/extractor-start-button-flow.md
index a30703d..7507641 100644
--- a/docs/extractor-start-button-flow.md
+++ b/docs/extractor-start-button-flow.md
@@ -1,19 +1,24 @@
# 数据提取界面 - 开始按钮工作流程详解
-> **文档版本**: 1.2
-> **更新日期**: 2026-03-03
+> **文档版本**: 1.3
+> **更新日期**: 2026-03-04
> **适用范围**: ERPAuto v1.0+
> **相关文件**:
>
-> - `src/renderer/src/pages/ExtractorPage.tsx` (UI层)
+> - `src/renderer/src/pages/ExtractorPage.tsx` (UI 层)
> - `src/preload/index.ts` (IPC API 暴露)
-> - `src/main/ipc/extractor-handler.ts` (IPC处理层)
-> - `src/main/services/erp/extractor.ts` (业务逻辑层)
+> - `src/main/ipc/extractor-handler.ts` (IPC 处理层)
+> - `src/main/services/erp/extractor.ts` (业务逻辑层 - 协调文件合并和数据库导入)
+> - `src/main/services/erp/extractor-core.ts` (浏览器自动化核心)
> - `src/main/services/erp/order-resolver.ts` (订单号解析服务)
-> - `src/main/services/erp/erp-auth.ts` (ERP认证服务)
+> - `src/main/services/erp/erp-auth.ts` (ERP 认证服务)
+> - `src/main/services/database/data-importer.ts` (数据库导入服务)
+> - `src/main/services/database/discrete-material-plan-dao.ts` (数据访问层)
+> - `src/main/services/excel/excel-parser.ts` (Excel 解析器)
> - `src/main/services/database/index.ts` (数据库工厂)
> - `src/main/types/database.types.ts` (数据库类型定义)
> - `src/main/types/extractor.types.ts` (类型定义)
+> - `src/main/types/errors.ts` (错误类型定义)
## 目录
@@ -31,10 +36,10 @@
```mermaid
graph TB
- subgraph "Renderer Process (UI层)"
+ subgraph "Renderer Process (UI 层)"
A[ExtractorPage.tsx]
B[React State Management]
- C[用户输入: 订单号列表]
+ C[用户输入:订单号列表]
D[开始按钮]
end
@@ -48,57 +53,82 @@ graph TB
H[OrderNumberResolver]
I[ErpAuthService]
J[ExtractorService]
+ K[ExtractorCore]
+ L[DataImportService]
end
subgraph "Database Layer (数据库层)"
- N["DatabaseFactory
- create()"]
- O["IDatabaseService
- 统一接口"]
+ N["DatabaseFactory
create()"]
+ O["IDatabaseService
统一接口"]
P1[(MySQL)]
P2[(SQL Server)]
+ Q[DiscreteMaterialPlanDAO]
+ end
+
+ subgraph "File Processing (文件处理)"
+ R[ExcelParser]
+ S[ExcelJS]
end
subgraph "External Services (外部服务)"
- L[ERP Web System]
- M[Playwright Browser]
+ T[ERP Web System]
+ U[Playwright Browser]
end
- A -->|用户点击| D
- D -->|调用API| E
- E -->|IPC通信| F
- F -->|接收请求| G
- G -->|解析订单号| H
- H -->|创建服务| N
- N -->|返回实例| O
- O -->|查询数据| P1
- O -->|查询数据| P2
- G -->|登录认证| I
- I -->|自动化操作| M
- M -->|访问页面| L
- G -->|执行提取| J
- J -->|使用| I
- J -->|返回结果| G
- G -->|IPC响应| F
- F -->|更新UI| A
- B -->|管理状态| A
+ A -->|用户点击 | D
+ D -->|调用 API| E
+ E -->|IPC 通信 | F
+ F -->|接收请求 | G
+ G -->|解析订单号 | H
+ H -->|创建服务 | N
+ N -->|返回实例 | O
+ O -->|查询数据 | P1
+ O -->|查询数据 | P2
+ G -->|登录认证 | I
+ I -->|自动化操作 | U
+ U -->|访问页面 | T
+ G -->|执行提取 | J
+ J -->|使用 | I
+ J -->|浏览器自动化 | K
+ K -->|下载文件 | S
+ J -->|合并文件 | R
+ R -->|解析 Excel| S
+ J -->|导入数据 | L
+ L -->|写入数据库 | Q
+ J -->|返回结果 | G
+ G -->|IPC 响应 | F
+ F -->|更新 UI| A
+ B -->|管理状态 | A
style A fill:#e1f5ff
style G fill:#fff4e1
+ style J fill:#fff4e1
+ style K fill:#fff4e1
style N fill:#e8f5e9
style O fill:#e8f5e9
- style L fill:#f3e5f5
+ style Q fill:#e8f5e9
+ style R fill:#f3e5f5
+ style S fill:#f3e5f5
+ style L fill:#e8f5e9
+ style T fill:#f3e5f5
```
### 架构说明
-- **Renderer Process**: 负责UI展示和用户交互,使用React管理状态
-- **IPC Bridge**: 安全的进程间通信桥梁,通过preload脚本暴露
+- **Renderer Process**: 负责 UI 展示和用户交互,使用 React 管理状态
+- **IPC Bridge**: 安全的进程间通信桥梁,通过 preload 脚本暴露
- **Main Process**: 处理业务逻辑、数据库操作、浏览器自动化
+ - `ExtractorService`: 协调提取流程(调用 ExtractorCore、文件合并、数据库导入)
+ - `ExtractorCore`: 纯浏览器自动化操作(导航、搜索、下载)
+ - `DataImportService`: 将合并后的 Excel 数据导入数据库
- **Database Layer**: 数据库抽象层,通过工厂模式创建服务实例
- 支持 MySQL 和 SQL Server 双数据库
- 通过 `IDatabaseService` 统一接口操作
- 由 `DB_TYPE` 环境变量决定使用哪种数据库
+ - `DiscreteMaterialPlanDAO`: 备料计划数据访问对象
+- **File Processing**: Excel 文件处理
+ - `ExcelParser`: 解析和合并 Excel 文件
+ - `ExcelJS`: Excel 文件读写库
- **External Services**: ERP Web 系统
---
@@ -117,29 +147,34 @@ sequenceDiagram
participant Database as IDatabaseService
participant Auth as ErpAuthService
participant Extractor as ExtractorService
+ participant Core as ExtractorCore
+ participant Parser as ExcelParser
+ participant Importer as DataImportService
participant Browser as Playwright Browser
participant ERP as ERP Web System
+ participant Excel as Excel Files
+ participant DAO as DiscreteMaterialPlanDAO
User->>UI: 1. 输入订单号列表
(每行一个)
User->>UI: 2. 点击"开始提取"按钮
Note over UI: 前端验证与准备
UI->>UI: 3. 验证订单号非空
- UI->>UI: 4. 设置isRunning=true
+ UI->>UI: 4. 设置 isRunning=true
UI->>UI: 5. 清空之前的结果和错误
- UI->>UI: 6. 存储Production IDs到共享状态
- UI->>IPC: 7. 调用electron.extractor.runExtractor()
+ UI->>UI: 6. 存储 Production IDs 到共享状态
+ UI->>IPC: 7. 调用 electron.extractor.runExtractor()
- Note over IPC,Handler: IPC通信
- IPC->>Handler: 8. 发送IPC消息 'extractor:run'
+ Note over IPC,Handler: IPC 通信
+ IPC->>Handler: 8. 发送 IPC 消息 'extractor:run'
Note over Handler: 环境配置检查
- Handler->>Handler: 9. 读取.env配置
(ERP_URL, USERNAME, PASSWORD)
+ Handler->>Handler: 9. 读取.env 配置
(ERP_URL, USERNAME, PASSWORD)
Handler->>Handler: 10. 验证配置完整性
alt 配置不完整
- Handler-->>UI: 返回ValidationError
+ Handler-->>UI: 返回 ValidationError
UI->>UI: 显示错误提示
- UI->>UI: 设置isRunning=false
+ UI->>UI: 设置 isRunning=false
end
Note over Handler,Database: 数据库连接阶段
@@ -153,84 +188,108 @@ sequenceDiagram
Database->>Database: 13. connect()
alt 数据库连接失败
Database-->>Handler: 抛出异常
- Handler-->>UI: 返回DatabaseQueryError
+ Handler-->>UI: 返回 DatabaseQueryError
end
DBFactory-->>Handler: 14. 返回 IDatabaseService 实例
Note over Handler,Database: 订单号解析阶段
- Handler->>Resolver: 15. 创建OrderNumberResolver(dbService)
- Handler->>Resolver: 16. 调用resolve(orderNumbers)
+ Handler->>Resolver: 15. 创建 OrderNumberResolver(dbService)
+ Handler->>Resolver: 16. 调用 resolve(orderNumbers)
Resolver->>Database: 17. 查询生产订单号映射
Database-->>Resolver: 18. 返回映射结果
Resolver-->>Handler: 19. 返回映射结果
(包含有效订单号和警告)
alt 没有有效订单号
- Handler-->>UI: 返回ValidationError
- UI->>UI: 显示错误: "没有有效的生产订单号"
+ Handler-->>UI: 返回 ValidationError
+ UI->>UI: 显示错误:"没有有效的生产订单号"
end
- Note over Handler,Browser: ERP认证阶段
- Handler->>Auth: 20. 创建ErpAuthService
- Handler->>Auth: 21. 调用login()
- Auth->>Browser: 22. 启动Playwright浏览器
- Browser->>ERP: 23. 访问ERP登录页面
+ Note over Handler,Browser: ERP 认证阶段
+ Handler->>Auth: 20. 创建 ErpAuthService
+ Handler->>Auth: 21. 调用 login()
+ Auth->>Browser: 22. 启动 Playwright 浏览器
+ Browser->>ERP: 23. 访问 ERP 登录页面
Browser->>ERP: 24. 填写用户名密码
Browser->>ERP: 25. 点击登录按钮
ERP-->>Browser: 26. 登录成功
- Browser-->>Auth: 27. 返回session对象
+ Browser-->>Auth: 27. 返回 session 对象
Auth-->>Handler: 28. 登录成功
alt 登录失败
Auth-->>Handler: 抛出异常
- Handler-->>UI: 返回ErpConnectionError
+ Handler-->>UI: 返回 ErpConnectionError
end
- Note over Extractor,ERP: 数据提取阶段
- Handler->>Extractor: 29. 创建ExtractorService
- Handler->>Extractor: 30. 调用extract()
传入有效订单号
- Extractor->>Browser: 31. 使用已有session
- Extractor->>ERP: 32. 导航到离散备料计划维护页面
- Extractor->>ERP: 33. 设置查询界面
(订单号查询, 全部标签, 限制5000)
+ Note over Extractor,Core: 数据提取阶段
+ Handler->>Extractor: 29. 创建 ExtractorService
+ Handler->>Extractor: 30. 调用 extract()
传入有效订单号
+ Extractor->>Core: 31. 调用 downloadAllBatches()
- loop 批处理循环 (每批最多100个订单)
- Extractor->>Extractor: 34. 创建批次
(按batchSize分组)
- Note over Extractor: onProgress回调存在但
无法通过IPC传递(函数不可序列化)
-
- Extractor->>ERP: 35. 填充订单号到搜索框
- Extractor->>ERP: 36. 点击搜索按钮
- Extractor->>ERP: 37. 等待加载完成
- Extractor->>ERP: 38. 点击第一行复选框
- Extractor->>ERP: 39. 悬停并点击"更多"
- Extractor->>ERP: 40. 点击"输出"
- Extractor->>ERP: 41. 设置行数阈值为300000
- Extractor->>ERP: 42. 点击"确定(Y)"
+ Note over Core: 浏览器自动化操作
+ Core->>Browser: 32. 导航到离散备料计划维护页面
+ Core->>Browser: 33. 设置查询界面
+ loop 批处理循环 (每批最多 100 个订单)
+ Core->>Core: 34. 创建批次
+ Core->>ERP: 35. 填充订单号到搜索框
+ Core->>ERP: 36. 点击搜索按钮
+ Core->>ERP: 37. 等待加载完成
+ Core->>ERP: 38. 点击第一行复选框
+ Core->>ERP: 39. 悬停并点击"更多"
+ Core->>ERP: 40. 点击"输出"
+ Core->>ERP: 41. 设置行数阈值为 300000
+ Core->>ERP: 42. 点击"确定 (Y)"
Browser->>Browser: 43. 监听下载事件
ERP->>Browser: 44. 触发文件下载
- Browser->>Browser: 45. 保存文件到downloads目录
- Browser-->>Extractor: 46. 返回文件路径
- Extractor->>Extractor: 47. 记录下载文件路径
+ Browser->>Excel: 45. 保存文件到 downloads 目录
+ Excel-->>Core: 46. 返回文件路径
+ Core->>Core: 47. 记录下载文件路径
end
- Extractor->>Extractor: 48. 汇总结果
(文件列表, 记录数, 错误)
- Extractor-->>Handler: 49. 返回ExtractorResult
- Handler->>Handler: 50. 添加解析警告到错误列表
+ Core-->>Extractor: 48. 返回 ExtractorCoreResult
(downloadedFiles, errors)
+ Extractor->>Extractor: 49. 合并下载的文件
+
+ Note over Extractor,Parser: 文件合并阶段
+ Extractor->>Parser: 50. 解析每个 Excel 文件
+ Parser->>Excel: 51. 读取订单头信息和材料行
+ Excel-->>Parser: 52. 返回订单数据
+ Parser->>Parser: 53. 合并所有订单到单一工作簿
+ Parser->>Excel: 54. 保存合并后的文件 (merged_*.xlsx)
+ Excel-->>Extractor: 55. 返回合并文件路径
+
+ Note over Extractor,Importer: 数据库导入阶段
+ Extractor->>Importer: 56. 调用 importFromExcel()
+ Importer->>Excel: 57. 读取合并的 Excel 文件
+ Excel-->>Importer: 58. 返回记录列表
+ Importer->>DAO: 59. 按 SourceNumber 删除旧记录
+ DAO->>Database: 60. 执行 DELETE 操作
+ Database-->>DAO: 61. 返回删除行数
+ Importer->>DAO: 62. 批量插入新记录
+ DAO->>Database: 63. 执行 INSERT 操作
+ Database-->>DAO: 64. 返回插入行数
+ DAO-->>Importer: 65. 返回导入统计
+ Importer-->>Extractor: 66. 返回 ImportResult
+
+ Note over Extractor: 清理临时文件
+ Extractor->>Excel: 67. 删除临时批次文件
+
+ Extractor-->>Handler: 68. 返回 ExtractorResult
(包含 importResult)
+ Handler->>Handler: 69. 添加解析警告到错误列表
Note over Handler,IPC: 清理阶段
- Handler->>Browser: 51. 关闭浏览器
- Handler->>Database: 52. 断开数据库连接
(dbService.disconnect())
+ Handler->>Browser: 70. 关闭浏览器
+ Handler->>Database: 71. 断开数据库连接
Note over Handler,UI: 响应阶段
- Handler-->>IPC: 53. 返回IPC响应
(success: true, data: result)
- IPC-->>UI: 54. 返回response
- UI->>UI: 55. 设置result状态
- UI->>UI: 56. 设置isRunning=false
- UI->>UI: 57. 清空进度状态
- UI->>User: 58. 显示提取结果
(文件数, 记录数, 错误数)
+ Handler-->>IPC: 72. 返回 IPC 响应
(success: true, data: result)
+ IPC-->>UI: 73. 返回 response
+ UI->>UI: 74. 设置 result 状态
+ UI->>UI: 75. 设置 isRunning=false
+ UI->>User: 76. 显示提取结果
(文件数,记录数,错误数,导入结果)
alt 发生任何错误
- Handler-->>UI: 返回error响应
- UI->>UI: 设置error状态
- UI->>UI: 设置isRunning=false
+ Handler-->>UI: 返回 error 响应
+ UI->>UI: 设置 error 状态
+ UI->>UI: 设置 isRunning=false
UI->>User: 显示错误信息
end
```
@@ -247,7 +306,7 @@ stateDiagram-v2
Validating --> Idle: 验证失败
(订单号为空)
Validating --> Running: 验证通过
- Running --> Processing: 调用IPC API
+ Running --> Processing: 调用 IPC API
Processing --> Progress: 收到进度更新
Progress --> Processing: 继续处理
@@ -259,9 +318,9 @@ stateDiagram-v2
note right of Validating
前端验证阶段:
- - 检查orderNumbers非空
+ - 检查 orderNumbers 非空
- 解析订单号列表
- - 存储到sessionStorage
+ - 存储到 sessionStorage
- 存储到共享状态
end note
@@ -269,8 +328,10 @@ stateDiagram-v2
后端处理阶段:
- 环境配置检查
- 订单号解析
- - ERP登录
+ - ERP 登录
- 批量数据提取
+ - 文件合并
+ - 数据库导入
- 资源清理
end note
@@ -278,7 +339,7 @@ stateDiagram-v2
进度更新:
- 更新进度条百分比
- 添加日志到控制台
- - 保持isRunning=true
+ - 保持 isRunning=true
end note
note right of Success
@@ -286,6 +347,7 @@ stateDiagram-v2
- 显示下载文件数
- 显示记录总数
- 显示错误数
+ - 显示数据库导入结果
- isRunning=false
end note
@@ -302,14 +364,12 @@ stateDiagram-v2
| 状态变量 | 类型 | 说明 | 持久化 |
| -------------- | ------------------------- | ------------------------------------- | ----------------- |
| `orderNumbers` | string | 用户输入的订单号列表 | ✅ sessionStorage |
-| `batchSize` | number | 每批处理的订单数量 (默认100) | ✅ sessionStorage |
| `isRunning` | boolean | 是否正在执行提取 | ❌ 内存状态 |
| `progress` | ExtractorProgress \| null | 当前进度信息 (当前实现中未从后端接收) | ❌ 内存状态 |
-| `result` | ExtractorResult \| null | 提取结果 | ❌ 内存状态 |
| `error` | string \| null | 错误信息 | ❌ 内存状态 |
-| `logs` | string[] | 执行日志列表 | ❌ 内存状态 |
+| `logs` | LogEntry[] | 执行日志列表 (带时间戳和级别) | ❌ 内存状态 |
-> **注意**: `progress` 状态目前未从后端接收实时更新。虽然 `ExtractorService` 内部调用 `onProgress` 回调,但函数无法通过 IPC 序列化传递。后续可通过 IPC 事件通道实现实时进度更新。
+> **注意**: `progress` 状态目前未从后端接收实时更新。虽然 `ExtractorCore` 内部调用 `onProgress` 回调,但函数无法通过 IPC 序列化传递。后续可通过 IPC 事件通道实现实时进度更新。
---
@@ -318,51 +378,55 @@ stateDiagram-v2
```mermaid
flowchart TD
Start([用户点击开始]) --> Validate{前端验证}
- Validate -->|订单号为空| ShowEmptyError["显示错误:
- 请输入至少一个订单号"]
- Validate -->|验证通过| CallIPC[调用IPC API]
+ Validate -->|订单号为空 | ShowEmptyError["显示错误:
请输入至少一个订单号"]
+ Validate -->|验证通过 | CallIPC[调用 IPC API]
CallIPC --> ConfigCheck{环境配置检查}
- ConfigCheck -->|配置不完整| ConfigError["返回ValidationError:
- ERP配置不完整"]
- ConfigCheck -->|配置完整| ConnectDB["连接数据库
- (MySQL或SQL Server)"]
+ ConfigCheck -->|配置不完整 | ConfigError["返回 ValidationError:
ERP 配置不完整"]
+ ConfigCheck -->|配置完整 | ConnectDB["连接数据库
(MySQL 或 SQL Server)"]
- ConnectDB --> DBCheck{连接成功?}
- DBCheck -->|失败| DBError["返回DatabaseQueryError:
- 数据库连接失败"]
- DBCheck -->|成功| ResolveOrders[解析订单号]
+ ConnectDB --> DBCheck{连接成功?}
+ DBCheck -->|失败 | DBError["返回 DatabaseQueryError:
数据库连接失败"]
+ DBCheck -->|成功 | ResolveOrders[解析订单号]
- ResolveOrders --> ValidOrders{有有效订单号?}
- ValidOrders -->|无| NoOrdersError["返回ValidationError:
- 没有有效的生产订单号"]
- ValidOrders -->|有| LoginERP[ERP登录]
+ ResolveOrders --> ValidOrders{有有效订单号?}
+ ValidOrders -->|无 | NoOrdersError["返回 ValidationError:
没有有效的生产订单号"]
+ ValidOrders -->|有 | LoginERP[ERP 登录]
- LoginERP --> LoginCheck{登录成功?}
- LoginCheck -->|失败| LoginError["返回ErpConnectionError:
- ERP登录失败"]
- LoginCheck -->|成功| ExtractData[执行数据提取]
+ LoginERP --> LoginCheck{登录成功?}
+ LoginCheck -->|失败 | LoginError["返回 ErpConnectionError:
ERP 登录失败"]
+ LoginCheck -->|成功 | ExtractData[执行数据提取]
ExtractData --> BatchLoop[批处理循环]
- BatchLoop --> BatchError{批次成功?}
- BatchError -->|失败| RecordError[记录错误到result.errors]
- BatchError -->|成功| SaveFile[保存文件]
- RecordError --> NextBatch{还有批次?}
+ BatchLoop --> BatchError{批次成功?}
+ BatchError -->|失败 | RecordError[记录错误到 result.errors]
+ BatchError -->|成功 | SaveFile[保存文件]
+ RecordError --> NextBatch{还有批次?}
SaveFile --> NextBatch
- NextBatch -->|是| BatchLoop
- NextBatch -->|否| Cleanup[清理资源]
+ NextBatch -->|是 | BatchLoop
+ NextBatch -->|否 | MergeFiles[合并 Excel 文件]
- Cleanup --> CheckWarnings{有警告?}
- CheckWarnings -->|是| AddWarnings[添加警告到errors]
- CheckWarnings -->|否| ReturnSuccess[返回成功结果]
+ MergeFiles --> MergeCheck{合并成功?}
+ MergeCheck -->|失败 | MergeError[记录合并错误]
+ MergeCheck -->|成功 | ImportDB[导入数据库]
+
+ ImportDB --> ImportCheck{导入成功?}
+ ImportCheck -->|失败 | ImportError[记录导入错误]
+ ImportCheck -->|成功 | Cleanup[清理临时文件]
+ MergeError --> Cleanup
+ ImportError --> Cleanup
+
+ Cleanup --> CheckWarnings{有警告?}
+ CheckWarnings -->|是 | AddWarnings[添加警告到 errors]
+ CheckWarnings -->|否 | ReturnSuccess[返回成功结果]
AddWarnings --> ReturnSuccess
- ShowEmptyError --> ResetState1[设置isRunning=false]
- ConfigError --> ResetState2[设置isRunning=false]
- DBError --> ResetState3[设置isRunning=false]
- NoOrdersError --> ResetState4[设置isRunning=false]
- LoginError --> ResetState5[设置isRunning=false]
+ ShowEmptyError --> ResetState1[设置 isRunning=false]
+ ConfigError --> ResetState2[设置 isRunning=false]
+ DBError --> ResetState3[设置 isRunning=false]
+ NoOrdersError --> ResetState4[设置 isRunning=false]
+ LoginError --> ResetState5[设置 isRunning=false]
ResetState1 --> End1([结束])
ResetState2 --> End2([结束])
@@ -377,6 +441,8 @@ flowchart TD
style NoOrdersError fill:#ffcccc
style LoginError fill:#ffcccc
style RecordError fill:#fff4cc
+ style MergeError fill:#fff4cc
+ style ImportError fill:#fff4cc
style ReturnSuccess fill:#ccffcc
```
@@ -386,8 +452,10 @@ flowchart TD
| -------------------- | ------------------------------------ | ------------------------ | -------------- |
| `ValidationError` | 订单号为空、配置不完整、无有效订单号 | 显示红色错误消息 | 修正输入后重试 |
| `DatabaseQueryError` | 数据库连接失败 (MySQL/SQL Server) | 显示数据库连接错误 | 检查数据库配置 |
-| `ErpConnectionError` | ERP登录失败 | 显示ERP登录错误 | 检查ERP凭据 |
+| `ErpConnectionError` | ERP 登录失败 | 显示 ERP 登录错误 | 检查 ERP 凭据 |
| `BatchError` | 单个批次处理失败 | 记录到错误列表,继续处理 | 查看错误详情 |
+| `MergeError` | Excel 文件合并失败 | 记录到错误列表 | 查看日志 |
+| `ImportError` | 数据库导入失败 | 记录到错误列表 | 查看导入错误 |
| `SystemError` | 未知系统错误 | 显示通用错误消息 | 查看日志 |
---
@@ -397,52 +465,49 @@ flowchart TD
```mermaid
flowchart LR
subgraph "Input (用户输入)"
- A1["原始输入
- 订单号列表"]
- A2["批次大小
- batchSize=100"]
+ A1["原始输入
订单号列表"]
+ A2["批次大小
batchSize=100"]
end
subgraph "Transformation (数据转换)"
- B1["行解析
- 按换行符分割"]
- B2["去空白
- trim每行"]
- B3["过滤空行
- 移除空字符串"]
- B4["存储共享状态
- Production IDs"]
+ B1["行解析
按换行符分割"]
+ B2["去空白
trim 每行"]
+ B3["过滤空行
移除空字符串"]
+ B4["存储共享状态
Production IDs"]
end
subgraph "Resolution (订单号解析)"
- C0["DatabaseFactory
- create()"]
- C1["查询数据库
- 查找映射关系"]
- C2["提取生产订单号
- 获取有效值"]
- C3["收集警告
- 记录未映射项"]
+ C0["DatabaseFactory
create()"]
+ C1["查询数据库
查找映射关系"]
+ C2["提取生产订单号
获取有效值"]
+ C3["收集警告
记录未映射项"]
end
subgraph "Processing (批量处理)"
- D1["批次分组
- 按batchSize切分"]
- D2["批次迭代
- 逐批处理"]
- D3["订单拼接
- 逗号连接"]
+ D1["批次分组
按 batchSize 切分"]
+ D2["批次迭代
逐批处理"]
+ D3["订单拼接
逗号连接"]
+ end
+
+ subgraph "Merging (文件合并)"
+ E1["解析 Excel
读取订单头 + 材料行"]
+ E2["合并订单
合并所有文件"]
+ E3["保存合并文件
merged_*.xlsx"]
+ end
+
+ subgraph "Import (数据库导入)"
+ F1["读取 Excel
解析记录"]
+ F2["删除旧记录
按 SourceNumber"]
+ F3["批量插入
新记录"]
+ F4["返回统计
recordsRead/Deleted/Imported"]
end
subgraph "Output (结果输出)"
- E1["下载文件列表
- downloadedFiles数组"]
- E2["合并文件
- mergedFile TODO"]
- E3["记录总数
- recordCount"]
- E4["错误列表
- errors数组"]
+ G1["下载文件列表
downloadedFiles 数组"]
+ G2["合并文件
mergedFile 路径"]
+ G3["记录总数
recordCount"]
+ G4["错误列表
errors 数组"]
+ G5["导入结果
importResult 对象"]
end
A1 --> B1
@@ -454,122 +519,176 @@ flowchart LR
C0 --> C1
C1 --> C2
C2 --> D1
- C3 --> E4
+ C3 --> G4
D1 --> D2
D2 --> D3
- D3 --> E1
- E1 --> E3
+ D3 --> G1
+ G1 --> E1
+ E1 --> E2
+ E2 --> E3
+ E3 --> F1
+ F1 --> F2
+ F2 --> F3
+ F3 --> F4
+ F4 --> G5
+ E3 --> G2
+ F1 --> G3
style A1 fill:#e3f2fd
style A2 fill:#e3f2fd
style C0 fill:#e8f5e9
- style E1 fill:#e8f5e9
- style E2 fill:#e8f5e9
- style E3 fill:#e8f5e9
- style E4 fill:#fff3e0
+ style E1 fill:#f3e5f5
+ style E2 fill:#f3e5f5
+ style E3 fill:#f3e5f5
+ style F1 fill:#e8f5e9
+ style G1 fill:#e8f5e9
+ style G2 fill:#e8f5e9
+ style G3 fill:#e8f5e9
+ style G4 fill:#fff3e0
+ style G5 fill:#e8f5e9
```
### 数据转换详情
-**阶段1: 用户输入 → Production IDs**
+**阶段 1: 用户输入 → Production IDs**
```
-输入: "PO-20231024-001\nPO-20231024-002\nPO-20231024-003"
+输入:"PO-20231024-001\nPO-20231024-002\nPO-20231024-003"
↓ 分割 + trim + 过滤
-结果: ["PO-20231024-001", "PO-20231024-002", "PO-20231024-003"]
+结果:["PO-20231024-001", "PO-20231024-002", "PO-20231024-003"]
↓ 存储到共享状态
-共享状态: Production IDs (供清理模块使用)
+共享状态:Production IDs (供清理模块使用)
```
-**阶段2: Production IDs → 生产订单号**
+**阶段 2: Production IDs → 生产订单号**
```
-输入: ["PO-20231024-001", "PO-20231024-002", "INVALID"]
- ↓ MySQL查询 (production_order表)
-映射结果: {
+输入:["PO-20231024-001", "PO-20231024-002", "INVALID"]
+ ↓ MySQL 查询 (production_order 表)
+映射结果:{
"PO-20231024-001": "MO-20231024-001",
"PO-20231024-002": "MO-20231024-002",
"INVALID": null
}
↓ 提取有效值
-有效订单号: ["MO-20231024-001", "MO-20231024-002"]
-警告: ["INVALID: 未找到对应的生产订单号"]
+有效订单号:["MO-20231024-001", "MO-20231024-002"]
+警告:["INVALID: 未找到对应的生产订单号"]
```
-**阶段3: 生产订单号 → 批次**
+**阶段 3: 生产订单号 → 批次**
```
-输入: ["MO-001", "MO-002", ..., "MO-250"] (250个)
-批次大小: 100
+输入:["MO-001", "MO-002", ..., "MO-250"] (250 个)
+批次大小:100
↓ 分组
-批次1: ["MO-001", ..., "MO-100"]
-批次2: ["MO-101", ..., "MO-200"]
-批次3: ["MO-201", ..., "MO-250"]
+批次 1: ["MO-001", ..., "MO-100"]
+批次 2: ["MO-101", ..., "MO-200"]
+批次 3: ["MO-201", ..., "MO-250"]
```
-**阶段4: 批次 → ERP查询字符串**
+**阶段 4: 批次 → ERP 查询字符串**
```
-批次: ["MO-001", "MO-002", "MO-003"]
+批次:["MO-001", "MO-002", "MO-003"]
↓ 逗号连接
-查询字符串: "MO-001,MO-002,MO-003"
- ↓ 填充到ERP搜索框
-ERP操作: 填入搜索框并点击搜索
+查询字符串:"MO-001,MO-002,MO-003"
+ ↓ 填充到 ERP 搜索框
+ERP 操作:填入搜索框并点击搜索
+```
+
+**阶段 5: 下载文件 → 合并文件**
+
+```
+输入:["temp_batch_1.xlsx", "temp_batch_2.xlsx", "temp_batch_3.xlsx"]
+ ↓ ExcelParser.parse() 读取每个文件
+订单数据:[
+ { orderInfo: {...}, materials: [...] },
+ ...
+]
+ ↓ 合并所有订单到单一工作簿
+输出:"merged_20260304120000.xlsx"
+记录数:所有订单的材料行总数
+```
+
+**阶段 6: 合并文件 → 数据库记录**
+
+```
+输入:"merged_20260304120000.xlsx"
+ ↓ 读取 Excel,解析每行数据
+记录列表:[MaterialPlanRecord, ...]
+SourceNumbers: Set<"MO-001", "MO-002", ...>
+ ↓ DELETE FROM table WHERE SourceNumber IN (...)
+删除旧记录:N 条
+ ↓ INSERT INTO table VALUES (...)
+插入新记录:M 条
+ ↓ 返回统计
+ImportResult: {
+ success: true,
+ recordsRead: 1500,
+ recordsDeleted: 500,
+ recordsImported: 1500,
+ uniqueSourceNumbers: 150,
+ errors: []
+}
```
---
## 关键代码引用
-### 1. 前端开始按钮处理 (ExtractorPage.tsx:52-90)
+### 1. 前端开始按钮处理 (ExtractorPage.tsx:63-109)
```typescript
const handleExtract = async () => {
- // 1. 前端验证
if (!orderNumbers.trim()) {
setError('请输入至少一个订单号')
return
}
- // 2. 设置运行状态
setIsRunning(true)
setProgress(null)
- setResult(null)
setError(null)
+ setLogs([])
+
+ addLog('system', '提取引擎启动,准备执行...')
try {
- // 3. 解析订单号列表
const orderNumberList = orderNumbers
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0)
- // 4. 存储到共享状态 (与Cleaner模块共享)
await window.electron.validation.setSharedProductionIds(orderNumberList)
+ addLog('info', `已存储 ${orderNumberList.length} 个订单号用于跨模块共享`)
- // 5. 调用后端API
const response = await window.electron.extractor.runExtractor({
- orderNumbers: orderNumberList,
- batchSize
+ orderNumbers: orderNumberList
})
- // 6. 处理响应
if (response.success && response.data) {
- setResult(response.data)
+ addLog(
+ 'success',
+ `提取完成:下载 ${response.data.downloadedFiles.length} 个文件,共 ${response.data.recordCount} 条记录`
+ )
+ if (response.data.errors.length > 0) {
+ addLog('warning', `存在 ${response.data.errors.length} 个错误`)
+ }
} else {
setError(response.error || '提取失败')
+ addLog('error', response.error || '提取失败')
}
} catch (err) {
- setError(err instanceof Error ? err.message : '发生未知错误')
+ const errMsg = err instanceof Error ? err.message : '发生未知错误'
+ setError(errMsg)
+ addLog('error', errMsg)
} finally {
- // 7. 重置状态
setIsRunning(false)
setProgress(null)
}
}
```
-### 1.1 订单号实时同步到共享状态 (ExtractorPage.tsx:34-45)
+### 1.1 订单号实时同步到共享状态 (ExtractorPage.tsx:41-50)
```typescript
// 当用户输入订单号时,实时同步到共享状态
@@ -591,9 +710,9 @@ useEffect(() => {
> 1. `useEffect` 在用户输入时实时更新
> 2. `handleExtract` 在提取开始前再次确认存储
>
-> 这确保了即使用户在Cleaner页面刷新,数据也已同步。
+> 这确保了即使用户在 Cleaner 页面刷新,数据也已同步。
-### 2. IPC处理器核心逻辑 (extractor-handler.ts:17-145)
+### 2. IPC 处理器核心逻辑 (extractor-handler.ts:17-145)
```typescript
ipcMain.handle(
@@ -609,127 +728,123 @@ ipcMain.handle(
const erpUsername = process.env.ERP_USERNAME || ''
const erpPassword = process.env.ERP_PASSWORD || ''
+ log.info('Config check', {
+ url: erpUrl ? 'configured' : 'EMPTY',
+ username: erpUsername ? 'configured' : 'EMPTY'
+ })
+
if (!erpUrl || !erpUsername || !erpPassword) {
- throw new ValidationError('ERP 配置不完整')
+ throw new ValidationError(
+ 'ERP 配置不完整。请检查 .env 文件中的 ERP_URL, ERP_USERNAME, ERP_PASSWORD',
+ 'VAL_MISSING_REQUIRED'
+ )
}
// 2. 使用数据库工厂创建服务实例 (支持 MySQL 和 SQL Server)
try {
dbService = await create() // 工厂方法,根据 DB_TYPE 自动选择数据库
} catch (error) {
- throw new DatabaseQueryError('数据库连接失败', 'DB_CONNECTION_FAILED', error)
+ throw new DatabaseQueryError(
+ '数据库连接失败',
+ 'DB_CONNECTION_FAILED',
+ error instanceof Error ? error : undefined
+ )
}
// 3. 解析订单号
const resolver = new OrderNumberResolver(dbService)
const mappings = await resolver.resolve(input.orderNumbers)
+
+ // Get valid order numbers and warnings
const validOrderNumbers = resolver.getValidOrderNumbers(mappings)
const warnings = resolver.getWarnings(mappings)
- if (validOrderNumbers.length === 0) {
- throw new ValidationError('没有有效的生产订单号可处理')
+ if (warnings.length > 0) {
+ log.warn('Resolution warnings', { warnings })
}
- // 4. ERP登录
- authService = new ErpAuthService({ url, username, password, headless: true })
- await authService.login()
+ if (validOrderNumbers.length === 0) {
+ throw new ValidationError(
+ '没有有效的生产订单号可处理。请检查输入的格式或数据库连接。',
+ 'VAL_INVALID_INPUT'
+ )
+ }
- // 5. 执行提取
- const extractor = new ExtractorService(authService)
- const result = await extractor.extract({
- ...input,
- orderNumbers: validOrderNumbers
+ log.info('Resolved order numbers', { count: validOrderNumbers.length })
+
+ // 4. ERP 登录
+ authService = new ErpAuthService({
+ url: erpUrl,
+ username: erpUsername,
+ password: erpPassword,
+ headless: true
})
- // 6. 添加警告到结果
+ log.info('Logging in to ERP...')
+ try {
+ await authService.login()
+ } catch (error) {
+ throw new ErpConnectionError(
+ 'ERP 登录失败',
+ 'ERP_LOGIN_FAILED',
+ error instanceof Error ? error : undefined
+ )
+ }
+ log.info('Login successful')
+
+ // 5. 执行提取(包含文件合并和数据库导入)
+ const extractor = new ExtractorService(authService)
+ log.info('Starting extraction', { orderCount: validOrderNumbers.length })
+
+ const modifiedInput: ExtractorInput = {
+ ...input,
+ orderNumbers: validOrderNumbers
+ }
+
+ const result = await extractor.extract(modifiedInput)
+
+ // 添加警告到结果
if (warnings.length > 0) {
result.errors = [...warnings, ...result.errors]
}
+ log.info('Extraction completed', {
+ rowCount: result.recordCount,
+ errorCount: result.errors.length
+ })
+
return result
} finally {
// 7. 资源清理
- if (authService) await authService.close()
- if (dbService) await dbService.disconnect()
+ if (authService) {
+ try {
+ await authService.close()
+ log.debug('Browser closed')
+ } catch (closeError) {
+ log.warn('Error closing browser', {
+ error: closeError instanceof Error ? closeError.message : String(closeError)
+ })
+ }
+ }
+
+ // 断开数据库连接
+ if (dbService) {
+ try {
+ await dbService.disconnect()
+ log.debug('Database disconnected')
+ } catch (closeError) {
+ log.warn('Error disconnecting database', {
+ error: closeError instanceof Error ? closeError.message : String(closeError)
+ })
+ }
+ }
}
}, 'extractor:run')
}
)
```
-### 2.1 数据库工厂模式 (database/index.ts)
-
-```typescript
-/**
- * 数据库工厂 - 创建数据库服务实例
- * 支持 MySQL 和 SQL Server 双数据库
- */
-export async function create(type?: DatabaseType): Promise {
- const dbType = type || getDatabaseType() // 从 DB_TYPE 环境变量读取
-
- // 返回缓存的实例(单例模式)
- const cached = instances.get(dbType)
- if (cached && cached.isConnected()) {
- return cached
- }
-
- // 创建新实例
- let service: IDatabaseService
-
- if (dbType === 'sqlserver') {
- service = new SqlServerService(createSqlServerConfig())
- } else {
- service = new MySqlService(createMySqlConfig())
- }
-
- await service.connect()
- instances.set(dbType, service) // 缓存实例
-
- return service
-}
-
-/**
- * 数据库类型判断
- */
-export function getDatabaseType(): DatabaseType {
- const dbType = process.env.DB_TYPE?.toLowerCase()
- if (dbType === 'sqlserver' || dbType === 'mssql') {
- return 'sqlserver'
- }
- return 'mysql'
-}
-```
-
-### 2.2 数据库服务接口 (types/database.types.ts)
-
-```typescript
-/**
- * 数据库服务统一接口
- */
-export interface IDatabaseService {
- /** 数据库类型标识 */
- readonly type: DatabaseType
-
- /** 连接数据库 */
- connect(): Promise
-
- /** 断开连接 */
- disconnect(): Promise
-
- /** 检查连接状态 */
- isConnected(): boolean
-
- /** 执行查询 */
- query(sql: string, params?: any[]): Promise
-
- /** 事务执行 */
- transaction(queries: { sql: string; params?: any[] }[]): Promise
-}
-
-export type DatabaseType = 'mysql' | 'sqlserver'
-```
-
-### 3. 提取服务批处理逻辑 (extractor.ts:29-77)
+### 3. 提取服务协调逻辑 (extractor.ts:36-90)
```typescript
async extract(input: ExtractorInput): Promise {
@@ -743,90 +858,214 @@ async extract(input: ExtractorInput): Promise {
try {
const session = this.authService.getSession()
- // 导航到提取页面并获取工作框架
- const { popupPage, workFrame } = await this.navigateToExtractorPage(session)
+ // 调用 ExtractorCore 执行浏览器自动化操作
+ const core = new ExtractorCore()
+ const coreResult = await core.downloadAllBatches({
+ session,
+ orderNumbers: input.orderNumbers,
+ downloadDir: this.downloadDir,
+ batchSize: input.batchSize || 100,
+ onProgress: input.onProgress
+ })
- // 批处理设置
- const batchSize = input.batchSize || 100
- const batches = this.createBatches(input.orderNumbers, batchSize)
+ result.downloadedFiles = coreResult.downloadedFiles
+ result.errors = coreResult.errors
- for (let i = 0; i < batches.length; i++) {
- const batch = batches[i]
- const progress = ((i + 1) / batches.length) * 100
+ // 合并下载的文件
+ if (result.downloadedFiles.length > 0) {
+ input.onProgress?.('正在合并文件...', 95)
+ const mergeResult = await this.mergeFiles(result.downloadedFiles)
+ result.mergedFile = mergeResult.mergedFile
+ result.recordCount = mergeResult.recordCount
- // 注意: onProgress 回调存在但无法通过 IPC 传递
- // 后续可通过 IPC 事件通道实现实时进度
- input.onProgress?.(`Processing batch ${i + 1}/${batches.length}`, progress)
+ // 添加合并错误到结果
+ if (mergeResult.error) {
+ result.errors.push(mergeResult.error)
+ }
- try {
- const filePath = await this.downloadBatch(
- session, popupPage, workFrame, batch, i, batches.length
- )
- result.downloadedFiles.push(filePath)
- } catch (error) {
- // 单批次失败不影响其他批次
- result.errors.push(`Batch ${i + 1}: ${error.message}`)
+ // 清理临时文件
+ await this.cleanupTempFiles(result.downloadedFiles)
+
+ // 自动导入数据库
+ if (result.mergedFile) {
+ input.onProgress?.('正在写入数据库...', 98)
+ const importResult = await this.importToDatabase(result.mergedFile)
+ result.importResult = importResult
+
+ if (!importResult.success && importResult.errors.length > 0) {
+ result.errors.push(...importResult.errors)
+ }
}
}
-
- // TODO: 合并文件功能待实现
} catch (error) {
- result.errors.push(`Extraction failed: ${error.message}`)
+ const message = error instanceof Error ? error.message : 'Unknown error'
+ result.errors.push(`Extraction failed: ${message}`)
}
return result
}
```
-### 4. 浏览器自动化单批次处理 (extractor.ts:151-194)
+### 4. 文件合并逻辑 (extractor.ts:101-168)
```typescript
-private async downloadBatch(
- session: ErpSession,
- popupPage: any,
- workFrame: any,
- orderNumbers: string[],
- batchIndex: number,
- totalBatches: number
-): Promise {
- // 1. 清空并填充订单号
- const textbox = workFrame.getByRole('textbox', { name: '来源生产订单号' })
- await textbox.fill('')
- await textbox.fill(orderNumbers.join(','))
+private async mergeFiles(
+ filePaths: string[]
+): Promise<{ mergedFile: string | null; recordCount: number; error?: string }> {
+ if (filePaths.length === 0) {
+ return { mergedFile: null, recordCount: 0 }
+ }
- // 2. 点击搜索按钮
- await workFrame.locator('.search-component-searchBtn').click()
+ console.log(`[Extractor] Starting merge of ${filePaths.length} files`)
+ const parser = new ExcelParser({ verbose: true })
- // 3. 等待加载完成
- await this.waitForLoading(workFrame)
+ // 收集所有订单(包含完整订单头信息和材料行)
+ const allOrders: Array<{ orderInfo: any; materials: any[] }> = []
- // 4. 选择第一行(全选)
- await workFrame.getByRole('row', { name: '序号' }).getByLabel('').click()
+ // 解析每个下载的文件
+ for (const filePath of filePaths) {
+ try {
+ console.log(`[Extractor] Parsing file: ${filePath}`)
+ await parser.parse(filePath)
+ const orders = (parser as any).lastOrders
+ console.log(`[Extractor] Parsed ${orders?.length || 0} orders from ${filePath}`)
+ if (orders && Array.isArray(orders)) {
+ allOrders.push(...orders)
+ }
+ } catch (error) {
+ const errorMsg = error instanceof Error ? error.message : String(error)
+ console.error(`[Extractor] Failed to parse file ${filePath}:`, errorMsg)
+ }
+ }
- // 5. 悬停"更多"按钮并点击"输出"
- await workFrame.getByRole('button', { name: '更多' }).hover()
- await workFrame.getByText('输出', { exact: true }).click()
+ // 计算总记录数
+ let recordCount = 0
+ for (const order of allOrders) {
+ recordCount += order.materials.length
+ }
- // 6. 设置行数阈值
- const thresholdBox = workFrame
- .locator('div')
- .filter({ hasText: /^行数阈值$/ })
- .locator('input[type="text"]')
- await thresholdBox.fill('300000')
+ console.log(`[Extractor] Total orders: ${allOrders.length}, total records: ${recordCount}`)
- // 7. 等待下载并保存
- const downloadPath = path.join(this.downloadDir, `temp_batch_${batchIndex + 1}.xlsx`)
- const downloadPromise = popupPage.waitForEvent('download')
- await workFrame.getByRole('button', { name: '确定(Y)' }).click()
+ if (recordCount === 0) {
+ console.warn('[Extractor] No records found in any of the downloaded files')
+ return { mergedFile: null, recordCount: 0 }
+ }
- const download = await downloadPromise
- await download.saveAs(downloadPath)
+ // 生成输出文件名(带时间戳)
+ const timestamp = new Date()
+ .toISOString()
+ .replace(/[-:T]/g, '')
+ .replace(/\..+/, '')
+ .slice(0, 14)
+ const outputPath = path.join(this.downloadDir, `merged_${timestamp}.xlsx`)
- return downloadPath
+ // 保存合并文件
+ try {
+ console.log(`[Extractor] Saving merged file to: ${outputPath}`)
+ await this.saveMergedOrders(allOrders, outputPath)
+ console.log(`[Extractor] Successfully saved merged file with ${recordCount} records`)
+ return { mergedFile: outputPath, recordCount }
+ } catch (error) {
+ const errorMsg = error instanceof Error ? error.message : String(error)
+ console.error(`[Extractor] Failed to save merged file: ${errorMsg}`)
+ return { mergedFile: null, recordCount, error: `保存合并文件失败:${errorMsg}` }
+ }
}
```
-### 5. Preload API 暴露 (preload/index.ts:24-26)
+### 5. 数据库导入逻辑 (extractor.ts:285-320)
+
+```typescript
+private async importToDatabase(filePath: string): Promise {
+ console.log(`[Extractor] Starting database import from: ${filePath}`)
+
+ const importService = new DataImportService()
+
+ try {
+ const result = await importService.importFromExcel(filePath, 1000)
+
+ console.log(`[Extractor] Import completed`, {
+ success: result.success,
+ recordsRead: result.recordsRead,
+ recordsDeleted: result.recordsDeleted,
+ recordsImported: result.recordsImported
+ })
+
+ return result
+ } catch (error) {
+ const errorMsg = error instanceof Error ? error.message : String(error)
+ console.error(`[Extractor] Import failed: ${errorMsg}`)
+
+ return {
+ success: false,
+ recordsRead: 0,
+ recordsDeleted: 0,
+ recordsImported: 0,
+ uniqueSourceNumbers: 0,
+ errors: [errorMsg]
+ }
+ }
+}
+```
+
+### 6. 数据导入服务 (data-importer.ts:82-152)
+
+```typescript
+async importFromExcel(filePath: string, batchSize = 1000): Promise {
+ const result: ImportResult = {
+ success: false,
+ recordsRead: 0,
+ recordsDeleted: 0,
+ recordsImported: 0,
+ uniqueSourceNumbers: 0,
+ errors: []
+ }
+
+ try {
+ log.info('Starting import from Excel', { filePath, batchSize })
+
+ // Step 1: 读取 Excel 文件
+ log.info('Reading Excel file...')
+ const { records, sourceNumbers } = await this.readExcelFile(filePath)
+ result.recordsRead = records.length
+ result.uniqueSourceNumbers = sourceNumbers.size
+
+ if (records.length === 0) {
+ result.success = true
+ result.errors.push('Excel file contains no data records')
+ return result
+ }
+
+ // Step 2: 按 SourceNumber 删除旧记录
+ log.info('Deleting existing records...', {
+ sourceNumberCount: sourceNumbers.size
+ })
+
+ const sourceNumberArray = Array.from(sourceNumbers)
+ result.recordsDeleted = await this.dao.deleteBySourceNumbers(sourceNumberArray)
+
+ // Step 3: 批量插入新记录
+ result.recordsImported = await this.dao.batchInsert(records, batchSize)
+ result.success = true
+ } catch (error) {
+ const errorMsg = error instanceof Error ? error.message : String(error)
+ result.errors.push(`Import failed: ${errorMsg}`)
+ } finally {
+ // 断开 DAO 连接
+ try {
+ await this.dao.disconnect()
+ } catch (e) {
+ log.warn('Error disconnecting DAO', {
+ error: e instanceof Error ? e.message : String(e)
+ })
+ }
+ }
+
+ return result
+}
+```
+
+### 7. Preload API 暴露 (preload/index.ts:28-30)
```typescript
// Extractor service
@@ -835,13 +1074,22 @@ extractor: {
}
```
-### 6. 类型定义 (types/extractor.types.ts)
+### 8. 类型定义 (types/extractor.types.ts)
```typescript
export interface ExtractorInput {
orderNumbers: string[]
batchSize?: number
- onProgress?: (message: string, progress: number) => void // 注意: 函数无法通过IPC传递
+ onProgress?: (message: string, progress: number) => void
+}
+
+export interface ImportResult {
+ success: boolean
+ recordsRead: number
+ recordsDeleted: number
+ recordsImported: number
+ uniqueSourceNumbers: number
+ errors: string[]
}
export interface ExtractorResult {
@@ -849,6 +1097,33 @@ export interface ExtractorResult {
mergedFile: string | null
recordCount: number
errors: string[]
+ /** Database import result (only populated if mergedFile was created) */
+ importResult?: ImportResult
+}
+```
+
+### 9. 错误类型定义 (types/errors.ts)
+
+```typescript
+// ERP 连接错误
+export class ErpConnectionError extends BaseError {
+ constructor(message: string, code: string = ERP_ERROR_CODES.CONNECTION_FAILED, cause?: Error) {
+ super('ErpConnectionError', message, code, cause)
+ }
+}
+
+// 数据库查询错误
+export class DatabaseQueryError extends BaseError {
+ constructor(message: string, code: string = DATABASE_ERROR_CODES.QUERY_FAILED, cause?: Error) {
+ super('DatabaseQueryError', message, code, cause)
+ }
+}
+
+// 验证错误
+export class ValidationError extends BaseError {
+ constructor(message: string, code: string = VALIDATION_ERROR_CODES.INVALID_INPUT, cause?: Error) {
+ super('ValidationError', message, code, cause)
+ }
}
```
@@ -859,11 +1134,11 @@ export interface ExtractorResult {
### 流程关键点
1. **三层验证机制**:
- - 前端验证: 非空检查
- - 配置验证: 环境变量完整性
- - 数据验证: 订单号有效性(通过数据库查询)
+ - 前端验证:非空检查
+ - 配置验证:环境变量完整性
+ - 数据验证:订单号有效性(通过数据库查询)
-2. **数据库架构 (v1.2 更新)**:
+2. **数据库架构**:
- 使用工厂模式 (`create()`) 创建数据库服务实例
- 支持 MySQL 和 SQL Server 双数据库,通过 `DB_TYPE` 环境变量切换
- 通过 `IDatabaseService` 统一接口实现数据库无关操作
@@ -873,6 +1148,7 @@ export interface ExtractorResult {
- 使用 try-finally 确保资源清理
- 浏览器在使用后立即关闭
- 数据库连接在使用后断开
+ - 临时批次文件在合并后自动删除
- 清理操作在 finally 块中独立 try-catch,避免清理失败影响结果返回
4. **错误容错设计**:
@@ -882,11 +1158,17 @@ export interface ExtractorResult {
- 使用自定义错误类型 (`ValidationError`, `DatabaseQueryError`, `ErpConnectionError`)
5. **用户体验优化**:
- - sessionStorage 持久化用户输入(`orderNumbers`, `batchSize`)
+ - sessionStorage 持久化用户输入(`orderNumbers`)
- 订单号实时同步到共享状态(供 Cleaner 模块使用)
- - 详细的日志记录
+ - 详细的日志记录(带时间戳和级别)
- 结果面板显示文件数、记录数、错误数
+6. **数据处理流程 (v1.3 新增)**:
+ - 自动合并所有批次下载的文件为单一 Excel
+ - 自动将合并后的数据导入数据库
+ - 导入前按 SourceNumber 删除旧记录,确保数据一致性
+ - 返回详细的导入统计(读取数、删除数、导入数)
+
### 已知限制
1. **进度更新未实现**:
@@ -894,22 +1176,24 @@ export interface ExtractorResult {
- 前端 `progress` 状态当前未从后端接收实时更新
- 后续可通过 IPC 事件通道(`ipcRenderer.on` / `webContents.send`)实现
-2. **文件合并未实现**:
- - `ExtractorResult.mergedFile` 当前始终为 `null`
- - 各批次文件独立保存在 `downloads` 目录
+2. **日志级别前端未完全利用**:
+ - 后端支持详细日志分级(info, success, warning, error, system)
+ - 前端已定义颜色映射但未在日志中充分展示
### 性能考虑
-- **批处理**: 默认每批100个订单,平衡性能与稳定性
+- **批处理**: 默认每批 100 个订单,平衡性能与稳定性
- **异步并发**: 使用 async/await 处理异步操作
- **下载监听**: 使用 Playwright 事件监听处理文件下载
- **数据库连接池**: 工厂模式缓存实例,复用连接
+- **批量导入**: 数据库导入使用批量插入(默认 1000 条/批)
+- **临时文件清理**: 合并完成后立即删除临时批次文件
### 扩展性
- **数据库可切换**: 通过 `DB_TYPE` 环境变量切换 MySQL/SQL Server
- **配置化**: batchSize 可配置
-- **模块化**: 服务独立,易于测试和维护
+- **模块化**: 服务独立(ExtractorService, ExtractorCore, DataImportService),易于测试和维护
- **错误类型化**: 使用自定义错误类型便于精确处理
- **共享状态**: 通过 `validation.setSharedProductionIds` 实现跨页面数据共享
@@ -926,14 +1210,14 @@ export interface ExtractorResult {
**当前实现**:
```typescript
-// extractor.ts 中调用但无效
+// extractor-core.ts 中调用但无效
input.onProgress?.(`Processing batch ${i + 1}/${batches.length}`, progress)
```
**建议实现方案**:
```typescript
-// 方案: 使用 IPC 事件通道
+// 方案:使用 IPC 事件通道
// 1. 主进程发送进度
event.sender.send('extractor:progress', { message, progress })
@@ -949,23 +1233,35 @@ extractor: {
useEffect(() => {
window.electron.extractor.onProgress((data) => {
setProgress(data)
- setLogs((prev) => [...prev, `[${new Date().toLocaleTimeString()}] ${data.message}`])
+ setLogs((prev) => [
+ ...prev,
+ {
+ timestamp: new Date().toLocaleTimeString(),
+ level: 'info',
+ message: data.message
+ }
+ ])
})
}, [])
```
-### 文件合并功能
+### 合并文件保留策略
-**当前状态**: 未实现
+**当前状态**: 合并文件保存在 `downloads` 目录,无清理机制
-**待实现**: 将多个批次下载的文件合并为单一 Excel 文件。
+**待实现**:
-**相关代码位置**: `extractor.ts:69-70`
+- 定期清理旧的合并文件
+- 或将合并文件移动到用户指定的归档目录
-```typescript
-// TODO: Merge files (implement in separate task)
-// result.mergedFile = await this.mergeFiles(result.downloadedFiles);
-```
+### 数据库导入错误恢复
+
+**当前状态**: 导入失败时记录错误,但不回滚
+
+**待实现**:
+
+- 使用事务包装导入操作
+- 失败时自动回滚到导入前状态
---