From ad8b1791bc6f9ed5dc99dfbdf9d12ccad19da3e8 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Wed, 21 Jan 2026 11:53:51 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=99=94=EB=A9=B4=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=20=EB=B0=8F=20=EB=A9=94=EB=89=B4=20=EB=8F=99=EA=B8=B0=ED=99=94?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 화면 그룹 컨트롤러 기능 확장 - 메뉴 복사 서비스 개선 - 메뉴-화면 동기화 서비스 추가 - 번호 규칙 서비스 개선 - 화면 관리 서비스 확장 - CopyScreenModal 기능 개선 - DataFlowPanel, FieldJoinPanel 수정 --- .../controllers/numberingRuleController.ts | 37 +- .../src/controllers/screenGroupController.ts | 518 ++++++-- .../controllers/screenManagementController.ts | 261 ++++ .../src/routes/screenManagementRoutes.ts | 24 + backend-node/src/services/menuCopyService.ts | 228 +++- .../src/services/menuScreenSyncService.ts | 1021 +++++++++++++++ .../src/services/numberingRuleService.ts | 205 ++- .../src/services/screenManagementService.ts | 1162 ++++++++++++++++- .../admin/screenMng/screenMngList/page.tsx | 8 +- .../numbering-rule/NumberingRuleDesigner.tsx | 9 +- .../components/screen/CopyScreenModal.tsx | 377 +++++- .../screen/panels/DataFlowPanel.tsx | 2 + .../screen/panels/FieldJoinPanel.tsx | 2 + frontend/lib/api/screen.ts | 13 + frontend/lib/api/screenGroup.ts | 164 +++ 15 files changed, 3895 insertions(+), 136 deletions(-) create mode 100644 backend-node/src/services/menuScreenSyncService.ts diff --git a/backend-node/src/controllers/numberingRuleController.ts b/backend-node/src/controllers/numberingRuleController.ts index ab7114a5..d94cd25a 100644 --- a/backend-node/src/controllers/numberingRuleController.ts +++ b/backend-node/src/controllers/numberingRuleController.ts @@ -169,14 +169,22 @@ router.put("/:ruleId", authenticateToken, async (req: AuthenticatedRequest, res: const { ruleId } = req.params; const updates = req.body; + logger.info("채번 규칙 수정 요청", { ruleId, companyCode, updates }); + try { const updatedRule = await numberingRuleService.updateRule(ruleId, updates, companyCode); + logger.info("채번 규칙 수정 성공", { ruleId, companyCode }); return res.json({ success: true, data: updatedRule }); } catch (error: any) { + logger.error("채번 규칙 수정 실패", { + ruleId, + companyCode, + error: error.message, + stack: error.stack + }); if (error.message.includes("찾을 수 없거나")) { return res.status(404).json({ success: false, error: error.message }); } - logger.error("규칙 수정 실패", { error: error.message }); return res.status(500).json({ success: false, error: error.message }); } }); @@ -257,4 +265,31 @@ router.post("/:ruleId/reset", authenticateToken, async (req: AuthenticatedReques } }); +// 회사별 채번규칙 복제 (화면 복제 후 메뉴 동기화 완료 상태에서 호출) +router.post("/copy-for-company", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { + const userCompanyCode = req.user!.companyCode; + const { sourceCompanyCode, targetCompanyCode } = req.body; + + // 최고 관리자만 회사간 복제 가능 + if (userCompanyCode !== "*") { + return res.status(403).json({ success: false, error: "최고 관리자만 회사간 채번규칙 복제가 가능합니다." }); + } + + if (!sourceCompanyCode || !targetCompanyCode) { + return res.status(400).json({ success: false, error: "원본 회사 코드와 대상 회사 코드가 필요합니다." }); + } + + try { + logger.info("회사별 채번규칙 복제 시작", { sourceCompanyCode, targetCompanyCode }); + + const result = await numberingRuleService.copyRulesForCompany(sourceCompanyCode, targetCompanyCode); + + logger.info("회사별 채번규칙 복제 완료", { sourceCompanyCode, targetCompanyCode, result }); + return res.json({ success: true, data: result }); + } catch (error: any) { + logger.error("회사별 채번규칙 복제 실패", { error: error.message, sourceCompanyCode, targetCompanyCode }); + return res.status(500).json({ success: false, error: error.message }); + } +}); + export default router; diff --git a/backend-node/src/controllers/screenGroupController.ts b/backend-node/src/controllers/screenGroupController.ts index 2d7bc0e1..ffe34a66 100644 --- a/backend-node/src/controllers/screenGroupController.ts +++ b/backend-node/src/controllers/screenGroupController.ts @@ -1,15 +1,17 @@ import { Request, Response } from "express"; import { getPool } from "../database/db"; import { logger } from "../utils/logger"; -import { MultiLangService } from "../services/multilangService"; import { AuthenticatedRequest } from "../types/auth"; +import { + syncScreenGroupsToMenu, + syncMenuToScreenGroups, + getSyncStatus, + syncAllCompanies, +} from "../services/menuScreenSyncService"; // pool 인스턴스 가져오기 const pool = getPool(); -// 다국어 서비스 인스턴스 -const multiLangService = new MultiLangService(); - // ============================================================ // 화면 그룹 (screen_groups) CRUD // ============================================================ @@ -17,7 +19,7 @@ const multiLangService = new MultiLangService(); // 화면 그룹 목록 조회 export const getScreenGroups = async (req: AuthenticatedRequest, res: Response) => { try { - const companyCode = req.user!.companyCode; + const companyCode = req.user?.companyCode || "*"; const { page = 1, size = 20, searchTerm } = req.query; const offset = (parseInt(page as string) - 1) * parseInt(size as string); @@ -92,7 +94,7 @@ export const getScreenGroups = async (req: AuthenticatedRequest, res: Response) export const getScreenGroup = async (req: AuthenticatedRequest, res: Response) => { try { const { id } = req.params; - const companyCode = req.user!.companyCode; + const companyCode = req.user?.companyCode || "*"; let query = ` SELECT sg.*, @@ -137,8 +139,8 @@ export const getScreenGroup = async (req: AuthenticatedRequest, res: Response) = // 화면 그룹 생성 export const createScreenGroup = async (req: AuthenticatedRequest, res: Response) => { try { - const userCompanyCode = req.user!.companyCode; - const userId = req.user!.userId; + 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) { @@ -196,47 +198,6 @@ export const createScreenGroup = async (req: AuthenticatedRequest, res: Response // 업데이트된 데이터 반환 const updatedResult = await pool.query(`SELECT * FROM screen_groups WHERE id = $1`, [newGroupId]); - // 다국어 카테고리 자동 생성 (그룹 경로 기반) - try { - // 그룹 경로 조회 (상위 그룹 → 현재 그룹) - const groupPathResult = await pool.query( - `WITH RECURSIVE group_path AS ( - SELECT id, parent_group_id, group_name, group_level, 1 as depth - FROM screen_groups - WHERE id = $1 - UNION ALL - SELECT g.id, g.parent_group_id, g.group_name, g.group_level, gp.depth + 1 - FROM screen_groups g - INNER JOIN group_path gp ON g.id = gp.parent_group_id - WHERE g.parent_group_id IS NOT NULL - ) - SELECT group_name FROM group_path - ORDER BY depth DESC`, - [newGroupId] - ); - - const groupPath = groupPathResult.rows.map((r: any) => r.group_name); - - // 회사 이름 조회 - let companyName = "공통"; - if (finalCompanyCode !== "*") { - const companyResult = await pool.query( - `SELECT company_name FROM company_mng WHERE company_code = $1`, - [finalCompanyCode] - ); - if (companyResult.rows.length > 0) { - companyName = companyResult.rows[0].company_name; - } - } - - // 다국어 카테고리 생성 - await multiLangService.ensureScreenGroupCategory(finalCompanyCode, companyName, groupPath); - logger.info("화면 그룹 다국어 카테고리 자동 생성 완료", { groupPath, companyCode: finalCompanyCode }); - } catch (multilangError: any) { - // 다국어 카테고리 생성 실패해도 그룹 생성은 성공으로 처리 - logger.warn("화면 그룹 다국어 카테고리 생성 실패 (무시하고 계속):", multilangError.message); - } - logger.info("화면 그룹 생성", { userCompanyCode, finalCompanyCode, groupId: newGroupId, groupName: group_name, parentGroupId: parent_group_id }); res.json({ success: true, data: updatedResult.rows[0], message: "화면 그룹이 생성되었습니다." }); @@ -253,7 +214,7 @@ export const createScreenGroup = async (req: AuthenticatedRequest, res: Response export const updateScreenGroup = async (req: AuthenticatedRequest, res: Response) => { try { const { id } = req.params; - const userCompanyCode = req.user!.companyCode; + 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; // 회사 코드 결정: 최고 관리자가 특정 회사를 선택한 경우 해당 회사로, 아니면 현재 그룹의 회사 유지 @@ -340,10 +301,35 @@ export const updateScreenGroup = async (req: AuthenticatedRequest, res: Response // 화면 그룹 삭제 export const deleteScreenGroup = async (req: AuthenticatedRequest, res: Response) => { + const client = await pool.connect(); try { const { id } = req.params; - const companyCode = req.user!.companyCode; + const companyCode = req.user?.companyCode || "*"; + await client.query('BEGIN'); + + // 1. 삭제할 그룹과 하위 그룹 ID 수집 (CASCADE 삭제 대상) + const childGroupsResult = await client.query(` + WITH RECURSIVE child_groups AS ( + SELECT id FROM screen_groups WHERE id = $1 + UNION ALL + SELECT sg.id FROM screen_groups sg + JOIN child_groups cg ON sg.parent_group_id = cg.id + ) + SELECT id FROM child_groups + `, [id]); + const groupIdsToDelete = childGroupsResult.rows.map((r: any) => r.id); + + // 2. menu_info에서 삭제될 screen_group 참조를 NULL로 정리 + if (groupIdsToDelete.length > 0) { + await client.query(` + UPDATE menu_info + SET screen_group_id = NULL + WHERE screen_group_id = ANY($1::int[]) + `, [groupIdsToDelete]); + } + + // 3. screen_groups 삭제 let query = `DELETE FROM screen_groups WHERE id = $1`; const params: any[] = [id]; @@ -354,18 +340,24 @@ export const deleteScreenGroup = async (req: AuthenticatedRequest, res: Response query += " RETURNING id"; - const result = await pool.query(query, params); + const result = await client.query(query, params); if (result.rows.length === 0) { + await client.query('ROLLBACK'); return res.status(404).json({ success: false, message: "화면 그룹을 찾을 수 없거나 권한이 없습니다." }); } - logger.info("화면 그룹 삭제", { companyCode, groupId: id }); + await client.query('COMMIT'); + + logger.info("화면 그룹 삭제", { companyCode, 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(); } }; @@ -377,14 +369,19 @@ export const deleteScreenGroup = async (req: AuthenticatedRequest, res: Response // 그룹에 화면 추가 export const addScreenToGroup = async (req: AuthenticatedRequest, res: Response) => { try { - const companyCode = req.user!.companyCode; - const userId = req.user!.userId; - const { group_id, screen_id, screen_role, display_order, is_default } = req.body; + 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) @@ -396,13 +393,13 @@ export const addScreenToGroup = async (req: AuthenticatedRequest, res: Response) screen_role || 'main', display_order || 0, is_default || 'N', - companyCode === "*" ? "*" : companyCode, + effectiveCompanyCode, userId ]; const result = await pool.query(query, params); - logger.info("화면-그룹 연결 추가", { companyCode, groupId: group_id, screenId: screen_id }); + logger.info("화면-그룹 연결 추가", { companyCode: effectiveCompanyCode, groupId: group_id, screenId: screen_id }); res.json({ success: true, data: result.rows[0], message: "화면이 그룹에 추가되었습니다." }); } catch (error: any) { @@ -418,7 +415,7 @@ export const addScreenToGroup = async (req: AuthenticatedRequest, res: Response) export const removeScreenFromGroup = async (req: AuthenticatedRequest, res: Response) => { try { const { id } = req.params; - const companyCode = req.user!.companyCode; + const companyCode = req.user?.companyCode || "*"; let query = `DELETE FROM screen_group_screens WHERE id = $1`; const params: any[] = [id]; @@ -449,7 +446,7 @@ export const removeScreenFromGroup = async (req: AuthenticatedRequest, res: Resp export const updateScreenInGroup = async (req: AuthenticatedRequest, res: Response) => { try { const { id } = req.params; - const companyCode = req.user!.companyCode; + const companyCode = req.user?.companyCode || "*"; const { screen_role, display_order, is_default } = req.body; let query = ` @@ -487,7 +484,7 @@ export const updateScreenInGroup = async (req: AuthenticatedRequest, res: Respon // 화면 필드 조인 목록 조회 export const getFieldJoins = async (req: AuthenticatedRequest, res: Response) => { try { - const companyCode = req.user!.companyCode; + const companyCode = req.user?.companyCode || "*"; const { screen_id } = req.query; let query = ` @@ -528,8 +525,8 @@ export const getFieldJoins = async (req: AuthenticatedRequest, res: Response) => // 화면 필드 조인 생성 export const createFieldJoin = async (req: AuthenticatedRequest, res: Response) => { try { - const companyCode = req.user!.companyCode; - const userId = req.user!.userId; + 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, @@ -570,7 +567,7 @@ export const createFieldJoin = async (req: AuthenticatedRequest, res: Response) export const updateFieldJoin = async (req: AuthenticatedRequest, res: Response) => { try { const { id } = req.params; - const companyCode = req.user!.companyCode; + const companyCode = req.user?.companyCode || "*"; const { layout_id, component_id, field_name, save_table, save_column, join_table, join_column, display_column, @@ -615,7 +612,7 @@ export const updateFieldJoin = async (req: AuthenticatedRequest, res: Response) export const deleteFieldJoin = async (req: AuthenticatedRequest, res: Response) => { try { const { id } = req.params; - const companyCode = req.user!.companyCode; + const companyCode = req.user?.companyCode || "*"; let query = `DELETE FROM screen_field_joins WHERE id = $1`; const params: any[] = [id]; @@ -648,7 +645,7 @@ export const deleteFieldJoin = async (req: AuthenticatedRequest, res: Response) // 데이터 흐름 목록 조회 export const getDataFlows = async (req: AuthenticatedRequest, res: Response) => { try { - const companyCode = req.user!.companyCode; + const companyCode = req.user?.companyCode || "*"; const { group_id, source_screen_id } = req.query; let query = ` @@ -698,8 +695,8 @@ export const getDataFlows = async (req: AuthenticatedRequest, res: Response) => // 데이터 흐름 생성 export const createDataFlow = async (req: AuthenticatedRequest, res: Response) => { try { - const companyCode = req.user!.companyCode; - const userId = req.user!.userId; + 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 @@ -738,7 +735,7 @@ export const createDataFlow = async (req: AuthenticatedRequest, res: Response) = export const updateDataFlow = async (req: AuthenticatedRequest, res: Response) => { try { const { id } = req.params; - const companyCode = req.user!.companyCode; + 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 @@ -781,7 +778,7 @@ export const updateDataFlow = async (req: AuthenticatedRequest, res: Response) = export const deleteDataFlow = async (req: AuthenticatedRequest, res: Response) => { try { const { id } = req.params; - const companyCode = req.user!.companyCode; + const companyCode = req.user?.companyCode || "*"; let query = `DELETE FROM screen_data_flows WHERE id = $1`; const params: any[] = [id]; @@ -814,7 +811,7 @@ export const deleteDataFlow = async (req: AuthenticatedRequest, res: Response) = // 화면-테이블 관계 목록 조회 export const getTableRelations = async (req: AuthenticatedRequest, res: Response) => { try { - const companyCode = req.user!.companyCode; + const companyCode = req.user?.companyCode || "*"; const { screen_id, group_id } = req.query; let query = ` @@ -863,8 +860,8 @@ export const getTableRelations = async (req: AuthenticatedRequest, res: Response // 화면-테이블 관계 생성 export const createTableRelation = async (req: AuthenticatedRequest, res: Response) => { try { - const companyCode = req.user!.companyCode; - const userId = req.user!.userId; + 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) { @@ -897,7 +894,7 @@ export const createTableRelation = async (req: AuthenticatedRequest, res: Respon export const updateTableRelation = async (req: AuthenticatedRequest, res: Response) => { try { const { id } = req.params; - const companyCode = req.user!.companyCode; + const companyCode = req.user?.companyCode || "*"; const { group_id, table_name, relation_type, crud_operations, description, is_active } = req.body; let query = ` @@ -932,7 +929,7 @@ export const updateTableRelation = async (req: AuthenticatedRequest, res: Respon export const deleteTableRelation = async (req: AuthenticatedRequest, res: Response) => { try { const { id } = req.params; - const companyCode = req.user!.companyCode; + const companyCode = req.user?.companyCode || "*"; let query = `DELETE FROM screen_table_relations WHERE id = $1`; const params: any[] = [id]; @@ -962,7 +959,7 @@ export const deleteTableRelation = async (req: AuthenticatedRequest, res: Respon // ============================================================ // 화면 레이아웃 요약 조회 (위젯 타입별 개수, 라벨 목록) -export const getScreenLayoutSummary = async (req: Request, res: Response) => { +export const getScreenLayoutSummary = async (req: AuthenticatedRequest, res: Response) => { try { const { screenId } = req.params; @@ -1030,7 +1027,7 @@ export const getScreenLayoutSummary = async (req: Request, res: Response) => { }; // 여러 화면의 레이아웃 요약 일괄 조회 (미니어처 렌더링용 좌표 포함) -export const getMultipleScreenLayoutSummary = async (req: Request, res: Response) => { +export const getMultipleScreenLayoutSummary = async (req: AuthenticatedRequest, res: Response) => { try { const { screenIds } = req.body; @@ -1230,7 +1227,7 @@ export const getMultipleScreenLayoutSummary = async (req: Request, res: Response // ============================================================ // 여러 화면의 서브 테이블 정보 조회 (메인 테이블 → 서브 테이블 관계) -export const getScreenSubTables = async (req: Request, res: Response) => { +export const getScreenSubTables = async (req: AuthenticatedRequest, res: Response) => { try { const { screenIds } = req.body; @@ -2060,3 +2057,368 @@ export const getScreenSubTables = async (req: Request, res: Response) => { } }; + +// ============================================================ +// 메뉴-화면그룹 동기화 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, + }); + } +}; + diff --git a/backend-node/src/controllers/screenManagementController.ts b/backend-node/src/controllers/screenManagementController.ts index 5605031e..4eae31a4 100644 --- a/backend-node/src/controllers/screenManagementController.ts +++ b/backend-node/src/controllers/screenManagementController.ts @@ -834,3 +834,264 @@ export const cleanupDeletedScreenMenuAssignments = async ( }); } }; + +// 그룹 복제 완료 후 탭 컴포넌트의 screenId 참조 일괄 업데이트 +export const updateTabScreenReferences = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { targetScreenIds, screenIdMap } = req.body; + + if (!targetScreenIds || !Array.isArray(targetScreenIds)) { + return res.status(400).json({ + success: false, + message: "targetScreenIds 배열이 필요합니다.", + }); + } + + if (!screenIdMap || typeof screenIdMap !== "object") { + return res.status(400).json({ + success: false, + message: "screenIdMap 객체가 필요합니다.", + }); + } + + const result = await screenManagementService.updateTabScreenReferences( + targetScreenIds, + screenIdMap + ); + + return res.json({ + success: true, + message: `${result.updated}개 레이아웃의 탭 참조가 업데이트되었습니다.`, + updated: result.updated, + details: result.details, + }); + } catch (error) { + console.error("탭 screenId 참조 업데이트 실패:", error); + return res.status(500).json({ + success: false, + message: "탭 screenId 참조 업데이트에 실패했습니다.", + }); + } +}; + +// 화면-메뉴 할당 복제 (다른 회사로 복제 시) +export const copyScreenMenuAssignments = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { sourceCompanyCode, targetCompanyCode, screenIdMap } = req.body; + const userCompanyCode = req.user?.companyCode; + + // 권한 체크: 최고 관리자만 가능 + if (userCompanyCode !== "*") { + return res.status(403).json({ + success: false, + message: "최고 관리자만 이 기능을 사용할 수 있습니다.", + }); + } + + if (!sourceCompanyCode || !targetCompanyCode) { + return res.status(400).json({ + success: false, + message: "sourceCompanyCode와 targetCompanyCode가 필요합니다.", + }); + } + + if (!screenIdMap || typeof screenIdMap !== "object") { + return res.status(400).json({ + success: false, + message: "screenIdMap 객체가 필요합니다.", + }); + } + + const result = await screenManagementService.copyScreenMenuAssignments( + sourceCompanyCode, + targetCompanyCode, + screenIdMap + ); + + return res.json({ + success: true, + message: `화면-메뉴 할당 ${result.copiedCount}개 복제 완료`, + data: result, + }); + } catch (error) { + console.error("화면-메뉴 할당 복제 실패:", error); + return res.status(500).json({ + success: false, + message: "화면-메뉴 할당 복제에 실패했습니다.", + }); + } +}; + +// 코드 카테고리 + 코드 복제 +export const copyCodeCategoryAndCodes = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { sourceCompanyCode, targetCompanyCode } = req.body; + const userCompanyCode = req.user?.companyCode; + + if (userCompanyCode !== "*") { + return res.status(403).json({ + success: false, + message: "최고 관리자만 이 기능을 사용할 수 있습니다.", + }); + } + + if (!sourceCompanyCode || !targetCompanyCode) { + return res.status(400).json({ + success: false, + message: "sourceCompanyCode와 targetCompanyCode가 필요합니다.", + }); + } + + const result = await screenManagementService.copyCodeCategoryAndCodes( + sourceCompanyCode, + targetCompanyCode + ); + + return res.json({ + success: true, + message: `코드 카테고리 ${result.copiedCategories}개, 코드 ${result.copiedCodes}개 복제 완료`, + data: result, + }); + } catch (error) { + console.error("코드 카테고리/코드 복제 실패:", error); + return res.status(500).json({ + success: false, + message: "코드 카테고리/코드 복제에 실패했습니다.", + }); + } +}; + +// 카테고리 매핑 + 값 복제 +export const copyCategoryMapping = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { sourceCompanyCode, targetCompanyCode } = req.body; + const userCompanyCode = req.user?.companyCode; + + if (userCompanyCode !== "*") { + return res.status(403).json({ + success: false, + message: "최고 관리자만 이 기능을 사용할 수 있습니다.", + }); + } + + if (!sourceCompanyCode || !targetCompanyCode) { + return res.status(400).json({ + success: false, + message: "sourceCompanyCode와 targetCompanyCode가 필요합니다.", + }); + } + + const result = await screenManagementService.copyCategoryMapping( + sourceCompanyCode, + targetCompanyCode + ); + + return res.json({ + success: true, + message: `카테고리 매핑 ${result.copiedMappings}개, 값 ${result.copiedValues}개 복제 완료`, + data: result, + }); + } catch (error) { + console.error("카테고리 매핑/값 복제 실패:", error); + return res.status(500).json({ + success: false, + message: "카테고리 매핑/값 복제에 실패했습니다.", + }); + } +}; + +// 테이블 타입관리 입력타입 설정 복제 +export const copyTableTypeColumns = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { sourceCompanyCode, targetCompanyCode } = req.body; + const userCompanyCode = req.user?.companyCode; + + if (userCompanyCode !== "*") { + return res.status(403).json({ + success: false, + message: "최고 관리자만 이 기능을 사용할 수 있습니다.", + }); + } + + if (!sourceCompanyCode || !targetCompanyCode) { + return res.status(400).json({ + success: false, + message: "sourceCompanyCode와 targetCompanyCode가 필요합니다.", + }); + } + + const result = await screenManagementService.copyTableTypeColumns( + sourceCompanyCode, + targetCompanyCode + ); + + return res.json({ + success: true, + message: `테이블 타입 컬럼 ${result.copiedCount}개 복제 완료`, + data: result, + }); + } catch (error) { + console.error("테이블 타입 컬럼 복제 실패:", error); + return res.status(500).json({ + success: false, + message: "테이블 타입 컬럼 복제에 실패했습니다.", + }); + } +}; + +// 연쇄관계 설정 복제 +export const copyCascadingRelation = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { sourceCompanyCode, targetCompanyCode } = req.body; + const userCompanyCode = req.user?.companyCode; + + if (userCompanyCode !== "*") { + return res.status(403).json({ + success: false, + message: "최고 관리자만 이 기능을 사용할 수 있습니다.", + }); + } + + if (!sourceCompanyCode || !targetCompanyCode) { + return res.status(400).json({ + success: false, + message: "sourceCompanyCode와 targetCompanyCode가 필요합니다.", + }); + } + + const result = await screenManagementService.copyCascadingRelation( + sourceCompanyCode, + targetCompanyCode + ); + + return res.json({ + success: true, + message: `연쇄관계 설정 ${result.copiedCount}개 복제 완료`, + data: result, + }); + } catch (error) { + console.error("연쇄관계 설정 복제 실패:", error); + return res.status(500).json({ + success: false, + message: "연쇄관계 설정 복제에 실패했습니다.", + }); + } +}; diff --git a/backend-node/src/routes/screenManagementRoutes.ts b/backend-node/src/routes/screenManagementRoutes.ts index 67263277..e8dc402a 100644 --- a/backend-node/src/routes/screenManagementRoutes.ts +++ b/backend-node/src/routes/screenManagementRoutes.ts @@ -29,6 +29,12 @@ import { getScreensByMenu, unassignScreenFromMenu, cleanupDeletedScreenMenuAssignments, + updateTabScreenReferences, + copyScreenMenuAssignments, + copyCodeCategoryAndCodes, + copyCategoryMapping, + copyTableTypeColumns, + copyCascadingRelation, } from "../controllers/screenManagementController"; const router = express.Router(); @@ -83,4 +89,22 @@ router.post( cleanupDeletedScreenMenuAssignments ); +// 그룹 복제 완료 후 탭 컴포넌트의 screenId 참조 일괄 업데이트 +router.post("/screens/update-tab-references", updateTabScreenReferences); + +// 화면-메뉴 할당 복제 (다른 회사로 복제 시) +router.post("/copy-menu-assignments", copyScreenMenuAssignments); + +// 코드 카테고리 + 코드 복제 +router.post("/copy-code-category", copyCodeCategoryAndCodes); + +// 카테고리 매핑 + 값 복제 +router.post("/copy-category-mapping", copyCategoryMapping); + +// 테이블 타입 컬럼 복제 +router.post("/copy-table-type-columns", copyTableTypeColumns); + +// 연쇄관계 설정 복제 +router.post("/copy-cascading-relation", copyCascadingRelation); + export default router; diff --git a/backend-node/src/services/menuCopyService.ts b/backend-node/src/services/menuCopyService.ts index a163f30c..1e65cddd 100644 --- a/backend-node/src/services/menuCopyService.ts +++ b/backend-node/src/services/menuCopyService.ts @@ -16,6 +16,8 @@ export interface MenuCopyResult { copiedCategoryMappings: number; copiedTableTypeColumns: number; // 테이블 타입관리 입력타입 설정 copiedCascadingRelations: number; // 연쇄관계 설정 + copiedNodeFlows: number; // 노드 플로우 (제어관리) + copiedDataflowDiagrams: number; // 데이터플로우 다이어그램 (버튼 제어) menuIdMap: Record; screenIdMap: Record; flowIdMap: Record; @@ -983,6 +985,14 @@ export class MenuCopyService { client ); + // === 2.1단계: 노드 플로우 복사는 화면 복사에서 처리 === + // (screenManagementService.ts의 copyScreen에서 처리) + const copiedNodeFlows = 0; + + // === 2.2단계: 데이터플로우 다이어그램 복사는 화면 복사에서 처리 === + // (screenManagementService.ts의 copyScreen에서 처리) + const copiedDataflowDiagrams = 0; + // 변수 초기화 let copiedCodeCategories = 0; let copiedCodes = 0; @@ -1132,6 +1142,8 @@ export class MenuCopyService { copiedCategoryMappings, copiedTableTypeColumns, copiedCascadingRelations, + copiedNodeFlows, + copiedDataflowDiagrams, menuIdMap: Object.fromEntries(menuIdMap), screenIdMap: Object.fromEntries(screenIdMap), flowIdMap: Object.fromEntries(flowIdMap), @@ -1144,6 +1156,8 @@ export class MenuCopyService { - 메뉴: ${result.copiedMenus}개 - 화면: ${result.copiedScreens}개 - 플로우: ${result.copiedFlows}개 + - 노드 플로우(제어관리): ${copiedNodeFlows}개 + - 데이터플로우 다이어그램(버튼 제어): ${copiedDataflowDiagrams}개 - 코드 카테고리: ${copiedCodeCategories}개 - 코드: ${copiedCodes}개 - 채번규칙: ${copiedNumberingRules}개 @@ -2556,33 +2570,34 @@ export class MenuCopyService { } // 4. 배치 INSERT로 채번 규칙 복사 - if (rulesToCopy.length > 0) { - const ruleValues = rulesToCopy + // menu 스코프인데 menu_objid 매핑이 없는 규칙은 제외 (연결 없이 복제하지 않음) + const validRulesToCopy = rulesToCopy.filter((r) => { + if (r.scope_type === "menu") { + const newMenuObjid = menuIdMap.get(r.menu_objid); + if (newMenuObjid === undefined) { + logger.info(` ⏭️ 채번규칙 "${r.rule_name}" 건너뜀: 메뉴 연결 없음 (원본 menu_objid: ${r.menu_objid})`); + // ruleIdMap에서도 제거 + ruleIdMap.delete(r.rule_id); + return false; // 복제 대상에서 제외 + } + } + return true; + }); + + if (validRulesToCopy.length > 0) { + const ruleValues = validRulesToCopy .map( (_, i) => `($${i * 13 + 1}, $${i * 13 + 2}, $${i * 13 + 3}, $${i * 13 + 4}, $${i * 13 + 5}, $${i * 13 + 6}, $${i * 13 + 7}, $${i * 13 + 8}, $${i * 13 + 9}, NOW(), $${i * 13 + 10}, $${i * 13 + 11}, $${i * 13 + 12}, $${i * 13 + 13})` ) .join(", "); - const ruleParams = rulesToCopy.flatMap((r) => { + const ruleParams = validRulesToCopy.flatMap((r) => { const newMenuObjid = menuIdMap.get(r.menu_objid); - // scope_type = 'menu'인 경우 menu_objid가 반드시 필요함 (check 제약조건) - // menuIdMap에 없으면 원본 menu_objid가 복사된 메뉴 범위 밖이므로 - // scope_type을 'table'로 변경하거나, 매핑이 없으면 null 처리 + // menu 스코프인 경우 반드시 menu_objid가 있음 (위에서 필터링됨) const finalMenuObjid = newMenuObjid !== undefined ? newMenuObjid : null; - // scope_type 결정 로직: - // 1. menu 스코프인데 menu_objid 매핑이 없는 경우 - // - table_name이 있으면 'table' 스코프로 변경 - // - table_name이 없으면 'global' 스코프로 변경 - // 2. 그 외에는 원본 scope_type 유지 - let finalScopeType = r.scope_type; - if (r.scope_type === "menu" && finalMenuObjid === null) { - if (r.table_name) { - finalScopeType = "table"; // table_name이 있으면 table 스코프 - } else { - finalScopeType = "global"; // table_name도 없으면 global 스코프 - } - } + // scope_type은 원본 유지 (menu 스코프는 반드시 menu_objid가 있으므로) + const finalScopeType = r.scope_type; return [ r.newRuleId, @@ -2610,8 +2625,8 @@ export class MenuCopyService { ruleParams ); - copiedCount = rulesToCopy.length; - logger.info(` ✅ 채번 규칙 ${copiedCount}개 복사`); + copiedCount = validRulesToCopy.length; + logger.info(` ✅ 채번 규칙 ${copiedCount}개 복사 (${rulesToCopy.length - validRulesToCopy.length}개 건너뜀)`); } // 4-1. 기존 채번 규칙의 menu_objid 업데이트 (새 메뉴와 연결) - 배치 처리 @@ -3324,4 +3339,175 @@ export class MenuCopyService { logger.info(`✅ 연쇄관계 복사 완료: ${copiedCount}개`); return copiedCount; } + + /** + * 노드 플로우 복사 (node_flows 테이블 - 제어관리에서 사용) + * - 원본 회사의 모든 node_flows를 대상 회사로 복사 + * - 대상 회사에 같은 이름의 노드 플로우가 있으면 재사용 + * - 없으면 새로 복사 (flow_data 포함) + * - 원본 ID → 새 ID 매핑 반환 (버튼의 flowId, selectedDiagramId 매핑용) + */ + private async copyNodeFlows( + sourceCompanyCode: string, + targetCompanyCode: string, + client: PoolClient + ): Promise<{ copiedCount: number; nodeFlowIdMap: Map }> { + logger.info(`📋 노드 플로우(제어관리) 복사 시작`); + const nodeFlowIdMap = new Map(); + let copiedCount = 0; + + // 1. 원본 회사의 모든 node_flows 조회 + const sourceFlowsResult = await client.query( + `SELECT * FROM node_flows WHERE company_code = $1`, + [sourceCompanyCode] + ); + + if (sourceFlowsResult.rows.length === 0) { + logger.info(` 📭 원본 회사에 노드 플로우 없음`); + return { copiedCount: 0, nodeFlowIdMap }; + } + + logger.info(` 📋 원본 노드 플로우: ${sourceFlowsResult.rows.length}개`); + + // 2. 대상 회사의 기존 노드 플로우 조회 (이름 기준) + const existingFlowsResult = await client.query( + `SELECT flow_id, flow_name FROM node_flows WHERE company_code = $1`, + [targetCompanyCode] + ); + const existingFlowsByName = new Map( + existingFlowsResult.rows.map((f) => [f.flow_name, f.flow_id]) + ); + + // 3. 복사할 플로우 필터링 + 기존 플로우 매핑 + const flowsToCopy: any[] = []; + for (const flow of sourceFlowsResult.rows) { + const existingId = existingFlowsByName.get(flow.flow_name); + if (existingId) { + // 기존 플로우 재사용 - ID 매핑 추가 + nodeFlowIdMap.set(flow.flow_id, existingId); + logger.info(` ♻️ 기존 노드 플로우 재사용: ${flow.flow_name} (${flow.flow_id} → ${existingId})`); + } else { + flowsToCopy.push(flow); + } + } + + if (flowsToCopy.length === 0) { + logger.info(` 📭 모든 노드 플로우가 이미 존재함 (매핑 ${nodeFlowIdMap.size}개)`); + return { copiedCount: 0, nodeFlowIdMap }; + } + + logger.info(` 🔄 복사할 노드 플로우: ${flowsToCopy.length}개`); + + // 4. 개별 INSERT (RETURNING으로 새 ID 획득) + for (const flow of flowsToCopy) { + const insertResult = await client.query( + `INSERT INTO node_flows (flow_name, flow_description, flow_data, company_code) + VALUES ($1, $2, $3, $4) + RETURNING flow_id`, + [ + flow.flow_name, + flow.flow_description, + JSON.stringify(flow.flow_data), + targetCompanyCode, + ] + ); + + const newFlowId = insertResult.rows[0].flow_id; + nodeFlowIdMap.set(flow.flow_id, newFlowId); + logger.info(` ➕ 노드 플로우 복사: ${flow.flow_name} (${flow.flow_id} → ${newFlowId})`); + copiedCount++; + } + + logger.info(` ✅ 노드 플로우 복사 완료: ${copiedCount}개, 매핑 ${nodeFlowIdMap.size}개`); + + return { copiedCount, nodeFlowIdMap }; + } + + /** + * 데이터플로우 다이어그램 복사 (dataflow_diagrams 테이블 - 버튼 제어 설정에서 사용) + * - 원본 회사의 모든 dataflow_diagrams를 대상 회사로 복사 + * - 대상 회사에 같은 이름의 다이어그램이 있으면 재사용 + * - 없으면 새로 복사 (relationships, node_positions, control, plan, category 포함) + * - 원본 ID → 새 ID 매핑 반환 + */ + private async copyDataflowDiagrams( + sourceCompanyCode: string, + targetCompanyCode: string, + userId: string, + client: PoolClient + ): Promise<{ copiedCount: number; diagramIdMap: Map }> { + logger.info(`📋 데이터플로우 다이어그램(버튼 제어) 복사 시작`); + const diagramIdMap = new Map(); + let copiedCount = 0; + + // 1. 원본 회사의 모든 dataflow_diagrams 조회 + const sourceDiagramsResult = await client.query( + `SELECT * FROM dataflow_diagrams WHERE company_code = $1`, + [sourceCompanyCode] + ); + + if (sourceDiagramsResult.rows.length === 0) { + logger.info(` 📭 원본 회사에 데이터플로우 다이어그램 없음`); + return { copiedCount: 0, diagramIdMap }; + } + + logger.info(` 📋 원본 데이터플로우 다이어그램: ${sourceDiagramsResult.rows.length}개`); + + // 2. 대상 회사의 기존 다이어그램 조회 (이름 기준) + const existingDiagramsResult = await client.query( + `SELECT diagram_id, diagram_name FROM dataflow_diagrams WHERE company_code = $1`, + [targetCompanyCode] + ); + const existingDiagramsByName = new Map( + existingDiagramsResult.rows.map((d) => [d.diagram_name, d.diagram_id]) + ); + + // 3. 복사할 다이어그램 필터링 + 기존 다이어그램 매핑 + const diagramsToCopy: any[] = []; + for (const diagram of sourceDiagramsResult.rows) { + const existingId = existingDiagramsByName.get(diagram.diagram_name); + if (existingId) { + // 기존 다이어그램 재사용 - ID 매핑 추가 + diagramIdMap.set(diagram.diagram_id, existingId); + logger.info(` ♻️ 기존 다이어그램 재사용: ${diagram.diagram_name} (${diagram.diagram_id} → ${existingId})`); + } else { + diagramsToCopy.push(diagram); + } + } + + if (diagramsToCopy.length === 0) { + logger.info(` 📭 모든 다이어그램이 이미 존재함 (매핑 ${diagramIdMap.size}개)`); + return { copiedCount: 0, diagramIdMap }; + } + + logger.info(` 🔄 복사할 다이어그램: ${diagramsToCopy.length}개`); + + // 4. 개별 INSERT (RETURNING으로 새 ID 획득) + for (const diagram of diagramsToCopy) { + const insertResult = await client.query( + `INSERT INTO dataflow_diagrams (diagram_name, relationships, company_code, created_by, node_positions, control, plan, category) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING diagram_id`, + [ + diagram.diagram_name, + JSON.stringify(diagram.relationships), + targetCompanyCode, + userId, + diagram.node_positions ? JSON.stringify(diagram.node_positions) : null, + diagram.control ? JSON.stringify(diagram.control) : null, + diagram.plan ? JSON.stringify(diagram.plan) : null, + diagram.category ? JSON.stringify(diagram.category) : null, + ] + ); + + const newDiagramId = insertResult.rows[0].diagram_id; + diagramIdMap.set(diagram.diagram_id, newDiagramId); + logger.info(` ➕ 다이어그램 복사: ${diagram.diagram_name} (${diagram.diagram_id} → ${newDiagramId})`); + copiedCount++; + } + + logger.info(` ✅ 데이터플로우 다이어그램 복사 완료: ${copiedCount}개, 매핑 ${diagramIdMap.size}개`); + + return { copiedCount, diagramIdMap }; + } } diff --git a/backend-node/src/services/menuScreenSyncService.ts b/backend-node/src/services/menuScreenSyncService.ts new file mode 100644 index 00000000..3d581a4a --- /dev/null +++ b/backend-node/src/services/menuScreenSyncService.ts @@ -0,0 +1,1021 @@ +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] + ); + + // 해당 그룹에 연결된 기본 화면으로 URL 항상 업데이트 (화면 재생성 시에도 반영) + const defaultScreenQuery = ` + SELECT sd.screen_id, sd.screen_code, sd.screen_name + FROM screen_group_screens sgs + JOIN screen_definitions sd ON sgs.screen_id = sd.screen_id + WHERE sgs.group_id = $1 AND sgs.company_code = $2 + ORDER BY + CASE WHEN sgs.is_default = 'Y' THEN 0 ELSE 1 END, + sgs.display_order ASC + LIMIT 1 + `; + const defaultScreenResult = await client.query(defaultScreenQuery, [groupId, companyCode]); + if (defaultScreenResult.rows.length > 0) { + const defaultScreen = defaultScreenResult.rows[0]; + const newMenuUrl = `/screens/${defaultScreen.screen_id}`; + await client.query( + `UPDATE menu_info SET menu_url = $1, screen_code = $2 WHERE objid = $3`, + [newMenuUrl, defaultScreen.screen_code, menuObjid] + ); + logger.info("메뉴 URL 업데이트", { groupName, screenId: defaultScreen.screen_id, menuUrl: newMenuUrl }); + } + + 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; + } + + // 해당 그룹에 연결된 기본 화면 조회 (is_default = 'Y' 우선, 없으면 첫 번째 화면) + let menuUrl: string | null = null; + let screenCode: string | null = null; + const defaultScreenQuery = ` + SELECT sd.screen_id, sd.screen_code, sd.screen_name + FROM screen_group_screens sgs + JOIN screen_definitions sd ON sgs.screen_id = sd.screen_id + WHERE sgs.group_id = $1 AND sgs.company_code = $2 + ORDER BY + CASE WHEN sgs.is_default = 'Y' THEN 0 ELSE 1 END, + sgs.display_order ASC + LIMIT 1 + `; + const defaultScreenResult = await client.query(defaultScreenQuery, [groupId, companyCode]); + if (defaultScreenResult.rows.length > 0) { + const defaultScreen = defaultScreenResult.rows[0]; + screenCode = defaultScreen.screen_code; + menuUrl = `/screens/${defaultScreen.screen_id}`; + logger.info("기본 화면 URL 설정", { groupName, screenId: defaultScreen.screen_id, menuUrl }); + } + + // 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, + menu_url, screen_code + ) VALUES ($1, $2, $3, $4, $5, 1, $6, $7, NOW(), 'active', $8, $9, $10, $11) + RETURNING objid + `; + await client.query(insertMenuQuery, [ + newObjid, + parentMenuObjid, + groupName, + group.group_code || groupName, + nextSeq, + companyCode, + userId, + groupId, + group.description || null, + menuUrl, + screenCode, + ]); + + // 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, + stack: error.stack, + code: error.code, + detail: error.detail, + }); + 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; + } +} + diff --git a/backend-node/src/services/numberingRuleService.ts b/backend-node/src/services/numberingRuleService.ts index 8208ecc5..381fc907 100644 --- a/backend-node/src/services/numberingRuleService.ts +++ b/backend-node/src/services/numberingRuleService.ts @@ -854,7 +854,13 @@ class NumberingRuleService { return { ...ruleResult.rows[0], parts }; } catch (error: any) { await client.query("ROLLBACK"); - logger.error("채번 규칙 수정 실패", { error: error.message }); + logger.error("채번 규칙 수정 실패", { + ruleId, + companyCode, + error: error.message, + stack: error.stack, + updates + }); throw error; } finally { client.release(); @@ -1062,6 +1068,203 @@ class NumberingRuleService { ); logger.info("시퀀스 초기화 완료", { ruleId, companyCode }); } + + /** + * 회사별 채번규칙 복제 (메뉴 동기화 완료 후 호출) + * 메뉴 이름을 기준으로 채번규칙을 대상 회사의 메뉴에 연결 + * 복제 후 화면 레이아웃의 numberingRuleId 참조도 업데이트 + */ + async copyRulesForCompany( + sourceCompanyCode: string, + targetCompanyCode: string + ): Promise<{ copiedCount: number; skippedCount: number; details: string[]; ruleIdMap: Record }> { + const pool = getPool(); + const client = await pool.connect(); + + const result = { copiedCount: 0, skippedCount: 0, details: [] as string[], ruleIdMap: {} as Record }; + + try { + await client.query("BEGIN"); + + // 1. 원본 회사의 채번규칙 조회 (menu + table 스코프 모두) + const sourceRulesResult = await client.query( + `SELECT nr.*, mi.menu_name_kor as source_menu_name + FROM numbering_rules nr + LEFT JOIN menu_info mi ON nr.menu_objid = mi.objid + WHERE nr.company_code = $1 AND nr.scope_type IN ('menu', 'table')`, + [sourceCompanyCode] + ); + + logger.info("원본 채번규칙 조회", { + sourceCompanyCode, + count: sourceRulesResult.rowCount + }); + + // 2. 각 채번규칙 복제 + for (const rule of sourceRulesResult.rows) { + // 새 rule_id 생성 + const newRuleId = `rule-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + + // 이미 존재하는지 확인 (이름 기반) + const existsCheck = await client.query( + `SELECT rule_id FROM numbering_rules + WHERE company_code = $1 AND rule_name = $2`, + [targetCompanyCode, rule.rule_name] + ); + + if (existsCheck.rows.length > 0) { + // 이미 존재하면 매핑만 추가 + result.ruleIdMap[rule.rule_id] = existsCheck.rows[0].rule_id; + result.skippedCount++; + result.details.push(`건너뜀 (이미 존재): ${rule.rule_name}`); + continue; + } + + let targetMenuObjid = null; + + // menu 스코프인 경우 대상 메뉴 찾기 + if (rule.scope_type === 'menu' && rule.source_menu_name) { + const targetMenuResult = await client.query( + `SELECT objid FROM menu_info + WHERE company_code = $1 AND menu_name_kor = $2 + LIMIT 1`, + [targetCompanyCode, rule.source_menu_name] + ); + + if (targetMenuResult.rows.length === 0) { + result.skippedCount++; + result.details.push(`건너뜀 (메뉴 없음): ${rule.rule_name} - 메뉴: ${rule.source_menu_name}`); + continue; + } + + targetMenuObjid = targetMenuResult.rows[0].objid; + } + + // 채번규칙 복제 + await client.query( + `INSERT INTO numbering_rules ( + rule_id, rule_name, description, separator, reset_period, + current_sequence, table_name, column_name, company_code, + created_at, updated_at, created_by, scope_type, menu_objid + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW(), $10, $11, $12)`, + [ + newRuleId, + rule.rule_name, + rule.description, + rule.separator, + rule.reset_period, + 0, // 시퀀스 초기화 + rule.table_name, + rule.column_name, + targetCompanyCode, + rule.created_by, + rule.scope_type, + targetMenuObjid, + ] + ); + + // 채번규칙 파트 복제 + const partsResult = await client.query( + `SELECT * FROM numbering_rule_parts WHERE rule_id = $1 ORDER BY part_order`, + [rule.rule_id] + ); + + for (const part of partsResult.rows) { + await client.query( + `INSERT INTO numbering_rule_parts ( + rule_id, part_order, part_type, generation_method, + auto_config, manual_config, company_code, created_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())`, + [ + newRuleId, + part.part_order, + part.part_type, + part.generation_method, + part.auto_config ? JSON.stringify(part.auto_config) : null, + part.manual_config ? JSON.stringify(part.manual_config) : null, + targetCompanyCode, + ] + ); + } + + // 매핑 추가 + result.ruleIdMap[rule.rule_id] = newRuleId; + result.copiedCount++; + result.details.push(`복제 완료: ${rule.rule_name} (${rule.scope_type})`); + logger.info("채번규칙 복제 완료", { + ruleName: rule.rule_name, + oldRuleId: rule.rule_id, + newRuleId, + targetMenuObjid + }); + } + + // 3. 화면 레이아웃의 numberingRuleId 참조 업데이트 + if (Object.keys(result.ruleIdMap).length > 0) { + logger.info("화면 레이아웃 numberingRuleId 참조 업데이트 시작", { + targetCompanyCode, + mappingCount: Object.keys(result.ruleIdMap).length + }); + + // 대상 회사의 모든 화면 레이아웃 조회 + const layoutsResult = await client.query( + `SELECT sl.layout_id, sl.properties + FROM screen_layouts sl + JOIN screen_definitions sd ON sl.screen_id = sd.screen_id + WHERE sd.company_code = $1 + AND sl.properties::text LIKE '%numberingRuleId%'`, + [targetCompanyCode] + ); + + let updatedLayouts = 0; + + for (const layout of layoutsResult.rows) { + let propsStr = JSON.stringify(layout.properties); + let updated = false; + + // 각 매핑에 대해 치환 + for (const [oldRuleId, newRuleId] of Object.entries(result.ruleIdMap)) { + if (propsStr.includes(`"${oldRuleId}"`)) { + propsStr = propsStr.split(`"${oldRuleId}"`).join(`"${newRuleId}"`); + updated = true; + } + } + + if (updated) { + await client.query( + `UPDATE screen_layouts SET properties = $1::jsonb WHERE layout_id = $2`, + [propsStr, layout.layout_id] + ); + updatedLayouts++; + } + } + + logger.info("화면 레이아웃 numberingRuleId 참조 업데이트 완료", { + targetCompanyCode, + updatedLayouts + }); + result.details.push(`화면 레이아웃 ${updatedLayouts}개의 채번규칙 참조 업데이트`); + } + + await client.query("COMMIT"); + + logger.info("회사별 채번규칙 복제 완료", { + sourceCompanyCode, + targetCompanyCode, + copiedCount: result.copiedCount, + skippedCount: result.skippedCount, + ruleIdMapCount: Object.keys(result.ruleIdMap).length + }); + + return result; + } catch (error) { + await client.query("ROLLBACK"); + logger.error("회사별 채번규칙 복제 실패", { error, sourceCompanyCode, targetCompanyCode }); + throw error; + } finally { + client.release(); + } + } } export const numberingRuleService = new NumberingRuleService(); diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 783e83c0..15c0e1f5 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -2549,6 +2549,730 @@ export class ScreenManagementService { })); } + /** + * 화면 레이아웃에서 사용하는 numberingRuleId 수집 + * - componentConfig.autoGeneration.options.numberingRuleId (text-input) + * - componentConfig.sections[].fields[].numberingRule.ruleId (universal-form-modal) + * - componentConfig.action.excelNumberingRuleId (엑셀 업로드) + */ + private collectNumberingRuleIdsFromLayouts(layouts: any[]): Set { + const ruleIds = new Set(); + + for (const layout of layouts) { + const props = layout.properties; + if (!props) continue; + + // 1. componentConfig.autoGeneration.options.numberingRuleId (text-input 컴포넌트) + const autoGenRuleId = props?.componentConfig?.autoGeneration?.options?.numberingRuleId; + if (autoGenRuleId && typeof autoGenRuleId === 'string' && autoGenRuleId.startsWith('rule-')) { + ruleIds.add(autoGenRuleId); + } + + // 2. componentConfig.sections[].fields[].numberingRule.ruleId (universal-form-modal) + const sections = props?.componentConfig?.sections; + if (Array.isArray(sections)) { + for (const section of sections) { + const fields = section?.fields; + if (Array.isArray(fields)) { + for (const field of fields) { + const ruleId = field?.numberingRule?.ruleId; + if (ruleId && typeof ruleId === 'string' && ruleId.startsWith('rule-')) { + ruleIds.add(ruleId); + } + } + } + // optionalFieldGroups 내부의 필드들도 확인 + const optGroups = section?.optionalFieldGroups; + if (Array.isArray(optGroups)) { + for (const optGroup of optGroups) { + const optFields = optGroup?.fields; + if (Array.isArray(optFields)) { + for (const field of optFields) { + const ruleId = field?.numberingRule?.ruleId; + if (ruleId && typeof ruleId === 'string' && ruleId.startsWith('rule-')) { + ruleIds.add(ruleId); + } + } + } + } + } + } + } + + // 3. componentConfig.action.excelNumberingRuleId (엑셀 업로드) + const excelRuleId = props?.componentConfig?.action?.excelNumberingRuleId; + if (excelRuleId && typeof excelRuleId === 'string' && excelRuleId.startsWith('rule-')) { + ruleIds.add(excelRuleId); + } + + // 4. componentConfig.action.numberingRuleId (버튼 액션) + const actionRuleId = props?.componentConfig?.action?.numberingRuleId; + if (actionRuleId && typeof actionRuleId === 'string' && actionRuleId.startsWith('rule-')) { + ruleIds.add(actionRuleId); + } + } + + return ruleIds; + } + + /** + * 채번 규칙 복사 및 ID 매핑 반환 + * - 원본 회사의 채번 규칙을 대상 회사로 복사 + * - 이름이 같은 규칙이 있으면 재사용 + * - current_sequence는 0으로 초기화 + */ + private async copyNumberingRulesForScreen( + ruleIds: Set, + sourceCompanyCode: string, + targetCompanyCode: string, + client: any + ): Promise> { + const ruleIdMap = new Map(); + + if (ruleIds.size === 0) { + return ruleIdMap; + } + + console.log(`🔄 채번 규칙 복사 시작: ${ruleIds.size}개 규칙`); + + // 1. 원본 채번 규칙 조회 (회사 코드 제한 없이 rule_id로 조회) + // 화면이 다른 회사의 채번 규칙을 참조할 수 있으므로 회사 필터 제거 + const ruleIdArray = Array.from(ruleIds); + const sourceRulesResult = await client.query( + `SELECT * FROM numbering_rules WHERE rule_id = ANY($1)`, + [ruleIdArray] + ); + + if (sourceRulesResult.rows.length === 0) { + console.log(` 📭 복사할 채번 규칙 없음 (해당 rule_id 없음)`); + return ruleIdMap; + } + + console.log(` 📋 원본 채번 규칙: ${sourceRulesResult.rows.length}개`); + + // 2. 대상 회사의 기존 채번 규칙 조회 (이름 기준) + const existingRulesResult = await client.query( + `SELECT rule_id, rule_name FROM numbering_rules WHERE company_code = $1`, + [targetCompanyCode] + ); + const existingRulesByName = new Map( + existingRulesResult.rows.map((r: any) => [r.rule_name, r.rule_id]) + ); + + // 3. 각 규칙 복사 또는 재사용 + for (const rule of sourceRulesResult.rows) { + const existingId = existingRulesByName.get(rule.rule_name); + + if (existingId) { + // 기존 규칙 재사용 + ruleIdMap.set(rule.rule_id, existingId); + console.log(` ♻️ 기존 채번 규칙 재사용: ${rule.rule_name} (${rule.rule_id} → ${existingId})`); + } else { + // 새로 복사 - 새 rule_id 생성 + const newRuleId = `rule-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + + // scope_type이 'menu'인 경우 대상 회사에서 같은 이름의 메뉴 찾기 + let newScopeType = rule.scope_type; + let newMenuObjid: string | null = null; + + if (rule.scope_type === 'menu' && rule.menu_objid) { + // 원본 menu_objid로 메뉴와 연결된 screen_group 조회 + const sourceMenuResult = await client.query( + `SELECT mi.menu_name_kor, sg.group_name + FROM menu_info mi + LEFT JOIN screen_groups sg ON sg.id = mi.screen_group_id + WHERE mi.objid = $1`, + [rule.menu_objid] + ); + + if (sourceMenuResult.rows.length > 0) { + const { menu_name_kor: menuName, group_name: groupName } = sourceMenuResult.rows[0]; + + // 방법 1: 그룹 이름으로 대상 회사의 메뉴 찾기 (더 정확) + let targetMenuResult; + if (groupName) { + targetMenuResult = await client.query( + `SELECT mi.objid, mi.menu_name_kor + FROM menu_info mi + JOIN screen_groups sg ON sg.id = mi.screen_group_id + WHERE mi.company_code = $1 AND sg.group_name = $2 + LIMIT 1`, + [targetCompanyCode, groupName] + ); + } + + // 방법 2: 그룹으로 못 찾으면 메뉴 이름으로 찾기 + if (!targetMenuResult || targetMenuResult.rows.length === 0) { + targetMenuResult = await client.query( + `SELECT objid, menu_name_kor FROM menu_info + WHERE company_code = $1 AND menu_name_kor = $2 + LIMIT 1`, + [targetCompanyCode, menuName] + ); + } + + if (targetMenuResult.rows.length > 0) { + // 대상 회사에 매칭되는 메뉴가 있으면 연결 + newMenuObjid = targetMenuResult.rows[0].objid; + console.log(` 🔗 메뉴 연결: "${menuName}" → "${targetMenuResult.rows[0].menu_name_kor}" (objid: ${newMenuObjid})`); + } else { + // 대상 회사에 메뉴가 없으면 복제하지 않음 (메뉴 동기화 후 다시 시도 필요) + console.log(` ⏭️ 채번규칙 "${rule.rule_name}" 건너뜀: 대상 회사에 "${menuName}" 메뉴 없음`); + continue; // 이 채번규칙은 복제하지 않음 + } + } else { + // 원본 메뉴를 찾을 수 없으면 복제하지 않음 + console.log(` ⏭️ 채번규칙 "${rule.rule_name}" 건너뜀: 원본 메뉴(${rule.menu_objid})를 찾을 수 없음`); + continue; // 이 채번규칙은 복제하지 않음 + } + } + + // numbering_rules 복사 (current_sequence = 0으로 초기화) + await client.query( + `INSERT INTO numbering_rules ( + rule_id, rule_name, description, separator, reset_period, + current_sequence, table_name, column_name, company_code, + created_at, updated_at, created_by, scope_type, last_generated_date, menu_objid + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)`, + [ + newRuleId, + rule.rule_name, + rule.description, + rule.separator, + rule.reset_period, + 0, // current_sequence 초기화 + rule.table_name, + rule.column_name, + targetCompanyCode, + new Date(), + new Date(), + rule.created_by, + newScopeType, + null, // last_generated_date 초기화 + newMenuObjid, // 대상 회사의 메뉴 objid (없으면 null) + ] + ); + + // numbering_rule_parts 복사 + const partsResult = await client.query( + `SELECT * FROM numbering_rule_parts WHERE rule_id = $1 ORDER BY part_order`, + [rule.rule_id] + ); + + for (const part of partsResult.rows) { + await client.query( + `INSERT INTO numbering_rule_parts ( + rule_id, part_order, part_type, generation_method, + auto_config, manual_config, company_code, created_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + [ + newRuleId, + part.part_order, + part.part_type, + part.generation_method, + part.auto_config ? JSON.stringify(part.auto_config) : null, + part.manual_config ? JSON.stringify(part.manual_config) : null, + targetCompanyCode, + new Date(), + ] + ); + } + + ruleIdMap.set(rule.rule_id, newRuleId); + console.log(` ➕ 채번 규칙 복사: ${rule.rule_name} (${rule.rule_id} → ${newRuleId}), scope: ${newScopeType}, menu_objid: ${newMenuObjid || 'NULL'}, 파트 ${partsResult.rows.length}개`); + } + } + + console.log(` ✅ 채번 규칙 복사 완료: 매핑 ${ruleIdMap.size}개`); + return ruleIdMap; + } + + /** + * properties 내의 numberingRuleId 매핑 + * - componentConfig.autoGeneration.options.numberingRuleId (text-input) + * - componentConfig.sections[].fields[].numberingRule.ruleId (universal-form-modal) + * - componentConfig.action.excelNumberingRuleId (엑셀 업로드) + */ + private updateNumberingRuleIdsInProperties(properties: any, ruleIdMap: Map): any { + if (!properties || ruleIdMap.size === 0) return properties; + + const updated = JSON.parse(JSON.stringify(properties)); + + // 1. componentConfig.autoGeneration.options.numberingRuleId (text-input) + if (updated?.componentConfig?.autoGeneration?.options?.numberingRuleId) { + const oldId = updated.componentConfig.autoGeneration.options.numberingRuleId; + const newId = ruleIdMap.get(oldId); + if (newId) { + updated.componentConfig.autoGeneration.options.numberingRuleId = newId; + console.log(` 🔗 autoGeneration.numberingRuleId: ${oldId} → ${newId}`); + } + } + + // 2. componentConfig.sections[].fields[].numberingRule.ruleId (universal-form-modal) + if (Array.isArray(updated?.componentConfig?.sections)) { + for (const section of updated.componentConfig.sections) { + // 일반 필드 + if (Array.isArray(section?.fields)) { + for (const field of section.fields) { + if (field?.numberingRule?.ruleId) { + const oldId = field.numberingRule.ruleId; + const newId = ruleIdMap.get(oldId); + if (newId) { + field.numberingRule.ruleId = newId; + console.log(` 🔗 field.numberingRule.ruleId: ${oldId} → ${newId}`); + } + } + } + } + // optionalFieldGroups 내부의 필드들 + if (Array.isArray(section?.optionalFieldGroups)) { + for (const optGroup of section.optionalFieldGroups) { + if (Array.isArray(optGroup?.fields)) { + for (const field of optGroup.fields) { + if (field?.numberingRule?.ruleId) { + const oldId = field.numberingRule.ruleId; + const newId = ruleIdMap.get(oldId); + if (newId) { + field.numberingRule.ruleId = newId; + console.log(` 🔗 optField.numberingRule.ruleId: ${oldId} → ${newId}`); + } + } + } + } + } + } + } + } + + // 3. componentConfig.action.excelNumberingRuleId + if (updated?.componentConfig?.action?.excelNumberingRuleId) { + const oldId = updated.componentConfig.action.excelNumberingRuleId; + const newId = ruleIdMap.get(oldId); + if (newId) { + updated.componentConfig.action.excelNumberingRuleId = newId; + console.log(` 🔗 excelNumberingRuleId: ${oldId} → ${newId}`); + } + } + + // 4. componentConfig.action.numberingRuleId (버튼 액션) + if (updated?.componentConfig?.action?.numberingRuleId) { + const oldId = updated.componentConfig.action.numberingRuleId; + const newId = ruleIdMap.get(oldId); + if (newId) { + updated.componentConfig.action.numberingRuleId = newId; + console.log(` 🔗 action.numberingRuleId: ${oldId} → ${newId}`); + } + } + + return updated; + } + + /** + * properties 내의 탭 컴포넌트 screenId 매핑 + * - componentConfig.tabs[].screenId (tabs-widget) + */ + private updateTabScreenIdsInProperties(properties: any, screenIdMap: Map): any { + if (!properties || screenIdMap.size === 0) return properties; + + const updated = JSON.parse(JSON.stringify(properties)); + + // componentConfig.tabs[].screenId (tabs-widget) + if (Array.isArray(updated?.componentConfig?.tabs)) { + for (const tab of updated.componentConfig.tabs) { + if (tab?.screenId) { + const oldId = Number(tab.screenId); + const newId = screenIdMap.get(oldId); + if (newId) { + tab.screenId = newId; + console.log(` 🔗 tab.screenId: ${oldId} → ${newId}`); + } + } + } + } + + return updated; + } + + /** + * 그룹 복제 완료 후 모든 컴포넌트의 screenId/modalScreenId 참조 일괄 업데이트 + * - tabs 컴포넌트의 screenId + * - conditional-container의 screenId + * - 버튼/액션의 modalScreenId + * @param targetScreenIds 복제된 대상 화면 ID 목록 + * @param screenIdMap 원본 화면 ID -> 새 화면 ID 매핑 + */ + async updateTabScreenReferences( + targetScreenIds: number[], + screenIdMap: { [key: number]: number } + ): Promise<{ updated: number; details: string[] }> { + const result = { updated: 0, details: [] as string[] }; + + if (targetScreenIds.length === 0 || Object.keys(screenIdMap).length === 0) { + console.log(`⚠️ updateTabScreenReferences 스킵: targetScreenIds=${targetScreenIds.length}, screenIdMap keys=${Object.keys(screenIdMap).length}`); + return result; + } + + console.log(`🔄 updateTabScreenReferences 시작:`); + console.log(` - targetScreenIds: ${targetScreenIds.length}개`); + console.log(` - screenIdMap: ${JSON.stringify(screenIdMap)}`); + + const screenMap = new Map( + Object.entries(screenIdMap).map(([k, v]) => [Number(k), v]) + ); + + await transaction(async (client) => { + // 대상 화면들의 모든 레이아웃 조회 (screenId 또는 modalScreenId 참조가 있는 것) + const placeholders = targetScreenIds.map((_, i) => `$${i + 1}`).join(', '); + const layoutsResult = await client.query( + `SELECT layout_id, screen_id, properties + FROM screen_layouts + WHERE screen_id IN (${placeholders}) + AND ( + properties::text LIKE '%"screenId"%' + OR properties::text LIKE '%"modalScreenId"%' + )`, + targetScreenIds + ); + + console.log(`🔍 참조 업데이트 대상 레이아웃: ${layoutsResult.rows.length}개`); + + for (const layout of layoutsResult.rows) { + let properties = layout.properties; + if (typeof properties === 'string') { + try { + properties = JSON.parse(properties); + } catch (e) { + continue; + } + } + + let hasChanges = false; + + // 재귀적으로 모든 screenId/modalScreenId 참조 업데이트 + const updateReferences = async (obj: any, path: string = ''): Promise => { + if (!obj || typeof obj !== 'object') return; + + for (const key of Object.keys(obj)) { + const value = obj[key]; + const currentPath = path ? `${path}.${key}` : key; + + // screenId 업데이트 + if (key === 'screenId' && typeof value === 'number') { + const newId = screenMap.get(value); + if (newId) { + obj[key] = newId; + hasChanges = true; + result.details.push(`layout_id=${layout.layout_id}: ${currentPath} ${value} → ${newId}`); + console.log(`🔗 screenId 매핑: ${value} → ${newId} (${currentPath})`); + + // screenName도 함께 업데이트 (있는 경우) + if (obj.screenName !== undefined) { + const newScreenResult = await client.query( + `SELECT screen_name FROM screen_definitions WHERE screen_id = $1`, + [newId] + ); + if (newScreenResult.rows.length > 0) { + obj.screenName = newScreenResult.rows[0].screen_name; + } + } + } else { + console.log(`⚠️ screenId ${value} 매핑 없음 (${currentPath}) - screenMap에 해당 키 없음`); + } + } + + // modalScreenId 업데이트 + if (key === 'modalScreenId' && typeof value === 'number') { + const newId = screenMap.get(value); + if (newId) { + obj[key] = newId; + hasChanges = true; + result.details.push(`layout_id=${layout.layout_id}: ${currentPath} ${value} → ${newId}`); + console.log(`🔗 modalScreenId 매핑: ${value} → ${newId} (${currentPath})`); + } else { + console.log(`⚠️ modalScreenId ${value} 매핑 없음 (${currentPath}) - screenMap에 해당 키 없음`); + } + } + + // 배열 처리 + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + await updateReferences(value[i], `${currentPath}[${i}]`); + } + } + // 객체 재귀 + else if (typeof value === 'object' && value !== null) { + await updateReferences(value, currentPath); + } + } + }; + + await updateReferences(properties); + + if (hasChanges) { + await client.query( + `UPDATE screen_layouts SET properties = $1 WHERE layout_id = $2`, + [JSON.stringify(properties), layout.layout_id] + ); + result.updated++; + } + } + + console.log(`✅ screenId/modalScreenId 참조 업데이트 완료: ${result.updated}개 레이아웃`); + }); + + return result; + } + + /** + * 탭 컴포넌트의 screenId를 대상 회사에서 같은 이름의 화면으로 자동 매핑 + * @param properties 레이아웃 properties + * @param targetCompanyCode 대상 회사 코드 + * @param client PostgreSQL 클라이언트 + * @returns 업데이트된 properties + */ + private async autoMapTabScreenIds( + properties: any, + targetCompanyCode: string, + client: any + ): Promise { + if (!Array.isArray(properties?.componentConfig?.tabs)) { + return properties; + } + + const tabs = properties.componentConfig.tabs; + let hasChanges = false; + + for (const tab of tabs) { + if (!tab?.screenId) continue; + + const oldScreenId = Number(tab.screenId); + const oldScreenName = tab.screenName; + + // 1. 원본 화면 이름 조회 (screenName이 없는 경우) + let screenNameToFind = oldScreenName; + if (!screenNameToFind) { + const sourceResult = await client.query( + `SELECT screen_name FROM screen_definitions WHERE screen_id = $1 LIMIT 1`, + [oldScreenId] + ); + if (sourceResult.rows.length > 0) { + screenNameToFind = sourceResult.rows[0].screen_name; + } + } + + if (!screenNameToFind) continue; + + // 2. 대상 회사에서 유사한 이름의 화면 찾기 + // 원본 화면 이름에서 회사 접두어를 제거하고 핵심 이름으로 검색 + // 예: "탑씰 품목 카테고리설정" → "카테고리설정"으로 검색 + const nameParts = screenNameToFind.split(' '); + const coreNamePart = nameParts.length > 1 ? nameParts.slice(-1)[0] : screenNameToFind; + + const targetResult = await client.query( + `SELECT screen_id, screen_name + FROM screen_definitions + WHERE company_code = $1 + AND deleted_date IS NULL + AND is_active = 'Y' + AND screen_name LIKE $2 + ORDER BY screen_id DESC + LIMIT 1`, + [targetCompanyCode, `%${coreNamePart}`] + ); + + if (targetResult.rows.length > 0) { + const newScreen = targetResult.rows[0]; + tab.screenId = newScreen.screen_id; + tab.screenName = newScreen.screen_name; + hasChanges = true; + console.log(`🔗 탭 screenId 자동 매핑: ${oldScreenId} (${oldScreenName}) → ${newScreen.screen_id} (${newScreen.screen_name})`); + } + } + + return properties; + } + + /** + * 화면 레이아웃에서 사용하는 flowId 수집 + */ + private collectFlowIdsFromLayouts(layouts: any[]): Set { + const flowIds = new Set(); + + for (const layout of layouts) { + const props = layout.properties; + if (!props) continue; + + // webTypeConfig.dataflowConfig.flowConfig.flowId + const flowId = props?.webTypeConfig?.dataflowConfig?.flowConfig?.flowId; + if (flowId && !isNaN(parseInt(flowId))) { + flowIds.add(parseInt(flowId)); + } + + // webTypeConfig.dataflowConfig.selectedDiagramId + const diagramId = props?.webTypeConfig?.dataflowConfig?.selectedDiagramId; + if (diagramId && !isNaN(parseInt(diagramId))) { + flowIds.add(parseInt(diagramId)); + } + + // webTypeConfig.dataflowConfig.flowControls[].flowId + const flowControls = props?.webTypeConfig?.dataflowConfig?.flowControls; + if (Array.isArray(flowControls)) { + for (const control of flowControls) { + if (control?.flowId && !isNaN(parseInt(control.flowId))) { + flowIds.add(parseInt(control.flowId)); + } + } + } + + // componentConfig.action.excelAfterUploadFlows[].flowId + const excelFlows = props?.componentConfig?.action?.excelAfterUploadFlows; + if (Array.isArray(excelFlows)) { + for (const flow of excelFlows) { + if (flow?.flowId && !isNaN(parseInt(flow.flowId))) { + flowIds.add(parseInt(flow.flowId)); + } + } + } + } + + return flowIds; + } + + /** + * 노드 플로우 복사 및 ID 매핑 반환 + * - 원본 회사의 플로우를 대상 회사로 복사 + * - 이름이 같은 플로우가 있으면 재사용 + */ + private async copyNodeFlowsForScreen( + flowIds: Set, + sourceCompanyCode: string, + targetCompanyCode: string, + client: any + ): Promise> { + const flowIdMap = new Map(); + + if (flowIds.size === 0) { + return flowIdMap; + } + + console.log(`🔄 노드 플로우 복사 시작: ${flowIds.size}개 flowId`); + + // 1. 원본 플로우 조회 (company_code = "*" 전역 플로우는 복사하지 않음) + const flowIdArray = Array.from(flowIds); + const sourceFlowsResult = await client.query( + `SELECT * FROM node_flows + WHERE flow_id = ANY($1) + AND company_code = $2`, + [flowIdArray, sourceCompanyCode] + ); + + if (sourceFlowsResult.rows.length === 0) { + console.log(` 📭 복사할 노드 플로우 없음 (원본 회사 소속 플로우 없음)`); + return flowIdMap; + } + + console.log(` 📋 원본 노드 플로우: ${sourceFlowsResult.rows.length}개`); + + // 2. 대상 회사의 기존 플로우 조회 (이름 기준) + const existingFlowsResult = await client.query( + `SELECT flow_id, flow_name FROM node_flows WHERE company_code = $1`, + [targetCompanyCode] + ); + const existingFlowsByName = new Map( + existingFlowsResult.rows.map((f: any) => [f.flow_name, f.flow_id]) + ); + + // 3. 각 플로우 복사 또는 재사용 + for (const flow of sourceFlowsResult.rows) { + const existingId = existingFlowsByName.get(flow.flow_name); + + if (existingId) { + // 기존 플로우 재사용 + flowIdMap.set(flow.flow_id, existingId); + console.log(` ♻️ 기존 플로우 재사용: ${flow.flow_name} (${flow.flow_id} → ${existingId})`); + } else { + // 새로 복사 + const insertResult = await client.query( + `INSERT INTO node_flows (flow_name, flow_description, flow_data, company_code) + VALUES ($1, $2, $3, $4) + RETURNING flow_id`, + [ + flow.flow_name, + flow.flow_description, + JSON.stringify(flow.flow_data), + targetCompanyCode, + ] + ); + + const newFlowId = insertResult.rows[0].flow_id; + flowIdMap.set(flow.flow_id, newFlowId); + console.log(` ➕ 플로우 복사: ${flow.flow_name} (${flow.flow_id} → ${newFlowId})`); + } + } + + console.log(` ✅ 노드 플로우 복사 완료: 매핑 ${flowIdMap.size}개`); + return flowIdMap; + } + + /** + * properties 내의 flowId, selectedDiagramId 등을 매핑 + */ + private updateFlowIdsInProperties(properties: any, flowIdMap: Map): any { + if (!properties || flowIdMap.size === 0) return properties; + + const updated = JSON.parse(JSON.stringify(properties)); + + // webTypeConfig.dataflowConfig.flowConfig.flowId + if (updated?.webTypeConfig?.dataflowConfig?.flowConfig?.flowId) { + const oldId = parseInt(updated.webTypeConfig.dataflowConfig.flowConfig.flowId); + const newId = flowIdMap.get(oldId); + if (newId) { + updated.webTypeConfig.dataflowConfig.flowConfig.flowId = newId; + console.log(` 🔗 flowConfig.flowId: ${oldId} → ${newId}`); + } + } + + // webTypeConfig.dataflowConfig.selectedDiagramId + if (updated?.webTypeConfig?.dataflowConfig?.selectedDiagramId) { + const oldId = parseInt(updated.webTypeConfig.dataflowConfig.selectedDiagramId); + const newId = flowIdMap.get(oldId); + if (newId) { + updated.webTypeConfig.dataflowConfig.selectedDiagramId = newId; + console.log(` 🔗 selectedDiagramId: ${oldId} → ${newId}`); + } + } + + // webTypeConfig.dataflowConfig.flowControls[].flowId + if (Array.isArray(updated?.webTypeConfig?.dataflowConfig?.flowControls)) { + for (const control of updated.webTypeConfig.dataflowConfig.flowControls) { + if (control?.flowId) { + const oldId = parseInt(control.flowId); + const newId = flowIdMap.get(oldId); + if (newId) { + control.flowId = newId; + console.log(` 🔗 flowControls.flowId: ${oldId} → ${newId}`); + } + } + } + } + + // componentConfig.action.excelAfterUploadFlows[].flowId + if (Array.isArray(updated?.componentConfig?.action?.excelAfterUploadFlows)) { + for (const flow of updated.componentConfig.action.excelAfterUploadFlows) { + if (flow?.flowId) { + const oldId = parseInt(flow.flowId); + const newId = flowIdMap.get(oldId); + if (newId) { + flow.flowId = String(newId); + console.log(` 🔗 excelAfterUploadFlows.flowId: ${oldId} → ${newId}`); + } + } + } + } + + return updated; + } + /** * 화면 복사 (화면 정보 + 레이아웃 모두 복사) (✅ Raw Query 전환 완료) */ @@ -2610,6 +3334,9 @@ export class ScreenManagementService { } // 4. 새 화면 생성 (대상 회사에 생성) + // 삭제된 화면(is_active = 'D')을 복사할 경우 활성 상태('Y')로 변경 + const newIsActive = sourceScreen.is_active === 'D' ? 'Y' : sourceScreen.is_active; + const newScreenResult = await client.query( `INSERT INTO screen_definitions ( screen_code, screen_name, description, company_code, table_name, @@ -2622,7 +3349,7 @@ export class ScreenManagementService { copyData.description || sourceScreen.description, targetCompanyCode, // 대상 회사 코드 사용 sourceScreen.table_name, - sourceScreen.is_active, + newIsActive, // 삭제된 화면은 활성 상태로 복사 copyData.createdBy, new Date(), copyData.createdBy, @@ -2642,7 +3369,45 @@ export class ScreenManagementService { const sourceLayouts = sourceLayoutsResult.rows; - // 5. 레이아웃이 있다면 복사 + // 5. 노드 플로우 복사 (회사가 다른 경우) + let flowIdMap = new Map(); + if (sourceLayouts.length > 0 && sourceScreen.company_code !== targetCompanyCode) { + // 레이아웃에서 사용하는 flowId 수집 + const flowIds = this.collectFlowIdsFromLayouts(sourceLayouts); + + if (flowIds.size > 0) { + console.log(`🔍 화면 복사 - flowId 수집: ${flowIds.size}개`); + + // 노드 플로우 복사 및 매핑 생성 + flowIdMap = await this.copyNodeFlowsForScreen( + flowIds, + sourceScreen.company_code, + targetCompanyCode, + client + ); + } + } + + // 5.1. 채번 규칙 복사 (회사가 다른 경우) + let ruleIdMap = new Map(); + if (sourceLayouts.length > 0 && sourceScreen.company_code !== targetCompanyCode) { + // 레이아웃에서 사용하는 채번 규칙 ID 수집 + const ruleIds = this.collectNumberingRuleIdsFromLayouts(sourceLayouts); + + if (ruleIds.size > 0) { + console.log(`🔍 화면 복사 - 채번 규칙 ID 수집: ${ruleIds.size}개`); + + // 채번 규칙 복사 및 매핑 생성 + ruleIdMap = await this.copyNumberingRulesForScreen( + ruleIds, + sourceScreen.company_code, + targetCompanyCode, + client + ); + } + } + + // 6. 레이아웃이 있다면 복사 if (sourceLayouts.length > 0) { try { // ID 매핑 맵 생성 @@ -2660,6 +3425,29 @@ export class ScreenManagementService { ? idMapping[sourceLayout.parent_id] : null; + // properties 파싱 + let properties = sourceLayout.properties; + if (typeof properties === "string") { + try { + properties = JSON.parse(properties); + } catch (e) { + // 파싱 실패 시 그대로 사용 + } + } + + // flowId 매핑 적용 (회사가 다른 경우) + if (flowIdMap.size > 0) { + properties = this.updateFlowIdsInProperties(properties, flowIdMap); + } + + // 채번 규칙 ID 매핑 적용 (회사가 다른 경우) + if (ruleIdMap.size > 0) { + properties = this.updateNumberingRuleIdsInProperties(properties, ruleIdMap); + } + + // 탭 컴포넌트의 screenId는 개별 복제 시점에 업데이트하지 않음 + // 모든 화면 복제 완료 후 updateTabScreenReferences에서 screenIdMap 기반으로 일괄 업데이트 + await client.query( `INSERT INTO screen_layouts ( screen_id, component_type, component_id, parent_id, @@ -2675,9 +3463,7 @@ export class ScreenManagementService { Math.round(sourceLayout.position_y), // 정수로 반올림 Math.round(sourceLayout.width), // 정수로 반올림 Math.round(sourceLayout.height), // 정수로 반올림 - typeof sourceLayout.properties === "string" - ? sourceLayout.properties - : JSON.stringify(sourceLayout.properties), + JSON.stringify(properties), sourceLayout.display_order, new Date(), ] @@ -2896,6 +3682,372 @@ export class ScreenManagementService { console.log(`✅ 총 ${updateCount}개 레이아웃의 연결된 화면 ID 업데이트 완료 (버튼 + 조건부컨테이너)`); return updateCount; } + + /** + * 화면-메뉴 할당 복제 (screen_menu_assignments) + * + * @param sourceCompanyCode 원본 회사 코드 + * @param targetCompanyCode 대상 회사 코드 + * @param screenIdMap 원본 화면 ID -> 새 화면 ID 매핑 + * @returns 복제 결과 + */ + async copyScreenMenuAssignments( + sourceCompanyCode: string, + targetCompanyCode: string, + screenIdMap: Record + ): Promise<{ copiedCount: number; skippedCount: number; details: string[] }> { + const result = { + copiedCount: 0, + skippedCount: 0, + details: [] as string[], + }; + + return await transaction(async (client) => { + logger.info("🔗 화면-메뉴 할당 복제 시작", { sourceCompanyCode, targetCompanyCode }); + + // 1. 원본 회사의 screen_groups (menu_objid 포함) 조회 + const sourceGroupsResult = await client.query<{ + id: number; + group_name: string; + menu_objid: string | null; + }>( + `SELECT id, group_name, menu_objid + FROM screen_groups + WHERE company_code = $1 AND menu_objid IS NOT NULL`, + [sourceCompanyCode] + ); + + // 2. 대상 회사의 screen_groups (menu_objid 포함) 조회 + const targetGroupsResult = await client.query<{ + id: number; + group_name: string; + menu_objid: string | null; + }>( + `SELECT id, group_name, menu_objid + FROM screen_groups + WHERE company_code = $1 AND menu_objid IS NOT NULL`, + [targetCompanyCode] + ); + + // 3. 그룹 이름 기반으로 menu_objid 매핑 생성 + const menuObjidMap = new Map(); // 원본 menu_objid -> 새 menu_objid + for (const sourceGroup of sourceGroupsResult.rows) { + if (!sourceGroup.menu_objid) continue; + + const matchingTarget = targetGroupsResult.rows.find( + (t) => t.group_name === sourceGroup.group_name + ); + + if (matchingTarget?.menu_objid) { + menuObjidMap.set(sourceGroup.menu_objid, matchingTarget.menu_objid); + logger.debug(`메뉴 매핑: ${sourceGroup.group_name} | ${sourceGroup.menu_objid} → ${matchingTarget.menu_objid}`); + } + } + + logger.info(`📋 메뉴 매핑 생성 완료: ${menuObjidMap.size}개`); + + // 4. 원본 screen_menu_assignments 조회 + const assignmentsResult = await client.query<{ + screen_id: number; + menu_objid: string; + display_order: number; + is_active: string; + }>( + `SELECT screen_id, menu_objid::text, display_order, is_active + FROM screen_menu_assignments + WHERE company_code = $1`, + [sourceCompanyCode] + ); + + logger.info(`📌 원본 할당: ${assignmentsResult.rowCount}개`); + + // 5. 새 할당 생성 + for (const assignment of assignmentsResult.rows) { + const newScreenId = screenIdMap[assignment.screen_id]; + const newMenuObjid = menuObjidMap.get(assignment.menu_objid); + + if (!newScreenId) { + logger.warn(`⚠️ 화면 ID 매핑 없음: ${assignment.screen_id}`); + result.skippedCount++; + result.details.push(`화면 ${assignment.screen_id}: 매핑 없음`); + continue; + } + + if (!newMenuObjid) { + logger.warn(`⚠️ 메뉴 objid 매핑 없음: ${assignment.menu_objid}`); + result.skippedCount++; + result.details.push(`메뉴 ${assignment.menu_objid}: 매핑 없음`); + continue; + } + + try { + await client.query( + `INSERT INTO screen_menu_assignments + (screen_id, menu_objid, company_code, display_order, is_active, created_by) + VALUES ($1, $2, $3, $4, $5, 'system') + ON CONFLICT (screen_id, menu_objid, company_code) DO NOTHING`, + [ + newScreenId, + newMenuObjid, + targetCompanyCode, + assignment.display_order, + assignment.is_active, + ] + ); + result.copiedCount++; + logger.debug(`✅ 할당 복제: screen ${newScreenId} → menu ${newMenuObjid}`); + } catch (error: any) { + logger.error(`❌ 할당 복제 실패: ${error.message}`); + result.skippedCount++; + result.details.push(`할당 실패: ${error.message}`); + } + } + + logger.info(`✅ 화면-메뉴 할당 복제 완료: ${result.copiedCount}개 복제, ${result.skippedCount}개 스킵`); + return result; + }); + } + + /** + * 코드 카테고리 + 코드 복제 + */ + async copyCodeCategoryAndCodes( + sourceCompanyCode: string, + targetCompanyCode: string, + menuObjidMap?: Map + ): Promise<{ copiedCategories: number; copiedCodes: number; details: string[] }> { + const result = { + copiedCategories: 0, + copiedCodes: 0, + details: [] as string[], + }; + + return transaction(async (client) => { + logger.info(`📦 코드 카테고리/코드 복제: ${sourceCompanyCode} → ${targetCompanyCode}`); + + // 1. 기존 대상 회사 데이터 삭제 + await client.query(`DELETE FROM code_info WHERE company_code = $1`, [targetCompanyCode]); + await client.query(`DELETE FROM code_category WHERE company_code = $1`, [targetCompanyCode]); + + // 2. menuObjidMap 생성 (없는 경우) + if (!menuObjidMap || menuObjidMap.size === 0) { + menuObjidMap = new Map(); + const groupPairs = await client.query<{ source_objid: string; target_objid: string }>( + `SELECT DISTINCT + sg1.menu_objid::text as source_objid, + sg2.menu_objid::text as target_objid + FROM screen_groups sg1 + JOIN screen_groups sg2 ON sg1.group_name = sg2.group_name + WHERE sg1.company_code = $1 AND sg2.company_code = $2 + AND sg1.menu_objid IS NOT NULL AND sg2.menu_objid IS NOT NULL`, + [sourceCompanyCode, targetCompanyCode] + ); + groupPairs.rows.forEach(p => menuObjidMap!.set(p.source_objid, p.target_objid)); + } + + // 3. 코드 카테고리 복제 + const categories = await client.query( + `SELECT * FROM code_category WHERE company_code = $1`, + [sourceCompanyCode] + ); + + for (const cat of categories.rows) { + const newMenuObjid = cat.menu_objid ? menuObjidMap.get(cat.menu_objid.toString()) || cat.menu_objid : null; + + await client.query( + `INSERT INTO code_category + (category_code, category_name, category_name_eng, description, sort_order, is_active, company_code, menu_objid, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'system')`, + [cat.category_code, cat.category_name, cat.category_name_eng, cat.description, cat.sort_order, cat.is_active, targetCompanyCode, newMenuObjid] + ); + result.copiedCategories++; + } + + // 4. 코드 정보 복제 + const codes = await client.query( + `SELECT * FROM code_info WHERE company_code = $1`, + [sourceCompanyCode] + ); + + for (const code of codes.rows) { + const newMenuObjid = code.menu_objid ? menuObjidMap.get(code.menu_objid.toString()) || code.menu_objid : null; + + await client.query( + `INSERT INTO code_info + (code_category, code_value, code_name, code_name_eng, description, sort_order, is_active, company_code, menu_objid, parent_code_value, depth, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 'system')`, + [code.code_category, code.code_value, code.code_name, code.code_name_eng, code.description, code.sort_order, code.is_active, targetCompanyCode, newMenuObjid, code.parent_code_value, code.depth] + ); + result.copiedCodes++; + } + + logger.info(`✅ 코드 카테고리/코드 복제 완료: 카테고리 ${result.copiedCategories}개, 코드 ${result.copiedCodes}개`); + return result; + }); + } + + /** + * 카테고리 매핑 + 값 복제 + */ + async copyCategoryMapping( + sourceCompanyCode: string, + targetCompanyCode: string, + menuObjidMap?: Map + ): Promise<{ copiedMappings: number; copiedValues: number; details: string[] }> { + const result = { + copiedMappings: 0, + copiedValues: 0, + details: [] as string[], + }; + + return transaction(async (client) => { + logger.info(`📦 카테고리 매핑/값 복제: ${sourceCompanyCode} → ${targetCompanyCode}`); + + // 1. 기존 대상 회사 데이터 삭제 + await client.query(`DELETE FROM table_column_category_values WHERE company_code = $1`, [targetCompanyCode]); + await client.query(`DELETE FROM category_column_mapping WHERE company_code = $1`, [targetCompanyCode]); + + // 2. menuObjidMap 생성 (없는 경우) + if (!menuObjidMap || menuObjidMap.size === 0) { + menuObjidMap = new Map(); + const groupPairs = await client.query<{ source_objid: string; target_objid: string }>( + `SELECT DISTINCT + sg1.menu_objid::text as source_objid, + sg2.menu_objid::text as target_objid + FROM screen_groups sg1 + JOIN screen_groups sg2 ON sg1.group_name = sg2.group_name + WHERE sg1.company_code = $1 AND sg2.company_code = $2 + AND sg1.menu_objid IS NOT NULL AND sg2.menu_objid IS NOT NULL`, + [sourceCompanyCode, targetCompanyCode] + ); + groupPairs.rows.forEach(p => menuObjidMap!.set(p.source_objid, p.target_objid)); + } + + // 3. category_column_mapping 복제 + const mappings = await client.query( + `SELECT * FROM category_column_mapping WHERE company_code = $1`, + [sourceCompanyCode] + ); + + for (const m of mappings.rows) { + const newMenuObjid = m.menu_objid ? menuObjidMap.get(m.menu_objid.toString()) || m.menu_objid : null; + + await client.query( + `INSERT INTO category_column_mapping + (table_name, logical_column_name, physical_column_name, menu_objid, company_code, description, created_by) + VALUES ($1, $2, $3, $4, $5, $6, 'system')`, + [m.table_name, m.logical_column_name, m.physical_column_name, newMenuObjid, targetCompanyCode, m.description] + ); + result.copiedMappings++; + } + + // 4. table_column_category_values 복제 + const values = await client.query( + `SELECT * FROM table_column_category_values WHERE company_code = $1`, + [sourceCompanyCode] + ); + + for (const v of values.rows) { + const newMenuObjid = v.menu_objid ? menuObjidMap.get(v.menu_objid.toString()) || v.menu_objid : null; + + await client.query( + `INSERT INTO table_column_category_values + (table_name, column_name, value_code, value_label, value_order, parent_value_id, depth, description, color, icon, is_active, is_default, company_code, menu_objid, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, 'system')`, + [v.table_name, v.column_name, v.value_code, v.value_label, v.value_order, v.parent_value_id, v.depth, v.description, v.color, v.icon, v.is_active, v.is_default, targetCompanyCode, newMenuObjid] + ); + result.copiedValues++; + } + + logger.info(`✅ 카테고리 매핑/값 복제 완료: 매핑 ${result.copiedMappings}개, 값 ${result.copiedValues}개`); + return result; + }); + } + + /** + * 테이블 타입관리 입력타입 설정 복제 + */ + async copyTableTypeColumns( + sourceCompanyCode: string, + targetCompanyCode: string + ): Promise<{ copiedCount: number; details: string[] }> { + const result = { + copiedCount: 0, + details: [] as string[], + }; + + return transaction(async (client) => { + logger.info(`📦 테이블 타입 컬럼 복제: ${sourceCompanyCode} → ${targetCompanyCode}`); + + // 1. 기존 대상 회사 데이터 삭제 + await client.query(`DELETE FROM table_type_columns WHERE company_code = $1`, [targetCompanyCode]); + + // 2. 복제 + const columns = await client.query( + `SELECT * FROM table_type_columns WHERE company_code = $1`, + [sourceCompanyCode] + ); + + for (const col of columns.rows) { + await client.query( + `INSERT INTO table_type_columns + (table_name, column_name, input_type, detail_settings, is_nullable, display_order, company_code) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + [col.table_name, col.column_name, col.input_type, col.detail_settings, col.is_nullable, col.display_order, targetCompanyCode] + ); + result.copiedCount++; + } + + logger.info(`✅ 테이블 타입 컬럼 복제 완료: ${result.copiedCount}개`); + return result; + }); + } + + /** + * 연쇄관계 설정 복제 + */ + async copyCascadingRelation( + sourceCompanyCode: string, + targetCompanyCode: string + ): Promise<{ copiedCount: number; details: string[] }> { + const result = { + copiedCount: 0, + details: [] as string[], + }; + + return transaction(async (client) => { + logger.info(`📦 연쇄관계 설정 복제: ${sourceCompanyCode} → ${targetCompanyCode}`); + + // 1. 기존 대상 회사 데이터 삭제 + await client.query(`DELETE FROM cascading_relation WHERE company_code = $1`, [targetCompanyCode]); + + // 2. 복제 + const relations = await client.query( + `SELECT * FROM cascading_relation WHERE company_code = $1`, + [sourceCompanyCode] + ); + + for (const rel of relations.rows) { + // 새로운 relation_code 생성 + const newRelationCode = `${rel.relation_code}_${targetCompanyCode}`; + + await client.query( + `INSERT INTO cascading_relation + (relation_code, relation_name, description, parent_table, parent_value_column, parent_label_column, + child_table, child_filter_column, child_value_column, child_label_column, child_order_column, child_order_direction, + empty_parent_message, no_options_message, loading_message, clear_on_parent_change, company_code, is_active, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, 'system')`, + [newRelationCode, rel.relation_name, rel.description, rel.parent_table, rel.parent_value_column, rel.parent_label_column, + rel.child_table, rel.child_filter_column, rel.child_value_column, rel.child_label_column, rel.child_order_column, rel.child_order_direction, + rel.empty_parent_message, rel.no_options_message, rel.loading_message, rel.clear_on_parent_change, targetCompanyCode, rel.is_active] + ); + result.copiedCount++; + } + + logger.info(`✅ 연쇄관계 설정 복제 완료: ${result.copiedCount}개`); + return result; + }); + } } // 서비스 인스턴스 export diff --git a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx index 4e2878eb..d3a11572 100644 --- a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx +++ b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx @@ -111,7 +111,12 @@ export default function ScreenManagementPage() { }; // 검색어로 필터링된 화면 - const filteredScreens = screens.filter((screen) => + // 검색어가 여러 키워드(폴더 계층 검색)이면 화면 필터링 없이 모든 화면 표시 + // 단일 키워드면 해당 키워드로 화면 필터링 + const searchKeywords = searchTerm.toLowerCase().trim().split(/\s+/).filter(Boolean); + const filteredScreens = searchKeywords.length > 1 + ? screens // 폴더 계층 검색 시에는 화면 필터링 없음 (폴더에서 이미 필터링됨) + : screens.filter((screen) => screen.screenName.toLowerCase().includes(searchTerm.toLowerCase()) || screen.screenCode.toLowerCase().includes(searchTerm.toLowerCase()) ); @@ -183,6 +188,7 @@ export default function ScreenManagementPage() { selectedScreen={selectedScreen} onScreenSelect={handleScreenSelect} onScreenDesign={handleDesignScreen} + searchTerm={searchTerm} onGroupSelect={(group) => { setSelectedGroup(group); setSelectedScreen(null); // 화면 선택 해제 diff --git a/frontend/components/numbering-rule/NumberingRuleDesigner.tsx b/frontend/components/numbering-rule/NumberingRuleDesigner.tsx index 3a20b883..1192922d 100644 --- a/frontend/components/numbering-rule/NumberingRuleDesigner.tsx +++ b/frontend/components/numbering-rule/NumberingRuleDesigner.tsx @@ -217,13 +217,16 @@ export const NumberingRuleDesigner: React.FC = ({ }); // 저장 전에 현재 화면의 테이블명과 menuObjid 자동 설정 - // 메뉴 기반으로 채번규칙 관리 (menuObjid로 필터링) + // menuObjid가 있으면 menu 스코프, 없으면 기존 scopeType 유지 + const effectiveMenuObjid = menuObjid || currentRule.menuObjid || null; + const effectiveScopeType = effectiveMenuObjid ? "menu" : (currentRule.scopeType || "global"); + const ruleToSave = { ...currentRule, parts: partsWithDefaults, - scopeType: "menu" as const, // 메뉴 기반 채번규칙 + scopeType: effectiveScopeType as "menu" | "global", // menuObjid 유무에 따라 결정 tableName: currentTableName || currentRule.tableName || null, // 현재 테이블명 (참고용) - menuObjid: menuObjid || currentRule.menuObjid || null, // 메뉴 OBJID (필터링 기준) + menuObjid: effectiveMenuObjid, // 메뉴 OBJID (필터링 기준) }; console.log("💾 채번 규칙 저장:", { diff --git a/frontend/components/screen/CopyScreenModal.tsx b/frontend/components/screen/CopyScreenModal.tsx index 5590cef4..6f429362 100644 --- a/frontend/components/screen/CopyScreenModal.tsx +++ b/frontend/components/screen/CopyScreenModal.tsx @@ -33,9 +33,10 @@ import { CommandItem, CommandList, } from "@/components/ui/command"; -import { Loader2, Copy, Link as LinkIcon, Trash2, AlertCircle, AlertTriangle, Check, ChevronsUpDown, FolderTree } from "lucide-react"; +import { Loader2, Copy, Link as LinkIcon, Trash2, AlertCircle, AlertTriangle, Check, ChevronsUpDown, FolderTree, Hash, Code, Table, Settings, Database } from "lucide-react"; +import { Checkbox } from "@/components/ui/checkbox"; import { ScreenDefinition } from "@/types/screen"; -import { screenApi } from "@/lib/api/screen"; +import { screenApi, updateTabScreenReferences } from "@/lib/api/screen"; import { ScreenGroup, addScreenToGroup, createScreenGroup } from "@/lib/api/screenGroup"; import { apiClient } from "@/lib/api/client"; import { toast } from "sonner"; @@ -135,6 +136,15 @@ export default function CopyScreenModal({ // 그룹 복제 모드: "all" (전체), "folder_only" (폴더만), "screen_only" (화면만) const [groupCopyMode, setGroupCopyMode] = useState<"all" | "folder_only" | "screen_only">("all"); + // 채번규칙 복제 옵션 (체크 시: 복제 → 메뉴 동기화 → 채번규칙 복제 순서로 실행) + const [copyNumberingRules, setCopyNumberingRules] = useState(false); + + // 추가 복사 옵션들 + const [copyCodeCategory, setCopyCodeCategory] = useState(false); // 코드 카테고리 + 코드 복사 + const [copyCategoryMapping, setCopyCategoryMapping] = useState(false); // 카테고리 매핑 + 값 복사 + const [copyTableTypeColumns, setCopyTableTypeColumns] = useState(false); // 테이블 타입관리 입력타입 설정 복사 + const [copyCascadingRelation, setCopyCascadingRelation] = useState(false); // 연쇄관계 설정 복사 + // 복사 중 상태 const [isCopying, setIsCopying] = useState(false); const [copyProgress, setCopyProgress] = useState({ current: 0, total: 0, message: "" }); @@ -584,6 +594,7 @@ export default function CopyScreenModal({ screen_id: result.mainScreen.screenId, screen_role: "MAIN", display_order: 1, + target_company_code: finalCompanyCode, // 대상 회사 코드 전달 }); console.log(`✅ 복제된 화면을 그룹(${selectedTargetGroupId})에 추가 완료`); } catch (groupError) { @@ -609,7 +620,7 @@ export default function CopyScreenModal({ }; // 이름 변환 헬퍼 함수 (일괄 이름 변경 적용) - const transformName = (originalName: string, isRootGroup: boolean = false): string => { + const transformName = (originalName: string, isRootGroup: boolean = false, sourceCompanyCode?: string): string => { // 루트 그룹은 사용자가 직접 입력한 이름 사용 if (isRootGroup) { return newGroupName.trim(); @@ -621,7 +632,12 @@ export default function CopyScreenModal({ return originalName.replace(new RegExp(groupFindText, "g"), groupReplaceText); } - // 기본: "(복제)" 붙이기 + // 다른 회사로 복제하는 경우: 원본 이름 그대로 사용 (중복될 일 없음) + if (sourceCompanyCode && sourceCompanyCode !== targetCompanyCode) { + return originalName; + } + + // 같은 회사 내 복제: "(복제)" 붙이기 (중복 방지) return `${originalName} (복제)`; }; @@ -633,17 +649,19 @@ export default function CopyScreenModal({ screenCodes: string[], // 미리 생성된 화면 코드 배열 codeIndex: { current: number }, // 현재 사용할 코드 인덱스 (참조로 전달) stats: { groups: number; screens: number }, - totalScreenCount: number // 전체 화면 수 (진행률 표시용) + totalScreenCount: number, // 전체 화면 수 (진행률 표시용) + screenIdMap: { [key: number]: number } // 원본 화면 ID -> 새 화면 ID 매핑 ): Promise => { // 1. 현재 그룹 생성 (원본 display_order 유지) const timestamp = Date.now(); const randomSuffix = Math.floor(Math.random() * 1000); const newGroupCode = `${targetCompany}_GROUP_${timestamp}_${randomSuffix}`; - console.log(`📁 그룹 생성: ${sourceGroupData.group_name} (복제)`); + const transformedGroupName = transformName(sourceGroupData.group_name, false, sourceGroupData.company_code); + console.log(`📁 그룹 생성: ${transformedGroupName}`); const newGroupResponse = await createScreenGroup({ - group_name: transformName(sourceGroupData.group_name), // 일괄 이름 변경 적용 + group_name: transformedGroupName, // 일괄 이름 변경 적용 group_code: newGroupCode, parent_group_id: parentGroupId, target_company_code: targetCompany, @@ -663,13 +681,29 @@ export default function CopyScreenModal({ const sourceScreensInfo = sourceGroupData.screens || []; // 화면 정보와 display_order를 함께 매핑 + // allScreens에서 못 찾으면 그룹의 screens 정보를 직접 사용 (다른 회사 폴더 복사 시) const screensWithOrder = sourceScreensInfo.map((s: any) => { const screenId = typeof s === 'object' ? s.screen_id : s; const displayOrder = typeof s === 'object' ? s.display_order : 0; const screenRole = typeof s === 'object' ? s.screen_role : 'MAIN'; - const screenData = allScreens.find((sc) => sc.screenId === screenId); + const screenName = typeof s === 'object' ? s.screen_name : ''; + const tableName = typeof s === 'object' ? s.table_name : ''; + + // allScreens에서 먼저 찾고, 없으면 그룹의 screens 정보로 대체 + let screenData = allScreens.find((sc) => sc.screenId === screenId); + if (!screenData && screenId && screenName) { + // allScreens에 없는 경우 (다른 회사 화면) - 그룹의 screens 정보로 최소한의 데이터 구성 + screenData = { + screenId: screenId, + screenName: screenName, + screenCode: `SCREEN_${screenId}`, // 임시 코드 (실제 복사 시 새 코드 생성) + tableName: tableName || '', + description: '', + companyCode: sourceGroupData.company_code || '', + } as any; + } return { screenId, displayOrder, screenRole, screenData }; - }).filter(item => item.screenData); // 화면 데이터가 있는 것만 + }).filter(item => item.screenData && item.screenId); // screenId가 유효한 것만 // display_order 순으로 정렬 screensWithOrder.sort((a, b) => (a.displayOrder || 0) - (b.displayOrder || 0)); @@ -687,12 +721,13 @@ export default function CopyScreenModal({ message: `화면 복제 중: ${screen.screenName}` }); - console.log(` 📄 화면 복제: ${screen.screenName} → ${newScreenCode}`); + const transformedScreenName = transformName(screen.screenName, false, sourceGroupData.company_code); + console.log(` 📄 화면 복제: ${screen.screenName} → ${transformedScreenName}`); const result = await screenApi.copyScreenWithModals(screen.screenId, { targetCompanyCode: targetCompany, mainScreen: { - screenName: transformName(screen.screenName), // 일괄 이름 변경 적용 + screenName: transformedScreenName, // 일괄 이름 변경 적용 screenCode: newScreenCode, description: screen.description || "", }, @@ -700,14 +735,18 @@ export default function CopyScreenModal({ }); if (result.mainScreen?.screenId) { + // 원본 화면 ID -> 새 화면 ID 매핑 기록 + screenIdMap[screen.screenId] = result.mainScreen.screenId; + await addScreenToGroup({ group_id: newGroup.id, screen_id: result.mainScreen.screenId, screen_role: screenRole || "MAIN", display_order: displayOrder, // 원본 정렬순서 유지 + target_company_code: targetCompany, // 대상 회사 코드 전달 }); stats.screens++; - console.log(` ✅ 화면 복제 완료: ${result.mainScreen.screenName}`); + console.log(` ✅ 화면 복제 완료: ${result.mainScreen.screenName} (${screen.screenId} → ${result.mainScreen.screenId})`); } } catch (screenError) { console.error(` ❌ 화면 복제 실패 (${screen.screenCode}):`, screenError); @@ -730,7 +769,8 @@ export default function CopyScreenModal({ screenCodes, codeIndex, stats, - totalScreenCount + totalScreenCount, + screenIdMap // screenIdMap 전달 ); } } @@ -769,6 +809,7 @@ export default function CopyScreenModal({ const finalCompanyCode = targetCompanyCode || sourceGroup.company_code; const stats = { groups: 0, screens: 0 }; + const screenIdMap: { [key: number]: number } = {}; // 원본 화면 ID -> 새 화면 ID 매핑 console.log("🔄 그룹 복제 시작 (재귀적):", { sourceGroup: sourceGroup.group_name, @@ -795,7 +836,7 @@ export default function CopyScreenModal({ // 일괄 이름 변경이 활성화된 경우 원본 이름에 변환 적용 const rootGroupName = useGroupBulkRename && groupFindText - ? transformName(sourceGroup.group_name) + ? transformName(sourceGroup.group_name, false, sourceGroup.company_code) : newGroupName.trim(); const newGroupResponse = await createScreenGroup({ @@ -818,14 +859,41 @@ export default function CopyScreenModal({ if (groupCopyMode !== "folder_only") { const sourceScreensInfo = sourceGroup.screens || []; - // 화면 정보와 display_order를 함께 매핑 - const screensWithOrder = sourceScreensInfo.map((s: any) => { - const screenId = typeof s === 'object' ? s.screen_id : s; - const displayOrder = typeof s === 'object' ? s.display_order : 0; - const screenRole = typeof s === 'object' ? s.screen_role : 'MAIN'; - const screenData = allScreens.find((sc) => sc.screenId === screenId); - return { screenId, displayOrder, screenRole, screenData }; - }).filter(item => item.screenData); + // 화면 정보와 display_order를 함께 매핑 + // allScreens에서 못 찾으면 그룹의 screens 정보를 직접 사용 (다른 회사 폴더 복사 시) + console.log(`🔍 루트 그룹 화면 매핑 시작: ${sourceScreensInfo.length}개 화면, allScreens: ${allScreens.length}개`); + const screensWithOrder = sourceScreensInfo.map((s: any) => { + const screenId = typeof s === 'object' ? s.screen_id : s; + const displayOrder = typeof s === 'object' ? s.display_order : 0; + const screenRole = typeof s === 'object' ? s.screen_role : 'MAIN'; + const screenName = typeof s === 'object' ? s.screen_name : ''; + const tableName = typeof s === 'object' ? s.table_name : ''; + + // allScreens에서 먼저 찾고, 없으면 그룹의 screens 정보로 대체 + let screenData = allScreens.find((sc) => sc.screenId === screenId); + const foundInAllScreens = !!screenData; + + if (!screenData && screenId && screenName) { + // allScreens에 없는 경우 (다른 회사 화면) - 그룹의 screens 정보로 최소한의 데이터 구성 + console.log(` ⚠️ allScreens에서 못 찾음, 그룹 정보 사용: ${screenId} - ${screenName}`); + screenData = { + screenId: screenId, + screenName: screenName, + screenCode: `SCREEN_${screenId}`, // 임시 코드 (실제 복사 시 새 코드 생성) + tableName: tableName || '', + description: '', + companyCode: sourceGroup.company_code || '', + } as any; + } else if (screenData) { + console.log(` ✅ allScreens에서 찾음: ${screenId} - ${screenData.screenName}`); + } else { + console.log(` ❌ 화면 정보 없음: screenId=${screenId}, screenName=${screenName}`); + } + return { screenId, displayOrder, screenRole, screenData }; + }).filter(item => item.screenData && item.screenId); // screenId가 유효한 것만 + + console.log(`🔍 매핑 완료: ${screensWithOrder.length}개 화면 복사 예정`); + screensWithOrder.forEach(item => console.log(` - ${item.screenId}: ${item.screenData?.screenName}`)); // display_order 순으로 정렬 screensWithOrder.sort((a, b) => (a.displayOrder || 0) - (b.displayOrder || 0)); @@ -843,12 +911,13 @@ export default function CopyScreenModal({ message: `화면 복제 중: ${screen.screenName}` }); - console.log(`📄 화면 복제: ${screen.screenName} → ${newScreenCode}`); + const transformedScreenName = transformName(screen.screenName, false, sourceGroup.company_code); + console.log(`📄 화면 복제: ${screen.screenName} → ${transformedScreenName}`); const result = await screenApi.copyScreenWithModals(screen.screenId, { targetCompanyCode: finalCompanyCode, mainScreen: { - screenName: transformName(screen.screenName), // 일괄 이름 변경 적용 + screenName: transformedScreenName, // 일괄 이름 변경 적용 screenCode: newScreenCode, description: screen.description || "", }, @@ -856,14 +925,18 @@ export default function CopyScreenModal({ }); if (result.mainScreen?.screenId) { + // 원본 화면 ID -> 새 화면 ID 매핑 기록 + screenIdMap[screen.screenId] = result.mainScreen.screenId; + await addScreenToGroup({ group_id: newRootGroup.id, screen_id: result.mainScreen.screenId, screen_role: screenRole || "MAIN", display_order: displayOrder, // 원본 정렬순서 유지 + target_company_code: finalCompanyCode, // 대상 회사 코드 전달 }); stats.screens++; - console.log(`✅ 화면 복제 완료: ${result.mainScreen.screenName}`); + console.log(`✅ 화면 복제 완료: ${result.mainScreen.screenName} (${screen.screenId} → ${result.mainScreen.screenId})`); } } catch (screenError) { console.error(`화면 복제 실패 (${screen.screenCode}):`, screenError); @@ -886,11 +959,180 @@ export default function CopyScreenModal({ screenCodes, codeIndex, stats, - totalScreenCount + totalScreenCount, + screenIdMap // screenIdMap 전달 ); } } + // 6. 탭 컴포넌트의 screenId 참조 일괄 업데이트 + console.log("🔍 screenIdMap 상태:", screenIdMap, "키 개수:", Object.keys(screenIdMap).length); + if (Object.keys(screenIdMap).length > 0) { + console.log("🔗 탭 screenId 참조 업데이트 중...", screenIdMap); + setCopyProgress({ current: stats.screens, total: totalScreenCount, message: "탭 참조 업데이트 중..." }); + + const targetScreenIds = Object.values(screenIdMap); + try { + const updateResult = await updateTabScreenReferences(targetScreenIds, screenIdMap); + console.log(`✅ 탭 screenId 참조 업데이트 완료: ${updateResult.updated}개 레이아웃`); + } catch (tabUpdateError) { + console.warn("탭 screenId 참조 업데이트 실패 (무시):", tabUpdateError); + } + } + + // 7. 채번규칙 복제 옵션이 선택된 경우 (복제 → 메뉴 동기화 → 채번규칙 복제) + if (copyNumberingRules) { + try { + setCopyProgress({ current: stats.screens, total: totalScreenCount, message: "메뉴 동기화 중..." }); + console.log("📋 메뉴 동기화 시작 (채번규칙 복제 준비)..."); + + // 7-1. 메뉴 동기화 (화면 그룹 → 메뉴) + const syncResponse = await apiClient.post("/screen-groups/sync/screen-to-menu", { + targetCompanyCode: finalCompanyCode, + }); + + if (syncResponse.data?.success) { + console.log("✅ 메뉴 동기화 완료:", syncResponse.data.data); + + // 7-2. 채번규칙 복제 + setCopyProgress({ current: stats.screens, total: totalScreenCount, message: "채번규칙 복제 중..." }); + console.log("📋 채번규칙 복제 시작..."); + + const numberingResponse = await apiClient.post("/numbering-rules/copy-for-company", { + sourceCompanyCode: sourceGroup.company_code, + targetCompanyCode: finalCompanyCode, + }); + + if (numberingResponse.data?.success) { + console.log("✅ 채번규칙 복제 완료:", numberingResponse.data.data); + toast.success(`채번규칙 ${numberingResponse.data.data?.copiedCount || 0}개가 복제되었습니다.`); + } else { + console.warn("채번규칙 복제 실패:", numberingResponse.data?.error); + toast.warning("채번규칙 복제에 실패했습니다. 수동으로 복제해주세요."); + } + + // 7-3. 화면-메뉴 할당 복제 (screen_menu_assignments) + setCopyProgress({ current: stats.screens, total: totalScreenCount, message: "화면-메뉴 할당 복제 중..." }); + console.log("📋 화면-메뉴 할당 복제 시작..."); + + const menuAssignResponse = await apiClient.post("/screen-management/copy-menu-assignments", { + sourceCompanyCode: sourceGroup.company_code, + targetCompanyCode: finalCompanyCode, + screenIdMap, + }); + + if (menuAssignResponse.data?.success) { + console.log("✅ 화면-메뉴 할당 복제 완료:", menuAssignResponse.data.data); + toast.success(`화면-메뉴 할당 ${menuAssignResponse.data.data?.copiedCount || 0}개가 복제되었습니다.`); + } else { + console.warn("화면-메뉴 할당 복제 실패:", menuAssignResponse.data?.error); + } + } else { + console.warn("메뉴 동기화 실패:", syncResponse.data?.error); + toast.warning("메뉴 동기화에 실패했습니다. 채번규칙이 복제되지 않았습니다."); + } + } catch (numberingError) { + console.error("채번규칙 복제 중 오류:", numberingError); + toast.warning("채번규칙 복제 중 오류가 발생했습니다."); + } + } + + // 8. 코드 카테고리 + 코드 복제 + if (copyCodeCategory) { + try { + setCopyProgress({ current: stats.screens, total: totalScreenCount, message: "코드 카테고리/코드 복제 중..." }); + console.log("📋 코드 카테고리/코드 복제 시작..."); + + const response = await apiClient.post("/screen-management/copy-code-category", { + sourceCompanyCode: sourceGroup.company_code, + targetCompanyCode: finalCompanyCode, + }); + + if (response.data?.success) { + console.log("✅ 코드 카테고리/코드 복제 완료:", response.data.data); + toast.success(`코드 카테고리 ${response.data.data?.copiedCategories || 0}개, 코드 ${response.data.data?.copiedCodes || 0}개가 복제되었습니다.`); + } else { + console.warn("코드 카테고리/코드 복제 실패:", response.data?.error); + toast.warning("코드 카테고리/코드 복제에 실패했습니다."); + } + } catch (error) { + console.error("코드 카테고리/코드 복제 중 오류:", error); + toast.warning("코드 카테고리/코드 복제 중 오류가 발생했습니다."); + } + } + + // 9. 카테고리 매핑 + 값 복제 + if (copyCategoryMapping) { + try { + setCopyProgress({ current: stats.screens, total: totalScreenCount, message: "카테고리 매핑/값 복제 중..." }); + console.log("📋 카테고리 매핑/값 복제 시작..."); + + const response = await apiClient.post("/screen-management/copy-category-mapping", { + sourceCompanyCode: sourceGroup.company_code, + targetCompanyCode: finalCompanyCode, + }); + + if (response.data?.success) { + console.log("✅ 카테고리 매핑/값 복제 완료:", response.data.data); + toast.success(`카테고리 매핑 ${response.data.data?.copiedMappings || 0}개, 값 ${response.data.data?.copiedValues || 0}개가 복제되었습니다.`); + } else { + console.warn("카테고리 매핑/값 복제 실패:", response.data?.error); + toast.warning("카테고리 매핑/값 복제에 실패했습니다."); + } + } catch (error) { + console.error("카테고리 매핑/값 복제 중 오류:", error); + toast.warning("카테고리 매핑/값 복제 중 오류가 발생했습니다."); + } + } + + // 10. 테이블 타입관리 입력타입 설정 복제 + if (copyTableTypeColumns) { + try { + setCopyProgress({ current: stats.screens, total: totalScreenCount, message: "테이블 타입 컬럼 복제 중..." }); + console.log("📋 테이블 타입 컬럼 복제 시작..."); + + const response = await apiClient.post("/screen-management/copy-table-type-columns", { + sourceCompanyCode: sourceGroup.company_code, + targetCompanyCode: finalCompanyCode, + }); + + if (response.data?.success) { + console.log("✅ 테이블 타입 컬럼 복제 완료:", response.data.data); + toast.success(`테이블 타입 컬럼 ${response.data.data?.copiedCount || 0}개가 복제되었습니다.`); + } else { + console.warn("테이블 타입 컬럼 복제 실패:", response.data?.error); + toast.warning("테이블 타입 컬럼 복제에 실패했습니다."); + } + } catch (error) { + console.error("테이블 타입 컬럼 복제 중 오류:", error); + toast.warning("테이블 타입 컬럼 복제 중 오류가 발생했습니다."); + } + } + + // 11. 연쇄관계 설정 복제 + if (copyCascadingRelation) { + try { + setCopyProgress({ current: stats.screens, total: totalScreenCount, message: "연쇄관계 설정 복제 중..." }); + console.log("📋 연쇄관계 설정 복제 시작..."); + + const response = await apiClient.post("/screen-management/copy-cascading-relation", { + sourceCompanyCode: sourceGroup.company_code, + targetCompanyCode: finalCompanyCode, + }); + + if (response.data?.success) { + console.log("✅ 연쇄관계 설정 복제 완료:", response.data.data); + toast.success(`연쇄관계 설정 ${response.data.data?.copiedCount || 0}개가 복제되었습니다.`); + } else { + console.warn("연쇄관계 설정 복제 실패:", response.data?.error); + toast.warning("연쇄관계 설정 복제에 실패했습니다."); + } + } catch (error) { + console.error("연쇄관계 설정 복제 중 오류:", error); + toast.warning("연쇄관계 설정 복제 중 오류가 발생했습니다."); + } + } + toast.success( `그룹 복제가 완료되었습니다! (그룹 ${stats.groups}개 + 화면 ${stats.screens}개)` ); @@ -1045,6 +1287,89 @@ export default function CopyScreenModal({

+ {/* 추가 복사 옵션 (선택사항) */} +
+ + + {/* 코드 카테고리 + 코드 복사 */} +
+ setCopyCodeCategory(checked === true)} + /> + +
+ + {/* 채번규칙 복제 */} +
+ setCopyNumberingRules(checked === true)} + /> + +
+ + {/* 카테고리 매핑 + 값 복사 */} +
+ setCopyCategoryMapping(checked === true)} + /> +