diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index f2378fe1..e5a3c7b3 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -2595,6 +2595,72 @@ export const createCompany = async ( } }; +/** + * GET /api/admin/companies/:companyCode + * 회사 정보 조회 API + */ +export const getCompanyByCode = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode } = req.params; + + logger.info("회사 정보 조회 요청", { + companyCode, + user: req.user, + }); + + // Raw Query로 회사 정보 조회 + const company = await queryOne( + `SELECT * FROM company_mng WHERE company_code = $1`, + [companyCode] + ); + + if (!company) { + res.status(404).json({ + success: false, + message: "해당 회사를 찾을 수 없습니다.", + errorCode: "COMPANY_NOT_FOUND", + }); + return; + } + + logger.info("회사 정보 조회 성공", { + companyCode: company.company_code, + companyName: company.company_name, + }); + + const response = { + success: true, + message: "회사 정보 조회 성공", + data: { + companyCode: company.company_code, + companyName: company.company_name, + businessRegistrationNumber: company.business_registration_number, + representativeName: company.representative_name, + representativePhone: company.representative_phone, + email: company.email, + website: company.website, + address: company.address, + status: company.status, + writer: company.writer, + regdate: company.regdate, + }, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("회사 정보 조회 실패", { error, companyCode: req.params.companyCode }); + res.status(500).json({ + success: false, + message: "회사 정보 조회 중 오류가 발생했습니다.", + errorCode: "COMPANY_GET_ERROR", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}; + /** * PUT /api/admin/companies/:companyCode * 회사 정보 수정 API diff --git a/backend-node/src/controllers/screenManagementController.ts b/backend-node/src/controllers/screenManagementController.ts index 95277664..dd589fdd 100644 --- a/backend-node/src/controllers/screenManagementController.ts +++ b/backend-node/src/controllers/screenManagementController.ts @@ -5,11 +5,23 @@ import { AuthenticatedRequest } from "../types/auth"; // 화면 목록 조회 export const getScreens = async (req: AuthenticatedRequest, res: Response) => { try { - const { companyCode } = req.user as any; - const { page = 1, size = 20, searchTerm } = req.query; + const userCompanyCode = (req.user as any).companyCode; + const { page = 1, size = 20, searchTerm, companyCode } = req.query; + + // 쿼리 파라미터로 companyCode가 전달되면 해당 회사의 화면 조회 (최고 관리자 전용) + // 아니면 현재 사용자의 companyCode 사용 + const targetCompanyCode = (companyCode as string) || userCompanyCode; + + // 최고 관리자가 아닌 경우 자신의 회사 코드만 사용 가능 + if (userCompanyCode !== "*" && targetCompanyCode !== userCompanyCode) { + return res.status(403).json({ + success: false, + message: "다른 회사의 화면을 조회할 권한이 없습니다.", + }); + } const result = await screenManagementService.getScreensByCompany( - companyCode, + targetCompanyCode, parseInt(page as string), parseInt(size as string) ); @@ -325,7 +337,118 @@ export const bulkPermanentDeleteScreens = async ( } }; -// 화면 복사 +// 연결된 모달 화면 감지 (화면 복사 전 확인) +export const detectLinkedScreens = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { id } = req.params; + + const linkedScreens = await screenManagementService.detectLinkedModalScreens( + parseInt(id) + ); + + res.json({ + success: true, + data: linkedScreens, + message: linkedScreens.length > 0 + ? `${linkedScreens.length}개의 연결된 모달 화면을 감지했습니다.` + : "연결된 모달 화면이 없습니다.", + }); + } catch (error: any) { + console.error("연결된 화면 감지 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "연결된 화면 감지에 실패했습니다.", + }); + } +}; + +// 화면명 중복 체크 +export const checkDuplicateScreenName = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode, screenName } = req.body; + + if (!companyCode || !screenName) { + res.status(400).json({ + success: false, + message: "companyCode와 screenName은 필수입니다.", + }); + return; + } + + const isDuplicate = + await screenManagementService.checkDuplicateScreenName( + companyCode, + screenName + ); + + res.json({ + success: true, + data: { isDuplicate }, + message: isDuplicate + ? "이미 존재하는 화면명입니다." + : "사용 가능한 화면명입니다.", + }); + } catch (error: any) { + console.error("화면명 중복 체크 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "화면명 중복 체크에 실패했습니다.", + }); + } +}; + +// 화면 일괄 복사 (메인 + 모달 화면들) +export const copyScreenWithModals = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { id } = req.params; + const { mainScreen, modalScreens, targetCompanyCode } = req.body; + const { companyCode, userId } = req.user as any; + + if (!mainScreen || !mainScreen.screenName || !mainScreen.screenCode) { + res.status(400).json({ + success: false, + message: "메인 화면 정보(screenName, screenCode)가 필요합니다.", + }); + return; + } + + const result = await screenManagementService.copyScreenWithModals({ + sourceScreenId: parseInt(id), + companyCode, + userId, + targetCompanyCode, // 최고 관리자가 다른 회사로 복사할 때 사용 + mainScreen: { + screenName: mainScreen.screenName, + screenCode: mainScreen.screenCode, + description: mainScreen.description, + }, + modalScreens: modalScreens || [], + }); + + res.json({ + success: true, + data: result, + message: `화면 복사가 완료되었습니다. (메인 1개 + 모달 ${result.modalScreens.length}개)`, + }); + } catch (error: any) { + console.error("화면 일괄 복사 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "화면 일괄 복사에 실패했습니다.", + }); + } +}; + +// 화면 복사 (단일 - 하위 호환용) export const copyScreen = async ( req: AuthenticatedRequest, res: Response @@ -495,6 +618,50 @@ export const generateScreenCode = async ( } }; +// 여러 개의 화면 코드 일괄 생성 +export const generateMultipleScreenCodes = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { companyCode, count } = req.body; + + if (!companyCode || typeof companyCode !== "string") { + res.status(400).json({ + success: false, + message: "회사 코드(companyCode)는 필수입니다.", + }); + return; + } + + if (!count || typeof count !== "number" || count < 1 || count > 100) { + res.status(400).json({ + success: false, + message: "count는 1~100 사이의 숫자여야 합니다.", + }); + return; + } + + const screenCodes = + await screenManagementService.generateMultipleScreenCodes( + companyCode, + count + ); + + res.json({ + success: true, + data: { screenCodes }, + message: `${count}개의 화면 코드가 생성되었습니다.`, + }); + } catch (error: any) { + console.error("화면 코드 일괄 생성 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "화면 코드 일괄 생성에 실패했습니다.", + }); + } +}; + // 화면-메뉴 할당 export const assignScreenToMenu = async ( req: AuthenticatedRequest, diff --git a/backend-node/src/controllers/tableCategoryValueController.ts b/backend-node/src/controllers/tableCategoryValueController.ts index ffb6a5a4..6d0172c9 100644 --- a/backend-node/src/controllers/tableCategoryValueController.ts +++ b/backend-node/src/controllers/tableCategoryValueController.ts @@ -268,3 +268,206 @@ export const reorderCategoryValues = async (req: AuthenticatedRequest, res: Resp } }; +// ================================================ +// 컬럼 매핑 관련 API (논리명 ↔ 물리명) +// ================================================ + +/** + * 컬럼 매핑 조회 + * + * GET /api/categories/column-mapping/:tableName/:menuObjid + * + * 특정 테이블과 메뉴에 대한 논리적 컬럼명 → 물리적 컬럼명 매핑을 조회합니다. + * + * @returns { logical_column: physical_column } 형태의 매핑 객체 + */ +export const getColumnMapping = async (req: AuthenticatedRequest, res: Response) => { + try { + const companyCode = req.user!.companyCode; + const { tableName, menuObjid } = req.params; + + if (!tableName || !menuObjid) { + return res.status(400).json({ + success: false, + message: "tableName과 menuObjid는 필수입니다", + }); + } + + logger.info("컬럼 매핑 조회", { + tableName, + menuObjid, + companyCode, + }); + + const mapping = await tableCategoryValueService.getColumnMapping( + tableName, + Number(menuObjid), + companyCode + ); + + return res.json({ + success: true, + data: mapping, + }); + } catch (error: any) { + logger.error(`컬럼 매핑 조회 실패: ${error.message}`); + return res.status(500).json({ + success: false, + message: "컬럼 매핑 조회 중 오류가 발생했습니다", + error: error.message, + }); + } +}; + +/** + * 컬럼 매핑 생성/수정 + * + * POST /api/categories/column-mapping + * + * Body: + * - tableName: 테이블명 + * - logicalColumnName: 논리적 컬럼명 (예: status_stock) + * - physicalColumnName: 물리적 컬럼명 (예: status) + * - menuObjid: 메뉴 OBJID + * - description: 설명 (선택사항) + */ +export const createColumnMapping = async (req: AuthenticatedRequest, res: Response) => { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { + tableName, + logicalColumnName, + physicalColumnName, + menuObjid, + description, + } = req.body; + + // 입력 검증 + if (!tableName || !logicalColumnName || !physicalColumnName || !menuObjid) { + return res.status(400).json({ + success: false, + message: "tableName, logicalColumnName, physicalColumnName, menuObjid는 필수입니다", + }); + } + + logger.info("컬럼 매핑 생성", { + tableName, + logicalColumnName, + physicalColumnName, + menuObjid, + companyCode, + }); + + const mapping = await tableCategoryValueService.createColumnMapping( + tableName, + logicalColumnName, + physicalColumnName, + Number(menuObjid), + companyCode, + userId, + description + ); + + return res.status(201).json({ + success: true, + data: mapping, + message: "컬럼 매핑이 생성되었습니다", + }); + } catch (error: any) { + logger.error(`컬럼 매핑 생성 실패: ${error.message}`); + return res.status(500).json({ + success: false, + message: error.message || "컬럼 매핑 생성 중 오류가 발생했습니다", + error: error.message, + }); + } +}; + +/** + * 논리적 컬럼 목록 조회 + * + * GET /api/categories/logical-columns/:tableName/:menuObjid + * + * 특정 테이블과 메뉴에 대한 논리적 컬럼 목록을 조회합니다. + * (카테고리 값 추가 시 컬럼 선택용) + */ +export const getLogicalColumns = async (req: AuthenticatedRequest, res: Response) => { + try { + const companyCode = req.user!.companyCode; + const { tableName, menuObjid } = req.params; + + if (!tableName || !menuObjid) { + return res.status(400).json({ + success: false, + message: "tableName과 menuObjid는 필수입니다", + }); + } + + logger.info("논리적 컬럼 목록 조회", { + tableName, + menuObjid, + companyCode, + }); + + const columns = await tableCategoryValueService.getLogicalColumns( + tableName, + Number(menuObjid), + companyCode + ); + + return res.json({ + success: true, + data: columns, + }); + } catch (error: any) { + logger.error(`논리적 컬럼 목록 조회 실패: ${error.message}`); + return res.status(500).json({ + success: false, + message: "논리적 컬럼 목록 조회 중 오류가 발생했습니다", + error: error.message, + }); + } +}; + +/** + * 컬럼 매핑 삭제 + * + * DELETE /api/categories/column-mapping/:mappingId + */ +export const deleteColumnMapping = async (req: AuthenticatedRequest, res: Response) => { + try { + const companyCode = req.user!.companyCode; + const { mappingId } = req.params; + + if (!mappingId) { + return res.status(400).json({ + success: false, + message: "mappingId는 필수입니다", + }); + } + + logger.info("컬럼 매핑 삭제", { + mappingId, + companyCode, + }); + + await tableCategoryValueService.deleteColumnMapping( + Number(mappingId), + companyCode + ); + + return res.json({ + success: true, + message: "컬럼 매핑이 삭제되었습니다", + }); + } catch (error: any) { + logger.error(`컬럼 매핑 삭제 실패: ${error.message}`); + return res.status(500).json({ + success: false, + message: error.message || "컬럼 매핑 삭제 중 오류가 발생했습니다", + error: error.message, + }); + } +}; + diff --git a/backend-node/src/routes/adminRoutes.ts b/backend-node/src/routes/adminRoutes.ts index c9449e94..378a38d9 100644 --- a/backend-node/src/routes/adminRoutes.ts +++ b/backend-node/src/routes/adminRoutes.ts @@ -19,6 +19,7 @@ import { saveUser, // 사용자 등록/수정 getCompanyList, getCompanyListFromDB, // 실제 DB에서 회사 목록 조회 + getCompanyByCode, // 회사 단건 조회 createCompany, // 회사 등록 updateCompany, // 회사 수정 deleteCompany, // 회사 삭제 @@ -60,6 +61,7 @@ router.get("/departments", getDepartmentList); // 부서 목록 조회 // 회사 관리 API router.get("/companies", getCompanyList); router.get("/companies/db", getCompanyListFromDB); // 실제 DB에서 회사 목록 조회 +router.get("/companies/:companyCode", getCompanyByCode); // 회사 단건 조회 router.post("/companies", createCompany); // 회사 등록 router.put("/companies/:companyCode", updateCompany); // 회사 수정 router.delete("/companies/:companyCode", deleteCompany); // 회사 삭제 diff --git a/backend-node/src/routes/screenManagementRoutes.ts b/backend-node/src/routes/screenManagementRoutes.ts index e307ccc5..4207c719 100644 --- a/backend-node/src/routes/screenManagementRoutes.ts +++ b/backend-node/src/routes/screenManagementRoutes.ts @@ -13,13 +13,17 @@ import { permanentDeleteScreen, getDeletedScreens, bulkPermanentDeleteScreens, + detectLinkedScreens, + checkDuplicateScreenName, copyScreen, + copyScreenWithModals, getTables, getTableInfo, getTableColumns, saveLayout, getLayout, generateScreenCode, + generateMultipleScreenCodes, assignScreenToMenu, getScreensByMenu, unassignScreenFromMenu, @@ -40,7 +44,10 @@ router.put("/screens/:id", updateScreen); router.put("/screens/:id/info", updateScreenInfo); // 화면 정보만 수정 router.get("/screens/:id/dependencies", checkScreenDependencies); // 의존성 체크 router.delete("/screens/:id", deleteScreen); // 휴지통으로 이동 -router.post("/screens/:id/copy", copyScreen); +router.get("/screens/:id/linked-modals", detectLinkedScreens); // 연결된 모달 화면 감지 +router.post("/screens/check-duplicate-name", checkDuplicateScreenName); // 화면명 중복 체크 +router.post("/screens/:id/copy", copyScreen); // 단일 화면 복사 (하위 호환용) +router.post("/screens/:id/copy-with-modals", copyScreenWithModals); // 메인 + 모달 일괄 복사 // 휴지통 관리 router.get("/screens/trash/list", getDeletedScreens); // 휴지통 화면 목록 @@ -51,6 +58,9 @@ router.delete("/screens/trash/bulk", bulkPermanentDeleteScreens); // 일괄 영 // 화면 코드 자동 생성 router.get("/generate-screen-code/:companyCode", generateScreenCode); +// 여러 개의 화면 코드 일괄 생성 +router.post("/generate-screen-codes", generateMultipleScreenCodes); + // 테이블 관리 router.get("/tables", getTables); router.get("/tables/:tableName", getTableInfo); // 특정 테이블 정보 조회 (최적화) diff --git a/backend-node/src/routes/tableCategoryValueRoutes.ts b/backend-node/src/routes/tableCategoryValueRoutes.ts index e20374d0..436966e7 100644 --- a/backend-node/src/routes/tableCategoryValueRoutes.ts +++ b/backend-node/src/routes/tableCategoryValueRoutes.ts @@ -7,6 +7,10 @@ import { deleteCategoryValue, bulkDeleteCategoryValues, reorderCategoryValues, + getColumnMapping, + createColumnMapping, + getLogicalColumns, + deleteColumnMapping, } from "../controllers/tableCategoryValueController"; import { authenticateToken } from "../middleware/authMiddleware"; @@ -36,5 +40,21 @@ router.post("/values/bulk-delete", bulkDeleteCategoryValues); // 카테고리 값 순서 변경 router.post("/values/reorder", reorderCategoryValues); +// ================================================ +// 컬럼 매핑 관련 라우트 (논리명 ↔ 물리명) +// ================================================ + +// 컬럼 매핑 조회 +router.get("/column-mapping/:tableName/:menuObjid", getColumnMapping); + +// 논리적 컬럼 목록 조회 +router.get("/logical-columns/:tableName/:menuObjid", getLogicalColumns); + +// 컬럼 매핑 생성/수정 +router.post("/column-mapping", createColumnMapping); + +// 컬럼 매핑 삭제 +router.delete("/column-mapping/:mappingId", deleteColumnMapping); + export default router; diff --git a/backend-node/src/services/roleService.ts b/backend-node/src/services/roleService.ts index 403a1e46..abf19f40 100644 --- a/backend-node/src/services/roleService.ts +++ b/backend-node/src/services/roleService.ts @@ -539,29 +539,43 @@ export class RoleService { /** * 전체 메뉴 목록 조회 (권한 설정용) - */ - /** - * 전체 메뉴 목록 조회 (권한 설정용) + * + * @param companyCode - 회사 코드 + * - undefined: 최고 관리자 - 모든 회사의 모든 메뉴 조회 + * - "*": 최고 관리자의 공통 메뉴만 조회 (최고 관리자 전용) + * - "COMPANY_X": 해당 회사 메뉴만 조회 (공통 메뉴 제외) + * + * 중요: + * - 공통 메뉴(company_code = "*")는 최고 관리자 전용 메뉴입니다. + * - menu_type = 2 (화면)는 제외하고 메뉴만 조회합니다. */ static async getAllMenus(companyCode?: string): Promise { 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, diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 85081cd3..8ee3b9a4 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -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 { - // 해당 회사의 기존 화면 코드들 조회 (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 { + 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 { + const result = await query( + `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( + `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(); + + // 각 레이아웃에서 버튼의 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( + `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( - `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( `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( `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 = 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 + ): Promise { + console.log(`🔍 updateButtonTargetScreenIds 호출: screenId=${screenId}, 매핑 개수=${screenIdMapping.size}`); + + // 화면의 모든 레이아웃 조회 + const layouts = await query( + `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 diff --git a/backend-node/src/services/tableCategoryValueService.ts b/backend-node/src/services/tableCategoryValueService.ts index e60d6cd2..1a162e78 100644 --- a/backend-node/src/services/tableCategoryValueService.ts +++ b/backend-node/src/services/tableCategoryValueService.ts @@ -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> { + 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 = {}; + 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 { + 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 { + 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 { + 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 + ): Promise> { + try { + // 컬럼 매핑 조회 + const mapping = await this.getColumnMapping(tableName, menuObjid, companyCode); + + // 논리적 컬럼명 → 물리적 컬럼명 변환 + const physicalData: Record = {}; + 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(); diff --git a/docs/카테고리_메뉴별_컬럼_분리_구현_완료_보고서.md b/docs/카테고리_메뉴별_컬럼_분리_구현_완료_보고서.md new file mode 100644 index 00000000..45a45275 --- /dev/null +++ b/docs/카테고리_메뉴별_컬럼_분리_구현_완료_보고서.md @@ -0,0 +1,634 @@ +# 카테고리 메뉴별 컬럼 분리 구현 완료 보고서 + +## 📋 개요 + +**문제**: 같은 테이블의 같은 컬럼을 서로 다른 메뉴에서 다른 카테고리 값으로 사용하고 싶은 경우 지원 불가 + +**해결**: 가상 컬럼 분리 (Virtual Column Mapping) 방식 구현 + +**구현 날짜**: 2025-11-13 + +--- + +## ✅ 구현 완료 항목 + +### 1. 데이터베이스 스키마 + +#### `category_column_mapping` 테이블 생성 ✅ + +**파일**: `db/migrations/054_create_category_column_mapping.sql` + +```sql +CREATE TABLE category_column_mapping ( + mapping_id SERIAL PRIMARY KEY, + table_name VARCHAR(100) NOT NULL, + logical_column_name VARCHAR(100) NOT NULL, -- 논리적 컬럼명 + physical_column_name VARCHAR(100) NOT NULL, -- 물리적 컬럼명 + menu_objid NUMERIC NOT NULL, + company_code VARCHAR(20) NOT NULL, + description TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + created_by VARCHAR(50), + updated_by VARCHAR(50), + + CONSTRAINT uk_mapping UNIQUE(table_name, logical_column_name, menu_objid, company_code) +); +``` + +**인덱스**: +- `idx_mapping_table_menu`: 조회 성능 최적화 +- `idx_mapping_company`: 멀티테넌시 필터링 + +### 2. 백엔드 API 구현 + +#### 컨트롤러 (tableCategoryValueController.ts) ✅ + +구현된 API 엔드포인트: + +| 메서드 | 경로 | 설명 | +|--------|------|------| +| GET | `/table-categories/column-mapping/:tableName/:menuObjid` | 컬럼 매핑 조회 | +| POST | `/table-categories/column-mapping` | 컬럼 매핑 생성/수정 | +| GET | `/table-categories/logical-columns/:tableName/:menuObjid` | 논리적 컬럼 목록 조회 | +| DELETE | `/table-categories/column-mapping/:mappingId` | 컬럼 매핑 삭제 | + +**멀티테넌시 지원**: +- ✅ 최고 관리자(`company_code = "*"`): 모든 매핑 조회/수정 가능 +- ✅ 일반 회사: 자신의 매핑만 조회/수정 가능 + +#### 서비스 (tableCategoryValueService.ts) ✅ + +구현된 주요 메서드: + +1. `getColumnMapping()`: 논리명 → 물리명 매핑 조회 +2. `createColumnMapping()`: 컬럼 매핑 생성 (UPSERT) +3. `getLogicalColumns()`: 논리적 컬럼 목록 조회 +4. `deleteColumnMapping()`: 컬럼 매핑 삭제 +5. `convertToPhysicalColumns()`: 데이터 저장 시 자동 변환 + +**물리적 컬럼 존재 검증**: +```typescript +const columnCheckQuery = ` + SELECT column_name + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = $1 + AND column_name = $2 +`; +``` + +**UPSERT 지원**: +```sql +INSERT INTO category_column_mapping (...) +VALUES (...) +ON CONFLICT (table_name, logical_column_name, menu_objid, company_code) +DO UPDATE SET ... +``` + +### 3. 프론트엔드 API 클라이언트 + +#### `frontend/lib/api/tableCategoryValue.ts` ✅ + +구현된 함수: + +```typescript +// 컬럼 매핑 조회 +getColumnMapping(tableName: string, menuObjid: number) + +// 논리적 컬럼 목록 조회 +getLogicalColumns(tableName: string, menuObjid: number) + +// 컬럼 매핑 생성 +createColumnMapping(data: { + tableName: string; + logicalColumnName: string; + physicalColumnName: string; + menuObjid: number; + description?: string; +}) + +// 컬럼 매핑 삭제 +deleteColumnMapping(mappingId: number) +``` + +**에러 처리**: +- 네트워크 오류 시 `{ success: false, error: message }` 반환 +- 콘솔 로그로 디버깅 정보 출력 + +### 4. 프론트엔드 UI 컴포넌트 + +#### `AddCategoryColumnDialog.tsx` ✅ + +**기능**: +- 논리적 컬럼명 입력 +- 물리적 컬럼 선택 (드롭다운) +- 설명 입력 (선택사항) +- 적용 메뉴 표시 (읽기 전용) + +**검증 로직**: +- 논리적 컬럼명 필수 체크 +- 물리적 컬럼 선택 필수 체크 +- 중복 매핑 방지 + +**shadcn/ui 스타일 가이드 준수**: +- ✅ 반응형 크기: `max-w-[95vw] sm:max-w-[500px]` +- ✅ 텍스트 크기: `text-xs sm:text-sm` +- ✅ 입력 필드: `h-8 sm:h-10` +- ✅ 버튼 레이아웃: `flex-1` (모바일), `flex-none` (데스크톱) + +--- + +## 🔄 작동 방식 + +### 예시: item_info.status 컬럼 분리 + +#### 1단계: 컬럼 매핑 생성 + +``` +기준정보 > 품목정보 (menu_objid=103) + 논리적 컬럼: status_stock + 물리적 컬럼: status + 카테고리: "정상", "대기", "품절" + +영업관리 > 판매품목정보 (menu_objid=203) + 논리적 컬럼: status_sales + 물리적 컬럼: status + 카테고리: "판매중", "판매중지", "품절" +``` + +#### 2단계: 카테고리 값 저장 + +```sql +-- table_column_category_values 테이블 +INSERT INTO table_column_category_values +(table_name, column_name, value_code, value_label, menu_objid) +VALUES +('item_info', 'status_stock', 'NORMAL', '정상', 103), +('item_info', 'status_sales', 'ON_SALE', '판매중', 203); +``` + +#### 3단계: 데이터 입력 (자동 변환) + +**사용자 입력 (논리적 컬럼명)**: +```typescript +{ + item_name: "키보드", + status_stock: "정상" // 논리적 컬럼명 +} +``` + +**백엔드에서 자동 변환 (물리적 컬럼명)**: +```typescript +// convertToPhysicalColumns() 호출 +{ + item_name: "키보드", + status: "정상" // 물리적 컬럼명 +} +``` + +**DB에 저장**: +```sql +INSERT INTO item_info (item_name, status, company_code) +VALUES ('키보드', '정상', 'COMPANY_A'); +``` + +#### 4단계: 데이터 조회 (자동 매핑) + +**DB 쿼리 결과**: +```typescript +{ + item_name: "키보드", + status: "정상" // 물리적 컬럼명 +} +``` + +**프론트엔드 표시 (논리적 컬럼명으로 자동 매핑)**: +```typescript +// 기준정보 > 품목정보에서 보기 +{ + item_name: "키보드", + status_stock: "정상" // 논리적 컬럼명 +} + +// 영업관리 > 판매품목정보에서 보기 +{ + item_name: "마우스", + status_sales: "판매중" // 다른 논리적 컬럼명 +} +``` + +--- + +## 📊 데이터 흐름도 + +``` +┌────────────────────────────────────────────────────┐ +│ 프론트엔드 (UI) │ +├────────────────────────────────────────────────────┤ +│ 기준정보 > 품목정보 │ +│ - status_stock: "정상", "대기", "품절" │ +│ │ +│ 영업관리 > 판매품목정보 │ +│ - status_sales: "판매중", "판매중지", "품절" │ +└─────────────────┬──────────────────────────────────┘ + │ (논리적 컬럼명 사용) + ↓ +┌────────────────────────────────────────────────────┐ +│ category_column_mapping (매핑 테이블) │ +├────────────────────────────────────────────────────┤ +│ status_stock → status (menu_objid=103) │ +│ status_sales → status (menu_objid=203) │ +└─────────────────┬──────────────────────────────────┘ + │ (자동 변환) + ↓ +┌────────────────────────────────────────────────────┐ +│ item_info 테이블 (실제 DB) │ +├────────────────────────────────────────────────────┤ +│ item_name │ status (물리적 컬럼 - 하나만 존재) │ +│ 키보드 │ 정상 │ +│ 마우스 │ 판매중 │ +└────────────────────────────────────────────────────┘ +``` + +--- + +## 🎯 구현 효과 + +### 1. 문제 해결 ✅ + +**Before (문제)**: +``` +기준정보 > 품목정보: status = "정상", "대기", "품절" +영업관리 > 판매품목정보: status = "판매중", "판매중지", "품절" +→ 같은 컬럼이라 불가능! +``` + +**After (해결)**: +``` +기준정보 > 품목정보: status_stock = "정상", "대기", "품절" +영업관리 > 판매품목정보: status_sales = "판매중", "판매중지", "품절" +→ 논리적으로 분리되어 가능! +``` + +### 2. 사용자 경험 개선 + +- ✅ 메뉴별 맞춤형 카테고리 관리 +- ✅ 직관적인 논리적 컬럼명 사용 +- ✅ 관리자가 UI에서 쉽게 설정 가능 +- ✅ 불필요한 카테고리가 표시되지 않음 + +### 3. 시스템 안정성 + +- ✅ 데이터베이스 스키마 변경 최소화 +- ✅ 기존 데이터 마이그레이션 불필요 +- ✅ 물리적 컬럼 존재 검증으로 오류 방지 +- ✅ 멀티테넌시 완벽 지원 + +### 4. 확장성 + +- ✅ 새로운 메뉴 추가 시 독립적인 카테고리 설정 가능 +- ✅ 다른 컴포넌트에도 유사한 패턴 적용 가능 +- ✅ 메뉴별 카테고리 통계 및 분석 가능 + +--- + +## 🚀 사용 방법 + +### 관리자 작업 흐름 + +#### 1. 테이블 타입 관리 접속 +``` +메뉴: 시스템 관리 > 테이블 타입 관리 +``` + +#### 2. 카테고리 컬럼 추가 +``` +1. 테이블 선택: item_info +2. "카테고리 컬럼 추가" 버튼 클릭 +3. 실제 컬럼 선택: status +4. 논리적 컬럼명 입력: status_stock +5. 설명 입력: "재고 관리용 상태" +6. "추가" 버튼 클릭 +``` + +#### 3. 카테고리 값 추가 +``` +1. 논리적 컬럼 선택: status_stock +2. "카테고리 값 추가" 버튼 클릭 +3. 라벨 입력: "정상", "대기", "품절" +4. 각각 추가 +``` + +#### 4. 다른 메뉴에 대해 반복 +``` +1. 영업관리 > 판매품목정보 선택 +2. 논리적 컬럼명: status_sales +3. 카테고리 값: "판매중", "판매중지", "품절" +``` + +### 사용자 화면에서 확인 + +``` +기준정보 > 품목정보 + → status_stock 필드가 표시됨 + → 드롭다운: "정상", "대기", "품절" + +영업관리 > 판매품목정보 + → status_sales 필드가 표시됨 + → 드롭다운: "판매중", "판매중지", "품절" +``` + +--- + +## 🔧 실행 방법 + +### 1. 데이터베이스 마이그레이션 + +```sql +-- pgAdmin 또는 psql에서 실행 +\i db/migrations/054_create_category_column_mapping.sql +``` + +**결과 확인**: +```sql +-- 테이블 생성 확인 +SELECT * FROM category_column_mapping LIMIT 5; + +-- 인덱스 확인 +SELECT indexname FROM pg_indexes +WHERE tablename = 'category_column_mapping'; +``` + +### 2. 백엔드 재시작 (불필요) + +프로젝트 규칙에 따라 **백엔드 재시작 금지** +- 타입스크립트 파일 변경만으로 자동 반영됨 +- 라우트 등록 완료됨 + +### 3. 프론트엔드 확인 + +```bash +# 프론트엔드만 재시작 (필요 시) +cd frontend +npm run dev +``` + +--- + +## 🧪 테스트 시나리오 + +### 시나리오 1: 기본 매핑 생성 + +1. **테이블 타입 관리 접속** +2. **item_info 테이블 선택** +3. **"카테고리 컬럼 추가" 클릭** +4. **입력**: + - 실제 컬럼: `status` + - 논리적 컬럼명: `status_stock` + - 설명: "재고 관리용 상태" +5. **"추가" 클릭** +6. **확인**: 매핑이 생성되었는지 확인 + +**예상 결과**: +- ✅ 성공 토스트 메시지 표시 +- ✅ 논리적 컬럼 목록에 `status_stock` 추가됨 +- ✅ DB에 매핑 레코드 생성 + +### 시나리오 2: 카테고리 값 추가 + +1. **논리적 컬럼 `status_stock` 선택** +2. **"카테고리 값 추가" 클릭** +3. **입력**: + - 라벨: `정상` + - 코드: 자동 생성 +4. **"추가" 클릭** +5. **반복**: "대기", "품절" 추가 + +**예상 결과**: +- ✅ 각 카테고리 값이 `status_stock` 컬럼에 연결됨 +- ✅ `menu_objid`가 올바르게 설정됨 + +### 시나리오 3: 다른 메뉴에 다른 매핑 + +1. **영업관리 > 판매품목정보 메뉴 선택** +2. **item_info 테이블 선택** +3. **"카테고리 컬럼 추가" 클릭** +4. **입력**: + - 실제 컬럼: `status` (동일한 물리적 컬럼) + - 논리적 컬럼명: `status_sales` (다른 논리명) + - 설명: "판매 관리용 상태" +5. **카테고리 값 추가**: "판매중", "판매중지", "품절" + +**예상 결과**: +- ✅ 기준정보 > 품목정보: `status_stock` 표시 +- ✅ 영업관리 > 판매품목정보: `status_sales` 표시 +- ✅ 서로 다른 카테고리 값 리스트 + +### 시나리오 4: 데이터 저장 및 조회 + +1. **기준정보 > 품목정보에서 데이터 입력** + - 품목명: "키보드" + - status_stock: "정상" +2. **저장** +3. **DB 확인**: + ```sql + SELECT item_name, status FROM item_info WHERE item_name = '키보드'; + -- 결과: status = '정상' (물리적 컬럼) + ``` +4. **영업관리 > 판매품목정보에서 조회** + - status_sales 필드로 표시되지 않음 (다른 논리명) + +**예상 결과**: +- ✅ 논리적 컬럼명으로 입력 +- ✅ 물리적 컬럼명으로 저장 +- ✅ 메뉴별 독립적인 카테고리 표시 + +--- + +## 📝 주의사항 + +### 1. 기존 데이터 호환성 + +**기존에 물리적 컬럼명을 직접 사용하던 경우**: +- 마이그레이션 스크립트가 자동으로 기본 매핑 생성 +- `logical_column_name = physical_column_name`으로 설정 +- 기존 기능 유지됨 + +### 2. 성능 고려사항 + +**컬럼 매핑 조회**: +- 인덱스 활용으로 빠른 조회 +- 첫 조회 후 캐싱 권장 (향후 개선) + +**데이터 저장 시 변환**: +- 매번 매핑 조회 발생 +- 트랜잭션 내에서 처리하여 성능 영향 최소화 + +### 3. 에러 처리 + +**물리적 컬럼 없음**: +``` +에러 메시지: "테이블 item_info에 컬럼 status2가 존재하지 않습니다" +해결: 올바른 컬럼명 선택 +``` + +**논리적 컬럼명 중복**: +``` +에러 메시지: "중복된 키 값이 고유 제약조건을 위반합니다" +해결: 다른 논리적 컬럼명 사용 +``` + +--- + +## 🔍 디버깅 가이드 + +### 백엔드 로그 확인 + +```bash +# 로그 파일 위치 +tail -f backend-node/logs/app.log + +# 컬럼 매핑 조회 로그 +"컬럼 매핑 조회" { tableName, menuObjid, companyCode } + +# 컬럼 매핑 생성 로그 +"컬럼 매핑 생성 완료" { mappingId, tableName, logicalColumnName } +``` + +### 프론트엔드 콘솔 확인 + +```javascript +// 브라우저 개발자 도구 > 콘솔 +"논리적 컬럼 목록 조회 시작: item_info, 103" +"컬럼 매핑 조회 완료: { status_stock: 'status' }" +``` + +### 데이터베이스 쿼리 + +```sql +-- 모든 매핑 확인 +SELECT * FROM category_column_mapping +WHERE table_name = 'item_info' +ORDER BY menu_objid, logical_column_name; + +-- 특정 메뉴의 매핑 +SELECT + logical_column_name, + physical_column_name, + description +FROM category_column_mapping +WHERE table_name = 'item_info' + AND menu_objid = 103; + +-- 카테고리 값과 매핑 조인 +SELECT + ccm.logical_column_name, + ccm.physical_column_name, + tccv.value_label +FROM category_column_mapping ccm +JOIN table_column_category_values tccv + ON ccm.table_name = tccv.table_name + AND ccm.logical_column_name = tccv.column_name + AND ccm.menu_objid = tccv.menu_objid +WHERE ccm.table_name = 'item_info' + AND ccm.menu_objid = 103; +``` + +--- + +## 🎓 추가 참고 자료 + +### 관련 문서 +- [카테고리 메뉴스코프 개선 계획서](카테고리_메뉴스코프_개선_계획서.md) +- [카테고리 메뉴별 컬럼 분리 전략](카테고리_메뉴별_컬럼_분리_전략.md) + +### 주요 파일 위치 +- 마이그레이션: `db/migrations/054_create_category_column_mapping.sql` +- 컨트롤러: `backend-node/src/controllers/tableCategoryValueController.ts` +- 서비스: `backend-node/src/services/tableCategoryValueService.ts` +- 라우트: `backend-node/src/routes/tableCategoryValueRoutes.ts` +- API 클라이언트: `frontend/lib/api/tableCategoryValue.ts` +- UI 컴포넌트: `frontend/components/table-category/AddCategoryColumnDialog.tsx` + +--- + +## ✅ 체크리스트 + +### 개발 완료 +- [x] `category_column_mapping` 테이블 생성 +- [x] 백엔드: 컬럼 매핑 조회 API +- [x] 백엔드: 컬럼 매핑 생성 API +- [x] 백엔드: 논리적 컬럼 목록 조회 API +- [x] 백엔드: 컬럼 매핑 삭제 API +- [x] 백엔드: 데이터 저장 시 자동 변환 로직 +- [x] 프론트엔드: API 클라이언트 함수 +- [x] 프론트엔드: AddCategoryColumnDialog 컴포넌트 + +### 테스트 필요 (향후) +- [ ] 시나리오 1: 기본 매핑 생성 +- [ ] 시나리오 2: 카테고리 값 추가 +- [ ] 시나리오 3: 다른 메뉴에 다른 매핑 +- [ ] 시나리오 4: 데이터 저장 및 조회 +- [ ] 브라우저 테스트 (Chrome, Safari, Edge) +- [ ] 모바일 반응형 테스트 + +--- + +## 🚧 향후 개선 사항 + +### Phase 2 (권장) +1. **캐싱 메커니즘** + - 컬럼 매핑을 메모리에 캐싱 + - 변경 시에만 재조회 + - 성능 개선 + +2. **UI 개선** + - CategoryValueAddDialog에 논리적 컬럼 선택 기능 추가 + - 매핑 관리 전용 UI 페이지 + - 벌크 매핑 생성 기능 + +3. **관리 기능** + - 매핑 사용 현황 통계 + - 미사용 매핑 자동 정리 + - 매핑 복제 기능 (다른 메뉴로) + +### Phase 3 (선택) +4. **고급 기능** + - 매핑 버전 관리 + - 매핑 변경 이력 추적 + - 매핑 검증 도구 + +--- + +## 📞 문의 및 지원 + +**문제 발생 시**: +1. 로그 파일 확인 (backend-node/logs/app.log) +2. 브라우저 콘솔 확인 (개발자 도구) +3. 데이터베이스 쿼리로 직접 확인 + +**추가 개발 요청**: +- 새로운 기능 제안 +- 버그 리포트 +- 성능 개선 제안 + +--- + +## 🎉 결론 + +**가상 컬럼 분리 (Virtual Column Mapping) 방식**을 성공적으로 구현하여, 같은 물리적 컬럼을 메뉴별로 다른 카테고리로 사용할 수 있게 되었습니다. + +**핵심 장점**: +- ✅ 데이터베이스 스키마 변경 최소화 +- ✅ 메뉴별 완전히 독립적인 카테고리 관리 +- ✅ 자동 변환으로 개발자 부담 감소 +- ✅ 멀티테넌시 완벽 지원 + +**실무 적용**: +- 테이블 타입 관리에서 바로 사용 가능 +- 기존 기능과 완전히 호환 +- 확장성 있는 아키텍처 + +이 시스템을 통해 사용자는 메뉴별로 맞춤형 카테고리를 쉽게 관리할 수 있으며, 관리자는 유연하게 카테고리를 설정할 수 있습니다. + diff --git a/docs/카테고리_메뉴별_컬럼_분리_전략.md b/docs/카테고리_메뉴별_컬럼_분리_전략.md new file mode 100644 index 00000000..7f2d1ab7 --- /dev/null +++ b/docs/카테고리_메뉴별_컬럼_분리_전략.md @@ -0,0 +1,905 @@ +# 카테고리 메뉴별 컬럼 분리 전략 + +## 1. 문제 정의 + +### 상황 +같은 테이블(`item_info`)의 같은 컬럼(`status`)을 서로 다른 메뉴에서 다른 카테고리 값으로 사용하고 싶은 경우 + +**예시**: +``` +기준정보 > 품목정보 (menu_objid=103) + - status 컬럼: "정상", "대기", "품절" + +영업관리 > 판매품목정보 (menu_objid=203) + - status 컬럼: "판매중", "판매중지", "품절" +``` + +### 현재 문제점 +- `table_column_category_values` 테이블 구조: + - `table_name` + `column_name` + `menu_objid` 조합으로 카테고리 값 저장 + - 같은 테이블, 같은 컬럼, 다른 메뉴 = 서로 다른 카테고리 값 사용 가능 + - **하지만 실제 DB 컬럼은 하나뿐!** + +--- + +## 2. 해결 방안 비교 + +### 방안 A: 가상 컬럼 분리 (Virtual Column Mapping) ⭐ **추천** + +**개념**: 물리적으로는 같은 `status` 컬럼이지만, 메뉴별로 **논리적으로 다른 컬럼명**을 사용 + +#### 장점 +- ✅ 데이터베이스 스키마 변경 불필요 +- ✅ 기존 데이터 마이그레이션 불필요 +- ✅ 메뉴별 완전히 독립적인 카테고리 관리 +- ✅ 유연한 확장 가능 + +#### 단점 +- ⚠️ 컬럼 매핑 관리 필요 (논리명 → 물리명) +- ⚠️ UI에서 가상 컬럼 개념 이해 필요 + +#### 구현 방식 + +**데이터베이스**: +```sql +-- table_column_category_values 테이블 사용 +-- column_name을 "논리적 컬럼명"으로 저장 + +-- 기준정보 > 품목정보 +INSERT INTO table_column_category_values +(table_name, column_name, value_code, value_label, menu_objid) +VALUES +('item_info', 'status_stock', 'NORMAL', '정상', 103), +('item_info', 'status_stock', 'PENDING', '대기', 103), +('item_info', 'status_stock', 'OUT_OF_STOCK', '품절', 103); + +-- 영업관리 > 판매품목정보 +INSERT INTO table_column_category_values +(table_name, column_name, value_code, value_label, menu_objid) +VALUES +('item_info', 'status_sales', 'ON_SALE', '판매중', 203), +('item_info', 'status_sales', 'DISCONTINUED', '판매중지', 203), +('item_info', 'status_sales', 'OUT_OF_STOCK', '품절', 203); +``` + +**컬럼 매핑 테이블** (새로 생성): +```sql +CREATE TABLE category_column_mapping ( + mapping_id SERIAL PRIMARY KEY, + table_name VARCHAR(100) NOT NULL, + logical_column_name VARCHAR(100) NOT NULL, -- status_stock, status_sales + physical_column_name VARCHAR(100) NOT NULL, -- status (실제 DB 컬럼) + menu_objid NUMERIC NOT NULL, + company_code VARCHAR(20) NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + + UNIQUE(table_name, logical_column_name, menu_objid, company_code) +); + +-- 예시 데이터 +INSERT INTO category_column_mapping +(table_name, logical_column_name, physical_column_name, menu_objid, company_code) +VALUES +('item_info', 'status_stock', 'status', 103, 'COMPANY_A'), +('item_info', 'status_sales', 'status', 203, 'COMPANY_A'); +``` + +**프론트엔드 UI**: +```typescript +// 테이블 타입 관리에서 카테고리 컬럼 추가 시 +function AddCategoryColumn({ tableName, menuObjid }: Props) { + const [logicalColumnName, setLogicalColumnName] = useState(""); + const [physicalColumnName, setPhysicalColumnName] = useState(""); + + return ( + + + + 카테고리 컬럼 추가 + + +
+ {/* 실제 DB 컬럼 선택 */} +
+ + +
+ + {/* 논리적 컬럼명 입력 */} +
+ + setLogicalColumnName(e.target.value)} + placeholder="예: status_stock, status_sales" + /> +

+ 이 메뉴에서만 사용할 고유한 이름을 입력하세요 +

+
+ + {/* 적용할 메뉴 표시 (읽기 전용) */} +
+ + +
+
+ + + + +
+
+ ); +} +``` + +**데이터 저장 시 매핑 적용**: +```typescript +// InteractiveScreenViewer.tsx +async function saveData(formData: any) { + const companyCode = user.companyCode; + const menuObjid = screenConfig.menuObjid; + + // 논리적 컬럼명 → 물리적 컬럼명 매핑 + const mappingResponse = await apiClient.get( + `/api/categories/column-mapping/${tableName}/${menuObjid}` + ); + + const columnMapping = mappingResponse.data.data; // { status_sales: "status" } + + // formData를 물리적 컬럼명으로 변환 + const physicalData = {}; + for (const [logicalCol, value] of Object.entries(formData)) { + const physicalCol = columnMapping[logicalCol] || logicalCol; + physicalData[physicalCol] = value; + } + + // 실제 DB 저장 + await apiClient.post(`/api/data/${tableName}`, physicalData); +} +``` + +--- + +### 방안 B: 물리적 컬럼 분리 (Physical Column Separation) + +**개념**: 실제로 테이블에 `status_stock`, `status_sales` 같은 별도 컬럼 생성 + +#### 장점 +- ✅ 단순하고 직관적 +- ✅ 매핑 로직 불필요 + +#### 단점 +- ❌ 데이터베이스 스키마 변경 필요 +- ❌ 기존 데이터 마이그레이션 필요 +- ❌ 컬럼 추가마다 DDL 실행 필요 +- ❌ 유연성 부족 + +#### 구현 방식 + +**데이터베이스 스키마 변경**: +```sql +-- item_info 테이블에 컬럼 추가 +ALTER TABLE item_info +ADD COLUMN status_stock VARCHAR(50), +ADD COLUMN status_sales VARCHAR(50); + +-- 기존 데이터 마이그레이션 +UPDATE item_info +SET + status_stock = status, -- 기본값으로 복사 + status_sales = status; +``` + +**단점이 명확함**: +- 메뉴가 추가될 때마다 컬럼 추가 필요 +- 테이블 구조가 복잡해짐 +- 유지보수 어려움 + +--- + +### 방안 C: 현재 구조 유지 (Same Column, Different Values) + +**개념**: 같은 `status` 컬럼을 사용하되, 메뉴별로 다른 카테고리 값만 표시 + +#### 장점 +- ✅ 가장 단순한 구조 +- ✅ 추가 개발 불필요 + +#### 단점 +- ❌ **데이터 정합성 문제**: 실제 DB에는 하나의 값만 저장 가능 +- ❌ 메뉴별로 다른 값을 저장할 수 없음 + +#### 예시 (문제 발생) +``` +item_info 테이블의 실제 데이터: +item_id | status +--------|-------- +1 | "NORMAL" (기준정보에서 입력) +2 | "ON_SALE" (영업관리에서 입력) + +→ 기준정보에서 item_id=2를 볼 때 "ON_SALE"이 뭔지 모름 (정의되지 않은 값) +``` + +**결론**: 이 방안은 **불가능**합니다. + +--- + +## 3. 최종 추천 방안 + +### 🏆 방안 A: 가상 컬럼 분리 (Virtual Column Mapping) + +**이유**: +1. 데이터베이스 스키마 변경 없음 +2. 메뉴별 완전히 독립적인 카테고리 관리 +3. 실제 데이터 저장 시 물리적 컬럼으로 자동 매핑 +4. 확장성과 유연성 확보 + +**핵심 개념**: +- **논리적 컬럼명**: UI와 카테고리 설정에서 사용 (`status_stock`, `status_sales`) +- **물리적 컬럼명**: 실제 DB 저장 시 사용 (`status`) +- **매핑 테이블**: 논리명과 물리명을 연결 + +--- + +## 4. 구현 계획 + +### Phase 1: 데이터베이스 스키마 추가 + +#### 4.1 컬럼 매핑 테이블 생성 + +```sql +-- db/migrations/054_create_category_column_mapping.sql + +CREATE TABLE category_column_mapping ( + mapping_id SERIAL PRIMARY KEY, + table_name VARCHAR(100) NOT NULL, + logical_column_name VARCHAR(100) NOT NULL COMMENT '논리적 컬럼명 (UI에서 사용)', + physical_column_name VARCHAR(100) NOT NULL COMMENT '물리적 컬럼명 (실제 DB 컬럼)', + menu_objid NUMERIC NOT NULL, + company_code VARCHAR(20) NOT NULL, + description TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + created_by VARCHAR(50), + updated_by VARCHAR(50), + + CONSTRAINT fk_mapping_company FOREIGN KEY (company_code) + REFERENCES company_info(company_code), + CONSTRAINT fk_mapping_menu FOREIGN KEY (menu_objid) + REFERENCES menu_info(objid), + CONSTRAINT uk_mapping UNIQUE(table_name, logical_column_name, menu_objid, company_code) +); + +CREATE INDEX idx_mapping_table_menu ON category_column_mapping(table_name, menu_objid); +CREATE INDEX idx_mapping_company ON category_column_mapping(company_code); + +COMMENT ON TABLE category_column_mapping IS '카테고리 컬럼의 논리명-물리명 매핑'; +COMMENT ON COLUMN category_column_mapping.logical_column_name IS '메뉴별 카테고리 컬럼의 논리적 이름 (예: status_stock)'; +COMMENT ON COLUMN category_column_mapping.physical_column_name IS '실제 테이블의 물리적 컬럼 이름 (예: status)'; +``` + +#### 4.2 기존 카테고리 컬럼 마이그레이션 (선택사항) + +```sql +-- 기존에 직접 물리적 컬럼명을 사용하던 경우 매핑 생성 +INSERT INTO category_column_mapping +(table_name, logical_column_name, physical_column_name, menu_objid, company_code) +SELECT DISTINCT + table_name, + column_name, -- 기존에는 논리명=물리명 + column_name, + menu_objid, + company_code +FROM table_column_category_values +WHERE menu_objid IS NOT NULL +ON CONFLICT (table_name, logical_column_name, menu_objid, company_code) DO NOTHING; +``` + +### Phase 2: 백엔드 API 구현 + +#### 2.1 컬럼 매핑 API + +**파일**: `backend-node/src/controllers/categoryController.ts` + +```typescript +/** + * 메뉴별 컬럼 매핑 조회 + * + * @param tableName - 테이블명 + * @param menuObjid - 메뉴 OBJID + * @returns { logical_column: physical_column } 매핑 + */ +export async function getColumnMapping(req: Request, res: Response) { + const { tableName, menuObjid } = req.params; + const companyCode = req.user!.companyCode; + + const query = ` + SELECT + logical_column_name, + physical_column_name, + description + FROM category_column_mapping + WHERE table_name = $1 + AND menu_objid = $2 + AND company_code = $3 + `; + + const result = await pool.query(query, [tableName, menuObjid, companyCode]); + + // { status_stock: "status", status_sales: "status" } 형태로 변환 + const mapping: Record = {}; + result.rows.forEach((row) => { + mapping[row.logical_column_name] = row.physical_column_name; + }); + + logger.info("컬럼 매핑 조회", { + tableName, + menuObjid, + companyCode, + mappingCount: Object.keys(mapping).length, + }); + + return res.json({ + success: true, + data: mapping, + }); +} + +/** + * 컬럼 매핑 생성 + */ +export async function createColumnMapping(req: Request, res: Response) { + const companyCode = req.user!.companyCode; + const { + tableName, + logicalColumnName, + physicalColumnName, + menuObjid, + description, + } = req.body; + + // 입력 검증 + if (!tableName || !logicalColumnName || !physicalColumnName || !menuObjid) { + return res.status(400).json({ + success: false, + message: "필수 필드가 누락되었습니다", + }); + } + + // 물리적 컬럼이 실제로 존재하는지 확인 + 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) { + return res.status(400).json({ + success: false, + message: `테이블 ${tableName}에 컬럼 ${physicalColumnName}이(가) 존재하지 않습니다`, + }); + } + + // 매핑 저장 + const query = ` + 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(query, [ + tableName, + logicalColumnName, + physicalColumnName, + menuObjid, + companyCode, + description || null, + req.user!.userId, + req.user!.userId, + ]); + + logger.info("컬럼 매핑 생성", { + tableName, + logicalColumnName, + physicalColumnName, + menuObjid, + companyCode, + }); + + return res.json({ + success: true, + data: result.rows[0], + }); +} +``` + +**라우트 등록**: +```typescript +router.get("/api/categories/column-mapping/:tableName/:menuObjid", authenticate, getColumnMapping); +router.post("/api/categories/column-mapping", authenticate, createColumnMapping); +``` + +#### 2.2 데이터 저장 시 매핑 적용 + +**파일**: `backend-node/src/controllers/dataController.ts` + +```typescript +/** + * 데이터 저장 시 논리적 컬럼명 → 물리적 컬럼명 변환 + */ +export async function saveData(req: Request, res: Response) { + const { tableName } = req.params; + const { menuObjid, data } = req.body; + const companyCode = req.user!.companyCode; + + // 1. 컬럼 매핑 조회 + const mappingQuery = ` + SELECT logical_column_name, physical_column_name + FROM category_column_mapping + WHERE table_name = $1 + AND menu_objid = $2 + AND company_code = $3 + `; + + const mappingResult = await pool.query(mappingQuery, [tableName, menuObjid, companyCode]); + + const mapping: Record = {}; + mappingResult.rows.forEach((row) => { + mapping[row.logical_column_name] = row.physical_column_name; + }); + + // 2. 논리적 컬럼명 → 물리적 컬럼명 변환 + const physicalData: Record = {}; + for (const [key, value] of Object.entries(data)) { + const physicalColumn = mapping[key] || key; // 매핑 없으면 원래 이름 사용 + physicalData[physicalColumn] = value; + } + + // 3. 실제 데이터 저장 + const columns = Object.keys(physicalData); + const values = Object.values(physicalData); + const placeholders = columns.map((_, i) => `$${i + 1}`).join(", "); + + const insertQuery = ` + INSERT INTO ${tableName} (${columns.join(", ")}, company_code) + VALUES (${placeholders}, $${columns.length + 1}) + RETURNING * + `; + + const result = await pool.query(insertQuery, [...values, companyCode]); + + logger.info("데이터 저장 (컬럼 매핑 적용)", { + tableName, + menuObjid, + logicalColumns: Object.keys(data), + physicalColumns: columns, + }); + + return res.json({ + success: true, + data: result.rows[0], + }); +} +``` + +### Phase 3: 프론트엔드 UI 구현 + +#### 3.1 테이블 타입 관리: 논리적 컬럼 추가 + +**파일**: `frontend/components/admin/table-type-management/AddCategoryColumnDialog.tsx` + +```typescript +interface AddCategoryColumnDialogProps { + tableName: string; + menuObjid: number; + menuName: string; + onSuccess: () => void; +} + +export function AddCategoryColumnDialog({ + tableName, + menuObjid, + menuName, + onSuccess, +}: AddCategoryColumnDialogProps) { + const [open, setOpen] = useState(false); + const [physicalColumns, setPhysicalColumns] = useState([]); + const [logicalColumnName, setLogicalColumnName] = useState(""); + const [physicalColumnName, setPhysicalColumnName] = useState(""); + const [description, setDescription] = useState(""); + + // 테이블의 실제 컬럼 목록 조회 + useEffect(() => { + if (open) { + async function loadColumns() { + const response = await apiClient.get(`/api/tables/${tableName}/columns`); + if (response.data.success) { + setPhysicalColumns(response.data.data.map((col: any) => col.column_name)); + } + } + loadColumns(); + } + }, [open, tableName]); + + const handleSave = async () => { + // 1. 컬럼 매핑 생성 + const mappingResponse = await apiClient.post("/api/categories/column-mapping", { + tableName, + logicalColumnName, + physicalColumnName, + menuObjid, + description, + }); + + if (!mappingResponse.data.success) { + toast.error("컬럼 매핑 생성 실패"); + return; + } + + toast.success("논리적 컬럼이 추가되었습니다"); + setOpen(false); + onSuccess(); + }; + + return ( + + + + + + + + + 카테고리 컬럼 추가 + + + 같은 물리적 컬럼을 여러 메뉴에서 다른 카테고리로 사용할 수 있습니다 + + + +
+ {/* 적용 메뉴 (읽기 전용) */} +
+ + +
+ + {/* 실제 컬럼 선택 */} +
+ + +

+ 테이블의 실제 컬럼명 +

+
+ + {/* 논리적 컬럼명 입력 */} +
+ + setLogicalColumnName(e.target.value)} + placeholder="예: status_stock, status_sales" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +

+ 이 메뉴에서만 사용할 고유한 이름을 입력하세요 +

+
+ + {/* 설명 (선택사항) */} +
+ +