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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user