수식 노드 구현
This commit is contained in:
parent
3188bc0513
commit
088596480f
|
|
@ -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);
|
||||
|
||||
|
|
@ -856,7 +860,9 @@ export class NodeFlowExecutionService {
|
|||
|
||||
// 디버깅: 조회된 데이터 샘플 출력
|
||||
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,7 +1390,11 @@ 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++) {
|
||||
|
|
@ -1821,7 +1835,11 @@ export class NodeFlowExecutionService {
|
|||
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 *`;
|
||||
|
||||
|
|
@ -1857,7 +1875,11 @@ export class NodeFlowExecutionService {
|
|||
targetTable
|
||||
);
|
||||
|
||||
const whereResult = this.buildWhereClause(enhancedWhereConditions, data, 1);
|
||||
const whereResult = this.buildWhereClause(
|
||||
enhancedWhereConditions,
|
||||
data,
|
||||
1
|
||||
);
|
||||
|
||||
const sql = `DELETE FROM ${targetTable} ${whereResult.clause} RETURNING *`;
|
||||
|
||||
|
|
@ -2714,7 +2736,9 @@ export class NodeFlowExecutionService {
|
|||
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가 없습니다`);
|
||||
}
|
||||
|
|
@ -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,15 +2801,15 @@ 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, []);
|
||||
|
|
@ -3271,7 +3309,9 @@ export class NodeFlowExecutionService {
|
|||
|
||||
// 디버깅: 각 그룹의 데이터 출력
|
||||
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)}`
|
||||
);
|
||||
}
|
||||
|
||||
// 각 그룹에 대해 집계 수행
|
||||
|
|
@ -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,7 +3431,9 @@ export class NodeFlowExecutionService {
|
|||
});
|
||||
});
|
||||
|
||||
logger.info(`📊 HAVING 필터링: ${results.length}건 → ${filteredResults.length}건`);
|
||||
logger.info(
|
||||
`📊 HAVING 필터링: ${results.length}건 → ${filteredResults.length}건`
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(`✅ 집계 완료: ${filteredResults.length}건 결과`);
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
@ -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";
|
||||
|
|
@ -33,18 +34,18 @@ export function PropertiesPanel() {
|
|||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
overflow: 'hidden'
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
height: '64px'
|
||||
height: "64px",
|
||||
}}
|
||||
className="flex items-center justify-between border-b bg-white p-4"
|
||||
>
|
||||
|
|
@ -64,8 +65,8 @@ export function PropertiesPanel() {
|
|||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden'
|
||||
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 액션",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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})`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -60,6 +60,14 @@ export const NODE_PALETTE: NodePaletteItem[] = [
|
|||
category: "transform",
|
||||
color: "#A855F7", // 보라색
|
||||
},
|
||||
{
|
||||
type: "formulaTransform",
|
||||
label: "수식 변환",
|
||||
icon: "",
|
||||
description: "산술 연산, 함수, 조건문으로 새 필드를 계산합니다",
|
||||
category: "transform",
|
||||
color: "#F97316", // 오렌지색
|
||||
},
|
||||
|
||||
// ========================================================================
|
||||
// 액션
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -577,6 +680,7 @@ export type NodeData =
|
|||
| FieldMappingNodeData
|
||||
| DataTransformNodeData
|
||||
| AggregateNodeData
|
||||
| FormulaTransformNodeData
|
||||
| InsertActionNodeData
|
||||
| UpdateActionNodeData
|
||||
| DeleteActionNodeData
|
||||
|
|
|
|||
Loading…
Reference in New Issue