summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-11-05 10:57:01 +0900
committerjoonhoekim <26rote@gmail.com>2025-11-05 10:57:01 +0900
commit2e1a87c11c4ed65588342bcd66c3acf9a24f90f7 (patch)
treec25aaf9f3ebab94028c4fb8155fe5c036a033624
parenta6ad5fcfb65772b9ae240d9fa02bf9ed1a9160c9 (diff)
(김준회) 결재 트랜잭션 개선 (Saga Pattern 도입) 및 문서 작성
-rw-r--r--lib/approval/README.md696
-rw-r--r--lib/approval/approval-workflow.ts188
2 files changed, 814 insertions, 70 deletions
diff --git a/lib/approval/README.md b/lib/approval/README.md
new file mode 100644
index 00000000..025f589f
--- /dev/null
+++ b/lib/approval/README.md
@@ -0,0 +1,696 @@
+# 결재 관리 시스템 (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<string, string>; // 템플릿 변수
+ 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 (
+ '나의 액션 결재',
+ '<div>
+ <h2>{{제목}}</h2>
+ <p>{{설명}}</p>
+ {{상세_테이블}}
+ </div>'
+);
+```
+
+### 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 <form onSubmit={handleSubmit}>...</form>;
+}
+```
+
+## 파일 구조
+
+```
+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를 부여받아야 합니다.
+
diff --git a/lib/approval/approval-workflow.ts b/lib/approval/approval-workflow.ts
index cc8914f9..ed20e972 100644
--- a/lib/approval/approval-workflow.ts
+++ b/lib/approval/approval-workflow.ts
@@ -82,78 +82,87 @@ export async function withApproval<T>(
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);
- }
+ // 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
+ // 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(drafterLine);
+ aplns.push(approverLine);
}
-
- // 결재자들
- 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 템플릿 사용
}
-
- // 결재 요청 생성
- 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를 사용
+ /**
+ * 트랜잭션 관리 전략 (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,
@@ -162,15 +171,54 @@ export async function withApproval<T>(
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: pendingAction.id,
+ pendingActionId,
approvalId: submitRequest.apInfId,
- status: 'pending_approval',
+ status: 'pending_approval' as const,
};
} catch (error) {
- console.error('Failed to create approval workflow:', 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;
}
}