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.
This commit is contained in:
DDD1542 2026-02-10 12:07:25 +09:00
parent 3c8c2ebcf4
commit 8894216ee8
3 changed files with 149 additions and 59 deletions

View File

@ -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");

View File

@ -2979,7 +2979,24 @@ export class TableManagementService {
continue; // 기본 Entity 조인과 중복되면 추가하지 않음
}
// 추가 조인 컬럼 설정 생성
// 🆕 같은 sourceColumn + referenceTable 조합의 기존 config가 있으면 displayColumns에 병합
const existingConfig = joinConfigs.find(
(config) =>
config.sourceColumn === sourceColumn &&
config.referenceTable === ((additionalColumn as any).referenceTable || baseJoinConfig.referenceTable)
);
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)
@ -3007,6 +3024,7 @@ export class TableManagementService {
}
}
}
}
// 최종 조인 설정 배열 로깅
logger.info(`🎯 최종 joinConfigs 배열 (${joinConfigs.length}개):`);

View File

@ -1250,24 +1250,59 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
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<string, any> = {};
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<SplitPanelLayoutComponentProps>
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<SplitPanelLayoutComponentProps>
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<SplitPanelLayoutComponentProps>
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 (
<tr key={key} className="hover:bg-muted">
@ -3993,13 +4029,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
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<SplitPanelLayoutComponentProps>
<div className="text-muted-foreground mb-1 text-xs font-semibold tracking-wide uppercase">
{label || getColumnLabel(key)}
</div>
<div className="text-sm">{String(value)}</div>
<div className="text-sm">
{(value === null || value === undefined || value === "") ? <span className="text-muted-foreground">-</span> : String(value)}
</div>
</div>
))}
</div>