import { Request, Response } from "express"; import { getPool } from "../database/db"; import { logger } from "../utils/logger"; import { AuthenticatedRequest } from "../types/auth"; import { syncScreenGroupsToMenu, syncMenuToScreenGroups, getSyncStatus, syncAllCompanies, } from "../services/menuScreenSyncService"; // pool 인스턴스 가져오기 const pool = getPool(); // ============================================================ // 화면 그룹 (screen_groups) CRUD // ============================================================ // 화면 그룹 목록 조회 export const getScreenGroups = async (req: AuthenticatedRequest, res: Response) => { try { const companyCode = req.user?.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 배열 포함) - 삭제된 화면(is_active = 'D') 제외 const dataQuery = ` SELECT sg.*, (SELECT COUNT(*) FROM screen_group_screens sgs LEFT JOIN screen_definitions sd ON sgs.screen_id = sd.screen_id WHERE sgs.group_id = sg.id AND sd.is_active != 'D') 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 AND sd.is_active != 'D' ) 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: AuthenticatedRequest, res: Response) => { try { const { id } = req.params; const companyCode = req.user?.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 AND sd.is_active != 'D' ) 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: AuthenticatedRequest, res: Response) => { try { const userCompanyCode = req.user?.companyCode || "*"; const userId = req.user?.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: AuthenticatedRequest, res: Response) => { try { const { id } = req.params; const userCompanyCode = req.user?.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: AuthenticatedRequest, res: Response) => { const client = await pool.connect(); try { const { id } = req.params; const companyCode = req.user?.companyCode || "*"; await client.query('BEGIN'); // 0. 삭제할 그룹의 company_code 확인 const targetGroupResult = await client.query( `SELECT company_code FROM screen_groups WHERE id = $1`, [id] ); if (targetGroupResult.rows.length === 0) { await client.query('ROLLBACK'); return res.status(404).json({ success: false, message: "화면 그룹을 찾을 수 없습니다." }); } const targetCompanyCode = targetGroupResult.rows[0].company_code; // 권한 체크: 최고관리자가 아닌 경우 자신의 회사 그룹만 삭제 가능 if (companyCode !== "*" && targetCompanyCode !== companyCode) { await client.query('ROLLBACK'); return res.status(403).json({ success: false, message: "권한이 없습니다." }); } // 1. 삭제할 그룹과 하위 그룹 ID 수집 (같은 회사만 - CASCADE 삭제 대상) const childGroupsResult = await client.query(` WITH RECURSIVE child_groups AS ( SELECT id, company_code FROM screen_groups WHERE id = $1 AND company_code = $2 UNION ALL SELECT sg.id, sg.company_code FROM screen_groups sg JOIN child_groups cg ON sg.parent_group_id = cg.id AND sg.company_code = cg.company_code ) SELECT id FROM child_groups `, [id, targetCompanyCode]); const groupIdsToDelete = childGroupsResult.rows.map((r: any) => r.id); logger.info("화면 그룹 삭제 대상", { companyCode, targetCompanyCode, groupId: id, childGroupIds: groupIdsToDelete }); // 2. 삭제될 그룹에 연결된 메뉴 정리 if (groupIdsToDelete.length > 0) { // 2-1. 삭제할 메뉴 objid 수집 const menusToDelete = await client.query(` SELECT objid FROM menu_info WHERE screen_group_id = ANY($1::int[]) AND company_code = $2 `, [groupIdsToDelete, targetCompanyCode]); const menuObjids = menusToDelete.rows.map((r: any) => r.objid); if (menuObjids.length > 0) { // 2-2. screen_menu_assignments에서 해당 메뉴 관련 데이터 삭제 await client.query(` DELETE FROM screen_menu_assignments WHERE menu_objid = ANY($1::bigint[]) AND company_code = $2 `, [menuObjids, targetCompanyCode]); // 2-3. menu_info에서 해당 메뉴 삭제 await client.query(` DELETE FROM menu_info WHERE screen_group_id = ANY($1::int[]) AND company_code = $2 `, [groupIdsToDelete, targetCompanyCode]); logger.info("그룹 삭제 시 연결된 메뉴 삭제", { groupIds: groupIdsToDelete, deletedMenuCount: menuObjids.length, companyCode: targetCompanyCode }); } // 2-4. 해당 회사의 채번 규칙 삭제 (최상위 그룹 삭제 시) // 삭제되는 그룹이 최상위인지 확인 const isRootGroup = await client.query( `SELECT 1 FROM screen_groups WHERE id = $1 AND parent_group_id IS NULL`, [id] ); if (isRootGroup.rows.length > 0) { // 최상위 그룹 삭제 시 해당 회사의 채번 규칙도 삭제 // 먼저 파트 삭제 await client.query( `DELETE FROM numbering_rule_parts WHERE rule_id IN (SELECT rule_id FROM numbering_rules WHERE company_code = $1)`, [targetCompanyCode] ); // 규칙 삭제 const deletedRules = await client.query( `DELETE FROM numbering_rules WHERE company_code = $1 RETURNING rule_id`, [targetCompanyCode] ); if (deletedRules.rowCount && deletedRules.rowCount > 0) { logger.info("그룹 삭제 시 채번 규칙 삭제", { companyCode: targetCompanyCode, deletedCount: deletedRules.rowCount }); } } } // 3. screen_groups 삭제 (해당 그룹만 - 하위 그룹은 프론트엔드에서 순차 삭제) const result = await client.query( `DELETE FROM screen_groups WHERE id = $1 AND company_code = $2 RETURNING id`, [id, targetCompanyCode] ); if (result.rows.length === 0) { await client.query('ROLLBACK'); return res.status(404).json({ success: false, message: "화면 그룹을 찾을 수 없거나 권한이 없습니다." }); } await client.query('COMMIT'); logger.info("화면 그룹 삭제 완료", { companyCode, targetCompanyCode, groupId: id, cleanedRefs: groupIdsToDelete.length }); res.json({ success: true, message: "화면 그룹이 삭제되었습니다." }); } catch (error: any) { await client.query('ROLLBACK'); logger.error("화면 그룹 삭제 실패:", error); res.status(500).json({ success: false, message: "화면 그룹 삭제에 실패했습니다.", error: error.message }); } finally { client.release(); } }; // ============================================================ // 화면-그룹 연결 (screen_group_screens) CRUD // ============================================================ // 그룹에 화면 추가 export const addScreenToGroup = async (req: AuthenticatedRequest, res: Response) => { try { const userCompanyCode = req.user?.companyCode || "*"; const userId = req.user?.userId || ""; const { group_id, screen_id, screen_role, display_order, is_default, target_company_code } = req.body; if (!group_id || !screen_id) { return res.status(400).json({ success: false, message: "그룹 ID와 화면 ID는 필수입니다." }); } // 최고 관리자가 다른 회사로 복제할 때 target_company_code 사용 const effectiveCompanyCode = (userCompanyCode === "*" && target_company_code) ? target_company_code : userCompanyCode; 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', effectiveCompanyCode, userId ]; const result = await pool.query(query, params); logger.info("화면-그룹 연결 추가", { companyCode: effectiveCompanyCode, 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: AuthenticatedRequest, res: Response) => { try { const { id } = req.params; const companyCode = req.user?.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: AuthenticatedRequest, res: Response) => { try { const { id } = req.params; const companyCode = req.user?.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: AuthenticatedRequest, res: Response) => { try { const companyCode = req.user?.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: AuthenticatedRequest, res: Response) => { try { const companyCode = req.user?.companyCode || "*"; const userId = req.user?.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: AuthenticatedRequest, res: Response) => { try { const { id } = req.params; const companyCode = req.user?.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: AuthenticatedRequest, res: Response) => { try { const { id } = req.params; const companyCode = req.user?.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: AuthenticatedRequest, res: Response) => { try { const companyCode = req.user?.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: AuthenticatedRequest, res: Response) => { try { const companyCode = req.user?.companyCode || "*"; const userId = req.user?.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: AuthenticatedRequest, res: Response) => { try { const { id } = req.params; const companyCode = req.user?.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: AuthenticatedRequest, res: Response) => { try { const { id } = req.params; const companyCode = req.user?.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: AuthenticatedRequest, res: Response) => { try { const companyCode = req.user?.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: AuthenticatedRequest, res: Response) => { try { const companyCode = req.user?.companyCode || "*"; const userId = req.user?.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: AuthenticatedRequest, res: Response) => { try { const { id } = req.params; const companyCode = req.user?.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: AuthenticatedRequest, res: Response) => { try { const { id } = req.params; const companyCode = req.user?.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: AuthenticatedRequest, 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: AuthenticatedRequest, 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', properties->>'columnName' ) 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에서 추출됨 }); } } // 4. bindField가 있으면 usedColumns에 추가 (인풋 필드, 텍스트 필드 등) if (row.bind_field && !usedColumns.includes(row.bind_field)) { usedColumns.push(row.bind_field); } // 5. componentConfig.field 또는 componentConfig.valueField도 추가 const configField = componentConfig.field || componentConfig.valueField; if (configField && typeof configField === 'string' && !usedColumns.includes(configField)) { usedColumns.push(configField); } 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: AuthenticatedRequest, 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 table_type_columns WHERE (${conditions.join(' OR ')}) AND company_code = '*' `; 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. 추가 방식: 화면에서 사용하는 컬럼 중 table_type_columns.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 table_type_columns cl ON cl.table_name = suc.main_table AND cl.column_name = suc.column_name AND cl.company_code = '*' LEFT JOIN table_type_columns ref_cl ON ref_cl.table_name = cl.reference_table AND ref_cl.column_name = cl.reference_column AND ref_cl.company_code = '*' 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("table_type_columns 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 등에서 사용) // screen_layouts (v1)와 screen_layouts_v2 모두 조회 const rightPanelQuery = ` -- V1: screen_layouts에서 조회 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 UNION ALL -- V2: screen_layouts_v2에서 조회 (v2-split-panel-layout 컴포넌트) SELECT sd.screen_id, sd.screen_name, sd.table_name as main_table, comp->'overrides'->>'type' as component_type, comp->'overrides'->'rightPanel'->'relation' as right_panel_relation, comp->'overrides'->'rightPanel'->>'tableName' as right_panel_table, comp->'overrides'->'rightPanel'->'columns' as right_panel_columns FROM screen_definitions sd JOIN screen_layouts_v2 slv2 ON sd.screen_id = slv2.screen_id, jsonb_array_elements(slv2.layout_data->'components') as comp WHERE sd.screen_id = ANY($1) AND comp->'overrides'->'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 컬럼을 table_type_columns에서 조회 // 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 }); }); } }); }); // table_type_columns에서 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 table_type_columns 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) AND cl.company_code = '*' `; 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의 한글명을 table_type_columns에서 가져와서 적용 // 모든 테이블/컬럼 조합을 수집 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) ); // table_type_columns에서 한글명 조회 const columnLabelsMap: { [key: string]: string } = {}; if (uniqueColumnLookups.length > 0) { const columnLabelsQuery = ` SELECT table_name, column_name, column_label FROM table_type_columns WHERE (table_name, column_name) IN ( ${uniqueColumnLookups.map((_, i) => `($${i * 2 + 1}, $${i * 2 + 2})`).join(', ')} ) AND company_code = '*' `; 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("table_type_columns 한글명 조회 완료", { count: columnLabelsResult.rows.length }); } catch (error: any) { logger.warn("table_type_columns 한글명 조회 실패 (무시하고 계속 진행):", 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) || [] })) }); // ============================================================ // 6. 전역 메인 테이블 목록 수집 (우선순위 적용용) // ============================================================ // 메인 테이블 조건: // 1. screen_definitions.table_name (컴포넌트 직접 연결) // 2. v2-split-panel-layout의 rightPanel.tableName (WHERE 조건 대상) // // 이 목록에 있으면 서브 테이블로 분류되지 않음 (우선순위: 메인 > 서브) const globalMainTablesQuery = ` -- 1. 모든 화면의 메인 테이블 (screen_definitions.table_name) SELECT DISTINCT table_name as main_table FROM screen_definitions WHERE screen_id = ANY($1) AND table_name IS NOT NULL UNION -- 2. v2-split-panel-layout의 rightPanel.tableName (WHERE 조건 대상) -- 현재 그룹의 화면들에서 마스터-디테일로 연결된 테이블 SELECT DISTINCT comp->'overrides'->'rightPanel'->>'tableName' as main_table FROM screen_definitions sd JOIN screen_layouts_v2 slv2 ON sd.screen_id = slv2.screen_id, jsonb_array_elements(slv2.layout_data->'components') as comp WHERE sd.screen_id = ANY($1) AND comp->'overrides'->'rightPanel'->>'tableName' IS NOT NULL UNION -- 3. v1 screen_layouts의 rightPanel.tableName (WHERE 조건 대상) SELECT DISTINCT sl.properties->'componentConfig'->'rightPanel'->>'tableName' as main_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'->'rightPanel'->>'tableName' IS NOT NULL `; const globalMainTablesResult = await pool.query(globalMainTablesQuery, [screenIds]); const globalMainTables = globalMainTablesResult.rows .map((r: any) => r.main_table) .filter((t: string) => t != null && t !== ''); logger.info("전역 메인 테이블 목록 수집 완료", { count: globalMainTables.length, tables: globalMainTables }); res.json({ success: true, data: screenSubTables, globalMainTables: globalMainTables, // 메인 테이블로 분류되어야 하는 테이블 목록 }); } catch (error: any) { logger.error("화면 서브 테이블 정보 조회 실패:", error); res.status(500).json({ success: false, message: "화면 서브 테이블 정보 조회에 실패했습니다.", error: error.message }); } }; // ============================================================ // 메뉴-화면그룹 동기화 API // ============================================================ /** * 화면관리 → 메뉴 동기화 * screen_groups를 menu_info로 동기화 */ export const syncScreenGroupsToMenuController = async (req: AuthenticatedRequest, res: Response) => { try { const userCompanyCode = req.user?.companyCode || "*"; const userId = req.user?.userId || ""; const { targetCompanyCode } = req.body; // 최고 관리자가 특정 회사를 지정한 경우 해당 회사로 let companyCode = userCompanyCode; if (userCompanyCode === "*" && targetCompanyCode) { companyCode = targetCompanyCode; } // 최고 관리자(*)는 회사를 지정해야 함 if (companyCode === "*") { return res.status(400).json({ success: false, message: "동기화할 회사를 선택해주세요.", }); } logger.info("화면관리 → 메뉴 동기화 요청", { companyCode, userId }); const result = await syncScreenGroupsToMenu(companyCode, userId); if (!result.success) { return res.status(500).json({ success: false, message: "동기화 중 오류가 발생했습니다.", errors: result.errors, }); } res.json({ success: true, message: `동기화 완료: 생성 ${result.created}개, 연결 ${result.linked}개, 스킵 ${result.skipped}개`, data: result, }); } catch (error: any) { logger.error("화면관리 → 메뉴 동기화 실패:", error); res.status(500).json({ success: false, message: "동기화에 실패했습니다.", error: error.message, }); } }; /** * 메뉴 → 화면관리 동기화 * menu_info를 screen_groups로 동기화 */ export const syncMenuToScreenGroupsController = async (req: AuthenticatedRequest, res: Response) => { try { const userCompanyCode = req.user?.companyCode || "*"; const userId = req.user?.userId || ""; const { targetCompanyCode } = req.body; // 최고 관리자가 특정 회사를 지정한 경우 해당 회사로 let companyCode = userCompanyCode; if (userCompanyCode === "*" && targetCompanyCode) { companyCode = targetCompanyCode; } // 최고 관리자(*)는 회사를 지정해야 함 if (companyCode === "*") { return res.status(400).json({ success: false, message: "동기화할 회사를 선택해주세요.", }); } logger.info("메뉴 → 화면관리 동기화 요청", { companyCode, userId }); const result = await syncMenuToScreenGroups(companyCode, userId); if (!result.success) { return res.status(500).json({ success: false, message: "동기화 중 오류가 발생했습니다.", errors: result.errors, }); } res.json({ success: true, message: `동기화 완료: 생성 ${result.created}개, 연결 ${result.linked}개, 스킵 ${result.skipped}개`, data: result, }); } catch (error: any) { logger.error("메뉴 → 화면관리 동기화 실패:", error); res.status(500).json({ success: false, message: "동기화에 실패했습니다.", error: error.message, }); } }; /** * 동기화 상태 조회 */ export const getSyncStatusController = async (req: AuthenticatedRequest, res: Response) => { try { const userCompanyCode = req.user?.companyCode || "*"; const { targetCompanyCode } = req.query; // 최고 관리자가 특정 회사를 지정한 경우 해당 회사로 let companyCode = userCompanyCode; if (userCompanyCode === "*" && targetCompanyCode) { companyCode = targetCompanyCode as string; } // 최고 관리자(*)는 회사를 지정해야 함 if (companyCode === "*") { return res.status(400).json({ success: false, message: "조회할 회사를 선택해주세요.", }); } const status = await getSyncStatus(companyCode); res.json({ success: true, data: status, }); } catch (error: any) { logger.error("동기화 상태 조회 실패:", error); res.status(500).json({ success: false, message: "동기화 상태 조회에 실패했습니다.", error: error.message, }); } }; /** * 전체 회사 동기화 * 모든 회사에 대해 양방향 동기화 수행 (최고 관리자만) */ export const syncAllCompaniesController = async (req: AuthenticatedRequest, res: Response) => { try { const userCompanyCode = req.user?.companyCode || "*"; const userId = req.user?.userId || ""; // 최고 관리자만 전체 동기화 가능 if (userCompanyCode !== "*") { return res.status(403).json({ success: false, message: "전체 동기화는 최고 관리자만 수행할 수 있습니다.", }); } logger.info("전체 회사 동기화 요청", { userId }); const result = await syncAllCompanies(userId); if (!result.success) { return res.status(500).json({ success: false, message: "전체 동기화 중 오류가 발생했습니다.", }); } // 결과 요약 const totalCreated = result.results.reduce((sum, r) => sum + r.created, 0); const totalLinked = result.results.reduce((sum, r) => sum + r.linked, 0); res.json({ success: true, message: `전체 동기화 완료: ${result.totalCompanies}개 회사 중 ${result.successCount}개 성공`, data: { totalCompanies: result.totalCompanies, successCount: result.successCount, failedCount: result.failedCount, totalCreated, totalLinked, details: result.results, }, }); } catch (error: any) { logger.error("전체 회사 동기화 실패:", error); res.status(500).json({ success: false, message: "전체 동기화에 실패했습니다.", error: error.message, }); } }; /** * [PoC] screen_groups 기반 메뉴 트리 조회 * * 기존 menu_info 대신 screen_groups를 사이드바 메뉴로 사용하기 위한 테스트 API * - screen_groups를 트리 구조로 반환 * - 각 그룹에 연결된 기본 화면의 URL 포함 * - menu_objid를 통해 권한 체크 가능 * * DB 변경 없이 로직만 추가 */ export const getMenuTreeFromScreenGroups = async (req: AuthenticatedRequest, res: Response) => { try { const userCompanyCode = req.user?.companyCode || "*"; const { targetCompanyCode } = req.query; // 조회할 회사 코드 결정 const companyCode = userCompanyCode === "*" && targetCompanyCode ? String(targetCompanyCode) : userCompanyCode; logger.info("[PoC] screen_groups 기반 메뉴 트리 조회", { userCompanyCode, targetCompanyCode: companyCode }); // 1. screen_groups 조회 (계층 구조 포함) const groupsQuery = ` SELECT sg.id, sg.group_name, sg.group_code, sg.parent_group_id, sg.group_level, sg.display_order, sg.icon, sg.is_active, sg.menu_objid, sg.company_code, -- 기본 화면 정보 (URL 생성용) ( SELECT json_build_object( 'screen_id', sd.screen_id, 'screen_name', sd.screen_name, 'screen_code', sd.screen_code ) FROM screen_group_screens sgs JOIN screen_definitions sd ON sgs.screen_id = sd.screen_id WHERE sgs.group_id = sg.id AND sgs.company_code = sg.company_code ORDER BY CASE WHEN sgs.is_default = 'Y' THEN 0 ELSE 1 END, sgs.display_order ASC LIMIT 1 ) as default_screen, -- 하위 화면 개수 ( SELECT COUNT(*) FROM screen_group_screens sgs WHERE sgs.group_id = sg.id AND sgs.company_code = sg.company_code ) as screen_count FROM screen_groups sg WHERE sg.company_code = $1 AND (sg.is_active = 'Y' OR sg.is_active IS NULL) ORDER BY sg.group_level ASC, sg.display_order ASC, sg.group_name ASC `; const groupsResult = await pool.query(groupsQuery, [companyCode]); // 2. 트리 구조로 변환 const groups = groupsResult.rows; const groupMap = new Map(); const rootGroups: any[] = []; // 먼저 모든 그룹을 Map에 저장 for (const group of groups) { const menuItem = { id: group.id, objid: group.menu_objid || group.id, // 권한 체크용 (menu_objid 우선) name: group.group_name, name_kor: group.group_name, icon: group.icon, url: group.default_screen ? `/screens/${group.default_screen.screen_id}` : null, screen_id: group.default_screen?.screen_id || null, screen_code: group.default_screen?.screen_code || null, screen_count: parseInt(group.screen_count) || 0, parent_id: group.parent_group_id, level: group.group_level || 0, display_order: group.display_order || 0, is_active: group.is_active === 'Y', menu_objid: group.menu_objid, // 기존 권한 시스템 연결용 children: [], // menu_info 호환 필드 menu_name_kor: group.group_name, menu_url: group.default_screen ? `/screens/${group.default_screen.screen_id}` : null, parent_obj_id: null, // 나중에 설정 seq: group.display_order || 0, status: group.is_active === 'Y' ? 'active' : 'inactive', }; groupMap.set(group.id, menuItem); } // 부모-자식 관계 설정 for (const group of groups) { const menuItem = groupMap.get(group.id); if (group.parent_group_id && groupMap.has(group.parent_group_id)) { const parent = groupMap.get(group.parent_group_id); parent.children.push(menuItem); menuItem.parent_obj_id = parent.objid; } else { // 최상위 그룹 rootGroups.push(menuItem); menuItem.parent_obj_id = "0"; } } // 3. 통계 정보 const stats = { totalGroups: groups.length, groupsWithScreens: groups.filter(g => g.default_screen).length, groupsWithMenuObjid: groups.filter(g => g.menu_objid).length, rootGroups: rootGroups.length, }; logger.info("[PoC] screen_groups 메뉴 트리 생성 완료", stats); res.json({ success: true, message: "[PoC] screen_groups 기반 메뉴 트리", data: rootGroups, stats, // 플랫 리스트도 제공 (기존 menu_info 형식 호환) flatList: Array.from(groupMap.values()).map(item => ({ objid: String(item.objid), OBJID: String(item.objid), menu_name_kor: item.name, MENU_NAME_KOR: item.name, menu_url: item.url, MENU_URL: item.url, parent_obj_id: String(item.parent_obj_id || "0"), PARENT_OBJ_ID: String(item.parent_obj_id || "0"), seq: item.seq, SEQ: item.seq, status: item.status, STATUS: item.status, menu_type: 1, // 사용자 메뉴 MENU_TYPE: 1, screen_group_id: item.id, menu_objid: item.menu_objid, })), }); } catch (error: any) { logger.error("[PoC] screen_groups 메뉴 트리 조회 실패:", error); res.status(500).json({ success: false, message: "메뉴 트리 조회에 실패했습니다.", error: error.message, }); } };