diff --git a/src/main/ipc/extractor-handler.ts b/src/main/ipc/extractor-handler.ts index ddf1975..7e6aa40 100644 --- a/src/main/ipc/extractor-handler.ts +++ b/src/main/ipc/extractor-handler.ts @@ -3,6 +3,7 @@ import { ErpAuthService } from '../services/erp/erp-auth' import { ExtractorService } from '../services/erp/extractor' import { OrderNumberResolver } from '../services/erp/order-resolver' import { create, type IDatabaseService } from '../services/database' +import { ExtractorOperationHistoryDAO } from '../services/database/extractor-operation-history-dao' import { createLogger } from '../services/logger' import { logAudit } from '../services/logger/audit-logger' import { SessionManager } from '../services/user/session-manager' @@ -12,6 +13,7 @@ import type { ExtractorInput, ExtractorResult, ExtractionProgress } from '../typ import { UserErpConfigService } from '../services/user/user-erp-config-service' import { ConfigManager } from '../services/config/config-manager' import { IPC_CHANNELS } from '../../shared/ipc-channels' +import { randomUUID } from 'crypto' const log = createLogger('ExtractorHandler') @@ -141,6 +143,29 @@ export function registerExtractorHandlers(): void { log.info('Resolved order numbers', { count: validOrderNumbers.length }) + // Initialize operation history recording + const currentUser = SessionManager.getInstance().getUserInfo() + const historyDao = new ExtractorOperationHistoryDAO() + const batchId = randomUUID() + + // Save order records to history (preserve productionId -> orderNumber mapping) + if (currentUser) { + const orderRecords = mappings.map((m) => ({ + productionId: m.productionId || null, + orderNumber: m.orderNumber || m.input + })) + await historyDao.insertBatchRecords( + batchId, + currentUser.id, + currentUser.username, + orderRecords + ) + log.info('Operation history batch created', { + batchId, + recordCount: orderRecords.length + }) + } + // Log deduplication summary sendLog(sender, 'info', dedupReport.summary) @@ -223,11 +248,22 @@ export function registerExtractorHandlers(): void { }) } + // Update operation history batch status + if (currentUser) { + const status: 'success' | 'failed' | 'partial' = + result.errors.length > 0 && result.recordCount > 0 + ? 'partial' + : result.errors.length > 0 + ? 'failed' + : 'success' + await historyDao.updateBatchStatus(batchId, status, result.recordCount) + log.info('Operation history batch status updated', { batchId, status }) + } + // Audit log: EXTRACT (non-blocking) const os = await import('os') - const currentUser = SessionManager.getInstance().getUserInfo() if (currentUser) { - const status: 'success' | 'failure' | 'partial' = + const auditStatus: 'success' | 'failure' | 'partial' = result.errors.length > 0 && result.recordCount > 0 ? 'partial' : result.errors.length > 0 @@ -237,7 +273,7 @@ export function registerExtractorHandlers(): void { username: currentUser.username, computerName: os.hostname(), resource: 'MATERIAL_PLAN', - status, + status: auditStatus, metadata: { orderCount: validOrderNumbers.length, recordCount: result.recordCount, diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index c300ade..3b7268f 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -17,6 +17,7 @@ import { registerLoggerHandlers } from './logger-handler' import { registerReportHandlers } from './report-handler' import { registerUpdateHandlers } from './update-handler' import { registerPlaywrightBrowserHandlers } from './playwright-browser' +import { registerOperationHistoryHandlers } from './operation-history-handler' import { createLogger, logError } from '../services/logger' import { serializeError, sanitizeError } from '../services/logger/error-utils' import { getErrorMessage, getErrorCode, isBaseError } from '../types/errors' @@ -107,5 +108,6 @@ export function registerIpcHandlers(): void { registerReportHandlers() registerUpdateHandlers() registerPlaywrightBrowserHandlers() + registerOperationHistoryHandlers() log.info('All IPC handlers registered') } diff --git a/src/main/ipc/operation-history-handler.ts b/src/main/ipc/operation-history-handler.ts new file mode 100644 index 0000000..c91dbf7 --- /dev/null +++ b/src/main/ipc/operation-history-handler.ts @@ -0,0 +1,124 @@ +/** + * IPC Handler for Extractor Operation History + * + * Handles IPC requests for operation history management: + * - Get batch list (filtered by user for non-admin users) + * - Get batch details + * - Delete batches + */ + +import { ipcMain } from 'electron' +import { ExtractorOperationHistoryDAO } from '../services/database/extractor-operation-history-dao' +import { SessionManager } from '../services/user/session-manager' +import { withErrorHandling, type IpcResult } from './index' +import { IPC_CHANNELS } from '../../shared/ipc-channels' +import { createLogger } from '../services/logger' +import type { + BatchStats, + OperationHistoryRecord, + GetBatchesOptions +} from '../types/operation-history.types' + +const log = createLogger('OperationHistoryHandler') + +/** + * Register IPC handlers for operation history + */ +export function registerOperationHistoryHandlers(): void { + const dao = new ExtractorOperationHistoryDAO() + + /** + * Get batches list + * Admin users get all batches, regular users get only their own + */ + ipcMain.handle( + IPC_CHANNELS.OPERATION_HISTORY_GET_BATCHES, + async (event, options?: GetBatchesOptions): Promise> => { + return withErrorHandling(async () => { + const currentUser = SessionManager.getInstance().getUserInfo() + + if (!currentUser) { + throw new Error('用户未登录') + } + + // Admin gets all batches, User gets only their own + const userId = currentUser.userType === 'Admin' ? undefined : currentUser.id + + log.info('Getting operation history batches', { + userId: currentUser.id, + userType: currentUser.userType, + filtered: userId !== undefined + }) + + const batches = await dao.getBatches(userId, options) + return batches + }, 'operationHistory:getBatches') + } + ) + + /** + * Get batch details + * Users can only view their own batch details, admins can view all + */ + ipcMain.handle( + IPC_CHANNELS.OPERATION_HISTORY_GET_BATCH_DETAILS, + async (event, batchId: string): Promise> => { + return withErrorHandling(async () => { + const currentUser = SessionManager.getInstance().getUserInfo() + + if (!currentUser) { + throw new Error('用户未登录') + } + + log.info('Getting batch details', { batchId, userId: currentUser.id }) + + const details = await dao.getBatchDetails(batchId) + + // For non-admin users, verify they own this batch + if (currentUser.userType !== 'Admin' && details.length > 0) { + const batchOwnerId = details[0].userId + if (batchOwnerId !== currentUser.id) { + throw new Error('没有权限查看此批次详情') + } + } + + return details + }, 'operationHistory:getBatchDetails') + } + ) + + /** + * Delete a batch + * Users can only delete their own batches, admins can delete any + */ + ipcMain.handle( + IPC_CHANNELS.OPERATION_HISTORY_DELETE_BATCH, + async (event, batchId: string): Promise> => { + return withErrorHandling(async () => { + const currentUser = SessionManager.getInstance().getUserInfo() + + if (!currentUser) { + throw new Error('用户未登录') + } + + const isAdmin = currentUser.userType === 'Admin' + + log.info('Deleting batch', { + batchId, + userId: currentUser.id, + isAdmin + }) + + const result = await dao.deleteBatch(batchId, currentUser.id, isAdmin) + + if (!result.success) { + throw new Error(result.error || '删除批次失败') + } + + return { deleted: true } + }, 'operationHistory:deleteBatch') + } + ) + + log.info('Operation history IPC handlers registered') +} diff --git a/src/main/services/database/extractor-operation-history-dao.ts b/src/main/services/database/extractor-operation-history-dao.ts new file mode 100644 index 0000000..25fb680 --- /dev/null +++ b/src/main/services/database/extractor-operation-history-dao.ts @@ -0,0 +1,607 @@ +/** + * Data Access Object for ExtractorOperationHistory table + * + * Handles database operations for tracking extraction operation history: + * - Batch record insertion + * - Batch status updates + * - Querying batches (with user filtering for non-admin users) + * - Getting batch details + * - Deleting batches + */ + +import { create, type IDatabaseService } from './index' +import { createLogger } from '../logger' +import type { + OperationHistoryRecord, + BatchStats, + InsertBatchRecordInput, + UpdateBatchStatusResult, + GetBatchesOptions +} from '../../types/operation-history.types' + +const log = createLogger('ExtractorOperationHistoryDAO') + +/** + * Configuration for ExtractorOperationHistory table + */ +export const EXTRACTOR_OPERATION_HISTORY_CONFIG = { + TABLE_NAME_SQLSERVER: '[dbo].[ExtractorOperationHistory]', + TABLE_NAME_MYSQL: 'dbo_ExtractorOperationHistory', + COLUMNS: { + ID: 'ID', + BATCH_ID: 'BatchId', + USER_ID: 'UserId', + USERNAME: 'Username', + PRODUCTION_ID: 'ProductionId', + ORDER_NUMBER: 'OrderNumber', + OPERATION_TIME: 'OperationTime', + STATUS: 'Status', + RECORD_COUNT: 'RecordCount', + ERROR_MESSAGE: 'ErrorMessage' + } +} as const + +/** + * ExtractorOperationHistory DAO Class + */ +export class ExtractorOperationHistoryDAO { + private dbService: IDatabaseService | null = null + + /** + * Get the appropriate table name based on database type + */ + private getTableName(): string { + const isSqlServer = this.dbService?.type === 'sqlserver' + return isSqlServer + ? EXTRACTOR_OPERATION_HISTORY_CONFIG.TABLE_NAME_SQLSERVER + : EXTRACTOR_OPERATION_HISTORY_CONFIG.TABLE_NAME_MYSQL + } + + /** + * Get database service instance using DatabaseFactory + */ + private async getDatabaseService(): Promise { + if (this.dbService && this.dbService.isConnected()) { + return this.dbService + } + + this.dbService = await create() + return this.dbService + } + + /** + * Build placeholders for IN clause based on database type + */ + private buildPlaceholders(count: number, isSqlServer: boolean): string { + return isSqlServer + ? Array.from({ length: count }, (_, idx) => `@p${idx}`).join(',') + : Array.from({ length: count }, () => '?').join(',') + } + + // ==================== INSERT ==================== + + /** + * Insert batch records for a single extraction operation + * @param batchId - Unique batch identifier + * @param userId - User ID performing the operation + * @param username - Username performing the operation + * @param records - Array of order records to insert + * @returns True if successful + */ + async insertBatchRecords( + batchId: string, + userId: number, + username: string, + records: InsertBatchRecordInput[] + ): Promise { + if (!records || records.length === 0) { + log.warn('No records to insert') + return false + } + + try { + const dbService = await this.getDatabaseService() + const tableName = this.getTableName() + const isSqlServer = dbService.type === 'sqlserver' + + for (const record of records) { + try { + if (isSqlServer) { + const sqlString = ` + INSERT INTO ${tableName} + (BatchId, UserId, Username, ProductionId, OrderNumber, OperationTime, Status) + VALUES + (@p0, @p1, @p2, @p3, @p4, GETDATE(), 'pending') + ` + await dbService.query(sqlString, [ + batchId, + userId, + username, + record.productionId || null, + record.orderNumber + ]) + } else { + const sqlString = ` + INSERT INTO ${tableName} + (BatchId, UserId, Username, ProductionId, OrderNumber, OperationTime, Status) + VALUES + (?, ?, ?, ?, ?, NOW(), 'pending') + ` + await dbService.query(sqlString, [ + batchId, + userId, + username, + record.productionId || null, + record.orderNumber + ]) + } + } catch (error) { + log.error('Error inserting individual record', { + batchId, + orderNumber: record.orderNumber, + error: error instanceof Error ? error.message : String(error) + }) + } + } + + log.info('Batch records inserted', { batchId, count: records.length }) + return true + } catch (error) { + log.error('Insert batch records error', { + error: error instanceof Error ? error.message : String(error) + }) + return false + } + } + + // ==================== UPDATE ==================== + + /** + * Update the status of all records in a batch + * @param batchId - Batch identifier + * @param status - New status (success, failed, partial) + * @param recordCount - Total record count for the batch + * @returns Update result + */ + async updateBatchStatus( + batchId: string, + status: string, + recordCount: number | null + ): Promise { + try { + const dbService = await this.getDatabaseService() + const tableName = this.getTableName() + const isSqlServer = dbService.type === 'sqlserver' + + const placeholder = isSqlServer ? '@p0' : '?' + let sqlString: string + let params: (string | number | null)[] + + if (recordCount !== null) { + sqlString = ` + UPDATE ${tableName} + SET Status = ${isSqlServer ? '@p0' : '?'}, + RecordCount = ${isSqlServer ? '@p1' : '?'} + WHERE BatchId = ${isSqlServer ? '@p2' : '?'} + ` + params = isSqlServer ? [status, recordCount, batchId] : [status, recordCount, batchId] + } else { + sqlString = ` + UPDATE ${tableName} + SET Status = ${placeholder} + WHERE BatchId = ${isSqlServer ? '@p1' : '?'} + ` + params = isSqlServer ? [status, batchId] : [status, batchId] + } + + await dbService.query(sqlString, params) + + log.info('Batch status updated', { batchId, status, recordCount }) + return { success: true, updatedCount: 1 } + } catch (error) { + log.error('Update batch status error', { + batchId, + error: error instanceof Error ? error.message : String(error) + }) + return { success: false, updatedCount: 0 } + } + } + + /** + * Update a single record's status and error message + * @param batchId - Batch identifier + * @param orderNumber - Order number + * @param status - New status + * @param errorMessage - Optional error message + * @returns True if successful + */ + async updateRecordStatus( + batchId: string, + orderNumber: string, + status: string, + errorMessage?: string + ): Promise { + try { + const dbService = await this.getDatabaseService() + const tableName = this.getTableName() + const isSqlServer = dbService.type === 'sqlserver' + + const sqlString = ` + UPDATE ${tableName} + SET Status = ${isSqlServer ? '@p0' : '?'}, + ErrorMessage = ${isSqlServer ? '@p1' : '?'} + WHERE BatchId = ${isSqlServer ? '@p2' : '?'} + AND OrderNumber = ${isSqlServer ? '@p3' : '?'} + ` + + await dbService.query(sqlString, [status, errorMessage || null, batchId, orderNumber]) + + return true + } catch (error) { + log.error('Update record status error', { + batchId, + orderNumber, + error: error instanceof Error ? error.message : String(error) + }) + return false + } + } + + // ==================== READ ==================== + + /** + * Get batch statistics with optional user filtering + * @param userId - Optional user ID for filtering (Admin gets all, User gets own) + * @param options - Query options (limit, offset) + * @returns Array of batch statistics + */ + async getBatches(userId?: number, options?: GetBatchesOptions): Promise { + try { + const dbService = await this.getDatabaseService() + const tableName = this.getTableName() + const isSqlServer = dbService.type === 'sqlserver' + + let sqlString = ` + SELECT + BatchId, + UserId, + Username, + MIN(OperationTime) as OperationTime, + MAX(Status) as Status, + COUNT(*) as TotalOrders, + SUM(COALESCE(RecordCount, 0)) as TotalRecords, + SUM(CASE WHEN Status = 'success' THEN 1 ELSE 0 END) as SuccessCount, + SUM(CASE WHEN Status = 'failed' THEN 1 ELSE 0 END) as FailedCount + FROM ${tableName} + ` + + const params: (number | string)[] = [] + + if (userId !== undefined) { + sqlString += isSqlServer ? ` WHERE UserId = @p0 ` : ` WHERE UserId = ? ` + params.push(userId) + } + + sqlString += ` + GROUP BY BatchId, UserId, Username + ORDER BY OperationTime DESC + ` + + if (options?.limit) { + // Add pagination - track current param count before adding new params + const offsetIndex = params.length + const limitIndex = params.length + 1 + + if (options.offset !== undefined) { + params.push(options.offset) + } + params.push(options.limit) + + if (isSqlServer) { + if (options.offset !== undefined) { + sqlString += ` OFFSET @p${offsetIndex} ROWS FETCH NEXT @p${limitIndex} ROWS ONLY` + } else { + // When no offset, use 0 for offset and next index for limit + sqlString += ` OFFSET 0 ROWS FETCH NEXT @p${offsetIndex} ROWS ONLY` + } + } else { + if (options.offset !== undefined) { + sqlString += ` LIMIT ?` + // For MySQL with offset, we need to modify the query + // Replace LIMIT with OFFSET LIMIT + const parts = sqlString.split(' LIMIT ?') + sqlString = parts[0] + ` OFFSET ? LIMIT ?` + (parts[1] || '') + } else { + sqlString += ` LIMIT ?` + } + } + } + + const result = await dbService.query(sqlString, params) + + return result.rows.map((row) => ({ + batchId: row.BatchId as string, + userId: row.UserId as number, + username: row.Username as string, + operationTime: row.OperationTime + ? new Date(row.OperationTime as string).toISOString() + : new Date().toISOString(), + status: row.Status as string, + totalOrders: row.TotalOrders as number, + totalRecords: (row.TotalRecords as number) || 0, + successCount: (row.SuccessCount as number) || 0, + failedCount: (row.FailedCount as number) || 0 + })) + } catch (error) { + log.error('Get batches error', { + error: error instanceof Error ? error.message : String(error) + }) + return [] + } + } + + /** + * Get detailed records for a specific batch + * @param batchId - Batch identifier + * @returns Array of operation records + */ + async getBatchDetails(batchId: string): Promise { + try { + const dbService = await this.getDatabaseService() + const tableName = this.getTableName() + const isSqlServer = dbService.type === 'sqlserver' + + const placeholder = isSqlServer ? '@p0' : '?' + const sqlString = ` + SELECT + ID, + BatchId, + UserId, + Username, + ProductionId, + OrderNumber, + OperationTime, + Status, + RecordCount, + ErrorMessage + FROM ${tableName} + WHERE BatchId = ${placeholder} + ORDER BY ID + ` + + const result = await dbService.query(sqlString, [batchId]) + + return result.rows.map((row) => ({ + id: row.ID as number, + batchId: row.BatchId as string, + userId: row.UserId as number, + username: row.Username as string, + productionId: row.ProductionId as string | null, + orderNumber: row.OrderNumber as string, + operationTime: new Date(row.OperationTime as string), + status: row.Status as string, + recordCount: row.RecordCount as number | null, + errorMessage: row.ErrorMessage as string | null + })) + } catch (error) { + log.error('Get batch details error', { + batchId, + error: error instanceof Error ? error.message : String(error) + }) + return [] + } + } + + /** + * Get a single batch's statistics + * @param batchId - Batch identifier + * @returns Batch statistics or null + */ + async getBatchStats(batchId: string): Promise { + try { + const dbService = await this.getDatabaseService() + const tableName = this.getTableName() + const isSqlServer = dbService.type === 'sqlserver' + + const placeholder = isSqlServer ? '@p0' : '?' + const sqlString = ` + SELECT + BatchId, + UserId, + Username, + MIN(OperationTime) as OperationTime, + MAX(Status) as Status, + COUNT(*) as TotalOrders, + SUM(COALESCE(RecordCount, 0)) as TotalRecords, + SUM(CASE WHEN Status = 'success' THEN 1 ELSE 0 END) as SuccessCount, + SUM(CASE WHEN Status = 'failed' THEN 1 ELSE 0 END) as FailedCount + FROM ${tableName} + WHERE BatchId = ${placeholder} + GROUP BY BatchId, UserId, Username + ` + + const result = await dbService.query(sqlString, [batchId]) + + if (result.rows.length === 0) { + return null + } + + const row = result.rows[0] + return { + batchId: row.BatchId as string, + userId: row.UserId as number, + username: row.Username as string, + operationTime: row.OperationTime + ? new Date(row.OperationTime as string).toISOString() + : new Date().toISOString(), + status: row.Status as string, + totalOrders: row.TotalOrders as number, + totalRecords: (row.TotalRecords as number) || 0, + successCount: (row.SuccessCount as number) || 0, + failedCount: (row.FailedCount as number) || 0 + } + } catch (error) { + log.error('Get batch stats error', { + batchId, + error: error instanceof Error ? error.message : String(error) + }) + return null + } + } + + // ==================== DELETE ==================== + + /** + * Delete a batch with permission checking + * @param batchId - Batch identifier + * @param requestingUserId - User ID requesting the deletion + * @param isAdmin - Whether the requesting user is an admin + * @returns True if successful + */ + async deleteBatch( + batchId: string, + requestingUserId: number, + isAdmin: boolean + ): Promise<{ success: boolean; error?: string }> { + try { + const dbService = await this.getDatabaseService() + const tableName = this.getTableName() + const isSqlServer = dbService.type === 'sqlserver' + + // First check if the batch exists and if the user has permission + const batchStats = await this.getBatchStats(batchId) + + if (!batchStats) { + return { success: false, error: '批次不存在' } + } + + // Non-admin users can only delete their own batches + if (!isAdmin && batchStats.userId !== requestingUserId) { + return { success: false, error: '没有权限删除此批次' } + } + + // Delete the batch + const placeholder = isSqlServer ? '@p0' : '?' + const sqlString = ` + DELETE FROM ${tableName} + WHERE BatchId = ${placeholder} + ` + + const result = await dbService.query(sqlString, [batchId]) + + log.info('Batch deleted', { batchId, rowCount: result.rowCount }) + return { success: true } + } catch (error) { + log.error('Delete batch error', { + batchId, + error: error instanceof Error ? error.message : String(error) + }) + return { + success: false, + error: error instanceof Error ? error.message : String(error) + } + } + } + + /** + * Delete all batches for a specific user + * @param userId - User ID + * @returns Number of batches deleted + */ + async deleteByUser(userId: number): Promise { + try { + const dbService = await this.getDatabaseService() + const tableName = this.getTableName() + const isSqlServer = dbService.type === 'sqlserver' + + const placeholder = isSqlServer ? '@p0' : '?' + const sqlString = ` + DELETE FROM ${tableName} + WHERE UserId = ${placeholder} + ` + + const result = await dbService.query(sqlString, [userId]) + return result.rowCount + } catch (error) { + log.error('Delete by user error', { + userId, + error: error instanceof Error ? error.message : String(error) + }) + return 0 + } + } + + // ==================== UTILITIES ==================== + + /** + * Check if a batch exists + * @param batchId - Batch identifier + * @returns True if batch exists + */ + async batchExists(batchId: string): Promise { + try { + const dbService = await this.getDatabaseService() + const tableName = this.getTableName() + const isSqlServer = dbService.type === 'sqlserver' + + const placeholder = isSqlServer ? '@p0' : '?' + const sqlString = ` + SELECT COUNT(*) as count + FROM ${tableName} + WHERE BatchId = ${placeholder} + ` + + const result = await dbService.query(sqlString, [batchId]) + return result.rows.length > 0 && (result.rows[0].count as number) > 0 + } catch (error) { + log.error('Batch exists error', { + batchId, + error: error instanceof Error ? error.message : String(error) + }) + return false + } + } + + /** + * Count total batches with optional user filtering + * @param userId - Optional user ID for filtering + * @returns Total number of batches + */ + async countBatches(userId?: number): Promise { + try { + const dbService = await this.getDatabaseService() + const tableName = this.getTableName() + const isSqlServer = dbService.type === 'sqlserver' + + let sqlString = ` + SELECT COUNT(DISTINCT BatchId) as count + FROM ${tableName} + ` + + const params: number[] = [] + + if (userId !== undefined) { + sqlString += isSqlServer ? ` WHERE UserId = @p0 ` : ` WHERE UserId = ? ` + params.push(userId) + } + + const result = await dbService.query(sqlString, params) + return result.rows.length > 0 ? (result.rows[0].count as number) : 0 + } catch (error) { + log.error('Count batches error', { + error: error instanceof Error ? error.message : String(error) + }) + return 0 + } + } + + /** + * Disconnect from database + */ + async disconnect(): Promise { + if (this.dbService) { + await this.dbService.disconnect() + this.dbService = null + } + } +} diff --git a/src/main/types/operation-history.types.ts b/src/main/types/operation-history.types.ts new file mode 100644 index 0000000..a9e1852 --- /dev/null +++ b/src/main/types/operation-history.types.ts @@ -0,0 +1,86 @@ +/** + * Operation History Type Definitions + * + * Type definitions for the Extractor Operation History feature. + * Tracks extraction operations with batch and individual order record details. + */ + +/** + * Individual operation history record + */ +export interface OperationHistoryRecord { + /** Auto-increment ID */ + id?: number + /** Batch ID - shared among all orders in a single extraction operation */ + batchId: string + /** User ID who performed the operation */ + userId: number + /** Username who performed the operation */ + username: string + /** Original input production ID (e.g., "22A1"), null if input was already an order number */ + productionId: string | null + /** Resolved order number (e.g., "SC70202602120085") */ + orderNumber: string + /** When the operation was performed */ + operationTime: Date + /** Operation status: pending, success, failed, partial */ + status: string + /** Number of records extracted for this order */ + recordCount: number | null + /** Error message if operation failed */ + errorMessage: string | null +} + +/** + * Batch statistics - aggregated view of a batch operation + */ +export interface BatchStats { + /** Unique batch identifier */ + batchId: string + /** User ID who performed the operation */ + userId: number + /** Username who performed the operation */ + username: string + /** When the operation started */ + operationTime: string + /** Overall batch status: pending, success, failed, partial */ + status: string + /** Total number of orders in the batch */ + totalOrders: number + /** Total records extracted across all orders */ + totalRecords: number + /** Number of orders that succeeded */ + successCount: number + /** Number of orders that failed */ + failedCount: number +} + +/** + * Input for inserting batch records + */ +export interface InsertBatchRecordInput { + /** Original input production ID (e.g., "22A1") */ + productionId: string | null + /** Resolved order number (e.g., "SC70202602120085") */ + orderNumber: string +} + +/** + * Result for batch status update + */ +export interface UpdateBatchStatusResult { + /** Whether the update was successful */ + success: boolean + /** Number of records updated */ + updatedCount: number +} + +/** + * Options for querying batches + */ +export interface GetBatchesOptions { + /** Maximum number of batches to return */ + limit?: number + /** Number of batches to skip (for pagination) */ + offset?: number +} diff --git a/src/preload/api/index.ts b/src/preload/api/index.ts index 6ab8905..17191f4 100644 --- a/src/preload/api/index.ts +++ b/src/preload/api/index.ts @@ -17,6 +17,7 @@ import { } from './materials' import { loggerApi } from './logger' import { playwrightBrowserApi } from './browser-download' +import { operationHistoryApi } from './operation-history' export const api = { process: processApi, @@ -35,7 +36,8 @@ export const api = { logger: loggerApi, report: reportApi, update: updateApi, - playwrightBrowser: playwrightBrowserApi + playwrightBrowser: playwrightBrowserApi, + operationHistory: operationHistoryApi } as const export type ElectronApi = typeof api diff --git a/src/preload/api/operation-history.ts b/src/preload/api/operation-history.ts new file mode 100644 index 0000000..831ae93 --- /dev/null +++ b/src/preload/api/operation-history.ts @@ -0,0 +1,30 @@ +import { IPC_CHANNELS } from '../../shared/ipc-channels' +import { invokeIpc } from '../lib/ipc' +import type { + BatchStats, + OperationHistoryRecord, + GetBatchesOptions +} from '../../main/types/operation-history.types' +import type { IpcResult } from '../../main/types/ipc.types' + +export const operationHistoryApi = { + /** + * Get list of operation batches + * Admin users receive all batches, regular users only their own + */ + getBatches: (options?: GetBatchesOptions): Promise> => + invokeIpc(IPC_CHANNELS.OPERATION_HISTORY_GET_BATCHES, options), + + /** + * Get detailed records for a specific batch + */ + getBatchDetails: (batchId: string): Promise> => + invokeIpc(IPC_CHANNELS.OPERATION_HISTORY_GET_BATCH_DETAILS, batchId), + + /** + * Delete a batch + * Admin users can delete any batch, regular users only their own + */ + deleteBatch: (batchId: string): Promise> => + invokeIpc(IPC_CHANNELS.OPERATION_HISTORY_DELETE_BATCH, batchId) +} as const diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 3d6142c..9e40259 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -157,6 +157,37 @@ export interface PlaywrightBrowserAPI { onProgress: (callback: (data: DownloadProgress) => void) => () => void } +export interface OperationHistoryAPI { + getBatches: (options?: { limit?: number; offset?: number }) => Promise> + getBatchDetails: (batchId: string) => Promise> + deleteBatch: (batchId: string) => Promise> +} + +export interface BatchStats { + batchId: string + userId: number + username: string + operationTime: string + status: string + totalOrders: number + totalRecords: number + successCount: number + failedCount: number +} + +export interface OperationHistoryRecord { + id?: number + batchId: string + userId: number + username: string + productionId: string | null + orderNumber: string + operationTime: Date + status: string + recordCount: number | null + errorMessage: string | null +} + export interface ProcessAPI { versions: { electron: string @@ -185,6 +216,7 @@ declare global { report: ReportAPI update: UpdateAPI playwrightBrowser: PlaywrightBrowserAPI + operationHistory: OperationHistoryAPI } api: unknown } diff --git a/src/renderer/src/components/ExtractorOperationHistoryModal.tsx b/src/renderer/src/components/ExtractorOperationHistoryModal.tsx new file mode 100644 index 0000000..ec21af9 --- /dev/null +++ b/src/renderer/src/components/ExtractorOperationHistoryModal.tsx @@ -0,0 +1,392 @@ +/** + * Extractor Operation History Modal + * + * Displays extraction operation history with batch statistics and details. + * Admin users see all users' records, regular users see only their own. + */ + +import React, { useState, useEffect } from 'react' +import { Modal } from './ui/Modal' +import { + RefreshCw, + Trash2, + ChevronDown, + ChevronRight, + CheckCircle, + XCircle, + Clock +} from 'lucide-react' +import type { UserInfo } from './UserSelectionDialog' + +// Local type definitions matching the backend types +interface BatchStats { + batchId: string + userId: number + username: string + operationTime: string + status: string + totalOrders: number + totalRecords: number + successCount: number + failedCount: number +} + +interface OperationHistoryRecord { + id?: number + batchId: string + userId: number + username: string + productionId: string | null + orderNumber: string + operationTime: Date + status: string + recordCount: number | null + errorMessage: string | null +} + +interface ExtractorOperationHistoryModalProps { + isOpen: boolean + onClose: () => void + user?: UserInfo | null +} + +const statusStyles: Record = { + success: 'bg-green-100 text-green-700', + partial: 'bg-amber-100 text-amber-700', + failed: 'bg-red-100 text-red-700', + pending: 'bg-gray-100 text-gray-700' +} + +const statusLabels: Record = { + success: '成功', + partial: '部分成功', + failed: '失败', + pending: '进行中' +} + +const statusIcons: Record = { + success: , + partial: , + failed: , + pending: +} + +export const ExtractorOperationHistoryModal: React.FC = ({ + isOpen, + onClose, + user +}) => { + const [batches, setBatches] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [expandedBatches, setExpandedBatches] = useState>(new Set()) + const [batchDetails, setBatchDetails] = useState>(new Map()) + const [deleting, setDeleting] = useState>(new Set()) + + const isAdmin = user?.userType === 'Admin' + + // Fetch batches when modal opens + useEffect(() => { + if (isOpen) { + void fetchBatches() + } + }, [isOpen]) + + const fetchBatches = async () => { + setLoading(true) + setError(null) + try { + const result = await window.electron.operationHistory.getBatches({ limit: 100 }) + if (result.success && result.data) { + setBatches(result.data) + } else { + setError(result.error || '获取历史记录失败') + } + } catch (err) { + setError(err instanceof Error ? err.message : '获取历史记录失败') + } finally { + setLoading(false) + } + } + + const fetchBatchDetails = async (batchId: string) => { + // If already loaded, don't fetch again + if (batchDetails.has(batchId)) { + return + } + + try { + const result = await window.electron.operationHistory.getBatchDetails(batchId) + if (result.success && result.data) { + setBatchDetails((prev) => new Map(prev).set(batchId, result.data!)) + } + } catch (err) { + console.error('Failed to fetch batch details:', err) + } + } + + const toggleBatchExpansion = (batchId: string) => { + setExpandedBatches((prev) => { + const newSet = new Set(prev) + if (newSet.has(batchId)) { + newSet.delete(batchId) + } else { + newSet.add(batchId) + void fetchBatchDetails(batchId) + } + return newSet + }) + } + + const handleDeleteBatch = async (batchId: string) => { + if (deleting.has(batchId)) return + + const confirmed = confirm('确定要删除此批次记录吗?此操作不可撤销。') + if (!confirmed) return + + setDeleting((prev) => new Set(prev).add(batchId)) + + try { + const result = await window.electron.operationHistory.deleteBatch(batchId) + if (result.success) { + // Remove from local state + setBatches((prev) => prev.filter((b) => b.batchId !== batchId)) + setBatchDetails((prev) => { + const newMap = new Map(prev) + newMap.delete(batchId) + return newMap + }) + setExpandedBatches((prev) => { + const newSet = new Set(prev) + newSet.delete(batchId) + return newSet + }) + } else { + alert(result.error || '删除失败') + } + } catch (err) { + alert(err instanceof Error ? err.message : '删除失败') + } finally { + setDeleting((prev) => { + const newSet = new Set(prev) + newSet.delete(batchId) + return newSet + }) + } + } + + const formatDateTime = (dateStr: string) => { + const date = new Date(dateStr) + return date.toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }) + } + + if (!isOpen) return null + + return ( + +
+ {/* Toolbar */} +
+
+ + {isAdmin ? ( + 管理员模式:显示所有用户记录 + ) : ( + 仅显示您的操作记录 + )} + + {batches.length > 0 && ( + 共 {batches.length} 条批次 + )} +
+ +
+ + {/* Error message */} + {error && ( +
+ {error} +
+ )} + + {/* Batch list */} +
+ {loading && batches.length === 0 ? ( +
加载中...
+ ) : batches.length === 0 ? ( +
暂无操作记录
+ ) : ( +
+ {batches.map((batch) => { + const isExpanded = expandedBatches.has(batch.batchId) + const details = batchDetails.get(batch.batchId) || [] + const isDeleting = deleting.has(batch.batchId) + + return ( +
+ {/* Batch summary */} +
toggleBatchExpansion(batch.batchId)} + > +
+ + +
+
+
操作时间
+
+ {formatDateTime(batch.operationTime)} +
+
+
+
操作用户
+
{batch.username}
+
+
+
状态
+
+ {statusIcons[batch.status] || statusIcons.pending} + + {statusLabels[batch.status] || batch.status} + +
+
+
+
订单数
+
{batch.totalOrders}
+
+
+
记录数
+
{batch.totalRecords}
+
+
+
成功/失败
+
+ {batch.successCount} + {batch.failedCount > 0 && ( + <> + {' / '} + {batch.failedCount} + + )} +
+
+
+
+ + +
+ + {/* Batch details */} + {isExpanded && details.length > 0 && ( +
+
+ + + + + + + + + + + + {details.map((detail) => ( + + + + + + + + ))} + +
+ 总排号 + + 订单号 + + 状态 + + 记录数 + + 错误信息 +
+ {detail.productionId || '-'} + + {detail.orderNumber} + + + {statusIcons[detail.status]} + {statusLabels[detail.status] || detail.status} + + + {detail.recordCount ?? '-'} + + {detail.errorMessage || '-'} +
+
+
+ )} +
+ ) + })} +
+ )} +
+ + {/* Footer */} +
+ +
+
+
+ ) +} + +export default ExtractorOperationHistoryModal diff --git a/src/renderer/src/pages/ExtractorPage.tsx b/src/renderer/src/pages/ExtractorPage.tsx index 3467bd4..5373d30 100644 --- a/src/renderer/src/pages/ExtractorPage.tsx +++ b/src/renderer/src/pages/ExtractorPage.tsx @@ -1,14 +1,18 @@ import React from 'react' -import { Download, Play, CheckCircle } from 'lucide-react' +import { Download, Play, CheckCircle, History } from 'lucide-react' import OrderNumberInput from '../components/OrderNumberInput' import { useExtractor } from '../hooks/useExtractor' import { usePersistentTextState } from '../hooks/usePersistentTextState' import { useSharedProductionIds } from '../hooks/useSharedProductionIds' import LogPanel from '../components/ui/LogPanel' import { SegmentedProgressBar } from '../components/ui/SegmentedProgressBar' +import ExtractorOperationHistoryModal from '../components/ExtractorOperationHistoryModal' +import { useUserStore } from '../stores/useUserStore' const ExtractorPage: React.FC = () => { const [orderNumbers, setOrderNumbers] = usePersistentTextState('extractor_orderNumbers') + const [showHistoryModal, setShowHistoryModal] = React.useState(false) + const user = useUserStore((state) => state.user) const { isRunning, @@ -67,6 +71,14 @@ const ExtractorPage: React.FC = () => {
+
+ {showHistoryModal && ( + setShowHistoryModal(false)} + user={user} + /> + )} + {!isRunning && isComplete && (
diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts index ca267ce..1566c42 100644 --- a/src/shared/ipc-channels.ts +++ b/src/shared/ipc-channels.ts @@ -111,7 +111,12 @@ export const IPC_CHANNELS = { PLAYWRIGHT_BROWSER_DOWNLOAD: 'playwright-browser:download', PLAYWRIGHT_BROWSER_CANCEL: 'playwright-browser:cancel', PLAYWRIGHT_BROWSER_PROGRESS: 'playwright-browser:progress', - PLAYWRIGHT_BROWSER_CHECK: 'playwright-browser:check' + PLAYWRIGHT_BROWSER_CHECK: 'playwright-browser:check', + + // Operation History + OPERATION_HISTORY_GET_BATCHES: 'operationHistory:getBatches', + OPERATION_HISTORY_GET_BATCH_DETAILS: 'operationHistory:getBatchDetails', + OPERATION_HISTORY_DELETE_BATCH: 'operationHistory:deleteBatch' } as const /**