diff --git a/src/main/services/database/cleaner-operation-history-dao.ts b/src/main/services/database/cleaner-operation-history-dao.ts new file mode 100644 index 0000000..84fb6e1 --- /dev/null +++ b/src/main/services/database/cleaner-operation-history-dao.ts @@ -0,0 +1,983 @@ +/** + * Data Access Object for Cleaner operation history (three-table design) + * + * Handles database operations for tracking cleaner operation history across: + * - CleanerExecution: execution-level records (one per attempt) + * - CleanerOrderHistory: order-level records (one per order per attempt) + * - CleanerMaterialDetail: material-level records (one per material per order per attempt) + */ + +import { create, type IDatabaseService } from './index' +import { createDialect, type SqlDialect } from './dialects' +import { createLogger, getRequestId, trackDuration } from '../logger' +import type { + CleanerExecutionRecord, + CleanerOrderRecord, + CleanerMaterialRecord, + CleanerBatchStats, + InsertCleanerExecutionInput, + InsertOrderInput, + InsertMaterialDetailInput, + GetCleanerBatchesOptions +} from '../../types/cleaner-history.types' + +const log = createLogger('CleanerOperationHistoryDAO') + +/** + * Format datetime value from database to ISO string + * mssql driver returns Date objects in UTC format + */ +function formatDateTime(value: unknown): string { + if (value instanceof Date) { + return value.toISOString() + } + return value ? String(value) : new Date().toISOString() +} + +/** + * Configuration constants for all three Cleaner history tables + */ +export const CLEANER_EXECUTION_CONFIG = { + COLUMNS: { + ID: 'ID', + BATCH_ID: 'BatchId', + ATTEMPT_NUMBER: 'AttemptNumber', + USER_ID: 'UserId', + USERNAME: 'Username', + OPERATION_TIME: 'OperationTime', + END_TIME: 'EndTime', + STATUS: 'Status', + IS_DRY_RUN: 'IsDryRun', + TOTAL_ORDERS: 'TotalOrders', + ORDERS_PROCESSED: 'OrdersProcessed', + TOTAL_MATERIALS_DELETED: 'TotalMaterialsDeleted', + TOTAL_MATERIALS_SKIPPED: 'TotalMaterialsSkipped', + TOTAL_MATERIALS_FAILED: 'TotalMaterialsFailed', + TOTAL_UNCERTAIN_DELETIONS: 'TotalUncertainDeletions', + ERROR_MESSAGE: 'ErrorMessage', + APP_VERSION: 'AppVersion' + } +} as const + +export const CLEANER_ORDER_CONFIG = { + COLUMNS: { + ID: 'ID', + BATCH_ID: 'BatchId', + ATTEMPT_NUMBER: 'AttemptNumber', + ORDER_NUMBER: 'OrderNumber', + STATUS: 'Status', + MATERIALS_DELETED: 'MaterialsDeleted', + MATERIALS_SKIPPED: 'MaterialsSkipped', + MATERIALS_FAILED: 'MaterialsFailed', + UNCERTAIN_DELETIONS: 'UncertainDeletions', + RETRY_COUNT: 'RetryCount', + RETRY_SUCCESS: 'RetrySuccess', + ERROR_MESSAGE: 'ErrorMessage' + } +} as const + +export const CLEANER_MATERIAL_CONFIG = { + COLUMNS: { + ID: 'ID', + BATCH_ID: 'BatchId', + ATTEMPT_NUMBER: 'AttemptNumber', + ORDER_NUMBER: 'OrderNumber', + MATERIAL_CODE: 'MaterialCode', + MATERIAL_NAME: 'MaterialName', + ROW_NUMBER: 'RowNumber', + RESULT: 'Result', + REASON: 'Reason', + ATTEMPT_COUNT: 'AttemptCount', + FINAL_ERROR_CATEGORY: 'FinalErrorCategory' + } +} as const + +/** + * CleanerOperationHistory DAO Class + */ +export class CleanerOperationHistoryDAO { + private dbService: IDatabaseService | null = null + private dialect: SqlDialect | null = null + + private getDialect(): SqlDialect { + if (!this.dialect) { + this.dialect = createDialect(this.dbService!.type) + } + return this.dialect + } + + /** + * Get the appropriate table name based on database type + */ + private getExecutionTableName(): string { + return this.getDialect().quoteTableName('ERPAuto', 'CleanerExecution') + } + + private getOrderTableName(): string { + return this.getDialect().quoteTableName('ERPAuto', 'CleanerOrderHistory') + } + + private getMaterialTableName(): string { + return this.getDialect().quoteTableName('ERPAuto', 'CleanerMaterialDetail') + } + + /** + * 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 + } + + // ==================== EXECUTION INSERT ==================== + + /** + * Insert a new execution record + * @param input - Execution input data + * @returns True if successful + */ + async insertExecution(input: InsertCleanerExecutionInput): Promise { + const requestId = getRequestId() || `insert-exec-${Date.now()}` + + try { + const dbService = await this.getDatabaseService() + const tableName = this.getExecutionTableName() + const dialect = this.getDialect() + + log.info('Execution insertion started', { + tableName, + operationType: 'INSERT', + requestId, + batchId: input.batchId, + attemptNumber: input.attemptNumber, + userId: input.userId, + username: input.username + }) + + const sqlString = ` + INSERT INTO ${tableName} + (BatchId, AttemptNumber, UserId, Username, OperationTime, Status, IsDryRun, + TotalOrders, OrdersProcessed, TotalMaterialsDeleted, TotalMaterialsSkipped, + TotalMaterialsFailed, TotalUncertainDeletions, ErrorMessage, AppVersion) + VALUES + (${dialect.param(0)}, ${dialect.param(1)}, ${dialect.param(2)}, ${dialect.param(3)}, + ${dialect.currentTimestamp()}, 'pending', ${dialect.param(4)}, + ${dialect.param(5)}, 0, 0, 0, 0, 0, NULL, ${dialect.param(6)}) + ` + + const params = [ + input.batchId, + input.attemptNumber, + input.userId, + input.username, + input.isDryRun ? 1 : 0, + input.totalOrders, + input.appVersion + ] + + await trackDuration(async () => await dbService.query(sqlString, params), { + operationName: 'CleanerOperationHistoryDAO.insertExecution', + context: { tableName, operationType: 'INSERT', batchId: input.batchId } + }) + + log.info('Execution inserted', { + tableName, + operationType: 'INSERT', + requestId, + batchId: input.batchId, + attemptNumber: input.attemptNumber + }) + return true + } catch (error) { + log.error('Insert execution error', { + tableName: this.getExecutionTableName(), + operationType: 'INSERT', + requestId, + batchId: input.batchId, + error: error instanceof Error ? error.message : String(error) + }) + return false + } + } + + // ==================== EXECUTION UPDATE ==================== + + /** + * Update execution status and counters + */ + async updateExecutionStatus( + batchId: string, + attemptNumber: number, + status: string, + ordersProcessed: number, + materialsDeleted: number, + materialsSkipped: number, + materialsFailed: number, + uncertainDeletions: number, + endTime: Date, + errorMessage?: string + ): Promise { + const requestId = getRequestId() || `update-exec-${Date.now()}` + + try { + const dbService = await this.getDatabaseService() + const tableName = this.getExecutionTableName() + const dialect = this.getDialect() + + const sqlString = ` + UPDATE ${tableName} + SET Status = ${dialect.param(0)}, + OrdersProcessed = ${dialect.param(1)}, + TotalMaterialsDeleted = ${dialect.param(2)}, + TotalMaterialsSkipped = ${dialect.param(3)}, + TotalMaterialsFailed = ${dialect.param(4)}, + TotalUncertainDeletions = ${dialect.param(5)}, + EndTime = ${dialect.param(6)}, + ErrorMessage = ${dialect.param(7)} + WHERE BatchId = ${dialect.param(8)} + AND AttemptNumber = ${dialect.param(9)} + ` + + const params = [ + status, + ordersProcessed, + materialsDeleted, + materialsSkipped, + materialsFailed, + uncertainDeletions, + endTime, + errorMessage || null, + batchId, + attemptNumber + ] + + await trackDuration(async () => await dbService.query(sqlString, params), { + operationName: 'CleanerOperationHistoryDAO.updateExecutionStatus', + context: { tableName, operationType: 'UPDATE', batchId, attemptNumber } + }) + + log.info('Execution status updated', { + tableName, + operationType: 'UPDATE', + requestId, + batchId, + attemptNumber, + status + }) + return true + } catch (error) { + log.error('Update execution status error', { + tableName: this.getExecutionTableName(), + operationType: 'UPDATE', + requestId, + batchId, + attemptNumber, + error: error instanceof Error ? error.message : String(error) + }) + return false + } + } + + // ==================== ORDER INSERT ==================== + + /** + * Insert order records for a specific execution + * @param batchId - Batch identifier + * @param attemptNumber - Attempt number + * @param orders - Array of order inputs + * @returns True if successful + */ + async insertOrderRecords( + batchId: string, + attemptNumber: number, + orders: InsertOrderInput[] + ): Promise { + if (!orders || orders.length === 0) { + log.warn('No orders to insert', { + batchId, + attemptNumber, + tableName: this.getOrderTableName(), + requestId: getRequestId() + }) + return false + } + + const requestId = getRequestId() || `insert-orders-${Date.now()}` + + try { + const dbService = await this.getDatabaseService() + const tableName = this.getOrderTableName() + const dialect = this.getDialect() + + log.info('Order records insertion started', { + tableName, + operationType: 'INSERT', + requestId, + batchId, + attemptNumber, + orderCount: orders.length + }) + + for (const order of orders) { + try { + const sqlString = ` + INSERT INTO ${tableName} + (BatchId, AttemptNumber, OrderNumber, Status, + MaterialsDeleted, MaterialsSkipped, MaterialsFailed, + UncertainDeletions, RetryCount, RetrySuccess, ErrorMessage) + VALUES + (${dialect.param(0)}, ${dialect.param(1)}, ${dialect.param(2)}, 'pending', + 0, 0, 0, 0, 0, 0, NULL) + ` + await trackDuration( + async () => + await dbService.query(sqlString, [batchId, attemptNumber, order.orderNumber]), + { + operationName: 'CleanerOperationHistoryDAO.insertOrderRecords', + context: { tableName, operationType: 'INSERT', batchId, attemptNumber } + } + ) + } catch (error) { + log.error('Error inserting individual order record', { + tableName, + operationType: 'INSERT', + requestId, + batchId, + attemptNumber, + orderNumber: order.orderNumber, + error: error instanceof Error ? error.message : String(error) + }) + } + } + + log.info('Order records inserted', { + tableName, + operationType: 'INSERT', + requestId, + batchId, + attemptNumber, + count: orders.length + }) + return true + } catch (error) { + log.error('Insert order records error', { + tableName: this.getOrderTableName(), + operationType: 'INSERT', + requestId, + batchId, + attemptNumber, + orderCount: orders.length, + error: error instanceof Error ? error.message : String(error) + }) + return false + } + } + + // ==================== ORDER UPDATE ==================== + + /** + * Update order status and counters + */ + async updateOrderStatus( + batchId: string, + attemptNumber: number, + orderNumber: string, + status: string, + materialsDeleted: number, + materialsSkipped: number, + materialsFailed: number, + uncertainDeletions: number, + retryCount: number, + retrySuccess: boolean, + errorMessage?: string + ): Promise { + const requestId = getRequestId() || `update-order-${Date.now()}` + + try { + const dbService = await this.getDatabaseService() + const tableName = this.getOrderTableName() + const dialect = this.getDialect() + + const sqlString = ` + UPDATE ${tableName} + SET Status = ${dialect.param(0)}, + MaterialsDeleted = ${dialect.param(1)}, + MaterialsSkipped = ${dialect.param(2)}, + MaterialsFailed = ${dialect.param(3)}, + UncertainDeletions = ${dialect.param(4)}, + RetryCount = ${dialect.param(5)}, + RetrySuccess = ${dialect.param(6)}, + ErrorMessage = ${dialect.param(7)} + WHERE BatchId = ${dialect.param(8)} + AND AttemptNumber = ${dialect.param(9)} + AND OrderNumber = ${dialect.param(10)} + ` + + const params = [ + status, + materialsDeleted, + materialsSkipped, + materialsFailed, + uncertainDeletions, + retryCount, + retrySuccess ? 1 : 0, + errorMessage || null, + batchId, + attemptNumber, + orderNumber + ] + + await trackDuration(async () => await dbService.query(sqlString, params), { + operationName: 'CleanerOperationHistoryDAO.updateOrderStatus', + context: { + tableName, + operationType: 'UPDATE', + batchId, + attemptNumber, + orderNumber + } + }) + + log.info('Order status updated', { + tableName, + operationType: 'UPDATE', + requestId, + batchId, + attemptNumber, + orderNumber, + status + }) + return true + } catch (error) { + log.error('Update order status error', { + tableName: this.getOrderTableName(), + operationType: 'UPDATE', + requestId, + batchId, + attemptNumber, + orderNumber, + error: error instanceof Error ? error.message : String(error) + }) + return false + } + } + + // ==================== MATERIAL INSERT ==================== + + /** + * Insert material detail records for a specific order + * @param batchId - Batch identifier + * @param attemptNumber - Attempt number + * @param details - Array of material detail inputs + * @returns True if successful + */ + async insertMaterialDetails( + batchId: string, + attemptNumber: number, + details: InsertMaterialDetailInput[] + ): Promise { + if (!details || details.length === 0) { + log.warn('No material details to insert', { + batchId, + attemptNumber, + tableName: this.getMaterialTableName(), + requestId: getRequestId() + }) + return false + } + + const requestId = getRequestId() || `insert-materials-${Date.now()}` + + try { + const dbService = await this.getDatabaseService() + const tableName = this.getMaterialTableName() + const dialect = this.getDialect() + + log.info('Material details insertion started', { + tableName, + operationType: 'INSERT', + requestId, + batchId, + attemptNumber, + detailCount: details.length + }) + + for (const detail of details) { + try { + const sqlString = ` + INSERT INTO ${tableName} + (BatchId, AttemptNumber, OrderNumber, MaterialCode, MaterialName, + RowNumber, Result, Reason, AttemptCount, FinalErrorCategory) + VALUES + (${dialect.param(0)}, ${dialect.param(1)}, ${dialect.param(2)}, + ${dialect.param(3)}, ${dialect.param(4)}, ${dialect.param(5)}, + ${dialect.param(6)}, ${dialect.param(7)}, ${dialect.param(8)}, + ${dialect.param(9)}) + ` + await trackDuration( + async () => + await dbService.query(sqlString, [ + batchId, + attemptNumber, + detail.orderNumber, + detail.materialCode, + detail.materialName, + detail.rowNumber, + detail.result, + detail.reason || null, + detail.attemptCount, + detail.finalErrorCategory || null + ]), + { + operationName: 'CleanerOperationHistoryDAO.insertMaterialDetails', + context: { + tableName, + operationType: 'INSERT', + batchId, + attemptNumber, + materialCode: detail.materialCode + } + } + ) + } catch (error) { + log.error('Error inserting individual material detail', { + tableName, + operationType: 'INSERT', + requestId, + batchId, + attemptNumber, + orderNumber: detail.orderNumber, + materialCode: detail.materialCode, + error: error instanceof Error ? error.message : String(error) + }) + } + } + + log.info('Material details inserted', { + tableName, + operationType: 'INSERT', + requestId, + batchId, + attemptNumber, + count: details.length + }) + return true + } catch (error) { + log.error('Insert material details error', { + tableName: this.getMaterialTableName(), + operationType: 'INSERT', + requestId, + batchId, + attemptNumber, + detailCount: details.length, + error: error instanceof Error ? error.message : String(error) + }) + return false + } + } + + // ==================== QUERY: GET BATCHES ==================== + + /** + * Get batch statistics with optional user filtering + * @param userId - Optional user ID for filtering + * @param options - Query options (limit, offset, usernames) + * @returns Array of batch statistics + */ + async getBatches( + userId?: number, + options?: GetCleanerBatchesOptions + ): Promise { + try { + const dbService = await this.getDatabaseService() + const execTable = this.getExecutionTableName() + const orderTable = this.getOrderTableName() + const dialect = this.getDialect() + + let sqlString = ` + SELECT + e.BatchId, + e.UserId, + e.Username, + MIN(e.OperationTime) as OperationTime, + MAX(CASE WHEN e.AttemptNumber = latest.max_attempt THEN e.Status ELSE NULL END) as Status, + COUNT(DISTINCT e.AttemptNumber) as TotalAttempts, + MAX(e.TotalOrders) as TotalOrders, + MAX(CASE WHEN e.AttemptNumber = latest.max_attempt THEN e.OrdersProcessed ELSE 0 END) as OrdersProcessed, + MAX(CASE WHEN e.AttemptNumber = latest.max_attempt THEN e.TotalMaterialsDeleted ELSE 0 END) as TotalMaterialsDeleted, + MAX(CASE WHEN e.AttemptNumber = latest.max_attempt THEN e.TotalMaterialsFailed ELSE 0 END) as TotalMaterialsFailed, + MAX(e.IsDryRun) as IsDryRun, + ISNULL(SUM(CASE WHEN o.Status = 'success' THEN 1 ELSE 0 END), 0) as SuccessCount, + ISNULL(SUM(CASE WHEN o.Status = 'failed' THEN 1 ELSE 0 END), 0) as FailedCount + FROM ${execTable} e + INNER JOIN ( + SELECT BatchId, MAX(AttemptNumber) as max_attempt + FROM ${execTable} + GROUP BY BatchId + ) latest ON e.BatchId = latest.BatchId + LEFT JOIN ${orderTable} o ON e.BatchId = o.BatchId AND e.AttemptNumber = latest.max_attempt + ` + + const params: (number | string)[] = [] + + if (userId !== undefined) { + sqlString += ` WHERE e.UserId = ${dialect.param(params.length)} ` + params.push(userId) + } else if (options?.usernames && options.usernames.length > 0) { + sqlString += ` WHERE e.Username IN (${dialect.params(options.usernames.length)}) ` + params.push(...options.usernames) + } + + sqlString += ` + GROUP BY e.BatchId, e.UserId, e.Username, latest.max_attempt + ORDER BY OperationTime DESC + ` + + if (options?.limit) { + const safeLimit = Math.floor(options.limit) + const safeOffset = options.offset !== undefined ? Math.floor(options.offset) : undefined + + const result = dialect.paginate({ + sql: sqlString, + limit: safeLimit, + offset: safeOffset, + paramIndex: params.length + }) + sqlString = result.sql + + if (dialect.dbType === 'sqlserver') { + if (safeOffset !== undefined) { + params.push(safeOffset) + } + params.push(safeLimit) + } + } + + const result = await trackDuration(async () => await dbService.query(sqlString, params), { + operationName: 'CleanerOperationHistoryDAO.getBatches', + context: { operationType: 'SELECT', userId } + }) + + return result.result.rows.map((row) => ({ + batchId: row.BatchId as string, + userId: row.UserId as number, + username: row.Username as string, + operationTime: formatDateTime(row.OperationTime), + status: row.Status as string, + totalAttempts: row.TotalAttempts as number, + totalOrders: (row.TotalOrders as number) || 0, + ordersProcessed: (row.OrdersProcessed as number) || 0, + totalMaterialsDeleted: (row.TotalMaterialsDeleted as number) || 0, + totalMaterialsFailed: (row.TotalMaterialsFailed as number) || 0, + successCount: (row.SuccessCount as number) || 0, + failedCount: (row.FailedCount as number) || 0, + isDryRun: !!(row.IsDryRun as number) + })) + } catch (error) { + log.error('Get batches error', { + operationType: 'SELECT', + requestId: getRequestId(), + userId, + error: error instanceof Error ? error.message : String(error) + }) + return [] + } + } + + // ==================== QUERY: GET BATCH DETAILS ==================== + + /** + * Get detailed records for a specific batch + * @param batchId - Batch identifier + * @returns Object with executions and orders arrays + */ + async getBatchDetails( + batchId: string + ): Promise<{ executions: CleanerExecutionRecord[]; orders: CleanerOrderRecord[] }> { + const requestId = getRequestId() || `details-${Date.now()}` + + try { + const dbService = await this.getDatabaseService() + const execTable = this.getExecutionTableName() + const orderTable = this.getOrderTableName() + const dialect = this.getDialect() + + // Query executions + const execSql = ` + SELECT + ID, BatchId, AttemptNumber, UserId, Username, + OperationTime, EndTime, Status, IsDryRun, + TotalOrders, OrdersProcessed, + TotalMaterialsDeleted, TotalMaterialsSkipped, TotalMaterialsFailed, + TotalUncertainDeletions, ErrorMessage, AppVersion + FROM ${execTable} + WHERE BatchId = ${dialect.param(0)} + ORDER BY AttemptNumber + ` + + const execResult = await trackDuration( + async () => await dbService.query(execSql, [batchId]), + { + operationName: 'CleanerOperationHistoryDAO.getBatchDetails.executions', + context: { operationType: 'SELECT', batchId } + } + ) + + // Query orders + const orderSql = ` + SELECT + ID, BatchId, AttemptNumber, OrderNumber, Status, + MaterialsDeleted, MaterialsSkipped, MaterialsFailed, + UncertainDeletions, RetryCount, RetrySuccess, ErrorMessage + FROM ${orderTable} + WHERE BatchId = ${dialect.param(0)} + ORDER BY AttemptNumber, ID + ` + + const orderResult = await trackDuration( + async () => await dbService.query(orderSql, [batchId]), + { + operationName: 'CleanerOperationHistoryDAO.getBatchDetails.orders', + context: { operationType: 'SELECT', batchId } + } + ) + + const executions: CleanerExecutionRecord[] = execResult.result.rows.map((row) => ({ + id: row.ID as number, + batchId: row.BatchId as string, + attemptNumber: row.AttemptNumber as number, + userId: row.UserId as number, + username: row.Username as string, + operationTime: row.OperationTime instanceof Date ? row.OperationTime : new Date(row.OperationTime as string), + endTime: row.EndTime ? (row.EndTime instanceof Date ? row.EndTime : new Date(row.EndTime as string)) : null, + status: row.Status as string, + isDryRun: !!(row.IsDryRun as number), + totalOrders: row.TotalOrders as number, + ordersProcessed: row.OrdersProcessed as number, + totalMaterialsDeleted: row.TotalMaterialsDeleted as number, + totalMaterialsSkipped: row.TotalMaterialsSkipped as number, + totalMaterialsFailed: row.TotalMaterialsFailed as number, + totalUncertainDeletions: row.TotalUncertainDeletions as number, + errorMessage: row.ErrorMessage as string | null, + appVersion: row.AppVersion as string | null + })) + + const orders: CleanerOrderRecord[] = orderResult.result.rows.map((row) => ({ + id: row.ID as number, + batchId: row.BatchId as string, + attemptNumber: row.AttemptNumber as number, + orderNumber: row.OrderNumber as string, + status: row.Status as string, + materialsDeleted: row.MaterialsDeleted as number, + materialsSkipped: row.MaterialsSkipped as number, + materialsFailed: row.MaterialsFailed as number, + uncertainDeletions: row.UncertainDeletions as number, + retryCount: row.RetryCount as number, + retrySuccess: !!(row.RetrySuccess as number), + errorMessage: row.ErrorMessage as string | null + })) + + log.info('Batch details retrieved', { + operationType: 'SELECT', + requestId, + batchId, + executionCount: executions.length, + orderCount: orders.length + }) + + return { executions, orders } + } catch (error) { + log.error('Get batch details error', { + operationType: 'SELECT', + requestId, + batchId, + error: error instanceof Error ? error.message : String(error) + }) + return { executions: [], orders: [] } + } + } + + // ==================== QUERY: GET MATERIAL DETAILS ==================== + + /** + * Get material details for a specific order in an attempt + * @param batchId - Batch identifier + * @param attemptNumber - Attempt number + * @param orderNumber - Order number + * @returns Array of material records + */ + async getMaterialDetails( + batchId: string, + attemptNumber: number, + orderNumber: string + ): Promise { + try { + const dbService = await this.getDatabaseService() + const tableName = this.getMaterialTableName() + const dialect = this.getDialect() + + const sqlString = ` + SELECT + ID, BatchId, AttemptNumber, OrderNumber, + MaterialCode, MaterialName, RowNumber, + Result, Reason, AttemptCount, FinalErrorCategory + FROM ${tableName} + WHERE BatchId = ${dialect.param(0)} + AND AttemptNumber = ${dialect.param(1)} + AND OrderNumber = ${dialect.param(2)} + ` + + const result = await trackDuration( + async () => await dbService.query(sqlString, [batchId, attemptNumber, orderNumber]), + { + operationName: 'CleanerOperationHistoryDAO.getMaterialDetails', + context: { operationType: 'SELECT', batchId, attemptNumber, orderNumber } + } + ) + + return result.result.rows.map((row) => ({ + id: row.ID as number, + batchId: row.BatchId as string, + attemptNumber: row.AttemptNumber as number, + orderNumber: row.OrderNumber as string, + materialCode: row.MaterialCode as string, + materialName: row.MaterialName as string, + rowNumber: row.RowNumber as number, + result: row.Result as string, + reason: row.Reason as string | null, + attemptCount: row.AttemptCount as number, + finalErrorCategory: row.FinalErrorCategory as string | null + })) + } catch (error) { + log.error('Get material details error', { + tableName: this.getMaterialTableName(), + operationType: 'SELECT', + requestId: getRequestId(), + batchId, + attemptNumber, + orderNumber, + error: error instanceof Error ? error.message : String(error) + }) + return [] + } + } + + // ==================== DELETE ==================== + + /** + * Delete a batch with permission checking across all three tables + * @param batchId - Batch identifier + * @param requestingUserId - User ID requesting the deletion + * @param isAdmin - Whether the requesting user is an admin + * @returns Result with success flag and optional error message + */ + async deleteBatch( + batchId: string, + requestingUserId: number, + isAdmin: boolean + ): Promise<{ success: boolean; error?: string }> { + const requestId = getRequestId() || `delete-${Date.now()}` + + try { + const dbService = await this.getDatabaseService() + const execTable = this.getExecutionTableName() + const orderTable = this.getOrderTableName() + const materialTable = this.getMaterialTableName() + const dialect = this.getDialect() + + // Check if batch exists + const checkSql = ` + SELECT TOP 1 UserId + FROM ${execTable} + WHERE BatchId = ${dialect.param(0)} + ` + + const checkResult = await trackDuration( + async () => await dbService.query(checkSql, [batchId]), + { + operationName: 'CleanerOperationHistoryDAO.deleteBatch.check', + context: { operationType: 'SELECT', batchId } + } + ) + + if (checkResult.result.rows.length === 0) { + return { success: false, error: '批次不存在' } + } + + // Permission check: non-admin can only delete own batches + const batchUserId = checkResult.result.rows[0].UserId as number + if (!isAdmin && batchUserId !== requestingUserId) { + return { success: false, error: '没有权限删除此批次' } + } + + // Delete from CleanerMaterialDetail first (potential FK) + const deleteMaterialSql = ` + DELETE FROM ${materialTable} + WHERE BatchId = ${dialect.param(0)} + ` + await trackDuration(async () => await dbService.query(deleteMaterialSql, [batchId]), { + operationName: 'CleanerOperationHistoryDAO.deleteBatch.materials', + context: { operationType: 'DELETE', batchId } + }) + + // Delete from CleanerOrderHistory + const deleteOrderSql = ` + DELETE FROM ${orderTable} + WHERE BatchId = ${dialect.param(0)} + ` + await trackDuration(async () => await dbService.query(deleteOrderSql, [batchId]), { + operationName: 'CleanerOperationHistoryDAO.deleteBatch.orders', + context: { operationType: 'DELETE', batchId } + }) + + // Delete from CleanerExecution + const deleteExecSql = ` + DELETE FROM ${execTable} + WHERE BatchId = ${dialect.param(0)} + ` + const deleteResult = await trackDuration( + async () => await dbService.query(deleteExecSql, [batchId]), + { + operationName: 'CleanerOperationHistoryDAO.deleteBatch.executions', + context: { operationType: 'DELETE', batchId, requestingUserId } + } + ) + + log.info('Batch deleted', { + operationType: 'DELETE', + requestId, + batchId, + rowCount: deleteResult.result.rowCount + }) + return { success: true } + } catch (error) { + log.error('Delete batch error', { + operationType: 'DELETE', + requestId, + batchId, + error: error instanceof Error ? error.message : String(error) + }) + return { + success: false, + error: error instanceof Error ? error.message : String(error) + } + } + } + + // ==================== UTILITIES ==================== + + /** + * Disconnect from database + */ + async disconnect(): Promise { + if (this.dbService) { + await this.dbService.disconnect() + this.dbService = null + this.dialect = null + } + } +}