diff --git a/backend-node/src/controllers/batchManagementController.ts b/backend-node/src/controllers/batchManagementController.ts index bdd9e869..b2dc1e8c 100644 --- a/backend-node/src/controllers/batchManagementController.ts +++ b/backend-node/src/controllers/batchManagementController.ts @@ -768,4 +768,238 @@ export class BatchManagementController { }); } } + + /** + * 배치 대시보드 통계 조회 + * 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 : "알 수 없는 오류", + }); + } + } } diff --git a/backend-node/src/routes/batchManagementRoutes.ts b/backend-node/src/routes/batchManagementRoutes.ts index 50ee1ea0..6f57cb12 100644 --- a/backend-node/src/routes/batchManagementRoutes.ts +++ b/backend-node/src/routes/batchManagementRoutes.ts @@ -7,6 +7,13 @@ import { authenticateToken } from "../middleware/authMiddleware"; const router = Router(); +/** + * GET /api/batch-management/stats + * 배치 대시보드 통계 (전체/활성 배치 수, 오늘·어제 실행/실패 수) + * 반드시 /batch-configs 보다 위에 등록 (/:id로 잡히지 않도록) + */ +router.get("/stats", authenticateToken, BatchManagementController.getBatchStats); + /** * GET /api/batch-management/connections * 사용 가능한 커넥션 목록 조회 @@ -55,6 +62,18 @@ router.get("/batch-configs", authenticateToken, BatchManagementController.getBat */ router.get("/batch-configs/:id", authenticateToken, BatchManagementController.getBatchConfigById); +/** + * GET /api/batch-management/batch-configs/:id/sparkline + * 해당 배치 최근 24시간 1시간 단위 실행 집계 + */ +router.get("/batch-configs/:id/sparkline", authenticateToken, BatchManagementController.getBatchSparkline); + +/** + * GET /api/batch-management/batch-configs/:id/recent-logs + * 해당 배치 최근 실행 로그 (최대 20건) + */ +router.get("/batch-configs/:id/recent-logs", authenticateToken, BatchManagementController.getBatchRecentLogs); + /** * PUT /api/batch-management/batch-configs/:id * 배치 설정 업데이트