import { Response } from "express"; import { AuthenticatedRequest } from "../types/auth"; import { getPool } from "../database/db"; import { logger } from "../utils/logger"; const pool = getPool(); /** * 연쇄 관계 목록 조회 */ export const getCascadingRelations = async ( req: AuthenticatedRequest, res: Response ) => { try { const companyCode = req.user?.companyCode || "*"; const { isActive } = req.query; let query = ` SELECT relation_id, relation_code, relation_name, description, parent_table, parent_value_column, parent_label_column, child_table, child_filter_column, child_value_column, child_label_column, child_order_column, child_order_direction, empty_parent_message, no_options_message, loading_message, clear_on_parent_change, company_code, is_active, created_by, created_date, updated_by, updated_date FROM cascading_relation WHERE 1=1 `; const params: any[] = []; let paramIndex = 1; // 멀티테넌시 필터링 // - 최고 관리자(company_code = "*"): 모든 데이터 조회 가능 // - 일반 회사: 자기 회사 데이터만 조회 (공통 데이터는 조회 불가) if (companyCode !== "*") { query += ` AND company_code = $${paramIndex}`; params.push(companyCode); paramIndex++; } // 활성 상태 필터링 if (isActive !== undefined) { query += ` AND is_active = $${paramIndex}`; params.push(isActive); paramIndex++; } query += ` ORDER BY relation_name ASC`; const result = await pool.query(query, params); logger.info("연쇄 관계 목록 조회", { companyCode, count: result.rowCount, }); return res.json({ success: true, data: result.rows, }); } catch (error: any) { logger.error("연쇄 관계 목록 조회 실패", { error: error.message }); return res.status(500).json({ success: false, message: "연쇄 관계 목록 조회에 실패했습니다.", error: error.message, }); } }; /** * 연쇄 관계 상세 조회 */ export const getCascadingRelationById = async ( req: AuthenticatedRequest, res: Response ) => { try { const { id } = req.params; const companyCode = req.user?.companyCode || "*"; let query = ` SELECT relation_id, relation_code, relation_name, description, parent_table, parent_value_column, parent_label_column, child_table, child_filter_column, child_value_column, child_label_column, child_order_column, child_order_direction, empty_parent_message, no_options_message, loading_message, clear_on_parent_change, company_code, is_active, created_by, created_date, updated_by, updated_date FROM cascading_relation WHERE relation_id = $1 `; const params: any[] = [id]; // 멀티테넌시 필터링 (company_code = "*"는 최고 관리자 전용) if (companyCode !== "*") { query += ` AND company_code = $2`; params.push(companyCode); } const result = await pool.query(query, params); if (result.rowCount === 0) { return res.status(404).json({ success: false, message: "연쇄 관계를 찾을 수 없습니다.", }); } return res.json({ success: true, data: result.rows[0], }); } catch (error: any) { logger.error("연쇄 관계 상세 조회 실패", { error: error.message }); return res.status(500).json({ success: false, message: "연쇄 관계 조회에 실패했습니다.", error: error.message, }); } }; /** * 연쇄 관계 코드로 조회 */ export const getCascadingRelationByCode = async ( req: AuthenticatedRequest, res: Response ) => { try { const { code } = req.params; const companyCode = req.user?.companyCode || "*"; let query = ` SELECT relation_id, relation_code, relation_name, description, parent_table, parent_value_column, parent_label_column, child_table, child_filter_column, child_value_column, child_label_column, child_order_column, child_order_direction, empty_parent_message, no_options_message, loading_message, clear_on_parent_change, company_code, is_active FROM cascading_relation WHERE relation_code = $1 AND is_active = 'Y' `; const params: any[] = [code]; // 멀티테넌시 필터링 (company_code = "*"는 최고 관리자 전용) if (companyCode !== "*") { query += ` AND company_code = $2`; params.push(companyCode); } query += ` LIMIT 1`; const result = await pool.query(query, params); if (result.rowCount === 0) { return res.status(404).json({ success: false, message: "연쇄 관계를 찾을 수 없습니다.", }); } return res.json({ success: true, data: result.rows[0], }); } catch (error: any) { logger.error("연쇄 관계 코드 조회 실패", { error: error.message }); return res.status(500).json({ success: false, message: "연쇄 관계 조회에 실패했습니다.", error: error.message, }); } }; /** * 연쇄 관계 생성 */ export const createCascadingRelation = async ( req: AuthenticatedRequest, res: Response ) => { try { const companyCode = req.user?.companyCode || "*"; const userId = req.user?.userId || "system"; const { relationCode, relationName, description, parentTable, parentValueColumn, parentLabelColumn, childTable, childFilterColumn, childValueColumn, childLabelColumn, childOrderColumn, childOrderDirection, emptyParentMessage, noOptionsMessage, loadingMessage, clearOnParentChange, } = req.body; // 필수 필드 검증 if ( !relationCode || !relationName || !parentTable || !parentValueColumn || !childTable || !childFilterColumn || !childValueColumn || !childLabelColumn ) { return res.status(400).json({ success: false, message: "필수 필드가 누락되었습니다.", }); } // 중복 코드 체크 const duplicateCheck = await pool.query( `SELECT relation_id FROM cascading_relation WHERE relation_code = $1 AND company_code = $2`, [relationCode, companyCode] ); if (duplicateCheck.rowCount && duplicateCheck.rowCount > 0) { return res.status(400).json({ success: false, message: "이미 존재하는 관계 코드입니다.", }); } const query = ` INSERT INTO cascading_relation ( relation_code, relation_name, description, parent_table, parent_value_column, parent_label_column, child_table, child_filter_column, child_value_column, child_label_column, child_order_column, child_order_direction, empty_parent_message, no_options_message, loading_message, clear_on_parent_change, company_code, is_active, created_by, created_date ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, 'Y', $18, CURRENT_TIMESTAMP) RETURNING * `; const result = await pool.query(query, [ relationCode, relationName, description || null, parentTable, parentValueColumn, parentLabelColumn || null, childTable, childFilterColumn, childValueColumn, childLabelColumn, childOrderColumn || null, childOrderDirection || "ASC", emptyParentMessage || "상위 항목을 먼저 선택하세요", noOptionsMessage || "선택 가능한 항목이 없습니다", loadingMessage || "로딩 중...", clearOnParentChange !== false ? "Y" : "N", companyCode, userId, ]); logger.info("연쇄 관계 생성", { relationId: result.rows[0].relation_id, relationCode, companyCode, userId, }); return res.status(201).json({ success: true, data: result.rows[0], message: "연쇄 관계가 생성되었습니다.", }); } catch (error: any) { logger.error("연쇄 관계 생성 실패", { error: error.message }); return res.status(500).json({ success: false, message: "연쇄 관계 생성에 실패했습니다.", error: error.message, }); } }; /** * 연쇄 관계 수정 */ export const updateCascadingRelation = async ( req: AuthenticatedRequest, res: Response ) => { try { const { id } = req.params; const companyCode = req.user?.companyCode || "*"; const userId = req.user?.userId || "system"; const { relationName, description, parentTable, parentValueColumn, parentLabelColumn, childTable, childFilterColumn, childValueColumn, childLabelColumn, childOrderColumn, childOrderDirection, emptyParentMessage, noOptionsMessage, loadingMessage, clearOnParentChange, isActive, } = req.body; // 권한 체크 const existingCheck = await pool.query( `SELECT relation_id, company_code FROM cascading_relation WHERE relation_id = $1`, [id] ); if (existingCheck.rowCount === 0) { return res.status(404).json({ success: false, message: "연쇄 관계를 찾을 수 없습니다.", }); } // 다른 회사의 데이터는 수정 불가 (최고 관리자 제외) const existingCompanyCode = existingCheck.rows[0].company_code; if ( companyCode !== "*" && existingCompanyCode !== companyCode && existingCompanyCode !== "*" ) { return res.status(403).json({ success: false, message: "수정 권한이 없습니다.", }); } const query = ` UPDATE cascading_relation SET relation_name = COALESCE($1, relation_name), description = COALESCE($2, description), parent_table = COALESCE($3, parent_table), parent_value_column = COALESCE($4, parent_value_column), parent_label_column = COALESCE($5, parent_label_column), child_table = COALESCE($6, child_table), child_filter_column = COALESCE($7, child_filter_column), child_value_column = COALESCE($8, child_value_column), child_label_column = COALESCE($9, child_label_column), child_order_column = COALESCE($10, child_order_column), child_order_direction = COALESCE($11, child_order_direction), empty_parent_message = COALESCE($12, empty_parent_message), no_options_message = COALESCE($13, no_options_message), loading_message = COALESCE($14, loading_message), clear_on_parent_change = COALESCE($15, clear_on_parent_change), is_active = COALESCE($16, is_active), updated_by = $17, updated_date = CURRENT_TIMESTAMP WHERE relation_id = $18 RETURNING * `; const result = await pool.query(query, [ relationName, description, parentTable, parentValueColumn, parentLabelColumn, childTable, childFilterColumn, childValueColumn, childLabelColumn, childOrderColumn, childOrderDirection, emptyParentMessage, noOptionsMessage, loadingMessage, clearOnParentChange !== undefined ? clearOnParentChange ? "Y" : "N" : null, isActive !== undefined ? (isActive ? "Y" : "N") : null, userId, id, ]); logger.info("연쇄 관계 수정", { relationId: id, companyCode, userId, }); return res.json({ success: true, data: result.rows[0], message: "연쇄 관계가 수정되었습니다.", }); } catch (error: any) { logger.error("연쇄 관계 수정 실패", { error: error.message }); return res.status(500).json({ success: false, message: "연쇄 관계 수정에 실패했습니다.", error: error.message, }); } }; /** * 연쇄 관계 삭제 */ export const deleteCascadingRelation = async ( req: AuthenticatedRequest, res: Response ) => { try { const { id } = req.params; const companyCode = req.user?.companyCode || "*"; const userId = req.user?.userId || "system"; // 권한 체크 const existingCheck = await pool.query( `SELECT relation_id, company_code FROM cascading_relation WHERE relation_id = $1`, [id] ); if (existingCheck.rowCount === 0) { return res.status(404).json({ success: false, message: "연쇄 관계를 찾을 수 없습니다.", }); } // 다른 회사의 데이터는 삭제 불가 (최고 관리자 제외) const existingCompanyCode = existingCheck.rows[0].company_code; if ( companyCode !== "*" && existingCompanyCode !== companyCode && existingCompanyCode !== "*" ) { return res.status(403).json({ success: false, message: "삭제 권한이 없습니다.", }); } // 소프트 삭제 (is_active = 'N') await pool.query( `UPDATE cascading_relation SET is_active = 'N', updated_by = $1, updated_date = CURRENT_TIMESTAMP WHERE relation_id = $2`, [userId, id] ); logger.info("연쇄 관계 삭제", { relationId: id, companyCode, userId, }); return res.json({ success: true, message: "연쇄 관계가 삭제되었습니다.", }); } catch (error: any) { logger.error("연쇄 관계 삭제 실패", { error: error.message }); return res.status(500).json({ success: false, message: "연쇄 관계 삭제에 실패했습니다.", error: error.message, }); } }; /** * 🆕 연쇄 관계로 부모 옵션 조회 (상위 선택 역할용) * parent_table에서 전체 옵션을 조회합니다. */ export const getParentOptions = async ( req: AuthenticatedRequest, res: Response ) => { try { const { code } = req.params; const companyCode = req.user?.companyCode || "*"; // 관계 정보 조회 let relationQuery = ` SELECT parent_table, parent_value_column, parent_label_column FROM cascading_relation WHERE relation_code = $1 AND is_active = 'Y' `; const relationParams: any[] = [code]; // 멀티테넌시 필터링 (company_code = "*"는 최고 관리자 전용) if (companyCode !== "*") { relationQuery += ` AND company_code = $2`; relationParams.push(companyCode); } relationQuery += ` LIMIT 1`; const relationResult = await pool.query(relationQuery, relationParams); if (relationResult.rowCount === 0) { return res.status(404).json({ success: false, message: "연쇄 관계를 찾을 수 없습니다.", }); } const relation = relationResult.rows[0]; // 라벨 컬럼이 없으면 값 컬럼 사용 const labelColumn = relation.parent_label_column || relation.parent_value_column; // 부모 옵션 조회 let optionsQuery = ` SELECT ${relation.parent_value_column} as value, ${labelColumn} as label FROM ${relation.parent_table} WHERE 1=1 `; // 멀티테넌시 적용 (테이블에 company_code가 있는 경우) const tableInfoResult = await pool.query( `SELECT column_name FROM information_schema.columns WHERE table_name = $1 AND column_name = 'company_code'`, [relation.parent_table] ); const optionsParams: any[] = []; // company_code = "*"는 최고 관리자 전용이므로 일반 회사는 자기 회사 데이터만 if ( tableInfoResult.rowCount && tableInfoResult.rowCount > 0 && companyCode !== "*" ) { optionsQuery += ` AND company_code = $1`; optionsParams.push(companyCode); } // status 컬럼이 있으면 활성 상태만 조회 const statusInfoResult = await pool.query( `SELECT column_name FROM information_schema.columns WHERE table_name = $1 AND column_name = 'status'`, [relation.parent_table] ); if (statusInfoResult.rowCount && statusInfoResult.rowCount > 0) { optionsQuery += ` AND (status IS NULL OR status != 'N')`; } // 정렬 optionsQuery += ` ORDER BY ${labelColumn} ASC`; const optionsResult = await pool.query(optionsQuery, optionsParams); logger.info("부모 옵션 조회", { relationCode: code, parentTable: relation.parent_table, optionsCount: optionsResult.rowCount, }); return res.json({ success: true, data: optionsResult.rows, }); } catch (error: any) { logger.error("부모 옵션 조회 실패", { error: error.message }); return res.status(500).json({ success: false, message: "부모 옵션 조회에 실패했습니다.", error: error.message, }); } }; /** * 연쇄 관계로 자식 옵션 조회 * 실제 연쇄 드롭다운에서 사용하는 API * * 다중 부모값 지원: * - parentValue: 단일 값 (예: "공정검사") * - parentValues: 다중 값 (예: "공정검사,출하검사" 또는 배열) */ export const getCascadingOptions = async ( req: AuthenticatedRequest, res: Response ) => { try { const { code } = req.params; const { parentValue, parentValues } = req.query; const companyCode = req.user?.companyCode || "*"; // 다중 부모값 파싱 let parentValueArray: string[] = []; if (parentValues) { // parentValues가 있으면 우선 사용 (다중 선택) if (Array.isArray(parentValues)) { parentValueArray = parentValues.map(v => String(v)); } else { // 콤마로 구분된 문자열 parentValueArray = String(parentValues).split(',').map(v => v.trim()).filter(v => v); } } else if (parentValue) { // 기존 단일 값 호환 parentValueArray = [String(parentValue)]; } if (parentValueArray.length === 0) { return res.json({ success: true, data: [], message: "부모 값이 없습니다.", }); } // 관계 정보 조회 let relationQuery = ` SELECT child_table, child_filter_column, child_value_column, child_label_column, child_order_column, child_order_direction FROM cascading_relation WHERE relation_code = $1 AND is_active = 'Y' `; const relationParams: any[] = [code]; // 멀티테넌시 필터링 (company_code = "*"는 최고 관리자 전용) if (companyCode !== "*") { relationQuery += ` AND company_code = $2`; relationParams.push(companyCode); } relationQuery += ` LIMIT 1`; const relationResult = await pool.query(relationQuery, relationParams); if (relationResult.rowCount === 0) { return res.status(404).json({ success: false, message: "연쇄 관계를 찾을 수 없습니다.", }); } const relation = relationResult.rows[0]; // 자식 옵션 조회 - 다중 부모값에 대해 IN 절 사용 // SQL Injection 방지를 위해 파라미터화된 쿼리 사용 const placeholders = parentValueArray.map((_, idx) => `$${idx + 1}`).join(', '); let optionsQuery = ` SELECT DISTINCT ${relation.child_value_column} as value, ${relation.child_label_column} as label, ${relation.child_filter_column} as parent_value FROM ${relation.child_table} WHERE ${relation.child_filter_column} IN (${placeholders}) `; // 멀티테넌시 적용 (테이블에 company_code가 있는 경우) const tableInfoResult = await pool.query( `SELECT column_name FROM information_schema.columns WHERE table_name = $1 AND column_name = 'company_code'`, [relation.child_table] ); const optionsParams: any[] = [...parentValueArray]; let paramIndex = parentValueArray.length + 1; // company_code = "*"는 최고 관리자 전용이므로 일반 회사는 자기 회사 데이터만 if ( tableInfoResult.rowCount && tableInfoResult.rowCount > 0 && companyCode !== "*" ) { optionsQuery += ` AND company_code = $${paramIndex}`; optionsParams.push(companyCode); paramIndex++; } // 정렬 if (relation.child_order_column) { optionsQuery += ` ORDER BY ${relation.child_order_column} ${relation.child_order_direction || "ASC"}`; } else { optionsQuery += ` ORDER BY ${relation.child_label_column} ASC`; } const optionsResult = await pool.query(optionsQuery, optionsParams); logger.info("연쇄 옵션 조회 (다중 부모값 지원)", { relationCode: code, parentValues: parentValueArray, optionsCount: optionsResult.rowCount, }); return res.json({ success: true, data: optionsResult.rows, }); } catch (error: any) { logger.error("연쇄 옵션 조회 실패", { error: error.message }); return res.status(500).json({ success: false, message: "연쇄 옵션 조회에 실패했습니다.", error: error.message, }); } };