/** * 다단계 계층 (Hierarchy) 컨트롤러 * 국가 > 도시 > 구/군 같은 다단계 연쇄 드롭다운 관리 */ import { Request, Response } from "express"; import { query, queryOne } from "../database/db"; import logger from "../utils/logger"; // ===================================================== // 계층 그룹 CRUD // ===================================================== /** * 계층 그룹 목록 조회 */ export const getHierarchyGroups = async (req: Request, res: Response) => { try { const companyCode = req.user?.companyCode || "*"; const { isActive, hierarchyType } = req.query; let sql = ` SELECT g.*, (SELECT COUNT(*) FROM cascading_hierarchy_level l WHERE l.group_code = g.group_code AND l.company_code = g.company_code) as level_count FROM cascading_hierarchy_group g WHERE 1=1 `; const params: any[] = []; let paramIndex = 1; if (companyCode !== "*") { sql += ` AND g.company_code = $${paramIndex++}`; params.push(companyCode); } if (isActive) { sql += ` AND g.is_active = $${paramIndex++}`; params.push(isActive); } if (hierarchyType) { sql += ` AND g.hierarchy_type = $${paramIndex++}`; params.push(hierarchyType); } sql += ` ORDER BY g.group_name`; const result = await query(sql, params); logger.info("계층 그룹 목록 조회", { count: result.length, companyCode }); res.json({ success: true, data: result, }); } catch (error: any) { logger.error("계층 그룹 목록 조회 실패", { error: error.message }); res.status(500).json({ success: false, message: "계층 그룹 목록 조회에 실패했습니다.", error: error.message, }); } }; /** * 계층 그룹 상세 조회 (레벨 포함) */ export const getHierarchyGroupDetail = async (req: Request, res: Response) => { try { const { groupCode } = req.params; const companyCode = req.user?.companyCode || "*"; // 그룹 조회 let groupSql = `SELECT * FROM cascading_hierarchy_group WHERE group_code = $1`; const groupParams: any[] = [groupCode]; if (companyCode !== "*") { groupSql += ` AND company_code = $2`; groupParams.push(companyCode); } const group = await queryOne(groupSql, groupParams); if (!group) { return res.status(404).json({ success: false, message: "계층 그룹을 찾을 수 없습니다.", }); } // 레벨 조회 let levelSql = `SELECT * FROM cascading_hierarchy_level WHERE group_code = $1`; const levelParams: any[] = [groupCode]; if (companyCode !== "*") { levelSql += ` AND company_code = $2`; levelParams.push(companyCode); } levelSql += ` ORDER BY level_order`; const levels = await query(levelSql, levelParams); logger.info("계층 그룹 상세 조회", { groupCode, companyCode }); res.json({ success: true, data: { ...group, levels: levels, }, }); } catch (error: any) { logger.error("계층 그룹 상세 조회 실패", { error: error.message }); res.status(500).json({ success: false, message: "계층 그룹 상세 조회에 실패했습니다.", error: error.message, }); } }; /** * 계층 그룹 코드 자동 생성 함수 */ const generateHierarchyGroupCode = async (companyCode: string): Promise => { const prefix = "HG"; const result = await queryOne( `SELECT COUNT(*) as cnt FROM cascading_hierarchy_group WHERE company_code = $1`, [companyCode] ); const count = parseInt(result?.cnt || "0", 10) + 1; const timestamp = Date.now().toString(36).toUpperCase().slice(-4); return `${prefix}_${timestamp}_${count.toString().padStart(3, "0")}`; }; /** * 계층 그룹 생성 */ export const createHierarchyGroup = async (req: Request, res: Response) => { try { const companyCode = req.user?.companyCode || "*"; const userId = req.user?.userId || "system"; const { groupName, description, hierarchyType = "MULTI_TABLE", maxLevels, isFixedLevels = "Y", // Self-reference 설정 selfRefTable, selfRefIdColumn, selfRefParentColumn, selfRefValueColumn, selfRefLabelColumn, selfRefLevelColumn, selfRefOrderColumn, // BOM 설정 bomTable, bomParentColumn, bomChildColumn, bomItemTable, bomItemIdColumn, bomItemLabelColumn, bomQtyColumn, bomLevelColumn, // 메시지 emptyMessage, noOptionsMessage, loadingMessage, // 레벨 (MULTI_TABLE 타입인 경우) levels = [], } = req.body; // 필수 필드 검증 if (!groupName || !hierarchyType) { return res.status(400).json({ success: false, message: "필수 필드가 누락되었습니다. (groupName, hierarchyType)", }); } // 그룹 코드 자동 생성 const groupCode = await generateHierarchyGroupCode(companyCode); // 그룹 생성 const insertGroupSql = ` INSERT INTO cascading_hierarchy_group ( group_code, group_name, description, hierarchy_type, max_levels, is_fixed_levels, self_ref_table, self_ref_id_column, self_ref_parent_column, self_ref_value_column, self_ref_label_column, self_ref_level_column, self_ref_order_column, bom_table, bom_parent_column, bom_child_column, bom_item_table, bom_item_id_column, bom_item_label_column, bom_qty_column, bom_level_column, empty_message, no_options_message, loading_message, 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, $18, $19, $20, $21, $22, $23, $24, $25, 'Y', $26, CURRENT_TIMESTAMP) RETURNING * `; const group = await queryOne(insertGroupSql, [ groupCode, groupName, description || null, hierarchyType, maxLevels || null, isFixedLevels, selfRefTable || null, selfRefIdColumn || null, selfRefParentColumn || null, selfRefValueColumn || null, selfRefLabelColumn || null, selfRefLevelColumn || null, selfRefOrderColumn || null, bomTable || null, bomParentColumn || null, bomChildColumn || null, bomItemTable || null, bomItemIdColumn || null, bomItemLabelColumn || null, bomQtyColumn || null, bomLevelColumn || null, emptyMessage || "선택해주세요", noOptionsMessage || "옵션이 없습니다", loadingMessage || "로딩 중...", companyCode, userId, ]); // 레벨 생성 (MULTI_TABLE 타입인 경우) if (hierarchyType === "MULTI_TABLE" && levels.length > 0) { for (const level of levels) { await query( `INSERT INTO cascading_hierarchy_level ( group_code, company_code, level_order, level_name, level_code, table_name, value_column, label_column, parent_key_column, filter_column, filter_value, order_column, order_direction, placeholder, is_required, is_searchable, is_active, created_date ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, 'Y', CURRENT_TIMESTAMP)`, [ groupCode, companyCode, level.levelOrder, level.levelName, level.levelCode || null, level.tableName, level.valueColumn, level.labelColumn, level.parentKeyColumn || null, level.filterColumn || null, level.filterValue || null, level.orderColumn || null, level.orderDirection || "ASC", level.placeholder || `${level.levelName} 선택`, level.isRequired || "Y", level.isSearchable || "N", ] ); } } logger.info("계층 그룹 생성", { groupCode, hierarchyType, companyCode }); res.status(201).json({ success: true, message: "계층 그룹이 생성되었습니다.", data: group, }); } catch (error: any) { logger.error("계층 그룹 생성 실패", { error: error.message }); res.status(500).json({ success: false, message: "계층 그룹 생성에 실패했습니다.", error: error.message, }); } }; /** * 계층 그룹 수정 */ export const updateHierarchyGroup = async (req: Request, res: Response) => { try { const { groupCode } = req.params; const companyCode = req.user?.companyCode || "*"; const userId = req.user?.userId || "system"; const { groupName, description, maxLevels, isFixedLevels, emptyMessage, noOptionsMessage, loadingMessage, isActive, } = req.body; // 기존 그룹 확인 let checkSql = `SELECT * FROM cascading_hierarchy_group WHERE group_code = $1`; const checkParams: any[] = [groupCode]; if (companyCode !== "*") { checkSql += ` AND company_code = $2`; checkParams.push(companyCode); } const existing = await queryOne(checkSql, checkParams); if (!existing) { return res.status(404).json({ success: false, message: "계층 그룹을 찾을 수 없습니다.", }); } const updateSql = ` UPDATE cascading_hierarchy_group SET group_name = COALESCE($1, group_name), description = COALESCE($2, description), max_levels = COALESCE($3, max_levels), is_fixed_levels = COALESCE($4, is_fixed_levels), empty_message = COALESCE($5, empty_message), no_options_message = COALESCE($6, no_options_message), loading_message = COALESCE($7, loading_message), is_active = COALESCE($8, is_active), updated_by = $9, updated_date = CURRENT_TIMESTAMP WHERE group_code = $10 AND company_code = $11 RETURNING * `; const result = await queryOne(updateSql, [ groupName, description, maxLevels, isFixedLevels, emptyMessage, noOptionsMessage, loadingMessage, isActive, userId, groupCode, existing.company_code, ]); logger.info("계층 그룹 수정", { groupCode, companyCode }); res.json({ success: true, message: "계층 그룹이 수정되었습니다.", data: result, }); } catch (error: any) { logger.error("계층 그룹 수정 실패", { error: error.message }); res.status(500).json({ success: false, message: "계층 그룹 수정에 실패했습니다.", error: error.message, }); } }; /** * 계층 그룹 삭제 */ export const deleteHierarchyGroup = async (req: Request, res: Response) => { try { const { groupCode } = req.params; const companyCode = req.user?.companyCode || "*"; // 레벨 먼저 삭제 let deleteLevelsSql = `DELETE FROM cascading_hierarchy_level WHERE group_code = $1`; const levelParams: any[] = [groupCode]; if (companyCode !== "*") { deleteLevelsSql += ` AND company_code = $2`; levelParams.push(companyCode); } await query(deleteLevelsSql, levelParams); // 그룹 삭제 let deleteGroupSql = `DELETE FROM cascading_hierarchy_group WHERE group_code = $1`; const groupParams: any[] = [groupCode]; if (companyCode !== "*") { deleteGroupSql += ` AND company_code = $2`; groupParams.push(companyCode); } deleteGroupSql += ` RETURNING group_code`; const result = await queryOne(deleteGroupSql, groupParams); if (!result) { return res.status(404).json({ success: false, message: "계층 그룹을 찾을 수 없습니다.", }); } logger.info("계층 그룹 삭제", { groupCode, companyCode }); res.json({ success: true, message: "계층 그룹이 삭제되었습니다.", }); } catch (error: any) { logger.error("계층 그룹 삭제 실패", { error: error.message }); res.status(500).json({ success: false, message: "계층 그룹 삭제에 실패했습니다.", error: error.message, }); } }; // ===================================================== // 계층 레벨 관리 // ===================================================== /** * 레벨 추가 */ export const addLevel = async (req: Request, res: Response) => { try { const { groupCode } = req.params; const companyCode = req.user?.companyCode || "*"; const { levelOrder, levelName, levelCode, tableName, valueColumn, labelColumn, parentKeyColumn, filterColumn, filterValue, orderColumn, orderDirection = "ASC", placeholder, isRequired = "Y", isSearchable = "N", } = req.body; // 그룹 존재 확인 const groupCheck = await queryOne( `SELECT * FROM cascading_hierarchy_group WHERE group_code = $1 AND (company_code = $2 OR $2 = '*')`, [groupCode, companyCode] ); if (!groupCheck) { return res.status(404).json({ success: false, message: "계층 그룹을 찾을 수 없습니다.", }); } const insertSql = ` INSERT INTO cascading_hierarchy_level ( group_code, company_code, level_order, level_name, level_code, table_name, value_column, label_column, parent_key_column, filter_column, filter_value, order_column, order_direction, placeholder, is_required, is_searchable, is_active, created_date ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, 'Y', CURRENT_TIMESTAMP) RETURNING * `; const result = await queryOne(insertSql, [ groupCode, groupCheck.company_code, levelOrder, levelName, levelCode || null, tableName, valueColumn, labelColumn, parentKeyColumn || null, filterColumn || null, filterValue || null, orderColumn || null, orderDirection, placeholder || `${levelName} 선택`, isRequired, isSearchable, ]); logger.info("계층 레벨 추가", { groupCode, levelOrder, levelName }); res.status(201).json({ success: true, message: "레벨이 추가되었습니다.", data: result, }); } catch (error: any) { logger.error("계층 레벨 추가 실패", { error: error.message }); res.status(500).json({ success: false, message: "레벨 추가에 실패했습니다.", error: error.message, }); } }; /** * 레벨 수정 */ export const updateLevel = async (req: Request, res: Response) => { try { const { levelId } = req.params; const companyCode = req.user?.companyCode || "*"; const { levelName, tableName, valueColumn, labelColumn, parentKeyColumn, filterColumn, filterValue, orderColumn, orderDirection, placeholder, isRequired, isSearchable, isActive, } = req.body; let checkSql = `SELECT * FROM cascading_hierarchy_level WHERE level_id = $1`; const checkParams: any[] = [Number(levelId)]; if (companyCode !== "*") { checkSql += ` AND company_code = $2`; checkParams.push(companyCode); } const existing = await queryOne(checkSql, checkParams); if (!existing) { return res.status(404).json({ success: false, message: "레벨을 찾을 수 없습니다.", }); } const updateSql = ` UPDATE cascading_hierarchy_level SET level_name = COALESCE($1, level_name), table_name = COALESCE($2, table_name), value_column = COALESCE($3, value_column), label_column = COALESCE($4, label_column), parent_key_column = COALESCE($5, parent_key_column), filter_column = COALESCE($6, filter_column), filter_value = COALESCE($7, filter_value), order_column = COALESCE($8, order_column), order_direction = COALESCE($9, order_direction), placeholder = COALESCE($10, placeholder), is_required = COALESCE($11, is_required), is_searchable = COALESCE($12, is_searchable), is_active = COALESCE($13, is_active), updated_date = CURRENT_TIMESTAMP WHERE level_id = $14 RETURNING * `; const result = await queryOne(updateSql, [ levelName, tableName, valueColumn, labelColumn, parentKeyColumn, filterColumn, filterValue, orderColumn, orderDirection, placeholder, isRequired, isSearchable, isActive, Number(levelId), ]); logger.info("계층 레벨 수정", { levelId }); res.json({ success: true, message: "레벨이 수정되었습니다.", data: result, }); } catch (error: any) { logger.error("계층 레벨 수정 실패", { error: error.message }); res.status(500).json({ success: false, message: "레벨 수정에 실패했습니다.", error: error.message, }); } }; /** * 레벨 삭제 */ export const deleteLevel = async (req: Request, res: Response) => { try { const { levelId } = req.params; const companyCode = req.user?.companyCode || "*"; let deleteSql = `DELETE FROM cascading_hierarchy_level WHERE level_id = $1`; const deleteParams: any[] = [Number(levelId)]; if (companyCode !== "*") { deleteSql += ` AND company_code = $2`; deleteParams.push(companyCode); } deleteSql += ` RETURNING level_id`; const result = await queryOne(deleteSql, deleteParams); if (!result) { return res.status(404).json({ success: false, message: "레벨을 찾을 수 없습니다.", }); } logger.info("계층 레벨 삭제", { levelId }); res.json({ success: true, message: "레벨이 삭제되었습니다.", }); } catch (error: any) { logger.error("계층 레벨 삭제 실패", { error: error.message }); res.status(500).json({ success: false, message: "레벨 삭제에 실패했습니다.", error: error.message, }); } }; // ===================================================== // 계층 옵션 조회 API (실제 사용) // ===================================================== /** * 특정 레벨의 옵션 조회 */ export const getLevelOptions = async (req: Request, res: Response) => { try { const { groupCode, levelOrder } = req.params; const { parentValue } = req.query; const companyCode = req.user?.companyCode || "*"; // 레벨 정보 조회 let levelSql = ` SELECT l.*, g.hierarchy_type FROM cascading_hierarchy_level l JOIN cascading_hierarchy_group g ON l.group_code = g.group_code AND l.company_code = g.company_code WHERE l.group_code = $1 AND l.level_order = $2 AND l.is_active = 'Y' `; const levelParams: any[] = [groupCode, Number(levelOrder)]; if (companyCode !== "*") { levelSql += ` AND l.company_code = $3`; levelParams.push(companyCode); } const level = await queryOne(levelSql, levelParams); if (!level) { return res.status(404).json({ success: false, message: "레벨을 찾을 수 없습니다.", }); } // 옵션 조회 let optionsSql = ` SELECT ${level.value_column} as value, ${level.label_column} as label FROM ${level.table_name} WHERE 1=1 `; const optionsParams: any[] = []; let optionsParamIndex = 1; // 부모 값 필터 (레벨 2 이상) if (level.parent_key_column && parentValue) { optionsSql += ` AND ${level.parent_key_column} = $${optionsParamIndex++}`; optionsParams.push(parentValue); } // 고정 필터 if (level.filter_column && level.filter_value) { optionsSql += ` AND ${level.filter_column} = $${optionsParamIndex++}`; optionsParams.push(level.filter_value); } // 멀티테넌시 필터 if (companyCode !== "*") { const columnCheck = await queryOne( `SELECT column_name FROM information_schema.columns WHERE table_name = $1 AND column_name = 'company_code'`, [level.table_name] ); if (columnCheck) { optionsSql += ` AND company_code = $${optionsParamIndex++}`; optionsParams.push(companyCode); } } // 정렬 if (level.order_column) { optionsSql += ` ORDER BY ${level.order_column} ${level.order_direction || "ASC"}`; } else { optionsSql += ` ORDER BY ${level.label_column}`; } const optionsResult = await query(optionsSql, optionsParams); logger.info("계층 레벨 옵션 조회", { groupCode, levelOrder, parentValue, optionCount: optionsResult.length, }); res.json({ success: true, data: optionsResult, levelInfo: { levelId: level.level_id, levelName: level.level_name, placeholder: level.placeholder, isRequired: level.is_required, isSearchable: level.is_searchable, }, }); } catch (error: any) { logger.error("계층 레벨 옵션 조회 실패", { error: error.message }); res.status(500).json({ success: false, message: "옵션 조회에 실패했습니다.", error: error.message, }); } };