# 결재 관리 시스템 (Approval Workflow) Knox 결재 시스템과 연동된 자동화된 결재 워크플로우 모듈입니다. ## 📋 목차 - [개요](#개요) - [아키텍처](#아키텍처) - [주요 구성 요소](#주요-구성-요소) - [동작 흐름](#동작-흐름) - [사용 방법](#사용-방법) - [파일 구조](#파일-구조) ## 개요 이 시스템은 다음과 같은 기능을 제공합니다: 1. **결재 요청 자동화** - 비즈니스 액션을 Knox 결재 시스템에 자동으로 상신 2. **템플릿 기반 결재 문서** - 재사용 가능한 HTML 템플릿으로 결재 문서 생성 3. **자동 실행** - 결재 승인 후 대기 중이던 액션 자동 실행 4. **상태 폴링** - 1분마다 자동으로 결재 상태 확인 및 처리 ## 아키텍처 ``` ┌─────────────────┐ │ 사용자 요청 │ │ (UI/API) │ └────────┬────────┘ │ ▼ ┌─────────────────────────────────────────┐ │ withApproval() - Saga Pattern │ │ 1. DB 저장 (pending_actions, pending) │ │ 2. Knox 결재 상신 │ │ 3. 실패 시 보상 트랜잭션 (→ failed) │ └────────┬────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────┐ │ Knox 결재 시스템 │ │ - 결재자들이 승인/반려 처리 │ └────────┬────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────┐ │ Polling Service (1분 간격) │ │ - checkPendingApprovals() │ │ - Knox API로 상태 일괄 확인 │ │ - 상태 변경 감지 │ └────────┬────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────┐ │ executeApprovedAction() │ │ - 등록된 핸들러 호출 │ │ - 실제 비즈니스 로직 실행 │ │ - pending_actions 상태 업데이트 │ └─────────────────────────────────────────┘ ``` ### 트랜잭션 관리 (Saga Pattern) Knox는 외부 시스템이므로 일반적인 DB 트랜잭션으로 롤백할 수 없습니다. 따라서 **Saga Pattern**을 적용하여 데이터 정합성을 보장합니다. #### 처리 순서 ```typescript // withApproval() 내부 try { // 1. DB에 먼저 pendingAction 저장 (롤백 가능) const [pendingAction] = await db.insert(pendingActions).values({ apInfId: submitRequest.apInfId, actionType, actionPayload, status: 'pending', // 대기 상태 }); // 2. Knox 결재 상신 (외부 API, 롤백 불가) await submitApproval(submitRequest, user); // 3. 성공 시 정상 반환 return { approvalId, status: 'pending_approval' }; } catch (error) { // 4. Knox 실패 시 보상 트랜잭션 (Compensating Transaction) await db.update(pendingActions) .set({ status: 'failed', errorMessage: '...', }); throw error; } ``` #### 장점 | 시나리오 | 동작 | 결과 | |---------|------|------| | DB 저장 실패 | Knox 상신 안 함 | ✅ 전체 실패 (정상) | | Knox 상신 실패 | DB를 'failed'로 업데이트 | ✅ 실패 추적 가능 | | 모두 성공 | 정상 진행 | ✅ 결재 대기 상태 | 이를 통해 "Knox는 성공했지만 DB는 실패" 같은 데이터 불일치 상황을 방지합니다. ## 주요 구성 요소 ### 1. **types.ts** - 타입 정의 ```typescript interface ApprovalConfig { title: string; // 결재 제목 description?: string; // 결재 설명 templateName: string; // 템플릿 이름 variables: Record; // 템플릿 변수 approvers?: string[]; // 결재자 EP ID 배열 currentUser: { id: number; epId: string | null; email?: string; }; } ``` ### 2. **approval-workflow.ts** - 결재 워크플로우 핵심 #### 주요 함수: - **`registerActionHandler(actionType, handler)`** - 액션 타입별 실행 핸들러 등록 - 앱 시작 시 한 번만 실행 - **`withApproval(actionType, payload, config)`** - 결재가 필요한 액션을 래핑 - Knox에 결재 상신 및 pending_actions에 저장 - 템플릿 기반 결재 문서 생성 - **`executeApprovedAction(apInfId)`** - 결재 승인 후 자동 실행 - 등록된 핸들러로 실제 비즈니스 로직 수행 - **`handleRejectedAction(apInfId, reason)`** - 결재 반려 처리 - pending_actions 상태 업데이트 ### 3. **approval-polling-service.ts** - 자동 상태 확인 #### 주요 함수: - **`startApprovalPollingScheduler()`** - 1분마다 자동 실행되는 스케줄러 시작 - `instrumentation.ts`에서 앱 시작 시 호출 - **`checkPendingApprovals()`** - 진행 중인 결재 상태 일괄 확인 - 상태 변경 시 자동으로 후속 처리 실행 - **`checkSingleApprovalStatus(apInfId)`** - 특정 결재 상태를 즉시 확인 (수동 트리거용) - UI의 "새로고침" 버튼에서 사용 ### 4. **template-utils.ts** - 템플릿 관리 #### 주요 함수: - **`getApprovalTemplateByName(name)`** - 템플릿 이름으로 조회 - `approval_templates` 테이블에서 가져옴 - **`replaceTemplateVariables(content, variables)`** - `{{변수명}}` 형태를 실제 값으로 치환 - **`htmlTableConverter(data, columns)`** - 배열 데이터를 HTML 테이블로 변환 - **`htmlDescriptionList(items)`** - 키-값 쌍을 HTML 정의 목록으로 변환 - **`htmlListConverter(items, ordered)`** - 배열을 HTML 리스트로 변환 ### 5. **handlers-registry.ts** - 핸들러 등록소 모든 결재 가능한 액션의 핸들러를 중앙에서 관리: ```typescript export async function initializeApprovalHandlers() { // PQ 실사 의뢰 registerActionHandler('pq_investigation_request', requestPQInvestigationInternal); // PQ 실사 재의뢰 registerActionHandler('pq_investigation_rerequest', reRequestPQInvestigationInternal); // 정규업체 등록 registerActionHandler('vendor_regular_registration', registerVendorInternal); // ... 추가 핸들러 } ``` ## 동작 흐름 ### 전체 프로세스 ``` 1. 앱 시작 └─> instrumentation.ts에서 initializeApprovalHandlers() 호출 └─> startApprovalPollingScheduler() 시작 2. 사용자가 결재 필요 액션 요청 └─> withApproval() 호출 └─> 템플릿 조회 및 변수 치환 └─> Knox 결재 상신 └─> pending_actions 테이블에 저장 (status: 'pending') 3. 결재자가 Knox에서 승인/반려 4. 폴링 서비스 (1분마다) └─> checkPendingApprovals() 실행 └─> Knox API로 상태 일괄 확인 └─> 상태 변경 감지 5. 승인된 경우 └─> executeApprovedAction() 호출 └─> 등록된 핸들러로 실제 비즈니스 로직 실행 └─> pending_actions 상태: 'executed' 6. 반려된 경우 └─> handleRejectedAction() 호출 └─> pending_actions 상태: 'rejected' ``` ### 결재 상태 코드 | 상태 코드 | 의미 | 폴링 대상 | 후속 처리 | |---------|-----|---------|---------| | -2 | 암호화중 | ✓ | - | | -1 | 예약상신 | ✓ | - | | 0 | 보류 | ✓ | - | | 1 | 진행중 | ✓ | - | | 2 | 완결 | - | executeApprovedAction() | | 3 | 반려 | - | handleRejectedAction() | | 4 | 상신취소 | - | handleRejectedAction() | | 5 | 전결 | - | executeApprovedAction() | | 6 | 후완결 | - | executeApprovedAction() | ## 사용 방법 ### 1단계: 핸들러 구현 및 등록 ```typescript // lib/my-feature/handlers.ts export async function myActionInternal(payload: MyPayload) { // 실제 비즈니스 로직 const result = await db.insert(myTable).values(payload); return result; } // lib/approval/handlers-registry.ts import { myActionInternal } from '@/lib/my-feature/handlers'; export async function initializeApprovalHandlers() { // ... 기존 핸들러들 registerActionHandler('my_action_type', myActionInternal); } ``` ### 2단계: 결재 템플릿 준비 데이터베이스 `approval_templates` 테이블에 템플릿 추가: ```sql INSERT INTO approval_templates (name, content) VALUES ( '나의 액션 결재', '

{{제목}}

{{설명}}

{{상세_테이블}}
' ); ``` ### 3단계: 서버 액션 작성 ```typescript // lib/my-feature/actions.ts 'use server'; import { withApproval } from '@/lib/approval'; import { htmlTableConverter, htmlDescriptionList } from '@/lib/approval/template-utils'; export async function myActionWithApproval(data: MyData) { // 1. 템플릿 변수 준비 (HTML 변환 포함) const detailTable = await htmlTableConverter( data.items, [ { key: 'name', label: '이름' }, { key: 'value', label: '값' } ] ); const variables = { '제목': data.title, '설명': data.description, '상세_테이블': detailTable, }; // 2. 결재 워크플로우 시작 const result = await withApproval( 'my_action_type', // 등록한 핸들러 타입 data, // 실행할 때 전달할 payload { title: `결재 요청 - ${data.title}`, templateName: '나의 액션 결재', variables, approvers: data.approvers, // 결재자 EP ID 배열 currentUser: data.currentUser, } ); return result; } ``` ### 4단계: UI에서 호출 ```tsx // components/my-feature/my-form.tsx 'use client'; export function MyForm() { const handleSubmit = async (formData) => { try { const result = await myActionWithApproval({ title: formData.title, description: formData.description, items: formData.items, approvers: selectedApprovers, currentUser: session.user, }); toast.success(`결재가 상신되었습니다. (ID: ${result.approvalId})`); router.push(`/approval/log`); } catch (error) { toast.error('결재 상신에 실패했습니다.'); } }; return
...
; } ``` ## 파일 구조 ``` lib/approval/ ├── index.ts # 모듈 export (진입점) ├── types.ts # 타입 정의 ├── approval-workflow.ts # 결재 워크플로우 핵심 로직 ├── approval-polling-service.ts # 자동 상태 확인 서비스 ├── template-utils.ts # 템플릿 관리 및 HTML 유틸리티 ├── handlers-registry.ts # 핸들러 중앙 등록소 ├── example-usage.ts # 사용 예시 └── README.md # 이 문서 ``` ## 데이터베이스 스키마 ### approval_logs (결재 로그) Knox에서 동기화된 결재 정보: ```typescript { apInfId: string; // Knox 결재 ID (PK) status: string; // 결재 상태 (-2~6) title: string; // 결재 제목 content: string; // 결재 내용 (HTML) // ... } ``` ### approval_templates (결재 템플릿) 재사용 가능한 결재 문서 템플릿: ```typescript { id: number; // 템플릿 ID (PK) name: string; // 템플릿 이름 (한국어) content: string; // HTML 템플릿 ({{변수}} 포함) // ... } ``` ### pending_actions (대기 액션) 결재 승인 후 실행될 액션 정보: ```typescript { id: number; // 액션 ID (PK) apInfId: string; // Knox 결재 ID (FK) actionType: string; // 핸들러 타입 actionPayload: jsonb; // 실행 데이터 status: string; // 'pending' | 'executed' | 'rejected' | 'failed' executedAt?: Date; // 실행 시간 executionResult?: jsonb; // 실행 결과 errorMessage?: string; // 에러 메시지 // ... } ``` ## 주의사항 ### 1. 핸들러 등록 시점 - 핸들러는 **앱 시작 시 한 번만** 등록되어야 합니다 - `instrumentation.ts`에서 `initializeApprovalHandlers()` 호출 - 핸들러 등록 누락 시 `withApproval()`에서 에러 발생 ### 2. 템플릿 변수 이름 - 템플릿에서 `{{변수명}}`으로 정의 - `variables` 객체의 키와 정확히 일치해야 함 - 치환되지 않은 변수는 콘솔 경고 출력 ### 3. 폴링 간격 - 기본 1분 간격으로 설정 - 실시간성이 중요한 경우 간격 조정 가능 - 하지만 Knox API 부하 고려 필요 ### 4. 트랜잭션 관리 (중요!) #### Saga Pattern 적용 Knox는 외부 시스템이므로 DB 트랜잭션으로 롤백할 수 없습니다. 따라서 다음 순서를 **반드시** 준수해야 합니다: ```typescript // ✅ 올바른 순서 1. DB에 pendingAction 생성 (status: 'pending') 2. Knox 결재 상신 시도 3-A. 성공 → pendingAction 유지 3-B. 실패 → pendingAction을 'failed'로 업데이트 (보상 트랜잭션) ``` ```typescript // ❌ 잘못된 순서 (데이터 불일치 발생) 1. Knox 결재 상신 2. DB 저장 ← 이 시점에 실패하면 Knox만 올라가고 DB에 기록 없음! ``` #### 실패 추적 모든 실패는 `pending_actions` 테이블에 기록됩니다: ```sql SELECT * FROM pending_actions WHERE status = 'failed' ORDER BY created_at DESC; ``` 관리자가 로그를 확인하고 수동으로 재처리할 수 있습니다. ### 5. 에러 처리 - `executeApprovedAction()` 실패 시 `pending_actions.status = 'failed'` - 에러 메시지는 `errorMessage` 필드에 저장 - 실패한 액션은 자동 재시도하지 않음 (수동 재처리 필요) - Knox 상신 실패는 보상 트랜잭션으로 'failed' 상태 기록 ### 6. EP ID 검증 결재 기능을 사용하려면 사용자에게 Knox EP ID가 **필수**입니다: ```typescript // UI에서 사전 검증 권장 if (!session?.user?.epId) { toast.error('Knox EP ID가 없어 결재 기능을 사용할 수 없습니다.'); return; } // 서버에서도 검증됨 await withApproval(...); // EP ID 없으면 에러 발생 ``` EP ID가 없는 사용자는 시스템 관리자에게 문의하여 등록해야 합니다. ## 확장 가능성 ### 새로운 결재 타입 추가 1. 핸들러 함수 작성 (`lib/[feature]/handlers.ts`) 2. `handlers-registry.ts`에 등록 3. 템플릿 생성 (DB `approval_templates`) 4. 서버 액션 작성 (withApproval 호출) 5. UI 연동 ### 알림 기능 추가 ```typescript // approval-workflow.ts의 executeApprovedAction() 내부 if (result) { // 요청자에게 승인 알림 await sendNotification(pendingAction.createdBy, '결재가 승인되었습니다'); } ``` ### 결재 히스토리 조회 ```typescript export async function getApprovalHistory(userId: number) { return await db.query.approvalLogs.findMany({ where: eq(approvalLogs.createdBy, userId), orderBy: desc(approvalLogs.createdAt), }); } ``` ## 디버깅 ### 등록된 핸들러 확인 ```typescript import { getRegisteredHandlers } from '@/lib/approval'; const handlers = getRegisteredHandlers(); console.log('Registered handlers:', handlers); ``` ### 특정 결재 상태 수동 확인 ```typescript import { checkSingleApprovalStatus } from '@/lib/approval'; const result = await checkSingleApprovalStatus('AP-2024-001'); console.log('Status check result:', result); ``` ### 폴링 로그 확인 폴링 서비스는 다음과 같은 로그를 출력합니다: ``` [Approval Polling] Starting approval status check... [Approval Polling] Found 5 pending approvals to check [Approval Polling] Status changed: AP-2024-001 (1 → 2) [Approval Polling] ✅ Executed approved action: AP-2024-001 [Approval Polling] Summary: { checked: 5, updated: 1, executed: 1 } ``` ### 트랜잭션 관리 로그 `withApproval()` 실행 시 다음과 같은 로그가 출력됩니다: **성공 케이스:** ``` [Approval Workflow] Creating pending action for pq_investigation_request [Approval Workflow] Pending action created: 123 [Approval Workflow] Submitting to Knox: AP-2024-001 [Approval Workflow] ✅ Knox submission successful: AP-2024-001 ``` **실패 케이스 (Knox 상신 실패):** ``` [Approval Workflow] Creating pending action for pq_investigation_request [Approval Workflow] Pending action created: 124 [Approval Workflow] Submitting to Knox: AP-2024-002 [Approval Workflow] ❌ Error during approval workflow: Knox API timeout [Approval Workflow] Marking pending action as failed: 124 [Approval Workflow] Pending action marked as failed: 124 ``` ### 실패한 결재 조회 ```sql -- 실패한 결재 목록 SELECT id, action_type, error_message, created_at FROM pending_actions WHERE status = 'failed' ORDER BY created_at DESC; -- 실패 사유별 통계 SELECT error_message, COUNT(*) as count FROM pending_actions WHERE status = 'failed' GROUP BY error_message ORDER BY count DESC; ``` ## 관련 파일 ### 핵심 파일 - `lib/approval/approval-workflow.ts` - Saga Pattern 트랜잭션 관리 구현 - `lib/approval/approval-polling-service.ts` - 결재 상태 자동 확인 - `lib/approval/handlers-registry.ts` - 핸들러 중앙 등록소 - `instrumentation.ts` - 앱 시작 시 핸들러 등록 및 폴링 시작 ### Knox 연동 - `lib/knox-api/approval/` - Knox API 통신 모듈 ### 데이터베이스 - `db/schema/knox/approvals.ts` - 결재 관련 스키마 - `db/schema/knox/pending-actions.ts` - 대기 액션 스키마 ### 예시 구현 - `lib/vendor-investigation/handlers.ts` - PQ 실사 핸들러 - `lib/vendor-investigation/approval-actions.ts` - PQ 실사 결재 서버 액션 - `lib/vendor-regular-registrations/handlers.ts` - 정규업체 등록 핸들러 - `lib/pq/pq-review-table-new/vendors-table-toolbar-actions.tsx` - UI 구현 예시 ### 문서 - `lib/approval/README.md` - 이 문서 - `lib/approval/ARCHITECTURE_REVIEW.md` - 아키텍처 상세 분석 - `lib/approval/USAGE_PATTERN_ANALYSIS.md` - 사용 패턴 분석 - `lib/approval/EP_ID_VALIDATION_FIX.md` - EP ID 검증 개선 ## 문의 문제 발생 시 다음을 확인하세요: ### 1. 핸들러 등록 확인 ```typescript const handlers = getRegisteredHandlers(); console.log('Registered handlers:', handlers); // 예상: ['pq_investigation_request', 'vendor_regular_registration', ...] ``` ### 2. 템플릿 존재 확인 ```typescript const template = await getApprovalTemplateByName('Vendor 실사의뢰'); console.log('Template found:', !!template); ``` ### 3. 폴링 서비스 실행 확인 ```bash # 로그에서 폴링 시작 메시지 확인 grep "Approval Polling" logs/app.log ``` ### 4. Knox API 연결 확인 - Knox 상신 에러가 발생하면 `pending_actions`에 'failed' 상태로 기록됨 - `error_message` 필드에서 상세 원인 확인 ### 5. EP ID 확인 ```sql -- EP ID가 없는 사용자 조회 SELECT id, name, email FROM users WHERE ep_id IS NULL; ``` ### 6. 실패한 액션 확인 ```sql -- 최근 실패한 액션 조회 SELECT id, action_type, status, error_message, created_at FROM pending_actions WHERE status IN ('failed', 'rejected') ORDER BY created_at DESC LIMIT 10; ``` ### 트러블슈팅 #### Knox 상신 성공했지만 DB에 없는 경우 → 불가능합니다. Saga Pattern으로 **DB 먼저 저장 후** Knox 상신하므로 이런 상황은 발생하지 않습니다. #### Knox 상신 실패 시 → `pending_actions`에 'failed' 상태로 기록됩니다. `error_message`에서 원인을 확인하고 수동으로 재처리하세요. #### 결재 승인했지만 실행 안 되는 경우 1. 폴링 서비스가 실행 중인지 확인 (로그) 2. `pending_actions` 테이블에 해당 apInfId가 있는지 확인 3. 핸들러가 등록되어 있는지 확인 (`getRegisteredHandlers()`) #### EP ID가 없어서 결재 못 하는 경우 → 시스템 관리자에게 문의하여 Knox EP ID를 부여받아야 합니다.