From b979b73ba1b59753ce26ec39e89e791522b381d9 Mon Sep 17 00:00:00 2001 From: Misaka Date: Sat, 21 Mar 2026 08:54:35 +0800 Subject: [PATCH] refactor: split validation handler responsibilities --- src/main/ipc/validation-handler.ts | 773 ++---------------- .../validation/production-input-service.ts | 95 +++ .../validation/shared-production-ids-store.ts | 18 + .../validation-application-service.ts | 413 ++++++++++ .../validation/validation-database.ts | 56 ++ tests/unit/production-input-service.test.ts | 16 + .../unit/shared-production-ids-store.test.ts | 31 + 7 files changed, 678 insertions(+), 724 deletions(-) create mode 100644 src/main/services/validation/production-input-service.ts create mode 100644 src/main/services/validation/shared-production-ids-store.ts create mode 100644 src/main/services/validation/validation-application-service.ts create mode 100644 src/main/services/validation/validation-database.ts create mode 100644 tests/unit/production-input-service.test.ts create mode 100644 tests/unit/shared-production-ids-store.test.ts diff --git a/src/main/ipc/validation-handler.ts b/src/main/ipc/validation-handler.ts index 774d78e..ec49bfc 100644 --- a/src/main/ipc/validation-handler.ts +++ b/src/main/ipc/validation-handler.ts @@ -1,478 +1,49 @@ /** * IPC handlers for material validation operations - * - * Provides endpoints for: - * - Running material validation from database - * - Getting/setting materials to be deleted - * - Manager-based filtering */ import { ipcMain } from 'electron' -import { MySqlService } from '../services/database/mysql' -import { SqlServerService } from '../services/database/sql-server' import { MaterialsToBeDeletedDAO } from '../services/database/materials-to-be-deleted-dao' -import { DiscreteMaterialPlanDAO } from '../services/database/discrete-material-plan-dao' -import { ConfigManager } from '../services/config/config-manager' import { createLogger } from '../services/logger' import type { - ValidationRequest, - ValidationResponse, - MaterialUpsertBatchRequest, MaterialDeleteRequest, MaterialOperationResponse, - ValidationResult, - MaterialRecordSummary + MaterialRecordSummary, + MaterialUpsertBatchRequest, + ValidationRequest, + ValidationResponse } from '../types/validation.types' import { IPC_CHANNELS } from '../../shared/ipc-channels' +import { sharedProductionIdsStore } from '../services/validation/shared-production-ids-store' +import { validationApplicationService } from '../services/validation/validation-application-service' const log = createLogger('ValidationHandler') -/** - * Shared state for Production IDs from extractor page - * This is a simple in-memory store for sharing Production IDs between pages - */ -const sharedProductionIdsBySender = new Map>() - -/** - * Set shared Production IDs - */ -export function setSharedProductionIds(senderId: number, ids: string[]): void { - sharedProductionIdsBySender.set(senderId, new Set(ids)) -} - -/** - * Get shared Production IDs - */ -export function getSharedProductionIds(senderId: number): string[] { - const senderSet = sharedProductionIdsBySender.get(senderId) - return senderSet ? [...senderSet] : [] -} - -/** - * Clear shared Production IDs - */ -export function clearSharedProductionIds(senderId: number): void { - sharedProductionIdsBySender.delete(senderId) -} - -/** - * Get database service for validation operations (MySQL or SQL Server) - */ -async function getValidationDatabaseService(): Promise { - const configManager = ConfigManager.getInstance() - const config = configManager.getConfig() - const dbType = configManager.getDatabaseType() - - if (dbType === 'sqlserver') { - const dbConfig = config.database.sqlserver - const sqlServerService = new SqlServerService({ - server: dbConfig.server, - port: dbConfig.port, - user: dbConfig.username, - password: dbConfig.password, - database: dbConfig.database, - options: { - encrypt: false, - trustServerCertificate: dbConfig.trustServerCertificate - } - }) - await sqlServerService.connect() - return sqlServerService - } else { - const dbConfig = config.database.mysql - const mysqlService = new MySqlService({ - host: dbConfig.host, - port: dbConfig.port, - user: dbConfig.username, - password: dbConfig.password, - database: dbConfig.database - }) - await mysqlService.connect() - return mysqlService - } -} - -/** - * Get table name based on database type - * Converts MySQL schema_tablename format to SQL Server [schema].[tablename] format - * e.g., productionContractData_26年压力表合同数据 -> [productionContractData].[26年压力表合同数据] - * dbo_MaterialsToBeDeleted -> [dbo].[MaterialsToBeDeleted] - */ -function getTableName(mysqlTableName: string): string { - const configManager = ConfigManager.getInstance() - const dbType = configManager.getDatabaseType() - - if (dbType === 'sqlserver') { - // Find the FIRST underscore to split schema and table name - // This handles patterns like: schema_tablename - const firstUnderscoreIndex = mysqlTableName.indexOf('_') - if (firstUnderscoreIndex > 0) { - const schema = mysqlTableName.substring(0, firstUnderscoreIndex) - const tableName = mysqlTableName.substring(firstUnderscoreIndex + 1) - return `[${schema}].[${tableName}]` - } - // If no underscore found, default to dbo schema - return `[dbo].[${mysqlTableName}]` - } - return mysqlTableName -} - -/** - * Read Production IDs from file - */ -function readProductionIds(filePath: string): string[] { - const fs = require('fs') - const content = fs.readFileSync(filePath, 'utf-8') as string - return content - .split('\n') - .map((line: string) => line.trim()) - .filter((line: string) => line.length > 0) -} - -/** - * Identify input type (production ID or order number) - */ -function identifyInputType(input: string): 'production_id' | 'order_number' | 'unknown' { - // Order number: SC + 14 digits - if (/^SC\d{14}$/.test(input)) { - return 'order_number' - } - // Production ID: 2 digits + 1 letter + 1-6 digits - if (/^\d{2}[A-Za-z]\d{1,6}$/.test(input)) { - return 'production_id' - } - return 'unknown' -} - -/** - * Get source numbers from inputs - */ -async function getSourceNumbersFromInputs( - inputs: string[], - dbService: MySqlService | SqlServerService -): Promise { - const productionIds: string[] = [] - const orderNumbers: string[] = [] - const configManager = ConfigManager.getInstance() - const dbType = configManager.getDatabaseType() - const isSqlServer = dbType === 'sqlserver' - - for (const item of inputs) { - const type = identifyInputType(item) - if (type === 'order_number') { - orderNumbers.push(item) - } else if (type === 'production_id') { - productionIds.push(item) - } - } - - // Query production contract data for production IDs - // Table name in MySQL: productionContractData_26年压力表合同数据 - // Column name: 生产订单号 (SourceNumber) - if (productionIds.length > 0) { - const contractTableName = getTableName('productionContractData_26年压力表合同数据') - const batchSize = 2000 - - if (isSqlServer) { - const sql = require('mssql') - const allOrderNumbers: string[] = [] - - for (let i = 0; i < productionIds.length; i += batchSize) { - const batch = productionIds.slice(i, i + batchSize) - const placeholders = batch.map((_, idx) => `@p${idx}`).join(',') - const params: Record = {} - - batch.forEach((id, idx) => { - params[`p${idx}`] = { value: id, type: sql.NVarChar } - }) - - const contractSql = ` - SELECT DISTINCT 生产订单号 - FROM ${contractTableName} - WHERE 总排号 IN (${placeholders}) - ` - const contractResult = await (dbService as SqlServerService).queryWithParams( - contractSql, - params - ) - const dbOrderNumbers = contractResult.rows.map((row) => row.生产订单号 as string) - allOrderNumbers.push(...dbOrderNumbers) - } - - orderNumbers.push(...allOrderNumbers) - } else { - const allOrderNumbers: string[] = [] - - for (let i = 0; i < productionIds.length; i += batchSize) { - const batch = productionIds.slice(i, i + batchSize) - const placeholders = batch.map(() => '?').join(',') - const contractSql = ` - SELECT DISTINCT 生产订单号 - FROM ${contractTableName} - WHERE 总排号 IN (${placeholders}) - ` - const contractResult = await (dbService as MySqlService).query(contractSql, batch) - const dbOrderNumbers = contractResult.rows.map((row) => row.生产订单号 as string) - allOrderNumbers.push(...dbOrderNumbers) - } - - orderNumbers.push(...allOrderNumbers) - } - } - - // Deduplicate - return [...new Set(orderNumbers)] -} - -/** - * Register IPC handlers for validation operations - */ export function registerValidationHandlers(): void { - // ==================== VALIDATION ==================== - - /** - * Run material validation from database - */ ipcMain.handle( IPC_CHANNELS.VALIDATION_VALIDATE, async (event, request: ValidationRequest): Promise => { - let dbService: MySqlService | SqlServerService | null = null + const sessionManager = ( + await import('../services/user/session-manager') + ).SessionManager.getInstance() - try { - // Get current user info - const sessionManager = ( - await import('../services/user/session-manager') - ).SessionManager.getInstance() - - const userInfo = sessionManager.getUserInfo() - if (!userInfo) { - return { - success: false, - error: '用户未登录', - stats: { - totalRecords: 0, - matchedCount: 0, - markedCount: 0 - } - } - } - - const isAdmin = userInfo.userType === 'Admin' - const username = userInfo.username - - log.info('Starting validation', { mode: request.mode, user: username, isAdmin }) - - // Connect to database - dbService = await getValidationDatabaseService() - const configManager = ConfigManager.getInstance() - const dbType = configManager.getDatabaseType() - const isSqlServer = dbType === 'sqlserver' - - let sourceNumbers: string[] | null = null - - // Get source numbers based on mode - if (request.mode === 'database_filtered') { - if (request.useSharedProductionIds) { - // Use shared Production IDs from extractor page - const sharedIds = getSharedProductionIds(event.sender.id) - log.info(`Using ${sharedIds.length} shared Production IDs`) - - if (sharedIds.length === 0) { - return { - success: false, - error: '没有可用的共享 Production ID。请在数据提取页面输入 Production ID。', - stats: { - totalRecords: 0, - matchedCount: 0, - markedCount: 0 - } - } - } - - sourceNumbers = await getSourceNumbersFromInputs(sharedIds, dbService) - log.info(`Got ${sourceNumbers.length} source numbers from shared Production IDs`) - - // Check if we got any order numbers from the shared Production IDs - if (sourceNumbers.length === 0) { - return { - success: false, - error: - '共享的 Production ID 没有找到对应的订单数据。请确保在数据提取页面输入了有效的 Production ID 并成功获取了订单数据。', - stats: { - totalRecords: 0, - matchedCount: 0, - markedCount: 0 - } - } - } - } else if (request.productionIdFile) { - // Read from file - const inputs = readProductionIds(request.productionIdFile) - log.info(`Read ${inputs.length} inputs from file`) - sourceNumbers = await getSourceNumbersFromInputs(inputs, dbService) - log.info(`Got ${sourceNumbers.length} source numbers`) - - // Check if we got any order numbers from the file - if (sourceNumbers.length === 0) { - return { - success: false, - error: - '文件中的 Production ID 没有找到对应的订单数据。请检查 Production ID 是否正确,或确保数据库中有对应的订单数据。', - stats: { - totalRecords: 0, - matchedCount: 0, - markedCount: 0 - } - } - } - } - } - - // Get material records from DiscreteMaterialPlanData - const materialDao = new DiscreteMaterialPlanDAO() - - let materialRecords: any[] = [] - - if (request.mode === 'database_full') { - // Full table query with deduplication by MaterialCode - materialRecords = await materialDao.queryAllDistinctByMaterialCode() - } else if (sourceNumbers && sourceNumbers.length > 0) { - // Filtered query by source numbers - materialRecords = await materialDao.queryBySourceNumbersDistinct(sourceNumbers) - } - - if (materialRecords.length === 0) { - return { - success: false, - error: '未找到物料记录。请检查数据库中是否有对应订单的物料数据。', - stats: { - totalRecords: 0, - matchedCount: 0, - markedCount: 0 - } - } - } - - // Get type keywords from MaterialsTypeToBeDeleted - const typeKeywordTableName = getTableName('dbo_MaterialsTypeToBeDeleted') - const typeKeywordSql = ` - SELECT MaterialName, ManagerName - FROM ${typeKeywordTableName} - WHERE MaterialName IS NOT NULL - ` - const typeKeywordResult = isSqlServer - ? await (dbService as SqlServerService).query(typeKeywordSql) - : await (dbService as MySqlService).query(typeKeywordSql) - - const typeKeywords = typeKeywordResult.rows.map((row) => ({ - materialName: row.MaterialName as string, - managerName: row.ManagerName as string - })) - - // Get marked material codes from MaterialsToBeDeleted - const markedTableName = getTableName('dbo_MaterialsToBeDeleted') - const markedSql = ` - SELECT MaterialCode, ManagerName - FROM ${markedTableName} - WHERE MaterialCode IS NOT NULL AND ManagerName IS NOT NULL - ` - const markedResult = isSqlServer - ? await (dbService as SqlServerService).query(markedSql) - : await (dbService as MySqlService).query(markedSql) - - const markedCodesDict = new Map() - for (const row of markedResult.rows) { - markedCodesDict.set(row.MaterialCode as string, row.ManagerName as string) - } - - // Match materials - const results: ValidationResult[] = [] - for (const record of materialRecords) { - const materialName = (record.MaterialName as string) || '' - const materialCode = (record.MaterialCode as string) || '' - const specification = (record.Specification as string) || '' - const model = (record.Model as string) || '' - - // Priority 1: Check MaterialsToBeDeleted (MaterialCode exact match) - let managerName = markedCodesDict.get(materialCode) || null - const isMarkedForDeletion = managerName !== null - let matchedTypeKeyword: string | undefined = undefined - - // Priority 2: Match with MaterialsTypeToBeDeleted (MaterialName contains) - if (!managerName) { - for (const typeKeyword of typeKeywords) { - if (typeKeyword.materialName && materialName.includes(typeKeyword.materialName)) { - matchedTypeKeyword = typeKeyword.materialName - managerName = typeKeyword.managerName - break - } - } - } - - // Priority 3: User Override Match (only for non-admin users) - // Override with current user's typeKeyword if available - if (!isAdmin && username) { - const userKeywords = typeKeywords.filter((tk) => tk.managerName === username) - for (const userKeyword of userKeywords) { - if (userKeyword.materialName && materialName.includes(userKeyword.materialName)) { - matchedTypeKeyword = userKeyword.materialName - managerName = userKeyword.managerName - break // Force override with first match - } - } - } - - results.push({ - materialName, - materialCode, - specification, - model, - managerName: managerName || '', - isMarkedForDeletion, - matchedTypeKeyword - }) - } - - const markedCount = results.filter((r) => r.isMarkedForDeletion).length - const matchedCount = results.filter((r) => r.managerName).length - - return { - success: true, - results, - stats: { - totalRecords: results.length, - matchedCount, - markedCount - } - } - } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error' - log.error('Validation error', { - error: error instanceof Error ? error.message : String(error) - }) + const userInfo = sessionManager.getUserInfo() + if (!userInfo) { return { success: false, - error: `Validation failed: ${message}` - } - } finally { - if (dbService) { - try { - await dbService.disconnect() - } catch (closeError) { - log.warn('Error disconnecting database', { - error: closeError instanceof Error ? closeError.message : String(closeError) - }) + error: '用户未登录', + stats: { + totalRecords: 0, + matchedCount: 0, + markedCount: 0 } } } + + return validationApplicationService.validate(request, userInfo, event.sender.id) } ) - // ==================== MATERIAL OPERATIONS ==================== - - /** - * Upsert batch materials to MaterialsToBeDeleted - */ ipcMain.handle( IPC_CHANNELS.MATERIALS_UPSERT_BATCH, async (_event, request: MaterialUpsertBatchRequest): Promise => { @@ -486,9 +57,7 @@ export function registerValidationHandlers(): void { } } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error' - log.error('Upsert batch error', { - error: error instanceof Error ? error.message : String(error) - }) + log.error('Upsert batch error', { error: message }) return { success: false, error: `Upsert failed: ${message}` @@ -497,9 +66,6 @@ export function registerValidationHandlers(): void { } ) - /** - * Delete materials by material codes - */ ipcMain.handle( IPC_CHANNELS.MATERIALS_DELETE, async (_event, request: MaterialDeleteRequest): Promise => { @@ -513,7 +79,7 @@ export function registerValidationHandlers(): void { } } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error' - log.error('Delete error', { error: error instanceof Error ? error.message : String(error) }) + log.error('Delete error', { error: message }) return { success: false, error: `Delete failed: ${message}` @@ -522,28 +88,19 @@ export function registerValidationHandlers(): void { } ) - /** - * Get unique manager names - */ - ipcMain.handle( - IPC_CHANNELS.MATERIALS_GET_MANAGERS, - async (_event): Promise<{ managers: string[] }> => { - try { - const dao = new MaterialsToBeDeletedDAO() - const managers = await dao.getManagers() - return { managers } - } catch (error) { - log.error('Get managers error', { - error: error instanceof Error ? error.message : String(error) - }) - return { managers: [] } - } + ipcMain.handle(IPC_CHANNELS.MATERIALS_GET_MANAGERS, async (): Promise<{ managers: string[] }> => { + try { + const dao = new MaterialsToBeDeletedDAO() + const managers = await dao.getManagers() + return { managers } + } catch (error) { + log.error('Get managers error', { + error: error instanceof Error ? error.message : String(error) + }) + return { managers: [] } } - ) + }) - /** - * Update manager for a single material - */ ipcMain.handle( IPC_CHANNELS.MATERIALS_UPDATE_MANAGER, async ( @@ -554,176 +111,47 @@ export function registerValidationHandlers(): void { const dao = new MaterialsToBeDeletedDAO() return await dao.updateManager(request.materialCode, request.managerName) } catch (error) { - log.error('Update manager error', { - error: error instanceof Error ? error.message : String(error) - }) + const message = error instanceof Error ? error.message : String(error) + log.error('Update manager error', { error: message }) return { success: false, - error: error instanceof Error ? error.message : String(error) + error: message } } } ) - /** - * Get materials by manager - */ ipcMain.handle( IPC_CHANNELS.MATERIALS_GET_BY_MANAGER, async (_event, managerName: string): Promise<{ materials: MaterialRecordSummary[] }> => { - let dbService: MySqlService | SqlServerService | null = null - try { - dbService = await getValidationDatabaseService() - const configManager = ConfigManager.getInstance() - const dbType = configManager.getDatabaseType() - const isSqlServer = dbType === 'sqlserver' - - const dao = new MaterialsToBeDeletedDAO() - const materials = await dao.getMaterialsByManager(managerName) - - // Get material codes set for quick lookup - const markedCodes = await dao.getAllMaterialCodes() - - // Enrich with material details from DiscreteMaterialPlanData - const enrichedMaterials: MaterialRecordSummary[] = [] - const detailTableName = getTableName('dbo_DiscreteMaterialPlanData') - - for (const mat of materials) { - let detailResult: any - - if (isSqlServer) { - const sql = require('mssql') - const detailSql = ` - SELECT TOP 1 MaterialName, Specification, Model - FROM ${detailTableName} - WHERE MaterialCode = @materialCode - ` - detailResult = await (dbService as SqlServerService).queryWithParams(detailSql, { - materialCode: { value: mat.materialCode, type: sql.NVarChar } - }) - } else { - const detailSql = ` - SELECT MaterialName, Specification, Model - FROM ${detailTableName} - WHERE MaterialCode = ? - LIMIT 1 - ` - detailResult = await (dbService as MySqlService).query(detailSql, [mat.materialCode]) - } - - enrichedMaterials.push({ - materialCode: mat.materialCode, - materialName: - detailResult.rows.length > 0 ? (detailResult.rows[0].MaterialName as string) : '', - specification: - detailResult.rows.length > 0 ? (detailResult.rows[0].Specification as string) : '', - model: detailResult.rows.length > 0 ? (detailResult.rows[0].Model as string) : '', - managerName: mat.managerName, - isMarked: markedCodes.has(mat.materialCode) - }) - } - - return { materials: enrichedMaterials } + const materials = await validationApplicationService.getMaterialsByManager(managerName) + return { materials } } catch (error) { log.error('Get by manager error', { error: error instanceof Error ? error.message : String(error) }) return { materials: [] } - } finally { - if (dbService) { - try { - await dbService.disconnect() - } catch (closeError) { - log.warn('Error disconnecting database', { - error: closeError instanceof Error ? closeError.message : String(closeError) - }) - } - } } } ) - /** - * Get all material records - */ ipcMain.handle( IPC_CHANNELS.MATERIALS_GET_ALL, - async (_event): Promise<{ materials: MaterialRecordSummary[] }> => { - let dbService: MySqlService | SqlServerService | null = null - + async (): Promise<{ materials: MaterialRecordSummary[] }> => { try { - dbService = await getValidationDatabaseService() - const configManager = ConfigManager.getInstance() - const dbType = configManager.getDatabaseType() - const isSqlServer = dbType === 'sqlserver' - - const dao = new MaterialsToBeDeletedDAO() - const materials = await dao.getAllRecords() - const markedCodes = await dao.getAllMaterialCodes() - - const enrichedMaterials: MaterialRecordSummary[] = [] - const detailTableName = getTableName('dbo_DiscreteMaterialPlanData') - - for (const mat of materials) { - let detailResult: any - - if (isSqlServer) { - const sql = require('mssql') - const detailSql = ` - SELECT TOP 1 MaterialName, Specification, Model - FROM ${detailTableName} - WHERE MaterialCode = @materialCode - ` - detailResult = await (dbService as SqlServerService).queryWithParams(detailSql, { - materialCode: { value: mat.materialCode, type: sql.NVarChar } - }) - } else { - const detailSql = ` - SELECT MaterialName, Specification, Model - FROM ${detailTableName} - WHERE MaterialCode = ? - LIMIT 1 - ` - detailResult = await (dbService as MySqlService).query(detailSql, [mat.materialCode]) - } - - enrichedMaterials.push({ - materialCode: mat.materialCode, - materialName: - detailResult.rows.length > 0 ? (detailResult.rows[0].MaterialName as string) : '', - specification: - detailResult.rows.length > 0 ? (detailResult.rows[0].Specification as string) : '', - model: detailResult.rows.length > 0 ? (detailResult.rows[0].Model as string) : '', - managerName: mat.managerName, - isMarked: markedCodes.has(mat.materialCode) - }) - } - - return { materials: enrichedMaterials } + const materials = await validationApplicationService.getAllMaterials() + return { materials } } catch (error) { log.error('Get all error', { error: error instanceof Error ? error.message : String(error) }) return { materials: [] } - } finally { - if (dbService) { - try { - await dbService.disconnect() - } catch (closeError) { - log.warn('Error disconnecting database', { - error: closeError instanceof Error ? closeError.message : String(closeError) - }) - } - } } } ) - /** - * Get statistics - */ - ipcMain.handle(IPC_CHANNELS.MATERIALS_GET_STATISTICS, async (_event): Promise<{ stats: any }> => { + ipcMain.handle(IPC_CHANNELS.MATERIALS_GET_STATISTICS, async (): Promise<{ stats: any }> => { try { const dao = new MaterialsToBeDeletedDAO() const stats = await dao.getStatistics() @@ -736,155 +164,52 @@ export function registerValidationHandlers(): void { } }) - /** - * Set shared Production IDs from extractor page - */ ipcMain.handle( IPC_CHANNELS.VALIDATION_SET_SHARED_PRODUCTION_IDS, async (event, productionIds: string[]): Promise => { log.info(`Received ${productionIds.length} shared Production IDs`) - setSharedProductionIds(event.sender.id, productionIds) + sharedProductionIdsStore.set(event.sender.id, productionIds) } ) - /** - * Get shared Production IDs - */ ipcMain.handle( IPC_CHANNELS.VALIDATION_GET_SHARED_PRODUCTION_IDS, async (event): Promise<{ productionIds: string[] }> => { - return { productionIds: getSharedProductionIds(event.sender.id) } + return { productionIds: sharedProductionIdsStore.get(event.sender.id) } } ) - /** - * Clear shared Production IDs - */ ipcMain.handle( IPC_CHANNELS.VALIDATION_CLEAR_SHARED_PRODUCTION_IDS, async (event): Promise => { log.info('Clearing shared Production IDs') - clearSharedProductionIds(event.sender.id) + sharedProductionIdsStore.clear(event.sender.id) } ) - /** - * Get cleaner data (order numbers from shared Production IDs + material codes from MaterialsToBeDeleted) - * Filters materials by current user (admin sees all, regular users see only their own) - */ ipcMain.handle( IPC_CHANNELS.VALIDATION_GET_CLEANER_DATA, async ( - _event + event ): Promise<{ success: boolean orderNumbers?: string[] materialCodes?: string[] error?: string }> => { - let dbService: MySqlService | SqlServerService | null = null const sessionManager = ( await import('../services/user/session-manager') ).SessionManager.getInstance() + const userInfo = sessionManager.getUserInfo() - try { - // Get current user - const userInfo = sessionManager.getUserInfo() - if (!userInfo) { - return { - success: false, - error: '用户未登录' - } - } - - const isAdmin = userInfo.userType === 'Admin' - const username = userInfo.username - const configManager = ConfigManager.getInstance() - const dbType = configManager.getDatabaseType() - const isSqlServer = dbType === 'sqlserver' - - log.info(`User: ${username}, isAdmin: ${isAdmin}`) - - // Connect to database - dbService = await getValidationDatabaseService() - - // 1. Get order numbers from shared Production IDs - const sharedIds = getSharedProductionIds(_event.sender.id) - let orderNumbers: string[] = [] - - if (sharedIds.length > 0) { - log.info(`Using ${sharedIds.length} shared Production IDs`) - orderNumbers = await getSourceNumbersFromInputs(sharedIds, dbService) - log.info(`Got ${orderNumbers.length} order numbers`) - } - - // 2. Get material codes from MaterialsToBeDeleted table - let materialCodes: string[] = [] - const markedTableName = getTableName('dbo_MaterialsToBeDeleted') - - if (isAdmin) { - // Admin sees all materials - const allCodesSql = ` - SELECT MaterialCode - FROM ${markedTableName} - WHERE MaterialCode IS NOT NULL - ` - const result = isSqlServer - ? await (dbService as SqlServerService).query(allCodesSql) - : await (dbService as MySqlService).query(allCodesSql) - - materialCodes = result.rows.map((row) => row.MaterialCode as string).filter(Boolean) - log.info(`Admin user: got ${materialCodes.length} materials`) - } else { - // Regular users only see their own materials - if (isSqlServer) { - const sql = require('mssql') - const userMaterialsSql = ` - SELECT MaterialCode - FROM ${markedTableName} - WHERE ManagerName = @username AND MaterialCode IS NOT NULL - ` - const result = await (dbService as SqlServerService).queryWithParams(userMaterialsSql, { - username: { value: username, type: sql.NVarChar } - }) - materialCodes = result.rows.map((row) => row.MaterialCode as string).filter(Boolean) - } else { - const userMaterialsSql = ` - SELECT MaterialCode - FROM ${markedTableName} - WHERE ManagerName = ? AND MaterialCode IS NOT NULL - ` - const result = await (dbService as MySqlService).query(userMaterialsSql, [username]) - materialCodes = result.rows.map((row) => row.MaterialCode as string).filter(Boolean) - } - log.info(`Regular user: got ${materialCodes.length} materials`) - } - - return { - success: true, - orderNumbers, - materialCodes - } - } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error' - log.error('CleanerData error', { - error: error instanceof Error ? error.message : String(error) - }) + if (!userInfo) { return { success: false, - error: `获取清理数据失败:${message}` - } - } finally { - if (dbService) { - try { - await dbService.disconnect() - } catch (closeError) { - log.warn('Error disconnecting database', { - error: closeError instanceof Error ? closeError.message : String(closeError) - }) - } + error: '用户未登录' } } + + return validationApplicationService.getCleanerData(userInfo, event.sender.id) } ) } diff --git a/src/main/services/validation/production-input-service.ts b/src/main/services/validation/production-input-service.ts new file mode 100644 index 0000000..734f41a --- /dev/null +++ b/src/main/services/validation/production-input-service.ts @@ -0,0 +1,95 @@ +import fs from 'fs' +import { ConfigManager } from '../config/config-manager' +import { SqlServerService } from '../database/sql-server' +import type { ValidationDatabaseService } from './validation-database' +import { getValidationTableName } from './validation-database' + +export function readProductionIds(filePath: string): string[] { + const content = fs.readFileSync(filePath, 'utf-8') + return content + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0) +} + +export function identifyInputType(input: string): 'production_id' | 'order_number' | 'unknown' { + if (/^SC\d{14}$/.test(input)) { + return 'order_number' + } + if (/^\d{2}[A-Za-z]\d{1,6}$/.test(input)) { + return 'production_id' + } + return 'unknown' +} + +export async function getSourceNumbersFromInputs( + inputs: string[], + dbService: ValidationDatabaseService +): Promise { + const productionIds: string[] = [] + const orderNumbers: string[] = [] + const configManager = ConfigManager.getInstance() + const isSqlServer = configManager.getDatabaseType() === 'sqlserver' + + for (const item of inputs) { + const type = identifyInputType(item) + if (type === 'order_number') { + orderNumbers.push(item) + } else if (type === 'production_id') { + productionIds.push(item) + } + } + + if (productionIds.length > 0) { + const contractTableName = getValidationTableName('productionContractData_26年压力表合同数据') + const batchSize = 2000 + + if (isSqlServer) { + const sql = await import('mssql') + const allOrderNumbers: string[] = [] + + for (let i = 0; i < productionIds.length; i += batchSize) { + const batch = productionIds.slice(i, i + batchSize) + const placeholders = batch.map((_, idx) => `@p${idx}`).join(',') + const params: Record = {} + + batch.forEach((id, idx) => { + params[`p${idx}`] = { value: id, type: sql.default.NVarChar } + }) + + const contractSql = ` + SELECT DISTINCT 生产订单号 + FROM ${contractTableName} + WHERE 总排号 IN (${placeholders}) + ` + const contractResult = await (dbService as SqlServerService).queryWithParams( + contractSql, + params + ) + allOrderNumbers.push( + ...contractResult.rows.map((row: Record) => row.生产订单号 as string) + ) + } + + orderNumbers.push(...allOrderNumbers) + } else { + const allOrderNumbers: string[] = [] + + for (let i = 0; i < productionIds.length; i += batchSize) { + const batch = productionIds.slice(i, i + batchSize) + const placeholders = batch.map(() => '?').join(',') + const contractSql = ` + SELECT DISTINCT 生产订单号 + FROM ${contractTableName} + WHERE 总排号 IN (${placeholders}) + ` + const contractResult = await dbService.query(contractSql, batch) + allOrderNumbers.push(...contractResult.rows.map((row) => row.生产订单号 as string)) + } + + orderNumbers.push(...allOrderNumbers) + } + } + + return [...new Set(orderNumbers)] +} diff --git a/src/main/services/validation/shared-production-ids-store.ts b/src/main/services/validation/shared-production-ids-store.ts new file mode 100644 index 0000000..80f1ee3 --- /dev/null +++ b/src/main/services/validation/shared-production-ids-store.ts @@ -0,0 +1,18 @@ +export class SharedProductionIdsStore { + private sharedProductionIdsBySender = new Map>() + + set(senderId: number, ids: string[]): void { + this.sharedProductionIdsBySender.set(senderId, new Set(ids)) + } + + get(senderId: number): string[] { + const senderSet = this.sharedProductionIdsBySender.get(senderId) + return senderSet ? [...senderSet] : [] + } + + clear(senderId: number): void { + this.sharedProductionIdsBySender.delete(senderId) + } +} + +export const sharedProductionIdsStore = new SharedProductionIdsStore() diff --git a/src/main/services/validation/validation-application-service.ts b/src/main/services/validation/validation-application-service.ts new file mode 100644 index 0000000..4fb0fdc --- /dev/null +++ b/src/main/services/validation/validation-application-service.ts @@ -0,0 +1,413 @@ +import { DiscreteMaterialPlanDAO } from '../database/discrete-material-plan-dao' +import { MaterialsToBeDeletedDAO } from '../database/materials-to-be-deleted-dao' +import { SqlServerService } from '../database/sql-server' +import { createLogger } from '../logger' +import type { + MaterialRecordSummary, + ValidationRequest, + ValidationResponse, + ValidationResult +} from '../../types/validation.types' +import type { UserInfo } from '../../types/user.types' +import { getSourceNumbersFromInputs, readProductionIds } from './production-input-service' +import { sharedProductionIdsStore } from './shared-production-ids-store' +import { + createValidationDatabaseService, + getValidationTableName, + type ValidationDatabaseService +} from './validation-database' + +const log = createLogger('ValidationApplicationService') + +type TypeKeyword = { + materialName: string + managerName: string +} + +export class ValidationApplicationService { + async validate( + request: ValidationRequest, + userInfo: UserInfo, + senderId: number + ): Promise { + let dbService: ValidationDatabaseService | null = null + + try { + const isAdmin = userInfo.userType === 'Admin' + const username = userInfo.username + + log.info('Starting validation', { mode: request.mode, user: username, isAdmin }) + + dbService = await createValidationDatabaseService() + + let sourceNumbers: string[] | null = null + + if (request.mode === 'database_filtered') { + if (request.useSharedProductionIds) { + const sharedIds = sharedProductionIdsStore.get(senderId) + log.info(`Using ${sharedIds.length} shared Production IDs`) + + if (sharedIds.length === 0) { + return this.emptyFailure( + '没有可用的共享 Production ID。请在数据提取页面输入 Production ID。' + ) + } + + sourceNumbers = await getSourceNumbersFromInputs(sharedIds, dbService) + log.info(`Got ${sourceNumbers.length} source numbers from shared Production IDs`) + + if (sourceNumbers.length === 0) { + return this.emptyFailure( + '共享的 Production ID 没有找到对应的订单数据。请确保在数据提取页面输入了有效的 Production ID 并成功获取了订单数据。' + ) + } + } else if (request.productionIdFile) { + const inputs = readProductionIds(request.productionIdFile) + log.info(`Read ${inputs.length} inputs from file`) + + sourceNumbers = await getSourceNumbersFromInputs(inputs, dbService) + log.info(`Got ${sourceNumbers.length} source numbers`) + + if (sourceNumbers.length === 0) { + return this.emptyFailure( + '文件中的 Production ID 没有找到对应的订单数据。请检查 Production ID 是否正确,或确保数据库中有对应的订单数据。' + ) + } + } + } + + const materialDao = new DiscreteMaterialPlanDAO() + let materialRecords: any[] = [] + + if (request.mode === 'database_full') { + materialRecords = await materialDao.queryAllDistinctByMaterialCode() + } else if (sourceNumbers && sourceNumbers.length > 0) { + materialRecords = await materialDao.queryBySourceNumbersDistinct(sourceNumbers) + } + + if (materialRecords.length === 0) { + return this.emptyFailure('未找到物料记录。请检查数据库中是否有对应订单的物料数据。') + } + + const typeKeywords = await this.loadTypeKeywords(dbService) + const markedCodes = await this.loadMarkedCodes(dbService) + const results = this.buildValidationResults(materialRecords, typeKeywords, markedCodes, { + isAdmin, + username + }) + + const markedCount = results.filter((result) => result.isMarkedForDeletion).length + const matchedCount = results.filter((result) => result.managerName).length + + return { + success: true, + results, + stats: { + totalRecords: results.length, + matchedCount, + markedCount + } + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + log.error('Validation error', { error: message }) + return { + success: false, + error: `Validation failed: ${message}` + } + } finally { + if (dbService) { + await this.disconnectQuietly(dbService) + } + } + } + + async getMaterialsByManager(managerName: string): Promise { + const dao = new MaterialsToBeDeletedDAO() + const materials = await dao.getMaterialsByManager(managerName) + const markedCodes = await dao.getAllMaterialCodes() + return this.enrichMaterials(materials, markedCodes) + } + + async getAllMaterials(): Promise { + const dao = new MaterialsToBeDeletedDAO() + const materials = await dao.getAllRecords() + const markedCodes = await dao.getAllMaterialCodes() + return this.enrichMaterials(materials, markedCodes) + } + + async getCleanerData( + userInfo: UserInfo, + senderId: number + ): Promise<{ + success: boolean + orderNumbers?: string[] + materialCodes?: string[] + error?: string + }> { + let dbService: ValidationDatabaseService | null = null + + try { + const isAdmin = userInfo.userType === 'Admin' + const username = userInfo.username + log.info(`User: ${username}, isAdmin: ${isAdmin}`) + + dbService = await createValidationDatabaseService() + + const sharedIds = sharedProductionIdsStore.get(senderId) + let orderNumbers: string[] = [] + + if (sharedIds.length > 0) { + log.info(`Using ${sharedIds.length} shared Production IDs`) + orderNumbers = await getSourceNumbersFromInputs(sharedIds, dbService) + log.info(`Got ${orderNumbers.length} order numbers`) + } + + const materialCodes = await this.loadMaterialCodesForCleaner(dbService, username, isAdmin) + + return { + success: true, + orderNumbers, + materialCodes + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + log.error('CleanerData error', { error: message }) + return { + success: false, + error: `获取清理数据失败:${message}` + } + } finally { + if (dbService) { + await this.disconnectQuietly(dbService) + } + } + } + + private emptyFailure(error: string): ValidationResponse { + return { + success: false, + error, + stats: { + totalRecords: 0, + matchedCount: 0, + markedCount: 0 + } + } + } + + private async loadTypeKeywords(dbService: ValidationDatabaseService): Promise { + const typeKeywordTableName = getValidationTableName('dbo_MaterialsTypeToBeDeleted') + const sql = ` + SELECT MaterialName, ManagerName + FROM ${typeKeywordTableName} + WHERE MaterialName IS NOT NULL + ` + const result = await dbService.query(sql) + return result.rows.map((row) => ({ + materialName: row.MaterialName as string, + managerName: row.ManagerName as string + })) + } + + private async loadMarkedCodes( + dbService: ValidationDatabaseService + ): Promise> { + const markedTableName = getValidationTableName('dbo_MaterialsToBeDeleted') + const sql = ` + SELECT MaterialCode, ManagerName + FROM ${markedTableName} + WHERE MaterialCode IS NOT NULL AND ManagerName IS NOT NULL + ` + const result = await dbService.query(sql) + const markedCodes = new Map() + + for (const row of result.rows) { + markedCodes.set(row.MaterialCode as string, row.ManagerName as string) + } + + return markedCodes + } + + private buildValidationResults( + materialRecords: any[], + typeKeywords: TypeKeyword[], + markedCodes: Map, + context: { isAdmin: boolean; username: string } + ): ValidationResult[] { + return materialRecords.map((record) => { + const materialName = (record.MaterialName as string) || '' + const materialCode = (record.MaterialCode as string) || '' + const specification = (record.Specification as string) || '' + const model = (record.Model as string) || '' + + let managerName = markedCodes.get(materialCode) || null + const isMarkedForDeletion = managerName !== null + let matchedTypeKeyword: string | undefined + + if (!managerName) { + for (const typeKeyword of typeKeywords) { + if (typeKeyword.materialName && materialName.includes(typeKeyword.materialName)) { + matchedTypeKeyword = typeKeyword.materialName + managerName = typeKeyword.managerName + break + } + } + } + + if (!context.isAdmin && context.username) { + const userKeywords = typeKeywords.filter( + (keyword) => keyword.managerName === context.username + ) + for (const userKeyword of userKeywords) { + if (userKeyword.materialName && materialName.includes(userKeyword.materialName)) { + matchedTypeKeyword = userKeyword.materialName + managerName = userKeyword.managerName + break + } + } + } + + return { + materialName, + materialCode, + specification, + model, + managerName: managerName || '', + isMarkedForDeletion, + matchedTypeKeyword + } + }) + } + + private async enrichMaterials( + materials: Array<{ materialCode: string; managerName: string }>, + markedCodes: Set + ): Promise { + let dbService: ValidationDatabaseService | null = null + + try { + dbService = await createValidationDatabaseService() + const detailTableName = getValidationTableName('dbo_DiscreteMaterialPlanData') + const enrichedMaterials: MaterialRecordSummary[] = [] + + for (const material of materials) { + const detailResult = await this.queryMaterialDetail( + dbService, + detailTableName, + material.materialCode + ) + const firstRow = detailResult.rows[0] + + enrichedMaterials.push({ + materialCode: material.materialCode, + materialName: firstRow ? (firstRow.MaterialName as string) : '', + specification: firstRow ? (firstRow.Specification as string) : '', + model: firstRow ? (firstRow.Model as string) : '', + managerName: material.managerName, + isMarked: markedCodes.has(material.materialCode) + }) + } + + return enrichedMaterials + } finally { + if (dbService) { + await this.disconnectQuietly(dbService) + } + } + } + + private async queryMaterialDetail( + dbService: ValidationDatabaseService, + detailTableName: string, + materialCode: string + ) { + if (dbService.type === 'sqlserver') { + const sql = await import('mssql') + return (dbService as SqlServerService).queryWithParams( + ` + SELECT TOP 1 MaterialName, Specification, Model + FROM ${detailTableName} + WHERE MaterialCode = @materialCode + `, + { + materialCode: { value: materialCode, type: sql.default.NVarChar } + } + ) + } + + return dbService.query( + ` + SELECT MaterialName, Specification, Model + FROM ${detailTableName} + WHERE MaterialCode = ? + LIMIT 1 + `, + [materialCode] + ) + } + + private async loadMaterialCodesForCleaner( + dbService: ValidationDatabaseService, + username: string, + isAdmin: boolean + ): Promise { + const markedTableName = getValidationTableName('dbo_MaterialsToBeDeleted') + + if (isAdmin) { + const result = await dbService.query( + ` + SELECT MaterialCode + FROM ${markedTableName} + WHERE MaterialCode IS NOT NULL + ` + ) + const materialCodes = result.rows.map((row) => row.MaterialCode as string).filter(Boolean) + log.info(`Admin user: got ${materialCodes.length} materials`) + return materialCodes + } + + if (dbService.type === 'sqlserver') { + const sql = await import('mssql') + const result = await (dbService as SqlServerService).queryWithParams( + ` + SELECT MaterialCode + FROM ${markedTableName} + WHERE ManagerName = @username AND MaterialCode IS NOT NULL + `, + { + username: { value: username, type: sql.default.NVarChar } + } + ) + const materialCodes = result.rows + .map((row: Record) => row.MaterialCode as string) + .filter(Boolean) + log.info(`Regular user: got ${materialCodes.length} materials`) + return materialCodes + } + + const result = await dbService.query( + ` + SELECT MaterialCode + FROM ${markedTableName} + WHERE ManagerName = ? AND MaterialCode IS NOT NULL + `, + [username] + ) + const materialCodes = result.rows.map((row) => row.MaterialCode as string).filter(Boolean) + log.info(`Regular user: got ${materialCodes.length} materials`) + return materialCodes + } + + private async disconnectQuietly(dbService: ValidationDatabaseService): Promise { + try { + await dbService.disconnect() + } catch (closeError) { + log.warn('Error disconnecting database', { + error: closeError instanceof Error ? closeError.message : String(closeError) + }) + } + } +} + +export const validationApplicationService = new ValidationApplicationService() diff --git a/src/main/services/validation/validation-database.ts b/src/main/services/validation/validation-database.ts new file mode 100644 index 0000000..24cddf1 --- /dev/null +++ b/src/main/services/validation/validation-database.ts @@ -0,0 +1,56 @@ +import { ConfigManager } from '../config/config-manager' +import { MySqlService } from '../database/mysql' +import { SqlServerService } from '../database/sql-server' + +export type ValidationDatabaseService = MySqlService | SqlServerService + +export async function createValidationDatabaseService(): Promise { + const configManager = ConfigManager.getInstance() + const config = configManager.getConfig() + const dbType = configManager.getDatabaseType() + + if (dbType === 'sqlserver') { + const dbConfig = config.database.sqlserver + const sqlServerService = new SqlServerService({ + server: dbConfig.server, + port: dbConfig.port, + user: dbConfig.username, + password: dbConfig.password, + database: dbConfig.database, + options: { + encrypt: false, + trustServerCertificate: dbConfig.trustServerCertificate + } + }) + await sqlServerService.connect() + return sqlServerService + } + + const dbConfig = config.database.mysql + const mysqlService = new MySqlService({ + host: dbConfig.host, + port: dbConfig.port, + user: dbConfig.username, + password: dbConfig.password, + database: dbConfig.database + }) + await mysqlService.connect() + return mysqlService +} + +export function getValidationTableName(mysqlTableName: string): string { + const configManager = ConfigManager.getInstance() + const dbType = configManager.getDatabaseType() + + if (dbType === 'sqlserver') { + const firstUnderscoreIndex = mysqlTableName.indexOf('_') + if (firstUnderscoreIndex > 0) { + const schema = mysqlTableName.substring(0, firstUnderscoreIndex) + const tableName = mysqlTableName.substring(firstUnderscoreIndex + 1) + return `[${schema}].[${tableName}]` + } + return `[dbo].[${mysqlTableName}]` + } + + return mysqlTableName +} diff --git a/tests/unit/production-input-service.test.ts b/tests/unit/production-input-service.test.ts new file mode 100644 index 0000000..ddb63b7 --- /dev/null +++ b/tests/unit/production-input-service.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' +import { identifyInputType } from '../../src/main/services/validation/production-input-service' + +describe('production-input-service', () => { + it('identifies order numbers', () => { + expect(identifyInputType('SC12345678901234')).toBe('order_number') + }) + + it('identifies production ids', () => { + expect(identifyInputType('26A1234')).toBe('production_id') + }) + + it('returns unknown for unsupported formats', () => { + expect(identifyInputType('invalid-value')).toBe('unknown') + }) +}) diff --git a/tests/unit/shared-production-ids-store.test.ts b/tests/unit/shared-production-ids-store.test.ts new file mode 100644 index 0000000..a953cf3 --- /dev/null +++ b/tests/unit/shared-production-ids-store.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { SharedProductionIdsStore } from '../../src/main/services/validation/shared-production-ids-store' + +describe('SharedProductionIdsStore', () => { + it('stores unique ids per sender', () => { + const store = new SharedProductionIdsStore() + + store.set(1, ['A', 'B', 'A']) + + expect(store.get(1)).toEqual(['A', 'B']) + }) + + it('keeps sender scopes isolated', () => { + const store = new SharedProductionIdsStore() + + store.set(1, ['A']) + store.set(2, ['B']) + + expect(store.get(1)).toEqual(['A']) + expect(store.get(2)).toEqual(['B']) + }) + + it('clears sender data', () => { + const store = new SharedProductionIdsStore() + + store.set(1, ['A']) + store.clear(1) + + expect(store.get(1)).toEqual([]) + }) +})