/** * 결재 워크플로우 Saga Orchestrator * * Saga 패턴을 통해 결재 프로세스의 각 단계를 명시적으로 관리하고, * 실패 시 보상 트랜잭션(Compensating Transaction)을 자동으로 실행합니다. * * 비즈니스 흐름: * 1. 핸들러 초기화 * 2. 템플릿 준비 * 3. 결재선 생성 * 4. 결재 요청 생성 * 5. DB에 Pending Action 저장 * 6. Knox 시스템에 결재 상신 * 7. 캐시 무효화 * * 실패 시 보상 트랜잭션: * - Knox 상신 실패 → Pending Action 상태를 'failed'로 업데이트 */ import db from '@/db/db'; import { eq } from 'drizzle-orm'; import { pendingActions } from '@/db/schema/knox/pending-actions'; import type { ApprovalConfig, ApprovalResult } from './types'; import type { ApprovalLine, SubmitApprovalRequest } from '@/lib/knox-api/approval/approval'; import type { ActionHandler } from './approval-workflow'; /** * Pending Action 타입 정의 */ interface PendingActionRecord { id: number; apInfId: string; actionType: string; // eslint-disable-next-line @typescript-eslint/no-explicit-any actionPayload: any; status: string; createdBy: number; executedAt: Date | null; errorMessage: string | null; // eslint-disable-next-line @typescript-eslint/no-explicit-any executionResult: any; } /** * 결재 상신 Saga Orchestrator * * @example * const saga = new ApprovalSubmissionSaga( * 'vendor_investigation_request', * { vendorId: 123 }, * { title: '실사 요청', templateName: '협력업체 실사 요청', ... } * ); * const result = await saga.execute(); */ export class ApprovalSubmissionSaga { private actionType: string; private actionPayload: T; private approvalConfig: ApprovalConfig; // Saga 상태 추적 private pendingActionId?: number; private submitRequest?: SubmitApprovalRequest; private templateContent?: string; constructor( actionType: string, actionPayload: T, approvalConfig: ApprovalConfig ) { this.actionType = actionType; this.actionPayload = actionPayload; this.approvalConfig = approvalConfig; } /** * Saga 실행 - 모든 단계를 순차적으로 실행 */ async execute(): Promise { try { console.log(`[ApprovalSaga] Starting approval saga for ${this.actionType}`); // 1단계: 핸들러 초기화 await this.initializeHandlers(); // 2단계: 템플릿 준비 (변수 치환) await this.prepareApprovalTemplate(); // 3단계: 결재선 생성 const approvalLines = await this.createApprovalLines(); // 4단계: Knox 결재 요청 생성 await this.createSubmitRequest(approvalLines); // 5단계: DB에 Pending Action 저장 (Knox 상신 전) await this.savePendingAction(); // 6단계: Knox 시스템에 결재 상신 await this.submitToKnox(); // 7단계: 캐시 무효화 await this.invalidateCache(); console.log(`[ApprovalSaga] ✅ Saga completed successfully for ${this.actionType}`); return { pendingActionId: this.pendingActionId!, approvalId: this.submitRequest!.apInfId, status: 'pending_approval' as const, }; } catch (error) { console.error(`[ApprovalSaga] ❌ Saga failed for ${this.actionType}:`, error); // 보상 트랜잭션 실행 await this.compensate(error); throw error; } } /** * 1단계: 핸들러 초기화 * Next.js 서버 액션의 격리된 실행 컨텍스트를 위한 Lazy Initialization */ private async initializeHandlers(): Promise { console.log(`[ApprovalSaga] Step 1: Initializing handlers`); const { ensureHandlersInitialized, actionHandlers } = await import('./approval-workflow'); await ensureHandlersInitialized(); // 핸들러 존재 여부 확인 if (!actionHandlers.has(this.actionType)) { const availableHandlers = Array.from(actionHandlers.keys()); console.error('[ApprovalSaga] Available handlers:', availableHandlers); throw new Error(`No handler registered for action type: ${this.actionType}`); } console.log(`[ApprovalSaga] ✓ Handlers initialized`); } /** * 2단계: 템플릿 준비 (변수 치환) */ private async prepareApprovalTemplate(): Promise { console.log(`[ApprovalSaga] Step 2: Preparing approval template`); const { getApprovalTemplateByName, replaceTemplateVariables } = await import('./template-utils'); const template = await getApprovalTemplateByName(this.approvalConfig.templateName); if (!template) { console.warn(`[ApprovalSaga] Template not found: ${this.approvalConfig.templateName}`); this.templateContent = this.approvalConfig.description || '결재 요청'; } else { this.templateContent = await replaceTemplateVariables( template.content, this.approvalConfig.variables ); } console.log(`[ApprovalSaga] ✓ Template prepared`); } /** * 3단계: 결재선 생성 */ private async createApprovalLines(): Promise { console.log(`[ApprovalSaga] Step 3: Creating approval lines`); const { createApprovalLine } = await import('@/lib/knox-api/approval/approval'); const aplns: ApprovalLine[] = []; // 기안자 (현재 사용자) if (this.approvalConfig.currentUser.epId) { const drafterLine = await createApprovalLine( { epId: this.approvalConfig.currentUser.epId, emailAddress: this.approvalConfig.currentUser.email, }, '0', // 기안 '0' // seq ); aplns.push(drafterLine); } // 결재자들 if (this.approvalConfig.approvers && this.approvalConfig.approvers.length > 0) { for (let i = 0; i < this.approvalConfig.approvers.length; i++) { const approverLine = await createApprovalLine( { epId: this.approvalConfig.approvers[i] }, '1', // 승인 String(i + 1) ); aplns.push(approverLine); } } console.log(`[ApprovalSaga] ✓ Created ${aplns.length} approval lines`); return aplns; } /** * 4단계: Knox 결재 요청 생성 */ private async createSubmitRequest(approvalLines: ApprovalLine[]): Promise { console.log(`[ApprovalSaga] Step 4: Creating submit request`); const { createSubmitApprovalRequest } = await import('@/lib/knox-api/approval/approval'); this.submitRequest = await createSubmitApprovalRequest( this.templateContent!, // 치환된 템플릿 content this.approvalConfig.title, approvalLines, { contentsType: 'HTML', // HTML 템플릿 사용 } ); console.log(`[ApprovalSaga] ✓ Submit request created (apInfId: ${this.submitRequest.apInfId})`); } /** * 5단계: DB에 Pending Action 저장 * * Saga 패턴의 핵심: Knox 상신 전에 DB에 먼저 저장 * 이렇게 하면 Knox 상신 실패 시 보상 트랜잭션 가능 */ private async savePendingAction(): Promise { console.log(`[ApprovalSaga] Step 5: Saving pending action to DB`); const [pendingAction] = await db.insert(pendingActions).values({ apInfId: this.submitRequest!.apInfId, actionType: this.actionType, // eslint-disable-next-line @typescript-eslint/no-explicit-any actionPayload: this.actionPayload as any, status: 'pending', createdBy: this.approvalConfig.currentUser.id, }).returning(); this.pendingActionId = pendingAction.id; console.log(`[ApprovalSaga] ✓ Pending action saved (ID: ${this.pendingActionId})`); } /** * 6단계: Knox 시스템에 결재 상신 * * 외부 시스템 호출 - 실패 시 보상 트랜잭션 필요 */ private async submitToKnox(): Promise { console.log(`[ApprovalSaga] Step 6: Submitting to Knox system`); const { submitApproval } = await import('@/lib/knox-api/approval/approval'); await submitApproval( this.submitRequest!, { userId: String(this.approvalConfig.currentUser.id), epId: this.approvalConfig.currentUser.epId || '', emailAddress: this.approvalConfig.currentUser.email || '', } ); console.log(`[ApprovalSaga] ✓ Successfully submitted to Knox`); } /** * 7단계: 캐시 무효화 */ private async invalidateCache(): Promise { console.log(`[ApprovalSaga] Step 7: Invalidating cache`); const { revalidateApprovalLogs } = await import('./cache-utils'); await revalidateApprovalLogs(); console.log(`[ApprovalSaga] ✓ Cache invalidated`); } /** * 보상 트랜잭션 (Compensating Transaction) * * Knox 상신 실패 시 DB의 pending action 상태를 'failed'로 업데이트 * Saga 패턴의 핵심: 분산 트랜잭션의 일관성 보장 */ private async compensate(error: unknown): Promise { console.log(`[ApprovalSaga] Executing compensating transaction`); if (typeof this.pendingActionId !== 'number') { console.log(`[ApprovalSaga] No pending action to compensate`); return; } try { console.log(`[ApprovalSaga] Marking pending action as failed: ${this.pendingActionId}`); await db.update(pendingActions) .set({ status: 'failed', errorMessage: error instanceof Error ? `Knox 상신 실패: ${error.message}` : 'Knox 상신 실패', executedAt: new Date(), }) .where(eq(pendingActions.id, this.pendingActionId)); console.log(`[ApprovalSaga] ✓ Compensating transaction completed`); } catch (compensateError) { console.error(`[ApprovalSaga] ❌ Compensating transaction failed:`, compensateError); // 보상 트랜잭션 실패는 로그만 남기고 원래 에러를 throw } } } /** * 결재 승인 후 액션 실행 Saga Orchestrator * * @example * const saga = new ApprovalExecutionSaga('AP-2024-001'); * await saga.execute(); */ export class ApprovalExecutionSaga { private apInfId: string; // Saga 상태 추적 private pendingAction?: PendingActionRecord; private handler?: ActionHandler; // eslint-disable-next-line @typescript-eslint/no-explicit-any private executionResult?: any; constructor(apInfId: string) { this.apInfId = apInfId; } /** * Saga 실행 - 승인된 액션 실행 */ // eslint-disable-next-line @typescript-eslint/no-explicit-any async execute(): Promise { const { debugLog, debugError, debugSuccess } = await import('@/lib/debug-utils'); try { debugLog('[ExecutionSaga] Starting execution saga', { apInfId: this.apInfId }); // 1단계: 핸들러 초기화 await this.initializeHandlers(); // 2단계: Pending Action 조회 await this.loadPendingAction(); // 3단계: 실행 가능 여부 확인 this.validateExecutionState(); // 4단계: 액션 핸들러 조회 await this.loadActionHandler(); // 5단계: 액션 실행 await this.executeAction(); // 6단계: 실행 완료 상태 업데이트 await this.markAsExecuted(); // 7단계: 캐시 무효화 await this.invalidateCache(); debugSuccess('[ExecutionSaga] ✅ Execution saga completed', { apInfId: this.apInfId, actionType: this.pendingAction?.actionType, }); return this.executionResult; } catch (error) { debugError('[ExecutionSaga] ❌ Execution saga failed', { apInfId: this.apInfId, error: error instanceof Error ? error.message : String(error), }); // 보상 트랜잭션 실행 await this.compensate(error); throw error; } } /** * 1단계: 핸들러 초기화 */ private async initializeHandlers(): Promise { const { debugLog } = await import('@/lib/debug-utils'); debugLog('[ExecutionSaga] Step 1: Initializing handlers'); const { ensureHandlersInitialized } = await import('./approval-workflow'); await ensureHandlersInitialized(); debugLog('[ExecutionSaga] ✓ Handlers initialized'); } /** * 2단계: Pending Action 조회 */ private async loadPendingAction(): Promise { const { debugLog } = await import('@/lib/debug-utils'); debugLog('[ExecutionSaga] Step 2: Loading pending action', { apInfId: this.apInfId }); this.pendingAction = await db.query.pendingActions.findFirst({ where: eq(pendingActions.apInfId, this.apInfId), }); if (!this.pendingAction) { debugLog('[ExecutionSaga] No pending action found', { apInfId: this.apInfId }); console.log(`[ExecutionSaga] No pending action found for approval: ${this.apInfId}`); return; // 결재만 있고 실행할 액션이 없는 경우 } debugLog('[ExecutionSaga] ✓ Pending action loaded', { id: this.pendingAction.id, actionType: this.pendingAction.actionType, status: this.pendingAction.status, }); } /** * 3단계: 실행 가능 여부 확인 */ private async validateExecutionState(): Promise { if (!this.pendingAction) { return; // 이미 2단계에서 처리됨 } const { debugLog } = await import('@/lib/debug-utils'); // 이미 실행되었거나 실패한 액션은 스킵 if (['executed', 'failed'].includes(this.pendingAction.status)) { debugLog('[ExecutionSaga] Action already processed', { apInfId: this.apInfId, status: this.pendingAction.status, }); console.log(`[ExecutionSaga] Pending action already processed: ${this.apInfId}`); throw new Error('Action already processed'); } debugLog('[ExecutionSaga] ✓ Action is ready for execution'); } /** * 4단계: 액션 핸들러 조회 */ private async loadActionHandler(): Promise { if (!this.pendingAction) return; const { debugLog, debugError } = await import('@/lib/debug-utils'); debugLog('[ExecutionSaga] Step 4: Loading action handler', { actionType: this.pendingAction.actionType, }); const { actionHandlers } = await import('./approval-workflow'); this.handler = actionHandlers.get(this.pendingAction.actionType); if (!this.handler) { const availableHandlers = Array.from(actionHandlers.keys()); debugError('[ExecutionSaga] Handler not found', { actionType: this.pendingAction.actionType, availableHandlers, }); throw new Error(`Handler not found for action type: ${this.pendingAction.actionType}`); } debugLog('[ExecutionSaga] ✓ Handler loaded'); } /** * 5단계: 액션 실행 */ private async executeAction(): Promise { if (!this.pendingAction || !this.handler) return; const { debugLog, debugSuccess } = await import('@/lib/debug-utils'); debugLog('[ExecutionSaga] Step 5: Executing action', { actionType: this.pendingAction.actionType, apInfId: this.apInfId, }); console.log(`[ExecutionSaga] Executing action: ${this.pendingAction.actionType} (${this.apInfId})`); this.executionResult = await this.handler(this.pendingAction.actionPayload); debugSuccess('[ExecutionSaga] ✓ Action executed', { actionType: this.pendingAction.actionType, resultKeys: this.executionResult ? Object.keys(this.executionResult) : [], }); } /** * 6단계: 실행 완료 상태 업데이트 */ private async markAsExecuted(): Promise { if (!this.pendingAction) return; const { debugLog } = await import('@/lib/debug-utils'); debugLog('[ExecutionSaga] Step 6: Marking as executed'); await db.update(pendingActions) .set({ status: 'executed', executedAt: new Date(), executionResult: this.executionResult, }) .where(eq(pendingActions.apInfId, this.apInfId)); debugLog('[ExecutionSaga] ✓ Marked as executed'); } /** * 7단계: 캐시 무효화 */ private async invalidateCache(): Promise { const { debugLog } = await import('@/lib/debug-utils'); debugLog('[ExecutionSaga] Step 7: Invalidating cache'); const { revalidateApprovalLogs } = await import('./cache-utils'); await revalidateApprovalLogs(); debugLog('[ExecutionSaga] ✓ Cache invalidated'); } /** * 보상 트랜잭션 - 실행 실패 시 상태 업데이트 */ private async compensate(error: unknown): Promise { if (!this.pendingAction) return; const { debugLog, debugError } = await import('@/lib/debug-utils'); debugLog('[ExecutionSaga] Executing compensating transaction'); try { await db.update(pendingActions) .set({ status: 'failed', errorMessage: error instanceof Error ? error.message : String(error), executedAt: new Date(), }) .where(eq(pendingActions.apInfId, this.apInfId)); debugLog('[ExecutionSaga] ✓ Compensating transaction completed'); } catch (compensateError) { debugError('[ExecutionSaga] ❌ Compensating transaction failed', { error: compensateError instanceof Error ? compensateError.message : String(compensateError), }); } } } /** * 결재 반려 처리 Saga Orchestrator */ export class ApprovalRejectionSaga { private apInfId: string; private reason?: string; constructor(apInfId: string, reason?: string) { this.apInfId = apInfId; this.reason = reason; } async execute(): Promise { try { console.log(`[RejectionSaga] Starting rejection saga for ${this.apInfId}`); // 1단계: Pending Action 조회 const pendingAction = await this.loadPendingAction(); if (!pendingAction) { console.log(`[RejectionSaga] No pending action found for ${this.apInfId}`); return; } // 2단계: 반려 상태 업데이트 await this.markAsRejected(); // 3단계: 캐시 무효화 await this.invalidateCache(); // 4단계: 알림 발송 (TODO) await this.sendNotification(); console.log(`[RejectionSaga] ✅ Rejection saga completed`); } catch (error) { console.error(`[RejectionSaga] ❌ Rejection saga failed:`, error); throw error; } } private async loadPendingAction() { return await db.query.pendingActions.findFirst({ where: eq(pendingActions.apInfId, this.apInfId), }); } private async markAsRejected(): Promise { await db.update(pendingActions) .set({ status: 'rejected', errorMessage: this.reason, executedAt: new Date(), }) .where(eq(pendingActions.apInfId, this.apInfId)); } private async invalidateCache(): Promise { const { revalidateApprovalLogs } = await import('./cache-utils'); await revalidateApprovalLogs(); } private async sendNotification(): Promise { // TODO: 요청자에게 알림 발송 console.log(`[RejectionSaga] TODO: Send notification for rejection`); } }