summaryrefslogtreecommitdiff
path: root/lib/approval/approval-polling-service.ts
diff options
context:
space:
mode:
Diffstat (limited to 'lib/approval/approval-polling-service.ts')
-rw-r--r--lib/approval/approval-polling-service.ts307
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;
+ }
+}
+