summaryrefslogtreecommitdiff
path: root/lib/approval/approval-polling-service.ts
blob: d2ee16cb425cfa26df1cece8333a0c9868cfa993 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
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;
  }
}