import { Request, Response } from "express"; import { getPool } from "../database/db"; import { logger } from "../utils/logger"; // pool 인스턴스 가져오기 const pool = getPool(); // ============================================================ // 화면 그룹 (screen_groups) CRUD // ============================================================ // 화면 그룹 목록 조회 export const getScreenGroups = async (req: Request, res: Response) => { try { const companyCode = (req.user as any).companyCode; const { page = 1, size = 20, searchTerm } = req.query; const offset = (parseInt(page as string) - 1) * parseInt(size as string); let whereClause = "WHERE 1=1"; const params: any[] = []; let paramIndex = 1; // 회사 코드 필터링 (멀티테넌시) if (companyCode !== "*") { whereClause += ` AND company_code = $${paramIndex}`; params.push(companyCode); paramIndex++; } // 검색어 필터링 if (searchTerm) { whereClause += ` AND (group_name ILIKE $${paramIndex} OR group_code ILIKE $${paramIndex} OR description ILIKE $${paramIndex})`; params.push(`%${searchTerm}%`); paramIndex++; } // 전체 개수 조회 const countQuery = `SELECT COUNT(*) as total FROM screen_groups ${whereClause}`; const countResult = await pool.query(countQuery, params); const total = parseInt(countResult.rows[0].total); // 데이터 조회 (screens 배열 포함) const dataQuery = ` SELECT sg.*, (SELECT COUNT(*) FROM screen_group_screens sgs WHERE sgs.group_id = sg.id) as screen_count, (SELECT json_agg( json_build_object( 'id', sgs.id, 'screen_id', sgs.screen_id, 'screen_name', sd.screen_name, 'screen_role', sgs.screen_role, 'display_order', sgs.display_order, 'is_default', sgs.is_default, 'table_name', sd.table_name ) ORDER BY sgs.display_order ) FROM screen_group_screens sgs LEFT JOIN screen_definitions sd ON sgs.screen_id = sd.screen_id WHERE sgs.group_id = sg.id ) as screens FROM screen_groups sg ${whereClause} ORDER BY sg.display_order ASC, sg.created_date DESC LIMIT $${paramIndex} OFFSET $${paramIndex + 1} `; params.push(parseInt(size as string), offset); const result = await pool.query(dataQuery, params); logger.info("화면 그룹 목록 조회", { companyCode, total, count: result.rows.length }); res.json({ success: true, data: result.rows, total, page: parseInt(page as string), size: parseInt(size as string), totalPages: Math.ceil(total / parseInt(size as string)), }); } catch (error: any) { logger.error("화면 그룹 목록 조회 실패:", error); res.status(500).json({ success: false, message: "화면 그룹 목록 조회에 실패했습니다.", error: error.message }); } }; // 화면 그룹 상세 조회 export const getScreenGroup = async (req: Request, res: Response) => { try { const { id } = req.params; const companyCode = (req.user as any).companyCode; let query = ` SELECT sg.*, (SELECT json_agg( json_build_object( 'id', sgs.id, 'screen_id', sgs.screen_id, 'screen_name', sd.screen_name, 'screen_role', sgs.screen_role, 'display_order', sgs.display_order, 'is_default', sgs.is_default, 'table_name', sd.table_name ) ORDER BY sgs.display_order ) FROM screen_group_screens sgs LEFT JOIN screen_definitions sd ON sgs.screen_id = sd.screen_id WHERE sgs.group_id = sg.id ) as screens FROM screen_groups sg WHERE sg.id = $1 `; const params: any[] = [id]; // 멀티테넌시 필터링 if (companyCode !== "*") { query += ` AND sg.company_code = $2`; params.push(companyCode); } const result = await pool.query(query, params); if (result.rows.length === 0) { return res.status(404).json({ success: false, message: "화면 그룹을 찾을 수 없습니다." }); } res.json({ success: true, data: result.rows[0] }); } catch (error: any) { logger.error("화면 그룹 상세 조회 실패:", error); res.status(500).json({ success: false, message: "화면 그룹 조회에 실패했습니다.", error: error.message }); } }; // 화면 그룹 생성 export const createScreenGroup = async (req: Request, res: Response) => { try { const userCompanyCode = (req.user as any).companyCode; const userId = (req.user as any).userId; const { group_name, group_code, main_table_name, description, icon, display_order, is_active, parent_group_id, target_company_code } = req.body; if (!group_name || !group_code) { return res.status(400).json({ success: false, message: "그룹명과 그룹코드는 필수입니다." }); } // 회사 코드 결정: 최고 관리자가 특정 회사를 선택한 경우 해당 회사로, 아니면 사용자 회사 let finalCompanyCode = userCompanyCode; if (userCompanyCode === "*" && target_company_code) { // 최고 관리자가 특정 회사를 선택한 경우 finalCompanyCode = target_company_code; } // 부모 그룹이 있으면 group_level과 hierarchy_path 계산 let groupLevel = 0; let parentHierarchyPath = ""; if (parent_group_id) { const parentQuery = `SELECT id, group_level, hierarchy_path FROM screen_groups WHERE id = $1`; const parentResult = await pool.query(parentQuery, [parent_group_id]); if (parentResult.rows.length > 0) { groupLevel = (parentResult.rows[0].group_level || 0) + 1; parentHierarchyPath = parentResult.rows[0].hierarchy_path || `/${parent_group_id}/`; } } const query = ` INSERT INTO screen_groups (group_name, group_code, main_table_name, description, icon, display_order, is_active, company_code, writer, parent_group_id, group_level) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING * `; const params = [ group_name, group_code, main_table_name || null, description || null, icon || null, display_order || 0, is_active || 'Y', finalCompanyCode, userId, parent_group_id || null, groupLevel ]; const result = await pool.query(query, params); const newGroupId = result.rows[0].id; // hierarchy_path 업데이트 const hierarchyPath = parent_group_id ? `${parentHierarchyPath}${newGroupId}/`.replace('//', '/') : `/${newGroupId}/`; await pool.query(`UPDATE screen_groups SET hierarchy_path = $1 WHERE id = $2`, [hierarchyPath, newGroupId]); // 업데이트된 데이터 반환 const updatedResult = await pool.query(`SELECT * FROM screen_groups WHERE id = $1`, [newGroupId]); logger.info("화면 그룹 생성", { userCompanyCode, finalCompanyCode, groupId: newGroupId, groupName: group_name, parentGroupId: parent_group_id }); res.json({ success: true, data: updatedResult.rows[0], message: "화면 그룹이 생성되었습니다." }); } catch (error: any) { logger.error("화면 그룹 생성 실패:", error); if (error.code === '23505') { return res.status(400).json({ success: false, message: "이미 존재하는 그룹 코드입니다." }); } res.status(500).json({ success: false, message: "화면 그룹 생성에 실패했습니다.", error: error.message }); } }; // 화면 그룹 수정 export const updateScreenGroup = async (req: Request, res: Response) => { try { const { id } = req.params; const userCompanyCode = (req.user as any).companyCode; const { group_name, group_code, main_table_name, description, icon, display_order, is_active, parent_group_id, target_company_code } = req.body; // 회사 코드 결정: 최고 관리자가 특정 회사를 선택한 경우 해당 회사로, 아니면 현재 그룹의 회사 유지 let finalCompanyCode = target_company_code || null; // 부모 그룹이 변경되면 group_level과 hierarchy_path 재계산 let groupLevel = 0; let hierarchyPath = `/${id}/`; if (parent_group_id !== undefined && parent_group_id !== null) { // 자기 자신을 부모로 지정하는 것 방지 if (Number(parent_group_id) === Number(id)) { return res.status(400).json({ success: false, message: "자기 자신을 상위 그룹으로 지정할 수 없습니다." }); } const parentQuery = `SELECT id, group_level, hierarchy_path FROM screen_groups WHERE id = $1`; const parentResult = await pool.query(parentQuery, [parent_group_id]); if (parentResult.rows.length > 0) { // 순환 참조 방지: 부모의 hierarchy_path에 현재 그룹 ID가 포함되어 있으면 오류 if (parentResult.rows[0].hierarchy_path && parentResult.rows[0].hierarchy_path.includes(`/${id}/`)) { return res.status(400).json({ success: false, message: "하위 그룹을 상위 그룹으로 지정할 수 없습니다." }); } groupLevel = (parentResult.rows[0].group_level || 0) + 1; const parentPath = parentResult.rows[0].hierarchy_path || `/${parent_group_id}/`; hierarchyPath = `${parentPath}${id}/`.replace('//', '/'); } } // 쿼리 구성: 회사 코드 변경 포함 여부 let query: string; let params: any[]; if (userCompanyCode === "*" && finalCompanyCode) { // 최고 관리자가 회사를 변경하는 경우 query = ` UPDATE screen_groups SET group_name = $1, group_code = $2, main_table_name = $3, description = $4, icon = $5, display_order = $6, is_active = $7, updated_date = NOW(), parent_group_id = $8, group_level = $9, hierarchy_path = $10, company_code = $11 WHERE id = $12 `; params = [ group_name, group_code, main_table_name, description, icon, display_order, is_active, parent_group_id || null, groupLevel, hierarchyPath, finalCompanyCode, id ]; } else { // 회사 코드 변경 없음 query = ` UPDATE screen_groups SET group_name = $1, group_code = $2, main_table_name = $3, description = $4, icon = $5, display_order = $6, is_active = $7, updated_date = NOW(), parent_group_id = $8, group_level = $9, hierarchy_path = $10 WHERE id = $11 `; params = [ group_name, group_code, main_table_name, description, icon, display_order, is_active, parent_group_id || null, groupLevel, hierarchyPath, id ]; } // 멀티테넌시 필터링 (최고 관리자가 아닌 경우) if (userCompanyCode !== "*") { const paramIndex = params.length + 1; query += ` AND company_code = $${paramIndex}`; params.push(userCompanyCode); } query += " RETURNING *"; const result = await pool.query(query, params); if (result.rows.length === 0) { return res.status(404).json({ success: false, message: "화면 그룹을 찾을 수 없거나 권한이 없습니다." }); } logger.info("화면 그룹 수정", { userCompanyCode, groupId: id, parentGroupId: parent_group_id, targetCompanyCode: finalCompanyCode }); res.json({ success: true, data: result.rows[0], message: "화면 그룹이 수정되었습니다." }); } catch (error: any) { logger.error("화면 그룹 수정 실패:", error); res.status(500).json({ success: false, message: "화면 그룹 수정에 실패했습니다.", error: error.message }); } }; // 화면 그룹 삭제 export const deleteScreenGroup = async (req: Request, res: Response) => { try { const { id } = req.params; const companyCode = (req.user as any).companyCode; let query = `DELETE FROM screen_groups WHERE id = $1`; const params: any[] = [id]; if (companyCode !== "*") { query += ` AND company_code = $2`; params.push(companyCode); } query += " RETURNING id"; const result = await pool.query(query, params); if (result.rows.length === 0) { return res.status(404).json({ success: false, message: "화면 그룹을 찾을 수 없거나 권한이 없습니다." }); } logger.info("화면 그룹 삭제", { companyCode, groupId: id }); res.json({ success: true, message: "화면 그룹이 삭제되었습니다." }); } catch (error: any) { logger.error("화면 그룹 삭제 실패:", error); res.status(500).json({ success: false, message: "화면 그룹 삭제에 실패했습니다.", error: error.message }); } }; // ============================================================ // 화면-그룹 연결 (screen_group_screens) CRUD // ============================================================ // 그룹에 화면 추가 export const addScreenToGroup = async (req: Request, res: Response) => { try { const companyCode = (req.user as any).companyCode; const userId = (req.user as any).userId; const { group_id, screen_id, screen_role, display_order, is_default } = req.body; if (!group_id || !screen_id) { return res.status(400).json({ success: false, message: "그룹 ID와 화면 ID는 필수입니다." }); } const query = ` INSERT INTO screen_group_screens (group_id, screen_id, screen_role, display_order, is_default, company_code, writer) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING * `; const params = [ group_id, screen_id, screen_role || 'main', display_order || 0, is_default || 'N', companyCode === "*" ? "*" : companyCode, userId ]; const result = await pool.query(query, params); logger.info("화면-그룹 연결 추가", { companyCode, groupId: group_id, screenId: screen_id }); res.json({ success: true, data: result.rows[0], message: "화면이 그룹에 추가되었습니다." }); } catch (error: any) { logger.error("화면-그룹 연결 추가 실패:", error); if (error.code === '23505') { return res.status(400).json({ success: false, message: "이미 그룹에 추가된 화면입니다." }); } res.status(500).json({ success: false, message: "화면 추가에 실패했습니다.", error: error.message }); } }; // 그룹에서 화면 제거 export const removeScreenFromGroup = async (req: Request, res: Response) => { try { const { id } = req.params; const companyCode = (req.user as any).companyCode; let query = `DELETE FROM screen_group_screens WHERE id = $1`; const params: any[] = [id]; if (companyCode !== "*") { query += ` AND company_code = $2`; params.push(companyCode); } query += " RETURNING id"; const result = await pool.query(query, params); if (result.rows.length === 0) { return res.status(404).json({ success: false, message: "연결을 찾을 수 없거나 권한이 없습니다." }); } logger.info("화면-그룹 연결 제거", { companyCode, id }); res.json({ success: true, message: "화면이 그룹에서 제거되었습니다." }); } catch (error: any) { logger.error("화면-그룹 연결 제거 실패:", error); res.status(500).json({ success: false, message: "화면 제거에 실패했습니다.", error: error.message }); } }; // 그룹 내 화면 순서/역할 수정 export const updateScreenInGroup = async (req: Request, res: Response) => { try { const { id } = req.params; const companyCode = (req.user as any).companyCode; const { screen_role, display_order, is_default } = req.body; let query = ` UPDATE screen_group_screens SET screen_role = $1, display_order = $2, is_default = $3, updated_date = NOW() WHERE id = $4 `; const params: any[] = [screen_role, display_order, is_default, id]; if (companyCode !== "*") { query += ` AND company_code = $5`; params.push(companyCode); } query += " RETURNING *"; const result = await pool.query(query, params); if (result.rows.length === 0) { return res.status(404).json({ success: false, message: "연결을 찾을 수 없거나 권한이 없습니다." }); } res.json({ success: true, data: result.rows[0], message: "화면 정보가 수정되었습니다." }); } catch (error: any) { logger.error("화면-그룹 연결 수정 실패:", error); res.status(500).json({ success: false, message: "화면 정보 수정에 실패했습니다.", error: error.message }); } }; // ============================================================ // 화면 필드 조인 설정 (screen_field_joins) CRUD // ============================================================ // 화면 필드 조인 목록 조회 export const getFieldJoins = async (req: Request, res: Response) => { try { const companyCode = (req.user as any).companyCode; const { screen_id } = req.query; let query = ` SELECT sfj.*, tl1.table_label as save_table_label, tl2.table_label as join_table_label FROM screen_field_joins sfj LEFT JOIN table_labels tl1 ON sfj.save_table = tl1.table_name LEFT JOIN table_labels tl2 ON sfj.join_table = tl2.table_name WHERE 1=1 `; const params: any[] = []; let paramIndex = 1; if (companyCode !== "*") { query += ` AND sfj.company_code = $${paramIndex}`; params.push(companyCode); paramIndex++; } if (screen_id) { query += ` AND sfj.screen_id = $${paramIndex}`; params.push(screen_id); paramIndex++; } query += " ORDER BY sfj.id ASC"; const result = await pool.query(query, params); res.json({ success: true, data: result.rows }); } catch (error: any) { logger.error("필드 조인 목록 조회 실패:", error); res.status(500).json({ success: false, message: "필드 조인 목록 조회에 실패했습니다.", error: error.message }); } }; // 화면 필드 조인 생성 export const createFieldJoin = async (req: Request, res: Response) => { try { const companyCode = (req.user as any).companyCode; const userId = (req.user as any).userId; const { screen_id, layout_id, component_id, field_name, save_table, save_column, join_table, join_column, display_column, join_type, filter_condition, sort_column, sort_direction, is_active } = req.body; if (!screen_id || !save_table || !save_column || !join_table || !join_column || !display_column) { return res.status(400).json({ success: false, message: "필수 필드가 누락되었습니다." }); } const query = ` INSERT INTO screen_field_joins ( screen_id, layout_id, component_id, field_name, save_table, save_column, join_table, join_column, display_column, join_type, filter_condition, sort_column, sort_direction, is_active, company_code, writer ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) RETURNING * `; const params = [ screen_id, layout_id || null, component_id || null, field_name || null, save_table, save_column, join_table, join_column, display_column, join_type || 'LEFT', filter_condition || null, sort_column || null, sort_direction || 'ASC', is_active || 'Y', companyCode === "*" ? "*" : companyCode, userId ]; const result = await pool.query(query, params); logger.info("필드 조인 생성", { companyCode, screenId: screen_id, id: result.rows[0].id }); res.json({ success: true, data: result.rows[0], message: "필드 조인이 생성되었습니다." }); } catch (error: any) { logger.error("필드 조인 생성 실패:", error); res.status(500).json({ success: false, message: "필드 조인 생성에 실패했습니다.", error: error.message }); } }; // 화면 필드 조인 수정 export const updateFieldJoin = async (req: Request, res: Response) => { try { const { id } = req.params; const companyCode = (req.user as any).companyCode; const { layout_id, component_id, field_name, save_table, save_column, join_table, join_column, display_column, join_type, filter_condition, sort_column, sort_direction, is_active } = req.body; let query = ` UPDATE screen_field_joins SET layout_id = $1, component_id = $2, field_name = $3, save_table = $4, save_column = $5, join_table = $6, join_column = $7, display_column = $8, join_type = $9, filter_condition = $10, sort_column = $11, sort_direction = $12, is_active = $13, updated_date = NOW() WHERE id = $14 `; const params: any[] = [ layout_id, component_id, field_name, save_table, save_column, join_table, join_column, display_column, join_type, filter_condition, sort_column, sort_direction, is_active, id ]; if (companyCode !== "*") { query += ` AND company_code = $15`; params.push(companyCode); } query += " RETURNING *"; const result = await pool.query(query, params); if (result.rows.length === 0) { return res.status(404).json({ success: false, message: "필드 조인을 찾을 수 없거나 권한이 없습니다." }); } res.json({ success: true, data: result.rows[0], message: "필드 조인이 수정되었습니다." }); } catch (error: any) { logger.error("필드 조인 수정 실패:", error); res.status(500).json({ success: false, message: "필드 조인 수정에 실패했습니다.", error: error.message }); } }; // 화면 필드 조인 삭제 export const deleteFieldJoin = async (req: Request, res: Response) => { try { const { id } = req.params; const companyCode = (req.user as any).companyCode; let query = `DELETE FROM screen_field_joins WHERE id = $1`; const params: any[] = [id]; if (companyCode !== "*") { query += ` AND company_code = $2`; params.push(companyCode); } query += " RETURNING id"; const result = await pool.query(query, params); if (result.rows.length === 0) { return res.status(404).json({ success: false, message: "필드 조인을 찾을 수 없거나 권한이 없습니다." }); } res.json({ success: true, message: "필드 조인이 삭제되었습니다." }); } catch (error: any) { logger.error("필드 조인 삭제 실패:", error); res.status(500).json({ success: false, message: "필드 조인 삭제에 실패했습니다.", error: error.message }); } }; // ============================================================ // 데이터 흐름 (screen_data_flows) CRUD // ============================================================ // 데이터 흐름 목록 조회 export const getDataFlows = async (req: Request, res: Response) => { try { const companyCode = (req.user as any).companyCode; const { group_id, source_screen_id } = req.query; let query = ` SELECT sdf.*, sd1.screen_name as source_screen_name, sd2.screen_name as target_screen_name, sg.group_name FROM screen_data_flows sdf LEFT JOIN screen_definitions sd1 ON sdf.source_screen_id = sd1.screen_id LEFT JOIN screen_definitions sd2 ON sdf.target_screen_id = sd2.screen_id LEFT JOIN screen_groups sg ON sdf.group_id = sg.id WHERE 1=1 `; const params: any[] = []; let paramIndex = 1; if (companyCode !== "*") { query += ` AND sdf.company_code = $${paramIndex}`; params.push(companyCode); paramIndex++; } if (group_id) { query += ` AND sdf.group_id = $${paramIndex}`; params.push(group_id); paramIndex++; } // 특정 화면에서 시작하는 데이터 흐름만 조회 if (source_screen_id) { query += ` AND sdf.source_screen_id = $${paramIndex}`; params.push(source_screen_id); paramIndex++; } query += " ORDER BY sdf.id ASC"; const result = await pool.query(query, params); res.json({ success: true, data: result.rows }); } catch (error: any) { logger.error("데이터 흐름 목록 조회 실패:", error); res.status(500).json({ success: false, message: "데이터 흐름 목록 조회에 실패했습니다.", error: error.message }); } }; // 데이터 흐름 생성 export const createDataFlow = async (req: Request, res: Response) => { try { const companyCode = (req.user as any).companyCode; const userId = (req.user as any).userId; const { group_id, source_screen_id, source_action, target_screen_id, target_action, data_mapping, flow_type, flow_label, condition_expression, is_active } = req.body; if (!source_screen_id || !target_screen_id) { return res.status(400).json({ success: false, message: "소스 화면과 타겟 화면은 필수입니다." }); } const query = ` INSERT INTO screen_data_flows ( group_id, source_screen_id, source_action, target_screen_id, target_action, data_mapping, flow_type, flow_label, condition_expression, is_active, company_code, writer ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING * `; const params = [ group_id || null, source_screen_id, source_action || null, target_screen_id, target_action || null, data_mapping ? JSON.stringify(data_mapping) : null, flow_type || 'unidirectional', flow_label || null, condition_expression || null, is_active || 'Y', companyCode === "*" ? "*" : companyCode, userId ]; const result = await pool.query(query, params); logger.info("데이터 흐름 생성", { companyCode, id: result.rows[0].id }); res.json({ success: true, data: result.rows[0], message: "데이터 흐름이 생성되었습니다." }); } catch (error: any) { logger.error("데이터 흐름 생성 실패:", error); res.status(500).json({ success: false, message: "데이터 흐름 생성에 실패했습니다.", error: error.message }); } }; // 데이터 흐름 수정 export const updateDataFlow = async (req: Request, res: Response) => { try { const { id } = req.params; const companyCode = (req.user as any).companyCode; const { group_id, source_screen_id, source_action, target_screen_id, target_action, data_mapping, flow_type, flow_label, condition_expression, is_active } = req.body; let query = ` UPDATE screen_data_flows SET group_id = $1, source_screen_id = $2, source_action = $3, target_screen_id = $4, target_action = $5, data_mapping = $6, flow_type = $7, flow_label = $8, condition_expression = $9, is_active = $10, updated_date = NOW() WHERE id = $11 `; const params: any[] = [ group_id, source_screen_id, source_action, target_screen_id, target_action, data_mapping ? JSON.stringify(data_mapping) : null, flow_type, flow_label, condition_expression, is_active, id ]; if (companyCode !== "*") { query += ` AND company_code = $12`; params.push(companyCode); } query += " RETURNING *"; const result = await pool.query(query, params); if (result.rows.length === 0) { return res.status(404).json({ success: false, message: "데이터 흐름을 찾을 수 없거나 권한이 없습니다." }); } res.json({ success: true, data: result.rows[0], message: "데이터 흐름이 수정되었습니다." }); } catch (error: any) { logger.error("데이터 흐름 수정 실패:", error); res.status(500).json({ success: false, message: "데이터 흐름 수정에 실패했습니다.", error: error.message }); } }; // 데이터 흐름 삭제 export const deleteDataFlow = async (req: Request, res: Response) => { try { const { id } = req.params; const companyCode = (req.user as any).companyCode; let query = `DELETE FROM screen_data_flows WHERE id = $1`; const params: any[] = [id]; if (companyCode !== "*") { query += ` AND company_code = $2`; params.push(companyCode); } query += " RETURNING id"; const result = await pool.query(query, params); if (result.rows.length === 0) { return res.status(404).json({ success: false, message: "데이터 흐름을 찾을 수 없거나 권한이 없습니다." }); } res.json({ success: true, message: "데이터 흐름이 삭제되었습니다." }); } catch (error: any) { logger.error("데이터 흐름 삭제 실패:", error); res.status(500).json({ success: false, message: "데이터 흐름 삭제에 실패했습니다.", error: error.message }); } }; // ============================================================ // 화면-테이블 관계 (screen_table_relations) CRUD // ============================================================ // 화면-테이블 관계 목록 조회 export const getTableRelations = async (req: Request, res: Response) => { try { const companyCode = (req.user as any).companyCode; const { screen_id, group_id } = req.query; let query = ` SELECT str.*, sd.screen_name, sg.group_name, tl.table_label FROM screen_table_relations str LEFT JOIN screen_definitions sd ON str.screen_id = sd.screen_id LEFT JOIN screen_groups sg ON str.group_id = sg.id LEFT JOIN table_labels tl ON str.table_name = tl.table_name WHERE 1=1 `; const params: any[] = []; let paramIndex = 1; if (companyCode !== "*") { query += ` AND str.company_code = $${paramIndex}`; params.push(companyCode); paramIndex++; } if (screen_id) { query += ` AND str.screen_id = $${paramIndex}`; params.push(screen_id); paramIndex++; } if (group_id) { query += ` AND str.group_id = $${paramIndex}`; params.push(group_id); paramIndex++; } query += " ORDER BY str.id ASC"; const result = await pool.query(query, params); res.json({ success: true, data: result.rows }); } catch (error: any) { logger.error("화면-테이블 관계 목록 조회 실패:", error); res.status(500).json({ success: false, message: "화면-테이블 관계 목록 조회에 실패했습니다.", error: error.message }); } }; // 화면-테이블 관계 생성 export const createTableRelation = async (req: Request, res: Response) => { try { const companyCode = (req.user as any).companyCode; const userId = (req.user as any).userId; const { group_id, screen_id, table_name, relation_type, crud_operations, description, is_active } = req.body; if (!screen_id || !table_name) { return res.status(400).json({ success: false, message: "화면 ID와 테이블명은 필수입니다." }); } const query = ` INSERT INTO screen_table_relations (group_id, screen_id, table_name, relation_type, crud_operations, description, is_active, company_code, writer) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING * `; const params = [ group_id || null, screen_id, table_name, relation_type || 'main', crud_operations || 'CRUD', description || null, is_active || 'Y', companyCode === "*" ? "*" : companyCode, userId ]; const result = await pool.query(query, params); logger.info("화면-테이블 관계 생성", { companyCode, screenId: screen_id, tableName: table_name }); res.json({ success: true, data: result.rows[0], message: "화면-테이블 관계가 생성되었습니다." }); } catch (error: any) { logger.error("화면-테이블 관계 생성 실패:", error); res.status(500).json({ success: false, message: "화면-테이블 관계 생성에 실패했습니다.", error: error.message }); } }; // 화면-테이블 관계 수정 export const updateTableRelation = async (req: Request, res: Response) => { try { const { id } = req.params; const companyCode = (req.user as any).companyCode; const { group_id, table_name, relation_type, crud_operations, description, is_active } = req.body; let query = ` UPDATE screen_table_relations SET group_id = $1, table_name = $2, relation_type = $3, crud_operations = $4, description = $5, is_active = $6, updated_date = NOW() WHERE id = $7 `; const params: any[] = [group_id, table_name, relation_type, crud_operations, description, is_active, id]; if (companyCode !== "*") { query += ` AND company_code = $8`; params.push(companyCode); } query += " RETURNING *"; const result = await pool.query(query, params); if (result.rows.length === 0) { return res.status(404).json({ success: false, message: "화면-테이블 관계를 찾을 수 없거나 권한이 없습니다." }); } res.json({ success: true, data: result.rows[0], message: "화면-테이블 관계가 수정되었습니다." }); } catch (error: any) { logger.error("화면-테이블 관계 수정 실패:", error); res.status(500).json({ success: false, message: "화면-테이블 관계 수정에 실패했습니다.", error: error.message }); } }; // 화면-테이블 관계 삭제 export const deleteTableRelation = async (req: Request, res: Response) => { try { const { id } = req.params; const companyCode = (req.user as any).companyCode; let query = `DELETE FROM screen_table_relations WHERE id = $1`; const params: any[] = [id]; if (companyCode !== "*") { query += ` AND company_code = $2`; params.push(companyCode); } query += " RETURNING id"; const result = await pool.query(query, params); if (result.rows.length === 0) { return res.status(404).json({ success: false, message: "화면-테이블 관계를 찾을 수 없거나 권한이 없습니다." }); } res.json({ success: true, message: "화면-테이블 관계가 삭제되었습니다." }); } catch (error: any) { logger.error("화면-테이블 관계 삭제 실패:", error); res.status(500).json({ success: false, message: "화면-테이블 관계 삭제에 실패했습니다.", error: error.message }); } }; // ============================================================ // 화면 레이아웃 요약 정보 (미리보기용) // ============================================================ // 화면 레이아웃 요약 조회 (위젯 타입별 개수, 라벨 목록) export const getScreenLayoutSummary = async (req: Request, res: Response) => { try { const { screenId } = req.params; // 화면의 컴포넌트 정보 조회 const query = ` SELECT properties->>'widgetType' as widget_type, properties->>'label' as label, properties->>'fieldName' as field_name, properties->>'tableName' as table_name FROM screen_layouts WHERE screen_id = $1 AND component_type = 'component' ORDER BY display_order ASC `; const result = await pool.query(query, [screenId]); // 위젯 타입별 집계 const widgetCounts: Record = {}; const labels: string[] = []; const fields: Array<{ label: string; widgetType: string; fieldName?: string }> = []; result.rows.forEach((row: any) => { const widgetType = row.widget_type || 'text'; widgetCounts[widgetType] = (widgetCounts[widgetType] || 0) + 1; if (row.label && row.label !== '기본 버튼') { labels.push(row.label); fields.push({ label: row.label, widgetType: widgetType, fieldName: row.field_name, }); } }); // 화면 타입 추론 (가장 많은 컴포넌트 기준) let screenType = 'form'; // 기본값 if (widgetCounts['table'] > 0) { screenType = 'grid'; } else if (widgetCounts['custom'] > 2) { screenType = 'dashboard'; } else if (Object.keys(widgetCounts).length <= 2 && widgetCounts['button'] > 0) { screenType = 'action'; } logger.info("화면 레이아웃 요약 조회", { screenId, widgetCounts, fieldCount: fields.length }); res.json({ success: true, data: { screenId: parseInt(screenId), screenType, widgetCounts, totalComponents: result.rows.length, fields: fields.slice(0, 10), // 최대 10개 labels: labels.slice(0, 8), // 최대 8개 }, }); } catch (error: any) { logger.error("화면 레이아웃 요약 조회 실패:", error); res.status(500).json({ success: false, message: "화면 레이아웃 요약 조회에 실패했습니다.", error: error.message }); } }; // 여러 화면의 레이아웃 요약 일괄 조회 (미니어처 렌더링용 좌표 포함) export const getMultipleScreenLayoutSummary = async (req: Request, res: Response) => { try { const { screenIds } = req.body; if (!screenIds || !Array.isArray(screenIds) || screenIds.length === 0) { return res.status(400).json({ success: false, message: "screenIds 배열이 필요합니다." }); } // 여러 화면의 컴포넌트 정보 (좌표 포함) 한번에 조회 // componentType이 더 정확한 위젯 종류 (table-list, button-primary 등) // 다양한 컴포넌트 타입에서 사용 컬럼 추출 const query = ` SELECT screen_id, component_type, position_x, position_y, width, height, properties->>'componentType' as component_kind, properties->>'widgetType' as widget_type, properties->>'label' as label, COALESCE( properties->'componentConfig'->>'bindField', properties->>'bindField', properties->'componentConfig'->>'field', properties->>'field' ) as bind_field, -- componentConfig 전체 (JavaScript에서 다양한 패턴 파싱용) properties->'componentConfig' as component_config FROM screen_layouts WHERE screen_id = ANY($1) AND component_type = 'component' ORDER BY screen_id, display_order ASC `; const result = await pool.query(query, [screenIds]); // 화면별로 그룹핑 const summaryMap: Record = {}; screenIds.forEach((id: number) => { summaryMap[id] = { screenId: id, screenType: 'form', widgetCounts: {}, totalComponents: 0, // 미니어처 렌더링용 레이아웃 데이터 layoutItems: [], canvasWidth: 0, canvasHeight: 0, }; }); result.rows.forEach((row: any) => { const screenId = row.screen_id; // componentKind가 더 정확한 타입 (table-list, button-primary, table-search-widget 등) const componentKind = row.component_kind || row.widget_type || 'text'; const widgetType = row.widget_type || 'text'; const componentConfig = row.component_config || {}; // 다양한 컴포넌트 타입에서 usedColumns, joinColumns 추출 let usedColumns: string[] = []; let joinColumns: string[] = []; // 1. 기본 columns 배열에서 추출 (table-list 등) if (Array.isArray(componentConfig.columns)) { componentConfig.columns.forEach((col: any) => { const colName = col.columnName || col.field || col.name; if (colName && !usedColumns.includes(colName)) { usedColumns.push(colName); } if (col.isEntityJoin === true && colName && !joinColumns.includes(colName)) { joinColumns.push(colName); } }); } // 2. split-panel-layout의 leftPanel.columns, rightPanel.columns 추출 if (componentKind === 'split-panel-layout') { if (componentConfig.leftPanel?.columns && Array.isArray(componentConfig.leftPanel.columns)) { componentConfig.leftPanel.columns.forEach((col: any) => { const colName = col.name || col.columnName || col.field; if (colName && !usedColumns.includes(colName)) { usedColumns.push(colName); } }); } if (componentConfig.rightPanel?.columns && Array.isArray(componentConfig.rightPanel.columns)) { componentConfig.rightPanel.columns.forEach((col: any) => { const colName = col.name || col.columnName || col.field; if (colName) { // customer_mng.customer_name 같은 경우 조인 컬럼으로 처리 if (colName.includes('.')) { if (!joinColumns.includes(colName)) { joinColumns.push(colName); } } else { if (!usedColumns.includes(colName)) { usedColumns.push(colName); } } } }); } } // 3. selected-items-detail-input의 additionalFields, displayColumns 추출 if (componentKind === 'selected-items-detail-input') { if (componentConfig.additionalFields && Array.isArray(componentConfig.additionalFields)) { componentConfig.additionalFields.forEach((field: any) => { const fieldName = field.name || field.field; if (fieldName && !usedColumns.includes(fieldName)) { usedColumns.push(fieldName); } }); } // displayColumns는 연관 테이블에서 가져오는 표시용 컬럼이므로 // 메인 테이블의 joinColumns가 아님 (parentDataMapping에서 별도 추출됨) // 단, 참조용으로 usedColumns에는 추가 가능 if (componentConfig.displayColumns && Array.isArray(componentConfig.displayColumns)) { componentConfig.displayColumns.forEach((col: any) => { const colName = col.name || col.columnName || col.field; // displayColumns는 연관 테이블 컬럼이므로 메인 테이블 usedColumns에 추가하지 않음 // 조인 컬럼은 parentDataMapping.targetField에서 추출됨 }); } } if (summaryMap[screenId]) { summaryMap[screenId].widgetCounts[componentKind] = (summaryMap[screenId].widgetCounts[componentKind] || 0) + 1; summaryMap[screenId].totalComponents++; // 레이아웃 아이템 추가 (미니어처 렌더링용) summaryMap[screenId].layoutItems.push({ x: row.position_x || 0, y: row.position_y || 0, width: row.width || 100, height: row.height || 30, componentKind: componentKind, // 정확한 컴포넌트 종류 widgetType: widgetType, label: row.label, bindField: row.bind_field || null, // 바인딩된 컬럼명 usedColumns: usedColumns, // 이 컴포넌트에서 사용하는 컬럼 목록 joinColumns: joinColumns, // 이 컴포넌트에서 조인 컬럼 목록 }); // 캔버스 크기 계산 (최대 좌표 기준) const rightEdge = (row.position_x || 0) + (row.width || 100); const bottomEdge = (row.position_y || 0) + (row.height || 30); if (rightEdge > summaryMap[screenId].canvasWidth) { summaryMap[screenId].canvasWidth = rightEdge; } if (bottomEdge > summaryMap[screenId].canvasHeight) { summaryMap[screenId].canvasHeight = bottomEdge; } } }); // 화면 타입 추론 (componentKind 기준) Object.values(summaryMap).forEach((summary: any) => { if (summary.widgetCounts['table-list'] > 0) { summary.screenType = 'grid'; } else if (summary.widgetCounts['table-search-widget'] > 1) { summary.screenType = 'dashboard'; } else if (summary.totalComponents <= 5 && summary.widgetCounts['button-primary'] > 0) { summary.screenType = 'action'; } }); logger.info("여러 화면 레이아웃 요약 조회", { screenIds, count: Object.keys(summaryMap).length }); res.json({ success: true, data: summaryMap, }); } catch (error: any) { logger.error("여러 화면 레이아웃 요약 조회 실패:", error); res.status(500).json({ success: false, message: "여러 화면 레이아웃 요약 조회에 실패했습니다.", error: error.message }); } }; // ============================================================ // 화면 서브 테이블 관계 조회 (조인/참조 테이블) // ============================================================ // 여러 화면의 서브 테이블 정보 조회 (메인 테이블 → 서브 테이블 관계) export const getScreenSubTables = async (req: Request, res: Response) => { try { const { screenIds } = req.body; if (!screenIds || !Array.isArray(screenIds) || screenIds.length === 0) { return res.status(400).json({ success: false, message: "screenIds 배열이 필요합니다." }); } // 화면별 서브 테이블 그룹화 const screenSubTables: Record; }>; saveTables?: Array<{ tableName: string; saveType: 'save' | 'edit' | 'delete' | 'transferData'; componentType: string; isMainTable: boolean; }>; }> = {}; // 1. 기존 방식: componentConfig에서 tableName, sourceTable, fieldMappings 추출 const componentQuery = ` SELECT DISTINCT sd.screen_id, sd.screen_name, sd.table_name as main_table, COALESCE( sl.properties->'componentConfig'->>'tableName', sl.properties->'componentConfig'->>'sourceTable' ) as sub_table, sl.properties->>'componentType' as component_type, sl.properties->'componentConfig'->>'targetTable' as target_table, sl.properties->'componentConfig'->'fieldMappings' as field_mappings, sl.properties->'componentConfig'->'columns' as columns_config FROM screen_definitions sd JOIN screen_layouts sl ON sd.screen_id = sl.screen_id WHERE sd.screen_id = ANY($1) AND ( sl.properties->'componentConfig'->>'tableName' IS NOT NULL OR sl.properties->'componentConfig'->>'sourceTable' IS NOT NULL ) ORDER BY sd.screen_id `; const componentResult = await pool.query(componentQuery, [screenIds]); // fieldMappings의 한글 컬럼명을 조회하기 위한 테이블-컬럼 쌍 수집 const columnLabelLookups: Array<{ table: string; column: string }> = []; componentResult.rows.forEach((row: any) => { if (row.field_mappings && Array.isArray(row.field_mappings)) { row.field_mappings.forEach((fm: any) => { const mainTable = row.main_table; const subTable = row.sub_table; if (fm.sourceField && subTable) { columnLabelLookups.push({ table: subTable, column: fm.sourceField }); } if (fm.targetField && mainTable) { columnLabelLookups.push({ table: mainTable, column: fm.targetField }); } }); } }); // 한글 컬럼명 조회 const columnLabelMap = new Map(); // "table.column" -> "한글명" if (columnLabelLookups.length > 0) { const uniqueLookups = [...new Set(columnLabelLookups.map(l => `${l.table}|${l.column}`))]; const conditions = uniqueLookups.map((lookup, i) => { const [table, column] = lookup.split('|'); return `(table_name = $${i * 2 + 1} AND column_name = $${i * 2 + 2})`; }); const params = uniqueLookups.flatMap(lookup => lookup.split('|')); if (conditions.length > 0) { const labelQuery = ` SELECT table_name, column_name, column_label FROM column_labels WHERE ${conditions.join(' OR ')} `; const labelResult = await pool.query(labelQuery, params); labelResult.rows.forEach((row: any) => { const key = `${row.table_name}.${row.column_name}`; columnLabelMap.set(key, row.column_label || row.column_name); }); } } componentResult.rows.forEach((row: any) => { const screenId = row.screen_id; const mainTable = row.main_table; const subTable = row.sub_table; // 메인 테이블과 동일한 경우 제외 if (!subTable || subTable === mainTable) { return; } if (!screenSubTables[screenId]) { screenSubTables[screenId] = { screenId, screenName: row.screen_name, mainTable: mainTable || '', subTables: [], }; } // 중복 체크 const exists = screenSubTables[screenId].subTables.some( (st) => st.tableName === subTable ); if (!exists) { // 관계 타입 추론 let relationType = 'lookup'; const componentType = row.component_type || ''; if (componentType.includes('autocomplete') || componentType.includes('entity-search')) { relationType = 'lookup'; } else if (componentType.includes('modal-repeater') || componentType.includes('selected-items')) { relationType = 'source'; } else if (componentType.includes('table')) { relationType = 'join'; } // fieldMappings 파싱 (JSON 배열 또는 null) let fieldMappings: Array<{ sourceField: string; targetField: string; sourceDisplayName?: string; targetDisplayName?: string }> | undefined; if (row.field_mappings && Array.isArray(row.field_mappings)) { // 1. 직접 fieldMappings가 있는 경우 fieldMappings = row.field_mappings.map((fm: any) => { const sourceField = fm.sourceField || fm.source_field || ''; const targetField = fm.targetField || fm.target_field || ''; // 한글 컬럼명 조회 (sourceField는 서브테이블 컬럼, targetField는 메인테이블 컬럼) const sourceKey = `${subTable}.${sourceField}`; const targetKey = `${mainTable}.${targetField}`; return { sourceField, targetField, // sourceField(서브테이블 컬럼)의 한글명 sourceDisplayName: columnLabelMap.get(sourceKey) || sourceField, // targetField(메인테이블 컬럼)의 한글명 targetDisplayName: columnLabelMap.get(targetKey) || targetField, }; }).filter((fm: any) => fm.sourceField || fm.targetField); } else if (row.columns_config && Array.isArray(row.columns_config)) { // 2. columns_config.mapping에서 추출 (item_info 같은 경우) // mapping.type === 'source'인 경우: sourceField(서브테이블) → field(메인테이블) fieldMappings = []; row.columns_config.forEach((col: any) => { if (col.mapping && col.mapping.type === 'source' && col.mapping.sourceField) { fieldMappings!.push({ sourceField: col.field || '', // 메인 테이블 컬럼 targetField: col.mapping.sourceField || '', // 서브 테이블 컬럼 sourceDisplayName: col.label || col.field || '', // 한글 라벨 targetDisplayName: col.mapping.sourceField || '', // 서브 테이블은 영문만 }); } }); if (fieldMappings.length === 0) { fieldMappings = undefined; } } screenSubTables[screenId].subTables.push({ tableName: subTable, componentType: componentType, relationType: relationType, fieldMappings: fieldMappings, }); } }); // 2. 추가 방식: 화면에서 사용하는 컬럼 중 column_labels.reference_table이 설정된 경우 // 화면의 usedColumns/joinColumns에서 reference_table 조회 const referenceQuery = ` WITH screen_used_columns AS ( -- 화면별 사용 컬럼 추출 (componentConfig.columns에서) SELECT sd.screen_id, sd.screen_name, sd.table_name as main_table, jsonb_array_elements_text( COALESCE( sl.properties->'componentConfig'->'columns', '[]'::jsonb ) )::jsonb->>'columnName' as column_name FROM screen_definitions sd JOIN screen_layouts sl ON sd.screen_id = sl.screen_id WHERE sd.screen_id = ANY($1) AND sl.properties->'componentConfig'->'columns' IS NOT NULL AND jsonb_array_length(sl.properties->'componentConfig'->'columns') > 0 UNION -- bindField도 포함 SELECT sd.screen_id, sd.screen_name, sd.table_name as main_table, COALESCE( sl.properties->'componentConfig'->>'bindField', sl.properties->>'bindField', sl.properties->'componentConfig'->>'field', sl.properties->>'field' ) as column_name FROM screen_definitions sd JOIN screen_layouts sl ON sd.screen_id = sl.screen_id WHERE sd.screen_id = ANY($1) AND ( sl.properties->'componentConfig'->>'bindField' IS NOT NULL OR sl.properties->>'bindField' IS NOT NULL OR sl.properties->'componentConfig'->>'field' IS NOT NULL OR sl.properties->>'field' IS NOT NULL ) UNION -- valueField 추출 (entity-search-input, autocomplete-search-input 등에서 사용) SELECT sd.screen_id, sd.screen_name, sd.table_name as main_table, sl.properties->'componentConfig'->>'valueField' as column_name FROM screen_definitions sd JOIN screen_layouts sl ON sd.screen_id = sl.screen_id WHERE sd.screen_id = ANY($1) AND sl.properties->'componentConfig'->>'valueField' IS NOT NULL UNION -- parentFieldId 추출 (캐스케이딩 관계에서 사용) SELECT sd.screen_id, sd.screen_name, sd.table_name as main_table, sl.properties->'componentConfig'->>'parentFieldId' as column_name FROM screen_definitions sd JOIN screen_layouts sl ON sd.screen_id = sl.screen_id WHERE sd.screen_id = ANY($1) AND sl.properties->'componentConfig'->>'parentFieldId' IS NOT NULL UNION -- cascadingParentField 추출 (캐스케이딩 부모 필드) SELECT sd.screen_id, sd.screen_name, sd.table_name as main_table, sl.properties->'componentConfig'->>'cascadingParentField' as column_name FROM screen_definitions sd JOIN screen_layouts sl ON sd.screen_id = sl.screen_id WHERE sd.screen_id = ANY($1) AND sl.properties->'componentConfig'->>'cascadingParentField' IS NOT NULL UNION -- controlField 추출 (conditional-container에서 사용) SELECT sd.screen_id, sd.screen_name, sd.table_name as main_table, sl.properties->'componentConfig'->>'controlField' as column_name FROM screen_definitions sd JOIN screen_layouts sl ON sd.screen_id = sl.screen_id WHERE sd.screen_id = ANY($1) AND sl.properties->'componentConfig'->>'controlField' IS NOT NULL ) SELECT DISTINCT suc.screen_id, suc.screen_name, suc.main_table, suc.column_name, cl.column_label as source_display_name, cl.reference_table, cl.reference_column, ref_cl.column_label as target_display_name FROM screen_used_columns suc JOIN column_labels cl ON cl.table_name = suc.main_table AND cl.column_name = suc.column_name LEFT JOIN column_labels ref_cl ON ref_cl.table_name = cl.reference_table AND ref_cl.column_name = cl.reference_column WHERE cl.reference_table IS NOT NULL AND cl.reference_table != '' AND cl.reference_table != suc.main_table AND cl.input_type = 'entity' ORDER BY suc.screen_id `; const referenceResult = await pool.query(referenceQuery, [screenIds]); logger.info("column_labels reference_table 조회 결과", { screenIds, referenceCount: referenceResult.rows.length, references: referenceResult.rows.map((r: any) => ({ screenId: r.screen_id, column: r.column_name, refTable: r.reference_table })) }); referenceResult.rows.forEach((row: any) => { const screenId = row.screen_id; const mainTable = row.main_table; const referenceTable = row.reference_table; if (!referenceTable || referenceTable === mainTable) { return; } if (!screenSubTables[screenId]) { screenSubTables[screenId] = { screenId, screenName: row.screen_name, mainTable: mainTable || '', subTables: [], }; } // 중복 체크 const exists = screenSubTables[screenId].subTables.some( (st) => st.tableName === referenceTable ); if (!exists) { screenSubTables[screenId].subTables.push({ tableName: referenceTable, componentType: 'column_reference', relationType: 'reference', fieldMappings: [{ sourceField: row.column_name, targetField: row.reference_column || 'id', sourceDisplayName: row.source_display_name || row.column_name, targetDisplayName: row.target_display_name || row.reference_column || 'id', }], }); } else { // 이미 존재하면 fieldMappings에 추가 const existingSubTable = screenSubTables[screenId].subTables.find( (st) => st.tableName === referenceTable ); if (existingSubTable && existingSubTable.fieldMappings) { const mappingExists = existingSubTable.fieldMappings.some( (fm) => fm.sourceField === row.column_name ); if (!mappingExists) { existingSubTable.fieldMappings.push({ sourceField: row.column_name, targetField: row.reference_column || 'id', sourceDisplayName: row.source_display_name || row.column_name, targetDisplayName: row.target_display_name || row.reference_column || 'id', }); } } } }); // 3. parentDataMapping 파싱 (selected-items-detail-input 등에서 사용) const parentMappingQuery = ` SELECT sd.screen_id, sd.screen_name, sd.table_name as main_table, sl.properties->>'componentType' as component_type, sl.properties->'componentConfig'->'parentDataMapping' as parent_data_mapping FROM screen_definitions sd JOIN screen_layouts sl ON sd.screen_id = sl.screen_id WHERE sd.screen_id = ANY($1) AND sl.properties->'componentConfig'->'parentDataMapping' IS NOT NULL `; const parentMappingResult = await pool.query(parentMappingQuery, [screenIds]); parentMappingResult.rows.forEach((row: any) => { const screenId = row.screen_id; const mainTable = row.main_table; const componentType = row.component_type || 'parentDataMapping'; const parentDataMapping = row.parent_data_mapping; if (!Array.isArray(parentDataMapping)) return; if (!screenSubTables[screenId]) { screenSubTables[screenId] = { screenId, screenName: row.screen_name, mainTable: mainTable || '', subTables: [], }; } parentDataMapping.forEach((mapping: any) => { const sourceTable = mapping.sourceTable; if (!sourceTable || sourceTable === mainTable) return; // 중복 체크 const existingSubTable = screenSubTables[screenId].subTables.find( (st) => st.tableName === sourceTable ); const newMapping = { sourceTable: sourceTable, // 연관 테이블 정보 추가 sourceField: mapping.sourceField || '', targetField: mapping.targetField || '', sourceDisplayName: mapping.sourceField || '', targetDisplayName: mapping.targetField || '', }; if (existingSubTable) { // 이미 존재하면 fieldMappings에 추가 if (!existingSubTable.fieldMappings) { existingSubTable.fieldMappings = []; } const mappingExists = existingSubTable.fieldMappings.some( (fm: any) => fm.sourceField === newMapping.sourceField && fm.targetField === newMapping.targetField ); if (!mappingExists) { existingSubTable.fieldMappings.push(newMapping); } } else { screenSubTables[screenId].subTables.push({ tableName: sourceTable, componentType: componentType, relationType: 'parentMapping', fieldMappings: [newMapping], }); } }); }); logger.info("parentDataMapping 파싱 완료", { screenIds, parentMappingCount: parentMappingResult.rows.length }); // 4. rightPanel.relation 파싱 (split-panel-layout 등에서 사용) const rightPanelQuery = ` SELECT sd.screen_id, sd.screen_name, sd.table_name as main_table, sl.properties->>'componentType' as component_type, sl.properties->'componentConfig'->'rightPanel'->'relation' as right_panel_relation, sl.properties->'componentConfig'->'rightPanel'->'tableName' as right_panel_table, sl.properties->'componentConfig'->'rightPanel'->'columns' as right_panel_columns FROM screen_definitions sd JOIN screen_layouts sl ON sd.screen_id = sl.screen_id WHERE sd.screen_id = ANY($1) AND sl.properties->'componentConfig'->'rightPanel'->'relation' IS NOT NULL `; const rightPanelResult = await pool.query(rightPanelQuery, [screenIds]); // rightPanel.columns에서 참조되는 외부 테이블 수집 (예: customer_mng.customer_name → customer_mng) const rightPanelJoinedTables: Map> = new Map(); // screenId_tableName → Set<참조테이블> rightPanelResult.rows.forEach((row: any) => { const screenId = row.screen_id; const rightPanelTable = row.right_panel_table; const rightPanelColumns = row.right_panel_columns; if (rightPanelColumns && Array.isArray(rightPanelColumns)) { rightPanelColumns.forEach((col: any) => { const colName = col.name || col.columnName || col.field; if (colName && colName.includes('.')) { const refTable = colName.split('.')[0]; const key = `${screenId}_${rightPanelTable}`; if (!rightPanelJoinedTables.has(key)) { rightPanelJoinedTables.set(key, new Set()); } rightPanelJoinedTables.get(key)!.add(refTable); } }); } }); rightPanelResult.rows.forEach((row: any) => { const screenId = row.screen_id; const mainTable = row.main_table; const componentType = row.component_type || 'split-panel-layout'; const relation = row.right_panel_relation; const rightPanelTable = row.right_panel_table; // relation 객체에서 테이블 및 필드 매핑 추출 const subTable = rightPanelTable || relation?.targetTable || relation?.tableName; if (!subTable || subTable === mainTable) return; // rightPanel.columns에서 참조하는 외부 테이블 목록 const key = `${screenId}_${subTable}`; const joinedTables = rightPanelJoinedTables.get(key) ? Array.from(rightPanelJoinedTables.get(key)!) : []; if (!screenSubTables[screenId]) { screenSubTables[screenId] = { screenId, screenName: row.screen_name, mainTable: mainTable || '', subTables: [], }; } // 중복 체크 const existingSubTable = screenSubTables[screenId].subTables.find( (st) => st.tableName === subTable ); // relation에서 필드 매핑 추출 const fieldMappings: Array<{ sourceField: string; targetField: string; sourceDisplayName?: string; targetDisplayName?: string }> = []; if (relation?.sourceField && relation?.targetField) { fieldMappings.push({ sourceField: relation.sourceField, targetField: relation.targetField, sourceDisplayName: relation.sourceField, targetDisplayName: relation.targetField, }); } // fieldMappings 배열이 있는 경우 if (relation?.fieldMappings && Array.isArray(relation.fieldMappings)) { relation.fieldMappings.forEach((fm: any) => { fieldMappings.push({ sourceField: fm.sourceField || fm.source_field || '', targetField: fm.targetField || fm.target_field || '', sourceDisplayName: fm.sourceField || fm.source_field || '', targetDisplayName: fm.targetField || fm.target_field || '', }); }); } if (existingSubTable) { // 이미 존재하면 fieldMappings에 추가 if (!existingSubTable.fieldMappings) { existingSubTable.fieldMappings = []; } fieldMappings.forEach((newMapping) => { const mappingExists = existingSubTable.fieldMappings!.some( (fm) => fm.sourceField === newMapping.sourceField && fm.targetField === newMapping.targetField ); if (!mappingExists) { existingSubTable.fieldMappings!.push(newMapping); } }); // 추가 정보도 업데이트 if (relation?.type) { (existingSubTable as any).originalRelationType = relation.type; } if (relation?.foreignKey) { (existingSubTable as any).foreignKey = relation.foreignKey; } if (relation?.leftColumn) { (existingSubTable as any).leftColumn = relation.leftColumn; } } else { screenSubTables[screenId].subTables.push({ tableName: subTable, componentType: componentType, relationType: 'rightPanelRelation', // 관계 유형 추론을 위한 추가 정보 originalRelationType: relation?.type || 'join', // 원본 relation.type ("join" | "detail") foreignKey: relation?.foreignKey, // 디테일 테이블의 FK 컬럼 leftColumn: relation?.leftColumn, // 마스터 테이블의 선택 기준 컬럼 joinedTables: joinedTables.length > 0 ? joinedTables : undefined, // rightPanel.columns에서 참조하는 외부 테이블들 fieldMappings: fieldMappings.length > 0 ? fieldMappings : undefined, } as any); } }); logger.info("rightPanel.relation 파싱 완료", { screenIds, rightPanelCount: rightPanelResult.rows.length }); // 5. joinedTables에 대한 FK 컬럼을 column_labels에서 조회 // rightPanelRelation에서 joinedTables가 있는 경우, 해당 테이블과 조인하는 FK 컬럼 찾기 const joinedTableFKLookups: Array<{ subTableName: string; refTable: string }> = []; Object.values(screenSubTables).forEach((screenData: any) => { screenData.subTables.forEach((subTable: any) => { if (subTable.joinedTables && Array.isArray(subTable.joinedTables)) { subTable.joinedTables.forEach((refTable: string) => { joinedTableFKLookups.push({ subTableName: subTable.tableName, refTable }); }); } }); }); // column_labels에서 FK 컬럼 조회 (reference_table로 조인하는 컬럼 찾기) const joinColumnsByTable: { [key: string]: string[] } = {}; // tableName → [FK 컬럼들] if (joinedTableFKLookups.length > 0) { const uniqueLookups = joinedTableFKLookups.filter((item, index, self) => index === self.findIndex((t) => t.subTableName === item.subTableName && t.refTable === item.refTable) ); // 각 subTable에 대해 reference_table이 일치하는 컬럼 조회 const subTableNames = [...new Set(uniqueLookups.map(l => l.subTableName))]; const refTableNames = [...new Set(uniqueLookups.map(l => l.refTable))]; const fkQuery = ` SELECT cl.table_name, cl.column_name, cl.column_label, cl.reference_table, cl.reference_column, tl.table_label as reference_table_label FROM column_labels cl LEFT JOIN table_labels tl ON cl.reference_table = tl.table_name WHERE cl.table_name = ANY($1) AND cl.reference_table = ANY($2) `; const fkResult = await pool.query(fkQuery, [subTableNames, refTableNames]); // 참조 정보 포함 객체 배열로 저장 (한글명 포함) const joinColumnRefsByTable: Record> = {}; fkResult.rows.forEach((row: any) => { if (!joinColumnRefsByTable[row.table_name]) { joinColumnRefsByTable[row.table_name] = []; } // 중복 체크 const exists = joinColumnRefsByTable[row.table_name].some( (ref) => ref.column === row.column_name && ref.refTable === row.reference_table ); if (!exists) { joinColumnRefsByTable[row.table_name].push({ column: row.column_name, columnLabel: row.column_label || row.column_name, // 컬럼 한글명 (없으면 영문명) refTable: row.reference_table, refTableLabel: row.reference_table_label || row.reference_table, // 참조 테이블 한글명 (없으면 영문명) refColumn: row.reference_column || 'id', }); } }); // subTables에 joinColumns (문자열 배열) 및 joinColumnRefs (참조 정보 배열) 추가 Object.values(screenSubTables).forEach((screenData: any) => { screenData.subTables.forEach((subTable: any) => { const refs = joinColumnRefsByTable[subTable.tableName]; if (refs) { (subTable as any).joinColumns = refs.map(r => r.column); (subTable as any).joinColumnRefs = refs; } }); }); logger.info("rightPanel joinedTables FK 조회 완료", { lookupCount: uniqueLookups.length, resultCount: fkResult.rows.length, joinColumnsByTable }); } // 5. 모든 fieldMappings의 한글명을 column_labels에서 가져와서 적용 // 모든 테이블/컬럼 조합을 수집 const columnLookups: Array<{ tableName: string; columnName: string }> = []; Object.values(screenSubTables).forEach((screenData: any) => { screenData.subTables.forEach((subTable: any) => { if (subTable.fieldMappings) { subTable.fieldMappings.forEach((mapping: any) => { // sourceTable + sourceField (연관 테이블의 컬럼) if (mapping.sourceTable && mapping.sourceField) { columnLookups.push({ tableName: mapping.sourceTable, columnName: mapping.sourceField }); } // mainTable + targetField (메인 테이블의 컬럼) if (screenData.mainTable && mapping.targetField) { columnLookups.push({ tableName: screenData.mainTable, columnName: mapping.targetField }); } }); } }); }); // 중복 제거 const uniqueColumnLookups = columnLookups.filter((item, index, self) => index === self.findIndex((t) => t.tableName === item.tableName && t.columnName === item.columnName) ); // column_labels에서 한글명 조회 const columnLabelsMap: { [key: string]: string } = {}; if (uniqueColumnLookups.length > 0) { const columnLabelsQuery = ` SELECT table_name, column_name, column_label FROM column_labels WHERE (table_name, column_name) IN ( ${uniqueColumnLookups.map((_, i) => `($${i * 2 + 1}, $${i * 2 + 2})`).join(', ')} ) `; const columnLabelsParams = uniqueColumnLookups.flatMap(item => [item.tableName, item.columnName]); try { const columnLabelsResult = await pool.query(columnLabelsQuery, columnLabelsParams); columnLabelsResult.rows.forEach((row: any) => { const key = `${row.table_name}.${row.column_name}`; columnLabelsMap[key] = row.column_label; }); logger.info("column_labels 한글명 조회 완료", { count: columnLabelsResult.rows.length }); } catch (error: any) { logger.warn("column_labels 한글명 조회 실패 (무시하고 계속 진행):", error.message); } } // 각 fieldMappings에 한글명 적용 Object.values(screenSubTables).forEach((screenData: any) => { screenData.subTables.forEach((subTable: any) => { if (subTable.fieldMappings) { subTable.fieldMappings.forEach((mapping: any) => { // sourceDisplayName: 연관 테이블의 컬럼 한글명 if (mapping.sourceTable && mapping.sourceField) { const sourceKey = `${mapping.sourceTable}.${mapping.sourceField}`; if (columnLabelsMap[sourceKey]) { mapping.sourceDisplayName = columnLabelsMap[sourceKey]; } } // targetDisplayName: 메인 테이블의 컬럼 한글명 if (screenData.mainTable && mapping.targetField) { const targetKey = `${screenData.mainTable}.${mapping.targetField}`; if (columnLabelsMap[targetKey]) { mapping.targetDisplayName = columnLabelsMap[targetKey]; } } }); } }); }); // ============================================================ // 저장 테이블 정보 추출 // ============================================================ // 제외 조건: // 1. table-list + 체크박스 활성화 + openModalWithData 버튼이 있는 화면 // → 선택 후 다음 화면으로 넘기는 패턴 (실제 DB 저장 아님) const saveTableQuery = ` SELECT DISTINCT sd.screen_id, sd.screen_name, sd.table_name as main_table, sl.properties->'componentConfig'->'action'->>'type' as action_type, sl.properties->>'componentType' as component_type, sl.properties->'componentConfig'->>'targetTable' as target_table, sl.properties->'componentConfig'->'action'->'dataTransfer'->>'targetTable' as transfer_target_table FROM screen_definitions sd JOIN screen_layouts sl ON sd.screen_id = sl.screen_id WHERE sd.screen_id = ANY($1) AND sl.properties->'componentConfig'->'action'->>'type' = 'save' AND sl.properties->'componentConfig'->'action'->>'targetScreenId' IS NULL -- 제외: table-list + 체크박스가 있는 화면 AND NOT EXISTS ( SELECT 1 FROM screen_layouts sl_list WHERE sl_list.screen_id = sd.screen_id AND sl_list.properties->>'componentType' = 'table-list' AND (sl_list.properties->'componentConfig'->'checkbox'->>'enabled')::boolean = true ) -- 제외: openModalWithData 버튼이 있는 화면 (선택 → 다음 화면 패턴) AND NOT EXISTS ( SELECT 1 FROM screen_layouts sl_modal WHERE sl_modal.screen_id = sd.screen_id AND sl_modal.properties->'componentConfig'->'action'->>'type' = 'openModalWithData' ) ORDER BY sd.screen_id `; const saveTableResult = await pool.query(saveTableQuery, [screenIds]); saveTableResult.rows.forEach((row: any) => { const screenId = row.screen_id; const mainTable = row.main_table; const actionType = row.action_type as 'save' | 'edit' | 'delete' | 'transferData'; const componentType = row.component_type || 'component'; const targetTable = row.target_table || row.transfer_target_table || mainTable; // 화면 정보가 없으면 초기화 if (!screenSubTables[screenId]) { screenSubTables[screenId] = { screenId, screenName: row.screen_name, mainTable: mainTable || '', subTables: [], saveTables: [], }; } // saveTables 배열 초기화 if (!screenSubTables[screenId].saveTables) { screenSubTables[screenId].saveTables = []; } // 중복 체크 const existingSaveTable = screenSubTables[screenId].saveTables!.find( (st) => st.tableName === targetTable && st.saveType === actionType ); if (!existingSaveTable && targetTable) { screenSubTables[screenId].saveTables!.push({ tableName: targetTable, saveType: actionType, componentType, isMainTable: targetTable === mainTable, }); } }); logger.info("화면 서브 테이블 정보 조회 완료", { screenIds, resultCount: Object.keys(screenSubTables).length, details: Object.values(screenSubTables).map(s => ({ screenId: s.screenId, mainTable: s.mainTable, subTables: s.subTables.map(st => st.tableName), saveTables: s.saveTables?.map(st => st.tableName) || [] })) }); res.json({ success: true, data: screenSubTables, }); } catch (error: any) { logger.error("화면 서브 테이블 정보 조회 실패:", error); res.status(500).json({ success: false, message: "화면 서브 테이블 정보 조회에 실패했습니다.", error: error.message }); } };