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;
// 원본 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<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]
);
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 || [];
if (layoutData && components.length > 0) {
// component_id 매핑 생성 (원본 → 새 ID)
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);
});
if (!layoutData || components.length === 0) continue;
totalComponents += components.length;
// V2 레이아웃 데이터 복사 및 참조 업데이트
const updatedLayoutData = this.updateReferencesInLayoutDataV2(
@ -1692,20 +1760,34 @@ export class MenuCopyService {
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(
`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)]
`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}: ${components.length}개 컴포넌트`);
} else {
logger.info(` ↳ V2 레이아웃 없음 (스킵): screen_id=${originalScreenId}`);
}
logger.info(` ↳ V2 레이아웃 ${action}: ${layoutV2Result.rows.length}개 레이어, ${totalComponents}개 컴포넌트`);
} catch (error: any) {
logger.error(
`❌ V2 레이아웃 처리 실패: screen_id=${originalScreenId}`,

View File

@ -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<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. 노드 플로우 복사 (회사가 다른 경우)
let flowIdMap = new Map<number, number>();
if (
components.length > 0 &&
hasComponents &&
sourceScreen.company_code !== targetCompanyCode
) {
// V2 레이아웃에서 flowId 수집
const flowIds = this.collectFlowIdsFromLayoutData(layoutData);
const flowIds = new Set<number>();
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<string, string>();
if (
components.length > 0 &&
hasComponents &&
sourceScreen.company_code !== targetCompanyCode
) {
// V2 레이아웃에서 채번 규칙 ID 수집
const ruleIds = this.collectNumberingRuleIdsFromLayoutData(layoutData);
const ruleIds = new Set<string>();
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,15 +4301,50 @@ export class ScreenManagementService {
}
}
// 6. V2 레이아웃이 있다면 복사
if (layoutData && components.length > 0) {
// 5.2. screen_conditional_zones 복제 + zoneIdMap 생성
const zoneIdMap = new Map<number, number>();
if (hasComponents) {
try {
// componentId 매핑 생성
const componentIdMap = new Map<string, string>();
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);
}
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 레이아웃 데이터 복사 및 참조 업데이트
const updatedLayoutData = this.updateReferencesInLayoutData(
layoutData,
@ -4290,23 +4352,38 @@ export class ScreenManagementService {
componentIdMap,
flowIdMap: flowIdMap.size > 0 ? flowIdMap : 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(
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layout_data, created_at, updated_at)
VALUES ($1, $2, 1, $3, NOW(), NOW())
`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 = $3, updated_at = NOW()`,
[newScreen.screen_id, targetCompanyCode, JSON.stringify(updatedLayoutData)],
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);
// 레이아웃 복사 실패해도 화면 생성은 유지
}
}