diff --git a/backend-node/prisma/schema.prisma b/backend-node/prisma/schema.prisma index c7e15d81..5d0aa77b 100644 --- a/backend-node/prisma/schema.prisma +++ b/backend-node/prisma/schema.prisma @@ -5207,53 +5207,6 @@ model grid_standards { @@index([company_code], map: "idx_grid_standards_company") } -/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments -model data_relationship_bridge { - bridge_id Int @id @default(autoincrement()) - relationship_id Int? - from_table_name String @db.VarChar(100) - from_column_name String @db.VarChar(100) - from_key_value String? @db.VarChar(500) - from_record_id String? @db.VarChar(100) - to_table_name String @db.VarChar(100) - to_column_name String @db.VarChar(100) - to_key_value String? @db.VarChar(500) - to_record_id String? @db.VarChar(100) - connection_type String @db.VarChar(20) - company_code String @db.VarChar(50) - created_at DateTime? @default(now()) @db.Timestamp(6) - created_by String? @db.VarChar(50) - updated_at DateTime? @default(now()) @db.Timestamp(6) - updated_by String? @db.VarChar(50) - is_active String? @default("Y") @db.Char(1) - bridge_data Json? - table_relationships table_relationships? @relation(fields: [relationship_id], references: [relationship_id], onDelete: NoAction, onUpdate: NoAction) - - @@index([company_code, is_active], map: "idx_data_bridge_company_active") - @@index([connection_type], map: "idx_data_bridge_connection_type") -} - -/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments -model table_relationships { - relationship_id Int @id @default(autoincrement()) - relationship_name String @db.VarChar(200) - from_table_name String @db.VarChar(100) - from_column_name String @db.VarChar(100) - to_table_name String @db.VarChar(100) - to_column_name String @db.VarChar(100) - relationship_type String @db.VarChar(20) - connection_type String @db.VarChar(20) - company_code String @db.VarChar(50) - settings Json? - is_active String? @default("Y") @db.Char(1) - created_date DateTime? @default(now()) @db.Timestamp(6) - created_by String? @db.VarChar(50) - updated_date DateTime? @default(now()) @db.Timestamp(6) - updated_by String? @db.VarChar(50) - data_relationship_bridge data_relationship_bridge[] - - @@index([to_table_name], map: "idx_table_relationships_to_table") -} // 템플릿 표준 관리 테이블 model template_standards { @@ -5302,3 +5255,88 @@ model component_standards { @@index([category], map: "idx_component_standards_category") @@index([company_code], map: "idx_component_standards_company") } +model table_relationships { + relationship_id Int @id @default(autoincrement()) + diagram_id Int // 관계도 그룹 식별자 + relationship_name String @db.VarChar(200) + from_table_name String @db.VarChar(100) + from_column_name String @db.VarChar(100) + to_table_name String @db.VarChar(100) + to_column_name String @db.VarChar(100) + relationship_type String @db.VarChar(20) // 'one-to-one', 'one-to-many', 'many-to-one', 'many-to-many' + connection_type String @db.VarChar(20) // 'simple-key', 'data-save', 'external-call' + company_code String @db.VarChar(50) + settings Json? // 연결 종류별 세부 설정 + is_active String? @default("Y") @db.Char(1) + created_date DateTime? @default(now()) @db.Timestamp(6) + created_by String? @db.VarChar(50) + updated_date DateTime? @default(now()) @db.Timestamp(6) + updated_by String? @db.VarChar(50) + + // 역참조 관계 + bridges data_relationship_bridge[] + + @@index([company_code], map: "idx_table_relationships_company_code") + @@index([diagram_id], map: "idx_table_relationships_diagram_id") + @@index([from_table_name], map: "idx_table_relationships_from_table") + @@index([to_table_name], map: "idx_table_relationships_to_table") + @@index([company_code, diagram_id], map: "idx_table_relationships_company_diagram") +} + +// 테이블 간 데이터 관계 중계 테이블 - 실제 데이터 연결 정보 저장 +model data_relationship_bridge { + bridge_id Int @id @default(autoincrement()) + relationship_id Int + + // 소스 테이블 정보 + from_table_name String @db.VarChar(100) + from_column_name String @db.VarChar(100) + + // 타겟 테이블 정보 + to_table_name String @db.VarChar(100) + to_column_name String @db.VarChar(100) + + // 메타데이터 + connection_type String @db.VarChar(20) // 'simple-key', 'data-save', 'external-call' + company_code String @db.VarChar(50) + created_at DateTime @default(now()) @db.Timestamp(6) + created_by String? @db.VarChar(50) + updated_at DateTime @default(now()) @db.Timestamp(6) + updated_by String? @db.VarChar(50) + is_active String @default("Y") @db.Char(1) + + // 추가 설정 (JSON) + bridge_data Json? // 연결 종류별 추가 데이터 + + // 관계 설정 + relationship table_relationships @relation(fields: [relationship_id], references: [relationship_id], onDelete: Cascade) + + @@index([relationship_id], map: "idx_data_bridge_relationship") + @@index([from_table_name], map: "idx_data_bridge_from_table") + @@index([to_table_name], map: "idx_data_bridge_to_table") + @@index([company_code], map: "idx_data_bridge_company") + @@index([is_active], map: "idx_data_bridge_active") + @@index([connection_type], map: "idx_data_bridge_connection_type") + @@index([from_table_name, from_column_name], map: "idx_data_bridge_from_lookup") + @@index([to_table_name, to_column_name], map: "idx_data_bridge_to_lookup") + @@index([company_code, is_active], map: "idx_data_bridge_company_active") +} + +// 데이터플로우 관계도 - JSON 구조로 저장 +model dataflow_diagrams { + diagram_id Int @id @default(autoincrement()) + diagram_name String @db.VarChar(255) + relationships Json // 모든 관계 정보를 JSON으로 저장 + node_positions Json? // 테이블 노드의 캔버스 위치 정보 (JSON 형태) + company_code String @db.VarChar(50) + created_at DateTime? @default(now()) @db.Timestamp(6) + updated_at DateTime? @default(now()) @updatedAt @db.Timestamp(6) + created_by String? @db.VarChar(50) + updated_by String? @db.VarChar(50) + + @@unique([company_code, diagram_name], map: "unique_diagram_name_per_company") + @@index([company_code], map: "idx_dataflow_diagrams_company") + @@index([diagram_name], map: "idx_dataflow_diagrams_name") +} + + diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index b82b6fb0..3c8b61cc 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -19,6 +19,8 @@ import commonCodeRoutes from "./routes/commonCodeRoutes"; import dynamicFormRoutes from "./routes/dynamicFormRoutes"; import fileRoutes from "./routes/fileRoutes"; import companyManagementRoutes from "./routes/companyManagementRoutes"; +import dataflowRoutes from "./routes/dataflowRoutes"; +import dataflowDiagramRoutes from "./routes/dataflowDiagramRoutes"; import webTypeStandardRoutes from "./routes/webTypeStandardRoutes"; import buttonActionStandardRoutes from "./routes/buttonActionStandardRoutes"; import screenStandardRoutes from "./routes/screenStandardRoutes"; @@ -106,6 +108,8 @@ app.use("/api/common-codes", commonCodeRoutes); app.use("/api/dynamic-form", dynamicFormRoutes); app.use("/api/files", fileRoutes); app.use("/api/company-management", companyManagementRoutes); +app.use("/api/dataflow", dataflowRoutes); +app.use("/api/dataflow-diagrams", dataflowDiagramRoutes); app.use("/api/admin/web-types", webTypeStandardRoutes); app.use("/api/admin/button-actions", buttonActionStandardRoutes); app.use("/api/admin/template-standards", templateStandardRoutes); diff --git a/backend-node/src/controllers/dataflowController.ts b/backend-node/src/controllers/dataflowController.ts new file mode 100644 index 00000000..c9a4a426 --- /dev/null +++ b/backend-node/src/controllers/dataflowController.ts @@ -0,0 +1,940 @@ +import { Request, Response } from "express"; +import { logger } from "../utils/logger"; +import { AuthenticatedRequest } from "../types/auth"; +import { ApiResponse } from "../types/common"; +import { DataflowService } from "../services/dataflowService"; + +/** + * 테이블 관계 생성 + */ +export async function createTableRelationship( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + logger.info("=== 테이블 관계 생성 시작 ==="); + + const { + diagramId, + relationshipName, + fromTableName, + fromColumnName, + toTableName, + toColumnName, + relationshipType, + connectionType, + settings, + } = req.body; + + // 필수 필드 검증 + if ( + !relationshipName || + !fromTableName || + !fromColumnName || + !toTableName || + !toColumnName + ) { + const response: ApiResponse = { + success: false, + message: "필수 필드가 누락되었습니다.", + error: { + code: "MISSING_REQUIRED_FIELDS", + details: + "relationshipName, fromTableName, fromColumnName, toTableName, toColumnName는 필수입니다.", + }, + }; + res.status(400).json(response); + return; + } + + // 사용자 정보에서 회사 코드 가져오기 + const companyCode = (req.user as any)?.company_code || "*"; + const userId = (req.user as any)?.userId || "system"; + + const dataflowService = new DataflowService(); + const relationship = await dataflowService.createTableRelationship({ + diagramId: diagramId ? parseInt(diagramId) : undefined, + relationshipName, + fromTableName, + fromColumnName, + toTableName, + toColumnName, + relationshipType: relationshipType || "one-to-one", + connectionType: connectionType || "simple-key", + companyCode, + settings: settings || {}, + createdBy: userId, + }); + + logger.info(`테이블 관계 생성 완료: ${relationship.relationship_id}`); + + const response: ApiResponse = { + success: true, + message: "테이블 관계가 성공적으로 생성되었습니다.", + data: relationship, + }; + + res.status(201).json(response); + } catch (error) { + logger.error("테이블 관계 생성 중 오류 발생:", error); + + const response: ApiResponse = { + success: false, + message: "테이블 관계 생성 중 오류가 발생했습니다.", + error: { + code: "TABLE_RELATIONSHIP_CREATE_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }; + + res.status(500).json(response); + } +} + +/** + * 테이블 관계 목록 조회 (회사별) + */ +export async function getTableRelationships( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + logger.info("=== 테이블 관계 목록 조회 시작 ==="); + + // 사용자 정보에서 회사 코드 가져오기 + const companyCode = (req.user as any)?.company_code || "*"; + + const dataflowService = new DataflowService(); + const relationships = + await dataflowService.getTableRelationships(companyCode); + + logger.info(`테이블 관계 목록 조회 완료: ${relationships.length}개`); + + const response: ApiResponse = { + success: true, + message: "테이블 관계 목록을 성공적으로 조회했습니다.", + data: relationships, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("테이블 관계 목록 조회 중 오류 발생:", error); + + const response: ApiResponse = { + success: false, + message: "테이블 관계 목록 조회 중 오류가 발생했습니다.", + error: { + code: "TABLE_RELATIONSHIPS_LIST_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }; + + res.status(500).json(response); + } +} + +/** + * 테이블 관계 수정 + */ +export async function updateTableRelationship( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + logger.info("=== 테이블 관계 수정 시작 ==="); + + const { relationshipId } = req.params; + const updateData = req.body; + + if (!relationshipId) { + const response: ApiResponse = { + success: false, + message: "관계 ID가 필요합니다.", + error: { + code: "MISSING_RELATIONSHIP_ID", + details: "relationshipId 파라미터가 누락되었습니다.", + }, + }; + res.status(400).json(response); + return; + } + + // 사용자 정보에서 회사 코드와 사용자 ID 가져오기 + const companyCode = (req.user as any)?.company_code || "*"; + const userId = (req.user as any)?.userId || "system"; + + const dataflowService = new DataflowService(); + const relationship = await dataflowService.updateTableRelationship( + parseInt(relationshipId), + { + ...updateData, + updatedBy: userId, + }, + companyCode + ); + + if (!relationship) { + const response: ApiResponse = { + success: false, + message: "테이블 관계를 찾을 수 없습니다.", + error: { + code: "TABLE_RELATIONSHIP_NOT_FOUND", + details: `관계 ID ${relationshipId}를 찾을 수 없습니다.`, + }, + }; + res.status(404).json(response); + return; + } + + logger.info(`테이블 관계 수정 완료: ${relationshipId}`); + + const response: ApiResponse = { + success: true, + message: "테이블 관계가 성공적으로 수정되었습니다.", + data: relationship, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("테이블 관계 수정 중 오류 발생:", error); + + const response: ApiResponse = { + success: false, + message: "테이블 관계 수정 중 오류가 발생했습니다.", + error: { + code: "TABLE_RELATIONSHIP_UPDATE_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }; + + res.status(500).json(response); + } +} + +/** + * 테이블 관계 삭제 + */ +export async function deleteTableRelationship( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + logger.info("=== 테이블 관계 삭제 시작 ==="); + + const { relationshipId } = req.params; + + if (!relationshipId) { + const response: ApiResponse = { + success: false, + message: "관계 ID가 필요합니다.", + error: { + code: "MISSING_RELATIONSHIP_ID", + details: "relationshipId 파라미터가 누락되었습니다.", + }, + }; + res.status(400).json(response); + return; + } + + // 사용자 정보에서 회사 코드 가져오기 + const companyCode = (req.user as any)?.company_code || "*"; + + const dataflowService = new DataflowService(); + const success = await dataflowService.deleteTableRelationship( + parseInt(relationshipId), + companyCode + ); + + if (!success) { + const response: ApiResponse = { + success: false, + message: "테이블 관계를 찾을 수 없습니다.", + error: { + code: "TABLE_RELATIONSHIP_NOT_FOUND", + details: `관계 ID ${relationshipId}를 찾을 수 없습니다.`, + }, + }; + res.status(404).json(response); + return; + } + + logger.info(`테이블 관계 삭제 완료: ${relationshipId}`); + + const response: ApiResponse = { + success: true, + message: "테이블 관계가 성공적으로 삭제되었습니다.", + }; + + res.status(200).json(response); + } catch (error) { + logger.error("테이블 관계 삭제 중 오류 발생:", error); + + const response: ApiResponse = { + success: false, + message: "테이블 관계 삭제 중 오류가 발생했습니다.", + error: { + code: "TABLE_RELATIONSHIP_DELETE_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }; + + res.status(500).json(response); + } +} + +/** + * 특정 테이블 관계 조회 + */ +export async function getTableRelationship( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + logger.info("=== 테이블 관계 조회 시작 ==="); + + const { relationshipId } = req.params; + + if (!relationshipId) { + const response: ApiResponse = { + success: false, + message: "관계 ID가 필요합니다.", + error: { + code: "MISSING_RELATIONSHIP_ID", + details: "relationshipId 파라미터가 누락되었습니다.", + }, + }; + res.status(400).json(response); + return; + } + + // 사용자 정보에서 회사 코드 가져오기 + const companyCode = (req.user as any)?.company_code || "*"; + + const dataflowService = new DataflowService(); + const relationship = await dataflowService.getTableRelationship( + parseInt(relationshipId), + companyCode + ); + + if (!relationship) { + const response: ApiResponse = { + success: false, + message: "테이블 관계를 찾을 수 없습니다.", + error: { + code: "TABLE_RELATIONSHIP_NOT_FOUND", + details: `관계 ID ${relationshipId}를 찾을 수 없습니다.`, + }, + }; + res.status(404).json(response); + return; + } + + logger.info(`테이블 관계 조회 완료: ${relationshipId}`); + + const response: ApiResponse = { + success: true, + message: "테이블 관계를 성공적으로 조회했습니다.", + data: relationship, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("테이블 관계 조회 중 오류 발생:", error); + + const response: ApiResponse = { + success: false, + message: "테이블 관계 조회 중 오류가 발생했습니다.", + error: { + code: "TABLE_RELATIONSHIP_GET_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }; + + res.status(500).json(response); + } +} + +// ==================== 데이터 연결 관리 API ==================== + +/** + * 데이터 관계 연결 생성 + */ +export async function createDataLink( + req: Request, + res: Response +): Promise { + try { + const { + relationshipId, + fromTableName, + fromColumnName, + toTableName, + toColumnName, + connectionType, + bridgeData, + } = req.body; + + // 필수 필드 검증 + if ( + !relationshipId || + !fromTableName || + !fromColumnName || + !toTableName || + !toColumnName || + !connectionType + ) { + const response: ApiResponse = { + success: false, + message: "필수 필드가 누락되었습니다.", + error: { + code: "MISSING_REQUIRED_FIELDS", + details: + "필수 필드: relationshipId, fromTableName, fromColumnName, toTableName, toColumnName, connectionType", + }, + }; + res.status(400).json(response); + return; + } + + const userInfo = (req as any).user; + const companyCode = userInfo?.company_code || "*"; + const createdBy = userInfo?.userId || "system"; + + const dataflowService = new DataflowService(); + const bridge = await dataflowService.createDataLink({ + relationshipId, + fromTableName, + fromColumnName, + toTableName, + toColumnName, + connectionType, + companyCode, + bridgeData, + createdBy, + }); + + const response: ApiResponse = { + success: true, + message: "데이터 연결이 성공적으로 생성되었습니다.", + data: bridge, + }; + + res.status(201).json(response); + } catch (error) { + logger.error("데이터 연결 생성 중 오류 발생:", error); + + const response: ApiResponse = { + success: false, + message: "데이터 연결 생성 중 오류가 발생했습니다.", + error: { + code: "DATA_LINK_CREATE_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }; + + res.status(500).json(response); + } +} + +/** + * 관계별 연결된 데이터 조회 + */ +export async function getLinkedDataByRelationship( + req: Request, + res: Response +): Promise { + try { + const relationshipId = parseInt(req.params.relationshipId); + + if (!relationshipId || isNaN(relationshipId)) { + const response: ApiResponse = { + success: false, + message: "유효하지 않은 관계 ID입니다.", + error: { + code: "INVALID_RELATIONSHIP_ID", + details: "관계 ID는 숫자여야 합니다.", + }, + }; + res.status(400).json(response); + return; + } + + const userInfo = (req as any).user; + const companyCode = userInfo?.company_code || "*"; + + const dataflowService = new DataflowService(); + const linkedData = await dataflowService.getLinkedDataByRelationship( + relationshipId, + companyCode + ); + + const response: ApiResponse = { + success: true, + message: "연결된 데이터를 성공적으로 조회했습니다.", + data: linkedData, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("연결된 데이터 조회 중 오류 발생:", error); + + const response: ApiResponse = { + success: false, + message: "연결된 데이터 조회 중 오류가 발생했습니다.", + error: { + code: "LINKED_DATA_GET_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }; + + res.status(500).json(response); + } +} + +/** + * 데이터 연결 삭제 + */ +export async function deleteDataLink( + req: Request, + res: Response +): Promise { + try { + const bridgeId = parseInt(req.params.bridgeId); + + if (!bridgeId || isNaN(bridgeId)) { + const response: ApiResponse = { + success: false, + message: "유효하지 않은 Bridge ID입니다.", + error: { + code: "INVALID_BRIDGE_ID", + details: "Bridge ID는 숫자여야 합니다.", + }, + }; + res.status(400).json(response); + return; + } + + const userInfo = (req as any).user; + const companyCode = userInfo?.company_code || "*"; + const deletedBy = userInfo?.userId || "system"; + + const dataflowService = new DataflowService(); + await dataflowService.deleteDataLink(bridgeId, companyCode, deletedBy); + + const response: ApiResponse = { + success: true, + message: "데이터 연결이 성공적으로 삭제되었습니다.", + data: null, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("데이터 연결 삭제 중 오류 발생:", error); + + const response: ApiResponse = { + success: false, + message: "데이터 연결 삭제 중 오류가 발생했습니다.", + error: { + code: "DATA_LINK_DELETE_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }; + + res.status(500).json(response); + } +} + +// ==================== 테이블 데이터 조회 ==================== + +/** + * 테이블 실제 데이터 조회 (페이징) + * GET /api/dataflow/table-data/:tableName + */ +export async function getTableData(req: Request, res: Response): Promise { + try { + const { tableName } = req.params; + const { + page = "1", + limit = "10", + search = "", + searchColumn = "", + } = req.query; + + if (!tableName) { + const response: ApiResponse = { + success: false, + message: "테이블명이 필요합니다.", + error: { + code: "MISSING_TABLE_NAME", + details: "테이블명을 제공해주세요.", + }, + }; + res.status(400).json(response); + return; + } + + const pageNum = parseInt(page as string) || 1; + const limitNum = parseInt(limit as string) || 10; + const userInfo = (req as any).user; + const companyCode = userInfo?.company_code || "*"; + + const dataflowService = new DataflowService(); + const result = await dataflowService.getTableData( + tableName, + pageNum, + limitNum, + search as string, + searchColumn as string, + 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: "TABLE_DATA_GET_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }; + + res.status(500).json(response); + } +} + +/** + * 관계도 그룹 목록 조회 (관계도 이름별로 그룹화) + */ +export async function getDataFlowDiagrams( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + logger.info("=== 관계도 목록 조회 시작 ==="); + + const { page = 1, size = 20, searchTerm = "" } = req.query; + + // 사용자 정보에서 회사 코드 가져오기 + const companyCode = (req.user as any)?.company_code || "*"; + + const pageNum = parseInt(page as string, 10); + const sizeNum = parseInt(size as string, 10); + + const dataflowService = new DataflowService(); + const result = await dataflowService.getDataFlowDiagrams( + companyCode, + pageNum, + sizeNum, + searchTerm as string + ); + + logger.info(`관계도 목록 조회 완료: ${result.total}개`); + + 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: "DATAFLOW_DIAGRAMS_LIST_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }; + + res.status(500).json(response); + } +} + +/** + * 특정 관계도의 모든 관계 조회 + */ +export async function getDiagramRelationships( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + logger.info("=== 관계도 관계 조회 시작 ==="); + + const { diagramName } = req.params; + + if (!diagramName) { + const response: ApiResponse = { + success: false, + message: "관계도 이름이 필요합니다.", + error: { + code: "MISSING_DIAGRAM_NAME", + details: "diagramName 파라미터가 필요합니다.", + }, + }; + res.status(400).json(response); + return; + } + + // 사용자 정보에서 회사 코드 가져오기 + const companyCode = (req.user as any)?.company_code || "*"; + + const dataflowService = new DataflowService(); + const relationships = await dataflowService.getDiagramRelationships( + companyCode, + decodeURIComponent(diagramName) + ); + + logger.info(`관계도 관계 조회 완료: ${relationships.length}개`); + + const response: ApiResponse = { + success: true, + message: "관계도 관계를 성공적으로 조회했습니다.", + data: relationships, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("관계도 관계 조회 중 오류 발생:", error); + + const response: ApiResponse = { + success: false, + message: "관계도 관계 조회 중 오류가 발생했습니다.", + error: { + code: "DIAGRAM_RELATIONSHIPS_GET_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }; + + res.status(500).json(response); + } +} + +/** + * 관계도 복사 + */ +export async function copyDiagram( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { diagramName } = req.params; + const companyCode = (req.user as any)?.company_code || "*"; + + if (!diagramName) { + const response: ApiResponse = { + success: false, + message: "관계도 이름이 필요합니다.", + error: { + code: "MISSING_DIAGRAM_NAME", + details: "diagramName 파라미터가 필요합니다.", + }, + }; + res.status(400).json(response); + return; + } + + const dataflowService = new DataflowService(); + const newDiagramName = await dataflowService.copyDiagram( + companyCode, + decodeURIComponent(diagramName) + ); + + const response: ApiResponse<{ newDiagramName: string }> = { + success: true, + message: "관계도가 성공적으로 복사되었습니다.", + data: { newDiagramName }, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("관계도 복사 실패:", error); + const response: ApiResponse = { + success: false, + message: "관계도 복사에 실패했습니다.", + error: { + code: "DIAGRAM_COPY_FAILED", + details: + error instanceof Error + ? error.message + : "알 수 없는 오류가 발생했습니다.", + }, + }; + res.status(500).json(response); + } +} + +/** + * 관계도 삭제 + */ +export async function deleteDiagram( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { diagramName } = req.params; + const companyCode = (req.user as any)?.company_code || "*"; + + if (!diagramName) { + const response: ApiResponse = { + success: false, + message: "관계도 이름이 필요합니다.", + error: { + code: "MISSING_DIAGRAM_NAME", + details: "diagramName 파라미터가 필요합니다.", + }, + }; + res.status(400).json(response); + return; + } + + const dataflowService = new DataflowService(); + const deletedCount = await dataflowService.deleteDiagram( + companyCode, + decodeURIComponent(diagramName) + ); + + const response: ApiResponse<{ deletedCount: number }> = { + success: true, + message: "관계도가 성공적으로 삭제되었습니다.", + data: { deletedCount }, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("관계도 삭제 실패:", error); + const response: ApiResponse = { + success: false, + message: "관계도 삭제에 실패했습니다.", + error: { + code: "DIAGRAM_DELETE_FAILED", + details: + error instanceof Error + ? error.message + : "알 수 없는 오류가 발생했습니다.", + }, + }; + res.status(500).json(response); + } +} + +/** + * diagram_id로 관계도 관계 조회 + */ +export async function getDiagramRelationshipsByDiagramId( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { diagramId } = req.params; + const companyCode = (req.user as any)?.company_code || "*"; + + if (!diagramId) { + const response: ApiResponse = { + success: false, + message: "관계도 ID가 필요합니다.", + error: { + code: "MISSING_DIAGRAM_ID", + details: "diagramId 파라미터가 필요합니다.", + }, + }; + res.status(400).json(response); + return; + } + + const dataflowService = new DataflowService(); + const relationships = + await dataflowService.getDiagramRelationshipsByDiagramId( + companyCode, + parseInt(diagramId) + ); + + const response: ApiResponse = { + success: true, + message: "관계도 관계 목록을 성공적으로 조회했습니다.", + data: relationships, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("관계도 관계 조회 실패:", error); + const response: ApiResponse = { + success: false, + message: "관계도 관계 조회에 실패했습니다.", + error: { + code: "DIAGRAM_RELATIONSHIPS_FETCH_FAILED", + details: + error instanceof Error + ? error.message + : "알 수 없는 오류가 발생했습니다.", + }, + }; + res.status(500).json(response); + } +} + +/** + * relationship_id로 관계도 관계 조회 (하위 호환성 유지) + */ +export async function getDiagramRelationshipsByRelationshipId( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { relationshipId } = req.params; + const companyCode = (req.user as any)?.company_code || "*"; + + if (!relationshipId) { + const response: ApiResponse = { + success: false, + message: "관계 ID가 필요합니다.", + error: { + code: "MISSING_RELATIONSHIP_ID", + details: "relationshipId 파라미터가 필요합니다.", + }, + }; + res.status(400).json(response); + return; + } + + const dataflowService = new DataflowService(); + const relationships = + await dataflowService.getDiagramRelationshipsByRelationshipId( + companyCode, + parseInt(relationshipId) + ); + + const response: ApiResponse = { + success: true, + message: "관계도 관계 목록을 성공적으로 조회했습니다.", + data: relationships, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("관계도 관계 조회 실패:", error); + const response: ApiResponse = { + success: false, + message: "관계도 관계 조회에 실패했습니다.", + error: { + code: "DIAGRAM_RELATIONSHIPS_FETCH_FAILED", + details: + error instanceof Error + ? error.message + : "알 수 없는 오류가 발생했습니다.", + }, + }; + res.status(500).json(response); + } +} diff --git a/backend-node/src/controllers/dataflowDiagramController.ts b/backend-node/src/controllers/dataflowDiagramController.ts new file mode 100644 index 00000000..20634d64 --- /dev/null +++ b/backend-node/src/controllers/dataflowDiagramController.ts @@ -0,0 +1,299 @@ +import { Request, Response } from "express"; +import { + getDataflowDiagrams as getDataflowDiagramsService, + getDataflowDiagramById as getDataflowDiagramByIdService, + createDataflowDiagram as createDataflowDiagramService, + updateDataflowDiagram as updateDataflowDiagramService, + deleteDataflowDiagram as deleteDataflowDiagramService, + copyDataflowDiagram as copyDataflowDiagramService, +} from "../services/dataflowDiagramService"; +import { logger } from "../utils/logger"; + +/** + * 관계도 목록 조회 (페이지네이션) + */ +export const getDataflowDiagrams = async (req: Request, res: Response) => { + try { + const page = parseInt(req.query.page as string) || 1; + const size = parseInt(req.query.size as string) || 20; + const searchTerm = req.query.searchTerm as string; + const companyCode = + (req.query.companyCode as string) || + (req.headers["x-company-code"] as string) || + "*"; + + const result = await getDataflowDiagramsService( + companyCode, + page, + size, + searchTerm + ); + + res.json({ + success: true, + data: result, + }); + } catch (error) { + logger.error("관계도 목록 조회 실패:", error); + res.status(500).json({ + success: false, + message: "관계도 목록 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}; + +/** + * 특정 관계도 조회 + */ +export const getDataflowDiagramById = async (req: Request, res: Response) => { + try { + const diagramId = parseInt(req.params.diagramId); + const companyCode = + (req.query.companyCode as string) || + (req.headers["x-company-code"] as string) || + "*"; + + if (isNaN(diagramId)) { + return res.status(400).json({ + success: false, + message: "유효하지 않은 관계도 ID입니다.", + }); + } + + const diagram = await getDataflowDiagramByIdService(diagramId, companyCode); + + if (!diagram) { + return res.status(404).json({ + success: false, + message: "관계도를 찾을 수 없습니다.", + }); + } + + return res.json({ + success: true, + data: diagram, + }); + } catch (error) { + logger.error("관계도 조회 실패:", error); + return res.status(500).json({ + success: false, + message: "관계도 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}; + +/** + * 새로운 관계도 생성 + */ +export const createDataflowDiagram = async (req: Request, res: Response) => { + try { + const { + diagram_name, + relationships, + node_positions, + company_code, + created_by, + updated_by, + } = req.body; + const companyCode = + company_code || + (req.query.companyCode as string) || + (req.headers["x-company-code"] as string) || + "*"; + const userId = + created_by || + updated_by || + (req.headers["x-user-id"] as string) || + "SYSTEM"; + + if (!diagram_name || !relationships) { + return res.status(400).json({ + success: false, + message: "관계도 이름과 관계 정보는 필수입니다.", + }); + } + + const newDiagram = await createDataflowDiagramService({ + diagram_name, + relationships, + node_positions, + company_code: companyCode, + created_by: userId, + updated_by: userId, + }); + + return res.status(201).json({ + success: true, + data: newDiagram, + message: "관계도가 성공적으로 생성되었습니다.", + }); + } catch (error) { + logger.error("관계도 생성 실패:", error); + + // 중복 이름 에러 처리 + if (error instanceof Error && error.message.includes("unique constraint")) { + return res.status(409).json({ + success: false, + message: "이미 존재하는 관계도 이름입니다.", + }); + } + + return res.status(500).json({ + success: false, + message: "관계도 생성 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}; + +/** + * 관계도 수정 + */ +export const updateDataflowDiagram = async (req: Request, res: Response) => { + try { + const diagramId = parseInt(req.params.diagramId); + const { updated_by } = req.body; + const companyCode = + (req.query.companyCode as string) || + (req.headers["x-company-code"] as string) || + "*"; + const userId = + updated_by || (req.headers["x-user-id"] as string) || "SYSTEM"; + + if (isNaN(diagramId)) { + return res.status(400).json({ + success: false, + message: "유효하지 않은 관계도 ID입니다.", + }); + } + + const updateData = { + ...req.body, + updated_by: userId, + }; + + const updatedDiagram = await updateDataflowDiagramService( + diagramId, + updateData, + companyCode + ); + + if (!updatedDiagram) { + return res.status(404).json({ + success: false, + message: "관계도를 찾을 수 없습니다.", + }); + } + + return res.json({ + success: true, + data: updatedDiagram, + message: "관계도가 성공적으로 수정되었습니다.", + }); + } catch (error) { + logger.error("관계도 수정 실패:", error); + return res.status(500).json({ + success: false, + message: "관계도 수정 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}; + +/** + * 관계도 삭제 + */ +export const deleteDataflowDiagram = async (req: Request, res: Response) => { + try { + const diagramId = parseInt(req.params.diagramId); + const companyCode = + (req.query.companyCode as string) || + (req.headers["x-company-code"] as string) || + "*"; + + if (isNaN(diagramId)) { + return res.status(400).json({ + success: false, + message: "유효하지 않은 관계도 ID입니다.", + }); + } + + const deleted = await deleteDataflowDiagramService(diagramId, companyCode); + + if (!deleted) { + return res.status(404).json({ + success: false, + message: "관계도를 찾을 수 없습니다.", + }); + } + + return res.json({ + success: true, + message: "관계도가 성공적으로 삭제되었습니다.", + }); + } catch (error) { + logger.error("관계도 삭제 실패:", error); + return res.status(500).json({ + success: false, + message: "관계도 삭제 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}; + +/** + * 관계도 복제 + */ +export const copyDataflowDiagram = async (req: Request, res: Response) => { + try { + const diagramId = parseInt(req.params.diagramId); + const { + new_name, + companyCode: bodyCompanyCode, + userId: bodyUserId, + } = req.body; + const companyCode = + bodyCompanyCode || + (req.query.companyCode as string) || + (req.headers["x-company-code"] as string) || + "*"; + const userId = + bodyUserId || (req.headers["x-user-id"] as string) || "SYSTEM"; + + if (isNaN(diagramId)) { + return res.status(400).json({ + success: false, + message: "유효하지 않은 관계도 ID입니다.", + }); + } + + const copiedDiagram = await copyDataflowDiagramService( + diagramId, + companyCode, + new_name, + userId + ); + + if (!copiedDiagram) { + return res.status(404).json({ + success: false, + message: "복제할 관계도를 찾을 수 없습니다.", + }); + } + + return res.status(201).json({ + success: true, + data: copiedDiagram, + message: "관계도가 성공적으로 복제되었습니다.", + }); + } catch (error) { + logger.error("관계도 복제 실패:", error); + return res.status(500).json({ + success: false, + message: "관계도 복제 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}; diff --git a/backend-node/src/routes/dataflowDiagramRoutes.ts b/backend-node/src/routes/dataflowDiagramRoutes.ts new file mode 100644 index 00000000..e160d6c3 --- /dev/null +++ b/backend-node/src/routes/dataflowDiagramRoutes.ts @@ -0,0 +1,49 @@ +import express from "express"; +import { + getDataflowDiagrams, + getDataflowDiagramById, + createDataflowDiagram, + updateDataflowDiagram, + deleteDataflowDiagram, + copyDataflowDiagram, +} from "../controllers/dataflowDiagramController"; + +const router = express.Router(); + +/** + * @route GET /api/dataflow-diagrams + * @desc 관계도 목록 조회 (페이지네이션) + */ +router.get("/", getDataflowDiagrams); + +/** + * @route GET /api/dataflow-diagrams/:diagramId + * @desc 특정 관계도 조회 + */ +router.get("/:diagramId", getDataflowDiagramById); + +/** + * @route POST /api/dataflow-diagrams + * @desc 새로운 관계도 생성 + */ +router.post("/", createDataflowDiagram); + +/** + * @route PUT /api/dataflow-diagrams/:diagramId + * @desc 관계도 수정 + */ +router.put("/:diagramId", updateDataflowDiagram); + +/** + * @route DELETE /api/dataflow-diagrams/:diagramId + * @desc 관계도 삭제 + */ +router.delete("/:diagramId", deleteDataflowDiagram); + +/** + * @route POST /api/dataflow-diagrams/:diagramId/copy + * @desc 관계도 복제 + */ +router.post("/:diagramId/copy", copyDataflowDiagram); + +export default router; diff --git a/backend-node/src/routes/dataflowRoutes.ts b/backend-node/src/routes/dataflowRoutes.ts new file mode 100644 index 00000000..983ac181 --- /dev/null +++ b/backend-node/src/routes/dataflowRoutes.ts @@ -0,0 +1,131 @@ +import express from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import { + createTableRelationship, + getTableRelationships, + getTableRelationship, + updateTableRelationship, + deleteTableRelationship, + createDataLink, + getLinkedDataByRelationship, + deleteDataLink, + getTableData, + getDataFlowDiagrams, + getDiagramRelationships, + getDiagramRelationshipsByDiagramId, + getDiagramRelationshipsByRelationshipId, + copyDiagram, + deleteDiagram, +} from "../controllers/dataflowController"; + +const router = express.Router(); + +// 모든 라우트에 인증 미들웨어 적용 +router.use(authenticateToken); + +/** + * 테이블 관계 생성 + * POST /api/dataflow/table-relationships + */ +router.post("/table-relationships", createTableRelationship); + +/** + * 테이블 관계 목록 조회 (회사별) + * GET /api/dataflow/table-relationships + */ +router.get("/table-relationships", getTableRelationships); + +/** + * 특정 테이블 관계 조회 + * GET /api/dataflow/table-relationships/:relationshipId + */ +router.get("/table-relationships/:relationshipId", getTableRelationship); + +/** + * 테이블 관계 수정 + * PUT /api/dataflow/table-relationships/:relationshipId + */ +router.put("/table-relationships/:relationshipId", updateTableRelationship); + +/** + * 테이블 관계 삭제 + * DELETE /api/dataflow/table-relationships/:relationshipId + */ +router.delete("/table-relationships/:relationshipId", deleteTableRelationship); + +// ==================== 데이터 연결 관리 라우트 ==================== + +/** + * 데이터 연결 생성 + * POST /api/dataflow/data-links + */ +router.post("/data-links", createDataLink); + +/** + * 관계별 연결된 데이터 조회 + * GET /api/dataflow/data-links/relationship/:relationshipId + */ +router.get( + "/data-links/relationship/:relationshipId", + getLinkedDataByRelationship +); + +/** + * 데이터 연결 삭제 + * DELETE /api/dataflow/data-links/:bridgeId + */ +router.delete("/data-links/:bridgeId", deleteDataLink); + +// ==================== 테이블 데이터 조회 라우트 ==================== + +/** + * 테이블 실제 데이터 조회 + * GET /api/dataflow/table-data/:tableName + */ +router.get("/table-data/:tableName", getTableData); + +// ==================== 관계도 관리 라우트 ==================== + +/** + * 관계도 목록 조회 (관계도 이름별로 그룹화) + * GET /api/dataflow/diagrams + */ +router.get("/diagrams", getDataFlowDiagrams); + +/** + * 특정 관계도의 모든 관계 조회 (diagram_id로) + * GET /api/dataflow/diagrams/:diagramId/relationships + */ +router.get( + "/diagrams/:diagramId/relationships", + getDiagramRelationshipsByDiagramId +); + +/** + * 특정 관계도의 모든 관계 조회 (diagramName으로 - 하위 호환성) + * GET /api/dataflow/diagrams/name/:diagramName/relationships + */ +router.get( + "/diagrams/name/:diagramName/relationships", + getDiagramRelationships +); + +/** + * 관계도 복사 + * POST /api/dataflow/diagrams/:diagramName/copy + */ +router.post("/diagrams/:diagramName/copy", copyDiagram); + +/** + * 관계도 삭제 + * DELETE /api/dataflow/diagrams/:diagramName + */ +router.delete("/diagrams/:diagramName", deleteDiagram); + +// relationship_id로 관계도 관계 조회 (하위 호환성) +router.get( + "/relationships/:relationshipId/diagram", + getDiagramRelationshipsByRelationshipId +); + +export default router; diff --git a/backend-node/src/services/dataflowDiagramService.ts b/backend-node/src/services/dataflowDiagramService.ts new file mode 100644 index 00000000..16524e4a --- /dev/null +++ b/backend-node/src/services/dataflowDiagramService.ts @@ -0,0 +1,313 @@ +import { PrismaClient } from "@prisma/client"; +import { logger } from "../utils/logger"; + +const prisma = new PrismaClient(); + +// 타입 정의 +interface CreateDataflowDiagramData { + diagram_name: string; + relationships: any; // JSON 데이터 + node_positions?: any; // JSON 데이터 (노드 위치 정보) + company_code: string; + created_by: string; + updated_by: string; +} + +interface UpdateDataflowDiagramData { + diagram_name?: string; + relationships?: any; // JSON 데이터 + node_positions?: any; // JSON 데이터 (노드 위치 정보) + updated_by: string; +} + +/** + * 관계도 목록 조회 (페이지네이션) + */ +export const getDataflowDiagrams = async ( + companyCode: string, + page: number = 1, + size: number = 20, + searchTerm?: string +) => { + try { + const offset = (page - 1) * size; + + // 검색 조건 구성 + const whereClause: any = {}; + + // company_code가 '*'가 아닌 경우에만 필터링 + if (companyCode !== "*") { + whereClause.company_code = companyCode; + } + + if (searchTerm) { + whereClause.diagram_name = { + contains: searchTerm, + mode: "insensitive", + }; + } + + // 총 개수 조회 + const total = await prisma.dataflow_diagrams.count({ + where: whereClause, + }); + + // 데이터 조회 + const diagrams = await prisma.dataflow_diagrams.findMany({ + where: whereClause, + orderBy: { + updated_at: "desc", + }, + skip: offset, + take: size, + }); + + const totalPages = Math.ceil(total / size); + + return { + diagrams, + pagination: { + page, + size, + total, + totalPages, + }, + }; + } catch (error) { + logger.error("관계도 목록 조회 서비스 오류:", error); + throw error; + } +}; + +/** + * 특정 관계도 조회 + */ +export const getDataflowDiagramById = async ( + diagramId: number, + companyCode: string +) => { + try { + const whereClause: any = { + diagram_id: diagramId, + }; + + // company_code가 '*'가 아닌 경우에만 필터링 + if (companyCode !== "*") { + whereClause.company_code = companyCode; + } + + const diagram = await prisma.dataflow_diagrams.findFirst({ + where: whereClause, + }); + + return diagram; + } catch (error) { + logger.error("관계도 조회 서비스 오류:", error); + throw error; + } +}; + +/** + * 새로운 관계도 생성 + */ +export const createDataflowDiagram = async ( + data: CreateDataflowDiagramData +) => { + try { + const newDiagram = await prisma.dataflow_diagrams.create({ + data: { + diagram_name: data.diagram_name, + relationships: data.relationships, + node_positions: data.node_positions || null, + company_code: data.company_code, + created_by: data.created_by, + updated_by: data.updated_by, + }, + }); + + return newDiagram; + } catch (error) { + logger.error("관계도 생성 서비스 오류:", error); + throw error; + } +}; + +/** + * 관계도 수정 + */ +export const updateDataflowDiagram = async ( + diagramId: number, + data: UpdateDataflowDiagramData, + companyCode: string +) => { + try { + // 먼저 해당 관계도가 존재하는지 확인 + const whereClause: any = { + diagram_id: diagramId, + }; + + // company_code가 '*'가 아닌 경우에만 필터링 + if (companyCode !== "*") { + whereClause.company_code = companyCode; + } + + const existingDiagram = await prisma.dataflow_diagrams.findFirst({ + where: whereClause, + }); + + if (!existingDiagram) { + return null; + } + + // 업데이트 실행 + const updatedDiagram = await prisma.dataflow_diagrams.update({ + where: { + diagram_id: diagramId, + }, + data: { + ...(data.diagram_name && { diagram_name: data.diagram_name }), + ...(data.relationships && { relationships: data.relationships }), + ...(data.node_positions !== undefined && { + node_positions: data.node_positions, + }), + updated_by: data.updated_by, + updated_at: new Date(), + }, + }); + + return updatedDiagram; + } catch (error) { + logger.error("관계도 수정 서비스 오류:", error); + throw error; + } +}; + +/** + * 관계도 삭제 + */ +export const deleteDataflowDiagram = async ( + diagramId: number, + companyCode: string +) => { + try { + // 먼저 해당 관계도가 존재하는지 확인 + const whereClause: any = { + diagram_id: diagramId, + }; + + // company_code가 '*'가 아닌 경우에만 필터링 + if (companyCode !== "*") { + whereClause.company_code = companyCode; + } + + const existingDiagram = await prisma.dataflow_diagrams.findFirst({ + where: whereClause, + }); + + if (!existingDiagram) { + return false; + } + + // 삭제 실행 + await prisma.dataflow_diagrams.delete({ + where: { + diagram_id: diagramId, + }, + }); + + return true; + } catch (error) { + logger.error("관계도 삭제 서비스 오류:", error); + throw error; + } +}; + +/** + * 관계도 복제 + */ +export const copyDataflowDiagram = async ( + diagramId: number, + companyCode: string, + newName?: string, + userId: string = "SYSTEM" +) => { + try { + // 원본 관계도 조회 + const whereClause: any = { + diagram_id: diagramId, + }; + + // company_code가 '*'가 아닌 경우에만 필터링 + if (companyCode !== "*") { + whereClause.company_code = companyCode; + } + + const originalDiagram = await prisma.dataflow_diagrams.findFirst({ + where: whereClause, + }); + + if (!originalDiagram) { + return null; + } + + // 새로운 이름 생성 (제공되지 않은 경우) + let copyName = newName; + if (!copyName) { + // 기존 이름에서 (n) 패턴을 찾아서 증가 + const baseNameMatch = originalDiagram.diagram_name.match( + /^(.+?)(\s*\((\d+)\))?$/ + ); + const baseName = baseNameMatch + ? baseNameMatch[1] + : originalDiagram.diagram_name; + + // 같은 패턴의 이름들을 찾아서 가장 큰 번호 찾기 + const copyWhereClause: any = { + diagram_name: { + startsWith: baseName, + }, + }; + + // company_code가 '*'가 아닌 경우에만 필터링 + if (companyCode !== "*") { + copyWhereClause.company_code = companyCode; + } + + const existingCopies = await prisma.dataflow_diagrams.findMany({ + where: copyWhereClause, + select: { + diagram_name: true, + }, + }); + + let maxNumber = 0; + existingCopies.forEach((copy) => { + const match = copy.diagram_name.match(/\((\d+)\)$/); + if (match) { + const num = parseInt(match[1]); + if (num > maxNumber) { + maxNumber = num; + } + } + }); + + copyName = `${baseName} (${maxNumber + 1})`; + } + + // 새로운 관계도 생성 + const copiedDiagram = await prisma.dataflow_diagrams.create({ + data: { + diagram_name: copyName, + relationships: originalDiagram.relationships as any, + company_code: companyCode, + created_by: userId, + updated_by: userId, + }, + }); + + return copiedDiagram; + } catch (error) { + logger.error("관계도 복제 서비스 오류:", error); + throw error; + } +}; diff --git a/backend-node/src/services/dataflowService.ts b/backend-node/src/services/dataflowService.ts new file mode 100644 index 00000000..0f711335 --- /dev/null +++ b/backend-node/src/services/dataflowService.ts @@ -0,0 +1,1125 @@ +import { PrismaClient } from "@prisma/client"; +import { logger } from "../utils/logger"; + +const prisma = new PrismaClient(); + +// 테이블 관계 생성 데이터 타입 +interface CreateTableRelationshipData { + diagramId?: number; // 기존 관계도에 추가하는 경우 + relationshipName: string; + fromTableName: string; + fromColumnName: string; + toTableName: string; + toColumnName: string; + relationshipType: string; + connectionType: string; + companyCode: string; + settings: any; + createdBy: string; +} + +// 테이블 관계 수정 데이터 타입 +interface UpdateTableRelationshipData { + relationshipName?: string; + fromTableName?: string; + fromColumnName?: string; + toTableName?: string; + toColumnName?: string; + relationshipType?: string; + connectionType?: string; + settings?: any; + updatedBy: string; +} + +export class DataflowService { + /** + * 테이블 관계 생성 + */ + async createTableRelationship(data: CreateTableRelationshipData) { + try { + logger.info("DataflowService: 테이블 관계 생성 시작", data); + + // diagram_id 결정 로직 + let diagramId = data.diagramId; + + if (!diagramId) { + // 새로운 관계도인 경우, 새로운 diagram_id 생성 + // 현재 최대 diagram_id + 1 + const maxDiagramId = await prisma.table_relationships.findFirst({ + where: { + company_code: data.companyCode, + }, + orderBy: { + diagram_id: "desc", + }, + select: { + diagram_id: true, + }, + }); + + diagramId = (maxDiagramId?.diagram_id || 0) + 1; + } + + // 중복 관계 확인 (같은 diagram_id 내에서) + const existingRelationship = await prisma.table_relationships.findFirst({ + where: { + diagram_id: diagramId, + from_table_name: data.fromTableName, + from_column_name: data.fromColumnName, + to_table_name: data.toTableName, + to_column_name: data.toColumnName, + company_code: data.companyCode, + is_active: "Y", + }, + }); + + if (existingRelationship) { + throw new Error( + `이미 존재하는 관계입니다: ${data.fromTableName}.${data.fromColumnName} → ${data.toTableName}.${data.toColumnName}` + ); + } + + // 새 관계 생성 + const relationship = await prisma.table_relationships.create({ + data: { + diagram_id: diagramId, + relationship_name: data.relationshipName, + from_table_name: data.fromTableName, + from_column_name: data.fromColumnName, + to_table_name: data.toTableName, + to_column_name: data.toColumnName, + relationship_type: data.relationshipType, + connection_type: data.connectionType, + company_code: data.companyCode, + settings: data.settings, + created_by: data.createdBy, + updated_by: data.createdBy, + }, + }); + + logger.info( + `DataflowService: 테이블 관계 생성 완료 - ID: ${relationship.relationship_id}, Diagram ID: ${relationship.diagram_id}` + ); + return relationship; + } catch (error) { + logger.error("DataflowService: 테이블 관계 생성 실패", error); + throw error; + } + } + + /** + * 회사별 테이블 관계 목록 조회 + */ + async getTableRelationships(companyCode: string) { + try { + logger.info( + `DataflowService: 테이블 관계 목록 조회 시작 - 회사코드: ${companyCode}` + ); + + // 관리자는 모든 회사의 관계를 볼 수 있음 + const whereCondition: any = { + is_active: "Y", + }; + + if (companyCode !== "*") { + whereCondition.company_code = companyCode; + } + + const relationships = await prisma.table_relationships.findMany({ + where: whereCondition, + orderBy: { + created_date: "desc", + }, + }); + + logger.info( + `DataflowService: 테이블 관계 목록 조회 완료 - ${relationships.length}개` + ); + return relationships; + } catch (error) { + logger.error("DataflowService: 테이블 관계 목록 조회 실패", error); + throw error; + } + } + + /** + * 특정 테이블 관계 조회 + */ + async getTableRelationship(relationshipId: number, companyCode: string) { + try { + logger.info( + `DataflowService: 테이블 관계 조회 시작 - ID: ${relationshipId}, 회사코드: ${companyCode}` + ); + + const whereCondition: any = { + relationship_id: relationshipId, + is_active: "Y", + }; + + // 관리자가 아닌 경우 회사코드 제한 + if (companyCode !== "*") { + whereCondition.company_code = companyCode; + } + + const relationship = await prisma.table_relationships.findFirst({ + where: whereCondition, + }); + + if (relationship) { + logger.info( + `DataflowService: 테이블 관계 조회 완료 - ID: ${relationshipId}` + ); + } else { + logger.warn( + `DataflowService: 테이블 관계를 찾을 수 없음 - ID: ${relationshipId}` + ); + } + + return relationship; + } catch (error) { + logger.error("DataflowService: 테이블 관계 조회 실패", error); + throw error; + } + } + + /** + * 테이블 관계 수정 + */ + async updateTableRelationship( + relationshipId: number, + updateData: UpdateTableRelationshipData, + companyCode: string + ) { + try { + logger.info( + `DataflowService: 테이블 관계 수정 시작 - ID: ${relationshipId}`, + updateData + ); + + // 기존 관계 확인 + const existingRelationship = await this.getTableRelationship( + relationshipId, + companyCode + ); + if (!existingRelationship) { + return null; + } + + // 관계 수정 + const relationship = await prisma.table_relationships.update({ + where: { + relationship_id: relationshipId, + }, + data: { + ...updateData, + updated_date: new Date(), + }, + }); + + logger.info( + `DataflowService: 테이블 관계 수정 완료 - ID: ${relationshipId}` + ); + return relationship; + } catch (error) { + logger.error("DataflowService: 테이블 관계 수정 실패", error); + throw error; + } + } + + /** + * 테이블 관계 삭제 (소프트 삭제) + */ + async deleteTableRelationship(relationshipId: number, companyCode: string) { + try { + logger.info( + `DataflowService: 테이블 관계 삭제 시작 - ID: ${relationshipId}, 회사코드: ${companyCode}` + ); + + // 기존 관계 확인 + const existingRelationship = await this.getTableRelationship( + relationshipId, + companyCode + ); + if (!existingRelationship) { + return false; + } + + // 소프트 삭제 (is_active = 'N') + await prisma.table_relationships.update({ + where: { + relationship_id: relationshipId, + }, + data: { + is_active: "N", + updated_date: new Date(), + }, + }); + + logger.info( + `DataflowService: 테이블 관계 삭제 완료 - ID: ${relationshipId}` + ); + return true; + } catch (error) { + logger.error("DataflowService: 테이블 관계 삭제 실패", error); + throw error; + } + } + + /** + * 특정 테이블과 관련된 모든 관계 조회 + */ + async getRelationshipsByTable(tableName: string, companyCode: string) { + try { + logger.info( + `DataflowService: 테이블별 관계 조회 시작 - 테이블: ${tableName}, 회사코드: ${companyCode}` + ); + + const whereCondition: any = { + OR: [{ from_table_name: tableName }, { to_table_name: tableName }], + is_active: "Y", + }; + + // 관리자가 아닌 경우 회사코드 제한 + if (companyCode !== "*") { + whereCondition.company_code = companyCode; + } + + const relationships = await prisma.table_relationships.findMany({ + where: whereCondition, + orderBy: { + created_date: "desc", + }, + }); + + logger.info( + `DataflowService: 테이블별 관계 조회 완료 - ${relationships.length}개` + ); + return relationships; + } catch (error) { + logger.error("DataflowService: 테이블별 관계 조회 실패", error); + throw error; + } + } + + /** + * 연결 타입별 관계 조회 + */ + async getRelationshipsByConnectionType( + connectionType: string, + companyCode: string + ) { + try { + logger.info( + `DataflowService: 연결타입별 관계 조회 시작 - 타입: ${connectionType}, 회사코드: ${companyCode}` + ); + + const whereCondition: any = { + connection_type: connectionType, + is_active: "Y", + }; + + // 관리자가 아닌 경우 회사코드 제한 + if (companyCode !== "*") { + whereCondition.company_code = companyCode; + } + + const relationships = await prisma.table_relationships.findMany({ + where: whereCondition, + orderBy: { + created_date: "desc", + }, + }); + + logger.info( + `DataflowService: 연결타입별 관계 조회 완료 - ${relationships.length}개` + ); + return relationships; + } catch (error) { + logger.error("DataflowService: 연결타입별 관계 조회 실패", error); + throw error; + } + } + + /** + * 관계 통계 조회 + */ + async getRelationshipStats(companyCode: string) { + try { + logger.info( + `DataflowService: 관계 통계 조회 시작 - 회사코드: ${companyCode}` + ); + + const whereCondition: any = { + is_active: "Y", + }; + + // 관리자가 아닌 경우 회사코드 제한 + if (companyCode !== "*") { + whereCondition.company_code = companyCode; + } + + // 전체 관계 수 + const totalCount = await prisma.table_relationships.count({ + where: whereCondition, + }); + + // 관계 타입별 통계 + const relationshipTypeStats = await prisma.table_relationships.groupBy({ + by: ["relationship_type"], + where: whereCondition, + _count: { + relationship_id: true, + }, + }); + + // 연결 타입별 통계 + const connectionTypeStats = await prisma.table_relationships.groupBy({ + by: ["connection_type"], + where: whereCondition, + _count: { + relationship_id: true, + }, + }); + + const stats = { + totalCount, + relationshipTypeStats: relationshipTypeStats.map((stat) => ({ + type: stat.relationship_type, + count: stat._count.relationship_id, + })), + connectionTypeStats: connectionTypeStats.map((stat) => ({ + type: stat.connection_type, + count: stat._count.relationship_id, + })), + }; + + logger.info(`DataflowService: 관계 통계 조회 완료`, stats); + return stats; + } catch (error) { + logger.error("DataflowService: 관계 통계 조회 실패", error); + throw error; + } + } + + // ==================== 데이터 중계 관리 ==================== + + /** + * 데이터 관계 연결 생성 + */ + async createDataLink(linkData: { + relationshipId: number; + fromTableName: string; + fromColumnName: string; + toTableName: string; + toColumnName: string; + connectionType: string; + companyCode: string; + bridgeData?: any; + createdBy: string; + }) { + try { + logger.info( + `DataflowService: 데이터 연결 생성 시작 - 관계ID: ${linkData.relationshipId}` + ); + + const bridge = await prisma.data_relationship_bridge.create({ + data: { + relationship_id: linkData.relationshipId, + from_table_name: linkData.fromTableName, + from_column_name: linkData.fromColumnName, + to_table_name: linkData.toTableName, + to_column_name: linkData.toColumnName, + connection_type: linkData.connectionType, + company_code: linkData.companyCode, + bridge_data: linkData.bridgeData || {}, + created_by: linkData.createdBy, + }, + }); + + logger.info( + `DataflowService: 데이터 연결 생성 완료 - Bridge ID: ${bridge.bridge_id}` + ); + return bridge; + } catch (error) { + logger.error("DataflowService: 데이터 연결 생성 실패", error); + throw error; + } + } + + /** + * 관계별 연결된 데이터 조회 + */ + async getLinkedDataByRelationship( + relationshipId: number, + companyCode: string + ) { + try { + logger.info( + `DataflowService: 관계별 연결 데이터 조회 시작 - 관계ID: ${relationshipId}` + ); + + const whereCondition: any = { + relationship_id: relationshipId, + is_active: "Y", + }; + + // 관리자가 아닌 경우 회사코드 제한 + if (companyCode !== "*") { + whereCondition.company_code = companyCode; + } + + const linkedData = await prisma.data_relationship_bridge.findMany({ + where: whereCondition, + orderBy: { created_at: "desc" }, + include: { + relationship: { + select: { + relationship_name: true, + relationship_type: true, + connection_type: true, + }, + }, + }, + }); + + logger.info( + `DataflowService: 관계별 연결 데이터 조회 완료 - ${linkedData.length}건` + ); + return linkedData; + } catch (error) { + logger.error("DataflowService: 관계별 연결 데이터 조회 실패", error); + throw error; + } + } + + /** + * 특정 테이블의 연결된 데이터 조회 + */ + async getLinkedDataByTable( + tableName: string, + keyValue?: string, + companyCode?: string + ) { + try { + logger.info( + `DataflowService: 테이블별 연결 데이터 조회 시작 - 테이블: ${tableName}` + ); + + const whereCondition: any = { + OR: [{ from_table_name: tableName }, { to_table_name: tableName }], + is_active: "Y", + }; + + // keyValue 파라미터는 더 이상 사용하지 않음 (key_value 필드 제거됨) + + // 회사코드 필터링 + if (companyCode && companyCode !== "*") { + whereCondition.company_code = companyCode; + } + + const linkedData = await prisma.data_relationship_bridge.findMany({ + where: whereCondition, + orderBy: { created_at: "desc" }, + include: { + relationship: { + select: { + relationship_name: true, + relationship_type: true, + connection_type: true, + }, + }, + }, + }); + + logger.info( + `DataflowService: 테이블별 연결 데이터 조회 완료 - ${linkedData.length}건` + ); + return linkedData; + } catch (error) { + logger.error("DataflowService: 테이블별 연결 데이터 조회 실패", error); + throw error; + } + } + + /** + * 데이터 연결 수정 + */ + async updateDataLink( + bridgeId: number, + updateData: { + bridgeData?: any; + updatedBy: string; + }, + companyCode: string + ) { + try { + logger.info( + `DataflowService: 데이터 연결 수정 시작 - Bridge ID: ${bridgeId}` + ); + + const whereCondition: any = { + bridge_id: bridgeId, + is_active: "Y", + }; + + // 관리자가 아닌 경우 회사코드 제한 + if (companyCode !== "*") { + whereCondition.company_code = companyCode; + } + + const updatedBridge = await prisma.data_relationship_bridge.update({ + where: whereCondition, + data: { + ...updateData, + updated_at: new Date(), + }, + }); + + logger.info( + `DataflowService: 데이터 연결 수정 완료 - Bridge ID: ${bridgeId}` + ); + return updatedBridge; + } catch (error) { + logger.error("DataflowService: 데이터 연결 수정 실패", error); + throw error; + } + } + + /** + * 데이터 연결 삭제 (소프트 삭제) + */ + async deleteDataLink( + bridgeId: number, + companyCode: string, + deletedBy: string + ) { + try { + logger.info( + `DataflowService: 데이터 연결 삭제 시작 - Bridge ID: ${bridgeId}` + ); + + const whereCondition: any = { + bridge_id: bridgeId, + is_active: "Y", + }; + + // 관리자가 아닌 경우 회사코드 제한 + if (companyCode !== "*") { + whereCondition.company_code = companyCode; + } + + await prisma.data_relationship_bridge.update({ + where: whereCondition, + data: { + is_active: "N", + updated_at: new Date(), + updated_by: deletedBy, + }, + }); + + logger.info( + `DataflowService: 데이터 연결 삭제 완료 - Bridge ID: ${bridgeId}` + ); + return true; + } catch (error) { + logger.error("DataflowService: 데이터 연결 삭제 실패", error); + throw error; + } + } + + /** + * 관계 삭제 시 연결된 모든 데이터도 삭제 + */ + async deleteAllLinkedDataByRelationship( + relationshipId: number, + companyCode: string, + deletedBy: string + ) { + try { + logger.info( + `DataflowService: 관계별 모든 데이터 연결 삭제 시작 - 관계ID: ${relationshipId}` + ); + + const whereCondition: any = { + relationship_id: relationshipId, + is_active: "Y", + }; + + // 관리자가 아닌 경우 회사코드 제한 + if (companyCode !== "*") { + whereCondition.company_code = companyCode; + } + + const result = await prisma.data_relationship_bridge.updateMany({ + where: whereCondition, + data: { + is_active: "N", + updated_at: new Date(), + updated_by: deletedBy, + }, + }); + + logger.info( + `DataflowService: 관계별 모든 데이터 연결 삭제 완료 - ${result.count}건` + ); + return result.count; + } catch (error) { + logger.error("DataflowService: 관계별 모든 데이터 연결 삭제 실패", error); + throw error; + } + } + + // ==================== 테이블 데이터 조회 ==================== + + /** + * 테이블 실제 데이터 조회 (페이징) + */ + async getTableData( + tableName: string, + page: number = 1, + limit: number = 10, + search: string = "", + searchColumn: string = "", + companyCode: string = "*" + ) { + try { + logger.info(`DataflowService: 테이블 데이터 조회 시작 - ${tableName}`); + + // 테이블 존재 여부 확인 (정보 스키마 사용) + const tableExists = await prisma.$queryRaw` + SELECT table_name + FROM information_schema.tables + WHERE table_name = ${tableName.toLowerCase()} + AND table_schema = 'public' + `; + + if ( + !tableExists || + (Array.isArray(tableExists) && tableExists.length === 0) + ) { + throw new Error(`테이블 '${tableName}'이 존재하지 않습니다.`); + } + + // 전체 데이터 개수 조회 + let totalCountQuery = `SELECT COUNT(*) as total FROM "${tableName}"`; + let dataQuery = `SELECT * FROM "${tableName}"`; + + // 검색 조건 추가 + if (search && searchColumn) { + const whereCondition = `WHERE "${searchColumn}" ILIKE '%${search}%'`; + totalCountQuery += ` ${whereCondition}`; + dataQuery += ` ${whereCondition}`; + } + + // 페이징 처리 + const offset = (page - 1) * limit; + dataQuery += ` ORDER BY 1 LIMIT ${limit} OFFSET ${offset}`; + + // 실제 쿼리 실행 + const [totalResult, dataResult] = await Promise.all([ + prisma.$queryRawUnsafe(totalCountQuery), + prisma.$queryRawUnsafe(dataQuery), + ]); + + const total = + Array.isArray(totalResult) && totalResult.length > 0 + ? Number((totalResult[0] as any).total) + : 0; + + const data = Array.isArray(dataResult) ? dataResult : []; + + const result = { + data, + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + hasNext: page < Math.ceil(total / limit), + hasPrev: page > 1, + }, + }; + + logger.info( + `DataflowService: 테이블 데이터 조회 완료 - ${tableName}, 총 ${total}건 중 ${data.length}건 조회` + ); + + return result; + } catch (error) { + logger.error( + `DataflowService: 테이블 데이터 조회 실패 - ${tableName}`, + error + ); + throw error; + } + } + + /** + * 관계도 그룹 목록 조회 (diagram_id별로 그룹화) + */ + async getDataFlowDiagrams( + companyCode: string, + page: number = 1, + size: number = 20, + searchTerm: string = "" + ) { + try { + logger.info( + `DataflowService: 관계도 목록 조회 시작 - ${companyCode}, page: ${page}, size: ${size}, search: ${searchTerm}` + ); + + // diagram_id별로 그룹화하여 조회 + const whereCondition = { + company_code: companyCode, + is_active: "Y", + ...(searchTerm && { + OR: [ + { + relationship_name: { + contains: searchTerm, + mode: "insensitive" as any, + }, + }, + { + from_table_name: { + contains: searchTerm, + mode: "insensitive" as any, + }, + }, + { + to_table_name: { + contains: searchTerm, + mode: "insensitive" as any, + }, + }, + ], + }), + }; + + // diagram_id별로 그룹화된 데이터 조회 + const relationships = await prisma.table_relationships.findMany({ + where: whereCondition, + select: { + relationship_id: true, + diagram_id: true, + relationship_name: true, + from_table_name: true, + to_table_name: true, + connection_type: true, + relationship_type: true, + created_date: true, + created_by: true, + updated_date: true, + updated_by: true, + }, + orderBy: [{ diagram_id: "asc" }, { created_date: "desc" }], + }); + + // diagram_id별로 그룹화 + const diagramMap = new Map(); + + relationships.forEach((rel) => { + const diagramId = rel.diagram_id; + + if (!diagramMap.has(diagramId)) { + diagramMap.set(diagramId, { + diagramId: diagramId, + diagramName: rel.relationship_name, // 첫 번째 관계의 이름을 사용 + connectionType: rel.connection_type, + relationshipType: rel.relationship_type, + tableCount: new Set(), + relationshipCount: 0, + createdAt: rel.created_date, + createdBy: rel.created_by, + updatedAt: rel.updated_date, + updatedBy: rel.updated_by, + tables: [], + }); + } + + const diagram = diagramMap.get(diagramId); + diagram.tableCount.add(rel.from_table_name); + diagram.tableCount.add(rel.to_table_name); + diagram.relationshipCount++; + + // 최신 업데이트 시간 유지 + if (rel.updated_date && rel.updated_date > diagram.updatedAt) { + diagram.updatedAt = rel.updated_date; + diagram.updatedBy = rel.updated_by; + } + }); + + // Set을 배열로 변환하고 테이블 개수 계산 + const diagrams = Array.from(diagramMap.values()).map((diagram) => ({ + ...diagram, + tableCount: diagram.tableCount.size, + tables: Array.from(diagram.tableCount), + })); + + // 페이징 처리 + const total = diagrams.length; + const startIndex = (page - 1) * size; + const endIndex = startIndex + size; + const paginatedDiagrams = diagrams.slice(startIndex, endIndex); + + const result = { + diagrams: paginatedDiagrams, + total, + page, + size, + totalPages: Math.ceil(total / size), + hasNext: page < Math.ceil(total / size), + hasPrev: page > 1, + }; + + logger.info( + `DataflowService: 관계도 목록 조회 완료 - 총 ${total}개 관계도 중 ${paginatedDiagrams.length}개 조회` + ); + + return result; + } catch (error) { + logger.error("DataflowService: 관계도 목록 조회 실패", error); + throw error; + } + } + + /** + * 특정 관계도의 모든 관계 조회 + */ + async getDiagramRelationships(companyCode: string, diagramName: string) { + try { + logger.info( + `DataflowService: 관계도 관계 조회 시작 - ${companyCode}, diagram: ${diagramName}` + ); + + const relationships = await prisma.table_relationships.findMany({ + where: { + company_code: companyCode, + relationship_name: diagramName, + is_active: "Y", + }, + orderBy: { + created_date: "asc", + }, + }); + + logger.info( + `DataflowService: 관계도 관계 조회 완료 - ${diagramName}, ${relationships.length}개 관계` + ); + + return relationships; + } catch (error) { + logger.error( + `DataflowService: 관계도 관계 조회 실패 - ${diagramName}`, + error + ); + throw error; + } + } + + /** + * 관계도 복사 (diagram_id 기반) + */ + async copyDiagram( + companyCode: string, + originalDiagramName: string + ): Promise { + try { + logger.info(`DataflowService: 관계도 복사 시작 - ${originalDiagramName}`); + + // 원본 관계도의 모든 관계 조회 + const originalRelationships = await prisma.table_relationships.findMany({ + where: { + company_code: companyCode, + relationship_name: originalDiagramName, + is_active: "Y", + }, + }); + + if (originalRelationships.length === 0) { + throw new Error("복사할 관계도를 찾을 수 없습니다."); + } + + // 새로운 관계도 이름 생성 (중복 검사) + let newDiagramName = `${originalDiagramName} (1)`; + let counter = 1; + + while (true) { + const existingDiagram = await prisma.table_relationships.findFirst({ + where: { + company_code: companyCode, + relationship_name: newDiagramName, + is_active: "Y", + }, + }); + + if (!existingDiagram) { + break; + } + + counter++; + newDiagramName = `${originalDiagramName} (${counter})`; + } + + // 새로운 diagram_id 생성 + const maxDiagramId = await prisma.table_relationships.findFirst({ + where: { + company_code: companyCode, + }, + orderBy: { + diagram_id: "desc", + }, + select: { + diagram_id: true, + }, + }); + + const newDiagramId = (maxDiagramId?.diagram_id || 0) + 1; + + // 트랜잭션으로 모든 관계 복사 + const copiedRelationships = await prisma.$transaction( + originalRelationships.map((rel) => + prisma.table_relationships.create({ + data: { + diagram_id: newDiagramId, + relationship_name: newDiagramName, + from_table_name: rel.from_table_name, + from_column_name: rel.from_column_name, + to_table_name: rel.to_table_name, + to_column_name: rel.to_column_name, + relationship_type: rel.relationship_type, + connection_type: rel.connection_type, + settings: rel.settings as any, + company_code: rel.company_code, + is_active: "Y", + created_by: rel.created_by, + updated_by: rel.updated_by, + }, + }) + ) + ); + + logger.info( + `DataflowService: 관계도 복사 완료 - ${originalDiagramName} → ${newDiagramName} (diagram_id: ${newDiagramId}), ${copiedRelationships.length}개 관계 복사` + ); + + return newDiagramName; + } catch (error) { + logger.error( + `DataflowService: 관계도 복사 실패 - ${originalDiagramName}`, + error + ); + throw error; + } + } + + /** + * 관계도 삭제 + */ + async deleteDiagram( + companyCode: string, + diagramName: string + ): Promise { + try { + logger.info(`DataflowService: 관계도 삭제 시작 - ${diagramName}`); + + // 관계도의 모든 관계 삭제 (하드 삭제) + const deleteResult = await prisma.table_relationships.deleteMany({ + where: { + company_code: companyCode, + relationship_name: diagramName, + }, + }); + + logger.info( + `DataflowService: 관계도 삭제 완료 - ${diagramName}, ${deleteResult.count}개 관계 삭제` + ); + + return deleteResult.count; + } catch (error) { + logger.error(`DataflowService: 관계도 삭제 실패 - ${diagramName}`, error); + throw error; + } + } + + /** + * diagram_id로 해당 관계도의 모든 관계 조회 + */ + async getDiagramRelationshipsByDiagramId( + companyCode: string, + diagramId: number + ) { + try { + logger.info( + `DataflowService: diagram_id로 관계도 관계 조회 - ${diagramId}` + ); + + // diagram_id로 모든 관계 조회 + const relationships = await prisma.table_relationships.findMany({ + where: { + diagram_id: diagramId, + company_code: companyCode, + is_active: "Y", + }, + orderBy: [{ relationship_id: "asc" }], + }); + + logger.info( + `DataflowService: diagram_id로 관계도 관계 조회 완료 - ${relationships.length}개 관계` + ); + + return relationships.map((rel) => ({ + ...rel, + settings: rel.settings as any, + })); + } catch (error) { + logger.error( + `DataflowService: diagram_id로 관계도 관계 조회 실패 - ${diagramId}`, + error + ); + throw error; + } + } + + /** + * relationship_id로 해당 관계도의 모든 관계 조회 (하위 호환성 유지) + */ + async getDiagramRelationshipsByRelationshipId( + companyCode: string, + relationshipId: number + ) { + try { + logger.info( + `DataflowService: relationship_id로 관계도 관계 조회 - ${relationshipId}` + ); + + // 먼저 해당 relationship_id의 diagram_id를 찾음 + const targetRelationship = await prisma.table_relationships.findFirst({ + where: { + relationship_id: relationshipId, + company_code: companyCode, + is_active: "Y", + }, + select: { + diagram_id: true, + }, + }); + + if (!targetRelationship) { + throw new Error("해당 관계 ID를 찾을 수 없습니다."); + } + + // diagram_id로 모든 관계 조회 + return this.getDiagramRelationshipsByDiagramId( + companyCode, + targetRelationship.diagram_id + ); + } catch (error) { + logger.error( + `DataflowService: relationship_id로 관계도 관계 조회 실패 - ${relationshipId}`, + error + ); + throw error; + } + } +} diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index ee9c9e92..494c8f63 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -123,12 +123,14 @@ export class TableManagementService { SELECT c.column_name as "columnName", COALESCE(cl.column_label, c.column_name) as "displayName", + c.data_type as "dataType", c.data_type as "dbType", COALESCE(cl.web_type, 'text') as "webType", COALESCE(cl.input_type, 'direct') as "inputType", COALESCE(cl.detail_settings, '') as "detailSettings", COALESCE(cl.description, '') as "description", c.is_nullable as "isNullable", + CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as "isPrimaryKey", c.column_default as "defaultValue", c.character_maximum_length as "maxLength", c.numeric_precision as "numericPrecision", @@ -141,6 +143,15 @@ export class TableManagementService { cl.is_visible as "isVisible" FROM information_schema.columns c LEFT JOIN column_labels cl ON c.table_name = cl.table_name AND c.column_name = cl.column_name + LEFT JOIN ( + SELECT kcu.column_name, kcu.table_name + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema + WHERE tc.constraint_type = 'PRIMARY KEY' + AND tc.table_name = ${tableName} + ) pk ON c.column_name = pk.column_name AND c.table_name = pk.table_name WHERE c.table_name = ${tableName} ORDER BY c.ordinal_position LIMIT ${size} OFFSET ${offset} diff --git a/backend-node/src/types/tableManagement.ts b/backend-node/src/types/tableManagement.ts index a8a65332..3469077f 100644 --- a/backend-node/src/types/tableManagement.ts +++ b/backend-node/src/types/tableManagement.ts @@ -10,12 +10,14 @@ export interface TableInfo { export interface ColumnTypeInfo { columnName: string; displayName: string; + dataType: string; // 추가: 데이터 타입 (dbType과 동일하지만 별도 필드) dbType: string; webType: string; inputType?: "direct" | "auto"; detailSettings: string; description: string; isNullable: string; + isPrimaryKey: boolean; // 추가: 기본키 여부 defaultValue?: string; maxLength?: number; numericPrecision?: number; diff --git a/docs/화면간_데이터_관계_설정_시스템_설계.md b/docs/화면간_데이터_관계_설정_시스템_설계.md new file mode 100644 index 00000000..2688af38 --- /dev/null +++ b/docs/화면간_데이터_관계_설정_시스템_설계.md @@ -0,0 +1,724 @@ +# 테이블 간 데이터 관계 설정 시스템 설계 + +## 📋 목차 + +1. [시스템 개요](#시스템-개요) +2. [핵심 기능](#핵심-기능) +3. [데이터베이스 설계](#데이터베이스-설계) +4. [프론트엔드 설계](#프론트엔드-설계) +5. [API 설계](#api-설계) +6. [사용 시나리오](#사용-시나리오) +7. [구현 계획](#구현-계획) + +## 🎯 시스템 개요 + +### 테이블 간 데이터 관계 설정 시스템이란? + +테이블 간 데이터 관계 설정 시스템은 회사별로 데이터베이스 테이블들 간의 데이터 관계를 시각적으로 설계하고 관리할 수 있는 시스템입니다. React Flow 라이브러리를 활용하여 직관적인 노드 기반 인터페이스로 1:1, 1:N, N:1, N:N 관계를 지원하며, 다양한 연결 방식과 종류로 복합적인 데이터 관계를 설계할 수 있습니다. + +### 주요 특징 + +- **React Flow 기반 인터페이스**: 직관적인 노드와 엣지 기반 시각적 설계 +- **회사별 관계 관리**: 사용자 회사 코드에 따른 테이블 관계 접근 제어 +- **시각적 관계 설계**: 드래그앤드롭으로 테이블 노드 배치 및 컬럼 간 연결 +- **다양한 관계 타입**: 1:1, 1:N, N:1, N:N 관계 지원 +- **연결 종류별 세부 설정**: 단순 키값, 데이터 저장, 외부 호출 +- **실시간 시뮬레이션**: 설계한 관계의 데이터 흐름 시뮬레이션 +- **중계 테이블 자동 생성**: N:N 관계에서 중계 테이블 자동 생성 +- **인터랙티브 캔버스**: 줌, 팬, 미니맵 등 React Flow의 고급 기능 활용 + +### 지원하는 관계 타입 + +- **1:1 (One to One)**: 한 테이블의 컬럼과 다른 테이블의 컬럼이 1:1로 연결 +- **1:N (One to Many)**: 한 테이블의 컬럼이 여러 테이블의 컬럼과 연결 +- **N:1 (Many to One)**: 여러 테이블의 컬럼이 한 테이블의 컬럼과 연결 +- **N:N (Many to Many)**: 여러 테이블의 컬럼이 여러 테이블의 컬럼과 연결 + +### 지원하는 연결 종류 + +- **단순 키값 연결**: 중계 테이블을 통한 참조 관계 +- **데이터 저장**: 컬럼 매핑을 통한 데이터 저장 +- **외부 호출**: API, 이메일, 웹훅 등을 통한 외부 시스템 연동 + +## 🚀 핵심 기능 + +### 1. React Flow 기반 테이블 노드 관리 + +- **테이블 추가**: 데이터베이스 테이블 목록에서 관계를 설정할 테이블들을 React Flow 캔버스에 추가 +- **테이블 배치**: 드래그앤드롭으로 테이블 노드를 원하는 위치에 배치 +- **테이블 이동**: React Flow의 내장 기능으로 테이블 노드 자유롭게 이동 +- **노드 선택**: 단일 또는 다중 노드 선택 지원 +- **자동 정렬**: React Flow의 레이아웃 알고리즘을 활용한 자동 정렬 + +### 2. React Flow 기반 컬럼 간 연결 설정 + +- **컬럼 선택**: 첫 번째 테이블의 컬럼을 클릭하여 연결 시작 +- **대상 컬럼 선택**: 두 번째 테이블의 컬럼을 클릭하여 연결 대상 지정 +- **드래그 연결**: React Flow의 핸들(Handle)을 드래그하여 시각적 연결 +- **관계 타입 선택**: 1:1, 1:N, N:1, N:N 중 선택 +- **연결 종류 선택**: 단순 키값, 데이터 저장, 외부 호출 중 선택 +- **엣지 커스터마이징**: 연결 타입별 색상, 스타일, 라벨 설정 + +### 3. 연결 종류별 세부 설정 + +#### 단순 키값 연결 + +- **중계 테이블명**: 자동 생성 또는 사용자 정의 +- **연결 규칙**: 중계 테이블 생성 및 관리 규칙 +- **참조 무결성**: 외래키 제약조건 설정 + +#### 데이터 저장 + +- **컬럼 매핑**: 소스 컬럼과 대상 컬럼 매핑 설정 +- **저장 조건**: 데이터 저장 조건 정의 +- **데이터 변환**: 컬럼 값 변환 규칙 + +#### 외부 호출 + +- **REST API**: API URL, HTTP Method, Headers, Body Template +- **이메일**: SMTP 서버, 발신자, 수신자, 제목/본문 템플릿 +- **웹훅**: 웹훅 URL, Payload 형식, Payload 템플릿 +- **FTP**: FTP 서버, 업로드 경로, 파일명 템플릿 +- **메시지 큐**: 큐 시스템, 큐 이름, 메시지 형식 + +### 4. React Flow 기반 시각적 관계 관리 + +- **엣지 렌더링**: React Flow의 커스텀 엣지로 테이블 간 관계를 시각적으로 표현 +- **관계 타입별 스타일링**: 연결 종류에 따른 색상, 선 스타일, 라벨 구분 +- **인터랙티브 캔버스**: 줌, 팬, 미니맵을 통한 대규모 다이어그램 탐색 +- **실시간 시뮬레이션**: 데이터 흐름 애니메이션 및 시뮬레이션 +- **관계 검증**: 연결 가능성 및 무결성 검증 +- **엣지 편집**: 연결선 클릭으로 관계 설정 수정 + +### 5. 관계 통계 및 관리 + +- **연결 통계**: 관계 타입별 연결 수 표시 +- **중계 테이블 관리**: 생성된 중계 테이블 목록 및 관리 +- **관계 목록**: 생성된 모든 관계 목록 조회 +- **관계 삭제**: 불필요한 관계 삭제 + +## 🗄️ 데이터베이스 설계 + +### 1. 테이블 관계 테이블 + +```sql +-- 테이블 간 관계 정의 +CREATE TABLE table_relationships ( + relationship_id SERIAL PRIMARY KEY, + relationship_name VARCHAR(200) NOT NULL, + from_table_name VARCHAR(100) NOT NULL, + from_column_name VARCHAR(100) NOT NULL, + to_table_name VARCHAR(100) NOT NULL, + to_column_name VARCHAR(100) NOT NULL, + relationship_type VARCHAR(20) NOT NULL, -- 'one-to-one', 'one-to-many', 'many-to-one', 'many-to-many' + connection_type VARCHAR(20) NOT NULL, -- 'simple-key', 'data-save', 'external-call' + company_code VARCHAR(50) NOT NULL, + settings JSONB, -- 연결 종류별 세부 설정 + is_active CHAR(1) DEFAULT 'Y', + created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_by VARCHAR(50), + updated_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_by VARCHAR(50) +); + +-- 회사 코드 인덱스 +CREATE INDEX idx_table_relationships_company_code ON table_relationships(company_code); +-- 테이블명 인덱스 +CREATE INDEX idx_table_relationships_from_table ON table_relationships(from_table_name); +CREATE INDEX idx_table_relationships_to_table ON table_relationships(to_table_name); +``` + +### 2. 중계 테이블 관리 + +```sql +-- 중계 테이블 정의 +CREATE TABLE bridge_tables ( + bridge_id SERIAL PRIMARY KEY, + bridge_name VARCHAR(200) NOT NULL, + table_name VARCHAR(100) NOT NULL, + relationship_id INTEGER NOT NULL, + company_code VARCHAR(50) NOT NULL, + description TEXT, + is_active CHAR(1) DEFAULT 'Y', + created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_by VARCHAR(50), + + -- 외래키 제약조건 + CONSTRAINT fk_bridge_tables_relationship + FOREIGN KEY (relationship_id) REFERENCES table_relationships(relationship_id) +); + +-- 회사 코드 인덱스 +CREATE INDEX idx_bridge_tables_company_code ON bridge_tables(company_code); +``` + +### 3. 외부 호출 설정 + +```sql +-- 외부 호출 설정 +CREATE TABLE external_call_configs ( + config_id SERIAL PRIMARY KEY, + relationship_id INTEGER NOT NULL, + call_type VARCHAR(50) NOT NULL, -- 'rest-api', 'email', 'webhook', 'ftp', 'queue' + parameters JSONB NOT NULL, -- 호출 유형별 파라미터 + is_active CHAR(1) DEFAULT 'Y', + created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_by VARCHAR(50), + + -- 외래키 제약조건 + CONSTRAINT fk_external_call_configs_relationship + FOREIGN KEY (relationship_id) REFERENCES table_relationships(relationship_id) +); +``` + +### 4. 테이블 간 연계 관계 + +``` +table_relationships (테이블 관계) + ↓ (1:N) +bridge_tables (중계 테이블) + ↓ (1:N) +external_call_configs (외부 호출 설정) +``` + +## 🎨 프론트엔드 설계 + +### 1. React Flow 기반 메인 컴포넌트 + +```typescript +// DataFlowDesigner.tsx +import ReactFlow, { + Node, + Edge, + Controls, + Background, + MiniMap, + useNodesState, + useEdgesState, + addEdge, + Connection, + EdgeChange, + NodeChange, +} from "reactflow"; +import "reactflow/dist/style.css"; + +interface DataFlowDesignerProps { + companyCode: string; + onSave?: (relationships: TableRelationship[]) => void; +} + +export const DataFlowDesigner: React.FC = ({ + companyCode, + onSave, +}) => { + const [nodes, setNodes, onNodesChange] = useNodesState([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); + const [selectedField, setSelectedField] = useState( + null + ); + const [pendingConnection, setPendingConnection] = + useState(null); + + const onConnect = useCallback( + (params: Connection) => { + setEdges((eds) => addEdge(params, eds)); + }, + [setEdges] + ); + + return ( +
+
+ {/* 사이드바 */} +
+ + + +
+ + {/* React Flow 캔버스 */} +
+ + + + + +
+
+ + +
+ ); +}; +``` + +### 2. React Flow 테이블 노드 컴포넌트 + +```typescript +// TableNode.tsx +import { Handle, Position } from "reactflow"; + +interface TableNodeData { + table: TableDefinition; + onColumnClick: (tableName: string, columnName: string) => void; +} + +export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => { + const { table, onColumnClick } = data; + + return ( +
+ {/* 노드 헤더 */} +
+
{table.tableName}
+
테이블
+
컬럼: {table.columns.length}개
+
+ + {/* 컬럼 목록 */} +
+
+ 컬럼 목록 ({table.columns.length}개) +
+
+ {table.columns.map((column) => ( +
onColumnClick(table.tableName, column.name)} + > +
+
{column.name}
+
+ {column.description} +
+
+
+ {column.type} +
+
+ ))} +
+
+ + {/* React Flow 핸들 */} + + +
+ ); +}; + +// 노드 타입 정의 +export const nodeTypes = { + tableNode: TableNode, +}; +``` + +### 3. 연결 설정 모달 + +```typescript +// ConnectionSetupModal.tsx +interface ConnectionSetupModalProps { + isOpen: boolean; + connection: PendingConnection | null; + onConfirm: (config: ConnectionConfig) => void; + onCancel: () => void; +} + +export const ConnectionSetupModal: React.FC = ({ + isOpen, + connection, + onConfirm, + onCancel, +}) => { + const [connectionType, setConnectionType] = + useState("simple-key"); + const [relationshipType, setRelationshipType] = + useState("one-to-one"); + const [settings, setSettings] = useState({}); + + return ( + + + + 필드 연결 설정 + + +
+ {/* 연결 정보 표시 */} + + + {/* 관계 타입 선택 */} + + + {/* 연결 종류 선택 */} + + + {/* 연결 종류별 세부 설정 */} + +
+ + + + + +
+
+ ); +}; +``` + +### 4. React Flow 엣지 컴포넌트 + +```typescript +// CustomEdge.tsx +import { + EdgeProps, + getBezierPath, + EdgeLabelRenderer, + BaseEdge, +} from "reactflow"; + +interface CustomEdgeData { + relationshipType: string; + connectionType: string; + label?: string; +} + +export const CustomEdge: React.FC> = ({ + id, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + data, + markerEnd, +}) => { + const [edgePath, labelX, labelY] = getBezierPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + }); + + const getEdgeColor = (connectionType: string) => { + switch (connectionType) { + case "simple-key": + return "#3B82F6"; // 파란색 + case "data-save": + return "#10B981"; // 초록색 + case "external-call": + return "#F59E0B"; // 주황색 + default: + return "#6B7280"; // 회색 + } + }; + + const getEdgeStyle = (connectionType: string) => { + switch (connectionType) { + case "simple-key": + return { strokeWidth: 2, strokeDasharray: "5,5" }; + case "data-save": + return { strokeWidth: 3 }; + case "external-call": + return { strokeWidth: 2, strokeDasharray: "10,5" }; + default: + return { strokeWidth: 2 }; + } + }; + + return ( + <> + + +
+ {data?.label || data?.relationshipType} +
+
+ + ); +}; + +// 엣지 타입 정의 +export const edgeTypes = { + customEdge: CustomEdge, +}; +``` + +## 🌐 API 설계 + +### 1. 테이블 관계 관리 API + +```typescript +// 테이블 관계 생성 +POST /api/table-relationships +Body: { + relationshipName: string; + fromTableName: string; + fromColumnName: string; + toTableName: string; + toColumnName: string; + relationshipType: 'one-to-one' | 'one-to-many' | 'many-to-one' | 'many-to-many'; + connectionType: 'simple-key' | 'data-save' | 'external-call'; + settings: ConnectionSettings; +} + +// 테이블 관계 목록 조회 (회사별) +GET /api/table-relationships?companyCode=COMP001 + +// 테이블 관계 수정 +PUT /api/table-relationships/:id + +// 테이블 관계 삭제 +DELETE /api/table-relationships/:id + +// 관계 시뮬레이션 +POST /api/table-relationships/:id/simulate +``` + +### 2. 중계 테이블 관리 API + +```typescript +// 중계 테이블 생성 +POST /api/bridge-tables +Body: { + bridgeName: string; + tableName: string; + relationshipId: number; + description?: string; +} + +// 중계 테이블 목록 조회 +GET /api/bridge-tables?companyCode=COMP001 + +// 중계 테이블 삭제 +DELETE /api/bridge-tables/:id +``` + +### 3. 외부 호출 설정 API + +```typescript +// 외부 호출 설정 생성 +POST /api/external-call-configs +Body: { + relationshipId: number; + callType: 'rest-api' | 'email' | 'webhook' | 'ftp' | 'queue'; + parameters: Record; +} + +// 외부 호출 설정 조회 +GET /api/external-call-configs?relationshipId=123 + +// 외부 호출 설정 수정 +PUT /api/external-call-configs/:id +``` + +## 🎬 사용 시나리오 + +### 1. 기본 관계 설정 + +1. **테이블 추가**: 데이터베이스 테이블 목록에서 관계를 설정할 테이블들을 캔버스에 추가 +2. **컬럼 선택**: 첫 번째 테이블의 컬럼을 클릭하여 연결 시작 +3. **대상 컬럼 선택**: 두 번째 테이블의 컬럼을 클릭하여 연결 대상 지정 +4. **관계 설정**: 관계 타입과 연결 종류를 선택하고 세부 설정 구성 +5. **연결 생성**: 설정 완료 후 연결 생성 + +### 2. 복합 데이터 흐름 설계 + +1. **다중 테이블 배치**: 관련된 여러 테이블을 캔버스에 배치 +2. **다양한 연결 타입**: 단순 키값, 데이터 저장, 외부 호출을 조합 +3. **중계 테이블 활용**: N:N 관계에서 중계 테이블 자동 생성 +4. **시각적 검증**: 연결선과 색상으로 관계 유형 구분 + +### 3. 외부 시스템 연동 + +1. **API 연결**: REST API 호출을 통한 외부 시스템 연동 +2. **이메일 알림**: 데이터 변경 시 이메일 자동 전송 +3. **웹훅 설정**: 실시간 데이터 동기화 +4. **메시지 큐**: 비동기 데이터 처리 + +## 📅 구현 계획 + +### Phase 1: React Flow 기본 설정 (1주) ✅ **완료** + +- [x] React Flow 라이브러리 설치 및 설정 (@xyflow/react 12.8.4) +- [x] 기본 노드와 엣지 컴포넌트 구현 +- [x] 테이블 노드 컴포넌트 구현 (TableNode.tsx) +- [x] 기본 연결선 그리기 (CustomEdge.tsx) +- [x] 메인 데이터 흐름 관리 컴포넌트 구현 (DataFlowDesigner.tsx) +- [x] /admin/dataflow 페이지 생성 +- [x] 메뉴 시스템 연동 (SQL 스크립트 제공) +- [x] 샘플 노드 추가/삭제 기능 +- [x] 노드 간 드래그앤드롭 연결 기능 +- [x] 줌, 팬, 미니맵 등 React Flow 기본 기능 + +### Phase 2: 관계 설정 기능 (2주) - 🚧 **진행 중 (85% 완료)** + +- [x] 연결 설정 모달 UI 구현 +- [x] 1:1, 1:N, N:1, N:N 관계 타입 선택 UI +- [x] 단순 키값, 데이터 저장, 외부 호출 연결 종류 UI +- [x] 컬럼-to-컬럼 연결 시스템 (클릭 기반) +- [x] 선택된 컬럼 정보 표시 및 순서 보장 +- [x] 드래그 다중 선택 기능 (부분 터치 선택 지원) +- [x] 테이블 기반 시스템으로 전환 (화면 → 테이블) +- [x] 코드 정리 및 최적화 (불필요한 props 제거) +- [ ] 연결 생성 로직 구현 (모달에서 실제 엣지 생성) +- [ ] 생성된 연결의 시각적 표시 (React Flow 엣지) +- [ ] 연결 데이터 백엔드 저장 API 연동 +- [ ] 기존 연결 수정/삭제 기능 + +### Phase 3: 고급 연결 타입 (2-3주) + +- [ ] 데이터 저장 연결 +- [ ] 외부 호출 연결 +- [ ] 중계 테이블 자동 생성 +- [ ] 커스텀 엣지 스타일링 + +### Phase 4: React Flow 고급 기능 (1-2주) + +- [ ] 줌, 팬, 미니맵 기능 +- [ ] 노드 선택 및 다중 선택 +- [ ] 키보드 단축키 지원 +- [ ] 레이아웃 자동 정렬 + +### Phase 5: 시각화 및 관리 (1-2주) + +- [ ] 관계 시뮬레이션 +- [ ] 연결 통계 및 관리 +- [ ] 관계 검증 +- [ ] 데이터 흐름 애니메이션 + +### Phase 6: 고급 기능 (2-3주) + +- [ ] N:N 관계 지원 +- [ ] 복합 데이터 흐름 +- [ ] 외부 시스템 연동 +- [ ] 성능 최적화 + +## 🎯 결론 + +**테이블 간 데이터 관계 설정 시스템**을 통해 ERP 시스템의 테이블들 간 데이터 관계를 시각적으로 설계하고 관리할 수 있습니다. React Flow 라이브러리를 활용한 직관적인 노드 기반 인터페이스와 회사별 권한 관리, 기존 테이블관리 시스템과의 완벽한 연동을 통해 체계적인 데이터 관계 관리가 가능합니다. + +## 📊 구현 현황 + +### ✅ Phase 1 완료 (2024-12-19) + +**구현된 기능:** + +- React Flow 12.8.4 기반 시각적 캔버스 +- 테이블 노드 컴포넌트 (컬럼 정보, 타입별 색상 구분, 노드 리사이징) +- 커스텀 엣지 컴포넌트 (관계 타입별 스타일링) +- 드래그앤드롭 노드 배치 및 연결 +- 줌, 팬, 미니맵 등 고급 시각화 기능 (스크롤 충돌 해결) +- 실제 테이블 데이터 연동 (테이블 관리 API 연결) +- 컬럼-to-컬럼 연결 시스템 (클릭 기반, 2개 테이블 제한) +- 연결 설정 모달 (관계 타입, 연결 종류 선택 UI) +- /admin/dataflow 경로 설정 +- 메뉴 시스템 연동 완료 +- 사용자 경험 개선 (토스트 알림, 선택 순서 보장) + +**구현된 파일:** + +- `frontend/components/dataflow/DataFlowDesigner.tsx` - 메인 캔버스 컴포넌트 +- `frontend/components/dataflow/TableNode.tsx` - 테이블 노드 컴포넌트 (NodeResizer 포함) +- `frontend/components/dataflow/CustomEdge.tsx` - 커스텀 엣지 컴포넌트 +- `frontend/components/dataflow/ConnectionSetupModal.tsx` - 연결 설정 모달 +- `frontend/app/(main)/admin/dataflow/page.tsx` - 데이터 흐름 관리 페이지 +- `frontend/lib/api/dataflow.ts` - 데이터 흐름 API 클라이언트 +- `docs/add_dataflow_menu.sql` - 메뉴 추가 스크립트 + +**주요 개선사항:** + +1. **스크롤 충돌 해결**: 노드 내부 스크롤과 React Flow 줌/팬 기능 분리 +2. **테이블 기반 시스템**: 화면 기반에서 테이블 기반으로 완전 전환 +3. **컬럼-to-컬럼 연결**: 드래그앤드롭 대신 클릭 기반 컬럼 선택 방식 +4. **2개 테이블 제한**: 최대 2개 테이블에서만 컬럼 선택 가능 +5. **선택 순서 보장**: 사이드바와 모달에서 컬럼 선택 순서 정확히 반영 +6. **실제 데이터 연동**: 테이블 관리 시스템의 실제 테이블/컬럼 데이터 사용 +7. **사용자 경험**: react-hot-toast를 통한 친화적인 알림 시스템 +8. **React 안정성**: 렌더링 중 상태 변경 문제 해결 +9. **드래그 다중 선택**: 부분 터치로도 노드 선택 가능한 고급 선택 기능 +10. **코드 최적화**: 불필요한 props 및 컴포넌트 제거로 성능 향상 + +**다음 단계:** Phase 2 - 실제 연결 생성 및 시각적 표시 기능 구현 + +### 주요 가치 + +- **React Flow 기반 시각적 설계**: 복잡한 테이블 관계를 직관적인 노드와 엣지로 설계 +- **인터랙티브 캔버스**: 줌, 팬, 미니맵 등 고급 시각화 기능 제공 +- **회사별 관리**: 각 회사별로 독립적인 테이블 관계 관리 +- **다양한 연결 타입**: 업무 요구사항에 맞는 다양한 연결 방식 +- **자동화**: 중계 테이블 자동 생성 및 외부 시스템 연동 +- **확장성**: 새로운 연결 타입과 관계 유형 쉽게 추가 +- **사용자 친화적**: 드래그앤드롭 기반의 직관적인 사용자 인터페이스 diff --git a/frontend/app/(main)/admin/dataflow/edit/[diagramId]/page.tsx b/frontend/app/(main)/admin/dataflow/edit/[diagramId]/page.tsx new file mode 100644 index 00000000..25b3f193 --- /dev/null +++ b/frontend/app/(main)/admin/dataflow/edit/[diagramId]/page.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { useParams, useRouter } from "next/navigation"; +import { useState, useEffect } from "react"; +import { ArrowLeft } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { DataFlowDesigner } from "@/components/dataflow/DataFlowDesigner"; +import { DataFlowAPI } from "@/lib/api/dataflow"; +import { toast } from "sonner"; + +export default function DataFlowEditPage() { + const params = useParams(); + const router = useRouter(); + const [diagramId, setDiagramId] = useState(0); + const [diagramName, setDiagramName] = useState(""); + + useEffect(() => { + if (params.diagramId) { + // URL에서 diagram_id 설정 + const id = parseInt(params.diagramId as string); + setDiagramId(id); + + // diagram_id로 관계도명 조회 + const fetchDiagramName = async () => { + try { + const jsonDiagram = await DataFlowAPI.getJsonDataFlowDiagramById(id); + if (jsonDiagram && jsonDiagram.diagram_name) { + setDiagramName(jsonDiagram.diagram_name); + } else { + setDiagramName(`관계도 ID: ${id}`); + } + } catch (error) { + console.error("관계도명 조회 실패:", error); + setDiagramName(`관계도 ID: ${id}`); + } + }; + + fetchDiagramName(); + } + }, [params.diagramId]); + + const handleBackToList = () => { + router.push("/admin/dataflow"); + }; + + if (!diagramId || !diagramName) { + return ( +
+
+
+

관계도 정보를 불러오는 중...

+
+
+ ); + } + + return ( +
+ {/* 헤더 */} +
+
+ +
+

📊 관계도 편집

+

+ {diagramName} 관계도를 편집하고 있습니다 +

+
+
+
+ + {/* 데이터플로우 디자이너 */} +
+ +
+
+ ); +} diff --git a/frontend/app/(main)/admin/dataflow/page.tsx b/frontend/app/(main)/admin/dataflow/page.tsx new file mode 100644 index 00000000..19914665 --- /dev/null +++ b/frontend/app/(main)/admin/dataflow/page.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { DataFlowDesigner } from "@/components/dataflow/DataFlowDesigner"; +import DataFlowList from "@/components/dataflow/DataFlowList"; +import { TableRelationship, DataFlowDiagram } from "@/lib/api/dataflow"; +import { useAuth } from "@/hooks/useAuth"; +import { ArrowLeft } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +type Step = "list" | "design"; + +export default function DataFlowPage() { + const { user } = useAuth(); + const router = useRouter(); + const [currentStep, setCurrentStep] = useState("list"); + const [stepHistory, setStepHistory] = useState(["list"]); + + // 단계별 제목과 설명 + const stepConfig = { + list: { + title: "데이터 흐름 관계도 관리", + description: "생성된 관계도들을 확인하고 관리하세요", + icon: "📊", + }, + design: { + title: "새 관계도 설계", + description: "테이블 간 데이터 관계를 시각적으로 설계하세요", + icon: "🎨", + }, + }; + + // 다음 단계로 이동 + const goToNextStep = (nextStep: Step) => { + setStepHistory((prev) => [...prev, nextStep]); + setCurrentStep(nextStep); + }; + + // 이전 단계로 이동 + const goToPreviousStep = () => { + if (stepHistory.length > 1) { + const newHistory = stepHistory.slice(0, -1); + const previousStep = newHistory[newHistory.length - 1]; + setStepHistory(newHistory); + setCurrentStep(previousStep); + } + }; + + // 특정 단계로 이동 + const goToStep = (step: Step) => { + setCurrentStep(step); + // 해당 단계까지의 히스토리만 유지 + const stepIndex = stepHistory.findIndex((s) => s === step); + if (stepIndex !== -1) { + setStepHistory(stepHistory.slice(0, stepIndex + 1)); + } + }; + + const handleSave = (relationships: TableRelationship[]) => { + console.log("저장된 관계:", relationships); + // 저장 후 목록으로 돌아가기 - 다음 렌더링 사이클로 지연 + setTimeout(() => { + goToStep("list"); + }, 0); + }; + + const handleDesignDiagram = (diagram: DataFlowDiagram | null) => { + if (diagram) { + // 기존 관계도 편집 - 새로운 URL로 이동 + router.push(`/admin/dataflow/edit/${diagram.diagramId}`); + } else { + // 새 관계도 생성 - 현재 페이지에서 처리 + goToNextStep("design"); + } + }; + + return ( +
+ {/* 헤더 */} +
+
+
+ {currentStep !== "list" && ( + + )} +
+

+ {stepConfig[currentStep].icon} + {stepConfig[currentStep].title} +

+

{stepConfig[currentStep].description}

+
+
+
+
+ + {/* 단계별 내용 */} +
+ {/* 관계도 목록 단계 */} + {currentStep === "list" && ( +
+ +
+ )} + + {/* 관계도 설계 단계 */} + {currentStep === "design" && ( +
+ goToStep("list")} + /> +
+ )} +
+
+ ); +} diff --git a/frontend/components/dataflow/ConnectionSetupModal.tsx b/frontend/components/dataflow/ConnectionSetupModal.tsx new file mode 100644 index 00000000..4147b87e --- /dev/null +++ b/frontend/components/dataflow/ConnectionSetupModal.tsx @@ -0,0 +1,761 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +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 toast from "react-hot-toast"; + +// 연결 정보 타입 +interface ConnectionInfo { + fromNode: { + id: string; + tableName: string; + displayName: string; + }; + toNode: { + id: string; + tableName: string; + displayName: string; + }; + fromColumn?: string; + toColumn?: string; + selectedColumnsData?: { + [tableName: string]: { + displayName: string; + columns: string[]; + }; + }; + existingRelationship?: { + relationshipName: string; + relationshipType: string; + connectionType: string; + settings?: any; + }; +} + +// 연결 설정 타입 +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; +} + +// 단순 키값 연결 설정 +interface SimpleKeySettings { + notes: string; +} + +// 데이터 저장 설정 +interface DataSaveSettings { + sourceField: string; + targetField: string; + saveConditions: string; +} + +// 외부 호출 설정 +interface ExternalCallSettings { + callType: "rest-api" | "email" | "webhook" | "ftp" | "queue"; + apiUrl?: string; + httpMethod?: "GET" | "POST" | "PUT" | "DELETE"; + headers?: string; + bodyTemplate?: string; +} + +interface ConnectionSetupModalProps { + isOpen: boolean; + connection: ConnectionInfo | null; + companyCode: string; + diagramId?: number; + onConfirm: (relationship: TableRelationship) => void; + onCancel: () => void; +} + +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: {}, + }); + + // 연결 종류별 설정 상태 + const [simpleKeySettings, setSimpleKeySettings] = useState({ + notes: "", + }); + + const [dataSaveSettings, setDataSaveSettings] = useState({ + sourceField: "", + targetField: "", + saveConditions: "", + }); + + const [externalCallSettings, setExternalCallSettings] = useState({ + callType: "rest-api", + apiUrl: "", + httpMethod: "POST", + headers: "{}", + bodyTemplate: "{}", + }); + + // 테이블 및 컬럼 선택을 위한 새로운 상태들 + const [availableTables, setAvailableTables] = useState([]); + const [selectedFromTable, setSelectedFromTable] = useState(""); + const [selectedToTable, setSelectedToTable] = useState(""); + const [fromTableColumns, setFromTableColumns] = useState([]); + const [toTableColumns, setToTableColumns] = useState([]); + const [selectedFromColumns, setSelectedFromColumns] = useState([]); + const [selectedToColumns, setSelectedToColumns] = useState([]); + + // 테이블 목록 로드 + useEffect(() => { + const loadTables = async () => { + try { + const tables = await DataFlowAPI.getTables(); + setAvailableTables(tables); + } catch (error) { + console.error("테이블 목록 로드 실패:", error); + toast.error("테이블 목록을 불러오는데 실패했습니다."); + } + }; + + if (isOpen) { + loadTables(); + } + }, [isOpen]); + + // 모달이 열릴 때 기본값 설정 + useEffect(() => { + if (isOpen && connection) { + const fromTableName = connection.fromNode.tableName; + const toTableName = connection.toNode.tableName; + const fromDisplayName = connection.fromNode.displayName; + const toDisplayName = connection.toNode.displayName; + + // 테이블 선택 설정 + setSelectedFromTable(fromTableName); + setSelectedToTable(toTableName); + + // 기존 관계 정보가 있으면 사용, 없으면 기본값 설정 + 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 || {}, + }); + + // 단순 키값 연결 기본값 설정 + setSimpleKeySettings({ + notes: `${fromDisplayName}과 ${toDisplayName} 간의 키값 연결`, + }); + + // 데이터 저장 기본값 설정 + setDataSaveSettings({ + sourceField: "", + targetField: "", + saveConditions: "데이터 저장 조건을 입력하세요", + }); + + // 외부 호출 기본값 설정 + setExternalCallSettings({ + callType: "rest-api", + apiUrl: "https://api.example.com/webhook", + httpMethod: "POST", + headers: "{}", + bodyTemplate: "{}", + }); + + // 선택된 컬럼 정보가 있다면 설정 + if (connection.selectedColumnsData) { + const fromColumns = connection.selectedColumnsData[fromTableName]?.columns || []; + const toColumns = connection.selectedColumnsData[toTableName]?.columns || []; + + setSelectedFromColumns(fromColumns); + setSelectedToColumns(toColumns); + + setConfig((prev) => ({ + ...prev, + fromColumnName: fromColumns.join(", "), + toColumnName: toColumns.join(", "), + })); + } + } + }, [isOpen, connection]); + + // From 테이블 선택 시 컬럼 로드 + useEffect(() => { + const loadFromColumns = async () => { + if (selectedFromTable) { + try { + const columns = await DataFlowAPI.getTableColumns(selectedFromTable); + setFromTableColumns(columns); + } catch (error) { + console.error("From 테이블 컬럼 로드 실패:", error); + toast.error("From 테이블 컬럼을 불러오는데 실패했습니다."); + } + } + }; + + loadFromColumns(); + }, [selectedFromTable]); + + // To 테이블 선택 시 컬럼 로드 + useEffect(() => { + const loadToColumns = async () => { + if (selectedToTable) { + try { + const columns = await DataFlowAPI.getTableColumns(selectedToTable); + setToTableColumns(columns); + } catch (error) { + console.error("To 테이블 컬럼 로드 실패:", error); + toast.error("To 테이블 컬럼을 불러오는데 실패했습니다."); + } + } + }; + + loadToColumns(); + }, [selectedToTable]); + + // 선택된 컬럼들이 변경될 때 config 업데이트 + useEffect(() => { + setConfig((prev) => ({ + ...prev, + fromColumnName: selectedFromColumns.join(", "), + toColumnName: selectedToColumns.join(", "), + })); + }, [selectedFromColumns, selectedToColumns]); + + const handleConfirm = () => { + if (!config.relationshipName || !connection) { + toast.error("필수 정보를 모두 입력해주세요."); + return; + } + + // 연결 종류별 설정을 준비 + let settings = {}; + + switch (config.connectionType) { + case "simple-key": + settings = simpleKeySettings; + break; + case "data-save": + settings = dataSaveSettings; + break; + case "external-call": + settings = externalCallSettings; + break; + } + + // 선택된 컬럼들 검증 + 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 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, + 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, + }, + }, + }; + + toast.success("관계가 생성되었습니다!"); + + // 부모 컴포넌트로 관계 데이터 전달 (DB 저장 없이) + onConfirm(relationshipData); + handleCancel(); // 모달 닫기 + }; + + 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 fromTableData = selectedColumnsData[fromTable]; + const toTableData = selectedColumnsData[toTable]; + + // 연결 종류별 설정 패널 렌더링 + const renderConnectionTypeSettings = () => { + switch (config.connectionType) { + case "simple-key": + return ( +
+
+ + 단순 키값 연결 설정 +
+
+
+ +