2025-11-27 12:08:32 +09:00
|
|
|
/**
|
|
|
|
|
* 데이터 매핑 유틸리티
|
|
|
|
|
* 화면 간 데이터 전달 시 매핑 규칙 적용
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import type {
|
|
|
|
|
MappingRule,
|
|
|
|
|
Condition,
|
|
|
|
|
TransformFunction,
|
|
|
|
|
} from "@/types/screen-embedding";
|
|
|
|
|
import { logger } from "./logger";
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 매핑 규칙 적용
|
2025-11-28 14:56:11 +09:00
|
|
|
* @param data 배열 또는 단일 객체
|
|
|
|
|
* @param rules 매핑 규칙 배열
|
|
|
|
|
* @returns 매핑된 배열
|
2025-11-27 12:08:32 +09:00
|
|
|
*/
|
2025-11-28 14:56:11 +09:00
|
|
|
export function applyMappingRules(data: any[] | any, rules: MappingRule[]): any[] {
|
|
|
|
|
// 빈 데이터 처리
|
|
|
|
|
if (!data) {
|
2025-11-27 12:08:32 +09:00
|
|
|
return [];
|
|
|
|
|
}
|
2025-11-28 14:56:11 +09:00
|
|
|
|
|
|
|
|
// 🆕 배열이 아닌 경우 배열로 변환
|
|
|
|
|
const dataArray = Array.isArray(data) ? data : [data];
|
|
|
|
|
|
|
|
|
|
if (dataArray.length === 0) {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 규칙이 없으면 원본 데이터 반환
|
|
|
|
|
if (!rules || rules.length === 0) {
|
|
|
|
|
return dataArray;
|
|
|
|
|
}
|
2025-11-27 12:08:32 +09:00
|
|
|
|
|
|
|
|
// 변환 함수가 있는 규칙 확인
|
|
|
|
|
const hasTransform = rules.some((rule) => rule.transform && rule.transform !== "none");
|
|
|
|
|
|
|
|
|
|
if (hasTransform) {
|
|
|
|
|
// 변환 함수가 있으면 단일 값 또는 집계 결과 반환
|
2025-11-28 14:56:11 +09:00
|
|
|
return [applyTransformRules(dataArray, rules)];
|
2025-11-27 12:08:32 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 일반 매핑 (각 행에 대해 매핑)
|
2025-11-28 14:56:11 +09:00
|
|
|
// 🆕 원본 데이터를 복사한 후 매핑 규칙 적용 (매핑되지 않은 필드도 유지)
|
|
|
|
|
return dataArray.map((row) => {
|
|
|
|
|
// 원본 데이터 복사
|
|
|
|
|
const mappedRow: any = { ...row };
|
2025-11-27 12:08:32 +09:00
|
|
|
|
|
|
|
|
for (const rule of rules) {
|
2025-11-28 14:56:11 +09:00
|
|
|
// sourceField와 targetField가 모두 있어야 매핑 적용
|
|
|
|
|
if (!rule.sourceField || !rule.targetField) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-27 12:08:32 +09:00
|
|
|
const sourceValue = getNestedValue(row, rule.sourceField);
|
|
|
|
|
const targetValue = sourceValue ?? rule.defaultValue;
|
|
|
|
|
|
2025-11-28 14:56:11 +09:00
|
|
|
// 소스 필드와 타겟 필드가 다르면 소스 필드 제거 후 타겟 필드에 설정
|
|
|
|
|
if (rule.sourceField !== rule.targetField) {
|
|
|
|
|
delete mappedRow[rule.sourceField];
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-27 12:08:32 +09:00
|
|
|
setNestedValue(mappedRow, rule.targetField, targetValue);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return mappedRow;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 변환 함수 적용
|
|
|
|
|
*/
|
|
|
|
|
function applyTransformRules(data: any[], rules: MappingRule[]): any {
|
|
|
|
|
const result: any = {};
|
|
|
|
|
|
|
|
|
|
for (const rule of rules) {
|
|
|
|
|
const values = data.map((row) => getNestedValue(row, rule.sourceField));
|
|
|
|
|
const transformedValue = applyTransform(values, rule.transform || "none");
|
|
|
|
|
|
|
|
|
|
setNestedValue(result, rule.targetField, transformedValue);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 변환 함수 실행
|
|
|
|
|
*/
|
|
|
|
|
function applyTransform(values: any[], transform: TransformFunction): any {
|
|
|
|
|
switch (transform) {
|
|
|
|
|
case "none":
|
|
|
|
|
return values;
|
|
|
|
|
|
|
|
|
|
case "sum":
|
|
|
|
|
return values.reduce((sum, val) => sum + (Number(val) || 0), 0);
|
|
|
|
|
|
|
|
|
|
case "average":
|
|
|
|
|
const sum = values.reduce((s, val) => s + (Number(val) || 0), 0);
|
|
|
|
|
return values.length > 0 ? sum / values.length : 0;
|
|
|
|
|
|
|
|
|
|
case "count":
|
|
|
|
|
return values.length;
|
|
|
|
|
|
|
|
|
|
case "min":
|
|
|
|
|
return Math.min(...values.map((v) => Number(v) || 0));
|
|
|
|
|
|
|
|
|
|
case "max":
|
|
|
|
|
return Math.max(...values.map((v) => Number(v) || 0));
|
|
|
|
|
|
|
|
|
|
case "first":
|
|
|
|
|
return values[0];
|
|
|
|
|
|
|
|
|
|
case "last":
|
|
|
|
|
return values[values.length - 1];
|
|
|
|
|
|
|
|
|
|
case "concat":
|
|
|
|
|
return values.filter((v) => v != null).join("");
|
|
|
|
|
|
|
|
|
|
case "join":
|
|
|
|
|
return values.filter((v) => v != null).join(", ");
|
|
|
|
|
|
|
|
|
|
case "custom":
|
|
|
|
|
// TODO: 커스텀 함수 실행
|
|
|
|
|
logger.warn("커스텀 변환 함수는 아직 구현되지 않았습니다.");
|
|
|
|
|
return values;
|
|
|
|
|
|
|
|
|
|
default:
|
|
|
|
|
return values;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 조건에 따른 데이터 필터링
|
|
|
|
|
*/
|
|
|
|
|
export function filterDataByCondition(data: any[], condition: Condition): any[] {
|
|
|
|
|
return data.filter((row) => {
|
|
|
|
|
const value = getNestedValue(row, condition.field);
|
|
|
|
|
return evaluateCondition(value, condition.operator, condition.value);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 조건 평가
|
|
|
|
|
*/
|
|
|
|
|
function evaluateCondition(value: any, operator: string, targetValue: any): boolean {
|
|
|
|
|
switch (operator) {
|
|
|
|
|
case "equals":
|
|
|
|
|
return value === targetValue;
|
|
|
|
|
|
|
|
|
|
case "notEquals":
|
|
|
|
|
return value !== targetValue;
|
|
|
|
|
|
|
|
|
|
case "contains":
|
|
|
|
|
return String(value).includes(String(targetValue));
|
|
|
|
|
|
|
|
|
|
case "notContains":
|
|
|
|
|
return !String(value).includes(String(targetValue));
|
|
|
|
|
|
|
|
|
|
case "greaterThan":
|
|
|
|
|
return Number(value) > Number(targetValue);
|
|
|
|
|
|
|
|
|
|
case "lessThan":
|
|
|
|
|
return Number(value) < Number(targetValue);
|
|
|
|
|
|
|
|
|
|
case "greaterThanOrEqual":
|
|
|
|
|
return Number(value) >= Number(targetValue);
|
|
|
|
|
|
|
|
|
|
case "lessThanOrEqual":
|
|
|
|
|
return Number(value) <= Number(targetValue);
|
|
|
|
|
|
|
|
|
|
case "in":
|
|
|
|
|
return Array.isArray(targetValue) && targetValue.includes(value);
|
|
|
|
|
|
|
|
|
|
case "notIn":
|
|
|
|
|
return Array.isArray(targetValue) && !targetValue.includes(value);
|
|
|
|
|
|
|
|
|
|
default:
|
|
|
|
|
logger.warn(`알 수 없는 조건 연산자: ${operator}`);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 중첩된 객체에서 값 가져오기
|
|
|
|
|
* 예: "user.address.city" -> obj.user.address.city
|
|
|
|
|
*/
|
|
|
|
|
function getNestedValue(obj: any, path: string): any {
|
|
|
|
|
if (!obj || !path) {
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const keys = path.split(".");
|
|
|
|
|
let value = obj;
|
|
|
|
|
|
|
|
|
|
for (const key of keys) {
|
|
|
|
|
if (value == null) {
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
value = value[key];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 중첩된 객체에 값 설정
|
|
|
|
|
* 예: "user.address.city", "Seoul" -> obj.user.address.city = "Seoul"
|
|
|
|
|
*/
|
|
|
|
|
function setNestedValue(obj: any, path: string, value: any): void {
|
|
|
|
|
if (!obj || !path) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const keys = path.split(".");
|
|
|
|
|
const lastKey = keys.pop()!;
|
|
|
|
|
let current = obj;
|
|
|
|
|
|
|
|
|
|
for (const key of keys) {
|
|
|
|
|
if (!(key in current)) {
|
|
|
|
|
current[key] = {};
|
|
|
|
|
}
|
|
|
|
|
current = current[key];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
current[lastKey] = value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 매핑 결과 검증
|
|
|
|
|
*/
|
|
|
|
|
export function validateMappingResult(
|
|
|
|
|
data: any[],
|
|
|
|
|
rules: MappingRule[]
|
|
|
|
|
): { valid: boolean; errors: string[] } {
|
|
|
|
|
const errors: string[] = [];
|
|
|
|
|
|
|
|
|
|
// 필수 필드 검증
|
|
|
|
|
const requiredRules = rules.filter((rule) => rule.required);
|
|
|
|
|
|
|
|
|
|
for (const rule of requiredRules) {
|
|
|
|
|
const hasValue = data.some((row) => {
|
|
|
|
|
const value = getNestedValue(row, rule.targetField);
|
|
|
|
|
return value != null && value !== "";
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!hasValue) {
|
|
|
|
|
errors.push(`필수 필드 누락: ${rule.targetField}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
valid: errors.length === 0,
|
|
|
|
|
errors,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 매핑 규칙 미리보기
|
|
|
|
|
* 실제 데이터 전달 전에 결과를 미리 확인
|
|
|
|
|
*/
|
|
|
|
|
export function previewMapping(
|
|
|
|
|
sampleData: any[],
|
|
|
|
|
rules: MappingRule[]
|
|
|
|
|
): { success: boolean; preview: any[]; errors?: string[] } {
|
|
|
|
|
try {
|
|
|
|
|
const preview = applyMappingRules(sampleData.slice(0, 5), rules);
|
|
|
|
|
const validation = validateMappingResult(preview, rules);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
success: validation.valid,
|
|
|
|
|
preview,
|
|
|
|
|
errors: validation.errors,
|
|
|
|
|
};
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
return {
|
|
|
|
|
success: false,
|
|
|
|
|
preview: [],
|
|
|
|
|
errors: [error.message],
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|