import { Request, Response } from "express"; import { Client } from "pg"; import { logger } from "../utils/logger"; import { AuthenticatedRequest } from "../types/auth"; import { ApiResponse } from "../types/common"; import { TableManagementService } from "../services/tableManagementService"; import { TableInfo, ColumnTypeInfo, ColumnSettings, TableListResponse, ColumnListResponse, ColumnSettingsResponse, } from "../types/tableManagement"; import { query } from "../database/db"; import { auditLogService, getClientIp } from "../services/auditLogService"; /** * 테이블 목록 조회 */ export async function getTableList( req: AuthenticatedRequest, res: Response ): Promise { try { logger.info("=== 테이블 목록 조회 시작 ==="); const tableManagementService = new TableManagementService(); const tableList = await tableManagementService.getTableList(); logger.info(`테이블 목록 조회 결과: ${tableList.length}개`); const response: ApiResponse = { success: true, message: "테이블 목록을 성공적으로 조회했습니다.", data: tableList, }; res.status(200).json(response); } catch (error) { logger.error("테이블 목록 조회 중 오류 발생:", error); const response: ApiResponse = { success: false, message: "테이블 목록 조회 중 오류가 발생했습니다.", error: { code: "TABLE_LIST_ERROR", details: error instanceof Error ? error.message : "Unknown error", }, }; res.status(500).json(response); } } /** * 테이블 컬럼 정보 조회 */ export async function getColumnList( req: AuthenticatedRequest, res: Response ): Promise { try { const { tableName } = req.params; const { page = 1, size = 50 } = req.query; // 🔥 회사 코드 추출 (JWT에서 또는 DB에서 조회) let companyCode = req.user?.companyCode; if (!companyCode && req.user?.userId) { // JWT에 없으면 DB에서 조회 const { query } = require("../database/db"); const userResult = await query( `SELECT company_code FROM user_info WHERE user_id = $1`, [req.user.userId] ); companyCode = userResult[0]?.company_code; logger.info( `DB에서 회사 코드 조회 (컬럼 목록): ${req.user.userId} → ${companyCode}` ); } logger.info( `=== 컬럼 정보 조회 시작: ${tableName} (page: ${page}, size: ${size}), company: ${companyCode} ===` ); if (!tableName) { const response: ApiResponse = { success: false, message: "테이블명이 필요합니다.", error: { code: "MISSING_TABLE_NAME", details: "테이블명 파라미터가 누락되었습니다.", }, }; res.status(400).json(response); return; } const tableManagementService = new TableManagementService(); // 🔥 캐시 버스팅: _t 파라미터가 있으면 캐시 무시 const bustCache = !!req.query._t; const result = await tableManagementService.getColumnList( tableName, parseInt(page as string), parseInt(size as string), companyCode, // 🔥 회사 코드 전달 bustCache // 🔥 캐시 버스팅 옵션 ); logger.info( `컬럼 정보 조회 결과: ${tableName}, ${result.columns.length}/${result.total}개 (${result.page}/${result.totalPages} 페이지)` ); const response: ApiResponse = { success: true, message: "컬럼 목록을 성공적으로 조회했습니다.", data: result, }; res.status(200).json(response); } catch (error) { logger.error("컬럼 정보 조회 중 오류 발생:", error); const response: ApiResponse = { success: false, message: "컬럼 목록 조회 중 오류가 발생했습니다.", error: { code: "COLUMN_LIST_ERROR", details: error instanceof Error ? error.message : "Unknown error", }, }; res.status(500).json(response); } } /** * 개별 컬럼 설정 업데이트 */ export async function updateColumnSettings( req: AuthenticatedRequest, res: Response ): Promise { try { const { tableName, columnName } = req.params; const settings: ColumnSettings = req.body; // 🔥 회사 코드 추출 (JWT에서 또는 DB에서 조회) let companyCode = req.user?.companyCode; if (!companyCode && req.user?.userId) { // JWT에 없으면 DB에서 조회 const { query } = require("../database/db"); const userResult = await query( `SELECT company_code FROM user_info WHERE user_id = $1`, [req.user.userId] ); companyCode = userResult[0]?.company_code; logger.info(`DB에서 회사 코드 조회: ${req.user.userId} → ${companyCode}`); } logger.info( `=== 컬럼 설정 업데이트 시작: ${tableName}.${columnName}, company: ${companyCode} ===` ); if (!tableName || !columnName) { const response: ApiResponse = { success: false, message: "테이블명과 컬럼명이 필요합니다.", error: { code: "MISSING_PARAMETERS", details: "테이블명 또는 컬럼명 파라미터가 누락되었습니다.", }, }; res.status(400).json(response); return; } if (!settings) { const response: ApiResponse = { success: false, message: "컬럼 설정 정보가 필요합니다.", error: { code: "MISSING_SETTINGS", details: "요청 본문에 컬럼 설정 정보가 누락되었습니다.", }, }; res.status(400).json(response); return; } if (!companyCode) { logger.error(`회사 코드 누락: ${tableName}.${columnName}`, { user: req.user, hasUser: !!req.user, userId: req.user?.userId, companyCodeFromJWT: req.user?.companyCode, }); const response: ApiResponse = { success: false, message: "회사 코드를 찾을 수 없습니다.", error: { code: "MISSING_COMPANY_CODE", details: "사용자 정보에서 회사 코드를 찾을 수 없습니다. 관리자에게 문의하세요.", }, }; res.status(400).json(response); return; } const tableManagementService = new TableManagementService(); await tableManagementService.updateColumnSettings( tableName, columnName, settings, companyCode // 🔥 회사 코드 전달 ); logger.info( `컬럼 설정 업데이트 완료: ${tableName}.${columnName}, company: ${companyCode}` ); const response: ApiResponse = { success: true, message: "컬럼 설정을 성공적으로 저장했습니다.", }; res.status(200).json(response); } catch (error) { logger.error("컬럼 설정 업데이트 중 오류 발생:", error); const response: ApiResponse = { success: false, message: "컬럼 설정 저장 중 오류가 발생했습니다.", error: { code: "COLUMN_SETTINGS_UPDATE_ERROR", details: error instanceof Error ? error.message : "Unknown error", }, }; res.status(500).json(response); } } /** * 전체 컬럼 설정 일괄 업데이트 */ export async function updateAllColumnSettings( req: AuthenticatedRequest, res: Response ): Promise { try { const { tableName } = req.params; const columnSettings: ColumnSettings[] = req.body; // 🔥 회사 코드 추출 (JWT에서 또는 DB에서 조회) let companyCode = req.user?.companyCode; if (!companyCode && req.user?.userId) { // JWT에 없으면 DB에서 조회 const { query } = require("../database/db"); const userResult = await query( `SELECT company_code FROM user_info WHERE user_id = $1`, [req.user.userId] ); companyCode = userResult[0]?.company_code; logger.info(`DB에서 회사 코드 조회: ${req.user.userId} → ${companyCode}`); } // 🔍 디버깅: 사용자 정보 출력 logger.info(`[DEBUG] req.user:`, JSON.stringify(req.user, null, 2)); logger.info(`[DEBUG] req.user?.companyCode: ${req.user?.companyCode}`); logger.info(`[DEBUG] req.user?.userId: ${req.user?.userId}`); logger.info(`[DEBUG] companyCode 최종값: ${companyCode}`); logger.info( `=== 전체 컬럼 설정 일괄 업데이트 시작: ${tableName}, company: ${companyCode} ===` ); if (!tableName) { const response: ApiResponse = { success: false, message: "테이블명이 필요합니다.", error: { code: "MISSING_TABLE_NAME", details: "테이블명 파라미터가 누락되었습니다.", }, }; res.status(400).json(response); return; } if (!Array.isArray(columnSettings) || columnSettings.length === 0) { const response: ApiResponse = { success: false, message: "컬럼 설정 목록이 필요합니다.", error: { code: "MISSING_COLUMN_SETTINGS", details: "요청 본문에 컬럼 설정 목록이 누락되었습니다.", }, }; res.status(400).json(response); return; } if (!companyCode) { logger.error(`회사 코드 누락 (일괄 업데이트): ${tableName}`, { user: req.user, hasUser: !!req.user, userId: req.user?.userId, companyCodeFromJWT: req.user?.companyCode, settingsCount: columnSettings.length, }); const response: ApiResponse = { success: false, message: "회사 코드를 찾을 수 없습니다.", error: { code: "MISSING_COMPANY_CODE", details: "사용자 정보에서 회사 코드를 찾을 수 없습니다. 관리자에게 문의하세요.", }, }; res.status(400).json(response); return; } const tableManagementService = new TableManagementService(); await tableManagementService.updateAllColumnSettings( tableName, columnSettings, companyCode // 🔥 회사 코드 전달 ); logger.info( `전체 컬럼 설정 일괄 업데이트 완료: ${tableName}, ${columnSettings.length}개, company: ${companyCode}` ); const response: ApiResponse = { success: true, message: "모든 컬럼 설정을 성공적으로 저장했습니다.", }; res.status(200).json(response); } catch (error) { logger.error("전체 컬럼 설정 일괄 업데이트 중 오류 발생:", error); const response: ApiResponse = { success: false, message: "컬럼 설정 저장 중 오류가 발생했습니다.", error: { code: "ALL_COLUMN_SETTINGS_UPDATE_ERROR", details: error instanceof Error ? error.message : "Unknown error", }, }; res.status(500).json(response); } } /** * 테이블 라벨 정보 조회 */ export async function getTableLabels( req: AuthenticatedRequest, res: Response ): Promise { try { const { tableName } = req.params; logger.info(`=== 테이블 라벨 정보 조회 시작: ${tableName} ===`); if (!tableName) { const response: ApiResponse = { success: false, message: "테이블명이 필요합니다.", error: { code: "MISSING_TABLE_NAME", details: "테이블명 파라미터가 누락되었습니다.", }, }; res.status(400).json(response); return; } const tableManagementService = new TableManagementService(); const tableLabels = await tableManagementService.getTableLabels(tableName); if (!tableLabels) { // 라벨이 없으면 빈 객체를 성공으로 반환 (404 에러 대신) const response: ApiResponse<{}> = { success: true, message: "테이블 라벨 정보를 조회했습니다.", data: {}, }; res.status(200).json(response); return; } logger.info(`테이블 라벨 정보 조회 완료: ${tableName}`); const response: ApiResponse = { success: true, message: "테이블 라벨 정보를 성공적으로 조회했습니다.", data: tableLabels, }; res.status(200).json(response); } catch (error) { logger.error("테이블 라벨 정보 조회 중 오류 발생:", error); const response: ApiResponse = { success: false, message: "테이블 라벨 정보 조회 중 오류가 발생했습니다.", error: { code: "TABLE_LABELS_ERROR", details: error instanceof Error ? error.message : "Unknown error", }, }; res.status(500).json(response); } } /** * 컬럼 라벨 정보 조회 */ export async function getColumnLabels( req: AuthenticatedRequest, res: Response ): Promise { try { const { tableName, columnName } = req.params; logger.info(`=== 컬럼 라벨 정보 조회 시작: ${tableName}.${columnName} ===`); if (!tableName || !columnName) { const response: ApiResponse = { success: false, message: "테이블명과 컬럼명이 필요합니다.", error: { code: "MISSING_PARAMETERS", details: "테이블명 또는 컬럼명 파라미터가 누락되었습니다.", }, }; res.status(400).json(response); return; } const tableManagementService = new TableManagementService(); const columnLabels = await tableManagementService.getColumnLabels( tableName, columnName ); if (!columnLabels) { // 라벨이 없으면 빈 객체를 성공으로 반환 (404 에러 대신) const response: ApiResponse<{}> = { success: true, message: "컬럼 라벨 정보를 조회했습니다.", data: {}, }; res.status(200).json(response); return; } logger.info(`컬럼 라벨 정보 조회 완료: ${tableName}.${columnName}`); const response: ApiResponse = { success: true, message: "컬럼 라벨 정보를 성공적으로 조회했습니다.", data: columnLabels, }; res.status(200).json(response); } catch (error) { logger.error("컬럼 라벨 정보 조회 중 오류 발생:", error); const response: ApiResponse = { success: false, message: "컬럼 라벨 정보 조회 중 오류가 발생했습니다.", error: { code: "COLUMN_LABELS_ERROR", details: error instanceof Error ? error.message : "Unknown error", }, }; res.status(500).json(response); } } /** * 테이블 라벨 설정 */ export async function updateTableLabel( req: AuthenticatedRequest, res: Response ): Promise { try { const { tableName } = req.params; const { displayName, description } = req.body; logger.info(`=== 테이블 라벨 설정 시작: ${tableName} ===`); logger.info(`표시명: ${displayName}, 설명: ${description}`); if (!tableName) { const response: ApiResponse = { success: false, message: "테이블명이 필요합니다.", error: { code: "MISSING_TABLE_NAME", details: "테이블명 파라미터가 누락되었습니다.", }, }; res.status(400).json(response); return; } const tableManagementService = new TableManagementService(); await tableManagementService.updateTableLabel( tableName, displayName, description ); logger.info(`테이블 라벨 설정 완료: ${tableName}`); const response: ApiResponse = { success: true, message: "테이블 라벨이 성공적으로 설정되었습니다.", data: null, }; res.status(200).json(response); } catch (error) { logger.error("테이블 라벨 설정 중 오류 발생:", error); const response: ApiResponse = { success: false, message: "테이블 라벨 설정 중 오류가 발생했습니다.", error: { code: "TABLE_LABEL_UPDATE_ERROR", details: error instanceof Error ? error.message : "Unknown error", }, }; res.status(500).json(response); } } /** * 컬럼 입력 타입 설정 */ export async function updateColumnInputType( req: AuthenticatedRequest, res: Response ): Promise { try { const { tableName, columnName } = req.params; let { inputType, detailSettings } = req.body; // 🔥 "direct" 또는 "auto"는 프론트엔드의 입력 방식 구분값이므로 // DB의 input_type(웹타입)으로 저장하면 안 됨 - "text"로 변환 if (inputType === "direct" || inputType === "auto") { logger.warn( `잘못된 inputType 값 감지: ${inputType} → 'text'로 변환 (${tableName}.${columnName})` ); inputType = "text"; } // 🔥 회사 코드 추출 (JWT에서 또는 DB에서 조회) let companyCode = req.user?.companyCode; if (!companyCode && req.user?.userId) { // JWT에 없으면 DB에서 조회 const { query } = require("../database/db"); const userResult = await query( `SELECT company_code FROM user_info WHERE user_id = $1`, [req.user.userId] ); companyCode = userResult[0]?.company_code; logger.info(`DB에서 회사 코드 조회: ${req.user.userId} → ${companyCode}`); } logger.info( `=== 컬럼 입력 타입 설정 시작: ${tableName}.${columnName} = ${inputType}, company: ${companyCode} ===` ); if (!tableName || !columnName || !inputType) { const response: ApiResponse = { success: false, message: "테이블명, 컬럼명, 입력 타입이 모두 필요합니다.", error: { code: "MISSING_PARAMETERS", details: "필수 파라미터가 누락되었습니다.", }, }; res.status(400).json(response); return; } if (!companyCode) { logger.error(`회사 코드 누락 (입력 타입): ${tableName}.${columnName}`, { user: req.user, hasUser: !!req.user, userId: req.user?.userId, companyCodeFromJWT: req.user?.companyCode, inputType, }); const response: ApiResponse = { success: false, message: "회사 코드를 찾을 수 없습니다.", error: { code: "MISSING_COMPANY_CODE", details: "사용자 정보에서 회사 코드를 찾을 수 없습니다. 관리자에게 문의하세요.", }, }; res.status(400).json(response); return; } const tableManagementService = new TableManagementService(); await tableManagementService.updateColumnInputType( tableName, columnName, inputType, companyCode, detailSettings ); logger.info( `컬럼 입력 타입 설정 완료: ${tableName}.${columnName} = ${inputType}, company: ${companyCode}` ); const response: ApiResponse = { success: true, message: "컬럼 입력 타입이 성공적으로 설정되었습니다.", data: null, }; res.status(200).json(response); } catch (error) { logger.error("컬럼 입력 타입 설정 중 오류 발생:", error); const response: ApiResponse = { success: false, message: "컬럼 입력 타입 설정 중 오류가 발생했습니다.", error: { code: "INPUT_TYPE_UPDATE_ERROR", details: error instanceof Error ? error.message : "Unknown error", }, }; res.status(500).json(response); } } /** * 단일 레코드 조회 (자동 입력용) */ export async function getTableRecord( req: AuthenticatedRequest, res: Response ): Promise { try { const { tableName } = req.params; const { filterColumn, filterValue, displayColumn } = req.body; logger.info(`=== 단일 레코드 조회 시작: ${tableName} ===`); logger.info(`필터: ${filterColumn} = ${filterValue}`); logger.info(`표시 컬럼: ${displayColumn}`); if (!tableName || !filterColumn || !filterValue) { const response: ApiResponse = { success: false, message: "필수 파라미터가 누락되었습니다.", error: { code: "MISSING_PARAMETERS", details: "tableName, filterColumn, filterValue가 필요합니다. displayColumn은 선택적입니다.", }, }; res.status(400).json(response); return; } const tableManagementService = new TableManagementService(); // 단일 레코드 조회 (WHERE filterColumn = filterValue) const result = await tableManagementService.getTableData(tableName, { page: 1, size: 1, search: { [filterColumn]: filterValue, }, }); if (!result.data || result.data.length === 0) { const response: ApiResponse = { success: false, message: "데이터를 찾을 수 없습니다.", error: { code: "NOT_FOUND", details: `${filterColumn} = ${filterValue}에 해당하는 데이터가 없습니다.`, }, }; res.status(404).json(response); return; } const record = result.data[0]; // displayColumn이 "*"이거나 없으면 전체 레코드 반환 const displayValue = displayColumn && displayColumn !== "*" ? record[displayColumn] : record; logger.info(`레코드 조회 완료: ${displayColumn || "*"} = ${typeof displayValue === 'object' ? '[전체 레코드]' : displayValue}`); const response: ApiResponse<{ value: any; record: any }> = { success: true, message: "레코드를 성공적으로 조회했습니다.", data: { value: displayValue, record: record, }, }; res.status(200).json(response); } catch (error) { logger.error("레코드 조회 중 오류 발생:", error); const response: ApiResponse = { success: false, message: "레코드 조회 중 오류가 발생했습니다.", error: { code: "RECORD_ERROR", details: error instanceof Error ? error.message : "Unknown error", }, }; res.status(500).json(response); } } /** * 테이블 데이터 조회 (페이징 + 검색 + 필터링) */ export async function getTableData( req: AuthenticatedRequest, res: Response ): Promise { try { const { tableName } = req.params; const { page = 1, size = 10, search = {}, sortBy, sortOrder = "asc", autoFilter, // 🆕 자동 필터 설정 추가 (컴포넌트에서 직접 전달) dataFilter, // 🆕 컬럼 값 기반 데이터 필터링 } = req.body; logger.info(`=== 테이블 데이터 조회 시작: ${tableName} ===`); logger.info(`페이징: page=${page}, size=${size}`); logger.info(`검색 조건:`, search); logger.info(`정렬: ${sortBy} ${sortOrder}`); logger.info(`자동 필터:`, autoFilter); // 🆕 logger.info(`데이터 필터:`, dataFilter); // 🆕 if (!tableName) { const response: ApiResponse = { success: false, message: "테이블명이 필요합니다.", error: { code: "MISSING_TABLE_NAME", details: "테이블명 파라미터가 누락되었습니다.", }, }; res.status(400).json(response); return; } const tableManagementService = new TableManagementService(); // 🆕 현재 사용자 필터 적용 (autoFilter가 없거나 enabled가 명시적으로 false가 아니면 기본 적용) let enhancedSearch = { ...search }; const shouldApplyAutoFilter = autoFilter?.enabled !== false; // 기본값: true if (shouldApplyAutoFilter && req.user) { const filterColumn = autoFilter?.filterColumn || "company_code"; const userField = autoFilter?.userField || "companyCode"; const userValue = (req.user as any)[userField]; // 🆕 프리뷰용 회사 코드 오버라이드 (최고 관리자만 허용) let finalCompanyCode = userValue; if (autoFilter?.companyCodeOverride && userValue === "*") { // 최고 관리자만 다른 회사 코드로 오버라이드 가능 finalCompanyCode = autoFilter.companyCodeOverride; logger.info("🔓 최고 관리자 회사 코드 오버라이드:", { originalCompanyCode: userValue, overrideCompanyCode: autoFilter.companyCodeOverride, tableName, }); } if (finalCompanyCode) { enhancedSearch[filterColumn] = finalCompanyCode; logger.info("🔍 현재 사용자 필터 적용:", { filterColumn, userField, userValue: finalCompanyCode, tableName, }); } else { logger.warn("⚠️ 사용자 정보 필드 값 없음:", { userField, user: req.user, }); } } // 데이터 조회 const result = await tableManagementService.getTableData(tableName, { page: parseInt(page), size: parseInt(size), search: enhancedSearch, // 🆕 필터가 적용된 search 사용 sortBy, sortOrder, dataFilter, // 🆕 데이터 필터 전달 }); logger.info( `테이블 데이터 조회 완료: ${tableName}, 총 ${result.total}건, 페이지 ${result.page}/${result.totalPages}` ); const response: ApiResponse = { success: true, message: "테이블 데이터를 성공적으로 조회했습니다.", data: result, }; res.status(200).json(response); } catch (error) { logger.error("테이블 데이터 조회 중 오류 발생:", error); const response: ApiResponse = { success: false, message: "테이블 데이터 조회 중 오류가 발생했습니다.", error: { code: "TABLE_DATA_ERROR", details: error instanceof Error ? error.message : "Unknown error", }, }; res.status(500).json(response); } } /** * 테이블 데이터 추가 */ export async function addTableData( req: AuthenticatedRequest, res: Response ): Promise { try { const { tableName } = req.params; const data = req.body; logger.info(`=== 테이블 데이터 추가 시작: ${tableName} ===`); logger.info(`추가할 데이터:`, data); if (!tableName) { const response: ApiResponse = { success: false, message: "테이블명이 필요합니다.", error: { code: "MISSING_TABLE_NAME", details: "테이블명 파라미터가 누락되었습니다.", }, }; res.status(400).json(response); return; } if (!data || Object.keys(data).length === 0) { const response: ApiResponse = { success: false, message: "추가할 데이터가 필요합니다.", error: { code: "MISSING_DATA", details: "요청 본문에 데이터가 없습니다.", }, }; res.status(400).json(response); return; } const tableManagementService = new TableManagementService(); // 🆕 멀티테넌시: company_code 자동 추가 (테이블에 company_code 컬럼이 있는 경우) const companyCode = req.user?.companyCode; if (companyCode && !data.company_code) { // 테이블에 company_code 컬럼이 있는지 확인 const hasCompanyCodeColumn = await tableManagementService.hasColumn(tableName, "company_code"); if (hasCompanyCodeColumn) { data.company_code = companyCode; logger.info(`멀티테넌시: company_code 자동 추가 - ${companyCode}`); } } // 🆕 writer 컬럼 자동 추가 (테이블에 writer 컬럼이 있고 값이 없는 경우) const userId = req.user?.userId; if (userId && !data.writer) { const hasWriterColumn = await tableManagementService.hasColumn(tableName, "writer"); if (hasWriterColumn) { data.writer = userId; logger.info(`writer 자동 추가 - ${userId}`); } } // 회사별 NOT NULL 소프트 제약조건 검증 const notNullViolations = await tableManagementService.validateNotNullConstraints( tableName, data, companyCode || "*" ); if (notNullViolations.length > 0) { res.status(400).json({ success: false, message: `필수 항목이 비어있습니다: ${notNullViolations.join(", ")}`, error: { code: "NOT_NULL_VIOLATION", details: notNullViolations, }, }); return; } // 회사별 UNIQUE 소프트 제약조건 검증 const uniqueViolations = await tableManagementService.validateUniqueConstraints( tableName, data, companyCode || "*" ); if (uniqueViolations.length > 0) { res.status(400).json({ success: false, message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`, error: { code: "UNIQUE_VIOLATION", details: uniqueViolations, }, }); return; } // 데이터 추가 const result = await tableManagementService.addTableData(tableName, data); logger.info(`테이블 데이터 추가 완료: ${tableName}, id: ${result.insertedId}`); auditLogService.log({ companyCode: req.user?.companyCode || "", userId: req.user?.userId || "", userName: req.user?.userName || "", action: "CREATE", resourceType: "DATA", resourceId: result.insertedId || "", resourceName: tableName, tableName, summary: `${tableName} 데이터 추가`, changes: { after: data }, ipAddress: getClientIp(req), requestPath: req.originalUrl, }); const response: ApiResponse<{ id: string | null }> = { success: true, message: "테이블 데이터를 성공적으로 추가했습니다.", data: { id: result.insertedId }, }; res.status(201).json(response); } catch (error) { logger.error("테이블 데이터 추가 중 오류 발생:", error); const response: ApiResponse = { success: false, message: "테이블 데이터 추가 중 오류가 발생했습니다.", error: { code: "TABLE_ADD_ERROR", details: error instanceof Error ? error.message : "Unknown error", }, }; res.status(500).json(response); } } /** * 테이블 데이터 수정 */ export async function editTableData( req: AuthenticatedRequest, res: Response ): Promise { try { const { tableName } = req.params; const { originalData, updatedData } = req.body; logger.info(`=== 테이블 데이터 수정 시작: ${tableName} ===`); logger.info(`원본 데이터:`, originalData); logger.info(`수정할 데이터:`, updatedData); if (!tableName) { const response: ApiResponse = { success: false, message: "테이블명이 필요합니다.", error: { code: "INVALID_TABLE_NAME", details: "테이블명이 누락되었습니다.", }, }; res.status(400).json(response); return; } if (!originalData || !updatedData) { const response: ApiResponse = { success: false, message: "원본 데이터와 수정할 데이터가 모두 필요합니다.", error: { code: "INVALID_DATA", details: "originalData와 updatedData가 모두 제공되어야 합니다.", }, }; res.status(400).json(response); return; } if (Object.keys(updatedData).length === 0) { const response: ApiResponse = { success: false, message: "수정할 데이터가 없습니다.", error: { code: "INVALID_DATA", details: "수정할 데이터가 비어있습니다.", }, }; res.status(400).json(response); return; } const tableManagementService = new TableManagementService(); const companyCode = req.user?.companyCode || "*"; // 회사별 NOT NULL 소프트 제약조건 검증 (수정 데이터 대상) const notNullViolations = await tableManagementService.validateNotNullConstraints( tableName, updatedData, companyCode ); if (notNullViolations.length > 0) { res.status(400).json({ success: false, message: `필수 항목이 비어있습니다: ${notNullViolations.join(", ")}`, error: { code: "NOT_NULL_VIOLATION", details: notNullViolations, }, }); return; } // 회사별 UNIQUE 소프트 제약조건 검증 (수정 시 자기 자신 제외) const excludeId = originalData?.id ? String(originalData.id) : undefined; const uniqueViolations = await tableManagementService.validateUniqueConstraints( tableName, updatedData, companyCode, excludeId ); if (uniqueViolations.length > 0) { res.status(400).json({ success: false, message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`, error: { code: "UNIQUE_VIOLATION", details: uniqueViolations, }, }); return; } // 변경된 필드만 추출 const changedBefore: Record = {}; const changedAfter: Record = {}; for (const key of Object.keys(updatedData)) { if (String(originalData[key] ?? "") !== String(updatedData[key] ?? "")) { changedBefore[key] = originalData[key]; changedAfter[key] = updatedData[key]; } } // 데이터 수정 await tableManagementService.editTableData( tableName, originalData, updatedData ); logger.info(`테이블 데이터 수정 완료: ${tableName}`); if (Object.keys(changedAfter).length > 0) { auditLogService.log({ companyCode: req.user?.companyCode || "", userId: req.user?.userId || "", userName: req.user?.userName || "", action: "UPDATE", resourceType: "DATA", resourceId: originalData.id?.toString() || "", resourceName: tableName, tableName, summary: `${tableName} 데이터 수정`, changes: { before: changedBefore, after: changedAfter }, ipAddress: getClientIp(req), requestPath: req.originalUrl, }); } const response: ApiResponse = { success: true, message: "테이블 데이터를 성공적으로 수정했습니다.", }; res.status(200).json(response); } catch (error) { logger.error("테이블 데이터 수정 중 오류 발생:", error); const response: ApiResponse = { success: false, message: "테이블 데이터 수정 중 오류가 발생했습니다.", error: { code: "TABLE_EDIT_ERROR", details: error instanceof Error ? error.message : "Unknown error", }, }; res.status(500).json(response); } } /** * 테이블 스키마 정보 조회 (컬럼 존재 여부 검증용) */ export async function getTableSchema( req: AuthenticatedRequest, res: Response ): Promise { try { const { tableName } = req.params; logger.info(`=== 테이블 스키마 정보 조회 시작: ${tableName} ===`); if (!tableName) { const response: ApiResponse = { success: false, message: "테이블명이 필요합니다.", error: { code: "MISSING_TABLE_NAME", details: "테이블명 파라미터가 누락되었습니다.", }, }; res.status(400).json(response); return; } const tableManagementService = new TableManagementService(); const schema = await tableManagementService.getTableSchema(tableName); logger.info( `테이블 스키마 정보 조회 완료: ${tableName}, ${schema.length}개 컬럼` ); const response: ApiResponse = { success: true, message: "테이블 스키마 정보를 성공적으로 조회했습니다.", data: schema, }; res.status(200).json(response); } catch (error) { logger.error("테이블 스키마 정보 조회 중 오류 발생:", error); const response: ApiResponse = { success: false, message: "테이블 스키마 정보 조회 중 오류가 발생했습니다.", error: { code: "TABLE_SCHEMA_ERROR", details: error instanceof Error ? error.message : "Unknown error", }, }; res.status(500).json(response); } } /** * 테이블 존재 여부 확인 */ export async function checkTableExists( req: AuthenticatedRequest, res: Response ): Promise { try { const { tableName } = req.params; logger.info(`=== 테이블 존재 여부 확인 시작: ${tableName} ===`); if (!tableName) { const response: ApiResponse = { success: false, message: "테이블명이 필요합니다.", error: { code: "MISSING_TABLE_NAME", details: "테이블명 파라미터가 누락되었습니다.", }, }; res.status(400).json(response); return; } const tableManagementService = new TableManagementService(); const exists = await tableManagementService.checkTableExists(tableName); logger.info(`테이블 존재 여부 확인 완료: ${tableName} = ${exists}`); const response: ApiResponse<{ exists: boolean }> = { success: true, message: "테이블 존재 여부를 확인했습니다.", data: { exists }, }; res.status(200).json(response); } catch (error) { logger.error("테이블 존재 여부 확인 중 오류 발생:", error); const response: ApiResponse = { success: false, message: "테이블 존재 여부 확인 중 오류가 발생했습니다.", error: { code: "TABLE_EXISTS_CHECK_ERROR", details: error instanceof Error ? error.message : "Unknown error", }, }; res.status(500).json(response); } } /** * 컬럼 웹타입 정보 조회 (화면관리 연동용) */ export async function getColumnWebTypes( req: AuthenticatedRequest, res: Response ): Promise { try { const { tableName } = req.params; // 🔥 회사 코드 추출 (JWT에서 또는 DB에서 조회) let companyCode = req.user?.companyCode; if (!companyCode && req.user?.userId) { // JWT에 없으면 DB에서 조회 const { query } = require("../database/db"); const userResult = await query( `SELECT company_code FROM user_info WHERE user_id = $1`, [req.user.userId] ); companyCode = userResult[0]?.company_code; logger.info( `DB에서 회사 코드 조회 (조회): ${req.user.userId} → ${companyCode}` ); } logger.info( `=== 컬럼 웹타입 정보 조회 시작: ${tableName}, company: ${companyCode} ===` ); if (!tableName) { const response: ApiResponse = { success: false, message: "테이블명이 필요합니다.", error: { code: "MISSING_TABLE_NAME", details: "테이블명 파라미터가 누락되었습니다.", }, }; res.status(400).json(response); return; } if (!companyCode) { logger.error(`회사 코드 누락 (조회): ${tableName}`, { user: req.user, hasUser: !!req.user, userId: req.user?.userId, companyCodeFromJWT: req.user?.companyCode, }); const response: ApiResponse = { success: false, message: "회사 코드를 찾을 수 없습니다.", error: { code: "MISSING_COMPANY_CODE", details: "사용자 정보에서 회사 코드를 찾을 수 없습니다. 관리자에게 문의하세요.", }, }; res.status(400).json(response); return; } const tableManagementService = new TableManagementService(); const inputTypes = await tableManagementService.getColumnInputTypes( tableName, companyCode ); logger.info( `컬럼 입력타입 정보 조회 완료: ${tableName}, company: ${companyCode}, ${inputTypes.length}개 컬럼` ); const response: ApiResponse = { success: true, message: "컬럼 입력타입 정보를 성공적으로 조회했습니다.", data: inputTypes, }; res.status(200).json(response); } catch (error) { logger.error("컬럼 웹타입 정보 조회 중 오류 발생:", error); const response: ApiResponse = { success: false, message: "컬럼 웹타입 정보 조회 중 오류가 발생했습니다.", error: { code: "COLUMN_WEB_TYPES_ERROR", details: error instanceof Error ? error.message : "Unknown error", }, }; res.status(500).json(response); } } /** * 데이터베이스 연결 상태 확인 */ export async function checkDatabaseConnection( req: AuthenticatedRequest, res: Response ): Promise { try { logger.info("=== 데이터베이스 연결 상태 확인 시작 ==="); const tableManagementService = new TableManagementService(); const connectionStatus = await tableManagementService.checkDatabaseConnection(); logger.info( `데이터베이스 연결 상태: ${connectionStatus.connected ? "연결됨" : "연결 안됨"}` ); const response: ApiResponse<{ connected: boolean; message: string }> = { success: true, message: "데이터베이스 연결 상태를 확인했습니다.", data: connectionStatus, }; res.status(200).json(response); } catch (error) { logger.error("데이터베이스 연결 상태 확인 중 오류 발생:", error); const response: ApiResponse = { success: false, message: "데이터베이스 연결 상태 확인 중 오류가 발생했습니다.", error: { code: "DATABASE_CONNECTION_ERROR", details: error instanceof Error ? error.message : "Unknown error", }, }; res.status(500).json(response); } } /** * 테이블 데이터 삭제 */ export async function deleteTableData( req: AuthenticatedRequest, res: Response ): Promise { try { const { tableName } = req.params; const data = req.body; logger.info(`=== 테이블 데이터 삭제 시작: ${tableName} ===`); logger.info(`삭제할 데이터:`, data); if (!tableName) { const response: ApiResponse = { success: false, message: "테이블명이 필요합니다.", error: { code: "MISSING_TABLE_NAME", details: "테이블명 파라미터가 누락되었습니다.", }, }; res.status(400).json(response); return; } if (!data || (Array.isArray(data) && data.length === 0)) { const response: ApiResponse = { success: false, message: "삭제할 데이터가 필요합니다.", error: { code: "MISSING_DATA", details: "요청 본문에 삭제할 데이터가 없습니다.", }, }; res.status(400).json(response); return; } const tableManagementService = new TableManagementService(); // 데이터 삭제 const deletedCount = await tableManagementService.deleteTableData( tableName, data ); logger.info( `테이블 데이터 삭제 완료: ${tableName}, ${deletedCount}건 삭제` ); const deleteItems = Array.isArray(data) ? data : [data]; auditLogService.log({ companyCode: req.user?.companyCode || "", userId: req.user?.userId || "", userName: req.user?.userName || "", action: "DELETE", resourceType: "DATA", resourceId: deleteItems[0]?.id?.toString() || "", resourceName: tableName, tableName, summary: `${tableName} 데이터 삭제 (${deletedCount}건)`, changes: { before: { deletedCount, items: deleteItems.length } }, ipAddress: getClientIp(req), requestPath: req.originalUrl, }); const response: ApiResponse<{ deletedCount: number }> = { success: true, message: `테이블 데이터를 성공적으로 삭제했습니다. (${deletedCount}건)`, data: { deletedCount }, }; res.status(200).json(response); } catch (error) { logger.error("테이블 데이터 삭제 중 오류 발생:", error); const response: ApiResponse = { success: false, message: "테이블 데이터 삭제 중 오류가 발생했습니다.", error: { code: "TABLE_DELETE_ERROR", details: error instanceof Error ? error.message : "Unknown error", }, }; res.status(500).json(response); } } /** * 컬럼 웹 타입 설정 (레거시 지원) * @deprecated updateColumnInputType 사용 권장 */ export async function updateColumnWebType( req: AuthenticatedRequest, res: Response ): Promise { try { const { tableName, columnName } = req.params; const { webType, detailSettings, inputType } = req.body; logger.warn( `레거시 API 사용: updateColumnWebType → updateColumnInputType 사용 권장` ); // 🔥 inputType이 "direct" 또는 "auto"이면 무시하고 webType 사용 // "direct"/"auto"는 프론트엔드의 입력 방식(직접입력/자동입력) 구분값이지 // DB에 저장할 웹 타입(text, number, date 등)이 아님 let convertedInputType = webType || "text"; if (inputType && inputType !== "direct" && inputType !== "auto") { convertedInputType = inputType; } logger.info( `웹타입 변환: webType=${webType}, inputType=${inputType} → ${convertedInputType}` ); // 새로운 메서드 호출 req.body = { inputType: convertedInputType, detailSettings }; await updateColumnInputType(req, res); } catch (error) { logger.error("레거시 컬럼 웹 타입 설정 중 오류 발생:", error); const response: ApiResponse = { success: false, message: "컬럼 웹 타입 설정 중 오류가 발생했습니다.", error: { code: "WEB_TYPE_UPDATE_ERROR", details: error instanceof Error ? error.message : "Unknown error", }, }; res.status(500).json(response); } } // ======================================== // 🎯 테이블 로그 시스템 API // ======================================== /** * 로그 테이블 생성 */ export async function createLogTable( req: AuthenticatedRequest, res: Response ): Promise { try { const { tableName } = req.params; const { pkColumn } = req.body; const userId = req.user?.userId; logger.info(`=== 로그 테이블 생성 시작: ${tableName} ===`); if (!tableName) { const response: ApiResponse = { success: false, message: "테이블명이 필요합니다.", error: { code: "MISSING_TABLE_NAME", details: "테이블명 파라미터가 누락되었습니다.", }, }; res.status(400).json(response); return; } if (!pkColumn || !pkColumn.columnName || !pkColumn.dataType) { const response: ApiResponse = { success: false, message: "PK 컬럼 정보가 필요합니다.", error: { code: "MISSING_PK_COLUMN", details: "PK 컬럼명과 데이터 타입이 필요합니다.", }, }; res.status(400).json(response); return; } const tableManagementService = new TableManagementService(); await tableManagementService.createLogTable(tableName, pkColumn, userId); logger.info(`로그 테이블 생성 완료: ${tableName}_log`); const response: ApiResponse = { success: true, message: "로그 테이블이 성공적으로 생성되었습니다.", }; res.status(200).json(response); } catch (error) { logger.error("로그 테이블 생성 중 오류 발생:", error); const response: ApiResponse = { success: false, message: "로그 테이블 생성 중 오류가 발생했습니다.", error: { code: "LOG_TABLE_CREATE_ERROR", details: error instanceof Error ? error.message : "Unknown error", }, }; res.status(500).json(response); } } /** * 로그 설정 조회 */ export async function getLogConfig( req: AuthenticatedRequest, res: Response ): Promise { try { const { tableName } = req.params; logger.info(`=== 로그 설정 조회: ${tableName} ===`); if (!tableName) { const response: ApiResponse = { success: false, message: "테이블명이 필요합니다.", error: { code: "MISSING_TABLE_NAME", details: "테이블명 파라미터가 누락되었습니다.", }, }; res.status(400).json(response); return; } const tableManagementService = new TableManagementService(); const logConfig = await tableManagementService.getLogConfig(tableName); const response: ApiResponse = { success: true, message: "로그 설정을 조회했습니다.", data: logConfig, }; res.status(200).json(response); } catch (error) { logger.error("로그 설정 조회 중 오류 발생:", error); const response: ApiResponse = { success: false, message: "로그 설정 조회 중 오류가 발생했습니다.", error: { code: "LOG_CONFIG_ERROR", details: error instanceof Error ? error.message : "Unknown error", }, }; res.status(500).json(response); } } /** * 로그 데이터 조회 */ export async function getLogData( req: AuthenticatedRequest, res: Response ): Promise { try { const { tableName } = req.params; const { page = 1, size = 20, operationType, startDate, endDate, changedBy, originalId, } = req.query; logger.info(`=== 로그 데이터 조회: ${tableName} ===`); if (!tableName) { const response: ApiResponse = { success: false, message: "테이블명이 필요합니다.", error: { code: "MISSING_TABLE_NAME", details: "테이블명 파라미터가 누락되었습니다.", }, }; res.status(400).json(response); return; } const tableManagementService = new TableManagementService(); const result = await tableManagementService.getLogData(tableName, { page: parseInt(page as string), size: parseInt(size as string), operationType: operationType as string, startDate: startDate as string, endDate: endDate as string, changedBy: changedBy as string, originalId: originalId as string, }); logger.info(`로그 데이터 조회 완료: ${tableName}_log, ${result.total}건`); const response: ApiResponse = { success: true, message: "로그 데이터를 조회했습니다.", data: result, }; res.status(200).json(response); } catch (error) { logger.error("로그 데이터 조회 중 오류 발생:", error); const response: ApiResponse = { success: false, message: "로그 데이터 조회 중 오류가 발생했습니다.", error: { code: "LOG_DATA_ERROR", details: error instanceof Error ? error.message : "Unknown error", }, }; res.status(500).json(response); } } /** * 로그 테이블 활성화/비활성화 */ export async function toggleLogTable( req: AuthenticatedRequest, res: Response ): Promise { try { const { tableName } = req.params; const { isActive } = req.body; logger.info( `=== 로그 테이블 토글: ${tableName}, isActive: ${isActive} ===` ); if (!tableName) { const response: ApiResponse = { success: false, message: "테이블명이 필요합니다.", error: { code: "MISSING_TABLE_NAME", details: "테이블명 파라미터가 누락되었습니다.", }, }; res.status(400).json(response); return; } if (isActive === undefined || isActive === null) { const response: ApiResponse = { success: false, message: "isActive 값이 필요합니다.", error: { code: "MISSING_IS_ACTIVE", details: "isActive 파라미터가 누락되었습니다.", }, }; res.status(400).json(response); return; } const tableManagementService = new TableManagementService(); await tableManagementService.toggleLogTable( tableName, isActive === "Y" || isActive === true ); logger.info(`로그 테이블 토글 완료: ${tableName}, isActive: ${isActive}`); const response: ApiResponse = { success: true, message: `로그 기능이 ${isActive ? "활성화" : "비활성화"}되었습니다.`, }; res.status(200).json(response); } catch (error) { logger.error("로그 테이블 토글 중 오류 발생:", error); const response: ApiResponse = { success: false, message: "로그 테이블 토글 중 오류가 발생했습니다.", error: { code: "LOG_TOGGLE_ERROR", details: error instanceof Error ? error.message : "Unknown error", }, }; res.status(500).json(response); } } /** * 회사별 카테고리 컬럼 조회 (메뉴 종속 없음) * * @route GET /api/table-management/category-columns * @description table_type_columns에서 회사 코드 기준으로 input_type = 'category'인 컬럼을 조회 */ export async function getCategoryColumnsByCompany( req: AuthenticatedRequest, res: Response ): Promise { try { const companyCode = req.user?.companyCode; logger.info("📥 회사별 카테고리 컬럼 조회 요청", { companyCode }); if (!companyCode) { logger.error("❌ 회사 코드가 없습니다", { user: req.user }); res.status(400).json({ success: false, message: "회사 코드를 확인할 수 없습니다. 다시 로그인해주세요.", }); return; } const { getPool } = await import("../database/db"); const pool = getPool(); let columnsResult; // 최고 관리자인 경우 company_code = '*'인 카테고리 컬럼 조회 // category_ref가 설정된 컬럼은 제외 (참조 컬럼은 자체 값 관리 안 함) if (companyCode === "*") { const columnsQuery = ` SELECT DISTINCT ttc.table_name AS "tableName", COALESCE( tl.table_label, initcap(replace(ttc.table_name, '_', ' ')) ) AS "tableLabel", ttc.column_name AS "columnName", COALESCE( ttc.column_label, initcap(replace(ttc.column_name, '_', ' ')) ) AS "columnLabel", ttc.input_type AS "inputType" FROM table_type_columns ttc LEFT JOIN table_labels tl ON ttc.table_name = tl.table_name WHERE ttc.input_type = 'category' AND ttc.company_code = '*' AND (ttc.category_ref IS NULL OR ttc.category_ref = '') ORDER BY ttc.table_name, ttc.column_name `; columnsResult = await pool.query(columnsQuery); logger.info("최고 관리자: 전체 카테고리 컬럼 조회 완료 (참조 제외)", { rowCount: columnsResult.rows.length }); } else { const columnsQuery = ` SELECT DISTINCT ttc.table_name AS "tableName", COALESCE( tl.table_label, initcap(replace(ttc.table_name, '_', ' ')) ) AS "tableLabel", ttc.column_name AS "columnName", COALESCE( ttc.column_label, initcap(replace(ttc.column_name, '_', ' ')) ) AS "columnLabel", ttc.input_type AS "inputType" FROM table_type_columns ttc LEFT JOIN table_labels tl ON ttc.table_name = tl.table_name WHERE ttc.input_type = 'category' AND ttc.company_code = $1 AND (ttc.category_ref IS NULL OR ttc.category_ref = '') ORDER BY ttc.table_name, ttc.column_name `; columnsResult = await pool.query(columnsQuery, [companyCode]); logger.info("회사별 카테고리 컬럼 조회 완료 (참조 제외)", { companyCode, rowCount: columnsResult.rows.length }); } res.json({ success: true, data: columnsResult.rows, message: "카테고리 컬럼 조회 성공", }); } catch (error: any) { logger.error("❌ 회사별 카테고리 컬럼 조회 실패", { error: error.message }); res.status(500).json({ success: false, message: "카테고리 컬럼 조회 중 오류가 발생했습니다.", error: error.message, }); } } /** * 메뉴의 상위 메뉴들이 설정한 모든 카테고리 타입 컬럼 조회 (계층 구조 상속) * * @route GET /api/table-management/menu/:menuObjid/category-columns * @description 현재 메뉴와 상위 메뉴들에서 설정한 category_column_mapping의 모든 카테고리 컬럼 조회 * * 예시: * - 2레벨 메뉴 "고객사관리"에서 discount_type, rounding_type 설정 * - 3레벨 메뉴 "고객등록", "고객조회" 등에서도 동일하게 보임 (상속) */ export async function getCategoryColumnsByMenu( req: AuthenticatedRequest, res: Response ): Promise { try { const { menuObjid } = req.params; const companyCode = req.user?.companyCode; logger.info("📥 메뉴별 카테고리 컬럼 조회 요청", { menuObjid, companyCode }); if (!menuObjid) { res.status(400).json({ success: false, message: "메뉴 OBJID가 필요합니다.", }); return; } if (!companyCode) { logger.error("❌ 회사 코드가 없습니다", { menuObjid, user: req.user }); res.status(400).json({ success: false, message: "회사 코드를 확인할 수 없습니다. 다시 로그인해주세요.", }); return; } const { getPool } = await import("../database/db"); const pool = getPool(); // table_type_columns에서 input_type = 'category' 컬럼 조회 // category_ref가 설정된 컬럼은 제외 (참조 컬럼은 자체 값 관리 안 함) let columnsResult; if (companyCode === "*") { const columnsQuery = ` SELECT DISTINCT ttc.table_name AS "tableName", COALESCE( tl.table_label, initcap(replace(ttc.table_name, '_', ' ')) ) AS "tableLabel", ttc.column_name AS "columnName", COALESCE( ttc.column_label, initcap(replace(ttc.column_name, '_', ' ')) ) AS "columnLabel", ttc.input_type AS "inputType" FROM table_type_columns ttc LEFT JOIN table_labels tl ON ttc.table_name = tl.table_name WHERE ttc.input_type = 'category' AND ttc.company_code = '*' AND (ttc.category_ref IS NULL OR ttc.category_ref = '') ORDER BY ttc.table_name, ttc.column_name `; columnsResult = await pool.query(columnsQuery); logger.info("최고 관리자: 메뉴별 카테고리 컬럼 조회 완료 (참조 제외)", { rowCount: columnsResult.rows.length }); } else { const columnsQuery = ` SELECT DISTINCT ttc.table_name AS "tableName", COALESCE( tl.table_label, initcap(replace(ttc.table_name, '_', ' ')) ) AS "tableLabel", ttc.column_name AS "columnName", COALESCE( ttc.column_label, initcap(replace(ttc.column_name, '_', ' ')) ) AS "columnLabel", ttc.input_type AS "inputType" FROM table_type_columns ttc LEFT JOIN table_labels tl ON ttc.table_name = tl.table_name WHERE ttc.input_type = 'category' AND ttc.company_code = $1 AND (ttc.category_ref IS NULL OR ttc.category_ref = '') ORDER BY ttc.table_name, ttc.column_name `; columnsResult = await pool.query(columnsQuery, [companyCode]); logger.info("회사별 메뉴 카테고리 컬럼 조회 완료 (참조 제외)", { companyCode, rowCount: columnsResult.rows.length }); } logger.info("✅ 카테고리 컬럼 조회 완료", { columnCount: columnsResult.rows.length }); res.json({ success: true, data: columnsResult.rows, message: "카테고리 컬럼 조회 성공", }); } catch (error: any) { logger.error("❌ 메뉴별 카테고리 컬럼 조회 실패"); logger.error("에러 메시지:", error.message); logger.error("에러 스택:", error.stack); logger.error("에러 전체:", error); res.status(500).json({ success: false, message: "카테고리 컬럼 조회에 실패했습니다.", error: error.message, stack: error.stack, // 디버깅용 }); } } /** * 범용 다중 테이블 저장 API * * 메인 테이블과 서브 테이블(들)에 트랜잭션으로 데이터를 저장합니다. * * 요청 본문: * { * mainTable: { tableName: string, primaryKeyColumn: string }, * mainData: Record, * subTables: Array<{ * tableName: string, * linkColumn: { mainField: string, subColumn: string }, * items: Record[], * options?: { * saveMainAsFirst?: boolean, * mainFieldMappings?: Array<{ formField: string, targetColumn: string }>, * mainMarkerColumn?: string, * mainMarkerValue?: any, * subMarkerValue?: any, * deleteExistingBefore?: boolean, * } * }>, * isUpdate?: boolean * } */ export async function multiTableSave( req: AuthenticatedRequest, res: Response ): Promise { const pool = require("../database/db").getPool(); const client = await pool.connect(); try { const { mainTable, mainData, subTables, isUpdate } = req.body; const companyCode = req.user?.companyCode || "*"; logger.info("=== 다중 테이블 저장 시작 ===", { mainTable, mainDataKeys: Object.keys(mainData || {}), subTablesCount: subTables?.length || 0, isUpdate, companyCode, }); // 유효성 검사 if (!mainTable?.tableName || !mainTable?.primaryKeyColumn) { res.status(400).json({ success: false, message: "메인 테이블 설정이 올바르지 않습니다.", }); return; } if (!mainData || Object.keys(mainData).length === 0) { res.status(400).json({ success: false, message: "저장할 메인 데이터가 없습니다.", }); return; } await client.query("BEGIN"); // 1. 메인 테이블 저장 const mainTableName = mainTable.tableName; const pkColumn = mainTable.primaryKeyColumn; const pkValue = mainData[pkColumn]; // company_code 자동 추가 (최고 관리자가 아닌 경우) if (companyCode !== "*" && !mainData.company_code) { mainData.company_code = companyCode; } let mainResult: any; if (isUpdate && pkValue) { // UPDATE const updateColumns = Object.keys(mainData) .filter(col => col !== pkColumn) .map((col, idx) => `"${col}" = $${idx + 1}`) .join(", "); const updateValues = Object.keys(mainData) .filter(col => col !== pkColumn) .map(col => mainData[col]); // updated_at 컬럼 존재 여부 확인 const hasUpdatedAt = await client.query(` SELECT 1 FROM information_schema.columns WHERE table_name = $1 AND column_name = 'updated_at' `, [mainTableName]); const updatedAtClause = hasUpdatedAt.rowCount && hasUpdatedAt.rowCount > 0 ? ", updated_at = NOW()" : ""; const updateQuery = ` UPDATE "${mainTableName}" SET ${updateColumns}${updatedAtClause} WHERE "${pkColumn}" = $${updateValues.length + 1} ${companyCode !== "*" ? `AND company_code = $${updateValues.length + 2}` : ""} RETURNING * `; const updateParams = companyCode !== "*" ? [...updateValues, pkValue, companyCode] : [...updateValues, pkValue]; logger.info("메인 테이블 UPDATE:", { query: updateQuery, paramsCount: updateParams.length }); mainResult = await client.query(updateQuery, updateParams); } else { // INSERT const columns = Object.keys(mainData).map(col => `"${col}"`).join(", "); const placeholders = Object.keys(mainData).map((_, idx) => `$${idx + 1}`).join(", "); const values = Object.values(mainData); // updated_at 컬럼 존재 여부 확인 const hasUpdatedAt = await client.query(` SELECT 1 FROM information_schema.columns WHERE table_name = $1 AND column_name = 'updated_at' `, [mainTableName]); const updatedAtClause = hasUpdatedAt.rowCount && hasUpdatedAt.rowCount > 0 ? ", updated_at = NOW()" : ""; const updateSetClause = Object.keys(mainData) .filter(col => col !== pkColumn) .map(col => `"${col}" = EXCLUDED."${col}"`) .join(", "); const insertQuery = ` INSERT INTO "${mainTableName}" (${columns}) VALUES (${placeholders}) ON CONFLICT ("${pkColumn}") DO UPDATE SET ${updateSetClause}${updatedAtClause} RETURNING * `; logger.info("메인 테이블 INSERT/UPSERT:", { query: insertQuery, paramsCount: values.length }); mainResult = await client.query(insertQuery, values); } if (mainResult.rowCount === 0) { throw new Error("메인 테이블 저장 실패"); } const savedMainData = mainResult.rows[0]; const savedPkValue = savedMainData[pkColumn]; logger.info("메인 테이블 저장 완료:", { pkColumn, savedPkValue }); // 2. 서브 테이블 저장 const subTableResults: any[] = []; for (const subTableConfig of subTables || []) { const { tableName, linkColumn, items, options } = subTableConfig; // saveMainAsFirst가 활성화된 경우, items가 비어있어도 메인 데이터를 서브 테이블에 저장해야 함 const hasSaveMainAsFirst = options?.saveMainAsFirst && options?.mainFieldMappings && options.mainFieldMappings.length > 0; if (!tableName || (!items?.length && !hasSaveMainAsFirst)) { logger.info(`서브 테이블 ${tableName} 스킵: 데이터 없음 (saveMainAsFirst: ${hasSaveMainAsFirst})`); continue; } logger.info(`서브 테이블 ${tableName} 저장 시작:`, { itemsCount: items?.length || 0, linkColumn, options, hasSaveMainAsFirst, }); // 기존 데이터 삭제 옵션 if (options?.deleteExistingBefore && linkColumn?.subColumn) { const deleteQuery = options?.deleteOnlySubItems && options?.mainMarkerColumn ? `DELETE FROM "${tableName}" WHERE "${linkColumn.subColumn}" = $1 AND "${options.mainMarkerColumn}" = $2` : `DELETE FROM "${tableName}" WHERE "${linkColumn.subColumn}" = $1`; const deleteParams = options?.deleteOnlySubItems && options?.mainMarkerColumn ? [savedPkValue, options.subMarkerValue ?? false] : [savedPkValue]; logger.info(`서브 테이블 ${tableName} 기존 데이터 삭제:`, { deleteQuery, deleteParams }); await client.query(deleteQuery, deleteParams); } // 메인 데이터도 서브 테이블에 저장 (옵션) // mainFieldMappings가 비어 있으면 건너뜀 (필수 컬럼 누락 방지) logger.info(`saveMainAsFirst 옵션 확인:`, { saveMainAsFirst: options?.saveMainAsFirst, mainFieldMappings: options?.mainFieldMappings, mainFieldMappingsLength: options?.mainFieldMappings?.length, linkColumn, mainDataKeys: Object.keys(mainData), }); if (options?.saveMainAsFirst && options?.mainFieldMappings && options.mainFieldMappings.length > 0 && linkColumn?.subColumn) { const mainSubItem: Record = { [linkColumn.subColumn]: savedPkValue, }; // 메인 필드 매핑 적용 for (const mapping of options.mainFieldMappings) { if (mapping.formField && mapping.targetColumn) { mainSubItem[mapping.targetColumn] = mainData[mapping.formField]; } } // 메인 마커 설정 if (options.mainMarkerColumn) { mainSubItem[options.mainMarkerColumn] = options.mainMarkerValue ?? true; } // company_code 추가 if (companyCode !== "*") { mainSubItem.company_code = companyCode; } // 먼저 기존 데이터 존재 여부 확인 (user_id + is_primary 조합) const checkQuery = ` SELECT * FROM "${tableName}" WHERE "${linkColumn.subColumn}" = $1 ${options.mainMarkerColumn ? `AND "${options.mainMarkerColumn}" = $2` : ""} ${companyCode !== "*" ? `AND company_code = $${options.mainMarkerColumn ? 3 : 2}` : ""} LIMIT 1 `; const checkParams: any[] = [savedPkValue]; if (options.mainMarkerColumn) { checkParams.push(options.mainMarkerValue ?? true); } if (companyCode !== "*") { checkParams.push(companyCode); } const existingResult = await client.query(checkQuery, checkParams); if (existingResult.rows.length > 0) { // UPDATE const updateColumns = Object.keys(mainSubItem) .filter(col => col !== linkColumn.subColumn && col !== options.mainMarkerColumn && col !== "company_code") .map((col, idx) => `"${col}" = $${idx + 1}`) .join(", "); const updateValues = Object.keys(mainSubItem) .filter(col => col !== linkColumn.subColumn && col !== options.mainMarkerColumn && col !== "company_code") .map(col => mainSubItem[col]); if (updateColumns) { const updateQuery = ` UPDATE "${tableName}" SET ${updateColumns} WHERE "${linkColumn.subColumn}" = $${updateValues.length + 1} ${options.mainMarkerColumn ? `AND "${options.mainMarkerColumn}" = $${updateValues.length + 2}` : ""} ${companyCode !== "*" ? `AND company_code = $${updateValues.length + (options.mainMarkerColumn ? 3 : 2)}` : ""} RETURNING * `; const updateParams = [...updateValues, savedPkValue]; if (options.mainMarkerColumn) { updateParams.push(options.mainMarkerValue ?? true); } if (companyCode !== "*") { updateParams.push(companyCode); } const updateResult = await client.query(updateQuery, updateParams); subTableResults.push({ tableName, type: "main", data: updateResult.rows[0] }); } else { subTableResults.push({ tableName, type: "main", data: existingResult.rows[0] }); } } else { // INSERT const mainSubColumns = Object.keys(mainSubItem).map(col => `"${col}"`).join(", "); const mainSubPlaceholders = Object.keys(mainSubItem).map((_, idx) => `$${idx + 1}`).join(", "); const mainSubValues = Object.values(mainSubItem); const insertQuery = ` INSERT INTO "${tableName}" (${mainSubColumns}) VALUES (${mainSubPlaceholders}) RETURNING * `; const insertResult = await client.query(insertQuery, mainSubValues); subTableResults.push({ tableName, type: "main", data: insertResult.rows[0] }); } } // 서브 아이템들 저장 for (const item of items) { // 연결 컬럼 값 설정 if (linkColumn?.subColumn) { item[linkColumn.subColumn] = savedPkValue; } // company_code 추가 if (companyCode !== "*" && !item.company_code) { item.company_code = companyCode; } const subColumns = Object.keys(item).map(col => `"${col}"`).join(", "); const subPlaceholders = Object.keys(item).map((_, idx) => `$${idx + 1}`).join(", "); const subValues = Object.values(item); const subInsertQuery = ` INSERT INTO "${tableName}" (${subColumns}) VALUES (${subPlaceholders}) RETURNING * `; logger.info(`서브 테이블 ${tableName} 아이템 저장:`, { subInsertQuery, subValuesCount: subValues.length }); const subResult = await client.query(subInsertQuery, subValues); subTableResults.push({ tableName, type: "sub", data: subResult.rows[0] }); } logger.info(`서브 테이블 ${tableName} 저장 완료`); } await client.query("COMMIT"); logger.info("=== 다중 테이블 저장 완료 ===", { mainTable: mainTableName, mainPk: savedPkValue, subTableResultsCount: subTableResults.length, }); auditLogService.log({ companyCode: req.user?.companyCode || "", userId: req.user?.userId || "", userName: req.user?.userName || "", action: isUpdate ? "UPDATE" : "CREATE", resourceType: "DATA", resourceId: savedPkValue?.toString() || "", resourceName: mainTableName, tableName: mainTableName, summary: `${mainTableName} 데이터 ${isUpdate ? "수정" : "생성"}${subTableResults.length > 0 ? ` (서브 테이블 ${subTableResults.length}건)` : ""}`, changes: { after: mainData }, ipAddress: getClientIp(req), requestPath: req.originalUrl, }); res.json({ success: true, message: "다중 테이블 저장이 완료되었습니다.", data: { main: savedMainData, subTables: subTableResults, }, }); } catch (error: any) { await client.query("ROLLBACK"); logger.error("다중 테이블 저장 실패:", { message: error.message, stack: error.stack, }); res.status(500).json({ success: false, message: error.message || "다중 테이블 저장에 실패했습니다.", error: error.message, }); } finally { client.release(); } } /** * 두 테이블 간 엔티티 관계 조회 * table_type_columns의 entity/category 타입 설정을 기반으로 두 테이블 간의 관계를 조회 */ export async function getTableEntityRelations( req: AuthenticatedRequest, res: Response ): Promise { try { const { leftTable, rightTable } = req.query; if (!leftTable || !rightTable) { res.status(400).json({ success: false, message: "leftTable과 rightTable 파라미터가 필요합니다.", }); return; } logger.info("=== 테이블 엔티티 관계 조회 ===", { leftTable, rightTable }); // 두 테이블의 컬럼 라벨 정보 조회 const columnLabelsQuery = ` SELECT table_name, column_name, column_label, input_type as web_type, detail_settings FROM table_type_columns WHERE table_name IN ($1, $2) AND input_type IN ('entity', 'category') AND company_code = '*' `; const result = await query(columnLabelsQuery, [leftTable, rightTable]); // 관계 분석 const relations: Array<{ fromTable: string; fromColumn: string; toTable: string; toColumn: string; relationType: string; }> = []; for (const row of result) { try { const detailSettings = typeof row.detail_settings === "string" ? JSON.parse(row.detail_settings) : row.detail_settings; if (detailSettings && detailSettings.referenceTable) { const refTable = detailSettings.referenceTable; const refColumn = detailSettings.referenceColumn || "id"; // leftTable과 rightTable 간의 관계인지 확인 if ( (row.table_name === leftTable && refTable === rightTable) || (row.table_name === rightTable && refTable === leftTable) ) { relations.push({ fromTable: row.table_name, fromColumn: row.column_name, toTable: refTable, toColumn: refColumn, relationType: row.web_type, }); } } } catch (parseError) { logger.warn("detail_settings 파싱 오류:", { table: row.table_name, column: row.column_name, error: parseError }); } } logger.info("테이블 엔티티 관계 조회 완료", { leftTable, rightTable, relationsCount: relations.length }); res.json({ success: true, data: { leftTable, rightTable, relations, }, }); } catch (error: any) { logger.error("테이블 엔티티 관계 조회 실패:", error); res.status(500).json({ success: false, message: "테이블 엔티티 관계 조회에 실패했습니다.", error: error.message, }); } } /** * 현재 테이블을 참조(FK로 연결)하는 테이블 목록 조회 * GET /api/table-management/columns/:tableName/referenced-by * * table_type_columns에서 reference_table이 현재 테이블인 레코드를 찾아서 * 해당 테이블과 FK 컬럼 정보를 반환합니다. * * 우선순위: 현재 사용자의 company_code > 공통('*') */ export async function getReferencedByTables( req: AuthenticatedRequest, res: Response ): Promise { try { const { tableName } = req.params; // 현재 사용자의 회사 코드 (없으면 '*' 사용) const userCompanyCode = req.user?.companyCode || "*"; logger.info( `=== 테이블 참조 관계 조회 시작: ${tableName} 을 참조하는 테이블 (회사코드: ${userCompanyCode}) ===` ); if (!tableName) { const response: ApiResponse = { success: false, message: "tableName 파라미터가 필요합니다.", error: { code: "MISSING_PARAMETERS", details: "tableName 경로 파라미터가 필요합니다.", }, }; res.status(400).json(response); return; } // table_type_columns에서 reference_table이 현재 테이블인 레코드 조회 // input_type이 'entity'인 것만 조회 (실제 FK 관계) // 우선순위: 현재 사용자의 company_code > 공통('*') // ROW_NUMBER를 사용해서 같은 테이블/컬럼 조합에서 회사코드 우선순위로 하나만 선택 const sqlQuery = ` WITH ranked AS ( SELECT ttc.table_name, ttc.column_name, ttc.column_label, ttc.reference_table, ttc.reference_column, ttc.display_column, ttc.company_code, ROW_NUMBER() OVER ( PARTITION BY ttc.table_name, ttc.column_name ORDER BY CASE WHEN ttc.company_code = $2 THEN 1 ELSE 2 END ) as rn FROM table_type_columns ttc WHERE ttc.reference_table = $1 AND ttc.input_type = 'entity' AND ttc.company_code IN ($2, '*') ) SELECT DISTINCT table_name, column_name, column_label, reference_table, reference_column, display_column, table_name as table_label FROM ranked WHERE rn = 1 ORDER BY table_name, column_name `; const result = await query(sqlQuery, [tableName, userCompanyCode]); const referencedByTables = result.map((row: any) => ({ tableName: row.table_name, tableLabel: row.table_label, columnName: row.column_name, columnLabel: row.column_label, referenceTable: row.reference_table, referenceColumn: row.reference_column || "id", displayColumn: row.display_column, })); logger.info( `테이블 참조 관계 조회 완료: ${referencedByTables.length}개 발견 (회사코드: ${userCompanyCode})` ); const response: ApiResponse = { success: true, message: `${referencedByTables.length}개의 테이블이 ${tableName}을 참조합니다.`, data: referencedByTables, }; res.status(200).json(response); } catch (error) { logger.error("테이블 참조 관계 조회 중 오류 발생:", error); const response: ApiResponse = { success: false, message: "테이블 참조 관계 조회 중 오류가 발생했습니다.", error: { code: "REFERENCED_BY_ERROR", details: error instanceof Error ? error.message : "Unknown error", }, }; res.status(500).json(response); } } // ======================================== // PK / 인덱스 관리 API // ======================================== /** * PK/인덱스 상태 조회 * GET /api/table-management/tables/:tableName/constraints */ export async function getTableConstraints( req: AuthenticatedRequest, res: Response ): Promise { try { const { tableName } = req.params; if (!tableName) { res.status(400).json({ success: false, message: "테이블명이 필요합니다." }); return; } // PK 조회 const pkResult = await query( `SELECT tc.conname AS constraint_name, array_agg(a.attname ORDER BY x.n) AS columns FROM pg_constraint tc JOIN pg_class c ON tc.conrelid = c.oid JOIN pg_namespace ns ON c.relnamespace = ns.oid CROSS JOIN LATERAL unnest(tc.conkey) WITH ORDINALITY AS x(attnum, n) JOIN pg_attribute a ON a.attrelid = tc.conrelid AND a.attnum = x.attnum WHERE ns.nspname = 'public' AND c.relname = $1 AND tc.contype = 'p' GROUP BY tc.conname`, [tableName] ); // array_agg 결과가 문자열로 올 수 있으므로 안전하게 배열로 변환 const parseColumns = (cols: any): string[] => { if (Array.isArray(cols)) return cols; if (typeof cols === "string") { // PostgreSQL 배열 형식: {col1,col2} return cols.replace(/[{}]/g, "").split(",").filter(Boolean); } return []; }; const primaryKey = pkResult.length > 0 ? { name: pkResult[0].constraint_name, columns: parseColumns(pkResult[0].columns) } : { name: "", columns: [] }; // 인덱스 조회 (PK 인덱스 제외) const indexResult = await query( `SELECT i.relname AS index_name, ix.indisunique AS is_unique, array_agg(a.attname ORDER BY x.n) AS columns FROM pg_index ix JOIN pg_class t ON ix.indrelid = t.oid JOIN pg_class i ON ix.indexrelid = i.oid JOIN pg_namespace ns ON t.relnamespace = ns.oid CROSS JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY AS x(attnum, n) JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = x.attnum WHERE ns.nspname = 'public' AND t.relname = $1 AND ix.indisprimary = false GROUP BY i.relname, ix.indisunique ORDER BY i.relname`, [tableName] ); const indexes = indexResult.map((row: any) => ({ name: row.index_name, columns: parseColumns(row.columns), isUnique: row.is_unique, })); logger.info(`제약조건 조회: ${tableName} - PK: ${primaryKey.columns.join(",")}, 인덱스: ${indexes.length}개`); res.status(200).json({ success: true, data: { primaryKey, indexes }, }); } catch (error) { logger.error("제약조건 조회 오류:", error); res.status(500).json({ success: false, message: "제약조건 조회 중 오류가 발생했습니다.", error: error instanceof Error ? error.message : "Unknown error", }); } } /** * PK 설정 * PUT /api/table-management/tables/:tableName/primary-key */ export async function setTablePrimaryKey( req: AuthenticatedRequest, res: Response ): Promise { try { const { tableName } = req.params; const { columns } = req.body; if (!tableName || !columns || !Array.isArray(columns) || columns.length === 0) { res.status(400).json({ success: false, message: "테이블명과 PK 컬럼 배열이 필요합니다." }); return; } logger.info(`PK 설정: ${tableName} → [${columns.join(", ")}]`); // 기존 PK 제약조건 이름 조회 const existingPk = await query( `SELECT conname FROM pg_constraint tc JOIN pg_class c ON tc.conrelid = c.oid JOIN pg_namespace ns ON c.relnamespace = ns.oid WHERE ns.nspname = 'public' AND c.relname = $1 AND tc.contype = 'p'`, [tableName] ); // 기존 PK 삭제 if (existingPk.length > 0) { const dropSql = `ALTER TABLE "public"."${tableName}" DROP CONSTRAINT "${existingPk[0].conname}"`; logger.info(`기존 PK 삭제: ${dropSql}`); await query(dropSql); } // 새 PK 추가 const colList = columns.map((c: string) => `"${c}"`).join(", "); const addSql = `ALTER TABLE "public"."${tableName}" ADD PRIMARY KEY (${colList})`; logger.info(`새 PK 추가: ${addSql}`); await query(addSql); res.status(200).json({ success: true, message: `PK가 설정되었습니다: ${columns.join(", ")}`, }); } catch (error) { logger.error("PK 설정 오류:", error); res.status(500).json({ success: false, message: "PK 설정 중 오류가 발생했습니다.", error: error instanceof Error ? error.message : "Unknown error", }); } } /** * 인덱스 토글 (생성/삭제) * POST /api/table-management/tables/:tableName/indexes */ export async function toggleTableIndex( req: AuthenticatedRequest, res: Response ): Promise { try { const { tableName } = req.params; const { columnName, indexType, action } = req.body; if (!tableName || !columnName || !indexType || !action) { res.status(400).json({ success: false, message: "tableName, columnName, indexType(index|unique), action(create|drop)이 필요합니다.", }); return; } const indexName = `idx_${tableName}_${columnName}${indexType === "unique" ? "_uq" : ""}`; logger.info(`인덱스 ${action}: ${indexName} (${indexType})`); if (action === "create") { let indexColumns = `"${columnName}"`; // 유니크 인덱스: company_code 컬럼이 있으면 복합 유니크 (회사별 유니크 보장) if (indexType === "unique") { const hasCompanyCode = await query( `SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1 AND column_name = 'company_code'`, [tableName] ); if (hasCompanyCode.length > 0) { indexColumns = `"company_code", "${columnName}"`; logger.info(`멀티테넌시: company_code + ${columnName} 복합 유니크 인덱스 생성`); } } const uniqueClause = indexType === "unique" ? "UNIQUE " : ""; const sql = `CREATE ${uniqueClause}INDEX IF NOT EXISTS "${indexName}" ON "public"."${tableName}" (${indexColumns})`; logger.info(`인덱스 생성: ${sql}`); await query(sql); } else if (action === "drop") { const sql = `DROP INDEX IF EXISTS "public"."${indexName}"`; logger.info(`인덱스 삭제: ${sql}`); await query(sql); } else { res.status(400).json({ success: false, message: "action은 create 또는 drop이어야 합니다." }); return; } res.status(200).json({ success: true, message: action === "create" ? `인덱스가 생성되었습니다: ${indexName}` : `인덱스가 삭제되었습니다: ${indexName}`, }); } catch (error: any) { logger.error("인덱스 토글 오류:", error); const errMsg = error.message || ""; let userMessage = "인덱스 설정 중 오류가 발생했습니다."; let duplicates: any[] = []; // 중복 데이터로 인한 UNIQUE 인덱스 생성 실패 if ( errMsg.includes("could not create unique index") || errMsg.includes("duplicate key") ) { const { columnName, tableName } = { ...req.params, ...req.body }; try { duplicates = await query( `SELECT company_code, "${columnName}", COUNT(*) as cnt FROM "${tableName}" GROUP BY company_code, "${columnName}" HAVING COUNT(*) > 1 ORDER BY cnt DESC LIMIT 10` ); } catch { try { duplicates = await query( `SELECT "${columnName}", COUNT(*) as cnt FROM "${tableName}" GROUP BY "${columnName}" HAVING COUNT(*) > 1 ORDER BY cnt DESC LIMIT 10` ); } catch { /* 중복 조회 실패 시 무시 */ } } const dupDetails = duplicates.length > 0 ? duplicates.map((d: any) => { const company = d.company_code ? `[${d.company_code}] ` : ""; return `${company}"${d[columnName] ?? 'NULL'}" (${d.cnt}건)`; }).join(", ") : ""; userMessage = dupDetails ? `[${columnName}] 컬럼에 같은 회사 내 중복 데이터가 있어 유니크 인덱스를 생성할 수 없습니다. 중복 값: ${dupDetails}` : `[${columnName}] 컬럼에 중복 데이터가 있어 유니크 인덱스를 생성할 수 없습니다. 중복 데이터를 먼저 정리해주세요.`; } res.status(500).json({ success: false, message: userMessage, error: errMsg, duplicates, }); } } /** * NOT NULL 토글 (회사별 소프트 제약조건) * PUT /api/table-management/tables/:tableName/columns/:columnName/nullable * * DB 레벨 ALTER TABLE 대신 table_type_columns.is_nullable을 회사별로 관리한다. * 멀티테넌시 환경에서 회사 A는 NOT NULL, 회사 B는 NULL 허용이 가능하다. */ export async function toggleColumnNullable( req: AuthenticatedRequest, res: Response ): Promise { try { const { tableName, columnName } = req.params; const { nullable } = req.body; const companyCode = req.user?.companyCode || "*"; if (!tableName || !columnName || typeof nullable !== "boolean") { res.status(400).json({ success: false, message: "tableName, columnName, nullable(boolean)이 필요합니다.", }); return; } // is_nullable 값: 'Y' = NULL 허용, 'N' = NOT NULL const isNullableValue = nullable ? "Y" : "N"; if (!nullable) { // NOT NULL 설정 전 - 해당 회사의 기존 데이터에 NULL이 있는지 확인 const hasCompanyCode = await query<{ column_name: string }>( `SELECT column_name FROM information_schema.columns WHERE table_name = $1 AND column_name = 'company_code'`, [tableName] ); if (hasCompanyCode.length > 0) { const nullCheckQuery = companyCode === "*" ? `SELECT COUNT(*) as null_count FROM "${tableName}" WHERE "${columnName}" IS NULL` : `SELECT COUNT(*) as null_count FROM "${tableName}" WHERE "${columnName}" IS NULL AND company_code = $1`; const nullCheckParams = companyCode === "*" ? [] : [companyCode]; const nullCheckResult = await query<{ null_count: string }>(nullCheckQuery, nullCheckParams); const nullCount = parseInt(nullCheckResult[0]?.null_count || "0", 10); if (nullCount > 0) { logger.warn(`NOT NULL 설정 불가 - 해당 회사에 NULL 데이터 존재: ${tableName}.${columnName}`, { companyCode, nullCount, }); res.status(400).json({ success: false, message: `현재 회사 데이터에 NULL 값이 ${nullCount}건 존재합니다. NULL 데이터를 먼저 정리해주세요.`, }); return; } } } // table_type_columns에 회사별 is_nullable 설정 UPSERT await query( `INSERT INTO table_type_columns (table_name, column_name, is_nullable, company_code, created_date, updated_date) VALUES ($1, $2, $3, $4, NOW(), NOW()) ON CONFLICT (table_name, column_name, company_code) DO UPDATE SET is_nullable = $3, updated_date = NOW()`, [tableName, columnName, isNullableValue, companyCode] ); logger.info(`NOT NULL 소프트 제약조건 변경: ${tableName}.${columnName} → is_nullable=${isNullableValue}`, { companyCode, }); res.status(200).json({ success: true, message: nullable ? `${columnName} 컬럼의 NOT NULL 제약이 해제되었습니다.` : `${columnName} 컬럼이 NOT NULL로 설정되었습니다.`, }); } catch (error: any) { logger.error("NOT NULL 토글 오류:", error); res.status(500).json({ success: false, message: "NOT NULL 설정 중 오류가 발생했습니다.", error: error instanceof Error ? error.message : "Unknown error", }); } } /** * UNIQUE 토글 (회사별 소프트 제약조건) * PUT /api/table-management/tables/:tableName/columns/:columnName/unique * * DB 레벨 인덱스 대신 table_type_columns.is_unique를 회사별로 관리한다. * 저장 시 앱 레벨에서 중복 검증을 수행한다. */ export async function toggleColumnUnique( req: AuthenticatedRequest, res: Response ): Promise { try { const { tableName, columnName } = req.params; const { unique } = req.body; const companyCode = req.user?.companyCode || "*"; if (!tableName || !columnName || typeof unique !== "boolean") { res.status(400).json({ success: false, message: "tableName, columnName, unique(boolean)이 필요합니다.", }); return; } const isUniqueValue = unique ? "Y" : "N"; if (unique) { // UNIQUE 설정 전 - 해당 회사의 기존 데이터에 중복이 있는지 확인 const hasCompanyCode = await query<{ column_name: string }>( `SELECT column_name FROM information_schema.columns WHERE table_name = $1 AND column_name = 'company_code'`, [tableName] ); if (hasCompanyCode.length > 0) { const dupQuery = companyCode === "*" ? `SELECT "${columnName}", COUNT(*) as cnt FROM "${tableName}" WHERE "${columnName}" IS NOT NULL GROUP BY "${columnName}" HAVING COUNT(*) > 1 LIMIT 10` : `SELECT "${columnName}", COUNT(*) as cnt FROM "${tableName}" WHERE "${columnName}" IS NOT NULL AND company_code = $1 GROUP BY "${columnName}" HAVING COUNT(*) > 1 LIMIT 10`; const dupParams = companyCode === "*" ? [] : [companyCode]; const dupResult = await query(dupQuery, dupParams); if (dupResult.length > 0) { const dupDetails = dupResult .map((d: any) => `"${d[columnName]}" (${d.cnt}건)`) .join(", "); res.status(400).json({ success: false, message: `현재 회사 데이터에 중복 값이 존재합니다. 중복 데이터를 먼저 정리해주세요. 중복 값: ${dupDetails}`, }); return; } } } // table_type_columns에 회사별 is_unique 설정 UPSERT await query( `INSERT INTO table_type_columns (table_name, column_name, is_unique, company_code, created_date, updated_date) VALUES ($1, $2, $3, $4, NOW(), NOW()) ON CONFLICT (table_name, column_name, company_code) DO UPDATE SET is_unique = $3, updated_date = NOW()`, [tableName, columnName, isUniqueValue, companyCode] ); logger.info(`UNIQUE 소프트 제약조건 변경: ${tableName}.${columnName} → is_unique=${isUniqueValue}`, { companyCode, }); res.status(200).json({ success: true, message: unique ? `${columnName} 컬럼이 UNIQUE로 설정되었습니다.` : `${columnName} 컬럼의 UNIQUE 제약이 해제되었습니다.`, }); } catch (error: any) { logger.error("UNIQUE 토글 오류:", error); res.status(500).json({ success: false, message: "UNIQUE 설정 중 오류가 발생했습니다.", error: error instanceof Error ? error.message : "Unknown error", }); } } /** * 회사별 채번 타입 컬럼 조회 (카테고리 패턴과 동일) * * @route GET /api/table-management/numbering-columns */ export async function getNumberingColumnsByCompany( req: AuthenticatedRequest, res: Response ): Promise { try { const companyCode = req.user?.companyCode; logger.info("회사별 채번 컬럼 조회 요청", { companyCode }); if (!companyCode) { res.status(400).json({ success: false, message: "회사 코드를 확인할 수 없습니다.", }); return; } const { getPool } = await import("../database/db"); const pool = getPool(); const targetCompanyCode = companyCode === "*" ? "*" : companyCode; const columnsQuery = ` SELECT DISTINCT ttc.table_name AS "tableName", COALESCE( tl.table_label, initcap(replace(ttc.table_name, '_', ' ')) ) AS "tableLabel", ttc.column_name AS "columnName", COALESCE( ttc.column_label, initcap(replace(ttc.column_name, '_', ' ')) ) AS "columnLabel", ttc.input_type AS "inputType" FROM table_type_columns ttc LEFT JOIN table_labels tl ON ttc.table_name = tl.table_name WHERE ttc.input_type = 'numbering' AND ttc.company_code = $1 ORDER BY ttc.table_name, ttc.column_name `; const columnsResult = await pool.query(columnsQuery, [targetCompanyCode]); logger.info("채번 컬럼 조회 완료", { companyCode, rowCount: columnsResult.rows.length, }); res.json({ success: true, data: columnsResult.rows, }); } catch (error: any) { logger.error("채번 컬럼 조회 실패", { error: error.message }); res.status(500).json({ success: false, message: "채번 컬럼 조회 중 오류가 발생했습니다.", error: error.message, }); } }