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:
parent
afd936ff67
commit
fa97b361ed
|
|
@ -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: "데이터 검증 중 오류가 발생했습니다." });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 || "데이터 검증 실패",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue