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

@@ -471,3 +471,33 @@ export const deleteColumnMapping = async (req: AuthenticatedRequest, res: Respon
}
};
/**
* 2레벨 메뉴 목록 조회
*
* GET /api/categories/second-level-menus
*
* 카테고리 컬럼 매핑 생성 시 메뉴 선택용
* 2레벨 메뉴를 선택하면 해당 메뉴의 모든 하위 메뉴에서 사용 가능
*/
export const getSecondLevelMenus = async (req: AuthenticatedRequest, res: Response) => {
try {
const companyCode = req.user!.companyCode;
logger.info("2레벨 메뉴 목록 조회", { companyCode });
const menus = await tableCategoryValueService.getSecondLevelMenus(companyCode);
return res.json({
success: true,
data: menus,
});
} catch (error: any) {
logger.error(`2레벨 메뉴 목록 조회 실패: ${error.message}`);
return res.status(500).json({
success: false,
message: "2레벨 메뉴 목록 조회 중 오류가 발생했습니다",
error: error.message,
});
}
};

View File

@@ -1657,37 +1657,108 @@ export async function getCategoryColumnsByMenu(
return;
}
// 3. 테이블들의 카테고리 타입 컬럼 조회 (테이블 라벨 포함)
logger.info("🔍 카테고리 컬럼 쿼리 준비", { tableNames, companyCode });
const columnsQuery = `
SELECT
ttc.table_name AS "tableName",
COALESCE(
tl.table_label,
initcap(replace(ttc.table_name, '_', ' '))
) AS "tableLabel",
ttc.column_name AS "columnName",
COALESCE(
cl.column_label,
initcap(replace(ttc.column_name, '_', ' '))
) AS "columnLabel",
ttc.input_type AS "inputType"
FROM table_type_columns ttc
LEFT JOIN column_labels cl
ON ttc.table_name = cl.table_name
AND ttc.column_name = cl.column_name
LEFT JOIN table_labels tl
ON ttc.table_name = tl.table_name
WHERE ttc.table_name = ANY($1)
AND ttc.company_code = $2
AND ttc.input_type = 'category'
ORDER BY ttc.table_name, ttc.column_name
`;
logger.info("🔍 카테고리 컬럼 쿼리 실행 중...");
const columnsResult = await pool.query(columnsQuery, [tableNames, companyCode]);
logger.info("✅ 카테고리 컬럼 쿼리 완료", { rowCount: columnsResult.rows.length });
// 3. category_column_mapping 테이블 존재 여부 확인
const tableExistsResult = await pool.query(`
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = 'category_column_mapping'
) as table_exists
`);
const mappingTableExists = tableExistsResult.rows[0]?.table_exists === true;
let columnsResult;
if (mappingTableExists) {
// 🆕 category_column_mapping을 사용한 필터링
logger.info("🔍 category_column_mapping 기반 카테고리 컬럼 조회", { menuObjid, companyCode });
// 현재 메뉴와 모든 상위 메뉴의 objid 조회 (재귀)
const ancestorMenuQuery = `
WITH RECURSIVE menu_hierarchy AS (
-- 현재 메뉴
SELECT objid, parent_obj_id, menu_type
FROM menu_info
WHERE objid = $1
UNION ALL
-- 부모 메뉴 재귀 조회
SELECT m.objid, m.parent_obj_id, m.menu_type
FROM menu_info m
INNER JOIN menu_hierarchy mh ON m.objid = mh.parent_obj_id
WHERE m.parent_obj_id != 0 -- 최상위 메뉴(parent_obj_id=0) 제외
)
SELECT ARRAY_AGG(objid) as menu_objids
FROM menu_hierarchy
`;
const ancestorMenuResult = await pool.query(ancestorMenuQuery, [parseInt(menuObjid)]);
const ancestorMenuObjids = ancestorMenuResult.rows[0]?.menu_objids || [parseInt(menuObjid)];
const columnsQuery = `
SELECT DISTINCT
ttc.table_name AS "tableName",
COALESCE(
tl.table_label,
initcap(replace(ttc.table_name, '_', ' '))
) AS "tableLabel",
ccm.logical_column_name AS "columnName",
COALESCE(
cl.column_label,
initcap(replace(ccm.logical_column_name, '_', ' '))
) AS "columnLabel",
ttc.input_type AS "inputType"
FROM category_column_mapping ccm
INNER JOIN table_type_columns ttc
ON ccm.table_name = ttc.table_name
AND ccm.physical_column_name = ttc.column_name
LEFT JOIN column_labels cl
ON ttc.table_name = cl.table_name
AND ttc.column_name = cl.column_name
LEFT JOIN table_labels tl
ON ttc.table_name = tl.table_name
WHERE ccm.table_name = ANY($1)
AND ccm.company_code = $2
AND ccm.menu_objid = ANY($3)
AND ttc.input_type = 'category'
ORDER BY ttc.table_name, ccm.logical_column_name
`;
columnsResult = await pool.query(columnsQuery, [tableNames, companyCode, ancestorMenuObjids]);
logger.info("✅ category_column_mapping 기반 조회 완료", { rowCount: columnsResult.rows.length });
} else {
// 🔄 기존 방식: table_type_columns에서 모든 카테고리 컬럼 조회
logger.info("🔍 레거시 방식: table_type_columns 기반 카테고리 컬럼 조회", { tableNames, companyCode });
const columnsQuery = `
SELECT
ttc.table_name AS "tableName",
COALESCE(
tl.table_label,
initcap(replace(ttc.table_name, '_', ' '))
) AS "tableLabel",
ttc.column_name AS "columnName",
COALESCE(
cl.column_label,
initcap(replace(ttc.column_name, '_', ' '))
) AS "columnLabel",
ttc.input_type AS "inputType"
FROM table_type_columns ttc
LEFT JOIN column_labels cl
ON ttc.table_name = cl.table_name
AND ttc.column_name = cl.column_name
LEFT JOIN table_labels tl
ON ttc.table_name = tl.table_name
WHERE ttc.table_name = ANY($1)
AND ttc.company_code = $2
AND ttc.input_type = 'category'
ORDER BY ttc.table_name, ttc.column_name
`;
columnsResult = await pool.query(columnsQuery, [tableNames, companyCode]);
logger.info("✅ 레거시 방식 조회 완료", { rowCount: columnsResult.rows.length });
}
logger.info("✅ 카테고리 컬럼 조회 완료", {
columnCount: columnsResult.rows.length

View File

@@ -11,6 +11,7 @@ import {
createColumnMapping,
getLogicalColumns,
deleteColumnMapping,
getSecondLevelMenus,
} from "../controllers/tableCategoryValueController";
import { authenticateToken } from "../middleware/authMiddleware";
@@ -44,6 +45,9 @@ router.post("/values/reorder", reorderCategoryValues);
// 컬럼 매핑 관련 라우트 (논리명 ↔ 물리명)
// ================================================
// 2레벨 메뉴 목록 조회 (메뉴 선택용)
router.get("/second-level-menus", getSecondLevelMenus);
// 컬럼 매핑 조회
router.get("/column-mapping/:tableName/:menuObjid", getColumnMapping);

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}개 컬럼`