From b191144ec07c2f7eb9ad33ea6f2d7e31b6e00fce Mon Sep 17 00:00:00 2001 From: joonhoekim <26rote@gmail.com> Date: Wed, 5 Nov 2025 20:15:42 +0900 Subject: (김준회) 결재 패턴 README.md 정리 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/approval/README.md | 1845 +++++++++++++++++------------------------------- 1 file changed, 656 insertions(+), 1189 deletions(-) (limited to 'lib') diff --git a/lib/approval/README.md b/lib/approval/README.md index bc6a59d3..40f783c9 100644 --- a/lib/approval/README.md +++ b/lib/approval/README.md @@ -1,414 +1,395 @@ -# 결재 관리 시스템 (Approval Workflow) +# 결재 워크플로우 시스템 (Approval Workflow) -Knox 결재 시스템과 연동된 자동화된 결재 워크플로우 모듈입니다. +Knox 결재 시스템과 연동된 **Saga 패턴 기반** 자동화 결재 워크플로우 모듈입니다. -## 📋 목차 - -- [개요](#개요) -- [아키텍처](#아키텍처) -- [주요 구성 요소](#주요-구성-요소) -- [동작 흐름](#동작-흐름) -- [사용 방법](#사용-방법) -- [파일 구조](#파일-구조) - -## 개요 - -이 시스템은 다음과 같은 기능을 제공합니다: +> **최신 업데이트:** Saga Orchestrator 패턴으로 전면 리팩터링 완료 (2024-11) +> - 기존 래퍼 함수 제거, Saga 클래스로 완전 전환 +> - 비즈니스 프로세스가 명시적으로 표현됨 +> - 상세 내용: [SAGA_PATTERN.md](./SAGA_PATTERN.md) -1. **결재 요청 자동화** - 비즈니스 액션을 Knox 결재 시스템에 자동으로 상신 -2. **템플릿 기반 결재 문서** - 재사용 가능한 HTML 템플릿으로 결재 문서 생성 -3. **자동 실행** - 결재 승인 후 대기 중이던 액션 자동 실행 -4. **상태 폴링** - 1분마다 자동으로 결재 상태 확인 및 처리 +## 📋 목차 -## 아키텍처 +- [빠른 시작](#-빠른-시작) +- [Saga 패턴 아키텍처](#-saga-패턴-아키텍처) +- [주요 기능](#-주요-기능) +- [사용 방법](#-사용-방법) +- [API 레퍼런스](#-api-레퍼런스) +- [파일 구조](#-파일-구조) +- [개발 가이드](#-개발-가이드) +- [트러블슈팅](#-트러블슈팅) -``` -┌─────────────────┐ -│ 사용자 요청 │ -│ (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**을 적용하여 데이터 정합성을 보장합니다. +## 🚀 빠른 시작 -#### 처리 순서 +### 1. 핸들러 등록 (앱 초기화 시 1회) ```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; +// instrumentation.ts +import { registerActionHandler } from '@/lib/approval'; + +export async function register() { + const { initializeApprovalHandlers } = await import('@/lib/approval/handlers-registry'); + await initializeApprovalHandlers(); } ``` -#### 장점 +### 2. 결재 상신 -| 시나리오 | 동작 | 결과 | -|---------|------|------| -| DB 저장 실패 | Knox 상신 안 함 | ✅ 전체 실패 (정상) | -| Knox 상신 실패 | DB를 'failed'로 업데이트 | ✅ 실패 추적 가능 | -| 모두 성공 | 정상 진행 | ✅ 결재 대기 상태 | +```typescript +'use server'; -이를 통해 "Knox는 성공했지만 DB는 실패" 같은 데이터 불일치 상황을 방지합니다. +import { ApprovalSubmissionSaga } from '@/lib/approval'; -## 주요 구성 요소 +export async function requestWithApproval(data: RequestData) { + // 1. 템플릿 변수 준비 + const variables = await mapToTemplateVariables(data); -### 1. **types.ts** - 타입 정의 + // 2. Saga로 결재 상신 + const saga = new ApprovalSubmissionSaga( + 'my_action_type', // 핸들러 등록 시 사용한 키 + { id: data.id }, // 결재 승인 후 실행될 payload + { + title: '결재 요청', + templateName: '결재 템플릿', + variables, + approvers: ['EP001', 'EP002'], + currentUser: { + id: user.id, + epId: user.epId, + email: user.email, + } + } + ); -```typescript -interface ApprovalConfig { - title: string; // 결재 제목 - description?: string; // 결재 설명 - templateName: string; // 템플릿 이름 - variables: Record; // 템플릿 변수 - approvers?: string[]; // 결재자 EP ID 배열 - currentUser: { - id: number; - epId: string | null; - email?: string; - }; + return await saga.execute(); + // → { pendingActionId: 1, approvalId: 'AP-2024-001', status: 'pending_approval' } } ``` -### 2. **approval-workflow.ts** - 결재 워크플로우 핵심 - -#### 주요 함수: - -- **`registerActionHandler(actionType, handler)`** - - 액션 타입별 실행 핸들러 등록 - - 앱 시작 시 한 번만 실행 - -- **`withApproval(actionType, payload, config)`** - - 결재가 필요한 액션을 래핑 - - Knox에 결재 상신 및 pending_actions에 저장 - - 템플릿 기반 결재 문서 생성 - -- **`executeApprovedAction(apInfId)`** - - 결재 승인 후 자동 실행 - - 등록된 핸들러로 실제 비즈니스 로직 수행 - -- **`handleRejectedAction(apInfId, reason)`** - - 결재 반려 처리 - - pending_actions 상태 업데이트 +### 3. 자동 실행 (폴링 서비스가 처리) -### 3. **approval-polling-service.ts** - 자동 상태 확인 +결재 승인 시 등록된 핸들러가 자동으로 실행됩니다. 별도 작업 불필요! -#### 주요 함수: +--- -- **`startApprovalPollingScheduler()`** - - 1분마다 자동 실행되는 스케줄러 시작 - - `instrumentation.ts`에서 앱 시작 시 호출 +## 🏗️ Saga 패턴 아키텍처 + +### 전체 흐름 + +``` +┌─────────────────────────────────────────────────────────┐ +│ 1. 결재 상신 (UI) │ +│ ApprovalSubmissionSaga.execute() │ +└──────────────────────┬──────────────────────────────────┘ + │ + ▼ + ┌─────────────────────────────────────┐ + │ 7단계 Orchestration: │ + │ 1. 핸들러 초기화 │ + │ 2. 템플릿 준비 │ + │ 3. 결재선 생성 │ + │ 4. 결재 요청 생성 │ + │ 5. ⚠️ DB 저장 (Saga 핵심) │ + │ 6. Knox 상신 │ + │ 7. 캐시 무효화 │ + └──────────────┬──────────────────────┘ + │ + 성공 ▼ ▼ 실패 + ┌──────────┐ ┌─────────────┐ + │ 반환 │ │ 보상 트랜잭션 │ + └──────────┘ │ status→failed│ + └─────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ 2. Knox 결재 시스템 │ +│ (결재자들이 승인/반려 처리) │ +└──────────────────────┬──────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ 3. 폴링 서비스 (1분마다 자동 실행) │ +│ checkPendingApprovals() │ +│ - Knox API로 상태 일괄 조회 (최대 1000건) │ +│ - 상태 변경 감지 │ +└──────────────────────┬──────────────────────────────────┘ + │ + 승인 시 ▼ +┌─────────────────────────────────────────────────────────┐ +│ 4. 액션 자동 실행 (백그라운드) │ +│ ApprovalExecutionSaga.execute() │ +│ - 등록된 핸들러 호출 │ +│ - 비즈니스 로직 실행 │ +│ - 상태 업데이트 (executed) │ +└─────────────────────────────────────────────────────────┘ +``` + +### Saga 패턴의 핵심: 분산 트랜잭션 관리 + +Knox는 외부 시스템이므로 DB 트랜잭션으로 롤백할 수 없습니다. +**Saga 패턴**으로 데이터 정합성을 보장합니다. + +```typescript +// ApprovalSubmissionSaga 내부 +async execute() { + try { + // 1~4단계: 준비 작업 + await this.initializeHandlers(); + await this.prepareApprovalTemplate(); + await this.createApprovalLines(); + await this.createSubmitRequest(); + + // 5단계: ⚠️ DB에 먼저 저장 (Knox 상신 전) + await this.savePendingAction(); + + // 6단계: Knox 상신 (외부 API, 롤백 불가) + await this.submitToKnox(); + + // 7단계: 캐시 무효화 + await this.invalidateCache(); + + return { pendingActionId, approvalId, status: 'pending_approval' }; + + } catch (error) { + // 실패 시 보상 트랜잭션 (Compensating Transaction) + await this.compensate(error); + throw error; + } +} +``` -- **`checkPendingApprovals()`** - - 진행 중인 결재 상태 일괄 확인 - - 상태 변경 시 자동으로 후속 처리 실행 +**장점:** +- Knox 상신 전 DB 저장 실패 → 전체 실패 (정상) +- Knox 상신 실패 → DB에 실패 기록 남음 (추적 가능) +- Knox 상신 성공 후 DB 업데이트 실패 위험 제거 -- **`checkSingleApprovalStatus(apInfId)`** - - 특정 결재 상태를 즉시 확인 (수동 트리거용) - - UI의 "새로고침" 버튼에서 사용 +--- -### 4. **template-utils.ts** - 템플릿 관리 +## 🎯 주요 기능 -#### 주요 함수: +### 1. Saga Orchestrator (핵심) -- **`getApprovalTemplateByName(name)`** - - 템플릿 이름으로 조회 - - `approval_templates` 테이블에서 가져옴 +세 가지 Saga 클래스로 결재 프로세스를 명시적으로 관리: -- **`replaceTemplateVariables(content, variables)`** - - `{{변수명}}` 형태를 실제 값으로 치환 +| Saga 클래스 | 역할 | 단계 수 | +|------------|------|--------| +| `ApprovalSubmissionSaga` | 결재 상신 | 7단계 | +| `ApprovalExecutionSaga` | 결재 승인 후 액션 실행 | 7단계 | +| `ApprovalRejectionSaga` | 결재 반려 처리 | 4단계 | -- **`htmlTableConverter(data, columns)`** - - 배열 데이터를 HTML 테이블로 변환 +### 2. 템플릿 시스템 -- **`htmlDescriptionList(items)`** - - 키-값 쌍을 HTML 정의 목록으로 변환 +DB에 저장된 HTML 템플릿으로 결재 문서 생성: -- **`htmlListConverter(items, ordered)`** - - 배열을 HTML 리스트로 변환 +```typescript +// 템플릿에 변수 삽입 +const variables = { + '업체명': 'ABC 협력업체', + '담당자': '홍길동', + '요청일': '2024-11-05' +}; -### 5. **handlers-registry.ts** - 핸들러 등록소 +const saga = new ApprovalSubmissionSaga( + 'vendor_registration', + payload, + { + templateName: '정규업체 등록', // DB에서 조회 + variables, // 변수 치환 + // ... + } +); +``` -모든 결재 가능한 액션의 핸들러를 중앙에서 관리: +**HTML 변환 유틸리티:** ```typescript -export async function initializeApprovalHandlers() { - // PQ 실사 의뢰 - registerActionHandler('pq_investigation_request', - requestPQInvestigationInternal); - - // PQ 실사 재의뢰 - registerActionHandler('pq_investigation_rerequest', - reRequestPQInvestigationInternal); - - // 정규업체 등록 - registerActionHandler('vendor_regular_registration', - registerVendorInternal); - - // ... 추가 핸들러 -} -``` +import { + htmlTableConverter, + htmlListConverter, + htmlDescriptionList +} from '@/lib/approval'; -## 동작 흐름 +// 테이블 생성 +const table = await htmlTableConverter(data, [ + { key: 'name', label: '이름' }, + { key: 'email', label: '이메일' } +]); -### 전체 프로세스 +// 리스트 생성 +const list = await htmlListConverter(['항목1', '항목2']); +// Description List +const dl = await htmlDescriptionList({ + '제목': '내용', + '담당자': '홍길동' +}); ``` -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() | +### 3. 자동 폴링 서비스 -## 🚀 결재 시스템 사용 방법 (완전 가이드) - -### 📌 중요: Next.js 서버 액션과 핸들러 등록 - -#### Next.js 서버 액션의 격리 문제 - -Next.js의 `'use server'` 서버 액션은 **완전히 격리된 실행 컨텍스트**에서 동작합니다: +1분마다 Knox API를 호출하여 결재 상태 자동 확인: ```typescript -// instrumentation.ts (앱 시작 시) -await initializeApprovalHandlers(); -// → 메모리 컨텍스트 A에 핸들러 등록 - -// 서버 액션 실행 시 -'use server' -export async function myAction() { - await withApproval(...); - // → 메모리 컨텍스트 B (격리됨!) - // → 컨텍스트 A의 핸들러를 볼 수 없음 ❌ +// instrumentation.ts +import { startApprovalPollingScheduler } from '@/lib/approval'; + +export async function register() { + // 1분마다 자동 실행 + await startApprovalPollingScheduler(); } ``` -#### 해결 방법: Lazy 초기화 +**배치 처리 최적화:** +- 최대 1000건씩 일괄 조회 +- Knox API 부하 최소화 +- 에러 격리 (일부 실패해도 계속 진행) + +### 4. 캐시 관리 -이 시스템은 **Lazy 초기화**를 사용하여 자동으로 문제를 해결합니다: +Next.js의 `revalidatePath`를 활용한 실시간 UI 업데이트: ```typescript -// approval-workflow.ts -async function ensureHandlersInitialized() { - if (!handlersInitialized) { - // 각 컨텍스트에서 처음 호출 시 자동 초기화 - await initializeApprovalHandlers(); - handlersInitialized = true; - } -} +import { revalidateApprovalLogs } from '@/lib/approval'; -// withApproval() 호출 시 자동 실행 -export async function withApproval(...) { - await ensureHandlersInitialized(); // ✅ 자동으로 핸들러 등록 - // ... 결재 로직 -} +// 결재 상신/승인/반려 시 자동 호출됨 +await revalidateApprovalLogs(); ``` -**즉, 개발자는 핸들러만 등록하면 나머지는 자동으로 처리됩니다!** ✨ +**자동 무효화 시점:** +- ✅ 결재 상신 시 +- ✅ 결재 승인/반려 시 +- ✅ 액션 실행 완료 시 -### 📋 전체 프로세스 개요 +자세한 내용: [README_CACHE.md](./README_CACHE.md) -``` -1. handlers-registry.ts에 핸들러 등록 (1회) - ↓ -2. 각 기능별로 서버 액션 작성 ('use server') - ↓ -3. 서버 액션 내에서 withApproval() 호출 - ↓ -4. 자동으로 핸들러 Lazy 초기화됨 ✨ - ↓ -5. Knox 결재 상신 + DB 저장 (Saga Pattern) - ↓ -6. 폴링 서비스가 자동으로 승인 감지 및 실행 -``` +--- -## 사용 방법 +## 💻 사용 방법 -### 1단계: 핸들러 구현 및 등록 +### Step 1: 비즈니스 핸들러 작성 ```typescript // lib/my-feature/handlers.ts -export async function myActionInternal(payload: MyPayload) { - // 실제 비즈니스 로직 - const result = await db.insert(myTable).values(payload); +'use server'; + +export async function myBusinessLogicInternal(payload: { + id: number; + reason: string; +}) { + // 결재 승인 후 실행될 실제 비즈니스 로직 + const result = await db.insert(myTable).values({ + id: payload.id, + reason: payload.reason, + status: 'approved', + }); + return result; } +// 템플릿 변수 매핑 +export async function mapToTemplateVariables(data: MyData) { + return { + '요청자': data.requesterName, + '요청일': formatDate(new Date()), + '사유': data.reason, + }; +} +``` + +### Step 2: 핸들러 등록 + +```typescript // lib/approval/handlers-registry.ts -import { myActionInternal } from '@/lib/my-feature/handlers'; +import { registerActionHandler } from './approval-workflow'; export async function initializeApprovalHandlers() { - // ... 기존 핸들러들 + console.log('[Approval Handlers] Registering all handlers...'); + + // 1. 내 기능 핸들러 등록 + const { myBusinessLogicInternal } = await import('@/lib/my-feature/handlers'); + registerActionHandler('my_action_type', myBusinessLogicInternal); + + // 2. 다른 핸들러들... - registerActionHandler('my_action_type', myActionInternal); + console.log('[Approval Handlers] All handlers registered'); } ``` -### 2단계: 결재 템플릿 준비 +### Step 3: 결재 서버 액션 작성 -데이터베이스 `approval_templates` 테이블에 템플릿 추가: +```typescript +// lib/my-feature/approval-actions.ts +'use server'; -```sql -INSERT INTO approval_templates (name, content) VALUES ( - '나의 액션 결재', - '
-

{{제목}}

-

{{설명}}

- {{상세_테이블}} -
' -); -``` +import { ApprovalSubmissionSaga } from '@/lib/approval'; +import { mapToTemplateVariables } from './handlers'; -### 3단계: 서버 액션 작성 +export async function requestMyActionWithApproval(data: { + id: number; + reason: string; + currentUser: { id: number; epId: string | null; email?: string }; + approvers?: string[]; +}) { + // 1. 입력 검증 + if (!data.currentUser.epId) { + throw new Error('Knox EP ID가 필요합니다'); + } -```typescript -// lib/my-feature/actions.ts -'use server'; + // 2. 템플릿 변수 매핑 + const variables = await mapToTemplateVariables(data); -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 + // 3. Saga로 결재 상신 + const saga = new ApprovalSubmissionSaga( + 'my_action_type', // 핸들러 등록 시 사용한 키 { - title: `결재 요청 - ${data.title}`, - templateName: '나의 액션 결재', + id: data.id, + reason: data.reason, + }, + { + title: `내 기능 요청 - ${data.id}`, + description: `ID ${data.id}에 대한 승인 요청`, + templateName: '내 기능 템플릿', variables, - approvers: data.approvers, // 결재자 EP ID 배열 + approvers: data.approvers, currentUser: data.currentUser, } ); - - return result; + + return await saga.execute(); } ``` -### 4단계: UI에서 호출 +### Step 4: UI에서 호출 -```tsx -// components/my-feature/my-form.tsx +```typescript +// components/my-feature/my-dialog.tsx 'use client'; -export function MyForm() { +import { requestMyActionWithApproval } from '@/lib/my-feature/approval-actions'; +import { useSession } from 'next-auth/react'; + +export function MyDialog() { + const { data: session } = useSession(); + const handleSubmit = async (formData) => { try { - const result = await myActionWithApproval({ - title: formData.title, - description: formData.description, - items: formData.items, + const result = await requestMyActionWithApproval({ + id: formData.id, + reason: formData.reason, + currentUser: { + id: Number(session?.user?.id), + epId: session?.user?.epId || null, + email: session?.user?.email || undefined, + }, approvers: selectedApprovers, - currentUser: session.user, }); - toast.success(`결재가 상신되었습니다. (ID: ${result.approvalId})`); - router.push(`/approval/log`); + if (result.status === 'pending_approval') { + toast.success(`결재가 상신되었습니다. (ID: ${result.approvalId})`); + } } catch (error) { - toast.error('결재 상신에 실패했습니다.'); + toast.error('결재 상신 실패'); } }; @@ -418,1058 +399,544 @@ export function MyForm() { --- -## 💡 베스트 프랙티스 +## 📚 API 레퍼런스 -### ✅ 권장 패턴 +### Saga 클래스 -#### 1. 핸들러와 서버 액션 분리 +#### ApprovalSubmissionSaga -```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); -} -``` +결재를 Knox 시스템에 상신합니다. ```typescript -// ❌ 나쁜 예: 핸들러에 서버 액션 지시어 사용 -// lib/my-feature/handlers.ts -'use server'; // ❌ 핸들러에는 불필요 -export async function myActionInternal(payload: MyPayload) { - return await db.insert(myTable).values(payload); -} +constructor( + actionType: string, // 핸들러 등록 시 사용한 키 + actionPayload: T, // 결재 승인 후 실행될 데이터 + approvalConfig: ApprovalConfig +) + +async execute(): Promise +// → { pendingActionId, approvalId, status: 'pending_approval' } ``` -#### 2. 템플릿 변수는 서버 액션에서 준비 +**ApprovalConfig:** ```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), +interface ApprovalConfig { + title: string; // 결재 제목 + description?: string; // 결재 설명 + templateName: string; // DB에 저장된 템플릿명 + variables: Record; // 템플릿 변수 + approvers?: string[]; // Knox EP ID 배열 + currentUser: { + id: number; + epId: string | null; + email?: string; }; } ``` -#### 3. 파일 구조 컨벤션 - -``` -lib/my-feature/ -├── handlers.ts # 비즈니스 로직 (Internal 함수) -│ └── myActionInternal() # 결재 승인 후 실행될 순수 로직 -│ -├── approval-actions.ts # 결재 서버 액션 ('use server') -│ └── myActionWithApproval() -│ └── mapToTemplateVariables() -│ -├── service.ts # 일반 서비스 함수 -│ └── getMyData() -│ └── validateMyData() -│ -└── types.ts # 타입 정의 - └── MyPayload - └── MyData -``` +#### ApprovalExecutionSaga -#### 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'; +constructor(apInfId: string) // Knox 결재 ID -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: 결재선 선택 */} - - - ); -} +async execute(): Promise +// → 핸들러 실행 결과 ``` -### ❌ 피해야 할 패턴 +#### ApprovalRejectionSaga -#### 1. 핸들러에서 Knox API 직접 호출 +결재 반려를 처리합니다. (폴링 서비스가 자동 호출) ```typescript -// ❌ 나쁜 예: 핸들러에서 결재 로직 포함 -export async function myActionInternal(payload: MyPayload) { - // ❌ 핸들러는 순수 비즈니스 로직만! - const approval = await submitApproval(...); - return await db.insert(myTable).values(payload); -} +constructor(apInfId: string, reason?: string) -// ✅ 좋은 예: 핸들러는 순수 로직만 -export async function myActionInternal(payload: MyPayload) { - return await db.insert(myTable).values(payload); -} +async execute(): Promise ``` -#### 2. 서버 액션에서 DB 조작 +### 핸들러 레지스트리 ```typescript -// ❌ 나쁜 예: 서버 액션에 비즈니스 로직 포함 -'use server'; -export async function myActionWithApproval(data: MyData) { - // ❌ DB 조작은 핸들러에서! - await db.insert(myTable).values(data); - - return await withApproval(...); -} +// 핸들러 등록 +function registerActionHandler( + actionType: string, + handler: (payload: any) => Promise +): void -// ✅ 좋은 예: 서버 액션은 결재 래핑만 -'use server'; -export async function myActionWithApproval(data: MyData) { - // withApproval만 호출 - // 실제 DB 조작은 핸들러(myActionInternal)에서 실행됨 - return await withApproval('my_action', data, config); -} +// 등록된 핸들러 조회 (디버깅용) +function getRegisteredHandlers(): string[] ``` -#### 3. 핸들러를 서버 액션으로 직접 export +### 폴링 서비스 ```typescript -// ❌ 나쁜 예: 핸들러를 서버 액션으로 노출 -'use server'; -export async function myAction(data: MyData) { - // 이렇게 하면 결재 없이 바로 실행됨! - return await db.insert(myTable).values(data); -} +// 폴링 스케줄러 시작 (instrumentation.ts에서 호출) +async function startApprovalPollingScheduler(): Promise -// ✅ 좋은 예: 핸들러는 Internal로 분리 -export async function myActionInternal(payload: MyPayload) { - return await db.insert(myTable).values(payload); -} +// 수동으로 pending 결재 확인 +async function checkPendingApprovals(): Promise<{ + success: boolean; + checked: number; + updated: number; + executed: number; +}> -// 별도 파일에서 서버 액션으로 래핑 -'use server'; -export async function myActionWithApproval(data: MyData) { - return await withApproval('my_action', data, config); -} +// 특정 결재 상태 확인 (UI에서 "새로고침" 버튼) +async function checkSingleApprovalStatus(apInfId: string): Promise<{ + success: boolean; + oldStatus: string; + newStatus: string; + updated: boolean; + executed: boolean; +}> ``` ---- +### 템플릿 유틸리티 -## 🔧 실전 예제: PQ 실사 의뢰 +```typescript +// HTML 테이블 생성 +async function htmlTableConverter( + data: any[], + columns: Array<{ key: string; label: string }> +): Promise -### 파일 구조 +// HTML 리스트 생성 +async function htmlListConverter(items: string[]): Promise -``` -lib/vendor-investigation/ -├── handlers.ts # 핸들러 -│ ├── requestPQInvestigationInternal() -│ ├── reRequestPQInvestigationInternal() -│ └── mapPQInvestigationToTemplateVariables() -│ -├── approval-actions.ts # 서버 액션 -│ ├── requestPQInvestigationWithApproval() -│ └── reRequestPQInvestigationWithApproval() -│ -└── service.ts # 일반 서비스 - ├── getInvestigations() - └── updateInvestigationStatus() -``` +// HTML Description List 생성 +async function htmlDescriptionList( + items: Record +): Promise -### 1. 핸들러 (handlers.ts) +// 템플릿 조회 +async function getApprovalTemplateByName( + name: string +): Promise