diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index 8f03495..8991a36 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -9,6 +9,7 @@ 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' /** * Register all IPC handlers @@ -20,4 +21,5 @@ export function registerIpcHandlers(): void { registerDatabaseHandlers() registerResolverHandlers() registerAuthHandlers() + registerValidationHandlers() } diff --git a/src/main/ipc/validation-handler.ts b/src/main/ipc/validation-handler.ts new file mode 100644 index 0000000..b906dc7 --- /dev/null +++ b/src/main/ipc/validation-handler.ts @@ -0,0 +1,618 @@ +/** + * 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() + +/** + * 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 { + 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 { + 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 => { + 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() + 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 => { + 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 => { + 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 => { + 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() } + } + ) +} diff --git a/src/main/services/database/discrete-material-plan-dao.ts b/src/main/services/database/discrete-material-plan-dao.ts new file mode 100644 index 0000000..6581945 --- /dev/null +++ b/src/main/services/database/discrete-material-plan-dao.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + if (this.mysqlService) { + await this.mysqlService.disconnect() + this.mysqlService = null + } + } +} diff --git a/src/main/services/database/materials-to-be-deleted-dao.ts b/src/main/services/database/materials-to-be-deleted-dao.ts new file mode 100644 index 0000000..520aabf --- /dev/null +++ b/src/main/services/database/materials-to-be-deleted-dao.ts @@ -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[] +} + +/** + * 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 { + 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 { + 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 { + 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> { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + if (this.mysqlService) { + await this.mysqlService.disconnect() + this.mysqlService = null + } + } +} diff --git a/src/main/services/database/mysql.ts b/src/main/services/database/mysql.ts index ba2bed4..30cac6d 100644 --- a/src/main/services/database/mysql.ts +++ b/src/main/services/database/mysql.ts @@ -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[]) : [] + // Handle different result types + let rows: Record[] = [] + let rowCount = 0 + + if (Array.isArray(result)) { + // SELECT query - result is an array of rows + rows = result as Record[] + 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 } diff --git a/src/main/types/validation.types.ts b/src/main/types/validation.types.ts new file mode 100644 index 0000000..c1f8d6e --- /dev/null +++ b/src/main/types/validation.types.ts @@ -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 +} diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 14d6452..15600e0 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -8,6 +8,7 @@ import type { UserSelectionResponse, CurrentUserResponse } from '../main/ipc/auth-handler' +import type { ValidationRequest, ValidationResponse } from '../main/types/validation.types' /** * Order number resolver API @@ -69,6 +70,67 @@ export interface AuthAPI { isAdmin: () => Promise } +/** + * Validation API + */ +export interface ValidationAPI { + /** + * Run material validation + * @param request - Validation request + */ + validate: (request: ValidationRequest) => Promise + /** + * Set shared Production IDs from extractor page + * @param productionIds - List of Production IDs + */ + setSharedProductionIds: (productionIds: string[]) => Promise + /** + * Get shared Production IDs + */ + getSharedProductionIds: () => Promise<{ productionIds: 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 }> +} + declare global { interface Window { electron: { @@ -93,6 +155,8 @@ declare global { database: DatabaseAPI resolver: ResolverAPI auth: AuthAPI + validation: ValidationAPI + materials: MaterialsAPI } api: unknown } diff --git a/src/preload/index.ts b/src/preload/index.ts index 6dd1451..a07f84a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -6,6 +6,7 @@ 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' // Custom APIs for renderer const api = { @@ -61,6 +62,28 @@ const api = { isSqlServerConnected: () => ipcRenderer.invoke('database:sqlserver:isConnected'), querySqlServer: (sql: string, params?: Record) => 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') + }, + + // 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') } } as const diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index ce913fb..5d352b5 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -290,7 +290,7 @@ function App(): React.JSX.Element { minHeight: '100vh', backgroundColor: '#f5f7fa' }}> - {/* Header with user info and logout */} + {/* Header with user info, navigation and logout */}
-
+
欢迎,{currentUser?.username} ({currentUser?.userType}) + {/* Navigation tabs */} +
{shouldShowLogout && ( @@ -330,34 +378,9 @@ function App(): React.JSX.Element { {currentPage === 'home' && (

主页面

-
-
setCurrentPage('extractor')} style={{ - padding: '24px', - backgroundColor: '#fff', - borderRadius: '8px', - boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)', - cursor: 'pointer', - textAlign: 'center' - }}> -

数据提取

-

提取 ERP 数据

-
-
setCurrentPage('cleaner')} style={{ - padding: '24px', - backgroundColor: '#fff', - borderRadius: '8px', - boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)', - cursor: 'pointer', - textAlign: 'center' - }}> -

物料清理

-

清理 ERP 物料

-
-
+

+ 请使用顶部导航栏切换到 数据提取 或 物料清理 页面 +

)} diff --git a/src/renderer/src/pages/CleanerPage.tsx b/src/renderer/src/pages/CleanerPage.tsx index 3189fc5..3fd8267 100644 --- a/src/renderer/src/pages/CleanerPage.tsx +++ b/src/renderer/src/pages/CleanerPage.tsx @@ -1,38 +1,399 @@ -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 [orderNumbers, setOrderNumbers] = useState(() => { + return sessionStorage.getItem('cleaner_orderNumbers') || '' + }) + const [materialCodes, setMaterialCodes] = useState(() => { + return sessionStorage.getItem('cleaner_materialCodes') || '' + }) + const [dryRun, setDryRun] = 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(null) - const [result, setResult] = useState(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(null) - const handleClean = async () => { + // Validation state + const [validationResults, setValidationResults] = useState([]) + const [selectedItems, setSelectedItems] = useState>(new Set()) + const [managers, setManagers] = useState([]) + const [selectedManagers, setSelectedManagers] = useState>(new Set()) + const [isAdmin, setIsAdmin] = useState(false) + const [currentUsername, setCurrentUsername] = useState('') + 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>(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_orderNumbers', orderNumbers) + }, [orderNumbers]) + + useEffect(() => { + sessionStorage.setItem('cleaner_materialCodes', materialCodes) + }, [materialCodes]) + + 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) { + setCurrentUsername(user.username) + if (!admin) { + // Non-admin users can only see their own data + setSelectedManagers(new Set([user.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 + } + + // 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 export results + const handleExportResults = useCallback(async () => { + if (validationResults.length === 0) { + setError('没有数据可导出') + return + } + + try { + // Prepare data for export + const data = validationResults.map(result => ({ + '选择': selectedItems.has(result.materialCode) ? '是' : '否', + '材料名称': result.materialName, + '材料代码': result.materialCode, + '规格': result.specification, + '型号': result.model, + '负责人': result.managerName || '' + })) + + // Create CSV content + const headers = ['选择', '材料名称', '材料代码', '规格', '型号', '负责人'] + const csvContent = [ + headers.join(','), + ...data.map(row => + headers.map(header => { + const value = row[header as keyof typeof row] + // Escape quotes and wrap in quotes if contains comma + const escaped = String(value).replace(/"/g, '""') + return escaped.includes(',') ? `"${escaped}"` : escaped + }).join(',') + ) + ].join('\n') + + // Create blob and download + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }) + const link = document.createElement('a') + const url = URL.createObjectURL(blob) + link.setAttribute('href', url) + link.setAttribute('download', `物料状态校验结果_${new Date().getTime()}.csv`) + link.style.visibility = 'hidden' + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + } catch (err) { + setError(err instanceof Error ? err.message : '导出失败') + } + }, [validationResults, selectedItems]) + + // Handle execute deletion + const handleExecuteDeletion = useCallback(async () => { if (!orderNumbers.trim()) { setError('请输入至少一个订单号') return @@ -42,6 +403,11 @@ export const CleanerPage: React.FC = () => { return } + if (!dryRun) { + const confirmed = window.confirm('警告:正式执行将删除 ERP 系统中的物料数据,是否继续?') + if (!confirmed) return + } + setIsRunning(true) setProgress(null) setResult(null) @@ -50,15 +416,14 @@ export const CleanerPage: React.FC = () => { try { const orderNumberList = orderNumbers .split('\n') - .map((line) => line.trim()) - .filter((line) => line.length > 0) + .map(line => line.trim()) + .filter(line => line.length > 0) const materialCodeList = materialCodes .split('\n') - .map((line) => line.trim()) - .filter((line) => line.length > 0) + .map(line => line.trim()) + .filter(line => line.length > 0) - // Call cleaner API through electron const response = await window.electron.cleaner.runCleaner({ orderNumbers: orderNumberList, materialCodes: materialCodeList, @@ -76,37 +441,273 @@ export const CleanerPage: React.FC = () => { setIsRunning(false) setProgress(null) } - } + }, [orderNumbers, materialCodes, dryRun]) - const handleReset = () => { + // Handle clean (alias for execute deletion) + const handleClean = useCallback(handleExecuteDeletion, [handleExecuteDeletion]) + + // Handle reset + const handleReset = useCallback(() => { 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 (

ERP 物料清理

- {/* Input Section */} -
- + {/* Validation Section (Admin only for mode selection) */} +
+

物料校验

- + {isAdmin && ( +
+ + + {validationMode === 'database_filtered' && ( +
+ + {useSharedProductionIds && ( +

0 ? '#52c41a' : '#fa8c16' }}> + {sharedProductionIdsCount > 0 + ? `当前有 ${sharedProductionIdsCount} 个共享的 Production ID` + : '暂无共享的 Production ID,请先在数据提取页面输入'} +

+ )} +
+ )} +
+ )} + + {!isAdmin && ( +

+ 当前模式:数据库 - ProductionID 过滤(使用数据提取页面的数据) +

+ )} + +
+ + {isAdmin && ( + <> + + + + )} + +
+ + {validationStats && ( +
+ 总记录数:{validationStats.totalRecords} + 匹配到负责人:{validationStats.matchedCount} + 已标记删除:{validationStats.markedCount} +
+ )} + + {/* Additional action buttons */} +
+ + {isAdmin && ( + + )} +
+
+ + {/* Manager Filter (Admin only) */} + {isAdmin && managers.length > 0 && ( +
+

筛选(按负责人)

+
+ {managers.map(manager => ( + + ))} +
+
+ )} + + {/* Validation Results Table */} + {filteredResults.length > 0 && ( +
+
+

校验结果

+ {/* User mode buttons: Hide Checked / Show All */} + {!isAdmin && ( +
+ + +
+ )} +
+ + + + + + + + + + + + + {filteredResults.map(result => ( + + + + + + + + + ))} + +
选择材料名称材料代码规格型号负责人
+ handleCheckboxToggle(result.materialCode)} + /> + {result.materialName}{result.materialCode}{result.specification}{result.model}{result.managerName || '-'}
+
+ )} + + {/* Simple Input Section */} +
+

订单号和物料代码输入

+
+
+ +