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:
SeongHyun Kim
2026-02-09 10:41:30 +09:00
208 changed files with 45291 additions and 4169 deletions

View File

@@ -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 = '*'

View File

@@ -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 };
}
/**
* 카테고리 매핑 + 값 복사 (최적화: 배치 조회)
*

View File

@@ -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

View 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;
}
}

View File

@@ -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}`,

View File

@@ -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) {

View File

@@ -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,