2 Commits

Author SHA1 Message Date
Misaka
9caa38caa8 feat: implement settings interface with user permission control
New Features:
- Add SettingsPage component with Admin/User view differentiation
- Create ConfigManager service for .env file management
- Add IPC handlers for settings CRUD operations
- Implement connection testing for ERP and database
- Add settings navigation to main app

Files Created:
- src/main/types/settings.types.ts - Type definitions
- src/main/services/config/config-manager.ts - Config service
- src/main/ipc/settings-handler.ts - IPC handlers
- src/renderer/src/pages/SettingsPage.tsx - React UI component

Files Modified:
- src/main/ipc/index.ts - Register settings handlers
- src/preload/index.ts - Expose settings API
- src/preload/index.d.ts - Add SettingsAPI type
- src/renderer/src/App.tsx - Add settings navigation

Co-Authored-By: Claude (qwen3.5-plus) <noreply@anthropic.com>
2026-03-01 20:51:29 +08:00
Misaka
0f1e0a8908 feat: implement material validation UI in CleanerPage
New Features:
- Material validation from database (full table or filtered by ProductionID)
- Checkbox selection for materials to delete with auto-select for marked items
- Manager-based filtering (Admin only)
- Two-phase deletion: confirm (mark in DB) + execute (delete from ERP)
- Export results to CSV
- Hide/show checked items feature (User mode)
- Share Production IDs between ExtractorPage and CleanerPage
- Persist page state using sessionStorage

Bug Fixes:
- Fix MySQL query rowCount for INSERT/UPDATE/DELETE operations
- Add AUTO_INCREMENT to MaterialsToBeDeleted.ID (preserved 43 records)
- Add UNIQUE constraint on MaterialsToBeDeleted.MaterialCode

Files Added:
- src/main/ipc/validation-handler.ts
- src/main/types/validation.types.ts
- src/main/services/database/materials-to-be-deleted-dao.ts
- src/main/services/database/discrete-material-plan-dao.ts

Files Modified:
- src/renderer/src/pages/CleanerPage.tsx (complete rewrite)
- src/renderer/src/pages/ExtractorPage.tsx
- src/renderer/src/App.tsx (add navigation tabs)
- src/main/services/database/mysql.ts
- src/preload/index.ts + index.d.ts
2026-03-01 19:15:38 +08:00
15 changed files with 4639 additions and 139 deletions

View File

@@ -9,6 +9,8 @@ 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'
/**
* Register all IPC handlers
@@ -20,4 +22,6 @@ export function registerIpcHandlers(): void {
registerDatabaseHandlers()
registerResolverHandlers()
registerAuthHandlers()
registerValidationHandlers()
registerSettingsHandlers()
}

View File

@@ -0,0 +1,255 @@
/**
* Settings IPC Handler
*
* Provides IPC handlers for settings management:
* - Get/set settings
* - Reset to defaults
* - Test ERP connection
* - Test database connection
*/
import { ipcMain } from 'electron'
import { ConfigManager } from '../services/config/config-manager'
import { SessionManager } from '../services/user/session-manager'
import { ErpAuthService } from '../services/erp/erp-auth'
import { MySqlService } from '../services/database/mysql'
import { SqlServerService } from '../services/database/sql-server'
import type {
SettingsData,
UserType,
ConnectionTestResult,
SaveSettingsResult
} from '../types/settings.types'
/**
* Filter settings by user type
* Admin users get all settings, User users get limited settings
*/
function filterSettingsByUserType(
settings: SettingsData,
userType: UserType
): SettingsData {
if (userType === 'Admin') {
return settings // Return all settings for Admin
}
// User users get limited settings
return {
erp: {
username: settings.erp.username,
password: settings.erp.password,
headless: settings.erp.headless,
url: settings.erp.url,
ignoreHttpsErrors: settings.erp.ignoreHttpsErrors,
autoCloseBrowser: settings.erp.autoCloseBrowser
},
paths: settings.paths,
execution: settings.execution,
// Include minimal required fields for other sections
database: settings.database,
extraction: settings.extraction,
validation: settings.validation,
ui: settings.ui
}
}
/**
* Register IPC handlers for settings management
*/
export function registerSettingsHandlers(): void {
const configManager = ConfigManager.getInstance()
const sessionManager = SessionManager.getInstance()
/**
* Get current user type
*/
ipcMain.handle('settings:getUserType', async (): Promise<UserType> => {
return (sessionManager.getUserType() as UserType) || 'Guest'
})
/**
* Get settings (filtered by user type)
*/
ipcMain.handle('settings:getSettings', async (): Promise<SettingsData> => {
const userType = (sessionManager.getUserType() as UserType) || 'Guest'
const settings = configManager.getAllSettings()
return filterSettingsByUserType(settings, userType)
})
/**
* Save settings
*/
ipcMain.handle(
'settings:saveSettings',
async (_event, settings: SettingsData): Promise<SaveSettingsResult> => {
try {
const success = await configManager.saveAllSettings(settings)
if (success) {
return { success: true }
} else {
return { success: false, error: '保存设置失败' }
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
return { success: false, error: `保存设置失败:${message}` }
}
}
)
/**
* Reset to default settings (Admin only)
*/
ipcMain.handle(
'settings:resetDefaults',
async (): Promise<SaveSettingsResult> => {
try {
const userType = sessionManager.getUserType()
if (userType !== 'Admin') {
return { success: false, error: '只有管理员可以恢复默认设置' }
}
configManager.resetToDefaults()
const success = await configManager.save()
if (success) {
return { success: true }
} else {
return { success: false, error: '恢复默认设置失败' }
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
return { success: false, error: `恢复默认设置失败:${message}` }
}
}
)
/**
* Test ERP connection
*/
ipcMain.handle(
'settings:testErpConnection',
async (): Promise<ConnectionTestResult> => {
try {
const settings = configManager.getAllSettings()
const erpConfig = settings.erp
if (!erpConfig.url || !erpConfig.username || !erpConfig.password) {
return {
success: false,
message: '请先配置 ERP URL、用户名和密码'
}
}
// Create ERP auth service and try to login
const erpAuthService = new ErpAuthService(erpConfig)
try {
await erpAuthService.login()
// Login successful, close browser
await erpAuthService.close()
return {
success: true,
message: 'ERP 连接测试成功!'
}
} catch (loginError) {
const errorMessage = loginError instanceof Error ? loginError.message : '登录失败'
return {
success: false,
message: `ERP 连接测试失败:${errorMessage}`
}
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
return {
success: false,
message: `ERP 连接测试失败:${message}`
}
}
}
)
/**
* Test database connection
*/
ipcMain.handle(
'settings:testDbConnection',
async (): Promise<ConnectionTestResult> => {
try {
const settings = configManager.getAllSettings()
const dbConfig = settings.database
if (dbConfig.dbType === 'mysql') {
// Test MySQL connection
if (!dbConfig.mysqlHost || !dbConfig.database || !dbConfig.username) {
return {
success: false,
message: '请先配置 MySQL 主机、数据库名和用户名'
}
}
const mysqlService = new MySqlService({
host: dbConfig.mysqlHost,
port: dbConfig.mysqlPort,
user: dbConfig.username,
password: dbConfig.password,
database: dbConfig.database
})
try {
await mysqlService.connect()
await mysqlService.disconnect()
return {
success: true,
message: 'MySQL 数据库连接测试成功!'
}
} catch (connError) {
const errorMessage = connError instanceof Error ? connError.message : '连接失败'
return {
success: false,
message: `MySQL 数据库连接测试失败:${errorMessage}`
}
}
} else {
// Test SQL Server connection
if (!dbConfig.server || !dbConfig.database || !dbConfig.username) {
return {
success: false,
message: '请先配置 SQL Server 服务器、数据库名和用户名'
}
}
const sqlServerService = new SqlServerService({
server: dbConfig.server,
port: 1433, // Default SQL Server port
user: dbConfig.username,
password: dbConfig.password,
database: dbConfig.database,
options: {
trustServerCertificate: true
}
})
try {
await sqlServerService.connect()
await sqlServerService.disconnect()
return {
success: true,
message: 'SQL Server 数据库连接测试成功!'
}
} catch (connError) {
const errorMessage = connError instanceof Error ? connError.message : '连接失败'
return {
success: false,
message: `SQL Server 数据库连接测试失败:${errorMessage}`
}
}
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
return {
success: false,
message: `数据库连接测试失败:${message}`
}
}
}
)
}

View File

@@ -0,0 +1,714 @@
/**
* 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 { MaterialsToBeDeletedDAO } from '../services/database/materials-to-be-deleted-dao'
import { DiscreteMaterialPlanDAO } from '../services/database/discrete-material-plan-dao'
import type {
ValidationRequest,
ValidationResponse,
MaterialUpsertBatchRequest,
MaterialDeleteRequest,
MaterialOperationResponse,
ValidationResult,
MaterialRecordSummary
} from '../types/validation.types'
/**
* Shared state for Production IDs from extractor page
* This is a simple in-memory store for sharing Production IDs between pages
*/
const sharedProductionIds = new Set<string>()
/**
* Set shared Production IDs
*/
export function setSharedProductionIds(ids: string[]): void {
ids.forEach(id => sharedProductionIds.add(id))
}
/**
* Get shared Production IDs
*/
export function getSharedProductionIds(): string[] {
return [...sharedProductionIds]
}
/**
* Clear shared Production IDs
*/
export function clearSharedProductionIds(): void {
sharedProductionIds.clear()
}
/**
* Get MySQL service for validation operations
*/
async function getValidationMySqlService(): Promise<MySqlService> {
const mysqlService = new MySqlService({
host: process.env.DB_MYSQL_HOST || 'localhost',
port: parseInt(process.env.DB_MYSQL_PORT || '3306', 10),
user: process.env.DB_USERNAME || 'root',
password: process.env.DB_PASSWORD || '',
database: process.env.DB_NAME || ''
})
await mysqlService.connect()
return mysqlService
}
/**
* Read Production IDs from file
*/
function readProductionIds(filePath: string): string[] {
const fs = require('fs')
const content = fs.readFileSync(filePath, 'utf-8')
return content
.split('\n')
.map(line => line.trim())
.filter(line => 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[],
mysqlService: MySqlService
): Promise<string[]> {
const productionIds: string[] = []
const orderNumbers: string[] = []
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 placeholders = productionIds.map(() => '?').join(',')
const contractSql = `
SELECT DISTINCT 生产订单号
FROM productionContractData_26年压力表合同数据
WHERE 总排号 IN (${placeholders})
`
const contractResult = await mysqlService.query(contractSql, productionIds)
const dbOrderNumbers = contractResult.rows.map(
row => row. as string
)
orderNumbers.push(...dbOrderNumbers)
}
// Deduplicate
return [...new Set(orderNumbers)]
}
/**
* Register IPC handlers for validation operations
*/
export function registerValidationHandlers(): void {
// ==================== VALIDATION ====================
/**
* Run material validation from database
*/
ipcMain.handle(
'validation:validate',
async (
_event,
request: ValidationRequest
): Promise<ValidationResponse> => {
let mysqlService: MySqlService | null = null
try {
console.log('[Validation] Starting validation:', request)
// Connect to MySQL
mysqlService = await getValidationMySqlService()
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()
console.log(`[Validation] 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, mysqlService)
console.log(
`[Validation] Got ${sourceNumbers.length} source numbers from shared Production IDs`
)
} else if (request.productionIdFile) {
// Read from file
const inputs = readProductionIds(request.productionIdFile)
console.log(
`[Validation] Read ${inputs.length} inputs from file`
)
sourceNumbers = await getSourceNumbersFromInputs(inputs, mysqlService)
console.log(
`[Validation] Got ${sourceNumbers.length} source numbers`
)
}
}
// Get material records from DiscreteMaterialPlanData
const materialDao = new DiscreteMaterialPlanDAO()
// Inject the mysqlService instance
;(materialDao as any).mysqlService = mysqlService
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: 'No material records found',
stats: {
totalRecords: 0,
matchedCount: 0,
markedCount: 0
}
}
}
// Get type keywords from MaterialsTypeToBeDeleted
const typeKeywordSql = `
SELECT MaterialName, ManagerName
FROM dbo_MaterialsTypeToBeDeleted
WHERE MaterialName IS NOT NULL
`
const typeKeywordResult = await 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 markedSql = `
SELECT MaterialCode, ManagerName
FROM dbo_MaterialsToBeDeleted
WHERE MaterialCode IS NOT NULL AND ManagerName IS NOT NULL
`
const markedResult = await 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 &&
typeKeyword.materialName.includes(materialName)
) {
matchedTypeKeyword = typeKeyword.materialName
managerName = typeKeyword.managerName
break
}
}
}
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'
console.error('[Validation] Validation error:', error)
return {
success: false,
error: `Validation failed: ${message}`
}
} finally {
if (mysqlService) {
try {
await mysqlService.disconnect()
} catch (closeError) {
console.warn('[Validation] Error disconnecting MySQL:', closeError)
}
}
}
}
)
// ==================== MATERIAL OPERATIONS ====================
/**
* Upsert batch materials to MaterialsToBeDeleted
*/
ipcMain.handle(
'materials:upsertBatch',
async (
_event,
request: MaterialUpsertBatchRequest
): Promise<MaterialOperationResponse> => {
let mysqlService: MySqlService | null = null
try {
mysqlService = await getValidationMySqlService()
const dao = new MaterialsToBeDeletedDAO()
;(dao as any).mysqlService = mysqlService
const stats = await dao.upsertBatch(request.materials)
return {
success: true,
stats
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
console.error('[Materials] Upsert batch error:', error)
return {
success: false,
error: `Upsert failed: ${message}`
}
} finally {
if (mysqlService) {
try {
await mysqlService.disconnect()
} catch (closeError) {
console.warn('[Materials] Error disconnecting MySQL:', closeError)
}
}
}
}
)
/**
* Delete materials by material codes
*/
ipcMain.handle(
'materials:delete',
async (
_event,
request: MaterialDeleteRequest
): Promise<MaterialOperationResponse> => {
let mysqlService: MySqlService | null = null
try {
mysqlService = await getValidationMySqlService()
const dao = new MaterialsToBeDeletedDAO()
;(dao as any).mysqlService = mysqlService
const count = await dao.deleteByMaterialCodes(request.materialCodes)
return {
success: true,
count
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
console.error('[Materials] Delete error:', error)
return {
success: false,
error: `Delete failed: ${message}`
}
} finally {
if (mysqlService) {
try {
await mysqlService.disconnect()
} catch (closeError) {
console.warn('[Materials] Error disconnecting MySQL:', closeError)
}
}
}
}
)
/**
* Get unique manager names
*/
ipcMain.handle(
'materials:getManagers',
async (_event): Promise<{ managers: string[] }> => {
let mysqlService: MySqlService | null = null
try {
mysqlService = await getValidationMySqlService()
const dao = new MaterialsToBeDeletedDAO()
;(dao as any).mysqlService = mysqlService
const managers = await dao.getManagers()
return { managers }
} catch (error) {
console.error('[Materials] Get managers error:', error)
return { managers: [] }
} finally {
if (mysqlService) {
try {
await mysqlService.disconnect()
} catch (closeError) {
console.warn('[Materials] Error disconnecting MySQL:', closeError)
}
}
}
}
)
/**
* Get materials by manager
*/
ipcMain.handle(
'materials:getByManager',
async (
_event,
managerName: string
): Promise<{ materials: MaterialRecordSummary[] }> => {
let mysqlService: MySqlService | null = null
try {
mysqlService = await getValidationMySqlService()
const dao = new MaterialsToBeDeletedDAO()
;(dao as any).mysqlService = mysqlService
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[] = []
for (const mat of materials) {
const detailSql = `
SELECT MaterialName, Specification, Model
FROM dbo_DiscreteMaterialPlanData
WHERE MaterialCode = ?
LIMIT 1
`
const detailResult = await 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) {
console.error('[Materials] Get by manager error:', error)
return { materials: [] }
} finally {
if (mysqlService) {
try {
await mysqlService.disconnect()
} catch (closeError) {
console.warn('[Materials] Error disconnecting MySQL:', closeError)
}
}
}
}
)
/**
* Get all material records
*/
ipcMain.handle(
'materials:getAll',
async (_event): Promise<{ materials: MaterialRecordSummary[] }> => {
let mysqlService: MySqlService | null = null
try {
mysqlService = await getValidationMySqlService()
const dao = new MaterialsToBeDeletedDAO()
;(dao as any).mysqlService = mysqlService
const materials = await dao.getAllRecords()
const markedCodes = await dao.getAllMaterialCodes()
const enrichedMaterials: MaterialRecordSummary[] = []
for (const mat of materials) {
const detailSql = `
SELECT MaterialName, Specification, Model
FROM dbo_DiscreteMaterialPlanData
WHERE MaterialCode = ?
LIMIT 1
`
const detailResult = await 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) {
console.error('[Materials] Get all error:', error)
return { materials: [] }
} finally {
if (mysqlService) {
try {
await mysqlService.disconnect()
} catch (closeError) {
console.warn('[Materials] Error disconnecting MySQL:', closeError)
}
}
}
}
)
/**
* Get statistics
*/
ipcMain.handle(
'materials:getStatistics',
async (_event): Promise<{ stats: any }> => {
let mysqlService: MySqlService | null = null
try {
mysqlService = await getValidationMySqlService()
const dao = new MaterialsToBeDeletedDAO()
;(dao as any).mysqlService = mysqlService
const stats = await dao.getStatistics()
return { stats }
} catch (error) {
console.error('[Materials] Get statistics error:', error)
return { stats: null }
} finally {
if (mysqlService) {
try {
await mysqlService.disconnect()
} catch (closeError) {
console.warn('[Materials] Error disconnecting MySQL:', closeError)
}
}
}
}
)
/**
* Set shared Production IDs from extractor page
*/
ipcMain.handle(
'validation:setSharedProductionIds',
async (_event, productionIds: string[]): Promise<void> => {
console.log(`[Validation] Received ${productionIds.length} shared Production IDs`)
setSharedProductionIds(productionIds)
}
)
/**
* Get shared Production IDs
*/
ipcMain.handle(
'validation:getSharedProductionIds',
async (): Promise<{ productionIds: string[] }> => {
return { productionIds: getSharedProductionIds() }
}
)
/**
* 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(
'validation:getCleanerData',
async (_event): Promise<{
success: boolean
orderNumbers?: string[]
materialCodes?: string[]
error?: string
}> => {
let mysqlService: MySqlService | 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
console.log(`[CleanerData] User: ${username}, isAdmin: ${isAdmin}`)
// Connect to MySQL
mysqlService = await getValidationMySqlService()
// 1. Get order numbers from shared Production IDs
const sharedIds = getSharedProductionIds()
let orderNumbers: string[] = []
if (sharedIds.length > 0) {
console.log(`[CleanerData] Using ${sharedIds.length} shared Production IDs`)
orderNumbers = await getSourceNumbersFromInputs(sharedIds, mysqlService)
console.log(`[CleanerData] Got ${orderNumbers.length} order numbers`)
}
// 2. Get material codes from MaterialsToBeDeleted table
let materialCodes: string[] = []
if (isAdmin) {
// Admin sees all materials
const allCodesSql = `
SELECT MaterialCode
FROM dbo_MaterialsToBeDeleted
WHERE MaterialCode IS NOT NULL
`
const result = await mysqlService.query(allCodesSql)
materialCodes = result.rows
.map(row => row.MaterialCode as string)
.filter(Boolean)
console.log(`[CleanerData] Admin user: got ${materialCodes.length} materials`)
} else {
// Regular users only see their own materials
const userMaterialsSql = `
SELECT MaterialCode
FROM dbo_MaterialsToBeDeleted
WHERE ManagerName = ? AND MaterialCode IS NOT NULL
`
const result = await mysqlService.query(userMaterialsSql, [username])
materialCodes = result.rows
.map(row => row.MaterialCode as string)
.filter(Boolean)
console.log(`[CleanerData] Regular user: got ${materialCodes.length} materials`)
}
return {
success: true,
orderNumbers,
materialCodes
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
console.error('[CleanerData] Error:', error)
return {
success: false,
error: `获取清理数据失败:${message}`
}
} finally {
if (mysqlService) {
try {
await mysqlService.disconnect()
} catch (closeError) {
console.warn('[CleanerData] Error disconnecting MySQL:', closeError)
}
}
}
}
)
}

View File

@@ -0,0 +1,446 @@
/**
* Configuration Manager
*
* Manages application configuration stored in .env file
* Provides methods for reading, writing, and saving configuration values
*/
import * as fs from 'fs'
import * as path from 'path'
import { fileURLToPath } from 'url'
import { dirname } from 'path'
import type {
SettingsData,
ErpConfig,
DatabaseConfig,
PathsConfig,
ExtractionConfig,
ValidationConfig,
UiConfig,
ExecutionConfig,
DatabaseType,
MatchMode,
ValidationDataSource
} from '../../types/settings.types'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
/**
* Default settings values
*/
const DEFAULT_SETTINGS: SettingsData = {
erp: {
url: 'https://68.11.34.30:8082/',
username: '',
password: '',
headless: true,
ignoreHttpsErrors: true,
autoCloseBrowser: true
},
database: {
dbType: 'mysql',
server: '',
mysqlHost: '192.168.31.83',
mysqlPort: 3306,
database: 'BLD_DB',
username: 'remote_user',
password: ''
},
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: ''
},
ui: {
fontFamily: 'Microsoft YaHei UI',
fontSize: 10,
productionIdInputWidth: 20
},
execution: {
dryRun: false
}
}
/**
* Configuration Manager Class
*/
export class ConfigManager {
private static instance: ConfigManager | null = null
private envPath: string
private configCache: Map<string, string> = new Map()
private initialized: boolean = false
private constructor() {
if (this.initialized) {
return
}
this.envPath = path.resolve(__dirname, '../../.env')
this.initialized = true
}
/**
* Get the singleton instance
*/
public static getInstance(): ConfigManager {
if (ConfigManager.instance === null) {
ConfigManager.instance = new ConfigManager()
}
return ConfigManager.instance
}
/**
* Initialize configuration from .env file
*/
public async initialize(): Promise<void> {
await this.loadEnvFile()
}
/**
* Load .env file into cache
*/
private async loadEnvFile(): Promise<void> {
try {
if (fs.existsSync(this.envPath)) {
const content = fs.readFileSync(this.envPath, 'utf-8')
const lines = content.split('\n')
for (const line of lines) {
const trimmedLine = line.trim()
// Skip empty lines and comments
if (!trimmedLine || trimmedLine.startsWith('#')) {
continue
}
const [key, ...valueParts] = trimmedLine.split('=')
if (key && valueParts.length > 0) {
const value = valueParts.join('=').trim()
this.configCache.set(key.trim(), value)
}
}
}
} catch (error) {
console.error('[ConfigManager] Failed to load .env file:', error)
}
}
/**
* Get a configuration value
* @param key - Configuration key
* @param defaultValue - Default value if key doesn't exist
*/
public get(key: string, defaultValue?: string): string | undefined {
return this.configCache.get(key) ?? defaultValue
}
/**
* Get a boolean configuration value
*/
public getBoolean(key: string, defaultValue: boolean = false): boolean {
const value = this.get(key)
if (value === undefined) return defaultValue
return value.toLowerCase() === 'true'
}
/**
* Get a number configuration value
*/
public getNumber(key: string, defaultValue: number = 0): number {
const value = this.get(key)
if (value === undefined) return defaultValue
const parsed = parseInt(value, 10)
return isNaN(parsed) ? defaultValue : parsed
}
/**
* Set a configuration value in cache
*/
public set(key: string, value: string | number | boolean): void {
this.configCache.set(key, String(value))
}
/**
* Save configuration to .env file
*/
public async save(): Promise<boolean> {
try {
// Build .env content from cache
const lines: string[] = []
// ERP Configuration
lines.push('# ===========================')
lines.push('# ERP 系统配置')
lines.push('# ===========================')
lines.push(`ERP_URL=${this.configCache.get('erp.url') || DEFAULT_SETTINGS.erp.url}`)
lines.push(`ERP_USERNAME=${this.configCache.get('erp.username') || DEFAULT_SETTINGS.erp.username}`)
lines.push(`ERP_PASSWORD=${this.configCache.get('erp.password') || DEFAULT_SETTINGS.erp.password}`)
lines.push(`ERP_HEADLESS=${this.configCache.get('erp.headless') || DEFAULT_SETTINGS.erp.headless}`)
lines.push(`ERP_IGNORE_HTTPS_ERRORS=${this.configCache.get('erp.ignoreHttpsErrors') || DEFAULT_SETTINGS.erp.ignoreHttpsErrors}`)
lines.push(`ERP_AUTO_CLOSE_BROWSER=${this.configCache.get('erp.autoCloseBrowser') || DEFAULT_SETTINGS.erp.autoCloseBrowser}`)
lines.push('')
// Database Configuration - SQL Server
lines.push('# ===========================')
lines.push('# 数据库配置 - SQL Server')
lines.push('# ===========================')
lines.push(`# DB_TYPE=sqlserver`)
lines.push(`# DB_SERVER=${this.configCache.get('database.server') || ''}`)
lines.push(`# DB_NAME=${this.configCache.get('database.database') || ''}`)
lines.push(`# DB_USERNAME=${this.configCache.get('database.username') || ''}`)
lines.push(`# DB_PASSWORD=${this.configCache.get('database.password') || ''}`)
lines.push(`DB_SQLSERVER_DRIVER=ODBC Driver 18 for SQL Server`)
lines.push(`DB_TRUST_SERVER_CERTIFICATE=yes`)
lines.push('')
// Database Configuration - MySQL
lines.push('# ===========================')
lines.push('# 数据库配置 - MySQL (切换时使用)')
lines.push('# ===========================')
lines.push(`DB_TYPE=${this.configCache.get('database.dbType') || DEFAULT_SETTINGS.database.dbType}`)
lines.push(`DB_NAME=${this.configCache.get('database.database') || DEFAULT_SETTINGS.database.database}`)
lines.push(`DB_USERNAME=${this.configCache.get('database.username') || DEFAULT_SETTINGS.database.username}`)
lines.push(`DB_PASSWORD=${this.configCache.get('database.password') || DEFAULT_SETTINGS.database.password}`)
lines.push(`DB_MYSQL_HOST=${this.configCache.get('database.mysqlHost') || DEFAULT_SETTINGS.database.mysqlHost}`)
lines.push(`DB_MYSQL_PORT=${this.configCache.get('database.mysqlPort') || DEFAULT_SETTINGS.database.mysqlPort}`)
lines.push(`DB_MYSQL_CHARSET=utf8mb4`)
lines.push('')
// Order number parsing table configuration
lines.push('# 订单号解析表配置')
lines.push('# 表名:包含 productionID 和 生产订单号 映射关系的表')
lines.push(`DB_TABLE_NAME=productionContractData_26 年压力表合同数据`)
lines.push('# 字段名:总排号 (对应 productionID)')
lines.push(`DB_FIELD_PRODUCTION_ID=总排号`)
lines.push('# 字段名:生产订单号 (对应生产订单号)')
lines.push(`DB_FIELD_ORDER_NUMBER=生产订单号`)
lines.push('')
// Path Configuration
lines.push('# ===========================')
lines.push('# 路径配置')
lines.push('# ===========================')
lines.push(`PATH_DATA_DIR=${this.configCache.get('paths.dataDir') || DEFAULT_SETTINGS.paths.dataDir}`)
lines.push(`PATH_PRODUCTION_ID_FILE=ProductionID.txt`)
lines.push(`PATH_DEFAULT_OUTPUT=${this.configCache.get('paths.defaultOutput') || DEFAULT_SETTINGS.paths.defaultOutput}`)
lines.push(`PATH_VALIDATION_OUTPUT=${this.configCache.get('paths.validationOutput') || DEFAULT_SETTINGS.paths.validationOutput}`)
lines.push('')
// Data Extraction Configuration
lines.push('# ===========================')
lines.push('# 数据提取配置')
lines.push('# ===========================')
lines.push(`EXTRACTION_BATCH_SIZE=${this.configCache.get('extraction.batchSize') || DEFAULT_SETTINGS.extraction.batchSize}`)
lines.push(`EXTRACTION_VERBOSE=${this.configCache.get('extraction.verbose') || DEFAULT_SETTINGS.extraction.verbose}`)
lines.push(`EXTRACTION_AUTO_CONVERT=${this.configCache.get('extraction.autoConvert') || DEFAULT_SETTINGS.extraction.autoConvert}`)
lines.push(`EXTRACTION_MERGE_BATCHES=${this.configCache.get('extraction.mergeBatches') || DEFAULT_SETTINGS.extraction.mergeBatches}`)
lines.push(`EXTRACTION_ENABLE_DB_PERSISTENCE=${this.configCache.get('extraction.enableDbPersistence') || DEFAULT_SETTINGS.extraction.enableDbPersistence}`)
lines.push('')
// Validation Configuration
lines.push('# ===========================')
lines.push('# 校验配置')
lines.push('# ===========================')
lines.push(`VALIDATION_DATA_SOURCE=${this.configCache.get('validation.dataSource') || DEFAULT_SETTINGS.validation.dataSource}`)
lines.push(`VALIDATION_USE_DATABASE=${this.configCache.get('validation.useDatabase') || true}`)
lines.push(`VALIDATION_BATCH_SIZE=${this.configCache.get('validation.batchSize') || DEFAULT_SETTINGS.validation.batchSize}`)
lines.push(`VALIDATION_ENABLE_CRUD=${this.configCache.get('validation.enableCrud') || DEFAULT_SETTINGS.validation.enableCrud}`)
lines.push(`VALIDATION_DEFAULT_MANAGER=${this.configCache.get('validation.defaultManager') || DEFAULT_SETTINGS.validation.defaultManager}`)
lines.push(`VALIDATION_MATCH_MODE=${this.configCache.get('validation.matchMode') || DEFAULT_SETTINGS.validation.matchMode}`)
lines.push('')
// UI Configuration
lines.push('# ===========================')
lines.push('# UI 配置')
lines.push('# ===========================')
lines.push(`UI_FONT_FAMILY=${this.configCache.get('ui.fontFamily') || DEFAULT_SETTINGS.ui.fontFamily}`)
lines.push(`UI_FONT_SIZE=${this.configCache.get('ui.fontSize') || DEFAULT_SETTINGS.ui.fontSize}`)
lines.push(`UI_PRODUCTION_ID_INPUT_WIDTH=${this.configCache.get('ui.productionIdInputWidth') || DEFAULT_SETTINGS.ui.productionIdInputWidth}`)
lines.push('')
// Execution Configuration
lines.push('# ===========================')
lines.push('# 执行配置')
lines.push('# ===========================')
lines.push(`EXECUTION_DRYRUN=${this.configCache.get('execution.dryRun') || DEFAULT_SETTINGS.execution.dryRun}`)
const content = lines.join('\n')
fs.writeFileSync(this.envPath, content, 'utf-8')
return true
} catch (error) {
console.error('[ConfigManager] Failed to save .env file:', error)
return false
}
}
/**
* Get all settings as SettingsData object
*/
public getAllSettings(): SettingsData {
return {
erp: {
url: this.get('ERP_URL', DEFAULT_SETTINGS.erp.url),
username: this.get('ERP_USERNAME', DEFAULT_SETTINGS.erp.username),
password: this.get('ERP_PASSWORD', DEFAULT_SETTINGS.erp.password),
headless: this.getBoolean('ERP_HEADLESS', DEFAULT_SETTINGS.erp.headless),
ignoreHttpsErrors: this.getBoolean('ERP_IGNORE_HTTPS_ERRORS', DEFAULT_SETTINGS.erp.ignoreHttpsErrors),
autoCloseBrowser: this.getBoolean('ERP_AUTO_CLOSE_BROWSER', DEFAULT_SETTINGS.erp.autoCloseBrowser)
},
database: {
dbType: (this.get('DB_TYPE', DEFAULT_SETTINGS.database.dbType) as DatabaseType) || DEFAULT_SETTINGS.database.dbType,
server: this.get('DB_SERVER', DEFAULT_SETTINGS.database.server),
mysqlHost: this.get('DB_MYSQL_HOST', DEFAULT_SETTINGS.database.mysqlHost),
mysqlPort: this.getNumber('DB_MYSQL_PORT', DEFAULT_SETTINGS.database.mysqlPort),
database: this.get('DB_NAME', DEFAULT_SETTINGS.database.database),
username: this.get('DB_USERNAME', DEFAULT_SETTINGS.database.username),
password: this.get('DB_PASSWORD', DEFAULT_SETTINGS.database.password)
},
paths: {
dataDir: this.get('PATH_DATA_DIR', DEFAULT_SETTINGS.paths.dataDir),
defaultOutput: this.get('PATH_DEFAULT_OUTPUT', DEFAULT_SETTINGS.paths.defaultOutput),
validationOutput: this.get('PATH_VALIDATION_OUTPUT', DEFAULT_SETTINGS.paths.validationOutput)
},
extraction: {
batchSize: this.getNumber('EXTRACTION_BATCH_SIZE', DEFAULT_SETTINGS.extraction.batchSize),
verbose: this.getBoolean('EXTRACTION_VERBOSE', DEFAULT_SETTINGS.extraction.verbose),
autoConvert: this.getBoolean('EXTRACTION_AUTO_CONVERT', DEFAULT_SETTINGS.extraction.autoConvert),
mergeBatches: this.getBoolean('EXTRACTION_MERGE_BATCHES', DEFAULT_SETTINGS.extraction.mergeBatches),
enableDbPersistence: this.getBoolean('EXTRACTION_ENABLE_DB_PERSISTENCE', DEFAULT_SETTINGS.extraction.enableDbPersistence)
},
validation: {
dataSource: (this.get('VALIDATION_DATA_SOURCE', DEFAULT_SETTINGS.validation.dataSource) as ValidationDataSource) || DEFAULT_SETTINGS.validation.dataSource,
batchSize: this.getNumber('VALIDATION_BATCH_SIZE', DEFAULT_SETTINGS.validation.batchSize),
matchMode: (this.get('VALIDATION_MATCH_MODE', DEFAULT_SETTINGS.validation.matchMode) as MatchMode) || DEFAULT_SETTINGS.validation.matchMode,
enableCrud: this.getBoolean('VALIDATION_ENABLE_CRUD', DEFAULT_SETTINGS.validation.enableCrud),
defaultManager: this.get('VALIDATION_DEFAULT_MANAGER', DEFAULT_SETTINGS.validation.defaultManager)
},
ui: {
fontFamily: this.get('UI_FONT_FAMILY', DEFAULT_SETTINGS.ui.fontFamily),
fontSize: this.getNumber('UI_FONT_SIZE', DEFAULT_SETTINGS.ui.fontSize),
productionIdInputWidth: this.getNumber('UI_PRODUCTION_ID_INPUT_WIDTH', DEFAULT_SETTINGS.ui.productionIdInputWidth)
},
execution: {
dryRun: this.getBoolean('EXECUTION_DRYRUN', DEFAULT_SETTINGS.execution.dryRun)
}
}
}
/**
* Save settings from SettingsData object
*/
public async saveAllSettings(settings: SettingsData): Promise<boolean> {
// ERP settings
this.set('erp.url', settings.erp.url)
this.set('erp.username', settings.erp.username)
this.set('erp.password', settings.erp.password)
this.set('erp.headless', settings.erp.headless)
this.set('erp.ignoreHttpsErrors', settings.erp.ignoreHttpsErrors)
this.set('erp.autoCloseBrowser', settings.erp.autoCloseBrowser)
// Database settings
this.set('database.dbType', settings.database.dbType)
this.set('database.server', settings.database.server)
this.set('database.mysqlHost', settings.database.mysqlHost)
this.set('database.mysqlPort', settings.database.mysqlPort)
this.set('database.database', settings.database.database)
this.set('database.username', settings.database.username)
this.set('database.password', settings.database.password)
// Path settings
this.set('paths.dataDir', settings.paths.dataDir)
this.set('paths.defaultOutput', settings.paths.defaultOutput)
this.set('paths.validationOutput', settings.paths.validationOutput)
// Extraction settings
this.set('extraction.batchSize', settings.extraction.batchSize)
this.set('extraction.verbose', settings.extraction.verbose)
this.set('extraction.autoConvert', settings.extraction.autoConvert)
this.set('extraction.mergeBatches', settings.extraction.mergeBatches)
this.set('extraction.enableDbPersistence', settings.extraction.enableDbPersistence)
// Validation settings
this.set('validation.dataSource', settings.validation.dataSource)
this.set('validation.batchSize', settings.validation.batchSize)
this.set('validation.matchMode', settings.validation.matchMode)
this.set('validation.enableCrud', settings.validation.enableCrud)
this.set('validation.defaultManager', settings.validation.defaultManager)
// UI settings
this.set('ui.fontFamily', settings.ui.fontFamily)
this.set('ui.fontSize', settings.ui.fontSize)
this.set('ui.productionIdInputWidth', settings.ui.productionIdInputWidth)
// Execution settings
this.set('execution.dryRun', settings.execution.dryRun)
return this.save()
}
/**
* Reset to default settings
*/
public resetToDefaults(): SettingsData {
// Clear cache and reload from defaults
this.configCache.clear()
// Set all defaults
this.set('erp.url', DEFAULT_SETTINGS.erp.url)
this.set('erp.username', DEFAULT_SETTINGS.erp.username)
this.set('erp.password', DEFAULT_SETTINGS.erp.password)
this.set('erp.headless', DEFAULT_SETTINGS.erp.headless)
this.set('erp.ignoreHttpsErrors', DEFAULT_SETTINGS.erp.ignoreHttpsErrors)
this.set('erp.autoCloseBrowser', DEFAULT_SETTINGS.erp.autoCloseBrowser)
this.set('database.dbType', DEFAULT_SETTINGS.database.dbType)
this.set('database.server', DEFAULT_SETTINGS.database.server)
this.set('database.mysqlHost', DEFAULT_SETTINGS.database.mysqlHost)
this.set('database.mysqlPort', DEFAULT_SETTINGS.database.mysqlPort)
this.set('database.database', DEFAULT_SETTINGS.database.database)
this.set('database.username', DEFAULT_SETTINGS.database.username)
this.set('database.password', DEFAULT_SETTINGS.database.password)
this.set('paths.dataDir', DEFAULT_SETTINGS.paths.dataDir)
this.set('paths.defaultOutput', DEFAULT_SETTINGS.paths.defaultOutput)
this.set('paths.validationOutput', DEFAULT_SETTINGS.paths.validationOutput)
this.set('extraction.batchSize', DEFAULT_SETTINGS.extraction.batchSize)
this.set('extraction.verbose', DEFAULT_SETTINGS.extraction.verbose)
this.set('extraction.autoConvert', DEFAULT_SETTINGS.extraction.autoConvert)
this.set('extraction.mergeBatches', DEFAULT_SETTINGS.extraction.mergeBatches)
this.set('extraction.enableDbPersistence', DEFAULT_SETTINGS.extraction.enableDbPersistence)
this.set('validation.dataSource', DEFAULT_SETTINGS.validation.dataSource)
this.set('validation.batchSize', DEFAULT_SETTINGS.validation.batchSize)
this.set('validation.matchMode', DEFAULT_SETTINGS.validation.matchMode)
this.set('validation.enableCrud', DEFAULT_SETTINGS.validation.enableCrud)
this.set('validation.defaultManager', DEFAULT_SETTINGS.validation.defaultManager)
this.set('ui.fontFamily', DEFAULT_SETTINGS.ui.fontFamily)
this.set('ui.fontSize', DEFAULT_SETTINGS.ui.fontSize)
this.set('ui.productionIdInputWidth', DEFAULT_SETTINGS.ui.productionIdInputWidth)
this.set('execution.dryRun', DEFAULT_SETTINGS.execution.dryRun)
return DEFAULT_SETTINGS
}
/**
* Get default settings
*/
public getDefaultSettings(): SettingsData {
return DEFAULT_SETTINGS
}
}

View File

@@ -0,0 +1,460 @@
/**
* 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 { MySqlService } from './mysql'
/**
* 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_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 mysqlService: MySqlService | null = null
/**
* Get MySQL service instance
*/
private async getMySqlService(): Promise<MySqlService> {
if (this.mysqlService && this.mysqlService.isConnected()) {
return this.mysqlService
}
this.mysqlService = new MySqlService({
host: process.env.DB_MYSQL_HOST || 'localhost',
port: parseInt(process.env.DB_MYSQL_PORT || '3306', 10),
user: process.env.DB_USERNAME || 'root',
password: process.env.DB_PASSWORD || '',
database: process.env.DB_NAME || ''
})
await this.mysqlService.connect()
return this.mysqlService
}
// ==================== QUERY ALL ====================
/**
* Query all records from DiscreteMaterialPlanData table
* @returns List of all records
*/
async queryAll(): Promise<any[]> {
try {
const mysqlService = await this.getMySqlService()
const sql = `SELECT * FROM ${DISCRETE_MATERIAL_PLAN_CONFIG.TABLE_NAME_MYSQL}`
const result = await mysqlService.query(sql)
return result.rows
} catch (error) {
console.error('[DiscreteMaterialPlanDAO] Query all error:', 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 mysqlService = await this.getMySqlService()
const sql = `
WITH RankedRecords AS (
SELECT
*,
ROW_NUMBER() OVER (
PARTITION BY MaterialCode
ORDER BY CreateDate ASC, SequenceNumber ASC
) AS rn
FROM ${DISCRETE_MATERIAL_PLAN_CONFIG.TABLE_NAME_MYSQL}
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 mysqlService.query(sql)
return result.rows
} catch (error) {
console.error('[DiscreteMaterialPlanDAO] Query all distinct by material code error:', 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 mysqlService = await this.getMySqlService()
const batchSize = 2000
const allResults: any[] = []
for (let i = 0; i < sourceNumbers.length; i += batchSize) {
const batch = sourceNumbers.slice(i, i + batchSize)
const placeholders = batch.map(() => '?').join(',')
const sql = `
SELECT *
FROM ${DISCRETE_MATERIAL_PLAN_CONFIG.TABLE_NAME_MYSQL}
WHERE SourceNumber IN (${placeholders})
`
const result = await mysqlService.query(sql, batch)
allResults.push(...result.rows)
}
return allResults
} catch (error) {
console.error('[DiscreteMaterialPlanDAO] Query by source numbers error:', 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 mysqlService = await this.getMySqlService()
const batchSize = 2000
const allResults: any[] = []
for (let i = 0; i < sourceNumbers.length; i += batchSize) {
const batch = sourceNumbers.slice(i, i + batchSize)
const placeholders = batch.map(() => '?').join(',')
const sql = `
WITH RankedRecords AS (
SELECT
*,
ROW_NUMBER() OVER (
PARTITION BY MaterialCode
ORDER BY CreateDate ASC, SequenceNumber ASC
) AS rn
FROM ${DISCRETE_MATERIAL_PLAN_CONFIG.TABLE_NAME_MYSQL}
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 mysqlService.query(sql, batch)
allResults.push(...result.rows)
}
return allResults
} catch (error) {
console.error('[DiscreteMaterialPlanDAO] Query by source numbers distinct error:', error)
return []
}
}
/**
* Query by production order number (SourceNumber)
* @param sourceNumber - SourceNumber value
* @returns List of records
*/
async queryBySourceNumber(sourceNumber: string): Promise<any[]> {
try {
const mysqlService = await this.getMySqlService()
const sql = `
SELECT *
FROM ${DISCRETE_MATERIAL_PLAN_CONFIG.TABLE_NAME_MYSQL}
WHERE SourceNumber = ?
`
const result = await mysqlService.query(sql, [sourceNumber])
return result.rows
} catch (error) {
console.error('[DiscreteMaterialPlanDAO] Query by source number error:', 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 mysqlService = await this.getMySqlService()
const sql = `
SELECT *
FROM ${DISCRETE_MATERIAL_PLAN_CONFIG.TABLE_NAME_MYSQL}
WHERE PlanNumber = ?
`
const result = await mysqlService.query(sql, [planNumber])
return result.rows
} catch (error) {
console.error('[DiscreteMaterialPlanDAO] Query by plan number error:', 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 mysqlService = await this.getMySqlService()
const placeholders = planNumbers.map(() => '?').join(',')
const sql = `
SELECT *
FROM ${DISCRETE_MATERIAL_PLAN_CONFIG.TABLE_NAME_MYSQL}
WHERE PlanNumber IN (${placeholders})
`
const result = await mysqlService.query(sql, planNumbers)
return result.rows
} catch (error) {
console.error('[DiscreteMaterialPlanDAO] Query by plan numbers error:', error)
return []
}
}
// ==================== UTILITY METHODS ====================
/**
* Count all records
* @returns Total number of records
*/
async countAll(): Promise<number> {
try {
const mysqlService = await this.getMySqlService()
const sql = `SELECT COUNT(*) as count FROM ${DISCRETE_MATERIAL_PLAN_CONFIG.TABLE_NAME_MYSQL}`
const result = await mysqlService.query(sql)
return result.rows.length > 0 ? (result.rows[0].count as number) : 0
} catch (error) {
console.error('[DiscreteMaterialPlanDAO] Count all error:', error)
return 0
}
}
/**
* Count records by plan number
* @param planNumber - PlanNumber value
* @returns Number of records
*/
async countByPlanNumber(planNumber: string): Promise<number> {
try {
const mysqlService = await this.getMySqlService()
const sql = `
SELECT COUNT(*) as count
FROM ${DISCRETE_MATERIAL_PLAN_CONFIG.TABLE_NAME_MYSQL}
WHERE PlanNumber = ?
`
const result = await mysqlService.query(sql, [planNumber])
return result.rows.length > 0 ? (result.rows[0].count as number) : 0
} catch (error) {
console.error('[DiscreteMaterialPlanDAO] Count by plan number error:', 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 mysqlService = await this.getMySqlService()
let sql: string
let params: any[] = []
if (sourceNumbers && sourceNumbers.length > 0) {
const placeholders = sourceNumbers.map(() => '?').join(',')
sql = `
SELECT DISTINCT MaterialName
FROM ${DISCRETE_MATERIAL_PLAN_CONFIG.TABLE_NAME_MYSQL}
WHERE SourceNumber IN (${placeholders})
AND MaterialName IS NOT NULL
`
params = sourceNumbers
} else {
sql = `
SELECT DISTINCT MaterialName
FROM ${DISCRETE_MATERIAL_PLAN_CONFIG.TABLE_NAME_MYSQL}
WHERE MaterialName IS NOT NULL
`
}
const result = await mysqlService.query(sql, params)
return result.rows
.map(row => row.MaterialName as string)
.filter(Boolean)
} catch (error) {
console.error('[DiscreteMaterialPlanDAO] Get unique material names error:', error)
return []
}
}
/**
* Get statistics
* @returns Statistics object
*/
async getStatistics(): Promise<any> {
try {
const mysqlService = await this.getMySqlService()
const sql = `
SELECT
COUNT(*) as totalRecords,
COUNT(DISTINCT PlanNumber) as uniquePlans,
COUNT(DISTINCT SourceNumber) as uniqueOrders,
MIN(CreateDate) as earliestRecord,
MAX(CreateDate) as latestRecord
FROM ${DISCRETE_MATERIAL_PLAN_CONFIG.TABLE_NAME_MYSQL}
`
const result = await mysqlService.query(sql)
return result.rows.length > 0 ? result.rows[0] : {}
} catch (error) {
console.error('[DiscreteMaterialPlanDAO] Get statistics error:', error)
return {}
}
}
/**
* Disconnect from database
*/
async disconnect(): Promise<void> {
if (this.mysqlService) {
await this.mysqlService.disconnect()
this.mysqlService = null
}
}
}

View File

@@ -0,0 +1,516 @@
/**
* 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 { MySqlService } from './mysql'
/**
* 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_MYSQL: 'dbo_MaterialsToBeDeleted',
COLUMNS: {
ID: 'ID',
MATERIAL_CODE: 'MaterialCode',
MANAGER_NAME: 'ManagerName'
}
} as const
/**
* MaterialsToBeDeleted DAO Class
*/
export class MaterialsToBeDeletedDAO {
private mysqlService: MySqlService | null = null
/**
* Get MySQL service instance
*/
private async getMySqlService(): Promise<MySqlService> {
if (this.mysqlService && this.mysqlService.isConnected()) {
return this.mysqlService
}
this.mysqlService = new MySqlService({
host: process.env.DB_MYSQL_HOST || 'localhost',
port: parseInt(process.env.DB_MYSQL_PORT || '3306', 10),
user: process.env.DB_USERNAME || 'root',
password: process.env.DB_PASSWORD || '',
database: process.env.DB_NAME || ''
})
await this.mysqlService.connect()
return this.mysqlService
}
// ==================== 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()) {
console.error('[MaterialsToBeDeletedDAO] MaterialCode cannot be empty')
return false
}
try {
const mysqlService = await this.getMySqlService()
const sql = `
INSERT INTO ${MATERIALS_TO_BE_DELETED_CONFIG.TABLE_NAME_MYSQL} (MaterialCode, ManagerName)
VALUES (?, ?)
ON DUPLICATE KEY UPDATE ManagerName = VALUES(ManagerName)
`
await mysqlService.query(sql, [materialCode.trim(), managerName?.trim() || null])
return true
} catch (error) {
console.error('[MaterialsToBeDeletedDAO] Upsert material error:', 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 mysqlService = await this.getMySqlService()
for (const material of materials) {
const materialCode = material.materialCode?.trim()
const managerName = material.managerName?.trim() || ''
if (!materialCode) {
stats.failed++
continue
}
try {
const sql = `
INSERT INTO ${MATERIALS_TO_BE_DELETED_CONFIG.TABLE_NAME_MYSQL} (MaterialCode, ManagerName)
VALUES (?, ?)
ON DUPLICATE KEY UPDATE ManagerName = VALUES(ManagerName)
`
await mysqlService.query(sql, [materialCode, managerName || null])
stats.success++
} catch (error) {
console.error('[MaterialsToBeDeletedDAO] Error upserting material:', materialCode, error)
stats.failed++
}
}
} catch (error) {
console.error('[MaterialsToBeDeletedDAO] Batch upsert error:', error)
stats.failed = stats.total - stats.success
}
return stats
}
// ==================== READ ====================
/**
* Get all material codes as a set
* @returns Set of material codes
*/
async getAllMaterialCodes(): Promise<Set<string>> {
try {
const mysqlService = await this.getMySqlService()
const sql = `
SELECT MaterialCode
FROM ${MATERIALS_TO_BE_DELETED_CONFIG.TABLE_NAME_MYSQL}
WHERE MaterialCode IS NOT NULL
`
const result = await mysqlService.query(sql)
return new Set(result.rows.map(row => row.MaterialCode as string).filter(Boolean))
} catch (error) {
console.error('[MaterialsToBeDeletedDAO] Get all material codes error:', error)
return new Set()
}
}
/**
* Get all material records
* @returns List of all material records
*/
async getAllRecords(): Promise<MaterialRecord[]> {
try {
const mysqlService = await this.getMySqlService()
const sql = `
SELECT ID, MaterialCode, ManagerName
FROM ${MATERIALS_TO_BE_DELETED_CONFIG.TABLE_NAME_MYSQL}
WHERE MaterialCode IS NOT NULL
ORDER BY ManagerName, MaterialCode
`
const result = await mysqlService.query(sql)
return result.rows.map(row => ({
id: row.ID as number,
materialCode: row.MaterialCode as string,
managerName: row.ManagerName as string
}))
} catch (error) {
console.error('[MaterialsToBeDeletedDAO] Get all records error:', 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 mysqlService = await this.getMySqlService()
const sql = `
SELECT ID, MaterialCode, ManagerName
FROM ${MATERIALS_TO_BE_DELETED_CONFIG.TABLE_NAME_MYSQL}
WHERE ManagerName = ? AND MaterialCode IS NOT NULL
ORDER BY MaterialCode
`
const result = await mysqlService.query(sql, [managerName])
return result.rows.map(row => ({
id: row.ID as number,
materialCode: row.MaterialCode as string,
managerName: row.ManagerName as string
}))
} catch (error) {
console.error('[MaterialsToBeDeletedDAO] Get materials by manager error:', error)
return []
}
}
/**
* Get list of unique manager names
* @returns List of unique manager names
*/
async getManagers(): Promise<string[]> {
try {
const mysqlService = await this.getMySqlService()
const sql = `
SELECT DISTINCT ManagerName
FROM ${MATERIALS_TO_BE_DELETED_CONFIG.TABLE_NAME_MYSQL}
WHERE ManagerName IS NOT NULL
ORDER BY ManagerName
`
const result = await mysqlService.query(sql)
return result.rows.map(row => row.ManagerName as string).filter(Boolean)
} catch (error) {
console.error('[MaterialsToBeDeletedDAO] Get managers error:', 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 mysqlService = await this.getMySqlService()
const sql = `
SELECT ID, MaterialCode, ManagerName
FROM ${MATERIALS_TO_BE_DELETED_CONFIG.TABLE_NAME_MYSQL}
WHERE MaterialCode = ?
`
const result = await mysqlService.query(sql, [materialCode.trim()])
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) {
console.error('[MaterialsToBeDeletedDAO] Get record by material code error:', 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 mysqlService = await this.getMySqlService()
const sql = `
DELETE FROM ${MATERIALS_TO_BE_DELETED_CONFIG.TABLE_NAME_MYSQL}
WHERE MaterialCode = ?
`
const result = await mysqlService.query(sql, [materialCode.trim()])
return result.rowCount > 0
} catch (error) {
console.error('[MaterialsToBeDeletedDAO] Delete by material code error:', 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 mysqlService = await this.getMySqlService()
const sql = `
DELETE FROM ${MATERIALS_TO_BE_DELETED_CONFIG.TABLE_NAME_MYSQL}
WHERE ManagerName = ?
`
const result = await mysqlService.query(sql, [managerName])
return result.rowCount
} catch (error) {
console.error('[MaterialsToBeDeletedDAO] Delete by manager error:', error)
return 0
}
}
/**
* Delete all material records
* @returns Number of records deleted
*/
async deleteAllMaterials(): Promise<number> {
try {
const mysqlService = await this.getMySqlService()
const sql = `DELETE FROM ${MATERIALS_TO_BE_DELETED_CONFIG.TABLE_NAME_MYSQL}`
const result = await mysqlService.query(sql)
return result.rowCount
} catch (error) {
console.error('[MaterialsToBeDeletedDAO] Delete all materials error:', 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 mysqlService = await this.getMySqlService()
for (let i = 0; i < materialCodes.length; i += batchSize) {
const batch = materialCodes.slice(i, i + batchSize)
const placeholders = batch.map(() => '?').join(',')
const sql = `
DELETE FROM ${MATERIALS_TO_BE_DELETED_CONFIG.TABLE_NAME_MYSQL}
WHERE MaterialCode IN (${placeholders})
`
const result = await mysqlService.query(sql, batch)
totalDeleted += result.rowCount
}
} catch (error) {
console.error('[MaterialsToBeDeletedDAO] Delete by material codes error:', 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 mysqlService = await this.getMySqlService()
const sql = `
SELECT COUNT(*) as count
FROM ${MATERIALS_TO_BE_DELETED_CONFIG.TABLE_NAME_MYSQL}
WHERE MaterialCode = ?
`
const result = await mysqlService.query(sql, [materialCode.trim()])
return result.rows.length > 0 && (result.rows[0].count as number) > 0
} catch (error) {
console.error('[MaterialsToBeDeletedDAO] Material exists error:', error)
return false
}
}
/**
* Count all material records
* @returns Total number of records
*/
async countAll(): Promise<number> {
try {
const mysqlService = await this.getMySqlService()
const sql = `SELECT COUNT(*) as count FROM ${MATERIALS_TO_BE_DELETED_CONFIG.TABLE_NAME_MYSQL}`
const result = await mysqlService.query(sql)
return result.rows.length > 0 ? (result.rows[0].count as number) : 0
} catch (error) {
console.error('[MaterialsToBeDeletedDAO] Count all error:', 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 mysqlService = await this.getMySqlService()
const sql = `
SELECT COUNT(*) as count
FROM ${MATERIALS_TO_BE_DELETED_CONFIG.TABLE_NAME_MYSQL}
WHERE ManagerName = ?
`
const result = await mysqlService.query(sql, [managerName])
return result.rows.length > 0 ? (result.rows[0].count as number) : 0
} catch (error) {
console.error('[MaterialsToBeDeletedDAO] Count by manager error:', error)
return 0
}
}
/**
* Get comprehensive statistics
* @returns Statistics object
*/
async getStatistics(): Promise<MaterialStats> {
try {
const mysqlService = await this.getMySqlService()
// Get total and unique managers
const statsSql = `
SELECT
COUNT(*) as totalMaterials,
COUNT(DISTINCT ManagerName) as uniqueManagers
FROM ${MATERIALS_TO_BE_DELETED_CONFIG.TABLE_NAME_MYSQL}
WHERE MaterialCode IS NOT NULL
`
const statsResult = await mysqlService.query(statsSql)
const stats = statsResult.rows[0] || {}
// Get materials per manager
const managerSql = `
SELECT ManagerName, COUNT(*) as count
FROM ${MATERIALS_TO_BE_DELETED_CONFIG.TABLE_NAME_MYSQL}
WHERE ManagerName IS NOT NULL
GROUP BY ManagerName
ORDER BY count DESC
`
const managerResult = await mysqlService.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) {
console.error('[MaterialsToBeDeletedDAO] Get statistics error:', error)
return {
totalMaterials: 0,
uniqueManagers: 0,
materialsPerManager: []
}
}
}
/**
* Disconnect from database
*/
async disconnect(): Promise<void> {
if (this.mysqlService) {
await this.mysqlService.disconnect()
this.mysqlService = null
}
}
}

View File

@@ -78,17 +78,27 @@ export class MySqlService {
}
try {
const [rows, fields] = await this.connection.execute(sql, params)
const [result, fields] = await this.connection.execute(sql, params)
// Convert rows to plain objects and extract column names
const columns = fields.map((field) => field.name)
const rowCount = Array.isArray(rows) ? rows.length : 0
// Convert to plain objects and extract column names
const columns = Array.isArray(fields) ? fields.map((field) => field.name) : []
// Type assertion for rows - mysql2 returns different types based on query
const typedRows = Array.isArray(rows) ? (rows as Record<string, unknown>[]) : []
// 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: typedRows,
rows,
columns,
rowCount
}

View File

@@ -0,0 +1,185 @@
/**
* Settings types and interfaces
*
* Defines configuration structures for ERPAuto settings management
*/
/**
* User type for settings permission control
*/
export type UserType = 'Admin' | 'User' | 'Guest'
/**
* Database type selection
*/
export type DatabaseType = 'sqlserver' | 'mysql'
/**
* Validation match mode
*/
export type MatchMode = 'substring' | 'exact'
/**
* Validation data source options
*/
export type ValidationDataSource = 'database_full' | 'database_filtered' | 'excel_existing' | 'excel_full'
/**
* ERP configuration
*/
export interface ErpConfig {
/** ERP system URL */
url: string
/** ERP username */
username: string
/** ERP password */
password: string
/** Headless browser mode */
headless: boolean
/** Ignore HTTPS certificate errors */
ignoreHttpsErrors: boolean
/** Auto close browser after operations */
autoCloseBrowser: boolean
}
/**
* Database configuration
*/
export interface DatabaseConfig {
/** Database type */
dbType: DatabaseType
/** SQL Server server address */
server: string
/** MySQL host */
mysqlHost: string
/** MySQL port */
mysqlPort: number
/** Database name */
database: string
/** Database username */
username: string
/** Database password */
password: string
}
/**
* Path configuration
*/
export interface PathsConfig {
/** Data directory */
dataDir: string
/** Default output file */
defaultOutput: string
/** Validation output file */
validationOutput: string
}
/**
* Data extraction configuration
*/
export interface ExtractionConfig {
/** Batch size for data extraction */
batchSize: number
/** Enable verbose logging */
verbose: boolean
/** Auto convert to Excel */
autoConvert: boolean
/** Merge batches */
mergeBatches: boolean
/** Enable database persistence */
enableDbPersistence: boolean
}
/**
* Material validation configuration
*/
export interface ValidationConfig {
/** Data source type */
dataSource: ValidationDataSource
/** Batch size for validation */
batchSize: number
/** Match mode */
matchMode: MatchMode
/** Enable CRUD operations */
enableCrud: boolean
/** Default manager name */
defaultManager: string
}
/**
* UI configuration
*/
export interface UiConfig {
/** Font family */
fontFamily: string
/** Font size */
fontSize: number
/** Production ID input width */
productionIdInputWidth: number
}
/**
* Execution configuration (User-only)
*/
export interface ExecutionConfig {
/** Dry run mode */
dryRun: boolean
}
/**
* Complete settings data structure
*/
export interface SettingsData {
/** ERP configuration */
erp: ErpConfig
/** Database configuration */
database: DatabaseConfig
/** Path configuration */
paths: PathsConfig
/** Extraction configuration */
extraction: ExtractionConfig
/** Validation configuration */
validation: ValidationConfig
/** UI configuration */
ui: UiConfig
/** Execution configuration */
execution: ExecutionConfig
}
/**
* Connection test result
*/
export interface ConnectionTestResult {
/** Whether connection was successful */
success: boolean
/** Status message */
message?: string
}
/**
* Save settings result
*/
export interface SaveSettingsResult {
/** Whether save was successful */
success: boolean
/** Error message if failed */
error?: string
}
/**
* Settings API interface for preload
*/
export interface SettingsAPI {
/** Get current user type */
getUserType: () => Promise<UserType>
/** Get settings (filtered by user type) */
getSettings: () => Promise<SettingsData>
/** Save settings */
saveSettings: (settings: SettingsData) => Promise<SaveSettingsResult>
/** Reset to defaults (Admin only) */
resetDefaults: () => Promise<SaveSettingsResult>
/** Test ERP connection */
testErpConnection: () => Promise<ConnectionTestResult>
/** Test database connection */
testDbConnection: () => Promise<ConnectionTestResult>
}

View File

@@ -0,0 +1,105 @@
/**
* Validation IPC API type definitions
*/
/**
* Material validation result
*/
export interface ValidationResult {
materialName: string
materialCode: string
specification: string
model: string
managerName: string
isMarkedForDeletion: boolean
matchedTypeKeyword?: string
}
/**
* Material validation request
*/
export interface ValidationRequest {
/** Validation mode: 'database_full' or 'database_filtered' */
mode: 'database_full' | 'database_filtered'
/** Production ID file path (for database_filtered mode) */
productionIdFile?: string
/** Use shared Production IDs from extractor page */
useSharedProductionIds?: boolean
}
/**
* Validation response
*/
export interface ValidationResponse {
success: boolean
results?: ValidationResult[]
error?: string
stats?: {
totalRecords: number
matchedCount: number
markedCount: number
}
}
/**
* Material upsert request
*/
export interface MaterialUpsertRequest {
materialCode: string
managerName: string
}
/**
* Material upsert batch request
*/
export interface MaterialUpsertBatchRequest {
materials: { materialCode: string; managerName: string }[]
}
/**
* Material delete request
*/
export interface MaterialDeleteRequest {
materialCodes: string[]
}
/**
* Material query by manager request
*/
export interface MaterialQueryByManagerRequest {
managerName: string
}
/**
* Material operations response
*/
export interface MaterialOperationResponse {
success: boolean
count?: number
stats?: {
total: number
success: number
failed: number
}
error?: string
}
/**
* Manager filter response
*/
export interface ManagerFilterResponse {
managers: string[]
materials?: MaterialRecordSummary[]
}
/**
* Material record summary
*/
export interface MaterialRecordSummary {
materialCode: string
materialName: string
specification: string
model: string
managerName: string
isMarked: boolean
}

107
src/preload/index.d.ts vendored
View File

@@ -8,6 +8,8 @@ import type {
UserSelectionResponse,
CurrentUserResponse
} from '../main/ipc/auth-handler'
import type { ValidationRequest, ValidationResponse } from '../main/types/validation.types'
import type { SettingsData, UserType, ConnectionTestResult, SaveSettingsResult } from '../main/types/settings.types'
/**
* Order number resolver API
@@ -69,6 +71,108 @@ export interface AuthAPI {
isAdmin: () => Promise<boolean>
}
/**
* Validation API
*/
export interface ValidationAPI {
/**
* Run material validation
* @param request - Validation request
*/
validate: (request: ValidationRequest) => Promise<ValidationResponse>
/**
* Set shared Production IDs from extractor page
* @param productionIds - List of Production IDs
*/
setSharedProductionIds: (productionIds: string[]) => Promise<void>
/**
* Get shared Production IDs
*/
getSharedProductionIds: () => Promise<{ productionIds: string[] }>
/**
* 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)
*/
getCleanerData: () => Promise<{
success: boolean
orderNumbers?: string[]
materialCodes?: string[]
error?: string
}>
}
/**
* Materials API
*/
export interface MaterialsAPI {
/**
* Upsert batch materials to MaterialsToBeDeleted
* @param materials - List of materials with materialCode and managerName
*/
upsertBatch: (materials: { materialCode: string; managerName: string }[]) => Promise<{
success: boolean
stats?: { total: number; success: number; failed: number }
error?: string
}>
/**
* Delete materials by material codes
* @param materialCodes - List of material codes to delete
*/
delete: (materialCodes: string[]) => Promise<{
success: boolean
count?: number
error?: string
}>
/**
* Get unique manager names
*/
getManagers: () => Promise<{ managers: string[] }>
/**
* Get materials by manager
* @param managerName - Manager name
*/
getByManager: (managerName: string) => Promise<{ materials: unknown[] }>
/**
* Get all material records
*/
getAll: () => Promise<{ materials: unknown[] }>
/**
* Get statistics
*/
getStatistics: () => Promise<{ stats: unknown }>
}
/**
* Settings API
*/
export interface SettingsAPI {
/**
* Get current user type
*/
getUserType: () => Promise<UserType>
/**
* Get settings (filtered by user type)
*/
getSettings: () => Promise<SettingsData>
/**
* Save settings
* @param settings - Settings data to save
*/
saveSettings: (settings: SettingsData) => Promise<SaveSettingsResult>
/**
* Reset to defaults (Admin only)
*/
resetDefaults: () => Promise<SaveSettingsResult>
/**
* Test ERP connection
*/
testErpConnection: () => Promise<ConnectionTestResult>
/**
* Test database connection
*/
testDbConnection: () => Promise<ConnectionTestResult>
}
declare global {
interface Window {
electron: {
@@ -93,6 +197,9 @@ declare global {
database: DatabaseAPI
resolver: ResolverAPI
auth: AuthAPI
validation: ValidationAPI
materials: MaterialsAPI
settings: SettingsAPI
}
api: unknown
}

View File

@@ -6,6 +6,8 @@ import type { CleanerInput } from '../main/types/cleaner.types'
import type { ResolverInput } from '../main/ipc/resolver-handler'
import type { LoginRequest } from '../main/ipc/auth-handler'
import type { UserInfo } from '../main/types/user.types'
import type { ValidationRequest } from '../main/types/validation.types'
import type { SettingsData } from '../main/types/settings.types'
// Custom APIs for renderer
const api = {
@@ -61,6 +63,40 @@ const api = {
isSqlServerConnected: () => ipcRenderer.invoke('database:sqlserver:isConnected'),
querySqlServer: (sql: string, params?: Record<string, unknown>) =>
ipcRenderer.invoke('database:sqlserver:query', sql, params)
},
// Validation service
validation: {
validate: (request: ValidationRequest) => ipcRenderer.invoke('validation:validate', request),
setSharedProductionIds: (productionIds: string[]) =>
ipcRenderer.invoke('validation:setSharedProductionIds', productionIds),
getSharedProductionIds: () =>
ipcRenderer.invoke('validation:getSharedProductionIds'),
getCleanerData: () =>
ipcRenderer.invoke('validation:getCleanerData')
},
// Materials service
materials: {
upsertBatch: (materials: { materialCode: string; managerName: string }[]) =>
ipcRenderer.invoke('materials:upsertBatch', { materials }),
delete: (materialCodes: string[]) =>
ipcRenderer.invoke('materials:delete', { materialCodes }),
getManagers: () => ipcRenderer.invoke('materials:getManagers'),
getByManager: (managerName: string) =>
ipcRenderer.invoke('materials:getByManager', managerName),
getAll: () => ipcRenderer.invoke('materials:getAll'),
getStatistics: () => ipcRenderer.invoke('materials:getStatistics')
},
// Settings service
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')
}
} as const

View File

@@ -13,8 +13,9 @@ import LoginDialog from './components/LoginDialog'
import UserSelectionDialog, { type UserInfo as SelectedUserInfo } from './components/UserSelectionDialog'
import { ExtractorPage } from './pages/ExtractorPage'
import { CleanerPage } from './pages/CleanerPage'
import SettingsPage from './pages/SettingsPage'
type Page = 'home' | 'extractor' | 'cleaner'
type Page = 'home' | 'extractor' | 'cleaner' | 'settings'
interface CurrentUser {
username: string
@@ -290,7 +291,7 @@ function App(): React.JSX.Element {
minHeight: '100vh',
backgroundColor: '#f5f7fa'
}}>
{/* Header with user info and logout */}
{/* Header with user info, navigation and logout */}
<header style={{
display: 'flex',
justifyContent: 'space-between',
@@ -300,13 +301,76 @@ function App(): React.JSX.Element {
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
marginBottom: '16px'
}}>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '24px' }}>
<span style={{
fontSize: '14px',
color: '#666'
}}>
{currentUser?.username} ({currentUser?.userType})
</span>
{/* Navigation tabs */}
<nav style={{ display: 'flex', gap: '8px' }}>
<button
onClick={() => setCurrentPage('home')}
style={{
padding: '8px 16px',
backgroundColor: currentPage === 'home' ? '#1890ff' : '#f5f5f5',
color: currentPage === 'home' ? '#fff' : '#666',
border: 'none',
borderRadius: '6px',
fontSize: '14px',
cursor: 'pointer',
transition: 'all 0.3s'
}}
>
</button>
<button
onClick={() => setCurrentPage('extractor')}
style={{
padding: '8px 16px',
backgroundColor: currentPage === 'extractor' ? '#1890ff' : '#f5f5f5',
color: currentPage === 'extractor' ? '#fff' : '#666',
border: 'none',
borderRadius: '6px',
fontSize: '14px',
cursor: 'pointer',
transition: 'all 0.3s'
}}
>
</button>
<button
onClick={() => setCurrentPage('cleaner')}
style={{
padding: '8px 16px',
backgroundColor: currentPage === 'cleaner' ? '#1890ff' : '#f5f5f5',
color: currentPage === 'cleaner' ? '#fff' : '#666',
border: 'none',
borderRadius: '6px',
fontSize: '14px',
cursor: 'pointer',
transition: 'all 0.3s'
}}
>
</button>
<button
onClick={() => setCurrentPage('settings')}
style={{
padding: '8px 16px',
backgroundColor: currentPage === 'settings' ? '#1890ff' : '#f5f5f5',
color: currentPage === 'settings' ? '#fff' : '#666',
border: 'none',
borderRadius: '6px',
fontSize: '14px',
cursor: 'pointer',
transition: 'all 0.3s'
}}
>
</button>
</nav>
</div>
<div>
{shouldShowLogout && (
@@ -330,39 +394,15 @@ function App(): React.JSX.Element {
{currentPage === 'home' && (
<div>
<h1 style={{ fontSize: '24px', color: '#333', marginBottom: '24px' }}></h1>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
gap: '16px'
}}>
<div onClick={() => setCurrentPage('extractor')} style={{
padding: '24px',
backgroundColor: '#fff',
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
cursor: 'pointer',
textAlign: 'center'
}}>
<h3 style={{ margin: '0 0 8px 0', color: '#1890ff' }}></h3>
<p style={{ margin: 0, color: '#666', fontSize: '14px' }}> ERP </p>
</div>
<div onClick={() => setCurrentPage('cleaner')} style={{
padding: '24px',
backgroundColor: '#fff',
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
cursor: 'pointer',
textAlign: 'center'
}}>
<h3 style={{ margin: '0 0 8px 0', color: '#52c41a' }}></h3>
<p style={{ margin: 0, color: '#666', fontSize: '14px' }}> ERP </p>
</div>
</div>
<p style={{ color: '#666', fontSize: '14px' }}>
使
</p>
</div>
)}
{currentPage === 'extractor' && <ExtractorPage />}
{currentPage === 'cleaner' && <CleanerPage />}
{currentPage === 'settings' && <SettingsPage />}
</div>
</div>
)

View File

@@ -1,64 +1,380 @@
import React, { useState } from 'react'
import { OrderNumberInput } from '../components/OrderNumberInput'
import { MaterialCodeInput } from '../components/MaterialCodeInput'
import React, { useState, useCallback, useEffect } from 'react'
interface CleanerResult {
ordersProcessed: number
materialsDeleted: number
materialsSkipped: number
errors: string[]
details: Array<{
orderNumber: string
materialsDeleted: number
materialsSkipped: number
errors: string[]
}>
}
interface CleanerProgress {
message: string
progress?: number
/**
* Material validation result interface
*/
interface ValidationResult {
materialName: string
materialCode: string
specification: string
model: string
managerName: string
isMarkedForDeletion: boolean
matchedTypeKeyword?: string
}
/**
* CleanerPage - Main page for ERP material cleaning
* CleanerPage with material validation functionality
*
* This component provides the same functionality as the Python MaterialValidationTab:
* - Material validation from database (full table or filtered by ProductionID)
* - Checkbox selection for materials to delete
* - Manager-based filtering (Admin only)
* - Two-phase deletion: confirm (mark in DB) + execute (delete from ERP)
*/
export const CleanerPage: React.FC = () => {
const [orderNumbers, setOrderNumbers] = useState('')
const [materialCodes, setMaterialCodes] = useState('')
const [dryRun, setDryRun] = useState(true)
// State with sessionStorage persistence
const [dryRun] = useState(() => {
const saved = sessionStorage.getItem('cleaner_dryRun')
return saved ? saved === 'true' : true
})
const [validationMode, setValidationMode] = useState<'database_full' | 'database_filtered'>(() => {
const saved = sessionStorage.getItem('cleaner_validationMode') as 'database_full' | 'database_filtered'
return saved || 'database_filtered'
})
const [useSharedProductionIds, setUseSharedProductionIds] = useState(() => {
const saved = sessionStorage.getItem('cleaner_useSharedProductionIds')
return saved ? saved === 'true' : true
})
const [isRunning, setIsRunning] = useState(false)
const [progress, setProgress] = useState<CleanerProgress | null>(null)
const [result, setResult] = useState<CleanerResult | null>(null)
const [progress, setProgress] = useState<{ message: string; progress?: number } | null>(null)
const [result, setResult] = useState<{
ordersProcessed: number
materialsDeleted: number
materialsSkipped: number
errors: string[]
details: Array<{
orderNumber: string
materialsDeleted: number
materialsSkipped: number
errors: string[]
}>
} | null>(null)
const [error, setError] = useState<string | null>(null)
const handleClean = async () => {
if (!orderNumbers.trim()) {
setError('请输入至少一个订单号')
// Validation state
const [validationResults, setValidationResults] = useState<ValidationResult[]>([])
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set())
const [managers, setManagers] = useState<string[]>([])
const [selectedManagers, setSelectedManagers] = useState<Set<string>>(new Set())
const [isAdmin, setIsAdmin] = useState(false)
const [currentUsername, setCurrentUsername] = useState<string>('')
const [isValidationRunning, setIsValidationRunning] = useState(false)
const [validationStats, setValidationStats] = useState<{
totalRecords: number
matchedCount: number
markedCount: number
} | null>(null)
// Hidden items state (for user mode hide checked feature)
const [hiddenItems, setHiddenItems] = useState<Set<string>>(new Set())
// Filter validation results by selected managers and hidden items
const filteredResults = React.useMemo(() => {
let results = validationResults
if (!isAdmin && currentUsername) {
// Non-admin users only see their own data
results = results.filter(r => r.managerName === currentUsername || !r.managerName)
} else if (managers.length > 0 && selectedManagers.size > 0) {
// Admin users see selected managers' data
results = results.filter(
r => selectedManagers.has(r.managerName) || !r.managerName
)
}
// Filter out hidden items
results = results.filter(r => !hiddenItems.has(r.materialCode))
return results
}, [validationResults, isAdmin, currentUsername, managers, selectedManagers, hiddenItems])
useEffect(() => {
sessionStorage.setItem('cleaner_dryRun', dryRun.toString())
}, [dryRun])
useEffect(() => {
sessionStorage.setItem('cleaner_validationMode', validationMode)
}, [validationMode])
useEffect(() => {
sessionStorage.setItem('cleaner_useSharedProductionIds', useSharedProductionIds.toString())
}, [useSharedProductionIds])
// Shared Production IDs state
const [sharedProductionIdsCount, setSharedProductionIdsCount] = useState(0)
// Check admin status and get shared Production IDs on mount
React.useEffect(() => {
const initializePage = async () => {
const admin = await window.electron.auth.isAdmin()
const user = await window.electron.auth.getCurrentUser()
setIsAdmin(admin)
if (user && user.userInfo) {
setCurrentUsername(user.userInfo.username)
if (!admin) {
// Non-admin users can only see their own data
setSelectedManagers(new Set([user.userInfo.username]))
}
}
// Get shared Production IDs
try {
const result = await window.electron.validation.getSharedProductionIds()
setSharedProductionIdsCount(result.productionIds.length)
} catch (err) {
console.error('Failed to get shared Production IDs:', err)
}
}
initializePage()
}, [])
// Load managers for filter (Admin only)
const loadManagers = useCallback(async () => {
if (!isAdmin) return
try {
const response = await window.electron.materials.getManagers()
setManagers(response.managers)
// Select all managers by default
setSelectedManagers(new Set(response.managers))
} catch (err) {
console.error('Failed to load managers:', err)
}
}, [isAdmin])
// Handle validation
const handleValidation = useCallback(async () => {
setIsValidationRunning(true)
setError(null)
setValidationResults([])
setSelectedItems(new Set())
setValidationStats(null)
try {
const response = await window.electron.validation.validate({
mode: validationMode,
useSharedProductionIds: validationMode === 'database_filtered' ? useSharedProductionIds : undefined
})
if (response.success && response.results) {
setValidationResults(response.results)
setValidationStats(response.stats || null)
// Auto-select items that are already marked for deletion
const markedCodes = new Set(
response.results
.filter(r => r.isMarkedForDeletion)
.map(r => r.materialCode)
)
setSelectedItems(markedCodes)
// Auto-select managers for admin users
if (isAdmin) {
const uniqueManagers = new Set(
response.results
.map(r => r.managerName)
.filter(Boolean)
)
setManagers([...uniqueManagers])
setSelectedManagers(uniqueManagers)
}
} else {
setError(response.error || '校验失败')
}
} catch (err) {
setError(err instanceof Error ? err.message : '校验过程中发生未知错误')
} finally {
setIsValidationRunning(false)
}
}, [validationMode, useSharedProductionIds, isAdmin])
// Handle checkbox toggle
const handleCheckboxToggle = useCallback((materialCode: string) => {
setSelectedItems(prev => {
const newSet = new Set(prev)
if (newSet.has(materialCode)) {
newSet.delete(materialCode)
} else {
newSet.add(materialCode)
}
return newSet
})
}, [])
// Handle select all
const handleSelectAll = useCallback(() => {
setSelectedItems(new Set(filteredResults.map(r => r.materialCode)))
}, [filteredResults])
// Handle deselect all
const handleDeselectAll = useCallback(() => {
setSelectedItems(new Set())
}, [])
// Handle confirm deletion (mark materials in database)
const handleConfirmDeletion = useCallback(async () => {
if (validationResults.length === 0) {
alert('没有可处理的数据')
return
}
if (!materialCodes.trim()) {
setError('请输入至少一个物料代码')
// Collect all data
const materialsToUpsert: { materialCode: string; managerName: string }[] = []
const materialsToDelete: string[] = []
const missingManager: string[] = []
for (const result of validationResults) {
// Skip empty material codes
if (!result.materialCode || !result.materialCode.trim()) {
continue
}
const materialCode = result.materialCode.trim()
const isChecked = selectedItems.has(materialCode)
if (isChecked) {
// Checked: needs to be upserted
if (!result.managerName || !result.managerName.trim()) {
missingManager.push(materialCode)
} else {
materialsToUpsert.push({
materialCode,
managerName: result.managerName.trim()
})
}
} else {
// Unchecked: needs to be deleted
materialsToDelete.push(materialCode)
}
}
// Validate: checked items must have manager names
if (missingManager.length > 0) {
const msg = `以下已勾选的记录缺少负责人信息,无法保存:\n\n` +
missingManager.slice(0, 10).join('\n') +
(missingManager.length > 10 ? `\n... 共 ${missingManager.length}` : '')
alert(msg)
return
}
// Check if there's anything to do
if (materialsToUpsert.length === 0 && materialsToDelete.length === 0) {
alert('没有需要处理的记录')
return
}
// Build confirmation message
const confirmParts: string[] = []
if (materialsToUpsert.length > 0) {
confirmParts.push(`写入/更新 ${materialsToUpsert.length} 条记录`)
}
if (materialsToDelete.length > 0) {
confirmParts.push(`删除 ${materialsToDelete.length} 条记录`)
}
const confirmed = window.confirm(`确认以下操作吗?\n\n${confirmParts.join('\n')}`)
if (!confirmed) {
return
}
// Execute the operation
try {
setIsRunning(true)
// Upsert selected materials
let upsertSuccess = 0
let upsertFailed = 0
if (materialsToUpsert.length > 0) {
const upsertResult = await window.electron.materials.upsertBatch(materialsToUpsert)
if (upsertResult.success) {
upsertSuccess = upsertResult.stats?.success || 0
upsertFailed = upsertResult.stats?.failed || 0
} else {
throw new Error(upsertResult.error || '写入物料失败')
}
}
// Delete unselected materials
let deleteCount = 0
if (materialsToDelete.length > 0) {
const deleteResult = await window.electron.materials.delete(materialsToDelete)
if (deleteResult.success) {
deleteCount = deleteResult.count || 0
} else {
throw new Error(deleteResult.error || '删除物料失败')
}
}
// Show result message
const msgParts: string[] = []
if (upsertSuccess > 0) {
msgParts.push(`写入/更新成功:${upsertSuccess}`)
}
if (upsertFailed > 0) {
msgParts.push(`写入/更新失败:${upsertFailed}`)
}
if (deleteCount > 0) {
msgParts.push(`删除成功:${deleteCount}`)
}
const msg = `操作完成!\n\n${msgParts.join('\n')}`
if (upsertFailed > 0) {
alert(`完成(部分失败)\n\n${msg}`)
} else {
alert(msg)
}
// Reload managers to reflect changes
await loadManagers()
} catch (err) {
setError(err instanceof Error ? err.message : '操作失败')
} finally {
setIsRunning(false)
}
}, [selectedItems, validationResults, loadManagers])
// Handle execute deletion (清理)
const handleExecuteDeletion = useCallback(async () => {
if (!dryRun) {
const confirmed = window.confirm('警告:正式执行将删除 ERP 系统中的物料数据,是否继续?')
if (!confirmed) return
}
setIsRunning(true)
setProgress(null)
setResult(null)
setError(null)
try {
const orderNumberList = orderNumbers
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0)
// Get cleaner data from backend (order numbers from shared Production IDs + material codes from DB)
const cleanerDataResult = await window.electron.validation.getCleanerData()
const materialCodeList = materialCodes
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0)
if (!cleanerDataResult.success) {
setError(cleanerDataResult.error || '获取清理数据失败')
setIsRunning(false)
return
}
const orderNumberList = cleanerDataResult.orderNumbers || []
const materialCodeList = cleanerDataResult.materialCodes || []
if (orderNumberList.length === 0) {
setError('没有订单号数据。请先到数据提取页面输入 Production ID。')
setIsRunning(false)
return
}
if (materialCodeList.length === 0) {
setError('没有物料代码数据。请确认已在物料清理界面勾选要删除的物料。')
setIsRunning(false)
return
}
console.log('[CleanerPage] Starting cleaner with:', {
orderNumbers: orderNumberList.length,
materialCodes: materialCodeList.length,
dryRun
})
// Call cleaner API through electron
const response = await window.electron.cleaner.runCleaner({
orderNumbers: orderNumberList,
materialCodes: materialCodeList,
@@ -76,64 +392,228 @@ export const CleanerPage: React.FC = () => {
setIsRunning(false)
setProgress(null)
}
}
}, [dryRun])
const handleReset = () => {
setOrderNumbers('')
setMaterialCodes('')
setDryRun(true)
setResult(null)
setError(null)
setProgress(null)
}
// Handle hide checked items (for user mode)
const handleHideChecked = useCallback(() => {
const checkedCodes = filteredResults
.filter(r => selectedItems.has(r.materialCode))
.map(r => r.materialCode)
if (checkedCodes.length === 0) {
setError('没有已勾选的物料')
return
}
setHiddenItems(prev => new Set([...prev, ...checkedCodes]))
}, [filteredResults, selectedItems])
// Handle show all items
const handleShowAll = useCallback(() => {
setHiddenItems(new Set())
}, [])
return (
<div className="cleaner-page">
<h1 className="page-title">ERP </h1>
<div className="cleaner-content">
{/* Input Section */}
<div className="input-section">
<OrderNumberInput
value={orderNumbers}
onChange={setOrderNumbers}
label="订单号列表"
placeholder="请输入订单号,每行一个"
/>
{/* Validation Section (Admin only for mode selection) */}
<div className="validation-section">
<h2></h2>
<MaterialCodeInput
value={materialCodes}
onChange={setMaterialCodes}
label="物料代码列表"
placeholder="请输入物料代码,每行一个"
/>
{isAdmin && (
<div className="validation-mode-selector">
<label>
<input
type="radio"
checked={validationMode === 'database_full'}
onChange={() => setValidationMode('database_full')}
disabled={isValidationRunning}
/>
-
</label>
<label>
<input
type="radio"
checked={validationMode === 'database_filtered'}
onChange={() => setValidationMode('database_filtered')}
disabled={isValidationRunning}
/>
- ProductionID
</label>
{validationMode === 'database_filtered' && (
<div style={{ marginTop: '8px', marginLeft: '24px' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<input
type="checkbox"
checked={useSharedProductionIds}
onChange={(e) => setUseSharedProductionIds(e.target.checked)}
disabled={isValidationRunning}
/>
使 Production ID
</label>
{useSharedProductionIds && (
<p style={{ margin: '4px 0 0 0', fontSize: '12px', color: sharedProductionIdsCount > 0 ? '#52c41a' : '#fa8c16' }}>
{sharedProductionIdsCount > 0
? `当前有 ${sharedProductionIdsCount} 个共享的 Production ID`
: '暂无共享的 Production ID请先在数据提取页面输入'}
</p>
)}
</div>
)}
</div>
)}
<div className="dry-run-option">
<label>
<input
type="checkbox"
checked={dryRun}
onChange={(e) => setDryRun(e.target.checked)}
disabled={isRunning}
/>
<span></span>
</label>
</div>
{!isAdmin && (
<p style={{ fontSize: '14px', color: '#666', marginBottom: '16px' }}>
- ProductionID 使
</p>
)}
<div className="button-group">
<button
className="btn btn-primary"
onClick={handleClean}
disabled={isRunning || !orderNumbers.trim() || !materialCodes.trim()}
onClick={handleValidation}
disabled={isValidationRunning}
>
{isRunning ? '清理中...' : '开始清理'}
</button>
<button className="btn btn-secondary" onClick={handleReset} disabled={isRunning}>
{isValidationRunning ? '校验中...' : '开始校验'}
</button>
{isAdmin && (
<>
<button
className="btn btn-secondary"
onClick={handleSelectAll}
disabled={filteredResults.length === 0}
>
</button>
<button
className="btn btn-secondary"
onClick={handleDeselectAll}
disabled={selectedItems.size === 0}
>
</button>
</>
)}
</div>
{validationStats && (
<div className="validation-stats">
<span>{validationStats.totalRecords}</span>
<span>{validationStats.matchedCount}</span>
<span>{validationStats.markedCount}</span>
</div>
)}
</div>
{/* Manager Filter (Admin only) */}
{isAdmin && managers.length > 0 && (
<div className="manager-filter-section">
<h3></h3>
<div className="manager-checkboxes">
{managers.map(manager => (
<label key={manager}>
<input
type="checkbox"
checked={selectedManagers.has(manager)}
onChange={(e) => {
setSelectedManagers(prev => {
const newSet = new Set(prev)
if (e.target.checked) {
newSet.add(manager)
} else {
newSet.delete(manager)
}
return newSet
})
}}
/>
{manager}
</label>
))}
</div>
</div>
)}
{/* Validation Results Table */}
{filteredResults.length > 0 && (
<div className="validation-results-section">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
<h2 style={{ margin: 0 }}></h2>
{/* Action buttons: left group (Hide Checked, Show All, Confirm Delete) + right button (Execute Delete in red) */}
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<div style={{ display: 'flex', gap: '8px' }}>
{!isAdmin && (
<>
<button
className="btn btn-secondary"
onClick={handleHideChecked}
disabled={selectedItems.size === 0}
>
</button>
<button
className="btn btn-secondary"
onClick={handleShowAll}
disabled={hiddenItems.size === 0}
>
</button>
</>
)}
<button
className="btn btn-warning"
onClick={handleConfirmDeletion}
disabled={selectedItems.size === 0 || isRunning}
>
</button>
</div>
<button
className="btn btn-danger"
onClick={handleExecuteDeletion}
disabled={isRunning || validationResults.length === 0}
style={{ marginLeft: '16px' }}
>
</button>
</div>
</div>
<table className="validation-results-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{filteredResults.map(result => (
<tr key={result.materialCode}>
<td>
<input
type="checkbox"
checked={selectedItems.has(result.materialCode)}
onChange={() => handleCheckboxToggle(result.materialCode)}
/>
</td>
<td>{result.materialName}</td>
<td>{result.materialCode}</td>
<td>{result.specification}</td>
<td>{result.model}</td>
<td>{result.managerName || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Progress Section */}
{progress && (
<div className="progress-section">
@@ -150,7 +630,13 @@ export const CleanerPage: React.FC = () => {
{error && (
<div className="error-section">
<h3></h3>
<p>{error}</p>
<div className="error-message" title="双击可选中复制" onDoubleClick={(e) => {
const text = e.currentTarget.textContent
navigator.clipboard.writeText(text || '')
}}>
{error}
</div>
<p className="error-hint">💡 </p>
</div>
)}
@@ -226,7 +712,7 @@ export const CleanerPage: React.FC = () => {
<style>{`
.cleaner-page {
padding: 24px;
max-width: 800px;
max-width: 1200px;
margin: 0 auto;
}
.page-title {
@@ -241,16 +727,23 @@ export const CleanerPage: React.FC = () => {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
padding: 24px;
}
.input-section {
.validation-section, .manager-filter-section {
margin-bottom: 24px;
padding-bottom: 24px;
border-bottom: 1px solid #e8e8e8;
}
.dry-run-option {
.validation-section h2, .manager-filter-section h3 {
font-size: 18px;
color: #333;
margin-bottom: 16px;
padding: 12px;
background: #f5f5f5;
border-radius: 6px;
}
.dry-run-option label {
.validation-mode-selector {
margin-bottom: 16px;
display: flex;
flex-direction: column;
gap: 8px;
}
.validation-mode-selector label {
display: flex;
align-items: center;
gap: 8px;
@@ -258,17 +751,10 @@ export const CleanerPage: React.FC = () => {
font-size: 14px;
color: #666;
}
.dry-run-option input[type="checkbox"] {
width: 16px;
height: 16px;
cursor: pointer;
}
.dry-run-option input:disabled {
cursor: not-allowed;
}
.button-group {
display: flex;
gap: 12px;
margin-bottom: 16px;
}
.btn {
padding: 10px 24px;
@@ -297,6 +783,80 @@ export const CleanerPage: React.FC = () => {
.btn-secondary:hover:not(:disabled) {
background: #e8e8e8;
}
.btn-warning {
background: #fa8c16;
color: #fff;
}
.btn-warning:hover:not(:disabled) {
background: #ffc069;
}
.btn-danger {
background: #ff4d4f;
color: #fff;
}
.btn-danger:hover:not(:disabled) {
background: #ff7875;
}
.validation-stats {
display: flex;
gap: 24px;
padding: 12px;
background: #f6ffed;
border: 1px solid #b7eb8f;
border-radius: 6px;
font-size: 14px;
color: #52c41a;
}
.validation-stats span {
font-weight: 500;
}
.manager-filter-section {
background: #fafafa;
padding: 16px;
border-radius: 6px;
}
.manager-checkboxes {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.manager-checkboxes label {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
font-size: 14px;
color: #666;
background: #fff;
padding: 6px 12px;
border-radius: 4px;
border: 1px solid #d9d9d9;
}
.validation-results-section {
margin-bottom: 24px;
}
.validation-results-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.validation-results-table th, .validation-results-table td {
padding: 12px;
border: 1px solid #e8e8e8;
text-align: left;
color: #000;
}
.validation-results-table th {
background: #fafafa;
font-weight: 600;
color: #333;
}
.validation-results-table tr:nth-child(even) {
background: #fafafa;
}
.validation-results-table tr:hover {
background: #e6f7ff;
}
.progress-section {
margin-top: 24px;
padding: 16px;
@@ -332,9 +892,27 @@ export const CleanerPage: React.FC = () => {
margin: 0 0 8px 0;
font-size: 16px;
}
.error-section p {
color: #666;
margin: 0;
.error-message {
background: #fff;
padding: 12px;
border-radius: 4px;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 13px;
color: #333;
border: 1px solid #ffccc7;
user-select: text;
-webkit-user-select: text;
cursor: text;
white-space: pre-wrap;
word-break: break-all;
}
.error-message:hover {
background: #fffbfc;
}
.error-hint {
color: #999;
font-size: 12px;
margin: 8px 0 0 0;
}
.result-section {
margin-top: 24px;
@@ -434,6 +1012,11 @@ export const CleanerPage: React.FC = () => {
font-size: 13px;
color: #fa8c16;
}
@media (max-width: 768px) {
.input-row {
grid-template-columns: 1fr;
}
}
`}</style>
</div>
)

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react'
import React, { useState, useEffect } from 'react'
import { OrderNumberInput } from '../components/OrderNumberInput'
// Extractor result type (matches the type from main process)
@@ -18,12 +18,60 @@ interface ExtractorProgress {
* ExtractorPage - Main page for ERP data extraction
*/
export const ExtractorPage: React.FC = () => {
const [orderNumbers, setOrderNumbers] = useState('')
const [batchSize, setBatchSize] = useState(100)
const [orderNumbers, setOrderNumbers] = useState(() => {
// Restore from sessionStorage on mount
return sessionStorage.getItem('extractor_orderNumbers') || ''
})
const [batchSize, setBatchSize] = useState(() => {
const saved = sessionStorage.getItem('extractor_batchSize')
return saved ? parseInt(saved, 10) : 100
})
const [isRunning, setIsRunning] = useState(false)
const [progress, setProgress] = useState<ExtractorProgress | null>(null)
const [result, setResult] = useState<ExtractorResult | null>(null)
const [error, setError] = useState<string | null>(null)
const [saveStatus, setSaveStatus] = useState<'saved' | 'unsaved' | 'saving'>('unsaved')
// Save to sessionStorage when orderNumbers changes
useEffect(() => {
sessionStorage.setItem('extractor_orderNumbers', orderNumbers)
// Update shared Production IDs when orderNumbers changes
if (orderNumbers.trim()) {
const orderNumberList = orderNumbers
.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0)
window.electron.validation.setSharedProductionIds(orderNumberList)
}
}, [orderNumbers])
// Save to sessionStorage when batchSize changes
useEffect(() => {
sessionStorage.setItem('extractor_batchSize', batchSize.toString())
}, [batchSize])
const saveSharedProductionIds = async () => {
if (!orderNumbers.trim()) {
setError('请输入至少一个订单号')
return
}
setSaveStatus('saving')
try {
const orderNumberList = orderNumbers
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0)
await window.electron.validation.setSharedProductionIds(orderNumberList)
console.log(`[Extractor] Stored ${orderNumberList.length} Production IDs for sharing`)
setSaveStatus('saved')
setError(null)
} catch (err) {
setError(err instanceof Error ? err.message : '保存失败')
setSaveStatus('unsaved')
}
}
const handleExtract = async () => {
if (!orderNumbers.trim()) {
@@ -42,6 +90,10 @@ export const ExtractorPage: React.FC = () => {
.map((line) => line.trim())
.filter((line) => line.length > 0)
// Store Production IDs for sharing with cleaner page (before extraction starts)
await window.electron.validation.setSharedProductionIds(orderNumberList)
console.log(`[Extractor] Stored ${orderNumberList.length} Production IDs for sharing`)
// Call extractor API through electron
const response = await window.electron.extractor.runExtractor({
orderNumbers: orderNumberList,
@@ -104,6 +156,14 @@ export const ExtractorPage: React.FC = () => {
>
{isRunning ? '提取中...' : '开始提取'}
</button>
<button
className="btn btn-secondary"
onClick={saveSharedProductionIds}
disabled={isRunning || !orderNumbers.trim() || saveStatus === 'saved'}
title="保存订单号以便在清理页面使用"
>
{saveStatus === 'saving' ? '保存中...' : saveStatus === 'saved' ? '已保存' : '保存为共享 ID'}
</button>
<button className="btn btn-secondary" onClick={handleReset} disabled={isRunning}>
</button>
@@ -254,6 +314,10 @@ export const ExtractorPage: React.FC = () => {
.btn-secondary:hover:not(:disabled) {
background: #e8e8e8;
}
.btn-secondary[data-saved="true"] {
background: #52c41a;
color: #fff;
}
.progress-section {
margin-top: 24px;
padding: 16px;

View File

@@ -0,0 +1,975 @@
/**
* Settings Page
*
* System settings configuration page with user type-based access control
* Admin users see all settings, User users see limited settings
*/
import React, { useState, useEffect } from 'react'
import type {
SettingsData,
UserType,
DatabaseType,
MatchMode,
ValidationDataSource
} from '../../../main/types/settings.types'
// Default settings
const DEFAULT_SETTINGS: SettingsData = {
erp: {
url: 'https://68.11.34.30:8082/',
username: '',
password: '',
headless: true,
ignoreHttpsErrors: true,
autoCloseBrowser: true
},
database: {
dbType: 'mysql',
server: '',
mysqlHost: '192.168.31.83',
mysqlPort: 3306,
database: 'BLD_DB',
username: 'remote_user',
password: ''
},
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: ''
},
ui: {
fontFamily: 'Microsoft YaHei UI',
fontSize: 10,
productionIdInputWidth: 20
},
execution: {
dryRun: false
}
}
const SettingsPage: React.FC = () => {
// User type state
const [userType, setUserType] = useState<UserType>('Guest')
// Settings state
const [settings, setSettings] = useState<SettingsData>(DEFAULT_SETTINGS)
// UI state
const [isModified, setIsModified] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
// Password visibility state for User ERP credentials
const [showPassword, setShowPassword] = useState(false)
// Load settings on mount
useEffect(() => {
loadSettings()
}, [])
/**
* Load user type and settings from main process
*/
const loadSettings = async () => {
setIsLoading(true)
try {
// Get user type
const type = await window.electron.settings.getUserType()
setUserType(type)
// Get settings (filtered by user type)
const loadedSettings = await window.electron.settings.getSettings()
setSettings(loadedSettings)
} catch (error) {
console.error('Failed to load settings:', error)
showMessage('加载设置失败', 'error')
} finally {
setIsLoading(false)
}
}
/**
* Show a message to user
*/
const showMessage = (text: string, type: 'success' | 'error') => {
setMessage({ type, text })
setTimeout(() => setMessage(null), 3000)
}
/**
* Update settings and mark as modified
*/
const updateSettings = (
section: keyof SettingsData,
key: string,
value: unknown
) => {
setSettings((prev) => ({
...prev,
[section]: {
...prev[section],
[key]: value
}
}))
setIsModified(true)
}
/**
* Test ERP connection
*/
const handleTestErpConnection = async () => {
try {
const result = await window.electron.settings.testErpConnection()
if (result.success) {
showMessage(result.message || 'ERP 连接测试成功!', 'success')
} else {
showMessage(result.message || 'ERP 连接测试失败', 'error')
}
} catch (error) {
console.error('ERP connection test error:', error)
showMessage('ERP 连接测试失败', 'error')
}
}
/**
* Test database connection
*/
const handleTestDbConnection = async () => {
try {
const result = await window.electron.settings.testDbConnection()
if (result.success) {
showMessage(result.message || '数据库连接测试成功!', 'success')
} else {
showMessage(result.message || '数据库连接测试失败', 'error')
}
} catch (error) {
console.error('Database connection test error:', error)
showMessage('数据库连接测试失败', 'error')
}
}
/**
* Save settings
*/
const handleSaveSettings = async () => {
try {
const result = await window.electron.settings.saveSettings(settings)
if (result.success) {
showMessage('设置已保存', 'success')
setIsModified(false)
} else {
showMessage(result.error || '保存设置失败', 'error')
}
} catch (error) {
console.error('Save settings error:', error)
showMessage('保存设置失败', 'error')
}
}
/**
* Reset to default settings (Admin only)
*/
const handleResetDefaults = async () => {
if (!window.confirm('确定要恢复默认设置吗?这将覆盖 .env 文件中的所有配置。')) {
return
}
try {
const result = await window.electron.settings.resetDefaults()
if (result.success) {
showMessage('已恢复默认设置', 'success')
const loadedSettings = await window.electron.settings.getSettings()
setSettings(loadedSettings)
setIsModified(false)
} else {
showMessage(result.error || '恢复默认设置失败', 'error')
}
} catch (error) {
console.error('Reset defaults error:', error)
showMessage('恢复默认设置失败', 'error')
}
}
// Show loading state
if (isLoading) {
return (
<div className="loading-container">
<div className="loading-content">
<div className="loading-spinner"></div>
<p>...</p>
</div>
<style>{`
.loading-container {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: #f5f7fa;
}
.loading-content {
text-align: center;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid #e8e8e8;
border-top-color: #1890ff;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 16px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
`}</style>
</div>
)
}
const isUserOnly = userType === 'User'
return (
<div className="settings-page">
{/* Header */}
<div className="settings-header">
<h1></h1>
<span className="user-type-badge">
{userType === 'Admin' ? '管理员' : userType === 'User' ? '普通用户' : '访客'}
</span>
</div>
{/* Message toast */}
{message && (
<div className={`toast toast-${message.type}`}>
{message.text}
</div>
)}
{/* Settings content */}
<div className="settings-content">
{/* Admin: ERP System Configuration */}
{userType === 'Admin' && (
<>
<div className="settings-group">
<h2 className="settings-group-title">ERP </h2>
<div className="settings-form">
<div className="form-row">
<label className="form-label">ERP URL:</label>
<input
type="text"
className="form-input"
value={settings.erp.url}
onChange={(e) => updateSettings('erp', 'url', e.target.value)}
placeholder="https://68.11.34.30:8082/"
/>
</div>
<div className="form-row">
<label className="form-label">:</label>
<input
type="text"
className="form-input"
value={settings.erp.username}
onChange={(e) => updateSettings('erp', 'username', e.target.value)}
/>
</div>
<div className="form-row">
<label className="form-label">:</label>
<input
type="password"
className="form-input"
value={settings.erp.password}
onChange={(e) => updateSettings('erp', 'password', e.target.value)}
/>
</div>
</div>
</div>
{/* Database Configuration */}
<div className="settings-group">
<h2 className="settings-group-title"></h2>
<div className="settings-form">
<div className="form-row">
<label className="form-label">:</label>
<select
className="form-select"
value={settings.database.dbType}
onChange={(e) => updateSettings('database', 'dbType', e.target.value as DatabaseType)}
>
<option value="sqlserver">SQL Server</option>
<option value="mysql">MySQL</option>
</select>
</div>
{settings.database.dbType === 'mysql' ? (
<>
<div className="form-row">
<label className="form-label">MySQL :</label>
<input
type="text"
className="form-input"
value={settings.database.mysqlHost}
onChange={(e) => updateSettings('database', 'mysqlHost', e.target.value)}
/>
</div>
<div className="form-row">
<label className="form-label">MySQL :</label>
<input
type="number"
className="form-input"
value={settings.database.mysqlPort}
onChange={(e) => updateSettings('database', 'mysqlPort', parseInt(e.target.value) || 3306)}
/>
</div>
</>
) : (
<div className="form-row">
<label className="form-label">SQL Server :</label>
<input
type="text"
className="form-input"
value={settings.database.server}
onChange={(e) => updateSettings('database', 'server', e.target.value)}
/>
</div>
)}
<div className="form-row">
<label className="form-label">:</label>
<input
type="text"
className="form-input"
value={settings.database.database}
onChange={(e) => updateSettings('database', 'database', e.target.value)}
/>
</div>
<div className="form-row">
<label className="form-label">:</label>
<input
type="text"
className="form-input"
value={settings.database.username}
onChange={(e) => updateSettings('database', 'username', e.target.value)}
/>
</div>
<div className="form-row">
<label className="form-label">:</label>
<input
type="password"
className="form-input"
value={settings.database.password}
onChange={(e) => updateSettings('database', 'password', e.target.value)}
/>
</div>
</div>
</div>
{/* Browser Settings */}
<div className="settings-group">
<h2 className="settings-group-title"></h2>
<div className="settings-form">
<div className="form-row checkbox-row">
<label className="form-checkbox-label">
<input
type="checkbox"
checked={settings.erp.headless}
onChange={(e) => updateSettings('erp', 'headless', e.target.checked)}
/>
<span> ()</span>
</label>
</div>
<div className="form-row checkbox-row">
<label className="form-checkbox-label">
<input
type="checkbox"
checked={settings.erp.ignoreHttpsErrors}
onChange={(e) => updateSettings('erp', 'ignoreHttpsErrors', e.target.checked)}
/>
<span> HTTPS </span>
</label>
</div>
<div className="form-row checkbox-row">
<label className="form-checkbox-label">
<input
type="checkbox"
checked={settings.erp.autoCloseBrowser}
onChange={(e) => updateSettings('erp', 'autoCloseBrowser', e.target.checked)}
/>
<span></span>
</label>
</div>
</div>
</div>
{/* Paths Configuration */}
<div className="settings-group">
<h2 className="settings-group-title"></h2>
<div className="settings-form">
<div className="form-row">
<label className="form-label">:</label>
<input
type="text"
className="form-input"
value={settings.paths.dataDir}
onChange={(e) => updateSettings('paths', 'dataDir', e.target.value)}
/>
</div>
<div className="form-row">
<label className="form-label">:</label>
<input
type="text"
className="form-input"
value={settings.paths.defaultOutput}
onChange={(e) => updateSettings('paths', 'defaultOutput', e.target.value)}
/>
</div>
<div className="form-row">
<label className="form-label">:</label>
<input
type="text"
className="form-input"
value={settings.paths.validationOutput}
onChange={(e) => updateSettings('paths', 'validationOutput', e.target.value)}
/>
</div>
</div>
</div>
{/* Extraction Settings */}
<div className="settings-group">
<h2 className="settings-group-title"></h2>
<div className="settings-form">
<div className="form-row">
<label className="form-label">:</label>
<input
type="number"
className="form-input input-small"
value={settings.extraction.batchSize}
onChange={(e) => updateSettings('extraction', 'batchSize', parseInt(e.target.value) || 100)}
min="10"
max="500"
/>
</div>
<div className="form-row checkbox-row">
<label className="form-checkbox-label">
<input
type="checkbox"
checked={settings.extraction.verbose}
onChange={(e) => updateSettings('extraction', 'verbose', e.target.checked)}
/>
<span></span>
</label>
</div>
<div className="form-row checkbox-row">
<label className="form-checkbox-label">
<input
type="checkbox"
checked={settings.extraction.autoConvert}
onChange={(e) => updateSettings('extraction', 'autoConvert', e.target.checked)}
/>
<span> Excel </span>
</label>
</div>
<div className="form-row checkbox-row">
<label className="form-checkbox-label">
<input
type="checkbox"
checked={settings.extraction.mergeBatches}
onChange={(e) => updateSettings('extraction', 'mergeBatches', e.target.checked)}
/>
<span></span>
</label>
</div>
<div className="form-row checkbox-row">
<label className="form-checkbox-label">
<input
type="checkbox"
checked={settings.extraction.enableDbPersistence}
onChange={(e) => updateSettings('extraction', 'enableDbPersistence', e.target.checked)}
/>
<span></span>
</label>
</div>
</div>
</div>
{/* Validation Settings */}
<div className="settings-group">
<h2 className="settings-group-title"></h2>
<div className="settings-form">
<div className="form-row">
<label className="form-label">:</label>
<select
className="form-select"
value={settings.validation.dataSource}
onChange={(e) => updateSettings('validation', 'dataSource', e.target.value as ValidationDataSource)}
>
<option value="database_full">database_full</option>
<option value="database_filtered">database_filtered</option>
<option value="excel_existing">excel_existing</option>
<option value="excel_full">excel_full</option>
</select>
</div>
<div className="form-row checkbox-row">
<label className="form-checkbox-label">
<input
type="checkbox"
checked={settings.validation.enableCrud}
onChange={(e) => updateSettings('validation', 'enableCrud', e.target.checked)}
/>
<span> CRUD </span>
</label>
</div>
<div className="form-row">
<label className="form-label">:</label>
<input
type="number"
className="form-input input-small"
value={settings.validation.batchSize}
onChange={(e) => updateSettings('validation', 'batchSize', parseInt(e.target.value) || 2000)}
min="100"
max="2000"
/>
</div>
<div className="form-row">
<label className="form-label">:</label>
<select
className="form-select"
value={settings.validation.matchMode}
onChange={(e) => updateSettings('validation', 'matchMode', e.target.value as MatchMode)}
>
<option value="substring"> (substring)</option>
<option value="exact"> (exact)</option>
</select>
</div>
<div className="form-row">
<label className="form-label">:</label>
<input
type="text"
className="form-input"
value={settings.validation.defaultManager}
onChange={(e) => updateSettings('validation', 'defaultManager', e.target.value)}
/>
</div>
</div>
</div>
{/* UI Settings */}
<div className="settings-group">
<h2 className="settings-group-title"></h2>
<div className="settings-form">
<div className="form-row">
<label className="form-label">:</label>
<select
className="form-select"
value={settings.ui.fontFamily}
onChange={(e) => updateSettings('ui', 'fontFamily', e.target.value)}
>
<option value="Microsoft YaHei UI">Microsoft YaHei UI</option>
<option value="SimSun">SimSun ()</option>
<option value="KaiTi">KaiTi ()</option>
<option value="FangSong">FangSong (仿)</option>
<option value="Arial">Arial</option>
<option value="Segoe UI">Segoe UI</option>
</select>
</div>
<div className="form-row">
<label className="form-label">:</label>
<input
type="number"
className="form-input input-small"
value={settings.ui.fontSize}
onChange={(e) => updateSettings('ui', 'fontSize', parseInt(e.target.value) || 10)}
min="8"
max="24"
/>
</div>
<div className="form-row">
<label className="form-label">Production ID ():</label>
<input
type="number"
className="form-input input-small"
value={settings.ui.productionIdInputWidth}
onChange={(e) => updateSettings('ui', 'productionIdInputWidth', parseInt(e.target.value) || 20)}
min="10"
max="100"
/>
</div>
</div>
</div>
</>
)}
{/* User: ERP Credentials */}
{isUserOnly && (
<>
<div className="settings-group">
<h2 className="settings-group-title">ERP </h2>
<div className="settings-form">
<div className="form-row">
<label className="form-label">ERP :</label>
<input
type="text"
className="form-input"
value={settings.erp.username}
onChange={(e) => updateSettings('erp', 'username', e.target.value)}
/>
</div>
<div className="form-row">
<label className="form-label">ERP :</label>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<input
type={showPassword ? 'text' : 'password'}
className="form-input"
value={settings.erp.password}
onChange={(e) => updateSettings('erp', 'password', e.target.value)}
style={{ flex: 1 }}
/>
<label className="form-checkbox-label" style={{ margin: 0, whiteSpace: 'nowrap' }}>
<input
type="checkbox"
checked={showPassword}
onChange={(e) => setShowPassword(e.target.checked)}
/>
<span></span>
</label>
</div>
</div>
<div className="form-row">
<p className="form-hint">
使 ERP
</p>
</div>
</div>
</div>
{/* Paths Configuration (User) */}
<div className="settings-group">
<h2 className="settings-group-title"></h2>
<div className="settings-form">
<div className="form-row">
<label className="form-label">:</label>
<input
type="text"
className="form-input"
value={settings.paths.dataDir}
onChange={(e) => updateSettings('paths', 'dataDir', e.target.value)}
/>
</div>
<div className="form-row">
<label className="form-label">:</label>
<input
type="text"
className="form-input"
value={settings.paths.defaultOutput}
onChange={(e) => updateSettings('paths', 'defaultOutput', e.target.value)}
/>
</div>
<div className="form-row">
<label className="form-label">:</label>
<input
type="text"
className="form-input"
value={settings.paths.validationOutput}
onChange={(e) => updateSettings('paths', 'validationOutput', e.target.value)}
/>
</div>
</div>
</div>
{/* Execution Settings (User) */}
<div className="settings-group">
<h2 className="settings-group-title"></h2>
<div className="settings-form">
<div className="form-row checkbox-row">
<label className="form-checkbox-label">
<input
type="checkbox"
checked={settings.execution.dryRun}
onChange={(e) => updateSettings('execution', 'dryRun', e.target.checked)}
/>
<span> ()</span>
</label>
</div>
<div className="form-row">
<p className="form-hint">
</p>
</div>
<div className="form-row checkbox-row">
<label className="form-checkbox-label">
<input
type="checkbox"
checked={settings.erp.headless}
onChange={(e) => updateSettings('erp', 'headless', e.target.checked)}
/>
<span> ()</span>
</label>
</div>
<div className="form-row">
<p className="form-hint">
</p>
</div>
</div>
</div>
</>
)}
</div>
{/* Button Group */}
<div className="settings-buttons">
<button className="btn btn-primary" onClick={handleTestErpConnection}>
ERP
</button>
<button className="btn btn-primary" onClick={handleTestDbConnection}>
</button>
<button
className="btn btn-success"
onClick={handleSaveSettings}
disabled={!isModified}
>
</button>
{userType === 'Admin' && (
<button className="btn btn-warning" onClick={handleResetDefaults}>
</button>
)}
</div>
{/* Styles */}
<style>{`
.settings-page {
padding: 24px;
background: #f5f7fa;
min-height: 100vh;
}
.settings-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 2px solid #e8e8e8;
}
.settings-header h1 {
font-size: 24px;
color: #333;
margin: 0;
}
.user-type-badge {
background: #e6f7ff;
color: #1890ff;
padding: 6px 12px;
border-radius: 4px;
font-size: 14px;
}
.toast {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
padding: 12px 24px;
border-radius: 6px;
font-size: 14px;
z-index: 10000;
animation: slideDown 0.3s ease-out;
}
.toast-success {
background: #f6ffed;
border: 1px solid #b7eb8f;
color: #52c41a;
}
.toast-error {
background: #fff1f0;
border: 1px solid #ffa39e;
color: #ff4d4f;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translate(-50%, -20px);
}
to {
opacity: 1;
transform: translate(-50%, 0);
}
}
.settings-content {
max-width: 900px;
margin: 0 auto;
}
.settings-group {
background: #fff;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.settings-group-title {
font-size: 18px;
color: #333;
margin: 0 0 16px 0;
padding-bottom: 12px;
border-bottom: 1px solid #e8e8e8;
}
.settings-form {
display: flex;
flex-direction: column;
gap: 12px;
}
.form-row {
display: flex;
align-items: center;
gap: 16px;
}
.form-label {
min-width: 120px;
font-size: 14px;
color: #666;
text-align: right;
}
.form-input {
flex: 1;
padding: 8px 12px;
border: 1px solid #d9d9d9;
border-radius: 4px;
font-size: 14px;
transition: all 0.3s;
}
.form-input:focus {
outline: none;
border-color: #1890ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}
.form-input.input-small {
width: 100px;
flex: none;
}
.form-select {
flex: 1;
padding: 8px 12px;
border: 1px solid #d9d9d9;
border-radius: 4px;
font-size: 14px;
background: #fff;
cursor: pointer;
}
.form-select:focus {
outline: none;
border-color: #1890ff;
}
.checkbox-row {
justify-content: flex-start;
}
.form-checkbox-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 14px;
color: #333;
}
.form-checkbox-label input[type="checkbox"] {
width: 16px;
height: 16px;
cursor: pointer;
}
.form-hint {
color: #999;
font-size: 12px;
margin: 4px 0 0 136px;
}
.settings-buttons {
display: flex;
gap: 12px;
justify-content: center;
padding: 24px;
margin-top: 20px;
}
.btn {
padding: 10px 24px;
border: none;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
transition: all 0.3s;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: #1890ff;
color: #fff;
}
.btn-primary:hover:not(:disabled) {
background: #40a9ff;
}
.btn-success {
background: #52c41a;
color: #fff;
}
.btn-success:hover:not(:disabled) {
background: #73d13d;
}
.btn-warning {
background: #faad14;
color: #fff;
}
.btn-warning:hover:not(:disabled) {
background: #ffc53d;
}
`}</style>
</div>
)
}
export default SettingsPage