# 결재 관리 시스템 (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() | ## 🚀 결재 시스템 사용 방법 (완전 가이드) ### 📌 중요: Next.js 서버 액션과 핸들러 등록 #### Next.js 서버 액션의 격리 문제 Next.js의 `'use server'` 서버 액션은 **완전히 격리된 실행 컨텍스트**에서 동작합니다: ```typescript // instrumentation.ts (앱 시작 시) await initializeApprovalHandlers(); // → 메모리 컨텍스트 A에 핸들러 등록 // 서버 액션 실행 시 'use server' export async function myAction() { await withApproval(...); // → 메모리 컨텍스트 B (격리됨!) // → 컨텍스트 A의 핸들러를 볼 수 없음 ❌ } ``` #### 해결 방법: Lazy 초기화 이 시스템은 **Lazy 초기화**를 사용하여 자동으로 문제를 해결합니다: ```typescript // approval-workflow.ts async function ensureHandlersInitialized() { if (!handlersInitialized) { // 각 컨텍스트에서 처음 호출 시 자동 초기화 await initializeApprovalHandlers(); handlersInitialized = true; } } // withApproval() 호출 시 자동 실행 export async function withApproval(...) { await ensureHandlersInitialized(); // ✅ 자동으로 핸들러 등록 // ... 결재 로직 } ``` **즉, 개발자는 핸들러만 등록하면 나머지는 자동으로 처리됩니다!** ✨ ### 📋 전체 프로세스 개요 ``` 1. handlers-registry.ts에 핸들러 등록 (1회) ↓ 2. 각 기능별로 서버 액션 작성 ('use server') ↓ 3. 서버 액션 내에서 withApproval() 호출 ↓ 4. 자동으로 핸들러 Lazy 초기화됨 ✨ ↓ 5. Knox 결재 상신 + DB 저장 (Saga Pattern) ↓ 6. 폴링 서비스가 자동으로 승인 감지 및 실행 ``` ## 사용 방법 ### 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
...
; } ``` --- ## 💡 베스트 프랙티스 ### ✅ 권장 패턴 #### 1. 핸들러와 서버 액션 분리 ```typescript // ✅ 좋은 예: 핸들러는 Internal 함수로 분리 // lib/my-feature/handlers.ts export async function myActionInternal(payload: MyPayload) { // 실제 비즈니스 로직 (DB 조작 등) // 결재 승인 후 실행될 순수 로직 return await db.insert(myTable).values(payload); } // lib/my-feature/approval-actions.ts 'use server'; export async function myActionWithApproval(data: MyData) { // 결재 워크플로우 래핑 return await withApproval('my_action', data, config); } ``` ```typescript // ❌ 나쁜 예: 핸들러에 서버 액션 지시어 사용 // lib/my-feature/handlers.ts 'use server'; // ❌ 핸들러에는 불필요 export async function myActionInternal(payload: MyPayload) { return await db.insert(myTable).values(payload); } ``` #### 2. 템플릿 변수는 서버 액션에서 준비 ```typescript // ✅ 좋은 예: 서버 액션에서 템플릿 변수 매핑 // lib/my-feature/approval-actions.ts 'use server'; export async function myActionWithApproval(data: MyData) { // 1. 템플릿 변수 매핑 함수 분리 const variables = await mapToTemplateVariables(data); // 2. withApproval 호출 return await withApproval('my_action', data, { templateName: '나의 액션 결재', variables, ...config }); } // 변수 매핑 로직 분리 async function mapToTemplateVariables(data: MyData) { return { '제목': data.title, '설명': data.description, '상세_테이블': await htmlTableConverter(data.items, columns), }; } ``` #### 3. 파일 구조 컨벤션 ``` lib/my-feature/ ├── handlers.ts # 비즈니스 로직 (Internal 함수) │ └── myActionInternal() # 결재 승인 후 실행될 순수 로직 │ ├── approval-actions.ts # 결재 서버 액션 ('use server') │ └── myActionWithApproval() │ └── mapToTemplateVariables() │ ├── service.ts # 일반 서비스 함수 │ └── getMyData() │ └── validateMyData() │ └── types.ts # 타입 정의 └── MyPayload └── MyData ``` #### 4. 에러 처리 전략 ```typescript // ✅ 좋은 예: 단계별 에러 처리 'use server'; export async function myActionWithApproval(data: MyData) { try { // 1. 사전 검증 if (!data.currentUser.epId) { throw new Error('Knox EP ID가 필요합니다'); } // 2. 비즈니스 로직 검증 const validation = await validateData(data); if (!validation.success) { throw new Error(validation.error); } // 3. 템플릿 변수 준비 const variables = await mapToTemplateVariables(data); // 4. 결재 워크플로우 return await withApproval('my_action', data, { title: `결재 요청 - ${data.title}`, templateName: '나의 액션 결재', variables, approvers: data.approvers, currentUser: data.currentUser, }); } catch (error) { // 5. 에러 로깅 console.error('[MyAction] 결재 요청 실패:', error); // 6. 사용자 친화적 에러 메시지 if (error instanceof Error) { throw new Error(`결재 요청 중 오류 발생: ${error.message}`); } throw new Error('결재 요청 중 알 수 없는 오류가 발생했습니다'); } } ``` #### 5. UI 컴포넌트 패턴 ```tsx // ✅ 좋은 예: 결재 다이얼로그 패턴 'use client'; export function MyFeatureToolbar() { const [isDialogOpen, setIsDialogOpen] = useState(false); const [isApprovalDialogOpen, setIsApprovalDialogOpen] = useState(false); const [formData, setFormData] = useState(null); const [variables, setVariables] = useState>({}); // Step 1: 폼 데이터 입력 const handleFormSubmit = async (data: MyData) => { // 템플릿 변수 미리 생성 const vars = await mapToTemplateVariables(data); setVariables(vars); setFormData(data); // 폼 다이얼로그 닫고 결재선 선택 다이얼로그 열기 setIsDialogOpen(false); setIsApprovalDialogOpen(true); }; // Step 2: 결재선 선택 후 제출 const handleApprovalSubmit = async (approvers: ApprovalLineItem[]) => { if (!formData) return; const approverEpIds = approvers .filter(line => line.seq !== "0" && line.epId) .map(line => line.epId!); const result = await myActionWithApproval({ ...formData, approvers: approverEpIds, }); if (result.status === 'pending_approval') { toast.success('결재가 상신되었습니다'); window.location.reload(); } }; return ( <> {/* Step 1: 데이터 입력 */} {/* Step 2: 결재선 선택 */} ); } ``` ### ❌ 피해야 할 패턴 #### 1. 핸들러에서 Knox API 직접 호출 ```typescript // ❌ 나쁜 예: 핸들러에서 결재 로직 포함 export async function myActionInternal(payload: MyPayload) { // ❌ 핸들러는 순수 비즈니스 로직만! const approval = await submitApproval(...); return await db.insert(myTable).values(payload); } // ✅ 좋은 예: 핸들러는 순수 로직만 export async function myActionInternal(payload: MyPayload) { return await db.insert(myTable).values(payload); } ``` #### 2. 서버 액션에서 DB 조작 ```typescript // ❌ 나쁜 예: 서버 액션에 비즈니스 로직 포함 'use server'; export async function myActionWithApproval(data: MyData) { // ❌ DB 조작은 핸들러에서! await db.insert(myTable).values(data); return await withApproval(...); } // ✅ 좋은 예: 서버 액션은 결재 래핑만 'use server'; export async function myActionWithApproval(data: MyData) { // withApproval만 호출 // 실제 DB 조작은 핸들러(myActionInternal)에서 실행됨 return await withApproval('my_action', data, config); } ``` #### 3. 핸들러를 서버 액션으로 직접 export ```typescript // ❌ 나쁜 예: 핸들러를 서버 액션으로 노출 'use server'; export async function myAction(data: MyData) { // 이렇게 하면 결재 없이 바로 실행됨! return await db.insert(myTable).values(data); } // ✅ 좋은 예: 핸들러는 Internal로 분리 export async function myActionInternal(payload: MyPayload) { return await db.insert(myTable).values(payload); } // 별도 파일에서 서버 액션으로 래핑 'use server'; export async function myActionWithApproval(data: MyData) { return await withApproval('my_action', data, config); } ``` --- ## 🔧 실전 예제: PQ 실사 의뢰 ### 파일 구조 ``` lib/vendor-investigation/ ├── handlers.ts # 핸들러 │ ├── requestPQInvestigationInternal() │ ├── reRequestPQInvestigationInternal() │ └── mapPQInvestigationToTemplateVariables() │ ├── approval-actions.ts # 서버 액션 │ ├── requestPQInvestigationWithApproval() │ └── reRequestPQInvestigationWithApproval() │ └── service.ts # 일반 서비스 ├── getInvestigations() └── updateInvestigationStatus() ``` ### 1. 핸들러 (handlers.ts) ```typescript // lib/vendor-investigation/handlers.ts /** * PQ 실사 의뢰 핸들러 (Internal) * 결재 승인 후 실행될 순수 비즈니스 로직 */ export async function requestPQInvestigationInternal(payload: { pqSubmissionIds: number[]; qmManagerId: number; forecastedAt: Date; investigationAddress: string; investigationNotes?: string; }) { // 트랜잭션으로 실사 생성 return await db.transaction(async (tx) => { const investigations = []; for (const pqId of payload.pqSubmissionIds) { const [investigation] = await tx.insert(vendorInvestigations).values({ pqSubmissionId: pqId, qmManagerId: payload.qmManagerId, forecastedAt: payload.forecastedAt, investigationAddress: payload.investigationAddress, investigationNotes: payload.investigationNotes, investigationStatus: 'PLANNED', }).returning(); investigations.push(investigation); } return investigations; }); } /** * 템플릿 변수 매핑 함수 */ export async function mapPQInvestigationToTemplateVariables(data: { vendorNames: string; qmManagerName: string; qmManagerEmail?: string; forecastedAt: Date; investigationAddress: string; investigationNotes?: string; requestedAt: Date; }) { return { '협력사명': data.vendorNames, 'QM담당자': data.qmManagerName, 'QM담당자_이메일': data.qmManagerEmail || 'N/A', '예정일자': data.forecastedAt.toLocaleDateString('ko-KR'), '실사주소': data.investigationAddress, '실사비고': data.investigationNotes || '없음', '요청일시': data.requestedAt.toLocaleString('ko-KR'), }; } ``` ### 2. 서버 액션 (approval-actions.ts) ```typescript // lib/vendor-investigation/approval-actions.ts 'use server'; import { withApproval } from '@/lib/approval/approval-workflow'; import { mapPQInvestigationToTemplateVariables } from './handlers'; /** * PQ 실사 의뢰 결재 서버 액션 * UI에서 호출하는 함수 */ export async function requestPQInvestigationWithApproval(data: { pqSubmissionIds: number[]; vendorNames: string; qmManagerId: number; qmManagerName: string; qmManagerEmail?: string; forecastedAt: Date; investigationAddress: string; investigationNotes?: string; currentUser: { id: number; epId: string | null; email?: string }; approvers?: string[]; }) { // 1. 입력 검증 if (!data.currentUser.epId) { throw new Error('Knox EP ID가 필요합니다'); } if (data.pqSubmissionIds.length === 0) { throw new Error('실사 의뢰할 PQ를 선택해주세요'); } // 2. 템플릿 변수 매핑 const requestedAt = new Date(); const variables = await mapPQInvestigationToTemplateVariables({ vendorNames: data.vendorNames, qmManagerName: data.qmManagerName, qmManagerEmail: data.qmManagerEmail, forecastedAt: data.forecastedAt, investigationAddress: data.investigationAddress, investigationNotes: data.investigationNotes, requestedAt, }); // 3. 결재 워크플로우 시작 return await withApproval( 'pq_investigation_request', { pqSubmissionIds: data.pqSubmissionIds, qmManagerId: data.qmManagerId, forecastedAt: data.forecastedAt, investigationAddress: data.investigationAddress, investigationNotes: data.investigationNotes, }, { title: `Vendor 실사의뢰 - ${data.vendorNames}`, description: `${data.vendorNames}에 대한 실사 의뢰`, templateName: 'Vendor 실사의뢰', variables, approvers: data.approvers, currentUser: data.currentUser, } ); } ``` ### 3. 핸들러 등록 (handlers-registry.ts) ```typescript // lib/approval/handlers-registry.ts import { registerActionHandler } from './approval-workflow'; export async function initializeApprovalHandlers() { console.log('[Approval Handlers] Registering all handlers...'); // PQ 실사 의뢰 핸들러 등록 const { requestPQInvestigationInternal } = await import('@/lib/vendor-investigation/handlers'); registerActionHandler( 'pq_investigation_request', // actionType requestPQInvestigationInternal // handler ); console.log('[Approval Handlers] All handlers registered successfully'); } ``` ### 4. UI 컴포넌트 ```tsx // components/pq/request-investigation-button.tsx 'use client'; import { useState } from 'react'; import { Button } from '@/components/ui/button'; import { ApprovalPreviewDialog } from '@/components/approval/ApprovalPreviewDialog'; import { requestPQInvestigationWithApproval } from '@/lib/vendor-investigation/approval-actions'; import { mapPQInvestigationToTemplateVariables } from '@/lib/vendor-investigation/handlers'; export function RequestInvestigationButton({ selectedPQs, currentUser }) { const [isApprovalDialogOpen, setIsApprovalDialogOpen] = useState(false); const [variables, setVariables] = useState({}); const [formData, setFormData] = useState(null); const handleOpenDialog = async () => { // 폼 데이터 수집 (실제로는 별도 다이얼로그에서) const data = { pqSubmissionIds: selectedPQs.map(pq => pq.id), vendorNames: selectedPQs.map(pq => pq.vendorName).join(', '), qmManagerId: 123, qmManagerName: '홍길동', forecastedAt: new Date(), investigationAddress: '경기도 ...', }; // 템플릿 변수 미리 생성 const vars = await mapPQInvestigationToTemplateVariables({ ...data, requestedAt: new Date(), }); setVariables(vars); setFormData(data); setIsApprovalDialogOpen(true); }; const handleApprovalSubmit = async (approvers) => { const approverEpIds = approvers .filter(line => line.seq !== "0" && line.epId) .map(line => line.epId); const result = await requestPQInvestigationWithApproval({ ...formData, currentUser, approvers: approverEpIds, }); if (result.status === 'pending_approval') { toast.success('실사 의뢰가 상신되었습니다'); window.location.reload(); } }; return ( <> ); } ``` --- ## ✅ 새로운 결재 기능 추가 체크리스트 새로운 결재 기능을 추가할 때 다음 순서로 작업하세요: ### 1. 핸들러 작성 (lib/[feature]/handlers.ts) - [ ] `[actionName]Internal()` 함수 작성 - 결재 승인 후 실행될 순수 비즈니스 로직 - DB 트랜잭션 처리 포함 - `'use server'` 지시어 **사용하지 않음** - [ ] `mapToTemplateVariables()` 함수 작성 (선택적) - 템플릿 변수 매핑 로직 - HTML 변환 함수 활용 (htmlTableConverter 등) ```typescript // ✅ 체크포인트 export async function myActionInternal(payload: MyPayload) { // 순수 비즈니스 로직만 포함 return await db.insert(...).values(payload); } export async function mapToTemplateVariables(data: MyData) { return { '변수1': data.field1, '변수2': data.field2, }; } ``` ### 2. 서버 액션 작성 (lib/[feature]/approval-actions.ts) - [ ] 파일 상단에 `'use server'` 지시어 추가 - [ ] `[actionName]WithApproval()` 함수 작성 - 입력 검증 (EP ID 등) - 템플릿 변수 준비 - `withApproval()` 호출 ```typescript // ✅ 체크포인트 'use server'; export async function myActionWithApproval(data: MyData) { // 1. 검증 if (!data.currentUser.epId) { throw new Error('Knox EP ID가 필요합니다'); } // 2. 템플릿 변수 const variables = await mapToTemplateVariables(data); // 3. 결재 워크플로우 return await withApproval('my_action_type', payload, config); } ``` ### 3. 핸들러 등록 (lib/approval/handlers-registry.ts) - [ ] `initializeApprovalHandlers()` 함수 내에 핸들러 등록 추가 ```typescript // ✅ 체크포인트 export async function initializeApprovalHandlers() { // ... 기존 핸들러들 const { myActionInternal } = await import('@/lib/my-feature/handlers'); registerActionHandler('my_action_type', myActionInternal); // ... 추가 핸들러들 } ``` ### 4. 결재 템플릿 생성 (DB) - [ ] `approval_templates` 테이블에 템플릿 추가 - `name`: 한국어 템플릿명 - `content`: HTML 템플릿 ({{변수}} 포함) ```sql -- ✅ 체크포인트 INSERT INTO approval_templates (name, content, created_at) VALUES ( '나의 액션 결재', '

{{제목}}

{{설명}}

{{상세_테이블}}
', NOW() ); ``` ### 5. UI 컴포넌트 작성 - [ ] 데이터 입력 다이얼로그 (선택적) - [ ] 결재선 선택 다이얼로그 연동 - `ApprovalPreviewDialog` 컴포넌트 사용 - 템플릿 변수 미리 준비 - 결재자 EP ID 추출 ```tsx // ✅ 체크포인트 const handleApprovalSubmit = async (approvers) => { const approverEpIds = approvers .filter(line => line.seq !== "0" && line.epId) .map(line => line.epId); const result = await myActionWithApproval({ ...formData, approvers: approverEpIds, }); if (result.status === 'pending_approval') { toast.success('결재가 상신되었습니다'); } }; ``` ### 6. 테스트 - [ ] 로컬 환경에서 결재 상신 테스트 - 핸들러 자동 초기화 확인 (`[Approval Workflow] Lazy initializing handlers...` 로그) - Knox 결재 상신 성공 확인 - `pending_actions` 테이블에 레코드 생성 확인 - [ ] Knox에서 결재 승인/반려 테스트 - 폴링 서비스가 상태 변경 감지 확인 - 승인 시 핸들러 실행 및 DB 업데이트 확인 - 반려 시 상태 업데이트 확인 - [ ] 에러 케이스 테스트 - EP ID 없는 사용자 - Knox API 실패 시 보상 트랜잭션 - 핸들러 실행 실패 시 에러 처리 ### 7. 문서화 (선택적) - [ ] 코드에 JSDoc 주석 추가 - [ ] 팀 위키 또는 문서에 사용 방법 기록 --- ## 🎓 핵심 개념 요약 ### Lazy 초기화의 작동 원리 ```typescript // 각 실행 컨텍스트별로 독립적으로 동작 ┌─────────────────────────────────────┐ │ 서버 액션 호출 (컨텍스트 A) │ │ → ensureHandlersInitialized() │ │ → 처음 호출: 핸들러 등록 ✅ │ │ → 이후 호출: 스킵 (이미 등록됨) │ └─────────────────────────────────────┘ ┌─────────────────────────────────────┐ │ 폴링 서비스 (컨텍스트 B) │ │ → ensureHandlersInitialized() │ │ → 처음 호출: 핸들러 등록 ✅ │ │ → 이후 호출: 스킵 (이미 등록됨) │ └─────────────────────────────────────┘ ``` **핵심**: 각 컨텍스트는 독립적이지만, 같은 코드를 통해 핸들러를 등록하므로 일관성 보장! ### Saga Pattern (트랜잭션 관리) ```typescript // Knox는 외부 시스템이므로 일반 DB 트랜잭션으로 롤백 불가 // 따라서 다음 순서로 처리: 1. DB에 pending_actions 저장 (status: 'pending') ↓ 2. Knox 결재 상신 시도 ↓ 3-A. 성공 → 상태 유지 3-B. 실패 → 보상 트랜잭션 (status: 'failed') ``` 이를 통해 **Knox만 올라가고 DB는 없는** 불일치 상황을 방지합니다. ### 핸들러 vs 서버 액션 | 구분 | 핸들러 (Internal) | 서버 액션 (WithApproval) | |------|-------------------|-------------------------| | **역할** | 순수 비즈니스 로직 | 결재 워크플로우 래핑 | | **실행 시점** | 결재 승인 후 | 사용자 요청 시 | | **'use server'** | ❌ 사용 안 함 | ✅ 반드시 사용 | | **DB 조작** | ✅ 허용 | ❌ 비권장 | | **결재 로직** | ❌ 포함 안 함 | ✅ withApproval() 호출 | | **예시** | `myActionInternal()` | `myActionWithApproval()` | ### 파일 구조 컨벤션 ``` lib/my-feature/ ├── handlers.ts # Internal 함수 (결재 승인 후 실행) ├── approval-actions.ts # 서버 액션 ('use server') ├── service.ts # 일반 서비스 함수 └── types.ts # 타입 정의 lib/approval/ └── handlers-registry.ts # 모든 핸들러 등록 (중앙 관리) ``` --- ## 파일 구조 ``` 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를 부여받아야 합니다.