수식 노드 구현

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
);
}
}
}

View File

@ -25,6 +25,7 @@ import { DeleteActionNode } from "./nodes/DeleteActionNode";
import { UpsertActionNode } from "./nodes/UpsertActionNode";
import { DataTransformNode } from "./nodes/DataTransformNode";
import { AggregateNode } from "./nodes/AggregateNode";
import { FormulaTransformNode } from "./nodes/FormulaTransformNode";
import { RestAPISourceNode } from "./nodes/RestAPISourceNode";
import { CommentNode } from "./nodes/CommentNode";
import { LogNode } from "./nodes/LogNode";
@ -44,6 +45,7 @@ const nodeTypes = {
condition: ConditionNode,
dataTransform: DataTransformNode,
aggregate: AggregateNode,
formulaTransform: FormulaTransformNode,
// 데이터 액션
insertAction: InsertActionNode,
updateAction: UpdateActionNode,

View File

@ -0,0 +1,149 @@
"use client";
/**
* (Formula Transform Node)
* , , .
* UPSERT .
*/
import { memo } from "react";
import { Handle, Position, NodeProps } from "reactflow";
import { Calculator, Database, ArrowRight } from "lucide-react";
import type { FormulaTransformNodeData, FormulaType } from "@/types/node-editor";
// 수식 타입별 라벨
const FORMULA_TYPE_LABELS: Record<FormulaType, { label: string; color: string }> = {
arithmetic: { label: "산술", color: "bg-orange-500" },
function: { label: "함수", color: "bg-blue-500" },
condition: { label: "조건", color: "bg-yellow-500" },
static: { label: "정적", color: "bg-gray-500" },
};
// 연산자 표시
const OPERATOR_LABELS: Record<string, string> = {
"+": "+",
"-": "-",
"*": "x",
"/": "/",
"%": "%",
};
// 수식 요약 생성
function getFormulaSummary(transformation: FormulaTransformNodeData["transformations"][0]): string {
const { formulaType, arithmetic, function: func, condition, staticValue } = transformation;
switch (formulaType) {
case "arithmetic": {
if (!arithmetic) return "미설정";
const left = arithmetic.leftOperand;
const right = arithmetic.rightOperand;
const leftStr = left.type === "static" ? left.value : `${left.type}.${left.field || left.resultField}`;
const rightStr = right.type === "static" ? right.value : `${right.type}.${right.field || right.resultField}`;
return `${leftStr} ${OPERATOR_LABELS[arithmetic.operator]} ${rightStr}`;
}
case "function": {
if (!func) return "미설정";
const args = func.arguments
.map((arg) => (arg.type === "static" ? arg.value : `${arg.type}.${arg.field || arg.resultField}`))
.join(", ");
return `${func.name}(${args})`;
}
case "condition": {
if (!condition) return "미설정";
return "CASE WHEN ... THEN ... ELSE ...";
}
case "static": {
return staticValue !== undefined ? String(staticValue) : "미설정";
}
default:
return "미설정";
}
}
export const FormulaTransformNode = memo(({ data, selected }: NodeProps<FormulaTransformNodeData>) => {
const transformationCount = data.transformations?.length || 0;
const hasTargetLookup = !!data.targetLookup?.tableName;
return (
<div
className={`min-w-[300px] rounded-lg border-2 bg-white shadow-md transition-all ${
selected ? "border-orange-500 shadow-lg" : "border-gray-200"
}`}
>
{/* 헤더 */}
<div className="flex items-center gap-2 rounded-t-lg bg-orange-500 px-3 py-2 text-white">
<Calculator className="h-4 w-4" />
<div className="flex-1">
<div className="text-sm font-semibold">{data.displayName || "수식 변환"}</div>
<div className="text-xs opacity-80">
{transformationCount} {hasTargetLookup && "| 타겟 조회"}
</div>
</div>
</div>
{/* 본문 */}
<div className="space-y-3 p-3">
{/* 타겟 테이블 조회 설정 */}
{hasTargetLookup && (
<div className="rounded bg-blue-50 p-2">
<div className="mb-1 flex items-center gap-1">
<Database className="h-3 w-3 text-blue-600" />
<span className="text-xs font-medium text-blue-700"> </span>
</div>
<div className="text-xs text-blue-600">{data.targetLookup?.tableLabel || data.targetLookup?.tableName}</div>
{data.targetLookup?.lookupKeys && data.targetLookup.lookupKeys.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{data.targetLookup.lookupKeys.slice(0, 2).map((key, idx) => (
<span
key={idx}
className="inline-flex items-center gap-1 rounded bg-blue-100 px-1.5 py-0.5 text-xs text-blue-700"
>
{key.sourceFieldLabel || key.sourceField}
<ArrowRight className="h-2 w-2" />
{key.targetFieldLabel || key.targetField}
</span>
))}
{data.targetLookup.lookupKeys.length > 2 && (
<span className="text-xs text-blue-500">+{data.targetLookup.lookupKeys.length - 2}</span>
)}
</div>
)}
</div>
)}
{/* 변환 규칙들 */}
{transformationCount > 0 ? (
<div className="space-y-2">
{data.transformations.slice(0, 4).map((trans, idx) => {
const typeInfo = FORMULA_TYPE_LABELS[trans.formulaType];
return (
<div key={trans.id || idx} className="rounded bg-gray-50 p-2">
<div className="flex items-center justify-between">
<span className={`rounded px-1.5 py-0.5 text-xs font-medium text-white ${typeInfo.color}`}>
{typeInfo.label}
</span>
<span className="text-xs font-medium text-gray-700">
{trans.outputFieldLabel || trans.outputField}
</span>
</div>
<div className="mt-1 truncate font-mono text-xs text-gray-500">{getFormulaSummary(trans)}</div>
</div>
);
})}
{data.transformations.length > 4 && (
<div className="text-center text-xs text-gray-400">... {data.transformations.length - 4}</div>
)}
</div>
) : (
<div className="py-4 text-center text-xs text-gray-400"> </div>
)}
</div>
{/* 핸들 */}
<Handle type="target" position={Position.Left} className="!h-3 !w-3 !bg-orange-500" />
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !bg-orange-500" />
</div>
);
});
FormulaTransformNode.displayName = "FormulaTransformNode";

View File

@ -16,6 +16,7 @@ import { ExternalDBSourceProperties } from "./properties/ExternalDBSourcePropert
import { UpsertActionProperties } from "./properties/UpsertActionProperties";
import { DataTransformProperties } from "./properties/DataTransformProperties";
import { AggregateProperties } from "./properties/AggregateProperties";
import { FormulaTransformProperties } from "./properties/FormulaTransformProperties";
import { RestAPISourceProperties } from "./properties/RestAPISourceProperties";
import { CommentProperties } from "./properties/CommentProperties";
import { LogProperties } from "./properties/LogProperties";
@ -31,21 +32,21 @@ export function PropertiesPanel() {
const selectedNode = selectedNodes.length === 1 ? nodes.find((n) => n.id === selectedNodes[0]) : null;
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
height: '100%',
width: '100%',
overflow: 'hidden'
<div
style={{
display: "flex",
flexDirection: "column",
height: "100%",
width: "100%",
overflow: "hidden",
}}
>
{/* 헤더 */}
<div
style={{
flexShrink: 0,
height: '64px'
}}
<div
style={{
flexShrink: 0,
height: "64px",
}}
className="flex items-center justify-between border-b bg-white p-4"
>
<div>
@ -60,12 +61,12 @@ export function PropertiesPanel() {
</div>
{/* 내용 - 스크롤 가능 영역 */}
<div
style={{
flex: 1,
minHeight: 0,
overflowY: 'auto',
overflowX: 'hidden'
<div
style={{
flex: 1,
minHeight: 0,
overflowY: "auto",
overflowX: "hidden",
}}
>
{selectedNodes.length === 0 ? (
@ -125,6 +126,9 @@ function NodePropertiesRenderer({ node }: { node: any }) {
case "aggregate":
return <AggregateProperties nodeId={node.id} data={node.data} />;
case "formulaTransform":
return <FormulaTransformProperties nodeId={node.id} data={node.data} />;
case "restAPISource":
return <RestAPISourceProperties nodeId={node.id} data={node.data} />;
@ -173,6 +177,7 @@ function getNodeTypeLabel(type: NodeType): string {
fieldMapping: "필드 매핑",
dataTransform: "데이터 변환",
aggregate: "집계",
formulaTransform: "수식 변환",
insertAction: "INSERT 액션",
updateAction: "UPDATE 액션",
deleteAction: "DELETE 액션",

View File

@ -0,0 +1,969 @@
"use client";
/**
*
* - ( )
* - , , ,
*/
import { useEffect, useState, useCallback } from "react";
import { Plus, Trash2, Calculator, Database, ArrowRight, Check, ChevronsUpDown } from "lucide-react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
import { tableTypeApi } from "@/lib/api/screen";
import type { FormulaTransformNodeData, FormulaType } from "@/types/node-editor";
interface FormulaTransformPropertiesProps {
nodeId: string;
data: FormulaTransformNodeData;
}
interface TableOption {
tableName: string;
displayName: string;
label: string;
}
interface ColumnInfo {
columnName: string;
columnLabel?: string;
dataType: string;
}
// 수식 타입 옵션
const FORMULA_TYPES: Array<{ value: FormulaType; label: string; description: string }> = [
{ value: "arithmetic", label: "산술 연산", description: "덧셈, 뺄셈, 곱셈, 나눗셈" },
{ value: "function", label: "함수", description: "NOW, COALESCE, CONCAT 등" },
{ value: "condition", label: "조건", description: "CASE WHEN ... THEN ... ELSE" },
{ value: "static", label: "정적 값", description: "고정 값 설정" },
];
// 산술 연산자
const ARITHMETIC_OPERATORS = [
{ value: "+", label: "더하기 (+)" },
{ value: "-", label: "빼기 (-)" },
{ value: "*", label: "곱하기 (*)" },
{ value: "/", label: "나누기 (/)" },
{ value: "%", label: "나머지 (%)" },
];
// 함수 목록
const FUNCTIONS = [
{ value: "NOW", label: "NOW()", description: "현재 시간", argCount: 0 },
{ value: "COALESCE", label: "COALESCE(a, b)", description: "NULL이면 대체값 사용", argCount: 2 },
{ value: "CONCAT", label: "CONCAT(a, b, ...)", description: "문자열 연결", argCount: -1 },
{ value: "UPPER", label: "UPPER(text)", description: "대문자 변환", argCount: 1 },
{ value: "LOWER", label: "LOWER(text)", description: "소문자 변환", argCount: 1 },
{ value: "TRIM", label: "TRIM(text)", description: "공백 제거", argCount: 1 },
{ value: "ROUND", label: "ROUND(number)", description: "반올림", argCount: 1 },
{ value: "ABS", label: "ABS(number)", description: "절대값", argCount: 1 },
];
// 조건 연산자
const CONDITION_OPERATORS = [
{ value: "=", label: "같음 (=)" },
{ value: "!=", label: "다름 (!=)" },
{ value: ">", label: "보다 큼 (>)" },
{ value: "<", label: "보다 작음 (<)" },
{ value: ">=", label: "크거나 같음 (>=)" },
{ value: "<=", label: "작거나 같음 (<=)" },
{ value: "IS_NULL", label: "NULL임" },
{ value: "IS_NOT_NULL", label: "NULL 아님" },
];
export function FormulaTransformProperties({ nodeId, data }: FormulaTransformPropertiesProps) {
const { updateNode, nodes, edges } = useFlowEditorStore();
// 로컬 상태
const [displayName, setDisplayName] = useState(data.displayName || "수식 변환");
const [targetLookup, setTargetLookup] = useState(data.targetLookup);
const [transformations, setTransformations] = useState(data.transformations || []);
// 테이블/컬럼 관련 상태
const [tables, setTables] = useState<TableOption[]>([]);
const [tablesLoading, setTablesLoading] = useState(false);
const [tablesOpen, setTablesOpen] = useState(false);
const [targetColumns, setTargetColumns] = useState<ColumnInfo[]>([]);
const [targetColumnsLoading, setTargetColumnsLoading] = useState(false);
// 소스 필드 목록 (연결된 입력 노드에서 가져오기)
const [sourceFields, setSourceFields] = useState<Array<{ name: string; label?: string }>>([]);
// 데이터 변경 시 로컬 상태 업데이트
useEffect(() => {
setDisplayName(data.displayName || "수식 변환");
setTargetLookup(data.targetLookup);
setTransformations(data.transformations || []);
}, [data]);
// 테이블 목록 로딩
useEffect(() => {
loadTables();
}, []);
// 타겟 테이블 변경 시 컬럼 로딩
useEffect(() => {
if (targetLookup?.tableName) {
loadTargetColumns(targetLookup.tableName);
}
}, [targetLookup?.tableName]);
// 연결된 소스 노드에서 필드 가져오기 (재귀적으로)
useEffect(() => {
const getAllSourceFields = (
targetNodeId: string,
visitedNodes = new Set<string>(),
): Array<{ name: string; label?: string }> => {
if (visitedNodes.has(targetNodeId)) return [];
visitedNodes.add(targetNodeId);
const inputEdges = edges.filter((edge) => edge.target === targetNodeId);
const sourceNodeIds = inputEdges.map((edge) => edge.source);
const sourceNodes = nodes.filter((node) => sourceNodeIds.includes(node.id));
const fields: Array<{ name: string; label?: string }> = [];
sourceNodes.forEach((node) => {
// 테이블/외부DB 소스 노드
if (node.type === "tableSource" || node.type === "externalDBSource") {
const nodeFields = (node.data as any).fields || (node.data as any).outputFields;
if (nodeFields && Array.isArray(nodeFields)) {
nodeFields.forEach((field: any) => {
const fieldName = field.name || field.fieldName || field.column_name;
const fieldLabel = field.label || field.displayName || field.label_ko;
if (fieldName) {
fields.push({ name: fieldName, label: fieldLabel });
}
});
}
}
// 데이터 변환 노드
else if (node.type === "dataTransform") {
const upperFields = getAllSourceFields(node.id, visitedNodes);
fields.push(...upperFields);
// 변환된 필드 추가
if ((node.data as any).transformations) {
(node.data as any).transformations.forEach((transform: any) => {
const targetField = transform.targetField || transform.sourceField;
if (targetField) {
fields.push({
name: targetField,
label: transform.targetFieldLabel || targetField,
});
}
});
}
}
// 집계 노드
else if (node.type === "aggregate") {
const nodeData = node.data as any;
// 그룹 기준 필드
if (nodeData.groupByFields) {
nodeData.groupByFields.forEach((groupField: any) => {
const fieldName = groupField.field || groupField.fieldName;
if (fieldName) {
fields.push({ name: fieldName, label: groupField.fieldLabel || fieldName });
}
});
}
// 집계 결과 필드
const aggregations = nodeData.aggregations || [];
aggregations.forEach((aggFunc: any) => {
const outputFieldName = aggFunc.outputField || aggFunc.targetField;
if (outputFieldName) {
fields.push({ name: outputFieldName, label: aggFunc.outputFieldLabel || outputFieldName });
}
});
}
// 기타 노드: 상위 탐색
else {
const upperFields = getAllSourceFields(node.id, visitedNodes);
fields.push(...upperFields);
}
});
return fields;
};
const fields = getAllSourceFields(nodeId);
const uniqueFields = Array.from(new Map(fields.map((field) => [field.name, field])).values());
setSourceFields(uniqueFields);
}, [nodeId, nodes, edges]);
// 저장 함수
const saveToNode = useCallback(
(updates: Partial<FormulaTransformNodeData>) => {
updateNode(nodeId, {
displayName,
targetLookup,
transformations,
...updates,
});
},
[nodeId, updateNode, displayName, targetLookup, transformations],
);
// 테이블 목록 로딩
const loadTables = async () => {
try {
setTablesLoading(true);
const tableList = await tableTypeApi.getTables();
const options: TableOption[] = tableList.map((table) => ({
tableName: table.tableName,
displayName: table.displayName || table.tableName,
label: (table as any).tableLabel || table.displayName || table.tableName,
}));
setTables(options);
} catch (error) {
console.error("테이블 목록 로딩 실패:", error);
} finally {
setTablesLoading(false);
}
};
// 타겟 테이블 컬럼 로딩
const loadTargetColumns = async (tableName: string) => {
try {
setTargetColumnsLoading(true);
const columns = await tableTypeApi.getColumns(tableName);
const columnInfo: ColumnInfo[] = columns.map((col: any) => ({
columnName: col.column_name || col.columnName,
columnLabel: col.label_ko || col.columnLabel,
dataType: col.data_type || col.dataType || "unknown",
}));
setTargetColumns(columnInfo);
} catch (error) {
console.error("컬럼 목록 로딩 실패:", error);
setTargetColumns([]);
} finally {
setTargetColumnsLoading(false);
}
};
// 타겟 테이블 선택
const handleTargetTableSelect = async (tableName: string) => {
const selectedTable = tables.find((t) => t.tableName === tableName);
const newTargetLookup = {
tableName,
tableLabel: selectedTable?.label,
lookupKeys: targetLookup?.lookupKeys || [],
};
setTargetLookup(newTargetLookup);
saveToNode({ targetLookup: newTargetLookup });
setTablesOpen(false);
};
// 타겟 테이블 조회 키 추가
const handleAddLookupKey = () => {
const newLookupKeys = [...(targetLookup?.lookupKeys || []), { sourceField: "", targetField: "" }];
const newTargetLookup = { ...targetLookup!, lookupKeys: newLookupKeys };
setTargetLookup(newTargetLookup);
saveToNode({ targetLookup: newTargetLookup });
};
// 타겟 테이블 조회 키 삭제
const handleRemoveLookupKey = (index: number) => {
const newLookupKeys = (targetLookup?.lookupKeys || []).filter((_, i) => i !== index);
const newTargetLookup = { ...targetLookup!, lookupKeys: newLookupKeys };
setTargetLookup(newTargetLookup);
saveToNode({ targetLookup: newTargetLookup });
};
// 타겟 테이블 조회 키 변경
const handleLookupKeyChange = (index: number, field: string, value: string) => {
const newLookupKeys = [...(targetLookup?.lookupKeys || [])];
if (field === "sourceField") {
const sourceField = sourceFields.find((f) => f.name === value);
newLookupKeys[index] = {
...newLookupKeys[index],
sourceField: value,
sourceFieldLabel: sourceField?.label,
};
} else if (field === "targetField") {
const targetCol = targetColumns.find((c) => c.columnName === value);
newLookupKeys[index] = {
...newLookupKeys[index],
targetField: value,
targetFieldLabel: targetCol?.columnLabel,
};
}
const newTargetLookup = { ...targetLookup!, lookupKeys: newLookupKeys };
setTargetLookup(newTargetLookup);
saveToNode({ targetLookup: newTargetLookup });
};
// 변환 규칙 추가
const handleAddTransformation = () => {
const newTransformation = {
id: `trans_${Date.now()}`,
outputField: "",
outputFieldLabel: "",
formulaType: "arithmetic" as FormulaType,
arithmetic: {
leftOperand: { type: "source" as const, field: "" },
operator: "+" as const,
rightOperand: { type: "source" as const, field: "" },
},
};
const newTransformations = [...transformations, newTransformation];
setTransformations(newTransformations);
saveToNode({ transformations: newTransformations });
};
// 변환 규칙 삭제
const handleRemoveTransformation = (index: number) => {
const newTransformations = transformations.filter((_, i) => i !== index);
setTransformations(newTransformations);
saveToNode({ transformations: newTransformations });
};
// 변환 규칙 변경
const handleTransformationChange = (
index: number,
updates: Partial<FormulaTransformNodeData["transformations"][0]>,
) => {
const newTransformations = [...transformations];
newTransformations[index] = { ...newTransformations[index], ...updates };
setTransformations(newTransformations);
saveToNode({ transformations: newTransformations });
};
// 수식 타입 변경
const handleFormulaTypeChange = (index: number, newType: FormulaType) => {
const newTransformations = [...transformations];
const trans = newTransformations[index];
// 기본값 설정
switch (newType) {
case "arithmetic":
trans.arithmetic = {
leftOperand: { type: "source", field: "" },
operator: "+",
rightOperand: { type: "source", field: "" },
};
trans.function = undefined;
trans.condition = undefined;
trans.staticValue = undefined;
break;
case "function":
trans.function = {
name: "COALESCE",
arguments: [
{ type: "source", field: "" },
{ type: "static", value: 0 },
],
};
trans.arithmetic = undefined;
trans.condition = undefined;
trans.staticValue = undefined;
break;
case "condition":
trans.condition = {
when: {
leftOperand: { type: "source", field: "" },
operator: "=",
rightOperand: { type: "static", value: "" },
},
then: { type: "static", value: "" },
else: { type: "static", value: "" },
};
trans.arithmetic = undefined;
trans.function = undefined;
trans.staticValue = undefined;
break;
case "static":
trans.staticValue = "";
trans.arithmetic = undefined;
trans.function = undefined;
trans.condition = undefined;
break;
}
trans.formulaType = newType;
setTransformations(newTransformations);
saveToNode({ transformations: newTransformations });
};
// 이전 변환 결과 필드 목록 (result 타입용)
const getResultFields = (currentIndex: number) => {
return transformations
.slice(0, currentIndex)
.filter((t) => t.outputField)
.map((t) => ({
name: t.outputField,
label: t.outputFieldLabel || t.outputField,
}));
};
// 피연산자 렌더링 (산술, 함수, 조건에서 공통 사용)
const renderOperandSelector = (
operand: { type: string; field?: string; fieldLabel?: string; value?: string | number; resultField?: string },
onChange: (updates: any) => void,
currentTransIndex: number,
) => {
const resultFields = getResultFields(currentTransIndex);
return (
<div className="space-y-2">
<Select
value={operand.type}
onValueChange={(value) => onChange({ type: value, field: "", value: undefined, resultField: "" })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="source"> (source.*)</SelectItem>
{targetLookup?.tableName && <SelectItem value="target"> (target.*)</SelectItem>}
<SelectItem value="static"> </SelectItem>
{resultFields.length > 0 && <SelectItem value="result"> (result.*)</SelectItem>}
</SelectContent>
</Select>
{operand.type === "source" && (
<Select
value={operand.field || ""}
onValueChange={(value) => {
const sf = sourceFields.find((f) => f.name === value);
onChange({ ...operand, field: value, fieldLabel: sf?.label });
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="소스 필드 선택" />
</SelectTrigger>
<SelectContent>
{sourceFields.length === 0 ? (
<div className="p-2 text-center text-xs text-gray-400"> </div>
) : (
sourceFields.map((f) => (
<SelectItem key={f.name} value={f.name}>
{f.label || f.name}
</SelectItem>
))
)}
</SelectContent>
</Select>
)}
{operand.type === "target" && (
<Select
value={operand.field || ""}
onValueChange={(value) => {
const tc = targetColumns.find((c) => c.columnName === value);
onChange({ ...operand, field: value, fieldLabel: tc?.columnLabel });
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="타겟 필드 선택" />
</SelectTrigger>
<SelectContent>
{targetColumns.length === 0 ? (
<div className="p-2 text-center text-xs text-gray-400"> </div>
) : (
targetColumns.map((c) => (
<SelectItem key={c.columnName} value={c.columnName}>
{c.columnLabel || c.columnName}
</SelectItem>
))
)}
</SelectContent>
</Select>
)}
{operand.type === "static" && (
<Input
value={operand.value ?? ""}
onChange={(e) => onChange({ ...operand, value: e.target.value })}
placeholder="값 입력"
className="h-8 text-xs"
/>
)}
{operand.type === "result" && (
<Select
value={operand.resultField || ""}
onValueChange={(value) => {
const rf = resultFields.find((f) => f.name === value);
onChange({ ...operand, resultField: value, fieldLabel: rf?.label });
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="이전 결과 선택" />
</SelectTrigger>
<SelectContent>
{resultFields.map((f) => (
<SelectItem key={f.name} value={f.name}>
{f.label || f.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
);
};
return (
<div>
<div className="space-y-4 p-4 pb-8">
{/* 헤더 */}
<div className="flex items-center gap-2 rounded-md bg-orange-50 p-2">
<Calculator className="h-4 w-4 text-orange-600" />
<span className="font-semibold text-orange-600"> </span>
</div>
{/* 기본 정보 */}
<div>
<h3 className="mb-3 text-sm font-semibold"> </h3>
<div>
<Label htmlFor="displayName" className="text-xs">
</Label>
<Input
id="displayName"
value={displayName}
onChange={(e) => {
setDisplayName(e.target.value);
saveToNode({ displayName: e.target.value });
}}
className="mt-1"
placeholder="노드 표시 이름"
/>
</div>
</div>
{/* 타겟 테이블 조회 설정 */}
<div>
<div className="mb-2 flex items-center gap-2">
<Database className="h-4 w-4 text-blue-600" />
<h3 className="text-sm font-semibold"> ()</h3>
</div>
<p className="mb-2 text-xs text-gray-500">
UPSERT . target.* .
</p>
{/* 타겟 테이블 선택 */}
<Popover open={tablesOpen} onOpenChange={setTablesOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={tablesOpen}
className="mb-3 w-full justify-between"
disabled={tablesLoading}
>
{tablesLoading ? (
<span className="text-muted-foreground"> ...</span>
) : targetLookup?.tableName ? (
<span>{targetLookup.tableLabel || targetLookup.tableName}</span>
) : (
<span className="text-muted-foreground"> ()</span>
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[320px] p-0" align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="h-9" />
<CommandEmpty> .</CommandEmpty>
<CommandList>
<CommandGroup>
{tables.map((table) => (
<CommandItem
key={table.tableName}
value={`${table.label} ${table.displayName} ${table.tableName}`}
onSelect={() => handleTargetTableSelect(table.tableName)}
>
<Check
className={cn(
"mr-2 h-4 w-4",
targetLookup?.tableName === table.tableName ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{table.label}</span>
<span className="text-muted-foreground text-xs">{table.tableName}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{/* 조회 키 설정 */}
{targetLookup?.tableName && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs"> (source target)</Label>
<Button size="sm" variant="outline" onClick={handleAddLookupKey} className="h-6 px-2 text-xs">
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
{(targetLookup.lookupKeys || []).length === 0 ? (
<div className="rounded border border-dashed bg-gray-50 p-3 text-center text-xs text-gray-500">
(: item_code, lot_number)
</div>
) : (
<div className="space-y-2">
{targetLookup.lookupKeys.map((key, idx) => (
<div key={idx} className="flex items-center gap-2 rounded border bg-gray-50 p-2">
<Select
value={key.sourceField}
onValueChange={(v) => handleLookupKeyChange(idx, "sourceField", v)}
>
<SelectTrigger className="h-8 flex-1 text-xs">
<SelectValue placeholder="소스 필드" />
</SelectTrigger>
<SelectContent>
{sourceFields.map((f) => (
<SelectItem key={f.name} value={f.name}>
{f.label || f.name}
</SelectItem>
))}
</SelectContent>
</Select>
<ArrowRight className="h-4 w-4 text-gray-400" />
<Select
value={key.targetField}
onValueChange={(v) => handleLookupKeyChange(idx, "targetField", v)}
>
<SelectTrigger className="h-8 flex-1 text-xs">
<SelectValue placeholder="타겟 필드" />
</SelectTrigger>
<SelectContent>
{targetColumns.map((c) => (
<SelectItem key={c.columnName} value={c.columnName}>
{c.columnLabel || c.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
variant="ghost"
onClick={() => handleRemoveLookupKey(idx)}
className="h-6 w-6 p-0 text-red-600"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
</div>
)}
</div>
{/* 변환 규칙 */}
<div>
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<Calculator className="h-4 w-4 text-orange-600" />
<h3 className="text-sm font-semibold"> </h3>
</div>
<Button size="sm" variant="outline" onClick={handleAddTransformation} className="h-7 px-2 text-xs">
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
{transformations.length === 0 ? (
<div className="rounded border border-dashed bg-gray-50 p-4 text-center text-xs text-gray-500">
</div>
) : (
<div className="space-y-3">
{transformations.map((trans, index) => (
<div key={trans.id || index} className="rounded border bg-orange-50 p-3">
<div className="mb-3 flex items-center justify-between">
<span className="text-xs font-medium text-orange-700"> #{index + 1}</span>
<Button
size="sm"
variant="ghost"
onClick={() => handleRemoveTransformation(index)}
className="h-6 w-6 p-0 text-red-600"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
<div className="space-y-3">
{/* 출력 필드명 */}
<div>
<Label className="text-xs text-gray-600"> </Label>
<Input
value={trans.outputField || ""}
onChange={(e) => handleTransformationChange(index, { outputField: e.target.value })}
placeholder="예: new_current_qty"
className="mt-1 h-8 text-xs"
/>
</div>
{/* 출력 필드 라벨 */}
<div>
<Label className="text-xs text-gray-600"> ()</Label>
<Input
value={trans.outputFieldLabel || ""}
onChange={(e) => handleTransformationChange(index, { outputFieldLabel: e.target.value })}
placeholder="예: 새 현재고량"
className="mt-1 h-8 text-xs"
/>
</div>
{/* 수식 타입 선택 */}
<div>
<Label className="text-xs text-gray-600"> </Label>
<Select
value={trans.formulaType}
onValueChange={(value) => handleFormulaTypeChange(index, value as FormulaType)}
>
<SelectTrigger className="mt-1 h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FORMULA_TYPES.map((ft) => (
<SelectItem key={ft.value} value={ft.value}>
<div>
<div className="font-medium">{ft.label}</div>
<div className="text-xs text-gray-400">{ft.description}</div>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 수식 타입별 설정 */}
{trans.formulaType === "arithmetic" && trans.arithmetic && (
<div className="space-y-2 rounded border bg-white p-2">
<Label className="text-xs text-gray-600"> </Label>
{/* 좌측 피연산자 */}
<div className="rounded bg-gray-50 p-2">
<div className="mb-1 text-xs text-gray-500"></div>
{renderOperandSelector(
trans.arithmetic.leftOperand,
(updates) => {
const newArithmetic = { ...trans.arithmetic!, leftOperand: updates };
handleTransformationChange(index, { arithmetic: newArithmetic });
},
index,
)}
</div>
{/* 연산자 */}
<Select
value={trans.arithmetic.operator}
onValueChange={(value) => {
const newArithmetic = { ...trans.arithmetic!, operator: value as any };
handleTransformationChange(index, { arithmetic: newArithmetic });
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ARITHMETIC_OPERATORS.map((op) => (
<SelectItem key={op.value} value={op.value}>
{op.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 우측 피연산자 */}
<div className="rounded bg-gray-50 p-2">
<div className="mb-1 text-xs text-gray-500"></div>
{renderOperandSelector(
trans.arithmetic.rightOperand,
(updates) => {
const newArithmetic = { ...trans.arithmetic!, rightOperand: updates };
handleTransformationChange(index, { arithmetic: newArithmetic });
},
index,
)}
</div>
</div>
)}
{trans.formulaType === "function" && trans.function && (
<div className="space-y-2 rounded border bg-white p-2">
<Label className="text-xs text-gray-600"></Label>
<Select
value={trans.function.name}
onValueChange={(value) => {
const funcDef = FUNCTIONS.find((f) => f.value === value);
const argCount = funcDef?.argCount || 0;
const newArgs =
argCount === 0
? []
: Array(argCount === -1 ? 2 : argCount)
.fill(null)
.map(() => ({ type: "source" as const, field: "" }));
handleTransformationChange(index, {
function: { name: value as any, arguments: newArgs },
});
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FUNCTIONS.map((f) => (
<SelectItem key={f.value} value={f.value}>
<div>
<div className="font-mono font-medium">{f.label}</div>
<div className="text-xs text-gray-400">{f.description}</div>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{/* 함수 인자들 */}
{trans.function.arguments.length > 0 && (
<div className="space-y-2">
{trans.function.arguments.map((arg, argIdx) => (
<div key={argIdx} className="rounded bg-gray-50 p-2">
<div className="mb-1 text-xs text-gray-500"> {argIdx + 1}</div>
{renderOperandSelector(
arg,
(updates) => {
const newArgs = [...trans.function!.arguments];
newArgs[argIdx] = updates;
handleTransformationChange(index, {
function: { ...trans.function!, arguments: newArgs },
});
},
index,
)}
</div>
))}
</div>
)}
</div>
)}
{trans.formulaType === "condition" && trans.condition && (
<div className="space-y-2 rounded border bg-white p-2">
<Label className="text-xs text-gray-600"> (CASE WHEN)</Label>
{/* WHEN 절 */}
<div className="rounded bg-yellow-50 p-2">
<div className="mb-1 text-xs font-medium text-yellow-700">WHEN</div>
<div className="space-y-2">
{renderOperandSelector(
trans.condition.when.leftOperand,
(updates) => {
const newCondition = {
...trans.condition!,
when: { ...trans.condition!.when, leftOperand: updates },
};
handleTransformationChange(index, { condition: newCondition });
},
index,
)}
<Select
value={trans.condition.when.operator}
onValueChange={(value) => {
const newCondition = {
...trans.condition!,
when: { ...trans.condition!.when, operator: value as any },
};
handleTransformationChange(index, { condition: newCondition });
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CONDITION_OPERATORS.map((op) => (
<SelectItem key={op.value} value={op.value}>
{op.label}
</SelectItem>
))}
</SelectContent>
</Select>
{!["IS_NULL", "IS_NOT_NULL"].includes(trans.condition.when.operator) &&
trans.condition.when.rightOperand &&
renderOperandSelector(
trans.condition.when.rightOperand,
(updates) => {
const newCondition = {
...trans.condition!,
when: { ...trans.condition!.when, rightOperand: updates },
};
handleTransformationChange(index, { condition: newCondition });
},
index,
)}
</div>
</div>
{/* THEN 절 */}
<div className="rounded bg-green-50 p-2">
<div className="mb-1 text-xs font-medium text-green-700">THEN</div>
{renderOperandSelector(
trans.condition.then,
(updates) => {
const newCondition = { ...trans.condition!, then: updates };
handleTransformationChange(index, { condition: newCondition });
},
index,
)}
</div>
{/* ELSE 절 */}
<div className="rounded bg-red-50 p-2">
<div className="mb-1 text-xs font-medium text-red-700">ELSE</div>
{renderOperandSelector(
trans.condition.else,
(updates) => {
const newCondition = { ...trans.condition!, else: updates };
handleTransformationChange(index, { condition: newCondition });
},
index,
)}
</div>
</div>
)}
{trans.formulaType === "static" && (
<div className="space-y-2 rounded border bg-white p-2">
<Label className="text-xs text-gray-600"> </Label>
<Input
value={trans.staticValue ?? ""}
onChange={(e) => handleTransformationChange(index, { staticValue: e.target.value })}
placeholder="고정 값 입력"
className="h-8 text-xs"
/>
<p className="text-xs text-gray-400">, , true/false </p>
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
}

View File

@ -236,7 +236,31 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
console.log("⚠️ REST API 노드에 responseFields 없음");
}
}
// 3⃣ 집계(Aggregate) 노드: 그룹 필드 + 집계 결과 필드
// 3⃣ 수식 변환(FormulaTransform) 노드: 상위 필드 + 변환 출력 필드
else if (node.type === "formulaTransform") {
console.log("✅ 수식 변환 노드 발견");
// 상위 노드의 필드 가져오기
const upperResult = getAllSourceFields(node.id, visitedNodes, currentPath);
fields.push(...upperResult.fields);
foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
// 수식 변환 출력 필드 추가
const nodeData = node.data as any;
if (nodeData.transformations && Array.isArray(nodeData.transformations)) {
console.log(` 📊 ${nodeData.transformations.length}개 수식 변환 발견`);
nodeData.transformations.forEach((trans: any) => {
if (trans.outputField) {
fields.push({
name: trans.outputField,
label: trans.outputFieldLabel || trans.outputField,
sourcePath: currentPath,
});
}
});
}
}
// 4⃣ 집계(Aggregate) 노드: 그룹 필드 + 집계 결과 필드
else if (node.type === "aggregate") {
console.log("✅ 집계 노드 발견");
const nodeData = node.data as any;
@ -268,7 +292,10 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
if (outputFieldName) {
fields.push({
name: outputFieldName,
label: aggFunc.outputFieldLabel || aggFunc.targetFieldLabel || `${funcType}(${aggFunc.sourceFieldLabel || aggFunc.sourceField})`,
label:
aggFunc.outputFieldLabel ||
aggFunc.targetFieldLabel ||
`${funcType}(${aggFunc.sourceFieldLabel || aggFunc.sourceField})`,
sourcePath: currentPath,
});
}

View File

@ -212,7 +212,27 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
fields.push(...upperFields);
}
}
// 2⃣ 집계(Aggregate) 노드: 그룹 필드 + 집계 결과 필드
// 2⃣ 수식 변환(FormulaTransform) 노드: 상위 필드 + 변환 출력 필드
else if (node.type === "formulaTransform") {
// 상위 노드의 필드 가져오기
const upperResult = getAllSourceFields(node.id, visitedNodes);
fields.push(...upperResult.fields);
foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
// 수식 변환 출력 필드 추가
const nodeData = node.data as any;
if (nodeData.transformations && Array.isArray(nodeData.transformations)) {
nodeData.transformations.forEach((trans: any) => {
if (trans.outputField) {
fields.push({
name: trans.outputField,
label: trans.outputFieldLabel || trans.outputField,
});
}
});
}
}
// 3⃣ 집계(Aggregate) 노드: 그룹 필드 + 집계 결과 필드
else if (node.type === "aggregate") {
const nodeData = node.data as any;
@ -240,7 +260,10 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
if (outputFieldName) {
fields.push({
name: outputFieldName,
label: aggFunc.outputFieldLabel || aggFunc.targetFieldLabel || `${funcType}(${aggFunc.sourceFieldLabel || aggFunc.sourceField})`,
label:
aggFunc.outputFieldLabel ||
aggFunc.targetFieldLabel ||
`${funcType}(${aggFunc.sourceFieldLabel || aggFunc.sourceField})`,
});
}
});
@ -248,7 +271,7 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
// 집계 노드는 상위 노드의 필드를 그대로 통과시키지 않음 (집계된 결과만 전달)
}
// 3️⃣ REST API 소스 노드
// 4️⃣ REST API 소스 노드
else if (node.type === "restAPISource") {
foundRestAPI = true;
const responseFields = (node.data as any).responseFields;

View File

@ -212,7 +212,27 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
});
}
}
// 3⃣ 집계(Aggregate) 노드: 그룹 필드 + 집계 결과 필드
// 3⃣ 수식 변환(FormulaTransform) 노드: 상위 필드 + 변환 출력 필드
else if (node.type === "formulaTransform") {
// 상위 노드의 필드 가져오기
const upperResult = getAllSourceFields(node.id, visitedNodes);
fields.push(...upperResult.fields);
foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
// 수식 변환 출력 필드 추가
const nodeData = node.data as any;
if (nodeData.transformations && Array.isArray(nodeData.transformations)) {
nodeData.transformations.forEach((trans: any) => {
if (trans.outputField) {
fields.push({
name: trans.outputField,
label: trans.outputFieldLabel || trans.outputField,
});
}
});
}
}
// 4⃣ 집계(Aggregate) 노드: 그룹 필드 + 집계 결과 필드
else if (node.type === "aggregate") {
const nodeData = node.data as any;
@ -240,7 +260,10 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
if (outputFieldName) {
fields.push({
name: outputFieldName,
label: aggFunc.outputFieldLabel || aggFunc.targetFieldLabel || `${funcType}(${aggFunc.sourceFieldLabel || aggFunc.sourceField})`,
label:
aggFunc.outputFieldLabel ||
aggFunc.targetFieldLabel ||
`${funcType}(${aggFunc.sourceFieldLabel || aggFunc.sourceField})`,
});
}
});

View File

@ -60,6 +60,14 @@ export const NODE_PALETTE: NodePaletteItem[] = [
category: "transform",
color: "#A855F7", // 보라색
},
{
type: "formulaTransform",
label: "수식 변환",
icon: "",
description: "산술 연산, 함수, 조건문으로 새 필드를 계산합니다",
category: "transform",
color: "#F97316", // 오렌지색
},
// ========================================================================
// 액션

View File

@ -15,6 +15,7 @@ export type NodeType =
| "condition" // 조건 분기
| "dataTransform" // 데이터 변환
| "aggregate" // 집계 노드 (SUM, COUNT, AVG 등)
| "formulaTransform" // 수식 변환 노드
| "insertAction" // INSERT 액션
| "updateAction" // UPDATE 액션
| "deleteAction" // DELETE 액션
@ -171,6 +172,108 @@ export interface DataTransformNodeData {
// 집계 함수 타입
export type AggregateFunction = "SUM" | "COUNT" | "AVG" | "MIN" | "MAX" | "FIRST" | "LAST";
// ============================================================================
// 수식 변환 노드 (Formula Transform)
// ============================================================================
// 수식 타입
export type FormulaType = "arithmetic" | "function" | "condition" | "static";
// 수식 변환 노드 데이터
export interface FormulaTransformNodeData {
displayName?: string;
// 타겟 테이블 조회 설정 (기존 값 참조용 - UPSERT 시나리오)
targetLookup?: {
tableName: string; // 조회할 테이블명
tableLabel?: string; // 테이블 라벨
lookupKeys: Array<{
// 조회 키 (source 필드와 매칭)
sourceField: string; // 소스 필드명
sourceFieldLabel?: string;
targetField: string; // 타겟 테이블의 필드명
targetFieldLabel?: string;
}>;
};
// 변환 규칙들
transformations: Array<{
id: string; // 고유 ID
outputField: string; // 출력 필드명
outputFieldLabel?: string; // 출력 필드 라벨
formulaType: FormulaType; // 수식 타입
// 산술 연산 (formulaType === "arithmetic")
arithmetic?: {
leftOperand: {
type: "source" | "target" | "static" | "result"; // 값 소스
field?: string; // source.* 또는 target.* 필드
fieldLabel?: string;
value?: string | number; // 정적 값
resultField?: string; // 이전 변환 결과 필드 참조
};
operator: "+" | "-" | "*" | "/" | "%"; // 연산자
rightOperand: {
type: "source" | "target" | "static" | "result";
field?: string;
fieldLabel?: string;
value?: string | number;
resultField?: string;
};
};
// 함수 (formulaType === "function")
function?: {
name: "NOW" | "COALESCE" | "CONCAT" | "UPPER" | "LOWER" | "TRIM" | "ROUND" | "ABS" | "SUBSTRING";
arguments: Array<{
type: "source" | "target" | "static" | "result";
field?: string;
fieldLabel?: string;
value?: string | number;
resultField?: string;
}>;
};
// 조건 (formulaType === "condition")
condition?: {
when: {
leftOperand: {
type: "source" | "target" | "static" | "result";
field?: string;
fieldLabel?: string;
value?: string | number;
resultField?: string;
};
operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "IS_NULL" | "IS_NOT_NULL";
rightOperand?: {
type: "source" | "target" | "static" | "result";
field?: string;
fieldLabel?: string;
value?: string | number;
resultField?: string;
};
};
then: {
type: "source" | "target" | "static" | "result";
field?: string;
fieldLabel?: string;
value?: string | number;
resultField?: string;
};
else: {
type: "source" | "target" | "static" | "result";
field?: string;
fieldLabel?: string;
value?: string | number;
resultField?: string;
};
};
// 정적 값 (formulaType === "static")
staticValue?: string | number | boolean;
}>;
}
// 집계 노드 데이터
export interface AggregateNodeData {
displayName?: string;
@ -403,15 +506,15 @@ export interface LogNodeData {
// 메일 발송 액션 노드
export interface EmailActionNodeData {
displayName?: string;
// 메일 계정 선택 (메일관리에서 등록한 계정)
accountId?: string; // 메일 계정 ID (우선 사용)
// 🆕 수신자 컴포넌트 사용 여부
useRecipientComponent?: boolean; // true면 {{mailTo}}, {{mailCc}} 자동 사용
recipientToField?: string; // 수신자 필드명 (기본: mailTo)
recipientCcField?: string; // 참조 필드명 (기본: mailCc)
// SMTP 서버 설정 (직접 설정 시 사용, accountId가 있으면 무시됨)
smtpConfig?: {
host: string;
@ -422,7 +525,7 @@ export interface EmailActionNodeData {
pass: string;
};
};
// 메일 내용
from?: string; // 발신자 이메일 (계정 선택 시 자동 설정)
to: string; // 수신자 이메일 (쉼표로 구분하여 여러 명) - useRecipientComponent가 true면 무시됨
@ -431,18 +534,18 @@ export interface EmailActionNodeData {
subject: string; // 제목 (템플릿 변수 지원)
body: string; // 본문 (템플릿 변수 지원)
bodyType: "text" | "html"; // 본문 형식
// 첨부파일 (선택)
attachments?: Array<{
filename: string;
path?: string; // 파일 경로
content?: string; // Base64 인코딩된 내용
}>;
// 고급 설정
replyTo?: string;
priority?: "high" | "normal" | "low";
// 실행 옵션
options?: {
retryCount?: number;
@ -454,35 +557,35 @@ export interface EmailActionNodeData {
// 스크립트 실행 액션 노드
export interface ScriptActionNodeData {
displayName?: string;
// 스크립트 타입
scriptType: "python" | "shell" | "powershell" | "node" | "executable";
// 실행 방식
executionMode: "inline" | "file";
// 인라인 스크립트 (executionMode === "inline")
inlineScript?: string;
// 파일 경로 (executionMode === "file")
scriptPath?: string;
// 실행 파일 경로 (scriptType === "executable")
executablePath?: string;
// 명령줄 인자
arguments?: string[];
// 환경 변수
environmentVariables?: Record<string, string>;
// 입력 데이터 전달 방식
inputMethod: "stdin" | "args" | "env" | "file";
inputFormat?: "json" | "csv" | "text"; // stdin/file 사용 시
// 작업 디렉토리
workingDirectory?: string;
// 실행 옵션
options?: {
timeout?: number; // ms (기본: 60000)
@ -490,7 +593,7 @@ export interface ScriptActionNodeData {
shell?: string; // 사용할 쉘 (예: /bin/bash)
encoding?: string; // 출력 인코딩 (기본: utf8)
};
// 출력 처리
outputHandling?: {
captureStdout: boolean;
@ -503,17 +606,17 @@ export interface ScriptActionNodeData {
// HTTP 요청 액션 노드
export interface HttpRequestActionNodeData {
displayName?: string;
// 기본 설정
url: string; // URL (템플릿 변수 지원)
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS";
// 헤더
headers?: Record<string, string>;
// 쿼리 파라미터
queryParams?: Record<string, string>;
// 요청 본문
bodyType?: "none" | "json" | "form" | "text" | "binary";
body?: string; // JSON 문자열 또는 텍스트 (템플릿 변수 지원)
@ -522,7 +625,7 @@ export interface HttpRequestActionNodeData {
value: string;
type: "text" | "file";
}>;
// 인증
authentication?: {
type: "none" | "basic" | "bearer" | "apikey" | "oauth2";
@ -544,7 +647,7 @@ export interface HttpRequestActionNodeData {
scope?: string;
};
};
// 고급 설정
options?: {
timeout?: number; // ms (기본: 30000)
@ -555,7 +658,7 @@ export interface HttpRequestActionNodeData {
retryDelay?: number; // ms
retryOn?: ("timeout" | "5xx" | "network")[]; // 재시도 조건
};
// 응답 처리
responseHandling?: {
extractPath?: string; // JSON 경로 (예: "data.items")
@ -577,6 +680,7 @@ export type NodeData =
| FieldMappingNodeData
| DataTransformNodeData
| AggregateNodeData
| FormulaTransformNodeData
| InsertActionNodeData
| UpdateActionNodeData
| DeleteActionNodeData