ERP-node/backend-node/src/services/entityJoinService.ts

829 lines
31 KiB
TypeScript
Raw Normal View History

import { query, queryOne } from "../database/db";
2025-09-16 15:13:00 +09:00
import { logger } from "../utils/logger";
import {
EntityJoinConfig,
BatchLookupRequest,
BatchLookupResponse,
} from "../types/tableManagement";
2025-09-16 16:53:03 +09:00
import { referenceCacheService } from "./referenceCacheService";
2025-09-16 15:13:00 +09:00
/**
* Entity
* ID값을
*/
export class EntityJoinService {
/**
* Entity
* @param tableName
* @param screenEntityConfigs ()
* @param companyCode ( , )
2025-09-16 15:13:00 +09:00
*/
async detectEntityJoins(
tableName: string,
screenEntityConfigs?: Record<string, any>,
companyCode?: string
): Promise<EntityJoinConfig[]> {
2025-09-16 15:13:00 +09:00
try {
logger.info(`Entity 컬럼 감지 시작: ${tableName} (companyCode: ${companyCode || 'all'})`);
2025-09-16 15:13:00 +09:00
// table_type_columns에서 entity 및 category 타입인 컬럼들 조회
// 회사코드가 있으면 해당 회사 + '*' 만 조회, 회사별 우선
const entityColumns = await query<{
column_name: string;
input_type: string;
reference_table: string;
reference_column: string;
display_column: string | null;
}>(
`SELECT DISTINCT ON (column_name)
column_name, input_type, reference_table, reference_column, display_column
FROM table_type_columns
WHERE table_name = $1
AND input_type IN ('entity', 'category')
AND reference_table IS NOT NULL
AND reference_table != ''
${companyCode ? `AND company_code IN ($2, '*')` : ''}
ORDER BY column_name,
CASE WHEN company_code = '*' THEN 1 ELSE 0 END`,
companyCode ? [tableName, companyCode] : [tableName]
);
2025-09-16 15:13:00 +09:00
logger.info(`🔍 Entity 컬럼 조회 결과: ${entityColumns.length}개 발견`);
entityColumns.forEach((col, index) => {
logger.info(
` ${index + 1}. ${col.column_name} -> ${col.reference_table}.${col.reference_column} (display: ${col.display_column})`
);
});
2025-09-16 15:13:00 +09:00
const joinConfigs: EntityJoinConfig[] = [];
// 🎯 writer 컬럼 자동 감지 및 조인 설정 추가
const tableColumns = await query<{ column_name: string }>(
`SELECT column_name
FROM information_schema.columns
WHERE table_name = $1
AND table_schema = 'public'
AND column_name = 'writer'`,
[tableName]
);
if (tableColumns.length > 0) {
const writerJoinConfig: EntityJoinConfig = {
sourceTable: tableName,
sourceColumn: "writer",
referenceTable: "user_info",
referenceColumn: "user_id",
displayColumns: ["user_name"],
displayColumn: "user_name",
aliasColumn: "writer_name",
separator: " - ",
};
if (await this.validateJoinConfig(writerJoinConfig)) {
joinConfigs.push(writerJoinConfig);
}
}
2025-09-16 15:13:00 +09:00
for (const column of entityColumns) {
// 카테고리 타입인 경우 자동으로 category_values 테이블 참조 설정
let referenceTable = column.reference_table;
let referenceColumn = column.reference_column;
let displayColumn = column.display_column;
if (column.input_type === "category") {
// 카테고리 타입: reference 정보가 비어있어도 자동 설정
referenceTable = referenceTable || "category_values";
referenceColumn = referenceColumn || "value_code";
displayColumn = displayColumn || "value_label";
logger.info(`🏷️ 카테고리 타입 자동 설정: ${column.column_name}`, {
referenceTable,
referenceColumn,
displayColumn,
});
}
logger.info(`🔍 Entity 컬럼 상세 정보:`, {
column_name: column.column_name,
input_type: column.input_type,
reference_table: referenceTable,
reference_column: referenceColumn,
display_column: displayColumn,
});
if (!column.column_name || !referenceTable || !referenceColumn) {
logger.warn(`⚠️ 필수 정보 누락으로 스킵: ${column.column_name}`);
2025-09-16 15:13:00 +09:00
continue;
}
// 화면별 엔티티 설정이 있으면 우선 사용, 없으면 기본값 사용
const screenConfig = screenEntityConfigs?.[column.column_name];
let displayColumns: string[] = [];
let separator = " - ";
logger.info(`🔍 조건 확인 - 컬럼: ${column.column_name}`, {
hasScreenConfig: !!screenConfig,
hasDisplayColumns: screenConfig?.displayColumns,
displayColumn: column.display_column,
});
if (screenConfig && screenConfig.displayColumns) {
2025-09-23 17:43:24 +09:00
// 화면에서 설정된 표시 컬럼들 사용 (기본 테이블 + 조인 테이블 조합 지원)
displayColumns = screenConfig.displayColumns;
separator = screenConfig.separator || " - ";
2025-09-23 17:43:24 +09:00
console.log(`🎯 화면별 엔티티 설정 적용: ${column.column_name}`, {
displayColumns,
separator,
screenConfig,
});
} else if (displayColumn && displayColumn !== "none") {
2025-09-24 10:33:54 +09:00
// 기존 설정된 단일 표시 컬럼 사용 (none이 아닌 경우만)
displayColumns = [displayColumn];
logger.info(
`🔧 기존 display_column 사용: ${column.column_name}${displayColumn}`
);
} else {
// display_column이 "none"이거나 없는 경우 참조 테이블의 표시용 컬럼 자동 감지
logger.info(`🔍 ${referenceTable}의 표시 컬럼 자동 감지 중...`);
// 참조 테이블의 모든 컬럼 이름 가져오기
const tableColumnsResult = await query<{ column_name: string }>(
`SELECT column_name
FROM information_schema.columns
WHERE table_name = $1
AND table_schema = 'public'
ORDER BY ordinal_position`,
[referenceTable]
);
if (tableColumnsResult.length > 0) {
const allColumns = tableColumnsResult.map((col) => col.column_name);
// 🆕 표시용 컬럼 자동 감지 (우선순위 순서)
// 1. *_name 컬럼 (item_name, customer_name 등)
// 2. name 컬럼
// 3. label 컬럼
// 4. title 컬럼
// 5. 참조 컬럼 (referenceColumn)
const nameColumn = allColumns.find(
(col) => col.endsWith("_name") && col !== "company_name"
);
const simpleNameColumn = allColumns.find((col) => col === "name");
const labelColumn = allColumns.find(
(col) => col === "label" || col.endsWith("_label")
);
const titleColumn = allColumns.find((col) => col === "title");
// 우선순위에 따라 표시 컬럼 선택
const displayColumn =
nameColumn ||
simpleNameColumn ||
labelColumn ||
titleColumn ||
referenceColumn;
displayColumns = [displayColumn];
logger.info(
`${referenceTable}의 표시 컬럼 자동 감지: ${displayColumn} (전체 ${allColumns.length}개 중)`
);
} else {
// 테이블 컬럼을 못 찾으면 기본값 사용
displayColumns = [referenceColumn];
logger.warn(
`⚠️ ${referenceTable}의 컬럼 조회 실패, 기본값 사용: ${referenceColumn}`
);
}
}
2025-09-16 15:13:00 +09:00
2025-12-17 17:41:29 +09:00
// 🎯 별칭 컬럼명 생성 - 사용자가 선택한 displayColumns 기반으로 동적 생성
// 단일 컬럼: manager + user_name → manager_user_name
// 여러 컬럼: 첫 번째 컬럼 기준 (나머지는 개별 alias로 처리됨)
const firstDisplayColumn = displayColumns[0] || "name";
const aliasColumn = `${column.column_name}_${firstDisplayColumn}`;
logger.info(`🔧 별칭 컬럼명 생성: ${column.column_name} + ${firstDisplayColumn}${aliasColumn}`);
2025-09-16 15:13:00 +09:00
const joinConfig: EntityJoinConfig = {
sourceTable: tableName,
sourceColumn: column.column_name,
referenceTable: referenceTable, // 카테고리의 경우 자동 설정된 값 사용
referenceColumn: referenceColumn, // 카테고리의 경우 자동 설정된 값 사용
displayColumns: displayColumns,
displayColumn: displayColumns[0], // 하위 호환성
2025-09-16 15:13:00 +09:00
aliasColumn: aliasColumn,
separator: separator,
2025-09-16 15:13:00 +09:00
};
logger.info(`🔧 기본 조인 설정 생성:`, {
sourceTable: joinConfig.sourceTable,
sourceColumn: joinConfig.sourceColumn,
referenceTable: joinConfig.referenceTable,
aliasColumn: joinConfig.aliasColumn,
displayColumns: joinConfig.displayColumns,
});
2025-09-16 15:13:00 +09:00
// 조인 설정 유효성 검증
logger.info(
`🔍 조인 설정 검증 중: ${joinConfig.sourceColumn} -> ${joinConfig.referenceTable}`
);
2025-09-16 15:13:00 +09:00
if (await this.validateJoinConfig(joinConfig)) {
joinConfigs.push(joinConfig);
logger.info(`✅ 조인 설정 추가됨: ${joinConfig.aliasColumn}`);
} else {
logger.warn(`❌ 조인 설정 검증 실패: ${joinConfig.sourceColumn}`);
2025-09-16 15:13:00 +09:00
}
}
logger.info(`🎯 Entity 조인 설정 생성 완료: ${joinConfigs.length}`);
joinConfigs.forEach((config, index) => {
logger.info(
` ${index + 1}. ${config.sourceColumn} -> ${config.referenceTable}.${config.referenceColumn} AS ${config.aliasColumn}`
);
});
2025-09-16 15:13:00 +09:00
return joinConfigs;
} catch (error) {
logger.error(`Entity 조인 감지 실패: ${tableName}`, error);
return [];
}
}
2025-11-20 11:58:43 +09:00
/**
* YYYY-MM-DD SQL
*/
private formatDateColumn(
tableAlias: string,
columnName: string,
dataType?: string
): string {
// date, timestamp 타입이면 TO_CHAR로 변환
if (
dataType &&
(dataType.includes("date") || dataType.includes("timestamp"))
) {
return `TO_CHAR(${tableAlias}.${columnName}, 'YYYY-MM-DD')`;
}
// 기본은 TEXT 캐스팅
return `${tableAlias}.${columnName}::TEXT`;
}
2025-09-16 15:13:00 +09:00
/**
* Entity SQL
*/
buildJoinQuery(
tableName: string,
joinConfigs: EntityJoinConfig[],
selectColumns: string[],
whereClause: string = "",
orderBy: string = "",
limit?: number,
2025-11-20 11:58:43 +09:00
offset?: number,
columnTypes?: Map<string, string>, // 컬럼명 → 데이터 타입 매핑
referenceTableColumns?: Map<string, string[]> // 🆕 참조 테이블별 전체 컬럼 목록
2025-09-17 11:15:34 +09:00
): { query: string; aliasMap: Map<string, string> } {
2025-09-16 15:13:00 +09:00
try {
2025-11-20 11:58:43 +09:00
// 기본 SELECT 컬럼들 (날짜는 YYYY-MM-DD 형식, 나머지는 TEXT 캐스팅)
// 🔧 "*"는 전체 조회하되, 날짜 타입 타임존 문제를 피하기 위해
// jsonb_build_object를 사용하여 명시적으로 변환
let baseColumns: string;
if (selectColumns.length === 1 && selectColumns[0] === "*") {
// main.* 사용 시 날짜 타입 필드만 TO_CHAR로 변환
// PostgreSQL의 날짜 → 타임스탬프 자동 변환으로 인한 타임존 문제 방지
baseColumns = `main.*`;
logger.info(
`⚠️ [buildJoinQuery] main.* 사용 - 날짜 타임존 변환 주의 필요`
);
} else {
baseColumns = selectColumns
.map((col) => {
const dataType = columnTypes?.get(col);
const formattedCol = this.formatDateColumn("main", col, dataType);
return `${formattedCol} AS ${col}`;
})
.join(", ");
}
2025-09-16 15:13:00 +09:00
// Entity 조인 컬럼들 (COALESCE로 NULL을 빈 문자열로 처리)
2025-09-17 11:15:34 +09:00
// 별칭 매핑 생성 (JOIN 절과 동일한 로직)
const aliasMap = new Map<string, string>();
const usedAliasesForColumns = new Set<string>();
// joinConfigs를 참조 테이블 + 소스 컬럼별로 중복 제거하여 별칭 생성
// (category_values는 같은 테이블이라도 sourceColumn마다 별도 JOIN 필요)
2025-09-17 11:15:34 +09:00
const uniqueReferenceTableConfigs = joinConfigs.reduce((acc, config) => {
if (
!acc.some(
(existingConfig) =>
existingConfig.referenceTable === config.referenceTable &&
existingConfig.sourceColumn === config.sourceColumn
2025-09-17 11:15:34 +09:00
)
) {
acc.push(config);
}
return acc;
}, [] as EntityJoinConfig[]);
logger.info(
`🔧 별칭 생성 시작: ${uniqueReferenceTableConfigs.length}개 고유 테이블+컬럼 조합`
2025-09-17 11:15:34 +09:00
);
uniqueReferenceTableConfigs.forEach((config) => {
let baseAlias = config.referenceTable.substring(0, 3);
let alias = baseAlias;
let counter = 1;
while (usedAliasesForColumns.has(alias)) {
alias = `${baseAlias}${counter}`;
counter++;
}
usedAliasesForColumns.add(alias);
// 같은 테이블이라도 sourceColumn이 다르면 별도 별칭 생성 (category_values 대응)
const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
aliasMap.set(aliasKey, alias);
logger.info(
`🔧 별칭 생성: ${config.referenceTable}.${config.sourceColumn}${alias}`
);
2025-09-17 11:15:34 +09:00
});
// 🔧 생성된 별칭 중복 방지를 위한 Set
const generatedAliases = new Set<string>();
const joinColumns = uniqueReferenceTableConfigs
.map((config) => {
const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
const alias = aliasMap.get(aliasKey);
const resultColumns: string[] = [];
// 🆕 참조 테이블의 전체 컬럼 목록이 있으면 모든 컬럼을 SELECT
const refTableCols = referenceTableColumns?.get(
`${config.referenceTable}:${config.sourceColumn}`
) || referenceTableColumns?.get(config.referenceTable);
if (refTableCols && refTableCols.length > 0) {
// 메타 컬럼은 제외 (메인 테이블과 중복되거나 불필요)
const skipColumns = new Set(["company_code", "created_date", "updated_date", "writer"]);
for (const col of refTableCols) {
if (skipColumns.has(col)) continue;
const colAlias = `${config.sourceColumn}_${col}`;
if (generatedAliases.has(colAlias)) continue;
2025-09-23 17:43:24 +09:00
resultColumns.push(
`COALESCE(${alias}."${col}"::TEXT, '') AS "${colAlias}"`
);
generatedAliases.add(colAlias);
}
// _label 필드도 추가 (기존 호환성)
const labelAlias = `${config.sourceColumn}_label`;
if (!generatedAliases.has(labelAlias)) {
// 표시용 컬럼 자동 감지: *_name > name > label > referenceColumn
const nameCol = refTableCols.find((c) => c.endsWith("_name") && c !== "company_name");
const displayCol = nameCol || refTableCols.find((c) => c === "name") || config.referenceColumn;
resultColumns.push(
`COALESCE(${alias}."${displayCol}"::TEXT, '') AS "${labelAlias}"`
);
generatedAliases.add(labelAlias);
2025-09-23 17:43:24 +09:00
}
} else {
// 🔄 기존 로직 (참조 테이블 컬럼 목록이 없는 경우 - fallback)
const displayColumns = config.displayColumns || [config.displayColumn];
if (displayColumns.length === 0 || !displayColumns[0]) {
resultColumns.push(
`COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.aliasColumn}`
);
} else if (displayColumns.length === 1) {
const col = displayColumns[0];
const isJoinTableColumn =
config.referenceTable && config.referenceTable !== tableName;
if (isJoinTableColumn) {
resultColumns.push(
`COALESCE(${alias}.${col}::TEXT, '') AS ${config.aliasColumn}`
);
const labelAlias = `${config.sourceColumn}_label`;
if (!generatedAliases.has(labelAlias)) {
resultColumns.push(
`COALESCE(${alias}.${col}::TEXT, '') AS ${labelAlias}`
);
generatedAliases.add(labelAlias);
}
} else {
resultColumns.push(
`COALESCE(main.${col}::TEXT, '') AS ${config.aliasColumn}`
);
}
} else {
displayColumns.forEach((col) => {
const isJoinTableColumn =
config.referenceTable && config.referenceTable !== tableName;
const individualAlias = `${config.sourceColumn}_${col}`;
if (generatedAliases.has(individualAlias)) return;
if (isJoinTableColumn) {
resultColumns.push(
`COALESCE(${alias}.${col}::TEXT, '') AS ${individualAlias}`
);
} else {
resultColumns.push(
`COALESCE(main.${col}::TEXT, '') AS ${individualAlias}`
);
}
generatedAliases.add(individualAlias);
});
2025-11-20 11:58:43 +09:00
}
}
return resultColumns.join(", ");
})
.filter(Boolean)
2025-09-16 15:13:00 +09:00
.join(", ");
// SELECT 절 구성
const selectClause = joinColumns
? `${baseColumns}, ${joinColumns}`
: baseColumns;
// FROM 절 (메인 테이블)
const fromClause = `FROM ${tableName} main`;
// LEFT JOIN 절들 (위에서 생성한 별칭 매핑 사용, 각 sourceColumn마다 별도 JOIN)
2025-12-03 18:28:43 +09:00
// 멀티테넌시: 모든 조인에 company_code 조건 추가 (다른 회사 데이터 혼합 방지)
2025-09-17 11:15:34 +09:00
const joinClauses = uniqueReferenceTableConfigs
.map((config) => {
const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
const alias = aliasMap.get(aliasKey);
// category_values는 특별한 조인 조건 필요 (회사별 필터링)
// is_active 필터 제거: 비활성화된 카테고리도 라벨로 표시되어야 함
if (config.referenceTable === "category_values") {
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`;
}
2025-12-03 18:28:43 +09:00
// user_info는 전역 테이블이므로 company_code 조건 없이 조인
if (config.referenceTable === "user_info") {
return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT`;
2025-12-03 18:28:43 +09:00
}
// 일반 테이블: company_code가 있으면 같은 회사 데이터만 조인 (멀티테넌시)
// supplier_mng, customer_mng, item_info 등 회사별 데이터 테이블
// ::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`;
2025-09-16 15:13:00 +09:00
})
.join("\n");
// WHERE 절
const whereSQL = whereClause ? `WHERE ${whereClause}` : "";
// ORDER BY 절
const orderSQL = orderBy ? `ORDER BY ${orderBy}` : "";
// LIMIT 및 OFFSET
let limitSQL = "";
if (limit !== undefined) {
limitSQL = `LIMIT ${limit}`;
if (offset !== undefined) {
limitSQL += ` OFFSET ${offset}`;
}
}
// 최종 쿼리 조합
const query = [
`SELECT ${selectClause}`,
fromClause,
joinClauses,
whereSQL,
orderSQL,
limitSQL,
]
.filter(Boolean)
.join("\n");
logger.info(`🔍 생성된 Entity 조인 쿼리:`, query);
2025-09-17 11:15:34 +09:00
return {
query: query,
aliasMap: aliasMap,
};
2025-09-16 15:13:00 +09:00
} catch (error) {
logger.error("Entity 조인 쿼리 생성 실패", error);
throw error;
}
}
2025-09-16 16:53:03 +09:00
/**
* ( )
*/
async determineJoinStrategy(
joinConfigs: EntityJoinConfig[]
): Promise<"full_join" | "cache_lookup" | "hybrid"> {
try {
const strategies = await Promise.all(
joinConfigs.map(async (config) => {
2025-09-23 17:43:24 +09:00
// 여러 컬럼을 조합하는 경우 캐시 전략 사용 불가
if (config.displayColumns && config.displayColumns.length > 1) {
console.log(
`🎯 여러 컬럼 조합으로 인해 조인 전략 사용: ${config.sourceColumn}`,
config.displayColumns
);
return "join";
}
// category_values는 특수 조인 조건이 필요하므로 캐시 불가
if (config.referenceTable === "category_values") {
logger.info(
`🎯 category_values는 캐시 전략 불가: ${config.sourceColumn}`
);
return "join";
}
2025-09-16 16:53:03 +09:00
// 참조 테이블의 캐시 가능성 확인
const displayCol =
config.displayColumn ||
config.displayColumns?.[0] ||
config.referenceColumn;
logger.info(
`🔍 캐시 확인용 표시 컬럼: ${config.referenceTable} - ${displayCol}`
);
2025-09-16 16:53:03 +09:00
const cachedData = await referenceCacheService.getCachedReference(
config.referenceTable,
config.referenceColumn,
displayCol
2025-09-16 16:53:03 +09:00
);
return cachedData ? "cache" : "join";
})
);
// 모두 캐시 가능한 경우
if (strategies.every((s) => s === "cache")) {
return "cache_lookup";
}
// 혼합인 경우
if (strategies.includes("cache") && strategies.includes("join")) {
return "hybrid";
}
// 기본은 조인
return "full_join";
} catch (error) {
logger.error("조인 전략 결정 실패", error);
return "full_join"; // 안전한 기본값
}
}
2025-09-16 15:13:00 +09:00
/**
*
*/
private async validateJoinConfig(config: EntityJoinConfig): Promise<boolean> {
try {
logger.info("🔍 조인 설정 검증 상세:", {
sourceColumn: config.sourceColumn,
referenceTable: config.referenceTable,
referenceColumn: config.referenceColumn,
displayColumns: config.displayColumns,
displayColumn: config.displayColumn,
aliasColumn: config.aliasColumn,
});
2025-09-16 15:13:00 +09:00
// 참조 테이블 존재 확인
const tableExists = await query<{ exists: number }>(
`SELECT 1 as exists FROM information_schema.tables
WHERE table_name = $1
LIMIT 1`,
[config.referenceTable]
);
2025-09-16 15:13:00 +09:00
if (tableExists.length === 0) {
2025-09-16 15:13:00 +09:00
logger.warn(`참조 테이블이 존재하지 않음: ${config.referenceTable}`);
return false;
}
// 참조 컬럼(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] 사용)
2025-09-24 10:33:54 +09:00
const displayColumn = config.displayColumns?.[0] || config.displayColumn;
logger.info(
`🔍 표시 컬럼 확인: ${displayColumn} (from displayColumns: ${config.displayColumns}, displayColumn: ${config.displayColumn})`
);
2025-09-16 15:13:00 +09:00
// 🚨 display_column이 항상 "none"이므로, 표시 컬럼이 없어도 조인 허용
if (displayColumn && displayColumn !== "none") {
const columnExists = await query<{ exists: number }>(
`SELECT 1 as exists FROM information_schema.columns
WHERE table_name = $1
AND column_name = $2
LIMIT 1`,
[config.referenceTable, displayColumn]
);
if (columnExists.length === 0) {
logger.warn(
`표시 컬럼이 존재하지 않음: ${config.referenceTable}.${displayColumn}`
);
return false;
}
logger.info(
`✅ 표시 컬럼 확인 완료: ${config.referenceTable}.${displayColumn}`
);
} else {
logger.info(
`🔧 표시 컬럼 검증 생략: display_column이 none이거나 설정되지 않음`
2025-09-16 15:13:00 +09:00
);
}
return true;
} catch (error) {
logger.error("조인 설정 검증 실패", error);
return false;
}
}
/**
* ()
*/
buildCountQuery(
tableName: string,
joinConfigs: EntityJoinConfig[],
whereClause: string = ""
): string {
try {
2025-09-17 11:15:34 +09:00
// 별칭 매핑 생성 (buildJoinQuery와 동일한 로직)
const aliasMap = new Map<string, string>();
const usedAliases = new Set<string>();
// joinConfigs를 참조 테이블 + 소스 컬럼별로 중복 제거하여 별칭 생성
2025-09-17 11:15:34 +09:00
const uniqueReferenceTableConfigs = joinConfigs.reduce((acc, config) => {
if (
!acc.some(
(existingConfig) =>
existingConfig.referenceTable === config.referenceTable &&
existingConfig.sourceColumn === config.sourceColumn
2025-09-17 11:15:34 +09:00
)
) {
acc.push(config);
}
return acc;
}, [] as EntityJoinConfig[]);
uniqueReferenceTableConfigs.forEach((config) => {
let baseAlias = config.referenceTable.substring(0, 3);
let alias = baseAlias;
let counter = 1;
while (usedAliases.has(alias)) {
alias = `${baseAlias}${counter}`;
counter++;
}
usedAliases.add(alias);
const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
aliasMap.set(aliasKey, alias);
2025-09-17 11:15:34 +09:00
});
2025-09-16 15:13:00 +09:00
// JOIN 절들 (COUNT에서는 SELECT 컬럼 불필요)
2025-09-17 11:15:34 +09:00
const joinClauses = uniqueReferenceTableConfigs
.map((config) => {
const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
const alias = aliasMap.get(aliasKey);
// category_values는 특별한 조인 조건 필요 (회사별 필터링만)
// is_active 필터 제거: 비활성화된 카테고리도 라벨로 표시되어야 함
if (config.referenceTable === "category_values") {
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`;
}
return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT`;
2025-09-16 15:13:00 +09:00
})
.join("\n");
// WHERE 절
const whereSQL = whereClause ? `WHERE ${whereClause}` : "";
// COUNT 쿼리 조합
const query = [
`SELECT COUNT(*) as total`,
`FROM ${tableName} main`,
joinClauses,
whereSQL,
]
.filter(Boolean)
.join("\n");
return query;
} catch (error) {
logger.error("COUNT 쿼리 생성 실패", error);
throw error;
}
}
/**
* (UI용)
*/
async getReferenceTableColumns(tableName: string, companyCode?: string): Promise<
2025-09-16 15:13:00 +09:00
Array<{
columnName: string;
displayName: string;
dataType: string;
inputType?: string;
2025-09-16 15:13:00 +09:00
}>
> {
try {
// 1. 테이블의 기본 컬럼 정보 조회 (모든 데이터 타입 포함)
const columns = await query<{
column_name: string;
data_type: string;
}>(
`SELECT
2025-09-16 15:13:00 +09:00
column_name,
data_type
FROM information_schema.columns
WHERE table_name = $1
AND table_schema = 'public'
ORDER BY ordinal_position`,
[tableName]
);
2025-09-16 15:13:00 +09:00
// 2. table_type_columns 테이블에서 라벨과 input_type 정보 조회
// 회사코드가 있으면 해당 회사 + '*' 만, 회사별 우선
const columnLabels = await query<{
column_name: string;
column_label: string | null;
input_type: string | null;
}>(
`SELECT DISTINCT ON (column_name) column_name, column_label, input_type
FROM table_type_columns
WHERE table_name = $1
${companyCode ? `AND company_code IN ($2, '*')` : ''}
ORDER BY column_name,
CASE WHEN company_code = '*' THEN 1 ELSE 0 END`,
companyCode ? [tableName, companyCode] : [tableName]
);
// 3. 라벨 및 inputType 정보를 맵으로 변환
const labelMap = new Map<string, { label: string; inputType: string }>();
columnLabels.forEach((col) => {
if (col.column_name) {
labelMap.set(col.column_name, {
label: col.column_label || col.column_name,
inputType: col.input_type || "text",
});
}
});
// 4. 컬럼 정보와 라벨/inputType 정보 결합
return columns.map((col) => {
const labelInfo = labelMap.get(col.column_name);
return {
columnName: col.column_name,
displayName: labelInfo?.label || col.column_name,
dataType: col.data_type,
inputType: labelInfo?.inputType || "text",
};
});
2025-09-16 15:13:00 +09:00
} catch (error) {
logger.error(`참조 테이블 컬럼 조회 실패: ${tableName}`, error);
return [];
}
}
}
export const entityJoinService = new EntityJoinService();