diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index 5087a1c9..0ab73e09 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -3105,3 +3105,153 @@ export async function getNumberingColumnsByCompany( }); } } + +/** + * 엑셀 업로드 전 데이터 검증 + * POST /api/table-management/validate-excel + * Body: { tableName, data: Record[] } + */ +export async function validateExcelData( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName, data } = req.body as { + tableName: string; + data: Record[]; + }; + const companyCode = req.user?.companyCode || "*"; + + if (!tableName || !Array.isArray(data) || data.length === 0) { + res.status(400).json({ success: false, message: "tableName과 data 배열이 필요합니다." }); + return; + } + + const effectiveCompanyCode = + companyCode === "*" && data[0]?.company_code && data[0].company_code !== "*" + ? data[0].company_code + : companyCode; + + let constraintCols = await query<{ + column_name: string; + column_label: string; + is_nullable: string; + is_unique: string; + }>( + `SELECT column_name, + COALESCE(column_label, column_name) as column_label, + COALESCE(is_nullable, 'Y') as is_nullable, + COALESCE(is_unique, 'N') as is_unique + FROM table_type_columns + WHERE table_name = $1 AND company_code = $2`, + [tableName, effectiveCompanyCode] + ); + + if (constraintCols.length === 0 && effectiveCompanyCode !== "*") { + constraintCols = await query( + `SELECT column_name, + COALESCE(column_label, column_name) as column_label, + COALESCE(is_nullable, 'Y') as is_nullable, + COALESCE(is_unique, 'N') as is_unique + FROM table_type_columns + WHERE table_name = $1 AND company_code = '*'`, + [tableName] + ); + } + + const autoGenCols = ["id", "created_date", "updated_date", "writer", "company_code"]; + const notNullCols = constraintCols.filter((c) => c.is_nullable === "N" && !autoGenCols.includes(c.column_name)); + const uniqueCols = constraintCols.filter((c) => c.is_unique === "Y" && !autoGenCols.includes(c.column_name)); + + const notNullErrors: { row: number; column: string; label: string }[] = []; + const uniqueInExcelErrors: { rows: number[]; column: string; label: string; value: string }[] = []; + const uniqueInDbErrors: { row: number; column: string; label: string; value: string }[] = []; + + // NOT NULL 검증 + for (const col of notNullCols) { + for (let i = 0; i < data.length; i++) { + const val = data[i][col.column_name]; + if (val === null || val === undefined || String(val).trim() === "") { + notNullErrors.push({ row: i + 1, column: col.column_name, label: col.column_label }); + } + } + } + + // UNIQUE: 엑셀 내부 중복 + for (const col of uniqueCols) { + const seen = new Map(); + for (let i = 0; i < data.length; i++) { + const val = data[i][col.column_name]; + if (val === null || val === undefined || String(val).trim() === "") continue; + const key = String(val).trim(); + if (!seen.has(key)) seen.set(key, []); + seen.get(key)!.push(i + 1); + } + for (const [value, rows] of seen) { + if (rows.length > 1) { + uniqueInExcelErrors.push({ rows, column: col.column_name, label: col.column_label, value }); + } + } + } + + // UNIQUE: DB 기존 데이터와 중복 + const hasCompanyCode = await query( + `SELECT column_name FROM information_schema.columns + WHERE table_name = $1 AND column_name = 'company_code'`, + [tableName] + ); + + for (const col of uniqueCols) { + const values = [...new Set( + data + .map((row) => row[col.column_name]) + .filter((v) => v !== null && v !== undefined && String(v).trim() !== "") + .map((v) => String(v).trim()) + )]; + if (values.length === 0) continue; + + let dupQuery: string; + let dupParams: any[]; + const targetCompany = data[0]?.company_code || (effectiveCompanyCode !== "*" ? effectiveCompanyCode : null); + + if (hasCompanyCode.length > 0 && targetCompany) { + dupQuery = `SELECT "${col.column_name}" FROM "${tableName}" WHERE "${col.column_name}" = ANY($1) AND company_code = $2`; + dupParams = [values, targetCompany]; + } else { + dupQuery = `SELECT "${col.column_name}" FROM "${tableName}" WHERE "${col.column_name}" = ANY($1)`; + dupParams = [values]; + } + + const existingRows = await query>(dupQuery, dupParams); + const existingSet = new Set(existingRows.map((r) => String(r[col.column_name]).trim())); + + for (let i = 0; i < data.length; i++) { + const val = data[i][col.column_name]; + if (val === null || val === undefined || String(val).trim() === "") continue; + if (existingSet.has(String(val).trim())) { + uniqueInDbErrors.push({ row: i + 1, column: col.column_name, label: col.column_label, value: String(val) }); + } + } + } + + const isValid = notNullErrors.length === 0 && uniqueInExcelErrors.length === 0 && uniqueInDbErrors.length === 0; + + res.json({ + success: true, + data: { + isValid, + notNullErrors, + uniqueInExcelErrors, + uniqueInDbErrors, + summary: { + notNull: notNullErrors.length, + uniqueInExcel: uniqueInExcelErrors.length, + uniqueInDb: uniqueInDbErrors.length, + }, + }, + }); + } catch (error: any) { + logger.error("엑셀 데이터 검증 오류:", error); + res.status(500).json({ success: false, message: "데이터 검증 중 오류가 발생했습니다." }); + } +} diff --git a/backend-node/src/routes/tableManagementRoutes.ts b/backend-node/src/routes/tableManagementRoutes.ts index 92449cf6..6a4a8ce8 100644 --- a/backend-node/src/routes/tableManagementRoutes.ts +++ b/backend-node/src/routes/tableManagementRoutes.ts @@ -27,6 +27,7 @@ import { getCategoryColumnsByCompany, // 🆕 회사별 카테고리 컬럼 조회 getNumberingColumnsByCompany, // 채번 타입 컬럼 조회 multiTableSave, // 🆕 범용 다중 테이블 저장 + validateExcelData, // 엑셀 업로드 전 데이터 검증 getTableEntityRelations, // 🆕 두 테이블 간 엔티티 관계 조회 getReferencedByTables, // 🆕 현재 테이블을 참조하는 테이블 조회 getTableConstraints, // 🆕 PK/인덱스 상태 조회 @@ -280,4 +281,9 @@ router.get("/menu/:menuObjid/category-columns", getCategoryColumnsByMenu); */ router.post("/multi-table-save", multiTableSave); +/** + * 엑셀 업로드 전 데이터 검증 + */ +router.post("/validate-excel", validateExcelData); + export default router; diff --git a/frontend/components/common/ExcelUploadModal.tsx b/frontend/components/common/ExcelUploadModal.tsx index da749392..d3a4b187 100644 --- a/frontend/components/common/ExcelUploadModal.tsx +++ b/frontend/components/common/ExcelUploadModal.tsx @@ -24,6 +24,7 @@ import { FileSpreadsheet, AlertCircle, CheckCircle2, + XCircle, ArrowRight, Zap, Copy, @@ -136,6 +137,10 @@ export const ExcelUploadModal: React.FC = ({ // 중복 처리 방법 (전역 설정) const [duplicateAction, setDuplicateAction] = useState<"overwrite" | "skip">("skip"); + // 엑셀 데이터 사전 검증 결과 + const [isDataValidating, setIsDataValidating] = useState(false); + const [validationResult, setValidationResult] = useState(null); + // 카테고리 검증 관련 const [showCategoryValidation, setShowCategoryValidation] = useState(false); const [isCategoryValidating, setIsCategoryValidating] = useState(false); @@ -874,6 +879,43 @@ export const ExcelUploadModal: React.FC = ({ setShowCategoryValidation(true); return; } + + // 데이터 사전 검증 (NOT NULL 값 누락, UNIQUE 중복) + setIsDataValidating(true); + try { + const { validateExcelData: validateExcel } = await import("@/lib/api/tableManagement"); + + // 매핑된 데이터 구성 + const mappedForValidation = allData.map((row) => { + const mapped: Record = {}; + columnMappings.forEach((m) => { + if (m.systemColumn) { + let colName = m.systemColumn; + if (isMasterDetail && colName.includes(".")) { + colName = colName.split(".")[1]; + } + mapped[colName] = row[m.excelColumn]; + } + }); + return mapped; + }).filter((row) => Object.values(row).some((v) => v !== null && v !== undefined && String(v).trim() !== "")); + + if (mappedForValidation.length > 0) { + const result = await validateExcel(tableName, mappedForValidation); + if (result.success && result.data) { + setValidationResult(result.data); + } else { + setValidationResult(null); + } + } else { + setValidationResult(null); + } + } catch (err) { + console.warn("데이터 사전 검증 실패 (무시):", err); + setValidationResult(null); + } finally { + setIsDataValidating(false); + } } setCurrentStep((prev) => Math.min(prev + 1, 3)); @@ -1301,6 +1343,9 @@ export const ExcelUploadModal: React.FC = ({ setSystemColumns([]); setColumnMappings([]); setDuplicateAction("skip"); + // 검증 상태 초기화 + setValidationResult(null); + setIsDataValidating(false); // 카테고리 검증 초기화 setShowCategoryValidation(false); setCategoryMismatches({}); @@ -1870,6 +1915,100 @@ export const ExcelUploadModal: React.FC = ({ + {/* 데이터 검증 결과 */} + {validationResult && !validationResult.isValid && ( +
+ {/* NOT NULL 에러 */} + {validationResult.notNullErrors.length > 0 && ( +
+

+ + 필수값 누락 ({validationResult.notNullErrors.length}건) +

+
+ {(() => { + const grouped = new Map(); + for (const err of validationResult.notNullErrors) { + const key = err.label; + if (!grouped.has(key)) grouped.set(key, []); + grouped.get(key)!.push(err.row); + } + return Array.from(grouped).map(([label, rows]) => ( +

+ {label}: {rows.length > 5 ? `행 ${rows.slice(0, 5).join(", ")} 외 ${rows.length - 5}건` : `행 ${rows.join(", ")}`} +

+ )); + })()} +
+
+ )} + + {/* 엑셀 내부 중복 */} + {validationResult.uniqueInExcelErrors.length > 0 && ( +
+

+ + 엑셀 내 중복 ({validationResult.uniqueInExcelErrors.length}건) +

+
+ {validationResult.uniqueInExcelErrors.slice(0, 10).map((err, i) => ( +

+ {err.label} "{err.value}": 행 {err.rows.join(", ")} +

+ ))} + {validationResult.uniqueInExcelErrors.length > 10 && ( +

...외 {validationResult.uniqueInExcelErrors.length - 10}건

+ )} +
+
+ )} + + {/* DB 기존 데이터 중복 */} + {validationResult.uniqueInDbErrors.length > 0 && ( +
+

+ + DB 기존 데이터와 중복 ({validationResult.uniqueInDbErrors.length}건) +

+
+ {(() => { + const grouped = new Map(); + for (const err of validationResult.uniqueInDbErrors) { + const key = err.label; + if (!grouped.has(key)) grouped.set(key, []); + const existing = grouped.get(key)!.find((e) => e.value === err.value); + if (existing) existing.rows.push(err.row); + else grouped.get(key)!.push({ value: err.value, rows: [err.row] }); + } + return Array.from(grouped).map(([label, items]) => ( +
+ {items.slice(0, 5).map((item, i) => ( +

+ {label} "{item.value}": 행 {item.rows.join(", ")} +

+ ))} + {items.length > 5 &&

...외 {items.length - 5}건

} +
+ )); + })()} +
+
+ )} +
+ )} + + {validationResult?.isValid && ( +
+

+ + 데이터 검증 통과 +

+

+ 필수값 및 중복 검사를 통과했습니다. +

+
+ )} +

컬럼 매핑

@@ -1948,10 +2087,10 @@ export const ExcelUploadModal: React.FC = ({ {currentStep < 3 ? ( )} diff --git a/frontend/lib/api/tableManagement.ts b/frontend/lib/api/tableManagement.ts index 24ef25a0..50824d7b 100644 --- a/frontend/lib/api/tableManagement.ts +++ b/frontend/lib/api/tableManagement.ts @@ -372,3 +372,30 @@ export const getTableColumns = (tableName: string) => tableManagementApi.getColu export const updateColumnType = (tableName: string, columnName: string, settings: ColumnSettings) => tableManagementApi.updateColumnSettings(tableName, columnName, settings); export const checkTableExists = (tableName: string) => tableManagementApi.checkTableExists(tableName); + +// 엑셀 업로드 전 데이터 검증 API +export interface ExcelValidationResult { + isValid: boolean; + notNullErrors: { row: number; column: string; label: string }[]; + uniqueInExcelErrors: { rows: number[]; column: string; label: string; value: string }[]; + uniqueInDbErrors: { row: number; column: string; label: string; value: string }[]; + summary: { notNull: number; uniqueInExcel: number; uniqueInDb: number }; +} + +export async function validateExcelData( + tableName: string, + data: Record[] +): Promise> { + try { + const response = await apiClient.post>( + "/table-management/validate-excel", + { tableName, data } + ); + return response.data; + } catch (error: any) { + return { + success: false, + message: error.response?.data?.message || "데이터 검증 실패", + }; + } +}