/** * 다중 테이블 엑셀 업로드 범용 서비스 * * 하나의 플랫 엑셀 데이터를 계층적 다중 테이블(2~N개)에 * 트랜잭션으로 일괄 UPSERT하는 범용 엔진. * * 적용 사례: * - 거래처: customer_mng → customer_item_mapping → customer_item_prices * - 공급업체: supplier_mng → supplier_item_mapping → supplier_item_prices */ import { getPool } from "../database/db"; import { logger } from "../utils/logger"; // ================================ // 인터페이스 정의 // ================================ /** 테이블 계층 레벨 설정 */ export interface TableLevel { tableName: string; label: string; parentFkColumn?: string; parentRefColumn?: string; upsertMode: "upsert" | "insert"; upsertKeyColumns?: string[]; columns: ColumnDef[]; } /** 컬럼 정의 */ export interface ColumnDef { dbColumn: string; excelHeader: string; required: boolean; defaultValue?: any; } /** 업로드 모드 정의 */ export interface UploadMode { id: string; label: string; description: string; activeLevels: number[]; } /** 테이블 체인 설정 (범용) */ export interface TableChainConfig { id: string; name: string; description: string; levels: TableLevel[]; uploadModes: UploadMode[]; } /** 레벨별 업로드 결과 */ export interface LevelResult { tableName: string; inserted: number; updated: number; } /** 전체 업로드 결과 */ export interface MultiTableUploadResult { success: boolean; results: LevelResult[]; totalRows: number; errors: string[]; } // ================================ // 서비스 클래스 // ================================ class MultiTableExcelService { /** * 다중 테이블 엑셀 업로드 실행 * * @param config 테이블 체인 설정 * @param modeId 업로드 모드 ID * @param rows 엑셀에서 파싱된 플랫 JSON 배열 (excelHeader 기준) * @param companyCode 회사 코드 * @param userId 사용자 ID */ async uploadMultiTable( config: TableChainConfig, modeId: string, rows: Record[], companyCode: string, userId: string ): Promise { const result: MultiTableUploadResult = { success: false, results: [], totalRows: rows.length, errors: [], }; const mode = config.uploadModes.find((m) => m.id === modeId); if (!mode) { result.errors.push(`업로드 모드를 찾을 수 없습니다: ${modeId}`); return result; } const activeLevels = mode.activeLevels .map((i) => config.levels[i]) .filter(Boolean); if (activeLevels.length === 0) { result.errors.push("활성화된 테이블 레벨이 없습니다."); return result; } // 레벨별 결과 초기화 for (const level of activeLevels) { result.results.push({ tableName: level.tableName, inserted: 0, updated: 0, }); } const pool = getPool(); const client = await pool.connect(); try { await client.query("BEGIN"); // 각 레벨의 실제 DB 컬럼 존재 여부 캐시 const existingColsCache = new Map>(); for (const level of activeLevels) { const colsResult = await client.query( `SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1`, [level.tableName] ); existingColsCache.set( level.tableName, new Set(colsResult.rows.map((r: any) => r.column_name)) ); } // 엑셀 헤더 → DB 컬럼 매핑 테이블 구축 (활성 레벨만) const headerToColumn = new Map(); for (let i = 0; i < activeLevels.length; i++) { for (const col of activeLevels[i].columns) { headerToColumn.set(col.excelHeader, { levelIndex: i, dbColumn: col.dbColumn, }); } } // 행 단위로 처리 (트랜잭션 내) // 부모 ID 캐시: 각 레벨에서 upsertKey → 반환된 PK 매핑 const pkCaches: Map[] = activeLevels.map( () => new Map() ); for (let rowIdx = 0; rowIdx < rows.length; rowIdx++) { const row = rows[rowIdx]; try { let parentId: string | number | null = null; let parentLevelData: Record = {}; for (let lvlIdx = 0; lvlIdx < activeLevels.length; lvlIdx++) { const level = activeLevels[lvlIdx]; const levelResult = result.results[lvlIdx]; const existingCols = existingColsCache.get(level.tableName)!; const levelData: Record = {}; for (const colDef of level.columns) { const excelValue = row[colDef.excelHeader]; if (excelValue !== undefined && excelValue !== null && excelValue !== "") { levelData[colDef.dbColumn] = excelValue; } else if (colDef.defaultValue !== undefined) { levelData[colDef.dbColumn] = colDef.defaultValue; } } const hasAnyData = Object.keys(levelData).length > 0; if (!hasAnyData && lvlIdx > 0) { break; } const missingRequired = level.columns .filter((c) => c.required && !levelData[c.dbColumn]) .map((c) => c.excelHeader); if (missingRequired.length > 0) { result.errors.push( `[행 ${rowIdx + 1}] ${level.label} 필수 컬럼 누락: ${missingRequired.join(", ")}` ); break; } // 부모 FK 주입: parentRefColumn이 'id'가 아닌 경우 부모 데이터에서 해당 컬럼 값 사용 if (lvlIdx > 0 && level.parentFkColumn && parentId !== null) { if ( level.parentRefColumn && level.parentRefColumn !== "id" && parentLevelData[level.parentRefColumn] !== undefined && parentLevelData[level.parentRefColumn] !== null ) { levelData[level.parentFkColumn] = String( parentLevelData[level.parentRefColumn] ); } else { levelData[level.parentFkColumn] = String(parentId); } } if (existingCols.has("company_code")) { levelData.company_code = companyCode; } if (existingCols.has("writer")) { levelData.writer = userId; } const upsertKey = level.upsertKeyColumns ? level.upsertKeyColumns.map((k) => String(levelData[k] ?? "")).join("|||") : null; let returnedId: string | number; if (level.upsertMode === "upsert" && upsertKey) { const cachedId = pkCaches[lvlIdx].get(upsertKey); if (cachedId !== undefined) { returnedId = cachedId; } else { returnedId = await this.upsertRow( client, level, levelData, existingCols, companyCode, levelResult ); pkCaches[lvlIdx].set(upsertKey, returnedId); } } else { returnedId = await this.insertRow( client, level.tableName, levelData, existingCols ); levelResult.inserted++; } parentId = returnedId; parentLevelData = { ...levelData }; } } catch (error: any) { result.errors.push(`[행 ${rowIdx + 1}] 처리 실패: ${error.message}`); logger.error(`[행 ${rowIdx + 1}] 처리 실패:`, error); } } await client.query("COMMIT"); result.success = result.errors.length === 0 || result.results.some((r) => r.inserted + r.updated > 0); logger.info("다중 테이블 엑셀 업로드 완료:", { results: result.results, errors: result.errors.length, }); } catch (error: any) { await client.query("ROLLBACK"); result.errors.push(`트랜잭션 실패: ${error.message}`); logger.error("다중 테이블 엑셀 업로드 트랜잭션 실패:", error); } finally { client.release(); } return result; } /** * UPSERT 행 처리 (존재하면 UPDATE, 없으면 INSERT) * @returns 해당 행의 PK (id) */ private async upsertRow( client: any, level: TableLevel, data: Record, existingCols: Set, companyCode: string, levelResult: LevelResult ): Promise { const { tableName, upsertKeyColumns } = level; if (!upsertKeyColumns || upsertKeyColumns.length === 0) { const id = await this.insertRow(client, tableName, data, existingCols); levelResult.inserted++; return id; } // UPSERT 키로 기존 행 조회 const whereClause = upsertKeyColumns .map((col, i) => `"${col}" = $${i + 1}`) .join(" AND "); const companyIdx = upsertKeyColumns.length + 1; const companyWhere = existingCols.has("company_code") ? ` AND company_code = $${companyIdx}` : ""; const params = upsertKeyColumns.map((col) => data[col]); if (existingCols.has("company_code")) { params.push(companyCode); } const existing = await client.query( `SELECT id FROM "${tableName}" WHERE ${whereClause}${companyWhere} LIMIT 1`, params ); if (existing.rows.length > 0) { // UPDATE const existingId = existing.rows[0].id; const skipCols = new Set([ "id", "company_code", "created_date", ...upsertKeyColumns, ]); const updateKeys = Object.keys(data).filter( (k) => !skipCols.has(k) && existingCols.has(k) ); if (updateKeys.length > 0) { const setClauses = updateKeys.map((k, i) => `"${k}" = $${i + 1}`); const setValues = updateKeys.map((k) => data[k]); const updatedDateClause = existingCols.has("updated_date") ? `, updated_date = NOW()` : ""; await client.query( `UPDATE "${tableName}" SET ${setClauses.join(", ")}${updatedDateClause} WHERE id = $${setValues.length + 1}`, [...setValues, existingId] ); } levelResult.updated++; return existingId; } else { // INSERT const id = await this.insertRow(client, tableName, data, existingCols); levelResult.inserted++; return id; } } /** * 단순 INSERT 후 PK(id) 반환 */ private async insertRow( client: any, tableName: string, data: Record, existingCols: Set ): Promise { // DB에 실제 존재하는 컬럼만 필터 const cols = Object.keys(data).filter( (k) => existingCols.has(k) && k !== "id" && data[k] !== undefined ); const hasCreatedDate = existingCols.has("created_date"); const colList = hasCreatedDate ? [...cols, "created_date"] : cols; const placeholders = cols.map((_, i) => `$${i + 1}`); const valList = hasCreatedDate ? [...placeholders, "NOW()"] : placeholders; const values = cols.map((k) => data[k]); const result = await client.query( `INSERT INTO "${tableName}" (${colList.map((c) => `"${c}"`).join(", ")}) VALUES (${valList.join(", ")}) RETURNING id`, values ); return result.rows[0].id; } /** * 모드에 맞는 템플릿 데이터 생성 (엑셀 다운로드용) */ generateTemplateData( config: TableChainConfig, modeId: string ): { headers: string[]; requiredHeaders: string[]; sampleRow: Record } { const mode = config.uploadModes.find((m) => m.id === modeId); if (!mode) { throw new Error(`업로드 모드를 찾을 수 없습니다: ${modeId}`); } const headers: string[] = []; const requiredHeaders: string[] = []; const sampleRow: Record = {}; for (const levelIdx of mode.activeLevels) { const level = config.levels[levelIdx]; if (!level) continue; for (const col of level.columns) { headers.push(col.excelHeader); if (col.required) { requiredHeaders.push(col.excelHeader); } sampleRow[col.excelHeader] = col.required ? `(필수)` : ""; } } return { headers, requiredHeaders, sampleRow }; } // ================================ // 자동 감지 // ================================ // 엑셀 업로드에서 제외할 시스템 컬럼 private static SYSTEM_COLUMNS = new Set([ "id", "company_code", "writer", "created_date", "updated_date", "created_at", "updated_at", ]); /** * 루트 테이블에서 자식/손자 테이블을 자동 탐색하여 * TableChainConfig를 실시간으로 생성한다. * * @param screenId 화면 ID (선택). 제공 시 관련 화면의 테이블을 참고하여 * 다수 자식 중 올바른 체인을 선택한다. */ async autoDetectTableChain( rootTableName: string, companyCode: string, screenId?: number ): Promise { const pool = getPool(); // 1) 루트 테이블이 존재하는지 확인 const tableCheck = await pool.query( `SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = $1`, [rootTableName] ); if (tableCheck.rows.length === 0) { throw new Error(`테이블이 존재하지 않습니다: ${rootTableName}`); } // 2) 화면 컨텍스트에서 관련 테이블 세트 + 보이는 컬럼 추출 let contextTables = new Set(); let visibleColumnsMap = new Map>(); if (screenId) { const ctx = await this.getScreenContext(pool, screenId, companyCode); contextTables = ctx.contextTables; visibleColumnsMap = ctx.visibleColumns; } logger.info("자동 감지 시작", { rootTableName, screenId, contextTables: [...contextTables], visibleColumnsPerTable: Object.fromEntries( [...visibleColumnsMap].map(([k, v]) => [k, [...v]]) ), }); // 3) 루트부터 재귀적으로 자식 탐색 (최대 3레벨) const levels: TableLevel[] = []; await this.buildLevelChain(pool, rootTableName, companyCode, levels, 0, 3, contextTables); // 3.5) 화면 레이아웃에서 보이는 컬럼으로 필터링 if (visibleColumnsMap.size > 0) { for (const level of levels) { const visibleCols = visibleColumnsMap.get(level.tableName); if (visibleCols && visibleCols.size > 0) { const before = level.columns.length; level.columns = level.columns.filter((col) => visibleCols.has(col.dbColumn)); logger.info(`컬럼 필터링: ${level.tableName}`, { before, after: level.columns.length, visibleCols: [...visibleCols], }); } } } // 4) 업로드 모드 자동 생성 const uploadModes: UploadMode[] = []; for (let depth = 0; depth < levels.length; depth++) { const activeLevels = Array.from({ length: depth + 1 }, (_, i) => i); const labels = activeLevels.map((i) => levels[i].label); uploadModes.push({ id: `auto_mode_${depth}`, label: labels.join(" + "), description: `${labels.join(", ")} 일괄 등록`, activeLevels, }); } // 5) 라벨 생성 const rootLabel = await this.getTableLabel(pool, rootTableName, companyCode); return { id: rootTableName, name: rootLabel, description: `${rootLabel} 다중 테이블 엑셀 업로드 (자동 감지)`, levels, uploadModes, }; } /** * 재귀적으로 테이블 계층 빌드 * contextTables: 화면의 관련 테이블 세트 (자식 선택 시 우선순위에 사용) */ private async buildLevelChain( pool: any, tableName: string, companyCode: string, levels: TableLevel[], depth: number, maxDepth: number, contextTables: Set ): Promise { if (depth >= maxDepth) return; const columns = await this.getTableColumns(pool, tableName, companyCode); const { upsertMode, upsertKeyColumns } = await this.detectUpsertKeys( pool, tableName ); const label = await this.getTableLabel(pool, tableName, companyCode); let parentFkColumn: string | undefined; let parentRefColumn: string | undefined; if (depth > 0 && levels.length > 0) { const parentTable = levels[depth - 1].tableName; const fkInfo = await this.findFkColumn(pool, tableName, parentTable, companyCode); if (fkInfo) { parentFkColumn = fkInfo.fkColumn; parentRefColumn = fkInfo.refColumn; } } const level: TableLevel = { tableName, label, upsertMode, upsertKeyColumns: upsertMode === "upsert" ? upsertKeyColumns : undefined, columns, ...(parentFkColumn ? { parentFkColumn, parentRefColumn } : {}), }; levels.push(level); const childTables = await this.findChildTables(pool, tableName, companyCode); if (childTables.length > 0) { const bestChild = this.pickBestChild(tableName, childTables, contextTables); await this.buildLevelChain(pool, bestChild, companyCode, levels, depth + 1, maxDepth, contextTables); } } /** * 여러 자식 테이블 중 가장 관련성 높은 하나를 선택 * * 우선순위: * 1. 화면 컨텍스트 테이블에 포함된 자식 (같은 화면 관련 모달이 사용하는 테이블) * 2. 부모 테이블명의 접두사를 공유하는 자식 * 3. 첫 번째 자식 */ private pickBestChild( parentTable: string, children: string[], contextTables: Set ): string { if (children.length === 1) return children[0]; // 1순위: 화면 컨텍스트 테이블에 있는 자식 if (contextTables.size > 0) { const contextMatch = children.find((c) => contextTables.has(c)); if (contextMatch) { logger.info(`pickBestChild: 화면 컨텍스트 매칭 - ${contextMatch}`, { parentTable, candidates: children, }); return contextMatch; } } // 2순위: 부모 테이블의 접두사 매칭 (예: customer_mng → customer_item_mapping) const parentPrefix = parentTable.split("_")[0]; const prefixMatch = children.find((c) => c.startsWith(parentPrefix + "_")); if (prefixMatch) return prefixMatch; return children[0]; } /** * 화면 ID로부터 관련 화면 정보를 통합 추출: * 1) contextTables: 자식 테이블 선택에 사용할 관련 테이블 세트 * 2) visibleColumns: 각 테이블별 화면에서 실제 사용 중인 컬럼 세트 * * 추출 전략: * A) 화면 레이아웃에서 모달 참조(targetScreenId, modalScreenId)를 재귀 추적 * B) 화면명 키워드로 관련 화면 검색 * C) 같은 테이블의 등록/수정 폼 검색 */ private async getScreenContext( pool: any, screenId: number, companyCode: string ): Promise<{ contextTables: Set; visibleColumns: Map>; }> { const emptyResult = { contextTables: new Set(), visibleColumns: new Map>(), }; const screenResult = await pool.query( `SELECT screen_name, table_name FROM screen_definitions WHERE screen_id = $1`, [screenId] ); if (screenResult.rows.length === 0) return emptyResult; const screenName: string = screenResult.rows[0].screen_name; const rootTable: string = screenResult.rows[0].table_name; const companyPrefix = screenName.split(/\s+/)[0] || ""; const contextTables = new Set(); const collectedScreenIds = new Set(); // ─── A) 화면명 키워드로 관련 화면 검색 (자식 테이블 선택용) ─── const suffixWords = new Set(["화면", "모달", "폼", "페이지"]); const nameTokens = screenName.split(/\s+/); const middleTokens = nameTokens.filter((t, i) => { if (i === 0) return false; if (suffixWords.has(t)) return false; return t.length >= 2; }); for (const token of middleTokens) { const relatedResult = await pool.query( `SELECT screen_id, table_name FROM screen_definitions WHERE screen_name LIKE $1 AND table_name IS NOT NULL`, [`%${token}%`] ); for (const row of relatedResult.rows) { if (row.table_name && row.table_name !== rootTable) { contextTables.add(row.table_name); } collectedScreenIds.add(row.screen_id); } } // ─── B) 모달 참조 체인 재귀 추적 (컬럼 추출용) ─── await this.collectModalChain(pool, screenId, collectedScreenIds, 0, 4); // ─── C) 수집된 테이블의 등록/수정/입력 폼도 포함 (컬럼 추출용) ─── const allTablesInChain = new Set([rootTable]); if (collectedScreenIds.size > 0) { const idArr = [...collectedScreenIds]; const ph = idArr.map((_, i) => `$${i + 1}`).join(","); const tableResult = await pool.query( `SELECT DISTINCT table_name FROM screen_definitions WHERE screen_id IN (${ph}) AND table_name IS NOT NULL`, idArr ); for (const row of tableResult.rows) { allTablesInChain.add(row.table_name); } } for (const tbl of allTablesInChain) { const formResult = await pool.query( `SELECT sd.screen_id FROM screen_definitions sd JOIN screen_layouts_v3 lv ON sd.screen_id = lv.screen_id WHERE sd.table_name = $1 AND sd.screen_name LIKE $2 AND (sd.screen_name LIKE '%등록%' OR sd.screen_name LIKE '%수정%' OR sd.screen_name LIKE '%입력%')`, [tbl, `${companyPrefix}%`] ); for (const row of formResult.rows) { collectedScreenIds.add(row.screen_id); } } // ─── 수집된 화면들의 layout_data에서 테이블별 컬럼 추출 ─── const visibleColumns = new Map>(); if (collectedScreenIds.size > 0) { const screenIdArray = [...collectedScreenIds]; const placeholders = screenIdArray.map((_, i) => `$${i + 1}`).join(","); const layoutResult = await pool.query( `SELECT sd.screen_id, sd.table_name, lv.layout_data FROM screen_definitions sd JOIN screen_layouts_v3 lv ON sd.screen_id = lv.screen_id WHERE sd.screen_id IN (${placeholders}) AND lv.layout_data IS NOT NULL`, screenIdArray ); for (const row of layoutResult.rows) { const tableName = row.table_name; if (!tableName) continue; if (tableName !== rootTable) { contextTables.add(tableName); } if (!visibleColumns.has(tableName)) { visibleColumns.set(tableName, new Set()); } const colSet = visibleColumns.get(tableName)!; this.extractColumnsFromLayout(row.layout_data, colSet); } } logger.info("화면 컨텍스트 추출", { screenId, screenName, collectedScreenCount: collectedScreenIds.size, contextTables: [...contextTables], visibleColumnsPerTable: Object.fromEntries( [...visibleColumns].map(([k, v]) => [k, [...v]]) ), }); return { contextTables, visibleColumns }; } /** * 화면 레이아웃에서 모달 참조(targetScreenId, modalScreenId)를 재귀적으로 추적 * 최대 depth까지 모달 → 서브모달 → ... 체인을 따라감 */ private async collectModalChain( pool: any, screenId: number, collected: Set, depth: number, maxDepth: number ): Promise { if (depth >= maxDepth || collected.has(screenId)) return; collected.add(screenId); const layoutResult = await pool.query( `SELECT lv.layout_data FROM screen_layouts_v3 lv WHERE lv.screen_id = $1 AND lv.layout_data IS NOT NULL`, [screenId] ); if (layoutResult.rows.length === 0) return; const layoutData = layoutResult.rows[0].layout_data; const referencedIds = new Set(); this.extractModalReferences(layoutData, referencedIds); for (const refId of referencedIds) { await this.collectModalChain(pool, refId, collected, depth + 1, maxDepth); } } /** * layout_data에서 targetScreenId, modalScreenId 값을 추출 */ private extractModalReferences(obj: any, refs: Set): void { if (!obj || typeof obj !== "object") return; if (Array.isArray(obj)) { for (const item of obj) { if (item && typeof item === "object") { this.extractModalReferences(item, refs); } } return; } for (const key of ["targetScreenId", "modalScreenId"]) { const val = obj[key]; if (val !== undefined && val !== null) { const num = Number(val); if (!isNaN(num) && num > 0) { refs.add(num); } } } for (const key of Object.keys(obj)) { const val = obj[key]; if (val && typeof val === "object") { this.extractModalReferences(val, refs); } } } /** * layout_data JSON에서 사용 중인 컬럼명을 재귀적으로 추출 * - columnName 속성 (v2-input, v2-select 등) * - columns[].name 속성 (테이블 리스트, 분할 패널) * - dot notation (supplier_mng.supplier_name) 은 JOIN이므로 제외 */ private extractColumnsFromLayout( layoutData: any, colSet: Set ): void { if (!layoutData || typeof layoutData !== "object") return; if (Array.isArray(layoutData)) { for (const item of layoutData) { if (item && typeof item === "object") { this.extractColumnsFromLayout(item, colSet); } } return; } // columnName 속성 (폼 필드) const cn = layoutData.columnName; if (cn && typeof cn === "string" && cn.trim() && !cn.includes(".")) { colSet.add(cn); } // columns 배열 (리스트/테이블) const cols = layoutData.columns; if (Array.isArray(cols)) { for (const col of cols) { if (col && typeof col === "object") { const name = col.name; if (name && typeof name === "string" && !name.includes(".")) { colSet.add(name); } } } } // 재귀 탐색 for (const key of Object.keys(layoutData)) { const val = layoutData[key]; if (val && typeof val === "object") { this.extractColumnsFromLayout(val, colSet); } } } /** * 특정 테이블을 참조하는 자식 테이블 목록 찾기 * 1차: table_type_columns의 reference_table * 2차: pg_constraint FK */ private async findChildTables( pool: any, parentTable: string, companyCode: string ): Promise { // 1) table_type_columns에서 이 테이블을 reference_table로 가진 다른 테이블 검색 const ttcResult = await pool.query( `SELECT DISTINCT table_name FROM table_type_columns WHERE reference_table = $1 AND table_name != $1 AND company_code IN ($2, '*') ORDER BY table_name`, [parentTable, companyCode] ); if (ttcResult.rows.length > 0) { return ttcResult.rows.map((r: any) => r.table_name); } // 2) fallback: pg_constraint FK const fkResult = await pool.query( `SELECT DISTINCT c2.relname AS child_table FROM pg_constraint con JOIN pg_class c1 ON con.confrelid = c1.oid JOIN pg_class c2 ON con.conrelid = c2.oid JOIN pg_namespace ns ON c2.relnamespace = ns.oid WHERE ns.nspname = 'public' AND c1.relname = $1 AND con.contype = 'f' AND c2.relname != $1 ORDER BY c2.relname`, [parentTable] ); return fkResult.rows.map((r: any) => r.child_table); } /** * 자식 테이블에서 부모 테이블을 참조하는 FK 컬럼 찾기 */ private async findFkColumn( pool: any, childTable: string, parentTable: string, companyCode: string ): Promise<{ fkColumn: string; refColumn: string } | null> { // 1) table_type_columns const ttcResult = await pool.query( `SELECT column_name, reference_column FROM table_type_columns WHERE table_name = $1 AND reference_table = $2 AND company_code IN ($3, '*') ORDER BY CASE WHEN company_code = '*' THEN 1 ELSE 0 END LIMIT 1`, [childTable, parentTable, companyCode] ); if (ttcResult.rows.length > 0) { return { fkColumn: ttcResult.rows[0].column_name, refColumn: ttcResult.rows[0].reference_column || "id", }; } // 2) pg_constraint FK const fkResult = await pool.query( `SELECT a_child.attname AS fk_column, a_parent.attname AS ref_column FROM pg_constraint con JOIN pg_class c_child ON con.conrelid = c_child.oid JOIN pg_class c_parent ON con.confrelid = c_parent.oid JOIN pg_namespace ns ON c_child.relnamespace = ns.oid JOIN pg_attribute a_child ON a_child.attrelid = c_child.oid AND a_child.attnum = ANY(con.conkey) JOIN pg_attribute a_parent ON a_parent.attrelid = c_parent.oid AND a_parent.attnum = ANY(con.confkey) WHERE ns.nspname = 'public' AND c_child.relname = $1 AND c_parent.relname = $2 AND con.contype = 'f' LIMIT 1`, [childTable, parentTable] ); if (fkResult.rows.length > 0) { return { fkColumn: fkResult.rows[0].fk_column, refColumn: fkResult.rows[0].ref_column, }; } return null; } /** * 테이블의 컬럼 목록을 가져와 ColumnDef 배열로 변환 * 시스템 컬럼과 FK 컬럼은 제외 */ private async getTableColumns( pool: any, tableName: string, companyCode: string ): Promise { const result = await pool.query( `SELECT c.column_name, c.is_nullable, c.column_default, COALESCE(ttc.column_label, cl.column_label) AS column_label, COALESCE(ttc.reference_table, cl.reference_table) AS reference_table FROM information_schema.columns c LEFT JOIN table_type_columns cl ON c.table_name = cl.table_name AND c.column_name = cl.column_name AND cl.company_code = '*' LEFT JOIN table_type_columns ttc ON c.table_name = ttc.table_name AND c.column_name = ttc.column_name AND ttc.company_code = $2 WHERE c.table_schema = 'public' AND c.table_name = $1 ORDER BY c.ordinal_position`, [tableName, companyCode] ); const columns: ColumnDef[] = []; for (const row of result.rows) { const colName: string = row.column_name; // 시스템 컬럼 제외 if (MultiTableExcelService.SYSTEM_COLUMNS.has(colName)) continue; // FK 컬럼 제외 (reference_table이 있는 컬럼 = 다른 테이블의 PK를 참조) // 단, 비즈니스적으로 의미 있는 FK는 남길 수 있으므로, // _id로 끝나면서 reference_table이 있는 경우만 제외 if (row.reference_table && colName.endsWith("_id")) continue; const hasDefault = row.column_default !== null; const isNullable = row.is_nullable === "YES"; const isRequired = !isNullable && !hasDefault; columns.push({ dbColumn: colName, excelHeader: row.column_label || colName, required: isRequired, }); } return columns; } /** * UPSERT 키 감지: UNIQUE 제약조건 → 없으면 insert 모드 * company_code가 포함된 UNIQUE 제약조건에서 company_code를 제외한 컬럼 사용 */ private async detectUpsertKeys( pool: any, tableName: string ): Promise<{ upsertMode: "upsert" | "insert"; upsertKeyColumns: string[] }> { const result = await pool.query( `SELECT con.conname, array_agg(a.attname ORDER BY x.n) AS columns FROM pg_constraint con JOIN pg_class c ON con.conrelid = c.oid JOIN pg_namespace ns ON c.relnamespace = ns.oid CROSS JOIN LATERAL unnest(con.conkey) WITH ORDINALITY AS x(attnum, n) JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = x.attnum WHERE ns.nspname = 'public' AND c.relname = $1 AND con.contype = 'u' GROUP BY con.conname ORDER BY con.conname LIMIT 1`, [tableName] ); if (result.rows.length > 0) { let rawCols = result.rows[0].columns; // pg 드라이버가 array_agg를 문자열로 반환할 수 있음 if (typeof rawCols === "string") { rawCols = rawCols.replace(/[{}]/g, "").split(",").map((s: string) => s.trim()); } const cols: string[] = (rawCols as string[]).filter( (c: string) => c !== "company_code" ); if (cols.length > 0) { return { upsertMode: "upsert", upsertKeyColumns: cols }; } } return { upsertMode: "insert", upsertKeyColumns: [] }; } /** * 테이블의 한글 라벨 가져오기 */ private async getTableLabel( pool: any, tableName: string, companyCode: string ): Promise { const result = await pool.query( `SELECT COALESCE( (SELECT table_label FROM table_labels WHERE table_name = $1 LIMIT 1), $1 ) AS label`, [tableName] ); return result.rows[0]?.label || tableName; } } export const multiTableExcelService = new MultiTableExcelService();