summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-11-05 19:56:29 +0900
committerjoonhoekim <26rote@gmail.com>2025-11-05 19:56:29 +0900
commit551129656039aae409b3af51ce4acbb59f60229f (patch)
treee041f542cd46920086e84d0071c9e0a76c4b8699
parente890fbae0c9c273b825ac808aa516de1f87fb218 (diff)
(김준회) approval: Saga Pattern 클래스로 리팩터링, 명시적으로 개선, 기존 사용 함수 리팩터링
-rw-r--r--lib/approval/approval-polling-service.ts24
-rw-r--r--lib/approval/approval-saga.ts623
-rw-r--r--lib/approval/approval-workflow.ts375
-rw-r--r--lib/approval/index.ts28
-rw-r--r--lib/vendor-investigation/approval-actions.ts20
-rw-r--r--lib/vendor-regular-registrations/approval-actions.ts11
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로 변경');