From 8894216ee86cb91e20807227925eb089f8819845 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Tue, 10 Feb 2026 12:07:25 +0900 Subject: [PATCH] feat: Improve entity join handling with enhanced column validation and support for complex keys - Updated the entityJoinService to include type casting for source and reference columns, ensuring compatibility during joins. - Implemented validation for reference columns in the TableManagementService, allowing automatic fallback to 'id' if the specified reference column does not exist. - Enhanced logging for join configurations to provide better insights during the join setup process. - Transitioned the SplitPanelLayoutComponent to utilize the entityJoinApi for handling single key to composite key transformations, improving data retrieval efficiency. - Added support for displaying null or empty values as "-" in the SplitPanelLayout, enhancing user experience. --- .../src/services/entityJoinService.ts | 52 +++++++++-- .../src/services/tableManagementService.ts | 66 +++++++++----- .../SplitPanelLayoutComponent.tsx | 90 +++++++++++++------ 3 files changed, 149 insertions(+), 59 deletions(-) diff --git a/backend-node/src/services/entityJoinService.ts b/backend-node/src/services/entityJoinService.ts index 13f757fd..059dad4a 100644 --- a/backend-node/src/services/entityJoinService.ts +++ b/backend-node/src/services/entityJoinService.ts @@ -457,17 +457,18 @@ export class EntityJoinService { // table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링) if (config.referenceTable === "table_column_category_values") { - return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`; + return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`; } // user_info는 전역 테이블이므로 company_code 조건 없이 조인 if (config.referenceTable === "user_info") { - return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn}`; + return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT`; } // 일반 테이블: company_code가 있으면 같은 회사 데이터만 조인 (멀티테넌시) // supplier_mng, customer_mng, item_info 등 회사별 데이터 테이블 - return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.company_code = main.company_code`; + // ::TEXT 캐스팅으로 varchar/integer 등 타입 불일치 방지 + return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT AND ${alias}.company_code = main.company_code`; }) .join("\n"); @@ -580,6 +581,7 @@ export class EntityJoinService { logger.info("🔍 조인 설정 검증 상세:", { sourceColumn: config.sourceColumn, referenceTable: config.referenceTable, + referenceColumn: config.referenceColumn, displayColumns: config.displayColumns, displayColumn: config.displayColumn, aliasColumn: config.aliasColumn, @@ -598,7 +600,45 @@ export class EntityJoinService { return false; } - // 참조 컬럼 존재 확인 (displayColumns[0] 사용) + // 참조 컬럼(JOIN 키) 존재 확인 - 참조 테이블에 reference_column이 실제로 있는지 검증 + if (config.referenceColumn) { + const refColExists = await query<{ exists: number }>( + `SELECT 1 as exists FROM information_schema.columns + WHERE table_name = $1 + AND column_name = $2 + LIMIT 1`, + [config.referenceTable, config.referenceColumn] + ); + + if (refColExists.length === 0) { + // reference_column이 없으면 'id' 컬럼으로 자동 대체 시도 + const idColExists = await query<{ exists: number }>( + `SELECT 1 as exists FROM information_schema.columns + WHERE table_name = $1 + AND column_name = 'id' + LIMIT 1`, + [config.referenceTable] + ); + + if (idColExists.length > 0) { + logger.warn( + `⚠️ 참조 컬럼 ${config.referenceTable}.${config.referenceColumn}이 존재하지 않음 → 'id'로 자동 대체` + ); + config.referenceColumn = "id"; + } else { + logger.warn( + `❌ 참조 컬럼 ${config.referenceTable}.${config.referenceColumn}이 존재하지 않고 'id' 컬럼도 없음 → 스킵` + ); + return false; + } + } else { + logger.info( + `✅ 참조 컬럼 확인 완료: ${config.referenceTable}.${config.referenceColumn}` + ); + } + } + + // 표시 컬럼 존재 확인 (displayColumns[0] 사용) const displayColumn = config.displayColumns?.[0] || config.displayColumn; logger.info( `🔍 표시 컬럼 확인: ${displayColumn} (from displayColumns: ${config.displayColumns}, displayColumn: ${config.displayColumn})` @@ -686,10 +726,10 @@ export class EntityJoinService { // table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링만) if (config.referenceTable === "table_column_category_values") { // 멀티테넌시: 회사 데이터만 사용 (공통 데이터 제외) - return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`; + return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`; } - return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn}`; + return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT`; }) .join("\n"); diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 27f713fc..6e0f3944 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -2979,31 +2979,49 @@ export class TableManagementService { continue; // 기본 Entity 조인과 중복되면 추가하지 않음 } - // 추가 조인 컬럼 설정 생성 - const additionalJoinConfig: EntityJoinConfig = { - sourceTable: tableName, - sourceColumn: sourceColumn, // 실제 소스 컬럼 (partner_id) - referenceTable: - (additionalColumn as any).referenceTable || - baseJoinConfig.referenceTable, // 참조 테이블 (customer_mng) - referenceColumn: baseJoinConfig.referenceColumn, // 참조 키 (customer_code) - displayColumns: [actualColumnName], // 표시할 컬럼들 (customer_name) - displayColumn: actualColumnName, // 하위 호환성 - aliasColumn: correctedJoinAlias, // 수정된 별칭 (partner_id_customer_name) - separator: " - ", // 기본 구분자 - }; - - joinConfigs.push(additionalJoinConfig); - logger.info( - `✅ 추가 조인 컬럼 설정 추가: ${additionalJoinConfig.aliasColumn} -> ${actualColumnName}` + // 🆕 같은 sourceColumn + referenceTable 조합의 기존 config가 있으면 displayColumns에 병합 + const existingConfig = joinConfigs.find( + (config) => + config.sourceColumn === sourceColumn && + config.referenceTable === ((additionalColumn as any).referenceTable || baseJoinConfig.referenceTable) ); - logger.info(`🔍 추가된 조인 설정 상세:`, { - sourceTable: additionalJoinConfig.sourceTable, - sourceColumn: additionalJoinConfig.sourceColumn, - referenceTable: additionalJoinConfig.referenceTable, - displayColumns: additionalJoinConfig.displayColumns, - aliasColumn: additionalJoinConfig.aliasColumn, - }); + + if (existingConfig) { + // 기존 config에 display column 추가 (중복 방지) + if (!existingConfig.displayColumns?.includes(actualColumnName)) { + existingConfig.displayColumns = existingConfig.displayColumns || []; + existingConfig.displayColumns.push(actualColumnName); + logger.info( + `🔄 기존 조인 설정에 컬럼 병합: ${existingConfig.aliasColumn} ← ${actualColumnName} (총 ${existingConfig.displayColumns.length}개)` + ); + } + } else { + // 새 조인 설정 생성 + const additionalJoinConfig: EntityJoinConfig = { + sourceTable: tableName, + sourceColumn: sourceColumn, // 실제 소스 컬럼 (partner_id) + referenceTable: + (additionalColumn as any).referenceTable || + baseJoinConfig.referenceTable, // 참조 테이블 (customer_mng) + referenceColumn: baseJoinConfig.referenceColumn, // 참조 키 (customer_code) + displayColumns: [actualColumnName], // 표시할 컬럼들 (customer_name) + displayColumn: actualColumnName, // 하위 호환성 + aliasColumn: correctedJoinAlias, // 수정된 별칭 (partner_id_customer_name) + separator: " - ", // 기본 구분자 + }; + + joinConfigs.push(additionalJoinConfig); + logger.info( + `✅ 추가 조인 컬럼 설정 추가: ${additionalJoinConfig.aliasColumn} -> ${actualColumnName}` + ); + logger.info(`🔍 추가된 조인 설정 상세:`, { + sourceTable: additionalJoinConfig.sourceTable, + sourceColumn: additionalJoinConfig.sourceColumn, + referenceTable: additionalJoinConfig.referenceTable, + displayColumns: additionalJoinConfig.displayColumns, + aliasColumn: additionalJoinConfig.aliasColumn, + }); + } } } } diff --git a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx index 74b4add0..9328df8c 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx @@ -1250,24 +1250,59 @@ export const SplitPanelLayoutComponent: React.FC setRightData(filteredData); } else { - // 단일키 (하위 호환성) + // 단일키 (하위 호환성) → entityJoinApi 사용으로 전환 (entity 조인 컬럼 지원) const leftColumn = componentConfig.rightPanel?.relation?.leftColumn; const rightColumn = componentConfig.rightPanel?.relation?.foreignKey; if (leftColumn && rightColumn && leftTable) { const leftValue = leftItem[leftColumn]; - const joinedData = await dataApi.getJoinedData( - leftTable, + const { entityJoinApi } = await import("@/lib/api/entityJoin"); + + // 단일키를 복합키 형식으로 변환 + const searchConditions: Record = {}; + searchConditions[rightColumn] = leftValue; + + // Entity 조인 컬럼 추출 + const rightJoinColumnsLegacy = extractAdditionalJoinColumns( + componentConfig.rightPanel?.columns, rightTableName, - leftColumn, - rightColumn, - leftValue, - componentConfig.rightPanel?.dataFilter, // 🆕 데이터 필터 전달 - true, // 🆕 Entity 조인 활성화 - componentConfig.rightPanel?.columns, // 🆕 표시 컬럼 전달 (item_info.item_name 등) - componentConfig.rightPanel?.deduplication, // 🆕 중복 제거 설정 전달 ); - setRightData(joinedData || []); // 모든 관련 레코드 (배열) + if (rightJoinColumnsLegacy) { + console.log("🔗 [분할패널] 단일키 모드 additionalJoinColumns:", rightJoinColumnsLegacy); + } + + const result = await entityJoinApi.getTableDataWithJoins(rightTableName, { + search: searchConditions, + enableEntityJoin: true, + size: 1000, + companyCodeOverride: companyCode, + additionalJoinColumns: rightJoinColumnsLegacy, + }); + + let filteredDataLegacy = result.data || []; + + // 데이터 필터 적용 + const dataFilterLegacy = componentConfig.rightPanel?.dataFilter; + if (dataFilterLegacy?.enabled && dataFilterLegacy.conditions?.length > 0) { + filteredDataLegacy = filteredDataLegacy.filter((item: any) => { + return dataFilterLegacy.conditions.every((cond: any) => { + const value = item[cond.column]; + const condValue = cond.value; + switch (cond.operator) { + case "equals": + return value === condValue; + case "notEquals": + return value !== condValue; + case "contains": + return String(value).includes(String(condValue)); + default: + return true; + } + }); + }); + } + + setRightData(filteredDataLegacy || []); } } } @@ -3802,23 +3837,20 @@ export const SplitPanelLayoutComponent: React.FC if (rightColumns && rightColumns.length > 0) { // 설정된 컬럼만 표시 (엔티티 조인 컬럼 처리) + // 설정된 컬럼은 null/empty여도 항상 표시 (사용자가 명시적으로 설정한 컬럼이므로) const summaryCount = componentConfig.rightPanel?.summaryColumnCount ?? 3; firstValues = rightColumns .slice(0, summaryCount) .map((col) => { - // 🆕 엔티티 조인 컬럼 처리 (getEntityJoinValue 사용) const value = getEntityJoinValue(item, col.name); return [col.name, value, col.label] as [string, any, string]; - }) - .filter(([_, value]) => value !== null && value !== undefined && value !== ""); + }); allValues = rightColumns .map((col) => { - // 🆕 엔티티 조인 컬럼 처리 (getEntityJoinValue 사용) const value = getEntityJoinValue(item, col.name); return [col.name, value, col.label] as [string, any, string]; - }) - .filter(([_, value]) => value !== null && value !== undefined && value !== ""); + }); } else { // 설정 없으면 모든 컬럼 표시 (기존 로직) const summaryCount = componentConfig.rightPanel?.summaryColumnCount ?? 3; @@ -3851,8 +3883,10 @@ export const SplitPanelLayoutComponent: React.FC const format = colConfig?.format; const boldValue = colConfig?.bold ?? false; - // 🆕 포맷 적용 (날짜/숫자/카테고리) - const displayValue = formatCellValue(key, value, rightCategoryMappings, format); + // 🆕 포맷 적용 (날짜/숫자/카테고리) - null/empty는 "-"로 표시 + const displayValue = (value === null || value === undefined || value === "") + ? "-" + : formatCellValue(key, value, rightCategoryMappings, format); const showLabel = componentConfig.rightPanel?.summaryShowLabel ?? true; @@ -3929,8 +3963,10 @@ export const SplitPanelLayoutComponent: React.FC const colConfig = rightColumns?.find((c) => c.name === key); const format = colConfig?.format; - // 🆕 포맷 적용 (날짜/숫자/카테고리) - const displayValue = formatCellValue(key, value, rightCategoryMappings, format); + // 🆕 포맷 적용 (날짜/숫자/카테고리) - null/empty는 "-"로 표시 + const displayValue = (value === null || value === undefined || value === "") + ? "-" + : formatCellValue(key, value, rightCategoryMappings, format); return ( @@ -3993,13 +4029,7 @@ export const SplitPanelLayoutComponent: React.FC return [col.name, value, col.label] as [string, any, string]; }) - .filter(([key, value]) => { - const filtered = value === null || value === undefined || value === ""; - if (filtered) { - console.log(` ❌ 필터링됨: "${key}" (값: ${value})`); - } - return !filtered; - }); +; // 설정된 컬럼은 null/empty여도 항상 표시 console.log(" ✅ 최종 표시할 항목:", displayEntries.length, "개"); } else { @@ -4017,7 +4047,9 @@ export const SplitPanelLayoutComponent: React.FC
{label || getColumnLabel(key)}
-
{String(value)}
+
+ {(value === null || value === undefined || value === "") ? - : String(value)} +
))}