diff options
Diffstat (limited to 'lib/approval/approval-polling-service.ts')
| -rw-r--r-- | lib/approval/approval-polling-service.ts | 307 |
1 files changed, 307 insertions, 0 deletions
diff --git a/lib/approval/approval-polling-service.ts b/lib/approval/approval-polling-service.ts new file mode 100644 index 00000000..d2ee16cb --- /dev/null +++ b/lib/approval/approval-polling-service.ts @@ -0,0 +1,307 @@ +/** + * 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 { executeApprovedAction, handleRejectedAction } from './approval-workflow'; + +/** + * 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 { + await executeApprovedAction(currentApproval.apInfId); + 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 { + await handleRejectedAction(currentApproval.apInfId, '결재가 반려되었습니다'); + 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 { + await handleRejectedAction(currentApproval.apInfId, '결재가 취소되었습니다'); + 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 { + await executeApprovedAction(approvalLog.apInfId); + 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') { + await handleRejectedAction(approvalLog.apInfId, '결재가 반려되었습니다'); + console.log(`[Approval Polling] ⛔ Single check - Handled rejected action: ${apInfId}`); + } + // 상신취소(4) + else if (newStatus === '4') { + await handleRejectedAction(approvalLog.apInfId, '결재가 취소되었습니다'); + 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; + } +} + |
