2025-10-01 12:10:34 +09:00
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 컬 럼 들 을 감 지 하 여 조 인 설 정 생 성
2025-09-23 15:58:54 +09:00
* @param tableName 테 이 블 명
* @param screenEntityConfigs 화 면 별 엔 티 티 설 정 ( 선 택 사 항 )
2026-02-10 10:51:23 +09:00
* @param companyCode 회 사 코 드 ( 회 사 별 설 정 우 선 , 없 으 면 전 체 조 회 )
2025-09-16 15:13:00 +09:00
* /
2025-09-23 15:58:54 +09:00
async detectEntityJoins (
2025-09-23 16:23:36 +09:00
tableName : string ,
2026-02-10 10:51:23 +09:00
screenEntityConfigs? : Record < string , any > ,
companyCode? : string
2025-09-23 15:58:54 +09:00
) : Promise < EntityJoinConfig [ ] > {
2025-09-16 15:13:00 +09:00
try {
2026-02-10 10:51:23 +09:00
logger . info ( ` Entity 컬럼 감지 시작: ${ tableName } (companyCode: ${ companyCode || 'all' } ) ` ) ;
2025-09-16 15:13:00 +09:00
2026-01-28 11:24:25 +09:00
// table_type_columns에서 entity 및 category 타입인 컬럼들 조회
2026-02-10 10:51:23 +09:00
// 회사코드가 있으면 해당 회사 + '*' 만 조회, 회사별 우선
2025-10-01 12:10:34 +09:00
const entityColumns = await query < {
column_name : string ;
2025-11-12 14:02:58 +09:00
input_type : string ;
2025-10-01 12:10:34 +09:00
reference_table : string ;
reference_column : string ;
display_column : string | null ;
} > (
2026-02-10 10:51:23 +09:00
` SELECT DISTINCT ON (column_name)
column_name , input_type , reference_table , reference_column , display_column
2026-01-28 11:24:25 +09:00
FROM table_type_columns
2025-10-01 12:10:34 +09:00
WHERE table_name = $1
2026-01-28 11:24:25 +09:00
AND input_type IN ( 'entity' , 'category' )
AND reference_table IS NOT NULL
2026-02-10 10:51:23 +09:00
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-10-01 12:10:34 +09:00
) ;
2025-09-16 15:13:00 +09:00
2025-09-24 14:31:46 +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 [ ] = [ ] ;
2025-11-10 16:32:00 +09:00
// 🎯 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 : " - " ,
} ;
2025-11-10 16:38:16 +09:00
if ( await this . validateJoinConfig ( writerJoinConfig ) ) {
2025-11-10 16:32:00 +09:00
joinConfigs . push ( writerJoinConfig ) ;
}
}
2025-09-16 15:13:00 +09:00
for ( const column of entityColumns ) {
2025-11-12 14:02:58 +09:00
// 카테고리 타입인 경우 자동으로 category_values 테이블 참조 설정
let referenceTable = column . reference_table ;
let referenceColumn = column . reference_column ;
let displayColumn = column . display_column ;
2025-11-20 10:23:54 +09:00
if ( column . input_type === "category" ) {
// 카테고리 타입: reference 정보가 비어있어도 자동 설정
2026-03-09 13:46:38 +09:00
referenceTable = referenceTable || "category_values" ;
2025-11-20 10:23:54 +09:00
referenceColumn = referenceColumn || "value_code" ;
displayColumn = displayColumn || "value_label" ;
logger . info ( ` 🏷️ 카테고리 타입 자동 설정: ${ column . column_name } ` , {
referenceTable ,
referenceColumn ,
displayColumn ,
} ) ;
}
2025-11-12 14:02:58 +09:00
2025-09-24 14:31:46 +09:00
logger . info ( ` 🔍 Entity 컬럼 상세 정보: ` , {
column_name : column.column_name ,
2025-11-12 14:02:58 +09:00
input_type : column.input_type ,
reference_table : referenceTable ,
reference_column : referenceColumn ,
display_column : displayColumn ,
2025-09-24 14:31:46 +09:00
} ) ;
2025-11-12 14:02:58 +09:00
if ( ! column . column_name || ! referenceTable || ! referenceColumn ) {
logger . warn ( ` ⚠️ 필수 정보 누락으로 스킵: ${ column . column_name } ` ) ;
2025-09-16 15:13:00 +09:00
continue ;
}
2025-09-23 15:58:54 +09:00
// 화면별 엔티티 설정이 있으면 우선 사용, 없으면 기본값 사용
const screenConfig = screenEntityConfigs ? . [ column . column_name ] ;
let displayColumns : string [ ] = [ ] ;
let separator = " - " ;
2025-09-23 16:23:36 +09:00
2025-09-24 14:31:46 +09:00
logger . info ( ` 🔍 조건 확인 - 컬럼: ${ column . column_name } ` , {
hasScreenConfig : ! ! screenConfig ,
hasDisplayColumns : screenConfig?.displayColumns ,
displayColumn : column.display_column ,
} ) ;
2025-09-23 15:58:54 +09:00
if ( screenConfig && screenConfig . displayColumns ) {
2025-09-23 17:43:24 +09:00
// 화면에서 설정된 표시 컬럼들 사용 (기본 테이블 + 조인 테이블 조합 지원)
2025-09-23 15:58:54 +09:00
displayColumns = screenConfig . displayColumns ;
separator = screenConfig . separator || " - " ;
2025-09-23 17:43:24 +09:00
console . log ( ` 🎯 화면별 엔티티 설정 적용: ${ column . column_name } ` , {
displayColumns ,
separator ,
screenConfig ,
} ) ;
2025-11-12 14:02:58 +09:00
} else if ( displayColumn && displayColumn !== "none" ) {
2025-09-24 10:33:54 +09:00
// 기존 설정된 단일 표시 컬럼 사용 (none이 아닌 경우만)
2025-11-12 14:02:58 +09:00
displayColumns = [ displayColumn ] ;
2025-09-24 14:31:46 +09:00
logger . info (
2025-11-12 14:02:58 +09:00
` 🔧 기존 display_column 사용: ${ column . column_name } → ${ displayColumn } `
2025-09-24 14:31:46 +09:00
) ;
2025-09-23 15:58:54 +09:00
} else {
2025-12-11 12:01:00 +09:00
// display_column이 "none"이거나 없는 경우 참조 테이블의 표시용 컬럼 자동 감지
logger . info ( ` 🔍 ${ referenceTable } 의 표시 컬럼 자동 감지 중... ` ) ;
2025-11-20 15:07:26 +09:00
// 참조 테이블의 모든 컬럼 이름 가져오기
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 ]
2025-09-23 16:23:36 +09:00
) ;
2025-11-20 15:07:26 +09:00
if ( tableColumnsResult . length > 0 ) {
2025-12-11 12:01:00 +09:00
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 ] ;
2025-11-20 15:07:26 +09:00
logger . info (
2025-12-11 12:01:00 +09:00
` ✅ ${ referenceTable } 의 표시 컬럼 자동 감지: ${ displayColumn } (전체 ${ allColumns . length } 개 중) `
2025-11-20 15:07:26 +09:00
) ;
} else {
// 테이블 컬럼을 못 찾으면 기본값 사용
displayColumns = [ referenceColumn ] ;
logger . warn (
` ⚠️ ${ referenceTable } 의 컬럼 조회 실패, 기본값 사용: ${ referenceColumn } `
) ;
}
2025-09-23 15:58:54 +09:00
}
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 ,
2025-11-12 14:02:58 +09:00
referenceTable : referenceTable , // 카테고리의 경우 자동 설정된 값 사용
referenceColumn : referenceColumn , // 카테고리의 경우 자동 설정된 값 사용
2025-09-23 15:58:54 +09:00
displayColumns : displayColumns ,
displayColumn : displayColumns [ 0 ] , // 하위 호환성
2025-09-16 15:13:00 +09:00
aliasColumn : aliasColumn ,
2025-09-23 15:58:54 +09:00
separator : separator ,
2025-09-16 15:13:00 +09:00
} ;
2025-09-24 14:31:46 +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
// 조인 설정 유효성 검증
2025-09-24 14:31:46 +09:00
logger . info (
` 🔍 조인 설정 검증 중: ${ joinConfig . sourceColumn } -> ${ joinConfig . referenceTable } `
) ;
2025-09-16 15:13:00 +09:00
if ( await this . validateJoinConfig ( joinConfig ) ) {
joinConfigs . push ( joinConfig ) ;
2025-09-24 14:31:46 +09:00
logger . info ( ` ✅ 조인 설정 추가됨: ${ joinConfig . aliasColumn } ` ) ;
} else {
logger . warn ( ` ❌ 조인 설정 검증 실패: ${ joinConfig . sourceColumn } ` ) ;
2025-09-16 15:13:00 +09:00
}
}
2025-09-24 14:31:46 +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 ,
2026-02-10 10:51:23 +09:00
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
2025-09-16 18:02:19 +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 > ( ) ;
2025-11-13 15:16:36 +09:00
// joinConfigs를 참조 테이블 + 소스 컬럼별로 중복 제거하여 별칭 생성
2026-03-09 13:46:38 +09:00
// (category_values는 같은 테이블이라도 sourceColumn마다 별도 JOIN 필요)
2025-09-17 11:15:34 +09:00
const uniqueReferenceTableConfigs = joinConfigs . reduce ( ( acc , config ) = > {
if (
! acc . some (
( existingConfig ) = >
2025-11-13 15:16:36 +09:00
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 (
2025-11-13 15:16:36 +09:00
` 🔧 별칭 생성 시작: ${ 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 ) ;
2026-03-09 13:46:38 +09:00
// 같은 테이블이라도 sourceColumn이 다르면 별도 별칭 생성 (category_values 대응)
2025-11-13 15:16:36 +09:00
const aliasKey = ` ${ config . referenceTable } : ${ config . sourceColumn } ` ;
aliasMap . set ( aliasKey , alias ) ;
2025-11-20 10:23:54 +09:00
logger . info (
` 🔧 별칭 생성: ${ config . referenceTable } . ${ config . sourceColumn } → ${ alias } `
) ;
2025-09-17 11:15:34 +09:00
} ) ;
2026-02-10 10:51:23 +09:00
// 🔧 생성된 별칭 중복 방지를 위한 Set
const generatedAliases = new Set < string > ( ) ;
2026-01-20 16:08:38 +09:00
2026-02-10 10:51:23 +09:00
const joinColumns = uniqueReferenceTableConfigs
2025-09-23 15:58:54 +09:00
. map ( ( config ) = > {
2025-11-13 15:16:36 +09:00
const aliasKey = ` ${ config . referenceTable } : ${ config . sourceColumn } ` ;
const alias = aliasMap . get ( aliasKey ) ;
2025-11-12 14:02:58 +09:00
const resultColumns : string [ ] = [ ] ;
2025-09-23 16:23:36 +09:00
2026-02-10 10:51:23 +09:00
// 🆕 참조 테이블의 전체 컬럼 목록이 있으면 모든 컬럼을 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" ] ) ;
2025-11-20 10:23:54 +09:00
2026-02-10 10:51:23 +09:00
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
2025-11-20 10:23:54 +09:00
resultColumns . push (
2026-02-10 10:51:23 +09:00
` COALESCE( ${ alias } ." ${ col } "::TEXT, '') AS " ${ colAlias } " `
2025-11-20 10:23:54 +09:00
) ;
2026-02-10 10:51:23 +09:00
generatedAliases . add ( colAlias ) ;
}
2025-11-20 10:23:54 +09:00
2026-02-10 10:51:23 +09:00
// _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 ;
2025-11-20 10:23:54 +09:00
resultColumns . push (
2026-02-10 10:51:23 +09:00
` COALESCE( ${ alias } ." ${ displayCol } "::TEXT, '') AS " ${ labelAlias } " `
2025-11-20 10:23:54 +09:00
) ;
2026-02-10 10:51:23 +09:00
generatedAliases . add ( labelAlias ) ;
2025-09-23 17:43:24 +09:00
}
2025-09-23 15:58:54 +09:00
} else {
2026-02-10 10:51:23 +09:00
// 🔄 기존 로직 (참조 테이블 컬럼 목록이 없는 경우 - 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 ] ;
2025-11-20 15:07:26 +09:00
const isJoinTableColumn =
config . referenceTable && config . referenceTable !== tableName ;
if ( isJoinTableColumn ) {
resultColumns . push (
2026-02-10 10:51:23 +09:00
` COALESCE( ${ alias } . ${ col } ::TEXT, '') AS ${ config . aliasColumn } `
2025-11-20 15:07:26 +09:00
) ;
2026-02-10 10:51:23 +09:00
const labelAlias = ` ${ config . sourceColumn } _label ` ;
if ( ! generatedAliases . has ( labelAlias ) ) {
resultColumns . push (
` COALESCE( ${ alias } . ${ col } ::TEXT, '') AS ${ labelAlias } `
) ;
generatedAliases . add ( labelAlias ) ;
}
2025-11-20 15:07:26 +09:00
} else {
resultColumns . push (
2026-02-10 10:51:23 +09:00
` COALESCE(main. ${ col } ::TEXT, '') AS ${ config . aliasColumn } `
2025-11-20 15:07:26 +09:00
) ;
}
2026-02-10 10:51:23 +09:00
} 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
}
2025-09-23 15:58:54 +09:00
}
2025-11-20 10:23:54 +09:00
2025-11-12 14:02:58 +09:00
return resultColumns . join ( ", " ) ;
2025-09-23 15:58:54 +09:00
} )
2026-02-10 10:51:23 +09:00
. filter ( Boolean )
2025-09-16 15:13:00 +09:00
. join ( ", " ) ;
// SELECT 절 구성
const selectClause = joinColumns
? ` ${ baseColumns } , ${ joinColumns } `
: baseColumns ;
// FROM 절 (메인 테이블)
const fromClause = ` FROM ${ tableName } main ` ;
2025-11-13 15:16:36 +09:00
// 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 ) = > {
2025-11-13 15:16:36 +09:00
const aliasKey = ` ${ config . referenceTable } : ${ config . sourceColumn } ` ;
const alias = aliasMap . get ( aliasKey ) ;
2025-11-20 10:23:54 +09:00
2026-03-09 13:46:38 +09:00
// 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-11-12 14:02:58 +09:00
}
2025-11-20 10:23:54 +09:00
2025-12-03 18:28:43 +09:00
// user_info는 전역 테이블이므로 company_code 조건 없이 조인
if ( config . referenceTable === "user_info" ) {
2026-02-10 12:07:25 +09:00
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 등 회사별 데이터 테이블
2026-02-10 12:07:25 +09:00
// ::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" ) ;
2025-09-24 14:31:46 +09:00
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" ;
}
2026-03-09 13:46:38 +09:00
// category_values는 특수 조인 조건이 필요하므로 캐시 불가
if ( config . referenceTable === "category_values" ) {
2025-11-12 14:02:58 +09:00
logger . info (
2026-03-09 13:46:38 +09:00
` 🎯 category_values는 캐시 전략 불가: ${ config . sourceColumn } `
2025-11-12 14:02:58 +09:00
) ;
return "join" ;
}
2025-09-16 16:53:03 +09:00
// 참조 테이블의 캐시 가능성 확인
2025-09-24 14:31:46 +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 ,
2025-09-24 14:31:46 +09:00
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 {
2025-09-24 14:31:46 +09:00
logger . info ( "🔍 조인 설정 검증 상세:" , {
sourceColumn : config.sourceColumn ,
referenceTable : config.referenceTable ,
2026-02-10 12:07:25 +09:00
referenceColumn : config.referenceColumn ,
2025-09-24 14:31:46 +09:00
displayColumns : config.displayColumns ,
displayColumn : config.displayColumn ,
aliasColumn : config.aliasColumn ,
} ) ;
2025-09-16 15:13:00 +09:00
// 참조 테이블 존재 확인
2025-10-01 12:10:34 +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
2025-10-01 12:10:34 +09:00
if ( tableExists . length === 0 ) {
2025-09-16 15:13:00 +09:00
logger . warn ( ` 참조 테이블이 존재하지 않음: ${ config . referenceTable } ` ) ;
return false ;
}
2026-02-10 12:07:25 +09:00
// 참조 컬럼(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 ;
2025-09-24 14:31:46 +09:00
logger . info (
` 🔍 표시 컬럼 확인: ${ displayColumn } (from displayColumns: ${ config . displayColumns } , displayColumn: ${ config . displayColumn } ) `
) ;
2025-09-16 15:13:00 +09:00
2025-09-24 14:31:46 +09:00
// 🚨 display_column이 항상 "none"이므로, 표시 컬럼이 없어도 조인 허용
if ( displayColumn && displayColumn !== "none" ) {
2025-10-01 12:10:34 +09:00
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 ) {
2025-09-24 14:31:46 +09:00
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 > ( ) ;
2025-11-13 15:16:36 +09:00
// joinConfigs를 참조 테이블 + 소스 컬럼별로 중복 제거하여 별칭 생성
2025-09-17 11:15:34 +09:00
const uniqueReferenceTableConfigs = joinConfigs . reduce ( ( acc , config ) = > {
if (
! acc . some (
( existingConfig ) = >
2025-11-13 15:16:36 +09:00
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 ) ;
2025-11-13 15:16:36 +09:00
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 ) = > {
2025-11-13 15:16:36 +09:00
const aliasKey = ` ${ config . referenceTable } : ${ config . sourceColumn } ` ;
const alias = aliasMap . get ( aliasKey ) ;
2025-11-20 10:23:54 +09:00
2026-03-09 13:46:38 +09:00
// 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-11-13 15:16:36 +09:00
}
2025-11-20 10:23:54 +09:00
2026-02-10 12:07:25 +09:00
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용 )
* /
2026-02-10 10:51:23 +09:00
async getReferenceTableColumns ( tableName : string , companyCode? : string ) : Promise <
2025-09-16 15:13:00 +09:00
Array < {
columnName : string ;
displayName : string ;
dataType : string ;
2026-01-15 10:39:23 +09:00
inputType? : string ;
2025-09-16 15:13:00 +09:00
} >
> {
try {
feat: Integrate audit logging for various operations
- Added audit logging functionality across multiple controllers, including menu, user, department, flow, screen, and table management.
- Implemented logging for create, update, and delete actions, capturing relevant details such as company code, user information, and changes made.
- Enhanced the category tree service with a new endpoint to check if category values are in use, improving data integrity checks.
- Updated routes to include new functionalities and ensure proper logging for batch operations and individual record changes.
- This integration improves traceability and accountability for data modifications within the application.
2026-03-04 13:49:08 +09:00
// 1. 테이블의 기본 컬럼 정보 조회 (모든 데이터 타입 포함)
2025-10-01 12:10:34 +09:00
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
2025-10-01 12:10:34 +09:00
WHERE table_name = $1
feat: Integrate audit logging for various operations
- Added audit logging functionality across multiple controllers, including menu, user, department, flow, screen, and table management.
- Implemented logging for create, update, and delete actions, capturing relevant details such as company code, user information, and changes made.
- Enhanced the category tree service with a new endpoint to check if category values are in use, improving data integrity checks.
- Updated routes to include new functionalities and ensure proper logging for batch operations and individual record changes.
- This integration improves traceability and accountability for data modifications within the application.
2026-03-04 13:49:08 +09:00
AND table_schema = 'public'
2025-10-01 12:10:34 +09:00
ORDER BY ordinal_position ` ,
[ tableName ]
) ;
2025-09-16 15:13:00 +09:00
2026-01-28 11:24:25 +09:00
// 2. table_type_columns 테이블에서 라벨과 input_type 정보 조회
2026-02-10 10:51:23 +09:00
// 회사코드가 있으면 해당 회사 + '*' 만, 회사별 우선
2025-10-01 12:10:34 +09:00
const columnLabels = await query < {
column_name : string ;
column_label : string | null ;
2026-01-15 10:39:23 +09:00
input_type : string | null ;
2025-10-01 12:10:34 +09:00
} > (
2026-02-10 10:51:23 +09:00
` SELECT DISTINCT ON (column_name) column_name, column_label, input_type
2026-01-28 11:24:25 +09:00
FROM table_type_columns
WHERE table_name = $1
2026-02-10 10:51:23 +09:00
$ { companyCode ? ` AND company_code IN ( $ 2, '*') ` : '' }
ORDER BY column_name ,
CASE WHEN company_code = '*' THEN 1 ELSE 0 END ` ,
companyCode ? [ tableName , companyCode ] : [ tableName ]
2025-10-01 12:10:34 +09:00
) ;
2025-09-17 10:35:36 +09:00
2026-01-15 10:39:23 +09:00
// 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" ,
} ) ;
2025-09-17 10:35:36 +09:00
}
} ) ;
2026-01-15 10:39:23 +09:00
// 4. 컬럼 정보와 라벨/inputType 정보 결합
return columns . map ( ( col ) = > {
const labelInfo = labelMap . get ( col . column_name ) ;
return {
2026-01-15 12:22:45 +09:00
columnName : col.column_name ,
2026-01-15 10:39:23 +09:00
displayName : labelInfo?.label || col . column_name ,
2026-01-15 12:22:45 +09:00
dataType : col.data_type ,
2026-01-15 10:39:23 +09:00
inputType : labelInfo?.inputType || "text" ,
} ;
} ) ;
2025-09-16 15:13:00 +09:00
} catch ( error ) {
logger . error ( ` 참조 테이블 컬럼 조회 실패: ${ tableName } ` , error ) ;
return [ ] ;
}
}
}
export const entityJoinService = new EntityJoinService ( ) ;