merge: origin/main을 ksh-v2-work-merge-test에 병합
origin/main의 feature/v2-unified-renewal(PR #386) 포함 86개 커밋을 병합. ScreenDesigner.tsx에서 3건의 충돌을 수동 해결: 1. 함수 시그니처: isPop/defaultDevicePreview props 유지 (POP 모드 지원) 2. 저장 로직: POP/V2/Legacy 3단계 분기 유지, 디버그 로그 제거 3. 툴바 props: origin/main의 정렬/분배/크기맞춤/라벨토글/단축키 기능 채택 검증 완료: 빌드 성공, 타입 에러 없음, 시맨틱 충돌 없음 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -43,6 +43,7 @@ export interface CreateCategoryValueInput {
|
||||
icon?: string;
|
||||
isActive?: boolean;
|
||||
isDefault?: boolean;
|
||||
targetCompanyCode?: string; // 최고 관리자가 특정 회사를 선택할 때 사용
|
||||
}
|
||||
|
||||
// 카테고리 값 수정 입력
|
||||
@@ -89,7 +90,7 @@ class CategoryTreeService {
|
||||
updated_at AS "updatedAt",
|
||||
created_by AS "createdBy",
|
||||
updated_by AS "updatedBy"
|
||||
FROM category_values_test
|
||||
FROM category_values
|
||||
WHERE (company_code = $1 OR company_code = '*')
|
||||
AND table_name = $2
|
||||
AND column_name = $3
|
||||
@@ -142,7 +143,7 @@ class CategoryTreeService {
|
||||
company_code AS "companyCode",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt"
|
||||
FROM category_values_test
|
||||
FROM category_values
|
||||
WHERE (company_code = $1 OR company_code = '*')
|
||||
AND table_name = $2
|
||||
AND column_name = $3
|
||||
@@ -184,7 +185,7 @@ class CategoryTreeService {
|
||||
company_code AS "companyCode",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt"
|
||||
FROM category_values_test
|
||||
FROM category_values
|
||||
WHERE (company_code = $1 OR company_code = '*') AND value_id = $2
|
||||
`;
|
||||
|
||||
@@ -221,7 +222,7 @@ class CategoryTreeService {
|
||||
}
|
||||
|
||||
const query = `
|
||||
INSERT INTO category_values_test (
|
||||
INSERT INTO category_values (
|
||||
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, updated_by
|
||||
@@ -334,7 +335,7 @@ class CategoryTreeService {
|
||||
}
|
||||
|
||||
const query = `
|
||||
UPDATE category_values_test
|
||||
UPDATE category_values
|
||||
SET
|
||||
value_code = COALESCE($3, value_code),
|
||||
value_label = COALESCE($4, value_label),
|
||||
@@ -415,11 +416,11 @@ class CategoryTreeService {
|
||||
// 재귀 CTE를 사용하여 모든 하위 카테고리 수집
|
||||
const query = `
|
||||
WITH RECURSIVE category_tree AS (
|
||||
SELECT value_id FROM category_values_test
|
||||
SELECT value_id FROM category_values
|
||||
WHERE parent_value_id = $1 AND (company_code = $2 OR company_code = '*')
|
||||
UNION ALL
|
||||
SELECT cv.value_id
|
||||
FROM category_values_test cv
|
||||
FROM category_values cv
|
||||
INNER JOIN category_tree ct ON cv.parent_value_id = ct.value_id
|
||||
WHERE cv.company_code = $2 OR cv.company_code = '*'
|
||||
)
|
||||
@@ -452,7 +453,7 @@ class CategoryTreeService {
|
||||
|
||||
for (const id of reversedIds) {
|
||||
await pool.query(
|
||||
`DELETE FROM category_values_test WHERE (company_code = $1 OR company_code = '*') AND value_id = $2`,
|
||||
`DELETE FROM category_values WHERE (company_code = $1 OR company_code = '*') AND value_id = $2`,
|
||||
[companyCode, id]
|
||||
);
|
||||
}
|
||||
@@ -479,7 +480,7 @@ class CategoryTreeService {
|
||||
|
||||
const query = `
|
||||
SELECT value_id, value_label
|
||||
FROM category_values_test
|
||||
FROM category_values
|
||||
WHERE (company_code = $1 OR company_code = '*') AND parent_value_id = $2
|
||||
`;
|
||||
|
||||
@@ -488,7 +489,7 @@ class CategoryTreeService {
|
||||
for (const child of result.rows) {
|
||||
const newPath = `${parentPath}/${child.value_label}`;
|
||||
|
||||
await pool.query(`UPDATE category_values_test SET path = $1, updated_at = NOW() WHERE value_id = $2`, [
|
||||
await pool.query(`UPDATE category_values SET path = $1, updated_at = NOW() WHERE value_id = $2`, [
|
||||
newPath,
|
||||
child.value_id,
|
||||
]);
|
||||
@@ -550,7 +551,7 @@ class CategoryTreeService {
|
||||
|
||||
/**
|
||||
* 전체 카테고리 키 목록 조회 (모든 테이블.컬럼 조합)
|
||||
* category_values_test 테이블에서 고유한 table_name, column_name 조합을 조회
|
||||
* category_values 테이블에서 고유한 table_name, column_name 조합을 조회
|
||||
* 라벨 정보도 함께 반환
|
||||
*/
|
||||
async getAllCategoryKeys(companyCode: string): Promise<{ tableName: string; columnName: string; tableLabel: string; columnLabel: string }[]> {
|
||||
@@ -564,7 +565,7 @@ class CategoryTreeService {
|
||||
cv.column_name AS "columnName",
|
||||
COALESCE(tl.table_label, cv.table_name) AS "tableLabel",
|
||||
COALESCE(ttc.column_label, cv.column_name) AS "columnLabel"
|
||||
FROM category_values_test cv
|
||||
FROM category_values cv
|
||||
LEFT JOIN table_labels tl ON tl.table_name = cv.table_name
|
||||
LEFT JOIN table_type_columns ttc ON ttc.table_name = cv.table_name AND ttc.column_name = cv.column_name AND ttc.company_code = '*'
|
||||
WHERE cv.company_code = $1 OR cv.company_code = '*'
|
||||
|
||||
@@ -851,47 +851,10 @@ export class MenuCopyService {
|
||||
]);
|
||||
logger.info(` ✅ 메뉴 권한 삭제 완료`);
|
||||
|
||||
// 5-4. 채번 규칙 처리 (체크 제약조건 고려)
|
||||
// scope_type = 'menu'인 채번 규칙: 메뉴 전용이므로 삭제 (파트 포함)
|
||||
// check_menu_scope_requires_menu_objid 제약: scope_type='menu'이면 menu_objid NOT NULL 필수
|
||||
const menuScopedRulesResult = await client.query(
|
||||
`SELECT rule_id FROM numbering_rules
|
||||
WHERE menu_objid = ANY($1) AND company_code = $2 AND scope_type = 'menu'`,
|
||||
[existingMenuIds, targetCompanyCode]
|
||||
);
|
||||
if (menuScopedRulesResult.rows.length > 0) {
|
||||
const menuScopedRuleIds = menuScopedRulesResult.rows.map(
|
||||
(r) => r.rule_id
|
||||
);
|
||||
// 채번 규칙 파트 먼저 삭제
|
||||
await client.query(
|
||||
`DELETE FROM numbering_rule_parts WHERE rule_id = ANY($1)`,
|
||||
[menuScopedRuleIds]
|
||||
);
|
||||
// 채번 규칙 삭제
|
||||
await client.query(
|
||||
`DELETE FROM numbering_rules WHERE rule_id = ANY($1)`,
|
||||
[menuScopedRuleIds]
|
||||
);
|
||||
logger.info(
|
||||
` ✅ 메뉴 전용 채번 규칙 삭제: ${menuScopedRuleIds.length}개`
|
||||
);
|
||||
}
|
||||
|
||||
// scope_type != 'menu'인 채번 규칙: menu_objid만 NULL로 설정 (규칙 보존)
|
||||
const updatedNumberingRules = await client.query(
|
||||
`UPDATE numbering_rules
|
||||
SET menu_objid = NULL
|
||||
WHERE menu_objid = ANY($1) AND company_code = $2
|
||||
AND (scope_type IS NULL OR scope_type != 'menu')
|
||||
RETURNING rule_id`,
|
||||
[existingMenuIds, targetCompanyCode]
|
||||
);
|
||||
if (updatedNumberingRules.rowCount && updatedNumberingRules.rowCount > 0) {
|
||||
logger.info(
|
||||
` ✅ 테이블 스코프 채번 규칙 연결 해제: ${updatedNumberingRules.rowCount}개 (데이터 보존됨)`
|
||||
);
|
||||
}
|
||||
// 5-4. 채번 규칙 처리 (새 스키마에서는 menu_objid 없음 - 스킵)
|
||||
// 새 numbering_rules 스키마: table_name + column_name + company_code 기반
|
||||
// 메뉴와 직접 연결되지 않으므로 메뉴 삭제 시 처리 불필요
|
||||
logger.info(` ⏭️ 채번 규칙: 새 스키마에서는 메뉴와 연결되지 않음 (스킵)`);
|
||||
|
||||
// 5-5. 카테고리 매핑 삭제 (menu_objid가 NOT NULL이므로 NULL 설정 불가)
|
||||
// 카테고리 매핑은 메뉴와 강하게 연결되어 있으므로 함께 삭제
|
||||
@@ -961,6 +924,16 @@ export class MenuCopyService {
|
||||
const menus = await this.collectMenuTree(sourceMenuObjid, client);
|
||||
const sourceCompanyCode = menus[0].company_code!;
|
||||
|
||||
// 같은 회사로 복제하는 경우 경고 (자기 자신의 데이터 손상 위험)
|
||||
if (sourceCompanyCode === targetCompanyCode) {
|
||||
logger.warn(
|
||||
`⚠️ 같은 회사로 메뉴 복제 시도: ${sourceCompanyCode} → ${targetCompanyCode}`
|
||||
);
|
||||
warnings.push(
|
||||
"같은 회사로 복제하면 추가 데이터(카테고리, 채번 등)가 복제되지 않습니다."
|
||||
);
|
||||
}
|
||||
|
||||
const screenIds = await this.collectScreens(
|
||||
menus.map((m) => m.objid),
|
||||
sourceCompanyCode,
|
||||
@@ -1116,6 +1089,10 @@ export class MenuCopyService {
|
||||
client
|
||||
);
|
||||
|
||||
// === 6.5단계: 메뉴 URL 업데이트 (화면 ID 재매핑) ===
|
||||
logger.info("\n🔄 [6.5단계] 메뉴 URL 화면 ID 재매핑");
|
||||
await this.updateMenuUrls(menuIdMap, screenIdMap, client);
|
||||
|
||||
// === 7단계: 테이블 타입 설정 복사 ===
|
||||
if (additionalCopyOptions?.copyTableTypeColumns) {
|
||||
logger.info("\n📦 [7단계] 테이블 타입 설정 복사");
|
||||
@@ -1556,22 +1533,22 @@ export class MenuCopyService {
|
||||
// === 기존 복사본이 있는 경우: 업데이트 ===
|
||||
const existingScreenId = existingCopy.screen_id;
|
||||
|
||||
// 원본 레이아웃 조회
|
||||
const sourceLayoutsResult = await client.query<ScreenLayout>(
|
||||
`SELECT * FROM screen_layouts WHERE screen_id = $1 ORDER BY display_order`,
|
||||
// 원본 V2 레이아웃 조회
|
||||
const sourceLayoutV2Result = await client.query<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = $1`,
|
||||
[originalScreenId]
|
||||
);
|
||||
|
||||
// 대상 레이아웃 조회
|
||||
const targetLayoutsResult = await client.query<ScreenLayout>(
|
||||
`SELECT * FROM screen_layouts WHERE screen_id = $1 ORDER BY display_order`,
|
||||
// 대상 V2 레이아웃 조회
|
||||
const targetLayoutV2Result = await client.query<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = $1`,
|
||||
[existingScreenId]
|
||||
);
|
||||
|
||||
// 변경 여부 확인 (레이아웃 개수 또는 내용 비교)
|
||||
const hasChanges = this.hasLayoutChanges(
|
||||
sourceLayoutsResult.rows,
|
||||
targetLayoutsResult.rows
|
||||
// 변경 여부 확인 (V2 레이아웃 비교)
|
||||
const hasChanges = this.hasLayoutChangesV2(
|
||||
sourceLayoutV2Result.rows[0]?.layout_data,
|
||||
targetLayoutV2Result.rows[0]?.layout_data
|
||||
);
|
||||
|
||||
if (hasChanges) {
|
||||
@@ -1673,9 +1650,9 @@ export class MenuCopyService {
|
||||
}
|
||||
}
|
||||
|
||||
// === 2단계: screen_layouts 처리 (이제 screenIdMap이 완성됨) ===
|
||||
// === 2단계: screen_layouts_v2 처리 (이제 screenIdMap이 완성됨) ===
|
||||
logger.info(
|
||||
`\n📐 레이아웃 처리 시작 (screenIdMap 완성: ${screenIdMap.size}개)`
|
||||
`\n📐 V2 레이아웃 처리 시작 (screenIdMap 완성: ${screenIdMap.size}개)`
|
||||
);
|
||||
|
||||
for (const {
|
||||
@@ -1685,91 +1662,51 @@ export class MenuCopyService {
|
||||
isUpdate,
|
||||
} of screenDefsToProcess) {
|
||||
try {
|
||||
// 원본 레이아웃 조회
|
||||
const layoutsResult = await client.query<ScreenLayout>(
|
||||
`SELECT * FROM screen_layouts WHERE screen_id = $1 ORDER BY display_order`,
|
||||
// 원본 V2 레이아웃 조회
|
||||
const layoutV2Result = await client.query<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = $1`,
|
||||
[originalScreenId]
|
||||
);
|
||||
|
||||
if (isUpdate) {
|
||||
// 업데이트: 기존 레이아웃 삭제 후 새로 삽입
|
||||
await client.query(
|
||||
`DELETE FROM screen_layouts WHERE screen_id = $1`,
|
||||
[targetScreenId]
|
||||
const layoutData = layoutV2Result.rows[0]?.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);
|
||||
});
|
||||
|
||||
// V2 레이아웃 데이터 복사 및 참조 업데이트
|
||||
const updatedLayoutData = this.updateReferencesInLayoutDataV2(
|
||||
layoutData,
|
||||
componentIdMap,
|
||||
screenIdMap,
|
||||
flowIdMap,
|
||||
numberingRuleIdMap,
|
||||
menuIdMap
|
||||
);
|
||||
logger.info(` ↳ 기존 레이아웃 삭제 (업데이트 준비)`);
|
||||
}
|
||||
|
||||
// component_id 매핑 생성 (원본 → 새 ID)
|
||||
const componentIdMap = new Map<string, string>();
|
||||
const timestamp = Date.now();
|
||||
layoutsResult.rows.forEach((layout, idx) => {
|
||||
const newComponentId = `comp_${timestamp}_${idx}_${Math.random().toString(36).substr(2, 5)}`;
|
||||
componentIdMap.set(layout.component_id, newComponentId);
|
||||
});
|
||||
|
||||
// 레이아웃 배치 삽입 준비
|
||||
if (layoutsResult.rows.length > 0) {
|
||||
const layoutValues: string[] = [];
|
||||
const layoutParams: any[] = [];
|
||||
let paramIdx = 1;
|
||||
|
||||
for (const layout of layoutsResult.rows) {
|
||||
const newComponentId = componentIdMap.get(layout.component_id)!;
|
||||
|
||||
const newParentId = layout.parent_id
|
||||
? componentIdMap.get(layout.parent_id) || layout.parent_id
|
||||
: null;
|
||||
const newZoneId = layout.zone_id
|
||||
? componentIdMap.get(layout.zone_id) || layout.zone_id
|
||||
: null;
|
||||
|
||||
const updatedProperties = this.updateReferencesInProperties(
|
||||
layout.properties,
|
||||
screenIdMap,
|
||||
flowIdMap,
|
||||
numberingRuleIdMap,
|
||||
menuIdMap
|
||||
);
|
||||
|
||||
layoutValues.push(
|
||||
`($${paramIdx}, $${paramIdx + 1}, $${paramIdx + 2}, $${paramIdx + 3}, $${paramIdx + 4}, $${paramIdx + 5}, $${paramIdx + 6}, $${paramIdx + 7}, $${paramIdx + 8}, $${paramIdx + 9}, $${paramIdx + 10}, $${paramIdx + 11}, $${paramIdx + 12}, $${paramIdx + 13})`
|
||||
);
|
||||
layoutParams.push(
|
||||
targetScreenId,
|
||||
layout.component_type,
|
||||
newComponentId,
|
||||
newParentId,
|
||||
layout.position_x,
|
||||
layout.position_y,
|
||||
layout.width,
|
||||
layout.height,
|
||||
updatedProperties,
|
||||
layout.display_order,
|
||||
layout.layout_type,
|
||||
layout.layout_config,
|
||||
layout.zones_config,
|
||||
newZoneId
|
||||
);
|
||||
paramIdx += 14;
|
||||
}
|
||||
|
||||
// 배치 INSERT
|
||||
// V2 레이아웃 저장 (UPSERT)
|
||||
await client.query(
|
||||
`INSERT INTO screen_layouts (
|
||||
screen_id, component_type, component_id, parent_id,
|
||||
position_x, position_y, width, height, properties,
|
||||
display_order, layout_type, layout_config, zones_config, zone_id
|
||||
) VALUES ${layoutValues.join(", ")}`,
|
||||
layoutParams
|
||||
`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)]
|
||||
);
|
||||
}
|
||||
|
||||
const action = isUpdate ? "업데이트" : "복사";
|
||||
logger.info(` ↳ 레이아웃 ${action}: ${layoutsResult.rows.length}개`);
|
||||
const action = isUpdate ? "업데이트" : "복사";
|
||||
logger.info(` ↳ V2 레이아웃 ${action}: ${components.length}개 컴포넌트`);
|
||||
} else {
|
||||
logger.info(` ↳ V2 레이아웃 없음 (스킵): screen_id=${originalScreenId}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error(
|
||||
`❌ 레이아웃 처리 실패: screen_id=${originalScreenId}`,
|
||||
`❌ V2 레이아웃 처리 실패: screen_id=${originalScreenId}`,
|
||||
error
|
||||
);
|
||||
throw error;
|
||||
@@ -1835,6 +1772,83 @@ export class MenuCopyService {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 레이아웃 변경 여부 확인 (screen_layouts_v2용)
|
||||
*/
|
||||
private hasLayoutChangesV2(
|
||||
sourceLayoutData: any,
|
||||
targetLayoutData: any
|
||||
): boolean {
|
||||
// 1. 둘 다 없으면 변경 없음
|
||||
if (!sourceLayoutData && !targetLayoutData) return false;
|
||||
|
||||
// 2. 하나만 있으면 변경됨
|
||||
if (!sourceLayoutData || !targetLayoutData) return true;
|
||||
|
||||
// 3. components 배열 비교
|
||||
const sourceComps = sourceLayoutData.components || [];
|
||||
const targetComps = targetLayoutData.components || [];
|
||||
|
||||
if (sourceComps.length !== targetComps.length) return true;
|
||||
|
||||
// 4. 각 컴포넌트 비교 (url, position, size, overrides)
|
||||
for (let i = 0; i < sourceComps.length; i++) {
|
||||
const s = sourceComps[i];
|
||||
const t = targetComps[i];
|
||||
|
||||
if (s.url !== t.url) return true;
|
||||
if (JSON.stringify(s.position) !== JSON.stringify(t.position)) return true;
|
||||
if (JSON.stringify(s.size) !== JSON.stringify(t.size)) return true;
|
||||
if (JSON.stringify(s.overrides) !== JSON.stringify(t.overrides)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 레이아웃 데이터의 참조 ID들을 업데이트 (componentId, flowId, ruleId, screenId, menuId)
|
||||
*/
|
||||
private updateReferencesInLayoutDataV2(
|
||||
layoutData: any,
|
||||
componentIdMap: Map<string, string>,
|
||||
screenIdMap: Map<number, number>,
|
||||
flowIdMap: Map<number, number>,
|
||||
numberingRuleIdMap?: Map<string, string>,
|
||||
menuIdMap?: Map<number, number>
|
||||
): any {
|
||||
if (!layoutData?.components) return layoutData;
|
||||
|
||||
const updatedComponents = layoutData.components.map((comp: any) => {
|
||||
// 1. componentId 매핑
|
||||
const newId = componentIdMap.get(comp.id) || comp.id;
|
||||
|
||||
// 2. overrides 복사 및 재귀적 참조 업데이트
|
||||
let overrides = JSON.parse(JSON.stringify(comp.overrides || {}));
|
||||
|
||||
// 재귀적으로 모든 참조 업데이트
|
||||
this.recursiveUpdateReferences(
|
||||
overrides,
|
||||
screenIdMap,
|
||||
flowIdMap,
|
||||
"",
|
||||
numberingRuleIdMap,
|
||||
menuIdMap
|
||||
);
|
||||
|
||||
return {
|
||||
...comp,
|
||||
id: newId,
|
||||
overrides,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...layoutData,
|
||||
components: updatedComponents,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 위상 정렬 (부모 먼저)
|
||||
*/
|
||||
@@ -2231,6 +2245,68 @@ export class MenuCopyService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 URL 업데이트 (화면 ID 재매핑)
|
||||
* menu_url에 포함된 /screens/{screenId} 형식의 화면 ID를 복제된 화면 ID로 교체
|
||||
*/
|
||||
private async updateMenuUrls(
|
||||
menuIdMap: Map<number, number>,
|
||||
screenIdMap: Map<number, number>,
|
||||
client: PoolClient
|
||||
): Promise<void> {
|
||||
if (menuIdMap.size === 0 || screenIdMap.size === 0) {
|
||||
logger.info("📭 메뉴 URL 업데이트 대상 없음");
|
||||
return;
|
||||
}
|
||||
|
||||
const newMenuObjids = Array.from(menuIdMap.values());
|
||||
|
||||
// 복제된 메뉴 중 menu_url이 있는 것 조회
|
||||
const menusWithUrl = await client.query<{
|
||||
objid: number;
|
||||
menu_url: string;
|
||||
}>(
|
||||
`SELECT objid, menu_url FROM menu_info
|
||||
WHERE objid = ANY($1) AND menu_url IS NOT NULL AND menu_url != ''`,
|
||||
[newMenuObjids]
|
||||
);
|
||||
|
||||
if (menusWithUrl.rows.length === 0) {
|
||||
logger.info("📭 menu_url 업데이트 대상 없음");
|
||||
return;
|
||||
}
|
||||
|
||||
let updatedCount = 0;
|
||||
const screenIdPattern = /\/screens\/(\d+)/;
|
||||
|
||||
for (const menu of menusWithUrl.rows) {
|
||||
const match = menu.menu_url.match(screenIdPattern);
|
||||
if (!match) continue;
|
||||
|
||||
const originalScreenId = parseInt(match[1], 10);
|
||||
const newScreenId = screenIdMap.get(originalScreenId);
|
||||
|
||||
if (newScreenId && newScreenId !== originalScreenId) {
|
||||
const newMenuUrl = menu.menu_url.replace(
|
||||
`/screens/${originalScreenId}`,
|
||||
`/screens/${newScreenId}`
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`UPDATE menu_info SET menu_url = $1 WHERE objid = $2`,
|
||||
[newMenuUrl, menu.objid]
|
||||
);
|
||||
|
||||
logger.info(
|
||||
` 🔗 메뉴 URL 업데이트: ${menu.menu_url} → ${newMenuUrl}`
|
||||
);
|
||||
updatedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`✅ 메뉴 URL 업데이트 완료: ${updatedCount}개`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 코드 카테고리 + 코드 복사 (최적화: 배치 조회/삽입)
|
||||
*/
|
||||
@@ -2477,8 +2553,9 @@ export class MenuCopyService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 채번 규칙 복사 (최적화: 배치 조회/삽입)
|
||||
* 화면 복사 전에 호출되어 numberingRuleId 참조 업데이트에 사용됨
|
||||
* 채번 규칙 복사 (새 스키마: table_name + column_name 기반)
|
||||
* 프론트엔드에서 /numbering-rules/copy-for-company API를 별도 호출하므로
|
||||
* 이 함수는 ruleIdMap 생성만 담당 (실제 복제는 numberingRuleService에서 처리)
|
||||
*/
|
||||
private async copyNumberingRulesWithMap(
|
||||
menuObjids: number[],
|
||||
@@ -2487,222 +2564,47 @@ export class MenuCopyService {
|
||||
userId: string,
|
||||
client: PoolClient
|
||||
): Promise<{ copiedCount: number; ruleIdMap: Map<string, string> }> {
|
||||
let copiedCount = 0;
|
||||
const ruleIdMap = new Map<string, string>();
|
||||
|
||||
if (menuObjids.length === 0) {
|
||||
return { copiedCount, ruleIdMap };
|
||||
}
|
||||
|
||||
// === 최적화: 배치 조회 ===
|
||||
// 1. 모든 원본 채번 규칙 한 번에 조회
|
||||
const allRulesResult = await client.query(
|
||||
`SELECT * FROM numbering_rules WHERE menu_objid = ANY($1)`,
|
||||
[menuObjids]
|
||||
// 새 스키마에서는 채번규칙이 메뉴와 직접 연결되지 않음
|
||||
// 프론트엔드에서 /numbering-rules/copy-for-company API를 별도 호출
|
||||
// 여기서는 기존 규칙 ID를 그대로 매핑 (화면 레이아웃의 numberingRuleId 참조용)
|
||||
|
||||
// 원본 회사의 채번규칙 조회 (company_code 기반)
|
||||
const sourceRulesResult = await client.query(
|
||||
`SELECT rule_id, rule_name FROM numbering_rules WHERE company_code = $1`,
|
||||
[menuObjids.length > 0 ? (await client.query(
|
||||
`SELECT company_code FROM menu_info WHERE objid = $1`,
|
||||
[menuObjids[0]]
|
||||
)).rows[0]?.company_code : null]
|
||||
);
|
||||
|
||||
if (allRulesResult.rows.length === 0) {
|
||||
logger.info(` 📭 복사할 채번 규칙 없음`);
|
||||
return { copiedCount, ruleIdMap };
|
||||
}
|
||||
|
||||
// 2. 대상 회사에 이미 존재하는 모든 채번 규칙 조회 (원본 ID + 새로 생성될 ID 모두 체크 필요)
|
||||
const existingRulesResult = await client.query(
|
||||
`SELECT rule_id FROM numbering_rules WHERE company_code = $1`,
|
||||
// 대상 회사의 채번규칙 조회 (이름 기준 매핑)
|
||||
const targetRulesResult = await client.query(
|
||||
`SELECT rule_id, rule_name FROM numbering_rules WHERE company_code = $1`,
|
||||
[targetCompanyCode]
|
||||
);
|
||||
const existingRuleIds = new Set(
|
||||
existingRulesResult.rows.map((r) => r.rule_id)
|
||||
|
||||
const targetRulesByName = new Map(
|
||||
targetRulesResult.rows.map((r: any) => [r.rule_name, r.rule_id])
|
||||
);
|
||||
|
||||
// 3. 복사할 규칙과 스킵할 규칙 분류
|
||||
const rulesToCopy: any[] = [];
|
||||
const originalToNewRuleMap: Array<{ original: string; new: string }> = [];
|
||||
|
||||
// 기존 규칙 중 menu_objid 업데이트가 필요한 규칙들
|
||||
const rulesToUpdate: Array<{ ruleId: string; newMenuObjid: number }> = [];
|
||||
|
||||
for (const rule of allRulesResult.rows) {
|
||||
// 새 rule_id 계산: 회사코드 접두사 제거 후 대상 회사코드 추가
|
||||
// 예: COMPANY_10_rule-123 -> rule-123 -> COMPANY_16_rule-123
|
||||
// 예: rule-123 -> rule-123 -> COMPANY_16_rule-123
|
||||
// 예: WACE_품목코드 -> 품목코드 -> COMPANY_16_품목코드
|
||||
let baseName = rule.rule_id;
|
||||
|
||||
// 회사코드 접두사 패턴들을 순서대로 제거 시도
|
||||
// 1. COMPANY_숫자_ 패턴 (예: COMPANY_10_)
|
||||
// 2. 일반 접두사_ 패턴 (예: WACE_)
|
||||
if (baseName.match(/^COMPANY_\d+_/)) {
|
||||
baseName = baseName.replace(/^COMPANY_\d+_/, "");
|
||||
} else if (baseName.includes("_")) {
|
||||
baseName = baseName.replace(/^[^_]+_/, "");
|
||||
}
|
||||
|
||||
const newRuleId = `${targetCompanyCode}_${baseName}`;
|
||||
|
||||
if (existingRuleIds.has(rule.rule_id)) {
|
||||
// 원본 ID가 이미 존재 (동일한 ID로 매핑)
|
||||
ruleIdMap.set(rule.rule_id, rule.rule_id);
|
||||
|
||||
const newMenuObjid = menuIdMap.get(rule.menu_objid);
|
||||
if (newMenuObjid) {
|
||||
rulesToUpdate.push({ ruleId: rule.rule_id, newMenuObjid });
|
||||
}
|
||||
logger.info(` ♻️ 채번규칙 이미 존재 (원본 ID): ${rule.rule_id}`);
|
||||
} else if (existingRuleIds.has(newRuleId)) {
|
||||
// 새로 생성될 ID가 이미 존재 (기존 규칙으로 매핑)
|
||||
ruleIdMap.set(rule.rule_id, newRuleId);
|
||||
|
||||
const newMenuObjid = menuIdMap.get(rule.menu_objid);
|
||||
if (newMenuObjid) {
|
||||
rulesToUpdate.push({ ruleId: newRuleId, newMenuObjid });
|
||||
}
|
||||
logger.info(
|
||||
` ♻️ 채번규칙 이미 존재 (대상 ID): ${rule.rule_id} -> ${newRuleId}`
|
||||
);
|
||||
} else {
|
||||
// 새로 복사 필요
|
||||
ruleIdMap.set(rule.rule_id, newRuleId);
|
||||
originalToNewRuleMap.push({ original: rule.rule_id, new: newRuleId });
|
||||
rulesToCopy.push({ ...rule, newRuleId });
|
||||
logger.info(` 📋 채번규칙 복사 예정: ${rule.rule_id} -> ${newRuleId}`);
|
||||
// 이름 기준으로 매핑 생성
|
||||
for (const sourceRule of sourceRulesResult.rows) {
|
||||
const targetRuleId = targetRulesByName.get(sourceRule.rule_name);
|
||||
if (targetRuleId) {
|
||||
ruleIdMap.set(sourceRule.rule_id, targetRuleId);
|
||||
logger.info(` 🔗 채번규칙 매핑: ${sourceRule.rule_id} -> ${targetRuleId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 배치 INSERT로 채번 규칙 복사
|
||||
// menu 스코프인데 menu_objid 매핑이 없는 규칙은 제외 (연결 없이 복제하지 않음)
|
||||
const validRulesToCopy = rulesToCopy.filter((r) => {
|
||||
if (r.scope_type === "menu") {
|
||||
const newMenuObjid = menuIdMap.get(r.menu_objid);
|
||||
if (newMenuObjid === undefined) {
|
||||
logger.info(` ⏭️ 채번규칙 "${r.rule_name}" 건너뜀: 메뉴 연결 없음 (원본 menu_objid: ${r.menu_objid})`);
|
||||
// ruleIdMap에서도 제거
|
||||
ruleIdMap.delete(r.rule_id);
|
||||
return false; // 복제 대상에서 제외
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (validRulesToCopy.length > 0) {
|
||||
const ruleValues = validRulesToCopy
|
||||
.map(
|
||||
(_, i) =>
|
||||
`($${i * 13 + 1}, $${i * 13 + 2}, $${i * 13 + 3}, $${i * 13 + 4}, $${i * 13 + 5}, $${i * 13 + 6}, $${i * 13 + 7}, $${i * 13 + 8}, $${i * 13 + 9}, NOW(), $${i * 13 + 10}, $${i * 13 + 11}, $${i * 13 + 12}, $${i * 13 + 13})`
|
||||
)
|
||||
.join(", ");
|
||||
|
||||
const ruleParams = validRulesToCopy.flatMap((r) => {
|
||||
const newMenuObjid = menuIdMap.get(r.menu_objid);
|
||||
// menu 스코프인 경우 반드시 menu_objid가 있음 (위에서 필터링됨)
|
||||
const finalMenuObjid = newMenuObjid !== undefined ? newMenuObjid : null;
|
||||
// scope_type은 원본 유지 (menu 스코프는 반드시 menu_objid가 있으므로)
|
||||
const finalScopeType = r.scope_type;
|
||||
|
||||
return [
|
||||
r.newRuleId,
|
||||
r.rule_name,
|
||||
r.description,
|
||||
r.separator,
|
||||
r.reset_period,
|
||||
0,
|
||||
r.table_name,
|
||||
r.column_name,
|
||||
targetCompanyCode,
|
||||
userId,
|
||||
finalMenuObjid,
|
||||
finalScopeType,
|
||||
null,
|
||||
];
|
||||
});
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO numbering_rules (
|
||||
rule_id, rule_name, description, separator, reset_period,
|
||||
current_sequence, table_name, column_name, company_code,
|
||||
created_at, created_by, menu_objid, scope_type, last_generated_date
|
||||
) VALUES ${ruleValues}`,
|
||||
ruleParams
|
||||
);
|
||||
|
||||
copiedCount = validRulesToCopy.length;
|
||||
logger.info(` ✅ 채번 규칙 ${copiedCount}개 복사 (${rulesToCopy.length - validRulesToCopy.length}개 건너뜀)`);
|
||||
}
|
||||
|
||||
// 4-1. 기존 채번 규칙의 menu_objid 업데이트 (새 메뉴와 연결) - 배치 처리
|
||||
if (rulesToUpdate.length > 0) {
|
||||
// CASE WHEN을 사용한 배치 업데이트
|
||||
// menu_objid는 numeric 타입이므로 ::numeric 캐스팅 필요
|
||||
const caseWhen = rulesToUpdate
|
||||
.map(
|
||||
(_, i) => `WHEN rule_id = $${i * 2 + 1} THEN $${i * 2 + 2}::numeric`
|
||||
)
|
||||
.join(" ");
|
||||
const ruleIdsForUpdate = rulesToUpdate.map((r) => r.ruleId);
|
||||
const params = rulesToUpdate.flatMap((r) => [r.ruleId, r.newMenuObjid]);
|
||||
|
||||
await client.query(
|
||||
`UPDATE numbering_rules
|
||||
SET menu_objid = CASE ${caseWhen} END, updated_at = NOW()
|
||||
WHERE rule_id = ANY($${params.length + 1}) AND company_code = $${params.length + 2}`,
|
||||
[...params, ruleIdsForUpdate, targetCompanyCode]
|
||||
);
|
||||
logger.info(
|
||||
` ✅ 기존 채번 규칙 ${rulesToUpdate.length}개 메뉴 연결 갱신`
|
||||
);
|
||||
}
|
||||
|
||||
// 5. 모든 원본 파트 한 번에 조회 (새로 복사한 규칙만 대상)
|
||||
if (rulesToCopy.length > 0) {
|
||||
const originalRuleIds = rulesToCopy.map((r) => r.rule_id);
|
||||
const allPartsResult = await client.query(
|
||||
`SELECT * FROM numbering_rule_parts
|
||||
WHERE rule_id = ANY($1) ORDER BY rule_id, part_order`,
|
||||
[originalRuleIds]
|
||||
);
|
||||
|
||||
// 6. 배치 INSERT로 채번 규칙 파트 복사
|
||||
if (allPartsResult.rows.length > 0) {
|
||||
// 원본 rule_id -> 새 rule_id 매핑
|
||||
const ruleMapping = new Map(
|
||||
originalToNewRuleMap.map((m) => [m.original, m.new])
|
||||
);
|
||||
|
||||
const partValues = allPartsResult.rows
|
||||
.map(
|
||||
(_, i) =>
|
||||
`($${i * 7 + 1}, $${i * 7 + 2}, $${i * 7 + 3}, $${i * 7 + 4}, $${i * 7 + 5}, $${i * 7 + 6}, $${i * 7 + 7}, NOW())`
|
||||
)
|
||||
.join(", ");
|
||||
|
||||
const partParams = allPartsResult.rows.flatMap((p) => [
|
||||
ruleMapping.get(p.rule_id),
|
||||
p.part_order,
|
||||
p.part_type,
|
||||
p.generation_method,
|
||||
p.auto_config,
|
||||
p.manual_config,
|
||||
targetCompanyCode,
|
||||
]);
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO numbering_rule_parts (
|
||||
rule_id, part_order, part_type, generation_method,
|
||||
auto_config, manual_config, company_code, created_at
|
||||
) VALUES ${partValues}`,
|
||||
partParams
|
||||
);
|
||||
|
||||
logger.info(` ✅ 채번 규칙 파트 ${allPartsResult.rows.length}개 복사`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`✅ 채번 규칙 복사 완료: ${copiedCount}개, 매핑: ${ruleIdMap.size}개`
|
||||
);
|
||||
return { copiedCount, ruleIdMap };
|
||||
logger.info(` 📋 채번규칙 매핑 완료: ${ruleIdMap.size}개`);
|
||||
|
||||
// 실제 복제는 프론트엔드에서 별도 API 호출로 처리됨
|
||||
return { copiedCount: 0, ruleIdMap };
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 카테고리 매핑 + 값 복사 (최적화: 배치 조회)
|
||||
*
|
||||
|
||||
@@ -102,6 +102,80 @@ export interface NodeExecutionSummary {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ===== 헬퍼 함수 =====
|
||||
|
||||
/**
|
||||
* 🔧 유효한 값인지 체크 (중괄호, 따옴표, 백슬래시 없어야 함)
|
||||
* 숫자도 유효한 값으로 처리
|
||||
*/
|
||||
function isValidDBValue(v: any): boolean {
|
||||
// 숫자면 유효 (나중에 문자열로 변환됨)
|
||||
if (typeof v === "number" && !isNaN(v)) return true;
|
||||
|
||||
// 문자열이 아니면 무효
|
||||
if (typeof v !== "string") return false;
|
||||
if (!v || v.trim() === "") return false;
|
||||
if (v.includes("{") || v.includes("}") || v.includes('"') || v.includes("\\")) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔧 값을 DB 저장용으로 정규화 (PostgreSQL 배열 형식 저장 방지)
|
||||
* - JavaScript 배열 → 쉼표 구분 문자열 (유효한 값만)
|
||||
* - PostgreSQL 배열 형식 문자열 → 쉼표 구분 문자열 (유효한 값만)
|
||||
* - 중첩된 잘못된 형식 → null
|
||||
*/
|
||||
function normalizeValueForDB(value: any): any {
|
||||
// 1. 배열이면 유효한 값만 필터링 후 쉼표 구분 문자열로 변환
|
||||
if (Array.isArray(value)) {
|
||||
// 숫자를 문자열로 변환하고 유효한 값만 필터링
|
||||
const validValues = value
|
||||
.map(v => typeof v === "number" ? String(v) : v)
|
||||
.filter(isValidDBValue)
|
||||
.map(v => typeof v === "number" ? String(v) : v); // 최종 문자열 변환
|
||||
if (validValues.length === 0) {
|
||||
console.warn(`⚠️ [normalizeValueForDB] 배열에 유효한 값 없음:`, value);
|
||||
return null;
|
||||
}
|
||||
const normalized = validValues.join(",");
|
||||
console.log(`🔧 [normalizeValueForDB] 배열→문자열:`, { original: value.length, valid: validValues.length, normalized });
|
||||
return normalized;
|
||||
}
|
||||
|
||||
// 2. 문자열인데 잘못된 형식이면 정리
|
||||
if (typeof value === "string" && value) {
|
||||
// 잘못된 형식 감지
|
||||
if (value.includes("{") || value.includes("}") || value.includes('\\"') || value.includes("\\\\")) {
|
||||
console.warn(`⚠️ [normalizeValueForDB] 잘못된 문자열 형식:`, value.substring(0, 80));
|
||||
|
||||
// 정규표현식으로 유효한 코드만 추출
|
||||
const codePattern = /\b(CAT_[A-Z0-9_]+|[A-Z]{2,}_[A-Z0-9_]+)\b/g;
|
||||
const matches = value.match(codePattern);
|
||||
|
||||
if (matches && matches.length > 0) {
|
||||
const uniqueValues = [...new Set(matches)];
|
||||
const normalized = uniqueValues.join(",");
|
||||
console.log(`🔧 [normalizeValueForDB] 코드 추출:`, { count: uniqueValues.length, normalized });
|
||||
return normalized;
|
||||
}
|
||||
|
||||
console.warn(`⚠️ [normalizeValueForDB] 유효한 코드 없음, null 반환`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 쉼표 구분 문자열이면 각 값 검증
|
||||
if (value.includes(",")) {
|
||||
const parts = value.split(",").map(v => v.trim()).filter(isValidDBValue);
|
||||
if (parts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return parts.join(",");
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
// ===== 메인 실행 서비스 =====
|
||||
|
||||
export class NodeFlowExecutionService {
|
||||
@@ -845,6 +919,9 @@ export class NodeFlowExecutionService {
|
||||
logger.info(
|
||||
`📊 컨텍스트 데이터 사용: ${context.dataSourceType}, ${context.sourceData.length}건`
|
||||
);
|
||||
// 🔍 디버깅: sourceData 내용 출력
|
||||
logger.info(`📊 [테이블소스] sourceData 필드: ${JSON.stringify(Object.keys(context.sourceData[0]))}`);
|
||||
logger.info(`📊 [테이블소스] sourceData.sabun: ${context.sourceData[0]?.sabun}`);
|
||||
return context.sourceData;
|
||||
}
|
||||
|
||||
@@ -1016,10 +1093,12 @@ export class NodeFlowExecutionService {
|
||||
);
|
||||
}
|
||||
|
||||
values.push(value);
|
||||
// 🔧 배열을 쉼표 구분 문자열로 변환
|
||||
const normalizedValue = normalizeValueForDB(value);
|
||||
values.push(normalizedValue);
|
||||
|
||||
// 🔥 삽입된 값을 데이터에 반영
|
||||
insertedData[mapping.targetField] = value;
|
||||
insertedData[mapping.targetField] = normalizedValue;
|
||||
}
|
||||
|
||||
// 🆕 writer와 company_code 자동 추가 (필드 매핑에 없는 경우)
|
||||
@@ -1152,9 +1231,11 @@ export class NodeFlowExecutionService {
|
||||
mapping.staticValue !== undefined
|
||||
? mapping.staticValue
|
||||
: data[mapping.sourceField];
|
||||
values.push(value);
|
||||
// 🔧 배열을 쉼표 구분 문자열로 변환
|
||||
const normalizedValue = normalizeValueForDB(value);
|
||||
values.push(normalizedValue);
|
||||
// 🔥 삽입된 데이터 객체에 매핑된 값 적용
|
||||
insertedData[mapping.targetField] = value;
|
||||
insertedData[mapping.targetField] = normalizedValue;
|
||||
});
|
||||
|
||||
// 외부 DB별 SQL 문법 차이 처리
|
||||
@@ -1490,7 +1571,8 @@ export class NodeFlowExecutionService {
|
||||
|
||||
if (mapping.targetField) {
|
||||
setClauses.push(`${mapping.targetField} = $${paramIndex}`);
|
||||
values.push(value);
|
||||
// 🔧 배열을 쉼표 구분 문자열로 변환
|
||||
values.push(normalizeValueForDB(value));
|
||||
paramIndex++;
|
||||
}
|
||||
});
|
||||
@@ -1553,11 +1635,13 @@ export class NodeFlowExecutionService {
|
||||
// targetField가 비어있지 않은 경우만 추가
|
||||
if (mapping.targetField) {
|
||||
setClauses.push(`${mapping.targetField} = $${paramIndex}`);
|
||||
values.push(value);
|
||||
// 🔧 배열을 쉼표 구분 문자열로 변환
|
||||
const normalizedValue = normalizeValueForDB(value);
|
||||
values.push(normalizedValue);
|
||||
paramIndex++;
|
||||
|
||||
// 🔥 업데이트된 값을 데이터에 반영
|
||||
updatedData[mapping.targetField] = value;
|
||||
updatedData[mapping.targetField] = normalizedValue;
|
||||
} else {
|
||||
console.log(
|
||||
`⚠️ targetField가 비어있어 스킵: ${mapping.sourceField}`
|
||||
@@ -1682,10 +1766,12 @@ export class NodeFlowExecutionService {
|
||||
setClauses.push(`${mapping.targetField} = $${paramIndex}`);
|
||||
}
|
||||
|
||||
values.push(value);
|
||||
// 🔧 배열을 쉼표 구분 문자열로 변환
|
||||
const normalizedValue = normalizeValueForDB(value);
|
||||
values.push(normalizedValue);
|
||||
paramIndex++;
|
||||
// 🔥 업데이트된 데이터 객체에 매핑된 값 적용
|
||||
updatedData[mapping.targetField] = value;
|
||||
updatedData[mapping.targetField] = normalizedValue;
|
||||
});
|
||||
|
||||
// WHERE 조건 생성
|
||||
@@ -2314,7 +2400,8 @@ export class NodeFlowExecutionService {
|
||||
? mapping.staticValue
|
||||
: data[mapping.sourceField];
|
||||
setClauses.push(`${mapping.targetField} = $${paramIndex}`);
|
||||
updateValues.push(value);
|
||||
// 🔧 배열을 쉼표 구분 문자열로 변환
|
||||
updateValues.push(normalizeValueForDB(value));
|
||||
paramIndex++;
|
||||
}
|
||||
});
|
||||
@@ -2365,7 +2452,8 @@ export class NodeFlowExecutionService {
|
||||
? mapping.staticValue
|
||||
: data[mapping.sourceField];
|
||||
columns.push(mapping.targetField);
|
||||
values.push(value);
|
||||
// 🔧 배열을 쉼표 구분 문자열로 변환
|
||||
values.push(normalizeValueForDB(value));
|
||||
});
|
||||
|
||||
// 🆕 writer와 company_code 자동 추가 (필드 매핑에 없는 경우)
|
||||
@@ -2546,7 +2634,8 @@ export class NodeFlowExecutionService {
|
||||
setClauses.push(`${mapping.targetField} = $${paramIndex}`);
|
||||
}
|
||||
|
||||
updateValues.push(value);
|
||||
// 🔧 배열을 쉼표 구분 문자열로 변환
|
||||
updateValues.push(normalizeValueForDB(value));
|
||||
paramIndex++;
|
||||
}
|
||||
});
|
||||
@@ -2584,7 +2673,8 @@ export class NodeFlowExecutionService {
|
||||
? mapping.staticValue
|
||||
: data[mapping.sourceField];
|
||||
columns.push(mapping.targetField);
|
||||
values.push(value);
|
||||
// 🔧 배열을 쉼표 구분 문자열로 변환
|
||||
values.push(normalizeValueForDB(value));
|
||||
});
|
||||
|
||||
let insertSql: string;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
520
backend-node/src/services/scheduleService.ts
Normal file
520
backend-node/src/services/scheduleService.ts
Normal file
@@ -0,0 +1,520 @@
|
||||
/**
|
||||
* 스케줄 자동 생성 서비스
|
||||
*
|
||||
* 스케줄 미리보기 생성, 적용, 조회 로직을 처리합니다.
|
||||
*/
|
||||
|
||||
import { pool } from "../database/db";
|
||||
|
||||
// ============================================================================
|
||||
// 타입 정의
|
||||
// ============================================================================
|
||||
|
||||
export interface ScheduleGenerationConfig {
|
||||
scheduleType: "PRODUCTION" | "MAINTENANCE" | "SHIPPING" | "WORK_ASSIGN";
|
||||
source: {
|
||||
tableName: string;
|
||||
groupByField: string;
|
||||
quantityField: string;
|
||||
dueDateField?: string;
|
||||
};
|
||||
resource: {
|
||||
type: string;
|
||||
idField: string;
|
||||
nameField: string;
|
||||
};
|
||||
rules: {
|
||||
leadTimeDays?: number;
|
||||
dailyCapacity?: number;
|
||||
workingDays?: number[];
|
||||
considerStock?: boolean;
|
||||
stockTableName?: string;
|
||||
stockQtyField?: string;
|
||||
safetyStockField?: string;
|
||||
};
|
||||
target: {
|
||||
tableName: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SchedulePreview {
|
||||
toCreate: any[];
|
||||
toDelete: any[];
|
||||
toUpdate: any[];
|
||||
summary: {
|
||||
createCount: number;
|
||||
deleteCount: number;
|
||||
updateCount: number;
|
||||
totalQty: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ApplyOptions {
|
||||
deleteExisting: boolean;
|
||||
updateMode: "replace" | "merge";
|
||||
}
|
||||
|
||||
export interface ApplyResult {
|
||||
created: number;
|
||||
deleted: number;
|
||||
updated: number;
|
||||
}
|
||||
|
||||
export interface ScheduleListQuery {
|
||||
scheduleType?: string;
|
||||
resourceType?: string;
|
||||
resourceId?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
status?: string;
|
||||
companyCode: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 서비스 클래스
|
||||
// ============================================================================
|
||||
|
||||
export class ScheduleService {
|
||||
/**
|
||||
* 스케줄 미리보기 생성
|
||||
*/
|
||||
async generatePreview(
|
||||
config: ScheduleGenerationConfig,
|
||||
sourceData: any[],
|
||||
period: { start: string; end: string } | undefined,
|
||||
companyCode: string
|
||||
): Promise<SchedulePreview> {
|
||||
console.log("[ScheduleService] generatePreview 시작:", {
|
||||
scheduleType: config.scheduleType,
|
||||
sourceDataCount: sourceData.length,
|
||||
period,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
// 기본 기간 설정 (현재 월)
|
||||
const now = new Date();
|
||||
const defaultPeriod = {
|
||||
start: new Date(now.getFullYear(), now.getMonth(), 1)
|
||||
.toISOString()
|
||||
.split("T")[0],
|
||||
end: new Date(now.getFullYear(), now.getMonth() + 1, 0)
|
||||
.toISOString()
|
||||
.split("T")[0],
|
||||
};
|
||||
const effectivePeriod = period || defaultPeriod;
|
||||
|
||||
// 1. 소스 데이터를 리소스별로 그룹화
|
||||
const groupedData = this.groupByResource(sourceData, config);
|
||||
|
||||
// 2. 각 리소스에 대해 스케줄 생성
|
||||
const toCreate: any[] = [];
|
||||
let totalQty = 0;
|
||||
|
||||
for (const [resourceId, items] of Object.entries(groupedData)) {
|
||||
const schedules = this.generateSchedulesForResource(
|
||||
resourceId,
|
||||
items as any[],
|
||||
config,
|
||||
effectivePeriod,
|
||||
companyCode
|
||||
);
|
||||
toCreate.push(...schedules);
|
||||
totalQty += schedules.reduce((sum, s) => sum + (s.plan_qty || 0), 0);
|
||||
}
|
||||
|
||||
// 3. 기존 스케줄 조회 (삭제 대상)
|
||||
// 그룹 키에서 리소스 ID만 추출 ("리소스ID|날짜" 형식에서 "리소스ID"만)
|
||||
const resourceIds = [
|
||||
...new Set(Object.keys(groupedData).map((key) => key.split("|")[0])),
|
||||
];
|
||||
const toDelete = await this.getExistingSchedules(
|
||||
config.scheduleType,
|
||||
resourceIds,
|
||||
effectivePeriod,
|
||||
companyCode
|
||||
);
|
||||
|
||||
// 4. 미리보기 결과 생성
|
||||
const preview: SchedulePreview = {
|
||||
toCreate,
|
||||
toDelete,
|
||||
toUpdate: [], // 현재는 Replace 모드만 지원
|
||||
summary: {
|
||||
createCount: toCreate.length,
|
||||
deleteCount: toDelete.length,
|
||||
updateCount: 0,
|
||||
totalQty,
|
||||
},
|
||||
};
|
||||
|
||||
console.log("[ScheduleService] generatePreview 완료:", preview.summary);
|
||||
|
||||
return preview;
|
||||
}
|
||||
|
||||
/**
|
||||
* 스케줄 적용
|
||||
*/
|
||||
async applySchedules(
|
||||
config: ScheduleGenerationConfig,
|
||||
preview: SchedulePreview,
|
||||
options: ApplyOptions,
|
||||
companyCode: string,
|
||||
userId: string
|
||||
): Promise<ApplyResult> {
|
||||
console.log("[ScheduleService] applySchedules 시작:", {
|
||||
createCount: preview.summary.createCount,
|
||||
deleteCount: preview.summary.deleteCount,
|
||||
options,
|
||||
companyCode,
|
||||
userId,
|
||||
});
|
||||
|
||||
const client = await pool.connect();
|
||||
const result: ApplyResult = { created: 0, deleted: 0, updated: 0 };
|
||||
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
// 1. 기존 스케줄 삭제
|
||||
if (options.deleteExisting && preview.toDelete.length > 0) {
|
||||
const deleteIds = preview.toDelete.map((s) => s.schedule_id);
|
||||
await client.query(
|
||||
`DELETE FROM schedule_mng
|
||||
WHERE schedule_id = ANY($1) AND company_code = $2`,
|
||||
[deleteIds, companyCode]
|
||||
);
|
||||
result.deleted = deleteIds.length;
|
||||
console.log("[ScheduleService] 스케줄 삭제 완료:", result.deleted);
|
||||
}
|
||||
|
||||
// 2. 새 스케줄 생성
|
||||
for (const schedule of preview.toCreate) {
|
||||
await client.query(
|
||||
`INSERT INTO schedule_mng (
|
||||
company_code, schedule_type, schedule_name,
|
||||
resource_type, resource_id, resource_name,
|
||||
start_date, end_date, due_date,
|
||||
plan_qty, unit, status, priority,
|
||||
source_table, source_id, source_group_key,
|
||||
auto_generated, generated_at, generated_by,
|
||||
metadata, created_by, updated_by
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
|
||||
$11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22
|
||||
)`,
|
||||
[
|
||||
companyCode,
|
||||
schedule.schedule_type,
|
||||
schedule.schedule_name,
|
||||
schedule.resource_type,
|
||||
schedule.resource_id,
|
||||
schedule.resource_name,
|
||||
schedule.start_date,
|
||||
schedule.end_date,
|
||||
schedule.due_date || null,
|
||||
schedule.plan_qty,
|
||||
schedule.unit || null,
|
||||
schedule.status || "PLANNED",
|
||||
schedule.priority || null,
|
||||
schedule.source_table || null,
|
||||
schedule.source_id || null,
|
||||
schedule.source_group_key || null,
|
||||
true,
|
||||
new Date(),
|
||||
userId,
|
||||
schedule.metadata ? JSON.stringify(schedule.metadata) : null,
|
||||
userId,
|
||||
userId,
|
||||
]
|
||||
);
|
||||
result.created++;
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
console.log("[ScheduleService] applySchedules 완료:", result);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK");
|
||||
console.error("[ScheduleService] applySchedules 오류:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 스케줄 목록 조회
|
||||
*/
|
||||
async getScheduleList(
|
||||
query: ScheduleListQuery
|
||||
): Promise<{ data: any[]; total: number }> {
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
// company_code 필터
|
||||
if (query.companyCode !== "*") {
|
||||
conditions.push(`company_code = $${paramIndex++}`);
|
||||
params.push(query.companyCode);
|
||||
}
|
||||
|
||||
// scheduleType 필터
|
||||
if (query.scheduleType) {
|
||||
conditions.push(`schedule_type = $${paramIndex++}`);
|
||||
params.push(query.scheduleType);
|
||||
}
|
||||
|
||||
// resourceType 필터
|
||||
if (query.resourceType) {
|
||||
conditions.push(`resource_type = $${paramIndex++}`);
|
||||
params.push(query.resourceType);
|
||||
}
|
||||
|
||||
// resourceId 필터
|
||||
if (query.resourceId) {
|
||||
conditions.push(`resource_id = $${paramIndex++}`);
|
||||
params.push(query.resourceId);
|
||||
}
|
||||
|
||||
// 기간 필터
|
||||
if (query.startDate) {
|
||||
conditions.push(`end_date >= $${paramIndex++}`);
|
||||
params.push(query.startDate);
|
||||
}
|
||||
if (query.endDate) {
|
||||
conditions.push(`start_date <= $${paramIndex++}`);
|
||||
params.push(query.endDate);
|
||||
}
|
||||
|
||||
// status 필터
|
||||
if (query.status) {
|
||||
conditions.push(`status = $${paramIndex++}`);
|
||||
params.push(query.status);
|
||||
}
|
||||
|
||||
const whereClause =
|
||||
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT * FROM schedule_mng
|
||||
${whereClause}
|
||||
ORDER BY start_date, resource_id`,
|
||||
params
|
||||
);
|
||||
|
||||
return {
|
||||
data: result.rows,
|
||||
total: result.rows.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 스케줄 삭제
|
||||
*/
|
||||
async deleteSchedule(
|
||||
scheduleId: number,
|
||||
companyCode: string,
|
||||
userId: string
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
const result = await pool.query(
|
||||
`DELETE FROM schedule_mng
|
||||
WHERE schedule_id = $1 AND (company_code = $2 OR $2 = '*')
|
||||
RETURNING schedule_id`,
|
||||
[scheduleId, companyCode]
|
||||
);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: "스케줄을 찾을 수 없거나 권한이 없습니다.",
|
||||
};
|
||||
}
|
||||
|
||||
// 이력 기록
|
||||
await pool.query(
|
||||
`INSERT INTO schedule_history (company_code, schedule_id, action, changed_by)
|
||||
VALUES ($1, $2, 'DELETE', $3)`,
|
||||
[companyCode, scheduleId, userId]
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 헬퍼 메서드
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 소스 데이터를 리소스별로 그룹화
|
||||
* - 기준일(dueDateField)이 설정된 경우: 리소스 + 기준일 조합으로 그룹화
|
||||
* - 기준일이 없는 경우: 리소스별로만 그룹화
|
||||
*/
|
||||
private groupByResource(
|
||||
sourceData: any[],
|
||||
config: ScheduleGenerationConfig
|
||||
): Record<string, any[]> {
|
||||
const grouped: Record<string, any[]> = {};
|
||||
const dueDateField = config.source.dueDateField;
|
||||
|
||||
for (const item of sourceData) {
|
||||
const resourceId = item[config.resource.idField];
|
||||
if (!resourceId) continue;
|
||||
|
||||
// 그룹 키 생성: 기준일이 있으면 "리소스ID|기준일", 없으면 "리소스ID"
|
||||
let groupKey = resourceId;
|
||||
if (dueDateField && item[dueDateField]) {
|
||||
// 날짜를 YYYY-MM-DD 형식으로 정규화
|
||||
const dueDate = new Date(item[dueDateField])
|
||||
.toISOString()
|
||||
.split("T")[0];
|
||||
groupKey = `${resourceId}|${dueDate}`;
|
||||
}
|
||||
|
||||
if (!grouped[groupKey]) {
|
||||
grouped[groupKey] = [];
|
||||
}
|
||||
grouped[groupKey].push(item);
|
||||
}
|
||||
|
||||
console.log("[ScheduleService] 그룹화 결과:", {
|
||||
groupCount: Object.keys(grouped).length,
|
||||
groups: Object.keys(grouped),
|
||||
dueDateField,
|
||||
});
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
* 리소스에 대한 스케줄 생성
|
||||
* - groupKey 형식: "리소스ID" 또는 "리소스ID|기준일(YYYY-MM-DD)"
|
||||
*/
|
||||
private generateSchedulesForResource(
|
||||
groupKey: string,
|
||||
items: any[],
|
||||
config: ScheduleGenerationConfig,
|
||||
period: { start: string; end: string },
|
||||
companyCode: string
|
||||
): any[] {
|
||||
const schedules: any[] = [];
|
||||
|
||||
// 그룹 키에서 리소스ID와 기준일 분리
|
||||
const [resourceId, groupDueDate] = groupKey.split("|");
|
||||
const resourceName = items[0]?.[config.resource.nameField] || resourceId;
|
||||
|
||||
// 총 수량 계산
|
||||
const totalQty = items.reduce((sum, item) => {
|
||||
return sum + (parseFloat(item[config.source.quantityField]) || 0);
|
||||
}, 0);
|
||||
|
||||
if (totalQty <= 0) return schedules;
|
||||
|
||||
// 스케줄 규칙 적용
|
||||
const {
|
||||
leadTimeDays = 3,
|
||||
dailyCapacity = totalQty,
|
||||
workingDays = [1, 2, 3, 4, 5],
|
||||
} = config.rules;
|
||||
|
||||
// 기준일(납기일/마감일) 결정
|
||||
let dueDate: Date;
|
||||
if (groupDueDate) {
|
||||
// 그룹 키에 기준일이 포함된 경우
|
||||
dueDate = new Date(groupDueDate);
|
||||
} else if (config.source.dueDateField) {
|
||||
// 아이템에서 기준일 찾기 (가장 빠른 날짜)
|
||||
let earliestDate: Date | null = null;
|
||||
for (const item of items) {
|
||||
const itemDueDate = item[config.source.dueDateField];
|
||||
if (itemDueDate) {
|
||||
const date = new Date(itemDueDate);
|
||||
if (!earliestDate || date < earliestDate) {
|
||||
earliestDate = date;
|
||||
}
|
||||
}
|
||||
}
|
||||
dueDate = earliestDate || new Date(period.end);
|
||||
} else {
|
||||
// 기준일이 없으면 기간 종료일 사용
|
||||
dueDate = new Date(period.end);
|
||||
}
|
||||
|
||||
// 종료일 = 기준일 (납기일에 맞춰 완료)
|
||||
const endDate = new Date(dueDate);
|
||||
|
||||
// 시작일 계산 (종료일에서 리드타임만큼 역산)
|
||||
const startDate = new Date(endDate);
|
||||
startDate.setDate(startDate.getDate() - leadTimeDays);
|
||||
|
||||
// 스케줄명 생성 (기준일 포함)
|
||||
const dueDateStr = dueDate.toISOString().split("T")[0];
|
||||
const scheduleName = groupDueDate
|
||||
? `${resourceName} (${dueDateStr})`
|
||||
: `${resourceName} - ${config.scheduleType}`;
|
||||
|
||||
// 스케줄 생성
|
||||
schedules.push({
|
||||
schedule_type: config.scheduleType,
|
||||
schedule_name: scheduleName,
|
||||
resource_type: config.resource.type,
|
||||
resource_id: resourceId,
|
||||
resource_name: resourceName,
|
||||
start_date: startDate.toISOString(),
|
||||
end_date: endDate.toISOString(),
|
||||
due_date: dueDate.toISOString(),
|
||||
plan_qty: totalQty,
|
||||
status: "PLANNED",
|
||||
source_table: config.source.tableName,
|
||||
source_id: items
|
||||
.map((i) => i.id || i.order_no || i.sales_order_no)
|
||||
.join(","),
|
||||
source_group_key: resourceId,
|
||||
metadata: {
|
||||
sourceCount: items.length,
|
||||
dailyCapacity,
|
||||
leadTimeDays,
|
||||
workingDays,
|
||||
groupDueDate: groupDueDate || null,
|
||||
},
|
||||
});
|
||||
|
||||
console.log("[ScheduleService] 스케줄 생성:", {
|
||||
groupKey,
|
||||
resourceId,
|
||||
resourceName,
|
||||
dueDate: dueDateStr,
|
||||
totalQty,
|
||||
startDate: startDate.toISOString().split("T")[0],
|
||||
endDate: endDate.toISOString().split("T")[0],
|
||||
});
|
||||
|
||||
return schedules;
|
||||
}
|
||||
|
||||
/**
|
||||
* 기존 스케줄 조회 (삭제 대상)
|
||||
*/
|
||||
private async getExistingSchedules(
|
||||
scheduleType: string,
|
||||
resourceIds: string[],
|
||||
period: { start: string; end: string },
|
||||
companyCode: string
|
||||
): Promise<any[]> {
|
||||
if (resourceIds.length === 0) return [];
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT * FROM schedule_mng
|
||||
WHERE schedule_type = $1
|
||||
AND resource_id = ANY($2)
|
||||
AND end_date >= $3
|
||||
AND start_date <= $4
|
||||
AND (company_code = $5 OR $5 = '*')
|
||||
AND auto_generated = true`,
|
||||
[scheduleType, resourceIds, period.start, period.end, companyCode]
|
||||
);
|
||||
|
||||
return result.rows;
|
||||
}
|
||||
}
|
||||
@@ -635,7 +635,76 @@ export class ScreenManagementService {
|
||||
|
||||
// 트랜잭션으로 화면 삭제와 메뉴 할당 정리를 함께 처리 (Raw Query)
|
||||
await transaction(async (client) => {
|
||||
// 소프트 삭제 (휴지통으로 이동)
|
||||
// 1. 화면에서 사용하는 flowId 수집 (V2 레이아웃)
|
||||
const layoutResult = await client.query<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND (company_code = $2 OR company_code = '*')
|
||||
ORDER BY CASE WHEN company_code = $2 THEN 0 ELSE 1 END
|
||||
LIMIT 1`,
|
||||
[screenId, userCompanyCode],
|
||||
);
|
||||
|
||||
const layoutData = layoutResult.rows[0]?.layout_data;
|
||||
const flowIds = this.collectFlowIdsFromLayoutData(layoutData);
|
||||
|
||||
// 2. 각 flowId가 다른 화면에서도 사용되는지 체크 후 삭제
|
||||
if (flowIds.size > 0) {
|
||||
for (const flowId of flowIds) {
|
||||
// 다른 화면에서 사용 중인지 확인 (같은 회사 내, 삭제되지 않은 화면 기준)
|
||||
const companyFilterForCheck = userCompanyCode === "*" ? "" : " AND sd.company_code = $3";
|
||||
const checkParams = userCompanyCode === "*"
|
||||
? [screenId, flowId]
|
||||
: [screenId, flowId, userCompanyCode];
|
||||
|
||||
const otherUsageResult = await client.query<{ count: string }>(
|
||||
`SELECT COUNT(*) as count FROM screen_layouts_v2 slv
|
||||
JOIN screen_definitions sd ON slv.screen_id = sd.screen_id
|
||||
WHERE slv.screen_id != $1
|
||||
AND sd.is_active != 'D'
|
||||
${companyFilterForCheck}
|
||||
AND (
|
||||
slv.layout_data::text LIKE '%"flowId":' || $2 || '%'
|
||||
OR slv.layout_data::text LIKE '%"flowId":"' || $2 || '"%'
|
||||
)`,
|
||||
checkParams,
|
||||
);
|
||||
|
||||
const otherUsageCount = parseInt(otherUsageResult.rows[0]?.count || "0");
|
||||
|
||||
// 다른 화면에서 사용하지 않는 경우에만 플로우 삭제
|
||||
if (otherUsageCount === 0) {
|
||||
// 해당 회사의 플로우만 삭제 (멀티테넌시)
|
||||
const companyFilter = userCompanyCode === "*" ? "" : " AND company_code = $2";
|
||||
const flowParams = userCompanyCode === "*" ? [flowId] : [flowId, userCompanyCode];
|
||||
|
||||
// 1. flow_definition 관련 데이터 먼저 삭제 (외래키 순서)
|
||||
await client.query(
|
||||
`DELETE FROM flow_step_connection WHERE flow_definition_id = $1`,
|
||||
[flowId],
|
||||
);
|
||||
await client.query(
|
||||
`DELETE FROM flow_step WHERE flow_definition_id = $1`,
|
||||
[flowId],
|
||||
);
|
||||
await client.query(
|
||||
`DELETE FROM flow_definition WHERE id = $1${companyFilter}`,
|
||||
flowParams,
|
||||
);
|
||||
|
||||
// 2. node_flows 테이블에서도 삭제 (제어플로우)
|
||||
await client.query(
|
||||
`DELETE FROM node_flows WHERE flow_id = $1${companyFilter}`,
|
||||
flowParams,
|
||||
);
|
||||
|
||||
logger.info("화면 삭제 시 플로우 삭제 (flow_definition + node_flows)", { screenId, flowId, companyCode: userCompanyCode });
|
||||
} else {
|
||||
logger.debug("플로우가 다른 화면에서 사용 중 - 삭제 스킵", { screenId, flowId, otherUsageCount });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 소프트 삭제 (휴지통으로 이동)
|
||||
await client.query(
|
||||
`UPDATE screen_definitions
|
||||
SET is_active = 'D',
|
||||
@@ -655,13 +724,21 @@ export class ScreenManagementService {
|
||||
],
|
||||
);
|
||||
|
||||
// 메뉴 할당도 비활성화
|
||||
// 4. 메뉴 할당도 비활성화
|
||||
await client.query(
|
||||
`UPDATE screen_menu_assignments
|
||||
SET is_active = 'N'
|
||||
WHERE screen_id = $1 AND is_active = 'Y'`,
|
||||
[screenId],
|
||||
);
|
||||
|
||||
// 5. 화면 그룹 연결 삭제 (screen_group_screens)
|
||||
await client.query(
|
||||
`DELETE FROM screen_group_screens WHERE screen_id = $1`,
|
||||
[screenId],
|
||||
);
|
||||
|
||||
logger.info("화면 삭제 시 그룹 연결 해제", { screenId });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1665,18 +1742,28 @@ export class ScreenManagementService {
|
||||
console.log(`V2 레이아웃 발견, V2 형식으로 반환`);
|
||||
const layoutData = v2Layout.layout_data;
|
||||
|
||||
// URL에서 컴포넌트 타입 추출하는 헬퍼 함수
|
||||
const getTypeFromUrl = (url: string | undefined): string => {
|
||||
if (!url) return "component";
|
||||
const parts = url.split("/");
|
||||
return parts[parts.length - 1] || "component";
|
||||
};
|
||||
|
||||
// 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,
|
||||
}));
|
||||
const components = (layoutData.components || []).map((comp: any) => {
|
||||
const componentType = getTypeFromUrl(comp.url);
|
||||
return {
|
||||
id: comp.id,
|
||||
type: componentType,
|
||||
position: comp.position || { x: 0, y: 0, z: 1 },
|
||||
size: comp.size || { width: 200, height: 100 },
|
||||
componentUrl: comp.url,
|
||||
componentType: componentType,
|
||||
componentConfig: comp.overrides || {},
|
||||
displayOrder: comp.displayOrder || 0,
|
||||
...comp.overrides,
|
||||
};
|
||||
});
|
||||
|
||||
// screenResolution이 없으면 컴포넌트 위치 기반으로 자동 계산
|
||||
let screenResolution = layoutData.screenResolution;
|
||||
@@ -2936,7 +3023,7 @@ export class ScreenManagementService {
|
||||
* - current_sequence는 0으로 초기화
|
||||
*/
|
||||
/**
|
||||
* 채번 규칙 복제 (numbering_rules_test 테이블 사용)
|
||||
* 채번 규칙 복제 (numbering_rules 테이블 사용)
|
||||
* - menu_objid 의존성 제거됨
|
||||
* - table_name + column_name + company_code 기반
|
||||
*/
|
||||
@@ -2954,10 +3041,10 @@ export class ScreenManagementService {
|
||||
|
||||
console.log(`🔄 채번 규칙 복사 시작: ${ruleIds.size}개 규칙`);
|
||||
|
||||
// 1. 원본 채번 규칙 조회 (numbering_rules_test 테이블)
|
||||
// 1. 원본 채번 규칙 조회 (numbering_rules 테이블)
|
||||
const ruleIdArray = Array.from(ruleIds);
|
||||
const sourceRulesResult = await client.query(
|
||||
`SELECT * FROM numbering_rules_test WHERE rule_id = ANY($1)`,
|
||||
`SELECT * FROM numbering_rules WHERE rule_id = ANY($1)`,
|
||||
[ruleIdArray],
|
||||
);
|
||||
|
||||
@@ -2970,7 +3057,7 @@ export class ScreenManagementService {
|
||||
|
||||
// 2. 대상 회사의 기존 채번 규칙 조회 (이름 기준)
|
||||
const existingRulesResult = await client.query(
|
||||
`SELECT rule_id, rule_name FROM numbering_rules_test WHERE company_code = $1`,
|
||||
`SELECT rule_id, rule_name FROM numbering_rules WHERE company_code = $1`,
|
||||
[targetCompanyCode],
|
||||
);
|
||||
const existingRulesByName = new Map<string, string>(
|
||||
@@ -2991,9 +3078,9 @@ export class ScreenManagementService {
|
||||
// 새로 복사 - 새 rule_id 생성
|
||||
const newRuleId = `rule-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
// numbering_rules_test 복사 (current_sequence = 0으로 초기화)
|
||||
// numbering_rules 복사 (current_sequence = 0으로 초기화)
|
||||
await client.query(
|
||||
`INSERT INTO numbering_rules_test (
|
||||
`INSERT INTO numbering_rules (
|
||||
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,
|
||||
@@ -3018,15 +3105,15 @@ export class ScreenManagementService {
|
||||
],
|
||||
);
|
||||
|
||||
// numbering_rule_parts_test 복사
|
||||
// numbering_rule_parts 복사
|
||||
const partsResult = await client.query(
|
||||
`SELECT * FROM numbering_rule_parts_test WHERE rule_id = $1 ORDER BY part_order`,
|
||||
`SELECT * FROM numbering_rule_parts 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 (
|
||||
`INSERT INTO numbering_rule_parts (
|
||||
rule_id, part_order, part_type, generation_method,
|
||||
auto_config, manual_config, company_code, created_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||
@@ -3471,6 +3558,371 @@ export class ScreenManagementService {
|
||||
return flowIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 레이아웃에서 flowId 수집 (screen_layouts_v2용)
|
||||
* - overrides.flowId (flow-widget)
|
||||
* - overrides.webTypeConfig.dataflowConfig.flowConfig.flowId (버튼)
|
||||
* - overrides.webTypeConfig.dataflowConfig.flowControls[].flowId
|
||||
* - overrides.action.excelAfterUploadFlows[].flowId
|
||||
*/
|
||||
private collectFlowIdsFromLayoutData(layoutData: any): Set<number> {
|
||||
const flowIds = new Set<number>();
|
||||
if (!layoutData?.components) return flowIds;
|
||||
|
||||
for (const comp of layoutData.components) {
|
||||
const overrides = comp.overrides || {};
|
||||
|
||||
// 1. overrides.flowId (flow-widget 등)
|
||||
if (overrides.flowId && !isNaN(parseInt(overrides.flowId))) {
|
||||
flowIds.add(parseInt(overrides.flowId));
|
||||
}
|
||||
|
||||
// 2. webTypeConfig.dataflowConfig.flowConfig.flowId (버튼)
|
||||
const flowConfigId = overrides?.webTypeConfig?.dataflowConfig?.flowConfig?.flowId;
|
||||
if (flowConfigId && !isNaN(parseInt(flowConfigId))) {
|
||||
flowIds.add(parseInt(flowConfigId));
|
||||
}
|
||||
|
||||
// 3. webTypeConfig.dataflowConfig.selectedDiagramId
|
||||
const diagramId = overrides?.webTypeConfig?.dataflowConfig?.selectedDiagramId;
|
||||
if (diagramId && !isNaN(parseInt(diagramId))) {
|
||||
flowIds.add(parseInt(diagramId));
|
||||
}
|
||||
|
||||
// 4. webTypeConfig.dataflowConfig.flowControls[].flowId
|
||||
const flowControls = overrides?.webTypeConfig?.dataflowConfig?.flowControls;
|
||||
if (Array.isArray(flowControls)) {
|
||||
for (const control of flowControls) {
|
||||
if (control?.flowId && !isNaN(parseInt(control.flowId))) {
|
||||
flowIds.add(parseInt(control.flowId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. action.excelAfterUploadFlows[].flowId
|
||||
const excelFlows = overrides?.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;
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 레이아웃에서 numberingRuleId 수집 (screen_layouts_v2용)
|
||||
* - overrides.autoGeneration.options.numberingRuleId
|
||||
* - overrides.sections[].fields[].numberingRule.ruleId
|
||||
* - overrides.action.excelNumberingRuleId
|
||||
* - overrides.action.numberingRuleId
|
||||
*/
|
||||
private collectNumberingRuleIdsFromLayoutData(layoutData: any): Set<string> {
|
||||
const ruleIds = new Set<string>();
|
||||
if (!layoutData?.components) return ruleIds;
|
||||
|
||||
for (const comp of layoutData.components) {
|
||||
const overrides = comp.overrides || {};
|
||||
|
||||
// 1. autoGeneration.options.numberingRuleId
|
||||
const autoGenRuleId = overrides?.autoGeneration?.options?.numberingRuleId;
|
||||
if (autoGenRuleId && typeof autoGenRuleId === "string" && autoGenRuleId.startsWith("rule-")) {
|
||||
ruleIds.add(autoGenRuleId);
|
||||
}
|
||||
|
||||
// 2. sections[].fields[].numberingRule.ruleId
|
||||
const sections = overrides?.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. action.excelNumberingRuleId
|
||||
const excelRuleId = overrides?.action?.excelNumberingRuleId;
|
||||
if (excelRuleId && typeof excelRuleId === "string" && excelRuleId.startsWith("rule-")) {
|
||||
ruleIds.add(excelRuleId);
|
||||
}
|
||||
|
||||
// 4. action.numberingRuleId
|
||||
const actionRuleId = overrides?.action?.numberingRuleId;
|
||||
if (actionRuleId && typeof actionRuleId === "string" && actionRuleId.startsWith("rule-")) {
|
||||
ruleIds.add(actionRuleId);
|
||||
}
|
||||
}
|
||||
|
||||
return ruleIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 레이아웃 데이터의 참조 ID들을 업데이트
|
||||
* - componentId, flowId, numberingRuleId, screenId 매핑 적용
|
||||
*/
|
||||
private updateReferencesInLayoutData(
|
||||
layoutData: any,
|
||||
mappings: {
|
||||
componentIdMap: Map<string, string>;
|
||||
flowIdMap?: Map<number, number>;
|
||||
ruleIdMap?: Map<string, string>;
|
||||
screenIdMap?: Map<number, number>;
|
||||
},
|
||||
): any {
|
||||
if (!layoutData?.components) return layoutData;
|
||||
|
||||
const updatedComponents = layoutData.components.map((comp: any) => {
|
||||
// 1. componentId 매핑
|
||||
const newId = mappings.componentIdMap.get(comp.id) || comp.id;
|
||||
|
||||
// 2. overrides 복사 및 참조 업데이트
|
||||
let overrides = JSON.parse(JSON.stringify(comp.overrides || {}));
|
||||
|
||||
// flowId 매핑
|
||||
if (mappings.flowIdMap && mappings.flowIdMap.size > 0) {
|
||||
overrides = this.updateFlowIdsInOverrides(overrides, mappings.flowIdMap);
|
||||
}
|
||||
|
||||
// numberingRuleId 매핑
|
||||
if (mappings.ruleIdMap && mappings.ruleIdMap.size > 0) {
|
||||
overrides = this.updateNumberingRuleIdsInOverrides(overrides, mappings.ruleIdMap);
|
||||
}
|
||||
|
||||
// screenId 매핑 (탭, 버튼 등)
|
||||
if (mappings.screenIdMap && mappings.screenIdMap.size > 0) {
|
||||
overrides = this.updateScreenIdsInOverrides(overrides, mappings.screenIdMap);
|
||||
}
|
||||
|
||||
return {
|
||||
...comp,
|
||||
id: newId,
|
||||
overrides,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...layoutData,
|
||||
components: updatedComponents,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 overrides 내의 flowId 업데이트
|
||||
*/
|
||||
private updateFlowIdsInOverrides(
|
||||
overrides: any,
|
||||
flowIdMap: Map<number, number>,
|
||||
): any {
|
||||
if (!overrides || flowIdMap.size === 0) return overrides;
|
||||
|
||||
// 1. overrides.flowId (flow-widget)
|
||||
if (overrides.flowId) {
|
||||
const oldId = parseInt(overrides.flowId);
|
||||
const newId = flowIdMap.get(oldId);
|
||||
if (newId) {
|
||||
overrides.flowId = newId;
|
||||
console.log(` 🔗 flowId: ${oldId} → ${newId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. webTypeConfig.dataflowConfig.flowConfig.flowId
|
||||
if (overrides?.webTypeConfig?.dataflowConfig?.flowConfig?.flowId) {
|
||||
const oldId = parseInt(overrides.webTypeConfig.dataflowConfig.flowConfig.flowId);
|
||||
const newId = flowIdMap.get(oldId);
|
||||
if (newId) {
|
||||
overrides.webTypeConfig.dataflowConfig.flowConfig.flowId = newId;
|
||||
console.log(` 🔗 flowConfig.flowId: ${oldId} → ${newId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. webTypeConfig.dataflowConfig.selectedDiagramId
|
||||
if (overrides?.webTypeConfig?.dataflowConfig?.selectedDiagramId) {
|
||||
const oldId = parseInt(overrides.webTypeConfig.dataflowConfig.selectedDiagramId);
|
||||
const newId = flowIdMap.get(oldId);
|
||||
if (newId) {
|
||||
overrides.webTypeConfig.dataflowConfig.selectedDiagramId = newId;
|
||||
console.log(` 🔗 selectedDiagramId: ${oldId} → ${newId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. webTypeConfig.dataflowConfig.flowControls[]
|
||||
if (Array.isArray(overrides?.webTypeConfig?.dataflowConfig?.flowControls)) {
|
||||
for (const control of overrides.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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. action.excelAfterUploadFlows[]
|
||||
if (Array.isArray(overrides?.action?.excelAfterUploadFlows)) {
|
||||
for (const flow of overrides.action.excelAfterUploadFlows) {
|
||||
if (flow?.flowId) {
|
||||
const oldId = parseInt(flow.flowId);
|
||||
const newId = flowIdMap.get(oldId);
|
||||
if (newId) {
|
||||
flow.flowId = newId;
|
||||
console.log(` 🔗 excelAfterUploadFlows.flowId: ${oldId} → ${newId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return overrides;
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 overrides 내의 numberingRuleId 업데이트
|
||||
*/
|
||||
private updateNumberingRuleIdsInOverrides(
|
||||
overrides: any,
|
||||
ruleIdMap: Map<string, string>,
|
||||
): any {
|
||||
if (!overrides || ruleIdMap.size === 0) return overrides;
|
||||
|
||||
// 1. autoGeneration.options.numberingRuleId
|
||||
if (overrides?.autoGeneration?.options?.numberingRuleId) {
|
||||
const oldId = overrides.autoGeneration.options.numberingRuleId;
|
||||
const newId = ruleIdMap.get(oldId);
|
||||
if (newId) {
|
||||
overrides.autoGeneration.options.numberingRuleId = newId;
|
||||
console.log(` 🔗 autoGeneration.numberingRuleId: ${oldId} → ${newId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. sections[].fields[].numberingRule.ruleId
|
||||
if (Array.isArray(overrides?.sections)) {
|
||||
for (const section of overrides.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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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. action.excelNumberingRuleId
|
||||
if (overrides?.action?.excelNumberingRuleId) {
|
||||
const oldId = overrides.action.excelNumberingRuleId;
|
||||
const newId = ruleIdMap.get(oldId);
|
||||
if (newId) {
|
||||
overrides.action.excelNumberingRuleId = newId;
|
||||
console.log(` 🔗 excelNumberingRuleId: ${oldId} → ${newId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. action.numberingRuleId
|
||||
if (overrides?.action?.numberingRuleId) {
|
||||
const oldId = overrides.action.numberingRuleId;
|
||||
const newId = ruleIdMap.get(oldId);
|
||||
if (newId) {
|
||||
overrides.action.numberingRuleId = newId;
|
||||
console.log(` 🔗 action.numberingRuleId: ${oldId} → ${newId}`);
|
||||
}
|
||||
}
|
||||
|
||||
return overrides;
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 overrides 내의 screenId 업데이트 (탭, 버튼 등)
|
||||
*/
|
||||
private updateScreenIdsInOverrides(
|
||||
overrides: any,
|
||||
screenIdMap: Map<number, number>,
|
||||
): any {
|
||||
if (!overrides || screenIdMap.size === 0) return overrides;
|
||||
|
||||
// 1. tabs[].screenId (탭 위젯)
|
||||
if (Array.isArray(overrides?.tabs)) {
|
||||
for (const tab of overrides.tabs) {
|
||||
if (tab?.screenId) {
|
||||
const oldId = parseInt(tab.screenId);
|
||||
const newId = screenIdMap.get(oldId);
|
||||
if (newId) {
|
||||
tab.screenId = newId;
|
||||
console.log(` 🔗 tab.screenId: ${oldId} → ${newId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. action.targetScreenId (버튼)
|
||||
if (overrides?.action?.targetScreenId) {
|
||||
const oldId = parseInt(overrides.action.targetScreenId);
|
||||
const newId = screenIdMap.get(oldId);
|
||||
if (newId) {
|
||||
overrides.action.targetScreenId = newId;
|
||||
console.log(` 🔗 action.targetScreenId: ${oldId} → ${newId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. action.modalScreenId
|
||||
if (overrides?.action?.modalScreenId) {
|
||||
const oldId = parseInt(overrides.action.modalScreenId);
|
||||
const newId = screenIdMap.get(oldId);
|
||||
if (newId) {
|
||||
overrides.action.modalScreenId = newId;
|
||||
console.log(` 🔗 action.modalScreenId: ${oldId} → ${newId}`);
|
||||
}
|
||||
}
|
||||
|
||||
return overrides;
|
||||
}
|
||||
|
||||
/**
|
||||
* 노드 플로우 복사 및 ID 매핑 반환
|
||||
* - 원본 회사의 플로우를 대상 회사로 복사
|
||||
@@ -3709,24 +4161,34 @@ export class ScreenManagementService {
|
||||
|
||||
const newScreen = newScreenResult.rows[0];
|
||||
|
||||
// 4. 원본 화면의 레이아웃 정보 조회
|
||||
const sourceLayoutsResult = await client.query<any>(
|
||||
`SELECT * FROM screen_layouts
|
||||
WHERE screen_id = $1
|
||||
ORDER BY display_order ASC NULLS LAST`,
|
||||
[sourceScreenId],
|
||||
// 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`,
|
||||
[sourceScreenId, sourceScreen.company_code],
|
||||
);
|
||||
|
||||
const sourceLayouts = sourceLayoutsResult.rows;
|
||||
// 없으면 공통(*) 레이아웃 조회
|
||||
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 = '*'`,
|
||||
[sourceScreenId],
|
||||
);
|
||||
layoutData = fallbackResult.rows[0]?.layout_data;
|
||||
}
|
||||
|
||||
const components = layoutData?.components || [];
|
||||
|
||||
// 5. 노드 플로우 복사 (회사가 다른 경우)
|
||||
let flowIdMap = new Map<number, number>();
|
||||
if (
|
||||
sourceLayouts.length > 0 &&
|
||||
components.length > 0 &&
|
||||
sourceScreen.company_code !== targetCompanyCode
|
||||
) {
|
||||
// 레이아웃에서 사용하는 flowId 수집
|
||||
const flowIds = this.collectFlowIdsFromLayouts(sourceLayouts);
|
||||
// V2 레이아웃에서 flowId 수집
|
||||
const flowIds = this.collectFlowIdsFromLayoutData(layoutData);
|
||||
|
||||
if (flowIds.size > 0) {
|
||||
console.log(`🔍 화면 복사 - flowId 수집: ${flowIds.size}개`);
|
||||
@@ -3744,11 +4206,11 @@ export class ScreenManagementService {
|
||||
// 5.1. 채번 규칙 복사 (회사가 다른 경우)
|
||||
let ruleIdMap = new Map<string, string>();
|
||||
if (
|
||||
sourceLayouts.length > 0 &&
|
||||
components.length > 0 &&
|
||||
sourceScreen.company_code !== targetCompanyCode
|
||||
) {
|
||||
// 레이아웃에서 사용하는 채번 규칙 ID 수집
|
||||
const ruleIds = this.collectNumberingRuleIdsFromLayouts(sourceLayouts);
|
||||
// V2 레이아웃에서 채번 규칙 ID 수집
|
||||
const ruleIds = this.collectNumberingRuleIdsFromLayoutData(layoutData);
|
||||
|
||||
if (ruleIds.size > 0) {
|
||||
console.log(`🔍 화면 복사 - 채번 규칙 ID 수집: ${ruleIds.size}개`);
|
||||
@@ -3763,81 +4225,43 @@ export class ScreenManagementService {
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 레이아웃이 있다면 복사
|
||||
if (sourceLayouts.length > 0) {
|
||||
// 6. V2 레이아웃이 있다면 복사
|
||||
if (layoutData && components.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(),
|
||||
],
|
||||
);
|
||||
// componentId 매핑 생성
|
||||
const componentIdMap = new Map<string, string>();
|
||||
for (const comp of components) {
|
||||
componentIdMap.set(comp.id, generateId());
|
||||
}
|
||||
|
||||
// V2 레이아웃 데이터 복사 및 참조 업데이트
|
||||
const updatedLayoutData = this.updateReferencesInLayoutData(
|
||||
layoutData,
|
||||
{
|
||||
componentIdMap,
|
||||
flowIdMap: flowIdMap.size > 0 ? flowIdMap : undefined,
|
||||
ruleIdMap: ruleIdMap.size > 0 ? ruleIdMap : undefined,
|
||||
// screenIdMap은 모든 화면 복제 완료 후 updateTabScreenReferences에서 일괄 처리
|
||||
},
|
||||
);
|
||||
|
||||
// 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()`,
|
||||
[newScreen.screen_id, targetCompanyCode, JSON.stringify(updatedLayoutData)],
|
||||
);
|
||||
|
||||
console.log(` ✅ V2 레이아웃 복사 완료: ${components.length}개 컴포넌트`);
|
||||
} catch (error) {
|
||||
console.error("레이아웃 복사 중 오류:", error);
|
||||
console.error("V2 레이아웃 복사 중 오류:", error);
|
||||
// 레이아웃 복사 실패해도 화면 생성은 유지
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 생성된 화면 정보 반환
|
||||
// 7. 생성된 화면 정보 반환
|
||||
return {
|
||||
screenId: newScreen.screen_id,
|
||||
screenCode: newScreen.screen_code,
|
||||
@@ -4195,7 +4619,8 @@ export class ScreenManagementService {
|
||||
);
|
||||
|
||||
if (menuInfo.rows.length > 0) {
|
||||
const isAdminMenu = menuInfo.rows[0].menu_type === "1";
|
||||
// menu_type: "0" = 관리자 메뉴, "1" = 사용자 메뉴
|
||||
const isAdminMenu = menuInfo.rows[0].menu_type === "0";
|
||||
const newMenuUrl = isAdminMenu
|
||||
? `/screens/${newScreenId}?mode=admin`
|
||||
: `/screens/${newScreenId}`;
|
||||
@@ -4248,6 +4673,15 @@ export class ScreenManagementService {
|
||||
details: [] as string[],
|
||||
};
|
||||
|
||||
// 같은 회사로 복제하는 경우 스킵 (자기 자신의 데이터 삭제 방지)
|
||||
if (sourceCompanyCode === targetCompanyCode) {
|
||||
logger.warn(
|
||||
`⚠️ 같은 회사로 코드 카테고리/코드 복제 시도 - 스킵: ${sourceCompanyCode}`,
|
||||
);
|
||||
result.details.push("같은 회사로는 복제할 수 없습니다.");
|
||||
return result;
|
||||
}
|
||||
|
||||
return transaction(async (client) => {
|
||||
logger.info(
|
||||
`📦 코드 카테고리/코드 복제: ${sourceCompanyCode} → ${targetCompanyCode}`,
|
||||
@@ -4351,7 +4785,7 @@ export class ScreenManagementService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 값 복제 (category_values_test 테이블 사용)
|
||||
* 카테고리 값 복제 (category_values 테이블 사용)
|
||||
* - menu_objid 의존성 제거됨
|
||||
* - table_name + column_name + company_code 기반
|
||||
*/
|
||||
@@ -4369,20 +4803,29 @@ export class ScreenManagementService {
|
||||
details: [] as string[],
|
||||
};
|
||||
|
||||
// 같은 회사로 복제하는 경우 스킵 (자기 자신의 데이터 삭제 방지)
|
||||
if (sourceCompanyCode === targetCompanyCode) {
|
||||
logger.warn(
|
||||
`⚠️ 같은 회사로 카테고리 값 복제 시도 - 스킵: ${sourceCompanyCode}`,
|
||||
);
|
||||
result.details.push("같은 회사로는 복제할 수 없습니다.");
|
||||
return result;
|
||||
}
|
||||
|
||||
return transaction(async (client) => {
|
||||
logger.info(
|
||||
`📦 카테고리 값 복제: ${sourceCompanyCode} → ${targetCompanyCode}`,
|
||||
);
|
||||
|
||||
// 1. 기존 대상 회사 데이터 삭제
|
||||
// 1. 기존 대상 회사 데이터 삭제 (다른 회사로 복제 시에만)
|
||||
await client.query(
|
||||
`DELETE FROM category_values_test WHERE company_code = $1`,
|
||||
`DELETE FROM category_values WHERE company_code = $1`,
|
||||
[targetCompanyCode],
|
||||
);
|
||||
|
||||
// 2. category_values_test 복제
|
||||
// 2. category_values 복제
|
||||
const values = await client.query(
|
||||
`SELECT * FROM category_values_test WHERE company_code = $1`,
|
||||
`SELECT * FROM category_values WHERE company_code = $1`,
|
||||
[sourceCompanyCode],
|
||||
);
|
||||
|
||||
@@ -4391,7 +4834,7 @@ export class ScreenManagementService {
|
||||
|
||||
for (const v of values.rows) {
|
||||
const insertResult = await client.query(
|
||||
`INSERT INTO category_values_test
|
||||
`INSERT INTO category_values
|
||||
(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)
|
||||
@@ -4426,7 +4869,7 @@ export class ScreenManagementService {
|
||||
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`,
|
||||
`UPDATE category_values SET parent_value_id = $1 WHERE value_id = $2`,
|
||||
[newParentId, newValueId],
|
||||
);
|
||||
}
|
||||
@@ -4451,6 +4894,15 @@ export class ScreenManagementService {
|
||||
details: [] as string[],
|
||||
};
|
||||
|
||||
// 같은 회사로 복제하는 경우 스킵 (자기 자신의 데이터 삭제 방지)
|
||||
if (sourceCompanyCode === targetCompanyCode) {
|
||||
logger.warn(
|
||||
`⚠️ 같은 회사로 테이블 타입 컬럼 복제 시도 - 스킵: ${sourceCompanyCode}`,
|
||||
);
|
||||
result.details.push("같은 회사로는 복제할 수 없습니다.");
|
||||
return result;
|
||||
}
|
||||
|
||||
return transaction(async (client) => {
|
||||
logger.info(
|
||||
`📦 테이블 타입 컬럼 복제: ${sourceCompanyCode} → ${targetCompanyCode}`,
|
||||
@@ -4514,6 +4966,15 @@ export class ScreenManagementService {
|
||||
details: [] as string[],
|
||||
};
|
||||
|
||||
// 같은 회사로 복제하는 경우 스킵 (자기 자신의 데이터 삭제 방지)
|
||||
if (sourceCompanyCode === targetCompanyCode) {
|
||||
logger.warn(
|
||||
`⚠️ 같은 회사로 연쇄관계 설정 복제 시도 - 스킵: ${sourceCompanyCode}`,
|
||||
);
|
||||
result.details.push("같은 회사로는 복제할 수 없습니다.");
|
||||
return result;
|
||||
}
|
||||
|
||||
return transaction(async (client) => {
|
||||
logger.info(
|
||||
`📦 연쇄관계 설정 복제: ${sourceCompanyCode} → ${targetCompanyCode}`,
|
||||
|
||||
@@ -212,22 +212,22 @@ class TableCategoryValueService {
|
||||
updated_at AS "updatedAt",
|
||||
created_by AS "createdBy",
|
||||
updated_by AS "updatedBy"
|
||||
FROM category_values_test
|
||||
FROM category_values
|
||||
WHERE table_name = $1
|
||||
AND column_name = $2
|
||||
`;
|
||||
|
||||
// category_values_test 테이블 사용 (menu_objid 없음)
|
||||
// category_values 테이블 사용 (menu_objid 없음)
|
||||
if (companyCode === "*") {
|
||||
// 최고 관리자: 모든 값 조회
|
||||
query = baseSelect;
|
||||
params = [tableName, columnName];
|
||||
logger.info("최고 관리자 전체 카테고리 값 조회 (category_values_test)");
|
||||
logger.info("최고 관리자 전체 카테고리 값 조회 (category_values)");
|
||||
} else {
|
||||
// 일반 회사: 자신의 회사 또는 공통(*) 카테고리 조회
|
||||
query = baseSelect + ` AND (company_code = $3 OR company_code = '*')`;
|
||||
params = [tableName, columnName, companyCode];
|
||||
logger.info("회사별 카테고리 값 조회 (category_values_test)", { companyCode });
|
||||
logger.info("회사별 카테고리 값 조회 (category_values)", { companyCode });
|
||||
}
|
||||
|
||||
if (!includeInactive) {
|
||||
|
||||
@@ -289,29 +289,48 @@ export class TableManagementService {
|
||||
companyCode,
|
||||
});
|
||||
|
||||
const mappings = await query<any>(
|
||||
`SELECT
|
||||
logical_column_name as "columnName",
|
||||
menu_objid as "menuObjid"
|
||||
FROM category_column_mapping
|
||||
WHERE table_name = $1
|
||||
AND company_code = $2`,
|
||||
[tableName, companyCode]
|
||||
);
|
||||
try {
|
||||
// menu_objid 컬럼이 있는지 먼저 확인
|
||||
const columnCheck = await query<any>(
|
||||
`SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = 'category_column_mapping' AND column_name = 'menu_objid'`
|
||||
);
|
||||
|
||||
logger.info("✅ getColumnList: 카테고리 매핑 조회 완료", {
|
||||
tableName,
|
||||
companyCode,
|
||||
mappingCount: mappings.length,
|
||||
mappings: mappings,
|
||||
});
|
||||
if (columnCheck.length > 0) {
|
||||
// menu_objid 컬럼이 있는 경우
|
||||
const mappings = await query<any>(
|
||||
`SELECT
|
||||
logical_column_name as "columnName",
|
||||
menu_objid as "menuObjid"
|
||||
FROM category_column_mapping
|
||||
WHERE table_name = $1
|
||||
AND company_code = $2`,
|
||||
[tableName, companyCode]
|
||||
);
|
||||
|
||||
mappings.forEach((m: any) => {
|
||||
if (!categoryMappings.has(m.columnName)) {
|
||||
categoryMappings.set(m.columnName, []);
|
||||
logger.info("✅ getColumnList: 카테고리 매핑 조회 완료", {
|
||||
tableName,
|
||||
companyCode,
|
||||
mappingCount: mappings.length,
|
||||
});
|
||||
|
||||
mappings.forEach((m: any) => {
|
||||
if (!categoryMappings.has(m.columnName)) {
|
||||
categoryMappings.set(m.columnName, []);
|
||||
}
|
||||
categoryMappings.get(m.columnName)!.push(Number(m.menuObjid));
|
||||
});
|
||||
} else {
|
||||
// menu_objid 컬럼이 없는 경우 - 매핑 없이 진행
|
||||
logger.info(
|
||||
"⚠️ getColumnList: menu_objid 컬럼이 없음, 카테고리 매핑 스킵"
|
||||
);
|
||||
}
|
||||
categoryMappings.get(m.columnName)!.push(Number(m.menuObjid));
|
||||
});
|
||||
} catch (mappingError: any) {
|
||||
logger.warn("⚠️ getColumnList: 카테고리 매핑 조회 실패, 스킵", {
|
||||
error: mappingError.message,
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("✅ getColumnList: categoryMappings Map 생성 완료", {
|
||||
size: categoryMappings.size,
|
||||
@@ -456,13 +475,25 @@ export class TableManagementService {
|
||||
`컬럼 설정 업데이트 시작: ${tableName}.${columnName}, company: ${companyCode}`
|
||||
);
|
||||
|
||||
// 🔥 "direct" 또는 "auto"는 프론트엔드의 입력 방식 구분값이므로
|
||||
// DB의 input_type(웹타입)으로 저장하면 안 됨 - "text"로 변환
|
||||
if (settings.inputType === "direct" || settings.inputType === "auto") {
|
||||
logger.warn(
|
||||
`잘못된 inputType 값 감지: ${settings.inputType} → 'text'로 변환 (${tableName}.${columnName})`
|
||||
);
|
||||
settings.inputType = "text";
|
||||
}
|
||||
|
||||
// 테이블이 table_labels에 없으면 자동 추가
|
||||
await this.insertTableIfNotExists(tableName);
|
||||
|
||||
// table_type_columns에 모든 설정 저장 (멀티테넌시 지원)
|
||||
// detailSettings가 문자열이면 그대로, 객체면 JSON.stringify
|
||||
let detailSettingsStr = settings.detailSettings;
|
||||
if (typeof settings.detailSettings === "object" && settings.detailSettings !== null) {
|
||||
if (
|
||||
typeof settings.detailSettings === "object" &&
|
||||
settings.detailSettings !== null
|
||||
) {
|
||||
detailSettingsStr = JSON.stringify(settings.detailSettings);
|
||||
}
|
||||
|
||||
@@ -708,12 +739,23 @@ export class TableManagementService {
|
||||
inputType?: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
// 🔥 'direct'나 'auto'는 프론트엔드의 입력 방식 구분값이므로
|
||||
// DB의 input_type(웹타입)으로 저장하면 안 됨 - 'text'로 변환
|
||||
let finalWebType = webType;
|
||||
if (webType === "direct" || webType === "auto") {
|
||||
logger.warn(
|
||||
`잘못된 webType 값 감지: ${webType} → 'text'로 변환 (${tableName}.${columnName})`
|
||||
);
|
||||
finalWebType = "text";
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`컬럼 입력 타입 설정 시작: ${tableName}.${columnName} = ${webType}`
|
||||
`컬럼 입력 타입 설정 시작: ${tableName}.${columnName} = ${finalWebType}`
|
||||
);
|
||||
|
||||
// 웹 타입별 기본 상세 설정 생성
|
||||
const defaultDetailSettings = this.generateDefaultDetailSettings(webType);
|
||||
const defaultDetailSettings =
|
||||
this.generateDefaultDetailSettings(finalWebType);
|
||||
|
||||
// 사용자 정의 설정과 기본 설정 병합
|
||||
const finalDetailSettings = {
|
||||
@@ -732,10 +774,15 @@ export class TableManagementService {
|
||||
input_type = EXCLUDED.input_type,
|
||||
detail_settings = EXCLUDED.detail_settings,
|
||||
updated_date = NOW()`,
|
||||
[tableName, columnName, webType, JSON.stringify(finalDetailSettings)]
|
||||
[
|
||||
tableName,
|
||||
columnName,
|
||||
finalWebType,
|
||||
JSON.stringify(finalDetailSettings),
|
||||
]
|
||||
);
|
||||
logger.info(
|
||||
`컬럼 입력 타입 설정 완료: ${tableName}.${columnName} = ${webType}`
|
||||
`컬럼 입력 타입 설정 완료: ${tableName}.${columnName} = ${finalWebType}`
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
@@ -760,13 +807,23 @@ export class TableManagementService {
|
||||
detailSettings?: Record<string, any>
|
||||
): Promise<void> {
|
||||
try {
|
||||
// 🔥 'direct'나 'auto'는 프론트엔드의 입력 방식 구분값이므로
|
||||
// DB의 input_type(웹타입)으로 저장하면 안 됨 - 'text'로 변환
|
||||
let finalInputType = inputType;
|
||||
if (inputType === "direct" || inputType === "auto") {
|
||||
logger.warn(
|
||||
`잘못된 input_type 값 감지: ${inputType} → 'text'로 변환 (${tableName}.${columnName})`
|
||||
);
|
||||
finalInputType = "text";
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`컬럼 입력 타입 설정 시작: ${tableName}.${columnName} = ${inputType}, company: ${companyCode}`
|
||||
`컬럼 입력 타입 설정 시작: ${tableName}.${columnName} = ${finalInputType}, company: ${companyCode}`
|
||||
);
|
||||
|
||||
// 입력 타입별 기본 상세 설정 생성
|
||||
const defaultDetailSettings =
|
||||
this.generateDefaultInputTypeSettings(inputType);
|
||||
this.generateDefaultInputTypeSettings(finalInputType);
|
||||
|
||||
// 사용자 정의 설정과 기본 설정 병합
|
||||
const finalDetailSettings = {
|
||||
@@ -788,7 +845,7 @@ export class TableManagementService {
|
||||
[
|
||||
tableName,
|
||||
columnName,
|
||||
inputType,
|
||||
finalInputType,
|
||||
JSON.stringify(finalDetailSettings),
|
||||
companyCode,
|
||||
]
|
||||
@@ -798,7 +855,7 @@ export class TableManagementService {
|
||||
await this.syncScreenLayoutsInputType(
|
||||
tableName,
|
||||
columnName,
|
||||
inputType,
|
||||
finalInputType,
|
||||
companyCode
|
||||
);
|
||||
|
||||
@@ -1415,6 +1472,44 @@ export class TableManagementService {
|
||||
});
|
||||
}
|
||||
|
||||
// 🔧 파이프로 구분된 문자열 처리 (객체에서 추출한 actualValue도 처리)
|
||||
if (typeof actualValue === "string" && actualValue.includes("|")) {
|
||||
const columnInfo = await this.getColumnWebTypeInfo(
|
||||
tableName,
|
||||
columnName
|
||||
);
|
||||
|
||||
// 날짜 타입이면 날짜 범위로 처리
|
||||
if (
|
||||
columnInfo &&
|
||||
(columnInfo.webType === "date" || columnInfo.webType === "datetime")
|
||||
) {
|
||||
return this.buildDateRangeCondition(
|
||||
columnName,
|
||||
actualValue,
|
||||
paramIndex
|
||||
);
|
||||
}
|
||||
|
||||
// 그 외 타입이면 다중선택(IN 조건)으로 처리
|
||||
const multiValues = actualValue
|
||||
.split("|")
|
||||
.filter((v: string) => v.trim() !== "");
|
||||
if (multiValues.length > 0) {
|
||||
const placeholders = multiValues
|
||||
.map((_: string, idx: number) => `$${paramIndex + idx}`)
|
||||
.join(", ");
|
||||
logger.info(
|
||||
`🔍 다중선택 필터 적용 (객체에서 추출): ${columnName} IN (${multiValues.join(", ")})`
|
||||
);
|
||||
return {
|
||||
whereClause: `${columnName}::text IN (${placeholders})`,
|
||||
values: multiValues,
|
||||
paramCount: multiValues.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// "__ALL__" 값이거나 빈 값이면 필터 조건을 적용하지 않음
|
||||
if (
|
||||
actualValue === "__ALL__" ||
|
||||
@@ -2171,6 +2266,9 @@ export class TableManagementService {
|
||||
? `WHERE ${whereConditions.join(" AND ")}`
|
||||
: "";
|
||||
|
||||
// 안전한 테이블명 검증
|
||||
const safeTableName = tableName.replace(/[^a-zA-Z0-9_]/g, "");
|
||||
|
||||
// ORDER BY 조건 구성
|
||||
let orderClause = "";
|
||||
if (sortBy) {
|
||||
@@ -2178,11 +2276,17 @@ export class TableManagementService {
|
||||
const safeSortOrder =
|
||||
sortOrder.toLowerCase() === "desc" ? "DESC" : "ASC";
|
||||
orderClause = `ORDER BY ${safeSortBy} ${safeSortOrder}`;
|
||||
} else {
|
||||
// sortBy가 없으면 created_date 컬럼이 있는 경우에만 기본 정렬 적용
|
||||
const hasCreatedDate = await query<any>(
|
||||
`SELECT 1 FROM information_schema.columns WHERE table_name = $1 AND column_name = 'created_date' LIMIT 1`,
|
||||
[safeTableName]
|
||||
);
|
||||
if (hasCreatedDate.length > 0) {
|
||||
orderClause = `ORDER BY main.created_date DESC`;
|
||||
}
|
||||
}
|
||||
|
||||
// 안전한 테이블명 검증
|
||||
const safeTableName = tableName.replace(/[^a-zA-Z0-9_]/g, "");
|
||||
|
||||
// 전체 개수 조회 (main 별칭 추가 - buildWhereClause가 main. 접두사를 사용하므로 필요)
|
||||
const countQuery = `SELECT COUNT(*) as count FROM ${safeTableName} main ${whereClause}`;
|
||||
const countResult = await query<any>(countQuery, searchValues);
|
||||
@@ -3090,9 +3194,13 @@ export class TableManagementService {
|
||||
}
|
||||
|
||||
// ORDER BY 절 구성
|
||||
// sortBy가 없으면 created_date 컬럼이 있는 경우에만 기본 정렬 적용
|
||||
const hasCreatedDateColumn = selectColumns.includes("created_date");
|
||||
const orderBy = options.sortBy
|
||||
? `main.${options.sortBy} ${options.sortOrder === "desc" ? "DESC" : "ASC"}`
|
||||
: "";
|
||||
? `main."${options.sortBy}" ${options.sortOrder === "desc" ? "DESC" : "ASC"}`
|
||||
: hasCreatedDateColumn
|
||||
? `main."created_date" DESC`
|
||||
: "";
|
||||
|
||||
// 페이징 계산
|
||||
const offset = (options.page - 1) * options.size;
|
||||
@@ -3302,14 +3410,17 @@ export class TableManagementService {
|
||||
const entitySearchColumns: string[] = [];
|
||||
|
||||
// Entity 조인 쿼리 생성하여 별칭 매핑 얻기
|
||||
const hasCreatedDateForSearch = selectColumns.includes("created_date");
|
||||
const joinQueryResult = entityJoinService.buildJoinQuery(
|
||||
tableName,
|
||||
joinConfigs,
|
||||
selectColumns,
|
||||
"", // WHERE 절은 나중에 추가
|
||||
options.sortBy
|
||||
? `main.${options.sortBy} ${options.sortOrder || "ASC"}`
|
||||
: undefined,
|
||||
? `main."${options.sortBy}" ${options.sortOrder || "ASC"}`
|
||||
: hasCreatedDateForSearch
|
||||
? `main."created_date" DESC`
|
||||
: undefined,
|
||||
options.size,
|
||||
(options.page - 1) * options.size
|
||||
);
|
||||
@@ -3323,14 +3434,16 @@ export class TableManagementService {
|
||||
|
||||
if (options.search) {
|
||||
for (const [key, value] of Object.entries(options.search)) {
|
||||
// 검색값 추출 (객체 형태일 수 있음)
|
||||
// 검색값 및 operator 추출 (객체 형태일 수 있음)
|
||||
let searchValue = value;
|
||||
let operator = "contains"; // 기본값: 부분 일치
|
||||
if (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"value" in value
|
||||
) {
|
||||
searchValue = value.value;
|
||||
operator = (value as any).operator || "contains";
|
||||
}
|
||||
|
||||
// 빈 값이면 스킵
|
||||
@@ -3382,15 +3495,49 @@ export class TableManagementService {
|
||||
// 기본 Entity 조인 컬럼인 경우: 조인된 테이블의 표시 컬럼에서 검색
|
||||
const aliasKey = `${joinConfig.referenceTable}:${joinConfig.sourceColumn}`;
|
||||
const alias = aliasMap.get(aliasKey);
|
||||
whereConditions.push(
|
||||
`${alias}.${joinConfig.displayColumn} ILIKE '%${safeValue}%'`
|
||||
);
|
||||
entitySearchColumns.push(
|
||||
`${key} (${joinConfig.referenceTable}.${joinConfig.displayColumn})`
|
||||
);
|
||||
logger.info(
|
||||
`🎯 Entity 조인 검색: ${key} → ${joinConfig.referenceTable}.${joinConfig.displayColumn} LIKE '%${safeValue}%' (별칭: ${alias})`
|
||||
);
|
||||
|
||||
// 🔧 파이프로 구분된 다중 선택값 처리
|
||||
if (safeValue.includes("|")) {
|
||||
const multiValues = safeValue
|
||||
.split("|")
|
||||
.filter((v: string) => v.trim() !== "");
|
||||
if (multiValues.length > 0) {
|
||||
const inClause = multiValues
|
||||
.map((v: string) => `'${v}'`)
|
||||
.join(", ");
|
||||
whereConditions.push(
|
||||
`${alias}.${joinConfig.displayColumn}::text IN (${inClause})`
|
||||
);
|
||||
entitySearchColumns.push(
|
||||
`${key} (${joinConfig.referenceTable}.${joinConfig.displayColumn})`
|
||||
);
|
||||
logger.info(
|
||||
`🎯 Entity 조인 다중선택 검색: ${key} → ${joinConfig.referenceTable}.${joinConfig.displayColumn} IN (${multiValues.join(", ")}) (별칭: ${alias})`
|
||||
);
|
||||
}
|
||||
} else if (operator === "equals") {
|
||||
// 🔧 equals 연산자: 정확히 일치
|
||||
whereConditions.push(
|
||||
`${alias}.${joinConfig.displayColumn}::text = '${safeValue}'`
|
||||
);
|
||||
entitySearchColumns.push(
|
||||
`${key} (${joinConfig.referenceTable}.${joinConfig.displayColumn})`
|
||||
);
|
||||
logger.info(
|
||||
`🎯 Entity 조인 정확히 일치 검색: ${key} → ${joinConfig.referenceTable}.${joinConfig.displayColumn} = '${safeValue}' (별칭: ${alias})`
|
||||
);
|
||||
} else {
|
||||
// 기본: 부분 일치 (ILIKE)
|
||||
whereConditions.push(
|
||||
`${alias}.${joinConfig.displayColumn} ILIKE '%${safeValue}%'`
|
||||
);
|
||||
entitySearchColumns.push(
|
||||
`${key} (${joinConfig.referenceTable}.${joinConfig.displayColumn})`
|
||||
);
|
||||
logger.info(
|
||||
`🎯 Entity 조인 검색: ${key} → ${joinConfig.referenceTable}.${joinConfig.displayColumn} LIKE '%${safeValue}%' (별칭: ${alias})`
|
||||
);
|
||||
}
|
||||
} else if (key === "writer_dept_code") {
|
||||
// writer_dept_code: user_info.dept_code에서 검색
|
||||
const userAliasKey = Array.from(aliasMap.keys()).find((k) =>
|
||||
@@ -3427,18 +3574,44 @@ export class TableManagementService {
|
||||
}
|
||||
} else {
|
||||
// 일반 컬럼인 경우: 메인 테이블에서 검색
|
||||
whereConditions.push(`main.${key} ILIKE '%${safeValue}%'`);
|
||||
logger.info(
|
||||
`🔍 일반 컬럼 검색: ${key} → main.${key} LIKE '%${safeValue}%'`
|
||||
);
|
||||
// 🔧 파이프로 구분된 다중 선택값 처리
|
||||
if (safeValue.includes("|")) {
|
||||
const multiValues = safeValue
|
||||
.split("|")
|
||||
.filter((v: string) => v.trim() !== "");
|
||||
if (multiValues.length > 0) {
|
||||
const inClause = multiValues
|
||||
.map((v: string) => `'${v}'`)
|
||||
.join(", ");
|
||||
whereConditions.push(`main.${key}::text IN (${inClause})`);
|
||||
logger.info(
|
||||
`🔍 다중선택 컬럼 검색: ${key} → main.${key} IN (${multiValues.join(", ")})`
|
||||
);
|
||||
}
|
||||
} else if (operator === "equals") {
|
||||
// 🔧 equals 연산자: 정확히 일치
|
||||
whereConditions.push(`main.${key}::text = '${safeValue}'`);
|
||||
logger.info(
|
||||
`🔍 정확히 일치 검색: ${key} → main.${key} = '${safeValue}'`
|
||||
);
|
||||
} else {
|
||||
// 기본: 부분 일치 (ILIKE)
|
||||
whereConditions.push(`main.${key} ILIKE '%${safeValue}%'`);
|
||||
logger.info(
|
||||
`🔍 일반 컬럼 검색: ${key} → main.${key} LIKE '%${safeValue}%'`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const whereClause = whereConditions.join(" AND ");
|
||||
const hasCreatedDateForOrder = selectColumns.includes("created_date");
|
||||
const orderBy = options.sortBy
|
||||
? `main.${options.sortBy} ${options.sortOrder === "desc" ? "DESC" : "ASC"}`
|
||||
: "";
|
||||
? `main."${options.sortBy}" ${options.sortOrder === "desc" ? "DESC" : "ASC"}`
|
||||
: hasCreatedDateForOrder
|
||||
? `main."created_date" DESC`
|
||||
: "";
|
||||
|
||||
// 페이징 계산
|
||||
const offset = (options.page - 1) * options.size;
|
||||
@@ -3715,6 +3888,7 @@ export class TableManagementService {
|
||||
columnName: string;
|
||||
displayName: string;
|
||||
dataType: string;
|
||||
inputType?: string;
|
||||
}>
|
||||
> {
|
||||
return await entityJoinService.getReferenceTableColumns(tableName);
|
||||
@@ -4163,31 +4337,46 @@ export class TableManagementService {
|
||||
if (mappingTableExists) {
|
||||
logger.info("카테고리 매핑 조회 시작", { tableName, companyCode });
|
||||
|
||||
const mappings = await query<any>(
|
||||
`SELECT DISTINCT ON (logical_column_name, menu_objid)
|
||||
logical_column_name as "columnName",
|
||||
menu_objid as "menuObjid"
|
||||
FROM category_column_mapping
|
||||
WHERE table_name = $1
|
||||
AND company_code IN ($2, '*')
|
||||
ORDER BY logical_column_name, menu_objid,
|
||||
CASE WHEN company_code = $2 THEN 0 ELSE 1 END`,
|
||||
[tableName, companyCode]
|
||||
);
|
||||
try {
|
||||
// menu_objid 컬럼이 있는지 먼저 확인
|
||||
const columnCheck = await query<any>(
|
||||
`SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = 'category_column_mapping' AND column_name = 'menu_objid'`
|
||||
);
|
||||
|
||||
logger.info("카테고리 매핑 조회 완료", {
|
||||
tableName,
|
||||
companyCode,
|
||||
mappingCount: mappings.length,
|
||||
mappings: mappings,
|
||||
});
|
||||
if (columnCheck.length > 0) {
|
||||
const mappings = await query<any>(
|
||||
`SELECT DISTINCT ON (logical_column_name, menu_objid)
|
||||
logical_column_name as "columnName",
|
||||
menu_objid as "menuObjid"
|
||||
FROM category_column_mapping
|
||||
WHERE table_name = $1
|
||||
AND company_code IN ($2, '*')
|
||||
ORDER BY logical_column_name, menu_objid,
|
||||
CASE WHEN company_code = $2 THEN 0 ELSE 1 END`,
|
||||
[tableName, companyCode]
|
||||
);
|
||||
|
||||
mappings.forEach((m: any) => {
|
||||
if (!categoryMappings.has(m.columnName)) {
|
||||
categoryMappings.set(m.columnName, []);
|
||||
logger.info("카테고리 매핑 조회 완료", {
|
||||
tableName,
|
||||
companyCode,
|
||||
mappingCount: mappings.length,
|
||||
});
|
||||
|
||||
mappings.forEach((m: any) => {
|
||||
if (!categoryMappings.has(m.columnName)) {
|
||||
categoryMappings.set(m.columnName, []);
|
||||
}
|
||||
categoryMappings.get(m.columnName)!.push(Number(m.menuObjid));
|
||||
});
|
||||
} else {
|
||||
logger.info("⚠️ menu_objid 컬럼이 없음, 카테고리 매핑 스킵");
|
||||
}
|
||||
categoryMappings.get(m.columnName)!.push(Number(m.menuObjid));
|
||||
});
|
||||
} catch (mappingError: any) {
|
||||
logger.warn("⚠️ 카테고리 매핑 조회 실패, 스킵", {
|
||||
error: mappingError.message,
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("categoryMappings Map 생성 완료", {
|
||||
size: categoryMappings.size,
|
||||
|
||||
Reference in New Issue
Block a user