import { getPool } from "../database/db"; import { logger } from "../utils/logger"; const pool = getPool(); /** * 메뉴-화면그룹 동기화 서비스 * * 양방향 동기화: * 1. screen_groups → menu_info: 화면관리 폴더 구조를 메뉴로 동기화 * 2. menu_info → screen_groups: 사용자 메뉴를 화면관리 폴더로 동기화 */ // ============================================================ // 타입 정의 // ============================================================ interface SyncResult { success: boolean; created: number; linked: number; skipped: number; errors: string[]; details: SyncDetail[]; } interface SyncDetail { action: 'created' | 'linked' | 'skipped' | 'error'; sourceName: string; sourceId: number | string; targetId?: number | string; reason?: string; } // ============================================================ // 화면관리 → 메뉴 동기화 // ============================================================ /** * screen_groups를 menu_info로 동기화 * * 로직: * 1. 해당 회사의 screen_groups 조회 (폴더 구조) * 2. 이미 menu_objid가 연결된 것은 제외 * 3. 이름으로 기존 menu_info 매칭 시도 * - 매칭되면: 양쪽에 연결 ID 업데이트 * - 매칭 안되면: menu_info에 새로 생성 * 4. 계층 구조(parent) 유지 */ export async function syncScreenGroupsToMenu( companyCode: string, userId: string ): Promise { const result: SyncResult = { success: true, created: 0, linked: 0, skipped: 0, errors: [], details: [], }; const client = await pool.connect(); try { await client.query('BEGIN'); logger.info("화면관리 → 메뉴 동기화 시작", { companyCode, userId }); // 1. 해당 회사의 screen_groups 조회 (아직 menu_objid가 없는 것) const screenGroupsQuery = ` SELECT sg.id, sg.group_name, sg.group_code, sg.parent_group_id, sg.group_level, sg.display_order, sg.description, sg.icon, sg.menu_objid, -- 부모 그룹의 menu_objid도 조회 (계층 연결용) parent.menu_objid as parent_menu_objid FROM screen_groups sg LEFT JOIN screen_groups parent ON sg.parent_group_id = parent.id WHERE sg.company_code = $1 ORDER BY sg.group_level ASC, sg.display_order ASC `; const screenGroupsResult = await client.query(screenGroupsQuery, [companyCode]); // 2. 해당 회사의 기존 menu_info 조회 (사용자 메뉴, menu_type=1) // 경로 기반 매칭을 위해 부모 이름도 조회 const existingMenusQuery = ` SELECT m.objid, m.menu_name_kor, m.parent_obj_id, m.screen_group_id, p.menu_name_kor as parent_name FROM menu_info m LEFT JOIN menu_info p ON m.parent_obj_id = p.objid WHERE m.company_code = $1 AND m.menu_type = 1 `; const existingMenusResult = await client.query(existingMenusQuery, [companyCode]); // 경로(부모이름 > 이름) → 메뉴 매핑 (screen_group_id가 없는 것만) // 단순 이름 매칭도 유지 (하위 호환) const menuByPath: Map = new Map(); const menuByName: Map = new Map(); existingMenusResult.rows.forEach((menu: any) => { if (!menu.screen_group_id) { const menuName = menu.menu_name_kor?.trim().toLowerCase() || ''; const parentName = menu.parent_name?.trim().toLowerCase() || ''; const pathKey = parentName ? `${parentName}>${menuName}` : menuName; menuByPath.set(pathKey, menu); // 단순 이름 매핑은 첫 번째 것만 (중복 방지) if (!menuByName.has(menuName)) { menuByName.set(menuName, menu); } } }); // 모든 메뉴의 objid 집합 (삭제 확인용) const existingMenuObjids = new Set(existingMenusResult.rows.map((m: any) => Number(m.objid))); // 3. 사용자 메뉴의 루트 찾기 (parent_obj_id = 0인 사용자 메뉴) // 없으면 생성 let userMenuRootObjid: number | null = null; const rootMenuQuery = ` SELECT objid FROM menu_info WHERE company_code = $1 AND menu_type = 1 AND parent_obj_id = 0 ORDER BY seq ASC LIMIT 1 `; const rootMenuResult = await client.query(rootMenuQuery, [companyCode]); if (rootMenuResult.rows.length > 0) { userMenuRootObjid = Number(rootMenuResult.rows[0].objid); } else { // 루트 메뉴가 없으면 생성 const newObjid = Date.now(); const createRootQuery = ` INSERT INTO menu_info (objid, parent_obj_id, menu_name_kor, menu_name_eng, seq, menu_type, company_code, writer, regdate, status) VALUES ($1, 0, '사용자', 'User', 1, 1, $2, $3, NOW(), 'active') RETURNING objid `; const createRootResult = await client.query(createRootQuery, [newObjid, companyCode, userId]); userMenuRootObjid = Number(createRootResult.rows[0].objid); logger.info("사용자 메뉴 루트 생성", { companyCode, objid: userMenuRootObjid }); } // 4. screen_groups ID → menu_objid 매핑 (순차 처리를 위해) const groupToMenuMap: Map = new Map(); // screen_groups의 부모 이름 조회를 위한 매핑 const groupIdToName: Map = new Map(); screenGroupsResult.rows.forEach((g: any) => { groupIdToName.set(g.id, g.group_name?.trim().toLowerCase() || ''); }); // 5. 최상위 회사 폴더 ID 찾기 (level 0, parent_group_id IS NULL) // 이 폴더는 메뉴로 생성하지 않고, 하위 폴더들을 사용자 루트 바로 아래에 배치 const topLevelCompanyFolderIds = new Set(); for (const group of screenGroupsResult.rows) { if (group.group_level === 0 && group.parent_group_id === null) { topLevelCompanyFolderIds.add(group.id); // 최상위 폴더 → 사용자 루트에 매핑 (하위 폴더의 부모로 사용) groupToMenuMap.set(group.id, userMenuRootObjid!); logger.info("최상위 회사 폴더 스킵", { groupId: group.id, groupName: group.group_name }); } } // 6. 각 screen_group 처리 for (const group of screenGroupsResult.rows) { const groupId = group.id; const groupName = group.group_name?.trim(); const groupNameLower = groupName?.toLowerCase() || ''; // 최상위 회사 폴더는 메뉴로 생성하지 않고 스킵 if (topLevelCompanyFolderIds.has(groupId)) { result.skipped++; result.details.push({ action: 'skipped', sourceName: groupName, sourceId: groupId, reason: '최상위 회사 폴더 (메뉴 생성 스킵)', }); continue; } // 이미 연결된 경우 - 실제로 메뉴가 존재하는지 확인 if (group.menu_objid) { const menuExists = existingMenuObjids.has(Number(group.menu_objid)); if (menuExists) { // 메뉴가 존재하면 스킵 result.skipped++; result.details.push({ action: 'skipped', sourceName: groupName, sourceId: groupId, targetId: group.menu_objid, reason: '이미 메뉴와 연결됨', }); groupToMenuMap.set(groupId, Number(group.menu_objid)); continue; } else { // 메뉴가 삭제되었으면 연결 해제하고 재생성 logger.info("삭제된 메뉴 연결 해제", { groupId, deletedMenuObjid: group.menu_objid }); await client.query( `UPDATE screen_groups SET menu_objid = NULL, updated_date = NOW() WHERE id = $1`, [groupId] ); // 계속 진행하여 재생성 또는 재연결 } } // 부모 그룹 이름 조회 (경로 기반 매칭용) const parentGroupName = group.parent_group_id ? groupIdToName.get(group.parent_group_id) : ''; const pathKey = parentGroupName ? `${parentGroupName}>${groupNameLower}` : groupNameLower; // 경로로 기존 메뉴 매칭 시도 (우선순위: 경로 매칭 > 이름 매칭) let matchedMenu = menuByPath.get(pathKey); if (!matchedMenu) { // 경로 매칭 실패시 이름으로 시도 (하위 호환) matchedMenu = menuByName.get(groupNameLower); } if (matchedMenu) { // 매칭된 메뉴와 연결 const menuObjid = Number(matchedMenu.objid); // screen_groups에 menu_objid 업데이트 await client.query( `UPDATE screen_groups SET menu_objid = $1, updated_date = NOW() WHERE id = $2`, [menuObjid, groupId] ); // menu_info에 screen_group_id 업데이트 await client.query( `UPDATE menu_info SET screen_group_id = $1 WHERE objid = $2`, [groupId, menuObjid] ); groupToMenuMap.set(groupId, menuObjid); result.linked++; result.details.push({ action: 'linked', sourceName: groupName, sourceId: groupId, targetId: menuObjid, }); // 매칭된 메뉴는 Map에서 제거 (중복 매칭 방지) menuByPath.delete(pathKey); menuByName.delete(groupNameLower); } else { // 새 메뉴 생성 const newObjid = Date.now() + groupId; // 고유 ID 보장 // 부모 메뉴 objid 결정 // 우선순위: groupToMenuMap > parent_menu_objid (존재 확인 필수) let parentMenuObjid = userMenuRootObjid; if (group.parent_group_id && groupToMenuMap.has(group.parent_group_id)) { // 현재 트랜잭션에서 생성된 부모 메뉴 사용 parentMenuObjid = groupToMenuMap.get(group.parent_group_id)!; } else if (group.parent_group_id && group.parent_menu_objid) { // 기존 parent_menu_objid가 실제로 존재하는지 확인 const parentMenuExists = existingMenuObjids.has(Number(group.parent_menu_objid)); if (parentMenuExists) { parentMenuObjid = Number(group.parent_menu_objid); } } // 같은 부모 아래에서 가장 높은 seq 조회 후 +1 let nextSeq = 1; const maxSeqQuery = ` SELECT COALESCE(MAX(seq), 0) + 1 as next_seq FROM menu_info WHERE parent_obj_id = $1 AND company_code = $2 AND menu_type = 1 `; const maxSeqResult = await client.query(maxSeqQuery, [parentMenuObjid, companyCode]); if (maxSeqResult.rows.length > 0) { nextSeq = parseInt(maxSeqResult.rows[0].next_seq) || 1; } // menu_info에 삽입 const insertMenuQuery = ` INSERT INTO menu_info ( objid, parent_obj_id, menu_name_kor, menu_name_eng, seq, menu_type, company_code, writer, regdate, status, screen_group_id, menu_desc ) VALUES ($1, $2, $3, $4, $5, 1, $6, $7, NOW(), 'active', $8, $9) RETURNING objid `; await client.query(insertMenuQuery, [ newObjid, parentMenuObjid, groupName, group.group_code || groupName, nextSeq, companyCode, userId, groupId, group.description || null, ]); // screen_groups에 menu_objid 업데이트 await client.query( `UPDATE screen_groups SET menu_objid = $1, updated_date = NOW() WHERE id = $2`, [newObjid, groupId] ); groupToMenuMap.set(groupId, newObjid); result.created++; result.details.push({ action: 'created', sourceName: groupName, sourceId: groupId, targetId: newObjid, }); } } await client.query('COMMIT'); logger.info("화면관리 → 메뉴 동기화 완료", { companyCode, created: result.created, linked: result.linked, skipped: result.skipped }); return result; } catch (error: any) { await client.query('ROLLBACK'); logger.error("화면관리 → 메뉴 동기화 실패", { companyCode, error: error.message }); result.success = false; result.errors.push(error.message); return result; } finally { client.release(); } } // ============================================================ // 메뉴 → 화면관리 동기화 // ============================================================ /** * menu_info를 screen_groups로 동기화 * * 로직: * 1. 해당 회사의 사용자 메뉴(menu_type=1) 조회 * 2. 이미 screen_group_id가 연결된 것은 제외 * 3. 이름으로 기존 screen_groups 매칭 시도 * - 매칭되면: 양쪽에 연결 ID 업데이트 * - 매칭 안되면: screen_groups에 새로 생성 (폴더로) * 4. 계층 구조(parent) 유지 */ export async function syncMenuToScreenGroups( companyCode: string, userId: string ): Promise { const result: SyncResult = { success: true, created: 0, linked: 0, skipped: 0, errors: [], details: [], }; const client = await pool.connect(); try { await client.query('BEGIN'); logger.info("메뉴 → 화면관리 동기화 시작", { companyCode, userId }); // 0. 회사 이름 조회 (회사 폴더 찾기/생성용) const companyNameQuery = `SELECT company_name FROM company_mng WHERE company_code = $1`; const companyNameResult = await client.query(companyNameQuery, [companyCode]); const companyName = companyNameResult.rows[0]?.company_name || companyCode; // 1. 해당 회사의 사용자 메뉴 조회 (menu_type=1) const menusQuery = ` SELECT m.objid, m.menu_name_kor, m.menu_name_eng, m.parent_obj_id, m.seq, m.menu_url, m.menu_desc, m.screen_group_id, -- 부모 메뉴의 screen_group_id도 조회 (계층 연결용) parent.screen_group_id as parent_screen_group_id FROM menu_info m LEFT JOIN menu_info parent ON m.parent_obj_id = parent.objid WHERE m.company_code = $1 AND m.menu_type = 1 ORDER BY CASE WHEN m.parent_obj_id = 0 THEN 0 ELSE 1 END, m.parent_obj_id, m.seq `; const menusResult = await client.query(menusQuery, [companyCode]); // 2. 해당 회사의 기존 screen_groups 조회 (경로 기반 매칭을 위해 부모 이름도 조회) const existingGroupsQuery = ` SELECT g.id, g.group_name, g.menu_objid, g.parent_group_id, p.group_name as parent_name FROM screen_groups g LEFT JOIN screen_groups p ON g.parent_group_id = p.id WHERE g.company_code = $1 `; const existingGroupsResult = await client.query(existingGroupsQuery, [companyCode]); // 경로(부모이름 > 이름) → 그룹 매핑 (menu_objid가 없는 것만) // 단순 이름 매칭도 유지 (하위 호환) const groupByPath: Map = new Map(); const groupByName: Map = new Map(); existingGroupsResult.rows.forEach((group: any) => { if (!group.menu_objid) { const groupName = group.group_name?.trim().toLowerCase() || ''; const parentName = group.parent_name?.trim().toLowerCase() || ''; const pathKey = parentName ? `${parentName}>${groupName}` : groupName; groupByPath.set(pathKey, group); // 단순 이름 매핑은 첫 번째 것만 (중복 방지) if (!groupByName.has(groupName)) { groupByName.set(groupName, group); } } }); // 모든 그룹의 id 집합 (삭제 확인용) const existingGroupIds = new Set(existingGroupsResult.rows.map((g: any) => Number(g.id))); // 3. 회사 폴더 찾기 또는 생성 (루트 레벨에 회사명으로 된 폴더) let companyFolderId: number | null = null; const companyFolderQuery = ` SELECT id FROM screen_groups WHERE company_code = $1 AND parent_group_id IS NULL AND group_level = 0 ORDER BY id ASC LIMIT 1 `; const companyFolderResult = await client.query(companyFolderQuery, [companyCode]); if (companyFolderResult.rows.length > 0) { companyFolderId = companyFolderResult.rows[0].id; logger.info("회사 폴더 발견", { companyCode, companyFolderId, companyName }); } else { // 회사 폴더가 없으면 생성 // 루트 레벨에서 가장 높은 display_order 조회 후 +1 let nextRootOrder = 1; const maxRootOrderQuery = ` SELECT COALESCE(MAX(display_order), 0) + 1 as next_order FROM screen_groups WHERE parent_group_id IS NULL `; const maxRootOrderResult = await client.query(maxRootOrderQuery); if (maxRootOrderResult.rows.length > 0) { nextRootOrder = parseInt(maxRootOrderResult.rows[0].next_order) || 1; } const createFolderQuery = ` INSERT INTO screen_groups ( group_name, group_code, parent_group_id, group_level, display_order, company_code, writer, hierarchy_path ) VALUES ($1, $2, NULL, 0, $3, $4, $5, '/') RETURNING id `; const createFolderResult = await client.query(createFolderQuery, [ companyName, companyCode.toLowerCase(), nextRootOrder, companyCode, userId, ]); companyFolderId = createFolderResult.rows[0].id; // hierarchy_path 업데이트 await client.query( `UPDATE screen_groups SET hierarchy_path = $1 WHERE id = $2`, [`/${companyFolderId}/`, companyFolderId] ); logger.info("회사 폴더 생성", { companyCode, companyFolderId, companyName }); } // 4. menu_objid → screen_group_id 매핑 (순차 처리를 위해) const menuToGroupMap: Map = new Map(); // 부모 메뉴 중 이미 screen_group_id가 있는 것 등록 menusResult.rows.forEach((menu: any) => { if (menu.screen_group_id) { menuToGroupMap.set(Number(menu.objid), Number(menu.screen_group_id)); } }); // 루트 메뉴(parent_obj_id = 0)의 objid 찾기 → 회사 폴더와 매핑 let rootMenuObjid: number | null = null; for (const menu of menusResult.rows) { if (Number(menu.parent_obj_id) === 0) { rootMenuObjid = Number(menu.objid); // 루트 메뉴는 회사 폴더와 연결 if (companyFolderId) { menuToGroupMap.set(rootMenuObjid, companyFolderId); } break; } } // 5. 각 메뉴 처리 for (const menu of menusResult.rows) { const menuObjid = Number(menu.objid); const menuName = menu.menu_name_kor?.trim(); // 루트 메뉴(parent_obj_id = 0)는 스킵 (이미 회사 폴더와 매핑됨) if (Number(menu.parent_obj_id) === 0) { result.skipped++; result.details.push({ action: 'skipped', sourceName: menuName, sourceId: menuObjid, targetId: companyFolderId || undefined, reason: '루트 메뉴 → 회사 폴더와 매핑됨', }); continue; } // 이미 연결된 경우 - 실제로 그룹이 존재하는지 확인 if (menu.screen_group_id) { const groupExists = existingGroupIds.has(Number(menu.screen_group_id)); if (groupExists) { // 그룹이 존재하면 스킵 result.skipped++; result.details.push({ action: 'skipped', sourceName: menuName, sourceId: menuObjid, targetId: menu.screen_group_id, reason: '이미 화면그룹과 연결됨', }); menuToGroupMap.set(menuObjid, Number(menu.screen_group_id)); continue; } else { // 그룹이 삭제되었으면 연결 해제하고 재생성 logger.info("삭제된 그룹 연결 해제", { menuObjid, deletedGroupId: menu.screen_group_id }); await client.query( `UPDATE menu_info SET screen_group_id = NULL WHERE objid = $1`, [menuObjid] ); // 계속 진행하여 재생성 또는 재연결 } } const menuNameLower = menuName?.toLowerCase() || ''; // 부모 메뉴 이름 조회 (경로 기반 매칭용) const parentMenu = menusResult.rows.find((m: any) => Number(m.objid) === Number(menu.parent_obj_id)); const parentMenuName = parentMenu?.menu_name_kor?.trim().toLowerCase() || ''; const pathKey = parentMenuName ? `${parentMenuName}>${menuNameLower}` : menuNameLower; // 경로로 기존 그룹 매칭 시도 (우선순위: 경로 매칭 > 이름 매칭) let matchedGroup = groupByPath.get(pathKey); if (!matchedGroup) { // 경로 매칭 실패시 이름으로 시도 (하위 호환) matchedGroup = groupByName.get(menuNameLower); } if (matchedGroup) { // 매칭된 그룹과 연결 const groupId = Number(matchedGroup.id); try { // menu_info에 screen_group_id 업데이트 await client.query( `UPDATE menu_info SET screen_group_id = $1 WHERE objid = $2`, [groupId, menuObjid] ); // screen_groups에 menu_objid 업데이트 await client.query( `UPDATE screen_groups SET menu_objid = $1, updated_date = NOW() WHERE id = $2`, [menuObjid, groupId] ); menuToGroupMap.set(menuObjid, groupId); result.linked++; result.details.push({ action: 'linked', sourceName: menuName, sourceId: menuObjid, targetId: groupId, }); // 매칭된 그룹은 Map에서 제거 (중복 매칭 방지) groupByPath.delete(pathKey); groupByName.delete(menuNameLower); } catch (linkError: any) { logger.error("그룹 연결 중 에러", { menuName, menuObjid, groupId, error: linkError.message, stack: linkError.stack }); throw linkError; } } else { // 새 screen_group 생성 // 부모 그룹 ID 결정 let parentGroupId: number | null = null; let groupLevel = 1; // 기본값은 1 (회사 폴더 아래) // 우선순위 1: menuToGroupMap에서 부모 메뉴의 새 그룹 ID 조회 (같은 트랜잭션에서 생성된 것) if (menuToGroupMap.has(Number(menu.parent_obj_id))) { parentGroupId = menuToGroupMap.get(Number(menu.parent_obj_id))!; } // 우선순위 2: 부모 메뉴가 루트 메뉴면 회사 폴더 사용 else if (Number(menu.parent_obj_id) === rootMenuObjid) { parentGroupId = companyFolderId; } // 우선순위 3: 부모 메뉴의 screen_group_id가 있고, 해당 그룹이 실제로 존재하면 사용 else if (menu.parent_screen_group_id && existingGroupIds.has(Number(menu.parent_screen_group_id))) { parentGroupId = Number(menu.parent_screen_group_id); } // 부모 그룹의 레벨 조회 if (parentGroupId) { const parentLevelQuery = `SELECT group_level FROM screen_groups WHERE id = $1`; const parentLevelResult = await client.query(parentLevelQuery, [parentGroupId]); if (parentLevelResult.rows.length > 0) { groupLevel = (parentLevelResult.rows[0].group_level || 0) + 1; } } // 같은 부모 아래에서 가장 높은 display_order 조회 후 +1 let nextDisplayOrder = 1; const maxOrderQuery = parentGroupId ? `SELECT COALESCE(MAX(display_order), 0) + 1 as next_order FROM screen_groups WHERE parent_group_id = $1 AND company_code = $2` : `SELECT COALESCE(MAX(display_order), 0) + 1 as next_order FROM screen_groups WHERE parent_group_id IS NULL AND company_code = $1`; const maxOrderParams = parentGroupId ? [parentGroupId, companyCode] : [companyCode]; const maxOrderResult = await client.query(maxOrderQuery, maxOrderParams); if (maxOrderResult.rows.length > 0) { nextDisplayOrder = parseInt(maxOrderResult.rows[0].next_order) || 1; } // group_code 생성 (영문명 또는 이름 기반) const groupCode = (menu.menu_name_eng || menuName || 'group') .replace(/\s+/g, '_') .toLowerCase() .substring(0, 50); // screen_groups에 삽입 const insertGroupQuery = ` INSERT INTO screen_groups ( group_name, group_code, parent_group_id, group_level, display_order, company_code, writer, menu_objid, description ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id `; let newGroupId: number; try { logger.info("새 그룹 생성 시도", { menuName, menuObjid, groupCode: groupCode + '_' + menuObjid, parentGroupId, groupLevel, nextDisplayOrder, companyCode, }); const insertResult = await client.query(insertGroupQuery, [ menuName, groupCode + '_' + menuObjid, // 고유성 보장 parentGroupId, groupLevel, nextDisplayOrder, companyCode, userId, menuObjid, menu.menu_desc || null, ]); newGroupId = insertResult.rows[0].id; } catch (insertError: any) { logger.error("그룹 생성 중 에러", { menuName, menuObjid, parentGroupId, groupLevel, error: insertError.message, stack: insertError.stack, code: insertError.code, detail: insertError.detail, }); throw insertError; } // hierarchy_path 업데이트 let hierarchyPath = `/${newGroupId}/`; if (parentGroupId) { const parentPathQuery = `SELECT hierarchy_path FROM screen_groups WHERE id = $1`; const parentPathResult = await client.query(parentPathQuery, [parentGroupId]); if (parentPathResult.rows.length > 0 && parentPathResult.rows[0].hierarchy_path) { hierarchyPath = `${parentPathResult.rows[0].hierarchy_path}${newGroupId}/`.replace('//', '/'); } } await client.query( `UPDATE screen_groups SET hierarchy_path = $1 WHERE id = $2`, [hierarchyPath, newGroupId] ); // menu_info에 screen_group_id 업데이트 await client.query( `UPDATE menu_info SET screen_group_id = $1 WHERE objid = $2`, [newGroupId, menuObjid] ); menuToGroupMap.set(menuObjid, newGroupId); result.created++; result.details.push({ action: 'created', sourceName: menuName, sourceId: menuObjid, targetId: newGroupId, }); } } await client.query('COMMIT'); logger.info("메뉴 → 화면관리 동기화 완료", { companyCode, created: result.created, linked: result.linked, skipped: result.skipped }); return result; } catch (error: any) { await client.query('ROLLBACK'); logger.error("메뉴 → 화면관리 동기화 실패", { companyCode, error: error.message, stack: error.stack, code: error.code, detail: error.detail, }); result.success = false; result.errors.push(error.message); return result; } finally { client.release(); } } // ============================================================ // 동기화 상태 조회 // ============================================================ /** * 동기화 상태 조회 * * - 연결된 항목 수 * - 연결 안 된 항목 수 * - 양방향 비교 */ export async function getSyncStatus(companyCode: string): Promise<{ screenGroups: { total: number; linked: number; unlinked: number }; menuItems: { total: number; linked: number; unlinked: number }; potentialMatches: Array<{ menuName: string; groupName: string; similarity: string }>; }> { // screen_groups 상태 const sgQuery = ` SELECT COUNT(*) as total, COUNT(menu_objid) as linked FROM screen_groups WHERE company_code = $1 `; const sgResult = await pool.query(sgQuery, [companyCode]); // menu_info 상태 (사용자 메뉴만, 루트 제외) const menuQuery = ` SELECT COUNT(*) as total, COUNT(screen_group_id) as linked FROM menu_info WHERE company_code = $1 AND menu_type = 1 AND parent_obj_id != 0 `; const menuResult = await pool.query(menuQuery, [companyCode]); // 이름이 같은 잠재적 매칭 후보 조회 const matchQuery = ` SELECT m.menu_name_kor as menu_name, sg.group_name FROM menu_info m JOIN screen_groups sg ON LOWER(TRIM(m.menu_name_kor)) = LOWER(TRIM(sg.group_name)) WHERE m.company_code = $1 AND sg.company_code = $1 AND m.menu_type = 1 AND m.screen_group_id IS NULL AND sg.menu_objid IS NULL LIMIT 10 `; const matchResult = await pool.query(matchQuery, [companyCode]); const sgTotal = parseInt(sgResult.rows[0].total); const sgLinked = parseInt(sgResult.rows[0].linked); const menuTotal = parseInt(menuResult.rows[0].total); const menuLinked = parseInt(menuResult.rows[0].linked); return { screenGroups: { total: sgTotal, linked: sgLinked, unlinked: sgTotal - sgLinked, }, menuItems: { total: menuTotal, linked: menuLinked, unlinked: menuTotal - menuLinked, }, potentialMatches: matchResult.rows.map((row: any) => ({ menuName: row.menu_name, groupName: row.group_name, similarity: 'exact', })), }; } // ============================================================ // 전체 동기화 (모든 회사) // ============================================================ interface AllCompaniesSyncResult { success: boolean; totalCompanies: number; successCount: number; failedCount: number; results: Array<{ companyCode: string; companyName: string; direction: 'screens-to-menus' | 'menus-to-screens'; created: number; linked: number; skipped: number; success: boolean; error?: string; }>; } /** * 모든 회사에 대해 양방향 동기화 수행 * * 로직: * 1. 모든 회사 조회 * 2. 각 회사별로 양방향 동기화 수행 * - 화면관리 → 메뉴 동기화 * - 메뉴 → 화면관리 동기화 * 3. 결과 집계 */ export async function syncAllCompanies( userId: string ): Promise { const result: AllCompaniesSyncResult = { success: true, totalCompanies: 0, successCount: 0, failedCount: 0, results: [], }; try { logger.info("전체 동기화 시작", { userId }); // 모든 회사 조회 (최고 관리자 전용 회사 제외) const companiesQuery = ` SELECT company_code, company_name FROM company_mng WHERE company_code != '*' ORDER BY company_name `; const companiesResult = await pool.query(companiesQuery); result.totalCompanies = companiesResult.rows.length; // 각 회사별로 양방향 동기화 for (const company of companiesResult.rows) { const companyCode = company.company_code; const companyName = company.company_name; try { // 1. 화면관리 → 메뉴 동기화 const screensToMenusResult = await syncScreenGroupsToMenu(companyCode, userId); result.results.push({ companyCode, companyName, direction: 'screens-to-menus', created: screensToMenusResult.created, linked: screensToMenusResult.linked, skipped: screensToMenusResult.skipped, success: screensToMenusResult.success, error: screensToMenusResult.errors.length > 0 ? screensToMenusResult.errors.join(', ') : undefined, }); // 2. 메뉴 → 화면관리 동기화 const menusToScreensResult = await syncMenuToScreenGroups(companyCode, userId); result.results.push({ companyCode, companyName, direction: 'menus-to-screens', created: menusToScreensResult.created, linked: menusToScreensResult.linked, skipped: menusToScreensResult.skipped, success: menusToScreensResult.success, error: menusToScreensResult.errors.length > 0 ? menusToScreensResult.errors.join(', ') : undefined, }); if (screensToMenusResult.success && menusToScreensResult.success) { result.successCount++; } else { result.failedCount++; } } catch (error: any) { logger.error("회사 동기화 실패", { companyCode, companyName, error: error.message }); result.results.push({ companyCode, companyName, direction: 'screens-to-menus', created: 0, linked: 0, skipped: 0, success: false, error: error.message, }); result.failedCount++; } } logger.info("전체 동기화 완료", { totalCompanies: result.totalCompanies, successCount: result.successCount, failedCount: result.failedCount, }); return result; } catch (error: any) { logger.error("전체 동기화 실패", { error: error.message }); result.success = false; return result; } }