Compare commits
127 Commits
6698d82d6b
...
v1.4.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68e6c9483f | ||
|
|
63ff32817e | ||
|
|
78544af8de | ||
|
|
7d73592d41 | ||
|
|
bacdd2d82f | ||
|
|
4add295e44 | ||
|
|
88b2e8d355 | ||
|
|
fe5ad13f88 | ||
|
|
c8ce67dc75 | ||
|
|
9add23f6ed | ||
|
|
6d4b5efc95 | ||
|
|
2216720e24 | ||
|
|
36304b88e1 | ||
|
|
6ad9463e73 | ||
|
|
7644b8d4ea | ||
|
|
5e50a8fbcf | ||
|
|
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 |
35
.env.example
35
.env.example
@@ -1,35 +0,0 @@
|
|||||||
# ERP System Configuration
|
|
||||||
ERP_URL=https://your-erp-system.com
|
|
||||||
ERP_USERNAME=your_username
|
|
||||||
ERP_PASSWORD=your_password
|
|
||||||
|
|
||||||
# Additional ERP Settings
|
|
||||||
ERP_HEADLESS=true
|
|
||||||
ERP_IGNORE_HTTPS_ERRORS=true
|
|
||||||
ERP_AUTO_CLOSE_BROWSER=true
|
|
||||||
|
|
||||||
# Database Configuration - SQL Server
|
|
||||||
SQL_SERVER_HOST=localhost
|
|
||||||
SQL_SERVER_PORT=1433
|
|
||||||
SQL_SERVER_DATABASE=erp_db
|
|
||||||
SQL_SERVER_USERNAME=sa
|
|
||||||
SQL_SERVER_PASSWORD=your_password
|
|
||||||
|
|
||||||
MYSQL_HOST=localhost
|
|
||||||
MYSQL_PORT=3306
|
|
||||||
MYSQL_DATABASE=erp_db
|
|
||||||
MYSQL_USERNAME=root
|
|
||||||
MYSQL_PASSWORD=your_password
|
|
||||||
|
|
||||||
# Application Settings
|
|
||||||
LOG_LEVEL=info
|
|
||||||
DOWNLOAD_DIR=./downloads
|
|
||||||
TEMP_DIR=./temp
|
|
||||||
|
|
||||||
# Legacy configurations (if needed)
|
|
||||||
DB_SQLSERVER_DRIVER=ODBC Driver 18 for SQL Server
|
|
||||||
DB_TRUST_SERVER_CERTIFICATE=yes
|
|
||||||
DB_TYPE=mysql
|
|
||||||
DB_MYSQL_HOST=192.168.31.83
|
|
||||||
DB_MYSQL_PORT=3306
|
|
||||||
DB_MYSQL_CHARSET=utf8mb4
|
|
||||||
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
|
||||||
18
.gitignore
vendored
18
.gitignore
vendored
@@ -4,6 +4,7 @@ out
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
.eslintcache
|
.eslintcache
|
||||||
*.log*
|
*.log*
|
||||||
|
.npmrc
|
||||||
|
|
||||||
# AI Agent
|
# AI Agent
|
||||||
.claude
|
.claude
|
||||||
@@ -14,6 +15,8 @@ out
|
|||||||
# Test outputs
|
# Test outputs
|
||||||
coverage/
|
coverage/
|
||||||
downloads/
|
downloads/
|
||||||
|
release-output/
|
||||||
|
build/bin/
|
||||||
*.xlsx
|
*.xlsx
|
||||||
*.parsed.json
|
*.parsed.json
|
||||||
|
|
||||||
@@ -26,3 +29,18 @@ compare_*.js
|
|||||||
|
|
||||||
# logs
|
# logs
|
||||||
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
|
||||||
|
|||||||
212
CLAUDE.md
212
CLAUDE.md
@@ -1,133 +1,165 @@
|
|||||||
# CLAUDE.md
|
# CLAUDE.md
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
本文件用于给 AI 编程代理提供本项目的最小必要指导。
|
||||||
|
目标不是替代 `README` 或 `docs/`,而是帮助代理快速理解项目结构、工作方式和关键约束。
|
||||||
|
|
||||||
## Development Commands
|
## 项目概览
|
||||||
|
|
||||||
### Running the Application
|
ERPAuto 是一个基于 Electron 的桌面应用,用于自动化处理 ERP 系统中的数据提取、清理、校验和配置管理。
|
||||||
|
|
||||||
|
技术栈:
|
||||||
|
|
||||||
|
- Electron 39
|
||||||
|
- React 19
|
||||||
|
- TypeScript 5.9
|
||||||
|
- electron-vite / Vite 7
|
||||||
|
- Playwright 1.58
|
||||||
|
- Vitest
|
||||||
|
|
||||||
|
## 常用命令
|
||||||
|
|
||||||
|
开发与构建:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev # Start development server with hot reload
|
npm run dev
|
||||||
npm run build # Full build with type checking
|
npm run build
|
||||||
npm run build:win # Build Windows executable
|
npm run build:win
|
||||||
npm run build:mac # Build macOS DMG
|
|
||||||
npm run build:linux # Build Linux AppImage
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Code Quality
|
质量检查:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run lint # ESLint check
|
npm run typecheck
|
||||||
npm run format # Prettier format
|
npm run typecheck:node
|
||||||
npm run typecheck # TypeScript check (both main and renderer)
|
npm run typecheck:web
|
||||||
npm run typecheck:node # TypeScript check for main process only
|
npm run lint
|
||||||
npm run typecheck:web # TypeScript check for renderer only
|
npm run format
|
||||||
```
|
```
|
||||||
|
|
||||||
### Testing
|
测试:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run test # Run unit tests (Vitest)
|
npm run test
|
||||||
npm run test:coverage # Run tests with coverage report
|
npm run test:coverage
|
||||||
npm run test:e2e # Run E2E tests (Playwright)
|
npm run test:e2e
|
||||||
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:
|
```bash
|
||||||
|
npm run release:publish -- --channel stable
|
||||||
|
npm run release:publish -- --channel preview
|
||||||
|
```
|
||||||
|
|
||||||
### Process Structure
|
详细发布流程见:
|
||||||
|
[docs/build-and-release-guide.md](/d:/FileLib/Projects/CodeMigration/ERPAuto/docs/build-and-release-guide.md)
|
||||||
|
|
||||||
1. **Main Process** (`src/main/`)
|
## 代码结构
|
||||||
- Node.js environment managing application lifecycle
|
|
||||||
- Entry point: `src/main/index.ts`
|
|
||||||
- Registers all IPC handlers via `registerIpcHandlers()`
|
|
||||||
- Loads environment variables from `.env` 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/`)
|
- 路径:`src/main/`
|
||||||
- React 19 + TypeScript UI
|
- 入口:`src/main/index.ts`
|
||||||
- Uses exposed preload APIs for all main process communication
|
- 职责:
|
||||||
- Authentication-based routing with role-based access control
|
- 应用生命周期管理
|
||||||
|
- 配置加载
|
||||||
|
- IPC 注册
|
||||||
|
- 更新服务初始化
|
||||||
|
|
||||||
### Service Architecture
|
### 预加载层
|
||||||
|
|
||||||
The main process is organized around domain-specific services in `src/main/services/`:
|
- 路径:`src/preload/`
|
||||||
|
- 职责:
|
||||||
|
- 暴露 `window.electron` API
|
||||||
|
- 作为 renderer 和 main 之间的安全桥
|
||||||
|
|
||||||
- **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
|
- 路径:`src/renderer/`
|
||||||
- `MySqlService` / `mysql.ts` - MySQL operations
|
- 技术:React + TypeScript
|
||||||
- `SqlServerService` / `sql-server.ts` - SQL Server operations
|
- 特点:
|
||||||
- DAO pattern: `discrete-material-plan-dao.ts`, `materials-to-be-deleted-dao.ts`
|
- 通过 preload 暴露的 API 调用主进程
|
||||||
|
- 以登录状态和角色控制主要功能入口
|
||||||
|
|
||||||
- **User Services** (`services/user/`): Authentication and session management
|
### 服务层
|
||||||
- `BipUsersDao` - User data access
|
|
||||||
- `SessionManager` - Active session tracking
|
|
||||||
|
|
||||||
- **Other Services**:
|
主进程服务集中在 `src/main/services/`,按领域拆分:
|
||||||
- `config/` - Configuration management
|
|
||||||
- `excel/` - Excel file parsing
|
|
||||||
|
|
||||||
### IPC Handler Pattern
|
- `erp/`:ERP 浏览器自动化
|
||||||
|
- `database/`:MySQL / SQL Server
|
||||||
|
- `user/`:登录、会话、用户切换
|
||||||
|
- `config/`:YAML 配置管理
|
||||||
|
- `update/`:便携版更新
|
||||||
|
- `excel/`:Excel 处理
|
||||||
|
|
||||||
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
|
- 使用 YAML 配置
|
||||||
|
- 模板文件:`config.template.yaml`
|
||||||
|
- 开发环境通常使用项目根目录下的 `config.yaml`
|
||||||
|
- 生产环境会将配置放到用户目录
|
||||||
|
|
||||||
The application implements a multi-stage authentication system:
|
### IPC 组织方式
|
||||||
|
|
||||||
1. **Silent Login**: On startup, attempts automatic login using computer name
|
- 所有 IPC handler 在 `src/main/ipc/`
|
||||||
2. **Fallback**: Shows login dialog if silent login fails
|
- 每个领域一个 handler 模块
|
||||||
3. **Admin User Selection**: Admin users can switch to other user accounts
|
- 统一在 `src/main/ipc/index.ts` 注册
|
||||||
4. **Session Management**: Persistent sessions with role-based permissions (Admin/User/Guest)
|
- channel 命名遵循 `domain:action`
|
||||||
|
|
||||||
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)
|
- `Admin`
|
||||||
- Types are co-located with features: `src/main/types/` contains domain-specific type definitions
|
- `User`
|
||||||
- The preload script exposes a typed API surface that's available in renderer
|
- `Guest`
|
||||||
|
|
||||||
### 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)
|
|
||||||
|
|
||||||
## Environment Configuration
|
### 便携版自动更新
|
||||||
|
|
||||||
The application requires a `.env` file in the project root. Reference `.env.example` for the full structure. Key configurations:
|
项目已实现 Windows 便携版更新,关键点:
|
||||||
|
|
||||||
- **ERP Settings**: URL, credentials, headless mode, HTTPS error handling
|
- 更新检查基于登录用户角色
|
||||||
- **Database**: MySQL and SQL Server connection configs (dual support)
|
- 支持 `stable` / `preview` 双通道
|
||||||
- **App Settings**: Log level, download/temp directories
|
- `User` 只看 `stable`
|
||||||
|
- `Admin` 同时看 `stable` 和 `preview`
|
||||||
|
- 使用原生 `portable-updater.exe` 完成替换,不依赖 PowerShell
|
||||||
|
|
||||||
## Key Technologies
|
相关文档:
|
||||||
|
|
||||||
- **Electron 39** - Desktop framework
|
- [docs/portable-auto-update-architecture.md](/d:/FileLib/Projects/CodeMigration/ERPAuto/docs/portable-auto-update-architecture.md)
|
||||||
- **React 19** - UI framework
|
- [docs/build-and-release-guide.md](/d:/FileLib/Projects/CodeMigration/ERPAuto/docs/build-and-release-guide.md)
|
||||||
- **TypeScript 5.9** - Type safety
|
|
||||||
- **Playwright 1.58** - Browser automation for ERP interaction
|
## 代理工作约束
|
||||||
- **electron-vite + Vite 7** - Build tooling
|
|
||||||
- **Zod** - Runtime validation
|
1. 优先修改现有文件,不要随意新建同类文件。
|
||||||
- **Vitest** - Unit tests
|
2. 变更前先理解对应模块的现有模式,尽量保持风格一致。
|
||||||
- **Playwright Test** - E2E tests
|
3. renderer 不要直接访问 Node/Electron 能力,统一走 preload。
|
||||||
|
4. 配置、IPC、类型定义通常需要同步更新,避免只改一层。
|
||||||
|
5. 涉及发布、更新、构建链路时,优先复用现有脚本,不要重复实现。
|
||||||
|
6. 涉及浏览器部署或更新流程时,先看 `docs/` 里的专题文档。
|
||||||
|
|
||||||
|
## 代理优先查看的文档
|
||||||
|
|
||||||
|
- [README.md](/d:/FileLib/Projects/CodeMigration/ERPAuto/README.md)
|
||||||
|
- [docs/build-and-release-guide.md](/d:/FileLib/Projects/CodeMigration/ERPAuto/docs/build-and-release-guide.md)
|
||||||
|
- [docs/portable-auto-update-architecture.md](/d:/FileLib/Projects/CodeMigration/ERPAuto/docs/portable-auto-update-architecture.md)
|
||||||
|
- [docs/browser/PLAYWRIGHT_DEPLOYMENT.md](/d:/FileLib/Projects/CodeMigration/ERPAuto/docs/browser/PLAYWRIGHT_DEPLOYMENT.md)
|
||||||
|
|
||||||
|
## 不放在这里的内容
|
||||||
|
|
||||||
|
以下内容不应继续堆在本文件中:
|
||||||
|
|
||||||
|
- 详细用户使用说明
|
||||||
|
- 大段业务流程说明
|
||||||
|
- 重复的架构长文
|
||||||
|
- 版本发布记录
|
||||||
|
|
||||||
|
这些内容应继续放在 `README` 或 `docs/` 下的专题文档中。
|
||||||
|
|||||||
55
README.md
55
README.md
@@ -30,29 +30,43 @@ npm install
|
|||||||
|
|
||||||
### 配置
|
### 配置
|
||||||
|
|
||||||
在项目根目录创建 `.env` 文件:
|
在项目根目录创建 `config.yaml` 文件(可参考 `config.template.yaml`):
|
||||||
|
|
||||||
```bash
|
```yaml
|
||||||
# ERP 配置
|
# ERP 配置(固定基础设施)
|
||||||
ERP_URL=https://your-erp-server.com
|
erp:
|
||||||
ERP_USERNAME=your_username
|
url: https://your-erp-server.com
|
||||||
ERP_PASSWORD=your_password
|
|
||||||
|
|
||||||
# MySQL 配置(可选)
|
# 数据库配置
|
||||||
MYSQL_HOST=localhost
|
database:
|
||||||
MYSQL_PORT=3306
|
activeType: mysql # 或 sqlserver
|
||||||
MYSQL_USER=root
|
|
||||||
MYSQL_PASSWORD=password
|
|
||||||
MYSQL_DATABASE=erpauto
|
|
||||||
|
|
||||||
# SQL Server 配置(可选)
|
mysql:
|
||||||
SQL_SERVER_HOST=localhost
|
host: localhost
|
||||||
SQL_SERVER_PORT=1433
|
port: 3306
|
||||||
SQL_SERVER_USER=sa
|
database: erpauto
|
||||||
SQL_SERVER_PASSWORD=password
|
username: root
|
||||||
SQL_SERVER_DATABASE=erpauto
|
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
|
```bash
|
||||||
@@ -136,9 +150,10 @@ ERPAuto/
|
|||||||
|
|
||||||
### 无法连接 ERP 系统
|
### 无法连接 ERP 系统
|
||||||
|
|
||||||
1. 检查 `.env` 文件中的 ERP_URL 是否正确
|
1. 检查 `config.yaml` 中的 ERP URL 是否正确
|
||||||
2. 确认网络连接正常
|
2. 确认网络连接正常
|
||||||
3. 检查 ERP 系统是否可访问
|
3. 检查 ERP 系统是否可访问
|
||||||
|
4. 在设置界面中确认 ERP 用户名和密码已配置
|
||||||
|
|
||||||
### 提取失败
|
### 提取失败
|
||||||
|
|
||||||
@@ -149,7 +164,7 @@ ERPAuto/
|
|||||||
### 数据库连接失败
|
### 数据库连接失败
|
||||||
|
|
||||||
1. 确认数据库服务已启动
|
1. 确认数据库服务已启动
|
||||||
2. 检查 `.env` 中的数据库配置
|
2. 检查 `config.yaml` 中的数据库配置
|
||||||
3. 确认防火墙允许数据库端口访问
|
3. 确认防火墙允许数据库端口访问
|
||||||
|
|
||||||
## 开发
|
## 开发
|
||||||
|
|||||||
247
build/PortableUpdater.cs
Normal file
247
build/PortableUpdater.cs
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading;
|
||||||
|
|
||||||
|
internal static class PortableUpdater
|
||||||
|
{
|
||||||
|
private static string _logPath = string.Empty;
|
||||||
|
|
||||||
|
private static int Main(string[] args)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var options = ParseArgs(args);
|
||||||
|
|
||||||
|
var targetExe = Require(options, "--targetExe");
|
||||||
|
var downloadedExe = Require(options, "--downloadedExe");
|
||||||
|
var parentPid = int.Parse(Require(options, "--parentPid"));
|
||||||
|
_logPath = Require(options, "--logPath");
|
||||||
|
var argsBase64 = options.ContainsKey("--argsBase64") ? options["--argsBase64"] : string.Empty;
|
||||||
|
|
||||||
|
WriteLog("Portable updater started");
|
||||||
|
WriteLog("Target exe: " + targetExe);
|
||||||
|
WriteLog("Downloaded exe: " + downloadedExe);
|
||||||
|
WriteLog("Parent pid: " + parentPid);
|
||||||
|
|
||||||
|
var appArgs = DecodeArgs(argsBase64);
|
||||||
|
|
||||||
|
WaitForProcessExit(parentPid, 120);
|
||||||
|
WaitForFileAvailable(targetExe, 120);
|
||||||
|
|
||||||
|
var backupExe = targetExe + ".bak";
|
||||||
|
if (File.Exists(backupExe))
|
||||||
|
{
|
||||||
|
WriteLog("Removing stale backup: " + backupExe);
|
||||||
|
File.Delete(backupExe);
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteLog("Backing up current executable");
|
||||||
|
File.Move(targetExe, backupExe);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
WriteLog("Replacing executable");
|
||||||
|
File.Move(downloadedExe, targetExe);
|
||||||
|
}
|
||||||
|
catch (Exception replaceError)
|
||||||
|
{
|
||||||
|
WriteLog("Replace failed: " + replaceError.Message);
|
||||||
|
if (File.Exists(backupExe) && !File.Exists(targetExe))
|
||||||
|
{
|
||||||
|
File.Move(backupExe, targetExe);
|
||||||
|
}
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
var startInfo = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = targetExe,
|
||||||
|
UseShellExecute = false,
|
||||||
|
WorkingDirectory = Path.GetDirectoryName(targetExe) ?? Environment.CurrentDirectory,
|
||||||
|
Arguments = BuildArgumentString(appArgs)
|
||||||
|
};
|
||||||
|
|
||||||
|
WriteLog("Launching updated executable");
|
||||||
|
Process.Start(startInfo);
|
||||||
|
|
||||||
|
if (File.Exists(backupExe))
|
||||||
|
{
|
||||||
|
WriteLog("Removing backup file");
|
||||||
|
File.Delete(backupExe);
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteLog("Portable update completed successfully");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
WriteLog("Portable update failed: " + ex);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, string> ParseArgs(string[] args)
|
||||||
|
{
|
||||||
|
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
for (var i = 0; i < args.Length; i++)
|
||||||
|
{
|
||||||
|
var key = args[i];
|
||||||
|
if (!key.StartsWith("--", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var value = i + 1 < args.Length ? args[i + 1] : string.Empty;
|
||||||
|
if (value.StartsWith("--", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
result[key] = string.Empty;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
result[key] = value;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Require(Dictionary<string, string> options, string key)
|
||||||
|
{
|
||||||
|
if (!options.ContainsKey(key) || string.IsNullOrWhiteSpace(options[key]))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Missing required argument: " + key);
|
||||||
|
}
|
||||||
|
|
||||||
|
return options[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string[] DecodeArgs(string argsBase64)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(argsBase64))
|
||||||
|
{
|
||||||
|
return Array.Empty<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var raw = Encoding.UTF8.GetString(Convert.FromBase64String(argsBase64));
|
||||||
|
return raw.Split(new[] { '\0' }, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WaitForProcessExit(int pid, int timeoutSeconds)
|
||||||
|
{
|
||||||
|
var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds);
|
||||||
|
|
||||||
|
while (DateTime.UtcNow < deadline)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using (var process = Process.GetProcessById(pid))
|
||||||
|
{
|
||||||
|
if (process.HasExited)
|
||||||
|
{
|
||||||
|
WriteLog("Parent process exited");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (ArgumentException)
|
||||||
|
{
|
||||||
|
WriteLog("Parent process already exited");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Thread.Sleep(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new TimeoutException("Timed out waiting for process exit: " + pid);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WaitForFileAvailable(string filePath, int timeoutSeconds)
|
||||||
|
{
|
||||||
|
var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds);
|
||||||
|
|
||||||
|
while (DateTime.UtcNow < deadline)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using (File.Open(filePath, FileMode.Open, FileAccess.ReadWrite, FileShare.None))
|
||||||
|
{
|
||||||
|
WriteLog("Target executable is no longer locked");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
Thread.Sleep(500);
|
||||||
|
}
|
||||||
|
catch (UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
Thread.Sleep(500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new TimeoutException("Timed out waiting for target executable to become writable: " + filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteLog(string message)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_logPath))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var directory = Path.GetDirectoryName(_logPath);
|
||||||
|
if (!string.IsNullOrWhiteSpace(directory))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
File.AppendAllText(
|
||||||
|
_logPath,
|
||||||
|
DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff") + " " + message + Environment.NewLine,
|
||||||
|
Encoding.UTF8
|
||||||
|
);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Best effort logging only.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildArgumentString(IEnumerable<string> args)
|
||||||
|
{
|
||||||
|
var builder = new StringBuilder();
|
||||||
|
|
||||||
|
foreach (var arg in args)
|
||||||
|
{
|
||||||
|
if (builder.Length > 0)
|
||||||
|
{
|
||||||
|
builder.Append(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.Append(QuoteArgument(arg));
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string QuoteArgument(string arg)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(arg))
|
||||||
|
{
|
||||||
|
return "\"\"";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg.IndexOfAny(new[] { ' ', '\t', '"' }) < 0)
|
||||||
|
{
|
||||||
|
return arg;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "\"" + arg.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\"";
|
||||||
|
}
|
||||||
|
}
|
||||||
85
config.template.yaml
Normal file
85
config.template.yaml
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# ================================
|
||||||
|
# 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 兼容,默认即可)
|
||||||
|
|
||||||
|
# 便携版自动更新配置
|
||||||
|
update:
|
||||||
|
enabled: false
|
||||||
|
allowDevMode: false
|
||||||
|
endpoint: 'http://192.168.110.114:9000'
|
||||||
|
accessKey: '<YOUR_ACCESS_KEY>'
|
||||||
|
secretKey: '<YOUR_SECRET_KEY>'
|
||||||
|
bucket: 'erpauto'
|
||||||
|
region: 'us-east-1'
|
||||||
|
basePrefix: 'updates/win-portable'
|
||||||
|
checkIntervalMinutes: 30
|
||||||
|
maxAdminHistoryPerChannel: 10
|
||||||
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
|
||||||
139
docs/browser/BROWSER_VERSIONS.md
Normal file
139
docs/browser/BROWSER_VERSIONS.md
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
# Playwright 浏览器版本信息
|
||||||
|
|
||||||
|
本文档记录 ERPAuto 当前使用的 Playwright 版本,以及代码中采用的浏览器目录约定。
|
||||||
|
|
||||||
|
## 当前版本
|
||||||
|
|
||||||
|
根据 [package.json](/d:/FileLib/Projects/CodeMigration/ERPAuto/package.json),项目当前依赖为:
|
||||||
|
|
||||||
|
| 组件 | 当前版本 |
|
||||||
|
| --- | --- |
|
||||||
|
| `playwright` | `^1.58.2` |
|
||||||
|
| `playwright-core` | `^1.58.2` |
|
||||||
|
| `@playwright/test` | `^1.58.2` |
|
||||||
|
|
||||||
|
## 当前代码中的目录约定
|
||||||
|
|
||||||
|
根据 [src/main/index.ts](/d:/FileLib/Projects/CodeMigration/ERPAuto/src/main/index.ts),应用启动时会把:
|
||||||
|
|
||||||
|
```text
|
||||||
|
PLAYWRIGHT_BROWSERS_PATH = %APPDATA%\erpauto\ms-playwright
|
||||||
|
```
|
||||||
|
|
||||||
|
随后按以下顺序检查 Chromium:
|
||||||
|
|
||||||
|
1. 优先检查:
|
||||||
|
|
||||||
|
```text
|
||||||
|
%APPDATA%\erpauto\ms-playwright\chromium-1208\chrome-win64\chrome.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 兼容旧结构:
|
||||||
|
|
||||||
|
```text
|
||||||
|
%APPDATA%\erpauto\ms-playwright\chromium-win32\chrome.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 如果仍然找不到,则继续扫描任意 `chromium-*` 目录,并尝试:
|
||||||
|
|
||||||
|
```text
|
||||||
|
%APPDATA%\erpauto\ms-playwright\chromium-<revision>\chrome-win64\chrome.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
这意味着:
|
||||||
|
|
||||||
|
- `chromium-1208` 是当前代码优先查找的默认 revision
|
||||||
|
- 但应用并不只接受 `1208`
|
||||||
|
- 当前主目录结构是 `chrome-win64`
|
||||||
|
|
||||||
|
## 推荐目录结构
|
||||||
|
|
||||||
|
### Windows 开发环境
|
||||||
|
|
||||||
|
```text
|
||||||
|
C:\Users\<用户名>\AppData\Local\ms-playwright\
|
||||||
|
└── chromium-1208/
|
||||||
|
└── chrome-win64/
|
||||||
|
├── chrome.exe
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 目标部署环境
|
||||||
|
|
||||||
|
```text
|
||||||
|
%APPDATA%\erpauto\ms-playwright\
|
||||||
|
└── chromium-1208/
|
||||||
|
└── chrome-win64/
|
||||||
|
├── chrome.exe
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
如果不是 `1208`,只要目录名满足 `chromium-*` 且内部存在 `chrome-win64\chrome.exe`,当前代码也能识别。
|
||||||
|
|
||||||
|
## 使用建议
|
||||||
|
|
||||||
|
### 开发环境准备
|
||||||
|
|
||||||
|
1. 安装项目依赖:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 下载 Chromium 浏览器:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright install chromium
|
||||||
|
```
|
||||||
|
|
||||||
|
### 复制浏览器文件
|
||||||
|
|
||||||
|
1. 在开发机上找到 Playwright 浏览器缓存目录:
|
||||||
|
|
||||||
|
```text
|
||||||
|
C:\Users\<用户名>\AppData\Local\ms-playwright\
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 复制完整的 `chromium-*` 目录到目标路径:
|
||||||
|
|
||||||
|
```text
|
||||||
|
%APPDATA%\erpauto\ms-playwright\
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 确保内部存在:
|
||||||
|
|
||||||
|
```text
|
||||||
|
chrome-win64\chrome.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- ERPAuto 当前只依赖 Chromium,不需要 Firefox 和 WebKit
|
||||||
|
- 浏览器文件体积较大,建议按整个 revision 目录复制
|
||||||
|
- 当前代码会优先尝试 `chromium-1208`
|
||||||
|
- 但从实现角度看,“revision 严格等于 1208”不是唯一成功条件
|
||||||
|
- 真正关键的是目录结构与可执行文件路径满足当前查找规则
|
||||||
|
|
||||||
|
## 故障排查
|
||||||
|
|
||||||
|
### 浏览器无法启动
|
||||||
|
|
||||||
|
优先检查:
|
||||||
|
|
||||||
|
1. `%APPDATA%\erpauto\ms-playwright\` 是否存在
|
||||||
|
2. 是否存在 `chromium-1208\chrome-win64\chrome.exe`
|
||||||
|
3. 是否存在其他 `chromium-*` 目录,且包含 `chrome-win64\chrome.exe`
|
||||||
|
4. 是否误用了过时的 `chrome-win\chrome.exe` 路径
|
||||||
|
|
||||||
|
### 版本不匹配或路径不匹配
|
||||||
|
|
||||||
|
建议同时确认:
|
||||||
|
|
||||||
|
- `package.json` 中的 Playwright 版本
|
||||||
|
- 目标机器上实际部署的 Chromium 目录
|
||||||
|
- 当前目录结构是否为 `chrome-win64\chrome.exe`
|
||||||
|
- 应用启动时是否能在 `%APPDATA%\erpauto\ms-playwright\` 下扫描到有效 revision
|
||||||
|
|
||||||
|
## 相关文档
|
||||||
|
|
||||||
|
- [PLAYWRIGHT_DEPLOYMENT.md](./PLAYWRIGHT_DEPLOYMENT.md)
|
||||||
179
docs/browser/PLAYWRIGHT_DEPLOYMENT.md
Normal file
179
docs/browser/PLAYWRIGHT_DEPLOYMENT.md
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
# Playwright 部署说明
|
||||||
|
|
||||||
|
本文档聚焦“如何让 ERPAuto 在目标机器上拥有可用的 Playwright Chromium 浏览器”,适合作为实际部署操作说明。
|
||||||
|
|
||||||
|
如果你想看版本信息,请同时参考:
|
||||||
|
|
||||||
|
- [BROWSER_VERSIONS.md](./BROWSER_VERSIONS.md)
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
ERPAuto 启动时会把 `PLAYWRIGHT_BROWSERS_PATH` 设置到:
|
||||||
|
|
||||||
|
```text
|
||||||
|
%APPDATA%\erpauto\ms-playwright
|
||||||
|
```
|
||||||
|
|
||||||
|
随后在该目录下查找 Chromium 浏览器文件。当前代码支持:
|
||||||
|
|
||||||
|
- `chromium-1208\chrome-win64\chrome.exe`
|
||||||
|
- `chromium-win32\chrome.exe`
|
||||||
|
- 任意 `chromium-*` 目录下的 `chrome-win64\chrome.exe`
|
||||||
|
|
||||||
|
因此,部署的本质就是把一个完整可用的 Chromium revision 目录放到这个位置。
|
||||||
|
|
||||||
|
当前查找顺序是:
|
||||||
|
|
||||||
|
1. 先查 `%APPDATA%\erpauto\ms-playwright\chromium-1208\chrome-win64\chrome.exe`
|
||||||
|
2. 再查 `%APPDATA%\erpauto\ms-playwright\chromium-win32\chrome.exe`
|
||||||
|
3. 最后扫描任意 `chromium-*` 目录下的 `chrome-win64\chrome.exe`
|
||||||
|
|
||||||
|
所以从部署角度看,真正重要的不是目录名一定等于 `1208`,而是目录结构满足当前实现的查找规则。
|
||||||
|
|
||||||
|
## 推荐部署方式
|
||||||
|
|
||||||
|
### 方式 1:使用 Playwright CLI
|
||||||
|
|
||||||
|
如果目标机器能联网,最简单的方式是在项目目录执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright install chromium
|
||||||
|
```
|
||||||
|
|
||||||
|
执行后,需要把下载得到的 `chromium-*` 目录放到:
|
||||||
|
|
||||||
|
```text
|
||||||
|
%APPDATA%\erpauto\ms-playwright\
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- Playwright 默认下载目录通常是 `%LOCALAPPDATA%\ms-playwright\`
|
||||||
|
- ERPAuto 运行时查找的是 `%APPDATA%\erpauto\ms-playwright\`
|
||||||
|
- 所以“下载成功”不等于“应用一定能找到”,最终还是要确保文件落在 ERPAuto 使用的目录下
|
||||||
|
|
||||||
|
### 方式 2:手动复制
|
||||||
|
|
||||||
|
这是离线环境或最稳定的部署方式。
|
||||||
|
|
||||||
|
1. 在一台已完成 `npx playwright install chromium` 的机器上找到:
|
||||||
|
|
||||||
|
```text
|
||||||
|
C:\Users\<用户名>\AppData\Local\ms-playwright\
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 复制完整的 `chromium-*` 目录,例如:
|
||||||
|
|
||||||
|
```text
|
||||||
|
chromium-1208
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 将该目录复制到目标机器:
|
||||||
|
|
||||||
|
```text
|
||||||
|
%APPDATA%\erpauto\ms-playwright\
|
||||||
|
```
|
||||||
|
|
||||||
|
4. 确认内部存在:
|
||||||
|
|
||||||
|
```text
|
||||||
|
chrome-win64\chrome.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
## 推荐目录示例
|
||||||
|
|
||||||
|
```text
|
||||||
|
%APPDATA%\erpauto\ms-playwright\
|
||||||
|
└── chromium-1208/
|
||||||
|
└── chrome-win64/
|
||||||
|
├── chrome.exe
|
||||||
|
├── chrome.dll
|
||||||
|
├── locales/
|
||||||
|
├── resources/
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
如果你使用的是旧结构,也可兼容:
|
||||||
|
|
||||||
|
```text
|
||||||
|
%APPDATA%\erpauto\ms-playwright\
|
||||||
|
└── chromium-win32/
|
||||||
|
└── chrome.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
## 验证方式
|
||||||
|
|
||||||
|
### 验证 1:检查文件
|
||||||
|
|
||||||
|
执行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Get-ChildItem $env:APPDATA\erpauto\ms-playwright
|
||||||
|
```
|
||||||
|
|
||||||
|
如果使用默认 revision,再执行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Test-Path "$env:APPDATA\erpauto\ms-playwright\chromium-1208\chrome-win64\chrome.exe"
|
||||||
|
```
|
||||||
|
|
||||||
|
如果你部署的是其他 revision,请按实际目录替换。
|
||||||
|
|
||||||
|
### 验证 2:启动应用
|
||||||
|
|
||||||
|
启动 ERPAuto,观察是否仍弹出“浏览器文件未找到”错误框。
|
||||||
|
|
||||||
|
如果没有弹窗,通常说明启动时的浏览器路径检查已经通过。
|
||||||
|
|
||||||
|
### 验证 3:执行真实业务
|
||||||
|
|
||||||
|
进入依赖 Playwright 的功能页面,执行一次真实流程,确认浏览器可以正常启动。
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### 问题 1:文件明明存在,但应用还是报找不到
|
||||||
|
|
||||||
|
常见原因:
|
||||||
|
|
||||||
|
1. 文件放在 `%LOCALAPPDATA%\ms-playwright\`,而不是 `%APPDATA%\erpauto\ms-playwright\`
|
||||||
|
2. 目录结构是旧文档里的 `chrome-win\chrome.exe`
|
||||||
|
3. revision 目录名不符合 `chromium-*`
|
||||||
|
4. 缺少 `chrome-win64\chrome.exe`
|
||||||
|
|
||||||
|
### 问题 2:想多个用户共用同一份浏览器文件
|
||||||
|
|
||||||
|
当前代码在应用启动时会直接把 `PLAYWRIGHT_BROWSERS_PATH` 设为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
%APPDATA%\erpauto\ms-playwright
|
||||||
|
```
|
||||||
|
|
||||||
|
所以默认行为是“每个用户使用自己的用户目录”。
|
||||||
|
如果要改成共享目录,需要同时改代码,而不是只改系统环境变量。
|
||||||
|
|
||||||
|
### 问题 3:构建时是否会自动打包 Chromium
|
||||||
|
|
||||||
|
不会。
|
||||||
|
|
||||||
|
当前 [package.json](/d:/FileLib/Projects/CodeMigration/ERPAuto/package.json) 的 `build:win` 明确设置了:
|
||||||
|
|
||||||
|
```text
|
||||||
|
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
|
||||||
|
```
|
||||||
|
|
||||||
|
也就是说:
|
||||||
|
|
||||||
|
- 构建过程不会自动下载浏览器
|
||||||
|
- 打包产物也不自带 Chromium
|
||||||
|
- 浏览器仍需要单独部署到目标机器
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
|
||||||
|
当前最稳妥的部署方式是:
|
||||||
|
|
||||||
|
1. 在联网机器下载 Chromium
|
||||||
|
2. 复制完整 `chromium-*` 目录
|
||||||
|
3. 放到 `%APPDATA%\erpauto\ms-playwright\`
|
||||||
|
4. 确保内部有 `chrome-win64\chrome.exe`
|
||||||
|
|
||||||
|
只要满足这几个条件,ERPAuto 当前实现就能正确识别并使用浏览器。
|
||||||
199
docs/build-and-release-guide.md
Normal file
199
docs/build-and-release-guide.md
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
# 构建与发布流程
|
||||||
|
|
||||||
|
本文档说明 ERPAuto Windows 便携版的当前构建与发布方式,包括推荐的一键发布命令、分步命令,以及发布产物在对象存储中的结构。
|
||||||
|
|
||||||
|
## 概览
|
||||||
|
|
||||||
|
当前发布链路分为 3 个阶段:
|
||||||
|
|
||||||
|
1. 构建 Windows 包
|
||||||
|
2. 生成本地发布物料和索引
|
||||||
|
3. 上传到对象存储并校验远端索引
|
||||||
|
|
||||||
|
现在已经提供一键总控脚本:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run release:publish -- --channel stable
|
||||||
|
```
|
||||||
|
|
||||||
|
或:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run release:publish -- --channel preview
|
||||||
|
```
|
||||||
|
|
||||||
|
## 发布前准备
|
||||||
|
|
||||||
|
发布前需要确认:
|
||||||
|
|
||||||
|
1. [package.json](/d:/FileLib/Projects/CodeMigration/ERPAuto/package.json) 和 [package-lock.json](/d:/FileLib/Projects/CodeMigration/ERPAuto/package-lock.json) 的版本号已经改到目标版本
|
||||||
|
2. [config.yaml](/d:/FileLib/Projects/CodeMigration/ERPAuto/config.yaml) 中 `update` 配置正确,且对象存储可访问
|
||||||
|
3. 对应版本的 changelog 已存在于 `docs/releases/`
|
||||||
|
|
||||||
|
changelog 自动查找规则如下:
|
||||||
|
|
||||||
|
1. 优先查找 `docs/releases/<version>-rebuild.md`
|
||||||
|
2. 如果不存在,再查找 `docs/releases/<version>.md`
|
||||||
|
|
||||||
|
例如当前版本是 `1.3.6`,脚本会按顺序尝试:
|
||||||
|
|
||||||
|
```text
|
||||||
|
docs/releases/1.3.6-rebuild.md
|
||||||
|
docs/releases/1.3.6.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 推荐流程:一键发布
|
||||||
|
|
||||||
|
### 发布 Stable
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run release:publish -- --channel stable
|
||||||
|
```
|
||||||
|
|
||||||
|
### 发布 Preview
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run release:publish -- --channel preview
|
||||||
|
```
|
||||||
|
|
||||||
|
这个命令会自动完成:
|
||||||
|
|
||||||
|
1. 读取当前 `package.json` 版本号
|
||||||
|
2. 校验 `package.json` / `package-lock.json` 版本一致
|
||||||
|
3. 校验 changelog 文件存在
|
||||||
|
4. 设置 `APP_CHANNEL`
|
||||||
|
5. 执行 `build:win`
|
||||||
|
6. 执行 `release:prepare`
|
||||||
|
7. 执行 `release:upload --verify`
|
||||||
|
8. 输出本次发布摘要
|
||||||
|
|
||||||
|
## 分步流程
|
||||||
|
|
||||||
|
如果需要调试,也可以手工分步执行。
|
||||||
|
|
||||||
|
### 1. 构建
|
||||||
|
|
||||||
|
Stable:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$env:APP_CHANNEL="stable"
|
||||||
|
npm run build:win
|
||||||
|
```
|
||||||
|
|
||||||
|
Preview:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$env:APP_CHANNEL="preview"
|
||||||
|
npm run build:win
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 生成发布物料
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run release:prepare -- --channel stable --changelog docs/releases/1.3.6.md
|
||||||
|
```
|
||||||
|
|
||||||
|
或:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run release:prepare -- --channel preview --changelog docs/releases/1.3.6.md
|
||||||
|
```
|
||||||
|
|
||||||
|
这一步会生成:
|
||||||
|
|
||||||
|
- `release-output/updates/win-portable/<channel>/artifacts/...`
|
||||||
|
- `release-output/updates/win-portable/<channel>/changelogs/...`
|
||||||
|
- `release-output/updates/win-portable/<channel>/index.json`
|
||||||
|
|
||||||
|
### 3. 上传并校验
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run release:upload -- --channel stable --verify
|
||||||
|
```
|
||||||
|
|
||||||
|
或:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run release:upload -- --channel preview --verify
|
||||||
|
```
|
||||||
|
|
||||||
|
## 上传策略
|
||||||
|
|
||||||
|
当前上传脚本默认采用“增量上传”:
|
||||||
|
|
||||||
|
- 上传当前版本对应的 `artifact`
|
||||||
|
- 上传当前版本对应的 `changelog`
|
||||||
|
- 上传最新的 `index.json`
|
||||||
|
|
||||||
|
也就是说,它不会再把旧版本的 exe 和旧 changelog 全部重复上传。
|
||||||
|
|
||||||
|
如果确实需要整条通道做一次全量同步,可以显式使用:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run release:upload -- --channel stable --full-sync
|
||||||
|
```
|
||||||
|
|
||||||
|
## 对象存储目录结构
|
||||||
|
|
||||||
|
发布到对象存储后的目录结构如下:
|
||||||
|
|
||||||
|
```text
|
||||||
|
updates/win-portable/
|
||||||
|
├── stable/
|
||||||
|
│ ├── artifacts/
|
||||||
|
│ │ └── erpauto-<version>-stable-portable.exe
|
||||||
|
│ ├── changelogs/
|
||||||
|
│ │ └── <version>.md
|
||||||
|
│ └── index.json
|
||||||
|
└── preview/
|
||||||
|
├── artifacts/
|
||||||
|
│ └── erpauto-<version>-preview-portable.exe
|
||||||
|
├── changelogs/
|
||||||
|
│ └── <version>.md
|
||||||
|
└── index.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## 相关脚本
|
||||||
|
|
||||||
|
- [publish-release.js](/d:/FileLib/Projects/CodeMigration/ERPAuto/scripts/publish-release.js)
|
||||||
|
一键总控脚本,负责串起 build、prepare、upload。
|
||||||
|
- [prepare-release.js](/d:/FileLib/Projects/CodeMigration/ERPAuto/scripts/prepare-release.js)
|
||||||
|
负责整理本地发布物料和生成 `index.json`。
|
||||||
|
- [upload-release.js](/d:/FileLib/Projects/CodeMigration/ERPAuto/scripts/upload-release.js)
|
||||||
|
负责上传当前版本物料和 `index.json`,并可回读远端索引。
|
||||||
|
- [compile-updater.js](/d:/FileLib/Projects/CodeMigration/ERPAuto/scripts/compile-updater.js)
|
||||||
|
负责构建原生 `portable-updater.exe`。
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### 1. 缺少 `--channel`
|
||||||
|
|
||||||
|
会直接失败,因为发布必须显式指定 `stable` 或 `preview`。
|
||||||
|
|
||||||
|
### 2. 找不到 changelog
|
||||||
|
|
||||||
|
说明 `docs/releases/` 下没有当前版本对应的 Markdown 文件。
|
||||||
|
先补 changelog,再执行发布。
|
||||||
|
|
||||||
|
### 3. 版本不是当前通道最新
|
||||||
|
|
||||||
|
总控脚本在 `prepare` 后会检查本地生成的 `index.json`。
|
||||||
|
如果当前版本不是该通道索引的第一项,脚本会停止,避免把旧版本误当成当前发布版本。
|
||||||
|
|
||||||
|
### 4. 只想调试某一步
|
||||||
|
|
||||||
|
可以直接使用分步命令:
|
||||||
|
|
||||||
|
- `npm run build:win`
|
||||||
|
- `npm run release:prepare -- ...`
|
||||||
|
- `npm run release:upload -- ...`
|
||||||
|
|
||||||
|
## 建议
|
||||||
|
|
||||||
|
日常发布优先使用:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run release:publish -- --channel <stable|preview>
|
||||||
|
```
|
||||||
|
|
||||||
|
只有在排查问题或需要特殊处理时,再退回分步命令。
|
||||||
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 获取 | - |
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
### Bug Description
|
### Bug Description
|
||||||
|
|
||||||
For **User type (non-Admin)** users:
|
For **User type (non-Admin)** users:
|
||||||
|
|
||||||
1. The table shows only materials assigned to the current user (filtered by `filteredResults`)
|
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
|
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
|
3. Clicking "确认删除" (Confirm Deletion) processed **ALL** materials in `validationResults`, not just visible ones
|
||||||
@@ -20,12 +21,14 @@ For **User type (non-Admin)** users:
|
|||||||
### Root Causes
|
### Root Causes
|
||||||
|
|
||||||
#### 1. "取消" Button (Line 420)
|
#### 1. "取消" Button (Line 420)
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// ❌ WRONG: Clears ALL selected items
|
// ❌ WRONG: Clears ALL selected items
|
||||||
onClick={() => setSelectedItems(new Set())}
|
onClick={() => setSelectedItems(new Set())}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 2. `handleConfirmDeletion` Function (Line 165)
|
#### 2. `handleConfirmDeletion` Function (Line 165)
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// ❌ WRONG: Iterates ALL validation results
|
// ❌ WRONG: Iterates ALL validation results
|
||||||
for (const result of validationResults) {
|
for (const result of validationResults) {
|
||||||
@@ -87,6 +90,7 @@ graph TB
|
|||||||
```
|
```
|
||||||
|
|
||||||
**What Changed**:
|
**What Changed**:
|
||||||
|
|
||||||
- Before: `setSelectedItems(new Set())` - clears everything
|
- Before: `setSelectedItems(new Set())` - clears everything
|
||||||
- After: Iterates through `filteredResults` and removes only visible items from `selectedItems`
|
- After: Iterates through `filteredResults` and removes only visible items from `selectedItems`
|
||||||
- Preserves selections for items not currently visible (e.g., other users' data)
|
- Preserves selections for items not currently visible (e.g., other users' data)
|
||||||
@@ -115,6 +119,7 @@ const handleConfirmDeletion = async () => {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**What Changed**:
|
**What Changed**:
|
||||||
|
|
||||||
- Before: `for (const result of validationResults)` - processes all 1000 items
|
- Before: `for (const result of validationResults)` - processes all 1000 items
|
||||||
- After: `for (const result of resultsToProcess)` where:
|
- After: `for (const result of resultsToProcess)` where:
|
||||||
- `Admin` → processes `validationResults` (all items)
|
- `Admin` → processes `validationResults` (all items)
|
||||||
@@ -127,20 +132,24 @@ const handleConfirmDeletion = async () => {
|
|||||||
### Scenario 1: User Unchecks Own Data Only
|
### Scenario 1: User Unchecks Own Data Only
|
||||||
|
|
||||||
**Setup**:
|
**Setup**:
|
||||||
|
|
||||||
- User A logs in (non-Admin)
|
- User A logs in (non-Admin)
|
||||||
- 100 materials visible (assigned to User A)
|
- 100 materials visible (assigned to User A)
|
||||||
- 900 materials invisible (assigned to other users)
|
- 900 materials invisible (assigned to other users)
|
||||||
- All 1000 materials are initially checked
|
- All 1000 materials are initially checked
|
||||||
|
|
||||||
**Actions**:
|
**Actions**:
|
||||||
|
|
||||||
1. User A clicks "取消"
|
1. User A clicks "取消"
|
||||||
2. Table shows all checkboxes unchecked
|
2. Table shows all checkboxes unchecked
|
||||||
|
|
||||||
**Expected**:
|
**Expected**:
|
||||||
|
|
||||||
- ✅ User A's 100 materials are unchecked
|
- ✅ User A's 100 materials are unchecked
|
||||||
- ✅ Other users' 900 materials **remain checked** (not affected)
|
- ✅ Other users' 900 materials **remain checked** (not affected)
|
||||||
|
|
||||||
**Verification**:
|
**Verification**:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Before fix: selectedItems.size === 0
|
// Before fix: selectedItems.size === 0
|
||||||
// After fix: selectedItems.size === 900 (other users' items still checked)
|
// After fix: selectedItems.size === 900 (other users' items still checked)
|
||||||
@@ -149,16 +158,19 @@ const handleConfirmDeletion = async () => {
|
|||||||
### Scenario 2: User Confirms Deletion
|
### Scenario 2: User Confirms Deletion
|
||||||
|
|
||||||
**Setup**:
|
**Setup**:
|
||||||
|
|
||||||
- User A logs in (non-Admin)
|
- User A logs in (non-Admin)
|
||||||
- User A unchecks 50 of their 100 materials
|
- User A unchecks 50 of their 100 materials
|
||||||
- 50 items checked (User A's)
|
- 50 items checked (User A's)
|
||||||
- 900 items checked (other users')
|
- 900 items checked (other users')
|
||||||
|
|
||||||
**Actions**:
|
**Actions**:
|
||||||
|
|
||||||
1. User A clicks "确认删除"
|
1. User A clicks "确认删除"
|
||||||
2. Confirm dialog shows: "写入/更新 50 条记录"
|
2. Confirm dialog shows: "写入/更新 50 条记录"
|
||||||
|
|
||||||
**Expected**:
|
**Expected**:
|
||||||
|
|
||||||
- ✅ Only User A's 50 materials are upserted to database
|
- ✅ Only User A's 50 materials are upserted to database
|
||||||
- ✅ Other users' 900 materials are **NOT touched**
|
- ✅ Other users' 900 materials are **NOT touched**
|
||||||
- ✅ No materials are deleted (since other users' items aren't processed)
|
- ✅ No materials are deleted (since other users' items aren't processed)
|
||||||
@@ -166,15 +178,18 @@ const handleConfirmDeletion = async () => {
|
|||||||
### Scenario 3: Admin Behavior Unchanged
|
### Scenario 3: Admin Behavior Unchanged
|
||||||
|
|
||||||
**Setup**:
|
**Setup**:
|
||||||
|
|
||||||
- Admin logs in
|
- Admin logs in
|
||||||
- All 1000 materials visible
|
- All 1000 materials visible
|
||||||
- All filtered by selected managers
|
- All filtered by selected managers
|
||||||
|
|
||||||
**Actions**:
|
**Actions**:
|
||||||
|
|
||||||
1. Admin clicks "取消" → all visible items unchecked
|
1. Admin clicks "取消" → all visible items unchecked
|
||||||
2. Admin clicks "确认删除" → processes all filtered items
|
2. Admin clicks "确认删除" → processes all filtered items
|
||||||
|
|
||||||
**Expected**:
|
**Expected**:
|
||||||
|
|
||||||
- ✅ Admin behavior unchanged (can manage all data)
|
- ✅ Admin behavior unchanged (can manage all data)
|
||||||
- ✅ Admin can still filter by managers and process filtered results
|
- ✅ Admin can still filter by managers and process filtered results
|
||||||
|
|
||||||
@@ -183,6 +198,7 @@ const handleConfirmDeletion = async () => {
|
|||||||
## Security & Scope Implications
|
## Security & Scope Implications
|
||||||
|
|
||||||
### Before Fix (Vulnerability)
|
### Before Fix (Vulnerability)
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
UserA[User A] --> Sees[Sees 100 items]
|
UserA[User A] --> Sees[Sees 100 items]
|
||||||
@@ -193,6 +209,7 @@ flowchart LR
|
|||||||
```
|
```
|
||||||
|
|
||||||
### After Fix (Secure)
|
### After Fix (Secure)
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
UserA[User A] --> Sees[Sees 100 items]
|
UserA[User A] --> Sees[Sees 100 items]
|
||||||
@@ -210,7 +227,7 @@ flowchart LR
|
|||||||
### File: `src/renderer/src/pages/CleanerPage.tsx`
|
### File: `src/renderer/src/pages/CleanerPage.tsx`
|
||||||
|
|
||||||
| Line | Change | Description |
|
| Line | Change | Description |
|
||||||
|------|--------|-------------|
|
| ------- | -------------------------------- | ----------------------------------------- |
|
||||||
| 419-432 | Modified "取消" button | Only uncheck visible filteredResults |
|
| 419-432 | Modified "取消" button | Only uncheck visible filteredResults |
|
||||||
| 158-222 | Modified `handleConfirmDeletion` | Use `resultsToProcess` based on `isAdmin` |
|
| 158-222 | Modified `handleConfirmDeletion` | Use `resultsToProcess` based on `isAdmin` |
|
||||||
|
|
||||||
@@ -227,6 +244,7 @@ flowchart LR
|
|||||||
## Verification Steps
|
## Verification Steps
|
||||||
|
|
||||||
1. **Test as User A**:
|
1. **Test as User A**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Login as user1
|
# Login as user1
|
||||||
npm run dev
|
npm run dev
|
||||||
@@ -237,6 +255,7 @@ flowchart LR
|
|||||||
```
|
```
|
||||||
|
|
||||||
2. **Test as User B**:
|
2. **Test as User B**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Login as user2
|
# Login as user2
|
||||||
# Verify user1's changes didn't affect user2's data
|
# Verify user1's changes didn't affect user2's data
|
||||||
|
|||||||
@@ -1246,7 +1246,7 @@ flowchart TB
|
|||||||
## 文件索引
|
## 文件索引
|
||||||
|
|
||||||
| 文件路径 | 说明 | 关键行号 |
|
| 文件路径 | 说明 | 关键行号 |
|
||||||
| ----------------------------------------------------------- | ------------- | ------------------------------------------------------------------------------------------------ |
|
| ----------------------------------------------------------- | ------------- | ---------------------------------------------------------------------------------------------- |
|
||||||
| `src/renderer/src/pages/CleanerPage.tsx` | 前端清理页面 | 117-155 (handleValidation)<br>166-226 (handleConfirmDeletion) |
|
| `src/renderer/src/pages/CleanerPage.tsx` | 前端清理页面 | 117-155 (handleValidation)<br>166-226 (handleConfirmDeletion) |
|
||||||
| `src/main/ipc/validation-handler.ts` | IPC处理器 | 212-400 (validation:validate)<br>407-420 (materials:upsertBatch)<br>425-447 (materials:delete) |
|
| `src/main/ipc/validation-handler.ts` | IPC处理器 | 212-400 (validation:validate)<br>407-420 (materials:upsertBatch)<br>425-447 (materials:delete) |
|
||||||
| `src/main/ipc/validation-handler.ts` | 用户信息获取 | 218-237 (获取当前用户 isAdmin username) |
|
| `src/main/ipc/validation-handler.ts` | 用户信息获取 | 218-237 (获取当前用户 isAdmin username) |
|
||||||
|
|||||||
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` | 详细文档 |
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,7 @@
|
|||||||
在 `config-manager.ts:437-483` 中,`saveAllSettings()` 方法无条件覆盖所有配置类别。当 UI 只发送部分字段时,未包含的字段会被设置为 `undefined` 或默认值,导致原有配置丢失。
|
在 `config-manager.ts:437-483` 中,`saveAllSettings()` 方法无条件覆盖所有配置类别。当 UI 只发送部分字段时,未包含的字段会被设置为 `undefined` 或默认值,导致原有配置丢失。
|
||||||
|
|
||||||
**数据流问题:**
|
**数据流问题:**
|
||||||
|
|
||||||
```
|
```
|
||||||
SettingsPage (只修改 ERP URL)
|
SettingsPage (只修改 ERP URL)
|
||||||
↓ 发送完整的 settings 对象
|
↓ 发送完整的 settings 对象
|
||||||
@@ -70,7 +71,7 @@ ConfigManager.saveAllSettings()
|
|||||||
### 改动点
|
### 改动点
|
||||||
|
|
||||||
| 文件 | 改动类型 | 说明 |
|
| 文件 | 改动类型 | 说明 |
|
||||||
|------|---------|------|
|
| -------------------------------------------- | -------- | ------------------------------------------------ |
|
||||||
| `src/main/services/config/config-manager.ts` | 核心 | 新增 `savePartialSettings()`、深度合并、备份机制 |
|
| `src/main/services/config/config-manager.ts` | 核心 | 新增 `savePartialSettings()`、深度合并、备份机制 |
|
||||||
| `src/main/ipc/settings-handler.ts` | 调整 | IPC 参数改为 `Partial<SettingsData>` |
|
| `src/main/ipc/settings-handler.ts` | 调整 | IPC 参数改为 `Partial<SettingsData>` |
|
||||||
| `src/renderer/src/pages/SettingsPage.tsx` | 优化 | 只发送 UI 支持的字段 |
|
| `src/renderer/src/pages/SettingsPage.tsx` | 优化 | 只发送 UI 支持的字段 |
|
||||||
@@ -120,7 +121,7 @@ function isObject(value: unknown): value is Record<string, unknown> {
|
|||||||
const UI_EDITABLE_FIELDS: string[] = [
|
const UI_EDITABLE_FIELDS: string[] = [
|
||||||
'erp.url',
|
'erp.url',
|
||||||
'erp.username',
|
'erp.username',
|
||||||
'erp.password',
|
'erp.password'
|
||||||
// 未来扩展:
|
// 未来扩展:
|
||||||
// 'database.dbType',
|
// 'database.dbType',
|
||||||
// 'paths.dataDir',
|
// 'paths.dataDir',
|
||||||
@@ -305,6 +306,7 @@ ipcMain.handle(
|
|||||||
```
|
```
|
||||||
|
|
||||||
**关键改动:**
|
**关键改动:**
|
||||||
|
|
||||||
- 参数类型从 `SettingsData` 改为 `Partial<SettingsData>`
|
- 参数类型从 `SettingsData` 改为 `Partial<SettingsData>`
|
||||||
- 调用 `savePartialSettings()` 替代 `saveAllSettings()`
|
- 调用 `savePartialSettings()` 替代 `saveAllSettings()`
|
||||||
|
|
||||||
@@ -411,7 +413,7 @@ const UI_EDITABLE_FIELDS: string[] = [
|
|||||||
'erp.password',
|
'erp.password',
|
||||||
'database.dbType', // 新增
|
'database.dbType', // 新增
|
||||||
'paths.dataDir', // 新增
|
'paths.dataDir', // 新增
|
||||||
'extraction.batchSize', // 新增
|
'extraction.batchSize' // 新增
|
||||||
// ...
|
// ...
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
@@ -425,10 +427,7 @@ const EDITABLE_FIELDS_BY_ROLE: Record<UserType, string[]> = {
|
|||||||
Guest: []
|
Guest: []
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateEditableFields(
|
function validateEditableFields(settings: Partial<SettingsData>, userType: UserType) {
|
||||||
settings: Partial<SettingsData>,
|
|
||||||
userType: UserType
|
|
||||||
) {
|
|
||||||
const allowed = EDITABLE_FIELDS_BY_ROLE[userType]
|
const allowed = EDITABLE_FIELDS_BY_ROLE[userType]
|
||||||
// 验证逻辑...
|
// 验证逻辑...
|
||||||
}
|
}
|
||||||
@@ -465,7 +464,7 @@ interface ConfigChange {
|
|||||||
## 风险与缓解
|
## 风险与缓解
|
||||||
|
|
||||||
| 风险 | 影响 | 缓解措施 |
|
| 风险 | 影响 | 缓解措施 |
|
||||||
|------|------|---------|
|
| ---------------- | ---------- | ----------------------------- |
|
||||||
| 深度合并逻辑错误 | 配置错误 | 完善单元测试覆盖 |
|
| 深度合并逻辑错误 | 配置错误 | 完善单元测试覆盖 |
|
||||||
| 备份文件权限问题 | 无法恢复 | 错误处理 + 日志 |
|
| 备份文件权限问题 | 无法恢复 | 错误处理 + 日志 |
|
||||||
| 白名单漏配置 | 功能受限 | 清晰的文档 + 代码注释 |
|
| 白名单漏配置 | 功能受限 | 清晰的文档 + 代码注释 |
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
## Task 1: Add Utility Functions to ConfigManager
|
## Task 1: Add Utility Functions to ConfigManager
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Modify: `src/main/services/config/config-manager.ts`
|
- Modify: `src/main/services/config/config-manager.ts`
|
||||||
|
|
||||||
**Step 1: Write failing test for deep merge**
|
**Step 1: Write failing test for deep merge**
|
||||||
@@ -31,11 +32,38 @@ describe('ConfigManager - deep merge utilities', () => {
|
|||||||
|
|
||||||
// Setup initial state
|
// Setup initial state
|
||||||
const initial: SettingsData = {
|
const initial: SettingsData = {
|
||||||
erp: { url: 'http://old.com', username: 'user1', password: 'pass1', headless: true, ignoreHttpsErrors: true, autoCloseBrowser: true },
|
erp: {
|
||||||
database: { dbType: 'mysql', server: '', mysqlHost: 'localhost', mysqlPort: 3306, database: 'db', username: 'user', password: '' },
|
url: 'http://old.com',
|
||||||
|
username: 'user1',
|
||||||
|
password: 'pass1',
|
||||||
|
headless: true,
|
||||||
|
ignoreHttpsErrors: true,
|
||||||
|
autoCloseBrowser: true
|
||||||
|
},
|
||||||
|
database: {
|
||||||
|
dbType: 'mysql',
|
||||||
|
server: '',
|
||||||
|
mysqlHost: 'localhost',
|
||||||
|
mysqlPort: 3306,
|
||||||
|
database: 'db',
|
||||||
|
username: 'user',
|
||||||
|
password: ''
|
||||||
|
},
|
||||||
paths: { dataDir: '/data', defaultOutput: 'out.xlsx', validationOutput: 'validation.xlsx' },
|
paths: { dataDir: '/data', defaultOutput: 'out.xlsx', validationOutput: 'validation.xlsx' },
|
||||||
extraction: { batchSize: 100, verbose: true, autoConvert: true, mergeBatches: true, enableDbPersistence: true },
|
extraction: {
|
||||||
validation: { dataSource: 'database_full', batchSize: 2000, matchMode: 'substring', enableCrud: false, defaultManager: '' },
|
batchSize: 100,
|
||||||
|
verbose: true,
|
||||||
|
autoConvert: true,
|
||||||
|
mergeBatches: true,
|
||||||
|
enableDbPersistence: true
|
||||||
|
},
|
||||||
|
validation: {
|
||||||
|
dataSource: 'database_full',
|
||||||
|
batchSize: 2000,
|
||||||
|
matchMode: 'substring',
|
||||||
|
enableCrud: false,
|
||||||
|
defaultManager: ''
|
||||||
|
},
|
||||||
ui: { fontFamily: 'Arial', fontSize: 12, productionIdInputWidth: 20 },
|
ui: { fontFamily: 'Arial', fontSize: 12, productionIdInputWidth: 20 },
|
||||||
execution: { dryRun: false }
|
execution: { dryRun: false }
|
||||||
}
|
}
|
||||||
@@ -113,7 +141,7 @@ function deepMerge<T>(source: T, target: Partial<T>): T {
|
|||||||
const UI_EDITABLE_FIELDS: string[] = [
|
const UI_EDITABLE_FIELDS: string[] = [
|
||||||
'erp.url',
|
'erp.url',
|
||||||
'erp.username',
|
'erp.username',
|
||||||
'erp.password',
|
'erp.password'
|
||||||
// Add more fields as UI expands
|
// Add more fields as UI expands
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -163,6 +191,7 @@ git commit -m "feat: add deep merge and validation utility functions to ConfigMa
|
|||||||
## Task 2: Add Backup and Restore Mechanism
|
## Task 2: Add Backup and Restore Mechanism
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Modify: `src/main/services/config/config-manager.ts`
|
- Modify: `src/main/services/config/config-manager.ts`
|
||||||
|
|
||||||
**Step 1: Write test for backup functionality**
|
**Step 1: Write test for backup functionality**
|
||||||
@@ -302,6 +331,7 @@ git commit -m "feat: add backup and restore mechanism to ConfigManager"
|
|||||||
## Task 3: Implement savePartialSettings Method
|
## Task 3: Implement savePartialSettings Method
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Modify: `src/main/services/config/config-manager.ts`
|
- Modify: `src/main/services/config/config-manager.ts`
|
||||||
|
|
||||||
**Step 1: Write comprehensive test for savePartialSettings**
|
**Step 1: Write comprehensive test for savePartialSettings**
|
||||||
@@ -316,11 +346,38 @@ describe('ConfigManager.savePartialSettings', () => {
|
|||||||
|
|
||||||
// Setup initial state with multiple categories
|
// Setup initial state with multiple categories
|
||||||
await manager.saveAllSettings({
|
await manager.saveAllSettings({
|
||||||
erp: { url: 'http://old.com', username: 'user1', password: 'pass1', headless: true, ignoreHttpsErrors: true, autoCloseBrowser: true },
|
erp: {
|
||||||
database: { dbType: 'mysql', server: '', mysqlHost: '192.168.1.1', mysqlPort: 3306, database: 'testdb', username: 'dbuser', password: '' },
|
url: 'http://old.com',
|
||||||
|
username: 'user1',
|
||||||
|
password: 'pass1',
|
||||||
|
headless: true,
|
||||||
|
ignoreHttpsErrors: true,
|
||||||
|
autoCloseBrowser: true
|
||||||
|
},
|
||||||
|
database: {
|
||||||
|
dbType: 'mysql',
|
||||||
|
server: '',
|
||||||
|
mysqlHost: '192.168.1.1',
|
||||||
|
mysqlPort: 3306,
|
||||||
|
database: 'testdb',
|
||||||
|
username: 'dbuser',
|
||||||
|
password: ''
|
||||||
|
},
|
||||||
paths: { dataDir: '/old/path', defaultOutput: 'out.xlsx', validationOutput: 'val.xlsx' },
|
paths: { dataDir: '/old/path', defaultOutput: 'out.xlsx', validationOutput: 'val.xlsx' },
|
||||||
extraction: { batchSize: 50, verbose: true, autoConvert: true, mergeBatches: true, enableDbPersistence: true },
|
extraction: {
|
||||||
validation: { dataSource: 'database_full', batchSize: 1000, matchMode: 'exact', enableCrud: false, defaultManager: '' },
|
batchSize: 50,
|
||||||
|
verbose: true,
|
||||||
|
autoConvert: true,
|
||||||
|
mergeBatches: true,
|
||||||
|
enableDbPersistence: true
|
||||||
|
},
|
||||||
|
validation: {
|
||||||
|
dataSource: 'database_full',
|
||||||
|
batchSize: 1000,
|
||||||
|
matchMode: 'exact',
|
||||||
|
enableCrud: false,
|
||||||
|
defaultManager: ''
|
||||||
|
},
|
||||||
ui: { fontFamily: 'Tahoma', fontSize: 14, productionIdInputWidth: 25 },
|
ui: { fontFamily: 'Tahoma', fontSize: 14, productionIdInputWidth: 25 },
|
||||||
execution: { dryRun: true }
|
execution: { dryRun: true }
|
||||||
})
|
})
|
||||||
@@ -367,11 +424,38 @@ describe('ConfigManager.savePartialSettings', () => {
|
|||||||
await manager.initialize()
|
await manager.initialize()
|
||||||
|
|
||||||
await manager.saveAllSettings({
|
await manager.saveAllSettings({
|
||||||
erp: { url: 'http://test.com', username: 'u', password: 'p', headless: false, ignoreHttpsErrors: false, autoCloseBrowser: false },
|
erp: {
|
||||||
database: { dbType: 'mysql', server: '', mysqlHost: 'localhost', mysqlPort: 3306, database: 'db', username: 'user', password: '' },
|
url: 'http://test.com',
|
||||||
|
username: 'u',
|
||||||
|
password: 'p',
|
||||||
|
headless: false,
|
||||||
|
ignoreHttpsErrors: false,
|
||||||
|
autoCloseBrowser: false
|
||||||
|
},
|
||||||
|
database: {
|
||||||
|
dbType: 'mysql',
|
||||||
|
server: '',
|
||||||
|
mysqlHost: 'localhost',
|
||||||
|
mysqlPort: 3306,
|
||||||
|
database: 'db',
|
||||||
|
username: 'user',
|
||||||
|
password: ''
|
||||||
|
},
|
||||||
paths: { dataDir: '/data', defaultOutput: 'out.xlsx', validationOutput: 'val.xlsx' },
|
paths: { dataDir: '/data', defaultOutput: 'out.xlsx', validationOutput: 'val.xlsx' },
|
||||||
extraction: { batchSize: 100, verbose: true, autoConvert: true, mergeBatches: true, enableDbPersistence: true },
|
extraction: {
|
||||||
validation: { dataSource: 'database_full', batchSize: 2000, matchMode: 'substring', enableCrud: false, defaultManager: '' },
|
batchSize: 100,
|
||||||
|
verbose: true,
|
||||||
|
autoConvert: true,
|
||||||
|
mergeBatches: true,
|
||||||
|
enableDbPersistence: true
|
||||||
|
},
|
||||||
|
validation: {
|
||||||
|
dataSource: 'database_full',
|
||||||
|
batchSize: 2000,
|
||||||
|
matchMode: 'substring',
|
||||||
|
enableCrud: false,
|
||||||
|
defaultManager: ''
|
||||||
|
},
|
||||||
ui: { fontFamily: 'Arial', fontSize: 12, productionIdInputWidth: 20 },
|
ui: { fontFamily: 'Arial', fontSize: 12, productionIdInputWidth: 20 },
|
||||||
execution: { dryRun: false }
|
execution: { dryRun: false }
|
||||||
})
|
})
|
||||||
@@ -516,6 +600,7 @@ git commit -m "feat: implement savePartialSettings with validation and rollback"
|
|||||||
## Task 4: Update IPC Handler to Use Partial Save
|
## Task 4: Update IPC Handler to Use Partial Save
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Modify: `src/main/ipc/settings-handler.ts`
|
- Modify: `src/main/ipc/settings-handler.ts`
|
||||||
|
|
||||||
**Step 1: Update settings:saveSettings handler**
|
**Step 1: Update settings:saveSettings handler**
|
||||||
@@ -580,6 +665,7 @@ git commit -m "feat: update settings handler to use savePartialSettings"
|
|||||||
## Task 5: Update Frontend to Send Only Necessary Fields
|
## Task 5: Update Frontend to Send Only Necessary Fields
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Modify: `src/renderer/src/pages/SettingsPage.tsx`
|
- Modify: `src/renderer/src/pages/SettingsPage.tsx`
|
||||||
|
|
||||||
**Step 1: Update handleSaveSettings to send partial settings**
|
**Step 1: Update handleSaveSettings to send partial settings**
|
||||||
@@ -631,6 +717,7 @@ git commit -m "feat: send only ERP fields from settings page (defensive programm
|
|||||||
## Task 6: Manual Testing and Verification
|
## Task 6: Manual Testing and Verification
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Manual test procedure
|
- Manual test procedure
|
||||||
|
|
||||||
**Step 1: Prepare test environment**
|
**Step 1: Prepare test environment**
|
||||||
@@ -701,6 +788,7 @@ cat .env
|
|||||||
```
|
```
|
||||||
|
|
||||||
Expected results:
|
Expected results:
|
||||||
|
|
||||||
- `ERP_URL` should be `http://modified-test.com` (CHANGED)
|
- `ERP_URL` should be `http://modified-test.com` (CHANGED)
|
||||||
- `DB_TYPE` should still be `mysql` (PRESERVED)
|
- `DB_TYPE` should still be `mysql` (PRESERVED)
|
||||||
- `VALIDATION_MATCH_MODE` should still be `substring` (PRESERVED)
|
- `VALIDATION_MATCH_MODE` should still be `substring` (PRESERVED)
|
||||||
@@ -790,6 +878,7 @@ git commit -m "test: add manual test report for settings partial save"
|
|||||||
## Task 7: Update Documentation
|
## Task 7: Update Documentation
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Create: `docs/settings-partial-save.md`
|
- Create: `docs/settings-partial-save.md`
|
||||||
- Update: `README.md` (if applicable)
|
- Update: `README.md` (if applicable)
|
||||||
|
|
||||||
@@ -797,7 +886,7 @@ git commit -m "test: add manual test report for settings partial save"
|
|||||||
|
|
||||||
Create `docs/settings-partial-save.md`:
|
Create `docs/settings-partial-save.md`:
|
||||||
|
|
||||||
```markdown
|
````markdown
|
||||||
# Settings Partial Save Feature
|
# Settings Partial Save Feature
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
@@ -813,6 +902,7 @@ The settings system now implements partial save functionality to prevent uninten
|
|||||||
## Editable Fields
|
## Editable Fields
|
||||||
|
|
||||||
Currently editable via UI:
|
Currently editable via UI:
|
||||||
|
|
||||||
- `erp.url` - ERP system URL
|
- `erp.url` - ERP system URL
|
||||||
- `erp.username` - ERP login username
|
- `erp.username` - ERP login username
|
||||||
- `erp.password` - ERP login password
|
- `erp.password` - ERP login password
|
||||||
@@ -828,9 +918,10 @@ const UI_EDITABLE_FIELDS: string[] = [
|
|||||||
'erp.url',
|
'erp.url',
|
||||||
'erp.username',
|
'erp.username',
|
||||||
'erp.password',
|
'erp.password',
|
||||||
'database.dbType', // Add new field here
|
'database.dbType' // Add new field here
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
````
|
||||||
|
|
||||||
2. Add UI input in `src/renderer/src/pages/SettingsPage.tsx`
|
2. Add UI input in `src/renderer/src/pages/SettingsPage.tsx`
|
||||||
3. Update `handleSaveSettings` to include the new field
|
3. Update `handleSaveSettings` to include the new field
|
||||||
@@ -844,6 +935,7 @@ Saves only the provided fields, preserving all existing configuration.
|
|||||||
**Returns:** `{ success: boolean, error?: string }`
|
**Returns:** `{ success: boolean, error?: string }`
|
||||||
|
|
||||||
**Validation:**
|
**Validation:**
|
||||||
|
|
||||||
- Checks whitelist before applying changes
|
- Checks whitelist before applying changes
|
||||||
- Returns error for unauthorized fields
|
- Returns error for unauthorized fields
|
||||||
|
|
||||||
@@ -858,7 +950,8 @@ Saves only the provided fields, preserving all existing configuration.
|
|||||||
Location: `.env.backup` (in project root)
|
Location: `.env.backup` (in project root)
|
||||||
|
|
||||||
Created before every save operation. Used for rollback on failure.
|
Created before every save operation. Used for rollback on failure.
|
||||||
```
|
|
||||||
|
````
|
||||||
|
|
||||||
**Step 2: Update CLAUDE.md if needed**
|
**Step 2: Update CLAUDE.md if needed**
|
||||||
|
|
||||||
@@ -870,13 +963,14 @@ Add to "Development Commands" or "Architecture Overview" sections if there's rel
|
|||||||
cd D:/Node/ERPAuto-settings-fix
|
cd D:/Node/ERPAuto-settings-fix
|
||||||
git add docs/settings-partial-save.md
|
git add docs/settings-partial-save.md
|
||||||
git commit -m "docs: add settings partial save feature documentation"
|
git commit -m "docs: add settings partial save feature documentation"
|
||||||
```
|
````
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Task 8: Final Verification and Cleanup
|
## Task 8: Final Verification and Cleanup
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- All modified files
|
- All modified files
|
||||||
|
|
||||||
**Step 1: Run full test suite**
|
**Step 1: Run full test suite**
|
||||||
@@ -937,6 +1031,7 @@ This implementation plan fixes the settings save issue through:
|
|||||||
**Total estimated implementation time:** 2-3 hours
|
**Total estimated implementation time:** 2-3 hours
|
||||||
|
|
||||||
**Key files modified:**
|
**Key files modified:**
|
||||||
|
|
||||||
- `src/main/services/config/config-manager.ts` (core logic)
|
- `src/main/services/config/config-manager.ts` (core logic)
|
||||||
- `src/main/ipc/settings-handler.ts` (IPC layer)
|
- `src/main/ipc/settings-handler.ts` (IPC layer)
|
||||||
- `src/renderer/src/pages/SettingsPage.tsx` (frontend)
|
- `src/renderer/src/pages/SettingsPage.tsx` (frontend)
|
||||||
|
|||||||
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
|
||||||
274
docs/portable-auto-update-architecture.md
Normal file
274
docs/portable-auto-update-architecture.md
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
# ERPAuto 便携版自动更新说明
|
||||||
|
|
||||||
|
## 概览
|
||||||
|
|
||||||
|
当前实现的是一套面向 Windows 便携版的自定义更新系统,核心特点如下:
|
||||||
|
|
||||||
|
- 基于 S3 兼容对象存储分发更新包
|
||||||
|
- 按登录用户角色决定更新通道和行为
|
||||||
|
- `User` 只跟随 `Stable`
|
||||||
|
- `Admin` 同时可见 `Stable` 和 `Preview`
|
||||||
|
- 更新包可后台下载,但安装必须由用户触发
|
||||||
|
- 安装阶段使用独立的原生 `portable-updater.exe` 完成 exe 替换
|
||||||
|
|
||||||
|
## 核心组件
|
||||||
|
|
||||||
|
- 主进程更新服务
|
||||||
|
路径:[`src/main/services/update/update-service.ts`](/d:/FileLib/Projects/CodeMigration/ERPAuto/src/main/services/update/update-service.ts)
|
||||||
|
- 更新规则工具
|
||||||
|
路径:[`src/main/services/update/update-utils.ts`](/d:/FileLib/Projects/CodeMigration/ERPAuto/src/main/services/update/update-utils.ts)
|
||||||
|
- 更新 IPC
|
||||||
|
路径:[`src/main/ipc/update-handler.ts`](/d:/FileLib/Projects/CodeMigration/ERPAuto/src/main/ipc/update-handler.ts)
|
||||||
|
- 前端更新弹窗
|
||||||
|
路径:[`src/renderer/src/components/UpdateDialog.tsx`](/d:/FileLib/Projects/CodeMigration/ERPAuto/src/renderer/src/components/UpdateDialog.tsx)
|
||||||
|
- 前端更新入口
|
||||||
|
路径:[`src/renderer/src/App.tsx`](/d:/FileLib/Projects/CodeMigration/ERPAuto/src/renderer/src/App.tsx)
|
||||||
|
- 原生更新器
|
||||||
|
路径:[`build/PortableUpdater.cs`](/d:/FileLib/Projects/CodeMigration/ERPAuto/build/PortableUpdater.cs)
|
||||||
|
- 更新器编译脚本
|
||||||
|
路径:[`scripts/compile-updater.js`](/d:/FileLib/Projects/CodeMigration/ERPAuto/scripts/compile-updater.js)
|
||||||
|
- 发布准备脚本
|
||||||
|
路径:[`scripts/prepare-release.js`](/d:/FileLib/Projects/CodeMigration/ERPAuto/scripts/prepare-release.js)
|
||||||
|
- 发布上传脚本
|
||||||
|
路径:[`scripts/upload-release.js`](/d:/FileLib/Projects/CodeMigration/ERPAuto/scripts/upload-release.js)
|
||||||
|
|
||||||
|
## 角色策略
|
||||||
|
|
||||||
|
### `User`
|
||||||
|
|
||||||
|
- 只读取 `stable/index.json`
|
||||||
|
- 目标版本永远是最新 `Stable`
|
||||||
|
- 如果当前客户端是 `Preview`,即使本地版本号更高,也会被视为“需要更新回稳定版”
|
||||||
|
- 后台会自动下载推荐的 `Stable`
|
||||||
|
- 用户点击后执行安装
|
||||||
|
|
||||||
|
### `Admin`
|
||||||
|
|
||||||
|
- 同时读取 `stable/index.json` 和 `preview/index.json`
|
||||||
|
- 不自动下载
|
||||||
|
- 只展示可选版本和更新说明
|
||||||
|
- 由管理员手动选择版本并触发下载、安装
|
||||||
|
|
||||||
|
## 当前安装包身份
|
||||||
|
|
||||||
|
构建时会注入 `__APP_CHANNEL__`,用于标识当前客户端自身是 `stable` 还是 `preview`。
|
||||||
|
|
||||||
|
这个值的作用非常关键:
|
||||||
|
|
||||||
|
- 决定 `User` 是否需要从 `Preview` 洗回 `Stable`
|
||||||
|
- 决定 `Admin` 当前处于哪条版本线
|
||||||
|
- 决定更新弹窗中当前通道的展示
|
||||||
|
|
||||||
|
相关声明:
|
||||||
|
|
||||||
|
- [`src/shared/app-env.d.ts`](/d:/FileLib/Projects/CodeMigration/ERPAuto/src/shared/app-env.d.ts)
|
||||||
|
- [`src/renderer/src/env.d.ts`](/d:/FileLib/Projects/CodeMigration/ERPAuto/src/renderer/src/env.d.ts)
|
||||||
|
|
||||||
|
## 远端目录结构
|
||||||
|
|
||||||
|
更新目录按通道分开:
|
||||||
|
|
||||||
|
```text
|
||||||
|
updates/win-portable/stable/index.json
|
||||||
|
updates/win-portable/stable/artifacts/erpauto-<version>-stable-portable.exe
|
||||||
|
updates/win-portable/stable/changelogs/<version>.md
|
||||||
|
|
||||||
|
updates/win-portable/preview/index.json
|
||||||
|
updates/win-portable/preview/artifacts/erpauto-<version>-preview-portable.exe
|
||||||
|
updates/win-portable/preview/changelogs/<version>.md
|
||||||
|
```
|
||||||
|
|
||||||
|
`index.json` 的每个条目至少包含:
|
||||||
|
|
||||||
|
- `version`
|
||||||
|
- `channel`
|
||||||
|
- `artifactKey`
|
||||||
|
- `sha256`
|
||||||
|
- `size`
|
||||||
|
- `publishedAt`
|
||||||
|
- `changelogKey`
|
||||||
|
- `notesSummary`
|
||||||
|
|
||||||
|
## 总体架构图
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A[Electron Renderer] -->|IPC| B[Update Handler]
|
||||||
|
B --> C[Update Service]
|
||||||
|
C --> D[S3-Compatible Object Storage]
|
||||||
|
C --> E[Local Cache<br/>pending-update]
|
||||||
|
C --> F[portable-updater.exe]
|
||||||
|
F --> G[Replace Old EXE]
|
||||||
|
G --> H[Launch New EXE]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 登录后的更新时序
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant U as User/Admin
|
||||||
|
participant R as Renderer
|
||||||
|
participant A as Auth Handler
|
||||||
|
participant S as Update Service
|
||||||
|
participant O as Object Storage
|
||||||
|
|
||||||
|
U->>R: 登录 / 无感登录
|
||||||
|
R->>A: auth.login / auth.silentLogin
|
||||||
|
A->>S: setUserContext(userType)
|
||||||
|
S->>O: 读取 stable/index.json
|
||||||
|
alt Admin
|
||||||
|
S->>O: 读取 preview/index.json
|
||||||
|
end
|
||||||
|
S->>S: 计算推荐版本与状态
|
||||||
|
alt User 且需要更新
|
||||||
|
S->>O: 下载最新 Stable
|
||||||
|
S->>S: 校验 sha256
|
||||||
|
S-->>R: UPDATE_STATUS_CHANGED(downloaded)
|
||||||
|
else Admin
|
||||||
|
S-->>R: UPDATE_STATUS_CHANGED(available)
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
## `User` 更新决策图
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A[当前用户是 User] --> B[读取 stable 最新版本]
|
||||||
|
B --> C{当前通道是 preview?}
|
||||||
|
C -- 是 --> D[强制推荐回 Stable]
|
||||||
|
C -- 否 --> E{当前版本 != 最新 Stable?}
|
||||||
|
E -- 是 --> F[推荐最新 Stable]
|
||||||
|
E -- 否 --> G[不提示更新]
|
||||||
|
D --> H[后台自动下载]
|
||||||
|
F --> H
|
||||||
|
H --> I[校验 sha256]
|
||||||
|
I --> J[导航栏显示 立即更新]
|
||||||
|
```
|
||||||
|
|
||||||
|
## `Admin` 更新决策图
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A[当前用户是 Admin] --> B[读取 stable 目录]
|
||||||
|
A --> C[读取 preview 目录]
|
||||||
|
B --> D[合并目录]
|
||||||
|
C --> D
|
||||||
|
D --> E{是否存在更高版本?}
|
||||||
|
E -- 是 --> F[推荐更高版本]
|
||||||
|
E -- 否 --> G{是否存在跨通道可切换版本?}
|
||||||
|
G -- 是 --> H[推荐跨通道版本]
|
||||||
|
G -- 否 --> I[不展示更新]
|
||||||
|
F --> J[打开弹窗后手动下载]
|
||||||
|
H --> J
|
||||||
|
```
|
||||||
|
|
||||||
|
## 安装阶段时序
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant R as Renderer
|
||||||
|
participant S as Update Service
|
||||||
|
participant P as portable-updater.exe
|
||||||
|
participant X as Current Portable EXE
|
||||||
|
participant N as New Downloaded EXE
|
||||||
|
|
||||||
|
R->>S: installDownloaded()
|
||||||
|
S->>P: 启动 portable-updater.exe
|
||||||
|
S->>X: app.quit()
|
||||||
|
P->>X: 等待旧进程退出
|
||||||
|
P->>X: 等待文件解锁
|
||||||
|
P->>X: 备份为 .bak
|
||||||
|
P->>N: 移动到目标路径
|
||||||
|
P->>X: 启动新版本
|
||||||
|
P->>X: 删除 .bak
|
||||||
|
```
|
||||||
|
|
||||||
|
## 本地目录与日志
|
||||||
|
|
||||||
|
### 下载缓存
|
||||||
|
|
||||||
|
```text
|
||||||
|
%APPDATA%\erpauto\pending-update\
|
||||||
|
```
|
||||||
|
|
||||||
|
### 更新器运行文件与日志
|
||||||
|
|
||||||
|
```text
|
||||||
|
%APPDATA%\erpauto\updates\
|
||||||
|
portable-updater.exe
|
||||||
|
portable-update.log
|
||||||
|
portable-launch.log
|
||||||
|
```
|
||||||
|
|
||||||
|
## 发布流程
|
||||||
|
|
||||||
|
### 1. 构建
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:APP_CHANNEL="stable"
|
||||||
|
npm run build:win
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 准备发布目录
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
node scripts/prepare-release.js --channel stable --changelog docs/releases/1.3.2-rebuild.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 上传到对象存储
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run release:upload -- --channel stable --verify
|
||||||
|
```
|
||||||
|
|
||||||
|
## 发布流程图
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A[build:win] --> B[生成 dist/erpauto-portable.exe]
|
||||||
|
B --> C[prepare-release]
|
||||||
|
C --> D[复制 artifact]
|
||||||
|
C --> E[复制 changelog]
|
||||||
|
C --> F[生成 index.json]
|
||||||
|
F --> G[release-upload]
|
||||||
|
G --> H[上传到对象存储]
|
||||||
|
H --> I[verify 远端 index.json]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 版本排序规则
|
||||||
|
|
||||||
|
为了避免“后发布低版本覆盖高版本”的问题,当前排序规则是:
|
||||||
|
|
||||||
|
- 优先按版本号降序
|
||||||
|
- 同版本再按 `publishedAt` 降序
|
||||||
|
|
||||||
|
这条规则同时存在于:
|
||||||
|
|
||||||
|
- [`src/main/services/update/update-utils.ts`](/d:/FileLib/Projects/CodeMigration/ERPAuto/src/main/services/update/update-utils.ts)
|
||||||
|
- [`scripts/prepare-release.js`](/d:/FileLib/Projects/CodeMigration/ERPAuto/scripts/prepare-release.js)
|
||||||
|
|
||||||
|
## 失败保护
|
||||||
|
|
||||||
|
当前实现包含这些基本保护:
|
||||||
|
|
||||||
|
- 缺失 `preview/index.json` 时,按空列表处理,不中断整体更新检查
|
||||||
|
- 更新包下载完成后必须校验 `sha256`
|
||||||
|
- `portable-updater.exe` 会等待旧进程退出和目标文件解锁
|
||||||
|
- 替换前先备份旧 exe 为 `.bak`
|
||||||
|
- 替换失败时尝试回滚
|
||||||
|
|
||||||
|
## 当前已验证通过的能力
|
||||||
|
|
||||||
|
- `1.3.1 -> 1.3.2` 的 `Stable` 发布链路已打通
|
||||||
|
- 新分支实现能够成功构建 Windows 便携版
|
||||||
|
- 原生 `portable-updater.exe` 能成功编译并被打包带入资源目录
|
||||||
|
- 本地发布目录生成正常
|
||||||
|
- 上传脚本可将更新包和索引发布到对象存储
|
||||||
|
- 客户端真实升级流程已验证通过
|
||||||
|
|
||||||
|
## 后续可继续优化的点
|
||||||
|
|
||||||
|
- 将 `UpdateService` 进一步拆分,降低文件复杂度
|
||||||
|
- 将日志策略区分成“正式日志”和“诊断日志”
|
||||||
|
- 增加更多针对下载与安装阶段的单测
|
||||||
|
- 将完整构建发布链路整理为一键化脚本
|
||||||
11
docs/releases/1.3.1-rebuild.md
Normal file
11
docs/releases/1.3.1-rebuild.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# 1.3.1 Rebuild
|
||||||
|
|
||||||
|
## Highlights
|
||||||
|
|
||||||
|
- Rebuilt the portable auto-update flow on a clean branch.
|
||||||
|
- Added role-aware update catalog handling for `Stable` and `Preview`.
|
||||||
|
- Added native `portable-updater.exe` handoff for portable upgrades.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- This release is intended for rebuild validation on the new implementation branch.
|
||||||
11
docs/releases/1.3.2-rebuild.md
Normal file
11
docs/releases/1.3.2-rebuild.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# 1.3.2 Rebuild
|
||||||
|
|
||||||
|
## Highlights
|
||||||
|
|
||||||
|
- Published the clean-branch portable auto-update implementation.
|
||||||
|
- Added role-aware update checks, changelog loading, and update dialog UI.
|
||||||
|
- Added native `portable-updater.exe` build and packaging flow.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- This release is intended to validate `1.3.1 -> 1.3.2` upgrade flow on the rebuilt implementation.
|
||||||
44
docs/releases/1.4.0.md
Normal file
44
docs/releases/1.4.0.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# 1.4.0
|
||||||
|
|
||||||
|
## 亮点
|
||||||
|
|
||||||
|
- 新增 Windows 便携版自动更新能力,支持 `stable` / `preview` 双通道发布。
|
||||||
|
- 更新策略与登录用户角色联动:
|
||||||
|
- `User` 只接收稳定版更新
|
||||||
|
- `Admin` 可查看并切换稳定版与预览版
|
||||||
|
- 更新包下载完成后,可在应用内查看更新说明并执行自动替换升级。
|
||||||
|
|
||||||
|
## 自动更新
|
||||||
|
|
||||||
|
- 新增便携版更新服务,支持:
|
||||||
|
- 登录后自动检查更新
|
||||||
|
- 后台下载更新包
|
||||||
|
- 展示更新状态与更新日志
|
||||||
|
- 退出后自动替换旧版本并重启
|
||||||
|
- 更新器采用原生 `portable-updater.exe`,不再依赖 PowerShell 脚本。
|
||||||
|
- 支持预览版与稳定版分通道发布,并兼容普通用户从 `preview` 回退到 `stable` 的场景。
|
||||||
|
|
||||||
|
## 界面与交互
|
||||||
|
|
||||||
|
- 顶部导航新增更新入口。
|
||||||
|
- 新增更新对话框,可展示 changelog 并执行安装。
|
||||||
|
- 报告查看器增强了 Markdown 渲染体验,支持 GitHub 风格样式与代码高亮。
|
||||||
|
|
||||||
|
## 发布与维护
|
||||||
|
|
||||||
|
- 新增一键发布命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run release:publish -- --channel stable
|
||||||
|
npm run release:publish -- --channel preview
|
||||||
|
```
|
||||||
|
|
||||||
|
- 发布脚本会自动串联构建、整理发布物料、上传和远端索引校验。
|
||||||
|
- 上传逻辑已优化为默认增量上传,只上传当前版本的 artifact、changelog 和 `index.json`。
|
||||||
|
- 补充了构建发布文档和自动更新架构文档,方便后续维护。
|
||||||
|
|
||||||
|
## 文档整理
|
||||||
|
|
||||||
|
- 浏览器部署文档已迁移并整理到 `docs/browser/`。
|
||||||
|
- 新增构建与发布流程说明文档。
|
||||||
|
- 精简了 `CLAUDE.md`,让 AI 代理指导文档更聚焦、更易维护。
|
||||||
11
docs/releases/1.4.1.md
Normal file
11
docs/releases/1.4.1.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# 1.4.1
|
||||||
|
|
||||||
|
## 改进
|
||||||
|
|
||||||
|
- 报告查看器的报告选择器升级为可搜索下拉框。
|
||||||
|
- 报表较多时,可以通过输入关键字快速筛选目标报告,减少滚动查找成本。
|
||||||
|
|
||||||
|
## 体验优化
|
||||||
|
|
||||||
|
- 优化了报告选择交互,选择流程更适合长列表场景。
|
||||||
|
- 同步合并 `dev` 分支中已完成的报告查看器可用性改进。
|
||||||
@@ -13,6 +13,7 @@ The settings system now implements partial save functionality to prevent uninten
|
|||||||
## Editable Fields
|
## Editable Fields
|
||||||
|
|
||||||
Currently editable via UI:
|
Currently editable via UI:
|
||||||
|
|
||||||
- `erp.url` - ERP system URL
|
- `erp.url` - ERP system URL
|
||||||
- `erp.username` - ERP login username
|
- `erp.username` - ERP login username
|
||||||
- `erp.password` - ERP login password
|
- `erp.password` - ERP login password
|
||||||
@@ -28,7 +29,7 @@ const UI_EDITABLE_FIELDS: string[] = [
|
|||||||
'erp.url',
|
'erp.url',
|
||||||
'erp.username',
|
'erp.username',
|
||||||
'erp.password',
|
'erp.password',
|
||||||
'database.dbType', // Add new field here
|
'database.dbType' // Add new field here
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -44,6 +45,7 @@ Saves only the provided fields, preserving all existing configuration.
|
|||||||
**Returns:** `{ success: boolean, error?: string }`
|
**Returns:** `{ success: boolean, error?: string }`
|
||||||
|
|
||||||
**Validation:**
|
**Validation:**
|
||||||
|
|
||||||
- Checks whitelist before applying changes
|
- Checks whitelist before applying changes
|
||||||
- Returns error for unauthorized fields
|
- Returns error for unauthorized fields
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
# 系统设置保存按钮工作流程分析
|
# 系统设置保存按钮工作流程分析
|
||||||
|
|
||||||
# System Settings Save Button Workflow Analysis
|
# System Settings Save Button Workflow Analysis
|
||||||
|
|
||||||
## 文档概述 / Document Overview
|
## 文档概述 / Document Overview
|
||||||
@@ -171,6 +172,7 @@ graph LR
|
|||||||
#### SettingsPage.tsx (`src/renderer/src/pages/SettingsPage.tsx`)
|
#### SettingsPage.tsx (`src/renderer/src/pages/SettingsPage.tsx`)
|
||||||
|
|
||||||
**主要职责 / Main Responsibilities:**
|
**主要职责 / Main Responsibilities:**
|
||||||
|
|
||||||
- 用户界面渲染和交互
|
- 用户界面渲染和交互
|
||||||
- 本地状态管理(settings, isModified, message)
|
- 本地状态管理(settings, isModified, message)
|
||||||
- 调用 IPC 通信
|
- 调用 IPC 通信
|
||||||
@@ -197,7 +199,7 @@ const handleSaveSettings = async () => {
|
|||||||
**状态管理 / State Management:**
|
**状态管理 / State Management:**
|
||||||
|
|
||||||
| 状态变量 | 类型 | 用途 |
|
| 状态变量 | 类型 | 用途 |
|
||||||
|---------|------|------|
|
| ------------ | ---------------- | ----------------------------------------------------------- |
|
||||||
| `settings` | `Settings` | 当前配置数据,结构为 `{ erp: { url, username, password } }` |
|
| `settings` | `Settings` | 当前配置数据,结构为 `{ erp: { url, username, password } }` |
|
||||||
| `isModified` | `boolean` | 标记配置是否已修改,控制保存按钮启用状态 |
|
| `isModified` | `boolean` | 标记配置是否已修改,控制保存按钮启用状态 |
|
||||||
| `isLoading` | `boolean` | 加载状态,显示加载动画 |
|
| `isLoading` | `boolean` | 加载状态,显示加载动画 |
|
||||||
@@ -232,6 +234,7 @@ stateDiagram-v2
|
|||||||
#### preload/index.ts (`src/preload/index.ts`)
|
#### preload/index.ts (`src/preload/index.ts`)
|
||||||
|
|
||||||
**主要职责 / Main Responsibilities:**
|
**主要职责 / Main Responsibilities:**
|
||||||
|
|
||||||
- 安全桥梁,暴露受限 API 到渲染进程
|
- 安全桥梁,暴露受限 API 到渲染进程
|
||||||
- 类型安全的 IPC 通道定义
|
- 类型安全的 IPC 通道定义
|
||||||
|
|
||||||
@@ -273,6 +276,7 @@ graph TB
|
|||||||
#### settings-handler.ts (`src/main/ipc/settings-handler.ts`)
|
#### settings-handler.ts (`src/main/ipc/settings-handler.ts`)
|
||||||
|
|
||||||
**主要职责 / Main Responsibilities:**
|
**主要职责 / Main Responsibilities:**
|
||||||
|
|
||||||
- IPC 通道注册和处理
|
- IPC 通道注册和处理
|
||||||
- 权限验证(基于用户类型)
|
- 权限验证(基于用户类型)
|
||||||
- 业务逻辑协调
|
- 业务逻辑协调
|
||||||
@@ -335,7 +339,7 @@ function filterSettingsByUserType(settings: SettingsData, userType: UserType): S
|
|||||||
**权限控制矩阵 / Permission Control Matrix:**
|
**权限控制矩阵 / Permission Control Matrix:**
|
||||||
|
|
||||||
| 功能 / Feature | Admin | User | Guest |
|
| 功能 / Feature | Admin | User | Guest |
|
||||||
|---------------|-------|------|-------|
|
| -------------- | ----- | ------- | ----- |
|
||||||
| 查看所有设置 | ✅ | ⚠️ 部分 | ❌ |
|
| 查看所有设置 | ✅ | ⚠️ 部分 | ❌ |
|
||||||
| 保存设置 | ✅ | ✅ | ❌ |
|
| 保存设置 | ✅ | ✅ | ❌ |
|
||||||
| 恢复默认值 | ✅ | ❌ | ❌ |
|
| 恢复默认值 | ✅ | ❌ | ❌ |
|
||||||
@@ -347,6 +351,7 @@ function filterSettingsByUserType(settings: SettingsData, userType: UserType): S
|
|||||||
#### config-manager.ts (`src/main/services/config/config-manager.ts`)
|
#### config-manager.ts (`src/main/services/config/config-manager.ts`)
|
||||||
|
|
||||||
**主要职责 / Main Responsibilities:**
|
**主要职责 / Main Responsibilities:**
|
||||||
|
|
||||||
- .env 文件读写
|
- .env 文件读写
|
||||||
- 配置缓存管理
|
- 配置缓存管理
|
||||||
- 默认值管理
|
- 默认值管理
|
||||||
@@ -519,6 +524,7 @@ graph TB
|
|||||||
### IPC 通信数据格式 / IPC Communication Data Format
|
### IPC 通信数据格式 / IPC Communication Data Format
|
||||||
|
|
||||||
**请求格式 / Request Format:**
|
**请求格式 / Request Format:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"erp": {
|
"erp": {
|
||||||
@@ -539,6 +545,7 @@ graph TB
|
|||||||
```
|
```
|
||||||
|
|
||||||
**响应格式 / Response Format:**
|
**响应格式 / Response Format:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
// 成功 / Success
|
// 成功 / Success
|
||||||
{
|
{
|
||||||
@@ -595,7 +602,7 @@ graph TB
|
|||||||
### 错误场景分析 / Error Scenario Analysis
|
### 错误场景分析 / Error Scenario Analysis
|
||||||
|
|
||||||
| 错误场景 / Error Scenario | 触发位置 / Location | 处理方式 / Handling | 用户反馈 / User Feedback |
|
| 错误场景 / Error Scenario | 触发位置 / Location | 处理方式 / Handling | 用户反馈 / User Feedback |
|
||||||
|--------------------------|-------------------|-------------------|----------------------|
|
| ------------------------- | ------------------- | --------------------- | ------------------------ |
|
||||||
| IPC 通信失败 | Renderer | try-catch | 显示"保存设置时发生错误" |
|
| IPC 通信失败 | Renderer | try-catch | 显示"保存设置时发生错误" |
|
||||||
| 权限不足 | Main Process | 检查 UserType | 返回权限错误信息 |
|
| 权限不足 | Main Process | 检查 UserType | 返回权限错误信息 |
|
||||||
| 文件写入失败 | ConfigManager | fs.writeFileSync 捕获 | 返回"保存设置失败" |
|
| 文件写入失败 | ConfigManager | fs.writeFileSync 捕获 | 返回"保存设置失败" |
|
||||||
@@ -613,6 +620,7 @@ log.error('Error saving settings', { error: message })
|
|||||||
```
|
```
|
||||||
|
|
||||||
**日志级别使用 / Log Level Usage:**
|
**日志级别使用 / Log Level Usage:**
|
||||||
|
|
||||||
- `info`: 正常操作流程
|
- `info`: 正常操作流程
|
||||||
- `warn`: 潜在问题(如保存失败但未崩溃)
|
- `warn`: 潜在问题(如保存失败但未崩溃)
|
||||||
- `error`: 严重错误(如异常抛出)
|
- `error`: 严重错误(如异常抛出)
|
||||||
@@ -681,7 +689,7 @@ sequenceDiagram
|
|||||||
### 文件位置索引 / File Location Index
|
### 文件位置索引 / File Location Index
|
||||||
|
|
||||||
| 组件 / Component | 文件路径 / File Path | 关键行数 / Key Lines |
|
| 组件 / Component | 文件路径 / File Path | 关键行数 / Key Lines |
|
||||||
|-----------------|---------------------|-------------------|
|
| ---------------- | -------------------------------------------- | --------------------- |
|
||||||
| UI 组件 | `src/renderer/src/pages/SettingsPage.tsx` | 61-73 (保存处理) |
|
| UI 组件 | `src/renderer/src/pages/SettingsPage.tsx` | 61-73 (保存处理) |
|
||||||
| 预加载脚本 | `src/preload/index.ts` | 89-97 (API 定义) |
|
| 预加载脚本 | `src/preload/index.ts` | 89-97 (API 定义) |
|
||||||
| IPC 处理器 | `src/main/ipc/settings-handler.ts` | 83-102 (保存处理) |
|
| IPC 处理器 | `src/main/ipc/settings-handler.ts` | 83-102 (保存处理) |
|
||||||
@@ -748,6 +756,7 @@ const updateSettings = (category: string, key: string, value: any) => {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**设计要点 / Design Points:**
|
**设计要点 / Design Points:**
|
||||||
|
|
||||||
- 不可变更新模式(Immutable Update Pattern)
|
- 不可变更新模式(Immutable Update Pattern)
|
||||||
- 使用展开运算符保持对象引用
|
- 使用展开运算符保持对象引用
|
||||||
- 自动启用保存按钮
|
- 自动启用保存按钮
|
||||||
@@ -768,6 +777,7 @@ public async saveAllSettings(settings: SettingsData): Promise<boolean> {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**设计要点 / Design Points:**
|
**设计要点 / Design Points:**
|
||||||
|
|
||||||
- 先更新内存,后写入磁盘
|
- 先更新内存,后写入磁盘
|
||||||
- 失败时缓存保持不变
|
- 失败时缓存保持不变
|
||||||
- 返回布尔值表示成功/失败
|
- 返回布尔值表示成功/失败
|
||||||
@@ -792,6 +802,7 @@ public async save(): Promise<boolean> {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**设计要点 / Design Points:**
|
**设计要点 / Design Points:**
|
||||||
|
|
||||||
- 添加注释分隔符提高可读性
|
- 添加注释分隔符提高可读性
|
||||||
- 使用默认值作为后备
|
- 使用默认值作为后备
|
||||||
- 同步写入确保一致性
|
- 同步写入确保一致性
|
||||||
@@ -820,6 +831,7 @@ public async save(): Promise<boolean> {
|
|||||||
### 长期改进 / Long-term Improvements
|
### 长期改进 / Long-term Improvements
|
||||||
|
|
||||||
1. **安全性增强 / Security Enhancement**
|
1. **安全性增强 / Security Enhancement**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// 建议实现密码加密
|
// 建议实现密码加密
|
||||||
interface SecureSettingsData extends SettingsData {
|
interface SecureSettingsData extends SettingsData {
|
||||||
@@ -851,7 +863,9 @@ public async save(): Promise<boolean> {
|
|||||||
describe('ConfigManager', () => {
|
describe('ConfigManager', () => {
|
||||||
it('should save settings successfully', async () => {
|
it('should save settings successfully', async () => {
|
||||||
const manager = ConfigManager.getInstance()
|
const manager = ConfigManager.getInstance()
|
||||||
const settings: SettingsData = { /* mock data */ }
|
const settings: SettingsData = {
|
||||||
|
/* mock data */
|
||||||
|
}
|
||||||
const result = await manager.saveAllSettings(settings)
|
const result = await manager.saveAllSettings(settings)
|
||||||
expect(result).toBe(true)
|
expect(result).toBe(true)
|
||||||
})
|
})
|
||||||
@@ -884,7 +898,7 @@ describe('Settings Save Flow', () => {
|
|||||||
### 完整配置字段列表 / Complete Configuration Field List
|
### 完整配置字段列表 / Complete Configuration Field List
|
||||||
|
|
||||||
| 类别 / Category | 字段数 / Field Count | 字段列表 / Field List |
|
| 类别 / Category | 字段数 / Field Count | 字段列表 / Field List |
|
||||||
|---------------|---------------------|-------------------|
|
| ---------------- | -------------------- | ---------------------------------------------------------------------- |
|
||||||
| ERP | 6 | url, username, password, headless, ignoreHttpsErrors, autoCloseBrowser |
|
| ERP | 6 | url, username, password, headless, ignoreHttpsErrors, autoCloseBrowser |
|
||||||
| Database | 7 | dbType, server, mysqlHost, mysqlPort, database, username, password |
|
| Database | 7 | dbType, server, mysqlHost, mysqlPort, database, username, password |
|
||||||
| Paths | 3 | dataDir, defaultOutput, validationOutput |
|
| Paths | 3 | dataDir, defaultOutput, validationOutput |
|
||||||
@@ -903,7 +917,7 @@ describe('Settings Save Flow', () => {
|
|||||||
### 版本历史 / Version History
|
### 版本历史 / Version History
|
||||||
|
|
||||||
| 版本 / Version | 日期 / Date | 变更 / Changes |
|
| 版本 / Version | 日期 / Date | 变更 / Changes |
|
||||||
|---------------|------------|--------------|
|
| -------------- | ----------- | -------------------------- |
|
||||||
| 1.0 | 2025-03-03 | 初始版本 / Initial version |
|
| 1.0 | 2025-03-03 | 初始版本 / Initial version |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ log.info('Starting validation', { mode: request.mode, user: username, isAdmin })
|
|||||||
```
|
```
|
||||||
|
|
||||||
**说明**:
|
**说明**:
|
||||||
|
|
||||||
- 在 `validation:validate` handler 开始时获取当前登录用户信息
|
- 在 `validation:validate` handler 开始时获取当前登录用户信息
|
||||||
- 提取 `isAdmin` 和 `username` 用于后续匹配逻辑
|
- 提取 `isAdmin` 和 `username` 用于后续匹配逻辑
|
||||||
- 如果用户未登录,返回错误响应
|
- 如果用户未登录,返回错误响应
|
||||||
@@ -68,6 +69,7 @@ if (!isAdmin && username) {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**匹配逻辑**:
|
**匹配逻辑**:
|
||||||
|
|
||||||
1. **适用范围**: 仅对 `isAdmin === false` 的 User 用户生效
|
1. **适用范围**: 仅对 `isAdmin === false` 的 User 用户生效
|
||||||
2. **筛选关键词**: 从 `typeKeywords` 中筛选 `managerName === username` 的记录
|
2. **筛选关键词**: 从 `typeKeywords` 中筛选 `managerName === username` 的记录
|
||||||
3. **匹配规则**: 使用 `materialName.includes(userKeyword.materialName)` 包含关系匹配
|
3. **匹配规则**: 使用 `materialName.includes(userKeyword.materialName)` 包含关系匹配
|
||||||
@@ -109,11 +111,13 @@ flowchart TB
|
|||||||
### 场景1: User 用户匹配到自己的 typeKeyword
|
### 场景1: User 用户匹配到自己的 typeKeyword
|
||||||
|
|
||||||
**输入**:
|
**输入**:
|
||||||
|
|
||||||
- 当前用户: `user1`
|
- 当前用户: `user1`
|
||||||
- 物料名称: `螺丝 M6`
|
- 物料名称: `螺丝 M6`
|
||||||
- MaterialsTypeToBeDeleted: `{ materialName: "螺丝", managerName: "user1" }`
|
- MaterialsTypeToBeDeleted: `{ materialName: "螺丝", managerName: "user1" }`
|
||||||
|
|
||||||
**预期输出**:
|
**预期输出**:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"materialName": "螺丝 M6",
|
"materialName": "螺丝 M6",
|
||||||
@@ -126,6 +130,7 @@ flowchart TB
|
|||||||
### 场景2: User 用户覆盖其他用户的匹配
|
### 场景2: User 用户覆盖其他用户的匹配
|
||||||
|
|
||||||
**输入**:
|
**输入**:
|
||||||
|
|
||||||
- 当前用户: `user1`
|
- 当前用户: `user1`
|
||||||
- 物料名称: `螺丝 M6`
|
- 物料名称: `螺丝 M6`
|
||||||
- MaterialsTypeToBeDeleted:
|
- MaterialsTypeToBeDeleted:
|
||||||
@@ -138,12 +143,14 @@ flowchart TB
|
|||||||
### 场景3: User 用户无匹配关键词
|
### 场景3: User 用户无匹配关键词
|
||||||
|
|
||||||
**输入**:
|
**输入**:
|
||||||
|
|
||||||
- 当前用户: `user1`
|
- 当前用户: `user1`
|
||||||
- 物料名称: `螺丝 M6`
|
- 物料名称: `螺丝 M6`
|
||||||
- MaterialsTypeToBeDeleted:
|
- MaterialsTypeToBeDeleted:
|
||||||
- `{ materialName: "螺丝", managerName: "user2" }`
|
- `{ materialName: "螺丝", managerName: "user2" }`
|
||||||
|
|
||||||
**预期输出**:
|
**预期输出**:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"materialName": "螺丝 M6",
|
"materialName": "螺丝 M6",
|
||||||
@@ -152,11 +159,13 @@ flowchart TB
|
|||||||
"isMarkedForDeletion": false
|
"isMarkedForDeletion": false
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**说明**: 保持优先级2的匹配结果
|
**说明**: 保持优先级2的匹配结果
|
||||||
|
|
||||||
### 场景4: Admin 用户不执行覆盖
|
### 场景4: Admin 用户不执行覆盖
|
||||||
|
|
||||||
**输入**:
|
**输入**:
|
||||||
|
|
||||||
- 当前用户: `admin` (isAdmin=true)
|
- 当前用户: `admin` (isAdmin=true)
|
||||||
- 物料名称: `螺丝 M6`
|
- 物料名称: `螺丝 M6`
|
||||||
- MaterialsTypeToBeDeleted:
|
- MaterialsTypeToBeDeleted:
|
||||||
@@ -164,6 +173,7 @@ flowchart TB
|
|||||||
- `{ materialName: "螺丝", managerName: "admin" }`
|
- `{ materialName: "螺丝", managerName: "admin" }`
|
||||||
|
|
||||||
**预期输出**:
|
**预期输出**:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"materialName": "螺丝 M6",
|
"materialName": "螺丝 M6",
|
||||||
@@ -172,16 +182,19 @@ flowchart TB
|
|||||||
"isMarkedForDeletion": false
|
"isMarkedForDeletion": false
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**说明**: Admin 不执行优先级3,保持原有匹配行为
|
**说明**: Admin 不执行优先级3,保持原有匹配行为
|
||||||
|
|
||||||
### 场景5: 优先级1匹配不受影响
|
### 场景5: 优先级1匹配不受影响
|
||||||
|
|
||||||
**输入**:
|
**输入**:
|
||||||
|
|
||||||
- 当前用户: `user1`
|
- 当前用户: `user1`
|
||||||
- 物料代码: `MAT001`
|
- 物料代码: `MAT001`
|
||||||
- MaterialsToBeDeleted: `{ materialCode: "MAT001", managerName: "user2" }`
|
- MaterialsToBeDeleted: `{ materialCode: "MAT001", managerName: "user2" }`
|
||||||
|
|
||||||
**预期输出**:
|
**预期输出**:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"materialCode": "MAT001",
|
"materialCode": "MAT001",
|
||||||
@@ -190,6 +203,7 @@ flowchart TB
|
|||||||
"matchedTypeKeyword": undefined
|
"matchedTypeKeyword": undefined
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**说明**: 优先级1的精确匹配不受覆盖影响
|
**说明**: 优先级1的精确匹配不受覆盖影响
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -199,7 +213,7 @@ flowchart TB
|
|||||||
### MaterialsTypeToBeDeleted 表数据
|
### MaterialsTypeToBeDeleted 表数据
|
||||||
|
|
||||||
| MaterialName | ManagerName | 说明 |
|
| MaterialName | ManagerName | 说明 |
|
||||||
|--------------|-------------|------|
|
| ------------ | ----------- | ------------------------------ |
|
||||||
| 螺丝 | user1 | user1 负责所有包含"螺丝"的物料 |
|
| 螺丝 | user1 | user1 负责所有包含"螺丝"的物料 |
|
||||||
| 螺母 | user2 | user2 负责所有包含"螺母"的物料 |
|
| 螺母 | user2 | user2 负责所有包含"螺母"的物料 |
|
||||||
| 垫圈 | user1 | user1 也负责"垫圈"类物料 |
|
| 垫圈 | user1 | user1 也负责"垫圈"类物料 |
|
||||||
@@ -208,7 +222,7 @@ flowchart TB
|
|||||||
### 匹配结果示例
|
### 匹配结果示例
|
||||||
|
|
||||||
| 物料名称 | 当前用户 | 原匹配 (优先级2) | 覆盖后 (优先级3) |
|
| 物料名称 | 当前用户 | 原匹配 (优先级2) | 覆盖后 (优先级3) |
|
||||||
|------------|---------|----------------|----------------|
|
| -------- | -------- | ---------------- | ----------------- |
|
||||||
| 螺丝 M6 | user1 | user2 | **user1** ✅ |
|
| 螺丝 M6 | user1 | user2 | **user1** ✅ |
|
||||||
| 螺母 M8 | user1 | user2 | user2 (无匹配) |
|
| 螺母 M8 | user1 | user2 | user2 (无匹配) |
|
||||||
| 垫圈 φ10 | user1 | user2 | **user1** ✅ |
|
| 垫圈 φ10 | user1 | user2 | **user1** ✅ |
|
||||||
@@ -233,6 +247,7 @@ const filteredResults = React.useMemo(() => {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**协同效果**:
|
**协同效果**:
|
||||||
|
|
||||||
1. 后端匹配算法确保 User 用户的物料优先分配给自己
|
1. 后端匹配算法确保 User 用户的物料优先分配给自己
|
||||||
2. 前端过滤器只显示属于当前用户或未分配的物料
|
2. 前端过滤器只显示属于当前用户或未分配的物料
|
||||||
3. Admin 用户可以看到所有物料并切换查看不同负责人
|
3. Admin 用户可以看到所有物料并切换查看不同负责人
|
||||||
|
|||||||
@@ -2,17 +2,37 @@ appId: com.electron.app
|
|||||||
productName: erpauto
|
productName: erpauto
|
||||||
directories:
|
directories:
|
||||||
buildResources: build
|
buildResources: build
|
||||||
|
extraResources:
|
||||||
|
- from: build/bin/portable-updater.exe
|
||||||
|
to: portable-updater.exe
|
||||||
files:
|
files:
|
||||||
- '!**/.vscode/*'
|
- '!**/.vscode/*'
|
||||||
- '!src/*'
|
- '!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}'
|
- '!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}'
|
||||||
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
|
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
|
||||||
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
|
- '!{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:
|
asarUnpack:
|
||||||
- resources/**
|
- resources/**
|
||||||
|
# Unpack playwright for native modules
|
||||||
|
- '**/node_modules/playwright/**'
|
||||||
|
- '**/node_modules/playwright-core/**'
|
||||||
win:
|
win:
|
||||||
executableName: erpauto
|
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:
|
nsis:
|
||||||
artifactName: ${name}-${version}-setup.${ext}
|
artifactName: ${name}-${version}-setup.${ext}
|
||||||
shortcutName: ${productName}
|
shortcutName: ${productName}
|
||||||
|
|||||||
@@ -2,16 +2,58 @@ import { resolve } from 'path'
|
|||||||
import { defineConfig } from 'electron-vite'
|
import { defineConfig } from 'electron-vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
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()
|
||||||
|
const appChannel = process.env.APP_CHANNEL === 'preview' ? 'preview' : 'stable'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
main: {},
|
main: {
|
||||||
preload: {},
|
define: {
|
||||||
|
__APP_CHANNEL__: JSON.stringify(appChannel)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
preload: {
|
||||||
|
define: {
|
||||||
|
__APP_CHANNEL__: JSON.stringify(appChannel)
|
||||||
|
}
|
||||||
|
},
|
||||||
renderer: {
|
renderer: {
|
||||||
|
define: {
|
||||||
|
__APP_VERSION__: JSON.stringify(version),
|
||||||
|
__GIT_HASH__: JSON.stringify(gitHash),
|
||||||
|
__APP_CHANNEL__: JSON.stringify(appChannel)
|
||||||
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@renderer': resolve('src/renderer/src')
|
'@renderer': resolve('src/renderer/src')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
plugins: [react(), tailwindcss()]
|
plugins: [
|
||||||
|
react(),
|
||||||
|
tailwindcss(),
|
||||||
|
{
|
||||||
|
name: 'update-title',
|
||||||
|
transformIndexHtml(html) {
|
||||||
|
return html.replace(
|
||||||
|
'<title>ERP Auto Tool</title>',
|
||||||
|
`<title>ERPAuto - v${version}(${gitHash})</title>`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"keep": {
|
|
||||||
"days": true,
|
|
||||||
"amount": 14
|
|
||||||
},
|
|
||||||
"auditLog": "D:\\FileLib\\Projects\\CodeMigration\\ERPAuto\\logs\\.869a8c37397718a299488a3d6c7b9753a8bc7cf9-audit.json",
|
|
||||||
"files": [
|
|
||||||
{
|
|
||||||
"date": 1772462852547,
|
|
||||||
"name": "D:\\FileLib\\Projects\\CodeMigration\\ERPAuto\\logs\\app-2026-03-02.log",
|
|
||||||
"hash": "baa4ab4c2dfd6ec62a003e44496d2f428a4ad4037ce2529a8da14da1b91ef7a2"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"hashType": "sha256"
|
|
||||||
}
|
|
||||||
4205
package-lock.json
generated
4205
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
39
package.json
39
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "erpauto",
|
"name": "erpauto",
|
||||||
"version": "1.0.0",
|
"version": "1.4.1",
|
||||||
"description": "An Electron application with React and TypeScript",
|
"description": "An Electron application with React and TypeScript",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": "example.com",
|
"author": "example.com",
|
||||||
@@ -12,32 +12,52 @@
|
|||||||
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
|
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
|
||||||
"typecheck": "npm run typecheck:node && npm run typecheck:web",
|
"typecheck": "npm run typecheck:node && npm run typecheck:web",
|
||||||
"start": "electron-vite preview",
|
"start": "electron-vite preview",
|
||||||
"dev": "electron-vite dev",
|
"dev": "chcp 65001 && electron-vite dev",
|
||||||
"build": "npm run typecheck && electron-vite build",
|
"build": "chcp 65001 && npm run typecheck && electron-vite build",
|
||||||
"postinstall": "electron-builder install-app-deps",
|
"postinstall": "electron-builder install-app-deps",
|
||||||
"build:unpack": "npm run build && electron-builder --dir",
|
"build:updater": "node scripts/compile-updater.js",
|
||||||
"build:win": "npm run build && electron-builder --win",
|
"build:unpack": "chcp 65001 && set PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 && npm run build:updater && npm run build && electron-builder --dir",
|
||||||
"build:mac": "electron-vite build && electron-builder --mac",
|
"build:win": "chcp 65001 && set PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 && npm run prebuild && npm run build && npm run build:updater && electron-builder --win",
|
||||||
"build:linux": "electron-vite build && electron-builder --linux",
|
"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",
|
||||||
|
"release:prepare": "node scripts/prepare-release.js",
|
||||||
|
"release:publish": "node scripts/publish-release.js",
|
||||||
|
"release:upload": "node scripts/upload-release.js",
|
||||||
|
"prebuild": "node -e \"const fs=require('fs');['dist','out'].forEach(d=>{try{fs.rmSync(d,{recursive:true})}catch(e){}})\"",
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
"test:run": "vitest run",
|
"test:run": "vitest run",
|
||||||
"test:coverage": "vitest run --coverage",
|
"test:coverage": "vitest run --coverage",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"test:e2e:ui": "playwright test --ui",
|
"test:e2e:ui": "playwright test --ui",
|
||||||
"test:e2e:report": "playwright show-report"
|
"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": {
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.929.0",
|
||||||
"@electron-toolkit/preload": "^3.0.2",
|
"@electron-toolkit/preload": "^3.0.2",
|
||||||
"@electron-toolkit/utils": "^4.0.0",
|
"@electron-toolkit/utils": "^4.0.0",
|
||||||
|
"@headlessui/react": "^2.2.9",
|
||||||
"@tailwindcss/vite": "^4.2.1",
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
|
"@types/js-yaml": "^4.0.9",
|
||||||
|
"chromium-bidi": "^15.0.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"dotenv": "^17.3.1",
|
|
||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
|
"github-markdown-css": "^5.9.0",
|
||||||
|
"js-yaml": "^4.1.1",
|
||||||
"lucide-react": "^0.575.0",
|
"lucide-react": "^0.575.0",
|
||||||
"mssql": "^12.2.0",
|
"mssql": "^12.2.0",
|
||||||
"mysql2": "^3.18.2",
|
"mysql2": "^3.18.2",
|
||||||
"playwright": "^1.58.2",
|
"playwright": "^1.58.2",
|
||||||
|
"playwright-core": "^1.58.2",
|
||||||
|
"react-focus-lock": "^2.13.7",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"rehype-autolink-headings": "^7.1.0",
|
||||||
|
"rehype-highlight": "^7.0.2",
|
||||||
|
"rehype-slug": "^6.0.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"typeorm": "^0.3.28",
|
"typeorm": "^0.3.28",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
"winston": "^3.19.0",
|
"winston": "^3.19.0",
|
||||||
@@ -70,6 +90,7 @@
|
|||||||
"react": "^19.2.1",
|
"react": "^19.2.1",
|
||||||
"react-dom": "^19.2.1",
|
"react-dom": "^19.2.1",
|
||||||
"tailwindcss": "^4.2.1",
|
"tailwindcss": "^4.2.1",
|
||||||
|
"tsx": "^4.19.3",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^7.2.6",
|
"vite": "^7.2.6",
|
||||||
"vitest": "^4.0.18"
|
"vitest": "^4.0.18"
|
||||||
|
|||||||
57
scripts/compile-updater.js
Normal file
57
scripts/compile-updater.js
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
const { spawnSync } = require('child_process')
|
||||||
|
|
||||||
|
const rootDir = process.cwd()
|
||||||
|
const sourcePath = path.join(rootDir, 'build', 'PortableUpdater.cs')
|
||||||
|
const outputDir = path.join(rootDir, 'build', 'bin')
|
||||||
|
const outputPath = path.join(outputDir, 'portable-updater.exe')
|
||||||
|
|
||||||
|
function findCompiler() {
|
||||||
|
const candidates = [
|
||||||
|
'C:\\Windows\\Microsoft.NET\\Framework64\\v4.0.30319\\csc.exe',
|
||||||
|
'C:\\Windows\\Microsoft.NET\\Framework\\v4.0.30319\\csc.exe'
|
||||||
|
]
|
||||||
|
|
||||||
|
return candidates.find((candidate) => fs.existsSync(candidate)) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
console.log('Skipping updater compilation on non-Windows platform.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(sourcePath)) {
|
||||||
|
throw new Error(`Updater source not found: ${sourcePath}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const compiler = findCompiler()
|
||||||
|
if (!compiler) {
|
||||||
|
throw new Error('Unable to find csc.exe for compiling portable-updater.exe')
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.mkdirSync(outputDir, { recursive: true })
|
||||||
|
|
||||||
|
const compileArgs = ['/nologo', '/target:exe', '/optimize+', `/out:${outputPath}`, sourcePath]
|
||||||
|
|
||||||
|
const result = spawnSync(compiler, compileArgs, {
|
||||||
|
cwd: rootDir,
|
||||||
|
stdio: 'inherit'
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.status !== 0) {
|
||||||
|
throw new Error(`csc.exe failed with exit code ${result.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Portable updater compiled successfully: ${outputPath}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
main()
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`compile-updater failed: ${error.message}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
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()
|
||||||
193
scripts/prepare-release.js
Normal file
193
scripts/prepare-release.js
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
const crypto = require('crypto')
|
||||||
|
|
||||||
|
function printUsage() {
|
||||||
|
console.log(`
|
||||||
|
Usage:
|
||||||
|
node scripts/prepare-release.js --channel <stable|preview> --changelog <file> [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--channel <stable|preview> Release channel. Required.
|
||||||
|
--changelog <file> Markdown changelog file. Required.
|
||||||
|
--artifact <file> Portable exe path. Default: dist/erpauto-portable.exe
|
||||||
|
--version <x.y.z> Release version. Default: package.json version
|
||||||
|
--summary <text> Optional short summary for notesSummary
|
||||||
|
--published-at <ISO date> Optional publish time. Default: current time
|
||||||
|
--base-prefix <prefix> Default: updates/win-portable
|
||||||
|
--output <dir> Default: release-output
|
||||||
|
--existing-index <file> Existing index.json to merge with
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const args = {}
|
||||||
|
for (let i = 0; i < argv.length; i += 1) {
|
||||||
|
const token = argv[i]
|
||||||
|
if (!token.startsWith('--')) continue
|
||||||
|
const key = token.slice(2)
|
||||||
|
const value = argv[i + 1]
|
||||||
|
if (!value || value.startsWith('--')) {
|
||||||
|
args[key] = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
args[key] = value
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
function assert(condition, message) {
|
||||||
|
if (!condition) {
|
||||||
|
throw new Error(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJson(filePath) {
|
||||||
|
return JSON.parse(fs.readFileSync(filePath, 'utf-8'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureDir(dirPath) {
|
||||||
|
fs.mkdirSync(dirPath, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha256File(filePath) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const hash = crypto.createHash('sha256')
|
||||||
|
const stream = fs.createReadStream(filePath)
|
||||||
|
stream.on('data', (chunk) => hash.update(chunk))
|
||||||
|
stream.on('error', reject)
|
||||||
|
stream.on('end', () => resolve(hash.digest('hex')))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadExistingIndex(existingIndexPath, outputIndexPath) {
|
||||||
|
const candidate = existingIndexPath || (fs.existsSync(outputIndexPath) ? outputIndexPath : null)
|
||||||
|
if (!candidate) {
|
||||||
|
return { releases: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = readJson(candidate)
|
||||||
|
if (!parsed || !Array.isArray(parsed.releases)) {
|
||||||
|
throw new Error(`Invalid index file: ${candidate}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortReleases(releases) {
|
||||||
|
return [...releases].sort((left, right) => {
|
||||||
|
const leftParts = String(left.version)
|
||||||
|
.split('.')
|
||||||
|
.map((part) => Number.parseInt(part, 10) || 0)
|
||||||
|
const rightParts = String(right.version)
|
||||||
|
.split('.')
|
||||||
|
.map((part) => Number.parseInt(part, 10) || 0)
|
||||||
|
|
||||||
|
const length = Math.max(leftParts.length, rightParts.length)
|
||||||
|
for (let i = 0; i < length; i += 1) {
|
||||||
|
const l = leftParts[i] || 0
|
||||||
|
const r = rightParts[i] || 0
|
||||||
|
if (r !== l) {
|
||||||
|
return r - l
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const leftTime = new Date(left.publishedAt).getTime()
|
||||||
|
const rightTime = new Date(right.publishedAt).getTime()
|
||||||
|
if (rightTime !== leftTime) {
|
||||||
|
return rightTime - leftTime
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const args = parseArgs(process.argv.slice(2))
|
||||||
|
|
||||||
|
if (args.help || args.h) {
|
||||||
|
printUsage()
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const packageJson = readJson(path.resolve(process.cwd(), 'package.json'))
|
||||||
|
const version = args.version || packageJson.version
|
||||||
|
const channel = args.channel
|
||||||
|
const changelogPath = args.changelog
|
||||||
|
const artifactPath = path.resolve(process.cwd(), args.artifact || 'dist/erpauto-portable.exe')
|
||||||
|
const basePrefix = args['base-prefix'] || 'updates/win-portable'
|
||||||
|
const outputRoot = path.resolve(process.cwd(), args.output || 'release-output')
|
||||||
|
const publishedAt = args['published-at'] || new Date().toISOString()
|
||||||
|
const summary = args.summary
|
||||||
|
|
||||||
|
assert(channel === 'stable' || channel === 'preview', 'Missing or invalid --channel')
|
||||||
|
assert(changelogPath, 'Missing --changelog')
|
||||||
|
assert(fs.existsSync(artifactPath), `Artifact not found: ${artifactPath}`)
|
||||||
|
|
||||||
|
const resolvedChangelogPath = path.resolve(process.cwd(), changelogPath)
|
||||||
|
assert(fs.existsSync(resolvedChangelogPath), `Changelog not found: ${resolvedChangelogPath}`)
|
||||||
|
|
||||||
|
const artifactStat = fs.statSync(artifactPath)
|
||||||
|
const sha256 = await sha256File(artifactPath)
|
||||||
|
const fileName = `erpauto-${version}-${channel}-portable.exe`
|
||||||
|
const changelogFileName = `${version}.md`
|
||||||
|
|
||||||
|
const channelDir = path.join(outputRoot, basePrefix, channel)
|
||||||
|
const artifactsDir = path.join(channelDir, 'artifacts')
|
||||||
|
const changelogDir = path.join(channelDir, 'changelogs')
|
||||||
|
const outputIndexPath = path.join(channelDir, 'index.json')
|
||||||
|
|
||||||
|
ensureDir(artifactsDir)
|
||||||
|
ensureDir(changelogDir)
|
||||||
|
|
||||||
|
const targetArtifactPath = path.join(artifactsDir, fileName)
|
||||||
|
const targetChangelogPath = path.join(changelogDir, changelogFileName)
|
||||||
|
|
||||||
|
fs.copyFileSync(artifactPath, targetArtifactPath)
|
||||||
|
fs.copyFileSync(resolvedChangelogPath, targetChangelogPath)
|
||||||
|
|
||||||
|
const releaseEntry = {
|
||||||
|
version,
|
||||||
|
channel,
|
||||||
|
artifactKey: `${basePrefix}/${channel}/artifacts/${fileName}`,
|
||||||
|
sha256,
|
||||||
|
size: artifactStat.size,
|
||||||
|
publishedAt,
|
||||||
|
changelogKey: `${basePrefix}/${channel}/changelogs/${changelogFileName}`,
|
||||||
|
...(summary ? { notesSummary: summary } : {})
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingIndex = loadExistingIndex(
|
||||||
|
args['existing-index'] ? path.resolve(process.cwd(), args['existing-index']) : null,
|
||||||
|
outputIndexPath
|
||||||
|
)
|
||||||
|
|
||||||
|
const mergedReleases = sortReleases([
|
||||||
|
releaseEntry,
|
||||||
|
...existingIndex.releases.filter(
|
||||||
|
(item) => !(item.version === version && item.channel === channel)
|
||||||
|
)
|
||||||
|
])
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
outputIndexPath,
|
||||||
|
`${JSON.stringify({ releases: mergedReleases }, null, 2)}\n`,
|
||||||
|
'utf-8'
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log('Release package prepared successfully.')
|
||||||
|
console.log(`Channel: ${channel}`)
|
||||||
|
console.log(`Version: ${version}`)
|
||||||
|
console.log(`Artifact: ${targetArtifactPath}`)
|
||||||
|
console.log(`Changelog: ${targetChangelogPath}`)
|
||||||
|
console.log(`Index: ${outputIndexPath}`)
|
||||||
|
console.log(`SHA256: ${sha256}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(`prepare-release failed: ${error.message}`)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
229
scripts/publish-release.js
Normal file
229
scripts/publish-release.js
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
const yaml = require('js-yaml')
|
||||||
|
const { spawnSync } = require('child_process')
|
||||||
|
|
||||||
|
function usage() {
|
||||||
|
console.log(`
|
||||||
|
Usage:
|
||||||
|
node scripts/publish-release.js --channel <stable|preview> [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--channel <stable|preview> Release channel. Required.
|
||||||
|
--changelog <file> Optional changelog file. Default: auto-resolve from version
|
||||||
|
--config <file> Config file path. Default: config.yaml
|
||||||
|
--help Show this help message
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const args = {}
|
||||||
|
for (let i = 0; i < argv.length; i += 1) {
|
||||||
|
const token = argv[i]
|
||||||
|
if (!token.startsWith('--')) continue
|
||||||
|
const key = token.slice(2)
|
||||||
|
const value = argv[i + 1]
|
||||||
|
if (!value || value.startsWith('--')) {
|
||||||
|
args[key] = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
args[key] = value
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
function assert(condition, message) {
|
||||||
|
if (!condition) {
|
||||||
|
throw new Error(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJson(filePath) {
|
||||||
|
return JSON.parse(fs.readFileSync(filePath, 'utf-8'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function readYaml(filePath) {
|
||||||
|
return yaml.load(fs.readFileSync(filePath, 'utf-8'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveChangelogPath(version, explicitPath) {
|
||||||
|
if (explicitPath) {
|
||||||
|
return path.resolve(process.cwd(), explicitPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rebuildPath = path.resolve(process.cwd(), 'docs', 'releases', `${version}-rebuild.md`)
|
||||||
|
if (fs.existsSync(rebuildPath)) {
|
||||||
|
return rebuildPath
|
||||||
|
}
|
||||||
|
|
||||||
|
return path.resolve(process.cwd(), 'docs', 'releases', `${version}.md`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateUpdateConfig(configPath) {
|
||||||
|
assert(fs.existsSync(configPath), `Config file not found: ${configPath}`)
|
||||||
|
|
||||||
|
const parsed = readYaml(configPath)
|
||||||
|
const update = parsed && parsed.update
|
||||||
|
|
||||||
|
assert(update, 'Missing update config in config file')
|
||||||
|
assert(update.enabled, 'update.enabled is false')
|
||||||
|
assert(update.endpoint, 'update.endpoint is required')
|
||||||
|
assert(update.accessKey, 'update.accessKey is required')
|
||||||
|
assert(update.secretKey, 'update.secretKey is required')
|
||||||
|
assert(update.bucket, 'update.bucket is required')
|
||||||
|
assert(update.basePrefix, 'update.basePrefix is required')
|
||||||
|
|
||||||
|
return update
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasConflictMarker(filePath) {
|
||||||
|
const content = fs.readFileSync(filePath, 'utf-8')
|
||||||
|
return (
|
||||||
|
content.includes('<<<<<<<') || content.includes('=======') || content.includes('>>>>>>>')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateVersionFiles(version) {
|
||||||
|
const packageJsonPath = path.resolve(process.cwd(), 'package.json')
|
||||||
|
const packageLockPath = path.resolve(process.cwd(), 'package-lock.json')
|
||||||
|
|
||||||
|
assert(fs.existsSync(packageLockPath), `package-lock.json not found: ${packageLockPath}`)
|
||||||
|
assert(!hasConflictMarker(packageJsonPath), 'package.json contains merge conflict markers')
|
||||||
|
assert(!hasConflictMarker(packageLockPath), 'package-lock.json contains merge conflict markers')
|
||||||
|
|
||||||
|
const packageLock = readJson(packageLockPath)
|
||||||
|
assert(packageLock.version === version, 'package-lock.json version does not match package.json')
|
||||||
|
|
||||||
|
const rootPackage = packageLock.packages && packageLock.packages['']
|
||||||
|
if (rootPackage && rootPackage.version) {
|
||||||
|
assert(rootPackage.version === version, 'package-lock root package version does not match package.json')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runStep(name, command, args, envOverrides = {}) {
|
||||||
|
console.log('')
|
||||||
|
console.log(`==> ${name}`)
|
||||||
|
console.log(`$ ${command} ${args.join(' ')}`)
|
||||||
|
|
||||||
|
let result
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
const shellCommand = [command, ...args]
|
||||||
|
.map((arg) => (/\s|"/.test(arg) ? `"${String(arg).replace(/"/g, '\\"')}"` : arg))
|
||||||
|
.join(' ')
|
||||||
|
|
||||||
|
result = spawnSync(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', shellCommand], {
|
||||||
|
cwd: process.cwd(),
|
||||||
|
env: { ...process.env, ...envOverrides },
|
||||||
|
stdio: 'inherit'
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
result = spawnSync(command, args, {
|
||||||
|
cwd: process.cwd(),
|
||||||
|
env: { ...process.env, ...envOverrides },
|
||||||
|
stdio: 'inherit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
throw result.error
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status !== 0) {
|
||||||
|
throw new Error(`${name} failed with exit code ${result.status}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPreparedIndex(channel, basePrefix) {
|
||||||
|
const indexPath = path.resolve(
|
||||||
|
process.cwd(),
|
||||||
|
'release-output',
|
||||||
|
...basePrefix.split('/'),
|
||||||
|
channel,
|
||||||
|
'index.json'
|
||||||
|
)
|
||||||
|
|
||||||
|
assert(fs.existsSync(indexPath), `Prepared index not found: ${indexPath}`)
|
||||||
|
const parsed = readJson(indexPath)
|
||||||
|
assert(parsed && Array.isArray(parsed.releases), `Invalid prepared index: ${indexPath}`)
|
||||||
|
return { indexPath, parsed }
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeRelease(version, channel, releaseEntry, indexPath) {
|
||||||
|
console.log('')
|
||||||
|
console.log('Release published successfully.')
|
||||||
|
console.log(`Version: ${version}`)
|
||||||
|
console.log(`Channel: ${channel}`)
|
||||||
|
console.log(`Artifact: ${releaseEntry.artifactKey}`)
|
||||||
|
console.log(`SHA256: ${releaseEntry.sha256}`)
|
||||||
|
console.log(`Changelog: ${releaseEntry.changelogKey}`)
|
||||||
|
console.log(`Prepared Index: ${indexPath}`)
|
||||||
|
console.log(`Published At: ${releaseEntry.publishedAt}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const args = parseArgs(process.argv.slice(2))
|
||||||
|
|
||||||
|
if (args.help || args.h) {
|
||||||
|
usage()
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = args.channel
|
||||||
|
assert(channel === 'stable' || channel === 'preview', 'Missing or invalid --channel')
|
||||||
|
|
||||||
|
const packageJsonPath = path.resolve(process.cwd(), 'package.json')
|
||||||
|
assert(fs.existsSync(packageJsonPath), `package.json not found: ${packageJsonPath}`)
|
||||||
|
const packageJson = readJson(packageJsonPath)
|
||||||
|
const version = packageJson.version
|
||||||
|
assert(version, 'package.json version is required')
|
||||||
|
|
||||||
|
const configPath = path.resolve(process.cwd(), args.config || 'config.yaml')
|
||||||
|
const updateConfig = validateUpdateConfig(configPath)
|
||||||
|
validateVersionFiles(version)
|
||||||
|
|
||||||
|
const changelogPath = resolveChangelogPath(version, args.changelog)
|
||||||
|
assert(fs.existsSync(changelogPath), `Changelog not found: ${changelogPath}`)
|
||||||
|
|
||||||
|
runStep('Build Windows package', 'npm', ['run', 'build:win'], {
|
||||||
|
APP_CHANNEL: channel
|
||||||
|
})
|
||||||
|
|
||||||
|
runStep('Prepare release package', 'npm', [
|
||||||
|
'run',
|
||||||
|
'release:prepare',
|
||||||
|
'--',
|
||||||
|
'--channel',
|
||||||
|
channel,
|
||||||
|
'--changelog',
|
||||||
|
changelogPath
|
||||||
|
])
|
||||||
|
|
||||||
|
const { indexPath, parsed } = readPreparedIndex(channel, updateConfig.basePrefix)
|
||||||
|
const latestRelease = parsed.releases[0]
|
||||||
|
assert(latestRelease, 'Prepared index does not contain any release entry')
|
||||||
|
assert(
|
||||||
|
latestRelease.version === version && latestRelease.channel === channel,
|
||||||
|
`Prepared index latest entry mismatch: expected ${version}/${channel}, got ${latestRelease.version}/${latestRelease.channel}`
|
||||||
|
)
|
||||||
|
|
||||||
|
runStep('Upload release package', 'npm', [
|
||||||
|
'run',
|
||||||
|
'release:upload',
|
||||||
|
'--',
|
||||||
|
'--channel',
|
||||||
|
channel,
|
||||||
|
'--verify'
|
||||||
|
])
|
||||||
|
|
||||||
|
summarizeRelease(version, channel, latestRelease, indexPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
main()
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`publish-release failed: ${error.message}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
217
scripts/upload-release.js
Normal file
217
scripts/upload-release.js
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
const yaml = require('js-yaml')
|
||||||
|
const { GetObjectCommand, PutObjectCommand, S3Client } = require('@aws-sdk/client-s3')
|
||||||
|
|
||||||
|
function usage() {
|
||||||
|
console.log(`
|
||||||
|
Usage:
|
||||||
|
node scripts/upload-release.js --channel <stable|preview> [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--channel <stable|preview> Release channel. Required.
|
||||||
|
--source <dir> Local release root. Default: release-output
|
||||||
|
--config <file> Config file path. Default: config.yaml
|
||||||
|
--version <x.y.z> Release version. Default: package.json version
|
||||||
|
--full-sync Upload the whole channel directory instead of current release only
|
||||||
|
--verify Read back remote index.json after upload
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const args = {}
|
||||||
|
for (let i = 0; i < argv.length; i += 1) {
|
||||||
|
const token = argv[i]
|
||||||
|
if (!token.startsWith('--')) continue
|
||||||
|
const key = token.slice(2)
|
||||||
|
const value = argv[i + 1]
|
||||||
|
if (!value || value.startsWith('--')) {
|
||||||
|
args[key] = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
args[key] = value
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
function assert(condition, message) {
|
||||||
|
if (!condition) {
|
||||||
|
throw new Error(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJson(filePath) {
|
||||||
|
return JSON.parse(fs.readFileSync(filePath, 'utf-8'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadConfig(configPath) {
|
||||||
|
const raw = fs.readFileSync(configPath, 'utf-8')
|
||||||
|
const parsed = yaml.load(raw)
|
||||||
|
const update = parsed && parsed.update
|
||||||
|
|
||||||
|
assert(update, 'Missing update config in config.yaml')
|
||||||
|
assert(update.enabled, 'update.enabled is false')
|
||||||
|
assert(update.endpoint, 'update.endpoint is required')
|
||||||
|
assert(update.accessKey, 'update.accessKey is required')
|
||||||
|
assert(update.secretKey, 'update.secretKey is required')
|
||||||
|
assert(update.bucket, 'update.bucket is required')
|
||||||
|
|
||||||
|
return update
|
||||||
|
}
|
||||||
|
|
||||||
|
function getContentType(filePath) {
|
||||||
|
const ext = path.extname(filePath).toLowerCase()
|
||||||
|
if (ext === '.json') return 'application/json; charset=utf-8'
|
||||||
|
if (ext === '.md') return 'text/markdown; charset=utf-8'
|
||||||
|
if (ext === '.exe') return 'application/vnd.microsoft.portable-executable'
|
||||||
|
return 'application/octet-stream'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readRemoteText(client, bucket, key) {
|
||||||
|
const response = await client.send(
|
||||||
|
new GetObjectCommand({
|
||||||
|
Bucket: bucket,
|
||||||
|
Key: key
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const chunks = []
|
||||||
|
for await (const chunk of response.Body) {
|
||||||
|
chunks.push(Buffer.from(chunk))
|
||||||
|
}
|
||||||
|
return Buffer.concat(chunks).toString('utf-8')
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFileDescriptor(sourceRoot, absolutePath) {
|
||||||
|
return {
|
||||||
|
absolutePath,
|
||||||
|
relativeKey: path.relative(sourceRoot, absolutePath).replace(/\\/g, '/')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectUploadFiles(sourceRoot, channelRoot, channel, version, fullSync, basePrefix) {
|
||||||
|
const indexPath = path.join(channelRoot, 'index.json')
|
||||||
|
assert(fs.existsSync(indexPath), `Index not found: ${indexPath}`)
|
||||||
|
|
||||||
|
if (fullSync) {
|
||||||
|
const results = []
|
||||||
|
const entries = fs.readdirSync(channelRoot, { withFileTypes: true })
|
||||||
|
|
||||||
|
function walk(dirPath) {
|
||||||
|
const dirEntries = fs.readdirSync(dirPath, { withFileTypes: true })
|
||||||
|
for (const entry of dirEntries) {
|
||||||
|
const fullPath = path.join(dirPath, entry.name)
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
walk(fullPath)
|
||||||
|
} else {
|
||||||
|
results.push(buildFileDescriptor(sourceRoot, fullPath))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = path.join(channelRoot, entry.name)
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
walk(fullPath)
|
||||||
|
} else {
|
||||||
|
results.push(buildFileDescriptor(sourceRoot, fullPath))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { files: results, indexKey: `${basePrefix}/${channel}/index.json` }
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedIndex = readJson(indexPath)
|
||||||
|
assert(parsedIndex && Array.isArray(parsedIndex.releases), `Invalid index file: ${indexPath}`)
|
||||||
|
|
||||||
|
const releaseEntry = parsedIndex.releases.find(
|
||||||
|
(release) => release.version === version && release.channel === channel
|
||||||
|
)
|
||||||
|
assert(releaseEntry, `Release entry not found in index for ${channel}/${version}`)
|
||||||
|
|
||||||
|
const artifactPath = path.resolve(sourceRoot, releaseEntry.artifactKey)
|
||||||
|
const changelogPath = path.resolve(sourceRoot, releaseEntry.changelogKey)
|
||||||
|
|
||||||
|
assert(fs.existsSync(artifactPath), `Artifact not found: ${artifactPath}`)
|
||||||
|
assert(fs.existsSync(changelogPath), `Changelog not found: ${changelogPath}`)
|
||||||
|
|
||||||
|
return {
|
||||||
|
files: [
|
||||||
|
buildFileDescriptor(sourceRoot, artifactPath),
|
||||||
|
buildFileDescriptor(sourceRoot, changelogPath),
|
||||||
|
buildFileDescriptor(sourceRoot, indexPath)
|
||||||
|
],
|
||||||
|
indexKey: `${basePrefix}/${channel}/index.json`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const args = parseArgs(process.argv.slice(2))
|
||||||
|
if (args.help || args.h) {
|
||||||
|
usage()
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = args.channel
|
||||||
|
assert(channel === 'stable' || channel === 'preview', 'Missing or invalid --channel')
|
||||||
|
|
||||||
|
const sourceRoot = path.resolve(process.cwd(), args.source || 'release-output')
|
||||||
|
const configPath = path.resolve(process.cwd(), args.config || 'config.yaml')
|
||||||
|
const updateConfig = loadConfig(configPath)
|
||||||
|
const packageJson = readJson(path.resolve(process.cwd(), 'package.json'))
|
||||||
|
const version = args.version || packageJson.version
|
||||||
|
assert(version, 'Missing release version')
|
||||||
|
|
||||||
|
const channelRoot = path.join(sourceRoot, updateConfig.basePrefix, channel)
|
||||||
|
assert(fs.existsSync(channelRoot), `Upload root not found: ${channelRoot}`)
|
||||||
|
|
||||||
|
const client = new S3Client({
|
||||||
|
region: updateConfig.region || 'us-east-1',
|
||||||
|
endpoint: updateConfig.endpoint,
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: updateConfig.accessKey,
|
||||||
|
secretAccessKey: updateConfig.secretKey
|
||||||
|
},
|
||||||
|
forcePathStyle: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const { files, indexKey } = collectUploadFiles(
|
||||||
|
sourceRoot,
|
||||||
|
channelRoot,
|
||||||
|
channel,
|
||||||
|
version,
|
||||||
|
Boolean(args['full-sync']),
|
||||||
|
updateConfig.basePrefix
|
||||||
|
)
|
||||||
|
assert(files.length > 0, `No files found under ${channelRoot}`)
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const body = fs.readFileSync(file.absolutePath)
|
||||||
|
|
||||||
|
await client.send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: updateConfig.bucket,
|
||||||
|
Key: file.relativeKey,
|
||||||
|
Body: body,
|
||||||
|
ContentType: getContentType(file.absolutePath)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log(`Uploaded: ${file.relativeKey}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.verify) {
|
||||||
|
const remoteIndex = await readRemoteText(client, updateConfig.bucket, indexKey)
|
||||||
|
console.log('')
|
||||||
|
console.log(`Verified remote index: ${indexKey}`)
|
||||||
|
console.log(remoteIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(`upload-release failed: ${error.message}`)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
@@ -1,28 +1,33 @@
|
|||||||
import { app, shell, BrowserWindow, ipcMain } from 'electron'
|
import { app, shell, BrowserWindow, ipcMain, dialog } from 'electron'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||||
import icon from '../../resources/icon.png?asset'
|
import icon from '../../resources/icon.png?asset'
|
||||||
import { registerIpcHandlers } from './ipc'
|
import { registerIpcHandlers } from './ipc'
|
||||||
import * as dotenv from 'dotenv'
|
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 { fileURLToPath } from 'url'
|
||||||
import { dirname, resolve } from 'path'
|
import { dirname } from 'path'
|
||||||
|
import fs from 'fs'
|
||||||
|
import { UpdateService } from './services/update/update-service'
|
||||||
|
|
||||||
|
// Set Playwright browsers path BEFORE any playwright import
|
||||||
|
process.env.PLAYWRIGHT_BROWSERS_PATH = join(app.getPath('userData'), 'ms-playwright')
|
||||||
|
|
||||||
// Load environment variables from .env file
|
|
||||||
const __filename = fileURLToPath(import.meta.url)
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
const __dirname = dirname(__filename)
|
const __dirname = dirname(__filename)
|
||||||
dotenv.config({ path: resolve(__dirname, '../../.env') })
|
|
||||||
|
|
||||||
function createWindow(): void {
|
function createWindow(): void {
|
||||||
// Create the browser window.
|
// Create the browser window.
|
||||||
const mainWindow = new BrowserWindow({
|
const mainWindow = new BrowserWindow({
|
||||||
width: 900,
|
width: 1200,
|
||||||
height: 670,
|
height: 670,
|
||||||
show: false,
|
show: false,
|
||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
...(process.platform === 'linux' ? { icon } : {}),
|
...(process.platform === 'linux' ? { icon } : {}),
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: join(__dirname, '../preload/index.js'),
|
preload: join(__dirname, '../preload/index.js'),
|
||||||
sandbox: false
|
sandbox: true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -47,7 +52,71 @@ function createWindow(): void {
|
|||||||
// This method will be called when Electron has finished
|
// This method will be called when Electron has finished
|
||||||
// initialization and is ready to create browser windows.
|
// initialization and is ready to create browser windows.
|
||||||
// Some APIs can only be used after this event occurs.
|
// 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()
|
||||||
|
UpdateService.getInstance().initialize()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize ConfigManager:', error)
|
||||||
|
// Continue anyway - default config will be created
|
||||||
|
}
|
||||||
|
|
||||||
// Set app user model id for windows
|
// Set app user model id for windows
|
||||||
electronApp.setAppUserModelId('com.electron')
|
electronApp.setAppUserModelId('com.electron')
|
||||||
|
|
||||||
@@ -58,7 +127,7 @@ app.whenReady().then(() => {
|
|||||||
optimizer.watchWindowShortcuts(window)
|
optimizer.watchWindowShortcuts(window)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Register IPC handlers
|
// Register IPC handlers (after ConfigManager is initialized)
|
||||||
registerIpcHandlers()
|
registerIpcHandlers()
|
||||||
|
|
||||||
// IPC test
|
// IPC test
|
||||||
@@ -82,5 +151,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
|
// 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.
|
// code. You can also put them in separate files and require them here.
|
||||||
|
|||||||
@@ -13,7 +13,12 @@
|
|||||||
import { ipcMain } from 'electron'
|
import { ipcMain } from 'electron'
|
||||||
import { SessionManager } from '../services/user/session-manager'
|
import { SessionManager } from '../services/user/session-manager'
|
||||||
import { createLogger } from '../services/logger'
|
import { createLogger } from '../services/logger'
|
||||||
|
import { logAudit } from '../services/logger/audit-logger'
|
||||||
import type { UserInfo } from '../types/user.types'
|
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'
|
||||||
|
import { UpdateService } from '../services/update/update-service'
|
||||||
|
|
||||||
const log = createLogger('AuthHandler')
|
const log = createLogger('AuthHandler')
|
||||||
|
|
||||||
@@ -66,25 +71,32 @@ export interface CurrentUserResponse {
|
|||||||
*/
|
*/
|
||||||
export function registerAuthHandlers(): void {
|
export function registerAuthHandlers(): void {
|
||||||
const sessionManager = SessionManager.getInstance()
|
const sessionManager = SessionManager.getInstance()
|
||||||
|
const updateService = UpdateService.getInstance()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get computer name
|
* Get computer name
|
||||||
*/
|
*/
|
||||||
ipcMain.handle('auth:getComputerName', async (): Promise<string> => {
|
ipcMain.handle(IPC_CHANNELS.AUTH_GET_COMPUTER_NAME, async (): Promise<IpcResult<string>> => {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
const os = await import('os')
|
const os = await import('os')
|
||||||
return os.hostname()
|
return os.hostname()
|
||||||
|
}, 'auth:getComputerName')
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Silent login by computer name
|
* Silent login by computer name
|
||||||
*/
|
*/
|
||||||
ipcMain.handle('auth:silentLogin', async (): Promise<SilentLoginResponse> => {
|
ipcMain.handle(
|
||||||
try {
|
IPC_CHANNELS.AUTH_SILENT_LOGIN,
|
||||||
|
async (): Promise<IpcResult<SilentLoginResponse>> => {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
log.info('Attempting silent login')
|
log.info('Attempting silent login')
|
||||||
const success = await sessionManager.loginByComputerName()
|
const success = await sessionManager.loginByComputerName()
|
||||||
const userInfo = sessionManager.getUserInfo()
|
const userInfo = sessionManager.getUserInfo()
|
||||||
|
|
||||||
if (success && userInfo) {
|
if (success && userInfo) {
|
||||||
|
await updateService.setUserContext(userInfo.userType)
|
||||||
|
|
||||||
// Check if admin needs user selection
|
// Check if admin needs user selection
|
||||||
const requiresUserSelection = userInfo.userType === 'Admin'
|
const requiresUserSelection = userInfo.userType === 'Admin'
|
||||||
|
|
||||||
@@ -94,6 +106,16 @@ export function registerAuthHandlers(): void {
|
|||||||
requiresUserSelection
|
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
userInfo,
|
userInfo,
|
||||||
@@ -101,34 +123,24 @@ export function registerAuthHandlers(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.warn('Silent login failed - no matching user')
|
await updateService.setUserContext(null)
|
||||||
return {
|
throw new ValidationError('无感登录失败:未找到匹配用户', 'VAL_INVALID_INPUT')
|
||||||
success: false,
|
}, 'auth:silentLogin')
|
||||||
requiresUserSelection: false
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
)
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
|
||||||
log.error('Silent login error', { error: message })
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: `无感登录失败:${message}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Login with username and password
|
* Login with username and password
|
||||||
*/
|
*/
|
||||||
ipcMain.handle('auth:login', async (_event, request: LoginRequest): Promise<LoginResponse> => {
|
ipcMain.handle(
|
||||||
try {
|
IPC_CHANNELS.AUTH_LOGIN,
|
||||||
|
async (_event, request: LoginRequest): Promise<IpcResult<LoginResponse>> => {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
const { username, password } = request
|
const { username, password } = request
|
||||||
|
|
||||||
if (!username || !password) {
|
if (!username || !password) {
|
||||||
log.warn('Login attempt with missing credentials')
|
log.warn('Login attempt with missing credentials')
|
||||||
return {
|
throw new ValidationError('请输入用户名和密码', 'VAL_MISSING_REQUIRED')
|
||||||
success: false,
|
|
||||||
error: '请输入用户名和密码'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info('Login attempt', { username })
|
log.info('Login attempt', { username })
|
||||||
@@ -137,70 +149,107 @@ export function registerAuthHandlers(): void {
|
|||||||
|
|
||||||
if (success && userInfo) {
|
if (success && userInfo) {
|
||||||
log.info('Login successful', { username, userType: userInfo.userType })
|
log.info('Login successful', { username, userType: userInfo.userType })
|
||||||
|
await updateService.setUserContext(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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
userInfo
|
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 })
|
log.warn('Login failed - invalid credentials', { username })
|
||||||
return {
|
await updateService.setUserContext(null)
|
||||||
success: false,
|
throw new ValidationError('用户名或密码错误', 'VAL_INVALID_INPUT')
|
||||||
error: '用户名或密码错误'
|
}, 'auth:login')
|
||||||
}
|
}
|
||||||
} catch (error) {
|
)
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
|
||||||
log.error('Login error', { error: message })
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: `登录失败:${message}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Logout
|
* Logout
|
||||||
*/
|
*/
|
||||||
ipcMain.handle('auth:logout', async (): Promise<void> => {
|
ipcMain.handle(IPC_CHANNELS.AUTH_LOGOUT, async (): Promise<IpcResult<void>> => {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
const userInfo = sessionManager.getUserInfo()
|
const userInfo = sessionManager.getUserInfo()
|
||||||
log.info('User logout', { username: userInfo?.username })
|
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()
|
sessionManager.logout()
|
||||||
|
await updateService.setUserContext(null)
|
||||||
|
}, 'auth:logout')
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get current user
|
* Get current user
|
||||||
*/
|
*/
|
||||||
ipcMain.handle('auth:getCurrentUser', async (): Promise<CurrentUserResponse> => {
|
ipcMain.handle(
|
||||||
|
IPC_CHANNELS.AUTH_GET_CURRENT_USER,
|
||||||
|
async (): Promise<IpcResult<CurrentUserResponse>> => {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
const isAuthenticated = sessionManager.isAuthenticated()
|
const isAuthenticated = sessionManager.isAuthenticated()
|
||||||
const userInfo = sessionManager.getUserInfo()
|
const userInfo = sessionManager.getUserInfo()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
userInfo: userInfo ?? undefined
|
userInfo: userInfo ?? undefined
|
||||||
}
|
}
|
||||||
})
|
}, 'auth:getCurrentUser')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all users (for admin user selection)
|
* Get all users (for admin user selection)
|
||||||
*/
|
*/
|
||||||
ipcMain.handle('auth:getAllUsers', async (): Promise<UserInfo[]> => {
|
ipcMain.handle(IPC_CHANNELS.AUTH_GET_ALL_USERS, async (): Promise<IpcResult<UserInfo[]>> => {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
log.debug('Fetching all users for admin selection')
|
log.debug('Fetching all users for admin selection')
|
||||||
return await sessionManager.getAllUsers()
|
return await sessionManager.getAllUsers()
|
||||||
|
}, 'auth:getAllUsers')
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Switch user (admin only)
|
* Switch user (admin only)
|
||||||
*/
|
*/
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
'auth:switchUser',
|
IPC_CHANNELS.AUTH_SWITCH_USER,
|
||||||
async (_event, userInfo: UserInfo): Promise<UserSelectionResponse> => {
|
async (_event, userInfo: UserInfo): Promise<IpcResult<UserSelectionResponse>> => {
|
||||||
try {
|
return withErrorHandling(async () => {
|
||||||
log.info('User switch attempt', { targetUser: userInfo.username })
|
log.info('User switch attempt', { targetUser: userInfo.username })
|
||||||
const success = sessionManager.switchUser(userInfo)
|
const success = sessionManager.switchUser(userInfo)
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
const newUser = sessionManager.getUserInfo()
|
const newUser = sessionManager.getUserInfo()
|
||||||
log.info('User switch successful', { newUsername: newUser?.username })
|
log.info('User switch successful', { newUsername: newUser?.username })
|
||||||
|
await updateService.setUserContext(newUser?.userType ?? null)
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
userInfo: newUser ?? undefined
|
userInfo: newUser ?? undefined
|
||||||
@@ -208,25 +257,15 @@ export function registerAuthHandlers(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
log.warn('User switch failed')
|
log.warn('User switch failed')
|
||||||
return {
|
throw new ValidationError('用户切换失败', 'VAL_INVALID_INPUT')
|
||||||
success: false,
|
}, 'auth:switchUser')
|
||||||
error: '用户切换失败'
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
|
||||||
log.error('User switch error', { error: message })
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: `用户切换失败:${message}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if current user is admin
|
* Check if current user is admin
|
||||||
*/
|
*/
|
||||||
ipcMain.handle('auth:isAdmin', async (): Promise<boolean> => {
|
ipcMain.handle(IPC_CHANNELS.AUTH_IS_ADMIN, async (): Promise<IpcResult<boolean>> => {
|
||||||
return sessionManager.isAdmin()
|
return withErrorHandling(async () => sessionManager.isAdmin(), 'auth:isAdmin')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,70 +1,160 @@
|
|||||||
import { ipcMain } from 'electron'
|
import { ipcMain, type WebContents } from 'electron'
|
||||||
import { ErpAuthService } from '../services/erp/erp-auth'
|
import { ErpAuthService } from '../services/erp/erp-auth'
|
||||||
import { CleanerService } from '../services/erp/cleaner'
|
import { CleanerService } from '../services/erp/cleaner'
|
||||||
import { OrderNumberResolver } from '../services/erp/order-resolver'
|
import { OrderNumberResolver } from '../services/erp/order-resolver'
|
||||||
import { MySqlService } from '../services/database/mysql'
|
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 { createLogger } from '../services/logger'
|
||||||
|
import { logAudit } from '../services/logger/audit-logger'
|
||||||
import { withErrorHandling, type IpcResult } from './index'
|
import { withErrorHandling, type IpcResult } from './index'
|
||||||
import { ErpConnectionError, ValidationError, DatabaseQueryError } from '../types/errors'
|
import { ErpConnectionError, ValidationError, DatabaseQueryError } from '../types/errors'
|
||||||
import type { CleanerInput, CleanerResult } from '../types/cleaner.types'
|
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')
|
const log = createLogger('CleanerHandler')
|
||||||
|
|
||||||
/**
|
function sendProgress(
|
||||||
* Register IPC handlers for cleaner service
|
sender: WebContents,
|
||||||
*/
|
message: string,
|
||||||
export function registerCleanerHandlers(): void {
|
progress: number,
|
||||||
ipcMain.handle(
|
extra?: Partial<CleanerProgress>
|
||||||
'cleaner:run',
|
): void {
|
||||||
async (_event, input: CleanerInput): Promise<IpcResult<CleanerResult>> => {
|
|
||||||
return withErrorHandling(async () => {
|
|
||||||
let authService: ErpAuthService | null = null
|
|
||||||
let mysqlService: MySqlService | null = null
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check environment variables
|
const progressData: CleanerProgress = {
|
||||||
const erpUrl = process.env.ERP_URL || ''
|
message,
|
||||||
const erpUsername = process.env.ERP_USERNAME || ''
|
progress,
|
||||||
const erpPassword = process.env.ERP_PASSWORD || ''
|
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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
log.info('Config check', {
|
async function getDatabaseService(): Promise<MySqlService | SqlServerService> {
|
||||||
url: erpUrl ? 'configured' : 'EMPTY',
|
const configManager = ConfigManager.getInstance()
|
||||||
username: erpUsername ? 'configured' : 'EMPTY'
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!erpUrl || !erpUsername || !erpPassword) {
|
/**
|
||||||
|
* 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(
|
throw new ValidationError(
|
||||||
'ERP 配置不完整。请检查 .env 文件中的 ERP_URL, ERP_USERNAME, ERP_PASSWORD',
|
'ERP 配置不完整。请在设置中配置 ERP 用户名和密码',
|
||||||
'VAL_MISSING_REQUIRED'
|
'VAL_MISSING_REQUIRED'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve order numbers (convert productionIDs to 生产订单号)
|
return {
|
||||||
const mysqlConfig = {
|
url: erpUrl,
|
||||||
host: process.env.DB_MYSQL_HOST || 'localhost',
|
username: userConfig.username,
|
||||||
port: parseInt(process.env.DB_MYSQL_PORT || '3306', 10),
|
password: userConfig.password
|
||||||
user: process.env.DB_USERNAME || 'root',
|
}
|
||||||
password: process.env.DB_PASSWORD || '',
|
|
||||||
database: process.env.DB_NAME || ''
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info('Connecting to MySQL for order resolution...')
|
export function registerCleanerHandlers(): void {
|
||||||
mysqlService = new MySqlService(mysqlConfig)
|
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 {
|
try {
|
||||||
await mysqlService.connect()
|
// 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) {
|
} catch (error) {
|
||||||
throw new DatabaseQueryError(
|
throw new DatabaseQueryError(
|
||||||
'MySQL 连接失败',
|
'数据库连接失败',
|
||||||
'DB_CONNECTION_FAILED',
|
'DB_CONNECTION_FAILED',
|
||||||
error instanceof Error ? error : undefined
|
error instanceof Error ? error : undefined
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolver = new OrderNumberResolver(mysqlService)
|
const resolver = new OrderNumberResolver(dbService)
|
||||||
const mappings = await resolver.resolve(input.orderNumbers)
|
const mappings = await resolver.resolve(input.orderNumbers)
|
||||||
|
|
||||||
// Get valid order numbers and warnings
|
|
||||||
const validOrderNumbers = resolver.getValidOrderNumbers(mappings)
|
const validOrderNumbers = resolver.getValidOrderNumbers(mappings)
|
||||||
const warnings = resolver.getWarnings(mappings)
|
const warnings = resolver.getWarnings(mappings)
|
||||||
|
|
||||||
@@ -81,12 +171,11 @@ export function registerCleanerHandlers(): void {
|
|||||||
|
|
||||||
log.info('Resolved order numbers', { count: validOrderNumbers.length })
|
log.info('Resolved order numbers', { count: validOrderNumbers.length })
|
||||||
|
|
||||||
// Create auth service and login
|
|
||||||
authService = new ErpAuthService({
|
authService = new ErpAuthService({
|
||||||
url: erpUrl,
|
url: erpConfig.url,
|
||||||
username: erpUsername,
|
username: erpConfig.username,
|
||||||
password: erpPassword,
|
password: erpConfig.password,
|
||||||
headless: true
|
headless: input.headless ?? true
|
||||||
})
|
})
|
||||||
|
|
||||||
log.info('Logging in to ERP...')
|
log.info('Logging in to ERP...')
|
||||||
@@ -101,31 +190,142 @@ export function registerCleanerHandlers(): void {
|
|||||||
}
|
}
|
||||||
log.info('Login successful')
|
log.info('Login successful')
|
||||||
|
|
||||||
// Create cleaner service and run cleaning with resolved order numbers
|
// 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 cleaner = new CleanerService(authService)
|
||||||
|
|
||||||
const modifiedInput: CleanerInput = {
|
const modifiedInput: CleanerInput = {
|
||||||
...input,
|
...input,
|
||||||
orderNumbers: validOrderNumbers,
|
orderNumbers: validOrderNumbers,
|
||||||
onProgress: input.onProgress
|
onProgress: (message, progress, extra) => {
|
||||||
|
sendProgress(sender, message, progress ?? 0, extra)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info('Starting cleaning', { orderCount: validOrderNumbers.length })
|
log.info('Starting cleaning', {
|
||||||
|
orderCount: validOrderNumbers.length,
|
||||||
|
queryBatchSize: input.queryBatchSize ?? 100,
|
||||||
|
processConcurrency: input.processConcurrency ?? 1
|
||||||
|
})
|
||||||
const result = await cleaner.clean(modifiedInput)
|
const result = await cleaner.clean(modifiedInput)
|
||||||
|
|
||||||
// Add warnings to result errors if any
|
|
||||||
if (warnings.length > 0) {
|
if (warnings.length > 0) {
|
||||||
result.errors = [...warnings, ...result.errors]
|
result.errors = [...warnings, ...result.errors]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Send completion progress
|
||||||
|
sendProgress(sender, '清理完成', 100, {
|
||||||
|
phase: 'complete',
|
||||||
|
currentOrderIndex: totalOrders,
|
||||||
|
totalOrders,
|
||||||
|
currentMaterialIndex: 0,
|
||||||
|
totalMaterialsInOrder: 0
|
||||||
|
})
|
||||||
|
|
||||||
log.info('Cleaning completed', {
|
log.info('Cleaning completed', {
|
||||||
processedCount: result.ordersProcessed,
|
processedCount: result.ordersProcessed,
|
||||||
errorCount: result.errors.length
|
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
|
return result
|
||||||
} finally {
|
} finally {
|
||||||
// Clean up: close browser
|
|
||||||
if (authService) {
|
if (authService) {
|
||||||
try {
|
try {
|
||||||
await authService.close()
|
await authService.close()
|
||||||
@@ -137,13 +337,12 @@ export function registerCleanerHandlers(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up: disconnect MySQL
|
if (dbService) {
|
||||||
if (mysqlService) {
|
|
||||||
try {
|
try {
|
||||||
await mysqlService.disconnect()
|
await dbService.disconnect()
|
||||||
log.debug('MySQL disconnected')
|
log.debug('Database disconnected')
|
||||||
} catch (closeError) {
|
} catch (closeError) {
|
||||||
log.warn('Error disconnecting MySQL', {
|
log.warn('Error disconnecting database', {
|
||||||
error: closeError instanceof Error ? closeError.message : String(closeError)
|
error: closeError instanceof Error ? closeError.message : String(closeError)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -152,4 +351,18 @@ export function registerCleanerHandlers(): void {
|
|||||||
}, 'cleaner:run')
|
}, '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')
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,15 @@ import { ipcMain } from 'electron'
|
|||||||
import { MySqlService } from '../services/database/mysql'
|
import { MySqlService } from '../services/database/mysql'
|
||||||
import { SqlServerService } from '../services/database/sql-server'
|
import { SqlServerService } from '../services/database/sql-server'
|
||||||
import { createLogger } from '../services/logger'
|
import { createLogger } from '../services/logger'
|
||||||
import { DatabaseQueryError, ValidationError } from '../types/errors'
|
import { ValidationError } from '../types/errors'
|
||||||
import type {
|
import type {
|
||||||
MySqlConfig,
|
MySqlConfig,
|
||||||
MySqlQueryResult,
|
MySqlQueryResult,
|
||||||
SqlServerConfig,
|
SqlServerConfig,
|
||||||
SqlServerQueryResult
|
SqlServerQueryResult
|
||||||
} from '../types/ipc-api.types'
|
} from '../types/ipc-api.types'
|
||||||
|
import { IPC_CHANNELS } from '../../shared/ipc-channels'
|
||||||
|
import { withErrorHandling, type IpcResult } from './index'
|
||||||
|
|
||||||
const log = createLogger('DatabaseHandler')
|
const log = createLogger('DatabaseHandler')
|
||||||
|
|
||||||
@@ -17,6 +19,39 @@ const mysqlServices = new Map<string, MySqlService>()
|
|||||||
|
|
||||||
// Store SQL Server service instances per window/connection
|
// Store SQL Server service instances per window/connection
|
||||||
const sqlServerServices = new Map<string, SqlServerService>()
|
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
|
* Get or create MySQL service for a connection ID
|
||||||
@@ -65,29 +100,30 @@ function deleteSqlServerService(connectionId: string): void {
|
|||||||
*/
|
*/
|
||||||
export function registerDatabaseHandlers(): void {
|
export function registerDatabaseHandlers(): void {
|
||||||
// Connect to MySQL
|
// Connect to MySQL
|
||||||
ipcMain.handle('database:mysql:connect', async (event, config: MySqlConfig): Promise<void> => {
|
ipcMain.handle(
|
||||||
try {
|
IPC_CHANNELS.DATABASE_MYSQL_CONNECT,
|
||||||
|
async (event, config: MySqlConfig): Promise<IpcResult<void>> => {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
// Use window ID as connection identifier
|
// Use window ID as connection identifier
|
||||||
const windowId = (event.sender as { id: number }).id.toString()
|
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 })
|
log.info('Connecting to MySQL', { windowId })
|
||||||
const service = new MySqlService(config)
|
const service = new MySqlService(config)
|
||||||
await service.connect()
|
await service.connect()
|
||||||
setMySqlService(windowId, service)
|
setMySqlService(windowId, service)
|
||||||
log.info('MySQL connected', { windowId })
|
log.info('MySQL connected', { windowId })
|
||||||
} catch (error) {
|
}, 'database:mysql:connect')
|
||||||
const message = error instanceof Error ? error.message : 'Failed to connect to MySQL'
|
|
||||||
log.error('MySQL connection failed', { error: message })
|
|
||||||
throw new DatabaseQueryError(
|
|
||||||
message,
|
|
||||||
'DB_CONNECTION_FAILED',
|
|
||||||
error instanceof Error ? error : undefined
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
|
|
||||||
// Disconnect from MySQL
|
// Disconnect from MySQL
|
||||||
ipcMain.handle('database:mysql:disconnect', async (event): Promise<void> => {
|
ipcMain.handle(
|
||||||
try {
|
IPC_CHANNELS.DATABASE_MYSQL_DISCONNECT,
|
||||||
|
async (event): Promise<IpcResult<void>> => {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
const windowId = (event.sender as { id: number }).id.toString()
|
const windowId = (event.sender as { id: number }).id.toString()
|
||||||
const service = getMySqlService(windowId)
|
const service = getMySqlService(windowId)
|
||||||
if (service) {
|
if (service) {
|
||||||
@@ -95,29 +131,27 @@ export function registerDatabaseHandlers(): void {
|
|||||||
deleteMySqlService(windowId)
|
deleteMySqlService(windowId)
|
||||||
log.info('MySQL disconnected', { windowId })
|
log.info('MySQL disconnected', { windowId })
|
||||||
}
|
}
|
||||||
} catch (error) {
|
}, 'database:mysql:disconnect')
|
||||||
const message = error instanceof Error ? error.message : 'Failed to disconnect from MySQL'
|
|
||||||
log.error('MySQL disconnect failed', { error: message })
|
|
||||||
throw new DatabaseQueryError(
|
|
||||||
message,
|
|
||||||
'DB_CONNECTION_FAILED',
|
|
||||||
error instanceof Error ? error : undefined
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
|
|
||||||
// Check if MySQL is connected
|
// Check if MySQL is connected
|
||||||
ipcMain.handle('database:mysql:isConnected', async (event): Promise<boolean> => {
|
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 windowId = (event.sender as { id: number }).id.toString()
|
||||||
const service = getMySqlService(windowId)
|
const service = getMySqlService(windowId)
|
||||||
return service ? service.isConnected() : false
|
return service ? service.isConnected() : false
|
||||||
})
|
}, 'database:mysql:isConnected')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// Execute MySQL query
|
// Execute MySQL query
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
'database:mysql:query',
|
IPC_CHANNELS.DATABASE_MYSQL_QUERY,
|
||||||
async (event, sql: string, params?: unknown[]): Promise<MySqlQueryResult> => {
|
async (event, sql: string, params?: unknown[]): Promise<IpcResult<MySqlQueryResult>> => {
|
||||||
try {
|
return withErrorHandling(async () => {
|
||||||
const windowId = (event.sender as { id: number }).id.toString()
|
const windowId = (event.sender as { id: number }).id.toString()
|
||||||
const service = getMySqlService(windowId)
|
const service = getMySqlService(windowId)
|
||||||
|
|
||||||
@@ -130,44 +164,34 @@ export function registerDatabaseHandlers(): void {
|
|||||||
|
|
||||||
log.debug('Executing MySQL query', { windowId, sql: sql.substring(0, 100) })
|
log.debug('Executing MySQL query', { windowId, sql: sql.substring(0, 100) })
|
||||||
return await service.query(sql, params)
|
return await service.query(sql, params)
|
||||||
} catch (error) {
|
}, 'database:mysql:query')
|
||||||
const message = error instanceof Error ? error.message : 'MySQL query failed'
|
|
||||||
log.error('MySQL query failed', { error: message })
|
|
||||||
throw new DatabaseQueryError(
|
|
||||||
message,
|
|
||||||
'DB_QUERY_FAILED',
|
|
||||||
error instanceof Error ? error : undefined
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// Connect to SQL Server
|
// Connect to SQL Server
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
'database:sqlserver:connect',
|
IPC_CHANNELS.DATABASE_SQLSERVER_CONNECT,
|
||||||
async (event, config: SqlServerConfig): Promise<void> => {
|
async (event, config: SqlServerConfig): Promise<IpcResult<void>> => {
|
||||||
try {
|
return withErrorHandling(async () => {
|
||||||
const windowId = (event.sender as { id: number }).id.toString()
|
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 })
|
log.info('Connecting to SQL Server', { windowId })
|
||||||
const service = new SqlServerService(config)
|
const service = new SqlServerService(config)
|
||||||
await service.connect()
|
await service.connect()
|
||||||
setSqlServerService(windowId, service)
|
setSqlServerService(windowId, service)
|
||||||
log.info('SQL Server connected', { windowId })
|
log.info('SQL Server connected', { windowId })
|
||||||
} catch (error) {
|
}, 'database:sqlserver:connect')
|
||||||
const message = error instanceof Error ? error.message : 'Failed to connect to SQL Server'
|
|
||||||
log.error('SQL Server connection failed', { error: message })
|
|
||||||
throw new DatabaseQueryError(
|
|
||||||
message,
|
|
||||||
'DB_CONNECTION_FAILED',
|
|
||||||
error instanceof Error ? error : undefined
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// Disconnect from SQL Server
|
// Disconnect from SQL Server
|
||||||
ipcMain.handle('database:sqlserver:disconnect', async (event): Promise<void> => {
|
ipcMain.handle(
|
||||||
try {
|
IPC_CHANNELS.DATABASE_SQLSERVER_DISCONNECT,
|
||||||
|
async (event): Promise<IpcResult<void>> => {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
const windowId = (event.sender as { id: number }).id.toString()
|
const windowId = (event.sender as { id: number }).id.toString()
|
||||||
const service = getSqlServerService(windowId)
|
const service = getSqlServerService(windowId)
|
||||||
if (service) {
|
if (service) {
|
||||||
@@ -175,34 +199,31 @@ export function registerDatabaseHandlers(): void {
|
|||||||
deleteSqlServerService(windowId)
|
deleteSqlServerService(windowId)
|
||||||
log.info('SQL Server disconnected', { windowId })
|
log.info('SQL Server disconnected', { windowId })
|
||||||
}
|
}
|
||||||
} catch (error) {
|
}, 'database:sqlserver:disconnect')
|
||||||
const message =
|
|
||||||
error instanceof Error ? error.message : 'Failed to disconnect from SQL Server'
|
|
||||||
log.error('SQL Server disconnect failed', { error: message })
|
|
||||||
throw new DatabaseQueryError(
|
|
||||||
message,
|
|
||||||
'DB_CONNECTION_FAILED',
|
|
||||||
error instanceof Error ? error : undefined
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
|
|
||||||
// Check if SQL Server is connected
|
// Check if SQL Server is connected
|
||||||
ipcMain.handle('database:sqlserver:isConnected', async (event): Promise<boolean> => {
|
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 windowId = (event.sender as { id: number }).id.toString()
|
||||||
const service = getSqlServerService(windowId)
|
const service = getSqlServerService(windowId)
|
||||||
return service ? service.isConnected() : false
|
return service ? service.isConnected() : false
|
||||||
})
|
}, 'database:sqlserver:isConnected')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// Execute SQL Server query
|
// Execute SQL Server query
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
'database:sqlserver:query',
|
IPC_CHANNELS.DATABASE_SQLSERVER_QUERY,
|
||||||
async (
|
async (
|
||||||
event,
|
event,
|
||||||
sqlString: string,
|
sqlString: string,
|
||||||
params?: Record<string, unknown>
|
params?: Record<string, unknown>
|
||||||
): Promise<SqlServerQueryResult> => {
|
): Promise<IpcResult<SqlServerQueryResult>> => {
|
||||||
try {
|
return withErrorHandling(async () => {
|
||||||
const windowId = (event.sender as { id: number }).id.toString()
|
const windowId = (event.sender as { id: number }).id.toString()
|
||||||
const service = getSqlServerService(windowId)
|
const service = getSqlServerService(windowId)
|
||||||
|
|
||||||
@@ -214,16 +235,19 @@ export function registerDatabaseHandlers(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
log.debug('Executing SQL Server query', { windowId, sql: sqlString.substring(0, 100) })
|
log.debug('Executing SQL Server query', { windowId, sql: sqlString.substring(0, 100) })
|
||||||
return await service.query(sqlString, params)
|
|
||||||
} catch (error) {
|
// Use queryWithParams for named parameters, or query for no params
|
||||||
const message = error instanceof Error ? error.message : 'SQL Server query failed'
|
if (params && Object.keys(params).length > 0) {
|
||||||
log.error('SQL Server query failed', { error: message })
|
// Convert to the format expected by queryWithParams
|
||||||
throw new DatabaseQueryError(
|
const typedParams: Record<string, { value: unknown }> = {}
|
||||||
message,
|
for (const [key, value] of Object.entries(params)) {
|
||||||
'DB_QUERY_FAILED',
|
typedParams[key] = { value }
|
||||||
error instanceof Error ? error : undefined
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
return await service.queryWithParams(sqlString, typedParams)
|
||||||
|
} else {
|
||||||
|
return await service.query(sqlString)
|
||||||
|
}
|
||||||
|
}, 'database:sqlserver:query')
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,73 +1,133 @@
|
|||||||
import { ipcMain } from 'electron'
|
import { ipcMain, type WebContents } from 'electron'
|
||||||
import { ErpAuthService } from '../services/erp/erp-auth'
|
import { ErpAuthService } from '../services/erp/erp-auth'
|
||||||
import { ExtractorService } from '../services/erp/extractor'
|
import { ExtractorService } from '../services/erp/extractor'
|
||||||
import { OrderNumberResolver } from '../services/erp/order-resolver'
|
import { OrderNumberResolver } from '../services/erp/order-resolver'
|
||||||
import { MySqlService } from '../services/database/mysql'
|
import { create, type IDatabaseService } from '../services/database'
|
||||||
import { createLogger } from '../services/logger'
|
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 { withErrorHandling, type IpcResult } from './index'
|
||||||
import { ErpConnectionError, ValidationError, DatabaseQueryError } from '../types/errors'
|
import { ErpConnectionError, ValidationError, DatabaseQueryError } from '../types/errors'
|
||||||
import type { ExtractorInput, ExtractorResult } from '../types/extractor.types'
|
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')
|
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
|
* Register IPC handlers for extractor service
|
||||||
*/
|
*/
|
||||||
export function registerExtractorHandlers(): void {
|
export function registerExtractorHandlers(): void {
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
'extractor:run',
|
IPC_CHANNELS.EXTRACTOR_RUN,
|
||||||
async (_event, input: ExtractorInput): Promise<IpcResult<ExtractorResult>> => {
|
async (event, input: ExtractorInput): Promise<IpcResult<ExtractorResult>> => {
|
||||||
|
const sender = event.sender
|
||||||
|
|
||||||
return withErrorHandling(async () => {
|
return withErrorHandling(async () => {
|
||||||
let authService: ErpAuthService | null = null
|
let authService: ErpAuthService | null = null
|
||||||
let mysqlService: MySqlService | null = null
|
let dbService: IDatabaseService | null = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check environment variables
|
// Get ERP configuration from database for current user
|
||||||
const erpUrl = process.env.ERP_URL || ''
|
log.info('Fetching ERP configuration from database...')
|
||||||
const erpUsername = process.env.ERP_USERNAME || ''
|
const erpConfig = await getErpConfig()
|
||||||
const erpPassword = process.env.ERP_PASSWORD || ''
|
|
||||||
|
|
||||||
log.info('Config check', {
|
log.info('ERP config retrieved', {
|
||||||
url: erpUrl ? 'configured' : 'EMPTY',
|
url: erpConfig.url ? 'configured' : 'EMPTY',
|
||||||
username: erpUsername ? 'configured' : 'EMPTY'
|
username: erpConfig.username ? 'configured' : 'EMPTY'
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!erpUrl || !erpUsername || !erpPassword) {
|
// Create database service using factory
|
||||||
throw new ValidationError(
|
log.info('Connecting to database for order resolution...')
|
||||||
'ERP 配置不完整。请检查 .env 文件中的 ERP_URL, ERP_USERNAME, ERP_PASSWORD',
|
sendProgress(sender, '连接数据库...', 3.33, {
|
||||||
'VAL_MISSING_REQUIRED'
|
phase: 'login',
|
||||||
)
|
subProgress: { step: '连接数据库', current: 1, total: 3 }
|
||||||
}
|
})
|
||||||
|
sendLog(sender, 'system', '正在连接数据库...')
|
||||||
// Resolve order numbers (convert productionIDs to 生产订单号)
|
|
||||||
const mysqlConfig = {
|
|
||||||
host: process.env.DB_MYSQL_HOST || 'localhost',
|
|
||||||
port: parseInt(process.env.DB_MYSQL_PORT || '3306', 10),
|
|
||||||
user: process.env.DB_USERNAME || 'root',
|
|
||||||
password: process.env.DB_PASSWORD || '',
|
|
||||||
database: process.env.DB_NAME || ''
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info('Connecting to MySQL for order resolution...')
|
|
||||||
mysqlService = new MySqlService(mysqlConfig)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await mysqlService.connect()
|
dbService = await create()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new DatabaseQueryError(
|
throw new DatabaseQueryError(
|
||||||
'MySQL 连接失败',
|
'数据库连接失败',
|
||||||
'DB_CONNECTION_FAILED',
|
'DB_CONNECTION_FAILED',
|
||||||
error instanceof Error ? error : undefined
|
error instanceof Error ? error : undefined
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolver = new OrderNumberResolver(mysqlService)
|
// 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)
|
const mappings = await resolver.resolve(input.orderNumbers)
|
||||||
|
|
||||||
// Get valid order numbers and warnings
|
// Get valid order numbers and warnings
|
||||||
const validOrderNumbers = resolver.getValidOrderNumbers(mappings)
|
const validOrderNumbers = resolver.getValidOrderNumbers(mappings)
|
||||||
const warnings = resolver.getWarnings(mappings)
|
const warnings = resolver.getWarnings(mappings)
|
||||||
|
|
||||||
|
// Get deduplication report for detailed logging
|
||||||
|
const dedupReport = resolver.getDeduplicationReport(mappings)
|
||||||
|
|
||||||
if (warnings.length > 0) {
|
if (warnings.length > 0) {
|
||||||
log.warn('Resolution warnings', { warnings })
|
log.warn('Resolution warnings', { warnings })
|
||||||
}
|
}
|
||||||
@@ -81,18 +141,43 @@ export function registerExtractorHandlers(): void {
|
|||||||
|
|
||||||
log.info('Resolved order numbers', { count: validOrderNumbers.length })
|
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
|
// Create auth service and login
|
||||||
authService = new ErpAuthService({
|
authService = new ErpAuthService({
|
||||||
url: erpUrl,
|
url: erpConfig.url,
|
||||||
username: erpUsername,
|
username: erpConfig.username,
|
||||||
password: erpPassword,
|
password: erpConfig.password,
|
||||||
headless: true
|
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...')
|
log.info('Logging in to ERP...')
|
||||||
try {
|
try {
|
||||||
await authService.login()
|
await authService.login()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const errorMsg = error instanceof Error ? error.message : '未知错误'
|
||||||
|
sendLog(sender, 'error', `ERP 登录失败:${errorMsg}`)
|
||||||
throw new ErpConnectionError(
|
throw new ErpConnectionError(
|
||||||
'ERP 登录失败',
|
'ERP 登录失败',
|
||||||
'ERP_LOGIN_FAILED',
|
'ERP_LOGIN_FAILED',
|
||||||
@@ -100,6 +185,7 @@ export function registerExtractorHandlers(): void {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
log.info('Login successful')
|
log.info('Login successful')
|
||||||
|
sendLog(sender, 'success', 'ERP 登录成功')
|
||||||
|
|
||||||
// Create extractor service and run extraction with resolved order numbers
|
// Create extractor service and run extraction with resolved order numbers
|
||||||
const extractor = new ExtractorService(authService)
|
const extractor = new ExtractorService(authService)
|
||||||
@@ -107,7 +193,14 @@ export function registerExtractorHandlers(): void {
|
|||||||
|
|
||||||
const modifiedInput: ExtractorInput = {
|
const modifiedInput: ExtractorInput = {
|
||||||
...input,
|
...input,
|
||||||
orderNumbers: validOrderNumbers
|
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)
|
const result = await extractor.extract(modifiedInput)
|
||||||
@@ -122,6 +215,37 @@ export function registerExtractorHandlers(): void {
|
|||||||
errorCount: result.errors.length
|
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
|
return result
|
||||||
} finally {
|
} finally {
|
||||||
// Clean up: close browser
|
// Clean up: close browser
|
||||||
@@ -136,13 +260,13 @@ export function registerExtractorHandlers(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up: disconnect MySQL
|
// Clean up: disconnect database
|
||||||
if (mysqlService) {
|
if (dbService) {
|
||||||
try {
|
try {
|
||||||
await mysqlService.disconnect()
|
await dbService.disconnect()
|
||||||
log.debug('MySQL disconnected')
|
log.debug('Database disconnected')
|
||||||
} catch (closeError) {
|
} catch (closeError) {
|
||||||
log.warn('Error disconnecting MySQL', {
|
log.warn('Error disconnecting database', {
|
||||||
error: closeError instanceof Error ? closeError.message : String(closeError)
|
error: closeError instanceof Error ? closeError.message : String(closeError)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,64 +1,99 @@
|
|||||||
import { ipcMain } from 'electron'
|
import { app, ipcMain, shell } from 'electron'
|
||||||
import * as fs from 'fs/promises'
|
import * as fs from 'fs/promises'
|
||||||
import * as path from 'path'
|
import * as path from 'path'
|
||||||
import { createLogger } from '../services/logger'
|
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')
|
const log = createLogger('FileHandler')
|
||||||
|
|
||||||
/**
|
function getAllowedRoots(): string[] {
|
||||||
* Register IPC handlers for file operations
|
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 {
|
export function registerFileHandlers(): void {
|
||||||
// Read file content
|
ipcMain.handle(
|
||||||
ipcMain.handle('file:read', async (_event, filePath: string): Promise<string> => {
|
IPC_CHANNELS.FILE_READ,
|
||||||
try {
|
async (_event, filePath: string): Promise<IpcResult<string>> => {
|
||||||
log.debug('Reading file', { filePath })
|
return withErrorHandling(async () => {
|
||||||
return await fs.readFile(filePath, 'utf-8')
|
const safePath = normalizeAndValidatePath(filePath)
|
||||||
} catch (error) {
|
log.debug('Reading file', { filePath: safePath })
|
||||||
const message = error instanceof Error ? error.message : 'Failed to read file'
|
return await fs.readFile(safePath, 'utf-8')
|
||||||
log.error('Failed to read file', { filePath, error: message })
|
}, 'file:read')
|
||||||
throw new Error(message)
|
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
|
|
||||||
// Write content to file
|
ipcMain.handle(
|
||||||
ipcMain.handle('file:write', async (_event, filePath: string, content: string): Promise<void> => {
|
IPC_CHANNELS.FILE_WRITE,
|
||||||
try {
|
async (_event, filePath: string, content: string): Promise<IpcResult<void>> => {
|
||||||
log.debug('Writing file', { filePath })
|
return withErrorHandling(async () => {
|
||||||
// Ensure directory exists
|
const safePath = normalizeAndValidatePath(filePath)
|
||||||
const dir = path.dirname(filePath)
|
log.debug('Writing file', { filePath: safePath })
|
||||||
|
const dir = path.dirname(safePath)
|
||||||
await fs.mkdir(dir, { recursive: true })
|
await fs.mkdir(dir, { recursive: true })
|
||||||
await fs.writeFile(filePath, content, 'utf-8')
|
await fs.writeFile(safePath, content, 'utf-8')
|
||||||
} catch (error) {
|
}, 'file:write')
|
||||||
const message = error instanceof Error ? error.message : 'Failed to write file'
|
|
||||||
log.error('Failed to write file', { filePath, error: message })
|
|
||||||
throw new Error(message)
|
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
|
|
||||||
// Check if file exists
|
ipcMain.handle(
|
||||||
ipcMain.handle('file:exists', async (_event, filePath: string): Promise<boolean> => {
|
IPC_CHANNELS.FILE_EXISTS,
|
||||||
|
async (_event, filePath: string): Promise<IpcResult<boolean>> => {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const safePath = normalizeAndValidatePath(filePath)
|
||||||
try {
|
try {
|
||||||
await fs.access(filePath)
|
await fs.access(safePath)
|
||||||
return true
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
})
|
}, 'file:exists')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// List files in directory
|
ipcMain.handle(
|
||||||
ipcMain.handle('file:list', async (_event, dirPath: string): Promise<string[]> => {
|
IPC_CHANNELS.FILE_LIST,
|
||||||
try {
|
async (_event, dirPath: string): Promise<IpcResult<string[]>> => {
|
||||||
log.debug('Listing directory', { dirPath })
|
return withErrorHandling(async () => {
|
||||||
const entries = await fs.readdir(dirPath, { withFileTypes: true })
|
const safePath = normalizeAndValidatePath(dirPath)
|
||||||
|
log.debug('Listing directory', { dirPath: safePath })
|
||||||
|
const entries = await fs.readdir(safePath, { withFileTypes: true })
|
||||||
return entries
|
return entries
|
||||||
.filter((entry) => entry.isFile())
|
.filter((entry) => entry.isFile())
|
||||||
.map((entry) => entry.name)
|
.map((entry) => entry.name)
|
||||||
.sort()
|
.sort()
|
||||||
} catch (error) {
|
}, 'file:list')
|
||||||
const message = error instanceof Error ? error.message : 'Failed to list directory'
|
|
||||||
log.error('Failed to list directory', { dirPath, error: message })
|
|
||||||
throw new Error(message)
|
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
|
|
||||||
|
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')
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,13 @@ import { registerResolverHandlers } from './resolver-handler'
|
|||||||
import { registerAuthHandlers } from './auth-handler'
|
import { registerAuthHandlers } from './auth-handler'
|
||||||
import { registerValidationHandlers } from './validation-handler'
|
import { registerValidationHandlers } from './validation-handler'
|
||||||
import { registerSettingsHandlers } from './settings-handler'
|
import { registerSettingsHandlers } from './settings-handler'
|
||||||
import { createLogger } from '../services/logger'
|
import { registerMaterialTypeHandlers } from './material-type-handler'
|
||||||
|
import { registerUserErpConfigHandlers } from './user-erp-config-handler'
|
||||||
|
import { registerLoggerHandlers } from './logger-handler'
|
||||||
|
import { registerReportHandlers } from './report-handler'
|
||||||
|
import { registerUpdateHandlers } from './update-handler'
|
||||||
|
import { createLogger, logError } from '../services/logger'
|
||||||
|
import { serializeError, sanitizeError } from '../services/logger/error-utils'
|
||||||
import { getErrorMessage, getErrorCode, isBaseError } from '../types/errors'
|
import { getErrorMessage, getErrorCode, isBaseError } from '../types/errors'
|
||||||
|
|
||||||
const log = createLogger('IPC')
|
const log = createLogger('IPC')
|
||||||
@@ -26,8 +32,17 @@ export interface IpcResult<T = unknown> {
|
|||||||
code?: 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
|
* 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 handler - The async handler function to wrap
|
||||||
* @param context - The context name for logging
|
* @param context - The context name for logging
|
||||||
* @returns A wrapped handler that returns IpcResult
|
* @returns A wrapped handler that returns IpcResult
|
||||||
@@ -37,26 +52,40 @@ export function withErrorHandling<T>(
|
|||||||
context: string
|
context: string
|
||||||
): Promise<IpcResult<T>> {
|
): Promise<IpcResult<T>> {
|
||||||
return handler()
|
return handler()
|
||||||
.then((data) => {
|
.then((data): IpcResult<T> => {
|
||||||
log.debug(`[${context}] Handler completed successfully`)
|
log.debug(`[${context}] Handler completed successfully`)
|
||||||
return { success: true, data }
|
return ok(data)
|
||||||
})
|
})
|
||||||
.catch((error: unknown) => {
|
.catch((error: unknown) => {
|
||||||
const message = getErrorMessage(error)
|
const message = getErrorMessage(error)
|
||||||
const code = getErrorCode(error)
|
const code = getErrorCode(error)
|
||||||
|
|
||||||
if (isBaseError(error)) {
|
// Serialize error with full details
|
||||||
log.error(`[${context}] ${error.name}: ${message}`, { code, cause: error.cause?.message })
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
sanitizeError(serializeError(error))
|
||||||
} else {
|
} else {
|
||||||
log.error(`[${context}] Error: ${message}`, { code })
|
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
|
// Include stack trace in development
|
||||||
if (process.env.NODE_ENV !== 'production' && error instanceof Error) {
|
if (process.env.NODE_ENV !== 'production' && error instanceof Error) {
|
||||||
log.debug(`[${context}] Stack trace:`, { stack: error.stack })
|
log.debug(`[${context}] Stack trace: ${error.stack}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: false, error: message, code }
|
return fail<T>(message, code)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,5 +102,10 @@ export function registerIpcHandlers(): void {
|
|||||||
registerAuthHandlers()
|
registerAuthHandlers()
|
||||||
registerValidationHandlers()
|
registerValidationHandlers()
|
||||||
registerSettingsHandlers()
|
registerSettingsHandlers()
|
||||||
|
registerMaterialTypeHandlers()
|
||||||
|
registerUserErpConfigHandlers()
|
||||||
|
registerLoggerHandlers()
|
||||||
|
registerReportHandlers()
|
||||||
|
registerUpdateHandlers()
|
||||||
log.info('All IPC handlers registered')
|
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')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,11 +7,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ipcMain } from 'electron'
|
import { ipcMain } from 'electron'
|
||||||
import { MySqlService } from '../services/database/mysql'
|
import { create, type IDatabaseService } from '../services/database'
|
||||||
import { OrderNumberResolver } from '../services/erp/order-resolver'
|
import { OrderNumberResolver } from '../services/erp/order-resolver'
|
||||||
import { createLogger } from '../services/logger'
|
import { createLogger } from '../services/logger'
|
||||||
import { DatabaseQueryError } from '../types/errors'
|
|
||||||
import type { OrderMapping, ResolutionStats } from '../services/erp/order-resolver'
|
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')
|
const log = createLogger('ResolverHandler')
|
||||||
|
|
||||||
@@ -21,14 +22,6 @@ const log = createLogger('ResolverHandler')
|
|||||||
export interface ResolverInput {
|
export interface ResolverInput {
|
||||||
/** List of order numbers/productionIDs to resolve */
|
/** List of order numbers/productionIDs to resolve */
|
||||||
inputs: string[]
|
inputs: string[]
|
||||||
/** MySQL configuration (optional, uses default if not provided) */
|
|
||||||
mysqlConfig?: {
|
|
||||||
host: string
|
|
||||||
port: number
|
|
||||||
user: string
|
|
||||||
password: string
|
|
||||||
database: string
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -58,27 +51,17 @@ export function registerResolverHandlers(): void {
|
|||||||
* Converts productionIDs and 生产订单号 to production order numbers
|
* Converts productionIDs and 生产订单号 to production order numbers
|
||||||
*/
|
*/
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
'resolver:resolve',
|
IPC_CHANNELS.RESOLVER_RESOLVE,
|
||||||
async (_event, input: ResolverInput): Promise<ResolverResponse> => {
|
async (_event, input: ResolverInput): Promise<IpcResult<ResolverResponse>> => {
|
||||||
let mysqlService: MySqlService | null = null
|
let dbService: IDatabaseService | null = null
|
||||||
|
|
||||||
try {
|
return withErrorHandling(async () => {
|
||||||
// Use provided config or environment variables
|
// Create database service using factory
|
||||||
const mysqlConfig = input.mysqlConfig || {
|
log.info('Connecting to database for resolution', { inputCount: input.inputs.length })
|
||||||
host: process.env.DB_MYSQL_HOST || 'localhost',
|
dbService = await create()
|
||||||
port: parseInt(process.env.DB_MYSQL_PORT || '3306', 10),
|
|
||||||
user: process.env.DB_USERNAME || 'root',
|
|
||||||
password: process.env.DB_PASSWORD || '',
|
|
||||||
database: process.env.DB_NAME || ''
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create MySQL service
|
|
||||||
log.info('Connecting to MySQL for resolution', { inputCount: input.inputs.length })
|
|
||||||
mysqlService = new MySqlService(mysqlConfig)
|
|
||||||
await mysqlService.connect()
|
|
||||||
|
|
||||||
// Create resolver and resolve inputs
|
// Create resolver and resolve inputs
|
||||||
const resolver = new OrderNumberResolver(mysqlService)
|
const resolver = new OrderNumberResolver(dbService)
|
||||||
const mappings = await resolver.resolve(input.inputs)
|
const mappings = await resolver.resolve(input.inputs)
|
||||||
|
|
||||||
// Get valid order numbers and warnings
|
// Get valid order numbers and warnings
|
||||||
@@ -99,26 +82,19 @@ export function registerResolverHandlers(): void {
|
|||||||
warnings,
|
warnings,
|
||||||
stats
|
stats
|
||||||
}
|
}
|
||||||
} catch (error) {
|
}, 'resolver:resolve').finally(async () => {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
// Clean up database connection
|
||||||
log.error('Resolution failed', { error: message })
|
if (dbService) {
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: `解析失败:${message}`
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
// Clean up MySQL connection
|
|
||||||
if (mysqlService) {
|
|
||||||
try {
|
try {
|
||||||
await mysqlService.disconnect()
|
await dbService.disconnect()
|
||||||
log.debug('MySQL disconnected')
|
log.debug('Database disconnected')
|
||||||
} catch (closeError) {
|
} catch (closeError) {
|
||||||
log.warn('Error disconnecting MySQL', {
|
log.warn('Error disconnecting database', {
|
||||||
error: closeError instanceof Error ? closeError.message : String(closeError)
|
error: closeError instanceof Error ? closeError.message : String(closeError)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -126,19 +102,19 @@ export function registerResolverHandlers(): void {
|
|||||||
* Validate input format only (without database lookup)
|
* Validate input format only (without database lookup)
|
||||||
*/
|
*/
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
'resolver:validateFormat',
|
IPC_CHANNELS.RESOLVER_VALIDATE_FORMAT,
|
||||||
async (
|
async (
|
||||||
_event,
|
_event,
|
||||||
inputs: string[]
|
inputs: string[]
|
||||||
): Promise<{
|
): Promise<
|
||||||
success: boolean
|
IpcResult<Array<{ input: string; type: 'productionId' | 'orderNumber' | 'unknown' }>>
|
||||||
results?: Array<{ input: string; type: 'productionId' | 'orderNumber' | 'unknown' }>
|
> => {
|
||||||
error?: string
|
return withErrorHandling(async () => {
|
||||||
}> => {
|
// Create a mock resolver without database connection
|
||||||
try {
|
|
||||||
const resolver = new OrderNumberResolver({
|
const resolver = new OrderNumberResolver({
|
||||||
isConnected: () => false
|
isConnected: () => false,
|
||||||
} as MySqlService)
|
type: 'mysql'
|
||||||
|
} as IDatabaseService)
|
||||||
|
|
||||||
const results = inputs.map((input) => ({
|
const results = inputs.map((input) => ({
|
||||||
input,
|
input,
|
||||||
@@ -147,15 +123,8 @@ export function registerResolverHandlers(): void {
|
|||||||
|
|
||||||
log.debug('Format validation completed', { inputCount: inputs.length })
|
log.debug('Format validation completed', { inputCount: inputs.length })
|
||||||
|
|
||||||
return { success: true, results }
|
return results
|
||||||
} catch (error) {
|
}, 'resolver:validateFormat')
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
|
||||||
log.error('Format validation failed', { error: message })
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: `验证失败:${message}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,207 +1,114 @@
|
|||||||
/**
|
|
||||||
* Settings IPC Handler
|
|
||||||
*
|
|
||||||
* Provides IPC handlers for settings management:
|
|
||||||
* - Get/set settings
|
|
||||||
* - Reset to defaults
|
|
||||||
* - Test ERP connection
|
|
||||||
* - Test database connection
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { ipcMain } from 'electron'
|
import { ipcMain } from 'electron'
|
||||||
import { ConfigManager } from '../services/config/config-manager'
|
import { ConfigManager } from '../services/config/config-manager'
|
||||||
import { SessionManager } from '../services/user/session-manager'
|
import { SessionManager } from '../services/user/session-manager'
|
||||||
import { ErpAuthService } from '../services/erp/erp-auth'
|
import { UserErpConfigService } from '../services/user/user-erp-config-service'
|
||||||
import { MySqlService } from '../services/database/mysql'
|
import { MySqlService } from '../services/database/mysql'
|
||||||
import { SqlServerService } from '../services/database/sql-server'
|
import { SqlServerService } from '../services/database/sql-server'
|
||||||
import { createLogger } from '../services/logger'
|
import { createLogger } from '../services/logger'
|
||||||
import type {
|
import { logAudit } from '../services/logger/audit-logger'
|
||||||
SettingsData,
|
import type { UserType, ConnectionTestResult, SaveSettingsResult } from '../types/settings.types'
|
||||||
UserType,
|
import { IPC_CHANNELS } from '../../shared/ipc-channels'
|
||||||
ConnectionTestResult,
|
import { ValidationError } from '../types/errors'
|
||||||
SaveSettingsResult
|
import { withErrorHandling, type IpcResult } from './index'
|
||||||
} from '../types/settings.types'
|
import type { CleanerConfig } from '../types/config.schema'
|
||||||
|
|
||||||
const log = createLogger('SettingsHandler')
|
const log = createLogger('SettingsHandler')
|
||||||
|
|
||||||
/**
|
type ErpSettingsPayload = {
|
||||||
* Filter settings by user type
|
erp?: {
|
||||||
* Admin users get all settings, User users get limited settings
|
username?: string
|
||||||
*/
|
password?: string
|
||||||
function filterSettingsByUserType(settings: SettingsData, userType: UserType): SettingsData {
|
|
||||||
if (userType === 'Admin') {
|
|
||||||
return settings // Return all settings for Admin
|
|
||||||
}
|
|
||||||
|
|
||||||
// User users get limited settings
|
|
||||||
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,
|
|
||||||
// Include minimal required fields for other sections
|
|
||||||
database: settings.database,
|
|
||||||
extraction: settings.extraction,
|
|
||||||
validation: settings.validation,
|
|
||||||
ui: settings.ui
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Register IPC handlers for settings management
|
|
||||||
*/
|
|
||||||
export function registerSettingsHandlers(): void {
|
export function registerSettingsHandlers(): void {
|
||||||
const configManager = ConfigManager.getInstance()
|
const configManager = ConfigManager.getInstance()
|
||||||
const sessionManager = SessionManager.getInstance()
|
const sessionManager = SessionManager.getInstance()
|
||||||
|
const erpConfigService = UserErpConfigService.getInstance()
|
||||||
|
|
||||||
/**
|
ipcMain.handle(IPC_CHANNELS.SETTINGS_GET_USER_TYPE, async (): Promise<IpcResult<UserType>> => {
|
||||||
* Get current user type
|
return withErrorHandling(
|
||||||
*/
|
async () => (sessionManager.getUserType() as UserType) || 'Guest',
|
||||||
ipcMain.handle('settings:getUserType', async (): Promise<UserType> => {
|
'settings:getUserType'
|
||||||
return (sessionManager.getUserType() as UserType) || 'Guest'
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
|
||||||
* Get settings (filtered by user type)
|
|
||||||
*/
|
|
||||||
ipcMain.handle('settings:getSettings', async (): Promise<SettingsData> => {
|
|
||||||
const userType = (sessionManager.getUserType() as UserType) || 'Guest'
|
|
||||||
log.debug('Getting settings', { userType })
|
|
||||||
const settings = configManager.getAllSettings()
|
|
||||||
return filterSettingsByUserType(settings, userType)
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save settings (updated to use partial save)
|
|
||||||
*/
|
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
'settings:saveSettings',
|
IPC_CHANNELS.SETTINGS_GET_SETTINGS,
|
||||||
async (_event, settings: Partial<SettingsData>): Promise<SaveSettingsResult> => {
|
async (): Promise<IpcResult<{ erp: { username: string; password: string } }>> => {
|
||||||
try {
|
return withErrorHandling(async () => {
|
||||||
log.info('Saving settings', {
|
const userErpConfig = await erpConfigService.getCurrentUserErpConfig()
|
||||||
sections: Object.keys(settings)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Use partial save method
|
|
||||||
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 {
|
return {
|
||||||
success: false,
|
erp: {
|
||||||
error: result.error || '保存设置失败'
|
username: userErpConfig?.username || '',
|
||||||
}
|
password: userErpConfig?.password || ''
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
|
||||||
log.error('Error saving settings', { error: message })
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: `保存设置失败:${message}`
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}, 'settings:getSettings')
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
ipcMain.handle(
|
||||||
* Reset to default settings (Admin only)
|
IPC_CHANNELS.SETTINGS_SAVE_SETTINGS,
|
||||||
*/
|
async (_event, settings: ErpSettingsPayload): Promise<IpcResult<SaveSettingsResult>> => {
|
||||||
ipcMain.handle('settings:resetDefaults', async (): Promise<SaveSettingsResult> => {
|
return withErrorHandling(async () => {
|
||||||
try {
|
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()
|
const userType = sessionManager.getUserType()
|
||||||
if (userType !== 'Admin') {
|
if (userType !== 'Admin') {
|
||||||
log.warn('Non-admin user attempted to reset defaults', { userType })
|
throw new ValidationError('只有管理员可以恢复默认设置', 'VAL_INVALID_INPUT')
|
||||||
return { success: false, error: '只有管理员可以恢复默认设置' }
|
}
|
||||||
|
|
||||||
|
const success = await configManager.resetToDefaults()
|
||||||
|
if (!success) {
|
||||||
|
throw new ValidationError('恢复默认设置失败', 'VAL_INVALID_INPUT')
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info('Resetting settings to defaults')
|
|
||||||
configManager.resetToDefaults()
|
|
||||||
const success = await configManager.save()
|
|
||||||
if (success) {
|
|
||||||
log.info('Settings reset to defaults successfully')
|
|
||||||
return { success: true }
|
return { success: true }
|
||||||
} else {
|
}, 'settings:resetDefaults')
|
||||||
log.warn('Failed to reset settings to defaults')
|
|
||||||
return { success: false, error: '恢复默认设置失败' }
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
)
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
|
||||||
log.error('Error resetting settings', { error: message })
|
|
||||||
return { success: false, error: `恢复默认设置失败:${message}` }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
ipcMain.handle(
|
||||||
* Test ERP connection
|
IPC_CHANNELS.SETTINGS_TEST_DB_CONNECTION,
|
||||||
*/
|
async (): Promise<IpcResult<ConnectionTestResult>> => {
|
||||||
ipcMain.handle('settings:testErpConnection', async (): Promise<ConnectionTestResult> => {
|
return withErrorHandling(async () => {
|
||||||
try {
|
|
||||||
log.info('Testing ERP connection')
|
|
||||||
const settings = configManager.getAllSettings()
|
|
||||||
const erpConfig = settings.erp
|
|
||||||
|
|
||||||
if (!erpConfig.url || !erpConfig.username || !erpConfig.password) {
|
|
||||||
log.warn('ERP connection test failed - missing configuration')
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: '请先配置 ERP URL、用户名和密码'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create ERP auth service and try to login
|
|
||||||
const erpAuthService = new ErpAuthService(erpConfig)
|
|
||||||
|
|
||||||
try {
|
|
||||||
await erpAuthService.login()
|
|
||||||
// Login successful, close browser
|
|
||||||
await erpAuthService.close()
|
|
||||||
log.info('ERP connection test successful')
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: 'ERP 连接测试成功!'
|
|
||||||
}
|
|
||||||
} catch (loginError) {
|
|
||||||
const errorMessage = loginError instanceof Error ? loginError.message : '登录失败'
|
|
||||||
log.error('ERP login failed', { error: errorMessage })
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: `ERP 连接测试失败:${errorMessage}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
|
||||||
log.error('ERP connection test error', { error: message })
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: `ERP 连接测试失败:${message}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test database connection
|
|
||||||
*/
|
|
||||||
ipcMain.handle('settings:testDbConnection', async (): Promise<ConnectionTestResult> => {
|
|
||||||
try {
|
|
||||||
log.info('Testing database connection')
|
log.info('Testing database connection')
|
||||||
const settings = configManager.getAllSettings()
|
const config = configManager.getConfig()
|
||||||
const dbConfig = settings.database
|
const dbType = config.database.activeType
|
||||||
|
|
||||||
if (dbConfig.dbType === 'mysql') {
|
if (dbType === 'mysql') {
|
||||||
// Test MySQL connection
|
const dbConfig = config.database.mysql
|
||||||
if (!dbConfig.mysqlHost || !dbConfig.database || !dbConfig.username) {
|
if (!dbConfig.host || !dbConfig.database || !dbConfig.username) {
|
||||||
log.warn('MySQL connection test failed - missing configuration')
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: '请先配置 MySQL 主机、数据库名和用户名'
|
message: '请先配置 MySQL 主机、数据库名和用户名'
|
||||||
@@ -209,8 +116,8 @@ export function registerSettingsHandlers(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mysqlService = new MySqlService({
|
const mysqlService = new MySqlService({
|
||||||
host: dbConfig.mysqlHost,
|
host: dbConfig.host,
|
||||||
port: dbConfig.mysqlPort,
|
port: dbConfig.port,
|
||||||
user: dbConfig.username,
|
user: dbConfig.username,
|
||||||
password: dbConfig.password,
|
password: dbConfig.password,
|
||||||
database: dbConfig.database
|
database: dbConfig.database
|
||||||
@@ -219,23 +126,21 @@ export function registerSettingsHandlers(): void {
|
|||||||
try {
|
try {
|
||||||
await mysqlService.connect()
|
await mysqlService.connect()
|
||||||
await mysqlService.disconnect()
|
await mysqlService.disconnect()
|
||||||
log.info('MySQL connection test successful')
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: 'MySQL 数据库连接测试成功!'
|
message: 'MySQL 数据库连接测试成功!'
|
||||||
}
|
}
|
||||||
} catch (connError) {
|
} catch (error) {
|
||||||
const errorMessage = connError instanceof Error ? connError.message : '连接失败'
|
const message = error instanceof Error ? error.message : '连接失败'
|
||||||
log.error('MySQL connection failed', { error: errorMessage })
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: `MySQL 数据库连接测试失败:${errorMessage}`
|
message: `MySQL 数据库连接测试失败:${message}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
// Test SQL Server connection
|
|
||||||
|
const dbConfig = config.database.sqlserver
|
||||||
if (!dbConfig.server || !dbConfig.database || !dbConfig.username) {
|
if (!dbConfig.server || !dbConfig.database || !dbConfig.username) {
|
||||||
log.warn('SQL Server connection test failed - missing configuration')
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: '请先配置 SQL Server 服务器、数据库名和用户名'
|
message: '请先配置 SQL Server 服务器、数据库名和用户名'
|
||||||
@@ -244,39 +149,52 @@ export function registerSettingsHandlers(): void {
|
|||||||
|
|
||||||
const sqlServerService = new SqlServerService({
|
const sqlServerService = new SqlServerService({
|
||||||
server: dbConfig.server,
|
server: dbConfig.server,
|
||||||
port: 1433, // Default SQL Server port
|
port: dbConfig.port,
|
||||||
user: dbConfig.username,
|
user: dbConfig.username,
|
||||||
password: dbConfig.password,
|
password: dbConfig.password,
|
||||||
database: dbConfig.database,
|
database: dbConfig.database,
|
||||||
options: {
|
options: {
|
||||||
trustServerCertificate: true
|
trustServerCertificate: dbConfig.trustServerCertificate
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sqlServerService.connect()
|
await sqlServerService.connect()
|
||||||
await sqlServerService.disconnect()
|
await sqlServerService.disconnect()
|
||||||
log.info('SQL Server connection test successful')
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: 'SQL Server 数据库连接测试成功!'
|
message: 'SQL Server 数据库连接测试成功!'
|
||||||
}
|
}
|
||||||
} catch (connError) {
|
|
||||||
const errorMessage = connError instanceof Error ? connError.message : '连接失败'
|
|
||||||
log.error('SQL Server connection failed', { error: errorMessage })
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: `SQL Server 数据库连接测试失败:${errorMessage}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
const message = error instanceof Error ? error.message : '连接失败'
|
||||||
log.error('Database connection test error', { error: message })
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: `数据库连接测试失败:${message}`
|
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')
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
55
src/main/ipc/update-handler.ts
Normal file
55
src/main/ipc/update-handler.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { ipcMain } from 'electron'
|
||||||
|
import { IPC_CHANNELS } from '../../shared/ipc-channels'
|
||||||
|
import { withErrorHandling, type IpcResult } from './index'
|
||||||
|
import { UpdateService } from '../services/update/update-service'
|
||||||
|
import type {
|
||||||
|
DownloadReleaseRequest,
|
||||||
|
UpdateDialogCatalog,
|
||||||
|
UpdateStatus
|
||||||
|
} from '../types/update.types'
|
||||||
|
|
||||||
|
export function registerUpdateHandlers(): void {
|
||||||
|
const updateService = UpdateService.getInstance()
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.UPDATE_GET_STATUS, async (): Promise<IpcResult<UpdateStatus>> => {
|
||||||
|
return withErrorHandling(async () => updateService.getStatus(), 'update:getStatus')
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.UPDATE_CHECK_NOW, async (): Promise<IpcResult<UpdateStatus>> => {
|
||||||
|
return withErrorHandling(async () => updateService.checkForUpdates(), 'update:checkNow')
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
IPC_CHANNELS.UPDATE_GET_CATALOG,
|
||||||
|
async (): Promise<IpcResult<UpdateDialogCatalog>> => {
|
||||||
|
return withErrorHandling(async () => updateService.getCatalog(), 'update:getCatalog')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
IPC_CHANNELS.UPDATE_GET_CHANGELOG,
|
||||||
|
async (_event, request: DownloadReleaseRequest): Promise<IpcResult<string>> => {
|
||||||
|
return withErrorHandling(
|
||||||
|
async () => updateService.getChangelog(request),
|
||||||
|
'update:getChangelog'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
IPC_CHANNELS.UPDATE_DOWNLOAD_RELEASE,
|
||||||
|
async (_event, request: DownloadReleaseRequest): Promise<IpcResult<UpdateStatus>> => {
|
||||||
|
return withErrorHandling(
|
||||||
|
async () => updateService.downloadRelease(request),
|
||||||
|
'update:downloadRelease'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.UPDATE_INSTALL_DOWNLOADED, async (): Promise<IpcResult<void>> => {
|
||||||
|
return withErrorHandling(
|
||||||
|
async () => updateService.installDownloadedRelease(),
|
||||||
|
'update:installDownloaded'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
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')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import { MySqlService } from '../services/database/mysql'
|
|||||||
import { SqlServerService } from '../services/database/sql-server'
|
import { SqlServerService } from '../services/database/sql-server'
|
||||||
import { MaterialsToBeDeletedDAO } from '../services/database/materials-to-be-deleted-dao'
|
import { MaterialsToBeDeletedDAO } from '../services/database/materials-to-be-deleted-dao'
|
||||||
import { DiscreteMaterialPlanDAO } from '../services/database/discrete-material-plan-dao'
|
import { DiscreteMaterialPlanDAO } from '../services/database/discrete-material-plan-dao'
|
||||||
|
import { ConfigManager } from '../services/config/config-manager'
|
||||||
import { createLogger } from '../services/logger'
|
import { createLogger } from '../services/logger'
|
||||||
import type {
|
import type {
|
||||||
ValidationRequest,
|
ValidationRequest,
|
||||||
@@ -22,6 +23,7 @@ import type {
|
|||||||
ValidationResult,
|
ValidationResult,
|
||||||
MaterialRecordSummary
|
MaterialRecordSummary
|
||||||
} from '../types/validation.types'
|
} from '../types/validation.types'
|
||||||
|
import { IPC_CHANNELS } from '../../shared/ipc-channels'
|
||||||
|
|
||||||
const log = createLogger('ValidationHandler')
|
const log = createLogger('ValidationHandler')
|
||||||
|
|
||||||
@@ -29,57 +31,61 @@ const log = createLogger('ValidationHandler')
|
|||||||
* Shared state for Production IDs from extractor page
|
* Shared state for Production IDs from extractor page
|
||||||
* This is a simple in-memory store for sharing Production IDs between pages
|
* This is a simple in-memory store for sharing Production IDs between pages
|
||||||
*/
|
*/
|
||||||
const sharedProductionIds = new Set<string>()
|
const sharedProductionIdsBySender = new Map<number, Set<string>>()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set shared Production IDs
|
* Set shared Production IDs
|
||||||
*/
|
*/
|
||||||
export function setSharedProductionIds(ids: string[]): void {
|
export function setSharedProductionIds(senderId: number, ids: string[]): void {
|
||||||
sharedProductionIds.clear()
|
sharedProductionIdsBySender.set(senderId, new Set(ids))
|
||||||
ids.forEach((id) => sharedProductionIds.add(id))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get shared Production IDs
|
* Get shared Production IDs
|
||||||
*/
|
*/
|
||||||
export function getSharedProductionIds(): string[] {
|
export function getSharedProductionIds(senderId: number): string[] {
|
||||||
return [...sharedProductionIds]
|
const senderSet = sharedProductionIdsBySender.get(senderId)
|
||||||
|
return senderSet ? [...senderSet] : []
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear shared Production IDs
|
* Clear shared Production IDs
|
||||||
*/
|
*/
|
||||||
export function clearSharedProductionIds(): void {
|
export function clearSharedProductionIds(senderId: number): void {
|
||||||
sharedProductionIds.clear()
|
sharedProductionIdsBySender.delete(senderId)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get database service for validation operations (MySQL or SQL Server)
|
* Get database service for validation operations (MySQL or SQL Server)
|
||||||
*/
|
*/
|
||||||
async function getValidationDatabaseService(): Promise<MySqlService | SqlServerService> {
|
async function getValidationDatabaseService(): Promise<MySqlService | SqlServerService> {
|
||||||
const dbType = process.env.DB_TYPE?.toLowerCase()
|
const configManager = ConfigManager.getInstance()
|
||||||
|
const config = configManager.getConfig()
|
||||||
|
const dbType = configManager.getDatabaseType()
|
||||||
|
|
||||||
if (dbType === 'sqlserver' || dbType === 'mssql') {
|
if (dbType === 'sqlserver') {
|
||||||
|
const dbConfig = config.database.sqlserver
|
||||||
const sqlServerService = new SqlServerService({
|
const sqlServerService = new SqlServerService({
|
||||||
server: process.env.DB_SERVER || 'localhost',
|
server: dbConfig.server,
|
||||||
port: parseInt(process.env.DB_SQLSERVER_PORT || '1433', 10),
|
port: dbConfig.port,
|
||||||
user: process.env.DB_USERNAME || 'sa',
|
user: dbConfig.username,
|
||||||
password: process.env.DB_PASSWORD || '',
|
password: dbConfig.password,
|
||||||
database: process.env.DB_NAME || '',
|
database: dbConfig.database,
|
||||||
options: {
|
options: {
|
||||||
encrypt: process.env.DB_TRUST_SERVER_CERTIFICATE === 'yes',
|
encrypt: false,
|
||||||
trustServerCertificate: process.env.DB_TRUST_SERVER_CERTIFICATE === 'yes'
|
trustServerCertificate: dbConfig.trustServerCertificate
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
await sqlServerService.connect()
|
await sqlServerService.connect()
|
||||||
return sqlServerService
|
return sqlServerService
|
||||||
} else {
|
} else {
|
||||||
|
const dbConfig = config.database.mysql
|
||||||
const mysqlService = new MySqlService({
|
const mysqlService = new MySqlService({
|
||||||
host: process.env.DB_MYSQL_HOST || 'localhost',
|
host: dbConfig.host,
|
||||||
port: parseInt(process.env.DB_MYSQL_PORT || '3306', 10),
|
port: dbConfig.port,
|
||||||
user: process.env.DB_USERNAME || 'root',
|
user: dbConfig.username,
|
||||||
password: process.env.DB_PASSWORD || '',
|
password: dbConfig.password,
|
||||||
database: process.env.DB_NAME || ''
|
database: dbConfig.database
|
||||||
})
|
})
|
||||||
await mysqlService.connect()
|
await mysqlService.connect()
|
||||||
return mysqlService
|
return mysqlService
|
||||||
@@ -93,8 +99,10 @@ async function getValidationDatabaseService(): Promise<MySqlService | SqlServerS
|
|||||||
* dbo_MaterialsToBeDeleted -> [dbo].[MaterialsToBeDeleted]
|
* dbo_MaterialsToBeDeleted -> [dbo].[MaterialsToBeDeleted]
|
||||||
*/
|
*/
|
||||||
function getTableName(mysqlTableName: string): string {
|
function getTableName(mysqlTableName: string): string {
|
||||||
const dbType = process.env.DB_TYPE?.toLowerCase()
|
const configManager = ConfigManager.getInstance()
|
||||||
if (dbType === 'sqlserver' || dbType === 'mssql') {
|
const dbType = configManager.getDatabaseType()
|
||||||
|
|
||||||
|
if (dbType === 'sqlserver') {
|
||||||
// Find the FIRST underscore to split schema and table name
|
// Find the FIRST underscore to split schema and table name
|
||||||
// This handles patterns like: schema_tablename
|
// This handles patterns like: schema_tablename
|
||||||
const firstUnderscoreIndex = mysqlTableName.indexOf('_')
|
const firstUnderscoreIndex = mysqlTableName.indexOf('_')
|
||||||
@@ -145,8 +153,9 @@ async function getSourceNumbersFromInputs(
|
|||||||
): Promise<string[]> {
|
): Promise<string[]> {
|
||||||
const productionIds: string[] = []
|
const productionIds: string[] = []
|
||||||
const orderNumbers: string[] = []
|
const orderNumbers: string[] = []
|
||||||
const dbType = process.env.DB_TYPE?.toLowerCase()
|
const configManager = ConfigManager.getInstance()
|
||||||
const isSqlServer = dbType === 'sqlserver' || dbType === 'mssql'
|
const dbType = configManager.getDatabaseType()
|
||||||
|
const isSqlServer = dbType === 'sqlserver'
|
||||||
|
|
||||||
for (const item of inputs) {
|
for (const item of inputs) {
|
||||||
const type = identifyInputType(item)
|
const type = identifyInputType(item)
|
||||||
@@ -162,13 +171,18 @@ async function getSourceNumbersFromInputs(
|
|||||||
// Column name: 生产订单号 (SourceNumber)
|
// Column name: 生产订单号 (SourceNumber)
|
||||||
if (productionIds.length > 0) {
|
if (productionIds.length > 0) {
|
||||||
const contractTableName = getTableName('productionContractData_26年压力表合同数据')
|
const contractTableName = getTableName('productionContractData_26年压力表合同数据')
|
||||||
|
const batchSize = 2000
|
||||||
|
|
||||||
if (isSqlServer) {
|
if (isSqlServer) {
|
||||||
const sql = require('mssql')
|
const sql = require('mssql')
|
||||||
const placeholders = productionIds.map((_, idx) => `@p${idx}`).join(',')
|
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 }> = {}
|
const params: Record<string, { value: string; type: any }> = {}
|
||||||
|
|
||||||
productionIds.forEach((id, idx) => {
|
batch.forEach((id, idx) => {
|
||||||
params[`p${idx}`] = { value: id, type: sql.NVarChar }
|
params[`p${idx}`] = { value: id, type: sql.NVarChar }
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -182,17 +196,27 @@ async function getSourceNumbersFromInputs(
|
|||||||
params
|
params
|
||||||
)
|
)
|
||||||
const dbOrderNumbers = contractResult.rows.map((row) => row.生产订单号 as string)
|
const dbOrderNumbers = contractResult.rows.map((row) => row.生产订单号 as string)
|
||||||
orderNumbers.push(...dbOrderNumbers)
|
allOrderNumbers.push(...dbOrderNumbers)
|
||||||
|
}
|
||||||
|
|
||||||
|
orderNumbers.push(...allOrderNumbers)
|
||||||
} else {
|
} else {
|
||||||
const placeholders = productionIds.map(() => '?').join(',')
|
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 = `
|
const contractSql = `
|
||||||
SELECT DISTINCT 生产订单号
|
SELECT DISTINCT 生产订单号
|
||||||
FROM ${contractTableName}
|
FROM ${contractTableName}
|
||||||
WHERE 总排号 IN (${placeholders})
|
WHERE 总排号 IN (${placeholders})
|
||||||
`
|
`
|
||||||
const contractResult = await (dbService as MySqlService).query(contractSql, productionIds)
|
const contractResult = await (dbService as MySqlService).query(contractSql, batch)
|
||||||
const dbOrderNumbers = contractResult.rows.map((row) => row.生产订单号 as string)
|
const dbOrderNumbers = contractResult.rows.map((row) => row.生产订单号 as string)
|
||||||
orderNumbers.push(...dbOrderNumbers)
|
allOrderNumbers.push(...dbOrderNumbers)
|
||||||
|
}
|
||||||
|
|
||||||
|
orderNumbers.push(...allOrderNumbers)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,8 +234,8 @@ export function registerValidationHandlers(): void {
|
|||||||
* Run material validation from database
|
* Run material validation from database
|
||||||
*/
|
*/
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
'validation:validate',
|
IPC_CHANNELS.VALIDATION_VALIDATE,
|
||||||
async (_event, request: ValidationRequest): Promise<ValidationResponse> => {
|
async (event, request: ValidationRequest): Promise<ValidationResponse> => {
|
||||||
let dbService: MySqlService | SqlServerService | null = null
|
let dbService: MySqlService | SqlServerService | null = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -240,8 +264,9 @@ export function registerValidationHandlers(): void {
|
|||||||
|
|
||||||
// Connect to database
|
// Connect to database
|
||||||
dbService = await getValidationDatabaseService()
|
dbService = await getValidationDatabaseService()
|
||||||
const dbType = process.env.DB_TYPE?.toLowerCase()
|
const configManager = ConfigManager.getInstance()
|
||||||
const isSqlServer = dbType === 'sqlserver' || dbType === 'mssql'
|
const dbType = configManager.getDatabaseType()
|
||||||
|
const isSqlServer = dbType === 'sqlserver'
|
||||||
|
|
||||||
let sourceNumbers: string[] | null = null
|
let sourceNumbers: string[] | null = null
|
||||||
|
|
||||||
@@ -249,7 +274,7 @@ export function registerValidationHandlers(): void {
|
|||||||
if (request.mode === 'database_filtered') {
|
if (request.mode === 'database_filtered') {
|
||||||
if (request.useSharedProductionIds) {
|
if (request.useSharedProductionIds) {
|
||||||
// Use shared Production IDs from extractor page
|
// Use shared Production IDs from extractor page
|
||||||
const sharedIds = getSharedProductionIds()
|
const sharedIds = getSharedProductionIds(event.sender.id)
|
||||||
log.info(`Using ${sharedIds.length} shared Production IDs`)
|
log.info(`Using ${sharedIds.length} shared Production IDs`)
|
||||||
|
|
||||||
if (sharedIds.length === 0) {
|
if (sharedIds.length === 0) {
|
||||||
@@ -266,12 +291,40 @@ export function registerValidationHandlers(): void {
|
|||||||
|
|
||||||
sourceNumbers = await getSourceNumbersFromInputs(sharedIds, dbService)
|
sourceNumbers = await getSourceNumbersFromInputs(sharedIds, dbService)
|
||||||
log.info(`Got ${sourceNumbers.length} source numbers from shared Production IDs`)
|
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) {
|
} else if (request.productionIdFile) {
|
||||||
// Read from file
|
// Read from file
|
||||||
const inputs = readProductionIds(request.productionIdFile)
|
const inputs = readProductionIds(request.productionIdFile)
|
||||||
log.info(`Read ${inputs.length} inputs from file`)
|
log.info(`Read ${inputs.length} inputs from file`)
|
||||||
sourceNumbers = await getSourceNumbersFromInputs(inputs, dbService)
|
sourceNumbers = await getSourceNumbersFromInputs(inputs, dbService)
|
||||||
log.info(`Got ${sourceNumbers.length} source numbers`)
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,7 +344,7 @@ export function registerValidationHandlers(): void {
|
|||||||
if (materialRecords.length === 0) {
|
if (materialRecords.length === 0) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: 'No material records found',
|
error: '未找到物料记录。请检查数据库中是否有对应订单的物料数据。',
|
||||||
stats: {
|
stats: {
|
||||||
totalRecords: 0,
|
totalRecords: 0,
|
||||||
matchedCount: 0,
|
matchedCount: 0,
|
||||||
@@ -421,7 +474,7 @@ export function registerValidationHandlers(): void {
|
|||||||
* Upsert batch materials to MaterialsToBeDeleted
|
* Upsert batch materials to MaterialsToBeDeleted
|
||||||
*/
|
*/
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
'materials:upsertBatch',
|
IPC_CHANNELS.MATERIALS_UPSERT_BATCH,
|
||||||
async (_event, request: MaterialUpsertBatchRequest): Promise<MaterialOperationResponse> => {
|
async (_event, request: MaterialUpsertBatchRequest): Promise<MaterialOperationResponse> => {
|
||||||
try {
|
try {
|
||||||
const dao = new MaterialsToBeDeletedDAO()
|
const dao = new MaterialsToBeDeletedDAO()
|
||||||
@@ -448,7 +501,7 @@ export function registerValidationHandlers(): void {
|
|||||||
* Delete materials by material codes
|
* Delete materials by material codes
|
||||||
*/
|
*/
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
'materials:delete',
|
IPC_CHANNELS.MATERIALS_DELETE,
|
||||||
async (_event, request: MaterialDeleteRequest): Promise<MaterialOperationResponse> => {
|
async (_event, request: MaterialDeleteRequest): Promise<MaterialOperationResponse> => {
|
||||||
try {
|
try {
|
||||||
const dao = new MaterialsToBeDeletedDAO()
|
const dao = new MaterialsToBeDeletedDAO()
|
||||||
@@ -472,7 +525,9 @@ export function registerValidationHandlers(): void {
|
|||||||
/**
|
/**
|
||||||
* Get unique manager names
|
* Get unique manager names
|
||||||
*/
|
*/
|
||||||
ipcMain.handle('materials:getManagers', async (_event): Promise<{ managers: string[] }> => {
|
ipcMain.handle(
|
||||||
|
IPC_CHANNELS.MATERIALS_GET_MANAGERS,
|
||||||
|
async (_event): Promise<{ managers: string[] }> => {
|
||||||
try {
|
try {
|
||||||
const dao = new MaterialsToBeDeletedDAO()
|
const dao = new MaterialsToBeDeletedDAO()
|
||||||
const managers = await dao.getManagers()
|
const managers = await dao.getManagers()
|
||||||
@@ -483,20 +538,46 @@ export function registerValidationHandlers(): void {
|
|||||||
})
|
})
|
||||||
return { managers: [] }
|
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
|
* Get materials by manager
|
||||||
*/
|
*/
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
'materials:getByManager',
|
IPC_CHANNELS.MATERIALS_GET_BY_MANAGER,
|
||||||
async (_event, managerName: string): Promise<{ materials: MaterialRecordSummary[] }> => {
|
async (_event, managerName: string): Promise<{ materials: MaterialRecordSummary[] }> => {
|
||||||
let dbService: MySqlService | SqlServerService | null = null
|
let dbService: MySqlService | SqlServerService | null = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
dbService = await getValidationDatabaseService()
|
dbService = await getValidationDatabaseService()
|
||||||
const dbType = process.env.DB_TYPE?.toLowerCase()
|
const configManager = ConfigManager.getInstance()
|
||||||
const isSqlServer = dbType === 'sqlserver' || dbType === 'mssql'
|
const dbType = configManager.getDatabaseType()
|
||||||
|
const isSqlServer = dbType === 'sqlserver'
|
||||||
|
|
||||||
const dao = new MaterialsToBeDeletedDAO()
|
const dao = new MaterialsToBeDeletedDAO()
|
||||||
const materials = await dao.getMaterialsByManager(managerName)
|
const materials = await dao.getMaterialsByManager(managerName)
|
||||||
@@ -567,14 +648,15 @@ export function registerValidationHandlers(): void {
|
|||||||
* Get all material records
|
* Get all material records
|
||||||
*/
|
*/
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
'materials:getAll',
|
IPC_CHANNELS.MATERIALS_GET_ALL,
|
||||||
async (_event): Promise<{ materials: MaterialRecordSummary[] }> => {
|
async (_event): Promise<{ materials: MaterialRecordSummary[] }> => {
|
||||||
let dbService: MySqlService | SqlServerService | null = null
|
let dbService: MySqlService | SqlServerService | null = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
dbService = await getValidationDatabaseService()
|
dbService = await getValidationDatabaseService()
|
||||||
const dbType = process.env.DB_TYPE?.toLowerCase()
|
const configManager = ConfigManager.getInstance()
|
||||||
const isSqlServer = dbType === 'sqlserver' || dbType === 'mssql'
|
const dbType = configManager.getDatabaseType()
|
||||||
|
const isSqlServer = dbType === 'sqlserver'
|
||||||
|
|
||||||
const dao = new MaterialsToBeDeletedDAO()
|
const dao = new MaterialsToBeDeletedDAO()
|
||||||
const materials = await dao.getAllRecords()
|
const materials = await dao.getAllRecords()
|
||||||
@@ -641,7 +723,7 @@ export function registerValidationHandlers(): void {
|
|||||||
/**
|
/**
|
||||||
* Get statistics
|
* Get statistics
|
||||||
*/
|
*/
|
||||||
ipcMain.handle('materials:getStatistics', async (_event): Promise<{ stats: any }> => {
|
ipcMain.handle(IPC_CHANNELS.MATERIALS_GET_STATISTICS, async (_event): Promise<{ stats: any }> => {
|
||||||
try {
|
try {
|
||||||
const dao = new MaterialsToBeDeletedDAO()
|
const dao = new MaterialsToBeDeletedDAO()
|
||||||
const stats = await dao.getStatistics()
|
const stats = await dao.getStatistics()
|
||||||
@@ -658,10 +740,10 @@ export function registerValidationHandlers(): void {
|
|||||||
* Set shared Production IDs from extractor page
|
* Set shared Production IDs from extractor page
|
||||||
*/
|
*/
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
'validation:setSharedProductionIds',
|
IPC_CHANNELS.VALIDATION_SET_SHARED_PRODUCTION_IDS,
|
||||||
async (_event, productionIds: string[]): Promise<void> => {
|
async (event, productionIds: string[]): Promise<void> => {
|
||||||
log.info(`Received ${productionIds.length} shared Production IDs`)
|
log.info(`Received ${productionIds.length} shared Production IDs`)
|
||||||
setSharedProductionIds(productionIds)
|
setSharedProductionIds(event.sender.id, productionIds)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -669,9 +751,20 @@ export function registerValidationHandlers(): void {
|
|||||||
* Get shared Production IDs
|
* Get shared Production IDs
|
||||||
*/
|
*/
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
'validation:getSharedProductionIds',
|
IPC_CHANNELS.VALIDATION_GET_SHARED_PRODUCTION_IDS,
|
||||||
async (): Promise<{ productionIds: string[] }> => {
|
async (event): Promise<{ productionIds: string[] }> => {
|
||||||
return { productionIds: getSharedProductionIds() }
|
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)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -680,7 +773,7 @@ export function registerValidationHandlers(): void {
|
|||||||
* Filters materials by current user (admin sees all, regular users see only their own)
|
* Filters materials by current user (admin sees all, regular users see only their own)
|
||||||
*/
|
*/
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
'validation:getCleanerData',
|
IPC_CHANNELS.VALIDATION_GET_CLEANER_DATA,
|
||||||
async (
|
async (
|
||||||
_event
|
_event
|
||||||
): Promise<{
|
): Promise<{
|
||||||
@@ -706,8 +799,9 @@ export function registerValidationHandlers(): void {
|
|||||||
|
|
||||||
const isAdmin = userInfo.userType === 'Admin'
|
const isAdmin = userInfo.userType === 'Admin'
|
||||||
const username = userInfo.username
|
const username = userInfo.username
|
||||||
const dbType = process.env.DB_TYPE?.toLowerCase()
|
const configManager = ConfigManager.getInstance()
|
||||||
const isSqlServer = dbType === 'sqlserver' || dbType === 'mssql'
|
const dbType = configManager.getDatabaseType()
|
||||||
|
const isSqlServer = dbType === 'sqlserver'
|
||||||
|
|
||||||
log.info(`User: ${username}, isAdmin: ${isAdmin}`)
|
log.info(`User: ${username}, isAdmin: ${isAdmin}`)
|
||||||
|
|
||||||
@@ -715,7 +809,7 @@ export function registerValidationHandlers(): void {
|
|||||||
dbService = await getValidationDatabaseService()
|
dbService = await getValidationDatabaseService()
|
||||||
|
|
||||||
// 1. Get order numbers from shared Production IDs
|
// 1. Get order numbers from shared Production IDs
|
||||||
const sharedIds = getSharedProductionIds()
|
const sharedIds = getSharedProductionIds(_event.sender.id)
|
||||||
let orderNumbers: string[] = []
|
let orderNumbers: string[] = []
|
||||||
|
|
||||||
if (sharedIds.length > 0) {
|
if (sharedIds.length > 0) {
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ export const CleanerInputSchema = z.object({
|
|||||||
.array(z.string().min(1, 'Order number cannot be empty'))
|
.array(z.string().min(1, 'Order number cannot be empty'))
|
||||||
.min(1, 'At least one order number is required'),
|
.min(1, 'At least one order number is required'),
|
||||||
materialCodes: z.array(z.string().min(1, 'Material code cannot be empty')),
|
materialCodes: z.array(z.string().min(1, 'Material code cannot be empty')),
|
||||||
dryRun: z.boolean()
|
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
|
// Note: onProgress is a function, not validated via Zod
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,34 @@
|
|||||||
/**
|
/**
|
||||||
* Configuration Manager
|
* Configuration Manager (YAML Version)
|
||||||
*
|
*
|
||||||
* Manages application configuration stored in .env file
|
* Manages application configuration using YAML format
|
||||||
* Provides methods for reading, writing, and saving configuration values
|
* 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 fs from 'fs'
|
||||||
import * as path from 'path'
|
import * as path from 'path'
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
import { dirname } from 'path'
|
import { dirname } from 'path'
|
||||||
import { createLogger } from '../logger'
|
import { app } from 'electron'
|
||||||
import type {
|
import yaml from 'js-yaml'
|
||||||
SettingsData,
|
import { z } from 'zod'
|
||||||
DatabaseType,
|
import { createLogger, setLogLevel } from '../logger'
|
||||||
MatchMode,
|
import {
|
||||||
ValidationDataSource
|
fullConfigSchema,
|
||||||
} from '../../types/settings.types'
|
type FullConfig,
|
||||||
|
type DatabaseType,
|
||||||
|
type MySqlConfig,
|
||||||
|
type SqlServerConfig,
|
||||||
|
type LoggingConfig
|
||||||
|
} from '../../types/config.schema'
|
||||||
|
|
||||||
const log = createLogger('ConfigManager')
|
const log = createLogger('ConfigManager')
|
||||||
|
|
||||||
@@ -23,30 +36,36 @@ const __filename = fileURLToPath(import.meta.url)
|
|||||||
const __dirname = dirname(__filename)
|
const __dirname = dirname(__filename)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default settings values
|
* 默认配置
|
||||||
*/
|
*/
|
||||||
const DEFAULT_SETTINGS: SettingsData = {
|
const DEFAULT_CONFIG: FullConfig = {
|
||||||
erp: {
|
erp: {
|
||||||
url: 'https://68.11.34.30:8082/',
|
url: 'https://68.11.34.30:8082'
|
||||||
username: '',
|
|
||||||
password: '',
|
|
||||||
headless: true,
|
|
||||||
ignoreHttpsErrors: true,
|
|
||||||
autoCloseBrowser: true
|
|
||||||
},
|
},
|
||||||
database: {
|
database: {
|
||||||
dbType: 'mysql',
|
activeType: 'mysql',
|
||||||
server: '',
|
mysql: {
|
||||||
mysqlHost: '192.168.31.83',
|
host: 'localhost',
|
||||||
mysqlPort: 3306,
|
port: 3306,
|
||||||
database: 'BLD_DB',
|
database: 'erp_db',
|
||||||
username: 'remote_user',
|
username: 'root',
|
||||||
password: ''
|
password: '',
|
||||||
|
charset: 'utf8mb4'
|
||||||
|
},
|
||||||
|
sqlserver: {
|
||||||
|
server: 'localhost',
|
||||||
|
port: 1433,
|
||||||
|
database: 'erp_db',
|
||||||
|
username: 'sa',
|
||||||
|
password: '',
|
||||||
|
driver: 'ODBC Driver 18 for SQL Server',
|
||||||
|
trustServerCertificate: true
|
||||||
|
}
|
||||||
},
|
},
|
||||||
paths: {
|
paths: {
|
||||||
dataDir: 'D:/python/playwrite/data/',
|
dataDir: './data/',
|
||||||
defaultOutput: '离散备料计划维护_合并.xlsx',
|
defaultOutput: 'output.xlsx',
|
||||||
validationOutput: '物料状态校验结果.xlsx'
|
validationOutput: 'validation-result.xlsx'
|
||||||
},
|
},
|
||||||
extraction: {
|
extraction: {
|
||||||
batchSize: 100,
|
batchSize: 100,
|
||||||
@@ -62,108 +81,72 @@ const DEFAULT_SETTINGS: SettingsData = {
|
|||||||
enableCrud: false,
|
enableCrud: false,
|
||||||
defaultManager: ''
|
defaultManager: ''
|
||||||
},
|
},
|
||||||
ui: {
|
cleaner: {
|
||||||
fontFamily: 'Microsoft YaHei UI',
|
queryBatchSize: 100,
|
||||||
fontSize: 10,
|
processConcurrency: 1
|
||||||
productionIdInputWidth: 20
|
|
||||||
},
|
},
|
||||||
execution: {
|
orderResolution: {
|
||||||
dryRun: false
|
tableName: '',
|
||||||
|
productionIdField: '',
|
||||||
|
orderNumberField: ''
|
||||||
|
},
|
||||||
|
logging: {
|
||||||
|
level: 'info',
|
||||||
|
auditRetention: 30,
|
||||||
|
appRetention: 14
|
||||||
|
},
|
||||||
|
rustfs: {
|
||||||
|
enabled: false,
|
||||||
|
endpoint: '',
|
||||||
|
accessKey: '',
|
||||||
|
secretKey: '',
|
||||||
|
bucket: 'erpauto',
|
||||||
|
region: 'us-east-1'
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
enabled: false,
|
||||||
|
allowDevMode: false,
|
||||||
|
endpoint: '',
|
||||||
|
accessKey: '',
|
||||||
|
secretKey: '',
|
||||||
|
bucket: '',
|
||||||
|
region: 'us-east-1',
|
||||||
|
basePrefix: 'updates/win-portable',
|
||||||
|
checkIntervalMinutes: 30,
|
||||||
|
maxAdminHistoryPerChannel: 10
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if value is a plain object
|
|
||||||
*/
|
|
||||||
function isObject(value: unknown): value is Record<string, unknown> {
|
|
||||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deep merge two objects, only updating fields present in target
|
|
||||||
* Preserves all fields from source that are not in 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 as T[Extract<keyof T, string>],
|
|
||||||
targetValue as Partial<T[Extract<keyof T, string>]>
|
|
||||||
)
|
|
||||||
} else if (targetValue !== undefined) {
|
|
||||||
result[key] = targetValue as T[Extract<keyof T, string>]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* UI editable field whitelist
|
|
||||||
* Fields that can be modified through the settings UI
|
|
||||||
*/
|
|
||||||
const UI_EDITABLE_FIELDS: string[] = [
|
|
||||||
'erp.url',
|
|
||||||
'erp.username',
|
|
||||||
'erp.password'
|
|
||||||
// Add more fields as UI expands
|
|
||||||
]
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate that settings only contain editable fields
|
|
||||||
*/
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Configuration Manager Class
|
|
||||||
*/
|
|
||||||
export class ConfigManager {
|
export class ConfigManager {
|
||||||
private static instance: ConfigManager | null = null
|
private static instance: ConfigManager | null = null
|
||||||
private envPath: string
|
private configPath!: string
|
||||||
private backupPath: string
|
private backupPath!: string
|
||||||
private configCache: Map<string, string> = new Map()
|
private config: FullConfig | null = null
|
||||||
private initialized: boolean = false
|
private initialized: boolean = false
|
||||||
|
|
||||||
private constructor() {
|
private constructor() {
|
||||||
if (this.initialized) {
|
if (this.initialized) return
|
||||||
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.envPath = path.resolve(__dirname, '../../.env')
|
|
||||||
this.backupPath = path.resolve(__dirname, '../../.env.backup')
|
|
||||||
this.initialized = true
|
this.initialized = true
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the singleton instance
|
|
||||||
*/
|
|
||||||
public static getInstance(): ConfigManager {
|
public static getInstance(): ConfigManager {
|
||||||
if (ConfigManager.instance === null) {
|
if (ConfigManager.instance === null) {
|
||||||
ConfigManager.instance = new ConfigManager()
|
ConfigManager.instance = new ConfigManager()
|
||||||
@@ -172,550 +155,204 @@ export class ConfigManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize configuration from .env file
|
* 初始化配置
|
||||||
|
* - 如果 config.yaml 不存在,创建默认配置
|
||||||
|
* - 加载并验证配置
|
||||||
*/
|
*/
|
||||||
public async initialize(): Promise<void> {
|
public async initialize(): Promise<void> {
|
||||||
await this.loadEnvFile()
|
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()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load .env file into cache
|
* 加载并验证 YAML 配置
|
||||||
*/
|
*/
|
||||||
private async loadEnvFile(): Promise<void> {
|
private async loadConfig(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Clear cache before loading
|
const content = fs.readFileSync(this.configPath, 'utf-8')
|
||||||
this.configCache.clear()
|
const parsed = yaml.load(content) as Record<string, unknown>
|
||||||
|
|
||||||
if (fs.existsSync(this.envPath)) {
|
// Zod 验证
|
||||||
const content = fs.readFileSync(this.envPath, 'utf-8')
|
const validated = fullConfigSchema.parse(parsed)
|
||||||
const lines = content.split('\n')
|
this.config = validated
|
||||||
|
|
||||||
for (const line of lines) {
|
// Apply logging configuration
|
||||||
const trimmedLine = line.trim()
|
setLogLevel(validated.logging.level)
|
||||||
// Skip empty lines and comments
|
|
||||||
if (!trimmedLine || trimmedLine.startsWith('#')) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const [key, ...valueParts] = trimmedLine.split('=')
|
log.info('Configuration loaded and validated successfully')
|
||||||
if (key && valueParts.length > 0) {
|
|
||||||
const value = valueParts.join('=').trim()
|
|
||||||
this.configCache.set(key.trim(), value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[ConfigManager] Failed to load .env file:', 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a configuration value
|
* 保存配置到 YAML 文件
|
||||||
* @param key - Configuration key
|
|
||||||
* @param defaultValue - Default value if key doesn't exist
|
|
||||||
*/
|
*/
|
||||||
public get(key: string, defaultValue?: string): string | undefined {
|
private async saveConfig(config: FullConfig): Promise<boolean> {
|
||||||
return this.configCache.get(key) ?? defaultValue
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a boolean configuration value
|
|
||||||
*/
|
|
||||||
public getBoolean(key: string, defaultValue: boolean = false): boolean {
|
|
||||||
const value = this.get(key)
|
|
||||||
if (value === undefined) return defaultValue
|
|
||||||
return value.toLowerCase() === 'true'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a number configuration value
|
|
||||||
*/
|
|
||||||
public getNumber(key: string, defaultValue: number = 0): number {
|
|
||||||
const value = this.get(key)
|
|
||||||
if (value === undefined) return defaultValue
|
|
||||||
const parsed = parseInt(value, 10)
|
|
||||||
return isNaN(parsed) ? defaultValue : parsed
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set a configuration value in cache
|
|
||||||
*/
|
|
||||||
public set(key: string, value: string | number | boolean): void {
|
|
||||||
this.configCache.set(key, String(value))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save configuration to .env file
|
|
||||||
*/
|
|
||||||
public async save(): Promise<boolean> {
|
|
||||||
try {
|
try {
|
||||||
// Build .env content from cache
|
// 备份现有配置
|
||||||
const lines: string[] = []
|
if (fs.existsSync(this.configPath)) {
|
||||||
|
fs.copyFileSync(this.configPath, this.backupPath)
|
||||||
|
}
|
||||||
|
|
||||||
// ERP Configuration
|
// 转换为 YAML
|
||||||
lines.push('# ===========================')
|
const content = yaml.dump(config, {
|
||||||
lines.push('# ERP 系统配置')
|
indent: 2,
|
||||||
lines.push('# ===========================')
|
lineWidth: -1, // 不自动换行
|
||||||
lines.push(`ERP_URL=${this.configCache.get('ERP_URL') || DEFAULT_SETTINGS.erp.url}`)
|
noRefs: true, // 不使用引用
|
||||||
lines.push(
|
quotingType: '"',
|
||||||
`ERP_USERNAME=${this.configCache.get('ERP_USERNAME') || DEFAULT_SETTINGS.erp.username}`
|
forceQuotes: false
|
||||||
)
|
})
|
||||||
lines.push(
|
|
||||||
`ERP_PASSWORD=${this.configCache.get('ERP_PASSWORD') || DEFAULT_SETTINGS.erp.password}`
|
|
||||||
)
|
|
||||||
lines.push(
|
|
||||||
`ERP_HEADLESS=${this.configCache.get('ERP_HEADLESS') || DEFAULT_SETTINGS.erp.headless}`
|
|
||||||
)
|
|
||||||
lines.push(
|
|
||||||
`ERP_IGNORE_HTTPS_ERRORS=${this.configCache.get('ERP_IGNORE_HTTPS_ERRORS') || DEFAULT_SETTINGS.erp.ignoreHttpsErrors}`
|
|
||||||
)
|
|
||||||
lines.push(
|
|
||||||
`ERP_AUTO_CLOSE_BROWSER=${this.configCache.get('ERP_AUTO_CLOSE_BROWSER') || DEFAULT_SETTINGS.erp.autoCloseBrowser}`
|
|
||||||
)
|
|
||||||
lines.push('')
|
|
||||||
|
|
||||||
// Database Configuration - SQL Server
|
fs.writeFileSync(this.configPath, content, 'utf-8')
|
||||||
lines.push('# ===========================')
|
|
||||||
lines.push('# 数据库配置 - SQL Server')
|
|
||||||
lines.push('# ===========================')
|
|
||||||
lines.push(`# DB_TYPE=sqlserver`)
|
|
||||||
lines.push(`# DB_SERVER=${this.configCache.get('DB_SERVER') || ''}`)
|
|
||||||
lines.push(`# DB_NAME=${this.configCache.get('DB_NAME') || ''}`)
|
|
||||||
lines.push(`# DB_USERNAME=${this.configCache.get('DB_USERNAME') || ''}`)
|
|
||||||
lines.push(`# DB_PASSWORD=${this.configCache.get('DB_PASSWORD') || ''}`)
|
|
||||||
lines.push(`DB_SQLSERVER_DRIVER=ODBC Driver 18 for SQL Server`)
|
|
||||||
lines.push(`DB_TRUST_SERVER_CERTIFICATE=yes`)
|
|
||||||
lines.push('')
|
|
||||||
|
|
||||||
// Database Configuration - MySQL
|
this.config = config
|
||||||
lines.push('# ===========================')
|
log.info('Configuration saved successfully')
|
||||||
lines.push('# 数据库配置 - MySQL (切换时使用)')
|
|
||||||
lines.push('# ===========================')
|
|
||||||
lines.push(
|
|
||||||
`DB_TYPE=${this.configCache.get('DB_TYPE') || DEFAULT_SETTINGS.database.dbType}`
|
|
||||||
)
|
|
||||||
lines.push(
|
|
||||||
`DB_NAME=${this.configCache.get('DB_NAME') || DEFAULT_SETTINGS.database.database}`
|
|
||||||
)
|
|
||||||
lines.push(
|
|
||||||
`DB_USERNAME=${this.configCache.get('DB_USERNAME') || DEFAULT_SETTINGS.database.username}`
|
|
||||||
)
|
|
||||||
lines.push(
|
|
||||||
`DB_PASSWORD=${this.configCache.get('DB_PASSWORD') || DEFAULT_SETTINGS.database.password}`
|
|
||||||
)
|
|
||||||
lines.push(
|
|
||||||
`DB_MYSQL_HOST=${this.configCache.get('DB_MYSQL_HOST') || DEFAULT_SETTINGS.database.mysqlHost}`
|
|
||||||
)
|
|
||||||
lines.push(
|
|
||||||
`DB_MYSQL_PORT=${this.configCache.get('DB_MYSQL_PORT') || DEFAULT_SETTINGS.database.mysqlPort}`
|
|
||||||
)
|
|
||||||
lines.push(`DB_MYSQL_CHARSET=utf8mb4`)
|
|
||||||
lines.push('')
|
|
||||||
|
|
||||||
// Order number parsing table configuration
|
|
||||||
lines.push('# 订单号解析表配置')
|
|
||||||
lines.push('# 表名:包含 productionID 和 生产订单号 映射关系的表')
|
|
||||||
lines.push(`DB_TABLE_NAME=productionContractData_26 年压力表合同数据`)
|
|
||||||
lines.push('# 字段名:总排号 (对应 productionID)')
|
|
||||||
lines.push(`DB_FIELD_PRODUCTION_ID=总排号`)
|
|
||||||
lines.push('# 字段名:生产订单号 (对应生产订单号)')
|
|
||||||
lines.push(`DB_FIELD_ORDER_NUMBER=生产订单号`)
|
|
||||||
lines.push('')
|
|
||||||
|
|
||||||
// Path Configuration
|
|
||||||
lines.push('# ===========================')
|
|
||||||
lines.push('# 路径配置')
|
|
||||||
lines.push('# ===========================')
|
|
||||||
lines.push(
|
|
||||||
`PATH_DATA_DIR=${this.configCache.get('PATH_DATA_DIR') || DEFAULT_SETTINGS.paths.dataDir}`
|
|
||||||
)
|
|
||||||
lines.push(`PATH_PRODUCTION_ID_FILE=ProductionID.txt`)
|
|
||||||
lines.push(
|
|
||||||
`PATH_DEFAULT_OUTPUT=${this.configCache.get('PATH_DEFAULT_OUTPUT') || DEFAULT_SETTINGS.paths.defaultOutput}`
|
|
||||||
)
|
|
||||||
lines.push(
|
|
||||||
`PATH_VALIDATION_OUTPUT=${this.configCache.get('PATH_VALIDATION_OUTPUT') || DEFAULT_SETTINGS.paths.validationOutput}`
|
|
||||||
)
|
|
||||||
lines.push('')
|
|
||||||
|
|
||||||
// Data Extraction Configuration
|
|
||||||
lines.push('# ===========================')
|
|
||||||
lines.push('# 数据提取配置')
|
|
||||||
lines.push('# ===========================')
|
|
||||||
lines.push(
|
|
||||||
`EXTRACTION_BATCH_SIZE=${this.configCache.get('EXTRACTION_BATCH_SIZE') || DEFAULT_SETTINGS.extraction.batchSize}`
|
|
||||||
)
|
|
||||||
lines.push(
|
|
||||||
`EXTRACTION_VERBOSE=${this.configCache.get('EXTRACTION_VERBOSE') || DEFAULT_SETTINGS.extraction.verbose}`
|
|
||||||
)
|
|
||||||
lines.push(
|
|
||||||
`EXTRACTION_AUTO_CONVERT=${this.configCache.get('EXTRACTION_AUTO_CONVERT') || DEFAULT_SETTINGS.extraction.autoConvert}`
|
|
||||||
)
|
|
||||||
lines.push(
|
|
||||||
`EXTRACTION_MERGE_BATCHES=${this.configCache.get('EXTRACTION_MERGE_BATCHES') || DEFAULT_SETTINGS.extraction.mergeBatches}`
|
|
||||||
)
|
|
||||||
lines.push(
|
|
||||||
`EXTRACTION_ENABLE_DB_PERSISTENCE=${this.configCache.get('EXTRACTION_ENABLE_DB_PERSISTENCE') || DEFAULT_SETTINGS.extraction.enableDbPersistence}`
|
|
||||||
)
|
|
||||||
lines.push('')
|
|
||||||
|
|
||||||
// Validation Configuration
|
|
||||||
lines.push('# ===========================')
|
|
||||||
lines.push('# 校验配置')
|
|
||||||
lines.push('# ===========================')
|
|
||||||
lines.push(
|
|
||||||
`VALIDATION_DATA_SOURCE=${this.configCache.get('VALIDATION_DATA_SOURCE') || DEFAULT_SETTINGS.validation.dataSource}`
|
|
||||||
)
|
|
||||||
lines.push(
|
|
||||||
`VALIDATION_USE_DATABASE=${this.configCache.get('VALIDATION_USE_DATABASE') || true}`
|
|
||||||
)
|
|
||||||
lines.push(
|
|
||||||
`VALIDATION_BATCH_SIZE=${this.configCache.get('VALIDATION_BATCH_SIZE') || DEFAULT_SETTINGS.validation.batchSize}`
|
|
||||||
)
|
|
||||||
lines.push(
|
|
||||||
`VALIDATION_ENABLE_CRUD=${this.configCache.get('VALIDATION_ENABLE_CRUD') || DEFAULT_SETTINGS.validation.enableCrud}`
|
|
||||||
)
|
|
||||||
lines.push(
|
|
||||||
`VALIDATION_DEFAULT_MANAGER=${this.configCache.get('VALIDATION_DEFAULT_MANAGER') || DEFAULT_SETTINGS.validation.defaultManager}`
|
|
||||||
)
|
|
||||||
lines.push(
|
|
||||||
`VALIDATION_MATCH_MODE=${this.configCache.get('VALIDATION_MATCH_MODE') || DEFAULT_SETTINGS.validation.matchMode}`
|
|
||||||
)
|
|
||||||
lines.push('')
|
|
||||||
|
|
||||||
// UI Configuration
|
|
||||||
lines.push('# ===========================')
|
|
||||||
lines.push('# UI 配置')
|
|
||||||
lines.push('# ===========================')
|
|
||||||
lines.push(
|
|
||||||
`UI_FONT_FAMILY=${this.configCache.get('UI_FONT_FAMILY') || DEFAULT_SETTINGS.ui.fontFamily}`
|
|
||||||
)
|
|
||||||
lines.push(
|
|
||||||
`UI_FONT_SIZE=${this.configCache.get('UI_FONT_SIZE') || DEFAULT_SETTINGS.ui.fontSize}`
|
|
||||||
)
|
|
||||||
lines.push(
|
|
||||||
`UI_PRODUCTION_ID_INPUT_WIDTH=${this.configCache.get('UI_PRODUCTION_ID_INPUT_WIDTH') || DEFAULT_SETTINGS.ui.productionIdInputWidth}`
|
|
||||||
)
|
|
||||||
lines.push('')
|
|
||||||
|
|
||||||
// Execution Configuration
|
|
||||||
lines.push('# ===========================')
|
|
||||||
lines.push('# 执行配置')
|
|
||||||
lines.push('# ===========================')
|
|
||||||
lines.push(
|
|
||||||
`EXECUTION_DRYRUN=${this.configCache.get('EXECUTION_DRYRUN') || DEFAULT_SETTINGS.execution.dryRun}`
|
|
||||||
)
|
|
||||||
|
|
||||||
const content = lines.join('\n')
|
|
||||||
fs.writeFileSync(this.envPath, content, 'utf-8')
|
|
||||||
return true
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[ConfigManager] Failed to save .env file:', error)
|
log.error('Failed to save configuration', { error })
|
||||||
|
// 恢复备份
|
||||||
|
if (fs.existsSync(this.backupPath)) {
|
||||||
|
fs.copyFileSync(this.backupPath, this.configPath)
|
||||||
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all settings as SettingsData object
|
* 获取完整配置
|
||||||
*/
|
*/
|
||||||
public getAllSettings(): SettingsData {
|
public getConfig(): FullConfig {
|
||||||
return {
|
if (!this.config) {
|
||||||
erp: {
|
throw new Error('Configuration not initialized. Call initialize() first.')
|
||||||
url: this.get('ERP_URL', DEFAULT_SETTINGS.erp.url),
|
|
||||||
username: this.get('ERP_USERNAME', DEFAULT_SETTINGS.erp.username),
|
|
||||||
password: this.get('ERP_PASSWORD', DEFAULT_SETTINGS.erp.password),
|
|
||||||
headless: this.getBoolean('ERP_HEADLESS', DEFAULT_SETTINGS.erp.headless),
|
|
||||||
ignoreHttpsErrors: this.getBoolean(
|
|
||||||
'ERP_IGNORE_HTTPS_ERRORS',
|
|
||||||
DEFAULT_SETTINGS.erp.ignoreHttpsErrors
|
|
||||||
),
|
|
||||||
autoCloseBrowser: this.getBoolean(
|
|
||||||
'ERP_AUTO_CLOSE_BROWSER',
|
|
||||||
DEFAULT_SETTINGS.erp.autoCloseBrowser
|
|
||||||
)
|
|
||||||
},
|
|
||||||
database: {
|
|
||||||
dbType:
|
|
||||||
(this.get('DB_TYPE', DEFAULT_SETTINGS.database.dbType) as DatabaseType) ||
|
|
||||||
DEFAULT_SETTINGS.database.dbType,
|
|
||||||
server: this.get('DB_SERVER', DEFAULT_SETTINGS.database.server),
|
|
||||||
mysqlHost: this.get('DB_MYSQL_HOST', DEFAULT_SETTINGS.database.mysqlHost),
|
|
||||||
mysqlPort: this.getNumber('DB_MYSQL_PORT', DEFAULT_SETTINGS.database.mysqlPort),
|
|
||||||
database: this.get('DB_NAME', DEFAULT_SETTINGS.database.database),
|
|
||||||
username: this.get('DB_USERNAME', DEFAULT_SETTINGS.database.username),
|
|
||||||
password: this.get('DB_PASSWORD', DEFAULT_SETTINGS.database.password)
|
|
||||||
},
|
|
||||||
paths: {
|
|
||||||
dataDir: this.get('PATH_DATA_DIR', DEFAULT_SETTINGS.paths.dataDir),
|
|
||||||
defaultOutput: this.get('PATH_DEFAULT_OUTPUT', DEFAULT_SETTINGS.paths.defaultOutput),
|
|
||||||
validationOutput: this.get(
|
|
||||||
'PATH_VALIDATION_OUTPUT',
|
|
||||||
DEFAULT_SETTINGS.paths.validationOutput
|
|
||||||
)
|
|
||||||
},
|
|
||||||
extraction: {
|
|
||||||
batchSize: this.getNumber('EXTRACTION_BATCH_SIZE', DEFAULT_SETTINGS.extraction.batchSize),
|
|
||||||
verbose: this.getBoolean('EXTRACTION_VERBOSE', DEFAULT_SETTINGS.extraction.verbose),
|
|
||||||
autoConvert: this.getBoolean(
|
|
||||||
'EXTRACTION_AUTO_CONVERT',
|
|
||||||
DEFAULT_SETTINGS.extraction.autoConvert
|
|
||||||
),
|
|
||||||
mergeBatches: this.getBoolean(
|
|
||||||
'EXTRACTION_MERGE_BATCHES',
|
|
||||||
DEFAULT_SETTINGS.extraction.mergeBatches
|
|
||||||
),
|
|
||||||
enableDbPersistence: this.getBoolean(
|
|
||||||
'EXTRACTION_ENABLE_DB_PERSISTENCE',
|
|
||||||
DEFAULT_SETTINGS.extraction.enableDbPersistence
|
|
||||||
)
|
|
||||||
},
|
|
||||||
validation: {
|
|
||||||
dataSource:
|
|
||||||
(this.get(
|
|
||||||
'VALIDATION_DATA_SOURCE',
|
|
||||||
DEFAULT_SETTINGS.validation.dataSource
|
|
||||||
) as ValidationDataSource) || DEFAULT_SETTINGS.validation.dataSource,
|
|
||||||
batchSize: this.getNumber('VALIDATION_BATCH_SIZE', DEFAULT_SETTINGS.validation.batchSize),
|
|
||||||
matchMode:
|
|
||||||
(this.get('VALIDATION_MATCH_MODE', DEFAULT_SETTINGS.validation.matchMode) as MatchMode) ||
|
|
||||||
DEFAULT_SETTINGS.validation.matchMode,
|
|
||||||
enableCrud: this.getBoolean(
|
|
||||||
'VALIDATION_ENABLE_CRUD',
|
|
||||||
DEFAULT_SETTINGS.validation.enableCrud
|
|
||||||
),
|
|
||||||
defaultManager: this.get(
|
|
||||||
'VALIDATION_DEFAULT_MANAGER',
|
|
||||||
DEFAULT_SETTINGS.validation.defaultManager
|
|
||||||
)
|
|
||||||
},
|
|
||||||
ui: {
|
|
||||||
fontFamily: this.get('UI_FONT_FAMILY', DEFAULT_SETTINGS.ui.fontFamily),
|
|
||||||
fontSize: this.getNumber('UI_FONT_SIZE', DEFAULT_SETTINGS.ui.fontSize),
|
|
||||||
productionIdInputWidth: this.getNumber(
|
|
||||||
'UI_PRODUCTION_ID_INPUT_WIDTH',
|
|
||||||
DEFAULT_SETTINGS.ui.productionIdInputWidth
|
|
||||||
)
|
|
||||||
},
|
|
||||||
execution: {
|
|
||||||
dryRun: this.getBoolean('EXECUTION_DRYRUN', DEFAULT_SETTINGS.execution.dryRun)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return this.config
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save settings from SettingsData object
|
* 获取当前激活的数据库配置
|
||||||
*/
|
*/
|
||||||
public async saveAllSettings(settings: SettingsData): Promise<boolean> {
|
public getActiveDatabaseConfig(): MySqlConfig | SqlServerConfig {
|
||||||
// ERP settings - use underscore uppercase keys to match .env file
|
if (!this.config) {
|
||||||
this.set('ERP_URL', settings.erp.url)
|
throw new Error('Configuration not initialized')
|
||||||
this.set('ERP_USERNAME', settings.erp.username)
|
}
|
||||||
this.set('ERP_PASSWORD', settings.erp.password)
|
|
||||||
this.set('ERP_HEADLESS', settings.erp.headless)
|
|
||||||
this.set('ERP_IGNORE_HTTPS_ERRORS', settings.erp.ignoreHttpsErrors)
|
|
||||||
this.set('ERP_AUTO_CLOSE_BROWSER', settings.erp.autoCloseBrowser)
|
|
||||||
|
|
||||||
// Database settings
|
const { activeType, mysql, sqlserver } = this.config.database
|
||||||
this.set('DB_TYPE', settings.database.dbType)
|
return activeType === 'mysql' ? mysql : sqlserver
|
||||||
this.set('DB_SERVER', settings.database.server)
|
|
||||||
this.set('DB_MYSQL_HOST', settings.database.mysqlHost)
|
|
||||||
this.set('DB_MYSQL_PORT', settings.database.mysqlPort)
|
|
||||||
this.set('DB_NAME', settings.database.database)
|
|
||||||
this.set('DB_USERNAME', settings.database.username)
|
|
||||||
this.set('DB_PASSWORD', settings.database.password)
|
|
||||||
|
|
||||||
// Path settings
|
|
||||||
this.set('PATH_DATA_DIR', settings.paths.dataDir)
|
|
||||||
this.set('PATH_DEFAULT_OUTPUT', settings.paths.defaultOutput)
|
|
||||||
this.set('PATH_VALIDATION_OUTPUT', settings.paths.validationOutput)
|
|
||||||
|
|
||||||
// Extraction settings
|
|
||||||
this.set('EXTRACTION_BATCH_SIZE', settings.extraction.batchSize)
|
|
||||||
this.set('EXTRACTION_VERBOSE', settings.extraction.verbose)
|
|
||||||
this.set('EXTRACTION_AUTO_CONVERT', settings.extraction.autoConvert)
|
|
||||||
this.set('EXTRACTION_MERGE_BATCHES', settings.extraction.mergeBatches)
|
|
||||||
this.set('EXTRACTION_ENABLE_DB_PERSISTENCE', settings.extraction.enableDbPersistence)
|
|
||||||
|
|
||||||
// Validation settings
|
|
||||||
this.set('VALIDATION_DATA_SOURCE', settings.validation.dataSource)
|
|
||||||
this.set('VALIDATION_BATCH_SIZE', settings.validation.batchSize)
|
|
||||||
this.set('VALIDATION_MATCH_MODE', settings.validation.matchMode)
|
|
||||||
this.set('VALIDATION_ENABLE_CRUD', settings.validation.enableCrud)
|
|
||||||
this.set('VALIDATION_DEFAULT_MANAGER', settings.validation.defaultManager)
|
|
||||||
|
|
||||||
// UI settings
|
|
||||||
this.set('UI_FONT_FAMILY', settings.ui.fontFamily)
|
|
||||||
this.set('UI_FONT_SIZE', settings.ui.fontSize)
|
|
||||||
this.set('UI_PRODUCTION_ID_INPUT_WIDTH', settings.ui.productionIdInputWidth)
|
|
||||||
|
|
||||||
// Execution settings
|
|
||||||
this.set('EXECUTION_DRYRUN', settings.execution.dryRun)
|
|
||||||
|
|
||||||
return this.save()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save partial settings (only update provided fields)
|
* 获取数据库类型
|
||||||
* Preserves all existing fields not included in the update
|
|
||||||
*/
|
*/
|
||||||
public async savePartialSettings(
|
public getDatabaseType(): DatabaseType {
|
||||||
settings: Partial<SettingsData>
|
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 }> {
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
try {
|
try {
|
||||||
// Step 1: Validate field whitelist
|
if (!this.config) {
|
||||||
const validation = validateEditableFields(settings)
|
await this.loadConfig()
|
||||||
if (!validation.valid) {
|
|
||||||
log.warn('Attempted to save non-editable fields', {
|
|
||||||
invalidFields: validation.invalidFields
|
|
||||||
})
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: `包含不允许修改的字段:${validation.invalidFields.join(', ')}`
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Read current settings from .env file directly
|
// 深合并
|
||||||
// This avoids the cache key mismatch issue (ERP_URL vs erp.url)
|
const merged = this.deepMerge(this.config!, updates)
|
||||||
await this.loadEnvFile()
|
|
||||||
const currentSettings = this.getAllSettings()
|
|
||||||
|
|
||||||
log.info('Current settings before merge', {
|
// 验证合并后的配置
|
||||||
erpUrl: currentSettings.erp.url,
|
const validated = fullConfigSchema.parse(merged)
|
||||||
dbType: currentSettings.database.dbType,
|
|
||||||
dbName: currentSettings.database.database
|
|
||||||
})
|
|
||||||
|
|
||||||
// Step 3: Deep merge - only update provided fields
|
const success = await this.saveConfig(validated)
|
||||||
const mergedSettings = deepMerge(currentSettings, settings)
|
if (!success) {
|
||||||
|
return { success: false, error: '保存配置失败' }
|
||||||
log.info('Settings after merge', {
|
|
||||||
erpUrl: mergedSettings.erp.url,
|
|
||||||
dbType: mergedSettings.database.dbType,
|
|
||||||
dbName: mergedSettings.database.database
|
|
||||||
})
|
|
||||||
|
|
||||||
// Step 4: Backup and save
|
|
||||||
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) {
|
|
||||||
// Save failed, attempt restore
|
|
||||||
await this.restoreBackup()
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: '保存配置失败,已恢复原配置'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 5: Reload from disk to populate cache with correct keys (ERP_URL instead of erp.url)
|
|
||||||
await this.loadEnvFile()
|
|
||||||
|
|
||||||
log.info('Settings saved successfully', {
|
|
||||||
updatedFields: Object.keys(settings)
|
|
||||||
})
|
|
||||||
|
|
||||||
return { success: true }
|
return { success: true }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
if (error instanceof z.ZodError) {
|
||||||
log.error('Error in savePartialSettings', { error: message })
|
const messages = error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`)
|
||||||
await this.restoreBackup()
|
return { success: false, error: `配置验证失败:\n${messages.join('\n')}` }
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: `保存配置时发生错误:${message}`
|
|
||||||
}
|
}
|
||||||
|
return { success: false, error: error instanceof Error ? error.message : '未知错误' }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reset to default settings
|
* 深合并工具函数
|
||||||
*/
|
|
||||||
public resetToDefaults(): SettingsData {
|
|
||||||
// Clear cache and reload from defaults
|
|
||||||
this.configCache.clear()
|
|
||||||
|
|
||||||
// Set all defaults using underscore uppercase keys
|
|
||||||
this.set('ERP_URL', DEFAULT_SETTINGS.erp.url)
|
|
||||||
this.set('ERP_USERNAME', DEFAULT_SETTINGS.erp.username)
|
|
||||||
this.set('ERP_PASSWORD', DEFAULT_SETTINGS.erp.password)
|
|
||||||
this.set('ERP_HEADLESS', DEFAULT_SETTINGS.erp.headless)
|
|
||||||
this.set('ERP_IGNORE_HTTPS_ERRORS', DEFAULT_SETTINGS.erp.ignoreHttpsErrors)
|
|
||||||
this.set('ERP_AUTO_CLOSE_BROWSER', DEFAULT_SETTINGS.erp.autoCloseBrowser)
|
|
||||||
|
|
||||||
this.set('DB_TYPE', DEFAULT_SETTINGS.database.dbType)
|
|
||||||
this.set('DB_SERVER', DEFAULT_SETTINGS.database.server)
|
|
||||||
this.set('DB_MYSQL_HOST', DEFAULT_SETTINGS.database.mysqlHost)
|
|
||||||
this.set('DB_MYSQL_PORT', DEFAULT_SETTINGS.database.mysqlPort)
|
|
||||||
this.set('DB_NAME', DEFAULT_SETTINGS.database.database)
|
|
||||||
this.set('DB_USERNAME', DEFAULT_SETTINGS.database.username)
|
|
||||||
this.set('DB_PASSWORD', DEFAULT_SETTINGS.database.password)
|
|
||||||
|
|
||||||
this.set('PATH_DATA_DIR', DEFAULT_SETTINGS.paths.dataDir)
|
|
||||||
this.set('PATH_DEFAULT_OUTPUT', DEFAULT_SETTINGS.paths.defaultOutput)
|
|
||||||
this.set('PATH_VALIDATION_OUTPUT', DEFAULT_SETTINGS.paths.validationOutput)
|
|
||||||
|
|
||||||
this.set('EXTRACTION_BATCH_SIZE', DEFAULT_SETTINGS.extraction.batchSize)
|
|
||||||
this.set('EXTRACTION_VERBOSE', DEFAULT_SETTINGS.extraction.verbose)
|
|
||||||
this.set('EXTRACTION_AUTO_CONVERT', DEFAULT_SETTINGS.extraction.autoConvert)
|
|
||||||
this.set('EXTRACTION_MERGE_BATCHES', DEFAULT_SETTINGS.extraction.mergeBatches)
|
|
||||||
this.set('EXTRACTION_ENABLE_DB_PERSISTENCE', DEFAULT_SETTINGS.extraction.enableDbPersistence)
|
|
||||||
|
|
||||||
this.set('VALIDATION_DATA_SOURCE', DEFAULT_SETTINGS.validation.dataSource)
|
|
||||||
this.set('VALIDATION_BATCH_SIZE', DEFAULT_SETTINGS.validation.batchSize)
|
|
||||||
this.set('VALIDATION_MATCH_MODE', DEFAULT_SETTINGS.validation.matchMode)
|
|
||||||
this.set('VALIDATION_ENABLE_CRUD', DEFAULT_SETTINGS.validation.enableCrud)
|
|
||||||
this.set('VALIDATION_DEFAULT_MANAGER', DEFAULT_SETTINGS.validation.defaultManager)
|
|
||||||
|
|
||||||
this.set('UI_FONT_FAMILY', DEFAULT_SETTINGS.ui.fontFamily)
|
|
||||||
this.set('UI_FONT_SIZE', DEFAULT_SETTINGS.ui.fontSize)
|
|
||||||
this.set('UI_PRODUCTION_ID_INPUT_WIDTH', DEFAULT_SETTINGS.ui.productionIdInputWidth)
|
|
||||||
|
|
||||||
this.set('EXECUTION_DRYRUN', DEFAULT_SETTINGS.execution.dryRun)
|
|
||||||
|
|
||||||
return DEFAULT_SETTINGS
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get default settings
|
|
||||||
*/
|
|
||||||
public getDefaultSettings(): SettingsData {
|
|
||||||
return DEFAULT_SETTINGS
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Backup current .env file
|
|
||||||
*/
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Restore .env file from backup
|
|
||||||
*/
|
*/
|
||||||
private async restoreBackup(): Promise<boolean> {
|
private deepMerge<T extends Record<string, any>>(source: T, target: Partial<T>): T {
|
||||||
try {
|
const result = { ...source }
|
||||||
if (fs.existsSync(this.backupPath)) {
|
for (const key in target) {
|
||||||
fs.copyFileSync(this.backupPath, this.envPath)
|
if (target[key] !== undefined) {
|
||||||
await this.loadEnvFile()
|
if (
|
||||||
log.debug('Restored from backup')
|
typeof target[key] === 'object' &&
|
||||||
return true
|
target[key] !== null &&
|
||||||
}
|
!Array.isArray(target[key])
|
||||||
return false
|
) {
|
||||||
} catch (error) {
|
result[key] = this.deepMerge(result[key] as any, target[key] as any)
|
||||||
log.error('Failed to restore backup', { error })
|
} else {
|
||||||
return false
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
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()
|
||||||
|
}
|
||||||
@@ -2,21 +2,23 @@
|
|||||||
* TypeORM Data Source Configuration
|
* TypeORM Data Source Configuration
|
||||||
*
|
*
|
||||||
* Provides a centralized database connection for TypeORM entities.
|
* Provides a centralized database connection for TypeORM entities.
|
||||||
* Supports both MySQL and SQL Server based on DB_TYPE environment variable.
|
* 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 'reflect-metadata'
|
||||||
import { DataSource, DataSourceOptions } from 'typeorm'
|
import { DataSource, DataSourceOptions } from 'typeorm'
|
||||||
|
import { ConfigManager } from '../config/config-manager'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get database type from environment
|
* Get database type from config manager
|
||||||
*/
|
*/
|
||||||
function getDatabaseType(): 'mysql' | 'mssql' {
|
function getDatabaseType(): 'mysql' | 'mssql' {
|
||||||
const dbType = process.env.DB_TYPE?.toLowerCase()
|
const configManager = ConfigManager.getInstance()
|
||||||
if (dbType === 'sqlserver' || dbType === 'mssql') {
|
const dbType = configManager.getDatabaseType()
|
||||||
return 'mssql'
|
return dbType === 'sqlserver' ? 'mssql' : 'mysql'
|
||||||
}
|
|
||||||
return 'mysql'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,36 +26,40 @@ function getDatabaseType(): 'mysql' | 'mssql' {
|
|||||||
*/
|
*/
|
||||||
function buildDataSourceOptions(): DataSourceOptions {
|
function buildDataSourceOptions(): DataSourceOptions {
|
||||||
const type = getDatabaseType()
|
const type = getDatabaseType()
|
||||||
|
const configManager = ConfigManager.getInstance()
|
||||||
|
const config = configManager.getConfig()
|
||||||
|
|
||||||
const commonOptions: Partial<DataSourceOptions> = {
|
const commonOptions: Partial<DataSourceOptions> = {
|
||||||
entities: [__dirname + '/entities/*.{ts,js}'],
|
entities: [__dirname + '/entities/*.{ts,js}'],
|
||||||
synchronize: false, // Never auto-sync in production
|
synchronize: false, // Never auto-sync in production
|
||||||
logging: process.env.NODE_ENV !== 'production'
|
logging: false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'mssql') {
|
if (type === 'mssql') {
|
||||||
|
const dbConfig = config.database.sqlserver
|
||||||
return {
|
return {
|
||||||
type: 'mssql',
|
type: 'mssql',
|
||||||
host: process.env.DB_SERVER || 'localhost',
|
host: dbConfig.server,
|
||||||
port: parseInt(process.env.DB_SQLSERVER_PORT || '1433', 10),
|
port: dbConfig.port,
|
||||||
username: process.env.DB_USERNAME || 'sa',
|
username: dbConfig.username,
|
||||||
password: process.env.DB_PASSWORD || '',
|
password: dbConfig.password,
|
||||||
database: process.env.DB_NAME || '',
|
database: dbConfig.database,
|
||||||
options: {
|
options: {
|
||||||
encrypt: process.env.DB_TRUST_SERVER_CERTIFICATE === 'yes',
|
encrypt: false,
|
||||||
trustServerCertificate: process.env.DB_TRUST_SERVER_CERTIFICATE === 'yes'
|
trustServerCertificate: dbConfig.trustServerCertificate
|
||||||
},
|
},
|
||||||
...commonOptions
|
...commonOptions
|
||||||
} as DataSourceOptions
|
} as DataSourceOptions
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const dbConfig = config.database.mysql
|
||||||
return {
|
return {
|
||||||
type: 'mysql',
|
type: 'mysql',
|
||||||
host: process.env.DB_MYSQL_HOST || 'localhost',
|
host: dbConfig.host,
|
||||||
port: parseInt(process.env.DB_MYSQL_PORT || '3306', 10),
|
port: dbConfig.port,
|
||||||
username: process.env.DB_USERNAME || 'root',
|
username: dbConfig.username,
|
||||||
password: process.env.DB_PASSWORD || '',
|
password: dbConfig.password,
|
||||||
database: process.env.DB_NAME || '',
|
database: dbConfig.database,
|
||||||
...commonOptions
|
...commonOptions
|
||||||
} as DataSourceOptions
|
} as DataSourceOptions
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,7 @@
|
|||||||
* - Statistics gathering
|
* - Statistics gathering
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { MySqlService } from './mysql'
|
import { create, type IDatabaseService } from './index'
|
||||||
import { SqlServerService } from './sql-server'
|
|
||||||
import sql from 'mssql'
|
|
||||||
import { createLogger } from '../logger'
|
import { createLogger } from '../logger'
|
||||||
|
|
||||||
const log = createLogger('DiscreteMaterialPlanDAO')
|
const log = createLogger('DiscreteMaterialPlanDAO')
|
||||||
@@ -95,70 +93,37 @@ export const DISCRETE_MATERIAL_PLAN_CONFIG = {
|
|||||||
* DiscreteMaterialPlanDAO Class
|
* DiscreteMaterialPlanDAO Class
|
||||||
*/
|
*/
|
||||||
export class DiscreteMaterialPlanDAO {
|
export class DiscreteMaterialPlanDAO {
|
||||||
private mysqlService: MySqlService | null = null
|
private dbService: IDatabaseService | null = null
|
||||||
private sqlServerService: SqlServerService | null = null
|
|
||||||
private dbType: 'mysql' | 'sqlserver' = 'mysql'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Constructor - determine database type from environment
|
|
||||||
*/
|
|
||||||
constructor() {
|
|
||||||
const dbType = process.env.DB_TYPE?.toLowerCase()
|
|
||||||
if (dbType === 'sqlserver' || dbType === 'mssql') {
|
|
||||||
this.dbType = 'sqlserver'
|
|
||||||
} else {
|
|
||||||
this.dbType = 'mysql'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the appropriate table name based on database type
|
* Get the appropriate table name based on database type
|
||||||
*/
|
*/
|
||||||
private getTableName(): string {
|
private getTableName(): string {
|
||||||
return this.dbType === 'sqlserver'
|
const isSqlServer = this.dbService?.type === 'sqlserver'
|
||||||
|
return isSqlServer
|
||||||
? DISCRETE_MATERIAL_PLAN_CONFIG.TABLE_NAME_SQLSERVER
|
? DISCRETE_MATERIAL_PLAN_CONFIG.TABLE_NAME_SQLSERVER
|
||||||
: DISCRETE_MATERIAL_PLAN_CONFIG.TABLE_NAME_MYSQL
|
: DISCRETE_MATERIAL_PLAN_CONFIG.TABLE_NAME_MYSQL
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get database service instance (MySQL or SQL Server)
|
* Get database service instance using DatabaseFactory
|
||||||
*/
|
*/
|
||||||
private async getDatabaseService(): Promise<MySqlService | SqlServerService> {
|
private async getDatabaseService(): Promise<IDatabaseService> {
|
||||||
if (this.dbType === 'sqlserver') {
|
if (this.dbService && this.dbService.isConnected()) {
|
||||||
if (this.sqlServerService && this.sqlServerService.isConnected()) {
|
return this.dbService
|
||||||
return this.sqlServerService
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.sqlServerService = new SqlServerService({
|
this.dbService = await create()
|
||||||
server: process.env.DB_SERVER || 'localhost',
|
return this.dbService
|
||||||
port: parseInt(process.env.DB_SQLSERVER_PORT || '1433', 10),
|
|
||||||
user: process.env.DB_USERNAME || 'sa',
|
|
||||||
password: process.env.DB_PASSWORD || '',
|
|
||||||
database: process.env.DB_NAME || '',
|
|
||||||
options: {
|
|
||||||
encrypt: process.env.DB_TRUST_SERVER_CERTIFICATE === 'yes',
|
|
||||||
trustServerCertificate: process.env.DB_TRUST_SERVER_CERTIFICATE === 'yes'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
await this.sqlServerService.connect()
|
|
||||||
return this.sqlServerService
|
|
||||||
} else {
|
|
||||||
if (this.mysqlService && this.mysqlService.isConnected()) {
|
|
||||||
return this.mysqlService
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.mysqlService = new MySqlService({
|
/**
|
||||||
host: process.env.DB_MYSQL_HOST || 'localhost',
|
* Build placeholders for IN clause based on database type
|
||||||
port: parseInt(process.env.DB_MYSQL_PORT || '3306', 10),
|
*/
|
||||||
user: process.env.DB_USERNAME || 'root',
|
private buildPlaceholders(count: number, isSqlServer: boolean): string {
|
||||||
password: process.env.DB_PASSWORD || '',
|
return isSqlServer
|
||||||
database: process.env.DB_NAME || ''
|
? Array.from({ length: count }, (_, idx) => `@p${idx}`).join(',')
|
||||||
})
|
: Array.from({ length: count }, () => '?').join(',')
|
||||||
|
|
||||||
await this.mysqlService.connect()
|
|
||||||
return this.mysqlService
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== QUERY ALL ====================
|
// ==================== QUERY ALL ====================
|
||||||
@@ -173,11 +138,7 @@ export class DiscreteMaterialPlanDAO {
|
|||||||
const tableName = this.getTableName()
|
const tableName = this.getTableName()
|
||||||
|
|
||||||
const sqlString = `SELECT * FROM ${tableName}`
|
const sqlString = `SELECT * FROM ${tableName}`
|
||||||
|
const result = await dbService.query(sqlString)
|
||||||
const result =
|
|
||||||
this.dbType === 'sqlserver'
|
|
||||||
? await (dbService as SqlServerService).query(sqlString)
|
|
||||||
: await (dbService as MySqlService).query(sqlString)
|
|
||||||
|
|
||||||
return result.rows
|
return result.rows
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -221,11 +182,7 @@ export class DiscreteMaterialPlanDAO {
|
|||||||
WHERE rn = 1
|
WHERE rn = 1
|
||||||
`
|
`
|
||||||
|
|
||||||
const result =
|
const result = await dbService.query(sqlString)
|
||||||
this.dbType === 'sqlserver'
|
|
||||||
? await (dbService as SqlServerService).query(sqlString)
|
|
||||||
: await (dbService as MySqlService).query(sqlString)
|
|
||||||
|
|
||||||
return result.rows
|
return result.rows
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error('Query all distinct by material code error', {
|
log.error('Query all distinct by material code error', {
|
||||||
@@ -250,19 +207,13 @@ export class DiscreteMaterialPlanDAO {
|
|||||||
try {
|
try {
|
||||||
const dbService = await this.getDatabaseService()
|
const dbService = await this.getDatabaseService()
|
||||||
const tableName = this.getTableName()
|
const tableName = this.getTableName()
|
||||||
const batchSize = 2000
|
const isSqlServer = dbService.type === 'sqlserver'
|
||||||
|
const batchSize = 1500
|
||||||
const allResults: any[] = []
|
const allResults: any[] = []
|
||||||
|
|
||||||
for (let i = 0; i < sourceNumbers.length; i += batchSize) {
|
for (let i = 0; i < sourceNumbers.length; i += batchSize) {
|
||||||
const batch = sourceNumbers.slice(i, i + batchSize)
|
const batch = sourceNumbers.slice(i, i + batchSize)
|
||||||
|
const placeholders = this.buildPlaceholders(batch.length, isSqlServer)
|
||||||
if (this.dbType === 'sqlserver') {
|
|
||||||
const placeholders = batch.map((_, idx) => `@p${idx}`).join(',')
|
|
||||||
const params: Record<string, { value: string; type: sql.ISqlType }> = {}
|
|
||||||
|
|
||||||
batch.forEach((num, idx) => {
|
|
||||||
params[`p${idx}`] = { value: num, type: sql.NVarChar }
|
|
||||||
})
|
|
||||||
|
|
||||||
const sqlString = `
|
const sqlString = `
|
||||||
SELECT *
|
SELECT *
|
||||||
@@ -270,20 +221,8 @@ export class DiscreteMaterialPlanDAO {
|
|||||||
WHERE SourceNumber IN (${placeholders})
|
WHERE SourceNumber IN (${placeholders})
|
||||||
`
|
`
|
||||||
|
|
||||||
const result = await (dbService as SqlServerService).queryWithParams(sqlString, params)
|
const result = await dbService.query(sqlString, batch)
|
||||||
allResults.push(...result.rows)
|
allResults.push(...result.rows)
|
||||||
} else {
|
|
||||||
const placeholders = batch.map(() => '?').join(',')
|
|
||||||
|
|
||||||
const sqlString = `
|
|
||||||
SELECT *
|
|
||||||
FROM ${tableName}
|
|
||||||
WHERE SourceNumber IN (${placeholders})
|
|
||||||
`
|
|
||||||
|
|
||||||
const result = await (dbService as MySqlService).query(sqlString, batch)
|
|
||||||
allResults.push(...result.rows)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return allResults
|
return allResults
|
||||||
@@ -310,19 +249,13 @@ export class DiscreteMaterialPlanDAO {
|
|||||||
try {
|
try {
|
||||||
const dbService = await this.getDatabaseService()
|
const dbService = await this.getDatabaseService()
|
||||||
const tableName = this.getTableName()
|
const tableName = this.getTableName()
|
||||||
const batchSize = 2000
|
const isSqlServer = dbService.type === 'sqlserver'
|
||||||
|
const batchSize = 1500
|
||||||
const allResults: any[] = []
|
const allResults: any[] = []
|
||||||
|
|
||||||
for (let i = 0; i < sourceNumbers.length; i += batchSize) {
|
for (let i = 0; i < sourceNumbers.length; i += batchSize) {
|
||||||
const batch = sourceNumbers.slice(i, i + batchSize)
|
const batch = sourceNumbers.slice(i, i + batchSize)
|
||||||
|
const placeholders = this.buildPlaceholders(batch.length, isSqlServer)
|
||||||
if (this.dbType === 'sqlserver') {
|
|
||||||
const placeholders = batch.map((_, idx) => `@p${idx}`).join(',')
|
|
||||||
const params: Record<string, { value: string; type: sql.ISqlType }> = {}
|
|
||||||
|
|
||||||
batch.forEach((num, idx) => {
|
|
||||||
params[`p${idx}`] = { value: num, type: sql.NVarChar }
|
|
||||||
})
|
|
||||||
|
|
||||||
const sqlString = `
|
const sqlString = `
|
||||||
WITH RankedRecords AS (
|
WITH RankedRecords AS (
|
||||||
@@ -347,37 +280,8 @@ export class DiscreteMaterialPlanDAO {
|
|||||||
WHERE rn = 1
|
WHERE rn = 1
|
||||||
`
|
`
|
||||||
|
|
||||||
const result = await (dbService as SqlServerService).queryWithParams(sqlString, params)
|
const result = await dbService.query(sqlString, batch)
|
||||||
allResults.push(...result.rows)
|
allResults.push(...result.rows)
|
||||||
} else {
|
|
||||||
const placeholders = batch.map(() => '?').join(',')
|
|
||||||
|
|
||||||
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 as MySqlService).query(sqlString, batch)
|
|
||||||
allResults.push(...result.rows)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return allResults
|
return allResults
|
||||||
@@ -398,30 +302,17 @@ export class DiscreteMaterialPlanDAO {
|
|||||||
try {
|
try {
|
||||||
const dbService = await this.getDatabaseService()
|
const dbService = await this.getDatabaseService()
|
||||||
const tableName = this.getTableName()
|
const tableName = this.getTableName()
|
||||||
|
const isSqlServer = dbService.type === 'sqlserver'
|
||||||
|
|
||||||
if (this.dbType === 'sqlserver') {
|
const placeholder = isSqlServer ? '@p0' : '?'
|
||||||
const sqlString = `
|
const sqlString = `
|
||||||
SELECT *
|
SELECT *
|
||||||
FROM ${tableName}
|
FROM ${tableName}
|
||||||
WHERE SourceNumber = @sourceNumber
|
WHERE SourceNumber = ${placeholder}
|
||||||
`
|
`
|
||||||
|
|
||||||
const result = await (dbService as SqlServerService).queryWithParams(sqlString, {
|
const result = await dbService.query(sqlString, [sourceNumber])
|
||||||
sourceNumber: { value: sourceNumber, type: sql.NVarChar }
|
|
||||||
})
|
|
||||||
|
|
||||||
return result.rows
|
return result.rows
|
||||||
} else {
|
|
||||||
const sqlString = `
|
|
||||||
SELECT *
|
|
||||||
FROM ${tableName}
|
|
||||||
WHERE SourceNumber = ?
|
|
||||||
`
|
|
||||||
|
|
||||||
const result = await (dbService as MySqlService).query(sqlString, [sourceNumber])
|
|
||||||
|
|
||||||
return result.rows
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error('Query by source number error', {
|
log.error('Query by source number error', {
|
||||||
error: error instanceof Error ? error.message : String(error)
|
error: error instanceof Error ? error.message : String(error)
|
||||||
@@ -441,30 +332,17 @@ export class DiscreteMaterialPlanDAO {
|
|||||||
try {
|
try {
|
||||||
const dbService = await this.getDatabaseService()
|
const dbService = await this.getDatabaseService()
|
||||||
const tableName = this.getTableName()
|
const tableName = this.getTableName()
|
||||||
|
const isSqlServer = dbService.type === 'sqlserver'
|
||||||
|
|
||||||
if (this.dbType === 'sqlserver') {
|
const placeholder = isSqlServer ? '@p0' : '?'
|
||||||
const sqlString = `
|
const sqlString = `
|
||||||
SELECT *
|
SELECT *
|
||||||
FROM ${tableName}
|
FROM ${tableName}
|
||||||
WHERE PlanNumber = @planNumber
|
WHERE PlanNumber = ${placeholder}
|
||||||
`
|
`
|
||||||
|
|
||||||
const result = await (dbService as SqlServerService).queryWithParams(sqlString, {
|
const result = await dbService.query(sqlString, [planNumber])
|
||||||
planNumber: { value: planNumber, type: sql.NVarChar }
|
|
||||||
})
|
|
||||||
|
|
||||||
return result.rows
|
return result.rows
|
||||||
} else {
|
|
||||||
const sqlString = `
|
|
||||||
SELECT *
|
|
||||||
FROM ${tableName}
|
|
||||||
WHERE PlanNumber = ?
|
|
||||||
`
|
|
||||||
|
|
||||||
const result = await (dbService as MySqlService).query(sqlString, [planNumber])
|
|
||||||
|
|
||||||
return result.rows
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error('Query by plan number error', {
|
log.error('Query by plan number error', {
|
||||||
error: error instanceof Error ? error.message : String(error)
|
error: error instanceof Error ? error.message : String(error)
|
||||||
@@ -486,14 +364,13 @@ export class DiscreteMaterialPlanDAO {
|
|||||||
try {
|
try {
|
||||||
const dbService = await this.getDatabaseService()
|
const dbService = await this.getDatabaseService()
|
||||||
const tableName = this.getTableName()
|
const tableName = this.getTableName()
|
||||||
|
const isSqlServer = dbService.type === 'sqlserver'
|
||||||
|
const batchSize = 1500
|
||||||
|
const allResults: any[] = []
|
||||||
|
|
||||||
if (this.dbType === 'sqlserver') {
|
for (let i = 0; i < planNumbers.length; i += batchSize) {
|
||||||
const placeholders = planNumbers.map((_, idx) => `@p${idx}`).join(',')
|
const batch = planNumbers.slice(i, i + batchSize)
|
||||||
const params: Record<string, { value: string; type: sql.ISqlType }> = {}
|
const placeholders = this.buildPlaceholders(batch.length, isSqlServer)
|
||||||
|
|
||||||
planNumbers.forEach((num, idx) => {
|
|
||||||
params[`p${idx}`] = { value: num, type: sql.NVarChar }
|
|
||||||
})
|
|
||||||
|
|
||||||
const sqlString = `
|
const sqlString = `
|
||||||
SELECT *
|
SELECT *
|
||||||
@@ -501,20 +378,11 @@ export class DiscreteMaterialPlanDAO {
|
|||||||
WHERE PlanNumber IN (${placeholders})
|
WHERE PlanNumber IN (${placeholders})
|
||||||
`
|
`
|
||||||
|
|
||||||
const result = await (dbService as SqlServerService).queryWithParams(sqlString, params)
|
const result = await dbService.query(sqlString, batch)
|
||||||
return result.rows
|
allResults.push(...result.rows)
|
||||||
} else {
|
|
||||||
const placeholders = planNumbers.map(() => '?').join(',')
|
|
||||||
|
|
||||||
const sqlString = `
|
|
||||||
SELECT *
|
|
||||||
FROM ${tableName}
|
|
||||||
WHERE PlanNumber IN (${placeholders})
|
|
||||||
`
|
|
||||||
|
|
||||||
const result = await (dbService as MySqlService).query(sqlString, planNumbers)
|
|
||||||
return result.rows
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return allResults
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error('Query by plan numbers error', {
|
log.error('Query by plan numbers error', {
|
||||||
error: error instanceof Error ? error.message : String(error)
|
error: error instanceof Error ? error.message : String(error)
|
||||||
@@ -523,6 +391,263 @@ export class DiscreteMaterialPlanDAO {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 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 ====================
|
// ==================== UTILITY METHODS ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -535,11 +660,7 @@ export class DiscreteMaterialPlanDAO {
|
|||||||
const tableName = this.getTableName()
|
const tableName = this.getTableName()
|
||||||
|
|
||||||
const sqlString = `SELECT COUNT(*) as count FROM ${tableName}`
|
const sqlString = `SELECT COUNT(*) as count FROM ${tableName}`
|
||||||
|
const result = await dbService.query(sqlString)
|
||||||
const result =
|
|
||||||
this.dbType === 'sqlserver'
|
|
||||||
? await (dbService as SqlServerService).query(sqlString)
|
|
||||||
: await (dbService as MySqlService).query(sqlString)
|
|
||||||
|
|
||||||
return result.rows.length > 0 ? (result.rows[0].count as number) : 0
|
return result.rows.length > 0 ? (result.rows[0].count as number) : 0
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -559,30 +680,17 @@ export class DiscreteMaterialPlanDAO {
|
|||||||
try {
|
try {
|
||||||
const dbService = await this.getDatabaseService()
|
const dbService = await this.getDatabaseService()
|
||||||
const tableName = this.getTableName()
|
const tableName = this.getTableName()
|
||||||
|
const isSqlServer = dbService.type === 'sqlserver'
|
||||||
|
|
||||||
if (this.dbType === 'sqlserver') {
|
const placeholder = isSqlServer ? '@p0' : '?'
|
||||||
const sqlString = `
|
const sqlString = `
|
||||||
SELECT COUNT(*) as count
|
SELECT COUNT(*) as count
|
||||||
FROM ${tableName}
|
FROM ${tableName}
|
||||||
WHERE PlanNumber = @planNumber
|
WHERE PlanNumber = ${placeholder}
|
||||||
`
|
`
|
||||||
|
|
||||||
const result = await (dbService as SqlServerService).queryWithParams(sqlString, {
|
const result = await dbService.query(sqlString, [planNumber])
|
||||||
planNumber: { value: planNumber, type: sql.NVarChar }
|
|
||||||
})
|
|
||||||
|
|
||||||
return result.rows.length > 0 ? (result.rows[0].count as number) : 0
|
return result.rows.length > 0 ? (result.rows[0].count as number) : 0
|
||||||
} else {
|
|
||||||
const sqlString = `
|
|
||||||
SELECT COUNT(*) as count
|
|
||||||
FROM ${tableName}
|
|
||||||
WHERE PlanNumber = ?
|
|
||||||
`
|
|
||||||
|
|
||||||
const result = await (dbService as MySqlService).query(sqlString, [planNumber])
|
|
||||||
|
|
||||||
return result.rows.length > 0 ? (result.rows[0].count as number) : 0
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error('Count by plan number error', {
|
log.error('Count by plan number error', {
|
||||||
error: error instanceof Error ? error.message : String(error)
|
error: error instanceof Error ? error.message : String(error)
|
||||||
@@ -600,15 +708,15 @@ export class DiscreteMaterialPlanDAO {
|
|||||||
try {
|
try {
|
||||||
const dbService = await this.getDatabaseService()
|
const dbService = await this.getDatabaseService()
|
||||||
const tableName = this.getTableName()
|
const tableName = this.getTableName()
|
||||||
|
const isSqlServer = dbService.type === 'sqlserver'
|
||||||
|
|
||||||
if (sourceNumbers && sourceNumbers.length > 0) {
|
if (sourceNumbers && sourceNumbers.length > 0) {
|
||||||
if (this.dbType === 'sqlserver') {
|
const batchSize = 1500
|
||||||
const placeholders = sourceNumbers.map((_, idx) => `@p${idx}`).join(',')
|
const allNames: string[] = []
|
||||||
const params: Record<string, { value: string; type: sql.ISqlType }> = {}
|
|
||||||
|
|
||||||
sourceNumbers.forEach((num, idx) => {
|
for (let i = 0; i < sourceNumbers.length; i += batchSize) {
|
||||||
params[`p${idx}`] = { value: num, type: sql.NVarChar }
|
const batch = sourceNumbers.slice(i, i + batchSize)
|
||||||
})
|
const placeholders = this.buildPlaceholders(batch.length, isSqlServer)
|
||||||
|
|
||||||
const sqlString = `
|
const sqlString = `
|
||||||
SELECT DISTINCT MaterialName
|
SELECT DISTINCT MaterialName
|
||||||
@@ -617,21 +725,11 @@ export class DiscreteMaterialPlanDAO {
|
|||||||
AND MaterialName IS NOT NULL
|
AND MaterialName IS NOT NULL
|
||||||
`
|
`
|
||||||
|
|
||||||
const result = await (dbService as SqlServerService).queryWithParams(sqlString, params)
|
const result = await dbService.query(sqlString, batch)
|
||||||
return result.rows.map((row) => row.MaterialName as string).filter(Boolean)
|
allNames.push(...result.rows.map((row) => row.MaterialName as string).filter(Boolean))
|
||||||
} else {
|
|
||||||
const placeholders = sourceNumbers.map(() => '?').join(',')
|
|
||||||
|
|
||||||
const sqlString = `
|
|
||||||
SELECT DISTINCT MaterialName
|
|
||||||
FROM ${tableName}
|
|
||||||
WHERE SourceNumber IN (${placeholders})
|
|
||||||
AND MaterialName IS NOT NULL
|
|
||||||
`
|
|
||||||
|
|
||||||
const result = await (dbService as MySqlService).query(sqlString, sourceNumbers)
|
|
||||||
return result.rows.map((row) => row.MaterialName as string).filter(Boolean)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return allNames
|
||||||
} else {
|
} else {
|
||||||
const sqlString = `
|
const sqlString = `
|
||||||
SELECT DISTINCT MaterialName
|
SELECT DISTINCT MaterialName
|
||||||
@@ -639,11 +737,7 @@ export class DiscreteMaterialPlanDAO {
|
|||||||
WHERE MaterialName IS NOT NULL
|
WHERE MaterialName IS NOT NULL
|
||||||
`
|
`
|
||||||
|
|
||||||
const result =
|
const result = await dbService.query(sqlString)
|
||||||
this.dbType === 'sqlserver'
|
|
||||||
? await (dbService as SqlServerService).query(sqlString)
|
|
||||||
: await (dbService as MySqlService).query(sqlString)
|
|
||||||
|
|
||||||
return result.rows.map((row) => row.MaterialName as string).filter(Boolean)
|
return result.rows.map((row) => row.MaterialName as string).filter(Boolean)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -673,11 +767,7 @@ export class DiscreteMaterialPlanDAO {
|
|||||||
FROM ${tableName}
|
FROM ${tableName}
|
||||||
`
|
`
|
||||||
|
|
||||||
const result =
|
const result = await dbService.query(sqlString)
|
||||||
this.dbType === 'sqlserver'
|
|
||||||
? await (dbService as SqlServerService).query(sqlString)
|
|
||||||
: await (dbService as MySqlService).query(sqlString)
|
|
||||||
|
|
||||||
return result.rows.length > 0 ? result.rows[0] : {}
|
return result.rows.length > 0 ? result.rows[0] : {}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error('Get statistics error', {
|
log.error('Get statistics error', {
|
||||||
@@ -691,13 +781,9 @@ export class DiscreteMaterialPlanDAO {
|
|||||||
* Disconnect from database
|
* Disconnect from database
|
||||||
*/
|
*/
|
||||||
async disconnect(): Promise<void> {
|
async disconnect(): Promise<void> {
|
||||||
if (this.mysqlService) {
|
if (this.dbService) {
|
||||||
await this.mysqlService.disconnect()
|
await this.dbService.disconnect()
|
||||||
this.mysqlService = null
|
this.dbService = null
|
||||||
}
|
|
||||||
if (this.sqlServerService) {
|
|
||||||
await this.sqlServerService.disconnect()
|
|
||||||
this.sqlServerService = 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'
|
||||||
@@ -8,9 +8,7 @@
|
|||||||
* - Statistics gathering
|
* - Statistics gathering
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { MySqlService } from './mysql'
|
import { create, type IDatabaseService } from './index'
|
||||||
import { SqlServerService } from './sql-server'
|
|
||||||
import sql from 'mssql'
|
|
||||||
import { createLogger } from '../logger'
|
import { createLogger } from '../logger'
|
||||||
|
|
||||||
const log = createLogger('MaterialsToBeDeletedDAO')
|
const log = createLogger('MaterialsToBeDeletedDAO')
|
||||||
@@ -59,70 +57,37 @@ export const MATERIALS_TO_BE_DELETED_CONFIG = {
|
|||||||
* MaterialsToBeDeleted DAO Class
|
* MaterialsToBeDeleted DAO Class
|
||||||
*/
|
*/
|
||||||
export class MaterialsToBeDeletedDAO {
|
export class MaterialsToBeDeletedDAO {
|
||||||
private mysqlService: MySqlService | null = null
|
private dbService: IDatabaseService | null = null
|
||||||
private sqlServerService: SqlServerService | null = null
|
|
||||||
private dbType: 'mysql' | 'sqlserver' = 'mysql'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Constructor - determine database type from environment
|
|
||||||
*/
|
|
||||||
constructor() {
|
|
||||||
const dbType = process.env.DB_TYPE?.toLowerCase()
|
|
||||||
if (dbType === 'sqlserver' || dbType === 'mssql') {
|
|
||||||
this.dbType = 'sqlserver'
|
|
||||||
} else {
|
|
||||||
this.dbType = 'mysql'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the appropriate table name based on database type
|
* Get the appropriate table name based on database type
|
||||||
*/
|
*/
|
||||||
private getTableName(): string {
|
private getTableName(): string {
|
||||||
return this.dbType === 'sqlserver'
|
const isSqlServer = this.dbService?.type === 'sqlserver'
|
||||||
|
return isSqlServer
|
||||||
? MATERIALS_TO_BE_DELETED_CONFIG.TABLE_NAME_SQLSERVER
|
? MATERIALS_TO_BE_DELETED_CONFIG.TABLE_NAME_SQLSERVER
|
||||||
: MATERIALS_TO_BE_DELETED_CONFIG.TABLE_NAME_MYSQL
|
: MATERIALS_TO_BE_DELETED_CONFIG.TABLE_NAME_MYSQL
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get database service instance (MySQL or SQL Server)
|
* Get database service instance using DatabaseFactory
|
||||||
*/
|
*/
|
||||||
private async getDatabaseService(): Promise<MySqlService | SqlServerService> {
|
private async getDatabaseService(): Promise<IDatabaseService> {
|
||||||
if (this.dbType === 'sqlserver') {
|
if (this.dbService && this.dbService.isConnected()) {
|
||||||
if (this.sqlServerService && this.sqlServerService.isConnected()) {
|
return this.dbService
|
||||||
return this.sqlServerService
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.sqlServerService = new SqlServerService({
|
this.dbService = await create()
|
||||||
server: process.env.DB_SERVER || 'localhost',
|
return this.dbService
|
||||||
port: parseInt(process.env.DB_SQLSERVER_PORT || '1433', 10),
|
|
||||||
user: process.env.DB_USERNAME || 'sa',
|
|
||||||
password: process.env.DB_PASSWORD || '',
|
|
||||||
database: process.env.DB_NAME || '',
|
|
||||||
options: {
|
|
||||||
encrypt: process.env.DB_TRUST_SERVER_CERTIFICATE === 'yes',
|
|
||||||
trustServerCertificate: process.env.DB_TRUST_SERVER_CERTIFICATE === 'yes'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
await this.sqlServerService.connect()
|
|
||||||
return this.sqlServerService
|
|
||||||
} else {
|
|
||||||
if (this.mysqlService && this.mysqlService.isConnected()) {
|
|
||||||
return this.mysqlService
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.mysqlService = new MySqlService({
|
/**
|
||||||
host: process.env.DB_MYSQL_HOST || 'localhost',
|
* Build placeholders for IN clause based on database type
|
||||||
port: parseInt(process.env.DB_MYSQL_PORT || '3306', 10),
|
*/
|
||||||
user: process.env.DB_USERNAME || 'root',
|
private buildPlaceholders(count: number, isSqlServer: boolean): string {
|
||||||
password: process.env.DB_PASSWORD || '',
|
return isSqlServer
|
||||||
database: process.env.DB_NAME || ''
|
? Array.from({ length: count }, (_, idx) => `@p${idx}`).join(',')
|
||||||
})
|
: Array.from({ length: count }, () => '?').join(',')
|
||||||
|
|
||||||
await this.mysqlService.connect()
|
|
||||||
return this.mysqlService
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== UPSERT (MERGE) ====================
|
// ==================== UPSERT (MERGE) ====================
|
||||||
@@ -144,28 +109,28 @@ export class MaterialsToBeDeletedDAO {
|
|||||||
const tableName = this.getTableName()
|
const tableName = this.getTableName()
|
||||||
const code = materialCode.trim()
|
const code = materialCode.trim()
|
||||||
const manager = managerName?.trim() || null
|
const manager = managerName?.trim() || null
|
||||||
|
const isSqlServer = dbService.type === 'sqlserver'
|
||||||
|
|
||||||
if (this.dbType === 'sqlserver') {
|
if (isSqlServer) {
|
||||||
|
// SQL Server MERGE statement
|
||||||
const sqlString = `
|
const sqlString = `
|
||||||
MERGE ${tableName} AS target
|
MERGE ${tableName} AS target
|
||||||
USING (VALUES (@materialCode, @managerName)) AS source (MaterialCode, ManagerName)
|
USING (VALUES (@p0, @p1)) AS source (MaterialCode, ManagerName)
|
||||||
ON target.MaterialCode = source.MaterialCode
|
ON target.MaterialCode = source.MaterialCode
|
||||||
WHEN MATCHED THEN UPDATE SET ManagerName = source.ManagerName
|
WHEN MATCHED THEN UPDATE SET ManagerName = source.ManagerName
|
||||||
WHEN NOT MATCHED THEN INSERT (MaterialCode, ManagerName) VALUES (source.MaterialCode, source.ManagerName);
|
WHEN NOT MATCHED THEN INSERT (MaterialCode, ManagerName) VALUES (source.MaterialCode, source.ManagerName);
|
||||||
`
|
`
|
||||||
|
|
||||||
await (dbService as SqlServerService).queryWithParams(sqlString, {
|
await dbService.query(sqlString, [code, manager])
|
||||||
materialCode: { value: code, type: sql.NVarChar },
|
|
||||||
managerName: { value: manager, type: sql.NVarChar }
|
|
||||||
})
|
|
||||||
} else {
|
} else {
|
||||||
|
// MySQL ON DUPLICATE KEY UPDATE
|
||||||
const sqlString = `
|
const sqlString = `
|
||||||
INSERT INTO ${tableName} (MaterialCode, ManagerName)
|
INSERT INTO ${tableName} (MaterialCode, ManagerName)
|
||||||
VALUES (?, ?)
|
VALUES (?, ?)
|
||||||
ON DUPLICATE KEY UPDATE ManagerName = VALUES(ManagerName)
|
ON DUPLICATE KEY UPDATE ManagerName = VALUES(ManagerName)
|
||||||
`
|
`
|
||||||
|
|
||||||
await (dbService as MySqlService).query(sqlString, [code, manager])
|
await dbService.query(sqlString, [code, manager])
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
@@ -198,6 +163,7 @@ export class MaterialsToBeDeletedDAO {
|
|||||||
try {
|
try {
|
||||||
const dbService = await this.getDatabaseService()
|
const dbService = await this.getDatabaseService()
|
||||||
const tableName = this.getTableName()
|
const tableName = this.getTableName()
|
||||||
|
const isSqlServer = dbService.type === 'sqlserver'
|
||||||
|
|
||||||
for (const material of materials) {
|
for (const material of materials) {
|
||||||
const materialCode = material.materialCode?.trim()
|
const materialCode = material.materialCode?.trim()
|
||||||
@@ -209,27 +175,26 @@ export class MaterialsToBeDeletedDAO {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (this.dbType === 'sqlserver') {
|
if (isSqlServer) {
|
||||||
|
// SQL Server MERGE statement
|
||||||
const sqlString = `
|
const sqlString = `
|
||||||
MERGE ${tableName} AS target
|
MERGE ${tableName} AS target
|
||||||
USING (VALUES (@materialCode, @managerName)) AS source (MaterialCode, ManagerName)
|
USING (VALUES (@p0, @p1)) AS source (MaterialCode, ManagerName)
|
||||||
ON target.MaterialCode = source.MaterialCode
|
ON target.MaterialCode = source.MaterialCode
|
||||||
WHEN MATCHED THEN UPDATE SET ManagerName = source.ManagerName
|
WHEN MATCHED THEN UPDATE SET ManagerName = source.ManagerName
|
||||||
WHEN NOT MATCHED THEN INSERT (MaterialCode, ManagerName) VALUES (source.MaterialCode, source.ManagerName);
|
WHEN NOT MATCHED THEN INSERT (MaterialCode, ManagerName) VALUES (source.MaterialCode, source.ManagerName);
|
||||||
`
|
`
|
||||||
|
|
||||||
await (dbService as SqlServerService).queryWithParams(sqlString, {
|
await dbService.query(sqlString, [materialCode, managerName || null])
|
||||||
materialCode: { value: materialCode, type: sql.NVarChar },
|
|
||||||
managerName: { value: managerName || null, type: sql.NVarChar }
|
|
||||||
})
|
|
||||||
} else {
|
} else {
|
||||||
|
// MySQL ON DUPLICATE KEY UPDATE
|
||||||
const sqlString = `
|
const sqlString = `
|
||||||
INSERT INTO ${tableName} (MaterialCode, ManagerName)
|
INSERT INTO ${tableName} (MaterialCode, ManagerName)
|
||||||
VALUES (?, ?)
|
VALUES (?, ?)
|
||||||
ON DUPLICATE KEY UPDATE ManagerName = VALUES(ManagerName)
|
ON DUPLICATE KEY UPDATE ManagerName = VALUES(ManagerName)
|
||||||
`
|
`
|
||||||
|
|
||||||
await (dbService as MySqlService).query(sqlString, [materialCode, managerName || null])
|
await dbService.query(sqlString, [materialCode, managerName || null])
|
||||||
}
|
}
|
||||||
|
|
||||||
stats.success++
|
stats.success++
|
||||||
@@ -251,6 +216,52 @@ export class MaterialsToBeDeletedDAO {
|
|||||||
return stats
|
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 ====================
|
// ==================== READ ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -268,11 +279,7 @@ export class MaterialsToBeDeletedDAO {
|
|||||||
WHERE MaterialCode IS NOT NULL
|
WHERE MaterialCode IS NOT NULL
|
||||||
`
|
`
|
||||||
|
|
||||||
const result =
|
const result = await dbService.query(sqlString)
|
||||||
this.dbType === 'sqlserver'
|
|
||||||
? await (dbService as SqlServerService).query(sqlString)
|
|
||||||
: await (dbService as MySqlService).query(sqlString)
|
|
||||||
|
|
||||||
return new Set(result.rows.map((row) => row.MaterialCode as string).filter(Boolean))
|
return new Set(result.rows.map((row) => row.MaterialCode as string).filter(Boolean))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error('Get all material codes error', {
|
log.error('Get all material codes error', {
|
||||||
@@ -298,11 +305,7 @@ export class MaterialsToBeDeletedDAO {
|
|||||||
ORDER BY ManagerName, MaterialCode
|
ORDER BY ManagerName, MaterialCode
|
||||||
`
|
`
|
||||||
|
|
||||||
const result =
|
const result = await dbService.query(sqlString)
|
||||||
this.dbType === 'sqlserver'
|
|
||||||
? await (dbService as SqlServerService).query(sqlString)
|
|
||||||
: await (dbService as MySqlService).query(sqlString)
|
|
||||||
|
|
||||||
return result.rows.map((row) => ({
|
return result.rows.map((row) => ({
|
||||||
id: row.ID as number,
|
id: row.ID as number,
|
||||||
materialCode: row.MaterialCode as string,
|
materialCode: row.MaterialCode as string,
|
||||||
@@ -325,40 +328,22 @@ export class MaterialsToBeDeletedDAO {
|
|||||||
try {
|
try {
|
||||||
const dbService = await this.getDatabaseService()
|
const dbService = await this.getDatabaseService()
|
||||||
const tableName = this.getTableName()
|
const tableName = this.getTableName()
|
||||||
|
const isSqlServer = dbService.type === 'sqlserver'
|
||||||
|
|
||||||
if (this.dbType === 'sqlserver') {
|
const placeholder = isSqlServer ? '@p0' : '?'
|
||||||
const sqlString = `
|
const sqlString = `
|
||||||
SELECT ID, MaterialCode, ManagerName
|
SELECT ID, MaterialCode, ManagerName
|
||||||
FROM ${tableName}
|
FROM ${tableName}
|
||||||
WHERE ManagerName = @managerName AND MaterialCode IS NOT NULL
|
WHERE ManagerName = ${placeholder} AND MaterialCode IS NOT NULL
|
||||||
ORDER BY MaterialCode
|
ORDER BY MaterialCode
|
||||||
`
|
`
|
||||||
|
|
||||||
const result = await (dbService as SqlServerService).queryWithParams(sqlString, {
|
const result = await dbService.query(sqlString, [managerName])
|
||||||
managerName: { value: managerName, type: sql.NVarChar }
|
|
||||||
})
|
|
||||||
|
|
||||||
return result.rows.map((row) => ({
|
return result.rows.map((row) => ({
|
||||||
id: row.ID as number,
|
id: row.ID as number,
|
||||||
materialCode: row.MaterialCode as string,
|
materialCode: row.MaterialCode as string,
|
||||||
managerName: row.ManagerName as string
|
managerName: row.ManagerName as string
|
||||||
}))
|
}))
|
||||||
} else {
|
|
||||||
const sqlString = `
|
|
||||||
SELECT ID, MaterialCode, ManagerName
|
|
||||||
FROM ${tableName}
|
|
||||||
WHERE ManagerName = ? AND MaterialCode IS NOT NULL
|
|
||||||
ORDER BY MaterialCode
|
|
||||||
`
|
|
||||||
|
|
||||||
const result = await (dbService as MySqlService).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) {
|
} catch (error) {
|
||||||
log.error('Get materials by manager error', {
|
log.error('Get materials by manager error', {
|
||||||
error: error instanceof Error ? error.message : String(error)
|
error: error instanceof Error ? error.message : String(error)
|
||||||
@@ -383,11 +368,7 @@ export class MaterialsToBeDeletedDAO {
|
|||||||
ORDER BY ManagerName
|
ORDER BY ManagerName
|
||||||
`
|
`
|
||||||
|
|
||||||
const result =
|
const result = await dbService.query(sqlString)
|
||||||
this.dbType === 'sqlserver'
|
|
||||||
? await (dbService as SqlServerService).query(sqlString)
|
|
||||||
: await (dbService as MySqlService).query(sqlString)
|
|
||||||
|
|
||||||
return result.rows.map((row) => row.ManagerName as string).filter(Boolean)
|
return result.rows.map((row) => row.ManagerName as string).filter(Boolean)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error('Get managers error', {
|
log.error('Get managers error', {
|
||||||
@@ -407,17 +388,16 @@ export class MaterialsToBeDeletedDAO {
|
|||||||
const dbService = await this.getDatabaseService()
|
const dbService = await this.getDatabaseService()
|
||||||
const tableName = this.getTableName()
|
const tableName = this.getTableName()
|
||||||
const code = materialCode.trim()
|
const code = materialCode.trim()
|
||||||
|
const isSqlServer = dbService.type === 'sqlserver'
|
||||||
|
|
||||||
if (this.dbType === 'sqlserver') {
|
const placeholder = isSqlServer ? '@p0' : '?'
|
||||||
const sqlString = `
|
const sqlString = `
|
||||||
SELECT ID, MaterialCode, ManagerName
|
SELECT ID, MaterialCode, ManagerName
|
||||||
FROM ${tableName}
|
FROM ${tableName}
|
||||||
WHERE MaterialCode = @materialCode
|
WHERE MaterialCode = ${placeholder}
|
||||||
`
|
`
|
||||||
|
|
||||||
const result = await (dbService as SqlServerService).queryWithParams(sqlString, {
|
const result = await dbService.query(sqlString, [code])
|
||||||
materialCode: { value: code, type: sql.NVarChar }
|
|
||||||
})
|
|
||||||
|
|
||||||
if (result.rows.length === 0) {
|
if (result.rows.length === 0) {
|
||||||
return null
|
return null
|
||||||
@@ -429,26 +409,6 @@ export class MaterialsToBeDeletedDAO {
|
|||||||
materialCode: row.MaterialCode as string,
|
materialCode: row.MaterialCode as string,
|
||||||
managerName: row.ManagerName as string
|
managerName: row.ManagerName as string
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
const sqlString = `
|
|
||||||
SELECT ID, MaterialCode, ManagerName
|
|
||||||
FROM ${tableName}
|
|
||||||
WHERE MaterialCode = ?
|
|
||||||
`
|
|
||||||
|
|
||||||
const result = await (dbService as MySqlService).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) {
|
} catch (error) {
|
||||||
log.error('Get record by material code error', {
|
log.error('Get record by material code error', {
|
||||||
error: error instanceof Error ? error.message : String(error)
|
error: error instanceof Error ? error.message : String(error)
|
||||||
@@ -469,28 +429,16 @@ export class MaterialsToBeDeletedDAO {
|
|||||||
const dbService = await this.getDatabaseService()
|
const dbService = await this.getDatabaseService()
|
||||||
const tableName = this.getTableName()
|
const tableName = this.getTableName()
|
||||||
const code = materialCode.trim()
|
const code = materialCode.trim()
|
||||||
|
const isSqlServer = dbService.type === 'sqlserver'
|
||||||
|
|
||||||
if (this.dbType === 'sqlserver') {
|
const placeholder = isSqlServer ? '@p0' : '?'
|
||||||
const sqlString = `
|
const sqlString = `
|
||||||
DELETE FROM ${tableName}
|
DELETE FROM ${tableName}
|
||||||
WHERE MaterialCode = @materialCode
|
WHERE MaterialCode = ${placeholder}
|
||||||
`
|
`
|
||||||
|
|
||||||
const result = await (dbService as SqlServerService).queryWithParams(sqlString, {
|
const result = await dbService.query(sqlString, [code])
|
||||||
materialCode: { value: code, type: sql.NVarChar }
|
|
||||||
})
|
|
||||||
|
|
||||||
return result.rowCount > 0
|
return result.rowCount > 0
|
||||||
} else {
|
|
||||||
const sqlString = `
|
|
||||||
DELETE FROM ${tableName}
|
|
||||||
WHERE MaterialCode = ?
|
|
||||||
`
|
|
||||||
|
|
||||||
const result = await (dbService as MySqlService).query(sqlString, [code])
|
|
||||||
|
|
||||||
return result.rowCount > 0
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error('Delete by material code error', {
|
log.error('Delete by material code error', {
|
||||||
error: error instanceof Error ? error.message : String(error)
|
error: error instanceof Error ? error.message : String(error)
|
||||||
@@ -508,28 +456,16 @@ export class MaterialsToBeDeletedDAO {
|
|||||||
try {
|
try {
|
||||||
const dbService = await this.getDatabaseService()
|
const dbService = await this.getDatabaseService()
|
||||||
const tableName = this.getTableName()
|
const tableName = this.getTableName()
|
||||||
|
const isSqlServer = dbService.type === 'sqlserver'
|
||||||
|
|
||||||
if (this.dbType === 'sqlserver') {
|
const placeholder = isSqlServer ? '@p0' : '?'
|
||||||
const sqlString = `
|
const sqlString = `
|
||||||
DELETE FROM ${tableName}
|
DELETE FROM ${tableName}
|
||||||
WHERE ManagerName = @managerName
|
WHERE ManagerName = ${placeholder}
|
||||||
`
|
`
|
||||||
|
|
||||||
const result = await (dbService as SqlServerService).queryWithParams(sqlString, {
|
const result = await dbService.query(sqlString, [managerName])
|
||||||
managerName: { value: managerName, type: sql.NVarChar }
|
|
||||||
})
|
|
||||||
|
|
||||||
return result.rowCount
|
return result.rowCount
|
||||||
} else {
|
|
||||||
const sqlString = `
|
|
||||||
DELETE FROM ${tableName}
|
|
||||||
WHERE ManagerName = ?
|
|
||||||
`
|
|
||||||
|
|
||||||
const result = await (dbService as MySqlService).query(sqlString, [managerName])
|
|
||||||
|
|
||||||
return result.rowCount
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error('Delete by manager error', {
|
log.error('Delete by manager error', {
|
||||||
error: error instanceof Error ? error.message : String(error)
|
error: error instanceof Error ? error.message : String(error)
|
||||||
@@ -548,12 +484,7 @@ export class MaterialsToBeDeletedDAO {
|
|||||||
const tableName = this.getTableName()
|
const tableName = this.getTableName()
|
||||||
|
|
||||||
const sqlString = `DELETE FROM ${tableName}`
|
const sqlString = `DELETE FROM ${tableName}`
|
||||||
|
const result = await dbService.query(sqlString)
|
||||||
const result =
|
|
||||||
this.dbType === 'sqlserver'
|
|
||||||
? await (dbService as SqlServerService).query(sqlString)
|
|
||||||
: await (dbService as MySqlService).query(sqlString)
|
|
||||||
|
|
||||||
return result.rowCount
|
return result.rowCount
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error('Delete all materials error', {
|
log.error('Delete all materials error', {
|
||||||
@@ -579,36 +510,22 @@ export class MaterialsToBeDeletedDAO {
|
|||||||
try {
|
try {
|
||||||
const dbService = await this.getDatabaseService()
|
const dbService = await this.getDatabaseService()
|
||||||
const tableName = this.getTableName()
|
const tableName = this.getTableName()
|
||||||
|
const isSqlServer = dbService.type === 'sqlserver'
|
||||||
|
|
||||||
for (let i = 0; i < materialCodes.length; i += batchSize) {
|
for (let i = 0; i < materialCodes.length; i += batchSize) {
|
||||||
const batch = materialCodes.slice(i, i + batchSize)
|
const batch = materialCodes.slice(i, i + batchSize)
|
||||||
|
const placeholders = this.buildPlaceholders(batch.length, isSqlServer)
|
||||||
if (this.dbType === 'sqlserver') {
|
|
||||||
const placeholders = batch.map((_, idx) => `@p${idx}`).join(',')
|
|
||||||
const params: Record<string, { value: string; type: sql.ISqlType }> = {}
|
|
||||||
|
|
||||||
batch.forEach((code, idx) => {
|
|
||||||
params[`p${idx}`] = { value: code.trim(), type: sql.NVarChar }
|
|
||||||
})
|
|
||||||
|
|
||||||
const sqlString = `
|
const sqlString = `
|
||||||
DELETE FROM ${tableName}
|
DELETE FROM ${tableName}
|
||||||
WHERE MaterialCode IN (${placeholders})
|
WHERE MaterialCode IN (${placeholders})
|
||||||
`
|
`
|
||||||
|
|
||||||
const result = await (dbService as SqlServerService).queryWithParams(sqlString, params)
|
const result = await dbService.query(
|
||||||
|
sqlString,
|
||||||
|
batch.map((c) => c.trim())
|
||||||
|
)
|
||||||
totalDeleted += result.rowCount
|
totalDeleted += result.rowCount
|
||||||
} else {
|
|
||||||
const placeholders = batch.map(() => '?').join(',')
|
|
||||||
|
|
||||||
const sqlString = `
|
|
||||||
DELETE FROM ${tableName}
|
|
||||||
WHERE MaterialCode IN (${placeholders})
|
|
||||||
`
|
|
||||||
|
|
||||||
const result = await (dbService as MySqlService).query(sqlString, batch)
|
|
||||||
totalDeleted += result.rowCount
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error('Delete by material codes error', {
|
log.error('Delete by material codes error', {
|
||||||
@@ -631,30 +548,17 @@ export class MaterialsToBeDeletedDAO {
|
|||||||
const dbService = await this.getDatabaseService()
|
const dbService = await this.getDatabaseService()
|
||||||
const tableName = this.getTableName()
|
const tableName = this.getTableName()
|
||||||
const code = materialCode.trim()
|
const code = materialCode.trim()
|
||||||
|
const isSqlServer = dbService.type === 'sqlserver'
|
||||||
|
|
||||||
if (this.dbType === 'sqlserver') {
|
const placeholder = isSqlServer ? '@p0' : '?'
|
||||||
const sqlString = `
|
const sqlString = `
|
||||||
SELECT COUNT(*) as count
|
SELECT COUNT(*) as count
|
||||||
FROM ${tableName}
|
FROM ${tableName}
|
||||||
WHERE MaterialCode = @materialCode
|
WHERE MaterialCode = ${placeholder}
|
||||||
`
|
`
|
||||||
|
|
||||||
const result = await (dbService as SqlServerService).queryWithParams(sqlString, {
|
const result = await dbService.query(sqlString, [code])
|
||||||
materialCode: { value: code, type: sql.NVarChar }
|
|
||||||
})
|
|
||||||
|
|
||||||
return result.rows.length > 0 && (result.rows[0].count as number) > 0
|
return result.rows.length > 0 && (result.rows[0].count as number) > 0
|
||||||
} else {
|
|
||||||
const sqlString = `
|
|
||||||
SELECT COUNT(*) as count
|
|
||||||
FROM ${tableName}
|
|
||||||
WHERE MaterialCode = ?
|
|
||||||
`
|
|
||||||
|
|
||||||
const result = await (dbService as MySqlService).query(sqlString, [code])
|
|
||||||
|
|
||||||
return result.rows.length > 0 && (result.rows[0].count as number) > 0
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error('Material exists error', {
|
log.error('Material exists error', {
|
||||||
error: error instanceof Error ? error.message : String(error)
|
error: error instanceof Error ? error.message : String(error)
|
||||||
@@ -673,11 +577,7 @@ export class MaterialsToBeDeletedDAO {
|
|||||||
const tableName = this.getTableName()
|
const tableName = this.getTableName()
|
||||||
|
|
||||||
const sqlString = `SELECT COUNT(*) as count FROM ${tableName}`
|
const sqlString = `SELECT COUNT(*) as count FROM ${tableName}`
|
||||||
|
const result = await dbService.query(sqlString)
|
||||||
const result =
|
|
||||||
this.dbType === 'sqlserver'
|
|
||||||
? await (dbService as SqlServerService).query(sqlString)
|
|
||||||
: await (dbService as MySqlService).query(sqlString)
|
|
||||||
|
|
||||||
return result.rows.length > 0 ? (result.rows[0].count as number) : 0
|
return result.rows.length > 0 ? (result.rows[0].count as number) : 0
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -697,30 +597,17 @@ export class MaterialsToBeDeletedDAO {
|
|||||||
try {
|
try {
|
||||||
const dbService = await this.getDatabaseService()
|
const dbService = await this.getDatabaseService()
|
||||||
const tableName = this.getTableName()
|
const tableName = this.getTableName()
|
||||||
|
const isSqlServer = dbService.type === 'sqlserver'
|
||||||
|
|
||||||
if (this.dbType === 'sqlserver') {
|
const placeholder = isSqlServer ? '@p0' : '?'
|
||||||
const sqlString = `
|
const sqlString = `
|
||||||
SELECT COUNT(*) as count
|
SELECT COUNT(*) as count
|
||||||
FROM ${tableName}
|
FROM ${tableName}
|
||||||
WHERE ManagerName = @managerName
|
WHERE ManagerName = ${placeholder}
|
||||||
`
|
`
|
||||||
|
|
||||||
const result = await (dbService as SqlServerService).queryWithParams(sqlString, {
|
const result = await dbService.query(sqlString, [managerName])
|
||||||
managerName: { value: managerName, type: sql.NVarChar }
|
|
||||||
})
|
|
||||||
|
|
||||||
return result.rows.length > 0 ? (result.rows[0].count as number) : 0
|
return result.rows.length > 0 ? (result.rows[0].count as number) : 0
|
||||||
} else {
|
|
||||||
const sqlString = `
|
|
||||||
SELECT COUNT(*) as count
|
|
||||||
FROM ${tableName}
|
|
||||||
WHERE ManagerName = ?
|
|
||||||
`
|
|
||||||
|
|
||||||
const result = await (dbService as MySqlService).query(sqlString, [managerName])
|
|
||||||
|
|
||||||
return result.rows.length > 0 ? (result.rows[0].count as number) : 0
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error('Count by manager error', {
|
log.error('Count by manager error', {
|
||||||
error: error instanceof Error ? error.message : String(error)
|
error: error instanceof Error ? error.message : String(error)
|
||||||
@@ -747,11 +634,7 @@ export class MaterialsToBeDeletedDAO {
|
|||||||
WHERE MaterialCode IS NOT NULL
|
WHERE MaterialCode IS NOT NULL
|
||||||
`
|
`
|
||||||
|
|
||||||
const statsResult =
|
const statsResult = await dbService.query(statsSql)
|
||||||
this.dbType === 'sqlserver'
|
|
||||||
? await (dbService as SqlServerService).query(statsSql)
|
|
||||||
: await (dbService as MySqlService).query(statsSql)
|
|
||||||
|
|
||||||
const stats = statsResult.rows[0] || {}
|
const stats = statsResult.rows[0] || {}
|
||||||
|
|
||||||
// Get materials per manager
|
// Get materials per manager
|
||||||
@@ -763,11 +646,7 @@ export class MaterialsToBeDeletedDAO {
|
|||||||
ORDER BY count DESC
|
ORDER BY count DESC
|
||||||
`
|
`
|
||||||
|
|
||||||
const managerResult =
|
const managerResult = await dbService.query(managerSql)
|
||||||
this.dbType === 'sqlserver'
|
|
||||||
? await (dbService as SqlServerService).query(managerSql)
|
|
||||||
: await (dbService as MySqlService).query(managerSql)
|
|
||||||
|
|
||||||
const materialsPerManager = managerResult.rows.map((row) => ({
|
const materialsPerManager = managerResult.rows.map((row) => ({
|
||||||
[row.ManagerName as string]: row.count as number
|
[row.ManagerName as string]: row.count as number
|
||||||
}))
|
}))
|
||||||
@@ -793,13 +672,9 @@ export class MaterialsToBeDeletedDAO {
|
|||||||
* Disconnect from database
|
* Disconnect from database
|
||||||
*/
|
*/
|
||||||
async disconnect(): Promise<void> {
|
async disconnect(): Promise<void> {
|
||||||
if (this.mysqlService) {
|
if (this.dbService) {
|
||||||
await this.mysqlService.disconnect()
|
await this.dbService.disconnect()
|
||||||
this.mysqlService = null
|
this.dbService = null
|
||||||
}
|
|
||||||
if (this.sqlServerService) {
|
|
||||||
await this.sqlServerService.disconnect()
|
|
||||||
this.sqlServerService = 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +1,17 @@
|
|||||||
import mysql from 'mysql2/promise'
|
import mysql from 'mysql2/promise'
|
||||||
|
import type {
|
||||||
|
IDatabaseService,
|
||||||
|
DatabaseType,
|
||||||
|
QueryResult,
|
||||||
|
MySqlConfig
|
||||||
|
} from '../../types/database.types'
|
||||||
|
|
||||||
export interface MySqlConfig {
|
export type { MySqlConfig } from '../../types/database.types'
|
||||||
host: string
|
|
||||||
port: number
|
|
||||||
user: string
|
|
||||||
password: string
|
|
||||||
database: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MySqlQueryResult {
|
export class MySqlService implements IDatabaseService {
|
||||||
rows: Record<string, unknown>[]
|
/** Database type identifier */
|
||||||
columns: string[]
|
readonly type: DatabaseType = 'mysql'
|
||||||
rowCount: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export class MySqlService {
|
|
||||||
private connection: mysql.Connection | null = null
|
private connection: mysql.Connection | null = null
|
||||||
private config: MySqlConfig
|
private config: MySqlConfig
|
||||||
|
|
||||||
@@ -72,7 +69,7 @@ export class MySqlService {
|
|||||||
/**
|
/**
|
||||||
* Execute a query and return results
|
* Execute a query and return results
|
||||||
*/
|
*/
|
||||||
async query(sql: string, params?: any[]): Promise<MySqlQueryResult> {
|
async query(sql: string, params?: any[]): Promise<QueryResult> {
|
||||||
if (!this.connection) {
|
if (!this.connection) {
|
||||||
throw new Error('Not connected to MySQL. Call connect() first.')
|
throw new Error('Not connected to MySQL. Call connect() first.')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,17 @@
|
|||||||
import sql from 'mssql'
|
import sql from 'mssql'
|
||||||
|
import type {
|
||||||
|
IDatabaseService,
|
||||||
|
DatabaseType,
|
||||||
|
QueryResult,
|
||||||
|
SqlServerConfig
|
||||||
|
} from '../../types/database.types'
|
||||||
|
|
||||||
export interface SqlServerConfig {
|
export type { SqlServerConfig } from '../../types/database.types'
|
||||||
server: string
|
|
||||||
port: number
|
|
||||||
user: string
|
|
||||||
password: string
|
|
||||||
database: string
|
|
||||||
options?: {
|
|
||||||
encrypt?: boolean
|
|
||||||
trustServerCertificate?: boolean
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SqlServerQueryResult {
|
export class SqlServerService implements IDatabaseService {
|
||||||
rows: Record<string, unknown>[]
|
/** Database type identifier */
|
||||||
columns: string[]
|
readonly type: DatabaseType = 'sqlserver'
|
||||||
rowCount: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SqlServerService {
|
|
||||||
private pool: sql.ConnectionPool | null = null
|
private pool: sql.ConnectionPool | null = null
|
||||||
private config: SqlServerConfig
|
private config: SqlServerConfig
|
||||||
|
|
||||||
@@ -42,7 +35,7 @@ export class SqlServerService {
|
|||||||
password: this.config.password,
|
password: this.config.password,
|
||||||
database: this.config.database,
|
database: this.config.database,
|
||||||
options: {
|
options: {
|
||||||
encrypt: this.config.options?.encrypt ?? true,
|
encrypt: this.config.options?.encrypt ?? false,
|
||||||
trustServerCertificate: this.config.options?.trustServerCertificate ?? false
|
trustServerCertificate: this.config.options?.trustServerCertificate ?? false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -79,8 +72,10 @@ export class SqlServerService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute a query and return results
|
* 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?: Record<string, unknown>): Promise<SqlServerQueryResult> {
|
async query(sqlString: string, params?: any[]): Promise<QueryResult> {
|
||||||
if (!this.pool) {
|
if (!this.pool) {
|
||||||
throw new Error('Not connected to SQL Server. Call connect() first.')
|
throw new Error('Not connected to SQL Server. Call connect() first.')
|
||||||
}
|
}
|
||||||
@@ -88,17 +83,17 @@ export class SqlServerService {
|
|||||||
try {
|
try {
|
||||||
const request = this.pool.request()
|
const request = this.pool.request()
|
||||||
|
|
||||||
// Add parameters if provided
|
// Add parameters if provided - convert array to @p0, @p1, ... format
|
||||||
if (params) {
|
if (params && params.length > 0) {
|
||||||
for (const [key, value] of Object.entries(params)) {
|
params.forEach((value, index) => {
|
||||||
request.input(key, value)
|
request.input(`p${index}`, value)
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await request.query(sqlString)
|
const result = await request.query(sqlString)
|
||||||
|
|
||||||
// Convert recordset to array of objects
|
// Convert recordset to array of objects (may be undefined for DELETE/INSERT/UPDATE)
|
||||||
const rows = result.recordset as Record<string, unknown>[]
|
const rows = (result.recordset as Record<string, unknown>[]) || []
|
||||||
// Extract column names from the first row if available
|
// Extract column names from the first row if available
|
||||||
const columns = rows.length > 0 ? Object.keys(rows[0]) : []
|
const columns = rows.length > 0 ? Object.keys(rows[0]) : []
|
||||||
|
|
||||||
@@ -113,12 +108,20 @@ export class SqlServerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute a prepared statement with parameters
|
* 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(
|
async queryWithParams(
|
||||||
sqlString: string,
|
sqlString: string,
|
||||||
params: Record<string, { value: unknown; type?: sql.ISqlType }>
|
params: Record<
|
||||||
): Promise<SqlServerQueryResult> {
|
string,
|
||||||
|
{
|
||||||
|
value: unknown
|
||||||
|
type?: sql.ISqlType | sql.ISqlTypeFactoryWithLength | sql.ISqlTypeWithLength
|
||||||
|
}
|
||||||
|
>
|
||||||
|
): Promise<QueryResult> {
|
||||||
if (!this.pool) {
|
if (!this.pool) {
|
||||||
throw new Error('Not connected to SQL Server. Call connect() first.')
|
throw new Error('Not connected to SQL Server. Call connect() first.')
|
||||||
}
|
}
|
||||||
@@ -154,8 +157,9 @@ export class SqlServerService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute multiple queries in a transaction
|
* Execute multiple queries in a transaction
|
||||||
|
* @param queries - Array of queries with array-based parameters
|
||||||
*/
|
*/
|
||||||
async transaction(queries: { sql: string; params?: Record<string, unknown> }[]): Promise<void> {
|
async transaction(queries: { sql: string; params?: any[] }[]): Promise<void> {
|
||||||
if (!this.pool) {
|
if (!this.pool) {
|
||||||
throw new Error('Not connected to SQL Server. Call connect() first.')
|
throw new Error('Not connected to SQL Server. Call connect() first.')
|
||||||
}
|
}
|
||||||
@@ -168,10 +172,11 @@ export class SqlServerService {
|
|||||||
for (const { sql: sqlString, params } of queries) {
|
for (const { sql: sqlString, params } of queries) {
|
||||||
const request = new sql.Request(transaction)
|
const request = new sql.Request(transaction)
|
||||||
|
|
||||||
if (params) {
|
// Add parameters if provided - convert array to @p0, @p1, ... format
|
||||||
for (const [key, value] of Object.entries(params)) {
|
if (params && params.length > 0) {
|
||||||
request.input(key, value)
|
params.forEach((value, index) => {
|
||||||
}
|
request.input(`p${index}`, value)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
await request.query(sqlString)
|
await request.query(sqlString)
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export class ErpBrowserManager {
|
|||||||
const context = await browserInstance.newContext({
|
const context = await browserInstance.newContext({
|
||||||
acceptDownloads: this.config.acceptDownloads,
|
acceptDownloads: this.config.acceptDownloads,
|
||||||
viewport: this.config.viewport,
|
viewport: this.config.viewport,
|
||||||
ignoreHTTPSErrors: this.config.ignoreHTTPSErrors,
|
ignoreHTTPSErrors: true,
|
||||||
javaScriptEnabled: true
|
javaScriptEnabled: true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,51 @@ import { ErpAuthService } from './erp-auth'
|
|||||||
import type { CleanerInput, CleanerResult, OrderCleanDetail } from '../../types/cleaner.types'
|
import type { CleanerInput, CleanerResult, OrderCleanDetail } from '../../types/cleaner.types'
|
||||||
import type { ErpSession } from '../../types/erp.types'
|
import type { ErpSession } from '../../types/erp.types'
|
||||||
import type { FrameLocator, Locator, Page } from 'playwright'
|
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
|
* Cleaner Service Options
|
||||||
@@ -22,11 +67,58 @@ export interface ShouldDeleteParams {
|
|||||||
deleteSet: Set<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
|
* ERP Cleaner Service
|
||||||
* Deletes specified materials from production orders in ERP system
|
* Deletes specified materials from production orders in ERP system
|
||||||
*
|
|
||||||
* Reference: playwrite/utils/discrete_material_plan_cleaner.py
|
|
||||||
*/
|
*/
|
||||||
export class CleanerService {
|
export class CleanerService {
|
||||||
private authService: ErpAuthService
|
private authService: ErpAuthService
|
||||||
@@ -46,27 +138,18 @@ export class CleanerService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Determine if a material should be deleted
|
* Determine if a material should be deleted
|
||||||
* Reference: Python lines 284-406
|
|
||||||
*
|
|
||||||
* Deletion conditions:
|
|
||||||
* 1. Material code must be in the delete set
|
|
||||||
* 2. Row number must NOT be in range 7000-7999
|
|
||||||
* 3. Pending quantity must be empty
|
|
||||||
*/
|
*/
|
||||||
shouldDeleteMaterial(params: ShouldDeleteParams): boolean {
|
shouldDeleteMaterial(params: ShouldDeleteParams): boolean {
|
||||||
const { rowNumber, pendingQty, materialCode, deleteSet } = params
|
const { rowNumber, pendingQty, materialCode, deleteSet } = params
|
||||||
|
|
||||||
// Check if material is in delete list
|
|
||||||
if (!deleteSet.has(materialCode)) {
|
if (!deleteSet.has(materialCode)) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check row number range (7000-7999 are protected)
|
if (rowNumber >= 2000 && rowNumber < 8000) {
|
||||||
if (rowNumber >= 7000 && rowNumber < 8000) {
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check pending quantity (must be empty)
|
|
||||||
if (pendingQty && pendingQty.trim() !== '') {
|
if (pendingQty && pendingQty.trim() !== '') {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -74,99 +157,200 @@ export class CleanerService {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
getSkipReason(params: ShouldDeleteParams): string {
|
||||||
* Execute the cleaning process
|
const { rowNumber, pendingQty, materialCode, deleteSet } = params
|
||||||
* Reference: Python clean() method lines 456-525
|
|
||||||
*/
|
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> {
|
async clean(input: CleanerInput): Promise<CleanerResult> {
|
||||||
const result: CleanerResult = {
|
const result: CleanerResult = {
|
||||||
ordersProcessed: 0,
|
ordersProcessed: 0,
|
||||||
materialsDeleted: 0,
|
materialsDeleted: 0,
|
||||||
materialsSkipped: 0,
|
materialsSkipped: 0,
|
||||||
errors: [],
|
errors: [],
|
||||||
details: []
|
details: [],
|
||||||
|
retriedOrders: 0,
|
||||||
|
successfulRetries: 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create delete set for O(1) lookup
|
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)
|
const deleteSet = new Set(input.materialCodes)
|
||||||
|
|
||||||
|
let popupPage: Page | null = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const session = this.authService.getSession()
|
const session = this.authService.getSession()
|
||||||
|
const navigation = await this.navigateToCleanerPage(session)
|
||||||
|
popupPage = navigation.popupPage
|
||||||
|
const { workFrame } = navigation
|
||||||
|
|
||||||
// Navigate to cleaner page
|
|
||||||
const { popupPage, workFrame } = await this.navigateToCleanerPage(session)
|
|
||||||
|
|
||||||
// Setup query interface
|
|
||||||
await this.setupQueryInterface(workFrame)
|
await this.setupQueryInterface(workFrame)
|
||||||
|
|
||||||
// Process each order
|
const orderBatches = createBatches(input.orderNumbers, queryBatchSize)
|
||||||
for (let i = 0; i < input.orderNumbers.length; i++) {
|
const popupMutex = new AsyncMutex()
|
||||||
const orderNumber = input.orderNumbers[i]
|
const progressState: ProgressState = {
|
||||||
const progress = ((i + 1) / input.orderNumbers.length) * 100
|
completedOrders: 0,
|
||||||
|
totalOrders
|
||||||
|
}
|
||||||
|
|
||||||
input.onProgress?.(
|
for (let batchIndex = 0; batchIndex < orderBatches.length; batchIndex++) {
|
||||||
`Processing order ${i + 1}/${input.orderNumbers.length}: ${orderNumber}`,
|
const batchOrders = orderBatches[batchIndex]
|
||||||
progress
|
|
||||||
)
|
|
||||||
|
|
||||||
|
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 {
|
try {
|
||||||
const detail = await this.processOrder({
|
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,
|
workFrame,
|
||||||
popupPage,
|
popupPage,
|
||||||
orderNumber,
|
failedDetails: result.details.filter(
|
||||||
orderIndex: i,
|
(d) => d.errors.length > 0 && this.isOrderNumber(d.orderNumber)
|
||||||
totalOrders: input.orderNumbers.length,
|
),
|
||||||
deleteSet,
|
deleteSet,
|
||||||
dryRun: input.dryRun ?? this.dryRun,
|
dryRun,
|
||||||
onProgress: input.onProgress
|
onProgress: input.onProgress
|
||||||
})
|
})
|
||||||
|
|
||||||
result.details.push(detail)
|
result.retriedOrders = retryResult.retriedOrders
|
||||||
result.ordersProcessed++
|
result.successfulRetries = retryResult.successfulRetries
|
||||||
result.materialsDeleted += detail.materialsDeleted
|
|
||||||
result.materialsSkipped += detail.materialsSkipped
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
|
||||||
result.errors.push(`Order ${orderNumber}: ${message}`)
|
|
||||||
|
|
||||||
// Add error detail
|
retryResult.updatedDetails.forEach((updatedDetail) => {
|
||||||
result.details.push({
|
const index = result.details.findIndex((d) => d.orderNumber === updatedDetail.orderNumber)
|
||||||
orderNumber,
|
if (index !== -1) {
|
||||||
materialsDeleted: 0,
|
const previousDetail = result.details[index]
|
||||||
materialsSkipped: 0,
|
if (updatedDetail.retrySuccess && previousDetail.errors.length > 0) {
|
||||||
errors: [message]
|
result.ordersProcessed += 1
|
||||||
|
result.materialsDeleted += updatedDetail.materialsDeleted
|
||||||
|
result.materialsSkipped += updatedDetail.materialsSkipped
|
||||||
|
}
|
||||||
|
result.details[index] = updatedDetail
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close popup page
|
const successfulRetryOrders = new Set(
|
||||||
await popupPage.close()
|
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) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
log.error('Cleaner failed', { error: message })
|
||||||
result.errors.push(`Clean failed: ${message}`)
|
result.errors.push(`Clean failed: ${message}`)
|
||||||
|
} finally {
|
||||||
|
if (popupPage) {
|
||||||
|
try {
|
||||||
|
await popupPage.close()
|
||||||
|
} catch {
|
||||||
|
// Ignore close errors
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Navigate to the discrete production order maintenance page
|
|
||||||
* Reference: Python lines 476-486
|
|
||||||
*/
|
|
||||||
async navigateToCleanerPage(
|
async navigateToCleanerPage(
|
||||||
session: ErpSession
|
session: ErpSession
|
||||||
): Promise<{ popupPage: Page; workFrame: FrameLocator }> {
|
): Promise<{ popupPage: Page; workFrame: FrameLocator }> {
|
||||||
const { page, mainFrame } = session
|
const { page, mainFrame } = session
|
||||||
|
|
||||||
// Click menu icon (Python line 476)
|
|
||||||
await mainFrame.locator('i').first().click()
|
await mainFrame.locator('i').first().click()
|
||||||
|
|
||||||
// Click discrete production order menu item and expect popup (Python lines 477-479)
|
|
||||||
const popupPromise = page.waitForEvent('popup')
|
const popupPromise = page.waitForEvent('popup')
|
||||||
await mainFrame.getByTitle('离散生产订单维护', { exact: true }).first().click()
|
await mainFrame.getByTitle('离散生产订单维护', { exact: true }).first().click()
|
||||||
const popupPage = await popupPromise
|
const popupPage = await popupPromise
|
||||||
|
|
||||||
// Get nested frame structure (Python lines 482-486)
|
|
||||||
const forwardFrameLocator = popupPage.locator('#forwardFrame')
|
const forwardFrameLocator = popupPage.locator('#forwardFrame')
|
||||||
const fFrame = forwardFrameLocator.contentFrame()
|
const fFrame = forwardFrameLocator.contentFrame()
|
||||||
|
|
||||||
@@ -174,82 +358,130 @@ export class CleanerService {
|
|||||||
await innerFrameLocator.waitFor({ state: 'visible', timeout: 30000 })
|
await innerFrameLocator.waitFor({ state: 'visible', timeout: 30000 })
|
||||||
const workFrame = innerFrameLocator.contentFrame()
|
const workFrame = innerFrameLocator.contentFrame()
|
||||||
|
|
||||||
// Wait for hot-key-head_list to be visible (Python line 484-486)
|
|
||||||
await workFrame.locator('#hot-key-head_list').waitFor({ state: 'visible', timeout: 30000 })
|
await workFrame.locator('#hot-key-head_list').waitFor({ state: 'visible', timeout: 30000 })
|
||||||
|
|
||||||
return { popupPage, workFrame }
|
return { popupPage, workFrame }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Setup query interface
|
|
||||||
* Reference: Python setup_query_interface() lines 445-454
|
|
||||||
*/
|
|
||||||
private async setupQueryInterface(innerFrame: FrameLocator): Promise<void> {
|
private async setupQueryInterface(innerFrame: FrameLocator): Promise<void> {
|
||||||
// Click search icon (Python line 447)
|
|
||||||
await innerFrame.locator('.search-name-wrapper > .iconfont').click()
|
await innerFrame.locator('.search-name-wrapper > .iconfont').click()
|
||||||
|
|
||||||
// Click "订单号查询" menu item (Python line 448)
|
|
||||||
await innerFrame.getByText('订单号查询').click()
|
await innerFrame.getByText('订单号查询').click()
|
||||||
|
|
||||||
// Click "全部" tab (Python line 449)
|
|
||||||
await innerFrame.getByRole('tab', { name: '全部' }).click()
|
await innerFrame.getByRole('tab', { name: '全部' }).click()
|
||||||
|
|
||||||
// Set limit to 5000 (Python lines 451-454)
|
|
||||||
const inputEl = innerFrame.locator('#rc_select_0')
|
const inputEl = innerFrame.locator('#rc_select_0')
|
||||||
await inputEl.fill('5000')
|
await inputEl.fill('5000')
|
||||||
await inputEl.press('Enter')
|
await inputEl.press('Enter')
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private async queryOrders(workFrame: FrameLocator, orderNumbers: string[]): Promise<void> {
|
||||||
* Process a single order
|
const textbox = workFrame.getByRole('textbox', { name: '生产订单号' })
|
||||||
* Reference: Python process_order() lines 171-443
|
await textbox.fill(orderNumbers.join(','))
|
||||||
*/
|
await workFrame.locator('.search-component-searchBtn').click()
|
||||||
private async processOrder(params: {
|
|
||||||
workFrame: FrameLocator
|
|
||||||
popupPage: Page
|
|
||||||
orderNumber: string
|
|
||||||
orderIndex: number
|
|
||||||
totalOrders: number
|
|
||||||
deleteSet: Set<string>
|
|
||||||
dryRun: boolean
|
|
||||||
onProgress?: (message: string, progress?: number) => void
|
|
||||||
}): Promise<OrderCleanDetail> {
|
|
||||||
const {
|
|
||||||
workFrame,
|
|
||||||
popupPage,
|
|
||||||
orderNumber,
|
|
||||||
orderIndex,
|
|
||||||
totalOrders,
|
|
||||||
deleteSet,
|
|
||||||
dryRun,
|
|
||||||
onProgress
|
|
||||||
} = params
|
|
||||||
|
|
||||||
const detail: OrderCleanDetail = {
|
|
||||||
orderNumber,
|
|
||||||
materialsDeleted: 0,
|
|
||||||
materialsSkipped: 0,
|
|
||||||
errors: []
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Query the order (Python lines 187-189)
|
private async collectQueryResultRows(workFrame: FrameLocator): Promise<QueryResultRow[]> {
|
||||||
const textbox = workFrame.getByRole('textbox', { name: '生产订单号' })
|
const rows = workFrame.locator('tbody tr')
|
||||||
await textbox.fill(orderNumber)
|
const rowCount = await rows.count()
|
||||||
await workFrame.locator('.search-component-searchBtn').click()
|
const result: QueryResultRow[] = []
|
||||||
|
|
||||||
// Wait for loading (Python lines 192-197)
|
for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) {
|
||||||
await this.waitForLoading(workFrame)
|
const row = rows.nth(rowIndex)
|
||||||
|
const orderNumber = await this.extractOrderNumberFromQueryRow(row)
|
||||||
|
if (!this.isOrderNumber(orderNumber)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result.push({ rowIndex, orderNumber })
|
||||||
|
}
|
||||||
|
|
||||||
// Click "更多" to open menu (Python line 200)
|
return result
|
||||||
await workFrame.locator('#hot-key-head_list').getByText('更多').click()
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
// Click "备料计划" and expect popup (Python lines 201-203)
|
|
||||||
const detailPagePromise = popupPage.waitForEvent('popup')
|
const detailPagePromise = popupPage.waitForEvent('popup')
|
||||||
await workFrame.getByText('备料计划').click()
|
await moreButton.click()
|
||||||
const detailPage = await detailPagePromise
|
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 {
|
try {
|
||||||
// Navigate nested frames in detail page (Python lines 206-207)
|
|
||||||
const detailMainFrame = detailPage.locator('#forwardFrame')
|
const detailMainFrame = detailPage.locator('#forwardFrame')
|
||||||
const dFrame = await detailMainFrame.contentFrame()
|
const dFrame = await detailMainFrame.contentFrame()
|
||||||
|
|
||||||
@@ -265,34 +497,58 @@ export class CleanerService {
|
|||||||
throw new Error('Failed to access detail inner frame')
|
throw new Error('Failed to access detail inner frame')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for plan code (Python lines 210-213)
|
|
||||||
await detailInnerFrame
|
await detailInnerFrame
|
||||||
.getByText(/^离散备料计划维护:/)
|
.getByText(/^离散备料计划维护:/)
|
||||||
.waitFor({ state: 'visible', timeout: 30000 })
|
.waitFor({ state: 'visible', timeout: 30000 })
|
||||||
|
|
||||||
// Extract detail count (Python lines 215-218)
|
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 detailCountText = await detailInnerFrame.getByText(/^详细信息 \(\d+\)$/).innerText()
|
||||||
const detailCountMatch = detailCountText.match(/\((\d+)\)/)
|
const detailCountMatch = detailCountText.match(/\((\d+)\)/)
|
||||||
const detailCount = detailCountMatch ? parseInt(detailCountMatch[1], 10) : 0
|
const detailCount = detailCountMatch ? parseInt(detailCountMatch[1], 10) : 0
|
||||||
|
|
||||||
// Extract status (Python lines 220-225)
|
|
||||||
const statusText = await detailInnerFrame.getByText(/^备料状态:.+$/).innerText()
|
const statusText = await detailInnerFrame.getByText(/^备料状态:.+$/).innerText()
|
||||||
const statusMatch = statusText.replace(/\n/g, '').match(/备料状态:(.+)$/)
|
const statusMatch = statusText.replace(/\n/g, '').match(/备料状态:(.+)$/)
|
||||||
const detailStatus = statusMatch ? statusMatch[1].trim() : ''
|
const detailStatus = statusMatch ? statusMatch[1].trim() : ''
|
||||||
|
|
||||||
// Process based on status (Python lines 228-441)
|
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) {
|
if (detailStatus === '审批通过' && detailCount > 0) {
|
||||||
// Click modify button (Python line 235)
|
|
||||||
await detailInnerFrame.getByRole('button', { name: '修改' }).click()
|
await detailInnerFrame.getByRole('button', { name: '修改' }).click()
|
||||||
|
|
||||||
// Wait for save button (Python lines 238-242)
|
|
||||||
const saveButtonLocator = detailInnerFrame.getByRole('button', { name: '保存' })
|
const saveButtonLocator = detailInnerFrame.getByRole('button', { name: '保存' })
|
||||||
await saveButtonLocator.waitFor({ state: 'visible', timeout: 30000 })
|
await saveButtonLocator.waitFor({ state: 'visible', timeout: 30000 })
|
||||||
|
|
||||||
// Expand the form (Python line 245)
|
|
||||||
await detailInnerFrame.getByText('展开').first().click()
|
await detailInnerFrame.getByText('展开').first().click()
|
||||||
|
|
||||||
// Get form elements (Python lines 247-253)
|
|
||||||
const childForm = detailInnerFrame.locator('.card-table-side-box')
|
const childForm = detailInnerFrame.locator('.card-table-side-box')
|
||||||
const buttonWrapper = childForm.locator('.button-wrapper')
|
const buttonWrapper = childForm.locator('.button-wrapper')
|
||||||
const deleteRowBtn = buttonWrapper.getByRole('button', { name: '删行' })
|
const deleteRowBtn = buttonWrapper.getByRole('button', { name: '删行' })
|
||||||
@@ -302,11 +558,9 @@ export class CleanerService {
|
|||||||
let lastRowNumber = ''
|
let lastRowNumber = ''
|
||||||
let materialIdx = 0
|
let materialIdx = 0
|
||||||
|
|
||||||
// Process each material row (Python lines 257-423)
|
|
||||||
while (true) {
|
while (true) {
|
||||||
materialIdx++
|
materialIdx += 1
|
||||||
|
|
||||||
// Wait for row number to stabilize (Python lines 262-265)
|
|
||||||
const currentRow = await this.getInputValue(childForm, /^行号$/)
|
const currentRow = await this.getInputValue(childForm, /^行号$/)
|
||||||
const rowNumInt = parseInt(currentRow, 10)
|
const rowNumInt = parseInt(currentRow, 10)
|
||||||
|
|
||||||
@@ -314,18 +568,29 @@ export class CleanerService {
|
|||||||
await this.delay(500)
|
await this.delay(500)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get material data (Python lines 267-271)
|
|
||||||
const materialCode = await this.getInputValue(childForm, /^材料编码/)
|
const materialCode = await this.getInputValue(childForm, /^材料编码/)
|
||||||
const materialName = await this.getInputValue(childForm, /^材料名称/)
|
const materialName = await this.getInputValue(childForm, /^材料名称/)
|
||||||
const pendingQty = await this.getInputValue(childForm, /^累计待发数量$/)
|
const pendingQty = await this.getInputValue(childForm, /^累计待发数量$/)
|
||||||
|
|
||||||
// Report progress
|
const progress = this.calculateProgress(
|
||||||
onProgress?.(
|
progressState.completedOrders,
|
||||||
`Order ${orderNumber} - Material ${materialIdx}/${detailCount}: ${materialName}`,
|
materialIdx,
|
||||||
((orderIndex + materialIdx / detailCount) / totalOrders) * 100
|
detailCount,
|
||||||
|
progressState.totalOrders
|
||||||
|
)
|
||||||
|
|
||||||
|
onProgress?.(
|
||||||
|
`订单 ${orderNumber} - 物料 ${materialIdx}/${detailCount}: ${materialName}`,
|
||||||
|
progress,
|
||||||
|
{
|
||||||
|
currentOrderIndex: progressState.completedOrders + 1,
|
||||||
|
totalOrders: progressState.totalOrders,
|
||||||
|
currentMaterialIndex: materialIdx,
|
||||||
|
totalMaterialsInOrder: detailCount,
|
||||||
|
currentOrderNumber: orderNumber
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// Check if should delete (Python lines 284-406)
|
|
||||||
if (deleteSet.has(materialCode)) {
|
if (deleteSet.has(materialCode)) {
|
||||||
const shouldDelete = this.shouldDeleteMaterial({
|
const shouldDelete = this.shouldDeleteMaterial({
|
||||||
rowNumber: rowNumInt,
|
rowNumber: rowNumInt,
|
||||||
@@ -335,23 +600,34 @@ export class CleanerService {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (shouldDelete && !dryRun) {
|
if (shouldDelete && !dryRun) {
|
||||||
// Delete the material (Python lines 302-340)
|
|
||||||
const oldRowNumber = currentRow
|
const oldRowNumber = currentRow
|
||||||
await deleteRowBtn.click()
|
await deleteRowBtn.click()
|
||||||
|
|
||||||
// Wait for row number to change (Python lines 306-324)
|
|
||||||
const deleteSuccess = await this.waitForRowChange(childForm, oldRowNumber, 10000)
|
const deleteSuccess = await this.waitForRowChange(childForm, oldRowNumber, 10000)
|
||||||
|
|
||||||
if (deleteSuccess) {
|
if (deleteSuccess) {
|
||||||
detail.materialsDeleted++
|
detail.materialsDeleted += 1
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
} else if (!shouldDelete) {
|
}
|
||||||
detail.materialsSkipped++
|
|
||||||
|
if (!shouldDelete) {
|
||||||
|
detail.materialsSkipped += 1
|
||||||
|
const reason = this.getSkipReason({
|
||||||
|
rowNumber: rowNumInt,
|
||||||
|
pendingQty,
|
||||||
|
materialCode,
|
||||||
|
deleteSet
|
||||||
|
})
|
||||||
|
detail.skippedMaterials.push({
|
||||||
|
materialCode,
|
||||||
|
materialName,
|
||||||
|
rowNumber: rowNumInt,
|
||||||
|
reason
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Move to next row (Python lines 419-423)
|
|
||||||
const isNextEnabled = await this.isButtonEnabled(nextBtn)
|
const isNextEnabled = await this.isButtonEnabled(nextBtn)
|
||||||
if (isNextEnabled) {
|
if (isNextEnabled) {
|
||||||
lastRowNumber = currentRow
|
lastRowNumber = currentRow
|
||||||
@@ -361,27 +637,61 @@ export class CleanerService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collapse form (Python line 424)
|
|
||||||
await collapseBtn.click()
|
await collapseBtn.click()
|
||||||
|
|
||||||
// Save changes (Python lines 427-435)
|
|
||||||
if (!dryRun && detail.materialsDeleted > 0) {
|
if (!dryRun && detail.materialsDeleted > 0) {
|
||||||
await saveButtonLocator.click()
|
await saveButtonLocator.click()
|
||||||
await saveButtonLocator.waitFor({ state: 'hidden', timeout: 60000 })
|
await saveButtonLocator.waitFor({ state: 'hidden', timeout: 60000 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
// Close detail page (Python lines 442-443)
|
|
||||||
await detailPage.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
return detail
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Wait for loading overlay to disappear
|
|
||||||
* Reference: Python lines 192-197
|
|
||||||
*/
|
|
||||||
private async waitForLoading(frame: FrameLocator): Promise<void> {
|
private async waitForLoading(frame: FrameLocator): Promise<void> {
|
||||||
const loadingLocator = frame
|
const loadingLocator = frame
|
||||||
.locator('div')
|
.locator('div')
|
||||||
@@ -396,10 +706,6 @@ export class CleanerService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get input value by label regex
|
|
||||||
* Reference: Python _get_input_value() lines 141-148
|
|
||||||
*/
|
|
||||||
private async getInputValue(
|
private async getInputValue(
|
||||||
container: FrameLocator | Locator,
|
container: FrameLocator | Locator,
|
||||||
labelRegex: RegExp
|
labelRegex: RegExp
|
||||||
@@ -416,10 +722,6 @@ export class CleanerService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if button is enabled
|
|
||||||
* Reference: Python _is_button_enabled() lines 133-139
|
|
||||||
*/
|
|
||||||
private async isButtonEnabled(button: Locator): Promise<boolean> {
|
private async isButtonEnabled(button: Locator): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
return await button.isEnabled()
|
return await button.isEnabled()
|
||||||
@@ -428,10 +730,6 @@ export class CleanerService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Wait for row number to change after deletion
|
|
||||||
* Reference: Python lines 306-324
|
|
||||||
*/
|
|
||||||
private async waitForRowChange(
|
private async waitForRowChange(
|
||||||
childForm: FrameLocator | Locator,
|
childForm: FrameLocator | Locator,
|
||||||
oldRowNumber: string,
|
oldRowNumber: string,
|
||||||
@@ -454,10 +752,114 @@ export class CleanerService {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Delay helper
|
|
||||||
*/
|
|
||||||
private delay(ms: number): Promise<void> {
|
private delay(ms: number): Promise<void> {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import { chromium, type BrowserContext, type Page } from 'playwright'
|
import { chromium } from 'playwright'
|
||||||
import type { ErpConfig, ErpSession } from '../../types/erp.types'
|
import type { ErpConfig, ErpSession } from '../../types/erp.types'
|
||||||
import { createLogger } from '../logger'
|
import { createLogger } from '../logger'
|
||||||
|
|
||||||
const log = createLogger('ErpAuthService')
|
const log = createLogger('ErpAuthService')
|
||||||
|
|
||||||
|
// Timeout constants
|
||||||
|
const PAGE_LOAD_TIMEOUT = 10000
|
||||||
|
const LOGIN_RESULT_TIMEOUT = 15000
|
||||||
|
const FORCE_LOGIN_TIMEOUT = 5000
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ERP Authentication Service
|
* ERP Authentication Service
|
||||||
* Manages login session and browser lifecycle
|
* Manages login session and browser lifecycle
|
||||||
@@ -51,10 +56,13 @@ export class ErpAuthService {
|
|||||||
await page.goto(loginUrl)
|
await page.goto(loginUrl)
|
||||||
|
|
||||||
// Wait for page to load
|
// Wait for page to load
|
||||||
await page.waitForLoadState('domcontentloaded', { timeout: 10000 })
|
await page.waitForLoadState('domcontentloaded', { timeout: PAGE_LOAD_TIMEOUT })
|
||||||
|
|
||||||
// Wait for iframe to be present
|
// Wait for iframe to be present
|
||||||
await page.waitForSelector('#forwardFrame', { state: 'attached', timeout: 15000 })
|
await page.waitForSelector('#forwardFrame', {
|
||||||
|
state: 'attached',
|
||||||
|
timeout: LOGIN_RESULT_TIMEOUT
|
||||||
|
})
|
||||||
|
|
||||||
// Extract forwardFrame (Python: main_frame = page.locator("#forwardFrame").content_frame)
|
// Extract forwardFrame (Python: main_frame = page.locator("#forwardFrame").content_frame)
|
||||||
// This is the main working frame for all subsequent operations
|
// This is the main working frame for all subsequent operations
|
||||||
@@ -89,29 +97,11 @@ export class ErpAuthService {
|
|||||||
throw new Error(`Failed to click login button: ${e}`)
|
throw new Error(`Failed to click login button: ${e}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for navigation after login
|
await page.waitForLoadState('domcontentloaded', { timeout: PAGE_LOAD_TIMEOUT }).catch(() => {
|
||||||
// The login will redirect to the main page which has a different structure
|
|
||||||
try {
|
|
||||||
await page.waitForLoadState('domcontentloaded', { timeout: 10000 })
|
|
||||||
} catch (e) {
|
|
||||||
log.warn('Page load state check timed out, continuing')
|
log.warn('Page load state check timed out, continuing')
|
||||||
}
|
})
|
||||||
|
|
||||||
// Handle force login confirmation dialog if present (Python: get_by_role("button", name="确定"))
|
await this.waitForLoginResult(mainFrame as unknown as import('playwright').Frame)
|
||||||
try {
|
|
||||||
const confirmBtn = mainFrame.getByRole('button', { name: '确定' })
|
|
||||||
const count = await confirmBtn.count()
|
|
||||||
if (count > 0) {
|
|
||||||
log.info('Force login detected, clicking confirm button')
|
|
||||||
await confirmBtn.first().click()
|
|
||||||
await page.waitForTimeout(2000)
|
|
||||||
} else {
|
|
||||||
log.debug('Normal login, no confirmation dialog')
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// No force login dialog, continue
|
|
||||||
log.debug('Normal login, no confirmation dialog')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create session with mainFrame (Python returns main_frame as part of login result)
|
// Create session with mainFrame (Python returns main_frame as part of login result)
|
||||||
this.session = {
|
this.session = {
|
||||||
@@ -126,6 +116,47 @@ export class ErpAuthService {
|
|||||||
return this.session
|
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
|
* Close browser and cleanup session
|
||||||
*/
|
*/
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,27 @@
|
|||||||
import path from 'path'
|
import path from 'path'
|
||||||
import fs from 'fs/promises'
|
import fs from 'fs/promises'
|
||||||
import { ERP_LOCATORS } from './locators'
|
import { ExtractorCore } from './extractor-core'
|
||||||
import { ErpAuthService } from './erp-auth'
|
import { ErpAuthService } from './erp-auth'
|
||||||
import type { ExtractorInput, ExtractorResult } from '../../types/extractor.types'
|
import { ExcelParser } from '../excel/excel-parser'
|
||||||
import type { ErpSession } from '../../types/erp.types'
|
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
|
* ERP Data Extractor Service
|
||||||
* Downloads material plan data for given order numbers
|
* 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
|
* Reference: playwrite/utils/discrete_material_plan_extractor.py
|
||||||
*/
|
*/
|
||||||
export class ExtractorService {
|
export class ExtractorService {
|
||||||
@@ -25,6 +38,8 @@ export class ExtractorService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract data for given order numbers
|
* 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> {
|
async extract(input: ExtractorInput): Promise<ExtractorResult> {
|
||||||
const result: ExtractorResult = {
|
const result: ExtractorResult = {
|
||||||
@@ -37,37 +52,60 @@ export class ExtractorService {
|
|||||||
try {
|
try {
|
||||||
const session = this.authService.getSession()
|
const session = this.authService.getSession()
|
||||||
|
|
||||||
// Navigate to extractor page and get popup page + work frame
|
// Call ExtractorCore to execute web page operations
|
||||||
const { popupPage, workFrame } = await this.navigateToExtractorPage(session)
|
const core = new ExtractorCore()
|
||||||
|
const coreResult = await core.downloadAllBatches({
|
||||||
// Process orders in batches
|
|
||||||
const batchSize = input.batchSize || 100
|
|
||||||
const batches = this.createBatches(input.orderNumbers, batchSize)
|
|
||||||
|
|
||||||
for (let i = 0; i < batches.length; i++) {
|
|
||||||
const batch = batches[i]
|
|
||||||
const progress = ((i + 1) / batches.length) * 100
|
|
||||||
|
|
||||||
input.onProgress?.(`Processing batch ${i + 1}/${batches.length}`, progress)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const filePath = await this.downloadBatch(
|
|
||||||
session,
|
session,
|
||||||
popupPage,
|
orderNumbers: input.orderNumbers,
|
||||||
workFrame,
|
downloadDir: this.downloadDir,
|
||||||
batch,
|
batchSize: input.batchSize || 100,
|
||||||
i,
|
onProgress: input.onProgress
|
||||||
batches.length
|
})
|
||||||
)
|
|
||||||
result.downloadedFiles.push(filePath)
|
result.downloadedFiles = coreResult.downloadedFiles
|
||||||
} catch (error) {
|
result.errors = coreResult.errors
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
|
||||||
result.errors.push(`Batch ${i + 1}: ${message}`)
|
// 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Merge files (implement in separate task)
|
// Always clean up temporary files regardless of merge success
|
||||||
// result.mergedFile = await this.mergeFiles(result.downloadedFiles);
|
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) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||||
result.errors.push(`Extraction failed: ${message}`)
|
result.errors.push(`Extraction failed: ${message}`)
|
||||||
@@ -77,149 +115,243 @@ export class ExtractorService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Navigate to extractor/query page
|
* Merge downloaded Excel files into a single file
|
||||||
* Reference: Python extract() method lines 266-278
|
* Uses ExcelParser to parse and combine all material plans
|
||||||
*
|
*
|
||||||
* Python workflow:
|
* @param filePaths - Array of downloaded Excel file paths
|
||||||
* 1. main_frame.locator("i").first.click() - Click menu icon
|
* @returns Merged file path, total record count, and optional error message
|
||||||
* 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(
|
private async mergeFiles(
|
||||||
session: ErpSession
|
filePaths: string[]
|
||||||
): Promise<{ popupPage: any; workFrame: any }> {
|
): Promise<{ mergedFile: string | null; recordCount: number; error?: string }> {
|
||||||
const { page, mainFrame } = session
|
if (filePaths.length === 0) {
|
||||||
|
return { mergedFile: null, recordCount: 0 }
|
||||||
// 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')
|
log.info('Starting merge', { fileCount: filePaths.length })
|
||||||
await innerFrameLocator.waitFor({ state: 'visible', timeout: 15000 })
|
const parser = new ExcelParser()
|
||||||
const workFrame = await innerFrameLocator.contentFrame()
|
|
||||||
|
|
||||||
if (!workFrame) {
|
// Collect all orders with full order info and materials
|
||||||
throw new Error('Failed to access inner work frame')
|
// 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 })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 5: Setup query interface (Python line 278)
|
// Calculate total record count (total material rows)
|
||||||
await this.setupQueryInterface(workFrame)
|
let recordCount = 0
|
||||||
|
for (const order of allOrders) {
|
||||||
|
recordCount += order.materials.length
|
||||||
|
}
|
||||||
|
|
||||||
return { popupPage, workFrame }
|
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}` }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Setup query interface
|
* Save merged orders to a new Excel file with full 31 columns
|
||||||
* Reference: Python setup_query_interface() method lines 231-239
|
* Matches the output format of ExcelParser.saveAsExcel()
|
||||||
*/
|
*/
|
||||||
private async setupQueryInterface(innerFrame: any): Promise<void> {
|
private async saveMergedOrders(
|
||||||
// Click search icon (Python line 233)
|
orders: Array<{ orderInfo: any; materials: any[] }>,
|
||||||
await innerFrame.locator('.search-name-wrapper > .iconfont').click()
|
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')
|
||||||
|
|
||||||
// Click "订单号查询" menu item (Python line 234)
|
const workbook = new ExcelJS.Workbook()
|
||||||
await innerFrame.getByText('订单号查询').click()
|
const worksheet = workbook.addWorksheet('Data')
|
||||||
|
|
||||||
// Click "全部" tab (Python line 235)
|
// Define all 31 columns matching ExcelParser.saveAsExcel output format
|
||||||
await innerFrame.getByRole('tab', { name: '全部' }).click()
|
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 }
|
||||||
|
]
|
||||||
|
|
||||||
// Set limit to 5000 (Python lines 237-239)
|
log.debug('Adding orders to worksheet', { orderCount: orders.length })
|
||||||
const inputBox = innerFrame.locator('#rc_select_0')
|
// Add data rows - merge orderInfo with each material
|
||||||
await inputBox.fill('5000')
|
for (const order of orders) {
|
||||||
await inputBox.press('Enter')
|
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 })
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Download a single batch of orders
|
* Clean up temporary batch files after merging
|
||||||
* Reference: Python download_batch() method lines 133-175
|
* @param filePaths - Array of temporary file paths to delete
|
||||||
*/
|
*/
|
||||||
private async downloadBatch(
|
private async cleanupTempFiles(filePaths: string[]): Promise<void> {
|
||||||
session: ErpSession,
|
for (const filePath of filePaths) {
|
||||||
popupPage: any,
|
try {
|
||||||
workFrame: any,
|
await fs.unlink(filePath)
|
||||||
orderNumbers: string[],
|
log.debug('Deleted temporary file', { filePath })
|
||||||
batchIndex: number,
|
} catch (error) {
|
||||||
totalBatches: number
|
// Log error but don't fail the main process
|
||||||
): Promise<string> {
|
log.error('Failed to delete temporary file', { filePath, error })
|
||||||
// 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(this.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
|
* Import merged Excel data to database with logging
|
||||||
* Reference: Python lines 148-153
|
* @param filePath - Path to the merged Excel file
|
||||||
|
* @param onLog - Optional log callback
|
||||||
|
* @returns Import result with statistics
|
||||||
*/
|
*/
|
||||||
private async waitForLoading(workFrame: any): Promise<void> {
|
private async importToDatabaseWithLogging(
|
||||||
const loadingLocator = workFrame
|
filePath: string,
|
||||||
.locator('div')
|
onLog?: (level: LogLevel, message: string) => void
|
||||||
.filter({ hasText: ERP_LOCATORS.extractor.loadingText })
|
): Promise<ImportResult> {
|
||||||
.nth(1)
|
log.info('Starting database import', { filePath })
|
||||||
|
onLog?.('info', `开始导入数据到数据库...`)
|
||||||
|
|
||||||
|
const importService = new DataImportService()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await loadingLocator.waitFor({ state: 'visible', timeout: 3000 })
|
const result = await importService.importFromExcel(filePath, 1000)
|
||||||
await loadingLocator.waitFor({ state: 'hidden', timeout: 0 })
|
|
||||||
} catch {
|
log.info('Import completed', {
|
||||||
// Loading completed quickly or never appeared
|
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
|
||||||
* Split array into batches
|
} catch (error) {
|
||||||
* Reference: Python group_order_ids() method lines 128-131
|
const errorMsg = error instanceof Error ? error.message : String(error)
|
||||||
*/
|
log.error('Import failed', { error: errorMsg })
|
||||||
private createBatches<T>(items: T[], batchSize: number): T[][] {
|
onLog?.('error', `导入失败:${errorMsg}`)
|
||||||
const batches: T[][] = []
|
|
||||||
for (let i = 0; i < items.length; i += batchSize) {
|
return {
|
||||||
batches.push(items.slice(i, i + batchSize))
|
success: false,
|
||||||
}
|
recordsRead: 0,
|
||||||
return batches
|
recordsDeleted: 0,
|
||||||
|
recordsImported: 0,
|
||||||
|
uniqueSourceNumbers: 0,
|
||||||
|
errors: [errorMsg]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,16 +2,19 @@
|
|||||||
* Order Number Resolver Service
|
* Order Number Resolver Service
|
||||||
*
|
*
|
||||||
* Automatically recognizes productionID and 生产订单号 (production order number),
|
* Automatically recognizes productionID and 生产订单号 (production order number),
|
||||||
* and converts them via MySQL database lookup.
|
* and converts them via database lookup.
|
||||||
*
|
*
|
||||||
* - productionID format: 2 digits + 1 letter + serial number (e.g., "22A1", "22A1234")
|
* - productionID format: 2 digits + 1 letter + serial number (e.g., "22A1", "22A1234")
|
||||||
* - 生产订单号 format: SC + 14 digits (e.g., "SC70202602120085")
|
* - 生产订单号 format: SC + 14 digits (e.g., "SC70202602120085")
|
||||||
*
|
*
|
||||||
* Database table: productionContractData_26年压力表合同数据
|
* Database table and field names are loaded from config.yaml
|
||||||
* Fields: 总排号 (productionID), 生产订单号 (production order number)
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { MySqlService } from '../database/mysql'
|
import type { IDatabaseService } from '../database'
|
||||||
|
import { ConfigManager } from '../config/config-manager'
|
||||||
|
import { createLogger } from '../logger'
|
||||||
|
|
||||||
|
const log = createLogger('OrderResolver')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Order mapping result
|
* Order mapping result
|
||||||
@@ -23,316 +26,316 @@ export interface OrderMapping {
|
|||||||
productionId?: string
|
productionId?: string
|
||||||
/** Final production order number to use */
|
/** Final production order number to use */
|
||||||
orderNumber?: string
|
orderNumber?: string
|
||||||
/** Whether this mapping is valid */
|
/** Whether the order number was successfully resolved */
|
||||||
isValid: boolean
|
resolved: boolean
|
||||||
/** Error or warning message */
|
/** Error message if resolution failed */
|
||||||
error?: string
|
error?: string
|
||||||
/** Input type */
|
|
||||||
inputType: 'productionId' | 'orderNumber' | 'unknown'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Order number type recognition result
|
||||||
|
*/
|
||||||
|
export type OrderNumberType = 'productionId' | 'orderNumber' | 'unknown'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolution statistics
|
* Resolution statistics
|
||||||
*/
|
*/
|
||||||
export interface ResolutionStats {
|
export interface ResolutionStats {
|
||||||
totalInputs: number
|
totalInputs: number
|
||||||
recognizedAsProductionId: number
|
validOrderNumbers: number
|
||||||
recognizedAsOrderNumber: number
|
validProductionIds: number
|
||||||
|
resolvedCount: number
|
||||||
|
failedCount: number
|
||||||
unknownFormat: number
|
unknownFormat: number
|
||||||
successfullyResolved: number
|
|
||||||
failedToResolve: number
|
|
||||||
notFoundInDatabase: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Regular expression patterns
|
* ProductionID pattern: 2 digits + 1 letter + 1-6 digits
|
||||||
|
* Examples: 22A1, 22A123, 26B10617
|
||||||
*/
|
*/
|
||||||
export const ORDER_PATTERNS = {
|
const PRODUCTION_ID_PATTERN = /^\d{2}[A-Z]\d{1,6}$/i
|
||||||
/** productionID: 2 digits + 1 letter + serial number (1+) */
|
|
||||||
PRODUCTION_ID: /^\d{2}[A-Za-z]\d+$/,
|
/**
|
||||||
/** 生产订单号:SC + 14 digits */
|
* Production order number pattern: SC + 14 digits
|
||||||
ORDER_NUMBER: /^SC\d{14}$/
|
*/
|
||||||
} as const
|
const ORDER_NUMBER_PATTERN = /^SC\d{14}$/i
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Database table and field names
|
* Database table and field names
|
||||||
* Can be overridden via environment variables:
|
* Loaded from config.yaml via ConfigManager
|
||||||
* - DB_TABLE_NAME: Table name (default: 'productionContractData_26年压力表合同数据')
|
|
||||||
* - DB_FIELD_PRODUCTION_ID: Field name for productionID (default: '总排号')
|
|
||||||
* - DB_FIELD_ORDER_NUMBER: Field name for order number (default: '生产订单号')
|
|
||||||
*/
|
*/
|
||||||
export const DB_CONFIG = {
|
export function getDbConfig() {
|
||||||
TABLE_NAME: process.env.DB_TABLE_NAME || 'productionContractData_26年压力表合同数据',
|
const configManager = ConfigManager.getInstance()
|
||||||
FIELD_PRODUCTION_ID: process.env.DB_FIELD_PRODUCTION_ID || '总排号',
|
const config = configManager.getConfig()
|
||||||
FIELD_ORDER_NUMBER: process.env.DB_FIELD_ORDER_NUMBER || '生产订单号'
|
return {
|
||||||
} as const
|
TABLE_NAME: config.orderResolution.tableName || 'productionContractData_26 年压力表合同数据',
|
||||||
|
FIELD_PRODUCTION_ID: config.orderResolution.productionIdField || '总排号',
|
||||||
|
FIELD_ORDER_NUMBER: config.orderResolution.orderNumberField || '生产订单号'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Order Number Resolver Service
|
* Order Number Resolver Service
|
||||||
*/
|
*/
|
||||||
export class OrderNumberResolver {
|
export class OrderNumberResolver {
|
||||||
private mysqlService: MySqlService
|
private dbService: IDatabaseService
|
||||||
|
|
||||||
constructor(mysqlService: MySqlService) {
|
constructor(dbService: IDatabaseService) {
|
||||||
this.mysqlService = mysqlService
|
this.dbService = dbService
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recognize the type of an input string
|
* Get table name based on database type
|
||||||
* @param input - The input string to recognize
|
* Converts MySQL schema_tablename format to SQL Server [schema].[tablename] format
|
||||||
* @returns The recognized type
|
* e.g., productionContractData_26年压力表合同数据 -> [productionContractData].[26年压力表合同数据]
|
||||||
|
* dbo_MaterialsToBeDeleted -> [dbo].[MaterialsToBeDeleted]
|
||||||
*/
|
*/
|
||||||
recognizeType(input: string): 'productionId' | 'orderNumber' | 'unknown' {
|
private getTableName(tableName: string): string {
|
||||||
const trimmed = input.trim()
|
if (this.dbService.type === 'sqlserver') {
|
||||||
|
// Find the FIRST underscore to split schema and table name
|
||||||
if (ORDER_PATTERNS.ORDER_NUMBER.test(trimmed)) {
|
// This handles patterns like: schema_tablename
|
||||||
return 'orderNumber'
|
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
|
||||||
if (ORDER_PATTERNS.PRODUCTION_ID.test(trimmed)) {
|
return `[dbo].[${tableName}]`
|
||||||
return 'productionId'
|
|
||||||
}
|
}
|
||||||
|
return tableName
|
||||||
return 'unknown'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve a list of inputs to production order numbers
|
* Check if input matches productionID pattern
|
||||||
* @param inputs - List of input strings (can be productionID or 生产订单号)
|
|
||||||
* @returns List of order mappings
|
|
||||||
*/
|
*/
|
||||||
async resolve(inputs: string[]): Promise<OrderMapping[]> {
|
isProductionId(input: string): boolean {
|
||||||
const mappings: OrderMapping[] = []
|
return PRODUCTION_ID_PATTERN.test(input)
|
||||||
const stats: ResolutionStats = {
|
|
||||||
totalInputs: inputs.length,
|
|
||||||
recognizedAsProductionId: 0,
|
|
||||||
recognizedAsOrderNumber: 0,
|
|
||||||
unknownFormat: 0,
|
|
||||||
successfullyResolved: 0,
|
|
||||||
failedToResolve: 0,
|
|
||||||
notFoundInDatabase: 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// First pass: recognize types and categorize
|
|
||||||
const productionIds: string[] = []
|
|
||||||
const orderNumbers: string[] = []
|
|
||||||
|
|
||||||
for (const input of inputs) {
|
|
||||||
const trimmed = input.trim()
|
|
||||||
if (!trimmed) continue
|
|
||||||
|
|
||||||
const type = this.recognizeType(trimmed)
|
|
||||||
|
|
||||||
const baseMapping: OrderMapping = {
|
|
||||||
input: trimmed,
|
|
||||||
isValid: false,
|
|
||||||
inputType: type
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === 'productionId') {
|
|
||||||
stats.recognizedAsProductionId++
|
|
||||||
productionIds.push(trimmed)
|
|
||||||
baseMapping.productionId = trimmed
|
|
||||||
} else if (type === 'orderNumber') {
|
|
||||||
stats.recognizedAsOrderNumber++
|
|
||||||
orderNumbers.push(trimmed)
|
|
||||||
baseMapping.orderNumber = trimmed
|
|
||||||
baseMapping.isValid = true // Order numbers are valid by format
|
|
||||||
stats.successfullyResolved++
|
|
||||||
} else {
|
|
||||||
stats.unknownFormat++
|
|
||||||
baseMapping.error = `无法识别的格式:${trimmed}`
|
|
||||||
mappings.push(baseMapping)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
mappings.push(baseMapping)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Query database for productionIDs
|
|
||||||
if (productionIds.length > 0) {
|
|
||||||
const productionIdMappings = await this.resolveProductionIds(productionIds)
|
|
||||||
|
|
||||||
// Update mappings with database results
|
|
||||||
for (const mapping of mappings) {
|
|
||||||
if (mapping.inputType === 'productionId') {
|
|
||||||
const dbResult = productionIdMappings.find((m) => m.input === mapping.input)
|
|
||||||
if (dbResult) {
|
|
||||||
mapping.orderNumber = dbResult.orderNumber
|
|
||||||
mapping.isValid = dbResult.isValid
|
|
||||||
mapping.error = dbResult.error
|
|
||||||
|
|
||||||
if (dbResult.isValid) {
|
|
||||||
stats.successfullyResolved++
|
|
||||||
} else {
|
|
||||||
stats.failedToResolve++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify order numbers exist in database (optional validation)
|
|
||||||
// This can be skipped if you want to allow any SC+14digits format
|
|
||||||
// For now, we'll verify them against the database
|
|
||||||
if (orderNumbers.length > 0) {
|
|
||||||
const verifiedOrderNumbers = new Set(await this.verifyOrderNumbers(orderNumbers))
|
|
||||||
|
|
||||||
for (const mapping of mappings) {
|
|
||||||
if (mapping.inputType === 'orderNumber' && mapping.orderNumber) {
|
|
||||||
if (!verifiedOrderNumbers.has(mapping.orderNumber)) {
|
|
||||||
mapping.isValid = false
|
|
||||||
mapping.error = `生产订单号不存在于数据库中:${mapping.orderNumber}`
|
|
||||||
stats.notFoundInDatabase++
|
|
||||||
stats.successfullyResolved--
|
|
||||||
stats.failedToResolve++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return mappings
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve productionIDs to production order numbers via database lookup
|
* Check if input matches order number pattern
|
||||||
* @param productionIds - List of productionIDs to resolve
|
|
||||||
* @returns List of order mappings
|
|
||||||
*/
|
*/
|
||||||
private async resolveProductionIds(productionIds: string[]): Promise<OrderMapping[]> {
|
isOrderNumber(input: string): boolean {
|
||||||
const mappings: OrderMapping[] = []
|
return ORDER_NUMBER_PATTERN.test(input)
|
||||||
|
|
||||||
if (!this.mysqlService.isConnected()) {
|
|
||||||
// Database not connected, return all as failed
|
|
||||||
for (const pid of productionIds) {
|
|
||||||
mappings.push({
|
|
||||||
input: pid,
|
|
||||||
productionId: pid,
|
|
||||||
isValid: false,
|
|
||||||
error: '数据库未连接,无法查询生产订单号',
|
|
||||||
inputType: 'productionId'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return mappings
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map productionID to order number via database lookup
|
||||||
|
*/
|
||||||
|
async mapProductionIdToOrderNumber(productionId: string): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
// Build query with placeholders
|
const dbConfig = getDbConfig()
|
||||||
const placeholders = productionIds.map(() => '?').join(', ')
|
const tableName = this.getTableName(dbConfig.TABLE_NAME)
|
||||||
const query = `
|
|
||||||
SELECT \`${DB_CONFIG.FIELD_PRODUCTION_ID}\`, \`${DB_CONFIG.FIELD_ORDER_NUMBER}\`
|
|
||||||
FROM \`${DB_CONFIG.TABLE_NAME}\`
|
|
||||||
WHERE \`${DB_CONFIG.FIELD_PRODUCTION_ID}\` IN (${placeholders})
|
|
||||||
`
|
|
||||||
|
|
||||||
const result = await this.mysqlService.query(query, productionIds)
|
let sql: string
|
||||||
|
let params: any[]
|
||||||
|
|
||||||
// Create a map for quick lookup
|
if (this.dbService.type === 'sqlserver') {
|
||||||
const resultMap = new Map<string, string>()
|
// 使用 COLLATE 指定不区分大小写的排序规则
|
||||||
for (const row of result.rows) {
|
sql = `SELECT TOP 1 [${dbConfig.FIELD_ORDER_NUMBER}] FROM ${tableName} WHERE [${dbConfig.FIELD_PRODUCTION_ID}] COLLATE SQL_Latin1_General_CP1_CI_AS = @p0`
|
||||||
const prodId = row[DB_CONFIG.FIELD_PRODUCTION_ID] as string
|
params = [productionId]
|
||||||
const orderNum = row[DB_CONFIG.FIELD_ORDER_NUMBER] as string
|
|
||||||
if (prodId && orderNum) {
|
|
||||||
resultMap.set(prodId, orderNum)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build mappings
|
|
||||||
for (const pid of productionIds) {
|
|
||||||
const orderNumber = resultMap.get(pid)
|
|
||||||
|
|
||||||
if (orderNumber) {
|
|
||||||
mappings.push({
|
|
||||||
input: pid,
|
|
||||||
productionId: pid,
|
|
||||||
orderNumber,
|
|
||||||
isValid: true,
|
|
||||||
inputType: 'productionId'
|
|
||||||
})
|
|
||||||
} else {
|
} else {
|
||||||
mappings.push({
|
// MySQL 默认不区分大小写,但显式使用 UPPER 确保一致性
|
||||||
input: pid,
|
sql = `SELECT \`${dbConfig.FIELD_ORDER_NUMBER}\` FROM \`${tableName}\` WHERE UPPER(\`${dbConfig.FIELD_PRODUCTION_ID}\`) = UPPER(?) LIMIT 1`
|
||||||
productionId: pid,
|
params = [productionId]
|
||||||
isValid: false,
|
}
|
||||||
error: `数据库中未找到生产 ID:${pid}`,
|
|
||||||
inputType: '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
|
return mappings
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : '未知数据库错误'
|
const message = error instanceof Error ? error.message : '未知数据库错误'
|
||||||
|
log.error('Failed to map productionIds to order numbers', {
|
||||||
// Return all as failed with error
|
error: message
|
||||||
return productionIds.map((pid) => ({
|
})
|
||||||
input: pid,
|
throw error
|
||||||
productionId: pid,
|
|
||||||
isValid: false,
|
|
||||||
error: `数据库查询失败:${message}`,
|
|
||||||
inputType: 'productionId'
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verify that order numbers exist in the database
|
* Resolve order numbers from mixed input
|
||||||
* @param orderNumbers - List of order numbers to verify
|
*
|
||||||
* @returns List of valid order numbers
|
* 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
|
||||||
*/
|
*/
|
||||||
private async verifyOrderNumbers(orderNumbers: string[]): Promise<string[]> {
|
async resolve(inputs: string[]): Promise<OrderMapping[]> {
|
||||||
if (!this.mysqlService.isConnected()) {
|
// P1: Deduplicate inputs at the input layer to avoid redundant queries
|
||||||
return orderNumbers // Skip verification if not connected
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// Batch query productionId to order number mappings
|
||||||
const placeholders = orderNumbers.map(() => '?').join(', ')
|
// 使用小写 key 存储映射,以支持忽略大小写查找
|
||||||
const query = `
|
const mappings = new Map<string, string>()
|
||||||
SELECT \`${DB_CONFIG.FIELD_ORDER_NUMBER}\`
|
if (productionIds.length > 0) {
|
||||||
FROM \`${DB_CONFIG.TABLE_NAME}\`
|
const batchMappings = await this.mapProductionIdsToOrderNumbers(productionIds)
|
||||||
WHERE \`${DB_CONFIG.FIELD_ORDER_NUMBER}\` IN (${placeholders})
|
batchMappings.forEach((orderNum, prodId) => {
|
||||||
`
|
mappings.set(prodId.toLowerCase(), orderNum)
|
||||||
|
})
|
||||||
const result = await this.mysqlService.query(query, orderNumbers)
|
|
||||||
return result.rows.map((row) => row[DB_CONFIG.FIELD_ORDER_NUMBER] as string)
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('[OrderResolver] Failed to verify order numbers:', error)
|
|
||||||
return orderNumbers // Skip verification on error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
* Get resolution statistics
|
||||||
* @param mappings - List of order mappings
|
|
||||||
* @returns Resolution statistics
|
|
||||||
*/
|
*/
|
||||||
getStats(mappings: OrderMapping[]): ResolutionStats {
|
getStats(mappings: OrderMapping[]): ResolutionStats {
|
||||||
const stats: ResolutionStats = {
|
const stats: ResolutionStats = {
|
||||||
totalInputs: mappings.length,
|
totalInputs: mappings.length,
|
||||||
recognizedAsProductionId: 0,
|
validOrderNumbers: 0,
|
||||||
recognizedAsOrderNumber: 0,
|
validProductionIds: 0,
|
||||||
unknownFormat: 0,
|
resolvedCount: 0,
|
||||||
successfullyResolved: 0,
|
failedCount: 0,
|
||||||
failedToResolve: 0,
|
unknownFormat: 0
|
||||||
notFoundInDatabase: 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const mapping of mappings) {
|
for (const mapping of mappings) {
|
||||||
if (mapping.inputType === 'productionId') {
|
if (mapping.resolved) {
|
||||||
stats.recognizedAsProductionId++
|
stats.resolvedCount++
|
||||||
} else if (mapping.inputType === 'orderNumber') {
|
if (mapping.orderNumber && !mapping.productionId) {
|
||||||
stats.recognizedAsOrderNumber++
|
stats.validOrderNumbers++
|
||||||
|
} else if (mapping.productionId) {
|
||||||
|
stats.validProductionIds++
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
|
stats.failedCount++
|
||||||
|
if (!mapping.productionId && !mapping.orderNumber) {
|
||||||
stats.unknownFormat++
|
stats.unknownFormat++
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mapping.isValid) {
|
|
||||||
stats.successfullyResolved++
|
|
||||||
} else if (mapping.error?.includes('不存在于数据库中')) {
|
|
||||||
stats.notFoundInDatabase++
|
|
||||||
stats.failedToResolve++
|
|
||||||
} else if (mapping.error) {
|
|
||||||
stats.failedToResolve++
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,20 +343,45 @@ export class OrderNumberResolver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract valid order numbers from mappings
|
* Get deduplication summary for logging
|
||||||
* @param mappings - List of order mappings
|
* Returns a human-readable report showing:
|
||||||
* @returns List of valid production order numbers
|
* - Input count
|
||||||
|
* - Unique order numbers count
|
||||||
|
* - Mapping details (which productionIDs map to which order numbers)
|
||||||
*/
|
*/
|
||||||
getValidOrderNumbers(mappings: OrderMapping[]): string[] {
|
getDeduplicationReport(mappings: OrderMapping[]): {
|
||||||
return mappings.filter((m) => m.isValid && m.orderNumber).map((m) => m.orderNumber!)
|
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
|
||||||
* Extract warnings/errors from mappings
|
const uniqueOrderNumbersCount = orderNumberGroups.size
|
||||||
* @param mappings - List of order mappings
|
|
||||||
* @returns List of warning messages
|
// Build summary string
|
||||||
*/
|
let summary = `输入 ${inputCount} 个总排号 → 解析为 ${uniqueOrderNumbersCount} 个唯一订单号`
|
||||||
getWarnings(mappings: OrderMapping[]): string[] {
|
|
||||||
return mappings.filter((m) => !m.isValid && m.error).map((m) => m.error!)
|
if (inputCount > uniqueOrderNumbersCount) {
|
||||||
|
const duplicateCount = inputCount - uniqueOrderNumbersCount
|
||||||
|
summary += `(${duplicateCount} 个重复已合并)`
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
inputCount,
|
||||||
|
uniqueOrderNumbersCount,
|
||||||
|
orderNumberGroups,
|
||||||
|
summary
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import ExcelJS from 'exceljs'
|
import ExcelJS from 'exceljs'
|
||||||
import type { DiscreteMaterialPlan, ExcelParseOptions, OrderHeader } from '../../types/excel.types'
|
import type { DiscreteMaterialPlan, ExcelParseOptions, OrderHeader } from '../../types/excel.types'
|
||||||
import path from 'path'
|
import { createLogger } from '../logger'
|
||||||
|
|
||||||
|
const log = createLogger('ExcelParser')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Excel Parser Service
|
* Excel Parser Service
|
||||||
@@ -22,6 +24,11 @@ export class ExcelParser {
|
|||||||
|
|
||||||
// Mapping from Chinese field names to English property names
|
// Mapping from Chinese field names to English property names
|
||||||
private CHINESE_TO_ENGLISH_MAPPING: Record<string, string> = {
|
private CHINESE_TO_ENGLISH_MAPPING: Record<string, string> = {
|
||||||
|
// Header fields (row 2-4)
|
||||||
|
工厂: 'factory',
|
||||||
|
备料状态: 'materialStatus',
|
||||||
|
备料计划单号: 'planNumber',
|
||||||
|
备料类型: 'materialType',
|
||||||
生产部门: 'productionDepartment',
|
生产部门: 'productionDepartment',
|
||||||
生产订单: 'productionOrder',
|
生产订单: 'productionOrder',
|
||||||
来源单号: 'productionOrder', // This is the order number we need!
|
来源单号: 'productionOrder', // This is the order number we need!
|
||||||
@@ -30,8 +37,14 @@ export class ExcelParser {
|
|||||||
产品规格: 'productSpecification',
|
产品规格: 'productSpecification',
|
||||||
计划数量: 'plannedQuantity',
|
计划数量: 'plannedQuantity',
|
||||||
单位: 'unit',
|
单位: 'unit',
|
||||||
|
用料部门: 'department',
|
||||||
|
备注: 'remark',
|
||||||
需用日期: 'requiredDate',
|
需用日期: 'requiredDate',
|
||||||
|
// Footer fields (row 14-15)
|
||||||
制单人: 'creator',
|
制单人: 'creator',
|
||||||
|
制单日期: 'createDate',
|
||||||
|
审批人: 'approver',
|
||||||
|
审批日期: 'approveDate',
|
||||||
打印人: 'printer',
|
打印人: 'printer',
|
||||||
打印日期: 'printDate',
|
打印日期: 'printDate',
|
||||||
// Mapped fields (after FIELD_NAME_MAPPING)
|
// Mapped fields (after FIELD_NAME_MAPPING)
|
||||||
@@ -39,23 +52,11 @@ export class ExcelParser {
|
|||||||
产品单位: 'unit'
|
产品单位: 'unit'
|
||||||
}
|
}
|
||||||
|
|
||||||
private verbose: boolean
|
|
||||||
|
|
||||||
constructor(options: ExcelParseOptions = {}) {
|
|
||||||
this.verbose = options.verbose || false
|
|
||||||
}
|
|
||||||
|
|
||||||
private log(...args: any[]): void {
|
|
||||||
if (this.verbose) {
|
|
||||||
console.log('[ExcelParser]', ...args)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse Excel file and extract material plans
|
* Parse Excel file and extract material plans
|
||||||
*/
|
*/
|
||||||
async parse(filePath: string, options: ExcelParseOptions = {}): Promise<DiscreteMaterialPlan[]> {
|
async parse(filePath: string, options: ExcelParseOptions = {}): Promise<DiscreteMaterialPlan[]> {
|
||||||
this.log('Parsing Excel file:', filePath)
|
log.debug('Parsing Excel file:', filePath)
|
||||||
|
|
||||||
const workbook = new ExcelJS.Workbook()
|
const workbook = new ExcelJS.Workbook()
|
||||||
await workbook.xlsx.readFile(filePath)
|
await workbook.xlsx.readFile(filePath)
|
||||||
@@ -69,11 +70,11 @@ export class ExcelParser {
|
|||||||
const allRows: any[][] = []
|
const allRows: any[][] = []
|
||||||
|
|
||||||
// Read all rows into memory
|
// Read all rows into memory
|
||||||
worksheet.eachRow((row, rowNumber) => {
|
worksheet.eachRow((row, _rowNumber) => {
|
||||||
allRows.push(row.values as any[])
|
allRows.push(row.values as any[])
|
||||||
})
|
})
|
||||||
|
|
||||||
this.log(`Total rows read: ${allRows.length} (worksheet has ${worksheet.rowCount} rows)`)
|
log.debug(`Total rows read: ${allRows.length} (worksheet has ${worksheet.rowCount} rows)`)
|
||||||
|
|
||||||
// Parse orders from rows
|
// Parse orders from rows
|
||||||
const orders = this.parseOrders(allRows)
|
const orders = this.parseOrders(allRows)
|
||||||
@@ -87,7 +88,7 @@ export class ExcelParser {
|
|||||||
|
|
||||||
// Skip empty orders if option is set
|
// Skip empty orders if option is set
|
||||||
if (options.skipEmptyOrders && materials.length === 0) {
|
if (options.skipEmptyOrders && materials.length === 0) {
|
||||||
this.log('Skipping empty order:', orderInfo.productionOrder)
|
log.debug('Skipping empty order:', orderInfo.productionOrder)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +115,7 @@ export class ExcelParser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.log(`Parsed ${plans.length} material plans from ${orders.length} orders`)
|
log.debug(`Parsed ${plans.length} material plans from ${orders.length} orders`)
|
||||||
|
|
||||||
return plans
|
return plans
|
||||||
}
|
}
|
||||||
@@ -132,7 +133,7 @@ export class ExcelParser {
|
|||||||
throw new Error('No parsed data available. Call parse() first.')
|
throw new Error('No parsed data available. Call parse() first.')
|
||||||
}
|
}
|
||||||
|
|
||||||
this.log('Saving parsed data to Excel:', outputPath)
|
log.debug('Saving parsed data to Excel:', outputPath)
|
||||||
|
|
||||||
const workbook = new ExcelJS.Workbook()
|
const workbook = new ExcelJS.Workbook()
|
||||||
const worksheet = workbook.addWorksheet('Data')
|
const worksheet = workbook.addWorksheet('Data')
|
||||||
@@ -147,7 +148,7 @@ export class ExcelParser {
|
|||||||
{ header: '产品编码', key: 'productCode', width: 15 },
|
{ header: '产品编码', key: 'productCode', width: 15 },
|
||||||
{ header: '产品名称', key: 'productName', width: 30 },
|
{ header: '产品名称', key: 'productName', width: 30 },
|
||||||
{ header: '产品计划数量', key: 'productPlannedQuantity', width: 15 },
|
{ header: '产品计划数量', key: 'productPlannedQuantity', width: 15 },
|
||||||
{ header: '单位', key: 'productUnit', width: 10 },
|
{ header: '产品单位', key: 'productUnit', width: 10 },
|
||||||
{ header: '用料部门', key: 'department', width: 15 },
|
{ header: '用料部门', key: 'department', width: 15 },
|
||||||
{ header: '备注', key: 'remark', width: 20 },
|
{ header: '备注', key: 'remark', width: 20 },
|
||||||
{ header: '制单人', key: 'creator', width: 15 },
|
{ header: '制单人', key: 'creator', width: 15 },
|
||||||
@@ -215,7 +216,7 @@ export class ExcelParser {
|
|||||||
|
|
||||||
// Save workbook
|
// Save workbook
|
||||||
await workbook.xlsx.writeFile(outputPath)
|
await workbook.xlsx.writeFile(outputPath)
|
||||||
this.log(
|
log.debug(
|
||||||
`Excel file saved: ${outputPath} (${orders.length} orders, ${worksheet.rowCount - 1} data rows)`
|
`Excel file saved: ${outputPath} (${orders.length} orders, ${worksheet.rowCount - 1} data rows)`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -242,7 +243,7 @@ export class ExcelParser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Debug: check productionOrder extraction
|
// Debug: check productionOrder extraction
|
||||||
this.log(`Order ${orders.length + 1}: productionOrder="${orderInfo.productionOrder}"`)
|
log.debug(`Order ${orders.length + 1}: productionOrder="${orderInfo.productionOrder}"`)
|
||||||
|
|
||||||
// Find table header row dynamically (look for "序号" in index 1)
|
// Find table header row dynamically (look for "序号" in index 1)
|
||||||
// Note: worksheet.eachRow() skips empty rows, so we can't use fixed offsets
|
// Note: worksheet.eachRow() skips empty rows, so we can't use fixed offsets
|
||||||
@@ -252,7 +253,7 @@ export class ExcelParser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (tableRow >= allRows.length || !allRows[tableRow]) {
|
if (tableRow >= allRows.length || !allRows[tableRow]) {
|
||||||
this.log(' ⚠️ Table header not found, skipping this order')
|
log.debug(' ⚠️ Table header not found, skipping this order')
|
||||||
i++
|
i++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -269,7 +270,7 @@ export class ExcelParser {
|
|||||||
|
|
||||||
if (isEmptyRow) {
|
if (isEmptyRow) {
|
||||||
// No data, find footer info
|
// No data, find footer info
|
||||||
this.log('Order has no material data')
|
log.debug('Order has no material data')
|
||||||
const materials: any[] = []
|
const materials: any[] = []
|
||||||
const footerInfo: OrderHeader = {}
|
const footerInfo: OrderHeader = {}
|
||||||
let dataRow = nextRow + 1
|
let dataRow = nextRow + 1
|
||||||
@@ -295,7 +296,7 @@ export class ExcelParser {
|
|||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
// Has data, extract materials
|
// Has data, extract materials
|
||||||
this.log('Order has material data')
|
log.debug('Order has material data')
|
||||||
const materials: any[] = []
|
const materials: any[] = []
|
||||||
const footerInfo: OrderHeader = {}
|
const footerInfo: OrderHeader = {}
|
||||||
let dataRow = tableRow + 1
|
let dataRow = tableRow + 1
|
||||||
@@ -321,7 +322,7 @@ export class ExcelParser {
|
|||||||
if (dataRow + 1 < allRows.length && allRows[dataRow + 1]) {
|
if (dataRow + 1 < allRows.length && allRows[dataRow + 1]) {
|
||||||
this.parseHeaderRow(allRows[dataRow + 1], footerInfo)
|
this.parseHeaderRow(allRows[dataRow + 1], footerInfo)
|
||||||
}
|
}
|
||||||
this.log(' Found footer row, stopping material parsing')
|
log.debug(' Found footer row, stopping material parsing')
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,7 +330,7 @@ export class ExcelParser {
|
|||||||
// Next row is footer, parse current row as material first
|
// Next row is footer, parse current row as material first
|
||||||
const material = this.parseMaterialRowInternal(allRows[dataRow], dataRow + 1)
|
const material = this.parseMaterialRowInternal(allRows[dataRow], dataRow + 1)
|
||||||
if (material) {
|
if (material) {
|
||||||
this.log(' Parsed material:', material.materialCode)
|
log.debug(' Parsed material:', material.materialCode)
|
||||||
materials.push(material)
|
materials.push(material)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,17 +339,17 @@ export class ExcelParser {
|
|||||||
if (dataRow + 2 < allRows.length && allRows[dataRow + 2]) {
|
if (dataRow + 2 < allRows.length && allRows[dataRow + 2]) {
|
||||||
this.parseHeaderRow(allRows[dataRow + 2], footerInfo)
|
this.parseHeaderRow(allRows[dataRow + 2], footerInfo)
|
||||||
}
|
}
|
||||||
this.log(' Found footer in next row, stopping material parsing')
|
log.debug(' Found footer in next row, stopping material parsing')
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract material data
|
// Extract material data
|
||||||
const material = this.parseMaterialRowInternal(allRows[dataRow], dataRow + 1)
|
const material = this.parseMaterialRowInternal(allRows[dataRow], dataRow + 1)
|
||||||
if (material) {
|
if (material) {
|
||||||
this.log(' Parsed material:', material.materialCode)
|
//log.debug(' Parsed material:', material.materialCode)
|
||||||
materials.push(material)
|
materials.push(material)
|
||||||
} else {
|
} else {
|
||||||
this.log(' Skipped material row at', dataRow + 1)
|
log.debug(' Skipped material row at', dataRow + 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
dataRow++
|
dataRow++
|
||||||
@@ -360,7 +361,7 @@ export class ExcelParser {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.log(
|
log.debug(
|
||||||
` ⚠️ Table header check failed at row ${tableRow + 1}, value="${allRows[tableRow] ? allRows[tableRow][1] : 'null'}"`
|
` ⚠️ Table header check failed at row ${tableRow + 1}, value="${allRows[tableRow] ? allRows[tableRow][1] : 'null'}"`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -372,7 +373,7 @@ export class ExcelParser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.log(`parseOrders: Returning ${orders.length} orders`)
|
log.debug(`parseOrders: Returning ${orders.length} orders`)
|
||||||
return orders
|
return orders
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
126
src/main/services/logger/audit-logger.ts
Normal file
126
src/main/services/logger/audit-logger.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* Audit Logger Service
|
||||||
|
* Writes audit logs in JSONL format with 30-day rotation using winston-daily-rotate-file
|
||||||
|
*/
|
||||||
|
|
||||||
|
import winston from 'winston'
|
||||||
|
import DailyRotateFile from 'winston-daily-rotate-file'
|
||||||
|
import path from 'path'
|
||||||
|
import { app } from 'electron'
|
||||||
|
import fs from 'fs'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Audit log entry structure
|
||||||
|
* All 8 required fields for comprehensive audit tracking
|
||||||
|
*/
|
||||||
|
export interface AuditEntry {
|
||||||
|
/** ISO 8601 timestamp of the audit event */
|
||||||
|
timestamp: string
|
||||||
|
/** The action that was performed (e.g., 'LOGIN', 'EXTRACT', 'DELETE') */
|
||||||
|
action: string
|
||||||
|
/** User ID who performed the action */
|
||||||
|
userId: string
|
||||||
|
/** Username of the user who performed the action */
|
||||||
|
username: string
|
||||||
|
/** Computer name from which the action was performed */
|
||||||
|
computerName: string
|
||||||
|
/** The resource that was affected (e.g., table name, file path) */
|
||||||
|
resource: string
|
||||||
|
/** Status of the action: 'success' | 'failure' | 'partial' */
|
||||||
|
status: 'success' | 'failure' | 'partial'
|
||||||
|
/** Additional metadata about the audit event */
|
||||||
|
metadata: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the log directory for audit logs
|
||||||
|
* Uses app.getPath('logs') in production, local logs dir in development
|
||||||
|
*/
|
||||||
|
function getLogDir(): string {
|
||||||
|
if (app && app.isReady()) {
|
||||||
|
return app.getPath('logs')
|
||||||
|
}
|
||||||
|
// Fallback for development or before app is ready
|
||||||
|
const devLogDir = path.join(process.cwd(), 'logs')
|
||||||
|
if (!fs.existsSync(devLogDir)) {
|
||||||
|
fs.mkdirSync(devLogDir, { recursive: true })
|
||||||
|
}
|
||||||
|
return devLogDir
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSONL formatter - outputs one JSON object per line
|
||||||
|
* This is the key difference from the standard JSON formatter
|
||||||
|
*/
|
||||||
|
const jsonlFormat = winston.format.printf(({ message }) => {
|
||||||
|
// Message should already be a JSON string
|
||||||
|
return typeof message === 'string' ? message : JSON.stringify(message)
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the audit logger instance with daily rotation
|
||||||
|
* Configured for 30-day retention as per requirements
|
||||||
|
*/
|
||||||
|
const auditLogger = winston.createLogger({
|
||||||
|
level: 'info',
|
||||||
|
silent: false,
|
||||||
|
transports: [
|
||||||
|
new DailyRotateFile({
|
||||||
|
filename: path.join(getLogDir(), 'audit-%DATE%.jsonl'),
|
||||||
|
datePattern: 'YYYY-MM-DD',
|
||||||
|
zippedArchive: true,
|
||||||
|
maxSize: '20m',
|
||||||
|
maxFiles: '30d', // 30-day retention
|
||||||
|
level: 'info',
|
||||||
|
format: winston.format.combine(
|
||||||
|
winston.format.timestamp({ format: 'YYYY-MM-DDTHH:mm:ss.SSSZ' }),
|
||||||
|
jsonlFormat
|
||||||
|
)
|
||||||
|
})
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log an audit event
|
||||||
|
*
|
||||||
|
* @param action - The action that was performed
|
||||||
|
* @param userId - User ID who performed the action
|
||||||
|
* @param details - Additional details including username, computerName, resource, status, and optional metadata
|
||||||
|
* @returns Promise that resolves when the log is written (non-blocking)
|
||||||
|
*/
|
||||||
|
export async function logAudit(
|
||||||
|
action: string,
|
||||||
|
userId: string,
|
||||||
|
details: {
|
||||||
|
username: string
|
||||||
|
computerName: string
|
||||||
|
resource: string
|
||||||
|
status: 'success' | 'failure' | 'partial'
|
||||||
|
metadata?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
): Promise<void> {
|
||||||
|
const entry: AuditEntry = {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
action,
|
||||||
|
userId,
|
||||||
|
username: details.username,
|
||||||
|
computerName: details.computerName,
|
||||||
|
resource: details.resource,
|
||||||
|
status: details.status,
|
||||||
|
metadata: details.metadata || {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write as JSONL - one JSON object per line
|
||||||
|
// Using info level with the entry stringified as the message
|
||||||
|
auditLogger.info(JSON.stringify(entry))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flush and close the audit logger (call on app shutdown)
|
||||||
|
*/
|
||||||
|
export async function closeAuditLogger(): Promise<void> {
|
||||||
|
// Winston logger.close() is synchronous
|
||||||
|
auditLogger.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
export default auditLogger
|
||||||
242
src/main/services/logger/error-utils.ts
Normal file
242
src/main/services/logger/error-utils.ts
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
/**
|
||||||
|
* Error Logging Utilities
|
||||||
|
*
|
||||||
|
* Provides comprehensive error serialization and formatting for logging.
|
||||||
|
* Captures full error context including stack traces, causes, and custom properties.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ErrorLike, SerializedError } from '../../types/errors'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if value is an Error or Error-like object
|
||||||
|
*/
|
||||||
|
export function isError(value: unknown): value is Error | ErrorLike {
|
||||||
|
return (
|
||||||
|
value instanceof Error ||
|
||||||
|
(typeof value === 'object' &&
|
||||||
|
value !== null &&
|
||||||
|
'name' in value &&
|
||||||
|
'message' in value &&
|
||||||
|
typeof (value as any).message === 'string')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize an error into a plain object for logging
|
||||||
|
* Captures all enumerable and non-enumerable properties
|
||||||
|
*/
|
||||||
|
export function serializeError(error: unknown): SerializedError {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
const serialized: SerializedError = {
|
||||||
|
name: error.name,
|
||||||
|
message: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
cause: error.cause ? serializeError(error.cause) : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture custom properties from Error subclasses
|
||||||
|
const props = Object.getOwnPropertyNames(error)
|
||||||
|
for (const prop of props) {
|
||||||
|
if (!['name', 'message', 'stack', 'cause'].includes(prop)) {
|
||||||
|
const value = (error as any)[prop]
|
||||||
|
if (value !== undefined) {
|
||||||
|
serialized[prop] = isError(value) ? serializeError(value) : value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return serialized
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError(error)) {
|
||||||
|
return {
|
||||||
|
name: (error as any).name || 'UnknownError',
|
||||||
|
message: (error as any).message || String(error),
|
||||||
|
stack: (error as any).stack,
|
||||||
|
cause: (error as any).cause ? serializeError((error as any).cause) : undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-error values
|
||||||
|
return {
|
||||||
|
name: 'UnknownError',
|
||||||
|
message: typeof error === 'string' ? error : JSON.stringify(error) || 'Unknown error occurred'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize error for production logging
|
||||||
|
* Removes sensitive information while preserving error structure
|
||||||
|
*/
|
||||||
|
export function sanitizeError(error: SerializedError): SerializedError {
|
||||||
|
const sensitiveKeys = [
|
||||||
|
'password',
|
||||||
|
'secret',
|
||||||
|
'token',
|
||||||
|
'apiKey',
|
||||||
|
'api_key',
|
||||||
|
'credentials',
|
||||||
|
'authorization',
|
||||||
|
'privateKey',
|
||||||
|
'secretKey'
|
||||||
|
]
|
||||||
|
|
||||||
|
const sanitized: SerializedError = { ...error }
|
||||||
|
|
||||||
|
// Sanitize message in production
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
// Keep error name and structure, but sanitize message
|
||||||
|
if (sensitiveKeys.some((key) => error.message?.toLowerCase().includes(key))) {
|
||||||
|
sanitized.message = 'An error occurred due to invalid credentials or configuration'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively sanitize cause
|
||||||
|
if (sanitized.cause && typeof sanitized.cause === 'object') {
|
||||||
|
sanitized.cause = sanitizeError(sanitized.cause)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize any custom properties that might contain sensitive data
|
||||||
|
for (const key of Object.keys(sanitized)) {
|
||||||
|
if (sensitiveKeys.some((sensitive) => key.toLowerCase().includes(sensitive))) {
|
||||||
|
sanitized[key] = '[REDACTED]'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitized
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract context from error for logging
|
||||||
|
* Includes file, line, column from stack trace when available
|
||||||
|
*/
|
||||||
|
export function extractErrorContext(error: SerializedError): {
|
||||||
|
fileName?: string
|
||||||
|
lineNumber?: number
|
||||||
|
columnName?: number
|
||||||
|
functionName?: string
|
||||||
|
} {
|
||||||
|
if (!error.stack) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stackLines = error.stack.split('\n')
|
||||||
|
// Skip first line (error name and message), get first stack frame
|
||||||
|
const stackLine = stackLines[1] || stackLines[0]
|
||||||
|
|
||||||
|
// Parse stack frame: "at Function.module.exports (path/to/file.js:123:45)"
|
||||||
|
const match = stackLine.match(/at(?:\s+(.+?)\s+)?\((.+):(\d+):(\d+)\)/)
|
||||||
|
if (match) {
|
||||||
|
return {
|
||||||
|
functionName: match[1],
|
||||||
|
fileName: match[2],
|
||||||
|
lineNumber: parseInt(match[3], 10),
|
||||||
|
columnName: parseInt(match[4], 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alternative format: "at path/to/file.js:123:45"
|
||||||
|
const altMatch = stackLine.match(/at\s+(.+):(\d+):(\d+)/)
|
||||||
|
if (altMatch) {
|
||||||
|
return {
|
||||||
|
fileName: altMatch[1],
|
||||||
|
lineNumber: parseInt(altMatch[2], 10),
|
||||||
|
columnName: parseInt(altMatch[3], 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format error for console/file logging
|
||||||
|
* Returns a formatted string with all error details
|
||||||
|
*/
|
||||||
|
export function formatErrorForLogging(
|
||||||
|
error: unknown,
|
||||||
|
context?: {
|
||||||
|
operation?: string
|
||||||
|
module?: string
|
||||||
|
userId?: string
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
): {
|
||||||
|
message: string
|
||||||
|
metadata: Record<string, unknown>
|
||||||
|
} {
|
||||||
|
const serialized = serializeError(error)
|
||||||
|
const isProd = process.env.NODE_ENV === 'production'
|
||||||
|
const errorToLog = isProd ? sanitizeError(serialized) : serialized
|
||||||
|
const errorContext = extractErrorContext(errorToLog)
|
||||||
|
|
||||||
|
const metadata: Record<string, unknown> = {
|
||||||
|
error: errorToLog,
|
||||||
|
...context
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add error location context if available
|
||||||
|
if (errorContext.fileName) {
|
||||||
|
metadata.errorLocation = {
|
||||||
|
file: errorContext.fileName.split('/').pop() || errorContext.fileName,
|
||||||
|
line: errorContext.lineNumber,
|
||||||
|
column: errorContext.columnName,
|
||||||
|
function: errorContext.functionName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add environment info in development
|
||||||
|
if (!isProd) {
|
||||||
|
metadata.environment = {
|
||||||
|
NODE_ENV: process.env.NODE_ENV,
|
||||||
|
platform: process.platform,
|
||||||
|
nodeVersion: process.version
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = `[${errorToLog.name}] ${errorToLog.message}`
|
||||||
|
|
||||||
|
return { message, metadata }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log error with full context
|
||||||
|
* Wrapper for logger.error that ensures complete error information is captured
|
||||||
|
*/
|
||||||
|
export function logError(
|
||||||
|
logger: { error: (message: string, meta?: Record<string, unknown>) => void },
|
||||||
|
error: unknown,
|
||||||
|
options: {
|
||||||
|
message?: string
|
||||||
|
operation?: string
|
||||||
|
module?: string
|
||||||
|
userId?: string
|
||||||
|
context?: Record<string, unknown>
|
||||||
|
} = {}
|
||||||
|
): void {
|
||||||
|
const { message: customMessage, operation, module: moduleName, userId, context } = options
|
||||||
|
const { message, metadata } = formatErrorForLogging(error, {
|
||||||
|
operation,
|
||||||
|
module: moduleName,
|
||||||
|
userId,
|
||||||
|
...context
|
||||||
|
})
|
||||||
|
|
||||||
|
const finalMessage = customMessage || message
|
||||||
|
logger.error(finalMessage, metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-throw error after logging, preserving original stack
|
||||||
|
*/
|
||||||
|
export function throwAfterLogging(
|
||||||
|
logger: { error: (message: string, meta?: Record<string, unknown>) => void },
|
||||||
|
error: unknown,
|
||||||
|
options: {
|
||||||
|
message?: string
|
||||||
|
operation?: string
|
||||||
|
module?: string
|
||||||
|
} = {}
|
||||||
|
): never {
|
||||||
|
logError(logger, error, options)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* Unified logging system using Winston
|
* Unified logging system using Winston
|
||||||
* Console + File transports with daily rotation
|
* Console + File transports with daily rotation
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Full error serialization with stack traces
|
||||||
|
* - Development/Production environment differentiation
|
||||||
|
* - Structured logging with context
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import winston from 'winston'
|
import winston from 'winston'
|
||||||
@@ -8,6 +13,7 @@ import DailyRotateFile from 'winston-daily-rotate-file'
|
|||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
|
import { serializeError, sanitizeError } from './error-utils'
|
||||||
|
|
||||||
// Get log directory - use app.getPath('logs') in production, or local logs dir in development
|
// Get log directory - use app.getPath('logs') in production, or local logs dir in development
|
||||||
function getLogDir(): string {
|
function getLogDir(): string {
|
||||||
@@ -22,20 +28,54 @@ function getLogDir(): string {
|
|||||||
return devLogDir
|
return devLogDir
|
||||||
}
|
}
|
||||||
|
|
||||||
// Custom format for console output
|
// Check if running in production
|
||||||
|
const isProduction = app?.isPackaged ?? process.env.NODE_ENV === 'production'
|
||||||
|
|
||||||
|
// Custom format for console output - includes full error details
|
||||||
const consoleFormat = winston.format.combine(
|
const consoleFormat = winston.format.combine(
|
||||||
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
||||||
winston.format.colorize(),
|
winston.format.colorize(),
|
||||||
winston.format.printf(({ timestamp, level, message, context, ...meta }) => {
|
winston.format.printf(({ timestamp, level, message, context, error, ...meta }) => {
|
||||||
const contextStr = context ? `[${context}]` : ''
|
const contextStr = context ? `[${context}]` : ''
|
||||||
const metaStr = Object.keys(meta).length > 0 ? ` ${JSON.stringify(meta)}` : ''
|
|
||||||
return `${timestamp} [${level}]${contextStr} ${message}${metaStr}`
|
// Format error with full stack trace
|
||||||
|
let errorStr = ''
|
||||||
|
if (error) {
|
||||||
|
const serialized = isProduction ? sanitizeError(serializeError(error)) : serializeError(error)
|
||||||
|
if (serialized.stack) {
|
||||||
|
errorStr = `\n${serialized.stack}`
|
||||||
|
} else {
|
||||||
|
errorStr = ` ${serialized.message}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const metaStr = Object.keys(meta).length > 0 ? ` ${JSON.stringify(meta, null, 2)}` : ''
|
||||||
|
return `${timestamp} [${level}]${contextStr} ${message}${errorStr}${metaStr}`
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
// Custom format for file output
|
// Custom format for file output - JSON with full error details
|
||||||
const fileFormat = winston.format.combine(
|
const fileFormat = winston.format.combine(
|
||||||
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
||||||
|
winston.format((info) => {
|
||||||
|
// Serialize errors in metadata
|
||||||
|
if (info.error) {
|
||||||
|
info.error = isProduction
|
||||||
|
? sanitizeError(serializeError(info.error))
|
||||||
|
: serializeError(info.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialize any error in meta fields
|
||||||
|
for (const key of Object.keys(info)) {
|
||||||
|
if (key !== 'error' && info[key] instanceof Error) {
|
||||||
|
info[key] = isProduction
|
||||||
|
? sanitizeError(serializeError(info[key]))
|
||||||
|
: serializeError(info[key])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return info
|
||||||
|
})(),
|
||||||
winston.format.json()
|
winston.format.json()
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -52,9 +92,9 @@ const createFileTransport = (level?: string): DailyRotateFile => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the logger instance
|
// Create the logger instance with default level
|
||||||
const logger = winston.createLogger({
|
const logger = winston.createLogger({
|
||||||
level: process.env.LOG_LEVEL || 'info',
|
level: 'info', // Default level, can be updated via setLogLevel()
|
||||||
defaultMeta: { service: 'erpauto' },
|
defaultMeta: { service: 'erpauto' },
|
||||||
transports: [
|
transports: [
|
||||||
// Console transport - always enabled
|
// Console transport - always enabled
|
||||||
@@ -66,8 +106,16 @@ const logger = winston.createLogger({
|
|||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the logger level dynamically
|
||||||
|
* @param level - The new log level
|
||||||
|
*/
|
||||||
|
export function setLogLevel(level: string): void {
|
||||||
|
logger.level = level
|
||||||
|
}
|
||||||
|
|
||||||
// Add error-specific file transport in production
|
// Add error-specific file transport in production
|
||||||
if (process.env.NODE_ENV === 'production') {
|
if (app.isPackaged) {
|
||||||
logger.add(
|
logger.add(
|
||||||
new DailyRotateFile({
|
new DailyRotateFile({
|
||||||
filename: path.join(getLogDir(), 'error-%DATE%.log'),
|
filename: path.join(getLogDir(), 'error-%DATE%.log'),
|
||||||
@@ -90,6 +138,24 @@ export function createLogger(context: string): winston.Logger {
|
|||||||
return logger.child({ context })
|
return logger.child({ context })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log an error with full context and stack trace
|
||||||
|
* This is the recommended way to log errors in the application
|
||||||
|
*
|
||||||
|
* @param log - Logger instance
|
||||||
|
* @param message - Error message
|
||||||
|
* @param error - The error object (Error, BaseError, or any)
|
||||||
|
* @param meta - Additional metadata to include
|
||||||
|
*/
|
||||||
|
export function logError(
|
||||||
|
log: winston.Logger,
|
||||||
|
message: string,
|
||||||
|
error: unknown,
|
||||||
|
meta?: Record<string, unknown>
|
||||||
|
): void {
|
||||||
|
log.error(message, { error, ...meta })
|
||||||
|
}
|
||||||
|
|
||||||
// Export the main logger for direct use
|
// Export the main logger for direct use
|
||||||
export default logger
|
export default logger
|
||||||
|
|
||||||
|
|||||||
299
src/main/services/report/cleaner-report-generator.ts
Normal file
299
src/main/services/report/cleaner-report-generator.ts
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
import path from 'path'
|
||||||
|
import fs from 'fs'
|
||||||
|
import { app } from 'electron'
|
||||||
|
import { createLogger } from '../logger'
|
||||||
|
import type { CleanerResult, OrderCleanDetail, SkippedMaterial } from '../../types/cleaner.types'
|
||||||
|
|
||||||
|
const log = createLogger('CleanerReportGenerator')
|
||||||
|
|
||||||
|
export interface ReportOptions {
|
||||||
|
dryRun: boolean
|
||||||
|
username: string
|
||||||
|
startTime: number
|
||||||
|
endTime: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OrderStats {
|
||||||
|
successCount: number
|
||||||
|
failureCount: number
|
||||||
|
successRate: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CleanerReportGenerator {
|
||||||
|
private readonly reportDir: string
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
const logDir = app.isReady() ? app.getPath('logs') : path.join(process.cwd(), 'logs')
|
||||||
|
this.reportDir = path.join(logDir, 'reports')
|
||||||
|
this.ensureReportDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureReportDir(): void {
|
||||||
|
if (!fs.existsSync(this.reportDir)) {
|
||||||
|
fs.mkdirSync(this.reportDir, { recursive: true })
|
||||||
|
log.info('Created report directory', { path: this.reportDir })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateReport(result: CleanerResult, options: ReportOptions): Promise<string> {
|
||||||
|
const filePath = this.getReportFilePath()
|
||||||
|
log.info('Generating cleaner report', { path: filePath })
|
||||||
|
|
||||||
|
const stats = this.calculateOrderStats(result)
|
||||||
|
const content = this.buildReportContent(result, options, stats)
|
||||||
|
|
||||||
|
await fs.promises.writeFile(filePath, content, 'utf-8')
|
||||||
|
log.info('Report generated successfully', { path: filePath })
|
||||||
|
|
||||||
|
return filePath
|
||||||
|
}
|
||||||
|
|
||||||
|
private getReportFilePath(): string {
|
||||||
|
const now = new Date()
|
||||||
|
const timestamp = now.toISOString().replace(/[:.]/g, '-').slice(0, -5).replace('T', '-')
|
||||||
|
const fileName = `cleaner-report-${timestamp}.md`
|
||||||
|
return path.join(this.reportDir, fileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
private calculateOrderStats(result: CleanerResult): OrderStats {
|
||||||
|
const totalOrders = result.details.length
|
||||||
|
const failureCount = result.details.filter((d) => d.errors.length > 0).length
|
||||||
|
const successCount = totalOrders - failureCount
|
||||||
|
const successRate = totalOrders > 0 ? (successCount / totalOrders) * 100 : 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
successCount,
|
||||||
|
failureCount,
|
||||||
|
successRate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildReportContent(
|
||||||
|
result: CleanerResult,
|
||||||
|
options: ReportOptions,
|
||||||
|
stats: OrderStats
|
||||||
|
): string {
|
||||||
|
const lines: string[] = []
|
||||||
|
|
||||||
|
lines.push('# ERP 物料清理执行报告')
|
||||||
|
lines.push('')
|
||||||
|
|
||||||
|
lines.push('## 执行摘要')
|
||||||
|
lines.push('')
|
||||||
|
lines.push('| 项目 | 值 |')
|
||||||
|
lines.push('| -------------- | --------------------------------- |')
|
||||||
|
lines.push(`| **执行时间** | \`${this.formatDateTime(options.endTime)}\``)
|
||||||
|
lines.push(`| **执行模式** | \`${options.dryRun ? '模拟运行 (Dry Run)' : '正式执行'}\``)
|
||||||
|
lines.push(`| **操作用户** | \`${options.username}\``)
|
||||||
|
lines.push(`| **处理订单数** | \`${result.ordersProcessed}\``)
|
||||||
|
lines.push(`| **删除物料数** | \`${result.materialsDeleted}\``)
|
||||||
|
lines.push(`| **跳过物料数** | \`${result.materialsSkipped}\``)
|
||||||
|
lines.push(`| **错误数量** | \`${result.errors.length}\``)
|
||||||
|
if (result.retriedOrders > 0) {
|
||||||
|
lines.push(`| **重试订单数** | \`${result.retriedOrders}\``)
|
||||||
|
lines.push(`| **成功重试数** | \`${result.successfulRetries}\``)
|
||||||
|
}
|
||||||
|
lines.push(`| **执行耗时** | \`${this.formatDuration(options.startTime, options.endTime)}\``)
|
||||||
|
lines.push('')
|
||||||
|
lines.push('---')
|
||||||
|
lines.push('')
|
||||||
|
|
||||||
|
lines.push('## 执行状态')
|
||||||
|
lines.push('')
|
||||||
|
lines.push('| 状态 | 数量 | 百分比 |')
|
||||||
|
lines.push('| ----------- | ---- | ------ |')
|
||||||
|
lines.push(`| ✅ 成功订单 | ${stats.successCount} | ${stats.successRate.toFixed(1)}% |`)
|
||||||
|
lines.push(`| ❌ 失败订单 | ${stats.failureCount} | ${(100 - stats.successRate).toFixed(1)}% |`)
|
||||||
|
if (result.retriedOrders > 0) {
|
||||||
|
const retrySuccessRate =
|
||||||
|
result.retriedOrders > 0 ? (result.successfulRetries / result.retriedOrders) * 100 : 0
|
||||||
|
lines.push(`| 🔄 重试订单 | ${result.retriedOrders} | 100% |`)
|
||||||
|
lines.push(`| ✅ 成功重试 | ${result.successfulRetries} | ${retrySuccessRate.toFixed(1)}% |`)
|
||||||
|
}
|
||||||
|
lines.push('')
|
||||||
|
lines.push('---')
|
||||||
|
lines.push('')
|
||||||
|
|
||||||
|
lines.push('## 订单处理详情')
|
||||||
|
lines.push('')
|
||||||
|
lines.push('| # | 订单号 | 删除数 | 跳过数 | 状态 | 错误信息 |')
|
||||||
|
lines.push('| --- | -------- | ------ | ------ | ------- | ------------------------ |')
|
||||||
|
|
||||||
|
result.details.forEach((detail, index) => {
|
||||||
|
const orderNum = index + 1
|
||||||
|
let status = detail.errors.length > 0 ? '❌ 失败' : '✅ 成功'
|
||||||
|
|
||||||
|
// Override status if retry was successful
|
||||||
|
if (detail.retrySuccess) {
|
||||||
|
status = '✅ 重试成功'
|
||||||
|
} else if (detail.retryCount > 0 && !detail.retrySuccess) {
|
||||||
|
status = '❌ 重试失败'
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorMsg = detail.errors.length > 0 ? detail.errors[0] : '-'
|
||||||
|
const retryInfo = detail.retryCount > 0 ? ` [重试${detail.retryCount}次]` : ''
|
||||||
|
lines.push(
|
||||||
|
`| ${orderNum} | \`${detail.orderNumber}\` | ${detail.materialsDeleted} | ${detail.materialsSkipped} | ${status}${retryInfo} | \`${errorMsg}\` |`
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
lines.push('')
|
||||||
|
lines.push('---')
|
||||||
|
lines.push('')
|
||||||
|
|
||||||
|
const allSkippedMaterials = this.collectAllSkippedMaterials(result.details)
|
||||||
|
if (allSkippedMaterials.length > 0) {
|
||||||
|
lines.push('## 跳过的物料原因说明')
|
||||||
|
lines.push('')
|
||||||
|
lines.push('| 订单号 | 物料代码 | 物料名称 | 行号 | 跳过原因 |')
|
||||||
|
lines.push('| -------- | -------- | -------- | ---- | --------------------------------- |')
|
||||||
|
|
||||||
|
allSkippedMaterials.forEach((skipped) => {
|
||||||
|
lines.push(
|
||||||
|
`| \`${skipped.orderNumber}\` | \`${skipped.materialCode}\` | \`${skipped.materialName}\` | ${skipped.rowNumber} | ${skipped.reason} |`
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
lines.push('')
|
||||||
|
lines.push('---')
|
||||||
|
lines.push('')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.errors.length > 0) {
|
||||||
|
lines.push('## 错误详情')
|
||||||
|
lines.push('')
|
||||||
|
lines.push(`**错误总数**: \`${result.errors.length}\``)
|
||||||
|
lines.push('')
|
||||||
|
lines.push('### 错误订单列表')
|
||||||
|
lines.push('')
|
||||||
|
|
||||||
|
const errorOrders = this.extractErrorOrders(result.details)
|
||||||
|
errorOrders.forEach((order) => {
|
||||||
|
lines.push(`- \`${order}\``)
|
||||||
|
})
|
||||||
|
|
||||||
|
lines.push('')
|
||||||
|
lines.push('### 错误详细信息')
|
||||||
|
lines.push('')
|
||||||
|
|
||||||
|
result.details
|
||||||
|
.filter((d) => d.errors.length > 0)
|
||||||
|
.forEach((detail) => {
|
||||||
|
lines.push(`#### \`${detail.orderNumber}\``)
|
||||||
|
lines.push('')
|
||||||
|
lines.push('```')
|
||||||
|
lines.push(`订单号:${detail.orderNumber}`)
|
||||||
|
detail.errors.forEach((error) => {
|
||||||
|
lines.push(`错误:${error}`)
|
||||||
|
})
|
||||||
|
lines.push('```')
|
||||||
|
lines.push('')
|
||||||
|
})
|
||||||
|
|
||||||
|
lines.push('---')
|
||||||
|
lines.push('')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add retry details section
|
||||||
|
if (result.retriedOrders > 0) {
|
||||||
|
lines.push('## 重试执行详情')
|
||||||
|
lines.push('')
|
||||||
|
lines.push(
|
||||||
|
`**重试订单总数**: \`${result.retriedOrders}\` | **成功**: \`${result.successfulRetries}\` | **失败**: \`${result.retriedOrders - result.successfulRetries}\``
|
||||||
|
)
|
||||||
|
lines.push('')
|
||||||
|
|
||||||
|
const retriedDetails = result.details.filter((d) => d.retryCount > 0)
|
||||||
|
|
||||||
|
if (retriedDetails.length > 0) {
|
||||||
|
lines.push('### 重试订单列表')
|
||||||
|
lines.push('')
|
||||||
|
lines.push('| 订单号 | 重试次数 | 重试结果 | 重试时间 |')
|
||||||
|
lines.push('| -------- | -------- | -------- | ------------ |')
|
||||||
|
|
||||||
|
retriedDetails.forEach((detail) => {
|
||||||
|
const retryStatus = detail.retrySuccess ? '✅ 成功' : '❌ 失败'
|
||||||
|
const retryTime = detail.retriedAt ? this.formatDateTime(detail.retriedAt) : '-'
|
||||||
|
lines.push(
|
||||||
|
`| \`${detail.orderNumber}\` | ${detail.retryCount} | ${retryStatus} | ${retryTime} |`
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
lines.push('')
|
||||||
|
lines.push('### 重试尝试详细记录')
|
||||||
|
lines.push('')
|
||||||
|
|
||||||
|
retriedDetails.forEach((detail) => {
|
||||||
|
lines.push(`#### \`${detail.orderNumber}\``)
|
||||||
|
lines.push('')
|
||||||
|
lines.push(`- **重试次数**: ${detail.retryCount}`)
|
||||||
|
lines.push(`- **最终结果**: ${detail.retrySuccess ? '✅ 成功' : '❌ 失败'}`)
|
||||||
|
|
||||||
|
if (detail.retryAttempts && detail.retryAttempts.length > 0) {
|
||||||
|
lines.push('')
|
||||||
|
lines.push('**重试尝试记录**:')
|
||||||
|
lines.push('')
|
||||||
|
detail.retryAttempts.forEach((attempt, idx) => {
|
||||||
|
lines.push(
|
||||||
|
`${idx + 1}. **第${attempt.attempt}次尝试** - ${this.formatDateTime(attempt.timestamp)}`
|
||||||
|
)
|
||||||
|
lines.push(` - 错误:${attempt.error}`)
|
||||||
|
})
|
||||||
|
lines.push('')
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('---')
|
||||||
|
lines.push('')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('')
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(`**报告生成时间**: \`${this.formatDateTime(options.endTime)}\``)
|
||||||
|
lines.push('**报表版本**: `v1.0`')
|
||||||
|
|
||||||
|
return lines.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
private collectAllSkippedMaterials(
|
||||||
|
details: OrderCleanDetail[]
|
||||||
|
): Array<SkippedMaterial & { orderNumber: string }> {
|
||||||
|
const result: Array<SkippedMaterial & { orderNumber: string }> = []
|
||||||
|
|
||||||
|
details.forEach((detail) => {
|
||||||
|
if (detail.skippedMaterials && detail.skippedMaterials.length > 0) {
|
||||||
|
detail.skippedMaterials.forEach((skipped) => {
|
||||||
|
result.push({
|
||||||
|
...skipped,
|
||||||
|
orderNumber: detail.orderNumber
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractErrorOrders(details: OrderCleanDetail[]): string[] {
|
||||||
|
return details.filter((d) => d.errors.length > 0).map((d) => d.orderNumber)
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatDateTime(timestamp: number): string {
|
||||||
|
const date = new Date(timestamp)
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
const hours = String(date.getHours()).padStart(2, '0')
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||||
|
const seconds = String(date.getSeconds()).padStart(2, '0')
|
||||||
|
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatDuration(startTime: number, endTime: number): string {
|
||||||
|
const durationMs = endTime - startTime
|
||||||
|
const minutes = Math.floor(durationMs / 60000)
|
||||||
|
const seconds = Math.floor((durationMs % 60000) / 1000)
|
||||||
|
return `${minutes}分${seconds}秒`
|
||||||
|
}
|
||||||
|
}
|
||||||
6
src/main/services/rustfs/index.ts
Normal file
6
src/main/services/rustfs/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* RustFS Service Module
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { RustfsService } from './rustfs-service'
|
||||||
|
export type { UploadResult, DownloadResult, RustfsServiceOptions } from './rustfs-service'
|
||||||
376
src/main/services/rustfs/rustfs-service.ts
Normal file
376
src/main/services/rustfs/rustfs-service.ts
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
/**
|
||||||
|
* RustFS Service
|
||||||
|
*
|
||||||
|
* S3-compatible object storage service for persisting reports and files
|
||||||
|
* Uses AWS SDK for S3 protocol compatibility
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
S3Client,
|
||||||
|
PutObjectCommand,
|
||||||
|
GetObjectCommand,
|
||||||
|
DeleteObjectCommand,
|
||||||
|
ListObjectsV2Command,
|
||||||
|
type PutObjectCommandInput,
|
||||||
|
type GetObjectCommandInput,
|
||||||
|
type DeleteObjectCommandInput
|
||||||
|
} from '@aws-sdk/client-s3'
|
||||||
|
import { createLogger } from '../logger'
|
||||||
|
import type { RustfsConfig } from '../../types/config.schema'
|
||||||
|
import * as fs from 'fs'
|
||||||
|
import * as path from 'path'
|
||||||
|
|
||||||
|
const log = createLogger('RustfsService')
|
||||||
|
|
||||||
|
export interface UploadResult {
|
||||||
|
success: boolean
|
||||||
|
key: string
|
||||||
|
etag?: string
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DownloadResult {
|
||||||
|
success: boolean
|
||||||
|
content: Buffer
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RustfsServiceOptions {
|
||||||
|
config: RustfsConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RustfsService {
|
||||||
|
private client: S3Client
|
||||||
|
private config: RustfsConfig
|
||||||
|
|
||||||
|
constructor(options: RustfsServiceOptions) {
|
||||||
|
const { config } = options
|
||||||
|
|
||||||
|
this.config = config
|
||||||
|
|
||||||
|
// Configure S3 client for RustFS
|
||||||
|
// RustFS is fully compatible with S3 protocol
|
||||||
|
this.client = new S3Client({
|
||||||
|
region: config.region || 'us-east-1',
|
||||||
|
endpoint: config.endpoint,
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: config.accessKey,
|
||||||
|
secretAccessKey: config.secretKey
|
||||||
|
},
|
||||||
|
forcePathStyle: true // Required for some S3-compatible services
|
||||||
|
})
|
||||||
|
|
||||||
|
log.info('RustFS service initialized', {
|
||||||
|
endpoint: config.endpoint,
|
||||||
|
bucket: config.bucket,
|
||||||
|
region: config.region
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a file to RustFS
|
||||||
|
* @param filePath - Local file path to upload
|
||||||
|
* @param key - Object key (path) in the bucket
|
||||||
|
* @param contentType - Optional MIME type
|
||||||
|
*/
|
||||||
|
async uploadFile(filePath: string, key: string, contentType?: string): Promise<UploadResult> {
|
||||||
|
try {
|
||||||
|
// Validate configuration
|
||||||
|
if (!this.config.enabled) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
key,
|
||||||
|
error: 'RustFS is not enabled in configuration'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if file exists
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
key,
|
||||||
|
error: `File not found: ${filePath}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read file content
|
||||||
|
const fileContent = await fs.promises.readFile(filePath)
|
||||||
|
|
||||||
|
// Determine content type
|
||||||
|
const mimeType = contentType || this.getMimeType(filePath) || 'application/octet-stream'
|
||||||
|
|
||||||
|
log.info('Uploading file to RustFS', {
|
||||||
|
filePath,
|
||||||
|
key,
|
||||||
|
contentType: mimeType,
|
||||||
|
size: fileContent.length
|
||||||
|
})
|
||||||
|
|
||||||
|
const input: PutObjectCommandInput = {
|
||||||
|
Bucket: this.config.bucket,
|
||||||
|
Key: key,
|
||||||
|
Body: fileContent,
|
||||||
|
ContentType: mimeType
|
||||||
|
}
|
||||||
|
|
||||||
|
const command = new PutObjectCommand(input)
|
||||||
|
const response = await this.client.send(command)
|
||||||
|
|
||||||
|
log.info('File uploaded successfully', {
|
||||||
|
key,
|
||||||
|
etag: response.ETag
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
key,
|
||||||
|
etag: response.ETag
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown upload error'
|
||||||
|
log.error('Failed to upload file to RustFS', {
|
||||||
|
filePath,
|
||||||
|
key,
|
||||||
|
error: errorMessage
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
key,
|
||||||
|
error: errorMessage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a string content directly to RustFS
|
||||||
|
* @param content - String content to upload
|
||||||
|
* @param key - Object key (path) in the bucket
|
||||||
|
* @param contentType - Optional MIME type
|
||||||
|
*/
|
||||||
|
async uploadString(content: string, key: string, contentType?: string): Promise<UploadResult> {
|
||||||
|
try {
|
||||||
|
if (!this.config.enabled) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
key,
|
||||||
|
error: 'RustFS is not enabled in configuration'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mimeType = contentType || 'text/plain; charset=utf-8'
|
||||||
|
|
||||||
|
log.info('Uploading string content to RustFS', {
|
||||||
|
key,
|
||||||
|
contentType: mimeType,
|
||||||
|
size: content.length
|
||||||
|
})
|
||||||
|
|
||||||
|
const input: PutObjectCommandInput = {
|
||||||
|
Bucket: this.config.bucket,
|
||||||
|
Key: key,
|
||||||
|
Body: Buffer.from(content, 'utf-8'),
|
||||||
|
ContentType: mimeType
|
||||||
|
}
|
||||||
|
|
||||||
|
const command = new PutObjectCommand(input)
|
||||||
|
const response = await this.client.send(command)
|
||||||
|
|
||||||
|
log.info('String content uploaded successfully', {
|
||||||
|
key,
|
||||||
|
etag: response.ETag
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
key,
|
||||||
|
etag: response.ETag
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown upload error'
|
||||||
|
log.error('Failed to upload string to RustFS', {
|
||||||
|
key,
|
||||||
|
error: errorMessage
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
key,
|
||||||
|
error: errorMessage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download a file from RustFS
|
||||||
|
* @param key - Object key (path) in the bucket
|
||||||
|
*/
|
||||||
|
async downloadFile(key: string): Promise<DownloadResult> {
|
||||||
|
try {
|
||||||
|
if (!this.config.enabled) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
content: Buffer.alloc(0),
|
||||||
|
error: 'RustFS is not enabled in configuration'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info('Downloading file from RustFS', { key })
|
||||||
|
|
||||||
|
const input: GetObjectCommandInput = {
|
||||||
|
Bucket: this.config.bucket,
|
||||||
|
Key: key
|
||||||
|
}
|
||||||
|
|
||||||
|
const command = new GetObjectCommand(input)
|
||||||
|
const response = await this.client.send(command)
|
||||||
|
|
||||||
|
const chunks: Buffer[] = []
|
||||||
|
for await (const chunk of response.Body as any) {
|
||||||
|
chunks.push(Buffer.from(chunk))
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = Buffer.concat(chunks)
|
||||||
|
|
||||||
|
log.info('File downloaded successfully', {
|
||||||
|
key,
|
||||||
|
size: content.length
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
content
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown download error'
|
||||||
|
log.error('Failed to download file from RustFS', {
|
||||||
|
key,
|
||||||
|
error: errorMessage
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
content: Buffer.alloc(0),
|
||||||
|
error: errorMessage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a file from RustFS
|
||||||
|
* @param key - Object key (path) in the bucket
|
||||||
|
*/
|
||||||
|
async deleteFile(key: string): Promise<{ success: boolean; error?: string }> {
|
||||||
|
try {
|
||||||
|
if (!this.config.enabled) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'RustFS is not enabled in configuration'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info('Deleting file from RustFS', { key })
|
||||||
|
|
||||||
|
const input: DeleteObjectCommandInput = {
|
||||||
|
Bucket: this.config.bucket,
|
||||||
|
Key: key
|
||||||
|
}
|
||||||
|
|
||||||
|
const command = new DeleteObjectCommand(input)
|
||||||
|
await this.client.send(command)
|
||||||
|
|
||||||
|
log.info('File deleted successfully', { key })
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown delete error'
|
||||||
|
log.error('Failed to delete file from RustFS', {
|
||||||
|
key,
|
||||||
|
error: errorMessage
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: errorMessage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a storage key for cleaner reports
|
||||||
|
* @param reportFileName - Original report file name
|
||||||
|
* @param username - Username who generated the report
|
||||||
|
*/
|
||||||
|
generateReportKey(reportFileName: string, username: string): string {
|
||||||
|
// Organize reports by user for easy access
|
||||||
|
// Format: reports/cleaner/{username}/{filename}
|
||||||
|
return `reports/cleaner/${username}/${reportFileName}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get MIME type based on file extension
|
||||||
|
*/
|
||||||
|
private getMimeType(filePath: string): string | null {
|
||||||
|
const ext = path.extname(filePath).toLowerCase()
|
||||||
|
const mimeTypes: Record<string, string> = {
|
||||||
|
'.md': 'text/markdown; charset=utf-8',
|
||||||
|
'.txt': 'text/plain; charset=utf-8',
|
||||||
|
'.json': 'application/json; charset=utf-8',
|
||||||
|
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
'.xls': 'application/vnd.ms-excel',
|
||||||
|
'.csv': 'text/csv; charset=utf-8',
|
||||||
|
'.pdf': 'application/pdf',
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.gif': 'image/gif'
|
||||||
|
}
|
||||||
|
return mimeTypes[ext] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test connection to RustFS
|
||||||
|
*/
|
||||||
|
async testConnection(): Promise<{
|
||||||
|
success: boolean
|
||||||
|
message: string
|
||||||
|
error?: string
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
log.info('Testing RustFS connection', {
|
||||||
|
endpoint: this.config.endpoint,
|
||||||
|
bucket: this.config.bucket
|
||||||
|
})
|
||||||
|
|
||||||
|
// Try to list objects in the bucket (head bucket operation)
|
||||||
|
const input = {
|
||||||
|
Bucket: this.config.bucket,
|
||||||
|
Prefix: '',
|
||||||
|
MaxKeys: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const command = new ListObjectsV2Command(input)
|
||||||
|
await this.client.send(command)
|
||||||
|
|
||||||
|
log.info('RustFS connection test successful')
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: '连接成功'
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown connection error'
|
||||||
|
log.error('RustFS connection test failed', {
|
||||||
|
error: errorMessage
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: '连接失败',
|
||||||
|
error: errorMessage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
650
src/main/services/update/update-service.ts
Normal file
650
src/main/services/update/update-service.ts
Normal file
@@ -0,0 +1,650 @@
|
|||||||
|
import { BrowserWindow, app } from 'electron'
|
||||||
|
import { createHash } from 'crypto'
|
||||||
|
import * as fs from 'fs'
|
||||||
|
import * as path from 'path'
|
||||||
|
import { spawn } from 'child_process'
|
||||||
|
import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3'
|
||||||
|
import { ConfigManager } from '../config/config-manager'
|
||||||
|
import { createLogger } from '../logger'
|
||||||
|
import { IPC_CHANNELS } from '../../../shared/ipc-channels'
|
||||||
|
import type { UpdateConfig } from '../../types/config.schema'
|
||||||
|
import type { UserType } from '../../types/user.types'
|
||||||
|
import type {
|
||||||
|
DownloadReleaseRequest,
|
||||||
|
DownloadedRelease,
|
||||||
|
ReleaseChannel,
|
||||||
|
UpdateCatalog,
|
||||||
|
UpdateDialogCatalog,
|
||||||
|
UpdateRelease,
|
||||||
|
UpdateStatus
|
||||||
|
} from '../../types/update.types'
|
||||||
|
import {
|
||||||
|
compareVersions,
|
||||||
|
limitCatalogHistory,
|
||||||
|
normalizeReleases,
|
||||||
|
resolveAdminDecision,
|
||||||
|
resolveUserDecision
|
||||||
|
} from './update-utils'
|
||||||
|
|
||||||
|
const log = createLogger('UpdateService')
|
||||||
|
|
||||||
|
function appendPortableLaunchLog(logPath: string, message: string, meta?: Record<string, unknown>): void {
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.dirname(logPath), { recursive: true })
|
||||||
|
const timestamp = new Date().toISOString()
|
||||||
|
const suffix = meta ? ` ${JSON.stringify(meta)}` : ''
|
||||||
|
fs.appendFileSync(logPath, `${timestamp} ${message}${suffix}\n`, 'utf-8')
|
||||||
|
} catch {
|
||||||
|
// Best-effort debug log only.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentAppVersion(): string {
|
||||||
|
return typeof app.getVersion === 'function' ? app.getVersion() : '0.0.0'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentChannel(): ReleaseChannel {
|
||||||
|
return typeof __APP_CHANNEL__ !== 'undefined' ? __APP_CHANNEL__ : 'stable'
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMissingObjectError(error: unknown): boolean {
|
||||||
|
if (!error || typeof error !== 'object') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = error as {
|
||||||
|
name?: string
|
||||||
|
Code?: string
|
||||||
|
code?: string
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
candidate.name === 'NoSuchKey' ||
|
||||||
|
candidate.Code === 'NoSuchKey' ||
|
||||||
|
candidate.code === 'NoSuchKey' ||
|
||||||
|
candidate.message?.includes('The specified key does not exist') === true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSupportState(config: UpdateConfig | null): {
|
||||||
|
supported: boolean
|
||||||
|
reason?: string
|
||||||
|
} {
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
return { supported: false, reason: '当前仅支持 Windows 自动更新' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (app.isPackaged) {
|
||||||
|
return { supported: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config?.allowDevMode) {
|
||||||
|
return { supported: true, reason: '开发模式调试已启用更新检查' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { supported: false, reason: '开发模式默认禁用自动更新检查' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_STATUS: UpdateStatus = {
|
||||||
|
enabled: false,
|
||||||
|
supported: false,
|
||||||
|
phase: 'idle',
|
||||||
|
currentVersion: getCurrentAppVersion(),
|
||||||
|
currentChannel: getCurrentChannel(),
|
||||||
|
currentUserType: null
|
||||||
|
}
|
||||||
|
|
||||||
|
class UpdateStorageClient {
|
||||||
|
private client: S3Client
|
||||||
|
private bucket: string
|
||||||
|
|
||||||
|
constructor(config: UpdateConfig) {
|
||||||
|
this.bucket = config.bucket
|
||||||
|
this.client = new S3Client({
|
||||||
|
region: config.region,
|
||||||
|
endpoint: config.endpoint,
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: config.accessKey,
|
||||||
|
secretAccessKey: config.secretKey
|
||||||
|
},
|
||||||
|
forcePathStyle: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async readText(key: string): Promise<string> {
|
||||||
|
const response = await this.client.send(
|
||||||
|
new GetObjectCommand({
|
||||||
|
Bucket: this.bucket,
|
||||||
|
Key: key
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const chunks: Buffer[] = []
|
||||||
|
for await (const chunk of response.Body as AsyncIterable<Uint8Array>) {
|
||||||
|
chunks.push(Buffer.from(chunk))
|
||||||
|
}
|
||||||
|
return Buffer.concat(chunks).toString('utf-8')
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadToFile(key: string, destination: string): Promise<void> {
|
||||||
|
const response = await this.client.send(
|
||||||
|
new GetObjectCommand({
|
||||||
|
Bucket: this.bucket,
|
||||||
|
Key: key
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
await fs.promises.mkdir(path.dirname(destination), { recursive: true })
|
||||||
|
const output = fs.createWriteStream(destination)
|
||||||
|
const body = response.Body as NodeJS.ReadableStream
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
body.on('error', reject)
|
||||||
|
output.on('error', reject)
|
||||||
|
output.on('finish', resolve)
|
||||||
|
body.pipe(output)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateService {
|
||||||
|
private static instance: UpdateService | null = null
|
||||||
|
|
||||||
|
private config: UpdateConfig | null = null
|
||||||
|
private storageClient: UpdateStorageClient | null = null
|
||||||
|
private status: UpdateStatus = { ...DEFAULT_STATUS }
|
||||||
|
private catalog: UpdateCatalog = { stable: [], preview: [] }
|
||||||
|
private changelogCache = new Map<string, string>()
|
||||||
|
private intervalHandle: NodeJS.Timeout | null = null
|
||||||
|
private initialized = false
|
||||||
|
|
||||||
|
public static getInstance(): UpdateService {
|
||||||
|
if (!UpdateService.instance) {
|
||||||
|
UpdateService.instance = new UpdateService()
|
||||||
|
}
|
||||||
|
|
||||||
|
return UpdateService.instance
|
||||||
|
}
|
||||||
|
|
||||||
|
public initialize(): void {
|
||||||
|
if (this.initialized) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.config = ConfigManager.getInstance().getConfig().update ?? null
|
||||||
|
const supportState = getSupportState(this.config)
|
||||||
|
const enabled = Boolean(this.config?.enabled && supportState.supported)
|
||||||
|
|
||||||
|
this.status = {
|
||||||
|
...this.status,
|
||||||
|
enabled,
|
||||||
|
supported: supportState.supported,
|
||||||
|
message: supportState.reason
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enabled && this.config) {
|
||||||
|
this.storageClient = new UpdateStorageClient(this.config)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info('Update service initialized', {
|
||||||
|
enabled,
|
||||||
|
supported: supportState.supported,
|
||||||
|
currentVersion: this.status.currentVersion,
|
||||||
|
currentChannel: this.status.currentChannel
|
||||||
|
})
|
||||||
|
|
||||||
|
this.initialized = true
|
||||||
|
}
|
||||||
|
|
||||||
|
public getStatus(): UpdateStatus {
|
||||||
|
return { ...this.status }
|
||||||
|
}
|
||||||
|
|
||||||
|
public getCatalog(): UpdateDialogCatalog {
|
||||||
|
const currentUserType = this.status.currentUserType
|
||||||
|
if (!this.status.enabled || !currentUserType || currentUserType === 'Guest') {
|
||||||
|
return { mode: 'disabled' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentUserType === 'User') {
|
||||||
|
return {
|
||||||
|
mode: 'user',
|
||||||
|
recommendedRelease: this.status.recommendedRelease
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode: 'admin',
|
||||||
|
recommendedRelease: this.status.recommendedRelease,
|
||||||
|
channels: limitCatalogHistory(
|
||||||
|
this.catalog,
|
||||||
|
this.config?.maxAdminHistoryPerChannel ?? 10
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getChangelog(release: DownloadReleaseRequest): Promise<string> {
|
||||||
|
this.ensureInitialized()
|
||||||
|
if (!this.status.enabled || !this.storageClient) {
|
||||||
|
throw new Error('自动更新不可用')
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = `${release.channel}:${release.version}`
|
||||||
|
const cached = this.changelogCache.get(cacheKey)
|
||||||
|
if (cached) {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
const markdown = await this.storageClient.readText(release.changelogKey)
|
||||||
|
this.changelogCache.set(cacheKey, markdown)
|
||||||
|
return markdown
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setUserContext(userType: UserType | null): Promise<void> {
|
||||||
|
this.ensureInitialized()
|
||||||
|
this.status.currentUserType = userType
|
||||||
|
|
||||||
|
if (!this.status.enabled || !userType || userType === 'Guest') {
|
||||||
|
this.clearPolling()
|
||||||
|
this.catalog = { stable: [], preview: [] }
|
||||||
|
this.publishStatus({
|
||||||
|
phase: 'idle',
|
||||||
|
currentUserType: userType,
|
||||||
|
recommendedRelease: undefined,
|
||||||
|
latestVersion: undefined,
|
||||||
|
latestChannel: undefined,
|
||||||
|
downloadedRelease: undefined,
|
||||||
|
progress: undefined,
|
||||||
|
adminHasAnyRelease: false,
|
||||||
|
error: undefined,
|
||||||
|
message: this.status.supported ? undefined : this.status.message
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.checkForUpdates()
|
||||||
|
this.startPolling()
|
||||||
|
}
|
||||||
|
|
||||||
|
public async checkForUpdates(): Promise<UpdateStatus> {
|
||||||
|
this.ensureInitialized()
|
||||||
|
if (!this.status.enabled || !this.storageClient || !this.config) {
|
||||||
|
return this.getStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.publishStatus({
|
||||||
|
phase: 'checking',
|
||||||
|
error: undefined,
|
||||||
|
message: '正在检查更新...'
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const currentUserType = this.status.currentUserType
|
||||||
|
this.catalog = {
|
||||||
|
stable: await this.fetchChannelIndex('stable'),
|
||||||
|
preview: currentUserType === 'Admin' ? await this.fetchChannelIndex('preview') : []
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentUserType === 'User') {
|
||||||
|
await this.processUserCatalog()
|
||||||
|
} else if (currentUserType === 'Admin') {
|
||||||
|
this.processAdminCatalog()
|
||||||
|
} else {
|
||||||
|
this.publishStatus({
|
||||||
|
phase: 'idle',
|
||||||
|
message: undefined,
|
||||||
|
error: undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : '检查更新失败'
|
||||||
|
log.error('Failed to check for updates', { error: message })
|
||||||
|
this.publishStatus({
|
||||||
|
phase: 'error',
|
||||||
|
error: message,
|
||||||
|
message: '检查更新失败'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.getStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
public async downloadRelease(request: DownloadReleaseRequest): Promise<UpdateStatus> {
|
||||||
|
this.ensureInitialized()
|
||||||
|
if (!this.status.enabled || !this.storageClient) {
|
||||||
|
throw new Error('自动更新不可用')
|
||||||
|
}
|
||||||
|
|
||||||
|
this.publishStatus({
|
||||||
|
phase: 'downloading',
|
||||||
|
progress: 0,
|
||||||
|
latestVersion: request.version,
|
||||||
|
latestChannel: request.channel,
|
||||||
|
recommendedRelease: request,
|
||||||
|
message: `正在下载 ${request.channel} ${request.version}...`,
|
||||||
|
error: undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
const downloadPath = this.getDownloadPath(request)
|
||||||
|
await this.storageClient.downloadToFile(request.artifactKey, downloadPath)
|
||||||
|
const hash = await this.calculateSha256(downloadPath)
|
||||||
|
|
||||||
|
if (hash.toLowerCase() !== request.sha256.toLowerCase()) {
|
||||||
|
await fs.promises.rm(downloadPath, { force: true })
|
||||||
|
throw new Error('更新包校验失败,文件哈希不匹配')
|
||||||
|
}
|
||||||
|
|
||||||
|
this.publishStatus({
|
||||||
|
phase: 'downloaded',
|
||||||
|
progress: 100,
|
||||||
|
downloadedRelease: {
|
||||||
|
version: request.version,
|
||||||
|
channel: request.channel,
|
||||||
|
localPath: downloadPath
|
||||||
|
},
|
||||||
|
latestVersion: request.version,
|
||||||
|
latestChannel: request.channel,
|
||||||
|
message: `已下载 ${request.channel} ${request.version}`
|
||||||
|
})
|
||||||
|
|
||||||
|
return this.getStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
public async installDownloadedRelease(): Promise<void> {
|
||||||
|
this.ensureInitialized()
|
||||||
|
|
||||||
|
const downloaded = this.status.downloadedRelease
|
||||||
|
if (!this.status.enabled || !downloaded) {
|
||||||
|
throw new Error('没有可安装的更新包')
|
||||||
|
}
|
||||||
|
|
||||||
|
const updaterSourcePath = this.resolveUpdaterBinaryPath()
|
||||||
|
const updaterPath = await this.prepareUpdaterBinary(updaterSourcePath)
|
||||||
|
const targetExe = process.env.PORTABLE_EXECUTABLE_FILE || process.execPath
|
||||||
|
const logPath = path.join(app.getPath('userData'), 'updates', 'portable-update.log')
|
||||||
|
const launchLogPath = path.join(app.getPath('userData'), 'updates', 'portable-launch.log')
|
||||||
|
const appArgs = process.argv.slice(1)
|
||||||
|
const argsBase64 =
|
||||||
|
appArgs.length > 0 ? Buffer.from(appArgs.join('\0'), 'utf-8').toString('base64') : ''
|
||||||
|
|
||||||
|
const spawnArgs = [
|
||||||
|
'--targetExe',
|
||||||
|
targetExe,
|
||||||
|
'--downloadedExe',
|
||||||
|
downloaded.localPath,
|
||||||
|
'--parentPid',
|
||||||
|
String(process.pid),
|
||||||
|
'--logPath',
|
||||||
|
logPath
|
||||||
|
]
|
||||||
|
|
||||||
|
if (argsBase64) {
|
||||||
|
spawnArgs.push('--argsBase64', argsBase64)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.publishStatus({
|
||||||
|
phase: 'installing',
|
||||||
|
latestVersion: downloaded.version,
|
||||||
|
latestChannel: downloaded.channel,
|
||||||
|
message: `正在安装 ${downloaded.version}...`,
|
||||||
|
error: undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
appendPortableLaunchLog(launchLogPath, 'Preparing portable updater launch', {
|
||||||
|
updaterSourcePath,
|
||||||
|
updaterPath,
|
||||||
|
targetExe,
|
||||||
|
downloadedExe: downloaded.localPath,
|
||||||
|
parentPid: process.pid,
|
||||||
|
logPath,
|
||||||
|
appArgs,
|
||||||
|
updaterExists: fs.existsSync(updaterPath),
|
||||||
|
spawnArgs
|
||||||
|
})
|
||||||
|
|
||||||
|
const child = spawn(updaterPath, spawnArgs, {
|
||||||
|
detached: true,
|
||||||
|
stdio: 'ignore',
|
||||||
|
windowsHide: true
|
||||||
|
})
|
||||||
|
|
||||||
|
appendPortableLaunchLog(launchLogPath, 'Spawn returned for portable updater', {
|
||||||
|
childPid: child.pid ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
child.on('error', (error) => {
|
||||||
|
appendPortableLaunchLog(launchLogPath, 'Portable updater executable spawn error', {
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
})
|
||||||
|
log.error('Failed to launch portable updater executable', {
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
child.once('spawn', () => {
|
||||||
|
appendPortableLaunchLog(launchLogPath, 'Portable updater executable spawned', {
|
||||||
|
childPid: child.pid ?? null
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
child.unref()
|
||||||
|
app.quit()
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureInitialized(): void {
|
||||||
|
if (!this.initialized) {
|
||||||
|
this.initialize()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private publishStatus(next: Partial<UpdateStatus>): void {
|
||||||
|
this.status = {
|
||||||
|
...this.status,
|
||||||
|
...next
|
||||||
|
}
|
||||||
|
|
||||||
|
BrowserWindow.getAllWindows().forEach((window) => {
|
||||||
|
window.webContents.send(IPC_CHANNELS.UPDATE_STATUS_CHANGED, this.status)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchChannelIndex(channel: ReleaseChannel): Promise<UpdateRelease[]> {
|
||||||
|
if (!this.storageClient || !this.config) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = `${this.config.basePrefix}/${channel}/index.json`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = await this.storageClient.readText(key)
|
||||||
|
const parsed = JSON.parse(raw) as unknown
|
||||||
|
return normalizeReleases(parsed, channel)
|
||||||
|
} catch (error) {
|
||||||
|
if (isMissingObjectError(error)) {
|
||||||
|
log.warn('Update channel index not found, treating as empty', { channel, key })
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async processUserCatalog(): Promise<void> {
|
||||||
|
const decision = resolveUserDecision({
|
||||||
|
currentVersion: this.status.currentVersion,
|
||||||
|
currentChannel: this.status.currentChannel,
|
||||||
|
catalog: this.catalog
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!decision.recommendedRelease || !decision.shouldOffer) {
|
||||||
|
this.publishStatus({
|
||||||
|
phase: 'idle',
|
||||||
|
recommendedRelease: undefined,
|
||||||
|
latestVersion: undefined,
|
||||||
|
latestChannel: undefined,
|
||||||
|
downloadedRelease: undefined,
|
||||||
|
progress: undefined,
|
||||||
|
adminHasAnyRelease: false,
|
||||||
|
error: undefined,
|
||||||
|
message: undefined
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const recommended = decision.recommendedRelease
|
||||||
|
this.publishStatus({
|
||||||
|
phase: 'available',
|
||||||
|
recommendedRelease: recommended,
|
||||||
|
latestVersion: recommended.version,
|
||||||
|
latestChannel: recommended.channel,
|
||||||
|
adminHasAnyRelease: false,
|
||||||
|
message:
|
||||||
|
this.status.currentChannel === 'preview'
|
||||||
|
? `当前为预览版,可切换回稳定版 ${recommended.version}`
|
||||||
|
: `发现稳定版 ${recommended.version}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const existing = await this.getValidDownloadedRelease(recommended)
|
||||||
|
if (existing) {
|
||||||
|
this.publishStatus({
|
||||||
|
phase: 'downloaded',
|
||||||
|
progress: 100,
|
||||||
|
downloadedRelease: {
|
||||||
|
version: existing.version,
|
||||||
|
channel: existing.channel,
|
||||||
|
localPath: existing.localPath
|
||||||
|
},
|
||||||
|
message:
|
||||||
|
this.status.currentChannel === 'preview'
|
||||||
|
? `稳定版 ${recommended.version} 已准备安装`
|
||||||
|
: `发现稳定版 ${recommended.version}`
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.downloadRelease(recommended)
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : '下载更新失败'
|
||||||
|
this.publishStatus({
|
||||||
|
phase: 'error',
|
||||||
|
error: message,
|
||||||
|
message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private processAdminCatalog(): void {
|
||||||
|
const decision = resolveAdminDecision({
|
||||||
|
currentVersion: this.status.currentVersion,
|
||||||
|
currentChannel: this.status.currentChannel,
|
||||||
|
catalog: this.catalog,
|
||||||
|
maxHistoryPerChannel: this.config?.maxAdminHistoryPerChannel ?? 10
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasHigherVersion = [...this.catalog.stable, ...this.catalog.preview].some(
|
||||||
|
(release) => compareVersions(release.version, this.status.currentVersion) > 0
|
||||||
|
)
|
||||||
|
const hasCrossChannelOption = [...this.catalog.stable, ...this.catalog.preview].some(
|
||||||
|
(release) => release.channel !== this.status.currentChannel
|
||||||
|
)
|
||||||
|
const shouldOffer = hasHigherVersion || hasCrossChannelOption
|
||||||
|
|
||||||
|
this.publishStatus({
|
||||||
|
phase: shouldOffer ? 'available' : 'idle',
|
||||||
|
recommendedRelease: decision.recommendedRelease,
|
||||||
|
latestVersion: decision.recommendedRelease?.version,
|
||||||
|
latestChannel: decision.recommendedRelease?.channel,
|
||||||
|
adminHasAnyRelease: shouldOffer,
|
||||||
|
downloadedRelease: undefined,
|
||||||
|
progress: undefined,
|
||||||
|
error: undefined,
|
||||||
|
message: shouldOffer ? '发现可用版本' : undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private startPolling(): void {
|
||||||
|
this.clearPolling()
|
||||||
|
if (!this.config) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.intervalHandle = setInterval(() => {
|
||||||
|
this.checkForUpdates().catch((error) => {
|
||||||
|
log.warn('Periodic update check failed', {
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}, this.config.checkIntervalMinutes * 60 * 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearPolling(): void {
|
||||||
|
if (this.intervalHandle) {
|
||||||
|
clearInterval(this.intervalHandle)
|
||||||
|
this.intervalHandle = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getValidDownloadedRelease(
|
||||||
|
release: UpdateRelease
|
||||||
|
): Promise<DownloadedRelease | null> {
|
||||||
|
const downloadPath = this.getDownloadPath(release)
|
||||||
|
if (!fs.existsSync(downloadPath)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const hash = await this.calculateSha256(downloadPath)
|
||||||
|
if (hash.toLowerCase() !== release.sha256.toLowerCase()) {
|
||||||
|
await fs.promises.rm(downloadPath, { force: true })
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...release,
|
||||||
|
localPath: downloadPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDownloadPath(release: UpdateRelease): string {
|
||||||
|
return path.join(app.getPath('userData'), 'pending-update', `${release.channel}-${release.version}.exe`)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async calculateSha256(filePath: string): Promise<string> {
|
||||||
|
const hash = createHash('sha256')
|
||||||
|
const input = fs.createReadStream(filePath)
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
input.on('data', (chunk) => hash.update(chunk))
|
||||||
|
input.on('error', reject)
|
||||||
|
input.on('end', resolve)
|
||||||
|
})
|
||||||
|
|
||||||
|
return hash.digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveUpdaterBinaryPath(): string {
|
||||||
|
const packagedPath = path.join(process.resourcesPath, 'portable-updater.exe')
|
||||||
|
const devPath = path.resolve(process.cwd(), 'build', 'bin', 'portable-updater.exe')
|
||||||
|
|
||||||
|
if (fs.existsSync(packagedPath)) {
|
||||||
|
return packagedPath
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fs.existsSync(devPath)) {
|
||||||
|
return devPath
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('未找到便携版更新器')
|
||||||
|
}
|
||||||
|
|
||||||
|
private async prepareUpdaterBinary(sourcePath: string): Promise<string> {
|
||||||
|
const updatesDir = path.join(app.getPath('userData'), 'updates')
|
||||||
|
const stagedPath = path.join(updatesDir, 'portable-updater.exe')
|
||||||
|
|
||||||
|
await fs.promises.mkdir(updatesDir, { recursive: true })
|
||||||
|
await fs.promises.copyFile(sourcePath, stagedPath)
|
||||||
|
|
||||||
|
return stagedPath
|
||||||
|
}
|
||||||
|
}
|
||||||
123
src/main/services/update/update-utils.ts
Normal file
123
src/main/services/update/update-utils.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import type {
|
||||||
|
ReleaseChannel,
|
||||||
|
UpdateCatalog,
|
||||||
|
UpdateDialogCatalog,
|
||||||
|
UpdateRelease
|
||||||
|
} from '../../types/update.types'
|
||||||
|
|
||||||
|
export function compareVersions(left: string, right: string): number {
|
||||||
|
const leftParts = left.split('.').map((part) => Number.parseInt(part, 10) || 0)
|
||||||
|
const rightParts = right.split('.').map((part) => Number.parseInt(part, 10) || 0)
|
||||||
|
const length = Math.max(leftParts.length, rightParts.length)
|
||||||
|
|
||||||
|
for (let index = 0; index < length; index += 1) {
|
||||||
|
const leftValue = leftParts[index] ?? 0
|
||||||
|
const rightValue = rightParts[index] ?? 0
|
||||||
|
|
||||||
|
if (leftValue > rightValue) return 1
|
||||||
|
if (leftValue < rightValue) return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeReleases(input: unknown, channel: ReleaseChannel): UpdateRelease[] {
|
||||||
|
const list = Array.isArray(input)
|
||||||
|
? input
|
||||||
|
: input && typeof input === 'object' && Array.isArray((input as { releases?: unknown[] }).releases)
|
||||||
|
? (input as { releases: unknown[] }).releases
|
||||||
|
: []
|
||||||
|
|
||||||
|
return list
|
||||||
|
.filter((item): item is Record<string, unknown> => !!item && typeof item === 'object')
|
||||||
|
.map((item) => ({
|
||||||
|
version: String(item.version ?? ''),
|
||||||
|
channel,
|
||||||
|
artifactKey: String(item.artifactKey ?? ''),
|
||||||
|
sha256: String(item.sha256 ?? ''),
|
||||||
|
size: Number(item.size ?? 0),
|
||||||
|
publishedAt: String(item.publishedAt ?? ''),
|
||||||
|
changelogKey: String(item.changelogKey ?? ''),
|
||||||
|
notesSummary: item.notesSummary ? String(item.notesSummary) : undefined
|
||||||
|
}))
|
||||||
|
.filter(
|
||||||
|
(item) =>
|
||||||
|
!!item.version &&
|
||||||
|
!!item.artifactKey &&
|
||||||
|
!!item.sha256 &&
|
||||||
|
!!item.changelogKey &&
|
||||||
|
!!item.publishedAt
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sortReleases(releases: UpdateRelease[]): UpdateRelease[] {
|
||||||
|
return [...releases].sort((left, right) => {
|
||||||
|
const versionCompare = compareVersions(right.version, left.version)
|
||||||
|
if (versionCompare !== 0) {
|
||||||
|
return versionCompare
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date(right.publishedAt).getTime() - new Date(left.publishedAt).getTime()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickLatestRelease(releases: UpdateRelease[]): UpdateRelease | undefined {
|
||||||
|
return sortReleases(releases)[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function limitCatalogHistory(catalog: UpdateCatalog, maxPerChannel: number): UpdateCatalog {
|
||||||
|
return {
|
||||||
|
stable: sortReleases(catalog.stable).slice(0, maxPerChannel),
|
||||||
|
preview: sortReleases(catalog.preview).slice(0, maxPerChannel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveUserDecision(params: {
|
||||||
|
currentVersion: string
|
||||||
|
currentChannel: ReleaseChannel
|
||||||
|
catalog: UpdateCatalog
|
||||||
|
}): {
|
||||||
|
recommendedRelease?: UpdateRelease
|
||||||
|
shouldOffer: boolean
|
||||||
|
} {
|
||||||
|
const latestStable = pickLatestRelease(params.catalog.stable)
|
||||||
|
if (!latestStable) {
|
||||||
|
return { recommendedRelease: undefined, shouldOffer: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.currentChannel === 'preview') {
|
||||||
|
return { recommendedRelease: latestStable, shouldOffer: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
recommendedRelease: latestStable,
|
||||||
|
shouldOffer: params.currentVersion !== latestStable.version
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveAdminDecision(params: {
|
||||||
|
currentVersion: string
|
||||||
|
currentChannel: ReleaseChannel
|
||||||
|
catalog: UpdateCatalog
|
||||||
|
maxHistoryPerChannel: number
|
||||||
|
}): UpdateDialogCatalog {
|
||||||
|
const channels = limitCatalogHistory(params.catalog, params.maxHistoryPerChannel)
|
||||||
|
const allReleases = sortReleases([...channels.stable, ...channels.preview])
|
||||||
|
|
||||||
|
const higherVersionRelease = allReleases.find(
|
||||||
|
(release) => compareVersions(release.version, params.currentVersion) > 0
|
||||||
|
)
|
||||||
|
|
||||||
|
const preferredCrossChannelRelease = allReleases.find(
|
||||||
|
(release) => release.channel !== params.currentChannel
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode: 'admin',
|
||||||
|
recommendedRelease:
|
||||||
|
higherVersionRelease ??
|
||||||
|
preferredCrossChannelRelease ??
|
||||||
|
pickLatestRelease(channels[params.currentChannel]),
|
||||||
|
channels
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,8 +10,12 @@
|
|||||||
|
|
||||||
import { MySqlService } from '../database/mysql'
|
import { MySqlService } from '../database/mysql'
|
||||||
import { SqlServerService } from '../database/sql-server'
|
import { SqlServerService } from '../database/sql-server'
|
||||||
|
import { ConfigManager } from '../config/config-manager'
|
||||||
import sql from 'mssql'
|
import sql from 'mssql'
|
||||||
import type { UserInfo } from '../../types/user.types'
|
import type { UserInfo } from '../../types/user.types'
|
||||||
|
import { createLogger, logError } from '../logger'
|
||||||
|
|
||||||
|
const log = createLogger('BipUsersDao')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Database configuration for BIPUsers table
|
* Database configuration for BIPUsers table
|
||||||
@@ -27,8 +31,12 @@ export const BIP_USERS_CONFIG = {
|
|||||||
USERNAME: 'UserName',
|
USERNAME: 'UserName',
|
||||||
USER_TYPE: 'UserType',
|
USER_TYPE: 'UserType',
|
||||||
PASSWORD: 'Password',
|
PASSWORD: 'Password',
|
||||||
COMPUTER_NAME: 'ComputerNmae', // Note: typo in database schema
|
COMPUTER_NAME: 'ComputerName',
|
||||||
CREATE_TIME: 'CreateTime'
|
CREATE_TIME: 'CreateTime',
|
||||||
|
// ERP Configuration columns
|
||||||
|
ERP_URL: 'ERP_URL',
|
||||||
|
ERP_USERNAME: 'ERP_Username',
|
||||||
|
ERP_PASSWORD: 'ERP_Password'
|
||||||
}
|
}
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
@@ -39,17 +47,14 @@ export class BIPUsersDAO {
|
|||||||
private mysqlService: MySqlService | null = null
|
private mysqlService: MySqlService | null = null
|
||||||
private sqlServerService: SqlServerService | null = null
|
private sqlServerService: SqlServerService | null = null
|
||||||
private dbType: 'mysql' | 'sqlserver' = 'mysql'
|
private dbType: 'mysql' | 'sqlserver' = 'mysql'
|
||||||
|
private configManager: ConfigManager
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constructor - determine database type from environment
|
* Constructor - get database type from ConfigManager
|
||||||
*/
|
*/
|
||||||
constructor() {
|
constructor() {
|
||||||
const dbType = process.env.DB_TYPE?.toLowerCase()
|
this.configManager = ConfigManager.getInstance()
|
||||||
if (dbType === 'sqlserver' || dbType === 'mssql') {
|
this.dbType = this.configManager.getDatabaseType()
|
||||||
this.dbType = 'sqlserver'
|
|
||||||
} else {
|
|
||||||
this.dbType = 'mysql'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -65,20 +70,23 @@ export class BIPUsersDAO {
|
|||||||
* Get database service instance (MySQL or SQL Server)
|
* Get database service instance (MySQL or SQL Server)
|
||||||
*/
|
*/
|
||||||
private async getDatabaseService(): Promise<MySqlService | SqlServerService> {
|
private async getDatabaseService(): Promise<MySqlService | SqlServerService> {
|
||||||
|
const config = this.configManager.getConfig()
|
||||||
|
|
||||||
if (this.dbType === 'sqlserver') {
|
if (this.dbType === 'sqlserver') {
|
||||||
if (this.sqlServerService && this.sqlServerService.isConnected()) {
|
if (this.sqlServerService && this.sqlServerService.isConnected()) {
|
||||||
return this.sqlServerService
|
return this.sqlServerService
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const dbConfig = config.database.sqlserver
|
||||||
this.sqlServerService = new SqlServerService({
|
this.sqlServerService = new SqlServerService({
|
||||||
server: process.env.DB_SERVER || 'localhost',
|
server: dbConfig.server,
|
||||||
port: parseInt(process.env.DB_SQLSERVER_PORT || '1433', 10),
|
port: dbConfig.port,
|
||||||
user: process.env.DB_USERNAME || 'sa',
|
user: dbConfig.username,
|
||||||
password: process.env.DB_PASSWORD || '',
|
password: dbConfig.password,
|
||||||
database: process.env.DB_NAME || '',
|
database: dbConfig.database,
|
||||||
options: {
|
options: {
|
||||||
encrypt: process.env.DB_TRUST_SERVER_CERTIFICATE === 'yes',
|
encrypt: false,
|
||||||
trustServerCertificate: process.env.DB_TRUST_SERVER_CERTIFICATE === 'yes'
|
trustServerCertificate: dbConfig.trustServerCertificate
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -89,12 +97,13 @@ export class BIPUsersDAO {
|
|||||||
return this.mysqlService
|
return this.mysqlService
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const dbConfig = config.database.mysql
|
||||||
this.mysqlService = new MySqlService({
|
this.mysqlService = new MySqlService({
|
||||||
host: process.env.DB_MYSQL_HOST || 'localhost',
|
host: dbConfig.host,
|
||||||
port: parseInt(process.env.DB_MYSQL_PORT || '3306', 10),
|
port: dbConfig.port,
|
||||||
user: process.env.DB_USERNAME || 'root',
|
user: dbConfig.username,
|
||||||
password: process.env.DB_PASSWORD || '',
|
password: dbConfig.password,
|
||||||
database: process.env.DB_NAME || ''
|
database: dbConfig.database
|
||||||
})
|
})
|
||||||
|
|
||||||
await this.mysqlService.connect()
|
await this.mysqlService.connect()
|
||||||
@@ -121,8 +130,8 @@ export class BIPUsersDAO {
|
|||||||
`
|
`
|
||||||
|
|
||||||
const result = await (dbService as SqlServerService).queryWithParams(sqlString, {
|
const result = await (dbService as SqlServerService).queryWithParams(sqlString, {
|
||||||
username: { value: username, type: sql.NVarChar },
|
username: { value: username, type: sql.NVarChar(255) },
|
||||||
password: { value: password, type: sql.NVarChar }
|
password: { value: password, type: sql.NVarChar(255) }
|
||||||
})
|
})
|
||||||
|
|
||||||
if (result.rows.length > 0) {
|
if (result.rows.length > 0) {
|
||||||
@@ -154,7 +163,11 @@ export class BIPUsersDAO {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[BIPUsersDAO] Authenticate error:', error)
|
logError(log, 'Authenticate failed', error, {
|
||||||
|
operation: 'authenticate',
|
||||||
|
username,
|
||||||
|
dbType: this.dbType
|
||||||
|
})
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -173,11 +186,11 @@ export class BIPUsersDAO {
|
|||||||
const sqlString = `
|
const sqlString = `
|
||||||
SELECT ID, UserName, UserType
|
SELECT ID, UserName, UserType
|
||||||
FROM ${tableName}
|
FROM ${tableName}
|
||||||
WHERE ComputerNmae = @computerName
|
WHERE ComputerName = @computerName
|
||||||
`
|
`
|
||||||
|
|
||||||
const result = await (dbService as SqlServerService).queryWithParams(sqlString, {
|
const result = await (dbService as SqlServerService).queryWithParams(sqlString, {
|
||||||
computerName: { value: computerName, type: sql.NVarChar }
|
computerName: { value: computerName, type: sql.NVarChar(255) }
|
||||||
})
|
})
|
||||||
|
|
||||||
if (result.rows.length > 0) {
|
if (result.rows.length > 0) {
|
||||||
@@ -193,7 +206,7 @@ export class BIPUsersDAO {
|
|||||||
const sqlString = `
|
const sqlString = `
|
||||||
SELECT ID, UserName, UserType
|
SELECT ID, UserName, UserType
|
||||||
FROM ${tableName}
|
FROM ${tableName}
|
||||||
WHERE ComputerNmae = ?
|
WHERE ComputerName = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
const result = await (dbService as MySqlService).query(sqlString, [computerName])
|
const result = await (dbService as MySqlService).query(sqlString, [computerName])
|
||||||
@@ -209,7 +222,11 @@ export class BIPUsersDAO {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[BIPUsersDAO] Authenticate by computer name error:', error)
|
logError(log, 'Silent login failed', error, {
|
||||||
|
operation: 'authenticateByComputerName',
|
||||||
|
computerName,
|
||||||
|
dbType: this.dbType
|
||||||
|
})
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -241,7 +258,10 @@ export class BIPUsersDAO {
|
|||||||
createTime: row.CreateTime as Date | undefined
|
createTime: row.CreateTime as Date | undefined
|
||||||
}))
|
}))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[BIPUsersDAO] Get all users error:', error)
|
logError(log, 'Get all users failed', error, {
|
||||||
|
operation: 'getAllUsers',
|
||||||
|
dbType: this.dbType
|
||||||
|
})
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -265,49 +285,55 @@ export class BIPUsersDAO {
|
|||||||
const tableName = this.getTableName()
|
const tableName = this.getTableName()
|
||||||
|
|
||||||
if (this.dbType === 'sqlserver') {
|
if (this.dbType === 'sqlserver') {
|
||||||
let sql: string
|
let sqlString: string
|
||||||
let params: Record<string, { value: unknown; type?: sql.ISqlType }>
|
let params: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
value: unknown
|
||||||
|
type?: sql.ISqlType | sql.ISqlTypeFactoryWithLength | sql.ISqlTypeWithLength
|
||||||
|
}
|
||||||
|
>
|
||||||
|
|
||||||
if (computerName) {
|
if (computerName) {
|
||||||
sql = `
|
sqlString = `
|
||||||
INSERT INTO ${tableName}
|
INSERT INTO ${tableName}
|
||||||
(UserName, Password, UserType, ComputerNmae)
|
(UserName, Password, UserType, ComputerName)
|
||||||
VALUES (@username, @password, @userType, @computerName)
|
VALUES (@username, @password, @userType, @computerName)
|
||||||
`
|
`
|
||||||
params = {
|
params = {
|
||||||
username: { value: username, type: sql.NVarChar },
|
username: { value: username, type: sql.NVarChar(255) },
|
||||||
password: { value: password, type: sql.NVarChar },
|
password: { value: password, type: sql.NVarChar(255) },
|
||||||
userType: { value: userType, type: sql.NVarChar },
|
userType: { value: userType, type: sql.NVarChar(255) },
|
||||||
computerName: { value: computerName, type: sql.NVarChar }
|
computerName: { value: computerName, type: sql.NVarChar(255) }
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
sql = `
|
sqlString = `
|
||||||
INSERT INTO ${tableName}
|
INSERT INTO ${tableName}
|
||||||
(UserName, Password, UserType)
|
(UserName, Password, UserType)
|
||||||
VALUES (@username, @password, @userType)
|
VALUES (@username, @password, @userType)
|
||||||
`
|
`
|
||||||
params = {
|
params = {
|
||||||
username: { value: username, type: sql.NVarChar },
|
username: { value: username, type: sql.NVarChar(255) },
|
||||||
password: { value: password, type: sql.NVarChar },
|
password: { value: password, type: sql.NVarChar(255) },
|
||||||
userType: { value: userType, type: sql.NVarChar }
|
userType: { value: userType, type: sql.NVarChar(255) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await (dbService as SqlServerService).queryWithParams(sqlString, params)
|
await (dbService as SqlServerService).queryWithParams(sqlString, params)
|
||||||
return true
|
return true
|
||||||
} else {
|
} else {
|
||||||
let sql: string
|
let sqlString: string
|
||||||
let params: any[]
|
let params: unknown[]
|
||||||
|
|
||||||
if (computerName) {
|
if (computerName) {
|
||||||
sql = `
|
sqlString = `
|
||||||
INSERT INTO ${tableName}
|
INSERT INTO ${tableName}
|
||||||
(UserName, Password, UserType, ComputerNmae)
|
(UserName, Password, UserType, ComputerName)
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?)
|
||||||
`
|
`
|
||||||
params = [username, password, userType, computerName]
|
params = [username, password, userType, computerName]
|
||||||
} else {
|
} else {
|
||||||
sql = `
|
sqlString = `
|
||||||
INSERT INTO ${tableName}
|
INSERT INTO ${tableName}
|
||||||
(UserName, Password, UserType)
|
(UserName, Password, UserType)
|
||||||
VALUES (?, ?, ?)
|
VALUES (?, ?, ?)
|
||||||
@@ -319,7 +345,12 @@ export class BIPUsersDAO {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[BIPUsersDAO] Create user error:', error)
|
logError(log, 'Create user failed', error, {
|
||||||
|
operation: 'createUser',
|
||||||
|
username,
|
||||||
|
userType,
|
||||||
|
dbType: this.dbType
|
||||||
|
})
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -343,8 +374,8 @@ export class BIPUsersDAO {
|
|||||||
`
|
`
|
||||||
|
|
||||||
await (dbService as SqlServerService).queryWithParams(sqlString, {
|
await (dbService as SqlServerService).queryWithParams(sqlString, {
|
||||||
username: { value: username, type: sql.NVarChar },
|
username: { value: username, type: sql.NVarChar(255) },
|
||||||
userType: { value: userType, type: sql.NVarChar }
|
userType: { value: userType, type: sql.NVarChar(255) }
|
||||||
})
|
})
|
||||||
return true
|
return true
|
||||||
} else {
|
} else {
|
||||||
@@ -358,7 +389,12 @@ export class BIPUsersDAO {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[BIPUsersDAO] Update user type error:', error)
|
logError(log, 'Update user type failed', error, {
|
||||||
|
operation: 'updateUserType',
|
||||||
|
username,
|
||||||
|
userType,
|
||||||
|
dbType: this.dbType
|
||||||
|
})
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -382,8 +418,8 @@ export class BIPUsersDAO {
|
|||||||
`
|
`
|
||||||
|
|
||||||
await (dbService as SqlServerService).queryWithParams(sqlString, {
|
await (dbService as SqlServerService).queryWithParams(sqlString, {
|
||||||
username: { value: username, type: sql.NVarChar },
|
username: { value: username, type: sql.NVarChar(255) },
|
||||||
newPassword: { value: newPassword, type: sql.NVarChar }
|
newPassword: { value: newPassword, type: sql.NVarChar(255) }
|
||||||
})
|
})
|
||||||
return true
|
return true
|
||||||
} else {
|
} else {
|
||||||
@@ -397,7 +433,11 @@ export class BIPUsersDAO {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[BIPUsersDAO] Update password error:', error)
|
logError(log, 'Update password failed', error, {
|
||||||
|
operation: 'updatePassword',
|
||||||
|
username,
|
||||||
|
dbType: this.dbType
|
||||||
|
})
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -419,7 +459,7 @@ export class BIPUsersDAO {
|
|||||||
`
|
`
|
||||||
|
|
||||||
await (dbService as SqlServerService).queryWithParams(sqlString, {
|
await (dbService as SqlServerService).queryWithParams(sqlString, {
|
||||||
username: { value: username, type: sql.NVarChar }
|
username: { value: username, type: sql.NVarChar(255) }
|
||||||
})
|
})
|
||||||
return true
|
return true
|
||||||
} else {
|
} else {
|
||||||
@@ -432,7 +472,11 @@ export class BIPUsersDAO {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[BIPUsersDAO] Delete user error:', error)
|
logError(log, 'Delete user failed', error, {
|
||||||
|
operation: 'deleteUser',
|
||||||
|
username,
|
||||||
|
dbType: this.dbType
|
||||||
|
})
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -455,7 +499,7 @@ export class BIPUsersDAO {
|
|||||||
`
|
`
|
||||||
|
|
||||||
const result = await (dbService as SqlServerService).queryWithParams(sqlString, {
|
const result = await (dbService as SqlServerService).queryWithParams(sqlString, {
|
||||||
username: { value: username, type: sql.NVarChar }
|
username: { value: username, type: sql.NVarChar(255) }
|
||||||
})
|
})
|
||||||
return result.rows.length > 0 && (result.rows[0].count as number) > 0
|
return result.rows.length > 0 && (result.rows[0].count as number) > 0
|
||||||
} else {
|
} else {
|
||||||
@@ -469,11 +513,169 @@ export class BIPUsersDAO {
|
|||||||
return result.rows.length > 0 && (result.rows[0].count as number) > 0
|
return result.rows.length > 0 && (result.rows[0].count as number) > 0
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[BIPUsersDAO] User exists error:', error)
|
logError(log, 'Check user exists failed', error, {
|
||||||
|
operation: 'userExists',
|
||||||
|
username,
|
||||||
|
dbType: this.dbType
|
||||||
|
})
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get ERP credentials for a user (username and password only, URL is from config.yaml)
|
||||||
|
* @param username - The username to get ERP credentials for
|
||||||
|
* @returns ERP credentials object or null if not found
|
||||||
|
*/
|
||||||
|
async getUserErpCredentials(username: string): Promise<{
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
} | null> {
|
||||||
|
try {
|
||||||
|
const dbService = await this.getDatabaseService()
|
||||||
|
const tableName = this.getTableName()
|
||||||
|
const cols = BIP_USERS_CONFIG.COLUMNS
|
||||||
|
|
||||||
|
if (this.dbType === 'sqlserver') {
|
||||||
|
const sqlString = `
|
||||||
|
SELECT ${cols.ERP_USERNAME}, ${cols.ERP_PASSWORD}
|
||||||
|
FROM ${tableName}
|
||||||
|
WHERE UserName = @username
|
||||||
|
`
|
||||||
|
|
||||||
|
const result = await (dbService as SqlServerService).queryWithParams(sqlString, {
|
||||||
|
username: { value: username, type: sql.NVarChar(255) }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.rows.length > 0) {
|
||||||
|
const row = result.rows[0]
|
||||||
|
return {
|
||||||
|
username: (row[cols.ERP_USERNAME] as string) || '',
|
||||||
|
password: (row[cols.ERP_PASSWORD] as string) || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
} else {
|
||||||
|
const sqlString = `
|
||||||
|
SELECT ${cols.ERP_USERNAME}, ${cols.ERP_PASSWORD}
|
||||||
|
FROM ${tableName}
|
||||||
|
WHERE UserName = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
const result = await (dbService as MySqlService).query(sqlString, [username])
|
||||||
|
|
||||||
|
if (result.rows.length > 0) {
|
||||||
|
const row = result.rows[0]
|
||||||
|
return {
|
||||||
|
username: (row[cols.ERP_USERNAME] as string) || '',
|
||||||
|
password: (row[cols.ERP_PASSWORD] as string) || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logError(log, 'Get user ERP credentials failed', error, {
|
||||||
|
operation: 'getUserErpCredentials',
|
||||||
|
username,
|
||||||
|
dbType: this.dbType
|
||||||
|
})
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update ERP credentials for a user (username and password only, URL is from config.yaml)
|
||||||
|
* @param username - The username to update ERP credentials for
|
||||||
|
* @param erpUsername - The ERP username
|
||||||
|
* @param erpPassword - The ERP password
|
||||||
|
* @returns True if successful
|
||||||
|
*/
|
||||||
|
async updateUserErpCredentials(
|
||||||
|
username: string,
|
||||||
|
erpUsername: string,
|
||||||
|
erpPassword: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const dbService = await this.getDatabaseService()
|
||||||
|
const tableName = this.getTableName()
|
||||||
|
const cols = BIP_USERS_CONFIG.COLUMNS
|
||||||
|
|
||||||
|
if (this.dbType === 'sqlserver') {
|
||||||
|
const sqlString = `
|
||||||
|
UPDATE ${tableName}
|
||||||
|
SET ${cols.ERP_USERNAME} = @erpUsername,
|
||||||
|
${cols.ERP_PASSWORD} = @erpPassword
|
||||||
|
WHERE UserName = @username
|
||||||
|
`
|
||||||
|
|
||||||
|
await (dbService as SqlServerService).queryWithParams(sqlString, {
|
||||||
|
username: { value: username, type: sql.NVarChar(255) },
|
||||||
|
erpUsername: { value: erpUsername, type: sql.NVarChar(255) },
|
||||||
|
erpPassword: { value: erpPassword, type: sql.NVarChar(255) }
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
const sqlString = `
|
||||||
|
UPDATE ${tableName}
|
||||||
|
SET ${cols.ERP_USERNAME} = ?,
|
||||||
|
${cols.ERP_PASSWORD} = ?
|
||||||
|
WHERE UserName = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
await (dbService as MySqlService).query(sqlString, [erpUsername, erpPassword, username])
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logError(log, 'Update user ERP credentials failed', error, {
|
||||||
|
operation: 'updateUserErpCredentials',
|
||||||
|
username,
|
||||||
|
dbType: this.dbType
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get ERP configuration for all users (for migration/audit purposes)
|
||||||
|
* @returns List of users with their ERP configurations
|
||||||
|
*/
|
||||||
|
async getAllUsersErpConfig(): Promise<
|
||||||
|
Array<{
|
||||||
|
username: string
|
||||||
|
erpUrl: string
|
||||||
|
erpUsername: string
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
try {
|
||||||
|
const dbService = await this.getDatabaseService()
|
||||||
|
const tableName = this.getTableName()
|
||||||
|
const cols = BIP_USERS_CONFIG.COLUMNS
|
||||||
|
|
||||||
|
const sqlString = `
|
||||||
|
SELECT ${cols.USERNAME}, ${cols.ERP_URL}, ${cols.ERP_USERNAME}
|
||||||
|
FROM ${tableName}
|
||||||
|
ORDER BY ${cols.USERNAME}
|
||||||
|
`
|
||||||
|
|
||||||
|
const result =
|
||||||
|
this.dbType === 'sqlserver'
|
||||||
|
? await (dbService as SqlServerService).query(sqlString)
|
||||||
|
: await (dbService as MySqlService).query(sqlString)
|
||||||
|
|
||||||
|
return result.rows.map((row) => ({
|
||||||
|
username: row[cols.USERNAME] as string,
|
||||||
|
erpUrl: (row[cols.ERP_URL] as string) || '',
|
||||||
|
erpUsername: (row[cols.ERP_USERNAME] as string) || ''
|
||||||
|
}))
|
||||||
|
} catch (error) {
|
||||||
|
logError(log, 'Get all users ERP config failed', error, {
|
||||||
|
operation: 'getAllUsersErpConfig',
|
||||||
|
dbType: this.dbType
|
||||||
|
})
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Disconnect from database
|
* Disconnect from database
|
||||||
*/
|
*/
|
||||||
|
|||||||
289
src/main/services/user/migration/add-erp-params-migration.ts
Normal file
289
src/main/services/user/migration/add-erp-params-migration.ts
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
/**
|
||||||
|
* Migration Script: Add ERP parameters to BIPUsers table
|
||||||
|
*
|
||||||
|
* This script adds ERP_URL, ERP_Username, and ERP_Password columns
|
||||||
|
* to the dbo_BIPUsers table and initializes all existing users.
|
||||||
|
*
|
||||||
|
* Note: ERP credentials are now stored per-user in the database.
|
||||||
|
* This migration is for backward compatibility only.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* npx tsx src/main/services/user/migration/add-erp-params-migration.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as fs from 'fs'
|
||||||
|
import * as path from 'path'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
import { dirname } from 'path'
|
||||||
|
import { ConfigManager } from '../../config/config-manager'
|
||||||
|
import { MySqlService } from '../../database/mysql'
|
||||||
|
import { SqlServerService } from '../../database/sql-server'
|
||||||
|
import yaml from 'js-yaml'
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
|
const __dirname = dirname(__filename)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migration configuration
|
||||||
|
*/
|
||||||
|
const MIGRATION_CONFIG = {
|
||||||
|
sqlFile: path.join(__dirname, 'add-erp-params-to-bipusers.sql'),
|
||||||
|
tableName: {
|
||||||
|
mysql: 'dbo_BIPUsers',
|
||||||
|
sqlserver: '[dbo].[BIPUsers]'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if column exists in MySQL table
|
||||||
|
*/
|
||||||
|
async function checkColumnExistsMySQL(
|
||||||
|
mysqlService: MySqlService,
|
||||||
|
tableName: string,
|
||||||
|
columnName: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const result = await mysqlService.query(
|
||||||
|
`SELECT COUNT(*) as count FROM INFORMATION_SCHEMA.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?`,
|
||||||
|
[tableName, columnName]
|
||||||
|
)
|
||||||
|
return (result.rows[0]?.count as number) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if column exists in SQL Server table
|
||||||
|
*/
|
||||||
|
async function checkColumnExistsSqlServer(
|
||||||
|
sqlServerService: SqlServerService,
|
||||||
|
tableName: string,
|
||||||
|
columnName: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const result = await sqlServerService.query(
|
||||||
|
`SELECT COUNT(*) as count FROM sys.columns
|
||||||
|
WHERE OBJECT_ID = OBJECT_ID(?) AND name = ?`,
|
||||||
|
[tableName, columnName]
|
||||||
|
)
|
||||||
|
return (result.rows[0]?.count as number) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add column to MySQL table
|
||||||
|
*/
|
||||||
|
async function addColumnMySQL(
|
||||||
|
mysqlService: MySqlService,
|
||||||
|
tableName: string,
|
||||||
|
columnName: string,
|
||||||
|
columnType: string
|
||||||
|
): Promise<void> {
|
||||||
|
await mysqlService.query(`ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${columnType}`)
|
||||||
|
console.log(` ✓ Added column ${columnName} (${columnType})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add column to SQL Server table
|
||||||
|
*/
|
||||||
|
async function addColumnSqlServer(
|
||||||
|
sqlServerService: SqlServerService,
|
||||||
|
tableName: string,
|
||||||
|
columnName: string,
|
||||||
|
columnType: string
|
||||||
|
): Promise<void> {
|
||||||
|
await sqlServerService.query(`ALTER TABLE ${tableName} ADD ${columnName} ${columnType}`)
|
||||||
|
console.log(` ✓ Added column ${columnName} (${columnType})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize ERP credentials for all users in MySQL
|
||||||
|
*/
|
||||||
|
async function initializeErpCredentialsMySQL(
|
||||||
|
mysqlService: MySqlService,
|
||||||
|
tableName: string,
|
||||||
|
erpUrl: string,
|
||||||
|
erpUsername: string,
|
||||||
|
erpPassword: string
|
||||||
|
): Promise<void> {
|
||||||
|
const result = await mysqlService.query(`SELECT COUNT(*) as count FROM ${tableName}`)
|
||||||
|
const userCount = result.rows[0]?.count as number
|
||||||
|
|
||||||
|
if (userCount === 0) {
|
||||||
|
console.log('No users found in BIPUsers table')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Initializing ERP credentials for ${userCount} user(s)...`)
|
||||||
|
|
||||||
|
await mysqlService.query(
|
||||||
|
`UPDATE ${tableName} SET ERP_URL = ?, ERP_Username = ?, ERP_Password = ?`,
|
||||||
|
[erpUrl, erpUsername, erpPassword]
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log('✓ ERP credentials initialized for all users')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize ERP credentials for all users in SQL Server
|
||||||
|
*/
|
||||||
|
async function initializeErpCredentialsSqlServer(
|
||||||
|
sqlServerService: SqlServerService,
|
||||||
|
tableName: string,
|
||||||
|
erpUrl: string,
|
||||||
|
erpUsername: string,
|
||||||
|
erpPassword: string
|
||||||
|
): Promise<void> {
|
||||||
|
const result = await sqlServerService.query(`SELECT COUNT(*) as count FROM ${tableName}`)
|
||||||
|
const userCount = result.rows[0]?.count as number
|
||||||
|
|
||||||
|
if (userCount === 0) {
|
||||||
|
console.log('No users found in BIPUsers table')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Initializing ERP credentials for ${userCount} user(s)...`)
|
||||||
|
|
||||||
|
await sqlServerService.query(
|
||||||
|
`UPDATE ${tableName} SET ERP_URL = @p0, ERP_Username = @p1, ERP_Password = @p2`,
|
||||||
|
[erpUrl, erpUsername, erpPassword]
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log('✓ ERP credentials initialized for all users')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run migration for MySQL
|
||||||
|
*/
|
||||||
|
async function runMySQLMigration(configManager: ConfigManager): Promise<void> {
|
||||||
|
console.log('\n📦 Running MySQL Migration...')
|
||||||
|
|
||||||
|
const config = configManager.getConfig()
|
||||||
|
const dbConfig = config.database.mysql
|
||||||
|
|
||||||
|
console.log(`Connecting to MySQL: ${dbConfig.host}:${dbConfig.port}/${dbConfig.database}`)
|
||||||
|
|
||||||
|
const mysqlService = new MySqlService({
|
||||||
|
host: dbConfig.host,
|
||||||
|
port: dbConfig.port,
|
||||||
|
user: dbConfig.username,
|
||||||
|
password: dbConfig.password,
|
||||||
|
database: dbConfig.database
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mysqlService.connect()
|
||||||
|
console.log('✓ Connected to MySQL')
|
||||||
|
|
||||||
|
const tableName = MIGRATION_CONFIG.tableName.mysql
|
||||||
|
|
||||||
|
// Check and add columns
|
||||||
|
for (const [columnName, columnType] of [
|
||||||
|
['ERP_URL', 'VARCHAR(500)'],
|
||||||
|
['ERP_Username', 'VARCHAR(255)'],
|
||||||
|
['ERP_Password', 'VARCHAR(255)']
|
||||||
|
] as const) {
|
||||||
|
const exists = await checkColumnExistsMySQL(mysqlService, tableName, columnName)
|
||||||
|
if (exists) {
|
||||||
|
console.log(` ✓ Column ${columnName} already exists`)
|
||||||
|
} else {
|
||||||
|
await addColumnMySQL(mysqlService, tableName, columnName, columnType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: ERP credentials are now managed per-user via settings UI
|
||||||
|
// This migration no longer initializes them from config
|
||||||
|
console.log('\n✓ MySQL Migration completed')
|
||||||
|
console.log(' Note: ERP credentials should be configured per-user via the Settings UI')
|
||||||
|
|
||||||
|
await mysqlService.disconnect()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('✗ MySQL Migration failed:', error instanceof Error ? error.message : error)
|
||||||
|
if (mysqlService.isConnected()) {
|
||||||
|
await mysqlService.disconnect()
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run migration for SQL Server
|
||||||
|
*/
|
||||||
|
async function runSqlServerMigration(configManager: ConfigManager): Promise<void> {
|
||||||
|
console.log('\n📦 Running SQL Server Migration...')
|
||||||
|
|
||||||
|
const config = configManager.getConfig()
|
||||||
|
const dbConfig = config.database.sqlserver
|
||||||
|
|
||||||
|
console.log(`Connecting to SQL Server: ${dbConfig.server}:${dbConfig.port}/${dbConfig.database}`)
|
||||||
|
|
||||||
|
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()
|
||||||
|
console.log('✓ Connected to SQL Server')
|
||||||
|
|
||||||
|
const tableName = MIGRATION_CONFIG.tableName.sqlserver
|
||||||
|
|
||||||
|
// Check and add columns
|
||||||
|
for (const [columnName, columnType] of [
|
||||||
|
['ERP_URL', 'NVARCHAR(500)'],
|
||||||
|
['ERP_Username', 'NVARCHAR(255)'],
|
||||||
|
['ERP_Password', 'NVARCHAR(255)']
|
||||||
|
] as const) {
|
||||||
|
const exists = await checkColumnExistsSqlServer(sqlServerService, tableName, columnName)
|
||||||
|
if (exists) {
|
||||||
|
console.log(` ✓ Column ${columnName} already exists`)
|
||||||
|
} else {
|
||||||
|
await addColumnSqlServer(sqlServerService, tableName, columnName, columnType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: ERP credentials are now managed per-user via settings UI
|
||||||
|
console.log('\n✓ SQL Server Migration completed')
|
||||||
|
console.log(' Note: ERP credentials should be configured per-user via the Settings UI')
|
||||||
|
|
||||||
|
await sqlServerService.disconnect()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('✗ SQL Server Migration failed:', error instanceof Error ? error.message : error)
|
||||||
|
if (sqlServerService.isConnected()) {
|
||||||
|
await sqlServerService.disconnect()
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main function
|
||||||
|
*/
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
console.log('╔═══════════════════════════════════════════════════════════╗')
|
||||||
|
console.log('║ Migration: Add ERP Parameters to BIPUsers Table ║')
|
||||||
|
console.log('╚═══════════════════════════════════════════════════════════╝')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const configManager = ConfigManager.getInstance()
|
||||||
|
await configManager.initialize()
|
||||||
|
|
||||||
|
const dbType = configManager.getDatabaseType()
|
||||||
|
console.log(`\nCurrent database type: ${dbType}`)
|
||||||
|
|
||||||
|
if (dbType === 'mysql') {
|
||||||
|
await runMySQLMigration(configManager)
|
||||||
|
} else {
|
||||||
|
await runSqlServerMigration(configManager)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n✅ Migration completed successfully!\n')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('\n❌ Migration failed:', error instanceof Error ? error.message : error)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- BIPUsers Table Migration: Add ERP Parameters
|
||||||
|
-- Database: MySQL
|
||||||
|
-- ============================================
|
||||||
|
-- This script adds three new columns to store ERP connection parameters:
|
||||||
|
-- - ERP_URL: The ERP system URL
|
||||||
|
-- - ERP_Username: The ERP username
|
||||||
|
-- - ERP_Password: The ERP password
|
||||||
|
--
|
||||||
|
-- Usage: Run this script in your MySQL client
|
||||||
|
-- Example: mysql -u root -p BLD_DB < add-erp-params-to-bipusers-mysql.sql
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
USE BLD_DB;
|
||||||
|
|
||||||
|
-- Add ERP_URL column if not exists
|
||||||
|
SET @dbname = DATABASE();
|
||||||
|
SET @tablename = 'dbo_BIPUsers';
|
||||||
|
SET @columnname = 'ERP_URL';
|
||||||
|
SET @preparedStatement = (SELECT IF(
|
||||||
|
(
|
||||||
|
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||||
|
WHERE
|
||||||
|
(table_name = @tablename)
|
||||||
|
AND (table_schema = @dbname)
|
||||||
|
AND (column_name = @columnname)
|
||||||
|
) > 0,
|
||||||
|
'SELECT 1',
|
||||||
|
CONCAT('ALTER TABLE ', @tablename, ' ADD COLUMN ', @columnname, ' VARCHAR(500) NULL')
|
||||||
|
));
|
||||||
|
PREPARE alterIfNotExists FROM @preparedStatement;
|
||||||
|
EXECUTE alterIfNotExists;
|
||||||
|
DEALLOCATE PREPARE alterIfNotExists;
|
||||||
|
|
||||||
|
-- Add ERP_Username column if not exists
|
||||||
|
SET @columnname = 'ERP_Username';
|
||||||
|
SET @preparedStatement = (SELECT IF(
|
||||||
|
(
|
||||||
|
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||||
|
WHERE
|
||||||
|
(table_name = @tablename)
|
||||||
|
AND (table_schema = @dbname)
|
||||||
|
AND (column_name = @columnname)
|
||||||
|
) > 0,
|
||||||
|
'SELECT 1',
|
||||||
|
CONCAT('ALTER TABLE ', @tablename, ' ADD COLUMN ', @columnname, ' VARCHAR(255) NULL')
|
||||||
|
));
|
||||||
|
PREPARE alterIfNotExists FROM @preparedStatement;
|
||||||
|
EXECUTE alterIfNotExists;
|
||||||
|
DEALLOCATE PREPARE alterIfNotExists;
|
||||||
|
|
||||||
|
-- Add ERP_Password column if not exists
|
||||||
|
SET @columnname = 'ERP_Password';
|
||||||
|
SET @preparedStatement = (SELECT IF(
|
||||||
|
(
|
||||||
|
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||||
|
WHERE
|
||||||
|
(table_name = @tablename)
|
||||||
|
AND (table_schema = @dbname)
|
||||||
|
AND (column_name = @columnname)
|
||||||
|
) > 0,
|
||||||
|
'SELECT 1',
|
||||||
|
CONCAT('ALTER TABLE ', @tablename, ' ADD COLUMN ', @columnname, ' VARCHAR(255) NULL')
|
||||||
|
));
|
||||||
|
PREPARE alterIfNotExists FROM @preparedStatement;
|
||||||
|
EXECUTE alterIfNotExists;
|
||||||
|
DEALLOCATE PREPARE alterIfNotExists;
|
||||||
|
|
||||||
|
-- Verify columns were added
|
||||||
|
SELECT
|
||||||
|
COLUMN_NAME,
|
||||||
|
DATA_TYPE,
|
||||||
|
CHARACTER_MAXIMUM_LENGTH,
|
||||||
|
IS_NULLABLE
|
||||||
|
FROM INFORMATION_SCHEMA.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'dbo_BIPUsers'
|
||||||
|
AND COLUMN_NAME IN ('ERP_URL', 'ERP_Username', 'ERP_Password');
|
||||||
|
|
||||||
|
-- Optional: Update all existing users with the same ERP credentials
|
||||||
|
-- Replace the values below with your actual ERP credentials
|
||||||
|
-- Example:
|
||||||
|
-- UPDATE dbo_BIPUsers
|
||||||
|
-- SET ERP_URL = 'https://68.11.34.30:8082/',
|
||||||
|
-- ERP_Username = 'your_username',
|
||||||
|
-- ERP_Password = 'your_password'
|
||||||
|
-- WHERE ERP_URL IS NULL;
|
||||||
|
|
||||||
|
SELECT 'Migration completed successfully!' AS status;
|
||||||
114
src/main/services/user/migration/add-erp-params-to-bipusers.sql
Normal file
114
src/main/services/user/migration/add-erp-params-to-bipusers.sql
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* Database Migration Script
|
||||||
|
* Add ERP configuration fields to dbo_BIPUsers table
|
||||||
|
*
|
||||||
|
* This script adds three new columns to store ERP connection parameters:
|
||||||
|
* - ERP_URL: The ERP system URL
|
||||||
|
* - ERP_USERNAME: The ERP username
|
||||||
|
* - ERP_PASSWORD: The ERP password (encrypted in production)
|
||||||
|
*
|
||||||
|
* IMPORTANT:
|
||||||
|
* - For SQL Server: Run this script on the SQL Server database
|
||||||
|
* - For MySQL: Run this script on the MySQL database (syntax is auto-detected)
|
||||||
|
* - All existing users will have the same ERP credentials (to be configured individually later)
|
||||||
|
*/
|
||||||
|
|
||||||
|
-- ===========================================
|
||||||
|
-- SQL Server Version
|
||||||
|
-- ===========================================
|
||||||
|
-- Uncomment and run this section for SQL Server
|
||||||
|
/*
|
||||||
|
IF NOT EXISTS (SELECT * FROM sys.columns
|
||||||
|
WHERE object_id = OBJECT_ID(N'[dbo].[BIPUsers]')
|
||||||
|
AND name = 'ERP_URL')
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE [dbo].[BIPUsers]
|
||||||
|
ADD ERP_URL NVARCHAR(500) NULL;
|
||||||
|
|
||||||
|
ALTER TABLE [dbo].[BIPUsers]
|
||||||
|
ADD ERP_Username NVARCHAR(255) NULL;
|
||||||
|
|
||||||
|
ALTER TABLE [dbo].[BIPUsers]
|
||||||
|
ADD ERP_Password NVARCHAR(255) NULL;
|
||||||
|
|
||||||
|
PRINT 'ERP columns added successfully to [dbo].[BIPUsers]';
|
||||||
|
END
|
||||||
|
ELSE
|
||||||
|
BEGIN
|
||||||
|
PRINT 'ERP columns already exist in [dbo].[BIPUsers]';
|
||||||
|
END
|
||||||
|
|
||||||
|
-- Optional: Update all existing users with the same ERP credentials
|
||||||
|
-- Replace the values below with your actual ERP credentials
|
||||||
|
-- UPDATE [dbo].[BIPUsers]
|
||||||
|
-- SET ERP_URL = 'https://your-erp-system.com',
|
||||||
|
-- ERP_Username = 'your_username',
|
||||||
|
-- ERP_Password = 'your_password'
|
||||||
|
-- WHERE ERP_URL IS NULL;
|
||||||
|
*/
|
||||||
|
|
||||||
|
-- ===========================================
|
||||||
|
-- MySQL Version
|
||||||
|
-- ===========================================
|
||||||
|
-- Run this section for MySQL
|
||||||
|
|
||||||
|
-- Add ERP_URL column if not exists
|
||||||
|
SET @dbname = DATABASE();
|
||||||
|
SET @tablename = 'dbo_BIPUsers';
|
||||||
|
SET @columnname = 'ERP_URL';
|
||||||
|
SET @preparedStatement = (SELECT IF(
|
||||||
|
(
|
||||||
|
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||||
|
WHERE
|
||||||
|
(table_name = @tablename)
|
||||||
|
AND (table_schema = @dbname)
|
||||||
|
AND (column_name = @columnname)
|
||||||
|
) > 0,
|
||||||
|
'SELECT 1',
|
||||||
|
CONCAT('ALTER TABLE ', @tablename, ' ADD COLUMN ', @columnname, ' VARCHAR(500) NULL')
|
||||||
|
));
|
||||||
|
PREPARE alterIfNotExists FROM @preparedStatement;
|
||||||
|
EXECUTE alterIfNotExists;
|
||||||
|
DEALLOCATE PREPARE alterIfNotExists;
|
||||||
|
|
||||||
|
-- Add ERP_Username column if not exists
|
||||||
|
SET @columnname = 'ERP_Username';
|
||||||
|
SET @preparedStatement = (SELECT IF(
|
||||||
|
(
|
||||||
|
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||||
|
WHERE
|
||||||
|
(table_name = @tablename)
|
||||||
|
AND (table_schema = @dbname)
|
||||||
|
AND (column_name = @columnname)
|
||||||
|
) > 0,
|
||||||
|
'SELECT 1',
|
||||||
|
CONCAT('ALTER TABLE ', @tablename, ' ADD COLUMN ', @columnname, ' VARCHAR(255) NULL')
|
||||||
|
));
|
||||||
|
PREPARE alterIfNotExists FROM @preparedStatement;
|
||||||
|
EXECUTE alterIfNotExists;
|
||||||
|
DEALLOCATE PREPARE alterIfNotExists;
|
||||||
|
|
||||||
|
-- Add ERP_Password column if not exists
|
||||||
|
SET @columnname = 'ERP_Password';
|
||||||
|
SET @preparedStatement = (SELECT IF(
|
||||||
|
(
|
||||||
|
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||||
|
WHERE
|
||||||
|
(table_name = @tablename)
|
||||||
|
AND (table_schema = @dbname)
|
||||||
|
AND (column_name = @columnname)
|
||||||
|
) > 0,
|
||||||
|
'SELECT 1',
|
||||||
|
CONCAT('ALTER TABLE ', @tablename, ' ADD COLUMN ', @columnname, ' VARCHAR(255) NULL')
|
||||||
|
));
|
||||||
|
PREPARE alterIfNotExists FROM @preparedStatement;
|
||||||
|
EXECUTE alterIfNotExists;
|
||||||
|
DEALLOCATE PREPARE alterIfNotExists;
|
||||||
|
|
||||||
|
-- Optional: Update all existing users with the same ERP credentials
|
||||||
|
-- Replace the values below with your actual ERP credentials
|
||||||
|
-- UPDATE dbo_BIPUsers
|
||||||
|
-- SET ERP_URL = 'https://your-erp-system.com',
|
||||||
|
-- ERP_Username = 'your_username',
|
||||||
|
-- ERP_Password = 'your_password'
|
||||||
|
-- WHERE ERP_URL IS NULL;
|
||||||
98
src/main/services/user/migration/manual-migration.sql
Normal file
98
src/main/services/user/migration/manual-migration.sql
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- BIPUsers 表迁移:添加 ERP 参数字段
|
||||||
|
-- 数据库:MySQL
|
||||||
|
-- 目标数据库:BLD_DB
|
||||||
|
-- ============================================
|
||||||
|
-- 使用说明:
|
||||||
|
-- 1. 在 MySQL Workbench / Navicat / DBeaver 中打开此文件
|
||||||
|
-- 2. 连接到数据库 192.168.31.83:3306/BLD_DB
|
||||||
|
-- 3. 执行全部 SQL 语句
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- 切换到目标数据库
|
||||||
|
USE BLD_DB;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 步骤 1: 添加新字段
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- 添加 ERP_URL 字段(如果不存在)
|
||||||
|
-- 注意:如果 MySQL 版本不支持 ADD COLUMN IF NOT EXISTS,请移除 IF NOT EXISTS
|
||||||
|
ALTER TABLE dbo_BIPUsers
|
||||||
|
ADD COLUMN ERP_URL VARCHAR(500) NULL COMMENT 'ERP 系统 URL';
|
||||||
|
|
||||||
|
-- 添加 ERP_Username 字段
|
||||||
|
ALTER TABLE dbo_BIPUsers
|
||||||
|
ADD COLUMN ERP_Username VARCHAR(255) NULL COMMENT 'ERP 用户名';
|
||||||
|
|
||||||
|
-- 添加 ERP_Password 字段
|
||||||
|
ALTER TABLE dbo_BIPUsers
|
||||||
|
ADD COLUMN ERP_Password VARCHAR(255) NULL COMMENT 'ERP 密码';
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 步骤 2: 验证字段已添加
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- 显示表结构,确认新字段已添加
|
||||||
|
SELECT '字段添加验证' AS step;
|
||||||
|
DESCRIBE dbo_BIPUsers;
|
||||||
|
|
||||||
|
-- 或者使用以下查询确认新字段
|
||||||
|
SELECT
|
||||||
|
COLUMN_NAME AS '字段名',
|
||||||
|
DATA_TYPE AS '数据类型',
|
||||||
|
CHARACTER_MAXIMUM_LENGTH AS '最大长度',
|
||||||
|
IS_NULLABLE AS '允许 NULL',
|
||||||
|
COLUMN_COMMENT AS '注释'
|
||||||
|
FROM INFORMATION_SCHEMA.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = 'BLD_DB'
|
||||||
|
AND TABLE_NAME = 'dbo_BIPUsers'
|
||||||
|
AND COLUMN_NAME IN ('ERP_URL', 'ERP_Username', 'ERP_Password')
|
||||||
|
ORDER BY COLUMN_NAME;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 步骤 3: 初始化 ERP 配置
|
||||||
|
-- 注意:请根据实际情况修改下面的配置值!
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
SELECT '=== 请修改下面的 ERP 配置值 ===' AS notice;
|
||||||
|
SELECT '当前数据库中的用户:' AS notice;
|
||||||
|
SELECT UserName, UserType, ComputerName FROM dbo_BIPUsers ORDER BY UserName;
|
||||||
|
|
||||||
|
-- 更新所有用户的 ERP 配置
|
||||||
|
-- ⚠️ 请修改下面的配置值为你实际的 ERP 凭证!
|
||||||
|
UPDATE dbo_BIPUsers
|
||||||
|
SET
|
||||||
|
ERP_URL = 'https://68.11.34.30:8082/', -- 修改为你的 ERP 系统 URL
|
||||||
|
ERP_Username = 'your_erp_username', -- 修改为你的 ERP 用户名
|
||||||
|
ERP_Password = 'your_erp_password' -- 修改为你的 ERP 密码
|
||||||
|
WHERE ERP_URL IS NULL OR ERP_URL = '';
|
||||||
|
|
||||||
|
-- 显示更新后的结果
|
||||||
|
SELECT
|
||||||
|
'更新后的 ERP 配置' AS notice,
|
||||||
|
UserName,
|
||||||
|
ERP_URL,
|
||||||
|
ERP_Username
|
||||||
|
FROM dbo_BIPUsers
|
||||||
|
ORDER BY UserName;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 步骤 4: 完成确认
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
SELECT '================================' AS '';
|
||||||
|
SELECT '迁移完成!' AS message;
|
||||||
|
SELECT '================================' AS '';
|
||||||
|
SELECT '请确认:' AS notice;
|
||||||
|
SELECT '1. 所有用户都有 ERP_URL 配置' AS check1;
|
||||||
|
SELECT '2. ERP_URL 格式正确' AS check2;
|
||||||
|
SELECT '3. ERP 用户名和密码正确' AS check3;
|
||||||
|
SELECT '================================' AS '';
|
||||||
|
|
||||||
|
-- 统计信息
|
||||||
|
SELECT
|
||||||
|
COUNT(*) AS total_users,
|
||||||
|
COUNT(ERP_URL) AS users_with_erp_url,
|
||||||
|
COUNT(ERP_Username) AS users_with_erp_username
|
||||||
|
FROM dbo_BIPUsers;
|
||||||
203
src/main/services/user/migration/run-migration.ts
Normal file
203
src/main/services/user/migration/run-migration.ts
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
/**
|
||||||
|
* Simple Migration Script: Add ERP parameters to BIPUsers table
|
||||||
|
*
|
||||||
|
* This script adds ERP_URL, ERP_Username, and ERP_Password columns
|
||||||
|
* to the dbo_BIPUsers table.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* npx tsx src/main/services/user/migration/run-migration.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as mysql from 'mysql2/promise'
|
||||||
|
import * as fs from 'fs'
|
||||||
|
import * as path from 'path'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
import { dirname } from 'path'
|
||||||
|
import yaml from 'js-yaml'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MySQL configuration schema
|
||||||
|
*/
|
||||||
|
const mysqlConfigSchema = z.object({
|
||||||
|
host: z.string(),
|
||||||
|
port: z.number(),
|
||||||
|
database: z.string(),
|
||||||
|
username: z.string(),
|
||||||
|
password: z.string()
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load config.yaml file
|
||||||
|
*/
|
||||||
|
function loadConfig(filePath: string): {
|
||||||
|
host: string
|
||||||
|
port: number
|
||||||
|
database: string
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
} {
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
throw new Error(`Config file not found: ${filePath}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = fs.readFileSync(filePath, 'utf-8')
|
||||||
|
const parsed = yaml.load(content) as Record<string, unknown>
|
||||||
|
|
||||||
|
// Safely extract database.mysql config
|
||||||
|
const database = parsed?.database as Record<string, unknown> | undefined
|
||||||
|
const mysql = database?.mysql as Record<string, unknown> | undefined
|
||||||
|
|
||||||
|
const result = mysqlConfigSchema.parse(mysql)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if column exists in MySQL table
|
||||||
|
*/
|
||||||
|
async function checkColumnExists(
|
||||||
|
connection: mysql.Connection,
|
||||||
|
tableName: string,
|
||||||
|
columnName: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const sql = `
|
||||||
|
SELECT COUNT(*) as count
|
||||||
|
FROM INFORMATION_SCHEMA.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = ?
|
||||||
|
AND COLUMN_NAME = ?
|
||||||
|
`
|
||||||
|
const [rows] = await connection.query(sql, [tableName, columnName])
|
||||||
|
const result = rows as any[]
|
||||||
|
return result.length > 0 && result[0].count > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add column to MySQL table
|
||||||
|
*/
|
||||||
|
async function addColumn(
|
||||||
|
connection: mysql.Connection,
|
||||||
|
tableName: string,
|
||||||
|
columnName: string,
|
||||||
|
columnType: string
|
||||||
|
): Promise<void> {
|
||||||
|
const sql = `ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${columnType} NULL`
|
||||||
|
await connection.query(sql)
|
||||||
|
console.log(` ✓ Added column ${columnName} to ${tableName}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main migration function
|
||||||
|
*/
|
||||||
|
async function runMigration(): Promise<void> {
|
||||||
|
console.log('==============================================')
|
||||||
|
console.log('BIPUsers Table Migration: Add ERP Parameters')
|
||||||
|
console.log('==============================================\n')
|
||||||
|
|
||||||
|
// Load config.yaml from project root or user data directory
|
||||||
|
const isDev = !process.execPath.includes('Resources\\app')
|
||||||
|
const configPath = isDev
|
||||||
|
? path.resolve(process.cwd(), 'config.yaml')
|
||||||
|
: path.join(process.env.APPDATA || '', 'erpauto', 'config.yaml')
|
||||||
|
|
||||||
|
console.log(`Loading config from: ${configPath}`)
|
||||||
|
|
||||||
|
let dbConfig: { host: string; port: number; database: string; username: string; password: string }
|
||||||
|
|
||||||
|
try {
|
||||||
|
dbConfig = loadConfig(configPath)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load config.yaml:', error instanceof Error ? error.message : error)
|
||||||
|
console.error('Please ensure config.yaml exists and contains valid MySQL configuration.')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const dbHost = dbConfig.host || 'localhost'
|
||||||
|
const dbPort = dbConfig.port || 3306
|
||||||
|
const dbUser = dbConfig.username || 'root'
|
||||||
|
const dbPassword = dbConfig.password || ''
|
||||||
|
const dbName = dbConfig.database || ''
|
||||||
|
|
||||||
|
console.log(`Database: ${dbHost}:${dbPort}/${dbName}`)
|
||||||
|
console.log(`Username: ${dbUser}`)
|
||||||
|
console.log('')
|
||||||
|
|
||||||
|
let connection: mysql.Connection | null = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Connect to MySQL
|
||||||
|
console.log('Connecting to MySQL...')
|
||||||
|
connection = await mysql.createConnection({
|
||||||
|
host: dbHost,
|
||||||
|
port: dbPort,
|
||||||
|
user: dbUser,
|
||||||
|
password: dbPassword,
|
||||||
|
database: dbName
|
||||||
|
})
|
||||||
|
console.log('✓ Connected to MySQL\n')
|
||||||
|
|
||||||
|
const tableName = 'dbo_BIPUsers'
|
||||||
|
|
||||||
|
// Check and add columns
|
||||||
|
console.log('Checking columns...')
|
||||||
|
for (const [columnName, columnType] of [
|
||||||
|
['ERP_URL', 'VARCHAR(500)'],
|
||||||
|
['ERP_Username', 'VARCHAR(255)'],
|
||||||
|
['ERP_Password', 'VARCHAR(255)']
|
||||||
|
] as const) {
|
||||||
|
const exists = await checkColumnExists(connection, tableName, columnName)
|
||||||
|
if (exists) {
|
||||||
|
console.log(` ✓ Column ${columnName} already exists`)
|
||||||
|
} else {
|
||||||
|
await addColumn(connection, tableName, columnName, columnType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n==============================================')
|
||||||
|
console.log('Migration Summary:')
|
||||||
|
console.log('==============================================')
|
||||||
|
console.log('Database Type: MySQL')
|
||||||
|
console.log('Database: ' + dbName)
|
||||||
|
console.log('Columns Added/Verified:')
|
||||||
|
console.log(' - ERP_URL (VARCHAR 500)')
|
||||||
|
console.log(' - ERP_Username (VARCHAR 255)')
|
||||||
|
console.log(' - ERP_Password (VARCHAR 255)')
|
||||||
|
console.log('==============================================')
|
||||||
|
console.log('\n✅ Migration completed successfully!\n')
|
||||||
|
console.log('Next steps:')
|
||||||
|
console.log('1. Update ERP credentials for users in dbo_BIPUsers table')
|
||||||
|
console.log('2. Example SQL:')
|
||||||
|
console.log(` UPDATE ${tableName}`)
|
||||||
|
console.log(` SET ERP_URL = 'https://your-erp.com',`)
|
||||||
|
console.log(` ERP_Username = 'your_username',`)
|
||||||
|
console.log(` ERP_Password = 'your_password'`)
|
||||||
|
console.log(` WHERE ERP_URL IS NULL;\n`)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('\n❌ Migration failed with error:')
|
||||||
|
console.error(error)
|
||||||
|
console.error('\nTroubleshooting:')
|
||||||
|
console.error('1. Check if MySQL server is running')
|
||||||
|
console.error('2. Verify database credentials in config.yaml file')
|
||||||
|
console.error('3. Ensure database "' + dbName + '" exists')
|
||||||
|
console.error('4. Check network connectivity to ' + dbHost + ':' + dbPort)
|
||||||
|
process.exit(1)
|
||||||
|
} finally {
|
||||||
|
// Disconnect
|
||||||
|
if (connection) {
|
||||||
|
try {
|
||||||
|
await connection.end()
|
||||||
|
console.log('Disconnected from MySQL')
|
||||||
|
} catch {
|
||||||
|
// Ignore disconnect errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run migration
|
||||||
|
runMigration().catch((error) => {
|
||||||
|
console.error('Unexpected error:', error)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
197
src/main/services/user/user-erp-config-service.ts
Normal file
197
src/main/services/user/user-erp-config-service.ts
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
/**
|
||||||
|
* User ERP Configuration Service
|
||||||
|
*
|
||||||
|
* Manages ERP credentials (username, password) stored in the BIPUsers table.
|
||||||
|
* Each user can have their own ERP credentials.
|
||||||
|
* ERP URL is fixed and stored in config.yaml.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Get current user's ERP credentials
|
||||||
|
* - Update current user's ERP credentials
|
||||||
|
* - Get ERP credentials for any user (admin only)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { BIPUsersDAO } from './bip-users-dao'
|
||||||
|
import { SessionManager } from './session-manager'
|
||||||
|
import { createLogger } from '../logger'
|
||||||
|
|
||||||
|
const log = createLogger('UserErpConfigService')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ERP Credentials object (username and password only)
|
||||||
|
*/
|
||||||
|
export interface ErpCredentials {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User ERP Configuration Service Class
|
||||||
|
*/
|
||||||
|
export class UserErpConfigService {
|
||||||
|
private static instance: UserErpConfigService | null = null
|
||||||
|
private dao: BIPUsersDAO
|
||||||
|
|
||||||
|
private constructor() {
|
||||||
|
this.dao = new BIPUsersDAO()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the singleton instance
|
||||||
|
*/
|
||||||
|
public static getInstance(): UserErpConfigService {
|
||||||
|
if (UserErpConfigService.instance === null) {
|
||||||
|
UserErpConfigService.instance = new UserErpConfigService()
|
||||||
|
}
|
||||||
|
return UserErpConfigService.instance
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get ERP configuration for the current authenticated user
|
||||||
|
* @returns ERP credentials or null if not found
|
||||||
|
*/
|
||||||
|
async getCurrentUserErpConfig(): Promise<ErpCredentials | null> {
|
||||||
|
try {
|
||||||
|
const sessionManager = SessionManager.getInstance()
|
||||||
|
const currentUser = sessionManager.getUserInfo()
|
||||||
|
|
||||||
|
if (!currentUser) {
|
||||||
|
log.warn('No authenticated user found')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info('Fetching ERP credentials for user', { username: currentUser.username })
|
||||||
|
const config = await this.dao.getUserErpCredentials(currentUser.username)
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
log.warn('No ERP credentials found for user', { username: currentUser.username })
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info('ERP credentials retrieved successfully', {
|
||||||
|
username: currentUser.username,
|
||||||
|
hasUsername: !!config.username,
|
||||||
|
hasPassword: !!config.password
|
||||||
|
})
|
||||||
|
|
||||||
|
return config
|
||||||
|
} catch (error) {
|
||||||
|
log.error('Error getting current user ERP credentials', { error })
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get ERP credentials for a specific user (admin only)
|
||||||
|
* @param username - The username to get ERP credentials for
|
||||||
|
* @returns ERP credentials or null if not found
|
||||||
|
*/
|
||||||
|
async getUserErpConfig(username: string): Promise<ErpCredentials | null> {
|
||||||
|
try {
|
||||||
|
log.info('Fetching ERP credentials for user', { username })
|
||||||
|
const config = await this.dao.getUserErpCredentials(username)
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
log.warn('No ERP credentials found for user', { username })
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return config
|
||||||
|
} catch (error) {
|
||||||
|
log.error('Error getting user ERP credentials', { error })
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update ERP credentials for the current authenticated user
|
||||||
|
* @param credentials - ERP credentials to save
|
||||||
|
* @returns True if successful
|
||||||
|
*/
|
||||||
|
async updateCurrentUserErpConfig(credentials: ErpCredentials): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const sessionManager = SessionManager.getInstance()
|
||||||
|
const currentUser = sessionManager.getUserInfo()
|
||||||
|
|
||||||
|
if (!currentUser) {
|
||||||
|
log.warn('No authenticated user found')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info('Updating ERP credentials for user', { username: currentUser.username })
|
||||||
|
const success = await this.dao.updateUserErpCredentials(
|
||||||
|
currentUser.username,
|
||||||
|
credentials.username,
|
||||||
|
credentials.password
|
||||||
|
)
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
log.info('ERP credentials updated successfully', { username: currentUser.username })
|
||||||
|
} else {
|
||||||
|
log.error('Failed to update ERP credentials', { username: currentUser.username })
|
||||||
|
}
|
||||||
|
|
||||||
|
return success
|
||||||
|
} catch (error) {
|
||||||
|
log.error('Error updating current user ERP credentials', { error })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update ERP credentials for a specific user (admin only)
|
||||||
|
* @param username - The username to update ERP credentials for
|
||||||
|
* @param credentials - ERP credentials to save
|
||||||
|
* @returns True if successful
|
||||||
|
*/
|
||||||
|
async updateUserErpConfig(username: string, credentials: ErpCredentials): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
log.info('Updating ERP credentials for user', { username })
|
||||||
|
const success = await this.dao.updateUserErpCredentials(
|
||||||
|
username,
|
||||||
|
credentials.username,
|
||||||
|
credentials.password
|
||||||
|
)
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
log.info('ERP credentials updated successfully', { username })
|
||||||
|
} else {
|
||||||
|
log.error('Failed to update ERP credentials', { username })
|
||||||
|
}
|
||||||
|
|
||||||
|
return success
|
||||||
|
} catch (error) {
|
||||||
|
log.error('Error updating user ERP credentials', { error })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get ERP configuration for all users (admin only, for migration/audit)
|
||||||
|
* @returns List of users with their ERP configurations
|
||||||
|
*/
|
||||||
|
async getAllUsersErpConfig(): Promise<
|
||||||
|
Array<{
|
||||||
|
username: string
|
||||||
|
erpUrl: string
|
||||||
|
erpUsername: string
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
try {
|
||||||
|
log.info('Fetching ERP config for all users')
|
||||||
|
const configs = await this.dao.getAllUsersErpConfig()
|
||||||
|
log.info('Retrieved ERP configs for all users', { count: configs.length })
|
||||||
|
return configs
|
||||||
|
} catch (error) {
|
||||||
|
log.error('Error getting all users ERP config', { error })
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disconnect from database
|
||||||
|
*/
|
||||||
|
async disconnect(): Promise<void> {
|
||||||
|
await this.dao.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
114
src/main/tools/config-path-debug.ts
Normal file
114
src/main/tools/config-path-debug.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* Configuration Path Debug Tool
|
||||||
|
*
|
||||||
|
* Run this to see where config files will be stored in different modes
|
||||||
|
* Usage: npx tsx src/main/tools/config-path-debug.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as path from 'path'
|
||||||
|
|
||||||
|
// Simulate different environments
|
||||||
|
const scenarios = [
|
||||||
|
{
|
||||||
|
name: 'Development Mode (开发环境)',
|
||||||
|
env: {
|
||||||
|
NODE_ENV: 'development',
|
||||||
|
APP_PACKAGED: 'false'
|
||||||
|
},
|
||||||
|
appData: 'C:\\Users\\test\\AppData\\Roaming\\erpauto',
|
||||||
|
projectRoot: 'D:\\Projects\\ERPAuto'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Production - Portable (便携版)',
|
||||||
|
env: {
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
APP_PACKAGED: 'true'
|
||||||
|
},
|
||||||
|
appData: 'C:\\Users\\test\\AppData\\Roaming\\erpauto',
|
||||||
|
projectRoot: 'D:\\Projects\\ERPAuto',
|
||||||
|
exeDir: 'D:\\PortableApps\\ERPAuto'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Production - Installed (安装版)',
|
||||||
|
env: {
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
APP_PACKAGED: 'true'
|
||||||
|
},
|
||||||
|
appData: 'C:\\Users\\test\\AppData\\Roaming\\erpauto',
|
||||||
|
projectRoot: 'D:\\Projects\\ERPAuto'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
console.log('╔════════════════════════════════════════════════════════════════╗')
|
||||||
|
console.log('║ ERPAuto Configuration Path Debug Tool ║')
|
||||||
|
console.log('╚════════════════════════════════════════════════════════════════╝\n')
|
||||||
|
|
||||||
|
for (const scenario of scenarios) {
|
||||||
|
console.log(`📋 ${scenario.name}`)
|
||||||
|
console.log('─'.repeat(60))
|
||||||
|
|
||||||
|
const isDev = scenario.env.NODE_ENV === 'development' || scenario.env.APP_PACKAGED === 'false'
|
||||||
|
|
||||||
|
let configPath: string
|
||||||
|
let backupPath: string
|
||||||
|
|
||||||
|
if (isDev) {
|
||||||
|
// 开发环境:项目根目录
|
||||||
|
configPath = path.join(scenario.projectRoot, 'config.yaml')
|
||||||
|
backupPath = path.join(scenario.projectRoot, 'config.yaml.backup')
|
||||||
|
} else {
|
||||||
|
// 生产环境(便携版和安装版):用户数据目录
|
||||||
|
configPath = path.join(scenario.appData, 'config.yaml')
|
||||||
|
backupPath = path.join(scenario.appData, 'config.yaml.backup')
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` NODE_ENV: ${scenario.env.NODE_ENV}`)
|
||||||
|
console.log(` APP_PACKAGED: ${scenario.env.APP_PACKAGED}`)
|
||||||
|
console.log(` Is Development: ${isDev ? '✓ Yes' : '✗ No'}`)
|
||||||
|
if ('exeDir' in scenario) {
|
||||||
|
console.log(` EXE Directory: ${scenario.exeDir}`)
|
||||||
|
}
|
||||||
|
console.log(` → Config Path: ${configPath}`)
|
||||||
|
console.log(` → Backup Path: ${backupPath}`)
|
||||||
|
console.log('')
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('╔════════════════════════════════════════════════════════════════╗')
|
||||||
|
console.log('║ Configuration Strategy (配置策略): ║')
|
||||||
|
console.log('╚════════════════════════════════════════════════════════════════╝')
|
||||||
|
console.log(`
|
||||||
|
┌─────────────┬──────────────────────────────────────────────────────────┐
|
||||||
|
│ 环境 │ 配置文件位置 │
|
||||||
|
├─────────────┼──────────────────────────────────────────────────────────┤
|
||||||
|
│ 开发环境 │ 项目根目录\\config.yaml │
|
||||||
|
│ │ 方便编辑和调试,配置随代码版本管理 │
|
||||||
|
├─────────────┼──────────────────────────────────────────────────────────┤
|
||||||
|
│ 生产环境 │ %APPDATA%\\erpauto\\config.yaml │
|
||||||
|
│ (便携版/ │ 符合 Windows 规范,应用升级时配置保留,安全 │
|
||||||
|
│ 安装版) │ │
|
||||||
|
└─────────────┴──────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
💡 优势:
|
||||||
|
✓ 开发时配置在项目根目录,方便版本控制和团队协作
|
||||||
|
✓ 生产环境配置在用户数据目录,应用升级不会丢失配置
|
||||||
|
✓ 配置不暴露在应用目录,更安全
|
||||||
|
✓ 多用户环境下,每个用户有独立的配置
|
||||||
|
`)
|
||||||
|
|
||||||
|
console.log('╔════════════════════════════════════════════════════════════════╗')
|
||||||
|
console.log('║ Recommended Directory Structure: ║')
|
||||||
|
console.log('╚════════════════════════════════════════════════════════════════╝')
|
||||||
|
console.log(`
|
||||||
|
【开发环境】
|
||||||
|
D:\\Projects\\ERPAuto\\
|
||||||
|
├── src\\
|
||||||
|
├── package.json
|
||||||
|
├── config.yaml # 开发配置(可加入 .gitignore)
|
||||||
|
├── config.yaml.backup # 自动备份
|
||||||
|
└── config.template.yaml # 配置模板(提交到版本控制)
|
||||||
|
|
||||||
|
【生产环境 - 便携版/安装版】
|
||||||
|
C:\\Users\\<user>\\AppData\\Roaming\\erpauto\\
|
||||||
|
├── config.yaml # 用户配置
|
||||||
|
└── config.yaml.backup # 自动备份
|
||||||
|
`)
|
||||||
232
src/main/tools/erp-login-debug.ts
Normal file
232
src/main/tools/erp-login-debug.ts
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
/**
|
||||||
|
* ERP 登录调试脚本
|
||||||
|
*
|
||||||
|
* 用途:人工调试 ERP 登录流程,定位主界面特征元素
|
||||||
|
* 使用方式:
|
||||||
|
* 1. 修改下方的 ERP_CONFIG 配置(URL、用户名、密码)
|
||||||
|
* 2. 运行:npx ts-node src/main/tools/erp-login-debug.ts
|
||||||
|
* 3. 登录后会自动暂停,打开 Playwright Inspector 进行元素定位
|
||||||
|
* 4. 在 Inspector 中点击主界面元素,获取选择器
|
||||||
|
* 5. 将找到的元素选择器添加到 locators.ts
|
||||||
|
*
|
||||||
|
* 调试模式说明:
|
||||||
|
* - headless: false - 显示浏览器窗口
|
||||||
|
* - slowMo: 100 - 放慢操作速度便于观察
|
||||||
|
* - debugger 语句会暂停执行,打开开发者工具
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { chromium } from 'playwright'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
// ==================== 配置区域 ====================
|
||||||
|
const ERP_CONFIG = {
|
||||||
|
url: 'https://68.11.34.30:8082/', // ← 修改为你的 ERP 地址
|
||||||
|
username: 'BLDpengqiangqiang', // ← 修改为你的用户名
|
||||||
|
password: 'Cqbld123456.' // ← 修改为你的密码
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调试配置
|
||||||
|
const DEBUG_CONFIG = {
|
||||||
|
headless: false, // 必须为 false,需要看到浏览器
|
||||||
|
slowMo: 100, // 操作间隔(毫秒)
|
||||||
|
timeout: 60000 // 超时时间(毫秒)
|
||||||
|
}
|
||||||
|
// ================================================
|
||||||
|
|
||||||
|
async function debugErpLogin(): Promise<void> {
|
||||||
|
console.log('='.repeat(60))
|
||||||
|
console.log('ERP 登录调试工具')
|
||||||
|
console.log('='.repeat(60))
|
||||||
|
console.log('目标 URL:', ERP_CONFIG.url)
|
||||||
|
console.log('用户名:', ERP_CONFIG.username)
|
||||||
|
console.log('密码:', '***'.repeat(ERP_CONFIG.password.length))
|
||||||
|
console.log('='.repeat(60))
|
||||||
|
console.log()
|
||||||
|
console.log('操作步骤:')
|
||||||
|
console.log('1. 浏览器将自动打开并尝试登录')
|
||||||
|
console.log('2. 如果登录失败,请检查配置或手动重试')
|
||||||
|
console.log('3. 登录成功后,会自动暂停并打开开发者工具')
|
||||||
|
console.log('4. 使用元素选择器定位主界面特征元素')
|
||||||
|
console.log('5. 记录元素选择器,按 Ctrl+C 退出脚本')
|
||||||
|
console.log()
|
||||||
|
console.log('按 Enter 键开始...')
|
||||||
|
|
||||||
|
// 等待用户确认
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
process.stdin.resume()
|
||||||
|
process.stdin.once('data', () => {
|
||||||
|
process.stdin.pause()
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
let browser: any = null
|
||||||
|
let context: any = null
|
||||||
|
let page: any = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('\n正在启动浏览器...')
|
||||||
|
|
||||||
|
// 启动浏览器(带调试配置)
|
||||||
|
browser = await chromium.launch({
|
||||||
|
headless: DEBUG_CONFIG.headless,
|
||||||
|
slowMo: DEBUG_CONFIG.slowMo,
|
||||||
|
args: ['--ignore-certificate-errors', '--ignore-ssl-errors', '--disable-web-security']
|
||||||
|
})
|
||||||
|
|
||||||
|
context = await browser.newContext({
|
||||||
|
acceptDownloads: true,
|
||||||
|
viewport: { width: 1920, height: 1080 },
|
||||||
|
ignoreHTTPSErrors: true,
|
||||||
|
javaScriptEnabled: true
|
||||||
|
})
|
||||||
|
|
||||||
|
page = await context.newPage()
|
||||||
|
|
||||||
|
// 启用详细的控制台日志
|
||||||
|
page.on('console', (msg: any) => {
|
||||||
|
console.log(`[Page Console] ${msg.type()}: ${msg.text()}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
page.on('pageerror', (error: any) => {
|
||||||
|
console.error(`[Page Error] ${error.message}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 导航到登录页面
|
||||||
|
const loginUrl = `${ERP_CONFIG.url}/yonbip/resources/uap/rbac/login/main/index.html`
|
||||||
|
console.log(`正在加载登录页面:${loginUrl}`)
|
||||||
|
await page.goto(loginUrl, { timeout: DEBUG_CONFIG.timeout })
|
||||||
|
await page.waitForLoadState('domcontentloaded')
|
||||||
|
console.log('登录页面已加载')
|
||||||
|
|
||||||
|
// 等待并定位登录表单 iframe
|
||||||
|
console.log('等待登录表单...')
|
||||||
|
await page.waitForSelector('#forwardFrame', { state: 'attached', timeout: 15000 })
|
||||||
|
const frameLocator = page.locator('#forwardFrame')
|
||||||
|
const contentFrame = await frameLocator.contentFrame()
|
||||||
|
|
||||||
|
if (!contentFrame) {
|
||||||
|
throw new Error('无法访问 forwardFrame 内容框架')
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainFrame = contentFrame
|
||||||
|
console.log('登录表单框架已就绪')
|
||||||
|
|
||||||
|
// 填充用户名
|
||||||
|
console.log('正在输入用户名...')
|
||||||
|
try {
|
||||||
|
const usernameInput = mainFrame.getByRole('textbox', { name: '用户名' })
|
||||||
|
await usernameInput.waitFor({ state: 'visible', timeout: 5000 })
|
||||||
|
await usernameInput.fill(ERP_CONFIG.username)
|
||||||
|
console.log('用户名已输入')
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('找不到用户名输入框:', e.message)
|
||||||
|
throw new Error('找不到用户名输入框,请检查页面结构是否改变')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 填充密码
|
||||||
|
console.log('正在输入密码...')
|
||||||
|
try {
|
||||||
|
const passwordInput = mainFrame.getByRole('textbox', { name: '密码' })
|
||||||
|
await passwordInput.waitFor({ state: 'visible', timeout: 5000 })
|
||||||
|
await passwordInput.fill(ERP_CONFIG.password)
|
||||||
|
console.log('密码已输入')
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('找不到密码输入框:', e.message)
|
||||||
|
throw new Error('找不到密码输入框,请检查页面结构是否改变')
|
||||||
|
}
|
||||||
|
await page.pause()
|
||||||
|
// 点击登录按钮
|
||||||
|
console.log('正在点击登录按钮...')
|
||||||
|
try {
|
||||||
|
const loginButton = mainFrame.getByRole('button', { name: '登录' })
|
||||||
|
await loginButton.waitFor({ state: 'visible', timeout: 5000 })
|
||||||
|
await loginButton.click()
|
||||||
|
console.log('已点击登录按钮')
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('找不到登录按钮:', e.message)
|
||||||
|
throw new Error('找不到登录按钮,请检查页面结构是否改变')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 等待页面加载
|
||||||
|
console.log('等待登录响应...')
|
||||||
|
await page.waitForLoadState('domcontentloaded', { timeout: 10000 })
|
||||||
|
await page.waitForTimeout(2000) // 额外等待,确保页面完全加载
|
||||||
|
|
||||||
|
// 处理强制登录确认对话框
|
||||||
|
try {
|
||||||
|
const confirmBtn = mainFrame.getByRole('button', { name: '确定' })
|
||||||
|
const count = await confirmBtn.count()
|
||||||
|
if (count > 0) {
|
||||||
|
console.log('检测到强制登录确认对话框,正在确认...')
|
||||||
|
await confirmBtn.first().click()
|
||||||
|
await page.waitForTimeout(2000)
|
||||||
|
console.log('已确认强制登录')
|
||||||
|
} else {
|
||||||
|
console.log('普通登录,无需确认')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
console.log('未检测到确认对话框')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 登录成功,进入调试模式 ====================
|
||||||
|
console.log()
|
||||||
|
console.log('='.repeat(60))
|
||||||
|
console.log('✓ 登录成功!')
|
||||||
|
console.log('='.repeat(60))
|
||||||
|
console.log()
|
||||||
|
console.log('现在进入调试模式,请进行以下操作:')
|
||||||
|
console.log()
|
||||||
|
console.log('1. 按 F12 打开浏览器开发者工具')
|
||||||
|
console.log('2. 使用元素选择器 (Ctrl+Shift+C) 点击主界面特征元素')
|
||||||
|
console.log('3. 在 Elements 面板中右键元素 → Copy → Copy selector')
|
||||||
|
console.log('4. 或者使用 Playwright Inspector:')
|
||||||
|
console.log(' - 在控制台输入: await page.pause()')
|
||||||
|
console.log(' - 使用 Inspector 的元素选择工具')
|
||||||
|
console.log()
|
||||||
|
console.log('建议定位的特征元素:')
|
||||||
|
console.log('- 主界面顶部导航栏')
|
||||||
|
console.log('- 侧边菜单栏')
|
||||||
|
console.log('- 主内容区域的唯一标识')
|
||||||
|
console.log('- 用户信息显示区域')
|
||||||
|
console.log('- 任何登录后独有的界面元素')
|
||||||
|
console.log()
|
||||||
|
console.log('按 Ctrl+C 退出脚本')
|
||||||
|
console.log('='.repeat(60))
|
||||||
|
|
||||||
|
// 保持浏览器打开,等待用户调试
|
||||||
|
// 使用 pause 可以让用户手动恢复或使用 Inspector
|
||||||
|
await page.pause()
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error()
|
||||||
|
console.error('='.repeat(60))
|
||||||
|
console.error('错误:', error.message)
|
||||||
|
console.error('='.repeat(60))
|
||||||
|
console.error()
|
||||||
|
console.error('调试提示:')
|
||||||
|
console.error('1. 检查 ERP_CONFIG 配置是否正确')
|
||||||
|
console.error('2. 检查网络连接')
|
||||||
|
console.error('3. 确认 ERP 系统可访问')
|
||||||
|
console.error('4. 如果是元素找不到,可能需要更新 locators.ts')
|
||||||
|
} finally {
|
||||||
|
// 清理资源(给用户时间保存信息)
|
||||||
|
console.log()
|
||||||
|
console.log('等待 5 秒后关闭浏览器...')
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5000))
|
||||||
|
|
||||||
|
if (context) {
|
||||||
|
await context.close()
|
||||||
|
}
|
||||||
|
if (browser) {
|
||||||
|
await browser.close()
|
||||||
|
}
|
||||||
|
console.log('浏览器已关闭')
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 运行调试脚本
|
||||||
|
debugErpLogin().catch((err) => {
|
||||||
|
console.error('脚本执行失败:', err)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
215
src/main/tools/rustfs-test.ts
Normal file
215
src/main/tools/rustfs-test.ts
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
/**
|
||||||
|
* RustFS Integration Test Script
|
||||||
|
*
|
||||||
|
* Tests RustFS connection and upload functionality
|
||||||
|
* Usage: tsx src/main/tools/rustfs-test.ts
|
||||||
|
*
|
||||||
|
* Note: This test runs in standalone mode without Electron
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
S3Client,
|
||||||
|
PutObjectCommand,
|
||||||
|
GetObjectCommand,
|
||||||
|
ListObjectsV2Command,
|
||||||
|
DeleteObjectCommand,
|
||||||
|
type PutObjectCommandInput
|
||||||
|
} from '@aws-sdk/client-s3'
|
||||||
|
import * as path from 'path'
|
||||||
|
import * as fs from 'fs'
|
||||||
|
|
||||||
|
// Simple console logger (standalone mode)
|
||||||
|
const log = {
|
||||||
|
info: (msg: string, data?: any) => console.log(`[INFO] ${msg}`, data ? JSON.stringify(data) : ''),
|
||||||
|
error: (msg: string, data?: any) =>
|
||||||
|
console.error(`[ERROR] ${msg}`, data ? JSON.stringify(data) : ''),
|
||||||
|
warn: (msg: string, data?: any) => console.warn(`[WARN] ${msg}`, data ? JSON.stringify(data) : '')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test configuration
|
||||||
|
const TEST_CONFIG = {
|
||||||
|
enabled: true,
|
||||||
|
endpoint: 'http://192.168.110.114:9000',
|
||||||
|
accessKey: 'dP4O7ePAzyH8earoXxE9',
|
||||||
|
secretKey: '2vRPLnsh9Zi1KyBDymUtACyDdLHGfsLvw4MkG3cv',
|
||||||
|
bucket: 'erpauto',
|
||||||
|
region: 'us-east-1'
|
||||||
|
}
|
||||||
|
|
||||||
|
function createS3Client(config: typeof TEST_CONFIG) {
|
||||||
|
return new S3Client({
|
||||||
|
region: config.region,
|
||||||
|
endpoint: config.endpoint,
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: config.accessKey,
|
||||||
|
secretAccessKey: config.secretKey
|
||||||
|
},
|
||||||
|
forcePathStyle: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMimeType(filePath: string): string {
|
||||||
|
const ext = path.extname(filePath).toLowerCase()
|
||||||
|
const mimeTypes: Record<string, string> = {
|
||||||
|
'.md': 'text/markdown; charset=utf-8',
|
||||||
|
'.txt': 'text/plain; charset=utf-8',
|
||||||
|
'.json': 'application/json; charset=utf-8',
|
||||||
|
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
'.csv': 'text/csv; charset=utf-8'
|
||||||
|
}
|
||||||
|
return mimeTypes[ext] || 'application/octet-stream'
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateReportKey(reportFileName: string, username: string): string {
|
||||||
|
// Organize reports by user for easy access
|
||||||
|
// Format: reports/cleaner/{username}/{filename}
|
||||||
|
return `reports/cleaner/${username}/${reportFileName}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runTests() {
|
||||||
|
console.log('='.repeat(50))
|
||||||
|
console.log('RustFS Integration Test')
|
||||||
|
console.log('='.repeat(50))
|
||||||
|
console.log()
|
||||||
|
|
||||||
|
const client = createS3Client(TEST_CONFIG)
|
||||||
|
|
||||||
|
// Test connection
|
||||||
|
console.log('1. Testing connection...')
|
||||||
|
try {
|
||||||
|
const command = new ListObjectsV2Command({
|
||||||
|
Bucket: TEST_CONFIG.bucket,
|
||||||
|
Prefix: '',
|
||||||
|
MaxKeys: 1
|
||||||
|
})
|
||||||
|
await client.send(command)
|
||||||
|
console.log(' ✓ Connection successful')
|
||||||
|
} catch (error) {
|
||||||
|
console.log(` ✗ Connection failed: ${(error as Error).message}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.log()
|
||||||
|
|
||||||
|
// Test upload string
|
||||||
|
console.log('2. Testing string upload...')
|
||||||
|
const testContent = `# Test Report
|
||||||
|
Generated at: ${new Date().toISOString()}
|
||||||
|
|
||||||
|
This is a test report to verify RustFS integration.
|
||||||
|
`
|
||||||
|
const testKey = `test/reports/test-${Date.now()}.md`
|
||||||
|
try {
|
||||||
|
const input: PutObjectCommandInput = {
|
||||||
|
Bucket: TEST_CONFIG.bucket,
|
||||||
|
Key: testKey,
|
||||||
|
Body: Buffer.from(testContent, 'utf-8'),
|
||||||
|
ContentType: 'text/markdown; charset=utf-8'
|
||||||
|
}
|
||||||
|
const command = new PutObjectCommand(input)
|
||||||
|
const response = await client.send(command)
|
||||||
|
console.log(' ✓ Upload successful')
|
||||||
|
console.log(` Key: ${testKey}`)
|
||||||
|
console.log(` ETag: ${response.ETag}`)
|
||||||
|
} catch (error) {
|
||||||
|
console.log(' ✗ Upload failed')
|
||||||
|
console.log(` Error: ${(error as Error).message}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.log()
|
||||||
|
|
||||||
|
// Test download
|
||||||
|
console.log('3. Testing download...')
|
||||||
|
try {
|
||||||
|
const command = new GetObjectCommand({
|
||||||
|
Bucket: TEST_CONFIG.bucket,
|
||||||
|
Key: testKey
|
||||||
|
})
|
||||||
|
const response = await client.send(command)
|
||||||
|
const chunks: Buffer[] = []
|
||||||
|
for await (const chunk of response.Body as any) {
|
||||||
|
chunks.push(Buffer.from(chunk))
|
||||||
|
}
|
||||||
|
const content = Buffer.concat(chunks)
|
||||||
|
console.log(' ✓ Download successful')
|
||||||
|
console.log(` Size: ${content.length} bytes`)
|
||||||
|
console.log(` Content preview: ${content.toString('utf-8').slice(0, 50)}...`)
|
||||||
|
} catch (error) {
|
||||||
|
console.log(' ✗ Download failed')
|
||||||
|
console.log(` Error: ${(error as Error).message}`)
|
||||||
|
}
|
||||||
|
console.log()
|
||||||
|
|
||||||
|
// Test file upload (create a temporary file)
|
||||||
|
console.log('4. Testing file upload...')
|
||||||
|
const tempFilePath = path.join(process.cwd(), `test-file-${Date.now()}.md`)
|
||||||
|
fs.writeFileSync(tempFilePath, testContent, 'utf-8')
|
||||||
|
|
||||||
|
const fileKey = `test/files/test-file-${Date.now()}.md`
|
||||||
|
try {
|
||||||
|
const fileContent = fs.readFileSync(tempFilePath)
|
||||||
|
const input: PutObjectCommandInput = {
|
||||||
|
Bucket: TEST_CONFIG.bucket,
|
||||||
|
Key: fileKey,
|
||||||
|
Body: fileContent,
|
||||||
|
ContentType: getMimeType(tempFilePath)
|
||||||
|
}
|
||||||
|
const command = new PutObjectCommand(input)
|
||||||
|
const response = await client.send(command)
|
||||||
|
console.log(' ✓ File upload successful')
|
||||||
|
console.log(` Key: ${fileKey}`)
|
||||||
|
console.log(` ETag: ${response.ETag}`)
|
||||||
|
} catch (error) {
|
||||||
|
console.log(' ✗ File upload failed')
|
||||||
|
console.log(` Error: ${(error as Error).message}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup temp file
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(tempFilePath)
|
||||||
|
console.log(' ✓ Temporary file cleaned up')
|
||||||
|
} catch (e) {
|
||||||
|
console.log(` ⚠ Could not clean up temp file: ${(e as Error).message}`)
|
||||||
|
}
|
||||||
|
console.log()
|
||||||
|
|
||||||
|
// Test report key generation
|
||||||
|
console.log('5. Testing report key generation...')
|
||||||
|
const reportKey = generateReportKey('cleaner-report-2026-03-17-10-30-00.md', 'admin')
|
||||||
|
console.log(` ✓ Generated key: ${reportKey}`)
|
||||||
|
console.log()
|
||||||
|
|
||||||
|
// Test cleanup (delete test files)
|
||||||
|
console.log('6. Cleaning up test files...')
|
||||||
|
try {
|
||||||
|
const deleteCommand = new DeleteObjectCommand({
|
||||||
|
Bucket: TEST_CONFIG.bucket,
|
||||||
|
Key: testKey
|
||||||
|
})
|
||||||
|
await client.send(deleteCommand)
|
||||||
|
console.log(' ✓ Test string file deleted')
|
||||||
|
} catch (error) {
|
||||||
|
console.log(` ⚠ Could not delete test string file: ${(error as Error).message}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const deleteCommand = new DeleteObjectCommand({
|
||||||
|
Bucket: TEST_CONFIG.bucket,
|
||||||
|
Key: fileKey
|
||||||
|
})
|
||||||
|
await client.send(deleteCommand)
|
||||||
|
console.log(' ✓ Test file deleted')
|
||||||
|
} catch (error) {
|
||||||
|
console.log(` ⚠ Could not delete test file: ${(error as Error).message}`)
|
||||||
|
}
|
||||||
|
console.log()
|
||||||
|
|
||||||
|
console.log('='.repeat(50))
|
||||||
|
console.log('All tests completed!')
|
||||||
|
console.log('='.repeat(50))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run tests
|
||||||
|
runTests().catch((error) => {
|
||||||
|
console.error('Test failed with error:', error)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
44
src/main/types/audit.types.ts
Normal file
44
src/main/types/audit.types.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* Audit log types and interfaces
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Audit action enumeration
|
||||||
|
*/
|
||||||
|
export enum AuditAction {
|
||||||
|
LOGIN = 'LOGIN',
|
||||||
|
LOGOUT = 'LOGOUT',
|
||||||
|
EXTRACT = 'EXTRACT',
|
||||||
|
CLEAN = 'CLEAN',
|
||||||
|
SETTINGS_CHANGE = 'SETTINGS_CHANGE'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Audit status enumeration
|
||||||
|
*/
|
||||||
|
export enum AuditStatus {
|
||||||
|
SUCCESS = 'SUCCESS',
|
||||||
|
FAILURE = 'FAILURE'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Audit entry interface
|
||||||
|
*/
|
||||||
|
export interface AuditEntry {
|
||||||
|
/** Timestamp of the action */
|
||||||
|
timestamp: Date
|
||||||
|
/** Action performed */
|
||||||
|
action: AuditAction
|
||||||
|
/** User ID who performed the action */
|
||||||
|
userId: string
|
||||||
|
/** Username who performed the action */
|
||||||
|
username: string
|
||||||
|
/** Computer name where action was performed */
|
||||||
|
computerName: string
|
||||||
|
/** Resource affected by the action */
|
||||||
|
resource?: string
|
||||||
|
/** Status of the action */
|
||||||
|
status: AuditStatus
|
||||||
|
/** Additional metadata in JSON format */
|
||||||
|
metadata?: string
|
||||||
|
}
|
||||||
@@ -1,8 +1,24 @@
|
|||||||
|
export type CleanerPhase = 'login' | 'processing' | 'complete'
|
||||||
|
|
||||||
|
export interface CleanerProgress {
|
||||||
|
message: string
|
||||||
|
progress: number
|
||||||
|
currentOrderIndex: number
|
||||||
|
totalOrders: number
|
||||||
|
currentMaterialIndex: number
|
||||||
|
totalMaterialsInOrder: number
|
||||||
|
currentOrderNumber?: string
|
||||||
|
phase: CleanerPhase
|
||||||
|
}
|
||||||
|
|
||||||
export interface CleanerInput {
|
export interface CleanerInput {
|
||||||
orderNumbers: string[]
|
orderNumbers: string[]
|
||||||
materialCodes: string[]
|
materialCodes: string[]
|
||||||
dryRun: boolean
|
dryRun: boolean
|
||||||
onProgress?: (message: string, progress?: number) => void
|
headless?: boolean
|
||||||
|
queryBatchSize?: number
|
||||||
|
processConcurrency?: number
|
||||||
|
onProgress?: (message: string, progress?: number, extra?: Partial<CleanerProgress>) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CleanerResult {
|
export interface CleanerResult {
|
||||||
@@ -11,6 +27,22 @@ export interface CleanerResult {
|
|||||||
materialsSkipped: number
|
materialsSkipped: number
|
||||||
errors: string[]
|
errors: string[]
|
||||||
details: OrderCleanDetail[]
|
details: OrderCleanDetail[]
|
||||||
|
// Retry statistics
|
||||||
|
retriedOrders: number
|
||||||
|
successfulRetries: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkippedMaterial {
|
||||||
|
materialCode: string
|
||||||
|
materialName: string
|
||||||
|
rowNumber: number
|
||||||
|
reason: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RetryAttempt {
|
||||||
|
attempt: number
|
||||||
|
error: string
|
||||||
|
timestamp: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OrderCleanDetail {
|
export interface OrderCleanDetail {
|
||||||
@@ -18,4 +50,39 @@ export interface OrderCleanDetail {
|
|||||||
materialsDeleted: number
|
materialsDeleted: number
|
||||||
materialsSkipped: number
|
materialsSkipped: number
|
||||||
errors: string[]
|
errors: string[]
|
||||||
|
skippedMaterials: SkippedMaterial[]
|
||||||
|
// Retry-related fields
|
||||||
|
retryCount: number
|
||||||
|
retryAttempts?: RetryAttempt[]
|
||||||
|
retriedAt?: number
|
||||||
|
retrySuccess?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single validation result item for export
|
||||||
|
*/
|
||||||
|
export interface ExportResultItem {
|
||||||
|
materialName: string
|
||||||
|
materialCode: string
|
||||||
|
specification: string
|
||||||
|
model: string
|
||||||
|
managerName: string
|
||||||
|
isMarkedForDeletion: boolean
|
||||||
|
isSelected: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request payload for exporting validation results
|
||||||
|
*/
|
||||||
|
export interface ExportResultRequest {
|
||||||
|
items: ExportResultItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response for export operation
|
||||||
|
*/
|
||||||
|
export interface ExportResultResponse {
|
||||||
|
success: boolean
|
||||||
|
filePath?: string
|
||||||
|
error?: string
|
||||||
}
|
}
|
||||||
|
|||||||
208
src/main/types/config.schema.ts
Normal file
208
src/main/types/config.schema.ts
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
/**
|
||||||
|
* Configuration Schema Definitions
|
||||||
|
*
|
||||||
|
* Zod schemas for runtime validation of application configuration
|
||||||
|
*
|
||||||
|
* Note: ERP configuration is stored in database (dbo_BIPUsers table)
|
||||||
|
* and managed per-user, not in this config file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据库类型枚举
|
||||||
|
*/
|
||||||
|
export const databaseTypeSchema = z.enum(['mysql', 'sqlserver'])
|
||||||
|
export type DatabaseType = z.infer<typeof databaseTypeSchema>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证匹配模式枚举
|
||||||
|
*/
|
||||||
|
export const matchModeSchema = z.enum(['substring', 'exact'])
|
||||||
|
export type MatchMode = z.infer<typeof matchModeSchema>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证数据源枚举
|
||||||
|
*/
|
||||||
|
export const validationDataSourceSchema = z.enum([
|
||||||
|
'database_full',
|
||||||
|
'database_filtered',
|
||||||
|
'excel_existing',
|
||||||
|
'excel_full'
|
||||||
|
])
|
||||||
|
export type ValidationDataSource = z.infer<typeof validationDataSourceSchema>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MySQL 配置 Schema
|
||||||
|
*/
|
||||||
|
export const mysqlConfigSchema = z.object({
|
||||||
|
host: z.string().min(1, 'MySQL host is required'),
|
||||||
|
port: z.number().int().min(1).max(65535).default(3306),
|
||||||
|
database: z.string().min(1, 'MySQL database is required'),
|
||||||
|
username: z.string().min(1, 'MySQL username is required'),
|
||||||
|
password: z.string(),
|
||||||
|
charset: z.string().default('utf8mb4')
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SQL Server 配置 Schema
|
||||||
|
*/
|
||||||
|
export const sqlServerConfigSchema = z.object({
|
||||||
|
server: z.string().min(1, 'SQL Server is required'),
|
||||||
|
port: z.number().int().min(1).max(65535).default(1433),
|
||||||
|
database: z.string().min(1, 'SQL Server database is required'),
|
||||||
|
username: z.string().min(1, 'SQL Server username is required'),
|
||||||
|
password: z.string(),
|
||||||
|
driver: z.string().default('ODBC Driver 18 for SQL Server'),
|
||||||
|
trustServerCertificate: z.boolean().default(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据库配置(包含两种数据库的完整配置)
|
||||||
|
*/
|
||||||
|
export const databaseConfigSchema = z.object({
|
||||||
|
activeType: databaseTypeSchema.default('mysql'),
|
||||||
|
mysql: mysqlConfigSchema,
|
||||||
|
sqlserver: sqlServerConfigSchema
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 路径配置 Schema
|
||||||
|
*/
|
||||||
|
export const pathsConfigSchema = z.object({
|
||||||
|
dataDir: z.string().min(1, 'Data directory is required'),
|
||||||
|
defaultOutput: z.string().default('离散备料计划维护_合并.xlsx'),
|
||||||
|
validationOutput: z.string().default('物料状态校验结果.xlsx')
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据提取配置 Schema
|
||||||
|
*/
|
||||||
|
export const extractionConfigSchema = z.object({
|
||||||
|
batchSize: z.number().int().min(1).max(1000).default(100),
|
||||||
|
verbose: z.boolean().default(true),
|
||||||
|
autoConvert: z.boolean().default(true),
|
||||||
|
mergeBatches: z.boolean().default(true),
|
||||||
|
enableDbPersistence: z.boolean().default(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 物料校验配置 Schema
|
||||||
|
*/
|
||||||
|
export const validationConfigSchema = z.object({
|
||||||
|
dataSource: validationDataSourceSchema.default('database_full'),
|
||||||
|
batchSize: z.number().int().min(1).max(10000).default(2000),
|
||||||
|
matchMode: matchModeSchema.default('substring'),
|
||||||
|
enableCrud: z.boolean().default(false),
|
||||||
|
defaultManager: z.string().default('')
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理配置 Schema
|
||||||
|
*/
|
||||||
|
export const cleanerConfigSchema = z.object({
|
||||||
|
queryBatchSize: z.number().int().min(1).max(100).default(100),
|
||||||
|
processConcurrency: z.number().int().min(1).max(20).default(1)
|
||||||
|
})
|
||||||
|
export type CleanerConfig = z.infer<typeof cleanerConfigSchema>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订单号解析配置 Schema
|
||||||
|
*/
|
||||||
|
export const orderResolutionSchema = z.object({
|
||||||
|
tableName: z.string(),
|
||||||
|
productionIdField: z.string(),
|
||||||
|
orderNumberField: z.string()
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ERP 系统配置 Schema(固定基础设施)
|
||||||
|
*/
|
||||||
|
export const erpSystemConfigSchema = z.object({
|
||||||
|
url: z.string().url('ERP URL must be a valid URL')
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 日志配置 Schema
|
||||||
|
*/
|
||||||
|
export const loggingConfigSchema = z.object({
|
||||||
|
level: z.enum(['error', 'warn', 'info', 'debug', 'verbose']).default('info'),
|
||||||
|
auditRetention: z.number().int().min(1).max(365).default(30),
|
||||||
|
appRetention: z.number().int().min(1).max(365).default(14)
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RustFS 对象存储配置 Schema
|
||||||
|
*/
|
||||||
|
export const rustfsConfigSchema = z.object({
|
||||||
|
enabled: z.boolean().default(false),
|
||||||
|
endpoint: z.string().min(1, 'RustFS endpoint is required'),
|
||||||
|
accessKey: z.string().min(1, 'RustFS access key is required'),
|
||||||
|
secretKey: z.string().min(1, 'RustFS secret key is required'),
|
||||||
|
bucket: z.string().min(1, 'RustFS bucket is required'),
|
||||||
|
region: z.string().default('us-east-1')
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update service configuration schema
|
||||||
|
*/
|
||||||
|
export const updateConfigSchema = z.object({
|
||||||
|
enabled: z.boolean().default(false),
|
||||||
|
allowDevMode: z.boolean().default(false),
|
||||||
|
endpoint: z.string().default(''),
|
||||||
|
accessKey: z.string().default(''),
|
||||||
|
secretKey: z.string().default(''),
|
||||||
|
bucket: z.string().default(''),
|
||||||
|
region: z.string().default('us-east-1'),
|
||||||
|
basePrefix: z.string().default('updates/win-portable'),
|
||||||
|
checkIntervalMinutes: z.number().int().min(1).max(1440).default(30),
|
||||||
|
maxAdminHistoryPerChannel: z.number().int().min(1).max(100).default(10)
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 完整应用配置 Schema
|
||||||
|
*/
|
||||||
|
export const fullConfigSchema = z.object({
|
||||||
|
erp: erpSystemConfigSchema,
|
||||||
|
database: databaseConfigSchema,
|
||||||
|
paths: pathsConfigSchema,
|
||||||
|
extraction: extractionConfigSchema,
|
||||||
|
validation: validationConfigSchema,
|
||||||
|
cleaner: cleanerConfigSchema,
|
||||||
|
orderResolution: orderResolutionSchema,
|
||||||
|
logging: loggingConfigSchema,
|
||||||
|
rustfs: rustfsConfigSchema.optional(),
|
||||||
|
update: updateConfigSchema.optional()
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类型导出
|
||||||
|
*/
|
||||||
|
export type FullConfig = z.infer<typeof fullConfigSchema>
|
||||||
|
export type DatabaseConfig = z.infer<typeof databaseConfigSchema>
|
||||||
|
export type MySqlConfig = z.infer<typeof mysqlConfigSchema>
|
||||||
|
export type SqlServerConfig = z.infer<typeof sqlServerConfigSchema>
|
||||||
|
export type ErpSystemConfig = z.infer<typeof erpSystemConfigSchema>
|
||||||
|
export type LoggingConfig = z.infer<typeof loggingConfigSchema>
|
||||||
|
export type RustfsConfig = z.infer<typeof rustfsConfigSchema>
|
||||||
|
export type UpdateConfig = z.infer<typeof updateConfigSchema>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证并解析配置
|
||||||
|
*/
|
||||||
|
export function validateConfig(input: unknown): {
|
||||||
|
success: boolean
|
||||||
|
data?: FullConfig
|
||||||
|
error?: string
|
||||||
|
} {
|
||||||
|
const result = fullConfigSchema.safeParse(input)
|
||||||
|
if (result.success) {
|
||||||
|
return { success: true, data: result.data }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: result.error.issues
|
||||||
|
.map((issue) => `${issue.path.join('.')}: ${issue.message}`)
|
||||||
|
.join('; ')
|
||||||
|
}
|
||||||
|
}
|
||||||
104
src/main/types/database.types.ts
Normal file
104
src/main/types/database.types.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* Database Type Definitions
|
||||||
|
*
|
||||||
|
* Provides abstract interfaces for database operations,
|
||||||
|
* supporting both MySQL and SQL Server databases.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supported database types
|
||||||
|
*/
|
||||||
|
export type DatabaseType = 'mysql' | 'sqlserver'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard query result interface
|
||||||
|
*/
|
||||||
|
export interface QueryResult {
|
||||||
|
/** Query result rows */
|
||||||
|
rows: Record<string, unknown>[]
|
||||||
|
/** Column names from the query */
|
||||||
|
columns: string[]
|
||||||
|
/** Number of rows affected or returned */
|
||||||
|
rowCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Database service interface
|
||||||
|
*
|
||||||
|
* Defines the common interface that all database services must implement.
|
||||||
|
* This allows for database-agnostic operations throughout the application.
|
||||||
|
*/
|
||||||
|
export interface IDatabaseService {
|
||||||
|
/** Database type identifier */
|
||||||
|
readonly type: DatabaseType
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect to the database
|
||||||
|
*/
|
||||||
|
connect(): Promise<void>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disconnect from the database
|
||||||
|
*/
|
||||||
|
disconnect(): Promise<void>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if connected to the database
|
||||||
|
*/
|
||||||
|
isConnected(): boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a query and return results
|
||||||
|
* @param sql - SQL query string
|
||||||
|
* @param params - Query parameters as an array
|
||||||
|
*/
|
||||||
|
query(sql: string, params?: any[]): Promise<QueryResult>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute multiple queries in a transaction
|
||||||
|
* @param queries - Array of queries with optional parameters
|
||||||
|
*/
|
||||||
|
transaction(queries: { sql: string; params?: any[] }[]): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base database configuration interface
|
||||||
|
*/
|
||||||
|
export interface DatabaseConfig {
|
||||||
|
/** Database server host */
|
||||||
|
host?: string
|
||||||
|
/** Database server port */
|
||||||
|
port?: number
|
||||||
|
/** Database username */
|
||||||
|
user?: string
|
||||||
|
/** Database password */
|
||||||
|
password?: string
|
||||||
|
/** Database name */
|
||||||
|
database?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MySQL-specific configuration
|
||||||
|
*/
|
||||||
|
export interface MySqlConfig extends DatabaseConfig {
|
||||||
|
host: string
|
||||||
|
port: number
|
||||||
|
user: string
|
||||||
|
password: string
|
||||||
|
database: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SQL Server-specific configuration
|
||||||
|
*/
|
||||||
|
export interface SqlServerConfig extends DatabaseConfig {
|
||||||
|
server: string
|
||||||
|
port: number
|
||||||
|
user: string
|
||||||
|
password: string
|
||||||
|
database: string
|
||||||
|
options?: {
|
||||||
|
encrypt?: boolean
|
||||||
|
trustServerCertificate?: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user