feat: add Excel data validation functionality

- Implemented a new API endpoint for validating Excel data before upload, ensuring that required fields are not null and that unique constraints are respected.
- Added frontend integration to handle validation results, displaying errors for missing required fields and duplicates within the Excel file and against existing database records.
- Enhanced user experience by providing immediate feedback on data validity during the upload process.

Made-with: Cursor
This commit is contained in:
kjs 2026-03-11 14:16:50 +09:00
parent afd936ff67
commit fa97b361ed
4 changed files with 328 additions and 4 deletions

View File

@ -3105,3 +3105,153 @@ export async function getNumberingColumnsByCompany(
});
}
}
/**
*
* POST /api/table-management/validate-excel
* Body: { tableName, data: Record<string,any>[] }
*/
export async function validateExcelData(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName, data } = req.body as {
tableName: string;
data: Record<string, any>[];
};
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<string, number[]>();
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<Record<string, any>>(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: "데이터 검증 중 오류가 발생했습니다." });
}
}

View File

@ -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;

View File

@ -24,6 +24,7 @@ import {
FileSpreadsheet,
AlertCircle,
CheckCircle2,
XCircle,
ArrowRight,
Zap,
Copy,
@ -136,6 +137,10 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
// 중복 처리 방법 (전역 설정)
const [duplicateAction, setDuplicateAction] = useState<"overwrite" | "skip">("skip");
// 엑셀 데이터 사전 검증 결과
const [isDataValidating, setIsDataValidating] = useState(false);
const [validationResult, setValidationResult] = useState<import("@/lib/api/tableManagement").ExcelValidationResult | null>(null);
// 카테고리 검증 관련
const [showCategoryValidation, setShowCategoryValidation] = useState(false);
const [isCategoryValidating, setIsCategoryValidating] = useState(false);
@ -874,6 +879,43 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
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<string, any> = {};
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<ExcelUploadModalProps> = ({
setSystemColumns([]);
setColumnMappings([]);
setDuplicateAction("skip");
// 검증 상태 초기화
setValidationResult(null);
setIsDataValidating(false);
// 카테고리 검증 초기화
setShowCategoryValidation(false);
setCategoryMismatches({});
@ -1870,6 +1915,100 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
</div>
</div>
{/* 데이터 검증 결과 */}
{validationResult && !validationResult.isValid && (
<div className="space-y-3">
{/* NOT NULL 에러 */}
{validationResult.notNullErrors.length > 0 && (
<div className="rounded-md border border-destructive bg-destructive/10 p-4">
<h3 className="flex items-center gap-2 text-sm font-medium text-destructive sm:text-base">
<XCircle className="h-4 w-4" />
({validationResult.notNullErrors.length})
</h3>
<div className="mt-2 max-h-[150px] space-y-0.5 overflow-y-auto text-[10px] text-destructive sm:text-xs">
{(() => {
const grouped = new Map<string, number[]>();
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]) => (
<p key={label}>
<span className="font-medium">{label}</span>: {rows.length > 5 ? `${rows.slice(0, 5).join(", ")}${rows.length - 5}` : `${rows.join(", ")}`}
</p>
));
})()}
</div>
</div>
)}
{/* 엑셀 내부 중복 */}
{validationResult.uniqueInExcelErrors.length > 0 && (
<div className="rounded-md border border-warning bg-warning/10 p-4">
<h3 className="flex items-center gap-2 text-sm font-medium text-warning sm:text-base">
<AlertCircle className="h-4 w-4" />
({validationResult.uniqueInExcelErrors.length})
</h3>
<div className="mt-2 max-h-[150px] space-y-0.5 overflow-y-auto text-[10px] text-warning sm:text-xs">
{validationResult.uniqueInExcelErrors.slice(0, 10).map((err, i) => (
<p key={i}>
<span className="font-medium">{err.label}</span> "{err.value}": {err.rows.join(", ")}
</p>
))}
{validationResult.uniqueInExcelErrors.length > 10 && (
<p className="font-medium">... {validationResult.uniqueInExcelErrors.length - 10}</p>
)}
</div>
</div>
)}
{/* DB 기존 데이터 중복 */}
{validationResult.uniqueInDbErrors.length > 0 && (
<div className="rounded-md border border-destructive bg-destructive/10 p-4">
<h3 className="flex items-center gap-2 text-sm font-medium text-destructive sm:text-base">
<XCircle className="h-4 w-4" />
DB ({validationResult.uniqueInDbErrors.length})
</h3>
<div className="mt-2 max-h-[150px] space-y-0.5 overflow-y-auto text-[10px] text-destructive sm:text-xs">
{(() => {
const grouped = new Map<string, { value: string; rows: number[] }[]>();
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]) => (
<div key={label}>
{items.slice(0, 5).map((item, i) => (
<p key={i}>
<span className="font-medium">{label}</span> "{item.value}": {item.rows.join(", ")}
</p>
))}
{items.length > 5 && <p className="font-medium">... {items.length - 5}</p>}
</div>
));
})()}
</div>
</div>
)}
</div>
)}
{validationResult?.isValid && (
<div className="rounded-md border border-success bg-success/10 p-4">
<h3 className="flex items-center gap-2 text-sm font-medium text-success sm:text-base">
<CheckCircle2 className="h-4 w-4" />
</h3>
<p className="mt-1 text-[10px] text-success sm:text-xs">
.
</p>
</div>
)}
<div className="rounded-md border border-border bg-muted/50 p-4">
<h3 className="text-sm font-medium sm:text-base"> </h3>
<div className="mt-2 space-y-1 text-[10px] text-muted-foreground sm:text-xs">
@ -1948,10 +2087,10 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
{currentStep < 3 ? (
<Button
onClick={handleNext}
disabled={isUploading || isCategoryValidating || (currentStep === 1 && !file)}
disabled={isUploading || isCategoryValidating || isDataValidating || (currentStep === 1 && !file)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{isCategoryValidating ? (
{isCategoryValidating || isDataValidating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
@ -1964,11 +2103,13 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
<Button
onClick={handleUpload}
disabled={
isUploading || columnMappings.filter((m) => m.systemColumn).length === 0
isUploading ||
columnMappings.filter((m) => m.systemColumn).length === 0 ||
(validationResult !== null && !validationResult.isValid)
}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{isUploading ? "업로드 중..." : "업로드"}
{isUploading ? "업로드 중..." : validationResult && !validationResult.isValid ? "검증 실패 - 이전으로 돌아가 수정" : "업로드"}
</Button>
)}
</DialogFooter>

View File

@ -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<string, any>[]
): Promise<ApiResponse<ExcelValidationResult>> {
try {
const response = await apiClient.post<ApiResponse<ExcelValidationResult>>(
"/table-management/validate-excel",
{ tableName, data }
);
return response.data;
} catch (error: any) {
return {
success: false,
message: error.response?.data?.message || "데이터 검증 실패",
};
}
}