7 Commits

Author SHA1 Message Date
Misaka
f112046178 feat: implement Winston logging service
- Add LogEntry interface for structured log data
- Create LoggerService with Winston integration
- Support for console and file transports (app.log, error.log)
- Implement UI log notification callbacks
- Provide info, warn, error, and debug logging methods
- Configure log rotation (10MB max, 5 files retained)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 22:17:09 +08:00
Misaka
319b5ec03b feat: implement configuration management system
- Add comprehensive TypeScript interfaces for all config types
- Implement ConfigManager singleton class with layered loading
- Support for default, environment-specific, and environment variable configs
- Add example .env file for sensitive configuration

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 22:17:09 +08:00
Misaka
ae60273782 feat: implement configuration management system
- Add comprehensive TypeScript interfaces for all config types
- Implement ConfigManager singleton class with layered loading
- Support for default, environment-specific, and environment variable configs
- Add example .env file for sensitive configuration

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 22:10:41 +08:00
Misaka
fb6f2bbc02 feat: configure TypeScript with strict mode 2026-02-28 22:07:52 +08:00
Misaka
cacc53a184 feat: install core dependencies (playwright, database, logging)
Install runtime dependencies:
- playwright: For browser automation
- mssql: SQL Server database connectivity
- mysql2: MySQL database connectivity
- winston: Logging framework
- exceljs: Excel file generation and manipulation

Install dev dependencies:
- @types/node: TypeScript definitions for Node.js

Update package.json scripts:
- postinstall: Automatically install Playwright Chromium
- test: Jest unit test runner
- test:e2e: Playwright end-to-end test runner

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 22:01:03 +08:00
Misaka
b5a8c11a30 fix: restore Electron template files deleted in Task 1
This commit restores essential Electron + React template files that were
accidentally deleted during Task 1 implementation. These files were part
of the initial template setup (commit 0077005) and are required for the
Electron application to function properly.

Restored files:
- src/preload/index.d.ts
- src/preload/index.ts
- src/renderer/index.html
- src/renderer/src/assets/base.css
- src/renderer/src/assets/electron.svg
- src/renderer/src/assets/main.css
- src/renderer/src/assets/wavy-lines.svg
- src/renderer/src/components/Versions.tsx
- src/renderer/src/env.d.ts
- src/renderer/src/main.tsx
- src/renderer/src/App.tsx (restored to original template)

The directory structure additions from Task 1 (hooks, pages, styles) are
preserved.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 21:44:06 +08:00
Misaka
7704c0c067 feat: create project directory structure
- Create main process structure (services, dao, controllers, models, utils, config)
- Create preload script directory with .gitkeep
- Create renderer structure (pages, components, hooks, styles)
- Add placeholder App.tsx for React (to be implemented in later tasks)
- Create config, tests, logs directories
- Create data directories (temp, output, reports)
- Update .gitignore to exclude logs, data files, and environment configs

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 21:39:52 +08:00
205 changed files with 655 additions and 44301 deletions

18
.gitattributes vendored
View File

@@ -1,18 +0,0 @@
* 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

51
.gitignore vendored
View File

@@ -4,43 +4,22 @@ out
.DS_Store
.eslintcache
*.log*
.npmrc
# AI Agent
#AI Agent
.claude
# Logs
logs/
*.log
# Data
data/temp/*
data/output/*
data/reports/*
!data/temp/.gitkeep
!data/output/.gitkeep
!data/reports/.gitkeep
# Environment
.env
# Test outputs
coverage/
downloads/
release-output/
build/bin/
*.xlsx
*.parsed.json
# Test scripts (debug and manual)
tests/debug/
tests/manual/
test-*.mjs
test-*.js
compare_*.js
# logs
logs
# oh my opencode
.sisyphus
# Runtime config files
*.yaml
*.yaml.backup
# But keep config.template.yaml
!config.template.yaml
# nul
nul
# TypeScript incremental compilation cache
*.tsbuildinfo
config/.env
config/.env.local

165
CLAUDE.md
View File

@@ -1,165 +0,0 @@
# CLAUDE.md
本文件用于给 AI 编程代理提供本项目的最小必要指导。
目标不是替代 `README``docs/`,而是帮助代理快速理解项目结构、工作方式和关键约束。
## 项目概览
ERPAuto 是一个基于 Electron 的桌面应用,用于自动化处理 ERP 系统中的数据提取、清理、校验和配置管理。
技术栈:
- Electron 39
- React 19
- TypeScript 5.9
- electron-vite / Vite 7
- Playwright 1.58
- Vitest
## 常用命令
开发与构建:
```bash
npm run dev
npm run build
npm run build:win
```
质量检查:
```bash
npm run typecheck
npm run typecheck:node
npm run typecheck:web
npm run lint
npm run format
```
测试:
```bash
npm run test
npm run test:coverage
npm run test:e2e
```
发布:
```bash
npm run release:publish -- --channel stable
npm run release:publish -- --channel preview
```
详细发布流程见:
[docs/build-and-release-guide.md](/d:/FileLib/Projects/CodeMigration/ERPAuto/docs/build-and-release-guide.md)
## 代码结构
### 主进程
- 路径:`src/main/`
- 入口:`src/main/index.ts`
- 职责:
- 应用生命周期管理
- 配置加载
- IPC 注册
- 更新服务初始化
### 预加载层
- 路径:`src/preload/`
- 职责:
- 暴露 `window.electron` API
- 作为 renderer 和 main 之间的安全桥
### 渲染进程
- 路径:`src/renderer/`
- 技术React + TypeScript
- 特点:
- 通过 preload 暴露的 API 调用主进程
- 以登录状态和角色控制主要功能入口
### 服务层
主进程服务集中在 `src/main/services/`,按领域拆分:
- `erp/`ERP 浏览器自动化
- `database/`MySQL / SQL Server
- `user/`:登录、会话、用户切换
- `config/`YAML 配置管理
- `update/`:便携版更新
- `excel/`Excel 处理
## 关键事实
### 配置系统
- 使用 YAML 配置
- 模板文件:`config.template.yaml`
- 开发环境通常使用项目根目录下的 `config.yaml`
- 生产环境会将配置放到用户目录
### IPC 组织方式
- 所有 IPC handler 在 `src/main/ipc/`
- 每个领域一个 handler 模块
- 统一在 `src/main/ipc/index.ts` 注册
- channel 命名遵循 `domain:action`
### 认证与角色
当前主要角色是:
- `Admin`
- `User`
- `Guest`
登录流程支持:
- 静默登录
- 普通登录
- 管理员切换用户
### 便携版自动更新
项目已实现 Windows 便携版更新,关键点:
- 更新检查基于登录用户角色
- 支持 `stable` / `preview` 双通道
- `User` 只看 `stable`
- `Admin` 同时看 `stable``preview`
- 使用原生 `portable-updater.exe` 完成替换,不依赖 PowerShell
相关文档:
- [docs/portable-auto-update-architecture.md](/d:/FileLib/Projects/CodeMigration/ERPAuto/docs/portable-auto-update-architecture.md)
- [docs/build-and-release-guide.md](/d:/FileLib/Projects/CodeMigration/ERPAuto/docs/build-and-release-guide.md)
## 代理工作约束
1. 优先修改现有文件,不要随意新建同类文件。
2. 变更前先理解对应模块的现有模式,尽量保持风格一致。
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/` 下的专题文档中。

189
README.md
View File

@@ -1,191 +1,34 @@
# ERPAuto - ERP 数据自动化处理工具
# erpauto
一个基于 Electron 的桌面应用程序,用于自动化处理 ERP 系统中的数据提取和清理任务。
An Electron application with React and TypeScript
## 功能特性
## Recommended IDE Setup
- **数据提取**:从 ERP 系统批量下载物料计划数据
- **物料清理**:自动删除指定的物料代码,支持干运行模式
- **数据库支持**:支持 MySQL 和 SQL Server 数据存储
- **Excel 解析**:自动解析下载的 Excel 文件
- [VSCode](https://code.visualstudio.com/) + [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) + [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode)
## 快速开始
## Project Setup
### 环境要求
- Node.js >= 18
- npm >= 9
- 可访问的 ERP 系统
### 安装
### Install
```bash
# 克隆项目
git clone <repository-url>
cd ERPAuto
# 安装依赖
npm install
$ npm install
```
### 配置
在项目根目录创建 `config.yaml` 文件(可参考 `config.template.yaml`
```yaml
# ERP 配置(固定基础设施)
erp:
url: https://your-erp-server.com
# 数据库配置
database:
activeType: mysql # 或 sqlserver
mysql:
host: localhost
port: 3306
database: erpauto
username: root
password: your_password
charset: utf8mb4
sqlserver:
server: localhost
port: 1433
database: erpauto
username: sa
password: your_password
driver: 'ODBC Driver 18 for SQL Server'
trustServerCertificate: true
# 路径配置
paths:
dataDir: './data/'
defaultOutput: 'output.xlsx'
validationOutput: 'validation-result.xlsx'
```
**注意**ERP 用户名和密码在应用的设置界面中配置,存储在数据库中(按用户管理)。
### 运行开发环境
### Development
```bash
npm run dev
$ npm run dev
```
### 构建应用
### Build
```bash
# Windows
npm run build:win
# For windows
$ npm run build:win
# macOS
npm run build:mac
# For macOS
$ npm run build:mac
# Linux
npm run build:linux
# For Linux
$ npm run build:linux
```
## 使用指南
### 数据提取
1. 启动应用后,点击主页的「数据提取」进入提取页面
2. 在订单号输入框中输入订单号,每行一个
3. 设置批量大小(默认 100
4. 点击「开始提取」按钮
5. 等待提取完成,查看结果
### 物料清理
1. 点击主页的「物料清理」进入清理页面
2. 输入订单号(每行一个)
3. 输入要删除的物料代码(每行一个)
4. 勾选「干运行模式」可预览删除结果(不实际删除)
5. 点击「开始清理」按钮
6. 查看清理结果和详细统计
## 测试
```bash
# 运行单元测试
npm run test
# 运行 E2E 测试
npm run test:e2e
# 查看测试报告
npm run test:e2e:report
```
## 项目结构
```
ERPAuto/
├── src/
│ ├── main/ # 主进程代码
│ │ ├── services/ # 业务服务
│ │ ├── ipc/ # IPC 处理器
│ │ └── types/ # TypeScript 类型
│ ├── preload/ # 预加载脚本
│ └── renderer/ # 渲染进程React UI
├── tests/
│ ├── unit/ # 单元测试
│ ├── integration/ # 集成测试
│ └── e2e/ # E2E 测试
└── docs/ # 文档
```
## 技术栈
- **框架**Electron 39
- **前端**React 19 + TypeScript
- **构建工具**electron-vite
- **浏览器自动化**Playwright
- **数据库**mysql2, mssql
- **Excel 处理**ExcelJS
- **测试**Vitest, Playwright Test
## 常见问题
### 无法连接 ERP 系统
1. 检查 `config.yaml` 中的 ERP URL 是否正确
2. 确认网络连接正常
3. 检查 ERP 系统是否可访问
4. 在设置界面中确认 ERP 用户名和密码已配置
### 提取失败
1. 确认订单号格式正确
2. 检查 ERP 系统账号权限
3. 查看应用日志获取详细错误信息
### 数据库连接失败
1. 确认数据库服务已启动
2. 检查 `config.yaml` 中的数据库配置
3. 确认防火墙允许数据库端口访问
## 开发
```bash
# 安装依赖
npm install
# 启动开发服务器
npm run dev
# 类型检查
npm run typecheck
# 代码格式化
npm run format
# Lint 检查
npm run lint
```
## 许可证
MIT License

View File

@@ -1,247 +0,0 @@
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("\"", "\\\"") + "\"";
}
}

View File

@@ -1,85 +0,0 @@
# ================================
# 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

18
config/.env.example Normal file
View File

@@ -0,0 +1,18 @@
# SQL Server
SQL_SERVER_SERVER=192.168.110.114
SQL_SERVER_DATABASE=CompanyDB
SQL_SERVER_USERNAME=peng
SQL_SERVER_PASSWORD=your_password_here
SQL_SERVER_DRIVER=ODBC Driver 18 for SQL Server
SQL_SERVER_TRUST_CERT=yes
# MySQL
MYSQL_HOST=localhost
MYSQL_PORT=3306
MYSQL_DATABASE=erp_db
MYSQL_USERNAME=root
MYSQL_PASSWORD=your_password_here
# ERP Credentials (optional, can also be entered in UI)
ERP_USERNAME=BLDpengqiangqiang
ERP_PASSWORD=your_password_here

0
config/.gitkeep Normal file
View File

19
config/app.json Normal file
View File

@@ -0,0 +1,19 @@
{
"appName": "ERPAuto",
"version": "1.0.0",
"logLevel": "info",
"browser": {
"headless": false,
"slowMo": 50,
"timeout": 30000
},
"erp": {
"baseUrl": "",
"ignoreHttpsErrors": false
},
"paths": {
"tempDir": "./data/temp",
"outputDir": "./data/output",
"reportDir": "./data/reports"
}
}

7
config/development.json Normal file
View File

@@ -0,0 +1,7 @@
{
"logLevel": "debug",
"browser": {
"headless": false,
"slowMo": 100
}
}

0
data/output/.gitkeep Normal file
View File

0
data/reports/.gitkeep Normal file
View File

0
data/temp/.gitkeep Normal file
View File

View File

@@ -1,300 +0,0 @@
# 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)

View File

@@ -1,731 +0,0 @@
# 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 配置变更需重启浏览器才能生效

View File

@@ -1,171 +0,0 @@
# 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

View File

@@ -1,262 +0,0 @@
# ERPAuto 用户指南
## 目录
1. [简介](#简介)
2. [安装指南](#安装指南)
3. [配置说明](#配置说明)
4. [使用指南](#使用指南)
5. [数据库设置](#数据库设置)
6. [常见问题](#常见问题)
---
## 简介
ERPAuto 是一个专为 ERP 系统设计的自动化工具,主要功能包括:
- **数据提取**:自动从 ERP 系统批量下载物料计划数据为 Excel 文件
- **物料清理**:自动删除指定订单的物料代码
- **数据库存储**:支持将提取的数据存储到 MySQL 或 SQL Server 数据库
---
## 安装指南
### 系统要求
- **操作系统**Windows 10/11, macOS 10.15+, Linux
- **内存**:至少 4GB RAM
- **Node.js**:版本 18 或更高
### 安装步骤
1. **下载应用**
- 从发布页面下载对应系统的安装包
- Windows: `ERPAuto-Setup-x.x.x.exe`
- macOS: `ERPAuto-x.x.x.dmg`
- Linux: `ERPAuto-x.x.x.AppImage`
2. **安装**
- Windows: 运行安装程序,按照提示完成安装
- macOS: 将应用拖拽到 Applications 文件夹
- Linux: 赋予执行权限后运行
3. **首次运行**
- 启动应用
- 首次运行需要先配置 ERP 连接信息
---
## 配置说明
### ERP 连接配置
应用需要配置 ERP 系统的连接信息。在主界面点击「设置」->「ERP 配置」:
| 配置项 | 说明 | 示例 |
| ------- | -------------- | ----------------------------- |
| ERP URL | ERP 系统地址 | `https://192.168.1.100:8082/` |
| 用户名 | ERP 登录用户名 | `admin` |
| 密码 | ERP 登录密码 | `******` |
### 数据库配置(可选)
如需将提取的数据存储到数据库,需要配置数据库连接:
**MySQL 配置:**
| 配置项 | 默认值 | 说明 |
| -------- | ----------- | ---------------- |
| 主机 | `localhost` | MySQL 服务器地址 |
| 端口 | `3306` | MySQL 端口 |
| 用户名 | `root` | 数据库用户名 |
| 密码 | - | 数据库密码 |
| 数据库名 | `erpauto` | 数据库名称 |
**SQL Server 配置:**
| 配置项 | 默认值 | 说明 |
| -------- | ----------- | ------------------ |
| 服务器 | `localhost` | SQL Server 地址 |
| 端口 | `1433` | SQL Server 端口 |
| 用户名 | `sa` | 登录用户名 |
| 密码 | - | 登录密码 |
| 数据库 | `erpauto` | 数据库名称 |
| 加密 | `false` | 是否启用 SSL 加密 |
| 信任证书 | `true` | 是否信任服务器证书 |
---
## 使用指南
### 数据提取功能
**使用场景:** 从 ERP 系统批量下载多个订单的物料计划数据。
**操作步骤:**
1. 进入「数据提取」页面
2. 在左侧输入框中输入订单号,每行一个:
```
SC70202602120085
SC70202602120120
SC70202602120137
```
3. 设置批量大小(建议 100-500
4. 点击「开始提取」
5. 等待提取完成,查看结果统计
**提取结果说明:**
- **下载文件数**:成功下载的 Excel 文件数量
- **记录数**:提取的总记录数
- **错误数**:失败的订单数量
### 物料清理功能
**使用场景:** 删除指定订单中的特定物料代码。
**操作步骤:**
1. 进入「物料清理」页面
2. 输入订单号(每行一个)
3. 输入要删除的物料代码(每行一个)
4. **重要**:首次使用建议勾选「干运行模式」
5. 点击「开始清理」
6. 查看清理结果
**干运行模式:**
- 勾选后,系统仅预览将要删除的数据,不实际执行删除
- 建议先用干运行模式确认数据正确
- 确认无误后,取消勾选执行实际删除
**清理结果说明:**
- **处理订单数**:成功处理的订单数量
- **删除物料数**:实际删除的物料数量
- **跳过物料数**:未找到或跳过的物料数量
- **订单详情**:每个订单的详细处理结果
---
## 数据库设置
### MySQL 数据库初始化
```sql
CREATE DATABASE IF NOT EXISTS erpauto CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE erpauto;
-- 创建物料数据表
CREATE TABLE IF NOT EXISTS material_data (
id INT AUTO_INCREMENT PRIMARY KEY,
order_number VARCHAR(50) NOT NULL,
material_code VARCHAR(100) NOT NULL,
material_name VARCHAR(255),
quantity DECIMAL(10, 2),
unit VARCHAR(20),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_order (order_number),
INDEX idx_material (material_code)
);
```
### SQL Server 数据库初始化
```sql
CREATE DATABASE erpauto;
GO
USE erpauto;
GO
-- 创建物料数据表
CREATE TABLE material_data (
id INT IDENTITY(1,1) PRIMARY KEY,
order_number NVARCHAR(50) NOT NULL,
material_code NVARCHAR(100) NOT NULL,
material_name NVARCHAR(255),
quantity DECIMAL(10, 2),
unit NVARCHAR(20),
created_at DATETIME DEFAULT GETDATE()
);
GO
CREATE INDEX idx_order ON material_data(order_number);
CREATE INDEX idx_material ON material_data(material_code);
GO
```
---
## 常见问题
### 1. 无法登录 ERP 系统
**问题描述:** 点击登录后提示认证失败
**解决方法:**
1. 确认用户名和密码正确
2. 检查 ERP 系统是否可访问
3. 确认账号有足够权限
### 2. 提取时卡在加载中
**问题描述:** 点击提取后一直显示加载中
**解决方法:**
1. 检查网络连接
2. 减少批量大小
3. 确认 ERP 系统运行正常
4. 刷新页面后重试
### 3. 物料清理无数据
**问题描述:** 清理时显示没有可删除的数据
**解决方法:**
1. 确认订单号正确
2. 确认物料代码在该订单中存在
3. 先用干运行模式查看是否有匹配数据
### 4. 数据库连接失败
**问题描述:** 无法连接到数据库
**解决方法:**
1. 确认数据库服务已启动
2. 检查数据库配置信息
3. 确认防火墙允许数据库端口
4. 测试数据库连接:
```bash
# MySQL
mysql -h localhost -u root -p
# SQL Server
sqlcmd -S localhost -U sa
```
### 5. 应用闪退
**问题描述:** 应用启动后立即关闭
**解决方法:**
1. 查看日志文件获取错误信息
2. 重新安装应用
3. 确认系统满足最低要求
4. 尝试以管理员身份运行
---
## 技术支持
如有其他问题,请联系技术支持团队或提交 Issue。

View File

@@ -1,139 +0,0 @@
# 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)

View File

@@ -1,179 +0,0 @@
# 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 当前实现就能正确识别并使用浏览器。

View File

@@ -1,199 +0,0 @@
# 构建与发布流程
本文档说明 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>
```
只有在排查问题或需要特殊处理时,再退回分步命令。

View File

@@ -1,86 +0,0 @@
# 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`

View File

@@ -1,985 +0,0 @@
# 物料清理模块 - 订单错误收集逻辑分析
本文档详细分析了 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 获取 | - |

View File

@@ -1,291 +0,0 @@
# CleanerPage User Scope Fix
**Issue**: User users were affecting other users' data when using "取消" and "确认删除" buttons
**Date**: 2026-03-03
**Branch**: `fix/cleaner-user-scope`
---
## Problem Analysis
### Bug Description
For **User type (non-Admin)** users:
1. The table shows only materials assigned to the current user (filtered by `filteredResults`)
2. Clicking "取消" (Uncheck All) was unchecking **ALL** materials in `validationResults`, including invisible ones
3. Clicking "确认删除" (Confirm Deletion) processed **ALL** materials in `validationResults`, not just visible ones
4. This caused User A to delete User B's materials that User A never saw!
### Root Causes
#### 1. "取消" Button (Line 420)
```typescript
// ❌ WRONG: Clears ALL selected items
onClick={() => setSelectedItems(new Set())}
```
#### 2. `handleConfirmDeletion` Function (Line 165)
```typescript
// ❌ WRONG: Iterates ALL validation results
for (const result of validationResults) {
// Processes items user can't even see!
}
```
### Data Flow
```mermaid
graph TB
subgraph "Backend"
A[validationResults<br/>1000 items] --> B[User Filter<br/>currentUsername]
end
subgraph "Frontend Display"
B --> C[filteredResults<br/>100 items visible]
C --> D[Table Display]
end
subgraph "Bug Behavior (BEFORE FIX)"
E[取消 Button] --> F[Clears selectedItems<br/>for ALL 1000 items ❌]
G[确认删除 Button] --> H[Processes ALL 1000 items ❌]
H --> I[Deletes User B's data ❌]
end
subgraph "Fixed Behavior (AFTER FIX)"
E2[取消 Button] --> F2[Clears only visible<br/>100 items ✅]
G2[确认删除 Button] --> H2[Processes only<br/>100 items ✅]
H2 --> I2[Only affects User A ✅]
end
```
---
## Solution
### Fix 1: "取消" Button - Only Uncheck Visible Items
**File**: `src/renderer/src/pages/CleanerPage.tsx:419-432`
```typescript
<button
onClick={() => {
// Only uncheck items that are visible in filteredResults
const visibleCodes = new Set(filteredResults.map((r) => r.materialCode))
setSelectedItems((prev) => {
const newSet = new Set(prev)
for (const code of visibleCodes) {
newSet.delete(code)
}
return newSet
})
}}
className="text-xs bg-white border border-slate-300 text-slate-700 px-2.5 py-1.5 rounded shadow-sm hover:bg-slate-50 flex items-center gap-1"
>
<Square size={14} className="text-slate-400" />
</button>
```
**What Changed**:
- Before: `setSelectedItems(new Set())` - clears everything
- After: Iterates through `filteredResults` and removes only visible items from `selectedItems`
- Preserves selections for items not currently visible (e.g., other users' data)
### Fix 2: `handleConfirmDeletion` - Only Process Visible Items (Non-Admin)
**File**: `src/renderer/src/pages/CleanerPage.tsx:158-222`
```typescript
const handleConfirmDeletion = async () => {
// For non-admin users, only process visible filtered results
// For admin users, process all validation results
const resultsToProcess = isAdmin ? validationResults : filteredResults
if (resultsToProcess.length === 0) return alert('没有可处理的数据')
const materialsToUpsert: { materialCode: string; managerName: string }[] = []
const materialsToDelete: string[] = []
const missingManager: string[] = []
for (const result of resultsToProcess) {
// ... rest of processing logic
}
// ...
}
```
**What Changed**:
- Before: `for (const result of validationResults)` - processes all 1000 items
- After: `for (const result of resultsToProcess)` where:
- `Admin` → processes `validationResults` (all items)
- `User` → processes only `filteredResults` (visible items)
---
## Testing Scenarios
### Scenario 1: User Unchecks Own Data Only
**Setup**:
- User A logs in (non-Admin)
- 100 materials visible (assigned to User A)
- 900 materials invisible (assigned to other users)
- All 1000 materials are initially checked
**Actions**:
1. User A clicks "取消"
2. Table shows all checkboxes unchecked
**Expected**:
- ✅ User A's 100 materials are unchecked
- ✅ Other users' 900 materials **remain checked** (not affected)
**Verification**:
```typescript
// Before fix: selectedItems.size === 0
// After fix: selectedItems.size === 900 (other users' items still checked)
```
### Scenario 2: User Confirms Deletion
**Setup**:
- User A logs in (non-Admin)
- User A unchecks 50 of their 100 materials
- 50 items checked (User A's)
- 900 items checked (other users')
**Actions**:
1. User A clicks "确认删除"
2. Confirm dialog shows: "写入/更新 50 条记录"
**Expected**:
- ✅ Only User A's 50 materials are upserted to database
- ✅ Other users' 900 materials are **NOT touched**
- ✅ No materials are deleted (since other users' items aren't processed)
### Scenario 3: Admin Behavior Unchanged
**Setup**:
- Admin logs in
- All 1000 materials visible
- All filtered by selected managers
**Actions**:
1. Admin clicks "取消" → all visible items unchecked
2. Admin clicks "确认删除" → processes all filtered items
**Expected**:
- ✅ Admin behavior unchanged (can manage all data)
- ✅ Admin can still filter by managers and process filtered results
---
## Security & Scope Implications
### Before Fix (Vulnerability)
```mermaid
flowchart LR
UserA[User A] --> Sees[Sees 100 items]
UserB[User B] --> Sees2[Sees 900 items]
Sees --> Clicks[Clicks 取消 + 确认删除]
Clicks --> Deletes[Deletes ALL 1000 items ❌]
Deletes --> Impact[User B loses data ❌]
```
### After Fix (Secure)
```mermaid
flowchart LR
UserA[User A] --> Sees[Sees 100 items]
UserB[User B] --> Sees2[Sees 900 items]
Sees --> Clicks[Clicks 取消 + 确认删除]
Clicks --> Deletes[Deletes 100 items ✅]
Sees2 --> Independent[User B's data independent ✅]
Deletes --> Safe[User scope isolation ✅]
```
---
## Code Changes Summary
### File: `src/renderer/src/pages/CleanerPage.tsx`
| Line | Change | Description |
| ------- | -------------------------------- | ----------------------------------------- |
| 419-432 | Modified "取消" button | Only uncheck visible filteredResults |
| 158-222 | Modified `handleConfirmDeletion` | Use `resultsToProcess` based on `isAdmin` |
### Variables Used
- `validationResults`: All materials from backend (1000 items)
- `filteredResults`: Materials after user/manager filtering (100 items for User A)
- `selectedItems`: Set of checked material codes
- `isAdmin`: Boolean, true for Admin users
- `currentUsername`: Current logged-in username
---
## Verification Steps
1. **Test as User A**:
```bash
# Login as user1
npm run dev
# Navigate to CleanerPage
# Verify only user1's materials are visible
# Click "取消" → only visible items unchecked
# Check selectedItems size = other users' checked items
```
2. **Test as User B**:
```bash
# Login as user2
# Verify user1's changes didn't affect user2's data
# All user2's materials should still be intact
```
3. **Test as Admin**:
```bash
# Login as admin
# Verify can still see and manage all materials
# "取消" and "确认删除" work on all filtered results
```
---
## Related Files
- **Implementation**: `src/renderer/src/pages/CleanerPage.tsx`
- **Related**: `src/main/ipc/validation-handler.ts` (backend matching logic)
- **Related**: `docs/user-override-match-feature.md` (user override matching)
---
## Future Improvements
1. **Add Confirmation Dialog for Scope**: Show user how many items will be affected
2. **Add Audit Logging**: Log which user modified which materials
3. **Add Warning for Large Operations**: Warn if user is about to delete many items
4. **Backend Validation**: Add backend check to prevent cross-user data modification
---
**Document End**

File diff suppressed because it is too large Load Diff

View File

@@ -1,236 +0,0 @@
# 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. **更新测试** - 验证新的登录判定逻辑

View File

@@ -1,241 +0,0 @@
# 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

View File

@@ -1,93 +0,0 @@
# ERPAuto 优化执行计划文档
基于《ERPAuto 优化建议与规范指南》,本文档规划了具体的分阶段重构与优化执行步骤。每个阶段遵循“渐进式重构”原则,保证在优化期间项目依然可运行、可测试。
## 阶段一:基础设施建设 (Error & Logging)
在进行大规模业务逻辑重构前,首先建立坚实的基础设施,以便后续问题排查与数据追踪。
1. **引入并配置统一日志库**
- **目标**: 替换分散的 `console.log`
- **执行**:
- 安装 `winston` (针对 Node.js 主进程)。
-`src/main/services/logger` 创建单例日志记录器。
- 配置双通道输出Console (Dev 环境) 与 File (生产环境按天切割,如 `%AppData%/ERPAuto/logs/app-%DATE%.log`)。
2. **定义全局错误类型与 IPC 拦截器**
- **目标**: 规范前后端错误抛出与展示体系。
- **执行**:
-`src/main/types/errors.ts` 定义 `BaseError`, `ErpConnectionError`, `DatabaseQueryError`
-`src/main/ipc/index.ts` 中封装高阶函数 `withErrorHandling`。所有 IPC Handler 统一用此高阶函数包裹,将捕获的错误统一转为 `{ success: false, error: string, code: string }` 结构。
## 阶段二:数据层抽象与 ORM 改造
彻底解决 SQL 语句散落和不同数据库适配成本高的问题。
1. **选型并引入 ORM**
- **目标**: 弃用原生 SQL 拼接。
- **执行**:
- 引入 `Prisma``TypeORM`。结合当前多数据源 (MySQL + SQL Server) 需求,推荐 `TypeORM` 因为其在运行时切换数据源更为灵活。
2. **创建 Repository 抽象**
- **目标**: 隔离数据库实现细节。
- **执行**:
- 建立 `src/main/services/database/repositories` 目录。
- 为业务实体 (如 Users, ExtractedPlans 等) 编写 Repository 类接口。
- 将原有 `mysql2``mssql` 的调用逐步迁移至 Repository 中。
3. **Zod 运行时校验**
- **目标**: 保护 IPC 边界免受恶意/格式错误的 payload 影响。
- **执行**:
- 安装 `zod`
- 对所有的 IPC Handler 的入参(如 `ExtractorInput`, `LoginRequest`)添加 `zod` Schema 校验。
## 阶段三React 渲染层规范化
提高前端代码复用率,解耦视图与逻辑。
1. **提取 IPC Hooks**
- **目标**: 清理组件中的大段异步调用。
- **执行**:
-`src/renderer/src/hooks` 创建 `useExtractor.ts`, `useCleaner.ts`
- 使用 React 的 `useState` 包装 `window.api` 调用,返回 `{ loading, data, error, execute }`
2. **状态管理引入 (Zustand)**
- **目标**: 解决跨组件状态共享 (如全局报错信息、用户认证状态)。
- **执行**:
- 安装 `zustand`
- 创建 `useUserStore``useAppStore`
3. **UI 组件库/公共样式提取**
- **目标**: 统一 Tailwind 设计语言。
- **执行**:
- 将高频使用的 Button, Input, Modal 抽取到 `src/renderer/src/components/ui/`
## 阶段四:自动化服务解耦 (Domain Logic)
将基于 Playwright 的具体执行细节与业务调度逻辑分离。
1. **重构 ERP 自动化服务 (`cleaner.ts` / `extractor.ts`)**
- **目标**: 遵循单一职责原则。
- **执行**:
- 抽象出 `ErpBrowserManager` (负责浏览器启动与资源回收)。
- 抽象出 `ErpAuthService` (专职处理登录和 Session)。
- `extractor.ts` 将只负责调度:调用 Browser -> Auth -> Navigate -> Download -> Excel Parse。
2. **加强 TypeScript 严格模式**
- **目标**: 提升代码健壮性。
- **执行**:
- 开启 `tsconfig.json` 中的 `"strict": true``"noImplicitAny": true`
- 全局清理并替换现存的 `any` 为具体的 Type 或 `unknown` 并添加类型保护。
## 阶段五:测试覆盖率补充
确保核心流程不被破坏。
1. **补充关键服务的单元测试**
- **目标**: 防止复杂转换逻辑衰退。
- **执行**:
- 使用 `Vitest` 测试所有的 Repository (使用内存数据库/Mock) 和工具函数 (如 ExcelParser)。
2. **核心业务 E2E 测试**
- **目标**: 确保 IPC 及 Electron 整体运行顺畅。
- **执行**:
- 使用 Playwright 针对 Electron 的测试框架 (`@playwright/test` 的 electron 插件) 编写主流程测试:登录 -> 点击提取 -> 验证本地结果文件生成。
## 执行建议与回顾
- 每个阶段应作为一个单独的 Git 分支 (Feature Branch) 开发。
- 完成一个阶段后,必须全量运行既有的测试套件并通过 `npm run typecheck`
- 本文档可作为每次 PR Review 的检查清单使用。

View File

@@ -1,487 +0,0 @@
# 配置保存优化设计文档
**日期:** 2026-03-03
**分支:** fix/settings-partial-save
**状态:** 设计阶段
---
## 问题描述
当前设置界面只能配置 3 个字段ERP URL、用户名、密码但保存后会意外覆盖 `.env` 文件中的其他配置项(如 `DB_TYPE``VALIDATION_DATA_SOURCE` 等),导致这些字段被重置为默认值或丢失。
### 根本原因
`config-manager.ts:437-483` 中,`saveAllSettings()` 方法无条件覆盖所有配置类别。当 UI 只发送部分字段时,未包含的字段会被设置为 `undefined` 或默认值,导致原有配置丢失。
**数据流问题:**
```
SettingsPage (只修改 ERP URL)
↓ 发送完整的 settings 对象
ConfigManager.saveAllSettings()
↓ 覆盖所有字段到缓存
.env 文件被完全重写(丢失未被 UI 包含的字段)
```
---
## 解决方案
采用 **方案 A深度合并+ 方案 C字段白名单** 的组合策略:
### 核心策略
1. **部分更新**:只更新传入的字段,保留其他字段不变
2. **白名单验证**:只允许 UI 支持的字段被修改
3. **备份机制**:保存前备份,失败可回滚
4. **安全日志**:记录所有配置变更操作
---
## 架构设计
### 数据流
```
┌─────────────────┐
│ SettingsPage │
│ (Renderer) │
└────────┬────────┘
│ 只发送支持的字段
│ { erp: { url, username, password } }
┌─────────────────┐
│ Settings Handler│
│ (IPC Bridge) │
└────────┬────────┘
│ 传递部分配置 (Partial<SettingsData>)
┌─────────────────────────────┐
│ ConfigManager │
│ ┌─────────────────────┐ │
│ │ 1. 验证字段白名单 │ │
│ │ 2. 深度合并当前配置 │ │
│ │ 3. 备份 .env 文件 │ │
│ │ 4. 原子写入新配置 │ │
│ └─────────────────────┘ │
└─────────────────────────────┘
```
### 改动点
| 文件 | 改动类型 | 说明 |
| -------------------------------------------- | -------- | ------------------------------------------------ |
| `src/main/services/config/config-manager.ts` | 核心 | 新增 `savePartialSettings()`、深度合并、备份机制 |
| `src/main/ipc/settings-handler.ts` | 调整 | IPC 参数改为 `Partial<SettingsData>` |
| `src/renderer/src/pages/SettingsPage.tsx` | 优化 | 只发送 UI 支持的字段 |
---
## 核心实现
### 1. 深度合并工具函数
```typescript
/**
* 深度合并两个对象,只更新 target 中存在的字段
* 保留 source 中 target 没有的字段
*/
function deepMerge<T>(source: T, target: Partial<T>): T {
const result = { ...source }
for (const key in target) {
if (key in target) {
const targetValue = target[key]
const sourceValue = result[key]
if (isObject(targetValue) && isObject(sourceValue)) {
result[key] = deepMerge(sourceValue, targetValue)
} else if (targetValue !== undefined) {
result[key] = targetValue as T[Extract<keyof T, string>]
}
}
}
return result
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
```
### 2. 字段白名单验证
```typescript
/**
* 定义 UI 可编辑的字段路径
* 使用点号表示法:'section.field'
*/
const UI_EDITABLE_FIELDS: string[] = [
'erp.url',
'erp.username',
'erp.password'
// 未来扩展:
// 'database.dbType',
// 'paths.dataDir',
// ...
]
/**
* 验证配置更新是否只包含允许的字段
*/
function validateEditableFields(settings: Partial<SettingsData>): {
valid: boolean
invalidFields: string[]
} {
const invalidFields: string[] = []
for (const [section, values] of Object.entries(settings)) {
if (values && typeof values === 'object') {
for (const field of Object.keys(values)) {
const fieldPath = `${section}.${field}`
if (!UI_EDITABLE_FIELDS.includes(fieldPath)) {
invalidFields.push(fieldPath)
}
}
}
}
return {
valid: invalidFields.length === 0,
invalidFields
}
}
```
### 3. 部分保存方法
```typescript
/**
* 保存部分配置(只更新传入的字段)
*/
public async savePartialSettings(
settings: Partial<SettingsData>
): Promise<{ success: boolean; error?: string }> {
try {
// 步骤 1: 验证字段白名单
const validation = validateEditableFields(settings)
if (!validation.valid) {
log.warn('Attempted to save non-editable fields', {
invalidFields: validation.invalidFields
})
return {
success: false,
error: `包含不允许修改的字段:${validation.invalidFields.join(', ')}`
}
}
// 步骤 2: 读取当前配置
const currentSettings = this.getAllSettings()
// 步骤 3: 深度合并
const mergedSettings = deepMerge(currentSettings, settings)
// 步骤 4: 备份并保存
const backupSuccess = await this.backupEnvFile()
if (!backupSuccess) {
log.warn('Failed to backup .env file, proceeding with caution')
}
const saveSuccess = await this.saveAllSettings(mergedSettings)
if (!saveSuccess) {
// 保存失败,尝试恢复备份
await this.restoreBackup()
return {
success: false,
error: '保存配置失败,已恢复原配置'
}
}
log.info('Settings saved successfully', {
updatedFields: Object.keys(settings)
})
return { success: true }
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
log.error('Error in savePartialSettings', { error: message })
await this.restoreBackup()
return {
success: false,
error: `保存配置时发生错误:${message}`
}
}
}
```
### 4. 备份与恢复机制
```typescript
private backupPath: string
constructor() {
// ...
this.backupPath = path.resolve(__dirname, '../../.env.backup')
}
/**
* 备份当前 .env 文件
*/
private async backupEnvFile(): Promise<boolean> {
try {
if (fs.existsSync(this.envPath)) {
fs.copyFileSync(this.envPath, this.backupPath)
log.debug('Backup created', { path: this.backupPath })
return true
}
return false
} catch (error) {
log.error('Failed to backup .env file', { error })
return false
}
}
/**
* 从备份恢复 .env 文件
*/
private async restoreBackup(): Promise<boolean> {
try {
if (fs.existsSync(this.backupPath)) {
fs.copyFileSync(this.backupPath, this.envPath)
await this.loadEnvFile() // 重新加载到缓存
log.info('Restored from backup')
return true
}
return false
} catch (error) {
log.error('Failed to restore backup', { error })
return false
}
}
```
---
## IPC 调用链路调整
### settings-handler.ts
```typescript
ipcMain.handle(
'settings:saveSettings',
async (_event, settings: Partial<SettingsData>): Promise<SaveSettingsResult> => {
try {
log.info('Saving settings', {
sections: Object.keys(settings)
})
// 使用新的部分保存方法
const result = await configManager.savePartialSettings(settings)
if (result.success) {
log.info('Settings saved successfully')
return { success: true }
} else {
log.warn('Failed to save settings', {
error: result.error
})
return {
success: false,
error: result.error || '保存设置失败'
}
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
log.error('Error saving settings', { error: message })
return {
success: false,
error: `保存设置失败:${message}`
}
}
}
)
```
**关键改动:**
- 参数类型从 `SettingsData` 改为 `Partial<SettingsData>`
- 调用 `savePartialSettings()` 替代 `saveAllSettings()`
---
## 前端优化(双重保险)
### SettingsPage.tsx
```typescript
const handleSaveSettings = async () => {
try {
// 只发送 UI 支持的字段(双重保险)
const partialSettings = {
erp: {
url: settings.erp?.url,
username: settings.erp?.username,
password: settings.erp?.password
}
}
const result = await window.electron.settings.saveSettings(partialSettings)
if (result.success) {
setIsModified(false)
showMessage('success', '设置保存成功')
} else {
showMessage('error', result.error || '保存失败')
}
} catch (error) {
showMessage('error', '保存设置时发生错误')
}
}
```
---
## 测试策略
### 单元测试场景
```typescript
describe('ConfigManager.savePartialSettings', () => {
it('应该只更新指定的字段,保留其他字段', async () => {
const initial = {
erp: { url: 'http://old.com', username: 'user1' },
database: { dbType: 'mysql' }
}
const update = {
erp: { url: 'http://new.com' }
}
await configManager.savePartialSettings(update)
const result = configManager.getAllSettings()
expect(result.erp.url).toBe('http://new.com')
expect(result.erp.username).toBe('user1') // 保留
expect(result.database.dbType).toBe('mysql') // 保留
})
it('应该拒绝未授权的字段更新', async () => {
const invalidUpdate = {
database: { dbType: 'postgres' }
}
const result = await configManager.savePartialSettings(invalidUpdate)
expect(result.success).toBe(false)
expect(result.error).toContain('不允许修改')
})
it('保存失败时应该恢复备份', async () => {
jest.spyOn(fs, 'writeFileSync').mockImplementation(() => {
throw new Error('Disk full')
})
const result = await configManager.savePartialSettings({ erp: { url: 'x' } })
expect(result.success).toBe(false)
})
})
```
### 手动验证步骤
1. 打开 `.env`,记录所有字段值
2. 打开设置页面,只修改 ERP URL
3. 点击保存
4. 检查 `.env`:只有 `ERP_URL` 改变,其他字段保持原值
---
## 未来扩展性
### 1. 白名单配置化
当设置页面需要支持更多配置时:
```typescript
const UI_EDITABLE_FIELDS: string[] = [
'erp.url',
'erp.username',
'erp.password',
'database.dbType', // 新增
'paths.dataDir', // 新增
'extraction.batchSize' // 新增
// ...
]
```
### 2. 按用户角色分级
```typescript
const EDITABLE_FIELDS_BY_ROLE: Record<UserType, string[]> = {
Admin: ['*'],
User: ['erp.url', 'erp.username', 'erp.password'],
Guest: []
}
function validateEditableFields(settings: Partial<SettingsData>, userType: UserType) {
const allowed = EDITABLE_FIELDS_BY_ROLE[userType]
// 验证逻辑...
}
```
### 3. 配置变更审计
```typescript
interface ConfigChange {
timestamp: Date
user: string
field: string
oldValue: string
newValue: string
}
```
---
## 实施计划
下一步将创建详细的实施计划,包括:
1. 在 ConfigManager 中添加深度合并和验证函数
2. 实现 `savePartialSettings()` 方法
3. 添加备份与恢复机制
4. 更新 IPC handler 调用
5. 前端优化(只发送必要字段)
6. 编写单元测试
7. 集成测试和手动验证
---
## 风险与缓解
| 风险 | 影响 | 缓解措施 |
| ---------------- | ---------- | ----------------------------- |
| 深度合并逻辑错误 | 配置错误 | 完善单元测试覆盖 |
| 备份文件权限问题 | 无法恢复 | 错误处理 + 日志 |
| 白名单漏配置 | 功能受限 | 清晰的文档 + 代码注释 |
| 并发保存冲突 | 数据不一致 | 单实例 ConfigManager + 文件锁 |
---
## 附录
### 相关文件
- `src/main/services/config/config-manager.ts` - 配置管理器
- `src/main/ipc/settings-handler.ts` - IPC 处理器
- `src/renderer/src/pages/SettingsPage.tsx` - 设置页面
- `src/main/types/settings.types.ts` - 类型定义
### 参考
- 当前问题:保存设置时 `.env` 中未包含的字段被覆盖
- 设计原则:安全优先、最小化修改、可扩展性

File diff suppressed because it is too large Load Diff

View File

@@ -1,130 +0,0 @@
# 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

View File

@@ -1,274 +0,0 @@
# 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` 进一步拆分,降低文件复杂度
- 将日志策略区分成“正式日志”和“诊断日志”
- 增加更多针对下载与安装阶段的单测
- 将完整构建发布链路整理为一键化脚本

View File

@@ -1,11 +0,0 @@
# 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.

View File

@@ -1,11 +0,0 @@
# 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.

View File

@@ -1,44 +0,0 @@
# 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 代理指导文档更聚焦、更易维护。

View File

@@ -1,62 +0,0 @@
# Settings Partial Save Feature
## Overview
The settings system now implements partial save functionality to prevent unintended overwrites of configuration values.
## How It Works
1. **Field Whitelist**: Only fields exposed in the UI can be modified
2. **Deep Merge**: Updates are merged with existing config, preserving unmodified fields
3. **Backup & Rollback**: Config is backed up before save; failures trigger automatic rollback
## Editable Fields
Currently editable via UI:
- `erp.url` - ERP system URL
- `erp.username` - ERP login username
- `erp.password` - ERP login password
## Adding New Editable Fields
To add a new field to the UI:
1. Add field to whitelist in `src/main/services/config/config-manager.ts`:
```typescript
const UI_EDITABLE_FIELDS: string[] = [
'erp.url',
'erp.username',
'erp.password',
'database.dbType' // Add new field here
]
```
2. Add UI input in `src/renderer/src/pages/SettingsPage.tsx`
3. Update `handleSaveSettings` to include the new field
## API
### savePartialSettings(settings: Partial<SettingsData>)
Saves only the provided fields, preserving all existing configuration.
**Returns:** `{ success: boolean, error?: string }`
**Validation:**
- Checks whitelist before applying changes
- Returns error for unauthorized fields
## Error Handling
- **Unauthorized field**: Returns error message listing invalid fields
- **Save failure**: Automatically restores from backup
- **Backup failure**: Logs warning, continues with save
## Backup File
Location: `.env.backup` (in project root)
Created before every save operation. Used for rollback on failure.

View File

@@ -1,927 +0,0 @@
# 系统设置保存按钮工作流程分析
# System Settings Save Button Workflow Analysis
## 文档概述 / Document Overview
本文档详细分析了 ERPAuto 系统设置界面中保存按钮的完整工作流程,包括架构设计、数据流转、技术实现细节以及错误处理机制。
This document provides a comprehensive analysis of the save button workflow in the ERPAuto system settings interface, including architecture design, data flow, technical implementation details, and error handling mechanisms.
---
## 目录 / Table of Contents
1. [架构概览](#架构概览)
2. [数据流程图](#数据流程图)
3. [组件详解](#组件详解)
4. [数据结构](#数据结构)
5. [错误处理机制](#错误处理机制)
6. [安全考虑](#安全考虑)
7. [技术实现细节](#技术实现细节)
---
## 架构概览 / Architecture Overview
### 系统架构 / System Architecture
系统设置保存功能采用典型的 Electron 三层架构模式:
The system settings save functionality follows the classic Electron three-tier architecture pattern:
```mermaid
graph TB
subgraph "Renderer Process 渲染进程"
UI[SettingsPage.tsx<br/>UI Component]
end
subgraph "Preload Script 预加载脚本"
BRIDGE[contextBridge API<br/>Security Boundary]
end
subgraph "Main Process 主进程"
IPC[settings-handler.ts<br/>IPC Handler]
SERVICE[ConfigManager.ts<br/>Configuration Service]
FILE[.env File<br/>Persistent Storage]
end
UI -->|IPC Invoke| BRIDGE
BRIDGE -->|Secure Channel| IPC
IPC -->|Business Logic| SERVICE
SERVICE -->|Write| FILE
FILE -->|Confirm| SERVICE
SERVICE -->|Result| IPC
IPC -->|Response| BRIDGE
BRIDGE -->|Promise Resolve| UI
style UI fill:#e1f5ff
style BRIDGE fill:#fff4e1
style IPC fill:#ffe1f5
style SERVICE fill:#e1ffe1
style FILE fill:#f5f5f5
```
### 核心设计模式 / Core Design Patterns
1. **单向数据流**:数据从 UI → Main Process → File响应沿相反路径返回
2. **安全隔离**Preload 脚本作为安全桥梁,通过 `contextBridge` 暴露受限 API
3. **单例模式**ConfigManager 使用单例确保配置一致性
4. **缓存优先**:配置读取优先从内存缓存获取,写入时同步到磁盘
---
## 数据流程图 / Data Flow Diagrams
### 完整保存流程 / Complete Save Flow
```mermaid
sequenceDiagram
actor User as 用户 User
participant UI as SettingsPage.tsx
participant Preload as preload/index.ts
participant IPC as settings-handler.ts
participant Config as ConfigManager.ts
participant File as .env File
User->>UI: 点击保存按钮<br/>Click Save Button
activate UI
UI->>UI: handleSaveSettings()
Note over UI: 检查是否修改<br/>Check isModified
UI->>Preload: window.electron.settings<br/>.saveSettings(settings)
activate Preload
Preload->>IPC: ipcRenderer.invoke<br/>('settings:saveSettings', settings)
activate IPC
IPC->>IPC: 验证用户类型<br/>Validate User Type
IPC->>Config: configManager<br/>.saveAllSettings(settings)
activate Config
Config->>Config: 更新内存缓存<br/>Update Cache
Note over Config: set('erp.url', value)<br/>set('erp.username', value)<br/>... (40+ fields)
Config->>File: fs.writeFileSync<br/>(.env, content)
activate File
File-->>Config: true/false
deactivate File
Config-->>IPC: Promise<boolean>
deactivate Config
IPC-->>Preload: {success, error?}
deactivate IPC
Preload-->>UI: Promise resolve
deactivate Preload
alt 保存成功 / Save Success
UI->>UI: setIsModified(false)
UI->>User: 显示成功消息<br/>Show Success Message
else 保存失败 / Save Failed
UI->>User: 显示错误消息<br/>Show Error Message
end
deactivate UI
```
### 数据转换流程 / Data Transformation Flow
```mermaid
graph LR
subgraph "UI State"
STATE[Settings Interface<br/>settings.erp.url = 'https://...']
end
subgraph "Type Conversion"
T1[SettingsData Object<br/>TypeScript Interface]
end
subgraph "IPC Transport"
JSON[JSON Serialization<br/>String Transfer]
end
subgraph "Service Layer"
CACHE[Config Cache<br/>Map<string, string>]
end
subgraph "File System"
ENV[.env File Format<br/>KEY=VALUE]
end
STATE -->|Object| T1
T1 -->|JSON.stringify| JSON
JSON -->|Deserialize| T1
T1 -->|set key-value| CACHE
CACHE -->|Format| ENV
style STATE fill:#e1f5ff
style JSON fill:#fff4e1
style CACHE fill:#e1ffe1
style ENV fill:#f5f5f5
```
---
## 组件详解 / Component Details
### 1. 渲染进程 / Renderer Process
#### SettingsPage.tsx (`src/renderer/src/pages/SettingsPage.tsx`)
**主要职责 / Main Responsibilities:**
- 用户界面渲染和交互
- 本地状态管理settings, isModified, message
- 调用 IPC 通信
**关键函数 / Key Functions:**
```typescript
// 第 61-73 行 / Lines 61-73
const handleSaveSettings = async () => {
try {
const result = await window.electron.settings.saveSettings(settings as any)
if (result.success) {
setIsModified(false) // 清除修改标记
showMessage('success', '设置保存成功')
} else {
showMessage('error', result.error || '保存失败')
}
} catch (error) {
showMessage('error', '保存设置时发生错误')
}
}
```
**状态管理 / State Management:**
| 状态变量 | 类型 | 用途 |
| ------------ | ---------------- | ----------------------------------------------------------- |
| `settings` | `Settings` | 当前配置数据,结构为 `{ erp: { url, username, password } }` |
| `isModified` | `boolean` | 标记配置是否已修改,控制保存按钮启用状态 |
| `isLoading` | `boolean` | 加载状态,显示加载动画 |
| `message` | `object \| null` | 临时消息3秒后自动消失 |
**UI 交互逻辑 / UI Interaction Logic:**
```mermaid
stateDiagram-v2
[*] --> Loading: 组件挂载
Loading --> Ready: loadSettings()
Ready --> Modified: updateSettings()
Modified --> Modified: 继续修改
Modified --> Ready: 保存成功
Modified --> Error: 保存失败
Error --> Modified: 用户继续操作
Ready --> [*]: 组件卸载
note right of Modified
保存按钮启用
Save Button Enabled
end note
note right of Ready
保存按钮禁用
Save Button Disabled
end note
```
### 2. 预加载脚本 / Preload Script
#### preload/index.ts (`src/preload/index.ts`)
**主要职责 / Main Responsibilities:**
- 安全桥梁,暴露受限 API 到渲染进程
- 类型安全的 IPC 通道定义
**关键代码 / Key Code:**
```typescript
// 第 89-97 行 / Lines 89-97
settings: {
getUserType: () => ipcRenderer.invoke('settings:getUserType'),
getSettings: () => ipcRenderer.invoke('settings:getSettings'),
saveSettings: (settings: SettingsData) =>
ipcRenderer.invoke('settings:saveSettings', settings),
resetDefaults: () => ipcRenderer.invoke('settings:resetDefaults'),
testErpConnection: () => ipcRenderer.invoke('settings:testErpConnection'),
testDbConnection: () => ipcRenderer.invoke('settings:testDbConnection')
}
```
**安全隔离机制 / Security Isolation:**
```mermaid
graph TB
Renderer[Renderer Process<br/>Untrusted Context]
Preload[Preload Script<br/>Trusted Context]
Main[Main Process<br/>Trusted Context]
Renderer -->|window.electron| Preload
Preload -->|ipcRenderer.invoke| Main
Main -->|Validation| Preload
Preload -->|Return Promise| Renderer
style Renderer fill:#ffe1e1
style Preload fill:#e1ffe1
style Main fill:#e1e1ff
```
### 3. 主进程 / Main Process
#### settings-handler.ts (`src/main/ipc/settings-handler.ts`)
**主要职责 / Main Responsibilities:**
- IPC 通道注册和处理
- 权限验证(基于用户类型)
- 业务逻辑协调
**保存设置处理函数 / Save Settings Handler:**
```typescript
// 第 83-102 行 / Lines 83-102
ipcMain.handle(
'settings:saveSettings',
async (_event, settings: SettingsData): Promise<SaveSettingsResult> => {
try {
log.info('Saving settings')
const success = await configManager.saveAllSettings(settings)
if (success) {
log.info('Settings saved successfully')
return { success: true }
} else {
log.warn('Failed to save settings')
return { success: false, error: '保存设置失败' }
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
log.error('Error saving settings', { error: message })
return { success: false, error: `保存设置失败:${message}` }
}
}
)
```
**用户类型过滤 / User Type Filtering:**
```typescript
// 第 31-54 行 / Lines 31-54
function filterSettingsByUserType(settings: SettingsData, userType: UserType): SettingsData {
if (userType === 'Admin') {
return settings // Admin 获取完整配置
}
// User 用户获取受限配置
return {
erp: {
username: settings.erp.username,
password: settings.erp.password,
headless: settings.erp.headless,
url: settings.erp.url,
ignoreHttpsErrors: settings.erp.ignoreHttpsErrors,
autoCloseBrowser: settings.erp.autoCloseBrowser
},
paths: settings.paths,
execution: settings.execution,
database: settings.database,
extraction: settings.extraction,
validation: settings.validation,
ui: settings.ui
}
}
```
**权限控制矩阵 / Permission Control Matrix:**
| 功能 / Feature | Admin | User | Guest |
| -------------- | ----- | ------- | ----- |
| 查看所有设置 | ✅ | ⚠️ 部分 | ❌ |
| 保存设置 | ✅ | ✅ | ❌ |
| 恢复默认值 | ✅ | ❌ | ❌ |
| 测试 ERP 连接 | ✅ | ✅ | ❌ |
| 测试数据库连接 | ✅ | ✅ | ❌ |
### 4. 配置管理服务 / Configuration Manager Service
#### config-manager.ts (`src/main/services/config/config-manager.ts`)
**主要职责 / Main Responsibilities:**
- .env 文件读写
- 配置缓存管理
- 默认值管理
- 类型转换和验证
**类结构 / Class Structure:**
```typescript
export class ConfigManager {
private static instance: ConfigManager | null = null // 单例模式
private envPath: string // .env 文件路径
private configCache: Map<string, string> // 内存缓存
private initialized: boolean = false // 初始化标记
// 单例获取方法
public static getInstance(): ConfigManager
// 配置读取
public get(key: string, defaultValue?: string): string | undefined
public getBoolean(key: string, defaultValue?: boolean): boolean
public getNumber(key: string, defaultValue?: number): number
// 配置写入
public set(key: string, value: string | number | boolean): void
// 持久化
public async save(): Promise<boolean>
// 高级操作
public getAllSettings(): SettingsData
public async saveAllSettings(settings: SettingsData): Promise<boolean>
public resetToDefaults(): SettingsData
}
```
**保存详细流程 / Save Detailed Flow:**
```mermaid
graph TD
START[saveAllSettings] --> STEP1[更新 ERP 配置 6 字段]
STEP1 --> STEP2[更新数据库配置 7 字段]
STEP2 --> STEP3[更新路径配置 3 字段]
STEP3 --> STEP4[更新提取配置 5 字段]
STEP4 --> STEP5[更新校验配置 5 字段]
STEP5 --> STEP6[更新 UI 配置 3 字段]
STEP6 --> STEP7[更新执行配置 1 字段]
STEP7 --> SAVE[调用 save 方法]
SAVE --> BUILD[构建 .env 内容]
BUILD --> WRITE[写入文件系统]
WRITE --> CHECK{检查结果}
CHECK -->|成功| SUCCESS[返回 true]
CHECK -->|失败| FAILURE[返回 false]
```
**.env 文件格式 / .env File Format:**
```bash
# ===========================
# ERP 系统配置
# ===========================
ERP_URL=https://68.11.34.30:8082/
ERP_USERNAME=
ERP_PASSWORD=
ERP_HEADLESS=true
ERP_IGNORE_HTTPS_ERRORS=true
ERP_AUTO_CLOSE_BROWSER=true
# ===========================
# 数据库配置 - MySQL
# ===========================
DB_TYPE=mysql
DB_NAME=BLD_DB
DB_USERNAME=remote_user
DB_PASSWORD=
DB_MYSQL_HOST=192.168.31.83
DB_MYSQL_PORT=3306
DB_MYSQL_CHARSET=utf8mb4
# ===========================
# 路径配置
# ===========================
PATH_DATA_DIR=D:/python/playwrite/data/
PATH_DEFAULT_OUTPUT=离散备料计划维护_合并.xlsx
PATH_VALIDATION_OUTPUT=物料状态校验结果.xlsx
# ... 更多配置节
```
---
## 数据结构 / Data Structures
### SettingsData 接口 / Interface Definition
**类型定义位置 / Type Definition Location:**
`src/main/types/settings.types.ts` (第 136-151 行)
```typescript
export interface SettingsData {
erp: ErpConfig
database: DatabaseConfig
paths: PathsConfig
extraction: ExtractionConfig
validation: ValidationConfig
ui: UiConfig
execution: ExecutionConfig
}
```
### 完整数据结构树 / Complete Data Structure Tree
```mermaid
graph TB
Settings[SettingsData]
Settings --> Erp[ErpConfig]
Erp --> Erp1[url: string]
Erp --> Erp2[username: string]
Erp --> Erp3[password: string]
Erp --> Erp4[headless: boolean]
Erp --> Erp5[ignoreHttpsErrors: boolean]
Erp --> Erp6[autoCloseBrowser: boolean]
Settings --> DB[DatabaseConfig]
DB --> DB1[dbType: mysql or sqlserver]
DB --> DB2[server: string]
DB --> DB3[mysqlHost: string]
DB --> DB4[mysqlPort: number]
DB --> DB5[database: string]
DB --> DB6[username: string]
DB --> DB7[password: string]
Settings --> Paths[PathsConfig]
Paths --> Paths1[dataDir: string]
Paths --> Paths2[defaultOutput: string]
Paths --> Paths3[validationOutput: string]
Settings --> Extract[ExtractionConfig]
Extract --> Extract1[batchSize: number]
Extract --> Extract2[verbose: boolean]
Extract --> Extract3[autoConvert: boolean]
Extract --> Extract4[mergeBatches: boolean]
Extract --> Extract5[enableDbPersistence: boolean]
Settings --> Valid[ValidationConfig]
Valid --> Valid1[dataSource: ValidationDataSource]
Valid --> Valid2[batchSize: number]
Valid --> Valid3[matchMode: MatchMode]
Valid --> Valid4[enableCrud: boolean]
Valid --> Valid5[defaultManager: string]
Settings --> UI[UiConfig]
UI --> UI1[fontFamily: string]
UI --> UI2[fontSize: number]
UI --> UI3[productionIdInputWidth: number]
Settings --> Exec[ExecutionConfig]
Exec --> Exec1[dryRun: boolean]
style Settings fill:#e1f5ff
style Erp fill:#ffe1f5
style DB fill:#e1ffe1
style Paths fill:#fff4e1
style Extract fill:#f5e1ff
style Valid fill:#ffe1e1
style UI fill:#e1f5ff
style Exec fill:#f5f5f5
```
### IPC 通信数据格式 / IPC Communication Data Format
**请求格式 / Request Format:**
```json
{
"erp": {
"url": "https://68.11.34.30:8082/",
"username": "admin",
"password": "password123",
"headless": true,
"ignoreHttpsErrors": true,
"autoCloseBrowser": true
},
"database": { ... },
"paths": { ... },
"extraction": { ... },
"validation": { ... },
"ui": { ... },
"execution": { ... }
}
```
**响应格式 / Response Format:**
```json
// 成功 / Success
{
"success": true
}
// 失败 / Failure
{
"success": false,
"error": "保存设置失败Access denied"
}
```
---
## 错误处理机制 / Error Handling Mechanism
### 错误处理层次 / Error Handling Layers
```mermaid
graph TB
subgraph "UI Layer"
UI_TRY[try-catch in handleSaveSettings]
UI_MSG[showMessage display]
end
subgraph "IPC Layer"
IPC_TRY[try-catch in handler]
IPC_LOG[Structured logging]
IPC_RETURN[Return error object]
end
subgraph "Service Layer"
SVC_TRY[try-catch in save]
SVC_LOG[Console error log]
SVC_RETURN[Return false]
end
subgraph "File System"
FS_CHECK[File exists check]
FS_WRITE[Write with error handling]
end
UI_TRY -->|Catch| UI_MSG
IPC_TRY -->|Catch| IPC_LOG --> IPC_RETURN
SVC_TRY -->|Catch| SVC_LOG --> SVC_RETURN
FS_WRITE -->|Error| SVC_TRY
style UI_TRY fill:#ffe1e1
style IPC_TRY fill:#ffe1e1
style SVC_TRY fill:#ffe1e1
```
### 错误场景分析 / Error Scenario Analysis
| 错误场景 / Error Scenario | 触发位置 / Location | 处理方式 / Handling | 用户反馈 / User Feedback |
| ------------------------- | ------------------- | --------------------- | ------------------------ |
| IPC 通信失败 | Renderer | try-catch | 显示"保存设置时发生错误" |
| 权限不足 | Main Process | 检查 UserType | 返回权限错误信息 |
| 文件写入失败 | ConfigManager | fs.writeFileSync 捕获 | 返回"保存设置失败" |
| 无效数据类型 | IPC Handler | TypeScript 类型检查 | 返回验证错误 |
| 磁盘空间不足 | File System | OS 异常捕获 | 返回系统错误信息 |
### 日志记录策略 / Logging Strategy
```typescript
// Main Process 结构化日志 / Structured Logging
log.info('Saving settings')
log.info('Settings saved successfully')
log.warn('Failed to save settings')
log.error('Error saving settings', { error: message })
```
**日志级别使用 / Log Level Usage:**
- `info`: 正常操作流程
- `warn`: 潜在问题(如保存失败但未崩溃)
- `error`: 严重错误(如异常抛出)
---
## 安全考虑 / Security Considerations
### 安全机制层级 / Security Layers
```mermaid
graph TB
L1[Layer 1: Context Isolation<br/>渲染进程隔离]
L2[Layer 2: contextBridge<br/>受限 API 暴露]
L3[Layer 3: User Type Filtering<br/>基于角色的访问控制]
L4[Layer 4: File System Permissions<br/>.env 文件保护]
L1 --> L2 --> L3 --> L4
style L1 fill:#e1f5ff
style L2 fill:#fff4e1
style L3 fill:#e1ffe1
style L4 fill:#ffe1f5
```
### 关键安全措施 / Key Security Measures
1. **密码明文存储风险 / Password Storage Risk**
- ⚠️ 当前:密码以明文形式存储在 .env 文件中
- 🔒 建议:实现加密存储机制
2. **用户权限隔离 / User Permission Isolation**
- ✅ 实现:基于用户类型过滤可见配置
- ✅ 实现Guest 用户无法访问设置页面
3. **IPC 通信安全 / IPC Communication Security**
- ✅ 实现:使用 `contextBridge` 而非直接暴露
- ✅ 实现:类型安全的 TypeScript 接口
4. **文件系统访问 / File System Access**
- ✅ 实现:.env 文件仅主进程可访问
- ⚠️ 风险:文件权限取决于操作系统
### 敏感数据流向 / Sensitive Data Flow
```mermaid
sequenceDiagram
participant User as 用户输入
participant UI as UI State (内存)
participant IPC as IPC Channel
participant Cache as Config Cache
participant File as .env File
User->>UI: password = "secret123"
UI->>IPC: JSON 传输 (未加密)
IPC->>Cache: Map.set('erp.password', 'secret123')
Cache->>File: 写入明文到磁盘
Note over File: ⚠️ 安全风险:<br/>密码以明文形式持久化
```
---
## 技术实现细节 / Technical Implementation Details
### 文件位置索引 / File Location Index
| 组件 / Component | 文件路径 / File Path | 关键行数 / Key Lines |
| ---------------- | -------------------------------------------- | --------------------- |
| UI 组件 | `src/renderer/src/pages/SettingsPage.tsx` | 61-73 (保存处理) |
| 预加载脚本 | `src/preload/index.ts` | 89-97 (API 定义) |
| IPC 处理器 | `src/main/ipc/settings-handler.ts` | 83-102 (保存处理) |
| 配置管理器 | `src/main/services/config/config-manager.ts` | 437-483 (保存方法) |
| 类型定义 | `src/main/types/settings.types.ts` | 136-171 (接口定义) |
| IPC 注册 | `src/main/ipc/index.ts` | 导入 settings-handler |
### 性能特性 / Performance Characteristics
1. **异步操作 / Async Operations**
- 所有 IPC 调用使用 `async/await` 模式
- 避免阻塞主进程事件循环
2. **内存优化 / Memory Optimization**
- 使用 Map 缓存配置,减少文件读取
- 按需加载配置项
3. **写入策略 / Write Strategy**
- 每次保存完整重写 .env 文件
- 原子写入writeFileSync
### 依赖关系图 / Dependency Graph
```mermaid
graph TD
A[SettingsPage.tsx] -->|imports| B[lucide-react]
A -->|uses| C[window.electron.settings]
C -->|exposed by| D[preload/index.ts]
D -->|imports| E[electron API]
D -->|imports| F[SettingsData Type]
G[settings-handler.ts] -->|imports| H[ipcMain]
G -->|imports| I[ConfigManager]
G -->|imports| J[SessionManager]
G -->|imports| K[Logger]
I -->|imports| L[fs/path]
I -->|imports| M[SettingsData Type]
I -->|imports| N[DEFAULT_SETTINGS]
style A fill:#e1f5ff
style D fill:#fff4e1
style G fill:#ffe1f5
style I fill:#e1ffe1
```
### 关键代码片段分析 / Key Code Snippet Analysis
**1. 状态更新逻辑 / State Update Logic**
```typescript
// SettingsPage.tsx 第 50-59 行
const updateSettings = (category: string, key: string, value: any) => {
setSettings((prev) => ({
...prev,
[category]: {
...(prev as any)[category],
[key]: value
}
}))
setIsModified(true) // 标记为已修改
}
```
**设计要点 / Design Points:**
- 不可变更新模式Immutable Update Pattern
- 使用展开运算符保持对象引用
- 自动启用保存按钮
**2. 配置保存逻辑 / Configuration Save Logic**
```typescript
// config-manager.ts 第 437-483 行
public async saveAllSettings(settings: SettingsData): Promise<boolean> {
// 批量更新缓存 (40+ 字段)
this.set('erp.url', settings.erp.url)
this.set('erp.username', settings.erp.username)
// ... 更多字段
// 同步写入文件
return this.save()
}
```
**设计要点 / Design Points:**
- 先更新内存,后写入磁盘
- 失败时缓存保持不变
- 返回布尔值表示成功/失败
**3. .env 文件生成逻辑 / .env File Generation**
```typescript
// config-manager.ts 第 179-345 行
public async save(): Promise<boolean> {
const lines: string[] = []
// 构建格式化的 .env 内容
lines.push('# ===========================')
lines.push('# ERP 系统配置')
lines.push('# ===========================')
lines.push(`ERP_URL=${this.configCache.get('erp.url') || DEFAULT_SETTINGS.erp.url}`)
const content = lines.join('\n')
fs.writeFileSync(this.envPath, content, 'utf-8')
return true
}
```
**设计要点 / Design Points:**
- 添加注释分隔符提高可读性
- 使用默认值作为后备
- 同步写入确保一致性
---
## 扩展与改进建议 / Extension and Improvement Suggestions
### 短期改进 / Short-term Improvements
1. **输入验证 / Input Validation**
- 添加 URL 格式验证
- 密码强度检查
- 端口号范围验证
2. **用户体验 / User Experience**
- 添加保存进度指示器
- 实现自动保存功能
- 添加配置导入/导出
3. **错误处理 / Error Handling**
- 更详细的错误消息
- 错误恢复建议
- 错误日志导出
### 长期改进 / Long-term Improvements
1. **安全性增强 / Security Enhancement**
```typescript
// 建议实现密码加密
interface SecureSettingsData extends SettingsData {
erp: {
...ErpConfig
encryptedPassword: string // 替代明文密码
}
}
```
2. **配置版本控制 / Configuration Versioning**
- 实现配置历史记录
- 支持回滚到之前版本
- 配置变更审计日志
3. **实时配置重载 / Live Config Reload**
- 监听 .env 文件变化
- 自动重载配置
- 通知相关服务更新
---
## 测试建议 / Testing Recommendations
### 单元测试 / Unit Tests
```typescript
// 测试用例示例
describe('ConfigManager', () => {
it('should save settings successfully', async () => {
const manager = ConfigManager.getInstance()
const settings: SettingsData = {
/* mock data */
}
const result = await manager.saveAllSettings(settings)
expect(result).toBe(true)
})
it('should handle file write errors', async () => {
// Mock fs.writeFileSync to throw error
const result = await manager.saveAllSettings(settings)
expect(result).toBe(false)
})
})
```
### 集成测试 / Integration Tests
```typescript
describe('Settings Save Flow', () => {
it('should complete full save cycle', async () => {
// 1. User modifies settings
// 2. Clicks save button
// 3. Verifies .env file updated
// 4. Confirms UI feedback
})
})
```
---
## 附录 / Appendix
### 完整配置字段列表 / Complete Configuration Field List
| 类别 / Category | 字段数 / Field Count | 字段列表 / Field List |
| ---------------- | -------------------- | ---------------------------------------------------------------------- |
| ERP | 6 | url, username, password, headless, ignoreHttpsErrors, autoCloseBrowser |
| Database | 7 | dbType, server, mysqlHost, mysqlPort, database, username, password |
| Paths | 3 | dataDir, defaultOutput, validationOutput |
| Extraction | 5 | batchSize, verbose, autoConvert, mergeBatches, enableDbPersistence |
| Validation | 5 | dataSource, batchSize, matchMode, enableCrud, defaultManager |
| UI | 3 | fontFamily, fontSize, productionIdInputWidth |
| Execution | 1 | dryRun |
| **总计 / Total** | **30** | |
### 相关文档 / Related Documentation
- [Electron Security Guidelines](https://www.electronjs.org/docs/latest/tutorial/security)
- [IPC 通信最佳实践](https://www.electronjs.org/docs/latest/tutorial/ipc)
- [环境变量管理规范](.env.example)
### 版本历史 / Version History
| 版本 / Version | 日期 / Date | 变更 / Changes |
| -------------- | ----------- | -------------------------- |
| 1.0 | 2025-03-03 | 初始版本 / Initial version |
---
**文档生成时间 / Document Generated:** 2025-03-03
**最后更新 / Last Updated:** 2025-03-03
**维护者 / Maintainer:** ERPAuto Development Team

View File

@@ -1,287 +0,0 @@
# 物料匹配算法增强 - 用户覆盖匹配功能
**实施日期**: 2026-03-03
**功能版本**: 1.0
**修改文件**: `src/main/ipc/validation-handler.ts`
---
## 功能概述
**User 用户类型** 在物料清理界面增加了 **优先级3用户覆盖匹配** 功能,确保 User 用户能够优先看到并管理与自己关键词匹配的物料。
---
## 实现的更改
### 1. 获取当前用户信息
**位置**: `validation-handler.ts:218-239`
```typescript
// Get current user info
const sessionManager = (
await import('../services/user/session-manager')
).SessionManager.getInstance()
const userInfo = sessionManager.getUserInfo()
if (!userInfo) {
return {
success: false,
error: '用户未登录',
stats: {
totalRecords: 0,
matchedCount: 0,
markedCount: 0
}
}
}
const isAdmin = userInfo.userType === 'Admin'
const username = userInfo.username
log.info('Starting validation', { mode: request.mode, user: username, isAdmin })
```
**说明**:
-`validation:validate` handler 开始时获取当前登录用户信息
- 提取 `isAdmin``username` 用于后续匹配逻辑
- 如果用户未登录,返回错误响应
### 2. 新增优先级3用户覆盖匹配
**位置**: `validation-handler.ts:359-370`
```typescript
// Priority 3: User Override Match (only for non-admin users)
// Override with current user's typeKeyword if available
if (!isAdmin && username) {
const userKeywords = typeKeywords.filter((tk) => tk.managerName === username)
for (const userKeyword of userKeywords) {
if (userKeyword.materialName && materialName.includes(userKeyword.materialName)) {
matchedTypeKeyword = userKeyword.materialName
managerName = userKeyword.managerName
break // Force override with first match
}
}
}
```
**匹配逻辑**:
1. **适用范围**: 仅对 `isAdmin === false` 的 User 用户生效
2. **筛选关键词**: 从 `typeKeywords` 中筛选 `managerName === username` 的记录
3. **匹配规则**: 使用 `materialName.includes(userKeyword.materialName)` 包含关系匹配
4. **强制覆盖**: 只要匹配成功,立即覆盖原有的 `managerName``matchedTypeKeyword`
5. **无匹配时**: 保持优先级2的匹配结果不变
---
## 匹配优先级(更新后)
```mermaid
flowchart TB
Start([物料数据]) --> P1{优先级1<br/>MaterialsToBeDeleted<br/>精确匹配?}
P1 -->|MaterialCode匹配| M1[✅ 已标记删除<br/>isMarkedForDeletion=true]
P1 -->|未匹配| P2{优先级2<br/>MaterialsTypeToBeDeleted<br/>包含匹配?}
P2 -->|匹配到| M2[⚠️ 类型匹配<br/>managerName=其他用户]
P2 -->|未匹配| M3[❌ 未匹配<br/>managerName='']
M1 --> Check{用户类型?}
M2 --> Check
M3 --> Check
Check -->|Admin| Skip[跳过覆盖]
Check -->|User| P3{优先级3<br/>用户覆盖匹配?}
P3 -->|匹配成功| Override[✅ 覆为当前用户<br/>managerName=当前用户]
P3 -->|未匹配| Keep[保持原结果]
Skip --> End([返回结果])
Override --> End
Keep --> End
```
---
## 测试场景
### 场景1: User 用户匹配到自己的 typeKeyword
**输入**:
- 当前用户: `user1`
- 物料名称: `螺丝 M6`
- MaterialsTypeToBeDeleted: `{ materialName: "螺丝", managerName: "user1" }`
**预期输出**:
```json
{
"materialName": "螺丝 M6",
"managerName": "user1",
"matchedTypeKeyword": "螺丝",
"isMarkedForDeletion": false
}
```
### 场景2: User 用户覆盖其他用户的匹配
**输入**:
- 当前用户: `user1`
- 物料名称: `螺丝 M6`
- MaterialsTypeToBeDeleted:
- `{ materialName: "螺丝", managerName: "user2" }`
- `{ materialName: "螺丝", managerName: "user1" }`
**优先级2结果**: `managerName = "user2"`
**优先级3结果**: `managerName = "user1"` ✅ 强制覆盖
### 场景3: User 用户无匹配关键词
**输入**:
- 当前用户: `user1`
- 物料名称: `螺丝 M6`
- MaterialsTypeToBeDeleted:
- `{ materialName: "螺丝", managerName: "user2" }`
**预期输出**:
```json
{
"materialName": "螺丝 M6",
"managerName": "user2",
"matchedTypeKeyword": "螺丝",
"isMarkedForDeletion": false
}
```
**说明**: 保持优先级2的匹配结果
### 场景4: Admin 用户不执行覆盖
**输入**:
- 当前用户: `admin` (isAdmin=true)
- 物料名称: `螺丝 M6`
- MaterialsTypeToBeDeleted:
- `{ materialName: "螺丝", managerName: "user1" }`
- `{ materialName: "螺丝", managerName: "admin" }`
**预期输出**:
```json
{
"materialName": "螺丝 M6",
"managerName": "user1",
"matchedTypeKeyword": "螺丝",
"isMarkedForDeletion": false
}
```
**说明**: Admin 不执行优先级3保持原有匹配行为
### 场景5: 优先级1匹配不受影响
**输入**:
- 当前用户: `user1`
- 物料代码: `MAT001`
- MaterialsToBeDeleted: `{ materialCode: "MAT001", managerName: "user2" }`
**预期输出**:
```json
{
"materialCode": "MAT001",
"managerName": "user2",
"isMarkedForDeletion": true,
"matchedTypeKeyword": undefined
}
```
**说明**: 优先级1的精确匹配不受覆盖影响
---
## 数据库配置示例
### MaterialsTypeToBeDeleted 表数据
| MaterialName | ManagerName | 说明 |
| ------------ | ----------- | ------------------------------ |
| 螺丝 | user1 | user1 负责所有包含"螺丝"的物料 |
| 螺母 | user2 | user2 负责所有包含"螺母"的物料 |
| 垫圈 | user1 | user1 也负责"垫圈"类物料 |
| 电缆 | admin | admin 负责电缆类物料 |
### 匹配结果示例
| 物料名称 | 当前用户 | 原匹配 (优先级2) | 覆盖后 (优先级3) |
| -------- | -------- | ---------------- | ----------------- |
| 螺丝 M6 | user1 | user2 | **user1** ✅ |
| 螺母 M8 | user1 | user2 | user2 (无匹配) |
| 垫圈 φ10 | user1 | user2 | **user1** ✅ |
| 电缆 5m | user1 | admin | user1 (无匹配) |
| 螺丝 M6 | admin | user2 | user2 (Admin跳过) |
---
## 与前端协同
前端过滤器逻辑 (`CleanerPage.tsx`) 保持不变:
```typescript
const filteredResults = React.useMemo(() => {
let results = validationResults
if (!isAdmin && currentUsername) {
// User 只看到自己的物料 + 未分配的物料
results = results.filter((r) => r.managerName === currentUsername || !r.managerName)
}
return results
}, [validationResults, isAdmin, currentUsername, managers, selectedManagers, hiddenItems])
```
**协同效果**:
1. 后端匹配算法确保 User 用户的物料优先分配给自己
2. 前端过滤器只显示属于当前用户或未分配的物料
3. Admin 用户可以看到所有物料并切换查看不同负责人
---
## 代码审查检查点
- ✅ User 信息获取正确使用 `SessionManager`
- ✅ 只对 `!isAdmin` 的用户执行覆盖逻辑
- ✅ 使用相同的包含匹配规则 `materialName.includes(typeKeyword.materialName)`
- ✅ 优先级1精确匹配不受覆盖影响
- ✅ 无匹配时保持原有结果
- ✅ 日志记录包含用户信息 `{ user: username, isAdmin }`
- ✅ 未登录时返回明确的错误信息
---
## 潜在改进方向
1. **性能优化**: 如果 `typeKeywords` 数量很大,可以预先构建 `Map<username, typeKeyword[]>` 索引
2. **日志增强**: 添加覆盖匹配的统计信息(覆盖了多少条记录)
3. **配置开关**: 允许 Admin 用户通过配置启用/禁用覆盖功能
4. **UI 反馈**: 在前端显示哪些物料是通过覆盖匹配分配的
---
## 相关文件
- **实现文件**: `src/main/ipc/validation-handler.ts` (Lines 218-239, 359-370)
- **前端页面**: `src/renderer/src/pages/CleanerPage.tsx`
- **会话管理**: `src/main/services/user/session-manager.ts`
- **类型定义**: `src/main/types/validation.types.ts`
---
**文档结束**

View File

@@ -2,37 +2,17 @@ appId: com.electron.app
productName: erpauto
directories:
buildResources: build
extraResources:
- from: build/bin/portable-updater.exe
to: portable-updater.exe
files:
- '!**/.vscode/*'
- '!src/*'
- '!electron-vite.config.{js,ts,mjs,cjs}'
- '!electron.vite.config.{js,ts,mjs,cjs}'
- '!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}'
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
- 'package.json'
# Include build output
- 'out/**/*'
# Include config.template.yaml in the build for reference
- 'config.template.yaml'
# Exclude Playwright browser downloads (manual install for company environment)
- '!**/node_modules/playwright-core/.local-browsers/**'
asarUnpack:
- resources/**
# Unpack playwright for native modules
- '**/node_modules/playwright/**'
- '**/node_modules/playwright-core/**'
win:
executableName: erpauto
target:
- nsis
- portable
portable:
artifactName: ${name}-portable.${ext}
# Portable app uses user data directory (AppData), not exe directory
# This ensures config persists across app updates
nsis:
artifactName: ${name}-${version}-setup.${ext}
shortcutName: ${productName}

View File

@@ -1,59 +1,16 @@
import { resolve } from 'path'
import { defineConfig } from 'electron-vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { execSync } from 'child_process'
import { createRequire } from 'module'
// Get git hash (first 7 characters)
const getGitHash = (): string => {
try {
return execSync('git rev-parse --short=7 HEAD', { encoding: 'utf-8' }).trim()
} catch {
return 'unknown'
}
}
// Get version from package.json
const require = createRequire(import.meta.url)
const version = require('./package.json').version
const gitHash = getGitHash()
const appChannel = process.env.APP_CHANNEL === 'preview' ? 'preview' : 'stable'
export default defineConfig({
main: {
define: {
__APP_CHANNEL__: JSON.stringify(appChannel)
}
},
preload: {
define: {
__APP_CHANNEL__: JSON.stringify(appChannel)
}
},
main: {},
preload: {},
renderer: {
define: {
__APP_VERSION__: JSON.stringify(version),
__GIT_HASH__: JSON.stringify(gitHash),
__APP_CHANNEL__: JSON.stringify(appChannel)
},
resolve: {
alias: {
'@renderer': resolve('src/renderer/src')
}
},
plugins: [
react(),
tailwindcss(),
{
name: 'update-title',
transformIndexHtml(html) {
return html.replace(
'<title>ERP Auto Tool</title>',
`<title>ERPAuto - v${version}(${gitHash})</title>`
)
}
}
]
plugins: [react()]
}
})

0
logs/.gitkeep Normal file
View File

5710
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "erpauto",
"version": "1.4.0",
"version": "1.0.0",
"description": "An Electron application with React and TypeScript",
"main": "./out/main/index.js",
"author": "example.com",
@@ -12,71 +12,33 @@
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck": "npm run typecheck:node && npm run typecheck:web",
"start": "electron-vite preview",
"dev": "chcp 65001 && electron-vite dev",
"build": "chcp 65001 && npm run typecheck && electron-vite build",
"postinstall": "electron-builder install-app-deps",
"build:updater": "node scripts/compile-updater.js",
"build:unpack": "chcp 65001 && set PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 && npm run build:updater && npm run build && electron-builder --dir",
"build:win": "chcp 65001 && set PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 && npm run prebuild && npm run build && npm run build:updater && electron-builder --win",
"build:mac": "chcp 65001 && npm run prebuild && electron-vite build && electron-builder --mac",
"build:linux": "chcp 65001 && npm run prebuild && electron-vite build && electron-builder --linux",
"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:run": "vitest run",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:report": "playwright show-report",
"debug:erp-login": "tsx src/main/tools/erp-login-debug.ts",
"debug:config-path": "tsx src/main/tools/config-path-debug.ts",
"test:rustfs": "tsx src/main/tools/rustfs-test.ts"
"dev": "electron-vite dev",
"build": "npm run typecheck && electron-vite build",
"postinstall": "electron-builder install-app-deps && playwright install chromium",
"build:unpack": "npm run build && electron-builder --dir",
"build:win": "npm run build && electron-builder --win",
"build:mac": "electron-vite build && electron-builder --mac",
"build:linux": "electron-vite build && electron-builder --linux",
"test": "jest",
"test:e2e": "playwright test"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.929.0",
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0",
"@tailwindcss/vite": "^4.2.1",
"@types/js-yaml": "^4.0.9",
"chromium-bidi": "^15.0.0",
"date-fns": "^4.1.0",
"exceljs": "^4.4.0",
"github-markdown-css": "^5.9.0",
"js-yaml": "^4.1.1",
"lucide-react": "^0.575.0",
"mssql": "^12.2.0",
"mysql2": "^3.18.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",
"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",
"uuid": "^13.0.0",
"winston": "^3.19.0",
"winston-daily-rotate-file": "^5.0.0",
"zod": "^4.3.6",
"zustand": "^5.0.11"
"winston": "^3.19.0"
},
"devDependencies": {
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
"@electron-toolkit/eslint-config-ts": "^3.1.0",
"@electron-toolkit/tsconfig": "^2.0.0",
"@playwright/test": "^1.58.2",
"@types/mssql": "^9.1.9",
"@types/node": "^22.19.13",
"@types/node": "^22.19.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/uuid": "^10.0.0",
"@vitejs/plugin-react": "^5.1.1",
"@vitest/coverage-v8": "^4.0.18",
"autoprefixer": "^10.4.27",
"electron": "^39.2.6",
"electron-builder": "^26.0.12",
"electron-vite": "^5.0.0",
@@ -84,14 +46,10 @@
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"postcss": "^8.5.6",
"prettier": "^3.7.4",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"tailwindcss": "^4.2.1",
"tsx": "^4.19.3",
"typescript": "^5.9.3",
"vite": "^7.2.6",
"vitest": "^4.0.18"
"vite": "^7.2.6"
}
}

View File

@@ -1,23 +0,0 @@
import { defineConfig } from '@playwright/test'
export default defineConfig({
testDir: './tests/e2e',
timeout: 120000,
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: 1,
reporter: 'html',
use: {
trace: 'on-first-retry',
screenshot: 'only-on-failure'
},
// Test configuration for Electron
projects: [
{
name: 'electron',
testMatch: '**/*.test.ts'
}
]
})

View File

@@ -1,57 +0,0 @@
#!/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)
}

View File

@@ -1,115 +0,0 @@
/**
* 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()

View File

@@ -1,142 +0,0 @@
/**
* 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()

View File

@@ -1,13 +0,0 @@
-- 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';

View File

@@ -1,82 +0,0 @@
-- ============================================================================
-- 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.
-- ============================================================================

View File

@@ -1,125 +0,0 @@
/**
* 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()

View File

@@ -1,193 +0,0 @@
#!/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)
})

View File

@@ -1,229 +0,0 @@
#!/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)
}

View File

@@ -1,217 +0,0 @@
#!/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)
})

0
src/main/config/.gitkeep Normal file
View File

View File

@@ -0,0 +1,158 @@
import fs from 'fs';
import path from 'path';
import { AppConfig } from '../models/config.types';
export class ConfigManager {
private static instance: AppConfig | null = null;
static load(): AppConfig {
if (this.instance) {
return this.instance;
}
try {
// Load default config (minimal, no sensitive data)
const defaultConfig = this.getDefaultConfig();
// Load environment-specific config
const env = process.env.NODE_ENV || 'development';
const envConfigPath = path.join(__dirname, `../../../config/${env}.json`);
let envConfig: Partial<AppConfig> = {};
if (fs.existsSync(envConfigPath)) {
try {
const envConfigContent = fs.readFileSync(envConfigPath, 'utf-8');
envConfig = JSON.parse(envConfigContent);
} catch (error) {
console.warn(`Failed to load env config from ${envConfigPath}:`, error);
}
}
// Load environment variables (with validation)
const envVars = this.loadFromEnv();
// Deep merge configurations
this.instance = this.deepMerge(defaultConfig, envConfig, envVars);
// Validate required configuration
this.validateConfig(this.instance);
return this.instance;
} catch (error) {
console.error('Failed to load configuration:', error);
throw new Error('Configuration loading failed');
}
}
private static getDefaultConfig(): AppConfig {
const appConfigPath = path.join(__dirname, '../../../config/app.json');
try {
if (fs.existsSync(appConfigPath)) {
const configContent = fs.readFileSync(appConfigPath, 'utf-8');
return JSON.parse(configContent);
}
} catch (error) {
console.warn(`Failed to load app.json, using minimal defaults:`, error);
}
// Minimal fallback defaults (NO sensitive data)
return {
appName: 'ERPAuto',
version: '1.0.0',
logLevel: 'info',
browser: {
headless: false,
slowMo: 50,
timeout: 30000,
},
databases: {
sqlServer: {
server: '', // MUST be set via env var
database: '',
username: '',
password: '',
driver: 'ODBC Driver 18 for SQL Server',
trustServerCertificate: 'yes',
},
mysql: {
host: 'localhost',
port: 3306,
database: '',
username: '',
password: '',
},
},
erp: {
baseUrl: '', // MUST be set via env var
ignoreHttpsErrors: false,
},
paths: {
tempDir: './data/temp',
outputDir: './data/output',
reportDir: './data/reports',
},
};
}
private static loadFromEnv(): Partial<AppConfig> {
const sqlServer = {
server: process.env.SQL_SERVER_SERVER || '',
database: process.env.SQL_SERVER_DATABASE || '',
username: process.env.SQL_SERVER_USERNAME || '',
password: process.env.SQL_SERVER_PASSWORD || '',
driver: process.env.SQL_SERVER_DRIVER || 'ODBC Driver 18 for SQL Server',
trustServerCertificate: process.env.SQL_SERVER_TRUST_CERT || 'yes',
};
const mysql = {
host: process.env.MYSQL_HOST || 'localhost',
port: parseInt(process.env.MYSQL_PORT || '3306'),
database: process.env.MYSQL_DATABASE || '',
username: process.env.MYSQL_USERNAME || '',
password: process.env.MYSQL_PASSWORD || '',
};
return {
databases: {
sqlServer,
mysql,
},
};
}
private static deepMerge(...configs: Partial<AppConfig>[]): AppConfig {
const result = configs[0] as AppConfig;
for (let i = 1; i < configs.length; i++) {
const config = configs[i];
for (const key in config) {
if (Object.prototype.hasOwnProperty.call(config, key)) {
const value = (config as any)[key];
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
(result as any)[key] = { ...(result as any)[key], ...value };
} else {
(result as any)[key] = value;
}
}
}
}
return result;
}
private static validateConfig(config: AppConfig): void {
// Validate critical configuration
if (!config.databases.sqlServer.server && process.env.NODE_ENV === 'production') {
throw new Error('SQL Server server address must be configured via environment variable');
}
if (!config.databases.sqlServer.database) {
throw new Error('SQL Server database name must be configured');
}
if (!config.erp.baseUrl) {
console.warn('Warning: ERP base URL not configured, ERP features will not work');
}
}
}

View File

0
src/main/dao/.gitkeep Normal file
View File

View File

@@ -1,33 +1,19 @@
import { app, shell, BrowserWindow, ipcMain, dialog } from 'electron'
import { app, shell, BrowserWindow, ipcMain } from 'electron'
import { join } from 'path'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import icon from '../../resources/icon.png?asset'
import { registerIpcHandlers } from './ipc'
import { ConfigManager } from './services/config/config-manager'
import logger from './services/logger/index'
import { logAudit } from './services/logger/audit-logger'
import { fileURLToPath } from 'url'
import { dirname } from 'path'
import fs from 'fs'
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')
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
function createWindow(): void {
// Create the browser window.
const mainWindow = new BrowserWindow({
width: 1200,
width: 900,
height: 670,
show: false,
autoHideMenuBar: true,
...(process.platform === 'linux' ? { icon } : {}),
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: true
sandbox: false
}
})
@@ -52,71 +38,7 @@ function createWindow(): void {
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.whenReady().then(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
}
app.whenReady().then(() => {
// Set app user model id for windows
electronApp.setAppUserModelId('com.electron')
@@ -127,9 +49,6 @@ app.whenReady().then(async () => {
optimizer.watchWindowShortcuts(window)
})
// Register IPC handlers (after ConfigManager is initialized)
registerIpcHandlers()
// IPC test
ipcMain.on('ping', () => console.log('pong'))
@@ -151,41 +70,5 @@ app.on('window-all-closed', () => {
}
})
// Global exception handlers to prevent crashes without logging
process.on('uncaughtException', async (err) => {
logger.error('Uncaught exception', { error: err })
await logAudit('SYSTEM_CRASH', 'system', {
username: 'system',
computerName: process.env.COMPUTERNAME || 'unknown',
resource: 'main-process',
status: 'failure',
metadata: { error: err.message, stack: err.stack }
})
console.error('Uncaught exception:', err)
setTimeout(() => process.exit(1), 1000)
})
process.on('unhandledRejection', async (reason, promise) => {
logger.error('Unhandled Rejection', { reason: String(reason) })
await logAudit('SYSTEM_ERROR', 'system', {
username: 'system',
computerName: process.env.COMPUTERNAME || 'unknown',
resource: 'main-process',
status: 'failure',
metadata: { reason: String(reason) }
})
console.error('Unhandled Rejection:', reason)
})
app.on('render-process-gone', (_, webContents, details) => {
logger.error('Render process gone', { details, webContentsId: webContents.id })
console.error('Render process gone:', details)
})
app.on('child-process-gone', (_, details) => {
logger.error('Child process gone', { details })
console.error('Child process gone:', details)
})
// In this file you can include the rest of your app's specific main process
// code. You can also put them in separate files and require them here.

View File

@@ -1,271 +0,0 @@
/**
* IPC handlers for User Authentication
*
* Provides APIs for the renderer process to:
* - Login with username and password
* - Silent login by computer name
* - Logout
* - Get current user info
* - Get all users (for admin user selection)
* - Switch user (admin only)
*/
import { ipcMain } from 'electron'
import { SessionManager } from '../services/user/session-manager'
import { createLogger } from '../services/logger'
import { logAudit } from '../services/logger/audit-logger'
import type { UserInfo } from '../types/user.types'
import { IPC_CHANNELS } from '../../shared/ipc-channels'
import { ValidationError } from '../types/errors'
import { withErrorHandling, type IpcResult } from './index'
import { UpdateService } from '../services/update/update-service'
const log = createLogger('AuthHandler')
/**
* Login request
*/
export interface LoginRequest {
username: string
password: string
}
/**
* Login response
*/
export interface LoginResponse {
success: boolean
userInfo?: UserInfo
error?: string
}
/**
* Silent login response
*/
export interface SilentLoginResponse {
success: boolean
userInfo?: UserInfo
requiresUserSelection?: boolean // True if admin needs to select a user
error?: string
}
/**
* User selection response
*/
export interface UserSelectionResponse {
success: boolean
userInfo?: UserInfo
error?: string
}
/**
* Current user response
*/
export interface CurrentUserResponse {
isAuthenticated: boolean
userInfo?: UserInfo
}
/**
* Register IPC handlers for user authentication
*/
export function registerAuthHandlers(): void {
const sessionManager = SessionManager.getInstance()
const updateService = UpdateService.getInstance()
/**
* Get computer name
*/
ipcMain.handle(IPC_CHANNELS.AUTH_GET_COMPUTER_NAME, async (): Promise<IpcResult<string>> => {
return withErrorHandling(async () => {
const os = await import('os')
return os.hostname()
}, 'auth:getComputerName')
})
/**
* Silent login by computer name
*/
ipcMain.handle(
IPC_CHANNELS.AUTH_SILENT_LOGIN,
async (): Promise<IpcResult<SilentLoginResponse>> => {
return withErrorHandling(async () => {
log.info('Attempting silent login')
const success = await sessionManager.loginByComputerName()
const userInfo = sessionManager.getUserInfo()
if (success && userInfo) {
await updateService.setUserContext(userInfo.userType)
// Check if admin needs user selection
const requiresUserSelection = userInfo.userType === 'Admin'
log.info('Silent login successful', {
username: userInfo.username,
userType: userInfo.userType,
requiresUserSelection
})
// Audit log: LOGIN success (non-blocking)
const os = await import('os')
logAudit('LOGIN', String(userInfo.id), {
username: userInfo.username,
computerName: os.hostname(),
resource: 'ERP_SYSTEM',
status: 'success',
metadata: { loginType: 'silent', userType: userInfo.userType }
}).catch((err) => log.warn('Failed to write audit log', { err }))
return {
success: true,
userInfo,
requiresUserSelection
}
}
await updateService.setUserContext(null)
throw new ValidationError('无感登录失败:未找到匹配用户', 'VAL_INVALID_INPUT')
}, 'auth:silentLogin')
}
)
/**
* Login with username and password
*/
ipcMain.handle(
IPC_CHANNELS.AUTH_LOGIN,
async (_event, request: LoginRequest): Promise<IpcResult<LoginResponse>> => {
return withErrorHandling(async () => {
const { username, password } = request
if (!username || !password) {
log.warn('Login attempt with missing credentials')
throw new ValidationError('请输入用户名和密码', 'VAL_MISSING_REQUIRED')
}
log.info('Login attempt', { username })
const success = await sessionManager.login(username, password)
const userInfo = sessionManager.getUserInfo()
if (success && userInfo) {
log.info('Login successful', { username, userType: userInfo.userType })
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 {
success: true,
userInfo
}
}
// Audit log: LOGIN failure (non-blocking)
const os = await import('os')
logAudit('LOGIN', '0', {
username,
computerName: os.hostname(),
resource: 'ERP_SYSTEM',
status: 'failure',
metadata: { loginType: 'credentials', reason: 'invalid_credentials' }
}).catch((err) => log.warn('Failed to write audit log', { err }))
log.warn('Login failed - invalid credentials', { username })
await updateService.setUserContext(null)
throw new ValidationError('用户名或密码错误', 'VAL_INVALID_INPUT')
}, 'auth:login')
}
)
/**
* Logout
*/
ipcMain.handle(IPC_CHANNELS.AUTH_LOGOUT, async (): Promise<IpcResult<void>> => {
return withErrorHandling(async () => {
const userInfo = sessionManager.getUserInfo()
log.info('User logout', { username: userInfo?.username })
// Audit log: LOGOUT (non-blocking)
if (userInfo) {
const os = await import('os')
logAudit('LOGOUT', String(userInfo.id), {
username: userInfo.username,
computerName: os.hostname(),
resource: 'ERP_SYSTEM',
status: 'success',
metadata: { userType: userInfo.userType }
}).catch((err) => log.warn('Failed to write audit log', { err }))
}
sessionManager.logout()
await updateService.setUserContext(null)
}, 'auth:logout')
})
/**
* Get current user
*/
ipcMain.handle(
IPC_CHANNELS.AUTH_GET_CURRENT_USER,
async (): Promise<IpcResult<CurrentUserResponse>> => {
return withErrorHandling(async () => {
const isAuthenticated = sessionManager.isAuthenticated()
const userInfo = sessionManager.getUserInfo()
return {
isAuthenticated,
userInfo: userInfo ?? undefined
}
}, 'auth:getCurrentUser')
}
)
/**
* Get all users (for admin user selection)
*/
ipcMain.handle(IPC_CHANNELS.AUTH_GET_ALL_USERS, async (): Promise<IpcResult<UserInfo[]>> => {
return withErrorHandling(async () => {
log.debug('Fetching all users for admin selection')
return await sessionManager.getAllUsers()
}, 'auth:getAllUsers')
})
/**
* Switch user (admin only)
*/
ipcMain.handle(
IPC_CHANNELS.AUTH_SWITCH_USER,
async (_event, userInfo: UserInfo): Promise<IpcResult<UserSelectionResponse>> => {
return withErrorHandling(async () => {
log.info('User switch attempt', { targetUser: userInfo.username })
const success = sessionManager.switchUser(userInfo)
if (success) {
const newUser = sessionManager.getUserInfo()
log.info('User switch successful', { newUsername: newUser?.username })
await updateService.setUserContext(newUser?.userType ?? null)
return {
success: true,
userInfo: newUser ?? undefined
}
}
log.warn('User switch failed')
throw new ValidationError('用户切换失败', 'VAL_INVALID_INPUT')
}, 'auth:switchUser')
}
)
/**
* Check if current user is admin
*/
ipcMain.handle(IPC_CHANNELS.AUTH_IS_ADMIN, async (): Promise<IpcResult<boolean>> => {
return withErrorHandling(async () => sessionManager.isAdmin(), 'auth:isAdmin')
})
}

View File

@@ -1,368 +0,0 @@
import { ipcMain, type WebContents } from 'electron'
import { ErpAuthService } from '../services/erp/erp-auth'
import { CleanerService } from '../services/erp/cleaner'
import { OrderNumberResolver } from '../services/erp/order-resolver'
import { MySqlService } from '../services/database/mysql'
import { SqlServerService } from '../services/database/sql-server'
import { ConfigManager } from '../services/config/config-manager'
import { ResultExporter } from '../services/excel/result-exporter'
import { CleanerReportGenerator } from '../services/report/cleaner-report-generator'
import { RustfsService } from '../services/rustfs'
import { SessionManager } from '../services/user/session-manager'
import { createLogger } from '../services/logger'
import { logAudit } from '../services/logger/audit-logger'
import { withErrorHandling, type IpcResult } from './index'
import { ErpConnectionError, ValidationError, DatabaseQueryError } from '../types/errors'
import type {
CleanerInput,
CleanerResult,
CleanerProgress,
ExportResultItem,
ExportResultResponse
} from '../types/cleaner.types'
import { UserErpConfigService } from '../services/user/user-erp-config-service'
import { IPC_CHANNELS } from '../../shared/ipc-channels'
const log = createLogger('CleanerHandler')
function sendProgress(
sender: WebContents,
message: string,
progress: number,
extra?: Partial<CleanerProgress>
): void {
try {
const progressData: CleanerProgress = {
message,
progress,
currentOrderIndex: extra?.currentOrderIndex ?? 0,
totalOrders: extra?.totalOrders ?? 0,
currentMaterialIndex: extra?.currentMaterialIndex ?? 0,
totalMaterialsInOrder: extra?.totalMaterialsInOrder ?? 0,
currentOrderNumber: extra?.currentOrderNumber,
phase: extra?.phase ?? 'processing'
}
sender.send(IPC_CHANNELS.CLEANER_PROGRESS, progressData)
} catch (error) {
log.warn('Failed to send progress event', { error })
}
}
async function getDatabaseService(): Promise<MySqlService | SqlServerService> {
const configManager = ConfigManager.getInstance()
const config = configManager.getConfig()
const dbType = configManager.getDatabaseType()
if (dbType === 'sqlserver') {
const dbConfig = config.database.sqlserver
const sqlServerService = new SqlServerService({
server: dbConfig.server,
port: dbConfig.port,
user: dbConfig.username,
password: dbConfig.password,
database: dbConfig.database,
options: {
encrypt: false,
trustServerCertificate: dbConfig.trustServerCertificate
}
})
await sqlServerService.connect()
return sqlServerService
} else {
const dbConfig = config.database.mysql
const mysqlService = new MySqlService({
host: dbConfig.host,
port: dbConfig.port,
user: dbConfig.username,
password: dbConfig.password,
database: dbConfig.database
})
await mysqlService.connect()
return mysqlService
}
}
/**
* Get ERP configuration for current user
* URL is from config.yaml (fixed infrastructure)
* Username and password are from user's database config
*/
async function getErpConfig(): Promise<{
url: string
username: string
password: string
}> {
// Get ERP URL from config.yaml (fixed for all users)
const configManager = ConfigManager.getInstance()
const globalConfig = configManager.getConfig()
const erpUrl = globalConfig.erp.url
// Get username and password from user's database config
const erpConfigService = UserErpConfigService.getInstance()
const userConfig = await erpConfigService.getCurrentUserErpConfig()
if (!userConfig || !userConfig.username || !userConfig.password) {
throw new ValidationError(
'ERP 配置不完整。请在设置中配置 ERP 用户名和密码',
'VAL_MISSING_REQUIRED'
)
}
return {
url: erpUrl,
username: userConfig.username,
password: userConfig.password
}
}
export function registerCleanerHandlers(): void {
ipcMain.handle(
IPC_CHANNELS.CLEANER_RUN,
async (event, input: CleanerInput): Promise<IpcResult<CleanerResult>> => {
const sender = event.sender
const startTime = Date.now()
return withErrorHandling(async () => {
let authService: ErpAuthService | null = null
let dbService: MySqlService | SqlServerService | null = null
try {
// Get ERP configuration from database for current user
log.info('Fetching ERP configuration from database...')
const erpConfig = await getErpConfig()
log.info('ERP config retrieved', {
url: erpConfig.url ? 'configured' : 'EMPTY',
username: erpConfig.username ? 'configured' : 'EMPTY'
})
const configManager = ConfigManager.getInstance()
const dbType = configManager.getDatabaseType()
log.info(
`Connecting to ${dbType === 'sqlserver' ? 'SQL Server' : 'MySQL'} for order resolution...`
)
try {
dbService = await getDatabaseService()
} catch (error) {
throw new DatabaseQueryError(
'数据库连接失败',
'DB_CONNECTION_FAILED',
error instanceof Error ? error : undefined
)
}
const resolver = new OrderNumberResolver(dbService)
const mappings = await resolver.resolve(input.orderNumbers)
const validOrderNumbers = resolver.getValidOrderNumbers(mappings)
const warnings = resolver.getWarnings(mappings)
if (warnings.length > 0) {
log.warn('Resolution warnings', { warnings })
}
if (validOrderNumbers.length === 0) {
throw new ValidationError(
'没有有效的生产订单号可处理。请检查输入的格式或数据库连接。',
'VAL_INVALID_INPUT'
)
}
log.info('Resolved order numbers', { count: validOrderNumbers.length })
authService = new ErpAuthService({
url: erpConfig.url,
username: erpConfig.username,
password: erpConfig.password,
headless: input.headless ?? true
})
log.info('Logging in to ERP...')
try {
await authService.login()
} catch (error) {
throw new ErpConnectionError(
'ERP 登录失败',
'ERP_LOGIN_FAILED',
error instanceof Error ? error : undefined
)
}
log.info('Login successful')
// Send login complete progress
const totalOrders = validOrderNumbers.length
const loginProgress = (1 / (1 + totalOrders)) * 100
sendProgress(sender, 'ERP 登录成功', loginProgress, {
phase: 'login',
currentOrderIndex: 0,
totalOrders,
currentMaterialIndex: 0,
totalMaterialsInOrder: 0
})
const cleaner = new CleanerService(authService)
const modifiedInput: CleanerInput = {
...input,
orderNumbers: validOrderNumbers,
onProgress: (message, progress, extra) => {
sendProgress(sender, message, progress ?? 0, extra)
}
}
log.info('Starting cleaning', {
orderCount: validOrderNumbers.length,
queryBatchSize: input.queryBatchSize ?? 100,
processConcurrency: input.processConcurrency ?? 1
})
const result = await cleaner.clean(modifiedInput)
if (warnings.length > 0) {
result.errors = [...warnings, ...result.errors]
}
// Send completion progress
sendProgress(sender, '清理完成', 100, {
phase: 'complete',
currentOrderIndex: totalOrders,
totalOrders,
currentMaterialIndex: 0,
totalMaterialsInOrder: 0
})
log.info('Cleaning completed', {
processedCount: result.ordersProcessed,
errorCount: result.errors.length
})
// Audit log: CLEAN (non-blocking)
const os = await import('os')
const currentUser = SessionManager.getInstance().getUserInfo()
if (currentUser) {
const status: 'success' | 'failure' | 'partial' =
result.errors.length > 0 && result.materialsDeleted > 0
? 'partial'
: result.errors.length > 0
? 'failure'
: 'success'
logAudit('CLEAN', String(currentUser.id), {
username: currentUser.username,
computerName: os.hostname(),
resource: 'MATERIAL_PLAN',
status,
metadata: {
orderCount: validOrderNumbers.length,
dryRun: input.dryRun ?? false,
queryBatchSize: input.queryBatchSize ?? 100,
processConcurrency: input.processConcurrency ?? 1,
materialsDeleted: result.materialsDeleted,
materialsSkipped: result.materialsSkipped,
errorCount: result.errors.length
}
}).catch((err) => log.warn('Failed to write audit log', { err }))
}
// Generate report and upload to RustFS (silent, user unaware)
try {
const endTime = Date.now()
const currentUser = SessionManager.getInstance().getUserInfo()
const username = currentUser?.username ?? 'unknown'
const reportGenerator = new CleanerReportGenerator()
const reportPath = await reportGenerator.generateReport(result, {
dryRun: input.dryRun ?? false,
username,
startTime,
endTime
})
log.info('Report generated', { path: reportPath })
// Upload to RustFS if enabled
const configManager = ConfigManager.getInstance()
const config = configManager.getConfig()
if (config.rustfs?.enabled && config.rustfs.endpoint) {
try {
const rustfs = new RustfsService({ config: config.rustfs })
const reportFileName = reportPath.split(/[\\/]/).pop() || 'report.md'
const storageKey = rustfs.generateReportKey(reportFileName, username)
log.info('Uploading report to RustFS', {
localPath: reportPath,
storageKey
})
const uploadResult = await rustfs.uploadFile(
reportPath,
storageKey,
'text/markdown; charset=utf-8'
)
if (uploadResult.success) {
log.info('Report uploaded to RustFS successfully', {
key: storageKey,
etag: uploadResult.etag
})
} else {
log.warn('Failed to upload report to RustFS', {
error: uploadResult.error,
key: storageKey
})
}
} catch (rustfsError) {
log.error('RustFS upload failed', {
error: rustfsError instanceof Error ? rustfsError.message : String(rustfsError)
})
}
} else {
log.debug('RustFS is not enabled, skipping upload')
}
} catch (reportError) {
log.warn('Failed to generate report', {
error: reportError instanceof Error ? reportError.message : String(reportError)
})
}
return result
} finally {
if (authService) {
try {
await authService.close()
log.debug('Browser closed')
} catch (closeError) {
log.warn('Error closing browser', {
error: closeError instanceof Error ? closeError.message : String(closeError)
})
}
}
if (dbService) {
try {
await dbService.disconnect()
log.debug('Database disconnected')
} catch (closeError) {
log.warn('Error disconnecting database', {
error: closeError instanceof Error ? closeError.message : String(closeError)
})
}
}
}
}, 'cleaner:run')
}
)
ipcMain.handle(
IPC_CHANNELS.CLEANER_EXPORT_RESULTS,
async (_event, items: ExportResultItem[]): Promise<IpcResult<ExportResultResponse>> => {
return withErrorHandling(async () => {
log.info('Exporting validation results', { count: items.length })
if (!items || items.length === 0) {
throw new ValidationError('没有数据可导出', 'VAL_INVALID_INPUT')
}
const exporter = new ResultExporter()
return await exporter.exportValidationResults(items)
}, 'cleaner:exportResults')
}
)
}

View File

@@ -1,253 +0,0 @@
import { ipcMain } from 'electron'
import { MySqlService } from '../services/database/mysql'
import { SqlServerService } from '../services/database/sql-server'
import { createLogger } from '../services/logger'
import { ValidationError } from '../types/errors'
import type {
MySqlConfig,
MySqlQueryResult,
SqlServerConfig,
SqlServerQueryResult
} from '../types/ipc-api.types'
import { IPC_CHANNELS } from '../../shared/ipc-channels'
import { withErrorHandling, type IpcResult } from './index'
const log = createLogger('DatabaseHandler')
// Store MySQL service instances per window/connection
const mysqlServices = new Map<string, MySqlService>()
// Store SQL Server service instances per window/connection
const sqlServerServices = new Map<string, SqlServerService>()
const cleanupBoundWindows = new Set<string>()
function bindWindowCleanup(
windowId: string,
sender: { once: (event: string, listener: () => void) => void }
): void {
if (cleanupBoundWindows.has(windowId)) {
return
}
sender.once('destroyed', () => {
const mysql = getMySqlService(windowId)
const sqlServer = getSqlServerService(windowId)
if (mysql) {
mysql
.disconnect()
.catch((error) => log.warn('MySQL disconnect on window destroy failed', { error }))
deleteMySqlService(windowId)
}
if (sqlServer) {
sqlServer
.disconnect()
.catch((error) => log.warn('SQL Server disconnect on window destroy failed', { error }))
deleteSqlServerService(windowId)
}
cleanupBoundWindows.delete(windowId)
})
cleanupBoundWindows.add(windowId)
}
/**
* Get or create MySQL service for a connection ID
*/
function getMySqlService(connectionId: string): MySqlService | undefined {
return mysqlServices.get(connectionId)
}
/**
* Set MySQL service for a connection ID
*/
function setMySqlService(connectionId: string, service: MySqlService): void {
mysqlServices.set(connectionId, service)
}
/**
* Delete MySQL service for a connection ID
*/
function deleteMySqlService(connectionId: string): void {
mysqlServices.delete(connectionId)
}
/**
* Get or create SQL Server service for a connection ID
*/
function getSqlServerService(connectionId: string): SqlServerService | undefined {
return sqlServerServices.get(connectionId)
}
/**
* Set SQL Server service for a connection ID
*/
function setSqlServerService(connectionId: string, service: SqlServerService): void {
sqlServerServices.set(connectionId, service)
}
/**
* Delete SQL Server service for a connection ID
*/
function deleteSqlServerService(connectionId: string): void {
sqlServerServices.delete(connectionId)
}
/**
* Register IPC handlers for database operations
*/
export function registerDatabaseHandlers(): void {
// Connect to MySQL
ipcMain.handle(
IPC_CHANNELS.DATABASE_MYSQL_CONNECT,
async (event, config: MySqlConfig): Promise<IpcResult<void>> => {
return withErrorHandling(async () => {
// Use window ID as connection identifier
const windowId = (event.sender as { id: number }).id.toString()
bindWindowCleanup(
windowId,
event.sender as { once: (event: string, listener: () => void) => void }
)
log.info('Connecting to MySQL', { windowId })
const service = new MySqlService(config)
await service.connect()
setMySqlService(windowId, service)
log.info('MySQL connected', { windowId })
}, 'database:mysql:connect')
}
)
// Disconnect from MySQL
ipcMain.handle(
IPC_CHANNELS.DATABASE_MYSQL_DISCONNECT,
async (event): Promise<IpcResult<void>> => {
return withErrorHandling(async () => {
const windowId = (event.sender as { id: number }).id.toString()
const service = getMySqlService(windowId)
if (service) {
await service.disconnect()
deleteMySqlService(windowId)
log.info('MySQL disconnected', { windowId })
}
}, 'database:mysql:disconnect')
}
)
// Check if MySQL is connected
ipcMain.handle(
IPC_CHANNELS.DATABASE_MYSQL_IS_CONNECTED,
async (event): Promise<IpcResult<boolean>> => {
return withErrorHandling(async () => {
const windowId = (event.sender as { id: number }).id.toString()
const service = getMySqlService(windowId)
return service ? service.isConnected() : false
}, 'database:mysql:isConnected')
}
)
// Execute MySQL query
ipcMain.handle(
IPC_CHANNELS.DATABASE_MYSQL_QUERY,
async (event, sql: string, params?: unknown[]): Promise<IpcResult<MySqlQueryResult>> => {
return withErrorHandling(async () => {
const windowId = (event.sender as { id: number }).id.toString()
const service = getMySqlService(windowId)
if (!service) {
throw new ValidationError(
'Not connected to MySQL. Call connect() first.',
'VAL_INVALID_INPUT'
)
}
log.debug('Executing MySQL query', { windowId, sql: sql.substring(0, 100) })
return await service.query(sql, params)
}, 'database:mysql:query')
}
)
// Connect to SQL Server
ipcMain.handle(
IPC_CHANNELS.DATABASE_SQLSERVER_CONNECT,
async (event, config: SqlServerConfig): Promise<IpcResult<void>> => {
return withErrorHandling(async () => {
const windowId = (event.sender as { id: number }).id.toString()
bindWindowCleanup(
windowId,
event.sender as { once: (event: string, listener: () => void) => void }
)
log.info('Connecting to SQL Server', { windowId })
const service = new SqlServerService(config)
await service.connect()
setSqlServerService(windowId, service)
log.info('SQL Server connected', { windowId })
}, 'database:sqlserver:connect')
}
)
// Disconnect from SQL Server
ipcMain.handle(
IPC_CHANNELS.DATABASE_SQLSERVER_DISCONNECT,
async (event): Promise<IpcResult<void>> => {
return withErrorHandling(async () => {
const windowId = (event.sender as { id: number }).id.toString()
const service = getSqlServerService(windowId)
if (service) {
await service.disconnect()
deleteSqlServerService(windowId)
log.info('SQL Server disconnected', { windowId })
}
}, 'database:sqlserver:disconnect')
}
)
// Check if SQL Server is connected
ipcMain.handle(
IPC_CHANNELS.DATABASE_SQLSERVER_IS_CONNECTED,
async (event): Promise<IpcResult<boolean>> => {
return withErrorHandling(async () => {
const windowId = (event.sender as { id: number }).id.toString()
const service = getSqlServerService(windowId)
return service ? service.isConnected() : false
}, 'database:sqlserver:isConnected')
}
)
// Execute SQL Server query
ipcMain.handle(
IPC_CHANNELS.DATABASE_SQLSERVER_QUERY,
async (
event,
sqlString: string,
params?: Record<string, unknown>
): Promise<IpcResult<SqlServerQueryResult>> => {
return withErrorHandling(async () => {
const windowId = (event.sender as { id: number }).id.toString()
const service = getSqlServerService(windowId)
if (!service) {
throw new ValidationError(
'Not connected to SQL Server. Call connect() first.',
'VAL_INVALID_INPUT'
)
}
log.debug('Executing SQL Server query', { windowId, sql: sqlString.substring(0, 100) })
// Use queryWithParams for named parameters, or query for no params
if (params && Object.keys(params).length > 0) {
// Convert to the format expected by queryWithParams
const typedParams: Record<string, { value: unknown }> = {}
for (const [key, value] of Object.entries(params)) {
typedParams[key] = { value }
}
return await service.queryWithParams(sqlString, typedParams)
} else {
return await service.query(sqlString)
}
}, 'database:sqlserver:query')
}
)
}

View File

@@ -1,278 +0,0 @@
import { ipcMain, type WebContents } from 'electron'
import { ErpAuthService } from '../services/erp/erp-auth'
import { ExtractorService } from '../services/erp/extractor'
import { OrderNumberResolver } from '../services/erp/order-resolver'
import { create, type IDatabaseService } from '../services/database'
import { createLogger } from '../services/logger'
import { logAudit } from '../services/logger/audit-logger'
import { SessionManager } from '../services/user/session-manager'
import { withErrorHandling, type IpcResult } from './index'
import { ErpConnectionError, ValidationError, DatabaseQueryError } from '../types/errors'
import type { ExtractorInput, ExtractorResult, ExtractionProgress } from '../types/extractor.types'
import { UserErpConfigService } from '../services/user/user-erp-config-service'
import { ConfigManager } from '../services/config/config-manager'
import { IPC_CHANNELS } from '../../shared/ipc-channels'
const log = createLogger('ExtractorHandler')
function sendProgress(
sender: WebContents,
message: string,
progress: number,
extra?: Partial<ExtractionProgress>
): void {
try {
const progressData = { message, progress, ...extra }
sender.send(IPC_CHANNELS.EXTRACTOR_PROGRESS, progressData)
} catch (error) {
log.warn('Failed to send progress event', { error })
}
}
function sendLog(sender: WebContents, level: string, message: string): void {
try {
sender.send(IPC_CHANNELS.EXTRACTOR_LOG, { level, message })
} catch (error) {
log.warn('Failed to send log event', { error })
}
}
/**
* Get ERP configuration for current user
* URL is from config.yaml (fixed infrastructure)
* Username and password are from user's database config
*/
async function getErpConfig(): Promise<{
url: string
username: string
password: string
}> {
// Get ERP URL from config.yaml (fixed for all users)
const configManager = ConfigManager.getInstance()
const globalConfig = configManager.getConfig()
const erpUrl = globalConfig.erp.url
// Get username and password from user's database config
const erpConfigService = UserErpConfigService.getInstance()
const userConfig = await erpConfigService.getCurrentUserErpConfig()
if (!userConfig || !userConfig.username || !userConfig.password) {
throw new ValidationError(
'ERP 配置不完整。请在设置中配置 ERP 用户名和密码',
'VAL_MISSING_REQUIRED'
)
}
return {
url: erpUrl,
username: userConfig.username,
password: userConfig.password
}
}
/**
* Register IPC handlers for extractor service
*/
export function registerExtractorHandlers(): void {
ipcMain.handle(
IPC_CHANNELS.EXTRACTOR_RUN,
async (event, input: ExtractorInput): Promise<IpcResult<ExtractorResult>> => {
const sender = event.sender
return withErrorHandling(async () => {
let authService: ErpAuthService | null = null
let dbService: IDatabaseService | null = null
try {
// Get ERP configuration from database for current user
log.info('Fetching ERP configuration from database...')
const erpConfig = await getErpConfig()
log.info('ERP config retrieved', {
url: erpConfig.url ? 'configured' : 'EMPTY',
username: erpConfig.username ? 'configured' : 'EMPTY'
})
// Create database service using factory
log.info('Connecting to database for order resolution...')
sendProgress(sender, '连接数据库...', 3.33, {
phase: 'login',
subProgress: { step: '连接数据库', current: 1, total: 3 }
})
sendLog(sender, 'system', '正在连接数据库...')
try {
dbService = await create()
} catch (error) {
throw new DatabaseQueryError(
'数据库连接失败',
'DB_CONNECTION_FAILED',
error instanceof Error ? error : undefined
)
}
// Resolve order numbers (convert productionIDs to 生产订单号)
sendProgress(sender, '解析订单号...', 6.67, {
phase: 'login',
subProgress: { step: '解析订单号', current: 2, total: 3 }
})
sendLog(sender, 'info', '正在解析订单号...')
const resolver = new OrderNumberResolver(dbService)
const mappings = await resolver.resolve(input.orderNumbers)
// Get valid order numbers and warnings
const validOrderNumbers = resolver.getValidOrderNumbers(mappings)
const warnings = resolver.getWarnings(mappings)
// Get deduplication report for detailed logging
const dedupReport = resolver.getDeduplicationReport(mappings)
if (warnings.length > 0) {
log.warn('Resolution warnings', { warnings })
}
if (validOrderNumbers.length === 0) {
throw new ValidationError(
'没有有效的生产订单号可处理。请检查输入的格式或数据库连接。',
'VAL_INVALID_INPUT'
)
}
log.info('Resolved order numbers', { count: validOrderNumbers.length })
// Log deduplication summary
sendLog(sender, 'info', dedupReport.summary)
// Log only merged mappings (where multiple productionIDs map to the same order number)
if (dedupReport.inputCount > dedupReport.uniqueOrderNumbersCount) {
sendLog(sender, 'info', '重复合并详情:')
dedupReport.orderNumberGroups.forEach((productionIds, orderNumber) => {
if (productionIds.length > 1) {
sendLog(
sender,
'info',
` ${orderNumber}${productionIds.join('、')} (共 ${productionIds.length} 个总排号)`
)
}
})
}
// Create auth service and login
authService = new ErpAuthService({
url: erpConfig.url,
username: erpConfig.username,
password: erpConfig.password,
headless: true
})
sendProgress(sender, '登录 ERP 系统...', 9.99, {
phase: 'login',
subProgress: { step: '登录 ERP 系统', current: 3, total: 3 }
})
sendLog(sender, 'system', '正在登录 ERP 系统...')
log.info('Logging in to ERP...')
try {
await authService.login()
} catch (error) {
const errorMsg = error instanceof Error ? error.message : '未知错误'
sendLog(sender, 'error', `ERP 登录失败:${errorMsg}`)
throw new ErpConnectionError(
'ERP 登录失败',
'ERP_LOGIN_FAILED',
error instanceof Error ? error : undefined
)
}
log.info('Login successful')
sendLog(sender, 'success', 'ERP 登录成功')
// Create extractor service and run extraction with resolved order numbers
const extractor = new ExtractorService(authService)
log.info('Starting extraction', { orderCount: validOrderNumbers.length })
const modifiedInput: ExtractorInput = {
...input,
orderNumbers: validOrderNumbers,
onProgress: (message, progress, extra) => {
sendProgress(sender, message, progress, extra)
sendLog(sender, 'info', message)
},
onLog: (level, message) => {
sendLog(sender, level, message)
}
}
const result = await extractor.extract(modifiedInput)
// Add warnings to result errors if any
if (warnings.length > 0) {
result.errors = [...warnings, ...result.errors]
}
log.info('Extraction completed', {
rowCount: result.recordCount,
errorCount: result.errors.length
})
// Log detailed error information if any errors occurred
if (result.errors.length > 0) {
log.warn('Extraction errors occurred', { errors: result.errors })
result.errors.forEach((err, index) => {
log.error(`Error ${index + 1}/${result.errors.length}: ${err}`)
})
}
// Audit log: EXTRACT (non-blocking)
const os = await import('os')
const currentUser = SessionManager.getInstance().getUserInfo()
if (currentUser) {
const status: 'success' | 'failure' | 'partial' =
result.errors.length > 0 && result.recordCount > 0
? 'partial'
: result.errors.length > 0
? 'failure'
: 'success'
logAudit('EXTRACT', String(currentUser.id), {
username: currentUser.username,
computerName: os.hostname(),
resource: 'MATERIAL_PLAN',
status,
metadata: {
orderCount: validOrderNumbers.length,
recordCount: result.recordCount,
errorCount: result.errors.length
}
}).catch((err) => log.warn('Failed to write audit log', { err }))
}
return result
} finally {
// Clean up: close browser
if (authService) {
try {
await authService.close()
log.debug('Browser closed')
} catch (closeError) {
log.warn('Error closing browser', {
error: closeError instanceof Error ? closeError.message : String(closeError)
})
}
}
// Clean up: disconnect database
if (dbService) {
try {
await dbService.disconnect()
log.debug('Database disconnected')
} catch (closeError) {
log.warn('Error disconnecting database', {
error: closeError instanceof Error ? closeError.message : String(closeError)
})
}
}
}
}, 'extractor:run')
}
)
}

View File

@@ -1,99 +0,0 @@
import { app, ipcMain, shell } from 'electron'
import * as fs from 'fs/promises'
import * as path from 'path'
import { createLogger } from '../services/logger'
import { IPC_CHANNELS } from '../../shared/ipc-channels'
import { ValidationError } from '../types/errors'
import { withErrorHandling, type IpcResult } from './index'
const log = createLogger('FileHandler')
function getAllowedRoots(): string[] {
return [path.resolve(app.getAppPath()), path.resolve(app.getPath('userData'))]
}
export function isPathWithinAllowedRoots(inputPath: string, roots: string[]): boolean {
const normalized = path.resolve(inputPath)
return roots.some((root) => {
const rel = path.relative(root, normalized)
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel))
})
}
function normalizeAndValidatePath(inputPath: string): string {
const normalized = path.resolve(inputPath)
const isAllowed = isPathWithinAllowedRoots(normalized, getAllowedRoots())
if (!isAllowed) {
throw new ValidationError('Path is outside allowed roots', 'VAL_INVALID_INPUT')
}
return normalized
}
export function registerFileHandlers(): void {
ipcMain.handle(
IPC_CHANNELS.FILE_READ,
async (_event, filePath: string): Promise<IpcResult<string>> => {
return withErrorHandling(async () => {
const safePath = normalizeAndValidatePath(filePath)
log.debug('Reading file', { filePath: safePath })
return await fs.readFile(safePath, 'utf-8')
}, 'file:read')
}
)
ipcMain.handle(
IPC_CHANNELS.FILE_WRITE,
async (_event, filePath: string, content: string): Promise<IpcResult<void>> => {
return withErrorHandling(async () => {
const safePath = normalizeAndValidatePath(filePath)
log.debug('Writing file', { filePath: safePath })
const dir = path.dirname(safePath)
await fs.mkdir(dir, { recursive: true })
await fs.writeFile(safePath, content, 'utf-8')
}, 'file:write')
}
)
ipcMain.handle(
IPC_CHANNELS.FILE_EXISTS,
async (_event, filePath: string): Promise<IpcResult<boolean>> => {
return withErrorHandling(async () => {
const safePath = normalizeAndValidatePath(filePath)
try {
await fs.access(safePath)
return true
} catch {
return false
}
}, 'file:exists')
}
)
ipcMain.handle(
IPC_CHANNELS.FILE_LIST,
async (_event, dirPath: string): Promise<IpcResult<string[]>> => {
return withErrorHandling(async () => {
const safePath = normalizeAndValidatePath(dirPath)
log.debug('Listing directory', { dirPath: safePath })
const entries = await fs.readdir(safePath, { withFileTypes: true })
return entries
.filter((entry) => entry.isFile())
.map((entry) => entry.name)
.sort()
}, 'file:list')
}
)
ipcMain.handle(
IPC_CHANNELS.FILE_OPEN_PATH,
async (_event, filePath: string): Promise<IpcResult<void>> => {
return withErrorHandling(async () => {
const safePath = normalizeAndValidatePath(filePath)
log.debug('Opening path in explorer', { filePath: safePath })
await fs.access(safePath)
await shell.openPath(safePath)
}, 'file:openPath')
}
)
}

View File

@@ -1,111 +0,0 @@
/**
* IPC Handler registration
* Centralized registration for all IPC handlers
*/
import { registerFileHandlers } from './file-handler'
import { registerExtractorHandlers } from './extractor-handler'
import { registerCleanerHandlers } from './cleaner-handler'
import { registerDatabaseHandlers } from './database-handler'
import { registerResolverHandlers } from './resolver-handler'
import { registerAuthHandlers } from './auth-handler'
import { registerValidationHandlers } from './validation-handler'
import { registerSettingsHandlers } from './settings-handler'
import { registerMaterialTypeHandlers } from './material-type-handler'
import { registerUserErpConfigHandlers } from './user-erp-config-handler'
import { registerLoggerHandlers } from './logger-handler'
import { registerReportHandlers } from './report-handler'
import { 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'
const log = createLogger('IPC')
/**
* Standard result type for all IPC handlers
*/
export interface IpcResult<T = unknown> {
success: boolean
data?: T
error?: string
code?: string
}
export function ok<T>(data: T): IpcResult<T> {
return { success: true, data }
}
export function fail<T = unknown>(error: string, code?: string): IpcResult<T> {
return { success: false, error, code }
}
/**
* Higher-order function to wrap IPC handlers with consistent error handling
* Enhanced to capture full error context including stack traces
* @param handler - The async handler function to wrap
* @param context - The context name for logging
* @returns A wrapped handler that returns IpcResult
*/
export function withErrorHandling<T>(
handler: () => Promise<T>,
context: string
): Promise<IpcResult<T>> {
return handler()
.then((data): IpcResult<T> => {
log.debug(`[${context}] Handler completed successfully`)
return ok(data)
})
.catch((error: unknown) => {
const message = getErrorMessage(error)
const code = getErrorCode(error)
// Serialize error with full details
if (process.env.NODE_ENV === 'production') {
sanitizeError(serializeError(error))
} else {
serializeError(error)
}
if (isBaseError(error)) {
logError(log, `[${context}] ${error.name}`, error, {
code,
cause: (error as any).cause?.message,
handler: context
})
} else {
logError(log, `[${context}] Error`, error, {
code,
handler: context
})
}
// Include stack trace in development
if (process.env.NODE_ENV !== 'production' && error instanceof Error) {
log.debug(`[${context}] Stack trace: ${error.stack}`)
}
return fail<T>(message, code)
})
}
/**
* Register all IPC handlers
*/
export function registerIpcHandlers(): void {
log.info('Registering IPC handlers...')
registerFileHandlers()
registerExtractorHandlers()
registerCleanerHandlers()
registerDatabaseHandlers()
registerResolverHandlers()
registerAuthHandlers()
registerValidationHandlers()
registerSettingsHandlers()
registerMaterialTypeHandlers()
registerUserErpConfigHandlers()
registerLoggerHandlers()
registerReportHandlers()
registerUpdateHandlers()
log.info('All IPC handlers registered')
}

View File

@@ -1,229 +0,0 @@
/**
* 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 }

View File

@@ -1,126 +0,0 @@
/**
* 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')
}

View File

@@ -1,183 +0,0 @@
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')
}
)
}

View File

@@ -1,130 +0,0 @@
/**
* IPC handlers for Order Number Resolver
*
* Provides APIs for the renderer process to:
* - Resolve productionIDs and 生产订单号 to production order numbers
* - Validate input formats
*/
import { ipcMain } from 'electron'
import { create, type IDatabaseService } from '../services/database'
import { OrderNumberResolver } from '../services/erp/order-resolver'
import { createLogger } from '../services/logger'
import type { OrderMapping, ResolutionStats } from '../services/erp/order-resolver'
import { IPC_CHANNELS } from '../../shared/ipc-channels'
import { withErrorHandling, type IpcResult } from './index'
const log = createLogger('ResolverHandler')
/**
* Resolver input from renderer
*/
export interface ResolverInput {
/** List of order numbers/productionIDs to resolve */
inputs: string[]
}
/**
* Resolver response to renderer
*/
export interface ResolverResponse {
/** Whether the resolution was successful */
success: boolean
/** Resolved order mappings */
mappings?: OrderMapping[]
/** Valid production order numbers ready for use */
validOrderNumbers?: string[]
/** Warning messages for invalid inputs */
warnings?: string[]
/** Resolution statistics */
stats?: ResolutionStats
/** Error message if failed */
error?: string
}
/**
* Register IPC handlers for order number resolver
*/
export function registerResolverHandlers(): void {
/**
* Resolve order numbers
* Converts productionIDs and 生产订单号 to production order numbers
*/
ipcMain.handle(
IPC_CHANNELS.RESOLVER_RESOLVE,
async (_event, input: ResolverInput): Promise<IpcResult<ResolverResponse>> => {
let dbService: IDatabaseService | null = null
return withErrorHandling(async () => {
// Create database service using factory
log.info('Connecting to database for resolution', { inputCount: input.inputs.length })
dbService = await create()
// Create resolver and resolve inputs
const resolver = new OrderNumberResolver(dbService)
const mappings = await resolver.resolve(input.inputs)
// Get valid order numbers and warnings
const validOrderNumbers = resolver.getValidOrderNumbers(mappings)
const warnings = resolver.getWarnings(mappings)
const stats = resolver.getStats(mappings)
log.info('Resolution completed', {
inputCount: input.inputs.length,
validCount: validOrderNumbers.length,
warningCount: warnings.length
})
return {
success: true,
mappings,
validOrderNumbers,
warnings,
stats
}
}, 'resolver:resolve').finally(async () => {
// Clean up database connection
if (dbService) {
try {
await dbService.disconnect()
log.debug('Database disconnected')
} catch (closeError) {
log.warn('Error disconnecting database', {
error: closeError instanceof Error ? closeError.message : String(closeError)
})
}
}
})
}
)
/**
* Validate input format only (without database lookup)
*/
ipcMain.handle(
IPC_CHANNELS.RESOLVER_VALIDATE_FORMAT,
async (
_event,
inputs: string[]
): Promise<
IpcResult<Array<{ input: string; type: 'productionId' | 'orderNumber' | 'unknown' }>>
> => {
return withErrorHandling(async () => {
// Create a mock resolver without database connection
const resolver = new OrderNumberResolver({
isConnected: () => false,
type: 'mysql'
} as IDatabaseService)
const results = inputs.map((input) => ({
input,
type: resolver.recognizeType(input)
}))
log.debug('Format validation completed', { inputCount: inputs.length })
return results
}, 'resolver:validateFormat')
}
)
}

View File

@@ -1,200 +0,0 @@
import { ipcMain } from 'electron'
import { ConfigManager } from '../services/config/config-manager'
import { SessionManager } from '../services/user/session-manager'
import { UserErpConfigService } from '../services/user/user-erp-config-service'
import { MySqlService } from '../services/database/mysql'
import { SqlServerService } from '../services/database/sql-server'
import { createLogger } from '../services/logger'
import { logAudit } from '../services/logger/audit-logger'
import type { UserType, ConnectionTestResult, SaveSettingsResult } from '../types/settings.types'
import { IPC_CHANNELS } from '../../shared/ipc-channels'
import { ValidationError } from '../types/errors'
import { withErrorHandling, type IpcResult } from './index'
import type { CleanerConfig } from '../types/config.schema'
const log = createLogger('SettingsHandler')
type ErpSettingsPayload = {
erp?: {
username?: string
password?: string
}
}
export function registerSettingsHandlers(): void {
const configManager = ConfigManager.getInstance()
const sessionManager = SessionManager.getInstance()
const erpConfigService = UserErpConfigService.getInstance()
ipcMain.handle(IPC_CHANNELS.SETTINGS_GET_USER_TYPE, async (): Promise<IpcResult<UserType>> => {
return withErrorHandling(
async () => (sessionManager.getUserType() as UserType) || 'Guest',
'settings:getUserType'
)
})
ipcMain.handle(
IPC_CHANNELS.SETTINGS_GET_SETTINGS,
async (): Promise<IpcResult<{ erp: { username: string; password: string } }>> => {
return withErrorHandling(async () => {
const userErpConfig = await erpConfigService.getCurrentUserErpConfig()
return {
erp: {
username: userErpConfig?.username || '',
password: userErpConfig?.password || ''
}
}
}, 'settings:getSettings')
}
)
ipcMain.handle(
IPC_CHANNELS.SETTINGS_SAVE_SETTINGS,
async (_event, settings: ErpSettingsPayload): Promise<IpcResult<SaveSettingsResult>> => {
return withErrorHandling(async () => {
if (settings.erp) {
const currentUser = sessionManager.getUserInfo()
if (!currentUser) {
throw new ValidationError('未找到当前用户', 'VAL_INVALID_INPUT')
}
await erpConfigService.updateCurrentUserErpConfig({
username: settings.erp.username || '',
password: settings.erp.password || ''
})
// Audit log: SETTINGS_CHANGE (non-blocking)
const os = await import('os')
logAudit('SETTINGS_CHANGE', String(currentUser.id), {
username: currentUser.username,
computerName: os.hostname(),
resource: 'ERP_CONFIG',
status: 'success',
metadata: { changeType: 'erp_credentials', usernameChanged: !!settings.erp.username }
}).catch((err) => log.warn('Failed to write audit log', { err }))
}
return { success: true }
}, 'settings:saveSettings')
}
)
ipcMain.handle(
IPC_CHANNELS.SETTINGS_RESET_DEFAULTS,
async (): Promise<IpcResult<SaveSettingsResult>> => {
return withErrorHandling(async () => {
const userType = sessionManager.getUserType()
if (userType !== 'Admin') {
throw new ValidationError('只有管理员可以恢复默认设置', 'VAL_INVALID_INPUT')
}
const success = await configManager.resetToDefaults()
if (!success) {
throw new ValidationError('恢复默认设置失败', 'VAL_INVALID_INPUT')
}
return { success: true }
}, 'settings:resetDefaults')
}
)
ipcMain.handle(
IPC_CHANNELS.SETTINGS_TEST_DB_CONNECTION,
async (): Promise<IpcResult<ConnectionTestResult>> => {
return withErrorHandling(async () => {
log.info('Testing database connection')
const config = configManager.getConfig()
const dbType = config.database.activeType
if (dbType === 'mysql') {
const dbConfig = config.database.mysql
if (!dbConfig.host || !dbConfig.database || !dbConfig.username) {
return {
success: false,
message: '请先配置 MySQL 主机、数据库名和用户名'
}
}
const mysqlService = new MySqlService({
host: dbConfig.host,
port: dbConfig.port,
user: dbConfig.username,
password: dbConfig.password,
database: dbConfig.database
})
try {
await mysqlService.connect()
await mysqlService.disconnect()
return {
success: true,
message: 'MySQL 数据库连接测试成功!'
}
} catch (error) {
const message = error instanceof Error ? error.message : '连接失败'
return {
success: false,
message: `MySQL 数据库连接测试失败:${message}`
}
}
}
const dbConfig = config.database.sqlserver
if (!dbConfig.server || !dbConfig.database || !dbConfig.username) {
return {
success: false,
message: '请先配置 SQL Server 服务器、数据库名和用户名'
}
}
const sqlServerService = new SqlServerService({
server: dbConfig.server,
port: dbConfig.port,
user: dbConfig.username,
password: dbConfig.password,
database: dbConfig.database,
options: {
trustServerCertificate: dbConfig.trustServerCertificate
}
})
try {
await sqlServerService.connect()
await sqlServerService.disconnect()
return {
success: true,
message: 'SQL Server 数据库连接测试成功!'
}
} catch (error) {
const message = error instanceof Error ? error.message : '连接失败'
return {
success: false,
message: `SQL Server 数据库连接测试失败:${message}`
}
}
}, 'settings:testDbConnection')
}
)
ipcMain.handle(IPC_CHANNELS.CONFIG_GET_CLEANER, async (): Promise<IpcResult<CleanerConfig>> => {
return withErrorHandling(async () => {
const configManager = ConfigManager.getInstance()
const config = configManager.getConfig()
return config.cleaner
}, 'config:getCleaner')
})
ipcMain.handle(
IPC_CHANNELS.CONFIG_UPDATE_CLEANER,
async (_event, updates: Partial<CleanerConfig>): Promise<IpcResult<CleanerConfig>> => {
return withErrorHandling(async () => {
const configManager = ConfigManager.getInstance()
const result = await configManager.updateConfig({ cleaner: updates as CleanerConfig })
if (!result.success) {
throw new Error(result.error)
}
return configManager.getConfig().cleaner
}, 'config:updateCleaner')
}
)
}

View File

@@ -1,55 +0,0 @@
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'
)
})
}

View File

@@ -1,147 +0,0 @@
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')
}
)
}

View File

@@ -1,890 +0,0 @@
/**
* IPC handlers for material validation operations
*
* Provides endpoints for:
* - Running material validation from database
* - Getting/setting materials to be deleted
* - Manager-based filtering
*/
import { ipcMain } from 'electron'
import { MySqlService } from '../services/database/mysql'
import { SqlServerService } from '../services/database/sql-server'
import { MaterialsToBeDeletedDAO } from '../services/database/materials-to-be-deleted-dao'
import { DiscreteMaterialPlanDAO } from '../services/database/discrete-material-plan-dao'
import { ConfigManager } from '../services/config/config-manager'
import { createLogger } from '../services/logger'
import type {
ValidationRequest,
ValidationResponse,
MaterialUpsertBatchRequest,
MaterialDeleteRequest,
MaterialOperationResponse,
ValidationResult,
MaterialRecordSummary
} from '../types/validation.types'
import { IPC_CHANNELS } from '../../shared/ipc-channels'
const log = createLogger('ValidationHandler')
/**
* Shared state for Production IDs from extractor page
* This is a simple in-memory store for sharing Production IDs between pages
*/
const sharedProductionIdsBySender = new Map<number, Set<string>>()
/**
* Set shared Production IDs
*/
export function setSharedProductionIds(senderId: number, ids: string[]): void {
sharedProductionIdsBySender.set(senderId, new Set(ids))
}
/**
* Get shared Production IDs
*/
export function getSharedProductionIds(senderId: number): string[] {
const senderSet = sharedProductionIdsBySender.get(senderId)
return senderSet ? [...senderSet] : []
}
/**
* Clear shared Production IDs
*/
export function clearSharedProductionIds(senderId: number): void {
sharedProductionIdsBySender.delete(senderId)
}
/**
* Get database service for validation operations (MySQL or SQL Server)
*/
async function getValidationDatabaseService(): Promise<MySqlService | SqlServerService> {
const configManager = ConfigManager.getInstance()
const config = configManager.getConfig()
const dbType = configManager.getDatabaseType()
if (dbType === 'sqlserver') {
const dbConfig = config.database.sqlserver
const sqlServerService = new SqlServerService({
server: dbConfig.server,
port: dbConfig.port,
user: dbConfig.username,
password: dbConfig.password,
database: dbConfig.database,
options: {
encrypt: false,
trustServerCertificate: dbConfig.trustServerCertificate
}
})
await sqlServerService.connect()
return sqlServerService
} else {
const dbConfig = config.database.mysql
const mysqlService = new MySqlService({
host: dbConfig.host,
port: dbConfig.port,
user: dbConfig.username,
password: dbConfig.password,
database: dbConfig.database
})
await mysqlService.connect()
return mysqlService
}
}
/**
* Get table name based on database type
* Converts MySQL schema_tablename format to SQL Server [schema].[tablename] format
* e.g., productionContractData_26年压力表合同数据 -> [productionContractData].[26年压力表合同数据]
* dbo_MaterialsToBeDeleted -> [dbo].[MaterialsToBeDeleted]
*/
function getTableName(mysqlTableName: string): string {
const configManager = ConfigManager.getInstance()
const dbType = configManager.getDatabaseType()
if (dbType === 'sqlserver') {
// Find the FIRST underscore to split schema and table name
// This handles patterns like: schema_tablename
const firstUnderscoreIndex = mysqlTableName.indexOf('_')
if (firstUnderscoreIndex > 0) {
const schema = mysqlTableName.substring(0, firstUnderscoreIndex)
const tableName = mysqlTableName.substring(firstUnderscoreIndex + 1)
return `[${schema}].[${tableName}]`
}
// If no underscore found, default to dbo schema
return `[dbo].[${mysqlTableName}]`
}
return mysqlTableName
}
/**
* Read Production IDs from file
*/
function readProductionIds(filePath: string): string[] {
const fs = require('fs')
const content = fs.readFileSync(filePath, 'utf-8') as string
return content
.split('\n')
.map((line: string) => line.trim())
.filter((line: string) => line.length > 0)
}
/**
* Identify input type (production ID or order number)
*/
function identifyInputType(input: string): 'production_id' | 'order_number' | 'unknown' {
// Order number: SC + 14 digits
if (/^SC\d{14}$/.test(input)) {
return 'order_number'
}
// Production ID: 2 digits + 1 letter + 1-6 digits
if (/^\d{2}[A-Za-z]\d{1,6}$/.test(input)) {
return 'production_id'
}
return 'unknown'
}
/**
* Get source numbers from inputs
*/
async function getSourceNumbersFromInputs(
inputs: string[],
dbService: MySqlService | SqlServerService
): Promise<string[]> {
const productionIds: string[] = []
const orderNumbers: string[] = []
const configManager = ConfigManager.getInstance()
const dbType = configManager.getDatabaseType()
const isSqlServer = dbType === 'sqlserver'
for (const item of inputs) {
const type = identifyInputType(item)
if (type === 'order_number') {
orderNumbers.push(item)
} else if (type === 'production_id') {
productionIds.push(item)
}
}
// Query production contract data for production IDs
// Table name in MySQL: productionContractData_26年压力表合同数据
// Column name: 生产订单号 (SourceNumber)
if (productionIds.length > 0) {
const contractTableName = getTableName('productionContractData_26年压力表合同数据')
const batchSize = 2000
if (isSqlServer) {
const sql = require('mssql')
const allOrderNumbers: string[] = []
for (let i = 0; i < productionIds.length; i += batchSize) {
const batch = productionIds.slice(i, i + batchSize)
const placeholders = batch.map((_, idx) => `@p${idx}`).join(',')
const params: Record<string, { value: string; type: any }> = {}
batch.forEach((id, idx) => {
params[`p${idx}`] = { value: id, type: sql.NVarChar }
})
const contractSql = `
SELECT DISTINCT 生产订单号
FROM ${contractTableName}
WHERE 总排号 IN (${placeholders})
`
const contractResult = await (dbService as SqlServerService).queryWithParams(
contractSql,
params
)
const dbOrderNumbers = contractResult.rows.map((row) => row. as string)
allOrderNumbers.push(...dbOrderNumbers)
}
orderNumbers.push(...allOrderNumbers)
} else {
const allOrderNumbers: string[] = []
for (let i = 0; i < productionIds.length; i += batchSize) {
const batch = productionIds.slice(i, i + batchSize)
const placeholders = batch.map(() => '?').join(',')
const contractSql = `
SELECT DISTINCT 生产订单号
FROM ${contractTableName}
WHERE 总排号 IN (${placeholders})
`
const contractResult = await (dbService as MySqlService).query(contractSql, batch)
const dbOrderNumbers = contractResult.rows.map((row) => row. as string)
allOrderNumbers.push(...dbOrderNumbers)
}
orderNumbers.push(...allOrderNumbers)
}
}
// Deduplicate
return [...new Set(orderNumbers)]
}
/**
* Register IPC handlers for validation operations
*/
export function registerValidationHandlers(): void {
// ==================== VALIDATION ====================
/**
* Run material validation from database
*/
ipcMain.handle(
IPC_CHANNELS.VALIDATION_VALIDATE,
async (event, request: ValidationRequest): Promise<ValidationResponse> => {
let dbService: MySqlService | SqlServerService | null = null
try {
// Get current user info
const sessionManager = (
await import('../services/user/session-manager')
).SessionManager.getInstance()
const userInfo = sessionManager.getUserInfo()
if (!userInfo) {
return {
success: false,
error: '用户未登录',
stats: {
totalRecords: 0,
matchedCount: 0,
markedCount: 0
}
}
}
const isAdmin = userInfo.userType === 'Admin'
const username = userInfo.username
log.info('Starting validation', { mode: request.mode, user: username, isAdmin })
// Connect to database
dbService = await getValidationDatabaseService()
const configManager = ConfigManager.getInstance()
const dbType = configManager.getDatabaseType()
const isSqlServer = dbType === 'sqlserver'
let sourceNumbers: string[] | null = null
// Get source numbers based on mode
if (request.mode === 'database_filtered') {
if (request.useSharedProductionIds) {
// Use shared Production IDs from extractor page
const sharedIds = getSharedProductionIds(event.sender.id)
log.info(`Using ${sharedIds.length} shared Production IDs`)
if (sharedIds.length === 0) {
return {
success: false,
error: '没有可用的共享 Production ID。请在数据提取页面输入 Production ID。',
stats: {
totalRecords: 0,
matchedCount: 0,
markedCount: 0
}
}
}
sourceNumbers = await getSourceNumbersFromInputs(sharedIds, dbService)
log.info(`Got ${sourceNumbers.length} source numbers from shared Production IDs`)
// Check if we got any order numbers from the shared Production IDs
if (sourceNumbers.length === 0) {
return {
success: false,
error:
'共享的 Production ID 没有找到对应的订单数据。请确保在数据提取页面输入了有效的 Production ID 并成功获取了订单数据。',
stats: {
totalRecords: 0,
matchedCount: 0,
markedCount: 0
}
}
}
} else if (request.productionIdFile) {
// Read from file
const inputs = readProductionIds(request.productionIdFile)
log.info(`Read ${inputs.length} inputs from file`)
sourceNumbers = await getSourceNumbersFromInputs(inputs, dbService)
log.info(`Got ${sourceNumbers.length} source numbers`)
// Check if we got any order numbers from the file
if (sourceNumbers.length === 0) {
return {
success: false,
error:
'文件中的 Production ID 没有找到对应的订单数据。请检查 Production ID 是否正确,或确保数据库中有对应的订单数据。',
stats: {
totalRecords: 0,
matchedCount: 0,
markedCount: 0
}
}
}
}
}
// Get material records from DiscreteMaterialPlanData
const materialDao = new DiscreteMaterialPlanDAO()
let materialRecords: any[] = []
if (request.mode === 'database_full') {
// Full table query with deduplication by MaterialCode
materialRecords = await materialDao.queryAllDistinctByMaterialCode()
} else if (sourceNumbers && sourceNumbers.length > 0) {
// Filtered query by source numbers
materialRecords = await materialDao.queryBySourceNumbersDistinct(sourceNumbers)
}
if (materialRecords.length === 0) {
return {
success: false,
error: '未找到物料记录。请检查数据库中是否有对应订单的物料数据。',
stats: {
totalRecords: 0,
matchedCount: 0,
markedCount: 0
}
}
}
// Get type keywords from MaterialsTypeToBeDeleted
const typeKeywordTableName = getTableName('dbo_MaterialsTypeToBeDeleted')
const typeKeywordSql = `
SELECT MaterialName, ManagerName
FROM ${typeKeywordTableName}
WHERE MaterialName IS NOT NULL
`
const typeKeywordResult = isSqlServer
? await (dbService as SqlServerService).query(typeKeywordSql)
: await (dbService as MySqlService).query(typeKeywordSql)
const typeKeywords = typeKeywordResult.rows.map((row) => ({
materialName: row.MaterialName as string,
managerName: row.ManagerName as string
}))
// Get marked material codes from MaterialsToBeDeleted
const markedTableName = getTableName('dbo_MaterialsToBeDeleted')
const markedSql = `
SELECT MaterialCode, ManagerName
FROM ${markedTableName}
WHERE MaterialCode IS NOT NULL AND ManagerName IS NOT NULL
`
const markedResult = isSqlServer
? await (dbService as SqlServerService).query(markedSql)
: await (dbService as MySqlService).query(markedSql)
const markedCodesDict = new Map<string, string>()
for (const row of markedResult.rows) {
markedCodesDict.set(row.MaterialCode as string, row.ManagerName as string)
}
// Match materials
const results: ValidationResult[] = []
for (const record of materialRecords) {
const materialName = (record.MaterialName as string) || ''
const materialCode = (record.MaterialCode as string) || ''
const specification = (record.Specification as string) || ''
const model = (record.Model as string) || ''
// Priority 1: Check MaterialsToBeDeleted (MaterialCode exact match)
let managerName = markedCodesDict.get(materialCode) || null
const isMarkedForDeletion = managerName !== null
let matchedTypeKeyword: string | undefined = undefined
// Priority 2: Match with MaterialsTypeToBeDeleted (MaterialName contains)
if (!managerName) {
for (const typeKeyword of typeKeywords) {
if (typeKeyword.materialName && materialName.includes(typeKeyword.materialName)) {
matchedTypeKeyword = typeKeyword.materialName
managerName = typeKeyword.managerName
break
}
}
}
// Priority 3: User Override Match (only for non-admin users)
// Override with current user's typeKeyword if available
if (!isAdmin && username) {
const userKeywords = typeKeywords.filter((tk) => tk.managerName === username)
for (const userKeyword of userKeywords) {
if (userKeyword.materialName && materialName.includes(userKeyword.materialName)) {
matchedTypeKeyword = userKeyword.materialName
managerName = userKeyword.managerName
break // Force override with first match
}
}
}
results.push({
materialName,
materialCode,
specification,
model,
managerName: managerName || '',
isMarkedForDeletion,
matchedTypeKeyword
})
}
const markedCount = results.filter((r) => r.isMarkedForDeletion).length
const matchedCount = results.filter((r) => r.managerName).length
return {
success: true,
results,
stats: {
totalRecords: results.length,
matchedCount,
markedCount
}
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
log.error('Validation error', {
error: error instanceof Error ? error.message : String(error)
})
return {
success: false,
error: `Validation failed: ${message}`
}
} finally {
if (dbService) {
try {
await dbService.disconnect()
} catch (closeError) {
log.warn('Error disconnecting database', {
error: closeError instanceof Error ? closeError.message : String(closeError)
})
}
}
}
}
)
// ==================== MATERIAL OPERATIONS ====================
/**
* Upsert batch materials to MaterialsToBeDeleted
*/
ipcMain.handle(
IPC_CHANNELS.MATERIALS_UPSERT_BATCH,
async (_event, request: MaterialUpsertBatchRequest): Promise<MaterialOperationResponse> => {
try {
const dao = new MaterialsToBeDeletedDAO()
const stats = await dao.upsertBatch(request.materials)
return {
success: true,
stats
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
log.error('Upsert batch error', {
error: error instanceof Error ? error.message : String(error)
})
return {
success: false,
error: `Upsert failed: ${message}`
}
}
}
)
/**
* Delete materials by material codes
*/
ipcMain.handle(
IPC_CHANNELS.MATERIALS_DELETE,
async (_event, request: MaterialDeleteRequest): Promise<MaterialOperationResponse> => {
try {
const dao = new MaterialsToBeDeletedDAO()
const count = await dao.deleteByMaterialCodes(request.materialCodes)
return {
success: true,
count
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
log.error('Delete error', { error: error instanceof Error ? error.message : String(error) })
return {
success: false,
error: `Delete failed: ${message}`
}
}
}
)
/**
* Get unique manager names
*/
ipcMain.handle(
IPC_CHANNELS.MATERIALS_GET_MANAGERS,
async (_event): Promise<{ managers: string[] }> => {
try {
const dao = new MaterialsToBeDeletedDAO()
const managers = await dao.getManagers()
return { managers }
} catch (error) {
log.error('Get managers error', {
error: error instanceof Error ? error.message : String(error)
})
return { managers: [] }
}
}
)
/**
* Update manager for a single material
*/
ipcMain.handle(
IPC_CHANNELS.MATERIALS_UPDATE_MANAGER,
async (
_event,
request: { materialCode: string; managerName: string }
): Promise<{ success: boolean; error?: string }> => {
try {
const dao = new MaterialsToBeDeletedDAO()
return await dao.updateManager(request.materialCode, request.managerName)
} catch (error) {
log.error('Update manager error', {
error: error instanceof Error ? error.message : String(error)
})
return {
success: false,
error: error instanceof Error ? error.message : String(error)
}
}
}
)
/**
* Get materials by manager
*/
ipcMain.handle(
IPC_CHANNELS.MATERIALS_GET_BY_MANAGER,
async (_event, managerName: string): Promise<{ materials: MaterialRecordSummary[] }> => {
let dbService: MySqlService | SqlServerService | null = null
try {
dbService = await getValidationDatabaseService()
const configManager = ConfigManager.getInstance()
const dbType = configManager.getDatabaseType()
const isSqlServer = dbType === 'sqlserver'
const dao = new MaterialsToBeDeletedDAO()
const materials = await dao.getMaterialsByManager(managerName)
// Get material codes set for quick lookup
const markedCodes = await dao.getAllMaterialCodes()
// Enrich with material details from DiscreteMaterialPlanData
const enrichedMaterials: MaterialRecordSummary[] = []
const detailTableName = getTableName('dbo_DiscreteMaterialPlanData')
for (const mat of materials) {
let detailResult: any
if (isSqlServer) {
const sql = require('mssql')
const detailSql = `
SELECT TOP 1 MaterialName, Specification, Model
FROM ${detailTableName}
WHERE MaterialCode = @materialCode
`
detailResult = await (dbService as SqlServerService).queryWithParams(detailSql, {
materialCode: { value: mat.materialCode, type: sql.NVarChar }
})
} else {
const detailSql = `
SELECT MaterialName, Specification, Model
FROM ${detailTableName}
WHERE MaterialCode = ?
LIMIT 1
`
detailResult = await (dbService as MySqlService).query(detailSql, [mat.materialCode])
}
enrichedMaterials.push({
materialCode: mat.materialCode,
materialName:
detailResult.rows.length > 0 ? (detailResult.rows[0].MaterialName as string) : '',
specification:
detailResult.rows.length > 0 ? (detailResult.rows[0].Specification as string) : '',
model: detailResult.rows.length > 0 ? (detailResult.rows[0].Model as string) : '',
managerName: mat.managerName,
isMarked: markedCodes.has(mat.materialCode)
})
}
return { materials: enrichedMaterials }
} catch (error) {
log.error('Get by manager error', {
error: error instanceof Error ? error.message : String(error)
})
return { materials: [] }
} finally {
if (dbService) {
try {
await dbService.disconnect()
} catch (closeError) {
log.warn('Error disconnecting database', {
error: closeError instanceof Error ? closeError.message : String(closeError)
})
}
}
}
}
)
/**
* Get all material records
*/
ipcMain.handle(
IPC_CHANNELS.MATERIALS_GET_ALL,
async (_event): Promise<{ materials: MaterialRecordSummary[] }> => {
let dbService: MySqlService | SqlServerService | null = null
try {
dbService = await getValidationDatabaseService()
const configManager = ConfigManager.getInstance()
const dbType = configManager.getDatabaseType()
const isSqlServer = dbType === 'sqlserver'
const dao = new MaterialsToBeDeletedDAO()
const materials = await dao.getAllRecords()
const markedCodes = await dao.getAllMaterialCodes()
const enrichedMaterials: MaterialRecordSummary[] = []
const detailTableName = getTableName('dbo_DiscreteMaterialPlanData')
for (const mat of materials) {
let detailResult: any
if (isSqlServer) {
const sql = require('mssql')
const detailSql = `
SELECT TOP 1 MaterialName, Specification, Model
FROM ${detailTableName}
WHERE MaterialCode = @materialCode
`
detailResult = await (dbService as SqlServerService).queryWithParams(detailSql, {
materialCode: { value: mat.materialCode, type: sql.NVarChar }
})
} else {
const detailSql = `
SELECT MaterialName, Specification, Model
FROM ${detailTableName}
WHERE MaterialCode = ?
LIMIT 1
`
detailResult = await (dbService as MySqlService).query(detailSql, [mat.materialCode])
}
enrichedMaterials.push({
materialCode: mat.materialCode,
materialName:
detailResult.rows.length > 0 ? (detailResult.rows[0].MaterialName as string) : '',
specification:
detailResult.rows.length > 0 ? (detailResult.rows[0].Specification as string) : '',
model: detailResult.rows.length > 0 ? (detailResult.rows[0].Model as string) : '',
managerName: mat.managerName,
isMarked: markedCodes.has(mat.materialCode)
})
}
return { materials: enrichedMaterials }
} catch (error) {
log.error('Get all error', {
error: error instanceof Error ? error.message : String(error)
})
return { materials: [] }
} finally {
if (dbService) {
try {
await dbService.disconnect()
} catch (closeError) {
log.warn('Error disconnecting database', {
error: closeError instanceof Error ? closeError.message : String(closeError)
})
}
}
}
}
)
/**
* Get statistics
*/
ipcMain.handle(IPC_CHANNELS.MATERIALS_GET_STATISTICS, async (_event): Promise<{ stats: any }> => {
try {
const dao = new MaterialsToBeDeletedDAO()
const stats = await dao.getStatistics()
return { stats }
} catch (error) {
log.error('Get statistics error', {
error: error instanceof Error ? error.message : String(error)
})
return { stats: null }
}
})
/**
* Set shared Production IDs from extractor page
*/
ipcMain.handle(
IPC_CHANNELS.VALIDATION_SET_SHARED_PRODUCTION_IDS,
async (event, productionIds: string[]): Promise<void> => {
log.info(`Received ${productionIds.length} shared Production IDs`)
setSharedProductionIds(event.sender.id, productionIds)
}
)
/**
* Get shared Production IDs
*/
ipcMain.handle(
IPC_CHANNELS.VALIDATION_GET_SHARED_PRODUCTION_IDS,
async (event): Promise<{ productionIds: string[] }> => {
return { productionIds: getSharedProductionIds(event.sender.id) }
}
)
/**
* Clear shared Production IDs
*/
ipcMain.handle(
IPC_CHANNELS.VALIDATION_CLEAR_SHARED_PRODUCTION_IDS,
async (event): Promise<void> => {
log.info('Clearing shared Production IDs')
clearSharedProductionIds(event.sender.id)
}
)
/**
* Get cleaner data (order numbers from shared Production IDs + material codes from MaterialsToBeDeleted)
* Filters materials by current user (admin sees all, regular users see only their own)
*/
ipcMain.handle(
IPC_CHANNELS.VALIDATION_GET_CLEANER_DATA,
async (
_event
): Promise<{
success: boolean
orderNumbers?: string[]
materialCodes?: string[]
error?: string
}> => {
let dbService: MySqlService | SqlServerService | null = null
const sessionManager = (
await import('../services/user/session-manager')
).SessionManager.getInstance()
try {
// Get current user
const userInfo = sessionManager.getUserInfo()
if (!userInfo) {
return {
success: false,
error: '用户未登录'
}
}
const isAdmin = userInfo.userType === 'Admin'
const username = userInfo.username
const configManager = ConfigManager.getInstance()
const dbType = configManager.getDatabaseType()
const isSqlServer = dbType === 'sqlserver'
log.info(`User: ${username}, isAdmin: ${isAdmin}`)
// Connect to database
dbService = await getValidationDatabaseService()
// 1. Get order numbers from shared Production IDs
const sharedIds = getSharedProductionIds(_event.sender.id)
let orderNumbers: string[] = []
if (sharedIds.length > 0) {
log.info(`Using ${sharedIds.length} shared Production IDs`)
orderNumbers = await getSourceNumbersFromInputs(sharedIds, dbService)
log.info(`Got ${orderNumbers.length} order numbers`)
}
// 2. Get material codes from MaterialsToBeDeleted table
let materialCodes: string[] = []
const markedTableName = getTableName('dbo_MaterialsToBeDeleted')
if (isAdmin) {
// Admin sees all materials
const allCodesSql = `
SELECT MaterialCode
FROM ${markedTableName}
WHERE MaterialCode IS NOT NULL
`
const result = isSqlServer
? await (dbService as SqlServerService).query(allCodesSql)
: await (dbService as MySqlService).query(allCodesSql)
materialCodes = result.rows.map((row) => row.MaterialCode as string).filter(Boolean)
log.info(`Admin user: got ${materialCodes.length} materials`)
} else {
// Regular users only see their own materials
if (isSqlServer) {
const sql = require('mssql')
const userMaterialsSql = `
SELECT MaterialCode
FROM ${markedTableName}
WHERE ManagerName = @username AND MaterialCode IS NOT NULL
`
const result = await (dbService as SqlServerService).queryWithParams(userMaterialsSql, {
username: { value: username, type: sql.NVarChar }
})
materialCodes = result.rows.map((row) => row.MaterialCode as string).filter(Boolean)
} else {
const userMaterialsSql = `
SELECT MaterialCode
FROM ${markedTableName}
WHERE ManagerName = ? AND MaterialCode IS NOT NULL
`
const result = await (dbService as MySqlService).query(userMaterialsSql, [username])
materialCodes = result.rows.map((row) => row.MaterialCode as string).filter(Boolean)
}
log.info(`Regular user: got ${materialCodes.length} materials`)
}
return {
success: true,
orderNumbers,
materialCodes
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
log.error('CleanerData error', {
error: error instanceof Error ? error.message : String(error)
})
return {
success: false,
error: `获取清理数据失败:${message}`
}
} finally {
if (dbService) {
try {
await dbService.disconnect()
} catch (closeError) {
log.warn('Error disconnecting database', {
error: closeError instanceof Error ? closeError.message : String(closeError)
})
}
}
}
}
)
}

0
src/main/models/.gitkeep Normal file
View File

View File

@@ -0,0 +1,46 @@
export interface SQLServerConfig {
server: string;
database: string;
username: string;
password: string;
driver: string;
trustServerCertificate?: string;
}
export interface MySQLConfig {
host: string;
port: number;
database: string;
username: string;
password: string;
}
export interface BrowserConfig {
headless: boolean;
slowMo: number;
timeout: number;
}
export interface ERPConfig {
baseUrl: string;
ignoreHttpsErrors: boolean;
}
export interface PathConfig {
tempDir: string;
outputDir: string;
reportDir: string;
}
export interface AppConfig {
appName: string;
version: string;
logLevel: 'debug' | 'info' | 'warn' | 'error';
browser: BrowserConfig;
databases: {
sqlServer: SQLServerConfig;
mysql: MySQLConfig;
};
erp: ERPConfig;
paths: PathConfig;
}

View File

@@ -0,0 +1,6 @@
export interface LogEntry {
timestamp: string;
level: string;
message: string;
details?: any;
}

View File

@@ -1,45 +0,0 @@
/**
* Zod schemas for Authentication module validation
*/
import { z } from 'zod'
/**
* Schema for login request validation
*/
export const LoginRequestSchema = z.object({
username: z.string().min(1, 'Username is required'),
password: z.string().min(1, 'Password is required')
})
export type LoginRequestZod = z.infer<typeof LoginRequestSchema>
/**
* Schema for user info validation
*/
export const UserInfoSchema = z.object({
id: z.number().int().positive(),
username: z.string().min(1),
userType: z.enum(['Admin', 'User', 'Guest']),
computerName: z.string().optional()
})
export type UserInfoZod = z.infer<typeof UserInfoSchema>
/**
* Validate login request
*/
export function validateLoginRequest(input: unknown): {
success: boolean
data?: LoginRequestZod
error?: string
} {
const result = LoginRequestSchema.safeParse(input)
if (result.success) {
return { success: true, data: result.data }
}
return {
success: false,
error: result.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join('; ')
}
}

View File

@@ -1,49 +0,0 @@
/**
* Zod schemas for Cleaner module validation
*/
import { z } from 'zod'
/**
* Schema for cleaner input validation
*/
export const CleanerInputSchema = z.object({
orderNumbers: z
.array(z.string().min(1, 'Order number cannot be empty'))
.min(1, 'At least one order number is required'),
materialCodes: z.array(z.string().min(1, 'Material code cannot be empty')),
dryRun: z.boolean(),
queryBatchSize: z.number().int().min(1).max(100).optional().default(100),
processConcurrency: z.number().int().min(1).max(20).optional().default(1)
// Note: onProgress is a function, not validated via Zod
})
export type CleanerInputZod = z.infer<typeof CleanerInputSchema>
/**
* Schema for cleaner result validation
*/
export const CleanerResultSchema = z.object({
processedCount: z.number().int().nonnegative(),
errors: z.array(z.string())
})
export type CleanerResultZod = z.infer<typeof CleanerResultSchema>
/**
* Validate cleaner input
*/
export function validateCleanerInput(input: unknown): {
success: boolean
data?: CleanerInputZod
error?: string
} {
const result = CleanerInputSchema.safeParse(input)
if (result.success) {
return { success: true, data: result.data }
}
return {
success: false,
error: result.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join('; ')
}
}

View File

@@ -1,46 +0,0 @@
/**
* Zod schemas for Extractor module validation
*/
import { z } from 'zod'
/**
* Schema for extractor input validation
*/
export const ExtractorInputSchema = z.object({
orderNumbers: z
.array(z.string().min(1, 'Order number cannot be empty'))
.min(1, 'At least one order number is required'),
batchSize: z.number().int().positive().optional().default(10)
// Note: onProgress is a function, not validated via Zod
})
export type ExtractorInputZod = z.infer<typeof ExtractorInputSchema>
/**
* Schema for extractor result validation
*/
export const ExtractorResultSchema = z.object({
data: z.array(z.record(z.string(), z.unknown())),
errors: z.array(z.string())
})
export type ExtractorResultZod = z.infer<typeof ExtractorResultSchema>
/**
* Validate extractor input
*/
export function validateExtractorInput(input: unknown): {
success: boolean
data?: ExtractorInputZod
error?: string
} {
const result = ExtractorInputSchema.safeParse(input)
if (result.success) {
return { success: true, data: result.data }
}
return {
success: false,
error: result.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join('; ')
}
}

View File

View File

@@ -1,358 +0,0 @@
/**
* Configuration Manager (YAML Version)
*
* Manages application configuration using YAML format
* Provides type-safe access with Zod validation
*
* Note: ERP configuration is stored in database (dbo_BIPUsers table)
* and managed per-user, not in this config file.
*
* Configuration File Location:
* - Development: Project root directory (config.yaml)
* - Production (Installed & Portable): User data directory (AppData)
* This ensures config persists across app updates and is not exposed
*/
import * as fs from 'fs'
import * as path from 'path'
import { fileURLToPath } from 'url'
import { dirname } from 'path'
import { app } from 'electron'
import yaml from 'js-yaml'
import { z } from 'zod'
import { createLogger, setLogLevel } from '../logger'
import {
fullConfigSchema,
type FullConfig,
type DatabaseType,
type MySqlConfig,
type SqlServerConfig,
type LoggingConfig
} from '../../types/config.schema'
const log = createLogger('ConfigManager')
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
/**
* 默认配置
*/
const DEFAULT_CONFIG: FullConfig = {
erp: {
url: 'https://68.11.34.30:8082'
},
database: {
activeType: 'mysql',
mysql: {
host: 'localhost',
port: 3306,
database: 'erp_db',
username: 'root',
password: '',
charset: 'utf8mb4'
},
sqlserver: {
server: 'localhost',
port: 1433,
database: 'erp_db',
username: 'sa',
password: '',
driver: 'ODBC Driver 18 for SQL Server',
trustServerCertificate: true
}
},
paths: {
dataDir: './data/',
defaultOutput: 'output.xlsx',
validationOutput: 'validation-result.xlsx'
},
extraction: {
batchSize: 100,
verbose: true,
autoConvert: true,
mergeBatches: true,
enableDbPersistence: true
},
validation: {
dataSource: 'database_full',
batchSize: 2000,
matchMode: 'substring',
enableCrud: false,
defaultManager: ''
},
cleaner: {
queryBatchSize: 100,
processConcurrency: 1
},
orderResolution: {
tableName: '',
productionIdField: '',
orderNumberField: ''
},
logging: {
level: 'info',
auditRetention: 30,
appRetention: 14
},
rustfs: {
enabled: false,
endpoint: '',
accessKey: '',
secretKey: '',
bucket: 'erpauto',
region: 'us-east-1'
},
update: {
enabled: false,
allowDevMode: false,
endpoint: '',
accessKey: '',
secretKey: '',
bucket: '',
region: 'us-east-1',
basePrefix: 'updates/win-portable',
checkIntervalMinutes: 30,
maxAdminHistoryPerChannel: 10
}
}
export class ConfigManager {
private static instance: ConfigManager | null = null
private configPath!: string
private backupPath!: string
private config: FullConfig | null = null
private initialized: boolean = false
private constructor() {
if (this.initialized) return
// 检测是否为开发环境
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged
if (isDev) {
// 开发环境:配置文件放在项目根目录,方便编辑和调试
this.configPath = path.resolve(__dirname, '../../config.yaml')
this.backupPath = path.resolve(__dirname, '../../config.yaml.backup')
log.info('Running in development mode', { configPath: this.configPath })
} else {
// 生产环境(包括安装版和便携版):配置文件放在用户数据目录
// Windows: C:\Users\<user>\AppData\Roaming\erpauto\config.yaml
// 这样配置会在应用升级时保留,且不会暴露在应用目录中
this.configPath = path.join(app.getPath('userData'), 'config.yaml')
this.backupPath = path.join(app.getPath('userData'), 'config.yaml.backup')
log.info('Running in production mode', { configPath: this.configPath })
}
this.initialized = true
}
public static getInstance(): ConfigManager {
if (ConfigManager.instance === null) {
ConfigManager.instance = new ConfigManager()
}
return ConfigManager.instance
}
/**
* 初始化配置
* - 如果 config.yaml 不存在,创建默认配置
* - 加载并验证配置
*/
public async initialize(): Promise<void> {
if (!fs.existsSync(this.configPath)) {
log.info('Config file not found, creating default config.yaml')
await this.saveConfig(DEFAULT_CONFIG)
this.config = DEFAULT_CONFIG
// Apply logging configuration from default config
setLogLevel(DEFAULT_CONFIG.logging.level)
return
}
await this.loadConfig()
}
/**
* 加载并验证 YAML 配置
*/
private async loadConfig(): Promise<void> {
try {
const content = fs.readFileSync(this.configPath, 'utf-8')
const parsed = yaml.load(content) as Record<string, unknown>
// Zod 验证
const validated = fullConfigSchema.parse(parsed)
this.config = validated
// Apply logging configuration
setLogLevel(validated.logging.level)
log.info('Configuration loaded and validated successfully')
} catch (error) {
if (error instanceof z.ZodError) {
const messages = error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`)
log.error('Configuration validation failed', { errors: messages })
throw new Error(`配置文件验证失败:\n${messages.join('\n')}`)
}
log.error('Failed to load configuration', { error })
throw error
}
}
/**
* 保存配置到 YAML 文件
*/
private async saveConfig(config: FullConfig): Promise<boolean> {
try {
// 备份现有配置
if (fs.existsSync(this.configPath)) {
fs.copyFileSync(this.configPath, this.backupPath)
}
// 转换为 YAML
const content = yaml.dump(config, {
indent: 2,
lineWidth: -1, // 不自动换行
noRefs: true, // 不使用引用
quotingType: '"',
forceQuotes: false
})
fs.writeFileSync(this.configPath, content, 'utf-8')
this.config = config
log.info('Configuration saved successfully')
return true
} catch (error) {
log.error('Failed to save configuration', { error })
// 恢复备份
if (fs.existsSync(this.backupPath)) {
fs.copyFileSync(this.backupPath, this.configPath)
}
return false
}
}
/**
* 获取完整配置
*/
public getConfig(): FullConfig {
if (!this.config) {
throw new Error('Configuration not initialized. Call initialize() first.')
}
return this.config
}
/**
* 获取当前激活的数据库配置
*/
public getActiveDatabaseConfig(): MySqlConfig | SqlServerConfig {
if (!this.config) {
throw new Error('Configuration not initialized')
}
const { activeType, mysql, sqlserver } = this.config.database
return activeType === 'mysql' ? mysql : sqlserver
}
/**
* 获取数据库类型
*/
public getDatabaseType(): DatabaseType {
if (!this.config) {
throw new Error('Configuration not initialized')
}
return this.config.database.activeType
}
/**
* 获取日志配置
*/
public getLoggingConfig(): LoggingConfig {
if (!this.config) {
throw new Error('Configuration not initialized')
}
return this.config.logging
}
/**
* 更新部分配置(深合并)
*/
public async updateConfig(
updates: Partial<FullConfig>
): Promise<{ success: boolean; error?: string }> {
try {
if (!this.config) {
await this.loadConfig()
}
// 深合并
const merged = this.deepMerge(this.config!, updates)
// 验证合并后的配置
const validated = fullConfigSchema.parse(merged)
const success = await this.saveConfig(validated)
if (!success) {
return { success: false, error: '保存配置失败' }
}
return { success: true }
} catch (error) {
if (error instanceof z.ZodError) {
const messages = error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`)
return { success: false, error: `配置验证失败:\n${messages.join('\n')}` }
}
return { success: false, error: error instanceof Error ? error.message : '未知错误' }
}
}
/**
* 深合并工具函数
*/
private deepMerge<T extends Record<string, any>>(source: T, target: Partial<T>): T {
const result = { ...source }
for (const key in target) {
if (target[key] !== undefined) {
if (
typeof target[key] === 'object' &&
target[key] !== null &&
!Array.isArray(target[key])
) {
result[key] = this.deepMerge(result[key] as any, target[key] as any)
} else {
result[key] = target[key] as any
}
}
}
return result
}
/**
* 重置为默认配置
*/
public async resetToDefaults(): Promise<boolean> {
return this.saveConfig(DEFAULT_CONFIG)
}
/**
* 获取默认配置
*/
public getDefaultConfig(): FullConfig {
return DEFAULT_CONFIG
}
/**
* 导出配置为 YAML 字符串(用于 UI 显示或导出)
*/
public exportToYaml(): string {
if (!this.config) {
throw new Error('Configuration not initialized')
}
return yaml.dump(this.config, {
indent: 2,
lineWidth: -1,
noRefs: true
})
}
}

View File

@@ -1,299 +0,0 @@
/**
* 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()
}

View File

@@ -1,103 +0,0 @@
/**
* TypeORM Data Source Configuration
*
* Provides a centralized database connection for TypeORM entities.
* Supports both MySQL and SQL Server based on configuration.
*
* Note: Configuration is now loaded from config.yaml via ConfigManager,
* not from environment variables.
*/
import 'reflect-metadata'
import { DataSource, DataSourceOptions } from 'typeorm'
import { ConfigManager } from '../config/config-manager'
/**
* Get database type from config manager
*/
function getDatabaseType(): 'mysql' | 'mssql' {
const configManager = ConfigManager.getInstance()
const dbType = configManager.getDatabaseType()
return dbType === 'sqlserver' ? 'mssql' : 'mysql'
}
/**
* Build DataSourceOptions based on database type
*/
function buildDataSourceOptions(): DataSourceOptions {
const type = getDatabaseType()
const configManager = ConfigManager.getInstance()
const config = configManager.getConfig()
const commonOptions: Partial<DataSourceOptions> = {
entities: [__dirname + '/entities/*.{ts,js}'],
synchronize: false, // Never auto-sync in production
logging: false
}
if (type === 'mssql') {
const dbConfig = config.database.sqlserver
return {
type: 'mssql',
host: dbConfig.server,
port: dbConfig.port,
username: dbConfig.username,
password: dbConfig.password,
database: dbConfig.database,
options: {
encrypt: false,
trustServerCertificate: dbConfig.trustServerCertificate
},
...commonOptions
} as DataSourceOptions
}
const dbConfig = config.database.mysql
return {
type: 'mysql',
host: dbConfig.host,
port: dbConfig.port,
username: dbConfig.username,
password: dbConfig.password,
database: dbConfig.database,
...commonOptions
} as DataSourceOptions
}
/**
* TypeORM DataSource singleton
*/
let dataSource: DataSource | null = null
/**
* Get or create the DataSource
*/
export function getDataSource(): DataSource {
if (!dataSource) {
dataSource = new DataSource(buildDataSourceOptions())
}
return dataSource
}
/**
* Initialize the DataSource
*/
export async function initializeDataSource(): Promise<DataSource> {
const ds = getDataSource()
if (!ds.isInitialized) {
await ds.initialize()
}
return ds
}
/**
* Destroy the DataSource
*/
export async function destroyDataSource(): Promise<void> {
if (dataSource && dataSource.isInitialized) {
await dataSource.destroy()
dataSource = null
}
}
export default getDataSource

View File

@@ -1,789 +0,0 @@
/**
* Data Access Object for DiscreteMaterialPlanData table
*
* Mirrors the Python DiscreteMaterialPlanDAO functionality:
* - Query operations for discrete material plan data
* - Support for querying by PlanNumber, SourceNumber
* - Deduplication by MaterialCode
* - Statistics gathering
*/
import { create, type IDatabaseService } from './index'
import { createLogger } from '../logger'
const log = createLogger('DiscreteMaterialPlanDAO')
/**
* Material plan record interface
*/
export interface MaterialPlanRecord {
id?: number
factory: string
materialStatus: string
planNumber: string
sourceNumber: string
materialType: string
productCode: string
productName: string
productUnit: string
productPlanQuantity: number
useDepartment: string
remark: string
creator: string
createDate: Date
approver: string
approveDate: Date
sequenceNumber: number
materialCode: string
materialName: string
specification: string
model: string
drawingNumber: string
materialQuality: string
planQuantity: number
unit: string
requiredDate: Date
warehouse: string
unitUsage: number
cumulativeOutputQuantity: number
bomVersion: string
}
/**
* Configuration for DiscreteMaterialPlanData table
*/
export const DISCRETE_MATERIAL_PLAN_CONFIG = {
TABLE_NAME_SQLSERVER: '[dbo].[DiscreteMaterialPlanData]',
TABLE_NAME_MYSQL: 'dbo_DiscreteMaterialPlanData',
COLUMNS: {
ID: 'ID',
FACTORY: 'Factory',
MATERIAL_STATUS: 'MaterialStatus',
PLAN_NUMBER: 'PlanNumber',
SOURCE_NUMBER: 'SourceNumber',
MATERIAL_TYPE: 'MaterialType',
PRODUCT_CODE: 'ProductCode',
PRODUCT_NAME: 'ProductName',
PRODUCT_UNIT: 'ProductUnit',
PRODUCT_PLAN_QUANTITY: 'ProductPlanQuantity',
USE_DEPARTMENT: 'UseDepartment',
REMARK: 'Remark',
CREATOR: 'Creator',
CREATE_DATE: 'CreateDate',
APPROVER: 'Approver',
APPROVE_DATE: 'ApproveDate',
SEQUENCE_NUMBER: 'SequenceNumber',
MATERIAL_CODE: 'MaterialCode',
MATERIAL_NAME: 'MaterialName',
SPECIFICATION: 'Specification',
MODEL: 'Model',
DRAWING_NUMBER: 'DrawingNumber',
MATERIAL_QUALITY: 'MaterialQuality',
PLAN_QUANTITY: 'PlanQuantity',
UNIT: 'Unit',
REQUIRED_DATE: 'RequiredDate',
WAREHOUSE: 'Warehouse',
UNIT_USAGE: 'UnitUsage',
CUMULATIVE_OUTPUT_QUANTITY: 'CumulativeOutputQuantity',
BOM_VERSION: 'BOMVersion'
}
} as const
/**
* DiscreteMaterialPlanDAO Class
*/
export class DiscreteMaterialPlanDAO {
private dbService: IDatabaseService | null = null
/**
* Get the appropriate table name based on database type
*/
private getTableName(): string {
const isSqlServer = this.dbService?.type === 'sqlserver'
return isSqlServer
? DISCRETE_MATERIAL_PLAN_CONFIG.TABLE_NAME_SQLSERVER
: DISCRETE_MATERIAL_PLAN_CONFIG.TABLE_NAME_MYSQL
}
/**
* Get database service instance using DatabaseFactory
*/
private async getDatabaseService(): Promise<IDatabaseService> {
if (this.dbService && this.dbService.isConnected()) {
return this.dbService
}
this.dbService = await create()
return this.dbService
}
/**
* Build placeholders for IN clause based on database type
*/
private buildPlaceholders(count: number, isSqlServer: boolean): string {
return isSqlServer
? Array.from({ length: count }, (_, idx) => `@p${idx}`).join(',')
: Array.from({ length: count }, () => '?').join(',')
}
// ==================== QUERY ALL ====================
/**
* Query all records from DiscreteMaterialPlanData table
* @returns List of all records
*/
async queryAll(): Promise<any[]> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const sqlString = `SELECT * FROM ${tableName}`
const result = await dbService.query(sqlString)
return result.rows
} catch (error) {
log.error('Query all error', {
error: error instanceof Error ? error.message : String(error)
})
return []
}
}
/**
* Query all records with deduplication by MaterialCode
* Strategy: Keep first record for each MaterialCode
* Order: CreateDate ASC, SequenceNumber ASC
* @returns List of deduplicated records
*/
async queryAllDistinctByMaterialCode(): Promise<any[]> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const sqlString = `
WITH RankedRecords AS (
SELECT
*,
ROW_NUMBER() OVER (
PARTITION BY MaterialCode
ORDER BY CreateDate ASC, SequenceNumber ASC
) AS rn
FROM ${tableName}
WHERE MaterialCode IS NOT NULL
)
SELECT
Factory, MaterialStatus, PlanNumber, SourceNumber, MaterialType,
ProductCode, ProductName, ProductUnit, ProductPlanQuantity,
UseDepartment, Remark, Creator, CreateDate, Approver, ApproveDate,
SequenceNumber, MaterialCode, MaterialName, Specification, Model,
DrawingNumber, MaterialQuality, PlanQuantity, Unit, RequiredDate,
Warehouse, UnitUsage, CumulativeOutputQuantity, BOMVersion
FROM RankedRecords
WHERE rn = 1
`
const result = await dbService.query(sqlString)
return result.rows
} catch (error) {
log.error('Query all distinct by material code error', {
error: error instanceof Error ? error.message : String(error)
})
return []
}
}
// ==================== QUERY BY SOURCE NUMBER ====================
/**
* Query records by SourceNumber list
* @param sourceNumbers - List of SourceNumber values
* @returns List of records
*/
async queryBySourceNumbers(sourceNumbers: string[]): Promise<any[]> {
if (!sourceNumbers || sourceNumbers.length === 0) {
return []
}
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const isSqlServer = dbService.type === 'sqlserver'
const batchSize = 1500
const allResults: any[] = []
for (let i = 0; i < sourceNumbers.length; i += batchSize) {
const batch = sourceNumbers.slice(i, i + batchSize)
const placeholders = this.buildPlaceholders(batch.length, isSqlServer)
const sqlString = `
SELECT *
FROM ${tableName}
WHERE SourceNumber IN (${placeholders})
`
const result = await dbService.query(sqlString, batch)
allResults.push(...result.rows)
}
return allResults
} catch (error) {
log.error('Query by source numbers error', {
error: error instanceof Error ? error.message : String(error)
})
return []
}
}
/**
* Query records by SourceNumber with deduplication by MaterialCode
* Strategy: Keep first record for each MaterialCode
* Order: CreateDate ASC, SequenceNumber ASC
* @param sourceNumbers - List of SourceNumber values
* @returns List of deduplicated records
*/
async queryBySourceNumbersDistinct(sourceNumbers: string[]): Promise<any[]> {
if (!sourceNumbers || sourceNumbers.length === 0) {
return []
}
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const isSqlServer = dbService.type === 'sqlserver'
const batchSize = 1500
const allResults: any[] = []
for (let i = 0; i < sourceNumbers.length; i += batchSize) {
const batch = sourceNumbers.slice(i, i + batchSize)
const placeholders = this.buildPlaceholders(batch.length, isSqlServer)
const sqlString = `
WITH RankedRecords AS (
SELECT
*,
ROW_NUMBER() OVER (
PARTITION BY MaterialCode
ORDER BY CreateDate ASC, SequenceNumber ASC
) AS rn
FROM ${tableName}
WHERE SourceNumber IN (${placeholders})
AND MaterialCode IS NOT NULL
)
SELECT
Factory, MaterialStatus, PlanNumber, SourceNumber, MaterialType,
ProductCode, ProductName, ProductUnit, ProductPlanQuantity,
UseDepartment, Remark, Creator, CreateDate, Approver, ApproveDate,
SequenceNumber, MaterialCode, MaterialName, Specification, Model,
DrawingNumber, MaterialQuality, PlanQuantity, Unit, RequiredDate,
Warehouse, UnitUsage, CumulativeOutputQuantity, BOMVersion
FROM RankedRecords
WHERE rn = 1
`
const result = await dbService.query(sqlString, batch)
allResults.push(...result.rows)
}
return allResults
} catch (error) {
log.error('Query by source numbers distinct error', {
error: error instanceof Error ? error.message : String(error)
})
return []
}
}
/**
* Query by production order number (SourceNumber)
* @param sourceNumber - SourceNumber value
* @returns List of records
*/
async queryBySourceNumber(sourceNumber: string): Promise<any[]> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const isSqlServer = dbService.type === 'sqlserver'
const placeholder = isSqlServer ? '@p0' : '?'
const sqlString = `
SELECT *
FROM ${tableName}
WHERE SourceNumber = ${placeholder}
`
const result = await dbService.query(sqlString, [sourceNumber])
return result.rows
} catch (error) {
log.error('Query by source number error', {
error: error instanceof Error ? error.message : String(error)
})
return []
}
}
// ==================== QUERY BY PLAN NUMBER ====================
/**
* Query by plan number
* @param planNumber - PlanNumber value
* @returns List of records
*/
async queryByPlanNumber(planNumber: string): Promise<any[]> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const isSqlServer = dbService.type === 'sqlserver'
const placeholder = isSqlServer ? '@p0' : '?'
const sqlString = `
SELECT *
FROM ${tableName}
WHERE PlanNumber = ${placeholder}
`
const result = await dbService.query(sqlString, [planNumber])
return result.rows
} catch (error) {
log.error('Query by plan number error', {
error: error instanceof Error ? error.message : String(error)
})
return []
}
}
/**
* Query by multiple plan numbers
* @param planNumbers - List of PlanNumber values
* @returns List of records
*/
async queryByPlanNumbers(planNumbers: string[]): Promise<any[]> {
if (!planNumbers || planNumbers.length === 0) {
return []
}
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const isSqlServer = dbService.type === 'sqlserver'
const batchSize = 1500
const allResults: any[] = []
for (let i = 0; i < planNumbers.length; i += batchSize) {
const batch = planNumbers.slice(i, i + batchSize)
const placeholders = this.buildPlaceholders(batch.length, isSqlServer)
const sqlString = `
SELECT *
FROM ${tableName}
WHERE PlanNumber IN (${placeholders})
`
const result = await dbService.query(sqlString, batch)
allResults.push(...result.rows)
}
return allResults
} catch (error) {
log.error('Query by plan numbers error', {
error: error instanceof Error ? error.message : String(error)
})
return []
}
}
// ==================== DELETE OPERATIONS ====================
/**
* Delete records by SourceNumber list
* Uses batch processing for large lists
* @param sourceNumbers - List of SourceNumber values to delete
* @returns Number of records deleted
*/
async deleteBySourceNumbers(sourceNumbers: string[]): Promise<number> {
if (!sourceNumbers || sourceNumbers.length === 0) {
return 0
}
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const isSqlServer = dbService.type === 'sqlserver'
const batchSize = 2000
let totalDeleted = 0
// Get unique source numbers
const uniqueSourceNumbers = [...new Set(sourceNumbers.filter(Boolean))]
for (let i = 0; i < uniqueSourceNumbers.length; i += batchSize) {
const batch = uniqueSourceNumbers.slice(i, i + batchSize)
const placeholders = this.buildPlaceholders(batch.length, isSqlServer)
const sqlString = `
DELETE FROM ${tableName}
WHERE SourceNumber IN (${placeholders})
`
const result = await dbService.query(sqlString, batch)
totalDeleted += result.rowCount || 0
log.debug('Deleted batch', {
batch: i / batchSize + 1,
count: result.rowCount
})
}
log.info('Deleted records by source numbers', {
totalDeleted,
sourceNumberCount: uniqueSourceNumbers.length
})
return totalDeleted
} catch (error) {
log.error('Delete by source numbers error', {
error: error instanceof Error ? error.message : String(error)
})
throw error
}
}
// ==================== INSERT OPERATIONS ====================
/**
* Insert records in batches
* @param records - List of MaterialPlanRecord to insert
* @param batchSize - Number of records per batch (default: 1000, auto-adjusted for SQL Server)
* @returns Number of records inserted
*/
async batchInsert(records: MaterialPlanRecord[], batchSize = 1000): Promise<number> {
if (!records || records.length === 0) {
return 0
}
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const isSqlServer = dbService.type === 'sqlserver'
let totalInserted = 0
// SQL Server has a limit of 2100 parameters per query
// Each record has 28 columns, so max rows per batch = 2100 / 28 = 75
// Leave some margin for query overhead
const columnsPerRow = 28
const sqlServerMaxParams = 2000
const effectiveBatchSize = isSqlServer
? Math.min(batchSize, Math.floor(sqlServerMaxParams / columnsPerRow))
: batchSize
log.info('Batch insert parameters', {
isSqlServer,
dbType: dbService.type,
columnsPerRow,
effectiveBatchSize,
totalRecords: records.length
})
// Process in batches
for (let i = 0; i < records.length; i += effectiveBatchSize) {
const batch = records.slice(i, i + effectiveBatchSize)
const inserted = await this.insertBatch(dbService, tableName, batch, isSqlServer)
totalInserted += inserted
log.debug('Inserted batch', {
batch: Math.floor(i / effectiveBatchSize) + 1,
count: inserted
})
}
log.info('Batch insert completed', {
totalInserted,
batchSize: effectiveBatchSize
})
return totalInserted
} catch (error) {
log.error('Batch insert error', {
error: error instanceof Error ? error.message : String(error)
})
throw error
}
}
/**
* Insert a single batch of records
*/
private async insertBatch(
dbService: IDatabaseService,
tableName: string,
records: MaterialPlanRecord[],
isSqlServer: boolean
): Promise<number> {
if (records.length === 0) {
return 0
}
// Build column list (excluding id)
const columns = [
'Factory',
'MaterialStatus',
'PlanNumber',
'SourceNumber',
'MaterialType',
'ProductCode',
'ProductName',
'ProductUnit',
'ProductPlanQuantity',
'UseDepartment',
'Remark',
'Creator',
'CreateDate',
'Approver',
'ApproveDate',
'SequenceNumber',
'MaterialCode',
'MaterialName',
'Specification',
'Model',
'DrawingNumber',
'MaterialQuality',
'PlanQuantity',
'Unit',
'RequiredDate',
'Warehouse',
'UnitUsage',
'CumulativeOutputQuantity'
]
// Build parameterized insert
const values: any[] = []
const rowPlaceholders: string[] = []
records.forEach((record, rowIndex) => {
const rowValues = this.buildRowValues(record, columns, rowIndex, isSqlServer, values)
rowPlaceholders.push(`(${rowValues.join(',')})`)
})
const sqlString = `
INSERT INTO ${tableName} (${columns.join(', ')})
VALUES ${rowPlaceholders.join(', ')}
`
const result = await dbService.query(sqlString, values)
return result.rowCount || records.length
}
/**
* Build parameter values for a single row
*/
private buildRowValues(
record: MaterialPlanRecord,
columns: string[],
_rowIndex: number,
isSqlServer: boolean,
values: any[]
): string[] {
return columns.map((col) => {
const value = this.getColumnValue(record, col)
values.push(value)
if (isSqlServer) {
return `@p${values.length - 1}`
} else {
return '?'
}
})
}
/**
* Get the value for a specific column from the record
*/
private getColumnValue(record: MaterialPlanRecord, column: string): any {
const columnMapping: Record<string, keyof MaterialPlanRecord> = {
Factory: 'factory',
MaterialStatus: 'materialStatus',
PlanNumber: 'planNumber',
SourceNumber: 'sourceNumber',
MaterialType: 'materialType',
ProductCode: 'productCode',
ProductName: 'productName',
ProductUnit: 'productUnit',
ProductPlanQuantity: 'productPlanQuantity',
UseDepartment: 'useDepartment',
Remark: 'remark',
Creator: 'creator',
CreateDate: 'createDate',
Approver: 'approver',
ApproveDate: 'approveDate',
SequenceNumber: 'sequenceNumber',
MaterialCode: 'materialCode',
MaterialName: 'materialName',
Specification: 'specification',
Model: 'model',
DrawingNumber: 'drawingNumber',
MaterialQuality: 'materialQuality',
PlanQuantity: 'planQuantity',
Unit: 'unit',
RequiredDate: 'requiredDate',
Warehouse: 'warehouse',
UnitUsage: 'unitUsage',
CumulativeOutputQuantity: 'cumulativeOutputQuantity'
}
const key = columnMapping[column]
if (!key) {
return null
}
const value = record[key]
// Handle null/undefined
if (value === null || value === undefined) {
return null
}
// Handle empty strings for string fields
if (typeof value === 'string' && value.trim() === '') {
return null
}
return value
}
// ==================== UTILITY METHODS ====================
/**
* Count all records
* @returns Total number of records
*/
async countAll(): Promise<number> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const sqlString = `SELECT COUNT(*) as count FROM ${tableName}`
const result = await dbService.query(sqlString)
return result.rows.length > 0 ? (result.rows[0].count as number) : 0
} catch (error) {
log.error('Count all error', {
error: error instanceof Error ? error.message : String(error)
})
return 0
}
}
/**
* Count records by plan number
* @param planNumber - PlanNumber value
* @returns Number of records
*/
async countByPlanNumber(planNumber: string): Promise<number> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const isSqlServer = dbService.type === 'sqlserver'
const placeholder = isSqlServer ? '@p0' : '?'
const sqlString = `
SELECT COUNT(*) as count
FROM ${tableName}
WHERE PlanNumber = ${placeholder}
`
const result = await dbService.query(sqlString, [planNumber])
return result.rows.length > 0 ? (result.rows[0].count as number) : 0
} catch (error) {
log.error('Count by plan number error', {
error: error instanceof Error ? error.message : String(error)
})
return 0
}
}
/**
* Get unique material names
* @param sourceNumbers - Optional list of SourceNumber values to filter
* @returns List of unique material names
*/
async getUniqueMaterialNames(sourceNumbers?: string[]): Promise<string[]> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const isSqlServer = dbService.type === 'sqlserver'
if (sourceNumbers && sourceNumbers.length > 0) {
const batchSize = 1500
const allNames: string[] = []
for (let i = 0; i < sourceNumbers.length; i += batchSize) {
const batch = sourceNumbers.slice(i, i + batchSize)
const placeholders = this.buildPlaceholders(batch.length, isSqlServer)
const sqlString = `
SELECT DISTINCT MaterialName
FROM ${tableName}
WHERE SourceNumber IN (${placeholders})
AND MaterialName IS NOT NULL
`
const result = await dbService.query(sqlString, batch)
allNames.push(...result.rows.map((row) => row.MaterialName as string).filter(Boolean))
}
return allNames
} else {
const sqlString = `
SELECT DISTINCT MaterialName
FROM ${tableName}
WHERE MaterialName IS NOT NULL
`
const result = await dbService.query(sqlString)
return result.rows.map((row) => row.MaterialName as string).filter(Boolean)
}
} catch (error) {
log.error('Get unique material names error', {
error: error instanceof Error ? error.message : String(error)
})
return []
}
}
/**
* Get statistics
* @returns Statistics object
*/
async getStatistics(): Promise<any> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const sqlString = `
SELECT
COUNT(*) as totalRecords,
COUNT(DISTINCT PlanNumber) as uniquePlans,
COUNT(DISTINCT SourceNumber) as uniqueOrders,
MIN(CreateDate) as earliestRecord,
MAX(CreateDate) as latestRecord
FROM ${tableName}
`
const result = await dbService.query(sqlString)
return result.rows.length > 0 ? result.rows[0] : {}
} catch (error) {
log.error('Get statistics error', {
error: error instanceof Error ? error.message : String(error)
})
return {}
}
}
/**
* Disconnect from database
*/
async disconnect(): Promise<void> {
if (this.dbService) {
await this.dbService.disconnect()
this.dbService = null
}
}
}

View File

@@ -1,143 +0,0 @@
/**
* TypeORM Entity for DiscreteMaterialPlanData table
*/
import { Entity, PrimaryGeneratedColumn, Column, Index } from 'typeorm'
@Entity('DiscreteMaterialPlanData')
export class DiscreteMaterialPlan {
@PrimaryGeneratedColumn()
id!: number
@Column({ name: 'Factory', type: 'nvarchar', length: 100, nullable: true })
factory!: string | null
@Column({ name: 'MaterialStatus', type: 'nvarchar', length: 50, nullable: true })
materialStatus!: string | null
@Index()
@Column({ name: 'PlanNumber', type: 'nvarchar', length: 100, nullable: true })
planNumber!: string | null
@Index()
@Column({ name: 'SourceNumber', type: 'nvarchar', length: 100, nullable: true })
sourceNumber!: string | null
@Column({ name: 'MaterialType', type: 'nvarchar', length: 100, nullable: true })
materialType!: string | null
@Column({ name: 'ProductCode', type: 'nvarchar', length: 100, nullable: true })
productCode!: string | null
@Column({ name: 'ProductName', type: 'nvarchar', length: 255, nullable: true })
productName!: string | null
@Column({ name: 'ProductUnit', type: 'nvarchar', length: 50, nullable: true })
productUnit!: string | null
@Column({ name: 'ProductPlanQuantity', type: 'decimal', precision: 18, scale: 4, nullable: true })
productPlanQuantity!: number | null
@Column({ name: 'UseDepartment', type: 'nvarchar', length: 100, nullable: true })
useDepartment!: string | null
@Column({ name: 'Remark', type: 'nvarchar', length: 500, nullable: true })
remark!: string | null
@Column({ name: 'Creator', type: 'nvarchar', length: 100, nullable: true })
creator!: string | null
@Column({ name: 'CreateDate', type: 'datetime', nullable: true })
createDate!: Date | null
@Column({ name: 'Approver', type: 'nvarchar', length: 100, nullable: true })
approver!: string | null
@Column({ name: 'ApproveDate', type: 'datetime', nullable: true })
approveDate!: Date | null
@Column({ name: 'SequenceNumber', type: 'int', nullable: true })
sequenceNumber!: number | null
@Index()
@Column({ name: 'MaterialCode', type: 'nvarchar', length: 100, nullable: true })
materialCode!: string | null
@Column({ name: 'MaterialName', type: 'nvarchar', length: 255, nullable: true })
materialName!: string | null
@Column({ name: 'Specification', type: 'nvarchar', length: 255, nullable: true })
specification!: string | null
@Column({ name: 'Model', type: 'nvarchar', length: 255, nullable: true })
model!: string | null
@Column({ name: 'DrawingNumber', type: 'nvarchar', length: 100, nullable: true })
drawingNumber!: string | null
@Column({ name: 'MaterialQuality', type: 'nvarchar', length: 100, nullable: true })
materialQuality!: string | null
@Column({ name: 'PlanQuantity', type: 'decimal', precision: 18, scale: 4, nullable: true })
planQuantity!: number | null
@Column({ name: 'Unit', type: 'nvarchar', length: 50, nullable: true })
unit!: string | null
@Column({ name: 'RequiredDate', type: 'datetime', nullable: true })
requiredDate!: Date | null
@Column({ name: 'Warehouse', type: 'nvarchar', length: 100, nullable: true })
warehouse!: string | null
@Column({ name: 'UnitUsage', type: 'decimal', precision: 18, scale: 6, nullable: true })
unitUsage!: number | null
@Column({
name: 'CumulativeOutputQuantity',
type: 'decimal',
precision: 18,
scale: 4,
nullable: true
})
cumulativeOutputQuantity!: number | null
@Column({ name: 'BOMVersion', type: 'nvarchar', length: 50, nullable: true })
bomVersion!: string | null
}
/**
* Material plan record interface for type-safe operations
*/
export interface MaterialPlanRecordData {
id?: number
factory?: string | null
materialStatus?: string | null
planNumber?: string | null
sourceNumber?: string | null
materialType?: string | null
productCode?: string | null
productName?: string | null
productUnit?: string | null
productPlanQuantity?: number | null
useDepartment?: string | null
remark?: string | null
creator?: string | null
createDate?: Date | null
approver?: string | null
approveDate?: Date | null
sequenceNumber?: number | null
materialCode?: string | null
materialName?: string | null
specification?: string | null
model?: string | null
drawingNumber?: string | null
materialQuality?: string | null
planQuantity?: number | null
unit?: string | null
requiredDate?: Date | null
warehouse?: string | null
unitUsage?: number | null
cumulativeOutputQuantity?: number | null
bomVersion?: string | null
}

View File

@@ -1,27 +0,0 @@
/**
* TypeORM Entity for MaterialsToBeDeleted table
*/
import { Entity, PrimaryGeneratedColumn, Column, Index } from 'typeorm'
@Entity('MaterialsToBeDeleted')
export class MaterialsToBeDeleted {
@PrimaryGeneratedColumn()
id!: number
@Index({ unique: true })
@Column({ name: 'MaterialCode', type: 'nvarchar', length: 255, nullable: false })
materialCode!: string
@Column({ name: 'ManagerName', type: 'nvarchar', length: 255, nullable: true })
managerName!: string | null
}
/**
* Material record interface for type-safe operations
*/
export interface MaterialRecordData {
id?: number
materialCode: string
managerName: string | null
}

View File

@@ -1,184 +0,0 @@
/**
* 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'

View File

@@ -1,680 +0,0 @@
/**
* Data Access Object for MaterialsToBeDeleted table
*
* Mirrors the Python MaterialsToBeDeletedDAO functionality:
* - CRUD operations for materials identified by MaterialCode
* - Batch upsert operations
* - Manager-based filtering and queries
* - Statistics gathering
*/
import { create, type IDatabaseService } from './index'
import { createLogger } from '../logger'
const log = createLogger('MaterialsToBeDeletedDAO')
/**
* Material record interface
*/
export interface MaterialRecord {
id?: number
materialCode: string
managerName: string
}
/**
* Upsert statistics
*/
export interface UpsertStats {
total: number
success: number
failed: number
}
/**
* Material statistics
*/
export interface MaterialStats {
totalMaterials: number
uniqueManagers: number
materialsPerManager: Record<string, number>[]
}
/**
* Configuration for MaterialsToBeDeleted table
*/
export const MATERIALS_TO_BE_DELETED_CONFIG = {
TABLE_NAME_SQLSERVER: '[dbo].[MaterialsToBeDeleted]',
TABLE_NAME_MYSQL: 'dbo_MaterialsToBeDeleted',
COLUMNS: {
ID: 'ID',
MATERIAL_CODE: 'MaterialCode',
MANAGER_NAME: 'ManagerName'
}
} as const
/**
* MaterialsToBeDeleted DAO Class
*/
export class MaterialsToBeDeletedDAO {
private dbService: IDatabaseService | null = null
/**
* Get the appropriate table name based on database type
*/
private getTableName(): string {
const isSqlServer = this.dbService?.type === 'sqlserver'
return isSqlServer
? MATERIALS_TO_BE_DELETED_CONFIG.TABLE_NAME_SQLSERVER
: MATERIALS_TO_BE_DELETED_CONFIG.TABLE_NAME_MYSQL
}
/**
* Get database service instance using DatabaseFactory
*/
private async getDatabaseService(): Promise<IDatabaseService> {
if (this.dbService && this.dbService.isConnected()) {
return this.dbService
}
this.dbService = await create()
return this.dbService
}
/**
* Build placeholders for IN clause based on database type
*/
private buildPlaceholders(count: number, isSqlServer: boolean): string {
return isSqlServer
? Array.from({ length: count }, (_, idx) => `@p${idx}`).join(',')
: Array.from({ length: count }, () => '?').join(',')
}
// ==================== UPSERT (MERGE) ====================
/**
* Insert or update a single material record
* @param materialCode - Material code (exact match key)
* @param managerName - Manager name
* @returns True if successful
*/
async upsertMaterial(materialCode: string, managerName: string): Promise<boolean> {
if (!materialCode || !materialCode.trim()) {
log.error('MaterialCode cannot be empty')
return false
}
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const code = materialCode.trim()
const manager = managerName?.trim() || null
const isSqlServer = dbService.type === 'sqlserver'
if (isSqlServer) {
// SQL Server MERGE statement
const sqlString = `
MERGE ${tableName} AS target
USING (VALUES (@p0, @p1)) AS source (MaterialCode, ManagerName)
ON target.MaterialCode = source.MaterialCode
WHEN MATCHED THEN UPDATE SET ManagerName = source.ManagerName
WHEN NOT MATCHED THEN INSERT (MaterialCode, ManagerName) VALUES (source.MaterialCode, source.ManagerName);
`
await dbService.query(sqlString, [code, manager])
} else {
// MySQL ON DUPLICATE KEY UPDATE
const sqlString = `
INSERT INTO ${tableName} (MaterialCode, ManagerName)
VALUES (?, ?)
ON DUPLICATE KEY UPDATE ManagerName = VALUES(ManagerName)
`
await dbService.query(sqlString, [code, manager])
}
return true
} catch (error) {
log.error('Upsert material error', {
error: error instanceof Error ? error.message : String(error)
})
return false
}
}
/**
* Insert or update multiple material records in batch
* @param materials - List of materials with materialCode and managerName
* @returns Statistics object
*/
async upsertBatch(
materials: { materialCode: string; managerName: string }[]
): Promise<UpsertStats> {
if (!materials || materials.length === 0) {
return { total: 0, success: 0, failed: 0 }
}
const stats: UpsertStats = {
total: materials.length,
success: 0,
failed: 0
}
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const isSqlServer = dbService.type === 'sqlserver'
for (const material of materials) {
const materialCode = material.materialCode?.trim()
const managerName = material.managerName?.trim() || ''
if (!materialCode) {
stats.failed++
continue
}
try {
if (isSqlServer) {
// SQL Server MERGE statement
const sqlString = `
MERGE ${tableName} AS target
USING (VALUES (@p0, @p1)) AS source (MaterialCode, ManagerName)
ON target.MaterialCode = source.MaterialCode
WHEN MATCHED THEN UPDATE SET ManagerName = source.ManagerName
WHEN NOT MATCHED THEN INSERT (MaterialCode, ManagerName) VALUES (source.MaterialCode, source.ManagerName);
`
await dbService.query(sqlString, [materialCode, managerName || null])
} else {
// MySQL ON DUPLICATE KEY UPDATE
const sqlString = `
INSERT INTO ${tableName} (MaterialCode, ManagerName)
VALUES (?, ?)
ON DUPLICATE KEY UPDATE ManagerName = VALUES(ManagerName)
`
await dbService.query(sqlString, [materialCode, managerName || null])
}
stats.success++
} catch (error) {
log.error('Error upserting material', {
materialCode,
error: error instanceof Error ? error.message : String(error)
})
stats.failed++
}
}
} catch (error) {
log.error('Batch upsert error', {
error: error instanceof Error ? error.message : String(error)
})
stats.failed = stats.total - stats.success
}
return stats
}
/**
* Update manager for a single material
* @param materialCode - Material code
* @param managerName - New manager name
* @returns Success status
*/
async updateManager(
materialCode: string,
managerName: string
): Promise<{ success: boolean; error?: string }> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const isSqlServer = dbService.type === 'sqlserver'
if (isSqlServer) {
const sqlString = `
MERGE ${tableName} AS target
USING (VALUES (@p0, @p1)) AS source (MaterialCode, ManagerName)
ON target.MaterialCode = source.MaterialCode
WHEN MATCHED THEN UPDATE SET ManagerName = source.ManagerName
WHEN NOT MATCHED THEN INSERT (MaterialCode, ManagerName) VALUES (source.MaterialCode, source.ManagerName);
`
await dbService.query(sqlString, [materialCode, managerName || null])
} else {
const sqlString = `
INSERT INTO ${tableName} (MaterialCode, ManagerName)
VALUES (?, ?)
ON DUPLICATE KEY UPDATE ManagerName = VALUES(ManagerName)
`
await dbService.query(sqlString, [materialCode, managerName || null])
}
return { success: true }
} catch (error) {
log.error('Update manager error', {
materialCode,
error: error instanceof Error ? error.message : String(error)
})
return {
success: false,
error: error instanceof Error ? error.message : String(error)
}
}
}
// ==================== READ ====================
/**
* Get all material codes as a set
* @returns Set of material codes
*/
async getAllMaterialCodes(): Promise<Set<string>> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const sqlString = `
SELECT MaterialCode
FROM ${tableName}
WHERE MaterialCode IS NOT NULL
`
const result = await dbService.query(sqlString)
return new Set(result.rows.map((row) => row.MaterialCode as string).filter(Boolean))
} catch (error) {
log.error('Get all material codes error', {
error: error instanceof Error ? error.message : String(error)
})
return new Set()
}
}
/**
* Get all material records
* @returns List of all material records
*/
async getAllRecords(): Promise<MaterialRecord[]> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const sqlString = `
SELECT ID, MaterialCode, ManagerName
FROM ${tableName}
WHERE MaterialCode IS NOT NULL
ORDER BY ManagerName, MaterialCode
`
const result = await dbService.query(sqlString)
return result.rows.map((row) => ({
id: row.ID as number,
materialCode: row.MaterialCode as string,
managerName: row.ManagerName as string
}))
} catch (error) {
log.error('Get all records error', {
error: error instanceof Error ? error.message : String(error)
})
return []
}
}
/**
* Get all materials for a specific manager
* @param managerName - Manager name
* @returns List of materials for the manager
*/
async getMaterialsByManager(managerName: string): Promise<MaterialRecord[]> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const isSqlServer = dbService.type === 'sqlserver'
const placeholder = isSqlServer ? '@p0' : '?'
const sqlString = `
SELECT ID, MaterialCode, ManagerName
FROM ${tableName}
WHERE ManagerName = ${placeholder} AND MaterialCode IS NOT NULL
ORDER BY MaterialCode
`
const result = await dbService.query(sqlString, [managerName])
return result.rows.map((row) => ({
id: row.ID as number,
materialCode: row.MaterialCode as string,
managerName: row.ManagerName as string
}))
} catch (error) {
log.error('Get materials by manager error', {
error: error instanceof Error ? error.message : String(error)
})
return []
}
}
/**
* Get list of unique manager names
* @returns List of unique manager names
*/
async getManagers(): Promise<string[]> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const sqlString = `
SELECT DISTINCT ManagerName
FROM ${tableName}
WHERE ManagerName IS NOT NULL
ORDER BY ManagerName
`
const result = await dbService.query(sqlString)
return result.rows.map((row) => row.ManagerName as string).filter(Boolean)
} catch (error) {
log.error('Get managers error', {
error: error instanceof Error ? error.message : String(error)
})
return []
}
}
/**
* Get a specific record by material code
* @param materialCode - Material code
* @returns Material record or null
*/
async getRecordByMaterialCode(materialCode: string): Promise<MaterialRecord | null> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const code = materialCode.trim()
const isSqlServer = dbService.type === 'sqlserver'
const placeholder = isSqlServer ? '@p0' : '?'
const sqlString = `
SELECT ID, MaterialCode, ManagerName
FROM ${tableName}
WHERE MaterialCode = ${placeholder}
`
const result = await dbService.query(sqlString, [code])
if (result.rows.length === 0) {
return null
}
const row = result.rows[0]
return {
id: row.ID as number,
materialCode: row.MaterialCode as string,
managerName: row.ManagerName as string
}
} catch (error) {
log.error('Get record by material code error', {
error: error instanceof Error ? error.message : String(error)
})
return null
}
}
// ==================== DELETE ====================
/**
* Delete a specific material by material code
* @param materialCode - Material code
* @returns True if successful
*/
async deleteByMaterialCode(materialCode: string): Promise<boolean> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const code = materialCode.trim()
const isSqlServer = dbService.type === 'sqlserver'
const placeholder = isSqlServer ? '@p0' : '?'
const sqlString = `
DELETE FROM ${tableName}
WHERE MaterialCode = ${placeholder}
`
const result = await dbService.query(sqlString, [code])
return result.rowCount > 0
} catch (error) {
log.error('Delete by material code error', {
error: error instanceof Error ? error.message : String(error)
})
return false
}
}
/**
* Delete all materials for a specific manager
* @param managerName - Manager name
* @returns Number of records deleted
*/
async deleteByManager(managerName: string): Promise<number> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const isSqlServer = dbService.type === 'sqlserver'
const placeholder = isSqlServer ? '@p0' : '?'
const sqlString = `
DELETE FROM ${tableName}
WHERE ManagerName = ${placeholder}
`
const result = await dbService.query(sqlString, [managerName])
return result.rowCount
} catch (error) {
log.error('Delete by manager error', {
error: error instanceof Error ? error.message : String(error)
})
return 0
}
}
/**
* Delete all material records
* @returns Number of records deleted
*/
async deleteAllMaterials(): Promise<number> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const sqlString = `DELETE FROM ${tableName}`
const result = await dbService.query(sqlString)
return result.rowCount
} catch (error) {
log.error('Delete all materials error', {
error: error instanceof Error ? error.message : String(error)
})
return 0
}
}
/**
* Delete multiple materials by material codes
* @param materialCodes - List of material codes to delete
* @returns Number of records deleted
*/
async deleteByMaterialCodes(materialCodes: string[]): Promise<number> {
if (!materialCodes || materialCodes.length === 0) {
return 0
}
let totalDeleted = 0
const batchSize = 1000
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const isSqlServer = dbService.type === 'sqlserver'
for (let i = 0; i < materialCodes.length; i += batchSize) {
const batch = materialCodes.slice(i, i + batchSize)
const placeholders = this.buildPlaceholders(batch.length, isSqlServer)
const sqlString = `
DELETE FROM ${tableName}
WHERE MaterialCode IN (${placeholders})
`
const result = await dbService.query(
sqlString,
batch.map((c) => c.trim())
)
totalDeleted += result.rowCount
}
} catch (error) {
log.error('Delete by material codes error', {
error: error instanceof Error ? error.message : String(error)
})
}
return totalDeleted
}
// ==================== UTILITIES ====================
/**
* Check if a material exists
* @param materialCode - Material code
* @returns True if material exists
*/
async materialExists(materialCode: string): Promise<boolean> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const code = materialCode.trim()
const isSqlServer = dbService.type === 'sqlserver'
const placeholder = isSqlServer ? '@p0' : '?'
const sqlString = `
SELECT COUNT(*) as count
FROM ${tableName}
WHERE MaterialCode = ${placeholder}
`
const result = await dbService.query(sqlString, [code])
return result.rows.length > 0 && (result.rows[0].count as number) > 0
} catch (error) {
log.error('Material exists error', {
error: error instanceof Error ? error.message : String(error)
})
return false
}
}
/**
* Count all material records
* @returns Total number of records
*/
async countAll(): Promise<number> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const sqlString = `SELECT COUNT(*) as count FROM ${tableName}`
const result = await dbService.query(sqlString)
return result.rows.length > 0 ? (result.rows[0].count as number) : 0
} catch (error) {
log.error('Count all error', {
error: error instanceof Error ? error.message : String(error)
})
return 0
}
}
/**
* Count materials for a specific manager
* @param managerName - Manager name
* @returns Number of materials for the manager
*/
async countByManager(managerName: string): Promise<number> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const isSqlServer = dbService.type === 'sqlserver'
const placeholder = isSqlServer ? '@p0' : '?'
const sqlString = `
SELECT COUNT(*) as count
FROM ${tableName}
WHERE ManagerName = ${placeholder}
`
const result = await dbService.query(sqlString, [managerName])
return result.rows.length > 0 ? (result.rows[0].count as number) : 0
} catch (error) {
log.error('Count by manager error', {
error: error instanceof Error ? error.message : String(error)
})
return 0
}
}
/**
* Get comprehensive statistics
* @returns Statistics object
*/
async getStatistics(): Promise<MaterialStats> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
// Get total and unique managers
const statsSql = `
SELECT
COUNT(*) as totalMaterials,
COUNT(DISTINCT ManagerName) as uniqueManagers
FROM ${tableName}
WHERE MaterialCode IS NOT NULL
`
const statsResult = await dbService.query(statsSql)
const stats = statsResult.rows[0] || {}
// Get materials per manager
const managerSql = `
SELECT ManagerName, COUNT(*) as count
FROM ${tableName}
WHERE ManagerName IS NOT NULL
GROUP BY ManagerName
ORDER BY count DESC
`
const managerResult = await dbService.query(managerSql)
const materialsPerManager = managerResult.rows.map((row) => ({
[row.ManagerName as string]: row.count as number
}))
return {
totalMaterials: (stats.totalMaterials as number) || 0,
uniqueManagers: (stats.uniqueManagers as number) || 0,
materialsPerManager
}
} catch (error) {
log.error('Get statistics error', {
error: error instanceof Error ? error.message : String(error)
})
return {
totalMaterials: 0,
uniqueManagers: 0,
materialsPerManager: []
}
}
}
/**
* Disconnect from database
*/
async disconnect(): Promise<void> {
if (this.dbService) {
await this.dbService.disconnect()
this.dbService = null
}
}
}

View File

@@ -1,376 +0,0 @@
/**
* 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
}
}
}

View File

@@ -1,130 +0,0 @@
import mysql from 'mysql2/promise'
import type {
IDatabaseService,
DatabaseType,
QueryResult,
MySqlConfig
} from '../../types/database.types'
export type { MySqlConfig } from '../../types/database.types'
export class MySqlService implements IDatabaseService {
/** Database type identifier */
readonly type: DatabaseType = 'mysql'
private connection: mysql.Connection | null = null
private config: MySqlConfig
constructor(config: MySqlConfig) {
this.config = config
}
/**
* Connect to MySQL database
*/
async connect(): Promise<void> {
if (this.connection) {
throw new Error('Already connected to MySQL')
}
try {
this.connection = await mysql.createConnection({
host: this.config.host,
port: this.config.port,
user: this.config.user,
password: this.config.password,
database: this.config.database
})
// Test connection
await this.connection.ping()
} catch (error) {
throw new Error(`Failed to connect to MySQL: ${(error as Error).message}`)
}
}
/**
* Disconnect from MySQL database
*/
async disconnect(): Promise<void> {
if (!this.connection) {
return
}
try {
await this.connection.end()
this.connection = null
} catch (error) {
throw new Error(`Failed to disconnect from MySQL: ${(error as Error).message}`)
}
}
/**
* Check if connected to MySQL database
*/
isConnected(): boolean {
return this.connection !== null
}
/**
* Execute a query and return results
*/
async query(sql: string, params?: any[]): Promise<QueryResult> {
if (!this.connection) {
throw new Error('Not connected to MySQL. Call connect() first.')
}
try {
const [result, fields] = await this.connection.execute(sql, params)
// Convert to plain objects and extract column names
const columns = Array.isArray(fields) ? fields.map((field) => field.name) : []
// Handle different result types
let rows: Record<string, unknown>[] = []
let rowCount = 0
if (Array.isArray(result)) {
// SELECT query - result is an array of rows
rows = result as Record<string, unknown>[]
rowCount = rows.length
} else if (typeof result === 'object' && result !== null) {
// INSERT/UPDATE/DELETE query - result is OkPacket
const okPacket = result as any
rowCount = okPacket.affectedRows || okPacket.changedRows || 0
}
return {
rows,
columns,
rowCount
}
} catch (error) {
throw new Error(`MySQL query failed: ${(error as Error).message}`)
}
}
/**
* Execute multiple queries in a transaction
*/
async transaction(queries: { sql: string; params?: any[] }[]): Promise<void> {
if (!this.connection) {
throw new Error('Not connected to MySQL. Call connect() first.')
}
try {
await this.connection.beginTransaction()
for (const { sql, params } of queries) {
await this.connection.execute(sql, params)
}
await this.connection.commit()
} catch (error) {
if (this.connection) {
await this.connection.rollback()
}
throw new Error(`MySQL transaction failed: ${(error as Error).message}`)
}
}
}

View File

@@ -1,281 +0,0 @@
/**
* Repository for DiscreteMaterialPlan entity
*
* Provides type-safe database operations for discrete material plan data.
*/
import { DataSource, Repository, In } from 'typeorm'
import { DiscreteMaterialPlan, MaterialPlanRecordData } from '../entities/DiscreteMaterialPlan'
import { getDataSource } from '../data-source'
import { createLogger } from '../../logger'
const log = createLogger('DiscreteMaterialPlanRepository')
/**
* DiscreteMaterialPlan Repository class
*/
export class DiscreteMaterialPlanRepository {
private repository: Repository<DiscreteMaterialPlan> | null = null
private dataSource: DataSource | null = null
/**
* Get the repository instance
*/
private async getRepository(): Promise<Repository<DiscreteMaterialPlan>> {
if (!this.repository) {
this.dataSource = getDataSource()
if (!this.dataSource.isInitialized) {
await this.dataSource.initialize()
}
this.repository = this.dataSource.getRepository(DiscreteMaterialPlan)
}
return this.repository
}
/**
* Query all records
*/
async queryAll(): Promise<DiscreteMaterialPlan[]> {
try {
const repo = await this.getRepository()
return await repo.find()
} catch (error) {
log.error('Query all failed', {
error: error instanceof Error ? error.message : String(error)
})
return []
}
}
/**
* Query all records with deduplication by MaterialCode
*/
async queryAllDistinctByMaterialCode(): Promise<DiscreteMaterialPlan[]> {
try {
const repo = await this.getRepository()
const query = `
WITH RankedRecords AS (
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY MaterialCode
ORDER BY CreateDate ASC, SequenceNumber ASC
) AS rn
FROM DiscreteMaterialPlanData
WHERE MaterialCode IS NOT NULL
)
SELECT * FROM RankedRecords WHERE rn = 1
`
return await repo.query(query)
} catch (error) {
log.error('Query all distinct by material code failed', {
error: error instanceof Error ? error.message : String(error)
})
return []
}
}
/**
* Query by source numbers (production order numbers)
*/
async queryBySourceNumbers(sourceNumbers: string[]): Promise<DiscreteMaterialPlan[]> {
if (!sourceNumbers.length) return []
try {
const repo = await this.getRepository()
const batchSize = 2000
const allResults: DiscreteMaterialPlan[] = []
for (let i = 0; i < sourceNumbers.length; i += batchSize) {
const batch = sourceNumbers.slice(i, i + batchSize)
const results = await repo.find({
where: { sourceNumber: In(batch) }
})
allResults.push(...results)
}
return allResults
} catch (error) {
log.error('Query by source numbers failed', {
count: sourceNumbers.length,
error: error instanceof Error ? error.message : String(error)
})
return []
}
}
/**
* Query by source numbers with deduplication by MaterialCode
*/
async queryBySourceNumbersDistinct(sourceNumbers: string[]): Promise<DiscreteMaterialPlan[]> {
if (!sourceNumbers.length) return []
try {
const repo = await this.getRepository()
const batchSize = 2000
const allResults: DiscreteMaterialPlan[] = []
for (let i = 0; i < sourceNumbers.length; i += batchSize) {
const batch = sourceNumbers.slice(i, i + batchSize)
const query = `
WITH RankedRecords AS (
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY MaterialCode
ORDER BY CreateDate ASC, SequenceNumber ASC
) AS rn
FROM DiscreteMaterialPlanData
WHERE SourceNumber IN (?) AND MaterialCode IS NOT NULL
)
SELECT * FROM RankedRecords WHERE rn = 1
`
const results = await repo.query(query, [batch])
allResults.push(...results)
}
return allResults
} catch (error) {
log.error('Query by source numbers distinct failed', {
count: sourceNumbers.length,
error: error instanceof Error ? error.message : String(error)
})
return []
}
}
/**
* Query by single source number
*/
async queryBySourceNumber(sourceNumber: string): Promise<DiscreteMaterialPlan[]> {
try {
const repo = await this.getRepository()
return await repo.find({ where: { sourceNumber } })
} catch (error) {
log.error('Query by source number failed', {
sourceNumber,
error: error instanceof Error ? error.message : String(error)
})
return []
}
}
/**
* Query by plan number
*/
async queryByPlanNumber(planNumber: string): Promise<DiscreteMaterialPlan[]> {
try {
const repo = await this.getRepository()
return await repo.find({ where: { planNumber } })
} catch (error) {
log.error('Query by plan number failed', {
planNumber,
error: error instanceof Error ? error.message : String(error)
})
return []
}
}
/**
* Query by multiple plan numbers
*/
async queryByPlanNumbers(planNumbers: string[]): Promise<DiscreteMaterialPlan[]> {
if (!planNumbers.length) return []
try {
const repo = await this.getRepository()
return await repo.find({
where: { planNumber: In(planNumbers) }
})
} catch (error) {
log.error('Query by plan numbers failed', {
count: planNumbers.length,
error: error instanceof Error ? error.message : String(error)
})
return []
}
}
/**
* Count all records
*/
async countAll(): Promise<number> {
try {
const repo = await this.getRepository()
return await repo.count()
} catch {
return 0
}
}
/**
* Get unique material names
*/
async getUniqueMaterialNames(sourceNumbers?: string[]): Promise<string[]> {
try {
const repo = await this.getRepository()
let query = repo
.createQueryBuilder('m')
.select('DISTINCT m.materialName', 'materialName')
.where('m.materialName IS NOT NULL')
if (sourceNumbers && sourceNumbers.length > 0) {
query = query.andWhere('m.sourceNumber IN (:...sourceNumbers)', { sourceNumbers })
}
const result = await query.orderBy('m.materialName', 'ASC').getRawMany()
return result.map((r) => r.materialName).filter(Boolean)
} catch (error) {
log.error('Get unique material names failed', {
error: error instanceof Error ? error.message : String(error)
})
return []
}
}
/**
* Get statistics
*/
async getStatistics(): Promise<{
totalRecords: number
uniquePlans: number
uniqueOrders: number
earliestRecord: Date | null
latestRecord: Date | null
}> {
try {
const repo = await this.getRepository()
const result = await repo
.createQueryBuilder('m')
.select('COUNT(*)', 'totalRecords')
.addSelect('COUNT(DISTINCT m.planNumber)', 'uniquePlans')
.addSelect('COUNT(DISTINCT m.sourceNumber)', 'uniqueOrders')
.addSelect('MIN(m.createDate)', 'earliestRecord')
.addSelect('MAX(m.createDate)', 'latestRecord')
.getRawOne()
return {
totalRecords: parseInt(result?.totalRecords || '0', 10),
uniquePlans: parseInt(result?.uniquePlans || '0', 10),
uniqueOrders: parseInt(result?.uniqueOrders || '0', 10),
earliestRecord: result?.earliestRecord || null,
latestRecord: result?.latestRecord || null
}
} catch (error) {
log.error('Get statistics failed', {
error: error instanceof Error ? error.message : String(error)
})
return {
totalRecords: 0,
uniquePlans: 0,
uniqueOrders: 0,
earliestRecord: null,
latestRecord: null
}
}
}
}

View File

@@ -1,266 +0,0 @@
/**
* Repository for MaterialsToBeDeleted entity
*
* Provides type-safe database operations for materials to be deleted.
*/
import { DataSource, Repository, In } from 'typeorm'
import { MaterialsToBeDeleted, MaterialRecordData } from '../entities/MaterialsToBeDeleted'
import { getDataSource } from '../data-source'
import { createLogger } from '../../logger'
const log = createLogger('MaterialsToBeDeletedRepository')
/**
* Upsert statistics
*/
export interface UpsertStats {
total: number
success: number
failed: number
}
/**
* MaterialsToBeDeleted Repository class
*/
export class MaterialsToBeDeletedRepository {
private repository: Repository<MaterialsToBeDeleted> | null = null
private dataSource: DataSource | null = null
/**
* Get the repository instance
*/
private async getRepository(): Promise<Repository<MaterialsToBeDeleted>> {
if (!this.repository) {
this.dataSource = getDataSource()
if (!this.dataSource.isInitialized) {
await this.dataSource.initialize()
}
this.repository = this.dataSource.getRepository(MaterialsToBeDeleted)
}
return this.repository
}
/**
* Insert or update a single material record
*/
async upsert(materialCode: string, managerName: string | null): Promise<boolean> {
try {
const repo = await this.getRepository()
// Use upsert pattern
let entity = await repo.findOne({ where: { materialCode } })
if (entity) {
entity.managerName = managerName
} else {
entity = repo.create({ materialCode, managerName })
}
await repo.save(entity)
log.debug('Upserted material', { materialCode })
return true
} catch (error) {
log.error('Upsert material failed', {
materialCode,
error: error instanceof Error ? error.message : String(error)
})
return false
}
}
/**
* Insert or update multiple material records in batch
*/
async upsertBatch(materials: MaterialRecordData[]): Promise<UpsertStats> {
const stats: UpsertStats = {
total: materials.length,
success: 0,
failed: 0
}
try {
const repo = await this.getRepository()
for (const material of materials) {
if (!material.materialCode?.trim()) {
stats.failed++
continue
}
try {
let entity = await repo.findOne({ where: { materialCode: material.materialCode } })
if (entity) {
entity.managerName = material.managerName
} else {
entity = repo.create({
materialCode: material.materialCode,
managerName: material.managerName
})
}
await repo.save(entity)
stats.success++
} catch {
stats.failed++
}
}
log.info('Batch upsert completed', stats)
return stats
} catch (error) {
log.error('Batch upsert failed', {
error: error instanceof Error ? error.message : String(error)
})
stats.failed = stats.total - stats.success
return stats
}
}
/**
* Get all material codes as a set
*/
async getAllMaterialCodes(): Promise<Set<string>> {
try {
const repo = await this.getRepository()
const records = await repo.find({
select: ['materialCode'],
where: { materialCode: In([]) } // This will be overridden
})
// Use query builder for better performance
const result = await repo
.createQueryBuilder('m')
.select('m.materialCode')
.where('m.materialCode IS NOT NULL')
.getMany()
return new Set(result.map((r) => r.materialCode).filter(Boolean))
} catch (error) {
log.error('Get all material codes failed', {
error: error instanceof Error ? error.message : String(error)
})
return new Set()
}
}
/**
* Get all records
*/
async getAllRecords(): Promise<MaterialsToBeDeleted[]> {
try {
const repo = await this.getRepository()
return await repo.find({
order: { managerName: 'ASC', materialCode: 'ASC' }
})
} catch (error) {
log.error('Get all records failed', {
error: error instanceof Error ? error.message : String(error)
})
return []
}
}
/**
* Get materials by manager name
*/
async getByManager(managerName: string): Promise<MaterialsToBeDeleted[]> {
try {
const repo = await this.getRepository()
return await repo.find({
where: { managerName },
order: { materialCode: 'ASC' }
})
} catch (error) {
log.error('Get by manager failed', {
managerName,
error: error instanceof Error ? error.message : String(error)
})
return []
}
}
/**
* Get unique manager names
*/
async getManagers(): Promise<string[]> {
try {
const repo = await this.getRepository()
const result = await repo
.createQueryBuilder('m')
.select('DISTINCT m.managerName', 'managerName')
.where('m.managerName IS NOT NULL')
.orderBy('m.managerName', 'ASC')
.getRawMany()
return result.map((r) => r.managerName).filter(Boolean)
} catch (error) {
log.error('Get managers failed', {
error: error instanceof Error ? error.message : String(error)
})
return []
}
}
/**
* Delete by material code
*/
async deleteByMaterialCode(materialCode: string): Promise<boolean> {
try {
const repo = await this.getRepository()
const result = await repo.delete({ materialCode })
return (result.affected ?? 0) > 0
} catch (error) {
log.error('Delete by material code failed', {
materialCode,
error: error instanceof Error ? error.message : String(error)
})
return false
}
}
/**
* Delete multiple materials by codes
*/
async deleteByMaterialCodes(materialCodes: string[]): Promise<number> {
if (!materialCodes.length) return 0
try {
const repo = await this.getRepository()
const result = await repo.delete({ materialCode: In(materialCodes) })
return result.affected ?? 0
} catch (error) {
log.error('Delete by material codes failed', {
count: materialCodes.length,
error: error instanceof Error ? error.message : String(error)
})
return 0
}
}
/**
* Check if a material exists
*/
async exists(materialCode: string): Promise<boolean> {
try {
const repo = await this.getRepository()
const count = await repo.count({ where: { materialCode } })
return count > 0
} catch {
return false
}
}
/**
* Count all records
*/
async countAll(): Promise<number> {
try {
const repo = await this.getRepository()
return await repo.count()
} catch {
return 0
}
}
}

View File

@@ -1,191 +0,0 @@
import sql from 'mssql'
import type {
IDatabaseService,
DatabaseType,
QueryResult,
SqlServerConfig
} from '../../types/database.types'
export type { SqlServerConfig } from '../../types/database.types'
export class SqlServerService implements IDatabaseService {
/** Database type identifier */
readonly type: DatabaseType = 'sqlserver'
private pool: sql.ConnectionPool | null = null
private config: SqlServerConfig
constructor(config: SqlServerConfig) {
this.config = config
}
/**
* Connect to SQL Server database
*/
async connect(): Promise<void> {
if (this.pool) {
throw new Error('Already connected to SQL Server')
}
try {
const poolConfig: sql.config = {
server: this.config.server,
port: this.config.port,
user: this.config.user,
password: this.config.password,
database: this.config.database,
options: {
encrypt: this.config.options?.encrypt ?? false,
trustServerCertificate: this.config.options?.trustServerCertificate ?? false
}
}
this.pool = new sql.ConnectionPool(poolConfig)
await this.pool.connect()
} catch (error) {
throw new Error(`Failed to connect to SQL Server: ${(error as Error).message}`)
}
}
/**
* Disconnect from SQL Server database
*/
async disconnect(): Promise<void> {
if (!this.pool) {
return
}
try {
await this.pool.close()
this.pool = null
} catch (error) {
throw new Error(`Failed to disconnect from SQL Server: ${(error as Error).message}`)
}
}
/**
* Check if connected to SQL Server database
*/
isConnected(): boolean {
return this.pool !== null && this.pool.connected
}
/**
* Execute a query and return results
* @param sqlString - SQL query string with @p0, @p1, ... placeholders
* @param params - Query parameters as an array (converted to @p0, @p1, ...)
*/
async query(sqlString: string, params?: any[]): Promise<QueryResult> {
if (!this.pool) {
throw new Error('Not connected to SQL Server. Call connect() first.')
}
try {
const request = this.pool.request()
// Add parameters if provided - convert array to @p0, @p1, ... format
if (params && params.length > 0) {
params.forEach((value, index) => {
request.input(`p${index}`, value)
})
}
const result = await request.query(sqlString)
// Convert recordset to array of objects (may be undefined for DELETE/INSERT/UPDATE)
const rows = (result.recordset as Record<string, unknown>[]) || []
// Extract column names from the first row if available
const columns = rows.length > 0 ? Object.keys(rows[0]) : []
return {
rows,
columns,
rowCount: result.rowsAffected?.[0] || rows.length
}
} catch (error) {
throw new Error(`SQL Server query failed: ${(error as Error).message}`)
}
}
/**
* Execute a prepared statement with named parameters
* @param sqlString - SQL query string with @paramName placeholders
* @param params - Parameters as an object with { value, type? } structure
*/
async queryWithParams(
sqlString: string,
params: Record<
string,
{
value: unknown
type?: sql.ISqlType | sql.ISqlTypeFactoryWithLength | sql.ISqlTypeWithLength
}
>
): Promise<QueryResult> {
if (!this.pool) {
throw new Error('Not connected to SQL Server. Call connect() first.')
}
try {
const request = this.pool.request()
// Add parameters with explicit types
for (const [key, { value, type }] of Object.entries(params)) {
if (type) {
request.input(key, type, value)
} else {
request.input(key, value)
}
}
const result = await request.query(sqlString)
// Convert recordset to array of objects
const rows = result.recordset as Record<string, unknown>[]
// Extract column names from the first row if available
const columns = rows.length > 0 ? Object.keys(rows[0]) : []
return {
rows,
columns,
rowCount: result.rowsAffected?.[0] || rows.length
}
} catch (error) {
throw new Error(`SQL Server query failed: ${(error as Error).message}`)
}
}
/**
* Execute multiple queries in a transaction
* @param queries - Array of queries with array-based parameters
*/
async transaction(queries: { sql: string; params?: any[] }[]): Promise<void> {
if (!this.pool) {
throw new Error('Not connected to SQL Server. Call connect() first.')
}
const transaction = new sql.Transaction(this.pool)
try {
await transaction.begin()
for (const { sql: sqlString, params } of queries) {
const request = new sql.Request(transaction)
// Add parameters if provided - convert array to @p0, @p1, ... format
if (params && params.length > 0) {
params.forEach((value, index) => {
request.input(`p${index}`, value)
})
}
await request.query(sqlString)
}
await transaction.commit()
} catch (error) {
await transaction.rollback()
throw new Error(`SQL Server transaction failed: ${(error as Error).message}`)
}
}
}

View File

@@ -1,208 +0,0 @@
/**
* ERP Browser Manager
*
* Manages browser lifecycle for ERP automation.
* Separates browser management from authentication logic.
*/
import { chromium, type Browser, type BrowserContext, type Page } from 'playwright'
import { createLogger } from '../logger'
const log = createLogger('ErpBrowserManager')
/**
* Browser configuration options
*/
export interface BrowserConfig {
headless?: boolean
slowMo?: number
viewport?: { width: number; height: number }
ignoreHTTPSErrors?: boolean
acceptDownloads?: boolean
}
/**
* Default browser configuration
*/
const DEFAULT_CONFIG: Required<BrowserConfig> = {
headless: false,
slowMo: 100,
viewport: { width: 1920, height: 1080 },
ignoreHTTPSErrors: true,
acceptDownloads: true
}
/**
* Browser session containing all browser-related objects
*/
export interface BrowserSession {
browser: Browser
context: BrowserContext
page: Page
}
/**
* ErpBrowserManager class
* Manages browser lifecycle independently from ERP authentication
*/
export class ErpBrowserManager {
private config: Required<BrowserConfig>
private session: BrowserSession | null = null
constructor(config?: BrowserConfig) {
this.config = { ...DEFAULT_CONFIG, ...config }
}
/**
* Launch a new browser instance
*/
async launch(): Promise<Browser> {
if (this.session?.browser?.isConnected()) {
log.debug('Browser already running, returning existing instance')
return this.session.browser
}
log.info('Launching browser', { headless: this.config.headless })
const browser = await chromium.launch({
headless: this.config.headless,
slowMo: this.config.slowMo,
args: [
'--ignore-certificate-errors',
'--ignore-ssl-errors',
'--ignore-certificate-errors-spki-list',
'--disable-web-security'
]
})
log.info('Browser launched successfully')
return browser
}
/**
* Create a new browser context
*/
async createContext(browser?: Browser): Promise<BrowserContext> {
const browserInstance = browser || (await this.launch())
log.debug('Creating browser context')
const context = await browserInstance.newContext({
acceptDownloads: this.config.acceptDownloads,
viewport: this.config.viewport,
ignoreHTTPSErrors: true,
javaScriptEnabled: true
})
log.debug('Browser context created')
return context
}
/**
* Create a new page in the context
*/
async createPage(context?: BrowserContext): Promise<Page> {
let contextInstance: BrowserContext
if (context) {
contextInstance = context
} else if (this.session?.context) {
contextInstance = this.session.context
} else {
const browser = await this.launch()
contextInstance = await this.createContext(browser)
}
log.debug('Creating new page')
const page = await contextInstance.newPage()
log.debug('Page created')
return page
}
/**
* Initialize a complete browser session
* This creates browser, context, and page in one call
*/
async initialize(): Promise<BrowserSession> {
if (this.session) {
log.debug('Returning existing browser session')
return this.session
}
const browser = await this.launch()
const context = await this.createContext(browser)
const page = await this.createPage(context)
this.session = { browser, context, page }
log.info('Browser session initialized')
return this.session
}
/**
* Get the current session
*/
getSession(): BrowserSession | null {
return this.session
}
/**
* Check if browser is running
*/
isRunning(): boolean {
return this.session?.browser?.isConnected() ?? false
}
/**
* Close the browser and cleanup
*/
async close(): Promise<void> {
if (!this.session) {
log.debug('No browser session to close')
return
}
log.info('Closing browser session')
try {
if (this.session.context) {
await this.session.context.close()
}
} catch (error) {
log.warn('Error closing context', {
error: error instanceof Error ? error.message : String(error)
})
}
try {
if (this.session.browser) {
await this.session.browser.close()
}
} catch (error) {
log.warn('Error closing browser', {
error: error instanceof Error ? error.message : String(error)
})
}
this.session = null
log.info('Browser session closed')
}
/**
* Navigate to a URL
*/
async navigate(url: string, options?: { timeout?: number }): Promise<void> {
const page = this.session?.page
if (!page) {
throw new Error('No page available. Call initialize() first.')
}
log.info('Navigating to URL', { url })
await page.goto(url, { timeout: options?.timeout ?? 30000 })
await page.waitForLoadState('domcontentloaded', { timeout: options?.timeout ?? 10000 })
log.debug('Page loaded')
}
}
export default ErpBrowserManager

View File

@@ -1,865 +0,0 @@
import { ERP_LOCATORS } from './locators'
import { ErpAuthService } from './erp-auth'
import type { CleanerInput, CleanerResult, OrderCleanDetail } from '../../types/cleaner.types'
import type { ErpSession } from '../../types/erp.types'
import type { FrameLocator, Locator, Page } from 'playwright'
import { createLogger } from '../logger'
const log = createLogger('CleanerService')
const DEFAULT_QUERY_BATCH_SIZE = 100
const MAX_QUERY_BATCH_SIZE = 100
const DEFAULT_PROCESS_CONCURRENCY = 1
const MAX_PROCESS_CONCURRENCY = 20
interface RetryResult {
retriedOrders: number
successfulRetries: number
updatedDetails: OrderCleanDetail[]
}
interface ProgressState {
completedOrders: number
totalOrders: number
}
interface QueryResultRow {
rowIndex: number
orderNumber: string
}
class AsyncMutex {
private queue: Promise<void> = Promise.resolve()
async runExclusive<T>(task: () => Promise<T>): Promise<T> {
let release!: () => void
const next = new Promise<void>((resolve) => {
release = resolve
})
const previous = this.queue
this.queue = this.queue.then(() => next)
await previous
try {
return await task()
} finally {
release()
}
}
}
/**
* Cleaner Service Options
*/
export interface CleanerOptions {
dryRun?: boolean
verbose?: boolean
}
/**
* Material deletion check parameters
*/
export interface ShouldDeleteParams {
rowNumber: number
pendingQty: string
materialCode: string
deleteSet: Set<string>
}
function clampNumber(
value: number | undefined,
fallback: number,
min: number,
max: number
): number {
if (!Number.isFinite(value)) {
return fallback
}
return Math.min(max, Math.max(min, Math.trunc(value ?? fallback)))
}
export function createBatches<T>(items: T[], batchSize: number): T[][] {
const batches: T[][] = []
for (let i = 0; i < items.length; i += batchSize) {
batches.push(items.slice(i, i + batchSize))
}
return batches
}
export function getMissingOrders(inputOrders: string[], processedOrders: Set<string>): string[] {
const uniqueInputOrders = Array.from(new Set(inputOrders))
return uniqueInputOrders.filter((order) => !processedOrders.has(order))
}
export async function runWithConcurrency<T, R>(
items: T[],
concurrency: number,
worker: (item: T, index: number) => Promise<R>
): Promise<R[]> {
const results = new Array<R>(items.length)
const limit = Math.max(1, Math.trunc(concurrency))
let cursor = 0
const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (true) {
const current = cursor
cursor += 1
if (current >= items.length) {
return
}
results[current] = await worker(items[current], current)
}
})
await Promise.all(runners)
return results
}
/**
* ERP Cleaner Service
* Deletes specified materials from production orders in ERP system
*/
export class CleanerService {
private authService: ErpAuthService
private dryRun: boolean
constructor(authService: ErpAuthService, options: CleanerOptions = {}) {
this.authService = authService
this.dryRun = options.dryRun ?? false
}
/**
* Check if dry-run mode is enabled
*/
isDryRun(): boolean {
return this.dryRun
}
/**
* Determine if a material should be deleted
*/
shouldDeleteMaterial(params: ShouldDeleteParams): boolean {
const { rowNumber, pendingQty, materialCode, deleteSet } = params
if (!deleteSet.has(materialCode)) {
return false
}
if (rowNumber >= 2000 && rowNumber < 8000) {
return false
}
if (pendingQty && pendingQty.trim() !== '') {
return false
}
return true
}
getSkipReason(params: ShouldDeleteParams): string {
const { rowNumber, pendingQty, materialCode, deleteSet } = params
if (!deleteSet.has(materialCode)) {
return '物料不在删除清单中'
}
if (rowNumber >= 2000 && rowNumber < 8000) {
return '行号在 2000-7999 范围内(受保护)'
}
if (pendingQty && pendingQty.trim() !== '') {
return '累计待发数量不为空'
}
return '未知原因'
}
async clean(input: CleanerInput): Promise<CleanerResult> {
const result: CleanerResult = {
ordersProcessed: 0,
materialsDeleted: 0,
materialsSkipped: 0,
errors: [],
details: [],
retriedOrders: 0,
successfulRetries: 0
}
const totalOrders = input.orderNumbers.length
const dryRun = input.dryRun ?? this.dryRun
const queryBatchSize = clampNumber(
input.queryBatchSize,
DEFAULT_QUERY_BATCH_SIZE,
1,
MAX_QUERY_BATCH_SIZE
)
const processConcurrency = clampNumber(
input.processConcurrency,
DEFAULT_PROCESS_CONCURRENCY,
1,
MAX_PROCESS_CONCURRENCY
)
log.info('Starting cleaner', {
totalOrders,
materialCount: input.materialCodes.length,
dryRun,
queryBatchSize,
processConcurrency
})
const deleteSet = new Set(input.materialCodes)
let popupPage: Page | null = null
try {
const session = this.authService.getSession()
const navigation = await this.navigateToCleanerPage(session)
popupPage = navigation.popupPage
const { workFrame } = navigation
await this.setupQueryInterface(workFrame)
const orderBatches = createBatches(input.orderNumbers, queryBatchSize)
const popupMutex = new AsyncMutex()
const progressState: ProgressState = {
completedOrders: 0,
totalOrders
}
for (let batchIndex = 0; batchIndex < orderBatches.length; batchIndex++) {
const batchOrders = orderBatches[batchIndex]
log.info('Processing cleaner batch', {
batchIndex: batchIndex + 1,
totalBatches: orderBatches.length,
batchSize: batchOrders.length
})
await this.queryOrders(workFrame, batchOrders)
await this.waitForLoading(workFrame)
const queriedRows = await this.collectQueryResultRows(workFrame)
const queriedOrderNumbersInBatch = new Set(queriedRows.map((row) => row.orderNumber))
await runWithConcurrency(queriedRows, processConcurrency, async (row) => {
const { rowIndex, orderNumber } = row
const openedDetailPage = await popupMutex.runExclusive(async () => {
return await this.openDetailPageFromRow(workFrame, popupPage!, rowIndex)
})
let detail: OrderCleanDetail
try {
detail = await this.processDetailPage({
detailPage: openedDetailPage,
deleteSet,
dryRun,
expectedOrderNumber: orderNumber,
progressState,
onProgress: input.onProgress
})
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
detail = this.createErrorDetail(orderNumber, message)
} finally {
progressState.completedOrders += 1
}
result.details.push(detail)
if (detail.errors.length > 0) {
result.errors.push(`Order ${detail.orderNumber}: ${detail.errors.join('; ')}`)
return
}
result.ordersProcessed += 1
result.materialsDeleted += detail.materialsDeleted
result.materialsSkipped += detail.materialsSkipped
})
const missingOrders = getMissingOrders(batchOrders, queriedOrderNumbersInBatch)
for (const missingOrder of missingOrders) {
const missingMessage = '订单未出现在查询结果中'
result.errors.push(`Order ${missingOrder}: ${missingMessage}`)
result.details.push(this.createErrorDetail(missingOrder, missingMessage))
}
}
const retryResult = await this.retryFailedOrders({
workFrame,
popupPage,
failedDetails: result.details.filter(
(d) => d.errors.length > 0 && this.isOrderNumber(d.orderNumber)
),
deleteSet,
dryRun,
onProgress: input.onProgress
})
result.retriedOrders = retryResult.retriedOrders
result.successfulRetries = retryResult.successfulRetries
retryResult.updatedDetails.forEach((updatedDetail) => {
const index = result.details.findIndex((d) => d.orderNumber === updatedDetail.orderNumber)
if (index !== -1) {
const previousDetail = result.details[index]
if (updatedDetail.retrySuccess && previousDetail.errors.length > 0) {
result.ordersProcessed += 1
result.materialsDeleted += updatedDetail.materialsDeleted
result.materialsSkipped += updatedDetail.materialsSkipped
}
result.details[index] = updatedDetail
}
})
const successfulRetryOrders = new Set(
retryResult.updatedDetails.filter((d) => d.retrySuccess).map((d) => d.orderNumber)
)
result.errors = result.errors.filter(
(err) => !successfulRetryOrders.has(err.split(':')[0].replace('Order ', ''))
)
log.info('Cleaner completed', {
ordersProcessed: result.ordersProcessed,
materialsDeleted: result.materialsDeleted,
materialsSkipped: result.materialsSkipped,
errorCount: result.errors.length
})
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
log.error('Cleaner failed', { error: message })
result.errors.push(`Clean failed: ${message}`)
} finally {
if (popupPage) {
try {
await popupPage.close()
} catch {
// Ignore close errors
}
}
}
return result
}
async navigateToCleanerPage(
session: ErpSession
): Promise<{ popupPage: Page; workFrame: FrameLocator }> {
const { page, mainFrame } = session
await mainFrame.locator('i').first().click()
const popupPromise = page.waitForEvent('popup')
await mainFrame.getByTitle('离散生产订单维护', { exact: true }).first().click()
const popupPage = await popupPromise
const forwardFrameLocator = popupPage.locator('#forwardFrame')
const fFrame = forwardFrameLocator.contentFrame()
const innerFrameLocator = fFrame.locator('#mainiframe')
await innerFrameLocator.waitFor({ state: 'visible', timeout: 30000 })
const workFrame = innerFrameLocator.contentFrame()
await workFrame.locator('#hot-key-head_list').waitFor({ state: 'visible', timeout: 30000 })
return { popupPage, workFrame }
}
private async setupQueryInterface(innerFrame: FrameLocator): Promise<void> {
await innerFrame.locator('.search-name-wrapper > .iconfont').click()
await innerFrame.getByText('订单号查询').click()
await innerFrame.getByRole('tab', { name: '全部' }).click()
const inputEl = innerFrame.locator('#rc_select_0')
await inputEl.fill('5000')
await inputEl.press('Enter')
}
private async queryOrders(workFrame: FrameLocator, orderNumbers: string[]): Promise<void> {
const textbox = workFrame.getByRole('textbox', { name: '生产订单号' })
await textbox.fill(orderNumbers.join(','))
await workFrame.locator('.search-component-searchBtn').click()
}
private async collectQueryResultRows(workFrame: FrameLocator): Promise<QueryResultRow[]> {
const rows = workFrame.locator('tbody tr')
const rowCount = await rows.count()
const result: QueryResultRow[] = []
for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) {
const row = rows.nth(rowIndex)
const orderNumber = await this.extractOrderNumberFromQueryRow(row)
if (!this.isOrderNumber(orderNumber)) {
continue
}
result.push({ rowIndex, orderNumber })
}
return result
}
private async extractOrderNumberFromQueryRow(row: Locator): Promise<string> {
try {
const cell = row.locator('td[colkey="vbillcode"]')
const codeLink = cell.locator('.code-detail-link').first()
const rawValue =
(await codeLink.count()) > 0 ? await codeLink.innerText() : await cell.innerText()
const value = rawValue.trim()
const match = value.match(/SC\d{14}/)
return match ? match[0] : value
} catch {
return ''
}
}
private async openDetailPageFromRow(
workFrame: FrameLocator,
popupPage: Page,
rowIndex: number
): Promise<Page> {
const row = workFrame.locator('tbody tr').nth(rowIndex)
await row.waitFor({ state: 'visible', timeout: 15000 })
const moreButton = row.locator('a.row-more').first()
await moreButton.scrollIntoViewIfNeeded()
const detailPagePromise = popupPage.waitForEvent('popup')
await moreButton.click()
await this.clickMaterialPlanMenu(workFrame)
return await detailPagePromise
}
private async openDetailPageFromCurrentQuery(
workFrame: FrameLocator,
popupPage: Page
): Promise<Page> {
const firstRow = workFrame.locator('tbody tr').first()
await firstRow.waitFor({ state: 'visible', timeout: 10000 })
const moreButton = firstRow.locator('a.row-more').first()
const detailPagePromise = popupPage.waitForEvent('popup')
await moreButton.click()
await this.clickMaterialPlanMenu(workFrame)
return await detailPagePromise
}
private async clickMaterialPlanMenu(workFrame: FrameLocator): Promise<void> {
const candidates = [
workFrame.locator('li:visible, a:visible, span:visible, div:visible').filter({
hasText: /^备料计划$/
}),
workFrame.getByRole('menuitem', { name: '备料计划' }),
workFrame.getByText('备料计划', { exact: true }),
workFrame.getByText('备料计划')
]
for (const candidate of candidates) {
const target = candidate.last()
try {
await target.waitFor({ state: 'visible', timeout: 2000 })
await target.click()
return
} catch {
// Try next locator candidate
}
}
throw new Error('无法定位“备料计划”菜单项(可能菜单结构已变化)')
}
private async processDetailPage(params: {
detailPage: Page
deleteSet: Set<string>
dryRun: boolean
progressState: ProgressState
expectedOrderNumber?: string
onProgress?: (
message: string,
progress?: number,
extra?: Partial<import('../../types/cleaner.types').CleanerProgress>
) => void
}): Promise<OrderCleanDetail> {
const { detailPage, deleteSet, dryRun, progressState, expectedOrderNumber, onProgress } = params
try {
const detailMainFrame = detailPage.locator('#forwardFrame')
const dFrame = await detailMainFrame.contentFrame()
if (!dFrame) {
throw new Error('Failed to access detail page forward frame')
}
const detailInnerLocator = dFrame.locator('#mainiframe')
await detailInnerLocator.waitFor({ state: 'visible', timeout: 30000 })
const detailInnerFrame = await detailInnerLocator.contentFrame()
if (!detailInnerFrame) {
throw new Error('Failed to access detail inner frame')
}
await detailInnerFrame
.getByText(/^离散备料计划维护:/)
.waitFor({ state: 'visible', timeout: 30000 })
const sourceOrderNumber = await this.extractSourceOrderNumber(detailInnerFrame)
const orderNumber = sourceOrderNumber || expectedOrderNumber || 'UNKNOWN_ORDER'
const detail: OrderCleanDetail = {
orderNumber,
materialsDeleted: 0,
materialsSkipped: 0,
errors: [],
skippedMaterials: [],
retryCount: 0,
retryAttempts: [],
retriedAt: undefined,
retrySuccess: false
}
const detailCountText = await detailInnerFrame.getByText(/^详细信息 \(\d+\)$/).innerText()
const detailCountMatch = detailCountText.match(/\((\d+)\)/)
const detailCount = detailCountMatch ? parseInt(detailCountMatch[1], 10) : 0
const statusText = await detailInnerFrame.getByText(/^备料状态:.+$/).innerText()
const statusMatch = statusText.replace(/\n/g, '').match(/备料状态:(.+)$/)
const detailStatus = statusMatch ? statusMatch[1].trim() : ''
onProgress?.(
`开始处理订单: ${orderNumber}`,
this.calculateProgress(
progressState.completedOrders,
0,
detailCount,
progressState.totalOrders
),
{
currentOrderIndex: progressState.completedOrders + 1,
totalOrders: progressState.totalOrders,
currentMaterialIndex: 0,
totalMaterialsInOrder: detailCount,
currentOrderNumber: orderNumber
}
)
if (detailStatus === '审批通过' && detailCount > 0) {
await detailInnerFrame.getByRole('button', { name: '修改' }).click()
const saveButtonLocator = detailInnerFrame.getByRole('button', { name: '保存' })
await saveButtonLocator.waitFor({ state: 'visible', timeout: 30000 })
await detailInnerFrame.getByText('展开').first().click()
const childForm = detailInnerFrame.locator('.card-table-side-box')
const buttonWrapper = childForm.locator('.button-wrapper')
const deleteRowBtn = buttonWrapper.getByRole('button', { name: '删行' })
const nextBtn = buttonWrapper.locator('.icon-jiantouyou')
const collapseBtn = buttonWrapper.locator('.icon-celashouqi')
let lastRowNumber = ''
let materialIdx = 0
while (true) {
materialIdx += 1
const currentRow = await this.getInputValue(childForm, /^行号$/)
const rowNumInt = parseInt(currentRow, 10)
if (currentRow === lastRowNumber) {
await this.delay(500)
}
const materialCode = await this.getInputValue(childForm, /^材料编码/)
const materialName = await this.getInputValue(childForm, /^材料名称/)
const pendingQty = await this.getInputValue(childForm, /^累计待发数量$/)
const progress = this.calculateProgress(
progressState.completedOrders,
materialIdx,
detailCount,
progressState.totalOrders
)
onProgress?.(
`订单 ${orderNumber} - 物料 ${materialIdx}/${detailCount}: ${materialName}`,
progress,
{
currentOrderIndex: progressState.completedOrders + 1,
totalOrders: progressState.totalOrders,
currentMaterialIndex: materialIdx,
totalMaterialsInOrder: detailCount,
currentOrderNumber: orderNumber
}
)
if (deleteSet.has(materialCode)) {
const shouldDelete = this.shouldDeleteMaterial({
rowNumber: rowNumInt,
pendingQty,
materialCode,
deleteSet
})
if (shouldDelete && !dryRun) {
const oldRowNumber = currentRow
await deleteRowBtn.click()
const deleteSuccess = await this.waitForRowChange(childForm, oldRowNumber, 10000)
if (deleteSuccess) {
detail.materialsDeleted += 1
}
continue
}
if (!shouldDelete) {
detail.materialsSkipped += 1
const reason = this.getSkipReason({
rowNumber: rowNumInt,
pendingQty,
materialCode,
deleteSet
})
detail.skippedMaterials.push({
materialCode,
materialName,
rowNumber: rowNumInt,
reason
})
}
}
const isNextEnabled = await this.isButtonEnabled(nextBtn)
if (isNextEnabled) {
lastRowNumber = currentRow
await nextBtn.click()
} else {
break
}
}
await collapseBtn.click()
if (!dryRun && detail.materialsDeleted > 0) {
await saveButtonLocator.click()
await saveButtonLocator.waitFor({ state: 'hidden', timeout: 60000 })
}
}
return detail
} finally {
await detailPage.close()
}
}
private calculateProgress(
completedOrders: number,
materialIdx: number,
detailCount: number,
totalOrders: number
): number {
const materialRatio = detailCount > 0 ? materialIdx / detailCount : 0
return ((1 + completedOrders + materialRatio) / (1 + totalOrders)) * 100
}
private async extractSourceOrderNumber(frame: FrameLocator): Promise<string> {
try {
const sourceOrder = await frame
.locator('.vsourcebillcode .code-detail-link')
.first()
.innerText()
const match = sourceOrder.match(/SC\d{14}/)
return match ? match[0] : sourceOrder.trim()
} catch {
return ''
}
}
private isOrderNumber(value: string): boolean {
return /^SC\d{14}$/.test(value)
}
private createErrorDetail(orderNumber: string, message: string): OrderCleanDetail {
return {
orderNumber,
materialsDeleted: 0,
materialsSkipped: 0,
errors: [message],
skippedMaterials: [],
retryCount: 0,
retryAttempts: [],
retriedAt: undefined,
retrySuccess: false
}
}
private async waitForLoading(frame: FrameLocator): Promise<void> {
const loadingLocator = frame
.locator('div')
.filter({ hasText: ERP_LOCATORS.extractor.loadingText })
.nth(1)
try {
await loadingLocator.waitFor({ state: 'visible', timeout: 3000 })
await loadingLocator.waitFor({ state: 'hidden', timeout: 60000 })
} catch {
// Loading completed quickly or never appeared
}
}
private async getInputValue(
container: FrameLocator | Locator,
labelRegex: RegExp
): Promise<string> {
try {
return await container
.locator('div')
.filter({ hasText: labelRegex })
.locator('input')
.first()
.inputValue()
} catch {
return ''
}
}
private async isButtonEnabled(button: Locator): Promise<boolean> {
try {
return await button.isEnabled()
} catch {
return false
}
}
private async waitForRowChange(
childForm: FrameLocator | Locator,
oldRowNumber: string,
maxWaitMs: number
): Promise<boolean> {
const startTime = Date.now()
while (Date.now() - startTime < maxWaitMs) {
try {
const newRowNumber = await this.getInputValue(childForm, /^行号$/)
if (newRowNumber !== oldRowNumber) {
return true
}
await this.delay(200)
} catch {
await this.delay(200)
}
}
return false
}
private delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
private async retryFailedOrders(params: {
workFrame: FrameLocator
popupPage: Page
failedDetails: OrderCleanDetail[]
deleteSet: Set<string>
dryRun: boolean
onProgress?: (
message: string,
progress?: number,
extra?: Partial<import('../../types/cleaner.types').CleanerProgress>
) => void
}): Promise<RetryResult> {
const { workFrame, popupPage, failedDetails, deleteSet, dryRun, onProgress } = params
const result: RetryResult = {
retriedOrders: 0,
successfulRetries: 0,
updatedDetails: []
}
if (failedDetails.length === 0) {
return result
}
log.info('Starting retry for failed orders', { count: failedDetails.length })
const MAX_RETRIES = 2
for (let detailIndex = 0; detailIndex < failedDetails.length; detailIndex++) {
const failedDetail = failedDetails[detailIndex]
const orderNumber = failedDetail.orderNumber
const retryAttempts: import('../../types/cleaner.types').RetryAttempt[] = []
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
log.info(`Retrying order ${orderNumber} (attempt ${attempt}/${MAX_RETRIES})`)
await this.queryOrders(workFrame, [orderNumber])
await this.waitForLoading(workFrame)
const rows = workFrame.locator('tbody tr')
const rowCount = await rows.count()
if (rowCount === 0) {
throw new Error('订单重试查询无结果')
}
const detailPage = await this.openDetailPageFromCurrentQuery(workFrame, popupPage)
const retryDetail = await this.processDetailPage({
detailPage,
deleteSet,
dryRun,
expectedOrderNumber: orderNumber,
progressState: {
completedOrders: detailIndex,
totalOrders: failedDetails.length
},
onProgress: (message, progress, extra) => {
onProgress?.(
`[重试 ${attempt}/${MAX_RETRIES}] ${message}`,
progress,
extra ? { ...extra, phase: 'processing' as const } : undefined
)
}
})
result.successfulRetries += 1
result.updatedDetails.push({
...retryDetail,
retryCount: attempt,
retriedAt: Date.now(),
retrySuccess: true,
retryAttempts
})
result.retriedOrders += 1
break
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
log.warn(`Retry attempt ${attempt} failed for order ${orderNumber}: ${message}`)
retryAttempts.push({
attempt,
error: message,
timestamp: Date.now()
})
if (attempt === MAX_RETRIES) {
result.updatedDetails.push({
...failedDetail,
retryCount: MAX_RETRIES,
retryAttempts,
retriedAt: Date.now(),
retrySuccess: false
})
result.retriedOrders += 1
}
}
}
}
log.info('Retry process completed', {
retriedOrders: result.retriedOrders,
successfulRetries: result.successfulRetries
})
return result
}
}

View File

@@ -1,187 +0,0 @@
import { chromium } from 'playwright'
import type { ErpConfig, ErpSession } from '../../types/erp.types'
import { createLogger } from '../logger'
const log = createLogger('ErpAuthService')
// Timeout constants
const PAGE_LOAD_TIMEOUT = 10000
const LOGIN_RESULT_TIMEOUT = 15000
const FORCE_LOGIN_TIMEOUT = 5000
/**
* ERP Authentication Service
* Manages login session and browser lifecycle
*/
export class ErpAuthService {
private config: ErpConfig
private session: ErpSession | null = null
constructor(config: ErpConfig) {
this.config = config
}
/**
* Login to ERP system and establish session
*/
async login(): Promise<ErpSession> {
if (this.session?.isLoggedIn) {
return this.session
}
// Launch browser with SSL certificate errors ignored
const browser = await chromium.launch({
headless: this.config.headless ?? false, // Use config or default to false
slowMo: 100, // Slow down for debugging
args: [
'--ignore-certificate-errors',
'--ignore-ssl-errors',
'--ignore-certificate-errors-spki-list',
'--disable-web-security' // Disable web security for internal VPN
]
})
const context = await browser.newContext({
acceptDownloads: true,
viewport: { width: 1920, height: 1080 },
ignoreHTTPSErrors: true, // Ignore SSL certificate errors
// Disable web security for internal VPN
javaScriptEnabled: true
})
const page = await context.newPage()
// Navigate to login page (use actual login URL from Python code)
const loginUrl = `${this.config.url}/yonbip/resources/uap/rbac/login/main/index.html`
await page.goto(loginUrl)
// Wait for page to load
await page.waitForLoadState('domcontentloaded', { timeout: PAGE_LOAD_TIMEOUT })
// Wait for iframe to be present
await page.waitForSelector('#forwardFrame', {
state: 'attached',
timeout: LOGIN_RESULT_TIMEOUT
})
// Extract forwardFrame (Python: main_frame = page.locator("#forwardFrame").content_frame)
// This is the main working frame for all subsequent operations
const frameLocator = page.locator('#forwardFrame')
const contentFrame = await frameLocator.contentFrame()
if (!contentFrame) {
throw new Error('Failed to access forwardFrame content frame')
}
// Store reference to main frame for later use (Python returns this as main_frame)
const mainFrame = contentFrame
// Fill username using role-based locator (Python: get_by_role("textbox", name="用户名"))
try {
await contentFrame.getByRole('textbox', { name: '用户名' }).fill(this.config.username)
} catch (e) {
throw new Error(`Failed to find username input: ${e}`)
}
// Fill password using role-based locator (Python: get_by_role("textbox", name="密码"))
try {
await contentFrame.getByRole('textbox', { name: '密码' }).fill(this.config.password)
} catch (e) {
throw new Error(`Failed to find password input: ${e}`)
}
// Click login button using role-based locator (Python: get_by_role("button", name="登录"))
try {
await contentFrame.getByRole('button', { name: '登录' }).click()
} catch (e) {
throw new Error(`Failed to click login button: ${e}`)
}
await page.waitForLoadState('domcontentloaded', { timeout: PAGE_LOAD_TIMEOUT }).catch(() => {
log.warn('Page load state check timed out, continuing')
})
await this.waitForLoginResult(mainFrame as unknown as import('playwright').Frame)
// Create session with mainFrame (Python returns main_frame as part of login result)
this.session = {
browser,
context,
page,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mainFrame: mainFrame as any, // Store forwardFrame content frame for subsequent operations
isLoggedIn: true
}
return this.session
}
/**
* Wait for login result: success, failure, or force login confirmation
*/
private async waitForLoginResult(mainFrame: import('playwright').Frame): Promise<void> {
const successLocator = mainFrame.locator('.nc-workbench-icon')
const errorLocator = mainFrame.getByText('名称或密码错误')
const forceLoginButton = mainFrame.getByRole('button', { name: '确定' })
try {
await Promise.race([
successLocator.waitFor({ state: 'visible', timeout: LOGIN_RESULT_TIMEOUT }),
errorLocator.waitFor({ state: 'visible', timeout: LOGIN_RESULT_TIMEOUT }),
forceLoginButton
.waitFor({ state: 'visible', timeout: FORCE_LOGIN_TIMEOUT })
.then(async () => {
log.info('Force login dialog detected, clicking confirm')
await forceLoginButton.click()
await this.waitForLoginResult(mainFrame)
})
])
const hasError = await errorLocator.isVisible()
if (hasError) {
throw new Error('ERP 登录失败:名称或密码错误')
}
log.info('Login successful')
} catch (error) {
if (error instanceof Error && error.message.includes('名称或密码错误')) {
throw error
}
const hasError = await errorLocator.isVisible().catch(() => false)
if (hasError) {
throw new Error('ERP 登录失败:名称或密码错误')
}
log.info('Login successful')
}
}
/**
* Close browser and cleanup session
*/
async close(): Promise<void> {
if (this.session) {
await this.session.context.close()
await this.session.browser.close()
this.session = null
}
}
/**
* Get current session (must be logged in first)
*/
getSession(): ErpSession {
if (!this.session?.isLoggedIn) {
throw new Error('Not logged in. Call login() first.')
}
return this.session
}
/**
* Check if session is active
*/
isActive(): boolean {
return this.session?.isLoggedIn ?? false
}
}

View File

@@ -1,216 +0,0 @@
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
}
}

View File

@@ -1,357 +0,0 @@
import path from 'path'
import fs from 'fs/promises'
import { ExtractorCore } from './extractor-core'
import { ErpAuthService } from './erp-auth'
import { ExcelParser } from '../excel/excel-parser'
import type {
ExtractorInput,
ExtractorResult,
ImportResult,
LogLevel
} from '../../types/extractor.types'
import { DataImportService } from '../database/data-importer'
import { createLogger } from '../logger'
const log = createLogger('ExtractorService')
/**
* ERP Data Extractor Service
* Downloads material plan data for given order numbers
*
* This service orchestrates the extraction process:
* - Uses ExtractorCore for web page operations
* - Handles file merging and cleanup
*
* Reference: playwrite/utils/discrete_material_plan_extractor.py
*/
export class ExtractorService {
private authService: ErpAuthService
private downloadDir: string
constructor(authService: ErpAuthService, downloadDir = './downloads') {
this.authService = authService
this.downloadDir = downloadDir
// Ensure download directory exists
fs.mkdir(downloadDir, { recursive: true }).catch(() => {})
}
/**
* Extract data for given order numbers
* Orchestrates the extraction process by delegating web operations to ExtractorCore
* and handling file merging/cleanup
*/
async extract(input: ExtractorInput): Promise<ExtractorResult> {
const result: ExtractorResult = {
downloadedFiles: [],
mergedFile: null,
recordCount: 0,
errors: []
}
try {
const session = this.authService.getSession()
// Call ExtractorCore to execute web page operations
const core = new ExtractorCore()
const coreResult = await core.downloadAllBatches({
session,
orderNumbers: input.orderNumbers,
downloadDir: this.downloadDir,
batchSize: input.batchSize || 100,
onProgress: input.onProgress
})
result.downloadedFiles = coreResult.downloadedFiles
result.errors = coreResult.errors
// Merge downloaded files (original logic preserved)
if (result.downloadedFiles.length > 0) {
const totalBatches = result.downloadedFiles.length
const totalPoints = 1 + totalBatches + 2
const progressPerPoint = 100 / totalPoints
const mergeProgress = (1 + totalBatches) * progressPerPoint
input.onProgress?.('正在合并文件...', mergeProgress, {
phase: 'merging',
totalBatches
})
const mergeResult = await this.mergeFiles(result.downloadedFiles)
result.mergedFile = mergeResult.mergedFile
result.recordCount = mergeResult.recordCount
// Add merge error to result if any
if (mergeResult.error) {
result.errors.push(mergeResult.error)
}
// Always clean up temporary files regardless of merge success
await this.cleanupTempFiles(result.downloadedFiles)
// Auto-import to database if merge was successful
if (result.mergedFile) {
const importProgress = (1 + totalBatches + 1) * progressPerPoint
input.onProgress?.('正在写入数据库...', importProgress, {
phase: 'importing',
totalBatches
})
const importResult = await this.importToDatabaseWithLogging(
result.mergedFile,
input.onLog
)
result.importResult = importResult
if (!importResult.success && importResult.errors.length > 0) {
result.errors.push(...importResult.errors)
}
}
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
result.errors.push(`Extraction failed: ${message}`)
}
return result
}
/**
* Merge downloaded Excel files into a single file
* Uses ExcelParser to parse and combine all material plans
*
* @param filePaths - Array of downloaded Excel file paths
* @returns Merged file path, total record count, and optional error message
*/
private async mergeFiles(
filePaths: string[]
): Promise<{ mergedFile: string | null; recordCount: number; error?: string }> {
if (filePaths.length === 0) {
return { mergedFile: null, recordCount: 0 }
}
log.info('Starting merge', { fileCount: filePaths.length })
const parser = new ExcelParser()
// Collect all orders with full order info and materials
// Each order has: { orderInfo: OrderHeader, materials: MaterialRow[] }
const allOrders: Array<{ orderInfo: any; materials: any[] }> = []
// Parse each downloaded file and collect orders
for (const filePath of filePaths) {
try {
log.debug('Parsing file', { filePath })
await parser.parse(filePath)
// After parse(), the parser store orders internally as lastOrders
const orders = (parser as any).lastOrders
log.debug('File parsed', { filePath, orderCount: orders?.length || 0 })
if (orders && Array.isArray(orders)) {
allOrders.push(...orders)
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error)
log.error('Failed to parse file', { filePath, error: errorMsg })
}
}
// Calculate total record count (total material rows)
let recordCount = 0
for (const order of allOrders) {
recordCount += order.materials.length
}
log.info('Merge summary', { orderCount: allOrders.length, recordCount })
if (recordCount === 0) {
log.warn('No records found in any downloaded files')
return { mergedFile: null, recordCount: 0 }
}
// Generate output filename with timestamp
const timestamp = new Date()
.toISOString()
.replace(/[-:T]/g, '')
.replace(/\..+/, '')
.slice(0, 14)
const outputPath = path.join(this.downloadDir, `merged_${timestamp}.xlsx`)
// Save with error handling
try {
log.info('Saving merged file', { outputPath })
await this.saveMergedOrders(allOrders, outputPath)
log.info('Merged file saved successfully', { recordCount })
return { mergedFile: outputPath, recordCount }
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error)
const errorStack = error instanceof Error ? error.stack : ''
log.error('Failed to save merged file', { error: errorMsg, stack: errorStack })
// Return parsed record count and error info even if save fails
return { mergedFile: null, recordCount, error: `保存合并文件失败:${errorMsg}` }
}
}
/**
* Save merged orders to a new Excel file with full 31 columns
* Matches the output format of ExcelParser.saveAsExcel()
*/
private async saveMergedOrders(
orders: Array<{ orderInfo: any; materials: any[] }>,
outputPath: string
): Promise<void> {
log.debug('Loading ExcelJS')
const ExcelJSModule = await import('exceljs')
// Handle both ESM and CommonJS module formats
const ExcelJS = ExcelJSModule.default || ExcelJSModule
log.debug('ExcelJS loaded, creating workbook')
const workbook = new ExcelJS.Workbook()
const worksheet = workbook.addWorksheet('Data')
// Define all 31 columns matching ExcelParser.saveAsExcel output format
worksheet.columns = [
{ header: '工厂', key: 'factory', width: 25 },
{ header: '备料状态', key: 'materialStatus', width: 15 },
{ header: '备料计划单号', key: 'planNumber', width: 25 },
{ header: '来源单号', key: 'productionOrder', width: 20 },
{ header: '备料类型', key: 'materialType', width: 15 },
{ header: '产品编码', key: 'productCode', width: 15 },
{ header: '产品名称', key: 'productName', width: 30 },
{ header: '产品计划数量', key: 'productPlannedQuantity', width: 15 },
{ header: '产品单位', key: 'productUnit', width: 10 },
{ header: '用料部门', key: 'department', width: 15 },
{ header: '备注', key: 'remark', width: 20 },
{ header: '制单人', key: 'creator', width: 15 },
{ header: '制单日期', key: 'createDate', width: 15 },
{ header: '审批人', key: 'approver', width: 15 },
{ header: '审批日期', key: 'approveDate', width: 15 },
{ header: '序号', key: 'sequence', width: 10 },
{ header: '材料编码', key: 'materialCode', width: 15 },
{ header: '材料名称', key: 'materialName', width: 30 },
{ header: '规格', key: 'specification', width: 30 },
{ header: '型号', key: 'model', width: 20 },
{ header: '图号', key: 'drawingNumber', width: 20 },
{ header: '物料材质', key: 'material', width: 15 },
{ header: '计划数量', key: 'quantity', width: 12 },
{ header: '单位', key: 'unit', width: 10 },
{ header: '需用日期', key: 'requiredDate', width: 15 },
{ header: '发料仓库', key: 'warehouse', width: 15 },
{ header: '单位用量', key: 'unitUsage', width: 12 },
{ header: '累计出库数量', key: 'cumulativeOutboundQty', width: 15 },
{ header: '打印人', key: 'printer', width: 15 },
{ header: '打印日期', key: 'printDate', width: 20 }
]
log.debug('Adding orders to worksheet', { orderCount: orders.length })
// Add data rows - merge orderInfo with each material
for (const order of orders) {
const { orderInfo, materials } = order
for (const material of materials) {
worksheet.addRow({
// Order info (first 15 columns)
factory: orderInfo.factory || '',
materialStatus: orderInfo.materialStatus || '',
planNumber: orderInfo.planNumber || '',
productionOrder: orderInfo.productionOrder || '',
materialType: orderInfo.materialType || '',
productCode: orderInfo.productCode || '',
productName: orderInfo.productName || '',
productPlannedQuantity: orderInfo.plannedQuantity || '',
productUnit: orderInfo.unit || '',
department: orderInfo.department || '',
remark: orderInfo.remark || '',
creator: orderInfo.creator || '',
createDate: orderInfo.createDate || '',
approver: orderInfo.approver || '',
approveDate: orderInfo.approveDate || '',
// Material data (columns 16-28)
sequence: material.sequence || '',
materialCode: material.materialCode || '',
materialName: material.materialName || '',
specification: material.specification || '',
model: material.model || '',
drawingNumber: material.drawingNumber || '',
material: material.material || '',
quantity: material.quantity || 0,
unit: material.unit || '',
requiredDate: material.requiredDate || '',
warehouse: material.warehouse || '',
unitUsage: material.unitUsage || 0,
cumulativeOutboundQty: material.cumulativeOutboundQty || 0,
// Footer info (last 2 columns)
printer: orderInfo.printer || '',
printDate: orderInfo.printDate || ''
})
}
}
log.debug('Writing file', { outputPath })
await workbook.xlsx.writeFile(outputPath)
log.debug('File saved successfully', { outputPath })
}
/**
* Clean up temporary batch files after merging
* @param filePaths - Array of temporary file paths to delete
*/
private async cleanupTempFiles(filePaths: string[]): Promise<void> {
for (const filePath of filePaths) {
try {
await fs.unlink(filePath)
log.debug('Deleted temporary file', { filePath })
} catch (error) {
// Log error but don't fail the main process
log.error('Failed to delete temporary file', { filePath, error })
}
}
}
/**
* Import merged Excel data to database with logging
* @param filePath - Path to the merged Excel file
* @param onLog - Optional log callback
* @returns Import result with statistics
*/
private async importToDatabaseWithLogging(
filePath: string,
onLog?: (level: LogLevel, message: string) => void
): Promise<ImportResult> {
log.info('Starting database import', { filePath })
onLog?.('info', `开始导入数据到数据库...`)
const importService = new DataImportService()
try {
const result = await importService.importFromExcel(filePath, 1000)
log.info('Import completed', {
success: result.success,
recordsRead: result.recordsRead,
recordsDeleted: result.recordsDeleted,
recordsImported: result.recordsImported
})
if (result.success) {
onLog?.(
'success',
`导入完成:读取 ${result.recordsRead} 条,删除 ${result.recordsDeleted} 条,导入 ${result.recordsImported}`
)
} else if (result.errors.length > 0) {
result.errors.forEach((err) => onLog?.('error', err))
}
return result
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error)
log.error('Import failed', { error: errorMsg })
onLog?.('error', `导入失败:${errorMsg}`)
return {
success: false,
recordsRead: 0,
recordsDeleted: 0,
recordsImported: 0,
uniqueSourceNumbers: 0,
errors: [errorMsg]
}
}
}
}

View File

@@ -1,79 +0,0 @@
/**
* ERP Page Element Locators
* Reference: playwrite/utils/ discrete_material_plan_extractor.py
* Reference: playwrite/utils/ discrete_material_plan_cleaner.py
*/
export const ERP_LOCATORS = {
// Login Page
login: {
usernameInput: '#username',
passwordInput: '#password',
submitButton: 'button[type="submit"]'
},
// Main Frame
// Reference: Nested iframe structure from Python code
main: {
// Main iframe on the page
mainIframe: '#mainiframe',
// Forward frame (nested inside main)
forwardFrame: '#forwardFrame',
// Inner iframe (inside forward frame)
innerIframe: '#mainiframe',
// Loading overlay text
loadingText: '加载中'
},
// Extractor (Data Export) Page
// Reference: playwrite/utils/discrete_material_plan_extractor.py
extractor: {
// Textbox by role: get_by_role("textbox", name="来源生产订单号")
orderNumberInputRole: '来源生产订单号',
// Search button: .search-component-searchBtn
queryButton: '.search-component-searchBtn',
// Loading indicator: div with text "加载中"
loadingText: '加载中',
// First row selector (序号 row)
firstRowSelector: 'internal:role=row[name=/序号/i]',
// More button
moreButton: 'internal:has-text="更多"',
// Export button (输出)
exportButton: 'internal:has-text="输出"',
// Export dialog - threshold input
thresholdInputSelector: 'div:has-text(/^行数阈值$/) input[type="text"]',
// Confirm button
confirmButton: 'internal:has-text="确定(Y)"'
},
// Menu navigation
menu: {
// Search icon in search wrapper
searchIcon: '.search-name-wrapper .iconfont',
// Order number query menu item
orderQuery: 'internal:has-text="订单号查询"',
// "All" tab
allTab: 'internal:role=tab[name="全部"]',
// Select input for setting limits
selectInput: '#rc_select_0'
},
// Discrete material plan menu item
discreteMaterialPlan: 'internal:has-title="离散备料计划维护"',
// Cleaner (Material Delete) Page
cleaner: {
orderNumberInput: 'input[name="orderNumber"]',
materialGrid: 'table.material-grid tbody tr',
saveButton: 'button:has-text("保存")'
},
// Common Elements
common: {
successMessage: '.message.success',
errorMessage: '.message.error',
confirmDialog: '.confirm-dialog',
confirmButton: 'button:has-text("确定")',
cancelButton: 'button:has-text("取消")'
}
}

View File

@@ -1,387 +0,0 @@
/**
* Order Number Resolver Service
*
* Automatically recognizes productionID and 生产订单号 (production order number),
* and converts them via database lookup.
*
* - productionID format: 2 digits + 1 letter + serial number (e.g., "22A1", "22A1234")
* - 生产订单号 format: SC + 14 digits (e.g., "SC70202602120085")
*
* Database table and field names are loaded from config.yaml
*/
import type { IDatabaseService } from '../database'
import { ConfigManager } from '../config/config-manager'
import { createLogger } from '../logger'
const log = createLogger('OrderResolver')
/**
* Order mapping result
*/
export interface OrderMapping {
/** Original input from user */
input: string
/** Recognized productionID (if input matches productionID pattern) */
productionId?: string
/** Final production order number to use */
orderNumber?: string
/** Whether the order number was successfully resolved */
resolved: boolean
/** Error message if resolution failed */
error?: string
}
/**
* Order number type recognition result
*/
export type OrderNumberType = 'productionId' | 'orderNumber' | 'unknown'
/**
* Resolution statistics
*/
export interface ResolutionStats {
totalInputs: number
validOrderNumbers: number
validProductionIds: number
resolvedCount: number
failedCount: number
unknownFormat: number
}
/**
* ProductionID pattern: 2 digits + 1 letter + 1-6 digits
* Examples: 22A1, 22A123, 26B10617
*/
const PRODUCTION_ID_PATTERN = /^\d{2}[A-Z]\d{1,6}$/i
/**
* Production order number pattern: SC + 14 digits
*/
const ORDER_NUMBER_PATTERN = /^SC\d{14}$/i
/**
* Database table and field names
* Loaded from config.yaml via ConfigManager
*/
export function getDbConfig() {
const configManager = ConfigManager.getInstance()
const config = configManager.getConfig()
return {
TABLE_NAME: config.orderResolution.tableName || 'productionContractData_26 年压力表合同数据',
FIELD_PRODUCTION_ID: config.orderResolution.productionIdField || '总排号',
FIELD_ORDER_NUMBER: config.orderResolution.orderNumberField || '生产订单号'
}
}
/**
* Order Number Resolver Service
*/
export class OrderNumberResolver {
private dbService: IDatabaseService
constructor(dbService: IDatabaseService) {
this.dbService = dbService
}
/**
* Get table name based on database type
* Converts MySQL schema_tablename format to SQL Server [schema].[tablename] format
* e.g., productionContractData_26年压力表合同数据 -> [productionContractData].[26年压力表合同数据]
* dbo_MaterialsToBeDeleted -> [dbo].[MaterialsToBeDeleted]
*/
private getTableName(tableName: string): string {
if (this.dbService.type === 'sqlserver') {
// Find the FIRST underscore to split schema and table name
// This handles patterns like: schema_tablename
const firstUnderscoreIndex = tableName.indexOf('_')
if (firstUnderscoreIndex > 0) {
const schema = tableName.substring(0, firstUnderscoreIndex)
const actualTableName = tableName.substring(firstUnderscoreIndex + 1)
return `[${schema}].[${actualTableName}]`
}
// If no underscore found, default to dbo schema
return `[dbo].[${tableName}]`
}
return tableName
}
/**
* Check if input matches productionID pattern
*/
isProductionId(input: string): boolean {
return PRODUCTION_ID_PATTERN.test(input)
}
/**
* Check if input matches order number pattern
*/
isOrderNumber(input: string): boolean {
return ORDER_NUMBER_PATTERN.test(input)
}
/**
* Map productionID to order number via database lookup
*/
async mapProductionIdToOrderNumber(productionId: string): Promise<string | null> {
try {
const dbConfig = getDbConfig()
const tableName = this.getTableName(dbConfig.TABLE_NAME)
let sql: string
let params: any[]
if (this.dbService.type === 'sqlserver') {
// 使用 COLLATE 指定不区分大小写的排序规则
sql = `SELECT TOP 1 [${dbConfig.FIELD_ORDER_NUMBER}] FROM ${tableName} WHERE [${dbConfig.FIELD_PRODUCTION_ID}] COLLATE SQL_Latin1_General_CP1_CI_AS = @p0`
params = [productionId]
} else {
// MySQL 默认不区分大小写,但显式使用 UPPER 确保一致性
sql = `SELECT \`${dbConfig.FIELD_ORDER_NUMBER}\` FROM \`${tableName}\` WHERE UPPER(\`${dbConfig.FIELD_PRODUCTION_ID}\`) = UPPER(?) LIMIT 1`
params = [productionId]
}
const result = await this.dbService.query(sql, params)
if (result.rows.length > 0) {
const orderNumber = result.rows[0][Object.keys(result.rows[0])[0]] as string
return orderNumber || null
}
return null
} catch (error) {
const message = error instanceof Error ? error.message : '未知数据库错误'
log.error('Failed to map productionID to order number', {
productionId,
error: message
})
throw error
}
}
/**
* Map multiple productionIds to order numbers
*/
async mapProductionIdsToOrderNumbers(productionIds: string[]): Promise<Map<string, string>> {
try {
const dbConfig = getDbConfig()
const tableName = this.getTableName(dbConfig.TABLE_NAME)
if (productionIds.length === 0) {
return new Map()
}
// P1: Deduplicate input productionIds to avoid redundant queries
const uniqueProductionIds = [...new Set(productionIds)]
// Use parameterized query to prevent SQL injection
const placeholders = uniqueProductionIds.map((_, i) => `@p${i}`).join(', ')
const params = uniqueProductionIds
let sql: string
if (this.dbService.type === 'sqlserver') {
// P0: Use DISTINCT to prevent duplicates from one-to-many relationships
// 使用 COLLATE 指定不区分大小写的排序规则
sql = `SELECT DISTINCT [${dbConfig.FIELD_PRODUCTION_ID}], [${dbConfig.FIELD_ORDER_NUMBER}] FROM ${tableName} WHERE [${dbConfig.FIELD_PRODUCTION_ID}] COLLATE SQL_Latin1_General_CP1_CI_AS IN (${placeholders})`
} else {
const idPlaceholders = uniqueProductionIds.map(() => 'UPPER(?)').join(', ')
// P0: Use DISTINCT to prevent duplicates from one-to-many relationships
// MySQL: 使用 UPPER 确保不区分大小写
sql = `SELECT DISTINCT \`${dbConfig.FIELD_PRODUCTION_ID}\`, \`${dbConfig.FIELD_ORDER_NUMBER}\` FROM \`${tableName}\` WHERE UPPER(\`${dbConfig.FIELD_PRODUCTION_ID}\`) IN (${idPlaceholders})`
}
const result = await this.dbService.query(sql, params)
const mappings = new Map<string, string>()
for (const row of result.rows) {
const keys = Object.keys(row)
const prodId = row[keys[0]] as string
const orderNum = row[keys[1]] as string
if (prodId && orderNum) {
mappings.set(prodId, orderNum)
}
}
return mappings
} catch (error) {
const message = error instanceof Error ? error.message : '未知数据库错误'
log.error('Failed to map productionIds to order numbers', {
error: message
})
throw error
}
}
/**
* Resolve order numbers from mixed input
*
* Optimized for batch processing with deduplication:
* - Multiple productionIDs mapping to the same order number are treated as valid (not errors)
* - Returns all mappings with duplicate tracking
*/
async resolve(inputs: string[]): Promise<OrderMapping[]> {
// P1: Deduplicate inputs at the input layer to avoid redundant queries
const uniqueInputs = [...new Set(inputs)]
// Separate productionIds and order numbers
const productionIds: string[] = []
const orderNumbers: string[] = []
for (const input of uniqueInputs) {
if (this.isOrderNumber(input)) {
orderNumbers.push(input)
} else if (this.isProductionId(input)) {
productionIds.push(input)
}
}
// Batch query productionId to order number mappings
// 使用小写 key 存储映射,以支持忽略大小写查找
const mappings = new Map<string, string>()
if (productionIds.length > 0) {
const batchMappings = await this.mapProductionIdsToOrderNumbers(productionIds)
batchMappings.forEach((orderNum, prodId) => {
mappings.set(prodId.toLowerCase(), orderNum)
})
}
// Build results while preserving original input order
// Note: Multiple productionIDs mapping to the same order number is VALID (not an error)
const results: OrderMapping[] = []
for (const input of inputs) {
// Skip if this exact input was already processed
const alreadyProcessed = results.some((r) => r.input === input)
if (alreadyProcessed) {
continue
}
const mapping: OrderMapping = { input, resolved: false }
if (this.isOrderNumber(input)) {
// Already an order number
mapping.orderNumber = input
mapping.resolved = true
} else if (this.isProductionId(input)) {
// Is a productionID, lookup from batch mappings (使用小写查找以忽略大小写)
mapping.productionId = input
const orderNumber = mappings.get(input.toLowerCase())
if (orderNumber) {
mapping.orderNumber = orderNumber
mapping.resolved = true
} else {
mapping.error = '未在数据库中找到对应的订单号'
}
} else {
mapping.error = '格式不识别:既不是有效的生产订单号也不是总排号格式'
}
results.push(mapping)
}
return results
}
/**
* Get valid order numbers from mappings
* P2: Returns deduplicated order numbers
*/
getValidOrderNumbers(mappings: OrderMapping[]): string[] {
const validNumbers = mappings
.filter((m) => m.resolved && m.orderNumber)
.map((m) => m.orderNumber!)
// P2: Deduplicate before returning
return [...new Set(validNumbers)]
}
/**
* Get warnings from failed mappings
*/
getWarnings(mappings: OrderMapping[]): string[] {
return mappings.filter((m) => !m.resolved && m.error).map((m) => `${m.input}: ${m.error}`)
}
/**
* Recognize the type of input
*/
recognizeType(input: string): OrderNumberType {
if (this.isOrderNumber(input)) return 'orderNumber'
if (this.isProductionId(input)) return 'productionId'
return 'unknown'
}
/**
* Get resolution statistics
*/
getStats(mappings: OrderMapping[]): ResolutionStats {
const stats: ResolutionStats = {
totalInputs: mappings.length,
validOrderNumbers: 0,
validProductionIds: 0,
resolvedCount: 0,
failedCount: 0,
unknownFormat: 0
}
for (const mapping of mappings) {
if (mapping.resolved) {
stats.resolvedCount++
if (mapping.orderNumber && !mapping.productionId) {
stats.validOrderNumbers++
} else if (mapping.productionId) {
stats.validProductionIds++
}
} else {
stats.failedCount++
if (!mapping.productionId && !mapping.orderNumber) {
stats.unknownFormat++
}
}
}
return stats
}
/**
* Get deduplication summary for logging
* Returns a human-readable report showing:
* - Input count
* - Unique order numbers count
* - Mapping details (which productionIDs map to which order numbers)
*/
getDeduplicationReport(mappings: OrderMapping[]): {
inputCount: number
uniqueOrderNumbersCount: number
orderNumberGroups: Map<string, string[]>
summary: string
} {
// Group productionIDs by their resolved order number
const orderNumberGroups = new Map<string, string[]>()
for (const mapping of mappings) {
if (mapping.resolved && mapping.orderNumber) {
const existing = orderNumberGroups.get(mapping.orderNumber) || []
existing.push(mapping.input)
orderNumberGroups.set(mapping.orderNumber, existing)
}
}
const inputCount = mappings.length
const uniqueOrderNumbersCount = orderNumberGroups.size
// Build summary string
let summary = `输入 ${inputCount} 个总排号 → 解析为 ${uniqueOrderNumbersCount} 个唯一订单号`
if (inputCount > uniqueOrderNumbersCount) {
const duplicateCount = inputCount - uniqueOrderNumbersCount
summary += `${duplicateCount} 个重复已合并)`
}
return {
inputCount,
uniqueOrderNumbersCount,
orderNumberGroups,
summary
}
}
}

View File

@@ -1,559 +0,0 @@
import ExcelJS from 'exceljs'
import type { DiscreteMaterialPlan, ExcelParseOptions, OrderHeader } from '../../types/excel.types'
import { createLogger } from '../logger'
const log = createLogger('ExcelParser')
/**
* Excel Parser Service
* Parses exported ERP Excel files into structured data
*
* Reference: playwrite/utils/excel_converter.py
*
* Excel Structure:
* - Multiple orders per file (each starting with "离散备料计划")
* - Each order has: header info (4 lines) + table header + data rows + footer
* - Material rows have 13 columns from "序号" to "累计出库数量"
*/
export class ExcelParser {
// Field name mapping for Python compatibility (from Python code)
private FIELD_NAME_MAPPING: Record<string, string> = {
: '产品计划数量',
: '产品单位'
}
// Mapping from Chinese field names to English property names
private CHINESE_TO_ENGLISH_MAPPING: Record<string, string> = {
// Header fields (row 2-4)
: 'factory',
: 'materialStatus',
: 'planNumber',
: 'materialType',
: 'productionDepartment',
: 'productionOrder',
: 'productionOrder', // This is the order number we need!
: 'productCode',
: 'productName',
: 'productSpecification',
: 'plannedQuantity',
: 'unit',
: 'department',
: 'remark',
: 'requiredDate',
// Footer fields (row 14-15)
: 'creator',
: 'createDate',
: 'approver',
: 'approveDate',
: 'printer',
: 'printDate',
// Mapped fields (after FIELD_NAME_MAPPING)
: 'plannedQuantity',
: 'unit'
}
/**
* Parse Excel file and extract material plans
*/
async parse(filePath: string, options: ExcelParseOptions = {}): Promise<DiscreteMaterialPlan[]> {
log.debug('Parsing Excel file:', filePath)
const workbook = new ExcelJS.Workbook()
await workbook.xlsx.readFile(filePath)
const worksheet = workbook.worksheets[0]
if (!worksheet) {
throw new Error('No worksheet found in file')
}
const plans: DiscreteMaterialPlan[] = []
const allRows: any[][] = []
// Read all rows into memory
worksheet.eachRow((row, _rowNumber) => {
allRows.push(row.values as any[])
})
log.debug(`Total rows read: ${allRows.length} (worksheet has ${worksheet.rowCount} rows)`)
// Parse orders from rows
const orders = this.parseOrders(allRows)
// Store orders for potential Excel export
;(this as any).lastOrders = orders
// Flatten orders into material plans
for (const order of orders) {
const { orderInfo, materials } = order
// Skip empty orders if option is set
if (options.skipEmptyOrders && materials.length === 0) {
log.debug('Skipping empty order:', orderInfo.productionOrder)
continue
}
// Create a material plan for each material row
for (const material of materials) {
const plan: DiscreteMaterialPlan = {
orderNumber: orderInfo.productionOrder || '',
productionId: orderInfo.productCode || '',
materialCode: material.materialCode || '',
materialName: material.materialName || '',
specification: material.specification,
model: material.model,
drawingNumber: material.drawingNumber,
material: material.material,
quantity: material.quantity || 0,
unit: material.unit || '',
requiredDate: material.requiredDate,
warehouse: material.warehouse,
unitUsage: material.unitUsage,
cumulativeOutboundQty: material.cumulativeOutboundQty,
rowNumber: material.rowNumber
}
plans.push(plan)
}
}
log.debug(`Parsed ${plans.length} material plans from ${orders.length} orders`)
return plans
}
/**
* Save parsed orders to Excel file
* Compatible with Python excel_converter.py output format
* Uses the last parsed orders data
*
* @param outputPath - Output Excel file path
*/
async saveAsExcel(outputPath: string): Promise<void> {
const orders = (this as any).lastOrders
if (!orders) {
throw new Error('No parsed data available. Call parse() first.')
}
log.debug('Saving parsed data to Excel:', outputPath)
const workbook = new ExcelJS.Workbook()
const worksheet = workbook.addWorksheet('Data')
// Define columns matching Python excel_converter output format exactly
worksheet.columns = [
{ header: '工厂', key: 'factory', width: 25 },
{ header: '备料状态', key: 'materialStatus', width: 15 },
{ header: '备料计划单号', key: 'planNumber', width: 25 },
{ header: '来源单号', key: 'productionOrder', width: 20 },
{ header: '备料类型', key: 'materialType', width: 15 },
{ header: '产品编码', key: 'productCode', width: 15 },
{ header: '产品名称', key: 'productName', width: 30 },
{ header: '产品计划数量', key: 'productPlannedQuantity', width: 15 },
{ header: '产品单位', key: 'productUnit', width: 10 },
{ header: '用料部门', key: 'department', width: 15 },
{ header: '备注', key: 'remark', width: 20 },
{ header: '制单人', key: 'creator', width: 15 },
{ header: '制单日期', key: 'createDate', width: 15 },
{ header: '审批人', key: 'approver', width: 15 },
{ header: '审批日期', key: 'approveDate', width: 15 },
{ header: '序号', key: 'sequence', width: 10 },
{ header: '材料编码', key: 'materialCode', width: 15 },
{ header: '材料名称', key: 'materialName', width: 30 },
{ header: '规格', key: 'specification', width: 30 },
{ header: '型号', key: 'model', width: 20 },
{ header: '图号', key: 'drawingNumber', width: 20 },
{ header: '物料材质', key: 'material', width: 15 },
{ header: '计划数量', key: 'quantity', width: 12 },
{ header: '单位', key: 'unit', width: 10 },
{ header: '需用日期', key: 'requiredDate', width: 15 },
{ header: '发料仓库', key: 'warehouse', width: 15 },
{ header: '单位用量', key: 'unitUsage', width: 12 },
{ header: '累计出库数量', key: 'cumulativeOutboundQty', width: 15 },
{ header: '打印人', key: 'printer', width: 15 },
{ header: '打印日期', key: 'printDate', width: 20 }
]
// Add data rows - merge orderInfo with each material
for (const order of orders) {
const { orderInfo, materials } = order
for (const material of materials) {
worksheet.addRow({
// Order info (first 14 columns)
factory: orderInfo.factory || '',
materialStatus: orderInfo.materialStatus || '',
planNumber: orderInfo.planNumber || '',
productionOrder: orderInfo.productionOrder || '',
materialType: orderInfo.materialType || '',
productCode: orderInfo.productCode || '',
productName: orderInfo.productName || '',
productPlannedQuantity: orderInfo.plannedQuantity || '',
unit: orderInfo.unit || '',
department: orderInfo.department || '',
remark: orderInfo.remark || '',
creator: orderInfo.creator || '',
createDate: orderInfo.createDate || '',
approver: orderInfo.approver || '',
approveDate: orderInfo.approveDate || '',
// Material data (columns 15-28)
sequence: material.sequence || '',
materialCode: material.materialCode || '',
materialName: material.materialName || '',
specification: material.specification || '',
model: material.model || '',
drawingNumber: material.drawingNumber || '',
material: material.material || '',
quantity: material.quantity || 0,
requiredDate: material.requiredDate || '',
warehouse: material.warehouse || '',
unitUsage: material.unitUsage || 0,
cumulativeOutboundQty: material.cumulativeOutboundQty || 0,
// Footer info (last 2 columns)
printer: orderInfo.printer || '',
printDate: orderInfo.printDate || ''
})
}
}
// Save workbook
await workbook.xlsx.writeFile(outputPath)
log.debug(
`Excel file saved: ${outputPath} (${orders.length} orders, ${worksheet.rowCount - 1} data rows)`
)
}
/**
* Parse orders from all rows
* Reference: _parse_sheet() in Python code
*/
private parseOrders(allRows: any[][]): Array<{ orderInfo: OrderHeader; materials: any[] }> {
const orders: Array<{ orderInfo: OrderHeader; materials: any[] }> = []
let i = 0
while (i < allRows.length) {
const row = allRows[i]
// Check if this is an order title row
if (row && row[2] && String(row[2]).includes('离散备料计划')) {
// Parse order header info (next 4 rows)
const orderInfo: OrderHeader = {}
for (let j = 1; j <= 4; j++) {
if (i + j < allRows.length && allRows[i + j]) {
this.parseHeaderRow(allRows[i + j], orderInfo)
}
}
// Debug: check productionOrder extraction
log.debug(`Order ${orders.length + 1}: productionOrder="${orderInfo.productionOrder}"`)
// Find table header row dynamically (look for "序号" in index 1)
// Note: worksheet.eachRow() skips empty rows, so we can't use fixed offsets
let tableRow = i + 1
while (tableRow < allRows.length && allRows[tableRow] && allRows[tableRow][1] !== '序号') {
tableRow++
}
if (tableRow >= allRows.length || !allRows[tableRow]) {
log.debug(' ⚠️ Table header not found, skipping this order')
i++
continue
}
// Check if this is the table header row
// ExcelJS is 1-indexed: index 0=null, index 1=序号, index 2=材料编码
if (tableRow < allRows.length && allRows[tableRow] && allRows[tableRow][1] === '序号') {
// Check if next row is empty (no data)
const nextRow = tableRow + 1
const isEmptyRow =
nextRow < allRows.length &&
allRows[nextRow] &&
allRows[nextRow].every((cell: any) => cell === null || String(cell).trim() === '')
if (isEmptyRow) {
// No data, find footer info
log.debug('Order has no material data')
const materials: any[] = []
const footerInfo: OrderHeader = {}
let dataRow = nextRow + 1
while (dataRow < allRows.length && allRows[dataRow]) {
if (
allRows[dataRow][2] &&
(String(allRows[dataRow][2]).includes('制单人') ||
String(allRows[dataRow][2]).includes('打印人'))
) {
this.parseHeaderRow(allRows[dataRow], footerInfo)
if (dataRow + 1 < allRows.length && allRows[dataRow + 1]) {
this.parseHeaderRow(allRows[dataRow + 1], footerInfo)
}
break
}
dataRow++
}
orders.push({
orderInfo: { ...orderInfo, ...footerInfo },
materials
})
} else {
// Has data, extract materials
log.debug('Order has material data')
const materials: any[] = []
const footerInfo: OrderHeader = {}
let dataRow = tableRow + 1
while (dataRow < allRows.length && allRows[dataRow]) {
// Check if CURRENT row is footer info (制单人/打印人)
const isCurrentRowFooter =
allRows[dataRow][2] &&
(String(allRows[dataRow][2]).includes('制单人') ||
String(allRows[dataRow][2]).includes('打印人'))
// Check if NEXT row is footer info (to handle empty row before footer)
const isNextRowFooter =
dataRow + 1 < allRows.length &&
allRows[dataRow + 1] &&
allRows[dataRow + 1][2] &&
(String(allRows[dataRow + 1][2]).includes('制单人') ||
String(allRows[dataRow + 1][2]).includes('打印人'))
if (isCurrentRowFooter) {
// Current row is footer, parse it and next row if exists
this.parseHeaderRow(allRows[dataRow], footerInfo)
if (dataRow + 1 < allRows.length && allRows[dataRow + 1]) {
this.parseHeaderRow(allRows[dataRow + 1], footerInfo)
}
log.debug(' Found footer row, stopping material parsing')
break
}
if (isNextRowFooter) {
// Next row is footer, parse current row as material first
const material = this.parseMaterialRowInternal(allRows[dataRow], dataRow + 1)
if (material) {
log.debug(' Parsed material:', material.materialCode)
materials.push(material)
}
// Then parse footer rows
this.parseHeaderRow(allRows[dataRow + 1], footerInfo)
if (dataRow + 2 < allRows.length && allRows[dataRow + 2]) {
this.parseHeaderRow(allRows[dataRow + 2], footerInfo)
}
log.debug(' Found footer in next row, stopping material parsing')
break
}
// Extract material data
const material = this.parseMaterialRowInternal(allRows[dataRow], dataRow + 1)
if (material) {
//log.debug(' Parsed material:', material.materialCode)
materials.push(material)
} else {
log.debug(' Skipped material row at', dataRow + 1)
}
dataRow++
}
orders.push({
orderInfo: { ...orderInfo, ...footerInfo },
materials
})
}
} else {
log.debug(
` ⚠️ Table header check failed at row ${tableRow + 1}, value="${allRows[tableRow] ? allRows[tableRow][1] : 'null'}"`
)
}
// Move to next row after this order
i = tableRow + 1
} else {
i++
}
}
log.debug(`parseOrders: Returning ${orders.length} orders`)
return orders
}
/**
* Parse header row (field names and values interleaved)
* Reference: _parse_header_row() in Python code
*/
private parseHeaderRow(row: any[], info: OrderHeader): void {
let j = 0
while (j < row.length) {
const cell = row[j]
if (cell && String(cell).trim() && String(cell).includes('')) {
// Found field name
let fieldName = String(cell).replace('', '').trim()
// Apply field name mapping (from Python code)
if (fieldName in this.FIELD_NAME_MAPPING) {
fieldName = this.FIELD_NAME_MAPPING[fieldName]
}
// Map Chinese field name to English property name
const englishFieldName = this.CHINESE_TO_ENGLISH_MAPPING[fieldName] || fieldName
// Skip empty cells to find first non-field-name value
let k = j + 1
while (
k < row.length &&
(!row[k] || !String(row[k]).trim() || String(row[k]).includes(''))
) {
k++
}
if (k < row.length && row[k] && !String(row[k]).includes('')) {
info[englishFieldName as keyof OrderHeader] = String(row[k]).trim()
}
// Skip processed value, continue to next field name
j = k + 1
} else {
j++
}
}
}
/**
* Parse material data row
* Reference: material data extraction in Python code
* ExcelJS row.values arrays are 1-indexed:
* - Index 0: null
* - Index 1: 序号 (sequence)
* - Index 2: 材料编码 (materialCode)
* - Index 3: 材料名称 (materialName)
* - etc.
* NOTE: This is an internal method that returns raw data structure
*/
private parseMaterialRowInternal(row: any[], rowNumber: number): any | null {
// Extract 13 fields from material row (ExcelJS is 1-indexed, so data starts at index 1)
const material = {
sequence: row[1],
materialCode: row[2],
materialName: row[3],
specification: row[4],
model: row[5],
drawingNumber: row[6],
material: row[7],
quantity: this.parseFloat(row[8]),
unit: row[9],
requiredDate: row[10],
warehouse: row[11],
unitUsage: this.parseFloat(row[12]),
cumulativeOutboundQty: this.parseFloat(row[13]),
rowNumber
}
// Skip if no material code
if (!material.materialCode) {
return null
}
return material
}
/**
* Safely parse float from cell value
*/
private parseFloat(value: any): number | undefined {
if (value === null || value === undefined) {
return undefined
}
const parsed = parseFloat(String(value))
return isNaN(parsed) ? undefined : parsed
}
/**
* Check if row contains order information
* (Spec-compliant method for detecting order rows)
*
* @param values - Row values array from ExcelJS
* @returns true if row contains "离散备料计划" (order title)
*/
public isOrderRow(values: any[]): boolean {
// ExcelJS arrays are 1-indexed, check index 2 for order title
const firstCell = values[2]
return typeof firstCell === 'string' && firstCell.includes('离散备料计划')
}
/**
* Extract order number from row
* (Spec-compliant method for extracting order number)
*
* Parses a row containing order header information and extracts
* the production order number (生产订单).
*
* @param values - Row values array from ExcelJS
* @returns Order number (e.g., "SC202501001") or empty string
*/
public extractOrderNumber(values: any[]): string {
// Parse the row to extract order number using same logic as header parsing
const orderInfo: OrderHeader = {}
this.parseHeaderRow(values, orderInfo)
return orderInfo.productionOrder || ''
}
/**
* Parse material row
* (Spec-compliant method for parsing material data)
*
* @param values - Row values array from ExcelJS (1-indexed, index 0 is null)
* @param orderNumber - Order number for this material
* @param productionId - Production ID for this material
* @param rowNumber - Row number in Excel file
* @returns DiscreteMaterialPlan or null if invalid row
*/
public parseMaterialRow(
values: any[],
orderNumber: string,
productionId: string,
rowNumber: number
): DiscreteMaterialPlan | null {
// ExcelJS arrays are 1-indexed:
// Index 0: null
// Index 1: 序号
// Index 2: 材料编码
// Index 3: 材料名称
// Index 4: 规格
// etc.
const materialCode = values[2]?.toString().trim()
const materialName = values[3]?.toString().trim()
const specification = values[4]?.toString().trim()
const model = values[5]?.toString().trim()
const drawingNumber = values[6]?.toString().trim()
const material = values[7]?.toString().trim()
const quantity = this.parseFloat(values[8]) || 0
const unit = values[9]?.toString().trim() || ''
const requiredDate = values[10]?.toString().trim()
const warehouse = values[11]?.toString().trim()
const unitUsage = this.parseFloat(values[12])
const cumulativeOutboundQty = this.parseFloat(values[13])
// Skip if no material code
if (!materialCode) {
return null
}
return {
orderNumber,
productionId,
materialCode,
materialName,
specification,
model,
drawingNumber,
material,
quantity,
unit,
requiredDate,
warehouse,
unitUsage,
cumulativeOutboundQty,
rowNumber
}
}
}

Some files were not shown because too many files have changed in this diff Show More