// ✅ Prisma → Raw Query 전환 (Phase 2.1) import { query, queryOne, transaction } from "../database/db"; import { ScreenDefinition, CreateScreenRequest, UpdateScreenRequest, LayoutData, SaveLayoutRequest, ScreenTemplate, MenuAssignmentRequest, PaginatedResponse, ComponentData, ColumnInfo, ColumnWebTypeSetting, WebType, WidgetData, } from "../types/screen"; import { generateId } from "../utils/generateId"; import logger from "../utils/logger"; import { reconstructConfig, extractConfigDiff, } from "../utils/componentDefaults"; // 화면 복사 요청 인터페이스 interface CopyScreenRequest { screenName: string; screenCode: string; description?: string; companyCode: string; // 요청한 사용자의 회사 코드 (인증용) createdBy?: string; // 생성자 ID targetCompanyCode?: string; // 복사 대상 회사 코드 (최고 관리자 전용) } // 백엔드에서 사용할 테이블 정보 타입 interface TableInfo { tableName: string; tableLabel: string; columns: ColumnInfo[]; } export class ScreenManagementService { // ======================================== // 화면 정의 관리 // ======================================== /** * 화면 정의 생성 (✅ Raw Query 전환 완료) */ async createScreen( screenData: CreateScreenRequest, userCompanyCode: string, ): Promise { console.log(`=== 화면 생성 요청 ===`); console.log(`요청 데이터:`, screenData); console.log(`사용자 회사 코드:`, userCompanyCode); // 화면 코드 중복 확인 (Raw Query) const existingResult = await query<{ screen_id: number }>( `SELECT screen_id FROM screen_definitions WHERE screen_code = $1 AND is_active != 'D' LIMIT 1`, [screenData.screenCode], ); console.log( `화면 코드 '${screenData.screenCode}' 중복 검사 결과:`, existingResult.length > 0 ? "중복됨" : "사용 가능", ); if (existingResult.length > 0) { console.log(`기존 화면 정보:`, existingResult[0]); throw new Error("이미 존재하는 화면 코드입니다."); } // 화면 생성 (Raw Query) - REST API 지원 추가 const [screen] = await query( `INSERT INTO screen_definitions ( screen_name, screen_code, table_name, company_code, description, created_by, db_source_type, db_connection_id, data_source_type, rest_api_connection_id, rest_api_endpoint, rest_api_json_path ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING *`, [ screenData.screenName, screenData.screenCode, screenData.tableName, screenData.companyCode, screenData.description || null, screenData.createdBy, screenData.dbSourceType || "internal", screenData.dbConnectionId || null, (screenData as any).dataSourceType || "database", (screenData as any).restApiConnectionId || null, (screenData as any).restApiEndpoint || null, (screenData as any).restApiJsonPath || "data", ], ); return this.mapToScreenDefinition(screen); } /** * 회사별 화면 목록 조회 (페이징 지원) - 활성 화면만 (✅ Raw Query 전환 완료) */ async getScreensByCompany( companyCode: string, page: number = 1, size: number = 20, searchTerm?: string, // 검색어 추가 ): Promise> { const offset = (page - 1) * size; // WHERE 절 동적 생성 const whereConditions: string[] = ["is_active != 'D'"]; const params: any[] = []; if (companyCode !== "*") { whereConditions.push(`company_code = $${params.length + 1}`); params.push(companyCode); } // 검색어 필터링 추가 (화면명, 화면 코드, 테이블명 검색) if (searchTerm && searchTerm.trim() !== "") { whereConditions.push(`( screen_name ILIKE $${params.length + 1} OR screen_code ILIKE $${params.length + 1} OR table_name ILIKE $${params.length + 1} )`); params.push(`%${searchTerm.trim()}%`); } const whereSQL = whereConditions.join(" AND "); // 페이징 쿼리 (Raw Query) const [screens, totalResult] = await Promise.all([ query( `SELECT * FROM screen_definitions WHERE ${whereSQL} ORDER BY created_date DESC LIMIT $${params.length + 1} OFFSET $${params.length + 2}`, [...params, size, offset], ), query<{ count: string }>( `SELECT COUNT(*)::text as count FROM screen_definitions WHERE ${whereSQL}`, params, ), ]); const total = parseInt(totalResult[0]?.count || "0", 10); // 테이블 라벨 정보를 한 번에 조회 (Raw Query) const tableNames = Array.from( new Set(screens.map((s: any) => s.table_name).filter(Boolean)), ); let tableLabelMap = new Map(); if (tableNames.length > 0) { try { const placeholders = tableNames.map((_, i) => `$${i + 1}`).join(", "); const tableLabels = await query<{ table_name: string; table_label: string | null; }>( `SELECT table_name, table_label FROM table_labels WHERE table_name IN (${placeholders})`, tableNames, ); tableLabelMap = new Map( tableLabels.map((tl) => [ tl.table_name, tl.table_label || tl.table_name, ]), ); // 테스트: company_mng 라벨 직접 확인 if (tableLabelMap.has("company_mng")) { console.log( "✅ company_mng 라벨 찾음:", tableLabelMap.get("company_mng"), ); } else { console.log("❌ company_mng 라벨 없음"); } } catch (error) { console.error("테이블 라벨 조회 오류:", error); } } return { data: screens.map((screen) => this.mapToScreenDefinition(screen, tableLabelMap), ), pagination: { page, size, total, totalPages: Math.ceil(total / size), }, }; } /** * 화면 목록 조회 (간단 버전) - 활성 화면만 (✅ Raw Query 전환 완료) */ async getScreens(companyCode: string): Promise { // 동적 WHERE 절 생성 const whereConditions: string[] = ["is_active != 'D'"]; const params: any[] = []; if (companyCode !== "*") { whereConditions.push(`company_code = $${params.length + 1}`); params.push(companyCode); } const whereSQL = whereConditions.join(" AND "); const screens = await query( `SELECT * FROM screen_definitions WHERE ${whereSQL} ORDER BY created_date DESC`, params, ); return screens.map((screen) => this.mapToScreenDefinition(screen)); } /** * 화면 정의 조회 (활성 화면만) (✅ Raw Query 전환 완료) */ async getScreenById(screenId: number): Promise { const screens = await query( `SELECT * FROM screen_definitions WHERE screen_id = $1 AND is_active != 'D' LIMIT 1`, [screenId], ); return screens.length > 0 ? this.mapToScreenDefinition(screens[0]) : null; } /** * 화면 정의 조회 (회사 코드 검증 포함, 활성 화면만) (✅ Raw Query 전환 완료) */ async getScreen( screenId: number, companyCode: string, ): Promise { // 동적 WHERE 절 생성 const whereConditions: string[] = [ "screen_id = $1", "is_active != 'D'", // 삭제된 화면 제외 ]; const params: any[] = [screenId]; // 회사 코드가 '*'가 아닌 경우 회사별 필터링 if (companyCode !== "*") { whereConditions.push(`company_code = $${params.length + 1}`); params.push(companyCode); } const whereSQL = whereConditions.join(" AND "); const screens = await query( `SELECT * FROM screen_definitions WHERE ${whereSQL} LIMIT 1`, params, ); return screens.length > 0 ? this.mapToScreenDefinition(screens[0]) : null; } /** * 화면 정의 수정 (✅ Raw Query 전환 완료) */ async updateScreen( screenId: number, updateData: UpdateScreenRequest, userCompanyCode: string, ): Promise { // 권한 확인 (Raw Query) const existingResult = await query<{ company_code: string | null }>( `SELECT company_code FROM screen_definitions WHERE screen_id = $1 LIMIT 1`, [screenId], ); if (existingResult.length === 0) { throw new Error("화면을 찾을 수 없습니다."); } const existingScreen = existingResult[0]; if ( userCompanyCode !== "*" && existingScreen.company_code !== userCompanyCode ) { throw new Error("이 화면을 수정할 권한이 없습니다."); } // 화면 업데이트 (Raw Query) const [screen] = await query( `UPDATE screen_definitions SET screen_name = $1, description = $2, is_active = $3, updated_by = $4, updated_date = $5 WHERE screen_id = $6 RETURNING *`, [ updateData.screenName, updateData.description || null, updateData.isActive ? "Y" : "N", updateData.updatedBy, new Date(), screenId, ], ); return this.mapToScreenDefinition(screen); } /** * 화면 정보 수정 (메타데이터만) - 편집 기능용 */ async updateScreenInfo( screenId: number, updateData: { screenName: string; tableName?: string; description?: string; isActive: string; // REST API 관련 필드 추가 dataSourceType?: string; dbSourceType?: string; dbConnectionId?: number; restApiConnectionId?: number; restApiEndpoint?: string; restApiJsonPath?: string; }, userCompanyCode: string, ): Promise { // 권한 확인 const existingResult = await query<{ company_code: string | null }>( `SELECT company_code FROM screen_definitions WHERE screen_id = $1 LIMIT 1`, [screenId], ); if (existingResult.length === 0) { throw new Error("화면을 찾을 수 없습니다."); } const existingScreen = existingResult[0]; if ( userCompanyCode !== "*" && existingScreen.company_code !== userCompanyCode ) { throw new Error("이 화면을 수정할 권한이 없습니다."); } // 화면 정보 업데이트 (REST API 필드 포함) await query( `UPDATE screen_definitions SET screen_name = $1, table_name = $2, description = $3, is_active = $4, updated_date = $5, data_source_type = $6, db_source_type = $7, db_connection_id = $8, rest_api_connection_id = $9, rest_api_endpoint = $10, rest_api_json_path = $11 WHERE screen_id = $12`, [ updateData.screenName, updateData.tableName || null, updateData.description || null, updateData.isActive, new Date(), updateData.dataSourceType || "database", updateData.dbSourceType || "internal", updateData.dbConnectionId || null, updateData.restApiConnectionId || null, updateData.restApiEndpoint || null, updateData.restApiJsonPath || null, screenId, ], ); console.log(`화면 정보 업데이트 완료: screenId=${screenId}`, { dataSourceType: updateData.dataSourceType, restApiConnectionId: updateData.restApiConnectionId, restApiEndpoint: updateData.restApiEndpoint, restApiJsonPath: updateData.restApiJsonPath, }); } /** * 화면 의존성 체크 - 다른 화면에서 이 화면을 참조하는지 확인 */ async checkScreenDependencies( screenId: number, userCompanyCode: string, ): Promise<{ hasDependencies: boolean; dependencies: Array<{ screenId: number; screenName: string; screenCode: string; componentId: string; componentType: string; referenceType: string; // 'popup', 'navigate', 'targetScreen' 등 }>; }> { // 권한 확인 const targetScreens = await query<{ company_code: string | null }>( `SELECT company_code FROM screen_definitions WHERE screen_id = $1 LIMIT 1`, [screenId], ); if (targetScreens.length === 0) { throw new Error("화면을 찾을 수 없습니다."); } const targetScreen = targetScreens[0]; if ( userCompanyCode !== "*" && targetScreen.company_code !== "*" && targetScreen.company_code !== userCompanyCode ) { throw new Error("이 화면에 접근할 권한이 없습니다."); } // 같은 회사의 모든 활성 화면에서 이 화면을 참조하는지 확인 const whereConditions: string[] = ["sd.is_active != 'D'"]; const params: any[] = []; if (userCompanyCode !== "*") { whereConditions.push( `sd.company_code IN ($${params.length + 1}, $${params.length + 2})`, ); params.push(userCompanyCode, "*"); } const whereSQL = whereConditions.join(" AND "); // 화면과 레이아웃을 JOIN해서 조회 const allScreens = await query( `SELECT sd.screen_id, sd.screen_name, sd.screen_code, sd.company_code, sl.layout_id, sl.component_id, sl.component_type, sl.properties FROM screen_definitions sd LEFT JOIN screen_layouts sl ON sd.screen_id = sl.screen_id WHERE ${whereSQL} ORDER BY sd.screen_id, sl.layout_id`, params, ); const dependencies: Array<{ screenId: number; screenName: string; screenCode: string; componentId: string; componentType: string; referenceType: string; }> = []; // 각 화면의 레이아웃에서 버튼 컴포넌트들을 검사 for (const screen of allScreens) { if (screen.screen_id === screenId) continue; // 자기 자신은 제외 try { // screen_layouts 테이블에서 버튼 컴포넌트 확인 (위젯 타입만) if (screen.component_type === "widget") { const properties = screen.properties as any; // 버튼 컴포넌트인지 확인 if (properties?.widgetType === "button") { const config = properties.webTypeConfig; if (!config) continue; // popup 액션에서 popupScreenId 확인 if ( config.actionType === "popup" && config.popupScreenId === screenId ) { dependencies.push({ screenId: screen.screen_id, screenName: screen.screen_name, screenCode: screen.screen_code, componentId: screen.component_id, componentType: "button", referenceType: "popup", }); } // navigate 액션에서 navigateScreenId 확인 if ( config.actionType === "navigate" && config.navigateScreenId === screenId ) { dependencies.push({ screenId: screen.screen_id, screenName: screen.screen_name, screenCode: screen.screen_code, componentId: screen.component_id, componentType: "button", referenceType: "navigate", }); } // navigateUrl에서 화면 ID 패턴 확인 (예: /screens/123) if ( config.navigateUrl && config.navigateUrl.includes(`/screens/${screenId}`) ) { dependencies.push({ screenId: screen.screen_id, screenName: screen.screen_name, screenCode: screen.screen_code, componentId: screen.component_id, componentType: "button", referenceType: "url", }); } } } // 기존 layout_metadata도 확인 (하위 호환성) - 현재는 사용하지 않음 // 실제 데이터는 screen_layouts 테이블에서 개별적으로 조회해야 함 } catch (error) { console.error( `화면 ${screen.screen_id}의 레이아웃 분석 중 오류:`, error, ); continue; } } // 메뉴 할당 확인 (Raw Query) try { const menuAssignments = await query<{ assignment_id: number; menu_objid: number; menu_name_kor?: string; }>( `SELECT sma.assignment_id, sma.menu_objid, mi.menu_name_kor FROM screen_menu_assignments sma LEFT JOIN menu_info mi ON sma.menu_objid = mi.objid WHERE sma.screen_id = $1 AND sma.is_active = 'Y'`, [screenId], ); // 메뉴에 할당된 경우 의존성에 추가 for (const assignment of menuAssignments) { dependencies.push({ screenId: 0, // 메뉴는 화면이 아니므로 0으로 설정 screenName: assignment.menu_name_kor || "알 수 없는 메뉴", screenCode: `MENU_${assignment.menu_objid}`, componentId: `menu_${assignment.assignment_id}`, componentType: "menu", referenceType: "menu_assignment", }); } } catch (error) { console.error("메뉴 할당 확인 중 오류:", error); // 메뉴 할당 확인 실패해도 다른 의존성 체크는 계속 진행 } return { hasDependencies: dependencies.length > 0, dependencies, }; } /** * 화면 정의 삭제 (휴지통으로 이동 - 소프트 삭제) (✅ Raw Query 전환 완료) */ async deleteScreen( screenId: number, userCompanyCode: string, deletedBy: string, deleteReason?: string, force: boolean = false, ): Promise { // 권한 확인 (Raw Query) const existingResult = await query<{ company_code: string | null; is_active: string; }>( `SELECT company_code, is_active FROM screen_definitions WHERE screen_id = $1 LIMIT 1`, [screenId], ); if (existingResult.length === 0) { throw new Error("화면을 찾을 수 없습니다."); } const existingScreen = existingResult[0]; if ( userCompanyCode !== "*" && existingScreen.company_code !== userCompanyCode ) { throw new Error("이 화면을 삭제할 권한이 없습니다."); } // 이미 삭제된 화면인지 확인 if (existingScreen.is_active === "D") { throw new Error("이미 삭제된 화면입니다."); } // 강제 삭제가 아닌 경우 의존성 체크 if (!force) { const dependencyCheck = await this.checkScreenDependencies( screenId, userCompanyCode, ); if (dependencyCheck.hasDependencies) { const error = new Error("다른 화면에서 사용 중인 화면입니다.") as any; error.code = "SCREEN_HAS_DEPENDENCIES"; error.dependencies = dependencyCheck.dependencies; throw error; } } // 트랜잭션으로 화면 삭제와 메뉴 할당 정리를 함께 처리 (Raw Query) await transaction(async (client) => { // 소프트 삭제 (휴지통으로 이동) await client.query( `UPDATE screen_definitions SET is_active = 'D', deleted_date = $1, deleted_by = $2, delete_reason = $3, updated_date = $4, updated_by = $5 WHERE screen_id = $6`, [ new Date(), deletedBy, deleteReason || null, new Date(), deletedBy, screenId, ], ); // 메뉴 할당도 비활성화 await client.query( `UPDATE screen_menu_assignments SET is_active = 'N' WHERE screen_id = $1 AND is_active = 'Y'`, [screenId], ); }); } /** * 화면 복원 (휴지통에서 복원) (✅ Raw Query 전환 완료) */ async restoreScreen( screenId: number, userCompanyCode: string, restoredBy: string, ): Promise { // 권한 확인 const screens = await query<{ company_code: string | null; is_active: string; screen_code: string; }>( `SELECT company_code, is_active, screen_code FROM screen_definitions WHERE screen_id = $1 LIMIT 1`, [screenId], ); if (screens.length === 0) { throw new Error("화면을 찾을 수 없습니다."); } const existingScreen = screens[0]; if ( userCompanyCode !== "*" && existingScreen.company_code !== userCompanyCode ) { throw new Error("이 화면을 복원할 권한이 없습니다."); } // 삭제된 화면이 아닌 경우 if (existingScreen.is_active !== "D") { throw new Error("삭제된 화면이 아닙니다."); } // 화면 코드 중복 확인 (복원 시 같은 코드가 이미 존재하는지) const duplicateScreens = await query<{ screen_id: number }>( `SELECT screen_id FROM screen_definitions WHERE screen_code = $1 AND is_active != 'D' AND screen_id != $2 LIMIT 1`, [existingScreen.screen_code, screenId], ); if (duplicateScreens.length > 0) { throw new Error( "같은 화면 코드를 가진 활성 화면이 이미 존재합니다. 복원하려면 기존 화면의 코드를 변경하거나 삭제해주세요.", ); } // 트랜잭션으로 화면 복원과 메뉴 할당 복원을 함께 처리 await transaction(async (client) => { // 화면 복원 await client.query( `UPDATE screen_definitions SET is_active = 'Y', deleted_date = NULL, deleted_by = NULL, delete_reason = NULL, updated_date = $1, updated_by = $2 WHERE screen_id = $3`, [new Date(), restoredBy, screenId], ); // 메뉴 할당도 다시 활성화 await client.query( `UPDATE screen_menu_assignments SET is_active = 'Y' WHERE screen_id = $1 AND is_active = 'N'`, [screenId], ); }); } /** * 휴지통 화면들의 메뉴 할당 정리 (관리자용) (✅ Raw Query 전환 완료) */ async cleanupDeletedScreenMenuAssignments(): Promise<{ updatedCount: number; message: string; }> { const result = await query( `UPDATE screen_menu_assignments SET is_active = 'N' WHERE screen_id IN ( SELECT screen_id FROM screen_definitions WHERE is_active = 'D' ) AND is_active = 'Y'`, [], ); const updatedCount = result.length; return { updatedCount, message: `${updatedCount}개의 메뉴 할당이 정리되었습니다.`, }; } /** * 화면 영구 삭제 (휴지통에서 완전 삭제) (✅ Raw Query 전환 완료) */ async permanentDeleteScreen( screenId: number, userCompanyCode: string, ): Promise { // 권한 확인 const screens = await query<{ company_code: string | null; is_active: string; }>( `SELECT company_code, is_active FROM screen_definitions WHERE screen_id = $1 LIMIT 1`, [screenId], ); if (screens.length === 0) { throw new Error("화면을 찾을 수 없습니다."); } const existingScreen = screens[0]; if ( userCompanyCode !== "*" && existingScreen.company_code !== userCompanyCode ) { throw new Error("이 화면을 영구 삭제할 권한이 없습니다."); } // 삭제된 화면이 아닌 경우 영구 삭제 불가 if (existingScreen.is_active !== "D") { throw new Error("휴지통에 있는 화면만 영구 삭제할 수 있습니다."); } // 물리적 삭제 (수동으로 관련 데이터 삭제) await transaction(async (client) => { await client.query(`DELETE FROM screen_layouts WHERE screen_id = $1`, [ screenId, ]); await client.query( `DELETE FROM screen_menu_assignments WHERE screen_id = $1`, [screenId], ); await client.query( `DELETE FROM screen_definitions WHERE screen_id = $1`, [screenId], ); }); } /** * 휴지통 화면 목록 조회 (✅ Raw Query 전환 완료) */ async getDeletedScreens( companyCode: string, page: number = 1, size: number = 20, ): Promise< PaginatedResponse< ScreenDefinition & { deletedDate?: Date; deletedBy?: string; deleteReason?: string; } > > { const offset = (page - 1) * size; const whereConditions: string[] = ["is_active = 'D'"]; const params: any[] = []; if (companyCode !== "*") { whereConditions.push(`company_code = $${params.length + 1}`); params.push(companyCode); } const whereSQL = whereConditions.join(" AND "); const [screens, totalResult] = await Promise.all([ query( `SELECT * FROM screen_definitions WHERE ${whereSQL} ORDER BY deleted_date DESC NULLS LAST LIMIT $${params.length + 1} OFFSET $${params.length + 2}`, [...params, size, offset], ), query<{ count: string }>( `SELECT COUNT(*)::text as count FROM screen_definitions WHERE ${whereSQL}`, params, ), ]); const total = parseInt(totalResult[0]?.count || "0", 10); // 테이블 라벨 정보를 한 번에 조회 const tableNames = Array.from( new Set(screens.map((s: any) => s.table_name).filter(Boolean)), ); let tableLabelMap = new Map(); if (tableNames.length > 0) { const placeholders = tableNames.map((_, i) => `$${i + 1}`).join(", "); const tableLabels = await query<{ table_name: string; table_label: string | null; }>( `SELECT table_name, table_label FROM table_labels WHERE table_name IN (${placeholders})`, tableNames, ); tableLabelMap = new Map( tableLabels.map((tl: any) => [ tl.table_name, tl.table_label || tl.table_name, ]), ); } return { data: screens.map((screen: any) => ({ ...this.mapToScreenDefinition(screen, tableLabelMap), deletedDate: screen.deleted_date || undefined, deletedBy: screen.deleted_by || undefined, deleteReason: screen.delete_reason || undefined, })), pagination: { page, size, total, totalPages: Math.ceil(total / size), }, }; } /** * 활성 화면 일괄 삭제 (휴지통으로 이동) */ async bulkDeleteScreens( screenIds: number[], userCompanyCode: string, deletedBy: string, deleteReason?: string, force: boolean = false, ): Promise<{ deletedCount: number; skippedCount: number; errors: Array<{ screenId: number; error: string }>; }> { if (screenIds.length === 0) { throw new Error("삭제할 화면을 선택해주세요."); } let deletedCount = 0; let skippedCount = 0; const errors: Array<{ screenId: number; error: string }> = []; // 각 화면을 개별적으로 삭제 처리 for (const screenId of screenIds) { try { // 권한 확인 (Raw Query) const existingResult = await query<{ company_code: string | null; is_active: string; screen_name: string; }>( `SELECT company_code, is_active, screen_name FROM screen_definitions WHERE screen_id = $1 LIMIT 1`, [screenId], ); if (existingResult.length === 0) { skippedCount++; errors.push({ screenId, error: "화면을 찾을 수 없습니다.", }); continue; } const existingScreen = existingResult[0]; // 권한 확인 if ( userCompanyCode !== "*" && existingScreen.company_code !== userCompanyCode ) { skippedCount++; errors.push({ screenId, error: "이 화면을 삭제할 권한이 없습니다.", }); continue; } // 이미 삭제된 화면인지 확인 if (existingScreen.is_active === "D") { skippedCount++; errors.push({ screenId, error: "이미 삭제된 화면입니다.", }); continue; } // 강제 삭제가 아닌 경우 의존성 체크 if (!force) { const dependencyCheck = await this.checkScreenDependencies( screenId, userCompanyCode, ); if (dependencyCheck.hasDependencies) { skippedCount++; errors.push({ screenId, error: `다른 화면에서 사용 중 (${dependencyCheck.dependencies.length}개 참조)`, }); continue; } } // 트랜잭션으로 화면 삭제와 메뉴 할당 정리를 함께 처리 await transaction(async (client) => { const now = new Date(); // 소프트 삭제 (휴지통으로 이동) await client.query( `UPDATE screen_definitions SET is_active = 'D', deleted_date = $1, deleted_by = $2, delete_reason = $3, updated_date = $4, updated_by = $5 WHERE screen_id = $6`, [now, deletedBy, deleteReason || null, now, deletedBy, screenId], ); // 메뉴 할당 정리 (삭제된 화면의 메뉴 할당 제거) await client.query( `DELETE FROM screen_menu_assignments WHERE screen_id = $1`, [screenId], ); }); deletedCount++; logger.info( `화면 삭제 완료: ${screenId} (${existingScreen.screen_name})`, ); } catch (error) { skippedCount++; errors.push({ screenId, error: error instanceof Error ? error.message : "알 수 없는 오류", }); logger.error(`화면 삭제 실패: ${screenId}`, error); } } logger.info( `일괄 삭제 완료: 성공 ${deletedCount}개, 실패 ${skippedCount}개`, ); return { deletedCount, skippedCount, errors }; } /** * 휴지통 화면 일괄 영구 삭제 */ async bulkPermanentDeleteScreens( screenIds: number[], userCompanyCode: string, ): Promise<{ deletedCount: number; skippedCount: number; errors: Array<{ screenId: number; error: string }>; }> { if (screenIds.length === 0) { throw new Error("삭제할 화면을 선택해주세요."); } // 권한 확인 - 해당 회사의 휴지통 화면들만 조회 const whereClause: any = { screen_id: { in: screenIds }, is_active: "D", // 휴지통에 있는 화면만 }; if (userCompanyCode !== "*") { whereClause.company_code = userCompanyCode; } // WHERE 절 생성 const whereConditions: string[] = ["is_active = 'D'"]; const params: any[] = []; if (userCompanyCode !== "*") { whereConditions.push(`company_code = $${params.length + 1}`); params.push(userCompanyCode); } const whereSQL = whereConditions.join(" AND "); const screensToDelete = await query<{ screen_id: number }>( `SELECT screen_id FROM screen_definitions WHERE ${whereSQL}`, params, ); let deletedCount = 0; let skippedCount = 0; const errors: Array<{ screenId: number; error: string }> = []; // 각 화면을 개별적으로 삭제 처리 for (const screenId of screenIds) { try { const screenToDelete = screensToDelete.find( (s: any) => s.screen_id === screenId, ); if (!screenToDelete) { skippedCount++; errors.push({ screenId, error: "화면을 찾을 수 없거나 삭제 권한이 없습니다.", }); continue; } // 관련 레이아웃 데이터도 함께 삭제 (트랜잭션) await transaction(async (client) => { // screen_layouts 삭제 await client.query( `DELETE FROM screen_layouts WHERE screen_id = $1`, [screenId], ); // screen_menu_assignments 삭제 await client.query( `DELETE FROM screen_menu_assignments WHERE screen_id = $1`, [screenId], ); // screen_definitions 삭제 await client.query( `DELETE FROM screen_definitions WHERE screen_id = $1`, [screenId], ); }); deletedCount++; } catch (error) { skippedCount++; errors.push({ screenId, error: error instanceof Error ? error.message : "알 수 없는 오류", }); console.error(`화면 ${screenId} 영구 삭제 실패:`, error); } } return { deletedCount, skippedCount, errors, }; } // ======================================== // 테이블 관리 // ======================================== /** * 테이블 목록 조회 (모든 테이블) (✅ Raw Query 전환 완료) */ async getTables(companyCode: string): Promise { try { // PostgreSQL에서 사용 가능한 테이블 목록 조회 const tables = await query<{ table_name: string }>( `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_type = 'BASE TABLE' ORDER BY table_name`, [], ); // 각 테이블의 컬럼 정보도 함께 조회 const tableInfos: TableInfo[] = []; for (const table of tables) { const columns = await this.getTableColumns( table.table_name, companyCode, ); if (columns.length > 0) { tableInfos.push({ tableName: table.table_name, tableLabel: this.getTableLabel(table.table_name), columns: columns, }); } } return tableInfos; } catch (error) { console.error("테이블 목록 조회 실패:", error); throw new Error("테이블 목록을 조회할 수 없습니다."); } } /** * 특정 테이블 정보 조회 (최적화된 단일 테이블 조회) */ async getTableInfo( tableName: string, companyCode: string, ): Promise { try { console.log(`=== 단일 테이블 조회 시작: ${tableName} ===`); // 테이블 존재 여부 확인 const tableExists = await query<{ table_name: string }>( `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_type = 'BASE TABLE' AND table_name = $1`, [tableName], ); if (tableExists.length === 0) { console.log(`테이블 ${tableName}이 존재하지 않습니다.`); return null; } // 해당 테이블의 컬럼 정보 조회 const columns = await this.getTableColumns(tableName, companyCode); if (columns.length === 0) { console.log(`테이블 ${tableName}의 컬럼 정보가 없습니다.`); return null; } const tableInfo: TableInfo = { tableName: tableName, tableLabel: this.getTableLabel(tableName), columns: columns, }; console.log( `단일 테이블 조회 완료: ${tableName}, 컬럼 ${columns.length}개`, ); return tableInfo; } catch (error) { console.error(`테이블 ${tableName} 조회 실패:`, error); throw new Error(`테이블 ${tableName} 정보를 조회할 수 없습니다.`); } } /** * 테이블 컬럼 정보 조회 (✅ Raw Query 전환 완료) */ async getTableColumns( tableName: string, companyCode: string, ): Promise { try { // 테이블 컬럼 정보 조회 const columns = await query<{ column_name: string; data_type: string; is_nullable: string; column_default: string | null; character_maximum_length: number | null; numeric_precision: number | null; numeric_scale: number | null; }>( `SELECT column_name, data_type, is_nullable, column_default, character_maximum_length, numeric_precision, numeric_scale FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1 ORDER BY ordinal_position`, [tableName], ); // 🆕 table_type_columns에서 입력타입 정보 조회 (회사별만, fallback 없음) // 멀티테넌시: 각 회사는 자신의 설정만 사용, 최고관리자 설정은 별도 관리 console.log( `🔍 [getTableColumns] 시작: table=${tableName}, company=${companyCode}`, ); const typeInfo = await query<{ column_name: string; input_type: string | null; detail_settings: any; }>( `SELECT column_name, input_type, detail_settings FROM table_type_columns WHERE table_name = $1 AND company_code = $2 ORDER BY id DESC`, // 최신 레코드 우선 (중복 방지) [tableName, companyCode], ); console.log( `📊 [getTableColumns] typeInfo 조회 완료: ${typeInfo.length}개`, ); const currencyCodeType = typeInfo.find( (t) => t.column_name === "currency_code", ); if (currencyCodeType) { console.log( `💰 [getTableColumns] currency_code 발견:`, currencyCodeType, ); } else { console.log(`⚠️ [getTableColumns] currency_code 없음`); } // table_type_columns 테이블에서 라벨 정보 조회 (우선순위 2) const labelInfo = await query<{ column_name: string; column_label: string | null; }>( `SELECT column_name, column_label FROM table_type_columns WHERE table_name = $1 AND company_code = '*'`, [tableName], ); // 🆕 category_column_mapping에서 코드 카테고리 정보 조회 const categoryInfo = await query<{ physical_column_name: string; logical_column_name: string; }>( `SELECT physical_column_name, logical_column_name FROM category_column_mapping WHERE table_name = $1 AND company_code = $2`, [tableName, companyCode], ); // 컬럼 정보 매핑 const columnMap = new Map(); // 먼저 information_schema에서 가져온 컬럼들로 기본 맵 생성 columns.forEach((column: any) => { columnMap.set(column.column_name, { tableName: tableName, columnName: column.column_name, dataType: column.data_type, isNullable: column.is_nullable, columnDefault: column.column_default || undefined, characterMaximumLength: column.character_maximum_length || undefined, numericPrecision: column.numeric_precision || undefined, numericScale: column.numeric_scale || undefined, }); }); console.log( `🗺️ [getTableColumns] 기본 columnMap 생성: ${columnMap.size}개`, ); // table_type_columns에서 input_type 추가 (중복 시 최신 것만) const addedTypes = new Set(); typeInfo.forEach((type) => { const colName = type.column_name; if (!addedTypes.has(colName) && columnMap.has(colName)) { const col = columnMap.get(colName); col.inputType = type.input_type; col.webType = type.input_type; // webType도 동일하게 설정 col.detailSettings = type.detail_settings; addedTypes.add(colName); if (colName === "currency_code") { console.log( `✅ [getTableColumns] currency_code inputType 설정됨: ${type.input_type}`, ); } } }); console.log( `🏷️ [getTableColumns] inputType 추가 완료: ${addedTypes.size}개`, ); // table_type_columns에서 라벨 추가 labelInfo.forEach((label) => { const col = columnMap.get(label.column_name); if (col) { col.columnLabel = label.column_label || this.getColumnLabel(label.column_name); } }); // category_column_mapping에서 코드 카테고리 추가 categoryInfo.forEach((cat) => { const col = columnMap.get(cat.physical_column_name); if (col) { col.codeCategory = cat.logical_column_name; } }); // 최종 결과 생성 const result = Array.from(columnMap.values()).map((col) => ({ ...col, // 기본값 설정 columnLabel: col.columnLabel || this.getColumnLabel(col.columnName), inputType: col.inputType || this.inferWebType(col.dataType), webType: col.webType || this.inferWebType(col.dataType), detailSettings: col.detailSettings || undefined, codeCategory: col.codeCategory || undefined, })); // 디버깅: currency_code의 최종 inputType 확인 const currencyCodeResult = result.find( (r) => r.columnName === "currency_code", ); if (currencyCodeResult) { console.log(`🎯 [getTableColumns] 최종 currency_code:`, { inputType: currencyCodeResult.inputType, webType: currencyCodeResult.webType, dataType: currencyCodeResult.dataType, }); } console.log(`✅ [getTableColumns] 반환: ${result.length}개 컬럼`); return result; } catch (error) { console.error("테이블 컬럼 조회 실패:", error); throw new Error("테이블 컬럼 정보를 조회할 수 없습니다."); } } /** * 테이블 라벨 생성 */ private getTableLabel(tableName: string): string { // snake_case를 읽기 쉬운 형태로 변환 return tableName .replace(/_/g, " ") .replace(/\b\w/g, (l) => l.toUpperCase()) .replace(/\s+/g, " ") .trim(); } /** * 컬럼 라벨 생성 */ private getColumnLabel(columnName: string): string { // snake_case를 읽기 쉬운 형태로 변환 return columnName .replace(/_/g, " ") .replace(/\b\w/g, (l) => l.toUpperCase()) .replace(/\s+/g, " ") .trim(); } /** * 데이터 타입으로부터 웹타입 추론 */ private inferWebType(dataType: string): WebType { // 통합 타입 매핑에서 import const { DB_TYPE_TO_WEB_TYPE } = require("../types/v2-web-types"); const lowerType = dataType.toLowerCase(); // 정확한 매핑 우선 확인 if (DB_TYPE_TO_WEB_TYPE[lowerType]) { return DB_TYPE_TO_WEB_TYPE[lowerType]; } // 부분 문자열 매칭 (더 정교한 규칙) for (const [dbType, webType] of Object.entries(DB_TYPE_TO_WEB_TYPE)) { if ( lowerType.includes(dbType.toLowerCase()) || dbType.toLowerCase().includes(lowerType) ) { return webType as WebType; } } // 추가 정밀 매핑 if (lowerType.includes("int") && !lowerType.includes("point")) { return "number"; } else if (lowerType.includes("numeric") || lowerType.includes("decimal")) { return "decimal"; } else if ( lowerType.includes("timestamp") || lowerType.includes("datetime") ) { return "datetime"; } else if (lowerType.includes("date")) { return "date"; } else if (lowerType.includes("time")) { return "datetime"; } else if (lowerType.includes("bool")) { return "checkbox"; } else if ( lowerType.includes("char") || lowerType.includes("text") || lowerType.includes("varchar") ) { return lowerType.includes("text") ? "textarea" : "text"; } // 기본값 return "text"; } // ======================================== // 레이아웃 관리 // ======================================== /** * 레이아웃 저장 (✅ Raw Query 전환 완료) */ async saveLayout( screenId: number, layoutData: LayoutData, companyCode: string, ): Promise { console.log(`=== 레이아웃 저장 시작 ===`); console.log(`화면 ID: ${screenId}`); console.log(`컴포넌트 수: ${layoutData.components.length}`); console.log(`격자 설정:`, layoutData.gridSettings); console.log(`해상도 설정:`, layoutData.screenResolution); console.log(`기본 테이블:`, (layoutData as any).mainTableName); // 권한 확인 const screens = await query<{ company_code: string | null }>( `SELECT company_code FROM screen_definitions WHERE screen_id = $1 LIMIT 1`, [screenId], ); if (screens.length === 0) { throw new Error("화면을 찾을 수 없습니다."); } const existingScreen = screens[0]; if (companyCode !== "*" && existingScreen.company_code !== companyCode) { throw new Error("이 화면의 레이아웃을 저장할 권한이 없습니다."); } // 🆕 화면의 기본 테이블 업데이트 (테이블이 선택된 경우) const mainTableName = (layoutData as any).mainTableName; if (mainTableName) { await query( `UPDATE screen_definitions SET table_name = $1, updated_date = NOW() WHERE screen_id = $2`, [mainTableName, screenId], ); console.log(`✅ 화면 기본 테이블 업데이트: ${mainTableName}`); } // 기존 레이아웃 삭제 (컴포넌트와 메타데이터 모두) await query(`DELETE FROM screen_layouts WHERE screen_id = $1`, [screenId]); // 1. 메타데이터 저장 (격자 설정과 해상도 정보) if (layoutData.gridSettings || layoutData.screenResolution) { const metadata: any = { gridSettings: layoutData.gridSettings, screenResolution: layoutData.screenResolution, }; await query( `INSERT INTO screen_layouts ( screen_id, component_type, component_id, parent_id, position_x, position_y, width, height, properties, display_order ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, [ screenId, "_metadata", // 특별한 타입으로 메타데이터 식별 `_metadata_${screenId}`, null, 0, 0, 0, 0, JSON.stringify(metadata), -1, // 메타데이터는 맨 앞에 배치 ], ); console.log(`메타데이터 저장 완료:`, metadata); } // 2. 컴포넌트 저장 for (const component of layoutData.components) { const { id, ...componentData } = component; console.log(`저장 중인 컴포넌트:`, { id: component.id, type: component.type, position: component.position, size: component.size, parentId: component.parentId, title: (component as any).title, }); // JSON 필드에 맞는 타입으로 변환 const properties: any = { ...componentData, position: { x: component.position.x, y: component.position.y, z: component.position.z || 1, // z 값 포함 }, size: { width: component.size.width, height: component.size.height, }, }; // 🔍 디버깅: webTypeConfig.dataflowConfig 확인 if ((component as any).webTypeConfig?.dataflowConfig) { console.log( `🔍 컴포넌트 ${component.id}의 dataflowConfig:`, JSON.stringify( (component as any).webTypeConfig.dataflowConfig, null, 2, ), ); } await query( `INSERT INTO screen_layouts ( screen_id, component_type, component_id, parent_id, position_x, position_y, width, height, properties ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, [ screenId, component.type, component.id, component.parentId || null, Math.round(component.position.x), // 정수로 반올림 Math.round(component.position.y), // 정수로 반올림 Math.round(component.size.width), // 정수로 반올림 Math.round(component.size.height), // 정수로 반올림 JSON.stringify(properties), ], ); } console.log(`=== 레이아웃 저장 완료 ===`); } /** * 레이아웃 조회 (✅ Raw Query 전환 완료) * V2 테이블 우선 조회 → 없으면 V1 테이블 조회 */ async getLayout( screenId: number, companyCode: string, ): Promise { console.log(`=== 레이아웃 로드 시작 ===`); console.log(`화면 ID: ${screenId}`); // 권한 확인 및 테이블명 조회 const screens = await query<{ company_code: string | null; table_name: string | null; }>( `SELECT company_code, table_name FROM screen_definitions WHERE screen_id = $1 LIMIT 1`, [screenId], ); if (screens.length === 0) { return null; } const existingScreen = screens[0]; if (companyCode !== "*" && existingScreen.company_code !== companyCode) { throw new Error("이 화면의 레이아웃을 조회할 권한이 없습니다."); } // 🆕 V2 테이블 우선 조회 (회사별 → 공통(*)) let v2Layout = await queryOne<{ layout_data: any }>( `SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = $1 AND company_code = $2`, [screenId, companyCode], ); // 회사별 레이아웃 없으면 공통(*) 조회 if (!v2Layout && companyCode !== "*") { v2Layout = await queryOne<{ layout_data: any }>( `SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = $1 AND company_code = '*'`, [screenId], ); } // V2 레이아웃이 있으면 V2 형식으로 반환 if (v2Layout && v2Layout.layout_data) { console.log(`V2 레이아웃 발견, V2 형식으로 반환`); const layoutData = v2Layout.layout_data; // V2 형식의 components를 LayoutData 형식으로 변환 const components = (layoutData.components || []).map((comp: any) => ({ id: comp.id, type: comp.overrides?.type || "component", position: comp.position || { x: 0, y: 0, z: 1 }, size: comp.size || { width: 200, height: 100 }, componentUrl: comp.url, componentType: comp.overrides?.type, componentConfig: comp.overrides || {}, displayOrder: comp.displayOrder || 0, ...comp.overrides, })); // screenResolution이 없으면 컴포넌트 위치 기반으로 자동 계산 let screenResolution = layoutData.screenResolution; if (!screenResolution && components.length > 0) { let maxRight = 0; let maxBottom = 0; for (const comp of layoutData.components || []) { const right = (comp.position?.x || 0) + (comp.size?.width || 200); const bottom = (comp.position?.y || 0) + (comp.size?.height || 100); maxRight = Math.max(maxRight, right); maxBottom = Math.max(maxBottom, bottom); } // 여백 100px 추가, 최소 1200x800 보장 screenResolution = { width: Math.max(1200, maxRight + 100), height: Math.max(800, maxBottom + 100), }; console.log(`screenResolution 자동 계산:`, screenResolution); } return { components, gridSettings: layoutData.gridSettings || { columns: 12, gap: 16, padding: 16, snapToGrid: true, showGrid: true, }, screenResolution, }; } console.log(`V2 레이아웃 없음, V1 테이블 조회`); const layouts = await query( `SELECT * FROM screen_layouts WHERE screen_id = $1 ORDER BY display_order ASC NULLS LAST, layout_id ASC`, [screenId], ); console.log(`DB에서 조회된 레이아웃 수: ${layouts.length}`); // 메타데이터와 컴포넌트 분리 const metadataLayout = layouts.find( (layout) => layout.component_type === "_metadata", ); const componentLayouts = layouts.filter( (layout) => layout.component_type !== "_metadata", ); // 기본 메타데이터 설정 let gridSettings = { columns: 12, gap: 16, padding: 16, snapToGrid: true, showGrid: true, }; let screenResolution = null; // 저장된 메타데이터가 있으면 적용 if (metadataLayout && metadataLayout.properties) { const metadata = metadataLayout.properties as any; if (metadata.gridSettings) { gridSettings = { ...gridSettings, ...metadata.gridSettings }; } if (metadata.screenResolution) { screenResolution = metadata.screenResolution; } console.log(`메타데이터 로드:`, { gridSettings, screenResolution }); } if (componentLayouts.length === 0) { return { components: [], gridSettings, screenResolution, }; } // 🔥 최신 inputType 정보 조회 (table_type_columns에서) const inputTypeMap = await this.getLatestInputTypes( componentLayouts, companyCode, ); const components: ComponentData[] = componentLayouts.map((layout) => { const properties = layout.properties as any; // 🔥 최신 inputType으로 widgetType 및 componentType 업데이트 const tableName = properties?.tableName; const columnName = properties?.columnName; const latestTypeInfo = tableName && columnName ? inputTypeMap.get(`${tableName}.${columnName}`) : null; // 🆕 V2 컴포넌트는 덮어쓰지 않음 (새로운 컴포넌트 시스템 보호) const savedComponentType = properties?.componentType; const isV2Component = savedComponentType?.startsWith("v2-"); const component = { id: layout.component_id, // 🔥 최신 componentType이 있으면 type 덮어쓰기 (단, V2 컴포넌트는 제외) type: isV2Component ? (layout.component_type as any) // V2는 저장된 값 유지 : latestTypeInfo?.componentType || (layout.component_type as any), position: { x: layout.position_x, y: layout.position_y, z: properties?.position?.z || 1, // z 값 복원 }, size: { width: layout.width, height: layout.height }, parentId: layout.parent_id, ...properties, // 🔥 최신 inputType이 있으면 widgetType, componentType 덮어쓰기 (단, V2 컴포넌트는 제외) ...(!isV2Component && latestTypeInfo && { widgetType: latestTypeInfo.inputType, inputType: latestTypeInfo.inputType, componentType: latestTypeInfo.componentType, componentConfig: { ...properties?.componentConfig, type: latestTypeInfo.componentType, inputType: latestTypeInfo.inputType, }, }), }; console.log(`로드된 컴포넌트:`, { id: component.id, type: component.type, position: component.position, size: component.size, parentId: component.parentId, title: (component as any).title, widgetType: (component as any).widgetType, componentType: (component as any).componentType, latestTypeInfo, }); return component; }); console.log(`=== 레이아웃 로드 완료 ===`); console.log(`반환할 컴포넌트 수: ${components.length}`); console.log(`최종 격자 설정:`, gridSettings); console.log(`최종 해상도 설정:`, screenResolution); console.log(`테이블명:`, existingScreen.table_name); return { components, gridSettings, screenResolution, tableName: existingScreen.table_name, // 🆕 테이블명 추가 }; } /** * V1 레이아웃 조회 (component_url + custom_config 기반) * screen_layouts_v1 테이블에서 조회 * * 🔒 확정 사항: * - component_url: 컴포넌트 파일 경로 (필수, NOT NULL) * - custom_config: 회사별 커스텀 설정 (slot 포함) * - company_code: 멀티테넌시 필터 필수 */ async getLayoutV1( screenId: number, companyCode: string, ): Promise { console.log(`=== V1 레이아웃 로드 시작 ===`); console.log(`화면 ID: ${screenId}, 회사: ${companyCode}`); // 권한 확인 및 테이블명 조회 const screens = await query<{ company_code: string | null; table_name: string | null; }>( `SELECT company_code, table_name FROM screen_definitions WHERE screen_id = $1 LIMIT 1`, [screenId], ); if (screens.length === 0) { return null; } const existingScreen = screens[0]; if (companyCode !== "*" && existingScreen.company_code !== companyCode) { throw new Error("이 화면의 레이아웃을 조회할 권한이 없습니다."); } // V1 테이블에서 조회 (company_code 필터 포함 - 멀티테넌시 필수) const layouts = await query( `SELECT * FROM screen_layouts_v1 WHERE screen_id = $1 AND (company_code = $2 OR $2 = '*') ORDER BY display_order ASC NULLS LAST, layout_id ASC`, [screenId, companyCode], ); console.log(`V1 DB에서 조회된 레이아웃 수: ${layouts.length}`); if (layouts.length === 0) { return { components: [], gridSettings: { columns: 12, gap: 16, padding: 16, snapToGrid: true, showGrid: true, }, screenResolution: null, }; } const components: ComponentData[] = layouts.map((layout: any) => { // component_url에서 컴포넌트 타입 추출 // "@/lib/registry/components/split-panel-layout" → "split-panel-layout" const componentUrl = layout.component_url || ""; const componentType = componentUrl.split("/").pop() || "unknown"; // custom_config가 곧 componentConfig const componentConfig = layout.custom_config || {}; const component = { id: layout.component_id, type: componentType as any, componentType: componentType, componentUrl: componentUrl, // URL도 전달 position: { x: layout.position_x, y: layout.position_y, z: 1, }, size: { width: layout.width, height: layout.height, }, parentId: layout.parent_id, componentConfig, }; return component; }); console.log(`=== V1 레이아웃 로드 완료 ===`); console.log(`반환할 컴포넌트 수: ${components.length}`); return { components, gridSettings: { columns: 12, gap: 16, padding: 16, snapToGrid: true, showGrid: true, }, screenResolution: null, tableName: existingScreen.table_name, }; } /** * 입력 타입에 해당하는 컴포넌트 ID 반환 * (프론트엔드 webTypeMapping.ts와 동일한 매핑) */ private getComponentIdFromInputType(inputType: string): string { const mapping: Record = { // 텍스트 입력 text: "text-input", email: "text-input", password: "text-input", tel: "text-input", // 숫자 입력 number: "number-input", decimal: "number-input", // 날짜/시간 date: "date-input", datetime: "date-input", time: "date-input", // 텍스트 영역 textarea: "textarea-basic", // 선택 select: "select-basic", dropdown: "select-basic", // 체크박스/라디오 checkbox: "checkbox-basic", radio: "radio-basic", boolean: "toggle-switch", // 파일 file: "file-upload", // 이미지 image: "image-widget", img: "image-widget", picture: "image-widget", photo: "image-widget", // 버튼 button: "button-primary", // 기타 label: "text-display", code: "select-basic", entity: "entity-search-input", // 엔티티는 entity-search-input 사용 category: "select-basic", }; return mapping[inputType] || "text-input"; } /** * 컴포넌트들의 최신 inputType 정보 조회 * @param layouts - 레이아웃 목록 * @param companyCode - 회사 코드 * @returns Map<"tableName.columnName", { inputType, componentType }> */ private async getLatestInputTypes( layouts: any[], companyCode: string, ): Promise> { const inputTypeMap = new Map< string, { inputType: string; componentType: string } >(); // tableName과 columnName이 있는 컴포넌트들의 고유 조합 추출 const tableColumnPairs = new Set(); for (const layout of layouts) { const properties = layout.properties as any; if (properties?.tableName && properties?.columnName) { tableColumnPairs.add( `${properties.tableName}|${properties.columnName}`, ); } } if (tableColumnPairs.size === 0) { return inputTypeMap; } // 각 테이블-컬럼 조합에 대해 최신 inputType 조회 const pairs = Array.from(tableColumnPairs).map((pair) => { const [tableName, columnName] = pair.split("|"); return { tableName, columnName }; }); // 배치 쿼리로 한 번에 조회 const placeholders = pairs .map((_, i) => `($${i * 2 + 1}, $${i * 2 + 2})`) .join(", "); const params = pairs.flatMap((p) => [p.tableName, p.columnName]); try { const results = await query<{ table_name: string; column_name: string; input_type: string; }>( `SELECT table_name, column_name, input_type FROM table_type_columns WHERE (table_name, column_name) IN (${placeholders}) AND company_code = $${params.length + 1}`, [...params, companyCode], ); for (const row of results) { const componentType = this.getComponentIdFromInputType(row.input_type); inputTypeMap.set(`${row.table_name}.${row.column_name}`, { inputType: row.input_type, componentType: componentType, }); } console.log(`최신 inputType 조회 완료: ${results.length}개`); } catch (error) { console.warn(`최신 inputType 조회 실패 (무시됨):`, error); } return inputTypeMap; } // ======================================== // 템플릿 관리 // ======================================== /** * 템플릿 목록 조회 (회사별) (✅ Raw Query 전환 완료) */ async getTemplatesByCompany( companyCode: string, type?: string, isPublic?: boolean, ): Promise { const whereConditions: string[] = []; const params: any[] = []; if (companyCode !== "*") { whereConditions.push(`company_code = $${params.length + 1}`); params.push(companyCode); } if (type) { whereConditions.push(`template_type = $${params.length + 1}`); params.push(type); } if (isPublic !== undefined) { whereConditions.push(`is_public = $${params.length + 1}`); params.push(isPublic); } const whereSQL = whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : ""; const templates = await query( `SELECT * FROM screen_templates ${whereSQL} ORDER BY created_date DESC`, params, ); return templates.map(this.mapToScreenTemplate); } /** * 템플릿 생성 (✅ Raw Query 전환 완료) */ async createTemplate( templateData: Partial, ): Promise { const [template] = await query( `INSERT INTO screen_templates ( template_name, template_type, company_code, description, layout_data, is_public, created_by ) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`, [ templateData.templateName!, templateData.templateType!, templateData.companyCode!, templateData.description || null, templateData.layoutData ? JSON.stringify(JSON.parse(JSON.stringify(templateData.layoutData))) : null, templateData.isPublic || false, templateData.createdBy || null, ], ); return this.mapToScreenTemplate(template); } // ======================================== // 메뉴 할당 관리 // ======================================== /** * 화면-메뉴 할당 (✅ Raw Query 전환 완료) */ async assignScreenToMenu( screenId: number, assignmentData: MenuAssignmentRequest, ): Promise { // 중복 할당 방지 const existing = await query<{ assignment_id: number }>( `SELECT assignment_id FROM screen_menu_assignments WHERE screen_id = $1 AND menu_objid = $2 AND company_code = $3 LIMIT 1`, [screenId, assignmentData.menuObjid, assignmentData.companyCode], ); if (existing.length > 0) { throw new Error("이미 할당된 화면입니다."); } // screen_menu_assignments에 할당 추가 await query( `INSERT INTO screen_menu_assignments ( screen_id, menu_objid, company_code, display_order, created_by ) VALUES ($1, $2, $3, $4, $5)`, [ screenId, assignmentData.menuObjid, assignmentData.companyCode, assignmentData.displayOrder || 0, assignmentData.createdBy || null, ], ); // 화면 정보 조회 (screen_code 가져오기) const screen = await queryOne<{ screen_code: string }>( `SELECT screen_code FROM screen_definitions WHERE screen_id = $1`, [screenId], ); if (screen) { // menu_info 테이블도 함께 업데이트 (menu_url과 screen_code 설정) // 관리자 메뉴인지 확인 const menu = await queryOne<{ menu_type: string }>( `SELECT menu_type FROM menu_info WHERE objid = $1`, [assignmentData.menuObjid], ); const isAdminMenu = menu && (menu.menu_type === "0" || menu.menu_type === "admin"); const menuUrl = isAdminMenu ? `/screens/${screenId}?mode=admin` : `/screens/${screenId}`; await query( `UPDATE menu_info SET menu_url = $1, screen_code = $2 WHERE objid = $3`, [menuUrl, screen.screen_code, assignmentData.menuObjid], ); logger.info("화면 할당 완료 (menu_info 업데이트)", { screenId, menuObjid: assignmentData.menuObjid, menuUrl, screenCode: screen.screen_code, }); } } /** * 메뉴별 화면 목록 조회 (✅ Raw Query 전환 완료) */ async getScreensByMenu( menuObjid: number, companyCode: string, ): Promise { const screens = await query( `SELECT sd.* FROM screen_menu_assignments sma INNER JOIN screen_definitions sd ON sma.screen_id = sd.screen_id WHERE sma.menu_objid = $1 AND sma.company_code = $2 AND sma.is_active = 'Y' ORDER BY sma.display_order ASC`, [menuObjid, companyCode], ); return screens.map((screen) => this.mapToScreenDefinition(screen)); } /** * 화면에 할당된 메뉴 조회 (첫 번째 할당만 반환) * 화면 편집기에서 menuObjid를 가져오기 위해 사용 */ async getMenuByScreen( screenId: number, companyCode: string, ): Promise<{ menuObjid: number; menuName?: string } | null> { const result = await queryOne<{ menu_objid: string; menu_name_kor?: string; }>( `SELECT sma.menu_objid, mi.menu_name_kor FROM screen_menu_assignments sma LEFT JOIN menu_info mi ON sma.menu_objid = mi.objid WHERE sma.screen_id = $1 AND sma.company_code = $2 AND sma.is_active = 'Y' ORDER BY sma.created_date ASC LIMIT 1`, [screenId, companyCode], ); if (!result) { return null; } return { menuObjid: parseInt(result.menu_objid), menuName: result.menu_name_kor, }; } /** * 화면-메뉴 할당 해제 (✅ Raw Query 전환 완료) */ async unassignScreenFromMenu( screenId: number, menuObjid: number, companyCode: string, ): Promise { // screen_menu_assignments에서 할당 삭제 await query( `DELETE FROM screen_menu_assignments WHERE screen_id = $1 AND menu_objid = $2 AND company_code = $3`, [screenId, menuObjid, companyCode], ); // menu_info 테이블도 함께 업데이트 (menu_url과 screen_code 제거) await query( `UPDATE menu_info SET menu_url = NULL, screen_code = NULL WHERE objid = $1`, [menuObjid], ); logger.info("화면 할당 해제 완료 (menu_info 업데이트)", { screenId, menuObjid, companyCode, }); } // ======================================== // 테이블 타입 연계 // ======================================== /** * 컬럼 정보 조회 (웹 타입 포함) (✅ Raw Query 전환 완료) */ async getColumnInfo(tableName: string): Promise { const columns = await query( `SELECT c.column_name, COALESCE(ttc.column_label, c.column_name) as column_label, c.data_type, COALESCE(ttc.input_type, 'text') as web_type, c.is_nullable, c.column_default, c.character_maximum_length, c.numeric_precision, c.numeric_scale, ttc.detail_settings, ttc.code_category, ttc.reference_table, ttc.reference_column, ttc.display_column, ttc.is_visible, ttc.display_order, ttc.description FROM information_schema.columns c LEFT JOIN table_type_columns ttc ON c.table_name = ttc.table_name AND c.column_name = ttc.column_name AND ttc.company_code = '*' WHERE c.table_name = $1 ORDER BY COALESCE(ttc.display_order, c.ordinal_position)`, [tableName], ); return columns as ColumnInfo[]; } /** * 입력 타입 설정 (✅ Raw Query 전환 완료) */ async setColumnWebType( tableName: string, columnName: string, webType: WebType, additionalSettings?: Partial, ): Promise { // UPSERT를 INSERT ... ON CONFLICT로 변환 (table_type_columns 사용) await query( `INSERT INTO table_type_columns ( table_name, column_name, column_label, input_type, detail_settings, code_category, reference_table, reference_column, display_column, is_visible, display_order, description, is_nullable, company_code, created_date, updated_date ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 'Y', '*', $13, $14) ON CONFLICT (table_name, column_name, company_code) DO UPDATE SET input_type = EXCLUDED.input_type, column_label = COALESCE(EXCLUDED.column_label, table_type_columns.column_label), detail_settings = COALESCE(EXCLUDED.detail_settings, table_type_columns.detail_settings), code_category = COALESCE(EXCLUDED.code_category, table_type_columns.code_category), reference_table = COALESCE(EXCLUDED.reference_table, table_type_columns.reference_table), reference_column = COALESCE(EXCLUDED.reference_column, table_type_columns.reference_column), display_column = COALESCE(EXCLUDED.display_column, table_type_columns.display_column), is_visible = COALESCE(EXCLUDED.is_visible, table_type_columns.is_visible), display_order = COALESCE(EXCLUDED.display_order, table_type_columns.display_order), description = COALESCE(EXCLUDED.description, table_type_columns.description), updated_date = EXCLUDED.updated_date`, [ tableName, columnName, additionalSettings?.columnLabel || null, webType, additionalSettings?.detailSettings ? JSON.stringify(additionalSettings.detailSettings) : null, additionalSettings?.codeCategory || null, additionalSettings?.referenceTable || null, additionalSettings?.referenceColumn || null, (additionalSettings as any)?.displayColumn || null, additionalSettings?.isVisible ?? true, additionalSettings?.displayOrder ?? 0, additionalSettings?.description || null, new Date(), new Date(), ], ); } /** * 웹 타입별 위젯 생성 */ generateWidgetFromColumn(column: ColumnInfo): WidgetData { const baseWidget = { id: generateId(), tableName: column.tableName, columnName: column.columnName, type: column.webType || "text", label: column.columnLabel || column.columnName, required: column.isNullable === "N", readonly: false, }; // detail_settings JSON 파싱 const detailSettings = column.detailSettings ? JSON.parse(column.detailSettings) : {}; switch (column.webType) { case "text": return { ...baseWidget, maxLength: detailSettings.maxLength || column.characterMaximumLength, placeholder: `Enter ${column.columnLabel || column.columnName}`, pattern: detailSettings.pattern, }; case "number": return { ...baseWidget, min: detailSettings.min, max: detailSettings.max || (column.numericPrecision ? Math.pow(10, column.numericPrecision) - 1 : undefined), step: detailSettings.step || (column.numericScale && column.numericScale > 0 ? Math.pow(10, -column.numericScale) : 1), }; case "date": return { ...baseWidget, format: detailSettings.format || "YYYY-MM-DD", minDate: detailSettings.minDate, maxDate: detailSettings.maxDate, }; case "code": return { ...baseWidget, codeCategory: column.codeCategory, multiple: detailSettings.multiple || false, searchable: detailSettings.searchable || false, }; case "entity": return { ...baseWidget, referenceTable: column.referenceTable, referenceColumn: column.referenceColumn, searchable: detailSettings.searchable || true, multiple: detailSettings.multiple || false, }; case "textarea": return { ...baseWidget, rows: detailSettings.rows || 3, maxLength: detailSettings.maxLength || column.characterMaximumLength, }; case "select": return { ...baseWidget, options: detailSettings.options || [], multiple: detailSettings.multiple || false, searchable: detailSettings.searchable || false, }; case "checkbox": return { ...baseWidget, defaultChecked: detailSettings.defaultChecked || false, label: detailSettings.label || column.columnLabel, }; case "radio": return { ...baseWidget, options: detailSettings.options || [], inline: detailSettings.inline || false, }; case "file": return { ...baseWidget, accept: detailSettings.accept || "*/*", maxSize: detailSettings.maxSize || 10485760, // 10MB multiple: detailSettings.multiple || false, }; default: return { ...baseWidget, type: "text", }; } } // ======================================== // 유틸리티 메서드 // ======================================== private mapToScreenDefinition( data: any, tableLabelMap?: Map, ): ScreenDefinition { const tableLabel = tableLabelMap?.get(data.table_name) || data.table_name; return { screenId: data.screen_id, screenName: data.screen_name, screenCode: data.screen_code, tableName: data.table_name, tableLabel: tableLabel, // 라벨이 있으면 라벨, 없으면 테이블명 companyCode: data.company_code, description: data.description, isActive: data.is_active, createdDate: data.created_date, createdBy: data.created_by, updatedDate: data.updated_date, updatedBy: data.updated_by, dbSourceType: data.db_source_type || "internal", dbConnectionId: data.db_connection_id || undefined, // REST API 관련 필드 dataSourceType: data.data_source_type || "database", restApiConnectionId: data.rest_api_connection_id || undefined, restApiEndpoint: data.rest_api_endpoint || undefined, restApiJsonPath: data.rest_api_json_path || "data", }; } private mapToScreenTemplate(data: any): ScreenTemplate { return { templateId: data.template_id, templateName: data.template_name, templateType: data.template_type, companyCode: data.company_code, description: data.description, layoutData: data.layout_data, isPublic: data.is_public, createdBy: data.created_by, createdDate: data.created_date, }; } /** * 화면 코드 자동 생성 (회사코드 + '_' + 순번) (✅ Raw Query 전환 완료) * 동시성 문제 방지: Advisory Lock 사용 */ async generateScreenCode(companyCode: string): Promise { return await transaction(async (client) => { // 회사 코드를 숫자로 변환하여 advisory lock ID로 사용 const lockId = Buffer.from(companyCode).reduce( (acc, byte) => acc + byte, 0, ); // Advisory lock 획득 (다른 트랜잭션이 같은 회사 코드를 생성하는 동안 대기) await client.query("SELECT pg_advisory_xact_lock($1)", [lockId]); // 해당 회사의 기존 화면 코드들 조회 (모든 화면 - 삭제된 코드도 재사용 방지) // LIMIT 제거하고 숫자 추출하여 최대값 찾기 const existingScreens = await client.query<{ screen_code: string }>( `SELECT screen_code FROM screen_definitions WHERE screen_code LIKE $1 ORDER BY screen_code DESC`, [`${companyCode}_%`], ); // 회사 코드 뒤의 숫자 부분 추출하여 최대값 찾기 let maxNumber = 0; const pattern = new RegExp( `^${companyCode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}_(\\d+)$`, ); console.log( `🔍 화면 코드 생성 - 조회된 화면 수: ${existingScreens.rows.length}`, ); console.log(`🔍 패턴: ${pattern}`); for (const screen of existingScreens.rows) { const match = screen.screen_code.match(pattern); if (match) { const number = parseInt(match[1], 10); console.log(`🔍 매칭: ${screen.screen_code} → 숫자: ${number}`); if (number > maxNumber) { maxNumber = number; } } } // 다음 순번으로 화면 코드 생성 const nextNumber = maxNumber + 1; // 숫자가 3자리 이상이면 패딩 없이, 아니면 3자리 패딩 const newCode = `${companyCode}_${nextNumber}`; console.log( `🔢 화면 코드 생성: ${companyCode} → ${newCode} (maxNumber: ${maxNumber}, nextNumber: ${nextNumber})`, ); return newCode; // Advisory lock은 트랜잭션 종료 시 자동으로 해제됨 }); } /** * 여러 개의 화면 코드를 한 번에 생성 (중복 방지) * 한 트랜잭션 내에서 순차적으로 생성하여 중복 방지 */ async generateMultipleScreenCodes( companyCode: string, count: number, ): Promise { return await transaction(async (client) => { // Advisory lock 획득 const lockId = Buffer.from(companyCode).reduce( (acc, byte) => acc + byte, 0, ); await client.query("SELECT pg_advisory_xact_lock($1)", [lockId]); // 현재 최대 번호 조회 (숫자 추출 후 정렬) // 패턴: COMPANY_CODE_XXX 또는 COMPANY_CODEXXX const existingScreens = await client.query<{ screen_code: string; num: number; }>( `SELECT screen_code, COALESCE( NULLIF( regexp_replace(screen_code, $2, '\\1'), screen_code )::integer, 0 ) as num FROM screen_definitions WHERE company_code = $1 AND screen_code ~ $2 AND deleted_date IS NULL ORDER BY num DESC LIMIT 1`, [ companyCode, `^${companyCode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[_]?(\\d+)$`, ], ); let maxNumber = 0; if (existingScreens.rows.length > 0 && existingScreens.rows[0].num) { maxNumber = existingScreens.rows[0].num; } console.log(`🔢 현재 최대 화면 코드 번호: ${companyCode} → ${maxNumber}`); // count개의 코드를 순차적으로 생성 const codes: string[] = []; for (let i = 0; i < count; i++) { const nextNumber = maxNumber + i + 1; const paddedNumber = nextNumber.toString().padStart(3, "0"); codes.push(`${companyCode}_${paddedNumber}`); } console.log( `🔢 화면 코드 일괄 생성 (${count}개): ${companyCode} → [${codes.join(", ")}]`, ); return codes; }); } /** * 화면명 중복 체크 * 같은 회사 내에서 동일한 화면명이 있는지 확인 */ async checkDuplicateScreenName( companyCode: string, screenName: string, ): Promise { const result = await query( `SELECT COUNT(*) as count FROM screen_definitions WHERE company_code = $1 AND screen_name = $2 AND deleted_date IS NULL`, [companyCode, screenName], ); const count = parseInt(result[0]?.count || "0", 10); return count > 0; } /** * 화면에 연결된 모달/화면들을 재귀적으로 자동 감지 * - 버튼 컴포넌트: popup/modal/edit/openModalWithData 액션의 targetScreenId * - 조건부 컨테이너: sections[].screenId (조건별 화면 할당) * - 중첩된 화면들도 모두 감지 (재귀) */ async detectLinkedModalScreens( screenId: number, ): Promise<{ screenId: number; screenName: string; screenCode: string }[]> { console.log(`\n🔍 [재귀 감지 시작] 화면 ID: ${screenId}`); const allLinkedScreenIds = new Set(); const visited = new Set(); // 무한 루프 방지 const queue: number[] = [screenId]; // BFS 큐 // BFS로 연결된 모든 화면 탐색 while (queue.length > 0) { const currentScreenId = queue.shift()!; // 이미 방문한 화면은 스킵 (순환 참조 방지) if (visited.has(currentScreenId)) { console.log(`⏭️ 이미 방문한 화면 스킵: ${currentScreenId}`); continue; } visited.add(currentScreenId); console.log( `\n📋 현재 탐색 중인 화면: ${currentScreenId} (깊이: ${visited.size})`, ); // 현재 화면의 모든 레이아웃 조회 const layouts = await query( `SELECT layout_id, properties FROM screen_layouts WHERE screen_id = $1 AND component_type = 'component' AND properties IS NOT NULL`, [currentScreenId], ); console.log(` 📦 레이아웃 개수: ${layouts.length}`); // 각 레이아웃에서 연결된 화면 ID 확인 for (const layout of layouts) { try { const properties = layout.properties; // 1. 버튼 컴포넌트의 액션 확인 if ( properties?.componentType === "button" || properties?.componentType?.startsWith("button-") ) { const action = properties?.componentConfig?.action; const modalActionTypes = [ "popup", "modal", "edit", "openModalWithData", ]; if ( modalActionTypes.includes(action?.type) && action?.targetScreenId ) { const targetScreenId = parseInt(action.targetScreenId); if ( !isNaN(targetScreenId) && targetScreenId !== currentScreenId ) { // 메인 화면이 아닌 경우에만 추가 if (targetScreenId !== screenId) { allLinkedScreenIds.add(targetScreenId); } // 아직 방문하지 않은 화면이면 큐에 추가 if (!visited.has(targetScreenId)) { queue.push(targetScreenId); console.log( ` 🔗 [버튼] 연결된 화면 발견: ${targetScreenId} (action: ${action.type}) → 큐에 추가`, ); } } } } // 2. conditional-container 컴포넌트의 sections 확인 if (properties?.componentType === "conditional-container") { const sections = properties?.componentConfig?.sections || []; for (const section of sections) { if (section?.screenId) { const sectionScreenId = parseInt(section.screenId); if ( !isNaN(sectionScreenId) && sectionScreenId !== currentScreenId ) { // 메인 화면이 아닌 경우에만 추가 if (sectionScreenId !== screenId) { allLinkedScreenIds.add(sectionScreenId); } // 아직 방문하지 않은 화면이면 큐에 추가 if (!visited.has(sectionScreenId)) { queue.push(sectionScreenId); console.log( ` 🔗 [조건부컨테이너] 연결된 화면 발견: ${sectionScreenId} (condition: ${section.condition}) → 큐에 추가`, ); } } } } } } catch (error) { console.warn(` ⚠️ 레이아웃 ${layout.layout_id} 파싱 오류:`, error); } } } console.log( `\n✅ [재귀 감지 완료] 총 방문한 화면: ${visited.size}개, 연결된 화면: ${allLinkedScreenIds.size}개`, ); console.log(` 방문한 화면 ID: [${Array.from(visited).join(", ")}]`); console.log( ` 연결된 화면 ID: [${Array.from(allLinkedScreenIds).join(", ")}]`, ); // 감지된 화면 ID들의 정보 조회 if (allLinkedScreenIds.size === 0) { console.log(`ℹ️ 연결된 화면이 없습니다.`); return []; } const screenIds = Array.from(allLinkedScreenIds); const placeholders = screenIds.map((_, i) => `$${i + 1}`).join(", "); const linkedScreens = await query( `SELECT screen_id, screen_name, screen_code FROM screen_definitions WHERE screen_id IN (${placeholders}) AND deleted_date IS NULL ORDER BY screen_name`, screenIds, ); console.log(`\n📋 최종 감지된 화면 목록:`); linkedScreens.forEach((s: any) => { console.log( ` - ${s.screen_name} (ID: ${s.screen_id}, 코드: ${s.screen_code})`, ); }); return linkedScreens.map((s) => ({ screenId: s.screen_id, screenName: s.screen_name, screenCode: s.screen_code, })); } /** * 화면 레이아웃에서 사용하는 numberingRuleId 수집 * - componentConfig.autoGeneration.options.numberingRuleId (text-input) * - componentConfig.sections[].fields[].numberingRule.ruleId (universal-form-modal) * - componentConfig.action.excelNumberingRuleId (엑셀 업로드) */ private collectNumberingRuleIdsFromLayouts(layouts: any[]): Set { const ruleIds = new Set(); for (const layout of layouts) { const props = layout.properties; if (!props) continue; // 1. componentConfig.autoGeneration.options.numberingRuleId (text-input 컴포넌트) const autoGenRuleId = props?.componentConfig?.autoGeneration?.options?.numberingRuleId; if ( autoGenRuleId && typeof autoGenRuleId === "string" && autoGenRuleId.startsWith("rule-") ) { ruleIds.add(autoGenRuleId); } // 2. componentConfig.sections[].fields[].numberingRule.ruleId (universal-form-modal) const sections = props?.componentConfig?.sections; if (Array.isArray(sections)) { for (const section of sections) { const fields = section?.fields; if (Array.isArray(fields)) { for (const field of fields) { const ruleId = field?.numberingRule?.ruleId; if ( ruleId && typeof ruleId === "string" && ruleId.startsWith("rule-") ) { ruleIds.add(ruleId); } } } // optionalFieldGroups 내부의 필드들도 확인 const optGroups = section?.optionalFieldGroups; if (Array.isArray(optGroups)) { for (const optGroup of optGroups) { const optFields = optGroup?.fields; if (Array.isArray(optFields)) { for (const field of optFields) { const ruleId = field?.numberingRule?.ruleId; if ( ruleId && typeof ruleId === "string" && ruleId.startsWith("rule-") ) { ruleIds.add(ruleId); } } } } } } } // 3. componentConfig.action.excelNumberingRuleId (엑셀 업로드) const excelRuleId = props?.componentConfig?.action?.excelNumberingRuleId; if ( excelRuleId && typeof excelRuleId === "string" && excelRuleId.startsWith("rule-") ) { ruleIds.add(excelRuleId); } // 4. componentConfig.action.numberingRuleId (버튼 액션) const actionRuleId = props?.componentConfig?.action?.numberingRuleId; if ( actionRuleId && typeof actionRuleId === "string" && actionRuleId.startsWith("rule-") ) { ruleIds.add(actionRuleId); } } return ruleIds; } /** * 채번 규칙 복사 및 ID 매핑 반환 * - 원본 회사의 채번 규칙을 대상 회사로 복사 * - 이름이 같은 규칙이 있으면 재사용 * - current_sequence는 0으로 초기화 */ /** * 채번 규칙 복제 (numbering_rules_test 테이블 사용) * - menu_objid 의존성 제거됨 * - table_name + column_name + company_code 기반 */ private async copyNumberingRulesForScreen( ruleIds: Set, sourceCompanyCode: string, targetCompanyCode: string, client: any, ): Promise> { const ruleIdMap = new Map(); if (ruleIds.size === 0) { return ruleIdMap; } console.log(`🔄 채번 규칙 복사 시작: ${ruleIds.size}개 규칙`); // 1. 원본 채번 규칙 조회 (numbering_rules_test 테이블) const ruleIdArray = Array.from(ruleIds); const sourceRulesResult = await client.query( `SELECT * FROM numbering_rules_test WHERE rule_id = ANY($1)`, [ruleIdArray], ); if (sourceRulesResult.rows.length === 0) { console.log(` 📭 복사할 채번 규칙 없음 (해당 rule_id 없음)`); return ruleIdMap; } console.log(` 📋 원본 채번 규칙: ${sourceRulesResult.rows.length}개`); // 2. 대상 회사의 기존 채번 규칙 조회 (이름 기준) const existingRulesResult = await client.query( `SELECT rule_id, rule_name FROM numbering_rules_test WHERE company_code = $1`, [targetCompanyCode], ); const existingRulesByName = new Map( existingRulesResult.rows.map((r: any) => [r.rule_name, r.rule_id]), ); // 3. 각 규칙 복사 또는 재사용 for (const rule of sourceRulesResult.rows) { const existingId = existingRulesByName.get(rule.rule_name); if (existingId) { // 기존 규칙 재사용 ruleIdMap.set(rule.rule_id, existingId); console.log( ` ♻️ 기존 채번 규칙 재사용: ${rule.rule_name} (${rule.rule_id} → ${existingId})`, ); } else { // 새로 복사 - 새 rule_id 생성 const newRuleId = `rule-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; // numbering_rules_test 복사 (current_sequence = 0으로 초기화) await client.query( `INSERT INTO numbering_rules_test ( rule_id, rule_name, description, separator, reset_period, current_sequence, table_name, column_name, company_code, created_at, updated_at, created_by, last_generated_date, category_column, category_value_id ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)`, [ newRuleId, rule.rule_name, rule.description, rule.separator, rule.reset_period, 0, // current_sequence 초기화 rule.table_name, rule.column_name, targetCompanyCode, new Date(), new Date(), rule.created_by, null, // last_generated_date 초기화 rule.category_column, rule.category_value_id, ], ); // numbering_rule_parts_test 복사 const partsResult = await client.query( `SELECT * FROM numbering_rule_parts_test WHERE rule_id = $1 ORDER BY part_order`, [rule.rule_id], ); for (const part of partsResult.rows) { await client.query( `INSERT INTO numbering_rule_parts_test ( rule_id, part_order, part_type, generation_method, auto_config, manual_config, company_code, created_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, [ newRuleId, part.part_order, part.part_type, part.generation_method, part.auto_config ? JSON.stringify(part.auto_config) : null, part.manual_config ? JSON.stringify(part.manual_config) : null, targetCompanyCode, new Date(), ], ); } ruleIdMap.set(rule.rule_id, newRuleId); console.log( ` ➕ 채번 규칙 복사: ${rule.rule_name} (${rule.rule_id} → ${newRuleId}), 파트 ${partsResult.rows.length}개`, ); } } console.log(` ✅ 채번 규칙 복사 완료: 매핑 ${ruleIdMap.size}개`); return ruleIdMap; } /** * properties 내의 numberingRuleId 매핑 * - componentConfig.autoGeneration.options.numberingRuleId (text-input) * - componentConfig.sections[].fields[].numberingRule.ruleId (universal-form-modal) * - componentConfig.action.excelNumberingRuleId (엑셀 업로드) */ private updateNumberingRuleIdsInProperties( properties: any, ruleIdMap: Map, ): any { if (!properties || ruleIdMap.size === 0) return properties; const updated = JSON.parse(JSON.stringify(properties)); // 1. componentConfig.autoGeneration.options.numberingRuleId (text-input) if (updated?.componentConfig?.autoGeneration?.options?.numberingRuleId) { const oldId = updated.componentConfig.autoGeneration.options.numberingRuleId; const newId = ruleIdMap.get(oldId); if (newId) { updated.componentConfig.autoGeneration.options.numberingRuleId = newId; console.log( ` 🔗 autoGeneration.numberingRuleId: ${oldId} → ${newId}`, ); } } // 2. componentConfig.sections[].fields[].numberingRule.ruleId (universal-form-modal) if (Array.isArray(updated?.componentConfig?.sections)) { for (const section of updated.componentConfig.sections) { // 일반 필드 if (Array.isArray(section?.fields)) { for (const field of section.fields) { if (field?.numberingRule?.ruleId) { const oldId = field.numberingRule.ruleId; const newId = ruleIdMap.get(oldId); if (newId) { field.numberingRule.ruleId = newId; console.log( ` 🔗 field.numberingRule.ruleId: ${oldId} → ${newId}`, ); } } } } // optionalFieldGroups 내부의 필드들 if (Array.isArray(section?.optionalFieldGroups)) { for (const optGroup of section.optionalFieldGroups) { if (Array.isArray(optGroup?.fields)) { for (const field of optGroup.fields) { if (field?.numberingRule?.ruleId) { const oldId = field.numberingRule.ruleId; const newId = ruleIdMap.get(oldId); if (newId) { field.numberingRule.ruleId = newId; console.log( ` 🔗 optField.numberingRule.ruleId: ${oldId} → ${newId}`, ); } } } } } } } } // 3. componentConfig.action.excelNumberingRuleId if (updated?.componentConfig?.action?.excelNumberingRuleId) { const oldId = updated.componentConfig.action.excelNumberingRuleId; const newId = ruleIdMap.get(oldId); if (newId) { updated.componentConfig.action.excelNumberingRuleId = newId; console.log(` 🔗 excelNumberingRuleId: ${oldId} → ${newId}`); } } // 4. componentConfig.action.numberingRuleId (버튼 액션) if (updated?.componentConfig?.action?.numberingRuleId) { const oldId = updated.componentConfig.action.numberingRuleId; const newId = ruleIdMap.get(oldId); if (newId) { updated.componentConfig.action.numberingRuleId = newId; console.log(` 🔗 action.numberingRuleId: ${oldId} → ${newId}`); } } return updated; } /** * properties 내의 탭 컴포넌트 screenId 매핑 * - componentConfig.tabs[].screenId (tabs-widget) */ private updateTabScreenIdsInProperties( properties: any, screenIdMap: Map, ): any { if (!properties || screenIdMap.size === 0) return properties; const updated = JSON.parse(JSON.stringify(properties)); // componentConfig.tabs[].screenId (tabs-widget) if (Array.isArray(updated?.componentConfig?.tabs)) { for (const tab of updated.componentConfig.tabs) { if (tab?.screenId) { const oldId = Number(tab.screenId); const newId = screenIdMap.get(oldId); if (newId) { tab.screenId = newId; console.log(` 🔗 tab.screenId: ${oldId} → ${newId}`); } } } } return updated; } /** * 그룹 복제 완료 후 모든 컴포넌트의 화면 참조 일괄 업데이트 * - tabs 컴포넌트의 screenId * - conditional-container의 screenId * - 버튼/액션의 modalScreenId * - 버튼/액션의 targetScreenId (화면 이동, 모달 열기 등) * @param targetScreenIds 복제된 대상 화면 ID 목록 * @param screenIdMap 원본 화면 ID -> 새 화면 ID 매핑 */ async updateTabScreenReferences( targetScreenIds: number[], screenIdMap: { [key: number]: number }, ): Promise<{ updated: number; details: string[] }> { const result = { updated: 0, details: [] as string[] }; if (targetScreenIds.length === 0 || Object.keys(screenIdMap).length === 0) { console.log( `⚠️ updateTabScreenReferences 스킵: targetScreenIds=${targetScreenIds.length}, screenIdMap keys=${Object.keys(screenIdMap).length}`, ); return result; } console.log(`🔄 updateTabScreenReferences 시작:`); console.log(` - targetScreenIds: ${targetScreenIds.length}개`); console.log(` - screenIdMap: ${JSON.stringify(screenIdMap)}`); const screenMap = new Map( Object.entries(screenIdMap).map(([k, v]) => [Number(k), v]), ); await transaction(async (client) => { // 대상 화면들의 모든 레이아웃 조회 (screenId, modalScreenId, targetScreenId 참조가 있는 것) const placeholders = targetScreenIds .map((_, i) => `$${i + 1}`) .join(", "); const layoutsResult = await client.query( `SELECT layout_id, screen_id, properties FROM screen_layouts WHERE screen_id IN (${placeholders}) AND ( properties::text LIKE '%"screenId"%' OR properties::text LIKE '%"modalScreenId"%' OR properties::text LIKE '%"targetScreenId"%' )`, targetScreenIds, ); console.log( `🔍 참조 업데이트 대상 레이아웃: ${layoutsResult.rows.length}개`, ); for (const layout of layoutsResult.rows) { let properties = layout.properties; if (typeof properties === "string") { try { properties = JSON.parse(properties); } catch (e) { continue; } } let hasChanges = false; // 재귀적으로 모든 screenId/modalScreenId 참조 업데이트 const updateReferences = async ( obj: any, path: string = "", ): Promise => { if (!obj || typeof obj !== "object") return; for (const key of Object.keys(obj)) { const value = obj[key]; const currentPath = path ? `${path}.${key}` : key; // screenId 업데이트 if (key === "screenId" && typeof value === "number") { const newId = screenMap.get(value); if (newId) { obj[key] = newId; hasChanges = true; result.details.push( `layout_id=${layout.layout_id}: ${currentPath} ${value} → ${newId}`, ); console.log( `🔗 screenId 매핑: ${value} → ${newId} (${currentPath})`, ); // screenName도 함께 업데이트 (있는 경우) if (obj.screenName !== undefined) { const newScreenResult = await client.query( `SELECT screen_name FROM screen_definitions WHERE screen_id = $1`, [newId], ); if (newScreenResult.rows.length > 0) { obj.screenName = newScreenResult.rows[0].screen_name; } } } else { console.log( `⚠️ screenId ${value} 매핑 없음 (${currentPath}) - screenMap에 해당 키 없음`, ); } } // modalScreenId 업데이트 if (key === "modalScreenId" && typeof value === "number") { const newId = screenMap.get(value); if (newId) { obj[key] = newId; hasChanges = true; result.details.push( `layout_id=${layout.layout_id}: ${currentPath} ${value} → ${newId}`, ); console.log( `🔗 modalScreenId 매핑: ${value} → ${newId} (${currentPath})`, ); } else { console.log( `⚠️ modalScreenId ${value} 매핑 없음 (${currentPath}) - screenMap에 해당 키 없음`, ); } } // targetScreenId 업데이트 (버튼 액션에서 사용, 문자열 또는 숫자) if (key === "targetScreenId") { const oldId = typeof value === "string" ? parseInt(value, 10) : value; if (!isNaN(oldId)) { const newId = screenMap.get(oldId); if (newId) { // 원래 타입 유지 (문자열이면 문자열, 숫자면 숫자) obj[key] = typeof value === "string" ? newId.toString() : newId; hasChanges = true; result.details.push( `layout_id=${layout.layout_id}: ${currentPath} ${oldId} → ${newId}`, ); console.log( `🔗 targetScreenId 매핑: ${oldId} → ${newId} (${currentPath})`, ); } else { console.log( `⚠️ targetScreenId ${oldId} 매핑 없음 (${currentPath}) - screenMap에 해당 키 없음`, ); } } } // 배열 처리 if (Array.isArray(value)) { for (let i = 0; i < value.length; i++) { await updateReferences(value[i], `${currentPath}[${i}]`); } } // 객체 재귀 else if (typeof value === "object" && value !== null) { await updateReferences(value, currentPath); } } }; await updateReferences(properties); if (hasChanges) { await client.query( `UPDATE screen_layouts SET properties = $1 WHERE layout_id = $2`, [JSON.stringify(properties), layout.layout_id], ); result.updated++; } } console.log( `✅ screenId/modalScreenId/targetScreenId 참조 업데이트 완료: ${result.updated}개 레이아웃`, ); }); return result; } /** * 탭 컴포넌트의 screenId를 대상 회사에서 같은 이름의 화면으로 자동 매핑 * @param properties 레이아웃 properties * @param targetCompanyCode 대상 회사 코드 * @param client PostgreSQL 클라이언트 * @returns 업데이트된 properties */ private async autoMapTabScreenIds( properties: any, targetCompanyCode: string, client: any, ): Promise { if (!Array.isArray(properties?.componentConfig?.tabs)) { return properties; } const tabs = properties.componentConfig.tabs; let hasChanges = false; for (const tab of tabs) { if (!tab?.screenId) continue; const oldScreenId = Number(tab.screenId); const oldScreenName = tab.screenName; // 1. 원본 화면 이름 조회 (screenName이 없는 경우) let screenNameToFind = oldScreenName; if (!screenNameToFind) { const sourceResult = await client.query( `SELECT screen_name FROM screen_definitions WHERE screen_id = $1 LIMIT 1`, [oldScreenId], ); if (sourceResult.rows.length > 0) { screenNameToFind = sourceResult.rows[0].screen_name; } } if (!screenNameToFind) continue; // 2. 대상 회사에서 유사한 이름의 화면 찾기 // 원본 화면 이름에서 회사 접두어를 제거하고 핵심 이름으로 검색 // 예: "탑씰 품목 카테고리설정" → "카테고리설정"으로 검색 const nameParts = screenNameToFind.split(" "); const coreNamePart = nameParts.length > 1 ? nameParts.slice(-1)[0] : screenNameToFind; const targetResult = await client.query( `SELECT screen_id, screen_name FROM screen_definitions WHERE company_code = $1 AND deleted_date IS NULL AND is_active = 'Y' AND screen_name LIKE $2 ORDER BY screen_id DESC LIMIT 1`, [targetCompanyCode, `%${coreNamePart}`], ); if (targetResult.rows.length > 0) { const newScreen = targetResult.rows[0]; tab.screenId = newScreen.screen_id; tab.screenName = newScreen.screen_name; hasChanges = true; console.log( `🔗 탭 screenId 자동 매핑: ${oldScreenId} (${oldScreenName}) → ${newScreen.screen_id} (${newScreen.screen_name})`, ); } } return properties; } /** * 화면 레이아웃에서 사용하는 flowId 수집 */ private collectFlowIdsFromLayouts(layouts: any[]): Set { const flowIds = new Set(); for (const layout of layouts) { const props = layout.properties; if (!props) continue; // webTypeConfig.dataflowConfig.flowConfig.flowId const flowId = props?.webTypeConfig?.dataflowConfig?.flowConfig?.flowId; if (flowId && !isNaN(parseInt(flowId))) { flowIds.add(parseInt(flowId)); } // webTypeConfig.dataflowConfig.selectedDiagramId const diagramId = props?.webTypeConfig?.dataflowConfig?.selectedDiagramId; if (diagramId && !isNaN(parseInt(diagramId))) { flowIds.add(parseInt(diagramId)); } // webTypeConfig.dataflowConfig.flowControls[].flowId const flowControls = props?.webTypeConfig?.dataflowConfig?.flowControls; if (Array.isArray(flowControls)) { for (const control of flowControls) { if (control?.flowId && !isNaN(parseInt(control.flowId))) { flowIds.add(parseInt(control.flowId)); } } } // componentConfig.action.excelAfterUploadFlows[].flowId const excelFlows = props?.componentConfig?.action?.excelAfterUploadFlows; if (Array.isArray(excelFlows)) { for (const flow of excelFlows) { if (flow?.flowId && !isNaN(parseInt(flow.flowId))) { flowIds.add(parseInt(flow.flowId)); } } } } return flowIds; } /** * 노드 플로우 복사 및 ID 매핑 반환 * - 원본 회사의 플로우를 대상 회사로 복사 * - 이름이 같은 플로우가 있으면 재사용 */ private async copyNodeFlowsForScreen( flowIds: Set, sourceCompanyCode: string, targetCompanyCode: string, client: any, ): Promise> { const flowIdMap = new Map(); if (flowIds.size === 0) { return flowIdMap; } console.log(`🔄 노드 플로우 복사 시작: ${flowIds.size}개 flowId`); // 1. 원본 플로우 조회 (company_code = "*" 전역 플로우는 복사하지 않음) const flowIdArray = Array.from(flowIds); const sourceFlowsResult = await client.query( `SELECT * FROM node_flows WHERE flow_id = ANY($1) AND company_code = $2`, [flowIdArray, sourceCompanyCode], ); if (sourceFlowsResult.rows.length === 0) { console.log(` 📭 복사할 노드 플로우 없음 (원본 회사 소속 플로우 없음)`); return flowIdMap; } console.log(` 📋 원본 노드 플로우: ${sourceFlowsResult.rows.length}개`); // 2. 대상 회사의 기존 플로우 조회 (이름 기준) const existingFlowsResult = await client.query( `SELECT flow_id, flow_name FROM node_flows WHERE company_code = $1`, [targetCompanyCode], ); const existingFlowsByName = new Map( existingFlowsResult.rows.map((f: any) => [f.flow_name, f.flow_id]), ); // 3. 각 플로우 복사 또는 재사용 for (const flow of sourceFlowsResult.rows) { const existingId = existingFlowsByName.get(flow.flow_name); if (existingId) { // 기존 플로우 재사용 flowIdMap.set(flow.flow_id, existingId); console.log( ` ♻️ 기존 플로우 재사용: ${flow.flow_name} (${flow.flow_id} → ${existingId})`, ); } else { // 새로 복사 const insertResult = await client.query( `INSERT INTO node_flows (flow_name, flow_description, flow_data, company_code) VALUES ($1, $2, $3, $4) RETURNING flow_id`, [ flow.flow_name, flow.flow_description, JSON.stringify(flow.flow_data), targetCompanyCode, ], ); const newFlowId = insertResult.rows[0].flow_id; flowIdMap.set(flow.flow_id, newFlowId); console.log( ` ➕ 플로우 복사: ${flow.flow_name} (${flow.flow_id} → ${newFlowId})`, ); } } console.log(` ✅ 노드 플로우 복사 완료: 매핑 ${flowIdMap.size}개`); return flowIdMap; } /** * properties 내의 flowId, selectedDiagramId 등을 매핑 */ private updateFlowIdsInProperties( properties: any, flowIdMap: Map, ): any { if (!properties || flowIdMap.size === 0) return properties; const updated = JSON.parse(JSON.stringify(properties)); // webTypeConfig.dataflowConfig.flowConfig.flowId if (updated?.webTypeConfig?.dataflowConfig?.flowConfig?.flowId) { const oldId = parseInt( updated.webTypeConfig.dataflowConfig.flowConfig.flowId, ); const newId = flowIdMap.get(oldId); if (newId) { updated.webTypeConfig.dataflowConfig.flowConfig.flowId = newId; console.log(` 🔗 flowConfig.flowId: ${oldId} → ${newId}`); } } // webTypeConfig.dataflowConfig.selectedDiagramId if (updated?.webTypeConfig?.dataflowConfig?.selectedDiagramId) { const oldId = parseInt( updated.webTypeConfig.dataflowConfig.selectedDiagramId, ); const newId = flowIdMap.get(oldId); if (newId) { updated.webTypeConfig.dataflowConfig.selectedDiagramId = newId; console.log(` 🔗 selectedDiagramId: ${oldId} → ${newId}`); } } // webTypeConfig.dataflowConfig.flowControls[].flowId if (Array.isArray(updated?.webTypeConfig?.dataflowConfig?.flowControls)) { for (const control of updated.webTypeConfig.dataflowConfig.flowControls) { if (control?.flowId) { const oldId = parseInt(control.flowId); const newId = flowIdMap.get(oldId); if (newId) { control.flowId = newId; console.log(` 🔗 flowControls.flowId: ${oldId} → ${newId}`); } } } } // componentConfig.action.excelAfterUploadFlows[].flowId if ( Array.isArray(updated?.componentConfig?.action?.excelAfterUploadFlows) ) { for (const flow of updated.componentConfig.action.excelAfterUploadFlows) { if (flow?.flowId) { const oldId = parseInt(flow.flowId); const newId = flowIdMap.get(oldId); if (newId) { flow.flowId = String(newId); console.log( ` 🔗 excelAfterUploadFlows.flowId: ${oldId} → ${newId}`, ); } } } } return updated; } /** * 화면 복사 (화면 정보 + 레이아웃 모두 복사) (✅ Raw Query 전환 완료) */ async copyScreen( sourceScreenId: number, copyData: CopyScreenRequest, ): Promise { // 트랜잭션으로 처리 return await transaction(async (client) => { // 1. 원본 화면 정보 조회 // 최고 관리자(company_code = "*")는 모든 화면을 조회할 수 있음 let sourceScreenQuery: string; let sourceScreenParams: any[]; if (copyData.companyCode === "*") { // 최고 관리자: 모든 회사의 화면 조회 가능 sourceScreenQuery = ` SELECT * FROM screen_definitions WHERE screen_id = $1 LIMIT 1 `; sourceScreenParams = [sourceScreenId]; } else { // 일반 회사: 자신의 회사 화면만 조회 가능 sourceScreenQuery = ` SELECT * FROM screen_definitions WHERE screen_id = $1 AND company_code = $2 LIMIT 1 `; sourceScreenParams = [sourceScreenId, copyData.companyCode]; } const sourceScreens = await client.query( sourceScreenQuery, sourceScreenParams, ); if (sourceScreens.rows.length === 0) { throw new Error("복사할 화면을 찾을 수 없습니다."); } const sourceScreen = sourceScreens.rows[0]; // 2. 대상 회사 코드 결정 // copyData.targetCompanyCode가 있으면 사용 (회사 간 복사) // 없으면 원본과 같은 회사에 복사 const targetCompanyCode = copyData.targetCompanyCode || sourceScreen.company_code; // 3. 화면 코드 중복 체크 (대상 회사 기준, 삭제되지 않은 화면만) const existingScreens = await client.query( `SELECT screen_id FROM screen_definitions WHERE screen_code = $1 AND company_code = $2 AND deleted_date IS NULL LIMIT 1`, [copyData.screenCode, targetCompanyCode], ); if (existingScreens.rows.length > 0) { throw new Error("이미 존재하는 화면 코드입니다."); } // 4. 새 화면 생성 (대상 회사에 생성) // 삭제된 화면(is_active = 'D')을 복사할 경우 활성 상태('Y')로 변경 const newIsActive = sourceScreen.is_active === "D" ? "Y" : sourceScreen.is_active; const newScreenResult = await client.query( `INSERT INTO screen_definitions ( screen_code, screen_name, description, company_code, table_name, is_active, created_by, created_date, updated_by, updated_date ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING *`, [ copyData.screenCode, copyData.screenName, copyData.description || sourceScreen.description, targetCompanyCode, // 대상 회사 코드 사용 sourceScreen.table_name, newIsActive, // 삭제된 화면은 활성 상태로 복사 copyData.createdBy, new Date(), copyData.createdBy, new Date(), ], ); const newScreen = newScreenResult.rows[0]; // 4. 원본 화면의 레이아웃 정보 조회 const sourceLayoutsResult = await client.query( `SELECT * FROM screen_layouts WHERE screen_id = $1 ORDER BY display_order ASC NULLS LAST`, [sourceScreenId], ); const sourceLayouts = sourceLayoutsResult.rows; // 5. 노드 플로우 복사 (회사가 다른 경우) let flowIdMap = new Map(); if ( sourceLayouts.length > 0 && sourceScreen.company_code !== targetCompanyCode ) { // 레이아웃에서 사용하는 flowId 수집 const flowIds = this.collectFlowIdsFromLayouts(sourceLayouts); if (flowIds.size > 0) { console.log(`🔍 화면 복사 - flowId 수집: ${flowIds.size}개`); // 노드 플로우 복사 및 매핑 생성 flowIdMap = await this.copyNodeFlowsForScreen( flowIds, sourceScreen.company_code, targetCompanyCode, client, ); } } // 5.1. 채번 규칙 복사 (회사가 다른 경우) let ruleIdMap = new Map(); if ( sourceLayouts.length > 0 && sourceScreen.company_code !== targetCompanyCode ) { // 레이아웃에서 사용하는 채번 규칙 ID 수집 const ruleIds = this.collectNumberingRuleIdsFromLayouts(sourceLayouts); if (ruleIds.size > 0) { console.log(`🔍 화면 복사 - 채번 규칙 ID 수집: ${ruleIds.size}개`); // 채번 규칙 복사 및 매핑 생성 ruleIdMap = await this.copyNumberingRulesForScreen( ruleIds, sourceScreen.company_code, targetCompanyCode, client, ); } } // 6. 레이아웃이 있다면 복사 if (sourceLayouts.length > 0) { try { // ID 매핑 맵 생성 const idMapping: { [oldId: string]: string } = {}; // 새로운 컴포넌트 ID 미리 생성 sourceLayouts.forEach((layout: any) => { idMapping[layout.component_id] = generateId(); }); // 각 레이아웃 컴포넌트 복사 for (const sourceLayout of sourceLayouts) { const newComponentId = idMapping[sourceLayout.component_id]; const newParentId = sourceLayout.parent_id ? idMapping[sourceLayout.parent_id] : null; // properties 파싱 let properties = sourceLayout.properties; if (typeof properties === "string") { try { properties = JSON.parse(properties); } catch (e) { // 파싱 실패 시 그대로 사용 } } // flowId 매핑 적용 (회사가 다른 경우) if (flowIdMap.size > 0) { properties = this.updateFlowIdsInProperties( properties, flowIdMap, ); } // 채번 규칙 ID 매핑 적용 (회사가 다른 경우) if (ruleIdMap.size > 0) { properties = this.updateNumberingRuleIdsInProperties( properties, ruleIdMap, ); } // 탭 컴포넌트의 screenId는 개별 복제 시점에 업데이트하지 않음 // 모든 화면 복제 완료 후 updateTabScreenReferences에서 screenIdMap 기반으로 일괄 업데이트 await client.query( `INSERT INTO screen_layouts ( screen_id, component_type, component_id, parent_id, position_x, position_y, width, height, properties, display_order, created_date ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, [ newScreen.screen_id, sourceLayout.component_type, newComponentId, newParentId, Math.round(sourceLayout.position_x), // 정수로 반올림 Math.round(sourceLayout.position_y), // 정수로 반올림 Math.round(sourceLayout.width), // 정수로 반올림 Math.round(sourceLayout.height), // 정수로 반올림 JSON.stringify(properties), sourceLayout.display_order, new Date(), ], ); } } catch (error) { console.error("레이아웃 복사 중 오류:", error); // 레이아웃 복사 실패해도 화면 생성은 유지 } } // 6. 생성된 화면 정보 반환 return { screenId: newScreen.screen_id, screenCode: newScreen.screen_code, screenName: newScreen.screen_name, description: newScreen.description || "", companyCode: newScreen.company_code, tableName: newScreen.table_name, isActive: newScreen.is_active, createdBy: newScreen.created_by || undefined, createdDate: newScreen.created_date, updatedBy: newScreen.updated_by || undefined, updatedDate: newScreen.updated_date, }; }); } /** * 메인 화면 + 연결된 모달 화면들 일괄 복사 */ async copyScreenWithModals(data: { sourceScreenId: number; companyCode: string; userId: string; targetCompanyCode?: string; // 최고 관리자 전용: 다른 회사로 복사 mainScreen: { screenName: string; screenCode: string; description?: string; }; modalScreens: Array<{ sourceScreenId: number; screenName: string; screenCode: string; }>; }): Promise<{ mainScreen: ScreenDefinition; modalScreens: ScreenDefinition[]; }> { const targetCompany = data.targetCompanyCode || data.companyCode; console.log( `🔄 일괄 복사 시작: 메인(${data.sourceScreenId}) + 모달(${data.modalScreens.length}개) → ${targetCompany}`, ); // 1. 메인 화면 복사 const mainScreen = await this.copyScreen(data.sourceScreenId, { screenName: data.mainScreen.screenName, screenCode: data.mainScreen.screenCode, description: data.mainScreen.description || "", companyCode: data.companyCode, createdBy: data.userId, targetCompanyCode: data.targetCompanyCode, // 대상 회사 코드 전달 }); console.log( `✅ 메인 화면 복사 완료: ${mainScreen.screenId} (${mainScreen.screenCode}) @ ${mainScreen.companyCode}`, ); // 2. 모달 화면들 복사 (원본 screenId → 새 screenId 매핑) const modalScreens: ScreenDefinition[] = []; const screenIdMapping: Map = new Map(); // 원본 ID → 새 ID for (const modalData of data.modalScreens) { const copiedModal = await this.copyScreen(modalData.sourceScreenId, { screenName: modalData.screenName, screenCode: modalData.screenCode, description: "", companyCode: data.companyCode, createdBy: data.userId, targetCompanyCode: data.targetCompanyCode, // 대상 회사 코드 전달 }); modalScreens.push(copiedModal); screenIdMapping.set(modalData.sourceScreenId, copiedModal.screenId); console.log( `✅ 모달 화면 복사 완료: ${modalData.sourceScreenId} → ${copiedModal.screenId} (${copiedModal.screenCode})`, ); } // 3. 메인 화면의 버튼 액션에서 targetScreenId 업데이트 // 모든 복사가 완료되고 커밋된 후에 실행 console.log( `🔧 버튼 업데이트 시작: 메인 화면 ${mainScreen.screenId}, 매핑:`, Array.from(screenIdMapping.entries()), ); const updateCount = await this.updateButtonTargetScreenIds( mainScreen.screenId, screenIdMapping, ); console.log( `🎉 일괄 복사 완료: 메인(${mainScreen.screenId}) + 모달(${modalScreens.length}개), 버튼 ${updateCount}개 업데이트`, ); return { mainScreen, modalScreens, }; } /** * 화면 레이아웃에서 버튼의 targetScreenId를 새 screenId로 업데이트 * (독립적인 트랜잭션으로 실행) */ private async updateButtonTargetScreenIds( screenId: number, screenIdMapping: Map, ): Promise { console.log( `🔍 updateButtonTargetScreenIds 호출: screenId=${screenId}, 매핑 개수=${screenIdMapping.size}`, ); // 화면의 모든 레이아웃 조회 const layouts = await query( `SELECT layout_id, properties FROM screen_layouts WHERE screen_id = $1 AND component_type = 'component' AND properties IS NOT NULL`, [screenId], ); console.log(`📦 조회된 레이아웃 개수: ${layouts.length}`); let updateCount = 0; for (const layout of layouts) { try { const properties = layout.properties; let needsUpdate = false; // 1. 버튼 컴포넌트의 targetScreenId 업데이트 if ( properties?.componentType === "button" || properties?.componentType?.startsWith("button-") ) { const action = properties?.componentConfig?.action; // targetScreenId가 있는 액션 (popup, modal, edit, openModalWithData) const modalActionTypes = [ "popup", "modal", "edit", "openModalWithData", ]; if ( modalActionTypes.includes(action?.type) && action?.targetScreenId ) { const oldScreenId = parseInt(action.targetScreenId); console.log( `🔍 [버튼] 발견: layout ${layout.layout_id}, action=${action.type}, targetScreenId=${oldScreenId}`, ); // 매핑에 있으면 업데이트 if (screenIdMapping.has(oldScreenId)) { const newScreenId = screenIdMapping.get(oldScreenId)!; console.log(`✅ 매핑 발견: ${oldScreenId} → ${newScreenId}`); // properties 업데이트 properties.componentConfig.action.targetScreenId = newScreenId.toString(); needsUpdate = true; console.log( `🔗 [버튼] targetScreenId 업데이트 준비: ${oldScreenId} → ${newScreenId} (layout ${layout.layout_id})`, ); } else { console.log(`⚠️ 매핑 없음: ${oldScreenId} (업데이트 건너뜀)`); } } } // 2. conditional-container 컴포넌트의 sections[].screenId 업데이트 if (properties?.componentType === "conditional-container") { const sections = properties?.componentConfig?.sections || []; for (const section of sections) { if (section?.screenId) { const oldScreenId = parseInt(section.screenId); console.log( `🔍 [조건부컨테이너] section 발견: layout ${layout.layout_id}, condition=${section.condition}, screenId=${oldScreenId}`, ); // 매핑에 있으면 업데이트 if (screenIdMapping.has(oldScreenId)) { const newScreenId = screenIdMapping.get(oldScreenId)!; console.log(`✅ 매핑 발견: ${oldScreenId} → ${newScreenId}`); // section.screenId 업데이트 section.screenId = newScreenId; needsUpdate = true; console.log( `🔗 [조건부컨테이너] screenId 업데이트 준비: ${oldScreenId} → ${newScreenId} (layout ${layout.layout_id}, condition=${section.condition})`, ); } else { console.log(`⚠️ 매핑 없음: ${oldScreenId} (업데이트 건너뜀)`); } } } } // 3. 업데이트가 필요한 경우 DB 저장 if (needsUpdate) { await query( `UPDATE screen_layouts SET properties = $1 WHERE layout_id = $2`, [JSON.stringify(properties), layout.layout_id], ); updateCount++; console.log(`💾 레이아웃 ${layout.layout_id} 업데이트 완료`); } } catch (error) { console.warn(`❌ 레이아웃 ${layout.layout_id} 업데이트 오류:`, error); // 개별 레이아웃 오류는 무시하고 계속 진행 } } console.log( `✅ 총 ${updateCount}개 레이아웃의 연결된 화면 ID 업데이트 완료 (버튼 + 조건부컨테이너)`, ); return updateCount; } /** * 화면-메뉴 할당 복제 (screen_menu_assignments) * * @param sourceCompanyCode 원본 회사 코드 * @param targetCompanyCode 대상 회사 코드 * @param screenIdMap 원본 화면 ID -> 새 화면 ID 매핑 * @returns 복제 결과 */ async copyScreenMenuAssignments( sourceCompanyCode: string, targetCompanyCode: string, screenIdMap: Record, ): Promise<{ copiedCount: number; skippedCount: number; details: string[] }> { const result = { copiedCount: 0, skippedCount: 0, details: [] as string[], }; return await transaction(async (client) => { logger.info("🔗 화면-메뉴 할당 복제 시작", { sourceCompanyCode, targetCompanyCode, }); // 1. 원본 회사의 screen_groups (menu_objid 포함) 조회 const sourceGroupsResult = await client.query<{ id: number; group_name: string; menu_objid: string | null; }>( `SELECT id, group_name, menu_objid FROM screen_groups WHERE company_code = $1 AND menu_objid IS NOT NULL`, [sourceCompanyCode], ); // 2. 대상 회사의 screen_groups (menu_objid 포함) 조회 const targetGroupsResult = await client.query<{ id: number; group_name: string; menu_objid: string | null; }>( `SELECT id, group_name, menu_objid FROM screen_groups WHERE company_code = $1 AND menu_objid IS NOT NULL`, [targetCompanyCode], ); // 3. 그룹 이름 기반으로 menu_objid 매핑 생성 const menuObjidMap = new Map(); // 원본 menu_objid -> 새 menu_objid for (const sourceGroup of sourceGroupsResult.rows) { if (!sourceGroup.menu_objid) continue; const matchingTarget = targetGroupsResult.rows.find( (t) => t.group_name === sourceGroup.group_name, ); if (matchingTarget?.menu_objid) { menuObjidMap.set(sourceGroup.menu_objid, matchingTarget.menu_objid); logger.debug( `메뉴 매핑: ${sourceGroup.group_name} | ${sourceGroup.menu_objid} → ${matchingTarget.menu_objid}`, ); } } logger.info(`📋 메뉴 매핑 생성 완료: ${menuObjidMap.size}개`); // 4. 원본 screen_menu_assignments 조회 const assignmentsResult = await client.query<{ screen_id: number; menu_objid: string; display_order: number; is_active: string; }>( `SELECT screen_id, menu_objid::text, display_order, is_active FROM screen_menu_assignments WHERE company_code = $1`, [sourceCompanyCode], ); logger.info(`📌 원본 할당: ${assignmentsResult.rowCount}개`); // 5. 새 할당 생성 for (const assignment of assignmentsResult.rows) { const newScreenId = screenIdMap[assignment.screen_id]; const newMenuObjid = menuObjidMap.get(assignment.menu_objid); if (!newScreenId) { logger.warn(`⚠️ 화면 ID 매핑 없음: ${assignment.screen_id}`); result.skippedCount++; result.details.push(`화면 ${assignment.screen_id}: 매핑 없음`); continue; } if (!newMenuObjid) { logger.warn(`⚠️ 메뉴 objid 매핑 없음: ${assignment.menu_objid}`); result.skippedCount++; result.details.push(`메뉴 ${assignment.menu_objid}: 매핑 없음`); continue; } try { await client.query( `INSERT INTO screen_menu_assignments (screen_id, menu_objid, company_code, display_order, is_active, created_by) VALUES ($1, $2, $3, $4, $5, 'system') ON CONFLICT (screen_id, menu_objid, company_code) DO NOTHING`, [ newScreenId, newMenuObjid, targetCompanyCode, assignment.display_order, assignment.is_active, ], ); // 🔧 menu_info.menu_url도 새 화면 ID로 업데이트 const menuInfo = await client.query<{ menu_type: string; screen_code: string | null; }>( `SELECT mi.menu_type, sd.screen_code FROM menu_info mi LEFT JOIN screen_definitions sd ON sd.screen_id = $1 WHERE mi.objid = $2`, [newScreenId, newMenuObjid], ); if (menuInfo.rows.length > 0) { const isAdminMenu = menuInfo.rows[0].menu_type === "1"; const newMenuUrl = isAdminMenu ? `/screens/${newScreenId}?mode=admin` : `/screens/${newScreenId}`; const screenCode = menuInfo.rows[0].screen_code; await client.query( `UPDATE menu_info SET menu_url = $1, screen_code = $2 WHERE objid = $3`, [newMenuUrl, screenCode, newMenuObjid], ); logger.debug( `✅ menu_info.menu_url 업데이트: ${newMenuObjid} → ${newMenuUrl}`, ); } result.copiedCount++; logger.debug( `✅ 할당 복제: screen ${newScreenId} → menu ${newMenuObjid}`, ); } catch (error: any) { logger.error(`❌ 할당 복제 실패: ${error.message}`); result.skippedCount++; result.details.push(`할당 실패: ${error.message}`); } } logger.info( `✅ 화면-메뉴 할당 복제 완료: ${result.copiedCount}개 복제, ${result.skippedCount}개 스킵`, ); return result; }); } /** * 코드 카테고리 + 코드 복제 */ async copyCodeCategoryAndCodes( sourceCompanyCode: string, targetCompanyCode: string, menuObjidMap?: Map, ): Promise<{ copiedCategories: number; copiedCodes: number; details: string[]; }> { const result = { copiedCategories: 0, copiedCodes: 0, details: [] as string[], }; return transaction(async (client) => { logger.info( `📦 코드 카테고리/코드 복제: ${sourceCompanyCode} → ${targetCompanyCode}`, ); // 1. 기존 대상 회사 데이터 삭제 await client.query(`DELETE FROM code_info WHERE company_code = $1`, [ targetCompanyCode, ]); await client.query(`DELETE FROM code_category WHERE company_code = $1`, [ targetCompanyCode, ]); // 2. menuObjidMap 생성 (없는 경우) if (!menuObjidMap || menuObjidMap.size === 0) { menuObjidMap = new Map(); const groupPairs = await client.query<{ source_objid: string; target_objid: string; }>( `SELECT DISTINCT sg1.menu_objid::text as source_objid, sg2.menu_objid::text as target_objid FROM screen_groups sg1 JOIN screen_groups sg2 ON sg1.group_name = sg2.group_name WHERE sg1.company_code = $1 AND sg2.company_code = $2 AND sg1.menu_objid IS NOT NULL AND sg2.menu_objid IS NOT NULL`, [sourceCompanyCode, targetCompanyCode], ); groupPairs.rows.forEach((p) => menuObjidMap!.set(p.source_objid, p.target_objid), ); } // 3. 코드 카테고리 복제 const categories = await client.query( `SELECT * FROM code_category WHERE company_code = $1`, [sourceCompanyCode], ); for (const cat of categories.rows) { const newMenuObjid = cat.menu_objid ? menuObjidMap.get(cat.menu_objid.toString()) || cat.menu_objid : null; await client.query( `INSERT INTO code_category (category_code, category_name, category_name_eng, description, sort_order, is_active, company_code, menu_objid, created_by) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'system')`, [ cat.category_code, cat.category_name, cat.category_name_eng, cat.description, cat.sort_order, cat.is_active, targetCompanyCode, newMenuObjid, ], ); result.copiedCategories++; } // 4. 코드 정보 복제 const codes = await client.query( `SELECT * FROM code_info WHERE company_code = $1`, [sourceCompanyCode], ); for (const code of codes.rows) { const newMenuObjid = code.menu_objid ? menuObjidMap.get(code.menu_objid.toString()) || code.menu_objid : null; await client.query( `INSERT INTO code_info (code_category, code_value, code_name, code_name_eng, description, sort_order, is_active, company_code, menu_objid, parent_code_value, depth, created_by) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 'system')`, [ code.code_category, code.code_value, code.code_name, code.code_name_eng, code.description, code.sort_order, code.is_active, targetCompanyCode, newMenuObjid, code.parent_code_value, code.depth, ], ); result.copiedCodes++; } logger.info( `✅ 코드 카테고리/코드 복제 완료: 카테고리 ${result.copiedCategories}개, 코드 ${result.copiedCodes}개`, ); return result; }); } /** * 카테고리 값 복제 (category_values_test 테이블 사용) * - menu_objid 의존성 제거됨 * - table_name + column_name + company_code 기반 */ async copyCategoryMapping( sourceCompanyCode: string, targetCompanyCode: string, ): Promise<{ copiedMappings: number; copiedValues: number; details: string[]; }> { const result = { copiedMappings: 0, copiedValues: 0, details: [] as string[], }; return transaction(async (client) => { logger.info( `📦 카테고리 값 복제: ${sourceCompanyCode} → ${targetCompanyCode}`, ); // 1. 기존 대상 회사 데이터 삭제 await client.query( `DELETE FROM category_values_test WHERE company_code = $1`, [targetCompanyCode], ); // 2. category_values_test 복제 const values = await client.query( `SELECT * FROM category_values_test WHERE company_code = $1`, [sourceCompanyCode], ); // value_id 매핑 (parent_value_id 참조 업데이트용) const valueIdMap = new Map(); for (const v of values.rows) { const insertResult = await client.query( `INSERT INTO category_values_test (table_name, column_name, value_code, value_label, value_order, parent_value_id, depth, path, description, color, icon, is_active, is_default, company_code, created_by) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, 'system') RETURNING value_id`, [ v.table_name, v.column_name, v.value_code, v.value_label, v.value_order, null, // parent_value_id는 나중에 업데이트 v.depth, v.path, v.description, v.color, v.icon, v.is_active, v.is_default, targetCompanyCode, ], ); valueIdMap.set(v.value_id, insertResult.rows[0].value_id); result.copiedValues++; } // 3. parent_value_id 업데이트 (새 value_id로 매핑) for (const v of values.rows) { if (v.parent_value_id) { const newParentId = valueIdMap.get(v.parent_value_id); const newValueId = valueIdMap.get(v.value_id); if (newParentId && newValueId) { await client.query( `UPDATE category_values_test SET parent_value_id = $1 WHERE value_id = $2`, [newParentId, newValueId], ); } } } logger.info(`✅ 카테고리 값 복제 완료: ${result.copiedValues}개`); return result; }); } /** * 테이블 타입관리 입력타입 설정 복제 * - column_labels 통합 후 모든 컬럼 포함 */ async copyTableTypeColumns( sourceCompanyCode: string, targetCompanyCode: string, ): Promise<{ copiedCount: number; details: string[] }> { const result = { copiedCount: 0, details: [] as string[], }; return transaction(async (client) => { logger.info( `📦 테이블 타입 컬럼 복제: ${sourceCompanyCode} → ${targetCompanyCode}`, ); // 1. 기존 대상 회사 데이터 삭제 await client.query( `DELETE FROM table_type_columns WHERE company_code = $1`, [targetCompanyCode], ); // 2. 복제 (column_labels 통합 후 모든 컬럼 포함) const columns = await client.query( `SELECT * FROM table_type_columns WHERE company_code = $1`, [sourceCompanyCode], ); for (const col of columns.rows) { await client.query( `INSERT INTO table_type_columns (table_name, column_name, input_type, detail_settings, is_nullable, display_order, column_label, description, is_visible, code_category, code_value, reference_table, reference_column, display_column, company_code, created_date, updated_date) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, NOW(), NOW())`, [ col.table_name, col.column_name, col.input_type, col.detail_settings, col.is_nullable, col.display_order, col.column_label, col.description, col.is_visible, col.code_category, col.code_value, col.reference_table, col.reference_column, col.display_column, targetCompanyCode, ], ); result.copiedCount++; } logger.info(`✅ 테이블 타입 컬럼 복제 완료: ${result.copiedCount}개`); return result; }); } /** * 연쇄관계 설정 복제 */ async copyCascadingRelation( sourceCompanyCode: string, targetCompanyCode: string, ): Promise<{ copiedCount: number; details: string[] }> { const result = { copiedCount: 0, details: [] as string[], }; return transaction(async (client) => { logger.info( `📦 연쇄관계 설정 복제: ${sourceCompanyCode} → ${targetCompanyCode}`, ); // 1. 기존 대상 회사 데이터 삭제 await client.query( `DELETE FROM cascading_relation WHERE company_code = $1`, [targetCompanyCode], ); // 2. 복제 const relations = await client.query( `SELECT * FROM cascading_relation WHERE company_code = $1`, [sourceCompanyCode], ); for (const rel of relations.rows) { // 새로운 relation_code 생성 const newRelationCode = `${rel.relation_code}_${targetCompanyCode}`; await client.query( `INSERT INTO cascading_relation (relation_code, relation_name, description, parent_table, parent_value_column, parent_label_column, child_table, child_filter_column, child_value_column, child_label_column, child_order_column, child_order_direction, empty_parent_message, no_options_message, loading_message, clear_on_parent_change, company_code, is_active, created_by) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, 'system')`, [ newRelationCode, rel.relation_name, rel.description, rel.parent_table, rel.parent_value_column, rel.parent_label_column, rel.child_table, rel.child_filter_column, rel.child_value_column, rel.child_label_column, rel.child_order_column, rel.child_order_direction, rel.empty_parent_message, rel.no_options_message, rel.loading_message, rel.clear_on_parent_change, targetCompanyCode, rel.is_active, ], ); result.copiedCount++; } logger.info(`✅ 연쇄관계 설정 복제 완료: ${result.copiedCount}개`); return result; }); } // ======================================== // V2 레이아웃 관리 (1 레코드 방식) // ======================================== /** * V2 레이아웃 조회 (1 레코드 방식) * - screen_layouts_v2 테이블에서 화면당 1개 레코드 조회 * - layout_data JSON에 모든 컴포넌트 포함 */ async getLayoutV2( screenId: number, companyCode: string, userType?: string, ): Promise { console.log(`=== V2 레이아웃 로드 시작 ===`); console.log(`화면 ID: ${screenId}, 회사: ${companyCode}, 사용자 유형: ${userType}`); // SUPER_ADMIN 여부 확인 const isSuperAdmin = userType === "SUPER_ADMIN"; // 권한 확인 const screens = await query<{ company_code: string | null; table_name: string | null; }>( `SELECT company_code, table_name FROM screen_definitions WHERE screen_id = $1 LIMIT 1`, [screenId], ); if (screens.length === 0) { return null; } const existingScreen = screens[0]; // SUPER_ADMIN이 아니고 회사 코드가 다르면 권한 없음 if (!isSuperAdmin && companyCode !== "*" && existingScreen.company_code !== companyCode) { throw new Error("이 화면의 레이아웃을 조회할 권한이 없습니다."); } let layout: { layout_data: any } | null = null; // SUPER_ADMIN인 경우: 화면의 회사 코드로 레이아웃 조회 if (isSuperAdmin) { // 1. 화면 정의의 회사 코드로 레이아웃 조회 layout = await queryOne<{ layout_data: any }>( `SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = $1 AND company_code = $2`, [screenId, existingScreen.company_code], ); // 2. 화면 정의의 회사 코드로 없으면, 해당 화면의 모든 레이아웃 중 첫 번째 조회 if (!layout) { layout = await queryOne<{ layout_data: any }>( `SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = $1 ORDER BY updated_at DESC LIMIT 1`, [screenId], ); } } else { // 일반 사용자: 기존 로직 (회사별 우선, 없으면 공통(*) 조회) layout = await queryOne<{ layout_data: any }>( `SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = $1 AND company_code = $2`, [screenId, companyCode], ); // 회사별 레이아웃이 없으면 공통(*) 레이아웃 조회 if (!layout && companyCode !== "*") { layout = await queryOne<{ layout_data: any }>( `SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = $1 AND company_code = '*'`, [screenId], ); } } if (!layout) { console.log(`V2 레이아웃 없음: screen_id=${screenId}`); return null; } console.log( `V2 레이아웃 로드 완료: ${layout.layout_data?.components?.length || 0}개 컴포넌트`, ); return layout.layout_data; } /** * V2 레이아웃 저장 (1 레코드 방식) * - screen_layouts_v2 테이블에 화면당 1개 레코드 저장 * - layout_data JSON에 모든 컴포넌트 포함 */ async saveLayoutV2( screenId: number, layoutData: any, companyCode: string, ): Promise { console.log(`=== V2 레이아웃 저장 시작 ===`); console.log(`화면 ID: ${screenId}, 회사: ${companyCode}`); console.log(`컴포넌트 수: ${layoutData.components?.length || 0}`); // 권한 확인 const screens = await query<{ company_code: string | null }>( `SELECT company_code FROM screen_definitions WHERE screen_id = $1 LIMIT 1`, [screenId], ); if (screens.length === 0) { throw new Error("화면을 찾을 수 없습니다."); } const existingScreen = screens[0]; if (companyCode !== "*" && existingScreen.company_code !== companyCode) { throw new Error("이 화면의 레이아웃을 저장할 권한이 없습니다."); } // 버전 정보 추가 (updatedAt은 DB 컬럼 updated_at으로 관리) const dataToSave = { version: "2.0", ...layoutData }; // UPSERT (있으면 업데이트, 없으면 삽입) await query( `INSERT INTO screen_layouts_v2 (screen_id, company_code, layout_data, created_at, updated_at) VALUES ($1, $2, $3, NOW(), NOW()) ON CONFLICT (screen_id, company_code) DO UPDATE SET layout_data = $3, updated_at = NOW()`, [screenId, companyCode, JSON.stringify(dataToSave)], ); console.log(`V2 레이아웃 저장 완료`); } // ======================================== // POP 레이아웃 관리 (모바일/태블릿) // v2.0: 4모드 레이아웃 지원 (태블릿 가로/세로, 모바일 가로/세로) // ======================================== /** * POP v1 → v2 마이그레이션 (백엔드) * - 단일 sections 배열 → 4모드별 layouts + 공유 sections/components */ private migratePopV1ToV2(v1Data: any): any { console.log("POP v1 → v2 마이그레이션 시작"); // 기본 v2 구조 const v2Data: any = { version: "pop-2.0", layouts: { tablet_landscape: { sectionPositions: {}, componentPositions: {} }, tablet_portrait: { sectionPositions: {}, componentPositions: {} }, mobile_landscape: { sectionPositions: {}, componentPositions: {} }, mobile_portrait: { sectionPositions: {}, componentPositions: {} }, }, sections: {}, components: {}, dataFlow: { sectionConnections: [], }, settings: { touchTargetMin: 48, mode: "normal", canvasGrid: v1Data.canvasGrid || { columns: 24, rowHeight: 20, gap: 4 }, }, metadata: v1Data.metadata, }; // v1 섹션 배열 처리 const sections = v1Data.sections || []; const modeKeys = ["tablet_landscape", "tablet_portrait", "mobile_landscape", "mobile_portrait"]; for (const section of sections) { // 섹션 정의 생성 v2Data.sections[section.id] = { id: section.id, label: section.label, componentIds: (section.components || []).map((c: any) => c.id), innerGrid: section.innerGrid || { columns: 3, rows: 3, gap: 4 }, style: section.style, }; // 섹션 위치 복사 (4모드 모두 동일) const sectionPos = section.grid || { col: 1, row: 1, colSpan: 3, rowSpan: 4 }; for (const mode of modeKeys) { v2Data.layouts[mode].sectionPositions[section.id] = { ...sectionPos }; } // 컴포넌트별 처리 for (const comp of section.components || []) { // 컴포넌트 정의 생성 v2Data.components[comp.id] = { id: comp.id, type: comp.type, label: comp.label, dataBinding: comp.dataBinding, style: comp.style, config: comp.config, }; // 컴포넌트 위치 복사 (4모드 모두 동일) const compPos = comp.grid || { col: 1, row: 1, colSpan: 1, rowSpan: 1 }; for (const mode of modeKeys) { v2Data.layouts[mode].componentPositions[comp.id] = { ...compPos }; } } } const sectionCount = Object.keys(v2Data.sections).length; const componentCount = Object.keys(v2Data.components).length; console.log(`POP v1 → v2 마이그레이션 완료: ${sectionCount}개 섹션, ${componentCount}개 컴포넌트`); return v2Data; } /** * POP 레이아웃 조회 * - screen_layouts_pop 테이블에서 화면당 1개 레코드 조회 * - v1 데이터는 자동으로 v2로 마이그레이션하여 반환 */ async getLayoutPop( screenId: number, companyCode: string, userType?: string, ): Promise { console.log(`=== POP 레이아웃 로드 시작 ===`); console.log(`화면 ID: ${screenId}, 회사: ${companyCode}, 사용자 유형: ${userType}`); // SUPER_ADMIN 여부 확인 const isSuperAdmin = userType === "SUPER_ADMIN"; // 권한 확인 const screens = await query<{ company_code: string | null; table_name: string | null; }>( `SELECT company_code, table_name FROM screen_definitions WHERE screen_id = $1 LIMIT 1`, [screenId], ); if (screens.length === 0) { return null; } const existingScreen = screens[0]; // SUPER_ADMIN이 아니고 회사 코드가 다르면 권한 없음 if (!isSuperAdmin && companyCode !== "*" && existingScreen.company_code !== companyCode) { throw new Error("이 화면의 POP 레이아웃을 조회할 권한이 없습니다."); } let layout: { layout_data: any } | null = null; // SUPER_ADMIN인 경우: 화면의 회사 코드로 레이아웃 조회 if (isSuperAdmin) { // 1. 화면 정의의 회사 코드로 레이아웃 조회 layout = await queryOne<{ layout_data: any }>( `SELECT layout_data FROM screen_layouts_pop WHERE screen_id = $1 AND company_code = $2`, [screenId, existingScreen.company_code], ); // 2. 화면 정의의 회사 코드로 없으면, 해당 화면의 모든 레이아웃 중 첫 번째 조회 if (!layout) { layout = await queryOne<{ layout_data: any }>( `SELECT layout_data FROM screen_layouts_pop WHERE screen_id = $1 ORDER BY updated_at DESC LIMIT 1`, [screenId], ); } } else { // 일반 사용자: 회사별 우선, 없으면 공통(*) 조회 layout = await queryOne<{ layout_data: any }>( `SELECT layout_data FROM screen_layouts_pop WHERE screen_id = $1 AND company_code = $2`, [screenId, companyCode], ); // 회사별 레이아웃이 없으면 공통(*) 레이아웃 조회 if (!layout && companyCode !== "*") { layout = await queryOne<{ layout_data: any }>( `SELECT layout_data FROM screen_layouts_pop WHERE screen_id = $1 AND company_code = '*'`, [screenId], ); } } if (!layout) { console.log(`POP 레이아웃 없음: screen_id=${screenId}`); return null; } const layoutData = layout.layout_data; // v1 → v2 자동 마이그레이션 if (layoutData && layoutData.version === "pop-1.0") { console.log("POP v1 레이아웃 감지, v2로 마이그레이션"); return this.migratePopV1ToV2(layoutData); } // v2 또는 버전 태그 없는 경우 (버전 태그 없으면 sections 구조 확인) if (layoutData && !layoutData.version && layoutData.sections && Array.isArray(layoutData.sections)) { console.log("버전 태그 없는 v1 레이아웃 감지, v2로 마이그레이션"); return this.migratePopV1ToV2({ ...layoutData, version: "pop-1.0" }); } // v2 레이아웃 그대로 반환 const sectionCount = layoutData?.sections ? Object.keys(layoutData.sections).length : 0; const componentCount = layoutData?.components ? Object.keys(layoutData.components).length : 0; console.log(`POP v2 레이아웃 로드 완료: ${sectionCount}개 섹션, ${componentCount}개 컴포넌트`); return layoutData; } /** * POP 레이아웃 저장 * - screen_layouts_pop 테이블에 화면당 1개 레코드 저장 * - v2 형식으로 저장 (version: "pop-2.0") */ async saveLayoutPop( screenId: number, layoutData: any, companyCode: string, userId?: string, ): Promise { console.log(`=== POP 레이아웃 저장 시작 ===`); console.log(`화면 ID: ${screenId}, 회사: ${companyCode}`); // v2 구조 확인 const isV2 = layoutData.version === "pop-2.0" || (layoutData.layouts && layoutData.sections && layoutData.components); if (isV2) { const sectionCount = Object.keys(layoutData.sections || {}).length; const componentCount = Object.keys(layoutData.components || {}).length; console.log(`v2 레이아웃: ${sectionCount}개 섹션, ${componentCount}개 컴포넌트`); } else { console.log(`v1 레이아웃 (섹션 수: ${layoutData.sections?.length || 0})`); } // 권한 확인 const screens = await query<{ company_code: string | null }>( `SELECT company_code FROM screen_definitions WHERE screen_id = $1 LIMIT 1`, [screenId], ); if (screens.length === 0) { throw new Error("화면을 찾을 수 없습니다."); } const existingScreen = screens[0]; if (companyCode !== "*" && existingScreen.company_code !== companyCode) { throw new Error("이 화면의 POP 레이아웃을 저장할 권한이 없습니다."); } // 버전 정보 보장 (v2 우선, v1은 프론트엔드에서 마이그레이션 후 저장 권장) let dataToSave: any; if (isV2) { dataToSave = { ...layoutData, version: "pop-2.0", }; } else { // v1 형식으로 저장 (하위 호환) dataToSave = { version: "pop-1.0", ...layoutData, }; } // UPSERT (있으면 업데이트, 없으면 삽입) await query( `INSERT INTO screen_layouts_pop (screen_id, company_code, layout_data, created_at, updated_at, created_by, updated_by) VALUES ($1, $2, $3, NOW(), NOW(), $4, $4) ON CONFLICT (screen_id, company_code) DO UPDATE SET layout_data = $3, updated_at = NOW(), updated_by = $4`, [screenId, companyCode, JSON.stringify(dataToSave), userId || null], ); console.log(`POP 레이아웃 저장 완료 (version: ${dataToSave.version})`); } /** * POP 레이아웃이 존재하는 화면 ID 목록 조회 * - 옵션 B: POP 레이아웃 존재 여부로 화면 구분 */ async getScreenIdsWithPopLayout( companyCode: string, ): Promise { console.log(`=== POP 레이아웃 존재 화면 ID 조회 ===`); console.log(`회사 코드: ${companyCode}`); let result: { screen_id: number }[]; if (companyCode === "*") { // 최고 관리자: 모든 POP 레이아웃 조회 result = await query<{ screen_id: number }>( `SELECT DISTINCT screen_id FROM screen_layouts_pop`, [], ); } else { // 일반 회사: 해당 회사 또는 공통(*) 레이아웃 조회 result = await query<{ screen_id: number }>( `SELECT DISTINCT screen_id FROM screen_layouts_pop WHERE company_code = $1 OR company_code = '*'`, [companyCode], ); } const screenIds = result.map((r) => r.screen_id); console.log(`POP 레이아웃 존재 화면 수: ${screenIds.length}개`); return screenIds; } /** * POP 레이아웃 삭제 */ async deleteLayoutPop( screenId: number, companyCode: string, ): Promise { console.log(`=== POP 레이아웃 삭제 시작 ===`); console.log(`화면 ID: ${screenId}, 회사: ${companyCode}`); // 권한 확인 const screens = await query<{ company_code: string | null }>( `SELECT company_code FROM screen_definitions WHERE screen_id = $1 LIMIT 1`, [screenId], ); if (screens.length === 0) { throw new Error("화면을 찾을 수 없습니다."); } const existingScreen = screens[0]; if (companyCode !== "*" && existingScreen.company_code !== companyCode) { throw new Error("이 화면의 POP 레이아웃을 삭제할 권한이 없습니다."); } const result = await query( `DELETE FROM screen_layouts_pop WHERE screen_id = $1 AND company_code = $2`, [screenId, companyCode], ); console.log(`POP 레이아웃 삭제 완료`); return true; } } // 서비스 인스턴스 export export const screenManagementService = new ScreenManagementService();