diff options
| author | joonhoekim <26rote@gmail.com> | 2025-11-05 10:57:01 +0900 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-11-05 10:57:01 +0900 |
| commit | 2e1a87c11c4ed65588342bcd66c3acf9a24f90f7 (patch) | |
| tree | c25aaf9f3ebab94028c4fb8155fe5c036a033624 | |
| parent | a6ad5fcfb65772b9ae240d9fa02bf9ed1a9160c9 (diff) | |
(김준회) 결재 트랜잭션 개선 (Saga Pattern 도입) 및 문서 작성
| -rw-r--r-- | lib/approval/README.md | 696 | ||||
| -rw-r--r-- | lib/approval/approval-workflow.ts | 188 |
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; } } |
