import { Response } from "express"; import { AuthenticatedRequest } from "../types/auth"; import { getPool } from "../database/db"; import { logger } from "../utils/logger"; /** * 엔티티 옵션 조회 API (UnifiedSelect용) * GET /api/entity/:tableName/options * * Query Params: * - value: 값 컬럼 (기본: id) * - label: 표시 컬럼 (기본: name) */ export async function getEntityOptions(req: AuthenticatedRequest, res: Response) { try { const { tableName } = req.params; const { value = "id", label = "name" } = req.query; // tableName 유효성 검증 if (!tableName || tableName === "undefined" || tableName === "null") { logger.warn("엔티티 옵션 조회 실패: 테이블명이 없음", { tableName }); return res.status(400).json({ success: false, message: "테이블명이 지정되지 않았습니다.", }); } const companyCode = req.user!.companyCode; const pool = getPool(); // 테이블의 실제 컬럼 목록 조회 const columnsResult = await pool.query( `SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1`, [tableName] ); const existingColumns = new Set(columnsResult.rows.map((r: any) => r.column_name)); // 요청된 컬럼 검증 const valueColumn = existingColumns.has(value as string) ? value : "id"; const labelColumn = existingColumns.has(label as string) ? label : "name"; // 둘 다 없으면 에러 if (!existingColumns.has(valueColumn as string)) { return res.status(400).json({ success: false, message: `테이블 "${tableName}"에 값 컬럼 "${value}"이 존재하지 않습니다.`, }); } // label 컬럼이 없으면 value 컬럼을 label로도 사용 const effectiveLabelColumn = existingColumns.has(labelColumn as string) ? labelColumn : valueColumn; // WHERE 조건 (멀티테넌시) const whereConditions: string[] = []; const params: any[] = []; let paramIndex = 1; if (companyCode !== "*" && existingColumns.has("company_code")) { whereConditions.push(`company_code = $${paramIndex}`); params.push(companyCode); paramIndex++; } const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : ""; // 쿼리 실행 (최대 500개) const query = ` SELECT ${valueColumn} as value, ${effectiveLabelColumn} as label FROM ${tableName} ${whereClause} ORDER BY ${effectiveLabelColumn} ASC LIMIT 500 `; const result = await pool.query(query, params); logger.info("엔티티 옵션 조회 성공", { tableName, valueColumn, labelColumn: effectiveLabelColumn, companyCode, rowCount: result.rowCount, }); res.json({ success: true, data: result.rows, }); } catch (error: any) { logger.error("엔티티 옵션 조회 오류", { error: error.message, stack: error.stack, }); res.status(500).json({ success: false, message: error.message }); } } /** * 엔티티 검색 API * GET /api/entity-search/:tableName */ export async function searchEntity(req: AuthenticatedRequest, res: Response) { try { const { tableName } = req.params; const { searchText = "", searchFields = "", filterCondition = "{}", page = "1", limit = "20", } = req.query; // tableName 유효성 검증 if (!tableName || tableName === "undefined" || tableName === "null") { logger.warn("엔티티 검색 실패: 테이블명이 없음", { tableName }); return res.status(400).json({ success: false, message: "테이블명이 지정되지 않았습니다. 컴포넌트 설정에서 sourceTable을 확인해주세요.", }); } // 멀티테넌시 const companyCode = req.user!.companyCode; // 검색 필드 파싱 const requestedFields = searchFields ? (searchFields as string).split(",").map((f) => f.trim()) : []; // 🆕 테이블의 실제 컬럼 목록 조회 const pool = getPool(); const columnsResult = await pool.query( `SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1`, [tableName] ); const existingColumns = new Set(columnsResult.rows.map((r: any) => r.column_name)); // 🆕 존재하는 컬럼만 필터링 const fields = requestedFields.filter((field) => { if (existingColumns.has(field)) { return true; } else { logger.warn(`엔티티 검색: 테이블 "${tableName}"에 컬럼 "${field}"이(가) 존재하지 않아 제외`); return false; } }); const existingColumnsArray = Array.from(existingColumns); logger.info(`엔티티 검색 필드 확인 - 테이블: ${tableName}, 요청필드: [${requestedFields.join(", ")}], 유효필드: [${fields.join(", ")}], 테이블컬럼(샘플): [${existingColumnsArray.slice(0, 10).join(", ")}]`); // WHERE 조건 생성 const whereConditions: string[] = []; const params: any[] = []; let paramIndex = 1; // 멀티테넌시 필터링 if (companyCode !== "*") { // 🆕 company_code 컬럼이 있는 경우에만 필터링 if (existingColumns.has("company_code")) { whereConditions.push(`company_code = $${paramIndex}`); params.push(companyCode); paramIndex++; } } // 검색 조건 if (searchText) { // 유효한 검색 필드가 없으면 기본 텍스트 컬럼에서 검색 let searchableFields = fields; if (searchableFields.length === 0) { // 기본 검색 컬럼: name, code, description 등 일반적인 컬럼명 const defaultSearchColumns = [ 'name', 'code', 'description', 'title', 'label', 'item_name', 'item_code', 'item_number', 'equipment_name', 'equipment_code', 'inspection_item', 'consumable_name', // 소모품명 추가 'supplier_name', 'customer_name', 'product_name', ]; searchableFields = defaultSearchColumns.filter(col => existingColumns.has(col)); logger.info(`엔티티 검색: 기본 검색 필드 사용 - 테이블: ${tableName}, 검색필드: [${searchableFields.join(", ")}]`); } if (searchableFields.length > 0) { const searchConditions = searchableFields.map((field) => { const condition = `${field}::text ILIKE $${paramIndex}`; paramIndex++; return condition; }); whereConditions.push(`(${searchConditions.join(" OR ")})`); // 검색어 파라미터 추가 searchableFields.forEach(() => { params.push(`%${searchText}%`); }); } } // 추가 필터 조건 (존재하는 컬럼만) const additionalFilter = JSON.parse(filterCondition as string); for (const [key, value] of Object.entries(additionalFilter)) { if (existingColumns.has(key)) { whereConditions.push(`${key} = $${paramIndex}`); params.push(value); paramIndex++; } else { logger.warn("엔티티 검색: 필터 조건에 존재하지 않는 컬럼 제외", { tableName, key }); } } // 페이징 const offset = (parseInt(page as string) - 1) * parseInt(limit as string); const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : ""; // 쿼리 실행 (pool은 위에서 이미 선언됨) const countQuery = `SELECT COUNT(*) FROM ${tableName} ${whereClause}`; const dataQuery = ` SELECT * FROM ${tableName} ${whereClause} ORDER BY id DESC LIMIT $${paramIndex} OFFSET $${paramIndex + 1} `; params.push(parseInt(limit as string)); params.push(offset); const countResult = await pool.query( countQuery, params.slice(0, params.length - 2) ); const dataResult = await pool.query(dataQuery, params); logger.info("엔티티 검색 성공", { tableName, searchText, companyCode, rowCount: dataResult.rowCount, }); res.json({ success: true, data: dataResult.rows, pagination: { total: parseInt(countResult.rows[0].count), page: parseInt(page as string), limit: parseInt(limit as string), }, }); } catch (error: any) { logger.error("엔티티 검색 오류", { error: error.message, stack: error.stack, }); res.status(500).json({ success: false, message: error.message }); } }