diff --git a/backend-node/src/services/menuCopyService.ts b/backend-node/src/services/menuCopyService.ts index 4a53b0ff..aee32eeb 100644 --- a/backend-node/src/services/menuCopyService.ts +++ b/backend-node/src/services/menuCopyService.ts @@ -1535,20 +1535,21 @@ export class MenuCopyService { // === 기존 복사본이 있는 경우: 업데이트 === const existingScreenId = existingCopy.screen_id; - // 원본 V2 레이아웃 조회 + // 원본 V2 레이아웃 조회 (모든 레이어) const sourceLayoutV2Result = await client.query<{ layout_data: any }>( - `SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = $1`, + `SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = $1 ORDER BY layer_id`, [originalScreenId] ); - // 대상 V2 레이아웃 조회 + // 대상 V2 레이아웃 조회 (모든 레이어) const targetLayoutV2Result = await client.query<{ layout_data: any }>( - `SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = $1`, + `SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = $1 ORDER BY layer_id`, [existingScreenId] ); - // 변경 여부 확인 (V2 레이아웃 비교) - const hasChanges = this.hasLayoutChangesV2( + // 변경 여부 확인: 레이어 수가 다르면 무조건 변경됨 + const layerCountDiffers = sourceLayoutV2Result.rows.length !== targetLayoutV2Result.rows.length; + const hasChanges = layerCountDiffers || this.hasLayoutChangesV2( sourceLayoutV2Result.rows[0]?.layout_data, targetLayoutV2Result.rows[0]?.layout_data ); @@ -1652,7 +1653,7 @@ export class MenuCopyService { } } - // === 2단계: screen_layouts_v2 처리 (이제 screenIdMap이 완성됨) === + // === 2단계: screen_conditional_zones + screen_layouts_v2 처리 (멀티 레이어 지원) === logger.info( `\n📐 V2 레이아웃 처리 시작 (screenIdMap 완성: ${screenIdMap.size}개)` ); @@ -1664,23 +1665,90 @@ export class MenuCopyService { isUpdate, } of screenDefsToProcess) { try { - // 원본 V2 레이아웃 조회 - const layoutV2Result = await client.query<{ layout_data: any }>( - `SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = $1`, + const sourceCompanyCode = screenDef.company_code; + + // 원본 V2 레이아웃 전체 조회 (모든 레이어) + const layoutV2Result = await client.query<{ + layout_data: any; + layer_id: number; + layer_name: string; + condition_config: any; + }>( + `SELECT layout_data, layer_id, layer_name, condition_config + FROM screen_layouts_v2 + WHERE screen_id = $1 AND company_code = $2 + ORDER BY layer_id`, + [originalScreenId, sourceCompanyCode] + ); + + if (layoutV2Result.rows.length === 0) { + logger.info(` ↳ V2 레이아웃 없음 (스킵): screen_id=${originalScreenId}`); + continue; + } + + // 모든 레이어에서 컴포넌트를 수집하여 componentIdMap 생성 + const componentIdMap = new Map(); + const timestamp = Date.now(); + let compIdx = 0; + for (const layer of layoutV2Result.rows) { + const components = layer.layout_data?.components || []; + for (const comp of components) { + if (!componentIdMap.has(comp.id)) { + const newId = `comp_${timestamp}_${compIdx++}_${Math.random().toString(36).substr(2, 5)}`; + componentIdMap.set(comp.id, newId); + } + } + } + + // screen_conditional_zones 복제 + zoneIdMap 생성 + const zoneIdMap = new Map(); + const zonesResult = await client.query( + `SELECT * FROM screen_conditional_zones WHERE screen_id = $1`, [originalScreenId] ); - const layoutData = layoutV2Result.rows[0]?.layout_data; - const components = layoutData?.components || []; + if (isUpdate) { + await client.query( + `DELETE FROM screen_conditional_zones WHERE screen_id = $1 AND company_code = $2`, + [targetScreenId, targetCompanyCode] + ); + } - if (layoutData && components.length > 0) { - // component_id 매핑 생성 (원본 → 새 ID) - const componentIdMap = new Map(); - const timestamp = Date.now(); - components.forEach((comp: any, idx: number) => { - const newComponentId = `comp_${timestamp}_${idx}_${Math.random().toString(36).substr(2, 5)}`; - componentIdMap.set(comp.id, newComponentId); - }); + for (const zone of zonesResult.rows) { + const newTriggerCompId = componentIdMap.get(zone.trigger_component_id) || zone.trigger_component_id; + const newZone = await client.query<{ zone_id: number }>( + `INSERT INTO screen_conditional_zones + (screen_id, company_code, zone_name, x, y, width, height, + trigger_component_id, trigger_operator) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING zone_id`, + [targetScreenId, targetCompanyCode, zone.zone_name, + zone.x, zone.y, zone.width, zone.height, + newTriggerCompId, zone.trigger_operator] + ); + zoneIdMap.set(zone.zone_id, newZone.rows[0].zone_id); + } + + if (zonesResult.rows.length > 0) { + logger.info(` ↳ 조건부 영역 복사: ${zonesResult.rows.length}개 (zoneIdMap: ${zoneIdMap.size}개)`); + } + + // 업데이트인 경우 기존 레이아웃 삭제 (레이어 수 변경 대응) + if (isUpdate) { + await client.query( + `DELETE FROM screen_layouts_v2 WHERE screen_id = $1 AND company_code = $2`, + [targetScreenId, targetCompanyCode] + ); + } + + // 각 레이어별 처리 + let totalComponents = 0; + for (const layer of layoutV2Result.rows) { + const layoutData = layer.layout_data; + const components = layoutData?.components || []; + + if (!layoutData || components.length === 0) continue; + totalComponents += components.length; // V2 레이아웃 데이터 복사 및 참조 업데이트 const updatedLayoutData = this.updateReferencesInLayoutDataV2( @@ -1692,20 +1760,34 @@ export class MenuCopyService { menuIdMap ); - // V2 레이아웃 저장 (UPSERT) - await client.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()`, - [targetScreenId, targetCompanyCode, JSON.stringify(updatedLayoutData)] - ); + // condition_config의 zone_id 재매핑 + let updatedConditionConfig = layer.condition_config ? { ...layer.condition_config } : null; + if (updatedConditionConfig?.zone_id) { + const newZoneId = zoneIdMap.get(updatedConditionConfig.zone_id); + if (newZoneId) { + updatedConditionConfig.zone_id = newZoneId; + } + } - const action = isUpdate ? "업데이트" : "복사"; - logger.info(` ↳ V2 레이아웃 ${action}: ${components.length}개 컴포넌트`); - } else { - logger.info(` ↳ V2 레이아웃 없음 (스킵): screen_id=${originalScreenId}`); + // V2 레이아웃 저장 (레이어별 INSERT) + await client.query( + `INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, layout_data, condition_config, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW()) + ON CONFLICT (screen_id, company_code, layer_id) + DO UPDATE SET layout_data = $5, layer_name = $4, condition_config = $6, updated_at = NOW()`, + [ + targetScreenId, + targetCompanyCode, + layer.layer_id, + layer.layer_name, + JSON.stringify(updatedLayoutData), + updatedConditionConfig ? JSON.stringify(updatedConditionConfig) : null, + ] + ); } + + const action = isUpdate ? "업데이트" : "복사"; + logger.info(` ↳ V2 레이아웃 ${action}: ${layoutV2Result.rows.length}개 레이어, ${totalComponents}개 컴포넌트`); } catch (error: any) { logger.error( `❌ V2 레이아웃 처리 실패: screen_id=${originalScreenId}`, diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index e6ee6b0f..a75fc431 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -4210,39 +4210,65 @@ export class ScreenManagementService { const newScreen = newScreenResult.rows[0]; - // 4. 원본 화면의 V2 레이아웃 조회 - let sourceLayoutV2Result = await client.query<{ layout_data: any }>( - `SELECT layout_data FROM screen_layouts_v2 - WHERE screen_id = $1 AND company_code = $2`, + // 4. 원본 화면의 V2 레이아웃 전체 조회 (모든 레이어) + let sourceLayoutV2Result = await client.query<{ + layout_data: any; + layer_id: number; + layer_name: string; + condition_config: any; + }>( + `SELECT layout_data, layer_id, layer_name, condition_config + FROM screen_layouts_v2 + WHERE screen_id = $1 AND company_code = $2 + ORDER BY layer_id`, [sourceScreenId, sourceScreen.company_code], ); // 없으면 공통(*) 레이아웃 조회 - let layoutData = sourceLayoutV2Result.rows[0]?.layout_data; - if (!layoutData && sourceScreen.company_code !== "*") { - const fallbackResult = await client.query<{ layout_data: any }>( - `SELECT layout_data FROM screen_layouts_v2 - WHERE screen_id = $1 AND company_code = '*'`, + if (sourceLayoutV2Result.rows.length === 0 && sourceScreen.company_code !== "*") { + sourceLayoutV2Result = await client.query<{ + layout_data: any; + layer_id: number; + layer_name: string; + condition_config: any; + }>( + `SELECT layout_data, layer_id, layer_name, condition_config + FROM screen_layouts_v2 + WHERE screen_id = $1 AND company_code = '*' + ORDER BY layer_id`, [sourceScreenId], ); - layoutData = fallbackResult.rows[0]?.layout_data; } - const components = layoutData?.components || []; + // 모든 레이어에서 컴포넌트를 수집하여 componentIdMap 생성 + const componentIdMap = new Map(); + for (const layer of sourceLayoutV2Result.rows) { + const components = layer.layout_data?.components || []; + for (const comp of components) { + if (!componentIdMap.has(comp.id)) { + componentIdMap.set(comp.id, generateId()); + } + } + } + + const hasComponents = componentIdMap.size > 0; + // 첫 번째 레이어의 layoutData (flowId/ruleId 수집용 - 모든 레이어에서 수집) + const allLayoutDatas = sourceLayoutV2Result.rows.map((r: any) => r.layout_data).filter(Boolean); // 5. 노드 플로우 복사 (회사가 다른 경우) let flowIdMap = new Map(); if ( - components.length > 0 && + hasComponents && sourceScreen.company_code !== targetCompanyCode ) { - // V2 레이아웃에서 flowId 수집 - const flowIds = this.collectFlowIdsFromLayoutData(layoutData); + const flowIds = new Set(); + for (const ld of allLayoutDatas) { + const ids = this.collectFlowIdsFromLayoutData(ld); + ids.forEach((id: number) => flowIds.add(id)); + } if (flowIds.size > 0) { console.log(`🔍 화면 복사 - flowId 수집: ${flowIds.size}개`); - - // 노드 플로우 복사 및 매핑 생성 flowIdMap = await this.copyNodeFlowsForScreen( flowIds, sourceScreen.company_code, @@ -4255,16 +4281,17 @@ export class ScreenManagementService { // 5.1. 채번 규칙 복사 (회사가 다른 경우) let ruleIdMap = new Map(); if ( - components.length > 0 && + hasComponents && sourceScreen.company_code !== targetCompanyCode ) { - // V2 레이아웃에서 채번 규칙 ID 수집 - const ruleIds = this.collectNumberingRuleIdsFromLayoutData(layoutData); + const ruleIds = new Set(); + for (const ld of allLayoutDatas) { + const ids = this.collectNumberingRuleIdsFromLayoutData(ld); + ids.forEach((id: string) => ruleIds.add(id)); + } if (ruleIds.size > 0) { console.log(`🔍 화면 복사 - 채번 규칙 ID 수집: ${ruleIds.size}개`); - - // 채번 규칙 복사 및 매핑 생성 ruleIdMap = await this.copyNumberingRulesForScreen( ruleIds, sourceScreen.company_code, @@ -4274,39 +4301,89 @@ export class ScreenManagementService { } } - // 6. V2 레이아웃이 있다면 복사 - if (layoutData && components.length > 0) { + // 5.2. screen_conditional_zones 복제 + zoneIdMap 생성 + const zoneIdMap = new Map(); + if (hasComponents) { try { - // componentId 매핑 생성 - const componentIdMap = new Map(); - for (const comp of components) { - componentIdMap.set(comp.id, generateId()); + const zonesResult = await client.query( + `SELECT * FROM screen_conditional_zones WHERE screen_id = $1`, + [sourceScreenId] + ); + + for (const zone of zonesResult.rows) { + const newTriggerCompId = componentIdMap.get(zone.trigger_component_id) || zone.trigger_component_id; + const newZone = await client.query<{ zone_id: number }>( + `INSERT INTO screen_conditional_zones + (screen_id, company_code, zone_name, x, y, width, height, + trigger_component_id, trigger_operator) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING zone_id`, + [newScreen.screen_id, targetCompanyCode, zone.zone_name, + zone.x, zone.y, zone.width, zone.height, + newTriggerCompId, zone.trigger_operator] + ); + zoneIdMap.set(zone.zone_id, newZone.rows[0].zone_id); } - // V2 레이아웃 데이터 복사 및 참조 업데이트 - const updatedLayoutData = this.updateReferencesInLayoutData( - layoutData, - { - componentIdMap, - flowIdMap: flowIdMap.size > 0 ? flowIdMap : undefined, - ruleIdMap: ruleIdMap.size > 0 ? ruleIdMap : undefined, - // screenIdMap은 모든 화면 복제 완료 후 updateTabScreenReferences에서 일괄 처리 - }, - ); + if (zonesResult.rows.length > 0) { + console.log(` ↳ 조건부 영역 복사: ${zonesResult.rows.length}개`); + } + } catch (error) { + console.error("조건부 영역 복사 중 오류:", error); + } + } - // V2 레이아웃 저장 (UPSERT) - layer_id 포함 - await client.query( - `INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layout_data, created_at, updated_at) - VALUES ($1, $2, 1, $3, NOW(), NOW()) - ON CONFLICT (screen_id, company_code, layer_id) - DO UPDATE SET layout_data = $3, updated_at = NOW()`, - [newScreen.screen_id, targetCompanyCode, JSON.stringify(updatedLayoutData)], - ); + // 6. V2 레이아웃 복사 (모든 레이어 순회) + if (sourceLayoutV2Result.rows.length > 0 && hasComponents) { + try { + let totalComponents = 0; - + for (const layer of sourceLayoutV2Result.rows) { + const layoutData = layer.layout_data; + const components = layoutData?.components || []; + + if (!layoutData || components.length === 0) continue; + totalComponents += components.length; + + // V2 레이아웃 데이터 복사 및 참조 업데이트 + const updatedLayoutData = this.updateReferencesInLayoutData( + layoutData, + { + componentIdMap, + flowIdMap: flowIdMap.size > 0 ? flowIdMap : undefined, + ruleIdMap: ruleIdMap.size > 0 ? ruleIdMap : undefined, + }, + ); + + // condition_config의 zone_id 재매핑 + let updatedConditionConfig = layer.condition_config ? { ...layer.condition_config } : null; + if (updatedConditionConfig?.zone_id) { + const newZoneId = zoneIdMap.get(updatedConditionConfig.zone_id); + if (newZoneId) { + updatedConditionConfig.zone_id = newZoneId; + } + } + + // V2 레이아웃 저장 (레이어별 INSERT) + await client.query( + `INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, layout_data, condition_config, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW()) + ON CONFLICT (screen_id, company_code, layer_id) + DO UPDATE SET layout_data = $5, layer_name = $4, condition_config = $6, updated_at = NOW()`, + [ + newScreen.screen_id, + targetCompanyCode, + layer.layer_id, + layer.layer_name, + JSON.stringify(updatedLayoutData), + updatedConditionConfig ? JSON.stringify(updatedConditionConfig) : null, + ], + ); + } + + console.log(` ↳ V2 레이아웃 복사: ${sourceLayoutV2Result.rows.length}개 레이어, ${totalComponents}개 컴포넌트`); } catch (error) { console.error("V2 레이아웃 복사 중 오류:", error); - // 레이아웃 복사 실패해도 화면 생성은 유지 } }