diff --git a/.cursor/agents/pipeline-backend.md b/.cursor/agents/pipeline-backend.md new file mode 100644 index 00000000..6b4ff99c --- /dev/null +++ b/.cursor/agents/pipeline-backend.md @@ -0,0 +1,66 @@ +--- +name: pipeline-backend +description: Agent Pipeline 백엔드 전문가. Express + TypeScript + PostgreSQL Raw Query 기반 API 구현. 멀티테넌시(company_code) 필터링 필수. +model: inherit +--- + +# Role +You are a Backend specialist for ERP-node project. +Stack: Node.js + Express + TypeScript + PostgreSQL Raw Query. + +# CRITICAL PROJECT RULES + +## 1. Multi-tenancy (ABSOLUTE MUST!) +- ALL queries MUST include company_code filter +- Use req.user!.companyCode from auth middleware +- NEVER trust client-sent company_code +- Super Admin (company_code = "*") sees all data +- Regular users CANNOT see company_code = "*" data + +## 2. Required Code Pattern +```typescript +const companyCode = req.user!.companyCode; +if (companyCode === "*") { + query = "SELECT * FROM table ORDER BY company_code"; +} else { + query = "SELECT * FROM table WHERE company_code = $1 AND company_code != '*'"; + params = [companyCode]; +} +``` + +## 3. Controller Structure +```typescript +import { Request, Response } from "express"; +import pool from "../config/database"; +import { logger } from "../config/logger"; + +export const getList = async (req: Request, res: Response) => { + try { + const companyCode = req.user!.companyCode; + // ... company_code 분기 처리 + const result = await pool.query(query, params); + res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("조회 실패", error); + res.status(500).json({ success: false, message: error.message }); + } +}; +``` + +## 4. Route Registration +- backend-node/src/routes/index.ts에 import 추가 필수 +- authenticateToken 미들웨어 적용 필수 + +# Your Domain +- backend-node/src/controllers/ +- backend-node/src/services/ +- backend-node/src/routes/ +- backend-node/src/middleware/ + +# Code Rules +1. TypeScript strict mode +2. Error handling with try/catch +3. Comments in Korean +4. Follow existing code patterns +5. Use logger for important operations +6. Parameter binding ($1, $2) for SQL injection prevention 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/.cursor/agents/pipeline-db.md b/.cursor/agents/pipeline-db.md new file mode 100644 index 00000000..33e25218 --- /dev/null +++ b/.cursor/agents/pipeline-db.md @@ -0,0 +1,50 @@ +--- +name: pipeline-db +description: Agent Pipeline DB 전문가. PostgreSQL 스키마 설계, 마이그레이션 작성 및 실행. 모든 테이블에 company_code 필수. +model: inherit +--- + +# Role +You are a Database specialist for ERP-node project. +Stack: PostgreSQL + Raw Query (no ORM). Migrations in db/migrations/. + +# CRITICAL PROJECT RULES + +## 1. Multi-tenancy (ABSOLUTE MUST!) +- ALL tables MUST have company_code VARCHAR(20) NOT NULL +- ALL queries MUST filter by company_code +- JOINs MUST include company_code matching condition +- CREATE INDEX on company_code for every table + +## 2. Migration Rules +- File naming: NNN_description.sql +- Always include company_code column +- Always create index on company_code +- Use IF NOT EXISTS for idempotent migrations +- Use TIMESTAMPTZ for dates (not TIMESTAMP) + +## 3. MIGRATION EXECUTION (절대 규칙!) +마이그레이션 SQL 파일을 생성한 후, 반드시 직접 실행해서 테이블을 생성해라. +절대 사용자에게 "직접 실행해주세요"라고 떠넘기지 마라. + +Docker 환경: +```bash +DOCKER_HOST=unix:///Users/gbpark/.orbstack/run/docker.sock docker exec pms-backend-mac node -e " +const {Pool}=require('pg'); +const p=new Pool({connectionString:process.env.DATABASE_URL,ssl:false}); +const fs=require('fs'); +const sql=fs.readFileSync('/app/db/migrations/파일명.sql','utf8'); +p.query(sql).then(()=>{console.log('OK');p.end()}).catch(e=>{console.error(e.message);p.end();process.exit(1)}) +" +``` + +# Your Domain +- db/migrations/ +- SQL schema design +- Query optimization + +# Code Rules +1. PostgreSQL syntax only +2. Parameter binding ($1, $2) +3. Use COALESCE for NULL handling +4. Use TIMESTAMPTZ for dates diff --git a/.cursor/agents/pipeline-frontend.md b/.cursor/agents/pipeline-frontend.md new file mode 100644 index 00000000..0eef5611 --- /dev/null +++ b/.cursor/agents/pipeline-frontend.md @@ -0,0 +1,63 @@ +--- +name: pipeline-frontend +description: Agent Pipeline 프론트엔드 전문가. Next.js 14 + React + TypeScript + shadcn/ui 기반 화면 구현. fetch 직접 사용 금지, lib/api/ 클라이언트 필수. +model: inherit +--- + +# Role +You are a Frontend specialist for ERP-node project. +Stack: Next.js 14 + React + TypeScript + Tailwind CSS + shadcn/ui. + +# CRITICAL PROJECT RULES + +## 1. API Client (ABSOLUTE RULE!) +- NEVER use fetch() directly! +- ALWAYS use lib/api/ clients (Axios-based) +- 환경별 URL 자동 처리: v1.vexplor.com → api.vexplor.com, localhost → localhost:8080 + +## 2. shadcn/ui Style Rules +- Use CSS variables: bg-primary, text-muted-foreground (하드코딩 색상 금지) +- No nested boxes: Card inside Card is FORBIDDEN +- Responsive: mobile-first approach (flex-col md:flex-row) + +## 3. V2 Component Standard +V2 컴포넌트를 만들거나 수정할 때 반드시 이 규격을 따라야 한다. + +### 폴더 구조 (필수) +``` +frontend/lib/registry/components/v2-{name}/ +├── index.ts # createComponentDefinition() 호출 +├── types.ts # Config extends ComponentConfig +├── {Name}Component.tsx # React 함수 컴포넌트 +├── {Name}Renderer.tsx # extends AutoRegisteringComponentRenderer + registerSelf() +├── {Name}ConfigPanel.tsx # ConfigPanelBuilder 사용 +└── config.ts # 기본 설정값 상수 +``` + +### ConfigPanel 규칙 (절대!) +- 반드시 ConfigPanelBuilder 또는 ConfigSection 사용 +- 직접 JSX로 설정 UI 작성 금지 + +## 4. API Client 생성 패턴 +```typescript +// frontend/lib/api/yourModule.ts +import apiClient from "@/lib/api/client"; + +export async function getYourData(id: number) { + const response = await apiClient.get(`/api/your-endpoint/${id}`); + return response.data; +} +``` + +# Your Domain +- frontend/components/ +- frontend/app/ +- frontend/lib/ +- frontend/hooks/ + +# Code Rules +1. TypeScript strict mode +2. React functional components with hooks +3. Prefer shadcn/ui components +4. Use cn() utility for conditional classes +5. Comments in Korean diff --git a/.cursor/agents/pipeline-ui.md b/.cursor/agents/pipeline-ui.md new file mode 100644 index 00000000..44cf2daa --- /dev/null +++ b/.cursor/agents/pipeline-ui.md @@ -0,0 +1,50 @@ +--- +name: pipeline-ui +description: Agent Pipeline UI/UX 디자인 전문가. 모던 엔터프라이즈 UI 구현. CSS 변수 필수, 하드코딩 색상 금지, 반응형 필수. +model: inherit +--- + +# Role +You are a UI/UX Design specialist for the ERP-node project. +Stack: Next.js 14 + React + TypeScript + Tailwind CSS + shadcn/ui + lucide-react icons. + +# Design Philosophy +- Apple-level polish with enterprise functionality +- Consistent spacing, typography, color usage +- Subtle animations and micro-interactions +- Dark mode compatible using CSS variables + +# CRITICAL STYLE RULES + +## 1. Color System (CSS Variables ONLY) +- bg-background / text-foreground (base) +- bg-primary / text-primary-foreground (actions) +- bg-muted / text-muted-foreground (secondary) +- bg-destructive / text-destructive-foreground (danger) +FORBIDDEN: bg-gray-50, text-blue-500, bg-white, text-black + +## 2. Layout Rules +- No nested boxes (Card inside Card FORBIDDEN) +- Spacing: p-6 for cards, space-y-4 for forms, gap-4 for grids +- Mobile-first responsive: flex-col md:flex-row + +## 3. Typography +- Page title: text-3xl font-bold +- Section: text-xl font-semibold +- Body: text-sm +- Helper: text-xs text-muted-foreground + +## 4. Components +- ALWAYS use shadcn/ui components +- Use cn() for conditional classes +- Use lucide-react for ALL icons + +# Your Domain +- frontend/components/ (UI components) +- frontend/app/ (pages) + +# Output Rules +1. TypeScript strict mode +2. "use client" for client components +3. Comments in Korean +4. MINIMAL targeted changes when modifying existing files diff --git a/.cursor/agents/pipeline-verifier.md b/.cursor/agents/pipeline-verifier.md new file mode 100644 index 00000000..a4f4186d --- /dev/null +++ b/.cursor/agents/pipeline-verifier.md @@ -0,0 +1,57 @@ +--- +name: pipeline-verifier +description: Agent Pipeline 검증 전문가. 구현 완료 후 실제 동작 검증. 빈 껍데기 탐지, 패턴 준수 확인, 멀티테넌시 검증. +model: fast +readonly: true +--- + +# Role +You are a skeptical validator for the ERP-node project. +Your job is to verify that work claimed as complete actually works. + +# Verification Checklist + +## 1. Multi-tenancy (최우선) +- [ ] 모든 SQL에 company_code 필터 존재 +- [ ] req.user!.companyCode 사용 (클라이언트 입력 아님) +- [ ] INSERT에 company_code 포함 +- [ ] JOIN에 company_code 매칭 조건 존재 +- [ ] company_code = "*" 최고관리자 예외 처리 + +## 2. Empty Shell Detection (빈 껍데기) +- [ ] API가 실제 DB 쿼리 실행 (mock 아님) +- [ ] 컴포넌트가 실제 데이터 로딩 (하드코딩 아님) +- [ ] TODO/FIXME/placeholder 없음 +- [ ] 타입만 정의하고 구현 없는 함수 없음 + +## 3. Pattern Compliance (패턴 준수) +- [ ] Frontend: fetch 직접 사용 안 함 (lib/api/ 사용) +- [ ] Frontend: CSS 변수 사용 (하드코딩 색상 없음) +- [ ] Frontend: V2 컴포넌트 규격 준수 +- [ ] Backend: logger 사용 +- [ ] Backend: try/catch 에러 처리 + +## 4. Integration Check +- [ ] Route가 index.ts에 등록됨 +- [ ] Import 경로 정확 +- [ ] Export 존재 +- [ ] TypeScript 타입 일치 + +# Reporting Format +``` +## 검증 결과: [PASS/FAIL] + +### 통과 항목 +- item 1 +- item 2 + +### 실패 항목 +- item 1: 구체적 이유 +- item 2: 구체적 이유 + +### 권장 수정사항 +- fix 1 +- fix 2 +``` + +Do not accept claims at face value. Check the actual code. diff --git a/.cursor/worktrees.json b/.cursor/worktrees.json new file mode 100644 index 00000000..d568a8fe --- /dev/null +++ b/.cursor/worktrees.json @@ -0,0 +1,8 @@ +{ + "setup-worktree-unix": [ + "cd backend-node && npm ci --prefer-offline --no-audit 2>/dev/null || npm install --prefer-offline --no-audit", + "cd frontend && npm ci --prefer-offline --no-audit 2>/dev/null || npm install --prefer-offline --no-audit", + "cp $ROOT_WORKTREE_PATH/backend-node/.env backend-node/.env 2>/dev/null || true", + "cp $ROOT_WORKTREE_PATH/frontend/.env.local frontend/.env.local 2>/dev/null || true" + ] +} diff --git a/approval-company7-report.txt b/approval-company7-report.txt new file mode 100644 index 00000000..57760435 --- /dev/null +++ b/approval-company7-report.txt @@ -0,0 +1,33 @@ + +=== Step 1: 로그인 (topseal_admin) === + 현재 URL: http://localhost:9771/screens/138 + 스크린샷: 01-after-login.png + OK: 로그인 완료 + +=== Step 2: 발주관리 화면 이동 === + 스크린샷: 02-po-screen.png + OK: 발주관리 화면 로드 + +=== Step 3: 그리드 컬럼 및 데이터 확인 === + 컬럼 헤더 (전체): ["결재상태","발주번호","품목코드","품목명","규격","발주수량","출하수량","단위","구분","유형","재질","규격","품명"] + 첫 번째 컬럼: "결재상태" + 결재상태(한글) 표시됨 + 데이터 행 수: 11 + 데이터 있음 + 첫 번째 컬럼 값(샘플): ["","","","",""] + 발주번호 형식 데이터: ["PO-2026-0001","PO-2026-0001","PO-2026-0001","PO-2026-0045","PO-2026-0045"] + 스크린샷: 03-grid-detail.png + OK: 그리드 상세 스크린샷 저장 + +=== Step 4: 결재 요청 버튼 확인 === + OK: '결재 요청' 파란색 버튼 확인됨 + 스크린샷: 04-approval-button.png + +=== Step 5: 행 선택 후 결재 요청 === + OK: 행 선택 완료 + 스크린샷: 05-approval-modal.png + OK: 결재 모달 열림 + 스크린샷: 06-approver-search-results.png + 결재자 검색 결과: 8명 + 결재자 목록: ["상신결재","다단 결재순차적으로 결재","동시 결재모든 결재자 동시 진행","TEST(Kim1542)김동현","김혜인(qwer0578)배달집행부 / 차장","김지수(area09)배달집행부 / 대리","김한길(qoznd123)배달집행부 / 과장","김하세(kaoe123)배달집행부 / 사원"] + 스크린샷: 07-final.png \ No newline at end of file diff --git a/approval-test-report.txt b/approval-test-report.txt new file mode 100644 index 00000000..4a2e6386 --- /dev/null +++ b/approval-test-report.txt @@ -0,0 +1,29 @@ + +=== Step 1: 로그인 === + 스크린샷: 01-login-page.png + 스크린샷: 02-after-login.png + OK: 로그인 완료, 대시보드 로드 + +=== Step 2: 구매관리 → 발주관리 메뉴 이동 === + INFO: 메뉴에서 발주관리 미발견, 직접 URL로 이동 + 메뉴 목록: ["관리자 메뉴로 전환","회사 선택","관리자해외영업부"] + 스크린샷: 04-po-screen-loaded.png + OK: /screen/COMPANY_7_064 직접 이동 완료 + +=== Step 3: 그리드 컬럼 확인 === + 스크린샷: 05-grid-columns.png + 컬럼 목록: ["approval_status","발주번호","품목코드","품목명","규격","발주수량","출하","단위","구분","유형","재질","규격","품명"] + FAIL: '결재상태' 컬럼 없음 + 결재상태 값: 데이터 없음 또는 해당 값 없음 + +=== Step 4: 행 선택 및 결재 요청 버튼 클릭 === + 스크린샷: 06-row-selected.png + OK: 첫 번째 행 선택 + 스크린샷: 07-approval-modal-opened.png + OK: 결재 모달 열림 + +=== Step 5: 결재자 검색 테스트 === + 스크린샷: 08-approver-search-results.png + 검색 결과 수: 12명 + 결재자 목록: ["상신결재","템플릿","다단 결재순차적으로 결재","동시 결재모든 결재자 동시 진행","김동열(drkim)-","김아름(qwe123)생산부 / 차장","TEST(Kim1542)김동현","김혜인(qwer0578)배달집행부 / 차장","김욱동(dnrehd0171)-","김지수(area09)배달집행부 / 대리"] + 스크린샷: 09-final-state.png \ No newline at end of file diff --git a/backend-node/package-lock.json b/backend-node/package-lock.json index 8b97b9ed..f482dc7b 100644 --- a/backend-node/package-lock.json +++ b/backend-node/package-lock.json @@ -1046,7 +1046,6 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -2374,7 +2373,6 @@ "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", "license": "MIT", - "peer": true, "dependencies": { "cluster-key-slot": "1.1.2", "generic-pool": "3.9.0", @@ -3487,7 +3485,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz", "integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3724,7 +3721,6 @@ "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -3942,7 +3938,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4468,7 +4463,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001741", @@ -5679,7 +5673,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -5958,7 +5951,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -7494,7 +7486,6 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -8464,6 +8455,7 @@ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "license": "MIT", + "peer": true, "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, @@ -9351,7 +9343,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", @@ -10207,6 +10198,7 @@ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" } @@ -11014,7 +11006,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -11120,7 +11111,6 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/backend-node/scripts/run-1050-migration.js b/backend-node/scripts/run-1050-migration.js new file mode 100644 index 00000000..aa1b3723 --- /dev/null +++ b/backend-node/scripts/run-1050-migration.js @@ -0,0 +1,35 @@ +/** + * system_notice 테이블 생성 마이그레이션 실행 + */ +const { Pool } = require('pg'); +const fs = require('fs'); +const path = require('path'); + +const pool = new Pool({ + connectionString: process.env.DATABASE_URL || 'postgresql://postgres:ph0909!!@39.117.244.52:11132/plm', + ssl: false, +}); + +async function run() { + const client = await pool.connect(); + try { + const sqlPath = path.join(__dirname, '../../db/migrations/1050_create_system_notice.sql'); + const sql = fs.readFileSync(sqlPath, 'utf8'); + await client.query(sql); + console.log('OK: system_notice 테이블 생성 완료'); + + // 검증 + const result = await client.query( + "SELECT column_name FROM information_schema.columns WHERE table_name='system_notice' ORDER BY ordinal_position" + ); + console.log('컬럼:', result.rows.map(r => r.column_name).join(', ')); + } catch (e) { + console.error('ERROR:', e.message); + process.exit(1); + } finally { + client.release(); + await pool.end(); + } +} + +run(); diff --git a/backend-node/scripts/run-notice-migration.js b/backend-node/scripts/run-notice-migration.js new file mode 100644 index 00000000..4b23153d --- /dev/null +++ b/backend-node/scripts/run-notice-migration.js @@ -0,0 +1,38 @@ +/** + * system_notice 마이그레이션 실행 스크립트 + * 사용법: node scripts/run-notice-migration.js + */ +const fs = require('fs'); +const path = require('path'); +const { Pool } = require('pg'); + +const pool = new Pool({ + connectionString: process.env.DATABASE_URL || 'postgresql://postgres:ph0909!!@39.117.244.52:11132/plm', + ssl: false, +}); + +async function run() { + const client = await pool.connect(); + try { + const sqlPath = path.join(__dirname, '../../db/migrations/1050_create_system_notice.sql'); + const sql = fs.readFileSync(sqlPath, 'utf8'); + + console.log('마이그레이션 실행 중...'); + await client.query(sql); + console.log('마이그레이션 완료'); + + // 컬럼 확인 + const check = await client.query( + "SELECT column_name FROM information_schema.columns WHERE table_name='system_notice' ORDER BY ordinal_position" + ); + console.log('테이블 컬럼:', check.rows.map(r => r.column_name).join(', ')); + } catch (e) { + console.error('오류:', e.message); + process.exit(1); + } finally { + client.release(); + await pool.end(); + } +} + +run(); diff --git a/backend-node/src/controllers/approvalController.ts b/backend-node/src/controllers/approvalController.ts index 84231245..eabe77ce 100644 --- a/backend-node/src/controllers/approvalController.ts +++ b/backend-node/src/controllers/approvalController.ts @@ -1,6 +1,16 @@ import { Response } from "express"; import { AuthenticatedRequest } from "../types/auth"; import { query, queryOne, transaction } from "../database/db"; +import { PoolClient } from "pg"; +import { NodeFlowExecutionService } from "../services/nodeFlowExecutionService"; + +// 트랜잭션 내부에서 throw하고 외부에서 instanceof로 구분하기 위한 커스텀 에러 +class ValidationError extends Error { + constructor(public statusCode: number, message: string) { + super(message); + this.name = "ValidationError"; + } +} // ============================================================ // 결재 정의 (Approval Definitions) CRUD @@ -17,24 +27,34 @@ export class ApprovalDefinitionController { const { is_active, search } = req.query; - const conditions: string[] = ["company_code = $1"]; - const params: any[] = [companyCode]; - let idx = 2; + const conditions: string[] = []; + const params: any[] = []; + let idx = 1; + + // SUPER_ADMIN은 전체 조회, 일반 회사는 자사 데이터만 + if (companyCode === "*") { + // 전체 조회 (company_code 필터 없음) + } else { + conditions.push(`company_code = $${idx++}`); + params.push(companyCode); + } if (is_active) { - conditions.push(`is_active = $${idx}`); + conditions.push(`is_active = $${idx++}`); params.push(is_active); - idx++; } if (search) { + // ILIKE에서 같은 파라미터를 두 조건에서 참조 (파라미터는 1개만 push) conditions.push(`(definition_name ILIKE $${idx} OR definition_name_eng ILIKE $${idx})`); params.push(`%${search}%`); idx++; } + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + const rows = await query( - `SELECT * FROM approval_definitions WHERE ${conditions.join(" AND ")} ORDER BY definition_id ASC`, + `SELECT * FROM approval_definitions ${whereClause} ORDER BY company_code, definition_id ASC`, params ); @@ -58,9 +78,12 @@ export class ApprovalDefinitionController { } const { id } = req.params; + // SUPER_ADMIN은 company_code 필터 없이 조회 가능 const row = await queryOne( - "SELECT * FROM approval_definitions WHERE definition_id = $1 AND company_code = $2", - [id, companyCode] + companyCode === "*" + ? "SELECT * FROM approval_definitions WHERE definition_id = $1" + : "SELECT * FROM approval_definitions WHERE definition_id = $1 AND company_code = $2", + companyCode === "*" ? [id] : [id, companyCode] ); if (!row) { @@ -165,11 +188,15 @@ export class ApprovalDefinitionController { fields.push(`updated_by = $${idx++}`, `updated_at = NOW()`); params.push(req.user?.userId || "system"); + + // WHERE 절 파라미터 인덱스를 미리 계산 (쿼리 문자열 내 idx++ 호출 순서 보장) + const idIdx = idx++; + const ccIdx = idx++; params.push(id, companyCode); const [row] = await query( `UPDATE approval_definitions SET ${fields.join(", ")} - WHERE definition_id = $${idx++} AND company_code = $${idx++} RETURNING *`, + WHERE definition_id = $${idIdx} AND company_code = $${ccIdx} RETURNING *`, params ); @@ -234,9 +261,15 @@ export class ApprovalTemplateController { const { definition_id, is_active } = req.query; - const conditions: string[] = ["t.company_code = $1"]; - const params: any[] = [companyCode]; - let idx = 2; + const conditions: string[] = []; + const params: any[] = []; + let idx = 1; + + // SUPER_ADMIN은 전체 조회, 일반 회사는 자사 데이터만 + if (companyCode !== "*") { + conditions.push(`t.company_code = $${idx++}`); + params.push(companyCode); + } if (definition_id) { conditions.push(`t.definition_id = $${idx++}`); @@ -247,12 +280,14 @@ export class ApprovalTemplateController { params.push(is_active); } + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + const rows = await query( `SELECT t.*, d.definition_name FROM approval_line_templates t LEFT JOIN approval_definitions d ON t.definition_id = d.definition_id AND t.company_code = d.company_code - WHERE ${conditions.join(" AND ")} - ORDER BY t.template_id ASC`, + ${whereClause} + ORDER BY t.company_code, t.template_id ASC`, params ); @@ -276,12 +311,18 @@ export class ApprovalTemplateController { } const { id } = req.params; + // SUPER_ADMIN은 company_code 필터 없이 조회 가능 const template = await queryOne( - `SELECT t.*, d.definition_name - FROM approval_line_templates t - LEFT JOIN approval_definitions d ON t.definition_id = d.definition_id AND t.company_code = d.company_code - WHERE t.template_id = $1 AND t.company_code = $2`, - [id, companyCode] + companyCode === "*" + ? `SELECT t.*, d.definition_name + FROM approval_line_templates t + LEFT JOIN approval_definitions d ON t.definition_id = d.definition_id AND t.company_code = d.company_code + WHERE t.template_id = $1` + : `SELECT t.*, d.definition_name + FROM approval_line_templates t + LEFT JOIN approval_definitions d ON t.definition_id = d.definition_id AND t.company_code = d.company_code + WHERE t.template_id = $1 AND t.company_code = $2`, + companyCode === "*" ? [id] : [id, companyCode] ); if (!template) { @@ -289,8 +330,10 @@ export class ApprovalTemplateController { } const steps = await query( - "SELECT * FROM approval_line_template_steps WHERE template_id = $1 AND company_code = $2 ORDER BY step_order ASC", - [id, companyCode] + companyCode === "*" + ? "SELECT * FROM approval_line_template_steps WHERE template_id = $1 ORDER BY step_order ASC" + : "SELECT * FROM approval_line_template_steps WHERE template_id = $1 AND company_code = $2 ORDER BY step_order ASC", + companyCode === "*" ? [id] : [id, companyCode] ); return res.json({ success: true, data: { ...template, steps } }); @@ -312,7 +355,7 @@ export class ApprovalTemplateController { return res.status(401).json({ success: false, message: "인증 정보가 없습니다." }); } - const { template_name, description, definition_id, is_active = "Y", steps = [] } = req.body; + const { template_name, description, definition_id, after_approval_flow_id, is_active = "Y", steps = [] } = req.body; if (!template_name) { return res.status(400).json({ success: false, message: "템플릿명은 필수입니다." }); @@ -323,9 +366,9 @@ export class ApprovalTemplateController { let result: any; await transaction(async (client) => { const { rows } = await client.query( - `INSERT INTO approval_line_templates (template_name, description, definition_id, is_active, company_code, created_by, updated_by) - VALUES ($1, $2, $3, $4, $5, $6, $6) RETURNING *`, - [template_name, description, definition_id, is_active, companyCode, userId] + `INSERT INTO approval_line_templates (template_name, description, definition_id, after_approval_flow_id, is_active, company_code, created_by, updated_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $7) RETURNING *`, + [template_name, description, definition_id, after_approval_flow_id || null, is_active, companyCode, userId] ); result = rows[0]; @@ -380,7 +423,7 @@ export class ApprovalTemplateController { return res.status(404).json({ success: false, message: "결재선 템플릿을 찾을 수 없습니다." }); } - const { template_name, description, definition_id, is_active, steps } = req.body; + const { template_name, description, definition_id, after_approval_flow_id, is_active, steps } = req.body; const userId = req.user?.userId || "system"; let result: any; @@ -392,13 +435,19 @@ export class ApprovalTemplateController { if (template_name !== undefined) { fields.push(`template_name = $${idx++}`); params.push(template_name); } if (description !== undefined) { fields.push(`description = $${idx++}`); params.push(description); } if (definition_id !== undefined) { fields.push(`definition_id = $${idx++}`); params.push(definition_id); } + if (after_approval_flow_id !== undefined) { fields.push(`after_approval_flow_id = $${idx++}`); params.push(after_approval_flow_id); } if (is_active !== undefined) { fields.push(`is_active = $${idx++}`); params.push(is_active); } fields.push(`updated_by = $${idx++}`, `updated_at = NOW()`); - params.push(userId, id, companyCode); + params.push(userId); + + // WHERE 절 파라미터 인덱스를 미리 계산 + const tmplIdx = idx++; + const ccIdx = idx++; + params.push(id, companyCode); const { rows } = await client.query( `UPDATE approval_line_templates SET ${fields.join(", ")} - WHERE template_id = $${idx++} AND company_code = $${idx++} RETURNING *`, + WHERE template_id = $${tmplIdx} AND company_code = $${ccIdx} RETURNING *`, params ); result = rows[0]; @@ -467,6 +516,216 @@ export class ApprovalTemplateController { } } +// ============================================================ +// 다음 step 활성화 헬퍼 (혼합형 결재선 대응) +// notification step은 자동 통과 후 재귀적으로 다음 step 진행 +// ============================================================ + +// 결재 상태 변경 시 원본 테이블(target_table)의 approval_status를 동기화하는 범용 hook +async function syncApprovalStatusToTarget( + client: PoolClient, + requestId: number, + newStatus: string, + companyCode: string, +): Promise { + try { + const { rows: [req] } = await client.query( + `SELECT target_table, target_record_id FROM approval_requests WHERE request_id = $1 AND company_code = $2`, + [requestId, companyCode], + ); + if (!req?.target_table || !req?.target_record_id || req.target_record_id === "0") return; + + const { rows: cols } = await client.query( + `SELECT 1 FROM information_schema.columns WHERE table_name = $1 AND column_name = 'approval_status'`, + [req.target_table], + ); + if (cols.length === 0) return; + + const statusMap: Record = { + in_progress: "결재중", + approved: "결재완료", + rejected: "반려", + cancelled: "작성중", + draft: "작성중", + post_pending: "후결대기", + }; + + const businessStatus = statusMap[newStatus] || newStatus; + const safeTable = req.target_table.replace(/[^a-zA-Z0-9_]/g, ""); + + // super admin(company_code='*')은 다른 회사 레코드도 업데이트 가능 + if (companyCode === "*") { + await client.query( + `UPDATE "${safeTable}" SET approval_status = $1 WHERE id = $2`, + [businessStatus, req.target_record_id], + ); + } else { + await client.query( + `UPDATE "${safeTable}" SET approval_status = $1 WHERE id = $2 AND company_code = $3`, + [businessStatus, req.target_record_id, companyCode], + ); + } + + // 결재 완료(approved) 시 제어관리(노드 플로우) 자동 실행 + if (newStatus === "approved") { + await executeAfterApprovalFlow(client, requestId, companyCode, req); + } + } catch (err) { + console.error("[syncApprovalStatusToTarget] 원본 테이블 상태 동기화 실패:", err); + } +} + +// 결재 완료 후 제어관리(노드 플로우) 실행 hook +// 우선순위: 템플릿(template) > 정의(definition) > 요청(request) 직접 지정 +async function executeAfterApprovalFlow( + client: PoolClient, + requestId: number, + companyCode: string, + approvalReq: { target_table: string; target_record_id: string }, +): Promise { + try { + const { rows: [reqData] } = await client.query( + `SELECT r.after_approval_flow_id, r.definition_id, r.template_id, r.title, r.requester_id + FROM approval_requests r WHERE r.request_id = $1`, + [requestId], + ); + + let flowId: number | null = null; + + // 1순위: 템플릿에 연결된 제어관리 플로우 + if (reqData?.template_id) { + const { rows: [tmpl] } = await client.query( + `SELECT after_approval_flow_id FROM approval_line_templates WHERE template_id = $1`, + [reqData.template_id], + ); + flowId = tmpl?.after_approval_flow_id || null; + } + + // 2순위: 정의(definition)에 연결된 제어관리 플로우 (fallback) + if (!flowId && reqData?.definition_id) { + const { rows: [def] } = await client.query( + `SELECT after_approval_flow_id FROM approval_definitions WHERE definition_id = $1`, + [reqData.definition_id], + ); + flowId = def?.after_approval_flow_id || null; + } + + // 3순위: 요청 자체에 직접 지정된 플로우 + if (!flowId) { + flowId = reqData?.after_approval_flow_id || null; + } + + if (!flowId) return; + + // 3. 원본 레코드 데이터 조회 + const safeTable = approvalReq.target_table.replace(/[^a-zA-Z0-9_]/g, ""); + const { rows: [targetRecord] } = await client.query( + `SELECT * FROM "${safeTable}" WHERE id = $1`, + [approvalReq.target_record_id], + ); + + // 4. 노드 플로우 실행 + console.log(`[제어관리] 결재 완료 후 플로우 #${flowId} 실행 (request_id=${requestId})`); + const result = await NodeFlowExecutionService.executeFlow(flowId, { + formData: targetRecord || {}, + approvalInfo: { + requestId, + title: reqData.title, + requesterId: reqData.requester_id, + targetTable: approvalReq.target_table, + targetRecordId: approvalReq.target_record_id, + }, + companyCode, + selectedRows: targetRecord ? [targetRecord] : [], + }); + + console.log(`[제어관리] 플로우 #${flowId} 실행 결과: ${result.success ? "성공" : "실패"} (${result.executionTime}ms)`); + } catch (err) { + // 제어관리 실패는 결재 승인 자체에 영향 주지 않음 + console.error("[executeAfterApprovalFlow] 제어관리 실행 실패:", err); + } +} + +async function activateNextStep( + client: PoolClient, + requestId: number, + currentStep: number, + totalSteps: number, + companyCode: string, + userId: string, + comment: string | null, +): Promise { + const nextStep = currentStep + 1; + + if (nextStep > totalSteps) { + // 최종 승인 처리 + await client.query( + `UPDATE approval_requests + SET status = CASE WHEN approval_type = 'post' THEN 'approved' ELSE 'approved' END, + is_post_approved = CASE WHEN approval_type = 'post' THEN true ELSE is_post_approved END, + post_approved_at = CASE WHEN approval_type = 'post' THEN NOW() ELSE post_approved_at END, + final_approver_id = $1, final_comment = $2, completed_at = NOW(), updated_at = NOW() + WHERE request_id = $3 AND company_code = $4`, + [userId, comment, requestId, companyCode] + ); + await syncApprovalStatusToTarget(client, requestId, "approved", companyCode); + return; + } + + // 다음 step의 결재 라인 조회 (FOR UPDATE로 동시성 방어) + const { rows: nextLines } = await client.query( + `SELECT * FROM approval_lines + WHERE request_id = $1 AND step_order = $2 AND company_code = $3 + FOR UPDATE`, + [requestId, nextStep, companyCode] + ); + + if (nextLines.length === 0) { + // 다음 step이 비어있으면 최종 승인 처리 + await client.query( + `UPDATE approval_requests + SET status = 'approved', final_approver_id = $1, final_comment = $2, + completed_at = NOW(), updated_at = NOW() + WHERE request_id = $3 AND company_code = $4`, + [userId, comment, requestId, companyCode] + ); + await syncApprovalStatusToTarget(client, requestId, "approved", companyCode); + return; + } + + const nextStepType = nextLines[0].step_type || "approval"; + + if (nextStepType === "notification") { + // 통보 단계: 자동 approved 처리 후 다음 step으로 재귀 + for (const nl of nextLines) { + await client.query( + `UPDATE approval_lines SET status = 'approved', comment = '자동 통보 처리', processed_at = NOW() + WHERE line_id = $1 AND company_code = $2`, + [nl.line_id, companyCode] + ); + } + await client.query( + `UPDATE approval_requests SET current_step = $1, updated_at = NOW() + WHERE request_id = $2 AND company_code = $3`, + [nextStep, requestId, companyCode] + ); + // 재귀: 통보 다음 step 활성화 + await activateNextStep(client, requestId, nextStep, totalSteps, companyCode, userId, comment); + } else { + // approval 또는 consensus: pending으로 전환 + await client.query( + `UPDATE approval_lines SET status = 'pending' + WHERE request_id = $1 AND step_order = $2 AND company_code = $3`, + [requestId, nextStep, companyCode] + ); + await client.query( + `UPDATE approval_requests SET current_step = $1, updated_at = NOW() + WHERE request_id = $2 AND company_code = $3`, + [nextStep, requestId, companyCode] + ); + } +} + // ============================================================ // 결재 요청 (Approval Requests) CRUD // ============================================================ @@ -483,9 +742,15 @@ export class ApprovalRequestController { const { status, target_table, target_record_id, requester_id, my_approvals, page = "1", limit = "20" } = req.query; - const conditions: string[] = ["r.company_code = $1"]; - const params: any[] = [companyCode]; - let idx = 2; + const conditions: string[] = []; + const params: any[] = []; + let idx = 1; + + // SUPER_ADMIN은 전체 조회, 일반 회사는 자사 데이터만 + if (companyCode !== "*") { + conditions.push(`r.company_code = $${idx++}`); + params.push(companyCode); + } if (status) { conditions.push(`r.status = $${idx++}`); @@ -513,26 +778,31 @@ export class ApprovalRequestController { } const offset = (parseInt(page as string) - 1) * parseInt(limit as string); + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + + // countParams는 WHERE 조건 파라미터만 포함 (LIMIT/OFFSET 제외) + // my_approvals 파라미터도 포함된 후 복사해야 함 + const countParams = [...params]; + const [countRow] = await query( + `SELECT COUNT(*) as total FROM approval_requests r ${whereClause}`, + countParams + ); + + // LIMIT/OFFSET 파라미터 인덱스를 미리 계산 (countParams 복사 후에 idx 증가) + const limitIdx = idx++; + const offsetIdx = idx++; params.push(parseInt(limit as string), offset); const rows = await query( `SELECT r.*, d.definition_name FROM approval_requests r LEFT JOIN approval_definitions d ON r.definition_id = d.definition_id AND r.company_code = d.company_code - WHERE ${conditions.join(" AND ")} + ${whereClause} ORDER BY r.created_at DESC - LIMIT $${idx++} OFFSET $${idx++}`, + LIMIT $${limitIdx} OFFSET $${offsetIdx}`, params ); - // 전체 건수 조회 - const countParams = params.slice(0, params.length - 2); - const [countRow] = await query( - `SELECT COUNT(*) as total FROM approval_requests r - WHERE ${conditions.join(" AND ")}`, - countParams - ); - return res.json({ success: true, data: rows, @@ -559,12 +829,18 @@ export class ApprovalRequestController { } const { id } = req.params; + // SUPER_ADMIN은 company_code 필터 없이 모든 요청 조회 가능 const request = await queryOne( - `SELECT r.*, d.definition_name - FROM approval_requests r - LEFT JOIN approval_definitions d ON r.definition_id = d.definition_id AND r.company_code = d.company_code - WHERE r.request_id = $1 AND r.company_code = $2`, - [id, companyCode] + companyCode === "*" + ? `SELECT r.*, d.definition_name + FROM approval_requests r + LEFT JOIN approval_definitions d ON r.definition_id = d.definition_id AND r.company_code = d.company_code + WHERE r.request_id = $1` + : `SELECT r.*, d.definition_name + FROM approval_requests r + LEFT JOIN approval_definitions d ON r.definition_id = d.definition_id AND r.company_code = d.company_code + WHERE r.request_id = $1 AND r.company_code = $2`, + companyCode === "*" ? [id] : [id, companyCode] ); if (!request) { @@ -572,8 +848,10 @@ export class ApprovalRequestController { } const lines = await query( - "SELECT * FROM approval_lines WHERE request_id = $1 AND company_code = $2 ORDER BY step_order ASC", - [id, companyCode] + companyCode === "*" + ? "SELECT * FROM approval_lines WHERE request_id = $1 ORDER BY step_order ASC" + : "SELECT * FROM approval_lines WHERE request_id = $1 AND company_code = $2 ORDER BY step_order ASC", + companyCode === "*" ? [id] : [id, companyCode] ); return res.json({ success: true, data: { ...request, lines } }); @@ -587,7 +865,7 @@ export class ApprovalRequestController { } } - // 결재 요청 생성 (결재 라인 자동 생성) + // 결재 요청 생성 (혼합형 결재선 지원 - self/escalation/consensus/post) static async createRequest(req: AuthenticatedRequest, res: Response) { try { const companyCode = req.user?.companyCode; @@ -596,85 +874,238 @@ export class ApprovalRequestController { } const { - title, description, definition_id, target_table, target_record_id, + title, description, definition_id, template_id, target_table, target_record_id, target_record_data, screen_id, button_component_id, - approvers, // [{ approver_id, approver_name, approver_position, approver_dept, approver_label }] - approval_mode, // "sequential" | "parallel" + approvers, + approval_mode, + approval_type = "escalation", } = req.body; if (!title || !target_table) { return res.status(400).json({ success: false, message: "제목과 대상 테이블은 필수입니다." }); } - if (!Array.isArray(approvers) || approvers.length === 0) { - return res.status(400).json({ success: false, message: "결재자를 1명 이상 지정해야 합니다." }); - } + // target_record_id는 NOT NULL 컬럼이므로 빈 값은 기본값으로 대체 + const safeTargetRecordId = target_record_id || "0"; const userId = req.user?.userId || "system"; const userName = req.user?.userName || ""; const deptName = req.user?.deptName || ""; - const isParallel = approval_mode === "parallel"; - const totalSteps = approvers.length; - - // approval_mode를 target_record_data에 병합 저장 + // approval_mode를 target_record_data에 병합 저장 (하위호환) const mergedRecordData = { ...(target_record_data || {}), approval_mode: approval_mode || "sequential", }; + // ========== 자기결재(전결) ========== + if (approval_type === "self") { + // definition_id가 있으면 allow_self_approval 체크 + if (definition_id) { + const def = await queryOne( + "SELECT allow_self_approval FROM approval_definitions WHERE definition_id = $1 AND company_code = $2", + [definition_id, companyCode] + ); + if (def && !def.allow_self_approval) { + return res.status(400).json({ success: false, message: "해당 결재 유형은 자기결재(전결)를 허용하지 않습니다." }); + } + } + + let result: any; + await transaction(async (client) => { + const { rows: reqRows } = await client.query( + `INSERT INTO approval_requests ( + title, description, definition_id, template_id, target_table, target_record_id, + target_record_data, status, current_step, total_steps, approval_type, + requester_id, requester_name, requester_dept, + screen_id, button_component_id, company_code, + final_approver_id, completed_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, 'approved', 1, 1, 'self', + $8, $9, $10, $11, $12, $13, $8, NOW()) + RETURNING *`, + [ + title, description, definition_id, template_id || null, target_table, safeTargetRecordId, + JSON.stringify(mergedRecordData), + userId, userName, deptName, + screen_id, button_component_id, companyCode, + ] + ); + result = reqRows[0]; + + // 본인을 결재자로 INSERT (이미 approved) + await client.query( + `INSERT INTO approval_lines ( + request_id, step_order, approver_id, approver_name, approver_position, + approver_dept, approver_label, status, step_type, processed_at, company_code + ) VALUES ($1, 1, $2, $3, $4, $5, '자기결재', 'approved', 'approval', NOW(), $6)`, + [result.request_id, userId, userName, req.user?.positionName || null, deptName, companyCode] + ); + + await syncApprovalStatusToTarget(client, result.request_id, "approved", companyCode); + }); + + return res.status(201).json({ success: true, data: result, message: "자기결재(전결) 처리되었습니다." }); + } + + // ========== 그 외 유형: approvers 필수 검증 ========== + if (!Array.isArray(approvers) || approvers.length === 0) { + return res.status(400).json({ success: false, message: "결재자를 1명 이상 지정해야 합니다." }); + } + + // 각 approver에 step_type/step_order 할당 (혼합형 지원) + const hasExplicitStepType = approvers.some((a: any) => a.step_type); + + interface NormalizedApprover { + approver_id: string; + approver_name: string | null; + approver_position: string | null; + approver_dept: string | null; + approver_label: string | null; + step_order: number; + step_type: string; + } + + let normalizedApprovers: NormalizedApprover[]; + + if (approval_type === "consensus" && !hasExplicitStepType) { + // 단순 합의결재: 전원 step_order=1, step_type='consensus' + normalizedApprovers = approvers.map((a: any) => ({ + approver_id: a.approver_id, + approver_name: a.approver_name || null, + approver_position: a.approver_position || null, + approver_dept: a.approver_dept || null, + approver_label: a.approver_label || "합의 결재", + step_order: 1, + step_type: "consensus", + })); + } else if (hasExplicitStepType) { + // 혼합형: 각 approver에 명시된 step_type/step_order 사용 + normalizedApprovers = approvers.map((a: any, i: number) => ({ + approver_id: a.approver_id, + approver_name: a.approver_name || null, + approver_position: a.approver_position || null, + approver_dept: a.approver_dept || null, + approver_label: a.approver_label || null, + step_order: a.step_order ?? (i + 1), + step_type: a.step_type || "approval", + })); + } else { + // escalation / post: 기본 sequential + normalizedApprovers = approvers.map((a: any, i: number) => ({ + approver_id: a.approver_id, + approver_name: a.approver_name || null, + approver_position: a.approver_position || null, + approver_dept: a.approver_dept || null, + approver_label: a.approver_label || `${i + 1}차 결재`, + step_order: a.step_order ?? (i + 1), + step_type: "approval", + })); + } + + // escalation 타입에서 같은 step_order에 2명 이상이면서 step_type이 approval인 경우 에러 + const stepOrderGroups = new Map(); + for (const a of normalizedApprovers) { + const group = stepOrderGroups.get(a.step_order) || []; + group.push(a); + stepOrderGroups.set(a.step_order, group); + } + for (const [stepOrder, group] of stepOrderGroups) { + if (group.length > 1) { + const allApproval = group.every(g => g.step_type === "approval"); + if (allApproval) { + return res.status(400).json({ + success: false, + message: `step_order ${stepOrder}에 approval 타입 결재자가 2명 이상입니다. consensus로 지정해주세요.`, + }); + } + } + } + + // total_steps = 고유한 step_order의 최대값 + const uniqueStepOrders = [...new Set(normalizedApprovers.map(a => a.step_order))].sort((a, b) => a - b); + const totalSteps = Math.max(...uniqueStepOrders); + + // 저장할 approval_type 결정 (혼합형은 escalation으로 저장) + const storedApprovalType = hasExplicitStepType ? "escalation" : approval_type; + const initialStatus = approval_type === "post" ? "post_pending" : "requested"; + let result: any; await transaction(async (client) => { - // 결재 요청 생성 const { rows: reqRows } = await client.query( `INSERT INTO approval_requests ( - title, description, definition_id, target_table, target_record_id, - target_record_data, status, current_step, total_steps, + title, description, definition_id, template_id, target_table, target_record_id, + target_record_data, status, current_step, total_steps, approval_type, requester_id, requester_name, requester_dept, screen_id, button_component_id, company_code - ) VALUES ($1, $2, $3, $4, $5, $6, 'requested', 1, $7, $8, $9, $10, $11, $12, $13) + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 1, $9, $10, $11, $12, $13, $14, $15, $16) RETURNING *`, [ - title, description, definition_id, target_table, target_record_id || null, - JSON.stringify(mergedRecordData), - totalSteps, + title, description, definition_id, template_id || null, target_table, safeTargetRecordId, + JSON.stringify(mergedRecordData), initialStatus, totalSteps, storedApprovalType, userId, userName, deptName, screen_id, button_component_id, companyCode, ] ); result = reqRows[0]; - // 결재 라인 생성 - // 동시결재: 모든 결재자 pending (step_order는 고유값) / 다단결재: 첫 번째만 pending - for (let i = 0; i < approvers.length; i++) { - const approver = approvers[i]; - const lineStatus = isParallel ? "pending" : (i === 0 ? "pending" : "waiting"); + const firstStep = uniqueStepOrders[0]; + + for (const approver of normalizedApprovers) { + // 첫 번째 step의 결재자만 pending, 나머지는 waiting + let lineStatus: string; + if (approver.step_order === firstStep) { + lineStatus = "pending"; + } else { + lineStatus = "waiting"; + } await client.query( `INSERT INTO approval_lines ( request_id, step_order, approver_id, approver_name, approver_position, - approver_dept, approver_label, status, company_code - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + approver_dept, approver_label, status, step_type, company_code + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, [ result.request_id, - i + 1, + approver.step_order, approver.approver_id, - approver.approver_name || null, - approver.approver_position || null, - approver.approver_dept || null, - approver.approver_label || (isParallel ? "동시 결재" : `${i + 1}차 결재`), + approver.approver_name, + approver.approver_position, + approver.approver_dept, + approver.approver_label, lineStatus, + approver.step_type, companyCode, ] ); } - // 상태를 in_progress로 업데이트 - await client.query( - "UPDATE approval_requests SET status = 'in_progress' WHERE request_id = $1", - [result.request_id] - ); - result.status = "in_progress"; + // 첫 번째 step이 notification이면 자동 통과 처리 + const firstStepLines = normalizedApprovers.filter(a => a.step_order === firstStep); + const firstStepType = firstStepLines[0]?.step_type; + + if (firstStepType === "notification") { + // notification은 자동 처리 → activateNextStep으로 재귀 + for (const nl of firstStepLines) { + await client.query( + `UPDATE approval_lines SET status = 'approved', comment = '자동 통보 처리', processed_at = NOW() + WHERE request_id = $1 AND step_order = $2 AND approver_id = $3 AND company_code = $4`, + [result.request_id, nl.step_order, nl.approver_id, companyCode] + ); + } + await activateNextStep(client, result.request_id, firstStep, totalSteps, companyCode, userId, null); + } + + // status를 in_progress로 업데이트 (post_pending 제외) + if (approval_type !== "post") { + await client.query( + `UPDATE approval_requests SET status = 'in_progress' WHERE request_id = $1 AND company_code = $2`, + [result.request_id, companyCode] + ); + result.status = "in_progress"; + await syncApprovalStatusToTarget(client, result.request_id, "in_progress", companyCode); + } else { + await syncApprovalStatusToTarget(client, result.request_id, "post_pending", companyCode); + } }); return res.status(201).json({ success: true, data: result, message: "결재 요청이 생성되었습니다." }); @@ -711,14 +1142,17 @@ export class ApprovalRequestController { return res.status(403).json({ success: false, message: "본인이 요청한 건만 회수할 수 있습니다." }); } - if (!["requested", "in_progress"].includes(request.status)) { + if (!["requested", "in_progress", "post_pending"].includes(request.status)) { return res.status(400).json({ success: false, message: "이미 처리된 결재 요청은 회수할 수 없습니다." }); } - await query( - "UPDATE approval_requests SET status = 'cancelled', updated_at = NOW() WHERE request_id = $1 AND company_code = $2", - [id, companyCode] - ); + await transaction(async (client) => { + await client.query( + "UPDATE approval_requests SET status = 'cancelled', updated_at = NOW() WHERE request_id = $1 AND company_code = $2", + [id, companyCode] + ); + await syncApprovalStatusToTarget(client, Number(id), "cancelled", companyCode); + }); return res.json({ success: true, message: "결재 요청이 회수되었습니다." }); } catch (error) { @@ -730,6 +1164,68 @@ export class ApprovalRequestController { }); } } + + // 후결 처리 엔드포인트 + static async postApprove(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user?.companyCode; + const userId = req.user?.userId; + if (!companyCode || !userId) { + return res.status(401).json({ success: false, message: "인증 정보가 없습니다." }); + } + + const { id } = req.params; + const { comment } = req.body; + + const request = await queryOne( + "SELECT * FROM approval_requests WHERE request_id = $1 AND company_code = $2", + [id, companyCode] + ); + + if (!request) { + return res.status(404).json({ success: false, message: "결재 요청을 찾을 수 없습니다." }); + } + + if (request.approval_type !== "post") { + return res.status(400).json({ success: false, message: "후결 유형의 결재 요청만 후결 처리할 수 있습니다." }); + } + + if (request.is_post_approved) { + return res.status(400).json({ success: false, message: "이미 후결 처리된 요청입니다." }); + } + + // 결재선 전원 approved 확인 + const [pendingCount] = await query( + `SELECT COUNT(*) as cnt FROM approval_lines + WHERE request_id = $1 AND status NOT IN ('approved', 'skipped') AND company_code = $2`, + [id, companyCode] + ); + + if (parseInt(pendingCount?.cnt || "0") > 0) { + return res.status(400).json({ success: false, message: "모든 결재자의 승인이 완료되지 않았습니다." }); + } + + await transaction(async (client) => { + await client.query( + `UPDATE approval_requests + SET status = 'approved', is_post_approved = true, post_approved_at = NOW(), + final_approver_id = $1, final_comment = $2, completed_at = NOW(), updated_at = NOW() + WHERE request_id = $3 AND company_code = $4`, + [userId, comment || null, id, companyCode] + ); + await syncApprovalStatusToTarget(client, Number(id), "approved", 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 : "알 수 없는 오류", + }); + } + } } // ============================================================ @@ -737,7 +1233,7 @@ export class ApprovalRequestController { // ============================================================ export class ApprovalLineController { - // 결재 처리 (승인/반려) + // 결재 처리 (승인/반려) - FOR UPDATE 동시성 방어 + 대결 + step_type 분기 static async processApproval(req: AuthenticatedRequest, res: Response) { try { const companyCode = req.user?.companyCode; @@ -747,111 +1243,151 @@ export class ApprovalLineController { } const { lineId } = req.params; - const { action, comment } = req.body; // action: 'approved' | 'rejected' + const { action, comment, proxy_reason } = req.body; if (!["approved", "rejected"].includes(action)) { return res.status(400).json({ success: false, message: "액션은 approved 또는 rejected여야 합니다." }); } - const line = await queryOne( - "SELECT * FROM approval_lines WHERE line_id = $1 AND company_code = $2", - [lineId, companyCode] - ); - - if (!line) { - return res.status(404).json({ success: false, message: "결재 라인을 찾을 수 없습니다." }); - } - - if (line.approver_id !== userId) { - return res.status(403).json({ success: false, message: "본인이 결재자로 지정된 건만 처리할 수 있습니다." }); - } - - if (line.status !== "pending") { - return res.status(400).json({ success: false, message: "대기 중인 결재만 처리할 수 있습니다." }); - } - await transaction(async (client) => { - // 현재 라인 처리 + // FOR UPDATE로 결재 라인 잠금 + // super admin(*)은 모든 회사의 라인을 처리할 수 있음 + const lineQuery = companyCode === "*" + ? `SELECT * FROM approval_lines WHERE line_id = $1 FOR UPDATE` + : `SELECT * FROM approval_lines WHERE line_id = $1 AND company_code IN ($2, '*') FOR UPDATE`; + const lineParams = companyCode === "*" ? [lineId] : [lineId, companyCode]; + const { rows: [line] } = await client.query(lineQuery, lineParams); + + if (!line) { + throw new ValidationError(404, "결재 라인을 찾을 수 없습니다."); + } + + if (line.status !== "pending") { + throw new ValidationError(400, "대기 중인 결재만 처리할 수 있습니다."); + } + + // 대결(proxy) 인증 로직 + let proxyFor: string | null = null; + let proxyReasonVal: string | null = null; + + if (line.approver_id !== userId) { + // super admin(company_code='*')은 모든 결재를 대리 처리 가능 + if (companyCode === "*") { + proxyFor = line.approver_id; + proxyReasonVal = proxy_reason || "최고관리자 대리 처리"; + } else { + const { rows: proxyRows } = await client.query( + `SELECT * FROM approval_proxy_settings + WHERE original_user_id = $1 AND proxy_user_id = $2 + AND is_active = 'Y' AND start_date <= CURRENT_DATE AND end_date >= CURRENT_DATE + AND company_code = $3`, + [line.approver_id, userId, companyCode] + ); + if (proxyRows.length === 0) { + throw new ValidationError(403, "본인이 결재자로 지정된 건만 처리할 수 있습니다."); + } + proxyFor = line.approver_id; + proxyReasonVal = proxy_reason || proxyRows[0].reason || "대결 처리"; + } + } + + // 현재 라인 처리 (proxy_for, proxy_reason 포함) - 라인의 company_code 기준 await client.query( - `UPDATE approval_lines SET status = $1, comment = $2, processed_at = NOW() - WHERE line_id = $3`, - [action, comment || null, lineId] + `UPDATE approval_lines + SET status = $1, comment = $2, processed_at = NOW(), + proxy_for = $3, proxy_reason = $4 + WHERE line_id = $5 AND company_code = $6`, + [action, comment || null, proxyFor, proxyReasonVal, lineId, line.company_code] ); - const { rows: reqRows } = await client.query( - "SELECT * FROM approval_requests WHERE request_id = $1 AND company_code = $2", - [line.request_id, companyCode] + // 결재 요청 조회 (FOR UPDATE) - 라인의 company_code 기준 + const { rows: [request] } = await client.query( + `SELECT * FROM approval_requests WHERE request_id = $1 AND company_code = $2 FOR UPDATE`, + [line.request_id, line.company_code] ); - const request = reqRows[0]; if (!request) return; + const lineCC = line.company_code; + if (action === "rejected") { // 반려: 전체 요청 반려 처리 await client.query( `UPDATE approval_requests SET status = 'rejected', final_approver_id = $1, final_comment = $2, completed_at = NOW(), updated_at = NOW() - WHERE request_id = $3`, - [userId, comment || null, line.request_id] + WHERE request_id = $3 AND company_code = $4`, + [userId, comment || null, line.request_id, lineCC] ); // 남은 pending/waiting 라인도 skipped 처리 await client.query( `UPDATE approval_lines SET status = 'skipped' - WHERE request_id = $1 AND status IN ('pending', 'waiting') AND line_id != $2`, - [line.request_id, lineId] + WHERE request_id = $1 AND status IN ('pending', 'waiting') AND line_id != $2 AND company_code = $3`, + [line.request_id, lineId, lineCC] ); + await syncApprovalStatusToTarget(client, line.request_id, "rejected", lineCC); } else { - // 승인: 동시결재 vs 다단결재 분기 - const recordData = request.target_record_data; - const isParallelMode = recordData?.approval_mode === "parallel"; + // 승인 처리: step_type 기반 분기 + const currentStepType = line.step_type || "approval"; - if (isParallelMode) { - // 동시결재: 남은 pending 라인이 있는지 확인 + // 기존 isParallelMode 하위호환 (step_type이 없는 기존 데이터) + const recordData = request.target_record_data; + const isLegacyParallel = recordData?.approval_mode === "parallel" && !line.step_type; + + if (isLegacyParallel) { + // 레거시 동시결재 (하위호환) const { rows: remainingLines } = await client.query( `SELECT COUNT(*) as cnt FROM approval_lines WHERE request_id = $1 AND status = 'pending' AND line_id != $2 AND company_code = $3`, - [line.request_id, lineId, companyCode] + [line.request_id, lineId, lineCC] ); const remaining = parseInt(remainingLines[0]?.cnt || "0"); if (remaining === 0) { - // 모든 동시 결재자 승인 완료 → 최종 승인 await client.query( `UPDATE approval_requests SET status = 'approved', final_approver_id = $1, final_comment = $2, completed_at = NOW(), updated_at = NOW() - WHERE request_id = $3`, - [userId, comment || null, line.request_id] + WHERE request_id = $3 AND company_code = $4`, + [userId, comment || null, line.request_id, lineCC] ); + await syncApprovalStatusToTarget(client, line.request_id, "approved", lineCC); } - // 아직 남은 결재자 있으면 대기 (상태 변경 없음) - } else { - // 다단결재: 다음 단계 활성화 또는 최종 완료 - const nextStep = line.step_order + 1; + } else if (currentStepType === "consensus") { + // 합의결재: 같은 step의 모든 결재자 승인 확인 + const { rows: remaining } = await client.query( + `SELECT COUNT(*) as cnt FROM approval_lines + WHERE request_id = $1 AND step_order = $2 + AND status NOT IN ('approved', 'skipped') + AND line_id != $3 AND company_code = $4`, + [line.request_id, line.step_order, lineId, lineCC] + ); - if (nextStep <= request.total_steps) { - await client.query( - `UPDATE approval_lines SET status = 'pending' - WHERE request_id = $1 AND step_order = $2 AND company_code = $3`, - [line.request_id, nextStep, companyCode] - ); - await client.query( - `UPDATE approval_requests SET current_step = $1, updated_at = NOW() WHERE request_id = $2`, - [nextStep, line.request_id] - ); - } else { - await client.query( - `UPDATE approval_requests SET status = 'approved', final_approver_id = $1, final_comment = $2, - completed_at = NOW(), updated_at = NOW() - WHERE request_id = $3`, - [userId, comment || null, line.request_id] + if (parseInt(remaining[0].cnt) === 0) { + // 합의 완료 → 다음 step 활성화 + await activateNextStep( + client, line.request_id, line.step_order, request.total_steps, + lineCC, userId, comment || null, ); } + } else { + // approval (기존 sequential 로직): 다음 step 활성화 + await activateNextStep( + client, line.request_id, line.step_order, request.total_steps, + lineCC, userId, comment || null, + ); } } }); return res.json({ success: true, message: action === "approved" ? "승인 처리되었습니다." : "반려 처리되었습니다." }); } catch (error) { + // ValidationError는 트랜잭션이 rollback된 후 적절한 HTTP 상태코드로 응답 + if (error instanceof Error && error.name === "ValidationError") { + const validationErr = error as any; + return res.status(validationErr.statusCode).json({ + success: false, + message: validationErr.message, + }); + } console.error("결재 처리 오류:", error); return res.status(500).json({ success: false, @@ -874,7 +1410,7 @@ export class ApprovalLineController { `SELECT l.*, r.title, r.target_table, r.target_record_id, r.requester_name, r.requester_dept, r.created_at as request_created_at FROM approval_lines l JOIN approval_requests r ON l.request_id = r.request_id AND l.company_code = r.company_code - WHERE l.approver_id = $1 AND l.status = 'pending' AND l.company_code = $2 + WHERE l.approver_id = $1 AND l.status = 'pending' AND l.company_code IN ($2, '*') ORDER BY r.created_at ASC`, [userId, companyCode] ); @@ -890,3 +1426,4 @@ export class ApprovalLineController { } } } + diff --git a/backend-node/src/controllers/approvalProxyController.ts b/backend-node/src/controllers/approvalProxyController.ts new file mode 100644 index 00000000..5788c7bf --- /dev/null +++ b/backend-node/src/controllers/approvalProxyController.ts @@ -0,0 +1,212 @@ +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import { query, queryOne } from "../database/db"; + +// ============================================================ +// 대결 위임 설정 (Approval Proxy Settings) CRUD +// ============================================================ + +export class ApprovalProxyController { + // 대결 위임 목록 조회 (user_info JOIN으로 이름/부서 포함) + static async getProxySettings(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 정보가 없습니다." }); + } + + const rows = await query( + `SELECT ps.*, + u1.user_name AS original_user_name, u1.dept_name AS original_dept_name, + u2.user_name AS proxy_user_name, u2.dept_name AS proxy_dept_name + FROM approval_proxy_settings ps + LEFT JOIN user_info u1 ON ps.original_user_id = u1.user_id AND ps.company_code = u1.company_code + LEFT JOIN user_info u2 ON ps.proxy_user_id = u2.user_id AND ps.company_code = u2.company_code + WHERE ps.company_code = $1 + ORDER BY ps.created_at DESC`, + [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 : "알 수 없는 오류", + }); + } + } + + // 대결 위임 생성 (기간 중복 체크 포함) + 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: "위임자와 대결자가 동일할 수 없습니다." }); + } + + // 같은 기간 중복 체크 (daterange 오버랩) + const overlap = await queryOne( + `SELECT COUNT(*) AS cnt FROM approval_proxy_settings + WHERE original_user_id = $1 AND is_active = 'Y' + AND daterange(start_date, end_date, '[]') && daterange($2::date, $3::date, '[]') + AND company_code = $4`, + [original_user_id, start_date, end_date, companyCode] + ); + + if (overlap && parseInt(overlap.cnt) > 0) { + 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 { proxy_user_id, start_date, end_date, reason, is_active } = req.body; + + const fields: string[] = []; + const params: any[] = []; + let idx = 1; + + 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); } + + if (fields.length === 0) { + return res.status(400).json({ success: false, message: "수정할 필드가 없습니다." }); + } + + 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 result = await query( + "DELETE FROM approval_proxy_settings WHERE id = $1 AND company_code = $2 RETURNING id", + [id, companyCode] + ); + + if (result.length === 0) { + return res.status(404).json({ success: false, message: "대결 위임 설정을 찾을 수 없습니다." }); + } + + 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 } = req.params; + + if (!userId) { + return res.status(400).json({ success: false, message: "userId 파라미터는 필수입니다." }); + } + + const rows = await query( + `SELECT ps.*, u.user_name AS proxy_user_name + FROM approval_proxy_settings ps + LEFT JOIN user_info u ON ps.proxy_user_id = u.user_id AND ps.company_code = u.company_code + WHERE ps.original_user_id = $1 AND ps.is_active = 'Y' + AND ps.start_date <= CURRENT_DATE AND ps.end_date >= CURRENT_DATE + AND ps.company_code = $2`, + [userId, 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/backend-node/src/controllers/systemNoticeController.ts b/backend-node/src/controllers/systemNoticeController.ts new file mode 100644 index 00000000..9a00a4f0 --- /dev/null +++ b/backend-node/src/controllers/systemNoticeController.ts @@ -0,0 +1,275 @@ +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import { logger } from "../utils/logger"; +import { query } from "../database/db"; + +/** + * GET /api/system-notices + * 공지사항 목록 조회 + * - 최고 관리자(*): 전체 조회 + * - 일반 회사: 자신의 company_code 데이터만 조회 + * - is_active 필터 옵션 지원 + */ +export const getSystemNotices = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const companyCode = req.user!.companyCode; + const { is_active } = req.query; + + logger.info("공지사항 목록 조회 요청", { companyCode, is_active }); + + const conditions: string[] = []; + const params: any[] = []; + let paramIndex = 1; + + // 최고 관리자가 아닌 경우 company_code 필터링 + if (companyCode !== "*") { + conditions.push(`company_code = $${paramIndex}`); + params.push(companyCode); + paramIndex++; + } + + // is_active 필터 (true/false 문자열 처리) + if (is_active !== undefined && is_active !== "") { + const activeValue = is_active === "true" || is_active === "1"; + conditions.push(`is_active = $${paramIndex}`); + params.push(activeValue); + paramIndex++; + } + + const whereClause = + conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + + const rows = await query( + `SELECT + id, + company_code, + title, + content, + is_active, + created_by, + created_at, + updated_at + FROM system_notice + ${whereClause} + ORDER BY created_at DESC`, + params + ); + + logger.info("공지사항 목록 조회 성공", { + companyCode, + count: rows.length, + }); + + res.status(200).json({ + success: true, + data: rows, + total: rows.length, + }); + } catch (error) { + logger.error("공지사항 목록 조회 실패", { error }); + res.status(500).json({ + success: false, + message: "공지사항 목록 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}; + +/** + * POST /api/system-notices + * 공지사항 등록 + * - company_code는 req.user.companyCode에서 자동 추출 (클라이언트 입력 신뢰 금지) + */ +export const createSystemNotice = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { title, content, is_active = true } = req.body; + + logger.info("공지사항 등록 요청", { companyCode, userId, title }); + + if (!title || !title.trim()) { + res.status(400).json({ + success: false, + message: "제목을 입력해주세요.", + }); + return; + } + + if (!content || !content.trim()) { + res.status(400).json({ + success: false, + message: "내용을 입력해주세요.", + }); + return; + } + + const [created] = await query( + `INSERT INTO system_notice (company_code, title, content, is_active, created_by) + VALUES ($1, $2, $3, $4, $5) + RETURNING *`, + [companyCode, title.trim(), content.trim(), is_active, userId] + ); + + logger.info("공지사항 등록 성공", { + id: created.id, + companyCode, + title: created.title, + }); + + res.status(201).json({ + success: true, + data: created, + message: "공지사항이 등록되었습니다.", + }); + } catch (error) { + logger.error("공지사항 등록 실패", { error }); + res.status(500).json({ + success: false, + message: "공지사항 등록 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}; + +/** + * PUT /api/system-notices/:id + * 공지사항 수정 + * - WHERE id=$1 AND company_code=$2 로 타 회사 데이터 수정 차단 + * - 최고 관리자는 company_code 조건 없이 id만으로 수정 가능 + */ +export const updateSystemNotice = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const companyCode = req.user!.companyCode; + const { id } = req.params; + const { title, content, is_active } = req.body; + + logger.info("공지사항 수정 요청", { id, companyCode }); + + if (!title || !title.trim()) { + res.status(400).json({ + success: false, + message: "제목을 입력해주세요.", + }); + return; + } + + if (!content || !content.trim()) { + res.status(400).json({ + success: false, + message: "내용을 입력해주세요.", + }); + return; + } + + let result: any[]; + + if (companyCode === "*") { + // 최고 관리자: id만으로 수정 + result = await query( + `UPDATE system_notice + SET title = $1, content = $2, is_active = $3, updated_at = NOW() + WHERE id = $4 + RETURNING *`, + [title.trim(), content.trim(), is_active ?? true, id] + ); + } else { + // 일반 회사: company_code 추가 조건으로 타 회사 데이터 수정 차단 + result = await query( + `UPDATE system_notice + SET title = $1, content = $2, is_active = $3, updated_at = NOW() + WHERE id = $4 AND company_code = $5 + RETURNING *`, + [title.trim(), content.trim(), is_active ?? true, id, companyCode] + ); + } + + if (!result || result.length === 0) { + res.status(404).json({ + success: false, + message: "공지사항을 찾을 수 없거나 수정 권한이 없습니다.", + }); + return; + } + + logger.info("공지사항 수정 성공", { id, companyCode }); + + res.status(200).json({ + success: true, + data: result[0], + message: "공지사항이 수정되었습니다.", + }); + } catch (error) { + logger.error("공지사항 수정 실패", { error, id: req.params.id }); + res.status(500).json({ + success: false, + message: "공지사항 수정 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}; + +/** + * DELETE /api/system-notices/:id + * 공지사항 삭제 + * - WHERE id=$1 AND company_code=$2 로 타 회사 데이터 삭제 차단 + * - 최고 관리자는 company_code 조건 없이 id만으로 삭제 가능 + */ +export const deleteSystemNotice = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const companyCode = req.user!.companyCode; + const { id } = req.params; + + logger.info("공지사항 삭제 요청", { id, companyCode }); + + let result: any[]; + + if (companyCode === "*") { + // 최고 관리자: id만으로 삭제 + result = await query( + `DELETE FROM system_notice WHERE id = $1 RETURNING id`, + [id] + ); + } else { + // 일반 회사: company_code 추가 조건으로 타 회사 데이터 삭제 차단 + result = await query( + `DELETE FROM system_notice WHERE id = $1 AND company_code = $2 RETURNING id`, + [id, companyCode] + ); + } + + if (!result || result.length === 0) { + res.status(404).json({ + success: false, + message: "공지사항을 찾을 수 없거나 삭제 권한이 없습니다.", + }); + return; + } + + logger.info("공지사항 삭제 성공", { id, companyCode }); + + res.status(200).json({ + success: true, + message: "공지사항이 삭제되었습니다.", + }); + } catch (error) { + logger.error("공지사항 삭제 실패", { error, id: req.params.id }); + res.status(500).json({ + success: false, + message: "공지사항 삭제 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}; diff --git a/backend-node/src/routes/approvalRoutes.ts b/backend-node/src/routes/approvalRoutes.ts index 3f2cd2f2..d312b344 100644 --- a/backend-node/src/routes/approvalRoutes.ts +++ b/backend-node/src/routes/approvalRoutes.ts @@ -5,6 +5,7 @@ import { ApprovalRequestController, ApprovalLineController, } from "../controllers/approvalController"; +import { ApprovalProxyController } from "../controllers/approvalProxyController"; import { authenticateToken } from "../middleware/authMiddleware"; const router = express.Router(); @@ -30,9 +31,17 @@ router.get("/requests", ApprovalRequestController.getRequests); router.get("/requests/:id", ApprovalRequestController.getRequest); router.post("/requests", ApprovalRequestController.createRequest); router.post("/requests/:id/cancel", ApprovalRequestController.cancelRequest); +router.post("/requests/:id/post-approve", ApprovalRequestController.postApprove); // ==================== 결재 라인 처리 (Lines) ==================== router.get("/my-pending", ApprovalLineController.getMyPendingLines); router.post("/lines/:lineId/process", ApprovalLineController.processApproval); +// ==================== 대결 위임 설정 (Proxy Settings) ==================== +router.get("/proxy-settings", ApprovalProxyController.getProxySettings); +router.post("/proxy-settings", ApprovalProxyController.createProxySetting); +router.put("/proxy-settings/:id", ApprovalProxyController.updateProxySetting); +router.delete("/proxy-settings/:id", ApprovalProxyController.deleteProxySetting); +router.get("/proxy-settings/check/:userId", ApprovalProxyController.checkActiveProxy); + export default router; diff --git a/backend-node/src/routes/systemNoticeRoutes.ts b/backend-node/src/routes/systemNoticeRoutes.ts new file mode 100644 index 00000000..54506386 --- /dev/null +++ b/backend-node/src/routes/systemNoticeRoutes.ts @@ -0,0 +1,27 @@ +import { Router } from "express"; +import { + getSystemNotices, + createSystemNotice, + updateSystemNotice, + deleteSystemNotice, +} from "../controllers/systemNoticeController"; +import { authenticateToken } from "../middleware/authMiddleware"; + +const router = Router(); + +// 모든 공지사항 라우트에 인증 미들웨어 적용 +router.use(authenticateToken); + +// 공지사항 목록 조회 (is_active 필터 쿼리 파라미터 지원) +router.get("/", getSystemNotices); + +// 공지사항 등록 +router.post("/", createSystemNotice); + +// 공지사항 수정 +router.put("/:id", updateSystemNotice); + +// 공지사항 삭제 +router.delete("/:id", deleteSystemNotice); + +export default router; diff --git a/docs/responsive-component-strategy.md b/docs/responsive-component-strategy.md new file mode 100644 index 00000000..b7feab2f --- /dev/null +++ b/docs/responsive-component-strategy.md @@ -0,0 +1,155 @@ +# WACE 반응형 컴포넌트 전략 + +## 개요 + +WACE 프로젝트의 모든 반응형 UI는 **3개의 레이아웃 프리미티브 + 1개의 훅**으로 통일한다. +컴포넌트마다 새로 타입을 정의하거나 리사이저를 구현하지 않는다. + +## 아키텍처 + +``` +┌─────────────────────────────────────────────────┐ +│ useResponsive() 훅 │ +│ isMobile | isTablet | isDesktop | width │ +└──────────┬──────────┬──────────┬────────────────┘ + │ │ │ + ┌───────▼──┐ ┌────▼─────┐ ┌─▼──────────────┐ + │ 데이터 │ │ 좌우분할 │ │ 캔버스(디자이너)│ + │ 목록 │ │ 패널 │ │ 화면 │ + └──────────┘ └──────────┘ └────────────────┘ + ResponsiveDataView ResponsiveSplitPanel ResponsiveGridRenderer +``` + +## 1. useResponsive (훅) + +**위치**: `frontend/lib/hooks/useResponsive.ts` + +모든 반응형 판단의 기반. 직접 breakpoint 분기가 필요할 때만 사용. +가능하면 아래 레이아웃 컴포넌트를 쓰고, 훅 직접 사용은 최소화. + +| 반환값 | 브레이크포인트 | 해상도 | +|--------|---------------|--------| +| isMobile | xs, sm | < 768px | +| isTablet | md | 768 ~ 1023px | +| isDesktop | lg, xl, 2xl | >= 1024px | + +## 2. ResponsiveDataView (데이터 목록) + +**위치**: `frontend/components/common/ResponsiveDataView.tsx` +**패턴**: 데스크톱 = 테이블, 모바일 = 카드 리스트 +**적용 대상**: 모든 목록/리스트 화면 + +```tsx + + data={users} + columns={columns} + keyExtractor={(u) => u.id} + cardTitle={(u) => u.name} + cardFields={[ + { label: "이메일", render: (u) => u.email }, + { label: "부서", render: (u) => u.dept }, + ]} + renderActions={(u) => } +/> +``` + +**적용 완료 (12개 화면)**: +- UserTable, CompanyTable, UserAuthTable +- DataFlowList, ScreenList +- system-notices, approvalTemplate, standards +- batch-management, mail/receive, flowMgmtList +- exconList, exCallConfList + +## 3. ResponsiveSplitPanel (좌우 분할) + +**위치**: `frontend/components/common/ResponsiveSplitPanel.tsx` +**패턴**: 데스크톱 = 좌우 분할(리사이저 포함), 모바일 = 세로 스택(접기/펼치기) +**적용 대상**: 카테고리관리, 메뉴관리, 부서관리, BOM 등 좌우 분할 레이아웃 + +```tsx +} + right={} + leftTitle="카테고리" + leftWidth={25} + minLeftWidth={10} + maxLeftWidth={40} + height="calc(100vh - 120px)" +/> +``` + +**Props**: +| Prop | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| left | ReactNode | 필수 | 좌측 패널 콘텐츠 | +| right | ReactNode | 필수 | 우측 패널 콘텐츠 | +| leftTitle | string | "목록" | 모바일 접기 헤더 | +| leftWidth | number | 25 | 초기 좌측 너비(%) | +| minLeftWidth | number | 10 | 최소 좌측 너비(%) | +| maxLeftWidth | number | 50 | 최대 좌측 너비(%) | +| showResizer | boolean | true | 리사이저 표시 | +| collapsedOnMobile | boolean | true | 모바일 기본 접힘 | +| height | string | "100%" | 컨테이너 높이 | + +**동작**: +- 데스크톱(>= 1024px): 좌우 분할 + 드래그 리사이저 + 좌측 접기 버튼 +- 모바일(< 1024px): 세로 스택, 좌측 패널 40vh 제한, 접기/펼치기 + +**마이그레이션 후보**: +- `V2CategoryManagerComponent` (완료) +- `SplitPanelLayoutComponent` (v1, v2) +- `BomTreeComponent` +- `ScreenSplitPanel` +- menu/page.tsx (메뉴 관리) +- departments/page.tsx (부서 관리) + +## 4. ResponsiveGridRenderer (디자이너 캔버스) + +**위치**: `frontend/components/screen/ResponsiveGridRenderer.tsx` +**패턴**: 데스크톱(비전폭 컴포넌트) = 캔버스 스케일링, 그 외 = Flex 그리드 +**적용 대상**: 화면 디자이너로 만든 동적 화면 + +이 컴포넌트는 화면 디자이너 시스템 전용. 일반 개발에서 직접 사용하지 않음. + +## 사용 가이드 + +### 새 화면 만들 때 + +| 화면 유형 | 사용 컴포넌트 | +|-----------|--------------| +| 데이터 목록 (테이블) | `ResponsiveDataView` | +| 좌우 분할 (트리+상세) | `ResponsiveSplitPanel` | +| 디자이너 화면 | `ResponsiveGridRenderer` (자동) | +| 단순 레이아웃 | Tailwind 반응형 (`flex-col lg:flex-row`) | + +### 금지 사항 + +1. 컴포넌트 내부에 `isDraggingRef`, `handleMouseDown/Move/Up` 직접 구현 금지 + -> `ResponsiveSplitPanel` 사용 +2. `hidden lg:block` / `lg:hidden` 패턴으로 테이블/카드 이중 렌더링 금지 + -> `ResponsiveDataView` 사용 +3. `window.innerWidth` 직접 체크 금지 + -> `useResponsive()` 훅 사용 +4. 반응형 분기를 위한 새로운 타입/인터페이스 정의 금지 + -> 기존 프리미티브의 Props 사용 + +### 폐기 예정 컴포넌트 + +| 컴포넌트 | 대체 | 상태 | +|----------|------|------| +| `ResponsiveContainer` | Tailwind 또는 `useResponsive` | 미사용, 삭제 예정 | +| `ResponsiveGrid` | Tailwind `grid-cols-*` | 미사용, 삭제 예정 | +| `ResponsiveText` | Tailwind `text-sm lg:text-lg` | 미사용, 삭제 예정 | + +## 파일 구조 + +``` +frontend/ +├── lib/hooks/ +│ └── useResponsive.ts # 브레이크포인트 훅 (기반) +├── components/common/ +│ ├── ResponsiveDataView.tsx # 테이블/카드 전환 +│ └── ResponsiveSplitPanel.tsx # 좌우 분할 반응형 +└── components/screen/ + └── ResponsiveGridRenderer.tsx # 디자이너 캔버스 렌더러 +``` 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/docs/시스템_문제점_분석_및_개선_계획.md b/docs/시스템_문제점_분석_및_개선_계획.md new file mode 100644 index 00000000..0dcdf2ab --- /dev/null +++ b/docs/시스템_문제점_분석_및_개선_계획.md @@ -0,0 +1,759 @@ +# WACE 시스템 문제점 분석 및 개선 계획 + +> **작성일**: 2026-03-01 +> **상태**: 분석 완료, 계획 수립 +> **목적**: 반복적으로 발생하는 시스템 문제의 근본 원인 분석 및 구조적 개선 방안 + +--- + +## 목차 + +1. [문제 요약](#1-문제-요약) +2. [문제 1: AI(Cursor) 대화 길어질수록 정확도 저하](#2-문제-1-aicursor-대화-길어질수록-정확도-저하) +3. [문제 2: 컴포넌트가 일관되지 않게 생성됨](#3-문제-2-컴포넌트가-일관되지-않게-생성됨) +4. [문제 3: 코드 수정 시 다른 곳에 사이드 이펙트 발생](#4-문제-3-코드-수정-시-다른-곳에-사이드-이펙트-발생) +5. [근본 원인 종합](#5-근본-원인-종합) +6. [개선 계획](#6-개선-계획) +7. [우선순위 로드맵](#7-우선순위-로드맵) + +--- + +## 1. 문제 요약 + +| # | 증상 | 빈도 | 심각도 | +|---|------|------|--------| +| 1 | Cursor로 오래 작업하면 정확도 떨어짐 | 매 세션 | 중 | +| 2 | 로우코드 컴포넌트 생성 시 오류, 비일관성 | 매 컴포넌트 | 높 | +| 3 | 수정/신규 코드가 다른 곳에 영향 (저장 안됨, 특정 기능 깨짐) | 수시 | 높 | + +세 문제는 독립적으로 보이지만, **하나의 구조적 원인**에서 파생된다. + +--- + +## 2. 문제 1: AI(Cursor) 대화 길어질수록 정확도 저하 + +### 2.1. 증상 + +- 대화 초반에는 정확한 코드를 생성하다가, 30분~1시간 이상 작업하면 엉뚱한 코드 생성 +- 이전 맥락을 잊고 같은 질문을 반복하거나, 이미 수정한 부분을 되돌림 +- 관련 없는 파일을 수정하거나, 존재하지 않는 함수/변수를 참조 + +### 2.2. 원인 분석 + +AI의 컨텍스트 윈도우는 유한하다. 우리 코드베이스의 핵심 파일들이 **비정상적으로 거대**해서, AI가 한 번에 파악해야 할 정보량이 폭발한다. + +#### 거대 파일 목록 (상위 10개) + +| 파일 | 줄 수 | 역할 | +|------|-------|------| +| `frontend/lib/utils/buttonActions.ts` | **7,609줄** | 버튼 액션 전체 로직 | +| `frontend/components/screen/ScreenDesigner.tsx` | **7,559줄** | 화면 설계기 | +| `frontend/lib/registry/components/v2-table-list/TableListComponent.tsx` | **6,867줄** | V2 테이블 컴포넌트 | +| `frontend/lib/registry/components/table-list/TableListComponent.tsx` | **6,829줄** | 레거시 테이블 컴포넌트 | +| `frontend/components/screen/EditModal.tsx` | **1,648줄** | 편집 모달 | +| `frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx` | **1,524줄** | 버튼 컴포넌트 | +| `frontend/components/v2/V2Repeater.tsx` | **1,442줄** | 리피터 컴포넌트 | +| `frontend/components/screen/InteractiveScreenViewerDynamic.tsx` | **1,435줄** | 화면 뷰어 | +| `frontend/lib/utils/improvedButtonActionExecutor.ts` | **1,063줄** | 버튼 실행기 | +| `frontend/lib/registry/DynamicComponentRenderer.tsx` | **980줄** | 컴포넌트 렌더러 | + +**상위 3개 파일만 합쳐도 22,035줄**이다. AI가 이 파일 하나를 읽는 것만으로도 컨텍스트의 상당 부분을 소모한다. + +#### 타입 안전성 부재 + +```typescript +// frontend/types/component.ts:37-39 +export interface ComponentConfig { + [key: string]: any; // 사실상 타입 검증 없음 +} + +// frontend/types/component.ts:56-78 +export interface ComponentRendererProps { + component: any; // ComponentData인데 any로 선언 + // ... 중략 ... + [key: string]: any; // 여기도 any +} +``` + +`any` 타입이 핵심 인터페이스에 사용되어, AI가 "이 prop에 뭘 넣어야 하는지" 추론 불가. +사람이 봐도 모르는데 AI가 알 리가 없다. + +#### 이벤트 이름이 문자열 상수 + +```typescript +// 이 이벤트들이 코드 전체에 흩어져 있음 +window.dispatchEvent(new CustomEvent("refreshTable")); +window.dispatchEvent(new CustomEvent("closeEditModal")); +window.dispatchEvent(new CustomEvent("saveSuccessInModal")); +window.dispatchEvent(new CustomEvent("repeaterSaveComplete")); +window.dispatchEvent(new CustomEvent("refreshCardDisplay")); +window.dispatchEvent(new CustomEvent("refreshTableData")); +window.dispatchEvent(new CustomEvent("saveSuccess")); +window.dispatchEvent(new CustomEvent("closeScreenModal")); +``` + +문자열 기반이라 AI가 이벤트 흐름을 추적할 수 없다. 어떤 이벤트가 어디서 발생하고 어디서 수신되는지 **정적 분석이 불가능**하다. + +### 2.3. 영향 + +- AI가 파일 하나를 읽으면 다른 파일의 맥락을 잊음 +- 함수 시그니처를 추론하지 못하고 잘못된 파라미터를 넣음 +- 이벤트 기반 로직을 이해하지 못해 부정확한 코드 생성 + +--- + +## 3. 문제 2: 컴포넌트가 일관되지 않게 생성됨 + +### 3.1. 증상 + +- 새 컴포넌트를 만들 때마다 구조가 다름 +- Config 패널의 UI 패턴이 컴포넌트마다 제각각 +- 같은 기능인데 어떤 컴포넌트는 동작하고 어떤 컴포넌트는 안 됨 + +### 3.2. 원인 분석 + +#### 컴포넌트 수량과 중복 + +현재 등록된 컴포넌트 디렉토리: **81개** + +이 중 V2와 레거시가 병존하는 **중복 쌍**: + +| V2 버전 | 레거시 버전 | 기능 | +|---------|------------|------| +| `v2-table-list` (6,867줄) | `table-list` (6,829줄) | 테이블 | +| `v2-button-primary` (1,524줄) | `button-primary` | 버튼 | +| `v2-card-display` | `card-display` | 카드 표시 | +| `v2-aggregation-widget` | `aggregation-widget` | 집계 위젯 | +| `v2-file-upload` | `file-upload` | 파일 업로드 | +| `v2-split-panel-layout` | `split-panel-layout` | 분할 패널 | +| `v2-section-card` | `section-card` | 섹션 카드 | +| `v2-section-paper` | `section-paper` | 섹션 페이퍼 | +| `v2-category-manager` | `category-manager` | 카테고리 | +| `v2-repeater` | `repeater-field-group` | 리피터 | +| `v2-pivot-grid` | `pivot-grid` | 피벗 그리드 | +| `v2-rack-structure` | `rack-structure` | 랙 구조 | +| `v2-repeat-container` | `repeat-container` | 반복 컨테이너 | + +**13쌍이 중복** 존재. `v2-table-list`와 `table-list`는 각각 6,800줄 이상으로, 거의 같은 코드가 두 벌 있다. + +#### 패턴은 있지만 강제되지 않음 + +컴포넌트 표준 구조: +``` +v2-example/ +├── index.ts # createComponentDefinition() +├── ExampleRenderer.tsx # AutoRegisteringComponentRenderer 상속 +├── ExampleComponent.tsx # 실제 UI +├── ExampleConfigPanel.tsx # 설정 패널 (선택) +└── types.ts # ExampleConfig extends ComponentConfig +``` + +이 패턴을 **문서(`.cursor/rules/component-development-guide.mdc`)에서 설명**하고 있지만: + +1. **런타임 검증 없음**: `createComponentDefinition()`이 ID 형식만 검증, 나머지는 자유 +2. **Config 타입이 `any`**: `ComponentConfig = { [key: string]: any }` → 아무 값이나 들어감 +3. **테스트 0개**: 전체 프론트엔드에 테스트 파일 **1개** (`buttonDataflowPerformance.test.ts`), 컴포넌트 테스트는 **0개** +4. **스캐폴딩 도구 없음**: 수동으로 파일을 만들고 index.ts에 import를 추가해야 함 + +#### 컴포넌트 간 복잡도 격차 + +| 분류 | 예시 | 줄 수 | 외부 의존 | Error Boundary | +|------|------|-------|-----------|----------------| +| 단순 표시형 | `v2-text-display` | ~100줄 | 거의 없음 | 없음 | +| 입력형 | `v2-input` | ~500줄 | formData, eventBus | 없음 | +| 버튼 | `v2-button-primary` | 1,524줄 | buttonActions, apiClient, context, eventBus, modalDataStore | 있음 | +| 테이블 | `v2-table-list` | 6,867줄 | 거의 모든 것 | 있음 | + +100줄짜리와 6,867줄짜리가 같은 "컴포넌트"로 취급된다. AI에게 "컴포넌트 만들어"라고 하면 어떤 수준으로 만들어야 하는지 기준이 없다. + +#### POP 컴포넌트는 완전 별도 시스템 + +``` +frontend/lib/registry/ +├── ComponentRegistry.ts # 웹 컴포넌트 레지스트리 +├── PopComponentRegistry.ts # POP 컴포넌트 레지스트리 (별도 인터페이스) +``` + +같은 "컴포넌트"인데 등록 방식, 인터페이스, 설정 구조가 완전히 다르다. + +### 3.3. 영향 + +- 새 컴포넌트를 만들 때 "어떤 컴포넌트를 참고해야 하는지" 불명확 +- AI가 참조하는 컴포넌트에 따라 결과물이 달라짐 +- Config 구조가 제각각이라 설정 패널 UI도 불일치 + +--- + +## 4. 문제 3: 코드 수정 시 다른 곳에 사이드 이펙트 발생 + +### 4.1. 증상 + +- 저장 로직 수정했더니 다른 화면에서 저장이 안 됨 +- 테이블 관련 코드 수정했더니 모달에서 특정 기능이 깨짐 +- 리피터 수정했더니 버튼 동작이 달라짐 + +### 4.2. 원인 분석 + +#### 원인 A: window 전역 상태 오염 + +코드베이스 전체에서 `window.__*` 패턴 사용: **8개 파일, 32회 참조** + +| 전역 변수 | 정의 위치 | 사용 위치 | 위험도 | +|-----------|-----------|-----------|--------| +| `window.__v2RepeaterInstances` | `V2Repeater.tsx` (220줄) | `EditModal.tsx`, `buttonActions.ts` (4곳) | **높음** | +| `window.__relatedButtonsTargetTables` | `RelatedDataButtonsComponent.tsx` (25줄) | `v2-table-list`, `table-list`, `buttonActions.ts` | **높음** | +| `window.__relatedButtonsSelectedData` | `RelatedDataButtonsComponent.tsx` (51줄) | `buttonActions.ts` (3113줄) | **높음** | +| `window.__unifiedRepeaterInstances` | `UnifiedRepeater.tsx` (110줄) | `UnifiedRepeater.tsx` | 중간 | +| `window.__AUTH_LOG` | `authLogger.ts` | 디버깅용 | 낮음 | + +**사이드 이펙트 시나리오 예시**: + +``` +1. V2Repeater 마운트 → window.__v2RepeaterInstances에 등록 +2. EditModal이 저장 시 → window.__v2RepeaterInstances 체크 +3. 만약 Repeater가 언마운트 타이밍에 늦게 정리되면? + → EditModal은 "리피터가 있다"고 판단 + → 리피터 저장 로직 실행 + → 실제로는 리피터 데이터 없음 + → 저장 실패 또는 빈 데이터 저장 +``` + +#### 원인 B: 이벤트 스파게티 + +`window.dispatchEvent(new CustomEvent(...))` 사용: **43개 파일, 총 120회 이상** + +주요 이벤트와 발신/수신 관계: + +``` +[refreshTable 이벤트] +발신 (8곳): + - buttonActions.ts (5회) + - BomItemEditorComponent.tsx + - SelectedItemsDetailInputComponent.tsx + - BomTreeComponent.tsx (2회) + - ButtonPrimaryComponent.tsx (레거시) + - ScreenModal.tsx (2회) + - InteractiveScreenViewerDynamic.tsx + +수신 (5곳): + - v2-table-list/TableListComponent.tsx + - table-list/TableListComponent.tsx + - SplitPanelLayoutComponent.tsx + - InteractiveScreenViewerDynamic.tsx + - InteractiveScreenViewer.tsx +``` + +``` +[closeEditModal 이벤트] +발신 (4곳): + - buttonActions.ts (4회) + +수신 (2곳): + - EditModal.tsx + - screens/[screenId]/page.tsx +``` + +``` +[beforeFormSave 이벤트] +수신 (6곳): + - V2Input.tsx + - V2Repeater.tsx + - BomItemEditorComponent.tsx + - SelectedItemsDetailInputComponent.tsx + - UniversalFormModalComponent.tsx + - V2FormContext.tsx +``` + +**문제**: 이벤트 이름이 **문자열 상수**이고, 발신과 수신이 **타입으로 연결되지 않음**. +`refreshTable` 이벤트를 `refreshTableData`로 오타내도 컴파일 에러 없이 런타임에서만 발견된다. + +#### 원인 C: 이중/삼중 이벤트 시스템 + +동시에 3개의 이벤트 시스템이 공존: + +| 시스템 | 위치 | 방식 | 타입 안전 | +|--------|------|------|-----------| +| `window.dispatchEvent` | 전역 | CustomEvent 문자열 | 없음 | +| `v2EventBus` | `lib/v2-core/events/EventBus.ts` | 타입 기반 pub/sub | 있음 | +| `LegacyEventAdapter` | `lib/v2-core/adapters/LegacyEventAdapter.ts` | 1번↔2번 브릿지 | 부분적 | + +어떤 컴포넌트는 `window.dispatchEvent`를 쓰고, 어떤 컴포넌트는 `v2EventBus`를 쓰고, 또 어떤 컴포넌트는 둘 다 쓴다. **같은 이벤트가 두 시스템에서 동시에 발생**할 수 있어 예측 불가능한 동작이 발생한다. + +#### 원인 D: SplitPanelContext 이름 충돌 + +같은 이름의 Context가 2개 존재: + +| 위치 | 용도 | 제공하는 것 | +|------|------|------------| +| `frontend/contexts/SplitPanelContext.tsx` | 데이터 전달 | `selectedLeftData`, `transfer()`, `registerReceiver()` | +| `frontend/lib/registry/components/split-panel-layout/SplitPanelContext.tsx` | 리사이즈/좌표 | `getAdjustedX()`, `dividerX`, `leftWidthPercent` | + +import 경로에 따라 **완전히 다른 Context**를 가져온다. AI가 자동완성으로 잘못된 Context를 import하면 런타임에 `undefined` 에러가 발생한다. + +#### 원인 E: buttonActions.ts - 7,609줄의 신(God) 파일 + +이 파일 하나가 다음 기능을 전부 담당: + +- 저장 (INSERT/UPDATE/DELETE) +- 모달 열기/닫기 +- 리피터 데이터 수집 +- 테이블 새로고침 +- 파일 업로드 +- 외부 API 호출 +- 화면 전환 +- 데이터 검증 +- 이벤트 발송 (33회) +- window 전역 상태 읽기 (5회) + +**이 파일의 한 줄을 수정하면, 위의 모든 기능이 영향을 받을 수 있다.** + +#### 원인 F: 레거시-V2 코드 동시 존재 + +``` +v2-table-list/TableListComponent.tsx (6,867줄) +table-list/TableListComponent.tsx (6,829줄) +``` + +거의 같은 코드가 두 벌. 한쪽을 수정하면 다른 쪽은 수정 안 되어 동작이 달라진다. +또한 두 컴포넌트가 **같은 전역 이벤트를 수신**하므로, 한 화면에 둘 다 있으면 이중으로 반응할 수 있다. + +#### 원인 G: Error Boundary 미적용 + +| 컴포넌트 | Error Boundary | +|----------|----------------| +| `v2-button-primary` | 있음 | +| `v2-table-list` | 있음 | +| `v2-repeater` | 있음 | +| `v2-input` | **없음** | +| `v2-select` | **없음** | +| `v2-card-display` | **없음** | +| `v2-text-display` | **없음** | +| 기타 대부분 | **없음** | + +Error Boundary가 없는 컴포넌트에서 에러가 발생하면, **상위 컴포넌트까지 전파**되어 화면 전체가 깨진다. + +### 4.3. 사이드 이펙트 발생 위험 지도 + +``` +┌─────────────────────────────────────────────────────┐ +│ buttonActions.ts │ +│ (7,609줄) │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ 저장 로직 │ │ 모달 로직 │ │ 이벤트 │ │ +│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │ +└───────┼──────────────┼─────────────┼─────────────────┘ + │ │ │ + ▼ ▼ ▼ +┌──────────────┐ ┌──────────┐ ┌─────────────────┐ +│ window.__v2 │ │EditModal │ │ CustomEvent │ +│ RepeaterInst │ │(1,648줄) │ │ "refreshTable" │ +│ ances │ │ │ │ "closeEditModal" │ +└──────┬───────┘ └────┬─────┘ │ "saveSuccess" │ + │ │ └───────┬─────────┘ + ▼ │ │ +┌──────────────┐ │ ┌──────▼───────┐ +│ V2Repeater │◄─────┘ │ TableList │ +│ (1,442줄) │ │ (6,867줄) │ +└──────────────┘ │ + 레거시 │ + │ (6,829줄) │ + └──────────────┘ +``` + +**위 그래프에서 어디를 수정하든 화살표를 따라 다른 곳에 영향이 전파된다.** + +--- + +## 5. 근본 원인 종합 + +세 가지 문제의 근본 원인은 하나다: **경계(Boundary)가 없는 아키텍처** + +| 근본 원인 | 문제 1 영향 | 문제 2 영향 | 문제 3 영향 | +|-----------|-------------|-------------|-------------| +| 거대 파일 (God File) | AI 컨텍스트 소모 | 참조할 기준 불명확 | 수정 영향 범위 광범위 | +| `any` 타입 남발 | AI 타입 추론 불가 | Config 검증 없음 | 런타임 에러 | +| 문자열 이벤트 | AI 이벤트 흐름 추적 불가 | 이벤트 패턴 불일치 | 이벤트 누락/오타 | +| window 전역 상태 | AI 상태 추적 불가 | 컴포넌트 간 의존 증가 | 상태 오염 | +| 테스트 부재 (0개) | 변경 검증 불가 | 컴포넌트 계약 불명 | 사이드 이펙트 감지 불가 | +| 레거시-V2 중복 (13쌍) | AI 혼동 | 어느 쪽을 기준으로? | 한쪽만 수정 시 불일치 | + +--- + +## 6. 개선 계획 + +### Phase 1: 즉시 효과 (1~2주) - 안전장치 설치 + +#### 1-1. 이벤트 이름 상수화 + +**현재**: +```typescript +window.dispatchEvent(new CustomEvent("refreshTable")); +``` + +**개선**: +```typescript +// frontend/lib/constants/events.ts +export const EVENTS = { + REFRESH_TABLE: "refreshTable", + CLOSE_EDIT_MODAL: "closeEditModal", + SAVE_SUCCESS: "saveSuccess", + SAVE_SUCCESS_IN_MODAL: "saveSuccessInModal", + REPEATER_SAVE_COMPLETE: "repeaterSaveComplete", + REFRESH_CARD_DISPLAY: "refreshCardDisplay", + REFRESH_TABLE_DATA: "refreshTableData", + CLOSE_SCREEN_MODAL: "closeScreenModal", + BEFORE_FORM_SAVE: "beforeFormSave", +} as const; + +// 사용 +window.dispatchEvent(new CustomEvent(EVENTS.REFRESH_TABLE)); +``` + +**효과**: 오타 방지, AI가 이벤트 흐름 추적 가능, IDE 자동완성 지원 +**위험도**: 낮음 (기능 변경 없음, 리팩토링만) +**소요 예상**: 2~3시간 + +#### 1-2. window 전역 변수 타입 선언 + +**현재**: `window.__v2RepeaterInstances`를 사용하지만 타입 선언 없음 + +**개선**: +```typescript +// frontend/types/global.d.ts +declare global { + interface Window { + __v2RepeaterInstances?: Set; + __unifiedRepeaterInstances?: Set; + __relatedButtonsTargetTables?: Set; + __relatedButtonsSelectedData?: { + tableName: string; + selectedRows: any[]; + }; + __AUTH_LOG?: { show: () => void }; + __COMPONENT_REGISTRY__?: Map; + } +} +``` + +**효과**: 타입 안전성 확보, AI가 전역 상태 구조 이해 가능 +**위험도**: 낮음 (타입 선언만, 런타임 변경 없음) +**소요 예상**: 1시간 + +#### 1-3. ComponentConfig에 제네릭 타입 적용 + +**현재**: +```typescript +export interface ComponentConfig { + [key: string]: any; +} +``` + +**개선**: +```typescript +export interface ComponentConfig { + [key: string]: unknown; // any → unknown으로 변경하여 타입 체크 강제 +} + +// 각 컴포넌트에서 +export interface ButtonPrimaryConfig extends ComponentConfig { + text: string; // 구체적 타입 + action: ButtonAction; // 구체적 타입 + variant?: "default" | "destructive" | "outline"; +} +``` + +**효과**: 잘못된 config 값 사전 차단 +**위험도**: 중간 (기존 `any` 사용처에서 타입 에러 발생 가능, 점진적 적용 필요) +**소요 예상**: 3~5일 (점진적) + +--- + +### Phase 2: 구조 개선 (2~4주) - 핵심 분리 + +#### 2-1. buttonActions.ts 분할 + +**현재**: 7,609줄, 1개 파일 + +**개선 목표**: 도메인별 분리 + +``` +frontend/lib/actions/ +├── index.ts # re-export +├── types.ts # 공통 타입 +├── saveActions.ts # INSERT/UPDATE 저장 로직 +├── deleteActions.ts # DELETE 로직 +├── modalActions.ts # 모달 열기/닫기 +├── tableActions.ts # 테이블 새로고침, 데이터 조작 +├── repeaterActions.ts # 리피터 데이터 수집/저장 +├── fileActions.ts # 파일 업로드/다운로드 +├── navigationActions.ts # 화면 전환 +├── validationActions.ts # 데이터 검증 +└── externalActions.ts # 외부 API 호출 +``` + +**효과**: +- 저장 로직 수정 시 `saveActions.ts`만 영향 +- AI가 관련 파일만 읽으면 됨 (7,600줄 → 평균 500줄) +- import 관계로 의존성 명확화 + +**위험도**: 높음 (가장 많이 사용되는 파일, 신중한 분리 필요) +**소요 예상**: 1~2주 + +#### 2-2. 이벤트 시스템 통일 + +**현재**: 3개 시스템 공존 (window CustomEvent, v2EventBus, LegacyEventAdapter) + +**개선**: +```typescript +// v2EventBus로 통일, 타입 안전한 이벤트 정의 +interface EventMap { + "table:refresh": { tableId?: string }; + "modal:close": { modalId: string }; + "form:save": { formData: Record }; + "form:saveComplete": { success: boolean; message?: string }; + "repeater:saveComplete": { repeaterId: string }; +} + +// 사용 +v2EventBus.emit("table:refresh", { tableId: "order_table" }); +v2EventBus.on("table:refresh", (data) => { /* data.tableId 타입 안전 */ }); +``` + +**마이그레이션 전략**: +1. `v2EventBus`에 `EventMap` 타입 추가 +2. 새 코드는 반드시 `v2EventBus` 사용 +3. 기존 `window.dispatchEvent` → `v2EventBus`로 점진적 교체 +4. `LegacyEventAdapter`에서 양방향 브릿지 유지 (과도기) +5. 모든 교체 완료 후 `LegacyEventAdapter` 제거 + +**효과**: 이벤트 흐름 추적 가능, 타입 안전, 디버깅 용이 +**위험도**: 중간 (과도기 브릿지로 안전하게 전환) +**소요 예상**: 2~3주 + +#### 2-3. window 전역 상태 → Zustand 스토어 전환 + +**현재**: +```typescript +window.__v2RepeaterInstances = new Set(); +window.__relatedButtonsSelectedData = { tableName, selectedRows }; +``` + +**개선**: +```typescript +// frontend/lib/stores/componentInstanceStore.ts +import { create } from "zustand"; + +interface ComponentInstanceState { + repeaterInstances: Set; + relatedButtonsTargetTables: Set; + relatedButtonsSelectedData: { + tableName: string; + selectedRows: any[]; + } | null; + + registerRepeater: (key: string) => void; + unregisterRepeater: (key: string) => void; + setRelatedData: (data: { tableName: string; selectedRows: any[] }) => void; + clearRelatedData: () => void; +} + +export const useComponentInstanceStore = create((set) => ({ + repeaterInstances: new Set(), + relatedButtonsTargetTables: new Set(), + relatedButtonsSelectedData: null, + + registerRepeater: (key) => + set((state) => { + const next = new Set(state.repeaterInstances); + next.add(key); + return { repeaterInstances: next }; + }), + unregisterRepeater: (key) => + set((state) => { + const next = new Set(state.repeaterInstances); + next.delete(key); + return { repeaterInstances: next }; + }), + setRelatedData: (data) => set({ relatedButtonsSelectedData: data }), + clearRelatedData: () => set({ relatedButtonsSelectedData: null }), +})); +``` + +**효과**: +- 상태 변경 추적 가능 (Zustand devtools) +- 컴포넌트 리렌더링 최적화 (selector 사용) +- window 오염 제거 + +**위험도**: 중간 +**소요 예상**: 1주 + +--- + +### Phase 3: 품질 강화 (4~8주) - 예방 체계 + +#### 3-1. 레거시 컴포넌트 제거 + +**목표**: V2-레거시 중복 13쌍 → V2만 유지 + +**전략**: +1. 각 중복 쌍에서 레거시 사용처 검색 +2. 사용처가 없는 레거시 컴포넌트 즉시 제거 +3. 사용처가 있는 경우 V2로 교체 후 제거 +4. `components/index.ts`에서 import 제거 + +**효과**: 코드베이스 ~15,000줄 감소, AI 혼동 제거 +**소요 예상**: 2~3주 + +#### 3-2. 컴포넌트 스캐폴딩 CLI + +**목표**: `npx create-v2-component my-component` 실행 시 표준 구조 자동 생성 + +```bash +$ npx create-v2-component my-widget --category data + +생성 완료: + frontend/lib/registry/components/v2-my-widget/ + ├── index.ts # 자동 생성 + ├── MyWidgetRenderer.tsx # 자동 생성 + ├── MyWidgetComponent.tsx # 템플릿 + ├── MyWidgetConfigPanel.tsx # 템플릿 + └── types.ts # Config 인터페이스 템플릿 + + components/index.ts에 import 자동 추가 완료 +``` + +**효과**: 컴포넌트 구조 100% 일관성 보장 +**소요 예상**: 3~5일 + +#### 3-3. 핵심 컴포넌트 통합 테스트 + +**목표**: 사이드 이펙트 감지용 테스트 작성 + +```typescript +// __tests__/integration/save-flow.test.ts +describe("저장 플로우", () => { + it("버튼 저장 → refreshTable 이벤트 발생", async () => { + const listener = vi.fn(); + v2EventBus.on("table:refresh", listener); + + await executeSaveAction({ tableName: "test_table", data: mockData }); + + expect(listener).toHaveBeenCalledTimes(1); + }); + + it("리피터가 있을 때 저장 → 리피터 데이터도 포함", async () => { + useComponentInstanceStore.getState().registerRepeater("detail_table"); + + const result = await executeSaveAction({ tableName: "master_table", data: mockData }); + + expect(result.repeaterDataCollected).toBe(true); + }); +}); +``` + +**대상**: 저장/삭제/모달/리피터 흐름 (가장 빈번하게 깨지는 부분) +**효과**: 코드 수정 후 즉시 사이드 이펙트 감지 +**소요 예상**: 2~3주 + +#### 3-4. SplitPanelContext 통합 + +**목표**: 이름이 같은 2개의 Context → 1개로 통합 또는 명확히 분리 + +**방안 A - 통합**: +```typescript +// frontend/contexts/SplitPanelContext.tsx에 통합 +interface SplitPanelContextValue { + // 데이터 전달 (기존 contexts/ 버전) + selectedLeftData: any; + transfer: (data: any) => void; + registerReceiver: (handler: (data: any) => void) => void; + // 리사이즈 (기존 components/ 버전) + getAdjustedX: (x: number) => number; + dividerX: number; + leftWidthPercent: number; +} +``` + +**방안 B - 명확 분리**: +```typescript +// SplitPanelDataContext.tsx → 데이터 전달용 +// SplitPanelResizeContext.tsx → 리사이즈용 +``` + +**효과**: import 혼동 제거 +**소요 예상**: 2~3일 + +--- + +### Phase 4: 장기 개선 (8주+) - 아키텍처 전환 + +#### 4-1. 거대 컴포넌트 분할 + +| 대상 파일 | 현재 줄 수 | 분할 목표 | +|-----------|-----------|-----------| +| `v2-table-list/TableListComponent.tsx` | 6,867줄 | 훅 분리, 렌더링 분리 → 각 1,000줄 이하 | +| `ScreenDesigner.tsx` | 7,559줄 | 패널별 분리 → 각 1,500줄 이하 | +| `EditModal.tsx` | 1,648줄 | 저장/폼/UI 분리 → 각 500줄 이하 | +| `ButtonPrimaryComponent.tsx` | 1,524줄 | 액션 실행 분리 → 각 500줄 이하 | + +#### 4-2. Config 스키마 검증 (Zod) + +```typescript +// v2-button-primary/types.ts +import { z } from "zod"; + +export const ButtonPrimaryConfigSchema = z.object({ + text: z.string().default("버튼"), + variant: z.enum(["default", "destructive", "outline", "secondary", "ghost"]).default("default"), + action: z.object({ + type: z.enum(["save", "delete", "navigate", "custom"]), + targetTable: z.string().optional(), + // ... + }), +}); + +export type ButtonPrimaryConfig = z.infer; +``` + +`createComponentDefinition()`에서 스키마 검증을 강제하여 잘못된 config가 등록 시점에 차단되도록 한다. + +--- + +## 7. 우선순위 로드맵 + +### 즉시 (이번 주) + +- [ ] **1-1**: 이벤트 이름 상수 파일 생성 (`frontend/lib/constants/events.ts`) +- [ ] **1-2**: window 전역 변수 타입 선언 (`frontend/types/global.d.ts`) + +### 단기 (1~2주) + +- [ ] **2-3**: window 전역 상태 → Zustand 스토어 전환 +- [ ] **1-3**: ComponentConfig `any` → `unknown` 점진적 적용 + +### 중기 (2~4주) + +- [ ] **2-1**: buttonActions.ts 분할 (7,609줄 → 도메인별) +- [ ] **2-2**: 이벤트 시스템 통일 (v2EventBus 기반) +- [ ] **3-4**: SplitPanelContext 통합/분리 + +### 장기 (4~8주) + +- [ ] **3-1**: 레거시 컴포넌트 13쌍 제거 +- [ ] **3-2**: 컴포넌트 스캐폴딩 CLI +- [ ] **3-3**: 핵심 플로우 통합 테스트 +- [ ] **4-1**: 거대 컴포넌트 분할 +- [ ] **4-2**: Config 스키마 Zod 검증 + +--- + +## 부록: 수치 요약 + +| 지표 | 현재 | 목표 | +|------|------|------| +| 최대 파일 크기 | 7,609줄 | 1,500줄 이하 | +| 컴포넌트 수 | 81개 (13쌍 중복) | ~55개 (중복 제거) | +| window 전역 변수 | 5개 | 0개 | +| 이벤트 시스템 | 3개 공존 | 1개 (v2EventBus) | +| 테스트 파일 | 1개 | 핵심 플로우 최소 10개 | +| `any` 타입 사용 (핵심 인터페이스) | 3곳 | 0곳 | +| SplitPanelContext 중복 | 2개 | 1개 (또는 명확 분리) | diff --git a/docs/파이프라인_한계점_분석.md b/docs/파이프라인_한계점_분석.md new file mode 100644 index 00000000..7ba10256 --- /dev/null +++ b/docs/파이프라인_한계점_분석.md @@ -0,0 +1,361 @@ +# Agent Pipeline 한계점 분석 + +> 결재 시스템 같은 대규모 크로스도메인 프로젝트에서 현재 파이프라인이 왜 제대로 동작할 수 없는가 + +--- + +## 1. 에이전트 컨텍스트 격리 문제 + +### 현상 +`executor.ts`의 `spawnAgent()`는 매번 새로운 Cursor Agent CLI 프로세스를 생성한다. 각 에이전트는 `systemPrompt + taskDescription + fileContext`만 받고, 이전 대화/결정/아키텍처 논의는 전혀 알지 못한다. + +```typescript +// executor.ts:64-118 +function spawnAgent(agentType, prompt, model, workspacePath, timeoutMs) { + const child = spawn(agentPath, ['--model', model, '--print', '--trust'], { + cwd: workspacePath, + stdio: ['pipe', 'pipe', 'pipe'], + }); + child.stdin.write(prompt); // 이게 에이전트가 받는 전부 + child.stdin.end(); +} +``` + +### 문제 본질 +- 에이전트는 **"왜 이렇게 만들어야 하는지"** 모른다. 단지 task description에 적힌 대로 만든다 +- 결재 시스템의 **설계 의도** (한국 기업 결재 문화, 자기결재/상신결재/합의결재/대결/후결)는 task description에 다 담을 수 없다 +- PM과 사용자 사이에 오간 **아키텍처 논의** (이벤트 훅 시스템, 제어관리 연동, 엔티티 조인으로 결재 상태 표시) 같은 결정 사항이 전달되지 않는다 + +### 결재 시스템에서의 구체적 영향 +- "ApprovalRequestModal에 결재 유형 선택을 추가해라"라고 지시하면, 에이전트는 기존 모달 코드를 읽겠지만, **왜 그 UI가 그렇게 생겼는지, 다른 패널(TableListConfigPanel)의 Combobox 패턴을 왜 따라야 하는지** 모른다 +- 실제로 이 대화에서 Combobox UI가 4번 수정됐다. 매번 "다른 패널 참고해서 만들라"고 해도 패턴을 정확히 못 따라했다 + +--- + +## 2. 파일 컨텍스트 3000자 절삭 + +### 현상 +```typescript +// executor.ts:124-138 +async function readFileContexts(files, workspacePath) { + for (const file of files) { + const content = await readFile(fullPath, 'utf-8'); + contents.push(`--- ${file} ---\n${content.substring(0, 3000)}`); // 3000자 잘림 + } +} +``` + +### 문제 본질 +주요 파일들의 실제 크기: +- `approvalController.ts`: ~800줄 (3000자로는 약 100줄, 12.5%만 보인다) +- `improvedButtonActionExecutor.ts`: ~1500줄 +- `ButtonConfigPanel.tsx`: ~600줄 +- `ApprovalStepConfigPanel.tsx`: ~300줄 + +에이전트가 수정해야 할 파일의 **전체 구조를 이해할 수 없다**. 앞부분만 보고 import 구문이나 초기 코드만 파악하고, 실제 수정 지점에 도달하지 못한다. + +### 결재 시스템에서의 구체적 영향 +- `approvalController.ts`를 수정하려면 기존 함수 구조, DB 쿼리 패턴, 에러 처리 방식, 멀티테넌시 적용 패턴을 전부 알아야 한다. 3000자로는 불가능 +- `improvedButtonActionExecutor.ts`의 제어관리 연동 패턴을 이해하려면 파일 전체를 봐야 한다 +- V2 컴포넌트 표준을 따르려면 기존 컴포넌트(`v2-table-list/` 등)의 전체 구조를 참고해야 한다 + +--- + +## 3. 에이전트 간 실시간 소통 부재 + +### 현상 +병렬 실행 시 에이전트들은 **서로의 작업 결과를 실시간으로 공유하지 못한다**: + +```typescript +// executor.ts:442-454 +if (state.config.parallel) { + const promises = readyTasks.map(async (task, index) => { + if (index > 0) await sleep(index * STAGGER_DELAY); // 500ms 딜레이뿐 + return executeAndTrack(task); + }); + await Promise.all(promises); // 완료까지 기다린 후 PM이 리뷰 +} +``` + +PM 에이전트가 라운드 후에 리뷰하지만, 이것도 **round-N.md의 텍스트 기반 리뷰**일 뿐이다. + +### 문제 본질 +- DB 에이전트가 스키마를 변경하면, Backend 에이전트가 그 결과를 **같은 라운드에서 즉시 반영할 수 없다** +- Frontend 에이전트가 "이 API 응답 구조 좀 바꿔줘"라고 Backend에 요청할 수 없다 +- 협업 모드(`CollabMessage`)가 존재하지만, 이것도 **라운드 단위의 비동기 메시지**이지 실시간 대화가 아니다 + +### 결재 시스템에서의 구체적 영향 +- DB가 `approval_proxy_settings` 테이블을 만들고, Backend가 대결 API를 만들고, Frontend가 대결 설정 UI를 만드는 과정이 **최소 3라운드**가 필요하다 (각 의존성 해소를 위해) +- 실제로는 Backend가 DB 스키마를 보고 쿼리를 짜는 과정에서 "이 컬럼 타입이 좀 다른 것 같은데"라는 이슈가 생기면, 즉시 수정 불가하고 다음 라운드로 넘어간다 +- 라운드당 에이전트 호출 1~3분 + PM 리뷰 1~2분 = **라운드당 최소 3~5분**. 8개 phase를 3라운드씩 = **최소 72~120분 (1~2시간)** + +--- + +## 4. 시스템 프롬프트의 한계 (프로젝트 특수 패턴 부재) + +### 현상 +`prompts.ts`의 시스템 프롬프트는 **범용적**이다: + +```typescript +// prompts.ts:75-118 +export const BACKEND_PROMPT = ` +# Role +You are a Backend specialist for ERP-node project. +Stack: Node.js + Express + TypeScript + PostgreSQL Raw Query. +// ... 멀티테넌시, 기본 코드 패턴만 포함 +`; +``` + +### 프로젝트 특수 패턴 중 프롬프트에 없는 것들 + +| 필수 패턴 | 프롬프트 포함 여부 | 영향 | +|-----------|:------------------:|------| +| V2 컴포넌트 레지스트리 (`createComponentDefinition`, `AutoRegisteringComponentRenderer`) | 프론트엔드 프롬프트에 기본 구조만 | 컴포넌트 등록 방식 오류 가능 | +| ConfigPanelBuilder / ConfigSection | 언급만 | 직접 JSX로 패널 만드는 실수 반복 | +| Combobox UI 패턴 (Popover + Command) | 없음 | 실제로 4번 재수정 필요했음 | +| 엔티티 조인 시스템 | 없음 | 결재 상태를 대상 테이블에 표시하는 핵심 기능 구현 불가 | +| 제어관리(Node Flow) 연동 | 없음 | 결재 후 자동 액션 트리거 구현 불가 | +| ButtonActionExecutor 패턴 | 없음 | 결재 버튼 액션 구현 시 기존 패턴 미준수 | +| apiClient 사용법 (frontend/lib/api/) | 간략한 언급 | fetch 직접 사용 가능성 | +| CustomEvent 기반 모달 오픈 | 없음 | approval-modal 열기 방식 이해 불가 | +| 화면 디자이너 컨텍스트 | 없음 | screenTableName 같은 설계 시 컨텍스트 활용 불가 | + +### 결재 시스템에서의 구체적 영향 +- **이벤트 훅 시스템**을 만들려면 기존 `NodeFlowExecutionService`의 실행 패턴, 액션 타입 enum, 입력/출력 구조를 알아야 하는데, 프롬프트에 전혀 없다 +- **엔티티 조인으로 결재 상태 표시**하려면 기존 엔티티 조인 시스템이 어떻게 작동하는지(reverse lookup, join config) 알아야 하는데, 에이전트가 이 시스템 자체를 모른다 + +--- + +## 5. 단일 패스 실행 + 재시도의 비효율 + +### 현상 +```typescript +// executor.ts:240-288 +async function executeTaskWithRetry(task, state) { + while (task.attempts < task.maxRetries) { + const result = await executeTaskOnce(task, state, retryContext); + task.attempts++; + if (result.success) break; + // 검증 실패 → retryContext에 에러 메시지만 전달 + retryContext = failResult.retryContext || `이전 시도 실패: ${result.agentOutput.substring(0, 500)}`; + await sleep(2000); + } +} +``` + +### 문제 본질 +- 재시도 시 에이전트가 받는 건 **이전 에러 메시지 500자**뿐이다 +- "Combobox 패턴 대신 Select 박스를 썼다" 같은 **UI/UX 품질 문제**는 L1~L6 검증으로 잡을 수 없다 (빌드는 통과하니까) +- 사용자의 실시간 피드백("이거 다른 패널이랑 UI가 다른데?")을 반영할 수 없다 + +### 검증 피라미드(L1~L6)가 못 잡는 것들 + +| 검증 레벨 | 잡을 수 있는 것 | 못 잡는 것 | +|-----------|----------------|-----------| +| L1 (TS 빌드) | 타입 에러, import 오류 | 로직 오류, 패턴 미준수 | +| L2 (앱 빌드) | Next.js 빌드 에러 | 런타임 에러 | +| L3 (API 호출) | 엔드포인트 존재 여부, 기본 응답 | 복잡한 비즈니스 로직 (다단계 결재 플로우) | +| L4 (DB 검증) | 테이블 존재, 기본 CRUD | 결재 상태 전이 로직, 병렬 결재 집계 | +| L5 (브라우저 E2E) | 화면 렌더링, 기본 클릭 | 결재 모달 Combobox UX, 대결 설정 UI 일관성 | +| L6 (커스텀) | 명시적 조건 | 비명시적 품질 요구사항 | + +### 결재 시스템에서의 구체적 영향 +- "자기결재 시 즉시 approved로 처리"가 올바르게 동작하는지 L3/L4로 검증 가능하지만, **"자기결재 선택 시 결재자 선택 UI가 숨겨지고 즉시 처리된다"는 UX**는 L5 자연어로는 불충분 +- "합의결재(병렬)에서 3명 중 2명 승인 + 1명 반려 시 전체 반려" 같은 **엣지 케이스 비즈니스 로직**은 자동 검증이 어렵다 +- 결재 완료 후 이벤트 훅 → Node Flow 실행 → 이메일 발송 같은 **체이닝된 비동기 로직**은 E2E로 검증 불가 + +--- + +## 6. 태스크 분할의 구조적 한계 + +### 현상: 파이프라인이 잘 되는 경우 +``` +[DB 테이블 생성] → [Backend CRUD API] → [Frontend 화면] → [UI 개선] +``` +각 태스크가 **독립적**이고, 새 파일을 만들고, 의존성이 단방향이다. + +### 현상: 파이프라인이 안 되는 경우 (결재 시스템) +``` +[DB 스키마 변경] + ↓ ↘ +[Controller 수정] [새 API 추가] ← 기존 코드 500줄 이해 필요 + ↓ ↓ ↑ +[모달 수정] [새 화면] ← 기존 UI 패턴 준수 필요 + 엔티티 조인 시스템 이해 + ↓ +[V2 컴포넌트 수정] ← 레지스트리 시스템 + ConfigPanelBuilder 패턴 이해 + ↓ +[이벤트 훅 시스템] ← NodeFlowExecutionService 전체 이해 + 새 시스템 설계 + ↓ +[엔티티 조인 등록] ← 기존 엔티티 조인 시스템 전체 이해 +``` + +### 문제 본질 +- **기존 파일 수정**이 대부분이다. 새 파일 생성이 아니라 기존 코드에 기능을 끼워넣어야 한다 +- **패턴 준수**가 필수다. "돌아가기만 하면" 안 되고, 기존 시스템과 **일관된 방식**으로 구현해야 한다 +- **설계 결정**이 코드 작성보다 중요하다. "이벤트 훅을 어떻게 설계할까?"는 에이전트가 task description만 보고 결정할 수 없다 + +--- + +## 7. PM 에이전트의 역할 한계 + +### 현상 +```typescript +// pm-agent.ts:21-70 +const PM_SYSTEM_PROMPT = ` +# 판단 기준 +- 빌드만 통과하면 "complete" 아니다 -- 기능이 실제로 동작해야 "complete" +- 같은 에러 2회 반복 -> instruction에 구체적 해결책 제시 +- 같은 에러 3회 반복 -> "fail" 판정 +`; +``` + +PM은 `round-N.md`(에이전트 응답 + git diff + 테스트 결과)와 `progress.md`만 보고 판단한다. + +### PM이 할 수 없는 것 + +| 역할 | PM 가능 여부 | 이유 | +|------|:----------:|------| +| 빌드 실패 원인 파악 | 가능 | 에러 로그가 round-N.md에 있음 | +| 비즈니스 로직 검증 | 불가 | 실제 코드를 읽지 않고 git diff만 봄 | +| UI/UX 품질 판단 | 불가 | 스크린샷 없음, 렌더링 결과 못 봄 | +| 아키텍처 일관성 검증 | 불가 | 전체 시스템 구조를 모름 | +| 기존 패턴 준수 여부 | 불가 | 기존 코드를 참조하지 않음 | +| 사용자 의도 반영 여부 | 불가 | 사용자와 대화 맥락 없음 | + +### 결재 시스템에서의 구체적 영향 +- PM이 "Backend task 성공, Frontend task 실패"라고 판정할 수는 있지만, **"Backend가 만든 API 응답 구조가 Frontend가 기대하는 것과 다르다"**를 파악할 수 없다 +- "이 모달의 Combobox가 다른 패널과 UI가 다르다"는 사용자만 판단 가능 +- "이벤트 훅 시스템의 트리거 타이밍이 잘못됐다"는 전체 아키텍처를 이해해야 판단 가능 + +--- + +## 8. 안전성 리스크 + +### 역사적 사고 +> "과거 에이전트가 범위 밖 파일 50000줄 삭제하여 2800+ TS 에러 발생" +> — user rules + +### 결재 시스템의 리스크 +수정 대상 파일이 **시스템 핵심 파일**들이다: + +| 파일 | 리스크 | +|------|--------| +| `improvedButtonActionExecutor.ts` (~1500줄) | 모든 버튼 동작의 핵심. 잘못 건드리면 시스템 전체 버튼 동작 불능 | +| `approvalController.ts` (~800줄) | 기존 결재 API 깨질 수 있음 | +| `ButtonConfigPanel.tsx` (~600줄) | 화면 디자이너 설정 패널 전체에 영향 | +| `v2-approval-step/` (5개 파일) | V2 컴포넌트 레지스트리 손상 가능 | +| `AppLayout.tsx` | 전체 레이아웃 메뉴 깨질 수 있음 | +| `UserDropdown.tsx` | 사용자 프로필 메뉴 깨질 수 있음 | + +`files` 필드로 범위를 제한하더라도, **에이전트가 `--trust` 모드로 실행**되기 때문에 실제로는 모든 파일에 접근 가능하다: + +```typescript +// executor.ts:78 +const child = spawn(agentPath, ['--model', model, '--print', '--trust'], { +``` + +code-guard가 일부 보호하지만, **구조적 파괴(잘못된 import 삭제, 함수 시그니처 변경)는 코드 가드가 감지 불가**하다. + +--- + +## 9. 종합: 파이프라인이 적합한 경우 vs 부적합한 경우 + +### 적합한 경우 (현재 파이프라인) + +| 특성 | 예시 | +|------|------| +| 새 파일 생성 위주 | 새 CRUD 화면 만들기 | +| 독립적 태스크 | 테이블 → API → 화면 순차 | +| 패턴이 단순/반복적 | 표준 CRUD, 표준 Form | +| 검증이 명확 | 빌드 + API 호출 + 브라우저 기본 확인 | +| 컨텍스트 최소 | 기존 시스템 이해 불필요 | + +### 부적합한 경우 (결재 시스템) + +| 특성 | 결재 시스템 해당 여부 | +|------|:-------------------:| +| 기존 파일 대규모 수정 | 해당 (10+ 파일 수정) | +| 크로스도메인 의존성 | 해당 (DB ↔ BE ↔ FE ↔ 기존 시스템) | +| 복잡한 비즈니스 로직 | 해당 (5가지 결재 유형, 상태 전이, 이벤트 훅) | +| 기존 시스템 깊은 이해 필요 | 해당 (제어관리, 엔티티 조인, 컴포넌트 레지스트리) | +| UI/UX 일관성 필수 | 해당 (Combobox, 모달, 설정 패널 패턴 통일) | +| 설계 결정이 선행 필요 | 해당 (이벤트 훅 아키텍처, 결재 타입 상태 머신) | +| 사용자 피드백 반복 필요 | 해당 (실제로 4회 UI 수정 반복) | + +--- + +## 10. 개선 방향 제안 + +현재 파이프라인을 결재 시스템 같은 대규모 프로젝트에서 사용하려면 다음이 필요하다: + +### 10.1 컨텍스트 전달 강화 +- **프로젝트 컨텍스트 파일**: `.cursor/rules/` 수준의 프로젝트 규칙을 에이전트 프롬프트에 동적 주입 +- **아키텍처 결정 기록**: PM-사용자 간 논의된 설계 결정을 구조화된 형태로 에이전트에 전달 +- **패턴 레퍼런스 파일**: "이 파일을 참고해서 만들어라"를 task description이 아닌 시스템 차원에서 지원 + +### 10.2 파일 컨텍스트 확대 +- 3000자 절삭 → **전체 파일 전달** 또는 최소 10000자 이상 +- 관련 파일 자동 탐지 (import 그래프 기반) +- 참고 파일(reference files)과 수정 파일(target files) 구분 + +### 10.3 에이전트 간 소통 채널 +- 라운드 내에서도 에이전트 간 **중간 결과 공유** 가능 +- "Backend가 API 스펙을 먼저 정의 → Frontend가 그 스펙 기반으로 구현" 같은 **단계적 소통** +- 질문-응답 프로토콜 (현재 CollabMessage가 있지만 실질적으로 사용 안 됨) + +### 10.4 PM 에이전트 강화 +- **코드 리뷰 기능**: git diff만 보지 말고 실제 파일을 읽어서 패턴 준수 여부 확인 +- **아키텍처 검증**: 전체 시스템 구조와의 일관성 검증 +- **사용자 피드백 루프**: PM이 사용자에게 "이 부분 확인 필요합니다" 알림 가능 + +### 10.5 검증 시스템 확장 +- **비즈니스 로직 검증**: 상태 전이 테스트 (결재 플로우 시나리오 자동 실행) +- **UI 일관성 검증**: 스크린샷 비교, 컴포넌트 패턴 분석 +- **통합 테스트**: 단일 API 호출이 아닌 시나리오 기반 E2E + +### 10.6 안전성 강화 +- `--trust` 모드 대신 **파일 범위 제한된 실행 모드** +- 라운드별 git diff 자동 리뷰 (의도치 않은 파일 변경 감지) +- 롤백 자동화 (검증 실패 시 자동 `git checkout`) + +--- + +## 부록: 결재 시스템 파이프라인 실행 시 예상 시나리오 + +### 시도할 경우 예상되는 실패 패턴 + +``` +Round 1: DB 마이그레이션 (task-1) + → 성공 가능 (신규 파일 생성이므로) + +Round 2: Backend Controller 수정 (task-2) + → approvalController.ts 3000자만 보고 수정 시도 + → 기존 함수 구조 파악 실패 + → L1 빌드 에러 (import 누락, 타입 불일치) + → 재시도 1: 에러 메시지 보고 고치지만, 기존 패턴과 다른 방식으로 구현 + → L3 API 테스트 통과 (기능은 동작) + → 하지만 코드 품질/패턴 불일치 (PM이 감지 불가) + +Round 3: Frontend 모달 수정 (task-4) + → 기존 ApprovalRequestModal 3000자만 보고 수정 + → Combobox 패턴 대신 기본 Select 사용 (다른 패널 참고 불가) + → L1 빌드 통과, L5 브라우저 테스트도 기본 동작 통과 + → 하지만 UI 일관성 미달 (사용자가 보면 즉시 지적) + +Round 4-6: 이벤트 훅 시스템 (task-7) + → NodeFlowExecutionService 전체 이해 필요한데 3000자만 봄 + → 기존 시스템과 연동 불가능한 독립적 구현 생산 + → PM이 "빌드 통과했으니 complete" 판정 + → 실제로는 기존 제어관리와 전혀 연결 안 됨 + +최종: 8/8 task "성공" 판정 +→ 사용자가 확인: "이거 다 뜯어 고쳐야 하는데?" +→ 파이프라인 2시간 + 사용자 수동 수정 3시간 = 5시간 낭비 +→ PM이 직접 했으면 2~3시간에 끝 +``` + +--- + +*작성일: 2026-03-03* +*대상: Agent Pipeline v3.0 (`_local/agent-pipeline/`)* +*맥락: 결재 시스템 v2 재설계 프로젝트 (`docs/결재시스템_구현_현황.md`)* diff --git a/frontend/app/(auth)/login/page.tsx b/frontend/app/(auth)/login/page.tsx index fe697cee..62e48e08 100644 --- a/frontend/app/(auth)/login/page.tsx +++ b/frontend/app/(auth)/login/page.tsx @@ -5,17 +5,13 @@ import { LoginHeader } from "@/components/auth/LoginHeader"; import { LoginForm } from "@/components/auth/LoginForm"; import { LoginFooter } from "@/components/auth/LoginFooter"; -/** - * 로그인 페이지 컴포넌트 - * 비즈니스 로직은 useLogin 훅에서 처리하고, UI 컴포넌트들을 조합하여 구성 - */ export default function LoginPage() { const { formData, isLoading, error, showPassword, handleInputChange, handleLogin, togglePasswordVisibility } = useLogin(); return ( -
-
+
+
= { requested: { label: "요청", variant: "outline" }, @@ -63,8 +73,19 @@ function SentTab() { const fetchRequests = useCallback(async () => { setLoading(true); - const res = await getApprovalRequests({ my_approvals: false }); - if (res.success && res.data) setRequests(res.data); + try { + // 현재 로그인 사용자 ID를 기반으로 내가 올린 결재만 조회 + const userRes = await getCurrentUser(); + if (userRes.success && userRes.data) { + const res = await getApprovalRequests({ requester_id: userRes.data.userId }); + if (res.success && res.data) setRequests(res.data); + } else { + // 사용자 정보 없으면 빈 목록 표시 + setRequests([]); + } + } catch { + setRequests([]); + } setLoading(false); }, []); @@ -97,47 +118,126 @@ function SentTab() { return (
{loading ? ( -
- -
+ <> + {/* 데스크톱 스켈레톤 */} +
+ + + + 제목 + 대상 테이블 + 진행 + 상태 + 요청일 + 보기 + + + + {Array.from({ length: 5 }).map((_, i) => ( + +
+
+
+
+
+
+ + ))} + +
+
+ {/* 모바일 스켈레톤 */} +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+
+
+
+
+
+ {Array.from({ length: 3 }).map((_, j) => ( +
+
+
+
+ ))} +
+
+ ))} +
+ ) : requests.length === 0 ? ( -
+

상신한 결재가 없습니다.

) : ( -
- - - - 제목 - 대상 테이블 - 진행 - 상태 - 요청일 - 보기 - - - - {requests.map((req) => ( - - {req.title} - {req.target_table} - - {req.current_step}/{req.total_steps} - - - {formatDate(req.created_at)} - - - + <> + {/* 데스크톱 테이블 */} +
+
+ + + 제목 + 대상 테이블 + 진행 + 상태 + 요청일 + 보기 - ))} - -
-
+ + + {requests.map((req) => ( + + {req.title} + {req.target_table} + + {req.current_step}/{req.total_steps} + + + {formatDate(req.created_at)} + + + + + ))} + + +
+ {/* 모바일 카드 */} +
+ {requests.map((req) => ( +
+
+

{req.title}

+ +
+
+
+ 대상 테이블 + {req.target_table || "-"} +
+
+ 진행 + {req.current_step}/{req.total_steps} +
+
+ 요청일 + {formatDate(req.created_at)} +
+
+
+ +
+
+ ))} +
+ )} {/* 상세 모달 */} @@ -233,22 +333,36 @@ function ReceivedTab() { const [processOpen, setProcessOpen] = useState(false); const [selectedLine, setSelectedLine] = useState(null); + const [selectedRequest, setSelectedRequest] = useState(null); + const [detailLoading, setDetailLoading] = useState(false); const [comment, setComment] = useState(""); const [isProcessing, setIsProcessing] = useState(false); const fetchPending = useCallback(async () => { setLoading(true); - const res = await getMyPendingApprovals(); - if (res.success && res.data) setPendingLines(res.data); + try { + const res = await getMyPendingApprovals(); + if (res.success && res.data) setPendingLines(res.data); + } catch { + setPendingLines([]); + } setLoading(false); }, []); useEffect(() => { fetchPending(); }, [fetchPending]); - const openProcess = (line: ApprovalLine) => { + const openProcess = async (line: ApprovalLine) => { setSelectedLine(line); + setSelectedRequest(null); setComment(""); setProcessOpen(true); + // 결재 상세 내용(결재선 포함) 비동기 로드 + if (line.request_id) { + setDetailLoading(true); + const res = await getApprovalRequest(line.request_id); + if (res.success && res.data) setSelectedRequest(res.data); + setDetailLoading(false); + } }; const handleProcess = async (action: "approved" | "rejected") => { @@ -271,64 +385,147 @@ function ReceivedTab() { return (
{loading ? ( -
- -
+ <> + {/* 데스크톱 스켈레톤 */} +
+ + + + 제목 + 요청자 + 대상 테이블 + 단계 + 요청일 + 처리 + + + + {Array.from({ length: 5 }).map((_, i) => ( + +
+
+
+
+
+
+ + ))} + +
+
+ {/* 모바일 스켈레톤 */} +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+
+
+
+
+
+ {Array.from({ length: 3 }).map((_, j) => ( +
+
+
+
+ ))} +
+
+ ))} +
+ ) : pendingLines.length === 0 ? ( -
+

결재 대기 건이 없습니다.

) : ( -
- - - - 제목 - 요청자 - 대상 테이블 - 단계 - 요청일 - 처리 - - - - {pendingLines.map((line) => ( - - {line.title || "-"} - - {line.requester_name || "-"} - {line.requester_dept && ( - ({line.requester_dept}) - )} - - {line.target_table || "-"} - - {line.step_order}차 - - {formatDate(line.request_created_at || line.created_at)} - - - + <> + {/* 데스크톱 테이블 */} +
+
+ + + 제목 + 요청자 + 대상 테이블 + 단계 + 요청일 + 처리 - ))} - -
-
+ + + {pendingLines.map((line) => ( + + {line.title || "-"} + + {line.requester_name || "-"} + {line.requester_dept && ( + ({line.requester_dept}) + )} + + {line.target_table || "-"} + + {line.step_order}차 + + {formatDate(line.request_created_at || line.created_at)} + + + + + ))} + + +
+ {/* 모바일 카드 */} +
+ {pendingLines.map((line) => ( +
+
+

{line.title || "-"}

+ {line.step_order}차 +
+
+
+ 요청자 + + {line.requester_name || "-"} + {line.requester_dept && ( + ({line.requester_dept}) + )} + +
+
+ 대상 테이블 + {line.target_table || "-"} +
+
+ 요청일 + {formatDate(line.request_created_at || line.created_at)} +
+
+
+ +
+
+ ))} +
+ )} {/* 결재 처리 모달 */} - + 결재 처리 {selectedLine?.title} -
+
요청자 @@ -338,18 +535,84 @@ function ReceivedTab() { 결재 단계

{selectedLine?.step_order}차 결재

+ {selectedLine?.target_table && ( +
+ 대상 테이블 +

{selectedLine.target_table}

+
+ )} +
+ 요청일 +

{formatDate(selectedLine?.request_created_at || selectedLine?.created_at)}

+
+ + {/* 결재선 상세 */} + {detailLoading ? ( +
+ + 결재선 로딩 중... +
+ ) : selectedRequest?.lines && selectedRequest.lines.length > 0 && ( +
+ 전체 결재선 +
+ {selectedRequest.lines + .sort((a, b) => a.step_order - b.step_order) + .map((line) => ( +
+
+ + {line.step_order}차 + + {line.approver_name || line.approver_id} + {line.approver_position && ( + ({line.approver_position}) + )} + {line.line_id === selectedLine?.line_id && ( + 내 차례 + )} +
+
+ + {line.processed_at && ( + {formatDate(line.processed_at)} + )} +
+
+ ))} +
+
+ )} + + {/* 의견 입력 */}
- +