diff options
| author | joonhoekim <26rote@gmail.com> | 2025-11-05 19:56:29 +0900 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-11-05 19:56:29 +0900 |
| commit | 551129656039aae409b3af51ce4acbb59f60229f (patch) | |
| tree | e041f542cd46920086e84d0071c9e0a76c4b8699 | |
| parent | e890fbae0c9c273b825ac808aa516de1f87fb218 (diff) | |
(김준회) approval: Saga Pattern 클래스로 리팩터링, 명시적으로 개선, 기존 사용 함수 리팩터링
| -rw-r--r-- | lib/approval/approval-polling-service.ts | 24 | ||||
| -rw-r--r-- | lib/approval/approval-saga.ts | 623 | ||||
| -rw-r--r-- | lib/approval/approval-workflow.ts | 375 | ||||
| -rw-r--r-- | lib/approval/index.ts | 28 | ||||
| -rw-r--r-- | lib/vendor-investigation/approval-actions.ts | 20 | ||||
| -rw-r--r-- | lib/vendor-regular-registrations/approval-actions.ts | 11 |
6 files changed, 687 insertions, 394 deletions
diff --git a/lib/approval/approval-polling-service.ts b/lib/approval/approval-polling-service.ts index d2ee16cb..79a83c2e 100644 --- a/lib/approval/approval-polling-service.ts +++ b/lib/approval/approval-polling-service.ts @@ -18,7 +18,7 @@ import cron from 'node-cron'; import db from '@/db/db'; import { eq, and, inArray } from 'drizzle-orm'; -import { executeApprovedAction, handleRejectedAction } from './approval-workflow'; +import { ApprovalExecutionSaga, ApprovalRejectionSaga } from './approval-saga'; /** * Pending 상태의 결재들을 조회하고 상태 동기화 @@ -117,7 +117,9 @@ export async function checkPendingApprovals() { // 완결(2), 전결(5), 후완결(6) if (['2', '5', '6'].includes(newStatus)) { try { - await executeApprovedAction(currentApproval.apInfId); + // Saga 패턴으로 액션 실행 + const executionSaga = new ApprovalExecutionSaga(currentApproval.apInfId); + await executionSaga.execute(); executed++; console.log(`[Approval Polling] ✅ Executed approved action: ${statusData.apInfId}`); } catch (execError) { @@ -129,7 +131,9 @@ export async function checkPendingApprovals() { // 반려된 경우 else if (newStatus === '3') { try { - await handleRejectedAction(currentApproval.apInfId, '결재가 반려되었습니다'); + // 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); @@ -139,7 +143,9 @@ export async function checkPendingApprovals() { // 상신취소된 경우 else if (newStatus === '4') { try { - await handleRejectedAction(currentApproval.apInfId, '결재가 취소되었습니다'); + // 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); @@ -270,7 +276,9 @@ export async function checkSingleApprovalStatus(apInfId: string) { // 완결(2), 전결(5), 후완결(6) if (['2', '5', '6'].includes(newStatus)) { try { - await executeApprovedAction(approvalLog.apInfId); + // Saga 패턴으로 액션 실행 + const executionSaga = new ApprovalExecutionSaga(approvalLog.apInfId); + await executionSaga.execute(); executed = true; console.log(`[Approval Polling] ✅ Single check - Executed approved action: ${apInfId}`); } catch (execError) { @@ -279,12 +287,14 @@ export async function checkSingleApprovalStatus(apInfId: string) { } // 반려(3) else if (newStatus === '3') { - await handleRejectedAction(approvalLog.apInfId, '결재가 반려되었습니다'); + const rejectionSaga = new ApprovalRejectionSaga(approvalLog.apInfId, '결재가 반려되었습니다'); + await rejectionSaga.execute(); console.log(`[Approval Polling] ⛔ Single check - Handled rejected action: ${apInfId}`); } // 상신취소(4) else if (newStatus === '4') { - await handleRejectedAction(approvalLog.apInfId, '결재가 취소되었습니다'); + const cancellationSaga = new ApprovalRejectionSaga(approvalLog.apInfId, '결재가 취소되었습니다'); + await cancellationSaga.execute(); console.log(`[Approval Polling] 🚫 Single check - Handled cancelled action: ${apInfId}`); } } else { diff --git a/lib/approval/approval-saga.ts b/lib/approval/approval-saga.ts new file mode 100644 index 00000000..0bbf7836 --- /dev/null +++ b/lib/approval/approval-saga.ts @@ -0,0 +1,623 @@ +/** + * 결재 워크플로우 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<T> { + 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<ApprovalResult> { + 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<void> { + 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<void> { + 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<ApprovalLine[]> { + 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<void> { + 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<void> { + 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<void> { + 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<void> { + 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<void> { + 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<any> { + 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<void> { + 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<void> { + 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<void> { + 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<void> { + 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<void> { + 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<void> { + 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<void> { + 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<void> { + 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<void> { + 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<void> { + await db.update(pendingActions) + .set({ + status: 'rejected', + errorMessage: this.reason, + executedAt: new Date(), + }) + .where(eq(pendingActions.apInfId, this.apInfId)); + } + + private async invalidateCache(): Promise<void> { + const { revalidateApprovalLogs } = await import('./cache-utils'); + await revalidateApprovalLogs(); + } + + private async sendNotification(): Promise<void> { + // TODO: 요청자에게 알림 발송 + console.log(`[RejectionSaga] TODO: Send notification for rejection`); + } +} + diff --git a/lib/approval/approval-workflow.ts b/lib/approval/approval-workflow.ts index fc6a0dcf..5ae74cbd 100644 --- a/lib/approval/approval-workflow.ts +++ b/lib/approval/approval-workflow.ts @@ -1,22 +1,12 @@ /** - * 결재 워크플로우 관리 모듈 + * 결재 워크플로우 핸들러 레지스트리 * - * 주요 기능: - * 1. 결재가 필요한 액션을 pending 상태로 저장 - * 2. Knox 결재 시스템에 상신 - * 3. 결재 완료 시 저장된 액션 실행 + * Saga 패턴에서 사용할 액션 핸들러들을 등록하고 관리합니다. * * 흐름: - * withApproval() → Knox 상신 → [폴링으로 상태 감지] → executeApprovedAction() + * ApprovalSubmissionSaga → Knox 상신 → [폴링으로 상태 감지] → ApprovalExecutionSaga */ -import db from '@/db/db'; -import { eq } from 'drizzle-orm'; -import { pendingActions } from '@/db/schema/knox/pending-actions'; - -import type { ApprovalConfig } from './types'; -import type { ApprovalLine } from '@/lib/knox-api/approval/approval'; - /** * 액션 핸들러 타입 정의 * payload를 받아서 실제 비즈니스 로직을 수행하는 함수 @@ -28,7 +18,7 @@ export type ActionHandler = (payload: any) => Promise<any>; * 액션 타입별 핸들러 저장소 * registerActionHandler()로 등록된 핸들러들이 여기 저장됨 */ -const actionHandlers = new Map<string, ActionHandler>(); +export const actionHandlers = new Map<string, ActionHandler>(); /** * 특정 액션 타입에 대한 핸들러 등록 @@ -58,7 +48,7 @@ let handlersInitialized = false; * 핸들러가 등록되어 있지 않으면 자동으로 초기화 (Lazy Initialization) * Next.js 서버 액션의 격리된 실행 컨텍스트를 위한 안전장치 */ -async function ensureHandlersInitialized() { +export async function ensureHandlersInitialized() { if (!handlersInitialized) { console.log('[Approval Workflow] Lazy initializing handlers...'); const { initializeApprovalHandlers } = await import('./handlers-registry'); @@ -67,358 +57,3 @@ async function ensureHandlersInitialized() { } } -/** - * 결재가 필요한 액션을 래핑하는 공통 함수 - * - * 사용법: - * ```typescript - * const result = await withApproval( - * 'vendor_investigation_request', - * { vendorId: 123, reason: '실사 필요' }, - * { - * title: '실사 요청 결재', - * description: 'ABC 협력업체 실사 요청', - * templateName: '협력업체 실사 요청', - * variables: { '수신자이름': '결재자', ... }, - * currentUser: { id: 1, epId: 'EP001' } - * } - * ); - * ``` - * - * @param actionType - 액션 타입 (핸들러 등록 시 사용한 키) - * @param actionPayload - 액션 실행에 필요한 데이터 - * @param approvalConfig - 결재 상신 설정 (템플릿명, 변수 포함) - * @returns pendingActionId, approvalId, status - */ -export async function withApproval<T>( - actionType: string, - actionPayload: T, - approvalConfig: ApprovalConfig -) { - // 핸들러 자동 초기화 (서버 액션 격리 문제 해결) - await ensureHandlersInitialized(); - - // 핸들러가 등록되어 있는지 확인 - if (!actionHandlers.has(actionType)) { - console.error('[Approval Workflow] Available handlers:', Array.from(actionHandlers.keys())); - throw new Error(`No handler registered for action type: ${actionType}`); - } - - // 1. 템플릿 조회 및 변수 치환 - const { getApprovalTemplateByName, replaceTemplateVariables } = await import('./template-utils'); - const template = await getApprovalTemplateByName(approvalConfig.templateName); - - let content: string; - if (!template) { - console.warn(`[Approval Workflow] Template not found: ${approvalConfig.templateName}`); - // 템플릿이 없으면 기본 내용 사용 - content = approvalConfig.description || '결재 요청'; - } else { - // 템플릿 변수 치환 - content = await replaceTemplateVariables(template.content, approvalConfig.variables); - } - - // 2. Knox 결재 상신 준비 (apInfId 생성, 치환된 content 사용) - const { - submitApproval, - createSubmitApprovalRequest, - createApprovalLine - } = await import('@/lib/knox-api/approval/approval'); - - // 결재선 생성 - const aplns: ApprovalLine[] = []; - - // 기안자 (현재 사용자) - if (approvalConfig.currentUser.epId) { - const drafterLine = await createApprovalLine( - { - epId: approvalConfig.currentUser.epId, - emailAddress: approvalConfig.currentUser.email, - }, - '0', // 기안 - '0' // seq - ); - aplns.push(drafterLine); - } - - // 결재자들 - if (approvalConfig.approvers && approvalConfig.approvers.length > 0) { - for (let i = 0; i < approvalConfig.approvers.length; i++) { - const approverLine = await createApprovalLine( - { epId: approvalConfig.approvers[i] }, - '1', // 승인 - String(i + 1) - ); - aplns.push(approverLine); - } - } - - // 결재 요청 생성 - const submitRequest = await createSubmitApprovalRequest( - content, // 치환된 템플릿 content - approvalConfig.title, - aplns, - { - contentsType: 'HTML', // HTML 템플릿 사용 - } - ); - - /** - * 트랜잭션 관리 전략 (Saga Pattern): - * - * Knox는 외부 시스템이므로 DB 트랜잭션으로 롤백할 수 없습니다. - * 따라서 다음 순서로 처리하여 데이터 정합성을 보장합니다: - * - * 1. DB에 먼저 pendingAction 생성 (status: 'pending') - * 2. Knox 결재 상신 시도 - * 3-A. Knox 상신 성공 → pendingAction 그대로 유지 - * 3-B. Knox 상신 실패 → pendingAction status를 'failed'로 업데이트 (보상 트랜잭션) - * - * 이렇게 하면: - * - Knox 상신 전에 DB 저장 실패 → 전체 실패 (정상) - * - Knox 상신 실패 → DB에 실패 기록 남음 (추적 가능) - * - Knox 상신 성공 후 DB 업데이트 실패 위험 제거 - */ - - // 캐시 무효화 (결재 상신 시) - console.log(`[Approval Workflow] Revalidating cache after approval submission`); - const { revalidateApprovalLogs } = await import('./cache-utils'); - await revalidateApprovalLogs(); - - let pendingActionId: number | undefined; - - try { - // 3. Pending Action 먼저 생성 (Knox 상신 전) - console.log(`[Approval Workflow] Creating pending action for ${actionType}`); - const [pendingAction] = await db.insert(pendingActions).values({ - apInfId: submitRequest.apInfId, - actionType, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - actionPayload: actionPayload as any, - status: 'pending', - createdBy: approvalConfig.currentUser.id, - }).returning(); - - pendingActionId = pendingAction.id; - console.log(`[Approval Workflow] Pending action created: ${pendingActionId}`); - - // 4. Knox 결재 상신 시도 - console.log(`[Approval Workflow] Submitting to Knox: ${submitRequest.apInfId}`); - await submitApproval( - submitRequest, - { - userId: String(approvalConfig.currentUser.id), - epId: approvalConfig.currentUser.epId || '', - emailAddress: approvalConfig.currentUser.email || '', - } - ); - console.log(`[Approval Workflow] ✅ Knox submission successful: ${submitRequest.apInfId}`); - - // 5. 성공 시 정상 반환 - return { - pendingActionId, - approvalId: submitRequest.apInfId, - status: 'pending_approval' as const, - }; - - } catch (error) { - console.error(`[Approval Workflow] ❌ Error during approval workflow:`, error); - - // Knox 상신 실패 시 보상 트랜잭션 (Compensating Transaction) - // pendingAction의 상태를 'failed'로 업데이트 - if (typeof pendingActionId === 'number') { - try { - console.log(`[Approval Workflow] Marking pending action as failed: ${pendingActionId}`); - await db.update(pendingActions) - .set({ - status: 'failed', - errorMessage: error instanceof Error - ? `Knox 상신 실패: ${error.message}` - : 'Knox 상신 실패', - executedAt: new Date(), - }) - .where(eq(pendingActions.id, pendingActionId)); - - console.log(`[Approval Workflow] Pending action marked as failed: ${pendingActionId}`); - } catch (updateError) { - console.error(`[Approval Workflow] Failed to update pending action status:`, updateError); - // 상태 업데이트 실패는 로그만 남기고 원래 에러를 throw - } - } - - throw error; - } -} - -/** - * 결재가 승인된 액션을 실행 - * 폴링 서비스에서 호출됨 - * - * @param apInfId - Knox 결재 ID (approvalLogs의 primary key) - * @returns 액션 실행 결과 - */ -export async function executeApprovedAction(apInfId: string) { - // debug-utils import - const { debugLog, debugError, debugSuccess } = await import('@/lib/debug-utils'); - - debugLog('[executeApprovedAction] 시작', { apInfId }); - - try { - // 핸들러 자동 초기화 (폴링 서비스의 격리 문제 해결) - debugLog('[executeApprovedAction] 핸들러 초기화 중'); - await ensureHandlersInitialized(); - debugLog('[executeApprovedAction] 핸들러 초기화 완료'); - - // 1. apInfId로 pendingAction 조회 - debugLog('[executeApprovedAction] pendingAction 조회 중', { apInfId }); - const pendingAction = await db.query.pendingActions.findFirst({ - where: eq(pendingActions.apInfId, apInfId), - }); - - if (!pendingAction) { - debugLog('[executeApprovedAction] pendingAction 없음 (결재만 존재)', { apInfId }); - console.log(`[Approval Workflow] No pending action found for approval: ${apInfId}`); - return null; // 결재만 있고 실행할 액션이 없는 경우 - } - - debugLog('[executeApprovedAction] pendingAction 조회 완료', { - id: pendingAction.id, - actionType: pendingAction.actionType, - status: pendingAction.status, - createdBy: pendingAction.createdBy, - }); - - // 이미 실행되었거나 실패한 액션은 스킵 - if (['executed', 'failed'].includes(pendingAction.status)) { - debugLog('[executeApprovedAction] 이미 처리된 액션 스킵', { - apInfId, - status: pendingAction.status, - }); - console.log(`[Approval Workflow] Pending action already processed: ${apInfId} (${pendingAction.status})`); - return null; - } - - // 2. 등록된 핸들러 조회 - debugLog('[executeApprovedAction] 핸들러 조회 중', { - actionType: pendingAction.actionType, - }); - const handler = actionHandlers.get(pendingAction.actionType); - if (!handler) { - debugError('[executeApprovedAction] 핸들러를 찾을 수 없음', { - actionType: pendingAction.actionType, - availableHandlers: Array.from(actionHandlers.keys()), - }); - console.error('[Approval Workflow] Available handlers:', Array.from(actionHandlers.keys())); - throw new Error(`Handler not found for action type: ${pendingAction.actionType}`); - } - debugLog('[executeApprovedAction] 핸들러 조회 완료', { - actionType: pendingAction.actionType, - }); - - // 3. 실제 액션 실행 - debugLog('[executeApprovedAction] 핸들러 실행 시작', { - actionType: pendingAction.actionType, - apInfId, - payloadKeys: Object.keys(pendingAction.actionPayload || {}), - }); - console.log(`[Approval Workflow] Executing action: ${pendingAction.actionType} (${apInfId})`); - - const result = await handler(pendingAction.actionPayload); - - debugSuccess('[executeApprovedAction] 핸들러 실행 완료', { - actionType: pendingAction.actionType, - apInfId, - resultKeys: result ? Object.keys(result) : [], - }); - - // 4. 실행 완료 상태 업데이트 - debugLog('[executeApprovedAction] 상태 업데이트 중 (executed)'); - await db.update(pendingActions) - .set({ - status: 'executed', - executedAt: new Date(), - executionResult: result, - }) - .where(eq(pendingActions.apInfId, apInfId)); - debugLog('[executeApprovedAction] 상태 업데이트 완료 (executed)'); - - // 5. 캐시 무효화 (백그라운드에서도 동작) - debugLog('[executeApprovedAction] 캐시 무효화 중'); - const { revalidateApprovalLogs } = await import('./cache-utils'); - await revalidateApprovalLogs(); - debugLog('[executeApprovedAction] 캐시 무효화 완료'); - - debugSuccess('[executeApprovedAction] 전체 프로세스 완료', { - actionType: pendingAction.actionType, - apInfId, - }); - console.log(`[Approval Workflow] ✅ Successfully executed: ${pendingAction.actionType} (${apInfId})`); - return result; - - } catch (error) { - debugError('[executeApprovedAction] 실행 중 에러 발생', { - apInfId, - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - }); - console.error(`[Approval Workflow] ❌ Failed to execute action for ${apInfId}:`, error); - - // 실패 상태 업데이트 - try { - debugLog('[executeApprovedAction] 상태 업데이트 중 (failed)'); - await db.update(pendingActions) - .set({ - status: 'failed', - errorMessage: error instanceof Error ? error.message : String(error), - executedAt: new Date(), - }) - .where(eq(pendingActions.apInfId, apInfId)); - debugLog('[executeApprovedAction] 상태 업데이트 완료 (failed)'); - } catch (updateError) { - debugError('[executeApprovedAction] 상태 업데이트 실패', { - apInfId, - updateError: updateError instanceof Error ? updateError.message : String(updateError), - }); - } - - throw error; - } -} - -/** - * 결재 반려 시 처리 - * - * @param apInfId - Knox 결재 ID (approvalLogs의 primary key) - * @param reason - 반려 사유 - */ -export async function handleRejectedAction(apInfId: string, reason?: string) { - try { - const pendingAction = await db.query.pendingActions.findFirst({ - where: eq(pendingActions.apInfId, apInfId), - }); - - if (!pendingAction) { - console.log(`[Approval Workflow] No pending action found for rejected approval: ${apInfId}`); - return; // 결재만 있고 실행할 액션이 없는 경우 - } - - await db.update(pendingActions) - .set({ - status: 'rejected', - errorMessage: reason, - executedAt: new Date(), - }) - .where(eq(pendingActions.apInfId, apInfId)); - - // 캐시 무효화 (백그라운드에서도 동작) - console.log(`[Approval Workflow] Revalidating cache for rejected action: ${apInfId}`); - const { revalidateApprovalLogs } = await import('./cache-utils'); - await revalidateApprovalLogs(); - - // TODO: 요청자에게 알림 발송 등 추가 처리 - } catch (error) { - console.error(`[Approval Workflow] Failed to handle rejected action for approval ${apInfId}:`, error); - throw error; - } -} - diff --git a/lib/approval/index.ts b/lib/approval/index.ts index 82abac9a..943ada81 100644 --- a/lib/approval/index.ts +++ b/lib/approval/index.ts @@ -1,27 +1,41 @@ /** * 결재 워크플로우 모듈 Export * + * Saga 패턴을 사용한 결재 프로세스 관리 + * * 사용 방법: - * 1. registerActionHandler()로 액션 핸들러 등록 - * 2. withApproval()로 결재가 필요한 액션 래핑 - * 3. 폴링 서비스가 자동으로 상태 확인 및 실행 + * 1. registerActionHandler()로 액션 핸들러 등록 (instrumentation.ts에서 초기화) + * 2. ApprovalSubmissionSaga로 결재 상신 + * 3. 폴링 서비스가 자동으로 상태 확인 및 ApprovalExecutionSaga 실행 + * + * 주요 Saga 클래스: + * - ApprovalSubmissionSaga: 결재 상신 프로세스 (7단계) + * - ApprovalExecutionSaga: 결재 승인 후 액션 실행 (7단계) + * - ApprovalRejectionSaga: 결재 반려 처리 (4단계) */ +// 핸들러 레지스트리 export { registerActionHandler, getRegisteredHandlers, - withApproval, - executeApprovedAction, - handleRejectedAction, type ActionHandler, } from './approval-workflow'; +// Saga 클래스들 (주요 API) +export { + ApprovalSubmissionSaga, + ApprovalExecutionSaga, + ApprovalRejectionSaga, +} from './approval-saga'; + +// 폴링 서비스 export { startApprovalPollingScheduler, checkPendingApprovals, checkSingleApprovalStatus, } from './approval-polling-service'; +// 템플릿 유틸리티 export { getApprovalTemplateByName, replaceTemplateVariables, @@ -30,8 +44,10 @@ export { htmlDescriptionList, } from './template-utils'; +// 타입 정의 export type { TemplateVariables, ApprovalConfig, ApprovalResult } from './types'; +// 캐시 관리 export { revalidateApprovalCache, revalidateApprovalLogs, diff --git a/lib/vendor-investigation/approval-actions.ts b/lib/vendor-investigation/approval-actions.ts index e8e24ddc..74a25061 100644 --- a/lib/vendor-investigation/approval-actions.ts +++ b/lib/vendor-investigation/approval-actions.ts @@ -11,7 +11,7 @@ 'use server'; -import { withApproval } from '@/lib/approval/approval-workflow'; +import { ApprovalSubmissionSaga } from '@/lib/approval'; import { mapPQInvestigationToTemplateVariables } from './handlers'; import { debugLog, debugError, debugSuccess } from '@/lib/debug-utils'; @@ -87,9 +87,9 @@ export async function requestPQInvestigationWithApproval(data: { variableKeys: Object.keys(variables), }); - // 3. 결재 워크플로우 시작 - debugLog('[PQInvestigationApproval] withApproval 호출'); - const result = await withApproval( + // 3. 결재 워크플로우 시작 (Saga 패턴) + debugLog('[PQInvestigationApproval] ApprovalSubmissionSaga 생성'); + const saga = new ApprovalSubmissionSaga( // actionType: 핸들러를 찾을 때 사용할 키 'pq_investigation_request', @@ -114,6 +114,9 @@ export async function requestPQInvestigationWithApproval(data: { } ); + debugLog('[PQInvestigationApproval] Saga 실행 시작'); + const result = await saga.execute(); + debugSuccess('[PQInvestigationApproval] 결재 워크플로우 완료', { approvalId: result.approvalId, pendingActionId: result.pendingActionId, @@ -219,9 +222,9 @@ export async function reRequestPQInvestigationWithApproval(data: { variableKeys: Object.keys(variables), }); - // 4. 결재 워크플로우 시작 - debugLog('[PQReRequestApproval] withApproval 호출'); - const result = await withApproval( + // 4. 결재 워크플로우 시작 (Saga 패턴) + debugLog('[PQReRequestApproval] ApprovalSubmissionSaga 생성'); + const saga = new ApprovalSubmissionSaga( // actionType: 핸들러를 찾을 때 사용할 키 'pq_investigation_rerequest', @@ -242,6 +245,9 @@ export async function reRequestPQInvestigationWithApproval(data: { } ); + debugLog('[PQReRequestApproval] Saga 실행 시작'); + const result = await saga.execute(); + debugSuccess('[PQReRequestApproval] 재의뢰 결재 워크플로우 완료', { approvalId: result.approvalId, pendingActionId: result.pendingActionId, diff --git a/lib/vendor-regular-registrations/approval-actions.ts b/lib/vendor-regular-registrations/approval-actions.ts index 298591e9..b90ef2b6 100644 --- a/lib/vendor-regular-registrations/approval-actions.ts +++ b/lib/vendor-regular-registrations/approval-actions.ts @@ -7,7 +7,7 @@ 'use server'; -import { withApproval } from '@/lib/approval/approval-workflow'; +import { ApprovalSubmissionSaga } from '@/lib/approval'; import { mapRegistrationToTemplateVariables } from './handlers'; import { debugLog, debugError, debugSuccess } from '@/lib/debug-utils'; import type { RegistrationRequestData } from '@/components/vendor-regular-registrations/registration-request-dialog'; @@ -70,9 +70,9 @@ export async function registerVendorWithApproval(data: { variableKeys: Object.keys(variables), }); - // 2. 결재 워크플로우 시작 (템플릿 기반) - debugLog('[VendorRegistrationApproval] withApproval 호출'); - const result = await withApproval( + // 2. 결재 워크플로우 시작 (Saga 패턴) + debugLog('[VendorRegistrationApproval] ApprovalSubmissionSaga 생성'); + const saga = new ApprovalSubmissionSaga( // actionType: 핸들러를 찾을 때 사용할 키 'vendor_regular_registration', @@ -93,6 +93,9 @@ export async function registerVendorWithApproval(data: { } ); + debugLog('[VendorRegistrationApproval] Saga 실행 시작'); + const result = await saga.execute(); + // 3. 결재 상신 성공 시 상태를 pending_approval로 변경 if (result.status === 'pending_approval') { debugLog('[VendorRegistrationApproval] 상태를 pending_approval로 변경'); |
