// 배치관리 전용 컨트롤러 (기존 소스와 완전 분리) // 작성일: 2024-12-24 import { Request, Response } from "express"; import { AuthenticatedRequest } from "../types/auth"; import { BatchManagementService, BatchConnectionInfo, BatchTableInfo, BatchColumnInfo, } from "../services/batchManagementService"; import { BatchService } from "../services/batchService"; import { BatchSchedulerService } from "../services/batchSchedulerService"; import { BatchExternalDbService } from "../services/batchExternalDbService"; import { CreateBatchConfigRequest, BatchConfig } from "../types/batchTypes"; import { query } from "../database/db"; export class BatchManagementController { /** * 사용 가능한 커넥션 목록 조회 (회사별) */ static async getAvailableConnections( req: AuthenticatedRequest, res: Response ) { try { const userCompanyCode = req.user?.companyCode; const result = await BatchManagementService.getAvailableConnections(userCompanyCode); if (result.success) { res.json(result); } else { res.status(500).json(result); } } catch (error) { console.error("커넥션 목록 조회 오류:", error); res.status(500).json({ success: false, message: "커넥션 목록 조회 중 오류가 발생했습니다.", error: error instanceof Error ? error.message : "알 수 없는 오류", }); } } /** * 특정 커넥션의 테이블 목록 조회 (회사별) */ static async getTablesFromConnection( req: AuthenticatedRequest, res: Response ) { try { const { type, id } = req.params; const userCompanyCode = req.user?.companyCode; if (type !== "internal" && type !== "external") { return res.status(400).json({ success: false, message: "올바른 연결 타입을 지정해주세요. (internal 또는 external)", }); } const connectionId = type === "external" ? Number(id) : undefined; const result = await BatchManagementService.getTablesFromConnection( type, connectionId, userCompanyCode ); if (result.success) { return res.json(result); } else { return res.status(500).json(result); } } catch (error) { console.error("테이블 목록 조회 오류:", error); return res.status(500).json({ success: false, message: "테이블 목록 조회 중 오류가 발생했습니다.", error: error instanceof Error ? error.message : "알 수 없는 오류", }); } } /** * 특정 테이블의 컬럼 정보 조회 (회사별) */ static async getTableColumns(req: AuthenticatedRequest, res: Response) { try { const { type, id, tableName } = req.params; const userCompanyCode = req.user?.companyCode; if (type !== "internal" && type !== "external") { return res.status(400).json({ success: false, message: "올바른 연결 타입을 지정해주세요. (internal 또는 external)", }); } const connectionId = type === "external" ? Number(id) : undefined; const result = await BatchManagementService.getTableColumns( type, connectionId, tableName, userCompanyCode ); if (result.success) { return res.json(result); } else { return res.status(500).json(result); } } catch (error) { console.error("컬럼 정보 조회 오류:", error); return res.status(500).json({ success: false, message: "컬럼 정보 조회 중 오류가 발생했습니다.", error: error instanceof Error ? error.message : "알 수 없는 오류", }); } } /** * 배치 설정 생성 * POST /api/batch-management/batch-configs */ static async createBatchConfig(req: AuthenticatedRequest, res: Response) { try { const { batchName, description, cronSchedule, mappings, isActive, executionType, nodeFlowId, nodeFlowContext, } = req.body; const companyCode = req.user?.companyCode; if (!batchName || !cronSchedule) { return res.status(400).json({ success: false, message: "필수 필드가 누락되었습니다. (batchName, cronSchedule)", }); } // 노드 플로우 타입은 매핑 없이 생성 가능 if (executionType !== "node_flow" && (!mappings || !Array.isArray(mappings))) { return res.status(400).json({ success: false, message: "매핑 타입은 mappings 배열이 필요합니다.", }); } const batchConfig = await BatchService.createBatchConfig( { batchName, description, cronSchedule, mappings: mappings || [], isActive: isActive === false || isActive === "N" ? "N" : "Y", companyCode: companyCode || "", executionType: executionType || "mapping", nodeFlowId: nodeFlowId || null, nodeFlowContext: nodeFlowContext || null, } as CreateBatchConfigRequest, req.user?.userId ); return res.status(201).json({ success: true, data: batchConfig, message: "배치 설정이 성공적으로 생성되었습니다.", }); } catch (error) { console.error("배치 설정 생성 오류:", error); return res.status(500).json({ success: false, message: "배치 설정 생성에 실패했습니다.", error: error instanceof Error ? error.message : "알 수 없는 오류", }); } } /** * 특정 배치 설정 조회 * GET /api/batch-management/batch-configs/:id */ static async getBatchConfigById(req: AuthenticatedRequest, res: Response) { try { const { id } = req.params; console.log("🔍 배치 설정 조회 요청:", id); const result = await BatchService.getBatchConfigById(Number(id)); if (!result.success) { return res.status(404).json({ success: false, message: result.message || "배치 설정을 찾을 수 없습니다.", }); } console.log("📋 조회된 배치 설정:", result.data); return res.json({ success: true, data: result.data, }); } catch (error) { console.error("❌ 배치 설정 조회 오류:", error); return res.status(500).json({ success: false, message: "배치 설정 조회에 실패했습니다.", error: error instanceof Error ? error.message : "알 수 없는 오류", }); } } /** * 배치 설정 목록 조회 * GET /api/batch-management/batch-configs */ static async getBatchConfigs(req: AuthenticatedRequest, res: Response) { try { const { page = 1, limit = 10, search, isActive } = req.query; const filter = { page: Number(page), limit: Number(limit), search: search as string, is_active: isActive as string, }; const result = await BatchService.getBatchConfigs(filter); res.json({ success: true, data: result.data, pagination: result.pagination, }); } catch (error) { console.error("배치 설정 목록 조회 오류:", error); res.status(500).json({ success: false, message: "배치 설정 목록 조회에 실패했습니다.", error: error instanceof Error ? error.message : "알 수 없는 오류", }); } } /** * 배치 수동 실행 * POST /api/batch-management/batch-configs/:id/execute */ static async executeBatchConfig(req: AuthenticatedRequest, res: Response) { try { const { id } = req.params; if (!id || isNaN(Number(id))) { return res.status(400).json({ success: false, message: "올바른 배치 설정 ID를 제공해주세요.", }); } // 배치 설정 조회 const batchConfigResult = await BatchService.getBatchConfigById( Number(id) ); if (!batchConfigResult.success || !batchConfigResult.data) { return res.status(404).json({ success: false, message: "배치 설정을 찾을 수 없습니다.", }); } const batchConfig = batchConfigResult.data as BatchConfig; const startTime = new Date(); console.log(`배치 수동 실행 시작: ${batchConfig.batch_name} (ID: ${id})`); let executionLog: any = null; try { // 실행 로그 생성 const { BatchExecutionLogService } = await import( "../services/batchExecutionLogService" ); const logResult = await BatchExecutionLogService.createExecutionLog({ batch_config_id: Number(id), company_code: batchConfig.company_code, execution_status: "RUNNING", start_time: startTime, total_records: 0, success_records: 0, failed_records: 0, }); if (!logResult.success || !logResult.data) { throw new Error( logResult.message || "배치 실행 로그를 생성할 수 없습니다." ); } executionLog = logResult.data; // BatchSchedulerService의 executeBatchConfig 메서드 사용 (중복 로직 제거) const { BatchSchedulerService } = await import( "../services/batchSchedulerService" ); const result = await BatchSchedulerService.executeBatchConfig(batchConfig); // result가 undefined인 경우 처리 if (!result) { throw new Error("배치 실행 결과를 받을 수 없습니다."); } const endTime = new Date(); const duration = endTime.getTime() - startTime.getTime(); // 실행 로그 업데이트 (성공) await BatchExecutionLogService.updateExecutionLog(executionLog.id, { execution_status: "SUCCESS", end_time: endTime, duration_ms: duration, total_records: result.totalRecords, success_records: result.successRecords, failed_records: result.failedRecords, }); return res.json({ success: true, data: { batchName: batchConfig.batch_name, totalRecords: result.totalRecords, successRecords: result.successRecords, failedRecords: result.failedRecords, executionTime: duration, }, message: "배치가 성공적으로 실행되었습니다.", }); } catch (batchError) { console.error(`배치 실행 실패: ${batchConfig.batch_name}`, batchError); // 실행 로그 업데이트 (실패) - executionLog가 생성되었을 경우에만 try { const endTime = new Date(); const duration = endTime.getTime() - startTime.getTime(); // executionLog가 정의되어 있는지 확인 if (typeof executionLog !== "undefined" && executionLog) { const { BatchExecutionLogService } = await import( "../services/batchExecutionLogService" ); await BatchExecutionLogService.updateExecutionLog(executionLog.id, { execution_status: "FAILED", end_time: endTime, duration_ms: duration, error_message: batchError instanceof Error ? batchError.message : "알 수 없는 오류", }); } } catch (logError) { console.error("실행 로그 업데이트 실패:", logError); } return res.status(500).json({ success: false, message: "배치 실행에 실패했습니다.", error: batchError instanceof Error ? batchError.message : "알 수 없는 오류", }); } } catch (error) { console.error(`배치 실행 오류 (ID: ${req.params.id}):`, error); return res.status(500).json({ success: false, message: "배치 실행 중 오류가 발생했습니다.", error: error instanceof Error ? error.message : "Unknown error", }); } } /** * 배치 설정 업데이트 * PUT /api/batch-management/batch-configs/:id */ static async updateBatchConfig(req: AuthenticatedRequest, res: Response) { try { const { id } = req.params; const updateData = req.body; if (!id || isNaN(Number(id))) { return res.status(400).json({ success: false, message: "올바른 배치 설정 ID를 제공해주세요.", }); } const batchConfig = await BatchService.updateBatchConfig( Number(id), updateData ); // 스케줄러에서 배치 스케줄 업데이트 (즉시 실행 비활성화) await BatchSchedulerService.updateBatchSchedule(Number(id), false); return res.json({ success: true, data: batchConfig, message: "배치 설정이 성공적으로 업데이트되었습니다.", }); } catch (error) { console.error("배치 설정 업데이트 오류:", error); return res.status(500).json({ success: false, message: "배치 설정 업데이트에 실패했습니다.", error: error instanceof Error ? error.message : "알 수 없는 오류", }); } } /** * REST API 데이터 미리보기 */ static async previewRestApiData(req: AuthenticatedRequest, res: Response) { try { const { apiUrl, apiKey, endpoint, method = "GET", paramType, paramName, paramValue, paramSource, requestBody, authServiceName, // DB에서 토큰 가져올 서비스명 dataArrayPath, // 데이터 배열 경로 (예: response, data.items) } = req.body; // apiUrl, endpoint는 항상 필수 if (!apiUrl || !endpoint) { return res.status(400).json({ success: false, message: "API URL과 엔드포인트는 필수입니다.", }); } // 토큰 결정: authServiceName이 있으면 DB에서 조회, 없으면 apiKey 사용 let finalApiKey = apiKey || ""; if (authServiceName) { const companyCode = req.user?.companyCode; // DB에서 토큰 조회 (멀티테넌시: company_code 필터링) let tokenQuery: string; let tokenParams: any[]; if (companyCode === "*") { // 최고 관리자: 모든 회사 토큰 조회 가능 tokenQuery = `SELECT access_token FROM auth_tokens WHERE service_name = $1 ORDER BY created_date DESC LIMIT 1`; tokenParams = [authServiceName]; } else { // 일반 회사: 자신의 회사 토큰만 조회 tokenQuery = `SELECT access_token FROM auth_tokens WHERE service_name = $1 AND company_code = $2 ORDER BY created_date DESC LIMIT 1`; tokenParams = [authServiceName, companyCode]; } const tokenResult = await query<{ access_token: string }>( tokenQuery, tokenParams ); if (tokenResult.length > 0 && tokenResult[0].access_token) { finalApiKey = tokenResult[0].access_token; console.log(`auth_tokens에서 토큰 조회 성공: ${authServiceName}`); } else { return res.status(400).json({ success: false, message: `서비스 '${authServiceName}'의 토큰을 찾을 수 없습니다. 먼저 토큰 저장 배치를 실행하세요.`, }); } } // 토큰이 없어도 공개 API 호출 가능 (토큰 검증 제거) console.log("REST API 미리보기 요청:", { apiUrl, endpoint, method, paramType, paramName, paramValue, paramSource, requestBody: requestBody ? "Included" : "None", authServiceName: authServiceName || "직접 입력", dataArrayPath: dataArrayPath || "전체 응답", }); // RestApiConnector 사용하여 데이터 조회 const { RestApiConnector } = await import("../database/RestApiConnector"); const connector = new RestApiConnector({ baseUrl: apiUrl, apiKey: finalApiKey, timeout: 30000, }); // 연결 테스트 await connector.connect(); // 파라미터가 있는 경우 엔드포인트 수정 let finalEndpoint = endpoint; if (paramType && paramName && paramValue) { if (paramType === "url") { // URL 파라미터: /api/users/{userId} → /api/users/123 if (endpoint.includes(`{${paramName}}`)) { finalEndpoint = endpoint.replace(`{${paramName}}`, paramValue); } else { // 엔드포인트에 {paramName}이 없으면 뒤에 추가 finalEndpoint = `${endpoint}/${paramValue}`; } } else if (paramType === "query") { // 쿼리 파라미터: /api/users?userId=123 const separator = endpoint.includes("?") ? "&" : "?"; finalEndpoint = `${endpoint}${separator}${paramName}=${paramValue}`; } } console.log("🔗 최종 엔드포인트:", finalEndpoint); // Request Body 파싱 let parsedBody = undefined; if (requestBody && typeof requestBody === "string") { try { parsedBody = JSON.parse(requestBody); } catch (e) { console.warn("Request Body JSON 파싱 실패:", e); // 파싱 실패 시 원본 문자열 사용하거나 무시 (상황에 따라 결정, 여기선 undefined로 처리하거나 에러 반환 가능) // 여기서는 경고 로그 남기고 진행 } } else if (requestBody) { parsedBody = requestBody; } // 데이터 조회 - executeRequest 사용 (POST/PUT/DELETE 지원) const result = await connector.executeRequest( finalEndpoint, method as "GET" | "POST" | "PUT" | "DELETE", parsedBody ); console.log(`[previewRestApiData] executeRequest 결과:`, { rowCount: result.rowCount, rowsLength: result.rows ? result.rows.length : "undefined", firstRow: result.rows && result.rows.length > 0 ? result.rows[0] : "no data", }); // 데이터 배열 추출 헬퍼 함수 const getValueByPath = (obj: any, path: string): any => { if (!path) return obj; const keys = path.split("."); let current = obj; for (const key of keys) { if (current === null || current === undefined) return undefined; current = current[key]; } return current; }; // dataArrayPath가 있으면 해당 경로에서 배열 추출 let extractedData: any[] = []; if (dataArrayPath) { // result.rows가 단일 객체일 수 있음 (API 응답 전체) const rawData = result.rows.length === 1 ? result.rows[0] : result.rows; const arrayData = getValueByPath(rawData, dataArrayPath); if (Array.isArray(arrayData)) { extractedData = arrayData; console.log( `[previewRestApiData] '${dataArrayPath}' 경로에서 ${arrayData.length}개 항목 추출` ); } else { console.warn( `[previewRestApiData] '${dataArrayPath}' 경로가 배열이 아님:`, typeof arrayData ); // 배열이 아니면 단일 객체로 처리 if (arrayData) { extractedData = [arrayData]; } } } else { // dataArrayPath가 없으면 기존 로직 사용 extractedData = result.rows; } const data = extractedData.slice(0, 5); // 최대 5개 샘플만 console.log( `[previewRestApiData] 슬라이스된 데이터 (${extractedData.length}개 중 ${data.length}개):`, data ); if (data.length > 0) { // 첫 번째 객체에서 필드명 추출 const fields = Object.keys(data[0]); console.log(`[previewRestApiData] 추출된 필드:`, fields); return res.json({ success: true, data: { fields: fields, samples: data, totalCount: extractedData.length, }, message: `${fields.length}개 필드, ${extractedData.length}개 레코드를 조회했습니다.`, }); } else { return res.json({ success: true, data: { fields: [], samples: [], totalCount: 0, }, message: "API에서 데이터를 가져올 수 없습니다.", }); } } catch (error) { console.error("REST API 미리보기 오류:", error); return res.status(500).json({ success: false, message: "REST API 데이터 미리보기 중 오류가 발생했습니다.", error: error instanceof Error ? error.message : "알 수 없는 오류", }); } } /** * REST API 배치 설정 저장 */ static async saveRestApiBatch(req: AuthenticatedRequest, res: Response) { try { const { batchName, batchType, cronSchedule, description, apiMappings, authServiceName, dataArrayPath, saveMode, conflictKey, } = req.body; if ( !batchName || !batchType || !cronSchedule || !apiMappings || apiMappings.length === 0 ) { return res.status(400).json({ success: false, message: "필수 필드가 누락되었습니다.", }); } console.log("REST API 배치 저장 요청:", { batchName, batchType, cronSchedule, description, apiMappings, authServiceName, dataArrayPath, saveMode, conflictKey, }); // 🔐 멀티테넌시: 현재 사용자 회사 코드 사용 (프론트에서 받지 않음) const companyCode = req.user?.companyCode || "*"; const userId = req.user?.userId; // BatchService를 사용하여 배치 설정 저장 const batchConfig: CreateBatchConfigRequest = { batchName: batchName, description: description || "", cronSchedule: cronSchedule, isActive: "Y", companyCode, authServiceName: authServiceName || undefined, dataArrayPath: dataArrayPath || undefined, saveMode: saveMode || "INSERT", conflictKey: conflictKey || undefined, mappings: apiMappings, }; const result = await BatchService.createBatchConfig(batchConfig, userId); if (result.success && result.data) { // 스케줄러에 자동 등록 ✅ try { await BatchSchedulerService.scheduleBatch(result.data); console.log( `✅ 새로운 배치가 스케줄러에 등록되었습니다: ${batchName} (ID: ${result.data.id})` ); } catch (schedulerError) { console.error(`❌ 스케줄러 등록 실패: ${batchName}`, schedulerError); // 스케줄러 등록 실패해도 배치 저장은 성공으로 처리 } return res.json({ success: true, message: "REST API 배치가 성공적으로 저장되었습니다.", data: result.data, }); } else { return res.status(500).json({ success: false, message: result.message || "배치 저장에 실패했습니다.", }); } } catch (error) { console.error("REST API 배치 저장 오류:", error); return res.status(500).json({ success: false, message: "배치 저장 중 오류가 발생했습니다.", }); } } /** * 인증 토큰 서비스명 목록 조회 */ static async getAuthServiceNames(req: AuthenticatedRequest, res: Response) { try { const companyCode = req.user?.companyCode; // 멀티테넌시: company_code 필터링 let queryText: string; let queryParams: any[] = []; if (companyCode === "*") { // 최고 관리자: 모든 서비스 조회 queryText = `SELECT DISTINCT service_name FROM auth_tokens WHERE service_name IS NOT NULL ORDER BY service_name`; } else { // 일반 회사: 자신의 회사 서비스만 조회 queryText = `SELECT DISTINCT service_name FROM auth_tokens WHERE service_name IS NOT NULL AND company_code = $1 ORDER BY service_name`; queryParams = [companyCode]; } const result = await query<{ service_name: string }>( queryText, queryParams ); const serviceNames = result.map((row) => row.service_name); return res.json({ success: true, data: serviceNames, }); } catch (error) { console.error("인증 서비스 목록 조회 오류:", error); return res.status(500).json({ success: false, message: "인증 서비스 목록 조회 중 오류가 발생했습니다.", }); } } /** * 노드 플로우 목록 조회 (배치 설정에서 플로우 선택용) * GET /api/batch-management/node-flows */ static async getNodeFlows(req: AuthenticatedRequest, res: Response) { try { const companyCode = req.user?.companyCode; let flowQuery: string; let flowParams: any[] = []; if (companyCode === "*") { flowQuery = ` SELECT flow_id, flow_name, flow_description AS description, company_code, COALESCE(jsonb_array_length( CASE WHEN flow_data IS NOT NULL AND flow_data::text != '' THEN (flow_data::jsonb -> 'nodes') ELSE '[]'::jsonb END ), 0) AS node_count FROM node_flows ORDER BY flow_name `; } else { flowQuery = ` SELECT flow_id, flow_name, flow_description AS description, company_code, COALESCE(jsonb_array_length( CASE WHEN flow_data IS NOT NULL AND flow_data::text != '' THEN (flow_data::jsonb -> 'nodes') ELSE '[]'::jsonb END ), 0) AS node_count FROM node_flows WHERE company_code = $1 ORDER BY flow_name `; flowParams = [companyCode]; } const result = await query(flowQuery, flowParams); return res.json({ success: true, data: result }); } catch (error) { console.error("노드 플로우 목록 조회 오류:", error); return res.status(500).json({ success: false, message: "노드 플로우 목록 조회 실패", error: error instanceof Error ? error.message : "알 수 없는 오류", }); } } /** * 배치 대시보드 통계 조회 * GET /api/batch-management/stats * totalBatches, activeBatches, todayExecutions, todayFailures, prevDayExecutions, prevDayFailures * 멀티테넌시: company_code 필터링 필수 */ static async getBatchStats(req: AuthenticatedRequest, res: Response) { try { const companyCode = req.user?.companyCode; // 전체/활성 배치 수 let configQuery: string; let configParams: any[] = []; if (companyCode === "*") { configQuery = ` SELECT COUNT(*)::int AS total, COUNT(*) FILTER (WHERE is_active = 'Y')::int AS active FROM batch_configs `; } else { configQuery = ` SELECT COUNT(*)::int AS total, COUNT(*) FILTER (WHERE is_active = 'Y')::int AS active FROM batch_configs WHERE company_code = $1 `; configParams = [companyCode]; } const configResult = await query<{ total: number; active: number }>( configQuery, configParams ); // 오늘/어제 실행·실패 수 (KST 기준 날짜) const logParams: any[] = []; let logWhere = ""; if (companyCode && companyCode !== "*") { logWhere = " AND company_code = $1"; logParams.push(companyCode); } const todayLogQuery = ` SELECT COUNT(*)::int AS today_executions, COUNT(*) FILTER (WHERE execution_status = 'FAILED')::int AS today_failures FROM batch_execution_logs WHERE (start_time AT TIME ZONE 'Asia/Seoul')::date = (NOW() AT TIME ZONE 'Asia/Seoul')::date ${logWhere} `; const prevDayLogQuery = ` SELECT COUNT(*)::int AS prev_executions, COUNT(*) FILTER (WHERE execution_status = 'FAILED')::int AS prev_failures FROM batch_execution_logs WHERE (start_time AT TIME ZONE 'Asia/Seoul')::date = (NOW() AT TIME ZONE 'Asia/Seoul')::date - INTERVAL '1 day' ${logWhere} `; const [todayResult, prevResult] = await Promise.all([ query<{ today_executions: number; today_failures: number }>( todayLogQuery, logParams ), query<{ prev_executions: number; prev_failures: number }>( prevDayLogQuery, logParams ), ]); const config = configResult[0]; const today = todayResult[0]; const prev = prevResult[0]; return res.json({ success: true, data: { totalBatches: config?.total ?? 0, activeBatches: config?.active ?? 0, todayExecutions: today?.today_executions ?? 0, todayFailures: today?.today_failures ?? 0, prevDayExecutions: prev?.prev_executions ?? 0, prevDayFailures: prev?.prev_failures ?? 0, }, }); } catch (error) { console.error("배치 통계 조회 오류:", error); return res.status(500).json({ success: false, message: "배치 통계 조회 실패", error: error instanceof Error ? error.message : "알 수 없는 오류", }); } } /** * 배치별 최근 24시간 스파크라인 (1시간 단위 집계) * GET /api/batch-management/batch-configs/:id/sparkline * 멀티테넌시: company_code 필터링 필수 */ static async getBatchSparkline(req: AuthenticatedRequest, res: Response) { try { const { id } = req.params; const companyCode = req.user?.companyCode; const batchId = Number(id); if (!id || isNaN(batchId)) { return res.status(400).json({ success: false, message: "올바른 배치 ID를 제공해주세요.", }); } const params: any[] = [batchId]; let companyFilter = ""; if (companyCode && companyCode !== "*") { companyFilter = " AND bel.company_code = $2"; params.push(companyCode); } // KST 기준 최근 24시간 1시간 단위 슬롯 + 집계 (generate_series로 24개 보장) const sparklineQuery = ` WITH kst_slots AS ( SELECT to_char(s, 'YYYY-MM-DD"T"HH24:00:00') AS hour FROM generate_series( (NOW() AT TIME ZONE 'Asia/Seoul') - INTERVAL '23 hours', (NOW() AT TIME ZONE 'Asia/Seoul'), INTERVAL '1 hour' ) AS s ), agg AS ( SELECT to_char(date_trunc('hour', (bel.start_time AT TIME ZONE 'Asia/Seoul')) AT TIME ZONE 'Asia/Seoul', 'YYYY-MM-DD"T"HH24:00:00') AS hour, COUNT(*) FILTER (WHERE bel.execution_status = 'SUCCESS')::int AS success, COUNT(*) FILTER (WHERE bel.execution_status = 'FAILED')::int AS failed FROM batch_execution_logs bel WHERE bel.batch_config_id = $1 AND bel.start_time >= (NOW() AT TIME ZONE 'Asia/Seoul') - INTERVAL '24 hours' ${companyFilter} GROUP BY date_trunc('hour', (bel.start_time AT TIME ZONE 'Asia/Seoul')) ) SELECT k.hour, COALESCE(a.success, 0) AS success, COALESCE(a.failed, 0) AS failed FROM kst_slots k LEFT JOIN agg a ON k.hour = a.hour ORDER BY k.hour `; const data = await query<{ hour: string; success: number; failed: number; }>(sparklineQuery, params); return res.json({ success: true, data }); } catch (error) { console.error("스파크라인 조회 오류:", error); return res.status(500).json({ success: false, message: "스파크라인 데이터 조회 실패", error: error instanceof Error ? error.message : "알 수 없는 오류", }); } } /** * 배치별 최근 실행 로그 (최대 20건) * GET /api/batch-management/batch-configs/:id/recent-logs * 멀티테넌시: company_code 필터링 필수 */ static async getBatchRecentLogs(req: AuthenticatedRequest, res: Response) { try { const { id } = req.params; const companyCode = req.user?.companyCode; const batchId = Number(id); const limit = Math.min(Number(req.query.limit) || 20, 20); if (!id || isNaN(batchId)) { return res.status(400).json({ success: false, message: "올바른 배치 ID를 제공해주세요.", }); } let logsQuery: string; let logsParams: any[]; if (companyCode === "*") { logsQuery = ` SELECT id, start_time AS started_at, end_time AS finished_at, execution_status AS status, total_records, success_records, failed_records, error_message, duration_ms FROM batch_execution_logs WHERE batch_config_id = $1 ORDER BY start_time DESC LIMIT $2 `; logsParams = [batchId, limit]; } else { logsQuery = ` SELECT id, start_time AS started_at, end_time AS finished_at, execution_status AS status, total_records, success_records, failed_records, error_message, duration_ms FROM batch_execution_logs WHERE batch_config_id = $1 AND company_code = $2 ORDER BY start_time DESC LIMIT $3 `; logsParams = [batchId, companyCode, limit]; } const result = await query(logsQuery, logsParams); return res.json({ success: true, data: result }); } catch (error) { console.error("최근 실행 이력 조회 오류:", error); return res.status(500).json({ success: false, message: "최근 실행 이력 조회 실패", error: error instanceof Error ? error.message : "알 수 없는 오류", }); } } }