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); -``` +### 数据库导入错误恢复 + +**当前状态**: 导入失败时记录错误,但不回滚 + +**待实现**: + +- 使用事务包装导入操作 +- 失败时自动回滚到导入前状态 ---