From 898866a2f0b90a5930e13eb919fb0a277572bd57 Mon Sep 17 00:00:00 2001 From: hyeonsu Date: Thu, 11 Sep 2025 18:00:24 +0900 Subject: [PATCH 01/13] =?UTF-8?q?=EA=B8=B0=ED=83=80=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=EC=82=AC=ED=95=AD=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dataflow/ConnectionSetupModal.tsx | 19 ------------------- .../components/dataflow/DataFlowDesigner.tsx | 1 - 2 files changed, 20 deletions(-) diff --git a/frontend/components/dataflow/ConnectionSetupModal.tsx b/frontend/components/dataflow/ConnectionSetupModal.tsx index 4147b87e..1ece10f5 100644 --- a/frontend/components/dataflow/ConnectionSetupModal.tsx +++ b/frontend/components/dataflow/ConnectionSetupModal.tsx @@ -677,25 +677,6 @@ export const ConnectionSetupModal: React.FC = ({ className="text-sm" /> -
- - -
{/* 연결 종류 선택 */} diff --git a/frontend/components/dataflow/DataFlowDesigner.tsx b/frontend/components/dataflow/DataFlowDesigner.tsx index f0951f6b..a9e52128 100644 --- a/frontend/components/dataflow/DataFlowDesigner.tsx +++ b/frontend/components/dataflow/DataFlowDesigner.tsx @@ -1175,7 +1175,6 @@ export const DataFlowDesigner: React.FC = ({ const columns = selectedColumns[tableName]; const node = nodes.find((n) => n.data.table.tableName === tableName); const displayName = node?.data.table.displayName || tableName; - return (
{/* 테이블 정보 */} From 978a4937ad048c8166794bdf39241671d77c2e79 Mon Sep 17 00:00:00 2001 From: hyeonsu Date: Fri, 12 Sep 2025 09:49:53 +0900 Subject: [PATCH 02/13] =?UTF-8?q?DB=20=EC=8A=A4=ED=82=A4=EB=A7=88=20?= =?UTF-8?q?=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/prisma/schema.prisma | 6 + .../src/services/dataflowDiagramService.ts | 12 + docs/조건부_연결_구현_계획.md | 297 ++++++++++++++++++ frontend/lib/api/dataflow.ts | 69 +++- 4 files changed, 383 insertions(+), 1 deletion(-) create mode 100644 docs/조건부_연결_구현_계획.md diff --git a/backend-node/prisma/schema.prisma b/backend-node/prisma/schema.prisma index 5d0aa77b..bb214604 100644 --- a/backend-node/prisma/schema.prisma +++ b/backend-node/prisma/schema.prisma @@ -5328,6 +5328,12 @@ model dataflow_diagrams { diagram_name String @db.VarChar(255) relationships Json // 모든 관계 정보를 JSON으로 저장 node_positions Json? // 테이블 노드의 캔버스 위치 정보 (JSON 형태) + + // 조건부 연결 관련 컬럼들 + control Json? // 조건 설정 (트리거 타입, 조건 트리) + category Json? // 연결 종류 (타입, 기본 옵션) + plan Json? // 실행 계획 (대상 액션들) + company_code String @db.VarChar(50) created_at DateTime? @default(now()) @db.Timestamp(6) updated_at DateTime? @default(now()) @updatedAt @db.Timestamp(6) diff --git a/backend-node/src/services/dataflowDiagramService.ts b/backend-node/src/services/dataflowDiagramService.ts index 16524e4a..c477df93 100644 --- a/backend-node/src/services/dataflowDiagramService.ts +++ b/backend-node/src/services/dataflowDiagramService.ts @@ -8,6 +8,12 @@ interface CreateDataflowDiagramData { diagram_name: string; relationships: any; // JSON 데이터 node_positions?: any; // JSON 데이터 (노드 위치 정보) + + // 조건부 연결 관련 필드 + control?: any; // JSON 데이터 (조건 설정) + category?: any; // JSON 데이터 (연결 종류) + plan?: any; // JSON 데이터 (실행 계획) + company_code: string; created_by: string; updated_by: string; @@ -17,6 +23,12 @@ interface UpdateDataflowDiagramData { diagram_name?: string; relationships?: any; // JSON 데이터 node_positions?: any; // JSON 데이터 (노드 위치 정보) + + // 조건부 연결 관련 필드 + control?: any; // JSON 데이터 (조건 설정) + category?: any; // JSON 데이터 (연결 종류) + plan?: any; // JSON 데이터 (실행 계획) + updated_by: string; } diff --git a/docs/조건부_연결_구현_계획.md b/docs/조건부_연결_구현_계획.md new file mode 100644 index 00000000..a16b7cd7 --- /dev/null +++ b/docs/조건부_연결_구현_계획.md @@ -0,0 +1,297 @@ +# 🔗 조건부 연결 기능 구현 계획 + +## 📋 프로젝트 개요 + +현재 DataFlow 시스템에서 3가지 연결 종류를 지원하고 있으며, 이 중 **데이터 저장**과 **외부 호출** 기능에 조건부 실행 로직을 추가해야 합니다. + +### 현재 연결 종류 + +1. **단순 키값 연결** - 조건 설정 불필요 (기존 방식 유지) +2. **데이터 저장** - 조건부 실행 필요 ✨ +3. **외부 호출** - 조건부 실행 필요 ✨ + +## 🎯 기능 요구사항 + +### 데이터 저장 기능 + +``` +"from 테이블의 컬럼이 특정 조건을 만족하면 to 테이블에 특정 액션을 취할 것" +``` + +**예시 시나리오:** + +- `work_order` 테이블의 `status = 'APPROVED'` 이고 `quantity > 0` 일 때 +- `material_requirement` 테이블에 자재 소요량 데이터 INSERT + +### 외부 호출 기능 + +``` +"from테이블의 컬럼이 특정 조건을 만족하면 외부 api호출이나 이메일 발송 등의 동작을 취해야 함" +``` + +**예시 시나리오:** + +- `employee_master` 테이블의 `employment_status = 'APPROVED'` 일 때 +- 외부 이메일 API 호출하여 환영 메일 발송 + +## 🗄️ 데이터베이스 스키마 변경 + +### 1. 컬럼 추가 + +```sql +-- 기존 데이터 삭제 후 dataflow_diagrams 테이블에 3개 컬럼 추가 +DELETE FROM dataflow_diagrams; -- 기존 데이터 전체 삭제 + +ALTER TABLE dataflow_diagrams +ADD COLUMN control JSONB, -- 조건 설정 +ADD COLUMN category JSONB, -- 연결 종류 설정 +ADD COLUMN plan JSONB; -- 실행 계획 설정 + +-- 인덱스 추가 +CREATE INDEX idx_dataflow_control_trigger ON dataflow_diagrams USING GIN ((control->'triggerType')); +CREATE INDEX idx_dataflow_category_type ON dataflow_diagrams USING GIN ((category->'type')); +``` + +### 2. 데이터 구조 설계 + +#### `control` 컬럼 - 조건 설정 + +```json +{ + "triggerType": "insert", + "conditionTree": { + "type": "group", + "operator": "AND", + "children": [ + { + "type": "condition", + "field": "status", + "operator": "=", + "value": "APPROVED" + } + ] + } +} +``` + +#### `category` 컬럼 - 연결 종류 + +```json +{ + "type": "data-save", // "simple-key" | "data-save" | "external-call" + "rollbackOnError": true, + "enableLogging": true +} +``` + +#### `plan` 컬럼 - 실행 계획 + +```json +{ + "sourceTable": "work_order", + "targetActions": [ + { + "id": "action_1", + "actionType": "insert", + "targetTable": "material_requirement", + "enabled": true, + "fieldMappings": [ + { + "sourceField": "work_order_id", + "targetField": "order_id" + } + ] + } + ] +} +``` + +## 🎨 프론트엔드 UI 개선 + +### ConnectionSetupModal.tsx 재설계 + +#### 현재 구조 문제점 + +- 모든 연결 종류에 동일한 UI 적용 +- 조건 설정 기능 없음 +- 단순 키값 연결과 조건부 연결의 구분 없음 + +#### 개선 방안 + +##### 1. 연결 종류별 UI 분기 + +```tsx +// 연결 종류 선택 후 조건부 렌더링 +{ + config.connectionType === "simple-key" && ; +} + +{ + (config.connectionType === "data-save" || + config.connectionType === "external-call") && ( + + ); +} +``` + +##### 2. 조건 설정 섹션 추가 + +```tsx +// control.html의 제어 조건 설정 섹션을 참조하여 구현 +
+

📋 실행 조건 설정

+ +
+``` + +##### 3. 액션 설정 섹션 + +```tsx +
+

⚡ 실행 액션

+ {config.connectionType === "data-save" && } + {config.connectionType === "external-call" && } +
+``` + +### 새로운 컴포넌트 구조 + +``` +ConnectionSetupModal.tsx +├── BasicConnectionInfo (공통) +├── ConnectionTypeSelector (공통) +├── SimpleKeyConnectionSettings (단순 키값 전용) +└── ConditionalConnectionSettings (조건부 연결 전용) + ├── ConditionBuilder (조건 설정) + ├── DataSaveActionSettings (데이터 저장 액션) + └── ExternalCallActionSettings (외부 호출 액션) +``` + +## ⚙️ 백엔드 서비스 구현 + +### 1. EventTriggerService 생성 + +```typescript +// backend-node/src/services/eventTriggerService.ts +export class EventTriggerService { + static async executeEventTriggers( + triggerType: "insert" | "update" | "delete", + tableName: string, + data: Record, + companyCode: string + ): Promise; + + static async executeDataSaveAction( + action: TargetAction, + sourceData: Record + ): Promise; + + static async executeExternalCallAction( + action: ExternalCallAction, + sourceData: Record + ): Promise; +} +``` + +### 2. DynamicFormService 연동 + +```typescript +// 기존 saveFormData 메서드에 트리거 실행 추가 +async saveFormData(screenId: number, tableName: string, data: Record) { + // 기존 저장 로직 + const result = await this.saveToDatabase(data); + + // 🔥 조건부 연결 실행 + await EventTriggerService.executeEventTriggers("insert", tableName, data, companyCode); + + return result; +} +``` + +### 3. API 엔드포인트 추가 + +```typescript +// backend-node/src/routes/dataflowRoutes.ts +router.post("/diagrams/:id/test-conditions", async (req, res) => { + // 조건 테스트 실행 +}); + +router.post("/diagrams/:id/execute-actions", async (req, res) => { + // 액션 수동 실행 +}); +``` + +## 📝 구현 단계별 계획 + +### Phase 1: 데이터베이스 준비 + +- [ ] dataflow_diagrams 테이블 컬럼 추가 (기존 데이터 삭제 후 진행) +- [ ] Prisma 스키마 업데이트 + +### Phase 2: 프론트엔드 UI 개선 + +- [ ] ConnectionSetupModal.tsx 재구조화 +- [ ] ConditionBuilder 컴포넌트 개발 +- [ ] 연결 종류별 설정 컴포넌트 분리 +- [ ] control.html 참조하여 조건 설정 UI 구현 + +### Phase 3: 백엔드 서비스 개발 + +- [ ] EventTriggerService 기본 구조 생성 +- [ ] 조건 평가 엔진 구현 +- [ ] 데이터 저장 액션 실행 로직 +- [ ] DynamicFormService 연동 + +### Phase 4: 외부 호출 기능 + +- [ ] 외부 API 호출 서비스 +- [ ] 이메일 발송 기능 +- [ ] 웹훅 지원 +- [ ] 오류 처리 및 재시도 로직 + +## 🔧 기술적 고려사항 + +### 1. 성능 최적화 + +- 조건 평가 시 인덱스 활용 +- 대량 데이터 처리 시 배치 처리 +- 비동기 실행으로 메인 로직 블로킹 방지 + +### 2. 오류 처리 + +- 트랜잭션 롤백 지원 +- 부분 실패 시 복구 메커니즘 +- 상세한 실행 로그 저장 + +### 3. 보안 + +- SQL 인젝션 방지 +- 외부 API 호출 시 인증 처리 +- 민감 데이터 마스킹 + +### 4. 확장성 + +- 새로운 액션 타입 추가 용이성 +- 복잡한 조건문 지원 +- 다양한 외부 서비스 연동 + +## 📚 참고 자료 + +- [control.html](../control.html) - 제어 조건 설정 UI 참조 +- [ConnectionSetupModal.tsx](../frontend/components/dataflow/ConnectionSetupModal.tsx) - 현재 구현 +- [화면간*데이터*관계*설정*시스템\_설계.md](./화면간_데이터_관계_설정_시스템_설계.md) - 전체 시스템 설계 + +## 🚀 다음 단계 + +1. **데이터베이스 스키마 업데이트** 부터 시작 +2. **UI 재설계** - control.html 참조하여 조건 설정 UI 구현 +3. **백엔드 서비스** 단계별 구현 +4. **외부 호출 기능** 구현 + +--- + +_이 문서는 조건부 연결 기능 구현을 위한 전체적인 로드맵을 제시합니다. 각 단계별로 상세한 구현 계획을 수립하여 진행할 예정입니다._ diff --git a/frontend/lib/api/dataflow.ts b/frontend/lib/api/dataflow.ts index da3cb7f6..04c95785 100644 --- a/frontend/lib/api/dataflow.ts +++ b/frontend/lib/api/dataflow.ts @@ -2,6 +2,68 @@ import { apiClient, ApiResponse } from "./client"; // 테이블 간 데이터 관계 설정 관련 타입 정의 +// 조건부 연결 관련 타입들 +export interface ConditionControl { + triggerType: "insert" | "update" | "delete" | "insert_update"; + conditionTree: ConditionNode; +} + +export interface ConditionNode { + type: "group" | "condition"; + operator?: "AND" | "OR"; + children?: ConditionNode[]; + field?: string; + operator_type?: + | "=" + | "!=" + | ">" + | "<" + | ">=" + | "<=" + | "LIKE" + | "NOT_LIKE" + | "CONTAINS" + | "STARTS_WITH" + | "ENDS_WITH" + | "IN" + | "NOT_IN" + | "IS_NULL" + | "IS_NOT_NULL" + | "BETWEEN" + | "NOT_BETWEEN"; + value?: any; + dataType?: string; +} + +export interface ConnectionCategory { + type: "simple-key" | "data-save" | "external-call" | "conditional-link"; + rollbackOnError?: boolean; + enableLogging?: boolean; + maxRetryCount?: number; +} + +export interface ExecutionPlan { + sourceTable: string; + targetActions: TargetAction[]; +} + +export interface TargetAction { + id: string; + actionType: "insert" | "update" | "delete" | "upsert"; + targetTable: string; + enabled: boolean; + fieldMappings: FieldMapping[]; + conditions?: ConditionNode; + description?: string; +} + +export interface FieldMapping { + sourceField: string; + targetField: string; + transformFunction?: string; + defaultValue?: string; +} + export interface ColumnInfo { columnName: string; columnLabel?: string; @@ -46,7 +108,7 @@ export interface TableRelationship { to_table_name: string; to_column_name: string; relationship_type: "one-to-one" | "one-to-many" | "many-to-one" | "many-to-many"; - connection_type: "simple-key" | "data-save" | "external-call"; + connection_type: "simple-key" | "data-save" | "external-call" | "conditional-link"; settings?: Record; company_code: string; is_active?: string; @@ -98,6 +160,11 @@ export interface DataFlowDiagram { relationshipCount: number; tables: string[]; companyCode: string; // 회사 코드 추가 + + // 조건부 연결 관련 필드 + control?: ConditionControl; // 조건 설정 + category?: ConnectionCategory; // 연결 종류 + plan?: ExecutionPlan; // 실행 계획 createdAt: Date; createdBy: string; updatedAt: Date; From 441a5712c1ebfd910e8f971f60da73eee1c0052f Mon Sep 17 00:00:00 2001 From: hyeonsu Date: Fri, 12 Sep 2025 09:58:49 +0900 Subject: [PATCH 03/13] =?UTF-8?q?ConnectionSetupModal.tsx=20UI=20=EC=9E=AC?= =?UTF-8?q?=EC=84=A4=EA=B3=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dataflow/ConnectionSetupModal.tsx | 216 ++++++++++++++++-- .../components/dataflow/DataFlowDesigner.tsx | 1 - 2 files changed, 201 insertions(+), 16 deletions(-) diff --git a/frontend/components/dataflow/ConnectionSetupModal.tsx b/frontend/components/dataflow/ConnectionSetupModal.tsx index 1ece10f5..9a3b41d8 100644 --- a/frontend/components/dataflow/ConnectionSetupModal.tsx +++ b/frontend/components/dataflow/ConnectionSetupModal.tsx @@ -8,8 +8,8 @@ import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Badge } from "@/components/ui/badge"; import { Textarea } from "@/components/ui/textarea"; -import { ArrowRight, Link, Key, Save, Globe, Plus } from "lucide-react"; -import { DataFlowAPI, TableRelationship, TableInfo, ColumnInfo } from "@/lib/api/dataflow"; +import { Link, Key, Save, Globe, Plus, Zap, Trash2 } from "lucide-react"; +import { DataFlowAPI, TableRelationship, TableInfo, ColumnInfo, ConditionNode } from "@/lib/api/dataflow"; import toast from "react-hot-toast"; // 연결 정보 타입 @@ -36,7 +36,7 @@ interface ConnectionInfo { relationshipName: string; relationshipType: string; connectionType: string; - settings?: any; + settings?: Record; }; } @@ -76,7 +76,6 @@ interface ConnectionSetupModalProps { isOpen: boolean; connection: ConnectionInfo | null; companyCode: string; - diagramId?: number; onConfirm: (relationship: TableRelationship) => void; onCancel: () => void; } @@ -85,7 +84,6 @@ export const ConnectionSetupModal: React.FC = ({ isOpen, connection, companyCode, - diagramId, onConfirm, onCancel, }) => { @@ -127,6 +125,12 @@ export const ConnectionSetupModal: React.FC = ({ const [selectedFromColumns, setSelectedFromColumns] = useState([]); const [selectedToColumns, setSelectedToColumns] = useState([]); + // 조건부 연결을 위한 새로운 상태들 + const [triggerType, setTriggerType] = useState<"insert" | "update" | "delete" | "insert_update">("insert"); + const [conditions, setConditions] = useState([]); + const [rollbackOnError, setRollbackOnError] = useState(true); + const [enableLogging, setEnableLogging] = useState(true); + // 테이블 목록 로드 useEffect(() => { const loadTables = async () => { @@ -166,7 +170,8 @@ export const ConnectionSetupModal: React.FC = ({ connectionType: (existingRel?.connectionType as "simple-key" | "data-save" | "external-call") || "simple-key", fromColumnName: "", toColumnName: "", - description: existingRel?.settings?.description || `${fromDisplayName}과 ${toDisplayName} 간의 데이터 관계`, + description: + (existingRel?.settings?.description as string) || `${fromDisplayName}과 ${toDisplayName} 간의 데이터 관계`, settings: existingRel?.settings || {}, }); @@ -282,6 +287,32 @@ export const ConnectionSetupModal: React.FC = ({ const fromTableName = selectedFromTable || connection.fromNode.tableName; const toTableName = selectedToTable || connection.toNode.tableName; + // 조건부 연결 설정 데이터 준비 + const conditionalSettings = isConditionalConnection() + ? { + control: { + triggerType, + conditionTree: + conditions.length > 0 + ? { + type: "group" as const, + operator: "AND" as const, + children: conditions, + } + : null, + }, + category: { + type: config.connectionType, + rollbackOnError, + enableLogging, + }, + plan: { + sourceTable: fromTableName, + targetActions: [], // 나중에 액션 설정 UI에서 채울 예정 + }, + } + : {}; + // 메모리 기반 시스템: 관계 데이터만 생성하여 부모로 전달 const relationshipData: TableRelationship = { relationship_name: config.relationshipName, @@ -289,11 +320,12 @@ export const ConnectionSetupModal: React.FC = ({ to_table_name: toTableName, from_column_name: selectedFromColumns.join(","), // 여러 컬럼을 콤마로 구분 to_column_name: selectedToColumns.join(","), // 여러 컬럼을 콤마로 구분 - relationship_type: config.relationshipType as any, - connection_type: config.connectionType as any, + relationship_type: config.relationshipType, + connection_type: config.connectionType, company_code: companyCode, settings: { ...settings, + ...conditionalSettings, // 조건부 연결 설정 추가 description: config.description, multiColumnMapping: { fromColumns: selectedFromColumns, @@ -330,14 +362,165 @@ export const ConnectionSetupModal: React.FC = ({ if (!connection) return null; - // 선택된 컬럼 데이터 가져오기 - const selectedColumnsData = connection.selectedColumnsData || {}; - const tableNames = Object.keys(selectedColumnsData); - const fromTable = tableNames[0]; - const toTable = tableNames[1]; + // 선택된 컬럼 데이터 가져오기 (현재 사용되지 않음 - 향후 확장을 위해 유지) + // const selectedColumnsData = connection.selectedColumnsData || {}; - const fromTableData = selectedColumnsData[fromTable]; - const toTableData = selectedColumnsData[toTable]; + // 조건부 연결인지 확인하는 헬퍼 함수 + const isConditionalConnection = () => { + return config.connectionType === "data-save" || config.connectionType === "external-call"; + }; + + // 조건 관리 헬퍼 함수들 + const addCondition = () => { + const newCondition: ConditionNode = { + type: "condition", + field: "", + operator_type: "=", + value: "", + dataType: "string", + }; + setConditions([...conditions, newCondition]); + }; + + const updateCondition = (index: number, field: keyof ConditionNode, value: string) => { + const updatedConditions = [...conditions]; + updatedConditions[index] = { ...updatedConditions[index], [field]: value }; + setConditions(updatedConditions); + }; + + const removeCondition = (index: number) => { + const updatedConditions = conditions.filter((_, i) => i !== index); + setConditions(updatedConditions); + }; + + // 조건부 연결 설정 UI 렌더링 + const renderConditionalSettings = () => { + return ( +
+
+ + 조건부 실행 설정 +
+ + {/* 트리거 타입 선택 */} +
+ + +
+ + {/* 실행 조건 설정 */} +
+
+ + +
+ + {/* 조건 목록 */} +
+ {conditions.length === 0 ? ( +
+ 조건을 추가하면 해당 조건을 만족할 때만 실행됩니다. +
+ 조건이 없으면 항상 실행됩니다. +
+ ) : ( + conditions.map((condition, index) => ( +
+ + + + + updateCondition(index, "value", e.target.value)} + className="h-8 flex-1 text-xs" + /> + + +
+ )) + )} +
+
+ + {/* 추가 옵션 */} +
+ + + +
+
+ ); + }; // 연결 종류별 설정 패널 렌더링 const renderConnectionTypeSettings = () => { @@ -724,6 +907,9 @@ export const ConnectionSetupModal: React.FC = ({
+ {/* 조건부 연결을 위한 조건 설정 */} + {isConditionalConnection() && renderConditionalSettings()} + {/* 연결 종류별 상세 설정 */} {renderConnectionTypeSettings()} diff --git a/frontend/components/dataflow/DataFlowDesigner.tsx b/frontend/components/dataflow/DataFlowDesigner.tsx index a9e52128..93c7f3fe 100644 --- a/frontend/components/dataflow/DataFlowDesigner.tsx +++ b/frontend/components/dataflow/DataFlowDesigner.tsx @@ -1254,7 +1254,6 @@ export const DataFlowDesigner: React.FC = ({ isOpen={!!pendingConnection} connection={pendingConnection} companyCode={companyCode} - diagramId={currentDiagramId || diagramId || (relationshipId ? parseInt(relationshipId) : undefined)} onConfirm={handleConfirmConnection} onCancel={handleCancelConnection} /> From f50dd520ae7d2514b67b0f19760f5f60fa0d607f Mon Sep 17 00:00:00 2001 From: hyeonsu Date: Fri, 12 Sep 2025 10:05:25 +0900 Subject: [PATCH 04/13] =?UTF-8?q?=EB=B0=B1=EC=97=94=EB=93=9C=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../conditionalConnectionController.ts | 146 +++++ .../src/controllers/dataflowController.ts | 1 + backend-node/src/routes/dataflowRoutes.ts | 18 + .../src/services/dynamicFormService.ts | 61 +- .../src/services/eventTriggerService.ts | 581 ++++++++++++++++++ 5 files changed, 806 insertions(+), 1 deletion(-) create mode 100644 backend-node/src/controllers/conditionalConnectionController.ts create mode 100644 backend-node/src/services/eventTriggerService.ts diff --git a/backend-node/src/controllers/conditionalConnectionController.ts b/backend-node/src/controllers/conditionalConnectionController.ts new file mode 100644 index 00000000..a032ba6d --- /dev/null +++ b/backend-node/src/controllers/conditionalConnectionController.ts @@ -0,0 +1,146 @@ +import { Request, Response } from "express"; +import { logger } from "../utils/logger"; +import { AuthenticatedRequest } from "../types/auth"; +import { ApiResponse } from "../types/common"; +import { EventTriggerService } from "../services/eventTriggerService"; + +/** + * 조건부 연결 조건 테스트 + */ +export async function testConditionalConnection( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + logger.info("=== 조건부 연결 조건 테스트 시작 ==="); + + const { diagramId } = req.params; + const { testData } = req.body; + const companyCode = req.user?.company_code; + + if (!companyCode) { + const response: ApiResponse = { + success: false, + message: "회사 코드가 필요합니다.", + error: { + code: "MISSING_COMPANY_CODE", + details: "인증된 사용자의 회사 코드를 찾을 수 없습니다.", + }, + }; + res.status(400).json(response); + return; + } + + if (!diagramId || !testData) { + const response: ApiResponse = { + success: false, + message: "다이어그램 ID와 테스트 데이터가 필요합니다.", + error: { + code: "MISSING_REQUIRED_FIELDS", + details: "diagramId와 testData가 필요합니다.", + }, + }; + res.status(400).json(response); + return; + } + + const result = await EventTriggerService.testConditionalConnection( + parseInt(diagramId), + testData, + companyCode + ); + + const response: ApiResponse = { + success: true, + message: "조건부 연결 테스트를 성공적으로 완료했습니다.", + data: result, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("조건부 연결 테스트 실패:", error); + const response: ApiResponse = { + success: false, + message: "조건부 연결 테스트에 실패했습니다.", + error: { + code: "CONDITIONAL_CONNECTION_TEST_FAILED", + details: + error instanceof Error + ? error.message + : "알 수 없는 오류가 발생했습니다.", + }, + }; + res.status(500).json(response); + } +} + +/** + * 조건부 연결 액션 수동 실행 + */ +export async function executeConditionalActions( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + logger.info("=== 조건부 연결 액션 수동 실행 시작 ==="); + + const { diagramId } = req.params; + const { triggerType, tableName, data } = req.body; + const companyCode = req.user?.company_code; + + if (!companyCode) { + const response: ApiResponse = { + success: false, + message: "회사 코드가 필요합니다.", + error: { + code: "MISSING_COMPANY_CODE", + details: "인증된 사용자의 회사 코드를 찾을 수 없습니다.", + }, + }; + res.status(400).json(response); + return; + } + + if (!diagramId || !triggerType || !tableName || !data) { + const response: ApiResponse = { + success: false, + message: "필수 필드가 누락되었습니다.", + error: { + code: "MISSING_REQUIRED_FIELDS", + details: "diagramId, triggerType, tableName, data가 모두 필요합니다.", + }, + }; + res.status(400).json(response); + return; + } + + const results = await EventTriggerService.executeEventTriggers( + triggerType, + tableName, + data, + companyCode + ); + + const response: ApiResponse = { + success: true, + message: "조건부 연결 액션을 성공적으로 실행했습니다.", + data: results, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("조건부 연결 액션 실행 실패:", error); + const response: ApiResponse = { + success: false, + message: "조건부 연결 액션 실행에 실패했습니다.", + error: { + code: "CONDITIONAL_ACTION_EXECUTION_FAILED", + details: + error instanceof Error + ? error.message + : "알 수 없는 오류가 발생했습니다.", + }, + }; + res.status(500).json(response); + } +} diff --git a/backend-node/src/controllers/dataflowController.ts b/backend-node/src/controllers/dataflowController.ts index c9a4a426..3b17bb6a 100644 --- a/backend-node/src/controllers/dataflowController.ts +++ b/backend-node/src/controllers/dataflowController.ts @@ -3,6 +3,7 @@ import { logger } from "../utils/logger"; import { AuthenticatedRequest } from "../types/auth"; import { ApiResponse } from "../types/common"; import { DataflowService } from "../services/dataflowService"; +import { EventTriggerService } from "../services/eventTriggerService"; /** * 테이블 관계 생성 diff --git a/backend-node/src/routes/dataflowRoutes.ts b/backend-node/src/routes/dataflowRoutes.ts index 983ac181..fc6c235d 100644 --- a/backend-node/src/routes/dataflowRoutes.ts +++ b/backend-node/src/routes/dataflowRoutes.ts @@ -17,6 +17,10 @@ import { copyDiagram, deleteDiagram, } from "../controllers/dataflowController"; +import { + testConditionalConnection, + executeConditionalActions, +} from "../controllers/conditionalConnectionController"; const router = express.Router(); @@ -128,4 +132,18 @@ router.get( getDiagramRelationshipsByRelationshipId ); +// ==================== 조건부 연결 관리 라우트 ==================== + +/** + * 조건부 연결 조건 테스트 + * POST /api/dataflow/diagrams/:diagramId/test-conditions + */ +router.post("/diagrams/:diagramId/test-conditions", testConditionalConnection); + +/** + * 조건부 연결 액션 수동 실행 + * POST /api/dataflow/diagrams/:diagramId/execute-actions + */ +router.post("/diagrams/:diagramId/execute-actions", executeConditionalActions); + export default router; diff --git a/backend-node/src/services/dynamicFormService.ts b/backend-node/src/services/dynamicFormService.ts index b048d498..6be11f94 100644 --- a/backend-node/src/services/dynamicFormService.ts +++ b/backend-node/src/services/dynamicFormService.ts @@ -1,5 +1,6 @@ import prisma from "../config/database"; import { Prisma } from "@prisma/client"; +import { EventTriggerService } from "./eventTriggerService"; export interface FormDataResult { id: number; @@ -247,6 +248,22 @@ export class DynamicFormService { // 결과를 표준 형식으로 변환 const insertedRecord = Array.isArray(result) ? result[0] : result; + // 🔥 조건부 연결 실행 (INSERT 트리거) + try { + if (company_code) { + await EventTriggerService.executeEventTriggers( + "insert", + tableName, + insertedRecord as Record, + company_code + ); + console.log("🚀 조건부 연결 트리거 실행 완료 (INSERT)"); + } + } catch (triggerError) { + console.error("⚠️ 조건부 연결 트리거 실행 오류:", triggerError); + // 트리거 오류는 로그만 남기고 메인 저장 프로세스는 계속 진행 + } + return { id: insertedRecord.id || insertedRecord.objid || 0, screenId: screenId, @@ -343,6 +360,22 @@ export class DynamicFormService { const updatedRecord = Array.isArray(result) ? result[0] : result; + // 🔥 조건부 연결 실행 (UPDATE 트리거) + try { + if (company_code) { + await EventTriggerService.executeEventTriggers( + "update", + tableName, + updatedRecord as Record, + company_code + ); + console.log("🚀 조건부 연결 트리거 실행 완료 (UPDATE)"); + } + } catch (triggerError) { + console.error("⚠️ 조건부 연결 트리거 실행 오류:", triggerError); + // 트리거 오류는 로그만 남기고 메인 업데이트 프로세스는 계속 진행 + } + return { id: updatedRecord.id || updatedRecord.objid || id, screenId: 0, // 실제 테이블에는 screenId가 없으므로 0으로 설정 @@ -362,7 +395,11 @@ export class DynamicFormService { /** * 폼 데이터 삭제 (실제 테이블에서 직접 삭제) */ - async deleteFormData(id: number, tableName: string): Promise { + async deleteFormData( + id: number, + tableName: string, + companyCode?: string + ): Promise { try { console.log("🗑️ 서비스: 실제 테이블에서 폼 데이터 삭제 시작:", { id, @@ -382,6 +419,28 @@ export class DynamicFormService { const result = await prisma.$queryRawUnsafe(deleteQuery, id); console.log("✅ 서비스: 실제 테이블 삭제 성공:", result); + + // 🔥 조건부 연결 실행 (DELETE 트리거) + try { + if ( + companyCode && + result && + Array.isArray(result) && + result.length > 0 + ) { + const deletedRecord = result[0] as Record; + await EventTriggerService.executeEventTriggers( + "delete", + tableName, + deletedRecord, + companyCode + ); + console.log("🚀 조건부 연결 트리거 실행 완료 (DELETE)"); + } + } catch (triggerError) { + console.error("⚠️ 조건부 연결 트리거 실행 오류:", triggerError); + // 트리거 오류는 로그만 남기고 메인 삭제 프로세스는 계속 진행 + } } catch (error) { console.error("❌ 서비스: 실제 테이블 삭제 실패:", error); throw new Error(`실제 테이블 삭제 실패: ${error}`); diff --git a/backend-node/src/services/eventTriggerService.ts b/backend-node/src/services/eventTriggerService.ts new file mode 100644 index 00000000..10e3651e --- /dev/null +++ b/backend-node/src/services/eventTriggerService.ts @@ -0,0 +1,581 @@ +import { PrismaClient } from "@prisma/client"; +import { logger } from "../config/logger.js"; + +const prisma = new PrismaClient(); + +// 조건 노드 타입 정의 +interface ConditionNode { + type: "group" | "condition"; + operator?: "AND" | "OR"; + children?: ConditionNode[]; + field?: string; + operator_type?: + | "=" + | "!=" + | ">" + | "<" + | ">=" + | "<=" + | "LIKE" + | "NOT_LIKE" + | "CONTAINS" + | "STARTS_WITH" + | "ENDS_WITH" + | "IN" + | "NOT_IN" + | "IS_NULL" + | "IS_NOT_NULL" + | "BETWEEN" + | "NOT_BETWEEN"; + value?: any; + dataType?: string; +} + +// 조건 제어 정보 +interface ConditionControl { + triggerType: "insert" | "update" | "delete" | "insert_update"; + conditionTree: ConditionNode | null; +} + +// 연결 카테고리 정보 +interface ConnectionCategory { + type: "simple-key" | "data-save" | "external-call" | "conditional-link"; + rollbackOnError?: boolean; + enableLogging?: boolean; + maxRetryCount?: number; +} + +// 대상 액션 +interface TargetAction { + id: string; + actionType: "insert" | "update" | "delete" | "upsert"; + targetTable: string; + enabled: boolean; + fieldMappings: FieldMapping[]; + conditions?: ConditionNode; + description?: string; +} + +// 필드 매핑 +interface FieldMapping { + sourceField: string; + targetField: string; + transformFunction?: string; + defaultValue?: string; +} + +// 실행 계획 +interface ExecutionPlan { + sourceTable: string; + targetActions: TargetAction[]; +} + +// 실행 결과 +interface ExecutionResult { + success: boolean; + executedActions: number; + failedActions: number; + errors: string[]; + executionTime: number; +} + +/** + * 조건부 연결 실행을 위한 이벤트 트리거 서비스 + */ +export class EventTriggerService { + /** + * 특정 테이블에 대한 이벤트 트리거 실행 + */ + static async executeEventTriggers( + triggerType: "insert" | "update" | "delete", + tableName: string, + data: Record, + companyCode: string + ): Promise { + const startTime = Date.now(); + const results: ExecutionResult[] = []; + + try { + // 해당 테이블과 트리거 타입에 맞는 조건부 연결들 조회 + const diagrams = await prisma.dataflow_diagrams.findMany({ + where: { + company_code: companyCode, + control: { + path: ["triggerType"], + equals: + triggerType === "insert" + ? "insert" + : triggerType === "update" + ? ["update", "insert_update"] + : triggerType === "delete" + ? "delete" + : triggerType, + }, + plan: { + path: ["sourceTable"], + equals: tableName, + }, + }, + }); + + logger.info( + `Found ${diagrams.length} conditional connections for table ${tableName} with trigger ${triggerType}` + ); + + // 각 다이어그램에 대해 조건부 연결 실행 + for (const diagram of diagrams) { + try { + const result = await this.executeDiagramTrigger( + diagram, + data, + companyCode + ); + results.push(result); + } catch (error) { + logger.error(`Error executing diagram ${diagram.diagram_id}:`, error); + results.push({ + success: false, + executedActions: 0, + failedActions: 1, + errors: [error instanceof Error ? error.message : "Unknown error"], + executionTime: Date.now() - startTime, + }); + } + } + + return results; + } catch (error) { + logger.error("Error in executeEventTriggers:", error); + throw error; + } + } + + /** + * 단일 다이어그램의 트리거 실행 + */ + private static async executeDiagramTrigger( + diagram: any, + data: Record, + companyCode: string + ): Promise { + const startTime = Date.now(); + let executedActions = 0; + let failedActions = 0; + const errors: string[] = []; + + try { + const control = diagram.control as ConditionControl; + const category = diagram.category as ConnectionCategory; + const plan = diagram.plan as ExecutionPlan; + + logger.info( + `Executing diagram ${diagram.diagram_id} (${diagram.diagram_name})` + ); + + // 조건 평가 + if (control.conditionTree) { + const conditionMet = await this.evaluateCondition( + control.conditionTree, + data + ); + if (!conditionMet) { + logger.info( + `Conditions not met for diagram ${diagram.diagram_id}, skipping execution` + ); + return { + success: true, + executedActions: 0, + failedActions: 0, + errors: [], + executionTime: Date.now() - startTime, + }; + } + } + + // 대상 액션들 실행 + for (const action of plan.targetActions) { + if (!action.enabled) { + continue; + } + + try { + await this.executeTargetAction(action, data, companyCode); + executedActions++; + + if (category.enableLogging) { + logger.info( + `Successfully executed action ${action.id} on table ${action.targetTable}` + ); + } + } catch (error) { + failedActions++; + const errorMsg = + error instanceof Error ? error.message : "Unknown error"; + errors.push(`Action ${action.id}: ${errorMsg}`); + + logger.error(`Failed to execute action ${action.id}:`, error); + + // 오류 시 롤백 처리 + if (category.rollbackOnError) { + logger.warn(`Rolling back due to error in action ${action.id}`); + // TODO: 롤백 로직 구현 + break; + } + } + } + + return { + success: failedActions === 0, + executedActions, + failedActions, + errors, + executionTime: Date.now() - startTime, + }; + } catch (error) { + logger.error(`Error executing diagram ${diagram.diagram_id}:`, error); + return { + success: false, + executedActions: 0, + failedActions: 1, + errors: [error instanceof Error ? error.message : "Unknown error"], + executionTime: Date.now() - startTime, + }; + } + } + + /** + * 조건 평가 + */ + private static async evaluateCondition( + condition: ConditionNode, + data: Record + ): Promise { + if (condition.type === "group") { + if (!condition.children || condition.children.length === 0) { + return true; + } + + const results = await Promise.all( + condition.children.map((child) => this.evaluateCondition(child, data)) + ); + + if (condition.operator === "OR") { + return results.some((result) => result); + } else { + // AND + return results.every((result) => result); + } + } else if (condition.type === "condition") { + return this.evaluateSingleCondition(condition, data); + } + + return false; + } + + /** + * 단일 조건 평가 + */ + private static evaluateSingleCondition( + condition: ConditionNode, + data: Record + ): boolean { + const { field, operator_type, value } = condition; + + if (!field || !operator_type) { + return false; + } + + const fieldValue = data[field]; + + switch (operator_type) { + case "=": + return fieldValue == value; + case "!=": + return fieldValue != value; + case ">": + return Number(fieldValue) > Number(value); + case "<": + return Number(fieldValue) < Number(value); + case ">=": + return Number(fieldValue) >= Number(value); + case "<=": + return Number(fieldValue) <= Number(value); + case "LIKE": + return String(fieldValue).includes(String(value)); + case "NOT_LIKE": + return !String(fieldValue).includes(String(value)); + case "CONTAINS": + return String(fieldValue) + .toLowerCase() + .includes(String(value).toLowerCase()); + case "STARTS_WITH": + return String(fieldValue).startsWith(String(value)); + case "ENDS_WITH": + return String(fieldValue).endsWith(String(value)); + case "IN": + return Array.isArray(value) && value.includes(fieldValue); + case "NOT_IN": + return Array.isArray(value) && !value.includes(fieldValue); + case "IS_NULL": + return fieldValue == null || fieldValue === undefined; + case "IS_NOT_NULL": + return fieldValue != null && fieldValue !== undefined; + case "BETWEEN": + if (Array.isArray(value) && value.length === 2) { + const numValue = Number(fieldValue); + return numValue >= Number(value[0]) && numValue <= Number(value[1]); + } + return false; + case "NOT_BETWEEN": + if (Array.isArray(value) && value.length === 2) { + const numValue = Number(fieldValue); + return !( + numValue >= Number(value[0]) && numValue <= Number(value[1]) + ); + } + return false; + default: + return false; + } + } + + /** + * 대상 액션 실행 + */ + private static async executeTargetAction( + action: TargetAction, + sourceData: Record, + companyCode: string + ): Promise { + // 필드 매핑을 통해 대상 데이터 생성 + const targetData: Record = {}; + + for (const mapping of action.fieldMappings) { + let value = sourceData[mapping.sourceField]; + + // 변환 함수 적용 + if (mapping.transformFunction) { + value = this.applyTransformFunction(value, mapping.transformFunction); + } + + // 기본값 설정 + if (value === undefined || value === null) { + value = mapping.defaultValue; + } + + targetData[mapping.targetField] = value; + } + + // 회사 코드 추가 + targetData.company_code = companyCode; + + // 액션 타입별 실행 + switch (action.actionType) { + case "insert": + await this.executeInsertAction(action.targetTable, targetData); + break; + case "update": + await this.executeUpdateAction( + action.targetTable, + targetData, + action.conditions + ); + break; + case "delete": + await this.executeDeleteAction( + action.targetTable, + targetData, + action.conditions + ); + break; + case "upsert": + await this.executeUpsertAction(action.targetTable, targetData); + break; + default: + throw new Error(`Unsupported action type: ${action.actionType}`); + } + } + + /** + * INSERT 액션 실행 + */ + private static async executeInsertAction( + tableName: string, + data: Record + ): Promise { + // 동적 테이블 INSERT 실행 + const sql = `INSERT INTO ${tableName} (${Object.keys(data).join(", ")}) VALUES (${Object.keys( + data + ) + .map(() => "?") + .join(", ")})`; + + await prisma.$executeRawUnsafe(sql, ...Object.values(data)); + logger.info(`Inserted data into ${tableName}:`, data); + } + + /** + * UPDATE 액션 실행 + */ + private static async executeUpdateAction( + tableName: string, + data: Record, + conditions?: ConditionNode + ): Promise { + // 조건이 없으면 실행하지 않음 (안전장치) + if (!conditions) { + throw new Error( + "UPDATE action requires conditions to prevent accidental mass updates" + ); + } + + // 동적 테이블 UPDATE 실행 + const setClause = Object.keys(data) + .map((key) => `${key} = ?`) + .join(", "); + const whereClause = this.buildWhereClause(conditions); + + const sql = `UPDATE ${tableName} SET ${setClause} WHERE ${whereClause}`; + + await prisma.$executeRawUnsafe(sql, ...Object.values(data)); + logger.info(`Updated data in ${tableName}:`, data); + } + + /** + * DELETE 액션 실행 + */ + private static async executeDeleteAction( + tableName: string, + data: Record, + conditions?: ConditionNode + ): Promise { + // 조건이 없으면 실행하지 않음 (안전장치) + if (!conditions) { + throw new Error( + "DELETE action requires conditions to prevent accidental mass deletions" + ); + } + + // 동적 테이블 DELETE 실행 + const whereClause = this.buildWhereClause(conditions); + const sql = `DELETE FROM ${tableName} WHERE ${whereClause}`; + + await prisma.$executeRawUnsafe(sql); + logger.info(`Deleted data from ${tableName} with conditions`); + } + + /** + * UPSERT 액션 실행 + */ + private static async executeUpsertAction( + tableName: string, + data: Record + ): Promise { + // PostgreSQL UPSERT 구현 + const columns = Object.keys(data); + const values = Object.values(data); + const conflictColumns = ["id", "company_code"]; // 기본 충돌 컬럼 + + const sql = ` + INSERT INTO ${tableName} (${columns.join(", ")}) + VALUES (${columns.map(() => "?").join(", ")}) + ON CONFLICT (${conflictColumns.join(", ")}) + DO UPDATE SET ${columns.map((col) => `${col} = EXCLUDED.${col}`).join(", ")} + `; + + await prisma.$executeRawUnsafe(sql, ...values); + logger.info(`Upserted data into ${tableName}:`, data); + } + + /** + * WHERE 절 구성 + */ + private static buildWhereClause(conditions: ConditionNode): string { + // 간단한 WHERE 절 구성 (실제 구현에서는 더 복잡한 로직 필요) + if ( + conditions.type === "condition" && + conditions.field && + conditions.operator_type + ) { + return `${conditions.field} ${conditions.operator_type} '${conditions.value}'`; + } + + return "1=1"; // 기본값 + } + + /** + * 변환 함수 적용 + */ + private static applyTransformFunction( + value: any, + transformFunction: string + ): any { + try { + // 안전한 변환 함수들만 허용 + switch (transformFunction) { + case "UPPER": + return String(value).toUpperCase(); + case "LOWER": + return String(value).toLowerCase(); + case "TRIM": + return String(value).trim(); + case "NOW": + return new Date(); + case "UUID": + return require("crypto").randomUUID(); + default: + logger.warn(`Unknown transform function: ${transformFunction}`); + return value; + } + } catch (error) { + logger.error( + `Error applying transform function ${transformFunction}:`, + error + ); + return value; + } + } + + /** + * 조건부 연결 테스트 (개발/디버깅용) + */ + static async testConditionalConnection( + diagramId: number, + testData: Record, + companyCode: string + ): Promise<{ conditionMet: boolean; result?: ExecutionResult }> { + try { + const diagram = await prisma.dataflow_diagrams.findUnique({ + where: { diagram_id: diagramId }, + }); + + if (!diagram) { + throw new Error(`Diagram ${diagramId} not found`); + } + + const control = diagram.control as ConditionControl; + + // 조건 평가만 수행 + const conditionMet = control.conditionTree + ? await this.evaluateCondition(control.conditionTree, testData) + : true; + + if (conditionMet) { + // 실제 실행 (테스트 모드) + const result = await this.executeDiagramTrigger( + diagram, + testData, + companyCode + ); + return { conditionMet: true, result }; + } + + return { conditionMet: false }; + } catch (error) { + logger.error("Error testing conditional connection:", error); + throw error; + } + } +} + +export default EventTriggerService; From 3344a5785c6f65f350ba2cdd77350f6dd7070367 Mon Sep 17 00:00:00 2001 From: hyeonsu Date: Fri, 12 Sep 2025 11:33:54 +0900 Subject: [PATCH 05/13] =?UTF-8?q?=EC=8B=A4=ED=96=89=20=EC=A1=B0=EA=B1=B4?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../conditionalConnectionController.ts | 4 +- .../src/services/eventTriggerService.ts | 61 +------ docs/조건부_연결_구현_계획.md | 11 +- .../dataflow/ConnectionSetupModal.tsx | 161 +++++++----------- frontend/lib/api/dataflow.ts | 19 +-- 5 files changed, 79 insertions(+), 177 deletions(-) diff --git a/backend-node/src/controllers/conditionalConnectionController.ts b/backend-node/src/controllers/conditionalConnectionController.ts index a032ba6d..7ed1782f 100644 --- a/backend-node/src/controllers/conditionalConnectionController.ts +++ b/backend-node/src/controllers/conditionalConnectionController.ts @@ -16,7 +16,7 @@ export async function testConditionalConnection( const { diagramId } = req.params; const { testData } = req.body; - const companyCode = req.user?.company_code; + const companyCode = req.user?.companyCode; if (!companyCode) { const response: ApiResponse = { @@ -86,7 +86,7 @@ export async function executeConditionalActions( const { diagramId } = req.params; const { triggerType, tableName, data } = req.body; - const companyCode = req.user?.company_code; + const companyCode = req.user?.companyCode; if (!companyCode) { const response: ApiResponse = { diff --git a/backend-node/src/services/eventTriggerService.ts b/backend-node/src/services/eventTriggerService.ts index 10e3651e..a332d57a 100644 --- a/backend-node/src/services/eventTriggerService.ts +++ b/backend-node/src/services/eventTriggerService.ts @@ -1,5 +1,5 @@ import { PrismaClient } from "@prisma/client"; -import { logger } from "../config/logger.js"; +import { logger } from "../utils/logger"; const prisma = new PrismaClient(); @@ -9,24 +9,7 @@ interface ConditionNode { operator?: "AND" | "OR"; children?: ConditionNode[]; field?: string; - operator_type?: - | "=" - | "!=" - | ">" - | "<" - | ">=" - | "<=" - | "LIKE" - | "NOT_LIKE" - | "CONTAINS" - | "STARTS_WITH" - | "ENDS_WITH" - | "IN" - | "NOT_IN" - | "IS_NULL" - | "IS_NOT_NULL" - | "BETWEEN" - | "NOT_BETWEEN"; + operator_type?: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE"; value?: any; dataType?: string; } @@ -164,9 +147,9 @@ export class EventTriggerService { const errors: string[] = []; try { - const control = diagram.control as ConditionControl; - const category = diagram.category as ConnectionCategory; - const plan = diagram.plan as ExecutionPlan; + const control = diagram.control as unknown as ConditionControl; + const category = diagram.category as unknown as ConnectionCategory; + const plan = diagram.plan as unknown as ExecutionPlan; logger.info( `Executing diagram ${diagram.diagram_id} (${diagram.diagram_name})` @@ -302,38 +285,6 @@ export class EventTriggerService { return Number(fieldValue) <= Number(value); case "LIKE": return String(fieldValue).includes(String(value)); - case "NOT_LIKE": - return !String(fieldValue).includes(String(value)); - case "CONTAINS": - return String(fieldValue) - .toLowerCase() - .includes(String(value).toLowerCase()); - case "STARTS_WITH": - return String(fieldValue).startsWith(String(value)); - case "ENDS_WITH": - return String(fieldValue).endsWith(String(value)); - case "IN": - return Array.isArray(value) && value.includes(fieldValue); - case "NOT_IN": - return Array.isArray(value) && !value.includes(fieldValue); - case "IS_NULL": - return fieldValue == null || fieldValue === undefined; - case "IS_NOT_NULL": - return fieldValue != null && fieldValue !== undefined; - case "BETWEEN": - if (Array.isArray(value) && value.length === 2) { - const numValue = Number(fieldValue); - return numValue >= Number(value[0]) && numValue <= Number(value[1]); - } - return false; - case "NOT_BETWEEN": - if (Array.isArray(value) && value.length === 2) { - const numValue = Number(fieldValue); - return !( - numValue >= Number(value[0]) && numValue <= Number(value[1]) - ); - } - return false; default: return false; } @@ -553,7 +504,7 @@ export class EventTriggerService { throw new Error(`Diagram ${diagramId} not found`); } - const control = diagram.control as ConditionControl; + const control = diagram.control as unknown as ConditionControl; // 조건 평가만 수행 const conditionMet = control.conditionTree diff --git a/docs/조건부_연결_구현_계획.md b/docs/조건부_연결_구현_계획.md index a16b7cd7..c1e5b4f6 100644 --- a/docs/조건부_연결_구현_계획.md +++ b/docs/조건부_연결_구현_계획.md @@ -2,13 +2,13 @@ ## 📋 프로젝트 개요 -현재 DataFlow 시스템에서 3가지 연결 종류를 지원하고 있으며, 이 중 **데이터 저장**과 **외부 호출** 기능에 조건부 실행 로직을 추가해야 합니다. +현재 DataFlow 시스템에서 3가지 연결 종류를 지원하고 있으며, 이 중 **데이터 저장**과 **외부 호출** 기능에 실행 조건 로직을 추가해야 합니다. ### 현재 연결 종류 1. **단순 키값 연결** - 조건 설정 불필요 (기존 방식 유지) -2. **데이터 저장** - 조건부 실행 필요 ✨ -3. **외부 호출** - 조건부 실행 필요 ✨ +2. **데이터 저장** - 실행 조건 설정 필요 ✨ +3. **외부 호출** - 실행 조건 설정 필요 ✨ ## 🎯 기능 요구사항 @@ -78,9 +78,7 @@ CREATE INDEX idx_dataflow_category_type ON dataflow_diagrams USING GIN ((categor ```json { - "type": "data-save", // "simple-key" | "data-save" | "external-call" - "rollbackOnError": true, - "enableLogging": true + "type": "data-save" // "simple-key" | "data-save" | "external-call" } ``` @@ -265,7 +263,6 @@ router.post("/diagrams/:id/execute-actions", async (req, res) => { - 트랜잭션 롤백 지원 - 부분 실패 시 복구 메커니즘 -- 상세한 실행 로그 저장 ### 3. 보안 diff --git a/frontend/components/dataflow/ConnectionSetupModal.tsx b/frontend/components/dataflow/ConnectionSetupModal.tsx index 9a3b41d8..d8deba54 100644 --- a/frontend/components/dataflow/ConnectionSetupModal.tsx +++ b/frontend/components/dataflow/ConnectionSetupModal.tsx @@ -126,10 +126,7 @@ export const ConnectionSetupModal: React.FC = ({ const [selectedToColumns, setSelectedToColumns] = useState([]); // 조건부 연결을 위한 새로운 상태들 - const [triggerType, setTriggerType] = useState<"insert" | "update" | "delete" | "insert_update">("insert"); const [conditions, setConditions] = useState([]); - const [rollbackOnError, setRollbackOnError] = useState(true); - const [enableLogging, setEnableLogging] = useState(true); // 테이블 목록 로드 useEffect(() => { @@ -291,7 +288,7 @@ export const ConnectionSetupModal: React.FC = ({ const conditionalSettings = isConditionalConnection() ? { control: { - triggerType, + triggerType: "insert", conditionTree: conditions.length > 0 ? { @@ -303,8 +300,6 @@ export const ConnectionSetupModal: React.FC = ({ }, category: { type: config.connectionType, - rollbackOnError, - enableLogging, }, plan: { sourceTable: fromTableName, @@ -378,6 +373,7 @@ export const ConnectionSetupModal: React.FC = ({ operator_type: "=", value: "", dataType: "string", + operator: "AND", // 기본값으로 AND 설정 }; setConditions([...conditions, newCondition]); }; @@ -399,28 +395,7 @@ export const ConnectionSetupModal: React.FC = ({
- 조건부 실행 설정 -
- - {/* 트리거 타입 선택 */} -
- - + 실행 조건 설정
{/* 실행 조건 설정 */} @@ -443,81 +418,77 @@ export const ConnectionSetupModal: React.FC = ({
) : ( conditions.map((condition, index) => ( -
- +
+ {/* 첫 번째 조건이 아닐 때 AND/OR 연산자를 위에 표시 */} + {index > 0 && ( +
+ +
+ )} - + {/* 조건 행 */} +
+ - updateCondition(index, "value", e.target.value)} - className="h-8 flex-1 text-xs" - /> + - + updateCondition(index, "value", e.target.value)} + className="h-8 flex-1 text-xs" + /> + + +
)) )}
- - {/* 추가 옵션 */} -
- - - -
); }; diff --git a/frontend/lib/api/dataflow.ts b/frontend/lib/api/dataflow.ts index 04c95785..8e99db36 100644 --- a/frontend/lib/api/dataflow.ts +++ b/frontend/lib/api/dataflow.ts @@ -13,24 +13,7 @@ export interface ConditionNode { operator?: "AND" | "OR"; children?: ConditionNode[]; field?: string; - operator_type?: - | "=" - | "!=" - | ">" - | "<" - | ">=" - | "<=" - | "LIKE" - | "NOT_LIKE" - | "CONTAINS" - | "STARTS_WITH" - | "ENDS_WITH" - | "IN" - | "NOT_IN" - | "IS_NULL" - | "IS_NOT_NULL" - | "BETWEEN" - | "NOT_BETWEEN"; + operator_type?: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE"; value?: any; dataType?: string; } From 8e6f8d2a27fdac008f2b553a01670faeeb8fe00f Mon Sep 17 00:00:00 2001 From: hyeonsu Date: Fri, 12 Sep 2025 16:15:36 +0900 Subject: [PATCH 06/13] =?UTF-8?q?UI=20=EA=B0=9C=EC=84=A0=20-=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EB=B0=A9=EC=8B=9D=EB=B3=84=20=EC=B0=A8=EB=B3=84?= =?UTF-8?q?=ED=99=94=20=EB=B0=8F=20=EC=A4=91=EB=B3=B5=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dataflow/ConnectionSetupModal.tsx | 626 ++++++++++++++++-- 1 file changed, 572 insertions(+), 54 deletions(-) diff --git a/frontend/components/dataflow/ConnectionSetupModal.tsx b/frontend/components/dataflow/ConnectionSetupModal.tsx index d8deba54..036688af 100644 --- a/frontend/components/dataflow/ConnectionSetupModal.tsx +++ b/frontend/components/dataflow/ConnectionSetupModal.tsx @@ -58,9 +58,30 @@ interface SimpleKeySettings { // 데이터 저장 설정 interface DataSaveSettings { - sourceField: string; - targetField: string; - saveConditions: string; + saveMode: "simple" | "conditional" | "split"; // 저장 방식 + actions: Array<{ + id: string; + name: string; + actionType: "insert" | "update" | "delete" | "upsert"; + conditions?: Array<{ + field: string; + operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE"; + value: string; + }>; + fieldMappings: Array<{ + sourceTable?: string; + sourceField: string; + targetTable?: string; + targetField: string; + defaultValue?: string; + transformFunction?: string; + }>; + splitConfig?: { + sourceField: string; // 분할할 소스 필드 + delimiter: string; // 구분자 (예: ",") + targetField: string; // 분할된 값이 들어갈 필드 + }; + }>; } // 외부 호출 설정 @@ -103,9 +124,8 @@ export const ConnectionSetupModal: React.FC = ({ }); const [dataSaveSettings, setDataSaveSettings] = useState({ - sourceField: "", - targetField: "", - saveConditions: "", + saveMode: "simple", + actions: [], }); const [externalCallSettings, setExternalCallSettings] = useState({ @@ -124,6 +144,8 @@ export const ConnectionSetupModal: React.FC = ({ const [toTableColumns, setToTableColumns] = useState([]); const [selectedFromColumns, setSelectedFromColumns] = useState([]); const [selectedToColumns, setSelectedToColumns] = useState([]); + // 필요시 로드하는 테이블 컬럼 캐시 + const [tableColumnsCache, setTableColumnsCache] = useState<{ [tableName: string]: ColumnInfo[] }>({}); // 조건부 연결을 위한 새로운 상태들 const [conditions, setConditions] = useState([]); @@ -179,9 +201,15 @@ export const ConnectionSetupModal: React.FC = ({ // 데이터 저장 기본값 설정 setDataSaveSettings({ - sourceField: "", - targetField: "", - saveConditions: "데이터 저장 조건을 입력하세요", + saveMode: "simple", + actions: [ + { + id: "action_1", + name: `${fromDisplayName}에서 ${toDisplayName}로 데이터 저장`, + actionType: "insert", + fieldMappings: [], + }, + ], }); // 외부 호출 기본값 설정 @@ -253,6 +281,51 @@ export const ConnectionSetupModal: React.FC = ({ })); }, [selectedFromColumns, selectedToColumns]); + // 테이블 컬럼 로드 함수 (캐시 활용) + const loadTableColumns = async (tableName: string): Promise => { + if (tableColumnsCache[tableName]) { + return tableColumnsCache[tableName]; + } + + try { + const columns = await DataFlowAPI.getTableColumns(tableName); + setTableColumnsCache((prev) => ({ + ...prev, + [tableName]: columns, + })); + return columns; + } catch (error) { + console.error(`${tableName} 컬럼 로드 실패:`, error); + return []; + } + }; + + // 테이블 선택 시 컬럼 로드 + useEffect(() => { + const loadColumns = async () => { + const tablesToLoad = new Set(); + + // 필드 매핑에서 사용되는 모든 테이블 수집 + dataSaveSettings.actions.forEach((action) => { + action.fieldMappings.forEach((mapping) => { + if (mapping.sourceTable && !tableColumnsCache[mapping.sourceTable]) { + tablesToLoad.add(mapping.sourceTable); + } + if (mapping.targetTable && !tableColumnsCache[mapping.targetTable]) { + tablesToLoad.add(mapping.targetTable); + } + }); + }); + + // 필요한 테이블들의 컬럼만 로드 + for (const tableName of tablesToLoad) { + await loadTableColumns(tableName); + } + }; + + loadColumns(); + }, [dataSaveSettings.actions, tableColumnsCache]); // eslint-disable-line react-hooks/exhaustive-deps + const handleConfirm = () => { if (!config.relationshipName || !connection) { toast.error("필수 정보를 모두 입력해주세요."); @@ -303,7 +376,24 @@ export const ConnectionSetupModal: React.FC = ({ }, plan: { sourceTable: fromTableName, - targetActions: [], // 나중에 액션 설정 UI에서 채울 예정 + targetActions: + config.connectionType === "data-save" + ? dataSaveSettings.actions.map((action) => ({ + id: action.id, + actionType: action.actionType, + enabled: true, + conditions: action.conditions, + fieldMappings: action.fieldMappings.map((mapping) => ({ + sourceTable: mapping.sourceTable, + sourceField: mapping.sourceField, + targetTable: mapping.targetTable, + targetField: mapping.targetField, + defaultValue: mapping.defaultValue, + transformFunction: mapping.transformFunction, + })), + splitConfig: action.splitConfig, + })) + : [], }, } : {}; @@ -395,7 +485,7 @@ export const ConnectionSetupModal: React.FC = ({
- 실행 조건 설정 + 전체 실행 조건 (언제 이 연결이 동작할지)
{/* 실행 조건 설정 */} @@ -528,50 +618,478 @@ export const ConnectionSetupModal: React.FC = ({ 데이터 저장 설정
-
-
-
- - setDataSaveSettings({ ...dataSaveSettings, sourceField: e.target.value })} - placeholder="소스 필드" - className="text-sm" - /> -
-
- -
- setDataSaveSettings({ ...dataSaveSettings, targetField: e.target.value })} - placeholder="대상 필드" - className="text-sm" - /> - -
-
-
+
+ {/* 저장 방식 선택 */}
- -