Compare commits
169 Commits
2ecd8db79a
...
1.3.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ee1ea566c | ||
|
|
29f29f6a9e | ||
|
|
baa7622954 | ||
|
|
851c2ce634 | ||
|
|
64349125ba | ||
|
|
a020ee537d | ||
|
|
351e9a92bc | ||
|
|
f5514dd721 | ||
|
|
b94640ca81 | ||
|
|
b8163d7d4f | ||
|
|
569c8e8ecc | ||
|
|
21bb8ef79c | ||
|
|
fbcc656b99 | ||
|
|
3854c0f048 | ||
|
|
7bc6daf1b7 | ||
|
|
db44618ee5 | ||
|
|
2d17a6b792 | ||
|
|
e8aa7d21a8 | ||
|
|
defeb01808 | ||
|
|
b289fb9624 | ||
|
|
103effcfca | ||
|
|
975bee6ce8 | ||
|
|
6a2fba0e57 | ||
|
|
9248be6310 | ||
|
|
ad8b3deb00 | ||
|
|
cccc4e4c8c | ||
|
|
9e577a2226 | ||
|
|
715dfb4d71 | ||
|
|
a25ffd75c5 | ||
|
|
8eaba79c26 | ||
|
|
05a44bb464 | ||
|
|
77fabf2018 | ||
|
|
13187ddce1 | ||
|
|
33b17e278d | ||
|
|
bcfe0eecca | ||
|
|
9a640b96e6 | ||
|
|
5b4d7fab49 | ||
|
|
72d8d981b1 | ||
|
|
d1f2f40123 | ||
|
|
545048045d | ||
|
|
62e1647eaf | ||
|
|
fba2c73782 | ||
|
|
c4fff84848 | ||
|
|
57c3452d82 | ||
|
|
f412e0e72c | ||
|
|
6df16898da | ||
|
|
5e6898fb40 | ||
|
|
f45d3df385 | ||
|
|
8f3e5273e2 | ||
|
|
616e2b31a5 | ||
|
|
b3abad7fae | ||
|
|
9d15f7aca9 | ||
|
|
310d5e462f | ||
|
|
c4bacdaf2a | ||
|
|
54a82200b6 | ||
|
|
2d6838c0e7 | ||
|
|
73b8f2409a | ||
|
|
c7c192a703 | ||
|
|
42bd6dcb8a | ||
|
|
e4a92e9be4 | ||
|
|
6f19890a84 | ||
|
|
1ee33672dd | ||
|
|
8e0c37bf74 | ||
|
|
c13be9e19a | ||
|
|
48f1f51d76 | ||
|
|
5977254180 | ||
|
|
3d2127b660 | ||
|
|
7db7f513be | ||
|
|
a906f34ac2 | ||
|
|
224a42e44b | ||
|
|
7d2cf934f8 | ||
|
|
49ac29e70c | ||
|
|
dc01896d8b | ||
|
|
ba64c27457 | ||
|
|
921ca15be6 | ||
|
|
121d49bfe8 | ||
|
|
0783bc037c | ||
|
|
d855b3f84d | ||
|
|
e0d7559cf7 | ||
|
|
302018f631 | ||
|
|
e49191d531 | ||
|
|
2dea1f9556 | ||
|
|
4494351e52 | ||
|
|
9e1b5530ea | ||
|
|
743d830b0d | ||
|
|
dcd3c6a571 | ||
|
|
9939201ca1 | ||
|
|
fed76b4fe0 | ||
|
|
bfa2445c76 | ||
|
|
b62ae10650 | ||
|
|
974eaac6dd | ||
|
|
2f5dc7607d | ||
|
|
a06276127d | ||
|
|
29cb79abfd | ||
|
|
dc3d577f6f | ||
|
|
50041d2a7b | ||
|
|
9833552652 | ||
|
|
8dbdf6f394 | ||
|
|
c61776a1ff | ||
|
|
88c8c256e2 | ||
|
|
5497e86b58 | ||
|
|
63ea81e0d6 | ||
|
|
abe51d17fa | ||
|
|
240e3838ba | ||
|
|
c384513273 | ||
|
|
e23cf71f78 | ||
|
|
7aa1abbc22 | ||
|
|
879dccaa09 | ||
|
|
e13bb15969 | ||
|
|
00b120c762 | ||
|
|
0040ca7521 | ||
|
|
6698d82d6b | ||
|
|
b98cd46237 | ||
|
|
baaa031dac | ||
|
|
816060444c | ||
|
|
73a49f9de3 | ||
|
|
4d8df61187 | ||
|
|
a967050058 | ||
|
|
f3be0ad01d | ||
|
|
7e37a9b1fc | ||
|
|
b5ef38f486 | ||
|
|
c06880e946 | ||
|
|
765fb95644 | ||
|
|
429357ae8c | ||
|
|
79934a58d0 | ||
|
|
b942b2fb15 | ||
|
|
6ecf03ce11 | ||
|
|
77311edce0 | ||
|
|
74ddef2d7b | ||
|
|
edf696ab39 | ||
|
|
6ec422f357 | ||
|
|
a05c8a9037 | ||
|
|
982fb8fde6 | ||
|
|
11aec6d238 | ||
|
|
9fbf75a8a0 | ||
|
|
10e07cdf93 | ||
|
|
1aa2abdb43 | ||
|
|
679366a4b0 | ||
|
|
02bc6b24d6 | ||
|
|
05d856309f | ||
|
|
bbdd1e18f8 | ||
|
|
d2098a461a | ||
|
|
9caa38caa8 | ||
|
|
0f1e0a8908 | ||
|
|
fad08796f7 | ||
|
|
829851e3ca | ||
|
|
450eb41f96 | ||
|
|
079d3aeff6 | ||
|
|
e04337dc19 | ||
|
|
e794470d69 | ||
|
|
484ca31d79 | ||
|
|
cb2a5a84b2 | ||
|
|
5760b56f70 | ||
|
|
c39e1504aa | ||
|
|
8f6ca7b453 | ||
|
|
937ad85e9b | ||
|
|
b22f4995ba | ||
|
|
65bf79fa44 | ||
|
|
d9e0091602 | ||
|
|
f6b19b50f3 | ||
|
|
f045d66b65 | ||
|
|
87f6229d49 | ||
|
|
c66dc2ecf8 | ||
|
|
9ac8707921 | ||
|
|
3222e03cea | ||
|
|
4364c71bf9 | ||
|
|
752cfc28a1 | ||
|
|
82d3130188 | ||
|
|
9ef1c3baa2 |
18
.gitattributes
vendored
Normal file
18
.gitattributes
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
* text=auto
|
||||
|
||||
# Force Unix line endings for source files
|
||||
*.md text eol=lf
|
||||
*.ts text eol=lf
|
||||
*.js text eol=lf
|
||||
*.json text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.yaml text eol=lf
|
||||
*.tsx text eol=lf
|
||||
*.jsx text eol=lf
|
||||
*.css text eol=lf
|
||||
*.scss text eol=lf
|
||||
*.html text eol=lf
|
||||
|
||||
# Force Windows line endings for build artifacts
|
||||
*.bat text eol=crlf
|
||||
*.cmd text eol=crlf
|
||||
49
.gitignore
vendored
49
.gitignore
vendored
@@ -4,22 +4,41 @@ out
|
||||
.DS_Store
|
||||
.eslintcache
|
||||
*.log*
|
||||
.npmrc
|
||||
|
||||
#AI Agent
|
||||
# AI Agent
|
||||
.claude
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# Data
|
||||
data/temp/*
|
||||
data/output/*
|
||||
data/reports/*
|
||||
!data/temp/.gitkeep
|
||||
!data/output/.gitkeep
|
||||
!data/reports/.gitkeep
|
||||
|
||||
# Environment
|
||||
config/.env
|
||||
config/.env.local
|
||||
.env
|
||||
|
||||
# Test outputs
|
||||
coverage/
|
||||
downloads/
|
||||
*.xlsx
|
||||
*.parsed.json
|
||||
|
||||
# Test scripts (debug and manual)
|
||||
tests/debug/
|
||||
tests/manual/
|
||||
test-*.mjs
|
||||
test-*.js
|
||||
compare_*.js
|
||||
|
||||
# logs
|
||||
logs
|
||||
|
||||
# oh my opencode
|
||||
.sisyphus
|
||||
|
||||
# Runtime config files
|
||||
*.yaml
|
||||
*.yaml.backup
|
||||
# But keep config.template.yaml
|
||||
!config.template.yaml
|
||||
|
||||
# nul
|
||||
nul
|
||||
|
||||
# TypeScript incremental compilation cache
|
||||
*.tsbuildinfo
|
||||
176
BROWSER_DEPLOYMENT.md
Normal file
176
BROWSER_DEPLOYMENT.md
Normal file
@@ -0,0 +1,176 @@
|
||||
# Playwright 浏览器部署指南
|
||||
|
||||
## 概述
|
||||
|
||||
本文档为开发人员提供 ERPAuto 应用程序中 Playwright Chromium 浏览器的部署指南。文档说明如何将浏览器文件从开发机复制到目标用户机器,确保应用程序能够正常运行浏览器自动化任务。
|
||||
|
||||
**重要提示**:部署前请先阅读 [BROWSER_VERSIONS.md](./BROWSER_VERSIONS.md) 了解版本信息。
|
||||
|
||||
## 目标路径
|
||||
|
||||
浏览器文件必须部署在以下路径:
|
||||
|
||||
```
|
||||
%APPDATA%\erpauto\ms-playwright\chromium-1208\
|
||||
```
|
||||
|
||||
完整展开路径示例(Windows):
|
||||
|
||||
```
|
||||
C:\Users\<用户名>\AppData\Roaming\erpauto\ms-playwright\chromium-1208\
|
||||
```
|
||||
|
||||
## 目录结构
|
||||
|
||||
部署完成后,目标目录结构应如下所示:
|
||||
|
||||
```
|
||||
%APPDATA%\erpauto\ms-playwright\
|
||||
└── chromium-1208/
|
||||
└── chrome-win/
|
||||
├── chrome.exe # 浏览器可执行文件
|
||||
├── chrome_dll.dll
|
||||
├── resources/
|
||||
├── locales/
|
||||
└── ... # 其他浏览器文件
|
||||
```
|
||||
|
||||
**关键文件**:`chrome-win/chrome.exe` 必须存在,否则浏览器无法启动。
|
||||
|
||||
## 部署步骤
|
||||
|
||||
### 步骤 1:在开发机上准备
|
||||
|
||||
1. 确保开发机已安装正确版本的 Playwright:
|
||||
|
||||
```bash
|
||||
npm install playwright@1.58.2
|
||||
```
|
||||
|
||||
2. 下载 Chromium 浏览器:
|
||||
|
||||
```bash
|
||||
npx playwright install chromium
|
||||
```
|
||||
|
||||
3. 定位开发机上的浏览器缓存目录:
|
||||
|
||||
```
|
||||
C:\Users\<开发机用户名>\AppData\Local\ms-playwright\chromium-1208
|
||||
```
|
||||
|
||||
### 步骤 2:复制文件
|
||||
|
||||
1. **复制整个浏览器目录**
|
||||
- 将开发机上的 `chromium-1208` 目录完整复制
|
||||
- 不要只复制部分文件,确保所有子目录和文件都包含在内
|
||||
|
||||
2. **粘贴到目标路径**
|
||||
- 在目标机器上创建目录:`%APPDATA%\erpauto\ms-playwright\`
|
||||
- 将 `chromium-1208` 目录粘贴到该路径下
|
||||
|
||||
3. **验证文件完整性**
|
||||
- 确认目标路径存在:`%APPDATA%\erpauto\ms-playwright\chromium-1208\chrome-win\chrome.exe`
|
||||
- 检查文件大小约为 280MB
|
||||
|
||||
### 步骤 3:配置环境变量(可选)
|
||||
|
||||
如需确保应用程序使用正确的浏览器路径,可设置以下环境变量:
|
||||
|
||||
```batch
|
||||
set PLAYWRIGHT_BROWSERS_PATH=%APPDATA%\erpauto\ms-playwright
|
||||
```
|
||||
|
||||
或在应用程序代码中设置:
|
||||
|
||||
```javascript
|
||||
process.env.PLAYWRIGHT_BROWSERS_PATH = path.join(app.getPath('userData'), 'ms-playwright')
|
||||
```
|
||||
|
||||
## 验证步骤
|
||||
|
||||
部署完成后,执行以下验证步骤:
|
||||
|
||||
### 验证 1:检查目录结构
|
||||
|
||||
在目标机器上运行:
|
||||
|
||||
```batch
|
||||
dir %APPDATA%\erpauto\ms-playwright\chromium-1208\chrome-win\chrome.exe
|
||||
```
|
||||
|
||||
应显示文件存在。
|
||||
|
||||
### 验证 2:启动浏览器测试
|
||||
|
||||
运行 ERPAuto 应用程序,执行以下操作:
|
||||
|
||||
1. 登录应用程序
|
||||
2. 进入「数据提取」页面
|
||||
3. 输入一个有效订单号
|
||||
4. 点击「开始提取」
|
||||
5. 观察浏览器是否正常启动并执行任务
|
||||
|
||||
### 验证 3:检查日志
|
||||
|
||||
查看应用程序日志,确认没有浏览器相关的错误信息:
|
||||
|
||||
- 无 "browser not found" 错误
|
||||
- 无 "chromium revision not found" 错误
|
||||
- 无 "PLAYWRIGHT_BROWSERS_PATH" 相关警告
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 问题 1:浏览器无法启动
|
||||
|
||||
**症状**:应用程序报错,提示找不到浏览器或启动失败。
|
||||
|
||||
**解决方案**:
|
||||
|
||||
1. 确认修订号匹配(必须是 1208)
|
||||
2. 检查 `chrome-win/chrome.exe` 文件是否存在
|
||||
3. 验证 `PLAYWRIGHT_BROWSERS_PATH` 环境变量设置正确
|
||||
4. 确认目标机器具有相同的 Playwright 版本(1.58.2)
|
||||
|
||||
### 问题 2:版本不匹配错误
|
||||
|
||||
**症状**:应用程序启动时报出版本冲突错误。
|
||||
|
||||
**解决方案**:
|
||||
|
||||
1. 检查 `package.json` 中的 Playwright 版本是否为 1.58.2
|
||||
2. 确认复制的 Chromium 修订号为 1208
|
||||
3. 参考 [BROWSER_VERSIONS.md](./BROWSER_VERSIONS.md) 核对所有版本信息
|
||||
|
||||
### 问题 3:权限不足
|
||||
|
||||
**症状**:无法写入或读取浏览器目录。
|
||||
|
||||
**解决方案**:
|
||||
|
||||
1. 确保目标目录具有适当的读写权限
|
||||
2. 以管理员身份运行应用程序进行测试
|
||||
3. 检查防病毒软件是否阻止了浏览器执行
|
||||
|
||||
### 问题 4:路径错误
|
||||
|
||||
**症状**:应用程序在错误的位置查找浏览器文件。
|
||||
|
||||
**解决方案**:
|
||||
|
||||
1. 确认 `%APPDATA%` 环境变量指向正确的用户目录
|
||||
2. 检查应用程序是否正确解析了 `userData` 路径
|
||||
3. 在代码中硬编码浏览器路径进行调试
|
||||
|
||||
## 注意事项
|
||||
|
||||
- **仅部署 Chromium**:ERPAuto 只需要 Chromium 浏览器,不需要 Firefox 或 WebKit
|
||||
- **版本一致性**:开发机和目标机器的 Playwright 版本必须一致
|
||||
- **修订号匹配**:Chromium 修订号(1208)必须完全匹配,否则可能出现兼容性问题
|
||||
- **文件完整性**:复制时确保所有文件完整,损坏的浏览器文件会导致启动失败
|
||||
- **网络隔离环境**:目标机器如果无法访问互联网,必须提前部署浏览器文件,因为无法自动下载
|
||||
|
||||
## 参考文档
|
||||
|
||||
- [BROWSER_VERSIONS.md](./BROWSER_VERSIONS.md) - 版本信息和目录结构详情
|
||||
- [README.md](./README.md) - 项目总体说明
|
||||
96
BROWSER_VERSIONS.md
Normal file
96
BROWSER_VERSIONS.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# Playwright 浏览器版本信息
|
||||
|
||||
本文档记录 ERPAuto 项目使用的 Playwright 浏览器版本和部署信息。
|
||||
|
||||
## 版本信息
|
||||
|
||||
| 组件 | 版本号 |
|
||||
| --------------- | ------------ |
|
||||
| Playwright | 1.58.2 |
|
||||
| Chromium | 145.0.7632.6 |
|
||||
| Chromium 修订号 | 1208 |
|
||||
|
||||
## 浏览器目录结构
|
||||
|
||||
Playwright 将浏览器文件缓存在以下位置:
|
||||
|
||||
### Windows 开发环境
|
||||
|
||||
```
|
||||
C:\Users\<用户名>\AppData\Local\ms-playwright\
|
||||
└── chromium-1208/
|
||||
└── chrome-win/
|
||||
├── chrome.exe
|
||||
└── ...
|
||||
```
|
||||
|
||||
### 目标部署环境
|
||||
|
||||
```
|
||||
%APPDATA%\erpauto\ms-playwright\
|
||||
└── chromium-1208/
|
||||
└── chrome-win/
|
||||
├── chrome.exe
|
||||
└── ...
|
||||
```
|
||||
|
||||
## 部署指南
|
||||
|
||||
### 开发环境准备
|
||||
|
||||
1. 安装 Playwright 1.58.2
|
||||
|
||||
```bash
|
||||
npm install playwright@1.58.2
|
||||
```
|
||||
|
||||
2. 下载 Chromium 浏览器
|
||||
```bash
|
||||
npx playwright install chromium
|
||||
```
|
||||
|
||||
### 浏览器文件复制步骤
|
||||
|
||||
1. **定位源目录**
|
||||
- 开发机上找到 Playwright 浏览器缓存目录
|
||||
- 默认路径:`C:\Users\<用户名>\AppData\Local\ms-playwright\chromium-1208`
|
||||
|
||||
2. **复制浏览器文件**
|
||||
- 将整个 `chromium-1208` 目录复制到部署目标
|
||||
- 目标路径:`%APPDATA%\erpauto\ms-playwright\chromium-1208`
|
||||
|
||||
3. **验证目录结构**
|
||||
- 确认目标路径包含 `chrome-win/chrome.exe`
|
||||
- 确保所有子文件完整复制
|
||||
|
||||
### 环境变量配置
|
||||
|
||||
如需要自定义浏览器路径,可设置环境变量:
|
||||
|
||||
```bash
|
||||
# Windows
|
||||
set PLAYWRIGHT_BROWSERS_PATH=%APPDATA%\erpauto\ms-playwright
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 仅包含 Chromium 浏览器(Firefox 和 WebKit 不需要)
|
||||
- 浏览器文件体积约为 280MB
|
||||
- 部署时确保目标机器具有相同的 Playwright 版本(1.58.2)
|
||||
- 修订号必须匹配(1208),否则可能出现兼容性问题
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 浏览器无法启动
|
||||
|
||||
1. 检查修订号是否匹配(1208)
|
||||
2. 确认 `chrome-win/chrome.exe` 文件存在
|
||||
3. 验证 PLAYWRIGHT_BROWSERS_PATH 环境变量设置
|
||||
|
||||
### 版本不匹配错误
|
||||
|
||||
确保以下版本一致:
|
||||
|
||||
- package.json 中的 Playwright 版本
|
||||
- 下载的 Chromium 修订号
|
||||
- browsers.json 中定义版本号
|
||||
141
CLAUDE.md
Normal file
141
CLAUDE.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Running the Application
|
||||
|
||||
```bash
|
||||
npm run dev # Start development server with hot reload
|
||||
npm run build # Full build with type checking
|
||||
npm run build:win # Build Windows executable
|
||||
```
|
||||
|
||||
### Code Quality
|
||||
|
||||
```bash
|
||||
npm run lint # ESLint check
|
||||
npm run format # Prettier format
|
||||
npm run typecheck # TypeScript check (both main and renderer)
|
||||
npm run typecheck:node # TypeScript check for main process only
|
||||
npm run typecheck:web # TypeScript check for renderer only
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
npm run test # Run unit tests (Vitest)
|
||||
npm run test:coverage # Run tests with coverage report
|
||||
npm run test:e2e # Run E2E tests (Playwright)
|
||||
npm run test:e2e:ui # Run E2E tests with UI
|
||||
npm run test:e2e:report # Show E2E test report
|
||||
```
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
ERPAuto is an **Electron desktop application** for automating ERP system data processing. The application follows the classic Electron architecture with three distinct processes:
|
||||
|
||||
### Process Structure
|
||||
|
||||
1. **Main Process** (`src/main/`)
|
||||
- Node.js environment managing application lifecycle
|
||||
- Entry point: `src/main/index.ts`
|
||||
- Registers all IPC handlers via `registerIpcHandlers()`
|
||||
- Loads configuration from `config.yaml` via ConfigManager at startup
|
||||
|
||||
2. **Preload Script** (`src/preload/`)
|
||||
- Security bridge between main and renderer processes
|
||||
- Exposes type-safe APIs via `contextBridge` as `window.electron` and `window.api`
|
||||
- Central API surface organized by domain (auth, extractor, cleaner, database, etc.)
|
||||
|
||||
3. **Renderer Process** (`src/renderer/`)
|
||||
- React 19 + TypeScript UI
|
||||
- Uses exposed preload APIs for all main process communication
|
||||
- Authentication-based routing with role-based access control
|
||||
|
||||
### Service Architecture
|
||||
|
||||
The main process is organized around domain-specific services in `src/main/services/`:
|
||||
|
||||
- **ERP Services** (`services/erp/`): Browser automation using Playwright
|
||||
- `ExtractorService` - Downloads material plan data
|
||||
- `CleanerService` - Deletes specified materials with dry-run support
|
||||
- `ErpAuthService` - Handles ERP authentication
|
||||
- `OrderResolverService` - Validates and resolves order numbers
|
||||
- `locators.ts` - ERP element selectors
|
||||
|
||||
- **Database Services** (`services/database/`): Dual database support
|
||||
- `MySqlService` / `mysql.ts` - MySQL operations
|
||||
- `SqlServerService` / `sql-server.ts` - SQL Server operations
|
||||
- DAO pattern: `discrete-material-plan-dao.ts`, `materials-to-be-deleted-dao.ts`
|
||||
|
||||
- **User Services** (`services/user/`): Authentication and session management
|
||||
- `BipUsersDao` - User data access
|
||||
- `SessionManager` - Active session tracking
|
||||
|
||||
- **Other Services**:
|
||||
- `config/` - Configuration management
|
||||
- `excel/` - Excel file parsing
|
||||
|
||||
### IPC Handler Pattern
|
||||
|
||||
All IPC communication follows a consistent pattern:
|
||||
|
||||
- Handlers are in `src/main/ipc/`, organized by domain (8 modules)
|
||||
- Each handler module exports a `register*Handlers()` function
|
||||
- All handlers are registered in `src/main/ipc/index.ts`
|
||||
- Channel naming follows `domain:action` convention (e.g., `extractor:run`, `auth:login`)
|
||||
|
||||
### Authentication Flow
|
||||
|
||||
The application implements a multi-stage authentication system:
|
||||
|
||||
1. **Silent Login**: On startup, attempts automatic login using computer name
|
||||
2. **Fallback**: Shows login dialog if silent login fails
|
||||
3. **Admin User Selection**: Admin users can switch to other user accounts
|
||||
4. **Session Management**: Persistent sessions with role-based permissions (Admin/User/Guest)
|
||||
|
||||
Admin users see logout buttons and can access user switching. Non-admin users have restricted access based on the user who initiated their session.
|
||||
|
||||
### Type System
|
||||
|
||||
- Separate TypeScript configs: `tsconfig.node.json` (main/preload) and `tsconfig.web.json` (renderer)
|
||||
- Types are co-located with features: `src/main/types/` contains domain-specific type definitions
|
||||
- The preload script exposes a typed API surface that's available in renderer
|
||||
|
||||
### Path Aliases
|
||||
|
||||
- `@renderer` → `src/renderer/src` (renderer process)
|
||||
- `@main` → `src/main` (main process, tests only)
|
||||
- `@services` → `src/main/services` (main process, tests only)
|
||||
- `@types` → `src/main/types` (main process, tests only)
|
||||
|
||||
## Configuration Management
|
||||
|
||||
The application uses a YAML-based configuration system (`config.yaml`) managed by `ConfigManager`:
|
||||
|
||||
- **Development**: `config.yaml` in project root (easy to edit and version control)
|
||||
- **Production**: `config.yaml` in user data directory (AppData on Windows)
|
||||
|
||||
Key configurations in `config.yaml`:
|
||||
|
||||
- **ERP Settings**: URL (fixed infrastructure)
|
||||
- **Database**: MySQL and SQL Server connection configs (dual support)
|
||||
- **Paths**: Data directory and output file settings
|
||||
- **Extraction**: Batch size, verbosity, persistence options
|
||||
- **Validation**: Data source, batch size, match mode
|
||||
- **Order Resolution**: Database table and field names for order number lookup
|
||||
|
||||
Note: ERP credentials (username/password) are stored in the database (`dbo_BIPUsers` table) per user, managed via the Settings UI.
|
||||
|
||||
## Key Technologies
|
||||
|
||||
- **Electron 39** - Desktop framework
|
||||
- **React 19** - UI framework
|
||||
- **TypeScript 5.9** - Type safety
|
||||
- **Playwright 1.58** - Browser automation for ERP interaction
|
||||
- **electron-vite + Vite 7** - Build tooling
|
||||
- **Zod** - Runtime validation
|
||||
- **Vitest** - Unit tests
|
||||
- **Playwright Test** - E2E tests
|
||||
101
PLAYWRIGHT_DEPLOYMENT.md
Normal file
101
PLAYWRIGHT_DEPLOYMENT.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# Playwright 部署说明
|
||||
|
||||
## 浏览器路径问题修复
|
||||
|
||||
### 问题描述
|
||||
|
||||
应用启动时提示"浏览器文件未找到",但浏览器文件已经放置在正确路径下。
|
||||
|
||||
**原因**:Playwright 1.48+ 改变了浏览器目录命名规则:
|
||||
|
||||
- **旧格式**:`chromium-win32/chrome.exe`
|
||||
- **新格式**:`chromium-1208/chrome-win64/chrome.exe`(revision 号可能不同)
|
||||
|
||||
### 解决方案
|
||||
|
||||
代码已更新为自动检测新旧两种格式,并显示当前目录内容以便调试。
|
||||
|
||||
## 快速部署
|
||||
|
||||
### 方法 1:使用 Playwright CLI(推荐)
|
||||
|
||||
```bash
|
||||
# 在应用目录运行
|
||||
npx playwright install chromium
|
||||
```
|
||||
|
||||
浏览器会自动下载并安装到正确位置:
|
||||
|
||||
- **用户数据目录**:`%APPDATA%\erpauto\ms-playwright\`
|
||||
- **完整路径**:`C:\Users\pengq\AppData\Roaming\erpauto\ms-playwright\chromium-<revision>\chrome-win64\chrome.exe`
|
||||
|
||||
### 方法 2:手动复制
|
||||
|
||||
如果你已经有 Playwright 浏览器文件,可以复制到应用的用户数据目录:
|
||||
|
||||
1. 找到现有浏览器文件(通常在 `%USERPROFILE%\AppData\Local\ms-playwright`)
|
||||
2. 复制到 `%APPDATA%\erpauto\ms-playwright\`
|
||||
3. 确保目录结构正确:
|
||||
```
|
||||
ms-playwright/
|
||||
├── chromium-1208/
|
||||
│ ├── chrome-win64/
|
||||
│ │ └── chrome.exe
|
||||
│ └── INSTALLATION_COMPLETE
|
||||
└── chromium_headless_shell-1208/
|
||||
└── ...
|
||||
```
|
||||
|
||||
### 方法 3:使用 PLAYWRIGHT_BROWSERS_PATH 环境变量
|
||||
|
||||
将浏览器文件放在共享位置,然后设置环境变量:
|
||||
|
||||
```bash
|
||||
# 系统环境变量
|
||||
setx PLAYWRIGHT_BROWSERS_PATH "D:\shared\playwright-browsers"
|
||||
```
|
||||
|
||||
或在应用启动脚本中设置。
|
||||
|
||||
## 验证安装
|
||||
|
||||
运行应用后,检查是否还有错误提示。如果没有浏览器错误,说明安装成功。
|
||||
|
||||
你也可以在应用日志中查找:
|
||||
|
||||
- `Found Chromium revision: chromium-1208` - 表示成功找到浏览器
|
||||
- `Playwright browser not found` - 表示未找到,会显示可用目录列表
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 显示"浏览器文件未找到"但文件确实在那里
|
||||
|
||||
检查目录结构是否正确:
|
||||
|
||||
```powershell
|
||||
# 查看当前目录内容
|
||||
Get-ChildItem $env:APPDATA\erpauto\ms-playwright
|
||||
|
||||
# 检查 chrome.exe 是否存在
|
||||
Test-Path "$env:APPDATA\erpauto\ms-playwright\chromium-*/chrome-win64/chrome.exe"
|
||||
```
|
||||
|
||||
### Q: 不同用户使用同一个浏览器文件
|
||||
|
||||
使用环境变量 `PLAYWRIGHT_BROWSERS_PATH` 指向共享目录。
|
||||
|
||||
### Q: 离线部署
|
||||
|
||||
1. 在有网络的机器上运行 `npx playwright install chromium`
|
||||
2. 复制整个 `ms-playwright` 目录
|
||||
3. 在目标机器上设置 `PLAYWRIGHT_BROWSERS_PATH` 指向该目录
|
||||
|
||||
## 下次构建
|
||||
|
||||
只需运行:
|
||||
|
||||
```bash
|
||||
npm run build:win
|
||||
```
|
||||
|
||||
所有配置已保存,Playwright 模块和浏览器路径检查会自动处理。
|
||||
189
README.md
189
README.md
@@ -1,34 +1,191 @@
|
||||
# erpauto
|
||||
# ERPAuto - ERP 数据自动化处理工具
|
||||
|
||||
An Electron application with React and TypeScript
|
||||
一个基于 Electron 的桌面应用程序,用于自动化处理 ERP 系统中的数据提取和清理任务。
|
||||
|
||||
## Recommended IDE Setup
|
||||
## 功能特性
|
||||
|
||||
- [VSCode](https://code.visualstudio.com/) + [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) + [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode)
|
||||
- **数据提取**:从 ERP 系统批量下载物料计划数据
|
||||
- **物料清理**:自动删除指定的物料代码,支持干运行模式
|
||||
- **数据库支持**:支持 MySQL 和 SQL Server 数据存储
|
||||
- **Excel 解析**:自动解析下载的 Excel 文件
|
||||
|
||||
## Project Setup
|
||||
## 快速开始
|
||||
|
||||
### Install
|
||||
### 环境要求
|
||||
|
||||
- Node.js >= 18
|
||||
- npm >= 9
|
||||
- 可访问的 ERP 系统
|
||||
|
||||
### 安装
|
||||
|
||||
```bash
|
||||
$ npm install
|
||||
# 克隆项目
|
||||
git clone <repository-url>
|
||||
cd ERPAuto
|
||||
|
||||
# 安装依赖
|
||||
npm install
|
||||
```
|
||||
|
||||
### Development
|
||||
### 配置
|
||||
|
||||
在项目根目录创建 `config.yaml` 文件(可参考 `config.template.yaml`):
|
||||
|
||||
```yaml
|
||||
# ERP 配置(固定基础设施)
|
||||
erp:
|
||||
url: https://your-erp-server.com
|
||||
|
||||
# 数据库配置
|
||||
database:
|
||||
activeType: mysql # 或 sqlserver
|
||||
|
||||
mysql:
|
||||
host: localhost
|
||||
port: 3306
|
||||
database: erpauto
|
||||
username: root
|
||||
password: your_password
|
||||
charset: utf8mb4
|
||||
|
||||
sqlserver:
|
||||
server: localhost
|
||||
port: 1433
|
||||
database: erpauto
|
||||
username: sa
|
||||
password: your_password
|
||||
driver: 'ODBC Driver 18 for SQL Server'
|
||||
trustServerCertificate: true
|
||||
|
||||
# 路径配置
|
||||
paths:
|
||||
dataDir: './data/'
|
||||
defaultOutput: 'output.xlsx'
|
||||
validationOutput: 'validation-result.xlsx'
|
||||
```
|
||||
|
||||
**注意**:ERP 用户名和密码在应用的设置界面中配置,存储在数据库中(按用户管理)。
|
||||
|
||||
### 运行开发环境
|
||||
|
||||
```bash
|
||||
$ npm run dev
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Build
|
||||
### 构建应用
|
||||
|
||||
```bash
|
||||
# For windows
|
||||
$ npm run build:win
|
||||
# Windows
|
||||
npm run build:win
|
||||
|
||||
# For macOS
|
||||
$ npm run build:mac
|
||||
# macOS
|
||||
npm run build:mac
|
||||
|
||||
# For Linux
|
||||
$ npm run build:linux
|
||||
# Linux
|
||||
npm run build:linux
|
||||
```
|
||||
|
||||
## 使用指南
|
||||
|
||||
### 数据提取
|
||||
|
||||
1. 启动应用后,点击主页的「数据提取」进入提取页面
|
||||
2. 在订单号输入框中输入订单号,每行一个
|
||||
3. 设置批量大小(默认 100)
|
||||
4. 点击「开始提取」按钮
|
||||
5. 等待提取完成,查看结果
|
||||
|
||||
### 物料清理
|
||||
|
||||
1. 点击主页的「物料清理」进入清理页面
|
||||
2. 输入订单号(每行一个)
|
||||
3. 输入要删除的物料代码(每行一个)
|
||||
4. 勾选「干运行模式」可预览删除结果(不实际删除)
|
||||
5. 点击「开始清理」按钮
|
||||
6. 查看清理结果和详细统计
|
||||
|
||||
## 测试
|
||||
|
||||
```bash
|
||||
# 运行单元测试
|
||||
npm run test
|
||||
|
||||
# 运行 E2E 测试
|
||||
npm run test:e2e
|
||||
|
||||
# 查看测试报告
|
||||
npm run test:e2e:report
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
ERPAuto/
|
||||
├── src/
|
||||
│ ├── main/ # 主进程代码
|
||||
│ │ ├── services/ # 业务服务
|
||||
│ │ ├── ipc/ # IPC 处理器
|
||||
│ │ └── types/ # TypeScript 类型
|
||||
│ ├── preload/ # 预加载脚本
|
||||
│ └── renderer/ # 渲染进程(React UI)
|
||||
├── tests/
|
||||
│ ├── unit/ # 单元测试
|
||||
│ ├── integration/ # 集成测试
|
||||
│ └── e2e/ # E2E 测试
|
||||
└── docs/ # 文档
|
||||
```
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **框架**:Electron 39
|
||||
- **前端**:React 19 + TypeScript
|
||||
- **构建工具**:electron-vite
|
||||
- **浏览器自动化**:Playwright
|
||||
- **数据库**:mysql2, mssql
|
||||
- **Excel 处理**:ExcelJS
|
||||
- **测试**:Vitest, Playwright Test
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 无法连接 ERP 系统
|
||||
|
||||
1. 检查 `config.yaml` 中的 ERP URL 是否正确
|
||||
2. 确认网络连接正常
|
||||
3. 检查 ERP 系统是否可访问
|
||||
4. 在设置界面中确认 ERP 用户名和密码已配置
|
||||
|
||||
### 提取失败
|
||||
|
||||
1. 确认订单号格式正确
|
||||
2. 检查 ERP 系统账号权限
|
||||
3. 查看应用日志获取详细错误信息
|
||||
|
||||
### 数据库连接失败
|
||||
|
||||
1. 确认数据库服务已启动
|
||||
2. 检查 `config.yaml` 中的数据库配置
|
||||
3. 确认防火墙允许数据库端口访问
|
||||
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
npm install
|
||||
|
||||
# 启动开发服务器
|
||||
npm run dev
|
||||
|
||||
# 类型检查
|
||||
npm run typecheck
|
||||
|
||||
# 代码格式化
|
||||
npm run format
|
||||
|
||||
# Lint 检查
|
||||
npm run lint
|
||||
```
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT License
|
||||
|
||||
72
config.template.yaml
Normal file
72
config.template.yaml
Normal file
@@ -0,0 +1,72 @@
|
||||
# ================================
|
||||
# ERPAuto 配置模板
|
||||
# ================================
|
||||
# 部署说明:
|
||||
# 1. 复制此文件为 config.yaml
|
||||
# 2. 根据实际环境修改配置值
|
||||
# 3. 设置 database.activeType 为 mysql 或 sqlserver
|
||||
# ================================
|
||||
# 注意:ERP 认证信息存储在数据库 (dbo_BIPUsers) 中,按用户管理
|
||||
# ================================
|
||||
|
||||
database:
|
||||
activeType: mysql
|
||||
|
||||
mysql:
|
||||
host: <MYSQL_HOST>
|
||||
port: 3306
|
||||
database: <DATABASE_NAME>
|
||||
username: <USERNAME>
|
||||
password: <PASSWORD>
|
||||
charset: utf8mb4
|
||||
|
||||
sqlserver:
|
||||
server: <SQL_SERVER_HOST>
|
||||
port: 1433
|
||||
database: <DATABASE_NAME>
|
||||
username: <USERNAME>
|
||||
password: <PASSWORD>
|
||||
driver: 'ODBC Driver 18 for SQL Server'
|
||||
trustServerCertificate: true
|
||||
|
||||
paths:
|
||||
dataDir: './data/'
|
||||
defaultOutput: 'output.xlsx'
|
||||
validationOutput: 'validation-result.xlsx'
|
||||
|
||||
extraction:
|
||||
batchSize: 100
|
||||
verbose: true
|
||||
autoConvert: true
|
||||
mergeBatches: true
|
||||
enableDbPersistence: true
|
||||
|
||||
validation:
|
||||
dataSource: database_full
|
||||
batchSize: 2000
|
||||
matchMode: substring
|
||||
enableCrud: false
|
||||
defaultManager: ''
|
||||
|
||||
orderResolution:
|
||||
tableName: ''
|
||||
productionIdField: ''
|
||||
orderNumberField: ''
|
||||
|
||||
cleaner:
|
||||
queryBatchSize: 100
|
||||
processConcurrency: 1
|
||||
|
||||
logging:
|
||||
level: info
|
||||
auditRetention: 30
|
||||
appRetention: 14
|
||||
|
||||
# RustFS 对象存储配置(用于持久化报告)
|
||||
rustfs:
|
||||
enabled: false # 设置为 true 启用 RustFS 上传
|
||||
endpoint: 'http://192.168.110.114:9000' # RustFS 服务器地址
|
||||
accessKey: '<YOUR_ACCESS_KEY>' # 访问密钥
|
||||
secretKey: '<YOUR_SECRET_KEY>' # 密钥
|
||||
bucket: 'erpauto' # 存储桶名称
|
||||
region: 'us-east-1' # 区域(S3 兼容,默认即可)
|
||||
@@ -1,18 +0,0 @@
|
||||
# SQL Server
|
||||
SQL_SERVER_SERVER=192.168.110.114
|
||||
SQL_SERVER_DATABASE=CompanyDB
|
||||
SQL_SERVER_USERNAME=peng
|
||||
SQL_SERVER_PASSWORD=your_password_here
|
||||
SQL_SERVER_DRIVER=ODBC Driver 18 for SQL Server
|
||||
SQL_SERVER_TRUST_CERT=yes
|
||||
|
||||
# MySQL
|
||||
MYSQL_HOST=localhost
|
||||
MYSQL_PORT=3306
|
||||
MYSQL_DATABASE=erp_db
|
||||
MYSQL_USERNAME=root
|
||||
MYSQL_PASSWORD=your_password_here
|
||||
|
||||
# ERP Credentials (optional, can also be entered in UI)
|
||||
ERP_USERNAME=BLDpengqiangqiang
|
||||
ERP_PASSWORD=your_password_here
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"appName": "ERPAuto",
|
||||
"version": "1.0.0",
|
||||
"logLevel": "info",
|
||||
"browser": {
|
||||
"headless": false,
|
||||
"slowMo": 50,
|
||||
"timeout": 30000
|
||||
},
|
||||
"erp": {
|
||||
"baseUrl": "",
|
||||
"ignoreHttpsErrors": false
|
||||
},
|
||||
"paths": {
|
||||
"tempDir": "./data/temp",
|
||||
"outputDir": "./data/output",
|
||||
"reportDir": "./data/reports"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"logLevel": "debug",
|
||||
"browser": {
|
||||
"headless": false,
|
||||
"slowMo": 100
|
||||
}
|
||||
}
|
||||
300
docs/CONFIG_FILE_LOCATION.md
Normal file
300
docs/CONFIG_FILE_LOCATION.md
Normal file
@@ -0,0 +1,300 @@
|
||||
# ERPAuto 配置文件位置说明
|
||||
|
||||
## 概述
|
||||
|
||||
ERPAuto 根据运行环境自动选择配置文件的存储位置:
|
||||
|
||||
- **开发环境**:项目根目录(方便编辑和版本控制)
|
||||
- **生产环境**:用户数据目录(AppData,安全且升级时保留)
|
||||
|
||||
---
|
||||
|
||||
## 配置文件位置
|
||||
|
||||
### 1. 开发环境
|
||||
|
||||
**适用场景**:
|
||||
|
||||
- 开发和调试
|
||||
- 配置需要版本控制
|
||||
- 团队协作
|
||||
|
||||
**配置文件位置**:
|
||||
|
||||
```
|
||||
<项目根目录>\config.yaml
|
||||
```
|
||||
|
||||
**示例**:
|
||||
|
||||
```
|
||||
D:\Projects\ERPAuto\
|
||||
├── src\
|
||||
├── package.json
|
||||
├── config.yaml # 开发配置
|
||||
├── config.yaml.backup # 自动备份
|
||||
└── config.template.yaml # 配置模板
|
||||
```
|
||||
|
||||
**检测方式**:
|
||||
|
||||
```typescript
|
||||
process.env.NODE_ENV === 'development' || !app.isPackaged
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. 生产环境(安装版和便携版)
|
||||
|
||||
**适用场景**:
|
||||
|
||||
- 正式发布的应用
|
||||
- 配置需要在应用升级时保留
|
||||
- 多用户环境,每个用户独立配置
|
||||
|
||||
**配置文件位置**:
|
||||
|
||||
```
|
||||
Windows: C:\Users\<用户名>\AppData\Roaming\erpauto\config.yaml
|
||||
macOS: ~/Library/Application Support/erpauto/config.yaml
|
||||
Linux: ~/.config/erpauto/config.yaml
|
||||
```
|
||||
|
||||
**示例**:
|
||||
|
||||
```
|
||||
C:\Users\zhangsan\AppData\Roaming\erpauto\
|
||||
├── config.yaml # 用户配置
|
||||
└── config.yaml.backup # 自动备份
|
||||
```
|
||||
|
||||
**检测方式**:
|
||||
|
||||
```typescript
|
||||
app.isPackaged === true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 为什么生产环境使用用户数据目录?
|
||||
|
||||
| 方案 | 配置位置 | 优点 | 缺点 |
|
||||
| ------------------ | --------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
|
||||
| **用户数据目录** ✓ | AppData\Roaming | • 应用升级时配置保留<br>• 符合 Windows 规范<br>• 多用户隔离<br>• 配置不暴露 | • 路径较深,不易访问 |
|
||||
| **应用同目录** ✗ | .exe 同目录 | • 易于访问和编辑 | • 应用升级时配置可能丢失<br>• 需要写权限<br>• 配置暴露在应用目录<br>• 多用户共享配置 |
|
||||
|
||||
**我们的选择**:生产环境统一使用用户数据目录,确保:
|
||||
|
||||
1. ✅ 应用升级时用户配置不会丢失
|
||||
2. ✅ 符合 Windows 应用规范
|
||||
3. ✅ 配置不暴露在应用目录,更安全
|
||||
4. ✅ 多用户环境下,每个用户有独立配置
|
||||
|
||||
---
|
||||
|
||||
## 构建配置
|
||||
|
||||
### electron-builder.yml
|
||||
|
||||
```yaml
|
||||
win:
|
||||
target:
|
||||
- nsis # 安装版
|
||||
- portable # 便携版
|
||||
|
||||
portable:
|
||||
artifactName: ${name}-${version}-portable.${ext}
|
||||
# 便携版也使用用户数据目录 (AppData)
|
||||
# 不是 exe 同目录,确保配置在升级时保留
|
||||
|
||||
nsis:
|
||||
artifactName: ${name}-${version}-setup.${ext}
|
||||
```
|
||||
|
||||
### 构建命令
|
||||
|
||||
```bash
|
||||
# 构建 Windows 安装版和便携版
|
||||
npm run build:win
|
||||
```
|
||||
|
||||
### 输出文件
|
||||
|
||||
```
|
||||
dist/
|
||||
├── erpauto-1.0.0-setup.exe # 安装版
|
||||
└── erpauto-1.0.0-portable.exe # 便携版
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 配置文件结构
|
||||
|
||||
```yaml
|
||||
# ================================
|
||||
# ERPAuto 配置文件
|
||||
# ================================
|
||||
|
||||
# 数据库配置
|
||||
database:
|
||||
activeType: mysql # 切换字段:mysql 或 sqlserver
|
||||
|
||||
mysql:
|
||||
host: 192.168.31.83
|
||||
port: 3306
|
||||
database: BLD_DB
|
||||
username: remote_user
|
||||
password: ''
|
||||
charset: utf8mb4
|
||||
|
||||
sqlserver:
|
||||
server: localhost
|
||||
port: 1433
|
||||
database: BLD_DB
|
||||
username: sa
|
||||
password: ''
|
||||
driver: 'ODBC Driver 18 for SQL Server'
|
||||
trustServerCertificate: true
|
||||
|
||||
# 路径配置
|
||||
paths:
|
||||
dataDir: 'D:/python/playwrite/data/'
|
||||
defaultOutput: '离散备料计划维护_合并.xlsx'
|
||||
validationOutput: '物料状态校验结果.xlsx'
|
||||
|
||||
# 数据提取配置
|
||||
extraction:
|
||||
batchSize: 100
|
||||
verbose: true
|
||||
autoConvert: true
|
||||
mergeBatches: true
|
||||
enableDbPersistence: true
|
||||
|
||||
# 校验配置
|
||||
validation:
|
||||
dataSource: database_full
|
||||
batchSize: 2000
|
||||
matchMode: substring
|
||||
enableCrud: false
|
||||
defaultManager: ''
|
||||
|
||||
# 订单号解析配置
|
||||
orderResolution:
|
||||
tableName: 'productionContractData_26 年压力表合同数据'
|
||||
productionIdField: '总排号'
|
||||
orderNumberField: '生产订单号'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 配置文件管理
|
||||
|
||||
### 查看当前配置路径
|
||||
|
||||
运行调试工具:
|
||||
|
||||
```bash
|
||||
npx tsx src\main\tools\config-path-debug.ts
|
||||
```
|
||||
|
||||
### 快速访问配置(Windows)
|
||||
|
||||
```bash
|
||||
# 打开配置所在目录
|
||||
%APPDATA%\erpauto
|
||||
```
|
||||
|
||||
### 备份配置
|
||||
|
||||
```bash
|
||||
# 备份整个配置目录
|
||||
xcopy %APPDATA%\erpauto D:\Backup\erpauto-config /E /I
|
||||
```
|
||||
|
||||
### 迁移配置
|
||||
|
||||
从旧版本迁移:
|
||||
|
||||
```bash
|
||||
# 使用迁移脚本
|
||||
npx tsx scripts\migrate-env-to-yaml.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 便携版应用的配置为什么不放在 exe 同目录?
|
||||
|
||||
**A**:
|
||||
|
||||
- 放在 exe 同目录会导致应用升级时配置丢失
|
||||
- 便携版每次运行会解压到临时目录,无法持久保存配置
|
||||
- 使用用户数据目录(AppData)确保配置持久化
|
||||
|
||||
### Q: 如何快速访问配置文件?
|
||||
|
||||
**A**:
|
||||
|
||||
- Windows: 按 `Win + R`,输入 `%APPDATA%\erpauto`,回车
|
||||
- 或在文件管理器地址栏输入 `%APPDATA%\erpauto`
|
||||
|
||||
### Q: 多台电脑如何同步配置?
|
||||
|
||||
**A**:
|
||||
|
||||
1. 导出配置:`xcopy %APPDATA%\erpauto\config.yaml \\server\share\`
|
||||
2. 导入配置:`xcopy \\server\share\config.yaml %APPDATA%\erpauto\`
|
||||
|
||||
或使用同步工具(OneDrive、坚果云等)同步配置目录。
|
||||
|
||||
### Q: 配置文件损坏了怎么办?
|
||||
|
||||
**A**:
|
||||
|
||||
1. 删除 `config.yaml`
|
||||
2. 应用会自动创建新的默认配置
|
||||
3. 从 `config.yaml.backup` 恢复(如果存在)
|
||||
|
||||
### Q: 开发环境下如何切换配置?
|
||||
|
||||
**A**:
|
||||
|
||||
- 直接编辑项目根目录的 `config.yaml`
|
||||
- 建议保留 `config.template.yaml` 作为模板
|
||||
- 将 `config.yaml` 加入 `.gitignore`,避免提交敏感信息
|
||||
|
||||
---
|
||||
|
||||
## 技术实现
|
||||
|
||||
### ConfigManager 路径选择逻辑
|
||||
|
||||
```typescript
|
||||
// 检测是否为开发环境
|
||||
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged
|
||||
|
||||
if (isDev) {
|
||||
// 开发环境:项目根目录
|
||||
this.configPath = path.resolve(__dirname, '../../config.yaml')
|
||||
} else {
|
||||
// 生产环境(安装版和便携版):用户数据目录
|
||||
this.configPath = path.join(app.getPath('userData'), 'config.yaml')
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 版本历史
|
||||
|
||||
| 版本 | 配置策略 | 说明 |
|
||||
| ---- | ------------------------------- | -------------------- |
|
||||
| 1.0+ | 开发:项目目录<br>生产:AppData | 确保配置在升级时保留 |
|
||||
|
||||
---
|
||||
|
||||
## 参考资料
|
||||
|
||||
- [Electron app.getPath() 文档](https://www.electronjs.org/docs/api/app#appgetpathname)
|
||||
- [electron-builder 配置](https://www.electron.build/configuration.html)
|
||||
- [Windows 应用数据存储规范](https://docs.microsoft.com/en-us/windows/win32/shell/knownfolderid)
|
||||
731
docs/Configuration-Architecture-Analysis.md
Normal file
731
docs/Configuration-Architecture-Analysis.md
Normal file
@@ -0,0 +1,731 @@
|
||||
# ERPAuto 配置系统架构分析
|
||||
|
||||
## 1. 概述
|
||||
|
||||
ERPAuto 是一个基于 Electron 的桌面应用程序,采用多层次配置管理系统来支持 ERP 系统自动化数据处理。配置系统采用 `.env` 文件作为持久化存储,通过 `ConfigManager` 统一管理,支持运行时动态修改和持久化保存。
|
||||
|
||||
## 2. 配置系统整体架构
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "配置数据源"
|
||||
ENV[.env 文件]
|
||||
ENV_EXAMPLE[.env.example 模板]
|
||||
CACHE[内存缓存 ConfigCache]
|
||||
end
|
||||
|
||||
subgraph "配置管理层 ConfigManager"
|
||||
CM_LOAD[loadEnvFile]
|
||||
CM_GET[get/getBoolean/getNumber]
|
||||
CM_SET[set]
|
||||
CM_SAVE[save/saveAllSettings]
|
||||
CM_PARTIAL[savePartialSettings]
|
||||
CM_MERGE[deepMerge 深度合并]
|
||||
end
|
||||
|
||||
subgraph "IPC 通信层"
|
||||
SETTINGS_HANDLER[settings-handler.ts]
|
||||
IPC_GET[settings:getSettings]
|
||||
IPC_SAVE[settings:saveSettings]
|
||||
IPC_TEST[settings:testErpConnection/testDbConnection]
|
||||
end
|
||||
|
||||
subgraph "业务服务层"
|
||||
ERP_SVC[ERP 服务]
|
||||
DB_SVC[数据库服务]
|
||||
USER_SVC[用户服务]
|
||||
EXTRACTOR[ExtractorService]
|
||||
CLEANER[CleanerService]
|
||||
end
|
||||
|
||||
subgraph "UI 呈现层"
|
||||
SETTINGS_UI[设置界面]
|
||||
LOGIN_UI[登录界面]
|
||||
MAIN_UI[主界面]
|
||||
end
|
||||
|
||||
ENV -->|读取 | CM_LOAD
|
||||
ENV_EXAMPLE -.->|模板参考 | ENV
|
||||
CM_LOAD -->|填充 | CACHE
|
||||
CACHE --> CM_GET
|
||||
CM_SET --> CACHE
|
||||
CM_PARTIAL --> CM_MERGE --> CM_SAVE --> ENV
|
||||
|
||||
CM_GET --> SETTINGS_HANDLER
|
||||
SETTINGS_HANDLER --> IPC_GET
|
||||
SETTINGS_HANDLER --> IPC_SAVE
|
||||
SETTINGS_HANDLER --> IPC_TEST
|
||||
|
||||
IPC_GET --> SETTINGS_UI
|
||||
IPC_SAVE --> SETTINGS_UI
|
||||
IPC_TEST --> SETTINGS_UI
|
||||
|
||||
CACHE --> ERP_SVC
|
||||
CACHE --> DB_SVC
|
||||
CACHE --> USER_SVC
|
||||
CACHE --> EXTRACTOR
|
||||
CACHE --> CLEANER
|
||||
|
||||
SETTINGS_UI --> MAIN_UI
|
||||
LOGIN_UI --> USER_SVC
|
||||
```
|
||||
|
||||
## 3. 配置文件结构
|
||||
|
||||
### 3.1 .env 文件组织
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph "ERP 系统配置"
|
||||
ERP_URL[ERP_URL]
|
||||
ERP_USER[ERP_USERNAME]
|
||||
ERP_PASS[ERP_PASSWORD]
|
||||
ERP_HEADLESS[ERP_HEADLESS]
|
||||
ERP_HTTPS[ERP_IGNORE_HTTPS_ERRORS]
|
||||
ERP_CLOSE[ERP_AUTO_CLOSE_BROWSER]
|
||||
end
|
||||
|
||||
subgraph "数据库配置 - SQL Server"
|
||||
SQL_DRIVER[DB_SQLSERVER_DRIVER]
|
||||
SQL_TRUST[DB_TRUST_SERVER_CERTIFICATE]
|
||||
end
|
||||
|
||||
subgraph "数据库配置 - MySQL"
|
||||
DB_TYPE[DB_TYPE]
|
||||
DB_NAME[DB_NAME]
|
||||
DB_USER[DB_USERNAME]
|
||||
DB_PASS[DB_PASSWORD]
|
||||
MYSQL_HOST[DB_MYSQL_HOST]
|
||||
MYSQL_PORT[DB_MYSQL_PORT]
|
||||
MYSQL_CHARSET[DB_MYSQL_CHARSET]
|
||||
end
|
||||
|
||||
subgraph "订单号解析表配置"
|
||||
TABLE_NAME[DB_TABLE_NAME]
|
||||
FIELD_PROD_ID[DB_FIELD_PRODUCTION_ID]
|
||||
FIELD_ORDER[DB_FIELD_ORDER_NUMBER]
|
||||
end
|
||||
|
||||
subgraph "路径配置"
|
||||
DATA_DIR[PATH_DATA_DIR]
|
||||
PROD_ID_FILE[PATH_PRODUCTION_ID_FILE]
|
||||
DEFAULT_OUT[PATH_DEFAULT_OUTPUT]
|
||||
VALID_OUT[PATH_VALIDATION_OUTPUT]
|
||||
end
|
||||
|
||||
subgraph "数据提取配置"
|
||||
BATCH_SIZE[EXTRACTION_BATCH_SIZE]
|
||||
VERBOSE[EXTRACTION_VERBOSE]
|
||||
AUTO_CONVERT[EXTRACTION_AUTO_CONVERT]
|
||||
MERGE_BATCHES[EXTRACTION_MERGE_BATCHES]
|
||||
DB_PERSIST[EXTRACTION_ENABLE_DB_PERSISTENCE]
|
||||
end
|
||||
|
||||
subgraph "校验配置"
|
||||
DATA_SOURCE[VALIDATION_DATA_SOURCE]
|
||||
USE_DB[VALIDATION_USE_DATABASE]
|
||||
VAL_BATCH[VALIDATION_BATCH_SIZE]
|
||||
ENABLE_CRUD[VALIDATION_ENABLE_CRUD]
|
||||
DEFAULT_MGR[VALIDATION_DEFAULT_MANAGER]
|
||||
MATCH_MODE[VALIDATION_MATCH_MODE]
|
||||
end
|
||||
|
||||
subgraph "UI 配置"
|
||||
FONT_FAMILY[UI_FONT_FAMILY]
|
||||
FONT_SIZE[UI_FONT_SIZE]
|
||||
INPUT_WIDTH[UI_PRODUCTION_ID_INPUT_WIDTH]
|
||||
end
|
||||
|
||||
subgraph "执行配置"
|
||||
DRY_RUN[EXECUTION_DRYRUN]
|
||||
end
|
||||
```
|
||||
|
||||
### 3.2 默认配置值
|
||||
|
||||
| 配置类别 | 配置项 | 默认值 | 说明 |
|
||||
| ---------- | ----------------- | --------------------------- | -------------------- |
|
||||
| ERP | url | `https://68.11.34.30:8082/` | ERP 系统地址 |
|
||||
| ERP | headless | `true` | 无头浏览器模式 |
|
||||
| ERP | ignoreHttpsErrors | `true` | 忽略 HTTPS 证书错误 |
|
||||
| ERP | autoCloseBrowser | `true` | 操作后自动关闭浏览器 |
|
||||
| Database | dbType | `mysql` | 数据库类型 |
|
||||
| Database | mysqlHost | `192.168.31.83` | MySQL 主机地址 |
|
||||
| Database | mysqlPort | `3306` | MySQL 端口 |
|
||||
| Database | database | `BLD_DB` | 数据库名 |
|
||||
| Database | username | `remote_user` | 数据库用户名 |
|
||||
| Paths | dataDir | `D:/python/playwrite/data/` | 数据目录 |
|
||||
| Extraction | batchSize | `100` | 批次大小 |
|
||||
| Extraction | verbose | `true` | 详细日志 |
|
||||
| Validation | dataSource | `database_full` | 校验数据源 |
|
||||
| Validation | batchSize | `2000` | 校验批次大小 |
|
||||
| Validation | matchMode | `substring` | 匹配模式 |
|
||||
| UI | fontFamily | `Microsoft YaHei UI` | 字体 |
|
||||
| UI | fontSize | `10` | 字体大小 |
|
||||
| Execution | dryRun | `false` | 干运行模式 |
|
||||
|
||||
## 4. ConfigManager 核心类设计
|
||||
|
||||
### 4.1 类结构与单例模式
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
class ConfigManager {
|
||||
-static instance: ConfigManager | null
|
||||
-envPath: string
|
||||
-backupPath: string
|
||||
-configCache: Map<string, string>
|
||||
-initialized: boolean
|
||||
+static getInstance(): ConfigManager
|
||||
+initialize(): Promise<void>
|
||||
+get(key: string): string | undefined
|
||||
+getBoolean(key: string, default: boolean): boolean
|
||||
+getNumber(key: string, default: number): number
|
||||
+set(key: string, value: string|number|boolean): void
|
||||
+save(): Promise<boolean>
|
||||
+getAllSettings(): SettingsData
|
||||
+saveAllSettings(settings: SettingsData): Promise<boolean>
|
||||
+savePartialSettings(settings: Partial<SettingsData>): Promise<Object>
|
||||
+resetToDefaults(): SettingsData
|
||||
+getDefaultSettings(): SettingsData
|
||||
-loadEnvFile(): Promise<void>
|
||||
-backupEnvFile(): Promise<boolean>
|
||||
-restoreBackup(): Promise<boolean>
|
||||
}
|
||||
|
||||
class SettingsData {
|
||||
+erp: ErpConfig
|
||||
+database: DatabaseConfig
|
||||
+paths: PathsConfig
|
||||
+extraction: ExtractionConfig
|
||||
+validation: ValidationConfig
|
||||
+ui: UiConfig
|
||||
+execution: ExecutionConfig
|
||||
}
|
||||
|
||||
ConfigManager --> SettingsData: 返回/接收
|
||||
```
|
||||
|
||||
### 4.2 核心方法流程图
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client as 客户端/IPC
|
||||
participant CM as ConfigManager
|
||||
participant Cache as ConfigCache
|
||||
participant FS as 文件系统
|
||||
participant Backup as Backup 文件
|
||||
|
||||
Client->>CM: savePartialSettings(settings)
|
||||
activate CM
|
||||
|
||||
CM->>CM: validateEditableFields()
|
||||
alt 包含非白名单字段
|
||||
CM-->>Client: 返回错误 (不允许修改)
|
||||
else 验证通过
|
||||
CM->>FS: loadEnvFile()
|
||||
FS-->>Cache: 填充缓存
|
||||
CM->>CM: getAllSettings()
|
||||
CM->>Cache: 读取当前配置
|
||||
CM->>CM: deepMerge(current, settings)
|
||||
|
||||
CM->>FS: backupEnvFile()
|
||||
FS-->>Backup: 创建备份
|
||||
|
||||
CM->>FS: saveAllSettings(merged)
|
||||
alt 保存成功
|
||||
FS-->>Cache: 重新加载
|
||||
CM-->>Client: 返回成功
|
||||
else 保存失败
|
||||
CM->>FS: restoreBackup()
|
||||
FS-->>Cache: 恢复配置
|
||||
CM-->>Client: 返回错误
|
||||
end
|
||||
end
|
||||
deactivate CM
|
||||
```
|
||||
|
||||
### 4.3 深度合并算法
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[deepMerge 函数] --> B{遍历 target 键值对}
|
||||
B --> C{targetValue 是对象?}
|
||||
C -->|是 | D{sourceValue 也是对象?}
|
||||
D -->|是 | E[递归调用 deepMerge]
|
||||
D -->|否 | F[直接使用 targetValue]
|
||||
C -->|否 | G{targetValue !== undefined?}
|
||||
G -->|是 | H[更新该键值]
|
||||
G -->|否 | I[跳过该键]
|
||||
E --> J[合并结果存入 result]
|
||||
F --> J
|
||||
H --> J
|
||||
B --> K[遍历完成]
|
||||
K --> L[返回合并后的对象]
|
||||
```
|
||||
|
||||
## 5. 配置读取与使用模式
|
||||
|
||||
### 5.1 环境变量直接读取模式
|
||||
|
||||
各业务服务通过 `process.env` 直接读取配置:
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph "环境变量读取点"
|
||||
MAIN[main/index.ts<br/>dotenv.config]
|
||||
end
|
||||
|
||||
subgraph "服务模块"
|
||||
DB_INDEX[database/index.ts]
|
||||
DB_MYSQL[database/mysql.ts]
|
||||
DB_SQL[database/sql-server.ts]
|
||||
BIP_DAO[bip-users-dao.ts]
|
||||
ORDER_RES[order-resolver.ts]
|
||||
EXTRACTOR[extractor-handler.ts]
|
||||
CLEANER[cleaner-handler.ts]
|
||||
VALIDATION[validation-handler.ts]
|
||||
end
|
||||
|
||||
MAIN -->|初始化加载 | ENV[process.env]
|
||||
|
||||
ENV --> DB_INDEX
|
||||
ENV --> DB_MYSQL
|
||||
ENV --> DB_SQL
|
||||
ENV --> BIP_DAO
|
||||
ENV --> ORDER_RES
|
||||
ENV --> EXTRACTOR
|
||||
ENV --> CLEANER
|
||||
ENV --> VALIDATION
|
||||
```
|
||||
|
||||
### 5.2 ConfigManager 获取模式
|
||||
|
||||
通过 IPC 层统一获取:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant UI as 设置界面
|
||||
participant Preload as Preload 脚本
|
||||
participant IPC as IPC Handler
|
||||
participant CM as ConfigManager
|
||||
|
||||
UI->>Preload: window.api.settings.getSettings()
|
||||
Preload->>IPC: ipcRenderer.invoke('settings:getSettings')
|
||||
IPC->>IPC: SessionManager.getUserType()
|
||||
IPC->>CM: getAllSettings()
|
||||
CM->>IPC: SettingsData
|
||||
IPC->>IPC: filterSettingsByUserType()
|
||||
IPC-->>Preload: 过滤后的 SettingsData
|
||||
Preload-->>UI: SettingsData
|
||||
```
|
||||
|
||||
### 5.3 数据库配置工厂模式
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "配置创建"
|
||||
GET_TYPE[getDatabaseType] -->|DB_TYPE env| TYPE_CHECK{数据库类型}
|
||||
TYPE_CHECK -->|mysql| CREATE_MYSQL[createMySqlConfig]
|
||||
TYPE_CHECK -->|sqlserver| CREATE_SQL[createSqlServerConfig]
|
||||
end
|
||||
|
||||
subgraph "服务创建"
|
||||
CREATE_MYSQL --> MYSQL_SVC[MySqlService]
|
||||
CREATE_SQL --> SQL_SVC[SqlServerService]
|
||||
end
|
||||
|
||||
subgraph "单例缓存"
|
||||
MYSQL_SVC --> CACHE[instances Map]
|
||||
SQL_SVC --> CACHE
|
||||
CACHE -->|返回已连接实例 | CLIENT[调用方]
|
||||
end
|
||||
|
||||
CREATE_MYSQL --> CONNECT_MYSQL[service.connect]
|
||||
CREATE_SQL --> CONNECT_SQL[service.connect]
|
||||
|
||||
CONNECT_MYSQL --> CACHE
|
||||
CONNECT_SQL --> CACHE
|
||||
```
|
||||
|
||||
## 6. 用户权限与配置访问控制
|
||||
|
||||
### 6.1 用户类型与权限
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "用户类型 UserType"
|
||||
ADMIN[Admin<br/>管理员]
|
||||
USER[User<br/>普通用户]
|
||||
GUEST[Guest<br/>访客]
|
||||
end
|
||||
|
||||
subgraph "配置访问权限"
|
||||
ADMIN_SETTINGS[全部配置可访问<br/>可修改 ERP 配置<br/>可恢复默认设置]
|
||||
USER_SETTINGS[有限配置访问<br/>可修改 ERP 配置<br/>可查看执行配置]
|
||||
GUEST_SETTINGS[只读访问]
|
||||
end
|
||||
|
||||
ADMIN --> ADMIN_SETTINGS
|
||||
USER --> USER_SETTINGS
|
||||
GUEST --> GUEST_SETTINGS
|
||||
|
||||
subgraph "SessionManager 会话管理"
|
||||
SM_LOGIN[login]
|
||||
SM_SILENT[loginByComputerName]
|
||||
SM_SWITCH[switchUser - Admin only]
|
||||
SM_GET[getUserType/getUserInfo]
|
||||
end
|
||||
|
||||
SM_LOGIN --> USER
|
||||
SM_SILENT --> USER
|
||||
SM_SWITCH --> USER
|
||||
```
|
||||
|
||||
### 6.2 配置过滤机制
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[getSettings 请求] --> B[获取当前用户类型]
|
||||
B --> C{用户类型判断}
|
||||
|
||||
C -->|Admin| D[返回全部配置]
|
||||
|
||||
C -->|User| E[过滤配置]
|
||||
E --> F[返回 ERP 配置<br/>username/password/headless/url/...<br/>paths 配置<br/>execution 配置<br/>最小化其他配置]
|
||||
|
||||
C -->|Guest| G[返回空配置或只读配置]
|
||||
|
||||
D --> H[返回给 UI]
|
||||
E --> H
|
||||
G --> H
|
||||
```
|
||||
|
||||
## 7. 配置修改白名单机制
|
||||
|
||||
### 7.1 可编辑字段白名单
|
||||
|
||||
```javascript
|
||||
const UI_EDITABLE_FIELDS: string[] = [
|
||||
'erp.url',
|
||||
'erp.username',
|
||||
'erp.password'
|
||||
// 可根据需要扩展
|
||||
]
|
||||
```
|
||||
|
||||
### 7.2 白名单验证流程
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[savePartialSettings 调用] --> B[遍历 settings 中的字段]
|
||||
B --> C[构建字段路径 section.field]
|
||||
C --> D{字段在白名单中?}
|
||||
D -->|否 | E[添加到 invalidFields]
|
||||
D -->|是 | F[继续检查下一字段]
|
||||
E --> B
|
||||
F --> B
|
||||
B --> G{所有字段检查完成}
|
||||
G --> H{invalidFields 为空?}
|
||||
H -->|否 | I[返回错误<br/>包含不允许修改的字段]
|
||||
H -->|是 | J[继续保存流程]
|
||||
```
|
||||
|
||||
## 8. 数据库配置详解
|
||||
|
||||
### 8.1 双数据库支持架构
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "数据库抽象层"
|
||||
IDB[IDatabaseService 接口<br/>connect/disconnect<br/>query/transaction<br/>isConnected]
|
||||
end
|
||||
|
||||
subgraph "MySQL 实现"
|
||||
MYSQL[MySqlService<br/>mysql2/promise<br/>createConnection<br/>execute/transaction]
|
||||
end
|
||||
|
||||
subgraph "SQL Server 实现"
|
||||
MSSQL[SqlServerService<br/>mssql<br/>ConnectionPool<br/>request.query<br/>Transaction]
|
||||
end
|
||||
|
||||
IDB -.->|实现 | MYSQL
|
||||
IDB -.->|实现 | MSSQL
|
||||
|
||||
MYSQL --> ENV_MYSQL[DB_MYSQL_HOST<br/>DB_MYSQL_PORT<br/>DB_NAME<br/>DB_USERNAME<br/>DB_PASSWORD]
|
||||
MSSQL --> ENV_MSSQL[DB_SERVER<br/>DB_SQLSERVER_PORT<br/>DB_NAME<br/>DB_USERNAME<br/>DB_PASSWORD<br/>DB_TRUST_SERVER_CERTIFICATE]
|
||||
```
|
||||
|
||||
### 8.2 数据库配置参数映射
|
||||
|
||||
| 环境变量 | MySQL 用途 | SQL Server 用途 |
|
||||
| --------------------------- | ----------- | ----------------- |
|
||||
| DB_TYPE | mysql | sqlserver/mssql |
|
||||
| DB_NAME | 数据库名 | 数据库名 |
|
||||
| DB_USERNAME | 用户名 | 用户名 |
|
||||
| DB_PASSWORD | 密码 | 密码 |
|
||||
| DB_MYSQL_HOST | 主机地址 | - |
|
||||
| DB_MYSQL_PORT | 端口 (3306) | - |
|
||||
| DB_SERVER | - | 服务器地址 |
|
||||
| DB_SQLSERVER_PORT | - | 端口 (1433) |
|
||||
| DB_TRUST_SERVER_CERTIFICATE | - | 信任证书 (yes/no) |
|
||||
|
||||
## 9. ERP 配置与浏览器自动化
|
||||
|
||||
### 9.1 ERP 认证配置流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant UI as 设置界面
|
||||
participant IPC as settings-handler
|
||||
participant CM as ConfigManager
|
||||
participant ERP_AUTH as ErpAuthService
|
||||
participant PW as Playwright
|
||||
|
||||
UI->>IPC: testErpConnection
|
||||
IPC->>CM: getAllSettings
|
||||
CM-->>IPC: SettingsData(含 erp 配置)
|
||||
|
||||
IPC->>ERP_AUTH: new ErpAuthService(erpConfig)
|
||||
ERP_AUTH->>PW: chromium.launch
|
||||
Note over PW: headless=erpConfig.headless<br/>args=[--ignore-certificate-errors]
|
||||
|
||||
PW-->>ERP_AUTH: Browser Context
|
||||
|
||||
ERP_AUTH->>PW: page.goto(loginUrl)
|
||||
PW-->>ERP_AUTH: 加载登录页面
|
||||
|
||||
ERP_AUTH->>PW: fill username/password
|
||||
ERP_AUTH->>PW: click login button
|
||||
|
||||
PW-->>ERP_AUTH: 登录成功
|
||||
|
||||
ERP_AUTH-->>IPC: ErpSession
|
||||
IPC-->>UI: {success: true}
|
||||
|
||||
ERP_AUTH->>PW: close
|
||||
```
|
||||
|
||||
### 9.2 ERP 配置项说明
|
||||
|
||||
| 配置项 | 类型 | 默认值 | 说明 |
|
||||
| ----------------- | ------- | ------ | -------------- |
|
||||
| url | string | - | ERP 系统 URL |
|
||||
| username | string | - | ERP 用户名 |
|
||||
| password | string | - | ERP 密码 |
|
||||
| headless | boolean | true | 无头模式 |
|
||||
| ignoreHttpsErrors | boolean | true | 忽略 SSL 错误 |
|
||||
| autoCloseBrowser | boolean | true | 自动关闭浏览器 |
|
||||
|
||||
## 10. 订单号解析表配置
|
||||
|
||||
### 10.1 配置结构
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph "订单号解析配置"
|
||||
TABLE[DB_TABLE_NAME<br/>表名]
|
||||
FIELD_ID[DB_FIELD_PRODUCTION_ID<br/>总排号字段]
|
||||
FIELD_ORDER[DB_FIELD_ORDER_NUMBER<br/>生产订单号字段]
|
||||
end
|
||||
|
||||
TABLE --> ORDER_RESOLVER[OrderResolverService]
|
||||
FIELD_ID --> ORDER_RESOLVER
|
||||
FIELD_ORDER --> ORDER_RESOLVER
|
||||
|
||||
ORDER_RESOLVER --> DB_QUERY[查询映射关系]
|
||||
DB_QUERY --> PRODUCTION_ID[productionID]
|
||||
DB_QUERY --> ORDER_NUMBER[生产订单号]
|
||||
```
|
||||
|
||||
### 10.2 默认配置示例
|
||||
|
||||
```env
|
||||
DB_TABLE_NAME=productionContractData_26 年压力表合同数据
|
||||
DB_FIELD_PRODUCTION_ID=总排号
|
||||
DB_FIELD_ORDER_NUMBER=生产订单号
|
||||
```
|
||||
|
||||
## 11. 配置持久化与备份机制
|
||||
|
||||
### 11.1 保存流程
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[saveAllSettings] --> B[设置写入 configCache]
|
||||
B --> C[构建.env 文件内容]
|
||||
C --> D[按分类组织配置<br/>ERP/数据库/路径/提取/校验/UI/执行]
|
||||
D --> E[写入.env 文件]
|
||||
E --> F{写入成功?}
|
||||
F -->|是 | G[返回 true]
|
||||
F -->|否 | H[返回 false]
|
||||
```
|
||||
|
||||
### 11.2 备份与恢复流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Caller as 调用方
|
||||
participant CM as ConfigManager
|
||||
participant ENV as .env
|
||||
participant BAK as .env.backup
|
||||
|
||||
Caller->>CM: savePartialSettings
|
||||
CM->>CM: validateEditableFields
|
||||
|
||||
CM->>ENV: loadEnvFile
|
||||
CM->>CM: deepMerge 合并配置
|
||||
|
||||
CM->>ENV: backupEnvFile
|
||||
ENV->>BAK: copyFileSync
|
||||
|
||||
CM->>ENV: writeFileSync 新配置
|
||||
ENV-->>CM: 保存结果
|
||||
|
||||
alt 保存成功
|
||||
CM->>ENV: loadEnvFile 重新加载
|
||||
CM-->>Caller: success: true
|
||||
else 保存失败
|
||||
CM->>BAK: restoreBackup
|
||||
BAK->>ENV: copyFileSync 恢复
|
||||
CM->>ENV: loadEnvFile
|
||||
CM-->>Caller: success: false + error
|
||||
end
|
||||
```
|
||||
|
||||
## 12. 配置系统初始化时序
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant App as Electron App
|
||||
participant Main as main/index.ts
|
||||
participant Dotenv as dotenv
|
||||
participant CM as ConfigManager
|
||||
participant IPC as registerIpcHandlers
|
||||
participant SM as SessionManager
|
||||
|
||||
App->>Main: 应用启动
|
||||
Main->>Dotenv: config .env
|
||||
Dotenv-->>Main: process.env 已加载
|
||||
|
||||
Main->>IPC: registerIpcHandlers
|
||||
Note over IPC: 注册所有 IPC 处理器<br/>settings/extractor/cleaner/auth...
|
||||
|
||||
App->>Main: app.whenReady
|
||||
Main->>SM: silent login 尝试
|
||||
SM->>SM: loginByComputerName
|
||||
|
||||
alt 静默登录成功
|
||||
SM-->>Main: 用户已认证
|
||||
else 静默登录失败
|
||||
Main->>Main: 显示登录对话框
|
||||
end
|
||||
|
||||
Main->>CM: initialize 按需加载
|
||||
```
|
||||
|
||||
## 13. 关键代码模式
|
||||
|
||||
### 13.1 环境变量读取模式
|
||||
|
||||
```typescript
|
||||
// 直接读取 process.env
|
||||
const dbType = process.env.DB_TYPE?.toLowerCase()
|
||||
const mysqlHost = process.env.DB_MYSQL_HOST || 'localhost'
|
||||
const mysqlPort = parseInt(process.env.DB_MYSQL_PORT || '3306', 10)
|
||||
```
|
||||
|
||||
### 13.2 ConfigManager 读取模式
|
||||
|
||||
```typescript
|
||||
// 通过 ConfigManager 获取结构化配置
|
||||
const configManager = ConfigManager.getInstance()
|
||||
const settings = configManager.getAllSettings()
|
||||
const erpUrl = settings.erp.url
|
||||
const batchSize = settings.extraction.batchSize
|
||||
```
|
||||
|
||||
### 13.3 部分保存模式
|
||||
|
||||
```typescript
|
||||
// 只更新允许修改的字段
|
||||
const result = await configManager.savePartialSettings({
|
||||
erp: {
|
||||
url: 'http://new-url.com',
|
||||
username: 'newuser',
|
||||
password: 'newpass'
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## 14. 配置类别与业务模块映射
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "配置类别"
|
||||
ERP_CONF[ERP 配置]
|
||||
DB_CONF[数据库配置]
|
||||
PATH_CONF[路径配置]
|
||||
EXTRACT_CONF[提取配置]
|
||||
VALID_CONF[校验配置]
|
||||
UI_CONF[UI 配置]
|
||||
EXEC_CONF[执行配置]
|
||||
end
|
||||
|
||||
subgraph "业务模块"
|
||||
ERP_AUTH[ErpAuthService]
|
||||
ERP_EXTRACT[ExtractorService]
|
||||
ERP_CLEAN[CleanerService]
|
||||
ERP_ORDER[OrderResolverService]
|
||||
DB_MYSQL[MySqlService]
|
||||
DB_SQL[SqlServerService]
|
||||
DB_DAO[各种 DAO 类]
|
||||
EXCEL[Excel Parser/Exporter]
|
||||
UI[React 界面]
|
||||
end
|
||||
|
||||
ERP_CONF --> ERP_AUTH
|
||||
ERP_CONF --> ERP_EXTRACT
|
||||
ERP_CONF --> ERP_CLEAN
|
||||
|
||||
DB_CONF --> DB_MYSQL
|
||||
DB_CONF --> DB_SQL
|
||||
DB_CONF --> DB_DAO
|
||||
|
||||
PATH_CONF --> EXCEL
|
||||
PATH_CONF --> UI
|
||||
|
||||
EXTRACT_CONF --> ERP_EXTRACT
|
||||
EXTRACT_CONF --> DB_DAO
|
||||
|
||||
VALID_CONF --> ERP_EXTRACT
|
||||
VALID_CONF --> DB_DAO
|
||||
|
||||
UI_CONF --> UI
|
||||
|
||||
EXEC_CONF --> ERP_CLEAN
|
||||
```
|
||||
|
||||
## 15. 配置系统特点总结
|
||||
|
||||
### 15.1 优点
|
||||
|
||||
1. **集中化管理**: ConfigManager 单例模式统一管理所有配置
|
||||
2. **类型安全**: TypeScript 类型定义确保配置结构正确
|
||||
3. **权限控制**: 基于用户类型的配置访问和修改权限控制
|
||||
4. **备份恢复**: 自动备份机制防止配置丢失
|
||||
5. **双数据库支持**: MySQL 和 SQL Server 灵活切换
|
||||
6. **部分更新**: deepMerge 支持配置部分字段更新
|
||||
|
||||
### 15.2 可扩展性
|
||||
|
||||
1. **新增配置项**: 在 `.env.example` 添加 → `DEFAULT_SETTINGS` 定义 → `SettingsData` 类型 → `save` 方法输出
|
||||
2. **新增用户权限**: 扩展 `UserType` → 更新 `filterSettingsByUserType` 逻辑
|
||||
3. **新增白名单字段**: 在 `UI_EDITABLE_FIELDS` 数组添加路径
|
||||
|
||||
### 15.3 注意事项
|
||||
|
||||
1. 修改配置后需要重新加载 `.env` 文件使 `process.env` 生效
|
||||
2. 非白名单字段只能通过 `saveAllSettings` 或 `resetToDefaults` 修改
|
||||
3. 数据库服务使用单例缓存,配置变更需重启应用或手动重连
|
||||
4. ERP 配置变更需重启浏览器才能生效
|
||||
171
docs/MIGRATION_GUIDE.md
Normal file
171
docs/MIGRATION_GUIDE.md
Normal file
@@ -0,0 +1,171 @@
|
||||
# BIPUsers 表 ERP 参数迁移指南
|
||||
|
||||
## 概述
|
||||
|
||||
本次迁移将 ERP 配置参数(`ERP_URL`, `ERP_USERNAME`, `ERP_PASSWORD`)从 `.env` 文件迁移到 `dbo_BIPUsers` 数据库表中,实现每个用户独立的 ERP 配置。
|
||||
|
||||
## 迁移步骤
|
||||
|
||||
### 步骤 1:连接到 MySQL 数据库
|
||||
|
||||
使用你喜欢的 MySQL 客户端工具连接:
|
||||
|
||||
**方式 A: MySQL 命令行**
|
||||
|
||||
```bash
|
||||
mysql -h 192.168.31.83 -P 3306 -u remote_user -p'3.1415926Beeke' BLD_DB
|
||||
```
|
||||
|
||||
**方式 B: MySQL Workbench / Navicat / DBeaver**
|
||||
|
||||
- Host: `192.168.31.83`
|
||||
- Port: `3306`
|
||||
- Username: `remote_user`
|
||||
- Password: `3.1415926Beeke`
|
||||
- Database: `BLD_DB`
|
||||
|
||||
### 步骤 2:执行迁移 SQL
|
||||
|
||||
运行以下 SQL 脚本添加新字段:
|
||||
|
||||
```sql
|
||||
-- ============================================
|
||||
-- BIPUsers 表迁移:添加 ERP 参数字段
|
||||
-- ============================================
|
||||
|
||||
USE BLD_DB;
|
||||
|
||||
-- 1. 添加 ERP_URL 字段
|
||||
ALTER TABLE dbo_BIPUsers ADD COLUMN IF NOT EXISTS ERP_URL VARCHAR(500) NULL COMMENT 'ERP 系统 URL';
|
||||
|
||||
-- 2. 添加 ERP_Username 字段
|
||||
ALTER TABLE dbo_BIPUsers ADD COLUMN IF NOT EXISTS ERP_Username VARCHAR(255) NULL COMMENT 'ERP 用户名';
|
||||
|
||||
-- 3. 添加 ERP_Password 字段
|
||||
ALTER TABLE dbo_BIPUsers ADD COLUMN IF NOT EXISTS ERP_Password VARCHAR(255) NULL COMMENT 'ERP 密码';
|
||||
|
||||
-- 4. 验证字段已添加
|
||||
DESCRIBE dbo_BIPUsers;
|
||||
```
|
||||
|
||||
**注意:** 如果你的 MySQL 版本不支持 `ADD COLUMN IF NOT EXISTS`,请使用:
|
||||
|
||||
```sql
|
||||
USE BLD_DB;
|
||||
|
||||
ALTER TABLE dbo_BIPUsers ADD COLUMN ERP_URL VARCHAR(500) NULL COMMENT 'ERP 系统 URL';
|
||||
ALTER TABLE dbo_BIPUsers ADD COLUMN ERP_Username VARCHAR(255) NULL COMMENT 'ERP 用户名';
|
||||
ALTER TABLE dbo_BIPUsers ADD COLUMN ERP_Password VARCHAR(255) NULL COMMENT 'ERP 密码';
|
||||
```
|
||||
|
||||
### 步骤 3:初始化 ERP 配置
|
||||
|
||||
将所有现有用户的 ERP 配置设置为当前 `.env` 中的值:
|
||||
|
||||
```sql
|
||||
-- 更新所有用户的 ERP 配置
|
||||
UPDATE dbo_BIPUsers
|
||||
SET
|
||||
ERP_URL = 'https://68.11.34.30:8082/',
|
||||
ERP_Username = '在这里填写你的 ERP 用户名',
|
||||
ERP_Password = '在这里填写你的 ERP 密码'
|
||||
WHERE ERP_URL IS NULL OR ERP_URL = '';
|
||||
```
|
||||
|
||||
**请将上面的占位符替换为实际的 ERP 凭证!**
|
||||
|
||||
### 步骤 4:验证迁移结果
|
||||
|
||||
```sql
|
||||
-- 检查所有用户的 ERP 配置
|
||||
SELECT
|
||||
UserName,
|
||||
UserType,
|
||||
ERP_URL,
|
||||
ERP_Username,
|
||||
CreateTime
|
||||
FROM dbo_BIPUsers
|
||||
ORDER BY UserName;
|
||||
```
|
||||
|
||||
## 迁移后配置
|
||||
|
||||
### 更新 .env 文件(可选)
|
||||
|
||||
迁移完成后,`.env` 文件中的 ERP 配置将不再使用,但为了向后兼容可以保留:
|
||||
|
||||
```bash
|
||||
# ERP 配置(已废弃,仅用于向后兼容)
|
||||
# ERP_URL=https://68.11.34.30:8082/
|
||||
# ERP_USERNAME=your_username
|
||||
# ERP_PASSWORD=your_password
|
||||
```
|
||||
|
||||
### 在应用中配置用户 ERP 参数
|
||||
|
||||
迁移完成后,每个用户可以通过应用界面配置自己的 ERP 参数:
|
||||
|
||||
1. 登录应用
|
||||
2. 进入设置页面
|
||||
3. 配置个人 ERP 连接信息
|
||||
4. 测试连接
|
||||
5. 保存
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 问题 1:字段已存在错误
|
||||
|
||||
```
|
||||
Error: Duplicate column name 'ERP_URL'
|
||||
```
|
||||
|
||||
**解决方案:** 字段已经存在,跳过添加步骤,直接执行步骤 3 初始化数据。
|
||||
|
||||
### 问题 2:连接被拒绝
|
||||
|
||||
```
|
||||
Error: Access denied for user 'remote_user'@'%'
|
||||
```
|
||||
|
||||
**解决方案:** 检查数据库用户权限,确保 `remote_user` 有 `ALTER` 和 `UPDATE` 权限。
|
||||
|
||||
### 问题 3:连接超时
|
||||
|
||||
```
|
||||
Error: connect ETIMEDOUT
|
||||
```
|
||||
|
||||
**解决方案:**
|
||||
|
||||
- 检查网络连接
|
||||
- 确认 MySQL 服务器正在运行
|
||||
- 检查防火墙设置
|
||||
|
||||
## 回滚方案
|
||||
|
||||
如果需要回滚,可以删除新增的字段:
|
||||
|
||||
```sql
|
||||
-- ⚠️ 警告:这将永久删除 ERP 配置数据
|
||||
ALTER TABLE dbo_BIPUsers DROP COLUMN ERP_URL;
|
||||
ALTER TABLE dbo_BIPUsers DROP COLUMN ERP_Username;
|
||||
ALTER TABLE dbo_BIPUsers DROP COLUMN ERP_Password;
|
||||
```
|
||||
|
||||
## 完成确认
|
||||
|
||||
迁移完成后,请确认以下事项:
|
||||
|
||||
- [ ] 三个新字段已成功添加到 `dbo_BIPUsers` 表
|
||||
- [ ] 所有现有用户的 ERP 配置已初始化
|
||||
- [ ] 应用程序可以正常启动
|
||||
- [ ] 数据提取和物料清理功能正常工作
|
||||
|
||||
---
|
||||
|
||||
**迁移脚本文件:**
|
||||
|
||||
- `src/main/services/user/migration/add-erp-params-to-bipusers-mysql.sql` - 完整 SQL 脚本
|
||||
- `src/main/services/user/migration/run-migration.ts` - TypeScript 自动迁移脚本(需要网络访问)
|
||||
|
||||
**创建时间:** 2026-03-05
|
||||
262
docs/USER_GUIDE.md
Normal file
262
docs/USER_GUIDE.md
Normal file
@@ -0,0 +1,262 @@
|
||||
# ERPAuto 用户指南
|
||||
|
||||
## 目录
|
||||
|
||||
1. [简介](#简介)
|
||||
2. [安装指南](#安装指南)
|
||||
3. [配置说明](#配置说明)
|
||||
4. [使用指南](#使用指南)
|
||||
5. [数据库设置](#数据库设置)
|
||||
6. [常见问题](#常见问题)
|
||||
|
||||
---
|
||||
|
||||
## 简介
|
||||
|
||||
ERPAuto 是一个专为 ERP 系统设计的自动化工具,主要功能包括:
|
||||
|
||||
- **数据提取**:自动从 ERP 系统批量下载物料计划数据为 Excel 文件
|
||||
- **物料清理**:自动删除指定订单的物料代码
|
||||
- **数据库存储**:支持将提取的数据存储到 MySQL 或 SQL Server 数据库
|
||||
|
||||
---
|
||||
|
||||
## 安装指南
|
||||
|
||||
### 系统要求
|
||||
|
||||
- **操作系统**:Windows 10/11, macOS 10.15+, Linux
|
||||
- **内存**:至少 4GB RAM
|
||||
- **Node.js**:版本 18 或更高
|
||||
|
||||
### 安装步骤
|
||||
|
||||
1. **下载应用**
|
||||
- 从发布页面下载对应系统的安装包
|
||||
- Windows: `ERPAuto-Setup-x.x.x.exe`
|
||||
- macOS: `ERPAuto-x.x.x.dmg`
|
||||
- Linux: `ERPAuto-x.x.x.AppImage`
|
||||
|
||||
2. **安装**
|
||||
- Windows: 运行安装程序,按照提示完成安装
|
||||
- macOS: 将应用拖拽到 Applications 文件夹
|
||||
- Linux: 赋予执行权限后运行
|
||||
|
||||
3. **首次运行**
|
||||
- 启动应用
|
||||
- 首次运行需要先配置 ERP 连接信息
|
||||
|
||||
---
|
||||
|
||||
## 配置说明
|
||||
|
||||
### ERP 连接配置
|
||||
|
||||
应用需要配置 ERP 系统的连接信息。在主界面点击「设置」->「ERP 配置」:
|
||||
|
||||
| 配置项 | 说明 | 示例 |
|
||||
| ------- | -------------- | ----------------------------- |
|
||||
| ERP URL | ERP 系统地址 | `https://192.168.1.100:8082/` |
|
||||
| 用户名 | ERP 登录用户名 | `admin` |
|
||||
| 密码 | ERP 登录密码 | `******` |
|
||||
|
||||
### 数据库配置(可选)
|
||||
|
||||
如需将提取的数据存储到数据库,需要配置数据库连接:
|
||||
|
||||
**MySQL 配置:**
|
||||
|
||||
| 配置项 | 默认值 | 说明 |
|
||||
| -------- | ----------- | ---------------- |
|
||||
| 主机 | `localhost` | MySQL 服务器地址 |
|
||||
| 端口 | `3306` | MySQL 端口 |
|
||||
| 用户名 | `root` | 数据库用户名 |
|
||||
| 密码 | - | 数据库密码 |
|
||||
| 数据库名 | `erpauto` | 数据库名称 |
|
||||
|
||||
**SQL Server 配置:**
|
||||
|
||||
| 配置项 | 默认值 | 说明 |
|
||||
| -------- | ----------- | ------------------ |
|
||||
| 服务器 | `localhost` | SQL Server 地址 |
|
||||
| 端口 | `1433` | SQL Server 端口 |
|
||||
| 用户名 | `sa` | 登录用户名 |
|
||||
| 密码 | - | 登录密码 |
|
||||
| 数据库 | `erpauto` | 数据库名称 |
|
||||
| 加密 | `false` | 是否启用 SSL 加密 |
|
||||
| 信任证书 | `true` | 是否信任服务器证书 |
|
||||
|
||||
---
|
||||
|
||||
## 使用指南
|
||||
|
||||
### 数据提取功能
|
||||
|
||||
**使用场景:** 从 ERP 系统批量下载多个订单的物料计划数据。
|
||||
|
||||
**操作步骤:**
|
||||
|
||||
1. 进入「数据提取」页面
|
||||
2. 在左侧输入框中输入订单号,每行一个:
|
||||
```
|
||||
SC70202602120085
|
||||
SC70202602120120
|
||||
SC70202602120137
|
||||
```
|
||||
3. 设置批量大小(建议 100-500)
|
||||
4. 点击「开始提取」
|
||||
5. 等待提取完成,查看结果统计
|
||||
|
||||
**提取结果说明:**
|
||||
|
||||
- **下载文件数**:成功下载的 Excel 文件数量
|
||||
- **记录数**:提取的总记录数
|
||||
- **错误数**:失败的订单数量
|
||||
|
||||
### 物料清理功能
|
||||
|
||||
**使用场景:** 删除指定订单中的特定物料代码。
|
||||
|
||||
**操作步骤:**
|
||||
|
||||
1. 进入「物料清理」页面
|
||||
2. 输入订单号(每行一个)
|
||||
3. 输入要删除的物料代码(每行一个)
|
||||
4. **重要**:首次使用建议勾选「干运行模式」
|
||||
5. 点击「开始清理」
|
||||
6. 查看清理结果
|
||||
|
||||
**干运行模式:**
|
||||
|
||||
- 勾选后,系统仅预览将要删除的数据,不实际执行删除
|
||||
- 建议先用干运行模式确认数据正确
|
||||
- 确认无误后,取消勾选执行实际删除
|
||||
|
||||
**清理结果说明:**
|
||||
|
||||
- **处理订单数**:成功处理的订单数量
|
||||
- **删除物料数**:实际删除的物料数量
|
||||
- **跳过物料数**:未找到或跳过的物料数量
|
||||
- **订单详情**:每个订单的详细处理结果
|
||||
|
||||
---
|
||||
|
||||
## 数据库设置
|
||||
|
||||
### MySQL 数据库初始化
|
||||
|
||||
```sql
|
||||
CREATE DATABASE IF NOT EXISTS erpauto CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
USE erpauto;
|
||||
|
||||
-- 创建物料数据表
|
||||
CREATE TABLE IF NOT EXISTS material_data (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
order_number VARCHAR(50) NOT NULL,
|
||||
material_code VARCHAR(100) NOT NULL,
|
||||
material_name VARCHAR(255),
|
||||
quantity DECIMAL(10, 2),
|
||||
unit VARCHAR(20),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_order (order_number),
|
||||
INDEX idx_material (material_code)
|
||||
);
|
||||
```
|
||||
|
||||
### SQL Server 数据库初始化
|
||||
|
||||
```sql
|
||||
CREATE DATABASE erpauto;
|
||||
GO
|
||||
|
||||
USE erpauto;
|
||||
GO
|
||||
|
||||
-- 创建物料数据表
|
||||
CREATE TABLE material_data (
|
||||
id INT IDENTITY(1,1) PRIMARY KEY,
|
||||
order_number NVARCHAR(50) NOT NULL,
|
||||
material_code NVARCHAR(100) NOT NULL,
|
||||
material_name NVARCHAR(255),
|
||||
quantity DECIMAL(10, 2),
|
||||
unit NVARCHAR(20),
|
||||
created_at DATETIME DEFAULT GETDATE()
|
||||
);
|
||||
GO
|
||||
|
||||
CREATE INDEX idx_order ON material_data(order_number);
|
||||
CREATE INDEX idx_material ON material_data(material_code);
|
||||
GO
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 1. 无法登录 ERP 系统
|
||||
|
||||
**问题描述:** 点击登录后提示认证失败
|
||||
|
||||
**解决方法:**
|
||||
|
||||
1. 确认用户名和密码正确
|
||||
2. 检查 ERP 系统是否可访问
|
||||
3. 确认账号有足够权限
|
||||
|
||||
### 2. 提取时卡在加载中
|
||||
|
||||
**问题描述:** 点击提取后一直显示加载中
|
||||
|
||||
**解决方法:**
|
||||
|
||||
1. 检查网络连接
|
||||
2. 减少批量大小
|
||||
3. 确认 ERP 系统运行正常
|
||||
4. 刷新页面后重试
|
||||
|
||||
### 3. 物料清理无数据
|
||||
|
||||
**问题描述:** 清理时显示没有可删除的数据
|
||||
|
||||
**解决方法:**
|
||||
|
||||
1. 确认订单号正确
|
||||
2. 确认物料代码在该订单中存在
|
||||
3. 先用干运行模式查看是否有匹配数据
|
||||
|
||||
### 4. 数据库连接失败
|
||||
|
||||
**问题描述:** 无法连接到数据库
|
||||
|
||||
**解决方法:**
|
||||
|
||||
1. 确认数据库服务已启动
|
||||
2. 检查数据库配置信息
|
||||
3. 确认防火墙允许数据库端口
|
||||
4. 测试数据库连接:
|
||||
|
||||
```bash
|
||||
# MySQL
|
||||
mysql -h localhost -u root -p
|
||||
|
||||
# SQL Server
|
||||
sqlcmd -S localhost -U sa
|
||||
```
|
||||
|
||||
### 5. 应用闪退
|
||||
|
||||
**问题描述:** 应用启动后立即关闭
|
||||
|
||||
**解决方法:**
|
||||
|
||||
1. 查看日志文件获取错误信息
|
||||
2. 重新安装应用
|
||||
3. 确认系统满足最低要求
|
||||
4. 尝试以管理员身份运行
|
||||
|
||||
---
|
||||
|
||||
## 技术支持
|
||||
|
||||
如有其他问题,请联系技术支持团队或提交 Issue。
|
||||
86
docs/cleaner-execution-report-template.md
Normal file
86
docs/cleaner-execution-report-template.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# ERP 物料清理执行报告
|
||||
|
||||
## 执行摘要
|
||||
|
||||
| 项目 | 值 |
|
||||
| -------------- | --------------------------------- |
|
||||
| **执行时间** | `YYYY-MM-DD HH:mm:ss` |
|
||||
| **执行模式** | `正式执行` / `模拟运行 (Dry Run)` |
|
||||
| **操作用户** | `username` |
|
||||
| **处理订单数** | `X` |
|
||||
| **删除物料数** | `X` |
|
||||
| **跳过物料数** | `X` |
|
||||
| **错误数量** | `X` |
|
||||
| **执行耗时** | `X 分 Y 秒` |
|
||||
|
||||
---
|
||||
|
||||
## 执行状态
|
||||
|
||||
| 状态 | 数量 | 百分比 |
|
||||
| ----------- | ---- | ------ |
|
||||
| ✅ 成功订单 | X | XX% |
|
||||
| ❌ 失败订单 | X | XX% |
|
||||
|
||||
---
|
||||
|
||||
## 订单处理详情
|
||||
|
||||
| # | 订单号 | 删除数 | 跳过数 | 状态 | 错误信息 |
|
||||
| --- | -------- | ------ | ------ | ------- | ------------------------ |
|
||||
| 1 | `PO-001` | 5 | 2 | ✅ 成功 | - |
|
||||
| 2 | `PO-002` | 0 | 0 | ❌ 失败 | `Order PO-002: 超时错误` |
|
||||
| 3 | `PO-003` | 3 | 1 | ✅ 成功 | - |
|
||||
| ... | ... | ... | ... | ... | ... |
|
||||
|
||||
---
|
||||
|
||||
## 跳过的物料原因说明
|
||||
|
||||
| 订单号 | 物料代码 | 物料名称 | 行号 | 跳过原因 |
|
||||
| -------- | -------- | -------- | ---- | --------------------------------- |
|
||||
| `PO-001` | `M001` | 物料名称 | 7500 | 行号在 2000-7999 范围内(受保护) |
|
||||
| `PO-001` | `M002` | 物料名称 | 1200 | 累计待发数量不为空 |
|
||||
| `PO-002` | `M003` | 物料名称 | 300 | 物料不在删除清单中 |
|
||||
| ... | ... | ... | ... | ... |
|
||||
|
||||
---
|
||||
|
||||
## 错误详情
|
||||
|
||||
**错误总数**: `X`
|
||||
|
||||
### 错误订单列表
|
||||
|
||||
- `PO-002`
|
||||
- `PO-005`
|
||||
- `PO-008`
|
||||
- ...
|
||||
|
||||
### 错误详细信息
|
||||
|
||||
#### `PO-002`
|
||||
|
||||
```
|
||||
订单号: PO-002
|
||||
错误: 订单不存在或已被锁定,无法访问备料计划
|
||||
```
|
||||
|
||||
#### `PO-005`
|
||||
|
||||
```
|
||||
订单号: PO-005
|
||||
错误: ERP 连接超时:请求在 30000ms 内未得到响应
|
||||
```
|
||||
|
||||
#### `PO-008`
|
||||
|
||||
```
|
||||
订单号: PO-008
|
||||
错误: 备料状态异常:当前状态为"待审批",无法执行删除操作
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**报告生成时间**: `YYYY-MM-DD HH:mm:ss`
|
||||
**报表版本**: `v1.0`
|
||||
985
docs/cleaner-order-error-collection.md
Normal file
985
docs/cleaner-order-error-collection.md
Normal file
@@ -0,0 +1,985 @@
|
||||
# 物料清理模块 - 订单错误收集逻辑分析
|
||||
|
||||
本文档详细分析了 ERPAuto 应用中物料清理功能在处理订单过程中的错误收集机制。
|
||||
|
||||
## 一、系统架构概览
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Frontend["渲染进程 (Frontend)"]
|
||||
CleanerPage["CleanerPage.tsx<br/>UI 界面"]
|
||||
UseCleaner["useCleaner.ts<br/>状态管理 Hook"]
|
||||
ExecReport["ExecutionReportDialog.tsx<br/>错误报告展示"]
|
||||
end
|
||||
|
||||
subgraph Preload["Preload 脚本"]
|
||||
ContextBridge["window.electron.cleaner<br/>IPC API 桥接"]
|
||||
end
|
||||
|
||||
subgraph Main["主进程 (Main)"]
|
||||
CleanerHandler["cleaner-handler.ts<br/>IPC 处理器"]
|
||||
CleanerService["cleaner.ts<br/>CleanerService"]
|
||||
OrderResolver["order-resolver.ts<br/>订单号解析"]
|
||||
ReportGen["cleaner-report-generator.ts<br/>报告生成"]
|
||||
end
|
||||
|
||||
subgraph Storage["数据存储"]
|
||||
ConfigYAML["config.yaml<br/>ERP URL 配置"]
|
||||
DB[(数据库<br/>dbo_MaterialsToBeDeleted)]
|
||||
end
|
||||
|
||||
CleanerPage --> UseCleaner
|
||||
UseCleaner --> ContextBridge
|
||||
ContextBridge --> CleanerHandler
|
||||
CleanerHandler --> OrderResolver
|
||||
CleanerHandler --> CleanerService
|
||||
CleanerService --> ReportGen
|
||||
CleanerHandler --> ConfigYAML
|
||||
CleanerHandler --> DB
|
||||
|
||||
style CleanerService fill:#e1f5ff
|
||||
style CleanerHandler fill:#fff4e1
|
||||
style ExecReport fill:#f0e1ff
|
||||
```
|
||||
|
||||
## 二、错误收集流程图
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User as 用户
|
||||
participant UI as CleanerPage
|
||||
participant Hook as useCleaner
|
||||
participant IPC as cleaner-handler
|
||||
participant Resolver as OrderNumberResolver
|
||||
participant Service as CleanerService
|
||||
participant ERP as ERP 系统
|
||||
participant Dialog as ExecutionReportDialog
|
||||
|
||||
User->>UI: 点击"正式执行 ERP 清理"
|
||||
UI->>Hook: handleExecuteDeletion()
|
||||
|
||||
Hook->>Hook: 获取 CleanerData<br/>(订单号 + 物料代码)
|
||||
Hook->>IPC: electron.cleaner.runCleaner()
|
||||
|
||||
IPC->>IPC: 验证 ERP 配置
|
||||
IPC->>Resolver: resolve(orderNumbers)
|
||||
|
||||
Note over Resolver: 订单号解析验证
|
||||
Resolver-->>IPC: 返回 mappings + warnings
|
||||
|
||||
alt 存在解析警告
|
||||
IPC->>IPC: 收集 warnings 到错误列表
|
||||
end
|
||||
|
||||
IPC->>Service: new CleanerService()
|
||||
IPC->>Service: clean(input)
|
||||
|
||||
Note over Service: 批量处理订单
|
||||
loop 每个订单批次
|
||||
Service->>ERP: 查询订单列表
|
||||
Service->>ERP: 打开订单详情页
|
||||
|
||||
alt 订单处理成功
|
||||
Service->>Service: 记录删除/跳过统计
|
||||
else 订单处理失败
|
||||
Service->>Service: createErrorDetail()
|
||||
Service->>Service: errors.push(error)
|
||||
end
|
||||
|
||||
alt 订单未出现在查询结果中
|
||||
Service->>Service: 添加"订单未找到"错误
|
||||
end
|
||||
end
|
||||
|
||||
Note over Service: 失败订单重试机制
|
||||
Service->>Service: retryFailedOrders()
|
||||
loop 每个失败订单 (最多 2 次重试)
|
||||
Service->>ERP: 重新查询并处理
|
||||
alt 重试成功
|
||||
Service->>Service: retrySuccess = true
|
||||
Service->>Service: 从错误列表移除
|
||||
else 重试失败
|
||||
Service->>Service: 记录 retryAttempts
|
||||
end
|
||||
end
|
||||
|
||||
Service-->>IPC: 返回 CleanerResult
|
||||
IPC->>IPC: 合并 warnings + errors
|
||||
|
||||
IPC-->>Hook: IpcResult<CleanerResult>
|
||||
Hook->>Hook: 设置 reportData
|
||||
Hook->>Dialog: 打开错误报告对话框
|
||||
|
||||
Dialog->>User: 显示执行结果<br/>+ 错误详情列表
|
||||
```
|
||||
|
||||
## 三、错误类型详解
|
||||
|
||||
### 3.1 错误来源分类(完整版)
|
||||
|
||||
```mermaid
|
||||
mindmap
|
||||
root((订单错误))
|
||||
前置验证错误
|
||||
ERP 配置不完整
|
||||
数据库连接失败
|
||||
ERP 登录失败
|
||||
未登录先调用会话
|
||||
解析阶段错误
|
||||
订单号格式无效
|
||||
格式不识别 (非订单号/总排号)
|
||||
ProductionID 无对应订单
|
||||
数据库查询异常
|
||||
执行阶段错误
|
||||
导航失败
|
||||
弹出窗口等待超时
|
||||
forwardFrame 访问失败
|
||||
mainiframe 访问失败
|
||||
热键区域加载超时
|
||||
查询界面设置失败
|
||||
订单号查询模式切换失败
|
||||
下拉框选择失败
|
||||
订单查询失败
|
||||
查询结果加载超时
|
||||
查询无结果
|
||||
详情页打开失败
|
||||
行元素等待超时 (15s)
|
||||
更多按钮定位失败
|
||||
popup 事件等待超时
|
||||
备料计划菜单定位失败
|
||||
详情页处理失败
|
||||
forwardFrame 访问失败
|
||||
mainiframe 访问失败 (30s)
|
||||
页面标题等待超时 (30s)
|
||||
修改按钮点击失败
|
||||
保存按钮等待超时 (30s/60s)
|
||||
展开按钮点击失败
|
||||
删行按钮点击失败
|
||||
删行后行变化等待失败
|
||||
下一行按钮点击失败
|
||||
收起按钮点击失败
|
||||
重试阶段错误
|
||||
重试查询无结果
|
||||
重试打开详情页失败
|
||||
重试处理异常
|
||||
达到最大重试次数 (2 次)
|
||||
业务规则错误
|
||||
物料不在删除清单
|
||||
行号在保护范围 (2000-7999)
|
||||
累计待发数量不为空
|
||||
收尾错误
|
||||
浏览器关闭失败
|
||||
数据库断开失败
|
||||
报告生成失败
|
||||
```
|
||||
|
||||
### 3.2 错误数据结构
|
||||
|
||||
```typescript
|
||||
// 主结果结构
|
||||
interface CleanerResult {
|
||||
ordersProcessed: number // 成功处理的订单数
|
||||
materialsDeleted: number // 删除的物料数
|
||||
materialsSkipped: number // 跳过的物料数
|
||||
errors: string[] // 错误消息列表
|
||||
details: OrderCleanDetail[] // 每个订单的详细信息
|
||||
retriedOrders: number // 重试的订单数
|
||||
successfulRetries: number // 成功的重试数
|
||||
}
|
||||
|
||||
// 单个订单详情
|
||||
interface OrderCleanDetail {
|
||||
orderNumber: string // 订单号
|
||||
materialsDeleted: number // 该订单删除的物料数
|
||||
materialsSkipped: number // 该订单跳过的物料数
|
||||
errors: string[] // 该订单的错误列表
|
||||
skippedMaterials: SkippedMaterial[] // 跳过的物料详情
|
||||
retryCount: number // 重试次数
|
||||
retryAttempts?: RetryAttempt[] // 每次重试的错误详情
|
||||
retriedAt?: number // 重试时间戳
|
||||
retrySuccess?: boolean // 重试是否成功
|
||||
}
|
||||
|
||||
// 重试尝试记录
|
||||
interface RetryAttempt {
|
||||
attempt: number // 第几次尝试
|
||||
error: string // 错误消息
|
||||
timestamp: number // 时间戳
|
||||
}
|
||||
|
||||
// 跳过物料详情
|
||||
interface SkippedMaterial {
|
||||
materialCode: string // 物料代码
|
||||
materialName: string // 物料名称
|
||||
rowNumber: number // 行号
|
||||
reason: string // 跳过原因
|
||||
}
|
||||
```
|
||||
|
||||
## 四、核心错误收集点(完整版)
|
||||
|
||||
### 4.1 IPC 处理层 (cleaner-handler.ts)
|
||||
|
||||
```typescript
|
||||
// ========== 前置验证错误 ==========
|
||||
|
||||
// 1. ERP 配置验证失败
|
||||
const userConfig = await erpConfigService.getCurrentUserErpConfig()
|
||||
if (!userConfig || !userConfig.username || !userConfig.password) {
|
||||
throw new ValidationError(
|
||||
'ERP 配置不完整。请在设置中配置 ERP 用户名和密码',
|
||||
'VAL_MISSING_REQUIRED'
|
||||
)
|
||||
}
|
||||
|
||||
// 2. 数据库连接失败
|
||||
try {
|
||||
dbService = await getDatabaseService()
|
||||
} catch (error) {
|
||||
throw new DatabaseQueryError(
|
||||
'数据库连接失败',
|
||||
'DB_CONNECTION_FAILED',
|
||||
error instanceof Error ? error : undefined
|
||||
)
|
||||
}
|
||||
|
||||
// 3. 订单号解析后无有效订单
|
||||
if (validOrderNumbers.length === 0) {
|
||||
throw new ValidationError(
|
||||
'没有有效的生产订单号可处理。请检查输入的格式或数据库连接。',
|
||||
'VAL_INVALID_INPUT'
|
||||
)
|
||||
}
|
||||
|
||||
// 4. ERP 登录失败
|
||||
try {
|
||||
await authService.login()
|
||||
} catch (error) {
|
||||
throw new ErpConnectionError(
|
||||
'ERP 登录失败',
|
||||
'ERP_LOGIN_FAILED',
|
||||
error instanceof Error ? error : undefined
|
||||
)
|
||||
}
|
||||
|
||||
// ========== 执行结果合并 ==========
|
||||
|
||||
// 5. 解析警告合并到错误列表
|
||||
if (warnings.length > 0) {
|
||||
log.warn('Resolution warnings', { warnings })
|
||||
result.errors = [...warnings, ...result.errors]
|
||||
}
|
||||
|
||||
// 6. 导出验证错误
|
||||
if (!items || items.length === 0) {
|
||||
throw new ValidationError('没有数据可导出', 'VAL_INVALID_INPUT')
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 订单号解析层 (order-resolver.ts)
|
||||
|
||||
```typescript
|
||||
// ========== 解析错误 ==========
|
||||
|
||||
// 1. ProductionID 数据库查询失败
|
||||
async mapProductionIdToOrderNumber(productionId: string): Promise<string | null> {
|
||||
try {
|
||||
const result = await this.dbService.query(sql, params)
|
||||
// ...
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '未知数据库错误'
|
||||
log.error('Failed to map productionID to order number', {
|
||||
productionId,
|
||||
error: message
|
||||
})
|
||||
throw error // 向上抛出
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 批量映射查询失败
|
||||
async mapProductionIdsToOrderNumbers(productionIds: string[]): Promise<Map<string, string>> {
|
||||
try {
|
||||
const result = await this.dbService.query(sql, params)
|
||||
// ...
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '未知数据库错误'
|
||||
log.error('Failed to map productionIds to order numbers', { error: message })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 单个订单解析失败 - 在 resolve() 中记录
|
||||
for (const input of inputs) {
|
||||
const mapping: OrderMapping = { input, resolved: false }
|
||||
|
||||
if (this.isOrderNumber(input)) {
|
||||
mapping.orderNumber = input
|
||||
mapping.resolved = true
|
||||
} else if (this.isProductionId(input)) {
|
||||
mapping.productionId = input
|
||||
const orderNumber = mappings.get(input)
|
||||
if (orderNumber) {
|
||||
mapping.orderNumber = orderNumber
|
||||
mapping.resolved = true
|
||||
} else {
|
||||
// 错误:ProductionID 在数据库中找不到
|
||||
mapping.error = '未在数据库中找到对应的订单号'
|
||||
}
|
||||
} else {
|
||||
// 错误:格式不识别
|
||||
mapping.error = '格式不识别:既不是有效的生产订单号也不是总排号格式'
|
||||
}
|
||||
|
||||
results.push(mapping)
|
||||
}
|
||||
|
||||
// 4. 警告收集
|
||||
getWarnings(mappings: OrderMapping[]): string[] {
|
||||
return mappings.filter((m) => !m.resolved && m.error).map((m) => `${m.input}: ${m.error}`)
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 ERP 认证层 (erp-auth.ts)
|
||||
|
||||
```typescript
|
||||
// ========== 登录阶段错误 ==========
|
||||
|
||||
async login(): Promise<ErpSession> {
|
||||
// 1. 浏览器启动失败(隐式抛出)
|
||||
const browser = await chromium.launch({ ... })
|
||||
|
||||
// 2. 上下文创建失败(隐式抛出)
|
||||
const context = await browser.newContext({ ... })
|
||||
|
||||
// 3. 页面创建失败(隐式抛出)
|
||||
const page = await context.newPage()
|
||||
|
||||
// 4. 导航失败(隐式抛出)
|
||||
await page.goto(loginUrl)
|
||||
|
||||
// 5. 页面加载超时
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: PAGE_LOAD_TIMEOUT })
|
||||
|
||||
// 6. iframe 选择器等待超时
|
||||
await page.waitForSelector('#forwardFrame', {
|
||||
state: 'attached',
|
||||
timeout: LOGIN_RESULT_TIMEOUT
|
||||
})
|
||||
|
||||
// 7. forwardFrame content frame 访问失败
|
||||
const contentFrame = await frameLocator.contentFrame()
|
||||
if (!contentFrame) {
|
||||
throw new Error('Failed to access forwardFrame content frame')
|
||||
}
|
||||
|
||||
// 8. 用户名输入框定位失败
|
||||
try {
|
||||
await contentFrame.getByRole('textbox', { name: '用户名' }).fill(this.config.username)
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to find username input: ${e}`)
|
||||
}
|
||||
|
||||
// 9. 密码输入框定位失败
|
||||
try {
|
||||
await contentFrame.getByRole('textbox', { name: '密码' }).fill(this.config.password)
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to find password input: ${e}`)
|
||||
}
|
||||
|
||||
// 10. 登录按钮点击失败
|
||||
try {
|
||||
await contentFrame.getByRole('button', { name: '登录' }).click()
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to click login button: ${e}`)
|
||||
}
|
||||
|
||||
// 11. 登录结果等待 - 多种失败场景
|
||||
await this.waitForLoginResult(mainFrame)
|
||||
}
|
||||
|
||||
// waitForLoginResult 内部错误
|
||||
private async waitForLoginResult(mainFrame: Frame): Promise<void> {
|
||||
// 12. 登录成功图标等待超时
|
||||
// 13. 错误消息等待超时
|
||||
// 14. 强制登录对话框等待超时
|
||||
// 15. 强制登录确认按钮点击失败
|
||||
// 16. 名称或密码错误检测
|
||||
const hasError = await errorLocator.isVisible()
|
||||
if (hasError) {
|
||||
throw new Error('ERP 登录失败:名称或密码错误')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 服务层 (cleaner.ts) - 主处理循环
|
||||
|
||||
```typescript
|
||||
// ========== 导航阶段错误 ==========
|
||||
|
||||
async navigateToCleanerPage(session: ErpSession): Promise<{ popupPage: Page; workFrame: FrameLocator }> {
|
||||
// 1. 菜单图标点击失败
|
||||
await mainFrame.locator('i').first().click()
|
||||
|
||||
// 2. 弹出窗口等待超时
|
||||
const popupPromise = page.waitForEvent('popup')
|
||||
|
||||
// 3. 标题定位点击失败
|
||||
await mainFrame.getByTitle('离散生产订单维护', { exact: true }).first().click()
|
||||
const popupPage = await popupPromise
|
||||
|
||||
// 4. forwardFrame 定位失败
|
||||
const forwardFrameLocator = popupPage.locator('#forwardFrame')
|
||||
const fFrame = await forwardFrameLocator.contentFrame()
|
||||
|
||||
// 5. mainiframe 等待超时 (30s)
|
||||
const innerFrameLocator = fFrame.locator('#mainiframe')
|
||||
await innerFrameLocator.waitFor({ state: 'visible', timeout: 30000 })
|
||||
const workFrame = await innerFrameLocator.contentFrame()
|
||||
|
||||
// 6. 热键区域加载超时 (30s)
|
||||
await workFrame.locator('#hot-key-head_list').waitFor({ state: 'visible', timeout: 30000 })
|
||||
}
|
||||
|
||||
// ========== 查询界面设置错误 ==========
|
||||
|
||||
private async setupQueryInterface(innerFrame: FrameLocator): Promise<void> {
|
||||
// 7. 查询模式切换按钮点击失败
|
||||
await innerFrame.locator('.search-name-wrapper > .iconfont').click()
|
||||
|
||||
// 8. 订单号查询选项点击失败
|
||||
await innerFrame.getByText('订单号查询').click()
|
||||
|
||||
// 9. 全部 Tab 点击失败
|
||||
await innerFrame.getByRole('tab', { name: '全部' }).click()
|
||||
|
||||
// 10. 下拉框填充失败
|
||||
const inputEl = innerFrame.locator('#rc_select_0')
|
||||
await inputEl.fill('5000')
|
||||
await inputEl.press('Enter')
|
||||
}
|
||||
|
||||
// ========== 订单查询错误 ==========
|
||||
|
||||
private async queryOrders(workFrame: FrameLocator, orderNumbers: string[]): Promise<void> {
|
||||
// 11. 文本框填充失败
|
||||
const textbox = workFrame.getByRole('textbox', { name: '生产订单号' })
|
||||
await textbox.fill(orderNumbers.join(','))
|
||||
|
||||
// 12. 查询按钮点击失败
|
||||
await workFrame.locator('.search-component-searchBtn').click()
|
||||
}
|
||||
|
||||
// ========== 订单详情打开错误 ==========
|
||||
|
||||
private async openDetailPageFromRow(workFrame: FrameLocator, popupPage: Page, rowIndex: number): Promise<Page> {
|
||||
// 13. 行元素等待超时 (15s)
|
||||
const row = workFrame.locator('tbody tr').nth(rowIndex)
|
||||
await row.waitFor({ state: 'visible', timeout: 15000 })
|
||||
|
||||
// 14. 更多按钮定位失败
|
||||
const moreButton = row.locator('a.row-more').first()
|
||||
await moreButton.scrollIntoViewIfNeeded()
|
||||
|
||||
// 15. popup 事件等待超时
|
||||
const detailPagePromise = popupPage.waitForEvent('popup')
|
||||
|
||||
// 16. 更多按钮点击失败
|
||||
await moreButton.click()
|
||||
|
||||
// 17. 备料计划菜单点击失败(备料计划菜单可能有多套定位策略)
|
||||
await this.clickMaterialPlanMenu(workFrame)
|
||||
|
||||
return await detailPagePromise
|
||||
}
|
||||
|
||||
// 18. 备料计划菜单定位失败 - 遍历 4 套定位器全部失败
|
||||
private async clickMaterialPlanMenu(workFrame: FrameLocator): Promise<void> {
|
||||
const candidates = [/* 4 套定位器 */]
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
await target.waitFor({ state: 'visible', timeout: 2000 })
|
||||
await target.click()
|
||||
return
|
||||
} catch { /* 尝试下一个 */ }
|
||||
}
|
||||
throw new Error('无法定位"备料计划"菜单项(可能菜单结构已变化)')
|
||||
}
|
||||
|
||||
// ========== 详情页处理错误 ==========
|
||||
|
||||
private async processDetailPage(params: {...}): Promise<OrderCleanDetail> {
|
||||
try {
|
||||
// 19. forwardFrame 定位失败
|
||||
const detailMainFrame = detailPage.locator('#forwardFrame')
|
||||
const dFrame = await detailMainFrame.contentFrame()
|
||||
if (!dFrame) {
|
||||
throw new Error('Failed to access detail page forward frame')
|
||||
}
|
||||
|
||||
// 20. mainiframe 定位失败
|
||||
const detailInnerLocator = dFrame.locator('#mainiframe')
|
||||
|
||||
// 21. mainiframe 等待超时 (30s)
|
||||
await detailInnerLocator.waitFor({ state: 'visible', timeout: 30000 })
|
||||
const detailInnerFrame = await detailInnerLocator.contentFrame()
|
||||
if (!detailInnerFrame) {
|
||||
throw new Error('Failed to access detail inner frame')
|
||||
}
|
||||
|
||||
// 22. 页面标题等待超时 (30s)
|
||||
await detailInnerFrame.getByText(/^离散备料计划维护:/).waitFor({ state: 'visible', timeout: 30000 })
|
||||
|
||||
// 23. 源订单号提取失败(静默处理,返回空字符串)
|
||||
const sourceOrderNumber = await this.extractSourceOrderNumber(detailInnerFrame)
|
||||
|
||||
// 24. 详细信息计数提取失败(静默处理,返回 0)
|
||||
const detailCountText = await detailInnerFrame.getByText(/^详细信息(\d+)$/).innerText()
|
||||
|
||||
// 25. 备料状态文本提取失败(静默处理,返回空字符串)
|
||||
const statusText = await detailInnerFrame.getByText(/^备料状态:.+$/).innerText()
|
||||
|
||||
if (detailStatus === '审批通过' && detailCount > 0) {
|
||||
// 26. 修改按钮点击失败
|
||||
await detailInnerFrame.getByRole('button', { name: '修改' }).click()
|
||||
|
||||
// 27. 保存按钮等待超时 (30s)
|
||||
const saveButtonLocator = detailInnerFrame.getByRole('button', { name: '保存' })
|
||||
await saveButtonLocator.waitFor({ state: 'visible', timeout: 30000 })
|
||||
|
||||
// 28. 展开按钮点击失败
|
||||
await detailInnerFrame.getByText('展开').first().click()
|
||||
|
||||
// 29. 行号输入值获取失败(静默处理)
|
||||
const currentRow = await this.getInputValue(childForm, /^行号$/)
|
||||
|
||||
// 30. 材料编码输入值获取失败(静默处理)
|
||||
const materialCode = await this.getInputValue(childForm, /^材料编码/)
|
||||
|
||||
// 31. 材料名称输入值获取失败(静默处理)
|
||||
const materialName = await this.getInputValue(childForm, /^材料名称/)
|
||||
|
||||
// 32. 累计待发数量输入值获取失败(静默处理)
|
||||
const pendingQty = await this.getInputValue(childForm, /^累计待发数量$/)
|
||||
|
||||
// 33. 删行按钮点击失败
|
||||
await deleteRowBtn.click()
|
||||
|
||||
// 34. 删行后行变化等待超时 (10s)
|
||||
const deleteSuccess = await this.waitForRowChange(childForm, oldRowNumber, 10000)
|
||||
|
||||
// 35. 下一行按钮点击失败
|
||||
await nextBtn.click()
|
||||
|
||||
// 36. 收起按钮点击失败
|
||||
await collapseBtn.click()
|
||||
|
||||
// 37. 保存按钮点击失败
|
||||
await saveButtonLocator.click()
|
||||
|
||||
// 38. 保存完成等待超时 (60s)
|
||||
await saveButtonLocator.waitFor({ state: 'hidden', timeout: 60000 })
|
||||
}
|
||||
} finally {
|
||||
// 39. 详情页关闭失败(静默处理)
|
||||
await detailPage.close()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.5 重试机制 (cleaner.ts)
|
||||
|
||||
```typescript
|
||||
private async retryFailedOrders(params: {...}): Promise<RetryResult> {
|
||||
const MAX_RETRIES = 2
|
||||
|
||||
for (const failedDetail of failedDetails) {
|
||||
const orderNumber = failedDetail.orderNumber
|
||||
const retryAttempts: RetryAttempt[] = []
|
||||
|
||||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
// 1. 重试查询订单
|
||||
await this.queryOrders(workFrame, [orderNumber])
|
||||
|
||||
// 2. 重试加载等待
|
||||
await this.waitForLoading(workFrame)
|
||||
|
||||
// 3. 重试查询结果验证
|
||||
const rows = workFrame.locator('tbody tr')
|
||||
const rowCount = await rows.count()
|
||||
if (rowCount === 0) {
|
||||
throw new Error('订单重试查询无结果')
|
||||
}
|
||||
|
||||
// 4. 重试打开详情页(从第一行)
|
||||
const detailPage = await this.openDetailPageFromCurrentQuery(workFrame, popupPage)
|
||||
|
||||
// 5. 重试处理详情页
|
||||
const retryDetail = await this.processDetailPage({...})
|
||||
|
||||
// 重试成功
|
||||
result.successfulRetries += 1
|
||||
result.updatedDetails.push({
|
||||
...retryDetail,
|
||||
retryCount: attempt,
|
||||
retriedAt: Date.now(),
|
||||
retrySuccess: true,
|
||||
retryAttempts
|
||||
})
|
||||
break
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
log.warn(`Retry attempt ${attempt} failed for order ${orderNumber}: ${message}`)
|
||||
|
||||
// 记录重试失败详情
|
||||
retryAttempts.push({
|
||||
attempt,
|
||||
error: message,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
|
||||
// 达到最大重试次数
|
||||
if (attempt === MAX_RETRIES) {
|
||||
result.updatedDetails.push({
|
||||
...failedDetail,
|
||||
retryCount: MAX_RETRIES,
|
||||
retryAttempts,
|
||||
retriedAt: Date.now(),
|
||||
retrySuccess: false
|
||||
})
|
||||
result.retriedOrders += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 清理成功的重试错误
|
||||
const successfulRetryOrders = new Set(
|
||||
retryResult.updatedDetails.filter((d) => d.retrySuccess).map((d) => d.orderNumber)
|
||||
)
|
||||
result.errors = result.errors.filter(
|
||||
(err) => !successfulRetryOrders.has(err.split(':')[0].replace('Order ', ''))
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 4.6 全局异常捕获 (cleaner.ts - clean 方法)
|
||||
|
||||
```typescript
|
||||
async clean(input: CleanerInput): Promise<CleanerResult> {
|
||||
const result: CleanerResult = { /* ... */ }
|
||||
|
||||
try {
|
||||
// 主处理逻辑
|
||||
// ...
|
||||
} catch (error) {
|
||||
// 全局异常捕获 - 任何未处理的错误都会在这里被捕获
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
log.error('Cleaner failed', { error: message })
|
||||
result.errors.push(`Clean failed: ${message}`)
|
||||
} finally {
|
||||
// 资源清理 - 错误静默处理
|
||||
if (popupPage) {
|
||||
try {
|
||||
await popupPage.close()
|
||||
} catch { /* Ignore close errors */ }
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
```
|
||||
|
||||
## 五、前端错误展示流程
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph State["React 状态"]
|
||||
ReportData["reportData state"]
|
||||
IsExecuting["isExecuting state"]
|
||||
Progress["progress state"]
|
||||
end
|
||||
|
||||
subgraph Dialog["ExecutionReportDialog"]
|
||||
ProgressView["进度视图"]
|
||||
ResultView["结果视图"]
|
||||
ErrorList["错误列表渲染"]
|
||||
end
|
||||
|
||||
subgraph Display["UI 展示"]
|
||||
StatsCards["统计卡片"]
|
||||
ErrorItems["错误项"]
|
||||
RetryStats["重试统计"]
|
||||
end
|
||||
|
||||
ReportData --> ResultView
|
||||
IsExecuting --> ProgressView
|
||||
Progress --> ProgressView
|
||||
|
||||
ResultView --> StatsCards
|
||||
ResultView --> ErrorList
|
||||
ResultView --> RetryStats
|
||||
|
||||
ErrorList --> ErrorItems
|
||||
|
||||
style ErrorList fill:#ffe1e1
|
||||
style ErrorItems fill:#ffc0c0
|
||||
```
|
||||
|
||||
### 5.1 错误展示组件 (ExecutionReportDialog.tsx)
|
||||
|
||||
```tsx
|
||||
// 错误列表渲染
|
||||
{
|
||||
hasErrors && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-200">
|
||||
<div className="text-sm font-semibold text-red-600 mb-2">错误详情</div>
|
||||
<div className="flex flex-col gap-2 max-h-40 overflow-y-auto">
|
||||
{errors.map((error, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-start gap-2 p-2 bg-red-50 rounded border border-red-200"
|
||||
>
|
||||
<XCircle size={14} className="text-red-600 flex-shrink-0 mt-0.5" />
|
||||
<span className="text-sm text-gray-900 break-words">{error}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 重试统计展示
|
||||
{
|
||||
hasRetries && (
|
||||
<>
|
||||
<div className="bg-gray-50 rounded-lg p-3 flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-lg bg-purple-50">
|
||||
<RefreshIcon className="text-purple-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-600">重试订单</div>
|
||||
<div className="text-xl font-semibold">{retriedOrders}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded-lg p-3 flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-lg bg-emerald-50">
|
||||
<CheckCircle className="text-emerald-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-600">成功重试</div>
|
||||
<div className="text-xl font-semibold">{successfulRetries}</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## 六、完整数据流
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Input["输入数据"]
|
||||
ProductionIDs["Production IDs<br/>(共享状态)"]
|
||||
MaterialCodes["物料代码<br/>(dbo_MaterialsToBeDeleted)"]
|
||||
end
|
||||
|
||||
subgraph Resolve["解析阶段"]
|
||||
DBQuery["数据库查询<br/>生产订单号"]
|
||||
Validation["格式验证"]
|
||||
Warnings["警告收集"]
|
||||
end
|
||||
|
||||
subgraph Execute["执行阶段"]
|
||||
BatchQuery["批量查询订单"]
|
||||
ProcessDetail["处理订单详情"]
|
||||
SkipLogic["跳过判断逻辑"]
|
||||
end
|
||||
|
||||
subgraph Retry["重试阶段"]
|
||||
FailedList["失败订单列表"]
|
||||
RetryLoop["最多 2 次重试"]
|
||||
UpdateErrors["更新错误列表"]
|
||||
end
|
||||
|
||||
subgraph Output["输出结果"]
|
||||
Stats["统计数据"]
|
||||
Errors["错误列表"]
|
||||
Details["订单详情"]
|
||||
Report["生成报告"]
|
||||
end
|
||||
|
||||
ProductionIDs --> DBQuery
|
||||
MaterialCodes --> Execute
|
||||
DBQuery --> Validation
|
||||
Validation --> Warnings
|
||||
Warnings --> Errors
|
||||
|
||||
Validation --> BatchQuery
|
||||
BatchQuery --> ProcessDetail
|
||||
ProcessDetail --> SkipLogic
|
||||
SkipLogic --> Stats
|
||||
|
||||
ProcessDetail --> FailedList
|
||||
FailedList --> RetryLoop
|
||||
RetryLoop --> UpdateErrors
|
||||
UpdateErrors --> Errors
|
||||
|
||||
Stats --> Output
|
||||
Errors --> Output
|
||||
Details --> Output
|
||||
Output --> Report
|
||||
|
||||
style Warnings fill:#fff4e1
|
||||
style Errors fill:#ffe1e1
|
||||
style UpdateErrors fill:#e1ffe1
|
||||
```
|
||||
|
||||
## 七、关键配置参数
|
||||
|
||||
| 参数 | 默认值 | 范围 | 说明 |
|
||||
| -------------------- | ------ | ----- | ------------------------ |
|
||||
| `queryBatchSize` | 100 | 1-100 | 每批查询的订单数量 |
|
||||
| `processConcurrency` | 1 | 1-20 | 并行处理的订单详情页数量 |
|
||||
| `dryRun` | false | - | 预览模式,不实际删除 |
|
||||
| `headless` | true | - | 后台模式,不显示浏览器 |
|
||||
| `MAX_RETRIES` | 2 | - | 失败订单最大重试次数 |
|
||||
|
||||
## 八、错误处理最佳实践
|
||||
|
||||
### 8.1 已实现的模式
|
||||
|
||||
1. **分层错误收集**: IPC 层、服务层、重试层分别收集
|
||||
2. **错误聚合**: 所有错误最终汇总到 `CleanerResult.errors`
|
||||
3. **重试恢复**: 自动重试失败订单,成功后从错误列表移除
|
||||
4. **详细记录**: 每个订单的 `OrderCleanDetail` 包含独立错误列表
|
||||
5. **审计追踪**: `RetryAttempt[]` 记录每次重试的详细信息
|
||||
|
||||
### 8.2 错误格式规范
|
||||
|
||||
```typescript
|
||||
// 订单级别错误格式
|
||||
;`Order ${orderNumber}: ${errorMessage}`
|
||||
|
||||
// 解析警告直接添加
|
||||
warnings.push(warningMessage)
|
||||
|
||||
// 重试失败记录
|
||||
retryAttempts.push({
|
||||
attempt: 1,
|
||||
error: '具体错误消息',
|
||||
timestamp: Date.now()
|
||||
})
|
||||
```
|
||||
|
||||
## 九、完整错误覆盖清单
|
||||
|
||||
### 错误覆盖完整性审计
|
||||
|
||||
| 层级 | 错误点 | 错误类型 | 是否收集 | 是否可重试 |
|
||||
| --------------- | -------------------------- | ------------------ | -------- | ---------- |
|
||||
| **前置验证** |
|
||||
| cleaner-handler | ERP 配置不完整 | ValidationError | ✅ | ❌ |
|
||||
| cleaner-handler | 数据库连接失败 | DatabaseQueryError | ✅ | ❌ |
|
||||
| cleaner-handler | 无有效订单号 | ValidationError | ✅ | ❌ |
|
||||
| cleaner-handler | ERP 登录失败 | ErpConnectionError | ✅ | ❌ |
|
||||
| **订单解析** |
|
||||
| order-resolver | ProductionID 无对应订单 | 解析警告 | ✅ | ❌ |
|
||||
| order-resolver | 格式不识别 | 解析警告 | ✅ | ❌ |
|
||||
| order-resolver | 数据库查询异常 | 抛出错误 | ✅ | ❌ |
|
||||
| **ERP 认证** |
|
||||
| erp-auth | forwardFrame 访问失败 | Error | ✅ | ❌ |
|
||||
| erp-auth | 用户名输入框找不到 | Error | ✅ | ❌ |
|
||||
| erp-auth | 密码输入框找不到 | Error | ✅ | ❌ |
|
||||
| erp-auth | 登录按钮点击失败 | Error | ✅ | ❌ |
|
||||
| erp-auth | 登录超时 | 隐式超时 | ✅ | ❌ |
|
||||
| erp-auth | 名称或密码错误 | Error | ✅ | ❌ |
|
||||
| **导航阶段** |
|
||||
| cleaner | 弹出窗口等待超时 | Playwright Timeout | ✅ | ✅ |
|
||||
| cleaner | forwardFrame 访问失败 | Playwright Error | ✅ | ✅ |
|
||||
| cleaner | mainiframe 等待超时 (30s) | Playwright Timeout | ✅ | ✅ |
|
||||
| cleaner | 热键区域加载超时 (30s) | Playwright Timeout | ✅ | ✅ |
|
||||
| **查询设置** |
|
||||
| cleaner | 查询模式切换失败 | Playwright Error | ✅ | ✅ |
|
||||
| cleaner | 下拉框填充失败 | Playwright Error | ✅ | ✅ |
|
||||
| cleaner | 查询按钮点击失败 | Playwright Error | ✅ | ✅ |
|
||||
| **订单打开** |
|
||||
| cleaner | 行元素等待超时 (15s) | Playwright Timeout | ✅ | ✅ |
|
||||
| cleaner | 更多按钮定位失败 | Playwright Error | ✅ | ✅ |
|
||||
| cleaner | popup 事件等待超时 | Playwright Timeout | ✅ | ✅ |
|
||||
| cleaner | 备料计划菜单定位失败 | Error | ✅ | ✅ |
|
||||
| **详情处理** |
|
||||
| cleaner | forwardFrame 访问失败 | Error | ✅ | ✅ |
|
||||
| cleaner | mainiframe 访问失败 (30s) | Playwright Timeout | ✅ | ✅ |
|
||||
| cleaner | 页面标题等待超时 (30s) | Playwright Timeout | ✅ | ✅ |
|
||||
| cleaner | 修改按钮点击失败 | Playwright Error | ✅ | ✅ |
|
||||
| cleaner | 保存按钮等待超时 (30s) | Playwright Timeout | ✅ | ✅ |
|
||||
| cleaner | 展开按钮点击失败 | Playwright Error | ✅ | ✅ |
|
||||
| cleaner | 删行按钮点击失败 | Playwright Error | ✅ | ✅ |
|
||||
| cleaner | 删行后行变化等待失败 (10s) | 逻辑超时 | ✅ | ✅ |
|
||||
| cleaner | 下一行按钮点击失败 | Playwright Error | ✅ | ✅ |
|
||||
| cleaner | 收起按钮点击失败 | Playwright Error | ✅ | ✅ |
|
||||
| cleaner | 保存按钮点击失败 | Playwright Error | ✅ | ✅ |
|
||||
| cleaner | 保存完成等待超时 (60s) | Playwright Timeout | ✅ | ✅ |
|
||||
| **重试阶段** |
|
||||
| cleaner | 重试查询无结果 | Error | ✅ | N/A |
|
||||
| cleaner | 重试打开详情页失败 | Playwright Error | ✅ | N/A |
|
||||
| cleaner | 重试处理异常 | Error | ✅ | N/A |
|
||||
| cleaner | 达到最大重试次数 | 逻辑错误 | ✅ | N/A |
|
||||
| **业务规则** |
|
||||
| cleaner | 物料不在删除清单 | 跳过原因 | ✅ | ❌ |
|
||||
| cleaner | 行号在保护范围 | 跳过原因 | ✅ | ❌ |
|
||||
| cleaner | 累计待发数量不为空 | 跳过原因 | ✅ | ❌ |
|
||||
| **收尾阶段** |
|
||||
| cleaner | 浏览器关闭失败 | 静默忽略 | ⚠️ | N/A |
|
||||
| cleaner | 数据库断开失败 | 静默忽略 | ⚠️ | N/A |
|
||||
| cleaner | 报告生成失败 | 静默记录 | ⚠️ | N/A |
|
||||
|
||||
**图例说明**:
|
||||
|
||||
- ✅ = 已收集到 errors 数组
|
||||
- ⚠️ = 仅记录日志,不加入错误列表
|
||||
- ❌ = 不收集(终止性错误或业务跳过)
|
||||
- N/A = 不适用
|
||||
|
||||
### 覆盖率分析
|
||||
|
||||
**总计错误点**: 52 个
|
||||
|
||||
**覆盖情况**:
|
||||
|
||||
- 完全收集 (✅): 43 个 (82.7%)
|
||||
- 静默处理 (⚠️): 3 个 (5.8%) - 资源清理类错误,不影响业务
|
||||
- 不收集 (❌): 9 个 (17.3%) - 终止性错误或业务规则跳过
|
||||
|
||||
**结论**: 错误收集覆盖全面,所有影响业务结果的错误均被正确收集。资源清理类错误采用静默处理是合理的设计决策,不影响用户对执行结果的认知。
|
||||
|
||||
## 十、总结
|
||||
|
||||
物料清理模块的错误收集机制具有以下特点:
|
||||
|
||||
1. **多层防护**: 从解析、执行到重试,每个阶段都有错误捕获
|
||||
2. **自动恢复**: 失败订单自动重试,成功后从错误列表移除
|
||||
3. **详细追踪**: 每个订单、每次重试都有详细记录
|
||||
4. **用户友好**: 前端清晰展示错误类型和统计信息
|
||||
5. **审计完整**: 所有操作记录到数据库和报告文件
|
||||
|
||||
错误处理流程遵循"收集 → 尝试恢复 → 记录 → 报告"的模式,确保用户能够清楚了解每个订单的处理状态和失败原因。
|
||||
|
||||
## 十一、相关源文件
|
||||
|
||||
| 文件路径 | 职责 | 错误收集点数 |
|
||||
| ------------------------------------------------------- | ------------------- | ------------ |
|
||||
| `src/renderer/src/pages/CleanerPage.tsx` | UI 界面 | - |
|
||||
| `src/renderer/src/hooks/useCleaner.ts` | 状态管理与 IPC 调用 | - |
|
||||
| `src/renderer/src/components/ExecutionReportDialog.tsx` | 错误报告展示 | - |
|
||||
| `src/main/ipc/cleaner-handler.ts` | IPC 处理器 | 6 |
|
||||
| `src/main/services/erp/cleaner.ts` | 核心清理服务 | 32 |
|
||||
| `src/main/services/erp/order-resolver.ts` | 订单号解析 | 4 |
|
||||
| `src/main/services/erp/erp-auth.ts` | ERP 认证 | 6 |
|
||||
| `src/main/services/report/cleaner-report-generator.ts` | 报告生成 | - |
|
||||
| `src/main/types/cleaner.types.ts` | 类型定义 | - |
|
||||
| `src/main/types/errors.ts` | 错误类型定义 | - |
|
||||
| `src/main/ipc/validation-handler.ts` | CleanerData 获取 | - |
|
||||
291
docs/cleaner-user-scope-fix.md
Normal file
291
docs/cleaner-user-scope-fix.md
Normal file
@@ -0,0 +1,291 @@
|
||||
# CleanerPage User Scope Fix
|
||||
|
||||
**Issue**: User users were affecting other users' data when using "取消" and "确认删除" buttons
|
||||
|
||||
**Date**: 2026-03-03
|
||||
**Branch**: `fix/cleaner-user-scope`
|
||||
|
||||
---
|
||||
|
||||
## Problem Analysis
|
||||
|
||||
### Bug Description
|
||||
|
||||
For **User type (non-Admin)** users:
|
||||
|
||||
1. The table shows only materials assigned to the current user (filtered by `filteredResults`)
|
||||
2. Clicking "取消" (Uncheck All) was unchecking **ALL** materials in `validationResults`, including invisible ones
|
||||
3. Clicking "确认删除" (Confirm Deletion) processed **ALL** materials in `validationResults`, not just visible ones
|
||||
4. This caused User A to delete User B's materials that User A never saw!
|
||||
|
||||
### Root Causes
|
||||
|
||||
#### 1. "取消" Button (Line 420)
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG: Clears ALL selected items
|
||||
onClick={() => setSelectedItems(new Set())}
|
||||
```
|
||||
|
||||
#### 2. `handleConfirmDeletion` Function (Line 165)
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG: Iterates ALL validation results
|
||||
for (const result of validationResults) {
|
||||
// Processes items user can't even see!
|
||||
}
|
||||
```
|
||||
|
||||
### Data Flow
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Backend"
|
||||
A[validationResults<br/>1000 items] --> B[User Filter<br/>currentUsername]
|
||||
end
|
||||
|
||||
subgraph "Frontend Display"
|
||||
B --> C[filteredResults<br/>100 items visible]
|
||||
C --> D[Table Display]
|
||||
end
|
||||
|
||||
subgraph "Bug Behavior (BEFORE FIX)"
|
||||
E[取消 Button] --> F[Clears selectedItems<br/>for ALL 1000 items ❌]
|
||||
G[确认删除 Button] --> H[Processes ALL 1000 items ❌]
|
||||
H --> I[Deletes User B's data ❌]
|
||||
end
|
||||
|
||||
subgraph "Fixed Behavior (AFTER FIX)"
|
||||
E2[取消 Button] --> F2[Clears only visible<br/>100 items ✅]
|
||||
G2[确认删除 Button] --> H2[Processes only<br/>100 items ✅]
|
||||
H2 --> I2[Only affects User A ✅]
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Solution
|
||||
|
||||
### Fix 1: "取消" Button - Only Uncheck Visible Items
|
||||
|
||||
**File**: `src/renderer/src/pages/CleanerPage.tsx:419-432`
|
||||
|
||||
```typescript
|
||||
<button
|
||||
onClick={() => {
|
||||
// Only uncheck items that are visible in filteredResults
|
||||
const visibleCodes = new Set(filteredResults.map((r) => r.materialCode))
|
||||
setSelectedItems((prev) => {
|
||||
const newSet = new Set(prev)
|
||||
for (const code of visibleCodes) {
|
||||
newSet.delete(code)
|
||||
}
|
||||
return newSet
|
||||
})
|
||||
}}
|
||||
className="text-xs bg-white border border-slate-300 text-slate-700 px-2.5 py-1.5 rounded shadow-sm hover:bg-slate-50 flex items-center gap-1"
|
||||
>
|
||||
<Square size={14} className="text-slate-400" /> 取消
|
||||
</button>
|
||||
```
|
||||
|
||||
**What Changed**:
|
||||
|
||||
- Before: `setSelectedItems(new Set())` - clears everything
|
||||
- After: Iterates through `filteredResults` and removes only visible items from `selectedItems`
|
||||
- Preserves selections for items not currently visible (e.g., other users' data)
|
||||
|
||||
### Fix 2: `handleConfirmDeletion` - Only Process Visible Items (Non-Admin)
|
||||
|
||||
**File**: `src/renderer/src/pages/CleanerPage.tsx:158-222`
|
||||
|
||||
```typescript
|
||||
const handleConfirmDeletion = async () => {
|
||||
// For non-admin users, only process visible filtered results
|
||||
// For admin users, process all validation results
|
||||
const resultsToProcess = isAdmin ? validationResults : filteredResults
|
||||
|
||||
if (resultsToProcess.length === 0) return alert('没有可处理的数据')
|
||||
|
||||
const materialsToUpsert: { materialCode: string; managerName: string }[] = []
|
||||
const materialsToDelete: string[] = []
|
||||
const missingManager: string[] = []
|
||||
|
||||
for (const result of resultsToProcess) {
|
||||
// ... rest of processing logic
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**What Changed**:
|
||||
|
||||
- Before: `for (const result of validationResults)` - processes all 1000 items
|
||||
- After: `for (const result of resultsToProcess)` where:
|
||||
- `Admin` → processes `validationResults` (all items)
|
||||
- `User` → processes only `filteredResults` (visible items)
|
||||
|
||||
---
|
||||
|
||||
## Testing Scenarios
|
||||
|
||||
### Scenario 1: User Unchecks Own Data Only
|
||||
|
||||
**Setup**:
|
||||
|
||||
- User A logs in (non-Admin)
|
||||
- 100 materials visible (assigned to User A)
|
||||
- 900 materials invisible (assigned to other users)
|
||||
- All 1000 materials are initially checked
|
||||
|
||||
**Actions**:
|
||||
|
||||
1. User A clicks "取消"
|
||||
2. Table shows all checkboxes unchecked
|
||||
|
||||
**Expected**:
|
||||
|
||||
- ✅ User A's 100 materials are unchecked
|
||||
- ✅ Other users' 900 materials **remain checked** (not affected)
|
||||
|
||||
**Verification**:
|
||||
|
||||
```typescript
|
||||
// Before fix: selectedItems.size === 0
|
||||
// After fix: selectedItems.size === 900 (other users' items still checked)
|
||||
```
|
||||
|
||||
### Scenario 2: User Confirms Deletion
|
||||
|
||||
**Setup**:
|
||||
|
||||
- User A logs in (non-Admin)
|
||||
- User A unchecks 50 of their 100 materials
|
||||
- 50 items checked (User A's)
|
||||
- 900 items checked (other users')
|
||||
|
||||
**Actions**:
|
||||
|
||||
1. User A clicks "确认删除"
|
||||
2. Confirm dialog shows: "写入/更新 50 条记录"
|
||||
|
||||
**Expected**:
|
||||
|
||||
- ✅ Only User A's 50 materials are upserted to database
|
||||
- ✅ Other users' 900 materials are **NOT touched**
|
||||
- ✅ No materials are deleted (since other users' items aren't processed)
|
||||
|
||||
### Scenario 3: Admin Behavior Unchanged
|
||||
|
||||
**Setup**:
|
||||
|
||||
- Admin logs in
|
||||
- All 1000 materials visible
|
||||
- All filtered by selected managers
|
||||
|
||||
**Actions**:
|
||||
|
||||
1. Admin clicks "取消" → all visible items unchecked
|
||||
2. Admin clicks "确认删除" → processes all filtered items
|
||||
|
||||
**Expected**:
|
||||
|
||||
- ✅ Admin behavior unchanged (can manage all data)
|
||||
- ✅ Admin can still filter by managers and process filtered results
|
||||
|
||||
---
|
||||
|
||||
## Security & Scope Implications
|
||||
|
||||
### Before Fix (Vulnerability)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
UserA[User A] --> Sees[Sees 100 items]
|
||||
UserB[User B] --> Sees2[Sees 900 items]
|
||||
Sees --> Clicks[Clicks 取消 + 确认删除]
|
||||
Clicks --> Deletes[Deletes ALL 1000 items ❌]
|
||||
Deletes --> Impact[User B loses data ❌]
|
||||
```
|
||||
|
||||
### After Fix (Secure)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
UserA[User A] --> Sees[Sees 100 items]
|
||||
UserB[User B] --> Sees2[Sees 900 items]
|
||||
Sees --> Clicks[Clicks 取消 + 确认删除]
|
||||
Clicks --> Deletes[Deletes 100 items ✅]
|
||||
Sees2 --> Independent[User B's data independent ✅]
|
||||
Deletes --> Safe[User scope isolation ✅]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Code Changes Summary
|
||||
|
||||
### File: `src/renderer/src/pages/CleanerPage.tsx`
|
||||
|
||||
| Line | Change | Description |
|
||||
| ------- | -------------------------------- | ----------------------------------------- |
|
||||
| 419-432 | Modified "取消" button | Only uncheck visible filteredResults |
|
||||
| 158-222 | Modified `handleConfirmDeletion` | Use `resultsToProcess` based on `isAdmin` |
|
||||
|
||||
### Variables Used
|
||||
|
||||
- `validationResults`: All materials from backend (1000 items)
|
||||
- `filteredResults`: Materials after user/manager filtering (100 items for User A)
|
||||
- `selectedItems`: Set of checked material codes
|
||||
- `isAdmin`: Boolean, true for Admin users
|
||||
- `currentUsername`: Current logged-in username
|
||||
|
||||
---
|
||||
|
||||
## Verification Steps
|
||||
|
||||
1. **Test as User A**:
|
||||
|
||||
```bash
|
||||
# Login as user1
|
||||
npm run dev
|
||||
# Navigate to CleanerPage
|
||||
# Verify only user1's materials are visible
|
||||
# Click "取消" → only visible items unchecked
|
||||
# Check selectedItems size = other users' checked items
|
||||
```
|
||||
|
||||
2. **Test as User B**:
|
||||
|
||||
```bash
|
||||
# Login as user2
|
||||
# Verify user1's changes didn't affect user2's data
|
||||
# All user2's materials should still be intact
|
||||
```
|
||||
|
||||
3. **Test as Admin**:
|
||||
```bash
|
||||
# Login as admin
|
||||
# Verify can still see and manage all materials
|
||||
# "取消" and "确认删除" work on all filtered results
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Related Files
|
||||
|
||||
- **Implementation**: `src/renderer/src/pages/CleanerPage.tsx`
|
||||
- **Related**: `src/main/ipc/validation-handler.ts` (backend matching logic)
|
||||
- **Related**: `docs/user-override-match-feature.md` (user override matching)
|
||||
|
||||
---
|
||||
|
||||
## Future Improvements
|
||||
|
||||
1. **Add Confirmation Dialog for Scope**: Show user how many items will be affected
|
||||
2. **Add Audit Logging**: Log which user modified which materials
|
||||
3. **Add Warning for Large Operations**: Warn if user is about to delete many items
|
||||
4. **Backend Validation**: Add backend check to prevent cross-user data modification
|
||||
|
||||
---
|
||||
|
||||
**Document End**
|
||||
1293
docs/cleaner-validation-flow.md
Normal file
1293
docs/cleaner-validation-flow.md
Normal file
File diff suppressed because it is too large
Load Diff
236
docs/erp-login-debug-guide.md
Normal file
236
docs/erp-login-debug-guide.md
Normal file
@@ -0,0 +1,236 @@
|
||||
# ERP 登录调试工具使用说明
|
||||
|
||||
## 目的
|
||||
|
||||
用于人工调试 ERP 登录流程,定位主界面特征元素,以便优化登录成功的判定逻辑。
|
||||
|
||||
## 前置准备
|
||||
|
||||
### 1. 配置 ERP 登录信息
|
||||
|
||||
编辑 `src/main/tools/erp-login-debug.ts` 文件,修改以下配置:
|
||||
|
||||
```typescript
|
||||
const ERP_CONFIG = {
|
||||
url: 'https://your-erp-server.com', // ← 修改为你的 ERP 地址
|
||||
username: 'your_username', // ← 修改为你的用户名
|
||||
password: 'your_password' // ← 修改为你的密码
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 确保 tsx 已安装
|
||||
|
||||
如果运行时报错提示找不到 `tsx`,请安装:
|
||||
|
||||
```bash
|
||||
npm install -g tsx
|
||||
# 或作为项目依赖
|
||||
npm install --save-dev tsx
|
||||
```
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 方式一:使用 npm 脚本(推荐)
|
||||
|
||||
```bash
|
||||
npm run debug:erp-login
|
||||
```
|
||||
|
||||
### 方式二:直接运行
|
||||
|
||||
```bash
|
||||
npx tsx src/main/tools/erp-login-debug.ts
|
||||
```
|
||||
|
||||
## 调试流程
|
||||
|
||||
### 步骤 1:启动脚本
|
||||
|
||||
运行命令后,脚本会显示配置信息并等待你确认:
|
||||
|
||||
```
|
||||
============================================================
|
||||
ERP 登录调试工具
|
||||
============================================================
|
||||
目标 URL: https://your-erp-server.com
|
||||
用户名:your_username
|
||||
密码: ***
|
||||
============================================================
|
||||
|
||||
操作步骤:
|
||||
1. 浏览器将自动打开并尝试登录
|
||||
2. 如果登录失败,请检查配置或手动重试
|
||||
3. 登录成功后,会自动暂停并打开开发者工具
|
||||
4. 使用元素选择器定位主界面特征元素
|
||||
5. 记录元素选择器,按 Ctrl+C 退出脚本
|
||||
|
||||
按 Enter 键开始...
|
||||
```
|
||||
|
||||
### 步骤 2:自动登录
|
||||
|
||||
脚本会自动执行:
|
||||
|
||||
- 打开浏览器
|
||||
- 导航到登录页面
|
||||
- 输入用户名和密码
|
||||
- 点击登录按钮
|
||||
- 处理强制登录确认对话框(如果有)
|
||||
|
||||
### 步骤 3:人工元素定位
|
||||
|
||||
登录成功后,脚本会暂停并显示:
|
||||
|
||||
```
|
||||
============================================================
|
||||
✓ 登录成功!
|
||||
============================================================
|
||||
|
||||
现在进入调试模式,请进行以下操作:
|
||||
|
||||
1. 按 F12 打开浏览器开发者工具
|
||||
2. 使用元素选择器 (Ctrl+Shift+C) 点击主界面特征元素
|
||||
3. 在 Elements 面板中右键元素 → Copy → Copy selector
|
||||
4. 或者使用 Playwright Inspector:
|
||||
- 在控制台输入:await page.pause()
|
||||
- 使用 Inspector 的元素选择工具
|
||||
|
||||
建议定位的特征元素:
|
||||
- 主界面顶部导航栏
|
||||
- 侧边菜单栏
|
||||
- 主内容区域的唯一标识
|
||||
- 用户信息显示区域
|
||||
- 任何登录后独有的界面元素
|
||||
|
||||
============================================================
|
||||
```
|
||||
|
||||
### 步骤 4:记录元素选择器
|
||||
|
||||
在开发者工具中:
|
||||
|
||||
1. **使用元素选择器** (Ctrl+Shift+C) 点击界面元素
|
||||
2. **在 Elements 面板** 查看元素 HTML
|
||||
3. **右键元素** → Copy → 选择以下之一:
|
||||
- `Copy selector` - CSS 选择器
|
||||
- `Copy XPath` - XPath 路径
|
||||
- `Copy JS path` - JavaScript 路径
|
||||
|
||||
### 步骤 5:更新 locators.ts
|
||||
|
||||
将找到的元素选择器添加到 `src/main/services/erp/locators.ts`:
|
||||
|
||||
```typescript
|
||||
export const ERP_LOCATORS = {
|
||||
// ... 现有配置 ...
|
||||
|
||||
// 新增:主界面特征元素(用于登录成功判定)
|
||||
mainPage: {
|
||||
topNavigationBar: '#top-nav', // 顶部导航栏
|
||||
sideMenu: '.side-menu', // 侧边菜单
|
||||
userProfile: '.user-profile', // 用户信息
|
||||
welcomeMessage: 'internal:has-text="欢迎"' // 欢迎消息
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 步骤 6:退出脚本
|
||||
|
||||
按 `Ctrl+C` 终止脚本,浏览器会在 5 秒后自动关闭。
|
||||
|
||||
## Playwright Inspector 使用技巧
|
||||
|
||||
### 开启 Inspector
|
||||
|
||||
在脚本暂停时,在浏览器控制台输入:
|
||||
|
||||
```javascript
|
||||
await page.pause()
|
||||
```
|
||||
|
||||
会打开 Playwright Inspector,提供:
|
||||
|
||||
- 元素选择器
|
||||
- 实时 locator 测试
|
||||
- 代码生成
|
||||
|
||||
### 测试 Locator
|
||||
|
||||
在 Inspector 控制台测试 locator 是否有效:
|
||||
|
||||
```javascript
|
||||
// 测试 CSS 选择器
|
||||
await page.locator('#top-nav').count()
|
||||
|
||||
// 测试 role-based 选择器
|
||||
await page.getByRole('navigation').count()
|
||||
|
||||
// 测试文本选择器
|
||||
await page.getByText('欢迎').count()
|
||||
```
|
||||
|
||||
如果返回数量 > 0,说明选择器有效。
|
||||
|
||||
## 推荐的特征元素
|
||||
|
||||
选择登录成功判定元素时,优先选择:
|
||||
|
||||
1. **唯一性** - 只在登录后出现
|
||||
2. **稳定性** - 不易随版本变更
|
||||
3. **易定位** - 有明确的 id、class 或文本
|
||||
|
||||
### 推荐元素示例
|
||||
|
||||
| 元素类型 | 选择器示例 | 说明 |
|
||||
| ------------ | ----------------------- | ---------------------- |
|
||||
| 顶部导航栏 | `#top-nav` | 登录后才会显示的主导航 |
|
||||
| 用户菜单 | `.user-menu` | 显示当前用户名的菜单 |
|
||||
| 欢迎消息 | `text=欢迎` | 包含用户名的欢迎语 |
|
||||
| 工作台标题 | `h1:has-text("工作台")` | 主界面标题 |
|
||||
| 功能模块网格 | `.module-grid` | 功能模块入口区域 |
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 登录失败,提示找不到元素
|
||||
|
||||
**A**: 检查以下几点:
|
||||
|
||||
1. ERP URL 是否正确
|
||||
2. 用户名密码是否正确
|
||||
3. 网络连接是否正常
|
||||
4. ERP 系统是否可访问
|
||||
5. 是否需要验证码(如果 ERP 有验证码,需要手动输入)
|
||||
|
||||
### Q: 登录后没有暂停
|
||||
|
||||
**A**: 检查控制台输出,可能登录流程中抛出了异常。查看错误信息并修复。
|
||||
|
||||
### Q: 如何调试特定页面?
|
||||
|
||||
**A**: 修改脚本中的登录后逻辑,导航到特定页面:
|
||||
|
||||
```typescript
|
||||
// 登录后导航到特定页面
|
||||
await page.goto(`${ERP_CONFIG.url}/yonbip/sc`)
|
||||
await page.waitForTimeout(3000)
|
||||
```
|
||||
|
||||
### Q: 如何保存调试会话?
|
||||
|
||||
**A**: Playwright 支持录制 trace:
|
||||
|
||||
```typescript
|
||||
await context.tracing.start({ screenshots: true, snapshots: true })
|
||||
// ... 操作 ...
|
||||
await context.tracing.stop({ path: 'trace.zip' })
|
||||
```
|
||||
|
||||
然后使用 `npx playwright show-trace trace.zip` 查看。
|
||||
|
||||
## 下一步
|
||||
|
||||
找到稳定的主界面元素后,修改以下文件优化登录判定:
|
||||
|
||||
1. **更新 locators.ts** - 添加主界面元素定位器
|
||||
2. **修改 erp-auth.ts** - 在登录成功后等待主界面元素
|
||||
3. **更新测试** - 验证新的登录判定逻辑
|
||||
241
docs/erp-login-debug-quickref.md
Normal file
241
docs/erp-login-debug-quickref.md
Normal file
@@ -0,0 +1,241 @@
|
||||
# ERP 登录调试工具 - 快速参考
|
||||
|
||||
## 创建的文件
|
||||
|
||||
### 1. 调试脚本
|
||||
|
||||
**路径**: `src/main/tools/erp-login-debug.ts`
|
||||
|
||||
用途:人工调试 ERP 登录流程,定位主界面特征元素
|
||||
|
||||
### 2. 使用文档
|
||||
|
||||
**路径**: `docs/erp-login-debug-guide.md`
|
||||
|
||||
详细的调试工具使用说明
|
||||
|
||||
### 3. package.json 更新
|
||||
|
||||
添加了新的 npm 脚本和依赖:
|
||||
|
||||
- `debug:erp-login` - 运行调试脚本
|
||||
- `debug:config-path` - 运行配置路径调试(已有)
|
||||
- `tsx` - TypeScript 执行器依赖
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 步骤 1:配置登录信息
|
||||
|
||||
编辑 `src/main/tools/erp-login-debug.ts` 第 19-23 行:
|
||||
|
||||
```typescript
|
||||
const ERP_CONFIG = {
|
||||
url: 'https://your-erp-server.com', // ← 修改
|
||||
username: 'your_username', // ← 修改
|
||||
password: 'your_password' // ← 修改
|
||||
}
|
||||
```
|
||||
|
||||
### 步骤 2:运行调试
|
||||
|
||||
```bash
|
||||
npm run debug:erp-login
|
||||
```
|
||||
|
||||
### 步骤 3:定位元素
|
||||
|
||||
登录成功后:
|
||||
|
||||
1. 按 **F12** 打开开发者工具
|
||||
2. 按 **Ctrl+Shift+C** 启用元素选择器
|
||||
3. 点击主界面特征元素
|
||||
4. 右键 → Copy → Copy selector
|
||||
|
||||
### 步骤 4:更新定位器
|
||||
|
||||
将找到的元素添加到 `src/main/services/erp/locators.ts`:
|
||||
|
||||
```typescript
|
||||
export const ERP_LOCATORS = {
|
||||
// ... 现有配置 ...
|
||||
|
||||
// 新增:主界面特征元素
|
||||
mainPage: {
|
||||
// 在此添加找到的元素
|
||||
topNav: '#top-nav',
|
||||
userMenu: '.user-menu'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 脚本功能
|
||||
|
||||
### 自动执行
|
||||
|
||||
- ✅ 启动浏览器(可见窗口,非无头模式)
|
||||
- ✅ 导航到登录页面
|
||||
- ✅ 输入用户名和密码
|
||||
- ✅ 点击登录按钮
|
||||
- ✅ 处理强制登录确认对话框
|
||||
|
||||
### 调试支持
|
||||
|
||||
- ✅ 登录成功后自动暂停
|
||||
- ✅ 保持浏览器打开
|
||||
- ✅ 支持 F12 开发者工具
|
||||
- ✅ 支持 Playwright Inspector
|
||||
|
||||
### 安全特性
|
||||
|
||||
- ✅ 密码显示为星号
|
||||
- ✅ 需要按 Enter 确认后才开始
|
||||
- ✅ 退出前 5 秒缓冲时间
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
# 运行调试脚本
|
||||
npm run debug:erp-login
|
||||
|
||||
# 或使用 npx 直接运行
|
||||
npx tsx src/main/tools/erp-login-debug.ts
|
||||
|
||||
# 查看帮助
|
||||
npx tsx --help
|
||||
```
|
||||
|
||||
## 调试技巧
|
||||
|
||||
### 测试 Locator 有效性
|
||||
|
||||
在浏览器控制台(登录后暂停时):
|
||||
|
||||
```javascript
|
||||
// 测试 CSS 选择器
|
||||
await page.locator('#top-nav').count()
|
||||
|
||||
// 测试文本选择器
|
||||
await page.getByText('欢迎').isVisible()
|
||||
|
||||
// 测试 role 选择器
|
||||
await page.getByRole('navigation').count()
|
||||
```
|
||||
|
||||
返回值 > 0 或 true 表示选择器有效。
|
||||
|
||||
### 查看元素详细信息
|
||||
|
||||
```javascript
|
||||
// 获取元素 HTML
|
||||
const element = await page.$('#top-nav')
|
||||
console.log(await element.innerHTML())
|
||||
|
||||
// 获取元素属性
|
||||
console.log(await element.getAttributes())
|
||||
```
|
||||
|
||||
### 截图保存
|
||||
|
||||
```javascript
|
||||
// 全屏截图
|
||||
await page.screenshot({ path: 'login-success.png' })
|
||||
|
||||
// 元素截图
|
||||
const element = await page.$('#top-nav')
|
||||
await element.screenshot({ path: 'top-nav.png' })
|
||||
```
|
||||
|
||||
## 推荐的特征元素
|
||||
|
||||
选择登录成功判定元素的标准:
|
||||
|
||||
| 标准 | 说明 | 示例 |
|
||||
| ---------- | -------------- | ------------------- |
|
||||
| **唯一性** | 只在登录后出现 | 用户菜单、工作台 |
|
||||
| **稳定性** | 不易随版本变更 | ID 选择器优于 class |
|
||||
| **易定位** | 有明确的标识 | 有 id、独特文本 |
|
||||
|
||||
### 推荐元素类型
|
||||
|
||||
1. **顶部导航栏** - `#top-nav`, `.navbar`
|
||||
2. **用户信息区域** - `.user-info`, `.user-menu`
|
||||
3. **欢迎消息** - 包含用户名的文本
|
||||
4. **功能模块入口** - 主界面的模块网格
|
||||
5. **侧边菜单栏** - `.sidebar`, `.menu`
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 问题:脚本启动后立即退出
|
||||
|
||||
**原因**: tsx 未安装
|
||||
|
||||
**解决**:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### 问题:找不到用户名/密码输入框
|
||||
|
||||
**原因**:
|
||||
|
||||
1. ERP URL 不正确
|
||||
2. 页面结构已变更
|
||||
3. 登录页面加载超时
|
||||
|
||||
**解决**:
|
||||
|
||||
1. 检查 ERP_CONFIG.url 是否正确
|
||||
2. 手动打开 URL 确认页面结构
|
||||
3. 增加 timeout 值(第 25 行)
|
||||
|
||||
### 问题:登录后没有暂停
|
||||
|
||||
**原因**: 登录流程抛出异常
|
||||
|
||||
**解决**: 查看控制台错误信息,检查:
|
||||
|
||||
- 网络连接
|
||||
- ERP 系统可用性
|
||||
- 用户名密码正确性
|
||||
|
||||
### 问题:无法定位元素
|
||||
|
||||
**原因**:
|
||||
|
||||
1. 元素在 iframe 中
|
||||
2. 元素动态加载
|
||||
3. 选择器不正确
|
||||
|
||||
**解决**:
|
||||
|
||||
1. 检查元素是否在嵌套 iframe 中
|
||||
2. 增加等待时间 `await page.waitForTimeout(2000)`
|
||||
3. 使用更具体的选择器
|
||||
|
||||
## 下一步
|
||||
|
||||
找到稳定的主界面元素后:
|
||||
|
||||
1. **更新 locators.ts**
|
||||
- 添加 `mainPage` 配置节
|
||||
- 定义登录成功判定元素
|
||||
|
||||
2. **修改 erp-auth.ts**
|
||||
- 在 `login()` 方法末尾
|
||||
- 等待主界面元素出现
|
||||
- 作为登录成功的最终判定
|
||||
|
||||
3. **验证修改**
|
||||
- 重新运行调试脚本
|
||||
- 确认新的判定逻辑有效
|
||||
- 更新相关文档
|
||||
|
||||
## 相关文件
|
||||
|
||||
| 文件 | 用途 |
|
||||
| ----------------------------------- | ---------- |
|
||||
| `src/main/tools/erp-login-debug.ts` | 调试脚本 |
|
||||
| `src/main/services/erp/locators.ts` | 元素定位器 |
|
||||
| `src/main/services/erp/erp-auth.ts` | 登录服务 |
|
||||
| `docs/erp-login-debug-guide.md` | 详细文档 |
|
||||
1268
docs/extractor-start-button-flow.md
Normal file
1268
docs/extractor-start-button-flow.md
Normal file
File diff suppressed because it is too large
Load Diff
93
docs/optimization-execution-plan.md
Normal file
93
docs/optimization-execution-plan.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# ERPAuto 优化执行计划文档
|
||||
|
||||
基于《ERPAuto 优化建议与规范指南》,本文档规划了具体的分阶段重构与优化执行步骤。每个阶段遵循“渐进式重构”原则,保证在优化期间项目依然可运行、可测试。
|
||||
|
||||
## 阶段一:基础设施建设 (Error & Logging)
|
||||
|
||||
在进行大规模业务逻辑重构前,首先建立坚实的基础设施,以便后续问题排查与数据追踪。
|
||||
|
||||
1. **引入并配置统一日志库**
|
||||
- **目标**: 替换分散的 `console.log`。
|
||||
- **执行**:
|
||||
- 安装 `winston` (针对 Node.js 主进程)。
|
||||
- 在 `src/main/services/logger` 创建单例日志记录器。
|
||||
- 配置双通道输出:Console (Dev 环境) 与 File (生产环境按天切割,如 `%AppData%/ERPAuto/logs/app-%DATE%.log`)。
|
||||
2. **定义全局错误类型与 IPC 拦截器**
|
||||
- **目标**: 规范前后端错误抛出与展示体系。
|
||||
- **执行**:
|
||||
- 在 `src/main/types/errors.ts` 定义 `BaseError`, `ErpConnectionError`, `DatabaseQueryError`。
|
||||
- 在 `src/main/ipc/index.ts` 中封装高阶函数 `withErrorHandling`。所有 IPC Handler 统一用此高阶函数包裹,将捕获的错误统一转为 `{ success: false, error: string, code: string }` 结构。
|
||||
|
||||
## 阶段二:数据层抽象与 ORM 改造
|
||||
|
||||
彻底解决 SQL 语句散落和不同数据库适配成本高的问题。
|
||||
|
||||
1. **选型并引入 ORM**
|
||||
- **目标**: 弃用原生 SQL 拼接。
|
||||
- **执行**:
|
||||
- 引入 `Prisma` 或 `TypeORM`。结合当前多数据源 (MySQL + SQL Server) 需求,推荐 `TypeORM` 因为其在运行时切换数据源更为灵活。
|
||||
2. **创建 Repository 抽象**
|
||||
- **目标**: 隔离数据库实现细节。
|
||||
- **执行**:
|
||||
- 建立 `src/main/services/database/repositories` 目录。
|
||||
- 为业务实体 (如 Users, ExtractedPlans 等) 编写 Repository 类接口。
|
||||
- 将原有 `mysql2` 和 `mssql` 的调用逐步迁移至 Repository 中。
|
||||
3. **Zod 运行时校验**
|
||||
- **目标**: 保护 IPC 边界免受恶意/格式错误的 payload 影响。
|
||||
- **执行**:
|
||||
- 安装 `zod`。
|
||||
- 对所有的 IPC Handler 的入参(如 `ExtractorInput`, `LoginRequest`)添加 `zod` Schema 校验。
|
||||
|
||||
## 阶段三:React 渲染层规范化
|
||||
|
||||
提高前端代码复用率,解耦视图与逻辑。
|
||||
|
||||
1. **提取 IPC Hooks**
|
||||
- **目标**: 清理组件中的大段异步调用。
|
||||
- **执行**:
|
||||
- 在 `src/renderer/src/hooks` 创建 `useExtractor.ts`, `useCleaner.ts`。
|
||||
- 使用 React 的 `useState` 包装 `window.api` 调用,返回 `{ loading, data, error, execute }`。
|
||||
2. **状态管理引入 (Zustand)**
|
||||
- **目标**: 解决跨组件状态共享 (如全局报错信息、用户认证状态)。
|
||||
- **执行**:
|
||||
- 安装 `zustand`。
|
||||
- 创建 `useUserStore` 和 `useAppStore`。
|
||||
3. **UI 组件库/公共样式提取**
|
||||
- **目标**: 统一 Tailwind 设计语言。
|
||||
- **执行**:
|
||||
- 将高频使用的 Button, Input, Modal 抽取到 `src/renderer/src/components/ui/`。
|
||||
|
||||
## 阶段四:自动化服务解耦 (Domain Logic)
|
||||
|
||||
将基于 Playwright 的具体执行细节与业务调度逻辑分离。
|
||||
|
||||
1. **重构 ERP 自动化服务 (`cleaner.ts` / `extractor.ts`)**
|
||||
- **目标**: 遵循单一职责原则。
|
||||
- **执行**:
|
||||
- 抽象出 `ErpBrowserManager` (负责浏览器启动与资源回收)。
|
||||
- 抽象出 `ErpAuthService` (专职处理登录和 Session)。
|
||||
- `extractor.ts` 将只负责调度:调用 Browser -> Auth -> Navigate -> Download -> Excel Parse。
|
||||
2. **加强 TypeScript 严格模式**
|
||||
- **目标**: 提升代码健壮性。
|
||||
- **执行**:
|
||||
- 开启 `tsconfig.json` 中的 `"strict": true` 和 `"noImplicitAny": true`。
|
||||
- 全局清理并替换现存的 `any` 为具体的 Type 或 `unknown` 并添加类型保护。
|
||||
|
||||
## 阶段五:测试覆盖率补充
|
||||
|
||||
确保核心流程不被破坏。
|
||||
|
||||
1. **补充关键服务的单元测试**
|
||||
- **目标**: 防止复杂转换逻辑衰退。
|
||||
- **执行**:
|
||||
- 使用 `Vitest` 测试所有的 Repository (使用内存数据库/Mock) 和工具函数 (如 ExcelParser)。
|
||||
2. **核心业务 E2E 测试**
|
||||
- **目标**: 确保 IPC 及 Electron 整体运行顺畅。
|
||||
- **执行**:
|
||||
- 使用 Playwright 针对 Electron 的测试框架 (`@playwright/test` 的 electron 插件) 编写主流程测试:登录 -> 点击提取 -> 验证本地结果文件生成。
|
||||
|
||||
## 执行建议与回顾
|
||||
|
||||
- 每个阶段应作为一个单独的 Git 分支 (Feature Branch) 开发。
|
||||
- 完成一个阶段后,必须全量运行既有的测试套件并通过 `npm run typecheck`。
|
||||
- 本文档可作为每次 PR Review 的检查清单使用。
|
||||
487
docs/plans/2026-03-03-settings-partial-save-design.md
Normal file
487
docs/plans/2026-03-03-settings-partial-save-design.md
Normal file
@@ -0,0 +1,487 @@
|
||||
# 配置保存优化设计文档
|
||||
|
||||
**日期:** 2026-03-03
|
||||
**分支:** fix/settings-partial-save
|
||||
**状态:** 设计阶段
|
||||
|
||||
---
|
||||
|
||||
## 问题描述
|
||||
|
||||
当前设置界面只能配置 3 个字段(ERP URL、用户名、密码),但保存后会意外覆盖 `.env` 文件中的其他配置项(如 `DB_TYPE`、`VALIDATION_DATA_SOURCE` 等),导致这些字段被重置为默认值或丢失。
|
||||
|
||||
### 根本原因
|
||||
|
||||
在 `config-manager.ts:437-483` 中,`saveAllSettings()` 方法无条件覆盖所有配置类别。当 UI 只发送部分字段时,未包含的字段会被设置为 `undefined` 或默认值,导致原有配置丢失。
|
||||
|
||||
**数据流问题:**
|
||||
|
||||
```
|
||||
SettingsPage (只修改 ERP URL)
|
||||
↓ 发送完整的 settings 对象
|
||||
ConfigManager.saveAllSettings()
|
||||
↓ 覆盖所有字段到缓存
|
||||
.env 文件被完全重写(丢失未被 UI 包含的字段)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 解决方案
|
||||
|
||||
采用 **方案 A(深度合并)+ 方案 C(字段白名单)** 的组合策略:
|
||||
|
||||
### 核心策略
|
||||
|
||||
1. **部分更新**:只更新传入的字段,保留其他字段不变
|
||||
2. **白名单验证**:只允许 UI 支持的字段被修改
|
||||
3. **备份机制**:保存前备份,失败可回滚
|
||||
4. **安全日志**:记录所有配置变更操作
|
||||
|
||||
---
|
||||
|
||||
## 架构设计
|
||||
|
||||
### 数据流
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ SettingsPage │
|
||||
│ (Renderer) │
|
||||
└────────┬────────┘
|
||||
│ 只发送支持的字段
|
||||
│ { erp: { url, username, password } }
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ Settings Handler│
|
||||
│ (IPC Bridge) │
|
||||
└────────┬────────┘
|
||||
│ 传递部分配置 (Partial<SettingsData>)
|
||||
▼
|
||||
┌─────────────────────────────┐
|
||||
│ ConfigManager │
|
||||
│ ┌─────────────────────┐ │
|
||||
│ │ 1. 验证字段白名单 │ │
|
||||
│ │ 2. 深度合并当前配置 │ │
|
||||
│ │ 3. 备份 .env 文件 │ │
|
||||
│ │ 4. 原子写入新配置 │ │
|
||||
│ └─────────────────────┘ │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
### 改动点
|
||||
|
||||
| 文件 | 改动类型 | 说明 |
|
||||
| -------------------------------------------- | -------- | ------------------------------------------------ |
|
||||
| `src/main/services/config/config-manager.ts` | 核心 | 新增 `savePartialSettings()`、深度合并、备份机制 |
|
||||
| `src/main/ipc/settings-handler.ts` | 调整 | IPC 参数改为 `Partial<SettingsData>` |
|
||||
| `src/renderer/src/pages/SettingsPage.tsx` | 优化 | 只发送 UI 支持的字段 |
|
||||
|
||||
---
|
||||
|
||||
## 核心实现
|
||||
|
||||
### 1. 深度合并工具函数
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 深度合并两个对象,只更新 target 中存在的字段
|
||||
* 保留 source 中 target 没有的字段
|
||||
*/
|
||||
function deepMerge<T>(source: T, target: Partial<T>): T {
|
||||
const result = { ...source }
|
||||
|
||||
for (const key in target) {
|
||||
if (key in target) {
|
||||
const targetValue = target[key]
|
||||
const sourceValue = result[key]
|
||||
|
||||
if (isObject(targetValue) && isObject(sourceValue)) {
|
||||
result[key] = deepMerge(sourceValue, targetValue)
|
||||
} else if (targetValue !== undefined) {
|
||||
result[key] = targetValue as T[Extract<keyof T, string>]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 字段白名单验证
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 定义 UI 可编辑的字段路径
|
||||
* 使用点号表示法:'section.field'
|
||||
*/
|
||||
const UI_EDITABLE_FIELDS: string[] = [
|
||||
'erp.url',
|
||||
'erp.username',
|
||||
'erp.password'
|
||||
// 未来扩展:
|
||||
// 'database.dbType',
|
||||
// 'paths.dataDir',
|
||||
// ...
|
||||
]
|
||||
|
||||
/**
|
||||
* 验证配置更新是否只包含允许的字段
|
||||
*/
|
||||
function validateEditableFields(settings: Partial<SettingsData>): {
|
||||
valid: boolean
|
||||
invalidFields: string[]
|
||||
} {
|
||||
const invalidFields: string[] = []
|
||||
|
||||
for (const [section, values] of Object.entries(settings)) {
|
||||
if (values && typeof values === 'object') {
|
||||
for (const field of Object.keys(values)) {
|
||||
const fieldPath = `${section}.${field}`
|
||||
if (!UI_EDITABLE_FIELDS.includes(fieldPath)) {
|
||||
invalidFields.push(fieldPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: invalidFields.length === 0,
|
||||
invalidFields
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 部分保存方法
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 保存部分配置(只更新传入的字段)
|
||||
*/
|
||||
public async savePartialSettings(
|
||||
settings: Partial<SettingsData>
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
// 步骤 1: 验证字段白名单
|
||||
const validation = validateEditableFields(settings)
|
||||
if (!validation.valid) {
|
||||
log.warn('Attempted to save non-editable fields', {
|
||||
invalidFields: validation.invalidFields
|
||||
})
|
||||
return {
|
||||
success: false,
|
||||
error: `包含不允许修改的字段:${validation.invalidFields.join(', ')}`
|
||||
}
|
||||
}
|
||||
|
||||
// 步骤 2: 读取当前配置
|
||||
const currentSettings = this.getAllSettings()
|
||||
|
||||
// 步骤 3: 深度合并
|
||||
const mergedSettings = deepMerge(currentSettings, settings)
|
||||
|
||||
// 步骤 4: 备份并保存
|
||||
const backupSuccess = await this.backupEnvFile()
|
||||
if (!backupSuccess) {
|
||||
log.warn('Failed to backup .env file, proceeding with caution')
|
||||
}
|
||||
|
||||
const saveSuccess = await this.saveAllSettings(mergedSettings)
|
||||
|
||||
if (!saveSuccess) {
|
||||
// 保存失败,尝试恢复备份
|
||||
await this.restoreBackup()
|
||||
return {
|
||||
success: false,
|
||||
error: '保存配置失败,已恢复原配置'
|
||||
}
|
||||
}
|
||||
|
||||
log.info('Settings saved successfully', {
|
||||
updatedFields: Object.keys(settings)
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
log.error('Error in savePartialSettings', { error: message })
|
||||
await this.restoreBackup()
|
||||
return {
|
||||
success: false,
|
||||
error: `保存配置时发生错误:${message}`
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 备份与恢复机制
|
||||
|
||||
```typescript
|
||||
private backupPath: string
|
||||
|
||||
constructor() {
|
||||
// ...
|
||||
this.backupPath = path.resolve(__dirname, '../../.env.backup')
|
||||
}
|
||||
|
||||
/**
|
||||
* 备份当前 .env 文件
|
||||
*/
|
||||
private async backupEnvFile(): Promise<boolean> {
|
||||
try {
|
||||
if (fs.existsSync(this.envPath)) {
|
||||
fs.copyFileSync(this.envPath, this.backupPath)
|
||||
log.debug('Backup created', { path: this.backupPath })
|
||||
return true
|
||||
}
|
||||
return false
|
||||
} catch (error) {
|
||||
log.error('Failed to backup .env file', { error })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从备份恢复 .env 文件
|
||||
*/
|
||||
private async restoreBackup(): Promise<boolean> {
|
||||
try {
|
||||
if (fs.existsSync(this.backupPath)) {
|
||||
fs.copyFileSync(this.backupPath, this.envPath)
|
||||
await this.loadEnvFile() // 重新加载到缓存
|
||||
log.info('Restored from backup')
|
||||
return true
|
||||
}
|
||||
return false
|
||||
} catch (error) {
|
||||
log.error('Failed to restore backup', { error })
|
||||
return false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## IPC 调用链路调整
|
||||
|
||||
### settings-handler.ts
|
||||
|
||||
```typescript
|
||||
ipcMain.handle(
|
||||
'settings:saveSettings',
|
||||
async (_event, settings: Partial<SettingsData>): Promise<SaveSettingsResult> => {
|
||||
try {
|
||||
log.info('Saving settings', {
|
||||
sections: Object.keys(settings)
|
||||
})
|
||||
|
||||
// 使用新的部分保存方法
|
||||
const result = await configManager.savePartialSettings(settings)
|
||||
|
||||
if (result.success) {
|
||||
log.info('Settings saved successfully')
|
||||
return { success: true }
|
||||
} else {
|
||||
log.warn('Failed to save settings', {
|
||||
error: result.error
|
||||
})
|
||||
return {
|
||||
success: false,
|
||||
error: result.error || '保存设置失败'
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
log.error('Error saving settings', { error: message })
|
||||
return {
|
||||
success: false,
|
||||
error: `保存设置失败:${message}`
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
**关键改动:**
|
||||
|
||||
- 参数类型从 `SettingsData` 改为 `Partial<SettingsData>`
|
||||
- 调用 `savePartialSettings()` 替代 `saveAllSettings()`
|
||||
|
||||
---
|
||||
|
||||
## 前端优化(双重保险)
|
||||
|
||||
### SettingsPage.tsx
|
||||
|
||||
```typescript
|
||||
const handleSaveSettings = async () => {
|
||||
try {
|
||||
// 只发送 UI 支持的字段(双重保险)
|
||||
const partialSettings = {
|
||||
erp: {
|
||||
url: settings.erp?.url,
|
||||
username: settings.erp?.username,
|
||||
password: settings.erp?.password
|
||||
}
|
||||
}
|
||||
|
||||
const result = await window.electron.settings.saveSettings(partialSettings)
|
||||
|
||||
if (result.success) {
|
||||
setIsModified(false)
|
||||
showMessage('success', '设置保存成功')
|
||||
} else {
|
||||
showMessage('error', result.error || '保存失败')
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage('error', '保存设置时发生错误')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 测试策略
|
||||
|
||||
### 单元测试场景
|
||||
|
||||
```typescript
|
||||
describe('ConfigManager.savePartialSettings', () => {
|
||||
it('应该只更新指定的字段,保留其他字段', async () => {
|
||||
const initial = {
|
||||
erp: { url: 'http://old.com', username: 'user1' },
|
||||
database: { dbType: 'mysql' }
|
||||
}
|
||||
|
||||
const update = {
|
||||
erp: { url: 'http://new.com' }
|
||||
}
|
||||
|
||||
await configManager.savePartialSettings(update)
|
||||
const result = configManager.getAllSettings()
|
||||
|
||||
expect(result.erp.url).toBe('http://new.com')
|
||||
expect(result.erp.username).toBe('user1') // 保留
|
||||
expect(result.database.dbType).toBe('mysql') // 保留
|
||||
})
|
||||
|
||||
it('应该拒绝未授权的字段更新', async () => {
|
||||
const invalidUpdate = {
|
||||
database: { dbType: 'postgres' }
|
||||
}
|
||||
|
||||
const result = await configManager.savePartialSettings(invalidUpdate)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('不允许修改')
|
||||
})
|
||||
|
||||
it('保存失败时应该恢复备份', async () => {
|
||||
jest.spyOn(fs, 'writeFileSync').mockImplementation(() => {
|
||||
throw new Error('Disk full')
|
||||
})
|
||||
|
||||
const result = await configManager.savePartialSettings({ erp: { url: 'x' } })
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### 手动验证步骤
|
||||
|
||||
1. 打开 `.env`,记录所有字段值
|
||||
2. 打开设置页面,只修改 ERP URL
|
||||
3. 点击保存
|
||||
4. 检查 `.env`:只有 `ERP_URL` 改变,其他字段保持原值
|
||||
|
||||
---
|
||||
|
||||
## 未来扩展性
|
||||
|
||||
### 1. 白名单配置化
|
||||
|
||||
当设置页面需要支持更多配置时:
|
||||
|
||||
```typescript
|
||||
const UI_EDITABLE_FIELDS: string[] = [
|
||||
'erp.url',
|
||||
'erp.username',
|
||||
'erp.password',
|
||||
'database.dbType', // 新增
|
||||
'paths.dataDir', // 新增
|
||||
'extraction.batchSize' // 新增
|
||||
// ...
|
||||
]
|
||||
```
|
||||
|
||||
### 2. 按用户角色分级
|
||||
|
||||
```typescript
|
||||
const EDITABLE_FIELDS_BY_ROLE: Record<UserType, string[]> = {
|
||||
Admin: ['*'],
|
||||
User: ['erp.url', 'erp.username', 'erp.password'],
|
||||
Guest: []
|
||||
}
|
||||
|
||||
function validateEditableFields(settings: Partial<SettingsData>, userType: UserType) {
|
||||
const allowed = EDITABLE_FIELDS_BY_ROLE[userType]
|
||||
// 验证逻辑...
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 配置变更审计
|
||||
|
||||
```typescript
|
||||
interface ConfigChange {
|
||||
timestamp: Date
|
||||
user: string
|
||||
field: string
|
||||
oldValue: string
|
||||
newValue: string
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 实施计划
|
||||
|
||||
下一步将创建详细的实施计划,包括:
|
||||
|
||||
1. 在 ConfigManager 中添加深度合并和验证函数
|
||||
2. 实现 `savePartialSettings()` 方法
|
||||
3. 添加备份与恢复机制
|
||||
4. 更新 IPC handler 调用
|
||||
5. 前端优化(只发送必要字段)
|
||||
6. 编写单元测试
|
||||
7. 集成测试和手动验证
|
||||
|
||||
---
|
||||
|
||||
## 风险与缓解
|
||||
|
||||
| 风险 | 影响 | 缓解措施 |
|
||||
| ---------------- | ---------- | ----------------------------- |
|
||||
| 深度合并逻辑错误 | 配置错误 | 完善单元测试覆盖 |
|
||||
| 备份文件权限问题 | 无法恢复 | 错误处理 + 日志 |
|
||||
| 白名单漏配置 | 功能受限 | 清晰的文档 + 代码注释 |
|
||||
| 并发保存冲突 | 数据不一致 | 单实例 ConfigManager + 文件锁 |
|
||||
|
||||
---
|
||||
|
||||
## 附录
|
||||
|
||||
### 相关文件
|
||||
|
||||
- `src/main/services/config/config-manager.ts` - 配置管理器
|
||||
- `src/main/ipc/settings-handler.ts` - IPC 处理器
|
||||
- `src/renderer/src/pages/SettingsPage.tsx` - 设置页面
|
||||
- `src/main/types/settings.types.ts` - 类型定义
|
||||
|
||||
### 参考
|
||||
|
||||
- 当前问题:保存设置时 `.env` 中未包含的字段被覆盖
|
||||
- 设计原则:安全优先、最小化修改、可扩展性
|
||||
1038
docs/plans/2026-03-03-settings-partial-save-implementation.md
Normal file
1038
docs/plans/2026-03-03-settings-partial-save-implementation.md
Normal file
File diff suppressed because it is too large
Load Diff
130
docs/plans/IMPLEMENTATION_PLAN.md
Normal file
130
docs/plans/IMPLEMENTATION_PLAN.md
Normal file
@@ -0,0 +1,130 @@
|
||||
# Implementation Plan: Auto-import Extracted Data to Database
|
||||
|
||||
## Overview
|
||||
|
||||
Implement automatic database import after ERP data extraction completes. The merged Excel file will be read and written to the `dbo_DiscreteMaterialPlanData` table.
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Trigger**: Automatic after extraction completes
|
||||
- **Delete Strategy**: Batch delete by `SourceNumber` before insert
|
||||
- **Batch Insert**: 1000 records per batch
|
||||
- **Field Mapping**: 28 Excel fields → database columns (skip 打印人, 打印日期, BOMVersion)
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
ExtractorService
|
||||
│
|
||||
├── extract() → download + merge Excel
|
||||
│
|
||||
└── NEW: importToDatabase(mergedFile)
|
||||
│
|
||||
▼
|
||||
DataImportService
|
||||
│
|
||||
├── readExcelFile() → records + sourceNumbers
|
||||
├── deleteExistingRecords(sourceNumbers)
|
||||
└── batchInsert(records, batchSize=1000)
|
||||
│
|
||||
▼
|
||||
DiscreteMaterialPlanDAO
|
||||
├── deleteBySourceNumbers()
|
||||
└── batchInsert()
|
||||
```
|
||||
|
||||
## Field Mapping
|
||||
|
||||
| Excel Header | Database Column | Notes |
|
||||
| ------------ | ------------------------ | ---------------- |
|
||||
| 工厂 | Factory | |
|
||||
| 备料状态 | MaterialStatus | |
|
||||
| 备料计划单号 | PlanNumber | |
|
||||
| 来源单号 | SourceNumber | **Deletion key** |
|
||||
| 备料类型 | MaterialType | |
|
||||
| 产品编码 | ProductCode | |
|
||||
| 产品名称 | ProductName | |
|
||||
| 产品计划数量 | ProductPlanQuantity | decimal |
|
||||
| 产品单位 | ProductUnit | |
|
||||
| 用料部门 | UseDepartment | |
|
||||
| 备注 | Remark | |
|
||||
| 制单人 | Creator | |
|
||||
| 制单日期 | CreateDate | date |
|
||||
| 审批人 | Approver | |
|
||||
| 审批日期 | ApproveDate | date |
|
||||
| 序号 | SequenceNumber | int |
|
||||
| 材料编码 | MaterialCode | |
|
||||
| 材料名称 | MaterialName | |
|
||||
| 规格 | Specification | |
|
||||
| 型号 | Model | |
|
||||
| 图号 | DrawingNumber | |
|
||||
| 物料材质 | MaterialQuality | |
|
||||
| 计划数量 | PlanQuantity | decimal |
|
||||
| 单位 | Unit | |
|
||||
| 需用日期 | RequiredDate | date |
|
||||
| 发料仓库 | Warehouse | |
|
||||
| 单位用量 | UnitUsage | decimal |
|
||||
| 累计出库数量 | CumulativeOutputQuantity | decimal |
|
||||
| 打印人 | ❌ SKIP | Not in DB |
|
||||
| 打印日期 | ❌ SKIP | Not in DB |
|
||||
| - | BOMVersion | SKIP (no source) |
|
||||
|
||||
## Files to Create/Modify
|
||||
|
||||
### 1. NEW: `src/main/services/database/data-importer.ts`
|
||||
|
||||
Main import service with:
|
||||
|
||||
- `importFromExcel(filePath)` - Main entry point
|
||||
- `readExcelFile(filePath)` - Parse Excel using ExcelJS
|
||||
- Map Excel columns to database fields
|
||||
- Return records and unique SourceNumbers
|
||||
|
||||
### 2. MODIFY: `src/main/services/database/discrete-material-plan-dao.ts`
|
||||
|
||||
Add methods:
|
||||
|
||||
- `deleteBySourceNumbers(sourceNumbers: string[])` - Batch delete
|
||||
- `batchInsert(records: MaterialPlanRecord[], batchSize: number)` - Batch insert
|
||||
|
||||
### 3. MODIFY: `src/main/services/erp/extractor.ts`
|
||||
|
||||
- After successful merge, call `importToDatabase(mergedFile)`
|
||||
- Add import results to `ExtractorResult`
|
||||
|
||||
### 4. MODIFY: `src/main/types/extractor.types.ts`
|
||||
|
||||
Add types:
|
||||
|
||||
```typescript
|
||||
export interface ImportResult {
|
||||
success: boolean
|
||||
recordsImported: number
|
||||
recordsDeleted: number
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
export interface ExtractorResult {
|
||||
// existing fields...
|
||||
importResult?: ImportResult
|
||||
}
|
||||
```
|
||||
|
||||
### 5. MODIFY: `src/renderer/src/pages/ExtractorPage.tsx`
|
||||
|
||||
- Display import results
|
||||
- Show records deleted/imported counts
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. Extend `DiscreteMaterialPlanDAO` with insert/delete methods
|
||||
2. Create `DataImportService`
|
||||
3. Integrate into `ExtractorService`
|
||||
4. Update types
|
||||
5. Update UI
|
||||
|
||||
## Testing Plan
|
||||
|
||||
1. Unit test DAO methods
|
||||
2. Integration test with sample Excel file
|
||||
3. E2E test extraction → import flow
|
||||
62
docs/settings-partial-save.md
Normal file
62
docs/settings-partial-save.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# Settings Partial Save Feature
|
||||
|
||||
## Overview
|
||||
|
||||
The settings system now implements partial save functionality to prevent unintended overwrites of configuration values.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Field Whitelist**: Only fields exposed in the UI can be modified
|
||||
2. **Deep Merge**: Updates are merged with existing config, preserving unmodified fields
|
||||
3. **Backup & Rollback**: Config is backed up before save; failures trigger automatic rollback
|
||||
|
||||
## Editable Fields
|
||||
|
||||
Currently editable via UI:
|
||||
|
||||
- `erp.url` - ERP system URL
|
||||
- `erp.username` - ERP login username
|
||||
- `erp.password` - ERP login password
|
||||
|
||||
## Adding New Editable Fields
|
||||
|
||||
To add a new field to the UI:
|
||||
|
||||
1. Add field to whitelist in `src/main/services/config/config-manager.ts`:
|
||||
|
||||
```typescript
|
||||
const UI_EDITABLE_FIELDS: string[] = [
|
||||
'erp.url',
|
||||
'erp.username',
|
||||
'erp.password',
|
||||
'database.dbType' // Add new field here
|
||||
]
|
||||
```
|
||||
|
||||
2. Add UI input in `src/renderer/src/pages/SettingsPage.tsx`
|
||||
3. Update `handleSaveSettings` to include the new field
|
||||
|
||||
## API
|
||||
|
||||
### savePartialSettings(settings: Partial<SettingsData>)
|
||||
|
||||
Saves only the provided fields, preserving all existing configuration.
|
||||
|
||||
**Returns:** `{ success: boolean, error?: string }`
|
||||
|
||||
**Validation:**
|
||||
|
||||
- Checks whitelist before applying changes
|
||||
- Returns error for unauthorized fields
|
||||
|
||||
## Error Handling
|
||||
|
||||
- **Unauthorized field**: Returns error message listing invalid fields
|
||||
- **Save failure**: Automatically restores from backup
|
||||
- **Backup failure**: Logs warning, continues with save
|
||||
|
||||
## Backup File
|
||||
|
||||
Location: `.env.backup` (in project root)
|
||||
|
||||
Created before every save operation. Used for rollback on failure.
|
||||
927
docs/settings-save-button-flow.md
Normal file
927
docs/settings-save-button-flow.md
Normal file
@@ -0,0 +1,927 @@
|
||||
# 系统设置保存按钮工作流程分析
|
||||
|
||||
# System Settings Save Button Workflow Analysis
|
||||
|
||||
## 文档概述 / Document Overview
|
||||
|
||||
本文档详细分析了 ERPAuto 系统设置界面中保存按钮的完整工作流程,包括架构设计、数据流转、技术实现细节以及错误处理机制。
|
||||
|
||||
This document provides a comprehensive analysis of the save button workflow in the ERPAuto system settings interface, including architecture design, data flow, technical implementation details, and error handling mechanisms.
|
||||
|
||||
---
|
||||
|
||||
## 目录 / Table of Contents
|
||||
|
||||
1. [架构概览](#架构概览)
|
||||
2. [数据流程图](#数据流程图)
|
||||
3. [组件详解](#组件详解)
|
||||
4. [数据结构](#数据结构)
|
||||
5. [错误处理机制](#错误处理机制)
|
||||
6. [安全考虑](#安全考虑)
|
||||
7. [技术实现细节](#技术实现细节)
|
||||
|
||||
---
|
||||
|
||||
## 架构概览 / Architecture Overview
|
||||
|
||||
### 系统架构 / System Architecture
|
||||
|
||||
系统设置保存功能采用典型的 Electron 三层架构模式:
|
||||
|
||||
The system settings save functionality follows the classic Electron three-tier architecture pattern:
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Renderer Process 渲染进程"
|
||||
UI[SettingsPage.tsx<br/>UI Component]
|
||||
end
|
||||
|
||||
subgraph "Preload Script 预加载脚本"
|
||||
BRIDGE[contextBridge API<br/>Security Boundary]
|
||||
end
|
||||
|
||||
subgraph "Main Process 主进程"
|
||||
IPC[settings-handler.ts<br/>IPC Handler]
|
||||
SERVICE[ConfigManager.ts<br/>Configuration Service]
|
||||
FILE[.env File<br/>Persistent Storage]
|
||||
end
|
||||
|
||||
UI -->|IPC Invoke| BRIDGE
|
||||
BRIDGE -->|Secure Channel| IPC
|
||||
IPC -->|Business Logic| SERVICE
|
||||
SERVICE -->|Write| FILE
|
||||
FILE -->|Confirm| SERVICE
|
||||
SERVICE -->|Result| IPC
|
||||
IPC -->|Response| BRIDGE
|
||||
BRIDGE -->|Promise Resolve| UI
|
||||
|
||||
style UI fill:#e1f5ff
|
||||
style BRIDGE fill:#fff4e1
|
||||
style IPC fill:#ffe1f5
|
||||
style SERVICE fill:#e1ffe1
|
||||
style FILE fill:#f5f5f5
|
||||
```
|
||||
|
||||
### 核心设计模式 / Core Design Patterns
|
||||
|
||||
1. **单向数据流**:数据从 UI → Main Process → File,响应沿相反路径返回
|
||||
2. **安全隔离**:Preload 脚本作为安全桥梁,通过 `contextBridge` 暴露受限 API
|
||||
3. **单例模式**:ConfigManager 使用单例确保配置一致性
|
||||
4. **缓存优先**:配置读取优先从内存缓存获取,写入时同步到磁盘
|
||||
|
||||
---
|
||||
|
||||
## 数据流程图 / Data Flow Diagrams
|
||||
|
||||
### 完整保存流程 / Complete Save Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor User as 用户 User
|
||||
participant UI as SettingsPage.tsx
|
||||
participant Preload as preload/index.ts
|
||||
participant IPC as settings-handler.ts
|
||||
participant Config as ConfigManager.ts
|
||||
participant File as .env File
|
||||
|
||||
User->>UI: 点击保存按钮<br/>Click Save Button
|
||||
activate UI
|
||||
|
||||
UI->>UI: handleSaveSettings()
|
||||
Note over UI: 检查是否修改<br/>Check isModified
|
||||
|
||||
UI->>Preload: window.electron.settings<br/>.saveSettings(settings)
|
||||
activate Preload
|
||||
|
||||
Preload->>IPC: ipcRenderer.invoke<br/>('settings:saveSettings', settings)
|
||||
activate IPC
|
||||
|
||||
IPC->>IPC: 验证用户类型<br/>Validate User Type
|
||||
IPC->>Config: configManager<br/>.saveAllSettings(settings)
|
||||
activate Config
|
||||
|
||||
Config->>Config: 更新内存缓存<br/>Update Cache
|
||||
Note over Config: set('erp.url', value)<br/>set('erp.username', value)<br/>... (40+ fields)
|
||||
|
||||
Config->>File: fs.writeFileSync<br/>(.env, content)
|
||||
activate File
|
||||
File-->>Config: true/false
|
||||
deactivate File
|
||||
|
||||
Config-->>IPC: Promise<boolean>
|
||||
deactivate Config
|
||||
|
||||
IPC-->>Preload: {success, error?}
|
||||
deactivate IPC
|
||||
|
||||
Preload-->>UI: Promise resolve
|
||||
deactivate Preload
|
||||
|
||||
alt 保存成功 / Save Success
|
||||
UI->>UI: setIsModified(false)
|
||||
UI->>User: 显示成功消息<br/>Show Success Message
|
||||
else 保存失败 / Save Failed
|
||||
UI->>User: 显示错误消息<br/>Show Error Message
|
||||
end
|
||||
|
||||
deactivate UI
|
||||
```
|
||||
|
||||
### 数据转换流程 / Data Transformation Flow
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph "UI State"
|
||||
STATE[Settings Interface<br/>settings.erp.url = 'https://...']
|
||||
end
|
||||
|
||||
subgraph "Type Conversion"
|
||||
T1[SettingsData Object<br/>TypeScript Interface]
|
||||
end
|
||||
|
||||
subgraph "IPC Transport"
|
||||
JSON[JSON Serialization<br/>String Transfer]
|
||||
end
|
||||
|
||||
subgraph "Service Layer"
|
||||
CACHE[Config Cache<br/>Map<string, string>]
|
||||
end
|
||||
|
||||
subgraph "File System"
|
||||
ENV[.env File Format<br/>KEY=VALUE]
|
||||
end
|
||||
|
||||
STATE -->|Object| T1
|
||||
T1 -->|JSON.stringify| JSON
|
||||
JSON -->|Deserialize| T1
|
||||
T1 -->|set key-value| CACHE
|
||||
CACHE -->|Format| ENV
|
||||
|
||||
style STATE fill:#e1f5ff
|
||||
style JSON fill:#fff4e1
|
||||
style CACHE fill:#e1ffe1
|
||||
style ENV fill:#f5f5f5
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 组件详解 / Component Details
|
||||
|
||||
### 1. 渲染进程 / Renderer Process
|
||||
|
||||
#### SettingsPage.tsx (`src/renderer/src/pages/SettingsPage.tsx`)
|
||||
|
||||
**主要职责 / Main Responsibilities:**
|
||||
|
||||
- 用户界面渲染和交互
|
||||
- 本地状态管理(settings, isModified, message)
|
||||
- 调用 IPC 通信
|
||||
|
||||
**关键函数 / Key Functions:**
|
||||
|
||||
```typescript
|
||||
// 第 61-73 行 / Lines 61-73
|
||||
const handleSaveSettings = async () => {
|
||||
try {
|
||||
const result = await window.electron.settings.saveSettings(settings as any)
|
||||
if (result.success) {
|
||||
setIsModified(false) // 清除修改标记
|
||||
showMessage('success', '设置保存成功')
|
||||
} else {
|
||||
showMessage('error', result.error || '保存失败')
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage('error', '保存设置时发生错误')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**状态管理 / State Management:**
|
||||
|
||||
| 状态变量 | 类型 | 用途 |
|
||||
| ------------ | ---------------- | ----------------------------------------------------------- |
|
||||
| `settings` | `Settings` | 当前配置数据,结构为 `{ erp: { url, username, password } }` |
|
||||
| `isModified` | `boolean` | 标记配置是否已修改,控制保存按钮启用状态 |
|
||||
| `isLoading` | `boolean` | 加载状态,显示加载动画 |
|
||||
| `message` | `object \| null` | 临时消息,3秒后自动消失 |
|
||||
|
||||
**UI 交互逻辑 / UI Interaction Logic:**
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Loading: 组件挂载
|
||||
Loading --> Ready: loadSettings()
|
||||
Ready --> Modified: updateSettings()
|
||||
Modified --> Modified: 继续修改
|
||||
Modified --> Ready: 保存成功
|
||||
Modified --> Error: 保存失败
|
||||
Error --> Modified: 用户继续操作
|
||||
Ready --> [*]: 组件卸载
|
||||
|
||||
note right of Modified
|
||||
保存按钮启用
|
||||
Save Button Enabled
|
||||
end note
|
||||
|
||||
note right of Ready
|
||||
保存按钮禁用
|
||||
Save Button Disabled
|
||||
end note
|
||||
```
|
||||
|
||||
### 2. 预加载脚本 / Preload Script
|
||||
|
||||
#### preload/index.ts (`src/preload/index.ts`)
|
||||
|
||||
**主要职责 / Main Responsibilities:**
|
||||
|
||||
- 安全桥梁,暴露受限 API 到渲染进程
|
||||
- 类型安全的 IPC 通道定义
|
||||
|
||||
**关键代码 / Key Code:**
|
||||
|
||||
```typescript
|
||||
// 第 89-97 行 / Lines 89-97
|
||||
settings: {
|
||||
getUserType: () => ipcRenderer.invoke('settings:getUserType'),
|
||||
getSettings: () => ipcRenderer.invoke('settings:getSettings'),
|
||||
saveSettings: (settings: SettingsData) =>
|
||||
ipcRenderer.invoke('settings:saveSettings', settings),
|
||||
resetDefaults: () => ipcRenderer.invoke('settings:resetDefaults'),
|
||||
testErpConnection: () => ipcRenderer.invoke('settings:testErpConnection'),
|
||||
testDbConnection: () => ipcRenderer.invoke('settings:testDbConnection')
|
||||
}
|
||||
```
|
||||
|
||||
**安全隔离机制 / Security Isolation:**
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
Renderer[Renderer Process<br/>Untrusted Context]
|
||||
Preload[Preload Script<br/>Trusted Context]
|
||||
Main[Main Process<br/>Trusted Context]
|
||||
|
||||
Renderer -->|window.electron| Preload
|
||||
Preload -->|ipcRenderer.invoke| Main
|
||||
Main -->|Validation| Preload
|
||||
Preload -->|Return Promise| Renderer
|
||||
|
||||
style Renderer fill:#ffe1e1
|
||||
style Preload fill:#e1ffe1
|
||||
style Main fill:#e1e1ff
|
||||
```
|
||||
|
||||
### 3. 主进程 / Main Process
|
||||
|
||||
#### settings-handler.ts (`src/main/ipc/settings-handler.ts`)
|
||||
|
||||
**主要职责 / Main Responsibilities:**
|
||||
|
||||
- IPC 通道注册和处理
|
||||
- 权限验证(基于用户类型)
|
||||
- 业务逻辑协调
|
||||
|
||||
**保存设置处理函数 / Save Settings Handler:**
|
||||
|
||||
```typescript
|
||||
// 第 83-102 行 / Lines 83-102
|
||||
ipcMain.handle(
|
||||
'settings:saveSettings',
|
||||
async (_event, settings: SettingsData): Promise<SaveSettingsResult> => {
|
||||
try {
|
||||
log.info('Saving settings')
|
||||
const success = await configManager.saveAllSettings(settings)
|
||||
if (success) {
|
||||
log.info('Settings saved successfully')
|
||||
return { success: true }
|
||||
} else {
|
||||
log.warn('Failed to save settings')
|
||||
return { success: false, error: '保存设置失败' }
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
log.error('Error saving settings', { error: message })
|
||||
return { success: false, error: `保存设置失败:${message}` }
|
||||
}
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
**用户类型过滤 / User Type Filtering:**
|
||||
|
||||
```typescript
|
||||
// 第 31-54 行 / Lines 31-54
|
||||
function filterSettingsByUserType(settings: SettingsData, userType: UserType): SettingsData {
|
||||
if (userType === 'Admin') {
|
||||
return settings // Admin 获取完整配置
|
||||
}
|
||||
|
||||
// User 用户获取受限配置
|
||||
return {
|
||||
erp: {
|
||||
username: settings.erp.username,
|
||||
password: settings.erp.password,
|
||||
headless: settings.erp.headless,
|
||||
url: settings.erp.url,
|
||||
ignoreHttpsErrors: settings.erp.ignoreHttpsErrors,
|
||||
autoCloseBrowser: settings.erp.autoCloseBrowser
|
||||
},
|
||||
paths: settings.paths,
|
||||
execution: settings.execution,
|
||||
database: settings.database,
|
||||
extraction: settings.extraction,
|
||||
validation: settings.validation,
|
||||
ui: settings.ui
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**权限控制矩阵 / Permission Control Matrix:**
|
||||
|
||||
| 功能 / Feature | Admin | User | Guest |
|
||||
| -------------- | ----- | ------- | ----- |
|
||||
| 查看所有设置 | ✅ | ⚠️ 部分 | ❌ |
|
||||
| 保存设置 | ✅ | ✅ | ❌ |
|
||||
| 恢复默认值 | ✅ | ❌ | ❌ |
|
||||
| 测试 ERP 连接 | ✅ | ✅ | ❌ |
|
||||
| 测试数据库连接 | ✅ | ✅ | ❌ |
|
||||
|
||||
### 4. 配置管理服务 / Configuration Manager Service
|
||||
|
||||
#### config-manager.ts (`src/main/services/config/config-manager.ts`)
|
||||
|
||||
**主要职责 / Main Responsibilities:**
|
||||
|
||||
- .env 文件读写
|
||||
- 配置缓存管理
|
||||
- 默认值管理
|
||||
- 类型转换和验证
|
||||
|
||||
**类结构 / Class Structure:**
|
||||
|
||||
```typescript
|
||||
export class ConfigManager {
|
||||
private static instance: ConfigManager | null = null // 单例模式
|
||||
private envPath: string // .env 文件路径
|
||||
private configCache: Map<string, string> // 内存缓存
|
||||
private initialized: boolean = false // 初始化标记
|
||||
|
||||
// 单例获取方法
|
||||
public static getInstance(): ConfigManager
|
||||
|
||||
// 配置读取
|
||||
public get(key: string, defaultValue?: string): string | undefined
|
||||
public getBoolean(key: string, defaultValue?: boolean): boolean
|
||||
public getNumber(key: string, defaultValue?: number): number
|
||||
|
||||
// 配置写入
|
||||
public set(key: string, value: string | number | boolean): void
|
||||
|
||||
// 持久化
|
||||
public async save(): Promise<boolean>
|
||||
|
||||
// 高级操作
|
||||
public getAllSettings(): SettingsData
|
||||
public async saveAllSettings(settings: SettingsData): Promise<boolean>
|
||||
public resetToDefaults(): SettingsData
|
||||
}
|
||||
```
|
||||
|
||||
**保存详细流程 / Save Detailed Flow:**
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
START[saveAllSettings] --> STEP1[更新 ERP 配置 6 字段]
|
||||
STEP1 --> STEP2[更新数据库配置 7 字段]
|
||||
STEP2 --> STEP3[更新路径配置 3 字段]
|
||||
STEP3 --> STEP4[更新提取配置 5 字段]
|
||||
STEP4 --> STEP5[更新校验配置 5 字段]
|
||||
STEP5 --> STEP6[更新 UI 配置 3 字段]
|
||||
STEP6 --> STEP7[更新执行配置 1 字段]
|
||||
STEP7 --> SAVE[调用 save 方法]
|
||||
SAVE --> BUILD[构建 .env 内容]
|
||||
BUILD --> WRITE[写入文件系统]
|
||||
WRITE --> CHECK{检查结果}
|
||||
CHECK -->|成功| SUCCESS[返回 true]
|
||||
CHECK -->|失败| FAILURE[返回 false]
|
||||
```
|
||||
|
||||
**.env 文件格式 / .env File Format:**
|
||||
|
||||
```bash
|
||||
# ===========================
|
||||
# ERP 系统配置
|
||||
# ===========================
|
||||
ERP_URL=https://68.11.34.30:8082/
|
||||
ERP_USERNAME=
|
||||
ERP_PASSWORD=
|
||||
ERP_HEADLESS=true
|
||||
ERP_IGNORE_HTTPS_ERRORS=true
|
||||
ERP_AUTO_CLOSE_BROWSER=true
|
||||
|
||||
# ===========================
|
||||
# 数据库配置 - MySQL
|
||||
# ===========================
|
||||
DB_TYPE=mysql
|
||||
DB_NAME=BLD_DB
|
||||
DB_USERNAME=remote_user
|
||||
DB_PASSWORD=
|
||||
DB_MYSQL_HOST=192.168.31.83
|
||||
DB_MYSQL_PORT=3306
|
||||
DB_MYSQL_CHARSET=utf8mb4
|
||||
|
||||
# ===========================
|
||||
# 路径配置
|
||||
# ===========================
|
||||
PATH_DATA_DIR=D:/python/playwrite/data/
|
||||
PATH_DEFAULT_OUTPUT=离散备料计划维护_合并.xlsx
|
||||
PATH_VALIDATION_OUTPUT=物料状态校验结果.xlsx
|
||||
|
||||
# ... 更多配置节
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 数据结构 / Data Structures
|
||||
|
||||
### SettingsData 接口 / Interface Definition
|
||||
|
||||
**类型定义位置 / Type Definition Location:**
|
||||
`src/main/types/settings.types.ts` (第 136-151 行)
|
||||
|
||||
```typescript
|
||||
export interface SettingsData {
|
||||
erp: ErpConfig
|
||||
database: DatabaseConfig
|
||||
paths: PathsConfig
|
||||
extraction: ExtractionConfig
|
||||
validation: ValidationConfig
|
||||
ui: UiConfig
|
||||
execution: ExecutionConfig
|
||||
}
|
||||
```
|
||||
|
||||
### 完整数据结构树 / Complete Data Structure Tree
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
Settings[SettingsData]
|
||||
|
||||
Settings --> Erp[ErpConfig]
|
||||
Erp --> Erp1[url: string]
|
||||
Erp --> Erp2[username: string]
|
||||
Erp --> Erp3[password: string]
|
||||
Erp --> Erp4[headless: boolean]
|
||||
Erp --> Erp5[ignoreHttpsErrors: boolean]
|
||||
Erp --> Erp6[autoCloseBrowser: boolean]
|
||||
|
||||
Settings --> DB[DatabaseConfig]
|
||||
DB --> DB1[dbType: mysql or sqlserver]
|
||||
DB --> DB2[server: string]
|
||||
DB --> DB3[mysqlHost: string]
|
||||
DB --> DB4[mysqlPort: number]
|
||||
DB --> DB5[database: string]
|
||||
DB --> DB6[username: string]
|
||||
DB --> DB7[password: string]
|
||||
|
||||
Settings --> Paths[PathsConfig]
|
||||
Paths --> Paths1[dataDir: string]
|
||||
Paths --> Paths2[defaultOutput: string]
|
||||
Paths --> Paths3[validationOutput: string]
|
||||
|
||||
Settings --> Extract[ExtractionConfig]
|
||||
Extract --> Extract1[batchSize: number]
|
||||
Extract --> Extract2[verbose: boolean]
|
||||
Extract --> Extract3[autoConvert: boolean]
|
||||
Extract --> Extract4[mergeBatches: boolean]
|
||||
Extract --> Extract5[enableDbPersistence: boolean]
|
||||
|
||||
Settings --> Valid[ValidationConfig]
|
||||
Valid --> Valid1[dataSource: ValidationDataSource]
|
||||
Valid --> Valid2[batchSize: number]
|
||||
Valid --> Valid3[matchMode: MatchMode]
|
||||
Valid --> Valid4[enableCrud: boolean]
|
||||
Valid --> Valid5[defaultManager: string]
|
||||
|
||||
Settings --> UI[UiConfig]
|
||||
UI --> UI1[fontFamily: string]
|
||||
UI --> UI2[fontSize: number]
|
||||
UI --> UI3[productionIdInputWidth: number]
|
||||
|
||||
Settings --> Exec[ExecutionConfig]
|
||||
Exec --> Exec1[dryRun: boolean]
|
||||
|
||||
style Settings fill:#e1f5ff
|
||||
style Erp fill:#ffe1f5
|
||||
style DB fill:#e1ffe1
|
||||
style Paths fill:#fff4e1
|
||||
style Extract fill:#f5e1ff
|
||||
style Valid fill:#ffe1e1
|
||||
style UI fill:#e1f5ff
|
||||
style Exec fill:#f5f5f5
|
||||
```
|
||||
|
||||
### IPC 通信数据格式 / IPC Communication Data Format
|
||||
|
||||
**请求格式 / Request Format:**
|
||||
|
||||
```json
|
||||
{
|
||||
"erp": {
|
||||
"url": "https://68.11.34.30:8082/",
|
||||
"username": "admin",
|
||||
"password": "password123",
|
||||
"headless": true,
|
||||
"ignoreHttpsErrors": true,
|
||||
"autoCloseBrowser": true
|
||||
},
|
||||
"database": { ... },
|
||||
"paths": { ... },
|
||||
"extraction": { ... },
|
||||
"validation": { ... },
|
||||
"ui": { ... },
|
||||
"execution": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
**响应格式 / Response Format:**
|
||||
|
||||
```json
|
||||
// 成功 / Success
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
|
||||
// 失败 / Failure
|
||||
{
|
||||
"success": false,
|
||||
"error": "保存设置失败:Access denied"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 错误处理机制 / Error Handling Mechanism
|
||||
|
||||
### 错误处理层次 / Error Handling Layers
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "UI Layer"
|
||||
UI_TRY[try-catch in handleSaveSettings]
|
||||
UI_MSG[showMessage display]
|
||||
end
|
||||
|
||||
subgraph "IPC Layer"
|
||||
IPC_TRY[try-catch in handler]
|
||||
IPC_LOG[Structured logging]
|
||||
IPC_RETURN[Return error object]
|
||||
end
|
||||
|
||||
subgraph "Service Layer"
|
||||
SVC_TRY[try-catch in save]
|
||||
SVC_LOG[Console error log]
|
||||
SVC_RETURN[Return false]
|
||||
end
|
||||
|
||||
subgraph "File System"
|
||||
FS_CHECK[File exists check]
|
||||
FS_WRITE[Write with error handling]
|
||||
end
|
||||
|
||||
UI_TRY -->|Catch| UI_MSG
|
||||
IPC_TRY -->|Catch| IPC_LOG --> IPC_RETURN
|
||||
SVC_TRY -->|Catch| SVC_LOG --> SVC_RETURN
|
||||
FS_WRITE -->|Error| SVC_TRY
|
||||
|
||||
style UI_TRY fill:#ffe1e1
|
||||
style IPC_TRY fill:#ffe1e1
|
||||
style SVC_TRY fill:#ffe1e1
|
||||
```
|
||||
|
||||
### 错误场景分析 / Error Scenario Analysis
|
||||
|
||||
| 错误场景 / Error Scenario | 触发位置 / Location | 处理方式 / Handling | 用户反馈 / User Feedback |
|
||||
| ------------------------- | ------------------- | --------------------- | ------------------------ |
|
||||
| IPC 通信失败 | Renderer | try-catch | 显示"保存设置时发生错误" |
|
||||
| 权限不足 | Main Process | 检查 UserType | 返回权限错误信息 |
|
||||
| 文件写入失败 | ConfigManager | fs.writeFileSync 捕获 | 返回"保存设置失败" |
|
||||
| 无效数据类型 | IPC Handler | TypeScript 类型检查 | 返回验证错误 |
|
||||
| 磁盘空间不足 | File System | OS 异常捕获 | 返回系统错误信息 |
|
||||
|
||||
### 日志记录策略 / Logging Strategy
|
||||
|
||||
```typescript
|
||||
// Main Process 结构化日志 / Structured Logging
|
||||
log.info('Saving settings')
|
||||
log.info('Settings saved successfully')
|
||||
log.warn('Failed to save settings')
|
||||
log.error('Error saving settings', { error: message })
|
||||
```
|
||||
|
||||
**日志级别使用 / Log Level Usage:**
|
||||
|
||||
- `info`: 正常操作流程
|
||||
- `warn`: 潜在问题(如保存失败但未崩溃)
|
||||
- `error`: 严重错误(如异常抛出)
|
||||
|
||||
---
|
||||
|
||||
## 安全考虑 / Security Considerations
|
||||
|
||||
### 安全机制层级 / Security Layers
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
L1[Layer 1: Context Isolation<br/>渲染进程隔离]
|
||||
L2[Layer 2: contextBridge<br/>受限 API 暴露]
|
||||
L3[Layer 3: User Type Filtering<br/>基于角色的访问控制]
|
||||
L4[Layer 4: File System Permissions<br/>.env 文件保护]
|
||||
|
||||
L1 --> L2 --> L3 --> L4
|
||||
|
||||
style L1 fill:#e1f5ff
|
||||
style L2 fill:#fff4e1
|
||||
style L3 fill:#e1ffe1
|
||||
style L4 fill:#ffe1f5
|
||||
```
|
||||
|
||||
### 关键安全措施 / Key Security Measures
|
||||
|
||||
1. **密码明文存储风险 / Password Storage Risk**
|
||||
- ⚠️ 当前:密码以明文形式存储在 .env 文件中
|
||||
- 🔒 建议:实现加密存储机制
|
||||
|
||||
2. **用户权限隔离 / User Permission Isolation**
|
||||
- ✅ 实现:基于用户类型过滤可见配置
|
||||
- ✅ 实现:Guest 用户无法访问设置页面
|
||||
|
||||
3. **IPC 通信安全 / IPC Communication Security**
|
||||
- ✅ 实现:使用 `contextBridge` 而非直接暴露
|
||||
- ✅ 实现:类型安全的 TypeScript 接口
|
||||
|
||||
4. **文件系统访问 / File System Access**
|
||||
- ✅ 实现:.env 文件仅主进程可访问
|
||||
- ⚠️ 风险:文件权限取决于操作系统
|
||||
|
||||
### 敏感数据流向 / Sensitive Data Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User as 用户输入
|
||||
participant UI as UI State (内存)
|
||||
participant IPC as IPC Channel
|
||||
participant Cache as Config Cache
|
||||
participant File as .env File
|
||||
|
||||
User->>UI: password = "secret123"
|
||||
UI->>IPC: JSON 传输 (未加密)
|
||||
IPC->>Cache: Map.set('erp.password', 'secret123')
|
||||
Cache->>File: 写入明文到磁盘
|
||||
|
||||
Note over File: ⚠️ 安全风险:<br/>密码以明文形式持久化
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技术实现细节 / Technical Implementation Details
|
||||
|
||||
### 文件位置索引 / File Location Index
|
||||
|
||||
| 组件 / Component | 文件路径 / File Path | 关键行数 / Key Lines |
|
||||
| ---------------- | -------------------------------------------- | --------------------- |
|
||||
| UI 组件 | `src/renderer/src/pages/SettingsPage.tsx` | 61-73 (保存处理) |
|
||||
| 预加载脚本 | `src/preload/index.ts` | 89-97 (API 定义) |
|
||||
| IPC 处理器 | `src/main/ipc/settings-handler.ts` | 83-102 (保存处理) |
|
||||
| 配置管理器 | `src/main/services/config/config-manager.ts` | 437-483 (保存方法) |
|
||||
| 类型定义 | `src/main/types/settings.types.ts` | 136-171 (接口定义) |
|
||||
| IPC 注册 | `src/main/ipc/index.ts` | 导入 settings-handler |
|
||||
|
||||
### 性能特性 / Performance Characteristics
|
||||
|
||||
1. **异步操作 / Async Operations**
|
||||
- 所有 IPC 调用使用 `async/await` 模式
|
||||
- 避免阻塞主进程事件循环
|
||||
|
||||
2. **内存优化 / Memory Optimization**
|
||||
- 使用 Map 缓存配置,减少文件读取
|
||||
- 按需加载配置项
|
||||
|
||||
3. **写入策略 / Write Strategy**
|
||||
- 每次保存完整重写 .env 文件
|
||||
- 原子写入(writeFileSync)
|
||||
|
||||
### 依赖关系图 / Dependency Graph
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[SettingsPage.tsx] -->|imports| B[lucide-react]
|
||||
A -->|uses| C[window.electron.settings]
|
||||
|
||||
C -->|exposed by| D[preload/index.ts]
|
||||
D -->|imports| E[electron API]
|
||||
D -->|imports| F[SettingsData Type]
|
||||
|
||||
G[settings-handler.ts] -->|imports| H[ipcMain]
|
||||
G -->|imports| I[ConfigManager]
|
||||
G -->|imports| J[SessionManager]
|
||||
G -->|imports| K[Logger]
|
||||
|
||||
I -->|imports| L[fs/path]
|
||||
I -->|imports| M[SettingsData Type]
|
||||
I -->|imports| N[DEFAULT_SETTINGS]
|
||||
|
||||
style A fill:#e1f5ff
|
||||
style D fill:#fff4e1
|
||||
style G fill:#ffe1f5
|
||||
style I fill:#e1ffe1
|
||||
```
|
||||
|
||||
### 关键代码片段分析 / Key Code Snippet Analysis
|
||||
|
||||
**1. 状态更新逻辑 / State Update Logic**
|
||||
|
||||
```typescript
|
||||
// SettingsPage.tsx 第 50-59 行
|
||||
const updateSettings = (category: string, key: string, value: any) => {
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
[category]: {
|
||||
...(prev as any)[category],
|
||||
[key]: value
|
||||
}
|
||||
}))
|
||||
setIsModified(true) // 标记为已修改
|
||||
}
|
||||
```
|
||||
|
||||
**设计要点 / Design Points:**
|
||||
|
||||
- 不可变更新模式(Immutable Update Pattern)
|
||||
- 使用展开运算符保持对象引用
|
||||
- 自动启用保存按钮
|
||||
|
||||
**2. 配置保存逻辑 / Configuration Save Logic**
|
||||
|
||||
```typescript
|
||||
// config-manager.ts 第 437-483 行
|
||||
public async saveAllSettings(settings: SettingsData): Promise<boolean> {
|
||||
// 批量更新缓存 (40+ 字段)
|
||||
this.set('erp.url', settings.erp.url)
|
||||
this.set('erp.username', settings.erp.username)
|
||||
// ... 更多字段
|
||||
|
||||
// 同步写入文件
|
||||
return this.save()
|
||||
}
|
||||
```
|
||||
|
||||
**设计要点 / Design Points:**
|
||||
|
||||
- 先更新内存,后写入磁盘
|
||||
- 失败时缓存保持不变
|
||||
- 返回布尔值表示成功/失败
|
||||
|
||||
**3. .env 文件生成逻辑 / .env File Generation**
|
||||
|
||||
```typescript
|
||||
// config-manager.ts 第 179-345 行
|
||||
public async save(): Promise<boolean> {
|
||||
const lines: string[] = []
|
||||
|
||||
// 构建格式化的 .env 内容
|
||||
lines.push('# ===========================')
|
||||
lines.push('# ERP 系统配置')
|
||||
lines.push('# ===========================')
|
||||
lines.push(`ERP_URL=${this.configCache.get('erp.url') || DEFAULT_SETTINGS.erp.url}`)
|
||||
|
||||
const content = lines.join('\n')
|
||||
fs.writeFileSync(this.envPath, content, 'utf-8')
|
||||
return true
|
||||
}
|
||||
```
|
||||
|
||||
**设计要点 / Design Points:**
|
||||
|
||||
- 添加注释分隔符提高可读性
|
||||
- 使用默认值作为后备
|
||||
- 同步写入确保一致性
|
||||
|
||||
---
|
||||
|
||||
## 扩展与改进建议 / Extension and Improvement Suggestions
|
||||
|
||||
### 短期改进 / Short-term Improvements
|
||||
|
||||
1. **输入验证 / Input Validation**
|
||||
- 添加 URL 格式验证
|
||||
- 密码强度检查
|
||||
- 端口号范围验证
|
||||
|
||||
2. **用户体验 / User Experience**
|
||||
- 添加保存进度指示器
|
||||
- 实现自动保存功能
|
||||
- 添加配置导入/导出
|
||||
|
||||
3. **错误处理 / Error Handling**
|
||||
- 更详细的错误消息
|
||||
- 错误恢复建议
|
||||
- 错误日志导出
|
||||
|
||||
### 长期改进 / Long-term Improvements
|
||||
|
||||
1. **安全性增强 / Security Enhancement**
|
||||
|
||||
```typescript
|
||||
// 建议实现密码加密
|
||||
interface SecureSettingsData extends SettingsData {
|
||||
erp: {
|
||||
...ErpConfig
|
||||
encryptedPassword: string // 替代明文密码
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **配置版本控制 / Configuration Versioning**
|
||||
- 实现配置历史记录
|
||||
- 支持回滚到之前版本
|
||||
- 配置变更审计日志
|
||||
|
||||
3. **实时配置重载 / Live Config Reload**
|
||||
- 监听 .env 文件变化
|
||||
- 自动重载配置
|
||||
- 通知相关服务更新
|
||||
|
||||
---
|
||||
|
||||
## 测试建议 / Testing Recommendations
|
||||
|
||||
### 单元测试 / Unit Tests
|
||||
|
||||
```typescript
|
||||
// 测试用例示例
|
||||
describe('ConfigManager', () => {
|
||||
it('should save settings successfully', async () => {
|
||||
const manager = ConfigManager.getInstance()
|
||||
const settings: SettingsData = {
|
||||
/* mock data */
|
||||
}
|
||||
const result = await manager.saveAllSettings(settings)
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle file write errors', async () => {
|
||||
// Mock fs.writeFileSync to throw error
|
||||
const result = await manager.saveAllSettings(settings)
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### 集成测试 / Integration Tests
|
||||
|
||||
```typescript
|
||||
describe('Settings Save Flow', () => {
|
||||
it('should complete full save cycle', async () => {
|
||||
// 1. User modifies settings
|
||||
// 2. Clicks save button
|
||||
// 3. Verifies .env file updated
|
||||
// 4. Confirms UI feedback
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 附录 / Appendix
|
||||
|
||||
### 完整配置字段列表 / Complete Configuration Field List
|
||||
|
||||
| 类别 / Category | 字段数 / Field Count | 字段列表 / Field List |
|
||||
| ---------------- | -------------------- | ---------------------------------------------------------------------- |
|
||||
| ERP | 6 | url, username, password, headless, ignoreHttpsErrors, autoCloseBrowser |
|
||||
| Database | 7 | dbType, server, mysqlHost, mysqlPort, database, username, password |
|
||||
| Paths | 3 | dataDir, defaultOutput, validationOutput |
|
||||
| Extraction | 5 | batchSize, verbose, autoConvert, mergeBatches, enableDbPersistence |
|
||||
| Validation | 5 | dataSource, batchSize, matchMode, enableCrud, defaultManager |
|
||||
| UI | 3 | fontFamily, fontSize, productionIdInputWidth |
|
||||
| Execution | 1 | dryRun |
|
||||
| **总计 / Total** | **30** | |
|
||||
|
||||
### 相关文档 / Related Documentation
|
||||
|
||||
- [Electron Security Guidelines](https://www.electronjs.org/docs/latest/tutorial/security)
|
||||
- [IPC 通信最佳实践](https://www.electronjs.org/docs/latest/tutorial/ipc)
|
||||
- [环境变量管理规范](.env.example)
|
||||
|
||||
### 版本历史 / Version History
|
||||
|
||||
| 版本 / Version | 日期 / Date | 变更 / Changes |
|
||||
| -------------- | ----------- | -------------------------- |
|
||||
| 1.0 | 2025-03-03 | 初始版本 / Initial version |
|
||||
|
||||
---
|
||||
|
||||
**文档生成时间 / Document Generated:** 2025-03-03
|
||||
**最后更新 / Last Updated:** 2025-03-03
|
||||
**维护者 / Maintainer:** ERPAuto Development Team
|
||||
287
docs/user-override-match-feature.md
Normal file
287
docs/user-override-match-feature.md
Normal file
@@ -0,0 +1,287 @@
|
||||
# 物料匹配算法增强 - 用户覆盖匹配功能
|
||||
|
||||
**实施日期**: 2026-03-03
|
||||
**功能版本**: 1.0
|
||||
**修改文件**: `src/main/ipc/validation-handler.ts`
|
||||
|
||||
---
|
||||
|
||||
## 功能概述
|
||||
|
||||
为 **User 用户类型** 在物料清理界面增加了 **优先级3:用户覆盖匹配** 功能,确保 User 用户能够优先看到并管理与自己关键词匹配的物料。
|
||||
|
||||
---
|
||||
|
||||
## 实现的更改
|
||||
|
||||
### 1. 获取当前用户信息
|
||||
|
||||
**位置**: `validation-handler.ts:218-239`
|
||||
|
||||
```typescript
|
||||
// Get current user info
|
||||
const sessionManager = (
|
||||
await import('../services/user/session-manager')
|
||||
).SessionManager.getInstance()
|
||||
|
||||
const userInfo = sessionManager.getUserInfo()
|
||||
if (!userInfo) {
|
||||
return {
|
||||
success: false,
|
||||
error: '用户未登录',
|
||||
stats: {
|
||||
totalRecords: 0,
|
||||
matchedCount: 0,
|
||||
markedCount: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isAdmin = userInfo.userType === 'Admin'
|
||||
const username = userInfo.username
|
||||
|
||||
log.info('Starting validation', { mode: request.mode, user: username, isAdmin })
|
||||
```
|
||||
|
||||
**说明**:
|
||||
|
||||
- 在 `validation:validate` handler 开始时获取当前登录用户信息
|
||||
- 提取 `isAdmin` 和 `username` 用于后续匹配逻辑
|
||||
- 如果用户未登录,返回错误响应
|
||||
|
||||
### 2. 新增优先级3:用户覆盖匹配
|
||||
|
||||
**位置**: `validation-handler.ts:359-370`
|
||||
|
||||
```typescript
|
||||
// Priority 3: User Override Match (only for non-admin users)
|
||||
// Override with current user's typeKeyword if available
|
||||
if (!isAdmin && username) {
|
||||
const userKeywords = typeKeywords.filter((tk) => tk.managerName === username)
|
||||
for (const userKeyword of userKeywords) {
|
||||
if (userKeyword.materialName && materialName.includes(userKeyword.materialName)) {
|
||||
matchedTypeKeyword = userKeyword.materialName
|
||||
managerName = userKeyword.managerName
|
||||
break // Force override with first match
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**匹配逻辑**:
|
||||
|
||||
1. **适用范围**: 仅对 `isAdmin === false` 的 User 用户生效
|
||||
2. **筛选关键词**: 从 `typeKeywords` 中筛选 `managerName === username` 的记录
|
||||
3. **匹配规则**: 使用 `materialName.includes(userKeyword.materialName)` 包含关系匹配
|
||||
4. **强制覆盖**: 只要匹配成功,立即覆盖原有的 `managerName` 和 `matchedTypeKeyword`
|
||||
5. **无匹配时**: 保持优先级2的匹配结果不变
|
||||
|
||||
---
|
||||
|
||||
## 匹配优先级(更新后)
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
Start([物料数据]) --> P1{优先级1<br/>MaterialsToBeDeleted<br/>精确匹配?}
|
||||
P1 -->|MaterialCode匹配| M1[✅ 已标记删除<br/>isMarkedForDeletion=true]
|
||||
P1 -->|未匹配| P2{优先级2<br/>MaterialsTypeToBeDeleted<br/>包含匹配?}
|
||||
|
||||
P2 -->|匹配到| M2[⚠️ 类型匹配<br/>managerName=其他用户]
|
||||
P2 -->|未匹配| M3[❌ 未匹配<br/>managerName='']
|
||||
|
||||
M1 --> Check{用户类型?}
|
||||
M2 --> Check
|
||||
M3 --> Check
|
||||
|
||||
Check -->|Admin| Skip[跳过覆盖]
|
||||
Check -->|User| P3{优先级3<br/>用户覆盖匹配?}
|
||||
|
||||
P3 -->|匹配成功| Override[✅ 覆为当前用户<br/>managerName=当前用户]
|
||||
P3 -->|未匹配| Keep[保持原结果]
|
||||
|
||||
Skip --> End([返回结果])
|
||||
Override --> End
|
||||
Keep --> End
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 测试场景
|
||||
|
||||
### 场景1: User 用户匹配到自己的 typeKeyword
|
||||
|
||||
**输入**:
|
||||
|
||||
- 当前用户: `user1`
|
||||
- 物料名称: `螺丝 M6`
|
||||
- MaterialsTypeToBeDeleted: `{ materialName: "螺丝", managerName: "user1" }`
|
||||
|
||||
**预期输出**:
|
||||
|
||||
```json
|
||||
{
|
||||
"materialName": "螺丝 M6",
|
||||
"managerName": "user1",
|
||||
"matchedTypeKeyword": "螺丝",
|
||||
"isMarkedForDeletion": false
|
||||
}
|
||||
```
|
||||
|
||||
### 场景2: User 用户覆盖其他用户的匹配
|
||||
|
||||
**输入**:
|
||||
|
||||
- 当前用户: `user1`
|
||||
- 物料名称: `螺丝 M6`
|
||||
- MaterialsTypeToBeDeleted:
|
||||
- `{ materialName: "螺丝", managerName: "user2" }`
|
||||
- `{ materialName: "螺丝", managerName: "user1" }`
|
||||
|
||||
**优先级2结果**: `managerName = "user2"`
|
||||
**优先级3结果**: `managerName = "user1"` ✅ 强制覆盖
|
||||
|
||||
### 场景3: User 用户无匹配关键词
|
||||
|
||||
**输入**:
|
||||
|
||||
- 当前用户: `user1`
|
||||
- 物料名称: `螺丝 M6`
|
||||
- MaterialsTypeToBeDeleted:
|
||||
- `{ materialName: "螺丝", managerName: "user2" }`
|
||||
|
||||
**预期输出**:
|
||||
|
||||
```json
|
||||
{
|
||||
"materialName": "螺丝 M6",
|
||||
"managerName": "user2",
|
||||
"matchedTypeKeyword": "螺丝",
|
||||
"isMarkedForDeletion": false
|
||||
}
|
||||
```
|
||||
|
||||
**说明**: 保持优先级2的匹配结果
|
||||
|
||||
### 场景4: Admin 用户不执行覆盖
|
||||
|
||||
**输入**:
|
||||
|
||||
- 当前用户: `admin` (isAdmin=true)
|
||||
- 物料名称: `螺丝 M6`
|
||||
- MaterialsTypeToBeDeleted:
|
||||
- `{ materialName: "螺丝", managerName: "user1" }`
|
||||
- `{ materialName: "螺丝", managerName: "admin" }`
|
||||
|
||||
**预期输出**:
|
||||
|
||||
```json
|
||||
{
|
||||
"materialName": "螺丝 M6",
|
||||
"managerName": "user1",
|
||||
"matchedTypeKeyword": "螺丝",
|
||||
"isMarkedForDeletion": false
|
||||
}
|
||||
```
|
||||
|
||||
**说明**: Admin 不执行优先级3,保持原有匹配行为
|
||||
|
||||
### 场景5: 优先级1匹配不受影响
|
||||
|
||||
**输入**:
|
||||
|
||||
- 当前用户: `user1`
|
||||
- 物料代码: `MAT001`
|
||||
- MaterialsToBeDeleted: `{ materialCode: "MAT001", managerName: "user2" }`
|
||||
|
||||
**预期输出**:
|
||||
|
||||
```json
|
||||
{
|
||||
"materialCode": "MAT001",
|
||||
"managerName": "user2",
|
||||
"isMarkedForDeletion": true,
|
||||
"matchedTypeKeyword": undefined
|
||||
}
|
||||
```
|
||||
|
||||
**说明**: 优先级1的精确匹配不受覆盖影响
|
||||
|
||||
---
|
||||
|
||||
## 数据库配置示例
|
||||
|
||||
### MaterialsTypeToBeDeleted 表数据
|
||||
|
||||
| MaterialName | ManagerName | 说明 |
|
||||
| ------------ | ----------- | ------------------------------ |
|
||||
| 螺丝 | user1 | user1 负责所有包含"螺丝"的物料 |
|
||||
| 螺母 | user2 | user2 负责所有包含"螺母"的物料 |
|
||||
| 垫圈 | user1 | user1 也负责"垫圈"类物料 |
|
||||
| 电缆 | admin | admin 负责电缆类物料 |
|
||||
|
||||
### 匹配结果示例
|
||||
|
||||
| 物料名称 | 当前用户 | 原匹配 (优先级2) | 覆盖后 (优先级3) |
|
||||
| -------- | -------- | ---------------- | ----------------- |
|
||||
| 螺丝 M6 | user1 | user2 | **user1** ✅ |
|
||||
| 螺母 M8 | user1 | user2 | user2 (无匹配) |
|
||||
| 垫圈 φ10 | user1 | user2 | **user1** ✅ |
|
||||
| 电缆 5m | user1 | admin | user1 (无匹配) |
|
||||
| 螺丝 M6 | admin | user2 | user2 (Admin跳过) |
|
||||
|
||||
---
|
||||
|
||||
## 与前端协同
|
||||
|
||||
前端过滤器逻辑 (`CleanerPage.tsx`) 保持不变:
|
||||
|
||||
```typescript
|
||||
const filteredResults = React.useMemo(() => {
|
||||
let results = validationResults
|
||||
if (!isAdmin && currentUsername) {
|
||||
// User 只看到自己的物料 + 未分配的物料
|
||||
results = results.filter((r) => r.managerName === currentUsername || !r.managerName)
|
||||
}
|
||||
return results
|
||||
}, [validationResults, isAdmin, currentUsername, managers, selectedManagers, hiddenItems])
|
||||
```
|
||||
|
||||
**协同效果**:
|
||||
|
||||
1. 后端匹配算法确保 User 用户的物料优先分配给自己
|
||||
2. 前端过滤器只显示属于当前用户或未分配的物料
|
||||
3. Admin 用户可以看到所有物料并切换查看不同负责人
|
||||
|
||||
---
|
||||
|
||||
## 代码审查检查点
|
||||
|
||||
- ✅ User 信息获取正确使用 `SessionManager`
|
||||
- ✅ 只对 `!isAdmin` 的用户执行覆盖逻辑
|
||||
- ✅ 使用相同的包含匹配规则 `materialName.includes(typeKeyword.materialName)`
|
||||
- ✅ 优先级1(精确匹配)不受覆盖影响
|
||||
- ✅ 无匹配时保持原有结果
|
||||
- ✅ 日志记录包含用户信息 `{ user: username, isAdmin }`
|
||||
- ✅ 未登录时返回明确的错误信息
|
||||
|
||||
---
|
||||
|
||||
## 潜在改进方向
|
||||
|
||||
1. **性能优化**: 如果 `typeKeywords` 数量很大,可以预先构建 `Map<username, typeKeyword[]>` 索引
|
||||
2. **日志增强**: 添加覆盖匹配的统计信息(覆盖了多少条记录)
|
||||
3. **配置开关**: 允许 Admin 用户通过配置启用/禁用覆盖功能
|
||||
4. **UI 反馈**: 在前端显示哪些物料是通过覆盖匹配分配的
|
||||
|
||||
---
|
||||
|
||||
## 相关文件
|
||||
|
||||
- **实现文件**: `src/main/ipc/validation-handler.ts` (Lines 218-239, 359-370)
|
||||
- **前端页面**: `src/renderer/src/pages/CleanerPage.tsx`
|
||||
- **会话管理**: `src/main/services/user/session-manager.ts`
|
||||
- **类型定义**: `src/main/types/validation.types.ts`
|
||||
|
||||
---
|
||||
|
||||
**文档结束**
|
||||
@@ -5,14 +5,31 @@ directories:
|
||||
files:
|
||||
- '!**/.vscode/*'
|
||||
- '!src/*'
|
||||
- '!electron.vite.config.{js,ts,mjs,cjs}'
|
||||
- '!electron-vite.config.{js,ts,mjs,cjs}'
|
||||
- '!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}'
|
||||
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
|
||||
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
|
||||
- 'package.json'
|
||||
# Include build output
|
||||
- 'out/**/*'
|
||||
# Include config.template.yaml in the build for reference
|
||||
- 'config.template.yaml'
|
||||
# Exclude Playwright browser downloads (manual install for company environment)
|
||||
- '!**/node_modules/playwright-core/.local-browsers/**'
|
||||
asarUnpack:
|
||||
- resources/**
|
||||
# Unpack playwright for native modules
|
||||
- '**/node_modules/playwright/**'
|
||||
- '**/node_modules/playwright-core/**'
|
||||
win:
|
||||
executableName: erpauto
|
||||
target:
|
||||
- nsis
|
||||
- portable
|
||||
portable:
|
||||
artifactName: ${name}-portable.${ext}
|
||||
# Portable app uses user data directory (AppData), not exe directory
|
||||
# This ensures config persists across app updates
|
||||
nsis:
|
||||
artifactName: ${name}-${version}-setup.${ext}
|
||||
shortcutName: ${productName}
|
||||
|
||||
@@ -1,16 +1,49 @@
|
||||
import { resolve } from 'path'
|
||||
import { defineConfig } from 'electron-vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { execSync } from 'child_process'
|
||||
import { createRequire } from 'module'
|
||||
|
||||
// Get git hash (first 7 characters)
|
||||
const getGitHash = (): string => {
|
||||
try {
|
||||
return execSync('git rev-parse --short=7 HEAD', { encoding: 'utf-8' }).trim()
|
||||
} catch {
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
// Get version from package.json
|
||||
const require = createRequire(import.meta.url)
|
||||
const version = require('./package.json').version
|
||||
const gitHash = getGitHash()
|
||||
|
||||
export default defineConfig({
|
||||
main: {},
|
||||
preload: {},
|
||||
renderer: {
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(version),
|
||||
__GIT_HASH__: JSON.stringify(gitHash)
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@renderer': resolve('src/renderer/src')
|
||||
}
|
||||
},
|
||||
plugins: [react()]
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
{
|
||||
name: 'update-title',
|
||||
transformIndexHtml(html) {
|
||||
return html.replace(
|
||||
'<title>ERP Auto Tool</title>',
|
||||
`<title>ERPAuto - v${version}(${gitHash})</title>`
|
||||
)
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
roots: ['<rootDir>/tests'],
|
||||
testMatch: ['**/*.test.ts'],
|
||||
collectCoverageFrom: ['src/**/*.ts', '!src/**/*.d.ts'],
|
||||
moduleFileExtensions: ['ts', 'js', 'json'],
|
||||
};
|
||||
8394
package-lock.json
generated
8394
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
62
package.json
62
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "erpauto",
|
||||
"version": "1.0.0",
|
||||
"version": "1.3.1",
|
||||
"description": "An Electron application with React and TypeScript",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "example.com",
|
||||
@@ -12,35 +12,63 @@
|
||||
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
|
||||
"typecheck": "npm run typecheck:node && npm run typecheck:web",
|
||||
"start": "electron-vite preview",
|
||||
"dev": "electron-vite dev",
|
||||
"build": "npm run typecheck && electron-vite build",
|
||||
"postinstall": "electron-builder install-app-deps && playwright install chromium",
|
||||
"build:unpack": "npm run build && electron-builder --dir",
|
||||
"build:win": "npm run build && electron-builder --win",
|
||||
"build:mac": "electron-vite build && electron-builder --mac",
|
||||
"build:linux": "electron-vite build && electron-builder --linux",
|
||||
"test": "jest",
|
||||
"test:e2e": "playwright test"
|
||||
"dev": "chcp 65001 && electron-vite dev",
|
||||
"build": "chcp 65001 && npm run typecheck && electron-vite build",
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"build:unpack": "chcp 65001 && set PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 && npm run build && electron-builder --dir",
|
||||
"build:win": "chcp 65001 && set PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 && npm run prebuild && npm run build && electron-builder --win",
|
||||
"build:mac": "chcp 65001 && npm run prebuild && electron-vite build && electron-builder --mac",
|
||||
"build:linux": "chcp 65001 && npm run prebuild && electron-vite build && electron-builder --linux",
|
||||
"prebuild": "node -e \"const fs=require('fs');['dist','out'].forEach(d=>{try{fs.rmSync(d,{recursive:true})}catch(e){}})\"",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:e2e:report": "playwright show-report",
|
||||
"debug:erp-login": "tsx src/main/tools/erp-login-debug.ts",
|
||||
"debug:config-path": "tsx src/main/tools/config-path-debug.ts",
|
||||
"test:rustfs": "tsx src/main/tools/rustfs-test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.929.0",
|
||||
"@electron-toolkit/preload": "^3.0.2",
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"chromium-bidi": "^15.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"exceljs": "^4.4.0",
|
||||
"js-yaml": "^4.1.1",
|
||||
"lucide-react": "^0.575.0",
|
||||
"mssql": "^12.2.0",
|
||||
"mysql2": "^3.18.2",
|
||||
"playwright": "^1.58.2",
|
||||
"winston": "^3.19.0"
|
||||
"playwright-core": "^1.58.2",
|
||||
"react-focus-lock": "^2.13.7",
|
||||
"react-markdown": "^10.1.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"typeorm": "^0.3.28",
|
||||
"uuid": "^13.0.0",
|
||||
"winston": "^3.19.0",
|
||||
"winston-daily-rotate-file": "^5.0.0",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
|
||||
"@electron-toolkit/eslint-config-ts": "^3.1.0",
|
||||
"@electron-toolkit/tsconfig": "^2.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@types/mssql": "^9.1.9",
|
||||
"@types/node": "^22.19.1",
|
||||
"@types/node": "^22.19.13",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"electron": "^39.2.6",
|
||||
"electron-builder": "^26.0.12",
|
||||
"electron-vite": "^5.0.0",
|
||||
@@ -48,12 +76,14 @@
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"jest": "^30.2.0",
|
||||
"postcss": "^8.5.6",
|
||||
"prettier": "^3.7.4",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"ts-jest": "^29.4.6",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.2.6"
|
||||
"vite": "^7.2.6",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
|
||||
23
playwright.config.ts
Normal file
23
playwright.config.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { defineConfig } from '@playwright/test'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
timeout: 120000,
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: 1,
|
||||
reporter: 'html',
|
||||
use: {
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure'
|
||||
},
|
||||
|
||||
// Test configuration for Electron
|
||||
projects: [
|
||||
{
|
||||
name: 'electron',
|
||||
testMatch: '**/*.test.ts'
|
||||
}
|
||||
]
|
||||
})
|
||||
115
scripts/fix-auto-increment.js
Normal file
115
scripts/fix-auto-increment.js
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Fix MaterialsTypeToBeDeleted Table - Add AUTO_INCREMENT to ID
|
||||
*
|
||||
* This script modifies the ID column to be AUTO_INCREMENT while preserving data
|
||||
*/
|
||||
|
||||
const mysql = require('mysql2/promise')
|
||||
|
||||
async function main() {
|
||||
const config = {
|
||||
host: '192.168.31.83',
|
||||
port: 3306,
|
||||
user: 'remote_user',
|
||||
password: '3.1415926Beeke',
|
||||
database: 'BLD_DB'
|
||||
}
|
||||
|
||||
let connection
|
||||
|
||||
try {
|
||||
console.log('Connecting to MySQL...')
|
||||
connection = await mysql.createConnection(config)
|
||||
console.log('Connected successfully!\n')
|
||||
|
||||
// Step 1: Check current table structure
|
||||
console.log('=== Step 1: Current table structure ===')
|
||||
const [columns] = await connection.execute(`
|
||||
SELECT
|
||||
COLUMN_NAME,
|
||||
COLUMN_TYPE,
|
||||
IS_NULLABLE,
|
||||
COLUMN_KEY,
|
||||
COLUMN_DEFAULT,
|
||||
EXTRA
|
||||
FROM
|
||||
INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE
|
||||
TABLE_NAME = 'dbo_MaterialsTypeToBeDeleted'
|
||||
AND TABLE_SCHEMA = DATABASE()
|
||||
ORDER BY
|
||||
ORDINAL_POSITION
|
||||
`)
|
||||
console.table(columns)
|
||||
|
||||
// Step 2: Count records before modification
|
||||
console.log('\n=== Step 2: Record count before modification ===')
|
||||
const [countBefore] = await connection.execute(
|
||||
'SELECT COUNT(*) AS total FROM dbo_MaterialsTypeToBeDeleted'
|
||||
)
|
||||
console.log(`Total records: ${countBefore[0].total}`)
|
||||
|
||||
// Step 3: Show sample data
|
||||
console.log('\n=== Step 3: Sample data ===')
|
||||
const [sample] = await connection.execute('SELECT * FROM dbo_MaterialsTypeToBeDeleted LIMIT 5')
|
||||
console.table(sample)
|
||||
|
||||
// Step 4: Check if ID is already AUTO_INCREMENT
|
||||
const idColumn = columns.find((col) => col.COLUMN_NAME === 'ID')
|
||||
if (idColumn && idColumn.EXTRA.includes('auto_increment')) {
|
||||
console.log('\n=== ID is already AUTO_INCREMENT! No modification needed. ===')
|
||||
return
|
||||
}
|
||||
|
||||
// Step 5: Modify the ID column
|
||||
console.log('\n=== Step 4: Modifying ID column to AUTO_INCREMENT ===')
|
||||
await connection.execute(`
|
||||
ALTER TABLE dbo_MaterialsTypeToBeDeleted
|
||||
MODIFY COLUMN ID INT NOT NULL AUTO_INCREMENT
|
||||
`)
|
||||
console.log('Modification completed successfully!\n')
|
||||
|
||||
// Step 6: Verify the change
|
||||
console.log('=== Step 5: Verify modification ===')
|
||||
const [columnsAfter] = await connection.execute(`
|
||||
SELECT
|
||||
COLUMN_NAME,
|
||||
COLUMN_TYPE,
|
||||
IS_NULLABLE,
|
||||
COLUMN_KEY,
|
||||
EXTRA
|
||||
FROM
|
||||
INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE
|
||||
TABLE_NAME = 'dbo_MaterialsTypeToBeDeleted'
|
||||
AND TABLE_SCHEMA = DATABASE()
|
||||
AND COLUMN_NAME = 'ID'
|
||||
`)
|
||||
console.table(columnsAfter)
|
||||
|
||||
// Step 7: Verify data is still intact
|
||||
console.log('\n=== Step 6: Verify data integrity ===')
|
||||
const [countAfter] = await connection.execute(
|
||||
'SELECT COUNT(*) AS total FROM dbo_MaterialsTypeToBeDeleted'
|
||||
)
|
||||
console.log(`Total records after modification: ${countAfter[0].total}`)
|
||||
|
||||
if (countBefore[0].total === countAfter[0].total) {
|
||||
console.log('\n✅ SUCCESS: All data preserved, AUTO_INCREMENT added to ID column!')
|
||||
} else {
|
||||
console.log('\n⚠️ WARNING: Record count changed! Please check data.')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('\n❌ Error:', error.message)
|
||||
if (error.code) {
|
||||
console.error('Error code:', error.code)
|
||||
}
|
||||
} finally {
|
||||
if (connection) {
|
||||
await connection.end()
|
||||
console.log('\nConnection closed.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
142
scripts/fix-computer-name-typo.js
Normal file
142
scripts/fix-computer-name-typo.js
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Fix ComputerNmae Typo in dbo_BIPUsers Table
|
||||
*
|
||||
* This script renames the column from 'ComputerNmae' to 'ComputerName'
|
||||
*/
|
||||
|
||||
const mysql = require('mysql2/promise')
|
||||
|
||||
async function main() {
|
||||
const config = {
|
||||
host: '192.168.31.83',
|
||||
port: 3306,
|
||||
user: 'remote_user',
|
||||
password: '3.1415926Beeke',
|
||||
database: 'BLD_DB'
|
||||
}
|
||||
|
||||
let connection
|
||||
|
||||
try {
|
||||
console.log('Connecting to MySQL...')
|
||||
connection = await mysql.createConnection(config)
|
||||
console.log('Connected successfully!\n')
|
||||
|
||||
// Step 1: Check current column name
|
||||
console.log('=== Step 1: Check current column name ===')
|
||||
const [columns] = await connection.execute(`
|
||||
SELECT
|
||||
COLUMN_NAME,
|
||||
COLUMN_TYPE,
|
||||
IS_NULLABLE,
|
||||
CHARACTER_MAXIMUM_LENGTH,
|
||||
COLUMN_KEY,
|
||||
COLUMN_DEFAULT,
|
||||
EXTRA
|
||||
FROM
|
||||
INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE
|
||||
TABLE_NAME = 'dbo_BIPUsers'
|
||||
AND TABLE_SCHEMA = DATABASE()
|
||||
AND (COLUMN_NAME = 'ComputerNmae' OR COLUMN_NAME = 'ComputerName')
|
||||
ORDER BY
|
||||
ORDINAL_POSITION
|
||||
`)
|
||||
|
||||
if (columns.length === 0) {
|
||||
console.log('No ComputerNmae or ComputerName column found!')
|
||||
return
|
||||
}
|
||||
|
||||
console.table(columns)
|
||||
|
||||
const currentColumn = columns.find((col) => col.COLUMN_NAME === 'ComputerNmae')
|
||||
const newColumn = columns.find((col) => col.COLUMN_NAME === 'ComputerName')
|
||||
|
||||
if (newColumn) {
|
||||
console.log('\n=== Column is already named "ComputerName"! No modification needed. ===')
|
||||
return
|
||||
}
|
||||
|
||||
if (!currentColumn) {
|
||||
console.log('\n=== ERROR: ComputerNmae column not found! ===')
|
||||
return
|
||||
}
|
||||
|
||||
// Step 2: Count records before modification
|
||||
console.log('\n=== Step 2: Record count before modification ===')
|
||||
const [countBefore] = await connection.execute('SELECT COUNT(*) AS total FROM dbo_BIPUsers')
|
||||
console.log(`Total records: ${countBefore[0].total}`)
|
||||
|
||||
// Step 3: Show sample data with the column
|
||||
console.log('\n=== Step 3: Sample data (showing ComputerNmae column) ===')
|
||||
const [sample] = await connection.execute(`
|
||||
SELECT ID, UserName, UserType, ComputerNmae, CreateTime
|
||||
FROM dbo_BIPUsers
|
||||
LIMIT 5
|
||||
`)
|
||||
console.table(sample)
|
||||
|
||||
// Step 4: Rename the column
|
||||
console.log('\n=== Step 4: Renaming column ComputerNmae -> ComputerName ===')
|
||||
await connection.execute(`
|
||||
ALTER TABLE dbo_BIPUsers
|
||||
CHANGE COLUMN ComputerNmae ComputerName VARCHAR(255) NULL
|
||||
`)
|
||||
console.log('Column renamed successfully!\n')
|
||||
|
||||
// Step 5: Verify the change
|
||||
console.log('=== Step 5: Verify modification ===')
|
||||
const [columnsAfter] = await connection.execute(`
|
||||
SELECT
|
||||
COLUMN_NAME,
|
||||
COLUMN_TYPE,
|
||||
IS_NULLABLE,
|
||||
CHARACTER_MAXIMUM_LENGTH,
|
||||
COLUMN_KEY,
|
||||
COLUMN_DEFAULT,
|
||||
EXTRA
|
||||
FROM
|
||||
INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE
|
||||
TABLE_NAME = 'dbo_BIPUsers'
|
||||
AND TABLE_SCHEMA = DATABASE()
|
||||
AND COLUMN_NAME = 'ComputerName'
|
||||
`)
|
||||
console.table(columnsAfter)
|
||||
|
||||
// Step 6: Verify data is still intact
|
||||
console.log('\n=== Step 6: Verify data integrity ===')
|
||||
const [countAfter] = await connection.execute('SELECT COUNT(*) AS total FROM dbo_BIPUsers')
|
||||
console.log(`Total records after modification: ${countAfter[0].total}`)
|
||||
|
||||
// Step 7: Show sample data with new column name
|
||||
console.log('\n=== Step 7: Sample data (showing ComputerName column) ===')
|
||||
const [sampleAfter] = await connection.execute(`
|
||||
SELECT ID, UserName, UserType, ComputerName, CreateTime
|
||||
FROM dbo_BIPUsers
|
||||
LIMIT 5
|
||||
`)
|
||||
console.table(sampleAfter)
|
||||
|
||||
if (countBefore[0].total === countAfter[0].total) {
|
||||
console.log(
|
||||
'\n✅ SUCCESS: All data preserved, column renamed from ComputerNmae to ComputerName!'
|
||||
)
|
||||
} else {
|
||||
console.log('\n⚠️ WARNING: Record count changed! Please check data.')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('\n❌ Error:', error.message)
|
||||
if (error.code) {
|
||||
console.error('Error code:', error.code)
|
||||
}
|
||||
} finally {
|
||||
if (connection) {
|
||||
await connection.end()
|
||||
console.log('\nConnection closed.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
13
scripts/fix-computer-name-typo.sql
Normal file
13
scripts/fix-computer-name-typo.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
-- Migration script to fix ComputerNmae typo in dbo_BIPUsers table
|
||||
-- Changes column name from 'ComputerNmae' to 'ComputerName'
|
||||
-- Date: 2026-03-05
|
||||
|
||||
-- Rename the column (MySQL syntax)
|
||||
ALTER TABLE dbo_BIPUsers
|
||||
CHANGE COLUMN ComputerNmae ComputerName VARCHAR(255) NULL;
|
||||
|
||||
-- Verify the change
|
||||
SELECT COLUMN_NAME, DATA_TYPE, CHARACTER_MAXIMUM_LENGTH, IS_NULLABLE
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_NAME = 'dbo_BIPUsers'
|
||||
AND COLUMN_NAME = 'ComputerName';
|
||||
82
scripts/fix-materials-type-auto-increment.sql
Normal file
82
scripts/fix-materials-type-auto-increment.sql
Normal file
@@ -0,0 +1,82 @@
|
||||
-- ============================================================================
|
||||
-- Script: Fix MaterialsTypeToBeDeleted Table - Add AUTO_INCREMENT to ID
|
||||
-- Description: Modify the ID column to be AUTO_INCREMENT while preserving data
|
||||
-- Database: MySQL
|
||||
-- ============================================================================
|
||||
|
||||
-- Step 1: Check current table structure
|
||||
SELECT
|
||||
COLUMN_NAME,
|
||||
COLUMN_TYPE,
|
||||
IS_NULLABLE,
|
||||
COLUMN_KEY,
|
||||
COLUMN_DEFAULT,
|
||||
EXTRA
|
||||
FROM
|
||||
INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE
|
||||
TABLE_NAME = 'dbo_MaterialsTypeToBeDeleted'
|
||||
AND TABLE_SCHEMA = DATABASE()
|
||||
ORDER BY
|
||||
ORDINAL_POSITION;
|
||||
|
||||
-- Step 2: View current data before modification
|
||||
SELECT COUNT(*) AS total_records FROM dbo_MaterialsTypeToBeDeleted;
|
||||
SELECT * FROM dbo_MaterialsTypeToBeDeleted LIMIT 10;
|
||||
|
||||
-- Step 3: Check if ID is already AUTO_INCREMENT
|
||||
SELECT
|
||||
COLUMN_NAME,
|
||||
EXTRA
|
||||
FROM
|
||||
INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE
|
||||
TABLE_NAME = 'dbo_MaterialsTypeToBeDeleted'
|
||||
AND TABLE_SCHEMA = DATABASE()
|
||||
AND COLUMN_NAME = 'ID';
|
||||
|
||||
-- ============================================================================
|
||||
-- Step 4: Modify the ID column to AUTO_INCREMENT
|
||||
-- Note: This assumes ID is already the PRIMARY KEY
|
||||
-- If not, you may need to add PRIMARY KEY constraint first
|
||||
-- ============================================================================
|
||||
|
||||
-- Option A: If ID is already PRIMARY KEY (most likely case)
|
||||
ALTER TABLE dbo_MaterialsTypeToBeDeleted
|
||||
MODIFY COLUMN ID INT NOT NULL AUTO_INCREMENT;
|
||||
|
||||
-- Option B: If ID is NOT PRIMARY KEY (uncomment if needed)
|
||||
-- First check if there's an existing primary key
|
||||
-- SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
|
||||
-- WHERE TABLE_NAME = 'dbo_MaterialsTypeToBeDeleted'
|
||||
-- AND TABLE_SCHEMA = DATABASE() AND COLUMN_KEY = 'PRI';
|
||||
--
|
||||
-- If no primary key exists:
|
||||
-- ALTER TABLE dbo_MaterialsTypeToBeDeleted
|
||||
-- MODIFY COLUMN ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY;
|
||||
|
||||
-- Step 5: Verify the change
|
||||
SELECT
|
||||
COLUMN_NAME,
|
||||
COLUMN_TYPE,
|
||||
IS_NULLABLE,
|
||||
COLUMN_KEY,
|
||||
EXTRA
|
||||
FROM
|
||||
INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE
|
||||
TABLE_NAME = 'dbo_MaterialsTypeToBeDeleted'
|
||||
AND TABLE_SCHEMA = DATABASE()
|
||||
AND COLUMN_NAME = 'ID';
|
||||
|
||||
-- Step 6: Verify data is still intact
|
||||
SELECT COUNT(*) AS total_records_after FROM dbo_MaterialsTypeToBeDeleted;
|
||||
|
||||
-- ============================================================================
|
||||
-- Expected Results:
|
||||
-- After running this script, the ID column should show:
|
||||
-- EXTRA: 'auto_increment'
|
||||
--
|
||||
-- This will allow INSERT statements to omit the ID field, and MySQL will
|
||||
-- automatically generate the next sequential ID value.
|
||||
-- ============================================================================
|
||||
125
scripts/migrate-env-to-yaml.ts
Normal file
125
scripts/migrate-env-to-yaml.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Migration Script: .env to config.yaml
|
||||
*
|
||||
* Usage: npx tsx scripts/migrate-env-to-yaml.ts
|
||||
*
|
||||
* This script migrates the old .env configuration to the new YAML format.
|
||||
* ERP configuration is NOT migrated as it's now stored in the database per user.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import yaml from 'js-yaml'
|
||||
|
||||
const ENV_PATH = path.resolve(process.cwd(), '.env')
|
||||
const YAML_PATH = path.resolve(process.cwd(), 'config.yaml')
|
||||
const BACKUP_PATH = path.resolve(process.cwd(), '.env.backup')
|
||||
|
||||
interface EnvConfig {
|
||||
[key: string]: string
|
||||
}
|
||||
|
||||
function parseEnvFile(content: string): EnvConfig {
|
||||
const result: EnvConfig = {}
|
||||
const lines = content.split('\n')
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed || trimmed.startsWith('#')) continue
|
||||
|
||||
const [key, ...valueParts] = trimmed.split('=')
|
||||
if (key && valueParts.length > 0) {
|
||||
result[key.trim()] = valueParts.join('=').trim()
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function migrate() {
|
||||
console.log('🔄 Starting migration from .env to config.yaml...\n')
|
||||
|
||||
if (!fs.existsSync(ENV_PATH)) {
|
||||
console.error('❌ .env file not found at:', ENV_PATH)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const envContent = fs.readFileSync(ENV_PATH, 'utf-8')
|
||||
const env = parseEnvFile(envContent)
|
||||
|
||||
// Build configuration object (without ERP)
|
||||
const config = {
|
||||
database: {
|
||||
activeType: (env.DB_TYPE || 'mysql').toLowerCase() as 'mysql' | 'sqlserver',
|
||||
mysql: {
|
||||
host: env.DB_MYSQL_HOST || 'localhost',
|
||||
port: parseInt(env.DB_MYSQL_PORT || '3306', 10),
|
||||
database: env.DB_NAME || '',
|
||||
username: env.DB_USERNAME || '',
|
||||
password: env.DB_PASSWORD || '',
|
||||
charset: env.DB_MYSQL_CHARSET || 'utf8mb4'
|
||||
},
|
||||
sqlserver: {
|
||||
server: env.DB_SERVER || 'localhost',
|
||||
port: parseInt(env.DB_SQLSERVER_PORT || '1433', 10),
|
||||
database: env.DB_NAME || '',
|
||||
username: env.DB_USERNAME || '',
|
||||
password: env.DB_PASSWORD || '',
|
||||
driver: env.DB_SQLSERVER_DRIVER || 'ODBC Driver 18 for SQL Server',
|
||||
trustServerCertificate: env.DB_TRUST_SERVER_CERTIFICATE === 'yes'
|
||||
}
|
||||
},
|
||||
paths: {
|
||||
dataDir: env.PATH_DATA_DIR || './data/',
|
||||
defaultOutput: env.PATH_DEFAULT_OUTPUT || 'output.xlsx',
|
||||
validationOutput: env.PATH_VALIDATION_OUTPUT || 'validation-result.xlsx'
|
||||
},
|
||||
extraction: {
|
||||
batchSize: parseInt(env.EXTRACTION_BATCH_SIZE || '100', 10),
|
||||
verbose: env.EXTRACTION_VERBOSE !== 'false',
|
||||
autoConvert: env.EXTRACTION_AUTO_CONVERT !== 'false',
|
||||
mergeBatches: env.EXTRACTION_MERGE_BATCHES !== 'false',
|
||||
enableDbPersistence: env.EXTRACTION_ENABLE_DB_PERSISTENCE !== 'false'
|
||||
},
|
||||
validation: {
|
||||
dataSource: env.VALIDATION_DATA_SOURCE || 'database_full',
|
||||
batchSize: parseInt(env.VALIDATION_BATCH_SIZE || '2000', 10),
|
||||
matchMode: env.VALIDATION_MATCH_MODE || 'substring',
|
||||
enableCrud: env.VALIDATION_ENABLE_CRUD === 'true',
|
||||
defaultManager: env.VALIDATION_DEFAULT_MANAGER || ''
|
||||
},
|
||||
orderResolution: {
|
||||
tableName: env.DB_TABLE_NAME || '',
|
||||
productionIdField: env.DB_FIELD_PRODUCTION_ID || '',
|
||||
orderNumberField: env.DB_FIELD_ORDER_NUMBER || ''
|
||||
}
|
||||
}
|
||||
|
||||
// Backup .env
|
||||
if (fs.existsSync(ENV_PATH)) {
|
||||
fs.copyFileSync(ENV_PATH, BACKUP_PATH)
|
||||
console.log('📁 Backed up .env to .env.backup')
|
||||
}
|
||||
|
||||
// Write YAML with header comments
|
||||
const header = `# ================================\n# ERPAuto 配置文件\n# ================================\n# 由 .env 迁移生成\n# 迁移时间:${new Date().toISOString()}\n# 注意:ERP 配置已迁移到数据库 (dbo_BIPUsers 表)\n# ================================\n\n`
|
||||
|
||||
const yamlContent = yaml.dump(config, {
|
||||
indent: 2,
|
||||
lineWidth: -1,
|
||||
noRefs: true,
|
||||
quotingType: '"',
|
||||
forceQuotes: false
|
||||
})
|
||||
|
||||
fs.writeFileSync(YAML_PATH, header + yamlContent, 'utf-8')
|
||||
|
||||
console.log('✅ Migration completed successfully!')
|
||||
console.log(`📁 Config saved to: ${YAML_PATH}`)
|
||||
console.log('\n📋 Next steps:')
|
||||
console.log(' 1. Review config.yaml and verify all values')
|
||||
console.log(' 2. Test the application thoroughly')
|
||||
console.log(' 3. Remove .env file when confident (optional)\n')
|
||||
}
|
||||
|
||||
migrate()
|
||||
@@ -1,158 +0,0 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { AppConfig } from '../models/config.types';
|
||||
|
||||
export class ConfigManager {
|
||||
private static instance: AppConfig | null = null;
|
||||
|
||||
static load(): AppConfig {
|
||||
if (this.instance) {
|
||||
return this.instance;
|
||||
}
|
||||
|
||||
try {
|
||||
// Load default config (minimal, no sensitive data)
|
||||
const defaultConfig = this.getDefaultConfig();
|
||||
|
||||
// Load environment-specific config
|
||||
const env = process.env.NODE_ENV || 'development';
|
||||
const envConfigPath = path.join(__dirname, `../../../config/${env}.json`);
|
||||
|
||||
let envConfig: Partial<AppConfig> = {};
|
||||
if (fs.existsSync(envConfigPath)) {
|
||||
try {
|
||||
const envConfigContent = fs.readFileSync(envConfigPath, 'utf-8');
|
||||
envConfig = JSON.parse(envConfigContent);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to load env config from ${envConfigPath}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Load environment variables (with validation)
|
||||
const envVars = this.loadFromEnv();
|
||||
|
||||
// Deep merge configurations
|
||||
this.instance = this.deepMerge(defaultConfig, envConfig, envVars);
|
||||
|
||||
// Validate required configuration
|
||||
this.validateConfig(this.instance);
|
||||
|
||||
return this.instance;
|
||||
} catch (error) {
|
||||
console.error('Failed to load configuration:', error);
|
||||
throw new Error('Configuration loading failed');
|
||||
}
|
||||
}
|
||||
|
||||
private static getDefaultConfig(): AppConfig {
|
||||
const appConfigPath = path.join(__dirname, '../../../config/app.json');
|
||||
|
||||
try {
|
||||
if (fs.existsSync(appConfigPath)) {
|
||||
const configContent = fs.readFileSync(appConfigPath, 'utf-8');
|
||||
return JSON.parse(configContent);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Failed to load app.json, using minimal defaults:`, error);
|
||||
}
|
||||
|
||||
// Minimal fallback defaults (NO sensitive data)
|
||||
return {
|
||||
appName: 'ERPAuto',
|
||||
version: '1.0.0',
|
||||
logLevel: 'info',
|
||||
browser: {
|
||||
headless: false,
|
||||
slowMo: 50,
|
||||
timeout: 30000,
|
||||
},
|
||||
databases: {
|
||||
sqlServer: {
|
||||
server: '', // MUST be set via env var
|
||||
database: '',
|
||||
username: '',
|
||||
password: '',
|
||||
driver: 'ODBC Driver 18 for SQL Server',
|
||||
trustServerCertificate: 'yes',
|
||||
},
|
||||
mysql: {
|
||||
host: 'localhost',
|
||||
port: 3306,
|
||||
database: '',
|
||||
username: '',
|
||||
password: '',
|
||||
},
|
||||
},
|
||||
erp: {
|
||||
baseUrl: '', // MUST be set via env var
|
||||
ignoreHttpsErrors: false,
|
||||
},
|
||||
paths: {
|
||||
tempDir: './data/temp',
|
||||
outputDir: './data/output',
|
||||
reportDir: './data/reports',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static loadFromEnv(): Partial<AppConfig> {
|
||||
const sqlServer = {
|
||||
server: process.env.SQL_SERVER_SERVER || '',
|
||||
database: process.env.SQL_SERVER_DATABASE || '',
|
||||
username: process.env.SQL_SERVER_USERNAME || '',
|
||||
password: process.env.SQL_SERVER_PASSWORD || '',
|
||||
driver: process.env.SQL_SERVER_DRIVER || 'ODBC Driver 18 for SQL Server',
|
||||
trustServerCertificate: process.env.SQL_SERVER_TRUST_CERT || 'yes',
|
||||
};
|
||||
|
||||
const mysql = {
|
||||
host: process.env.MYSQL_HOST || 'localhost',
|
||||
port: parseInt(process.env.MYSQL_PORT || '3306'),
|
||||
database: process.env.MYSQL_DATABASE || '',
|
||||
username: process.env.MYSQL_USERNAME || '',
|
||||
password: process.env.MYSQL_PASSWORD || '',
|
||||
};
|
||||
|
||||
return {
|
||||
databases: {
|
||||
sqlServer,
|
||||
mysql,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static deepMerge(...configs: Partial<AppConfig>[]): AppConfig {
|
||||
const result = configs[0] as AppConfig;
|
||||
|
||||
for (let i = 1; i < configs.length; i++) {
|
||||
const config = configs[i];
|
||||
for (const key in config) {
|
||||
if (Object.prototype.hasOwnProperty.call(config, key)) {
|
||||
const value = (config as any)[key];
|
||||
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
||||
(result as any)[key] = { ...(result as any)[key], ...value };
|
||||
} else {
|
||||
(result as any)[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static validateConfig(config: AppConfig): void {
|
||||
// Validate critical configuration
|
||||
if (!config.databases.sqlServer.server && process.env.NODE_ENV === 'production') {
|
||||
throw new Error('SQL Server server address must be configured via environment variable');
|
||||
}
|
||||
|
||||
if (!config.databases.sqlServer.database) {
|
||||
throw new Error('SQL Server database name must be configured');
|
||||
}
|
||||
|
||||
if (!config.erp.baseUrl) {
|
||||
console.warn('Warning: ERP base URL not configured, ERP features will not work');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { DatabaseService } from '../services/database.service';
|
||||
import { LoggerService } from '../services/logger.service';
|
||||
|
||||
export class ProductionOrderDAO {
|
||||
constructor(private dbService: DatabaseService) {}
|
||||
|
||||
async queryProductionOrderNumbers(productionIds: string[]): Promise<string[]> {
|
||||
try {
|
||||
LoggerService.info(`Querying production orders for ${productionIds.length} IDs`);
|
||||
|
||||
const idsString = productionIds.map((id) => `'${id}'`).join(',');
|
||||
const query = `
|
||||
SELECT DISTINCT production_order_no
|
||||
FROM production_orders
|
||||
WHERE production_id IN (${idsString})
|
||||
`;
|
||||
|
||||
const result = await this.dbService.executeSQLServerQuery(query);
|
||||
const orderNumbers = result.rows.map((row: any) => row.production_order_no);
|
||||
|
||||
LoggerService.info(`Found ${orderNumbers.length} production orders`);
|
||||
return orderNumbers;
|
||||
} catch (error) {
|
||||
LoggerService.error('Failed to query production orders', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async readProductionIds(filePath: string): Promise<string[]> {
|
||||
try {
|
||||
const fs = await import('fs/promises');
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const ids = content
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0);
|
||||
|
||||
LoggerService.info(`Read ${ids.length} production IDs from file`);
|
||||
return ids;
|
||||
} catch (error) {
|
||||
LoggerService.error('Failed to read production IDs file', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,32 @@
|
||||
import { app, shell, BrowserWindow, ipcMain } from 'electron'
|
||||
import { app, shell, BrowserWindow, ipcMain, dialog } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||
import icon from '../../resources/icon.png?asset'
|
||||
import { registerIpcHandlers } from './ipc'
|
||||
import { ConfigManager } from './services/config/config-manager'
|
||||
import logger from './services/logger/index'
|
||||
import { logAudit } from './services/logger/audit-logger'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { dirname } from 'path'
|
||||
import fs from 'fs'
|
||||
|
||||
// Set Playwright browsers path BEFORE any playwright import
|
||||
process.env.PLAYWRIGHT_BROWSERS_PATH = join(app.getPath('userData'), 'ms-playwright')
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = dirname(__filename)
|
||||
|
||||
function createWindow(): void {
|
||||
// Create the browser window.
|
||||
const mainWindow = new BrowserWindow({
|
||||
width: 900,
|
||||
width: 1200,
|
||||
height: 670,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
...(process.platform === 'linux' ? { icon } : {}),
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
sandbox: false
|
||||
sandbox: true
|
||||
}
|
||||
})
|
||||
|
||||
@@ -38,7 +51,70 @@ function createWindow(): void {
|
||||
// This method will be called when Electron has finished
|
||||
// initialization and is ready to create browser windows.
|
||||
// Some APIs can only be used after this event occurs.
|
||||
app.whenReady().then(() => {
|
||||
app.whenReady().then(async () => {
|
||||
// Validate Playwright browser path
|
||||
const browsersPath = process.env.PLAYWRIGHT_BROWSERS_PATH!
|
||||
|
||||
// Create directory if it doesn't exist
|
||||
try {
|
||||
fs.mkdirSync(browsersPath, { recursive: true })
|
||||
} catch (error) {
|
||||
console.error('Failed to create browsers directory:', error)
|
||||
}
|
||||
|
||||
// Check if chromium browser exists (supports both old and new Playwright directory structures)
|
||||
// New format (v1.48+): chromium-1208/chrome-win64/chrome.exe
|
||||
// Old format: chromium-win32/chrome.exe
|
||||
const newChromiumPath = join(browsersPath, 'chromium-1208', 'chrome-win64', 'chrome.exe')
|
||||
const oldChromiumPath = join(browsersPath, 'chromium-win32', 'chrome.exe')
|
||||
const chromiumPath = fs.existsSync(newChromiumPath) ? newChromiumPath : oldChromiumPath
|
||||
|
||||
if (!fs.existsSync(chromiumPath)) {
|
||||
// Try to find any chromium revision
|
||||
let foundRevision = false
|
||||
try {
|
||||
const entries = fs.readdirSync(browsersPath)
|
||||
for (const entry of entries) {
|
||||
if (entry.startsWith('chromium-') && !entry.includes('headless')) {
|
||||
const revisionPath = join(browsersPath, entry, 'chrome-win64', 'chrome.exe')
|
||||
if (fs.existsSync(revisionPath)) {
|
||||
console.log('Found Chromium revision:', entry)
|
||||
foundRevision = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
if (!foundRevision) {
|
||||
dialog.showErrorBox(
|
||||
'浏览器文件未找到',
|
||||
`Playwright 浏览器文件不存在。\n\n` +
|
||||
`期望路径:${newChromiumPath}\n` +
|
||||
`或:${oldChromiumPath}\n\n` +
|
||||
`当前目录内容:${fs.existsSync(browsersPath) ? fs.readdirSync(browsersPath).join(', ') : '目录不存在'}\n\n` +
|
||||
`请运行以下命令安装浏览器:\n` +
|
||||
`npx playwright install chromium`
|
||||
)
|
||||
console.warn(
|
||||
'Playwright browser not found. Available:',
|
||||
fs.existsSync(browsersPath) ? fs.readdirSync(browsersPath) : 'none'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize ConfigManager BEFORE registering IPC handlers
|
||||
// This ensures config is loaded before any service tries to use it
|
||||
try {
|
||||
const configManager = ConfigManager.getInstance()
|
||||
await configManager.initialize()
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize ConfigManager:', error)
|
||||
// Continue anyway - default config will be created
|
||||
}
|
||||
|
||||
// Set app user model id for windows
|
||||
electronApp.setAppUserModelId('com.electron')
|
||||
|
||||
@@ -49,15 +125,12 @@ app.whenReady().then(() => {
|
||||
optimizer.watchWindowShortcuts(window)
|
||||
})
|
||||
|
||||
// Register IPC handlers (after ConfigManager is initialized)
|
||||
registerIpcHandlers()
|
||||
|
||||
// IPC test
|
||||
ipcMain.on('ping', () => console.log('pong'))
|
||||
|
||||
// IPC Handler Registration
|
||||
// TODO: Register IPC handlers for:
|
||||
// - automation:clean, automation:extract, automation:stop, automation:progress
|
||||
// - database:query-production-orders, database:query-materials-to-delete
|
||||
// - auth:login, auth:logout
|
||||
|
||||
createWindow()
|
||||
|
||||
app.on('activate', function () {
|
||||
@@ -76,5 +149,41 @@ app.on('window-all-closed', () => {
|
||||
}
|
||||
})
|
||||
|
||||
// Global exception handlers to prevent crashes without logging
|
||||
process.on('uncaughtException', async (err) => {
|
||||
logger.error('Uncaught exception', { error: err })
|
||||
await logAudit('SYSTEM_CRASH', 'system', {
|
||||
username: 'system',
|
||||
computerName: process.env.COMPUTERNAME || 'unknown',
|
||||
resource: 'main-process',
|
||||
status: 'failure',
|
||||
metadata: { error: err.message, stack: err.stack }
|
||||
})
|
||||
console.error('Uncaught exception:', err)
|
||||
setTimeout(() => process.exit(1), 1000)
|
||||
})
|
||||
|
||||
process.on('unhandledRejection', async (reason, promise) => {
|
||||
logger.error('Unhandled Rejection', { reason: String(reason) })
|
||||
await logAudit('SYSTEM_ERROR', 'system', {
|
||||
username: 'system',
|
||||
computerName: process.env.COMPUTERNAME || 'unknown',
|
||||
resource: 'main-process',
|
||||
status: 'failure',
|
||||
metadata: { reason: String(reason) }
|
||||
})
|
||||
console.error('Unhandled Rejection:', reason)
|
||||
})
|
||||
|
||||
app.on('render-process-gone', (_, webContents, details) => {
|
||||
logger.error('Render process gone', { details, webContentsId: webContents.id })
|
||||
console.error('Render process gone:', details)
|
||||
})
|
||||
|
||||
app.on('child-process-gone', (_, details) => {
|
||||
logger.error('Child process gone', { details })
|
||||
console.error('Child process gone:', details)
|
||||
})
|
||||
|
||||
// In this file you can include the rest of your app's specific main process
|
||||
// code. You can also put them in separate files and require them here.
|
||||
|
||||
262
src/main/ipc/auth-handler.ts
Normal file
262
src/main/ipc/auth-handler.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* IPC handlers for User Authentication
|
||||
*
|
||||
* Provides APIs for the renderer process to:
|
||||
* - Login with username and password
|
||||
* - Silent login by computer name
|
||||
* - Logout
|
||||
* - Get current user info
|
||||
* - Get all users (for admin user selection)
|
||||
* - Switch user (admin only)
|
||||
*/
|
||||
|
||||
import { ipcMain } from 'electron'
|
||||
import { SessionManager } from '../services/user/session-manager'
|
||||
import { createLogger } from '../services/logger'
|
||||
import { logAudit } from '../services/logger/audit-logger'
|
||||
import type { UserInfo } from '../types/user.types'
|
||||
import { IPC_CHANNELS } from '../../shared/ipc-channels'
|
||||
import { ValidationError } from '../types/errors'
|
||||
import { withErrorHandling, type IpcResult } from './index'
|
||||
|
||||
const log = createLogger('AuthHandler')
|
||||
|
||||
/**
|
||||
* Login request
|
||||
*/
|
||||
export interface LoginRequest {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Login response
|
||||
*/
|
||||
export interface LoginResponse {
|
||||
success: boolean
|
||||
userInfo?: UserInfo
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Silent login response
|
||||
*/
|
||||
export interface SilentLoginResponse {
|
||||
success: boolean
|
||||
userInfo?: UserInfo
|
||||
requiresUserSelection?: boolean // True if admin needs to select a user
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* User selection response
|
||||
*/
|
||||
export interface UserSelectionResponse {
|
||||
success: boolean
|
||||
userInfo?: UserInfo
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Current user response
|
||||
*/
|
||||
export interface CurrentUserResponse {
|
||||
isAuthenticated: boolean
|
||||
userInfo?: UserInfo
|
||||
}
|
||||
|
||||
/**
|
||||
* Register IPC handlers for user authentication
|
||||
*/
|
||||
export function registerAuthHandlers(): void {
|
||||
const sessionManager = SessionManager.getInstance()
|
||||
|
||||
/**
|
||||
* Get computer name
|
||||
*/
|
||||
ipcMain.handle(IPC_CHANNELS.AUTH_GET_COMPUTER_NAME, async (): Promise<IpcResult<string>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const os = await import('os')
|
||||
return os.hostname()
|
||||
}, 'auth:getComputerName')
|
||||
})
|
||||
|
||||
/**
|
||||
* Silent login by computer name
|
||||
*/
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.AUTH_SILENT_LOGIN,
|
||||
async (): Promise<IpcResult<SilentLoginResponse>> => {
|
||||
return withErrorHandling(async () => {
|
||||
log.info('Attempting silent login')
|
||||
const success = await sessionManager.loginByComputerName()
|
||||
const userInfo = sessionManager.getUserInfo()
|
||||
|
||||
if (success && userInfo) {
|
||||
// Check if admin needs user selection
|
||||
const requiresUserSelection = userInfo.userType === 'Admin'
|
||||
|
||||
log.info('Silent login successful', {
|
||||
username: userInfo.username,
|
||||
userType: userInfo.userType,
|
||||
requiresUserSelection
|
||||
})
|
||||
|
||||
// Audit log: LOGIN success (non-blocking)
|
||||
const os = await import('os')
|
||||
logAudit('LOGIN', String(userInfo.id), {
|
||||
username: userInfo.username,
|
||||
computerName: os.hostname(),
|
||||
resource: 'ERP_SYSTEM',
|
||||
status: 'success',
|
||||
metadata: { loginType: 'silent', userType: userInfo.userType }
|
||||
}).catch((err) => log.warn('Failed to write audit log', { err }))
|
||||
|
||||
return {
|
||||
success: true,
|
||||
userInfo,
|
||||
requiresUserSelection
|
||||
}
|
||||
}
|
||||
|
||||
throw new ValidationError('无感登录失败:未找到匹配用户', 'VAL_INVALID_INPUT')
|
||||
}, 'auth:silentLogin')
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Login with username and password
|
||||
*/
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.AUTH_LOGIN,
|
||||
async (_event, request: LoginRequest): Promise<IpcResult<LoginResponse>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const { username, password } = request
|
||||
|
||||
if (!username || !password) {
|
||||
log.warn('Login attempt with missing credentials')
|
||||
throw new ValidationError('请输入用户名和密码', 'VAL_MISSING_REQUIRED')
|
||||
}
|
||||
|
||||
log.info('Login attempt', { username })
|
||||
const success = await sessionManager.login(username, password)
|
||||
const userInfo = sessionManager.getUserInfo()
|
||||
|
||||
if (success && userInfo) {
|
||||
log.info('Login successful', { username, userType: userInfo.userType })
|
||||
|
||||
// Audit log: LOGIN success (non-blocking)
|
||||
const os = await import('os')
|
||||
logAudit('LOGIN', String(userInfo.id), {
|
||||
username: userInfo.username,
|
||||
computerName: os.hostname(),
|
||||
resource: 'ERP_SYSTEM',
|
||||
status: 'success',
|
||||
metadata: { loginType: 'credentials', userType: userInfo.userType }
|
||||
}).catch((err) => log.warn('Failed to write audit log', { err }))
|
||||
|
||||
return {
|
||||
success: true,
|
||||
userInfo
|
||||
}
|
||||
}
|
||||
|
||||
// Audit log: LOGIN failure (non-blocking)
|
||||
const os = await import('os')
|
||||
logAudit('LOGIN', '0', {
|
||||
username,
|
||||
computerName: os.hostname(),
|
||||
resource: 'ERP_SYSTEM',
|
||||
status: 'failure',
|
||||
metadata: { loginType: 'credentials', reason: 'invalid_credentials' }
|
||||
}).catch((err) => log.warn('Failed to write audit log', { err }))
|
||||
|
||||
log.warn('Login failed - invalid credentials', { username })
|
||||
throw new ValidationError('用户名或密码错误', 'VAL_INVALID_INPUT')
|
||||
}, 'auth:login')
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Logout
|
||||
*/
|
||||
ipcMain.handle(IPC_CHANNELS.AUTH_LOGOUT, async (): Promise<IpcResult<void>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const userInfo = sessionManager.getUserInfo()
|
||||
log.info('User logout', { username: userInfo?.username })
|
||||
|
||||
// Audit log: LOGOUT (non-blocking)
|
||||
if (userInfo) {
|
||||
const os = await import('os')
|
||||
logAudit('LOGOUT', String(userInfo.id), {
|
||||
username: userInfo.username,
|
||||
computerName: os.hostname(),
|
||||
resource: 'ERP_SYSTEM',
|
||||
status: 'success',
|
||||
metadata: { userType: userInfo.userType }
|
||||
}).catch((err) => log.warn('Failed to write audit log', { err }))
|
||||
}
|
||||
|
||||
sessionManager.logout()
|
||||
}, 'auth:logout')
|
||||
})
|
||||
|
||||
/**
|
||||
* Get current user
|
||||
*/
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.AUTH_GET_CURRENT_USER,
|
||||
async (): Promise<IpcResult<CurrentUserResponse>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const isAuthenticated = sessionManager.isAuthenticated()
|
||||
const userInfo = sessionManager.getUserInfo()
|
||||
return {
|
||||
isAuthenticated,
|
||||
userInfo: userInfo ?? undefined
|
||||
}
|
||||
}, 'auth:getCurrentUser')
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Get all users (for admin user selection)
|
||||
*/
|
||||
ipcMain.handle(IPC_CHANNELS.AUTH_GET_ALL_USERS, async (): Promise<IpcResult<UserInfo[]>> => {
|
||||
return withErrorHandling(async () => {
|
||||
log.debug('Fetching all users for admin selection')
|
||||
return await sessionManager.getAllUsers()
|
||||
}, 'auth:getAllUsers')
|
||||
})
|
||||
|
||||
/**
|
||||
* Switch user (admin only)
|
||||
*/
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.AUTH_SWITCH_USER,
|
||||
async (_event, userInfo: UserInfo): Promise<IpcResult<UserSelectionResponse>> => {
|
||||
return withErrorHandling(async () => {
|
||||
log.info('User switch attempt', { targetUser: userInfo.username })
|
||||
const success = sessionManager.switchUser(userInfo)
|
||||
|
||||
if (success) {
|
||||
const newUser = sessionManager.getUserInfo()
|
||||
log.info('User switch successful', { newUsername: newUser?.username })
|
||||
return {
|
||||
success: true,
|
||||
userInfo: newUser ?? undefined
|
||||
}
|
||||
}
|
||||
|
||||
log.warn('User switch failed')
|
||||
throw new ValidationError('用户切换失败', 'VAL_INVALID_INPUT')
|
||||
}, 'auth:switchUser')
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Check if current user is admin
|
||||
*/
|
||||
ipcMain.handle(IPC_CHANNELS.AUTH_IS_ADMIN, async (): Promise<IpcResult<boolean>> => {
|
||||
return withErrorHandling(async () => sessionManager.isAdmin(), 'auth:isAdmin')
|
||||
})
|
||||
}
|
||||
368
src/main/ipc/cleaner-handler.ts
Normal file
368
src/main/ipc/cleaner-handler.ts
Normal file
@@ -0,0 +1,368 @@
|
||||
import { ipcMain, type WebContents } from 'electron'
|
||||
import { ErpAuthService } from '../services/erp/erp-auth'
|
||||
import { CleanerService } from '../services/erp/cleaner'
|
||||
import { OrderNumberResolver } from '../services/erp/order-resolver'
|
||||
import { MySqlService } from '../services/database/mysql'
|
||||
import { SqlServerService } from '../services/database/sql-server'
|
||||
import { ConfigManager } from '../services/config/config-manager'
|
||||
import { ResultExporter } from '../services/excel/result-exporter'
|
||||
import { CleanerReportGenerator } from '../services/report/cleaner-report-generator'
|
||||
import { RustfsService } from '../services/rustfs'
|
||||
import { SessionManager } from '../services/user/session-manager'
|
||||
import { createLogger } from '../services/logger'
|
||||
import { logAudit } from '../services/logger/audit-logger'
|
||||
import { withErrorHandling, type IpcResult } from './index'
|
||||
import { ErpConnectionError, ValidationError, DatabaseQueryError } from '../types/errors'
|
||||
import type {
|
||||
CleanerInput,
|
||||
CleanerResult,
|
||||
CleanerProgress,
|
||||
ExportResultItem,
|
||||
ExportResultResponse
|
||||
} from '../types/cleaner.types'
|
||||
import { UserErpConfigService } from '../services/user/user-erp-config-service'
|
||||
import { IPC_CHANNELS } from '../../shared/ipc-channels'
|
||||
|
||||
const log = createLogger('CleanerHandler')
|
||||
|
||||
function sendProgress(
|
||||
sender: WebContents,
|
||||
message: string,
|
||||
progress: number,
|
||||
extra?: Partial<CleanerProgress>
|
||||
): void {
|
||||
try {
|
||||
const progressData: CleanerProgress = {
|
||||
message,
|
||||
progress,
|
||||
currentOrderIndex: extra?.currentOrderIndex ?? 0,
|
||||
totalOrders: extra?.totalOrders ?? 0,
|
||||
currentMaterialIndex: extra?.currentMaterialIndex ?? 0,
|
||||
totalMaterialsInOrder: extra?.totalMaterialsInOrder ?? 0,
|
||||
currentOrderNumber: extra?.currentOrderNumber,
|
||||
phase: extra?.phase ?? 'processing'
|
||||
}
|
||||
sender.send(IPC_CHANNELS.CLEANER_PROGRESS, progressData)
|
||||
} catch (error) {
|
||||
log.warn('Failed to send progress event', { error })
|
||||
}
|
||||
}
|
||||
|
||||
async function getDatabaseService(): Promise<MySqlService | SqlServerService> {
|
||||
const configManager = ConfigManager.getInstance()
|
||||
const config = configManager.getConfig()
|
||||
const dbType = configManager.getDatabaseType()
|
||||
|
||||
if (dbType === 'sqlserver') {
|
||||
const dbConfig = config.database.sqlserver
|
||||
const sqlServerService = new SqlServerService({
|
||||
server: dbConfig.server,
|
||||
port: dbConfig.port,
|
||||
user: dbConfig.username,
|
||||
password: dbConfig.password,
|
||||
database: dbConfig.database,
|
||||
options: {
|
||||
encrypt: false,
|
||||
trustServerCertificate: dbConfig.trustServerCertificate
|
||||
}
|
||||
})
|
||||
await sqlServerService.connect()
|
||||
return sqlServerService
|
||||
} else {
|
||||
const dbConfig = config.database.mysql
|
||||
const mysqlService = new MySqlService({
|
||||
host: dbConfig.host,
|
||||
port: dbConfig.port,
|
||||
user: dbConfig.username,
|
||||
password: dbConfig.password,
|
||||
database: dbConfig.database
|
||||
})
|
||||
await mysqlService.connect()
|
||||
return mysqlService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ERP configuration for current user
|
||||
* URL is from config.yaml (fixed infrastructure)
|
||||
* Username and password are from user's database config
|
||||
*/
|
||||
async function getErpConfig(): Promise<{
|
||||
url: string
|
||||
username: string
|
||||
password: string
|
||||
}> {
|
||||
// Get ERP URL from config.yaml (fixed for all users)
|
||||
const configManager = ConfigManager.getInstance()
|
||||
const globalConfig = configManager.getConfig()
|
||||
const erpUrl = globalConfig.erp.url
|
||||
|
||||
// Get username and password from user's database config
|
||||
const erpConfigService = UserErpConfigService.getInstance()
|
||||
const userConfig = await erpConfigService.getCurrentUserErpConfig()
|
||||
|
||||
if (!userConfig || !userConfig.username || !userConfig.password) {
|
||||
throw new ValidationError(
|
||||
'ERP 配置不完整。请在设置中配置 ERP 用户名和密码',
|
||||
'VAL_MISSING_REQUIRED'
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
url: erpUrl,
|
||||
username: userConfig.username,
|
||||
password: userConfig.password
|
||||
}
|
||||
}
|
||||
|
||||
export function registerCleanerHandlers(): void {
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CLEANER_RUN,
|
||||
async (event, input: CleanerInput): Promise<IpcResult<CleanerResult>> => {
|
||||
const sender = event.sender
|
||||
const startTime = Date.now()
|
||||
|
||||
return withErrorHandling(async () => {
|
||||
let authService: ErpAuthService | null = null
|
||||
let dbService: MySqlService | SqlServerService | null = null
|
||||
|
||||
try {
|
||||
// Get ERP configuration from database for current user
|
||||
log.info('Fetching ERP configuration from database...')
|
||||
const erpConfig = await getErpConfig()
|
||||
|
||||
log.info('ERP config retrieved', {
|
||||
url: erpConfig.url ? 'configured' : 'EMPTY',
|
||||
username: erpConfig.username ? 'configured' : 'EMPTY'
|
||||
})
|
||||
|
||||
const configManager = ConfigManager.getInstance()
|
||||
const dbType = configManager.getDatabaseType()
|
||||
log.info(
|
||||
`Connecting to ${dbType === 'sqlserver' ? 'SQL Server' : 'MySQL'} for order resolution...`
|
||||
)
|
||||
|
||||
try {
|
||||
dbService = await getDatabaseService()
|
||||
} catch (error) {
|
||||
throw new DatabaseQueryError(
|
||||
'数据库连接失败',
|
||||
'DB_CONNECTION_FAILED',
|
||||
error instanceof Error ? error : undefined
|
||||
)
|
||||
}
|
||||
|
||||
const resolver = new OrderNumberResolver(dbService)
|
||||
const mappings = await resolver.resolve(input.orderNumbers)
|
||||
|
||||
const validOrderNumbers = resolver.getValidOrderNumbers(mappings)
|
||||
const warnings = resolver.getWarnings(mappings)
|
||||
|
||||
if (warnings.length > 0) {
|
||||
log.warn('Resolution warnings', { warnings })
|
||||
}
|
||||
|
||||
if (validOrderNumbers.length === 0) {
|
||||
throw new ValidationError(
|
||||
'没有有效的生产订单号可处理。请检查输入的格式或数据库连接。',
|
||||
'VAL_INVALID_INPUT'
|
||||
)
|
||||
}
|
||||
|
||||
log.info('Resolved order numbers', { count: validOrderNumbers.length })
|
||||
|
||||
authService = new ErpAuthService({
|
||||
url: erpConfig.url,
|
||||
username: erpConfig.username,
|
||||
password: erpConfig.password,
|
||||
headless: input.headless ?? true
|
||||
})
|
||||
|
||||
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')
|
||||
|
||||
// Send login complete progress
|
||||
const totalOrders = validOrderNumbers.length
|
||||
const loginProgress = (1 / (1 + totalOrders)) * 100
|
||||
sendProgress(sender, 'ERP 登录成功', loginProgress, {
|
||||
phase: 'login',
|
||||
currentOrderIndex: 0,
|
||||
totalOrders,
|
||||
currentMaterialIndex: 0,
|
||||
totalMaterialsInOrder: 0
|
||||
})
|
||||
|
||||
const cleaner = new CleanerService(authService)
|
||||
|
||||
const modifiedInput: CleanerInput = {
|
||||
...input,
|
||||
orderNumbers: validOrderNumbers,
|
||||
onProgress: (message, progress, extra) => {
|
||||
sendProgress(sender, message, progress ?? 0, extra)
|
||||
}
|
||||
}
|
||||
|
||||
log.info('Starting cleaning', {
|
||||
orderCount: validOrderNumbers.length,
|
||||
queryBatchSize: input.queryBatchSize ?? 100,
|
||||
processConcurrency: input.processConcurrency ?? 1
|
||||
})
|
||||
const result = await cleaner.clean(modifiedInput)
|
||||
|
||||
if (warnings.length > 0) {
|
||||
result.errors = [...warnings, ...result.errors]
|
||||
}
|
||||
|
||||
// Send completion progress
|
||||
sendProgress(sender, '清理完成', 100, {
|
||||
phase: 'complete',
|
||||
currentOrderIndex: totalOrders,
|
||||
totalOrders,
|
||||
currentMaterialIndex: 0,
|
||||
totalMaterialsInOrder: 0
|
||||
})
|
||||
|
||||
log.info('Cleaning completed', {
|
||||
processedCount: result.ordersProcessed,
|
||||
errorCount: result.errors.length
|
||||
})
|
||||
|
||||
// Audit log: CLEAN (non-blocking)
|
||||
const os = await import('os')
|
||||
const currentUser = SessionManager.getInstance().getUserInfo()
|
||||
if (currentUser) {
|
||||
const status: 'success' | 'failure' | 'partial' =
|
||||
result.errors.length > 0 && result.materialsDeleted > 0
|
||||
? 'partial'
|
||||
: result.errors.length > 0
|
||||
? 'failure'
|
||||
: 'success'
|
||||
logAudit('CLEAN', String(currentUser.id), {
|
||||
username: currentUser.username,
|
||||
computerName: os.hostname(),
|
||||
resource: 'MATERIAL_PLAN',
|
||||
status,
|
||||
metadata: {
|
||||
orderCount: validOrderNumbers.length,
|
||||
dryRun: input.dryRun ?? false,
|
||||
queryBatchSize: input.queryBatchSize ?? 100,
|
||||
processConcurrency: input.processConcurrency ?? 1,
|
||||
materialsDeleted: result.materialsDeleted,
|
||||
materialsSkipped: result.materialsSkipped,
|
||||
errorCount: result.errors.length
|
||||
}
|
||||
}).catch((err) => log.warn('Failed to write audit log', { err }))
|
||||
}
|
||||
|
||||
// Generate report and upload to RustFS (silent, user unaware)
|
||||
try {
|
||||
const endTime = Date.now()
|
||||
const currentUser = SessionManager.getInstance().getUserInfo()
|
||||
const username = currentUser?.username ?? 'unknown'
|
||||
|
||||
const reportGenerator = new CleanerReportGenerator()
|
||||
const reportPath = await reportGenerator.generateReport(result, {
|
||||
dryRun: input.dryRun ?? false,
|
||||
username,
|
||||
startTime,
|
||||
endTime
|
||||
})
|
||||
log.info('Report generated', { path: reportPath })
|
||||
|
||||
// Upload to RustFS if enabled
|
||||
const configManager = ConfigManager.getInstance()
|
||||
const config = configManager.getConfig()
|
||||
|
||||
if (config.rustfs?.enabled && config.rustfs.endpoint) {
|
||||
try {
|
||||
const rustfs = new RustfsService({ config: config.rustfs })
|
||||
const reportFileName = reportPath.split(/[\\/]/).pop() || 'report.md'
|
||||
const storageKey = rustfs.generateReportKey(reportFileName, username)
|
||||
|
||||
log.info('Uploading report to RustFS', {
|
||||
localPath: reportPath,
|
||||
storageKey
|
||||
})
|
||||
|
||||
const uploadResult = await rustfs.uploadFile(
|
||||
reportPath,
|
||||
storageKey,
|
||||
'text/markdown; charset=utf-8'
|
||||
)
|
||||
|
||||
if (uploadResult.success) {
|
||||
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
|
||||
})
|
||||
}
|
||||
} catch (rustfsError) {
|
||||
log.error('RustFS upload failed', {
|
||||
error: rustfsError instanceof Error ? rustfsError.message : String(rustfsError)
|
||||
})
|
||||
}
|
||||
} else {
|
||||
log.debug('RustFS is not enabled, skipping upload')
|
||||
}
|
||||
} catch (reportError) {
|
||||
log.warn('Failed to generate report', {
|
||||
error: reportError instanceof Error ? reportError.message : String(reportError)
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
} finally {
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 'cleaner:run')
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CLEANER_EXPORT_RESULTS,
|
||||
async (_event, items: ExportResultItem[]): Promise<IpcResult<ExportResultResponse>> => {
|
||||
return withErrorHandling(async () => {
|
||||
log.info('Exporting validation results', { count: items.length })
|
||||
if (!items || items.length === 0) {
|
||||
throw new ValidationError('没有数据可导出', 'VAL_INVALID_INPUT')
|
||||
}
|
||||
const exporter = new ResultExporter()
|
||||
return await exporter.exportValidationResults(items)
|
||||
}, 'cleaner:exportResults')
|
||||
}
|
||||
)
|
||||
}
|
||||
253
src/main/ipc/database-handler.ts
Normal file
253
src/main/ipc/database-handler.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { MySqlService } from '../services/database/mysql'
|
||||
import { SqlServerService } from '../services/database/sql-server'
|
||||
import { createLogger } from '../services/logger'
|
||||
import { ValidationError } from '../types/errors'
|
||||
import type {
|
||||
MySqlConfig,
|
||||
MySqlQueryResult,
|
||||
SqlServerConfig,
|
||||
SqlServerQueryResult
|
||||
} from '../types/ipc-api.types'
|
||||
import { IPC_CHANNELS } from '../../shared/ipc-channels'
|
||||
import { withErrorHandling, type IpcResult } from './index'
|
||||
|
||||
const log = createLogger('DatabaseHandler')
|
||||
|
||||
// Store MySQL service instances per window/connection
|
||||
const mysqlServices = new Map<string, MySqlService>()
|
||||
|
||||
// Store SQL Server service instances per window/connection
|
||||
const sqlServerServices = new Map<string, SqlServerService>()
|
||||
const cleanupBoundWindows = new Set<string>()
|
||||
|
||||
function bindWindowCleanup(
|
||||
windowId: string,
|
||||
sender: { once: (event: string, listener: () => void) => void }
|
||||
): void {
|
||||
if (cleanupBoundWindows.has(windowId)) {
|
||||
return
|
||||
}
|
||||
|
||||
sender.once('destroyed', () => {
|
||||
const mysql = getMySqlService(windowId)
|
||||
const sqlServer = getSqlServerService(windowId)
|
||||
|
||||
if (mysql) {
|
||||
mysql
|
||||
.disconnect()
|
||||
.catch((error) => log.warn('MySQL disconnect on window destroy failed', { error }))
|
||||
deleteMySqlService(windowId)
|
||||
}
|
||||
|
||||
if (sqlServer) {
|
||||
sqlServer
|
||||
.disconnect()
|
||||
.catch((error) => log.warn('SQL Server disconnect on window destroy failed', { error }))
|
||||
deleteSqlServerService(windowId)
|
||||
}
|
||||
|
||||
cleanupBoundWindows.delete(windowId)
|
||||
})
|
||||
|
||||
cleanupBoundWindows.add(windowId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create MySQL service for a connection ID
|
||||
*/
|
||||
function getMySqlService(connectionId: string): MySqlService | undefined {
|
||||
return mysqlServices.get(connectionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set MySQL service for a connection ID
|
||||
*/
|
||||
function setMySqlService(connectionId: string, service: MySqlService): void {
|
||||
mysqlServices.set(connectionId, service)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete MySQL service for a connection ID
|
||||
*/
|
||||
function deleteMySqlService(connectionId: string): void {
|
||||
mysqlServices.delete(connectionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create SQL Server service for a connection ID
|
||||
*/
|
||||
function getSqlServerService(connectionId: string): SqlServerService | undefined {
|
||||
return sqlServerServices.get(connectionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set SQL Server service for a connection ID
|
||||
*/
|
||||
function setSqlServerService(connectionId: string, service: SqlServerService): void {
|
||||
sqlServerServices.set(connectionId, service)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete SQL Server service for a connection ID
|
||||
*/
|
||||
function deleteSqlServerService(connectionId: string): void {
|
||||
sqlServerServices.delete(connectionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register IPC handlers for database operations
|
||||
*/
|
||||
export function registerDatabaseHandlers(): void {
|
||||
// Connect to MySQL
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.DATABASE_MYSQL_CONNECT,
|
||||
async (event, config: MySqlConfig): Promise<IpcResult<void>> => {
|
||||
return withErrorHandling(async () => {
|
||||
// Use window ID as connection identifier
|
||||
const windowId = (event.sender as { id: number }).id.toString()
|
||||
bindWindowCleanup(
|
||||
windowId,
|
||||
event.sender as { once: (event: string, listener: () => void) => void }
|
||||
)
|
||||
log.info('Connecting to MySQL', { windowId })
|
||||
const service = new MySqlService(config)
|
||||
await service.connect()
|
||||
setMySqlService(windowId, service)
|
||||
log.info('MySQL connected', { windowId })
|
||||
}, 'database:mysql:connect')
|
||||
}
|
||||
)
|
||||
|
||||
// Disconnect from MySQL
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.DATABASE_MYSQL_DISCONNECT,
|
||||
async (event): Promise<IpcResult<void>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const windowId = (event.sender as { id: number }).id.toString()
|
||||
const service = getMySqlService(windowId)
|
||||
if (service) {
|
||||
await service.disconnect()
|
||||
deleteMySqlService(windowId)
|
||||
log.info('MySQL disconnected', { windowId })
|
||||
}
|
||||
}, 'database:mysql:disconnect')
|
||||
}
|
||||
)
|
||||
|
||||
// Check if MySQL is connected
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.DATABASE_MYSQL_IS_CONNECTED,
|
||||
async (event): Promise<IpcResult<boolean>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const windowId = (event.sender as { id: number }).id.toString()
|
||||
const service = getMySqlService(windowId)
|
||||
return service ? service.isConnected() : false
|
||||
}, 'database:mysql:isConnected')
|
||||
}
|
||||
)
|
||||
|
||||
// Execute MySQL query
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.DATABASE_MYSQL_QUERY,
|
||||
async (event, sql: string, params?: unknown[]): Promise<IpcResult<MySqlQueryResult>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const windowId = (event.sender as { id: number }).id.toString()
|
||||
const service = getMySqlService(windowId)
|
||||
|
||||
if (!service) {
|
||||
throw new ValidationError(
|
||||
'Not connected to MySQL. Call connect() first.',
|
||||
'VAL_INVALID_INPUT'
|
||||
)
|
||||
}
|
||||
|
||||
log.debug('Executing MySQL query', { windowId, sql: sql.substring(0, 100) })
|
||||
return await service.query(sql, params)
|
||||
}, 'database:mysql:query')
|
||||
}
|
||||
)
|
||||
|
||||
// Connect to SQL Server
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.DATABASE_SQLSERVER_CONNECT,
|
||||
async (event, config: SqlServerConfig): Promise<IpcResult<void>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const windowId = (event.sender as { id: number }).id.toString()
|
||||
bindWindowCleanup(
|
||||
windowId,
|
||||
event.sender as { once: (event: string, listener: () => void) => void }
|
||||
)
|
||||
log.info('Connecting to SQL Server', { windowId })
|
||||
const service = new SqlServerService(config)
|
||||
await service.connect()
|
||||
setSqlServerService(windowId, service)
|
||||
log.info('SQL Server connected', { windowId })
|
||||
}, 'database:sqlserver:connect')
|
||||
}
|
||||
)
|
||||
|
||||
// Disconnect from SQL Server
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.DATABASE_SQLSERVER_DISCONNECT,
|
||||
async (event): Promise<IpcResult<void>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const windowId = (event.sender as { id: number }).id.toString()
|
||||
const service = getSqlServerService(windowId)
|
||||
if (service) {
|
||||
await service.disconnect()
|
||||
deleteSqlServerService(windowId)
|
||||
log.info('SQL Server disconnected', { windowId })
|
||||
}
|
||||
}, 'database:sqlserver:disconnect')
|
||||
}
|
||||
)
|
||||
|
||||
// Check if SQL Server is connected
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.DATABASE_SQLSERVER_IS_CONNECTED,
|
||||
async (event): Promise<IpcResult<boolean>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const windowId = (event.sender as { id: number }).id.toString()
|
||||
const service = getSqlServerService(windowId)
|
||||
return service ? service.isConnected() : false
|
||||
}, 'database:sqlserver:isConnected')
|
||||
}
|
||||
)
|
||||
|
||||
// Execute SQL Server query
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.DATABASE_SQLSERVER_QUERY,
|
||||
async (
|
||||
event,
|
||||
sqlString: string,
|
||||
params?: Record<string, unknown>
|
||||
): Promise<IpcResult<SqlServerQueryResult>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const windowId = (event.sender as { id: number }).id.toString()
|
||||
const service = getSqlServerService(windowId)
|
||||
|
||||
if (!service) {
|
||||
throw new ValidationError(
|
||||
'Not connected to SQL Server. Call connect() first.',
|
||||
'VAL_INVALID_INPUT'
|
||||
)
|
||||
}
|
||||
|
||||
log.debug('Executing SQL Server query', { windowId, sql: sqlString.substring(0, 100) })
|
||||
|
||||
// Use queryWithParams for named parameters, or query for no params
|
||||
if (params && Object.keys(params).length > 0) {
|
||||
// Convert to the format expected by queryWithParams
|
||||
const typedParams: Record<string, { value: unknown }> = {}
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
typedParams[key] = { value }
|
||||
}
|
||||
return await service.queryWithParams(sqlString, typedParams)
|
||||
} else {
|
||||
return await service.query(sqlString)
|
||||
}
|
||||
}, 'database:sqlserver:query')
|
||||
}
|
||||
)
|
||||
}
|
||||
278
src/main/ipc/extractor-handler.ts
Normal file
278
src/main/ipc/extractor-handler.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import { ipcMain, type WebContents } from 'electron'
|
||||
import { ErpAuthService } from '../services/erp/erp-auth'
|
||||
import { ExtractorService } from '../services/erp/extractor'
|
||||
import { OrderNumberResolver } from '../services/erp/order-resolver'
|
||||
import { create, type IDatabaseService } from '../services/database'
|
||||
import { createLogger } from '../services/logger'
|
||||
import { logAudit } from '../services/logger/audit-logger'
|
||||
import { SessionManager } from '../services/user/session-manager'
|
||||
import { withErrorHandling, type IpcResult } from './index'
|
||||
import { ErpConnectionError, ValidationError, DatabaseQueryError } from '../types/errors'
|
||||
import type { ExtractorInput, ExtractorResult, ExtractionProgress } from '../types/extractor.types'
|
||||
import { UserErpConfigService } from '../services/user/user-erp-config-service'
|
||||
import { ConfigManager } from '../services/config/config-manager'
|
||||
import { IPC_CHANNELS } from '../../shared/ipc-channels'
|
||||
|
||||
const log = createLogger('ExtractorHandler')
|
||||
|
||||
function sendProgress(
|
||||
sender: WebContents,
|
||||
message: string,
|
||||
progress: number,
|
||||
extra?: Partial<ExtractionProgress>
|
||||
): void {
|
||||
try {
|
||||
const progressData = { message, progress, ...extra }
|
||||
sender.send(IPC_CHANNELS.EXTRACTOR_PROGRESS, progressData)
|
||||
} catch (error) {
|
||||
log.warn('Failed to send progress event', { error })
|
||||
}
|
||||
}
|
||||
|
||||
function sendLog(sender: WebContents, level: string, message: string): void {
|
||||
try {
|
||||
sender.send(IPC_CHANNELS.EXTRACTOR_LOG, { level, message })
|
||||
} catch (error) {
|
||||
log.warn('Failed to send log event', { error })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ERP configuration for current user
|
||||
* URL is from config.yaml (fixed infrastructure)
|
||||
* Username and password are from user's database config
|
||||
*/
|
||||
async function getErpConfig(): Promise<{
|
||||
url: string
|
||||
username: string
|
||||
password: string
|
||||
}> {
|
||||
// Get ERP URL from config.yaml (fixed for all users)
|
||||
const configManager = ConfigManager.getInstance()
|
||||
const globalConfig = configManager.getConfig()
|
||||
const erpUrl = globalConfig.erp.url
|
||||
|
||||
// Get username and password from user's database config
|
||||
const erpConfigService = UserErpConfigService.getInstance()
|
||||
const userConfig = await erpConfigService.getCurrentUserErpConfig()
|
||||
|
||||
if (!userConfig || !userConfig.username || !userConfig.password) {
|
||||
throw new ValidationError(
|
||||
'ERP 配置不完整。请在设置中配置 ERP 用户名和密码',
|
||||
'VAL_MISSING_REQUIRED'
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
url: erpUrl,
|
||||
username: userConfig.username,
|
||||
password: userConfig.password
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register IPC handlers for extractor service
|
||||
*/
|
||||
export function registerExtractorHandlers(): void {
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.EXTRACTOR_RUN,
|
||||
async (event, input: ExtractorInput): Promise<IpcResult<ExtractorResult>> => {
|
||||
const sender = event.sender
|
||||
|
||||
return withErrorHandling(async () => {
|
||||
let authService: ErpAuthService | null = null
|
||||
let dbService: IDatabaseService | null = null
|
||||
|
||||
try {
|
||||
// Get ERP configuration from database for current user
|
||||
log.info('Fetching ERP configuration from database...')
|
||||
const erpConfig = await getErpConfig()
|
||||
|
||||
log.info('ERP config retrieved', {
|
||||
url: erpConfig.url ? 'configured' : 'EMPTY',
|
||||
username: erpConfig.username ? 'configured' : 'EMPTY'
|
||||
})
|
||||
|
||||
// Create database service using factory
|
||||
log.info('Connecting to database for order resolution...')
|
||||
sendProgress(sender, '连接数据库...', 3.33, {
|
||||
phase: 'login',
|
||||
subProgress: { step: '连接数据库', current: 1, total: 3 }
|
||||
})
|
||||
sendLog(sender, 'system', '正在连接数据库...')
|
||||
|
||||
try {
|
||||
dbService = await create()
|
||||
} catch (error) {
|
||||
throw new DatabaseQueryError(
|
||||
'数据库连接失败',
|
||||
'DB_CONNECTION_FAILED',
|
||||
error instanceof Error ? error : undefined
|
||||
)
|
||||
}
|
||||
|
||||
// Resolve order numbers (convert productionIDs to 生产订单号)
|
||||
sendProgress(sender, '解析订单号...', 6.67, {
|
||||
phase: 'login',
|
||||
subProgress: { step: '解析订单号', current: 2, total: 3 }
|
||||
})
|
||||
sendLog(sender, 'info', '正在解析订单号...')
|
||||
|
||||
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)
|
||||
|
||||
// Get deduplication report for detailed logging
|
||||
const dedupReport = resolver.getDeduplicationReport(mappings)
|
||||
|
||||
if (warnings.length > 0) {
|
||||
log.warn('Resolution warnings', { warnings })
|
||||
}
|
||||
|
||||
if (validOrderNumbers.length === 0) {
|
||||
throw new ValidationError(
|
||||
'没有有效的生产订单号可处理。请检查输入的格式或数据库连接。',
|
||||
'VAL_INVALID_INPUT'
|
||||
)
|
||||
}
|
||||
|
||||
log.info('Resolved order numbers', { count: validOrderNumbers.length })
|
||||
|
||||
// Log deduplication summary
|
||||
sendLog(sender, 'info', dedupReport.summary)
|
||||
|
||||
// Log only merged mappings (where multiple productionIDs map to the same order number)
|
||||
if (dedupReport.inputCount > dedupReport.uniqueOrderNumbersCount) {
|
||||
sendLog(sender, 'info', '重复合并详情:')
|
||||
dedupReport.orderNumberGroups.forEach((productionIds, orderNumber) => {
|
||||
if (productionIds.length > 1) {
|
||||
sendLog(
|
||||
sender,
|
||||
'info',
|
||||
` ${orderNumber} ← ${productionIds.join('、')} (共 ${productionIds.length} 个总排号)`
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Create auth service and login
|
||||
authService = new ErpAuthService({
|
||||
url: erpConfig.url,
|
||||
username: erpConfig.username,
|
||||
password: erpConfig.password,
|
||||
headless: true
|
||||
})
|
||||
|
||||
sendProgress(sender, '登录 ERP 系统...', 9.99, {
|
||||
phase: 'login',
|
||||
subProgress: { step: '登录 ERP 系统', current: 3, total: 3 }
|
||||
})
|
||||
sendLog(sender, 'system', '正在登录 ERP 系统...')
|
||||
|
||||
log.info('Logging in to ERP...')
|
||||
try {
|
||||
await authService.login()
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : '未知错误'
|
||||
sendLog(sender, 'error', `ERP 登录失败:${errorMsg}`)
|
||||
throw new ErpConnectionError(
|
||||
'ERP 登录失败',
|
||||
'ERP_LOGIN_FAILED',
|
||||
error instanceof Error ? error : undefined
|
||||
)
|
||||
}
|
||||
log.info('Login successful')
|
||||
sendLog(sender, 'success', 'ERP 登录成功')
|
||||
|
||||
// Create extractor service and run extraction with resolved order numbers
|
||||
const extractor = new ExtractorService(authService)
|
||||
log.info('Starting extraction', { orderCount: validOrderNumbers.length })
|
||||
|
||||
const modifiedInput: ExtractorInput = {
|
||||
...input,
|
||||
orderNumbers: validOrderNumbers,
|
||||
onProgress: (message, progress, extra) => {
|
||||
sendProgress(sender, message, progress, extra)
|
||||
sendLog(sender, 'info', message)
|
||||
},
|
||||
onLog: (level, message) => {
|
||||
sendLog(sender, level, message)
|
||||
}
|
||||
}
|
||||
|
||||
const result = await extractor.extract(modifiedInput)
|
||||
|
||||
// Add warnings to result errors if any
|
||||
if (warnings.length > 0) {
|
||||
result.errors = [...warnings, ...result.errors]
|
||||
}
|
||||
|
||||
log.info('Extraction completed', {
|
||||
rowCount: result.recordCount,
|
||||
errorCount: result.errors.length
|
||||
})
|
||||
|
||||
// Log detailed error information if any errors occurred
|
||||
if (result.errors.length > 0) {
|
||||
log.warn('Extraction errors occurred', { errors: result.errors })
|
||||
result.errors.forEach((err, index) => {
|
||||
log.error(`Error ${index + 1}/${result.errors.length}: ${err}`)
|
||||
})
|
||||
}
|
||||
|
||||
// Audit log: EXTRACT (non-blocking)
|
||||
const os = await import('os')
|
||||
const currentUser = SessionManager.getInstance().getUserInfo()
|
||||
if (currentUser) {
|
||||
const status: 'success' | 'failure' | 'partial' =
|
||||
result.errors.length > 0 && result.recordCount > 0
|
||||
? 'partial'
|
||||
: result.errors.length > 0
|
||||
? 'failure'
|
||||
: 'success'
|
||||
logAudit('EXTRACT', String(currentUser.id), {
|
||||
username: currentUser.username,
|
||||
computerName: os.hostname(),
|
||||
resource: 'MATERIAL_PLAN',
|
||||
status,
|
||||
metadata: {
|
||||
orderCount: validOrderNumbers.length,
|
||||
recordCount: result.recordCount,
|
||||
errorCount: result.errors.length
|
||||
}
|
||||
}).catch((err) => log.warn('Failed to write audit log', { err }))
|
||||
}
|
||||
|
||||
return result
|
||||
} finally {
|
||||
// Clean up: close browser
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up: disconnect database
|
||||
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')
|
||||
}
|
||||
)
|
||||
}
|
||||
99
src/main/ipc/file-handler.ts
Normal file
99
src/main/ipc/file-handler.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { app, ipcMain, shell } from 'electron'
|
||||
import * as fs from 'fs/promises'
|
||||
import * as path from 'path'
|
||||
import { createLogger } from '../services/logger'
|
||||
import { IPC_CHANNELS } from '../../shared/ipc-channels'
|
||||
import { ValidationError } from '../types/errors'
|
||||
import { withErrorHandling, type IpcResult } from './index'
|
||||
|
||||
const log = createLogger('FileHandler')
|
||||
|
||||
function getAllowedRoots(): string[] {
|
||||
return [path.resolve(app.getAppPath()), path.resolve(app.getPath('userData'))]
|
||||
}
|
||||
|
||||
export function isPathWithinAllowedRoots(inputPath: string, roots: string[]): boolean {
|
||||
const normalized = path.resolve(inputPath)
|
||||
return roots.some((root) => {
|
||||
const rel = path.relative(root, normalized)
|
||||
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel))
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeAndValidatePath(inputPath: string): string {
|
||||
const normalized = path.resolve(inputPath)
|
||||
const isAllowed = isPathWithinAllowedRoots(normalized, getAllowedRoots())
|
||||
if (!isAllowed) {
|
||||
throw new ValidationError('Path is outside allowed roots', 'VAL_INVALID_INPUT')
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function registerFileHandlers(): void {
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.FILE_READ,
|
||||
async (_event, filePath: string): Promise<IpcResult<string>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const safePath = normalizeAndValidatePath(filePath)
|
||||
log.debug('Reading file', { filePath: safePath })
|
||||
return await fs.readFile(safePath, 'utf-8')
|
||||
}, 'file:read')
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.FILE_WRITE,
|
||||
async (_event, filePath: string, content: string): Promise<IpcResult<void>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const safePath = normalizeAndValidatePath(filePath)
|
||||
log.debug('Writing file', { filePath: safePath })
|
||||
const dir = path.dirname(safePath)
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
await fs.writeFile(safePath, content, 'utf-8')
|
||||
}, 'file:write')
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.FILE_EXISTS,
|
||||
async (_event, filePath: string): Promise<IpcResult<boolean>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const safePath = normalizeAndValidatePath(filePath)
|
||||
try {
|
||||
await fs.access(safePath)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}, 'file:exists')
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.FILE_LIST,
|
||||
async (_event, dirPath: string): Promise<IpcResult<string[]>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const safePath = normalizeAndValidatePath(dirPath)
|
||||
log.debug('Listing directory', { dirPath: safePath })
|
||||
const entries = await fs.readdir(safePath, { withFileTypes: true })
|
||||
return entries
|
||||
.filter((entry) => entry.isFile())
|
||||
.map((entry) => entry.name)
|
||||
.sort()
|
||||
}, 'file:list')
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.FILE_OPEN_PATH,
|
||||
async (_event, filePath: string): Promise<IpcResult<void>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const safePath = normalizeAndValidatePath(filePath)
|
||||
log.debug('Opening path in explorer', { filePath: safePath })
|
||||
await fs.access(safePath)
|
||||
await shell.openPath(safePath)
|
||||
}, 'file:openPath')
|
||||
}
|
||||
)
|
||||
}
|
||||
109
src/main/ipc/index.ts
Normal file
109
src/main/ipc/index.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* IPC Handler registration
|
||||
* Centralized registration for all IPC handlers
|
||||
*/
|
||||
|
||||
import { registerFileHandlers } from './file-handler'
|
||||
import { registerExtractorHandlers } from './extractor-handler'
|
||||
import { registerCleanerHandlers } from './cleaner-handler'
|
||||
import { registerDatabaseHandlers } from './database-handler'
|
||||
import { registerResolverHandlers } from './resolver-handler'
|
||||
import { registerAuthHandlers } from './auth-handler'
|
||||
import { registerValidationHandlers } from './validation-handler'
|
||||
import { registerSettingsHandlers } from './settings-handler'
|
||||
import { registerMaterialTypeHandlers } from './material-type-handler'
|
||||
import { registerUserErpConfigHandlers } from './user-erp-config-handler'
|
||||
import { registerLoggerHandlers } from './logger-handler'
|
||||
import { registerReportHandlers } from './report-handler'
|
||||
import { createLogger, logError } from '../services/logger'
|
||||
import { serializeError, sanitizeError } from '../services/logger/error-utils'
|
||||
import { getErrorMessage, getErrorCode, isBaseError } from '../types/errors'
|
||||
|
||||
const log = createLogger('IPC')
|
||||
|
||||
/**
|
||||
* Standard result type for all IPC handlers
|
||||
*/
|
||||
export interface IpcResult<T = unknown> {
|
||||
success: boolean
|
||||
data?: T
|
||||
error?: string
|
||||
code?: string
|
||||
}
|
||||
|
||||
export function ok<T>(data: T): IpcResult<T> {
|
||||
return { success: true, data }
|
||||
}
|
||||
|
||||
export function fail<T = unknown>(error: string, code?: string): IpcResult<T> {
|
||||
return { success: false, error, code }
|
||||
}
|
||||
|
||||
/**
|
||||
* Higher-order function to wrap IPC handlers with consistent error handling
|
||||
* Enhanced to capture full error context including stack traces
|
||||
* @param handler - The async handler function to wrap
|
||||
* @param context - The context name for logging
|
||||
* @returns A wrapped handler that returns IpcResult
|
||||
*/
|
||||
export function withErrorHandling<T>(
|
||||
handler: () => Promise<T>,
|
||||
context: string
|
||||
): Promise<IpcResult<T>> {
|
||||
return handler()
|
||||
.then((data): IpcResult<T> => {
|
||||
log.debug(`[${context}] Handler completed successfully`)
|
||||
return ok(data)
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
const message = getErrorMessage(error)
|
||||
const code = getErrorCode(error)
|
||||
|
||||
// Serialize error with full details
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
sanitizeError(serializeError(error))
|
||||
} else {
|
||||
serializeError(error)
|
||||
}
|
||||
|
||||
if (isBaseError(error)) {
|
||||
logError(log, `[${context}] ${error.name}`, error, {
|
||||
code,
|
||||
cause: (error as any).cause?.message,
|
||||
handler: context
|
||||
})
|
||||
} else {
|
||||
logError(log, `[${context}] Error`, error, {
|
||||
code,
|
||||
handler: context
|
||||
})
|
||||
}
|
||||
|
||||
// Include stack trace in development
|
||||
if (process.env.NODE_ENV !== 'production' && error instanceof Error) {
|
||||
log.debug(`[${context}] Stack trace: ${error.stack}`)
|
||||
}
|
||||
|
||||
return fail<T>(message, code)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Register all IPC handlers
|
||||
*/
|
||||
export function registerIpcHandlers(): void {
|
||||
log.info('Registering IPC handlers...')
|
||||
registerFileHandlers()
|
||||
registerExtractorHandlers()
|
||||
registerCleanerHandlers()
|
||||
registerDatabaseHandlers()
|
||||
registerResolverHandlers()
|
||||
registerAuthHandlers()
|
||||
registerValidationHandlers()
|
||||
registerSettingsHandlers()
|
||||
registerMaterialTypeHandlers()
|
||||
registerUserErpConfigHandlers()
|
||||
registerLoggerHandlers()
|
||||
registerReportHandlers()
|
||||
log.info('All IPC handlers registered')
|
||||
}
|
||||
229
src/main/ipc/logger-handler.ts
Normal file
229
src/main/ipc/logger-handler.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* IPC Logger Handler with Batching
|
||||
* Receives logs from renderer process and forwards to Winston
|
||||
*
|
||||
* Features:
|
||||
* - 100ms debounce for batch processing
|
||||
* - Maximum 50 messages per batch
|
||||
* - Circuit breaker: discards new logs when buffer > 500
|
||||
* - Error-level logs bypass circuit breaker
|
||||
*/
|
||||
|
||||
import { ipcMain } from 'electron'
|
||||
import { createLogger } from '../services/logger'
|
||||
import { IPC_CHANNELS, type LogLevel } from '../../shared/ipc-channels'
|
||||
|
||||
const log = createLogger('LoggerHandler')
|
||||
|
||||
/**
|
||||
* Log entry from renderer process
|
||||
*/
|
||||
interface LogEntry {
|
||||
level: LogLevel
|
||||
message: string
|
||||
context?: Record<string, unknown>
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch processing configuration
|
||||
*/
|
||||
const BATCH_CONFIG = {
|
||||
DEBOUNCE_MS: 100,
|
||||
MAX_BATCH_SIZE: 50,
|
||||
CIRCUIT_BREAKER_THRESHOLD: 500
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Logger handler state
|
||||
*/
|
||||
class LoggerHandlerState {
|
||||
private buffer: LogEntry[] = []
|
||||
private debounceTimer: NodeJS.Timeout | null = null
|
||||
private discardedCount = 0
|
||||
|
||||
/**
|
||||
* Add log entry to buffer
|
||||
* @param entry - Log entry to buffer
|
||||
* @returns true if entry was buffered, false if discarded
|
||||
*/
|
||||
addEntry(entry: LogEntry): boolean {
|
||||
// Error-level logs always bypass circuit breaker
|
||||
if (entry.level === 'error') {
|
||||
this.buffer.push(entry)
|
||||
this.flushIfNeeded()
|
||||
return true
|
||||
}
|
||||
|
||||
// Circuit breaker: discard non-error logs when buffer is too large
|
||||
if (this.buffer.length >= BATCH_CONFIG.CIRCUIT_BREAKER_THRESHOLD) {
|
||||
this.discardedCount++
|
||||
|
||||
// Log warning about discarded logs periodically (every 100 discarded)
|
||||
if (this.discardedCount % 100 === 0) {
|
||||
log.warn('Circuit breaker active: discarded logs', {
|
||||
discardedCount: this.discardedCount,
|
||||
bufferSize: this.buffer.length
|
||||
})
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
this.buffer.push(entry)
|
||||
this.flushIfNeeded()
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush buffer if it reaches max batch size
|
||||
*/
|
||||
private flushIfNeeded(): void {
|
||||
if (this.buffer.length >= BATCH_CONFIG.MAX_BATCH_SIZE) {
|
||||
this.flush()
|
||||
} else if (!this.debounceTimer) {
|
||||
// Start debounce timer if not already running
|
||||
this.debounceTimer = setTimeout(() => {
|
||||
this.flush()
|
||||
}, BATCH_CONFIG.DEBOUNCE_MS)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush all buffered logs to Winston
|
||||
*/
|
||||
flush(): void {
|
||||
if (this.debounceTimer) {
|
||||
clearTimeout(this.debounceTimer)
|
||||
this.debounceTimer = null
|
||||
}
|
||||
|
||||
if (this.buffer.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Create a copy of the buffer and clear it
|
||||
const batch = [...this.buffer]
|
||||
this.buffer = []
|
||||
|
||||
// Process batch asynchronously (non-blocking)
|
||||
setImmediate(() => {
|
||||
this.processBatch(batch)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a batch of log entries
|
||||
* @param batch - Array of log entries to process
|
||||
*/
|
||||
private processBatch(batch: LogEntry[]): void {
|
||||
try {
|
||||
for (const entry of batch) {
|
||||
this.forwardToWinston(entry)
|
||||
}
|
||||
} catch (error) {
|
||||
// If batch processing fails, log the error but don't rethrow
|
||||
// This ensures logging failures don't crash the app
|
||||
log.error('Failed to process log batch', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
batchSize: batch.length
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward a single log entry to Winston logger
|
||||
* @param entry - Log entry to forward
|
||||
*/
|
||||
private forwardToWinston(entry: LogEntry): void {
|
||||
const context = (entry.context?.component as string) || 'renderer'
|
||||
const childLogger = log.child({
|
||||
source: 'renderer',
|
||||
component: context
|
||||
})
|
||||
|
||||
const message = entry.context?.message
|
||||
? `[${entry.context.message}] ${entry.message}`
|
||||
: entry.message
|
||||
|
||||
switch (entry.level) {
|
||||
case 'debug':
|
||||
childLogger.debug(message, entry.context)
|
||||
break
|
||||
case 'warn':
|
||||
childLogger.warn(message, entry.context)
|
||||
break
|
||||
case 'error':
|
||||
childLogger.error(message, entry.context)
|
||||
break
|
||||
case 'info':
|
||||
default:
|
||||
childLogger.info(message, entry.context)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current buffer size (for testing/debugging)
|
||||
*/
|
||||
getBufferSize(): number {
|
||||
return this.buffer.length
|
||||
}
|
||||
|
||||
/**
|
||||
* Get discarded log count (for testing/debugging)
|
||||
*/
|
||||
getDiscardedCount(): number {
|
||||
return this.discardedCount
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset state (for testing)
|
||||
*/
|
||||
reset(): void {
|
||||
if (this.debounceTimer) {
|
||||
clearTimeout(this.debounceTimer)
|
||||
this.debounceTimer = null
|
||||
}
|
||||
this.buffer = []
|
||||
this.discardedCount = 0
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton state instance
|
||||
const state = new LoggerHandlerState()
|
||||
|
||||
/**
|
||||
* Register IPC handlers for logger
|
||||
*/
|
||||
export function registerLoggerHandlers(): void {
|
||||
// Use ipcMain.on with send() - fire-and-forget, non-blocking
|
||||
ipcMain.on(IPC_CHANNELS.LOGGER_FORWARD, (_event, entry: LogEntry) => {
|
||||
// Validate entry
|
||||
if (!entry || typeof entry.level !== 'string' || typeof entry.message !== 'string') {
|
||||
log.warn('Received invalid log entry', { entry })
|
||||
return
|
||||
}
|
||||
|
||||
// Add to buffer for batch processing
|
||||
const buffered = state.addEntry(entry)
|
||||
|
||||
if (!buffered && process.env.NODE_ENV !== 'production') {
|
||||
// In development, log when entries are discarded
|
||||
log.debug('Log entry discarded due to circuit breaker', {
|
||||
level: entry.level,
|
||||
message: entry.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
log.info('Logger IPC handler registered', {
|
||||
channel: IPC_CHANNELS.LOGGER_FORWARD,
|
||||
debounceMs: BATCH_CONFIG.DEBOUNCE_MS,
|
||||
maxBatchSize: BATCH_CONFIG.MAX_BATCH_SIZE,
|
||||
circuitBreakerThreshold: BATCH_CONFIG.CIRCUIT_BREAKER_THRESHOLD
|
||||
})
|
||||
}
|
||||
|
||||
// Export for testing
|
||||
export { state }
|
||||
126
src/main/ipc/material-type-handler.ts
Normal file
126
src/main/ipc/material-type-handler.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* IPC handlers for material type management operations
|
||||
*
|
||||
* Provides endpoints for:
|
||||
* - Getting all material type records
|
||||
* - Getting records by manager
|
||||
* - Getting list of managers
|
||||
* - Upserting (insert/update) records
|
||||
* - Deleting records
|
||||
* - Batch operations
|
||||
*/
|
||||
|
||||
import { ipcMain } from 'electron'
|
||||
import {
|
||||
MaterialsTypeToBeDeletedDAO,
|
||||
type MaterialTypeRecord,
|
||||
type MaterialTypeBatchRequest
|
||||
} from '../services/database/materials-type-to-be-deleted-dao'
|
||||
import { createLogger } from '../services/logger'
|
||||
import { IPC_CHANNELS } from '../../shared/ipc-channels'
|
||||
import { ValidationError } from '../types/errors'
|
||||
import { withErrorHandling, type IpcResult } from './index'
|
||||
|
||||
const log = createLogger('MaterialTypeHandler')
|
||||
|
||||
/**
|
||||
* Register IPC handlers for material type operations
|
||||
*/
|
||||
export function registerMaterialTypeHandlers(): void {
|
||||
const dao = new MaterialsTypeToBeDeletedDAO()
|
||||
|
||||
/**
|
||||
* Get all material type records
|
||||
*/
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.MATERIAL_TYPE_GET_ALL,
|
||||
async (): Promise<IpcResult<MaterialTypeRecord[]>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const records = await dao.getAllMaterials()
|
||||
return records
|
||||
}, 'materialType:getAll')
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Get material types by manager
|
||||
*/
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.MATERIAL_TYPE_GET_BY_MANAGER,
|
||||
async (_event, managerName: string): Promise<IpcResult<MaterialTypeRecord[]>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const records = await dao.getMaterialsByManager(managerName)
|
||||
return records
|
||||
}, 'materialType:getByManager')
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Get list of managers
|
||||
*/
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.MATERIAL_TYPE_GET_MANAGERS,
|
||||
async (): Promise<IpcResult<string[]>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const managers = await dao.getManagers()
|
||||
return managers
|
||||
}, 'materialType:getManagers')
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Upsert (insert or update) a material type record
|
||||
*/
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.MATERIAL_TYPE_UPSERT,
|
||||
async (
|
||||
_event,
|
||||
{ materialName, managerName }: { materialName: string; managerName: string }
|
||||
): Promise<IpcResult<{ updated: boolean }>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const result = await dao.upsertMaterial(materialName, managerName)
|
||||
if (!result) {
|
||||
throw new ValidationError('Failed to upsert material type', 'VAL_INVALID_INPUT')
|
||||
}
|
||||
return { updated: true }
|
||||
}, 'materialType:upsert')
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Delete a material type record
|
||||
*/
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.MATERIAL_TYPE_DELETE,
|
||||
async (
|
||||
_event,
|
||||
{ materialName, managerName }: { materialName: string; managerName: string }
|
||||
): Promise<IpcResult<{ deleted: boolean }>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const result = await dao.deleteMaterial(materialName, managerName)
|
||||
if (!result) {
|
||||
throw new ValidationError('Failed to delete material type', 'VAL_INVALID_INPUT')
|
||||
}
|
||||
return { deleted: true }
|
||||
}, 'materialType:delete')
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Batch operation for material types (insert, update, delete)
|
||||
*/
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.MATERIAL_TYPE_UPSERT_BATCH,
|
||||
async (
|
||||
_event,
|
||||
request: MaterialTypeBatchRequest
|
||||
): Promise<IpcResult<{ stats: { total: number; success: number; failed: number } }>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const stats = await dao.upsertBatch(request)
|
||||
return { stats }
|
||||
}, 'materialType:upsertBatch')
|
||||
}
|
||||
)
|
||||
|
||||
log.info('Material type handlers registered')
|
||||
}
|
||||
183
src/main/ipc/report-handler.ts
Normal file
183
src/main/ipc/report-handler.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { IPC_CHANNELS } from '../../shared/ipc-channels'
|
||||
import { withErrorHandling, type IpcResult } from './index'
|
||||
import { createLogger } from '../services/logger'
|
||||
import { ConfigManager } from '../services/config/config-manager'
|
||||
import { RustfsService } from '../services/rustfs'
|
||||
import { ListObjectsV2Command, S3Client } from '@aws-sdk/client-s3'
|
||||
|
||||
const log = createLogger('ReportHandler')
|
||||
|
||||
export interface ReportMetadata {
|
||||
key: string
|
||||
filename: string
|
||||
username: string
|
||||
lastModified?: Date
|
||||
size?: number
|
||||
}
|
||||
|
||||
function getRustfsService(): RustfsService | null {
|
||||
const configManager = ConfigManager.getInstance()
|
||||
const config = configManager.getConfig()
|
||||
|
||||
if (config.rustfs?.enabled && config.rustfs.endpoint) {
|
||||
return new RustfsService({ config: config.rustfs })
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function registerReportHandlers(): void {
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.REPORT_LIST_ALL,
|
||||
async (): Promise<IpcResult<ReportMetadata[]>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const rustfs = getRustfsService()
|
||||
if (!rustfs) {
|
||||
throw new Error('RustFS is not configured or enabled')
|
||||
}
|
||||
|
||||
const configManager = ConfigManager.getInstance()
|
||||
const config = configManager.getConfig()
|
||||
|
||||
// Create a direct S3Client since RustfsService doesn't expose listObjects natively easily
|
||||
const client = new S3Client({
|
||||
region: config.rustfs?.region || 'us-east-1',
|
||||
endpoint: config.rustfs?.endpoint || '',
|
||||
credentials: {
|
||||
accessKeyId: config.rustfs?.accessKey || '',
|
||||
secretAccessKey: config.rustfs?.secretKey || ''
|
||||
},
|
||||
forcePathStyle: true
|
||||
})
|
||||
|
||||
log.info('Fetching all reports from RustFS')
|
||||
const input = {
|
||||
Bucket: config.rustfs?.bucket || '',
|
||||
Prefix: 'reports/cleaner/'
|
||||
}
|
||||
|
||||
const command = new ListObjectsV2Command(input)
|
||||
const response = await client.send(command)
|
||||
|
||||
const reports: ReportMetadata[] = []
|
||||
|
||||
if (response.Contents) {
|
||||
for (const item of response.Contents) {
|
||||
if (item.Key && item.Key.endsWith('.md')) {
|
||||
// reports/cleaner/{username}/{filename}
|
||||
const parts = item.Key.split('/')
|
||||
if (parts.length >= 4) {
|
||||
const username = parts[2]
|
||||
const filename = parts.slice(3).join('/')
|
||||
reports.push({
|
||||
key: item.Key,
|
||||
filename,
|
||||
username,
|
||||
lastModified: item.LastModified,
|
||||
size: item.Size
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by lastModified descending
|
||||
reports.sort((a, b) => {
|
||||
if (a.lastModified && b.lastModified) {
|
||||
return b.lastModified.getTime() - a.lastModified.getTime()
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
return reports
|
||||
}, 'report:listAll')
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.REPORT_LIST_BY_USER,
|
||||
async (_event, username: string): Promise<IpcResult<ReportMetadata[]>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const rustfs = getRustfsService()
|
||||
if (!rustfs) {
|
||||
throw new Error('RustFS is not configured or enabled')
|
||||
}
|
||||
|
||||
const configManager = ConfigManager.getInstance()
|
||||
const config = configManager.getConfig()
|
||||
|
||||
const client = new S3Client({
|
||||
region: config.rustfs?.region || 'us-east-1',
|
||||
endpoint: config.rustfs?.endpoint || '',
|
||||
credentials: {
|
||||
accessKeyId: config.rustfs?.accessKey || '',
|
||||
secretAccessKey: config.rustfs?.secretKey || ''
|
||||
},
|
||||
forcePathStyle: true
|
||||
})
|
||||
|
||||
log.info('Fetching reports from RustFS for user', { username })
|
||||
const input = {
|
||||
Bucket: config.rustfs?.bucket || '',
|
||||
Prefix: `reports/cleaner/${username}/`
|
||||
}
|
||||
|
||||
const command = new ListObjectsV2Command(input)
|
||||
const response = await client.send(command)
|
||||
|
||||
const reports: ReportMetadata[] = []
|
||||
|
||||
if (response.Contents) {
|
||||
for (const item of response.Contents) {
|
||||
if (item.Key && item.Key.endsWith('.md')) {
|
||||
const parts = item.Key.split('/')
|
||||
if (parts.length >= 4) {
|
||||
const itemUsername = parts[2]
|
||||
const filename = parts.slice(3).join('/')
|
||||
reports.push({
|
||||
key: item.Key,
|
||||
filename,
|
||||
username: itemUsername,
|
||||
lastModified: item.LastModified,
|
||||
size: item.Size
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by lastModified descending
|
||||
reports.sort((a, b) => {
|
||||
if (a.lastModified && b.lastModified) {
|
||||
return b.lastModified.getTime() - a.lastModified.getTime()
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
return reports
|
||||
}, 'report:listByUser')
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.REPORT_DOWNLOAD,
|
||||
async (_event, key: string): Promise<IpcResult<string>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const rustfs = getRustfsService()
|
||||
if (!rustfs) {
|
||||
throw new Error('RustFS is not configured or enabled')
|
||||
}
|
||||
|
||||
log.info('Downloading report from RustFS', { key })
|
||||
const result = await rustfs.downloadFile(key)
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to download report')
|
||||
}
|
||||
|
||||
// Convert buffer to string
|
||||
return result.content.toString('utf-8')
|
||||
}, 'report:download')
|
||||
}
|
||||
)
|
||||
}
|
||||
130
src/main/ipc/resolver-handler.ts
Normal file
130
src/main/ipc/resolver-handler.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* IPC handlers for Order Number Resolver
|
||||
*
|
||||
* Provides APIs for the renderer process to:
|
||||
* - Resolve productionIDs and 生产订单号 to production order numbers
|
||||
* - Validate input formats
|
||||
*/
|
||||
|
||||
import { ipcMain } from 'electron'
|
||||
import { create, type IDatabaseService } from '../services/database'
|
||||
import { OrderNumberResolver } from '../services/erp/order-resolver'
|
||||
import { createLogger } from '../services/logger'
|
||||
import type { OrderMapping, ResolutionStats } from '../services/erp/order-resolver'
|
||||
import { IPC_CHANNELS } from '../../shared/ipc-channels'
|
||||
import { withErrorHandling, type IpcResult } from './index'
|
||||
|
||||
const log = createLogger('ResolverHandler')
|
||||
|
||||
/**
|
||||
* Resolver input from renderer
|
||||
*/
|
||||
export interface ResolverInput {
|
||||
/** List of order numbers/productionIDs to resolve */
|
||||
inputs: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolver response to renderer
|
||||
*/
|
||||
export interface ResolverResponse {
|
||||
/** Whether the resolution was successful */
|
||||
success: boolean
|
||||
/** Resolved order mappings */
|
||||
mappings?: OrderMapping[]
|
||||
/** Valid production order numbers ready for use */
|
||||
validOrderNumbers?: string[]
|
||||
/** Warning messages for invalid inputs */
|
||||
warnings?: string[]
|
||||
/** Resolution statistics */
|
||||
stats?: ResolutionStats
|
||||
/** Error message if failed */
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Register IPC handlers for order number resolver
|
||||
*/
|
||||
export function registerResolverHandlers(): void {
|
||||
/**
|
||||
* Resolve order numbers
|
||||
* Converts productionIDs and 生产订单号 to production order numbers
|
||||
*/
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.RESOLVER_RESOLVE,
|
||||
async (_event, input: ResolverInput): Promise<IpcResult<ResolverResponse>> => {
|
||||
let dbService: IDatabaseService | null = null
|
||||
|
||||
return withErrorHandling(async () => {
|
||||
// Create database service using factory
|
||||
log.info('Connecting to database for resolution', { inputCount: input.inputs.length })
|
||||
dbService = await create()
|
||||
|
||||
// Create resolver and resolve inputs
|
||||
const resolver = new OrderNumberResolver(dbService)
|
||||
const mappings = await resolver.resolve(input.inputs)
|
||||
|
||||
// Get valid order numbers and warnings
|
||||
const validOrderNumbers = resolver.getValidOrderNumbers(mappings)
|
||||
const warnings = resolver.getWarnings(mappings)
|
||||
const stats = resolver.getStats(mappings)
|
||||
|
||||
log.info('Resolution completed', {
|
||||
inputCount: input.inputs.length,
|
||||
validCount: validOrderNumbers.length,
|
||||
warningCount: warnings.length
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
mappings,
|
||||
validOrderNumbers,
|
||||
warnings,
|
||||
stats
|
||||
}
|
||||
}, 'resolver:resolve').finally(async () => {
|
||||
// Clean up database connection
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Validate input format only (without database lookup)
|
||||
*/
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.RESOLVER_VALIDATE_FORMAT,
|
||||
async (
|
||||
_event,
|
||||
inputs: string[]
|
||||
): Promise<
|
||||
IpcResult<Array<{ input: string; type: 'productionId' | 'orderNumber' | 'unknown' }>>
|
||||
> => {
|
||||
return withErrorHandling(async () => {
|
||||
// Create a mock resolver without database connection
|
||||
const resolver = new OrderNumberResolver({
|
||||
isConnected: () => false,
|
||||
type: 'mysql'
|
||||
} as IDatabaseService)
|
||||
|
||||
const results = inputs.map((input) => ({
|
||||
input,
|
||||
type: resolver.recognizeType(input)
|
||||
}))
|
||||
|
||||
log.debug('Format validation completed', { inputCount: inputs.length })
|
||||
|
||||
return results
|
||||
}, 'resolver:validateFormat')
|
||||
}
|
||||
)
|
||||
}
|
||||
200
src/main/ipc/settings-handler.ts
Normal file
200
src/main/ipc/settings-handler.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { ConfigManager } from '../services/config/config-manager'
|
||||
import { SessionManager } from '../services/user/session-manager'
|
||||
import { UserErpConfigService } from '../services/user/user-erp-config-service'
|
||||
import { MySqlService } from '../services/database/mysql'
|
||||
import { SqlServerService } from '../services/database/sql-server'
|
||||
import { createLogger } from '../services/logger'
|
||||
import { logAudit } from '../services/logger/audit-logger'
|
||||
import type { UserType, ConnectionTestResult, SaveSettingsResult } from '../types/settings.types'
|
||||
import { IPC_CHANNELS } from '../../shared/ipc-channels'
|
||||
import { ValidationError } from '../types/errors'
|
||||
import { withErrorHandling, type IpcResult } from './index'
|
||||
import type { CleanerConfig } from '../types/config.schema'
|
||||
|
||||
const log = createLogger('SettingsHandler')
|
||||
|
||||
type ErpSettingsPayload = {
|
||||
erp?: {
|
||||
username?: string
|
||||
password?: string
|
||||
}
|
||||
}
|
||||
|
||||
export function registerSettingsHandlers(): void {
|
||||
const configManager = ConfigManager.getInstance()
|
||||
const sessionManager = SessionManager.getInstance()
|
||||
const erpConfigService = UserErpConfigService.getInstance()
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.SETTINGS_GET_USER_TYPE, async (): Promise<IpcResult<UserType>> => {
|
||||
return withErrorHandling(
|
||||
async () => (sessionManager.getUserType() as UserType) || 'Guest',
|
||||
'settings:getUserType'
|
||||
)
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.SETTINGS_GET_SETTINGS,
|
||||
async (): Promise<IpcResult<{ erp: { username: string; password: string } }>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const userErpConfig = await erpConfigService.getCurrentUserErpConfig()
|
||||
return {
|
||||
erp: {
|
||||
username: userErpConfig?.username || '',
|
||||
password: userErpConfig?.password || ''
|
||||
}
|
||||
}
|
||||
}, 'settings:getSettings')
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.SETTINGS_SAVE_SETTINGS,
|
||||
async (_event, settings: ErpSettingsPayload): Promise<IpcResult<SaveSettingsResult>> => {
|
||||
return withErrorHandling(async () => {
|
||||
if (settings.erp) {
|
||||
const currentUser = sessionManager.getUserInfo()
|
||||
if (!currentUser) {
|
||||
throw new ValidationError('未找到当前用户', 'VAL_INVALID_INPUT')
|
||||
}
|
||||
|
||||
await erpConfigService.updateCurrentUserErpConfig({
|
||||
username: settings.erp.username || '',
|
||||
password: settings.erp.password || ''
|
||||
})
|
||||
|
||||
// Audit log: SETTINGS_CHANGE (non-blocking)
|
||||
const os = await import('os')
|
||||
logAudit('SETTINGS_CHANGE', String(currentUser.id), {
|
||||
username: currentUser.username,
|
||||
computerName: os.hostname(),
|
||||
resource: 'ERP_CONFIG',
|
||||
status: 'success',
|
||||
metadata: { changeType: 'erp_credentials', usernameChanged: !!settings.erp.username }
|
||||
}).catch((err) => log.warn('Failed to write audit log', { err }))
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
}, 'settings:saveSettings')
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.SETTINGS_RESET_DEFAULTS,
|
||||
async (): Promise<IpcResult<SaveSettingsResult>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const userType = sessionManager.getUserType()
|
||||
if (userType !== 'Admin') {
|
||||
throw new ValidationError('只有管理员可以恢复默认设置', 'VAL_INVALID_INPUT')
|
||||
}
|
||||
|
||||
const success = await configManager.resetToDefaults()
|
||||
if (!success) {
|
||||
throw new ValidationError('恢复默认设置失败', 'VAL_INVALID_INPUT')
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
}, 'settings:resetDefaults')
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.SETTINGS_TEST_DB_CONNECTION,
|
||||
async (): Promise<IpcResult<ConnectionTestResult>> => {
|
||||
return withErrorHandling(async () => {
|
||||
log.info('Testing database connection')
|
||||
const config = configManager.getConfig()
|
||||
const dbType = config.database.activeType
|
||||
|
||||
if (dbType === 'mysql') {
|
||||
const dbConfig = config.database.mysql
|
||||
if (!dbConfig.host || !dbConfig.database || !dbConfig.username) {
|
||||
return {
|
||||
success: false,
|
||||
message: '请先配置 MySQL 主机、数据库名和用户名'
|
||||
}
|
||||
}
|
||||
|
||||
const mysqlService = new MySqlService({
|
||||
host: dbConfig.host,
|
||||
port: dbConfig.port,
|
||||
user: dbConfig.username,
|
||||
password: dbConfig.password,
|
||||
database: dbConfig.database
|
||||
})
|
||||
|
||||
try {
|
||||
await mysqlService.connect()
|
||||
await mysqlService.disconnect()
|
||||
return {
|
||||
success: true,
|
||||
message: 'MySQL 数据库连接测试成功!'
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '连接失败'
|
||||
return {
|
||||
success: false,
|
||||
message: `MySQL 数据库连接测试失败:${message}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const dbConfig = config.database.sqlserver
|
||||
if (!dbConfig.server || !dbConfig.database || !dbConfig.username) {
|
||||
return {
|
||||
success: false,
|
||||
message: '请先配置 SQL Server 服务器、数据库名和用户名'
|
||||
}
|
||||
}
|
||||
|
||||
const sqlServerService = new SqlServerService({
|
||||
server: dbConfig.server,
|
||||
port: dbConfig.port,
|
||||
user: dbConfig.username,
|
||||
password: dbConfig.password,
|
||||
database: dbConfig.database,
|
||||
options: {
|
||||
trustServerCertificate: dbConfig.trustServerCertificate
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
await sqlServerService.connect()
|
||||
await sqlServerService.disconnect()
|
||||
return {
|
||||
success: true,
|
||||
message: 'SQL Server 数据库连接测试成功!'
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '连接失败'
|
||||
return {
|
||||
success: false,
|
||||
message: `SQL Server 数据库连接测试失败:${message}`
|
||||
}
|
||||
}
|
||||
}, 'settings:testDbConnection')
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.CONFIG_GET_CLEANER, async (): Promise<IpcResult<CleanerConfig>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const configManager = ConfigManager.getInstance()
|
||||
const config = configManager.getConfig()
|
||||
return config.cleaner
|
||||
}, 'config:getCleaner')
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CONFIG_UPDATE_CLEANER,
|
||||
async (_event, updates: Partial<CleanerConfig>): Promise<IpcResult<CleanerConfig>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const configManager = ConfigManager.getInstance()
|
||||
const result = await configManager.updateConfig({ cleaner: updates as CleanerConfig })
|
||||
if (!result.success) {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
return configManager.getConfig().cleaner
|
||||
}, 'config:updateCleaner')
|
||||
}
|
||||
)
|
||||
}
|
||||
147
src/main/ipc/user-erp-config-handler.ts
Normal file
147
src/main/ipc/user-erp-config-handler.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { UserErpConfigService } from '../services/user/user-erp-config-service'
|
||||
import { ErpAuthService } from '../services/erp/erp-auth'
|
||||
import { ConfigManager } from '../services/config/config-manager'
|
||||
import { createLogger } from '../services/logger'
|
||||
import { SessionManager } from '../services/user/session-manager'
|
||||
import { IPC_CHANNELS } from '../../shared/ipc-channels'
|
||||
import { ValidationError } from '../types/errors'
|
||||
import { withErrorHandling, type IpcResult } from './index'
|
||||
|
||||
const log = createLogger('UserErpConfigHandler')
|
||||
|
||||
export interface ErpCredentialsRequest {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface ErpConfigResponse {
|
||||
success: boolean
|
||||
config?: {
|
||||
url: string
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface ConnectionTestResult {
|
||||
success: boolean
|
||||
message?: string
|
||||
}
|
||||
|
||||
export function registerUserErpConfigHandlers(): void {
|
||||
const erpConfigService = UserErpConfigService.getInstance()
|
||||
const sessionManager = SessionManager.getInstance()
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.USER_ERP_CONFIG_GET_CURRENT,
|
||||
async (): Promise<IpcResult<ErpConfigResponse>> => {
|
||||
return withErrorHandling(async () => {
|
||||
log.info('Fetching current user ERP credentials')
|
||||
const credentials = await erpConfigService.getCurrentUserErpConfig()
|
||||
|
||||
if (!credentials) {
|
||||
throw new ValidationError(
|
||||
'未找到 ERP 配置。请先配置 ERP 账号和密码。',
|
||||
'VAL_INVALID_INPUT'
|
||||
)
|
||||
}
|
||||
|
||||
const configManager = ConfigManager.getInstance()
|
||||
const globalConfig = configManager.getConfig()
|
||||
|
||||
return {
|
||||
success: true,
|
||||
config: {
|
||||
url: globalConfig.erp.url,
|
||||
username: credentials.username,
|
||||
password: credentials.password
|
||||
}
|
||||
}
|
||||
}, 'user-erp-config:getCurrent')
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.USER_ERP_CONFIG_UPDATE,
|
||||
async (_event, credentials: ErpCredentialsRequest): Promise<IpcResult<ErpConfigResponse>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const updated = await erpConfigService.updateCurrentUserErpConfig(credentials)
|
||||
if (!updated) {
|
||||
throw new ValidationError('更新 ERP 配置失败', 'VAL_INVALID_INPUT')
|
||||
}
|
||||
|
||||
const configManager = ConfigManager.getInstance()
|
||||
const globalConfig = configManager.getConfig()
|
||||
|
||||
return {
|
||||
success: true,
|
||||
config: {
|
||||
url: globalConfig.erp.url,
|
||||
username: credentials.username,
|
||||
password: credentials.password
|
||||
}
|
||||
}
|
||||
}, 'user-erp-config:update')
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.USER_ERP_CONFIG_TEST_CONNECTION,
|
||||
async (
|
||||
_event,
|
||||
credentials: ErpCredentialsRequest
|
||||
): Promise<IpcResult<ConnectionTestResult>> => {
|
||||
return withErrorHandling(async () => {
|
||||
if (!credentials.username || !credentials.password) {
|
||||
throw new ValidationError(
|
||||
'ERP 配置不完整,请确保用户名和密码都已填写',
|
||||
'VAL_MISSING_REQUIRED'
|
||||
)
|
||||
}
|
||||
|
||||
const configManager = ConfigManager.getInstance()
|
||||
const globalConfig = configManager.getConfig()
|
||||
const authService = new ErpAuthService({
|
||||
url: globalConfig.erp.url,
|
||||
username: credentials.username,
|
||||
password: credentials.password,
|
||||
headless: true
|
||||
})
|
||||
|
||||
try {
|
||||
await authService.login()
|
||||
return {
|
||||
success: true,
|
||||
message: 'ERP 连接测试成功'
|
||||
}
|
||||
} finally {
|
||||
await authService.close().catch(() => {})
|
||||
}
|
||||
}, 'user-erp-config:testConnection')
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.USER_ERP_CONFIG_GET_ALL,
|
||||
async (): Promise<
|
||||
IpcResult<
|
||||
Array<{
|
||||
username: string
|
||||
erpUrl: string
|
||||
erpUsername: string
|
||||
}>
|
||||
>
|
||||
> => {
|
||||
return withErrorHandling(async () => {
|
||||
if (!sessionManager.isAdmin()) {
|
||||
throw new ValidationError('只有管理员可以查看全部用户 ERP 配置', 'VAL_INVALID_INPUT')
|
||||
}
|
||||
|
||||
const configs = await erpConfigService.getAllUsersErpConfig()
|
||||
return configs
|
||||
}, 'user-erp-config:getAll')
|
||||
}
|
||||
)
|
||||
}
|
||||
890
src/main/ipc/validation-handler.ts
Normal file
890
src/main/ipc/validation-handler.ts
Normal file
@@ -0,0 +1,890 @@
|
||||
/**
|
||||
* IPC handlers for material validation operations
|
||||
*
|
||||
* Provides endpoints for:
|
||||
* - Running material validation from database
|
||||
* - Getting/setting materials to be deleted
|
||||
* - Manager-based filtering
|
||||
*/
|
||||
|
||||
import { ipcMain } from 'electron'
|
||||
import { MySqlService } from '../services/database/mysql'
|
||||
import { SqlServerService } from '../services/database/sql-server'
|
||||
import { MaterialsToBeDeletedDAO } from '../services/database/materials-to-be-deleted-dao'
|
||||
import { DiscreteMaterialPlanDAO } from '../services/database/discrete-material-plan-dao'
|
||||
import { ConfigManager } from '../services/config/config-manager'
|
||||
import { createLogger } from '../services/logger'
|
||||
import type {
|
||||
ValidationRequest,
|
||||
ValidationResponse,
|
||||
MaterialUpsertBatchRequest,
|
||||
MaterialDeleteRequest,
|
||||
MaterialOperationResponse,
|
||||
ValidationResult,
|
||||
MaterialRecordSummary
|
||||
} from '../types/validation.types'
|
||||
import { IPC_CHANNELS } from '../../shared/ipc-channels'
|
||||
|
||||
const log = createLogger('ValidationHandler')
|
||||
|
||||
/**
|
||||
* Shared state for Production IDs from extractor page
|
||||
* This is a simple in-memory store for sharing Production IDs between pages
|
||||
*/
|
||||
const sharedProductionIdsBySender = new Map<number, Set<string>>()
|
||||
|
||||
/**
|
||||
* Set shared Production IDs
|
||||
*/
|
||||
export function setSharedProductionIds(senderId: number, ids: string[]): void {
|
||||
sharedProductionIdsBySender.set(senderId, new Set(ids))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get shared Production IDs
|
||||
*/
|
||||
export function getSharedProductionIds(senderId: number): string[] {
|
||||
const senderSet = sharedProductionIdsBySender.get(senderId)
|
||||
return senderSet ? [...senderSet] : []
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear shared Production IDs
|
||||
*/
|
||||
export function clearSharedProductionIds(senderId: number): void {
|
||||
sharedProductionIdsBySender.delete(senderId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get database service for validation operations (MySQL or SQL Server)
|
||||
*/
|
||||
async function getValidationDatabaseService(): Promise<MySqlService | SqlServerService> {
|
||||
const configManager = ConfigManager.getInstance()
|
||||
const config = configManager.getConfig()
|
||||
const dbType = configManager.getDatabaseType()
|
||||
|
||||
if (dbType === 'sqlserver') {
|
||||
const dbConfig = config.database.sqlserver
|
||||
const sqlServerService = new SqlServerService({
|
||||
server: dbConfig.server,
|
||||
port: dbConfig.port,
|
||||
user: dbConfig.username,
|
||||
password: dbConfig.password,
|
||||
database: dbConfig.database,
|
||||
options: {
|
||||
encrypt: false,
|
||||
trustServerCertificate: dbConfig.trustServerCertificate
|
||||
}
|
||||
})
|
||||
await sqlServerService.connect()
|
||||
return sqlServerService
|
||||
} else {
|
||||
const dbConfig = config.database.mysql
|
||||
const mysqlService = new MySqlService({
|
||||
host: dbConfig.host,
|
||||
port: dbConfig.port,
|
||||
user: dbConfig.username,
|
||||
password: dbConfig.password,
|
||||
database: dbConfig.database
|
||||
})
|
||||
await mysqlService.connect()
|
||||
return mysqlService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get table name based on database type
|
||||
* Converts MySQL schema_tablename format to SQL Server [schema].[tablename] format
|
||||
* e.g., productionContractData_26年压力表合同数据 -> [productionContractData].[26年压力表合同数据]
|
||||
* dbo_MaterialsToBeDeleted -> [dbo].[MaterialsToBeDeleted]
|
||||
*/
|
||||
function getTableName(mysqlTableName: string): string {
|
||||
const configManager = ConfigManager.getInstance()
|
||||
const dbType = configManager.getDatabaseType()
|
||||
|
||||
if (dbType === 'sqlserver') {
|
||||
// Find the FIRST underscore to split schema and table name
|
||||
// This handles patterns like: schema_tablename
|
||||
const firstUnderscoreIndex = mysqlTableName.indexOf('_')
|
||||
if (firstUnderscoreIndex > 0) {
|
||||
const schema = mysqlTableName.substring(0, firstUnderscoreIndex)
|
||||
const tableName = mysqlTableName.substring(firstUnderscoreIndex + 1)
|
||||
return `[${schema}].[${tableName}]`
|
||||
}
|
||||
// If no underscore found, default to dbo schema
|
||||
return `[dbo].[${mysqlTableName}]`
|
||||
}
|
||||
return mysqlTableName
|
||||
}
|
||||
|
||||
/**
|
||||
* Read Production IDs from file
|
||||
*/
|
||||
function readProductionIds(filePath: string): string[] {
|
||||
const fs = require('fs')
|
||||
const content = fs.readFileSync(filePath, 'utf-8') as string
|
||||
return content
|
||||
.split('\n')
|
||||
.map((line: string) => line.trim())
|
||||
.filter((line: string) => line.length > 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify input type (production ID or order number)
|
||||
*/
|
||||
function identifyInputType(input: string): 'production_id' | 'order_number' | 'unknown' {
|
||||
// Order number: SC + 14 digits
|
||||
if (/^SC\d{14}$/.test(input)) {
|
||||
return 'order_number'
|
||||
}
|
||||
// Production ID: 2 digits + 1 letter + 1-6 digits
|
||||
if (/^\d{2}[A-Za-z]\d{1,6}$/.test(input)) {
|
||||
return 'production_id'
|
||||
}
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get source numbers from inputs
|
||||
*/
|
||||
async function getSourceNumbersFromInputs(
|
||||
inputs: string[],
|
||||
dbService: MySqlService | SqlServerService
|
||||
): Promise<string[]> {
|
||||
const productionIds: string[] = []
|
||||
const orderNumbers: string[] = []
|
||||
const configManager = ConfigManager.getInstance()
|
||||
const dbType = configManager.getDatabaseType()
|
||||
const isSqlServer = dbType === 'sqlserver'
|
||||
|
||||
for (const item of inputs) {
|
||||
const type = identifyInputType(item)
|
||||
if (type === 'order_number') {
|
||||
orderNumbers.push(item)
|
||||
} else if (type === 'production_id') {
|
||||
productionIds.push(item)
|
||||
}
|
||||
}
|
||||
|
||||
// Query production contract data for production IDs
|
||||
// Table name in MySQL: productionContractData_26年压力表合同数据
|
||||
// Column name: 生产订单号 (SourceNumber)
|
||||
if (productionIds.length > 0) {
|
||||
const contractTableName = getTableName('productionContractData_26年压力表合同数据')
|
||||
const batchSize = 2000
|
||||
|
||||
if (isSqlServer) {
|
||||
const sql = require('mssql')
|
||||
const allOrderNumbers: string[] = []
|
||||
|
||||
for (let i = 0; i < productionIds.length; i += batchSize) {
|
||||
const batch = productionIds.slice(i, i + batchSize)
|
||||
const placeholders = batch.map((_, idx) => `@p${idx}`).join(',')
|
||||
const params: Record<string, { value: string; type: any }> = {}
|
||||
|
||||
batch.forEach((id, idx) => {
|
||||
params[`p${idx}`] = { value: id, type: sql.NVarChar }
|
||||
})
|
||||
|
||||
const contractSql = `
|
||||
SELECT DISTINCT 生产订单号
|
||||
FROM ${contractTableName}
|
||||
WHERE 总排号 IN (${placeholders})
|
||||
`
|
||||
const contractResult = await (dbService as SqlServerService).queryWithParams(
|
||||
contractSql,
|
||||
params
|
||||
)
|
||||
const dbOrderNumbers = contractResult.rows.map((row) => row.生产订单号 as string)
|
||||
allOrderNumbers.push(...dbOrderNumbers)
|
||||
}
|
||||
|
||||
orderNumbers.push(...allOrderNumbers)
|
||||
} else {
|
||||
const allOrderNumbers: string[] = []
|
||||
|
||||
for (let i = 0; i < productionIds.length; i += batchSize) {
|
||||
const batch = productionIds.slice(i, i + batchSize)
|
||||
const placeholders = batch.map(() => '?').join(',')
|
||||
const contractSql = `
|
||||
SELECT DISTINCT 生产订单号
|
||||
FROM ${contractTableName}
|
||||
WHERE 总排号 IN (${placeholders})
|
||||
`
|
||||
const contractResult = await (dbService as MySqlService).query(contractSql, batch)
|
||||
const dbOrderNumbers = contractResult.rows.map((row) => row.生产订单号 as string)
|
||||
allOrderNumbers.push(...dbOrderNumbers)
|
||||
}
|
||||
|
||||
orderNumbers.push(...allOrderNumbers)
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate
|
||||
return [...new Set(orderNumbers)]
|
||||
}
|
||||
|
||||
/**
|
||||
* Register IPC handlers for validation operations
|
||||
*/
|
||||
export function registerValidationHandlers(): void {
|
||||
// ==================== VALIDATION ====================
|
||||
|
||||
/**
|
||||
* Run material validation from database
|
||||
*/
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.VALIDATION_VALIDATE,
|
||||
async (event, request: ValidationRequest): Promise<ValidationResponse> => {
|
||||
let dbService: MySqlService | SqlServerService | null = null
|
||||
|
||||
try {
|
||||
// Get current user info
|
||||
const sessionManager = (
|
||||
await import('../services/user/session-manager')
|
||||
).SessionManager.getInstance()
|
||||
|
||||
const userInfo = sessionManager.getUserInfo()
|
||||
if (!userInfo) {
|
||||
return {
|
||||
success: false,
|
||||
error: '用户未登录',
|
||||
stats: {
|
||||
totalRecords: 0,
|
||||
matchedCount: 0,
|
||||
markedCount: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isAdmin = userInfo.userType === 'Admin'
|
||||
const username = userInfo.username
|
||||
|
||||
log.info('Starting validation', { mode: request.mode, user: username, isAdmin })
|
||||
|
||||
// Connect to database
|
||||
dbService = await getValidationDatabaseService()
|
||||
const configManager = ConfigManager.getInstance()
|
||||
const dbType = configManager.getDatabaseType()
|
||||
const isSqlServer = dbType === 'sqlserver'
|
||||
|
||||
let sourceNumbers: string[] | null = null
|
||||
|
||||
// Get source numbers based on mode
|
||||
if (request.mode === 'database_filtered') {
|
||||
if (request.useSharedProductionIds) {
|
||||
// Use shared Production IDs from extractor page
|
||||
const sharedIds = getSharedProductionIds(event.sender.id)
|
||||
log.info(`Using ${sharedIds.length} shared Production IDs`)
|
||||
|
||||
if (sharedIds.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: '没有可用的共享 Production ID。请在数据提取页面输入 Production ID。',
|
||||
stats: {
|
||||
totalRecords: 0,
|
||||
matchedCount: 0,
|
||||
markedCount: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sourceNumbers = await getSourceNumbersFromInputs(sharedIds, dbService)
|
||||
log.info(`Got ${sourceNumbers.length} source numbers from shared Production IDs`)
|
||||
|
||||
// Check if we got any order numbers from the shared Production IDs
|
||||
if (sourceNumbers.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
'共享的 Production ID 没有找到对应的订单数据。请确保在数据提取页面输入了有效的 Production ID 并成功获取了订单数据。',
|
||||
stats: {
|
||||
totalRecords: 0,
|
||||
matchedCount: 0,
|
||||
markedCount: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (request.productionIdFile) {
|
||||
// Read from file
|
||||
const inputs = readProductionIds(request.productionIdFile)
|
||||
log.info(`Read ${inputs.length} inputs from file`)
|
||||
sourceNumbers = await getSourceNumbersFromInputs(inputs, dbService)
|
||||
log.info(`Got ${sourceNumbers.length} source numbers`)
|
||||
|
||||
// Check if we got any order numbers from the file
|
||||
if (sourceNumbers.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
'文件中的 Production ID 没有找到对应的订单数据。请检查 Production ID 是否正确,或确保数据库中有对应的订单数据。',
|
||||
stats: {
|
||||
totalRecords: 0,
|
||||
matchedCount: 0,
|
||||
markedCount: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get material records from DiscreteMaterialPlanData
|
||||
const materialDao = new DiscreteMaterialPlanDAO()
|
||||
|
||||
let materialRecords: any[] = []
|
||||
|
||||
if (request.mode === 'database_full') {
|
||||
// Full table query with deduplication by MaterialCode
|
||||
materialRecords = await materialDao.queryAllDistinctByMaterialCode()
|
||||
} else if (sourceNumbers && sourceNumbers.length > 0) {
|
||||
// Filtered query by source numbers
|
||||
materialRecords = await materialDao.queryBySourceNumbersDistinct(sourceNumbers)
|
||||
}
|
||||
|
||||
if (materialRecords.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: '未找到物料记录。请检查数据库中是否有对应订单的物料数据。',
|
||||
stats: {
|
||||
totalRecords: 0,
|
||||
matchedCount: 0,
|
||||
markedCount: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get type keywords from MaterialsTypeToBeDeleted
|
||||
const typeKeywordTableName = getTableName('dbo_MaterialsTypeToBeDeleted')
|
||||
const typeKeywordSql = `
|
||||
SELECT MaterialName, ManagerName
|
||||
FROM ${typeKeywordTableName}
|
||||
WHERE MaterialName IS NOT NULL
|
||||
`
|
||||
const typeKeywordResult = isSqlServer
|
||||
? await (dbService as SqlServerService).query(typeKeywordSql)
|
||||
: await (dbService as MySqlService).query(typeKeywordSql)
|
||||
|
||||
const typeKeywords = typeKeywordResult.rows.map((row) => ({
|
||||
materialName: row.MaterialName as string,
|
||||
managerName: row.ManagerName as string
|
||||
}))
|
||||
|
||||
// Get marked material codes from MaterialsToBeDeleted
|
||||
const markedTableName = getTableName('dbo_MaterialsToBeDeleted')
|
||||
const markedSql = `
|
||||
SELECT MaterialCode, ManagerName
|
||||
FROM ${markedTableName}
|
||||
WHERE MaterialCode IS NOT NULL AND ManagerName IS NOT NULL
|
||||
`
|
||||
const markedResult = isSqlServer
|
||||
? await (dbService as SqlServerService).query(markedSql)
|
||||
: await (dbService as MySqlService).query(markedSql)
|
||||
|
||||
const markedCodesDict = new Map<string, string>()
|
||||
for (const row of markedResult.rows) {
|
||||
markedCodesDict.set(row.MaterialCode as string, row.ManagerName as string)
|
||||
}
|
||||
|
||||
// Match materials
|
||||
const results: ValidationResult[] = []
|
||||
for (const record of materialRecords) {
|
||||
const materialName = (record.MaterialName as string) || ''
|
||||
const materialCode = (record.MaterialCode as string) || ''
|
||||
const specification = (record.Specification as string) || ''
|
||||
const model = (record.Model as string) || ''
|
||||
|
||||
// Priority 1: Check MaterialsToBeDeleted (MaterialCode exact match)
|
||||
let managerName = markedCodesDict.get(materialCode) || null
|
||||
const isMarkedForDeletion = managerName !== null
|
||||
let matchedTypeKeyword: string | undefined = undefined
|
||||
|
||||
// Priority 2: Match with MaterialsTypeToBeDeleted (MaterialName contains)
|
||||
if (!managerName) {
|
||||
for (const typeKeyword of typeKeywords) {
|
||||
if (typeKeyword.materialName && materialName.includes(typeKeyword.materialName)) {
|
||||
matchedTypeKeyword = typeKeyword.materialName
|
||||
managerName = typeKeyword.managerName
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 3: User Override Match (only for non-admin users)
|
||||
// Override with current user's typeKeyword if available
|
||||
if (!isAdmin && username) {
|
||||
const userKeywords = typeKeywords.filter((tk) => tk.managerName === username)
|
||||
for (const userKeyword of userKeywords) {
|
||||
if (userKeyword.materialName && materialName.includes(userKeyword.materialName)) {
|
||||
matchedTypeKeyword = userKeyword.materialName
|
||||
managerName = userKeyword.managerName
|
||||
break // Force override with first match
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results.push({
|
||||
materialName,
|
||||
materialCode,
|
||||
specification,
|
||||
model,
|
||||
managerName: managerName || '',
|
||||
isMarkedForDeletion,
|
||||
matchedTypeKeyword
|
||||
})
|
||||
}
|
||||
|
||||
const markedCount = results.filter((r) => r.isMarkedForDeletion).length
|
||||
const matchedCount = results.filter((r) => r.managerName).length
|
||||
|
||||
return {
|
||||
success: true,
|
||||
results,
|
||||
stats: {
|
||||
totalRecords: results.length,
|
||||
matchedCount,
|
||||
markedCount
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
log.error('Validation error', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return {
|
||||
success: false,
|
||||
error: `Validation failed: ${message}`
|
||||
}
|
||||
} finally {
|
||||
if (dbService) {
|
||||
try {
|
||||
await dbService.disconnect()
|
||||
} catch (closeError) {
|
||||
log.warn('Error disconnecting database', {
|
||||
error: closeError instanceof Error ? closeError.message : String(closeError)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// ==================== MATERIAL OPERATIONS ====================
|
||||
|
||||
/**
|
||||
* Upsert batch materials to MaterialsToBeDeleted
|
||||
*/
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.MATERIALS_UPSERT_BATCH,
|
||||
async (_event, request: MaterialUpsertBatchRequest): Promise<MaterialOperationResponse> => {
|
||||
try {
|
||||
const dao = new MaterialsToBeDeletedDAO()
|
||||
const stats = await dao.upsertBatch(request.materials)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
stats
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
log.error('Upsert batch error', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return {
|
||||
success: false,
|
||||
error: `Upsert failed: ${message}`
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Delete materials by material codes
|
||||
*/
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.MATERIALS_DELETE,
|
||||
async (_event, request: MaterialDeleteRequest): Promise<MaterialOperationResponse> => {
|
||||
try {
|
||||
const dao = new MaterialsToBeDeletedDAO()
|
||||
const count = await dao.deleteByMaterialCodes(request.materialCodes)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
count
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
log.error('Delete error', { error: error instanceof Error ? error.message : String(error) })
|
||||
return {
|
||||
success: false,
|
||||
error: `Delete failed: ${message}`
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Get unique manager names
|
||||
*/
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.MATERIALS_GET_MANAGERS,
|
||||
async (_event): Promise<{ managers: string[] }> => {
|
||||
try {
|
||||
const dao = new MaterialsToBeDeletedDAO()
|
||||
const managers = await dao.getManagers()
|
||||
return { managers }
|
||||
} catch (error) {
|
||||
log.error('Get managers error', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return { managers: [] }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Update manager for a single material
|
||||
*/
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.MATERIALS_UPDATE_MANAGER,
|
||||
async (
|
||||
_event,
|
||||
request: { materialCode: string; managerName: string }
|
||||
): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
const dao = new MaterialsToBeDeletedDAO()
|
||||
return await dao.updateManager(request.materialCode, request.managerName)
|
||||
} catch (error) {
|
||||
log.error('Update manager error', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Get materials by manager
|
||||
*/
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.MATERIALS_GET_BY_MANAGER,
|
||||
async (_event, managerName: string): Promise<{ materials: MaterialRecordSummary[] }> => {
|
||||
let dbService: MySqlService | SqlServerService | null = null
|
||||
|
||||
try {
|
||||
dbService = await getValidationDatabaseService()
|
||||
const configManager = ConfigManager.getInstance()
|
||||
const dbType = configManager.getDatabaseType()
|
||||
const isSqlServer = dbType === 'sqlserver'
|
||||
|
||||
const dao = new MaterialsToBeDeletedDAO()
|
||||
const materials = await dao.getMaterialsByManager(managerName)
|
||||
|
||||
// Get material codes set for quick lookup
|
||||
const markedCodes = await dao.getAllMaterialCodes()
|
||||
|
||||
// Enrich with material details from DiscreteMaterialPlanData
|
||||
const enrichedMaterials: MaterialRecordSummary[] = []
|
||||
const detailTableName = getTableName('dbo_DiscreteMaterialPlanData')
|
||||
|
||||
for (const mat of materials) {
|
||||
let detailResult: any
|
||||
|
||||
if (isSqlServer) {
|
||||
const sql = require('mssql')
|
||||
const detailSql = `
|
||||
SELECT TOP 1 MaterialName, Specification, Model
|
||||
FROM ${detailTableName}
|
||||
WHERE MaterialCode = @materialCode
|
||||
`
|
||||
detailResult = await (dbService as SqlServerService).queryWithParams(detailSql, {
|
||||
materialCode: { value: mat.materialCode, type: sql.NVarChar }
|
||||
})
|
||||
} else {
|
||||
const detailSql = `
|
||||
SELECT MaterialName, Specification, Model
|
||||
FROM ${detailTableName}
|
||||
WHERE MaterialCode = ?
|
||||
LIMIT 1
|
||||
`
|
||||
detailResult = await (dbService as MySqlService).query(detailSql, [mat.materialCode])
|
||||
}
|
||||
|
||||
enrichedMaterials.push({
|
||||
materialCode: mat.materialCode,
|
||||
materialName:
|
||||
detailResult.rows.length > 0 ? (detailResult.rows[0].MaterialName as string) : '',
|
||||
specification:
|
||||
detailResult.rows.length > 0 ? (detailResult.rows[0].Specification as string) : '',
|
||||
model: detailResult.rows.length > 0 ? (detailResult.rows[0].Model as string) : '',
|
||||
managerName: mat.managerName,
|
||||
isMarked: markedCodes.has(mat.materialCode)
|
||||
})
|
||||
}
|
||||
|
||||
return { materials: enrichedMaterials }
|
||||
} catch (error) {
|
||||
log.error('Get by manager error', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return { materials: [] }
|
||||
} finally {
|
||||
if (dbService) {
|
||||
try {
|
||||
await dbService.disconnect()
|
||||
} catch (closeError) {
|
||||
log.warn('Error disconnecting database', {
|
||||
error: closeError instanceof Error ? closeError.message : String(closeError)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Get all material records
|
||||
*/
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.MATERIALS_GET_ALL,
|
||||
async (_event): Promise<{ materials: MaterialRecordSummary[] }> => {
|
||||
let dbService: MySqlService | SqlServerService | null = null
|
||||
|
||||
try {
|
||||
dbService = await getValidationDatabaseService()
|
||||
const configManager = ConfigManager.getInstance()
|
||||
const dbType = configManager.getDatabaseType()
|
||||
const isSqlServer = dbType === 'sqlserver'
|
||||
|
||||
const dao = new MaterialsToBeDeletedDAO()
|
||||
const materials = await dao.getAllRecords()
|
||||
const markedCodes = await dao.getAllMaterialCodes()
|
||||
|
||||
const enrichedMaterials: MaterialRecordSummary[] = []
|
||||
const detailTableName = getTableName('dbo_DiscreteMaterialPlanData')
|
||||
|
||||
for (const mat of materials) {
|
||||
let detailResult: any
|
||||
|
||||
if (isSqlServer) {
|
||||
const sql = require('mssql')
|
||||
const detailSql = `
|
||||
SELECT TOP 1 MaterialName, Specification, Model
|
||||
FROM ${detailTableName}
|
||||
WHERE MaterialCode = @materialCode
|
||||
`
|
||||
detailResult = await (dbService as SqlServerService).queryWithParams(detailSql, {
|
||||
materialCode: { value: mat.materialCode, type: sql.NVarChar }
|
||||
})
|
||||
} else {
|
||||
const detailSql = `
|
||||
SELECT MaterialName, Specification, Model
|
||||
FROM ${detailTableName}
|
||||
WHERE MaterialCode = ?
|
||||
LIMIT 1
|
||||
`
|
||||
detailResult = await (dbService as MySqlService).query(detailSql, [mat.materialCode])
|
||||
}
|
||||
|
||||
enrichedMaterials.push({
|
||||
materialCode: mat.materialCode,
|
||||
materialName:
|
||||
detailResult.rows.length > 0 ? (detailResult.rows[0].MaterialName as string) : '',
|
||||
specification:
|
||||
detailResult.rows.length > 0 ? (detailResult.rows[0].Specification as string) : '',
|
||||
model: detailResult.rows.length > 0 ? (detailResult.rows[0].Model as string) : '',
|
||||
managerName: mat.managerName,
|
||||
isMarked: markedCodes.has(mat.materialCode)
|
||||
})
|
||||
}
|
||||
|
||||
return { materials: enrichedMaterials }
|
||||
} catch (error) {
|
||||
log.error('Get all error', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return { materials: [] }
|
||||
} finally {
|
||||
if (dbService) {
|
||||
try {
|
||||
await dbService.disconnect()
|
||||
} catch (closeError) {
|
||||
log.warn('Error disconnecting database', {
|
||||
error: closeError instanceof Error ? closeError.message : String(closeError)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Get statistics
|
||||
*/
|
||||
ipcMain.handle(IPC_CHANNELS.MATERIALS_GET_STATISTICS, async (_event): Promise<{ stats: any }> => {
|
||||
try {
|
||||
const dao = new MaterialsToBeDeletedDAO()
|
||||
const stats = await dao.getStatistics()
|
||||
return { stats }
|
||||
} catch (error) {
|
||||
log.error('Get statistics error', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return { stats: null }
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Set shared Production IDs from extractor page
|
||||
*/
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.VALIDATION_SET_SHARED_PRODUCTION_IDS,
|
||||
async (event, productionIds: string[]): Promise<void> => {
|
||||
log.info(`Received ${productionIds.length} shared Production IDs`)
|
||||
setSharedProductionIds(event.sender.id, productionIds)
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Get shared Production IDs
|
||||
*/
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.VALIDATION_GET_SHARED_PRODUCTION_IDS,
|
||||
async (event): Promise<{ productionIds: string[] }> => {
|
||||
return { productionIds: getSharedProductionIds(event.sender.id) }
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Clear shared Production IDs
|
||||
*/
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.VALIDATION_CLEAR_SHARED_PRODUCTION_IDS,
|
||||
async (event): Promise<void> => {
|
||||
log.info('Clearing shared Production IDs')
|
||||
clearSharedProductionIds(event.sender.id)
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Get cleaner data (order numbers from shared Production IDs + material codes from MaterialsToBeDeleted)
|
||||
* Filters materials by current user (admin sees all, regular users see only their own)
|
||||
*/
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.VALIDATION_GET_CLEANER_DATA,
|
||||
async (
|
||||
_event
|
||||
): Promise<{
|
||||
success: boolean
|
||||
orderNumbers?: string[]
|
||||
materialCodes?: string[]
|
||||
error?: string
|
||||
}> => {
|
||||
let dbService: MySqlService | SqlServerService | null = null
|
||||
const sessionManager = (
|
||||
await import('../services/user/session-manager')
|
||||
).SessionManager.getInstance()
|
||||
|
||||
try {
|
||||
// Get current user
|
||||
const userInfo = sessionManager.getUserInfo()
|
||||
if (!userInfo) {
|
||||
return {
|
||||
success: false,
|
||||
error: '用户未登录'
|
||||
}
|
||||
}
|
||||
|
||||
const isAdmin = userInfo.userType === 'Admin'
|
||||
const username = userInfo.username
|
||||
const configManager = ConfigManager.getInstance()
|
||||
const dbType = configManager.getDatabaseType()
|
||||
const isSqlServer = dbType === 'sqlserver'
|
||||
|
||||
log.info(`User: ${username}, isAdmin: ${isAdmin}`)
|
||||
|
||||
// Connect to database
|
||||
dbService = await getValidationDatabaseService()
|
||||
|
||||
// 1. Get order numbers from shared Production IDs
|
||||
const sharedIds = getSharedProductionIds(_event.sender.id)
|
||||
let orderNumbers: string[] = []
|
||||
|
||||
if (sharedIds.length > 0) {
|
||||
log.info(`Using ${sharedIds.length} shared Production IDs`)
|
||||
orderNumbers = await getSourceNumbersFromInputs(sharedIds, dbService)
|
||||
log.info(`Got ${orderNumbers.length} order numbers`)
|
||||
}
|
||||
|
||||
// 2. Get material codes from MaterialsToBeDeleted table
|
||||
let materialCodes: string[] = []
|
||||
const markedTableName = getTableName('dbo_MaterialsToBeDeleted')
|
||||
|
||||
if (isAdmin) {
|
||||
// Admin sees all materials
|
||||
const allCodesSql = `
|
||||
SELECT MaterialCode
|
||||
FROM ${markedTableName}
|
||||
WHERE MaterialCode IS NOT NULL
|
||||
`
|
||||
const result = isSqlServer
|
||||
? await (dbService as SqlServerService).query(allCodesSql)
|
||||
: await (dbService as MySqlService).query(allCodesSql)
|
||||
|
||||
materialCodes = result.rows.map((row) => row.MaterialCode as string).filter(Boolean)
|
||||
log.info(`Admin user: got ${materialCodes.length} materials`)
|
||||
} else {
|
||||
// Regular users only see their own materials
|
||||
if (isSqlServer) {
|
||||
const sql = require('mssql')
|
||||
const userMaterialsSql = `
|
||||
SELECT MaterialCode
|
||||
FROM ${markedTableName}
|
||||
WHERE ManagerName = @username AND MaterialCode IS NOT NULL
|
||||
`
|
||||
const result = await (dbService as SqlServerService).queryWithParams(userMaterialsSql, {
|
||||
username: { value: username, type: sql.NVarChar }
|
||||
})
|
||||
materialCodes = result.rows.map((row) => row.MaterialCode as string).filter(Boolean)
|
||||
} else {
|
||||
const userMaterialsSql = `
|
||||
SELECT MaterialCode
|
||||
FROM ${markedTableName}
|
||||
WHERE ManagerName = ? AND MaterialCode IS NOT NULL
|
||||
`
|
||||
const result = await (dbService as MySqlService).query(userMaterialsSql, [username])
|
||||
materialCodes = result.rows.map((row) => row.MaterialCode as string).filter(Boolean)
|
||||
}
|
||||
log.info(`Regular user: got ${materialCodes.length} materials`)
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
orderNumbers,
|
||||
materialCodes
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
log.error('CleanerData error', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return {
|
||||
success: false,
|
||||
error: `获取清理数据失败:${message}`
|
||||
}
|
||||
} finally {
|
||||
if (dbService) {
|
||||
try {
|
||||
await dbService.disconnect()
|
||||
} catch (closeError) {
|
||||
log.warn('Error disconnecting database', {
|
||||
error: closeError instanceof Error ? closeError.message : String(closeError)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
export interface AuthResult {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface SessionInfo {
|
||||
username: string;
|
||||
isLoggedIn: boolean;
|
||||
loginTime: Date;
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
export interface SQLServerConfig {
|
||||
server: string;
|
||||
database: string;
|
||||
username: string;
|
||||
password: string;
|
||||
driver: string;
|
||||
trustServerCertificate?: string;
|
||||
}
|
||||
|
||||
export interface MySQLConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
database: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface BrowserConfig {
|
||||
headless: boolean;
|
||||
slowMo: number;
|
||||
timeout: number;
|
||||
}
|
||||
|
||||
export interface ERPConfig {
|
||||
baseUrl: string;
|
||||
ignoreHttpsErrors: boolean;
|
||||
}
|
||||
|
||||
export interface PathConfig {
|
||||
tempDir: string;
|
||||
outputDir: string;
|
||||
reportDir: string;
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
appName: string;
|
||||
version: string;
|
||||
logLevel: 'debug' | 'info' | 'warn' | 'error';
|
||||
browser: BrowserConfig;
|
||||
databases: {
|
||||
sqlServer: SQLServerConfig;
|
||||
mysql: MySQLConfig;
|
||||
};
|
||||
erp: ERPConfig;
|
||||
paths: PathConfig;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
export interface QueryResult {
|
||||
rows: any[];
|
||||
rowCount: number;
|
||||
}
|
||||
|
||||
export interface TransactionResult {
|
||||
success: boolean;
|
||||
rowsAffected: number;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
export interface IPCChannels {
|
||||
'automation:clean': any;
|
||||
'automation:extract': any;
|
||||
'automation:stop': any;
|
||||
'automation:progress': any;
|
||||
'database:query-production-orders': any;
|
||||
'database:query-materials-to-delete': any;
|
||||
'auth:login': any;
|
||||
'auth:logout': any;
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export interface LogEntry {
|
||||
timestamp: string;
|
||||
level: string;
|
||||
message: string;
|
||||
details?: any;
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { Browser, BrowserContext, Page, Frame } from 'playwright';
|
||||
|
||||
export interface Credentials {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
browser: Browser;
|
||||
context: BrowserContext;
|
||||
page: Page;
|
||||
mainFrame: Frame;
|
||||
}
|
||||
|
||||
export interface LoginResult extends Session {
|
||||
success: boolean;
|
||||
}
|
||||
45
src/main/schemas/auth.schema.ts
Normal file
45
src/main/schemas/auth.schema.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Zod schemas for Authentication module validation
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
|
||||
/**
|
||||
* Schema for login request validation
|
||||
*/
|
||||
export const LoginRequestSchema = z.object({
|
||||
username: z.string().min(1, 'Username is required'),
|
||||
password: z.string().min(1, 'Password is required')
|
||||
})
|
||||
|
||||
export type LoginRequestZod = z.infer<typeof LoginRequestSchema>
|
||||
|
||||
/**
|
||||
* Schema for user info validation
|
||||
*/
|
||||
export const UserInfoSchema = z.object({
|
||||
id: z.number().int().positive(),
|
||||
username: z.string().min(1),
|
||||
userType: z.enum(['Admin', 'User', 'Guest']),
|
||||
computerName: z.string().optional()
|
||||
})
|
||||
|
||||
export type UserInfoZod = z.infer<typeof UserInfoSchema>
|
||||
|
||||
/**
|
||||
* Validate login request
|
||||
*/
|
||||
export function validateLoginRequest(input: unknown): {
|
||||
success: boolean
|
||||
data?: LoginRequestZod
|
||||
error?: string
|
||||
} {
|
||||
const result = LoginRequestSchema.safeParse(input)
|
||||
if (result.success) {
|
||||
return { success: true, data: result.data }
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: result.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join('; ')
|
||||
}
|
||||
}
|
||||
49
src/main/schemas/cleaner.schema.ts
Normal file
49
src/main/schemas/cleaner.schema.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Zod schemas for Cleaner module validation
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
|
||||
/**
|
||||
* Schema for cleaner input validation
|
||||
*/
|
||||
export const CleanerInputSchema = z.object({
|
||||
orderNumbers: z
|
||||
.array(z.string().min(1, 'Order number cannot be empty'))
|
||||
.min(1, 'At least one order number is required'),
|
||||
materialCodes: z.array(z.string().min(1, 'Material code cannot be empty')),
|
||||
dryRun: z.boolean(),
|
||||
queryBatchSize: z.number().int().min(1).max(100).optional().default(100),
|
||||
processConcurrency: z.number().int().min(1).max(20).optional().default(1)
|
||||
// Note: onProgress is a function, not validated via Zod
|
||||
})
|
||||
|
||||
export type CleanerInputZod = z.infer<typeof CleanerInputSchema>
|
||||
|
||||
/**
|
||||
* Schema for cleaner result validation
|
||||
*/
|
||||
export const CleanerResultSchema = z.object({
|
||||
processedCount: z.number().int().nonnegative(),
|
||||
errors: z.array(z.string())
|
||||
})
|
||||
|
||||
export type CleanerResultZod = z.infer<typeof CleanerResultSchema>
|
||||
|
||||
/**
|
||||
* Validate cleaner input
|
||||
*/
|
||||
export function validateCleanerInput(input: unknown): {
|
||||
success: boolean
|
||||
data?: CleanerInputZod
|
||||
error?: string
|
||||
} {
|
||||
const result = CleanerInputSchema.safeParse(input)
|
||||
if (result.success) {
|
||||
return { success: true, data: result.data }
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: result.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join('; ')
|
||||
}
|
||||
}
|
||||
46
src/main/schemas/extractor.schema.ts
Normal file
46
src/main/schemas/extractor.schema.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Zod schemas for Extractor module validation
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
|
||||
/**
|
||||
* Schema for extractor input validation
|
||||
*/
|
||||
export const ExtractorInputSchema = z.object({
|
||||
orderNumbers: z
|
||||
.array(z.string().min(1, 'Order number cannot be empty'))
|
||||
.min(1, 'At least one order number is required'),
|
||||
batchSize: z.number().int().positive().optional().default(10)
|
||||
// Note: onProgress is a function, not validated via Zod
|
||||
})
|
||||
|
||||
export type ExtractorInputZod = z.infer<typeof ExtractorInputSchema>
|
||||
|
||||
/**
|
||||
* Schema for extractor result validation
|
||||
*/
|
||||
export const ExtractorResultSchema = z.object({
|
||||
data: z.array(z.record(z.string(), z.unknown())),
|
||||
errors: z.array(z.string())
|
||||
})
|
||||
|
||||
export type ExtractorResultZod = z.infer<typeof ExtractorResultSchema>
|
||||
|
||||
/**
|
||||
* Validate extractor input
|
||||
*/
|
||||
export function validateExtractorInput(input: unknown): {
|
||||
success: boolean
|
||||
data?: ExtractorInputZod
|
||||
error?: string
|
||||
} {
|
||||
const result = ExtractorInputSchema.safeParse(input)
|
||||
if (result.success) {
|
||||
return { success: true, data: result.data }
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: result.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join('; ')
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import { PlaywrightService } from './playwright.service';
|
||||
import { Credentials, LoginResult } from '../models/playwright.types';
|
||||
import { AuthResult, SessionInfo } from '../models/auth.types';
|
||||
import { LoggerService } from './logger.service';
|
||||
|
||||
export class AuthService {
|
||||
private currentSession?: LoginResult;
|
||||
private sessionInfo?: SessionInfo;
|
||||
|
||||
constructor(private playwrightService: PlaywrightService) {}
|
||||
|
||||
async login(credentials: Credentials): Promise<AuthResult> {
|
||||
try {
|
||||
LoggerService.info(`Logging in user: ${credentials.username}`);
|
||||
|
||||
const session = await this.playwrightService.login(credentials);
|
||||
this.currentSession = session;
|
||||
this.sessionInfo = {
|
||||
username: credentials.username,
|
||||
isLoggedIn: true,
|
||||
loginTime: new Date(),
|
||||
};
|
||||
|
||||
LoggerService.info('Login successful');
|
||||
return { success: true, message: 'Login successful' };
|
||||
} catch (error) {
|
||||
LoggerService.error('Login failed', error);
|
||||
|
||||
// Ensure cleanup on failed login
|
||||
await this.playwrightService.closeBrowser().catch(cleanupError => {
|
||||
LoggerService.error('Failed to cleanup after failed login', cleanupError);
|
||||
});
|
||||
|
||||
this.currentSession = undefined;
|
||||
this.sessionInfo = undefined;
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async logout(): Promise<void> {
|
||||
if (!this.currentSession) {
|
||||
return; // Already logged out
|
||||
}
|
||||
|
||||
try {
|
||||
LoggerService.info('Logging out...');
|
||||
await this.playwrightService.closeBrowser();
|
||||
LoggerService.info('Logout successful');
|
||||
} catch (error) {
|
||||
LoggerService.error('Logout failed', error);
|
||||
throw new Error(`Logout failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
} finally {
|
||||
// Always clear state
|
||||
this.currentSession = undefined;
|
||||
this.sessionInfo = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
getSessionInfo(): SessionInfo | undefined {
|
||||
return this.sessionInfo;
|
||||
}
|
||||
|
||||
isLoggedIn(): boolean {
|
||||
return this.sessionInfo?.isLoggedIn ?? false;
|
||||
}
|
||||
|
||||
async destroy(): Promise<void> {
|
||||
await this.logout().catch(err =>
|
||||
LoggerService.error('Failed to logout during destroy', err)
|
||||
);
|
||||
}
|
||||
}
|
||||
346
src/main/services/config/config-manager.ts
Normal file
346
src/main/services/config/config-manager.ts
Normal file
@@ -0,0 +1,346 @@
|
||||
/**
|
||||
* Configuration Manager (YAML Version)
|
||||
*
|
||||
* Manages application configuration using YAML format
|
||||
* Provides type-safe access with Zod validation
|
||||
*
|
||||
* Note: ERP configuration is stored in database (dbo_BIPUsers table)
|
||||
* and managed per-user, not in this config file.
|
||||
*
|
||||
* Configuration File Location:
|
||||
* - Development: Project root directory (config.yaml)
|
||||
* - Production (Installed & Portable): User data directory (AppData)
|
||||
* This ensures config persists across app updates and is not exposed
|
||||
*/
|
||||
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { dirname } from 'path'
|
||||
import { app } from 'electron'
|
||||
import yaml from 'js-yaml'
|
||||
import { z } from 'zod'
|
||||
import { createLogger, setLogLevel } from '../logger'
|
||||
import {
|
||||
fullConfigSchema,
|
||||
type FullConfig,
|
||||
type DatabaseType,
|
||||
type MySqlConfig,
|
||||
type SqlServerConfig,
|
||||
type LoggingConfig
|
||||
} from '../../types/config.schema'
|
||||
|
||||
const log = createLogger('ConfigManager')
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = dirname(__filename)
|
||||
|
||||
/**
|
||||
* 默认配置
|
||||
*/
|
||||
const DEFAULT_CONFIG: FullConfig = {
|
||||
erp: {
|
||||
url: 'https://68.11.34.30:8082'
|
||||
},
|
||||
database: {
|
||||
activeType: 'mysql',
|
||||
mysql: {
|
||||
host: 'localhost',
|
||||
port: 3306,
|
||||
database: 'erp_db',
|
||||
username: 'root',
|
||||
password: '',
|
||||
charset: 'utf8mb4'
|
||||
},
|
||||
sqlserver: {
|
||||
server: 'localhost',
|
||||
port: 1433,
|
||||
database: 'erp_db',
|
||||
username: 'sa',
|
||||
password: '',
|
||||
driver: 'ODBC Driver 18 for SQL Server',
|
||||
trustServerCertificate: true
|
||||
}
|
||||
},
|
||||
paths: {
|
||||
dataDir: './data/',
|
||||
defaultOutput: 'output.xlsx',
|
||||
validationOutput: 'validation-result.xlsx'
|
||||
},
|
||||
extraction: {
|
||||
batchSize: 100,
|
||||
verbose: true,
|
||||
autoConvert: true,
|
||||
mergeBatches: true,
|
||||
enableDbPersistence: true
|
||||
},
|
||||
validation: {
|
||||
dataSource: 'database_full',
|
||||
batchSize: 2000,
|
||||
matchMode: 'substring',
|
||||
enableCrud: false,
|
||||
defaultManager: ''
|
||||
},
|
||||
cleaner: {
|
||||
queryBatchSize: 100,
|
||||
processConcurrency: 1
|
||||
},
|
||||
orderResolution: {
|
||||
tableName: '',
|
||||
productionIdField: '',
|
||||
orderNumberField: ''
|
||||
},
|
||||
logging: {
|
||||
level: 'info',
|
||||
auditRetention: 30,
|
||||
appRetention: 14
|
||||
},
|
||||
rustfs: {
|
||||
enabled: false,
|
||||
endpoint: '',
|
||||
accessKey: '',
|
||||
secretKey: '',
|
||||
bucket: 'erpauto',
|
||||
region: 'us-east-1'
|
||||
}
|
||||
}
|
||||
|
||||
export class ConfigManager {
|
||||
private static instance: ConfigManager | null = null
|
||||
private configPath!: string
|
||||
private backupPath!: string
|
||||
private config: FullConfig | null = null
|
||||
private initialized: boolean = false
|
||||
|
||||
private constructor() {
|
||||
if (this.initialized) return
|
||||
|
||||
// 检测是否为开发环境
|
||||
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged
|
||||
|
||||
if (isDev) {
|
||||
// 开发环境:配置文件放在项目根目录,方便编辑和调试
|
||||
this.configPath = path.resolve(__dirname, '../../config.yaml')
|
||||
this.backupPath = path.resolve(__dirname, '../../config.yaml.backup')
|
||||
log.info('Running in development mode', { configPath: this.configPath })
|
||||
} else {
|
||||
// 生产环境(包括安装版和便携版):配置文件放在用户数据目录
|
||||
// Windows: C:\Users\<user>\AppData\Roaming\erpauto\config.yaml
|
||||
// 这样配置会在应用升级时保留,且不会暴露在应用目录中
|
||||
this.configPath = path.join(app.getPath('userData'), 'config.yaml')
|
||||
this.backupPath = path.join(app.getPath('userData'), 'config.yaml.backup')
|
||||
log.info('Running in production mode', { configPath: this.configPath })
|
||||
}
|
||||
|
||||
this.initialized = true
|
||||
}
|
||||
|
||||
public static getInstance(): ConfigManager {
|
||||
if (ConfigManager.instance === null) {
|
||||
ConfigManager.instance = new ConfigManager()
|
||||
}
|
||||
return ConfigManager.instance
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化配置
|
||||
* - 如果 config.yaml 不存在,创建默认配置
|
||||
* - 加载并验证配置
|
||||
*/
|
||||
public async initialize(): Promise<void> {
|
||||
if (!fs.existsSync(this.configPath)) {
|
||||
log.info('Config file not found, creating default config.yaml')
|
||||
await this.saveConfig(DEFAULT_CONFIG)
|
||||
this.config = DEFAULT_CONFIG
|
||||
// Apply logging configuration from default config
|
||||
setLogLevel(DEFAULT_CONFIG.logging.level)
|
||||
return
|
||||
}
|
||||
|
||||
await this.loadConfig()
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载并验证 YAML 配置
|
||||
*/
|
||||
private async loadConfig(): Promise<void> {
|
||||
try {
|
||||
const content = fs.readFileSync(this.configPath, 'utf-8')
|
||||
const parsed = yaml.load(content) as Record<string, unknown>
|
||||
|
||||
// Zod 验证
|
||||
const validated = fullConfigSchema.parse(parsed)
|
||||
this.config = validated
|
||||
|
||||
// Apply logging configuration
|
||||
setLogLevel(validated.logging.level)
|
||||
|
||||
log.info('Configuration loaded and validated successfully')
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
const messages = error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`)
|
||||
log.error('Configuration validation failed', { errors: messages })
|
||||
throw new Error(`配置文件验证失败:\n${messages.join('\n')}`)
|
||||
}
|
||||
log.error('Failed to load configuration', { error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存配置到 YAML 文件
|
||||
*/
|
||||
private async saveConfig(config: FullConfig): Promise<boolean> {
|
||||
try {
|
||||
// 备份现有配置
|
||||
if (fs.existsSync(this.configPath)) {
|
||||
fs.copyFileSync(this.configPath, this.backupPath)
|
||||
}
|
||||
|
||||
// 转换为 YAML
|
||||
const content = yaml.dump(config, {
|
||||
indent: 2,
|
||||
lineWidth: -1, // 不自动换行
|
||||
noRefs: true, // 不使用引用
|
||||
quotingType: '"',
|
||||
forceQuotes: false
|
||||
})
|
||||
|
||||
fs.writeFileSync(this.configPath, content, 'utf-8')
|
||||
|
||||
this.config = config
|
||||
log.info('Configuration saved successfully')
|
||||
return true
|
||||
} catch (error) {
|
||||
log.error('Failed to save configuration', { error })
|
||||
// 恢复备份
|
||||
if (fs.existsSync(this.backupPath)) {
|
||||
fs.copyFileSync(this.backupPath, this.configPath)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取完整配置
|
||||
*/
|
||||
public getConfig(): FullConfig {
|
||||
if (!this.config) {
|
||||
throw new Error('Configuration not initialized. Call initialize() first.')
|
||||
}
|
||||
return this.config
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前激活的数据库配置
|
||||
*/
|
||||
public getActiveDatabaseConfig(): MySqlConfig | SqlServerConfig {
|
||||
if (!this.config) {
|
||||
throw new Error('Configuration not initialized')
|
||||
}
|
||||
|
||||
const { activeType, mysql, sqlserver } = this.config.database
|
||||
return activeType === 'mysql' ? mysql : sqlserver
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取数据库类型
|
||||
*/
|
||||
public getDatabaseType(): DatabaseType {
|
||||
if (!this.config) {
|
||||
throw new Error('Configuration not initialized')
|
||||
}
|
||||
return this.config.database.activeType
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取日志配置
|
||||
*/
|
||||
public getLoggingConfig(): LoggingConfig {
|
||||
if (!this.config) {
|
||||
throw new Error('Configuration not initialized')
|
||||
}
|
||||
return this.config.logging
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新部分配置(深合并)
|
||||
*/
|
||||
public async updateConfig(
|
||||
updates: Partial<FullConfig>
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
if (!this.config) {
|
||||
await this.loadConfig()
|
||||
}
|
||||
|
||||
// 深合并
|
||||
const merged = this.deepMerge(this.config!, updates)
|
||||
|
||||
// 验证合并后的配置
|
||||
const validated = fullConfigSchema.parse(merged)
|
||||
|
||||
const success = await this.saveConfig(validated)
|
||||
if (!success) {
|
||||
return { success: false, error: '保存配置失败' }
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
const messages = error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`)
|
||||
return { success: false, error: `配置验证失败:\n${messages.join('\n')}` }
|
||||
}
|
||||
return { success: false, error: error instanceof Error ? error.message : '未知错误' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 深合并工具函数
|
||||
*/
|
||||
private deepMerge<T extends Record<string, any>>(source: T, target: Partial<T>): T {
|
||||
const result = { ...source }
|
||||
for (const key in target) {
|
||||
if (target[key] !== undefined) {
|
||||
if (
|
||||
typeof target[key] === 'object' &&
|
||||
target[key] !== null &&
|
||||
!Array.isArray(target[key])
|
||||
) {
|
||||
result[key] = this.deepMerge(result[key] as any, target[key] as any)
|
||||
} else {
|
||||
result[key] = target[key] as any
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置为默认配置
|
||||
*/
|
||||
public async resetToDefaults(): Promise<boolean> {
|
||||
return this.saveConfig(DEFAULT_CONFIG)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取默认配置
|
||||
*/
|
||||
public getDefaultConfig(): FullConfig {
|
||||
return DEFAULT_CONFIG
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出配置为 YAML 字符串(用于 UI 显示或导出)
|
||||
*/
|
||||
public exportToYaml(): string {
|
||||
if (!this.config) {
|
||||
throw new Error('Configuration not initialized')
|
||||
}
|
||||
return yaml.dump(this.config, {
|
||||
indent: 2,
|
||||
lineWidth: -1,
|
||||
noRefs: true
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
import sql from 'mssql';
|
||||
import mysql from 'mysql2/promise';
|
||||
import { SQLServerConfig, MySQLConfig } from '../models/config.types';
|
||||
import { QueryResult } from '../models/database.types';
|
||||
import { LoggerService } from './logger.service';
|
||||
|
||||
export class DatabaseService {
|
||||
private sqlServerPool?: sql.ConnectionPool;
|
||||
private mysqlPool?: mysql.Pool;
|
||||
|
||||
constructor(
|
||||
private sqlServerConfig: SQLServerConfig,
|
||||
private mysqlConfig: MySQLConfig
|
||||
) {}
|
||||
|
||||
async connectToSQLServer(): Promise<void> {
|
||||
try {
|
||||
LoggerService.info('Connecting to SQL Server...');
|
||||
|
||||
this.sqlServerPool = await sql.connect({
|
||||
server: this.sqlServerConfig.server,
|
||||
database: this.sqlServerConfig.database,
|
||||
user: this.sqlServerConfig.username,
|
||||
password: this.sqlServerConfig.password,
|
||||
driver: this.sqlServerConfig.driver,
|
||||
options: {
|
||||
trustServerCertificate:
|
||||
this.sqlServerConfig.trustServerCertificate === 'yes',
|
||||
},
|
||||
});
|
||||
|
||||
LoggerService.info('SQL Server connection established');
|
||||
} catch (error) {
|
||||
LoggerService.error('Failed to connect to SQL Server', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async connectToMySQL(): Promise<void> {
|
||||
try {
|
||||
LoggerService.info('Connecting to MySQL...');
|
||||
|
||||
this.mysqlPool = mysql.createPool({
|
||||
host: this.mysqlConfig.host,
|
||||
port: this.mysqlConfig.port,
|
||||
database: this.mysqlConfig.database,
|
||||
user: this.mysqlConfig.username,
|
||||
password: this.mysqlConfig.password,
|
||||
});
|
||||
|
||||
LoggerService.info('MySQL connection established');
|
||||
} catch (error) {
|
||||
LoggerService.error('Failed to connect to MySQL', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async executeSQLServerQuery(query: string, params?: any[]): Promise<QueryResult> {
|
||||
if (!this.sqlServerPool) {
|
||||
throw new Error('SQL Server not connected');
|
||||
}
|
||||
|
||||
try {
|
||||
const request = this.sqlServerPool.request();
|
||||
if (params) {
|
||||
params.forEach((param, index) => {
|
||||
request.input(`param${index}`, param);
|
||||
});
|
||||
}
|
||||
|
||||
const result = await request.query(query);
|
||||
return {
|
||||
rows: result.recordset,
|
||||
rowCount: result.rowsAffected[0],
|
||||
};
|
||||
} catch (error) {
|
||||
LoggerService.error('SQL Server query failed', { query, error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async executeMySQLQuery(query: string, params?: any[]): Promise<QueryResult> {
|
||||
if (!this.mysqlPool) {
|
||||
throw new Error('MySQL not connected');
|
||||
}
|
||||
|
||||
try {
|
||||
const [rows] = await this.mysqlPool.execute(query, params);
|
||||
return {
|
||||
rows: rows as any[],
|
||||
rowCount: (rows as any[]).length,
|
||||
};
|
||||
} catch (error) {
|
||||
LoggerService.error('MySQL query failed', { query, error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async closeAll(): Promise<void> {
|
||||
if (this.sqlServerPool) {
|
||||
await this.sqlServerPool.close();
|
||||
LoggerService.info('SQL Server connection closed');
|
||||
}
|
||||
if (this.mysqlPool) {
|
||||
await this.mysqlPool.end();
|
||||
LoggerService.info('MySQL connection closed');
|
||||
}
|
||||
}
|
||||
}
|
||||
299
src/main/services/database/data-importer.ts
Normal file
299
src/main/services/database/data-importer.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* Data Import Service
|
||||
*
|
||||
* Reads Excel files and imports data to the DiscreteMaterialPlanData table.
|
||||
* Workflow:
|
||||
* 1. Read Excel file
|
||||
* 2. Extract unique SourceNumbers
|
||||
* 3. Delete existing records by SourceNumber
|
||||
* 4. Batch insert new records
|
||||
*/
|
||||
|
||||
import { createLogger } from '../logger'
|
||||
import { DiscreteMaterialPlanDAO, type MaterialPlanRecord } from './discrete-material-plan-dao'
|
||||
|
||||
const log = createLogger('DataImportService')
|
||||
|
||||
/**
|
||||
* Excel column header to database field mapping
|
||||
*/
|
||||
const EXCEL_TO_DB_MAPPING: Record<string, keyof MaterialPlanRecord> = {
|
||||
工厂: 'factory',
|
||||
备料状态: 'materialStatus',
|
||||
备料计划单号: 'planNumber',
|
||||
来源单号: 'sourceNumber',
|
||||
备料类型: 'materialType',
|
||||
产品编码: 'productCode',
|
||||
产品名称: 'productName',
|
||||
产品计划数量: 'productPlanQuantity',
|
||||
产品单位: 'productUnit',
|
||||
用料部门: 'useDepartment',
|
||||
备注: 'remark',
|
||||
制单人: 'creator',
|
||||
制单日期: 'createDate',
|
||||
审批人: 'approver',
|
||||
审批日期: 'approveDate',
|
||||
序号: 'sequenceNumber',
|
||||
材料编码: 'materialCode',
|
||||
材料名称: 'materialName',
|
||||
规格: 'specification',
|
||||
型号: 'model',
|
||||
图号: 'drawingNumber',
|
||||
物料材质: 'materialQuality',
|
||||
计划数量: 'planQuantity',
|
||||
单位: 'unit',
|
||||
需用日期: 'requiredDate',
|
||||
发料仓库: 'warehouse',
|
||||
单位用量: 'unitUsage',
|
||||
累计出库数量: 'cumulativeOutputQuantity'
|
||||
// Note: '打印人', '打印日期' are skipped (not in DB)
|
||||
// Note: 'BOMVersion' is skipped (not in Excel)
|
||||
}
|
||||
|
||||
/**
|
||||
* Import result
|
||||
*/
|
||||
export interface ImportResult {
|
||||
success: boolean
|
||||
recordsRead: number
|
||||
recordsDeleted: number
|
||||
recordsImported: number
|
||||
uniqueSourceNumbers: number
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* DataImportService class
|
||||
*/
|
||||
export class DataImportService {
|
||||
private dao: DiscreteMaterialPlanDAO
|
||||
|
||||
constructor() {
|
||||
this.dao = new DiscreteMaterialPlanDAO()
|
||||
}
|
||||
|
||||
/**
|
||||
* Import data from Excel file to database
|
||||
* @param filePath - Path to the Excel file
|
||||
* @param batchSize - Number of records per insert batch (default: 1000)
|
||||
* @returns Import result with statistics
|
||||
*/
|
||||
async importFromExcel(filePath: string, batchSize = 1000): Promise<ImportResult> {
|
||||
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: Read Excel file
|
||||
log.info('Reading Excel file...')
|
||||
const { records, sourceNumbers } = await this.readExcelFile(filePath)
|
||||
result.recordsRead = records.length
|
||||
result.uniqueSourceNumbers = sourceNumbers.size
|
||||
|
||||
log.info('Excel read completed', {
|
||||
recordsRead: result.recordsRead,
|
||||
uniqueSourceNumbers: result.uniqueSourceNumbers
|
||||
})
|
||||
|
||||
if (records.length === 0) {
|
||||
result.success = true
|
||||
result.errors.push('Excel file contains no data records')
|
||||
return result
|
||||
}
|
||||
|
||||
// Step 2: Delete existing records by SourceNumber
|
||||
log.info('Deleting existing records...', {
|
||||
sourceNumberCount: sourceNumbers.size
|
||||
})
|
||||
|
||||
const sourceNumberArray = Array.from(sourceNumbers)
|
||||
result.recordsDeleted = await this.dao.deleteBySourceNumbers(sourceNumberArray)
|
||||
|
||||
log.info('Existing records deleted', {
|
||||
recordsDeleted: result.recordsDeleted
|
||||
})
|
||||
|
||||
// Step 3: Batch insert new records
|
||||
log.info('Inserting new records...', {
|
||||
recordCount: records.length,
|
||||
batchSize
|
||||
})
|
||||
|
||||
result.recordsImported = await this.dao.batchInsert(records, batchSize)
|
||||
|
||||
log.info('Records imported successfully', {
|
||||
recordsImported: result.recordsImported
|
||||
})
|
||||
|
||||
result.success = true
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error)
|
||||
result.errors.push(`Import failed: ${errorMsg}`)
|
||||
log.error('Import failed', { error: errorMsg })
|
||||
} finally {
|
||||
// Disconnect DAO
|
||||
try {
|
||||
await this.dao.disconnect()
|
||||
} catch (e) {
|
||||
log.warn('Error disconnecting DAO', {
|
||||
error: e instanceof Error ? e.message : String(e)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Read Excel file and extract records
|
||||
* @param filePath - Path to the Excel file
|
||||
* @returns Records and unique SourceNumbers
|
||||
*/
|
||||
private async readExcelFile(
|
||||
filePath: string
|
||||
): Promise<{ records: MaterialPlanRecord[]; sourceNumbers: Set<string> }> {
|
||||
const records: MaterialPlanRecord[] = []
|
||||
const sourceNumbers = new Set<string>()
|
||||
|
||||
// Dynamic import ExcelJS
|
||||
const ExcelJSModule = await import('exceljs')
|
||||
const ExcelJS = (ExcelJSModule as any).default || ExcelJSModule
|
||||
|
||||
const workbook = new ExcelJS.Workbook()
|
||||
await workbook.xlsx.readFile(filePath)
|
||||
|
||||
// Get first worksheet
|
||||
const worksheet = workbook.worksheets[0]
|
||||
if (!worksheet) {
|
||||
throw new Error('Excel file has no worksheets')
|
||||
}
|
||||
|
||||
// Get header row to map column indices
|
||||
const headerRow = worksheet.getRow(1)
|
||||
const columnMapping = this.buildColumnMapping(headerRow)
|
||||
|
||||
log.debug('Column mapping built', {
|
||||
columnCount: Object.keys(columnMapping).length
|
||||
})
|
||||
|
||||
// Iterate through data rows (starting from row 2)
|
||||
worksheet.eachRow((row: any, rowNumber: number) => {
|
||||
if (rowNumber === 1) return // Skip header row
|
||||
|
||||
try {
|
||||
const record = this.buildRecordFromRow(row, columnMapping)
|
||||
if (record) {
|
||||
records.push(record)
|
||||
if (record.sourceNumber) {
|
||||
sourceNumbers.add(record.sourceNumber)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn('Failed to parse row', {
|
||||
rowNumber,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return { records, sourceNumbers }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build column index to field name mapping from header row
|
||||
*/
|
||||
private buildColumnMapping(headerRow: any): Map<number, keyof MaterialPlanRecord> {
|
||||
const mapping = new Map<number, keyof MaterialPlanRecord>()
|
||||
|
||||
headerRow.eachCell((cell: any, colNumber: number) => {
|
||||
const headerText = cell.text?.toString().trim()
|
||||
if (headerText && EXCEL_TO_DB_MAPPING[headerText]) {
|
||||
mapping.set(colNumber, EXCEL_TO_DB_MAPPING[headerText])
|
||||
}
|
||||
})
|
||||
|
||||
return mapping
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a MaterialPlanRecord from an Excel row
|
||||
*/
|
||||
private buildRecordFromRow(
|
||||
row: any,
|
||||
columnMapping: Map<number, keyof MaterialPlanRecord>
|
||||
): MaterialPlanRecord | null {
|
||||
const record: Partial<MaterialPlanRecord> = {}
|
||||
|
||||
row.eachCell((cell: any, colNumber: number) => {
|
||||
const fieldName = columnMapping.get(colNumber)
|
||||
if (!fieldName) return
|
||||
|
||||
const value = this.parseCellValue(cell, fieldName)
|
||||
record[fieldName] = value as any
|
||||
})
|
||||
|
||||
// Validate required fields
|
||||
if (!record.planNumber) {
|
||||
return null // Skip records without PlanNumber
|
||||
}
|
||||
|
||||
return record as MaterialPlanRecord
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse cell value based on field type
|
||||
*/
|
||||
private parseCellValue(cell: any, fieldName: keyof MaterialPlanRecord): any {
|
||||
const text = cell.text?.toString().trim()
|
||||
const value = cell.value
|
||||
|
||||
// Return null for empty cells
|
||||
if (!text || text === '') {
|
||||
return null
|
||||
}
|
||||
|
||||
// Handle numeric fields
|
||||
const numericFields: (keyof MaterialPlanRecord)[] = [
|
||||
'productPlanQuantity',
|
||||
'sequenceNumber',
|
||||
'planQuantity',
|
||||
'unitUsage',
|
||||
'cumulativeOutputQuantity'
|
||||
]
|
||||
|
||||
if (numericFields.includes(fieldName)) {
|
||||
const num = parseFloat(text)
|
||||
return isNaN(num) ? null : num
|
||||
}
|
||||
|
||||
// Handle date fields
|
||||
const dateFields: (keyof MaterialPlanRecord)[] = ['createDate', 'approveDate', 'requiredDate']
|
||||
|
||||
if (dateFields.includes(fieldName)) {
|
||||
// ExcelJS returns date as Date object if recognized
|
||||
if (value instanceof Date) {
|
||||
return value
|
||||
}
|
||||
// Try to parse date string
|
||||
const date = new Date(text)
|
||||
return isNaN(date.getTime()) ? null : date
|
||||
}
|
||||
|
||||
// Handle string fields
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a DataImportService instance
|
||||
*/
|
||||
export function createDataImportService(): DataImportService {
|
||||
return new DataImportService()
|
||||
}
|
||||
103
src/main/services/database/data-source.ts
Normal file
103
src/main/services/database/data-source.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* TypeORM Data Source Configuration
|
||||
*
|
||||
* Provides a centralized database connection for TypeORM entities.
|
||||
* Supports both MySQL and SQL Server based on configuration.
|
||||
*
|
||||
* Note: Configuration is now loaded from config.yaml via ConfigManager,
|
||||
* not from environment variables.
|
||||
*/
|
||||
|
||||
import 'reflect-metadata'
|
||||
import { DataSource, DataSourceOptions } from 'typeorm'
|
||||
import { ConfigManager } from '../config/config-manager'
|
||||
|
||||
/**
|
||||
* Get database type from config manager
|
||||
*/
|
||||
function getDatabaseType(): 'mysql' | 'mssql' {
|
||||
const configManager = ConfigManager.getInstance()
|
||||
const dbType = configManager.getDatabaseType()
|
||||
return dbType === 'sqlserver' ? 'mssql' : 'mysql'
|
||||
}
|
||||
|
||||
/**
|
||||
* Build DataSourceOptions based on database type
|
||||
*/
|
||||
function buildDataSourceOptions(): DataSourceOptions {
|
||||
const type = getDatabaseType()
|
||||
const configManager = ConfigManager.getInstance()
|
||||
const config = configManager.getConfig()
|
||||
|
||||
const commonOptions: Partial<DataSourceOptions> = {
|
||||
entities: [__dirname + '/entities/*.{ts,js}'],
|
||||
synchronize: false, // Never auto-sync in production
|
||||
logging: false
|
||||
}
|
||||
|
||||
if (type === 'mssql') {
|
||||
const dbConfig = config.database.sqlserver
|
||||
return {
|
||||
type: 'mssql',
|
||||
host: dbConfig.server,
|
||||
port: dbConfig.port,
|
||||
username: dbConfig.username,
|
||||
password: dbConfig.password,
|
||||
database: dbConfig.database,
|
||||
options: {
|
||||
encrypt: false,
|
||||
trustServerCertificate: dbConfig.trustServerCertificate
|
||||
},
|
||||
...commonOptions
|
||||
} as DataSourceOptions
|
||||
}
|
||||
|
||||
const dbConfig = config.database.mysql
|
||||
return {
|
||||
type: 'mysql',
|
||||
host: dbConfig.host,
|
||||
port: dbConfig.port,
|
||||
username: dbConfig.username,
|
||||
password: dbConfig.password,
|
||||
database: dbConfig.database,
|
||||
...commonOptions
|
||||
} as DataSourceOptions
|
||||
}
|
||||
|
||||
/**
|
||||
* TypeORM DataSource singleton
|
||||
*/
|
||||
let dataSource: DataSource | null = null
|
||||
|
||||
/**
|
||||
* Get or create the DataSource
|
||||
*/
|
||||
export function getDataSource(): DataSource {
|
||||
if (!dataSource) {
|
||||
dataSource = new DataSource(buildDataSourceOptions())
|
||||
}
|
||||
return dataSource
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the DataSource
|
||||
*/
|
||||
export async function initializeDataSource(): Promise<DataSource> {
|
||||
const ds = getDataSource()
|
||||
if (!ds.isInitialized) {
|
||||
await ds.initialize()
|
||||
}
|
||||
return ds
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the DataSource
|
||||
*/
|
||||
export async function destroyDataSource(): Promise<void> {
|
||||
if (dataSource && dataSource.isInitialized) {
|
||||
await dataSource.destroy()
|
||||
dataSource = null
|
||||
}
|
||||
}
|
||||
|
||||
export default getDataSource
|
||||
789
src/main/services/database/discrete-material-plan-dao.ts
Normal file
789
src/main/services/database/discrete-material-plan-dao.ts
Normal file
@@ -0,0 +1,789 @@
|
||||
/**
|
||||
* Data Access Object for DiscreteMaterialPlanData table
|
||||
*
|
||||
* Mirrors the Python DiscreteMaterialPlanDAO functionality:
|
||||
* - Query operations for discrete material plan data
|
||||
* - Support for querying by PlanNumber, SourceNumber
|
||||
* - Deduplication by MaterialCode
|
||||
* - Statistics gathering
|
||||
*/
|
||||
|
||||
import { create, type IDatabaseService } from './index'
|
||||
import { createLogger } from '../logger'
|
||||
|
||||
const log = createLogger('DiscreteMaterialPlanDAO')
|
||||
|
||||
/**
|
||||
* Material plan record interface
|
||||
*/
|
||||
export interface MaterialPlanRecord {
|
||||
id?: number
|
||||
factory: string
|
||||
materialStatus: string
|
||||
planNumber: string
|
||||
sourceNumber: string
|
||||
materialType: string
|
||||
productCode: string
|
||||
productName: string
|
||||
productUnit: string
|
||||
productPlanQuantity: number
|
||||
useDepartment: string
|
||||
remark: string
|
||||
creator: string
|
||||
createDate: Date
|
||||
approver: string
|
||||
approveDate: Date
|
||||
sequenceNumber: number
|
||||
materialCode: string
|
||||
materialName: string
|
||||
specification: string
|
||||
model: string
|
||||
drawingNumber: string
|
||||
materialQuality: string
|
||||
planQuantity: number
|
||||
unit: string
|
||||
requiredDate: Date
|
||||
warehouse: string
|
||||
unitUsage: number
|
||||
cumulativeOutputQuantity: number
|
||||
bomVersion: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for DiscreteMaterialPlanData table
|
||||
*/
|
||||
export const DISCRETE_MATERIAL_PLAN_CONFIG = {
|
||||
TABLE_NAME_SQLSERVER: '[dbo].[DiscreteMaterialPlanData]',
|
||||
TABLE_NAME_MYSQL: 'dbo_DiscreteMaterialPlanData',
|
||||
COLUMNS: {
|
||||
ID: 'ID',
|
||||
FACTORY: 'Factory',
|
||||
MATERIAL_STATUS: 'MaterialStatus',
|
||||
PLAN_NUMBER: 'PlanNumber',
|
||||
SOURCE_NUMBER: 'SourceNumber',
|
||||
MATERIAL_TYPE: 'MaterialType',
|
||||
PRODUCT_CODE: 'ProductCode',
|
||||
PRODUCT_NAME: 'ProductName',
|
||||
PRODUCT_UNIT: 'ProductUnit',
|
||||
PRODUCT_PLAN_QUANTITY: 'ProductPlanQuantity',
|
||||
USE_DEPARTMENT: 'UseDepartment',
|
||||
REMARK: 'Remark',
|
||||
CREATOR: 'Creator',
|
||||
CREATE_DATE: 'CreateDate',
|
||||
APPROVER: 'Approver',
|
||||
APPROVE_DATE: 'ApproveDate',
|
||||
SEQUENCE_NUMBER: 'SequenceNumber',
|
||||
MATERIAL_CODE: 'MaterialCode',
|
||||
MATERIAL_NAME: 'MaterialName',
|
||||
SPECIFICATION: 'Specification',
|
||||
MODEL: 'Model',
|
||||
DRAWING_NUMBER: 'DrawingNumber',
|
||||
MATERIAL_QUALITY: 'MaterialQuality',
|
||||
PLAN_QUANTITY: 'PlanQuantity',
|
||||
UNIT: 'Unit',
|
||||
REQUIRED_DATE: 'RequiredDate',
|
||||
WAREHOUSE: 'Warehouse',
|
||||
UNIT_USAGE: 'UnitUsage',
|
||||
CUMULATIVE_OUTPUT_QUANTITY: 'CumulativeOutputQuantity',
|
||||
BOM_VERSION: 'BOMVersion'
|
||||
}
|
||||
} as const
|
||||
|
||||
/**
|
||||
* DiscreteMaterialPlanDAO Class
|
||||
*/
|
||||
export class DiscreteMaterialPlanDAO {
|
||||
private dbService: IDatabaseService | null = null
|
||||
|
||||
/**
|
||||
* Get the appropriate table name based on database type
|
||||
*/
|
||||
private getTableName(): string {
|
||||
const isSqlServer = this.dbService?.type === 'sqlserver'
|
||||
return isSqlServer
|
||||
? DISCRETE_MATERIAL_PLAN_CONFIG.TABLE_NAME_SQLSERVER
|
||||
: DISCRETE_MATERIAL_PLAN_CONFIG.TABLE_NAME_MYSQL
|
||||
}
|
||||
|
||||
/**
|
||||
* Get database service instance using DatabaseFactory
|
||||
*/
|
||||
private async getDatabaseService(): Promise<IDatabaseService> {
|
||||
if (this.dbService && this.dbService.isConnected()) {
|
||||
return this.dbService
|
||||
}
|
||||
|
||||
this.dbService = await create()
|
||||
return this.dbService
|
||||
}
|
||||
|
||||
/**
|
||||
* Build placeholders for IN clause based on database type
|
||||
*/
|
||||
private buildPlaceholders(count: number, isSqlServer: boolean): string {
|
||||
return isSqlServer
|
||||
? Array.from({ length: count }, (_, idx) => `@p${idx}`).join(',')
|
||||
: Array.from({ length: count }, () => '?').join(',')
|
||||
}
|
||||
|
||||
// ==================== QUERY ALL ====================
|
||||
|
||||
/**
|
||||
* Query all records from DiscreteMaterialPlanData table
|
||||
* @returns List of all records
|
||||
*/
|
||||
async queryAll(): Promise<any[]> {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
|
||||
const sqlString = `SELECT * FROM ${tableName}`
|
||||
const result = await dbService.query(sqlString)
|
||||
|
||||
return result.rows
|
||||
} catch (error) {
|
||||
log.error('Query all error', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query all records with deduplication by MaterialCode
|
||||
* Strategy: Keep first record for each MaterialCode
|
||||
* Order: CreateDate ASC, SequenceNumber ASC
|
||||
* @returns List of deduplicated records
|
||||
*/
|
||||
async queryAllDistinctByMaterialCode(): Promise<any[]> {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
|
||||
const sqlString = `
|
||||
WITH RankedRecords AS (
|
||||
SELECT
|
||||
*,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY MaterialCode
|
||||
ORDER BY CreateDate ASC, SequenceNumber ASC
|
||||
) AS rn
|
||||
FROM ${tableName}
|
||||
WHERE MaterialCode IS NOT NULL
|
||||
)
|
||||
SELECT
|
||||
Factory, MaterialStatus, PlanNumber, SourceNumber, MaterialType,
|
||||
ProductCode, ProductName, ProductUnit, ProductPlanQuantity,
|
||||
UseDepartment, Remark, Creator, CreateDate, Approver, ApproveDate,
|
||||
SequenceNumber, MaterialCode, MaterialName, Specification, Model,
|
||||
DrawingNumber, MaterialQuality, PlanQuantity, Unit, RequiredDate,
|
||||
Warehouse, UnitUsage, CumulativeOutputQuantity, BOMVersion
|
||||
FROM RankedRecords
|
||||
WHERE rn = 1
|
||||
`
|
||||
|
||||
const result = await dbService.query(sqlString)
|
||||
return result.rows
|
||||
} catch (error) {
|
||||
log.error('Query all distinct by material code error', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== QUERY BY SOURCE NUMBER ====================
|
||||
|
||||
/**
|
||||
* Query records by SourceNumber list
|
||||
* @param sourceNumbers - List of SourceNumber values
|
||||
* @returns List of records
|
||||
*/
|
||||
async queryBySourceNumbers(sourceNumbers: string[]): Promise<any[]> {
|
||||
if (!sourceNumbers || sourceNumbers.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
const batchSize = 1500
|
||||
const allResults: any[] = []
|
||||
|
||||
for (let i = 0; i < sourceNumbers.length; i += batchSize) {
|
||||
const batch = sourceNumbers.slice(i, i + batchSize)
|
||||
const placeholders = this.buildPlaceholders(batch.length, isSqlServer)
|
||||
|
||||
const sqlString = `
|
||||
SELECT *
|
||||
FROM ${tableName}
|
||||
WHERE SourceNumber IN (${placeholders})
|
||||
`
|
||||
|
||||
const result = await dbService.query(sqlString, batch)
|
||||
allResults.push(...result.rows)
|
||||
}
|
||||
|
||||
return allResults
|
||||
} catch (error) {
|
||||
log.error('Query by source numbers error', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query records by SourceNumber with deduplication by MaterialCode
|
||||
* Strategy: Keep first record for each MaterialCode
|
||||
* Order: CreateDate ASC, SequenceNumber ASC
|
||||
* @param sourceNumbers - List of SourceNumber values
|
||||
* @returns List of deduplicated records
|
||||
*/
|
||||
async queryBySourceNumbersDistinct(sourceNumbers: string[]): Promise<any[]> {
|
||||
if (!sourceNumbers || sourceNumbers.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
const batchSize = 1500
|
||||
const allResults: any[] = []
|
||||
|
||||
for (let i = 0; i < sourceNumbers.length; i += batchSize) {
|
||||
const batch = sourceNumbers.slice(i, i + batchSize)
|
||||
const placeholders = this.buildPlaceholders(batch.length, isSqlServer)
|
||||
|
||||
const sqlString = `
|
||||
WITH RankedRecords AS (
|
||||
SELECT
|
||||
*,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY MaterialCode
|
||||
ORDER BY CreateDate ASC, SequenceNumber ASC
|
||||
) AS rn
|
||||
FROM ${tableName}
|
||||
WHERE SourceNumber IN (${placeholders})
|
||||
AND MaterialCode IS NOT NULL
|
||||
)
|
||||
SELECT
|
||||
Factory, MaterialStatus, PlanNumber, SourceNumber, MaterialType,
|
||||
ProductCode, ProductName, ProductUnit, ProductPlanQuantity,
|
||||
UseDepartment, Remark, Creator, CreateDate, Approver, ApproveDate,
|
||||
SequenceNumber, MaterialCode, MaterialName, Specification, Model,
|
||||
DrawingNumber, MaterialQuality, PlanQuantity, Unit, RequiredDate,
|
||||
Warehouse, UnitUsage, CumulativeOutputQuantity, BOMVersion
|
||||
FROM RankedRecords
|
||||
WHERE rn = 1
|
||||
`
|
||||
|
||||
const result = await dbService.query(sqlString, batch)
|
||||
allResults.push(...result.rows)
|
||||
}
|
||||
|
||||
return allResults
|
||||
} catch (error) {
|
||||
log.error('Query by source numbers distinct error', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query by production order number (SourceNumber)
|
||||
* @param sourceNumber - SourceNumber value
|
||||
* @returns List of records
|
||||
*/
|
||||
async queryBySourceNumber(sourceNumber: string): Promise<any[]> {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
|
||||
const placeholder = isSqlServer ? '@p0' : '?'
|
||||
const sqlString = `
|
||||
SELECT *
|
||||
FROM ${tableName}
|
||||
WHERE SourceNumber = ${placeholder}
|
||||
`
|
||||
|
||||
const result = await dbService.query(sqlString, [sourceNumber])
|
||||
return result.rows
|
||||
} catch (error) {
|
||||
log.error('Query by source number error', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== QUERY BY PLAN NUMBER ====================
|
||||
|
||||
/**
|
||||
* Query by plan number
|
||||
* @param planNumber - PlanNumber value
|
||||
* @returns List of records
|
||||
*/
|
||||
async queryByPlanNumber(planNumber: string): Promise<any[]> {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
|
||||
const placeholder = isSqlServer ? '@p0' : '?'
|
||||
const sqlString = `
|
||||
SELECT *
|
||||
FROM ${tableName}
|
||||
WHERE PlanNumber = ${placeholder}
|
||||
`
|
||||
|
||||
const result = await dbService.query(sqlString, [planNumber])
|
||||
return result.rows
|
||||
} catch (error) {
|
||||
log.error('Query by plan number error', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query by multiple plan numbers
|
||||
* @param planNumbers - List of PlanNumber values
|
||||
* @returns List of records
|
||||
*/
|
||||
async queryByPlanNumbers(planNumbers: string[]): Promise<any[]> {
|
||||
if (!planNumbers || planNumbers.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
const batchSize = 1500
|
||||
const allResults: any[] = []
|
||||
|
||||
for (let i = 0; i < planNumbers.length; i += batchSize) {
|
||||
const batch = planNumbers.slice(i, i + batchSize)
|
||||
const placeholders = this.buildPlaceholders(batch.length, isSqlServer)
|
||||
|
||||
const sqlString = `
|
||||
SELECT *
|
||||
FROM ${tableName}
|
||||
WHERE PlanNumber IN (${placeholders})
|
||||
`
|
||||
|
||||
const result = await dbService.query(sqlString, batch)
|
||||
allResults.push(...result.rows)
|
||||
}
|
||||
|
||||
return allResults
|
||||
} catch (error) {
|
||||
log.error('Query by plan numbers error', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== DELETE OPERATIONS ====================
|
||||
|
||||
/**
|
||||
* Delete records by SourceNumber list
|
||||
* Uses batch processing for large lists
|
||||
* @param sourceNumbers - List of SourceNumber values to delete
|
||||
* @returns Number of records deleted
|
||||
*/
|
||||
async deleteBySourceNumbers(sourceNumbers: string[]): Promise<number> {
|
||||
if (!sourceNumbers || sourceNumbers.length === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
const batchSize = 2000
|
||||
let totalDeleted = 0
|
||||
|
||||
// Get unique source numbers
|
||||
const uniqueSourceNumbers = [...new Set(sourceNumbers.filter(Boolean))]
|
||||
|
||||
for (let i = 0; i < uniqueSourceNumbers.length; i += batchSize) {
|
||||
const batch = uniqueSourceNumbers.slice(i, i + batchSize)
|
||||
const placeholders = this.buildPlaceholders(batch.length, isSqlServer)
|
||||
|
||||
const sqlString = `
|
||||
DELETE FROM ${tableName}
|
||||
WHERE SourceNumber IN (${placeholders})
|
||||
`
|
||||
|
||||
const result = await dbService.query(sqlString, batch)
|
||||
totalDeleted += result.rowCount || 0
|
||||
|
||||
log.debug('Deleted batch', {
|
||||
batch: i / batchSize + 1,
|
||||
count: result.rowCount
|
||||
})
|
||||
}
|
||||
|
||||
log.info('Deleted records by source numbers', {
|
||||
totalDeleted,
|
||||
sourceNumberCount: uniqueSourceNumbers.length
|
||||
})
|
||||
|
||||
return totalDeleted
|
||||
} catch (error) {
|
||||
log.error('Delete by source numbers error', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== INSERT OPERATIONS ====================
|
||||
|
||||
/**
|
||||
* Insert records in batches
|
||||
* @param records - List of MaterialPlanRecord to insert
|
||||
* @param batchSize - Number of records per batch (default: 1000, auto-adjusted for SQL Server)
|
||||
* @returns Number of records inserted
|
||||
*/
|
||||
async batchInsert(records: MaterialPlanRecord[], batchSize = 1000): Promise<number> {
|
||||
if (!records || records.length === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
let totalInserted = 0
|
||||
|
||||
// SQL Server has a limit of 2100 parameters per query
|
||||
// Each record has 28 columns, so max rows per batch = 2100 / 28 = 75
|
||||
// Leave some margin for query overhead
|
||||
const columnsPerRow = 28
|
||||
const sqlServerMaxParams = 2000
|
||||
const effectiveBatchSize = isSqlServer
|
||||
? Math.min(batchSize, Math.floor(sqlServerMaxParams / columnsPerRow))
|
||||
: batchSize
|
||||
|
||||
log.info('Batch insert parameters', {
|
||||
isSqlServer,
|
||||
dbType: dbService.type,
|
||||
columnsPerRow,
|
||||
effectiveBatchSize,
|
||||
totalRecords: records.length
|
||||
})
|
||||
|
||||
// Process in batches
|
||||
for (let i = 0; i < records.length; i += effectiveBatchSize) {
|
||||
const batch = records.slice(i, i + effectiveBatchSize)
|
||||
const inserted = await this.insertBatch(dbService, tableName, batch, isSqlServer)
|
||||
totalInserted += inserted
|
||||
|
||||
log.debug('Inserted batch', {
|
||||
batch: Math.floor(i / effectiveBatchSize) + 1,
|
||||
count: inserted
|
||||
})
|
||||
}
|
||||
|
||||
log.info('Batch insert completed', {
|
||||
totalInserted,
|
||||
batchSize: effectiveBatchSize
|
||||
})
|
||||
|
||||
return totalInserted
|
||||
} catch (error) {
|
||||
log.error('Batch insert error', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a single batch of records
|
||||
*/
|
||||
private async insertBatch(
|
||||
dbService: IDatabaseService,
|
||||
tableName: string,
|
||||
records: MaterialPlanRecord[],
|
||||
isSqlServer: boolean
|
||||
): Promise<number> {
|
||||
if (records.length === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Build column list (excluding id)
|
||||
const columns = [
|
||||
'Factory',
|
||||
'MaterialStatus',
|
||||
'PlanNumber',
|
||||
'SourceNumber',
|
||||
'MaterialType',
|
||||
'ProductCode',
|
||||
'ProductName',
|
||||
'ProductUnit',
|
||||
'ProductPlanQuantity',
|
||||
'UseDepartment',
|
||||
'Remark',
|
||||
'Creator',
|
||||
'CreateDate',
|
||||
'Approver',
|
||||
'ApproveDate',
|
||||
'SequenceNumber',
|
||||
'MaterialCode',
|
||||
'MaterialName',
|
||||
'Specification',
|
||||
'Model',
|
||||
'DrawingNumber',
|
||||
'MaterialQuality',
|
||||
'PlanQuantity',
|
||||
'Unit',
|
||||
'RequiredDate',
|
||||
'Warehouse',
|
||||
'UnitUsage',
|
||||
'CumulativeOutputQuantity'
|
||||
]
|
||||
|
||||
// Build parameterized insert
|
||||
const values: any[] = []
|
||||
const rowPlaceholders: string[] = []
|
||||
|
||||
records.forEach((record, rowIndex) => {
|
||||
const rowValues = this.buildRowValues(record, columns, rowIndex, isSqlServer, values)
|
||||
rowPlaceholders.push(`(${rowValues.join(',')})`)
|
||||
})
|
||||
|
||||
const sqlString = `
|
||||
INSERT INTO ${tableName} (${columns.join(', ')})
|
||||
VALUES ${rowPlaceholders.join(', ')}
|
||||
`
|
||||
|
||||
const result = await dbService.query(sqlString, values)
|
||||
return result.rowCount || records.length
|
||||
}
|
||||
|
||||
/**
|
||||
* Build parameter values for a single row
|
||||
*/
|
||||
private buildRowValues(
|
||||
record: MaterialPlanRecord,
|
||||
columns: string[],
|
||||
_rowIndex: number,
|
||||
isSqlServer: boolean,
|
||||
values: any[]
|
||||
): string[] {
|
||||
return columns.map((col) => {
|
||||
const value = this.getColumnValue(record, col)
|
||||
values.push(value)
|
||||
|
||||
if (isSqlServer) {
|
||||
return `@p${values.length - 1}`
|
||||
} else {
|
||||
return '?'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the value for a specific column from the record
|
||||
*/
|
||||
private getColumnValue(record: MaterialPlanRecord, column: string): any {
|
||||
const columnMapping: Record<string, keyof MaterialPlanRecord> = {
|
||||
Factory: 'factory',
|
||||
MaterialStatus: 'materialStatus',
|
||||
PlanNumber: 'planNumber',
|
||||
SourceNumber: 'sourceNumber',
|
||||
MaterialType: 'materialType',
|
||||
ProductCode: 'productCode',
|
||||
ProductName: 'productName',
|
||||
ProductUnit: 'productUnit',
|
||||
ProductPlanQuantity: 'productPlanQuantity',
|
||||
UseDepartment: 'useDepartment',
|
||||
Remark: 'remark',
|
||||
Creator: 'creator',
|
||||
CreateDate: 'createDate',
|
||||
Approver: 'approver',
|
||||
ApproveDate: 'approveDate',
|
||||
SequenceNumber: 'sequenceNumber',
|
||||
MaterialCode: 'materialCode',
|
||||
MaterialName: 'materialName',
|
||||
Specification: 'specification',
|
||||
Model: 'model',
|
||||
DrawingNumber: 'drawingNumber',
|
||||
MaterialQuality: 'materialQuality',
|
||||
PlanQuantity: 'planQuantity',
|
||||
Unit: 'unit',
|
||||
RequiredDate: 'requiredDate',
|
||||
Warehouse: 'warehouse',
|
||||
UnitUsage: 'unitUsage',
|
||||
CumulativeOutputQuantity: 'cumulativeOutputQuantity'
|
||||
}
|
||||
|
||||
const key = columnMapping[column]
|
||||
if (!key) {
|
||||
return null
|
||||
}
|
||||
|
||||
const value = record[key]
|
||||
|
||||
// Handle null/undefined
|
||||
if (value === null || value === undefined) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Handle empty strings for string fields
|
||||
if (typeof value === 'string' && value.trim() === '') {
|
||||
return null
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
// ==================== UTILITY METHODS ====================
|
||||
|
||||
/**
|
||||
* Count all records
|
||||
* @returns Total number of records
|
||||
*/
|
||||
async countAll(): Promise<number> {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
|
||||
const sqlString = `SELECT COUNT(*) as count FROM ${tableName}`
|
||||
const result = await dbService.query(sqlString)
|
||||
|
||||
return result.rows.length > 0 ? (result.rows[0].count as number) : 0
|
||||
} catch (error) {
|
||||
log.error('Count all error', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Count records by plan number
|
||||
* @param planNumber - PlanNumber value
|
||||
* @returns Number of records
|
||||
*/
|
||||
async countByPlanNumber(planNumber: string): Promise<number> {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
|
||||
const placeholder = isSqlServer ? '@p0' : '?'
|
||||
const sqlString = `
|
||||
SELECT COUNT(*) as count
|
||||
FROM ${tableName}
|
||||
WHERE PlanNumber = ${placeholder}
|
||||
`
|
||||
|
||||
const result = await dbService.query(sqlString, [planNumber])
|
||||
return result.rows.length > 0 ? (result.rows[0].count as number) : 0
|
||||
} catch (error) {
|
||||
log.error('Count by plan number error', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unique material names
|
||||
* @param sourceNumbers - Optional list of SourceNumber values to filter
|
||||
* @returns List of unique material names
|
||||
*/
|
||||
async getUniqueMaterialNames(sourceNumbers?: string[]): Promise<string[]> {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
|
||||
if (sourceNumbers && sourceNumbers.length > 0) {
|
||||
const batchSize = 1500
|
||||
const allNames: string[] = []
|
||||
|
||||
for (let i = 0; i < sourceNumbers.length; i += batchSize) {
|
||||
const batch = sourceNumbers.slice(i, i + batchSize)
|
||||
const placeholders = this.buildPlaceholders(batch.length, isSqlServer)
|
||||
|
||||
const sqlString = `
|
||||
SELECT DISTINCT MaterialName
|
||||
FROM ${tableName}
|
||||
WHERE SourceNumber IN (${placeholders})
|
||||
AND MaterialName IS NOT NULL
|
||||
`
|
||||
|
||||
const result = await dbService.query(sqlString, batch)
|
||||
allNames.push(...result.rows.map((row) => row.MaterialName as string).filter(Boolean))
|
||||
}
|
||||
|
||||
return allNames
|
||||
} else {
|
||||
const sqlString = `
|
||||
SELECT DISTINCT MaterialName
|
||||
FROM ${tableName}
|
||||
WHERE MaterialName IS NOT NULL
|
||||
`
|
||||
|
||||
const result = await dbService.query(sqlString)
|
||||
return result.rows.map((row) => row.MaterialName as string).filter(Boolean)
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Get unique material names error', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get statistics
|
||||
* @returns Statistics object
|
||||
*/
|
||||
async getStatistics(): Promise<any> {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
|
||||
const sqlString = `
|
||||
SELECT
|
||||
COUNT(*) as totalRecords,
|
||||
COUNT(DISTINCT PlanNumber) as uniquePlans,
|
||||
COUNT(DISTINCT SourceNumber) as uniqueOrders,
|
||||
MIN(CreateDate) as earliestRecord,
|
||||
MAX(CreateDate) as latestRecord
|
||||
FROM ${tableName}
|
||||
`
|
||||
|
||||
const result = await dbService.query(sqlString)
|
||||
return result.rows.length > 0 ? result.rows[0] : {}
|
||||
} catch (error) {
|
||||
log.error('Get statistics error', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from database
|
||||
*/
|
||||
async disconnect(): Promise<void> {
|
||||
if (this.dbService) {
|
||||
await this.dbService.disconnect()
|
||||
this.dbService = null
|
||||
}
|
||||
}
|
||||
}
|
||||
143
src/main/services/database/entities/DiscreteMaterialPlan.ts
Normal file
143
src/main/services/database/entities/DiscreteMaterialPlan.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* TypeORM Entity for DiscreteMaterialPlanData table
|
||||
*/
|
||||
|
||||
import { Entity, PrimaryGeneratedColumn, Column, Index } from 'typeorm'
|
||||
|
||||
@Entity('DiscreteMaterialPlanData')
|
||||
export class DiscreteMaterialPlan {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number
|
||||
|
||||
@Column({ name: 'Factory', type: 'nvarchar', length: 100, nullable: true })
|
||||
factory!: string | null
|
||||
|
||||
@Column({ name: 'MaterialStatus', type: 'nvarchar', length: 50, nullable: true })
|
||||
materialStatus!: string | null
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'PlanNumber', type: 'nvarchar', length: 100, nullable: true })
|
||||
planNumber!: string | null
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'SourceNumber', type: 'nvarchar', length: 100, nullable: true })
|
||||
sourceNumber!: string | null
|
||||
|
||||
@Column({ name: 'MaterialType', type: 'nvarchar', length: 100, nullable: true })
|
||||
materialType!: string | null
|
||||
|
||||
@Column({ name: 'ProductCode', type: 'nvarchar', length: 100, nullable: true })
|
||||
productCode!: string | null
|
||||
|
||||
@Column({ name: 'ProductName', type: 'nvarchar', length: 255, nullable: true })
|
||||
productName!: string | null
|
||||
|
||||
@Column({ name: 'ProductUnit', type: 'nvarchar', length: 50, nullable: true })
|
||||
productUnit!: string | null
|
||||
|
||||
@Column({ name: 'ProductPlanQuantity', type: 'decimal', precision: 18, scale: 4, nullable: true })
|
||||
productPlanQuantity!: number | null
|
||||
|
||||
@Column({ name: 'UseDepartment', type: 'nvarchar', length: 100, nullable: true })
|
||||
useDepartment!: string | null
|
||||
|
||||
@Column({ name: 'Remark', type: 'nvarchar', length: 500, nullable: true })
|
||||
remark!: string | null
|
||||
|
||||
@Column({ name: 'Creator', type: 'nvarchar', length: 100, nullable: true })
|
||||
creator!: string | null
|
||||
|
||||
@Column({ name: 'CreateDate', type: 'datetime', nullable: true })
|
||||
createDate!: Date | null
|
||||
|
||||
@Column({ name: 'Approver', type: 'nvarchar', length: 100, nullable: true })
|
||||
approver!: string | null
|
||||
|
||||
@Column({ name: 'ApproveDate', type: 'datetime', nullable: true })
|
||||
approveDate!: Date | null
|
||||
|
||||
@Column({ name: 'SequenceNumber', type: 'int', nullable: true })
|
||||
sequenceNumber!: number | null
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'MaterialCode', type: 'nvarchar', length: 100, nullable: true })
|
||||
materialCode!: string | null
|
||||
|
||||
@Column({ name: 'MaterialName', type: 'nvarchar', length: 255, nullable: true })
|
||||
materialName!: string | null
|
||||
|
||||
@Column({ name: 'Specification', type: 'nvarchar', length: 255, nullable: true })
|
||||
specification!: string | null
|
||||
|
||||
@Column({ name: 'Model', type: 'nvarchar', length: 255, nullable: true })
|
||||
model!: string | null
|
||||
|
||||
@Column({ name: 'DrawingNumber', type: 'nvarchar', length: 100, nullable: true })
|
||||
drawingNumber!: string | null
|
||||
|
||||
@Column({ name: 'MaterialQuality', type: 'nvarchar', length: 100, nullable: true })
|
||||
materialQuality!: string | null
|
||||
|
||||
@Column({ name: 'PlanQuantity', type: 'decimal', precision: 18, scale: 4, nullable: true })
|
||||
planQuantity!: number | null
|
||||
|
||||
@Column({ name: 'Unit', type: 'nvarchar', length: 50, nullable: true })
|
||||
unit!: string | null
|
||||
|
||||
@Column({ name: 'RequiredDate', type: 'datetime', nullable: true })
|
||||
requiredDate!: Date | null
|
||||
|
||||
@Column({ name: 'Warehouse', type: 'nvarchar', length: 100, nullable: true })
|
||||
warehouse!: string | null
|
||||
|
||||
@Column({ name: 'UnitUsage', type: 'decimal', precision: 18, scale: 6, nullable: true })
|
||||
unitUsage!: number | null
|
||||
|
||||
@Column({
|
||||
name: 'CumulativeOutputQuantity',
|
||||
type: 'decimal',
|
||||
precision: 18,
|
||||
scale: 4,
|
||||
nullable: true
|
||||
})
|
||||
cumulativeOutputQuantity!: number | null
|
||||
|
||||
@Column({ name: 'BOMVersion', type: 'nvarchar', length: 50, nullable: true })
|
||||
bomVersion!: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Material plan record interface for type-safe operations
|
||||
*/
|
||||
export interface MaterialPlanRecordData {
|
||||
id?: number
|
||||
factory?: string | null
|
||||
materialStatus?: string | null
|
||||
planNumber?: string | null
|
||||
sourceNumber?: string | null
|
||||
materialType?: string | null
|
||||
productCode?: string | null
|
||||
productName?: string | null
|
||||
productUnit?: string | null
|
||||
productPlanQuantity?: number | null
|
||||
useDepartment?: string | null
|
||||
remark?: string | null
|
||||
creator?: string | null
|
||||
createDate?: Date | null
|
||||
approver?: string | null
|
||||
approveDate?: Date | null
|
||||
sequenceNumber?: number | null
|
||||
materialCode?: string | null
|
||||
materialName?: string | null
|
||||
specification?: string | null
|
||||
model?: string | null
|
||||
drawingNumber?: string | null
|
||||
materialQuality?: string | null
|
||||
planQuantity?: number | null
|
||||
unit?: string | null
|
||||
requiredDate?: Date | null
|
||||
warehouse?: string | null
|
||||
unitUsage?: number | null
|
||||
cumulativeOutputQuantity?: number | null
|
||||
bomVersion?: string | null
|
||||
}
|
||||
27
src/main/services/database/entities/MaterialsToBeDeleted.ts
Normal file
27
src/main/services/database/entities/MaterialsToBeDeleted.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* TypeORM Entity for MaterialsToBeDeleted table
|
||||
*/
|
||||
|
||||
import { Entity, PrimaryGeneratedColumn, Column, Index } from 'typeorm'
|
||||
|
||||
@Entity('MaterialsToBeDeleted')
|
||||
export class MaterialsToBeDeleted {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number
|
||||
|
||||
@Index({ unique: true })
|
||||
@Column({ name: 'MaterialCode', type: 'nvarchar', length: 255, nullable: false })
|
||||
materialCode!: string
|
||||
|
||||
@Column({ name: 'ManagerName', type: 'nvarchar', length: 255, nullable: true })
|
||||
managerName!: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Material record interface for type-safe operations
|
||||
*/
|
||||
export interface MaterialRecordData {
|
||||
id?: number
|
||||
materialCode: string
|
||||
managerName: string | null
|
||||
}
|
||||
184
src/main/services/database/index.ts
Normal file
184
src/main/services/database/index.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Database Factory
|
||||
*
|
||||
* Creates and manages database service instances based on configuration.
|
||||
* Supports both MySQL and SQL Server databases.
|
||||
*/
|
||||
|
||||
import { ConfigManager } from '../config/config-manager'
|
||||
import { MySqlService } from './mysql'
|
||||
import { SqlServerService } from './sql-server'
|
||||
import type {
|
||||
IDatabaseService,
|
||||
DatabaseType,
|
||||
MySqlConfig,
|
||||
SqlServerConfig
|
||||
} from '../../types/database.types'
|
||||
import { createLogger } from '../logger'
|
||||
|
||||
const log = createLogger('DatabaseFactory')
|
||||
|
||||
/**
|
||||
* Cached database service instances
|
||||
*/
|
||||
const instances: Map<DatabaseType, IDatabaseService> = new Map()
|
||||
|
||||
/**
|
||||
* Get the current database type from config manager
|
||||
*/
|
||||
export function getDatabaseType(): DatabaseType {
|
||||
const configManager = ConfigManager.getInstance()
|
||||
return configManager.getDatabaseType()
|
||||
}
|
||||
|
||||
/**
|
||||
* Create MySQL configuration from config manager
|
||||
*/
|
||||
export function createMySqlConfig(): MySqlConfig {
|
||||
const configManager = ConfigManager.getInstance()
|
||||
const dbConfig = configManager.getConfig().database.mysql
|
||||
return {
|
||||
host: dbConfig.host,
|
||||
port: dbConfig.port,
|
||||
user: dbConfig.username,
|
||||
password: dbConfig.password,
|
||||
database: dbConfig.database
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create SQL Server configuration from config manager
|
||||
*/
|
||||
export function createSqlServerConfig(): SqlServerConfig {
|
||||
const configManager = ConfigManager.getInstance()
|
||||
const dbConfig = configManager.getConfig().database.sqlserver
|
||||
return {
|
||||
server: dbConfig.server,
|
||||
port: dbConfig.port,
|
||||
user: dbConfig.username,
|
||||
password: dbConfig.password,
|
||||
database: dbConfig.database,
|
||||
options: {
|
||||
encrypt: false,
|
||||
trustServerCertificate: dbConfig.trustServerCertificate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a database service instance
|
||||
*
|
||||
* Uses singleton pattern - returns cached instance if available.
|
||||
*
|
||||
* @param type - Optional database type override (defaults to config)
|
||||
* @returns Database service instance
|
||||
*/
|
||||
export async function create(type?: DatabaseType): Promise<IDatabaseService> {
|
||||
const dbType = type || getDatabaseType()
|
||||
|
||||
// Return cached instance if available and connected
|
||||
const cached = instances.get(dbType)
|
||||
if (cached && cached.isConnected()) {
|
||||
log.debug('Returning cached database instance', { type: dbType })
|
||||
return cached
|
||||
}
|
||||
|
||||
// Create new instance
|
||||
let service: IDatabaseService
|
||||
|
||||
if (dbType === 'sqlserver') {
|
||||
log.info('Creating SQL Server database service')
|
||||
service = new SqlServerService(createSqlServerConfig())
|
||||
} else {
|
||||
log.info('Creating MySQL database service')
|
||||
service = new MySqlService(createMySqlConfig())
|
||||
}
|
||||
|
||||
// Connect to database
|
||||
await service.connect()
|
||||
log.info('Database connected', { type: dbType })
|
||||
|
||||
// Cache the instance
|
||||
instances.set(dbType, service)
|
||||
|
||||
return service
|
||||
}
|
||||
|
||||
/**
|
||||
* Get existing database service without creating new one
|
||||
*
|
||||
* @param type - Optional database type (defaults to config)
|
||||
* @returns Database service instance or undefined
|
||||
*/
|
||||
export function get(type?: DatabaseType): IDatabaseService | undefined {
|
||||
const dbType = type || getDatabaseType()
|
||||
return instances.get(dbType)
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect and remove a specific database service
|
||||
*
|
||||
* @param type - Optional database type (defaults to DB_TYPE env var)
|
||||
*/
|
||||
export async function disconnect(type?: DatabaseType): Promise<void> {
|
||||
const dbType = type || getDatabaseType()
|
||||
const service = instances.get(dbType)
|
||||
|
||||
if (service) {
|
||||
try {
|
||||
await service.disconnect()
|
||||
log.info('Database disconnected', { type: dbType })
|
||||
} catch (error) {
|
||||
log.warn('Error disconnecting database', {
|
||||
type: dbType,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
}
|
||||
instances.delete(dbType)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect all database services
|
||||
*/
|
||||
export async function disconnectAll(): Promise<void> {
|
||||
log.info('Disconnecting all database services')
|
||||
|
||||
const disconnectPromises = Array.from(instances.entries()).map(async ([type, service]) => {
|
||||
try {
|
||||
await service.disconnect()
|
||||
log.debug('Database disconnected', { type })
|
||||
} catch (error) {
|
||||
log.warn('Error disconnecting database', {
|
||||
type,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
await Promise.all(disconnectPromises)
|
||||
instances.clear()
|
||||
log.info('All database services disconnected')
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a database service is connected
|
||||
*
|
||||
* @param type - Optional database type (defaults to DB_TYPE env var)
|
||||
*/
|
||||
export function isConnected(type?: DatabaseType): boolean {
|
||||
const dbType = type || getDatabaseType()
|
||||
const service = instances.get(dbType)
|
||||
return service?.isConnected() ?? false
|
||||
}
|
||||
|
||||
// Re-export types and services
|
||||
export { MySqlService } from './mysql'
|
||||
export { SqlServerService } from './sql-server'
|
||||
export type {
|
||||
IDatabaseService,
|
||||
DatabaseType,
|
||||
QueryResult,
|
||||
MySqlConfig,
|
||||
SqlServerConfig
|
||||
} from '../../types/database.types'
|
||||
680
src/main/services/database/materials-to-be-deleted-dao.ts
Normal file
680
src/main/services/database/materials-to-be-deleted-dao.ts
Normal file
@@ -0,0 +1,680 @@
|
||||
/**
|
||||
* Data Access Object for MaterialsToBeDeleted table
|
||||
*
|
||||
* Mirrors the Python MaterialsToBeDeletedDAO functionality:
|
||||
* - CRUD operations for materials identified by MaterialCode
|
||||
* - Batch upsert operations
|
||||
* - Manager-based filtering and queries
|
||||
* - Statistics gathering
|
||||
*/
|
||||
|
||||
import { create, type IDatabaseService } from './index'
|
||||
import { createLogger } from '../logger'
|
||||
|
||||
const log = createLogger('MaterialsToBeDeletedDAO')
|
||||
|
||||
/**
|
||||
* Material record interface
|
||||
*/
|
||||
export interface MaterialRecord {
|
||||
id?: number
|
||||
materialCode: string
|
||||
managerName: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert statistics
|
||||
*/
|
||||
export interface UpsertStats {
|
||||
total: number
|
||||
success: number
|
||||
failed: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Material statistics
|
||||
*/
|
||||
export interface MaterialStats {
|
||||
totalMaterials: number
|
||||
uniqueManagers: number
|
||||
materialsPerManager: Record<string, number>[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for MaterialsToBeDeleted table
|
||||
*/
|
||||
export const MATERIALS_TO_BE_DELETED_CONFIG = {
|
||||
TABLE_NAME_SQLSERVER: '[dbo].[MaterialsToBeDeleted]',
|
||||
TABLE_NAME_MYSQL: 'dbo_MaterialsToBeDeleted',
|
||||
COLUMNS: {
|
||||
ID: 'ID',
|
||||
MATERIAL_CODE: 'MaterialCode',
|
||||
MANAGER_NAME: 'ManagerName'
|
||||
}
|
||||
} as const
|
||||
|
||||
/**
|
||||
* MaterialsToBeDeleted DAO Class
|
||||
*/
|
||||
export class MaterialsToBeDeletedDAO {
|
||||
private dbService: IDatabaseService | null = null
|
||||
|
||||
/**
|
||||
* Get the appropriate table name based on database type
|
||||
*/
|
||||
private getTableName(): string {
|
||||
const isSqlServer = this.dbService?.type === 'sqlserver'
|
||||
return isSqlServer
|
||||
? MATERIALS_TO_BE_DELETED_CONFIG.TABLE_NAME_SQLSERVER
|
||||
: MATERIALS_TO_BE_DELETED_CONFIG.TABLE_NAME_MYSQL
|
||||
}
|
||||
|
||||
/**
|
||||
* Get database service instance using DatabaseFactory
|
||||
*/
|
||||
private async getDatabaseService(): Promise<IDatabaseService> {
|
||||
if (this.dbService && this.dbService.isConnected()) {
|
||||
return this.dbService
|
||||
}
|
||||
|
||||
this.dbService = await create()
|
||||
return this.dbService
|
||||
}
|
||||
|
||||
/**
|
||||
* Build placeholders for IN clause based on database type
|
||||
*/
|
||||
private buildPlaceholders(count: number, isSqlServer: boolean): string {
|
||||
return isSqlServer
|
||||
? Array.from({ length: count }, (_, idx) => `@p${idx}`).join(',')
|
||||
: Array.from({ length: count }, () => '?').join(',')
|
||||
}
|
||||
|
||||
// ==================== UPSERT (MERGE) ====================
|
||||
|
||||
/**
|
||||
* Insert or update a single material record
|
||||
* @param materialCode - Material code (exact match key)
|
||||
* @param managerName - Manager name
|
||||
* @returns True if successful
|
||||
*/
|
||||
async upsertMaterial(materialCode: string, managerName: string): Promise<boolean> {
|
||||
if (!materialCode || !materialCode.trim()) {
|
||||
log.error('MaterialCode cannot be empty')
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const code = materialCode.trim()
|
||||
const manager = managerName?.trim() || null
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
|
||||
if (isSqlServer) {
|
||||
// SQL Server MERGE statement
|
||||
const sqlString = `
|
||||
MERGE ${tableName} AS target
|
||||
USING (VALUES (@p0, @p1)) AS source (MaterialCode, ManagerName)
|
||||
ON target.MaterialCode = source.MaterialCode
|
||||
WHEN MATCHED THEN UPDATE SET ManagerName = source.ManagerName
|
||||
WHEN NOT MATCHED THEN INSERT (MaterialCode, ManagerName) VALUES (source.MaterialCode, source.ManagerName);
|
||||
`
|
||||
|
||||
await dbService.query(sqlString, [code, manager])
|
||||
} else {
|
||||
// MySQL ON DUPLICATE KEY UPDATE
|
||||
const sqlString = `
|
||||
INSERT INTO ${tableName} (MaterialCode, ManagerName)
|
||||
VALUES (?, ?)
|
||||
ON DUPLICATE KEY UPDATE ManagerName = VALUES(ManagerName)
|
||||
`
|
||||
|
||||
await dbService.query(sqlString, [code, manager])
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
log.error('Upsert material error', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert or update multiple material records in batch
|
||||
* @param materials - List of materials with materialCode and managerName
|
||||
* @returns Statistics object
|
||||
*/
|
||||
async upsertBatch(
|
||||
materials: { materialCode: string; managerName: string }[]
|
||||
): Promise<UpsertStats> {
|
||||
if (!materials || materials.length === 0) {
|
||||
return { total: 0, success: 0, failed: 0 }
|
||||
}
|
||||
|
||||
const stats: UpsertStats = {
|
||||
total: materials.length,
|
||||
success: 0,
|
||||
failed: 0
|
||||
}
|
||||
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
|
||||
for (const material of materials) {
|
||||
const materialCode = material.materialCode?.trim()
|
||||
const managerName = material.managerName?.trim() || ''
|
||||
|
||||
if (!materialCode) {
|
||||
stats.failed++
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
if (isSqlServer) {
|
||||
// SQL Server MERGE statement
|
||||
const sqlString = `
|
||||
MERGE ${tableName} AS target
|
||||
USING (VALUES (@p0, @p1)) AS source (MaterialCode, ManagerName)
|
||||
ON target.MaterialCode = source.MaterialCode
|
||||
WHEN MATCHED THEN UPDATE SET ManagerName = source.ManagerName
|
||||
WHEN NOT MATCHED THEN INSERT (MaterialCode, ManagerName) VALUES (source.MaterialCode, source.ManagerName);
|
||||
`
|
||||
|
||||
await dbService.query(sqlString, [materialCode, managerName || null])
|
||||
} else {
|
||||
// MySQL ON DUPLICATE KEY UPDATE
|
||||
const sqlString = `
|
||||
INSERT INTO ${tableName} (MaterialCode, ManagerName)
|
||||
VALUES (?, ?)
|
||||
ON DUPLICATE KEY UPDATE ManagerName = VALUES(ManagerName)
|
||||
`
|
||||
|
||||
await dbService.query(sqlString, [materialCode, managerName || null])
|
||||
}
|
||||
|
||||
stats.success++
|
||||
} catch (error) {
|
||||
log.error('Error upserting material', {
|
||||
materialCode,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
stats.failed++
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Batch upsert error', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
stats.failed = stats.total - stats.success
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
/**
|
||||
* Update manager for a single material
|
||||
* @param materialCode - Material code
|
||||
* @param managerName - New manager name
|
||||
* @returns Success status
|
||||
*/
|
||||
async updateManager(
|
||||
materialCode: string,
|
||||
managerName: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
|
||||
if (isSqlServer) {
|
||||
const sqlString = `
|
||||
MERGE ${tableName} AS target
|
||||
USING (VALUES (@p0, @p1)) AS source (MaterialCode, ManagerName)
|
||||
ON target.MaterialCode = source.MaterialCode
|
||||
WHEN MATCHED THEN UPDATE SET ManagerName = source.ManagerName
|
||||
WHEN NOT MATCHED THEN INSERT (MaterialCode, ManagerName) VALUES (source.MaterialCode, source.ManagerName);
|
||||
`
|
||||
await dbService.query(sqlString, [materialCode, managerName || null])
|
||||
} else {
|
||||
const sqlString = `
|
||||
INSERT INTO ${tableName} (MaterialCode, ManagerName)
|
||||
VALUES (?, ?)
|
||||
ON DUPLICATE KEY UPDATE ManagerName = VALUES(ManagerName)
|
||||
`
|
||||
await dbService.query(sqlString, [materialCode, managerName || null])
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
log.error('Update manager error', {
|
||||
materialCode,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== READ ====================
|
||||
|
||||
/**
|
||||
* Get all material codes as a set
|
||||
* @returns Set of material codes
|
||||
*/
|
||||
async getAllMaterialCodes(): Promise<Set<string>> {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
|
||||
const sqlString = `
|
||||
SELECT MaterialCode
|
||||
FROM ${tableName}
|
||||
WHERE MaterialCode IS NOT NULL
|
||||
`
|
||||
|
||||
const result = await dbService.query(sqlString)
|
||||
return new Set(result.rows.map((row) => row.MaterialCode as string).filter(Boolean))
|
||||
} catch (error) {
|
||||
log.error('Get all material codes error', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return new Set()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all material records
|
||||
* @returns List of all material records
|
||||
*/
|
||||
async getAllRecords(): Promise<MaterialRecord[]> {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
|
||||
const sqlString = `
|
||||
SELECT ID, MaterialCode, ManagerName
|
||||
FROM ${tableName}
|
||||
WHERE MaterialCode IS NOT NULL
|
||||
ORDER BY ManagerName, MaterialCode
|
||||
`
|
||||
|
||||
const result = await dbService.query(sqlString)
|
||||
return result.rows.map((row) => ({
|
||||
id: row.ID as number,
|
||||
materialCode: row.MaterialCode as string,
|
||||
managerName: row.ManagerName as string
|
||||
}))
|
||||
} catch (error) {
|
||||
log.error('Get all records error', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all materials for a specific manager
|
||||
* @param managerName - Manager name
|
||||
* @returns List of materials for the manager
|
||||
*/
|
||||
async getMaterialsByManager(managerName: string): Promise<MaterialRecord[]> {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
|
||||
const placeholder = isSqlServer ? '@p0' : '?'
|
||||
const sqlString = `
|
||||
SELECT ID, MaterialCode, ManagerName
|
||||
FROM ${tableName}
|
||||
WHERE ManagerName = ${placeholder} AND MaterialCode IS NOT NULL
|
||||
ORDER BY MaterialCode
|
||||
`
|
||||
|
||||
const result = await dbService.query(sqlString, [managerName])
|
||||
return result.rows.map((row) => ({
|
||||
id: row.ID as number,
|
||||
materialCode: row.MaterialCode as string,
|
||||
managerName: row.ManagerName as string
|
||||
}))
|
||||
} catch (error) {
|
||||
log.error('Get materials by manager error', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of unique manager names
|
||||
* @returns List of unique manager names
|
||||
*/
|
||||
async getManagers(): Promise<string[]> {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
|
||||
const sqlString = `
|
||||
SELECT DISTINCT ManagerName
|
||||
FROM ${tableName}
|
||||
WHERE ManagerName IS NOT NULL
|
||||
ORDER BY ManagerName
|
||||
`
|
||||
|
||||
const result = await dbService.query(sqlString)
|
||||
return result.rows.map((row) => row.ManagerName as string).filter(Boolean)
|
||||
} catch (error) {
|
||||
log.error('Get managers error', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific record by material code
|
||||
* @param materialCode - Material code
|
||||
* @returns Material record or null
|
||||
*/
|
||||
async getRecordByMaterialCode(materialCode: string): Promise<MaterialRecord | null> {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const code = materialCode.trim()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
|
||||
const placeholder = isSqlServer ? '@p0' : '?'
|
||||
const sqlString = `
|
||||
SELECT ID, MaterialCode, ManagerName
|
||||
FROM ${tableName}
|
||||
WHERE MaterialCode = ${placeholder}
|
||||
`
|
||||
|
||||
const result = await dbService.query(sqlString, [code])
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const row = result.rows[0]
|
||||
return {
|
||||
id: row.ID as number,
|
||||
materialCode: row.MaterialCode as string,
|
||||
managerName: row.ManagerName as string
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Get record by material code error', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== DELETE ====================
|
||||
|
||||
/**
|
||||
* Delete a specific material by material code
|
||||
* @param materialCode - Material code
|
||||
* @returns True if successful
|
||||
*/
|
||||
async deleteByMaterialCode(materialCode: string): Promise<boolean> {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const code = materialCode.trim()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
|
||||
const placeholder = isSqlServer ? '@p0' : '?'
|
||||
const sqlString = `
|
||||
DELETE FROM ${tableName}
|
||||
WHERE MaterialCode = ${placeholder}
|
||||
`
|
||||
|
||||
const result = await dbService.query(sqlString, [code])
|
||||
return result.rowCount > 0
|
||||
} catch (error) {
|
||||
log.error('Delete by material code error', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all materials for a specific manager
|
||||
* @param managerName - Manager name
|
||||
* @returns Number of records deleted
|
||||
*/
|
||||
async deleteByManager(managerName: string): Promise<number> {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
|
||||
const placeholder = isSqlServer ? '@p0' : '?'
|
||||
const sqlString = `
|
||||
DELETE FROM ${tableName}
|
||||
WHERE ManagerName = ${placeholder}
|
||||
`
|
||||
|
||||
const result = await dbService.query(sqlString, [managerName])
|
||||
return result.rowCount
|
||||
} catch (error) {
|
||||
log.error('Delete by manager error', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all material records
|
||||
* @returns Number of records deleted
|
||||
*/
|
||||
async deleteAllMaterials(): Promise<number> {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
|
||||
const sqlString = `DELETE FROM ${tableName}`
|
||||
const result = await dbService.query(sqlString)
|
||||
return result.rowCount
|
||||
} catch (error) {
|
||||
log.error('Delete all materials error', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete multiple materials by material codes
|
||||
* @param materialCodes - List of material codes to delete
|
||||
* @returns Number of records deleted
|
||||
*/
|
||||
async deleteByMaterialCodes(materialCodes: string[]): Promise<number> {
|
||||
if (!materialCodes || materialCodes.length === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
let totalDeleted = 0
|
||||
const batchSize = 1000
|
||||
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
|
||||
for (let i = 0; i < materialCodes.length; i += batchSize) {
|
||||
const batch = materialCodes.slice(i, i + batchSize)
|
||||
const placeholders = this.buildPlaceholders(batch.length, isSqlServer)
|
||||
|
||||
const sqlString = `
|
||||
DELETE FROM ${tableName}
|
||||
WHERE MaterialCode IN (${placeholders})
|
||||
`
|
||||
|
||||
const result = await dbService.query(
|
||||
sqlString,
|
||||
batch.map((c) => c.trim())
|
||||
)
|
||||
totalDeleted += result.rowCount
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Delete by material codes error', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
}
|
||||
|
||||
return totalDeleted
|
||||
}
|
||||
|
||||
// ==================== UTILITIES ====================
|
||||
|
||||
/**
|
||||
* Check if a material exists
|
||||
* @param materialCode - Material code
|
||||
* @returns True if material exists
|
||||
*/
|
||||
async materialExists(materialCode: string): Promise<boolean> {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const code = materialCode.trim()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
|
||||
const placeholder = isSqlServer ? '@p0' : '?'
|
||||
const sqlString = `
|
||||
SELECT COUNT(*) as count
|
||||
FROM ${tableName}
|
||||
WHERE MaterialCode = ${placeholder}
|
||||
`
|
||||
|
||||
const result = await dbService.query(sqlString, [code])
|
||||
return result.rows.length > 0 && (result.rows[0].count as number) > 0
|
||||
} catch (error) {
|
||||
log.error('Material exists error', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Count all material records
|
||||
* @returns Total number of records
|
||||
*/
|
||||
async countAll(): Promise<number> {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
|
||||
const sqlString = `SELECT COUNT(*) as count FROM ${tableName}`
|
||||
const result = await dbService.query(sqlString)
|
||||
|
||||
return result.rows.length > 0 ? (result.rows[0].count as number) : 0
|
||||
} catch (error) {
|
||||
log.error('Count all error', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Count materials for a specific manager
|
||||
* @param managerName - Manager name
|
||||
* @returns Number of materials for the manager
|
||||
*/
|
||||
async countByManager(managerName: string): Promise<number> {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
|
||||
const placeholder = isSqlServer ? '@p0' : '?'
|
||||
const sqlString = `
|
||||
SELECT COUNT(*) as count
|
||||
FROM ${tableName}
|
||||
WHERE ManagerName = ${placeholder}
|
||||
`
|
||||
|
||||
const result = await dbService.query(sqlString, [managerName])
|
||||
return result.rows.length > 0 ? (result.rows[0].count as number) : 0
|
||||
} catch (error) {
|
||||
log.error('Count by manager error', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comprehensive statistics
|
||||
* @returns Statistics object
|
||||
*/
|
||||
async getStatistics(): Promise<MaterialStats> {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
|
||||
// Get total and unique managers
|
||||
const statsSql = `
|
||||
SELECT
|
||||
COUNT(*) as totalMaterials,
|
||||
COUNT(DISTINCT ManagerName) as uniqueManagers
|
||||
FROM ${tableName}
|
||||
WHERE MaterialCode IS NOT NULL
|
||||
`
|
||||
|
||||
const statsResult = await dbService.query(statsSql)
|
||||
const stats = statsResult.rows[0] || {}
|
||||
|
||||
// Get materials per manager
|
||||
const managerSql = `
|
||||
SELECT ManagerName, COUNT(*) as count
|
||||
FROM ${tableName}
|
||||
WHERE ManagerName IS NOT NULL
|
||||
GROUP BY ManagerName
|
||||
ORDER BY count DESC
|
||||
`
|
||||
|
||||
const managerResult = await dbService.query(managerSql)
|
||||
const materialsPerManager = managerResult.rows.map((row) => ({
|
||||
[row.ManagerName as string]: row.count as number
|
||||
}))
|
||||
|
||||
return {
|
||||
totalMaterials: (stats.totalMaterials as number) || 0,
|
||||
uniqueManagers: (stats.uniqueManagers as number) || 0,
|
||||
materialsPerManager
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Get statistics error', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return {
|
||||
totalMaterials: 0,
|
||||
uniqueManagers: 0,
|
||||
materialsPerManager: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from database
|
||||
*/
|
||||
async disconnect(): Promise<void> {
|
||||
if (this.dbService) {
|
||||
await this.dbService.disconnect()
|
||||
this.dbService = null
|
||||
}
|
||||
}
|
||||
}
|
||||
376
src/main/services/database/materials-type-to-be-deleted-dao.ts
Normal file
376
src/main/services/database/materials-type-to-be-deleted-dao.ts
Normal file
@@ -0,0 +1,376 @@
|
||||
/**
|
||||
* Data Access Object for MaterialsTypeToBeDeleted table
|
||||
*
|
||||
* Manages material type keywords for identifying materials to be deleted.
|
||||
* Used for matching material names against type keywords to assign managers.
|
||||
*/
|
||||
|
||||
import { create, type IDatabaseService } from './index'
|
||||
import { createLogger } from '../logger'
|
||||
|
||||
const log = createLogger('MaterialsTypeToBeDeletedDAO')
|
||||
|
||||
/**
|
||||
* Material type record interface
|
||||
*/
|
||||
export interface MaterialTypeRecord {
|
||||
id?: number
|
||||
materialName: string
|
||||
managerName: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch update request
|
||||
*/
|
||||
export interface MaterialTypeBatchRequest {
|
||||
toInsert: MaterialTypeRecord[]
|
||||
toUpdate: { old: MaterialTypeRecord; new: MaterialTypeRecord }[]
|
||||
toDelete: MaterialTypeRecord[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for MaterialsTypeToBeDeleted table
|
||||
*/
|
||||
export const MATERIALS_TYPE_TO_BE_DELETED_CONFIG = {
|
||||
TABLE_NAME_SQLSERVER: '[dbo].[MaterialsTypeToBeDeleted]',
|
||||
TABLE_NAME_MYSQL: 'dbo_MaterialsTypeToBeDeleted',
|
||||
COLUMNS: {
|
||||
ID: 'ID',
|
||||
MATERIAL_NAME: 'MaterialName',
|
||||
MANAGER_NAME: 'ManagerName'
|
||||
}
|
||||
} as const
|
||||
|
||||
/**
|
||||
* MaterialsTypeToBeDeleted DAO Class
|
||||
*/
|
||||
export class MaterialsTypeToBeDeletedDAO {
|
||||
private dbService: IDatabaseService | null = null
|
||||
|
||||
/**
|
||||
* Get the appropriate table name based on database type
|
||||
*/
|
||||
private getTableName(): string {
|
||||
const isSqlServer = this.dbService?.type === 'sqlserver'
|
||||
return isSqlServer
|
||||
? MATERIALS_TYPE_TO_BE_DELETED_CONFIG.TABLE_NAME_SQLSERVER
|
||||
: MATERIALS_TYPE_TO_BE_DELETED_CONFIG.TABLE_NAME_MYSQL
|
||||
}
|
||||
|
||||
/**
|
||||
* Get database service instance using DatabaseFactory
|
||||
*/
|
||||
private async getDatabaseService(): Promise<IDatabaseService> {
|
||||
if (this.dbService && this.dbService.isConnected()) {
|
||||
return this.dbService
|
||||
}
|
||||
|
||||
this.dbService = await create()
|
||||
return this.dbService
|
||||
}
|
||||
|
||||
// ==================== READ ====================
|
||||
|
||||
/**
|
||||
* Get all material type records
|
||||
* @returns List of all material type records
|
||||
*/
|
||||
async getAllMaterials(): Promise<MaterialTypeRecord[]> {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
|
||||
const sqlString = `
|
||||
SELECT ID, MaterialName, ManagerName
|
||||
FROM ${tableName}
|
||||
WHERE MaterialName IS NOT NULL
|
||||
ORDER BY ManagerName, MaterialName
|
||||
`
|
||||
|
||||
const result = await dbService.query(sqlString)
|
||||
return result.rows.map((row) => ({
|
||||
id: row.ID as number,
|
||||
materialName: row.MaterialName as string,
|
||||
managerName: row.ManagerName as string
|
||||
}))
|
||||
} catch (error) {
|
||||
log.error('Get all materials error', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all materials for a specific manager
|
||||
* @param managerName - Manager name
|
||||
* @returns List of materials for the manager
|
||||
*/
|
||||
async getMaterialsByManager(managerName: string): Promise<MaterialTypeRecord[]> {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
|
||||
const placeholder = isSqlServer ? '@p0' : '?'
|
||||
const sqlString = `
|
||||
SELECT ID, MaterialName, ManagerName
|
||||
FROM ${tableName}
|
||||
WHERE ManagerName = ${placeholder} AND MaterialName IS NOT NULL
|
||||
ORDER BY MaterialName
|
||||
`
|
||||
|
||||
const result = await dbService.query(sqlString, [managerName])
|
||||
return result.rows.map((row) => ({
|
||||
id: row.ID as number,
|
||||
materialName: row.MaterialName as string,
|
||||
managerName: row.ManagerName as string
|
||||
}))
|
||||
} catch (error) {
|
||||
log.error('Get materials by manager error', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of unique manager names
|
||||
* @returns List of unique manager names
|
||||
*/
|
||||
async getManagers(): Promise<string[]> {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
|
||||
const sqlString = `
|
||||
SELECT DISTINCT ManagerName
|
||||
FROM ${tableName}
|
||||
WHERE ManagerName IS NOT NULL
|
||||
ORDER BY ManagerName
|
||||
`
|
||||
|
||||
const result = await dbService.query(sqlString)
|
||||
return result.rows.map((row) => row.ManagerName as string).filter(Boolean)
|
||||
} catch (error) {
|
||||
log.error('Get managers error', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== UPSERT ====================
|
||||
|
||||
/**
|
||||
* Insert or update a material type record
|
||||
* @param materialName - Material name (type keyword)
|
||||
* @param managerName - Manager name
|
||||
* @returns True if successful
|
||||
*/
|
||||
async upsertMaterial(materialName: string, managerName: string): Promise<boolean> {
|
||||
if (!materialName || !materialName.trim()) {
|
||||
log.error('MaterialName cannot be empty')
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const name = materialName.trim()
|
||||
const manager = managerName?.trim() || null
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
|
||||
if (isSqlServer) {
|
||||
// SQL Server MERGE statement
|
||||
const sqlString = `
|
||||
MERGE ${tableName} AS target
|
||||
USING (VALUES (@p0, @p1)) AS source (MaterialName, ManagerName)
|
||||
ON target.MaterialName = source.MaterialName
|
||||
WHEN MATCHED THEN UPDATE SET ManagerName = source.ManagerName
|
||||
WHEN NOT MATCHED THEN INSERT (MaterialName, ManagerName) VALUES (source.MaterialName, source.ManagerName);
|
||||
`
|
||||
|
||||
await dbService.query(sqlString, [name, manager])
|
||||
} else {
|
||||
// MySQL ON DUPLICATE KEY UPDATE
|
||||
const sqlString = `
|
||||
INSERT INTO ${tableName} (MaterialName, ManagerName)
|
||||
VALUES (?, ?)
|
||||
ON DUPLICATE KEY UPDATE ManagerName = VALUES(ManagerName)
|
||||
`
|
||||
|
||||
await dbService.query(sqlString, [name, manager])
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
log.error('Upsert material error', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== DELETE ====================
|
||||
|
||||
/**
|
||||
* Delete a specific material type record
|
||||
* @param materialName - Material name
|
||||
* @param managerName - Manager name (optional, for verification)
|
||||
* @returns True if successful
|
||||
*/
|
||||
async deleteMaterial(materialName: string, managerName?: string): Promise<boolean> {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const name = materialName.trim()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
|
||||
let sqlString: string
|
||||
let params: (string | null)[]
|
||||
|
||||
if (managerName) {
|
||||
const placeholder1 = isSqlServer ? '@p0' : '?'
|
||||
const placeholder2 = isSqlServer ? '@p1' : '?'
|
||||
sqlString = `
|
||||
DELETE FROM ${tableName}
|
||||
WHERE MaterialName = ${placeholder1} AND ManagerName = ${placeholder2}
|
||||
`
|
||||
params = [name, managerName.trim()]
|
||||
} else {
|
||||
const placeholder = isSqlServer ? '@p0' : '?'
|
||||
sqlString = `
|
||||
DELETE FROM ${tableName}
|
||||
WHERE MaterialName = ${placeholder}
|
||||
`
|
||||
params = [name]
|
||||
}
|
||||
|
||||
const result = await dbService.query(sqlString, params)
|
||||
return result.rowCount > 0
|
||||
} catch (error) {
|
||||
log.error('Delete material error', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== UPDATE ====================
|
||||
|
||||
/**
|
||||
* Update a material type record (change name and/or manager)
|
||||
* @param oldName - Current material name
|
||||
* @param oldManager - Current manager name
|
||||
* @param newName - New material name
|
||||
* @param newManager - New manager name
|
||||
* @returns True if successful
|
||||
*/
|
||||
async updateMaterial(
|
||||
oldName: string,
|
||||
oldManager: string,
|
||||
newName: string,
|
||||
newManager: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
|
||||
if (isSqlServer) {
|
||||
const sqlString = `
|
||||
UPDATE ${tableName}
|
||||
SET MaterialName = @p0, ManagerName = @p1
|
||||
WHERE MaterialName = @p2 AND ManagerName = @p3
|
||||
`
|
||||
const result = await dbService.query(sqlString, [
|
||||
newName.trim(),
|
||||
newManager.trim(),
|
||||
oldName.trim(),
|
||||
oldManager.trim()
|
||||
])
|
||||
return result.rowCount > 0
|
||||
} else {
|
||||
const sqlString = `
|
||||
UPDATE ${tableName}
|
||||
SET MaterialName = ?, ManagerName = ?
|
||||
WHERE MaterialName = ? AND ManagerName = ?
|
||||
`
|
||||
const result = await dbService.query(sqlString, [
|
||||
newName.trim(),
|
||||
newManager.trim(),
|
||||
oldName.trim(),
|
||||
oldManager.trim()
|
||||
])
|
||||
return result.rowCount > 0
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Update material error', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== BATCH OPERATIONS ====================
|
||||
|
||||
/**
|
||||
* Process batch changes (insert, update, delete)
|
||||
* @param request - Batch request with toInsert, toUpdate, toDelete arrays
|
||||
* @returns Statistics object
|
||||
*/
|
||||
async upsertBatch(
|
||||
request: MaterialTypeBatchRequest
|
||||
): Promise<{ total: number; success: number; failed: number }> {
|
||||
const stats = { total: 0, success: 0, failed: 0 }
|
||||
|
||||
try {
|
||||
// Process inserts
|
||||
for (const record of request.toInsert) {
|
||||
stats.total++
|
||||
const success = await this.upsertMaterial(record.materialName, record.managerName)
|
||||
if (success) stats.success++
|
||||
else stats.failed++
|
||||
}
|
||||
|
||||
// Process updates
|
||||
for (const update of request.toUpdate) {
|
||||
stats.total++
|
||||
const success = await this.updateMaterial(
|
||||
update.old.materialName,
|
||||
update.old.managerName,
|
||||
update.new.materialName,
|
||||
update.new.managerName
|
||||
)
|
||||
if (success) stats.success++
|
||||
else stats.failed++
|
||||
}
|
||||
|
||||
// Process deletes
|
||||
for (const record of request.toDelete) {
|
||||
stats.total++
|
||||
const success = await this.deleteMaterial(record.materialName, record.managerName)
|
||||
if (success) stats.success++
|
||||
else stats.failed++
|
||||
}
|
||||
|
||||
return stats
|
||||
} catch (error) {
|
||||
log.error('Batch upsert error', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return stats
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from database
|
||||
*/
|
||||
async disconnect(): Promise<void> {
|
||||
if (this.dbService) {
|
||||
await this.dbService.disconnect()
|
||||
this.dbService = null
|
||||
}
|
||||
}
|
||||
}
|
||||
130
src/main/services/database/mysql.ts
Normal file
130
src/main/services/database/mysql.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import mysql from 'mysql2/promise'
|
||||
import type {
|
||||
IDatabaseService,
|
||||
DatabaseType,
|
||||
QueryResult,
|
||||
MySqlConfig
|
||||
} from '../../types/database.types'
|
||||
|
||||
export type { MySqlConfig } from '../../types/database.types'
|
||||
|
||||
export class MySqlService implements IDatabaseService {
|
||||
/** Database type identifier */
|
||||
readonly type: DatabaseType = 'mysql'
|
||||
|
||||
private connection: mysql.Connection | null = null
|
||||
private config: MySqlConfig
|
||||
|
||||
constructor(config: MySqlConfig) {
|
||||
this.config = config
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to MySQL database
|
||||
*/
|
||||
async connect(): Promise<void> {
|
||||
if (this.connection) {
|
||||
throw new Error('Already connected to MySQL')
|
||||
}
|
||||
|
||||
try {
|
||||
this.connection = await mysql.createConnection({
|
||||
host: this.config.host,
|
||||
port: this.config.port,
|
||||
user: this.config.user,
|
||||
password: this.config.password,
|
||||
database: this.config.database
|
||||
})
|
||||
|
||||
// Test connection
|
||||
await this.connection.ping()
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to connect to MySQL: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from MySQL database
|
||||
*/
|
||||
async disconnect(): Promise<void> {
|
||||
if (!this.connection) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await this.connection.end()
|
||||
this.connection = null
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to disconnect from MySQL: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if connected to MySQL database
|
||||
*/
|
||||
isConnected(): boolean {
|
||||
return this.connection !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a query and return results
|
||||
*/
|
||||
async query(sql: string, params?: any[]): Promise<QueryResult> {
|
||||
if (!this.connection) {
|
||||
throw new Error('Not connected to MySQL. Call connect() first.')
|
||||
}
|
||||
|
||||
try {
|
||||
const [result, fields] = await this.connection.execute(sql, params)
|
||||
|
||||
// Convert to plain objects and extract column names
|
||||
const columns = Array.isArray(fields) ? fields.map((field) => field.name) : []
|
||||
|
||||
// Handle different result types
|
||||
let rows: Record<string, unknown>[] = []
|
||||
let rowCount = 0
|
||||
|
||||
if (Array.isArray(result)) {
|
||||
// SELECT query - result is an array of rows
|
||||
rows = result as Record<string, unknown>[]
|
||||
rowCount = rows.length
|
||||
} else if (typeof result === 'object' && result !== null) {
|
||||
// INSERT/UPDATE/DELETE query - result is OkPacket
|
||||
const okPacket = result as any
|
||||
rowCount = okPacket.affectedRows || okPacket.changedRows || 0
|
||||
}
|
||||
|
||||
return {
|
||||
rows,
|
||||
columns,
|
||||
rowCount
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`MySQL query failed: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute multiple queries in a transaction
|
||||
*/
|
||||
async transaction(queries: { sql: string; params?: any[] }[]): Promise<void> {
|
||||
if (!this.connection) {
|
||||
throw new Error('Not connected to MySQL. Call connect() first.')
|
||||
}
|
||||
|
||||
try {
|
||||
await this.connection.beginTransaction()
|
||||
|
||||
for (const { sql, params } of queries) {
|
||||
await this.connection.execute(sql, params)
|
||||
}
|
||||
|
||||
await this.connection.commit()
|
||||
} catch (error) {
|
||||
if (this.connection) {
|
||||
await this.connection.rollback()
|
||||
}
|
||||
throw new Error(`MySQL transaction failed: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* Repository for DiscreteMaterialPlan entity
|
||||
*
|
||||
* Provides type-safe database operations for discrete material plan data.
|
||||
*/
|
||||
|
||||
import { DataSource, Repository, In } from 'typeorm'
|
||||
import { DiscreteMaterialPlan, MaterialPlanRecordData } from '../entities/DiscreteMaterialPlan'
|
||||
import { getDataSource } from '../data-source'
|
||||
import { createLogger } from '../../logger'
|
||||
|
||||
const log = createLogger('DiscreteMaterialPlanRepository')
|
||||
|
||||
/**
|
||||
* DiscreteMaterialPlan Repository class
|
||||
*/
|
||||
export class DiscreteMaterialPlanRepository {
|
||||
private repository: Repository<DiscreteMaterialPlan> | null = null
|
||||
private dataSource: DataSource | null = null
|
||||
|
||||
/**
|
||||
* Get the repository instance
|
||||
*/
|
||||
private async getRepository(): Promise<Repository<DiscreteMaterialPlan>> {
|
||||
if (!this.repository) {
|
||||
this.dataSource = getDataSource()
|
||||
if (!this.dataSource.isInitialized) {
|
||||
await this.dataSource.initialize()
|
||||
}
|
||||
this.repository = this.dataSource.getRepository(DiscreteMaterialPlan)
|
||||
}
|
||||
return this.repository
|
||||
}
|
||||
|
||||
/**
|
||||
* Query all records
|
||||
*/
|
||||
async queryAll(): Promise<DiscreteMaterialPlan[]> {
|
||||
try {
|
||||
const repo = await this.getRepository()
|
||||
return await repo.find()
|
||||
} catch (error) {
|
||||
log.error('Query all failed', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query all records with deduplication by MaterialCode
|
||||
*/
|
||||
async queryAllDistinctByMaterialCode(): Promise<DiscreteMaterialPlan[]> {
|
||||
try {
|
||||
const repo = await this.getRepository()
|
||||
|
||||
const query = `
|
||||
WITH RankedRecords AS (
|
||||
SELECT *,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY MaterialCode
|
||||
ORDER BY CreateDate ASC, SequenceNumber ASC
|
||||
) AS rn
|
||||
FROM DiscreteMaterialPlanData
|
||||
WHERE MaterialCode IS NOT NULL
|
||||
)
|
||||
SELECT * FROM RankedRecords WHERE rn = 1
|
||||
`
|
||||
|
||||
return await repo.query(query)
|
||||
} catch (error) {
|
||||
log.error('Query all distinct by material code failed', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query by source numbers (production order numbers)
|
||||
*/
|
||||
async queryBySourceNumbers(sourceNumbers: string[]): Promise<DiscreteMaterialPlan[]> {
|
||||
if (!sourceNumbers.length) return []
|
||||
|
||||
try {
|
||||
const repo = await this.getRepository()
|
||||
const batchSize = 2000
|
||||
const allResults: DiscreteMaterialPlan[] = []
|
||||
|
||||
for (let i = 0; i < sourceNumbers.length; i += batchSize) {
|
||||
const batch = sourceNumbers.slice(i, i + batchSize)
|
||||
const results = await repo.find({
|
||||
where: { sourceNumber: In(batch) }
|
||||
})
|
||||
allResults.push(...results)
|
||||
}
|
||||
|
||||
return allResults
|
||||
} catch (error) {
|
||||
log.error('Query by source numbers failed', {
|
||||
count: sourceNumbers.length,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query by source numbers with deduplication by MaterialCode
|
||||
*/
|
||||
async queryBySourceNumbersDistinct(sourceNumbers: string[]): Promise<DiscreteMaterialPlan[]> {
|
||||
if (!sourceNumbers.length) return []
|
||||
|
||||
try {
|
||||
const repo = await this.getRepository()
|
||||
const batchSize = 2000
|
||||
const allResults: DiscreteMaterialPlan[] = []
|
||||
|
||||
for (let i = 0; i < sourceNumbers.length; i += batchSize) {
|
||||
const batch = sourceNumbers.slice(i, i + batchSize)
|
||||
|
||||
const query = `
|
||||
WITH RankedRecords AS (
|
||||
SELECT *,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY MaterialCode
|
||||
ORDER BY CreateDate ASC, SequenceNumber ASC
|
||||
) AS rn
|
||||
FROM DiscreteMaterialPlanData
|
||||
WHERE SourceNumber IN (?) AND MaterialCode IS NOT NULL
|
||||
)
|
||||
SELECT * FROM RankedRecords WHERE rn = 1
|
||||
`
|
||||
|
||||
const results = await repo.query(query, [batch])
|
||||
allResults.push(...results)
|
||||
}
|
||||
|
||||
return allResults
|
||||
} catch (error) {
|
||||
log.error('Query by source numbers distinct failed', {
|
||||
count: sourceNumbers.length,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query by single source number
|
||||
*/
|
||||
async queryBySourceNumber(sourceNumber: string): Promise<DiscreteMaterialPlan[]> {
|
||||
try {
|
||||
const repo = await this.getRepository()
|
||||
return await repo.find({ where: { sourceNumber } })
|
||||
} catch (error) {
|
||||
log.error('Query by source number failed', {
|
||||
sourceNumber,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query by plan number
|
||||
*/
|
||||
async queryByPlanNumber(planNumber: string): Promise<DiscreteMaterialPlan[]> {
|
||||
try {
|
||||
const repo = await this.getRepository()
|
||||
return await repo.find({ where: { planNumber } })
|
||||
} catch (error) {
|
||||
log.error('Query by plan number failed', {
|
||||
planNumber,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query by multiple plan numbers
|
||||
*/
|
||||
async queryByPlanNumbers(planNumbers: string[]): Promise<DiscreteMaterialPlan[]> {
|
||||
if (!planNumbers.length) return []
|
||||
|
||||
try {
|
||||
const repo = await this.getRepository()
|
||||
return await repo.find({
|
||||
where: { planNumber: In(planNumbers) }
|
||||
})
|
||||
} catch (error) {
|
||||
log.error('Query by plan numbers failed', {
|
||||
count: planNumbers.length,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Count all records
|
||||
*/
|
||||
async countAll(): Promise<number> {
|
||||
try {
|
||||
const repo = await this.getRepository()
|
||||
return await repo.count()
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unique material names
|
||||
*/
|
||||
async getUniqueMaterialNames(sourceNumbers?: string[]): Promise<string[]> {
|
||||
try {
|
||||
const repo = await this.getRepository()
|
||||
|
||||
let query = repo
|
||||
.createQueryBuilder('m')
|
||||
.select('DISTINCT m.materialName', 'materialName')
|
||||
.where('m.materialName IS NOT NULL')
|
||||
|
||||
if (sourceNumbers && sourceNumbers.length > 0) {
|
||||
query = query.andWhere('m.sourceNumber IN (:...sourceNumbers)', { sourceNumbers })
|
||||
}
|
||||
|
||||
const result = await query.orderBy('m.materialName', 'ASC').getRawMany()
|
||||
return result.map((r) => r.materialName).filter(Boolean)
|
||||
} catch (error) {
|
||||
log.error('Get unique material names failed', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get statistics
|
||||
*/
|
||||
async getStatistics(): Promise<{
|
||||
totalRecords: number
|
||||
uniquePlans: number
|
||||
uniqueOrders: number
|
||||
earliestRecord: Date | null
|
||||
latestRecord: Date | null
|
||||
}> {
|
||||
try {
|
||||
const repo = await this.getRepository()
|
||||
|
||||
const result = await repo
|
||||
.createQueryBuilder('m')
|
||||
.select('COUNT(*)', 'totalRecords')
|
||||
.addSelect('COUNT(DISTINCT m.planNumber)', 'uniquePlans')
|
||||
.addSelect('COUNT(DISTINCT m.sourceNumber)', 'uniqueOrders')
|
||||
.addSelect('MIN(m.createDate)', 'earliestRecord')
|
||||
.addSelect('MAX(m.createDate)', 'latestRecord')
|
||||
.getRawOne()
|
||||
|
||||
return {
|
||||
totalRecords: parseInt(result?.totalRecords || '0', 10),
|
||||
uniquePlans: parseInt(result?.uniquePlans || '0', 10),
|
||||
uniqueOrders: parseInt(result?.uniqueOrders || '0', 10),
|
||||
earliestRecord: result?.earliestRecord || null,
|
||||
latestRecord: result?.latestRecord || null
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Get statistics failed', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return {
|
||||
totalRecords: 0,
|
||||
uniquePlans: 0,
|
||||
uniqueOrders: 0,
|
||||
earliestRecord: null,
|
||||
latestRecord: null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* Repository for MaterialsToBeDeleted entity
|
||||
*
|
||||
* Provides type-safe database operations for materials to be deleted.
|
||||
*/
|
||||
|
||||
import { DataSource, Repository, In } from 'typeorm'
|
||||
import { MaterialsToBeDeleted, MaterialRecordData } from '../entities/MaterialsToBeDeleted'
|
||||
import { getDataSource } from '../data-source'
|
||||
import { createLogger } from '../../logger'
|
||||
|
||||
const log = createLogger('MaterialsToBeDeletedRepository')
|
||||
|
||||
/**
|
||||
* Upsert statistics
|
||||
*/
|
||||
export interface UpsertStats {
|
||||
total: number
|
||||
success: number
|
||||
failed: number
|
||||
}
|
||||
|
||||
/**
|
||||
* MaterialsToBeDeleted Repository class
|
||||
*/
|
||||
export class MaterialsToBeDeletedRepository {
|
||||
private repository: Repository<MaterialsToBeDeleted> | null = null
|
||||
private dataSource: DataSource | null = null
|
||||
|
||||
/**
|
||||
* Get the repository instance
|
||||
*/
|
||||
private async getRepository(): Promise<Repository<MaterialsToBeDeleted>> {
|
||||
if (!this.repository) {
|
||||
this.dataSource = getDataSource()
|
||||
if (!this.dataSource.isInitialized) {
|
||||
await this.dataSource.initialize()
|
||||
}
|
||||
this.repository = this.dataSource.getRepository(MaterialsToBeDeleted)
|
||||
}
|
||||
return this.repository
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert or update a single material record
|
||||
*/
|
||||
async upsert(materialCode: string, managerName: string | null): Promise<boolean> {
|
||||
try {
|
||||
const repo = await this.getRepository()
|
||||
|
||||
// Use upsert pattern
|
||||
let entity = await repo.findOne({ where: { materialCode } })
|
||||
|
||||
if (entity) {
|
||||
entity.managerName = managerName
|
||||
} else {
|
||||
entity = repo.create({ materialCode, managerName })
|
||||
}
|
||||
|
||||
await repo.save(entity)
|
||||
log.debug('Upserted material', { materialCode })
|
||||
return true
|
||||
} catch (error) {
|
||||
log.error('Upsert material failed', {
|
||||
materialCode,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert or update multiple material records in batch
|
||||
*/
|
||||
async upsertBatch(materials: MaterialRecordData[]): Promise<UpsertStats> {
|
||||
const stats: UpsertStats = {
|
||||
total: materials.length,
|
||||
success: 0,
|
||||
failed: 0
|
||||
}
|
||||
|
||||
try {
|
||||
const repo = await this.getRepository()
|
||||
|
||||
for (const material of materials) {
|
||||
if (!material.materialCode?.trim()) {
|
||||
stats.failed++
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
let entity = await repo.findOne({ where: { materialCode: material.materialCode } })
|
||||
|
||||
if (entity) {
|
||||
entity.managerName = material.managerName
|
||||
} else {
|
||||
entity = repo.create({
|
||||
materialCode: material.materialCode,
|
||||
managerName: material.managerName
|
||||
})
|
||||
}
|
||||
|
||||
await repo.save(entity)
|
||||
stats.success++
|
||||
} catch {
|
||||
stats.failed++
|
||||
}
|
||||
}
|
||||
|
||||
log.info('Batch upsert completed', stats)
|
||||
return stats
|
||||
} catch (error) {
|
||||
log.error('Batch upsert failed', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
stats.failed = stats.total - stats.success
|
||||
return stats
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all material codes as a set
|
||||
*/
|
||||
async getAllMaterialCodes(): Promise<Set<string>> {
|
||||
try {
|
||||
const repo = await this.getRepository()
|
||||
const records = await repo.find({
|
||||
select: ['materialCode'],
|
||||
where: { materialCode: In([]) } // This will be overridden
|
||||
})
|
||||
|
||||
// Use query builder for better performance
|
||||
const result = await repo
|
||||
.createQueryBuilder('m')
|
||||
.select('m.materialCode')
|
||||
.where('m.materialCode IS NOT NULL')
|
||||
.getMany()
|
||||
|
||||
return new Set(result.map((r) => r.materialCode).filter(Boolean))
|
||||
} catch (error) {
|
||||
log.error('Get all material codes failed', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return new Set()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all records
|
||||
*/
|
||||
async getAllRecords(): Promise<MaterialsToBeDeleted[]> {
|
||||
try {
|
||||
const repo = await this.getRepository()
|
||||
return await repo.find({
|
||||
order: { managerName: 'ASC', materialCode: 'ASC' }
|
||||
})
|
||||
} catch (error) {
|
||||
log.error('Get all records failed', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get materials by manager name
|
||||
*/
|
||||
async getByManager(managerName: string): Promise<MaterialsToBeDeleted[]> {
|
||||
try {
|
||||
const repo = await this.getRepository()
|
||||
return await repo.find({
|
||||
where: { managerName },
|
||||
order: { materialCode: 'ASC' }
|
||||
})
|
||||
} catch (error) {
|
||||
log.error('Get by manager failed', {
|
||||
managerName,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unique manager names
|
||||
*/
|
||||
async getManagers(): Promise<string[]> {
|
||||
try {
|
||||
const repo = await this.getRepository()
|
||||
const result = await repo
|
||||
.createQueryBuilder('m')
|
||||
.select('DISTINCT m.managerName', 'managerName')
|
||||
.where('m.managerName IS NOT NULL')
|
||||
.orderBy('m.managerName', 'ASC')
|
||||
.getRawMany()
|
||||
|
||||
return result.map((r) => r.managerName).filter(Boolean)
|
||||
} catch (error) {
|
||||
log.error('Get managers failed', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete by material code
|
||||
*/
|
||||
async deleteByMaterialCode(materialCode: string): Promise<boolean> {
|
||||
try {
|
||||
const repo = await this.getRepository()
|
||||
const result = await repo.delete({ materialCode })
|
||||
return (result.affected ?? 0) > 0
|
||||
} catch (error) {
|
||||
log.error('Delete by material code failed', {
|
||||
materialCode,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete multiple materials by codes
|
||||
*/
|
||||
async deleteByMaterialCodes(materialCodes: string[]): Promise<number> {
|
||||
if (!materialCodes.length) return 0
|
||||
|
||||
try {
|
||||
const repo = await this.getRepository()
|
||||
const result = await repo.delete({ materialCode: In(materialCodes) })
|
||||
return result.affected ?? 0
|
||||
} catch (error) {
|
||||
log.error('Delete by material codes failed', {
|
||||
count: materialCodes.length,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a material exists
|
||||
*/
|
||||
async exists(materialCode: string): Promise<boolean> {
|
||||
try {
|
||||
const repo = await this.getRepository()
|
||||
const count = await repo.count({ where: { materialCode } })
|
||||
return count > 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Count all records
|
||||
*/
|
||||
async countAll(): Promise<number> {
|
||||
try {
|
||||
const repo = await this.getRepository()
|
||||
return await repo.count()
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
}
|
||||
191
src/main/services/database/sql-server.ts
Normal file
191
src/main/services/database/sql-server.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import sql from 'mssql'
|
||||
import type {
|
||||
IDatabaseService,
|
||||
DatabaseType,
|
||||
QueryResult,
|
||||
SqlServerConfig
|
||||
} from '../../types/database.types'
|
||||
|
||||
export type { SqlServerConfig } from '../../types/database.types'
|
||||
|
||||
export class SqlServerService implements IDatabaseService {
|
||||
/** Database type identifier */
|
||||
readonly type: DatabaseType = 'sqlserver'
|
||||
|
||||
private pool: sql.ConnectionPool | null = null
|
||||
private config: SqlServerConfig
|
||||
|
||||
constructor(config: SqlServerConfig) {
|
||||
this.config = config
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to SQL Server database
|
||||
*/
|
||||
async connect(): Promise<void> {
|
||||
if (this.pool) {
|
||||
throw new Error('Already connected to SQL Server')
|
||||
}
|
||||
|
||||
try {
|
||||
const poolConfig: sql.config = {
|
||||
server: this.config.server,
|
||||
port: this.config.port,
|
||||
user: this.config.user,
|
||||
password: this.config.password,
|
||||
database: this.config.database,
|
||||
options: {
|
||||
encrypt: this.config.options?.encrypt ?? false,
|
||||
trustServerCertificate: this.config.options?.trustServerCertificate ?? false
|
||||
}
|
||||
}
|
||||
|
||||
this.pool = new sql.ConnectionPool(poolConfig)
|
||||
await this.pool.connect()
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to connect to SQL Server: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from SQL Server database
|
||||
*/
|
||||
async disconnect(): Promise<void> {
|
||||
if (!this.pool) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await this.pool.close()
|
||||
this.pool = null
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to disconnect from SQL Server: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if connected to SQL Server database
|
||||
*/
|
||||
isConnected(): boolean {
|
||||
return this.pool !== null && this.pool.connected
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a query and return results
|
||||
* @param sqlString - SQL query string with @p0, @p1, ... placeholders
|
||||
* @param params - Query parameters as an array (converted to @p0, @p1, ...)
|
||||
*/
|
||||
async query(sqlString: string, params?: any[]): Promise<QueryResult> {
|
||||
if (!this.pool) {
|
||||
throw new Error('Not connected to SQL Server. Call connect() first.')
|
||||
}
|
||||
|
||||
try {
|
||||
const request = this.pool.request()
|
||||
|
||||
// Add parameters if provided - convert array to @p0, @p1, ... format
|
||||
if (params && params.length > 0) {
|
||||
params.forEach((value, index) => {
|
||||
request.input(`p${index}`, value)
|
||||
})
|
||||
}
|
||||
|
||||
const result = await request.query(sqlString)
|
||||
|
||||
// Convert recordset to array of objects (may be undefined for DELETE/INSERT/UPDATE)
|
||||
const rows = (result.recordset as Record<string, unknown>[]) || []
|
||||
// Extract column names from the first row if available
|
||||
const columns = rows.length > 0 ? Object.keys(rows[0]) : []
|
||||
|
||||
return {
|
||||
rows,
|
||||
columns,
|
||||
rowCount: result.rowsAffected?.[0] || rows.length
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`SQL Server query failed: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a prepared statement with named parameters
|
||||
* @param sqlString - SQL query string with @paramName placeholders
|
||||
* @param params - Parameters as an object with { value, type? } structure
|
||||
*/
|
||||
async queryWithParams(
|
||||
sqlString: string,
|
||||
params: Record<
|
||||
string,
|
||||
{
|
||||
value: unknown
|
||||
type?: sql.ISqlType | sql.ISqlTypeFactoryWithLength | sql.ISqlTypeWithLength
|
||||
}
|
||||
>
|
||||
): Promise<QueryResult> {
|
||||
if (!this.pool) {
|
||||
throw new Error('Not connected to SQL Server. Call connect() first.')
|
||||
}
|
||||
|
||||
try {
|
||||
const request = this.pool.request()
|
||||
|
||||
// Add parameters with explicit types
|
||||
for (const [key, { value, type }] of Object.entries(params)) {
|
||||
if (type) {
|
||||
request.input(key, type, value)
|
||||
} else {
|
||||
request.input(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
const result = await request.query(sqlString)
|
||||
|
||||
// Convert recordset to array of objects
|
||||
const rows = result.recordset as Record<string, unknown>[]
|
||||
// Extract column names from the first row if available
|
||||
const columns = rows.length > 0 ? Object.keys(rows[0]) : []
|
||||
|
||||
return {
|
||||
rows,
|
||||
columns,
|
||||
rowCount: result.rowsAffected?.[0] || rows.length
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`SQL Server query failed: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute multiple queries in a transaction
|
||||
* @param queries - Array of queries with array-based parameters
|
||||
*/
|
||||
async transaction(queries: { sql: string; params?: any[] }[]): Promise<void> {
|
||||
if (!this.pool) {
|
||||
throw new Error('Not connected to SQL Server. Call connect() first.')
|
||||
}
|
||||
|
||||
const transaction = new sql.Transaction(this.pool)
|
||||
|
||||
try {
|
||||
await transaction.begin()
|
||||
|
||||
for (const { sql: sqlString, params } of queries) {
|
||||
const request = new sql.Request(transaction)
|
||||
|
||||
// Add parameters if provided - convert array to @p0, @p1, ... format
|
||||
if (params && params.length > 0) {
|
||||
params.forEach((value, index) => {
|
||||
request.input(`p${index}`, value)
|
||||
})
|
||||
}
|
||||
|
||||
await request.query(sqlString)
|
||||
}
|
||||
|
||||
await transaction.commit()
|
||||
} catch (error) {
|
||||
await transaction.rollback()
|
||||
throw new Error(`SQL Server transaction failed: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
208
src/main/services/erp/ErpBrowserManager.ts
Normal file
208
src/main/services/erp/ErpBrowserManager.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* ERP Browser Manager
|
||||
*
|
||||
* Manages browser lifecycle for ERP automation.
|
||||
* Separates browser management from authentication logic.
|
||||
*/
|
||||
|
||||
import { chromium, type Browser, type BrowserContext, type Page } from 'playwright'
|
||||
import { createLogger } from '../logger'
|
||||
|
||||
const log = createLogger('ErpBrowserManager')
|
||||
|
||||
/**
|
||||
* Browser configuration options
|
||||
*/
|
||||
export interface BrowserConfig {
|
||||
headless?: boolean
|
||||
slowMo?: number
|
||||
viewport?: { width: number; height: number }
|
||||
ignoreHTTPSErrors?: boolean
|
||||
acceptDownloads?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Default browser configuration
|
||||
*/
|
||||
const DEFAULT_CONFIG: Required<BrowserConfig> = {
|
||||
headless: false,
|
||||
slowMo: 100,
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
ignoreHTTPSErrors: true,
|
||||
acceptDownloads: true
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser session containing all browser-related objects
|
||||
*/
|
||||
export interface BrowserSession {
|
||||
browser: Browser
|
||||
context: BrowserContext
|
||||
page: Page
|
||||
}
|
||||
|
||||
/**
|
||||
* ErpBrowserManager class
|
||||
* Manages browser lifecycle independently from ERP authentication
|
||||
*/
|
||||
export class ErpBrowserManager {
|
||||
private config: Required<BrowserConfig>
|
||||
private session: BrowserSession | null = null
|
||||
|
||||
constructor(config?: BrowserConfig) {
|
||||
this.config = { ...DEFAULT_CONFIG, ...config }
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch a new browser instance
|
||||
*/
|
||||
async launch(): Promise<Browser> {
|
||||
if (this.session?.browser?.isConnected()) {
|
||||
log.debug('Browser already running, returning existing instance')
|
||||
return this.session.browser
|
||||
}
|
||||
|
||||
log.info('Launching browser', { headless: this.config.headless })
|
||||
|
||||
const browser = await chromium.launch({
|
||||
headless: this.config.headless,
|
||||
slowMo: this.config.slowMo,
|
||||
args: [
|
||||
'--ignore-certificate-errors',
|
||||
'--ignore-ssl-errors',
|
||||
'--ignore-certificate-errors-spki-list',
|
||||
'--disable-web-security'
|
||||
]
|
||||
})
|
||||
|
||||
log.info('Browser launched successfully')
|
||||
return browser
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new browser context
|
||||
*/
|
||||
async createContext(browser?: Browser): Promise<BrowserContext> {
|
||||
const browserInstance = browser || (await this.launch())
|
||||
|
||||
log.debug('Creating browser context')
|
||||
|
||||
const context = await browserInstance.newContext({
|
||||
acceptDownloads: this.config.acceptDownloads,
|
||||
viewport: this.config.viewport,
|
||||
ignoreHTTPSErrors: true,
|
||||
javaScriptEnabled: true
|
||||
})
|
||||
|
||||
log.debug('Browser context created')
|
||||
return context
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new page in the context
|
||||
*/
|
||||
async createPage(context?: BrowserContext): Promise<Page> {
|
||||
let contextInstance: BrowserContext
|
||||
|
||||
if (context) {
|
||||
contextInstance = context
|
||||
} else if (this.session?.context) {
|
||||
contextInstance = this.session.context
|
||||
} else {
|
||||
const browser = await this.launch()
|
||||
contextInstance = await this.createContext(browser)
|
||||
}
|
||||
|
||||
log.debug('Creating new page')
|
||||
const page = await contextInstance.newPage()
|
||||
log.debug('Page created')
|
||||
|
||||
return page
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize a complete browser session
|
||||
* This creates browser, context, and page in one call
|
||||
*/
|
||||
async initialize(): Promise<BrowserSession> {
|
||||
if (this.session) {
|
||||
log.debug('Returning existing browser session')
|
||||
return this.session
|
||||
}
|
||||
|
||||
const browser = await this.launch()
|
||||
const context = await this.createContext(browser)
|
||||
const page = await this.createPage(context)
|
||||
|
||||
this.session = { browser, context, page }
|
||||
log.info('Browser session initialized')
|
||||
|
||||
return this.session
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current session
|
||||
*/
|
||||
getSession(): BrowserSession | null {
|
||||
return this.session
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if browser is running
|
||||
*/
|
||||
isRunning(): boolean {
|
||||
return this.session?.browser?.isConnected() ?? false
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the browser and cleanup
|
||||
*/
|
||||
async close(): Promise<void> {
|
||||
if (!this.session) {
|
||||
log.debug('No browser session to close')
|
||||
return
|
||||
}
|
||||
|
||||
log.info('Closing browser session')
|
||||
|
||||
try {
|
||||
if (this.session.context) {
|
||||
await this.session.context.close()
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn('Error closing context', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
if (this.session.browser) {
|
||||
await this.session.browser.close()
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn('Error closing browser', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
}
|
||||
|
||||
this.session = null
|
||||
log.info('Browser session closed')
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to a URL
|
||||
*/
|
||||
async navigate(url: string, options?: { timeout?: number }): Promise<void> {
|
||||
const page = this.session?.page
|
||||
if (!page) {
|
||||
throw new Error('No page available. Call initialize() first.')
|
||||
}
|
||||
|
||||
log.info('Navigating to URL', { url })
|
||||
await page.goto(url, { timeout: options?.timeout ?? 30000 })
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: options?.timeout ?? 10000 })
|
||||
log.debug('Page loaded')
|
||||
}
|
||||
}
|
||||
|
||||
export default ErpBrowserManager
|
||||
865
src/main/services/erp/cleaner.ts
Normal file
865
src/main/services/erp/cleaner.ts
Normal file
@@ -0,0 +1,865 @@
|
||||
import { ERP_LOCATORS } from './locators'
|
||||
import { ErpAuthService } from './erp-auth'
|
||||
import type { CleanerInput, CleanerResult, OrderCleanDetail } from '../../types/cleaner.types'
|
||||
import type { ErpSession } from '../../types/erp.types'
|
||||
import type { FrameLocator, Locator, Page } from 'playwright'
|
||||
import { createLogger } from '../logger'
|
||||
|
||||
const log = createLogger('CleanerService')
|
||||
|
||||
const DEFAULT_QUERY_BATCH_SIZE = 100
|
||||
const MAX_QUERY_BATCH_SIZE = 100
|
||||
const DEFAULT_PROCESS_CONCURRENCY = 1
|
||||
const MAX_PROCESS_CONCURRENCY = 20
|
||||
|
||||
interface RetryResult {
|
||||
retriedOrders: number
|
||||
successfulRetries: number
|
||||
updatedDetails: OrderCleanDetail[]
|
||||
}
|
||||
|
||||
interface ProgressState {
|
||||
completedOrders: number
|
||||
totalOrders: number
|
||||
}
|
||||
|
||||
interface QueryResultRow {
|
||||
rowIndex: number
|
||||
orderNumber: string
|
||||
}
|
||||
|
||||
class AsyncMutex {
|
||||
private queue: Promise<void> = Promise.resolve()
|
||||
|
||||
async runExclusive<T>(task: () => Promise<T>): Promise<T> {
|
||||
let release!: () => void
|
||||
const next = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
|
||||
const previous = this.queue
|
||||
this.queue = this.queue.then(() => next)
|
||||
|
||||
await previous
|
||||
try {
|
||||
return await task()
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleaner Service Options
|
||||
*/
|
||||
export interface CleanerOptions {
|
||||
dryRun?: boolean
|
||||
verbose?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Material deletion check parameters
|
||||
*/
|
||||
export interface ShouldDeleteParams {
|
||||
rowNumber: number
|
||||
pendingQty: string
|
||||
materialCode: string
|
||||
deleteSet: Set<string>
|
||||
}
|
||||
|
||||
function clampNumber(
|
||||
value: number | undefined,
|
||||
fallback: number,
|
||||
min: number,
|
||||
max: number
|
||||
): number {
|
||||
if (!Number.isFinite(value)) {
|
||||
return fallback
|
||||
}
|
||||
return Math.min(max, Math.max(min, Math.trunc(value ?? fallback)))
|
||||
}
|
||||
|
||||
export function createBatches<T>(items: T[], batchSize: number): T[][] {
|
||||
const batches: T[][] = []
|
||||
for (let i = 0; i < items.length; i += batchSize) {
|
||||
batches.push(items.slice(i, i + batchSize))
|
||||
}
|
||||
return batches
|
||||
}
|
||||
|
||||
export function getMissingOrders(inputOrders: string[], processedOrders: Set<string>): string[] {
|
||||
const uniqueInputOrders = Array.from(new Set(inputOrders))
|
||||
return uniqueInputOrders.filter((order) => !processedOrders.has(order))
|
||||
}
|
||||
|
||||
export async function runWithConcurrency<T, R>(
|
||||
items: T[],
|
||||
concurrency: number,
|
||||
worker: (item: T, index: number) => Promise<R>
|
||||
): Promise<R[]> {
|
||||
const results = new Array<R>(items.length)
|
||||
const limit = Math.max(1, Math.trunc(concurrency))
|
||||
let cursor = 0
|
||||
|
||||
const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
||||
while (true) {
|
||||
const current = cursor
|
||||
cursor += 1
|
||||
if (current >= items.length) {
|
||||
return
|
||||
}
|
||||
results[current] = await worker(items[current], current)
|
||||
}
|
||||
})
|
||||
|
||||
await Promise.all(runners)
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* ERP Cleaner Service
|
||||
* Deletes specified materials from production orders in ERP system
|
||||
*/
|
||||
export class CleanerService {
|
||||
private authService: ErpAuthService
|
||||
private dryRun: boolean
|
||||
|
||||
constructor(authService: ErpAuthService, options: CleanerOptions = {}) {
|
||||
this.authService = authService
|
||||
this.dryRun = options.dryRun ?? false
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if dry-run mode is enabled
|
||||
*/
|
||||
isDryRun(): boolean {
|
||||
return this.dryRun
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a material should be deleted
|
||||
*/
|
||||
shouldDeleteMaterial(params: ShouldDeleteParams): boolean {
|
||||
const { rowNumber, pendingQty, materialCode, deleteSet } = params
|
||||
|
||||
if (!deleteSet.has(materialCode)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (rowNumber >= 2000 && rowNumber < 8000) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (pendingQty && pendingQty.trim() !== '') {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
getSkipReason(params: ShouldDeleteParams): string {
|
||||
const { rowNumber, pendingQty, materialCode, deleteSet } = params
|
||||
|
||||
if (!deleteSet.has(materialCode)) {
|
||||
return '物料不在删除清单中'
|
||||
}
|
||||
if (rowNumber >= 2000 && rowNumber < 8000) {
|
||||
return '行号在 2000-7999 范围内(受保护)'
|
||||
}
|
||||
if (pendingQty && pendingQty.trim() !== '') {
|
||||
return '累计待发数量不为空'
|
||||
}
|
||||
return '未知原因'
|
||||
}
|
||||
|
||||
async clean(input: CleanerInput): Promise<CleanerResult> {
|
||||
const result: CleanerResult = {
|
||||
ordersProcessed: 0,
|
||||
materialsDeleted: 0,
|
||||
materialsSkipped: 0,
|
||||
errors: [],
|
||||
details: [],
|
||||
retriedOrders: 0,
|
||||
successfulRetries: 0
|
||||
}
|
||||
|
||||
const totalOrders = input.orderNumbers.length
|
||||
const dryRun = input.dryRun ?? this.dryRun
|
||||
const queryBatchSize = clampNumber(
|
||||
input.queryBatchSize,
|
||||
DEFAULT_QUERY_BATCH_SIZE,
|
||||
1,
|
||||
MAX_QUERY_BATCH_SIZE
|
||||
)
|
||||
const processConcurrency = clampNumber(
|
||||
input.processConcurrency,
|
||||
DEFAULT_PROCESS_CONCURRENCY,
|
||||
1,
|
||||
MAX_PROCESS_CONCURRENCY
|
||||
)
|
||||
|
||||
log.info('Starting cleaner', {
|
||||
totalOrders,
|
||||
materialCount: input.materialCodes.length,
|
||||
dryRun,
|
||||
queryBatchSize,
|
||||
processConcurrency
|
||||
})
|
||||
|
||||
const deleteSet = new Set(input.materialCodes)
|
||||
|
||||
let popupPage: Page | null = null
|
||||
|
||||
try {
|
||||
const session = this.authService.getSession()
|
||||
const navigation = await this.navigateToCleanerPage(session)
|
||||
popupPage = navigation.popupPage
|
||||
const { workFrame } = navigation
|
||||
|
||||
await this.setupQueryInterface(workFrame)
|
||||
|
||||
const orderBatches = createBatches(input.orderNumbers, queryBatchSize)
|
||||
const popupMutex = new AsyncMutex()
|
||||
const progressState: ProgressState = {
|
||||
completedOrders: 0,
|
||||
totalOrders
|
||||
}
|
||||
|
||||
for (let batchIndex = 0; batchIndex < orderBatches.length; batchIndex++) {
|
||||
const batchOrders = orderBatches[batchIndex]
|
||||
|
||||
log.info('Processing cleaner batch', {
|
||||
batchIndex: batchIndex + 1,
|
||||
totalBatches: orderBatches.length,
|
||||
batchSize: batchOrders.length
|
||||
})
|
||||
|
||||
await this.queryOrders(workFrame, batchOrders)
|
||||
await this.waitForLoading(workFrame)
|
||||
|
||||
const queriedRows = await this.collectQueryResultRows(workFrame)
|
||||
const queriedOrderNumbersInBatch = new Set(queriedRows.map((row) => row.orderNumber))
|
||||
|
||||
await runWithConcurrency(queriedRows, processConcurrency, async (row) => {
|
||||
const { rowIndex, orderNumber } = row
|
||||
const openedDetailPage = await popupMutex.runExclusive(async () => {
|
||||
return await this.openDetailPageFromRow(workFrame, popupPage!, rowIndex)
|
||||
})
|
||||
|
||||
let detail: OrderCleanDetail
|
||||
try {
|
||||
detail = await this.processDetailPage({
|
||||
detailPage: openedDetailPage,
|
||||
deleteSet,
|
||||
dryRun,
|
||||
expectedOrderNumber: orderNumber,
|
||||
progressState,
|
||||
onProgress: input.onProgress
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
detail = this.createErrorDetail(orderNumber, message)
|
||||
} finally {
|
||||
progressState.completedOrders += 1
|
||||
}
|
||||
|
||||
result.details.push(detail)
|
||||
|
||||
if (detail.errors.length > 0) {
|
||||
result.errors.push(`Order ${detail.orderNumber}: ${detail.errors.join('; ')}`)
|
||||
return
|
||||
}
|
||||
|
||||
result.ordersProcessed += 1
|
||||
result.materialsDeleted += detail.materialsDeleted
|
||||
result.materialsSkipped += detail.materialsSkipped
|
||||
})
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
const retryResult = await this.retryFailedOrders({
|
||||
workFrame,
|
||||
popupPage,
|
||||
failedDetails: result.details.filter(
|
||||
(d) => d.errors.length > 0 && this.isOrderNumber(d.orderNumber)
|
||||
),
|
||||
deleteSet,
|
||||
dryRun,
|
||||
onProgress: input.onProgress
|
||||
})
|
||||
|
||||
result.retriedOrders = retryResult.retriedOrders
|
||||
result.successfulRetries = retryResult.successfulRetries
|
||||
|
||||
retryResult.updatedDetails.forEach((updatedDetail) => {
|
||||
const index = result.details.findIndex((d) => d.orderNumber === updatedDetail.orderNumber)
|
||||
if (index !== -1) {
|
||||
const previousDetail = result.details[index]
|
||||
if (updatedDetail.retrySuccess && previousDetail.errors.length > 0) {
|
||||
result.ordersProcessed += 1
|
||||
result.materialsDeleted += updatedDetail.materialsDeleted
|
||||
result.materialsSkipped += updatedDetail.materialsSkipped
|
||||
}
|
||||
result.details[index] = updatedDetail
|
||||
}
|
||||
})
|
||||
|
||||
const successfulRetryOrders = new Set(
|
||||
retryResult.updatedDetails.filter((d) => d.retrySuccess).map((d) => d.orderNumber)
|
||||
)
|
||||
result.errors = result.errors.filter(
|
||||
(err) => !successfulRetryOrders.has(err.split(':')[0].replace('Order ', ''))
|
||||
)
|
||||
|
||||
log.info('Cleaner completed', {
|
||||
ordersProcessed: result.ordersProcessed,
|
||||
materialsDeleted: result.materialsDeleted,
|
||||
materialsSkipped: result.materialsSkipped,
|
||||
errorCount: result.errors.length
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
log.error('Cleaner failed', { error: message })
|
||||
result.errors.push(`Clean failed: ${message}`)
|
||||
} finally {
|
||||
if (popupPage) {
|
||||
try {
|
||||
await popupPage.close()
|
||||
} catch {
|
||||
// Ignore close errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async navigateToCleanerPage(
|
||||
session: ErpSession
|
||||
): Promise<{ popupPage: Page; workFrame: FrameLocator }> {
|
||||
const { page, mainFrame } = session
|
||||
|
||||
await mainFrame.locator('i').first().click()
|
||||
|
||||
const popupPromise = page.waitForEvent('popup')
|
||||
await mainFrame.getByTitle('离散生产订单维护', { exact: true }).first().click()
|
||||
const popupPage = await popupPromise
|
||||
|
||||
const forwardFrameLocator = popupPage.locator('#forwardFrame')
|
||||
const fFrame = forwardFrameLocator.contentFrame()
|
||||
|
||||
const innerFrameLocator = fFrame.locator('#mainiframe')
|
||||
await innerFrameLocator.waitFor({ state: 'visible', timeout: 30000 })
|
||||
const workFrame = innerFrameLocator.contentFrame()
|
||||
|
||||
await workFrame.locator('#hot-key-head_list').waitFor({ state: 'visible', timeout: 30000 })
|
||||
|
||||
return { popupPage, workFrame }
|
||||
}
|
||||
|
||||
private async setupQueryInterface(innerFrame: FrameLocator): Promise<void> {
|
||||
await innerFrame.locator('.search-name-wrapper > .iconfont').click()
|
||||
await innerFrame.getByText('订单号查询').click()
|
||||
await innerFrame.getByRole('tab', { name: '全部' }).click()
|
||||
|
||||
const inputEl = innerFrame.locator('#rc_select_0')
|
||||
await inputEl.fill('5000')
|
||||
await inputEl.press('Enter')
|
||||
}
|
||||
|
||||
private async queryOrders(workFrame: FrameLocator, orderNumbers: string[]): Promise<void> {
|
||||
const textbox = workFrame.getByRole('textbox', { name: '生产订单号' })
|
||||
await textbox.fill(orderNumbers.join(','))
|
||||
await workFrame.locator('.search-component-searchBtn').click()
|
||||
}
|
||||
|
||||
private async collectQueryResultRows(workFrame: FrameLocator): Promise<QueryResultRow[]> {
|
||||
const rows = workFrame.locator('tbody tr')
|
||||
const rowCount = await rows.count()
|
||||
const result: QueryResultRow[] = []
|
||||
|
||||
for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) {
|
||||
const row = rows.nth(rowIndex)
|
||||
const orderNumber = await this.extractOrderNumberFromQueryRow(row)
|
||||
if (!this.isOrderNumber(orderNumber)) {
|
||||
continue
|
||||
}
|
||||
result.push({ rowIndex, orderNumber })
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private async extractOrderNumberFromQueryRow(row: Locator): Promise<string> {
|
||||
try {
|
||||
const cell = row.locator('td[colkey="vbillcode"]')
|
||||
const codeLink = cell.locator('.code-detail-link').first()
|
||||
const rawValue =
|
||||
(await codeLink.count()) > 0 ? await codeLink.innerText() : await cell.innerText()
|
||||
const value = rawValue.trim()
|
||||
const match = value.match(/SC\d{14}/)
|
||||
return match ? match[0] : value
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
private async openDetailPageFromRow(
|
||||
workFrame: FrameLocator,
|
||||
popupPage: Page,
|
||||
rowIndex: number
|
||||
): Promise<Page> {
|
||||
const row = workFrame.locator('tbody tr').nth(rowIndex)
|
||||
await row.waitFor({ state: 'visible', timeout: 15000 })
|
||||
|
||||
const moreButton = row.locator('a.row-more').first()
|
||||
await moreButton.scrollIntoViewIfNeeded()
|
||||
|
||||
const detailPagePromise = popupPage.waitForEvent('popup')
|
||||
await moreButton.click()
|
||||
await this.clickMaterialPlanMenu(workFrame)
|
||||
|
||||
return await detailPagePromise
|
||||
}
|
||||
|
||||
private async openDetailPageFromCurrentQuery(
|
||||
workFrame: FrameLocator,
|
||||
popupPage: Page
|
||||
): Promise<Page> {
|
||||
const firstRow = workFrame.locator('tbody tr').first()
|
||||
await firstRow.waitFor({ state: 'visible', timeout: 10000 })
|
||||
|
||||
const moreButton = firstRow.locator('a.row-more').first()
|
||||
const detailPagePromise = popupPage.waitForEvent('popup')
|
||||
await moreButton.click()
|
||||
await this.clickMaterialPlanMenu(workFrame)
|
||||
|
||||
return await detailPagePromise
|
||||
}
|
||||
|
||||
private async clickMaterialPlanMenu(workFrame: FrameLocator): Promise<void> {
|
||||
const candidates = [
|
||||
workFrame.locator('li:visible, a:visible, span:visible, div:visible').filter({
|
||||
hasText: /^备料计划$/
|
||||
}),
|
||||
workFrame.getByRole('menuitem', { name: '备料计划' }),
|
||||
workFrame.getByText('备料计划', { exact: true }),
|
||||
workFrame.getByText('备料计划')
|
||||
]
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const target = candidate.last()
|
||||
try {
|
||||
await target.waitFor({ state: 'visible', timeout: 2000 })
|
||||
await target.click()
|
||||
return
|
||||
} catch {
|
||||
// Try next locator candidate
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('无法定位“备料计划”菜单项(可能菜单结构已变化)')
|
||||
}
|
||||
|
||||
private async processDetailPage(params: {
|
||||
detailPage: Page
|
||||
deleteSet: Set<string>
|
||||
dryRun: boolean
|
||||
progressState: ProgressState
|
||||
expectedOrderNumber?: string
|
||||
onProgress?: (
|
||||
message: string,
|
||||
progress?: number,
|
||||
extra?: Partial<import('../../types/cleaner.types').CleanerProgress>
|
||||
) => void
|
||||
}): Promise<OrderCleanDetail> {
|
||||
const { detailPage, deleteSet, dryRun, progressState, expectedOrderNumber, onProgress } = params
|
||||
|
||||
try {
|
||||
const detailMainFrame = detailPage.locator('#forwardFrame')
|
||||
const dFrame = await detailMainFrame.contentFrame()
|
||||
|
||||
if (!dFrame) {
|
||||
throw new Error('Failed to access detail page forward frame')
|
||||
}
|
||||
|
||||
const detailInnerLocator = dFrame.locator('#mainiframe')
|
||||
await detailInnerLocator.waitFor({ state: 'visible', timeout: 30000 })
|
||||
const detailInnerFrame = await detailInnerLocator.contentFrame()
|
||||
|
||||
if (!detailInnerFrame) {
|
||||
throw new Error('Failed to access detail inner frame')
|
||||
}
|
||||
|
||||
await detailInnerFrame
|
||||
.getByText(/^离散备料计划维护:/)
|
||||
.waitFor({ state: 'visible', timeout: 30000 })
|
||||
|
||||
const sourceOrderNumber = await this.extractSourceOrderNumber(detailInnerFrame)
|
||||
const orderNumber = sourceOrderNumber || expectedOrderNumber || 'UNKNOWN_ORDER'
|
||||
|
||||
const detail: OrderCleanDetail = {
|
||||
orderNumber,
|
||||
materialsDeleted: 0,
|
||||
materialsSkipped: 0,
|
||||
errors: [],
|
||||
skippedMaterials: [],
|
||||
retryCount: 0,
|
||||
retryAttempts: [],
|
||||
retriedAt: undefined,
|
||||
retrySuccess: false
|
||||
}
|
||||
|
||||
const detailCountText = await detailInnerFrame.getByText(/^详细信息 \(\d+\)$/).innerText()
|
||||
const detailCountMatch = detailCountText.match(/\((\d+)\)/)
|
||||
const detailCount = detailCountMatch ? parseInt(detailCountMatch[1], 10) : 0
|
||||
|
||||
const statusText = await detailInnerFrame.getByText(/^备料状态:.+$/).innerText()
|
||||
const statusMatch = statusText.replace(/\n/g, '').match(/备料状态:(.+)$/)
|
||||
const detailStatus = statusMatch ? statusMatch[1].trim() : ''
|
||||
|
||||
onProgress?.(
|
||||
`开始处理订单: ${orderNumber}`,
|
||||
this.calculateProgress(
|
||||
progressState.completedOrders,
|
||||
0,
|
||||
detailCount,
|
||||
progressState.totalOrders
|
||||
),
|
||||
{
|
||||
currentOrderIndex: progressState.completedOrders + 1,
|
||||
totalOrders: progressState.totalOrders,
|
||||
currentMaterialIndex: 0,
|
||||
totalMaterialsInOrder: detailCount,
|
||||
currentOrderNumber: orderNumber
|
||||
}
|
||||
)
|
||||
|
||||
if (detailStatus === '审批通过' && detailCount > 0) {
|
||||
await detailInnerFrame.getByRole('button', { name: '修改' }).click()
|
||||
|
||||
const saveButtonLocator = detailInnerFrame.getByRole('button', { name: '保存' })
|
||||
await saveButtonLocator.waitFor({ state: 'visible', timeout: 30000 })
|
||||
|
||||
await detailInnerFrame.getByText('展开').first().click()
|
||||
|
||||
const childForm = detailInnerFrame.locator('.card-table-side-box')
|
||||
const buttonWrapper = childForm.locator('.button-wrapper')
|
||||
const deleteRowBtn = buttonWrapper.getByRole('button', { name: '删行' })
|
||||
const nextBtn = buttonWrapper.locator('.icon-jiantouyou')
|
||||
const collapseBtn = buttonWrapper.locator('.icon-celashouqi')
|
||||
|
||||
let lastRowNumber = ''
|
||||
let materialIdx = 0
|
||||
|
||||
while (true) {
|
||||
materialIdx += 1
|
||||
|
||||
const currentRow = await this.getInputValue(childForm, /^行号$/)
|
||||
const rowNumInt = parseInt(currentRow, 10)
|
||||
|
||||
if (currentRow === lastRowNumber) {
|
||||
await this.delay(500)
|
||||
}
|
||||
|
||||
const materialCode = await this.getInputValue(childForm, /^材料编码/)
|
||||
const materialName = await this.getInputValue(childForm, /^材料名称/)
|
||||
const pendingQty = await this.getInputValue(childForm, /^累计待发数量$/)
|
||||
|
||||
const progress = this.calculateProgress(
|
||||
progressState.completedOrders,
|
||||
materialIdx,
|
||||
detailCount,
|
||||
progressState.totalOrders
|
||||
)
|
||||
|
||||
onProgress?.(
|
||||
`订单 ${orderNumber} - 物料 ${materialIdx}/${detailCount}: ${materialName}`,
|
||||
progress,
|
||||
{
|
||||
currentOrderIndex: progressState.completedOrders + 1,
|
||||
totalOrders: progressState.totalOrders,
|
||||
currentMaterialIndex: materialIdx,
|
||||
totalMaterialsInOrder: detailCount,
|
||||
currentOrderNumber: orderNumber
|
||||
}
|
||||
)
|
||||
|
||||
if (deleteSet.has(materialCode)) {
|
||||
const shouldDelete = this.shouldDeleteMaterial({
|
||||
rowNumber: rowNumInt,
|
||||
pendingQty,
|
||||
materialCode,
|
||||
deleteSet
|
||||
})
|
||||
|
||||
if (shouldDelete && !dryRun) {
|
||||
const oldRowNumber = currentRow
|
||||
await deleteRowBtn.click()
|
||||
|
||||
const deleteSuccess = await this.waitForRowChange(childForm, oldRowNumber, 10000)
|
||||
|
||||
if (deleteSuccess) {
|
||||
detail.materialsDeleted += 1
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (!shouldDelete) {
|
||||
detail.materialsSkipped += 1
|
||||
const reason = this.getSkipReason({
|
||||
rowNumber: rowNumInt,
|
||||
pendingQty,
|
||||
materialCode,
|
||||
deleteSet
|
||||
})
|
||||
detail.skippedMaterials.push({
|
||||
materialCode,
|
||||
materialName,
|
||||
rowNumber: rowNumInt,
|
||||
reason
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const isNextEnabled = await this.isButtonEnabled(nextBtn)
|
||||
if (isNextEnabled) {
|
||||
lastRowNumber = currentRow
|
||||
await nextBtn.click()
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
await collapseBtn.click()
|
||||
|
||||
if (!dryRun && detail.materialsDeleted > 0) {
|
||||
await saveButtonLocator.click()
|
||||
await saveButtonLocator.waitFor({ state: 'hidden', timeout: 60000 })
|
||||
}
|
||||
}
|
||||
|
||||
return detail
|
||||
} finally {
|
||||
await detailPage.close()
|
||||
}
|
||||
}
|
||||
|
||||
private calculateProgress(
|
||||
completedOrders: number,
|
||||
materialIdx: number,
|
||||
detailCount: number,
|
||||
totalOrders: number
|
||||
): number {
|
||||
const materialRatio = detailCount > 0 ? materialIdx / detailCount : 0
|
||||
return ((1 + completedOrders + materialRatio) / (1 + totalOrders)) * 100
|
||||
}
|
||||
|
||||
private async extractSourceOrderNumber(frame: FrameLocator): Promise<string> {
|
||||
try {
|
||||
const sourceOrder = await frame
|
||||
.locator('.vsourcebillcode .code-detail-link')
|
||||
.first()
|
||||
.innerText()
|
||||
const match = sourceOrder.match(/SC\d{14}/)
|
||||
return match ? match[0] : sourceOrder.trim()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
private isOrderNumber(value: string): boolean {
|
||||
return /^SC\d{14}$/.test(value)
|
||||
}
|
||||
|
||||
private createErrorDetail(orderNumber: string, message: string): OrderCleanDetail {
|
||||
return {
|
||||
orderNumber,
|
||||
materialsDeleted: 0,
|
||||
materialsSkipped: 0,
|
||||
errors: [message],
|
||||
skippedMaterials: [],
|
||||
retryCount: 0,
|
||||
retryAttempts: [],
|
||||
retriedAt: undefined,
|
||||
retrySuccess: false
|
||||
}
|
||||
}
|
||||
|
||||
private async waitForLoading(frame: FrameLocator): Promise<void> {
|
||||
const loadingLocator = frame
|
||||
.locator('div')
|
||||
.filter({ hasText: ERP_LOCATORS.extractor.loadingText })
|
||||
.nth(1)
|
||||
|
||||
try {
|
||||
await loadingLocator.waitFor({ state: 'visible', timeout: 3000 })
|
||||
await loadingLocator.waitFor({ state: 'hidden', timeout: 60000 })
|
||||
} catch {
|
||||
// Loading completed quickly or never appeared
|
||||
}
|
||||
}
|
||||
|
||||
private async getInputValue(
|
||||
container: FrameLocator | Locator,
|
||||
labelRegex: RegExp
|
||||
): Promise<string> {
|
||||
try {
|
||||
return await container
|
||||
.locator('div')
|
||||
.filter({ hasText: labelRegex })
|
||||
.locator('input')
|
||||
.first()
|
||||
.inputValue()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
private async isButtonEnabled(button: Locator): Promise<boolean> {
|
||||
try {
|
||||
return await button.isEnabled()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private async waitForRowChange(
|
||||
childForm: FrameLocator | Locator,
|
||||
oldRowNumber: string,
|
||||
maxWaitMs: number
|
||||
): Promise<boolean> {
|
||||
const startTime = Date.now()
|
||||
|
||||
while (Date.now() - startTime < maxWaitMs) {
|
||||
try {
|
||||
const newRowNumber = await this.getInputValue(childForm, /^行号$/)
|
||||
if (newRowNumber !== oldRowNumber) {
|
||||
return true
|
||||
}
|
||||
await this.delay(200)
|
||||
} catch {
|
||||
await this.delay(200)
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
private async retryFailedOrders(params: {
|
||||
workFrame: FrameLocator
|
||||
popupPage: Page
|
||||
failedDetails: OrderCleanDetail[]
|
||||
deleteSet: Set<string>
|
||||
dryRun: boolean
|
||||
onProgress?: (
|
||||
message: string,
|
||||
progress?: number,
|
||||
extra?: Partial<import('../../types/cleaner.types').CleanerProgress>
|
||||
) => void
|
||||
}): Promise<RetryResult> {
|
||||
const { workFrame, popupPage, failedDetails, deleteSet, dryRun, onProgress } = params
|
||||
|
||||
const result: RetryResult = {
|
||||
retriedOrders: 0,
|
||||
successfulRetries: 0,
|
||||
updatedDetails: []
|
||||
}
|
||||
|
||||
if (failedDetails.length === 0) {
|
||||
return result
|
||||
}
|
||||
|
||||
log.info('Starting retry for failed orders', { count: failedDetails.length })
|
||||
|
||||
const MAX_RETRIES = 2
|
||||
|
||||
for (let detailIndex = 0; detailIndex < failedDetails.length; detailIndex++) {
|
||||
const failedDetail = failedDetails[detailIndex]
|
||||
const orderNumber = failedDetail.orderNumber
|
||||
const retryAttempts: import('../../types/cleaner.types').RetryAttempt[] = []
|
||||
|
||||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
log.info(`Retrying order ${orderNumber} (attempt ${attempt}/${MAX_RETRIES})`)
|
||||
|
||||
await this.queryOrders(workFrame, [orderNumber])
|
||||
await this.waitForLoading(workFrame)
|
||||
|
||||
const rows = workFrame.locator('tbody tr')
|
||||
const rowCount = await rows.count()
|
||||
if (rowCount === 0) {
|
||||
throw new Error('订单重试查询无结果')
|
||||
}
|
||||
|
||||
const detailPage = await this.openDetailPageFromCurrentQuery(workFrame, popupPage)
|
||||
const retryDetail = await this.processDetailPage({
|
||||
detailPage,
|
||||
deleteSet,
|
||||
dryRun,
|
||||
expectedOrderNumber: orderNumber,
|
||||
progressState: {
|
||||
completedOrders: detailIndex,
|
||||
totalOrders: failedDetails.length
|
||||
},
|
||||
onProgress: (message, progress, extra) => {
|
||||
onProgress?.(
|
||||
`[重试 ${attempt}/${MAX_RETRIES}] ${message}`,
|
||||
progress,
|
||||
extra ? { ...extra, phase: 'processing' as const } : undefined
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
result.successfulRetries += 1
|
||||
result.updatedDetails.push({
|
||||
...retryDetail,
|
||||
retryCount: attempt,
|
||||
retriedAt: Date.now(),
|
||||
retrySuccess: true,
|
||||
retryAttempts
|
||||
})
|
||||
result.retriedOrders += 1
|
||||
break
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
log.warn(`Retry attempt ${attempt} failed for order ${orderNumber}: ${message}`)
|
||||
|
||||
retryAttempts.push({
|
||||
attempt,
|
||||
error: message,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
|
||||
if (attempt === MAX_RETRIES) {
|
||||
result.updatedDetails.push({
|
||||
...failedDetail,
|
||||
retryCount: MAX_RETRIES,
|
||||
retryAttempts,
|
||||
retriedAt: Date.now(),
|
||||
retrySuccess: false
|
||||
})
|
||||
result.retriedOrders += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.info('Retry process completed', {
|
||||
retriedOrders: result.retriedOrders,
|
||||
successfulRetries: result.successfulRetries
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
187
src/main/services/erp/erp-auth.ts
Normal file
187
src/main/services/erp/erp-auth.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { chromium } from 'playwright'
|
||||
import type { ErpConfig, ErpSession } from '../../types/erp.types'
|
||||
import { createLogger } from '../logger'
|
||||
|
||||
const log = createLogger('ErpAuthService')
|
||||
|
||||
// Timeout constants
|
||||
const PAGE_LOAD_TIMEOUT = 10000
|
||||
const LOGIN_RESULT_TIMEOUT = 15000
|
||||
const FORCE_LOGIN_TIMEOUT = 5000
|
||||
|
||||
/**
|
||||
* ERP Authentication Service
|
||||
* Manages login session and browser lifecycle
|
||||
*/
|
||||
export class ErpAuthService {
|
||||
private config: ErpConfig
|
||||
private session: ErpSession | null = null
|
||||
|
||||
constructor(config: ErpConfig) {
|
||||
this.config = config
|
||||
}
|
||||
|
||||
/**
|
||||
* Login to ERP system and establish session
|
||||
*/
|
||||
async login(): Promise<ErpSession> {
|
||||
if (this.session?.isLoggedIn) {
|
||||
return this.session
|
||||
}
|
||||
|
||||
// Launch browser with SSL certificate errors ignored
|
||||
const browser = await chromium.launch({
|
||||
headless: this.config.headless ?? false, // Use config or default to false
|
||||
slowMo: 100, // Slow down for debugging
|
||||
args: [
|
||||
'--ignore-certificate-errors',
|
||||
'--ignore-ssl-errors',
|
||||
'--ignore-certificate-errors-spki-list',
|
||||
'--disable-web-security' // Disable web security for internal VPN
|
||||
]
|
||||
})
|
||||
|
||||
const context = await browser.newContext({
|
||||
acceptDownloads: true,
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
ignoreHTTPSErrors: true, // Ignore SSL certificate errors
|
||||
// Disable web security for internal VPN
|
||||
javaScriptEnabled: true
|
||||
})
|
||||
|
||||
const page = await context.newPage()
|
||||
|
||||
// Navigate to login page (use actual login URL from Python code)
|
||||
const loginUrl = `${this.config.url}/yonbip/resources/uap/rbac/login/main/index.html`
|
||||
await page.goto(loginUrl)
|
||||
|
||||
// Wait for page to load
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: PAGE_LOAD_TIMEOUT })
|
||||
|
||||
// Wait for iframe to be present
|
||||
await page.waitForSelector('#forwardFrame', {
|
||||
state: 'attached',
|
||||
timeout: LOGIN_RESULT_TIMEOUT
|
||||
})
|
||||
|
||||
// Extract forwardFrame (Python: main_frame = page.locator("#forwardFrame").content_frame)
|
||||
// This is the main working frame for all subsequent operations
|
||||
const frameLocator = page.locator('#forwardFrame')
|
||||
const contentFrame = await frameLocator.contentFrame()
|
||||
|
||||
if (!contentFrame) {
|
||||
throw new Error('Failed to access forwardFrame content frame')
|
||||
}
|
||||
|
||||
// Store reference to main frame for later use (Python returns this as main_frame)
|
||||
const mainFrame = contentFrame
|
||||
|
||||
// Fill username using role-based locator (Python: get_by_role("textbox", name="用户名"))
|
||||
try {
|
||||
await contentFrame.getByRole('textbox', { name: '用户名' }).fill(this.config.username)
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to find username input: ${e}`)
|
||||
}
|
||||
|
||||
// Fill password using role-based locator (Python: get_by_role("textbox", name="密码"))
|
||||
try {
|
||||
await contentFrame.getByRole('textbox', { name: '密码' }).fill(this.config.password)
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to find password input: ${e}`)
|
||||
}
|
||||
|
||||
// Click login button using role-based locator (Python: get_by_role("button", name="登录"))
|
||||
try {
|
||||
await contentFrame.getByRole('button', { name: '登录' }).click()
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to click login button: ${e}`)
|
||||
}
|
||||
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: PAGE_LOAD_TIMEOUT }).catch(() => {
|
||||
log.warn('Page load state check timed out, continuing')
|
||||
})
|
||||
|
||||
await this.waitForLoginResult(mainFrame as unknown as import('playwright').Frame)
|
||||
|
||||
// Create session with mainFrame (Python returns main_frame as part of login result)
|
||||
this.session = {
|
||||
browser,
|
||||
context,
|
||||
page,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mainFrame: mainFrame as any, // Store forwardFrame content frame for subsequent operations
|
||||
isLoggedIn: true
|
||||
}
|
||||
|
||||
return this.session
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for login result: success, failure, or force login confirmation
|
||||
*/
|
||||
private async waitForLoginResult(mainFrame: import('playwright').Frame): Promise<void> {
|
||||
const successLocator = mainFrame.locator('.nc-workbench-icon')
|
||||
const errorLocator = mainFrame.getByText('名称或密码错误')
|
||||
const forceLoginButton = mainFrame.getByRole('button', { name: '确定' })
|
||||
|
||||
try {
|
||||
await Promise.race([
|
||||
successLocator.waitFor({ state: 'visible', timeout: LOGIN_RESULT_TIMEOUT }),
|
||||
errorLocator.waitFor({ state: 'visible', timeout: LOGIN_RESULT_TIMEOUT }),
|
||||
forceLoginButton
|
||||
.waitFor({ state: 'visible', timeout: FORCE_LOGIN_TIMEOUT })
|
||||
.then(async () => {
|
||||
log.info('Force login dialog detected, clicking confirm')
|
||||
await forceLoginButton.click()
|
||||
await this.waitForLoginResult(mainFrame)
|
||||
})
|
||||
])
|
||||
|
||||
const hasError = await errorLocator.isVisible()
|
||||
if (hasError) {
|
||||
throw new Error('ERP 登录失败:名称或密码错误')
|
||||
}
|
||||
|
||||
log.info('Login successful')
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('名称或密码错误')) {
|
||||
throw error
|
||||
}
|
||||
|
||||
const hasError = await errorLocator.isVisible().catch(() => false)
|
||||
if (hasError) {
|
||||
throw new Error('ERP 登录失败:名称或密码错误')
|
||||
}
|
||||
|
||||
log.info('Login successful')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close browser and cleanup session
|
||||
*/
|
||||
async close(): Promise<void> {
|
||||
if (this.session) {
|
||||
await this.session.context.close()
|
||||
await this.session.browser.close()
|
||||
this.session = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current session (must be logged in first)
|
||||
*/
|
||||
getSession(): ErpSession {
|
||||
if (!this.session?.isLoggedIn) {
|
||||
throw new Error('Not logged in. Call login() first.')
|
||||
}
|
||||
return this.session
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if session is active
|
||||
*/
|
||||
isActive(): boolean {
|
||||
return this.session?.isLoggedIn ?? false
|
||||
}
|
||||
}
|
||||
216
src/main/services/erp/extractor-core.ts
Normal file
216
src/main/services/erp/extractor-core.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import path from 'path'
|
||||
import { ERP_LOCATORS } from './locators'
|
||||
import type { ErpSession } from '../../types/erp.types'
|
||||
import type {
|
||||
ExtractorCoreInput,
|
||||
ExtractorCoreResult,
|
||||
ExtractionProgress
|
||||
} from '../../types/extractor.types'
|
||||
|
||||
/**
|
||||
* ExtractorCore - Handles all web page operations for data extraction
|
||||
* This class is responsible only for web interactions, not file processing
|
||||
*
|
||||
* Note: Uses 'any' for Frame types to maintain compatibility with Playwright's
|
||||
* frame handling API, matching the original implementation.
|
||||
*/
|
||||
export class ExtractorCore {
|
||||
/**
|
||||
* Execute all web page operations and return downloaded file paths
|
||||
* @param input - Contains session, order numbers, download directory, batch size, and progress callback
|
||||
* @returns List of downloaded file paths and any errors encountered
|
||||
*/
|
||||
async downloadAllBatches(input: ExtractorCoreInput): Promise<ExtractorCoreResult> {
|
||||
const result: ExtractorCoreResult = {
|
||||
downloadedFiles: [],
|
||||
errors: []
|
||||
}
|
||||
|
||||
const totalBatches = this.createBatches(input.orderNumbers, input.batchSize).length
|
||||
const totalPoints = 1 + totalBatches + 2
|
||||
const progressPerPoint = 100 / totalPoints
|
||||
|
||||
const { popupPage, workFrame } = await this.navigateToExtractorPage(input.session)
|
||||
|
||||
const batches = this.createBatches(input.orderNumbers, input.batchSize)
|
||||
|
||||
for (let i = 0; i < batches.length; i++) {
|
||||
const batch = batches[i]
|
||||
const progress = (1 + (i + 1)) * progressPerPoint
|
||||
|
||||
const progressExtra: Partial<ExtractionProgress> = {
|
||||
phase: 'downloading',
|
||||
currentBatch: i + 1,
|
||||
totalBatches
|
||||
}
|
||||
|
||||
input.onProgress?.(`处理批次 ${i + 1}/${totalBatches}`, progress, progressExtra)
|
||||
|
||||
try {
|
||||
const filePath = await this.downloadBatch(
|
||||
input.session,
|
||||
popupPage,
|
||||
workFrame,
|
||||
batch,
|
||||
i,
|
||||
batches.length,
|
||||
input.downloadDir
|
||||
)
|
||||
result.downloadedFiles.push(filePath)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
result.errors.push(`Batch ${i + 1}: ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to extractor/query page
|
||||
* Reference: Python extract() method lines 266-278
|
||||
*
|
||||
* Python workflow:
|
||||
* 1. main_frame.locator("i").first.click() - Click menu icon
|
||||
* 2. page.expect_popup() + get_by_title("离散备料计划维护").click() - Click menu item and wait for popup
|
||||
* 3. page1.locator("#forwardFrame").content_frame - Get popup's forward frame
|
||||
* 4. f_frame.locator("#mainiframe").content_frame - Get nested inner frame
|
||||
* 5. setup_query_interface(work_frame) - Setup query interface
|
||||
*/
|
||||
private async navigateToExtractorPage(
|
||||
session: ErpSession
|
||||
): Promise<{ popupPage: any; workFrame: any }> {
|
||||
const { page, mainFrame } = session
|
||||
|
||||
// Step 1: Click menu icon (Python line 266)
|
||||
// main_frame is #forwardFrame.content_frame returned from login
|
||||
await mainFrame.locator('i').first().click()
|
||||
|
||||
// Step 2: Click discrete material plan menu item and expect popup (Python lines 267-271)
|
||||
const popupPromise = page.waitForEvent('popup')
|
||||
await mainFrame.getByTitle('离散备料计划维护', { exact: true }).first().click()
|
||||
const popupPage = await popupPromise
|
||||
|
||||
// Step 3 & 4: Get nested frame structure in popup window (Python lines 273-276)
|
||||
// popup page contains #forwardFrame, which contains #mainiframe
|
||||
const forwardFrameLocator = popupPage.locator('#forwardFrame')
|
||||
const fFrame = await forwardFrameLocator.contentFrame()
|
||||
|
||||
if (!fFrame) {
|
||||
throw new Error('Failed to access popup forward frame')
|
||||
}
|
||||
|
||||
const innerFrameLocator = fFrame.locator('#mainiframe')
|
||||
await innerFrameLocator.waitFor({ state: 'visible', timeout: 15000 })
|
||||
const workFrame = await innerFrameLocator.contentFrame()
|
||||
|
||||
if (!workFrame) {
|
||||
throw new Error('Failed to access inner work frame')
|
||||
}
|
||||
|
||||
// Step 5: Setup query interface (Python line 278)
|
||||
await this.setupQueryInterface(workFrame)
|
||||
|
||||
return { popupPage, workFrame }
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup query interface
|
||||
* Reference: Python setup_query_interface() method lines 231-239
|
||||
*/
|
||||
private async setupQueryInterface(innerFrame: any): Promise<void> {
|
||||
// Click search icon (Python line 233)
|
||||
await innerFrame.locator('.search-name-wrapper > .iconfont').click()
|
||||
|
||||
// Click "订单号查询" menu item (Python line 234)
|
||||
await innerFrame.getByText('订单号查询').click()
|
||||
|
||||
// Click "全部" tab (Python line 235)
|
||||
await innerFrame.getByRole('tab', { name: '全部' }).click()
|
||||
|
||||
// Set limit to 5000 (Python lines 237-239)
|
||||
const inputBox = innerFrame.locator('#rc_select_0')
|
||||
await inputBox.fill('5000')
|
||||
await inputBox.press('Enter')
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a single batch of orders
|
||||
* Reference: Python download_batch() method lines 133-175
|
||||
*/
|
||||
private async downloadBatch(
|
||||
_session: ErpSession,
|
||||
popupPage: any,
|
||||
workFrame: any,
|
||||
orderNumbers: string[],
|
||||
batchIndex: number,
|
||||
_totalBatches: number,
|
||||
downloadDir: string
|
||||
): Promise<string> {
|
||||
// Fill order numbers (Python lines 143-145)
|
||||
const textbox = workFrame.getByRole('textbox', { name: '来源生产订单号' })
|
||||
await textbox.fill('')
|
||||
await textbox.fill(orderNumbers.join(','))
|
||||
|
||||
// Click search button (Python line 147)
|
||||
await workFrame.locator('.search-component-searchBtn').click()
|
||||
|
||||
// Wait for loading (Python lines 148-153)
|
||||
await this.waitForLoading(workFrame)
|
||||
|
||||
// Click first row checkbox (Python line 155)
|
||||
await workFrame.getByRole('row', { name: '序号' }).getByLabel('').click()
|
||||
|
||||
// Hover and click "更多" button (Python lines 156-157)
|
||||
await workFrame.getByRole('button', { name: '更多' }).hover()
|
||||
await workFrame.getByText('输出', { exact: true }).click()
|
||||
|
||||
// Set threshold (Python lines 159-164)
|
||||
const thresholdBox = workFrame
|
||||
.locator('div')
|
||||
.filter({ hasText: /^行数阈值$/ })
|
||||
.locator('input[type="text"]')
|
||||
await thresholdBox.fill('300000')
|
||||
|
||||
// Setup download handler and click confirm (Python lines 166-172)
|
||||
const downloadPath = path.join(downloadDir, `temp_batch_${batchIndex + 1}.xlsx`)
|
||||
|
||||
const downloadPromise = popupPage.waitForEvent('download')
|
||||
await workFrame.getByRole('button', { name: '确定(Y)' }).click()
|
||||
|
||||
const download = await downloadPromise
|
||||
await download.saveAs(downloadPath)
|
||||
|
||||
return downloadPath
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for loading overlay to disappear
|
||||
* Reference: Python lines 148-153
|
||||
*/
|
||||
private async waitForLoading(workFrame: any): Promise<void> {
|
||||
const loadingLocator = workFrame
|
||||
.locator('div')
|
||||
.filter({ hasText: ERP_LOCATORS.extractor.loadingText })
|
||||
.nth(1)
|
||||
|
||||
try {
|
||||
await loadingLocator.waitFor({ state: 'visible', timeout: 3000 })
|
||||
await loadingLocator.waitFor({ state: 'hidden', timeout: 0 })
|
||||
} catch {
|
||||
// Loading completed quickly or never appeared
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Split array into batches
|
||||
* Reference: Python group_order_ids() method lines 128-131
|
||||
*/
|
||||
private createBatches<T>(items: T[], batchSize: number): T[][] {
|
||||
const batches: T[][] = []
|
||||
for (let i = 0; i < items.length; i += batchSize) {
|
||||
batches.push(items.slice(i, i + batchSize))
|
||||
}
|
||||
return batches
|
||||
}
|
||||
}
|
||||
357
src/main/services/erp/extractor.ts
Normal file
357
src/main/services/erp/extractor.ts
Normal file
@@ -0,0 +1,357 @@
|
||||
import path from 'path'
|
||||
import fs from 'fs/promises'
|
||||
import { ExtractorCore } from './extractor-core'
|
||||
import { ErpAuthService } from './erp-auth'
|
||||
import { ExcelParser } from '../excel/excel-parser'
|
||||
import type {
|
||||
ExtractorInput,
|
||||
ExtractorResult,
|
||||
ImportResult,
|
||||
LogLevel
|
||||
} from '../../types/extractor.types'
|
||||
import { DataImportService } from '../database/data-importer'
|
||||
import { createLogger } from '../logger'
|
||||
|
||||
const log = createLogger('ExtractorService')
|
||||
|
||||
/**
|
||||
* ERP Data Extractor Service
|
||||
* Downloads material plan data for given order numbers
|
||||
*
|
||||
* This service orchestrates the extraction process:
|
||||
* - Uses ExtractorCore for web page operations
|
||||
* - Handles file merging and cleanup
|
||||
*
|
||||
* Reference: playwrite/utils/discrete_material_plan_extractor.py
|
||||
*/
|
||||
export class ExtractorService {
|
||||
private authService: ErpAuthService
|
||||
private downloadDir: string
|
||||
|
||||
constructor(authService: ErpAuthService, downloadDir = './downloads') {
|
||||
this.authService = authService
|
||||
this.downloadDir = downloadDir
|
||||
|
||||
// Ensure download directory exists
|
||||
fs.mkdir(downloadDir, { recursive: true }).catch(() => {})
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract data for given order numbers
|
||||
* Orchestrates the extraction process by delegating web operations to ExtractorCore
|
||||
* and handling file merging/cleanup
|
||||
*/
|
||||
async extract(input: ExtractorInput): Promise<ExtractorResult> {
|
||||
const result: ExtractorResult = {
|
||||
downloadedFiles: [],
|
||||
mergedFile: null,
|
||||
recordCount: 0,
|
||||
errors: []
|
||||
}
|
||||
|
||||
try {
|
||||
const session = this.authService.getSession()
|
||||
|
||||
// Call ExtractorCore to execute web page operations
|
||||
const core = new ExtractorCore()
|
||||
const coreResult = await core.downloadAllBatches({
|
||||
session,
|
||||
orderNumbers: input.orderNumbers,
|
||||
downloadDir: this.downloadDir,
|
||||
batchSize: input.batchSize || 100,
|
||||
onProgress: input.onProgress
|
||||
})
|
||||
|
||||
result.downloadedFiles = coreResult.downloadedFiles
|
||||
result.errors = coreResult.errors
|
||||
|
||||
// Merge downloaded files (original logic preserved)
|
||||
if (result.downloadedFiles.length > 0) {
|
||||
const totalBatches = result.downloadedFiles.length
|
||||
const totalPoints = 1 + totalBatches + 2
|
||||
const progressPerPoint = 100 / totalPoints
|
||||
const mergeProgress = (1 + totalBatches) * progressPerPoint
|
||||
|
||||
input.onProgress?.('正在合并文件...', mergeProgress, {
|
||||
phase: 'merging',
|
||||
totalBatches
|
||||
})
|
||||
const mergeResult = await this.mergeFiles(result.downloadedFiles)
|
||||
result.mergedFile = mergeResult.mergedFile
|
||||
result.recordCount = mergeResult.recordCount
|
||||
|
||||
// Add merge error to result if any
|
||||
if (mergeResult.error) {
|
||||
result.errors.push(mergeResult.error)
|
||||
}
|
||||
|
||||
// Always clean up temporary files regardless of merge success
|
||||
await this.cleanupTempFiles(result.downloadedFiles)
|
||||
|
||||
// Auto-import to database if merge was successful
|
||||
if (result.mergedFile) {
|
||||
const importProgress = (1 + totalBatches + 1) * progressPerPoint
|
||||
input.onProgress?.('正在写入数据库...', importProgress, {
|
||||
phase: 'importing',
|
||||
totalBatches
|
||||
})
|
||||
const importResult = await this.importToDatabaseWithLogging(
|
||||
result.mergedFile,
|
||||
input.onLog
|
||||
)
|
||||
result.importResult = importResult
|
||||
|
||||
if (!importResult.success && importResult.errors.length > 0) {
|
||||
result.errors.push(...importResult.errors)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
result.errors.push(`Extraction failed: ${message}`)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge downloaded Excel files into a single file
|
||||
* Uses ExcelParser to parse and combine all material plans
|
||||
*
|
||||
* @param filePaths - Array of downloaded Excel file paths
|
||||
* @returns Merged file path, total record count, and optional error message
|
||||
*/
|
||||
private async mergeFiles(
|
||||
filePaths: string[]
|
||||
): Promise<{ mergedFile: string | null; recordCount: number; error?: string }> {
|
||||
if (filePaths.length === 0) {
|
||||
return { mergedFile: null, recordCount: 0 }
|
||||
}
|
||||
|
||||
log.info('Starting merge', { fileCount: filePaths.length })
|
||||
const parser = new ExcelParser()
|
||||
|
||||
// Collect all orders with full order info and materials
|
||||
// Each order has: { orderInfo: OrderHeader, materials: MaterialRow[] }
|
||||
const allOrders: Array<{ orderInfo: any; materials: any[] }> = []
|
||||
|
||||
// Parse each downloaded file and collect orders
|
||||
for (const filePath of filePaths) {
|
||||
try {
|
||||
log.debug('Parsing file', { filePath })
|
||||
await parser.parse(filePath)
|
||||
// After parse(), the parser store orders internally as lastOrders
|
||||
const orders = (parser as any).lastOrders
|
||||
log.debug('File parsed', { filePath, orderCount: orders?.length || 0 })
|
||||
if (orders && Array.isArray(orders)) {
|
||||
allOrders.push(...orders)
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error)
|
||||
log.error('Failed to parse file', { filePath, error: errorMsg })
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate total record count (total material rows)
|
||||
let recordCount = 0
|
||||
for (const order of allOrders) {
|
||||
recordCount += order.materials.length
|
||||
}
|
||||
|
||||
log.info('Merge summary', { orderCount: allOrders.length, recordCount })
|
||||
|
||||
if (recordCount === 0) {
|
||||
log.warn('No records found in any downloaded files')
|
||||
return { mergedFile: null, recordCount: 0 }
|
||||
}
|
||||
|
||||
// Generate output filename with timestamp
|
||||
const timestamp = new Date()
|
||||
.toISOString()
|
||||
.replace(/[-:T]/g, '')
|
||||
.replace(/\..+/, '')
|
||||
.slice(0, 14)
|
||||
const outputPath = path.join(this.downloadDir, `merged_${timestamp}.xlsx`)
|
||||
|
||||
// Save with error handling
|
||||
try {
|
||||
log.info('Saving merged file', { outputPath })
|
||||
await this.saveMergedOrders(allOrders, outputPath)
|
||||
log.info('Merged file saved successfully', { recordCount })
|
||||
return { mergedFile: outputPath, recordCount }
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error)
|
||||
const errorStack = error instanceof Error ? error.stack : ''
|
||||
log.error('Failed to save merged file', { error: errorMsg, stack: errorStack })
|
||||
// Return parsed record count and error info even if save fails
|
||||
return { mergedFile: null, recordCount, error: `保存合并文件失败:${errorMsg}` }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save merged orders to a new Excel file with full 31 columns
|
||||
* Matches the output format of ExcelParser.saveAsExcel()
|
||||
*/
|
||||
private async saveMergedOrders(
|
||||
orders: Array<{ orderInfo: any; materials: any[] }>,
|
||||
outputPath: string
|
||||
): Promise<void> {
|
||||
log.debug('Loading ExcelJS')
|
||||
const ExcelJSModule = await import('exceljs')
|
||||
// Handle both ESM and CommonJS module formats
|
||||
const ExcelJS = ExcelJSModule.default || ExcelJSModule
|
||||
log.debug('ExcelJS loaded, creating workbook')
|
||||
|
||||
const workbook = new ExcelJS.Workbook()
|
||||
const worksheet = workbook.addWorksheet('Data')
|
||||
|
||||
// Define all 31 columns matching ExcelParser.saveAsExcel output format
|
||||
worksheet.columns = [
|
||||
{ header: '工厂', key: 'factory', width: 25 },
|
||||
{ header: '备料状态', key: 'materialStatus', width: 15 },
|
||||
{ header: '备料计划单号', key: 'planNumber', width: 25 },
|
||||
{ header: '来源单号', key: 'productionOrder', width: 20 },
|
||||
{ header: '备料类型', key: 'materialType', width: 15 },
|
||||
{ header: '产品编码', key: 'productCode', width: 15 },
|
||||
{ header: '产品名称', key: 'productName', width: 30 },
|
||||
{ header: '产品计划数量', key: 'productPlannedQuantity', width: 15 },
|
||||
{ header: '产品单位', key: 'productUnit', width: 10 },
|
||||
{ header: '用料部门', key: 'department', width: 15 },
|
||||
{ header: '备注', key: 'remark', width: 20 },
|
||||
{ header: '制单人', key: 'creator', width: 15 },
|
||||
{ header: '制单日期', key: 'createDate', width: 15 },
|
||||
{ header: '审批人', key: 'approver', width: 15 },
|
||||
{ header: '审批日期', key: 'approveDate', width: 15 },
|
||||
{ header: '序号', key: 'sequence', width: 10 },
|
||||
{ header: '材料编码', key: 'materialCode', width: 15 },
|
||||
{ header: '材料名称', key: 'materialName', width: 30 },
|
||||
{ header: '规格', key: 'specification', width: 30 },
|
||||
{ header: '型号', key: 'model', width: 20 },
|
||||
{ header: '图号', key: 'drawingNumber', width: 20 },
|
||||
{ header: '物料材质', key: 'material', width: 15 },
|
||||
{ header: '计划数量', key: 'quantity', width: 12 },
|
||||
{ header: '单位', key: 'unit', width: 10 },
|
||||
{ header: '需用日期', key: 'requiredDate', width: 15 },
|
||||
{ header: '发料仓库', key: 'warehouse', width: 15 },
|
||||
{ header: '单位用量', key: 'unitUsage', width: 12 },
|
||||
{ header: '累计出库数量', key: 'cumulativeOutboundQty', width: 15 },
|
||||
{ header: '打印人', key: 'printer', width: 15 },
|
||||
{ header: '打印日期', key: 'printDate', width: 20 }
|
||||
]
|
||||
|
||||
log.debug('Adding orders to worksheet', { orderCount: orders.length })
|
||||
// Add data rows - merge orderInfo with each material
|
||||
for (const order of orders) {
|
||||
const { orderInfo, materials } = order
|
||||
|
||||
for (const material of materials) {
|
||||
worksheet.addRow({
|
||||
// Order info (first 15 columns)
|
||||
factory: orderInfo.factory || '',
|
||||
materialStatus: orderInfo.materialStatus || '',
|
||||
planNumber: orderInfo.planNumber || '',
|
||||
productionOrder: orderInfo.productionOrder || '',
|
||||
materialType: orderInfo.materialType || '',
|
||||
productCode: orderInfo.productCode || '',
|
||||
productName: orderInfo.productName || '',
|
||||
productPlannedQuantity: orderInfo.plannedQuantity || '',
|
||||
productUnit: orderInfo.unit || '',
|
||||
department: orderInfo.department || '',
|
||||
remark: orderInfo.remark || '',
|
||||
creator: orderInfo.creator || '',
|
||||
createDate: orderInfo.createDate || '',
|
||||
approver: orderInfo.approver || '',
|
||||
approveDate: orderInfo.approveDate || '',
|
||||
// Material data (columns 16-28)
|
||||
sequence: material.sequence || '',
|
||||
materialCode: material.materialCode || '',
|
||||
materialName: material.materialName || '',
|
||||
specification: material.specification || '',
|
||||
model: material.model || '',
|
||||
drawingNumber: material.drawingNumber || '',
|
||||
material: material.material || '',
|
||||
quantity: material.quantity || 0,
|
||||
unit: material.unit || '',
|
||||
requiredDate: material.requiredDate || '',
|
||||
warehouse: material.warehouse || '',
|
||||
unitUsage: material.unitUsage || 0,
|
||||
cumulativeOutboundQty: material.cumulativeOutboundQty || 0,
|
||||
// Footer info (last 2 columns)
|
||||
printer: orderInfo.printer || '',
|
||||
printDate: orderInfo.printDate || ''
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
log.debug('Writing file', { outputPath })
|
||||
await workbook.xlsx.writeFile(outputPath)
|
||||
log.debug('File saved successfully', { outputPath })
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up temporary batch files after merging
|
||||
* @param filePaths - Array of temporary file paths to delete
|
||||
*/
|
||||
private async cleanupTempFiles(filePaths: string[]): Promise<void> {
|
||||
for (const filePath of filePaths) {
|
||||
try {
|
||||
await fs.unlink(filePath)
|
||||
log.debug('Deleted temporary file', { filePath })
|
||||
} catch (error) {
|
||||
// Log error but don't fail the main process
|
||||
log.error('Failed to delete temporary file', { filePath, error })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import merged Excel data to database with logging
|
||||
* @param filePath - Path to the merged Excel file
|
||||
* @param onLog - Optional log callback
|
||||
* @returns Import result with statistics
|
||||
*/
|
||||
private async importToDatabaseWithLogging(
|
||||
filePath: string,
|
||||
onLog?: (level: LogLevel, message: string) => void
|
||||
): Promise<ImportResult> {
|
||||
log.info('Starting database import', { filePath })
|
||||
onLog?.('info', `开始导入数据到数据库...`)
|
||||
|
||||
const importService = new DataImportService()
|
||||
|
||||
try {
|
||||
const result = await importService.importFromExcel(filePath, 1000)
|
||||
|
||||
log.info('Import completed', {
|
||||
success: result.success,
|
||||
recordsRead: result.recordsRead,
|
||||
recordsDeleted: result.recordsDeleted,
|
||||
recordsImported: result.recordsImported
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
onLog?.(
|
||||
'success',
|
||||
`导入完成:读取 ${result.recordsRead} 条,删除 ${result.recordsDeleted} 条,导入 ${result.recordsImported} 条`
|
||||
)
|
||||
} else if (result.errors.length > 0) {
|
||||
result.errors.forEach((err) => onLog?.('error', err))
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error)
|
||||
log.error('Import failed', { error: errorMsg })
|
||||
onLog?.('error', `导入失败:${errorMsg}`)
|
||||
|
||||
return {
|
||||
success: false,
|
||||
recordsRead: 0,
|
||||
recordsDeleted: 0,
|
||||
recordsImported: 0,
|
||||
uniqueSourceNumbers: 0,
|
||||
errors: [errorMsg]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
79
src/main/services/erp/locators.ts
Normal file
79
src/main/services/erp/locators.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* ERP Page Element Locators
|
||||
* Reference: playwrite/utils/ discrete_material_plan_extractor.py
|
||||
* Reference: playwrite/utils/ discrete_material_plan_cleaner.py
|
||||
*/
|
||||
|
||||
export const ERP_LOCATORS = {
|
||||
// Login Page
|
||||
login: {
|
||||
usernameInput: '#username',
|
||||
passwordInput: '#password',
|
||||
submitButton: 'button[type="submit"]'
|
||||
},
|
||||
|
||||
// Main Frame
|
||||
// Reference: Nested iframe structure from Python code
|
||||
main: {
|
||||
// Main iframe on the page
|
||||
mainIframe: '#mainiframe',
|
||||
// Forward frame (nested inside main)
|
||||
forwardFrame: '#forwardFrame',
|
||||
// Inner iframe (inside forward frame)
|
||||
innerIframe: '#mainiframe',
|
||||
// Loading overlay text
|
||||
loadingText: '加载中'
|
||||
},
|
||||
|
||||
// Extractor (Data Export) Page
|
||||
// Reference: playwrite/utils/discrete_material_plan_extractor.py
|
||||
extractor: {
|
||||
// Textbox by role: get_by_role("textbox", name="来源生产订单号")
|
||||
orderNumberInputRole: '来源生产订单号',
|
||||
// Search button: .search-component-searchBtn
|
||||
queryButton: '.search-component-searchBtn',
|
||||
// Loading indicator: div with text "加载中"
|
||||
loadingText: '加载中',
|
||||
// First row selector (序号 row)
|
||||
firstRowSelector: 'internal:role=row[name=/序号/i]',
|
||||
// More button
|
||||
moreButton: 'internal:has-text="更多"',
|
||||
// Export button (输出)
|
||||
exportButton: 'internal:has-text="输出"',
|
||||
// Export dialog - threshold input
|
||||
thresholdInputSelector: 'div:has-text(/^行数阈值$/) input[type="text"]',
|
||||
// Confirm button
|
||||
confirmButton: 'internal:has-text="确定(Y)"'
|
||||
},
|
||||
|
||||
// Menu navigation
|
||||
menu: {
|
||||
// Search icon in search wrapper
|
||||
searchIcon: '.search-name-wrapper .iconfont',
|
||||
// Order number query menu item
|
||||
orderQuery: 'internal:has-text="订单号查询"',
|
||||
// "All" tab
|
||||
allTab: 'internal:role=tab[name="全部"]',
|
||||
// Select input for setting limits
|
||||
selectInput: '#rc_select_0'
|
||||
},
|
||||
|
||||
// Discrete material plan menu item
|
||||
discreteMaterialPlan: 'internal:has-title="离散备料计划维护"',
|
||||
|
||||
// Cleaner (Material Delete) Page
|
||||
cleaner: {
|
||||
orderNumberInput: 'input[name="orderNumber"]',
|
||||
materialGrid: 'table.material-grid tbody tr',
|
||||
saveButton: 'button:has-text("保存")'
|
||||
},
|
||||
|
||||
// Common Elements
|
||||
common: {
|
||||
successMessage: '.message.success',
|
||||
errorMessage: '.message.error',
|
||||
confirmDialog: '.confirm-dialog',
|
||||
confirmButton: 'button:has-text("确定")',
|
||||
cancelButton: 'button:has-text("取消")'
|
||||
}
|
||||
}
|
||||
387
src/main/services/erp/order-resolver.ts
Normal file
387
src/main/services/erp/order-resolver.ts
Normal file
@@ -0,0 +1,387 @@
|
||||
/**
|
||||
* Order Number Resolver Service
|
||||
*
|
||||
* Automatically recognizes productionID and 生产订单号 (production order number),
|
||||
* and converts them via database lookup.
|
||||
*
|
||||
* - productionID format: 2 digits + 1 letter + serial number (e.g., "22A1", "22A1234")
|
||||
* - 生产订单号 format: SC + 14 digits (e.g., "SC70202602120085")
|
||||
*
|
||||
* Database table and field names are loaded from config.yaml
|
||||
*/
|
||||
|
||||
import type { IDatabaseService } from '../database'
|
||||
import { ConfigManager } from '../config/config-manager'
|
||||
import { createLogger } from '../logger'
|
||||
|
||||
const log = createLogger('OrderResolver')
|
||||
|
||||
/**
|
||||
* Order mapping result
|
||||
*/
|
||||
export interface OrderMapping {
|
||||
/** Original input from user */
|
||||
input: string
|
||||
/** Recognized productionID (if input matches productionID pattern) */
|
||||
productionId?: string
|
||||
/** Final production order number to use */
|
||||
orderNumber?: string
|
||||
/** Whether the order number was successfully resolved */
|
||||
resolved: boolean
|
||||
/** Error message if resolution failed */
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Order number type recognition result
|
||||
*/
|
||||
export type OrderNumberType = 'productionId' | 'orderNumber' | 'unknown'
|
||||
|
||||
/**
|
||||
* Resolution statistics
|
||||
*/
|
||||
export interface ResolutionStats {
|
||||
totalInputs: number
|
||||
validOrderNumbers: number
|
||||
validProductionIds: number
|
||||
resolvedCount: number
|
||||
failedCount: number
|
||||
unknownFormat: number
|
||||
}
|
||||
|
||||
/**
|
||||
* ProductionID pattern: 2 digits + 1 letter + 1-6 digits
|
||||
* Examples: 22A1, 22A123, 26B10617
|
||||
*/
|
||||
const PRODUCTION_ID_PATTERN = /^\d{2}[A-Z]\d{1,6}$/i
|
||||
|
||||
/**
|
||||
* Production order number pattern: SC + 14 digits
|
||||
*/
|
||||
const ORDER_NUMBER_PATTERN = /^SC\d{14}$/i
|
||||
|
||||
/**
|
||||
* Database table and field names
|
||||
* Loaded from config.yaml via ConfigManager
|
||||
*/
|
||||
export function getDbConfig() {
|
||||
const configManager = ConfigManager.getInstance()
|
||||
const config = configManager.getConfig()
|
||||
return {
|
||||
TABLE_NAME: config.orderResolution.tableName || 'productionContractData_26 年压力表合同数据',
|
||||
FIELD_PRODUCTION_ID: config.orderResolution.productionIdField || '总排号',
|
||||
FIELD_ORDER_NUMBER: config.orderResolution.orderNumberField || '生产订单号'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Order Number Resolver Service
|
||||
*/
|
||||
export class OrderNumberResolver {
|
||||
private dbService: IDatabaseService
|
||||
|
||||
constructor(dbService: IDatabaseService) {
|
||||
this.dbService = dbService
|
||||
}
|
||||
|
||||
/**
|
||||
* Get table name based on database type
|
||||
* Converts MySQL schema_tablename format to SQL Server [schema].[tablename] format
|
||||
* e.g., productionContractData_26年压力表合同数据 -> [productionContractData].[26年压力表合同数据]
|
||||
* dbo_MaterialsToBeDeleted -> [dbo].[MaterialsToBeDeleted]
|
||||
*/
|
||||
private getTableName(tableName: string): string {
|
||||
if (this.dbService.type === 'sqlserver') {
|
||||
// Find the FIRST underscore to split schema and table name
|
||||
// This handles patterns like: schema_tablename
|
||||
const firstUnderscoreIndex = tableName.indexOf('_')
|
||||
if (firstUnderscoreIndex > 0) {
|
||||
const schema = tableName.substring(0, firstUnderscoreIndex)
|
||||
const actualTableName = tableName.substring(firstUnderscoreIndex + 1)
|
||||
return `[${schema}].[${actualTableName}]`
|
||||
}
|
||||
// If no underscore found, default to dbo schema
|
||||
return `[dbo].[${tableName}]`
|
||||
}
|
||||
return tableName
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if input matches productionID pattern
|
||||
*/
|
||||
isProductionId(input: string): boolean {
|
||||
return PRODUCTION_ID_PATTERN.test(input)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if input matches order number pattern
|
||||
*/
|
||||
isOrderNumber(input: string): boolean {
|
||||
return ORDER_NUMBER_PATTERN.test(input)
|
||||
}
|
||||
|
||||
/**
|
||||
* Map productionID to order number via database lookup
|
||||
*/
|
||||
async mapProductionIdToOrderNumber(productionId: string): Promise<string | null> {
|
||||
try {
|
||||
const dbConfig = getDbConfig()
|
||||
const tableName = this.getTableName(dbConfig.TABLE_NAME)
|
||||
|
||||
let sql: string
|
||||
let params: any[]
|
||||
|
||||
if (this.dbService.type === 'sqlserver') {
|
||||
// 使用 COLLATE 指定不区分大小写的排序规则
|
||||
sql = `SELECT TOP 1 [${dbConfig.FIELD_ORDER_NUMBER}] FROM ${tableName} WHERE [${dbConfig.FIELD_PRODUCTION_ID}] COLLATE SQL_Latin1_General_CP1_CI_AS = @p0`
|
||||
params = [productionId]
|
||||
} else {
|
||||
// MySQL 默认不区分大小写,但显式使用 UPPER 确保一致性
|
||||
sql = `SELECT \`${dbConfig.FIELD_ORDER_NUMBER}\` FROM \`${tableName}\` WHERE UPPER(\`${dbConfig.FIELD_PRODUCTION_ID}\`) = UPPER(?) LIMIT 1`
|
||||
params = [productionId]
|
||||
}
|
||||
|
||||
const result = await this.dbService.query(sql, params)
|
||||
|
||||
if (result.rows.length > 0) {
|
||||
const orderNumber = result.rows[0][Object.keys(result.rows[0])[0]] as string
|
||||
return orderNumber || null
|
||||
}
|
||||
|
||||
return null
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '未知数据库错误'
|
||||
log.error('Failed to map productionID to order number', {
|
||||
productionId,
|
||||
error: message
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map multiple productionIds to order numbers
|
||||
*/
|
||||
async mapProductionIdsToOrderNumbers(productionIds: string[]): Promise<Map<string, string>> {
|
||||
try {
|
||||
const dbConfig = getDbConfig()
|
||||
const tableName = this.getTableName(dbConfig.TABLE_NAME)
|
||||
|
||||
if (productionIds.length === 0) {
|
||||
return new Map()
|
||||
}
|
||||
|
||||
// P1: Deduplicate input productionIds to avoid redundant queries
|
||||
const uniqueProductionIds = [...new Set(productionIds)]
|
||||
|
||||
// Use parameterized query to prevent SQL injection
|
||||
const placeholders = uniqueProductionIds.map((_, i) => `@p${i}`).join(', ')
|
||||
const params = uniqueProductionIds
|
||||
|
||||
let sql: string
|
||||
if (this.dbService.type === 'sqlserver') {
|
||||
// P0: Use DISTINCT to prevent duplicates from one-to-many relationships
|
||||
// 使用 COLLATE 指定不区分大小写的排序规则
|
||||
sql = `SELECT DISTINCT [${dbConfig.FIELD_PRODUCTION_ID}], [${dbConfig.FIELD_ORDER_NUMBER}] FROM ${tableName} WHERE [${dbConfig.FIELD_PRODUCTION_ID}] COLLATE SQL_Latin1_General_CP1_CI_AS IN (${placeholders})`
|
||||
} else {
|
||||
const idPlaceholders = uniqueProductionIds.map(() => 'UPPER(?)').join(', ')
|
||||
// P0: Use DISTINCT to prevent duplicates from one-to-many relationships
|
||||
// MySQL: 使用 UPPER 确保不区分大小写
|
||||
sql = `SELECT DISTINCT \`${dbConfig.FIELD_PRODUCTION_ID}\`, \`${dbConfig.FIELD_ORDER_NUMBER}\` FROM \`${tableName}\` WHERE UPPER(\`${dbConfig.FIELD_PRODUCTION_ID}\`) IN (${idPlaceholders})`
|
||||
}
|
||||
|
||||
const result = await this.dbService.query(sql, params)
|
||||
|
||||
const mappings = new Map<string, string>()
|
||||
for (const row of result.rows) {
|
||||
const keys = Object.keys(row)
|
||||
const prodId = row[keys[0]] as string
|
||||
const orderNum = row[keys[1]] as string
|
||||
if (prodId && orderNum) {
|
||||
mappings.set(prodId, orderNum)
|
||||
}
|
||||
}
|
||||
|
||||
return mappings
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '未知数据库错误'
|
||||
log.error('Failed to map productionIds to order numbers', {
|
||||
error: message
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve order numbers from mixed input
|
||||
*
|
||||
* Optimized for batch processing with deduplication:
|
||||
* - Multiple productionIDs mapping to the same order number are treated as valid (not errors)
|
||||
* - Returns all mappings with duplicate tracking
|
||||
*/
|
||||
async resolve(inputs: string[]): Promise<OrderMapping[]> {
|
||||
// P1: Deduplicate inputs at the input layer to avoid redundant queries
|
||||
const uniqueInputs = [...new Set(inputs)]
|
||||
|
||||
// Separate productionIds and order numbers
|
||||
const productionIds: string[] = []
|
||||
const orderNumbers: string[] = []
|
||||
|
||||
for (const input of uniqueInputs) {
|
||||
if (this.isOrderNumber(input)) {
|
||||
orderNumbers.push(input)
|
||||
} else if (this.isProductionId(input)) {
|
||||
productionIds.push(input)
|
||||
}
|
||||
}
|
||||
|
||||
// Batch query productionId to order number mappings
|
||||
// 使用小写 key 存储映射,以支持忽略大小写查找
|
||||
const mappings = new Map<string, string>()
|
||||
if (productionIds.length > 0) {
|
||||
const batchMappings = await this.mapProductionIdsToOrderNumbers(productionIds)
|
||||
batchMappings.forEach((orderNum, prodId) => {
|
||||
mappings.set(prodId.toLowerCase(), orderNum)
|
||||
})
|
||||
}
|
||||
|
||||
// Build results while preserving original input order
|
||||
// Note: Multiple productionIDs mapping to the same order number is VALID (not an error)
|
||||
const results: OrderMapping[] = []
|
||||
|
||||
for (const input of inputs) {
|
||||
// Skip if this exact input was already processed
|
||||
const alreadyProcessed = results.some((r) => r.input === input)
|
||||
if (alreadyProcessed) {
|
||||
continue
|
||||
}
|
||||
|
||||
const mapping: OrderMapping = { input, resolved: false }
|
||||
|
||||
if (this.isOrderNumber(input)) {
|
||||
// Already an order number
|
||||
mapping.orderNumber = input
|
||||
mapping.resolved = true
|
||||
} else if (this.isProductionId(input)) {
|
||||
// Is a productionID, lookup from batch mappings (使用小写查找以忽略大小写)
|
||||
mapping.productionId = input
|
||||
const orderNumber = mappings.get(input.toLowerCase())
|
||||
if (orderNumber) {
|
||||
mapping.orderNumber = orderNumber
|
||||
mapping.resolved = true
|
||||
} else {
|
||||
mapping.error = '未在数据库中找到对应的订单号'
|
||||
}
|
||||
} else {
|
||||
mapping.error = '格式不识别:既不是有效的生产订单号也不是总排号格式'
|
||||
}
|
||||
|
||||
results.push(mapping)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Get valid order numbers from mappings
|
||||
* P2: Returns deduplicated order numbers
|
||||
*/
|
||||
getValidOrderNumbers(mappings: OrderMapping[]): string[] {
|
||||
const validNumbers = mappings
|
||||
.filter((m) => m.resolved && m.orderNumber)
|
||||
.map((m) => m.orderNumber!)
|
||||
// P2: Deduplicate before returning
|
||||
return [...new Set(validNumbers)]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get warnings from failed mappings
|
||||
*/
|
||||
getWarnings(mappings: OrderMapping[]): string[] {
|
||||
return mappings.filter((m) => !m.resolved && m.error).map((m) => `${m.input}: ${m.error}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Recognize the type of input
|
||||
*/
|
||||
recognizeType(input: string): OrderNumberType {
|
||||
if (this.isOrderNumber(input)) return 'orderNumber'
|
||||
if (this.isProductionId(input)) return 'productionId'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get resolution statistics
|
||||
*/
|
||||
getStats(mappings: OrderMapping[]): ResolutionStats {
|
||||
const stats: ResolutionStats = {
|
||||
totalInputs: mappings.length,
|
||||
validOrderNumbers: 0,
|
||||
validProductionIds: 0,
|
||||
resolvedCount: 0,
|
||||
failedCount: 0,
|
||||
unknownFormat: 0
|
||||
}
|
||||
|
||||
for (const mapping of mappings) {
|
||||
if (mapping.resolved) {
|
||||
stats.resolvedCount++
|
||||
if (mapping.orderNumber && !mapping.productionId) {
|
||||
stats.validOrderNumbers++
|
||||
} else if (mapping.productionId) {
|
||||
stats.validProductionIds++
|
||||
}
|
||||
} else {
|
||||
stats.failedCount++
|
||||
if (!mapping.productionId && !mapping.orderNumber) {
|
||||
stats.unknownFormat++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
/**
|
||||
* Get deduplication summary for logging
|
||||
* Returns a human-readable report showing:
|
||||
* - Input count
|
||||
* - Unique order numbers count
|
||||
* - Mapping details (which productionIDs map to which order numbers)
|
||||
*/
|
||||
getDeduplicationReport(mappings: OrderMapping[]): {
|
||||
inputCount: number
|
||||
uniqueOrderNumbersCount: number
|
||||
orderNumberGroups: Map<string, string[]>
|
||||
summary: string
|
||||
} {
|
||||
// Group productionIDs by their resolved order number
|
||||
const orderNumberGroups = new Map<string, string[]>()
|
||||
|
||||
for (const mapping of mappings) {
|
||||
if (mapping.resolved && mapping.orderNumber) {
|
||||
const existing = orderNumberGroups.get(mapping.orderNumber) || []
|
||||
existing.push(mapping.input)
|
||||
orderNumberGroups.set(mapping.orderNumber, existing)
|
||||
}
|
||||
}
|
||||
|
||||
const inputCount = mappings.length
|
||||
const uniqueOrderNumbersCount = orderNumberGroups.size
|
||||
|
||||
// Build summary string
|
||||
let summary = `输入 ${inputCount} 个总排号 → 解析为 ${uniqueOrderNumbersCount} 个唯一订单号`
|
||||
|
||||
if (inputCount > uniqueOrderNumbersCount) {
|
||||
const duplicateCount = inputCount - uniqueOrderNumbersCount
|
||||
summary += `(${duplicateCount} 个重复已合并)`
|
||||
}
|
||||
|
||||
return {
|
||||
inputCount,
|
||||
uniqueOrderNumbersCount,
|
||||
orderNumberGroups,
|
||||
summary
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
import ExcelJS from 'exceljs';
|
||||
import path from 'path';
|
||||
import { LoggerService } from './logger.service';
|
||||
|
||||
export class ExcelConverterService {
|
||||
constructor(private verbose: boolean = true) {}
|
||||
|
||||
async convert(inputPath: string, outputPath?: string): Promise<any[]> {
|
||||
try {
|
||||
// Validate input file
|
||||
const fs = await import('fs/promises');
|
||||
|
||||
// Check file exists
|
||||
try {
|
||||
await fs.access(inputPath, fs.constants.R_OK);
|
||||
} catch {
|
||||
throw new Error(`File not found: ${inputPath}`);
|
||||
}
|
||||
|
||||
// Check file extension
|
||||
if (!inputPath.match(/\.(xlsx|xls)$/i)) {
|
||||
throw new Error(`Invalid file type. Expected .xlsx or .xls file: ${inputPath}`);
|
||||
}
|
||||
|
||||
LoggerService.info(`Converting Excel file: ${inputPath}`);
|
||||
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
await workbook.xlsx.readFile(inputPath);
|
||||
|
||||
const worksheet = workbook.worksheets[0];
|
||||
if (!worksheet) {
|
||||
throw new Error('No worksheet found in Excel file');
|
||||
}
|
||||
|
||||
const data: any[] = [];
|
||||
const headers: string[] = [];
|
||||
|
||||
// Extract headers
|
||||
const headerRow = worksheet.getRow(1);
|
||||
headerRow.eachCell((cell, colNumber) => {
|
||||
headers[colNumber - 1] = cell.text;
|
||||
});
|
||||
|
||||
// Extract data rows
|
||||
worksheet.eachRow((row, rowNumber) => {
|
||||
if (rowNumber === 1) return; // Skip header row
|
||||
|
||||
const rowData: any = {};
|
||||
row.eachCell((cell, colNumber) => {
|
||||
const header = headers[colNumber - 1];
|
||||
if (header) {
|
||||
// Extract actual value from cell (handles formulas, rich text, etc.)
|
||||
const value = cell.value;
|
||||
const extractedValue = typeof value === 'object' && value !== null
|
||||
? (value as any).result || (value as any).text || value
|
||||
: value;
|
||||
rowData[header] = extractedValue;
|
||||
}
|
||||
});
|
||||
data.push(rowData);
|
||||
});
|
||||
|
||||
// Validate we got data
|
||||
if (data.length === 0) {
|
||||
LoggerService.warn('Excel file contains no data rows');
|
||||
}
|
||||
|
||||
LoggerService.info(`Extracted ${data.length} rows from Excel file`);
|
||||
|
||||
// Save to output path if provided
|
||||
if (outputPath) {
|
||||
await this.saveAsJson(data, outputPath);
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
LoggerService.error('Excel conversion failed', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async mergeExcelFiles(filePaths: string[], outputPath: string): Promise<void> {
|
||||
try {
|
||||
LoggerService.info(`Merging ${filePaths.length} Excel files...`);
|
||||
|
||||
const allData: any[] = [];
|
||||
|
||||
for (const filePath of filePaths) {
|
||||
const data = await this.convert(filePath);
|
||||
allData.push(...data);
|
||||
}
|
||||
|
||||
// Create new Excel workbook with merged data
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const worksheet = workbook.addWorksheet('Merged Data');
|
||||
|
||||
if (allData.length > 0) {
|
||||
const headers = Object.keys(allData[0]);
|
||||
worksheet.addRow(headers);
|
||||
|
||||
allData.forEach((row) => {
|
||||
const values = headers.map((header) => row[header]);
|
||||
worksheet.addRow(values);
|
||||
});
|
||||
}
|
||||
|
||||
await workbook.xlsx.writeFile(outputPath);
|
||||
LoggerService.info(`Merged data saved to: ${outputPath}`);
|
||||
} catch (error) {
|
||||
LoggerService.error('Excel merge failed', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async saveAsJson(data: any[], outputPath: string): Promise<void> {
|
||||
try {
|
||||
const fs = await import('fs/promises');
|
||||
await fs.writeFile(outputPath, JSON.stringify(data, null, 2), 'utf-8');
|
||||
LoggerService.info(`Data saved to JSON: ${outputPath}`);
|
||||
} catch (error) {
|
||||
LoggerService.error(`Failed to save JSON to ${outputPath}`, error);
|
||||
throw new Error(`Failed to save JSON output: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
559
src/main/services/excel/excel-parser.ts
Normal file
559
src/main/services/excel/excel-parser.ts
Normal file
@@ -0,0 +1,559 @@
|
||||
import ExcelJS from 'exceljs'
|
||||
import type { DiscreteMaterialPlan, ExcelParseOptions, OrderHeader } from '../../types/excel.types'
|
||||
import { createLogger } from '../logger'
|
||||
|
||||
const log = createLogger('ExcelParser')
|
||||
|
||||
/**
|
||||
* Excel Parser Service
|
||||
* Parses exported ERP Excel files into structured data
|
||||
*
|
||||
* Reference: playwrite/utils/excel_converter.py
|
||||
*
|
||||
* Excel Structure:
|
||||
* - Multiple orders per file (each starting with "离散备料计划")
|
||||
* - Each order has: header info (4 lines) + table header + data rows + footer
|
||||
* - Material rows have 13 columns from "序号" to "累计出库数量"
|
||||
*/
|
||||
export class ExcelParser {
|
||||
// Field name mapping for Python compatibility (from Python code)
|
||||
private FIELD_NAME_MAPPING: Record<string, string> = {
|
||||
计划数量: '产品计划数量',
|
||||
单位: '产品单位'
|
||||
}
|
||||
|
||||
// Mapping from Chinese field names to English property names
|
||||
private CHINESE_TO_ENGLISH_MAPPING: Record<string, string> = {
|
||||
// Header fields (row 2-4)
|
||||
工厂: 'factory',
|
||||
备料状态: 'materialStatus',
|
||||
备料计划单号: 'planNumber',
|
||||
备料类型: 'materialType',
|
||||
生产部门: 'productionDepartment',
|
||||
生产订单: 'productionOrder',
|
||||
来源单号: 'productionOrder', // This is the order number we need!
|
||||
产品编码: 'productCode',
|
||||
产品名称: 'productName',
|
||||
产品规格: 'productSpecification',
|
||||
计划数量: 'plannedQuantity',
|
||||
单位: 'unit',
|
||||
用料部门: 'department',
|
||||
备注: 'remark',
|
||||
需用日期: 'requiredDate',
|
||||
// Footer fields (row 14-15)
|
||||
制单人: 'creator',
|
||||
制单日期: 'createDate',
|
||||
审批人: 'approver',
|
||||
审批日期: 'approveDate',
|
||||
打印人: 'printer',
|
||||
打印日期: 'printDate',
|
||||
// Mapped fields (after FIELD_NAME_MAPPING)
|
||||
产品计划数量: 'plannedQuantity',
|
||||
产品单位: 'unit'
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Excel file and extract material plans
|
||||
*/
|
||||
async parse(filePath: string, options: ExcelParseOptions = {}): Promise<DiscreteMaterialPlan[]> {
|
||||
log.debug('Parsing Excel file:', filePath)
|
||||
|
||||
const workbook = new ExcelJS.Workbook()
|
||||
await workbook.xlsx.readFile(filePath)
|
||||
|
||||
const worksheet = workbook.worksheets[0]
|
||||
if (!worksheet) {
|
||||
throw new Error('No worksheet found in file')
|
||||
}
|
||||
|
||||
const plans: DiscreteMaterialPlan[] = []
|
||||
const allRows: any[][] = []
|
||||
|
||||
// Read all rows into memory
|
||||
worksheet.eachRow((row, _rowNumber) => {
|
||||
allRows.push(row.values as any[])
|
||||
})
|
||||
|
||||
log.debug(`Total rows read: ${allRows.length} (worksheet has ${worksheet.rowCount} rows)`)
|
||||
|
||||
// Parse orders from rows
|
||||
const orders = this.parseOrders(allRows)
|
||||
|
||||
// Store orders for potential Excel export
|
||||
;(this as any).lastOrders = orders
|
||||
|
||||
// Flatten orders into material plans
|
||||
for (const order of orders) {
|
||||
const { orderInfo, materials } = order
|
||||
|
||||
// Skip empty orders if option is set
|
||||
if (options.skipEmptyOrders && materials.length === 0) {
|
||||
log.debug('Skipping empty order:', orderInfo.productionOrder)
|
||||
continue
|
||||
}
|
||||
|
||||
// Create a material plan for each material row
|
||||
for (const material of materials) {
|
||||
const plan: DiscreteMaterialPlan = {
|
||||
orderNumber: orderInfo.productionOrder || '',
|
||||
productionId: orderInfo.productCode || '',
|
||||
materialCode: material.materialCode || '',
|
||||
materialName: material.materialName || '',
|
||||
specification: material.specification,
|
||||
model: material.model,
|
||||
drawingNumber: material.drawingNumber,
|
||||
material: material.material,
|
||||
quantity: material.quantity || 0,
|
||||
unit: material.unit || '',
|
||||
requiredDate: material.requiredDate,
|
||||
warehouse: material.warehouse,
|
||||
unitUsage: material.unitUsage,
|
||||
cumulativeOutboundQty: material.cumulativeOutboundQty,
|
||||
rowNumber: material.rowNumber
|
||||
}
|
||||
plans.push(plan)
|
||||
}
|
||||
}
|
||||
|
||||
log.debug(`Parsed ${plans.length} material plans from ${orders.length} orders`)
|
||||
|
||||
return plans
|
||||
}
|
||||
|
||||
/**
|
||||
* Save parsed orders to Excel file
|
||||
* Compatible with Python excel_converter.py output format
|
||||
* Uses the last parsed orders data
|
||||
*
|
||||
* @param outputPath - Output Excel file path
|
||||
*/
|
||||
async saveAsExcel(outputPath: string): Promise<void> {
|
||||
const orders = (this as any).lastOrders
|
||||
if (!orders) {
|
||||
throw new Error('No parsed data available. Call parse() first.')
|
||||
}
|
||||
|
||||
log.debug('Saving parsed data to Excel:', outputPath)
|
||||
|
||||
const workbook = new ExcelJS.Workbook()
|
||||
const worksheet = workbook.addWorksheet('Data')
|
||||
|
||||
// Define columns matching Python excel_converter output format exactly
|
||||
worksheet.columns = [
|
||||
{ header: '工厂', key: 'factory', width: 25 },
|
||||
{ header: '备料状态', key: 'materialStatus', width: 15 },
|
||||
{ header: '备料计划单号', key: 'planNumber', width: 25 },
|
||||
{ header: '来源单号', key: 'productionOrder', width: 20 },
|
||||
{ header: '备料类型', key: 'materialType', width: 15 },
|
||||
{ header: '产品编码', key: 'productCode', width: 15 },
|
||||
{ header: '产品名称', key: 'productName', width: 30 },
|
||||
{ header: '产品计划数量', key: 'productPlannedQuantity', width: 15 },
|
||||
{ header: '产品单位', key: 'productUnit', width: 10 },
|
||||
{ header: '用料部门', key: 'department', width: 15 },
|
||||
{ header: '备注', key: 'remark', width: 20 },
|
||||
{ header: '制单人', key: 'creator', width: 15 },
|
||||
{ header: '制单日期', key: 'createDate', width: 15 },
|
||||
{ header: '审批人', key: 'approver', width: 15 },
|
||||
{ header: '审批日期', key: 'approveDate', width: 15 },
|
||||
{ header: '序号', key: 'sequence', width: 10 },
|
||||
{ header: '材料编码', key: 'materialCode', width: 15 },
|
||||
{ header: '材料名称', key: 'materialName', width: 30 },
|
||||
{ header: '规格', key: 'specification', width: 30 },
|
||||
{ header: '型号', key: 'model', width: 20 },
|
||||
{ header: '图号', key: 'drawingNumber', width: 20 },
|
||||
{ header: '物料材质', key: 'material', width: 15 },
|
||||
{ header: '计划数量', key: 'quantity', width: 12 },
|
||||
{ header: '单位', key: 'unit', width: 10 },
|
||||
{ header: '需用日期', key: 'requiredDate', width: 15 },
|
||||
{ header: '发料仓库', key: 'warehouse', width: 15 },
|
||||
{ header: '单位用量', key: 'unitUsage', width: 12 },
|
||||
{ header: '累计出库数量', key: 'cumulativeOutboundQty', width: 15 },
|
||||
{ header: '打印人', key: 'printer', width: 15 },
|
||||
{ header: '打印日期', key: 'printDate', width: 20 }
|
||||
]
|
||||
|
||||
// Add data rows - merge orderInfo with each material
|
||||
for (const order of orders) {
|
||||
const { orderInfo, materials } = order
|
||||
|
||||
for (const material of materials) {
|
||||
worksheet.addRow({
|
||||
// Order info (first 14 columns)
|
||||
factory: orderInfo.factory || '',
|
||||
materialStatus: orderInfo.materialStatus || '',
|
||||
planNumber: orderInfo.planNumber || '',
|
||||
productionOrder: orderInfo.productionOrder || '',
|
||||
materialType: orderInfo.materialType || '',
|
||||
productCode: orderInfo.productCode || '',
|
||||
productName: orderInfo.productName || '',
|
||||
productPlannedQuantity: orderInfo.plannedQuantity || '',
|
||||
unit: orderInfo.unit || '',
|
||||
department: orderInfo.department || '',
|
||||
remark: orderInfo.remark || '',
|
||||
creator: orderInfo.creator || '',
|
||||
createDate: orderInfo.createDate || '',
|
||||
approver: orderInfo.approver || '',
|
||||
approveDate: orderInfo.approveDate || '',
|
||||
// Material data (columns 15-28)
|
||||
sequence: material.sequence || '',
|
||||
materialCode: material.materialCode || '',
|
||||
materialName: material.materialName || '',
|
||||
specification: material.specification || '',
|
||||
model: material.model || '',
|
||||
drawingNumber: material.drawingNumber || '',
|
||||
material: material.material || '',
|
||||
quantity: material.quantity || 0,
|
||||
requiredDate: material.requiredDate || '',
|
||||
warehouse: material.warehouse || '',
|
||||
unitUsage: material.unitUsage || 0,
|
||||
cumulativeOutboundQty: material.cumulativeOutboundQty || 0,
|
||||
// Footer info (last 2 columns)
|
||||
printer: orderInfo.printer || '',
|
||||
printDate: orderInfo.printDate || ''
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Save workbook
|
||||
await workbook.xlsx.writeFile(outputPath)
|
||||
log.debug(
|
||||
`Excel file saved: ${outputPath} (${orders.length} orders, ${worksheet.rowCount - 1} data rows)`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse orders from all rows
|
||||
* Reference: _parse_sheet() in Python code
|
||||
*/
|
||||
private parseOrders(allRows: any[][]): Array<{ orderInfo: OrderHeader; materials: any[] }> {
|
||||
const orders: Array<{ orderInfo: OrderHeader; materials: any[] }> = []
|
||||
let i = 0
|
||||
|
||||
while (i < allRows.length) {
|
||||
const row = allRows[i]
|
||||
|
||||
// Check if this is an order title row
|
||||
if (row && row[2] && String(row[2]).includes('离散备料计划')) {
|
||||
// Parse order header info (next 4 rows)
|
||||
const orderInfo: OrderHeader = {}
|
||||
for (let j = 1; j <= 4; j++) {
|
||||
if (i + j < allRows.length && allRows[i + j]) {
|
||||
this.parseHeaderRow(allRows[i + j], orderInfo)
|
||||
}
|
||||
}
|
||||
|
||||
// Debug: check productionOrder extraction
|
||||
log.debug(`Order ${orders.length + 1}: productionOrder="${orderInfo.productionOrder}"`)
|
||||
|
||||
// Find table header row dynamically (look for "序号" in index 1)
|
||||
// Note: worksheet.eachRow() skips empty rows, so we can't use fixed offsets
|
||||
let tableRow = i + 1
|
||||
while (tableRow < allRows.length && allRows[tableRow] && allRows[tableRow][1] !== '序号') {
|
||||
tableRow++
|
||||
}
|
||||
|
||||
if (tableRow >= allRows.length || !allRows[tableRow]) {
|
||||
log.debug(' ⚠️ Table header not found, skipping this order')
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this is the table header row
|
||||
// ExcelJS is 1-indexed: index 0=null, index 1=序号, index 2=材料编码
|
||||
if (tableRow < allRows.length && allRows[tableRow] && allRows[tableRow][1] === '序号') {
|
||||
// Check if next row is empty (no data)
|
||||
const nextRow = tableRow + 1
|
||||
const isEmptyRow =
|
||||
nextRow < allRows.length &&
|
||||
allRows[nextRow] &&
|
||||
allRows[nextRow].every((cell: any) => cell === null || String(cell).trim() === '')
|
||||
|
||||
if (isEmptyRow) {
|
||||
// No data, find footer info
|
||||
log.debug('Order has no material data')
|
||||
const materials: any[] = []
|
||||
const footerInfo: OrderHeader = {}
|
||||
let dataRow = nextRow + 1
|
||||
|
||||
while (dataRow < allRows.length && allRows[dataRow]) {
|
||||
if (
|
||||
allRows[dataRow][2] &&
|
||||
(String(allRows[dataRow][2]).includes('制单人') ||
|
||||
String(allRows[dataRow][2]).includes('打印人'))
|
||||
) {
|
||||
this.parseHeaderRow(allRows[dataRow], footerInfo)
|
||||
if (dataRow + 1 < allRows.length && allRows[dataRow + 1]) {
|
||||
this.parseHeaderRow(allRows[dataRow + 1], footerInfo)
|
||||
}
|
||||
break
|
||||
}
|
||||
dataRow++
|
||||
}
|
||||
|
||||
orders.push({
|
||||
orderInfo: { ...orderInfo, ...footerInfo },
|
||||
materials
|
||||
})
|
||||
} else {
|
||||
// Has data, extract materials
|
||||
log.debug('Order has material data')
|
||||
const materials: any[] = []
|
||||
const footerInfo: OrderHeader = {}
|
||||
let dataRow = tableRow + 1
|
||||
|
||||
while (dataRow < allRows.length && allRows[dataRow]) {
|
||||
// Check if CURRENT row is footer info (制单人/打印人)
|
||||
const isCurrentRowFooter =
|
||||
allRows[dataRow][2] &&
|
||||
(String(allRows[dataRow][2]).includes('制单人') ||
|
||||
String(allRows[dataRow][2]).includes('打印人'))
|
||||
|
||||
// Check if NEXT row is footer info (to handle empty row before footer)
|
||||
const isNextRowFooter =
|
||||
dataRow + 1 < allRows.length &&
|
||||
allRows[dataRow + 1] &&
|
||||
allRows[dataRow + 1][2] &&
|
||||
(String(allRows[dataRow + 1][2]).includes('制单人') ||
|
||||
String(allRows[dataRow + 1][2]).includes('打印人'))
|
||||
|
||||
if (isCurrentRowFooter) {
|
||||
// Current row is footer, parse it and next row if exists
|
||||
this.parseHeaderRow(allRows[dataRow], footerInfo)
|
||||
if (dataRow + 1 < allRows.length && allRows[dataRow + 1]) {
|
||||
this.parseHeaderRow(allRows[dataRow + 1], footerInfo)
|
||||
}
|
||||
log.debug(' Found footer row, stopping material parsing')
|
||||
break
|
||||
}
|
||||
|
||||
if (isNextRowFooter) {
|
||||
// Next row is footer, parse current row as material first
|
||||
const material = this.parseMaterialRowInternal(allRows[dataRow], dataRow + 1)
|
||||
if (material) {
|
||||
log.debug(' Parsed material:', material.materialCode)
|
||||
materials.push(material)
|
||||
}
|
||||
|
||||
// Then parse footer rows
|
||||
this.parseHeaderRow(allRows[dataRow + 1], footerInfo)
|
||||
if (dataRow + 2 < allRows.length && allRows[dataRow + 2]) {
|
||||
this.parseHeaderRow(allRows[dataRow + 2], footerInfo)
|
||||
}
|
||||
log.debug(' Found footer in next row, stopping material parsing')
|
||||
break
|
||||
}
|
||||
|
||||
// Extract material data
|
||||
const material = this.parseMaterialRowInternal(allRows[dataRow], dataRow + 1)
|
||||
if (material) {
|
||||
//log.debug(' Parsed material:', material.materialCode)
|
||||
materials.push(material)
|
||||
} else {
|
||||
log.debug(' Skipped material row at', dataRow + 1)
|
||||
}
|
||||
|
||||
dataRow++
|
||||
}
|
||||
|
||||
orders.push({
|
||||
orderInfo: { ...orderInfo, ...footerInfo },
|
||||
materials
|
||||
})
|
||||
}
|
||||
} else {
|
||||
log.debug(
|
||||
` ⚠️ Table header check failed at row ${tableRow + 1}, value="${allRows[tableRow] ? allRows[tableRow][1] : 'null'}"`
|
||||
)
|
||||
}
|
||||
|
||||
// Move to next row after this order
|
||||
i = tableRow + 1
|
||||
} else {
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
log.debug(`parseOrders: Returning ${orders.length} orders`)
|
||||
return orders
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse header row (field names and values interleaved)
|
||||
* Reference: _parse_header_row() in Python code
|
||||
*/
|
||||
private parseHeaderRow(row: any[], info: OrderHeader): void {
|
||||
let j = 0
|
||||
while (j < row.length) {
|
||||
const cell = row[j]
|
||||
if (cell && String(cell).trim() && String(cell).includes(':')) {
|
||||
// Found field name
|
||||
let fieldName = String(cell).replace(':', '').trim()
|
||||
|
||||
// Apply field name mapping (from Python code)
|
||||
if (fieldName in this.FIELD_NAME_MAPPING) {
|
||||
fieldName = this.FIELD_NAME_MAPPING[fieldName]
|
||||
}
|
||||
|
||||
// Map Chinese field name to English property name
|
||||
const englishFieldName = this.CHINESE_TO_ENGLISH_MAPPING[fieldName] || fieldName
|
||||
|
||||
// Skip empty cells to find first non-field-name value
|
||||
let k = j + 1
|
||||
while (
|
||||
k < row.length &&
|
||||
(!row[k] || !String(row[k]).trim() || String(row[k]).includes(':'))
|
||||
) {
|
||||
k++
|
||||
}
|
||||
|
||||
if (k < row.length && row[k] && !String(row[k]).includes(':')) {
|
||||
info[englishFieldName as keyof OrderHeader] = String(row[k]).trim()
|
||||
}
|
||||
|
||||
// Skip processed value, continue to next field name
|
||||
j = k + 1
|
||||
} else {
|
||||
j++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse material data row
|
||||
* Reference: material data extraction in Python code
|
||||
* ExcelJS row.values arrays are 1-indexed:
|
||||
* - Index 0: null
|
||||
* - Index 1: 序号 (sequence)
|
||||
* - Index 2: 材料编码 (materialCode)
|
||||
* - Index 3: 材料名称 (materialName)
|
||||
* - etc.
|
||||
* NOTE: This is an internal method that returns raw data structure
|
||||
*/
|
||||
private parseMaterialRowInternal(row: any[], rowNumber: number): any | null {
|
||||
// Extract 13 fields from material row (ExcelJS is 1-indexed, so data starts at index 1)
|
||||
const material = {
|
||||
sequence: row[1],
|
||||
materialCode: row[2],
|
||||
materialName: row[3],
|
||||
specification: row[4],
|
||||
model: row[5],
|
||||
drawingNumber: row[6],
|
||||
material: row[7],
|
||||
quantity: this.parseFloat(row[8]),
|
||||
unit: row[9],
|
||||
requiredDate: row[10],
|
||||
warehouse: row[11],
|
||||
unitUsage: this.parseFloat(row[12]),
|
||||
cumulativeOutboundQty: this.parseFloat(row[13]),
|
||||
rowNumber
|
||||
}
|
||||
|
||||
// Skip if no material code
|
||||
if (!material.materialCode) {
|
||||
return null
|
||||
}
|
||||
|
||||
return material
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely parse float from cell value
|
||||
*/
|
||||
private parseFloat(value: any): number | undefined {
|
||||
if (value === null || value === undefined) {
|
||||
return undefined
|
||||
}
|
||||
const parsed = parseFloat(String(value))
|
||||
return isNaN(parsed) ? undefined : parsed
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if row contains order information
|
||||
* (Spec-compliant method for detecting order rows)
|
||||
*
|
||||
* @param values - Row values array from ExcelJS
|
||||
* @returns true if row contains "离散备料计划" (order title)
|
||||
*/
|
||||
public isOrderRow(values: any[]): boolean {
|
||||
// ExcelJS arrays are 1-indexed, check index 2 for order title
|
||||
const firstCell = values[2]
|
||||
return typeof firstCell === 'string' && firstCell.includes('离散备料计划')
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract order number from row
|
||||
* (Spec-compliant method for extracting order number)
|
||||
*
|
||||
* Parses a row containing order header information and extracts
|
||||
* the production order number (生产订单).
|
||||
*
|
||||
* @param values - Row values array from ExcelJS
|
||||
* @returns Order number (e.g., "SC202501001") or empty string
|
||||
*/
|
||||
public extractOrderNumber(values: any[]): string {
|
||||
// Parse the row to extract order number using same logic as header parsing
|
||||
const orderInfo: OrderHeader = {}
|
||||
this.parseHeaderRow(values, orderInfo)
|
||||
return orderInfo.productionOrder || ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse material row
|
||||
* (Spec-compliant method for parsing material data)
|
||||
*
|
||||
* @param values - Row values array from ExcelJS (1-indexed, index 0 is null)
|
||||
* @param orderNumber - Order number for this material
|
||||
* @param productionId - Production ID for this material
|
||||
* @param rowNumber - Row number in Excel file
|
||||
* @returns DiscreteMaterialPlan or null if invalid row
|
||||
*/
|
||||
public parseMaterialRow(
|
||||
values: any[],
|
||||
orderNumber: string,
|
||||
productionId: string,
|
||||
rowNumber: number
|
||||
): DiscreteMaterialPlan | null {
|
||||
// ExcelJS arrays are 1-indexed:
|
||||
// Index 0: null
|
||||
// Index 1: 序号
|
||||
// Index 2: 材料编码
|
||||
// Index 3: 材料名称
|
||||
// Index 4: 规格
|
||||
// etc.
|
||||
const materialCode = values[2]?.toString().trim()
|
||||
const materialName = values[3]?.toString().trim()
|
||||
const specification = values[4]?.toString().trim()
|
||||
const model = values[5]?.toString().trim()
|
||||
const drawingNumber = values[6]?.toString().trim()
|
||||
const material = values[7]?.toString().trim()
|
||||
const quantity = this.parseFloat(values[8]) || 0
|
||||
const unit = values[9]?.toString().trim() || ''
|
||||
const requiredDate = values[10]?.toString().trim()
|
||||
const warehouse = values[11]?.toString().trim()
|
||||
const unitUsage = this.parseFloat(values[12])
|
||||
const cumulativeOutboundQty = this.parseFloat(values[13])
|
||||
|
||||
// Skip if no material code
|
||||
if (!materialCode) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
orderNumber,
|
||||
productionId,
|
||||
materialCode,
|
||||
materialName,
|
||||
specification,
|
||||
model,
|
||||
drawingNumber,
|
||||
material,
|
||||
quantity,
|
||||
unit,
|
||||
requiredDate,
|
||||
warehouse,
|
||||
unitUsage,
|
||||
cumulativeOutboundQty,
|
||||
rowNumber
|
||||
}
|
||||
}
|
||||
}
|
||||
113
src/main/services/excel/result-exporter.ts
Normal file
113
src/main/services/excel/result-exporter.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import ExcelJS from 'exceljs'
|
||||
import path from 'path'
|
||||
import { app } from 'electron'
|
||||
import fs from 'fs'
|
||||
import { createLogger } from '../logger'
|
||||
import type { ExportResultItem, ExportResultResponse } from '../../types/cleaner.types'
|
||||
|
||||
const log = createLogger('ResultExporter')
|
||||
|
||||
/**
|
||||
* Excel exporter for validation results
|
||||
* Exports filtered validation results to Excel file
|
||||
*/
|
||||
export class ResultExporter {
|
||||
private readonly exportDir: string
|
||||
private readonly fileName: string = '校验结果.xlsx'
|
||||
|
||||
constructor() {
|
||||
// Export to app directory/exports
|
||||
this.exportDir = path.join(app.getPath('userData'), 'exports')
|
||||
this.ensureExportDir()
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure export directory exists
|
||||
*/
|
||||
private ensureExportDir(): void {
|
||||
if (!fs.existsSync(this.exportDir)) {
|
||||
fs.mkdirSync(this.exportDir, { recursive: true })
|
||||
log.info('Created export directory', { path: this.exportDir })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export validation results to Excel
|
||||
* @param items - Validation result items to export
|
||||
* @returns Export result with file path or error
|
||||
*/
|
||||
async exportValidationResults(items: ExportResultItem[]): Promise<ExportResultResponse> {
|
||||
try {
|
||||
const filePath = path.join(this.exportDir, this.fileName)
|
||||
log.info('Exporting validation results', { count: items.length, path: filePath })
|
||||
|
||||
const workbook = new ExcelJS.Workbook()
|
||||
const worksheet = workbook.addWorksheet('校验结果')
|
||||
|
||||
// Define columns
|
||||
worksheet.columns = [
|
||||
{ header: '材料名称', key: 'materialName', width: 30 },
|
||||
{ header: '材料代码', key: 'materialCode', width: 20 },
|
||||
{ header: '规格', key: 'specification', width: 25 },
|
||||
{ header: '型号', key: 'model', width: 20 },
|
||||
{ header: '负责人', key: 'managerName', width: 15 },
|
||||
{ header: '勾选状态', key: 'isSelectedText', width: 12 },
|
||||
{ header: '是否标记删除', key: 'isMarkedForDeletionText', width: 14 }
|
||||
]
|
||||
|
||||
// Style header row
|
||||
const headerRow = worksheet.getRow(1)
|
||||
headerRow.font = { bold: true }
|
||||
headerRow.fill = {
|
||||
type: 'pattern',
|
||||
pattern: 'solid',
|
||||
fgColor: { argb: 'FFE0E0E0' }
|
||||
}
|
||||
headerRow.alignment = { horizontal: 'center' }
|
||||
|
||||
// Add data rows
|
||||
for (const item of items) {
|
||||
worksheet.addRow({
|
||||
materialName: item.materialName || '',
|
||||
materialCode: item.materialCode || '',
|
||||
specification: item.specification || '',
|
||||
model: item.model || '',
|
||||
managerName: item.managerName || '',
|
||||
isSelectedText: item.isSelected ? '是' : '否',
|
||||
isMarkedForDeletionText: item.isMarkedForDeletion ? '是' : '否'
|
||||
})
|
||||
}
|
||||
|
||||
// Style data rows
|
||||
for (let i = 2; i <= worksheet.rowCount; i++) {
|
||||
const row = worksheet.getRow(i)
|
||||
row.alignment = { vertical: 'middle' }
|
||||
|
||||
// Highlight selected items
|
||||
if (items[i - 2]?.isSelected) {
|
||||
row.fill = {
|
||||
type: 'pattern',
|
||||
pattern: 'solid',
|
||||
fgColor: { argb: 'FFE6F3FF' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save file
|
||||
await workbook.xlsx.writeFile(filePath)
|
||||
log.info('Export completed', { path: filePath, rows: items.length })
|
||||
|
||||
return {
|
||||
success: true,
|
||||
filePath
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
log.error('Export failed', { error: errorMessage })
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user