diff --git a/backend-node/src/services/nodeFlowExecutionService.ts b/backend-node/src/services/nodeFlowExecutionService.ts index 0542b51e..fc38406a 100644 --- a/backend-node/src/services/nodeFlowExecutionService.ts +++ b/backend-node/src/services/nodeFlowExecutionService.ts @@ -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 { - 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 { + 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 = {}; + + // 변환 규칙 순차 실행 + 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 + ): 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 + ): 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 + ): 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 + ): 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 + ): 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 + ); + } + } } diff --git a/frontend/components/dataflow/node-editor/FlowEditor.tsx b/frontend/components/dataflow/node-editor/FlowEditor.tsx index 81282c0b..3686554f 100644 --- a/frontend/components/dataflow/node-editor/FlowEditor.tsx +++ b/frontend/components/dataflow/node-editor/FlowEditor.tsx @@ -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, diff --git a/frontend/components/dataflow/node-editor/nodes/FormulaTransformNode.tsx b/frontend/components/dataflow/node-editor/nodes/FormulaTransformNode.tsx new file mode 100644 index 00000000..981a5002 --- /dev/null +++ b/frontend/components/dataflow/node-editor/nodes/FormulaTransformNode.tsx @@ -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 = { + 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 = { + "+": "+", + "-": "-", + "*": "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) => { + const transformationCount = data.transformations?.length || 0; + const hasTargetLookup = !!data.targetLookup?.tableName; + + return ( +
+ {/* 헤더 */} +
+ +
+
{data.displayName || "수식 변환"}
+
+ {transformationCount}개 변환 {hasTargetLookup && "| 타겟 조회"} +
+
+
+ + {/* 본문 */} +
+ {/* 타겟 테이블 조회 설정 */} + {hasTargetLookup && ( +
+
+ + 타겟 조회 +
+
{data.targetLookup?.tableLabel || data.targetLookup?.tableName}
+ {data.targetLookup?.lookupKeys && data.targetLookup.lookupKeys.length > 0 && ( +
+ {data.targetLookup.lookupKeys.slice(0, 2).map((key, idx) => ( + + {key.sourceFieldLabel || key.sourceField} + + {key.targetFieldLabel || key.targetField} + + ))} + {data.targetLookup.lookupKeys.length > 2 && ( + +{data.targetLookup.lookupKeys.length - 2} + )} +
+ )} +
+ )} + + {/* 변환 규칙들 */} + {transformationCount > 0 ? ( +
+ {data.transformations.slice(0, 4).map((trans, idx) => { + const typeInfo = FORMULA_TYPE_LABELS[trans.formulaType]; + return ( +
+
+ + {typeInfo.label} + + + {trans.outputFieldLabel || trans.outputField} + +
+
{getFormulaSummary(trans)}
+
+ ); + })} + {data.transformations.length > 4 && ( +
... 외 {data.transformations.length - 4}개
+ )} +
+ ) : ( +
변환 규칙 없음
+ )} +
+ + {/* 핸들 */} + + +
+ ); +}); + +FormulaTransformNode.displayName = "FormulaTransformNode"; diff --git a/frontend/components/dataflow/node-editor/panels/PropertiesPanel.tsx b/frontend/components/dataflow/node-editor/panels/PropertiesPanel.tsx index 41f1a9b4..e62bab9f 100644 --- a/frontend/components/dataflow/node-editor/panels/PropertiesPanel.tsx +++ b/frontend/components/dataflow/node-editor/panels/PropertiesPanel.tsx @@ -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 ( -
{/* 헤더 */} -
@@ -60,12 +61,12 @@ export function PropertiesPanel() {
{/* 내용 - 스크롤 가능 영역 */} -
{selectedNodes.length === 0 ? ( @@ -125,6 +126,9 @@ function NodePropertiesRenderer({ node }: { node: any }) { case "aggregate": return ; + case "formulaTransform": + return ; + case "restAPISource": return ; @@ -173,6 +177,7 @@ function getNodeTypeLabel(type: NodeType): string { fieldMapping: "필드 매핑", dataTransform: "데이터 변환", aggregate: "집계", + formulaTransform: "수식 변환", insertAction: "INSERT 액션", updateAction: "UPDATE 액션", deleteAction: "DELETE 액션", diff --git a/frontend/components/dataflow/node-editor/panels/properties/FormulaTransformProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/FormulaTransformProperties.tsx new file mode 100644 index 00000000..fc2fbdf8 --- /dev/null +++ b/frontend/components/dataflow/node-editor/panels/properties/FormulaTransformProperties.tsx @@ -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([]); + const [tablesLoading, setTablesLoading] = useState(false); + const [tablesOpen, setTablesOpen] = useState(false); + const [targetColumns, setTargetColumns] = useState([]); + const [targetColumnsLoading, setTargetColumnsLoading] = useState(false); + + // 소스 필드 목록 (연결된 입력 노드에서 가져오기) + const [sourceFields, setSourceFields] = useState>([]); + + // 데이터 변경 시 로컬 상태 업데이트 + 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(), + ): 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) => { + 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, + ) => { + 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 ( +
+ + + {operand.type === "source" && ( + + )} + + {operand.type === "target" && ( + + )} + + {operand.type === "static" && ( + onChange({ ...operand, value: e.target.value })} + placeholder="값 입력" + className="h-8 text-xs" + /> + )} + + {operand.type === "result" && ( + + )} +
+ ); + }; + + return ( +
+
+ {/* 헤더 */} +
+ + 수식 변환 노드 +
+ + {/* 기본 정보 */} +
+

기본 정보

+
+ + { + setDisplayName(e.target.value); + saveToNode({ displayName: e.target.value }); + }} + className="mt-1" + placeholder="노드 표시 이름" + /> +
+
+ + {/* 타겟 테이블 조회 설정 */} +
+
+ +

타겟 테이블 조회 (선택)

+
+

+ UPSERT 시 기존 값을 참조하려면 타겟 테이블을 설정하세요. 수식에서 target.* 로 참조 가능합니다. +

+ + {/* 타겟 테이블 선택 */} + + + + + + + + 테이블을 찾을 수 없습니다. + + + {tables.map((table) => ( + handleTargetTableSelect(table.tableName)} + > + +
+ {table.label} + {table.tableName} +
+
+ ))} +
+
+
+
+
+ + {/* 조회 키 설정 */} + {targetLookup?.tableName && ( +
+
+ + +
+ + {(targetLookup.lookupKeys || []).length === 0 ? ( +
+ 조회 키를 추가하세요 (예: item_code, lot_number) +
+ ) : ( +
+ {targetLookup.lookupKeys.map((key, idx) => ( +
+ + + + + + + +
+ ))} +
+ )} +
+ )} +
+ + {/* 변환 규칙 */} +
+
+
+ +

변환 규칙

+
+ +
+ + {transformations.length === 0 ? ( +
+ 변환 규칙을 추가하세요 +
+ ) : ( +
+ {transformations.map((trans, index) => ( +
+
+ 변환 #{index + 1} + +
+ +
+ {/* 출력 필드명 */} +
+ + handleTransformationChange(index, { outputField: e.target.value })} + placeholder="예: new_current_qty" + className="mt-1 h-8 text-xs" + /> +
+ + {/* 출력 필드 라벨 */} +
+ + handleTransformationChange(index, { outputFieldLabel: e.target.value })} + placeholder="예: 새 현재고량" + className="mt-1 h-8 text-xs" + /> +
+ + {/* 수식 타입 선택 */} +
+ + +
+ + {/* 수식 타입별 설정 */} + {trans.formulaType === "arithmetic" && trans.arithmetic && ( +
+ + + {/* 좌측 피연산자 */} +
+
좌측
+ {renderOperandSelector( + trans.arithmetic.leftOperand, + (updates) => { + const newArithmetic = { ...trans.arithmetic!, leftOperand: updates }; + handleTransformationChange(index, { arithmetic: newArithmetic }); + }, + index, + )} +
+ + {/* 연산자 */} + + + {/* 우측 피연산자 */} +
+
우측
+ {renderOperandSelector( + trans.arithmetic.rightOperand, + (updates) => { + const newArithmetic = { ...trans.arithmetic!, rightOperand: updates }; + handleTransformationChange(index, { arithmetic: newArithmetic }); + }, + index, + )} +
+
+ )} + + {trans.formulaType === "function" && trans.function && ( +
+ + + + + {/* 함수 인자들 */} + {trans.function.arguments.length > 0 && ( +
+ {trans.function.arguments.map((arg, argIdx) => ( +
+
인자 {argIdx + 1}
+ {renderOperandSelector( + arg, + (updates) => { + const newArgs = [...trans.function!.arguments]; + newArgs[argIdx] = updates; + handleTransformationChange(index, { + function: { ...trans.function!, arguments: newArgs }, + }); + }, + index, + )} +
+ ))} +
+ )} +
+ )} + + {trans.formulaType === "condition" && trans.condition && ( +
+ + + {/* WHEN 절 */} +
+
WHEN
+
+ {renderOperandSelector( + trans.condition.when.leftOperand, + (updates) => { + const newCondition = { + ...trans.condition!, + when: { ...trans.condition!.when, leftOperand: updates }, + }; + handleTransformationChange(index, { condition: newCondition }); + }, + index, + )} + + + + {!["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, + )} +
+
+ + {/* THEN 절 */} +
+
THEN
+ {renderOperandSelector( + trans.condition.then, + (updates) => { + const newCondition = { ...trans.condition!, then: updates }; + handleTransformationChange(index, { condition: newCondition }); + }, + index, + )} +
+ + {/* ELSE 절 */} +
+
ELSE
+ {renderOperandSelector( + trans.condition.else, + (updates) => { + const newCondition = { ...trans.condition!, else: updates }; + handleTransformationChange(index, { condition: newCondition }); + }, + index, + )} +
+
+ )} + + {trans.formulaType === "static" && ( +
+ + handleTransformationChange(index, { staticValue: e.target.value })} + placeholder="고정 값 입력" + className="h-8 text-xs" + /> +

문자열, 숫자, true/false 입력 가능

+
+ )} +
+
+ ))} +
+ )} +
+
+
+ ); +} diff --git a/frontend/components/dataflow/node-editor/panels/properties/InsertActionProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/InsertActionProperties.tsx index 465a88fd..437487e9 100644 --- a/frontend/components/dataflow/node-editor/panels/properties/InsertActionProperties.tsx +++ b/frontend/components/dataflow/node-editor/panels/properties/InsertActionProperties.tsx @@ -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, }); } diff --git a/frontend/components/dataflow/node-editor/panels/properties/UpdateActionProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/UpdateActionProperties.tsx index 6d109d5b..1fd6b723 100644 --- a/frontend/components/dataflow/node-editor/panels/properties/UpdateActionProperties.tsx +++ b/frontend/components/dataflow/node-editor/panels/properties/UpdateActionProperties.tsx @@ -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; diff --git a/frontend/components/dataflow/node-editor/panels/properties/UpsertActionProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/UpsertActionProperties.tsx index 57d5d4f2..283640d1 100644 --- a/frontend/components/dataflow/node-editor/panels/properties/UpsertActionProperties.tsx +++ b/frontend/components/dataflow/node-editor/panels/properties/UpsertActionProperties.tsx @@ -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})`, }); } }); diff --git a/frontend/components/dataflow/node-editor/sidebar/nodePaletteConfig.ts b/frontend/components/dataflow/node-editor/sidebar/nodePaletteConfig.ts index 216028eb..334d150e 100644 --- a/frontend/components/dataflow/node-editor/sidebar/nodePaletteConfig.ts +++ b/frontend/components/dataflow/node-editor/sidebar/nodePaletteConfig.ts @@ -60,6 +60,14 @@ export const NODE_PALETTE: NodePaletteItem[] = [ category: "transform", color: "#A855F7", // 보라색 }, + { + type: "formulaTransform", + label: "수식 변환", + icon: "", + description: "산술 연산, 함수, 조건문으로 새 필드를 계산합니다", + category: "transform", + color: "#F97316", // 오렌지색 + }, // ======================================================================== // 액션 diff --git a/frontend/types/node-editor.ts b/frontend/types/node-editor.ts index 4c0b502b..55c8f67e 100644 --- a/frontend/types/node-editor.ts +++ b/frontend/types/node-editor.ts @@ -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; - + // 입력 데이터 전달 방식 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; - + // 쿼리 파라미터 queryParams?: Record; - + // 요청 본문 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