diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index 1a573834..9dea63b8 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -3563,19 +3563,21 @@ export async function getTableSchema( logger.info("테이블 스키마 조회", { tableName, companyCode }); - // information_schema와 table_type_columns를 JOIN하여 컬럼 정보와 라벨 정보 함께 가져오기 - // 회사별 라벨 우선, 없으면 공통(*) 라벨 사용 + // information_schema와 table_type_columns를 JOIN하여 컬럼 정보 + 회사별 제약조건 함께 가져오기 + // 회사별 설정 우선, 없으면 공통(*) 설정 사용 const schemaQuery = ` SELECT ic.column_name, ic.data_type, - ic.is_nullable, + ic.is_nullable AS db_is_nullable, ic.column_default, ic.character_maximum_length, ic.numeric_precision, ic.numeric_scale, COALESCE(ttc_company.column_label, ttc_common.column_label) AS column_label, - COALESCE(ttc_company.display_order, ttc_common.display_order) AS display_order + COALESCE(ttc_company.display_order, ttc_common.display_order) AS display_order, + COALESCE(ttc_company.is_nullable, ttc_common.is_nullable) AS ttc_is_nullable, + COALESCE(ttc_company.is_unique, ttc_common.is_unique) AS ttc_is_unique FROM information_schema.columns ic LEFT JOIN table_type_columns ttc_common ON ttc_common.table_name = ic.table_name @@ -3600,17 +3602,28 @@ export async function getTableSchema( return; } - // 컬럼 정보를 간단한 형태로 변환 (라벨 정보 포함) - const columnList = columns.map((col: any) => ({ - name: col.column_name, - label: col.column_label || col.column_name, // 라벨이 없으면 컬럼명 사용 - type: col.data_type, - nullable: col.is_nullable === "YES", - default: col.column_default, - maxLength: col.character_maximum_length, - precision: col.numeric_precision, - scale: col.numeric_scale, - })); + // 컬럼 정보를 간단한 형태로 변환 (회사별 제약조건 반영) + const columnList = columns.map((col: any) => { + // DB level nullable + 회사별 table_type_columns 제약조건 통합 + // table_type_columns에서 is_nullable = 'N'이면 필수 (DB가 nullable이어도) + const dbNullable = col.db_is_nullable === "YES"; + const ttcNotNull = col.ttc_is_nullable === "N"; + const effectiveNullable = ttcNotNull ? false : dbNullable; + + const ttcUnique = col.ttc_is_unique === "Y"; + + return { + name: col.column_name, + label: col.column_label || col.column_name, + type: col.data_type, + nullable: effectiveNullable, + unique: ttcUnique, + default: col.column_default, + maxLength: col.character_maximum_length, + precision: col.numeric_precision, + scale: col.numeric_scale, + }; + }); logger.info(`테이블 스키마 조회 성공: ${columnList.length}개 컬럼`); diff --git a/backend-node/src/controllers/dataflowExecutionController.ts b/backend-node/src/controllers/dataflowExecutionController.ts index 338fa628..71eb2211 100644 --- a/backend-node/src/controllers/dataflowExecutionController.ts +++ b/backend-node/src/controllers/dataflowExecutionController.ts @@ -8,6 +8,7 @@ import { Request, Response } from "express"; import { AuthenticatedRequest } from "../types/auth"; import { query } from "../database/db"; import logger from "../utils/logger"; +import { TableManagementService } from "../services/tableManagementService"; /** * 데이터 액션 실행 @@ -81,6 +82,19 @@ async function executeMainDatabaseAction( company_code: companyCode, }; + // UNIQUE 제약조건 검증 (INSERT/UPDATE/UPSERT 전) + if (["insert", "update", "upsert"].includes(actionType.toLowerCase())) { + const tms = new TableManagementService(); + const uniqueViolations = await tms.validateUniqueConstraints( + tableName, + dataWithCompany, + companyCode + ); + if (uniqueViolations.length > 0) { + throw new Error(`중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`); + } + } + switch (actionType.toLowerCase()) { case "insert": return await executeInsert(tableName, dataWithCompany); diff --git a/backend-node/src/controllers/dynamicFormController.ts b/backend-node/src/controllers/dynamicFormController.ts index 4ba6013a..31c11638 100644 --- a/backend-node/src/controllers/dynamicFormController.ts +++ b/backend-node/src/controllers/dynamicFormController.ts @@ -1,6 +1,7 @@ import { Response } from "express"; import { dynamicFormService } from "../services/dynamicFormService"; import { enhancedDynamicFormService } from "../services/enhancedDynamicFormService"; +import { TableManagementService } from "../services/tableManagementService"; import { AuthenticatedRequest } from "../types/auth"; import { formatPgError } from "../utils/pgErrorUtil"; @@ -48,6 +49,21 @@ export const saveFormData = async ( formDataWithMeta.company_code = companyCode; } + // UNIQUE 제약조건 검증 (INSERT 전) + const tms = new TableManagementService(); + const uniqueViolations = await tms.validateUniqueConstraints( + tableName, + formDataWithMeta, + companyCode || "*" + ); + if (uniqueViolations.length > 0) { + res.status(400).json({ + success: false, + message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`, + }); + return; + } + // 클라이언트 IP 주소 추출 const ipAddress = req.ip || @@ -112,6 +128,21 @@ export const saveFormDataEnhanced = async ( formDataWithMeta.company_code = companyCode; } + // UNIQUE 제약조건 검증 (INSERT 전) + const tmsEnhanced = new TableManagementService(); + const uniqueViolations = await tmsEnhanced.validateUniqueConstraints( + tableName, + formDataWithMeta, + companyCode || "*" + ); + if (uniqueViolations.length > 0) { + res.status(400).json({ + success: false, + message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`, + }); + return; + } + // 개선된 서비스 사용 const result = await enhancedDynamicFormService.saveFormData( screenId, @@ -153,12 +184,28 @@ export const updateFormData = async ( const formDataWithMeta = { ...data, updated_by: userId, - writer: data.writer || userId, // ✅ writer가 없으면 userId로 설정 + writer: data.writer || userId, updated_at: new Date(), }; + // UNIQUE 제약조건 검증 (UPDATE 시 자기 자신 제외) + const tmsUpdate = new TableManagementService(); + const uniqueViolations = await tmsUpdate.validateUniqueConstraints( + tableName, + formDataWithMeta, + companyCode || "*", + id + ); + if (uniqueViolations.length > 0) { + res.status(400).json({ + success: false, + message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`, + }); + return; + } + const result = await dynamicFormService.updateFormData( - id, // parseInt 제거 - 문자열 ID 지원 + id, tableName, formDataWithMeta ); @@ -209,11 +256,27 @@ export const updateFormDataPartial = async ( const newDataWithMeta = { ...newData, updated_by: userId, - writer: newData.writer || userId, // ✅ writer가 없으면 userId로 설정 + writer: newData.writer || userId, }; + // UNIQUE 제약조건 검증 (부분 UPDATE 시 자기 자신 제외) + const tmsPartial = new TableManagementService(); + const uniqueViolations = await tmsPartial.validateUniqueConstraints( + tableName, + newDataWithMeta, + companyCode || "*", + id + ); + if (uniqueViolations.length > 0) { + res.status(400).json({ + success: false, + message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`, + }); + return; + } + const result = await dynamicFormService.updateFormDataPartial( - id, // 🔧 parseInt 제거 - UUID 문자열도 지원 + id, tableName, originalData, newDataWithMeta diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index 0c35fdbd..5087a1c9 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -2087,6 +2087,23 @@ export async function multiTableSave( return; } + // UNIQUE 제약조건 검증 (트랜잭션 전에) + const tmsMulti = new TableManagementService(); + const uniqueViolations = await tmsMulti.validateUniqueConstraints( + mainTable.tableName, + mainData, + companyCode, + isUpdate ? mainData[mainTable.primaryKeyColumn] : undefined + ); + if (uniqueViolations.length > 0) { + client.release(); + res.status(400).json({ + success: false, + message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`, + }); + return; + } + await client.query("BEGIN"); // 1. 메인 테이블 저장 diff --git a/backend-node/src/middleware/errorHandler.ts b/backend-node/src/middleware/errorHandler.ts index baa5ce38..b592ae2e 100644 --- a/backend-node/src/middleware/errorHandler.ts +++ b/backend-node/src/middleware/errorHandler.ts @@ -41,7 +41,18 @@ export const errorHandler = ( // PostgreSQL 에러 코드 참조: https://www.postgresql.org/docs/current/errcodes-appendix.html if (pgError.code === "23505") { // unique_violation - error = new AppError("중복된 데이터가 존재합니다.", 400); + const constraint = pgError.constraint || ""; + const tbl = pgError.table || ""; + let col = ""; + if (constraint && tbl) { + const prefix = `${tbl}_`; + const suffix = "_key"; + if (constraint.startsWith(prefix) && constraint.endsWith(suffix)) { + col = constraint.slice(prefix.length, -suffix.length); + } + } + const detail = col ? ` [${col}]` : ""; + error = new AppError(`중복된 데이터가 존재합니다.${detail}`, 400); } else if (pgError.code === "23503") { // foreign_key_violation error = new AppError("참조 무결성 제약 조건 위반입니다.", 400); diff --git a/backend-node/src/routes/dataRoutes.ts b/backend-node/src/routes/dataRoutes.ts index de83e720..c8ba23d8 100644 --- a/backend-node/src/routes/dataRoutes.ts +++ b/backend-node/src/routes/dataRoutes.ts @@ -5,6 +5,8 @@ import { multiTableExcelService, TableChainConfig } from "../services/multiTable import { authenticateToken } from "../middleware/authMiddleware"; import { AuthenticatedRequest } from "../types/auth"; import { auditLogService } from "../services/auditLogService"; +import { TableManagementService } from "../services/tableManagementService"; +import { formatPgError } from "../utils/pgErrorUtil"; const router = express.Router(); @@ -950,6 +952,20 @@ router.post( console.log(`🏢 company_name 자동 추가: ${req.user.companyName}`); } + // UNIQUE 제약조건 검증 + const tms = new TableManagementService(); + const uniqueViolations = await tms.validateUniqueConstraints( + tableName, + enrichedData, + req.user?.companyCode || "*" + ); + if (uniqueViolations.length > 0) { + return res.status(400).json({ + success: false, + message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`, + }); + } + // 레코드 생성 const result = await dataService.createRecord(tableName, enrichedData); @@ -1019,6 +1035,21 @@ router.put( console.log(`✏️ 레코드 수정: ${tableName}/${id}`, data); + // UNIQUE 제약조건 검증 (자기 자신 제외) + const tmsUpdate = new TableManagementService(); + const uniqueViolations = await tmsUpdate.validateUniqueConstraints( + tableName, + data, + req.user?.companyCode || "*", + String(id) + ); + if (uniqueViolations.length > 0) { + return res.status(400).json({ + success: false, + message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`, + }); + } + // 레코드 수정 const result = await dataService.updateRecord(tableName, id, data); diff --git a/backend-node/src/services/multiTableExcelService.ts b/backend-node/src/services/multiTableExcelService.ts index d18f479b..7f9de79d 100644 --- a/backend-node/src/services/multiTableExcelService.ts +++ b/backend-node/src/services/multiTableExcelService.ts @@ -970,10 +970,11 @@ class MultiTableExcelService { const result = await pool.query( `SELECT c.column_name, - c.is_nullable, + c.is_nullable AS db_is_nullable, c.column_default, COALESCE(ttc.column_label, cl.column_label) AS column_label, - COALESCE(ttc.reference_table, cl.reference_table) AS reference_table + COALESCE(ttc.reference_table, cl.reference_table) AS reference_table, + COALESCE(ttc.is_nullable, cl.is_nullable) AS ttc_is_nullable FROM information_schema.columns c LEFT JOIN table_type_columns cl ON c.table_name = cl.table_name AND c.column_name = cl.column_name AND cl.company_code = '*' @@ -991,13 +992,13 @@ class MultiTableExcelService { // 시스템 컬럼 제외 if (MultiTableExcelService.SYSTEM_COLUMNS.has(colName)) continue; - // FK 컬럼 제외 (reference_table이 있는 컬럼 = 다른 테이블의 PK를 참조) - // 단, 비즈니스적으로 의미 있는 FK는 남길 수 있으므로, - // _id로 끝나면서 reference_table이 있는 경우만 제외 + // FK 컬럼 제외 if (row.reference_table && colName.endsWith("_id")) continue; const hasDefault = row.column_default !== null; - const isNullable = row.is_nullable === "YES"; + const dbNullable = row.db_is_nullable === "YES"; + const ttcNotNull = row.ttc_is_nullable === "N"; + const isNullable = ttcNotNull ? false : dbNullable; const isRequired = !isNullable && !hasDefault; columns.push({ diff --git a/backend-node/src/utils/pgErrorUtil.ts b/backend-node/src/utils/pgErrorUtil.ts index abec8f4e..3f7c56d3 100644 --- a/backend-node/src/utils/pgErrorUtil.ts +++ b/backend-node/src/utils/pgErrorUtil.ts @@ -37,8 +37,38 @@ export async function formatPgError( const detail = colName ? ` [${colName}]` : ""; return `필수 입력값이 누락되었습니다.${detail}`; } - case "23505": - return "중복된 데이터가 존재합니다."; + case "23505": { + // unique_violation + const constraint = error.constraint || ""; + const tblName = error.table || ""; + // constraint 이름에서 컬럼명 추출 시도 (예: item_mst_item_code_key → item_code) + let colName = ""; + if (constraint && tblName) { + const prefix = `${tblName}_`; + const suffix = "_key"; + if (constraint.startsWith(prefix) && constraint.endsWith(suffix)) { + colName = constraint.slice(prefix.length, -suffix.length); + } + } + if (colName && tblName && companyCode) { + try { + const rows = await query( + `SELECT column_label FROM table_type_columns + WHERE table_name = $1 AND column_name = $2 AND company_code = $3 + LIMIT 1`, + [tblName, colName, companyCode] + ); + const label = rows[0]?.column_label; + if (label) { + return `중복된 데이터가 존재합니다: ${label}`; + } + } catch { + // 폴백 + } + } + const detail = colName ? ` [${colName}]` : ""; + return `중복된 데이터가 존재합니다.${detail}`; + } case "23503": return "참조 무결성 제약 조건 위반입니다."; default: diff --git a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx index 4087be04..0ceb72fd 100644 --- a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx @@ -1061,19 +1061,22 @@ export const TableListComponent: React.FC = ({ } } - // 백엔드 DISTINCT API로 전체 고유값 조회 (페이징과 무관하게 모든 값 반환) - try { - const { apiClient } = await import("@/lib/api/client"); - const response = await apiClient.get(`/entity/${tableConfig.selectedTable}/distinct/${columnName}`); + // 카테고리/엔티티/코드 타입이 아닌 경우에만 DISTINCT API 사용 + // (카테고리 등은 DISTINCT로 조회하면 코드만 반환되어 label이 없음) + if (!["category", "entity", "code"].includes(inputType)) { + try { + const { apiClient } = await import("@/lib/api/client"); + const response = await apiClient.get(`/entity/${tableConfig.selectedTable}/distinct/${columnName}`); - if (response.data.success && response.data.data && response.data.data.length > 0) { - return response.data.data.map((item: any) => ({ - value: String(item.value), - label: String(item.label), - })); + if (response.data.success && response.data.data && response.data.data.length > 0) { + return response.data.data.map((item: any) => ({ + value: String(item.value), + label: String(item.label), + })); + } + } catch (error: any) { + // DISTINCT API 실패 시 현재 데이터 기반으로 fallback } - } catch (error: any) { - // DISTINCT API 실패 시 현재 데이터 기반으로 fallback } // fallback: 현재 로드된 데이터에서 고유 값 추출 diff --git a/frontend/lib/registry/components/v2-table-search-widget/TableSearchWidget.tsx b/frontend/lib/registry/components/v2-table-search-widget/TableSearchWidget.tsx index 39e888ab..8799c835 100644 --- a/frontend/lib/registry/components/v2-table-search-widget/TableSearchWidget.tsx +++ b/frontend/lib/registry/components/v2-table-search-widget/TableSearchWidget.tsx @@ -467,14 +467,9 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table if (hasNewOptions) { setSelectOptions((prev) => { - // 이미 로드된 옵션은 유지, 새로 로드된 옵션만 병합 - const merged = { ...prev }; - for (const [key, value] of Object.entries(loadedOptions)) { - if (!merged[key] || merged[key].length === 0) { - merged[key] = value; - } - } - return merged; + // 새로 로드된 옵션으로 항상 갱신 (카테고리 label 정보가 나중에 로드될 수 있으므로) + // 로드 실패한 컬럼의 기존 옵션은 유지 + return { ...prev, ...loadedOptions }; }); } };