데이터 매핑 설정 중간커밋
This commit is contained in:
575
backend-node/src/services/dataMappingService.ts
Normal file
575
backend-node/src/services/dataMappingService.ts
Normal file
@@ -0,0 +1,575 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import {
|
||||
DataMappingConfig,
|
||||
InboundMapping,
|
||||
OutboundMapping,
|
||||
FieldMapping,
|
||||
DataMappingResult,
|
||||
MappingValidationResult,
|
||||
FieldTransform,
|
||||
DataType,
|
||||
} from "../types/dataMappingTypes";
|
||||
|
||||
export class DataMappingService {
|
||||
private prisma: PrismaClient;
|
||||
|
||||
constructor() {
|
||||
this.prisma = new PrismaClient();
|
||||
}
|
||||
|
||||
/**
|
||||
* Inbound 데이터 매핑 처리 (외부 → 내부)
|
||||
*/
|
||||
async processInboundData(
|
||||
externalData: any,
|
||||
mapping: InboundMapping
|
||||
): Promise<DataMappingResult> {
|
||||
const startTime = Date.now();
|
||||
const result: DataMappingResult = {
|
||||
success: false,
|
||||
direction: "inbound",
|
||||
recordsProcessed: 0,
|
||||
recordsInserted: 0,
|
||||
recordsUpdated: 0,
|
||||
recordsSkipped: 0,
|
||||
errors: [],
|
||||
executionTime: 0,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
try {
|
||||
console.log(`📥 [DataMappingService] Inbound 매핑 시작:`, {
|
||||
targetTable: mapping.targetTable,
|
||||
insertMode: mapping.insertMode,
|
||||
fieldMappings: mapping.fieldMappings.length,
|
||||
});
|
||||
|
||||
// 데이터 배열로 변환
|
||||
const dataArray = Array.isArray(externalData)
|
||||
? externalData
|
||||
: [externalData];
|
||||
result.recordsProcessed = dataArray.length;
|
||||
|
||||
// 각 레코드 처리
|
||||
for (const record of dataArray) {
|
||||
try {
|
||||
const mappedData = await this.mapInboundRecord(record, mapping);
|
||||
|
||||
if (Object.keys(mappedData).length === 0) {
|
||||
result.recordsSkipped!++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 데이터베이스에 저장
|
||||
await this.saveInboundRecord(mappedData, mapping);
|
||||
|
||||
if (mapping.insertMode === "insert") {
|
||||
result.recordsInserted!++;
|
||||
} else {
|
||||
result.recordsUpdated!++;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ [DataMappingService] 레코드 처리 실패:`, error);
|
||||
result.errors!.push(
|
||||
`레코드 처리 실패: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
result.recordsSkipped!++;
|
||||
}
|
||||
}
|
||||
|
||||
result.success =
|
||||
result.errors!.length === 0 ||
|
||||
result.recordsInserted! > 0 ||
|
||||
result.recordsUpdated! > 0;
|
||||
} catch (error) {
|
||||
console.error(`❌ [DataMappingService] Inbound 매핑 실패:`, error);
|
||||
result.errors!.push(
|
||||
`매핑 처리 실패: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
|
||||
result.executionTime = Date.now() - startTime;
|
||||
|
||||
console.log(`✅ [DataMappingService] Inbound 매핑 완료:`, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Outbound 데이터 매핑 처리 (내부 → 외부)
|
||||
*/
|
||||
async processOutboundData(
|
||||
mapping: OutboundMapping,
|
||||
filter?: any
|
||||
): Promise<any> {
|
||||
console.log(`📤 [DataMappingService] Outbound 매핑 시작:`, {
|
||||
sourceTable: mapping.sourceTable,
|
||||
fieldMappings: mapping.fieldMappings.length,
|
||||
filter,
|
||||
});
|
||||
|
||||
try {
|
||||
// 소스 데이터 조회
|
||||
const sourceData = await this.getSourceData(mapping, filter);
|
||||
|
||||
if (
|
||||
!sourceData ||
|
||||
(Array.isArray(sourceData) && sourceData.length === 0)
|
||||
) {
|
||||
console.log(`⚠️ [DataMappingService] 소스 데이터가 없습니다.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 데이터 매핑
|
||||
const mappedData = Array.isArray(sourceData)
|
||||
? await Promise.all(
|
||||
sourceData.map((record) => this.mapOutboundRecord(record, mapping))
|
||||
)
|
||||
: await this.mapOutboundRecord(sourceData, mapping);
|
||||
|
||||
console.log(`✅ [DataMappingService] Outbound 매핑 완료:`, {
|
||||
recordCount: Array.isArray(mappedData) ? mappedData.length : 1,
|
||||
});
|
||||
|
||||
return mappedData;
|
||||
} catch (error) {
|
||||
console.error(`❌ [DataMappingService] Outbound 매핑 실패:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 Inbound 레코드 매핑
|
||||
*/
|
||||
private async mapInboundRecord(
|
||||
sourceRecord: any,
|
||||
mapping: InboundMapping
|
||||
): Promise<Record<string, any>> {
|
||||
const mappedRecord: Record<string, any> = {};
|
||||
|
||||
for (const fieldMapping of mapping.fieldMappings) {
|
||||
try {
|
||||
const sourceValue = sourceRecord[fieldMapping.sourceField];
|
||||
|
||||
// 필수 필드 체크
|
||||
if (
|
||||
fieldMapping.required &&
|
||||
(sourceValue === undefined || sourceValue === null)
|
||||
) {
|
||||
if (fieldMapping.defaultValue !== undefined) {
|
||||
mappedRecord[fieldMapping.targetField] = fieldMapping.defaultValue;
|
||||
} else {
|
||||
throw new Error(
|
||||
`필수 필드 '${fieldMapping.sourceField}'가 누락되었습니다.`
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 값이 없으면 기본값 사용
|
||||
if (sourceValue === undefined || sourceValue === null) {
|
||||
if (fieldMapping.defaultValue !== undefined) {
|
||||
mappedRecord[fieldMapping.targetField] = fieldMapping.defaultValue;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 데이터 변환 적용
|
||||
const transformedValue = await this.transformFieldValue(
|
||||
sourceValue,
|
||||
fieldMapping.dataType,
|
||||
fieldMapping.transform
|
||||
);
|
||||
|
||||
mappedRecord[fieldMapping.targetField] = transformedValue;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`❌ [DataMappingService] 필드 매핑 실패 (${fieldMapping.sourceField} → ${fieldMapping.targetField}):`,
|
||||
error
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return mappedRecord;
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 Outbound 레코드 매핑
|
||||
*/
|
||||
private async mapOutboundRecord(
|
||||
sourceRecord: any,
|
||||
mapping: OutboundMapping
|
||||
): Promise<Record<string, any>> {
|
||||
const mappedRecord: Record<string, any> = {};
|
||||
|
||||
for (const fieldMapping of mapping.fieldMappings) {
|
||||
try {
|
||||
const sourceValue = sourceRecord[fieldMapping.sourceField];
|
||||
|
||||
// 데이터 변환 적용
|
||||
const transformedValue = await this.transformFieldValue(
|
||||
sourceValue,
|
||||
fieldMapping.dataType,
|
||||
fieldMapping.transform
|
||||
);
|
||||
|
||||
mappedRecord[fieldMapping.targetField] = transformedValue;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`❌ [DataMappingService] 필드 매핑 실패 (${fieldMapping.sourceField} → ${fieldMapping.targetField}):`,
|
||||
error
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return mappedRecord;
|
||||
}
|
||||
|
||||
/**
|
||||
* 필드 값 변환
|
||||
*/
|
||||
private async transformFieldValue(
|
||||
value: any,
|
||||
targetDataType: DataType,
|
||||
transform?: FieldTransform
|
||||
): Promise<any> {
|
||||
let transformedValue = value;
|
||||
|
||||
// 1. 변환 함수 적용
|
||||
if (transform) {
|
||||
switch (transform.type) {
|
||||
case "constant":
|
||||
transformedValue = transform.value;
|
||||
break;
|
||||
|
||||
case "format":
|
||||
if (targetDataType === "date" && transform.format) {
|
||||
transformedValue = this.formatDate(value, transform.format);
|
||||
}
|
||||
break;
|
||||
|
||||
case "function":
|
||||
if (transform.functionName) {
|
||||
transformedValue = await this.applyCustomFunction(
|
||||
value,
|
||||
transform.functionName
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 데이터 타입 변환
|
||||
return this.convertDataType(transformedValue, targetDataType);
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터 타입 변환
|
||||
*/
|
||||
private convertDataType(value: any, targetType: DataType): any {
|
||||
if (value === null || value === undefined) return value;
|
||||
|
||||
switch (targetType) {
|
||||
case "string":
|
||||
return String(value);
|
||||
case "number":
|
||||
const num = Number(value);
|
||||
return isNaN(num) ? null : num;
|
||||
case "boolean":
|
||||
if (typeof value === "boolean") return value;
|
||||
if (typeof value === "string") {
|
||||
return (
|
||||
value.toLowerCase() === "true" || value === "1" || value === "Y"
|
||||
);
|
||||
}
|
||||
return Boolean(value);
|
||||
case "date":
|
||||
return new Date(value);
|
||||
case "json":
|
||||
return typeof value === "string" ? JSON.parse(value) : value;
|
||||
default:
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 날짜 포맷 변환
|
||||
*/
|
||||
private formatDate(value: any, format: string): string {
|
||||
const date = new Date(value);
|
||||
if (isNaN(date.getTime())) return value;
|
||||
|
||||
// 간단한 날짜 포맷 변환
|
||||
switch (format) {
|
||||
case "YYYY-MM-DD":
|
||||
return date.toISOString().split("T")[0];
|
||||
case "YYYY-MM-DD HH:mm:ss":
|
||||
return date
|
||||
.toISOString()
|
||||
.replace("T", " ")
|
||||
.replace(/\.\d{3}Z$/, "");
|
||||
default:
|
||||
return date.toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 커스텀 함수 적용
|
||||
*/
|
||||
private async applyCustomFunction(
|
||||
value: any,
|
||||
functionName: string
|
||||
): Promise<any> {
|
||||
// 추후 확장 가능한 커스텀 함수들
|
||||
switch (functionName) {
|
||||
case "upperCase":
|
||||
return String(value).toUpperCase();
|
||||
case "lowerCase":
|
||||
return String(value).toLowerCase();
|
||||
case "trim":
|
||||
return String(value).trim();
|
||||
default:
|
||||
console.warn(
|
||||
`⚠️ [DataMappingService] 알 수 없는 함수: ${functionName}`
|
||||
);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inbound 데이터 저장
|
||||
*/
|
||||
private async saveInboundRecord(
|
||||
mappedData: Record<string, any>,
|
||||
mapping: InboundMapping
|
||||
): Promise<void> {
|
||||
const tableName = mapping.targetTable;
|
||||
|
||||
try {
|
||||
switch (mapping.insertMode) {
|
||||
case "insert":
|
||||
await this.executeInsert(tableName, mappedData);
|
||||
break;
|
||||
|
||||
case "upsert":
|
||||
await this.executeUpsert(
|
||||
tableName,
|
||||
mappedData,
|
||||
mapping.keyFields || []
|
||||
);
|
||||
break;
|
||||
|
||||
case "update":
|
||||
await this.executeUpdate(
|
||||
tableName,
|
||||
mappedData,
|
||||
mapping.keyFields || []
|
||||
);
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`❌ [DataMappingService] 데이터 저장 실패 (${tableName}):`,
|
||||
error
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 소스 데이터 조회
|
||||
*/
|
||||
private async getSourceData(
|
||||
mapping: OutboundMapping,
|
||||
filter?: any
|
||||
): Promise<any> {
|
||||
const tableName = mapping.sourceTable;
|
||||
|
||||
try {
|
||||
// 동적 테이블 쿼리 (Prisma의 경우 런타임에서 제한적)
|
||||
// 실제 구현에서는 각 테이블별 모델을 사용하거나 Raw SQL을 사용해야 함
|
||||
|
||||
let whereClause = {};
|
||||
if (mapping.sourceFilter) {
|
||||
// 간단한 필터 파싱 (실제로는 더 정교한 파싱 필요)
|
||||
console.log(
|
||||
`🔍 [DataMappingService] 필터 조건: ${mapping.sourceFilter}`
|
||||
);
|
||||
// TODO: 필터 조건 파싱 및 적용
|
||||
}
|
||||
|
||||
if (filter) {
|
||||
whereClause = { ...whereClause, ...filter };
|
||||
}
|
||||
|
||||
// Raw SQL을 사용한 동적 쿼리
|
||||
const query = `SELECT * FROM ${tableName}${mapping.sourceFilter ? ` WHERE ${mapping.sourceFilter}` : ""}`;
|
||||
console.log(`🔍 [DataMappingService] 쿼리 실행: ${query}`);
|
||||
|
||||
const result = await this.prisma.$queryRawUnsafe(query);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`❌ [DataMappingService] 소스 데이터 조회 실패 (${tableName}):`,
|
||||
error
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* INSERT 실행
|
||||
*/
|
||||
private async executeInsert(
|
||||
tableName: string,
|
||||
data: Record<string, any>
|
||||
): Promise<void> {
|
||||
const columns = Object.keys(data);
|
||||
const values = Object.values(data);
|
||||
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
|
||||
|
||||
const query = `INSERT INTO ${tableName} (${columns.join(", ")}) VALUES (${placeholders})`;
|
||||
|
||||
console.log(`📝 [DataMappingService] INSERT 실행:`, {
|
||||
table: tableName,
|
||||
columns,
|
||||
query,
|
||||
});
|
||||
await this.prisma.$executeRawUnsafe(query, ...values);
|
||||
}
|
||||
|
||||
/**
|
||||
* UPSERT 실행
|
||||
*/
|
||||
private async executeUpsert(
|
||||
tableName: string,
|
||||
data: Record<string, any>,
|
||||
keyFields: string[]
|
||||
): Promise<void> {
|
||||
if (keyFields.length === 0) {
|
||||
throw new Error("UPSERT 모드에서는 키 필드가 필요합니다.");
|
||||
}
|
||||
|
||||
const columns = Object.keys(data);
|
||||
const values = Object.values(data);
|
||||
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
|
||||
|
||||
const updateClauses = columns
|
||||
.filter((col) => !keyFields.includes(col))
|
||||
.map((col) => `${col} = EXCLUDED.${col}`)
|
||||
.join(", ");
|
||||
|
||||
const query = `
|
||||
INSERT INTO ${tableName} (${columns.join(", ")})
|
||||
VALUES (${placeholders})
|
||||
ON CONFLICT (${keyFields.join(", ")})
|
||||
DO UPDATE SET ${updateClauses}
|
||||
`;
|
||||
|
||||
console.log(`🔄 [DataMappingService] UPSERT 실행:`, {
|
||||
table: tableName,
|
||||
keyFields,
|
||||
query,
|
||||
});
|
||||
await this.prisma.$executeRawUnsafe(query, ...values);
|
||||
}
|
||||
|
||||
/**
|
||||
* UPDATE 실행
|
||||
*/
|
||||
private async executeUpdate(
|
||||
tableName: string,
|
||||
data: Record<string, any>,
|
||||
keyFields: string[]
|
||||
): Promise<void> {
|
||||
if (keyFields.length === 0) {
|
||||
throw new Error("UPDATE 모드에서는 키 필드가 필요합니다.");
|
||||
}
|
||||
|
||||
const updateColumns = Object.keys(data).filter(
|
||||
(col) => !keyFields.includes(col)
|
||||
);
|
||||
const updateClauses = updateColumns
|
||||
.map((col, i) => `${col} = $${i + 1}`)
|
||||
.join(", ");
|
||||
|
||||
const whereConditions = keyFields
|
||||
.map((field, i) => `${field} = $${updateColumns.length + i + 1}`)
|
||||
.join(" AND ");
|
||||
|
||||
const values = [
|
||||
...updateColumns.map((col) => data[col]),
|
||||
...keyFields.map((field) => data[field]),
|
||||
];
|
||||
|
||||
const query = `UPDATE ${tableName} SET ${updateClauses} WHERE ${whereConditions}`;
|
||||
|
||||
console.log(`✏️ [DataMappingService] UPDATE 실행:`, {
|
||||
table: tableName,
|
||||
keyFields,
|
||||
query,
|
||||
});
|
||||
await this.prisma.$executeRawUnsafe(query, ...values);
|
||||
}
|
||||
|
||||
/**
|
||||
* 매핑 설정 검증
|
||||
*/
|
||||
validateMappingConfig(config: DataMappingConfig): MappingValidationResult {
|
||||
const result: MappingValidationResult = {
|
||||
isValid: true,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
if (config.direction === "none") {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Inbound 매핑 검증
|
||||
if (
|
||||
(config.direction === "inbound" ||
|
||||
config.direction === "bidirectional") &&
|
||||
config.inboundMapping
|
||||
) {
|
||||
if (!config.inboundMapping.targetTable) {
|
||||
result.errors.push("Inbound 매핑에 대상 테이블이 필요합니다.");
|
||||
}
|
||||
if (config.inboundMapping.fieldMappings.length === 0) {
|
||||
result.errors.push("Inbound 매핑에 필드 매핑이 필요합니다.");
|
||||
}
|
||||
if (
|
||||
config.inboundMapping.insertMode !== "insert" &&
|
||||
(!config.inboundMapping.keyFields ||
|
||||
config.inboundMapping.keyFields.length === 0)
|
||||
) {
|
||||
result.errors.push("UPSERT/UPDATE 모드에서는 키 필드가 필요합니다.");
|
||||
}
|
||||
}
|
||||
|
||||
// Outbound 매핑 검증
|
||||
if (
|
||||
(config.direction === "outbound" ||
|
||||
config.direction === "bidirectional") &&
|
||||
config.outboundMapping
|
||||
) {
|
||||
if (!config.outboundMapping.sourceTable) {
|
||||
result.errors.push("Outbound 매핑에 소스 테이블이 필요합니다.");
|
||||
}
|
||||
if (config.outboundMapping.fieldMappings.length === 0) {
|
||||
result.errors.push("Outbound 매핑에 필드 매핑이 필요합니다.");
|
||||
}
|
||||
}
|
||||
|
||||
result.isValid = result.errors.length === 0;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 리소스 정리
|
||||
*/
|
||||
async disconnect(): Promise<void> {
|
||||
await this.prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user