feat: 카테고리 컬럼 메뉴별 매핑 기능 구현

- category_column_mapping 테이블 생성 (마이그레이션 054)
- 테이블 타입 관리에서 2레벨 메뉴 선택 기능 추가
- 카테고리 컬럼 조회 시 현재 메뉴 및 상위 메뉴 매핑 자동 조회
- 캐시 무효화 로직 개선
- 메뉴별 카테고리 설정 저장 및 불러오기 기능 구현
This commit is contained in:
kjs
2025-11-13 14:41:24 +09:00
parent 9dc8a51f4c
commit 36bff64145
9 changed files with 779 additions and 94 deletions

View File

@@ -1514,6 +1514,7 @@ export class ScreenManagementService {
throw new Error("이미 할당된 화면입니다.");
}
// screen_menu_assignments에 할당 추가
await query(
`INSERT INTO screen_menu_assignments (
screen_id, menu_objid, company_code, display_order, created_by
@@ -1526,6 +1527,40 @@ export class ScreenManagementService {
assignmentData.createdBy || null,
]
);
// 화면 정보 조회 (screen_code 가져오기)
const screen = await queryOne<{ screen_code: string }>(
`SELECT screen_code FROM screen_definitions WHERE screen_id = $1`,
[screenId]
);
if (screen) {
// menu_info 테이블도 함께 업데이트 (menu_url과 screen_code 설정)
// 관리자 메뉴인지 확인
const menu = await queryOne<{ menu_type: string }>(
`SELECT menu_type FROM menu_info WHERE objid = $1`,
[assignmentData.menuObjid]
);
const isAdminMenu = menu && (menu.menu_type === "0" || menu.menu_type === "admin");
const menuUrl = isAdminMenu
? `/screens/${screenId}?mode=admin`
: `/screens/${screenId}`;
await query(
`UPDATE menu_info
SET menu_url = $1, screen_code = $2
WHERE objid = $3`,
[menuUrl, screen.screen_code, assignmentData.menuObjid]
);
logger.info("화면 할당 완료 (menu_info 업데이트)", {
screenId,
menuObjid: assignmentData.menuObjid,
menuUrl,
screenCode: screen.screen_code,
});
}
}
/**
@@ -1589,11 +1624,26 @@ export class ScreenManagementService {
menuObjid: number,
companyCode: string
): Promise<void> {
// screen_menu_assignments에서 할당 삭제
await query(
`DELETE FROM screen_menu_assignments
WHERE screen_id = $1 AND menu_objid = $2 AND company_code = $3`,
[screenId, menuObjid, companyCode]
);
// menu_info 테이블도 함께 업데이트 (menu_url과 screen_code 제거)
await query(
`UPDATE menu_info
SET menu_url = NULL, screen_code = NULL
WHERE objid = $1`,
[menuObjid]
);
logger.info("화면 할당 해제 완료 (menu_info 업데이트)", {
screenId,
menuObjid,
companyCode,
});
}
// ========================================

View File

@@ -973,6 +973,96 @@ class TableCategoryValueService {
return data;
}
}
/**
* 2레벨 메뉴 목록 조회
*
* 카테고리 컬럼 매핑 생성 시 메뉴 선택용
*
* @param companyCode - 회사 코드
* @returns 2레벨 메뉴 목록
*/
async getSecondLevelMenus(companyCode: string): Promise<any[]> {
const pool = getPool();
try {
logger.info("2레벨 메뉴 목록 조회", { companyCode });
// menu_info 테이블에 company_code 컬럼이 있는지 확인
const columnCheckQuery = `
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'menu_info' AND column_name = 'company_code'
`;
const columnCheck = await pool.query(columnCheckQuery);
const hasCompanyCode = columnCheck.rows.length > 0;
logger.info("menu_info 테이블 company_code 컬럼 존재 여부", { hasCompanyCode });
let query: string;
let params: any[];
if (!hasCompanyCode) {
// company_code 컬럼이 없는 경우: 모든 2레벨 사용자 메뉴 조회
query = `
SELECT
m1.objid as "menuObjid",
m1.menu_name_kor as "menuName",
m0.menu_name_kor as "parentMenuName",
m1.screen_code as "screenCode"
FROM menu_info m1
INNER JOIN menu_info m0 ON m1.parent_obj_id = m0.objid
WHERE m1.menu_type = 1
AND m1.status = 'active'
AND m0.parent_obj_id = 0
ORDER BY m0.seq, m1.seq
`;
params = [];
} else if (companyCode === "*") {
// 최고 관리자: 모든 회사의 2레벨 사용자 메뉴 조회
query = `
SELECT
m1.objid as "menuObjid",
m1.menu_name_kor as "menuName",
m0.menu_name_kor as "parentMenuName",
m1.screen_code as "screenCode"
FROM menu_info m1
INNER JOIN menu_info m0 ON m1.parent_obj_id = m0.objid
WHERE m1.menu_type = 1
AND m1.status = 'active'
AND m0.parent_obj_id = 0
ORDER BY m0.seq, m1.seq
`;
params = [];
} else {
// 일반 회사: 자신의 회사 메뉴만 조회 (공통 메뉴 제외)
query = `
SELECT
m1.objid as "menuObjid",
m1.menu_name_kor as "menuName",
m0.menu_name_kor as "parentMenuName",
m1.screen_code as "screenCode"
FROM menu_info m1
INNER JOIN menu_info m0 ON m1.parent_obj_id = m0.objid
WHERE m1.menu_type = 1
AND m1.status = 'active'
AND m0.parent_obj_id = 0
AND m1.company_code = $1
ORDER BY m0.seq, m1.seq
`;
params = [companyCode];
}
const result = await pool.query(query, params);
logger.info(`2레벨 메뉴 ${result.rows.length}개 조회 완료`, { companyCode });
return result.rows;
} catch (error: any) {
logger.error(`2레벨 메뉴 목록 조회 실패: ${error.message}`, { error });
throw error;
}
}
}
export default new TableCategoryValueService();

View File

@@ -249,21 +249,78 @@ export class TableManagementService {
[tableName, size, offset]
);
// 🆕 category_column_mapping 조회
const tableExistsResult = await query<any>(
`SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = 'category_column_mapping'
) as table_exists`
);
const mappingTableExists = tableExistsResult[0]?.table_exists === true;
let categoryMappings: Map<string, number[]> = new Map();
if (mappingTableExists && companyCode) {
logger.info("📥 getColumnList: 카테고리 매핑 조회 시작", { tableName, 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]
);
logger.info("✅ getColumnList: 카테고리 매핑 조회 완료", {
tableName,
companyCode,
mappingCount: mappings.length,
mappings: mappings
});
mappings.forEach((m: any) => {
if (!categoryMappings.has(m.columnName)) {
categoryMappings.set(m.columnName, []);
}
categoryMappings.get(m.columnName)!.push(Number(m.menuObjid));
});
logger.info("✅ getColumnList: categoryMappings Map 생성 완료", {
size: categoryMappings.size,
entries: Array.from(categoryMappings.entries())
});
}
// BigInt를 Number로 변환하여 JSON 직렬화 문제 해결
const columns: ColumnTypeInfo[] = rawColumns.map((column) => ({
...column,
maxLength: column.maxLength ? Number(column.maxLength) : null,
numericPrecision: column.numericPrecision
? Number(column.numericPrecision)
: null,
numericScale: column.numericScale ? Number(column.numericScale) : null,
displayOrder: column.displayOrder ? Number(column.displayOrder) : null,
// 자동 매핑: webType이 기본값('text')인 경우 DB 타입에 따라 자동 추론
webType:
column.webType === "text"
? this.inferWebType(column.dataType)
: column.webType,
}));
const columns: ColumnTypeInfo[] = rawColumns.map((column) => {
const baseColumn = {
...column,
maxLength: column.maxLength ? Number(column.maxLength) : null,
numericPrecision: column.numericPrecision
? Number(column.numericPrecision)
: null,
numericScale: column.numericScale ? Number(column.numericScale) : null,
displayOrder: column.displayOrder ? Number(column.displayOrder) : null,
// 자동 매핑: webType이 기본값('text')인 경우 DB 타입에 따라 자동 추론
webType:
column.webType === "text"
? this.inferWebType(column.dataType)
: column.webType,
};
// 카테고리 타입인 경우 categoryMenus 추가
if (column.inputType === "category" && categoryMappings.has(column.columnName)) {
const menus = categoryMappings.get(column.columnName);
logger.info(`✅ getColumnList: 컬럼 ${column.columnName}에 카테고리 메뉴 추가`, { menus });
return {
...baseColumn,
categoryMenus: menus,
};
}
return baseColumn;
});
const totalPages = Math.ceil(total / size);
@@ -429,7 +486,7 @@ export class TableManagementService {
// 캐시 무효화 - 해당 테이블의 컬럼 캐시 삭제
cache.deleteByPattern(`table_columns:${tableName}:`);
cache.delete(CacheKeys.TABLE_COLUMN_COUNT(tableName));
logger.info(`컬럼 설정 업데이트 완료: ${tableName}.${columnName}`);
} catch (error) {
logger.error(
@@ -484,7 +541,7 @@ export class TableManagementService {
cache.deleteByPattern(`table_columns:${tableName}:`);
cache.delete(CacheKeys.TABLE_COLUMN_COUNT(tableName));
logger.info(`전체 컬럼 설정 일괄 업데이트 완료: ${tableName}, company: ${companyCode}`);
logger.info(`전체 컬럼 설정 일괄 업데이트 완료: ${tableName}`);
} catch (error) {
logger.error(
`전체 컬럼 설정 일괄 업데이트 중 오류 발생: ${tableName}`,
@@ -3152,19 +3209,83 @@ export class TableManagementService {
[tableName, companyCode]
);
const inputTypes: ColumnTypeInfo[] = rawInputTypes.map((col) => ({
tableName: tableName,
columnName: col.columnName,
displayName: col.displayName,
dataType: col.dataType || "varchar",
inputType: col.inputType,
detailSettings: col.detailSettings,
description: "", // 필수 필드 추가
isNullable: col.isNullable === "Y" ? "Y" : "N", // 🔥 FIX: string 타입으로 변환
isPrimaryKey: false,
displayOrder: 0,
isVisible: true,
}));
// category_column_mapping 테이블 존재 여부 확인
const tableExistsResult = await query<any>(
`SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = 'category_column_mapping'
) as table_exists`
);
const mappingTableExists = tableExistsResult[0]?.table_exists === true;
// 카테고리 컬럼의 경우, 매핑된 메뉴 목록 조회
let categoryMappings: Map<string, number[]> = new Map();
if (mappingTableExists) {
logger.info("카테고리 매핑 조회 시작", { tableName, 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]
);
logger.info("카테고리 매핑 조회 완료", {
tableName,
companyCode,
mappingCount: mappings.length,
mappings: mappings
});
mappings.forEach((m: any) => {
if (!categoryMappings.has(m.columnName)) {
categoryMappings.set(m.columnName, []);
}
categoryMappings.get(m.columnName)!.push(Number(m.menuObjid));
});
logger.info("categoryMappings Map 생성 완료", {
size: categoryMappings.size,
entries: Array.from(categoryMappings.entries())
});
} else {
logger.warn("category_column_mapping 테이블이 존재하지 않음");
}
const inputTypes: ColumnTypeInfo[] = rawInputTypes.map((col) => {
const baseInfo = {
tableName: tableName,
columnName: col.columnName,
displayName: col.displayName,
dataType: col.dataType || "varchar",
inputType: col.inputType,
detailSettings: col.detailSettings,
description: "", // 필수 필드 추가
isNullable: col.isNullable === "Y" ? "Y" : "N", // 🔥 FIX: string 타입으로 변환
isPrimaryKey: false,
displayOrder: 0,
isVisible: true,
};
// 카테고리 타입인 경우 categoryMenus 추가
if (col.inputType === "category" && categoryMappings.has(col.columnName)) {
const menus = categoryMappings.get(col.columnName);
logger.info(`✅ 컬럼 ${col.columnName}에 카테고리 메뉴 추가`, { menus });
return {
...baseInfo,
categoryMenus: menus,
};
}
if (col.inputType === "category") {
logger.warn(`⚠️ 카테고리 컬럼 ${col.columnName}에 매핑 없음`);
}
return baseInfo;
});
logger.info(
`컬럼 입력타입 정보 조회 완료: ${tableName}, company: ${companyCode}, ${inputTypes.length}개 컬럼`