From ad8b1791bc6f9ed5dc99dfbdf9d12ccad19da3e8 Mon Sep 17 00:00:00 2001
From: DDD1542
Date: Wed, 21 Jan 2026 11:53:51 +0900
Subject: [PATCH] =?UTF-8?q?feat:=20=ED=99=94=EB=A9=B4=20=EA=B4=80=EB=A6=AC?=
=?UTF-8?q?=20=EB=B0=8F=20=EB=A9=94=EB=89=B4=20=EB=8F=99=EA=B8=B0=ED=99=94?=
=?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=EA=B0=9C=EC=84=A0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 화면 그룹 컨트롤러 기능 확장
- 메뉴 복사 서비스 개선
- 메뉴-화면 동기화 서비스 추가
- 번호 규칙 서비스 개선
- 화면 관리 서비스 확장
- CopyScreenModal 기능 개선
- DataFlowPanel, FieldJoinPanel 수정
---
.../controllers/numberingRuleController.ts | 37 +-
.../src/controllers/screenGroupController.ts | 518 ++++++--
.../controllers/screenManagementController.ts | 261 ++++
.../src/routes/screenManagementRoutes.ts | 24 +
backend-node/src/services/menuCopyService.ts | 228 +++-
.../src/services/menuScreenSyncService.ts | 1021 +++++++++++++++
.../src/services/numberingRuleService.ts | 205 ++-
.../src/services/screenManagementService.ts | 1162 ++++++++++++++++-
.../admin/screenMng/screenMngList/page.tsx | 8 +-
.../numbering-rule/NumberingRuleDesigner.tsx | 9 +-
.../components/screen/CopyScreenModal.tsx | 377 +++++-
.../screen/panels/DataFlowPanel.tsx | 2 +
.../screen/panels/FieldJoinPanel.tsx | 2 +
frontend/lib/api/screen.ts | 13 +
frontend/lib/api/screenGroup.ts | 164 +++
15 files changed, 3895 insertions(+), 136 deletions(-)
create mode 100644 backend-node/src/services/menuScreenSyncService.ts
diff --git a/backend-node/src/controllers/numberingRuleController.ts b/backend-node/src/controllers/numberingRuleController.ts
index ab7114a5..d94cd25a 100644
--- a/backend-node/src/controllers/numberingRuleController.ts
+++ b/backend-node/src/controllers/numberingRuleController.ts
@@ -169,14 +169,22 @@ router.put("/:ruleId", authenticateToken, async (req: AuthenticatedRequest, res:
const { ruleId } = req.params;
const updates = req.body;
+ logger.info("채번 규칙 수정 요청", { ruleId, companyCode, updates });
+
try {
const updatedRule = await numberingRuleService.updateRule(ruleId, updates, companyCode);
+ logger.info("채번 규칙 수정 성공", { ruleId, companyCode });
return res.json({ success: true, data: updatedRule });
} catch (error: any) {
+ logger.error("채번 규칙 수정 실패", {
+ ruleId,
+ companyCode,
+ error: error.message,
+ stack: error.stack
+ });
if (error.message.includes("찾을 수 없거나")) {
return res.status(404).json({ success: false, error: error.message });
}
- logger.error("규칙 수정 실패", { error: error.message });
return res.status(500).json({ success: false, error: error.message });
}
});
@@ -257,4 +265,31 @@ router.post("/:ruleId/reset", authenticateToken, async (req: AuthenticatedReques
}
});
+// 회사별 채번규칙 복제 (화면 복제 후 메뉴 동기화 완료 상태에서 호출)
+router.post("/copy-for-company", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
+ const userCompanyCode = req.user!.companyCode;
+ const { sourceCompanyCode, targetCompanyCode } = req.body;
+
+ // 최고 관리자만 회사간 복제 가능
+ if (userCompanyCode !== "*") {
+ return res.status(403).json({ success: false, error: "최고 관리자만 회사간 채번규칙 복제가 가능합니다." });
+ }
+
+ if (!sourceCompanyCode || !targetCompanyCode) {
+ return res.status(400).json({ success: false, error: "원본 회사 코드와 대상 회사 코드가 필요합니다." });
+ }
+
+ try {
+ logger.info("회사별 채번규칙 복제 시작", { sourceCompanyCode, targetCompanyCode });
+
+ const result = await numberingRuleService.copyRulesForCompany(sourceCompanyCode, targetCompanyCode);
+
+ logger.info("회사별 채번규칙 복제 완료", { sourceCompanyCode, targetCompanyCode, result });
+ return res.json({ success: true, data: result });
+ } catch (error: any) {
+ logger.error("회사별 채번규칙 복제 실패", { error: error.message, sourceCompanyCode, targetCompanyCode });
+ return res.status(500).json({ success: false, error: error.message });
+ }
+});
+
export default router;
diff --git a/backend-node/src/controllers/screenGroupController.ts b/backend-node/src/controllers/screenGroupController.ts
index 2d7bc0e1..ffe34a66 100644
--- a/backend-node/src/controllers/screenGroupController.ts
+++ b/backend-node/src/controllers/screenGroupController.ts
@@ -1,15 +1,17 @@
import { Request, Response } from "express";
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
-import { MultiLangService } from "../services/multilangService";
import { AuthenticatedRequest } from "../types/auth";
+import {
+ syncScreenGroupsToMenu,
+ syncMenuToScreenGroups,
+ getSyncStatus,
+ syncAllCompanies,
+} from "../services/menuScreenSyncService";
// pool 인스턴스 가져오기
const pool = getPool();
-// 다국어 서비스 인스턴스
-const multiLangService = new MultiLangService();
-
// ============================================================
// 화면 그룹 (screen_groups) CRUD
// ============================================================
@@ -17,7 +19,7 @@ const multiLangService = new MultiLangService();
// 화면 그룹 목록 조회
export const getScreenGroups = async (req: AuthenticatedRequest, res: Response) => {
try {
- const companyCode = req.user!.companyCode;
+ const companyCode = req.user?.companyCode || "*";
const { page = 1, size = 20, searchTerm } = req.query;
const offset = (parseInt(page as string) - 1) * parseInt(size as string);
@@ -92,7 +94,7 @@ export const getScreenGroups = async (req: AuthenticatedRequest, res: Response)
export const getScreenGroup = async (req: AuthenticatedRequest, res: Response) => {
try {
const { id } = req.params;
- const companyCode = req.user!.companyCode;
+ const companyCode = req.user?.companyCode || "*";
let query = `
SELECT sg.*,
@@ -137,8 +139,8 @@ export const getScreenGroup = async (req: AuthenticatedRequest, res: Response) =
// 화면 그룹 생성
export const createScreenGroup = async (req: AuthenticatedRequest, res: Response) => {
try {
- const userCompanyCode = req.user!.companyCode;
- const userId = req.user!.userId;
+ const userCompanyCode = req.user?.companyCode || "*";
+ const userId = req.user?.userId || "";
const { group_name, group_code, main_table_name, description, icon, display_order, is_active, parent_group_id, target_company_code } = req.body;
if (!group_name || !group_code) {
@@ -196,47 +198,6 @@ export const createScreenGroup = async (req: AuthenticatedRequest, res: Response
// 업데이트된 데이터 반환
const updatedResult = await pool.query(`SELECT * FROM screen_groups WHERE id = $1`, [newGroupId]);
- // 다국어 카테고리 자동 생성 (그룹 경로 기반)
- try {
- // 그룹 경로 조회 (상위 그룹 → 현재 그룹)
- const groupPathResult = await pool.query(
- `WITH RECURSIVE group_path AS (
- SELECT id, parent_group_id, group_name, group_level, 1 as depth
- FROM screen_groups
- WHERE id = $1
- UNION ALL
- SELECT g.id, g.parent_group_id, g.group_name, g.group_level, gp.depth + 1
- FROM screen_groups g
- INNER JOIN group_path gp ON g.id = gp.parent_group_id
- WHERE g.parent_group_id IS NOT NULL
- )
- SELECT group_name FROM group_path
- ORDER BY depth DESC`,
- [newGroupId]
- );
-
- const groupPath = groupPathResult.rows.map((r: any) => r.group_name);
-
- // 회사 이름 조회
- let companyName = "공통";
- if (finalCompanyCode !== "*") {
- const companyResult = await pool.query(
- `SELECT company_name FROM company_mng WHERE company_code = $1`,
- [finalCompanyCode]
- );
- if (companyResult.rows.length > 0) {
- companyName = companyResult.rows[0].company_name;
- }
- }
-
- // 다국어 카테고리 생성
- await multiLangService.ensureScreenGroupCategory(finalCompanyCode, companyName, groupPath);
- logger.info("화면 그룹 다국어 카테고리 자동 생성 완료", { groupPath, companyCode: finalCompanyCode });
- } catch (multilangError: any) {
- // 다국어 카테고리 생성 실패해도 그룹 생성은 성공으로 처리
- logger.warn("화면 그룹 다국어 카테고리 생성 실패 (무시하고 계속):", multilangError.message);
- }
-
logger.info("화면 그룹 생성", { userCompanyCode, finalCompanyCode, groupId: newGroupId, groupName: group_name, parentGroupId: parent_group_id });
res.json({ success: true, data: updatedResult.rows[0], message: "화면 그룹이 생성되었습니다." });
@@ -253,7 +214,7 @@ export const createScreenGroup = async (req: AuthenticatedRequest, res: Response
export const updateScreenGroup = async (req: AuthenticatedRequest, res: Response) => {
try {
const { id } = req.params;
- const userCompanyCode = req.user!.companyCode;
+ const userCompanyCode = req.user?.companyCode || "*";
const { group_name, group_code, main_table_name, description, icon, display_order, is_active, parent_group_id, target_company_code } = req.body;
// 회사 코드 결정: 최고 관리자가 특정 회사를 선택한 경우 해당 회사로, 아니면 현재 그룹의 회사 유지
@@ -340,10 +301,35 @@ export const updateScreenGroup = async (req: AuthenticatedRequest, res: Response
// 화면 그룹 삭제
export const deleteScreenGroup = async (req: AuthenticatedRequest, res: Response) => {
+ const client = await pool.connect();
try {
const { id } = req.params;
- const companyCode = req.user!.companyCode;
+ const companyCode = req.user?.companyCode || "*";
+ await client.query('BEGIN');
+
+ // 1. 삭제할 그룹과 하위 그룹 ID 수집 (CASCADE 삭제 대상)
+ const childGroupsResult = await client.query(`
+ WITH RECURSIVE child_groups AS (
+ SELECT id FROM screen_groups WHERE id = $1
+ UNION ALL
+ SELECT sg.id FROM screen_groups sg
+ JOIN child_groups cg ON sg.parent_group_id = cg.id
+ )
+ SELECT id FROM child_groups
+ `, [id]);
+ const groupIdsToDelete = childGroupsResult.rows.map((r: any) => r.id);
+
+ // 2. menu_info에서 삭제될 screen_group 참조를 NULL로 정리
+ if (groupIdsToDelete.length > 0) {
+ await client.query(`
+ UPDATE menu_info
+ SET screen_group_id = NULL
+ WHERE screen_group_id = ANY($1::int[])
+ `, [groupIdsToDelete]);
+ }
+
+ // 3. screen_groups 삭제
let query = `DELETE FROM screen_groups WHERE id = $1`;
const params: any[] = [id];
@@ -354,18 +340,24 @@ export const deleteScreenGroup = async (req: AuthenticatedRequest, res: Response
query += " RETURNING id";
- const result = await pool.query(query, params);
+ const result = await client.query(query, params);
if (result.rows.length === 0) {
+ await client.query('ROLLBACK');
return res.status(404).json({ success: false, message: "화면 그룹을 찾을 수 없거나 권한이 없습니다." });
}
- logger.info("화면 그룹 삭제", { companyCode, groupId: id });
+ await client.query('COMMIT');
+
+ logger.info("화면 그룹 삭제", { companyCode, groupId: id, cleanedRefs: groupIdsToDelete.length });
res.json({ success: true, message: "화면 그룹이 삭제되었습니다." });
} catch (error: any) {
+ await client.query('ROLLBACK');
logger.error("화면 그룹 삭제 실패:", error);
res.status(500).json({ success: false, message: "화면 그룹 삭제에 실패했습니다.", error: error.message });
+ } finally {
+ client.release();
}
};
@@ -377,14 +369,19 @@ export const deleteScreenGroup = async (req: AuthenticatedRequest, res: Response
// 그룹에 화면 추가
export const addScreenToGroup = async (req: AuthenticatedRequest, res: Response) => {
try {
- const companyCode = req.user!.companyCode;
- const userId = req.user!.userId;
- const { group_id, screen_id, screen_role, display_order, is_default } = req.body;
+ const userCompanyCode = req.user?.companyCode || "*";
+ const userId = req.user?.userId || "";
+ const { group_id, screen_id, screen_role, display_order, is_default, target_company_code } = req.body;
if (!group_id || !screen_id) {
return res.status(400).json({ success: false, message: "그룹 ID와 화면 ID는 필수입니다." });
}
+ // 최고 관리자가 다른 회사로 복제할 때 target_company_code 사용
+ const effectiveCompanyCode = (userCompanyCode === "*" && target_company_code)
+ ? target_company_code
+ : userCompanyCode;
+
const query = `
INSERT INTO screen_group_screens (group_id, screen_id, screen_role, display_order, is_default, company_code, writer)
VALUES ($1, $2, $3, $4, $5, $6, $7)
@@ -396,13 +393,13 @@ export const addScreenToGroup = async (req: AuthenticatedRequest, res: Response)
screen_role || 'main',
display_order || 0,
is_default || 'N',
- companyCode === "*" ? "*" : companyCode,
+ effectiveCompanyCode,
userId
];
const result = await pool.query(query, params);
- logger.info("화면-그룹 연결 추가", { companyCode, groupId: group_id, screenId: screen_id });
+ logger.info("화면-그룹 연결 추가", { companyCode: effectiveCompanyCode, groupId: group_id, screenId: screen_id });
res.json({ success: true, data: result.rows[0], message: "화면이 그룹에 추가되었습니다." });
} catch (error: any) {
@@ -418,7 +415,7 @@ export const addScreenToGroup = async (req: AuthenticatedRequest, res: Response)
export const removeScreenFromGroup = async (req: AuthenticatedRequest, res: Response) => {
try {
const { id } = req.params;
- const companyCode = req.user!.companyCode;
+ const companyCode = req.user?.companyCode || "*";
let query = `DELETE FROM screen_group_screens WHERE id = $1`;
const params: any[] = [id];
@@ -449,7 +446,7 @@ export const removeScreenFromGroup = async (req: AuthenticatedRequest, res: Resp
export const updateScreenInGroup = async (req: AuthenticatedRequest, res: Response) => {
try {
const { id } = req.params;
- const companyCode = req.user!.companyCode;
+ const companyCode = req.user?.companyCode || "*";
const { screen_role, display_order, is_default } = req.body;
let query = `
@@ -487,7 +484,7 @@ export const updateScreenInGroup = async (req: AuthenticatedRequest, res: Respon
// 화면 필드 조인 목록 조회
export const getFieldJoins = async (req: AuthenticatedRequest, res: Response) => {
try {
- const companyCode = req.user!.companyCode;
+ const companyCode = req.user?.companyCode || "*";
const { screen_id } = req.query;
let query = `
@@ -528,8 +525,8 @@ export const getFieldJoins = async (req: AuthenticatedRequest, res: Response) =>
// 화면 필드 조인 생성
export const createFieldJoin = async (req: AuthenticatedRequest, res: Response) => {
try {
- const companyCode = req.user!.companyCode;
- const userId = req.user!.userId;
+ const companyCode = req.user?.companyCode || "*";
+ const userId = req.user?.userId || "";
const {
screen_id, layout_id, component_id, field_name,
save_table, save_column, join_table, join_column, display_column,
@@ -570,7 +567,7 @@ export const createFieldJoin = async (req: AuthenticatedRequest, res: Response)
export const updateFieldJoin = async (req: AuthenticatedRequest, res: Response) => {
try {
const { id } = req.params;
- const companyCode = req.user!.companyCode;
+ const companyCode = req.user?.companyCode || "*";
const {
layout_id, component_id, field_name,
save_table, save_column, join_table, join_column, display_column,
@@ -615,7 +612,7 @@ export const updateFieldJoin = async (req: AuthenticatedRequest, res: Response)
export const deleteFieldJoin = async (req: AuthenticatedRequest, res: Response) => {
try {
const { id } = req.params;
- const companyCode = req.user!.companyCode;
+ const companyCode = req.user?.companyCode || "*";
let query = `DELETE FROM screen_field_joins WHERE id = $1`;
const params: any[] = [id];
@@ -648,7 +645,7 @@ export const deleteFieldJoin = async (req: AuthenticatedRequest, res: Response)
// 데이터 흐름 목록 조회
export const getDataFlows = async (req: AuthenticatedRequest, res: Response) => {
try {
- const companyCode = req.user!.companyCode;
+ const companyCode = req.user?.companyCode || "*";
const { group_id, source_screen_id } = req.query;
let query = `
@@ -698,8 +695,8 @@ export const getDataFlows = async (req: AuthenticatedRequest, res: Response) =>
// 데이터 흐름 생성
export const createDataFlow = async (req: AuthenticatedRequest, res: Response) => {
try {
- const companyCode = req.user!.companyCode;
- const userId = req.user!.userId;
+ const companyCode = req.user?.companyCode || "*";
+ const userId = req.user?.userId || "";
const {
group_id, source_screen_id, source_action, target_screen_id, target_action,
data_mapping, flow_type, flow_label, condition_expression, is_active
@@ -738,7 +735,7 @@ export const createDataFlow = async (req: AuthenticatedRequest, res: Response) =
export const updateDataFlow = async (req: AuthenticatedRequest, res: Response) => {
try {
const { id } = req.params;
- const companyCode = req.user!.companyCode;
+ const companyCode = req.user?.companyCode || "*";
const {
group_id, source_screen_id, source_action, target_screen_id, target_action,
data_mapping, flow_type, flow_label, condition_expression, is_active
@@ -781,7 +778,7 @@ export const updateDataFlow = async (req: AuthenticatedRequest, res: Response) =
export const deleteDataFlow = async (req: AuthenticatedRequest, res: Response) => {
try {
const { id } = req.params;
- const companyCode = req.user!.companyCode;
+ const companyCode = req.user?.companyCode || "*";
let query = `DELETE FROM screen_data_flows WHERE id = $1`;
const params: any[] = [id];
@@ -814,7 +811,7 @@ export const deleteDataFlow = async (req: AuthenticatedRequest, res: Response) =
// 화면-테이블 관계 목록 조회
export const getTableRelations = async (req: AuthenticatedRequest, res: Response) => {
try {
- const companyCode = req.user!.companyCode;
+ const companyCode = req.user?.companyCode || "*";
const { screen_id, group_id } = req.query;
let query = `
@@ -863,8 +860,8 @@ export const getTableRelations = async (req: AuthenticatedRequest, res: Response
// 화면-테이블 관계 생성
export const createTableRelation = async (req: AuthenticatedRequest, res: Response) => {
try {
- const companyCode = req.user!.companyCode;
- const userId = req.user!.userId;
+ const companyCode = req.user?.companyCode || "*";
+ const userId = req.user?.userId || "";
const { group_id, screen_id, table_name, relation_type, crud_operations, description, is_active } = req.body;
if (!screen_id || !table_name) {
@@ -897,7 +894,7 @@ export const createTableRelation = async (req: AuthenticatedRequest, res: Respon
export const updateTableRelation = async (req: AuthenticatedRequest, res: Response) => {
try {
const { id } = req.params;
- const companyCode = req.user!.companyCode;
+ const companyCode = req.user?.companyCode || "*";
const { group_id, table_name, relation_type, crud_operations, description, is_active } = req.body;
let query = `
@@ -932,7 +929,7 @@ export const updateTableRelation = async (req: AuthenticatedRequest, res: Respon
export const deleteTableRelation = async (req: AuthenticatedRequest, res: Response) => {
try {
const { id } = req.params;
- const companyCode = req.user!.companyCode;
+ const companyCode = req.user?.companyCode || "*";
let query = `DELETE FROM screen_table_relations WHERE id = $1`;
const params: any[] = [id];
@@ -962,7 +959,7 @@ export const deleteTableRelation = async (req: AuthenticatedRequest, res: Respon
// ============================================================
// 화면 레이아웃 요약 조회 (위젯 타입별 개수, 라벨 목록)
-export const getScreenLayoutSummary = async (req: Request, res: Response) => {
+export const getScreenLayoutSummary = async (req: AuthenticatedRequest, res: Response) => {
try {
const { screenId } = req.params;
@@ -1030,7 +1027,7 @@ export const getScreenLayoutSummary = async (req: Request, res: Response) => {
};
// 여러 화면의 레이아웃 요약 일괄 조회 (미니어처 렌더링용 좌표 포함)
-export const getMultipleScreenLayoutSummary = async (req: Request, res: Response) => {
+export const getMultipleScreenLayoutSummary = async (req: AuthenticatedRequest, res: Response) => {
try {
const { screenIds } = req.body;
@@ -1230,7 +1227,7 @@ export const getMultipleScreenLayoutSummary = async (req: Request, res: Response
// ============================================================
// 여러 화면의 서브 테이블 정보 조회 (메인 테이블 → 서브 테이블 관계)
-export const getScreenSubTables = async (req: Request, res: Response) => {
+export const getScreenSubTables = async (req: AuthenticatedRequest, res: Response) => {
try {
const { screenIds } = req.body;
@@ -2060,3 +2057,368 @@ export const getScreenSubTables = async (req: Request, res: Response) => {
}
};
+
+// ============================================================
+// 메뉴-화면그룹 동기화 API
+// ============================================================
+
+/**
+ * 화면관리 → 메뉴 동기화
+ * screen_groups를 menu_info로 동기화
+ */
+export const syncScreenGroupsToMenuController = async (req: AuthenticatedRequest, res: Response) => {
+ try {
+ const userCompanyCode = req.user?.companyCode || "*";
+ const userId = req.user?.userId || "";
+ const { targetCompanyCode } = req.body;
+
+ // 최고 관리자가 특정 회사를 지정한 경우 해당 회사로
+ let companyCode = userCompanyCode;
+ if (userCompanyCode === "*" && targetCompanyCode) {
+ companyCode = targetCompanyCode;
+ }
+
+ // 최고 관리자(*)는 회사를 지정해야 함
+ if (companyCode === "*") {
+ return res.status(400).json({
+ success: false,
+ message: "동기화할 회사를 선택해주세요.",
+ });
+ }
+
+ logger.info("화면관리 → 메뉴 동기화 요청", { companyCode, userId });
+
+ const result = await syncScreenGroupsToMenu(companyCode, userId);
+
+ if (!result.success) {
+ return res.status(500).json({
+ success: false,
+ message: "동기화 중 오류가 발생했습니다.",
+ errors: result.errors,
+ });
+ }
+
+ res.json({
+ success: true,
+ message: `동기화 완료: 생성 ${result.created}개, 연결 ${result.linked}개, 스킵 ${result.skipped}개`,
+ data: result,
+ });
+ } catch (error: any) {
+ logger.error("화면관리 → 메뉴 동기화 실패:", error);
+ res.status(500).json({
+ success: false,
+ message: "동기화에 실패했습니다.",
+ error: error.message,
+ });
+ }
+};
+
+/**
+ * 메뉴 → 화면관리 동기화
+ * menu_info를 screen_groups로 동기화
+ */
+export const syncMenuToScreenGroupsController = async (req: AuthenticatedRequest, res: Response) => {
+ try {
+ const userCompanyCode = req.user?.companyCode || "*";
+ const userId = req.user?.userId || "";
+ const { targetCompanyCode } = req.body;
+
+ // 최고 관리자가 특정 회사를 지정한 경우 해당 회사로
+ let companyCode = userCompanyCode;
+ if (userCompanyCode === "*" && targetCompanyCode) {
+ companyCode = targetCompanyCode;
+ }
+
+ // 최고 관리자(*)는 회사를 지정해야 함
+ if (companyCode === "*") {
+ return res.status(400).json({
+ success: false,
+ message: "동기화할 회사를 선택해주세요.",
+ });
+ }
+
+ logger.info("메뉴 → 화면관리 동기화 요청", { companyCode, userId });
+
+ const result = await syncMenuToScreenGroups(companyCode, userId);
+
+ if (!result.success) {
+ return res.status(500).json({
+ success: false,
+ message: "동기화 중 오류가 발생했습니다.",
+ errors: result.errors,
+ });
+ }
+
+ res.json({
+ success: true,
+ message: `동기화 완료: 생성 ${result.created}개, 연결 ${result.linked}개, 스킵 ${result.skipped}개`,
+ data: result,
+ });
+ } catch (error: any) {
+ logger.error("메뉴 → 화면관리 동기화 실패:", error);
+ res.status(500).json({
+ success: false,
+ message: "동기화에 실패했습니다.",
+ error: error.message,
+ });
+ }
+};
+
+/**
+ * 동기화 상태 조회
+ */
+export const getSyncStatusController = async (req: AuthenticatedRequest, res: Response) => {
+ try {
+ const userCompanyCode = req.user?.companyCode || "*";
+ const { targetCompanyCode } = req.query;
+
+ // 최고 관리자가 특정 회사를 지정한 경우 해당 회사로
+ let companyCode = userCompanyCode;
+ if (userCompanyCode === "*" && targetCompanyCode) {
+ companyCode = targetCompanyCode as string;
+ }
+
+ // 최고 관리자(*)는 회사를 지정해야 함
+ if (companyCode === "*") {
+ return res.status(400).json({
+ success: false,
+ message: "조회할 회사를 선택해주세요.",
+ });
+ }
+
+ const status = await getSyncStatus(companyCode);
+
+ res.json({
+ success: true,
+ data: status,
+ });
+ } catch (error: any) {
+ logger.error("동기화 상태 조회 실패:", error);
+ res.status(500).json({
+ success: false,
+ message: "동기화 상태 조회에 실패했습니다.",
+ error: error.message,
+ });
+ }
+};
+
+/**
+ * 전체 회사 동기화
+ * 모든 회사에 대해 양방향 동기화 수행 (최고 관리자만)
+ */
+export const syncAllCompaniesController = async (req: AuthenticatedRequest, res: Response) => {
+ try {
+ const userCompanyCode = req.user?.companyCode || "*";
+ const userId = req.user?.userId || "";
+
+ // 최고 관리자만 전체 동기화 가능
+ if (userCompanyCode !== "*") {
+ return res.status(403).json({
+ success: false,
+ message: "전체 동기화는 최고 관리자만 수행할 수 있습니다.",
+ });
+ }
+
+ logger.info("전체 회사 동기화 요청", { userId });
+
+ const result = await syncAllCompanies(userId);
+
+ if (!result.success) {
+ return res.status(500).json({
+ success: false,
+ message: "전체 동기화 중 오류가 발생했습니다.",
+ });
+ }
+
+ // 결과 요약
+ const totalCreated = result.results.reduce((sum, r) => sum + r.created, 0);
+ const totalLinked = result.results.reduce((sum, r) => sum + r.linked, 0);
+
+ res.json({
+ success: true,
+ message: `전체 동기화 완료: ${result.totalCompanies}개 회사 중 ${result.successCount}개 성공`,
+ data: {
+ totalCompanies: result.totalCompanies,
+ successCount: result.successCount,
+ failedCount: result.failedCount,
+ totalCreated,
+ totalLinked,
+ details: result.results,
+ },
+ });
+ } catch (error: any) {
+ logger.error("전체 회사 동기화 실패:", error);
+ res.status(500).json({
+ success: false,
+ message: "전체 동기화에 실패했습니다.",
+ error: error.message,
+ });
+ }
+};
+
+/**
+ * [PoC] screen_groups 기반 메뉴 트리 조회
+ *
+ * 기존 menu_info 대신 screen_groups를 사이드바 메뉴로 사용하기 위한 테스트 API
+ * - screen_groups를 트리 구조로 반환
+ * - 각 그룹에 연결된 기본 화면의 URL 포함
+ * - menu_objid를 통해 권한 체크 가능
+ *
+ * DB 변경 없이 로직만 추가
+ */
+export const getMenuTreeFromScreenGroups = async (req: AuthenticatedRequest, res: Response) => {
+ try {
+ const userCompanyCode = req.user?.companyCode || "*";
+ const { targetCompanyCode } = req.query;
+
+ // 조회할 회사 코드 결정
+ const companyCode = userCompanyCode === "*" && targetCompanyCode
+ ? String(targetCompanyCode)
+ : userCompanyCode;
+
+ logger.info("[PoC] screen_groups 기반 메뉴 트리 조회", {
+ userCompanyCode,
+ targetCompanyCode: companyCode
+ });
+
+ // 1. screen_groups 조회 (계층 구조 포함)
+ const groupsQuery = `
+ SELECT
+ sg.id,
+ sg.group_name,
+ sg.group_code,
+ sg.parent_group_id,
+ sg.group_level,
+ sg.display_order,
+ sg.icon,
+ sg.is_active,
+ sg.menu_objid,
+ sg.company_code,
+ -- 기본 화면 정보 (URL 생성용)
+ (
+ SELECT json_build_object(
+ 'screen_id', sd.screen_id,
+ 'screen_name', sd.screen_name,
+ 'screen_code', sd.screen_code
+ )
+ FROM screen_group_screens sgs
+ JOIN screen_definitions sd ON sgs.screen_id = sd.screen_id
+ WHERE sgs.group_id = sg.id AND sgs.company_code = sg.company_code
+ ORDER BY
+ CASE WHEN sgs.is_default = 'Y' THEN 0 ELSE 1 END,
+ sgs.display_order ASC
+ LIMIT 1
+ ) as default_screen,
+ -- 하위 화면 개수
+ (
+ SELECT COUNT(*)
+ FROM screen_group_screens sgs
+ WHERE sgs.group_id = sg.id AND sgs.company_code = sg.company_code
+ ) as screen_count
+ FROM screen_groups sg
+ WHERE sg.company_code = $1
+ AND (sg.is_active = 'Y' OR sg.is_active IS NULL)
+ ORDER BY sg.group_level ASC, sg.display_order ASC, sg.group_name ASC
+ `;
+
+ const groupsResult = await pool.query(groupsQuery, [companyCode]);
+
+ // 2. 트리 구조로 변환
+ const groups = groupsResult.rows;
+ const groupMap = new Map();
+ const rootGroups: any[] = [];
+
+ // 먼저 모든 그룹을 Map에 저장
+ for (const group of groups) {
+ const menuItem = {
+ id: group.id,
+ objid: group.menu_objid || group.id, // 권한 체크용 (menu_objid 우선)
+ name: group.group_name,
+ name_kor: group.group_name,
+ icon: group.icon,
+ url: group.default_screen
+ ? `/screens/${group.default_screen.screen_id}`
+ : null,
+ screen_id: group.default_screen?.screen_id || null,
+ screen_code: group.default_screen?.screen_code || null,
+ screen_count: parseInt(group.screen_count) || 0,
+ parent_id: group.parent_group_id,
+ level: group.group_level || 0,
+ display_order: group.display_order || 0,
+ is_active: group.is_active === 'Y',
+ menu_objid: group.menu_objid, // 기존 권한 시스템 연결용
+ children: [],
+ // menu_info 호환 필드
+ menu_name_kor: group.group_name,
+ menu_url: group.default_screen
+ ? `/screens/${group.default_screen.screen_id}`
+ : null,
+ parent_obj_id: null, // 나중에 설정
+ seq: group.display_order || 0,
+ status: group.is_active === 'Y' ? 'active' : 'inactive',
+ };
+
+ groupMap.set(group.id, menuItem);
+ }
+
+ // 부모-자식 관계 설정
+ for (const group of groups) {
+ const menuItem = groupMap.get(group.id);
+
+ if (group.parent_group_id && groupMap.has(group.parent_group_id)) {
+ const parent = groupMap.get(group.parent_group_id);
+ parent.children.push(menuItem);
+ menuItem.parent_obj_id = parent.objid;
+ } else {
+ // 최상위 그룹
+ rootGroups.push(menuItem);
+ menuItem.parent_obj_id = "0";
+ }
+ }
+
+ // 3. 통계 정보
+ const stats = {
+ totalGroups: groups.length,
+ groupsWithScreens: groups.filter(g => g.default_screen).length,
+ groupsWithMenuObjid: groups.filter(g => g.menu_objid).length,
+ rootGroups: rootGroups.length,
+ };
+
+ logger.info("[PoC] screen_groups 메뉴 트리 생성 완료", stats);
+
+ res.json({
+ success: true,
+ message: "[PoC] screen_groups 기반 메뉴 트리",
+ data: rootGroups,
+ stats,
+ // 플랫 리스트도 제공 (기존 menu_info 형식 호환)
+ flatList: Array.from(groupMap.values()).map(item => ({
+ objid: String(item.objid),
+ OBJID: String(item.objid),
+ menu_name_kor: item.name,
+ MENU_NAME_KOR: item.name,
+ menu_url: item.url,
+ MENU_URL: item.url,
+ parent_obj_id: String(item.parent_obj_id || "0"),
+ PARENT_OBJ_ID: String(item.parent_obj_id || "0"),
+ seq: item.seq,
+ SEQ: item.seq,
+ status: item.status,
+ STATUS: item.status,
+ menu_type: 1, // 사용자 메뉴
+ MENU_TYPE: 1,
+ screen_group_id: item.id,
+ menu_objid: item.menu_objid,
+ })),
+ });
+
+ } catch (error: any) {
+ logger.error("[PoC] screen_groups 메뉴 트리 조회 실패:", error);
+ res.status(500).json({
+ success: false,
+ message: "메뉴 트리 조회에 실패했습니다.",
+ error: error.message,
+ });
+ }
+};
+
diff --git a/backend-node/src/controllers/screenManagementController.ts b/backend-node/src/controllers/screenManagementController.ts
index 5605031e..4eae31a4 100644
--- a/backend-node/src/controllers/screenManagementController.ts
+++ b/backend-node/src/controllers/screenManagementController.ts
@@ -834,3 +834,264 @@ export const cleanupDeletedScreenMenuAssignments = async (
});
}
};
+
+// 그룹 복제 완료 후 탭 컴포넌트의 screenId 참조 일괄 업데이트
+export const updateTabScreenReferences = async (
+ req: AuthenticatedRequest,
+ res: Response
+) => {
+ try {
+ const { targetScreenIds, screenIdMap } = req.body;
+
+ if (!targetScreenIds || !Array.isArray(targetScreenIds)) {
+ return res.status(400).json({
+ success: false,
+ message: "targetScreenIds 배열이 필요합니다.",
+ });
+ }
+
+ if (!screenIdMap || typeof screenIdMap !== "object") {
+ return res.status(400).json({
+ success: false,
+ message: "screenIdMap 객체가 필요합니다.",
+ });
+ }
+
+ const result = await screenManagementService.updateTabScreenReferences(
+ targetScreenIds,
+ screenIdMap
+ );
+
+ return res.json({
+ success: true,
+ message: `${result.updated}개 레이아웃의 탭 참조가 업데이트되었습니다.`,
+ updated: result.updated,
+ details: result.details,
+ });
+ } catch (error) {
+ console.error("탭 screenId 참조 업데이트 실패:", error);
+ return res.status(500).json({
+ success: false,
+ message: "탭 screenId 참조 업데이트에 실패했습니다.",
+ });
+ }
+};
+
+// 화면-메뉴 할당 복제 (다른 회사로 복제 시)
+export const copyScreenMenuAssignments = async (
+ req: AuthenticatedRequest,
+ res: Response
+) => {
+ try {
+ const { sourceCompanyCode, targetCompanyCode, screenIdMap } = req.body;
+ const userCompanyCode = req.user?.companyCode;
+
+ // 권한 체크: 최고 관리자만 가능
+ if (userCompanyCode !== "*") {
+ return res.status(403).json({
+ success: false,
+ message: "최고 관리자만 이 기능을 사용할 수 있습니다.",
+ });
+ }
+
+ if (!sourceCompanyCode || !targetCompanyCode) {
+ return res.status(400).json({
+ success: false,
+ message: "sourceCompanyCode와 targetCompanyCode가 필요합니다.",
+ });
+ }
+
+ if (!screenIdMap || typeof screenIdMap !== "object") {
+ return res.status(400).json({
+ success: false,
+ message: "screenIdMap 객체가 필요합니다.",
+ });
+ }
+
+ const result = await screenManagementService.copyScreenMenuAssignments(
+ sourceCompanyCode,
+ targetCompanyCode,
+ screenIdMap
+ );
+
+ return res.json({
+ success: true,
+ message: `화면-메뉴 할당 ${result.copiedCount}개 복제 완료`,
+ data: result,
+ });
+ } catch (error) {
+ console.error("화면-메뉴 할당 복제 실패:", error);
+ return res.status(500).json({
+ success: false,
+ message: "화면-메뉴 할당 복제에 실패했습니다.",
+ });
+ }
+};
+
+// 코드 카테고리 + 코드 복제
+export const copyCodeCategoryAndCodes = async (
+ req: AuthenticatedRequest,
+ res: Response
+) => {
+ try {
+ const { sourceCompanyCode, targetCompanyCode } = req.body;
+ const userCompanyCode = req.user?.companyCode;
+
+ if (userCompanyCode !== "*") {
+ return res.status(403).json({
+ success: false,
+ message: "최고 관리자만 이 기능을 사용할 수 있습니다.",
+ });
+ }
+
+ if (!sourceCompanyCode || !targetCompanyCode) {
+ return res.status(400).json({
+ success: false,
+ message: "sourceCompanyCode와 targetCompanyCode가 필요합니다.",
+ });
+ }
+
+ const result = await screenManagementService.copyCodeCategoryAndCodes(
+ sourceCompanyCode,
+ targetCompanyCode
+ );
+
+ return res.json({
+ success: true,
+ message: `코드 카테고리 ${result.copiedCategories}개, 코드 ${result.copiedCodes}개 복제 완료`,
+ data: result,
+ });
+ } catch (error) {
+ console.error("코드 카테고리/코드 복제 실패:", error);
+ return res.status(500).json({
+ success: false,
+ message: "코드 카테고리/코드 복제에 실패했습니다.",
+ });
+ }
+};
+
+// 카테고리 매핑 + 값 복제
+export const copyCategoryMapping = async (
+ req: AuthenticatedRequest,
+ res: Response
+) => {
+ try {
+ const { sourceCompanyCode, targetCompanyCode } = req.body;
+ const userCompanyCode = req.user?.companyCode;
+
+ if (userCompanyCode !== "*") {
+ return res.status(403).json({
+ success: false,
+ message: "최고 관리자만 이 기능을 사용할 수 있습니다.",
+ });
+ }
+
+ if (!sourceCompanyCode || !targetCompanyCode) {
+ return res.status(400).json({
+ success: false,
+ message: "sourceCompanyCode와 targetCompanyCode가 필요합니다.",
+ });
+ }
+
+ const result = await screenManagementService.copyCategoryMapping(
+ sourceCompanyCode,
+ targetCompanyCode
+ );
+
+ return res.json({
+ success: true,
+ message: `카테고리 매핑 ${result.copiedMappings}개, 값 ${result.copiedValues}개 복제 완료`,
+ data: result,
+ });
+ } catch (error) {
+ console.error("카테고리 매핑/값 복제 실패:", error);
+ return res.status(500).json({
+ success: false,
+ message: "카테고리 매핑/값 복제에 실패했습니다.",
+ });
+ }
+};
+
+// 테이블 타입관리 입력타입 설정 복제
+export const copyTableTypeColumns = async (
+ req: AuthenticatedRequest,
+ res: Response
+) => {
+ try {
+ const { sourceCompanyCode, targetCompanyCode } = req.body;
+ const userCompanyCode = req.user?.companyCode;
+
+ if (userCompanyCode !== "*") {
+ return res.status(403).json({
+ success: false,
+ message: "최고 관리자만 이 기능을 사용할 수 있습니다.",
+ });
+ }
+
+ if (!sourceCompanyCode || !targetCompanyCode) {
+ return res.status(400).json({
+ success: false,
+ message: "sourceCompanyCode와 targetCompanyCode가 필요합니다.",
+ });
+ }
+
+ const result = await screenManagementService.copyTableTypeColumns(
+ sourceCompanyCode,
+ targetCompanyCode
+ );
+
+ return res.json({
+ success: true,
+ message: `테이블 타입 컬럼 ${result.copiedCount}개 복제 완료`,
+ data: result,
+ });
+ } catch (error) {
+ console.error("테이블 타입 컬럼 복제 실패:", error);
+ return res.status(500).json({
+ success: false,
+ message: "테이블 타입 컬럼 복제에 실패했습니다.",
+ });
+ }
+};
+
+// 연쇄관계 설정 복제
+export const copyCascadingRelation = async (
+ req: AuthenticatedRequest,
+ res: Response
+) => {
+ try {
+ const { sourceCompanyCode, targetCompanyCode } = req.body;
+ const userCompanyCode = req.user?.companyCode;
+
+ if (userCompanyCode !== "*") {
+ return res.status(403).json({
+ success: false,
+ message: "최고 관리자만 이 기능을 사용할 수 있습니다.",
+ });
+ }
+
+ if (!sourceCompanyCode || !targetCompanyCode) {
+ return res.status(400).json({
+ success: false,
+ message: "sourceCompanyCode와 targetCompanyCode가 필요합니다.",
+ });
+ }
+
+ const result = await screenManagementService.copyCascadingRelation(
+ sourceCompanyCode,
+ targetCompanyCode
+ );
+
+ return res.json({
+ success: true,
+ message: `연쇄관계 설정 ${result.copiedCount}개 복제 완료`,
+ data: result,
+ });
+ } catch (error) {
+ console.error("연쇄관계 설정 복제 실패:", error);
+ return res.status(500).json({
+ success: false,
+ message: "연쇄관계 설정 복제에 실패했습니다.",
+ });
+ }
+};
diff --git a/backend-node/src/routes/screenManagementRoutes.ts b/backend-node/src/routes/screenManagementRoutes.ts
index 67263277..e8dc402a 100644
--- a/backend-node/src/routes/screenManagementRoutes.ts
+++ b/backend-node/src/routes/screenManagementRoutes.ts
@@ -29,6 +29,12 @@ import {
getScreensByMenu,
unassignScreenFromMenu,
cleanupDeletedScreenMenuAssignments,
+ updateTabScreenReferences,
+ copyScreenMenuAssignments,
+ copyCodeCategoryAndCodes,
+ copyCategoryMapping,
+ copyTableTypeColumns,
+ copyCascadingRelation,
} from "../controllers/screenManagementController";
const router = express.Router();
@@ -83,4 +89,22 @@ router.post(
cleanupDeletedScreenMenuAssignments
);
+// 그룹 복제 완료 후 탭 컴포넌트의 screenId 참조 일괄 업데이트
+router.post("/screens/update-tab-references", updateTabScreenReferences);
+
+// 화면-메뉴 할당 복제 (다른 회사로 복제 시)
+router.post("/copy-menu-assignments", copyScreenMenuAssignments);
+
+// 코드 카테고리 + 코드 복제
+router.post("/copy-code-category", copyCodeCategoryAndCodes);
+
+// 카테고리 매핑 + 값 복제
+router.post("/copy-category-mapping", copyCategoryMapping);
+
+// 테이블 타입 컬럼 복제
+router.post("/copy-table-type-columns", copyTableTypeColumns);
+
+// 연쇄관계 설정 복제
+router.post("/copy-cascading-relation", copyCascadingRelation);
+
export default router;
diff --git a/backend-node/src/services/menuCopyService.ts b/backend-node/src/services/menuCopyService.ts
index a163f30c..1e65cddd 100644
--- a/backend-node/src/services/menuCopyService.ts
+++ b/backend-node/src/services/menuCopyService.ts
@@ -16,6 +16,8 @@ export interface MenuCopyResult {
copiedCategoryMappings: number;
copiedTableTypeColumns: number; // 테이블 타입관리 입력타입 설정
copiedCascadingRelations: number; // 연쇄관계 설정
+ copiedNodeFlows: number; // 노드 플로우 (제어관리)
+ copiedDataflowDiagrams: number; // 데이터플로우 다이어그램 (버튼 제어)
menuIdMap: Record;
screenIdMap: Record;
flowIdMap: Record;
@@ -983,6 +985,14 @@ export class MenuCopyService {
client
);
+ // === 2.1단계: 노드 플로우 복사는 화면 복사에서 처리 ===
+ // (screenManagementService.ts의 copyScreen에서 처리)
+ const copiedNodeFlows = 0;
+
+ // === 2.2단계: 데이터플로우 다이어그램 복사는 화면 복사에서 처리 ===
+ // (screenManagementService.ts의 copyScreen에서 처리)
+ const copiedDataflowDiagrams = 0;
+
// 변수 초기화
let copiedCodeCategories = 0;
let copiedCodes = 0;
@@ -1132,6 +1142,8 @@ export class MenuCopyService {
copiedCategoryMappings,
copiedTableTypeColumns,
copiedCascadingRelations,
+ copiedNodeFlows,
+ copiedDataflowDiagrams,
menuIdMap: Object.fromEntries(menuIdMap),
screenIdMap: Object.fromEntries(screenIdMap),
flowIdMap: Object.fromEntries(flowIdMap),
@@ -1144,6 +1156,8 @@ export class MenuCopyService {
- 메뉴: ${result.copiedMenus}개
- 화면: ${result.copiedScreens}개
- 플로우: ${result.copiedFlows}개
+ - 노드 플로우(제어관리): ${copiedNodeFlows}개
+ - 데이터플로우 다이어그램(버튼 제어): ${copiedDataflowDiagrams}개
- 코드 카테고리: ${copiedCodeCategories}개
- 코드: ${copiedCodes}개
- 채번규칙: ${copiedNumberingRules}개
@@ -2556,33 +2570,34 @@ export class MenuCopyService {
}
// 4. 배치 INSERT로 채번 규칙 복사
- if (rulesToCopy.length > 0) {
- const ruleValues = rulesToCopy
+ // menu 스코프인데 menu_objid 매핑이 없는 규칙은 제외 (연결 없이 복제하지 않음)
+ const validRulesToCopy = rulesToCopy.filter((r) => {
+ if (r.scope_type === "menu") {
+ const newMenuObjid = menuIdMap.get(r.menu_objid);
+ if (newMenuObjid === undefined) {
+ logger.info(` ⏭️ 채번규칙 "${r.rule_name}" 건너뜀: 메뉴 연결 없음 (원본 menu_objid: ${r.menu_objid})`);
+ // ruleIdMap에서도 제거
+ ruleIdMap.delete(r.rule_id);
+ return false; // 복제 대상에서 제외
+ }
+ }
+ return true;
+ });
+
+ if (validRulesToCopy.length > 0) {
+ const ruleValues = validRulesToCopy
.map(
(_, i) =>
`($${i * 13 + 1}, $${i * 13 + 2}, $${i * 13 + 3}, $${i * 13 + 4}, $${i * 13 + 5}, $${i * 13 + 6}, $${i * 13 + 7}, $${i * 13 + 8}, $${i * 13 + 9}, NOW(), $${i * 13 + 10}, $${i * 13 + 11}, $${i * 13 + 12}, $${i * 13 + 13})`
)
.join(", ");
- const ruleParams = rulesToCopy.flatMap((r) => {
+ const ruleParams = validRulesToCopy.flatMap((r) => {
const newMenuObjid = menuIdMap.get(r.menu_objid);
- // scope_type = 'menu'인 경우 menu_objid가 반드시 필요함 (check 제약조건)
- // menuIdMap에 없으면 원본 menu_objid가 복사된 메뉴 범위 밖이므로
- // scope_type을 'table'로 변경하거나, 매핑이 없으면 null 처리
+ // menu 스코프인 경우 반드시 menu_objid가 있음 (위에서 필터링됨)
const finalMenuObjid = newMenuObjid !== undefined ? newMenuObjid : null;
- // scope_type 결정 로직:
- // 1. menu 스코프인데 menu_objid 매핑이 없는 경우
- // - table_name이 있으면 'table' 스코프로 변경
- // - table_name이 없으면 'global' 스코프로 변경
- // 2. 그 외에는 원본 scope_type 유지
- let finalScopeType = r.scope_type;
- if (r.scope_type === "menu" && finalMenuObjid === null) {
- if (r.table_name) {
- finalScopeType = "table"; // table_name이 있으면 table 스코프
- } else {
- finalScopeType = "global"; // table_name도 없으면 global 스코프
- }
- }
+ // scope_type은 원본 유지 (menu 스코프는 반드시 menu_objid가 있으므로)
+ const finalScopeType = r.scope_type;
return [
r.newRuleId,
@@ -2610,8 +2625,8 @@ export class MenuCopyService {
ruleParams
);
- copiedCount = rulesToCopy.length;
- logger.info(` ✅ 채번 규칙 ${copiedCount}개 복사`);
+ copiedCount = validRulesToCopy.length;
+ logger.info(` ✅ 채번 규칙 ${copiedCount}개 복사 (${rulesToCopy.length - validRulesToCopy.length}개 건너뜀)`);
}
// 4-1. 기존 채번 규칙의 menu_objid 업데이트 (새 메뉴와 연결) - 배치 처리
@@ -3324,4 +3339,175 @@ export class MenuCopyService {
logger.info(`✅ 연쇄관계 복사 완료: ${copiedCount}개`);
return copiedCount;
}
+
+ /**
+ * 노드 플로우 복사 (node_flows 테이블 - 제어관리에서 사용)
+ * - 원본 회사의 모든 node_flows를 대상 회사로 복사
+ * - 대상 회사에 같은 이름의 노드 플로우가 있으면 재사용
+ * - 없으면 새로 복사 (flow_data 포함)
+ * - 원본 ID → 새 ID 매핑 반환 (버튼의 flowId, selectedDiagramId 매핑용)
+ */
+ private async copyNodeFlows(
+ sourceCompanyCode: string,
+ targetCompanyCode: string,
+ client: PoolClient
+ ): Promise<{ copiedCount: number; nodeFlowIdMap: Map }> {
+ logger.info(`📋 노드 플로우(제어관리) 복사 시작`);
+ const nodeFlowIdMap = new Map();
+ let copiedCount = 0;
+
+ // 1. 원본 회사의 모든 node_flows 조회
+ const sourceFlowsResult = await client.query(
+ `SELECT * FROM node_flows WHERE company_code = $1`,
+ [sourceCompanyCode]
+ );
+
+ if (sourceFlowsResult.rows.length === 0) {
+ logger.info(` 📭 원본 회사에 노드 플로우 없음`);
+ return { copiedCount: 0, nodeFlowIdMap };
+ }
+
+ logger.info(` 📋 원본 노드 플로우: ${sourceFlowsResult.rows.length}개`);
+
+ // 2. 대상 회사의 기존 노드 플로우 조회 (이름 기준)
+ const existingFlowsResult = await client.query(
+ `SELECT flow_id, flow_name FROM node_flows WHERE company_code = $1`,
+ [targetCompanyCode]
+ );
+ const existingFlowsByName = new Map(
+ existingFlowsResult.rows.map((f) => [f.flow_name, f.flow_id])
+ );
+
+ // 3. 복사할 플로우 필터링 + 기존 플로우 매핑
+ const flowsToCopy: any[] = [];
+ for (const flow of sourceFlowsResult.rows) {
+ const existingId = existingFlowsByName.get(flow.flow_name);
+ if (existingId) {
+ // 기존 플로우 재사용 - ID 매핑 추가
+ nodeFlowIdMap.set(flow.flow_id, existingId);
+ logger.info(` ♻️ 기존 노드 플로우 재사용: ${flow.flow_name} (${flow.flow_id} → ${existingId})`);
+ } else {
+ flowsToCopy.push(flow);
+ }
+ }
+
+ if (flowsToCopy.length === 0) {
+ logger.info(` 📭 모든 노드 플로우가 이미 존재함 (매핑 ${nodeFlowIdMap.size}개)`);
+ return { copiedCount: 0, nodeFlowIdMap };
+ }
+
+ logger.info(` 🔄 복사할 노드 플로우: ${flowsToCopy.length}개`);
+
+ // 4. 개별 INSERT (RETURNING으로 새 ID 획득)
+ for (const flow of flowsToCopy) {
+ const insertResult = await client.query(
+ `INSERT INTO node_flows (flow_name, flow_description, flow_data, company_code)
+ VALUES ($1, $2, $3, $4)
+ RETURNING flow_id`,
+ [
+ flow.flow_name,
+ flow.flow_description,
+ JSON.stringify(flow.flow_data),
+ targetCompanyCode,
+ ]
+ );
+
+ const newFlowId = insertResult.rows[0].flow_id;
+ nodeFlowIdMap.set(flow.flow_id, newFlowId);
+ logger.info(` ➕ 노드 플로우 복사: ${flow.flow_name} (${flow.flow_id} → ${newFlowId})`);
+ copiedCount++;
+ }
+
+ logger.info(` ✅ 노드 플로우 복사 완료: ${copiedCount}개, 매핑 ${nodeFlowIdMap.size}개`);
+
+ return { copiedCount, nodeFlowIdMap };
+ }
+
+ /**
+ * 데이터플로우 다이어그램 복사 (dataflow_diagrams 테이블 - 버튼 제어 설정에서 사용)
+ * - 원본 회사의 모든 dataflow_diagrams를 대상 회사로 복사
+ * - 대상 회사에 같은 이름의 다이어그램이 있으면 재사용
+ * - 없으면 새로 복사 (relationships, node_positions, control, plan, category 포함)
+ * - 원본 ID → 새 ID 매핑 반환
+ */
+ private async copyDataflowDiagrams(
+ sourceCompanyCode: string,
+ targetCompanyCode: string,
+ userId: string,
+ client: PoolClient
+ ): Promise<{ copiedCount: number; diagramIdMap: Map }> {
+ logger.info(`📋 데이터플로우 다이어그램(버튼 제어) 복사 시작`);
+ const diagramIdMap = new Map();
+ let copiedCount = 0;
+
+ // 1. 원본 회사의 모든 dataflow_diagrams 조회
+ const sourceDiagramsResult = await client.query(
+ `SELECT * FROM dataflow_diagrams WHERE company_code = $1`,
+ [sourceCompanyCode]
+ );
+
+ if (sourceDiagramsResult.rows.length === 0) {
+ logger.info(` 📭 원본 회사에 데이터플로우 다이어그램 없음`);
+ return { copiedCount: 0, diagramIdMap };
+ }
+
+ logger.info(` 📋 원본 데이터플로우 다이어그램: ${sourceDiagramsResult.rows.length}개`);
+
+ // 2. 대상 회사의 기존 다이어그램 조회 (이름 기준)
+ const existingDiagramsResult = await client.query(
+ `SELECT diagram_id, diagram_name FROM dataflow_diagrams WHERE company_code = $1`,
+ [targetCompanyCode]
+ );
+ const existingDiagramsByName = new Map(
+ existingDiagramsResult.rows.map((d) => [d.diagram_name, d.diagram_id])
+ );
+
+ // 3. 복사할 다이어그램 필터링 + 기존 다이어그램 매핑
+ const diagramsToCopy: any[] = [];
+ for (const diagram of sourceDiagramsResult.rows) {
+ const existingId = existingDiagramsByName.get(diagram.diagram_name);
+ if (existingId) {
+ // 기존 다이어그램 재사용 - ID 매핑 추가
+ diagramIdMap.set(diagram.diagram_id, existingId);
+ logger.info(` ♻️ 기존 다이어그램 재사용: ${diagram.diagram_name} (${diagram.diagram_id} → ${existingId})`);
+ } else {
+ diagramsToCopy.push(diagram);
+ }
+ }
+
+ if (diagramsToCopy.length === 0) {
+ logger.info(` 📭 모든 다이어그램이 이미 존재함 (매핑 ${diagramIdMap.size}개)`);
+ return { copiedCount: 0, diagramIdMap };
+ }
+
+ logger.info(` 🔄 복사할 다이어그램: ${diagramsToCopy.length}개`);
+
+ // 4. 개별 INSERT (RETURNING으로 새 ID 획득)
+ for (const diagram of diagramsToCopy) {
+ const insertResult = await client.query(
+ `INSERT INTO dataflow_diagrams (diagram_name, relationships, company_code, created_by, node_positions, control, plan, category)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
+ RETURNING diagram_id`,
+ [
+ diagram.diagram_name,
+ JSON.stringify(diagram.relationships),
+ targetCompanyCode,
+ userId,
+ diagram.node_positions ? JSON.stringify(diagram.node_positions) : null,
+ diagram.control ? JSON.stringify(diagram.control) : null,
+ diagram.plan ? JSON.stringify(diagram.plan) : null,
+ diagram.category ? JSON.stringify(diagram.category) : null,
+ ]
+ );
+
+ const newDiagramId = insertResult.rows[0].diagram_id;
+ diagramIdMap.set(diagram.diagram_id, newDiagramId);
+ logger.info(` ➕ 다이어그램 복사: ${diagram.diagram_name} (${diagram.diagram_id} → ${newDiagramId})`);
+ copiedCount++;
+ }
+
+ logger.info(` ✅ 데이터플로우 다이어그램 복사 완료: ${copiedCount}개, 매핑 ${diagramIdMap.size}개`);
+
+ return { copiedCount, diagramIdMap };
+ }
}
diff --git a/backend-node/src/services/menuScreenSyncService.ts b/backend-node/src/services/menuScreenSyncService.ts
new file mode 100644
index 00000000..3d581a4a
--- /dev/null
+++ b/backend-node/src/services/menuScreenSyncService.ts
@@ -0,0 +1,1021 @@
+import { getPool } from "../database/db";
+import { logger } from "../utils/logger";
+
+const pool = getPool();
+
+/**
+ * 메뉴-화면그룹 동기화 서비스
+ *
+ * 양방향 동기화:
+ * 1. screen_groups → menu_info: 화면관리 폴더 구조를 메뉴로 동기화
+ * 2. menu_info → screen_groups: 사용자 메뉴를 화면관리 폴더로 동기화
+ */
+
+// ============================================================
+// 타입 정의
+// ============================================================
+
+interface SyncResult {
+ success: boolean;
+ created: number;
+ linked: number;
+ skipped: number;
+ errors: string[];
+ details: SyncDetail[];
+}
+
+interface SyncDetail {
+ action: 'created' | 'linked' | 'skipped' | 'error';
+ sourceName: string;
+ sourceId: number | string;
+ targetId?: number | string;
+ reason?: string;
+}
+
+// ============================================================
+// 화면관리 → 메뉴 동기화
+// ============================================================
+
+/**
+ * screen_groups를 menu_info로 동기화
+ *
+ * 로직:
+ * 1. 해당 회사의 screen_groups 조회 (폴더 구조)
+ * 2. 이미 menu_objid가 연결된 것은 제외
+ * 3. 이름으로 기존 menu_info 매칭 시도
+ * - 매칭되면: 양쪽에 연결 ID 업데이트
+ * - 매칭 안되면: menu_info에 새로 생성
+ * 4. 계층 구조(parent) 유지
+ */
+export async function syncScreenGroupsToMenu(
+ companyCode: string,
+ userId: string
+): Promise {
+ const result: SyncResult = {
+ success: true,
+ created: 0,
+ linked: 0,
+ skipped: 0,
+ errors: [],
+ details: [],
+ };
+
+ const client = await pool.connect();
+
+ try {
+ await client.query('BEGIN');
+
+ logger.info("화면관리 → 메뉴 동기화 시작", { companyCode, userId });
+
+ // 1. 해당 회사의 screen_groups 조회 (아직 menu_objid가 없는 것)
+ const screenGroupsQuery = `
+ SELECT
+ sg.id,
+ sg.group_name,
+ sg.group_code,
+ sg.parent_group_id,
+ sg.group_level,
+ sg.display_order,
+ sg.description,
+ sg.icon,
+ sg.menu_objid,
+ -- 부모 그룹의 menu_objid도 조회 (계층 연결용)
+ parent.menu_objid as parent_menu_objid
+ FROM screen_groups sg
+ LEFT JOIN screen_groups parent ON sg.parent_group_id = parent.id
+ WHERE sg.company_code = $1
+ ORDER BY sg.group_level ASC, sg.display_order ASC
+ `;
+ const screenGroupsResult = await client.query(screenGroupsQuery, [companyCode]);
+
+ // 2. 해당 회사의 기존 menu_info 조회 (사용자 메뉴, menu_type=1)
+ // 경로 기반 매칭을 위해 부모 이름도 조회
+ const existingMenusQuery = `
+ SELECT
+ m.objid,
+ m.menu_name_kor,
+ m.parent_obj_id,
+ m.screen_group_id,
+ p.menu_name_kor as parent_name
+ FROM menu_info m
+ LEFT JOIN menu_info p ON m.parent_obj_id = p.objid
+ WHERE m.company_code = $1 AND m.menu_type = 1
+ `;
+ const existingMenusResult = await client.query(existingMenusQuery, [companyCode]);
+
+ // 경로(부모이름 > 이름) → 메뉴 매핑 (screen_group_id가 없는 것만)
+ // 단순 이름 매칭도 유지 (하위 호환)
+ const menuByPath: Map = new Map();
+ const menuByName: Map = new Map();
+ existingMenusResult.rows.forEach((menu: any) => {
+ if (!menu.screen_group_id) {
+ const menuName = menu.menu_name_kor?.trim().toLowerCase() || '';
+ const parentName = menu.parent_name?.trim().toLowerCase() || '';
+ const pathKey = parentName ? `${parentName}>${menuName}` : menuName;
+
+ menuByPath.set(pathKey, menu);
+ // 단순 이름 매핑은 첫 번째 것만 (중복 방지)
+ if (!menuByName.has(menuName)) {
+ menuByName.set(menuName, menu);
+ }
+ }
+ });
+
+ // 모든 메뉴의 objid 집합 (삭제 확인용)
+ const existingMenuObjids = new Set(existingMenusResult.rows.map((m: any) => Number(m.objid)));
+
+ // 3. 사용자 메뉴의 루트 찾기 (parent_obj_id = 0인 사용자 메뉴)
+ // 없으면 생성
+ let userMenuRootObjid: number | null = null;
+ const rootMenuQuery = `
+ SELECT objid FROM menu_info
+ WHERE company_code = $1 AND menu_type = 1 AND parent_obj_id = 0
+ ORDER BY seq ASC
+ LIMIT 1
+ `;
+ const rootMenuResult = await client.query(rootMenuQuery, [companyCode]);
+
+ if (rootMenuResult.rows.length > 0) {
+ userMenuRootObjid = Number(rootMenuResult.rows[0].objid);
+ } else {
+ // 루트 메뉴가 없으면 생성
+ const newObjid = Date.now();
+ const createRootQuery = `
+ INSERT INTO menu_info (objid, parent_obj_id, menu_name_kor, menu_name_eng, seq, menu_type, company_code, writer, regdate, status)
+ VALUES ($1, 0, '사용자', 'User', 1, 1, $2, $3, NOW(), 'active')
+ RETURNING objid
+ `;
+ const createRootResult = await client.query(createRootQuery, [newObjid, companyCode, userId]);
+ userMenuRootObjid = Number(createRootResult.rows[0].objid);
+ logger.info("사용자 메뉴 루트 생성", { companyCode, objid: userMenuRootObjid });
+ }
+
+ // 4. screen_groups ID → menu_objid 매핑 (순차 처리를 위해)
+ const groupToMenuMap: Map = new Map();
+
+ // screen_groups의 부모 이름 조회를 위한 매핑
+ const groupIdToName: Map = new Map();
+ screenGroupsResult.rows.forEach((g: any) => {
+ groupIdToName.set(g.id, g.group_name?.trim().toLowerCase() || '');
+ });
+
+ // 5. 최상위 회사 폴더 ID 찾기 (level 0, parent_group_id IS NULL)
+ // 이 폴더는 메뉴로 생성하지 않고, 하위 폴더들을 사용자 루트 바로 아래에 배치
+ const topLevelCompanyFolderIds = new Set();
+ for (const group of screenGroupsResult.rows) {
+ if (group.group_level === 0 && group.parent_group_id === null) {
+ topLevelCompanyFolderIds.add(group.id);
+ // 최상위 폴더 → 사용자 루트에 매핑 (하위 폴더의 부모로 사용)
+ groupToMenuMap.set(group.id, userMenuRootObjid!);
+ logger.info("최상위 회사 폴더 스킵", { groupId: group.id, groupName: group.group_name });
+ }
+ }
+
+ // 6. 각 screen_group 처리
+ for (const group of screenGroupsResult.rows) {
+ const groupId = group.id;
+ const groupName = group.group_name?.trim();
+ const groupNameLower = groupName?.toLowerCase() || '';
+
+ // 최상위 회사 폴더는 메뉴로 생성하지 않고 스킵
+ if (topLevelCompanyFolderIds.has(groupId)) {
+ result.skipped++;
+ result.details.push({
+ action: 'skipped',
+ sourceName: groupName,
+ sourceId: groupId,
+ reason: '최상위 회사 폴더 (메뉴 생성 스킵)',
+ });
+ continue;
+ }
+
+ // 이미 연결된 경우 - 실제로 메뉴가 존재하는지 확인
+ if (group.menu_objid) {
+ const menuExists = existingMenuObjids.has(Number(group.menu_objid));
+
+ if (menuExists) {
+ // 메뉴가 존재하면 스킵
+ result.skipped++;
+ result.details.push({
+ action: 'skipped',
+ sourceName: groupName,
+ sourceId: groupId,
+ targetId: group.menu_objid,
+ reason: '이미 메뉴와 연결됨',
+ });
+ groupToMenuMap.set(groupId, Number(group.menu_objid));
+ continue;
+ } else {
+ // 메뉴가 삭제되었으면 연결 해제하고 재생성
+ logger.info("삭제된 메뉴 연결 해제", { groupId, deletedMenuObjid: group.menu_objid });
+ await client.query(
+ `UPDATE screen_groups SET menu_objid = NULL, updated_date = NOW() WHERE id = $1`,
+ [groupId]
+ );
+ // 계속 진행하여 재생성 또는 재연결
+ }
+ }
+
+ // 부모 그룹 이름 조회 (경로 기반 매칭용)
+ const parentGroupName = group.parent_group_id ? groupIdToName.get(group.parent_group_id) : '';
+ const pathKey = parentGroupName ? `${parentGroupName}>${groupNameLower}` : groupNameLower;
+
+ // 경로로 기존 메뉴 매칭 시도 (우선순위: 경로 매칭 > 이름 매칭)
+ let matchedMenu = menuByPath.get(pathKey);
+ if (!matchedMenu) {
+ // 경로 매칭 실패시 이름으로 시도 (하위 호환)
+ matchedMenu = menuByName.get(groupNameLower);
+ }
+
+ if (matchedMenu) {
+ // 매칭된 메뉴와 연결
+ const menuObjid = Number(matchedMenu.objid);
+
+ // screen_groups에 menu_objid 업데이트
+ await client.query(
+ `UPDATE screen_groups SET menu_objid = $1, updated_date = NOW() WHERE id = $2`,
+ [menuObjid, groupId]
+ );
+
+ // menu_info에 screen_group_id 업데이트
+ await client.query(
+ `UPDATE menu_info SET screen_group_id = $1 WHERE objid = $2`,
+ [groupId, menuObjid]
+ );
+
+ // 해당 그룹에 연결된 기본 화면으로 URL 항상 업데이트 (화면 재생성 시에도 반영)
+ const defaultScreenQuery = `
+ SELECT sd.screen_id, sd.screen_code, sd.screen_name
+ FROM screen_group_screens sgs
+ JOIN screen_definitions sd ON sgs.screen_id = sd.screen_id
+ WHERE sgs.group_id = $1 AND sgs.company_code = $2
+ ORDER BY
+ CASE WHEN sgs.is_default = 'Y' THEN 0 ELSE 1 END,
+ sgs.display_order ASC
+ LIMIT 1
+ `;
+ const defaultScreenResult = await client.query(defaultScreenQuery, [groupId, companyCode]);
+ if (defaultScreenResult.rows.length > 0) {
+ const defaultScreen = defaultScreenResult.rows[0];
+ const newMenuUrl = `/screens/${defaultScreen.screen_id}`;
+ await client.query(
+ `UPDATE menu_info SET menu_url = $1, screen_code = $2 WHERE objid = $3`,
+ [newMenuUrl, defaultScreen.screen_code, menuObjid]
+ );
+ logger.info("메뉴 URL 업데이트", { groupName, screenId: defaultScreen.screen_id, menuUrl: newMenuUrl });
+ }
+
+ groupToMenuMap.set(groupId, menuObjid);
+ result.linked++;
+ result.details.push({
+ action: 'linked',
+ sourceName: groupName,
+ sourceId: groupId,
+ targetId: menuObjid,
+ });
+
+ // 매칭된 메뉴는 Map에서 제거 (중복 매칭 방지)
+ menuByPath.delete(pathKey);
+ menuByName.delete(groupNameLower);
+
+ } else {
+ // 새 메뉴 생성
+ const newObjid = Date.now() + groupId; // 고유 ID 보장
+
+ // 부모 메뉴 objid 결정
+ // 우선순위: groupToMenuMap > parent_menu_objid (존재 확인 필수)
+ let parentMenuObjid = userMenuRootObjid;
+ if (group.parent_group_id && groupToMenuMap.has(group.parent_group_id)) {
+ // 현재 트랜잭션에서 생성된 부모 메뉴 사용
+ parentMenuObjid = groupToMenuMap.get(group.parent_group_id)!;
+ } else if (group.parent_group_id && group.parent_menu_objid) {
+ // 기존 parent_menu_objid가 실제로 존재하는지 확인
+ const parentMenuExists = existingMenuObjids.has(Number(group.parent_menu_objid));
+ if (parentMenuExists) {
+ parentMenuObjid = Number(group.parent_menu_objid);
+ }
+ }
+
+ // 같은 부모 아래에서 가장 높은 seq 조회 후 +1
+ let nextSeq = 1;
+ const maxSeqQuery = `
+ SELECT COALESCE(MAX(seq), 0) + 1 as next_seq
+ FROM menu_info
+ WHERE parent_obj_id = $1 AND company_code = $2 AND menu_type = 1
+ `;
+ const maxSeqResult = await client.query(maxSeqQuery, [parentMenuObjid, companyCode]);
+ if (maxSeqResult.rows.length > 0) {
+ nextSeq = parseInt(maxSeqResult.rows[0].next_seq) || 1;
+ }
+
+ // 해당 그룹에 연결된 기본 화면 조회 (is_default = 'Y' 우선, 없으면 첫 번째 화면)
+ let menuUrl: string | null = null;
+ let screenCode: string | null = null;
+ const defaultScreenQuery = `
+ SELECT sd.screen_id, sd.screen_code, sd.screen_name
+ FROM screen_group_screens sgs
+ JOIN screen_definitions sd ON sgs.screen_id = sd.screen_id
+ WHERE sgs.group_id = $1 AND sgs.company_code = $2
+ ORDER BY
+ CASE WHEN sgs.is_default = 'Y' THEN 0 ELSE 1 END,
+ sgs.display_order ASC
+ LIMIT 1
+ `;
+ const defaultScreenResult = await client.query(defaultScreenQuery, [groupId, companyCode]);
+ if (defaultScreenResult.rows.length > 0) {
+ const defaultScreen = defaultScreenResult.rows[0];
+ screenCode = defaultScreen.screen_code;
+ menuUrl = `/screens/${defaultScreen.screen_id}`;
+ logger.info("기본 화면 URL 설정", { groupName, screenId: defaultScreen.screen_id, menuUrl });
+ }
+
+ // menu_info에 삽입
+ const insertMenuQuery = `
+ INSERT INTO menu_info (
+ objid, parent_obj_id, menu_name_kor, menu_name_eng,
+ seq, menu_type, company_code, writer, regdate, status, screen_group_id, menu_desc,
+ menu_url, screen_code
+ ) VALUES ($1, $2, $3, $4, $5, 1, $6, $7, NOW(), 'active', $8, $9, $10, $11)
+ RETURNING objid
+ `;
+ await client.query(insertMenuQuery, [
+ newObjid,
+ parentMenuObjid,
+ groupName,
+ group.group_code || groupName,
+ nextSeq,
+ companyCode,
+ userId,
+ groupId,
+ group.description || null,
+ menuUrl,
+ screenCode,
+ ]);
+
+ // screen_groups에 menu_objid 업데이트
+ await client.query(
+ `UPDATE screen_groups SET menu_objid = $1, updated_date = NOW() WHERE id = $2`,
+ [newObjid, groupId]
+ );
+
+ groupToMenuMap.set(groupId, newObjid);
+ result.created++;
+ result.details.push({
+ action: 'created',
+ sourceName: groupName,
+ sourceId: groupId,
+ targetId: newObjid,
+ });
+ }
+ }
+
+ await client.query('COMMIT');
+
+ logger.info("화면관리 → 메뉴 동기화 완료", {
+ companyCode,
+ created: result.created,
+ linked: result.linked,
+ skipped: result.skipped
+ });
+
+ return result;
+
+ } catch (error: any) {
+ await client.query('ROLLBACK');
+ logger.error("화면관리 → 메뉴 동기화 실패", {
+ companyCode,
+ error: error.message,
+ stack: error.stack,
+ code: error.code,
+ detail: error.detail,
+ });
+ result.success = false;
+ result.errors.push(error.message);
+ return result;
+ } finally {
+ client.release();
+ }
+}
+
+
+// ============================================================
+// 메뉴 → 화면관리 동기화
+// ============================================================
+
+/**
+ * menu_info를 screen_groups로 동기화
+ *
+ * 로직:
+ * 1. 해당 회사의 사용자 메뉴(menu_type=1) 조회
+ * 2. 이미 screen_group_id가 연결된 것은 제외
+ * 3. 이름으로 기존 screen_groups 매칭 시도
+ * - 매칭되면: 양쪽에 연결 ID 업데이트
+ * - 매칭 안되면: screen_groups에 새로 생성 (폴더로)
+ * 4. 계층 구조(parent) 유지
+ */
+export async function syncMenuToScreenGroups(
+ companyCode: string,
+ userId: string
+): Promise {
+ const result: SyncResult = {
+ success: true,
+ created: 0,
+ linked: 0,
+ skipped: 0,
+ errors: [],
+ details: [],
+ };
+
+ const client = await pool.connect();
+
+ try {
+ await client.query('BEGIN');
+
+ logger.info("메뉴 → 화면관리 동기화 시작", { companyCode, userId });
+
+ // 0. 회사 이름 조회 (회사 폴더 찾기/생성용)
+ const companyNameQuery = `SELECT company_name FROM company_mng WHERE company_code = $1`;
+ const companyNameResult = await client.query(companyNameQuery, [companyCode]);
+ const companyName = companyNameResult.rows[0]?.company_name || companyCode;
+
+ // 1. 해당 회사의 사용자 메뉴 조회 (menu_type=1)
+ const menusQuery = `
+ SELECT
+ m.objid,
+ m.menu_name_kor,
+ m.menu_name_eng,
+ m.parent_obj_id,
+ m.seq,
+ m.menu_url,
+ m.menu_desc,
+ m.screen_group_id,
+ -- 부모 메뉴의 screen_group_id도 조회 (계층 연결용)
+ parent.screen_group_id as parent_screen_group_id
+ FROM menu_info m
+ LEFT JOIN menu_info parent ON m.parent_obj_id = parent.objid
+ WHERE m.company_code = $1 AND m.menu_type = 1
+ ORDER BY
+ CASE WHEN m.parent_obj_id = 0 THEN 0 ELSE 1 END,
+ m.parent_obj_id,
+ m.seq
+ `;
+ const menusResult = await client.query(menusQuery, [companyCode]);
+
+ // 2. 해당 회사의 기존 screen_groups 조회 (경로 기반 매칭을 위해 부모 이름도 조회)
+ const existingGroupsQuery = `
+ SELECT
+ g.id,
+ g.group_name,
+ g.menu_objid,
+ g.parent_group_id,
+ p.group_name as parent_name
+ FROM screen_groups g
+ LEFT JOIN screen_groups p ON g.parent_group_id = p.id
+ WHERE g.company_code = $1
+ `;
+ const existingGroupsResult = await client.query(existingGroupsQuery, [companyCode]);
+
+ // 경로(부모이름 > 이름) → 그룹 매핑 (menu_objid가 없는 것만)
+ // 단순 이름 매칭도 유지 (하위 호환)
+ const groupByPath: Map = new Map();
+ const groupByName: Map = new Map();
+ existingGroupsResult.rows.forEach((group: any) => {
+ if (!group.menu_objid) {
+ const groupName = group.group_name?.trim().toLowerCase() || '';
+ const parentName = group.parent_name?.trim().toLowerCase() || '';
+ const pathKey = parentName ? `${parentName}>${groupName}` : groupName;
+
+ groupByPath.set(pathKey, group);
+ // 단순 이름 매핑은 첫 번째 것만 (중복 방지)
+ if (!groupByName.has(groupName)) {
+ groupByName.set(groupName, group);
+ }
+ }
+ });
+
+ // 모든 그룹의 id 집합 (삭제 확인용)
+ const existingGroupIds = new Set(existingGroupsResult.rows.map((g: any) => Number(g.id)));
+
+ // 3. 회사 폴더 찾기 또는 생성 (루트 레벨에 회사명으로 된 폴더)
+ let companyFolderId: number | null = null;
+ const companyFolderQuery = `
+ SELECT id FROM screen_groups
+ WHERE company_code = $1 AND parent_group_id IS NULL AND group_level = 0
+ ORDER BY id ASC
+ LIMIT 1
+ `;
+ const companyFolderResult = await client.query(companyFolderQuery, [companyCode]);
+
+ if (companyFolderResult.rows.length > 0) {
+ companyFolderId = companyFolderResult.rows[0].id;
+ logger.info("회사 폴더 발견", { companyCode, companyFolderId, companyName });
+ } else {
+ // 회사 폴더가 없으면 생성
+ // 루트 레벨에서 가장 높은 display_order 조회 후 +1
+ let nextRootOrder = 1;
+ const maxRootOrderQuery = `
+ SELECT COALESCE(MAX(display_order), 0) + 1 as next_order
+ FROM screen_groups
+ WHERE parent_group_id IS NULL
+ `;
+ const maxRootOrderResult = await client.query(maxRootOrderQuery);
+ if (maxRootOrderResult.rows.length > 0) {
+ nextRootOrder = parseInt(maxRootOrderResult.rows[0].next_order) || 1;
+ }
+
+ const createFolderQuery = `
+ INSERT INTO screen_groups (
+ group_name, group_code, parent_group_id, group_level,
+ display_order, company_code, writer, hierarchy_path
+ ) VALUES ($1, $2, NULL, 0, $3, $4, $5, '/')
+ RETURNING id
+ `;
+ const createFolderResult = await client.query(createFolderQuery, [
+ companyName,
+ companyCode.toLowerCase(),
+ nextRootOrder,
+ companyCode,
+ userId,
+ ]);
+ companyFolderId = createFolderResult.rows[0].id;
+
+ // hierarchy_path 업데이트
+ await client.query(
+ `UPDATE screen_groups SET hierarchy_path = $1 WHERE id = $2`,
+ [`/${companyFolderId}/`, companyFolderId]
+ );
+
+ logger.info("회사 폴더 생성", { companyCode, companyFolderId, companyName });
+ }
+
+ // 4. menu_objid → screen_group_id 매핑 (순차 처리를 위해)
+ const menuToGroupMap: Map = new Map();
+
+ // 부모 메뉴 중 이미 screen_group_id가 있는 것 등록
+ menusResult.rows.forEach((menu: any) => {
+ if (menu.screen_group_id) {
+ menuToGroupMap.set(Number(menu.objid), Number(menu.screen_group_id));
+ }
+ });
+
+ // 루트 메뉴(parent_obj_id = 0)의 objid 찾기 → 회사 폴더와 매핑
+ let rootMenuObjid: number | null = null;
+ for (const menu of menusResult.rows) {
+ if (Number(menu.parent_obj_id) === 0) {
+ rootMenuObjid = Number(menu.objid);
+ // 루트 메뉴는 회사 폴더와 연결
+ if (companyFolderId) {
+ menuToGroupMap.set(rootMenuObjid, companyFolderId);
+ }
+ break;
+ }
+ }
+
+ // 5. 각 메뉴 처리
+ for (const menu of menusResult.rows) {
+ const menuObjid = Number(menu.objid);
+ const menuName = menu.menu_name_kor?.trim();
+
+ // 루트 메뉴(parent_obj_id = 0)는 스킵 (이미 회사 폴더와 매핑됨)
+ if (Number(menu.parent_obj_id) === 0) {
+ result.skipped++;
+ result.details.push({
+ action: 'skipped',
+ sourceName: menuName,
+ sourceId: menuObjid,
+ targetId: companyFolderId || undefined,
+ reason: '루트 메뉴 → 회사 폴더와 매핑됨',
+ });
+ continue;
+ }
+
+ // 이미 연결된 경우 - 실제로 그룹이 존재하는지 확인
+ if (menu.screen_group_id) {
+ const groupExists = existingGroupIds.has(Number(menu.screen_group_id));
+
+ if (groupExists) {
+ // 그룹이 존재하면 스킵
+ result.skipped++;
+ result.details.push({
+ action: 'skipped',
+ sourceName: menuName,
+ sourceId: menuObjid,
+ targetId: menu.screen_group_id,
+ reason: '이미 화면그룹과 연결됨',
+ });
+ menuToGroupMap.set(menuObjid, Number(menu.screen_group_id));
+ continue;
+ } else {
+ // 그룹이 삭제되었으면 연결 해제하고 재생성
+ logger.info("삭제된 그룹 연결 해제", { menuObjid, deletedGroupId: menu.screen_group_id });
+ await client.query(
+ `UPDATE menu_info SET screen_group_id = NULL WHERE objid = $1`,
+ [menuObjid]
+ );
+ // 계속 진행하여 재생성 또는 재연결
+ }
+ }
+
+ const menuNameLower = menuName?.toLowerCase() || '';
+
+ // 부모 메뉴 이름 조회 (경로 기반 매칭용)
+ const parentMenu = menusResult.rows.find((m: any) => Number(m.objid) === Number(menu.parent_obj_id));
+ const parentMenuName = parentMenu?.menu_name_kor?.trim().toLowerCase() || '';
+ const pathKey = parentMenuName ? `${parentMenuName}>${menuNameLower}` : menuNameLower;
+
+ // 경로로 기존 그룹 매칭 시도 (우선순위: 경로 매칭 > 이름 매칭)
+ let matchedGroup = groupByPath.get(pathKey);
+ if (!matchedGroup) {
+ // 경로 매칭 실패시 이름으로 시도 (하위 호환)
+ matchedGroup = groupByName.get(menuNameLower);
+ }
+
+ if (matchedGroup) {
+ // 매칭된 그룹과 연결
+ const groupId = Number(matchedGroup.id);
+
+ try {
+ // menu_info에 screen_group_id 업데이트
+ await client.query(
+ `UPDATE menu_info SET screen_group_id = $1 WHERE objid = $2`,
+ [groupId, menuObjid]
+ );
+
+ // screen_groups에 menu_objid 업데이트
+ await client.query(
+ `UPDATE screen_groups SET menu_objid = $1, updated_date = NOW() WHERE id = $2`,
+ [menuObjid, groupId]
+ );
+
+ menuToGroupMap.set(menuObjid, groupId);
+ result.linked++;
+ result.details.push({
+ action: 'linked',
+ sourceName: menuName,
+ sourceId: menuObjid,
+ targetId: groupId,
+ });
+
+ // 매칭된 그룹은 Map에서 제거 (중복 매칭 방지)
+ groupByPath.delete(pathKey);
+ groupByName.delete(menuNameLower);
+ } catch (linkError: any) {
+ logger.error("그룹 연결 중 에러", { menuName, menuObjid, groupId, error: linkError.message, stack: linkError.stack });
+ throw linkError;
+ }
+
+ } else {
+ // 새 screen_group 생성
+ // 부모 그룹 ID 결정
+ let parentGroupId: number | null = null;
+ let groupLevel = 1; // 기본값은 1 (회사 폴더 아래)
+
+ // 우선순위 1: menuToGroupMap에서 부모 메뉴의 새 그룹 ID 조회 (같은 트랜잭션에서 생성된 것)
+ if (menuToGroupMap.has(Number(menu.parent_obj_id))) {
+ parentGroupId = menuToGroupMap.get(Number(menu.parent_obj_id))!;
+ }
+ // 우선순위 2: 부모 메뉴가 루트 메뉴면 회사 폴더 사용
+ else if (Number(menu.parent_obj_id) === rootMenuObjid) {
+ parentGroupId = companyFolderId;
+ }
+ // 우선순위 3: 부모 메뉴의 screen_group_id가 있고, 해당 그룹이 실제로 존재하면 사용
+ else if (menu.parent_screen_group_id && existingGroupIds.has(Number(menu.parent_screen_group_id))) {
+ parentGroupId = Number(menu.parent_screen_group_id);
+ }
+
+ // 부모 그룹의 레벨 조회
+ if (parentGroupId) {
+ const parentLevelQuery = `SELECT group_level FROM screen_groups WHERE id = $1`;
+ const parentLevelResult = await client.query(parentLevelQuery, [parentGroupId]);
+ if (parentLevelResult.rows.length > 0) {
+ groupLevel = (parentLevelResult.rows[0].group_level || 0) + 1;
+ }
+ }
+
+ // 같은 부모 아래에서 가장 높은 display_order 조회 후 +1
+ let nextDisplayOrder = 1;
+ const maxOrderQuery = parentGroupId
+ ? `SELECT COALESCE(MAX(display_order), 0) + 1 as next_order FROM screen_groups WHERE parent_group_id = $1 AND company_code = $2`
+ : `SELECT COALESCE(MAX(display_order), 0) + 1 as next_order FROM screen_groups WHERE parent_group_id IS NULL AND company_code = $1`;
+ const maxOrderParams = parentGroupId ? [parentGroupId, companyCode] : [companyCode];
+ const maxOrderResult = await client.query(maxOrderQuery, maxOrderParams);
+ if (maxOrderResult.rows.length > 0) {
+ nextDisplayOrder = parseInt(maxOrderResult.rows[0].next_order) || 1;
+ }
+
+ // group_code 생성 (영문명 또는 이름 기반)
+ const groupCode = (menu.menu_name_eng || menuName || 'group')
+ .replace(/\s+/g, '_')
+ .toLowerCase()
+ .substring(0, 50);
+
+ // screen_groups에 삽입
+ const insertGroupQuery = `
+ INSERT INTO screen_groups (
+ group_name, group_code, parent_group_id, group_level,
+ display_order, company_code, writer, menu_objid, description
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
+ RETURNING id
+ `;
+
+ let newGroupId: number;
+ try {
+ logger.info("새 그룹 생성 시도", {
+ menuName,
+ menuObjid,
+ groupCode: groupCode + '_' + menuObjid,
+ parentGroupId,
+ groupLevel,
+ nextDisplayOrder,
+ companyCode,
+ });
+
+ const insertResult = await client.query(insertGroupQuery, [
+ menuName,
+ groupCode + '_' + menuObjid, // 고유성 보장
+ parentGroupId,
+ groupLevel,
+ nextDisplayOrder,
+ companyCode,
+ userId,
+ menuObjid,
+ menu.menu_desc || null,
+ ]);
+
+ newGroupId = insertResult.rows[0].id;
+ } catch (insertError: any) {
+ logger.error("그룹 생성 중 에러", {
+ menuName,
+ menuObjid,
+ parentGroupId,
+ groupLevel,
+ error: insertError.message,
+ stack: insertError.stack,
+ code: insertError.code,
+ detail: insertError.detail,
+ });
+ throw insertError;
+ }
+
+ // hierarchy_path 업데이트
+ let hierarchyPath = `/${newGroupId}/`;
+ if (parentGroupId) {
+ const parentPathQuery = `SELECT hierarchy_path FROM screen_groups WHERE id = $1`;
+ const parentPathResult = await client.query(parentPathQuery, [parentGroupId]);
+ if (parentPathResult.rows.length > 0 && parentPathResult.rows[0].hierarchy_path) {
+ hierarchyPath = `${parentPathResult.rows[0].hierarchy_path}${newGroupId}/`.replace('//', '/');
+ }
+ }
+ await client.query(
+ `UPDATE screen_groups SET hierarchy_path = $1 WHERE id = $2`,
+ [hierarchyPath, newGroupId]
+ );
+
+ // menu_info에 screen_group_id 업데이트
+ await client.query(
+ `UPDATE menu_info SET screen_group_id = $1 WHERE objid = $2`,
+ [newGroupId, menuObjid]
+ );
+
+ menuToGroupMap.set(menuObjid, newGroupId);
+ result.created++;
+ result.details.push({
+ action: 'created',
+ sourceName: menuName,
+ sourceId: menuObjid,
+ targetId: newGroupId,
+ });
+ }
+ }
+
+ await client.query('COMMIT');
+
+ logger.info("메뉴 → 화면관리 동기화 완료", {
+ companyCode,
+ created: result.created,
+ linked: result.linked,
+ skipped: result.skipped
+ });
+
+ return result;
+
+ } catch (error: any) {
+ await client.query('ROLLBACK');
+ logger.error("메뉴 → 화면관리 동기화 실패", {
+ companyCode,
+ error: error.message,
+ stack: error.stack,
+ code: error.code,
+ detail: error.detail,
+ });
+ result.success = false;
+ result.errors.push(error.message);
+ return result;
+ } finally {
+ client.release();
+ }
+}
+
+
+// ============================================================
+// 동기화 상태 조회
+// ============================================================
+
+/**
+ * 동기화 상태 조회
+ *
+ * - 연결된 항목 수
+ * - 연결 안 된 항목 수
+ * - 양방향 비교
+ */
+export async function getSyncStatus(companyCode: string): Promise<{
+ screenGroups: { total: number; linked: number; unlinked: number };
+ menuItems: { total: number; linked: number; unlinked: number };
+ potentialMatches: Array<{ menuName: string; groupName: string; similarity: string }>;
+}> {
+ // screen_groups 상태
+ const sgQuery = `
+ SELECT
+ COUNT(*) as total,
+ COUNT(menu_objid) as linked
+ FROM screen_groups
+ WHERE company_code = $1
+ `;
+ const sgResult = await pool.query(sgQuery, [companyCode]);
+
+ // menu_info 상태 (사용자 메뉴만, 루트 제외)
+ const menuQuery = `
+ SELECT
+ COUNT(*) as total,
+ COUNT(screen_group_id) as linked
+ FROM menu_info
+ WHERE company_code = $1 AND menu_type = 1 AND parent_obj_id != 0
+ `;
+ const menuResult = await pool.query(menuQuery, [companyCode]);
+
+ // 이름이 같은 잠재적 매칭 후보 조회
+ const matchQuery = `
+ SELECT
+ m.menu_name_kor as menu_name,
+ sg.group_name
+ FROM menu_info m
+ JOIN screen_groups sg ON LOWER(TRIM(m.menu_name_kor)) = LOWER(TRIM(sg.group_name))
+ WHERE m.company_code = $1
+ AND sg.company_code = $1
+ AND m.menu_type = 1
+ AND m.screen_group_id IS NULL
+ AND sg.menu_objid IS NULL
+ LIMIT 10
+ `;
+ const matchResult = await pool.query(matchQuery, [companyCode]);
+
+ const sgTotal = parseInt(sgResult.rows[0].total);
+ const sgLinked = parseInt(sgResult.rows[0].linked);
+ const menuTotal = parseInt(menuResult.rows[0].total);
+ const menuLinked = parseInt(menuResult.rows[0].linked);
+
+ return {
+ screenGroups: {
+ total: sgTotal,
+ linked: sgLinked,
+ unlinked: sgTotal - sgLinked,
+ },
+ menuItems: {
+ total: menuTotal,
+ linked: menuLinked,
+ unlinked: menuTotal - menuLinked,
+ },
+ potentialMatches: matchResult.rows.map((row: any) => ({
+ menuName: row.menu_name,
+ groupName: row.group_name,
+ similarity: 'exact',
+ })),
+ };
+}
+
+
+// ============================================================
+// 전체 동기화 (모든 회사)
+// ============================================================
+
+interface AllCompaniesSyncResult {
+ success: boolean;
+ totalCompanies: number;
+ successCount: number;
+ failedCount: number;
+ results: Array<{
+ companyCode: string;
+ companyName: string;
+ direction: 'screens-to-menus' | 'menus-to-screens';
+ created: number;
+ linked: number;
+ skipped: number;
+ success: boolean;
+ error?: string;
+ }>;
+}
+
+/**
+ * 모든 회사에 대해 양방향 동기화 수행
+ *
+ * 로직:
+ * 1. 모든 회사 조회
+ * 2. 각 회사별로 양방향 동기화 수행
+ * - 화면관리 → 메뉴 동기화
+ * - 메뉴 → 화면관리 동기화
+ * 3. 결과 집계
+ */
+export async function syncAllCompanies(
+ userId: string
+): Promise {
+ const result: AllCompaniesSyncResult = {
+ success: true,
+ totalCompanies: 0,
+ successCount: 0,
+ failedCount: 0,
+ results: [],
+ };
+
+ try {
+ logger.info("전체 동기화 시작", { userId });
+
+ // 모든 회사 조회 (최고 관리자 전용 회사 제외)
+ const companiesQuery = `
+ SELECT company_code, company_name
+ FROM company_mng
+ WHERE company_code != '*'
+ ORDER BY company_name
+ `;
+ const companiesResult = await pool.query(companiesQuery);
+
+ result.totalCompanies = companiesResult.rows.length;
+
+ // 각 회사별로 양방향 동기화
+ for (const company of companiesResult.rows) {
+ const companyCode = company.company_code;
+ const companyName = company.company_name;
+
+ try {
+ // 1. 화면관리 → 메뉴 동기화
+ const screensToMenusResult = await syncScreenGroupsToMenu(companyCode, userId);
+ result.results.push({
+ companyCode,
+ companyName,
+ direction: 'screens-to-menus',
+ created: screensToMenusResult.created,
+ linked: screensToMenusResult.linked,
+ skipped: screensToMenusResult.skipped,
+ success: screensToMenusResult.success,
+ error: screensToMenusResult.errors.length > 0 ? screensToMenusResult.errors.join(', ') : undefined,
+ });
+
+ // 2. 메뉴 → 화면관리 동기화
+ const menusToScreensResult = await syncMenuToScreenGroups(companyCode, userId);
+ result.results.push({
+ companyCode,
+ companyName,
+ direction: 'menus-to-screens',
+ created: menusToScreensResult.created,
+ linked: menusToScreensResult.linked,
+ skipped: menusToScreensResult.skipped,
+ success: menusToScreensResult.success,
+ error: menusToScreensResult.errors.length > 0 ? menusToScreensResult.errors.join(', ') : undefined,
+ });
+
+ if (screensToMenusResult.success && menusToScreensResult.success) {
+ result.successCount++;
+ } else {
+ result.failedCount++;
+ }
+
+ } catch (error: any) {
+ logger.error("회사 동기화 실패", { companyCode, companyName, error: error.message });
+ result.results.push({
+ companyCode,
+ companyName,
+ direction: 'screens-to-menus',
+ created: 0,
+ linked: 0,
+ skipped: 0,
+ success: false,
+ error: error.message,
+ });
+ result.failedCount++;
+ }
+ }
+
+ logger.info("전체 동기화 완료", {
+ totalCompanies: result.totalCompanies,
+ successCount: result.successCount,
+ failedCount: result.failedCount,
+ });
+
+ return result;
+
+ } catch (error: any) {
+ logger.error("전체 동기화 실패", { error: error.message });
+ result.success = false;
+ return result;
+ }
+}
+
diff --git a/backend-node/src/services/numberingRuleService.ts b/backend-node/src/services/numberingRuleService.ts
index 8208ecc5..381fc907 100644
--- a/backend-node/src/services/numberingRuleService.ts
+++ b/backend-node/src/services/numberingRuleService.ts
@@ -854,7 +854,13 @@ class NumberingRuleService {
return { ...ruleResult.rows[0], parts };
} catch (error: any) {
await client.query("ROLLBACK");
- logger.error("채번 규칙 수정 실패", { error: error.message });
+ logger.error("채번 규칙 수정 실패", {
+ ruleId,
+ companyCode,
+ error: error.message,
+ stack: error.stack,
+ updates
+ });
throw error;
} finally {
client.release();
@@ -1062,6 +1068,203 @@ class NumberingRuleService {
);
logger.info("시퀀스 초기화 완료", { ruleId, companyCode });
}
+
+ /**
+ * 회사별 채번규칙 복제 (메뉴 동기화 완료 후 호출)
+ * 메뉴 이름을 기준으로 채번규칙을 대상 회사의 메뉴에 연결
+ * 복제 후 화면 레이아웃의 numberingRuleId 참조도 업데이트
+ */
+ async copyRulesForCompany(
+ sourceCompanyCode: string,
+ targetCompanyCode: string
+ ): Promise<{ copiedCount: number; skippedCount: number; details: string[]; ruleIdMap: Record }> {
+ const pool = getPool();
+ const client = await pool.connect();
+
+ const result = { copiedCount: 0, skippedCount: 0, details: [] as string[], ruleIdMap: {} as Record };
+
+ try {
+ await client.query("BEGIN");
+
+ // 1. 원본 회사의 채번규칙 조회 (menu + table 스코프 모두)
+ const sourceRulesResult = await client.query(
+ `SELECT nr.*, mi.menu_name_kor as source_menu_name
+ FROM numbering_rules nr
+ LEFT JOIN menu_info mi ON nr.menu_objid = mi.objid
+ WHERE nr.company_code = $1 AND nr.scope_type IN ('menu', 'table')`,
+ [sourceCompanyCode]
+ );
+
+ logger.info("원본 채번규칙 조회", {
+ sourceCompanyCode,
+ count: sourceRulesResult.rowCount
+ });
+
+ // 2. 각 채번규칙 복제
+ for (const rule of sourceRulesResult.rows) {
+ // 새 rule_id 생성
+ const newRuleId = `rule-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
+
+ // 이미 존재하는지 확인 (이름 기반)
+ const existsCheck = await client.query(
+ `SELECT rule_id FROM numbering_rules
+ WHERE company_code = $1 AND rule_name = $2`,
+ [targetCompanyCode, rule.rule_name]
+ );
+
+ if (existsCheck.rows.length > 0) {
+ // 이미 존재하면 매핑만 추가
+ result.ruleIdMap[rule.rule_id] = existsCheck.rows[0].rule_id;
+ result.skippedCount++;
+ result.details.push(`건너뜀 (이미 존재): ${rule.rule_name}`);
+ continue;
+ }
+
+ let targetMenuObjid = null;
+
+ // menu 스코프인 경우 대상 메뉴 찾기
+ if (rule.scope_type === 'menu' && rule.source_menu_name) {
+ const targetMenuResult = await client.query(
+ `SELECT objid FROM menu_info
+ WHERE company_code = $1 AND menu_name_kor = $2
+ LIMIT 1`,
+ [targetCompanyCode, rule.source_menu_name]
+ );
+
+ if (targetMenuResult.rows.length === 0) {
+ result.skippedCount++;
+ result.details.push(`건너뜀 (메뉴 없음): ${rule.rule_name} - 메뉴: ${rule.source_menu_name}`);
+ continue;
+ }
+
+ targetMenuObjid = targetMenuResult.rows[0].objid;
+ }
+
+ // 채번규칙 복제
+ await client.query(
+ `INSERT INTO numbering_rules (
+ rule_id, rule_name, description, separator, reset_period,
+ current_sequence, table_name, column_name, company_code,
+ created_at, updated_at, created_by, scope_type, menu_objid
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW(), $10, $11, $12)`,
+ [
+ newRuleId,
+ rule.rule_name,
+ rule.description,
+ rule.separator,
+ rule.reset_period,
+ 0, // 시퀀스 초기화
+ rule.table_name,
+ rule.column_name,
+ targetCompanyCode,
+ rule.created_by,
+ rule.scope_type,
+ targetMenuObjid,
+ ]
+ );
+
+ // 채번규칙 파트 복제
+ const partsResult = await client.query(
+ `SELECT * FROM numbering_rule_parts WHERE rule_id = $1 ORDER BY part_order`,
+ [rule.rule_id]
+ );
+
+ for (const part of partsResult.rows) {
+ await client.query(
+ `INSERT INTO numbering_rule_parts (
+ rule_id, part_order, part_type, generation_method,
+ auto_config, manual_config, company_code, created_at
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())`,
+ [
+ newRuleId,
+ part.part_order,
+ part.part_type,
+ part.generation_method,
+ part.auto_config ? JSON.stringify(part.auto_config) : null,
+ part.manual_config ? JSON.stringify(part.manual_config) : null,
+ targetCompanyCode,
+ ]
+ );
+ }
+
+ // 매핑 추가
+ result.ruleIdMap[rule.rule_id] = newRuleId;
+ result.copiedCount++;
+ result.details.push(`복제 완료: ${rule.rule_name} (${rule.scope_type})`);
+ logger.info("채번규칙 복제 완료", {
+ ruleName: rule.rule_name,
+ oldRuleId: rule.rule_id,
+ newRuleId,
+ targetMenuObjid
+ });
+ }
+
+ // 3. 화면 레이아웃의 numberingRuleId 참조 업데이트
+ if (Object.keys(result.ruleIdMap).length > 0) {
+ logger.info("화면 레이아웃 numberingRuleId 참조 업데이트 시작", {
+ targetCompanyCode,
+ mappingCount: Object.keys(result.ruleIdMap).length
+ });
+
+ // 대상 회사의 모든 화면 레이아웃 조회
+ const layoutsResult = await client.query(
+ `SELECT sl.layout_id, sl.properties
+ FROM screen_layouts sl
+ JOIN screen_definitions sd ON sl.screen_id = sd.screen_id
+ WHERE sd.company_code = $1
+ AND sl.properties::text LIKE '%numberingRuleId%'`,
+ [targetCompanyCode]
+ );
+
+ let updatedLayouts = 0;
+
+ for (const layout of layoutsResult.rows) {
+ let propsStr = JSON.stringify(layout.properties);
+ let updated = false;
+
+ // 각 매핑에 대해 치환
+ for (const [oldRuleId, newRuleId] of Object.entries(result.ruleIdMap)) {
+ if (propsStr.includes(`"${oldRuleId}"`)) {
+ propsStr = propsStr.split(`"${oldRuleId}"`).join(`"${newRuleId}"`);
+ updated = true;
+ }
+ }
+
+ if (updated) {
+ await client.query(
+ `UPDATE screen_layouts SET properties = $1::jsonb WHERE layout_id = $2`,
+ [propsStr, layout.layout_id]
+ );
+ updatedLayouts++;
+ }
+ }
+
+ logger.info("화면 레이아웃 numberingRuleId 참조 업데이트 완료", {
+ targetCompanyCode,
+ updatedLayouts
+ });
+ result.details.push(`화면 레이아웃 ${updatedLayouts}개의 채번규칙 참조 업데이트`);
+ }
+
+ await client.query("COMMIT");
+
+ logger.info("회사별 채번규칙 복제 완료", {
+ sourceCompanyCode,
+ targetCompanyCode,
+ copiedCount: result.copiedCount,
+ skippedCount: result.skippedCount,
+ ruleIdMapCount: Object.keys(result.ruleIdMap).length
+ });
+
+ return result;
+ } catch (error) {
+ await client.query("ROLLBACK");
+ logger.error("회사별 채번규칙 복제 실패", { error, sourceCompanyCode, targetCompanyCode });
+ throw error;
+ } finally {
+ client.release();
+ }
+ }
}
export const numberingRuleService = new NumberingRuleService();
diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts
index 783e83c0..15c0e1f5 100644
--- a/backend-node/src/services/screenManagementService.ts
+++ b/backend-node/src/services/screenManagementService.ts
@@ -2549,6 +2549,730 @@ export class ScreenManagementService {
}));
}
+ /**
+ * 화면 레이아웃에서 사용하는 numberingRuleId 수집
+ * - componentConfig.autoGeneration.options.numberingRuleId (text-input)
+ * - componentConfig.sections[].fields[].numberingRule.ruleId (universal-form-modal)
+ * - componentConfig.action.excelNumberingRuleId (엑셀 업로드)
+ */
+ private collectNumberingRuleIdsFromLayouts(layouts: any[]): Set {
+ const ruleIds = new Set();
+
+ for (const layout of layouts) {
+ const props = layout.properties;
+ if (!props) continue;
+
+ // 1. componentConfig.autoGeneration.options.numberingRuleId (text-input 컴포넌트)
+ const autoGenRuleId = props?.componentConfig?.autoGeneration?.options?.numberingRuleId;
+ if (autoGenRuleId && typeof autoGenRuleId === 'string' && autoGenRuleId.startsWith('rule-')) {
+ ruleIds.add(autoGenRuleId);
+ }
+
+ // 2. componentConfig.sections[].fields[].numberingRule.ruleId (universal-form-modal)
+ const sections = props?.componentConfig?.sections;
+ if (Array.isArray(sections)) {
+ for (const section of sections) {
+ const fields = section?.fields;
+ if (Array.isArray(fields)) {
+ for (const field of fields) {
+ const ruleId = field?.numberingRule?.ruleId;
+ if (ruleId && typeof ruleId === 'string' && ruleId.startsWith('rule-')) {
+ ruleIds.add(ruleId);
+ }
+ }
+ }
+ // optionalFieldGroups 내부의 필드들도 확인
+ const optGroups = section?.optionalFieldGroups;
+ if (Array.isArray(optGroups)) {
+ for (const optGroup of optGroups) {
+ const optFields = optGroup?.fields;
+ if (Array.isArray(optFields)) {
+ for (const field of optFields) {
+ const ruleId = field?.numberingRule?.ruleId;
+ if (ruleId && typeof ruleId === 'string' && ruleId.startsWith('rule-')) {
+ ruleIds.add(ruleId);
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // 3. componentConfig.action.excelNumberingRuleId (엑셀 업로드)
+ const excelRuleId = props?.componentConfig?.action?.excelNumberingRuleId;
+ if (excelRuleId && typeof excelRuleId === 'string' && excelRuleId.startsWith('rule-')) {
+ ruleIds.add(excelRuleId);
+ }
+
+ // 4. componentConfig.action.numberingRuleId (버튼 액션)
+ const actionRuleId = props?.componentConfig?.action?.numberingRuleId;
+ if (actionRuleId && typeof actionRuleId === 'string' && actionRuleId.startsWith('rule-')) {
+ ruleIds.add(actionRuleId);
+ }
+ }
+
+ return ruleIds;
+ }
+
+ /**
+ * 채번 규칙 복사 및 ID 매핑 반환
+ * - 원본 회사의 채번 규칙을 대상 회사로 복사
+ * - 이름이 같은 규칙이 있으면 재사용
+ * - current_sequence는 0으로 초기화
+ */
+ private async copyNumberingRulesForScreen(
+ ruleIds: Set,
+ sourceCompanyCode: string,
+ targetCompanyCode: string,
+ client: any
+ ): Promise
+ {/* 추가 복사 옵션 (선택사항) */}
+
+
+
+ {/* 코드 카테고리 + 코드 복사 */}
+
+ setCopyCodeCategory(checked === true)}
+ />
+
+
+
+ {/* 채번규칙 복제 */}
+
+ setCopyNumberingRules(checked === true)}
+ />
+
+
+
+ {/* 카테고리 매핑 + 값 복사 */}
+
+
setCopyCategoryMapping(checked === true)}
+ />
+
+
+
+ {/* 테이블 타입관리 입력타입 설정 복사 */}
+
+ setCopyTableTypeColumns(checked === true)}
+ />
+
+
+
+ {/* 연쇄관계 설정 복사 */}
+
+ setCopyCascadingRelation(checked === true)}
+ />
+
+
+
+
+ {/* 기본 복사 항목 안내 */}
+
+
기본 복사 항목:
+
+ - 메뉴 구조 (하위 메뉴 포함)
+ - 화면 + 레이아웃 (모달, 조건부 컨테이너)
+ - 플로우 제어 (스텝, 연결)
+
+
+ * 코드, 채번규칙, 카테고리는 위 옵션 선택 시 복사됩니다.
+
+
+
{/* 새 그룹명 + 정렬 순서 */}
diff --git a/frontend/components/screen/panels/DataFlowPanel.tsx b/frontend/components/screen/panels/DataFlowPanel.tsx
index 4b31d1a9..a307b6ba 100644
--- a/frontend/components/screen/panels/DataFlowPanel.tsx
+++ b/frontend/components/screen/panels/DataFlowPanel.tsx
@@ -462,3 +462,5 @@ export default function DataFlowPanel({ groupId, screenId, screens = [] }: DataF
+
+
diff --git a/frontend/components/screen/panels/FieldJoinPanel.tsx b/frontend/components/screen/panels/FieldJoinPanel.tsx
index 884ac69b..822c4dc9 100644
--- a/frontend/components/screen/panels/FieldJoinPanel.tsx
+++ b/frontend/components/screen/panels/FieldJoinPanel.tsx
@@ -414,3 +414,5 @@ export default function FieldJoinPanel({ screenId, componentId, layoutId }: Fiel
+
+
diff --git a/frontend/lib/api/screen.ts b/frontend/lib/api/screen.ts
index 93ecb7a9..015a0596 100644
--- a/frontend/lib/api/screen.ts
+++ b/frontend/lib/api/screen.ts
@@ -448,4 +448,17 @@ export const menuScreenApi = {
unassignScreenFromMenu: async (screenId: number, menuObjid: number): Promise
=> {
await apiClient.delete(`/screen-management/screens/${screenId}/menus/${menuObjid}`);
},
+
};
+
+// 그룹 복제 완료 후 탭 컴포넌트의 screenId 참조 일괄 업데이트 (별도 export)
+export async function updateTabScreenReferences(
+ targetScreenIds: number[],
+ screenIdMap: { [key: number]: number }
+): Promise<{ success: boolean; updated: number; details: string[] }> {
+ const response = await apiClient.post("/screen-management/screens/update-tab-references", {
+ targetScreenIds,
+ screenIdMap,
+ });
+ return response.data;
+}
diff --git a/frontend/lib/api/screenGroup.ts b/frontend/lib/api/screenGroup.ts
index 65294444..2c9f9606 100644
--- a/frontend/lib/api/screenGroup.ts
+++ b/frontend/lib/api/screenGroup.ts
@@ -176,6 +176,7 @@ export async function addScreenToGroup(data: {
screen_role?: string;
display_order?: number;
is_default?: string;
+ target_company_code?: string; // 최고 관리자가 다른 회사로 복제할 때 사용
}): Promise> {
try {
const response = await apiClient.post("/screen-groups/group-screens", data);
@@ -498,3 +499,166 @@ export async function getScreenSubTables(
}
}
+
+// ============================================================
+// 메뉴-화면그룹 동기화 API
+// ============================================================
+
+export interface SyncDetail {
+ action: 'created' | 'linked' | 'skipped' | 'error';
+ sourceName: string;
+ sourceId: number | string;
+ targetId?: number | string;
+ reason?: string;
+}
+
+export interface SyncResult {
+ success: boolean;
+ created: number;
+ linked: number;
+ skipped: number;
+ errors: string[];
+ details: SyncDetail[];
+}
+
+export interface SyncStatus {
+ screenGroups: { total: number; linked: number; unlinked: number };
+ menuItems: { total: number; linked: number; unlinked: number };
+ potentialMatches: Array<{ menuName: string; groupName: string; similarity: string }>;
+}
+
+// 동기화 상태 조회
+export async function getMenuScreenSyncStatus(
+ targetCompanyCode?: string
+): Promise> {
+ try {
+ const queryParams = targetCompanyCode ? `?targetCompanyCode=${targetCompanyCode}` : '';
+ const response = await apiClient.get(`/screen-groups/sync/status${queryParams}`);
+ return response.data;
+ } catch (error: any) {
+ return { success: false, error: error.message };
+ }
+}
+
+// 화면관리 → 메뉴 동기화
+export async function syncScreenGroupsToMenu(
+ targetCompanyCode?: string
+): Promise> {
+ try {
+ const response = await apiClient.post("/screen-groups/sync/screen-to-menu", { targetCompanyCode });
+ return response.data;
+ } catch (error: any) {
+ return { success: false, error: error.message };
+ }
+}
+
+// 메뉴 → 화면관리 동기화
+export async function syncMenuToScreenGroups(
+ targetCompanyCode?: string
+): Promise> {
+ try {
+ const response = await apiClient.post("/screen-groups/sync/menu-to-screen", { targetCompanyCode });
+ return response.data;
+ } catch (error: any) {
+ return { success: false, error: error.message };
+ }
+}
+
+// 전체 동기화 결과 타입
+export interface AllCompaniesSyncResult {
+ totalCompanies: number;
+ successCount: number;
+ failedCount: number;
+ totalCreated: number;
+ totalLinked: number;
+ details: Array<{
+ companyCode: string;
+ companyName: string;
+ direction: 'screens-to-menus' | 'menus-to-screens';
+ created: number;
+ linked: number;
+ skipped: number;
+ success: boolean;
+ error?: string;
+ }>;
+}
+
+// 전체 회사 동기화 (최고 관리자만)
+export async function syncAllCompanies(): Promise> {
+ try {
+ const response = await apiClient.post("/screen-groups/sync/all");
+ return response.data;
+ } catch (error: any) {
+ return { success: false, error: error.message };
+ }
+}
+
+// ============================================================
+// [PoC] screen_groups 기반 메뉴 트리 조회
+// 화면관리 → 메뉴관리 통합 테스트용
+// ============================================================
+
+export interface MenuTreeItem {
+ id: number;
+ objid: number | string;
+ name: string;
+ name_kor: string;
+ icon?: string;
+ url: string | null;
+ screen_id: number | null;
+ screen_code?: string;
+ screen_count: number;
+ parent_id: number | null;
+ level: number;
+ display_order: number;
+ is_active: boolean;
+ menu_objid: number | null;
+ children: MenuTreeItem[];
+ // menu_info 호환 필드
+ menu_name_kor: string;
+ menu_url: string | null;
+ parent_obj_id: string | null;
+ seq: number;
+ status: string;
+}
+
+export interface MenuTreeResult {
+ data: MenuTreeItem[];
+ stats: {
+ totalGroups: number;
+ groupsWithScreens: number;
+ groupsWithMenuObjid: number;
+ rootGroups: number;
+ };
+ flatList: Array<{
+ objid: string;
+ OBJID: string;
+ menu_name_kor: string;
+ MENU_NAME_KOR: string;
+ menu_url: string | null;
+ MENU_URL: string | null;
+ parent_obj_id: string;
+ PARENT_OBJ_ID: string;
+ seq: number;
+ SEQ: number;
+ status: string;
+ STATUS: string;
+ menu_type: number;
+ MENU_TYPE: number;
+ screen_group_id: number;
+ menu_objid: number | null;
+ }>;
+}
+
+// [PoC] screen_groups 기반 메뉴 트리 조회
+export async function getMenuTreeFromScreenGroups(targetCompanyCode?: string): Promise> {
+ try {
+ const params = targetCompanyCode ? { targetCompanyCode } : {};
+ const response = await apiClient.get("/screen-groups/menu-tree", { params });
+ return response.data;
+ } catch (error: any) {
+ console.error("[PoC] 메뉴 트리 조회 실패:", error);
+ return { success: false, error: error.message };
+ }
+}
+