ERP-node/backend-node/src/services/enhancedDynamicFormService.ts

787 lines
20 KiB
TypeScript
Raw Normal View History

/**
*
*
*/
import { PrismaClient } from "@prisma/client";
import {
WebType,
DynamicWebType,
normalizeWebType,
isValidWebType,
WEB_TYPE_TO_POSTGRES_CONVERTER,
WEB_TYPE_VALIDATION_PATTERNS,
} from "../types/unified-web-types";
import { DataflowControlService } from "./dataflowControlService";
const prisma = new PrismaClient();
// 테이블 컬럼 정보
export interface TableColumn {
column_name: string;
data_type: string;
is_nullable: string;
column_default: any;
character_maximum_length?: number;
numeric_precision?: number;
numeric_scale?: number;
}
// 컬럼 웹타입 정보
export interface ColumnWebTypeInfo {
columnName: string;
webType: WebType;
isRequired: boolean;
validationRules?: Record<string, any>;
defaultValue?: any;
}
// 폼 데이터 검증 결과
export interface FormValidationResult {
isValid: boolean;
errors: FormValidationError[];
warnings: FormValidationWarning[];
transformedData: Record<string, any>;
}
export interface FormValidationError {
field: string;
code: string;
message: string;
value?: any;
}
export interface FormValidationWarning {
field: string;
code: string;
message: string;
suggestion?: string;
}
// 저장 결과
export interface FormDataResult {
success: boolean;
message: string;
data?: any;
affectedRows?: number;
insertedId?: any;
validationResult?: FormValidationResult;
}
export class EnhancedDynamicFormService {
private dataflowControlService = new DataflowControlService();
private columnCache = new Map<string, TableColumn[]>();
private webTypeCache = new Map<string, ColumnWebTypeInfo[]>();
/**
* ( )
*/
async saveFormData(
screenId: number,
tableName: string,
data: Record<string, any>
): Promise<FormDataResult> {
const startTime = Date.now();
try {
console.log(`🚀 개선된 폼 저장 시작: ${tableName}`, {
screenId,
dataKeys: Object.keys(data),
timestamp: new Date().toISOString(),
});
// 1. 테이블 존재 여부 확인
const tableExists = await this.validateTableExists(tableName);
if (!tableExists) {
return {
success: false,
message: `테이블 '${tableName}'이 존재하지 않습니다.`,
};
}
// 2. 스키마 정보 로드
const [tableColumns, columnWebTypes] = await Promise.all([
this.getTableColumns(tableName),
this.getColumnWebTypes(tableName),
]);
// 3. 폼 데이터 검증
const validationResult = await this.validateFormData(
data,
tableColumns,
columnWebTypes,
tableName
);
if (!validationResult.isValid) {
console.error("❌ 폼 데이터 검증 실패:", validationResult.errors);
return {
success: false,
message: this.formatValidationErrors(validationResult.errors),
validationResult,
};
}
// 4. 데이터 저장 수행
const saveResult = await this.performDataSave(
tableName,
validationResult.transformedData,
tableColumns
);
const duration = Date.now() - startTime;
console.log(`✅ 폼 저장 완료: ${duration}ms`);
return {
success: true,
message: "데이터가 성공적으로 저장되었습니다.",
data: saveResult.data,
affectedRows: saveResult.affectedRows,
insertedId: saveResult.insertedId,
validationResult,
};
} catch (error: any) {
console.error("❌ 폼 저장 중 오류:", error);
return {
success: false,
message: this.formatErrorMessage(error),
data: { error: error.message, stack: error.stack },
};
}
}
/**
*
*/
private async validateTableExists(tableName: string): Promise<boolean> {
try {
const result = await prisma.$queryRawUnsafe(
`
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = $1
) as exists
`,
tableName
);
return (result as any)[0]?.exists || false;
} catch (error) {
console.error(`❌ 테이블 존재 여부 확인 실패: ${tableName}`, error);
return false;
}
}
/**
* ( )
*/
private async getTableColumns(tableName: string): Promise<TableColumn[]> {
// 캐시 확인
const cached = this.columnCache.get(tableName);
if (cached) {
return cached;
}
try {
const columns = (await prisma.$queryRawUnsafe(
`
SELECT
column_name,
data_type,
is_nullable,
column_default,
character_maximum_length,
numeric_precision,
numeric_scale
FROM information_schema.columns
WHERE table_name = $1
ORDER BY ordinal_position
`,
tableName
)) as TableColumn[];
// 캐시 저장 (10분)
this.columnCache.set(tableName, columns);
setTimeout(() => this.columnCache.delete(tableName), 10 * 60 * 1000);
return columns;
} catch (error) {
console.error(`❌ 테이블 컬럼 정보 조회 실패: ${tableName}`, error);
throw new Error(`테이블 컬럼 정보를 조회할 수 없습니다: ${error}`);
}
}
/**
*
*/
private async getColumnWebTypes(
tableName: string
): Promise<ColumnWebTypeInfo[]> {
// 캐시 확인
const cached = this.webTypeCache.get(tableName);
if (cached) {
return cached;
}
try {
// table_type_columns에서 웹타입 정보 조회
const webTypeData = (await prisma.$queryRawUnsafe(
`
SELECT
column_name,
web_type,
is_nullable,
detail_settings
FROM table_type_columns
WHERE table_name = $1
`,
tableName
)) as any[];
const columnWebTypes: ColumnWebTypeInfo[] = webTypeData.map((row) => ({
columnName: row.column_name,
webType: normalizeWebType(row.web_type || "text"),
isRequired: row.is_nullable === "N",
validationRules: this.parseDetailSettings(row.detail_settings),
defaultValue: null,
}));
// 캐시 저장 (10분)
this.webTypeCache.set(tableName, columnWebTypes);
setTimeout(() => this.webTypeCache.delete(tableName), 10 * 60 * 1000);
return columnWebTypes;
} catch (error) {
console.error(`❌ 컬럼 웹타입 정보 조회 실패: ${tableName}`, error);
// 실패 시 빈 배열 반환 (기본 검증만 수행)
return [];
}
}
/**
*
*/
private parseDetailSettings(
detailSettings: string | null
): Record<string, any> {
if (!detailSettings) return {};
try {
return JSON.parse(detailSettings);
} catch {
return {};
}
}
/**
*
*/
private async validateFormData(
data: Record<string, any>,
tableColumns: TableColumn[],
columnWebTypes: ColumnWebTypeInfo[],
tableName: string
): Promise<FormValidationResult> {
const errors: FormValidationError[] = [];
const warnings: FormValidationWarning[] = [];
const transformedData: Record<string, any> = {};
const columnMap = new Map(
tableColumns.map((col) => [col.column_name, col])
);
const webTypeMap = new Map(columnWebTypes.map((wt) => [wt.columnName, wt]));
console.log(`📋 폼 데이터 검증 시작: ${tableName}`, {
inputFields: Object.keys(data).length,
tableColumns: tableColumns.length,
webTypeColumns: columnWebTypes.length,
});
// 입력된 각 필드 검증
for (const [fieldName, value] of Object.entries(data)) {
const column = columnMap.get(fieldName);
const webTypeInfo = webTypeMap.get(fieldName);
// 1. 컬럼 존재 여부 확인
if (!column) {
errors.push({
field: fieldName,
code: "COLUMN_NOT_EXISTS",
message: `테이블 '${tableName}'에 '${fieldName}' 컬럼이 존재하지 않습니다.`,
value,
});
continue;
}
// 2. 필수값 검증
if (webTypeInfo?.isRequired && this.isEmptyValue(value)) {
errors.push({
field: fieldName,
code: "REQUIRED_FIELD",
message: `'${fieldName}'은(는) 필수 입력 항목입니다.`,
value,
});
continue;
}
// 3. 웹타입별 검증 및 변환
if (webTypeInfo?.webType) {
const validationResult = this.validateFieldByWebType(
fieldName,
value,
webTypeInfo.webType,
webTypeInfo.validationRules
);
if (!validationResult.isValid) {
errors.push(validationResult.error!);
continue;
}
transformedData[fieldName] = validationResult.transformedValue;
} else {
// 웹타입 정보가 없는 경우 DB 타입 기반 변환
transformedData[fieldName] = this.convertValueForPostgreSQL(
value,
column.data_type
);
}
// 4. DB 제약조건 검증
const constraintValidation = this.validateDatabaseConstraints(
fieldName,
transformedData[fieldName],
column
);
if (!constraintValidation.isValid) {
errors.push(constraintValidation.error!);
continue;
}
}
// 필수 컬럼 누락 확인
const requiredColumns = columnWebTypes.filter((wt) => wt.isRequired);
for (const requiredCol of requiredColumns) {
if (
!(requiredCol.columnName in data) ||
this.isEmptyValue(data[requiredCol.columnName])
) {
if (!errors.some((e) => e.field === requiredCol.columnName)) {
errors.push({
field: requiredCol.columnName,
code: "MISSING_REQUIRED_FIELD",
message: `필수 입력 항목 '${requiredCol.columnName}'이 누락되었습니다.`,
});
}
}
}
console.log(`📋 폼 데이터 검증 완료:`, {
errors: errors.length,
warnings: warnings.length,
transformedFields: Object.keys(transformedData).length,
});
return {
isValid: errors.length === 0,
errors,
warnings,
transformedData,
};
}
/**
*
*/
private validateFieldByWebType(
fieldName: string,
value: any,
webType: WebType,
validationRules?: Record<string, any>
): { isValid: boolean; error?: FormValidationError; transformedValue?: any } {
// 빈 값 처리
if (this.isEmptyValue(value)) {
return { isValid: true, transformedValue: null };
}
// 웹타입 유효성 확인
if (!isValidWebType(webType)) {
return {
isValid: false,
error: {
field: fieldName,
code: "INVALID_WEB_TYPE",
message: `'${fieldName}'의 웹타입 '${webType}'이 올바르지 않습니다.`,
value,
},
};
}
// 패턴 검증
const pattern = WEB_TYPE_VALIDATION_PATTERNS[webType];
if (pattern && !pattern.test(String(value))) {
return {
isValid: false,
error: {
field: fieldName,
code: "INVALID_FORMAT",
message: `'${fieldName}'의 형식이 올바르지 않습니다.`,
value,
},
};
}
// 값 변환
try {
const converter = WEB_TYPE_TO_POSTGRES_CONVERTER[webType];
const transformedValue = converter ? converter(value) : value;
return { isValid: true, transformedValue };
} catch (error) {
return {
isValid: false,
error: {
field: fieldName,
code: "CONVERSION_ERROR",
message: `'${fieldName}' 값 변환 중 오류가 발생했습니다: ${error}`,
value,
},
};
}
}
/**
*
*/
private validateDatabaseConstraints(
fieldName: string,
value: any,
column: TableColumn
): { isValid: boolean; error?: FormValidationError } {
// NULL 제약조건
if (
column.is_nullable === "NO" &&
(value === null || value === undefined)
) {
return {
isValid: false,
error: {
field: fieldName,
code: "NOT_NULL_VIOLATION",
message: `'${fieldName}'에는 NULL 값을 입력할 수 없습니다.`,
value,
},
};
}
// 문자열 길이 제약조건
if (column.character_maximum_length && typeof value === "string") {
if (value.length > column.character_maximum_length) {
return {
isValid: false,
error: {
field: fieldName,
code: "STRING_TOO_LONG",
message: `'${fieldName}'의 길이는 최대 ${column.character_maximum_length}자까지 입력할 수 있습니다.`,
value,
},
};
}
}
// 숫자 정밀도 검증
if (column.numeric_precision && typeof value === "number") {
const totalDigits = Math.abs(value).toString().replace(".", "").length;
if (totalDigits > column.numeric_precision) {
return {
isValid: false,
error: {
field: fieldName,
code: "NUMERIC_OVERFLOW",
message: `'${fieldName}'의 숫자 자릿수가 허용 범위를 초과했습니다.`,
value,
},
};
}
}
return { isValid: true };
}
/**
*
*/
private isEmptyValue(value: any): boolean {
return (
value === null ||
value === undefined ||
value === "" ||
(Array.isArray(value) && value.length === 0)
);
}
/**
*
*/
private async performDataSave(
tableName: string,
data: Record<string, any>,
tableColumns: TableColumn[]
): Promise<{ data?: any; affectedRows: number; insertedId?: any }> {
try {
// Primary Key 확인
const primaryKeys = await this.getPrimaryKeys(tableName);
const hasExistingRecord =
primaryKeys.length > 0 &&
primaryKeys.every((pk) => data[pk] !== undefined && data[pk] !== null);
if (hasExistingRecord) {
// UPDATE 수행
return await this.performUpdate(tableName, data, primaryKeys);
} else {
// INSERT 수행
return await this.performInsert(tableName, data);
}
} catch (error) {
console.error(`❌ 데이터 저장 실패: ${tableName}`, error);
throw error;
}
}
/**
* Primary Key
*/
private async getPrimaryKeys(tableName: string): Promise<string[]> {
try {
const result = (await prisma.$queryRawUnsafe(
`
SELECT column_name
FROM information_schema.key_column_usage
WHERE table_name = $1
AND constraint_name LIKE '%_pkey'
`,
tableName
)) as any[];
return result.map((row) => row.column_name);
} catch (error) {
console.error(`❌ Primary Key 조회 실패: ${tableName}`, error);
return [];
}
}
/**
* INSERT
*/
private async performInsert(
tableName: string,
data: Record<string, any>
): Promise<{ data?: any; affectedRows: number; insertedId?: any }> {
const columns = Object.keys(data);
const values = Object.values(data);
const placeholders = values.map((_, index) => `$${index + 1}`).join(", ");
const insertQuery = `
INSERT INTO ${tableName} (${columns.join(", ")})
VALUES (${placeholders})
RETURNING *
`;
console.log(`📝 INSERT 쿼리 실행: ${tableName}`, {
columns: columns.length,
query: insertQuery.replace(/\n\s+/g, " "),
});
const result = (await prisma.$queryRawUnsafe(
insertQuery,
...values
)) as any[];
return {
data: result[0],
affectedRows: result.length,
insertedId: result[0]?.id || result[0],
};
}
/**
* UPDATE
*/
private async performUpdate(
tableName: string,
data: Record<string, any>,
primaryKeys: string[]
): Promise<{ data?: any; affectedRows: number; insertedId?: any }> {
const updateColumns = Object.keys(data).filter(
(col) => !primaryKeys.includes(col)
);
const whereColumns = primaryKeys.filter((pk) => data[pk] !== undefined);
if (updateColumns.length === 0) {
throw new Error("업데이트할 컬럼이 없습니다.");
}
const setClause = updateColumns
.map((col, index) => `${col} = $${index + 1}`)
.join(", ");
const whereClause = whereColumns
.map((col, index) => `${col} = $${updateColumns.length + index + 1}`)
.join(" AND ");
const updateValues = [
...updateColumns.map((col) => data[col]),
...whereColumns.map((col) => data[col]),
];
const updateQuery = `
UPDATE ${tableName}
SET ${setClause}
WHERE ${whereClause}
RETURNING *
`;
console.log(`📝 UPDATE 쿼리 실행: ${tableName}`, {
updateColumns: updateColumns.length,
whereColumns: whereColumns.length,
query: updateQuery.replace(/\n\s+/g, " "),
});
const result = (await prisma.$queryRawUnsafe(
updateQuery,
...updateValues
)) as any[];
return {
data: result[0],
affectedRows: result.length,
};
}
/**
* PostgreSQL ( )
*/
private convertValueForPostgreSQL(value: any, dataType: string): any {
if (value === null || value === undefined || value === "") {
return null;
}
const lowerDataType = dataType.toLowerCase();
// 숫자 타입 처리
if (
lowerDataType.includes("integer") ||
lowerDataType.includes("bigint") ||
lowerDataType.includes("serial")
) {
return parseInt(value) || null;
}
if (
lowerDataType.includes("numeric") ||
lowerDataType.includes("decimal") ||
lowerDataType.includes("real") ||
lowerDataType.includes("double")
) {
return parseFloat(value) || null;
}
// 불린 타입 처리
if (lowerDataType.includes("boolean")) {
if (typeof value === "boolean") return value;
if (typeof value === "string") {
return value.toLowerCase() === "true" || value === "1";
}
return Boolean(value);
}
// 날짜/시간 타입 처리
if (
lowerDataType.includes("timestamp") ||
lowerDataType.includes("datetime")
) {
const date = new Date(value);
return isNaN(date.getTime()) ? null : date.toISOString();
}
if (lowerDataType.includes("date")) {
const date = new Date(value);
return isNaN(date.getTime()) ? null : date.toISOString().split("T")[0];
}
// JSON 타입 처리
if (lowerDataType.includes("json")) {
if (typeof value === "string") {
try {
JSON.parse(value);
return value;
} catch {
return JSON.stringify(value);
}
}
return JSON.stringify(value);
}
return String(value);
}
/**
*
*/
private formatValidationErrors(errors: FormValidationError[]): string {
if (errors.length === 0) return "알 수 없는 오류가 발생했습니다.";
if (errors.length === 1) return errors[0].message;
return `다음 오류들을 수정해주세요:\n• ${errors.map((e) => e.message).join("\n• ")}`;
}
/**
*
*/
private formatErrorMessage(error: any): string {
if (error.code === "23505") {
return "중복된 데이터가 이미 존재합니다.";
}
if (error.code === "23503") {
return "참조 무결성 제약조건을 위반했습니다.";
}
if (error.code === "23502") {
return "필수 입력 항목이 누락되었습니다.";
}
if (
error.message?.includes("relation") &&
error.message?.includes("does not exist")
) {
return "지정된 테이블이 존재하지 않습니다.";
}
return `저장 중 오류가 발생했습니다: ${error.message || error}`;
}
/**
*
*/
public clearCache(): void {
this.columnCache.clear();
this.webTypeCache.clear();
console.log("🧹 동적 폼 서비스 캐시가 클리어되었습니다.");
}
/**
*
*/
public clearTableCache(tableName: string): void {
this.columnCache.delete(tableName);
this.webTypeCache.delete(tableName);
console.log(`🧹 테이블 '${tableName}' 캐시가 클리어되었습니다.`);
}
}
// 싱글톤 인스턴스
export const enhancedDynamicFormService = new EnhancedDynamicFormService();