16 Commits

Author SHA1 Message Date
Misaka
9349391834 Task 12: Create MaterialsToDeleteDAO with SQL injection protection
Implement MaterialsToDeleteDAO class with secure parameterized queries following
the pattern established in Task 11 (commit 5aafb23).

Features:
- getMaterialsToDeleteByManagers(): Query materials_to_delete table with
  optional filtering by manager_names using parameterized queries
- getAllMaterialsToDelete(): Wrapper to retrieve all materials without filtering

Security:
- Uses parameterized queries (@param0, @param1, etc.) to prevent SQL injection
- Parameters passed separately from query string via executeSQLServerQuery()
- Input validation for empty arrays
- Comprehensive test coverage including SQL injection attempt scenarios

Testing:
- 11 comprehensive unit tests covering all methods and edge cases
- Tests verify parameterized query pattern prevents SQL injection
- All tests passing (12/12 including existing tests)

Files:
- src/main/dao/materials-to-delete.dao.ts: DAO implementation
- tests/unit/dao/materials-to-delete.dao.test.ts: Unit tests

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 23:03:14 +08:00
Misaka
b4d270faf6 feat: implement production order DAO
- Add ProductionOrderDAO class for production order data access
- Implement queryProductionOrderNumbers() to query SQL Server for production order numbers
- Implement readProductionIds() to read production IDs from text files
- Includes proper error handling and logging

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 23:03:14 +08:00
Misaka
2ecd8db79a feat: implement production order DAO
- Add ProductionOrderDAO class for production order data access
- Implement queryProductionOrderNumbers() to query SQL Server for production order numbers
- Implement readProductionIds() to read production IDs from text files
- Includes proper error handling and logging

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 22:45:06 +08:00
Misaka
3acdbf4b1a feat: implement Excel converter service
- Add ExcelConverterService for processing Excel files
- Support Excel to JSON conversion with header extraction
- Implement merge functionality for multiple Excel files
- Use exceljs library for Excel file operations
- Integrate with LoggerService for operation tracking

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 22:43:47 +08:00
Misaka
00f4e40505 feat: implement authentication service
Add auth service that wraps Playwright service for user authentication.
Includes login/logout functionality and session management.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 22:40:44 +08:00
Misaka
cfc2fe7f7d feat: implement Playwright service for browser automation
- Add playwright types for Credentials, Session, and LoginResult
- Implement PlaywrightService with launchBrowser, login, closeBrowser, and newPage methods
- Support browser configuration (headless mode, slowMo, timeout)
- Integrate with LoggerService for comprehensive logging
- Handle HTTPS errors via ERP configuration

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 22:35:25 +08:00
Misaka
550309c2e7 chore: add Jest testing dependencies
- Add jest, @types/jest, and ts-jest for testing
- Add @types/mssql for TypeScript support

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 22:28:33 +08:00
Misaka
351c7857cb feat: implement database service with SQL Server and MySQL support
- Add database types (QueryResult, TransactionResult)
- Implement DatabaseService with connection management for SQL Server and MySQL
- Add query execution methods for both database types
- Include comprehensive error handling and logging
- Add unit test for error handling
- Configure Jest for testing

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 22:28:02 +08:00
Misaka
7cfbce7505 feat: setup IPC communication framework
- Add IPC types definition with all communication channels
- Implement preload script with API surface for renderer process
- Add IPC handler registration stub in main entry point

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 22:19:21 +08:00
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
39 changed files with 16452 additions and 11 deletions

18
.gitignore vendored
View File

@@ -6,4 +6,20 @@ out
*.log*
#AI Agent
.claude
.claude
# Logs
logs/
*.log
# Data
data/temp/*
data/output/*
data/reports/*
!data/temp/.gitkeep
!data/output/.gitkeep
!data/reports/.gitkeep
# Environment
config/.env
config/.env.local

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

8
jest.config.js Normal file
View File

@@ -0,0 +1,8 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/tests'],
testMatch: ['**/*.test.ts'],
collectCoverageFrom: ['src/**/*.ts', '!src/**/*.d.ts'],
moduleFileExtensions: ['ts', 'js', 'json'],
};

0
logs/.gitkeep Normal file
View File

15160
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -14,20 +14,29 @@
"start": "electron-vite preview",
"dev": "electron-vite dev",
"build": "npm run typecheck && electron-vite build",
"postinstall": "electron-builder install-app-deps",
"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"
"build:linux": "electron-vite build && electron-builder --linux",
"test": "jest",
"test:e2e": "playwright test"
},
"dependencies": {
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0"
"@electron-toolkit/utils": "^4.0.0",
"exceljs": "^4.4.0",
"mssql": "^12.2.0",
"mysql2": "^3.18.2",
"playwright": "^1.58.2",
"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",
"@types/jest": "^30.0.0",
"@types/mssql": "^9.1.9",
"@types/node": "^22.19.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
@@ -39,9 +48,11 @@
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"jest": "^30.2.0",
"prettier": "^3.7.4",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"ts-jest": "^29.4.6",
"typescript": "^5.9.3",
"vite": "^7.2.6"
}

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

@@ -0,0 +1,61 @@
import { DatabaseService } from '../services/database.service';
import { LoggerService } from '../services/logger.service';
export class MaterialsToDeleteDAO {
constructor(private dbService: DatabaseService) {}
/**
* Query materials_to_delete table and return distinct material names
* @param managerNames - Optional array of manager names to filter by. Pass null to get all materials.
* @returns Promise<string[]> - Array of distinct material names
*/
async getMaterialsToDeleteByManagers(
managerNames: string[] | null
): Promise<string[]> {
try {
// Input validation
if (managerNames && managerNames.length === 0) {
LoggerService.warn('Empty manager names array provided, returning empty result');
return [];
}
LoggerService.info(
`Querying materials_to_delete${managerNames ? ` for ${managerNames.length} managers` : ' (all managers)'}`
);
// Build base query
let query = `
SELECT DISTINCT material_name
FROM materials_to_delete
`;
let params: string[] | undefined;
// Add WHERE clause with parameterized query if manager names provided
// SECURITY: Use parameterized queries to prevent SQL injection
if (managerNames && managerNames.length > 0) {
const placeholders = managerNames.map((_, i) => `@param${i}`).join(',');
query += ` WHERE manager_name IN (${placeholders})`;
params = managerNames;
}
// Execute query with parameters (if any)
const result = await this.dbService.executeSQLServerQuery(query, params);
const materials = result.rows.map((row: any) => row.material_name);
LoggerService.info(`Found ${materials.length} materials to delete`);
return materials;
} catch (error) {
LoggerService.error('Failed to query materials_to_delete table', error);
throw error;
}
}
/**
* Get all materials from materials_to_delete table without filtering
* @returns Promise<string[]> - Array of all distinct material names
*/
async getAllMaterialsToDelete(): Promise<string[]> {
return this.getMaterialsToDeleteByManagers(null);
}
}

View File

@@ -0,0 +1,64 @@
import { DatabaseService } from '../services/database.service';
import { LoggerService } from '../services/logger.service';
export class ProductionOrderDAO {
constructor(private dbService: DatabaseService) {}
async queryProductionOrderNumbers(productionIds: string[]): Promise<string[]> {
try {
LoggerService.info(`Querying production orders for ${productionIds.length} IDs`);
// Validate input
if (productionIds.length === 0) {
LoggerService.warn('No production IDs provided, returning empty array');
return [];
}
// Use parameterized query to prevent SQL injection
const placeholders = productionIds.map((_, i) => `@param${i}`).join(',');
const query = `
SELECT DISTINCT production_order_no
FROM production_orders
WHERE production_id IN (${placeholders})
`;
const result = await this.dbService.executeSQLServerQuery(query, productionIds);
const orderNumbers = result.rows.map((row: any) => row.production_order_no);
LoggerService.info(`Found ${orderNumbers.length} production orders`);
return orderNumbers;
} catch (error) {
LoggerService.error('Failed to query production orders', error);
throw error;
}
}
async readProductionIds(filePath: string): Promise<string[]> {
try {
const fs = await import('fs/promises');
// Validate file existence
try {
await fs.access(filePath, fs.constants.R_OK);
} catch {
throw new Error(`Production IDs file not found: ${filePath}`);
}
const content = await fs.readFile(filePath, 'utf-8');
const ids = content
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0);
if (ids.length === 0) {
LoggerService.warn('Production IDs file is empty');
}
LoggerService.info(`Read ${ids.length} production IDs from file`);
return ids;
} catch (error) {
LoggerService.error('Failed to read production IDs file', error);
throw error;
}
}
}

View File

@@ -52,6 +52,12 @@ app.whenReady().then(() => {
// IPC test
ipcMain.on('ping', () => console.log('pong'))
// IPC Handler Registration
// TODO: Register IPC handlers for:
// - automation:clean, automation:extract, automation:stop, automation:progress
// - database:query-production-orders, database:query-materials-to-delete
// - auth:login, auth:logout
createWindow()
app.on('activate', function () {

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

View File

@@ -0,0 +1,10 @@
export interface AuthResult {
success: boolean;
message?: string;
}
export interface SessionInfo {
username: string;
isLoggedIn: boolean;
loginTime: Date;
}

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,9 @@
export interface QueryResult {
rows: any[];
rowCount: number;
}
export interface TransactionResult {
success: boolean;
rowsAffected: number;
}

View File

@@ -0,0 +1,10 @@
export interface IPCChannels {
'automation:clean': any;
'automation:extract': any;
'automation:stop': any;
'automation:progress': any;
'database:query-production-orders': any;
'database:query-materials-to-delete': any;
'auth:login': any;
'auth:logout': any;
}

View File

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

View File

@@ -0,0 +1,17 @@
import { Browser, BrowserContext, Page, Frame } from 'playwright';
export interface Credentials {
username: string;
password: string;
}
export interface Session {
browser: Browser;
context: BrowserContext;
page: Page;
mainFrame: Frame;
}
export interface LoginResult extends Session {
success: boolean;
}

View File

View File

@@ -0,0 +1,76 @@
import { PlaywrightService } from './playwright.service';
import { Credentials, LoginResult } from '../models/playwright.types';
import { AuthResult, SessionInfo } from '../models/auth.types';
import { LoggerService } from './logger.service';
export class AuthService {
private currentSession?: LoginResult;
private sessionInfo?: SessionInfo;
constructor(private playwrightService: PlaywrightService) {}
async login(credentials: Credentials): Promise<AuthResult> {
try {
LoggerService.info(`Logging in user: ${credentials.username}`);
const session = await this.playwrightService.login(credentials);
this.currentSession = session;
this.sessionInfo = {
username: credentials.username,
isLoggedIn: true,
loginTime: new Date(),
};
LoggerService.info('Login successful');
return { success: true, message: 'Login successful' };
} catch (error) {
LoggerService.error('Login failed', error);
// Ensure cleanup on failed login
await this.playwrightService.closeBrowser().catch(cleanupError => {
LoggerService.error('Failed to cleanup after failed login', cleanupError);
});
this.currentSession = undefined;
this.sessionInfo = undefined;
return {
success: false,
message: error instanceof Error ? error.message : 'Unknown error',
};
}
}
async logout(): Promise<void> {
if (!this.currentSession) {
return; // Already logged out
}
try {
LoggerService.info('Logging out...');
await this.playwrightService.closeBrowser();
LoggerService.info('Logout successful');
} catch (error) {
LoggerService.error('Logout failed', error);
throw new Error(`Logout failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
} finally {
// Always clear state
this.currentSession = undefined;
this.sessionInfo = undefined;
}
}
getSessionInfo(): SessionInfo | undefined {
return this.sessionInfo;
}
isLoggedIn(): boolean {
return this.sessionInfo?.isLoggedIn ?? false;
}
async destroy(): Promise<void> {
await this.logout().catch(err =>
LoggerService.error('Failed to logout during destroy', err)
);
}
}

View File

@@ -0,0 +1,109 @@
import sql from 'mssql';
import mysql from 'mysql2/promise';
import { SQLServerConfig, MySQLConfig } from '../models/config.types';
import { QueryResult } from '../models/database.types';
import { LoggerService } from './logger.service';
export class DatabaseService {
private sqlServerPool?: sql.ConnectionPool;
private mysqlPool?: mysql.Pool;
constructor(
private sqlServerConfig: SQLServerConfig,
private mysqlConfig: MySQLConfig
) {}
async connectToSQLServer(): Promise<void> {
try {
LoggerService.info('Connecting to SQL Server...');
this.sqlServerPool = await sql.connect({
server: this.sqlServerConfig.server,
database: this.sqlServerConfig.database,
user: this.sqlServerConfig.username,
password: this.sqlServerConfig.password,
driver: this.sqlServerConfig.driver,
options: {
trustServerCertificate:
this.sqlServerConfig.trustServerCertificate === 'yes',
},
});
LoggerService.info('SQL Server connection established');
} catch (error) {
LoggerService.error('Failed to connect to SQL Server', error);
throw error;
}
}
async connectToMySQL(): Promise<void> {
try {
LoggerService.info('Connecting to MySQL...');
this.mysqlPool = mysql.createPool({
host: this.mysqlConfig.host,
port: this.mysqlConfig.port,
database: this.mysqlConfig.database,
user: this.mysqlConfig.username,
password: this.mysqlConfig.password,
});
LoggerService.info('MySQL connection established');
} catch (error) {
LoggerService.error('Failed to connect to MySQL', error);
throw error;
}
}
async executeSQLServerQuery(query: string, params?: any[]): Promise<QueryResult> {
if (!this.sqlServerPool) {
throw new Error('SQL Server not connected');
}
try {
const request = this.sqlServerPool.request();
if (params) {
params.forEach((param, index) => {
request.input(`param${index}`, param);
});
}
const result = await request.query(query);
return {
rows: result.recordset,
rowCount: result.rowsAffected[0],
};
} catch (error) {
LoggerService.error('SQL Server query failed', { query, error });
throw error;
}
}
async executeMySQLQuery(query: string, params?: any[]): Promise<QueryResult> {
if (!this.mysqlPool) {
throw new Error('MySQL not connected');
}
try {
const [rows] = await this.mysqlPool.execute(query, params);
return {
rows: rows as any[],
rowCount: (rows as any[]).length,
};
} catch (error) {
LoggerService.error('MySQL query failed', { query, error });
throw error;
}
}
async closeAll(): Promise<void> {
if (this.sqlServerPool) {
await this.sqlServerPool.close();
LoggerService.info('SQL Server connection closed');
}
if (this.mysqlPool) {
await this.mysqlPool.end();
LoggerService.info('MySQL connection closed');
}
}
}

View File

@@ -0,0 +1,125 @@
import ExcelJS from 'exceljs';
import path from 'path';
import { LoggerService } from './logger.service';
export class ExcelConverterService {
constructor(private verbose: boolean = true) {}
async convert(inputPath: string, outputPath?: string): Promise<any[]> {
try {
// Validate input file
const fs = await import('fs/promises');
// Check file exists
try {
await fs.access(inputPath, fs.constants.R_OK);
} catch {
throw new Error(`File not found: ${inputPath}`);
}
// Check file extension
if (!inputPath.match(/\.(xlsx|xls)$/i)) {
throw new Error(`Invalid file type. Expected .xlsx or .xls file: ${inputPath}`);
}
LoggerService.info(`Converting Excel file: ${inputPath}`);
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.readFile(inputPath);
const worksheet = workbook.worksheets[0];
if (!worksheet) {
throw new Error('No worksheet found in Excel file');
}
const data: any[] = [];
const headers: string[] = [];
// Extract headers
const headerRow = worksheet.getRow(1);
headerRow.eachCell((cell, colNumber) => {
headers[colNumber - 1] = cell.text;
});
// Extract data rows
worksheet.eachRow((row, rowNumber) => {
if (rowNumber === 1) return; // Skip header row
const rowData: any = {};
row.eachCell((cell, colNumber) => {
const header = headers[colNumber - 1];
if (header) {
// Extract actual value from cell (handles formulas, rich text, etc.)
const value = cell.value;
const extractedValue = typeof value === 'object' && value !== null
? (value as any).result || (value as any).text || value
: value;
rowData[header] = extractedValue;
}
});
data.push(rowData);
});
// Validate we got data
if (data.length === 0) {
LoggerService.warn('Excel file contains no data rows');
}
LoggerService.info(`Extracted ${data.length} rows from Excel file`);
// Save to output path if provided
if (outputPath) {
await this.saveAsJson(data, outputPath);
}
return data;
} catch (error) {
LoggerService.error('Excel conversion failed', error);
throw error;
}
}
async mergeExcelFiles(filePaths: string[], outputPath: string): Promise<void> {
try {
LoggerService.info(`Merging ${filePaths.length} Excel files...`);
const allData: any[] = [];
for (const filePath of filePaths) {
const data = await this.convert(filePath);
allData.push(...data);
}
// Create new Excel workbook with merged data
const workbook = new ExcelJS.Workbook();
const worksheet = workbook.addWorksheet('Merged Data');
if (allData.length > 0) {
const headers = Object.keys(allData[0]);
worksheet.addRow(headers);
allData.forEach((row) => {
const values = headers.map((header) => row[header]);
worksheet.addRow(values);
});
}
await workbook.xlsx.writeFile(outputPath);
LoggerService.info(`Merged data saved to: ${outputPath}`);
} catch (error) {
LoggerService.error('Excel merge failed', error);
throw error;
}
}
private async saveAsJson(data: any[], outputPath: string): Promise<void> {
try {
const fs = await import('fs/promises');
await fs.writeFile(outputPath, JSON.stringify(data, null, 2), 'utf-8');
LoggerService.info(`Data saved to JSON: ${outputPath}`);
} catch (error) {
LoggerService.error(`Failed to save JSON to ${outputPath}`, error);
throw new Error(`Failed to save JSON output: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
}

View File

@@ -0,0 +1,94 @@
import winston from 'winston';
import path from 'path';
import { app } from 'electron';
import { LogEntry } from '../models/logger.types';
export class LoggerService {
private static instance: winston.Logger | null = null;
private static uiLogCallbacks: Set<(logEntry: LogEntry) => void> = new Set();
static initialize(): winston.Logger {
if (this.instance) {
return this.instance;
}
const logDir = path.join(app.getPath('userData'), 'logs');
this.instance = winston.createLogger({
level: 'info',
format: winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.errors({ stack: true }),
winston.format.printf(({ level, message, timestamp, stack }) => {
if (stack) {
return `[${timestamp}] [${level.toUpperCase()}] ${message}\n${stack}`;
}
return `[${timestamp}] [${level.toUpperCase()}] ${message}`;
})
),
transports: [
new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple()
),
}),
new winston.transports.File({
filename: path.join(logDir, 'app.log'),
maxsize: 10 * 1024 * 1024,
maxFiles: 5,
}),
new winston.transports.File({
filename: path.join(logDir, 'error.log'),
level: 'error',
maxsize: 10 * 1024 * 1024,
maxFiles: 5,
}),
],
});
return this.instance;
}
static onUILog(callback: (logEntry: LogEntry) => void): () => void {
this.uiLogCallbacks.add(callback);
return () => {
this.uiLogCallbacks.delete(callback);
};
}
private static notifyUI(level: string, message: string, details?: any): void {
const logEntry: LogEntry = {
timestamp: new Date().toISOString(),
level,
message,
details,
};
this.uiLogCallbacks.forEach((callback) => callback(logEntry));
}
static info(message: string, details?: any): void {
if (!this.instance) this.initialize();
this.instance!.info(message, details);
this.notifyUI('info', message, details);
}
static warn(message: string, details?: any): void {
if (!this.instance) this.initialize();
this.instance!.warning(message, details);
this.notifyUI('warn', message, details);
}
static error(message: string, details?: any): void {
if (!this.instance) this.initialize();
this.instance!.error(message, details);
this.notifyUI('error', message, details);
}
static debug(message: string, details?: any): void {
if (!this.instance) this.initialize();
this.instance!.debug(message, details);
this.notifyUI('debug', message, details);
}
}

View File

@@ -0,0 +1,123 @@
import { chromium, Browser, BrowserContext, Page, Frame } from 'playwright';
import { Credentials, Session, LoginResult } from '../models/playwright.types';
import { BrowserConfig, ERPConfig } from '../models/config.types';
import { LoggerService } from './logger.service';
export class PlaywrightService {
private browser?: Browser;
private context?: BrowserContext;
constructor(
private browserConfig: BrowserConfig,
private erpConfig: ERPConfig
) {}
async launchBrowser(): Promise<Browser> {
try {
LoggerService.info('Launching Chromium browser...');
this.browser = await chromium.launch({
headless: this.browserConfig.headless,
slowMo: this.browserConfig.slowMo,
timeout: this.browserConfig.timeout,
});
this.context = await this.browser.newContext({
ignoreHTTPSErrors: this.erpConfig.ignoreHttpsErrors,
});
LoggerService.info('Browser launched successfully');
return this.browser;
} catch (error) {
LoggerService.error('Failed to launch browser', error);
throw error;
}
}
async login(credentials: Credentials): Promise<LoginResult> {
if (!this.browser || !this.context) {
throw new Error('Browser not launched');
}
let page: Page | undefined;
try {
LoggerService.info('Attempting ERP login...');
page = await this.context.newPage();
await page.goto(this.erpConfig.baseUrl, {
timeout: this.browserConfig.timeout,
});
// Wait for login form
await page.waitForSelector('input[name="username"]', {
timeout: this.browserConfig.timeout,
});
// Fill credentials
await page.fill('input[name="username"]', credentials.username);
await page.fill('input[name="password"]', credentials.password);
// Click login button
await page.click('button[type="submit"]');
// Wait for navigation
await page.waitForLoadState('domcontentloaded');
// Get main frame
const mainFrame = page.mainFrame();
LoggerService.info('Login successful');
return {
success: true,
browser: this.browser,
context: this.context,
page,
mainFrame,
};
} catch (error) {
LoggerService.error('Login failed', error);
// Clean up page on error to prevent resource leak
if (page) {
try {
await page.close();
} catch (closeError) {
LoggerService.error('Failed to close page after login error', closeError);
}
}
throw error;
}
}
async closeBrowser(): Promise<void> {
// Close context first
if (this.context) {
try {
await this.context.close();
LoggerService.info('Browser context closed');
} catch (error) {
LoggerService.error('Failed to close browser context', error);
}
}
// Always attempt to close browser, even if context.close() failed
if (this.browser) {
try {
await this.browser.close();
LoggerService.info('Browser closed');
} catch (error) {
LoggerService.error('Failed to close browser', error);
}
}
}
async newPage(): Promise<Page> {
if (!this.context) {
throw new Error('Browser context not initialized');
}
return await this.context.newPage();
}
}

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

0
src/preload/.gitkeep Normal file
View File

View File

@@ -1,8 +1,28 @@
import { contextBridge } from 'electron'
import { contextBridge, ipcRenderer } from 'electron'
import { electronAPI } from '@electron-toolkit/preload'
// Custom APIs for renderer
const api = {}
const API = {
automation: {
clean: (params: any) => ipcRenderer.invoke('automation:clean', params),
extract: (params: any) => ipcRenderer.invoke('automation:extract', params),
stop: () => ipcRenderer.invoke('automation:stop'),
onProgress: (callback: (progress: any) => void) => {
const listener = (_: any, progress: any) => callback(progress);
ipcRenderer.on('automation:progress', listener);
return () => ipcRenderer.removeListener('automation:progress', listener);
},
},
database: {
queryProductionOrders: (productionIds: string[]) =>
ipcRenderer.invoke('database:query-production-orders', productionIds),
queryMaterialsToDelete: (managerNames: string[] | null) =>
ipcRenderer.invoke('database:query-materials-to-delete', managerNames),
},
auth: {
login: (credentials: any) => ipcRenderer.invoke('auth:login', credentials),
logout: () => ipcRenderer.invoke('auth:logout'),
},
}
// Use `contextBridge` APIs to expose Electron APIs to
// renderer only if context isolation is enabled, otherwise
@@ -10,7 +30,7 @@ const api = {}
if (process.contextIsolated) {
try {
contextBridge.exposeInMainWorld('electron', electronAPI)
contextBridge.exposeInMainWorld('api', api)
contextBridge.exposeInMainWorld('api', API)
} catch (error) {
console.error(error)
}
@@ -18,5 +38,7 @@ if (process.contextIsolated) {
// @ts-ignore (define in dts)
window.electron = electronAPI
// @ts-ignore (define in dts)
window.api = api
window.api = API
}
export type API = typeof API

0
tests/.gitkeep Normal file
View File

View File

@@ -0,0 +1,232 @@
import { MaterialsToDeleteDAO } from '../../../src/main/dao/materials-to-delete.dao';
import { DatabaseService } from '../../../src/main/services/database.service';
// Mock LoggerService to avoid Electron app dependency
jest.mock('../../../src/main/services/logger.service', () => ({
LoggerService: {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
},
}));
describe('MaterialsToDeleteDAO', () => {
let dao: MaterialsToDeleteDAO;
let mockDbService: jest.Mocked<DatabaseService>;
beforeEach(() => {
// Clear all mocks before each test
jest.clearAllMocks();
// Create a mock DatabaseService
mockDbService = {
executeSQLServerQuery: jest.fn(),
} as any;
dao = new MaterialsToDeleteDAO(mockDbService);
});
describe('getMaterialsToDeleteByManagers', () => {
it('should return all materials when managerNames is null', async () => {
const mockResult = {
rows: [
{ material_name: 'MAT001' },
{ material_name: 'MAT002' },
{ material_name: 'MAT003' },
],
rowCount: 3,
};
mockDbService.executeSQLServerQuery.mockResolvedValue(mockResult);
const result = await dao.getMaterialsToDeleteByManagers(null);
expect(result).toEqual(['MAT001', 'MAT002', 'MAT003']);
expect(mockDbService.executeSQLServerQuery).toHaveBeenCalledTimes(1);
// Verify the query does not contain WHERE clause
const queryCall = mockDbService.executeSQLServerQuery.mock.calls[0];
const query = queryCall[0];
expect(query).not.toContain('WHERE');
expect(query).toContain('SELECT DISTINCT material_name');
expect(query).toContain('FROM materials_to_delete');
});
it('should return materials filtered by manager names using parameterized query', async () => {
const managerNames = ['Manager1', 'Manager2'];
const mockResult = {
rows: [
{ material_name: 'MAT001' },
{ material_name: 'MAT002' },
],
rowCount: 2,
};
mockDbService.executeSQLServerQuery.mockResolvedValue(mockResult);
const result = await dao.getMaterialsToDeleteByManagers(managerNames);
expect(result).toEqual(['MAT001', 'MAT002']);
expect(mockDbService.executeSQLServerQuery).toHaveBeenCalledTimes(1);
// Verify parameterized query is used (SQL injection protection)
const queryCall = mockDbService.executeSQLServerQuery.mock.calls[0];
const query = queryCall[0];
const params = queryCall[1];
expect(query).toContain('WHERE manager_name IN');
expect(query).toContain('@param0');
expect(query).toContain('@param1');
// Verify parameters are passed separately (not concatenated in query)
expect(params).toEqual(managerNames);
// Ensure no string concatenation of values in query
expect(query).not.toContain("'Manager1'");
expect(query).not.toContain("'Manager2'");
});
it('should return empty array when managerNames is empty', async () => {
const result = await dao.getMaterialsToDeleteByManagers([]);
expect(result).toEqual([]);
expect(mockDbService.executeSQLServerQuery).not.toHaveBeenCalled();
});
it('should handle single manager name', async () => {
const managerNames = ['Manager1'];
const mockResult = {
rows: [{ material_name: 'MAT001' }],
rowCount: 1,
};
mockDbService.executeSQLServerQuery.mockResolvedValue(mockResult);
const result = await dao.getMaterialsToDeleteByManagers(managerNames);
expect(result).toEqual(['MAT001']);
expect(mockDbService.executeSQLServerQuery).toHaveBeenCalledTimes(1);
const queryCall = mockDbService.executeSQLServerQuery.mock.calls[0];
expect(queryCall[0]).toContain('@param0');
expect(queryCall[1]).toEqual(['Manager1']);
});
it('should return empty array when no materials found', async () => {
const mockResult = {
rows: [],
rowCount: 0,
};
mockDbService.executeSQLServerQuery.mockResolvedValue(mockResult);
const result = await dao.getMaterialsToDeleteByManagers(['Manager1']);
expect(result).toEqual([]);
});
it('should handle SQL injection attempts via parameterized query', async () => {
const maliciousInput = [
"Manager1'; DROP TABLE materials_to_delete; --",
"Manager2' OR '1'='1",
];
const mockResult = {
rows: [],
rowCount: 0,
};
mockDbService.executeSQLServerQuery.mockResolvedValue(mockResult);
await dao.getMaterialsToDeleteByManagers(maliciousInput);
// Verify parameters are passed as values, not concatenated
const queryCall = mockDbService.executeSQLServerQuery.mock.calls[0];
const query = queryCall[0];
const params = queryCall[1];
// The malicious strings should be in params, not in query
expect(params).toEqual(maliciousInput);
// Query should only contain placeholders, not actual values
expect(query).not.toContain('DROP TABLE');
expect(query).not.toContain('OR 1=1');
expect(query).toMatch(/@param\d+/);
});
it('should throw error when database query fails', async () => {
const dbError = new Error('Database connection failed');
mockDbService.executeSQLServerQuery.mockRejectedValue(dbError);
await expect(
dao.getMaterialsToDeleteByManagers(['Manager1'])
).rejects.toThrow('Database connection failed');
});
it('should handle large number of manager names', async () => {
const managerNames = Array.from({ length: 100 }, (_, i) => `Manager${i}`);
const mockResult = {
rows: [{ material_name: 'MAT001' }],
rowCount: 1,
};
mockDbService.executeSQLServerQuery.mockResolvedValue(mockResult);
const result = await dao.getMaterialsToDeleteByManagers(managerNames);
expect(result).toEqual(['MAT001']);
// Verify all parameters are passed
const queryCall = mockDbService.executeSQLServerQuery.mock.calls[0];
expect(queryCall[1]).toEqual(managerNames);
expect(queryCall[1]?.length).toBe(100);
});
});
describe('getAllMaterialsToDelete', () => {
it('should return all materials by calling getMaterialsToDeleteByManagers with null', async () => {
const mockResult = {
rows: [
{ material_name: 'MAT001' },
{ material_name: 'MAT002' },
{ material_name: 'MAT003' },
],
rowCount: 3,
};
mockDbService.executeSQLServerQuery.mockResolvedValue(mockResult);
const result = await dao.getAllMaterialsToDelete();
expect(result).toEqual(['MAT001', 'MAT002', 'MAT003']);
expect(mockDbService.executeSQLServerQuery).toHaveBeenCalledTimes(1);
// Verify query without filter
const queryCall = mockDbService.executeSQLServerQuery.mock.calls[0];
expect(queryCall[0]).not.toContain('WHERE');
});
it('should propagate errors from getMaterialsToDeleteByManagers', async () => {
const dbError = new Error('Database error');
mockDbService.executeSQLServerQuery.mockRejectedValue(dbError);
await expect(dao.getAllMaterialsToDelete()).rejects.toThrow(
'Database error'
);
});
it('should return empty array when no materials exist', async () => {
const mockResult = {
rows: [],
rowCount: 0,
};
mockDbService.executeSQLServerQuery.mockResolvedValue(mockResult);
const result = await dao.getAllMaterialsToDelete();
expect(result).toEqual([]);
});
});
});

View File

@@ -0,0 +1,26 @@
import { DatabaseService } from '../../../src/main/services/database.service';
describe('DatabaseService', () => {
it('should throw error when querying without connection', async () => {
const service = new DatabaseService(
{
server: 'localhost',
database: 'test',
username: 'test',
password: 'test',
driver: 'test',
},
{
host: 'localhost',
port: 3306,
database: 'test',
username: 'test',
password: 'test',
}
);
await expect(
service.executeSQLServerQuery('SELECT 1')
).rejects.toThrow('SQL Server not connected');
});
});

View File

@@ -1,4 +1,12 @@
{
"files": [],
"references": [{ "path": "./tsconfig.node.json" }, { "path": "./tsconfig.web.json" }]
"compilerOptions": {
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "tests"]
}