From 4dfa82d3ddb15a92fd28e908e76737fb3f65630b Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 8 Jan 2026 15:56:06 +0900 Subject: [PATCH] =?UTF-8?q?=EB=B6=84=ED=95=A0=ED=8C=A8=EB=84=90=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controllers/tableManagementController.ts | 64 ++++ .../src/routes/tableManagementRoutes.ts | 10 + .../src/services/tableManagementService.ts | 132 +++++++ frontend/lib/api/tableManagement.ts | 34 ++ .../SplitPanelLayoutComponent.tsx | 56 ++- .../SplitPanelLayoutConfigPanel.tsx | 325 ++++++------------ 6 files changed, 392 insertions(+), 229 deletions(-) diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index 9b3d81a2..65cd5f4c 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -2185,3 +2185,67 @@ export async function multiTableSave( } } +/** + * 두 테이블 간의 엔티티 관계 자동 감지 + * GET /api/table-management/tables/entity-relations?leftTable=xxx&rightTable=yyy + * + * column_labels에서 정의된 엔티티/카테고리 타입 설정을 기반으로 + * 두 테이블 간의 외래키 관계를 자동으로 감지합니다. + */ +export async function getTableEntityRelations( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { leftTable, rightTable } = req.query; + + logger.info(`=== 테이블 엔티티 관계 조회 시작: ${leftTable} <-> ${rightTable} ===`); + + if (!leftTable || !rightTable) { + const response: ApiResponse = { + success: false, + message: "leftTable과 rightTable 파라미터가 필요합니다.", + error: { + code: "MISSING_PARAMETERS", + details: "leftTable과 rightTable 쿼리 파라미터가 필요합니다.", + }, + }; + res.status(400).json(response); + return; + } + + const tableManagementService = new TableManagementService(); + const relations = await tableManagementService.detectTableEntityRelations( + String(leftTable), + String(rightTable) + ); + + logger.info(`테이블 엔티티 관계 조회 완료: ${relations.length}개 발견`); + + const response: ApiResponse = { + success: true, + message: `${relations.length}개의 엔티티 관계를 발견했습니다.`, + data: { + leftTable: String(leftTable), + rightTable: String(rightTable), + relations, + }, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("테이블 엔티티 관계 조회 중 오류 발생:", error); + + const response: ApiResponse = { + success: false, + message: "테이블 엔티티 관계 조회 중 오류가 발생했습니다.", + error: { + code: "ENTITY_RELATIONS_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }; + + res.status(500).json(response); + } +} + diff --git a/backend-node/src/routes/tableManagementRoutes.ts b/backend-node/src/routes/tableManagementRoutes.ts index d0716d59..fa7832ee 100644 --- a/backend-node/src/routes/tableManagementRoutes.ts +++ b/backend-node/src/routes/tableManagementRoutes.ts @@ -25,6 +25,7 @@ import { toggleLogTable, getCategoryColumnsByMenu, // 🆕 메뉴별 카테고리 컬럼 조회 multiTableSave, // 🆕 범용 다중 테이블 저장 + getTableEntityRelations, // 🆕 두 테이블 간 엔티티 관계 조회 } from "../controllers/tableManagementController"; const router = express.Router(); @@ -38,6 +39,15 @@ router.use(authenticateToken); */ router.get("/tables", getTableList); +/** + * 두 테이블 간 엔티티 관계 조회 + * GET /api/table-management/tables/entity-relations?leftTable=xxx&rightTable=yyy + * + * column_labels에서 정의된 엔티티/카테고리 타입 설정을 기반으로 + * 두 테이블 간의 외래키 관계를 자동으로 감지합니다. + */ +router.get("/tables/entity-relations", getTableEntityRelations); + /** * 테이블 컬럼 정보 조회 * GET /api/table-management/tables/:tableName/columns diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 98db1eee..7df10fdb 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -1306,6 +1306,41 @@ export class TableManagementService { paramCount: number; } | null> { try { + // 🆕 배열 값 처리 (다중 값 검색 - 분할패널 엔티티 타입에서 "2,3" 형태 지원) + // 좌측에서 "2"를 선택해도, 우측에서 "2,3"을 가진 행이 표시되도록 함 + if (Array.isArray(value) && value.length > 0) { + // 배열의 각 값에 대해 OR 조건으로 검색 + // 우측 컬럼에 "2,3" 같은 다중 값이 있을 수 있으므로 + // 각 값을 LIKE 또는 = 조건으로 처리 + const conditions: string[] = []; + const values: any[] = []; + + value.forEach((v: any, idx: number) => { + const safeValue = String(v).trim(); + // 정확히 일치하거나, 콤마로 구분된 값 중 하나로 포함 + // 예: "2,3" 컬럼에서 "2"를 찾으려면: + // - 정확히 "2" + // - "2," 로 시작 + // - ",2" 로 끝남 + // - ",2," 중간에 포함 + const paramBase = paramIndex + (idx * 4); + conditions.push(`( + ${columnName}::text = $${paramBase} OR + ${columnName}::text LIKE $${paramBase + 1} OR + ${columnName}::text LIKE $${paramBase + 2} OR + ${columnName}::text LIKE $${paramBase + 3} + )`); + values.push(safeValue, `${safeValue},%`, `%,${safeValue}`, `%,${safeValue},%`); + }); + + logger.info(`🔍 다중 값 배열 검색: ${columnName} IN [${value.join(", ")}]`); + return { + whereClause: `(${conditions.join(" OR ")})`, + values, + paramCount: values.length, + }; + } + // 🔧 파이프로 구분된 문자열 처리 (다중선택 또는 날짜 범위) if (typeof value === "string" && value.includes("|")) { const columnInfo = await this.getColumnWebTypeInfo( @@ -4630,4 +4665,101 @@ export class TableManagementService { return false; } } + + /** + * 두 테이블 간의 엔티티 관계 자동 감지 + * column_labels에서 엔티티 타입 설정을 기반으로 테이블 간 관계를 찾습니다. + * + * @param leftTable 좌측 테이블명 + * @param rightTable 우측 테이블명 + * @returns 감지된 엔티티 관계 배열 + */ + async detectTableEntityRelations( + leftTable: string, + rightTable: string + ): Promise> { + try { + logger.info(`두 테이블 간 엔티티 관계 감지 시작: ${leftTable} <-> ${rightTable}`); + + const relations: Array<{ + leftColumn: string; + rightColumn: string; + direction: "left_to_right" | "right_to_left"; + inputType: string; + displayColumn?: string; + }> = []; + + // 1. 우측 테이블에서 좌측 테이블을 참조하는 엔티티 컬럼 찾기 + // 예: right_table의 customer_id -> left_table(customer_mng)의 customer_code + const rightToLeftRels = await query<{ + column_name: string; + reference_column: string; + input_type: string; + display_column: string | null; + }>( + `SELECT column_name, reference_column, input_type, display_column + FROM column_labels + WHERE table_name = $1 + AND input_type IN ('entity', 'category') + AND reference_table = $2 + AND reference_column IS NOT NULL + AND reference_column != ''`, + [rightTable, leftTable] + ); + + for (const rel of rightToLeftRels) { + relations.push({ + leftColumn: rel.reference_column, + rightColumn: rel.column_name, + direction: "right_to_left", + inputType: rel.input_type, + displayColumn: rel.display_column || undefined, + }); + } + + // 2. 좌측 테이블에서 우측 테이블을 참조하는 엔티티 컬럼 찾기 + // 예: left_table의 item_id -> right_table(item_info)의 item_number + const leftToRightRels = await query<{ + column_name: string; + reference_column: string; + input_type: string; + display_column: string | null; + }>( + `SELECT column_name, reference_column, input_type, display_column + FROM column_labels + WHERE table_name = $1 + AND input_type IN ('entity', 'category') + AND reference_table = $2 + AND reference_column IS NOT NULL + AND reference_column != ''`, + [leftTable, rightTable] + ); + + for (const rel of leftToRightRels) { + relations.push({ + leftColumn: rel.column_name, + rightColumn: rel.reference_column, + direction: "left_to_right", + inputType: rel.input_type, + displayColumn: rel.display_column || undefined, + }); + } + + logger.info(`엔티티 관계 감지 완료: ${relations.length}개 발견`); + relations.forEach((rel, idx) => { + logger.info(` ${idx + 1}. ${leftTable}.${rel.leftColumn} <-> ${rightTable}.${rel.rightColumn} (${rel.direction})`); + }); + + return relations; + } catch (error) { + logger.error(`엔티티 관계 감지 실패: ${leftTable} <-> ${rightTable}`, error); + return []; + } + } } diff --git a/frontend/lib/api/tableManagement.ts b/frontend/lib/api/tableManagement.ts index d03d83bf..5953fd82 100644 --- a/frontend/lib/api/tableManagement.ts +++ b/frontend/lib/api/tableManagement.ts @@ -328,6 +328,40 @@ class TableManagementApi { }; } } + + /** + * 두 테이블 간의 엔티티 관계 자동 감지 + * column_labels에서 정의된 엔티티/카테고리 타입 설정을 기반으로 + * 두 테이블 간의 외래키 관계를 자동으로 감지합니다. + */ + async getTableEntityRelations( + leftTable: string, + rightTable: string + ): Promise; + }>> { + try { + const response = await apiClient.get( + `${this.basePath}/tables/entity-relations?leftTable=${encodeURIComponent(leftTable)}&rightTable=${encodeURIComponent(rightTable)}` + ); + return response.data; + } catch (error: any) { + console.error(`❌ 테이블 엔티티 관계 조회 실패: ${leftTable} <-> ${rightTable}`, error); + return { + success: false, + message: error.response?.data?.message || error.message || "테이블 엔티티 관계를 조회할 수 없습니다.", + errorCode: error.response?.data?.errorCode, + }; + } + } } // 싱글톤 인스턴스 생성 diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx index afc5c13e..e674e1a9 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx @@ -830,7 +830,8 @@ export const SplitPanelLayoutComponent: React.FC size: 1, }); - const detail = result.items && result.items.length > 0 ? result.items[0] : null; + // result.data가 EntityJoinResponse의 실제 배열 필드 + const detail = result.data && result.data.length > 0 ? result.data[0] : null; setRightData(detail); } else if (relationshipType === "join") { // 조인 모드: 다른 테이블의 관련 데이터 (여러 개) @@ -899,16 +900,54 @@ export const SplitPanelLayoutComponent: React.FC return; } - // 🆕 복합키 지원 - if (keys && keys.length > 0 && leftTable) { + // 🆕 엔티티 관계 자동 감지 로직 개선 + // 1. 설정된 keys가 있으면 사용 + // 2. 없으면 테이블 타입관리에서 정의된 엔티티 관계를 자동으로 조회 + let effectiveKeys = keys || []; + + if (effectiveKeys.length === 0 && leftTable && rightTableName) { + // 엔티티 관계 자동 감지 + console.log("🔍 [분할패널] 엔티티 관계 자동 감지 시작:", leftTable, "->", rightTableName); + const { tableManagementApi } = await import("@/lib/api/tableManagement"); + const relResponse = await tableManagementApi.getTableEntityRelations(leftTable, rightTableName); + + if (relResponse.success && relResponse.data?.relations && relResponse.data.relations.length > 0) { + effectiveKeys = relResponse.data.relations.map((rel) => ({ + leftColumn: rel.leftColumn, + rightColumn: rel.rightColumn, + })); + console.log("✅ [분할패널] 자동 감지된 관계:", effectiveKeys); + } + } + + if (effectiveKeys.length > 0 && leftTable) { // 복합키: 여러 조건으로 필터링 const { entityJoinApi } = await import("@/lib/api/entityJoin"); - // 복합키 조건 생성 + // 복합키 조건 생성 (다중 값 지원) + // 🆕 항상 배열로 전달하여 백엔드에서 다중 값 컬럼 검색을 지원하도록 함 + // 예: 좌측에서 "2"를 선택해도, 우측에서 "2,3"을 가진 행이 표시되도록 const searchConditions: Record = {}; - keys.forEach((key) => { + effectiveKeys.forEach((key) => { if (key.leftColumn && key.rightColumn && leftItem[key.leftColumn] !== undefined) { - searchConditions[key.rightColumn] = leftItem[key.leftColumn]; + const leftValue = leftItem[key.leftColumn]; + // 다중 값 지원: 모든 값을 배열로 변환하여 다중 값 컬럼 검색 활성화 + if (typeof leftValue === "string") { + if (leftValue.includes(",")) { + // "2,3" 형태면 분리해서 배열로 + const values = leftValue.split(",").map((v: string) => v.trim()).filter((v: string) => v); + searchConditions[key.rightColumn] = values; + console.log("🔗 [분할패널] 다중 값 검색 (분리):", key.rightColumn, "=", values); + } else { + // 단일 값도 배열로 변환 (우측에 "2,3" 같은 다중 값이 있을 수 있으므로) + searchConditions[key.rightColumn] = [leftValue.trim()]; + console.log("🔗 [분할패널] 다중 값 검색 (단일):", key.rightColumn, "=", [leftValue.trim()]); + } + } else { + // 숫자나 다른 타입은 배열로 감싸기 + searchConditions[key.rightColumn] = [leftValue]; + console.log("🔗 [분할패널] 다중 값 검색 (기타):", key.rightColumn, "=", [leftValue]); + } } }); @@ -947,7 +986,7 @@ export const SplitPanelLayoutComponent: React.FC setRightData(filteredData); } else { - // 단일키 (하위 호환성) + // 단일키 (하위 호환성) 또는 관계를 찾지 못한 경우 const leftColumn = componentConfig.rightPanel?.relation?.leftColumn; const rightColumn = componentConfig.rightPanel?.relation?.foreignKey; @@ -965,6 +1004,9 @@ export const SplitPanelLayoutComponent: React.FC componentConfig.rightPanel?.deduplication, // 🆕 중복 제거 설정 전달 ); setRightData(joinedData || []); // 모든 관련 레코드 (배열) + } else { + console.warn("⚠️ [분할패널] 테이블 관계를 찾을 수 없습니다:", leftTable, "->", rightTableName); + setRightData([]); } } } diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx index 5ca50ffb..832107c4 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx @@ -429,6 +429,71 @@ export const SplitPanelLayoutConfigPanel: React.FC + >([]); + const [isDetectingRelations, setIsDetectingRelations] = useState(false); + + useEffect(() => { + const detectRelations = async () => { + const leftTable = config.leftPanel?.tableName || screenTableName; + const rightTable = config.rightPanel?.tableName; + + // 조인 모드이고 양쪽 테이블이 모두 있을 때만 감지 + if (relationshipType !== "join" || !leftTable || !rightTable) { + setAutoDetectedRelations([]); + return; + } + + setIsDetectingRelations(true); + try { + const { tableManagementApi } = await import("@/lib/api/tableManagement"); + const response = await tableManagementApi.getTableEntityRelations(leftTable, rightTable); + + if (response.success && response.data?.relations) { + console.log("🔍 엔티티 관계 자동 감지:", response.data.relations); + setAutoDetectedRelations(response.data.relations); + + // 감지된 관계가 있고, 현재 설정된 키가 없으면 자동으로 첫 번째 관계를 설정 + const currentKeys = config.rightPanel?.relation?.keys || []; + if (response.data.relations.length > 0 && currentKeys.length === 0) { + // 첫 번째 관계만 자동 설정 (사용자가 추가로 설정 가능) + const firstRel = response.data.relations[0]; + console.log("✅ 첫 번째 엔티티 관계 자동 설정:", firstRel); + updateRightPanel({ + relation: { + ...config.rightPanel?.relation, + type: "join", + useMultipleKeys: true, + keys: [ + { + leftColumn: firstRel.leftColumn, + rightColumn: firstRel.rightColumn, + }, + ], + }, + }); + } + } + } catch (error) { + console.error("❌ 엔티티 관계 감지 실패:", error); + setAutoDetectedRelations([]); + } finally { + setIsDetectingRelations(false); + } + }; + + detectRelations(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [config.leftPanel?.tableName, config.rightPanel?.tableName, screenTableName, relationshipType]); + console.log("🔧 SplitPanelLayoutConfigPanel 렌더링"); console.log(" - config:", config); console.log(" - tables:", tables); @@ -1633,234 +1698,50 @@ export const SplitPanelLayoutConfigPanel: React.FC )} - {/* 컬럼 매핑 - 조인 모드에서만 표시 */} + {/* 엔티티 관계 자동 감지 (읽기 전용) - 조인 모드에서만 표시 */} {relationshipType !== "detail" && ( -
-
-
- -

좌측 테이블의 컬럼을 우측 테이블의 컬럼과 연결합니다

-
- +
+
+ +

테이블 타입관리에서 정의된 엔티티 관계입니다

-

복합키: 여러 컬럼으로 조인 (예: item_code + lot_number)

- - {/* 복합키가 설정된 경우 */} - {(config.rightPanel?.relation?.keys || []).length > 0 ? ( - <> - {(config.rightPanel?.relation?.keys || []).map((key, index) => ( -
-
- 조인 키 {index + 1} - -
-
-
- - -
-
- - -
-
+ {isDetectingRelations ? ( +
+
+ 관계 감지 중... +
+ ) : autoDetectedRelations.length > 0 ? ( +
+ {autoDetectedRelations.map((rel, index) => ( +
+ + {leftTableName}.{rel.leftColumn} + + + + {rightTableName}.{rel.rightColumn} + + + {rel.inputType === "entity" ? "엔티티" : "카테고리"} +
))} - +

+ 테이블 타입관리에서 엔티티/카테고리 설정을 변경하면 자동으로 적용됩니다 +

+
+ ) : config.rightPanel?.tableName ? ( +
+

감지된 엔티티 관계가 없습니다

+

+ 테이블 타입관리에서 엔티티 타입과 참조 테이블을 설정하세요 +

+
) : ( - /* 단일키 (하위 호환성) */ - <> -
- - - - - - - - - 컬럼을 찾을 수 없습니다. - - {leftTableColumns.map((column) => ( - { - updateRightPanel({ - relation: { ...config.rightPanel?.relation, leftColumn: value }, - }); - setLeftColumnOpen(false); - }} - > - - {column.columnName} - ({column.columnLabel || ""}) - - ))} - - - - -
- -
- -
- -
- - - - - - - - - 컬럼을 찾을 수 없습니다. - - {rightTableColumns.map((column) => ( - { - updateRightPanel({ - relation: { ...config.rightPanel?.relation, foreignKey: value }, - }); - setRightColumnOpen(false); - }} - > - - {column.columnName} - ({column.columnLabel || ""}) - - ))} - - - - -
- +
+

우측 테이블을 선택하면 관계를 자동 감지합니다

+
)}
)} -- 2.43.0