import { getPool } from "../database/db"; import { logger } from "../utils/logger"; import { getSiblingMenuObjids } from "./menuService"; import { TableCategoryValue, CategoryColumn, } from "../types/tableCategoryValue"; class TableCategoryValueService { /** * 테이블의 카테고리 타입 컬럼 목록 조회 */ async getCategoryColumns( tableName: string, companyCode: string ): Promise { try { logger.info("카테고리 컬럼 목록 조회", { tableName, companyCode }); const pool = getPool(); // 멀티테넌시: 최고 관리자만 company_code="*" 데이터를 볼 수 있음 let query: string; if (companyCode === "*") { // 최고 관리자: 모든 카테고리 값 조회 query = ` SELECT tc.table_name AS "tableName", tc.column_name AS "columnName", tc.column_name AS "columnLabel", COUNT(cv.value_id) AS "valueCount" FROM table_type_columns tc LEFT JOIN table_column_category_values cv ON tc.table_name = cv.table_name AND tc.column_name = cv.column_name AND cv.is_active = true WHERE tc.table_name = $1 AND tc.input_type = 'category' GROUP BY tc.table_name, tc.column_name, tc.display_order ORDER BY tc.display_order, tc.column_name `; logger.info("최고 관리자 카테고리 컬럼 조회"); } else { // 일반 회사: 자신의 카테고리 값만 조회 query = ` SELECT tc.table_name AS "tableName", tc.column_name AS "columnName", tc.column_name AS "columnLabel", COUNT(cv.value_id) AS "valueCount" FROM table_type_columns tc LEFT JOIN table_column_category_values cv ON tc.table_name = cv.table_name AND tc.column_name = cv.column_name AND cv.is_active = true AND cv.company_code = $2 WHERE tc.table_name = $1 AND tc.input_type = 'category' GROUP BY tc.table_name, tc.column_name, tc.display_order ORDER BY tc.display_order, tc.column_name `; logger.info("회사별 카테고리 컬럼 조회", { companyCode }); } // 쿼리 파라미터는 company_code에 따라 다름 const params = companyCode === "*" ? [tableName] : [tableName, companyCode]; const result = await pool.query(query, params); logger.info(`카테고리 컬럼 ${result.rows.length}개 조회 완료`, { tableName, companyCode, }); return result.rows; } catch (error: any) { logger.error(`카테고리 컬럼 조회 실패: ${error.message}`); throw error; } } /** * 모든 테이블의 카테고리 컬럼 목록 조회 (Select 옵션 설정용) * 테이블 선택 없이 등록된 모든 카테고리 컬럼을 조회합니다. */ async getAllCategoryColumns( companyCode: string ): Promise { try { logger.info("전체 카테고리 컬럼 목록 조회", { companyCode }); const pool = getPool(); let query: string; let params: any[]; if (companyCode === "*") { // 최고 관리자: 모든 카테고리 컬럼 조회 (중복 제거) query = ` SELECT tc.table_name AS "tableName", tc.column_name AS "columnName", tc.column_name AS "columnLabel", COALESCE(cv_count.cnt, 0) AS "valueCount" FROM ( SELECT DISTINCT table_name, column_name, MIN(display_order) as display_order FROM table_type_columns WHERE input_type = 'category' GROUP BY table_name, column_name ) tc LEFT JOIN ( SELECT table_name, column_name, COUNT(*) as cnt FROM table_column_category_values WHERE is_active = true GROUP BY table_name, column_name ) cv_count ON tc.table_name = cv_count.table_name AND tc.column_name = cv_count.column_name ORDER BY tc.table_name, tc.display_order, tc.column_name `; params = []; } else { // 일반 회사: 자신의 카테고리 값만 카운트 (중복 제거) query = ` SELECT tc.table_name AS "tableName", tc.column_name AS "columnName", tc.column_name AS "columnLabel", COALESCE(cv_count.cnt, 0) AS "valueCount" FROM ( SELECT DISTINCT table_name, column_name, MIN(display_order) as display_order FROM table_type_columns WHERE input_type = 'category' GROUP BY table_name, column_name ) tc LEFT JOIN ( SELECT table_name, column_name, COUNT(*) as cnt FROM table_column_category_values WHERE is_active = true AND company_code = $1 GROUP BY table_name, column_name ) cv_count ON tc.table_name = cv_count.table_name AND tc.column_name = cv_count.column_name ORDER BY tc.table_name, tc.display_order, tc.column_name `; params = [companyCode]; } const result = await pool.query(query, params); logger.info(`전체 카테고리 컬럼 ${result.rows.length}개 조회 완료`, { companyCode, }); return result.rows; } catch (error: any) { logger.error(`전체 카테고리 컬럼 조회 실패: ${error.message}`); throw error; } } /** * 특정 컬럼의 카테고리 값 목록 조회 (메뉴 스코프) * * 메뉴 스코프 규칙: * - menuObjid가 제공되면 해당 메뉴와 형제 메뉴의 카테고리 값을 조회 * - menuObjid가 없으면 테이블 스코프로 동작 (하위 호환성) */ async getCategoryValues( tableName: string, columnName: string, companyCode: string, includeInactive: boolean = false, menuObjid?: number ): Promise { try { logger.info("카테고리 값 목록 조회 (메뉴 스코프)", { tableName, columnName, companyCode, includeInactive, menuObjid, }); const pool = getPool(); // 1. 메뉴 스코프: 형제 메뉴 OBJID 조회 let siblingObjids: number[] = []; if (menuObjid) { siblingObjids = await getSiblingMenuObjids(menuObjid); logger.info("형제 메뉴 OBJID 목록", { menuObjid, siblingObjids }); } // 2. 카테고리 값 조회 (메뉴 스코프 또는 형제 메뉴 포함) let query: string; let params: any[]; const baseSelect = ` SELECT value_id AS "valueId", table_name AS "tableName", column_name AS "columnName", value_code AS "valueCode", value_label AS "valueLabel", value_order AS "valueOrder", parent_value_id AS "parentValueId", depth, description, color, icon, is_active AS "isActive", is_default AS "isDefault", company_code AS "companyCode", menu_objid AS "menuObjid", created_at AS "createdAt", updated_at AS "updatedAt", created_by AS "createdBy", updated_by AS "updatedBy" FROM table_column_category_values WHERE table_name = $1 AND column_name = $2 `; if (companyCode === "*") { // 최고 관리자: menuObjid가 있으면 해당 메뉴(및 형제 메뉴)의 값만 조회 if (menuObjid && siblingObjids.length > 0) { query = baseSelect + ` AND menu_objid = ANY($3::numeric[])`; params = [tableName, columnName, siblingObjids]; logger.info("최고 관리자 메뉴 스코프 카테고리 값 조회", { menuObjid, siblingObjids }); } else if (menuObjid) { query = baseSelect + ` AND menu_objid = $3`; params = [tableName, columnName, menuObjid]; logger.info("최고 관리자 단일 메뉴 카테고리 값 조회", { menuObjid }); } else { // menuObjid 없으면 모든 값 조회 (중복 가능) query = baseSelect; params = [tableName, columnName]; logger.info("최고 관리자 전체 카테고리 값 조회 (menuObjid 없음)"); } } else { // 일반 회사: 자신의 회사 + menuObjid로 필터링 if (menuObjid && siblingObjids.length > 0) { query = baseSelect + ` AND company_code = $3 AND menu_objid = ANY($4::numeric[])`; params = [tableName, columnName, companyCode, siblingObjids]; logger.info("회사별 메뉴 스코프 카테고리 값 조회", { companyCode, menuObjid, siblingObjids }); } else if (menuObjid) { query = baseSelect + ` AND company_code = $3 AND menu_objid = $4`; params = [tableName, columnName, companyCode, menuObjid]; logger.info("회사별 단일 메뉴 카테고리 값 조회", { companyCode, menuObjid }); } else { // menuObjid 없으면 회사 전체 조회 (중복 가능하지만 회사별로 제한) query = baseSelect + ` AND company_code = $3`; params = [tableName, columnName, companyCode]; logger.info("회사별 카테고리 값 조회 (menuObjid 없음)", { companyCode }); } } if (!includeInactive) { query += ` AND is_active = true`; } query += ` ORDER BY value_order, value_label`; const result = await pool.query(query, params); // 계층 구조로 변환 const values = this.buildHierarchy(result.rows); logger.info(`카테고리 값 ${result.rows.length}개 조회 완료`, { tableName, columnName, companyCode, menuObjid, scopeType: menuObjid ? "menu" : "table", }); return values; } catch (error: any) { logger.error(`카테고리 값 조회 실패: ${error.message}`); throw error; } } /** * 카테고리 값 추가 (메뉴 스코프) * * @param value 카테고리 값 정보 * @param companyCode 회사 코드 * @param userId 생성자 ID * @param menuObjid 메뉴 OBJID (필수) */ async addCategoryValue( value: TableCategoryValue, companyCode: string, userId: string, menuObjid: number ): Promise { const pool = getPool(); try { logger.info("카테고리 값 추가 (메뉴 스코프)", { tableName: value.tableName, columnName: value.columnName, valueCode: value.valueCode, menuObjid, companyCode, }); // 중복 코드 체크 (멀티테넌시 + 메뉴 스코프) let duplicateQuery: string; let duplicateParams: any[]; if (companyCode === "*") { // 최고 관리자: 모든 회사에서 중복 체크 duplicateQuery = ` SELECT value_id FROM table_column_category_values WHERE table_name = $1 AND column_name = $2 AND value_code = $3 AND menu_objid = $4 `; duplicateParams = [value.tableName, value.columnName, value.valueCode, menuObjid]; } else { // 일반 회사: 자신의 회사에서만 중복 체크 duplicateQuery = ` SELECT value_id FROM table_column_category_values WHERE table_name = $1 AND column_name = $2 AND value_code = $3 AND menu_objid = $4 AND company_code = $5 `; duplicateParams = [value.tableName, value.columnName, value.valueCode, menuObjid, companyCode]; } const duplicateResult = await pool.query(duplicateQuery, duplicateParams); if (duplicateResult.rows.length > 0) { throw new Error("이미 존재하는 코드입니다"); } const insertQuery = ` INSERT INTO table_column_category_values ( table_name, column_name, value_code, value_label, value_order, parent_value_id, depth, description, color, icon, is_active, is_default, company_code, menu_objid, created_by ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING value_id AS "valueId", table_name AS "tableName", column_name AS "columnName", value_code AS "valueCode", value_label AS "valueLabel", value_order AS "valueOrder", parent_value_id AS "parentValueId", depth, description, color, icon, is_active AS "isActive", is_default AS "isDefault", company_code AS "companyCode", menu_objid AS "menuObjid", created_at AS "createdAt", created_by AS "createdBy" `; const result = await pool.query(insertQuery, [ value.tableName, value.columnName, value.valueCode, value.valueLabel, value.valueOrder || 0, value.parentValueId || null, value.depth || 1, value.description || null, value.color || null, value.icon || null, value.isActive !== false, value.isDefault || false, companyCode, menuObjid, // ← 메뉴 OBJID 저장 userId, ]); logger.info("카테고리 값 추가 완료", { valueId: result.rows[0].valueId, tableName: value.tableName, columnName: value.columnName, menuObjid, }); return result.rows[0]; } catch (error: any) { logger.error(`카테고리 값 추가 실패: ${error.message}`); throw error; } } /** * 카테고리 값 수정 */ async updateCategoryValue( valueId: number, updates: Partial, companyCode: string, userId: string ): Promise { const pool = getPool(); try { const setClauses: string[] = []; const values: any[] = []; let paramIndex = 1; if (updates.valueLabel !== undefined) { setClauses.push(`value_label = $${paramIndex++}`); values.push(updates.valueLabel); } if (updates.valueOrder !== undefined) { setClauses.push(`value_order = $${paramIndex++}`); values.push(updates.valueOrder); } if (updates.description !== undefined) { setClauses.push(`description = $${paramIndex++}`); values.push(updates.description); } if (updates.color !== undefined) { setClauses.push(`color = $${paramIndex++}`); values.push(updates.color); } if (updates.icon !== undefined) { setClauses.push(`icon = $${paramIndex++}`); values.push(updates.icon); } if (updates.isActive !== undefined) { setClauses.push(`is_active = $${paramIndex++}`); values.push(updates.isActive); } if (updates.isDefault !== undefined) { setClauses.push(`is_default = $${paramIndex++}`); values.push(updates.isDefault); } setClauses.push(`updated_at = NOW()`); setClauses.push(`updated_by = $${paramIndex++}`); values.push(userId); // 멀티테넌시: 최고 관리자는 company_code 조건 제외 let updateQuery: string; if (companyCode === "*") { // 최고 관리자: 모든 카테고리 값 수정 가능 values.push(valueId); updateQuery = ` UPDATE table_column_category_values SET ${setClauses.join(", ")} WHERE value_id = $${paramIndex++} RETURNING value_id AS "valueId", table_name AS "tableName", column_name AS "columnName", value_code AS "valueCode", value_label AS "valueLabel", value_order AS "valueOrder", description, color, icon, is_active AS "isActive", is_default AS "isDefault", company_code AS "companyCode", updated_at AS "updatedAt", updated_by AS "updatedBy" `; } else { // 일반 회사: 자신의 카테고리 값만 수정 가능 values.push(valueId, companyCode); updateQuery = ` UPDATE table_column_category_values SET ${setClauses.join(", ")} WHERE value_id = $${paramIndex++} AND company_code = $${paramIndex++} RETURNING value_id AS "valueId", table_name AS "tableName", column_name AS "columnName", value_code AS "valueCode", value_label AS "valueLabel", value_order AS "valueOrder", description, color, icon, is_active AS "isActive", is_default AS "isDefault", company_code AS "companyCode", updated_at AS "updatedAt", updated_by AS "updatedBy" `; } const result = await pool.query(updateQuery, values); if (result.rowCount === 0) { throw new Error("카테고리 값을 찾을 수 없습니다"); } logger.info("카테고리 값 수정 완료", { valueId, companyCode }); return result.rows[0]; } catch (error: any) { logger.error(`카테고리 값 수정 실패: ${error.message}`); throw error; } } /** * 카테고리 값 사용 여부 확인 * 실제 데이터 테이블에서 해당 카테고리 값이 사용되고 있는지 확인 */ async checkCategoryValueUsage( valueId: number, companyCode: string ): Promise<{ isUsed: boolean; usedInTables: any[]; totalCount: number }> { const pool = getPool(); try { logger.info("카테고리 값 사용 여부 확인", { valueId, companyCode }); // 1. 카테고리 값 정보 조회 let valueQuery: string; let valueParams: any[]; if (companyCode === "*") { valueQuery = ` SELECT table_name, column_name, value_code FROM table_column_category_values WHERE value_id = $1 `; valueParams = [valueId]; } else { valueQuery = ` SELECT table_name, column_name, value_code FROM table_column_category_values WHERE value_id = $1 AND company_code = $2 `; valueParams = [valueId, companyCode]; } const valueResult = await pool.query(valueQuery, valueParams); if (valueResult.rowCount === 0) { throw new Error("카테고리 값을 찾을 수 없습니다"); } const { table_name, column_name, value_code } = valueResult.rows[0]; // 2. 실제 데이터 테이블에서 사용 여부 확인 // 테이블이 존재하는지 먼저 확인 const tableExistsQuery = ` SELECT EXISTS ( SELECT FROM information_schema.tables WHERE table_schema = 'public' AND table_name = $1 ) as exists `; const tableExistsResult = await pool.query(tableExistsQuery, [table_name]); if (!tableExistsResult.rows[0].exists) { logger.info("테이블이 존재하지 않음", { table_name }); return { isUsed: false, usedInTables: [], totalCount: 0 }; } // 3. 해당 테이블에서 value_code를 사용하는 데이터 개수 확인 let dataCountQuery: string; let dataCountParams: any[]; if (companyCode === "*") { dataCountQuery = ` SELECT COUNT(*) as count FROM ${table_name} WHERE ${column_name} = $1 `; dataCountParams = [value_code]; } else { dataCountQuery = ` SELECT COUNT(*) as count FROM ${table_name} WHERE ${column_name} = $1 AND company_code = $2 `; dataCountParams = [value_code, companyCode]; } const dataCountResult = await pool.query(dataCountQuery, dataCountParams); const totalCount = parseInt(dataCountResult.rows[0].count); const isUsed = totalCount > 0; // 4. 사용 중인 메뉴 목록 조회 (해당 테이블을 사용하는 화면/메뉴) const menuQuery = ` SELECT DISTINCT mi.objid as menu_objid, mi.menu_name_kor as menu_name, mi.menu_url FROM menu_info mi INNER JOIN screen_menu_assignments sma ON sma.menu_objid = mi.objid INNER JOIN screen_definitions sd ON sd.screen_id = sma.screen_id WHERE sd.table_name = $1 AND mi.company_code = $2 ORDER BY mi.menu_name_kor `; const menuResult = await pool.query(menuQuery, [table_name, companyCode]); const usedInTables = menuResult.rows.map((row) => ({ menuObjid: row.menu_objid, menuName: row.menu_name, menuUrl: row.menu_url, tableName: table_name, columnName: column_name, })); logger.info("카테고리 값 사용 여부 확인 완료", { valueId, isUsed, totalCount, usedInMenusCount: usedInTables.length, }); return { isUsed, usedInTables, totalCount }; } catch (error: any) { logger.error(`카테고리 값 사용 여부 확인 실패: ${error.message}`); throw error; } } /** * 카테고리 값 삭제 (물리적 삭제) */ async deleteCategoryValue( valueId: number, companyCode: string, userId: string ): Promise { const pool = getPool(); try { // 1. 사용 여부 확인 const usage = await this.checkCategoryValueUsage(valueId, companyCode); if (usage.isUsed) { let errorMessage = "이 카테고리 값을 삭제할 수 없습니다.\n"; errorMessage += `\n현재 ${usage.totalCount}개의 데이터에서 사용 중입니다.`; if (usage.usedInTables.length > 0) { const menuNames = usage.usedInTables.map((t) => t.menuName).join(", "); errorMessage += `\n\n다음 메뉴에서 사용 중입니다:\n${menuNames}`; } errorMessage += "\n\n메뉴에서 사용하는 카테고리 항목을 수정한 후 다시 삭제해주세요."; throw new Error(errorMessage); } // 2. 하위 값 체크 (멀티테넌시 적용) let checkQuery: string; let checkParams: any[]; if (companyCode === "*") { // 최고 관리자: 모든 하위 값 체크 checkQuery = ` SELECT COUNT(*) as count FROM table_column_category_values WHERE parent_value_id = $1 `; checkParams = [valueId]; } else { // 일반 회사: 자신의 하위 값만 체크 checkQuery = ` SELECT COUNT(*) as count FROM table_column_category_values WHERE parent_value_id = $1 AND company_code = $2 `; checkParams = [valueId, companyCode]; } const checkResult = await pool.query(checkQuery, checkParams); if (parseInt(checkResult.rows[0].count) > 0) { throw new Error("하위 카테고리 값이 있어 삭제할 수 없습니다"); } // 3. 물리적 삭제 (멀티테넌시 적용) let deleteQuery: string; let deleteParams: any[]; if (companyCode === "*") { // 최고 관리자: 모든 카테고리 값 삭제 가능 deleteQuery = ` DELETE FROM table_column_category_values WHERE value_id = $1 `; deleteParams = [valueId]; } else { // 일반 회사: 자신의 카테고리 값만 삭제 가능 deleteQuery = ` DELETE FROM table_column_category_values WHERE value_id = $1 AND company_code = $2 `; deleteParams = [valueId, companyCode]; } const result = await pool.query(deleteQuery, deleteParams); if (result.rowCount === 0) { throw new Error("카테고리 값을 찾을 수 없거나 권한이 없습니다"); } logger.info("카테고리 값 삭제 완료", { valueId, companyCode, }); } catch (error: any) { logger.error(`카테고리 값 삭제 실패: ${error.message}`); throw error; } } /** * 카테고리 값 일괄 삭제 */ async bulkDeleteCategoryValues( valueIds: number[], companyCode: string, userId: string ): Promise { const pool = getPool(); try { // 멀티테넌시 적용 let deleteQuery: string; let deleteParams: any[]; if (companyCode === "*") { // 최고 관리자: 모든 카테고리 값 일괄 삭제 가능 deleteQuery = ` UPDATE table_column_category_values SET is_active = false, updated_at = NOW(), updated_by = $2 WHERE value_id = ANY($1::int[]) `; deleteParams = [valueIds, userId]; } else { // 일반 회사: 자신의 카테고리 값만 일괄 삭제 가능 deleteQuery = ` UPDATE table_column_category_values SET is_active = false, updated_at = NOW(), updated_by = $3 WHERE value_id = ANY($1::int[]) AND company_code = $2 `; deleteParams = [valueIds, companyCode, userId]; } await pool.query(deleteQuery, deleteParams); logger.info("카테고리 값 일괄 삭제 완료", { count: valueIds.length, companyCode, }); } catch (error: any) { logger.error(`카테고리 값 일괄 삭제 실패: ${error.message}`); throw error; } } /** * 카테고리 값 순서 변경 */ async reorderCategoryValues( orderedValueIds: number[], companyCode: string ): Promise { const pool = getPool(); const client = await pool.connect(); try { await client.query("BEGIN"); for (let i = 0; i < orderedValueIds.length; i++) { // 멀티테넌시 적용 let updateQuery: string; let updateParams: any[]; if (companyCode === "*") { // 최고 관리자: 모든 카테고리 값 순서 변경 가능 updateQuery = ` UPDATE table_column_category_values SET value_order = $1, updated_at = NOW() WHERE value_id = $2 `; updateParams = [i + 1, orderedValueIds[i]]; } else { // 일반 회사: 자신의 카테고리 값만 순서 변경 가능 updateQuery = ` UPDATE table_column_category_values SET value_order = $1, updated_at = NOW() WHERE value_id = $2 AND company_code = $3 `; updateParams = [i + 1, orderedValueIds[i], companyCode]; } await client.query(updateQuery, updateParams); } await client.query("COMMIT"); logger.info("카테고리 값 순서 변경 완료", { count: orderedValueIds.length, companyCode, }); } catch (error: any) { await client.query("ROLLBACK"); logger.error(`카테고리 값 순서 변경 실패: ${error.message}`); throw error; } finally { client.release(); } } /** * 계층 구조 변환 헬퍼 */ private buildHierarchy( values: TableCategoryValue[], parentId: number | null = null ): TableCategoryValue[] { return values .filter((v) => v.parentValueId === parentId) .map((v) => ({ ...v, children: this.buildHierarchy(values, v.valueId!), })); } // ================================================ // 컬럼 매핑 관련 메서드 (논리명 ↔ 물리명) // ================================================ /** * 컬럼 매핑 조회 * * @param tableName - 테이블명 * @param menuObjid - 메뉴 OBJID * @param companyCode - 회사 코드 * @returns { logical_column: physical_column } 형태의 매핑 객체 */ async getColumnMapping( tableName: string, menuObjid: number, companyCode: string ): Promise> { const pool = getPool(); try { logger.info("컬럼 매핑 조회", { tableName, menuObjid, companyCode }); // 멀티테넌시 적용 let query: string; let params: any[]; if (companyCode === "*") { // 최고 관리자: 모든 매핑 조회 가능 query = ` SELECT logical_column_name AS "logicalColumnName", physical_column_name AS "physicalColumnName" FROM category_column_mapping WHERE table_name = $1 AND menu_objid = $2 `; params = [tableName, menuObjid]; } else { // 일반 회사: 자신의 매핑만 조회 query = ` SELECT logical_column_name AS "logicalColumnName", physical_column_name AS "physicalColumnName" FROM category_column_mapping WHERE table_name = $1 AND menu_objid = $2 AND company_code = $3 `; params = [tableName, menuObjid, companyCode]; } const result = await pool.query(query, params); // { logical_column: physical_column } 형태로 변환 const mapping: Record = {}; result.rows.forEach((row: any) => { mapping[row.logicalColumnName] = row.physicalColumnName; }); logger.info(`컬럼 매핑 ${Object.keys(mapping).length}개 조회 완료`, { tableName, menuObjid, companyCode, }); return mapping; } catch (error: any) { logger.error(`컬럼 매핑 조회 실패: ${error.message}`); throw error; } } /** * 컬럼 매핑 생성/수정 * * @param tableName - 테이블명 * @param logicalColumnName - 논리적 컬럼명 * @param physicalColumnName - 물리적 컬럼명 * @param menuObjid - 메뉴 OBJID * @param companyCode - 회사 코드 * @param userId - 사용자 ID * @param description - 설명 (선택사항) */ async createColumnMapping( tableName: string, logicalColumnName: string, physicalColumnName: string, menuObjid: number, companyCode: string, userId: string, description?: string ): Promise { const pool = getPool(); try { logger.info("컬럼 매핑 생성", { tableName, logicalColumnName, physicalColumnName, menuObjid, companyCode, }); // 1. 물리적 컬럼이 실제로 존재하는지 확인 const columnCheckQuery = ` SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1 AND column_name = $2 `; const columnCheck = await pool.query(columnCheckQuery, [ tableName, physicalColumnName, ]); if (columnCheck.rowCount === 0) { throw new Error( `테이블 ${tableName}에 컬럼 ${physicalColumnName}이(가) 존재하지 않습니다` ); } // 2. 매핑 저장 (UPSERT) const insertQuery = ` INSERT INTO category_column_mapping ( table_name, logical_column_name, physical_column_name, menu_objid, company_code, description, created_by, updated_by ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT (table_name, logical_column_name, menu_objid, company_code) DO UPDATE SET physical_column_name = EXCLUDED.physical_column_name, description = EXCLUDED.description, updated_at = NOW(), updated_by = EXCLUDED.updated_by RETURNING * `; const result = await pool.query(insertQuery, [ tableName, logicalColumnName, physicalColumnName, menuObjid, companyCode, description || null, userId, userId, ]); logger.info("컬럼 매핑 생성 완료", { mappingId: result.rows[0].mapping_id, tableName, logicalColumnName, physicalColumnName, }); return result.rows[0]; } catch (error: any) { logger.error(`컬럼 매핑 생성 실패: ${error.message}`); throw error; } } /** * 논리적 컬럼 목록 조회 * * @param tableName - 테이블명 * @param menuObjid - 메뉴 OBJID * @param companyCode - 회사 코드 * @returns 논리적 컬럼 목록 */ async getLogicalColumns( tableName: string, menuObjid: number, companyCode: string ): Promise { const pool = getPool(); try { logger.info("논리적 컬럼 목록 조회", { tableName, menuObjid, companyCode, }); // 멀티테넌시 적용 let query: string; let params: any[]; if (companyCode === "*") { // 최고 관리자: 모든 논리적 컬럼 조회 query = ` SELECT mapping_id AS "mappingId", logical_column_name AS "logicalColumnName", physical_column_name AS "physicalColumnName", description FROM category_column_mapping WHERE table_name = $1 AND menu_objid = $2 ORDER BY logical_column_name `; params = [tableName, menuObjid]; } else { // 일반 회사: 자신의 논리적 컬럼만 조회 query = ` SELECT mapping_id AS "mappingId", logical_column_name AS "logicalColumnName", physical_column_name AS "physicalColumnName", description FROM category_column_mapping WHERE table_name = $1 AND menu_objid = $2 AND company_code = $3 ORDER BY logical_column_name `; params = [tableName, menuObjid, companyCode]; } const result = await pool.query(query, params); logger.info(`논리적 컬럼 ${result.rows.length}개 조회 완료`, { tableName, menuObjid, companyCode, }); return result.rows; } catch (error: any) { logger.error(`논리적 컬럼 목록 조회 실패: ${error.message}`); throw error; } } /** * 컬럼 매핑 삭제 * * @param mappingId - 매핑 ID * @param companyCode - 회사 코드 */ async deleteColumnMapping( mappingId: number, companyCode: string ): Promise { const pool = getPool(); try { logger.info("컬럼 매핑 삭제", { mappingId, companyCode }); // 멀티테넌시 적용 let deleteQuery: string; let deleteParams: any[]; if (companyCode === "*") { // 최고 관리자: 모든 매핑 삭제 가능 deleteQuery = ` DELETE FROM category_column_mapping WHERE mapping_id = $1 `; deleteParams = [mappingId]; } else { // 일반 회사: 자신의 매핑만 삭제 가능 deleteQuery = ` DELETE FROM category_column_mapping WHERE mapping_id = $1 AND company_code = $2 `; deleteParams = [mappingId, companyCode]; } const result = await pool.query(deleteQuery, deleteParams); if (result.rowCount === 0) { throw new Error("컬럼 매핑을 찾을 수 없거나 권한이 없습니다"); } logger.info("컬럼 매핑 삭제 완료", { mappingId, companyCode }); } catch (error: any) { logger.error(`컬럼 매핑 삭제 실패: ${error.message}`); throw error; } } /** * 테이블+컬럼 기준으로 모든 매핑 삭제 * * 메뉴 선택 변경 시 기존 매핑을 모두 삭제하고 새로운 매핑만 추가하기 위해 사용 * * @param tableName - 테이블명 * @param columnName - 컬럼명 * @param companyCode - 회사 코드 * @returns 삭제된 매핑 수 */ async deleteColumnMappingsByColumn( tableName: string, columnName: string, companyCode: string ): Promise { const pool = getPool(); try { logger.info("테이블+컬럼 기준 매핑 삭제", { tableName, columnName, companyCode }); // 멀티테넌시 적용 let deleteQuery: string; let deleteParams: any[]; if (companyCode === "*") { // 최고 관리자: 해당 테이블+컬럼의 모든 매핑 삭제 deleteQuery = ` DELETE FROM category_column_mapping WHERE table_name = $1 AND logical_column_name = $2 `; deleteParams = [tableName, columnName]; } else { // 일반 회사: 자신의 매핑만 삭제 deleteQuery = ` DELETE FROM category_column_mapping WHERE table_name = $1 AND logical_column_name = $2 AND company_code = $3 `; deleteParams = [tableName, columnName, companyCode]; } const result = await pool.query(deleteQuery, deleteParams); const deletedCount = result.rowCount || 0; logger.info("테이블+컬럼 기준 매핑 삭제 완료", { tableName, columnName, companyCode, deletedCount }); return deletedCount; } catch (error: any) { logger.error(`테이블+컬럼 기준 매핑 삭제 실패: ${error.message}`); throw error; } } /** * 논리적 컬럼명을 물리적 컬럼명으로 변환 * * 데이터 저장 시 사용 * * @param tableName - 테이블명 * @param menuObjid - 메뉴 OBJID * @param companyCode - 회사 코드 * @param data - 논리적 컬럼명으로 된 데이터 * @returns 물리적 컬럼명으로 변환된 데이터 */ async convertToPhysicalColumns( tableName: string, menuObjid: number, companyCode: string, data: Record ): Promise> { try { // 컬럼 매핑 조회 const mapping = await this.getColumnMapping(tableName, menuObjid, companyCode); // 논리적 컬럼명 → 물리적 컬럼명 변환 const physicalData: Record = {}; for (const [key, value] of Object.entries(data)) { const physicalColumn = mapping[key] || key; // 매핑 없으면 원래 이름 사용 physicalData[physicalColumn] = value; } logger.info("컬럼명 변환 완료", { tableName, menuObjid, logicalColumns: Object.keys(data), physicalColumns: Object.keys(physicalData), }); return physicalData; } catch (error: any) { logger.error(`컬럼명 변환 실패: ${error.message}`); // 매핑이 없으면 원본 데이터 그대로 반환 return data; } } /** * 2레벨 메뉴 목록 조회 * * 카테고리 컬럼 매핑 생성 시 메뉴 선택용 * * @param companyCode - 회사 코드 * @returns 2레벨 메뉴 목록 */ async getSecondLevelMenus(companyCode: string): Promise { const pool = getPool(); try { logger.info("2레벨 메뉴 목록 조회", { companyCode }); // menu_info 테이블에 company_code 컬럼이 있는지 확인 const columnCheckQuery = ` SELECT column_name FROM information_schema.columns WHERE table_name = 'menu_info' AND column_name = 'company_code' `; const columnCheck = await pool.query(columnCheckQuery); const hasCompanyCode = columnCheck.rows.length > 0; logger.info("menu_info 테이블 company_code 컬럼 존재 여부", { hasCompanyCode }); let query: string; let params: any[]; if (!hasCompanyCode) { // company_code 컬럼이 없는 경우: 모든 2레벨 사용자 메뉴 조회 query = ` SELECT m1.objid as "menuObjid", m1.menu_name_kor as "menuName", m0.menu_name_kor as "parentMenuName", m1.screen_code as "screenCode" FROM menu_info m1 INNER JOIN menu_info m0 ON m1.parent_obj_id = m0.objid WHERE m1.menu_type = 1 AND m1.status = 'active' AND m0.parent_obj_id = 0 ORDER BY m0.seq, m1.seq `; params = []; } else if (companyCode === "*") { // 최고 관리자: 모든 회사의 2레벨 사용자 메뉴 조회 query = ` SELECT m1.objid as "menuObjid", m1.menu_name_kor as "menuName", m0.menu_name_kor as "parentMenuName", m1.screen_code as "screenCode" FROM menu_info m1 INNER JOIN menu_info m0 ON m1.parent_obj_id = m0.objid WHERE m1.menu_type = 1 AND m1.status = 'active' AND m0.parent_obj_id = 0 ORDER BY m0.seq, m1.seq `; params = []; } else { // 일반 회사: 자신의 회사 메뉴만 조회 (공통 메뉴 제외) query = ` SELECT m1.objid as "menuObjid", m1.menu_name_kor as "menuName", m0.menu_name_kor as "parentMenuName", m1.screen_code as "screenCode" FROM menu_info m1 INNER JOIN menu_info m0 ON m1.parent_obj_id = m0.objid WHERE m1.menu_type = 1 AND m1.status = 'active' AND m0.parent_obj_id = 0 AND m1.company_code = $1 ORDER BY m0.seq, m1.seq `; params = [companyCode]; } const result = await pool.query(query, params); logger.info(`2레벨 메뉴 ${result.rows.length}개 조회 완료`, { companyCode }); return result.rows; } catch (error: any) { logger.error(`2레벨 메뉴 목록 조회 실패: ${error.message}`, { error }); throw error; } } /** * 카테고리 코드로 라벨 조회 * * @param valueCodes - 카테고리 코드 배열 * @param companyCode - 회사 코드 * @returns { [code]: label } 형태의 매핑 객체 */ async getCategoryLabelsByCodes( valueCodes: string[], companyCode: string ): Promise> { try { if (!valueCodes || valueCodes.length === 0) { return {}; } logger.info("카테고리 코드로 라벨 조회", { valueCodes, companyCode }); const pool = getPool(); // 동적으로 파라미터 플레이스홀더 생성 const placeholders = valueCodes.map((_, i) => `$${i + 1}`).join(", "); let query: string; let params: any[]; if (companyCode === "*") { // 최고 관리자: 모든 카테고리 값 조회 query = ` SELECT value_code, value_label FROM table_column_category_values WHERE value_code IN (${placeholders}) AND is_active = true `; params = valueCodes; } else { // 일반 회사: 자신의 카테고리 값 + 공통 카테고리 값 조회 query = ` SELECT value_code, value_label FROM table_column_category_values WHERE value_code IN (${placeholders}) AND is_active = true AND (company_code = $${valueCodes.length + 1} OR company_code = '*') `; params = [...valueCodes, companyCode]; } const result = await pool.query(query, params); // { [code]: label } 형태로 변환 const labels: Record = {}; for (const row of result.rows) { labels[row.value_code] = row.value_label; } logger.info(`카테고리 라벨 ${Object.keys(labels).length}개 조회 완료`, { companyCode }); return labels; } catch (error: any) { logger.error(`카테고리 코드로 라벨 조회 실패: ${error.message}`, { error }); throw error; } } /** * 테이블의 카테고리 타입 컬럼과 해당 값 매핑 조회 (라벨 → 코드 변환용) * * 엑셀 업로드 등에서 라벨 값을 코드 값으로 변환할 때 사용 * * @param tableName - 테이블명 * @param companyCode - 회사 코드 * @returns { [columnName]: { [label]: code } } 형태의 매핑 객체 */ async getCategoryLabelToCodeMapping( tableName: string, companyCode: string ): Promise>> { try { logger.info("카테고리 라벨→코드 매핑 조회", { tableName, companyCode }); const pool = getPool(); // 1. 해당 테이블의 카테고리 타입 컬럼 조회 const categoryColumnsQuery = ` SELECT column_name FROM table_type_columns WHERE table_name = $1 AND input_type = 'category' `; const categoryColumnsResult = await pool.query(categoryColumnsQuery, [tableName]); if (categoryColumnsResult.rows.length === 0) { logger.info("카테고리 타입 컬럼 없음", { tableName }); return {}; } const categoryColumns = categoryColumnsResult.rows.map(row => row.column_name); logger.info(`카테고리 컬럼 ${categoryColumns.length}개 발견`, { categoryColumns }); // 2. 각 카테고리 컬럼의 라벨→코드 매핑 조회 const result: Record> = {}; for (const columnName of categoryColumns) { let query: string; let params: any[]; if (companyCode === "*") { // 최고 관리자: 모든 카테고리 값 조회 query = ` SELECT value_code, value_label FROM table_column_category_values WHERE table_name = $1 AND column_name = $2 AND is_active = true `; params = [tableName, columnName]; } else { // 일반 회사: 자신의 카테고리 값 + 공통 카테고리 값 조회 query = ` SELECT value_code, value_label FROM table_column_category_values WHERE table_name = $1 AND column_name = $2 AND is_active = true AND (company_code = $3 OR company_code = '*') `; params = [tableName, columnName, companyCode]; } const valuesResult = await pool.query(query, params); // { [label]: code } 형태로 변환 const labelToCodeMap: Record = {}; for (const row of valuesResult.rows) { // 라벨을 소문자로 변환하여 대소문자 구분 없이 매핑 labelToCodeMap[row.value_label] = row.value_code; // 소문자 키도 추가 (대소문자 무시 검색용) labelToCodeMap[row.value_label.toLowerCase()] = row.value_code; } if (Object.keys(labelToCodeMap).length > 0) { result[columnName] = labelToCodeMap; logger.info(`컬럼 ${columnName}의 라벨→코드 매핑 ${valuesResult.rows.length}개 조회`); } } logger.info(`카테고리 라벨→코드 매핑 조회 완료`, { tableName, columnCount: Object.keys(result).length }); return result; } catch (error: any) { logger.error(`카테고리 라벨→코드 매핑 조회 실패: ${error.message}`, { error }); throw error; } } /** * 데이터의 카테고리 라벨 값을 코드 값으로 변환 * * 엑셀 업로드 등에서 사용자가 입력한 라벨 값을 DB 저장용 코드 값으로 변환 * * @param tableName - 테이블명 * @param companyCode - 회사 코드 * @param data - 변환할 데이터 객체 * @returns 라벨이 코드로 변환된 데이터 객체 */ async convertCategoryLabelsToCodesForData( tableName: string, companyCode: string, data: Record ): Promise<{ convertedData: Record; conversions: Array<{ column: string; label: string; code: string }> }> { try { // 라벨→코드 매핑 조회 const labelToCodeMapping = await this.getCategoryLabelToCodeMapping(tableName, companyCode); if (Object.keys(labelToCodeMapping).length === 0) { // 카테고리 컬럼 없음 return { convertedData: data, conversions: [] }; } const convertedData = { ...data }; const conversions: Array<{ column: string; label: string; code: string }> = []; for (const [columnName, labelCodeMap] of Object.entries(labelToCodeMapping)) { const value = data[columnName]; if (value !== undefined && value !== null && value !== "") { const stringValue = String(value).trim(); // 다중 값 확인 (쉼표로 구분된 경우) if (stringValue.includes(",")) { // 다중 카테고리 값 처리 const labels = stringValue.split(",").map(s => s.trim()).filter(s => s !== ""); const convertedCodes: string[] = []; let allConverted = true; for (const label of labels) { // 정확한 라벨 매칭 시도 let matchedCode = labelCodeMap[label]; // 대소문자 무시 매칭 if (!matchedCode) { matchedCode = labelCodeMap[label.toLowerCase()]; } if (matchedCode) { convertedCodes.push(matchedCode); conversions.push({ column: columnName, label: label, code: matchedCode, }); logger.info(`카테고리 라벨→코드 변환 (다중): ${columnName} "${label}" → "${matchedCode}"`); } else { // 이미 코드값인지 확인 const isAlreadyCode = Object.values(labelCodeMap).includes(label); if (isAlreadyCode) { // 이미 코드값이면 그대로 사용 convertedCodes.push(label); } else { // 라벨도 코드도 아니면 원래 값 유지 convertedCodes.push(label); allConverted = false; logger.warn(`카테고리 값 매핑 없음 (다중): ${columnName} = "${label}" (라벨도 코드도 아님)`); } } } // 변환된 코드들을 쉼표로 합쳐서 저장 convertedData[columnName] = convertedCodes.join(","); logger.info(`다중 카테고리 변환 완료: ${columnName} "${stringValue}" → "${convertedData[columnName]}"`); } else { // 단일 값 처리 // 정확한 라벨 매칭 시도 let matchedCode = labelCodeMap[stringValue]; // 대소문자 무시 매칭 if (!matchedCode) { matchedCode = labelCodeMap[stringValue.toLowerCase()]; } if (matchedCode) { // 라벨 값을 코드 값으로 변환 convertedData[columnName] = matchedCode; conversions.push({ column: columnName, label: stringValue, code: matchedCode, }); logger.info(`카테고리 라벨→코드 변환: ${columnName} "${stringValue}" → "${matchedCode}"`); } else { // 이미 코드값인지 확인 (역방향 확인) const isAlreadyCode = Object.values(labelCodeMap).includes(stringValue); if (!isAlreadyCode) { logger.warn(`카테고리 값 매핑 없음: ${columnName} = "${stringValue}" (라벨도 코드도 아님)`); } // 변환 없이 원래 값 유지 } } } } logger.info(`카테고리 라벨→코드 변환 완료`, { tableName, conversionCount: conversions.length, conversions, }); return { convertedData, conversions }; } catch (error: any) { logger.error(`카테고리 라벨→코드 변환 실패: ${error.message}`, { error }); // 실패 시 원본 데이터 반환 return { convertedData: data, conversions: [] }; } } } export default new TableCategoryValueService();