diff options
| author | joonhoekim <26rote@gmail.com> | 2025-11-05 11:54:08 +0900 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-11-05 11:54:08 +0900 |
| commit | 70aada2ef189467d1bc62dc892c629a71196e755 (patch) | |
| tree | fcca4c52c94f2d69d356bee2a7c5693236018794 | |
| parent | 5994c98054a5883f8b15a204ffaca6ceaf86e013 (diff) | |
(김준회) 결재 개선, 실사의뢰/실사재의뢰 베스트프랙티스로 수정
| -rw-r--r-- | app/[lng]/evcp/(evcp)/(system)/approval/log/page.tsx | 9 | ||||
| -rw-r--r-- | instrumentation.ts | 10 | ||||
| -rw-r--r-- | lib/approval/README.md | 779 | ||||
| -rw-r--r-- | lib/approval/approval-workflow.ts | 26 | ||||
| -rw-r--r-- | lib/vendor-investigation/approval-actions.ts | 41 | ||||
| -rw-r--r-- | lib/vendor-investigation/handlers.ts | 33 |
6 files changed, 840 insertions, 58 deletions
diff --git a/app/[lng]/evcp/(evcp)/(system)/approval/log/page.tsx b/app/[lng]/evcp/(evcp)/(system)/approval/log/page.tsx index d0264aa1..f5b069df 100644 --- a/app/[lng]/evcp/(evcp)/(system)/approval/log/page.tsx +++ b/app/[lng]/evcp/(evcp)/(system)/approval/log/page.tsx @@ -31,15 +31,6 @@ export default async function ApprovalLogPage() { </div> </div> - <React.Suspense fallback={<Skeleton className="h-7 w-52" />}> - {/* <DateRangePicker - triggerSize="sm" - triggerClassName="ml-auto w-56 sm:w-60" - align="end" - shallow={false} - /> */} - </React.Suspense> - <React.Suspense fallback={ <DataTableSkeleton diff --git a/instrumentation.ts b/instrumentation.ts index de353e5f..934f9025 100644 --- a/instrumentation.ts +++ b/instrumentation.ts @@ -83,16 +83,8 @@ export async function register() { // } try { - // Knox 결재 액션 핸들러 초기화 (앱 시작 시 핸들러 등록) - const { initializeApprovalHandlers } = await import('./lib/approval/handlers-registry'); - await initializeApprovalHandlers(); - } catch { - console.error('Failed to initialize approval handlers.'); - // 핸들러 초기화 실패해도 애플리케이션은 계속 실행 - } - - try { // Knox 결재 상태 폴링 스케줄러 시작 (1분마다 pending 결재 상태 확인) + // 참고: 핸들러는 Lazy 초기화로 자동 등록됨 (approval-workflow.ts의 ensureHandlersInitialized) const { startApprovalPollingScheduler } = await import('./lib/approval/approval-polling-service'); await startApprovalPollingScheduler(); } catch { diff --git a/lib/approval/README.md b/lib/approval/README.md index 025f589f..bc6a59d3 100644 --- a/lib/approval/README.md +++ b/lib/approval/README.md @@ -248,6 +248,67 @@ export async function initializeApprovalHandlers() { | 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단계: 핸들러 구현 및 등록 @@ -355,6 +416,724 @@ export function MyForm() { } ``` +--- + +## 💡 베스트 프랙티스 + +### ✅ 권장 패턴 + +#### 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<MyData | null>(null); + const [variables, setVariables] = useState<Record<string, string>>({}); + + // 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 ( + <> + <Button onClick={() => setIsDialogOpen(true)}> + 액션 실행 + </Button> + + {/* Step 1: 데이터 입력 */} + <MyFormDialog + open={isDialogOpen} + onOpenChange={setIsDialogOpen} + onSubmit={handleFormSubmit} + /> + + {/* Step 2: 결재선 선택 */} + <ApprovalPreviewDialog + open={isApprovalDialogOpen} + onOpenChange={setIsApprovalDialogOpen} + templateName="나의 액션 결재" + variables={variables} + onSubmit={handleApprovalSubmit} + /> + </> + ); +} +``` + +### ❌ 피해야 할 패턴 + +#### 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 ( + <> + <Button onClick={handleOpenDialog}> + 실사 의뢰 + </Button> + + <ApprovalPreviewDialog + open={isApprovalDialogOpen} + onOpenChange={setIsApprovalDialogOpen} + templateName="Vendor 실사의뢰" + variables={variables} + title={`Vendor 실사의뢰 - ${formData?.vendorNames}`} + currentUser={currentUser} + onSubmit={handleApprovalSubmit} + /> + </> + ); +} +``` + +--- + +## ✅ 새로운 결재 기능 추가 체크리스트 + +새로운 결재 기능을 추가할 때 다음 순서로 작업하세요: + +### 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 ( + '나의 액션 결재', + '<div> + <h2>{{제목}}</h2> + <p>{{설명}}</p> + {{상세_테이블}} + </div>', + 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 # 모든 핸들러 등록 (중앙 관리) +``` + +--- + ## 파일 구조 ``` diff --git a/lib/approval/approval-workflow.ts b/lib/approval/approval-workflow.ts index ed20e972..4c7eec09 100644 --- a/lib/approval/approval-workflow.ts +++ b/lib/approval/approval-workflow.ts @@ -50,6 +50,24 @@ export function getRegisteredHandlers() { } /** + * 핸들러 초기화 여부 추적 (Lazy Initialization용) + */ +let handlersInitialized = false; + +/** + * 핸들러가 등록되어 있지 않으면 자동으로 초기화 (Lazy Initialization) + * Next.js 서버 액션의 격리된 실행 컨텍스트를 위한 안전장치 + */ +async function ensureHandlersInitialized() { + if (!handlersInitialized) { + console.log('[Approval Workflow] Lazy initializing handlers...'); + const { initializeApprovalHandlers } = await import('./handlers-registry'); + await initializeApprovalHandlers(); + handlersInitialized = true; + } +} + +/** * 결재가 필요한 액션을 래핑하는 공통 함수 * * 사용법: @@ -77,8 +95,12 @@ export async function withApproval<T>( actionPayload: T, approvalConfig: ApprovalConfig ) { + // 핸들러 자동 초기화 (서버 액션 격리 문제 해결) + await ensureHandlersInitialized(); + // 핸들러가 등록되어 있는지 확인 if (!actionHandlers.has(actionType)) { + console.error('[Approval Workflow] Available handlers:', Array.from(actionHandlers.keys())); throw new Error(`No handler registered for action type: ${actionType}`); } @@ -232,6 +254,9 @@ export async function withApproval<T>( */ export async function executeApprovedAction(apInfId: string) { try { + // 핸들러 자동 초기화 (폴링 서비스의 격리 문제 해결) + await ensureHandlersInitialized(); + // 1. apInfId로 pendingAction 조회 const pendingAction = await db.query.pendingActions.findFirst({ where: eq(pendingActions.apInfId, apInfId), @@ -251,6 +276,7 @@ export async function executeApprovedAction(apInfId: string) { // 2. 등록된 핸들러 조회 const handler = actionHandlers.get(pendingAction.actionType); if (!handler) { + console.error('[Approval Workflow] Available handlers:', Array.from(actionHandlers.keys())); throw new Error(`Handler not found for action type: ${pendingAction.actionType}`); } diff --git a/lib/vendor-investigation/approval-actions.ts b/lib/vendor-investigation/approval-actions.ts index 607580d8..5da30011 100644 --- a/lib/vendor-investigation/approval-actions.ts +++ b/lib/vendor-investigation/approval-actions.ts @@ -1,8 +1,12 @@ /** * PQ 실사 관련 결재 서버 액션 * - * 사용자가 UI에서 호출하는 함수들 - * withApproval()을 사용하여 결재 프로세스를 시작 + * ✅ 베스트 프랙티스: + * - 'use server' 지시어 포함 (서버 액션) + * - UI에서 호출하는 진입점 함수들 + * - withApproval()을 사용하여 결재 프로세스 시작 + * - 템플릿 변수 준비 및 입력 검증 + * - 핸들러(Internal)에는 최소 데이터만 전달 */ 'use server'; @@ -14,13 +18,14 @@ import { debugLog, debugError, debugSuccess } from '@/lib/debug-utils'; /** * 결재를 거쳐 PQ 실사 의뢰를 생성하는 서버 액션 * - * 사용법 (클라이언트 컴포넌트에서): + * ✅ 사용법 (클라이언트 컴포넌트에서): * ```typescript * const result = await requestPQInvestigationWithApproval({ * pqSubmissionIds: [1, 2, 3], * vendorNames: "협력사A, 협력사B", * qmManagerId: 123, * qmManagerName: "홍길동", + * qmManagerEmail: "hong@example.com", * forecastedAt: new Date('2025-11-01'), * investigationAddress: "경기도 ...", * investigationNotes: "...", @@ -29,7 +34,7 @@ import { debugLog, debugError, debugSuccess } from '@/lib/debug-utils'; * }); * * if (result.status === 'pending_approval') { - * console.log('결재 ID:', result.approvalId); + * toast.success(`결재가 상신되었습니다. (ID: ${result.approvalId})`); * } * ``` */ @@ -55,7 +60,7 @@ export async function requestPQInvestigationWithApproval(data: { hasEpId: !!data.currentUser.epId, }); - // 입력 검증 + // 1. 입력 검증 if (!data.currentUser.epId) { debugError('[PQInvestigationApproval] Knox EP ID 없음'); throw new Error('Knox EP ID가 필요합니다'); @@ -66,7 +71,7 @@ export async function requestPQInvestigationWithApproval(data: { throw new Error('실사 의뢰할 PQ를 선택해주세요'); } - // 1. 템플릿 변수 매핑 + // 2. 템플릿 변수 매핑 debugLog('[PQInvestigationApproval] 템플릿 변수 매핑 시작'); const requestedAt = new Date(); const variables = await mapPQInvestigationToTemplateVariables({ @@ -82,22 +87,19 @@ export async function requestPQInvestigationWithApproval(data: { variableKeys: Object.keys(variables), }); - // 2. 결재 워크플로우 시작 (템플릿 기반) + // 3. 결재 워크플로우 시작 debugLog('[PQInvestigationApproval] withApproval 호출'); const result = await withApproval( // actionType: 핸들러를 찾을 때 사용할 키 'pq_investigation_request', - // actionPayload: 결재 승인 후 핸들러에 전달될 데이터 + // actionPayload: 결재 승인 후 핸들러에 전달될 데이터 (최소 데이터만) { pqSubmissionIds: data.pqSubmissionIds, qmManagerId: data.qmManagerId, - qmManagerName: data.qmManagerName, forecastedAt: data.forecastedAt, investigationAddress: data.investigationAddress, investigationNotes: data.investigationNotes, - vendorNames: data.vendorNames, - currentUser: data.currentUser, }, // approvalConfig: 결재 상신 정보 (템플릿 포함) @@ -123,7 +125,7 @@ export async function requestPQInvestigationWithApproval(data: { /** * 결재를 거쳐 PQ 실사 재의뢰를 처리하는 서버 액션 * - * 사용법 (클라이언트 컴포넌트에서): + * ✅ 사용법 (클라이언트 컴포넌트에서): * ```typescript * const result = await reRequestPQInvestigationWithApproval({ * investigationIds: [1, 2, 3], @@ -134,7 +136,7 @@ export async function requestPQInvestigationWithApproval(data: { * }); * * if (result.status === 'pending_approval') { - * console.log('결재 ID:', result.approvalId); + * toast.success(`재의뢰 결재가 상신되었습니다. (ID: ${result.approvalId})`); * } * ``` */ @@ -153,7 +155,7 @@ export async function reRequestPQInvestigationWithApproval(data: { hasReason: !!data.reason, }); - // 입력 검증 + // 1. 입력 검증 if (!data.currentUser.epId) { debugError('[PQReRequestApproval] Knox EP ID 없음'); throw new Error('Knox EP ID가 필요합니다'); @@ -164,7 +166,7 @@ export async function reRequestPQInvestigationWithApproval(data: { throw new Error('재의뢰할 실사를 선택해주세요'); } - // 1. 기존 실사 정보 조회 (첫 번째 실사 기준) + // 2. 기존 실사 정보 조회 (첫 번째 실사 기준 - 템플릿 변수용) debugLog('[PQReRequestApproval] 기존 실사 정보 조회'); const { default: db } = await import('@/db/db'); const { vendorInvestigations, users } = await import('@/db/schema'); @@ -197,7 +199,7 @@ export async function reRequestPQInvestigationWithApproval(data: { forecastedAt: existingInvestigation.forecastedAt, }); - // 2. 템플릿 변수 매핑 + // 3. 템플릿 변수 매핑 debugLog('[PQReRequestApproval] 템플릿 변수 매핑 시작'); const reRequestedAt = new Date(); const { mapPQReRequestToTemplateVariables } = await import('./handlers'); @@ -206,7 +208,7 @@ export async function reRequestPQInvestigationWithApproval(data: { investigationCount: data.investigationIds.length, reRequestedAt, reason: data.reason, - // 기존 실사 정보 전달 + // 기존 실사 정보 전달 (템플릿 표시용) forecastedAt: existingInvestigation.forecastedAt || undefined, investigationAddress: existingInvestigation.investigationAddress || undefined, qmManagerName: existingInvestigation.qmManagerName || undefined, @@ -216,16 +218,15 @@ export async function reRequestPQInvestigationWithApproval(data: { variableKeys: Object.keys(variables), }); - // 2. 결재 워크플로우 시작 (템플릿 기반) + // 4. 결재 워크플로우 시작 debugLog('[PQReRequestApproval] withApproval 호출'); const result = await withApproval( // actionType: 핸들러를 찾을 때 사용할 키 'pq_investigation_rerequest', - // actionPayload: 결재 승인 후 핸들러에 전달될 데이터 + // actionPayload: 결재 승인 후 핸들러에 전달될 데이터 (최소 데이터만) { investigationIds: data.investigationIds, - vendorNames: data.vendorNames, }, // approvalConfig: 결재 상신 정보 (템플릿 포함) diff --git a/lib/vendor-investigation/handlers.ts b/lib/vendor-investigation/handlers.ts index 24cad870..28a218b5 100644 --- a/lib/vendor-investigation/handlers.ts +++ b/lib/vendor-investigation/handlers.ts @@ -1,44 +1,40 @@ /** * PQ 실사 관련 결재 액션 핸들러 * - * 실제 비즈니스 로직만 포함 (결재 로직은 approval-workflow에서 처리) + * ✅ 베스트 프랙티스: + * - 'use server' 지시어 없음 (순수 비즈니스 로직만) + * - 결재 승인 후 실행될 최소한의 데이터만 처리 + * - DB 조작 및 실제 비즈니스 로직만 포함 */ -'use server'; - -import { requestInvestigationAction } from '@/lib/pq/service'; import { debugLog, debugError, debugSuccess } from '@/lib/debug-utils'; /** * PQ 실사 의뢰 핸들러 (결재 승인 후 실행됨) * - * 이 함수는 직접 호출하지 않고, 결재 워크플로우에서 자동으로 호출됨 + * ✅ Internal 함수: 결재 워크플로우에서 자동 호출됨 (직접 호출 금지) * - * @param payload - withApproval()에서 전달한 actionPayload + * @param payload - withApproval()에서 전달한 actionPayload (최소 데이터만) */ export async function requestPQInvestigationInternal(payload: { pqSubmissionIds: number[]; qmManagerId: number; - qmManagerName?: string; forecastedAt: Date; investigationAddress: string; investigationNotes?: string; - vendorNames?: string; // 복수 업체 이름 (표시용) - currentUser: { id: number; epId: string | null; email?: string }; }) { debugLog('[PQInvestigationHandler] 실사 의뢰 핸들러 시작', { pqCount: payload.pqSubmissionIds.length, qmManagerId: payload.qmManagerId, - currentUser: payload.currentUser, - vendorNames: payload.vendorNames, }); try { - // 실제 실사 의뢰 처리 - debugLog('[PQInvestigationHandler] requestInvestigationAction 호출'); + // 기존 PQ 서비스 함수 사용 (DB 트랜잭션 포함) + const { requestInvestigationAction } = await import('@/lib/pq/service'); + const result = await requestInvestigationAction( payload.pqSubmissionIds, - payload.currentUser, + { id: 0, epId: null, email: undefined }, // 핸들러에서는 currentUser 불필요 { qmManagerId: payload.qmManagerId, forecastedAt: payload.forecastedAt, @@ -104,23 +100,20 @@ export async function mapPQInvestigationToTemplateVariables(payload: { /** * PQ 실사 재의뢰 핸들러 (결재 승인 후 실행됨) * - * 이 함수는 직접 호출하지 않고, 결재 워크플로우에서 자동으로 호출됨 + * ✅ Internal 함수: 결재 워크플로우에서 자동 호출됨 (직접 호출 금지) * - * @param payload - withApproval()에서 전달한 actionPayload + * @param payload - withApproval()에서 전달한 actionPayload (최소 데이터만) */ export async function reRequestPQInvestigationInternal(payload: { investigationIds: number[]; - vendorNames?: string; // 복수 업체 이름 (표시용) }) { debugLog('[PQReRequestHandler] 실사 재의뢰 핸들러 시작', { investigationCount: payload.investigationIds.length, - vendorNames: payload.vendorNames, }); try { - // 실제 실사 재의뢰 처리 + // 기존 PQ 서비스 함수 사용 const { reRequestInvestigationAction } = await import('@/lib/pq/service'); - debugLog('[PQReRequestHandler] reRequestInvestigationAction 호출'); const result = await reRequestInvestigationAction(payload.investigationIds); |
