/** * 결재 워크플로우 관리 모듈 * * 주요 기능: * 1. 결재가 필요한 액션을 pending 상태로 저장 * 2. Knox 결재 시스템에 상신 * 3. 결재 완료 시 저장된 액션 실행 * * 흐름: * withApproval() → Knox 상신 → [폴링으로 상태 감지] → executeApprovedAction() */ 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를 받아서 실제 비즈니스 로직을 수행하는 함수 */ // eslint-disable-next-line @typescript-eslint/no-explicit-any export type ActionHandler = (payload: any) => Promise; /** * 액션 타입별 핸들러 저장소 * registerActionHandler()로 등록된 핸들러들이 여기 저장됨 */ const actionHandlers = new Map(); /** * 특정 액션 타입에 대한 핸들러 등록 * * @example * registerActionHandler('vendor_investigation_request', async (payload) => { * return await createInvestigation(payload); * }); */ export function registerActionHandler(actionType: string, handler: ActionHandler) { actionHandlers.set(actionType, handler); } /** * 등록된 핸들러 조회 (디버깅/테스트용) */ export function getRegisteredHandlers() { return Array.from(actionHandlers.keys()); } /** * 결재가 필요한 액션을 래핑하는 공통 함수 * * 사용법: * ```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( actionType: string, actionPayload: T, approvalConfig: ApprovalConfig ) { // 핸들러가 등록되어 있는지 확인 if (!actionHandlers.has(actionType)) { 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 업데이트 실패 위험 제거 */ 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) { try { // 1. apInfId로 pendingAction 조회 const pendingAction = await db.query.pendingActions.findFirst({ where: eq(pendingActions.apInfId, apInfId), }); if (!pendingAction) { console.log(`[Approval Workflow] No pending action found for approval: ${apInfId}`); return null; // 결재만 있고 실행할 액션이 없는 경우 } // 이미 실행되었거나 실패한 액션은 스킵 if (['executed', 'failed'].includes(pendingAction.status)) { console.log(`[Approval Workflow] Pending action already processed: ${apInfId} (${pendingAction.status})`); return null; } // 2. 등록된 핸들러 조회 const handler = actionHandlers.get(pendingAction.actionType); if (!handler) { throw new Error(`Handler not found for action type: ${pendingAction.actionType}`); } // 3. 실제 액션 실행 console.log(`[Approval Workflow] Executing action: ${pendingAction.actionType} (${apInfId})`); const result = await handler(pendingAction.actionPayload); // 4. 실행 완료 상태 업데이트 await db.update(pendingActions) .set({ status: 'executed', executedAt: new Date(), executionResult: result, }) .where(eq(pendingActions.apInfId, apInfId)); console.log(`[Approval Workflow] ✅ Successfully executed: ${pendingAction.actionType} (${apInfId})`); return result; } catch (error) { console.error(`[Approval Workflow] ❌ Failed to execute action for ${apInfId}:`, error); // 실패 상태 업데이트 await db.update(pendingActions) .set({ status: 'failed', errorMessage: error instanceof Error ? error.message : String(error), executedAt: new Date(), }) .where(eq(pendingActions.apInfId, apInfId)); 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)); // TODO: 요청자에게 알림 발송 등 추가 처리 } catch (error) { console.error(`[Approval Workflow] Failed to handle rejected action for approval ${apInfId}:`, error); throw error; } }