diff --git a/backend-node/src/controllers/numberingRuleController.ts b/backend-node/src/controllers/numberingRuleController.ts index 83cfc53d..333325a8 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 }); } }); @@ -259,151 +267,106 @@ router.post("/:ruleId/reset", authenticateToken, async (req: AuthenticatedReques } }); -// ====== 테스트용 API (menu_objid 없는 방식) ====== +// ==================== 테스트 테이블용 API ==================== -// [테스트] 테이블+컬럼 기반 채번규칙 조회 -router.get("/test/by-column", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { +// [테스트] 테이블+컬럼 기반 채번 규칙 조회 +router.get("/test/by-column/:tableName/:columnName", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { const companyCode = req.user!.companyCode; - const { tableName, columnName } = req.query; + const { tableName, columnName } = req.params; try { - if (!tableName || typeof tableName !== "string") { - return res.status(400).json({ success: false, error: "tableName is required" }); - } - if (!columnName || typeof columnName !== "string") { - return res.status(400).json({ success: false, error: "columnName is required" }); - } - - const rule = await numberingRuleService.getNumberingRuleByColumn( - companyCode, - tableName, - columnName - ); - - if (!rule) { - return res.status(404).json({ success: false, error: "규칙을 찾을 수 없습니다" }); - } - + const rule = await numberingRuleService.getNumberingRuleByColumn(companyCode, tableName, columnName); return res.json({ success: true, data: rule }); } catch (error: any) { - logger.error("테이블+컬럼 기반 채번규칙 조회 실패", { - error: error.message, - companyCode, - tableName, - columnName, - }); + logger.error("테이블+컬럼 기반 채번 규칙 조회 실패", { error: error.message }); return res.status(500).json({ success: false, error: error.message }); } }); -// [테스트] 테스트 테이블에 채번규칙 저장 +// [테스트] 테스트 테이블에 채번 규칙 저장 router.post("/test/save", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { const companyCode = req.user!.companyCode; const userId = req.user!.userId; - const config = req.body; + const ruleConfig = req.body; + + logger.info("[테스트] 채번 규칙 저장 요청", { + ruleId: ruleConfig.ruleId, + tableName: ruleConfig.tableName, + columnName: ruleConfig.columnName, + }); try { - if (!config.ruleId || !config.ruleName) { - return res.status(400).json({ success: false, error: "ruleId and ruleName are required" }); - } - if (!config.tableName || !config.columnName) { - return res.status(400).json({ success: false, error: "tableName and columnName are required" }); + if (!ruleConfig.tableName || !ruleConfig.columnName) { + return res.status(400).json({ + success: false, + error: "tableName and columnName are required" + }); } - const savedRule = await numberingRuleService.saveRuleToTest(config, companyCode, userId); + const savedRule = await numberingRuleService.saveRuleToTest(ruleConfig, companyCode, userId); return res.json({ success: true, data: savedRule }); } catch (error: any) { - logger.error("테스트 테이블에 채번규칙 저장 실패", { - error: error.message, - companyCode, - ruleId: config.ruleId, - }); + logger.error("[테스트] 채번 규칙 저장 실패", { error: error.message }); return res.status(500).json({ success: false, error: error.message }); } }); -// [테스트] 테스트 테이블에서 채번규칙 삭제 +// [테스트] 테스트 테이블에서 채번 규칙 삭제 router.delete("/test/:ruleId", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { const companyCode = req.user!.companyCode; const { ruleId } = req.params; try { await numberingRuleService.deleteRuleFromTest(ruleId, companyCode); - return res.json({ success: true }); + return res.json({ success: true, message: "테스트 채번 규칙이 삭제되었습니다" }); } catch (error: any) { - logger.error("테스트 테이블에서 채번규칙 삭제 실패", { - error: error.message, - companyCode, - ruleId, - }); + logger.error("[테스트] 채번 규칙 삭제 실패", { error: error.message }); return res.status(500).json({ success: false, error: error.message }); } }); -// [테스트] 카테고리 조건 포함 채번규칙 조회 -router.get("/test/by-column-with-category", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { +// [테스트] 코드 미리보기 (테스트 테이블 사용) +router.post("/test/:ruleId/preview", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { const companyCode = req.user!.companyCode; - const { tableName, columnName, categoryColumn, categoryValueId } = req.query; + const { ruleId } = req.params; + const { formData } = req.body; try { - if (!tableName || typeof tableName !== "string") { - return res.status(400).json({ success: false, error: "tableName is required" }); - } - if (!columnName || typeof columnName !== "string") { - return res.status(400).json({ success: false, error: "columnName is required" }); - } - - const rule = await numberingRuleService.getNumberingRuleByColumnWithCategory( - companyCode, - tableName, - columnName, - categoryColumn as string | undefined, - categoryValueId ? Number(categoryValueId) : undefined - ); - - if (!rule) { - return res.status(404).json({ success: false, error: "규칙을 찾을 수 없습니다" }); - } - - return res.json({ success: true, data: rule }); + const previewCode = await numberingRuleService.previewCode(ruleId, companyCode, formData); + return res.json({ success: true, data: { generatedCode: previewCode } }); } catch (error: any) { - logger.error("카테고리 조건 포함 채번규칙 조회 실패", { - error: error.message, - companyCode, - tableName, - columnName, - }); + logger.error("[테스트] 코드 미리보기 실패", { error: error.message }); return res.status(500).json({ success: false, error: error.message }); } }); -// [테스트] 테이블.컬럼별 모든 채번규칙 조회 (카테고리 조건별) -router.get("/test/rules-by-table-column", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { - const companyCode = req.user!.companyCode; - const { tableName, columnName } = req.query; +// ==================== 회사별 채번규칙 복제 API ==================== + +// 회사별 채번규칙 복제 +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: "sourceCompanyCode와 targetCompanyCode가 필요합니다" + }); + } try { - if (!tableName || typeof tableName !== "string") { - return res.status(400).json({ success: false, error: "tableName is required" }); - } - if (!columnName || typeof columnName !== "string") { - return res.status(400).json({ success: false, error: "columnName is required" }); - } - - const rules = await numberingRuleService.getRulesByTableColumn( - companyCode, - tableName, - columnName - ); - - return res.json({ success: true, data: rules }); + const result = await numberingRuleService.copyRulesForCompany(sourceCompanyCode, targetCompanyCode); + return res.json({ success: true, data: result }); } catch (error: any) { - logger.error("테이블.컬럼별 채번규칙 목록 조회 실패", { - error: error.message, - companyCode, - tableName, - columnName, - }); + logger.error("회사별 채번규칙 복제 실패", { error: error.message }); return res.status(500).json({ success: false, error: error.message }); } }); diff --git a/backend-node/src/controllers/screenGroupController.ts b/backend-node/src/controllers/screenGroupController.ts index 43ccce32..6185b973 100644 --- a/backend-node/src/controllers/screenGroupController.ts +++ b/backend-node/src/controllers/screenGroupController.ts @@ -369,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 userCompanyCode = req.user?.companyCode || "*"; const userId = req.user?.userId || ""; - const { group_id, screen_id, screen_role, display_order, is_default } = req.body; + 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) @@ -388,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) { @@ -2251,3 +2256,168 @@ export const syncAllCompaniesController = async (req: AuthenticatedRequest, res: } }; +/** + * [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 f8b808d3..1980a82c 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 index d6f27e07..68529c47 100644 --- a/backend-node/src/services/menuScreenSyncService.ts +++ b/backend-node/src/services/menuScreenSyncService.ts @@ -243,6 +243,28 @@ export async function syncScreenGroupsToMenu( [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({ @@ -286,12 +308,34 @@ export async function syncScreenGroupsToMenu( nextSeq = parseInt(maxSeqResult.rows[0].next_seq) || 1; } + // 해당 그룹에 연결된 기본 화면 조회 (is_default = 'Y' 우선, 없으면 첫 번째 화면) + let menuUrl: string | null = null; + let screenCode: string | null = null; + const defaultScreenQuery2 = ` + 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 defaultScreenResult2 = await client.query(defaultScreenQuery2, [groupId, companyCode]); + if (defaultScreenResult2.rows.length > 0) { + const defaultScreen = defaultScreenResult2.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 - ) VALUES ($1, $2, $3, $4, $5, 1, $6, $7, NOW(), 'active', $8, $9) + 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, [ @@ -304,6 +348,8 @@ export async function syncScreenGroupsToMenu( userId, groupId, group.description || null, + menuUrl, + screenCode, ]); // screen_groups에 menu_objid 업데이트 @@ -336,7 +382,13 @@ export async function syncScreenGroupsToMenu( } catch (error: any) { await client.query('ROLLBACK'); - logger.error("화면관리 → 메뉴 동기화 실패", { companyCode, error: error.message }); + 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; diff --git a/backend-node/src/services/numberingRuleService.ts b/backend-node/src/services/numberingRuleService.ts index ad1b672e..8ff7a2e7 100644 --- a/backend-node/src/services/numberingRuleService.ts +++ b/backend-node/src/services/numberingRuleService.ts @@ -858,7 +858,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(); @@ -1671,6 +1677,203 @@ class NumberingRuleService { throw error; } } + + /** + * 회사별 채번규칙 복제 (메뉴 동기화 완료 후 호출) + * 메뉴 이름을 기준으로 채번규칙을 대상 회사의 메뉴에 연결 + * 복제 후 화면 레이아웃의 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 4891e353..013653be 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -2555,6 +2555,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 전환 완료) */ @@ -2616,6 +3340,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, @@ -2628,7 +3355,7 @@ export class ScreenManagementService { copyData.description || sourceScreen.description, targetCompanyCode, // 대상 회사 코드 사용 sourceScreen.table_name, - sourceScreen.is_active, + newIsActive, // 삭제된 화면은 활성 상태로 복사 copyData.createdBy, new Date(), copyData.createdBy, @@ -2648,7 +3375,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 매핑 맵 생성 @@ -2666,6 +3431,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, @@ -2681,9 +3469,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(), ] @@ -2902,6 +3688,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 bac3ae54..e4b90c34 100644 --- a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx +++ b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx @@ -118,9 +118,9 @@ export default function ScreenManagementPage() { const filteredScreens = searchKeywords.length > 1 ? screens // 폴더 계층 검색 시에는 화면 필터링 없음 (폴더에서 이미 필터링됨) : screens.filter((screen) => - screen.screenName.toLowerCase().includes(searchTerm.toLowerCase()) || - screen.screenCode.toLowerCase().includes(searchTerm.toLowerCase()) - ); + screen.screenName.toLowerCase().includes(searchTerm.toLowerCase()) || + screen.screenCode.toLowerCase().includes(searchTerm.toLowerCase()) + ); // 화면 설계 모드일 때는 레이아웃 없이 전체 화면 사용 if (isDesignMode) { diff --git a/frontend/components/numbering-rule/NumberingRuleDesigner.tsx b/frontend/components/numbering-rule/NumberingRuleDesigner.tsx index 5cd826d0..60bbd247 100644 --- a/frontend/components/numbering-rule/NumberingRuleDesigner.tsx +++ b/frontend/components/numbering-rule/NumberingRuleDesigner.tsx @@ -317,13 +317,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 a5207b96..eb2fd06f 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)} + /> +