summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-11-05 11:54:08 +0900
committerjoonhoekim <26rote@gmail.com>2025-11-05 11:54:08 +0900
commit70aada2ef189467d1bc62dc892c629a71196e755 (patch)
treefcca4c52c94f2d69d356bee2a7c5693236018794
parent5994c98054a5883f8b15a204ffaca6ceaf86e013 (diff)
(김준회) 결재 개선, 실사의뢰/실사재의뢰 베스트프랙티스로 수정
-rw-r--r--app/[lng]/evcp/(evcp)/(system)/approval/log/page.tsx9
-rw-r--r--instrumentation.ts10
-rw-r--r--lib/approval/README.md779
-rw-r--r--lib/approval/approval-workflow.ts26
-rw-r--r--lib/vendor-investigation/approval-actions.ts41
-rw-r--r--lib/vendor-investigation/handlers.ts33
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);