/** * Knox 결재 상태 폴링 서비스 * * 기능: * - 1분마다 진행중인(pending) 결재를 조회 * - Knox API로 상태 일괄 확인 (최대 1000건씩 배치) * - 상태 변경 시 DB 업데이트 및 후속 처리 * * 흐름: * Cron (1분) → checkPendingApprovals() → Knox API 조회 → 상태 업데이트 → executeApprovedAction() * * 기존 syncApprovalStatusAction과의 차이점: * - syncApprovalStatusAction: UI에서 수동으로 트리거 (on-demand) * - checkPendingApprovals: 백그라운드 cron job (1분마다 자동) * - checkPendingApprovals: 결재 완료/반려 시 자동으로 후속 처리 실행 */ import cron from 'node-cron'; import db from '@/db/db'; import { eq, and, inArray } from 'drizzle-orm'; import { ApprovalExecutionSaga, ApprovalRejectionSaga } from './approval-saga'; /** * Pending 상태의 결재들을 조회하고 상태 동기화 * * 처리 로직 (syncApprovalStatusAction 기반): * 1. DB에서 진행중인 결재 조회 (암호화중~진행중) * 2. Knox API로 일괄 상태 확인 (최대 1000건씩 배치) * 3. 상태가 변경된 경우 DB 업데이트 * 4. 완결/반려로 변경 시 후속 처리 (executeApprovedAction/handleRejectedAction) */ export async function checkPendingApprovals() { console.log('[Approval Polling] Starting approval status check...'); try { // 동적 import로 스키마 및 Knox API 로드 const { approvalLogs } = await import('@/db/schema/knox/approvals'); const { getApprovalStatus } = await import('@/lib/knox-api/approval/approval'); const { upsertApprovalStatus } = await import('@/lib/knox-api/approval/service'); // 1. 진행중인 결재건들 조회 (암호화중~진행중까지) // 암호화중(-2), 예약상신(-1), 보류(0), 진행중(1) 상태만 조회 // 완결(2), 반려(3), 상신취소(4), 전결(5), 후완결(6)은 제외 const pendingApprovals = await db .select({ apInfId: approvalLogs.apInfId, status: approvalLogs.status, }) .from(approvalLogs) .where( and( eq(approvalLogs.isDeleted, false), inArray(approvalLogs.status, ['-2', '-1', '0', '1']) ) ); if (pendingApprovals.length === 0) { console.log('[Approval Polling] No pending approvals found'); return { success: true, message: 'No pending approvals', checked: 0, updated: 0, executed: 0, }; } console.log(`[Approval Polling] Found ${pendingApprovals.length} pending approvals to check`); // 2. Knox API 호출을 위한 요청 데이터 구성 const apinfids = pendingApprovals.map(approval => ({ apinfid: approval.apInfId })); // 3. Knox API로 결재 상황 조회 (최대 1000건씩 배치 처리) const batchSize = 1000; let updated = 0; let executed = 0; const failed: string[] = []; for (let i = 0; i < apinfids.length; i += batchSize) { const batch = apinfids.slice(i, i + batchSize); try { console.log(`[Approval Polling] Processing batch ${Math.floor(i / batchSize) + 1} (${batch.length} items)`); const statusResponse = await getApprovalStatus({ apinfids: batch }); if (statusResponse.result === 'success' && statusResponse.data) { // 4. 조회된 상태로 데이터베이스 업데이트 및 후속 처리 for (const statusData of statusResponse.data) { try { // 기존 데이터 찾기 const currentApproval = pendingApprovals.find( approval => approval.apInfId === statusData.apInfId ); if (!currentApproval) { console.warn(`[Approval Polling] Approval not found in local list: ${statusData.apInfId}`); continue; } const oldStatus = currentApproval.status; const newStatus = statusData.status; // 상태가 변경된 경우에만 처리 if (oldStatus !== newStatus) { console.log(`[Approval Polling] Status changed: ${statusData.apInfId} (${oldStatus} → ${newStatus})`); // DB 상태 업데이트 await upsertApprovalStatus(statusData.apInfId, newStatus); updated++; // 5. 후속 처리 - 완결 상태로 변경된 경우 // 완결(2), 전결(5), 후완결(6) if (['2', '5', '6'].includes(newStatus)) { try { // Saga 패턴으로 액션 실행 const executionSaga = new ApprovalExecutionSaga(currentApproval.apInfId); await executionSaga.execute(); executed++; console.log(`[Approval Polling] ✅ Executed approved action: ${statusData.apInfId}`); } catch (execError) { console.error(`[Approval Polling] ❌ Failed to execute action: ${statusData.apInfId}`, execError); // 실행 실패는 별도로 기록하되 폴링은 계속 진행 } } // 반려된 경우 else if (newStatus === '3') { try { // Saga 패턴으로 반려 처리 const rejectionSaga = new ApprovalRejectionSaga(currentApproval.apInfId, '결재가 반려되었습니다'); await rejectionSaga.execute(); console.log(`[Approval Polling] ⛔ Handled rejected action: ${statusData.apInfId}`); } catch (rejectError) { console.error(`[Approval Polling] Failed to handle rejection: ${statusData.apInfId}`, rejectError); } } // 상신취소된 경우 else if (newStatus === '4') { try { // Saga 패턴으로 취소 처리 const cancellationSaga = new ApprovalRejectionSaga(currentApproval.apInfId, '결재가 취소되었습니다'); await cancellationSaga.execute(); console.log(`[Approval Polling] 🚫 Handled cancelled action: ${statusData.apInfId}`); } catch (cancelError) { console.error(`[Approval Polling] Failed to handle cancellation: ${statusData.apInfId}`, cancelError); } } } } catch (updateError) { console.error(`[Approval Polling] Update failed for ${statusData.apInfId}:`, updateError); failed.push(statusData.apInfId); } } } else { console.error('[Approval Polling] Knox API returned error:', statusResponse); batch.forEach(item => failed.push(item.apinfid)); } } catch (batchError) { console.error('[Approval Polling] Batch processing failed:', batchError); batch.forEach(item => failed.push(item.apinfid)); } } const summary = { success: true, message: `Polling completed: ${updated} updated, ${executed} executed${failed.length > 0 ? `, ${failed.length} failed` : ''}`, checked: pendingApprovals.length, updated, executed, failed: failed.length, }; console.log('[Approval Polling] Summary:', summary); return summary; } catch (error) { console.error('[Approval Polling] Error during approval status check:', error); return { success: false, message: `Polling failed: ${error}`, checked: 0, updated: 0, executed: 0, failed: 0, }; } } /** * 결재 폴링 스케줄러 시작 * 1분마다 pending 결재 상태 확인 * * instrumentation.ts에서 호출됨 */ export async function startApprovalPollingScheduler() { console.log('[Approval Polling] Starting approval polling scheduler...'); // 1분마다 실행 (cron: '* * * * *') const task = cron.schedule( '* * * * *', // 매분 실행 async () => { try { await checkPendingApprovals(); } catch (error) { console.error('[Approval Polling] Scheduled task failed:', error); } }, { timezone: 'Asia/Seoul', } ); // 앱 시작 시 즉시 한 번 실행 (선택사항) // await checkPendingApprovals(); console.log('[Approval Polling] Scheduler started - running every minute'); return task; } /** * 특정 결재의 상태를 즉시 확인 (수동 트리거용) * UI에서 "상태 새로고침" 버튼 클릭 시 사용 * * @param apInfId - Knox 결재 ID * @returns 결재 상태 및 업데이트 여부 */ export async function checkSingleApprovalStatus(apInfId: string) { console.log(`[Approval Polling] Checking single approval: ${apInfId}`); try { // 동적 import로 스키마 및 Knox API 로드 const { approvalLogs } = await import('@/db/schema/knox/approvals'); const { getApprovalStatus } = await import('@/lib/knox-api/approval/approval'); const { upsertApprovalStatus } = await import('@/lib/knox-api/approval/service'); // 1. DB에서 결재 정보 조회 const approvalLog = await db.query.approvalLogs.findFirst({ where: eq(approvalLogs.apInfId, apInfId), }); if (!approvalLog) { throw new Error(`Approval log not found for apInfId: ${apInfId}`); } const oldStatus = approvalLog.status; // 2. Knox API로 현재 상태 조회 (단건) const statusResponse = await getApprovalStatus({ apinfids: [{ apinfid: apInfId }] }); if (statusResponse.result !== 'success' || !statusResponse.data || statusResponse.data.length === 0) { throw new Error(`Failed to fetch Knox status for ${apInfId}`); } const knoxStatus = statusResponse.data[0]; const newStatus = knoxStatus.status; // 3. 상태가 변경된 경우 업데이트 및 후속 처리 let executed = false; if (oldStatus !== newStatus) { console.log(`[Approval Polling] Single check - Status changed: ${apInfId} (${oldStatus} → ${newStatus})`); // DB 상태 업데이트 await upsertApprovalStatus(apInfId, newStatus); // 4. 후속 처리 // 완결(2), 전결(5), 후완결(6) if (['2', '5', '6'].includes(newStatus)) { try { // Saga 패턴으로 액션 실행 const executionSaga = new ApprovalExecutionSaga(approvalLog.apInfId); await executionSaga.execute(); executed = true; console.log(`[Approval Polling] ✅ Single check - Executed approved action: ${apInfId}`); } catch (execError) { console.error(`[Approval Polling] ❌ Single check - Failed to execute action: ${apInfId}`, execError); } } // 반려(3) else if (newStatus === '3') { const rejectionSaga = new ApprovalRejectionSaga(approvalLog.apInfId, '결재가 반려되었습니다'); await rejectionSaga.execute(); console.log(`[Approval Polling] ⛔ Single check - Handled rejected action: ${apInfId}`); } // 상신취소(4) else if (newStatus === '4') { const cancellationSaga = new ApprovalRejectionSaga(approvalLog.apInfId, '결재가 취소되었습니다'); await cancellationSaga.execute(); console.log(`[Approval Polling] 🚫 Single check - Handled cancelled action: ${apInfId}`); } } else { console.log(`[Approval Polling] Single check - No status change: ${apInfId} (${oldStatus})`); } return { success: true, apInfId, oldStatus, newStatus, updated: oldStatus !== newStatus, executed, }; } catch (error) { console.error(`[Approval Polling] Failed to check single approval status for ${apInfId}:`, error); throw error; } }