fix: 멀티 레이어 화면 복제 버그 수정

- menuCopyService: 모든 레이어 순회 처리, ON CONFLICT 제약조건 수정
- screenManagementService: layer_id 하드코딩 제거, 멀티 레이어 순회
- screen_conditional_zones 복제 로직 추가 (zoneIdMap 생성)
- condition_config의 zone_id 재매핑 로직 추가
- 레이어 수 변경 감지를 위한 변경 비교 로직 보강

Made-with: Cursor
This commit is contained in:
DDD1542 2026-03-05 01:29:56 +09:00
parent e11a7b1237
commit 8d0f2dbb27
2 changed files with 238 additions and 79 deletions

View File

@ -1535,20 +1535,21 @@ export class MenuCopyService {
// === 기존 복사본이 있는 경우: 업데이트 === // === 기존 복사본이 있는 경우: 업데이트 ===
const existingScreenId = existingCopy.screen_id; const existingScreenId = existingCopy.screen_id;
// 원본 V2 레이아웃 조회 // 원본 V2 레이아웃 조회 (모든 레이어)
const sourceLayoutV2Result = await client.query<{ layout_data: any }>( 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] [originalScreenId]
); );
// 대상 V2 레이아웃 조회 // 대상 V2 레이아웃 조회 (모든 레이어)
const targetLayoutV2Result = await client.query<{ layout_data: any }>( 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] [existingScreenId]
); );
// 변경 여부 확인 (V2 레이아웃 비교) // 변경 여부 확인: 레이어 수가 다르면 무조건 변경됨
const hasChanges = this.hasLayoutChangesV2( const layerCountDiffers = sourceLayoutV2Result.rows.length !== targetLayoutV2Result.rows.length;
const hasChanges = layerCountDiffers || this.hasLayoutChangesV2(
sourceLayoutV2Result.rows[0]?.layout_data, sourceLayoutV2Result.rows[0]?.layout_data,
targetLayoutV2Result.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( logger.info(
`\n📐 V2 레이아웃 처리 시작 (screenIdMap 완성: ${screenIdMap.size}개)` `\n📐 V2 레이아웃 처리 시작 (screenIdMap 완성: ${screenIdMap.size}개)`
); );
@ -1664,23 +1665,90 @@ export class MenuCopyService {
isUpdate, isUpdate,
} of screenDefsToProcess) { } of screenDefsToProcess) {
try { try {
// 원본 V2 레이아웃 조회 const sourceCompanyCode = screenDef.company_code;
const layoutV2Result = await client.query<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = $1`, // 원본 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<string, string>();
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<number, number>();
const zonesResult = await client.query(
`SELECT * FROM screen_conditional_zones WHERE screen_id = $1`,
[originalScreenId] [originalScreenId]
); );
const layoutData = layoutV2Result.rows[0]?.layout_data; if (isUpdate) {
await client.query(
`DELETE FROM screen_conditional_zones WHERE screen_id = $1 AND company_code = $2`,
[targetScreenId, targetCompanyCode]
);
}
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 || []; const components = layoutData?.components || [];
if (layoutData && components.length > 0) { if (!layoutData || components.length === 0) continue;
// component_id 매핑 생성 (원본 → 새 ID) totalComponents += components.length;
const componentIdMap = new Map<string, string>();
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);
});
// V2 레이아웃 데이터 복사 및 참조 업데이트 // V2 레이아웃 데이터 복사 및 참조 업데이트
const updatedLayoutData = this.updateReferencesInLayoutDataV2( const updatedLayoutData = this.updateReferencesInLayoutDataV2(
@ -1692,20 +1760,34 @@ export class MenuCopyService {
menuIdMap menuIdMap
); );
// V2 레이아웃 저장 (UPSERT) // 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( await client.query(
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layout_data, created_at, updated_at) `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, NOW(), NOW()) VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
ON CONFLICT (screen_id, company_code) ON CONFLICT (screen_id, company_code, layer_id)
DO UPDATE SET layout_data = $3, updated_at = NOW()`, DO UPDATE SET layout_data = $5, layer_name = $4, condition_config = $6, updated_at = NOW()`,
[targetScreenId, targetCompanyCode, JSON.stringify(updatedLayoutData)] [
targetScreenId,
targetCompanyCode,
layer.layer_id,
layer.layer_name,
JSON.stringify(updatedLayoutData),
updatedConditionConfig ? JSON.stringify(updatedConditionConfig) : null,
]
); );
}
const action = isUpdate ? "업데이트" : "복사"; const action = isUpdate ? "업데이트" : "복사";
logger.info(` ↳ V2 레이아웃 ${action}: ${components.length}개 컴포넌트`); logger.info(` ↳ V2 레이아웃 ${action}: ${layoutV2Result.rows.length}개 레이어, ${totalComponents}개 컴포넌트`);
} else {
logger.info(` ↳ V2 레이아웃 없음 (스킵): screen_id=${originalScreenId}`);
}
} catch (error: any) { } catch (error: any) {
logger.error( logger.error(
`❌ V2 레이아웃 처리 실패: screen_id=${originalScreenId}`, `❌ V2 레이아웃 처리 실패: screen_id=${originalScreenId}`,

View File

@ -4210,39 +4210,65 @@ export class ScreenManagementService {
const newScreen = newScreenResult.rows[0]; const newScreen = newScreenResult.rows[0];
// 4. 원본 화면의 V2 레이아웃 조회 // 4. 원본 화면의 V2 레이아웃 전체 조회 (모든 레이어)
let sourceLayoutV2Result = await client.query<{ layout_data: any }>( let sourceLayoutV2Result = await client.query<{
`SELECT layout_data FROM screen_layouts_v2 layout_data: any;
WHERE screen_id = $1 AND company_code = $2`, 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], [sourceScreenId, sourceScreen.company_code],
); );
// 없으면 공통(*) 레이아웃 조회 // 없으면 공통(*) 레이아웃 조회
let layoutData = sourceLayoutV2Result.rows[0]?.layout_data; if (sourceLayoutV2Result.rows.length === 0 && sourceScreen.company_code !== "*") {
if (!layoutData && sourceScreen.company_code !== "*") { sourceLayoutV2Result = await client.query<{
const fallbackResult = await client.query<{ layout_data: any }>( layout_data: any;
`SELECT layout_data FROM screen_layouts_v2 layer_id: number;
WHERE screen_id = $1 AND company_code = '*'`, 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], [sourceScreenId],
); );
layoutData = fallbackResult.rows[0]?.layout_data;
} }
const components = layoutData?.components || []; // 모든 레이어에서 컴포넌트를 수집하여 componentIdMap 생성
const componentIdMap = new Map<string, string>();
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. 노드 플로우 복사 (회사가 다른 경우) // 5. 노드 플로우 복사 (회사가 다른 경우)
let flowIdMap = new Map<number, number>(); let flowIdMap = new Map<number, number>();
if ( if (
components.length > 0 && hasComponents &&
sourceScreen.company_code !== targetCompanyCode sourceScreen.company_code !== targetCompanyCode
) { ) {
// V2 레이아웃에서 flowId 수집 const flowIds = new Set<number>();
const flowIds = this.collectFlowIdsFromLayoutData(layoutData); for (const ld of allLayoutDatas) {
const ids = this.collectFlowIdsFromLayoutData(ld);
ids.forEach((id: number) => flowIds.add(id));
}
if (flowIds.size > 0) { if (flowIds.size > 0) {
console.log(`🔍 화면 복사 - flowId 수집: ${flowIds.size}`); console.log(`🔍 화면 복사 - flowId 수집: ${flowIds.size}`);
// 노드 플로우 복사 및 매핑 생성
flowIdMap = await this.copyNodeFlowsForScreen( flowIdMap = await this.copyNodeFlowsForScreen(
flowIds, flowIds,
sourceScreen.company_code, sourceScreen.company_code,
@ -4255,16 +4281,17 @@ export class ScreenManagementService {
// 5.1. 채번 규칙 복사 (회사가 다른 경우) // 5.1. 채번 규칙 복사 (회사가 다른 경우)
let ruleIdMap = new Map<string, string>(); let ruleIdMap = new Map<string, string>();
if ( if (
components.length > 0 && hasComponents &&
sourceScreen.company_code !== targetCompanyCode sourceScreen.company_code !== targetCompanyCode
) { ) {
// V2 레이아웃에서 채번 규칙 ID 수집 const ruleIds = new Set<string>();
const ruleIds = this.collectNumberingRuleIdsFromLayoutData(layoutData); for (const ld of allLayoutDatas) {
const ids = this.collectNumberingRuleIdsFromLayoutData(ld);
ids.forEach((id: string) => ruleIds.add(id));
}
if (ruleIds.size > 0) { if (ruleIds.size > 0) {
console.log(`🔍 화면 복사 - 채번 규칙 ID 수집: ${ruleIds.size}`); console.log(`🔍 화면 복사 - 채번 규칙 ID 수집: ${ruleIds.size}`);
// 채번 규칙 복사 및 매핑 생성
ruleIdMap = await this.copyNumberingRulesForScreen( ruleIdMap = await this.copyNumberingRulesForScreen(
ruleIds, ruleIds,
sourceScreen.company_code, sourceScreen.company_code,
@ -4274,15 +4301,50 @@ export class ScreenManagementService {
} }
} }
// 6. V2 레이아웃이 있다면 복사 // 5.2. screen_conditional_zones 복제 + zoneIdMap 생성
if (layoutData && components.length > 0) { const zoneIdMap = new Map<number, number>();
if (hasComponents) {
try { try {
// componentId 매핑 생성 const zonesResult = await client.query(
const componentIdMap = new Map<string, string>(); `SELECT * FROM screen_conditional_zones WHERE screen_id = $1`,
for (const comp of components) { [sourceScreenId]
componentIdMap.set(comp.id, generateId()); );
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);
} }
if (zonesResult.rows.length > 0) {
console.log(` ↳ 조건부 영역 복사: ${zonesResult.rows.length}`);
}
} catch (error) {
console.error("조건부 영역 복사 중 오류:", error);
}
}
// 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 레이아웃 데이터 복사 및 참조 업데이트 // V2 레이아웃 데이터 복사 및 참조 업데이트
const updatedLayoutData = this.updateReferencesInLayoutData( const updatedLayoutData = this.updateReferencesInLayoutData(
layoutData, layoutData,
@ -4290,23 +4352,38 @@ export class ScreenManagementService {
componentIdMap, componentIdMap,
flowIdMap: flowIdMap.size > 0 ? flowIdMap : undefined, flowIdMap: flowIdMap.size > 0 ? flowIdMap : undefined,
ruleIdMap: ruleIdMap.size > 0 ? ruleIdMap : undefined, ruleIdMap: ruleIdMap.size > 0 ? ruleIdMap : undefined,
// screenIdMap은 모든 화면 복제 완료 후 updateTabScreenReferences에서 일괄 처리
}, },
); );
// V2 레이아웃 저장 (UPSERT) - layer_id 포함 // 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( await client.query(
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layout_data, created_at, updated_at) `INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, layout_data, condition_config, created_at, updated_at)
VALUES ($1, $2, 1, $3, NOW(), NOW()) VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
ON CONFLICT (screen_id, company_code, layer_id) ON CONFLICT (screen_id, company_code, layer_id)
DO UPDATE SET layout_data = $3, updated_at = NOW()`, DO UPDATE SET layout_data = $5, layer_name = $4, condition_config = $6, updated_at = NOW()`,
[newScreen.screen_id, targetCompanyCode, JSON.stringify(updatedLayoutData)], [
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) { } catch (error) {
console.error("V2 레이아웃 복사 중 오류:", error); console.error("V2 레이아웃 복사 중 오류:", error);
// 레이아웃 복사 실패해도 화면 생성은 유지
} }
} }