feat: 화면 복사 기능 개선 및 버튼 모달 설정 수정

## 주요 변경사항

### 1. 화면 복사 기능 강화
- 최고 관리자가 다른 회사로 화면 복사 가능하도록 개선
- 메인 화면과 연결된 모달 화면 자동 감지 및 일괄 복사
- 복사 시 버튼의 targetScreenId 자동 업데이트
- 일괄 이름 변경 기능 추가 (복사본 텍스트 제거)
- 중복 화면명 체크 기능 추가

#### 백엔드 (screenManagementService.ts)
- generateMultipleScreenCodes: 여러 화면 코드 일괄 생성 (Advisory Lock 사용)
- detectLinkedModalScreens: edit 액션도 모달로 감지하도록 개선
- checkDuplicateScreenName: 중복 화면명 체크 API 추가
- copyScreenWithModals: 메인+모달 일괄 복사 및 버튼 업데이트
- updateButtonTargetScreenIds: 복사된 모달로 버튼 targetScreenId 업데이트
- updated_date 컬럼 제거 (screen_layouts 테이블에 존재하지 않음)

#### 프론트엔드 (CopyScreenModal.tsx)
- 회사 선택 UI 추가 (최고 관리자 전용)
- 연결된 모달 화면 자동 감지 및 표시
- 일괄 이름 변경 기능 (텍스트 제거/추가)
- 실시간 미리보기
- 중복 화면명 체크

### 2. 버튼 설정 모달 화면 선택 개선
- 편집 중인 화면의 company_code 기준으로 화면 목록 조회
- 최고 관리자가 다른 회사 화면 편집 시 해당 회사의 모달 화면만 표시
- targetScreenId 문자열/숫자 타입 불일치 수정

#### 백엔드 (screenManagementController.ts)
- getScreens API에 companyCode 쿼리 파라미터 추가
- 최고 관리자는 다른 회사의 화면 목록 조회 가능

#### 프론트엔드
- ButtonConfigPanel: currentScreenCompanyCode props 추가
- DetailSettingsPanel: currentScreenCompanyCode 전달
- UnifiedPropertiesPanel: currentScreenCompanyCode 전달
- ScreenDesigner: selectedScreen.companyCode 전달
- targetScreenId 비교 시 parseInt 처리 (문자열→숫자)

### 3. 카테고리 메뉴별 컬럼 분리 기능
- 메뉴별로 카테고리 컬럼을 독립적으로 관리
- 카테고리 컬럼 추가/삭제 시 메뉴 스코프 적용

## 수정된 파일
- backend-node/src/services/screenManagementService.ts
- backend-node/src/controllers/screenManagementController.ts
- backend-node/src/routes/screenManagementRoutes.ts
- frontend/components/screen/CopyScreenModal.tsx
- frontend/components/screen/config-panels/ButtonConfigPanel.tsx
- frontend/components/screen/panels/DetailSettingsPanel.tsx
- frontend/components/screen/panels/UnifiedPropertiesPanel.tsx
- frontend/components/screen/ScreenDesigner.tsx
- frontend/lib/api/screen.ts
This commit is contained in:
kjs
2025-11-13 12:17:10 +09:00
parent b77fffbad7
commit 658211b9d1
21 changed files with 4969 additions and 209 deletions

View File

@@ -539,29 +539,43 @@ export class RoleService {
/**
* 전체 메뉴 목록 조회 (권한 설정용)
*/
/**
* 전체 메뉴 목록 조회 (권한 설정용)
*
* @param companyCode - 회사 코드
* - undefined: 최고 관리자 - 모든 회사의 모든 메뉴 조회
* - "*": 최고 관리자의 공통 메뉴만 조회 (최고 관리자 전용)
* - "COMPANY_X": 해당 회사 메뉴만 조회 (공통 메뉴 제외)
*
* 중요:
* - 공통 메뉴(company_code = "*")는 최고 관리자 전용 메뉴입니다.
* - menu_type = 2 (화면)는 제외하고 메뉴만 조회합니다.
*/
static async getAllMenus(companyCode?: string): Promise<any[]> {
try {
logger.info("🔍 전체 메뉴 목록 조회 시작", { companyCode });
let whereConditions: string[] = ["status = 'active'"];
let whereConditions: string[] = [
"status = 'active'",
"menu_type != 2" // 화면 제외, 메뉴만 조회
];
const params: any[] = [];
let paramIndex = 1;
// 회사 코드 필터 (선택적)
// 공통 메뉴(*)와 특정 회사 메뉴를 모두 조회
// 회사 코드 필터 (선택적)
if (companyCode) {
// 특정 회사 메뉴만 조회 (공통 메뉴 제외)
// 회사 코드에 따른 필터링
if (companyCode === undefined) {
// 최고 관리자: 모든 메뉴 조회
logger.info("📋 최고 관리자 모드: 모든 메뉴 조회");
} else if (companyCode === "*") {
// 공통 메뉴만 조회
whereConditions.push(`company_code = $${paramIndex}`);
params.push("*");
paramIndex++;
logger.info("📋 공통 메뉴만 조회");
} else {
// 특정 회사: 해당 회사 메뉴 + 공통 메뉴 조회
whereConditions.push(`(company_code = $${paramIndex} OR company_code = '*')`);
params.push(companyCode);
paramIndex++;
logger.info("📋 회사 코드 필터 적용 (공통 메뉴 제외)", { companyCode });
} else {
logger.info("📋 회사 코드 필터 없음 (전체 조회)");
logger.info("📋 회사 필터 적용 (해당 회사 + 공통 메뉴)", { companyCode });
}
const whereClause = whereConditions.join(" AND ");
@@ -573,13 +587,19 @@ export class RoleService {
menu_name_eng AS "menuNameEng",
menu_code AS "menuCode",
menu_url AS "menuUrl",
menu_type AS "menuType",
CAST(menu_type AS TEXT) AS "menuType",
parent_obj_id AS "parentObjid",
seq AS "sortOrder",
company_code AS "companyCode"
FROM menu_info
WHERE ${whereClause}
ORDER BY seq, menu_name_kor
ORDER BY
CASE
WHEN parent_obj_id = 0 OR parent_obj_id IS NULL THEN 0
ELSE 1
END,
seq,
menu_name_kor
`;
logger.info("🔍 SQL 쿼리 실행", {
@@ -592,8 +612,9 @@ export class RoleService {
logger.info("✅ 메뉴 목록 조회 성공", {
count: result.length,
companyCode,
menus: result.map((m) => ({
companyCode: companyCode || "전체",
companyCodes: [...new Set(result.map((m) => m.companyCode))],
menus: result.slice(0, 5).map((m) => ({
objid: m.objid,
name: m.menuName,
code: m.menuCode,

View File

@@ -23,8 +23,9 @@ interface CopyScreenRequest {
screenName: string;
screenCode: string;
description?: string;
companyCode: string;
createdBy: string;
companyCode: string; // 요청한 사용자의 회사 코드 (인증용)
userId: string;
targetCompanyCode?: string; // 복사 대상 회사 코드 (최고 관리자 전용)
}
// 백엔드에서 사용할 테이블 정보 타입
@@ -1841,37 +1842,191 @@ export class ScreenManagementService {
/**
* 화면 코드 자동 생성 (회사코드 + '_' + 순번) (✅ Raw Query 전환 완료)
* 동시성 문제 방지: Advisory Lock 사용
*/
async generateScreenCode(companyCode: string): Promise<string> {
// 해당 회사의 기존 화면 코드들 조회 (Raw Query)
const existingScreens = await query<{ screen_code: string }>(
`SELECT screen_code FROM screen_definitions
WHERE company_code = $1 AND screen_code LIKE $2
ORDER BY screen_code DESC`,
[companyCode, `${companyCode}%`]
);
return await transaction(async (client) => {
// 회사 코드를 숫자로 변환하여 advisory lock ID로 사용
const lockId = Buffer.from(companyCode).reduce((acc, byte) => acc + byte, 0);
// Advisory lock 획득 (다른 트랜잭션이 같은 회사 코드를 생성하는 동안 대기)
await client.query('SELECT pg_advisory_xact_lock($1)', [lockId]);
// 회사 코드 뒤의 숫자 부분 추출하여 최대값 찾기
let maxNumber = 0;
const pattern = new RegExp(
`^${companyCode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:_)?(\\d+)$`
);
// 해당 회사의 기존 화면 코드들 조회
const existingScreens = await client.query<{ screen_code: string }>(
`SELECT screen_code FROM screen_definitions
WHERE company_code = $1 AND screen_code LIKE $2
ORDER BY screen_code DESC
LIMIT 10`,
[companyCode, `${companyCode}%`]
);
for (const screen of existingScreens) {
const match = screen.screen_code.match(pattern);
if (match) {
const number = parseInt(match[1], 10);
if (number > maxNumber) {
maxNumber = number;
// 회사 코드 뒤의 숫자 부분 추출하여 최대값 찾기
let maxNumber = 0;
const pattern = new RegExp(
`^${companyCode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:_)?(\\d+)$`
);
for (const screen of existingScreens.rows) {
const match = screen.screen_code.match(pattern);
if (match) {
const number = parseInt(match[1], 10);
if (number > maxNumber) {
maxNumber = number;
}
}
}
// 다음 순번으로 화면 코드 생성 (3자리 패딩)
const nextNumber = maxNumber + 1;
const paddedNumber = nextNumber.toString().padStart(3, "0");
const newCode = `${companyCode}_${paddedNumber}`;
console.log(`🔢 화면 코드 생성: ${companyCode}${newCode} (maxNumber: ${maxNumber})`);
return newCode;
// Advisory lock은 트랜잭션 종료 시 자동으로 해제됨
});
}
/**
* 여러 개의 화면 코드를 한 번에 생성 (중복 방지)
* 한 트랜잭션 내에서 순차적으로 생성하여 중복 방지
*/
async generateMultipleScreenCodes(
companyCode: string,
count: number
): Promise<string[]> {
return await transaction(async (client) => {
// Advisory lock 획득
const lockId = Buffer.from(companyCode).reduce((acc, byte) => acc + byte, 0);
await client.query('SELECT pg_advisory_xact_lock($1)', [lockId]);
// 현재 최대 번호 조회
const existingScreens = await client.query<{ screen_code: string }>(
`SELECT screen_code FROM screen_definitions
WHERE company_code = $1 AND screen_code LIKE $2
ORDER BY screen_code DESC
LIMIT 10`,
[companyCode, `${companyCode}%`]
);
let maxNumber = 0;
const pattern = new RegExp(
`^${companyCode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:_)?(\\d+)$`
);
for (const screen of existingScreens.rows) {
const match = screen.screen_code.match(pattern);
if (match) {
const number = parseInt(match[1], 10);
if (number > maxNumber) {
maxNumber = number;
}
}
}
// count개의 코드를 순차적으로 생성
const codes: string[] = [];
for (let i = 0; i < count; i++) {
const nextNumber = maxNumber + i + 1;
const paddedNumber = nextNumber.toString().padStart(3, "0");
codes.push(`${companyCode}_${paddedNumber}`);
}
console.log(`🔢 화면 코드 일괄 생성 (${count}개): ${companyCode} → [${codes.join(', ')}]`);
return codes;
});
}
/**
* 화면명 중복 체크
* 같은 회사 내에서 동일한 화면명이 있는지 확인
*/
async checkDuplicateScreenName(
companyCode: string,
screenName: string
): Promise<boolean> {
const result = await query<any>(
`SELECT COUNT(*) as count
FROM screen_definitions
WHERE company_code = $1
AND screen_name = $2
AND deleted_date IS NULL`,
[companyCode, screenName]
);
const count = parseInt(result[0]?.count || "0", 10);
return count > 0;
}
/**
* 화면에 연결된 모달 화면들을 자동 감지
* 버튼 컴포넌트의 popup 액션에서 targetScreenId를 추출
*/
async detectLinkedModalScreens(
screenId: number
): Promise<{ screenId: number; screenName: string; screenCode: string }[]> {
// 화면의 모든 레이아웃 조회
const layouts = await query<any>(
`SELECT layout_id, properties
FROM screen_layouts
WHERE screen_id = $1
AND component_type = 'component'
AND properties IS NOT NULL`,
[screenId]
);
const linkedScreenIds = new Set<number>();
// 각 레이아웃에서 버튼의 popup/modal/edit 액션 확인
for (const layout of layouts) {
try {
const properties = layout.properties;
// 버튼 컴포넌트인지 확인
if (properties?.componentType === "button" || properties?.componentType?.startsWith("button-")) {
const action = properties?.componentConfig?.action;
// popup, modal, edit 액션이고 targetScreenId가 있는 경우
// edit 액션도 수정 폼 모달을 열기 때문에 포함
if ((action?.type === "popup" || action?.type === "modal" || action?.type === "edit") && action?.targetScreenId) {
const targetScreenId = parseInt(action.targetScreenId);
if (!isNaN(targetScreenId)) {
linkedScreenIds.add(targetScreenId);
console.log(`🔗 연결된 모달 화면 발견: screenId=${targetScreenId}, actionType=${action.type} (레이아웃 ${layout.layout_id})`);
}
}
}
} catch (error) {
// JSON 파싱 오류 등은 무시하고 계속 진행
console.warn(`레이아웃 ${layout.layout_id} 파싱 오류:`, error);
}
}
// 다음 순번으로 화면 코드 생성 (3자리 패딩)
const nextNumber = maxNumber + 1;
const paddedNumber = nextNumber.toString().padStart(3, "0");
// 감지된 화면 ID들의 정보 조회
if (linkedScreenIds.size === 0) {
return [];
}
return `${companyCode}_${paddedNumber}`;
const screenIds = Array.from(linkedScreenIds);
const placeholders = screenIds.map((_, i) => `$${i + 1}`).join(", ");
const linkedScreens = await query<any>(
`SELECT screen_id, screen_name, screen_code
FROM screen_definitions
WHERE screen_id IN (${placeholders})
AND deleted_date IS NULL
ORDER BY screen_name`,
screenIds
);
return linkedScreens.map((s) => ({
screenId: s.screen_id,
screenName: s.screen_name,
screenCode: s.screen_code,
}));
}
/**
@@ -1884,11 +2039,31 @@ export class ScreenManagementService {
// 트랜잭션으로 처리
return await transaction(async (client) => {
// 1. 원본 화면 정보 조회
// 최고 관리자(company_code = "*")는 모든 화면을 조회할 수 있음
let sourceScreenQuery: string;
let sourceScreenParams: any[];
if (copyData.companyCode === "*") {
// 최고 관리자: 모든 회사의 화면 조회 가능
sourceScreenQuery = `
SELECT * FROM screen_definitions
WHERE screen_id = $1
LIMIT 1
`;
sourceScreenParams = [sourceScreenId];
} else {
// 일반 회사: 자신의 회사 화면만 조회 가능
sourceScreenQuery = `
SELECT * FROM screen_definitions
WHERE screen_id = $1 AND company_code = $2
LIMIT 1
`;
sourceScreenParams = [sourceScreenId, copyData.companyCode];
}
const sourceScreens = await client.query<any>(
`SELECT * FROM screen_definitions
WHERE screen_id = $1 AND company_code = $2
LIMIT 1`,
[sourceScreenId, copyData.companyCode]
sourceScreenQuery,
sourceScreenParams
);
if (sourceScreens.rows.length === 0) {
@@ -1897,19 +2072,24 @@ export class ScreenManagementService {
const sourceScreen = sourceScreens.rows[0];
// 2. 화면 코드 중복 체크
// 2. 대상 회사 코드 결정
// copyData.targetCompanyCode가 있으면 사용 (회사 간 복사)
// 없으면 원본과 같은 회사에 복사
const targetCompanyCode = copyData.targetCompanyCode || sourceScreen.company_code;
// 3. 화면 코드 중복 체크 (대상 회사 기준)
const existingScreens = await client.query<any>(
`SELECT screen_id FROM screen_definitions
WHERE screen_code = $1 AND company_code = $2
LIMIT 1`,
[copyData.screenCode, copyData.companyCode]
[copyData.screenCode, targetCompanyCode]
);
if (existingScreens.rows.length > 0) {
throw new Error("이미 존재하는 화면 코드입니다.");
}
// 3. 새 화면 생성
// 4. 새 화면 생성 (대상 회사에 생성)
const newScreenResult = await client.query<any>(
`INSERT INTO screen_definitions (
screen_code, screen_name, description, company_code, table_name,
@@ -1920,12 +2100,12 @@ export class ScreenManagementService {
copyData.screenCode,
copyData.screenName,
copyData.description || sourceScreen.description,
copyData.companyCode,
targetCompanyCode, // 대상 회사 코드 사용
sourceScreen.table_name,
sourceScreen.is_active,
copyData.createdBy,
copyData.userId,
new Date(),
copyData.createdBy,
copyData.userId,
new Date(),
]
);
@@ -2005,6 +2185,165 @@ export class ScreenManagementService {
};
});
}
/**
* 메인 화면 + 연결된 모달 화면들 일괄 복사
*/
async copyScreenWithModals(data: {
sourceScreenId: number;
companyCode: string;
userId: string;
targetCompanyCode?: string; // 최고 관리자 전용: 다른 회사로 복사
mainScreen: {
screenName: string;
screenCode: string;
description?: string;
};
modalScreens: Array<{
sourceScreenId: number;
screenName: string;
screenCode: string;
}>;
}): Promise<{
mainScreen: ScreenDefinition;
modalScreens: ScreenDefinition[];
}> {
const targetCompany = data.targetCompanyCode || data.companyCode;
console.log(`🔄 일괄 복사 시작: 메인(${data.sourceScreenId}) + 모달(${data.modalScreens.length}개) → ${targetCompany}`);
// 1. 메인 화면 복사
const mainScreen = await this.copyScreen(data.sourceScreenId, {
screenName: data.mainScreen.screenName,
screenCode: data.mainScreen.screenCode,
description: data.mainScreen.description || "",
companyCode: data.companyCode,
userId: data.userId,
targetCompanyCode: data.targetCompanyCode, // 대상 회사 코드 전달
});
console.log(`✅ 메인 화면 복사 완료: ${mainScreen.screenId} (${mainScreen.screenCode}) @ ${mainScreen.companyCode}`);
// 2. 모달 화면들 복사 (원본 screenId → 새 screenId 매핑)
const modalScreens: ScreenDefinition[] = [];
const screenIdMapping: Map<number, number> = new Map(); // 원본 ID → 새 ID
for (const modalData of data.modalScreens) {
const copiedModal = await this.copyScreen(modalData.sourceScreenId, {
screenName: modalData.screenName,
screenCode: modalData.screenCode,
description: "",
companyCode: data.companyCode,
userId: data.userId,
targetCompanyCode: data.targetCompanyCode, // 대상 회사 코드 전달
});
modalScreens.push(copiedModal);
screenIdMapping.set(modalData.sourceScreenId, copiedModal.screenId);
console.log(
`✅ 모달 화면 복사 완료: ${modalData.sourceScreenId}${copiedModal.screenId} (${copiedModal.screenCode})`
);
}
// 3. 메인 화면의 버튼 액션에서 targetScreenId 업데이트
// 모든 복사가 완료되고 커밋된 후에 실행
console.log(`🔧 버튼 업데이트 시작: 메인 화면 ${mainScreen.screenId}, 매핑:`,
Array.from(screenIdMapping.entries())
);
const updateCount = await this.updateButtonTargetScreenIds(
mainScreen.screenId,
screenIdMapping
);
console.log(`🎉 일괄 복사 완료: 메인(${mainScreen.screenId}) + 모달(${modalScreens.length}개), 버튼 ${updateCount}개 업데이트`);
return {
mainScreen,
modalScreens,
};
}
/**
* 화면 레이아웃에서 버튼의 targetScreenId를 새 screenId로 업데이트
* (독립적인 트랜잭션으로 실행)
*/
private async updateButtonTargetScreenIds(
screenId: number,
screenIdMapping: Map<number, number>
): Promise<number> {
console.log(`🔍 updateButtonTargetScreenIds 호출: screenId=${screenId}, 매핑 개수=${screenIdMapping.size}`);
// 화면의 모든 레이아웃 조회
const layouts = await query<any>(
`SELECT layout_id, properties
FROM screen_layouts
WHERE screen_id = $1
AND component_type = 'component'
AND properties IS NOT NULL`,
[screenId]
);
console.log(`📦 조회된 레이아웃 개수: ${layouts.length}`);
let updateCount = 0;
for (const layout of layouts) {
try {
const properties = layout.properties;
// 버튼 컴포넌트인지 확인
if (
properties?.componentType === "button" ||
properties?.componentType?.startsWith("button-")
) {
const action = properties?.componentConfig?.action;
// targetScreenId가 있는 액션 (popup, modal, edit)
if (
(action?.type === "popup" ||
action?.type === "modal" ||
action?.type === "edit") &&
action?.targetScreenId
) {
const oldScreenId = parseInt(action.targetScreenId);
console.log(`🔍 버튼 발견: layout ${layout.layout_id}, action=${action.type}, targetScreenId=${oldScreenId}`);
// 매핑에 있으면 업데이트
if (screenIdMapping.has(oldScreenId)) {
const newScreenId = screenIdMapping.get(oldScreenId)!;
console.log(`✅ 매핑 발견: ${oldScreenId}${newScreenId}`);
// properties 업데이트
properties.componentConfig.action.targetScreenId =
newScreenId.toString();
// 데이터베이스 업데이트
await query(
`UPDATE screen_layouts
SET properties = $1
WHERE layout_id = $2`,
[JSON.stringify(properties), layout.layout_id]
);
updateCount++;
console.log(
`🔗 버튼 targetScreenId 업데이트: ${oldScreenId}${newScreenId} (layout ${layout.layout_id})`
);
} else {
console.log(`⚠️ 매핑 없음: ${oldScreenId} (업데이트 건너뜀)`);
}
}
}
} catch (error) {
console.warn(`❌ 레이아웃 ${layout.layout_id} 업데이트 오류:`, error);
// 개별 레이아웃 오류는 무시하고 계속 진행
}
}
console.log(`✅ 총 ${updateCount}개 버튼의 targetScreenId 업데이트 완료`);
return updateCount;
}
}
// 서비스 인스턴스 export

View File

@@ -640,6 +640,339 @@ class TableCategoryValueService {
children: this.buildHierarchy(values, v.valueId!),
}));
}
// ================================================
// 컬럼 매핑 관련 메서드 (논리명 ↔ 물리명)
// ================================================
/**
* 컬럼 매핑 조회
*
* @param tableName - 테이블명
* @param menuObjid - 메뉴 OBJID
* @param companyCode - 회사 코드
* @returns { logical_column: physical_column } 형태의 매핑 객체
*/
async getColumnMapping(
tableName: string,
menuObjid: number,
companyCode: string
): Promise<Record<string, string>> {
const pool = getPool();
try {
logger.info("컬럼 매핑 조회", { tableName, menuObjid, companyCode });
// 멀티테넌시 적용
let query: string;
let params: any[];
if (companyCode === "*") {
// 최고 관리자: 모든 매핑 조회 가능
query = `
SELECT
logical_column_name AS "logicalColumnName",
physical_column_name AS "physicalColumnName"
FROM category_column_mapping
WHERE table_name = $1
AND menu_objid = $2
`;
params = [tableName, menuObjid];
} else {
// 일반 회사: 자신의 매핑만 조회
query = `
SELECT
logical_column_name AS "logicalColumnName",
physical_column_name AS "physicalColumnName"
FROM category_column_mapping
WHERE table_name = $1
AND menu_objid = $2
AND company_code = $3
`;
params = [tableName, menuObjid, companyCode];
}
const result = await pool.query(query, params);
// { logical_column: physical_column } 형태로 변환
const mapping: Record<string, string> = {};
result.rows.forEach((row: any) => {
mapping[row.logicalColumnName] = row.physicalColumnName;
});
logger.info(`컬럼 매핑 ${Object.keys(mapping).length}개 조회 완료`, {
tableName,
menuObjid,
companyCode,
});
return mapping;
} catch (error: any) {
logger.error(`컬럼 매핑 조회 실패: ${error.message}`);
throw error;
}
}
/**
* 컬럼 매핑 생성/수정
*
* @param tableName - 테이블명
* @param logicalColumnName - 논리적 컬럼명
* @param physicalColumnName - 물리적 컬럼명
* @param menuObjid - 메뉴 OBJID
* @param companyCode - 회사 코드
* @param userId - 사용자 ID
* @param description - 설명 (선택사항)
*/
async createColumnMapping(
tableName: string,
logicalColumnName: string,
physicalColumnName: string,
menuObjid: number,
companyCode: string,
userId: string,
description?: string
): Promise<any> {
const pool = getPool();
try {
logger.info("컬럼 매핑 생성", {
tableName,
logicalColumnName,
physicalColumnName,
menuObjid,
companyCode,
});
// 1. 물리적 컬럼이 실제로 존재하는지 확인
const columnCheckQuery = `
SELECT column_name
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = $1
AND column_name = $2
`;
const columnCheck = await pool.query(columnCheckQuery, [
tableName,
physicalColumnName,
]);
if (columnCheck.rowCount === 0) {
throw new Error(
`테이블 ${tableName}에 컬럼 ${physicalColumnName}이(가) 존재하지 않습니다`
);
}
// 2. 매핑 저장 (UPSERT)
const insertQuery = `
INSERT INTO category_column_mapping (
table_name,
logical_column_name,
physical_column_name,
menu_objid,
company_code,
description,
created_by,
updated_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (table_name, logical_column_name, menu_objid, company_code)
DO UPDATE SET
physical_column_name = EXCLUDED.physical_column_name,
description = EXCLUDED.description,
updated_at = NOW(),
updated_by = EXCLUDED.updated_by
RETURNING *
`;
const result = await pool.query(insertQuery, [
tableName,
logicalColumnName,
physicalColumnName,
menuObjid,
companyCode,
description || null,
userId,
userId,
]);
logger.info("컬럼 매핑 생성 완료", {
mappingId: result.rows[0].mapping_id,
tableName,
logicalColumnName,
physicalColumnName,
});
return result.rows[0];
} catch (error: any) {
logger.error(`컬럼 매핑 생성 실패: ${error.message}`);
throw error;
}
}
/**
* 논리적 컬럼 목록 조회
*
* @param tableName - 테이블명
* @param menuObjid - 메뉴 OBJID
* @param companyCode - 회사 코드
* @returns 논리적 컬럼 목록
*/
async getLogicalColumns(
tableName: string,
menuObjid: number,
companyCode: string
): Promise<any[]> {
const pool = getPool();
try {
logger.info("논리적 컬럼 목록 조회", {
tableName,
menuObjid,
companyCode,
});
// 멀티테넌시 적용
let query: string;
let params: any[];
if (companyCode === "*") {
// 최고 관리자: 모든 논리적 컬럼 조회
query = `
SELECT
mapping_id AS "mappingId",
logical_column_name AS "logicalColumnName",
physical_column_name AS "physicalColumnName",
description
FROM category_column_mapping
WHERE table_name = $1
AND menu_objid = $2
ORDER BY logical_column_name
`;
params = [tableName, menuObjid];
} else {
// 일반 회사: 자신의 논리적 컬럼만 조회
query = `
SELECT
mapping_id AS "mappingId",
logical_column_name AS "logicalColumnName",
physical_column_name AS "physicalColumnName",
description
FROM category_column_mapping
WHERE table_name = $1
AND menu_objid = $2
AND company_code = $3
ORDER BY logical_column_name
`;
params = [tableName, menuObjid, companyCode];
}
const result = await pool.query(query, params);
logger.info(`논리적 컬럼 ${result.rows.length}개 조회 완료`, {
tableName,
menuObjid,
companyCode,
});
return result.rows;
} catch (error: any) {
logger.error(`논리적 컬럼 목록 조회 실패: ${error.message}`);
throw error;
}
}
/**
* 컬럼 매핑 삭제
*
* @param mappingId - 매핑 ID
* @param companyCode - 회사 코드
*/
async deleteColumnMapping(
mappingId: number,
companyCode: string
): Promise<void> {
const pool = getPool();
try {
logger.info("컬럼 매핑 삭제", { mappingId, companyCode });
// 멀티테넌시 적용
let deleteQuery: string;
let deleteParams: any[];
if (companyCode === "*") {
// 최고 관리자: 모든 매핑 삭제 가능
deleteQuery = `
DELETE FROM category_column_mapping
WHERE mapping_id = $1
`;
deleteParams = [mappingId];
} else {
// 일반 회사: 자신의 매핑만 삭제 가능
deleteQuery = `
DELETE FROM category_column_mapping
WHERE mapping_id = $1
AND company_code = $2
`;
deleteParams = [mappingId, companyCode];
}
const result = await pool.query(deleteQuery, deleteParams);
if (result.rowCount === 0) {
throw new Error("컬럼 매핑을 찾을 수 없거나 권한이 없습니다");
}
logger.info("컬럼 매핑 삭제 완료", { mappingId, companyCode });
} catch (error: any) {
logger.error(`컬럼 매핑 삭제 실패: ${error.message}`);
throw error;
}
}
/**
* 논리적 컬럼명을 물리적 컬럼명으로 변환
*
* 데이터 저장 시 사용
*
* @param tableName - 테이블명
* @param menuObjid - 메뉴 OBJID
* @param companyCode - 회사 코드
* @param data - 논리적 컬럼명으로 된 데이터
* @returns 물리적 컬럼명으로 변환된 데이터
*/
async convertToPhysicalColumns(
tableName: string,
menuObjid: number,
companyCode: string,
data: Record<string, any>
): Promise<Record<string, any>> {
try {
// 컬럼 매핑 조회
const mapping = await this.getColumnMapping(tableName, menuObjid, companyCode);
// 논리적 컬럼명 → 물리적 컬럼명 변환
const physicalData: Record<string, any> = {};
for (const [key, value] of Object.entries(data)) {
const physicalColumn = mapping[key] || key; // 매핑 없으면 원래 이름 사용
physicalData[physicalColumn] = value;
}
logger.info("컬럼명 변환 완료", {
tableName,
menuObjid,
logicalColumns: Object.keys(data),
physicalColumns: Object.keys(physicalData),
});
return physicalData;
} catch (error: any) {
logger.error(`컬럼명 변환 실패: ${error.message}`);
// 매핑이 없으면 원본 데이터 그대로 반환
return data;
}
}
}
export default new TableCategoryValueService();