수식 노드 구현

This commit is contained in:
kjs
2025-12-10 18:28:27 +09:00
parent 3188bc0513
commit 088596480f
10 changed files with 1957 additions and 134 deletions

View File

@@ -28,12 +28,13 @@ export type NodeType =
| "condition"
| "dataTransform"
| "aggregate"
| "formulaTransform" // 수식 변환 노드
| "insertAction"
| "updateAction"
| "deleteAction"
| "upsertAction"
| "emailAction" // 이메일 발송 액션
| "scriptAction" // 스크립트 실행 액션
| "emailAction" // 이메일 발송 액션
| "scriptAction" // 스크립트 실행 액션
| "httpRequestAction" // HTTP 요청 액션
| "comment"
| "log";
@@ -535,6 +536,9 @@ export class NodeFlowExecutionService {
case "aggregate":
return this.executeAggregate(node, inputData, context);
case "formulaTransform":
return this.executeFormulaTransform(node, inputData, context);
case "insertAction":
return this.executeInsertAction(node, inputData, context, client);
@@ -847,16 +851,18 @@ export class NodeFlowExecutionService {
const sql = `SELECT * FROM ${schemaPrefix}${tableName} ${whereResult.clause}`;
logger.info(`📊 테이블 전체 데이터 조회 SQL: ${sql}`);
const result = await query(sql, whereResult.values);
logger.info(
`📊 테이블 전체 데이터 조회: ${tableName}, ${result.length}`
);
// 디버깅: 조회된 데이터 샘플 출력
if (result.length > 0) {
logger.info(`📊 조회된 데이터 샘플: ${JSON.stringify(result[0])?.substring(0, 300)}`);
logger.info(
`📊 조회된 데이터 샘플: ${JSON.stringify(result[0])?.substring(0, 300)}`
);
}
return result;
@@ -962,8 +968,12 @@ export class NodeFlowExecutionService {
});
// 🆕 writer와 company_code 자동 추가 (필드 매핑에 없는 경우)
const hasWriterMapping = fieldMappings.some((m: any) => m.targetField === "writer");
const hasCompanyCodeMapping = fieldMappings.some((m: any) => m.targetField === "company_code");
const hasWriterMapping = fieldMappings.some(
(m: any) => m.targetField === "writer"
);
const hasCompanyCodeMapping = fieldMappings.some(
(m: any) => m.targetField === "company_code"
);
// 컨텍스트에서 사용자 정보 추출
const userId = context.buttonContext?.userId;
@@ -1380,8 +1390,12 @@ export class NodeFlowExecutionService {
// 🆕 table-all 모드: 각 그룹별로 UPDATE 실행 (집계 결과 반영)
if (context.currentNodeDataSourceType === "table-all") {
console.log("🚀 table-all 모드: 그룹별 업데이트 시작 (총 " + dataArray.length + "개 그룹)");
console.log(
"🚀 table-all 모드: 그룹별 업데이트 시작 (총 " +
dataArray.length +
"개 그룹)"
);
// 🔥 각 그룹(데이터)별로 UPDATE 실행
for (let i = 0; i < dataArray.length; i++) {
const data = dataArray[i];
@@ -1391,7 +1405,7 @@ export class NodeFlowExecutionService {
console.log(`\n📦 그룹 ${i + 1}/${dataArray.length} 처리 중...`);
console.log("🗺️ 필드 매핑 처리 중...");
fieldMappings.forEach((mapping: any) => {
const value =
mapping.staticValue !== undefined
@@ -1430,7 +1444,7 @@ export class NodeFlowExecutionService {
const result = await txClient.query(sql, values);
const rowCount = result.rowCount || 0;
updatedCount += rowCount;
console.log(`✅ 그룹 ${i + 1} UPDATE 완료: ${rowCount}`);
}
@@ -1444,7 +1458,7 @@ export class NodeFlowExecutionService {
// 🆕 context-data 모드: 개별 업데이트 (PK 자동 추가)
console.log("🎯 context-data 모드: 개별 업데이트 시작");
for (const data of dataArray) {
const setClauses: string[] = [];
const values: any[] = [];
@@ -1816,12 +1830,16 @@ export class NodeFlowExecutionService {
// 🆕 table-all 모드: 단일 SQL로 일괄 삭제
if (context.currentNodeDataSourceType === "table-all") {
console.log("🚀 table-all 모드: 단일 SQL로 일괄 삭제 시작");
// 첫 번째 데이터를 참조하여 WHERE 절 생성
const firstData = dataArray[0];
// WHERE 조건 (사용자 정의 조건만 사용, PK 자동 추가 안 함)
const whereResult = this.buildWhereClause(whereConditions, firstData, 1);
const whereResult = this.buildWhereClause(
whereConditions,
firstData,
1
);
const sql = `DELETE FROM ${targetTable} ${whereResult.clause} RETURNING *`;
@@ -1848,7 +1866,7 @@ export class NodeFlowExecutionService {
for (const data of dataArray) {
console.log("🔍 WHERE 조건 처리 중...");
// 🔑 Primary Key 자동 추가 (context-data 모드)
console.log("🔑 context-data 모드: Primary Key 자동 추가");
const enhancedWhereConditions = await this.enhanceWhereConditionsWithPK(
@@ -1856,8 +1874,12 @@ export class NodeFlowExecutionService {
data,
targetTable
);
const whereResult = this.buildWhereClause(enhancedWhereConditions, data, 1);
const whereResult = this.buildWhereClause(
enhancedWhereConditions,
data,
1
);
const sql = `DELETE FROM ${targetTable} ${whereResult.clause} RETURNING *`;
@@ -2712,13 +2734,15 @@ export class NodeFlowExecutionService {
try {
const result = await query(sql, [fullTableName]);
const pkColumns = result.map((row: any) => row.column_name);
if (pkColumns.length > 0) {
console.log(`🔑 테이블 ${tableName}의 Primary Key: ${pkColumns.join(", ")}`);
console.log(
`🔑 테이블 ${tableName}의 Primary Key: ${pkColumns.join(", ")}`
);
} else {
console.log(`⚠️ 테이블 ${tableName}에 Primary Key가 없습니다`);
}
return pkColumns;
} catch (error) {
console.error(`❌ Primary Key 조회 실패 (${tableName}):`, error);
@@ -2728,7 +2752,7 @@ export class NodeFlowExecutionService {
/**
* WHERE 조건에 Primary Key 자동 추가 (컨텍스트 데이터 사용 시)
*
*
* 테이블의 실제 Primary Key를 자동으로 감지하여 WHERE 조건에 추가
*/
private static async enhanceWhereConditionsWithPK(
@@ -2751,8 +2775,8 @@ export class NodeFlowExecutionService {
}
// 🔍 데이터에 모든 PK 컬럼이 있는지 확인
const missingPKColumns = pkColumns.filter(col =>
data[col] === undefined || data[col] === null
const missingPKColumns = pkColumns.filter(
(col) => data[col] === undefined || data[col] === null
);
if (missingPKColumns.length > 0) {
@@ -2766,8 +2790,9 @@ export class NodeFlowExecutionService {
const existingFields = new Set(
(whereConditions || []).map((cond: any) => cond.field)
);
const allPKsExist = pkColumns.every(col =>
existingFields.has(col) || existingFields.has(`${tableName}.${col}`)
const allPKsExist = pkColumns.every(
(col) =>
existingFields.has(col) || existingFields.has(`${tableName}.${col}`)
);
if (allPKsExist) {
@@ -2776,17 +2801,17 @@ export class NodeFlowExecutionService {
}
// 🔥 Primary Key 조건들을 맨 앞에 추가
const pkConditions = pkColumns.map(col => ({
const pkConditions = pkColumns.map((col) => ({
field: col,
operator: 'EQUALS',
value: data[col]
operator: "EQUALS",
value: data[col],
}));
const enhanced = [...pkConditions, ...(whereConditions || [])];
const pkValues = pkColumns.map(col => `${col} = ${data[col]}`).join(", ");
const pkValues = pkColumns.map((col) => `${col} = ${data[col]}`).join(", ");
console.log(`🔑 WHERE 조건에 Primary Key 자동 추가: ${pkValues}`);
return enhanced;
}
@@ -3236,20 +3261,30 @@ export class NodeFlowExecutionService {
inputData: any,
context: ExecutionContext
): Promise<any[]> {
const { groupByFields = [], aggregations = [], havingConditions = [] } = node.data;
const {
groupByFields = [],
aggregations = [],
havingConditions = [],
} = node.data;
logger.info(`📊 집계 노드 실행: ${node.data.displayName || node.id}`);
// 입력 데이터가 없으면 빈 배열 반환
if (!inputData || !Array.isArray(inputData) || inputData.length === 0) {
logger.warn("⚠️ 집계할 입력 데이터가 없습니다.");
logger.warn(`⚠️ inputData 타입: ${typeof inputData}, 값: ${JSON.stringify(inputData)?.substring(0, 200)}`);
logger.warn(
`⚠️ inputData 타입: ${typeof inputData}, 값: ${JSON.stringify(inputData)?.substring(0, 200)}`
);
return [];
}
logger.info(`📥 입력 데이터: ${inputData.length}`);
logger.info(`📥 입력 데이터 샘플: ${JSON.stringify(inputData[0])?.substring(0, 300)}`);
logger.info(`📊 그룹 기준: ${groupByFields.length > 0 ? groupByFields.map((f: any) => f.field).join(", ") : "전체"}`);
logger.info(
`📥 입력 데이터 샘플: ${JSON.stringify(inputData[0])?.substring(0, 300)}`
);
logger.info(
`📊 그룹 기준: ${groupByFields.length > 0 ? groupByFields.map((f: any) => f.field).join(", ") : "전체"}`
);
logger.info(`📊 집계 연산: ${aggregations.length}`);
// 그룹화 수행
@@ -3257,9 +3292,12 @@ export class NodeFlowExecutionService {
for (const row of inputData) {
// 그룹 키 생성
const groupKey = groupByFields.length > 0
? groupByFields.map((f: any) => String(row[f.field] ?? "")).join("|||")
: "__ALL__";
const groupKey =
groupByFields.length > 0
? groupByFields
.map((f: any) => String(row[f.field] ?? ""))
.join("|||")
: "__ALL__";
if (!groups.has(groupKey)) {
groups.set(groupKey, []);
@@ -3268,10 +3306,12 @@ export class NodeFlowExecutionService {
}
logger.info(`📊 그룹 수: ${groups.size}`);
// 디버깅: 각 그룹의 데이터 출력
for (const [groupKey, groupRows] of groups) {
logger.info(`📊 그룹 [${groupKey}]: ${groupRows.length}건, inbound_qty 합계: ${groupRows.reduce((sum, row) => sum + parseFloat(row.inbound_qty || 0), 0)}`);
logger.info(
`📊 그룹 [${groupKey}]: ${groupRows.length}건, inbound_qty 합계: ${groupRows.reduce((sum, row) => sum + parseFloat(row.inbound_qty || 0), 0)}`
);
}
// 각 그룹에 대해 집계 수행
@@ -3291,7 +3331,7 @@ export class NodeFlowExecutionService {
// 각 집계 연산 수행
for (const agg of aggregations) {
const { sourceField, function: aggFunc, outputField } = agg;
if (!outputField) continue;
let aggregatedValue: any;
@@ -3317,27 +3357,37 @@ export class NodeFlowExecutionService {
break;
case "MIN":
aggregatedValue = groupRows.reduce((min: number | null, row: any) => {
const val = parseFloat(row[sourceField]);
if (isNaN(val)) return min;
return min === null ? val : Math.min(min, val);
}, null);
aggregatedValue = groupRows.reduce(
(min: number | null, row: any) => {
const val = parseFloat(row[sourceField]);
if (isNaN(val)) return min;
return min === null ? val : Math.min(min, val);
},
null
);
break;
case "MAX":
aggregatedValue = groupRows.reduce((max: number | null, row: any) => {
const val = parseFloat(row[sourceField]);
if (isNaN(val)) return max;
return max === null ? val : Math.max(max, val);
}, null);
aggregatedValue = groupRows.reduce(
(max: number | null, row: any) => {
const val = parseFloat(row[sourceField]);
if (isNaN(val)) return max;
return max === null ? val : Math.max(max, val);
},
null
);
break;
case "FIRST":
aggregatedValue = groupRows.length > 0 ? groupRows[0][sourceField] : null;
aggregatedValue =
groupRows.length > 0 ? groupRows[0][sourceField] : null;
break;
case "LAST":
aggregatedValue = groupRows.length > 0 ? groupRows[groupRows.length - 1][sourceField] : null;
aggregatedValue =
groupRows.length > 0
? groupRows[groupRows.length - 1][sourceField]
: null;
break;
default:
@@ -3346,7 +3396,9 @@ export class NodeFlowExecutionService {
}
resultRow[outputField] = aggregatedValue;
logger.info(` ${aggFunc}(${sourceField}) → ${outputField}: ${aggregatedValue}`);
logger.info(
` ${aggFunc}(${sourceField}) → ${outputField}: ${aggregatedValue}`
);
}
results.push(resultRow);
@@ -3379,11 +3431,13 @@ export class NodeFlowExecutionService {
});
});
logger.info(`📊 HAVING 필터링: ${results.length}건 → ${filteredResults.length}`);
logger.info(
`📊 HAVING 필터링: ${results.length}건 → ${filteredResults.length}`
);
}
logger.info(`✅ 집계 완료: ${filteredResults.length}건 결과`);
// 결과 샘플 출력
if (filteredResults.length > 0) {
logger.info(`📄 결과 샘플:`, JSON.stringify(filteredResults[0], null, 2));
@@ -3419,10 +3473,16 @@ export class NodeFlowExecutionService {
templateVariables,
} = node.data;
logger.info(`📧 이메일 발송 노드 실행: ${node.data.displayName || node.id}`);
logger.info(
`📧 이메일 발송 노드 실행: ${node.data.displayName || node.id}`
);
// 입력 데이터를 배열로 정규화
const dataArray = Array.isArray(inputData) ? inputData : inputData ? [inputData] : [{}];
const dataArray = Array.isArray(inputData)
? inputData
: inputData
? [inputData]
: [{}];
const results: any[] = [];
// 동적 임포트로 순환 참조 방지
@@ -3433,12 +3493,18 @@ export class NodeFlowExecutionService {
let accountId = nodeAccountId || smtpConfigId;
if (!accountId) {
const accounts = await mailAccountFileService.getAccounts();
const activeAccount = accounts.find((acc: any) => acc.status === "active");
const activeAccount = accounts.find(
(acc: any) => acc.status === "active"
);
if (activeAccount) {
accountId = activeAccount.id;
logger.info(`📧 자동 선택된 메일 계정: ${activeAccount.name} (${activeAccount.email})`);
logger.info(
`📧 자동 선택된 메일 계정: ${activeAccount.name} (${activeAccount.email})`
);
} else {
throw new Error("활성화된 메일 계정이 없습니다. 메일 계정을 먼저 설정해주세요.");
throw new Error(
"활성화된 메일 계정이 없습니다. 메일 계정을 먼저 설정해주세요."
);
}
}
@@ -3448,16 +3514,36 @@ export class NodeFlowExecutionService {
for (const data of dataArray) {
try {
// 템플릿 변수 치환
const processedSubject = this.replaceTemplateVariables(subject || "", data);
const processedSubject = this.replaceTemplateVariables(
subject || "",
data
);
const processedBody = this.replaceTemplateVariables(body || "", data);
const processedTo = this.replaceTemplateVariables(to || "", data);
const processedCc = cc ? this.replaceTemplateVariables(cc, data) : undefined;
const processedBcc = bcc ? this.replaceTemplateVariables(bcc, data) : undefined;
const processedCc = cc
? this.replaceTemplateVariables(cc, data)
: undefined;
const processedBcc = bcc
? this.replaceTemplateVariables(bcc, data)
: undefined;
// 수신자 파싱 (쉼표로 구분)
const toList = processedTo.split(",").map((email: string) => email.trim()).filter((email: string) => email);
const ccList = processedCc ? processedCc.split(",").map((email: string) => email.trim()).filter((email: string) => email) : undefined;
const bccList = processedBcc ? processedBcc.split(",").map((email: string) => email.trim()).filter((email: string) => email) : undefined;
const toList = processedTo
.split(",")
.map((email: string) => email.trim())
.filter((email: string) => email);
const ccList = processedCc
? processedCc
.split(",")
.map((email: string) => email.trim())
.filter((email: string) => email)
: undefined;
const bccList = processedBcc
? processedBcc
.split(",")
.map((email: string) => email.trim())
.filter((email: string) => email)
: undefined;
if (toList.length === 0) {
throw new Error("수신자 이메일 주소가 지정되지 않았습니다.");
@@ -3504,7 +3590,9 @@ export class NodeFlowExecutionService {
const successCount = results.filter((r) => r.success).length;
const failedCount = results.filter((r) => !r.success).length;
logger.info(`📧 이메일 발송 완료: 성공 ${successCount}건, 실패 ${failedCount}`);
logger.info(
`📧 이메일 발송 완료: 성공 ${successCount}건, 실패 ${failedCount}`
);
return {
action: "emailAction",
@@ -3533,7 +3621,9 @@ export class NodeFlowExecutionService {
captureOutput,
} = node.data;
logger.info(`🖥️ 스크립트 실행 노드 실행: ${node.data.displayName || node.id}`);
logger.info(
`🖥️ 스크립트 실행 노드 실행: ${node.data.displayName || node.id}`
);
logger.info(` 스크립트 타입: ${scriptType}, 경로: ${scriptPath}`);
if (!scriptPath) {
@@ -3541,7 +3631,11 @@ export class NodeFlowExecutionService {
}
// 입력 데이터를 배열로 정규화
const dataArray = Array.isArray(inputData) ? inputData : inputData ? [inputData] : [{}];
const dataArray = Array.isArray(inputData)
? inputData
: inputData
? [inputData]
: [{}];
const results: any[] = [];
// child_process 모듈 동적 임포트
@@ -3659,7 +3753,9 @@ export class NodeFlowExecutionService {
const successCount = results.filter((r) => r.success).length;
const failedCount = results.filter((r) => !r.success).length;
logger.info(`🖥️ 스크립트 실행 완료: 성공 ${successCount}건, 실패 ${failedCount}`);
logger.info(
`🖥️ 스크립트 실행 완료: 성공 ${successCount}건, 실패 ${failedCount}`
);
return {
action: "scriptAction",
@@ -3700,7 +3796,11 @@ export class NodeFlowExecutionService {
}
// 입력 데이터를 배열로 정규화
const dataArray = Array.isArray(inputData) ? inputData : inputData ? [inputData] : [{}];
const dataArray = Array.isArray(inputData)
? inputData
: inputData
? [inputData]
: [{}];
const results: any[] = [];
for (const data of dataArray) {
@@ -3737,19 +3837,22 @@ export class NodeFlowExecutionService {
break;
case "bearer":
if (authentication.token) {
processedHeaders["Authorization"] = `Bearer ${authentication.token}`;
processedHeaders["Authorization"] =
`Bearer ${authentication.token}`;
}
break;
case "apikey":
if (authentication.apiKey) {
if (authentication.apiKeyLocation === "query") {
// 쿼리 파라미터로 추가 (URL에 추가)
const paramName = authentication.apiKeyQueryParam || "api_key";
const paramName =
authentication.apiKeyQueryParam || "api_key";
const separator = processedUrl.includes("?") ? "&" : "?";
// URL은 이미 처리되었으므로 여기서는 결과에 포함
} else {
// 헤더로 추가
const headerName = authentication.apiKeyHeader || "X-API-Key";
const headerName =
authentication.apiKeyHeader || "X-API-Key";
processedHeaders[headerName] = authentication.apiKey;
}
}
@@ -3758,7 +3861,10 @@ export class NodeFlowExecutionService {
}
// Content-Type 기본값
if (!processedHeaders["Content-Type"] && ["POST", "PUT", "PATCH"].includes(method)) {
if (
!processedHeaders["Content-Type"] &&
["POST", "PUT", "PATCH"].includes(method)
) {
processedHeaders["Content-Type"] =
bodyType === "json" ? "application/json" : "text/plain";
}
@@ -3785,7 +3891,9 @@ export class NodeFlowExecutionService {
validateStatus: () => true, // 모든 상태 코드 허용
});
logger.info(` 응답 상태: ${response.status} ${response.statusText}`);
logger.info(
` 응답 상태: ${response.status} ${response.statusText}`
);
// 응답 데이터 처리
let responseData = response.data;
@@ -3794,10 +3902,16 @@ export class NodeFlowExecutionService {
if (responseMapping && responseData) {
const paths = responseMapping.split(".");
for (const path of paths) {
if (responseData && typeof responseData === "object" && path in responseData) {
if (
responseData &&
typeof responseData === "object" &&
path in responseData
) {
responseData = responseData[path];
} else {
logger.warn(`⚠️ 응답 매핑 경로를 찾을 수 없습니다: ${responseMapping}`);
logger.warn(
`⚠️ 응답 매핑 경로를 찾을 수 없습니다: ${responseMapping}`
);
break;
}
}
@@ -3820,16 +3934,23 @@ export class NodeFlowExecutionService {
} catch (error: any) {
currentRetry++;
if (currentRetry > maxRetries) {
logger.error(`❌ HTTP 요청 실패 (재시도 ${currentRetry - 1}/${maxRetries}):`, error.message);
logger.error(
`❌ HTTP 요청 실패 (재시도 ${currentRetry - 1}/${maxRetries}):`,
error.message
);
results.push({
success: false,
error: error.message,
inputData: data,
});
} else {
logger.warn(`⚠️ HTTP 요청 재시도 (${currentRetry}/${maxRetries}): ${error.message}`);
logger.warn(
`⚠️ HTTP 요청 재시도 (${currentRetry}/${maxRetries}): ${error.message}`
);
// 재시도 전 잠시 대기
await new Promise((resolve) => setTimeout(resolve, 1000 * currentRetry));
await new Promise((resolve) =>
setTimeout(resolve, 1000 * currentRetry)
);
}
}
}
@@ -3838,7 +3959,9 @@ export class NodeFlowExecutionService {
const successCount = results.filter((r) => r.success).length;
const failedCount = results.filter((r) => !r.success).length;
logger.info(`🌐 HTTP 요청 완료: 성공 ${successCount}건, 실패 ${failedCount}`);
logger.info(
`🌐 HTTP 요청 완료: 성공 ${successCount}건, 실패 ${failedCount}`
);
return {
action: "httpRequestAction",
@@ -3850,4 +3973,394 @@ export class NodeFlowExecutionService {
results,
};
}
/**
* 수식 변환 노드 실행
* - 타겟 테이블에서 기존 값 조회 (targetLookup)
* - 산술 연산, 함수, 조건, 정적 값 계산
*/
private static async executeFormulaTransform(
node: FlowNode,
inputData: any,
context: ExecutionContext
): Promise<any[]> {
const { targetLookup, transformations = [] } = node.data;
logger.info(`🧮 수식 변환 노드 실행: ${node.data.displayName || node.id}`);
logger.info(` 변환 규칙: ${transformations.length}`);
// 입력 데이터를 배열로 정규화
const dataArray = Array.isArray(inputData)
? inputData
: inputData
? [inputData]
: [];
if (dataArray.length === 0) {
logger.warn(`⚠️ 수식 변환 노드: 입력 데이터가 없습니다`);
return [];
}
const results: any[] = [];
for (const sourceRow of dataArray) {
let targetRow: any = null;
// 타겟 테이블에서 기존 값 조회
if (targetLookup?.tableName && targetLookup?.lookupKeys?.length > 0) {
try {
const whereConditions = targetLookup.lookupKeys
.map((key: any, idx: number) => `${key.targetField} = $${idx + 1}`)
.join(" AND ");
const lookupValues = targetLookup.lookupKeys.map(
(key: any) => sourceRow[key.sourceField]
);
// company_code 필터링 추가
const companyCode =
context.buttonContext?.companyCode || sourceRow.company_code;
let sql = `SELECT * FROM ${targetLookup.tableName} WHERE ${whereConditions}`;
const params = [...lookupValues];
if (companyCode && companyCode !== "*") {
sql += ` AND company_code = $${params.length + 1}`;
params.push(companyCode);
}
sql += " LIMIT 1";
logger.info(` 타겟 조회: ${targetLookup.tableName}`);
logger.info(` 조회 조건: ${whereConditions}`);
logger.info(` 조회 값: ${JSON.stringify(lookupValues)}`);
targetRow = await queryOne(sql, params);
if (targetRow) {
logger.info(` ✅ 타겟 데이터 조회 성공`);
} else {
logger.info(` 타겟 데이터 없음 (신규)`);
}
} catch (error: any) {
logger.warn(` ⚠️ 타겟 조회 실패: ${error.message}`);
}
}
// 결과 객체 (소스 데이터 복사)
const resultRow = { ...sourceRow };
// 중간 결과 저장소 (이전 변환 결과 참조용)
const resultValues: Record<string, any> = {};
// 변환 규칙 순차 실행
for (const trans of transformations) {
try {
const value = this.evaluateFormula(
trans,
sourceRow,
targetRow,
resultValues
);
resultRow[trans.outputField] = value;
resultValues[trans.outputField] = value;
logger.info(
` ${trans.outputField} = ${JSON.stringify(value)} (${trans.formulaType})`
);
} catch (error: any) {
logger.error(
` ❌ 수식 계산 실패 [${trans.outputField}]: ${error.message}`
);
resultRow[trans.outputField] = null;
}
}
results.push(resultRow);
}
logger.info(`✅ 수식 변환 완료: ${results.length}`);
return results;
}
/**
* 수식 계산
*/
private static evaluateFormula(
trans: any,
sourceRow: any,
targetRow: any,
resultValues: Record<string, any>
): any {
const {
formulaType,
arithmetic,
function: func,
condition,
staticValue,
} = trans;
switch (formulaType) {
case "arithmetic":
return this.evaluateArithmetic(
arithmetic,
sourceRow,
targetRow,
resultValues
);
case "function":
return this.evaluateFunction(func, sourceRow, targetRow, resultValues);
case "condition":
return this.evaluateCondition(
condition,
sourceRow,
targetRow,
resultValues
);
case "static":
return this.parseStaticValue(staticValue);
default:
throw new Error(`지원하지 않는 수식 타입: ${formulaType}`);
}
}
/**
* 피연산자 값 가져오기
*/
private static getOperandValue(
operand: any,
sourceRow: any,
targetRow: any,
resultValues: Record<string, any>
): any {
if (!operand) return null;
switch (operand.type) {
case "source":
return sourceRow?.[operand.field] ?? null;
case "target":
return targetRow?.[operand.field] ?? null;
case "static":
return this.parseStaticValue(operand.value);
case "result":
return resultValues?.[operand.resultField] ?? null;
default:
return null;
}
}
/**
* 정적 값 파싱 (숫자, 불린, 문자열)
*/
private static parseStaticValue(value: any): any {
if (value === null || value === undefined || value === "") return null;
// 숫자 체크
const numValue = Number(value);
if (!isNaN(numValue) && value !== "") return numValue;
// 불린 체크
if (value === "true") return true;
if (value === "false") return false;
// 문자열 반환
return value;
}
/**
* 산술 연산 계산
*/
private static evaluateArithmetic(
arithmetic: any,
sourceRow: any,
targetRow: any,
resultValues: Record<string, any>
): number | null {
if (!arithmetic) return null;
const left = this.getOperandValue(
arithmetic.leftOperand,
sourceRow,
targetRow,
resultValues
);
const right = this.getOperandValue(
arithmetic.rightOperand,
sourceRow,
targetRow,
resultValues
);
// COALESCE 처리: null이면 0으로 대체
const leftNum = Number(left) || 0;
const rightNum = Number(right) || 0;
switch (arithmetic.operator) {
case "+":
return leftNum + rightNum;
case "-":
return leftNum - rightNum;
case "*":
return leftNum * rightNum;
case "/":
if (rightNum === 0) {
logger.warn(`⚠️ 0으로 나누기 시도`);
return null;
}
return leftNum / rightNum;
case "%":
if (rightNum === 0) {
logger.warn(`⚠️ 0으로 나머지 연산 시도`);
return null;
}
return leftNum % rightNum;
default:
throw new Error(`지원하지 않는 연산자: ${arithmetic.operator}`);
}
}
/**
* 함수 실행
*/
private static evaluateFunction(
func: any,
sourceRow: any,
targetRow: any,
resultValues: Record<string, any>
): any {
if (!func) return null;
const args = (func.arguments || []).map((arg: any) =>
this.getOperandValue(arg, sourceRow, targetRow, resultValues)
);
switch (func.name) {
case "NOW":
return new Date().toISOString();
case "COALESCE":
// 첫 번째 non-null 값 반환
for (const arg of args) {
if (arg !== null && arg !== undefined) return arg;
}
return null;
case "CONCAT":
return args.filter((a: any) => a !== null && a !== undefined).join("");
case "UPPER":
return args[0] ? String(args[0]).toUpperCase() : null;
case "LOWER":
return args[0] ? String(args[0]).toLowerCase() : null;
case "TRIM":
return args[0] ? String(args[0]).trim() : null;
case "ROUND":
return args[0] !== null ? Math.round(Number(args[0])) : null;
case "ABS":
return args[0] !== null ? Math.abs(Number(args[0])) : null;
case "SUBSTRING":
if (args[0] && args[1] !== undefined) {
const str = String(args[0]);
const start = Number(args[1]) || 0;
const length = args[2] !== undefined ? Number(args[2]) : undefined;
return length !== undefined
? str.substring(start, start + length)
: str.substring(start);
}
return null;
default:
throw new Error(`지원하지 않는 함수: ${func.name}`);
}
}
/**
* 조건 평가 (CASE WHEN ... THEN ... ELSE)
*/
private static evaluateCondition(
condition: any,
sourceRow: any,
targetRow: any,
resultValues: Record<string, any>
): any {
if (!condition) return null;
const { when, then: thenValue, else: elseValue } = condition;
// WHEN 조건 평가
const leftValue = this.getOperandValue(
when.leftOperand,
sourceRow,
targetRow,
resultValues
);
const rightValue = when.rightOperand
? this.getOperandValue(
when.rightOperand,
sourceRow,
targetRow,
resultValues
)
: null;
let conditionResult = false;
switch (when.operator) {
case "=":
conditionResult = leftValue == rightValue;
break;
case "!=":
conditionResult = leftValue != rightValue;
break;
case ">":
conditionResult = Number(leftValue) > Number(rightValue);
break;
case "<":
conditionResult = Number(leftValue) < Number(rightValue);
break;
case ">=":
conditionResult = Number(leftValue) >= Number(rightValue);
break;
case "<=":
conditionResult = Number(leftValue) <= Number(rightValue);
break;
case "IS_NULL":
conditionResult = leftValue === null || leftValue === undefined;
break;
case "IS_NOT_NULL":
conditionResult = leftValue !== null && leftValue !== undefined;
break;
default:
throw new Error(`지원하지 않는 조건 연산자: ${when.operator}`);
}
// THEN 또는 ELSE 값 반환
if (conditionResult) {
return this.getOperandValue(
thenValue,
sourceRow,
targetRow,
resultValues
);
} else {
return this.getOperandValue(
elseValue,
sourceRow,
targetRow,
resultValues
);
}
}
}