From cf8a5a3d934a30b05f902f69a8edccd1fd96bc70 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 18 Dec 2025 15:16:34 +0900 Subject: [PATCH 1/6] =?UTF-8?q?=EC=97=B0=EC=87=84=EA=B4=80=EA=B3=84=20?= =?UTF-8?q?=EC=9E=90=EC=8B=9D=20=EB=9D=BC=EB=B2=A8=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../categoryValueCascadingController.ts | 175 ++++++++++++++++-- .../routes/categoryValueCascadingRoutes.ts | 12 +- .../table-list/TableListComponent.tsx | 90 ++++++--- 3 files changed, 226 insertions(+), 51 deletions(-) diff --git a/backend-node/src/controllers/categoryValueCascadingController.ts b/backend-node/src/controllers/categoryValueCascadingController.ts index 41ac330e..66250bf9 100644 --- a/backend-node/src/controllers/categoryValueCascadingController.ts +++ b/backend-node/src/controllers/categoryValueCascadingController.ts @@ -76,7 +76,9 @@ export const getCategoryValueCascadingGroups = async ( data: result.rows, }); } catch (error: any) { - logger.error("카테고리 값 연쇄관계 그룹 목록 조회 실패", { error: error.message }); + logger.error("카테고리 값 연쇄관계 그룹 목록 조회 실패", { + error: error.message, + }); return res.status(500).json({ success: false, message: "카테고리 값 연쇄관계 그룹 목록 조회에 실패했습니다.", @@ -175,7 +177,9 @@ export const getCategoryValueCascadingGroupById = async ( }, }); } catch (error: any) { - logger.error("카테고리 값 연쇄관계 그룹 상세 조회 실패", { error: error.message }); + logger.error("카테고리 값 연쇄관계 그룹 상세 조회 실패", { + error: error.message, + }); return res.status(500).json({ success: false, message: "카테고리 값 연쇄관계 그룹 조회에 실패했습니다.", @@ -240,7 +244,9 @@ export const getCategoryValueCascadingByCode = async ( data: result.rows[0], }); } catch (error: any) { - logger.error("카테고리 값 연쇄관계 코드 조회 실패", { error: error.message }); + logger.error("카테고리 값 연쇄관계 코드 조회 실패", { + error: error.message, + }); return res.status(500).json({ success: false, message: "카테고리 값 연쇄관계 조회에 실패했습니다.", @@ -277,7 +283,14 @@ export const createCategoryValueCascadingGroup = async ( } = req.body; // 필수 필드 검증 - if (!relationCode || !relationName || !parentTableName || !parentColumnName || !childTableName || !childColumnName) { + if ( + !relationCode || + !relationName || + !parentTableName || + !parentColumnName || + !childTableName || + !childColumnName + ) { return res.status(400).json({ success: false, message: "필수 필드가 누락되었습니다.", @@ -352,7 +365,9 @@ export const createCategoryValueCascadingGroup = async ( message: "카테고리 값 연쇄관계 그룹이 생성되었습니다.", }); } catch (error: any) { - logger.error("카테고리 값 연쇄관계 그룹 생성 실패", { error: error.message }); + logger.error("카테고리 값 연쇄관계 그룹 생성 실패", { + error: error.message, + }); return res.status(500).json({ success: false, message: "카테고리 값 연쇄관계 그룹 생성에 실패했습니다.", @@ -403,7 +418,11 @@ export const updateCategoryValueCascadingGroup = async ( } const existingCompanyCode = existingCheck.rows[0].company_code; - if (companyCode !== "*" && existingCompanyCode !== companyCode && existingCompanyCode !== "*") { + if ( + companyCode !== "*" && + existingCompanyCode !== companyCode && + existingCompanyCode !== "*" + ) { return res.status(403).json({ success: false, message: "수정 권한이 없습니다.", @@ -440,7 +459,11 @@ export const updateCategoryValueCascadingGroup = async ( childTableName, childColumnName, childMenuObjid, - clearOnParentChange !== undefined ? (clearOnParentChange ? "Y" : "N") : null, + clearOnParentChange !== undefined + ? clearOnParentChange + ? "Y" + : "N" + : null, showGroupLabel !== undefined ? (showGroupLabel ? "Y" : "N") : null, emptyParentMessage, noOptionsMessage, @@ -461,7 +484,9 @@ export const updateCategoryValueCascadingGroup = async ( message: "카테고리 값 연쇄관계 그룹이 수정되었습니다.", }); } catch (error: any) { - logger.error("카테고리 값 연쇄관계 그룹 수정 실패", { error: error.message }); + logger.error("카테고리 값 연쇄관계 그룹 수정 실패", { + error: error.message, + }); return res.status(500).json({ success: false, message: "카테고리 값 연쇄관계 그룹 수정에 실패했습니다.", @@ -496,7 +521,11 @@ export const deleteCategoryValueCascadingGroup = async ( } const existingCompanyCode = existingCheck.rows[0].company_code; - if (companyCode !== "*" && existingCompanyCode !== companyCode && existingCompanyCode !== "*") { + if ( + companyCode !== "*" && + existingCompanyCode !== companyCode && + existingCompanyCode !== "*" + ) { return res.status(403).json({ success: false, message: "삭제 권한이 없습니다.", @@ -522,7 +551,9 @@ export const deleteCategoryValueCascadingGroup = async ( message: "카테고리 값 연쇄관계 그룹이 삭제되었습니다.", }); } catch (error: any) { - logger.error("카테고리 값 연쇄관계 그룹 삭제 실패", { error: error.message }); + logger.error("카테고리 값 연쇄관계 그룹 삭제 실패", { + error: error.message, + }); return res.status(500).json({ success: false, message: "카테고리 값 연쇄관계 그룹 삭제에 실패했습니다.", @@ -620,7 +651,9 @@ export const saveCategoryValueCascadingMappings = async ( client.release(); } } catch (error: any) { - logger.error("카테고리 값 연쇄관계 매핑 저장 실패", { error: error.message }); + logger.error("카테고리 값 연쇄관계 매핑 저장 실패", { + error: error.message, + }); return res.status(500).json({ success: false, message: "카테고리 값 연쇄관계 매핑 저장에 실패했습니다.", @@ -649,12 +682,15 @@ export const getCategoryValueCascadingOptions = async ( // 다중 부모값 파싱 let parentValueArray: string[] = []; - + if (parentValues) { if (Array.isArray(parentValues)) { - parentValueArray = parentValues.map(v => String(v)); + parentValueArray = parentValues.map((v) => String(v)); } else { - parentValueArray = String(parentValues).split(',').map(v => v.trim()).filter(v => v); + parentValueArray = String(parentValues) + .split(",") + .map((v) => v.trim()) + .filter((v) => v); } } else if (parentValue) { parentValueArray = [String(parentValue)]; @@ -696,8 +732,10 @@ export const getCategoryValueCascadingOptions = async ( const group = groupResult.rows[0]; // 매핑된 자식 값 조회 (다중 부모값에 대해 IN 절 사용) - const placeholders = parentValueArray.map((_, idx) => `$${idx + 2}`).join(', '); - + const placeholders = parentValueArray + .map((_, idx) => `$${idx + 2}`) + .join(", "); + const optionsQuery = ` SELECT DISTINCT child_value_code as value, @@ -712,7 +750,10 @@ export const getCategoryValueCascadingOptions = async ( ORDER BY parent_value_code, display_order, child_value_label `; - const optionsResult = await pool.query(optionsQuery, [group.group_id, ...parentValueArray]); + const optionsResult = await pool.query(optionsQuery, [ + group.group_id, + ...parentValueArray, + ]); logger.info("카테고리 값 연쇄 옵션 조회", { relationCode: code, @@ -723,7 +764,7 @@ export const getCategoryValueCascadingOptions = async ( return res.json({ success: true, data: optionsResult.rows, - showGroupLabel: group.show_group_label === 'Y', + showGroupLabel: group.show_group_label === "Y", }); } catch (error: any) { logger.error("카테고리 값 연쇄 옵션 조회 실패", { error: error.message }); @@ -789,7 +830,10 @@ export const getCategoryValueCascadingParentOptions = async ( AND is_active = true `; - const optionsParams: any[] = [group.parent_table_name, group.parent_column_name]; + const optionsParams: any[] = [ + group.parent_table_name, + group.parent_column_name, + ]; let paramIndex = 3; // 메뉴 스코프 적용 @@ -884,7 +928,10 @@ export const getCategoryValueCascadingChildOptions = async ( AND is_active = true `; - const optionsParams: any[] = [group.child_table_name, group.child_column_name]; + const optionsParams: any[] = [ + group.child_table_name, + group.child_column_name, + ]; let paramIndex = 3; // 메뉴 스코프 적용 @@ -925,3 +972,91 @@ export const getCategoryValueCascadingChildOptions = async ( } }; +/** + * 테이블명으로 해당 테이블의 모든 연쇄관계 매핑 조회 + * (테이블 목록에서 코드→라벨 변환에 사용) + */ +export const getCategoryValueCascadingMappingsByTable = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { tableName } = req.params; + const companyCode = req.user?.companyCode || "*"; + + if (!tableName) { + return res.status(400).json({ + success: false, + message: "테이블명이 필요합니다.", + }); + } + + // 해당 테이블이 자식 테이블인 연쇄관계 그룹 찾기 + let groupQuery = ` + SELECT + group_id, + relation_code, + child_column_name + FROM category_value_cascading_group + WHERE child_table_name = $1 + AND is_active = 'Y' + `; + const groupParams: any[] = [tableName]; + let paramIndex = 2; + + // 멀티테넌시 적용 + if (companyCode !== "*") { + groupQuery += ` AND (company_code = $${paramIndex} OR company_code = '*')`; + groupParams.push(companyCode); + } + + const groupResult = await pool.query(groupQuery, groupParams); + + if (groupResult.rowCount === 0) { + // 연쇄관계가 없으면 빈 객체 반환 + return res.json({ + success: true, + data: {}, + }); + } + + // 각 그룹의 매핑 조회 + const mappings: Record> = {}; + + for (const group of groupResult.rows) { + const mappingQuery = ` + SELECT DISTINCT + child_value_code as code, + child_value_label as label + FROM category_value_cascading_mapping + WHERE group_id = $1 + AND is_active = 'Y' + ORDER BY child_value_label + `; + + const mappingResult = await pool.query(mappingQuery, [group.group_id]); + + if (mappingResult.rowCount && mappingResult.rowCount > 0) { + mappings[group.child_column_name] = mappingResult.rows; + } + } + + logger.info("테이블별 연쇄관계 매핑 조회", { + tableName, + groupCount: groupResult.rowCount, + columnMappings: Object.keys(mappings), + }); + + return res.json({ + success: true, + data: mappings, + }); + } catch (error: any) { + logger.error("테이블별 연쇄관계 매핑 조회 실패", { error: error.message }); + return res.status(500).json({ + success: false, + message: "연쇄관계 매핑 조회에 실패했습니다.", + error: error.message, + }); + } +}; diff --git a/backend-node/src/routes/categoryValueCascadingRoutes.ts b/backend-node/src/routes/categoryValueCascadingRoutes.ts index d8919627..894da819 100644 --- a/backend-node/src/routes/categoryValueCascadingRoutes.ts +++ b/backend-node/src/routes/categoryValueCascadingRoutes.ts @@ -10,6 +10,7 @@ import { getCategoryValueCascadingOptions, getCategoryValueCascadingParentOptions, getCategoryValueCascadingChildOptions, + getCategoryValueCascadingMappingsByTable, } from "../controllers/categoryValueCascadingController"; import { authenticateToken } from "../middleware/authMiddleware"; @@ -60,5 +61,14 @@ router.get("/child-options/:code", getCategoryValueCascadingChildOptions); // 연쇄 옵션 조회 (부모 값 기반 자식 옵션) router.get("/options/:code", getCategoryValueCascadingOptions); -export default router; +// ============================================ +// 테이블별 매핑 조회 (테이블 목록 표시용) +// ============================================ +// 테이블명으로 해당 테이블의 모든 연쇄관계 매핑 조회 +router.get( + "/table/:tableName/mappings", + getCategoryValueCascadingMappingsByTable +); + +export default router; diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 171c65bb..7ac521af 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -209,7 +209,7 @@ export interface TableListComponentProps { onConfigChange?: (config: any) => void; refreshKey?: number; // 탭 관련 정보 (탭 내부의 테이블에서 사용) - parentTabId?: string; // 부모 탭 ID + parentTabId?: string; // 부모 탭 ID parentTabsComponentId?: string; // 부모 탭 컴포넌트 ID } @@ -689,7 +689,7 @@ export const TableListComponent: React.FC = ({ const [viewMode, setViewMode] = useState<"table" | "card" | "grouped-card">("table"); // 체크박스 컬럼은 항상 기본 틀고정 const [frozenColumns, setFrozenColumns] = useState( - (tableConfig.checkbox?.enabled ?? true) ? ["__checkbox__"] : [] + (tableConfig.checkbox?.enabled ?? true) ? ["__checkbox__"] : [], ); const [frozenColumnCount, setFrozenColumnCount] = useState(0); @@ -1311,17 +1311,15 @@ export const TableListComponent: React.FC = ({ const parts = columnName.split("."); targetTable = parts[0]; // 조인된 테이블명 (예: item_info) targetColumn = parts[1]; // 실제 컬럼명 (예: material) - console.log(`🔗 [TableList] 엔티티 조인 컬럼 감지:`, { + console.log("🔗 [TableList] 엔티티 조인 컬럼 감지:", { originalColumn: columnName, targetTable, targetColumn, }); } - const response = await apiClient.get(`/table-categories/${targetTable}/${targetColumn}/values`); - if (response.data.success && response.data.data && Array.isArray(response.data.data)) { const mapping: Record = {}; @@ -1376,7 +1374,6 @@ export const TableListComponent: React.FC = ({ col.columnName, })) || []; - // 조인 테이블별로 그룹화 const joinedTableColumns: Record = {}; @@ -1408,7 +1405,6 @@ export const TableListComponent: React.FC = ({ }); } - // 조인된 테이블별로 inputType 정보 가져오기 const newJoinedColumnMeta: Record = {}; @@ -1471,6 +1467,41 @@ export const TableListComponent: React.FC = ({ console.log("✅ [TableList] 조인 컬럼 메타데이터 설정:", newJoinedColumnMeta); } + // 🆕 카테고리 연쇄관계 매핑 로드 (category_value_cascading_mapping) + try { + const cascadingResponse = await apiClient.get( + `/category-value-cascading/table/${tableConfig.selectedTable}/mappings`, + ); + if (cascadingResponse.data.success && cascadingResponse.data.data) { + const cascadingMappings = cascadingResponse.data.data; + + // 각 자식 컬럼에 대한 매핑 추가 + for (const [columnName, columnMappings] of Object.entries( + cascadingMappings as Record>, + )) { + if (!mappings[columnName]) { + mappings[columnName] = {}; + } + // 연쇄관계 매핑 추가 + for (const item of columnMappings) { + mappings[columnName][item.code] = { + label: item.label, + color: undefined, // 연쇄관계는 색상 없음 + }; + } + } + console.log("✅ [TableList] 카테고리 연쇄관계 매핑 로드 완료:", { + tableName: tableConfig.selectedTable, + cascadingColumns: Object.keys(cascadingMappings), + }); + } + } catch (cascadingError: any) { + // 연쇄관계 매핑이 없는 경우 무시 (404 등) + if (cascadingError?.response?.status !== 404) { + console.warn("⚠️ [TableList] 카테고리 연쇄관계 매핑 로드 실패:", cascadingError?.message); + } + } + if (Object.keys(mappings).length > 0) { setCategoryMappings(mappings); setCategoryMappingsKey((prev) => prev + 1); @@ -1495,7 +1526,6 @@ export const TableListComponent: React.FC = ({ // ======================================== const fetchTableDataInternal = useCallback(async () => { - if (!tableConfig.selectedTable || isDesignMode) { setData([]); setTotalPages(0); @@ -1514,11 +1544,10 @@ export const TableListComponent: React.FC = ({ const search = searchTerm || undefined; // 🆕 연결 필터 값 가져오기 (분할 패널 내부일 때) - let linkedFilterValues: Record = {}; + const linkedFilterValues: Record = {}; let hasLinkedFiltersConfigured = false; // 연결 필터가 설정되어 있는지 여부 let hasSelectedLeftData = false; // 좌측에서 데이터가 선택되었는지 여부 - if (splitPanelContext) { // 연결 필터 설정 여부 확인 (현재 테이블에 해당하는 필터가 있는지) const linkedFiltersConfig = splitPanelContext.linkedFilters || []; @@ -1609,7 +1638,7 @@ export const TableListComponent: React.FC = ({ } // 🆕 RelatedDataButtons 필터 값 준비 - let relatedButtonFilterValues: Record = {}; + const relatedButtonFilterValues: Record = {}; if (relatedButtonFilter) { relatedButtonFilterValues[relatedButtonFilter.filterColumn] = { value: relatedButtonFilter.filterValue, @@ -1685,7 +1714,6 @@ export const TableListComponent: React.FC = ({ }; }); - // 🆕 제외 필터 처리 (다른 테이블에 이미 존재하는 데이터 제외) let excludeFilterParam: any = undefined; if (tableConfig.excludeFilter?.enabled) { @@ -2427,7 +2455,7 @@ export const TableListComponent: React.FC = ({ try { const { apiClient } = await import("@/lib/api/client"); - await apiClient.put(`/dynamic-form/update-field`, { + await apiClient.put("/dynamic-form/update-field", { tableName: tableConfig.selectedTable, keyField: primaryKeyField, keyValue: primaryKeyValue, @@ -2468,7 +2496,7 @@ export const TableListComponent: React.FC = ({ // 모든 변경사항 저장 const savePromises = Array.from(pendingChanges.values()).map((change) => - apiClient.put(`/dynamic-form/update-field`, { + apiClient.put("/dynamic-form/update-field", { tableName: tableConfig.selectedTable, keyField: primaryKeyField, keyValue: change.primaryKeyValue, @@ -2942,9 +2970,10 @@ export const TableListComponent: React.FC = ({ if (state.frozenColumns) { // 체크박스 컬럼이 항상 포함되도록 보장 const checkboxColumn = (tableConfig.checkbox?.enabled ?? true) ? "__checkbox__" : null; - const restoredFrozenColumns = checkboxColumn && !state.frozenColumns.includes(checkboxColumn) - ? [checkboxColumn, ...state.frozenColumns] - : state.frozenColumns; + const restoredFrozenColumns = + checkboxColumn && !state.frozenColumns.includes(checkboxColumn) + ? [checkboxColumn, ...state.frozenColumns] + : state.frozenColumns; setFrozenColumns(restoredFrozenColumns); } if (state.frozenColumnCount !== undefined) setFrozenColumnCount(state.frozenColumnCount); // 틀고정 컬럼 수 복원 @@ -2956,7 +2985,6 @@ export const TableListComponent: React.FC = ({ }); setHeaderFilters(filters); } - } catch (error) { console.error("❌ 테이블 상태 복원 실패:", error); } @@ -3576,7 +3604,7 @@ export const TableListComponent: React.FC = ({ })); // 배치 업데이트 - await Promise.all(updates.map((update) => apiClient.put(`/dynamic-form/update-field`, update))); + await Promise.all(updates.map((update) => apiClient.put("/dynamic-form/update-field", update))); toast.success("순서가 변경되었습니다."); setRefreshTrigger((prev) => prev + 1); @@ -4894,7 +4922,7 @@ export const TableListComponent: React.FC = ({ useEffect(() => { const handleRelatedButtonSelect = (event: CustomEvent) => { const { targetTable, filterColumn, filterValue } = event.detail || {}; - + // 이 테이블이 대상 테이블인지 확인 if (targetTable === tableConfig.selectedTable) { // filterValue가 null이면 선택 해제 (빈 상태) @@ -4925,9 +4953,9 @@ export const TableListComponent: React.FC = ({ useEffect(() => { if (!isDesignMode) { // relatedButtonFilter가 있으면 데이터 로드, null이면 빈 상태 (setRefreshTrigger로 트리거) - console.log("🔄 [TableList] RelatedDataButtons 상태 변경:", { - relatedButtonFilter, - isRelatedButtonTarget + console.log("🔄 [TableList] RelatedDataButtons 상태 변경:", { + relatedButtonFilter, + isRelatedButtonTarget, }); setRefreshTrigger((prev) => prev + 1); } @@ -5618,7 +5646,7 @@ export const TableListComponent: React.FC = ({ for (let i = 0; i < frozenIndex; i++) { const frozenCol = frozenColumns[i]; // 체크박스 컬럼은 48px 고정 - const frozenColWidth = frozenCol === "__checkbox__" ? 48 : (columnWidths[frozenCol] || 150); + const frozenColWidth = frozenCol === "__checkbox__" ? 48 : columnWidths[frozenCol] || 150; leftPosition += frozenColWidth; } } @@ -5930,7 +5958,8 @@ export const TableListComponent: React.FC = ({ for (let i = 0; i < frozenIndex; i++) { const frozenCol = frozenColumns[i]; // 체크박스 컬럼은 48px 고정 - const frozenColWidth = frozenCol === "__checkbox__" ? 48 : (columnWidths[frozenCol] || 150); + const frozenColWidth = + frozenCol === "__checkbox__" ? 48 : columnWidths[frozenCol] || 150; leftPosition += frozenColWidth; } } @@ -5958,7 +5987,7 @@ export const TableListComponent: React.FC = ({ : `${100 / visibleColumns.length}%`, minWidth: column.columnName === "__checkbox__" ? "48px" : undefined, maxWidth: column.columnName === "__checkbox__" ? "48px" : undefined, - ...(isFrozen && { + ...(isFrozen && { left: `${leftPosition}px`, backgroundColor: "hsl(var(--background))", }), @@ -6094,7 +6123,8 @@ export const TableListComponent: React.FC = ({ for (let i = 0; i < frozenIndex; i++) { const frozenCol = frozenColumns[i]; // 체크박스 컬럼은 48px 고정 - const frozenColWidth = frozenCol === "__checkbox__" ? 48 : (columnWidths[frozenCol] || 150); + const frozenColWidth = + frozenCol === "__checkbox__" ? 48 : columnWidths[frozenCol] || 150; leftPosition += frozenColWidth; } } @@ -6134,7 +6164,7 @@ export const TableListComponent: React.FC = ({ column.columnName === "__checkbox__" ? "48px" : `${100 / visibleColumns.length}%`, minWidth: column.columnName === "__checkbox__" ? "48px" : undefined, maxWidth: column.columnName === "__checkbox__" ? "48px" : undefined, - ...(isFrozen && { + ...(isFrozen && { left: `${leftPosition}px`, backgroundColor: "hsl(var(--background))", }), @@ -6259,7 +6289,7 @@ export const TableListComponent: React.FC = ({ for (let i = 0; i < frozenIndex; i++) { const frozenCol = frozenColumns[i]; // 체크박스 컬럼은 48px 고정 - const frozenColWidth = frozenCol === "__checkbox__" ? 48 : (columnWidths[frozenCol] || 150); + const frozenColWidth = frozenCol === "__checkbox__" ? 48 : columnWidths[frozenCol] || 150; leftPosition += frozenColWidth; } } @@ -6284,7 +6314,7 @@ export const TableListComponent: React.FC = ({ : columnWidth ? `${columnWidth}px` : undefined, - ...(isFrozen && { + ...(isFrozen && { left: `${leftPosition}px`, backgroundColor: "hsl(var(--muted) / 0.8)", }), From 66bd21ee65dd758f1d56349f5b3cf01405225484 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 18 Dec 2025 15:24:20 +0900 Subject: [PATCH 2/6] =?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0=ED=83=80?= =?UTF-8?q?=EC=9E=85=20=ED=91=9C=EC=8B=9C=EC=98=A4=EB=A5=98=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../EntitySearchInputComponent.tsx | 38 +++++++++++++------ 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/frontend/lib/registry/components/entity-search-input/EntitySearchInputComponent.tsx b/frontend/lib/registry/components/entity-search-input/EntitySearchInputComponent.tsx index df087536..8bdd5758 100644 --- a/frontend/lib/registry/components/entity-search-input/EntitySearchInputComponent.tsx +++ b/frontend/lib/registry/components/entity-search-input/EntitySearchInputComponent.tsx @@ -172,19 +172,38 @@ export function EntitySearchInputComponent({ // value가 변경되면 표시값 업데이트 (외래키 값으로 데이터 조회) useEffect(() => { const loadDisplayValue = async () => { - if (value && selectedData) { - // 이미 selectedData가 있으면 표시값만 업데이트 + // value가 없으면 초기화 + if (!value) { + setDisplayValue(""); + setSelectedData(null); + return; + } + + // 이미 selectedData가 있고 value와 일치하면 표시값만 업데이트 + if (selectedData && String(selectedData[valueField]) === String(value)) { setDisplayValue(selectedData[displayField] || ""); - } else if (value && mode === "select" && effectiveOptions.length > 0) { - // select 모드에서 value가 있고 options가 로드된 경우 - const found = effectiveOptions.find((opt) => opt[valueField] === value); + return; + } + + // select 모드에서 options가 로드된 경우 먼저 옵션에서 찾기 + if (mode === "select" && effectiveOptions.length > 0) { + // 타입 변환하여 비교 (숫자 vs 문자열 문제 해결) + const found = effectiveOptions.find((opt) => String(opt[valueField]) === String(value)); if (found) { setSelectedData(found); setDisplayValue(found[displayField] || ""); + console.log("✅ [EntitySearchInput] 옵션에서 초기값 찾음:", { value, found }); + return; } - } else if (value && !selectedData && tableName) { - // value는 있지만 selectedData가 없는 경우 (초기 로드 시) - // API로 해당 데이터 조회 + // 옵션에서 찾지 못한 경우 API로 조회 진행 + console.log("⚠️ [EntitySearchInput] 옵션에서 찾지 못함, API로 조회:", { + value, + optionsCount: effectiveOptions.length, + }); + } + + // API로 해당 데이터 조회 + if (tableName) { try { console.log("🔍 [EntitySearchInput] 초기값 조회:", { value, tableName, valueField }); const response = await dynamicFormApi.getTableData(tableName, { @@ -222,9 +241,6 @@ export function EntitySearchInputComponent({ // 에러 시 value 자체를 표시 setDisplayValue(String(value)); } - } else if (!value) { - setDisplayValue(""); - setSelectedData(null); } }; From 84efaed1eb5956ca733690a9dd5f97dfc45ace2f Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 18 Dec 2025 16:35:55 +0900 Subject: [PATCH 3/6] =?UTF-8?q?=EC=97=90=EB=9F=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/routes/cascadingAutoFillRoutes.ts | 1 + .../src/routes/cascadingConditionRoutes.ts | 1 + .../src/routes/cascadingHierarchyRoutes.ts | 1 + .../routes/cascadingMutualExclusionRoutes.ts | 1 + backend-node/src/services/menuCopyService.ts | 319 ++++++++++++++---- docs/노드플로우_개선사항.md | 1 + docs/메일발송_기능_사용_가이드.md | 1 + docs/즉시저장_버튼_액션_구현_계획서.md | 1 + frontend/components/admin/MenuCopyDialog.tsx | 23 ++ frontend/contexts/ActiveTabContext.tsx | 1 + frontend/hooks/useAutoFill.ts | 1 + frontend/lib/api/menu.ts | 17 +- .../UniversalFormModalComponent.tsx | 42 +-- ..._임베딩_및_데이터_전달_시스템_구현_계획서.md | 1 + 화면_임베딩_시스템_Phase1-4_구현_완료.md | 1 + 화면_임베딩_시스템_충돌_분석_보고서.md | 1 + 16 files changed, 331 insertions(+), 82 deletions(-) diff --git a/backend-node/src/routes/cascadingAutoFillRoutes.ts b/backend-node/src/routes/cascadingAutoFillRoutes.ts index 92cd1bbc..92036080 100644 --- a/backend-node/src/routes/cascadingAutoFillRoutes.ts +++ b/backend-node/src/routes/cascadingAutoFillRoutes.ts @@ -53,3 +53,4 @@ export default router; + diff --git a/backend-node/src/routes/cascadingConditionRoutes.ts b/backend-node/src/routes/cascadingConditionRoutes.ts index 5745511b..ed11d3d1 100644 --- a/backend-node/src/routes/cascadingConditionRoutes.ts +++ b/backend-node/src/routes/cascadingConditionRoutes.ts @@ -49,3 +49,4 @@ export default router; + diff --git a/backend-node/src/routes/cascadingHierarchyRoutes.ts b/backend-node/src/routes/cascadingHierarchyRoutes.ts index 92da4019..d74929cb 100644 --- a/backend-node/src/routes/cascadingHierarchyRoutes.ts +++ b/backend-node/src/routes/cascadingHierarchyRoutes.ts @@ -65,3 +65,4 @@ export default router; + diff --git a/backend-node/src/routes/cascadingMutualExclusionRoutes.ts b/backend-node/src/routes/cascadingMutualExclusionRoutes.ts index 451fe973..ce2fbcac 100644 --- a/backend-node/src/routes/cascadingMutualExclusionRoutes.ts +++ b/backend-node/src/routes/cascadingMutualExclusionRoutes.ts @@ -53,3 +53,4 @@ export default router; + diff --git a/backend-node/src/services/menuCopyService.ts b/backend-node/src/services/menuCopyService.ts index b5266377..683d71ba 100644 --- a/backend-node/src/services/menuCopyService.ts +++ b/backend-node/src/services/menuCopyService.ts @@ -15,6 +15,7 @@ export interface MenuCopyResult { copiedNumberingRules: number; copiedCategoryMappings: number; copiedTableTypeColumns: number; // 테이블 타입관리 입력타입 설정 + copiedCascadingRelations: number; // 연쇄관계 설정 menuIdMap: Record; screenIdMap: Record; flowIdMap: Record; @@ -29,6 +30,7 @@ export interface AdditionalCopyOptions { copyNumberingRules?: boolean; copyCategoryMapping?: boolean; copyTableTypeColumns?: boolean; // 테이블 타입관리 입력타입 설정 + copyCascadingRelation?: boolean; // 연쇄관계 설정 } /** @@ -754,28 +756,44 @@ export class MenuCopyService { client ); - // === 2.5단계: 채번 규칙 복사 (화면 복사 전에 실행하여 참조 업데이트 가능) === + // 변수 초기화 let copiedCodeCategories = 0; let copiedCodes = 0; let copiedNumberingRules = 0; let copiedCategoryMappings = 0; let copiedTableTypeColumns = 0; + let copiedCascadingRelations = 0; let numberingRuleIdMap = new Map(); const menuObjids = menus.map((m) => m.objid); - // 메뉴 ID 맵을 먼저 생성 (채번 규칙 복사에 필요) + // 메뉴 ID 맵을 먼저 생성 (일관된 ID 사용을 위해) const tempMenuIdMap = new Map(); let tempObjId = await this.getNextMenuObjid(client); for (const menu of menus) { tempMenuIdMap.set(menu.objid, tempObjId++); } + // === 3단계: 메뉴 복사 (외래키 의존성 해결을 위해 먼저 실행) === + // 채번 규칙, 코드 카테고리 등이 menu_info를 참조하므로 메뉴를 먼저 생성 + logger.info("\n📂 [3단계] 메뉴 복사 (외래키 선행 조건)"); + const menuIdMap = await this.copyMenus( + menus, + sourceMenuObjid, + sourceCompanyCode, + targetCompanyCode, + new Map(), // screenIdMap은 아직 없음 (나중에 할당에서 처리) + userId, + client, + tempMenuIdMap + ); + + // === 4단계: 채번 규칙 복사 (메뉴 복사 후, 화면 복사 전) === if (additionalCopyOptions?.copyNumberingRules) { - logger.info("\n📦 [2.5단계] 채번 규칙 복사 (화면 복사 전)"); + logger.info("\n📦 [4단계] 채번 규칙 복사"); const ruleResult = await this.copyNumberingRulesWithMap( menuObjids, - tempMenuIdMap, + menuIdMap, // 실제 생성된 메뉴 ID 사용 targetCompanyCode, userId, client @@ -784,8 +802,46 @@ export class MenuCopyService { numberingRuleIdMap = ruleResult.ruleIdMap; } - // === 3단계: 화면 복사 === - logger.info("\n📄 [3단계] 화면 복사"); + // === 4.1단계: 코드 카테고리 + 코드 복사 === + if (additionalCopyOptions?.copyCodeCategory) { + logger.info("\n📦 [4.1단계] 코드 카테고리 + 코드 복사"); + const codeResult = await this.copyCodeCategoriesAndCodes( + menuObjids, + menuIdMap, + targetCompanyCode, + userId, + client + ); + copiedCodeCategories = codeResult.copiedCategories; + copiedCodes = codeResult.copiedCodes; + } + + // === 4.2단계: 카테고리 매핑 + 값 복사 === + if (additionalCopyOptions?.copyCategoryMapping) { + logger.info("\n📦 [4.2단계] 카테고리 매핑 + 값 복사"); + copiedCategoryMappings = await this.copyCategoryMappingsAndValues( + menuObjids, + menuIdMap, + targetCompanyCode, + userId, + client + ); + } + + // === 4.3단계: 연쇄관계 복사 === + if (additionalCopyOptions?.copyCascadingRelation) { + logger.info("\n📦 [4.3단계] 연쇄관계 복사"); + copiedCascadingRelations = await this.copyCascadingRelations( + sourceCompanyCode, + targetCompanyCode, + menuIdMap, + userId, + client + ); + } + + // === 5단계: 화면 복사 === + logger.info("\n📄 [5단계] 화면 복사"); const screenIdMap = await this.copyScreens( screenIds, targetCompanyCode, @@ -796,20 +852,8 @@ export class MenuCopyService { numberingRuleIdMap ); - // === 4단계: 메뉴 복사 === - logger.info("\n📂 [4단계] 메뉴 복사"); - const menuIdMap = await this.copyMenus( - menus, - sourceMenuObjid, // 원본 최상위 메뉴 ID 전달 - sourceCompanyCode, - targetCompanyCode, - screenIdMap, - userId, - client - ); - - // === 5단계: 화면-메뉴 할당 === - logger.info("\n🔗 [5단계] 화면-메뉴 할당"); + // === 6단계: 화면-메뉴 할당 === + logger.info("\n🔗 [6단계] 화면-메뉴 할당"); await this.createScreenMenuAssignments( menus, menuIdMap, @@ -818,44 +862,15 @@ export class MenuCopyService { client ); - // === 6단계: 추가 복사 옵션 처리 (코드 카테고리, 카테고리 매핑) === - if (additionalCopyOptions) { - // 6-1. 코드 카테고리 + 코드 복사 - if (additionalCopyOptions.copyCodeCategory) { - logger.info("\n📦 [6-1단계] 코드 카테고리 + 코드 복사"); - const codeResult = await this.copyCodeCategoriesAndCodes( - menuObjids, - menuIdMap, - targetCompanyCode, - userId, - client - ); - copiedCodeCategories = codeResult.copiedCategories; - copiedCodes = codeResult.copiedCodes; - } - - // 6-2. 카테고리 매핑 + 값 복사 - if (additionalCopyOptions.copyCategoryMapping) { - logger.info("\n📦 [6-2단계] 카테고리 매핑 + 값 복사"); - copiedCategoryMappings = await this.copyCategoryMappingsAndValues( - menuObjids, - menuIdMap, - targetCompanyCode, - userId, - client - ); - } - - // 6-3. 테이블 타입관리 입력타입 설정 복사 - if (additionalCopyOptions.copyTableTypeColumns) { - logger.info("\n📦 [6-3단계] 테이블 타입 설정 복사"); - copiedTableTypeColumns = await this.copyTableTypeColumns( - Array.from(screenIdMap.keys()), // 원본 화면 IDs - sourceCompanyCode, - targetCompanyCode, - client - ); - } + // === 7단계: 테이블 타입 설정 복사 === + if (additionalCopyOptions?.copyTableTypeColumns) { + logger.info("\n📦 [7단계] 테이블 타입 설정 복사"); + copiedTableTypeColumns = await this.copyTableTypeColumns( + Array.from(screenIdMap.keys()), + sourceCompanyCode, + targetCompanyCode, + client + ); } // 커밋 @@ -872,6 +887,7 @@ export class MenuCopyService { copiedNumberingRules, copiedCategoryMappings, copiedTableTypeColumns, + copiedCascadingRelations, menuIdMap: Object.fromEntries(menuIdMap), screenIdMap: Object.fromEntries(screenIdMap), flowIdMap: Object.fromEntries(flowIdMap), @@ -889,6 +905,7 @@ export class MenuCopyService { - 채번규칙: ${copiedNumberingRules}개 - 카테고리 매핑: ${copiedCategoryMappings}개 - 테이블 타입 설정: ${copiedTableTypeColumns}개 + - 연쇄관계: ${copiedCascadingRelations}개 ============================================ `); @@ -1569,7 +1586,8 @@ export class MenuCopyService { targetCompanyCode: string, screenIdMap: Map, userId: string, - client: PoolClient + client: PoolClient, + preAllocatedMenuIdMap?: Map // 미리 할당된 메뉴 ID 맵 (옵션 데이터 복사에 사용된 경우) ): Promise> { const menuIdMap = new Map(); @@ -1676,7 +1694,8 @@ export class MenuCopyService { } // === 신규 메뉴 복사 === - const newObjId = await this.getNextMenuObjid(client); + // 미리 할당된 ID가 있으면 사용, 없으면 새로 생성 + const newObjId = preAllocatedMenuIdMap?.get(menu.objid) ?? await this.getNextMenuObjid(client); // source_menu_objid 저장: 모든 복사된 메뉴에 원본 ID 저장 (추적용) const sourceMenuObjid = menu.objid; @@ -2219,4 +2238,184 @@ export class MenuCopyService { return copiedCount; } + /** + * 연쇄관계 복사 + * - category_value_cascading_group + category_value_cascading_mapping + * - cascading_relation (테이블 기반) + */ + private async copyCascadingRelations( + sourceCompanyCode: string, + targetCompanyCode: string, + menuIdMap: Map, + userId: string, + client: PoolClient + ): Promise { + logger.info(`📋 연쇄관계 복사 시작`); + let copiedCount = 0; + + // === 1. category_value_cascading_group 복사 === + const groupsResult = await client.query( + `SELECT * FROM category_value_cascading_group + WHERE company_code = $1 AND is_active = 'Y'`, + [sourceCompanyCode] + ); + + logger.info(` 카테고리 값 연쇄 그룹: ${groupsResult.rows.length}개`); + + // group_id 매핑 (매핑 복사 시 사용) + const groupIdMap = new Map(); + + for (const group of groupsResult.rows) { + // 대상 회사에 같은 relation_code가 있는지 확인 + const existing = await client.query( + `SELECT group_id FROM category_value_cascading_group + WHERE relation_code = $1 AND company_code = $2`, + [group.relation_code, targetCompanyCode] + ); + + if (existing.rows.length > 0) { + // 이미 존재하면 스킵 (기존 설정 유지) + groupIdMap.set(group.group_id, existing.rows[0].group_id); + logger.info(` ↳ ${group.relation_name}: 이미 존재 (스킵)`); + continue; + } + + // menu_objid 재매핑 + const newParentMenuObjid = group.parent_menu_objid + ? menuIdMap.get(Number(group.parent_menu_objid)) || null + : null; + const newChildMenuObjid = group.child_menu_objid + ? menuIdMap.get(Number(group.child_menu_objid)) || null + : null; + + // 새로 삽입 + const insertResult = await client.query( + `INSERT INTO category_value_cascading_group ( + relation_code, relation_name, description, + parent_table_name, parent_column_name, parent_menu_objid, + child_table_name, child_column_name, child_menu_objid, + clear_on_parent_change, show_group_label, + empty_parent_message, no_options_message, + company_code, is_active, created_by, created_date + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, NOW()) + RETURNING group_id`, + [ + group.relation_code, + group.relation_name, + group.description, + group.parent_table_name, + group.parent_column_name, + newParentMenuObjid, + group.child_table_name, + group.child_column_name, + newChildMenuObjid, + group.clear_on_parent_change, + group.show_group_label, + group.empty_parent_message, + group.no_options_message, + targetCompanyCode, + "Y", + userId, + ] + ); + + const newGroupId = insertResult.rows[0].group_id; + groupIdMap.set(group.group_id, newGroupId); + logger.info(` ↳ ${group.relation_name}: 신규 추가 (ID: ${newGroupId})`); + copiedCount++; + + // 해당 그룹의 매핑 복사 + const mappingsResult = await client.query( + `SELECT * FROM category_value_cascading_mapping + WHERE group_id = $1 AND company_code = $2`, + [group.group_id, sourceCompanyCode] + ); + + for (const mapping of mappingsResult.rows) { + await client.query( + `INSERT INTO category_value_cascading_mapping ( + group_id, parent_value_code, parent_value_label, + child_value_code, child_value_label, display_order, + company_code, is_active, created_date + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW())`, + [ + newGroupId, + mapping.parent_value_code, + mapping.parent_value_label, + mapping.child_value_code, + mapping.child_value_label, + mapping.display_order, + targetCompanyCode, + "Y", + ] + ); + } + + if (mappingsResult.rows.length > 0) { + logger.info(` ↳ 매핑 ${mappingsResult.rows.length}개 복사`); + } + } + + // === 2. cascading_relation 복사 (테이블 기반) === + const relationsResult = await client.query( + `SELECT * FROM cascading_relation + WHERE company_code = $1 AND is_active = 'Y'`, + [sourceCompanyCode] + ); + + logger.info(` 기본 연쇄관계: ${relationsResult.rows.length}개`); + + for (const relation of relationsResult.rows) { + // 대상 회사에 같은 relation_code가 있는지 확인 + const existing = await client.query( + `SELECT relation_id FROM cascading_relation + WHERE relation_code = $1 AND company_code = $2`, + [relation.relation_code, targetCompanyCode] + ); + + if (existing.rows.length > 0) { + logger.info(` ↳ ${relation.relation_name}: 이미 존재 (스킵)`); + continue; + } + + // 새로 삽입 + await client.query( + `INSERT INTO cascading_relation ( + relation_code, relation_name, description, + parent_table, parent_value_column, parent_label_column, + child_table, child_filter_column, child_value_column, child_label_column, + child_order_column, child_order_direction, + empty_parent_message, no_options_message, loading_message, + clear_on_parent_change, company_code, is_active, created_by, created_date + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, NOW())`, + [ + relation.relation_code, + relation.relation_name, + relation.description, + relation.parent_table, + relation.parent_value_column, + relation.parent_label_column, + relation.child_table, + relation.child_filter_column, + relation.child_value_column, + relation.child_label_column, + relation.child_order_column, + relation.child_order_direction, + relation.empty_parent_message, + relation.no_options_message, + relation.loading_message, + relation.clear_on_parent_change, + targetCompanyCode, + "Y", + userId, + ] + ); + logger.info(` ↳ ${relation.relation_name}: 신규 추가`); + copiedCount++; + } + + logger.info(`✅ 연쇄관계 복사 완료: ${copiedCount}개`); + return copiedCount; + } + } diff --git a/docs/노드플로우_개선사항.md b/docs/노드플로우_개선사항.md index b19c7092..c2c44be0 100644 --- a/docs/노드플로우_개선사항.md +++ b/docs/노드플로우_개선사항.md @@ -585,3 +585,4 @@ const result = await executeNodeFlow(flowId, { + diff --git a/docs/메일발송_기능_사용_가이드.md b/docs/메일발송_기능_사용_가이드.md index f0805640..4ffb7655 100644 --- a/docs/메일발송_기능_사용_가이드.md +++ b/docs/메일발송_기능_사용_가이드.md @@ -358,3 +358,4 @@ + diff --git a/docs/즉시저장_버튼_액션_구현_계획서.md b/docs/즉시저장_버튼_액션_구현_계획서.md index 69e34f5a..1de42fb2 100644 --- a/docs/즉시저장_버튼_액션_구현_계획서.md +++ b/docs/즉시저장_버튼_액션_구현_계획서.md @@ -344,3 +344,4 @@ const getComponentValue = (componentId: string) => { 4. **연쇄 저장**: 한 번의 클릭으로 여러 테이블에 저장 + diff --git a/frontend/components/admin/MenuCopyDialog.tsx b/frontend/components/admin/MenuCopyDialog.tsx index 88d29de6..c33e726b 100644 --- a/frontend/components/admin/MenuCopyDialog.tsx +++ b/frontend/components/admin/MenuCopyDialog.tsx @@ -61,6 +61,7 @@ export function MenuCopyDialog({ const [copyNumberingRules, setCopyNumberingRules] = useState(false); const [copyCategoryMapping, setCopyCategoryMapping] = useState(false); const [copyTableTypeColumns, setCopyTableTypeColumns] = useState(false); + const [copyCascadingRelation, setCopyCascadingRelation] = useState(false); // 회사 목록 로드 useEffect(() => { @@ -76,6 +77,7 @@ export function MenuCopyDialog({ setCopyNumberingRules(false); setCopyCategoryMapping(false); setCopyTableTypeColumns(false); + setCopyCascadingRelation(false); } }, [open]); @@ -128,6 +130,7 @@ export function MenuCopyDialog({ copyNumberingRules, copyCategoryMapping, copyTableTypeColumns, + copyCascadingRelation, }; const response = await menuApi.copyMenu( @@ -344,6 +347,20 @@ export function MenuCopyDialog({ 테이블 타입관리 입력타입 설정 복사 +
+ setCopyCascadingRelation(checked as boolean)} + disabled={copying} + /> + +
)} @@ -410,6 +427,12 @@ export function MenuCopyDialog({ {result.copiedTableTypeColumns}개 )} + {(result.copiedCascadingRelations ?? 0) > 0 && ( +
+ 연쇄관계:{" "} + {result.copiedCascadingRelations}개 +
+ )} )} diff --git a/frontend/contexts/ActiveTabContext.tsx b/frontend/contexts/ActiveTabContext.tsx index 9a18a44d..340cf26f 100644 --- a/frontend/contexts/ActiveTabContext.tsx +++ b/frontend/contexts/ActiveTabContext.tsx @@ -138,3 +138,4 @@ export const useActiveTabOptional = () => { }; + diff --git a/frontend/hooks/useAutoFill.ts b/frontend/hooks/useAutoFill.ts index a1a2f711..ba81e49e 100644 --- a/frontend/hooks/useAutoFill.ts +++ b/frontend/hooks/useAutoFill.ts @@ -195,3 +195,4 @@ export function applyAutoFillToFormData( + diff --git a/frontend/lib/api/menu.ts b/frontend/lib/api/menu.ts index 5119b7e4..82ab39ac 100644 --- a/frontend/lib/api/menu.ts +++ b/frontend/lib/api/menu.ts @@ -163,7 +163,7 @@ export const menuApi = { } }, - // 메뉴 복사 + // 메뉴 복사 (타임아웃 5분 - 대량 데이터 처리) copyMenu: async ( menuObjid: number, targetCompanyCode: string, @@ -176,6 +176,7 @@ export const menuApi = { copyNumberingRules?: boolean; copyCategoryMapping?: boolean; copyTableTypeColumns?: boolean; + copyCascadingRelation?: boolean; } ): Promise> => { try { @@ -185,11 +186,24 @@ export const menuApi = { targetCompanyCode, screenNameConfig, additionalCopyOptions + }, + { + timeout: 300000, // 5분 (메뉴 복사는 많은 데이터를 처리하므로 긴 타임아웃 필요) } ); return response.data; } catch (error: any) { console.error("❌ 메뉴 복사 실패:", error); + + // 타임아웃 에러 구분 처리 + if (error.code === "ECONNABORTED" || error.message?.includes("timeout")) { + return { + success: false, + message: "메뉴 복사 요청 시간이 초과되었습니다. 백엔드에서 작업이 완료되었을 수 있으니 잠시 후 확인해주세요.", + errorCode: "MENU_COPY_TIMEOUT", + }; + } + return { success: false, message: error.response?.data?.message || "메뉴 복사 중 오류가 발생했습니다", @@ -211,6 +225,7 @@ export interface MenuCopyResult { copiedNumberingRules?: number; copiedCategoryMappings?: number; copiedTableTypeColumns?: number; + copiedCascadingRelations?: number; menuIdMap: Record; screenIdMap: Record; flowIdMap: Record; diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx index 68a1553c..03c2efb8 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx @@ -269,7 +269,7 @@ export function UniversalFormModalComponent({ // 설정에 정의된 필드 columnName 목록 수집 const configuredFields = new Set(); config.sections.forEach((section) => { - section.fields.forEach((field) => { + (section.fields || []).forEach((field) => { if (field.columnName) { configuredFields.add(field.columnName); } @@ -319,7 +319,7 @@ export function UniversalFormModalComponent({ // 모든 섹션의 필드에서 linkedFieldGroup.sourceTable 수집 config.sections.forEach((section) => { - section.fields.forEach((field) => { + (section.fields || []).forEach((field) => { if (field.linkedFieldGroup?.enabled && field.linkedFieldGroup?.sourceTable) { tablesToLoad.add(field.linkedFieldGroup.sourceTable); } @@ -374,7 +374,7 @@ export function UniversalFormModalComponent({ newRepeatSections[section.id] = items; } else { // 일반 섹션 필드 초기화 - for (const field of section.fields) { + for (const field of section.fields || []) { // 기본값 설정 let value = field.defaultValue ?? ""; @@ -405,7 +405,7 @@ export function UniversalFormModalComponent({ console.log(`[initializeForm] 옵셔널 그룹 자동 활성화: ${key}, triggerField=${group.triggerField}, value=${triggerValue}`); // 활성화된 그룹의 필드값도 초기화 - for (const field of group.fields) { + for (const field of group.fields || []) { let value = field.defaultValue ?? ""; const parentField = field.parentFieldName || field.columnName; if (effectiveInitialData[parentField] !== undefined) { @@ -448,7 +448,7 @@ export function UniversalFormModalComponent({ _index: index, }; - for (const field of section.fields) { + for (const field of section.fields || []) { item[field.columnName] = field.defaultValue ?? ""; } @@ -481,7 +481,7 @@ export function UniversalFormModalComponent({ for (const section of config.sections) { if (section.repeatable) continue; - for (const field of section.fields) { + for (const field of section.fields || []) { if ( field.numberingRule?.enabled && field.numberingRule?.generateOnOpen && @@ -653,7 +653,7 @@ export function UniversalFormModalComponent({ } // 옵셔널 필드 그룹 필드 값 초기화 - group.fields.forEach((field) => { + (group.fields || []).forEach((field) => { handleFieldChange(field.columnName, field.defaultValue || ""); }); }, [config, handleFieldChange]); @@ -783,7 +783,7 @@ export function UniversalFormModalComponent({ for (const section of config.sections) { if (section.repeatable) continue; // 반복 섹션은 별도 검증 - for (const field of section.fields) { + for (const field of section.fields || []) { if (field.required && !field.hidden && !field.numberingRule?.hidden) { const value = formData[field.columnName]; if (value === undefined || value === null || value === "") { @@ -809,7 +809,7 @@ export function UniversalFormModalComponent({ // 저장 시점 채번규칙 처리 (generateOnSave만 처리) for (const section of config.sections) { - for (const field of section.fields) { + for (const field of section.fields || []) { if (field.numberingRule?.enabled && field.numberingRule?.generateOnSave && field.numberingRule?.ruleId) { const response = await allocateNumberingCode(field.numberingRule.ruleId); if (response.success && response.data?.generatedCode) { @@ -840,7 +840,7 @@ export function UniversalFormModalComponent({ // 공통 필드가 설정되지 않은 경우, 기본정보 섹션의 모든 필드를 공통 필드로 사용 if (commonFields.length === 0) { const nonRepeatableSections = config.sections.filter((s) => !s.repeatable); - commonFields = nonRepeatableSections.flatMap((s) => s.fields.map((f) => f.columnName)); + commonFields = nonRepeatableSections.flatMap((s) => (s.fields || []).map((f) => f.columnName)); } // 반복 섹션 ID가 설정되지 않은 경우, 첫 번째 반복 섹션 사용 @@ -886,7 +886,7 @@ export function UniversalFormModalComponent({ // 반복 섹션의 필드 값 추가 const repeatSection = config.sections.find((s) => s.id === repeatSectionId); - repeatSection?.fields.forEach((field) => { + (repeatSection?.fields || []).forEach((field) => { if (item[field.columnName] !== undefined) { subRow[field.columnName] = item[field.columnName]; } @@ -903,7 +903,7 @@ export function UniversalFormModalComponent({ for (const section of config.sections) { if (section.repeatable) continue; - for (const field of section.fields) { + for (const field of section.fields || []) { if (field.numberingRule?.enabled && field.numberingRule?.ruleId) { // generateOnSave 또는 generateOnOpen 모두 저장 시 실제 순번 할당 const shouldAllocate = field.numberingRule.generateOnSave || field.numberingRule.generateOnOpen; @@ -952,7 +952,7 @@ export function UniversalFormModalComponent({ const mainData: Record = {}; config.sections.forEach((section) => { if (section.repeatable) return; // 반복 섹션은 제외 - section.fields.forEach((field) => { + (section.fields || []).forEach((field) => { const value = formData[field.columnName]; if (value !== undefined && value !== null && value !== "") { mainData[field.columnName] = value; @@ -964,7 +964,7 @@ export function UniversalFormModalComponent({ for (const section of config.sections) { if (section.repeatable) continue; - for (const field of section.fields) { + for (const field of section.fields || []) { // 채번규칙이 활성화된 필드 처리 if (field.numberingRule?.enabled && field.numberingRule?.ruleId) { // 신규 생성이거나 값이 없는 경우에만 채번 @@ -1055,7 +1055,7 @@ export function UniversalFormModalComponent({ else { config.sections.forEach((section) => { if (section.repeatable) return; - const matchingField = section.fields.find((f) => f.columnName === mapping.targetColumn); + const matchingField = (section.fields || []).find((f) => f.columnName === mapping.targetColumn); if (matchingField && mainData[matchingField.columnName] !== undefined) { mainFieldMappings!.push({ formField: matchingField.columnName, @@ -1560,7 +1560,7 @@ export function UniversalFormModalComponent({
{/* 일반 필드 렌더링 */} - {section.fields.map((field) => + {(section.fields || []).map((field) => renderFieldWithColumns( field, formData[field.columnName], @@ -1582,7 +1582,7 @@ export function UniversalFormModalComponent({
{/* 일반 필드 렌더링 */} - {section.fields.map((field) => + {(section.fields || []).map((field) => renderFieldWithColumns( field, formData[field.columnName], @@ -1719,7 +1719,7 @@ export function UniversalFormModalComponent({
- {group.fields.map((field) => + {(group.fields || []).map((field) => renderFieldWithColumns( field, formData[field.columnName], @@ -1763,7 +1763,7 @@ export function UniversalFormModalComponent({
- {group.fields.map((field) => + {(group.fields || []).map((field) => renderFieldWithColumns( field, formData[field.columnName], @@ -1819,7 +1819,7 @@ export function UniversalFormModalComponent({
{/* 일반 필드 렌더링 */} - {section.fields.map((field) => + {(section.fields || []).map((field) => renderFieldWithColumns( field, item[field.columnName], @@ -1898,7 +1898,7 @@ export function UniversalFormModalComponent({

{config.modal.title || "범용 폼 모달"}

- {config.sections.length}개 섹션 |{config.sections.reduce((acc, s) => acc + s.fields.length, 0)}개 필드 + {config.sections.length}개 섹션 |{config.sections.reduce((acc, s) => acc + (s.fields?.length || 0), 0)}개 필드

저장 테이블: {config.saveConfig.tableName || "(미설정)"}

diff --git a/화면_임베딩_및_데이터_전달_시스템_구현_계획서.md b/화면_임베딩_및_데이터_전달_시스템_구현_계획서.md index 275272ce..f962f9c3 100644 --- a/화면_임베딩_및_데이터_전달_시스템_구현_계획서.md +++ b/화면_임베딩_및_데이터_전달_시스템_구현_계획서.md @@ -1687,3 +1687,4 @@ const 출고등록_설정: ScreenSplitPanel = { + diff --git a/화면_임베딩_시스템_Phase1-4_구현_완료.md b/화면_임베딩_시스템_Phase1-4_구현_완료.md index f88df210..a480f7ec 100644 --- a/화면_임베딩_시스템_Phase1-4_구현_완료.md +++ b/화면_임베딩_시스템_Phase1-4_구현_완료.md @@ -534,3 +534,4 @@ const { data: config } = await getScreenSplitPanel(screenId); + diff --git a/화면_임베딩_시스템_충돌_분석_보고서.md b/화면_임베딩_시스템_충돌_분석_보고서.md index 0f74ba8d..eb56a747 100644 --- a/화면_임베딩_시스템_충돌_분석_보고서.md +++ b/화면_임베딩_시스템_충돌_분석_보고서.md @@ -521,3 +521,4 @@ function ScreenViewPage() { + From 51c788cae8a169e49205ebb96f92cba9e3a75b8e Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 19 Dec 2025 09:26:44 +0900 Subject: [PATCH 4/6] =?UTF-8?q?=EB=B3=B5=EC=82=AC=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/services/menuCopyService.ts | 1288 ++++++++++-------- 1 file changed, 723 insertions(+), 565 deletions(-) diff --git a/backend-node/src/services/menuCopyService.ts b/backend-node/src/services/menuCopyService.ts index 683d71ba..5c4fde7f 100644 --- a/backend-node/src/services/menuCopyService.ts +++ b/backend-node/src/services/menuCopyService.ts @@ -662,9 +662,9 @@ export class MenuCopyService { ); // 화면 정의 삭제 - await client.query( - `DELETE FROM screen_definitions - WHERE screen_id = ANY($1) AND company_code = $2`, + await client.query( + `DELETE FROM screen_definitions + WHERE screen_id = ANY($1) AND company_code = $2`, [screensToDelete, targetCompanyCode] ); logger.info(` ✅ 화면 정의 삭제 완료: ${screensToDelete.length}개`); @@ -794,10 +794,10 @@ export class MenuCopyService { const ruleResult = await this.copyNumberingRulesWithMap( menuObjids, menuIdMap, // 실제 생성된 메뉴 ID 사용 - targetCompanyCode, - userId, - client - ); + targetCompanyCode, + userId, + client + ); copiedNumberingRules = ruleResult.copiedCount; numberingRuleIdMap = ruleResult.ruleIdMap; } @@ -938,144 +938,182 @@ export class MenuCopyService { return flowIdMap; } + const flowIdArray = Array.from(flowIds); logger.info(`🔄 플로우 복사 중: ${flowIds.size}개`); - logger.info(` 📋 복사 대상 flowIds: [${Array.from(flowIds).join(", ")}]`); + logger.info(` 📋 복사 대상 flowIds: [${flowIdArray.join(", ")}]`); - for (const originalFlowId of flowIds) { - try { - // 1) 원본 flow_definition 조회 - const flowDefResult = await client.query( - `SELECT * FROM flow_definition WHERE id = $1`, - [originalFlowId] - ); + // === 최적화: 배치 조회 === + // 1) 모든 원본 플로우 한 번에 조회 + const allFlowDefsResult = await client.query( + `SELECT * FROM flow_definition WHERE id = ANY($1)`, + [flowIdArray] + ); + const flowDefMap = new Map(allFlowDefsResult.rows.map(f => [f.id, f])); - if (flowDefResult.rows.length === 0) { + // 2) 대상 회사의 기존 플로우 한 번에 조회 (이름+테이블 기준) + const flowNames = allFlowDefsResult.rows.map(f => f.name); + const existingFlowsResult = await client.query<{ id: number; name: string; table_name: string }>( + `SELECT id, name, table_name FROM flow_definition + WHERE company_code = $1 AND name = ANY($2)`, + [targetCompanyCode, flowNames] + ); + const existingFlowMap = new Map( + existingFlowsResult.rows.map(f => [`${f.name}|${f.table_name}`, f.id]) + ); + + // 3) 복사가 필요한 플로우 ID 목록 + const flowsToCopy: FlowDefinition[] = []; + + for (const originalFlowId of flowIdArray) { + const flowDef = flowDefMap.get(originalFlowId); + if (!flowDef) { logger.warn(`⚠️ 플로우를 찾을 수 없음: id=${originalFlowId}`); continue; } - const flowDef = flowDefResult.rows[0]; - logger.info(` 🔍 원본 플로우 발견: id=${originalFlowId}, name="${flowDef.name}", table="${flowDef.table_name}", company="${flowDef.company_code}"`); + const key = `${flowDef.name}|${flowDef.table_name}`; + const existingId = existingFlowMap.get(key); - // 2) 대상 회사에 이미 같은 이름+테이블의 플로우가 있는지 확인 - const existingFlowResult = await client.query<{ id: number }>( - `SELECT id FROM flow_definition - WHERE company_code = $1 AND name = $2 AND table_name = $3 - LIMIT 1`, - [targetCompanyCode, flowDef.name, flowDef.table_name] - ); + if (existingId) { + flowIdMap.set(originalFlowId, existingId); + logger.info(` ♻️ 기존 플로우 재사용: ${originalFlowId} → ${existingId} (${flowDef.name})`); + } else { + flowsToCopy.push(flowDef); + } + } - let newFlowId: number; + // 4) 새 플로우 복사 (배치 처리) + if (flowsToCopy.length > 0) { + // 배치 INSERT로 플로우 생성 + const flowValues = flowsToCopy.map((f, i) => + `($${i * 8 + 1}, $${i * 8 + 2}, $${i * 8 + 3}, $${i * 8 + 4}, $${i * 8 + 5}, $${i * 8 + 6}, $${i * 8 + 7}, $${i * 8 + 8})` + ).join(", "); + + const flowParams = flowsToCopy.flatMap(f => [ + f.name, f.description, f.table_name, f.is_active, + targetCompanyCode, userId, f.db_source_type, f.db_connection_id + ]); - if (existingFlowResult.rows.length > 0) { - // 기존 플로우가 있으면 재사용 - newFlowId = existingFlowResult.rows[0].id; - flowIdMap.set(originalFlowId, newFlowId); - logger.info( - ` ♻️ 기존 플로우 재사용: ${originalFlowId} → ${newFlowId} (${flowDef.name})` - ); - continue; // 스텝/연결 복사 생략 (기존 것 사용) - } - - // 3) 새 flow_definition 복사 - const newFlowResult = await client.query<{ id: number }>( + const newFlowsResult = await client.query<{ id: number }>( `INSERT INTO flow_definition ( name, description, table_name, is_active, company_code, created_by, db_source_type, db_connection_id - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ) VALUES ${flowValues} RETURNING id`, - [ - flowDef.name, - flowDef.description, - flowDef.table_name, - flowDef.is_active, - targetCompanyCode, // 새 회사 코드 - userId, - flowDef.db_source_type, - flowDef.db_connection_id, - ] - ); + flowParams + ); - newFlowId = newFlowResult.rows[0].id; - flowIdMap.set(originalFlowId, newFlowId); + // 새 플로우 ID 매핑 + flowsToCopy.forEach((flowDef, index) => { + const newFlowId = newFlowsResult.rows[index].id; + flowIdMap.set(flowDef.id, newFlowId); + logger.info(` ✅ 플로우 신규 복사: ${flowDef.id} → ${newFlowId} (${flowDef.name})`); + }); - logger.info( - ` ✅ 플로우 신규 복사: ${originalFlowId} → ${newFlowId} (${flowDef.name})` - ); + // 5) 스텝 및 연결 복사 (복사된 플로우만) + const originalFlowIdsToCopy = flowsToCopy.map(f => f.id); - // 3) flow_step 복사 - const stepsResult = await client.query( - `SELECT * FROM flow_step WHERE flow_definition_id = $1 ORDER BY step_order`, - [originalFlowId] - ); + // 모든 스텝 한 번에 조회 + const allStepsResult = await client.query( + `SELECT * FROM flow_step WHERE flow_definition_id = ANY($1) ORDER BY flow_definition_id, step_order`, + [originalFlowIdsToCopy] + ); + // 플로우별 스텝 그룹핑 + const stepsByFlow = new Map(); + for (const step of allStepsResult.rows) { + if (!stepsByFlow.has(step.flow_definition_id)) { + stepsByFlow.set(step.flow_definition_id, []); + } + stepsByFlow.get(step.flow_definition_id)!.push(step); + } + + // 스텝 복사 (플로우별) + const allStepIdMaps = new Map>(); // originalFlowId -> stepIdMap + + for (const originalFlowId of originalFlowIdsToCopy) { + const newFlowId = flowIdMap.get(originalFlowId)!; + const steps = stepsByFlow.get(originalFlowId) || []; const stepIdMap = new Map(); - for (const step of stepsResult.rows) { - const newStepResult = await client.query<{ id: number }>( + if (steps.length > 0) { + // 배치 INSERT로 스텝 생성 + const stepValues = steps.map((_, i) => + `($${i * 17 + 1}, $${i * 17 + 2}, $${i * 17 + 3}, $${i * 17 + 4}, $${i * 17 + 5}, $${i * 17 + 6}, $${i * 17 + 7}, $${i * 17 + 8}, $${i * 17 + 9}, $${i * 17 + 10}, $${i * 17 + 11}, $${i * 17 + 12}, $${i * 17 + 13}, $${i * 17 + 14}, $${i * 17 + 15}, $${i * 17 + 16}, $${i * 17 + 17})` + ).join(", "); + + const stepParams = steps.flatMap(s => [ + newFlowId, s.step_name, s.step_order, s.condition_json, + s.color, s.position_x, s.position_y, s.table_name, s.move_type, + s.status_column, s.status_value, s.target_table, s.field_mappings, + s.required_fields, s.integration_type, s.integration_config, s.display_config + ]); + + const newStepsResult = await client.query<{ id: number }>( `INSERT INTO flow_step ( flow_definition_id, step_name, step_order, condition_json, color, position_x, position_y, table_name, move_type, status_column, status_value, target_table, field_mappings, required_fields, integration_type, integration_config, display_config - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) + ) VALUES ${stepValues} RETURNING id`, - [ - newFlowId, // 새 플로우 ID - step.step_name, - step.step_order, - step.condition_json, - step.color, - step.position_x, - step.position_y, - step.table_name, - step.move_type, - step.status_column, - step.status_value, - step.target_table, - step.field_mappings, - step.required_fields, - step.integration_type, - step.integration_config, - step.display_config, - ] + stepParams ); - const newStepId = newStepResult.rows[0].id; - stepIdMap.set(step.id, newStepId); + steps.forEach((step, index) => { + stepIdMap.set(step.id, newStepsResult.rows[index].id); + }); + + logger.info(` ↳ 플로우 ${originalFlowId}: 스텝 ${steps.length}개 복사`); } - logger.info(` ↳ 스텝 복사: ${stepIdMap.size}개`); + allStepIdMaps.set(originalFlowId, stepIdMap); + } - // 4) flow_step_connection 복사 (스텝 ID 재매핑) - const connectionsResult = await client.query( - `SELECT * FROM flow_step_connection WHERE flow_definition_id = $1`, - [originalFlowId] - ); + // 모든 연결 한 번에 조회 + const allConnectionsResult = await client.query( + `SELECT * FROM flow_step_connection WHERE flow_definition_id = ANY($1)`, + [originalFlowIdsToCopy] + ); + + // 연결 복사 (배치 INSERT) + const connectionsToInsert: { newFlowId: number; newFromStepId: number; newToStepId: number; label: string }[] = []; + + for (const conn of allConnectionsResult.rows) { + const stepIdMap = allStepIdMaps.get(conn.flow_definition_id); + if (!stepIdMap) continue; - for (const conn of connectionsResult.rows) { const newFromStepId = stepIdMap.get(conn.from_step_id); const newToStepId = stepIdMap.get(conn.to_step_id); + const newFlowId = flowIdMap.get(conn.flow_definition_id); - if (!newFromStepId || !newToStepId) { - logger.warn( - `⚠️ 스텝 ID 매핑 실패: ${conn.from_step_id} → ${conn.to_step_id}` - ); - continue; - } + if (newFromStepId && newToStepId && newFlowId) { + connectionsToInsert.push({ + newFlowId, + newFromStepId, + newToStepId, + label: conn.label || "" + }); + } + } + + if (connectionsToInsert.length > 0) { + const connValues = connectionsToInsert.map((_, i) => + `($${i * 4 + 1}, $${i * 4 + 2}, $${i * 4 + 3}, $${i * 4 + 4})` + ).join(", "); + + const connParams = connectionsToInsert.flatMap(c => [ + c.newFlowId, c.newFromStepId, c.newToStepId, c.label + ]); await client.query( `INSERT INTO flow_step_connection ( flow_definition_id, from_step_id, to_step_id, label - ) VALUES ($1, $2, $3, $4)`, - [newFlowId, newFromStepId, newToStepId, conn.label] + ) VALUES ${connValues}`, + connParams ); - } - logger.info(` ↳ 연결 복사: ${connectionsResult.rows.length}개`); - } catch (error: any) { - logger.error(`❌ 플로우 복사 실패: id=${originalFlowId}`, error); - throw error; + logger.info(` ↳ 연결 ${connectionsToInsert.length}개 복사`); } } @@ -1752,7 +1790,7 @@ export class MenuCopyService { } /** - * 화면-메뉴 할당 + * 화면-메뉴 할당 (최적화: 배치 조회/삽입) */ private async createScreenMenuAssignments( menus: Menu[], @@ -1763,57 +1801,83 @@ export class MenuCopyService { ): Promise { logger.info(`🔗 화면-메뉴 할당 중...`); - let assignmentCount = 0; + if (menus.length === 0) { + return; + } - for (const menu of menus) { - const newMenuObjid = menuIdMap.get(menu.objid); - if (!newMenuObjid) continue; + // === 최적화: 배치 조회 === + // 1. 모든 원본 메뉴의 화면 할당 한 번에 조회 + const menuObjids = menus.map(m => m.objid); + const companyCodes = [...new Set(menus.map(m => m.company_code))]; - // 원본 메뉴에 할당된 화면 조회 - const assignmentsResult = await client.query<{ + const allAssignmentsResult = await client.query<{ + menu_objid: number; screen_id: number; display_order: number; is_active: string; }>( - `SELECT screen_id, display_order, is_active + `SELECT menu_objid, screen_id, display_order, is_active FROM screen_menu_assignments - WHERE menu_objid = $1 AND company_code = $2`, - [menu.objid, menu.company_code] - ); + WHERE menu_objid = ANY($1) AND company_code = ANY($2)`, + [menuObjids, companyCodes] + ); - for (const assignment of assignmentsResult.rows) { + if (allAssignmentsResult.rows.length === 0) { + logger.info(` 📭 화면-메뉴 할당 없음`); + return; + } + + // 2. 유효한 할당만 필터링 + const validAssignments: Array<{ + newScreenId: number; + newMenuObjid: number; + displayOrder: number; + isActive: string; + }> = []; + + for (const assignment of allAssignmentsResult.rows) { + const newMenuObjid = menuIdMap.get(assignment.menu_objid); const newScreenId = screenIdMap.get(assignment.screen_id); + + if (!newMenuObjid || !newScreenId) { if (!newScreenId) { - logger.warn( - `⚠️ 화면 ID 매핑 없음: screen_id=${assignment.screen_id}` - ); + logger.warn(`⚠️ 화면 ID 매핑 없음: screen_id=${assignment.screen_id}`); + } continue; } - // 새 할당 생성 + validAssignments.push({ + newScreenId, + newMenuObjid, + displayOrder: assignment.display_order, + isActive: assignment.is_active + }); + } + + // 3. 배치 INSERT + if (validAssignments.length > 0) { + const assignmentValues = validAssignments.map((_, i) => + `($${i * 6 + 1}, $${i * 6 + 2}, $${i * 6 + 3}, $${i * 6 + 4}, $${i * 6 + 5}, $${i * 6 + 6})` + ).join(", "); + + const assignmentParams = validAssignments.flatMap(a => [ + a.newScreenId, a.newMenuObjid, targetCompanyCode, + a.displayOrder, a.isActive, "system" + ]); + await client.query( `INSERT INTO screen_menu_assignments ( screen_id, menu_objid, company_code, display_order, is_active, created_by - ) VALUES ($1, $2, $3, $4, $5, $6)`, - [ - newScreenId, // 재매핑 - newMenuObjid, // 재매핑 - targetCompanyCode, - assignment.display_order, - assignment.is_active, - "system", - ] - ); - - assignmentCount++; - } + ) VALUES ${assignmentValues}`, + assignmentParams + ); } - logger.info(`✅ 화면-메뉴 할당 완료: ${assignmentCount}개`); + logger.info(`✅ 화면-메뉴 할당 완료: ${validAssignments.length}개`); } /** - * 코드 카테고리 + 코드 복사 + * 코드 카테고리 + 코드 복사 (최적화: 배치 조회/삽입) */ private async copyCodeCategoriesAndCodes( menuObjids: number[], @@ -1825,92 +1889,112 @@ export class MenuCopyService { let copiedCategories = 0; let copiedCodes = 0; - for (const menuObjid of menuObjids) { - const newMenuObjid = menuIdMap.get(menuObjid); - if (!newMenuObjid) continue; + if (menuObjids.length === 0) { + return { copiedCategories, copiedCodes }; + } - // 1. 코드 카테고리 조회 - const categoriesResult = await client.query( - `SELECT * FROM code_category WHERE menu_objid = $1`, - [menuObjid] + // === 최적화: 배치 조회 === + // 1. 모든 원본 카테고리 한 번에 조회 + const allCategoriesResult = await client.query( + `SELECT * FROM code_category WHERE menu_objid = ANY($1)`, + [menuObjids] + ); + + if (allCategoriesResult.rows.length === 0) { + logger.info(` 📭 복사할 코드 카테고리 없음`); + return { copiedCategories, copiedCodes }; + } + + // 2. 대상 회사에 이미 존재하는 카테고리 한 번에 조회 + const categoryCodes = allCategoriesResult.rows.map(c => c.category_code); + const existingCategoriesResult = await client.query( + `SELECT category_code FROM code_category + WHERE category_code = ANY($1) AND company_code = $2`, + [categoryCodes, targetCompanyCode] + ); + const existingCategoryCodes = new Set(existingCategoriesResult.rows.map(c => c.category_code)); + + // 3. 복사할 카테고리 필터링 + const categoriesToCopy = allCategoriesResult.rows.filter( + c => !existingCategoryCodes.has(c.category_code) + ); + + // 4. 배치 INSERT로 카테고리 복사 + if (categoriesToCopy.length > 0) { + const categoryValues = categoriesToCopy.map((_, i) => + `($${i * 9 + 1}, $${i * 9 + 2}, $${i * 9 + 3}, $${i * 9 + 4}, $${i * 9 + 5}, $${i * 9 + 6}, NOW(), $${i * 9 + 7}, $${i * 9 + 8}, $${i * 9 + 9})` + ).join(", "); + + const categoryParams = categoriesToCopy.flatMap(c => { + const newMenuObjid = menuIdMap.get(c.menu_objid); + return [ + c.category_code, c.category_name, c.category_name_eng, c.description, + c.sort_order, c.is_active, userId, targetCompanyCode, newMenuObjid + ]; + }); + + await client.query( + `INSERT INTO code_category ( + category_code, category_name, category_name_eng, description, + sort_order, is_active, created_date, created_by, company_code, menu_objid + ) VALUES ${categoryValues}`, + categoryParams ); - for (const category of categoriesResult.rows) { - // 대상 회사에 같은 category_code가 이미 있는지 확인 - const existingCategory = await client.query( - `SELECT category_code FROM code_category - WHERE category_code = $1 AND company_code = $2`, - [category.category_code, targetCompanyCode] - ); + copiedCategories = categoriesToCopy.length; + logger.info(` ✅ 코드 카테고리 ${copiedCategories}개 복사`); + } - if (existingCategory.rows.length > 0) { - logger.info(` ♻️ 코드 카테고리 이미 존재 (스킵): ${category.category_code}`); - continue; - } + // 5. 모든 원본 코드 한 번에 조회 + const allCodesResult = await client.query( + `SELECT * FROM code_info WHERE menu_objid = ANY($1)`, + [menuObjids] + ); - // 카테고리 복사 - await client.query( - `INSERT INTO code_category ( - category_code, category_name, category_name_eng, description, - sort_order, is_active, created_date, created_by, company_code, menu_objid - ) VALUES ($1, $2, $3, $4, $5, $6, NOW(), $7, $8, $9)`, - [ - category.category_code, - category.category_name, - category.category_name_eng, - category.description, - category.sort_order, - category.is_active, - userId, - targetCompanyCode, - newMenuObjid, - ] - ); - copiedCategories++; - logger.info(` ✅ 코드 카테고리 복사: ${category.category_code}`); + if (allCodesResult.rows.length === 0) { + logger.info(` 📭 복사할 코드 없음`); + return { copiedCategories, copiedCodes }; + } - // 2. 해당 카테고리의 코드 조회 및 복사 - const codesResult = await client.query( - `SELECT * FROM code_info - WHERE code_category = $1 AND menu_objid = $2`, - [category.category_code, menuObjid] - ); + // 6. 대상 회사에 이미 존재하는 코드 한 번에 조회 + const existingCodesResult = await client.query( + `SELECT code_category, code_value FROM code_info + WHERE menu_objid = ANY($1) AND company_code = $2`, + [Array.from(menuIdMap.values()), targetCompanyCode] + ); + const existingCodeKeys = new Set( + existingCodesResult.rows.map(c => `${c.code_category}|${c.code_value}`) + ); - for (const code of codesResult.rows) { - // 대상 회사에 같은 code_value가 이미 있는지 확인 - const existingCode = await client.query( - `SELECT code_value FROM code_info - WHERE code_category = $1 AND code_value = $2 AND company_code = $3`, - [category.category_code, code.code_value, targetCompanyCode] - ); + // 7. 복사할 코드 필터링 + const codesToCopy = allCodesResult.rows.filter( + c => !existingCodeKeys.has(`${c.code_category}|${c.code_value}`) + ); - if (existingCode.rows.length > 0) { - logger.info(` ♻️ 코드 이미 존재 (스킵): ${code.code_value}`); - continue; - } + // 8. 배치 INSERT로 코드 복사 + if (codesToCopy.length > 0) { + const codeValues = codesToCopy.map((_, i) => + `($${i * 10 + 1}, $${i * 10 + 2}, $${i * 10 + 3}, $${i * 10 + 4}, $${i * 10 + 5}, $${i * 10 + 6}, $${i * 10 + 7}, NOW(), $${i * 10 + 8}, $${i * 10 + 9}, $${i * 10 + 10})` + ).join(", "); - await client.query( - `INSERT INTO code_info ( - code_category, code_value, code_name, code_name_eng, description, - sort_order, is_active, created_date, created_by, company_code, menu_objid - ) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), $8, $9, $10)`, - [ - category.category_code, - code.code_value, - code.code_name, - code.code_name_eng, - code.description, - code.sort_order, - code.is_active, - userId, - targetCompanyCode, - newMenuObjid, - ] - ); - copiedCodes++; - } - logger.info(` ↳ 코드 ${codesResult.rows.length}개 복사 완료`); - } + const codeParams = codesToCopy.flatMap(c => { + const newMenuObjid = menuIdMap.get(c.menu_objid); + return [ + c.code_category, c.code_value, c.code_name, c.code_name_eng, c.description, + c.sort_order, c.is_active, userId, targetCompanyCode, newMenuObjid + ]; + }); + + await client.query( + `INSERT INTO code_info ( + code_category, code_value, code_name, code_name_eng, description, + sort_order, is_active, created_date, created_by, company_code, menu_objid + ) VALUES ${codeValues}`, + codeParams + ); + + copiedCodes = codesToCopy.length; + logger.info(` ✅ 코드 ${copiedCodes}개 복사`); } logger.info(`✅ 코드 카테고리 + 코드 복사 완료: 카테고리 ${copiedCategories}개, 코드 ${copiedCodes}개`); @@ -1918,7 +2002,7 @@ export class MenuCopyService { } /** - * 채번 규칙 복사 (ID 매핑 반환 버전) + * 채번 규칙 복사 (최적화: 배치 조회/삽입) * 화면 복사 전에 호출되어 numberingRuleId 참조 업데이트에 사용됨 */ private async copyNumberingRulesWithMap( @@ -1931,90 +2015,111 @@ export class MenuCopyService { let copiedCount = 0; const ruleIdMap = new Map(); - for (const menuObjid of menuObjids) { - const newMenuObjid = menuIdMap.get(menuObjid); - if (!newMenuObjid) continue; + if (menuObjids.length === 0) { + return { copiedCount, ruleIdMap }; + } - // 채번 규칙 조회 - const rulesResult = await client.query( - `SELECT * FROM numbering_rules WHERE menu_objid = $1`, - [menuObjid] - ); + // === 최적화: 배치 조회 === + // 1. 모든 원본 채번 규칙 한 번에 조회 + const allRulesResult = await client.query( + `SELECT * FROM numbering_rules WHERE menu_objid = ANY($1)`, + [menuObjids] + ); - for (const rule of rulesResult.rows) { - // 대상 회사에 같은 rule_id가 이미 있는지 확인 - const existingRule = await client.query( - `SELECT rule_id FROM numbering_rules - WHERE rule_id = $1 AND company_code = $2`, - [rule.rule_id, targetCompanyCode] - ); + if (allRulesResult.rows.length === 0) { + logger.info(` 📭 복사할 채번 규칙 없음`); + return { copiedCount, ruleIdMap }; + } - if (existingRule.rows.length > 0) { - logger.info(` ♻️ 채번규칙 이미 존재 (스킵): ${rule.rule_id}`); - // 기존 rule_id도 매핑에 추가 (동일한 ID 유지) - ruleIdMap.set(rule.rule_id, rule.rule_id); - continue; - } + // 2. 대상 회사에 이미 존재하는 채번 규칙 한 번에 조회 + const ruleIds = allRulesResult.rows.map(r => r.rule_id); + const existingRulesResult = await client.query( + `SELECT rule_id FROM numbering_rules + WHERE rule_id = ANY($1) AND company_code = $2`, + [ruleIds, targetCompanyCode] + ); + const existingRuleIds = new Set(existingRulesResult.rows.map(r => r.rule_id)); - // 새 rule_id 생성 (회사코드_원본rule_id에서 기존 접두사 제거) + // 3. 복사할 규칙과 스킵할 규칙 분류 + const rulesToCopy: any[] = []; + const originalToNewRuleMap: Array<{ original: string; new: string }> = []; + + for (const rule of allRulesResult.rows) { + if (existingRuleIds.has(rule.rule_id)) { + // 기존 규칙은 동일한 ID로 매핑 + ruleIdMap.set(rule.rule_id, rule.rule_id); + logger.info(` ♻️ 채번규칙 이미 존재 (스킵): ${rule.rule_id}`); + } else { + // 새 rule_id 생성 const originalSuffix = rule.rule_id.includes('_') ? rule.rule_id.replace(/^[^_]*_/, '') : rule.rule_id; const newRuleId = `${targetCompanyCode}_${originalSuffix}`; - - // 매핑 저장 (원본 rule_id → 새 rule_id) + ruleIdMap.set(rule.rule_id, newRuleId); + originalToNewRuleMap.push({ original: rule.rule_id, new: newRuleId }); + rulesToCopy.push({ ...rule, newRuleId }); + } + } + + // 4. 배치 INSERT로 채번 규칙 복사 + if (rulesToCopy.length > 0) { + const ruleValues = rulesToCopy.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 newMenuObjid = menuIdMap.get(r.menu_objid); + return [ + r.newRuleId, r.rule_name, r.description, r.separator, r.reset_period, + 0, r.table_name, r.column_name, targetCompanyCode, + userId, newMenuObjid, r.scope_type, null + ]; + }); + + 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, created_by, menu_objid, scope_type, last_generated_date + ) VALUES ${ruleValues}`, + ruleParams + ); + + copiedCount = rulesToCopy.length; + logger.info(` ✅ 채번 규칙 ${copiedCount}개 복사`); + + // 5. 모든 원본 파트 한 번에 조회 + const originalRuleIds = rulesToCopy.map(r => r.rule_id); + const allPartsResult = await client.query( + `SELECT * FROM numbering_rule_parts + WHERE rule_id = ANY($1) ORDER BY rule_id, part_order`, + [originalRuleIds] + ); + + // 6. 배치 INSERT로 채번 규칙 파트 복사 + if (allPartsResult.rows.length > 0) { + // 원본 rule_id -> 새 rule_id 매핑 + const ruleMapping = new Map(originalToNewRuleMap.map(m => [m.original, m.new])); + + const partValues = allPartsResult.rows.map((_, i) => + `($${i * 7 + 1}, $${i * 7 + 2}, $${i * 7 + 3}, $${i * 7 + 4}, $${i * 7 + 5}, $${i * 7 + 6}, $${i * 7 + 7}, NOW())` + ).join(", "); + + const partParams = allPartsResult.rows.flatMap(p => [ + ruleMapping.get(p.rule_id), p.part_order, p.part_type, p.generation_method, + p.auto_config, p.manual_config, targetCompanyCode + ]); - // 채번 규칙 복사 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, created_by, menu_objid, scope_type, last_generated_date - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), $10, $11, $12, $13)`, - [ - newRuleId, - rule.rule_name, - rule.description, - rule.separator, - rule.reset_period, - 0, // 시퀀스는 0부터 시작 - rule.table_name, - rule.column_name, - targetCompanyCode, - userId, - newMenuObjid, - rule.scope_type, - null, // 마지막 생성일은 null로 초기화 - ] - ); - copiedCount++; - logger.info(` ✅ 채번규칙 복사: ${rule.rule_id} → ${newRuleId}`); - - // 채번 규칙 파트 복사 - const partsResult = await client.query( - `SELECT * FROM numbering_rule_parts WHERE rule_id = $1 ORDER BY part_order`, - [rule.rule_id] + `INSERT INTO numbering_rule_parts ( + rule_id, part_order, part_type, generation_method, + auto_config, manual_config, company_code, created_at + ) VALUES ${partValues}`, + partParams ); - 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, - part.manual_config, - targetCompanyCode, - ] - ); - } - logger.info(` ↳ 채번규칙 파트 ${partsResult.rows.length}개 복사`); + logger.info(` ✅ 채번 규칙 파트 ${allPartsResult.rows.length}개 복사`); } } @@ -2023,7 +2128,7 @@ export class MenuCopyService { } /** - * 카테고리 매핑 + 값 복사 + * 카테고리 매핑 + 값 복사 (최적화: 배치 조회) */ private async copyCategoryMappingsAndValues( menuObjids: number[], @@ -2034,122 +2139,171 @@ export class MenuCopyService { ): Promise { let copiedCount = 0; - for (const menuObjid of menuObjids) { - const newMenuObjid = menuIdMap.get(menuObjid); - if (!newMenuObjid) continue; + if (menuObjids.length === 0) { + return copiedCount; + } - // 1. 카테고리 컬럼 매핑 조회 - const mappingsResult = await client.query( - `SELECT * FROM category_column_mapping WHERE menu_objid = $1`, - [menuObjid] + // === 최적화: 배치 조회 === + // 1. 모든 원본 카테고리 매핑 한 번에 조회 + const allMappingsResult = await client.query( + `SELECT * FROM category_column_mapping WHERE menu_objid = ANY($1)`, + [menuObjids] + ); + + if (allMappingsResult.rows.length === 0) { + logger.info(` 📭 복사할 카테고리 매핑 없음`); + return copiedCount; + } + + // 2. 대상 회사에 이미 존재하는 매핑 한 번에 조회 + const existingMappingsResult = await client.query( + `SELECT mapping_id, table_name, logical_column_name + FROM category_column_mapping WHERE company_code = $1`, + [targetCompanyCode] + ); + const existingMappingKeys = new Map( + existingMappingsResult.rows.map(m => [`${m.table_name}|${m.logical_column_name}`, m.mapping_id]) + ); + + // 3. 복사할 매핑 필터링 및 배치 INSERT + const mappingsToCopy = allMappingsResult.rows.filter( + m => !existingMappingKeys.has(`${m.table_name}|${m.logical_column_name}`) + ); + + // 새 매핑 ID -> 원본 매핑 정보 추적 + const mappingInsertInfo: Array<{ mapping: any; newMenuObjid: number }> = []; + + if (mappingsToCopy.length > 0) { + const mappingValues = mappingsToCopy.map((_, i) => + `($${i * 7 + 1}, $${i * 7 + 2}, $${i * 7 + 3}, $${i * 7 + 4}, $${i * 7 + 5}, $${i * 7 + 6}, NOW(), $${i * 7 + 7})` + ).join(", "); + + const mappingParams = mappingsToCopy.flatMap(m => { + const newMenuObjid = menuIdMap.get(m.menu_objid) || 0; + mappingInsertInfo.push({ mapping: m, newMenuObjid }); + return [ + m.table_name, m.logical_column_name, m.physical_column_name, + newMenuObjid, targetCompanyCode, m.description, userId + ]; + }); + + const insertResult = await client.query( + `INSERT INTO category_column_mapping ( + table_name, logical_column_name, physical_column_name, + menu_objid, company_code, description, created_at, created_by + ) VALUES ${mappingValues} + RETURNING mapping_id`, + mappingParams ); - for (const mapping of mappingsResult.rows) { - // 대상 회사에 같은 매핑이 이미 있는지 확인 - const existingMapping = await client.query( - `SELECT mapping_id FROM category_column_mapping - WHERE table_name = $1 AND logical_column_name = $2 AND company_code = $3`, - [mapping.table_name, mapping.logical_column_name, targetCompanyCode] - ); + // 새로 생성된 매핑 ID를 기존 매핑 맵에 추가 + insertResult.rows.forEach((row, index) => { + const m = mappingsToCopy[index]; + existingMappingKeys.set(`${m.table_name}|${m.logical_column_name}`, row.mapping_id); + }); - let newMappingId: number; + copiedCount = mappingsToCopy.length; + logger.info(` ✅ 카테고리 매핑 ${copiedCount}개 복사`); + } - if (existingMapping.rows.length > 0) { - logger.info(` ♻️ 카테고리 매핑 이미 존재: ${mapping.table_name}.${mapping.logical_column_name}`); - newMappingId = existingMapping.rows[0].mapping_id; - } else { - // 매핑 복사 - const insertResult = await client.query( - `INSERT INTO category_column_mapping ( - table_name, logical_column_name, physical_column_name, - menu_objid, company_code, description, created_at, created_by - ) VALUES ($1, $2, $3, $4, $5, $6, NOW(), $7) - RETURNING mapping_id`, - [ - mapping.table_name, - mapping.logical_column_name, - mapping.physical_column_name, - newMenuObjid, - targetCompanyCode, - mapping.description, - userId, - ] - ); - newMappingId = insertResult.rows[0].mapping_id; - copiedCount++; - logger.info(` ✅ 카테고리 매핑 복사: ${mapping.table_name}.${mapping.logical_column_name}`); - } + // 4. 모든 원본 카테고리 값 한 번에 조회 + const allValuesResult = await client.query( + `SELECT * FROM table_column_category_values + WHERE menu_objid = ANY($1) + ORDER BY depth NULLS FIRST, parent_value_id NULLS FIRST, value_order`, + [menuObjids] + ); - // 2. 카테고리 값 조회 및 복사 (menu_objid 기준) - const valuesResult = await client.query( - `SELECT * FROM table_column_category_values - WHERE table_name = $1 AND column_name = $2 AND menu_objid = $3 - ORDER BY parent_value_id NULLS FIRST, value_order`, - [mapping.table_name, mapping.logical_column_name, menuObjid] - ); + if (allValuesResult.rows.length === 0) { + logger.info(`✅ 카테고리 매핑 + 값 복사 완료: ${copiedCount}개`); + return copiedCount; + } - // 값 ID 매핑 (부모-자식 관계 유지를 위해) - const valueIdMap = new Map(); + // 5. 대상 회사에 이미 존재하는 값 한 번에 조회 + const existingValuesResult = await client.query( + `SELECT value_id, table_name, column_name, value_code + FROM table_column_category_values WHERE company_code = $1`, + [targetCompanyCode] + ); + const existingValueKeys = new Map( + existingValuesResult.rows.map(v => [`${v.table_name}|${v.column_name}|${v.value_code}`, v.value_id]) + ); - for (const value of valuesResult.rows) { - // 대상 회사에 같은 값이 이미 있는지 확인 - const existingValue = await client.query( - `SELECT value_id FROM table_column_category_values - WHERE table_name = $1 AND column_name = $2 AND value_code = $3 AND company_code = $4`, - [value.table_name, value.column_name, value.value_code, targetCompanyCode] - ); + // 6. 값 복사 (부모-자식 관계 유지를 위해 depth 순서로 처리) + const valueIdMap = new Map(); + let copiedValues = 0; - if (existingValue.rows.length > 0) { - valueIdMap.set(value.value_id, existingValue.rows[0].value_id); - continue; - } - - // 부모 ID 재매핑 - const newParentId = value.parent_value_id - ? valueIdMap.get(value.parent_value_id) || null - : null; - - const insertResult = await client.query( - `INSERT INTO table_column_category_values ( - table_name, column_name, value_code, value_label, value_order, - parent_value_id, depth, description, color, icon, - is_active, is_default, company_code, created_at, created_by, menu_objid - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, NOW(), $14, $15) - RETURNING value_id`, - [ - value.table_name, - value.column_name, - value.value_code, - value.value_label, - value.value_order, - newParentId, - value.depth, - value.description, - value.color, - value.icon, - value.is_active, - value.is_default, - targetCompanyCode, - userId, - newMenuObjid, - ] - ); - - valueIdMap.set(value.value_id, insertResult.rows[0].value_id); - } - - if (valuesResult.rows.length > 0) { - logger.info(` ↳ 카테고리 값 ${valuesResult.rows.length}개 처리`); - } + // 이미 존재하는 값들의 ID 매핑 + for (const value of allValuesResult.rows) { + const key = `${value.table_name}|${value.column_name}|${value.value_code}`; + const existingId = existingValueKeys.get(key); + if (existingId) { + valueIdMap.set(value.value_id, existingId); } } + // depth별로 그룹핑하여 배치 처리 (부모가 먼저 삽입되어야 함) + const valuesByDepth = new Map(); + for (const value of allValuesResult.rows) { + const key = `${value.table_name}|${value.column_name}|${value.value_code}`; + if (existingValueKeys.has(key)) continue; // 이미 존재하면 스킵 + + const depth = value.depth ?? 0; + if (!valuesByDepth.has(depth)) { + valuesByDepth.set(depth, []); + } + valuesByDepth.get(depth)!.push(value); + } + + // depth 순서대로 처리 + const sortedDepths = Array.from(valuesByDepth.keys()).sort((a, b) => a - b); + + for (const depth of sortedDepths) { + const values = valuesByDepth.get(depth)!; + if (values.length === 0) continue; + + const valueStrings = values.map((_, i) => + `($${i * 15 + 1}, $${i * 15 + 2}, $${i * 15 + 3}, $${i * 15 + 4}, $${i * 15 + 5}, $${i * 15 + 6}, $${i * 15 + 7}, $${i * 15 + 8}, $${i * 15 + 9}, $${i * 15 + 10}, $${i * 15 + 11}, $${i * 15 + 12}, NOW(), $${i * 15 + 13}, $${i * 15 + 14}, $${i * 15 + 15})` + ).join(", "); + + const valueParams = values.flatMap(v => { + const newMenuObjid = menuIdMap.get(v.menu_objid); + const newParentId = v.parent_value_id ? valueIdMap.get(v.parent_value_id) || null : null; + return [ + v.table_name, v.column_name, v.value_code, v.value_label, v.value_order, + newParentId, v.depth, v.description, v.color, v.icon, + v.is_active, v.is_default, userId, targetCompanyCode, newMenuObjid + ]; + }); + + const insertResult = await client.query( + `INSERT INTO table_column_category_values ( + table_name, column_name, value_code, value_label, value_order, + parent_value_id, depth, description, color, icon, + is_active, is_default, created_at, created_by, company_code, menu_objid + ) VALUES ${valueStrings} + RETURNING value_id`, + valueParams + ); + + // 새 value_id 매핑 + insertResult.rows.forEach((row, index) => { + valueIdMap.set(values[index].value_id, row.value_id); + }); + + copiedValues += values.length; + } + + if (copiedValues > 0) { + logger.info(` ✅ 카테고리 값 ${copiedValues}개 복사`); + } + logger.info(`✅ 카테고리 매핑 + 값 복사 완료: ${copiedCount}개`); return copiedCount; } /** - * 테이블 타입관리 입력타입 설정 복사 + * 테이블 타입관리 입력타입 설정 복사 (최적화: 배치 조회/삽입) * - 복사된 화면에서 사용하는 테이블들의 table_type_columns 설정을 대상 회사로 복사 */ private async copyTableTypeColumns( @@ -2163,8 +2317,8 @@ export class MenuCopyService { } logger.info(`📋 테이블 타입 설정 복사 시작`); - logger.info(` 원본 화면 IDs: ${screenIds.join(", ")}`); + // === 최적화: 배치 조회 === // 1. 복사된 화면에서 사용하는 테이블 목록 조회 const tablesResult = await client.query<{ table_name: string }>( `SELECT DISTINCT table_name FROM screen_definitions @@ -2180,66 +2334,61 @@ export class MenuCopyService { const tableNames = tablesResult.rows.map((r) => r.table_name); logger.info(` 사용 테이블: ${tableNames.join(", ")}`); - let copiedCount = 0; + // 2. 원본 회사의 모든 테이블 타입 설정 한 번에 조회 + const sourceSettingsResult = await client.query( + `SELECT * FROM table_type_columns + WHERE table_name = ANY($1) AND company_code = $2`, + [tableNames, sourceCompanyCode] + ); - for (const tableName of tableNames) { - // 2. 원본 회사의 테이블 타입 설정 조회 - const sourceSettings = await client.query( - `SELECT * FROM table_type_columns - WHERE table_name = $1 AND company_code = $2`, - [tableName, sourceCompanyCode] - ); - - if (sourceSettings.rows.length === 0) { - logger.info(` ⚠️ ${tableName}: 원본 회사 설정 없음 (기본 설정 사용)`); - continue; - } - - for (const setting of sourceSettings.rows) { - // 3. 대상 회사에 같은 설정이 이미 있는지 확인 - const existing = await client.query( - `SELECT id FROM table_type_columns - WHERE table_name = $1 AND column_name = $2 AND company_code = $3`, - [setting.table_name, setting.column_name, targetCompanyCode] - ); - - if (existing.rows.length > 0) { - // 이미 존재하면 스킵 (대상 회사에서 커스터마이징한 설정 유지) - logger.info( - ` ↳ ${setting.table_name}.${setting.column_name}: 이미 존재 (스킵)` - ); - continue; - } - - // 새로 삽입 - await client.query( - `INSERT INTO table_type_columns ( - table_name, column_name, input_type, detail_settings, - is_nullable, display_order, created_date, updated_date, company_code - ) VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW(), $7)`, - [ - setting.table_name, - setting.column_name, - setting.input_type, - setting.detail_settings, - setting.is_nullable, - setting.display_order, - targetCompanyCode, - ] - ); - logger.info( - ` ↳ ${setting.table_name}.${setting.column_name}: 신규 추가` - ); - copiedCount++; - } + if (sourceSettingsResult.rows.length === 0) { + logger.info(` ⚠️ 원본 회사 설정 없음`); + return 0; } - logger.info(`✅ 테이블 타입 설정 복사 완료: ${copiedCount}개`); - return copiedCount; + // 3. 대상 회사의 기존 설정 한 번에 조회 + const existingSettingsResult = await client.query( + `SELECT table_name, column_name FROM table_type_columns + WHERE table_name = ANY($1) AND company_code = $2`, + [tableNames, targetCompanyCode] + ); + const existingKeys = new Set( + existingSettingsResult.rows.map(s => `${s.table_name}|${s.column_name}`) + ); + + // 4. 복사할 설정 필터링 + const settingsToCopy = sourceSettingsResult.rows.filter( + s => !existingKeys.has(`${s.table_name}|${s.column_name}`) + ); + + logger.info(` 원본 설정: ${sourceSettingsResult.rows.length}개, 복사 대상: ${settingsToCopy.length}개`); + + // 5. 배치 INSERT + if (settingsToCopy.length > 0) { + const settingValues = settingsToCopy.map((_, i) => + `($${i * 7 + 1}, $${i * 7 + 2}, $${i * 7 + 3}, $${i * 7 + 4}, $${i * 7 + 5}, $${i * 7 + 6}, NOW(), NOW(), $${i * 7 + 7})` + ).join(", "); + + const settingParams = settingsToCopy.flatMap(s => [ + s.table_name, s.column_name, s.input_type, s.detail_settings, + s.is_nullable, s.display_order, targetCompanyCode + ]); + + await client.query( + `INSERT INTO table_type_columns ( + table_name, column_name, input_type, detail_settings, + is_nullable, display_order, created_date, updated_date, company_code + ) VALUES ${settingValues}`, + settingParams + ); + } + + logger.info(`✅ 테이블 타입 설정 복사 완료: ${settingsToCopy.length}개`); + return settingsToCopy.length; } /** - * 연쇄관계 복사 + * 연쇄관계 복사 (최적화: 배치 조회/삽입) * - category_value_cascading_group + category_value_cascading_mapping * - cascading_relation (테이블 기반) */ @@ -2260,99 +2409,106 @@ export class MenuCopyService { [sourceCompanyCode] ); - logger.info(` 카테고리 값 연쇄 그룹: ${groupsResult.rows.length}개`); + if (groupsResult.rows.length === 0) { + logger.info(` 카테고리 값 연쇄 그룹: 0개`); + } else { + logger.info(` 카테고리 값 연쇄 그룹: ${groupsResult.rows.length}개`); - // group_id 매핑 (매핑 복사 시 사용) - const groupIdMap = new Map(); - - for (const group of groupsResult.rows) { - // 대상 회사에 같은 relation_code가 있는지 확인 - const existing = await client.query( - `SELECT group_id FROM category_value_cascading_group - WHERE relation_code = $1 AND company_code = $2`, - [group.relation_code, targetCompanyCode] + // 대상 회사의 기존 그룹 한 번에 조회 + const existingGroupsResult = await client.query( + `SELECT group_id, relation_code FROM category_value_cascading_group + WHERE company_code = $1`, + [targetCompanyCode] + ); + const existingGroupsByCode = new Map( + existingGroupsResult.rows.map(g => [g.relation_code, g.group_id]) ); - if (existing.rows.length > 0) { - // 이미 존재하면 스킵 (기존 설정 유지) - groupIdMap.set(group.group_id, existing.rows[0].group_id); - logger.info(` ↳ ${group.relation_name}: 이미 존재 (스킵)`); - continue; + // group_id 매핑 + const groupIdMap = new Map(); + const groupsToCopy: any[] = []; + + for (const group of groupsResult.rows) { + const existingGroupId = existingGroupsByCode.get(group.relation_code); + if (existingGroupId) { + groupIdMap.set(group.group_id, existingGroupId); + } else { + groupsToCopy.push(group); + } } - // menu_objid 재매핑 - const newParentMenuObjid = group.parent_menu_objid - ? menuIdMap.get(Number(group.parent_menu_objid)) || null - : null; - const newChildMenuObjid = group.child_menu_objid - ? menuIdMap.get(Number(group.child_menu_objid)) || null - : null; + logger.info(` 기존: ${groupsResult.rows.length - groupsToCopy.length}개, 신규: ${groupsToCopy.length}개`); - // 새로 삽입 - const insertResult = await client.query( - `INSERT INTO category_value_cascading_group ( - relation_code, relation_name, description, - parent_table_name, parent_column_name, parent_menu_objid, - child_table_name, child_column_name, child_menu_objid, - clear_on_parent_change, show_group_label, - empty_parent_message, no_options_message, - company_code, is_active, created_by, created_date - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, NOW()) - RETURNING group_id`, - [ - group.relation_code, - group.relation_name, - group.description, - group.parent_table_name, - group.parent_column_name, - newParentMenuObjid, - group.child_table_name, - group.child_column_name, - newChildMenuObjid, - group.clear_on_parent_change, - group.show_group_label, - group.empty_parent_message, - group.no_options_message, - targetCompanyCode, - "Y", - userId, - ] - ); + // 그룹별로 삽입하고 매핑 저장 (RETURNING이 필요해서 배치 불가) + for (const group of groupsToCopy) { + const newParentMenuObjid = group.parent_menu_objid + ? menuIdMap.get(Number(group.parent_menu_objid)) || null + : null; + const newChildMenuObjid = group.child_menu_objid + ? menuIdMap.get(Number(group.child_menu_objid)) || null + : null; - const newGroupId = insertResult.rows[0].group_id; - groupIdMap.set(group.group_id, newGroupId); - logger.info(` ↳ ${group.relation_name}: 신규 추가 (ID: ${newGroupId})`); - copiedCount++; - - // 해당 그룹의 매핑 복사 - const mappingsResult = await client.query( - `SELECT * FROM category_value_cascading_mapping - WHERE group_id = $1 AND company_code = $2`, - [group.group_id, sourceCompanyCode] - ); - - for (const mapping of mappingsResult.rows) { - await client.query( - `INSERT INTO category_value_cascading_mapping ( - group_id, parent_value_code, parent_value_label, - child_value_code, child_value_label, display_order, - company_code, is_active, created_date - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW())`, + const insertResult = await client.query( + `INSERT INTO category_value_cascading_group ( + relation_code, relation_name, description, + parent_table_name, parent_column_name, parent_menu_objid, + child_table_name, child_column_name, child_menu_objid, + clear_on_parent_change, show_group_label, + empty_parent_message, no_options_message, + company_code, is_active, created_by, created_date + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, NOW()) + RETURNING group_id`, [ - newGroupId, - mapping.parent_value_code, - mapping.parent_value_label, - mapping.child_value_code, - mapping.child_value_label, - mapping.display_order, - targetCompanyCode, - "Y", + group.relation_code, group.relation_name, group.description, + group.parent_table_name, group.parent_column_name, newParentMenuObjid, + group.child_table_name, group.child_column_name, newChildMenuObjid, + group.clear_on_parent_change, group.show_group_label, + group.empty_parent_message, group.no_options_message, + targetCompanyCode, "Y", userId ] ); + + const newGroupId = insertResult.rows[0].group_id; + groupIdMap.set(group.group_id, newGroupId); + copiedCount++; } - if (mappingsResult.rows.length > 0) { - logger.info(` ↳ 매핑 ${mappingsResult.rows.length}개 복사`); + // 모든 매핑 한 번에 조회 (복사할 그룹만) + const groupIdsToCopy = groupsToCopy.map(g => g.group_id); + if (groupIdsToCopy.length > 0) { + const allMappingsResult = await client.query( + `SELECT * FROM category_value_cascading_mapping + WHERE group_id = ANY($1) AND company_code = $2 + ORDER BY group_id, display_order`, + [groupIdsToCopy, sourceCompanyCode] + ); + + // 배치 INSERT + if (allMappingsResult.rows.length > 0) { + const mappingValues = allMappingsResult.rows.map((_, i) => + `($${i * 8 + 1}, $${i * 8 + 2}, $${i * 8 + 3}, $${i * 8 + 4}, $${i * 8 + 5}, $${i * 8 + 6}, $${i * 8 + 7}, $${i * 8 + 8}, NOW())` + ).join(", "); + + const mappingParams = allMappingsResult.rows.flatMap(m => { + const newGroupId = groupIdMap.get(m.group_id); + return [ + newGroupId, m.parent_value_code, m.parent_value_label, + m.child_value_code, m.child_value_label, m.display_order, + targetCompanyCode, "Y" + ]; + }); + + await client.query( + `INSERT INTO category_value_cascading_mapping ( + group_id, parent_value_code, parent_value_label, + child_value_code, child_value_label, display_order, + company_code, is_active, created_date + ) VALUES ${mappingValues}`, + mappingParams + ); + + logger.info(` ↳ 매핑 ${allMappingsResult.rows.length}개 복사`); + } } } @@ -2363,55 +2519,57 @@ export class MenuCopyService { [sourceCompanyCode] ); - logger.info(` 기본 연쇄관계: ${relationsResult.rows.length}개`); + if (relationsResult.rows.length === 0) { + logger.info(` 기본 연쇄관계: 0개`); + } else { + logger.info(` 기본 연쇄관계: ${relationsResult.rows.length}개`); - for (const relation of relationsResult.rows) { - // 대상 회사에 같은 relation_code가 있는지 확인 - const existing = await client.query( - `SELECT relation_id FROM cascading_relation - WHERE relation_code = $1 AND company_code = $2`, - [relation.relation_code, targetCompanyCode] + // 대상 회사의 기존 관계 한 번에 조회 + const existingRelationsResult = await client.query( + `SELECT relation_code FROM cascading_relation + WHERE company_code = $1`, + [targetCompanyCode] + ); + const existingRelationCodes = new Set( + existingRelationsResult.rows.map(r => r.relation_code) ); - if (existing.rows.length > 0) { - logger.info(` ↳ ${relation.relation_name}: 이미 존재 (스킵)`); - continue; + // 복사할 관계 필터링 + const relationsToCopy = relationsResult.rows.filter( + r => !existingRelationCodes.has(r.relation_code) + ); + + logger.info(` 기존: ${relationsResult.rows.length - relationsToCopy.length}개, 신규: ${relationsToCopy.length}개`); + + // 배치 INSERT + if (relationsToCopy.length > 0) { + const relationValues = relationsToCopy.map((_, i) => + `($${i * 19 + 1}, $${i * 19 + 2}, $${i * 19 + 3}, $${i * 19 + 4}, $${i * 19 + 5}, $${i * 19 + 6}, $${i * 19 + 7}, $${i * 19 + 8}, $${i * 19 + 9}, $${i * 19 + 10}, $${i * 19 + 11}, $${i * 19 + 12}, $${i * 19 + 13}, $${i * 19 + 14}, $${i * 19 + 15}, $${i * 19 + 16}, $${i * 19 + 17}, $${i * 19 + 18}, $${i * 19 + 19}, NOW())` + ).join(", "); + + const relationParams = relationsToCopy.flatMap(r => [ + r.relation_code, r.relation_name, r.description, + r.parent_table, r.parent_value_column, r.parent_label_column, + r.child_table, r.child_filter_column, r.child_value_column, r.child_label_column, + r.child_order_column, r.child_order_direction, + r.empty_parent_message, r.no_options_message, r.loading_message, + r.clear_on_parent_change, targetCompanyCode, "Y", userId + ]); + + await client.query( + `INSERT INTO cascading_relation ( + relation_code, relation_name, description, + parent_table, parent_value_column, parent_label_column, + child_table, child_filter_column, child_value_column, child_label_column, + child_order_column, child_order_direction, + empty_parent_message, no_options_message, loading_message, + clear_on_parent_change, company_code, is_active, created_by, created_date + ) VALUES ${relationValues}`, + relationParams + ); + + copiedCount += relationsToCopy.length; } - - // 새로 삽입 - await client.query( - `INSERT INTO cascading_relation ( - relation_code, relation_name, description, - parent_table, parent_value_column, parent_label_column, - child_table, child_filter_column, child_value_column, child_label_column, - child_order_column, child_order_direction, - empty_parent_message, no_options_message, loading_message, - clear_on_parent_change, company_code, is_active, created_by, created_date - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, NOW())`, - [ - relation.relation_code, - relation.relation_name, - relation.description, - relation.parent_table, - relation.parent_value_column, - relation.parent_label_column, - relation.child_table, - relation.child_filter_column, - relation.child_value_column, - relation.child_label_column, - relation.child_order_column, - relation.child_order_direction, - relation.empty_parent_message, - relation.no_options_message, - relation.loading_message, - relation.clear_on_parent_change, - targetCompanyCode, - "Y", - userId, - ] - ); - logger.info(` ↳ ${relation.relation_name}: 신규 추가`); - copiedCount++; } logger.info(`✅ 연쇄관계 복사 완료: ${copiedCount}개`); From 849343ecfda0bfb18c23c910e79af9b9477df872 Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 19 Dec 2025 09:27:11 +0900 Subject: [PATCH 5/6] =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9=20=EA=B3=84=ED=9A=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PLAN_RENEWAL.md | 680 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 680 insertions(+) create mode 100644 PLAN_RENEWAL.md diff --git a/PLAN_RENEWAL.md b/PLAN_RENEWAL.md new file mode 100644 index 00000000..7d5575a6 --- /dev/null +++ b/PLAN_RENEWAL.md @@ -0,0 +1,680 @@ +# Screen Designer 2.0 리뉴얼 계획: 컴포넌트 통합 및 속성 기반 고도화 + +## 1. 개요 + +현재 **68개 이상**으로 파편화된 화면 관리 컴포넌트들을 **9개의 핵심 통합 컴포넌트(Unified Components)**로 재편합니다. +각 컴포넌트는 **속성(Config)** 설정을 통해 다양한 형태(View Mode)와 기능(Behavior)을 수행하도록 설계되어, 유지보수성과 확장성을 극대화합니다. + +### 현재 컴포넌트 현황 (AS-IS) + +| 카테고리 | 파일 수 | 주요 파일들 | +| :------------- | :-----: | :------------------------------------------------------------------ | +| Widget 타입별 | 14개 | TextWidget, NumberWidget, SelectWidget, DateWidget, EntityWidget 등 | +| Config Panel | 28개 | TextConfigPanel, SelectConfigPanel, DateConfigPanel 등 | +| WebType Config | 11개 | TextTypeConfigPanel, SelectTypeConfigPanel 등 | +| 기타 패널 | 15개+ | PropertiesPanel, DetailSettingsPanel 등 | + +--- + +## 2. 통합 전략: 9 Core Widgets + +### A. 입력 위젯 (Input Widgets) - 5종 + +단순 데이터 입력 필드를 통합합니다. + +| 통합 컴포넌트 (TO-BE) | 포함되는 기존 컴포넌트 (AS-IS) | 핵심 속성 (Configuration) | +| :-------------------- | :------------------------------------------------------------------ | :----------------------------------------------------------------------------------------------------------------------------------------------- | +| **1. Unified Select** | Select, Radio, Checkbox, Boolean, Code, Entity, Combobox, Toggle | **`mode`**: "dropdown" / "radio" / "check" / "tag"
**`source`**: "static" / "code" / "db" / "api"
**`dependency`**: { parentField: "..." } | +| **2. Unified Input** | Text, Number, Email, Tel, Password, Color, Search, Integer, Decimal | **`type`**: "text" / "number" / "password"
**`format`**: "email", "currency", "biz_no"
**`mask`**: "000-0000-0000" | +| **3. Unified Date** | Date, Time, DateTime, DateRange, Month, Year | **`type`**: "date" / "time" / "datetime"
**`range`**: true/false | +| **4. Unified Text** | Textarea, RichEditor, Markdown, HTML | **`mode`**: "simple" / "rich" / "code"
**`rows`**: number | +| **5. Unified Media** | File, Image, Video, Audio, Attachment | **`type`**: "file" / "image"
**`multiple`**: true/false
**`preview`**: true/false | + +### B. 구조/데이터 위젯 (Structure & Data Widgets) - 4종 + +레이아웃 배치와 데이터 시각화를 담당합니다. + +| 통합 컴포넌트 (TO-BE) | 포함되는 기존 컴포넌트 (AS-IS) | 핵심 속성 (Configuration) | 활용 예시 | +| :-------------------- | :-------------------------------------------------- | :------------------------------------------------------------------------ | :----------------------------------------------------------------------------------------------------------------------- | +| **6. Unified List** | **Table, Card List, Repeater, DataGrid, List View** | **`viewMode`**: "table" / "card" / "kanban"
**`editable`**: true/false | - `viewMode='table'`: 엑셀형 리스트
- `viewMode='card'`: **카드 디스플레이**
- `editable=true`: **반복 필드 그룹** | +| **7. Unified Layout** | **Row, Col, Split Panel, Grid, Spacer** | **`type`**: "grid" / "split" / "flex"
**`columns`**: number | - `type='split'`: **화면 분할 패널**
- `type='grid'`: 격자 레이아웃 | +| **8. Unified Group** | Tab, Accordion, FieldSet, Modal, Section | **`type`**: "tab" / "accordion" / "modal" | - 탭이나 아코디언으로 내용 그룹화 | +| **9. Unified Biz** | **Rack Structure**, Calendar, Gantt | **`type`**: "rack" / "calendar" / "gantt" | - `type='rack'`: **랙 구조 설정**
- 특수 비즈니스 로직 플러그인 탑재 | + +### C. Config Panel 통합 전략 (핵심) + +현재 28개의 ConfigPanel을 **1개의 DynamicConfigPanel**로 통합합니다. + +| AS-IS | TO-BE | 방식 | +| :-------------------- | :--------------------- | :------------------------------- | +| TextConfigPanel.tsx | | | +| SelectConfigPanel.tsx | **DynamicConfigPanel** | DB의 `sys_input_type` 테이블에서 | +| DateConfigPanel.tsx | (단일 컴포넌트) | JSON Schema를 읽어 | +| NumberConfigPanel.tsx | | 속성 UI를 동적 생성 | +| ... 24개 더 | | | + +--- + +## 3. 구현 시나리오 (속성 기반 변신) + +### Case 1: "테이블을 카드 리스트로 변경" + +- **AS-IS**: `DataTable` 컴포넌트를 삭제하고 `CardList` 컴포넌트를 새로 추가해야 함. +- **TO-BE**: `UnifiedList`의 속성창에서 **[View Mode]**를 `Table` → `Card`로 변경하면 즉시 반영. + +### Case 2: "단일 선택을 라디오 버튼으로 변경" + +- **AS-IS**: `SelectWidget`을 삭제하고 `RadioWidget` 추가. +- **TO-BE**: `UnifiedSelect` 속성창에서 **[Display Mode]**를 `Dropdown` → `Radio`로 변경. + +### Case 3: "입력 폼에 반복 필드(Repeater) 추가" + +- **TO-BE**: `UnifiedList` 컴포넌트 배치 후 `editable: true`, `viewMode: "table"` 설정. + +--- + +## 4. 실행 로드맵 (Action Plan) + +### Phase 0: 준비 단계 (1주) + +통합 작업 전 필수 분석 및 설계를 진행합니다. + +- [ ] 기존 컴포넌트 사용 현황 분석 (화면별 위젯 사용 빈도 조사) +- [ ] 데이터 마이그레이션 전략 설계 (`widgetType` → `UnifiedWidget.type` 매핑 정의) +- [ ] `sys_input_type` 테이블 JSON Schema 설계 +- [ ] DynamicConfigPanel 프로토타입 설계 + +### Phase 1: 입력 위젯 통합 (2주) + +가장 중복이 많고 효과가 즉각적인 입력 필드부터 통합합니다. + +- [ ] **UnifiedInput 구현**: Text, Number, Email, Tel, Password 통합 +- [ ] **UnifiedSelect 구현**: Select, Radio, Checkbox, Boolean 통합 +- [ ] **UnifiedDate 구현**: Date, DateTime, Time 통합 +- [ ] 기존 위젯과 **병행 운영** (deprecated 마킹, 삭제하지 않음) + +### Phase 2: Config Panel 통합 (2주) + +28개의 ConfigPanel을 단일 동적 패널로 통합합니다. + +- [ ] **DynamicConfigPanel 구현**: DB 스키마 기반 속성 UI 자동 생성 +- [ ] `sys_input_type` 테이블에 위젯별 JSON Schema 정의 저장 +- [ ] 기존 ConfigPanel과 **병행 운영** (삭제하지 않음) + +### Phase 3: 데이터/레이아웃 위젯 통합 (2주) + +프로젝트의 데이터를 보여주는 핵심 뷰를 통합합니다. + +- [ ] **UnifiedList 구현**: Table, Card, Repeater 통합 렌더러 개발 +- [ ] **UnifiedLayout 구현**: Split Panel, Grid, Flex 통합 +- [ ] **UnifiedGroup 구현**: Tab, Accordion, Modal 통합 + +### Phase 4: 안정화 및 마이그레이션 (2주) + +신규 컴포넌트 안정화 후 점진적 전환을 진행합니다. + +- [ ] 신규 화면은 Unified 컴포넌트만 사용하도록 가이드 +- [ ] 기존 화면 데이터 마이그레이션 스크립트 개발 +- [ ] 마이그레이션 테스트 (스테이징 환경) +- [ ] 문서화 및 개발 가이드 작성 + +### Phase 5: 레거시 정리 (추후 결정) + +충분한 안정화 기간 후 레거시 컴포넌트 정리를 검토합니다. + +- [ ] 사용 현황 재분석 (Unified 전환율 확인) +- [ ] 미전환 화면 목록 정리 +- [ ] 레거시 컴포넌트 삭제 여부 결정 (별도 회의) + +--- + +## 5. 데이터 마이그레이션 전략 + +### 5.1 위젯 타입 매핑 테이블 + +기존 `widgetType`을 신규 Unified 컴포넌트로 매핑합니다. + +| 기존 widgetType | 신규 컴포넌트 | 속성 설정 | +| :-------------- | :------------ | :------------------------------ | +| `text` | UnifiedInput | `type: "text"` | +| `number` | UnifiedInput | `type: "number"` | +| `email` | UnifiedInput | `type: "text", format: "email"` | +| `tel` | UnifiedInput | `type: "text", format: "tel"` | +| `select` | UnifiedSelect | `mode: "dropdown"` | +| `radio` | UnifiedSelect | `mode: "radio"` | +| `checkbox` | UnifiedSelect | `mode: "check"` | +| `date` | UnifiedDate | `type: "date"` | +| `datetime` | UnifiedDate | `type: "datetime"` | +| `textarea` | UnifiedText | `mode: "simple"` | +| `file` | UnifiedMedia | `type: "file"` | +| `image` | UnifiedMedia | `type: "image"` | + +### 5.2 마이그레이션 원칙 + +1. **비파괴적 전환**: 기존 데이터 구조 유지, 신규 필드 추가 방식 +2. **하위 호환성**: 기존 `widgetType` 필드는 유지, `unifiedType` 필드 추가 +3. **점진적 전환**: 화면 수정 시점에 자동 또는 수동 전환 + +--- + +## 6. 기대 효과 + +1. **컴포넌트 수 감소**: 68개 → **9개** (관리 포인트 87% 감소) +2. **Config Panel 통합**: 28개 → **1개** (DynamicConfigPanel) +3. **유연한 UI 변경**: 컴포넌트 교체 없이 속성 변경만으로 UI 모드 전환 가능 +4. **Low-Code 확장성**: 새로운 유형의 입력 방식이 필요할 때 코딩 없이 DB 설정만으로 추가 가능 + +--- + +## 7. 리스크 및 대응 방안 + +| 리스크 | 영향도 | 대응 방안 | +| :----------------------- | :----: | :-------------------------------- | +| 기존 화면 호환성 깨짐 | 높음 | 병행 운영 + 하위 호환성 유지 | +| 마이그레이션 데이터 손실 | 높음 | 백업 필수 + 롤백 스크립트 준비 | +| 개발자 학습 곡선 | 중간 | 상세 가이드 문서 + 예제 코드 제공 | +| 성능 저하 (동적 렌더링) | 중간 | 메모이제이션 + 지연 로딩 적용 | + +--- + +## 8. 현재 컴포넌트 매핑 분석 + +### 8.1 Registry 등록 컴포넌트 전수 조사 (44개) + +현재 `frontend/lib/registry/components/`에 등록된 모든 컴포넌트의 통합 가능 여부를 분석했습니다. + +#### UnifiedInput으로 통합 (4개) + +| 현재 컴포넌트 | 매핑 속성 | 비고 | +| :------------- | :--------------- | :------------- | +| text-input | `type: "text"` | | +| number-input | `type: "number"` | | +| slider-basic | `type: "slider"` | 속성 추가 필요 | +| button-primary | `type: "button"` | 별도 검토 | + +#### UnifiedSelect로 통합 (8개) + +| 현재 컴포넌트 | 매핑 속성 | 비고 | +| :------------------------ | :----------------------------------- | :------------- | +| select-basic | `mode: "dropdown"` | | +| checkbox-basic | `mode: "check"` | | +| radio-basic | `mode: "radio"` | | +| toggle-switch | `mode: "toggle"` | 속성 추가 필요 | +| autocomplete-search-input | `mode: "dropdown", searchable: true` | | +| entity-search-input | `source: "entity"` | | +| mail-recipient-selector | `mode: "multi", type: "email"` | 복합 컴포넌트 | +| location-swap-selector | `mode: "swap"` | 특수 UI | + +#### UnifiedDate로 통합 (1개) + +| 현재 컴포넌트 | 매핑 속성 | 비고 | +| :------------ | :------------- | :--- | +| date-input | `type: "date"` | | + +#### UnifiedText로 통합 (1개) + +| 현재 컴포넌트 | 매핑 속성 | 비고 | +| :------------- | :--------------- | :--- | +| textarea-basic | `mode: "simple"` | | + +#### UnifiedMedia로 통합 (3개) + +| 현재 컴포넌트 | 매핑 속성 | 비고 | +| :------------ | :------------------------------ | :--- | +| file-upload | `type: "file"` | | +| image-widget | `type: "image"` | | +| image-display | `type: "image", readonly: true` | | + +#### UnifiedList로 통합 (8개) + +| 현재 컴포넌트 | 매핑 속성 | 비고 | +| :-------------------- | :------------------------------------ | :------------ | +| table-list | `viewMode: "table"` | | +| card-display | `viewMode: "card"` | | +| repeater-field-group | `editable: true` | | +| modal-repeater-table | `viewMode: "table", modal: true` | | +| simple-repeater-table | `viewMode: "table", simple: true` | | +| repeat-screen-modal | `viewMode: "card", modal: true` | | +| table-search-widget | `viewMode: "table", searchable: true` | | +| tax-invoice-list | `viewMode: "table", bizType: "tax"` | 특수 비즈니스 | + +#### UnifiedLayout으로 통합 (4개) + +| 현재 컴포넌트 | 매핑 속성 | 비고 | +| :------------------ | :-------------------------- | :------------- | +| split-panel-layout | `type: "split"` | | +| split-panel-layout2 | `type: "split", version: 2` | | +| divider-line | `type: "divider"` | 속성 추가 필요 | +| screen-split-panel | `type: "screen-embed"` | 화면 임베딩 | + +#### UnifiedGroup으로 통합 (5개) + +| 현재 컴포넌트 | 매핑 속성 | 비고 | +| :------------------- | :--------------------- | :------------ | +| accordion-basic | `type: "accordion"` | | +| tabs | `type: "tabs"` | | +| section-paper | `type: "section"` | | +| section-card | `type: "card-section"` | | +| universal-form-modal | `type: "form-modal"` | 복합 컴포넌트 | + +#### UnifiedBiz로 통합 (7개) + +| 현재 컴포넌트 | 매핑 속성 | 비고 | +| :-------------------- | :------------------------ | :--------------- | +| flow-widget | `type: "flow"` | 플로우 관리 | +| rack-structure | `type: "rack"` | 창고 렉 구조 | +| map | `type: "map"` | 지도 | +| numbering-rule | `type: "numbering"` | 채번 규칙 | +| category-manager | `type: "category"` | 카테고리 관리 | +| customer-item-mapping | `type: "mapping"` | 거래처-품목 매핑 | +| related-data-buttons | `type: "related-buttons"` | 연관 데이터 | + +#### 별도 검토 필요 (3개) + +| 현재 컴포넌트 | 문제점 | 제안 | +| :-------------------------- | :------------------- | :------------------------------ | +| conditional-container | 조건부 렌더링 로직 | 공통 속성으로 분리 | +| selected-items-detail-input | 복합 (선택+상세입력) | UnifiedList + UnifiedGroup 조합 | +| text-display | 읽기 전용 텍스트 | UnifiedInput (readonly: true) | + +### 8.2 매핑 분석 결과 + +``` +┌─────────────────────────────────────────────────────────┐ +│ 전체 44개 컴포넌트 분석 결과 │ +├─────────────────────────────────────────────────────────┤ +│ ✅ 즉시 통합 가능 : 36개 (82%) │ +│ ⚠️ 속성 추가 후 통합 : 5개 (11%) │ +│ 🔄 별도 검토 필요 : 3개 (7%) │ +└─────────────────────────────────────────────────────────┘ +``` + +### 8.3 속성 확장 필요 사항 + +#### UnifiedInput 속성 확장 + +```typescript +// 기존 +type: "text" | "number" | "password"; + +// 확장 +type: "text" | "number" | "password" | "slider" | "color" | "button"; +``` + +#### UnifiedSelect 속성 확장 + +```typescript +// 기존 +mode: "dropdown" | "radio" | "check" | "tag"; + +// 확장 +mode: "dropdown" | "radio" | "check" | "tag" | "toggle" | "swap"; +``` + +#### UnifiedLayout 속성 확장 + +```typescript +// 기존 +type: "grid" | "split" | "flex"; + +// 확장 +type: "grid" | "split" | "flex" | "divider" | "screen-embed"; +``` + +### 8.4 조건부 렌더링 공통화 + +`conditional-container`의 기능을 모든 컴포넌트에서 사용 가능한 공통 속성으로 분리합니다. + +```typescript +// 모든 Unified 컴포넌트에 적용 가능한 공통 속성 +interface BaseUnifiedProps { + // ... 기존 속성 + + /** 조건부 렌더링 설정 */ + conditional?: { + enabled: boolean; + field: string; // 참조할 필드명 + operator: "=" | "!=" | ">" | "<" | "in" | "notIn"; + value: any; // 비교 값 + hideOnFalse?: boolean; // false일 때 숨김 (기본: true) + }; +} +``` + +--- + +## 9. 계층 구조(Hierarchy) 컴포넌트 전략 + +### 9.1 현재 계층 구조 지원 현황 + +DB 테이블 `cascading_hierarchy_group`에서 4가지 계층 타입을 지원합니다: + +| 타입 | 설명 | 예시 | +| :----------------- | :---------------------- | :--------------- | +| **MULTI_TABLE** | 다중 테이블 계층 | 국가 > 도시 > 구 | +| **SELF_REFERENCE** | 자기 참조 (단일 테이블) | 조직도, 메뉴 | +| **BOM** | 자재명세서 구조 | 부품 > 하위부품 | +| **TREE** | 일반 트리 | 카테고리 | + +### 9.2 통합 방안: UnifiedHierarchy 신설 (10번째 컴포넌트) + +계층 구조는 일반 입력/표시 위젯과 성격이 다르므로 **별도 컴포넌트로 분리**합니다. + +```typescript +interface UnifiedHierarchyProps { + /** 계층 유형 */ + type: "tree" | "org" | "bom" | "cascading"; + + /** 표시 방식 */ + viewMode: "tree" | "table" | "indent" | "dropdown"; + + /** 계층 그룹 코드 (cascading_hierarchy_group 연동) */ + source: string; + + /** 편집 가능 여부 */ + editable?: boolean; + + /** 드래그 정렬 가능 */ + draggable?: boolean; + + /** BOM 수량 표시 (BOM 타입 전용) */ + showQty?: boolean; + + /** 최대 레벨 제한 */ + maxLevel?: number; +} +``` + +### 9.3 활용 예시 + +| 설정 | 결과 | +| :---------------------------------------- | :------------------------- | +| `type: "tree", viewMode: "tree"` | 카테고리 트리뷰 | +| `type: "org", viewMode: "tree"` | 조직도 | +| `type: "bom", viewMode: "indent"` | BOM 들여쓰기 테이블 | +| `type: "cascading", viewMode: "dropdown"` | 연쇄 셀렉트 (국가>도시>구) | + +--- + +## 10. 최종 통합 컴포넌트 목록 (10개) + +| # | 컴포넌트 | 역할 | 커버 범위 | +| :-: | :------------------- | :------------- | :----------------------------------- | +| 1 | **UnifiedInput** | 단일 값 입력 | text, number, slider, button 등 | +| 2 | **UnifiedSelect** | 선택 입력 | dropdown, radio, checkbox, toggle 등 | +| 3 | **UnifiedDate** | 날짜/시간 입력 | date, datetime, time, range | +| 4 | **UnifiedText** | 다중 행 텍스트 | textarea, rich editor, markdown | +| 5 | **UnifiedMedia** | 파일/미디어 | file, image, video, audio | +| 6 | **UnifiedList** | 데이터 목록 | table, card, repeater, kanban | +| 7 | **UnifiedLayout** | 레이아웃 배치 | grid, split, flex, divider | +| 8 | **UnifiedGroup** | 콘텐츠 그룹화 | tabs, accordion, section, modal | +| 9 | **UnifiedBiz** | 비즈니스 특화 | flow, rack, map, numbering 등 | +| 10 | **UnifiedHierarchy** | 계층 구조 | tree, org, bom, cascading | + +--- + +## 11. 연쇄관계 관리 메뉴 통합 전략 + +### 11.1 현재 연쇄관계 관리 현황 + +**관리 메뉴**: `연쇄 드롭다운 통합 관리` (6개 탭) + +| 탭 | DB 테이블 | 실제 데이터 | 복잡도 | +| :--------------- | :--------------------------------------- | :---------: | :----: | +| 2단계 연쇄관계 | `cascading_relation` | 2건 | 낮음 | +| 다단계 계층 | `cascading_hierarchy_group/level` | 1건 | 높음 | +| 조건부 필터 | `cascading_condition` | 0건 | 중간 | +| 자동 입력 | `cascading_auto_fill_group/mapping` | 0건 | 낮음 | +| 상호 배제 | `cascading_mutual_exclusion` | 0건 | 낮음 | +| 카테고리 값 연쇄 | `category_value_cascading_group/mapping` | 2건 | 중간 | + +### 11.2 통합 방향: 속성 기반 vs 공통 정의 + +#### 판단 기준 + +| 기능 | 재사용 빈도 | 설정 복잡도 | 권장 방식 | +| :--------------- | :---------: | :---------: | :----------------------- | +| 2단계 연쇄 | 낮음 | 간단 | **속성에 inline 정의** | +| 다단계 계층 | 높음 | 복잡 | **공통 정의 유지** | +| 조건부 필터 | 낮음 | 간단 | **속성에 inline 정의** | +| 자동 입력 | 낮음 | 간단 | **속성에 inline 정의** | +| 상호 배제 | 낮음 | 간단 | **속성에 inline 정의** | +| 카테고리 값 연쇄 | 중간 | 중간 | **카테고리 관리와 통합** | + +### 11.3 속성 통합 설계 + +#### 2단계 연쇄 → UnifiedSelect 속성 + +```typescript +// AS-IS: 별도 관리 메뉴에서 정의 후 참조 + + +// TO-BE: 컴포넌트 속성에서 직접 정의 + +``` + +#### 조건부 필터 → 공통 conditional 속성 + +```typescript +// AS-IS: 별도 관리 메뉴에서 조건 정의 +// cascading_condition 테이블에 저장 + +// TO-BE: 모든 컴포넌트에 공통 속성으로 적용 + +``` + +#### 자동 입력 → autoFill 속성 + +```typescript +// AS-IS: cascading_auto_fill_group 테이블에 정의 + +// TO-BE: 컴포넌트 속성에서 직접 정의 + +``` + +#### 상호 배제 → mutualExclusion 속성 + +```typescript +// AS-IS: cascading_mutual_exclusion 테이블에 정의 + +// TO-BE: 컴포넌트 속성에서 직접 정의 + +``` + +### 11.4 관리 메뉴 정리 계획 + +| 현재 메뉴 | TO-BE | 비고 | +| :-------------------------- | :----------------------- | :-------------------- | +| **연쇄 드롭다운 통합 관리** | **삭제** | 6개 탭 전체 제거 | +| ├─ 2단계 연쇄관계 | UnifiedSelect 속성 | inline 정의 | +| ├─ 다단계 계층 | **테이블관리로 이동** | 복잡한 구조 유지 필요 | +| ├─ 조건부 필터 | 공통 conditional 속성 | 모든 컴포넌트에 적용 | +| ├─ 자동 입력 | autoFill 속성 | 컴포넌트별 정의 | +| ├─ 상호 배제 | mutualExclusion 속성 | 컴포넌트별 정의 | +| └─ 카테고리 값 연쇄 | **카테고리 관리로 이동** | 기존 메뉴 통합 | + +### 11.5 DB 테이블 정리 (Phase 5) + +| 테이블 | 조치 | 시점 | +| :--------------------------- | :----------------------- | :------ | +| `cascading_relation` | 마이그레이션 후 삭제 | Phase 5 | +| `cascading_condition` | 삭제 (데이터 없음) | Phase 5 | +| `cascading_auto_fill_*` | 삭제 (데이터 없음) | Phase 5 | +| `cascading_mutual_exclusion` | 삭제 (데이터 없음) | Phase 5 | +| `cascading_hierarchy_*` | **유지** | - | +| `category_value_cascading_*` | **유지** (카테고리 관리) | - | + +### 11.6 마이그레이션 스크립트 필요 항목 + +```sql +-- cascading_relation → 화면 레이아웃 데이터로 마이그레이션 +-- 기존 2건의 연쇄관계를 사용하는 화면을 찾아서 +-- 해당 컴포넌트의 cascading 속성으로 변환 + +-- 예시: WAREHOUSE_LOCATION 연쇄관계 +-- 이 관계를 사용하는 화면의 컴포넌트에 +-- cascading: { parentField: "warehouse_code", filterColumn: "warehouse_code" } +-- 속성 추가 +``` + +--- + +## 12. 최종 아키텍처 요약 + +### 12.1 통합 컴포넌트 (10개) + +| # | 컴포넌트 | 역할 | +| :-: | :------------------- | :--------------------------------------- | +| 1 | **UnifiedInput** | 단일 값 입력 (text, number, slider 등) | +| 2 | **UnifiedSelect** | 선택 입력 (dropdown, radio, checkbox 등) | +| 3 | **UnifiedDate** | 날짜/시간 입력 | +| 4 | **UnifiedText** | 다중 행 텍스트 (textarea, rich editor) | +| 5 | **UnifiedMedia** | 파일/미디어 (file, image) | +| 6 | **UnifiedList** | 데이터 목록 (table, card, repeater) | +| 7 | **UnifiedLayout** | 레이아웃 배치 (grid, split, flex) | +| 8 | **UnifiedGroup** | 콘텐츠 그룹화 (tabs, accordion, section) | +| 9 | **UnifiedBiz** | 비즈니스 특화 (flow, rack, map 등) | +| 10 | **UnifiedHierarchy** | 계층 구조 (tree, org, bom, cascading) | + +### 12.2 공통 속성 (모든 컴포넌트에 적용) + +```typescript +interface BaseUnifiedProps { + // 기본 속성 + id: string; + label?: string; + required?: boolean; + readonly?: boolean; + disabled?: boolean; + + // 스타일 + style?: ComponentStyle; + className?: string; + + // 조건부 렌더링 (conditional-container 대체) + conditional?: { + enabled: boolean; + field: string; + operator: + | "=" + | "!=" + | ">" + | "<" + | "in" + | "notIn" + | "isEmpty" + | "isNotEmpty"; + value: any; + action: "show" | "hide" | "disable" | "enable"; + }; + + // 자동 입력 (autoFill 대체) + autoFill?: { + enabled: boolean; + sourceTable: string; + filterColumn: string; + userField: "companyCode" | "userId" | "deptCode"; + displayColumn: string; + }; + + // 유효성 검사 + validation?: ValidationRule[]; +} +``` + +### 12.3 UnifiedSelect 전용 속성 + +```typescript +interface UnifiedSelectProps extends BaseUnifiedProps { + // 표시 모드 + mode: "dropdown" | "radio" | "check" | "tag" | "toggle" | "swap"; + + // 데이터 소스 + source: "static" | "code" | "db" | "api" | "entity"; + + // static 소스 + options?: Array<{ value: string; label: string }>; + + // db 소스 + table?: string; + valueColumn?: string; + labelColumn?: string; + + // code 소스 + codeGroup?: string; + + // 연쇄 관계 (cascading_relation 대체) + cascading?: { + parentField: string; // 부모 필드명 + filterColumn: string; // 필터링할 컬럼 + clearOnChange?: boolean; // 부모 변경 시 초기화 + }; + + // 상호 배제 (mutual_exclusion 대체) + mutualExclusion?: { + enabled: boolean; + targetField: string; // 상호 배제 대상 + type: "exclusive" | "inclusive"; + }; + + // 다중 선택 + multiple?: boolean; + maxSelect?: number; +} +``` + +### 12.4 관리 메뉴 정리 결과 + +| AS-IS | TO-BE | +| :---------------------------- | :----------------------------------- | +| 연쇄 드롭다운 통합 관리 (6탭) | **삭제** | +| - 2단계 연쇄관계 | → UnifiedSelect.cascading 속성 | +| - 다단계 계층 | → 테이블관리 > 계층 구조 설정 | +| - 조건부 필터 | → 공통 conditional 속성 | +| - 자동 입력 | → 공통 autoFill 속성 | +| - 상호 배제 | → UnifiedSelect.mutualExclusion 속성 | +| - 카테고리 값 연쇄 | → 카테고리 관리와 통합 | + +--- + +## 13. 주의사항 + +> **기존 컴포넌트 삭제 금지** +> 모든 Phase에서 기존 컴포넌트는 삭제하지 않고 **병행 운영**합니다. +> 레거시 정리는 Phase 5에서 충분한 안정화 후 별도 검토합니다. + +> **연쇄관계 마이그레이션 필수** +> 관리 메뉴 삭제 전 기존 `cascading_relation` 데이터(2건)를 +> 해당 화면의 컴포넌트 속성으로 마이그레이션해야 합니다. From 958624012d5a7fc5d8555ead0b77e95a9a086e01 Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 19 Dec 2025 16:01:57 +0900 Subject: [PATCH 6/6] =?UTF-8?q?=EB=B3=B5=EC=82=AC=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/services/menuCopyService.ts | 704 +++++++++++++------ 1 file changed, 499 insertions(+), 205 deletions(-) diff --git a/backend-node/src/services/menuCopyService.ts b/backend-node/src/services/menuCopyService.ts index 5c4fde7f..b66ba165 100644 --- a/backend-node/src/services/menuCopyService.ts +++ b/backend-node/src/services/menuCopyService.ts @@ -247,7 +247,9 @@ export class MenuCopyService { typeof screenId === "number" ? screenId : parseInt(screenId); if (!isNaN(numId)) { referenced.push(numId); - logger.debug(` 📑 탭 컴포넌트에서 화면 참조 발견: ${numId} (탭: ${tab.label || tab.id})`); + logger.debug( + ` 📑 탭 컴포넌트에서 화면 참조 발견: ${numId} (탭: ${tab.label || tab.id})` + ); } } } @@ -257,7 +259,9 @@ export class MenuCopyService { if (props?.componentConfig?.leftScreenId) { const leftScreenId = props.componentConfig.leftScreenId; const numId = - typeof leftScreenId === "number" ? leftScreenId : parseInt(leftScreenId); + typeof leftScreenId === "number" + ? leftScreenId + : parseInt(leftScreenId); if (!isNaN(numId) && numId > 0) { referenced.push(numId); logger.debug(` 📐 분할 패널 좌측 화면 참조 발견: ${numId}`); @@ -267,7 +271,9 @@ export class MenuCopyService { if (props?.componentConfig?.rightScreenId) { const rightScreenId = props.componentConfig.rightScreenId; const numId = - typeof rightScreenId === "number" ? rightScreenId : parseInt(rightScreenId); + typeof rightScreenId === "number" + ? rightScreenId + : parseInt(rightScreenId); if (!isNaN(numId) && numId > 0) { referenced.push(numId); logger.debug(` 📐 분할 패널 우측 화면 참조 발견: ${numId}`); @@ -359,7 +365,11 @@ export class MenuCopyService { logger.info(`🔄 플로우 수집 시작: ${screenIds.size}개 화면`); const flowIds = new Set(); - const flowDetails: Array<{ flowId: number; flowName: string; screenId: number }> = []; + const flowDetails: Array<{ + flowId: number; + flowName: string; + screenId: number; + }> = []; for (const screenId of screenIds) { const layoutsResult = await client.query( @@ -372,23 +382,38 @@ export class MenuCopyService { // webTypeConfig.dataflowConfig.flowConfig.flowId const flowId = props?.webTypeConfig?.dataflowConfig?.flowConfig?.flowId; - const flowName = props?.webTypeConfig?.dataflowConfig?.flowConfig?.flowName || "Unknown"; - + const flowName = + props?.webTypeConfig?.dataflowConfig?.flowConfig?.flowName || + "Unknown"; + if (flowId && typeof flowId === "number" && flowId > 0) { if (!flowIds.has(flowId)) { flowIds.add(flowId); flowDetails.push({ flowId, flowName, screenId }); - logger.info(` 📎 화면 ${screenId}에서 플로우 발견: id=${flowId}, name="${flowName}"`); + logger.info( + ` 📎 화면 ${screenId}에서 플로우 발견: id=${flowId}, name="${flowName}"` + ); } } // selectedDiagramId도 확인 (flowId와 동일할 수 있지만 다를 수도 있음) - const selectedDiagramId = props?.webTypeConfig?.dataflowConfig?.selectedDiagramId; - if (selectedDiagramId && typeof selectedDiagramId === "number" && selectedDiagramId > 0) { + const selectedDiagramId = + props?.webTypeConfig?.dataflowConfig?.selectedDiagramId; + if ( + selectedDiagramId && + typeof selectedDiagramId === "number" && + selectedDiagramId > 0 + ) { if (!flowIds.has(selectedDiagramId)) { flowIds.add(selectedDiagramId); - flowDetails.push({ flowId: selectedDiagramId, flowName: "SelectedDiagram", screenId }); - logger.info(` 📎 화면 ${screenId}에서 selectedDiagramId 발견: id=${selectedDiagramId}`); + flowDetails.push({ + flowId: selectedDiagramId, + flowName: "SelectedDiagram", + screenId, + }); + logger.info( + ` 📎 화면 ${screenId}에서 selectedDiagramId 발견: id=${selectedDiagramId}` + ); } } } @@ -400,7 +425,7 @@ export class MenuCopyService { } else { logger.info(`📭 수집된 플로우 없음 (화면에 플로우 참조가 없음)`); } - + return flowIds; } @@ -462,7 +487,13 @@ export class MenuCopyService { const updated = JSON.parse(JSON.stringify(properties)); // 재귀적으로 객체/배열 탐색 - this.recursiveUpdateReferences(updated, screenIdMap, flowIdMap, "", numberingRuleIdMap); + this.recursiveUpdateReferences( + updated, + screenIdMap, + flowIdMap, + "", + numberingRuleIdMap + ); return updated; } @@ -539,7 +570,12 @@ export class MenuCopyService { } // numberingRuleId 매핑 (문자열) - if (key === "numberingRuleId" && numberingRuleIdMap && typeof value === "string" && value) { + if ( + key === "numberingRuleId" && + numberingRuleIdMap && + typeof value === "string" && + value + ) { const newRuleId = numberingRuleIdMap.get(value); if (newRuleId) { obj[key] = newRuleId; @@ -590,11 +626,15 @@ export class MenuCopyService { } const sourceMenu = sourceMenuResult.rows[0]; - const isRootMenu = !sourceMenu.parent_obj_id || sourceMenu.parent_obj_id === 0; + const isRootMenu = + !sourceMenu.parent_obj_id || sourceMenu.parent_obj_id === 0; // 2. 대상 회사에 같은 원본에서 복사된 메뉴 찾기 (source_menu_objid로 정확히 매칭) // 최상위/하위 구분 없이 모든 복사본 검색 - const existingMenuResult = await client.query<{ objid: number; parent_obj_id: number | null }>( + const existingMenuResult = await client.query<{ + objid: number; + parent_obj_id: number | null; + }>( `SELECT objid, parent_obj_id FROM menu_info WHERE source_menu_objid = $1 @@ -608,8 +648,9 @@ export class MenuCopyService { } const existingMenuObjid = existingMenuResult.rows[0].objid; - const existingIsRoot = !existingMenuResult.rows[0].parent_obj_id || - existingMenuResult.rows[0].parent_obj_id === 0; + const existingIsRoot = + !existingMenuResult.rows[0].parent_obj_id || + existingMenuResult.rows[0].parent_obj_id === 0; logger.info( `🔍 기존 복사본 발견: ${sourceMenu.menu_name_kor} (원본: ${sourceMenuObjid}, 복사본: ${existingMenuObjid}, 최상위: ${existingIsRoot})` @@ -649,10 +690,14 @@ export class MenuCopyService { WHERE screen_id = ANY($1) AND company_code = $2`, [screenIds, targetCompanyCode] ); - const sharedScreenIds = new Set(sharedScreensResult.rows.map(r => r.screen_id)); + const sharedScreenIds = new Set( + sharedScreensResult.rows.map((r) => r.screen_id) + ); // 공유되지 않은 화면만 삭제 - const screensToDelete = screenIds.filter(id => !sharedScreenIds.has(id)); + const screensToDelete = screenIds.filter( + (id) => !sharedScreenIds.has(id) + ); if (screensToDelete.length > 0) { // 레이아웃 삭제 @@ -662,8 +707,8 @@ export class MenuCopyService { ); // 화면 정의 삭제 - await client.query( - `DELETE FROM screen_definitions + await client.query( + `DELETE FROM screen_definitions WHERE screen_id = ANY($1) AND company_code = $2`, [screensToDelete, targetCompanyCode] ); @@ -671,7 +716,9 @@ export class MenuCopyService { } if (sharedScreenIds.size > 0) { - logger.info(` ♻️ 공유 화면 유지: ${sharedScreenIds.size}개 (다른 메뉴에서 사용 중)`); + logger.info( + ` ♻️ 공유 화면 유지: ${sharedScreenIds.size}개 (다른 메뉴에서 사용 중)` + ); } } @@ -681,13 +728,65 @@ export class MenuCopyService { ]); logger.info(` ✅ 메뉴 권한 삭제 완료`); - // 5-4. 메뉴 삭제 (역순: 하위 메뉴부터) - // 주의: 채번 규칙과 카테고리 설정은 회사마다 고유하므로 삭제하지 않음 - for (let i = existingMenus.length - 1; i >= 0; i--) { - await client.query(`DELETE FROM menu_info WHERE objid = $1`, [ - existingMenus[i].objid, - ]); + // 5-4. 채번 규칙 처리 (외래키 제약조건 해결) + // scope_type = 'menu'인 채번 규칙: 메뉴 전용이므로 삭제 (파트 포함) + // check_menu_scope_requires_menu_objid 제약: scope_type='menu'이면 menu_objid NOT NULL 필수 + const menuScopedRulesResult = await client.query( + `SELECT rule_id FROM numbering_rules + WHERE menu_objid = ANY($1) AND company_code = $2 AND scope_type = 'menu'`, + [existingMenuIds, targetCompanyCode] + ); + if (menuScopedRulesResult.rows.length > 0) { + const menuScopedRuleIds = menuScopedRulesResult.rows.map( + (r) => r.rule_id + ); + // 채번 규칙 파트 먼저 삭제 + await client.query( + `DELETE FROM numbering_rule_parts WHERE rule_id = ANY($1)`, + [menuScopedRuleIds] + ); + // 채번 규칙 삭제 + await client.query( + `DELETE FROM numbering_rules WHERE rule_id = ANY($1)`, + [menuScopedRuleIds] + ); + logger.info( + ` ✅ 메뉴 전용 채번 규칙 삭제: ${menuScopedRuleIds.length}개` + ); } + + // scope_type != 'menu'인 채번 규칙: menu_objid만 NULL로 설정 (규칙 보존) + const tableScopedRulesResult = await client.query( + `UPDATE numbering_rules + SET menu_objid = NULL + WHERE menu_objid = ANY($1) AND company_code = $2 + AND (scope_type IS NULL OR scope_type != 'menu') + RETURNING rule_id`, + [existingMenuIds, targetCompanyCode] + ); + if (tableScopedRulesResult.rows.length > 0) { + logger.info( + ` ✅ 테이블 스코프 채번 규칙 연결 해제: ${tableScopedRulesResult.rows.length}개 (데이터 보존)` + ); + } + + // 5-5. 카테고리 컬럼 매핑 삭제 (NOT NULL 제약조건으로 삭제 필요) + const deletedCategoryMappings = await client.query( + `DELETE FROM category_column_mapping + WHERE menu_objid = ANY($1) AND company_code = $2 + RETURNING mapping_id`, + [existingMenuIds, targetCompanyCode] + ); + if (deletedCategoryMappings.rows.length > 0) { + logger.info( + ` ✅ 카테고리 매핑 삭제: ${deletedCategoryMappings.rows.length}개` + ); + } + + // 5-6. 메뉴 삭제 (배치) + await client.query(`DELETE FROM menu_info WHERE objid = ANY($1)`, [ + existingMenuIds, + ]); logger.info(` ✅ 메뉴 삭제 완료: ${existingMenus.length}개`); logger.info("✅ 기존 복사본 삭제 완료 - 덮어쓰기 준비됨"); @@ -794,10 +893,10 @@ export class MenuCopyService { const ruleResult = await this.copyNumberingRulesWithMap( menuObjids, menuIdMap, // 실제 생성된 메뉴 ID 사용 - targetCompanyCode, - userId, - client - ); + targetCompanyCode, + userId, + client + ); copiedNumberingRules = ruleResult.copiedCount; numberingRuleIdMap = ruleResult.ruleIdMap; } @@ -948,17 +1047,21 @@ export class MenuCopyService { `SELECT * FROM flow_definition WHERE id = ANY($1)`, [flowIdArray] ); - const flowDefMap = new Map(allFlowDefsResult.rows.map(f => [f.id, f])); + const flowDefMap = new Map(allFlowDefsResult.rows.map((f) => [f.id, f])); // 2) 대상 회사의 기존 플로우 한 번에 조회 (이름+테이블 기준) - const flowNames = allFlowDefsResult.rows.map(f => f.name); - const existingFlowsResult = await client.query<{ id: number; name: string; table_name: string }>( + const flowNames = allFlowDefsResult.rows.map((f) => f.name); + const existingFlowsResult = await client.query<{ + id: number; + name: string; + table_name: string; + }>( `SELECT id, name, table_name FROM flow_definition WHERE company_code = $1 AND name = ANY($2)`, [targetCompanyCode, flowNames] ); const existingFlowMap = new Map( - existingFlowsResult.rows.map(f => [`${f.name}|${f.table_name}`, f.id]) + existingFlowsResult.rows.map((f) => [`${f.name}|${f.table_name}`, f.id]) ); // 3) 복사가 필요한 플로우 ID 목록 @@ -967,16 +1070,18 @@ export class MenuCopyService { for (const originalFlowId of flowIdArray) { const flowDef = flowDefMap.get(originalFlowId); if (!flowDef) { - logger.warn(`⚠️ 플로우를 찾을 수 없음: id=${originalFlowId}`); - continue; - } + logger.warn(`⚠️ 플로우를 찾을 수 없음: id=${originalFlowId}`); + continue; + } const key = `${flowDef.name}|${flowDef.table_name}`; const existingId = existingFlowMap.get(key); if (existingId) { flowIdMap.set(originalFlowId, existingId); - logger.info(` ♻️ 기존 플로우 재사용: ${originalFlowId} → ${existingId} (${flowDef.name})`); + logger.info( + ` ♻️ 기존 플로우 재사용: ${originalFlowId} → ${existingId} (${flowDef.name})` + ); } else { flowsToCopy.push(flowDef); } @@ -985,17 +1090,26 @@ export class MenuCopyService { // 4) 새 플로우 복사 (배치 처리) if (flowsToCopy.length > 0) { // 배치 INSERT로 플로우 생성 - const flowValues = flowsToCopy.map((f, i) => - `($${i * 8 + 1}, $${i * 8 + 2}, $${i * 8 + 3}, $${i * 8 + 4}, $${i * 8 + 5}, $${i * 8 + 6}, $${i * 8 + 7}, $${i * 8 + 8})` - ).join(", "); - - const flowParams = flowsToCopy.flatMap(f => [ - f.name, f.description, f.table_name, f.is_active, - targetCompanyCode, userId, f.db_source_type, f.db_connection_id + const flowValues = flowsToCopy + .map( + (f, i) => + `($${i * 8 + 1}, $${i * 8 + 2}, $${i * 8 + 3}, $${i * 8 + 4}, $${i * 8 + 5}, $${i * 8 + 6}, $${i * 8 + 7}, $${i * 8 + 8})` + ) + .join(", "); + + const flowParams = flowsToCopy.flatMap((f) => [ + f.name, + f.description, + f.table_name, + f.is_active, + targetCompanyCode, + userId, + f.db_source_type, + f.db_connection_id, ]); const newFlowsResult = await client.query<{ id: number }>( - `INSERT INTO flow_definition ( + `INSERT INTO flow_definition ( name, description, table_name, is_active, company_code, created_by, db_source_type, db_connection_id ) VALUES ${flowValues} @@ -1007,11 +1121,13 @@ export class MenuCopyService { flowsToCopy.forEach((flowDef, index) => { const newFlowId = newFlowsResult.rows[index].id; flowIdMap.set(flowDef.id, newFlowId); - logger.info(` ✅ 플로우 신규 복사: ${flowDef.id} → ${newFlowId} (${flowDef.name})`); + logger.info( + ` ✅ 플로우 신규 복사: ${flowDef.id} → ${newFlowId} (${flowDef.name})` + ); }); // 5) 스텝 및 연결 복사 (복사된 플로우만) - const originalFlowIdsToCopy = flowsToCopy.map(f => f.id); + const originalFlowIdsToCopy = flowsToCopy.map((f) => f.id); // 모든 스텝 한 번에 조회 const allStepsResult = await client.query( @@ -1030,7 +1146,7 @@ export class MenuCopyService { // 스텝 복사 (플로우별) const allStepIdMaps = new Map>(); // originalFlowId -> stepIdMap - + for (const originalFlowId of originalFlowIdsToCopy) { const newFlowId = flowIdMap.get(originalFlowId)!; const steps = stepsByFlow.get(originalFlowId) || []; @@ -1038,15 +1154,31 @@ export class MenuCopyService { if (steps.length > 0) { // 배치 INSERT로 스텝 생성 - const stepValues = steps.map((_, i) => - `($${i * 17 + 1}, $${i * 17 + 2}, $${i * 17 + 3}, $${i * 17 + 4}, $${i * 17 + 5}, $${i * 17 + 6}, $${i * 17 + 7}, $${i * 17 + 8}, $${i * 17 + 9}, $${i * 17 + 10}, $${i * 17 + 11}, $${i * 17 + 12}, $${i * 17 + 13}, $${i * 17 + 14}, $${i * 17 + 15}, $${i * 17 + 16}, $${i * 17 + 17})` - ).join(", "); + const stepValues = steps + .map( + (_, i) => + `($${i * 17 + 1}, $${i * 17 + 2}, $${i * 17 + 3}, $${i * 17 + 4}, $${i * 17 + 5}, $${i * 17 + 6}, $${i * 17 + 7}, $${i * 17 + 8}, $${i * 17 + 9}, $${i * 17 + 10}, $${i * 17 + 11}, $${i * 17 + 12}, $${i * 17 + 13}, $${i * 17 + 14}, $${i * 17 + 15}, $${i * 17 + 16}, $${i * 17 + 17})` + ) + .join(", "); - const stepParams = steps.flatMap(s => [ - newFlowId, s.step_name, s.step_order, s.condition_json, - s.color, s.position_x, s.position_y, s.table_name, s.move_type, - s.status_column, s.status_value, s.target_table, s.field_mappings, - s.required_fields, s.integration_type, s.integration_config, s.display_config + const stepParams = steps.flatMap((s) => [ + newFlowId, + s.step_name, + s.step_order, + s.condition_json, + s.color, + s.position_x, + s.position_y, + s.table_name, + s.move_type, + s.status_column, + s.status_value, + s.target_table, + s.field_mappings, + s.required_fields, + s.integration_type, + s.integration_config, + s.display_config, ]); const newStepsResult = await client.query<{ id: number }>( @@ -1064,7 +1196,9 @@ export class MenuCopyService { stepIdMap.set(step.id, newStepsResult.rows[index].id); }); - logger.info(` ↳ 플로우 ${originalFlowId}: 스텝 ${steps.length}개 복사`); + logger.info( + ` ↳ 플로우 ${originalFlowId}: 스텝 ${steps.length}개 복사` + ); } allStepIdMaps.set(originalFlowId, stepIdMap); @@ -1077,14 +1211,19 @@ export class MenuCopyService { ); // 연결 복사 (배치 INSERT) - const connectionsToInsert: { newFlowId: number; newFromStepId: number; newToStepId: number; label: string }[] = []; + const connectionsToInsert: { + newFlowId: number; + newFromStepId: number; + newToStepId: number; + label: string; + }[] = []; for (const conn of allConnectionsResult.rows) { const stepIdMap = allStepIdMaps.get(conn.flow_definition_id); if (!stepIdMap) continue; - const newFromStepId = stepIdMap.get(conn.from_step_id); - const newToStepId = stepIdMap.get(conn.to_step_id); + const newFromStepId = stepIdMap.get(conn.from_step_id); + const newToStepId = stepIdMap.get(conn.to_step_id); const newFlowId = flowIdMap.get(conn.flow_definition_id); if (newFromStepId && newToStepId && newFlowId) { @@ -1092,26 +1231,32 @@ export class MenuCopyService { newFlowId, newFromStepId, newToStepId, - label: conn.label || "" + label: conn.label || "", }); } } if (connectionsToInsert.length > 0) { - const connValues = connectionsToInsert.map((_, i) => - `($${i * 4 + 1}, $${i * 4 + 2}, $${i * 4 + 3}, $${i * 4 + 4})` - ).join(", "); + const connValues = connectionsToInsert + .map( + (_, i) => + `($${i * 4 + 1}, $${i * 4 + 2}, $${i * 4 + 3}, $${i * 4 + 4})` + ) + .join(", "); - const connParams = connectionsToInsert.flatMap(c => [ - c.newFlowId, c.newFromStepId, c.newToStepId, c.label + const connParams = connectionsToInsert.flatMap((c) => [ + c.newFlowId, + c.newFromStepId, + c.newToStepId, + c.label, ]); - await client.query( - `INSERT INTO flow_step_connection ( + await client.query( + `INSERT INTO flow_step_connection ( flow_definition_id, from_step_id, to_step_id, label ) VALUES ${connValues}`, connParams - ); + ); logger.info(` ↳ 연결 ${connectionsToInsert.length}개 복사`); } @@ -1347,10 +1492,7 @@ export class MenuCopyService { }); } } catch (error: any) { - logger.error( - `❌ 화면 처리 실패: screen_id=${originalScreenId}`, - error - ); + logger.error(`❌ 화면 처리 실패: screen_id=${originalScreenId}`, error); throw error; } } @@ -1588,7 +1730,7 @@ export class MenuCopyService { const parentMenu = parentMenuResult.rows[0]; // 대상 회사에서 같은 이름 + 같은 원본 회사에서 복사된 메뉴 찾기 - // source_menu_objid가 있는 메뉴(복사된 메뉴)만 대상으로, + // source_menu_objid가 있는 메뉴(복사된 메뉴)만 대상으로, // 해당 source_menu_objid의 원본 메뉴가 같은 회사(sourceCompanyCode)에 속하는지 확인 const sameNameResult = await client.query<{ objid: number }>( `SELECT m.objid FROM menu_info m @@ -1643,7 +1785,10 @@ export class MenuCopyService { try { // 0. 이미 복사된 메뉴가 있는지 확인 (고아 메뉴 재연결용) // 1차: source_menu_objid로 검색 - let existingCopyResult = await client.query<{ objid: number; parent_obj_id: number | null }>( + let existingCopyResult = await client.query<{ + objid: number; + parent_obj_id: number | null; + }>( `SELECT objid, parent_obj_id FROM menu_info WHERE source_menu_objid = $1 AND company_code = $2 LIMIT 1`, @@ -1652,7 +1797,10 @@ export class MenuCopyService { // 2차: source_menu_objid가 없는 기존 복사본 (이름 + 메뉴타입으로 검색) - 호환성 유지 if (existingCopyResult.rows.length === 0 && menu.menu_name_kor) { - existingCopyResult = await client.query<{ objid: number; parent_obj_id: number | null }>( + existingCopyResult = await client.query<{ + objid: number; + parent_obj_id: number | null; + }>( `SELECT objid, parent_obj_id FROM menu_info WHERE menu_name_kor = $1 AND company_code = $2 @@ -1733,7 +1881,9 @@ export class MenuCopyService { // === 신규 메뉴 복사 === // 미리 할당된 ID가 있으면 사용, 없으면 새로 생성 - const newObjId = preAllocatedMenuIdMap?.get(menu.objid) ?? await this.getNextMenuObjid(client); + const newObjId = + preAllocatedMenuIdMap?.get(menu.objid) ?? + (await this.getNextMenuObjid(client)); // source_menu_objid 저장: 모든 복사된 메뉴에 원본 ID 저장 (추적용) const sourceMenuObjid = menu.objid; @@ -1807,15 +1957,15 @@ export class MenuCopyService { // === 최적화: 배치 조회 === // 1. 모든 원본 메뉴의 화면 할당 한 번에 조회 - const menuObjids = menus.map(m => m.objid); - const companyCodes = [...new Set(menus.map(m => m.company_code))]; + const menuObjids = menus.map((m) => m.objid); + const companyCodes = [...new Set(menus.map((m) => m.company_code))]; const allAssignmentsResult = await client.query<{ menu_objid: number; - screen_id: number; - display_order: number; - is_active: string; - }>( + screen_id: number; + display_order: number; + is_active: string; + }>( `SELECT menu_objid, screen_id, display_order, is_active FROM screen_menu_assignments WHERE menu_objid = ANY($1) AND company_code = ANY($2)`, @@ -1837,36 +1987,45 @@ export class MenuCopyService { for (const assignment of allAssignmentsResult.rows) { const newMenuObjid = menuIdMap.get(assignment.menu_objid); - const newScreenId = screenIdMap.get(assignment.screen_id); + const newScreenId = screenIdMap.get(assignment.screen_id); if (!newMenuObjid || !newScreenId) { if (!newScreenId) { - logger.warn(`⚠️ 화면 ID 매핑 없음: screen_id=${assignment.screen_id}`); - } - continue; + logger.warn( + `⚠️ 화면 ID 매핑 없음: screen_id=${assignment.screen_id}` + ); } + continue; + } validAssignments.push({ newScreenId, newMenuObjid, displayOrder: assignment.display_order, - isActive: assignment.is_active + isActive: assignment.is_active, }); } // 3. 배치 INSERT if (validAssignments.length > 0) { - const assignmentValues = validAssignments.map((_, i) => - `($${i * 6 + 1}, $${i * 6 + 2}, $${i * 6 + 3}, $${i * 6 + 4}, $${i * 6 + 5}, $${i * 6 + 6})` - ).join(", "); + const assignmentValues = validAssignments + .map( + (_, i) => + `($${i * 6 + 1}, $${i * 6 + 2}, $${i * 6 + 3}, $${i * 6 + 4}, $${i * 6 + 5}, $${i * 6 + 6})` + ) + .join(", "); - const assignmentParams = validAssignments.flatMap(a => [ - a.newScreenId, a.newMenuObjid, targetCompanyCode, - a.displayOrder, a.isActive, "system" + const assignmentParams = validAssignments.flatMap((a) => [ + a.newScreenId, + a.newMenuObjid, + targetCompanyCode, + a.displayOrder, + a.isActive, + "system", ]); - await client.query( - `INSERT INTO screen_menu_assignments ( + await client.query( + `INSERT INTO screen_menu_assignments ( screen_id, menu_objid, company_code, display_order, is_active, created_by ) VALUES ${assignmentValues}`, assignmentParams @@ -1906,30 +2065,42 @@ export class MenuCopyService { } // 2. 대상 회사에 이미 존재하는 카테고리 한 번에 조회 - const categoryCodes = allCategoriesResult.rows.map(c => c.category_code); + const categoryCodes = allCategoriesResult.rows.map((c) => c.category_code); const existingCategoriesResult = await client.query( `SELECT category_code FROM code_category WHERE category_code = ANY($1) AND company_code = $2`, [categoryCodes, targetCompanyCode] ); - const existingCategoryCodes = new Set(existingCategoriesResult.rows.map(c => c.category_code)); + const existingCategoryCodes = new Set( + existingCategoriesResult.rows.map((c) => c.category_code) + ); // 3. 복사할 카테고리 필터링 const categoriesToCopy = allCategoriesResult.rows.filter( - c => !existingCategoryCodes.has(c.category_code) + (c) => !existingCategoryCodes.has(c.category_code) ); // 4. 배치 INSERT로 카테고리 복사 if (categoriesToCopy.length > 0) { - const categoryValues = categoriesToCopy.map((_, i) => - `($${i * 9 + 1}, $${i * 9 + 2}, $${i * 9 + 3}, $${i * 9 + 4}, $${i * 9 + 5}, $${i * 9 + 6}, NOW(), $${i * 9 + 7}, $${i * 9 + 8}, $${i * 9 + 9})` - ).join(", "); + const categoryValues = categoriesToCopy + .map( + (_, i) => + `($${i * 9 + 1}, $${i * 9 + 2}, $${i * 9 + 3}, $${i * 9 + 4}, $${i * 9 + 5}, $${i * 9 + 6}, NOW(), $${i * 9 + 7}, $${i * 9 + 8}, $${i * 9 + 9})` + ) + .join(", "); - const categoryParams = categoriesToCopy.flatMap(c => { + const categoryParams = categoriesToCopy.flatMap((c) => { const newMenuObjid = menuIdMap.get(c.menu_objid); return [ - c.category_code, c.category_name, c.category_name_eng, c.description, - c.sort_order, c.is_active, userId, targetCompanyCode, newMenuObjid + c.category_code, + c.category_name, + c.category_name_eng, + c.description, + c.sort_order, + c.is_active, + userId, + targetCompanyCode, + newMenuObjid, ]; }); @@ -1963,25 +2134,36 @@ export class MenuCopyService { [Array.from(menuIdMap.values()), targetCompanyCode] ); const existingCodeKeys = new Set( - existingCodesResult.rows.map(c => `${c.code_category}|${c.code_value}`) + existingCodesResult.rows.map((c) => `${c.code_category}|${c.code_value}`) ); // 7. 복사할 코드 필터링 const codesToCopy = allCodesResult.rows.filter( - c => !existingCodeKeys.has(`${c.code_category}|${c.code_value}`) + (c) => !existingCodeKeys.has(`${c.code_category}|${c.code_value}`) ); // 8. 배치 INSERT로 코드 복사 if (codesToCopy.length > 0) { - const codeValues = codesToCopy.map((_, i) => - `($${i * 10 + 1}, $${i * 10 + 2}, $${i * 10 + 3}, $${i * 10 + 4}, $${i * 10 + 5}, $${i * 10 + 6}, $${i * 10 + 7}, NOW(), $${i * 10 + 8}, $${i * 10 + 9}, $${i * 10 + 10})` - ).join(", "); + const codeValues = codesToCopy + .map( + (_, i) => + `($${i * 10 + 1}, $${i * 10 + 2}, $${i * 10 + 3}, $${i * 10 + 4}, $${i * 10 + 5}, $${i * 10 + 6}, $${i * 10 + 7}, NOW(), $${i * 10 + 8}, $${i * 10 + 9}, $${i * 10 + 10})` + ) + .join(", "); - const codeParams = codesToCopy.flatMap(c => { + const codeParams = codesToCopy.flatMap((c) => { const newMenuObjid = menuIdMap.get(c.menu_objid); return [ - c.code_category, c.code_value, c.code_name, c.code_name_eng, c.description, - c.sort_order, c.is_active, userId, targetCompanyCode, newMenuObjid + c.code_category, + c.code_value, + c.code_name, + c.code_name_eng, + c.description, + c.sort_order, + c.is_active, + userId, + targetCompanyCode, + newMenuObjid, ]; }); @@ -1997,7 +2179,9 @@ export class MenuCopyService { logger.info(` ✅ 코드 ${copiedCodes}개 복사`); } - logger.info(`✅ 코드 카테고리 + 코드 복사 완료: 카테고리 ${copiedCategories}개, 코드 ${copiedCodes}개`); + logger.info( + `✅ 코드 카테고리 + 코드 복사 완료: 카테고리 ${copiedCategories}개, 코드 ${copiedCodes}개` + ); return { copiedCategories, copiedCodes }; } @@ -2031,31 +2215,34 @@ export class MenuCopyService { return { copiedCount, ruleIdMap }; } - // 2. 대상 회사에 이미 존재하는 채번 규칙 한 번에 조회 - const ruleIds = allRulesResult.rows.map(r => r.rule_id); + // 2. 대상 회사에 이미 존재하는 모든 채번 규칙 조회 (원본 ID + 새로 생성될 ID 모두 체크) const existingRulesResult = await client.query( - `SELECT rule_id FROM numbering_rules - WHERE rule_id = ANY($1) AND company_code = $2`, - [ruleIds, targetCompanyCode] + `SELECT rule_id FROM numbering_rules WHERE company_code = $1`, + [targetCompanyCode] + ); + const existingRuleIds = new Set( + existingRulesResult.rows.map((r) => r.rule_id) ); - const existingRuleIds = new Set(existingRulesResult.rows.map(r => r.rule_id)); // 3. 복사할 규칙과 스킵할 규칙 분류 const rulesToCopy: any[] = []; const originalToNewRuleMap: Array<{ original: string; new: string }> = []; for (const rule of allRulesResult.rows) { + // 새 rule_id 생성 + const originalSuffix = rule.rule_id.includes("_") + ? rule.rule_id.replace(/^[^_]*_/, "") + : rule.rule_id; + const newRuleId = `${targetCompanyCode}_${originalSuffix}`; + + // 원본 ID 또는 새로 생성될 ID가 이미 존재하는 경우 스킵 if (existingRuleIds.has(rule.rule_id)) { - // 기존 규칙은 동일한 ID로 매핑 ruleIdMap.set(rule.rule_id, rule.rule_id); - logger.info(` ♻️ 채번규칙 이미 존재 (스킵): ${rule.rule_id}`); + logger.info(` ♻️ 채번규칙 이미 존재 (원본 ID): ${rule.rule_id}`); + } else if (existingRuleIds.has(newRuleId)) { + ruleIdMap.set(rule.rule_id, newRuleId); + logger.info(` ♻️ 채번규칙 이미 존재 (대상 ID): ${newRuleId}`); } else { - // 새 rule_id 생성 - const originalSuffix = rule.rule_id.includes('_') - ? rule.rule_id.replace(/^[^_]*_/, '') - : rule.rule_id; - const newRuleId = `${targetCompanyCode}_${originalSuffix}`; - ruleIdMap.set(rule.rule_id, newRuleId); originalToNewRuleMap.push({ original: rule.rule_id, new: newRuleId }); rulesToCopy.push({ ...rule, newRuleId }); @@ -2064,16 +2251,29 @@ export class MenuCopyService { // 4. 배치 INSERT로 채번 규칙 복사 if (rulesToCopy.length > 0) { - const ruleValues = rulesToCopy.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 ruleValues = rulesToCopy + .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 = rulesToCopy.flatMap((r) => { const newMenuObjid = menuIdMap.get(r.menu_objid); return [ - r.newRuleId, r.rule_name, r.description, r.separator, r.reset_period, - 0, r.table_name, r.column_name, targetCompanyCode, - userId, newMenuObjid, r.scope_type, null + r.newRuleId, + r.rule_name, + r.description, + r.separator, + r.reset_period, + 0, + r.table_name, + r.column_name, + targetCompanyCode, + userId, + newMenuObjid, + r.scope_type, + null, ]; }); @@ -2090,7 +2290,7 @@ export class MenuCopyService { logger.info(` ✅ 채번 규칙 ${copiedCount}개 복사`); // 5. 모든 원본 파트 한 번에 조회 - const originalRuleIds = rulesToCopy.map(r => r.rule_id); + const originalRuleIds = rulesToCopy.map((r) => r.rule_id); const allPartsResult = await client.query( `SELECT * FROM numbering_rule_parts WHERE rule_id = ANY($1) ORDER BY rule_id, part_order`, @@ -2100,15 +2300,25 @@ export class MenuCopyService { // 6. 배치 INSERT로 채번 규칙 파트 복사 if (allPartsResult.rows.length > 0) { // 원본 rule_id -> 새 rule_id 매핑 - const ruleMapping = new Map(originalToNewRuleMap.map(m => [m.original, m.new])); + const ruleMapping = new Map( + originalToNewRuleMap.map((m) => [m.original, m.new]) + ); - const partValues = allPartsResult.rows.map((_, i) => - `($${i * 7 + 1}, $${i * 7 + 2}, $${i * 7 + 3}, $${i * 7 + 4}, $${i * 7 + 5}, $${i * 7 + 6}, $${i * 7 + 7}, NOW())` - ).join(", "); + const partValues = allPartsResult.rows + .map( + (_, i) => + `($${i * 7 + 1}, $${i * 7 + 2}, $${i * 7 + 3}, $${i * 7 + 4}, $${i * 7 + 5}, $${i * 7 + 6}, $${i * 7 + 7}, NOW())` + ) + .join(", "); - const partParams = allPartsResult.rows.flatMap(p => [ - ruleMapping.get(p.rule_id), p.part_order, p.part_type, p.generation_method, - p.auto_config, p.manual_config, targetCompanyCode + const partParams = allPartsResult.rows.flatMap((p) => [ + ruleMapping.get(p.rule_id), + p.part_order, + p.part_type, + p.generation_method, + p.auto_config, + p.manual_config, + targetCompanyCode, ]); await client.query( @@ -2123,7 +2333,9 @@ export class MenuCopyService { } } - logger.info(`✅ 채번 규칙 복사 완료: ${copiedCount}개, 매핑: ${ruleIdMap.size}개`); + logger.info( + `✅ 채번 규칙 복사 완료: ${copiedCount}개, 매핑: ${ruleIdMap.size}개` + ); return { copiedCount, ruleIdMap }; } @@ -2162,28 +2374,40 @@ export class MenuCopyService { [targetCompanyCode] ); const existingMappingKeys = new Map( - existingMappingsResult.rows.map(m => [`${m.table_name}|${m.logical_column_name}`, m.mapping_id]) + existingMappingsResult.rows.map((m) => [ + `${m.table_name}|${m.logical_column_name}`, + m.mapping_id, + ]) ); // 3. 복사할 매핑 필터링 및 배치 INSERT const mappingsToCopy = allMappingsResult.rows.filter( - m => !existingMappingKeys.has(`${m.table_name}|${m.logical_column_name}`) + (m) => + !existingMappingKeys.has(`${m.table_name}|${m.logical_column_name}`) ); // 새 매핑 ID -> 원본 매핑 정보 추적 const mappingInsertInfo: Array<{ mapping: any; newMenuObjid: number }> = []; if (mappingsToCopy.length > 0) { - const mappingValues = mappingsToCopy.map((_, i) => - `($${i * 7 + 1}, $${i * 7 + 2}, $${i * 7 + 3}, $${i * 7 + 4}, $${i * 7 + 5}, $${i * 7 + 6}, NOW(), $${i * 7 + 7})` - ).join(", "); + const mappingValues = mappingsToCopy + .map( + (_, i) => + `($${i * 7 + 1}, $${i * 7 + 2}, $${i * 7 + 3}, $${i * 7 + 4}, $${i * 7 + 5}, $${i * 7 + 6}, NOW(), $${i * 7 + 7})` + ) + .join(", "); - const mappingParams = mappingsToCopy.flatMap(m => { + const mappingParams = mappingsToCopy.flatMap((m) => { const newMenuObjid = menuIdMap.get(m.menu_objid) || 0; mappingInsertInfo.push({ mapping: m, newMenuObjid }); return [ - m.table_name, m.logical_column_name, m.physical_column_name, - newMenuObjid, targetCompanyCode, m.description, userId + m.table_name, + m.logical_column_name, + m.physical_column_name, + newMenuObjid, + targetCompanyCode, + m.description, + userId, ]; }); @@ -2199,7 +2423,10 @@ export class MenuCopyService { // 새로 생성된 매핑 ID를 기존 매핑 맵에 추가 insertResult.rows.forEach((row, index) => { const m = mappingsToCopy[index]; - existingMappingKeys.set(`${m.table_name}|${m.logical_column_name}`, row.mapping_id); + existingMappingKeys.set( + `${m.table_name}|${m.logical_column_name}`, + row.mapping_id + ); }); copiedCount = mappingsToCopy.length; @@ -2226,7 +2453,10 @@ export class MenuCopyService { [targetCompanyCode] ); const existingValueKeys = new Map( - existingValuesResult.rows.map(v => [`${v.table_name}|${v.column_name}|${v.value_code}`, v.value_id]) + existingValuesResult.rows.map((v) => [ + `${v.table_name}|${v.column_name}|${v.value_code}`, + v.value_id, + ]) ); // 6. 값 복사 (부모-자식 관계 유지를 위해 depth 순서로 처리) @@ -2262,17 +2492,34 @@ export class MenuCopyService { const values = valuesByDepth.get(depth)!; if (values.length === 0) continue; - const valueStrings = values.map((_, i) => - `($${i * 15 + 1}, $${i * 15 + 2}, $${i * 15 + 3}, $${i * 15 + 4}, $${i * 15 + 5}, $${i * 15 + 6}, $${i * 15 + 7}, $${i * 15 + 8}, $${i * 15 + 9}, $${i * 15 + 10}, $${i * 15 + 11}, $${i * 15 + 12}, NOW(), $${i * 15 + 13}, $${i * 15 + 14}, $${i * 15 + 15})` - ).join(", "); + const valueStrings = values + .map( + (_, i) => + `($${i * 15 + 1}, $${i * 15 + 2}, $${i * 15 + 3}, $${i * 15 + 4}, $${i * 15 + 5}, $${i * 15 + 6}, $${i * 15 + 7}, $${i * 15 + 8}, $${i * 15 + 9}, $${i * 15 + 10}, $${i * 15 + 11}, $${i * 15 + 12}, NOW(), $${i * 15 + 13}, $${i * 15 + 14}, $${i * 15 + 15})` + ) + .join(", "); - const valueParams = values.flatMap(v => { + const valueParams = values.flatMap((v) => { const newMenuObjid = menuIdMap.get(v.menu_objid); - const newParentId = v.parent_value_id ? valueIdMap.get(v.parent_value_id) || null : null; + const newParentId = v.parent_value_id + ? valueIdMap.get(v.parent_value_id) || null + : null; return [ - v.table_name, v.column_name, v.value_code, v.value_label, v.value_order, - newParentId, v.depth, v.description, v.color, v.icon, - v.is_active, v.is_default, userId, targetCompanyCode, newMenuObjid + v.table_name, + v.column_name, + v.value_code, + v.value_label, + v.value_order, + newParentId, + v.depth, + v.description, + v.color, + v.icon, + v.is_active, + v.is_default, + userId, + targetCompanyCode, + newMenuObjid, ]; }); @@ -2353,25 +2600,35 @@ export class MenuCopyService { [tableNames, targetCompanyCode] ); const existingKeys = new Set( - existingSettingsResult.rows.map(s => `${s.table_name}|${s.column_name}`) + existingSettingsResult.rows.map((s) => `${s.table_name}|${s.column_name}`) ); // 4. 복사할 설정 필터링 const settingsToCopy = sourceSettingsResult.rows.filter( - s => !existingKeys.has(`${s.table_name}|${s.column_name}`) + (s) => !existingKeys.has(`${s.table_name}|${s.column_name}`) ); - logger.info(` 원본 설정: ${sourceSettingsResult.rows.length}개, 복사 대상: ${settingsToCopy.length}개`); + logger.info( + ` 원본 설정: ${sourceSettingsResult.rows.length}개, 복사 대상: ${settingsToCopy.length}개` + ); // 5. 배치 INSERT if (settingsToCopy.length > 0) { - const settingValues = settingsToCopy.map((_, i) => - `($${i * 7 + 1}, $${i * 7 + 2}, $${i * 7 + 3}, $${i * 7 + 4}, $${i * 7 + 5}, $${i * 7 + 6}, NOW(), NOW(), $${i * 7 + 7})` - ).join(", "); + const settingValues = settingsToCopy + .map( + (_, i) => + `($${i * 7 + 1}, $${i * 7 + 2}, $${i * 7 + 3}, $${i * 7 + 4}, $${i * 7 + 5}, $${i * 7 + 6}, NOW(), NOW(), $${i * 7 + 7})` + ) + .join(", "); - const settingParams = settingsToCopy.flatMap(s => [ - s.table_name, s.column_name, s.input_type, s.detail_settings, - s.is_nullable, s.display_order, targetCompanyCode + const settingParams = settingsToCopy.flatMap((s) => [ + s.table_name, + s.column_name, + s.input_type, + s.detail_settings, + s.is_nullable, + s.display_order, + targetCompanyCode, ]); await client.query( @@ -2421,7 +2678,7 @@ export class MenuCopyService { [targetCompanyCode] ); const existingGroupsByCode = new Map( - existingGroupsResult.rows.map(g => [g.relation_code, g.group_id]) + existingGroupsResult.rows.map((g) => [g.relation_code, g.group_id]) ); // group_id 매핑 @@ -2437,7 +2694,9 @@ export class MenuCopyService { } } - logger.info(` 기존: ${groupsResult.rows.length - groupsToCopy.length}개, 신규: ${groupsToCopy.length}개`); + logger.info( + ` 기존: ${groupsResult.rows.length - groupsToCopy.length}개, 신규: ${groupsToCopy.length}개` + ); // 그룹별로 삽입하고 매핑 저장 (RETURNING이 필요해서 배치 불가) for (const group of groupsToCopy) { @@ -2459,12 +2718,22 @@ export class MenuCopyService { ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, NOW()) RETURNING group_id`, [ - group.relation_code, group.relation_name, group.description, - group.parent_table_name, group.parent_column_name, newParentMenuObjid, - group.child_table_name, group.child_column_name, newChildMenuObjid, - group.clear_on_parent_change, group.show_group_label, - group.empty_parent_message, group.no_options_message, - targetCompanyCode, "Y", userId + group.relation_code, + group.relation_name, + group.description, + group.parent_table_name, + group.parent_column_name, + newParentMenuObjid, + group.child_table_name, + group.child_column_name, + newChildMenuObjid, + group.clear_on_parent_change, + group.show_group_label, + group.empty_parent_message, + group.no_options_message, + targetCompanyCode, + "Y", + userId, ] ); @@ -2474,7 +2743,7 @@ export class MenuCopyService { } // 모든 매핑 한 번에 조회 (복사할 그룹만) - const groupIdsToCopy = groupsToCopy.map(g => g.group_id); + const groupIdsToCopy = groupsToCopy.map((g) => g.group_id); if (groupIdsToCopy.length > 0) { const allMappingsResult = await client.query( `SELECT * FROM category_value_cascading_mapping @@ -2485,16 +2754,24 @@ export class MenuCopyService { // 배치 INSERT if (allMappingsResult.rows.length > 0) { - const mappingValues = allMappingsResult.rows.map((_, i) => - `($${i * 8 + 1}, $${i * 8 + 2}, $${i * 8 + 3}, $${i * 8 + 4}, $${i * 8 + 5}, $${i * 8 + 6}, $${i * 8 + 7}, $${i * 8 + 8}, NOW())` - ).join(", "); + const mappingValues = allMappingsResult.rows + .map( + (_, i) => + `($${i * 8 + 1}, $${i * 8 + 2}, $${i * 8 + 3}, $${i * 8 + 4}, $${i * 8 + 5}, $${i * 8 + 6}, $${i * 8 + 7}, $${i * 8 + 8}, NOW())` + ) + .join(", "); - const mappingParams = allMappingsResult.rows.flatMap(m => { + const mappingParams = allMappingsResult.rows.flatMap((m) => { const newGroupId = groupIdMap.get(m.group_id); return [ - newGroupId, m.parent_value_code, m.parent_value_label, - m.child_value_code, m.child_value_label, m.display_order, - targetCompanyCode, "Y" + newGroupId, + m.parent_value_code, + m.parent_value_label, + m.child_value_code, + m.child_value_label, + m.display_order, + targetCompanyCode, + "Y", ]; }); @@ -2531,29 +2808,47 @@ export class MenuCopyService { [targetCompanyCode] ); const existingRelationCodes = new Set( - existingRelationsResult.rows.map(r => r.relation_code) + existingRelationsResult.rows.map((r) => r.relation_code) ); // 복사할 관계 필터링 const relationsToCopy = relationsResult.rows.filter( - r => !existingRelationCodes.has(r.relation_code) + (r) => !existingRelationCodes.has(r.relation_code) ); - logger.info(` 기존: ${relationsResult.rows.length - relationsToCopy.length}개, 신규: ${relationsToCopy.length}개`); + logger.info( + ` 기존: ${relationsResult.rows.length - relationsToCopy.length}개, 신규: ${relationsToCopy.length}개` + ); // 배치 INSERT if (relationsToCopy.length > 0) { - const relationValues = relationsToCopy.map((_, i) => - `($${i * 19 + 1}, $${i * 19 + 2}, $${i * 19 + 3}, $${i * 19 + 4}, $${i * 19 + 5}, $${i * 19 + 6}, $${i * 19 + 7}, $${i * 19 + 8}, $${i * 19 + 9}, $${i * 19 + 10}, $${i * 19 + 11}, $${i * 19 + 12}, $${i * 19 + 13}, $${i * 19 + 14}, $${i * 19 + 15}, $${i * 19 + 16}, $${i * 19 + 17}, $${i * 19 + 18}, $${i * 19 + 19}, NOW())` - ).join(", "); + const relationValues = relationsToCopy + .map( + (_, i) => + `($${i * 19 + 1}, $${i * 19 + 2}, $${i * 19 + 3}, $${i * 19 + 4}, $${i * 19 + 5}, $${i * 19 + 6}, $${i * 19 + 7}, $${i * 19 + 8}, $${i * 19 + 9}, $${i * 19 + 10}, $${i * 19 + 11}, $${i * 19 + 12}, $${i * 19 + 13}, $${i * 19 + 14}, $${i * 19 + 15}, $${i * 19 + 16}, $${i * 19 + 17}, $${i * 19 + 18}, $${i * 19 + 19}, NOW())` + ) + .join(", "); - const relationParams = relationsToCopy.flatMap(r => [ - r.relation_code, r.relation_name, r.description, - r.parent_table, r.parent_value_column, r.parent_label_column, - r.child_table, r.child_filter_column, r.child_value_column, r.child_label_column, - r.child_order_column, r.child_order_direction, - r.empty_parent_message, r.no_options_message, r.loading_message, - r.clear_on_parent_change, targetCompanyCode, "Y", userId + const relationParams = relationsToCopy.flatMap((r) => [ + r.relation_code, + r.relation_name, + r.description, + r.parent_table, + r.parent_value_column, + r.parent_label_column, + r.child_table, + r.child_filter_column, + r.child_value_column, + r.child_label_column, + r.child_order_column, + r.child_order_direction, + r.empty_parent_message, + r.no_options_message, + r.loading_message, + r.clear_on_parent_change, + targetCompanyCode, + "Y", + userId, ]); await client.query( @@ -2575,5 +2870,4 @@ export class MenuCopyService { logger.info(`✅ 연쇄관계 복사 완료: ${copiedCount}개`); return copiedCount; } - }