summaryrefslogtreecommitdiff
path: root/lib/approval/approval-workflow.ts
diff options
context:
space:
mode:
Diffstat (limited to 'lib/approval/approval-workflow.ts')
-rw-r--r--lib/approval/approval-workflow.ts272
1 files changed, 272 insertions, 0 deletions
diff --git a/lib/approval/approval-workflow.ts b/lib/approval/approval-workflow.ts
new file mode 100644
index 00000000..cc8914f9
--- /dev/null
+++ b/lib/approval/approval-workflow.ts
@@ -0,0 +1,272 @@
+/**
+ * 결재 워크플로우 관리 모듈
+ *
+ * 주요 기능:
+ * 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<any>;
+
+/**
+ * 액션 타입별 핸들러 저장소
+ * registerActionHandler()로 등록된 핸들러들이 여기 저장됨
+ */
+const actionHandlers = new Map<string, ActionHandler>();
+
+/**
+ * 특정 액션 타입에 대한 핸들러 등록
+ *
+ * @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<T>(
+ actionType: string,
+ actionPayload: T,
+ approvalConfig: ApprovalConfig
+) {
+ // 핸들러가 등록되어 있는지 확인
+ if (!actionHandlers.has(actionType)) {
+ throw new Error(`No handler registered for action type: ${actionType}`);
+ }
+
+ try {
+ // 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 템플릿 사용
+ }
+ );
+
+ // Knox 결재 상신
+ await submitApproval(
+ submitRequest,
+ {
+ userId: String(approvalConfig.currentUser.id),
+ epId: approvalConfig.currentUser.epId || '',
+ emailAddress: approvalConfig.currentUser.email || '',
+ }
+ );
+
+ // 3. Pending Action 생성 (approvalLog의 apInfId로 연결)
+ // Knox에서 apInfId를 반환하지 않고, 요청 시 생성한 apInfId를 사용
+ 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();
+
+ return {
+ pendingActionId: pendingAction.id,
+ approvalId: submitRequest.apInfId,
+ status: 'pending_approval',
+ };
+
+ } catch (error) {
+ console.error('Failed to create approval workflow:', error);
+ 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;
+ }
+}
+