diff --git a/docs/kjs/배치_노드플로우_연동_계획서.md b/docs/kjs/배치_노드플로우_연동_계획서.md new file mode 100644 index 00000000..f3a03d7d --- /dev/null +++ b/docs/kjs/배치_노드플로우_연동_계획서.md @@ -0,0 +1,355 @@ +# 배치 스케줄러 + 노드 플로우 연동 계획서 + +## 1. 배경 및 목적 + +### 현재 상태 + +현재 시스템에는 두 개의 독립적인 실행 엔진이 있다: + +| 시스템 | 역할 | 트리거 방식 | +|--------|------|-------------| +| **배치 스케줄러** | Cron 기반 자동 실행 (데이터 복사만 가능) | 시간 기반 (node-cron) | +| **노드 플로우 엔진** | 조건/변환/INSERT/UPDATE/DELETE 등 복합 로직 | 버튼 클릭 (수동) | + +### 문제 + +- 배치는 **INSERT/UPSERT만** 가능하고, 조건 기반 UPDATE/DELETE를 못 함 +- 노드 플로우는 강력하지만 **수동 실행만** 가능 (버튼 클릭 필수) +- "퇴사일이 지나면 자동으로 퇴사 처리" 같은 **시간 기반 비즈니스 로직**을 구현할 수 없음 + +### 목표 + +배치 스케줄러가 노드 플로우를 자동 실행할 수 있도록 연동하여, +시간 기반 비즈니스 로직 자동화를 지원한다. + +``` +[배치 스케줄러] ──Cron 트리거──> [노드 플로우 실행 엔진] + │ │ + │ ├── 테이블 소스 조회 + │ ├── 조건 분기 + │ ├── UPDATE / DELETE / INSERT + │ ├── 이메일 발송 + │ └── 로깅 + │ + └── 실행 로그 기록 (batch_execution_logs) +``` + +--- + +## 2. 사용 시나리오 + +### 시나리오 A: 자동 퇴사 처리 + +``` +매일 자정 실행: + 1. user_info에서 퇴사일 <= NOW() AND 상태 != '퇴사' 인 사람 조회 + 2. 해당 사용자의 상태를 '퇴사'로 UPDATE + 3. 관리자에게 이메일 알림 발송 +``` + +### 시나리오 B: 월말 재고 마감 + +``` +매월 1일 00:00 실행: + 1. 전월 재고 데이터를 재고마감 테이블로 INSERT + 2. 이월 수량 계산 후 UPDATE +``` + +### 시나리오 C: 미납 알림 + +``` +매일 09:00 실행: + 1. 납기일이 지난 미납 주문 조회 + 2. 담당자에게 이메일 발송 + 3. 알림 로그 INSERT +``` + +### 시나리오 D: 외부 API 연동 자동화 + +``` +매시간 실행: + 1. 외부 REST API에서 데이터 조회 + 2. 조건 필터링 (변경된 데이터만) + 3. 내부 테이블에 UPSERT +``` + +--- + +## 3. 구현 범위 + +### 3.1 DB 변경 (batch_configs 테이블 확장) + +```sql +-- batch_configs 테이블에 컬럼 추가 +ALTER TABLE batch_configs + ADD COLUMN execution_type VARCHAR(20) DEFAULT 'mapping', + ADD COLUMN node_flow_id INTEGER DEFAULT NULL, + ADD COLUMN node_flow_context JSONB DEFAULT NULL; + +-- execution_type: 'mapping' (기존 데이터 복사) | 'node_flow' (노드 플로우 실행) +-- node_flow_id: node_flows 테이블의 flow_id (FK) +-- node_flow_context: 플로우 실행 시 전달할 컨텍스트 데이터 (선택) + +COMMENT ON COLUMN batch_configs.execution_type IS '실행 타입: mapping(기존 데이터 복사), node_flow(노드 플로우 실행)'; +COMMENT ON COLUMN batch_configs.node_flow_id IS '연결된 노드 플로우 ID (execution_type이 node_flow일 때 사용)'; +COMMENT ON COLUMN batch_configs.node_flow_context IS '플로우 실행 시 전달할 컨텍스트 데이터 (JSON)'; +``` + +기존 데이터에 영향 없음 (`DEFAULT 'mapping'`으로 하위 호환성 보장) + +### 3.2 백엔드 변경 + +#### BatchSchedulerService 수정 (핵심) + +`executeBatchConfig()` 메서드에서 `execution_type` 분기: + +``` +executeBatchConfig(config) + ├── config.execution_type === 'mapping' + │ └── 기존 executeBatchMappings() (변경 없음) + │ + └── config.execution_type === 'node_flow' + └── NodeFlowExecutionService.executeFlow() + ├── 노드 플로우 조회 + ├── 위상 정렬 + ├── 레벨별 실행 + └── 결과 반환 +``` + +수정 파일: +- `backend-node/src/services/batchSchedulerService.ts` + - `executeBatchConfig()` 에 node_flow 분기 추가 + - 노드 플로우 실행 결과를 배치 로그 형식으로 변환 + +#### 배치 설정 API 수정 + +수정 파일: +- `backend-node/src/types/batchTypes.ts` + - `BatchConfig` 인터페이스에 `execution_type`, `node_flow_id`, `node_flow_context` 추가 + - `CreateBatchConfigRequest`, `UpdateBatchConfigRequest` 에도 추가 +- `backend-node/src/services/batchService.ts` + - `createBatchConfig()` - 새 필드 INSERT + - `updateBatchConfig()` - 새 필드 UPDATE +- `backend-node/src/controllers/batchManagementController.ts` + - 생성/수정 시 새 필드 처리 + +#### 노드 플로우 목록 API (배치용) + +추가 파일/수정: +- `backend-node/src/routes/batchManagementRoutes.ts` + - `GET /api/batch-management/node-flows` 추가 (배치 설정 UI에서 플로우 선택용) + +### 3.3 프론트엔드 변경 + +#### 배치 생성/편집 UI 수정 + +수정 파일: +- `frontend/app/(main)/admin/automaticMng/batchmngList/create/page.tsx` +- `frontend/app/(main)/admin/automaticMng/batchmngList/edit/[id]/page.tsx` + +변경 내용: +- "실행 타입" 선택 추가 (기존 매핑 / 노드 플로우) +- 노드 플로우 선택 시: 플로우 드롭다운 표시 (기존 매핑 설정 숨김) +- 노드 플로우 선택 시: 컨텍스트 데이터 입력 (선택사항, JSON) + +``` +┌─────────────────────────────────────────┐ +│ 배치 설정 │ +├─────────────────────────────────────────┤ +│ 배치명: [자동 퇴사 처리 ] │ +│ 설명: [퇴사일 경과 사용자 자동 처리] │ +│ Cron: [0 0 * * * ] │ +│ │ +│ 실행 타입: ○ 데이터 매핑 ● 노드 플로우 │ +│ │ +│ ┌─ 노드 플로우 선택 ─────────────────┐ │ +│ │ [▾ 자동 퇴사 처리 플로우 ] │ │ +│ │ │ │ +│ │ 플로우 설명: user_info에서 퇴사일..│ │ +│ │ 노드 수: 4개 │ │ +│ └────────────────────────────────────┘ │ +│ │ +│ [취소] [저장] │ +└─────────────────────────────────────────┘ +``` + +#### 배치 목록 UI 수정 + +수정 파일: +- `frontend/app/(main)/admin/automaticMng/batchmngList/page.tsx` + +변경 내용: +- 목록에 "실행 타입" 컬럼 추가 (배지: `매핑` / `플로우`) +- 노드 플로우 타입은 연결된 플로우명 표시 + +--- + +## 4. 변경 파일 목록 + +### DB + +| 파일 | 변경 | 설명 | +|------|------|------| +| `db/migrations/XXXX_batch_node_flow_integration.sql` | 신규 | ALTER TABLE batch_configs | + +### 백엔드 + +| 파일 | 변경 | 설명 | +|------|------|------| +| `backend-node/src/services/batchSchedulerService.ts` | 수정 | executeBatchConfig에 node_flow 분기 | +| `backend-node/src/types/batchTypes.ts` | 수정 | BatchConfig 타입에 새 필드 추가 | +| `backend-node/src/services/batchService.ts` | 수정 | create/update에 새 필드 처리 | +| `backend-node/src/controllers/batchManagementController.ts` | 수정 | 새 필드 API 처리 | +| `backend-node/src/routes/batchManagementRoutes.ts` | 수정 | node-flows 목록 엔드포인트 추가 | + +### 프론트엔드 + +| 파일 | 변경 | 설명 | +|------|------|------| +| `frontend/app/(main)/admin/automaticMng/batchmngList/page.tsx` | 수정 | 목록에 실행 타입 표시 | +| `frontend/app/(main)/admin/automaticMng/batchmngList/create/page.tsx` | 수정 | 실행 타입 선택 + 플로우 선택 | +| `frontend/app/(main)/admin/automaticMng/batchmngList/edit/[id]/page.tsx` | 수정 | 실행 타입 선택 + 플로우 선택 | + +--- + +## 5. 핵심 구현 상세 + +### 5.1 BatchSchedulerService 변경 (가장 중요) + +```typescript +// batchSchedulerService.ts - executeBatchConfig 메서드 수정 + +static async executeBatchConfig(config: any) { + const startTime = new Date(); + let executionLog: any = null; + + try { + // ... 실행 로그 생성 (기존 코드 유지) ... + + let result; + + // 실행 타입에 따라 분기 + if (config.execution_type === 'node_flow' && config.node_flow_id) { + // 노드 플로우 실행 + result = await this.executeNodeFlow(config); + } else { + // 기존 매핑 실행 (하위 호환) + result = await this.executeBatchMappings(config); + } + + // ... 실행 로그 업데이트 (기존 코드 유지) ... + return result; + } catch (error) { + // ... 에러 처리 (기존 코드 유지) ... + } +} + +/** + * 노드 플로우 실행 (신규) + */ +private static async executeNodeFlow(config: any) { + const { NodeFlowExecutionService } = await import('./nodeFlowExecutionService'); + + const context = { + sourceData: [], + dataSourceType: 'none', + nodeResults: new Map(), + executionOrder: [], + buttonContext: { + buttonId: `batch_${config.id}`, + companyCode: config.company_code, + userId: config.created_by || 'batch_system', + formData: config.node_flow_context || {}, + }, + }; + + const flowResult = await NodeFlowExecutionService.executeFlow( + config.node_flow_id, + context + ); + + // 노드 플로우 결과를 배치 로그 형식으로 변환 + return { + totalRecords: flowResult.totalNodes || 0, + successRecords: flowResult.successNodes || 0, + failedRecords: flowResult.failedNodes || 0, + }; +} +``` + +### 5.2 실행 결과 매핑 + +노드 플로우 결과 → 배치 로그 변환: + +| 노드 플로우 결과 | 배치 로그 필드 | 설명 | +|------------------|---------------|------| +| 전체 노드 수 | total_records | 실행 대상 노드 수 | +| 성공 노드 수 | success_records | 성공적으로 실행된 노드 | +| 실패 노드 수 | failed_records | 실패한 노드 | +| 에러 메시지 | error_message | 첫 번째 실패 노드의 에러 | + +### 5.3 보안 고려사항 + +- 배치에서 실행되는 노드 플로우도 **company_code** 필터링 적용 +- 배치 설정의 company_code와 노드 플로우의 company_code가 일치해야 함 +- 최고 관리자(`*`)는 모든 플로우 실행 가능 +- 실행 로그에 `batch_system`으로 사용자 기록 + +--- + +## 6. 구현 순서 + +### Phase 1: DB + 백엔드 (1일) + +1. 마이그레이션 SQL 작성 및 실행 +2. `batchTypes.ts` 타입 수정 +3. `batchService.ts` create/update 수정 +4. `batchSchedulerService.ts` 핵심 분기 로직 추가 +5. `batchManagementRoutes.ts` 노드 플로우 목록 API 추가 +6. 수동 실행 테스트 (`POST /batch-configs/:id/execute`) + +### Phase 2: 프론트엔드 (1일) + +1. 배치 생성 페이지에 실행 타입 선택 추가 +2. 노드 플로우 드롭다운 구현 +3. 배치 편집 페이지 동일 적용 +4. 배치 목록에 실행 타입 배지 표시 + +### Phase 3: 테스트 및 검증 (0.5일) + +1. 테스트용 노드 플로우 생성 (간단한 UPDATE) +2. 배치 설정에 연결 +3. 수동 실행 테스트 +4. Cron 스케줄 자동 실행 테스트 +5. 실행 로그 확인 + +--- + +## 7. 리스크 및 대응 + +### 7.1 노드 플로우 실행 시간 초과 + +- **리스크**: 복잡한 플로우가 오래 걸려서 다음 스케줄과 겹칠 수 있음 +- **대응**: 실행 중인 배치는 중복 실행 방지 (락 메커니즘) - Phase 2 이후 고려 + +### 7.2 노드 플로우 삭제 시 배치 깨짐 + +- **리스크**: 연결된 노드 플로우가 삭제되면 배치 실행 실패 +- **대응**: + - 플로우 존재 여부 체크 후 실행 + - 실패 시 로그에 "플로우를 찾을 수 없습니다" 기록 + - (향후) 플로우 삭제 시 연결된 배치가 있으면 경고 + +### 7.3 멀티 인스턴스 환경 + +- **리스크**: 서버가 여러 대일 때 같은 배치가 중복 실행 +- **대응**: 현재 단일 인스턴스 운영이므로 당장은 문제 없음. 향후 Redis 기반 분산 락 고려 + +--- + +## 8. 기대 효과 + +1. **시간 기반 비즈니스 자동화**: 수동 작업 없이 조건 충족 시 자동 처리 +2. **기존 인프라 재활용**: 검증된 배치 스케줄러(1,200+건 성공) + 강력한 노드 플로우 엔진 +3. **최소 코드 변경**: DB 컬럼 3개 + 백엔드 분기 1개 + 프론트 UI 확장 +4. **확장성**: 향후 이벤트 트리거(데이터 변경 감지) 등으로 확장 가능 diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 19097f43..f1e6383a 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -306,16 +306,126 @@ select { } } -/* ===== Sonner 토스트 애니메이션 완전 제거 ===== */ -[data-sonner-toaster] [data-sonner-toast] { - animation: none !important; - transition: none !important; - opacity: 1 !important; - transform: none !important; +/* ===== Sonner Toast - B안 (하단 중앙 스낵바) ===== */ + +/* 기본 토스트: 다크 배경 스낵바 */ +[data-sonner-toaster] [data-sonner-toast].sonner-toast-snackbar { + --normal-bg: hsl(222 30% 16%); + --normal-text: hsl(210 20% 92%); + --normal-border: hsl(222 20% 24%); + + background: var(--normal-bg); + color: var(--normal-text); + border: 1px solid var(--normal-border); + border-radius: 10px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25); + padding: 10px 16px; + font-size: 13px; + font-weight: 500; + gap: 10px; } -[data-sonner-toaster] [data-sonner-toast][data-mounted="true"] { - animation: none !important; + +/* 다크모드 토스트 */ +.dark [data-sonner-toaster] [data-sonner-toast].sonner-toast-snackbar { + --normal-bg: hsl(220 25% 14%); + --normal-border: hsl(220 20% 22%); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); } + +/* 성공 토스트 - 좌측 초록 바 */ +[data-sonner-toaster] [data-sonner-toast].sonner-toast-success { + --success-bg: hsl(222 30% 16%); + --success-text: hsl(210 20% 92%); + --success-border: hsl(222 20% 24%); + + background: var(--success-bg) !important; + color: var(--success-text) !important; + border: 1px solid var(--success-border) !important; + border-left: 3px solid hsl(142 76% 42%) !important; + border-radius: 10px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25); +} + +.dark [data-sonner-toaster] [data-sonner-toast].sonner-toast-success { + --success-bg: hsl(220 25% 14%) !important; + --success-border: hsl(220 20% 22%) !important; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); +} + +/* 에러 토스트 - 좌측 빨간 바 + 약간 붉은 배경 */ +[data-sonner-toaster] [data-sonner-toast].sonner-toast-error { + --error-bg: hsl(0 30% 14%); + --error-text: hsl(0 20% 92%); + --error-border: hsl(0 20% 22%); + + background: var(--error-bg) !important; + color: var(--error-text) !important; + border: 1px solid var(--error-border) !important; + border-left: 3px solid hsl(0 72% 55%) !important; + border-radius: 10px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25); +} + +.dark [data-sonner-toaster] [data-sonner-toast].sonner-toast-error { + --error-bg: hsl(0 25% 10%) !important; + --error-border: hsl(0 20% 18%) !important; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); +} + +/* 경고 토스트 - 좌측 노란 바 */ +[data-sonner-toaster] [data-sonner-toast].sonner-toast-warning { + background: hsl(222 30% 16%) !important; + color: hsl(210 20% 92%) !important; + border: 1px solid hsl(222 20% 24%) !important; + border-left: 3px solid hsl(38 92% 50%) !important; + border-radius: 10px; +} + +/* info 토스트 - 좌측 파란 바 */ +[data-sonner-toaster] [data-sonner-toast].sonner-toast-info { + background: hsl(222 30% 16%) !important; + color: hsl(210 20% 92%) !important; + border: 1px solid hsl(222 20% 24%) !important; + border-left: 3px solid hsl(217 91% 60%) !important; + border-radius: 10px; +} + +/* 토스트 내부 설명 텍스트 */ +[data-sonner-toaster] [data-sonner-toast] [data-description] { + color: hsl(210 15% 70%) !important; + font-size: 12px !important; +} + +/* 토스트 닫기 버튼: 토스트 안쪽 우측 상단 배치 */ +[data-sonner-toaster] [data-sonner-toast] [data-close-button] { + position: absolute !important; + top: 50% !important; + right: 8px !important; + left: auto !important; + transform: translateY(-50%) !important; + width: 20px !important; + height: 20px !important; + background: transparent !important; + border: none !important; + border-radius: 4px !important; + color: hsl(210 15% 55%) !important; + opacity: 0.6; + transition: opacity 0.15s, background 0.15s; +} +[data-sonner-toaster] [data-sonner-toast] [data-close-button]:hover { + background: hsl(220 20% 24%) !important; + color: hsl(210 20% 85%) !important; + opacity: 1; +} + +/* 토스트 액션 버튼 */ +[data-sonner-toaster] [data-sonner-toast] [data-button] { + color: hsl(217 91% 68%) !important; + font-weight: 700; + font-size: 12px; +} + +/* 애니메이션 제어: 부드러운 슬라이드 업만 허용, 나머지 제거 */ [data-sonner-toaster] [data-sonner-toast][data-removed="true"] { animation: none !important; } diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 2c5f1fa3..c57c6504 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -4,7 +4,7 @@ import "./globals.css"; import { ThemeProvider } from "@/components/providers/ThemeProvider"; import { QueryProvider } from "@/providers/QueryProvider"; import { RegistryProvider } from "./registry-provider"; -import { Toaster } from "sonner"; +import { Toaster } from "@/components/ui/sonner"; const inter = Inter({ subsets: ["latin"], @@ -45,7 +45,7 @@ export default function RootLayout({ {children} - + {/* Portal 컨테이너 */} diff --git a/frontend/components/ui/sonner.tsx b/frontend/components/ui/sonner.tsx new file mode 100644 index 00000000..0911ead1 --- /dev/null +++ b/frontend/components/ui/sonner.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { useTheme } from "next-themes"; +import { Toaster as SonnerToaster } from "sonner"; + +export function Toaster() { + const { theme = "system" } = useTheme(); + + return ( + + ); +} diff --git a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx index 31cabb70..616ef121 100644 --- a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx @@ -6317,7 +6317,7 @@ export const TableListComponent: React.FC = ({ "hover:bg-accent cursor-pointer border-b border-border/50 transition-[background] duration-75", index % 2 === 0 ? "bg-background" : "bg-muted/20", isRowSelected && "!bg-primary/10 hover:!bg-primary/15", - isRowFocused && "ring-primary/50 ring-1 ring-inset", + isRowFocused && "bg-accent/50", isDragEnabled && "cursor-grab active:cursor-grabbing", isDragging && "bg-muted opacity-50", isDropTarget && "border-t-primary border-t-2", @@ -6381,10 +6381,10 @@ export const TableListComponent: React.FC = ({ inputType !== "image" && "overflow-hidden text-ellipsis whitespace-nowrap max-w-[170px]", column.columnName === "__checkbox__" ? "px-0 py-[7px] text-center" : "px-3 py-[7px]", isFrozen && "sticky z-20 shadow-[2px_0_4px_rgba(0,0,0,0.08)]", - isCellFocused && !editingCell && "ring-primary bg-primary/5 ring-2 ring-inset", + isCellFocused && !editingCell && "bg-primary/5 shadow-[inset_0_0_0_2px_hsl(var(--primary))]", editingCell?.rowIndex === index && editingCell?.colIndex === colIndex && "p-0", isModified && !cellValidationError && "bg-amber-100 dark:bg-amber-900/40", - cellValidationError && "bg-red-50 ring-2 ring-red-500 ring-inset dark:bg-red-950/40", + cellValidationError && "bg-red-50 shadow-[inset_0_0_0_2px_hsl(0_84%_60%)] dark:bg-red-950/40", isSearchHighlighted && !isCellFocused && "bg-yellow-200 dark:bg-yellow-700/50", column.editable === false && "bg-muted/10 dark:bg-muted/10", // 코드 컬럼: mono 폰트 + primary 색상