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
+ {/* 추가 복사 옵션 (선택사항) */}
+
+
+
+ {/* 코드 카테고리 + 코드 복사 */}
+
+ 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 1fdf0ec8..518fbd2c 100644
--- a/frontend/components/screen/panels/DataFlowPanel.tsx
+++ b/frontend/components/screen/panels/DataFlowPanel.tsx
@@ -461,5 +461,3 @@ 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 29891228..86ebc226 100644
--- a/frontend/components/screen/panels/FieldJoinPanel.tsx
+++ b/frontend/components/screen/panels/FieldJoinPanel.tsx
@@ -413,5 +413,3 @@ export default function FieldJoinPanel({ screenId, componentId, layoutId }: Fiel
-
-
diff --git a/frontend/lib/api/screen.ts b/frontend/lib/api/screen.ts
index 2c530a49..74c11d6a 100644
--- a/frontend/lib/api/screen.ts
+++ b/frontend/lib/api/screen.ts
@@ -450,4 +450,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 0a91f907..f3883240 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);
@@ -592,3 +593,71 @@ export async function syncAllCompanies(): Promise;
+}
+
+// [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 };
+ }
+}