diff --git a/.cursor/agents/pipeline-common-rules.md b/.cursor/agents/pipeline-common-rules.md new file mode 100644 index 00000000..5baa0e97 --- /dev/null +++ b/.cursor/agents/pipeline-common-rules.md @@ -0,0 +1,82 @@ +# WACE ERP 파이프라인 공통 룰 (모든 에이전트 필수 준수) + +## 1. 화면 유형 구분 (절대 규칙!) + +이 시스템은 **관리자 메뉴**와 **사용자 메뉴**가 완전히 다른 방식으로 동작한다. +기능 구현 시 반드시 어느 유형인지 먼저 판단하라. + +### 관리자 메뉴 (Admin) +- **구현 방식**: React 코드 기반 페이지 (`.tsx` 파일) +- **경로**: `frontend/app/(main)/admin/{기능명}/page.tsx` +- **메뉴 등록**: `menu_info` 테이블에 INSERT 필수 (코드만 만들고 메뉴 등록 안 하면 미완성!) +- **대상**: 시스템 설정, 사용자 관리, 결재 관리, 코드 관리 등 +- **특징**: 하드코딩된 UI, 관리자만 접근 + +### 사용자 메뉴 (User/Screen) +- **구현 방식**: 로우코드 기반 (DB에 JSON으로 화면 구성 저장) +- **데이터 저장**: `screen_layouts` 테이블에 JSON 형식 보관 +- **화면 디자이너**: 스크린 디자이너로 드래그앤드롭 구성 +- **V2 컴포넌트**: `frontend/lib/registry/components/v2-*` 디렉토리 +- **대상**: 일반 업무 화면, BOM, 문서 관리 등 +- **특징**: 코드 수정 없이 화면 구성 변경 가능 + +### 판단 기준 + +| 질문 | 관리자 메뉴 | 사용자 메뉴 | +|------|-------------|-------------| +| 누가 쓰나? | 시스템 관리자 | 일반 사용자 | +| 화면 구조 고정? | 고정 (코드) | 유동적 (JSON) | +| URL 패턴 | `/admin/*` | 스크린 디자이너 경유 | +| 메뉴 등록 | `menu_info` INSERT 필수 | 스크린 레이아웃 등록 | + +## 2. 관리자 메뉴 등록 (코드 구현 후 필수!) + +관리자 기능을 코드로 만들었으면 반드시 `menu_info`에 등록해야 한다. + +```sql +-- 예시: 결재 템플릿 관리 메뉴 등록 +INSERT INTO menu_info (menu_id, menu_name, url, parent_id, menu_type, sort_order, is_active, company_code) +VALUES ('approvalTemplate', '결재 템플릿', '/admin/approvalTemplate', 'approval', 'ADMIN', 40, 'Y', '대상회사코드'); +``` + +- 기존 메뉴 구조를 먼저 조회해서 parent_id, sort_order 등을 맞춰라 +- company_code 별로 등록이 필요할 수 있다 +- menu_auth_group 권한 매핑도 필요하면 추가 + +## 3. 하드코딩 금지 / 범용성 필수 + +- 특정 회사에만 동작하는 코드 금지 +- 특정 사용자 ID에 의존하는 로직 금지 +- 매직 넘버 사용 금지 (상수 또는 설정 파일로 관리) +- 하드코딩 색상 금지 (CSS 변수 사용: bg-primary, text-destructive 등) +- 하드코딩 URL 금지 (환경 변수 또는 API 클라이언트 사용) + +## 4. 테스트 환경 정보 + +- **테스트 계정**: userId=`wace`, password=`qlalfqjsgh11` +- **역할**: SUPER_ADMIN (company_code = "*") +- **개발 프론트엔드**: http://localhost:9771 +- **개발 백엔드 API**: http://localhost:8080 +- **개발 DB**: postgresql://postgres:ph0909!!@39.117.244.52:11132/plm + +## 5. 기능 구현 완성 체크리스트 + +기능 하나를 "완성"이라고 말하려면 아래를 전부 충족해야 한다: + +- [ ] DB: 마이그레이션 작성 + 실행 완료 +- [ ] DB: company_code 컬럼 + 인덱스 존재 +- [ ] BE: API 엔드포인트 구현 + 라우트 등록 +- [ ] BE: company_code 필터링 적용 +- [ ] FE: API 클라이언트 함수 작성 (lib/api/) +- [ ] FE: 화면 컴포넌트 구현 +- [ ] **메뉴 등록**: 관리자 메뉴면 menu_info INSERT, 사용자 메뉴면 스크린 레이아웃 등록 +- [ ] 빌드 통과: 백엔드 tsc + 프론트엔드 tsc + +## 6. 절대 하지 말 것 + +1. 페이지 파일만 만들고 메뉴 등록 안 하기 (미완성!) +2. fetch() 직접 사용 (lib/api/ 클라이언트 필수) +3. company_code 필터링 빠뜨리기 +4. 하드코딩 색상/URL/사용자ID 사용 +5. Card 안에 Card 중첩 (중첩 박스 금지) +6. 백엔드 재실행하기 (nodemon이 자동 재시작) diff --git a/backend-node/src/controllers/approvalController.ts b/backend-node/src/controllers/approvalController.ts index 8c179a7e..b96d1dc2 100644 --- a/backend-node/src/controllers/approvalController.ts +++ b/backend-node/src/controllers/approvalController.ts @@ -1041,6 +1041,14 @@ export class ApprovalLineController { return res.status(400).json({ success: false, message: "액션은 approved 또는 rejected여야 합니다." }); } + // 검증 에러를 트랜잭션 바깥으로 전달하기 위한 커스텀 에러 클래스 + class ValidationError extends Error { + constructor(public statusCode: number, message: string) { + super(message); + this.name = "ValidationError"; + } + } + await transaction(async (client) => { // FOR UPDATE로 결재 라인 잠금 (동시성 방어) const { rows: [line] } = await client.query( @@ -1049,13 +1057,11 @@ export class ApprovalLineController { ); if (!line) { - res.status(404).json({ success: false, message: "결재 라인을 찾을 수 없습니다." }); - return; + throw new ValidationError(404, "결재 라인을 찾을 수 없습니다."); } if (line.status !== "pending") { - res.status(400).json({ success: false, message: "대기 중인 결재만 처리할 수 있습니다." }); - return; + throw new ValidationError(400, "대기 중인 결재만 처리할 수 있습니다."); } // 대결(proxy) 인증 로직 @@ -1071,8 +1077,7 @@ export class ApprovalLineController { [line.approver_id, userId, companyCode] ); if (proxyRows.length === 0) { - res.status(403).json({ success: false, message: "본인이 결재자로 지정된 건만 처리할 수 있습니다." }); - return; + throw new ValidationError(403, "본인이 결재자로 지정된 건만 처리할 수 있습니다."); } proxyFor = line.approver_id; proxyReasonVal = proxy_reason || proxyRows[0].reason || "대결 처리"; @@ -1163,19 +1168,22 @@ export class ApprovalLineController { } }); - // 트랜잭션이 res에 응답을 보내지 않은 경우 (정상 처리) - if (!res.headersSent) { - return res.json({ success: true, message: action === "approved" ? "승인 처리되었습니다." : "반려 처리되었습니다." }); - } + return res.json({ success: true, message: action === "approved" ? "승인 처리되었습니다." : "반려 처리되었습니다." }); } catch (error) { - console.error("결재 처리 오류:", error); - if (!res.headersSent) { - return res.status(500).json({ + // ValidationError는 트랜잭션이 rollback된 후 적절한 HTTP 상태코드로 응답 + if (error instanceof Error && error.name === "ValidationError") { + const validationErr = error as any; + return res.status(validationErr.statusCode).json({ success: false, - message: "결재 처리 중 오류가 발생했습니다.", - error: error instanceof Error ? error.message : "알 수 없는 오류", + message: validationErr.message, }); } + console.error("결재 처리 오류:", error); + return res.status(500).json({ + success: false, + message: "결재 처리 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); } } @@ -1209,211 +1217,3 @@ export class ApprovalLineController { } } -// ============================================================ -// 대결 위임 설정 (Proxy Settings) CRUD -// ============================================================ - -export class ApprovalProxyController { - // 대결 위임 목록 조회 - static async getProxySettings(req: AuthenticatedRequest, res: Response) { - try { - const companyCode = req.user?.companyCode; - if (!companyCode) { - return res.status(401).json({ success: false, message: "인증 정보가 없습니다." }); - } - - const { original_user_id, proxy_user_id, is_active } = req.query; - - const conditions: string[] = ["company_code = $1"]; - const params: any[] = [companyCode]; - let idx = 2; - - if (original_user_id) { - conditions.push(`original_user_id = $${idx++}`); - params.push(original_user_id); - } - if (proxy_user_id) { - conditions.push(`proxy_user_id = $${idx++}`); - params.push(proxy_user_id); - } - if (is_active) { - conditions.push(`is_active = $${idx++}`); - params.push(is_active); - } - - const rows = await query( - `SELECT * FROM approval_proxy_settings - WHERE ${conditions.join(" AND ")} - ORDER BY created_at DESC`, - params - ); - - return res.json({ success: true, data: rows }); - } catch (error) { - console.error("대결 위임 목록 조회 오류:", error); - return res.status(500).json({ - success: false, - message: "대결 위임 목록 조회 중 오류가 발생했습니다.", - error: error instanceof Error ? error.message : "알 수 없는 오류", - }); - } - } - - // 대결 위임 생성 - static async createProxySetting(req: AuthenticatedRequest, res: Response) { - try { - const companyCode = req.user?.companyCode; - if (!companyCode) { - return res.status(401).json({ success: false, message: "인증 정보가 없습니다." }); - } - - const { original_user_id, proxy_user_id, start_date, end_date, reason, is_active = "Y" } = req.body; - - if (!original_user_id || !proxy_user_id) { - return res.status(400).json({ success: false, message: "위임자와 대결자는 필수입니다." }); - } - if (!start_date || !end_date) { - return res.status(400).json({ success: false, message: "시작일과 종료일은 필수입니다." }); - } - if (original_user_id === proxy_user_id) { - return res.status(400).json({ success: false, message: "위임자와 대결자가 동일할 수 없습니다." }); - } - - const [row] = await query( - `INSERT INTO approval_proxy_settings - (original_user_id, proxy_user_id, start_date, end_date, reason, is_active, company_code) - VALUES ($1, $2, $3, $4, $5, $6, $7) - RETURNING *`, - [original_user_id, proxy_user_id, start_date, end_date, reason || null, is_active, companyCode] - ); - - return res.status(201).json({ success: true, data: row, message: "대결 위임이 생성되었습니다." }); - } catch (error) { - console.error("대결 위임 생성 오류:", error); - return res.status(500).json({ - success: false, - message: "대결 위임 생성 중 오류가 발생했습니다.", - error: error instanceof Error ? error.message : "알 수 없는 오류", - }); - } - } - - // 대결 위임 수정 - static async updateProxySetting(req: AuthenticatedRequest, res: Response) { - try { - const companyCode = req.user?.companyCode; - if (!companyCode) { - return res.status(401).json({ success: false, message: "인증 정보가 없습니다." }); - } - - const { id } = req.params; - const existing = await queryOne( - "SELECT id FROM approval_proxy_settings WHERE id = $1 AND company_code = $2", - [id, companyCode] - ); - - if (!existing) { - return res.status(404).json({ success: false, message: "대결 위임 설정을 찾을 수 없습니다." }); - } - - const { original_user_id, proxy_user_id, start_date, end_date, reason, is_active } = req.body; - - const fields: string[] = []; - const params: any[] = []; - let idx = 1; - - if (original_user_id !== undefined) { fields.push(`original_user_id = $${idx++}`); params.push(original_user_id); } - if (proxy_user_id !== undefined) { fields.push(`proxy_user_id = $${idx++}`); params.push(proxy_user_id); } - if (start_date !== undefined) { fields.push(`start_date = $${idx++}`); params.push(start_date); } - if (end_date !== undefined) { fields.push(`end_date = $${idx++}`); params.push(end_date); } - if (reason !== undefined) { fields.push(`reason = $${idx++}`); params.push(reason); } - if (is_active !== undefined) { fields.push(`is_active = $${idx++}`); params.push(is_active); } - - fields.push(`updated_at = NOW()`); - params.push(id, companyCode); - - const [row] = await query( - `UPDATE approval_proxy_settings SET ${fields.join(", ")} - WHERE id = $${idx++} AND company_code = $${idx++} - RETURNING *`, - params - ); - - return res.json({ success: true, data: row, message: "대결 위임이 수정되었습니다." }); - } catch (error) { - console.error("대결 위임 수정 오류:", error); - return res.status(500).json({ - success: false, - message: "대결 위임 수정 중 오류가 발생했습니다.", - error: error instanceof Error ? error.message : "알 수 없는 오류", - }); - } - } - - // 대결 위임 삭제 - static async deleteProxySetting(req: AuthenticatedRequest, res: Response) { - try { - const companyCode = req.user?.companyCode; - if (!companyCode) { - return res.status(401).json({ success: false, message: "인증 정보가 없습니다." }); - } - - const { id } = req.params; - const existing = await queryOne( - "SELECT id FROM approval_proxy_settings WHERE id = $1 AND company_code = $2", - [id, companyCode] - ); - - if (!existing) { - return res.status(404).json({ success: false, message: "대결 위임 설정을 찾을 수 없습니다." }); - } - - await query( - "DELETE FROM approval_proxy_settings WHERE id = $1 AND company_code = $2", - [id, companyCode] - ); - - return res.json({ success: true, message: "대결 위임이 삭제되었습니다." }); - } catch (error) { - console.error("대결 위임 삭제 오류:", error); - return res.status(500).json({ - success: false, - message: "대결 위임 삭제 중 오류가 발생했습니다.", - error: error instanceof Error ? error.message : "알 수 없는 오류", - }); - } - } - - // 특정 사용자의 현재 활성 대결자 조회 - static async checkActiveProxy(req: AuthenticatedRequest, res: Response) { - try { - const companyCode = req.user?.companyCode; - if (!companyCode) { - return res.status(401).json({ success: false, message: "인증 정보가 없습니다." }); - } - - const { userId: targetUserId } = req.query; - - if (!targetUserId) { - return res.status(400).json({ success: false, message: "userId 파라미터는 필수입니다." }); - } - - const rows = await query( - `SELECT * FROM approval_proxy_settings - WHERE original_user_id = $1 AND is_active = 'Y' - AND start_date <= CURRENT_DATE AND end_date >= CURRENT_DATE - AND company_code = $2`, - [targetUserId, companyCode] - ); - - return res.json({ success: true, data: rows }); - } catch (error) { - console.error("활성 대결자 조회 오류:", error); - return res.status(500).json({ - success: false, - message: "활성 대결자 조회 중 오류가 발생했습니다.", - error: error instanceof Error ? error.message : "알 수 없는 오류", - }); - } - } -} diff --git a/docs/결재시스템_v2_사용가이드.md b/docs/결재시스템_v2_사용가이드.md new file mode 100644 index 00000000..7fa686c4 --- /dev/null +++ b/docs/결재시스템_v2_사용가이드.md @@ -0,0 +1,380 @@ +# 결재 시스템 v2 사용 가이드 + +## 개요 + +결재 시스템 v2는 기존 순차결재(escalation) 외에 다양한 결재 방식을 지원합니다. + +| 결재 유형 | 코드 | 설명 | +|-----------|------|------| +| 순차결재 (기본) | `escalation` | 결재선 순서대로 한 명씩 처리 | +| 전결 (자기결재) | `self` | 상신자 본인이 직접 승인 (결재자 불필요) | +| 합의결재 | `consensus` | 같은 단계에 여러 결재자 → 전원 승인 필요 | +| 후결 | `post` | 먼저 실행 후 나중에 결재 (결재 전 상태에서도 업무 진행) | + +추가 기능: +- **대결 위임**: 부재 시 다른 사용자에게 결재 위임 +- **통보 단계**: 결재선에 통보만 하는 단계 (자동 승인 처리) +- **긴급도**: `normal` / `urgent` / `critical` +- **혼합형 결재선**: 한 결재선에 결재/합의/통보 단계를 자유롭게 조합 + +--- + +## DB 스키마 변경사항 + +### 마이그레이션 적용 + +```bash +# 개발 DB에 마이그레이션 적용 +psql -h 39.117.244.52 -p 11132 -U postgres -d plm -f db/migrations/1051_approval_system_v2.sql +psql -h 39.117.244.52 -p 11132 -U postgres -d plm -f db/migrations/1052_rename_proxy_id_to_id.sql +``` + +### 변경된 테이블 + +#### approval_requests (추가 컬럼) + +| 컬럼 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| approval_type | VARCHAR(20) | 'escalation' | self/escalation/consensus/post | +| is_post_approved | BOOLEAN | FALSE | 후결 처리 완료 여부 | +| post_approved_at | TIMESTAMPTZ | NULL | 후결 처리 시각 | +| urgency | VARCHAR(20) | 'normal' | normal/urgent/critical | + +#### approval_lines (추가 컬럼) + +| 컬럼 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| step_type | VARCHAR(20) | 'approval' | approval/consensus/notification | +| proxy_for | VARCHAR(50) | NULL | 대결 시 원래 결재자 ID | +| proxy_reason | TEXT | NULL | 대결 사유 | +| is_required | BOOLEAN | TRUE | 필수 결재 여부 | + +#### approval_proxy_settings (신규) + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| id | SERIAL PK | | +| company_code | VARCHAR(20) NOT NULL | | +| original_user_id | VARCHAR(50) | 원래 결재자 | +| proxy_user_id | VARCHAR(50) | 대결자 | +| start_date | DATE | 위임 시작일 | +| end_date | DATE | 위임 종료일 | +| reason | TEXT | 위임 사유 | +| is_active | CHAR(1) | 'Y'/'N' | + +--- + +## API 엔드포인트 + +모든 API는 `/api/approval` 접두사 + JWT 인증 필수. + +### 결재 요청 (Requests) + +| Method | Endpoint | 설명 | +|--------|----------|------| +| GET | `/requests` | 목록 조회 | +| GET | `/requests/:id` | 상세 조회 (lines 포함) | +| POST | `/requests` | 결재 요청 생성 | +| POST | `/requests/:id/cancel` | 결재 회수 | +| POST | `/requests/:id/post-approve` | 후결 처리 | + +#### 결재 요청 생성 Body + +```typescript +{ + title: string; + target_table: string; + target_record_id: string; + approval_type?: "self" | "escalation" | "consensus" | "post"; // 기본: escalation + urgency?: "normal" | "urgent" | "critical"; // 기본: normal + definition_id?: number; + target_record_data?: Record; + approvers: Array<{ + approver_id: string; + step_order: number; + step_type?: "approval" | "consensus" | "notification"; // 기본: approval + }>; +} +``` + +#### 결재 유형별 요청 예시 + +**전결 (self)**: 결재자 없이 본인 즉시 승인 + +```typescript +await createApprovalRequest({ + title: "긴급 출장비 전결", + target_table: "expense", + target_record_id: "123", + approval_type: "self", + approvers: [], +}); +``` + +**합의결재 (consensus)**: 같은 step_order에 여러 결재자 + +```typescript +await createApprovalRequest({ + title: "프로젝트 예산안 합의", + target_table: "budget", + target_record_id: "456", + approval_type: "consensus", + approvers: [ + { approver_id: "user1", step_order: 1, step_type: "consensus" }, + { approver_id: "user2", step_order: 1, step_type: "consensus" }, + { approver_id: "user3", step_order: 1, step_type: "consensus" }, + ], +}); +``` + +**혼합형 결재선**: 결재 → 합의 → 통보 조합 + +```typescript +await createApprovalRequest({ + title: "신규 채용 승인", + target_table: "recruitment", + target_record_id: "789", + approval_type: "escalation", + approvers: [ + { approver_id: "teamLead", step_order: 1, step_type: "approval" }, + { approver_id: "hrManager", step_order: 2, step_type: "consensus" }, + { approver_id: "cfo", step_order: 2, step_type: "consensus" }, + { approver_id: "ceo", step_order: 3, step_type: "approval" }, + { approver_id: "secretary", step_order: 4, step_type: "notification" }, + ], +}); +``` + +**후결 (post)**: 먼저 실행 후 나중에 결재 + +```typescript +await createApprovalRequest({ + title: "긴급 자재 발주", + target_table: "purchase_order", + target_record_id: "101", + approval_type: "post", + urgency: "urgent", + approvers: [ + { approver_id: "manager", step_order: 1, step_type: "approval" }, + ], +}); +``` + +### 결재 처리 (Lines) + +| Method | Endpoint | 설명 | +|--------|----------|------| +| GET | `/my-pending` | 내 결재 대기 목록 | +| POST | `/lines/:lineId/process` | 승인/반려 처리 | + +#### 승인/반려 Body + +```typescript +{ + action: "approved" | "rejected"; + comment?: string; + proxy_reason?: string; // 대결 시 사유 +} +``` + +대결 처리: 원래 결재자가 아닌 사용자가 처리하면 자동으로 대결 설정 확인 후 `proxy_for`, `proxy_reason` 기록. + +### 대결 위임 설정 (Proxy Settings) + +| Method | Endpoint | 설명 | +|--------|----------|------| +| GET | `/proxy-settings` | 위임 목록 | +| POST | `/proxy-settings` | 위임 생성 | +| PUT | `/proxy-settings/:id` | 위임 수정 | +| DELETE | `/proxy-settings/:id` | 위임 삭제 | +| GET | `/proxy-settings/check/:userId` | 활성 대결자 확인 | + +#### 대결 생성 Body + +```typescript +{ + original_user_id: string; + proxy_user_id: string; + start_date: string; // "2026-03-10" + end_date: string; // "2026-03-20" + reason?: string; + is_active?: "Y" | "N"; +} +``` + +### 템플릿 (Templates) + +| Method | Endpoint | 설명 | +|--------|----------|------| +| GET | `/templates` | 템플릿 목록 | +| GET | `/templates/:id` | 템플릿 상세 (steps 포함) | +| POST | `/templates` | 템플릿 생성 | +| PUT | `/templates/:id` | 템플릿 수정 | +| DELETE | `/templates/:id` | 템플릿 삭제 | + +--- + +## 프론트엔드 화면 + +### 1. 결재 요청 모달 (`ApprovalRequestModal`) + +경로: `frontend/components/approval/ApprovalRequestModal.tsx` + +- 결재 유형 선택: 상신결재 / 전결 / 합의결재 / 후결 +- 템플릿 불러오기: 등록된 템플릿에서 결재선 자동 세팅 +- 전결 시 결재자 섹션 숨김 + "본인이 직접 승인합니다" 안내 +- 합의결재 시 결재자 레이블 "합의 결재자"로 변경 +- 후결 시 안내 배너 표시 +- 혼합형 step_type 뱃지 표시 (결재/합의/통보) + +### 2. 결재함 (`/admin/approvalBox`) + +경로: `frontend/app/(main)/admin/approvalBox/page.tsx` + +탭 구성: +- **수신함**: 내가 결재할 건 목록 +- **상신함**: 내가 요청한 건 목록 +- **대결 설정**: 대결 위임 CRUD + +대결 설정 기능: +- 위임자/대결자 사용자 검색 (디바운스 300ms) +- 시작일/종료일 설정 +- 활성/비활성 토글 +- 기간 중복 체크 (서버 측) +- 등록/수정/삭제 모달 + +### 3. 결재 템플릿 관리 (`/admin/approvalTemplate`) + +경로: `frontend/app/(main)/admin/approvalTemplate/page.tsx` + +- 템플릿 목록/검색 +- 등록/수정 Dialog +- 단계별 결재 유형 설정 (결재/합의/통보) +- 합의 단계: "합의자 추가" 버튼으로 같은 step_order에 복수 결재자 +- 결재자 사용자 검색 + +### 4. 결재 단계 컴포넌트 (`v2-approval-step`) + +경로: `frontend/lib/registry/components/v2-approval-step/` + +화면 디자이너에서 사용하는 결재 단계 시각화 컴포넌트: +- 가로형/세로형 스테퍼 +- step_order 기준 그룹핑 (합의결재 시 가로 나열) +- step_type 아이콘: 결재(CheckCircle), 합의(Users), 통보(Bell) +- 상태별 색상: 승인(success), 반려(destructive), 대기(warning) +- 대결/후결/전결 뱃지 +- 긴급도 표시 (urgent: 주황 dot, critical: 빨강 배경) + +--- + +## API 클라이언트 사용법 + +```typescript +import { + // 결재 요청 + createApprovalRequest, + getApprovalRequests, + getApprovalRequest, + cancelApprovalRequest, + postApproveRequest, + + // 대결 위임 + getProxySettings, + createProxySetting, + updateProxySetting, + deleteProxySetting, + checkActiveProxy, + + // 템플릿 단계 + getTemplateSteps, + createTemplateStep, + updateTemplateStep, + deleteTemplateStep, + + // 타입 + type ApprovalProxySetting, + type CreateApprovalRequestInput, + type ApprovalLineTemplateStep, +} from "@/lib/api/approval"; +``` + +--- + +## 핵심 로직 설명 + +### 동시성 보호 (FOR UPDATE) + +결재 처리(`processApproval`)에서 동시 승인/반려 방지: + +```sql +SELECT * FROM approval_lines WHERE line_id = $1 FOR UPDATE +SELECT * FROM approval_requests WHERE request_id = $1 FOR UPDATE +``` + +### 대결 자동 감지 + +결재자가 아닌 사용자가 결재 처리하면: +1. `approval_proxy_settings`에서 활성 대결 설정 확인 +2. 대결 설정이 있으면 → `proxy_for`, `proxy_reason` 자동 기록 +3. 없으면 → 403 에러 + +### 통보 단계 자동 처리 + +`step_type = 'notification'`인 단계가 활성화되면: +1. 해당 단계의 모든 결재자를 자동 `approved` 처리 +2. `comment = '자동 통보 처리'` 기록 +3. `activateNextStep()` 재귀 호출로 다음 단계 진행 + +### 합의결재 단계 완료 판정 + +같은 `step_order`의 모든 결재자가 `approved`여야 다음 단계로: + +```sql +SELECT COUNT(*) FROM approval_lines +WHERE request_id = $1 AND step_order = $2 +AND status NOT IN ('approved', 'skipped') +``` + +하나라도 `rejected`면 전체 결재 반려. + +--- + +## 메뉴 등록 + +결재 관련 화면을 메뉴에 등록하려면: + +| 화면 | URL | 메뉴명 예시 | +|------|-----|-------------| +| 결재함 | `/admin/approvalBox` | 결재함 | +| 결재 템플릿 관리 | `/admin/approvalTemplate` | 결재 템플릿 | +| 결재 유형 관리 | `/admin/approvalMng` | 결재 유형 (기존) | + +--- + +## 파일 구조 + +``` +backend-node/src/ +├── controllers/ +│ ├── approvalController.ts # 결재 유형/템플릿/요청/라인 처리 +│ └── approvalProxyController.ts # 대결 위임 CRUD +└── routes/ + └── approvalRoutes.ts # 라우트 등록 + +frontend/ +├── app/(main)/admin/ +│ ├── approvalBox/page.tsx # 결재함 (수신/상신/대결) +│ ├── approvalTemplate/page.tsx # 템플릿 관리 +│ └── approvalMng/page.tsx # 결재 유형 관리 (기존) +├── components/approval/ +│ └── ApprovalRequestModal.tsx # 결재 요청 모달 +└── lib/ + ├── api/approval.ts # API 클라이언트 + └── registry/components/v2-approval-step/ + ├── ApprovalStepComponent.tsx # 결재 단계 시각화 + └── types.ts # 확장 타입 + +db/migrations/ +├── 1051_approval_system_v2.sql # v2 스키마 확장 +└── 1052_rename_proxy_id_to_id.sql # PK 컬럼명 통일 +``` diff --git a/frontend/components/approval/ApprovalRequestModal.tsx b/frontend/components/approval/ApprovalRequestModal.tsx index c5ebe876..3e7e8904 100644 --- a/frontend/components/approval/ApprovalRequestModal.tsx +++ b/frontend/components/approval/ApprovalRequestModal.tsx @@ -428,7 +428,7 @@ export const ApprovalRequestModal: React.FC = ({ {/* 후결 안내 배너 */} {approvalType === "post" && ( -
+
먼저 처리 후 나중에 결재받습니다. 결재 반려 시 별도 조치가 필요할 수 있습니다.
)} diff --git a/test-results/e2e-browser-test-177271953-bcfcc-접속-후-로그인-wace-qlalfqjsgh11-/error-context.md b/test-results/e2e-browser-test-177271953-bcfcc-접속-후-로그인-wace-qlalfqjsgh11-/error-context.md deleted file mode 100644 index 565e275b..00000000 --- a/test-results/e2e-browser-test-177271953-bcfcc-접속-후-로그인-wace-qlalfqjsgh11-/error-context.md +++ /dev/null @@ -1,62 +0,0 @@ -# Page snapshot - -```yaml -- generic [active] [ref=e1]: - - generic [ref=e2]: - - generic [ref=e4]: - - complementary [ref=e5]: - - img "WACE 솔루션 로고" [ref=e9] - - generic [ref=e11]: - - img [ref=e12] - - generic [ref=e16]: - - paragraph [ref=e17]: 현재 관리 회사 - - paragraph [ref=e18]: WACE (최고 관리자) - - generic [ref=e19]: - - button "관리자 메뉴로 전환" [ref=e20] [cursor=pointer]: - - img - - text: 관리자 메뉴로 전환 - - button "회사 선택" [ref=e21] [cursor=pointer]: - - img - - text: 회사 선택 - - navigation [ref=e23]: - - generic [ref=e25] [cursor=pointer]: - - generic [ref=e26]: - - img [ref=e27] - - generic "DTG 이력관리" [ref=e29] - - img [ref=e31] - - generic [ref=e34] [cursor=pointer]: - - generic [ref=e35]: - - img [ref=e36] - - generic "물류 통합관제" [ref=e39] - - img [ref=e41] - - generic [ref=e45] [cursor=pointer]: - - img [ref=e46] - - generic "카테고리" [ref=e49] - - generic [ref=e52] [cursor=pointer]: - - img [ref=e53] - - generic "견적관리 테스트" [ref=e56] - - generic [ref=e59] [cursor=pointer]: - - img [ref=e60] - - generic "피벗테스트" [ref=e63] - - button "관리자 관리자 해외영업부" [ref=e65] [cursor=pointer]: - - img "관리자" [ref=e67] - - generic [ref=e68]: - - paragraph [ref=e69]: 관리자 - - paragraph [ref=e70]: 해외영업부 - - main [ref=e71]: - - generic [ref=e75]: - - heading "Vexplor에 오신 것을 환영합니다!" [level=3] [ref=e76] - - paragraph [ref=e77]: 제품 수명 주기 관리 시스템을 통해 효율적인 업무를 시작하세요. - - generic [ref=e78]: - - generic [ref=e79]: Node.js - - generic [ref=e80]: Next.js - - generic [ref=e81]: Shadcn/ui - - region "Notifications alt+T" - - generic [ref=e82]: - - img [ref=e84] - - button "Open Tanstack query devtools" [ref=e132] [cursor=pointer]: - - img [ref=e133] - - button "Open Next.js Dev Tools" [ref=e186] [cursor=pointer]: - - img [ref=e187] - - alert [ref=e190] -``` \ No newline at end of file