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

722 lines
26 KiB
TypeScript

import { query, queryOne } from "../database/db";
import { logger } from "../utils/logger";
import {
EntityJoinConfig,
BatchLookupRequest,
BatchLookupResponse,
} from "../types/tableManagement";
import { referenceCacheService } from "./referenceCacheService";
/**
* Entity 조인 기능을 제공하는 서비스
* ID값을 의미있는 데이터로 자동 변환하는 스마트 테이블 시스템
*/
export class EntityJoinService {
/**
* 테이블의 Entity 컬럼들을 감지하여 조인 설정 생성
* @param tableName 테이블명
* @param screenEntityConfigs 화면별 엔티티 설정 (선택사항)
*/
async detectEntityJoins(
tableName: string,
screenEntityConfigs?: Record<string, any>
): Promise<EntityJoinConfig[]> {
try {
logger.info(`Entity 컬럼 감지 시작: ${tableName}`);
// column_labels에서 entity 및 category 타입인 컬럼들 조회 (input_type 사용)
const entityColumns = await query<{
column_name: string;
input_type: string;
reference_table: string;
reference_column: string;
display_column: string | null;
}>(
`SELECT column_name, input_type, reference_table, reference_column, display_column
FROM column_labels
WHERE table_name = $1
AND input_type IN ('entity', 'category')`,
[tableName]
);
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})`
);
});
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);
}
}
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 || "table_column_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}`);
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) {
// 화면에서 설정된 표시 컬럼들 사용 (기본 테이블 + 조인 테이블 조합 지원)
displayColumns = screenConfig.displayColumns;
separator = screenConfig.separator || " - ";
console.log(`🎯 화면별 엔티티 설정 적용: ${column.column_name}`, {
displayColumns,
separator,
screenConfig,
});
} else if (displayColumn && displayColumn !== "none") {
// 기존 설정된 단일 표시 컬럼 사용 (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) {
displayColumns = tableColumnsResult.map((col) => col.column_name);
logger.info(
`${referenceTable}의 모든 컬럼 자동 포함 (${displayColumns.length}개):`,
displayColumns.join(", ")
);
} else {
// 테이블 컬럼을 못 찾으면 기본값 사용
displayColumns = [referenceColumn];
logger.warn(
`⚠️ ${referenceTable}의 컬럼 조회 실패, 기본값 사용: ${referenceColumn}`
);
}
}
// 별칭 컬럼명 생성 (writer -> writer_name)
const aliasColumn = `${column.column_name}_name`;
const joinConfig: EntityJoinConfig = {
sourceTable: tableName,
sourceColumn: column.column_name,
referenceTable: referenceTable, // 카테고리의 경우 자동 설정된 값 사용
referenceColumn: referenceColumn, // 카테고리의 경우 자동 설정된 값 사용
displayColumns: displayColumns,
displayColumn: displayColumns[0], // 하위 호환성
aliasColumn: aliasColumn,
separator: separator,
};
logger.info(`🔧 기본 조인 설정 생성:`, {
sourceTable: joinConfig.sourceTable,
sourceColumn: joinConfig.sourceColumn,
referenceTable: joinConfig.referenceTable,
aliasColumn: joinConfig.aliasColumn,
displayColumns: joinConfig.displayColumns,
});
// 조인 설정 유효성 검증
logger.info(
`🔍 조인 설정 검증 중: ${joinConfig.sourceColumn} -> ${joinConfig.referenceTable}`
);
if (await this.validateJoinConfig(joinConfig)) {
joinConfigs.push(joinConfig);
logger.info(`✅ 조인 설정 추가됨: ${joinConfig.aliasColumn}`);
} else {
logger.warn(`❌ 조인 설정 검증 실패: ${joinConfig.sourceColumn}`);
}
}
logger.info(`🎯 Entity 조인 설정 생성 완료: ${joinConfigs.length}`);
joinConfigs.forEach((config, index) => {
logger.info(
` ${index + 1}. ${config.sourceColumn} -> ${config.referenceTable}.${config.referenceColumn} AS ${config.aliasColumn}`
);
});
return joinConfigs;
} catch (error) {
logger.error(`Entity 조인 감지 실패: ${tableName}`, error);
return [];
}
}
/**
* 날짜 컬럼을 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`;
}
/**
* Entity 조인이 포함된 SQL 쿼리 생성
*/
buildJoinQuery(
tableName: string,
joinConfigs: EntityJoinConfig[],
selectColumns: string[],
whereClause: string = "",
orderBy: string = "",
limit?: number,
offset?: number,
columnTypes?: Map<string, string> // 컬럼명 → 데이터 타입 매핑
): { query: string; aliasMap: Map<string, string> } {
try {
// 기본 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(", ");
}
// Entity 조인 컬럼들 (COALESCE로 NULL을 빈 문자열로 처리)
// 별칭 매핑 생성 (JOIN 절과 동일한 로직)
const aliasMap = new Map<string, string>();
const usedAliasesForColumns = new Set<string>();
// joinConfigs를 참조 테이블 + 소스 컬럼별로 중복 제거하여 별칭 생성
// (table_column_category_values는 같은 테이블이라도 sourceColumn마다 별도 JOIN 필요)
const uniqueReferenceTableConfigs = joinConfigs.reduce((acc, config) => {
if (
!acc.some(
(existingConfig) =>
existingConfig.referenceTable === config.referenceTable &&
existingConfig.sourceColumn === config.sourceColumn
)
) {
acc.push(config);
}
return acc;
}, [] as EntityJoinConfig[]);
logger.info(
`🔧 별칭 생성 시작: ${uniqueReferenceTableConfigs.length}개 고유 테이블+컬럼 조합`
);
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이 다르면 별도 별칭 생성 (table_column_category_values 대응)
const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
aliasMap.set(aliasKey, alias);
logger.info(
`🔧 별칭 생성: ${config.referenceTable}.${config.sourceColumn}${alias}`
);
});
const joinColumns = joinConfigs
.map((config) => {
const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
const alias = aliasMap.get(aliasKey);
const displayColumns = config.displayColumns || [
config.displayColumn,
];
const separator = config.separator || " - ";
// 결과 컬럼 배열 (aliasColumn + _label 필드)
const resultColumns: string[] = [];
if (displayColumns.length === 0 || !displayColumns[0]) {
// displayColumns가 빈 배열이거나 첫 번째 값이 null/undefined인 경우
// 조인 테이블의 referenceColumn을 기본값으로 사용
resultColumns.push(
`COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.aliasColumn}`
);
} else if (displayColumns.length === 1) {
// 단일 컬럼인 경우
const col = displayColumns[0];
// ✅ 개선: referenceTable이 설정되어 있으면 조인 테이블에서 가져옴
// 이렇게 하면 item_info.size, item_info.material 등 모든 조인 테이블 컬럼 지원
const isJoinTableColumn =
config.referenceTable && config.referenceTable !== tableName;
if (isJoinTableColumn) {
resultColumns.push(
`COALESCE(${alias}.${col}::TEXT, '') AS ${config.aliasColumn}`
);
// _label 필드도 함께 SELECT (프론트엔드 getColumnUniqueValues용)
// sourceColumn_label 형식으로 추가
resultColumns.push(
`COALESCE(${alias}.${col}::TEXT, '') AS ${config.sourceColumn}_label`
);
// 🆕 referenceColumn (PK)도 항상 SELECT (parentDataMapping용)
// 예: customer_code, item_number 등
// col과 동일해도 별도의 alias로 추가 (customer_code as customer_code)
resultColumns.push(
`COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.referenceColumn}`
);
} else {
resultColumns.push(
`COALESCE(main.${col}::TEXT, '') AS ${config.aliasColumn}`
);
}
} else {
// 🆕 여러 컬럼인 경우 각 컬럼을 개별 alias로 반환 (합치지 않음)
// 예: item_info.standard_price → sourceColumn_standard_price (item_id_standard_price)
displayColumns.forEach((col) => {
const isJoinTableColumn =
config.referenceTable && config.referenceTable !== tableName;
const individualAlias = `${config.sourceColumn}_${col}`;
if (isJoinTableColumn) {
// 조인 테이블 컬럼은 조인 별칭 사용
resultColumns.push(
`COALESCE(${alias}.${col}::TEXT, '') AS ${individualAlias}`
);
} else {
// 기본 테이블 컬럼은 main 별칭 사용
resultColumns.push(
`COALESCE(main.${col}::TEXT, '') AS ${individualAlias}`
);
}
});
// 🆕 referenceColumn (PK)도 함께 SELECT (parentDataMapping용)
const isJoinTableColumn =
config.referenceTable && config.referenceTable !== tableName;
if (
isJoinTableColumn &&
!displayColumns.includes(config.referenceColumn)
) {
resultColumns.push(
`COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.referenceColumn}`
);
}
}
// 모든 resultColumns를 반환
return resultColumns.join(", ");
})
.join(", ");
// SELECT 절 구성
const selectClause = joinColumns
? `${baseColumns}, ${joinColumns}`
: baseColumns;
// FROM 절 (메인 테이블)
const fromClause = `FROM ${tableName} main`;
// LEFT JOIN 절들 (위에서 생성한 별칭 매핑 사용, 각 sourceColumn마다 별도 JOIN)
const joinClauses = uniqueReferenceTableConfigs
.map((config) => {
const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
const alias = aliasMap.get(aliasKey);
// 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} = ${alias}.${config.referenceColumn}`;
})
.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);
return {
query: query,
aliasMap: aliasMap,
};
} catch (error) {
logger.error("Entity 조인 쿼리 생성 실패", error);
throw error;
}
}
/**
* 조인 전략 결정 (테이블 크기 기반)
*/
async determineJoinStrategy(
joinConfigs: EntityJoinConfig[]
): Promise<"full_join" | "cache_lookup" | "hybrid"> {
try {
const strategies = await Promise.all(
joinConfigs.map(async (config) => {
// 여러 컬럼을 조합하는 경우 캐시 전략 사용 불가
if (config.displayColumns && config.displayColumns.length > 1) {
console.log(
`🎯 여러 컬럼 조합으로 인해 조인 전략 사용: ${config.sourceColumn}`,
config.displayColumns
);
return "join";
}
// table_column_category_values는 특수 조인 조건이 필요하므로 캐시 불가
if (config.referenceTable === "table_column_category_values") {
logger.info(
`🎯 table_column_category_values는 캐시 전략 불가: ${config.sourceColumn}`
);
return "join";
}
// 참조 테이블의 캐시 가능성 확인
const displayCol =
config.displayColumn ||
config.displayColumns?.[0] ||
config.referenceColumn;
logger.info(
`🔍 캐시 확인용 표시 컬럼: ${config.referenceTable} - ${displayCol}`
);
const cachedData = await referenceCacheService.getCachedReference(
config.referenceTable,
config.referenceColumn,
displayCol
);
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"; // 안전한 기본값
}
}
/**
* 조인 설정 유효성 검증
*/
private async validateJoinConfig(config: EntityJoinConfig): Promise<boolean> {
try {
logger.info("🔍 조인 설정 검증 상세:", {
sourceColumn: config.sourceColumn,
referenceTable: config.referenceTable,
displayColumns: config.displayColumns,
displayColumn: config.displayColumn,
aliasColumn: config.aliasColumn,
});
// 참조 테이블 존재 확인
const tableExists = await query<{ exists: number }>(
`SELECT 1 as exists FROM information_schema.tables
WHERE table_name = $1
LIMIT 1`,
[config.referenceTable]
);
if (tableExists.length === 0) {
logger.warn(`참조 테이블이 존재하지 않음: ${config.referenceTable}`);
return false;
}
// 참조 컬럼 존재 확인 (displayColumns[0] 사용)
const displayColumn = config.displayColumns?.[0] || config.displayColumn;
logger.info(
`🔍 표시 컬럼 확인: ${displayColumn} (from displayColumns: ${config.displayColumns}, displayColumn: ${config.displayColumn})`
);
// 🚨 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이거나 설정되지 않음`
);
}
return true;
} catch (error) {
logger.error("조인 설정 검증 실패", error);
return false;
}
}
/**
* 카운트 쿼리 생성 (페이징용)
*/
buildCountQuery(
tableName: string,
joinConfigs: EntityJoinConfig[],
whereClause: string = ""
): string {
try {
// 별칭 매핑 생성 (buildJoinQuery와 동일한 로직)
const aliasMap = new Map<string, string>();
const usedAliases = new Set<string>();
// joinConfigs를 참조 테이블 + 소스 컬럼별로 중복 제거하여 별칭 생성
const uniqueReferenceTableConfigs = joinConfigs.reduce((acc, config) => {
if (
!acc.some(
(existingConfig) =>
existingConfig.referenceTable === config.referenceTable &&
existingConfig.sourceColumn === config.sourceColumn
)
) {
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);
});
// JOIN 절들 (COUNT에서는 SELECT 컬럼 불필요)
const joinClauses = uniqueReferenceTableConfigs
.map((config) => {
const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
const alias = aliasMap.get(aliasKey);
// 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} = ${alias}.${config.referenceColumn}`;
})
.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): Promise<
Array<{
columnName: string;
displayName: string;
dataType: string;
}>
> {
try {
// 1. 테이블의 기본 컬럼 정보 조회
const columns = await query<{
column_name: string;
data_type: string;
}>(
`SELECT
column_name,
data_type
FROM information_schema.columns
WHERE table_name = $1
AND data_type IN ('character varying', 'varchar', 'text', 'char')
ORDER BY ordinal_position`,
[tableName]
);
// 2. column_labels 테이블에서 라벨 정보 조회
const columnLabels = await query<{
column_name: string;
column_label: string | null;
}>(
`SELECT column_name, column_label
FROM column_labels
WHERE table_name = $1`,
[tableName]
);
// 3. 라벨 정보를 맵으로 변환
const labelMap = new Map<string, string>();
columnLabels.forEach((label) => {
if (label.column_name && label.column_label) {
labelMap.set(label.column_name, label.column_label);
}
});
// 4. 컬럼 정보와 라벨 정보 결합
return columns.map((col) => ({
columnName: col.column_name,
displayName: labelMap.get(col.column_name) || col.column_name, // 라벨이 있으면 사용, 없으면 컬럼명
dataType: col.data_type,
}));
} catch (error) {
logger.error(`참조 테이블 컬럼 조회 실패: ${tableName}`, error);
return [];
}
}
}
export const entityJoinService = new EntityJoinService();