import { Response } from "express"; import { AuthenticatedRequest } from "../types/auth"; import { getPool } from "../database/db"; import { logger } from "../utils/logger"; const pool = getPool(); // ============================================ // 카테고리 값 연쇄관계 그룹 CRUD // ============================================ /** * 카테고리 값 연쇄관계 그룹 목록 조회 */ export const getCategoryValueCascadingGroups = async ( req: AuthenticatedRequest, res: Response ) => { try { const companyCode = req.user?.companyCode || "*"; const { isActive } = req.query; let query = ` SELECT group_id, relation_code, relation_name, description, parent_table_name, parent_column_name, parent_menu_objid, child_table_name, child_column_name, child_menu_objid, clear_on_parent_change, show_group_label, empty_parent_message, no_options_message, company_code, is_active, created_by, created_date, updated_by, updated_date FROM category_value_cascading_group WHERE 1=1 `; const params: any[] = []; let paramIndex = 1; // 멀티테넌시 필터링 if (companyCode !== "*") { query += ` AND (company_code = $${paramIndex} OR company_code = '*')`; 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 getCategoryValueCascadingGroupById = async ( req: AuthenticatedRequest, res: Response ) => { try { const { groupId } = req.params; const companyCode = req.user?.companyCode || "*"; // 그룹 정보 조회 let groupQuery = ` SELECT group_id, relation_code, relation_name, description, parent_table_name, parent_column_name, parent_menu_objid, child_table_name, child_column_name, child_menu_objid, clear_on_parent_change, show_group_label, empty_parent_message, no_options_message, company_code, is_active FROM category_value_cascading_group WHERE group_id = $1 `; const groupParams: any[] = [groupId]; if (companyCode !== "*") { groupQuery += ` AND (company_code = $2 OR company_code = '*')`; groupParams.push(companyCode); } const groupResult = await pool.query(groupQuery, groupParams); if (groupResult.rowCount === 0) { return res.status(404).json({ success: false, message: "카테고리 값 연쇄관계 그룹을 찾을 수 없습니다.", }); } // 매핑 정보 조회 const mappingQuery = ` SELECT mapping_id, parent_value_code, parent_value_label, child_value_code, child_value_label, display_order, is_active FROM category_value_cascading_mapping WHERE group_id = $1 AND is_active = 'Y' ORDER BY parent_value_code, display_order, child_value_label `; const mappingResult = await pool.query(mappingQuery, [groupId]); // 부모 값별로 자식 값 그룹화 const mappingsByParent: Record = {}; for (const row of mappingResult.rows) { const parentKey = row.parent_value_code; if (!mappingsByParent[parentKey]) { mappingsByParent[parentKey] = []; } mappingsByParent[parentKey].push({ childValueCode: row.child_value_code, childValueLabel: row.child_value_label, displayOrder: row.display_order, }); } return res.json({ success: true, data: { ...groupResult.rows[0], mappings: mappingResult.rows, mappingsByParent, }, }); } catch (error: any) { logger.error("카테고리 값 연쇄관계 그룹 상세 조회 실패", { error: error.message, }); return res.status(500).json({ success: false, message: "카테고리 값 연쇄관계 그룹 조회에 실패했습니다.", error: error.message, }); } }; /** * 관계 코드로 조회 */ export const getCategoryValueCascadingByCode = async ( req: AuthenticatedRequest, res: Response ) => { try { const { code } = req.params; const companyCode = req.user?.companyCode || "*"; let query = ` SELECT group_id, relation_code, relation_name, description, parent_table_name, parent_column_name, parent_menu_objid, child_table_name, child_column_name, child_menu_objid, clear_on_parent_change, show_group_label, empty_parent_message, no_options_message, company_code, is_active FROM category_value_cascading_group WHERE relation_code = $1 AND is_active = 'Y' `; const params: any[] = [code]; if (companyCode !== "*") { query += ` AND (company_code = $2 OR company_code = '*')`; 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 createCategoryValueCascadingGroup = async ( req: AuthenticatedRequest, res: Response ) => { try { const companyCode = req.user?.companyCode || "*"; const userId = req.user?.userId || "system"; const { relationCode, relationName, description, parentTableName, parentColumnName, parentMenuObjid, childTableName, childColumnName, childMenuObjid, clearOnParentChange = true, showGroupLabel = true, emptyParentMessage, noOptionsMessage, } = req.body; // 필수 필드 검증 if ( !relationCode || !relationName || !parentTableName || !parentColumnName || !childTableName || !childColumnName ) { return res.status(400).json({ success: false, message: "필수 필드가 누락되었습니다.", }); } // 중복 코드 체크 const duplicateCheck = await pool.query( `SELECT group_id FROM category_value_cascading_group WHERE relation_code = $1 AND (company_code = $2 OR company_code = '*')`, [relationCode, companyCode] ); if (duplicateCheck.rowCount && duplicateCheck.rowCount > 0) { return res.status(400).json({ success: false, message: "이미 존재하는 관계 코드입니다.", }); } const query = ` INSERT INTO category_value_cascading_group ( relation_code, relation_name, description, parent_table_name, parent_column_name, parent_menu_objid, child_table_name, child_column_name, child_menu_objid, clear_on_parent_change, show_group_label, empty_parent_message, no_options_message, company_code, is_active, created_by, created_date ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, 'Y', $15, NOW()) RETURNING * `; const result = await pool.query(query, [ relationCode, relationName, description || null, parentTableName, parentColumnName, parentMenuObjid || null, childTableName, childColumnName, childMenuObjid || null, clearOnParentChange ? "Y" : "N", showGroupLabel ? "Y" : "N", emptyParentMessage || "상위 항목을 먼저 선택하세요", noOptionsMessage || "선택 가능한 항목이 없습니다", companyCode, userId, ]); logger.info("카테고리 값 연쇄관계 그룹 생성", { groupId: result.rows[0].group_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 updateCategoryValueCascadingGroup = async ( req: AuthenticatedRequest, res: Response ) => { try { const { groupId } = req.params; const companyCode = req.user?.companyCode || "*"; const userId = req.user?.userId || "system"; const { relationName, description, parentTableName, parentColumnName, parentMenuObjid, childTableName, childColumnName, childMenuObjid, clearOnParentChange, showGroupLabel, emptyParentMessage, noOptionsMessage, isActive, } = req.body; // 권한 체크 const existingCheck = await pool.query( `SELECT group_id, company_code FROM category_value_cascading_group WHERE group_id = $1`, [groupId] ); 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 category_value_cascading_group SET relation_name = COALESCE($1, relation_name), description = COALESCE($2, description), parent_table_name = COALESCE($3, parent_table_name), parent_column_name = COALESCE($4, parent_column_name), parent_menu_objid = COALESCE($5, parent_menu_objid), child_table_name = COALESCE($6, child_table_name), child_column_name = COALESCE($7, child_column_name), child_menu_objid = COALESCE($8, child_menu_objid), clear_on_parent_change = COALESCE($9, clear_on_parent_change), show_group_label = COALESCE($10, show_group_label), empty_parent_message = COALESCE($11, empty_parent_message), no_options_message = COALESCE($12, no_options_message), is_active = COALESCE($13, is_active), updated_by = $14, updated_date = NOW() WHERE group_id = $15 RETURNING * `; const result = await pool.query(query, [ relationName, description, parentTableName, parentColumnName, parentMenuObjid, childTableName, childColumnName, childMenuObjid, clearOnParentChange !== undefined ? clearOnParentChange ? "Y" : "N" : null, showGroupLabel !== undefined ? (showGroupLabel ? "Y" : "N") : null, emptyParentMessage, noOptionsMessage, isActive !== undefined ? (isActive ? "Y" : "N") : null, userId, groupId, ]); logger.info("카테고리 값 연쇄관계 그룹 수정", { groupId, 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 deleteCategoryValueCascadingGroup = async ( req: AuthenticatedRequest, res: Response ) => { try { const { groupId } = req.params; const companyCode = req.user?.companyCode || "*"; const userId = req.user?.userId || "system"; // 권한 체크 const existingCheck = await pool.query( `SELECT group_id, company_code FROM category_value_cascading_group WHERE group_id = $1`, [groupId] ); 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: "삭제 권한이 없습니다.", }); } // 소프트 삭제 await pool.query( `UPDATE category_value_cascading_group SET is_active = 'N', updated_by = $1, updated_date = NOW() WHERE group_id = $2`, [userId, groupId] ); logger.info("카테고리 값 연쇄관계 그룹 삭제", { groupId, 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, }); } }; // ============================================ // 카테고리 값 연쇄관계 매핑 CRUD // ============================================ /** * 매핑 일괄 저장 (기존 매핑 교체) */ export const saveCategoryValueCascadingMappings = async ( req: AuthenticatedRequest, res: Response ) => { try { const { groupId } = req.params; const companyCode = req.user?.companyCode || "*"; const { mappings } = req.body; // [{ parentValueCode, parentValueLabel, childValueCode, childValueLabel, displayOrder }] if (!Array.isArray(mappings)) { return res.status(400).json({ success: false, message: "mappings는 배열이어야 합니다.", }); } // 그룹 존재 확인 const groupCheck = await pool.query( `SELECT group_id FROM category_value_cascading_group WHERE group_id = $1 AND is_active = 'Y'`, [groupId] ); if (groupCheck.rowCount === 0) { return res.status(404).json({ success: false, message: "카테고리 값 연쇄관계 그룹을 찾을 수 없습니다.", }); } // 트랜잭션으로 처리 const client = await pool.connect(); try { await client.query("BEGIN"); // 기존 매핑 삭제 (하드 삭제) await client.query( `DELETE FROM category_value_cascading_mapping WHERE group_id = $1`, [groupId] ); // 새 매핑 삽입 if (mappings.length > 0) { const insertQuery = ` INSERT INTO category_value_cascading_mapping ( group_id, parent_value_code, parent_value_label, child_value_code, child_value_label, display_order, company_code, is_active, created_date ) VALUES ($1, $2, $3, $4, $5, $6, $7, 'Y', NOW()) `; for (const mapping of mappings) { await client.query(insertQuery, [ groupId, mapping.parentValueCode, mapping.parentValueLabel || null, mapping.childValueCode, mapping.childValueLabel || null, mapping.displayOrder || 0, companyCode, ]); } } await client.query("COMMIT"); logger.info("카테고리 값 연쇄관계 매핑 저장", { groupId, mappingCount: mappings.length, companyCode, }); return res.json({ success: true, message: `${mappings.length}개의 매핑이 저장되었습니다.`, }); } catch (err) { await client.query("ROLLBACK"); throw err; } finally { client.release(); } } catch (error: any) { logger.error("카테고리 값 연쇄관계 매핑 저장 실패", { error: error.message, }); return res.status(500).json({ success: false, message: "카테고리 값 연쇄관계 매핑 저장에 실패했습니다.", error: error.message, }); } }; // ============================================ // 연쇄 옵션 조회 (실제 드롭다운에서 사용) // ============================================ /** * 카테고리 값 연쇄 옵션 조회 * 부모 값(들)에 해당하는 자식 카테고리 값 목록 반환 * 다중 부모값 지원 */ export const getCategoryValueCascadingOptions = 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) { 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 groupQuery = ` SELECT group_id, show_group_label FROM category_value_cascading_group WHERE relation_code = $1 AND is_active = 'Y' `; const groupParams: any[] = [code]; if (companyCode !== "*") { groupQuery += ` AND (company_code = $2 OR company_code = '*')`; groupParams.push(companyCode); } groupQuery += ` LIMIT 1`; const groupResult = await pool.query(groupQuery, groupParams); if (groupResult.rowCount === 0) { return res.status(404).json({ success: false, message: "카테고리 값 연쇄관계를 찾을 수 없습니다.", }); } const group = groupResult.rows[0]; // 매핑된 자식 값 조회 (다중 부모값에 대해 IN 절 사용) const placeholders = parentValueArray .map((_, idx) => `$${idx + 2}`) .join(", "); const optionsQuery = ` SELECT DISTINCT child_value_code as value, child_value_label as label, parent_value_code as parent_value, parent_value_label as parent_label, display_order FROM category_value_cascading_mapping WHERE group_id = $1 AND parent_value_code IN (${placeholders}) AND is_active = 'Y' ORDER BY parent_value_code, display_order, child_value_label `; const optionsResult = await pool.query(optionsQuery, [ group.group_id, ...parentValueArray, ]); logger.info("카테고리 값 연쇄 옵션 조회", { relationCode: code, parentValues: parentValueArray, optionsCount: optionsResult.rowCount, }); return res.json({ success: true, data: optionsResult.rows, showGroupLabel: group.show_group_label === "Y", }); } catch (error: any) { logger.error("카테고리 값 연쇄 옵션 조회 실패", { error: error.message }); return res.status(500).json({ success: false, message: "카테고리 값 연쇄 옵션 조회에 실패했습니다.", error: error.message, }); } }; /** * 부모 카테고리 값 목록 조회 */ export const getCategoryValueCascadingParentOptions = async ( req: AuthenticatedRequest, res: Response ) => { try { const { code } = req.params; const companyCode = req.user?.companyCode || "*"; // 관계 정보 조회 let groupQuery = ` SELECT group_id, parent_table_name, parent_column_name, parent_menu_objid FROM category_value_cascading_group WHERE relation_code = $1 AND is_active = 'Y' `; const groupParams: any[] = [code]; if (companyCode !== "*") { groupQuery += ` AND (company_code = $2 OR company_code = '*')`; groupParams.push(companyCode); } groupQuery += ` LIMIT 1`; const groupResult = await pool.query(groupQuery, groupParams); if (groupResult.rowCount === 0) { return res.status(404).json({ success: false, message: "카테고리 값 연쇄관계를 찾을 수 없습니다.", }); } const group = groupResult.rows[0]; // 부모 카테고리 값 조회 (table_column_category_values에서) let optionsQuery = ` SELECT value_code as value, value_label as label, value_order as display_order FROM table_column_category_values WHERE table_name = $1 AND column_name = $2 AND is_active = true `; const optionsParams: any[] = [ group.parent_table_name, group.parent_column_name, ]; let paramIndex = 3; // 메뉴 스코프 적용 if (group.parent_menu_objid) { optionsQuery += ` AND menu_objid = $${paramIndex}`; optionsParams.push(group.parent_menu_objid); paramIndex++; } // 멀티테넌시 적용 if (companyCode !== "*") { optionsQuery += ` AND (company_code = $${paramIndex} OR company_code = '*')`; optionsParams.push(companyCode); } optionsQuery += ` ORDER BY value_order, value_label`; const optionsResult = await pool.query(optionsQuery, optionsParams); logger.info("부모 카테고리 값 조회", { relationCode: code, tableName: group.parent_table_name, columnName: group.parent_column_name, 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, }); } }; /** * 자식 카테고리 값 목록 조회 (매핑 설정 UI용) */ export const getCategoryValueCascadingChildOptions = async ( req: AuthenticatedRequest, res: Response ) => { try { const { code } = req.params; const companyCode = req.user?.companyCode || "*"; // 관계 정보 조회 let groupQuery = ` SELECT group_id, child_table_name, child_column_name, child_menu_objid FROM category_value_cascading_group WHERE relation_code = $1 AND is_active = 'Y' `; const groupParams: any[] = [code]; if (companyCode !== "*") { groupQuery += ` AND (company_code = $2 OR company_code = '*')`; groupParams.push(companyCode); } groupQuery += ` LIMIT 1`; const groupResult = await pool.query(groupQuery, groupParams); if (groupResult.rowCount === 0) { return res.status(404).json({ success: false, message: "카테고리 값 연쇄관계를 찾을 수 없습니다.", }); } const group = groupResult.rows[0]; // 자식 카테고리 값 조회 (table_column_category_values에서) let optionsQuery = ` SELECT value_code as value, value_label as label, value_order as display_order FROM table_column_category_values WHERE table_name = $1 AND column_name = $2 AND is_active = true `; const optionsParams: any[] = [ group.child_table_name, group.child_column_name, ]; let paramIndex = 3; // 메뉴 스코프 적용 if (group.child_menu_objid) { optionsQuery += ` AND menu_objid = $${paramIndex}`; optionsParams.push(group.child_menu_objid); paramIndex++; } // 멀티테넌시 적용 if (companyCode !== "*") { optionsQuery += ` AND (company_code = $${paramIndex} OR company_code = '*')`; optionsParams.push(companyCode); } optionsQuery += ` ORDER BY value_order, value_label`; const optionsResult = await pool.query(optionsQuery, optionsParams); logger.info("자식 카테고리 값 조회", { relationCode: code, tableName: group.child_table_name, columnName: group.child_column_name, 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, }); } }; /** * 테이블명으로 해당 테이블의 모든 연쇄관계 매핑 조회 * (테이블 목록에서 코드→라벨 변환에 사용) */ export const getCategoryValueCascadingMappingsByTable = async ( req: AuthenticatedRequest, res: Response ) => { try { const { tableName } = req.params; const companyCode = req.user?.companyCode || "*"; if (!tableName) { return res.status(400).json({ success: false, message: "테이블명이 필요합니다.", }); } // 해당 테이블이 자식 테이블인 연쇄관계 그룹 찾기 let groupQuery = ` SELECT group_id, relation_code, child_column_name FROM category_value_cascading_group WHERE child_table_name = $1 AND is_active = 'Y' `; const groupParams: any[] = [tableName]; let paramIndex = 2; // 멀티테넌시 적용 if (companyCode !== "*") { groupQuery += ` AND (company_code = $${paramIndex} OR company_code = '*')`; groupParams.push(companyCode); } const groupResult = await pool.query(groupQuery, groupParams); if (groupResult.rowCount === 0) { // 연쇄관계가 없으면 빈 객체 반환 return res.json({ success: true, data: {}, }); } // 각 그룹의 매핑 조회 const mappings: Record> = {}; for (const group of groupResult.rows) { const mappingQuery = ` SELECT DISTINCT child_value_code as code, child_value_label as label FROM category_value_cascading_mapping WHERE group_id = $1 AND is_active = 'Y' ORDER BY child_value_label `; const mappingResult = await pool.query(mappingQuery, [group.group_id]); if (mappingResult.rowCount && mappingResult.rowCount > 0) { mappings[group.child_column_name] = mappingResult.rows; } } logger.info("테이블별 연쇄관계 매핑 조회", { tableName, groupCount: groupResult.rowCount, columnMappings: Object.keys(mappings), }); return res.json({ success: true, data: mappings, }); } catch (error: any) { logger.error("테이블별 연쇄관계 매핑 조회 실패", { error: error.message }); return res.status(500).json({ success: false, message: "연쇄관계 매핑 조회에 실패했습니다.", error: error.message, }); } };