import { Request, Response } from "express"; import { logger } from "../utils/logger"; import { TableManagementService } from "../services/tableManagementService"; import { entityJoinService } from "../services/entityJoinService"; import { referenceCacheService } from "../services/referenceCacheService"; const tableManagementService = new TableManagementService(); /** * Entity 조인 기능 컨트롤러 * ID값을 의미있는 데이터로 자동 변환하는 API 제공 */ export class EntityJoinController { /** * Entity 조인이 포함된 테이블 데이터 조회 * GET /api/table-management/tables/:tableName/data-with-joins */ async getTableDataWithJoins(req: Request, res: Response): Promise { try { const { tableName } = req.params; const { page = 1, size = 20, search, sortBy, sortOrder = "asc", enableEntityJoin = true, additionalJoinColumns, // 추가 조인 컬럼 정보 (JSON 문자열) screenEntityConfigs, // 화면별 엔티티 설정 (JSON 문자열) autoFilter, // 🔒 멀티테넌시 자동 필터 dataFilter, // 🆕 데이터 필터 (JSON 문자열) excludeFilter, // 🆕 제외 필터 (JSON 문자열) - 다른 테이블에 이미 존재하는 데이터 제외 deduplication, // 🆕 중복 제거 설정 (JSON 문자열) userLang, // userLang은 별도로 분리하여 search에 포함되지 않도록 함 ...otherParams } = req.query; logger.info(`Entity 조인 데이터 요청: ${tableName}`, { page, size, enableEntityJoin, search, autoFilter, }); // 검색 조건 처리 let searchConditions: Record = {}; if (search) { try { // search가 문자열인 경우 JSON 파싱 searchConditions = typeof search === "string" ? JSON.parse(search) : search; // 🔍 디버그: 파싱된 검색 조건 로깅 logger.info(`🔍 파싱된 검색 조건:`, JSON.stringify(searchConditions, null, 2)); } catch (error) { logger.warn("검색 조건 파싱 오류:", error); searchConditions = {}; } } // 🔒 멀티테넌시: 자동 필터 처리 if (autoFilter) { try { const parsedAutoFilter = typeof autoFilter === "string" ? JSON.parse(autoFilter) : autoFilter; if (parsedAutoFilter.enabled && (req as any).user) { const filterColumn = parsedAutoFilter.filterColumn || "company_code"; const userField = parsedAutoFilter.userField || "companyCode"; const userValue = ((req as any).user as any)[userField]; // 🆕 프리뷰용 회사 코드 오버라이드 (최고 관리자만 허용) let finalCompanyCode = userValue; if (parsedAutoFilter.companyCodeOverride && userValue === "*") { // 최고 관리자만 다른 회사 코드로 오버라이드 가능 finalCompanyCode = parsedAutoFilter.companyCodeOverride; logger.info("🔓 최고 관리자 회사 코드 오버라이드:", { originalCompanyCode: userValue, overrideCompanyCode: parsedAutoFilter.companyCodeOverride, tableName, }); } if (finalCompanyCode) { searchConditions[filterColumn] = finalCompanyCode; logger.info("🔒 Entity 조인에 멀티테넌시 필터 적용:", { filterColumn, finalCompanyCode, tableName, }); } } } catch (error) { logger.warn("자동 필터 파싱 오류:", error); } } // 추가 조인 컬럼 정보 처리 let parsedAdditionalJoinColumns: any[] = []; if (additionalJoinColumns) { try { parsedAdditionalJoinColumns = typeof additionalJoinColumns === "string" ? JSON.parse(additionalJoinColumns) : additionalJoinColumns; logger.info("추가 조인 컬럼 파싱 완료:", parsedAdditionalJoinColumns); } catch (error) { logger.warn("추가 조인 컬럼 파싱 오류:", error); parsedAdditionalJoinColumns = []; } } // 화면별 엔티티 설정 처리 let parsedScreenEntityConfigs: Record = {}; if (screenEntityConfigs) { try { parsedScreenEntityConfigs = typeof screenEntityConfigs === "string" ? JSON.parse(screenEntityConfigs) : screenEntityConfigs; logger.info( "화면별 엔티티 설정 파싱 완료:", parsedScreenEntityConfigs ); } catch (error) { logger.warn("화면별 엔티티 설정 파싱 오류:", error); parsedScreenEntityConfigs = {}; } } // 🆕 데이터 필터 처리 let parsedDataFilter: any = undefined; if (dataFilter) { try { parsedDataFilter = typeof dataFilter === "string" ? JSON.parse(dataFilter) : dataFilter; logger.info("데이터 필터 파싱 완료:", parsedDataFilter); } catch (error) { logger.warn("데이터 필터 파싱 오류:", error); parsedDataFilter = undefined; } } // 🆕 제외 필터 처리 (다른 테이블에 이미 존재하는 데이터 제외) let parsedExcludeFilter: any = undefined; if (excludeFilter) { try { parsedExcludeFilter = typeof excludeFilter === "string" ? JSON.parse(excludeFilter) : excludeFilter; logger.info("제외 필터 파싱 완료:", parsedExcludeFilter); } catch (error) { logger.warn("제외 필터 파싱 오류:", error); parsedExcludeFilter = undefined; } } // 🆕 중복 제거 설정 처리 let parsedDeduplication: { enabled: boolean; groupByColumn: string; keepStrategy: "latest" | "earliest" | "base_price" | "current_date"; sortColumn?: string; } | undefined = undefined; if (deduplication) { try { parsedDeduplication = typeof deduplication === "string" ? JSON.parse(deduplication) : deduplication; logger.info("중복 제거 설정 파싱 완료:", parsedDeduplication); } catch (error) { logger.warn("중복 제거 설정 파싱 오류:", error); parsedDeduplication = undefined; } } const result = await tableManagementService.getTableDataWithEntityJoins( tableName, { page: Number(page), size: Number(size), search: Object.keys(searchConditions).length > 0 ? searchConditions : undefined, sortBy: sortBy as string, sortOrder: sortOrder as string, enableEntityJoin: enableEntityJoin === "true" || enableEntityJoin === true, additionalJoinColumns: parsedAdditionalJoinColumns, screenEntityConfigs: parsedScreenEntityConfigs, dataFilter: parsedDataFilter, // 🆕 데이터 필터 전달 excludeFilter: parsedExcludeFilter, // 🆕 제외 필터 전달 deduplication: parsedDeduplication, // 🆕 중복 제거 설정 전달 } ); // 🆕 중복 제거 처리 (결과 데이터에 적용) let finalData = result; if (parsedDeduplication?.enabled && parsedDeduplication.groupByColumn && Array.isArray(result.data)) { logger.info(`🔄 중복 제거 시작: 기준 컬럼 = ${parsedDeduplication.groupByColumn}, 전략 = ${parsedDeduplication.keepStrategy}`); const originalCount = result.data.length; finalData = { ...result, data: this.deduplicateData(result.data, parsedDeduplication), }; logger.info(`✅ 중복 제거 완료: ${originalCount}개 → ${finalData.data.length}개`); } res.status(200).json({ success: true, message: "Entity 조인 데이터 조회 성공", data: finalData, }); } catch (error) { logger.error("Entity 조인 데이터 조회 실패", error); res.status(500).json({ success: false, message: "Entity 조인 데이터 조회 중 오류가 발생했습니다.", error: error instanceof Error ? error.message : "Unknown error", }); } } /** * 테이블의 Entity 조인 설정 조회 * GET /api/table-management/tables/:tableName/entity-joins */ async getEntityJoinConfigs(req: Request, res: Response): Promise { try { const { tableName } = req.params; logger.info(`Entity 조인 설정 조회: ${tableName}`); const joinConfigs = await entityJoinService.detectEntityJoins(tableName); res.status(200).json({ success: true, message: "Entity 조인 설정 조회 성공", data: { tableName, joinConfigs, count: joinConfigs.length, }, }); } catch (error) { logger.error("Entity 조인 설정 조회 실패", error); res.status(500).json({ success: false, message: "Entity 조인 설정 조회 중 오류가 발생했습니다.", error: error instanceof Error ? error.message : "Unknown error", }); } } /** * 참조 테이블의 표시 가능한 컬럼 목록 조회 * GET /api/table-management/reference-tables/:tableName/columns */ async getReferenceTableColumns(req: Request, res: Response): Promise { try { const { tableName } = req.params; logger.info(`참조 테이블 컬럼 조회: ${tableName}`); const columns = await tableManagementService.getReferenceTableColumns(tableName); res.status(200).json({ success: true, message: "참조 테이블 컬럼 조회 성공", data: { tableName, columns, count: columns.length, }, }); } catch (error) { logger.error("참조 테이블 컬럼 조회 실패", error); res.status(500).json({ success: false, message: "참조 테이블 컬럼 조회 중 오류가 발생했습니다.", error: error instanceof Error ? error.message : "Unknown error", }); } } /** * 컬럼 Entity 설정 업데이트 (display_column 포함) * PUT /api/table-management/tables/:tableName/columns/:columnName/entity-settings */ async updateEntitySettings(req: Request, res: Response): Promise { try { const { tableName, columnName } = req.params; const { webType, referenceTable, referenceColumn, displayColumn, columnLabel, description, } = req.body; logger.info(`Entity 설정 업데이트: ${tableName}.${columnName}`, req.body); // Entity 타입인 경우 필수 필드 검증 if (webType === "entity") { if (!referenceTable || !referenceColumn) { res.status(400).json({ success: false, message: "Entity 타입의 경우 referenceTable과 referenceColumn이 필수입니다.", }); return; } } await tableManagementService.updateColumnLabel(tableName, columnName, { webType, referenceTable, referenceColumn, displayColumn, columnLabel, description, }); // Entity 설정 변경 시 관련 캐시 무효화 if (webType === "entity" && referenceTable) { referenceCacheService.invalidateCache( referenceTable, referenceColumn, displayColumn ); } res.status(200).json({ success: true, message: "Entity 설정 업데이트 성공", data: { tableName, columnName, settings: { webType, referenceTable, referenceColumn, displayColumn, }, }, }); } catch (error) { logger.error("Entity 설정 업데이트 실패", error); res.status(500).json({ success: false, message: "Entity 설정 업데이트 중 오류가 발생했습니다.", error: error instanceof Error ? error.message : "Unknown error", }); } } /** * 캐시 상태 조회 * GET /api/table-management/cache/status */ async getCacheStatus(req: Request, res: Response): Promise { try { logger.info("캐시 상태 조회"); const cacheInfo = referenceCacheService.getCacheInfo(); const overallHitRate = referenceCacheService.getOverallCacheHitRate(); res.status(200).json({ success: true, message: "캐시 상태 조회 성공", data: { overallHitRate, caches: cacheInfo, summary: { totalCaches: cacheInfo.length, totalSize: cacheInfo.reduce( (sum, cache) => sum + cache.dataSize, 0 ), averageHitRate: cacheInfo.length > 0 ? cacheInfo.reduce((sum, cache) => sum + cache.hitRate, 0) / cacheInfo.length : 0, }, }, }); } catch (error) { logger.error("캐시 상태 조회 실패", error); res.status(500).json({ success: false, message: "캐시 상태 조회 중 오류가 발생했습니다.", error: error instanceof Error ? error.message : "Unknown error", }); } } /** * 캐시 무효화 * DELETE /api/table-management/cache */ async invalidateCache(req: Request, res: Response): Promise { try { const { table, keyColumn, displayColumn } = req.query; logger.info("캐시 무효화 요청", { table, keyColumn, displayColumn }); if (table && keyColumn && displayColumn) { // 특정 캐시만 무효화 referenceCacheService.invalidateCache( table as string, keyColumn as string, displayColumn as string ); } else { // 전체 캐시 무효화 referenceCacheService.invalidateCache(); } res.status(200).json({ success: true, message: "캐시 무효화 완료", data: { target: table ? `${table}.${keyColumn}.${displayColumn}` : "전체", }, }); } catch (error) { logger.error("캐시 무효화 실패", error); res.status(500).json({ success: false, message: "캐시 무효화 중 오류가 발생했습니다.", error: error instanceof Error ? error.message : "Unknown error", }); } } /** * Entity 조인된 테이블의 추가 컬럼 목록 조회 * GET /api/table-management/tables/:tableName/entity-join-columns */ async getEntityJoinColumns(req: Request, res: Response): Promise { try { const { tableName } = req.params; logger.info(`Entity 조인 컬럼 조회: ${tableName}`); // 1. 현재 테이블의 Entity 조인 설정 조회 const joinConfigs = await entityJoinService.detectEntityJoins(tableName); if (joinConfigs.length === 0) { res.status(200).json({ success: true, message: "Entity 조인 설정이 없습니다.", data: { tableName, joinTables: [], availableColumns: [], }, }); return; } // 2. 각 조인 테이블의 컬럼 정보 조회 const joinTablesInfo = await Promise.all( joinConfigs.map(async (config) => { try { const columns = await tableManagementService.getReferenceTableColumns( config.referenceTable ); // 현재 display_column 정보 (참고용으로만 사용, 필터링하지 않음) const currentDisplayColumn = config.displayColumn || config.displayColumns[0]; // 모든 컬럼 표시 (기본 표시 컬럼도 포함) return { joinConfig: config, tableName: config.referenceTable, currentDisplayColumn: currentDisplayColumn, availableColumns: columns.map((col) => ({ columnName: col.columnName, columnLabel: col.displayName || col.columnName, dataType: col.dataType, isNullable: true, // 기본값으로 설정 maxLength: undefined, // 정보가 없으므로 undefined description: col.displayName, })), }; } catch (error) { logger.warn( `참조 테이블 컬럼 조회 실패: ${config.referenceTable}`, error ); return { joinConfig: config, tableName: config.referenceTable, currentDisplayColumn: config.displayColumn || config.displayColumns[0], availableColumns: [], error: error instanceof Error ? error.message : "Unknown error", }; } }) ); // 3. 사용 가능한 모든 컬럼 목록 생성 (중복 제거) const allAvailableColumns: Array<{ tableName: string; columnName: string; columnLabel: string; dataType: string; joinAlias: string; suggestedLabel: string; }> = []; joinTablesInfo.forEach((info) => { info.availableColumns.forEach((col) => { const joinAlias = `${info.joinConfig.sourceColumn}_${col.columnName}`; const suggestedLabel = col.columnLabel; // 라벨명만 사용 allAvailableColumns.push({ tableName: info.tableName, columnName: col.columnName, columnLabel: col.columnLabel, dataType: col.dataType, joinAlias, suggestedLabel, }); }); }); res.status(200).json({ success: true, message: "Entity 조인 컬럼 조회 성공", data: { tableName, joinTables: joinTablesInfo, availableColumns: allAvailableColumns, summary: { totalJoinTables: joinConfigs.length, totalAvailableColumns: allAvailableColumns.length, }, }, }); } catch (error) { logger.error("Entity 조인 컬럼 조회 실패", error); res.status(500).json({ success: false, message: "Entity 조인 컬럼 조회 중 오류가 발생했습니다.", error: error instanceof Error ? error.message : "Unknown error", }); } } /** * 공통 참조 테이블 자동 캐싱 * POST /api/table-management/cache/preload */ async preloadCommonCaches(req: Request, res: Response): Promise { try { logger.info("공통 참조 테이블 자동 캐싱 시작"); await referenceCacheService.autoPreloadCommonTables(); const cacheInfo = referenceCacheService.getCacheInfo(); res.status(200).json({ success: true, message: "공통 참조 테이블 캐싱 완료", data: { preloadedCaches: cacheInfo.length, caches: cacheInfo, }, }); } catch (error) { logger.error("공통 참조 테이블 캐싱 실패", error); res.status(500).json({ success: false, message: "공통 참조 테이블 캐싱 중 오류가 발생했습니다.", error: error instanceof Error ? error.message : "Unknown error", }); } } /** * 중복 데이터 제거 (메모리 내 처리) */ private deduplicateData( data: any[], config: { groupByColumn: string; keepStrategy: "latest" | "earliest" | "base_price" | "current_date"; sortColumn?: string; } ): any[] { if (!data || data.length === 0) return data; // 그룹별로 데이터 분류 const groups: Record = {}; for (const row of data) { const groupKey = row[config.groupByColumn]; if (groupKey === undefined || groupKey === null) continue; if (!groups[groupKey]) { groups[groupKey] = []; } groups[groupKey].push(row); } // 각 그룹에서 하나의 행만 선택 const result: any[] = []; for (const [groupKey, rows] of Object.entries(groups)) { if (rows.length === 0) continue; let selectedRow: any; switch (config.keepStrategy) { case "latest": // 정렬 컬럼 기준 최신 (가장 큰 값) if (config.sortColumn) { rows.sort((a, b) => { const aVal = a[config.sortColumn!]; const bVal = b[config.sortColumn!]; if (aVal === bVal) return 0; if (aVal > bVal) return -1; return 1; }); } selectedRow = rows[0]; break; case "earliest": // 정렬 컬럼 기준 최초 (가장 작은 값) if (config.sortColumn) { rows.sort((a, b) => { const aVal = a[config.sortColumn!]; const bVal = b[config.sortColumn!]; if (aVal === bVal) return 0; if (aVal < bVal) return -1; return 1; }); } selectedRow = rows[0]; break; case "base_price": // base_price가 true인 행 선택 selectedRow = rows.find((r) => r.base_price === true || r.base_price === "true") || rows[0]; break; case "current_date": // 오늘 날짜 기준 유효 기간 내 행 선택 const today = new Date().toISOString().split("T")[0]; selectedRow = rows.find((r) => { const startDate = r.start_date; const endDate = r.end_date; if (!startDate) return true; if (startDate <= today && (!endDate || endDate >= today)) return true; return false; }) || rows[0]; break; default: selectedRow = rows[0]; } if (selectedRow) { result.push(selectedRow); } } return result; } } export const entityJoinController = new EntityJoinController();