diff --git a/backend-node/prisma/schema.prisma b/backend-node/prisma/schema.prisma index beb91d68..edc62708 100644 --- a/backend-node/prisma/schema.prisma +++ b/backend-node/prisma/schema.prisma @@ -5359,6 +5359,12 @@ model dataflow_diagrams { diagram_name String @db.VarChar(255) relationships Json // 모든 관계 정보를 JSON으로 저장 node_positions Json? // 테이블 노드의 캔버스 위치 정보 (JSON 형태) + + // 조건부 연결 관련 컬럼들 + control Json? // 조건 설정 (트리거 타입, 조건 트리) + category Json? // 연결 종류 배열 (["simple-key", "data-save", "external-call"]) + 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/controllers/conditionalConnectionController.ts b/backend-node/src/controllers/conditionalConnectionController.ts new file mode 100644 index 00000000..7ed1782f --- /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?.companyCode; + + 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?.companyCode; + + 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/controllers/dataflowDiagramController.ts b/backend-node/src/controllers/dataflowDiagramController.ts index 20634d64..647a4317 100644 --- a/backend-node/src/controllers/dataflowDiagramController.ts +++ b/backend-node/src/controllers/dataflowDiagramController.ts @@ -93,10 +93,20 @@ export const createDataflowDiagram = async (req: Request, res: Response) => { diagram_name, relationships, node_positions, + category, + control, + plan, company_code, created_by, updated_by, } = req.body; + + logger.info(`새 관계도 생성 요청:`, { diagram_name, company_code }); + logger.info(`node_positions:`, node_positions); + logger.info(`category:`, category); + logger.info(`control:`, control); + logger.info(`plan:`, plan); + logger.info(`전체 요청 Body:`, JSON.stringify(req.body, null, 2)); const companyCode = company_code || (req.query.companyCode as string) || @@ -115,10 +125,27 @@ export const createDataflowDiagram = async (req: Request, res: Response) => { }); } + // 🔍 백엔드에서 받은 실제 데이터 로깅 + console.log( + "🔍 백엔드에서 받은 control 데이터:", + JSON.stringify(control, null, 2) + ); + console.log( + "🔍 백엔드에서 받은 plan 데이터:", + JSON.stringify(plan, null, 2) + ); + console.log( + "🔍 백엔드에서 받은 category 데이터:", + JSON.stringify(category, null, 2) + ); + const newDiagram = await createDataflowDiagramService({ diagram_name, relationships, node_positions, + category, + control, + plan, company_code: companyCode, created_by: userId, updated_by: userId, @@ -162,6 +189,14 @@ export const updateDataflowDiagram = async (req: Request, res: Response) => { const userId = updated_by || (req.headers["x-user-id"] as string) || "SYSTEM"; + logger.info(`관계도 수정 요청 - ID: ${diagramId}, Company: ${companyCode}`); + logger.info(`요청 Body:`, JSON.stringify(req.body, null, 2)); + logger.info(`node_positions:`, req.body.node_positions); + logger.info(`요청 Body 키들:`, Object.keys(req.body)); + logger.info(`요청 Body 타입:`, typeof req.body); + logger.info(`node_positions 타입:`, typeof req.body.node_positions); + logger.info(`node_positions 값:`, req.body.node_positions); + if (isNaN(diagramId)) { return res.status(400).json({ success: false, 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/dataflowDiagramService.ts b/backend-node/src/services/dataflowDiagramService.ts index 16524e4a..c391ef4e 100644 --- a/backend-node/src/services/dataflowDiagramService.ts +++ b/backend-node/src/services/dataflowDiagramService.ts @@ -1,4 +1,4 @@ -import { PrismaClient } from "@prisma/client"; +import { PrismaClient, Prisma } from "@prisma/client"; import { logger } from "../utils/logger"; const prisma = new PrismaClient(); @@ -6,8 +6,14 @@ const prisma = new PrismaClient(); // 타입 정의 interface CreateDataflowDiagramData { diagram_name: string; - relationships: any; // JSON 데이터 - node_positions?: any; // JSON 데이터 (노드 위치 정보) + relationships: Record; // JSON 데이터 + node_positions?: Record | null; // JSON 데이터 (노드 위치 정보) + + // 🔥 수정: 배열 구조로 변경된 조건부 연결 관련 필드 + control?: Array> | null; // JSON 배열 (각 관계별 조건 설정) + category?: Array> | null; // JSON 배열 (각 관계별 연결 종류) + plan?: Array> | null; // JSON 배열 (각 관계별 실행 계획) + company_code: string; created_by: string; updated_by: string; @@ -15,8 +21,14 @@ interface CreateDataflowDiagramData { interface UpdateDataflowDiagramData { diagram_name?: string; - relationships?: any; // JSON 데이터 - node_positions?: any; // JSON 데이터 (노드 위치 정보) + relationships?: Record; // JSON 데이터 + node_positions?: Record | null; // JSON 데이터 (노드 위치 정보) + + // 🔥 수정: 배열 구조로 변경된 조건부 연결 관련 필드 + control?: Array> | null; // JSON 배열 (각 관계별 조건 설정) + category?: Array> | null; // JSON 배열 (각 관계별 연결 종류) + plan?: Array> | null; // JSON 배열 (각 관계별 실행 계획) + updated_by: string; } @@ -33,7 +45,13 @@ export const getDataflowDiagrams = async ( const offset = (page - 1) * size; // 검색 조건 구성 - const whereClause: any = {}; + const whereClause: { + company_code?: string; + diagram_name?: { + contains: string; + mode: "insensitive"; + }; + } = {}; // company_code가 '*'가 아닌 경우에만 필터링 if (companyCode !== "*") { @@ -87,7 +105,10 @@ export const getDataflowDiagramById = async ( companyCode: string ) => { try { - const whereClause: any = { + const whereClause: { + diagram_id: number; + company_code?: string; + } = { diagram_id: diagramId, }; @@ -117,8 +138,15 @@ export const createDataflowDiagram = async ( const newDiagram = await prisma.dataflow_diagrams.create({ data: { diagram_name: data.diagram_name, - relationships: data.relationships, - node_positions: data.node_positions || null, + relationships: data.relationships as Prisma.InputJsonValue, + node_positions: data.node_positions as + | Prisma.InputJsonValue + | undefined, + category: data.category + ? (data.category as Prisma.InputJsonValue) + : undefined, + control: data.control as Prisma.InputJsonValue | undefined, + plan: data.plan as Prisma.InputJsonValue | undefined, company_code: data.company_code, created_by: data.created_by, updated_by: data.updated_by, @@ -141,8 +169,15 @@ export const updateDataflowDiagram = async ( companyCode: string ) => { try { + logger.info( + `관계도 수정 서비스 시작 - ID: ${diagramId}, Company: ${companyCode}` + ); + // 먼저 해당 관계도가 존재하는지 확인 - const whereClause: any = { + const whereClause: { + diagram_id: number; + company_code?: string; + } = { diagram_id: diagramId, }; @@ -155,7 +190,15 @@ export const updateDataflowDiagram = async ( where: whereClause, }); + logger.info( + `기존 관계도 조회 결과:`, + existingDiagram ? `ID ${existingDiagram.diagram_id} 발견` : "관계도 없음" + ); + if (!existingDiagram) { + logger.warn( + `관계도 ID ${diagramId}를 찾을 수 없음 - Company: ${companyCode}` + ); return null; } @@ -166,9 +209,24 @@ export const updateDataflowDiagram = async ( }, data: { ...(data.diagram_name && { diagram_name: data.diagram_name }), - ...(data.relationships && { relationships: data.relationships }), + ...(data.relationships && { + relationships: data.relationships as Prisma.InputJsonValue, + }), ...(data.node_positions !== undefined && { - node_positions: data.node_positions, + node_positions: data.node_positions + ? (data.node_positions as Prisma.InputJsonValue) + : Prisma.JsonNull, + }), + ...(data.category !== undefined && { + category: data.category + ? (data.category as Prisma.InputJsonValue) + : undefined, + }), + ...(data.control !== undefined && { + control: data.control as Prisma.InputJsonValue | undefined, + }), + ...(data.plan !== undefined && { + plan: data.plan as Prisma.InputJsonValue | undefined, }), updated_by: data.updated_by, updated_at: new Date(), @@ -191,7 +249,10 @@ export const deleteDataflowDiagram = async ( ) => { try { // 먼저 해당 관계도가 존재하는지 확인 - const whereClause: any = { + const whereClause: { + diagram_id: number; + company_code?: string; + } = { diagram_id: diagramId, }; @@ -233,7 +294,10 @@ export const copyDataflowDiagram = async ( ) => { try { // 원본 관계도 조회 - const whereClause: any = { + const whereClause: { + diagram_id: number; + company_code?: string; + } = { diagram_id: diagramId, }; @@ -262,7 +326,12 @@ export const copyDataflowDiagram = async ( : originalDiagram.diagram_name; // 같은 패턴의 이름들을 찾아서 가장 큰 번호 찾기 - const copyWhereClause: any = { + const copyWhereClause: { + diagram_name: { + startsWith: string; + }; + company_code?: string; + } = { diagram_name: { startsWith: baseName, }, @@ -298,7 +367,11 @@ export const copyDataflowDiagram = async ( const copiedDiagram = await prisma.dataflow_diagrams.create({ data: { diagram_name: copyName, - relationships: originalDiagram.relationships as any, + relationships: originalDiagram.relationships as Prisma.InputJsonValue, + node_positions: originalDiagram.node_positions + ? (originalDiagram.node_positions as Prisma.InputJsonValue) + : Prisma.JsonNull, + category: originalDiagram.category || undefined, company_code: companyCode, created_by: userId, updated_by: userId, 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..6a26fcb5 --- /dev/null +++ b/backend-node/src/services/eventTriggerService.ts @@ -0,0 +1,714 @@ +import { PrismaClient } from "@prisma/client"; +import { logger } from "../utils/logger"; + +const prisma = new PrismaClient(); + +// 조건 노드 타입 정의 +interface ConditionNode { + id: string; // 고유 ID + type: "condition" | "group-start" | "group-end"; + field?: string; + operator_type?: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE"; + value?: any; + dataType?: string; + logicalOperator?: "AND" | "OR"; // 다음 조건과의 논리 연산자 + groupId?: string; // 그룹 ID (group-start와 group-end가 같은 groupId를 가짐) + groupLevel?: number; // 중첩 레벨 (0, 1, 2, ...) +} + +// 조건 제어 정보 +interface ConditionControl { + triggerType: "insert" | "update" | "delete" | "insert_update"; + conditionTree: ConditionNode | 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?: Array<{ + field: string; + operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE"; + value: string; + logicalOperator?: "AND" | "OR"; + }>; + splitConfig?: { + sourceField: string; + delimiter: string; + targetField: string; + }; + 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 { + // 🔥 수정: Raw SQL을 사용하여 JSON 배열 검색 + const diagrams = (await prisma.$queryRaw` + SELECT * FROM dataflow_diagrams + WHERE company_code = ${companyCode} + AND ( + category::text = '"data-save"' OR + category::jsonb ? 'data-save' OR + category::jsonb @> '["data-save"]' + ) + `) as any[]; + + // 각 관계도에서 해당 테이블을 소스로 하는 데이터 저장 관계들 필터링 + const matchingDiagrams = diagrams.filter((diagram) => { + // category 배열에서 data-save 연결이 있는지 확인 + const categories = diagram.category as any[]; + const hasDataSave = Array.isArray(categories) + ? categories.some((cat) => cat.category === "data-save") + : false; + + if (!hasDataSave) return false; + + // plan 배열에서 해당 테이블을 소스로 하는 항목이 있는지 확인 + const plans = diagram.plan as any[]; + const hasMatchingPlan = Array.isArray(plans) + ? plans.some((plan) => plan.sourceTable === tableName) + : false; + + // control 배열에서 해당 트리거 타입이 있는지 확인 + const controls = diagram.control as any[]; + const hasMatchingControl = Array.isArray(controls) + ? controls.some((control) => control.triggerType === triggerType) + : false; + + return hasDataSave && hasMatchingPlan && hasMatchingControl; + }); + + logger.info( + `Found ${matchingDiagrams.length} matching data-save connections for table ${tableName} with trigger ${triggerType}` + ); + + // 각 다이어그램에 대해 조건부 연결 실행 + for (const diagram of matchingDiagrams) { + 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 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})` + ); + + // 조건 평가 + 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 | ConditionNode[], + data: Record + ): Promise { + // 단일 조건인 경우 (하위 호환성) + if (!Array.isArray(condition)) { + if (condition.type === "condition") { + return this.evaluateSingleCondition(condition, data); + } + return true; + } + + // 조건 배열인 경우 (새로운 그룹핑 시스템) + return this.evaluateConditionList(condition, data); + } + + /** + * 조건 리스트 평가 (괄호 그룹핑 지원) + */ + private static async evaluateConditionList( + conditions: ConditionNode[], + data: Record + ): Promise { + if (conditions.length === 0) { + return true; + } + + // 조건을 평가 가능한 표현식으로 변환 + const expression = await this.buildConditionExpression(conditions, data); + + // 표현식 평가 + return this.evaluateExpression(expression); + } + + /** + * 조건들을 평가 가능한 표현식으로 변환 + */ + private static async buildConditionExpression( + conditions: ConditionNode[], + data: Record + ): Promise { + const tokens: string[] = []; + + for (let i = 0; i < conditions.length; i++) { + const condition = conditions[i]; + + if (condition.type === "group-start") { + // 이전 조건과의 논리 연산자 추가 + if (i > 0 && condition.logicalOperator) { + tokens.push(condition.logicalOperator); + } + tokens.push("("); + } else if (condition.type === "group-end") { + tokens.push(")"); + } else if (condition.type === "condition") { + // 이전 조건과의 논리 연산자 추가 + if (i > 0 && condition.logicalOperator) { + tokens.push(condition.logicalOperator); + } + + // 조건 평가 결과를 토큰으로 추가 + const result = await this.evaluateSingleCondition(condition, data); + tokens.push(result.toString()); + } + } + + return tokens.join(" "); + } + + /** + * 논리 표현식 평가 (괄호 우선순위 지원) + */ + private static evaluateExpression(expression: string): boolean { + try { + // 안전한 논리 표현식 평가 + // true/false와 AND/OR/괄호만 포함된 표현식을 평가 + const sanitizedExpression = expression + .replace(/\bAND\b/g, "&&") + .replace(/\bOR\b/g, "||") + .replace(/\btrue\b/g, "true") + .replace(/\bfalse\b/g, "false"); + + // 보안을 위해 허용된 문자만 확인 + if (!/^[true|false|\s|&|\||\(|\)]+$/.test(sanitizedExpression)) { + logger.warn(`Invalid expression: ${expression}`); + return false; + } + + // Function constructor를 사용한 안전한 평가 + const result = new Function(`return ${sanitizedExpression}`)(); + return Boolean(result); + } catch (error) { + logger.error(`Error evaluating expression: ${expression}`, error); + return false; + } + } + + /** + * 액션별 조건들 평가 (AND/OR 연산자 지원) + */ + private static async evaluateActionConditions( + conditions: Array<{ + field: string; + operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE"; + value: string; + logicalOperator?: "AND" | "OR"; + }>, + data: Record + ): Promise { + if (conditions.length === 0) { + return true; + } + + let result = await this.evaluateActionCondition(conditions[0], data); + + for (let i = 1; i < conditions.length; i++) { + const prevCondition = conditions[i - 1]; + const currentCondition = conditions[i]; + const currentResult = await this.evaluateActionCondition( + currentCondition, + data + ); + + if (prevCondition.logicalOperator === "OR") { + result = result || currentResult; + } else { + // 기본값은 AND + result = result && currentResult; + } + } + + return result; + } + + /** + * 액션 단일 조건 평가 + */ + private static async evaluateActionCondition( + condition: { + field: string; + operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE"; + value: string; + }, + data: Record + ): Promise { + const fieldValue = data[condition.field]; + const conditionValue = condition.value; + + switch (condition.operator) { + case "=": + return fieldValue == conditionValue; + case "!=": + return fieldValue != conditionValue; + case ">": + return Number(fieldValue) > Number(conditionValue); + case "<": + return Number(fieldValue) < Number(conditionValue); + case ">=": + return Number(fieldValue) >= Number(conditionValue); + case "<=": + return Number(fieldValue) <= Number(conditionValue); + case "LIKE": + return String(fieldValue).includes(String(conditionValue)); + default: + 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)); + default: + return false; + } + } + + /** + * 대상 액션 실행 + */ + private static async executeTargetAction( + action: TargetAction, + sourceData: Record, + companyCode: string + ): Promise { + // 액션별 조건 평가 + if (action.conditions && action.conditions.length > 0) { + const conditionMet = await this.evaluateActionConditions( + action.conditions, + sourceData + ); + if (!conditionMet) { + logger.info( + `Action conditions not met for action ${action.id}, skipping execution` + ); + return; + } + } + + // 필드 매핑을 통해 대상 데이터 생성 + 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, + undefined // 액션별 조건은 이미 평가했으므로 WHERE 조건은 undefined + ); + break; + case "delete": + await this.executeDeleteAction( + action.targetTable, + targetData, + undefined // 액션별 조건은 이미 평가했으므로 WHERE 조건은 undefined + ); + 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 unknown 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; diff --git a/docs/조건부_연결_구현_계획.md b/docs/조건부_연결_구현_계획.md new file mode 100644 index 00000000..c1e5b4f6 --- /dev/null +++ b/docs/조건부_연결_구현_계획.md @@ -0,0 +1,294 @@ +# 🔗 조건부 연결 기능 구현 계획 + +## 📋 프로젝트 개요 + +현재 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" +} +``` + +#### `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/app/(main)/admin/dataflow/edit/[diagramId]/page.tsx b/frontend/app/(main)/admin/dataflow/edit/[diagramId]/page.tsx index 25b3f193..ede20c29 100644 --- a/frontend/app/(main)/admin/dataflow/edit/[diagramId]/page.tsx +++ b/frontend/app/(main)/admin/dataflow/edit/[diagramId]/page.tsx @@ -43,6 +43,11 @@ export default function DataFlowEditPage() { router.push("/admin/dataflow"); }; + // 관계도 이름 업데이트 핸들러 + const handleDiagramNameUpdate = (newDiagramName: string) => { + setDiagramName(newDiagramName); + }; + if (!diagramId || !diagramName) { return (
@@ -74,7 +79,12 @@ export default function DataFlowEditPage() { {/* 데이터플로우 디자이너 */}
- +
); diff --git a/frontend/components/dataflow/ConnectionSetupModal.tsx b/frontend/components/dataflow/ConnectionSetupModal.tsx index 4147b87e..de76be9a 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"; // 연결 정보 타입 @@ -34,21 +34,18 @@ interface ConnectionInfo { }; existingRelationship?: { relationshipName: string; - relationshipType: string; connectionType: string; - settings?: any; + settings?: Record; }; } // 연결 설정 타입 interface ConnectionConfig { relationshipName: string; - relationshipType: "one-to-one" | "one-to-many" | "many-to-one" | "many-to-many"; connectionType: "simple-key" | "data-save" | "external-call"; fromColumnName: string; toColumnName: string; settings?: Record; - description?: string; } // 단순 키값 연결 설정 @@ -58,9 +55,25 @@ interface SimpleKeySettings { // 데이터 저장 설정 interface DataSaveSettings { + actions: Array<{ + id: string; + name: string; + actionType: "insert" | "update" | "delete" | "upsert"; + conditions?: ConditionNode[]; + fieldMappings: Array<{ + sourceTable?: string; sourceField: string; + targetTable?: string; targetField: string; - saveConditions: string; + defaultValue?: string; + transformFunction?: string; + }>; + splitConfig?: { + sourceField: string; // 분할할 소스 필드 + delimiter: string; // 구분자 (예: ",") + targetField: string; // 분할된 값이 들어갈 필드 + }; + }>; } // 외부 호출 설정 @@ -76,7 +89,6 @@ interface ConnectionSetupModalProps { isOpen: boolean; connection: ConnectionInfo | null; companyCode: string; - diagramId?: number; onConfirm: (relationship: TableRelationship) => void; onCancel: () => void; } @@ -85,17 +97,14 @@ export const ConnectionSetupModal: React.FC = ({ isOpen, connection, companyCode, - diagramId, onConfirm, onCancel, }) => { const [config, setConfig] = useState({ relationshipName: "", - relationshipType: "one-to-one", connectionType: "simple-key", fromColumnName: "", toColumnName: "", - description: "", settings: {}, }); @@ -105,9 +114,7 @@ export const ConnectionSetupModal: React.FC = ({ }); const [dataSaveSettings, setDataSaveSettings] = useState({ - sourceField: "", - targetField: "", - saveConditions: "", + actions: [], }); const [externalCallSettings, setExternalCallSettings] = useState({ @@ -126,6 +133,11 @@ 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([]); // 테이블 목록 로드 useEffect(() => { @@ -160,13 +172,9 @@ export const ConnectionSetupModal: React.FC = ({ const existingRel = connection.existingRelationship; setConfig({ relationshipName: existingRel?.relationshipName || `${fromDisplayName} → ${toDisplayName}`, - relationshipType: - (existingRel?.relationshipType as "one-to-one" | "one-to-many" | "many-to-one" | "many-to-many") || - "one-to-one", connectionType: (existingRel?.connectionType as "simple-key" | "data-save" | "external-call") || "simple-key", fromColumnName: "", toColumnName: "", - description: existingRel?.settings?.description || `${fromDisplayName}과 ${toDisplayName} 간의 데이터 관계`, settings: existingRel?.settings || {}, }); @@ -175,13 +183,15 @@ export const ConnectionSetupModal: React.FC = ({ notes: `${fromDisplayName}과 ${toDisplayName} 간의 키값 연결`, }); - // 데이터 저장 기본값 설정 + // 데이터 저장 기본값 설정 (빈 배열로 시작) setDataSaveSettings({ - sourceField: "", - targetField: "", - saveConditions: "데이터 저장 조건을 입력하세요", + actions: [], }); + // 🔥 필드 선택 상태 초기화 + setSelectedFromColumns([]); + setSelectedToColumns([]); + // 외부 호출 기본값 설정 setExternalCallSettings({ callType: "rest-api", @@ -251,6 +261,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("필수 정보를 모두 입력해주세요."); @@ -272,40 +327,70 @@ export const ConnectionSetupModal: React.FC = ({ break; } - // 선택된 컬럼들 검증 + // 단순 키값 연결일 때만 컬럼 선택 검증 + if (config.connectionType === "simple-key") { if (selectedFromColumns.length === 0 || selectedToColumns.length === 0) { toast.error("선택된 컬럼이 없습니다. From과 To 테이블에서 각각 최소 1개 이상의 컬럼을 선택해주세요."); return; + } } // 선택된 테이블과 컬럼 정보 사용 const fromTableName = selectedFromTable || connection.fromNode.tableName; const toTableName = selectedToTable || connection.toNode.tableName; + // 조건부 연결 설정 데이터 준비 + const conditionalSettings = isConditionalConnection() + ? { + control: { + triggerType: "insert", + conditionTree: conditions.length > 0 ? conditions : null, + }, + category: { + type: config.connectionType, + }, + plan: { + sourceTable: fromTableName, + 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, + })) + : [], + }, + } + : {}; + + // 컬럼 정보는 단순 키값 연결일 때만 사용 + const finalFromColumns = config.connectionType === "simple-key" ? selectedFromColumns : []; + const finalToColumns = config.connectionType === "simple-key" ? selectedToColumns : []; + // 메모리 기반 시스템: 관계 데이터만 생성하여 부모로 전달 const relationshipData: TableRelationship = { relationship_name: config.relationshipName, from_table_name: fromTableName, 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, + from_column_name: finalFromColumns.join(","), // 여러 컬럼을 콤마로 구분 + to_column_name: finalToColumns.join(","), // 여러 컬럼을 콤마로 구분 + connection_type: config.connectionType, company_code: companyCode, settings: { ...settings, - description: config.description, - multiColumnMapping: { - fromColumns: selectedFromColumns, - toColumns: selectedToColumns, - fromTable: fromTableName, - toTable: toTableName, - }, - isMultiColumn: selectedFromColumns.length > 1 || selectedToColumns.length > 1, - columnCount: { - from: selectedFromColumns.length, - to: selectedToColumns.length, - }, + ...conditionalSettings, // 조건부 연결 설정 추가 + // 중복 제거: multiColumnMapping, isMultiColumn, columnCount, description 제거 + // 필요시 from_column_name, to_column_name에서 split으로 추출 가능 }, }; @@ -319,227 +404,746 @@ export const ConnectionSetupModal: React.FC = ({ const handleCancel = () => { setConfig({ relationshipName: "", - relationshipType: "one-to-one", connectionType: "simple-key", fromColumnName: "", toColumnName: "", - description: "", }); onCancel(); }; 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"; + }; + + // 고유 ID 생성 헬퍼 + const generateId = () => `cond_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + + // 조건 관리 헬퍼 함수들 + const addCondition = () => { + const newCondition: ConditionNode = { + id: generateId(), + type: "condition", + field: "", + operator_type: "=", + value: "", + dataType: "string", + logicalOperator: "AND", // 기본값으로 AND 설정 + }; + setConditions([...conditions, newCondition]); + }; + + // 그룹 시작 추가 + const addGroupStart = () => { + const groupId = `group_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + const groupLevel = getNextGroupLevel(); + + const groupStart: ConditionNode = { + id: generateId(), + type: "group-start", + groupId, + groupLevel, + logicalOperator: conditions.length > 0 ? "AND" : undefined, + }; + + setConditions([...conditions, groupStart]); + }; + + // 그룹 끝 추가 + const addGroupEnd = () => { + // 가장 최근에 열린 그룹 찾기 + const openGroups = findOpenGroups(); + if (openGroups.length === 0) { + toast.error("닫을 그룹이 없습니다."); + return; + } + + const lastOpenGroup = openGroups[openGroups.length - 1]; + const groupEnd: ConditionNode = { + id: generateId(), + type: "group-end", + groupId: lastOpenGroup.groupId, + groupLevel: lastOpenGroup.groupLevel, + }; + + setConditions([...conditions, groupEnd]); + }; + + // 다음 그룹 레벨 계산 + const getNextGroupLevel = (): number => { + const openGroups = findOpenGroups(); + return openGroups.length; + }; + + // 열린 그룹 찾기 + const findOpenGroups = () => { + const openGroups: Array<{ groupId: string; groupLevel: number }> = []; + + for (const condition of conditions) { + if (condition.type === "group-start") { + openGroups.push({ + groupId: condition.groupId!, + groupLevel: condition.groupLevel!, + }); + } else if (condition.type === "group-end") { + // 해당 그룹 제거 + const groupIndex = openGroups.findIndex((g) => g.groupId === condition.groupId); + if (groupIndex !== -1) { + openGroups.splice(groupIndex, 1); + } + } + } + + return openGroups; + }; + + 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 conditionToRemove = conditions[index]; + + // 그룹 시작/끝을 삭제하는 경우 해당 그룹 전체 삭제 + if (conditionToRemove.type === "group-start" || conditionToRemove.type === "group-end") { + removeGroup(conditionToRemove.groupId!); + } else { + const updatedConditions = conditions.filter((_, i) => i !== index); + setConditions(updatedConditions); + } + }; + + // 그룹 전체 삭제 + const removeGroup = (groupId: string) => { + const updatedConditions = conditions.filter((c) => c.groupId !== groupId); + setConditions(updatedConditions); + }; + + // 현재 조건의 그룹 레벨 계산 + const getCurrentGroupLevel = (conditionIndex: number): number => { + let level = 0; + for (let i = 0; i < conditionIndex; i++) { + const condition = conditions[i]; + if (condition.type === "group-start") { + level++; + } else if (condition.type === "group-end") { + level--; + } + } + return level; + }; + + // 액션별 조건 그룹 관리 함수들 + const addActionGroupStart = (actionIndex: number) => { + const groupId = `action_group_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + const currentConditions = dataSaveSettings.actions[actionIndex].conditions || []; + const groupLevel = getActionNextGroupLevel(currentConditions); + + const groupStart: ConditionNode = { + id: generateId(), + type: "group-start", + groupId, + groupLevel, + logicalOperator: currentConditions.length > 0 ? "AND" : undefined, + }; + + const newActions = [...dataSaveSettings.actions]; + newActions[actionIndex].conditions = [...currentConditions, groupStart]; + setDataSaveSettings({ ...dataSaveSettings, actions: newActions }); + }; + + const addActionGroupEnd = (actionIndex: number) => { + const currentConditions = dataSaveSettings.actions[actionIndex].conditions || []; + const openGroups = findActionOpenGroups(currentConditions); + + if (openGroups.length === 0) { + toast.error("닫을 그룹이 없습니다."); + return; + } + + const lastOpenGroup = openGroups[openGroups.length - 1]; + const groupEnd: ConditionNode = { + id: generateId(), + type: "group-end", + groupId: lastOpenGroup.groupId, + groupLevel: lastOpenGroup.groupLevel, + }; + + const newActions = [...dataSaveSettings.actions]; + newActions[actionIndex].conditions = [...currentConditions, groupEnd]; + setDataSaveSettings({ ...dataSaveSettings, actions: newActions }); + }; + + // 액션별 다음 그룹 레벨 계산 + const getActionNextGroupLevel = (conditions: ConditionNode[]): number => { + const openGroups = findActionOpenGroups(conditions); + return openGroups.length; + }; + + // 액션별 열린 그룹 찾기 + const findActionOpenGroups = (conditions: ConditionNode[]) => { + const openGroups: Array<{ groupId: string; groupLevel: number }> = []; + + for (const condition of conditions) { + if (condition.type === "group-start") { + openGroups.push({ + groupId: condition.groupId!, + groupLevel: condition.groupLevel!, + }); + } else if (condition.type === "group-end") { + const groupIndex = openGroups.findIndex((g) => g.groupId === condition.groupId); + if (groupIndex !== -1) { + openGroups.splice(groupIndex, 1); + } + } + } + + return openGroups; + }; + + // 액션별 현재 조건의 그룹 레벨 계산 + const getActionCurrentGroupLevel = (conditions: ConditionNode[], conditionIndex: number): number => { + let level = 0; + for (let i = 0; i < conditionIndex; i++) { + const condition = conditions[i]; + if (condition.type === "group-start") { + level++; + } else if (condition.type === "group-end") { + level--; + } + } + return level; + }; + + // 액션별 조건 렌더링 함수 + const renderActionCondition = (condition: ConditionNode, condIndex: number, actionIndex: number) => { + // 그룹 시작 렌더링 + if (condition.type === "group-start") { + return ( +
+ {/* 그룹 시작 앞의 논리 연산자 */} + {condIndex > 0 && ( + + )} +
+ ( + 그룹 시작 + +
+
+ ); + } + + // 그룹 끝 렌더링 + if (condition.type === "group-end") { + return ( +
+
+ ) + 그룹 끝 + +
+
+ ); + } + + // 일반 조건 렌더링 (기존 로직 간소화) + return ( +
+ {/* 그룹 내 첫 번째 조건이 아닐 때만 논리 연산자 표시 */} + {condIndex > 0 && dataSaveSettings.actions[actionIndex].conditions![condIndex - 1]?.type !== "group-start" && ( + + )} +
+ + + {/* 데이터 타입에 따른 동적 입력 컴포넌트 */} + {(() => { + const selectedColumn = fromTableColumns.find((col) => col.columnName === condition.field); + const dataType = selectedColumn?.dataType?.toLowerCase() || "string"; + + if (dataType.includes("timestamp") || dataType.includes("datetime") || dataType.includes("date")) { + return ( + { + const newActions = [...dataSaveSettings.actions]; + newActions[actionIndex].conditions![condIndex].value = e.target.value; + setDataSaveSettings({ ...dataSaveSettings, actions: newActions }); + }} + className="h-6 flex-1 text-xs" + /> + ); + } else if (dataType.includes("time")) { + return ( + { + const newActions = [...dataSaveSettings.actions]; + newActions[actionIndex].conditions![condIndex].value = e.target.value; + setDataSaveSettings({ ...dataSaveSettings, actions: newActions }); + }} + className="h-6 flex-1 text-xs" + /> + ); + } else if (dataType.includes("date")) { + return ( + { + const newActions = [...dataSaveSettings.actions]; + newActions[actionIndex].conditions![condIndex].value = e.target.value; + setDataSaveSettings({ ...dataSaveSettings, actions: newActions }); + }} + className="h-6 flex-1 text-xs" + /> + ); + } else if ( + dataType.includes("int") || + dataType.includes("numeric") || + dataType.includes("decimal") || + dataType.includes("float") || + dataType.includes("double") + ) { + return ( + { + const newActions = [...dataSaveSettings.actions]; + newActions[actionIndex].conditions![condIndex].value = e.target.value; + setDataSaveSettings({ ...dataSaveSettings, actions: newActions }); + }} + className="h-6 flex-1 text-xs" + /> + ); + } else if (dataType.includes("bool")) { + return ( + + ); + } else { + return ( + { + const newActions = [...dataSaveSettings.actions]; + newActions[actionIndex].conditions![condIndex].value = e.target.value; + setDataSaveSettings({ ...dataSaveSettings, actions: newActions }); + }} + className="h-6 flex-1 text-xs" + /> + ); + } + })()} + +
+
+ ); + }; + + // 조건부 연결 설정 UI 렌더링 + const renderConditionalSettings = () => { + return ( +
+
+ + 전체 실행 조건 (언제 이 연결이 동작할지) +
+ + {/* 실행 조건 설정 */} +
+
+ +
+ + + +
+
+ + {/* 조건 목록 */} +
+ {conditions.length === 0 ? ( +
+ 조건을 추가하면 해당 조건을 만족할 때만 실행됩니다. +
+ 조건이 없으면 항상 실행됩니다. +
+ ) : ( + conditions.map((condition, index) => { + // 그룹 시작 렌더링 + if (condition.type === "group-start") { + return ( +
+ {/* 그룹 시작 앞의 논리 연산자 */} + {index > 0 && ( + + )} + {/* 그룹 레벨에 따른 들여쓰기 */} +
+ ( + 그룹 시작 + +
+
+ ); + } + + // 그룹 끝 렌더링 + if (condition.type === "group-end") { + return ( +
+
+ ) + 그룹 끝 + +
+
+ ); + } + + // 일반 조건 렌더링 + return ( +
+ {/* 그룹 내 첫 번째 조건이 아닐 때만 논리 연산자 표시 */} + {index > 0 && conditions[index - 1]?.type !== "group-start" && ( + + )} + + {/* 그룹 레벨에 따른 들여쓰기와 조건 필드들 */} +
+ {/* 조건 필드 선택 */} + + + {/* 연산자 선택 */} + + + {/* 데이터 타입에 따른 동적 입력 컴포넌트 */} + {(() => { + const selectedColumn = fromTableColumns.find((col) => col.columnName === condition.field); + const dataType = selectedColumn?.dataType?.toLowerCase() || "string"; + + if ( + dataType.includes("timestamp") || + dataType.includes("datetime") || + dataType.includes("date") + ) { + return ( + updateCondition(index, "value", e.target.value)} + className="h-8 flex-1 text-xs" + /> + ); + } else if (dataType.includes("time")) { + return ( + updateCondition(index, "value", e.target.value)} + className="h-8 flex-1 text-xs" + /> + ); + } else if (dataType.includes("date")) { + return ( + updateCondition(index, "value", e.target.value)} + className="h-8 flex-1 text-xs" + /> + ); + } else if ( + dataType.includes("int") || + dataType.includes("numeric") || + dataType.includes("decimal") || + dataType.includes("float") || + dataType.includes("double") + ) { + return ( + updateCondition(index, "value", e.target.value)} + className="h-8 flex-1 text-xs" + /> + ); + } else if (dataType.includes("bool")) { + return ( + + ); + } else { + return ( + updateCondition(index, "value", e.target.value)} + className="h-8 flex-1 text-xs" + /> + ); + } + })()} + + {/* 삭제 버튼 */} + +
+
+ ); + }) + )} +
+
+
+ ); + }; // 연결 종류별 설정 패널 렌더링 const renderConnectionTypeSettings = () => { switch (config.connectionType) { case "simple-key": - return ( -
-
- - 단순 키값 연결 설정 -
-
-
- -