diff --git a/backend-node/src/services/nodeFlowExecutionService.ts b/backend-node/src/services/nodeFlowExecutionService.ts index 9cdd85f3..e70a1dae 100644 --- a/backend-node/src/services/nodeFlowExecutionService.ts +++ b/backend-node/src/services/nodeFlowExecutionService.ts @@ -528,6 +528,9 @@ export class NodeFlowExecutionService { case "dataTransform": return this.executeDataTransform(node, inputData, context); + case "aggregate": + return this.executeAggregate(node, inputData, context); + case "insertAction": return this.executeInsertAction(node, inputData, context, client); @@ -3197,4 +3200,161 @@ export class NodeFlowExecutionService { "upsertAction", ].includes(nodeType); } + + /** + * 집계 노드 실행 (SUM, COUNT, AVG, MIN, MAX 등) + */ + private static async executeAggregate( + node: FlowNode, + inputData: any, + context: ExecutionContext + ): Promise { + const { groupByFields = [], aggregations = [], havingConditions = [] } = node.data; + + logger.info(`📊 집계 노드 실행: ${node.data.displayName || node.id}`); + + // 입력 데이터가 없으면 빈 배열 반환 + if (!inputData || !Array.isArray(inputData) || inputData.length === 0) { + logger.warn("⚠️ 집계할 입력 데이터가 없습니다."); + return []; + } + + logger.info(`📥 입력 데이터: ${inputData.length}건`); + logger.info(`📊 그룹 기준: ${groupByFields.length > 0 ? groupByFields.map((f: any) => f.field).join(", ") : "전체"}`); + logger.info(`📊 집계 연산: ${aggregations.length}개`); + + // 그룹화 수행 + const groups = new Map(); + + for (const row of inputData) { + // 그룹 키 생성 + const groupKey = groupByFields.length > 0 + ? groupByFields.map((f: any) => String(row[f.field] ?? "")).join("|||") + : "__ALL__"; + + if (!groups.has(groupKey)) { + groups.set(groupKey, []); + } + groups.get(groupKey)!.push(row); + } + + logger.info(`📊 그룹 수: ${groups.size}개`); + + // 각 그룹에 대해 집계 수행 + const results: any[] = []; + + for (const [groupKey, groupRows] of groups) { + const resultRow: any = {}; + + // 그룹 기준 필드값 추가 + if (groupByFields.length > 0) { + const keyValues = groupKey.split("|||"); + groupByFields.forEach((field: any, idx: number) => { + resultRow[field.field] = keyValues[idx]; + }); + } + + // 각 집계 연산 수행 + for (const agg of aggregations) { + const { sourceField, function: aggFunc, outputField } = agg; + + if (!outputField) continue; + + let aggregatedValue: any; + + switch (aggFunc) { + case "SUM": + aggregatedValue = groupRows.reduce((sum: number, row: any) => { + const val = parseFloat(row[sourceField]); + return sum + (isNaN(val) ? 0 : val); + }, 0); + break; + + case "COUNT": + aggregatedValue = groupRows.length; + break; + + case "AVG": + const sum = groupRows.reduce((acc: number, row: any) => { + const val = parseFloat(row[sourceField]); + return acc + (isNaN(val) ? 0 : val); + }, 0); + aggregatedValue = groupRows.length > 0 ? sum / groupRows.length : 0; + 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); + 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); + break; + + case "FIRST": + aggregatedValue = groupRows.length > 0 ? groupRows[0][sourceField] : null; + break; + + case "LAST": + aggregatedValue = groupRows.length > 0 ? groupRows[groupRows.length - 1][sourceField] : null; + break; + + default: + logger.warn(`⚠️ 지원하지 않는 집계 함수: ${aggFunc}`); + aggregatedValue = null; + } + + resultRow[outputField] = aggregatedValue; + logger.info(` ${aggFunc}(${sourceField}) → ${outputField}: ${aggregatedValue}`); + } + + results.push(resultRow); + } + + // HAVING 조건 적용 (집계 후 필터링) + let filteredResults = results; + if (havingConditions && havingConditions.length > 0) { + filteredResults = results.filter((row) => { + return havingConditions.every((condition: any) => { + const fieldValue = row[condition.field]; + const compareValue = parseFloat(condition.value); + + switch (condition.operator) { + case "=": + return fieldValue === compareValue; + case "!=": + return fieldValue !== compareValue; + case ">": + return fieldValue > compareValue; + case ">=": + return fieldValue >= compareValue; + case "<": + return fieldValue < compareValue; + case "<=": + return fieldValue <= compareValue; + default: + return true; + } + }); + }); + + logger.info(`📊 HAVING 필터링: ${results.length}건 → ${filteredResults.length}건`); + } + + logger.info(`✅ 집계 완료: ${filteredResults.length}건 결과`); + + // 결과 샘플 출력 + if (filteredResults.length > 0) { + logger.info(`📄 결과 샘플:`, JSON.stringify(filteredResults[0], null, 2)); + } + + return filteredResults; + } } diff --git a/frontend/components/dataflow/node-editor/FlowEditor.tsx b/frontend/components/dataflow/node-editor/FlowEditor.tsx index c87c80aa..f74d35aa 100644 --- a/frontend/components/dataflow/node-editor/FlowEditor.tsx +++ b/frontend/components/dataflow/node-editor/FlowEditor.tsx @@ -25,6 +25,7 @@ import { UpdateActionNode } from "./nodes/UpdateActionNode"; import { DeleteActionNode } from "./nodes/DeleteActionNode"; import { UpsertActionNode } from "./nodes/UpsertActionNode"; import { DataTransformNode } from "./nodes/DataTransformNode"; +import { AggregateNode } from "./nodes/AggregateNode"; import { RestAPISourceNode } from "./nodes/RestAPISourceNode"; import { CommentNode } from "./nodes/CommentNode"; import { LogNode } from "./nodes/LogNode"; @@ -41,6 +42,7 @@ const nodeTypes = { // 변환/조건 condition: ConditionNode, dataTransform: DataTransformNode, + aggregate: AggregateNode, // 액션 insertAction: InsertActionNode, updateAction: UpdateActionNode, diff --git a/frontend/components/dataflow/node-editor/nodes/AggregateNode.tsx b/frontend/components/dataflow/node-editor/nodes/AggregateNode.tsx new file mode 100644 index 00000000..51ed5371 --- /dev/null +++ b/frontend/components/dataflow/node-editor/nodes/AggregateNode.tsx @@ -0,0 +1,107 @@ +"use client"; + +/** + * 집계 노드 (Aggregate Node) + * SUM, COUNT, AVG, MIN, MAX 등 집계 연산을 수행 + */ + +import { memo } from "react"; +import { Handle, Position, NodeProps } from "reactflow"; +import { Calculator, Layers } from "lucide-react"; +import type { AggregateNodeData, AggregateFunction } from "@/types/node-editor"; + +// 집계 함수별 아이콘/라벨 +const AGGREGATE_FUNCTION_LABELS: Record = { + SUM: "합계", + COUNT: "개수", + AVG: "평균", + MIN: "최소", + MAX: "최대", + FIRST: "첫번째", + LAST: "마지막", +}; + +export const AggregateNode = memo(({ data, selected }: NodeProps) => { + const groupByCount = data.groupByFields?.length || 0; + const aggregationCount = data.aggregations?.length || 0; + + return ( +
+ {/* 헤더 */} +
+ +
+
{data.displayName || "집계"}
+
+ {groupByCount > 0 ? `${groupByCount}개 그룹` : "전체"} / {aggregationCount}개 집계 +
+
+
+ + {/* 본문 */} +
+ {/* 그룹 기준 */} + {groupByCount > 0 && ( +
+
+ + 그룹 기준 +
+
+ {data.groupByFields.slice(0, 3).map((field, idx) => ( + + {field.fieldLabel || field.field} + + ))} + {data.groupByFields.length > 3 && ( + +{data.groupByFields.length - 3} + )} +
+
+ )} + + {/* 집계 연산 */} + {aggregationCount > 0 ? ( +
+ {data.aggregations.slice(0, 4).map((agg, idx) => ( +
+
+ + {AGGREGATE_FUNCTION_LABELS[agg.function] || agg.function} + + + {agg.outputFieldLabel || agg.outputField} + +
+
+ {agg.sourceFieldLabel || agg.sourceField} +
+
+ ))} + {data.aggregations.length > 4 && ( +
+ ... 외 {data.aggregations.length - 4}개 +
+ )} +
+ ) : ( +
집계 연산 없음
+ )} +
+ + {/* 핸들 */} + + +
+ ); +}); + +AggregateNode.displayName = "AggregateNode"; + diff --git a/frontend/components/dataflow/node-editor/panels/PropertiesPanel.tsx b/frontend/components/dataflow/node-editor/panels/PropertiesPanel.tsx index ada62e8d..cf7c7e6e 100644 --- a/frontend/components/dataflow/node-editor/panels/PropertiesPanel.tsx +++ b/frontend/components/dataflow/node-editor/panels/PropertiesPanel.tsx @@ -16,6 +16,7 @@ import { DeleteActionProperties } from "./properties/DeleteActionProperties"; import { ExternalDBSourceProperties } from "./properties/ExternalDBSourceProperties"; import { UpsertActionProperties } from "./properties/UpsertActionProperties"; import { DataTransformProperties } from "./properties/DataTransformProperties"; +import { AggregateProperties } from "./properties/AggregateProperties"; import { RestAPISourceProperties } from "./properties/RestAPISourceProperties"; import { CommentProperties } from "./properties/CommentProperties"; import { LogProperties } from "./properties/LogProperties"; @@ -122,6 +123,9 @@ function NodePropertiesRenderer({ node }: { node: any }) { case "dataTransform": return ; + case "aggregate": + return ; + case "restAPISource": return ; @@ -157,9 +161,11 @@ function getNodeTypeLabel(type: NodeType): string { tableSource: "테이블 소스", externalDBSource: "외부 DB 소스", restAPISource: "REST API 소스", + referenceLookup: "참조 조회", condition: "조건 분기", fieldMapping: "필드 매핑", dataTransform: "데이터 변환", + aggregate: "집계", insertAction: "INSERT 액션", updateAction: "UPDATE 액션", deleteAction: "DELETE 액션", diff --git a/frontend/components/dataflow/node-editor/panels/properties/AggregateProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/AggregateProperties.tsx new file mode 100644 index 00000000..6d3d7311 --- /dev/null +++ b/frontend/components/dataflow/node-editor/panels/properties/AggregateProperties.tsx @@ -0,0 +1,526 @@ +"use client"; + +/** + * 집계 노드 속성 편집 패널 + * SUM, COUNT, AVG, MIN, MAX 등 집계 연산 설정 + */ + +import { useEffect, useState, useCallback } from "react"; +import { Plus, Trash2, Calculator, Layers, Filter } 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 { Checkbox } from "@/components/ui/checkbox"; +import { useFlowEditorStore } from "@/lib/stores/flowEditorStore"; +import type { AggregateNodeData, AggregateFunction } from "@/types/node-editor"; + +interface AggregatePropertiesProps { + nodeId: string; + data: AggregateNodeData; +} + +// 집계 함수 옵션 +const AGGREGATE_FUNCTIONS: Array<{ value: AggregateFunction; label: string; description: string }> = [ + { value: "SUM", label: "합계 (SUM)", description: "숫자 필드의 합계를 계산합니다" }, + { value: "COUNT", label: "개수 (COUNT)", description: "레코드 개수를 계산합니다" }, + { value: "AVG", label: "평균 (AVG)", description: "숫자 필드의 평균을 계산합니다" }, + { value: "MIN", label: "최소 (MIN)", description: "최소값을 찾습니다" }, + { value: "MAX", label: "최대 (MAX)", description: "최대값을 찾습니다" }, + { value: "FIRST", label: "첫번째 (FIRST)", description: "그룹의 첫 번째 값을 가져옵니다" }, + { value: "LAST", label: "마지막 (LAST)", description: "그룹의 마지막 값을 가져옵니다" }, +]; + +// 비교 연산자 옵션 +const OPERATORS = [ + { value: "=", label: "같음 (=)" }, + { value: "!=", label: "다름 (!=)" }, + { value: ">", label: "보다 큼 (>)" }, + { value: ">=", label: "크거나 같음 (>=)" }, + { value: "<", label: "보다 작음 (<)" }, + { value: "<=", label: "작거나 같음 (<=)" }, +]; + +export function AggregateProperties({ nodeId, data }: AggregatePropertiesProps) { + const { updateNode, nodes, edges } = useFlowEditorStore(); + + // 로컬 상태 + const [displayName, setDisplayName] = useState(data.displayName || "집계"); + const [groupByFields, setGroupByFields] = useState(data.groupByFields || []); + const [aggregations, setAggregations] = useState(data.aggregations || []); + const [havingConditions, setHavingConditions] = useState(data.havingConditions || []); + + // 소스 필드 목록 (연결된 입력 노드에서 가져오기) + const [sourceFields, setSourceFields] = useState>([]); + + // 데이터 변경 시 로컬 상태 업데이트 + useEffect(() => { + setDisplayName(data.displayName || "집계"); + setGroupByFields(data.groupByFields || []); + setAggregations(data.aggregations || []); + setHavingConditions(data.havingConditions || []); + }, [data]); + + // 연결된 소스 노드에서 필드 가져오기 + useEffect(() => { + const inputEdges = edges.filter((edge) => edge.target === nodeId); + const sourceNodeIds = inputEdges.map((edge) => edge.source); + const sourceNodes = nodes.filter((node) => sourceNodeIds.includes(node.id)); + + const fields: Array<{ name: string; label?: string; type?: string }> = []; + sourceNodes.forEach((node) => { + if (node.data.fields) { + node.data.fields.forEach((field: any) => { + fields.push({ + name: field.name, + label: field.label || field.displayName, + type: field.type, + }); + }); + } + }); + + setSourceFields(fields); + }, [nodeId, nodes, edges]); + + // 저장 함수 + const saveToNode = useCallback( + (updates: Partial) => { + updateNode(nodeId, { + displayName, + groupByFields, + aggregations, + havingConditions, + ...updates, + }); + }, + [nodeId, updateNode, displayName, groupByFields, aggregations, havingConditions] + ); + + // 그룹 기준 필드 토글 + const handleGroupByToggle = (fieldName: string, checked: boolean) => { + let newGroupByFields; + if (checked) { + const field = sourceFields.find((f) => f.name === fieldName); + newGroupByFields = [...groupByFields, { field: fieldName, fieldLabel: field?.label }]; + } else { + newGroupByFields = groupByFields.filter((f) => f.field !== fieldName); + } + setGroupByFields(newGroupByFields); + saveToNode({ groupByFields: newGroupByFields }); + }; + + // 집계 연산 추가 + const handleAddAggregation = () => { + const newAggregation = { + id: `agg_${Date.now()}`, + sourceField: "", + sourceFieldLabel: "", + function: "SUM" as AggregateFunction, + outputField: "", + outputFieldLabel: "", + }; + const newAggregations = [...aggregations, newAggregation]; + setAggregations(newAggregations); + saveToNode({ aggregations: newAggregations }); + }; + + // 집계 연산 삭제 + const handleRemoveAggregation = (index: number) => { + const newAggregations = aggregations.filter((_, i) => i !== index); + setAggregations(newAggregations); + saveToNode({ aggregations: newAggregations }); + }; + + // 집계 연산 변경 + const handleAggregationChange = (index: number, field: string, value: any) => { + const newAggregations = [...aggregations]; + + if (field === "sourceField") { + const sourceField = sourceFields.find((f) => f.name === value); + newAggregations[index] = { + ...newAggregations[index], + sourceField: value, + sourceFieldLabel: sourceField?.label, + // 출력 필드명 자동 생성 (예: sum_amount) + outputField: + newAggregations[index].outputField || + `${newAggregations[index].function.toLowerCase()}_${value}`, + }; + } else if (field === "function") { + newAggregations[index] = { + ...newAggregations[index], + function: value, + // 출력 필드명 업데이트 + outputField: newAggregations[index].sourceField + ? `${value.toLowerCase()}_${newAggregations[index].sourceField}` + : newAggregations[index].outputField, + }; + } else { + newAggregations[index] = { ...newAggregations[index], [field]: value }; + } + + setAggregations(newAggregations); + saveToNode({ aggregations: newAggregations }); + }; + + // HAVING 조건 추가 + const handleAddHavingCondition = () => { + const newCondition = { + field: "", + operator: "=", + value: "", + }; + const newConditions = [...havingConditions, newCondition]; + setHavingConditions(newConditions); + saveToNode({ havingConditions: newConditions }); + }; + + // HAVING 조건 삭제 + const handleRemoveHavingCondition = (index: number) => { + const newConditions = havingConditions.filter((_, i) => i !== index); + setHavingConditions(newConditions); + saveToNode({ havingConditions: newConditions }); + }; + + // HAVING 조건 변경 + const handleHavingConditionChange = (index: number, field: string, value: any) => { + const newConditions = [...havingConditions]; + newConditions[index] = { ...newConditions[index], [field]: value }; + setHavingConditions(newConditions); + saveToNode({ havingConditions: newConditions }); + }; + + // 집계 결과 필드 목록 (HAVING 조건에서 선택용) + const aggregatedFields = aggregations + .filter((agg) => agg.outputField) + .map((agg) => ({ + name: agg.outputField, + label: agg.outputFieldLabel || agg.outputField, + })); + + return ( +
+
+ {/* 헤더 */} +
+ + 집계 노드 +
+ + {/* 기본 정보 */} +
+

기본 정보

+
+ + { + setDisplayName(e.target.value); + saveToNode({ displayName: e.target.value }); + }} + className="mt-1" + placeholder="노드 표시 이름" + /> +
+
+ + {/* 그룹 기준 필드 */} +
+
+ +

그룹 기준 필드

+
+

+ 선택한 필드를 기준으로 데이터를 그룹화합니다. 선택하지 않으면 전체 데이터를 하나의 그룹으로 처리합니다. +

+ + {sourceFields.length === 0 ? ( +
+ 연결된 소스 노드가 없습니다 +
+ ) : ( +
+
+ {sourceFields.map((field) => { + const isChecked = groupByFields.some((f) => f.field === field.name); + return ( +
+ handleGroupByToggle(field.name, checked as boolean)} + /> + +
+ ); + })} +
+
+ )} + + {groupByFields.length > 0 && ( +
+ {groupByFields.map((field) => ( + + {field.fieldLabel || field.field} + + ))} +
+ )} +
+ + {/* 집계 연산 */} +
+
+
+ +

집계 연산

+
+ +
+

SUM, COUNT, AVG 등 집계 연산을 설정합니다.

+ + {aggregations.length === 0 ? ( +
+ 집계 연산을 추가하세요 +
+ ) : ( +
+ {aggregations.map((agg, index) => ( +
+
+ 집계 #{index + 1} + +
+ +
+ {/* 집계 함수 선택 */} +
+ + +
+ + {/* 소스 필드 선택 */} +
+ + +
+ + {/* 출력 필드명 */} +
+ + handleAggregationChange(index, "outputField", e.target.value)} + placeholder="예: total_amount" + className="mt-1 h-8 text-xs" + /> +

+ 집계 결과가 저장될 필드명입니다 +

+
+ + {/* 출력 필드 라벨 */} +
+ + handleAggregationChange(index, "outputFieldLabel", e.target.value)} + placeholder="예: 총 금액" + className="mt-1 h-8 text-xs" + /> +
+
+
+ ))} +
+ )} +
+ + {/* HAVING 조건 (선택) */} +
+
+
+ +

집계 후 필터 (HAVING)

+
+ +
+

집계 결과에 대한 필터링 조건을 설정합니다 (선택 사항).

+ + {havingConditions.length === 0 ? ( +
+ 집계 후 필터링이 필요하면 조건을 추가하세요 +
+ ) : ( +
+ {havingConditions.map((condition, index) => ( +
+ {/* 집계 결과 필드 선택 */} + + + {/* 연산자 선택 */} + + + {/* 비교값 */} + handleHavingConditionChange(index, "value", e.target.value)} + placeholder="값" + className="h-8 flex-1 text-xs" + /> + + {/* 삭제 버튼 */} + +
+ ))} +
+ )} +
+ + {/* 미리보기 */} + {(groupByFields.length > 0 || aggregations.length > 0) && ( +
+

집계 결과 미리보기

+
+
+ 그룹 기준:{" "} + {groupByFields.length > 0 + ? groupByFields.map((f) => f.fieldLabel || f.field).join(", ") + : "전체 (그룹 없음)"} +
+
+ 집계 컬럼:{" "} + {aggregations.length > 0 + ? aggregations + .filter((a) => a.outputField) + .map((a) => `${a.function}(${a.sourceFieldLabel || a.sourceField}) → ${a.outputFieldLabel || a.outputField}`) + .join(", ") + : "없음"} +
+
+
+ )} +
+
+ ); +} + diff --git a/frontend/components/dataflow/node-editor/panels/properties/InsertActionProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/InsertActionProperties.tsx index 5f3b3220..465a88fd 100644 --- a/frontend/components/dataflow/node-editor/panels/properties/InsertActionProperties.tsx +++ b/frontend/components/dataflow/node-editor/panels/properties/InsertActionProperties.tsx @@ -236,7 +236,48 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP console.log("⚠️ REST API 노드에 responseFields 없음"); } } - // 3️⃣ 테이블/외부DB 소스 노드 + // 3️⃣ 집계(Aggregate) 노드: 그룹 필드 + 집계 결과 필드 + else if (node.type === "aggregate") { + console.log("✅ 집계 노드 발견"); + const nodeData = node.data as any; + + // 그룹 기준 필드 추가 (field 또는 fieldName 둘 다 지원) + if (nodeData.groupByFields && Array.isArray(nodeData.groupByFields)) { + console.log(` 📊 ${nodeData.groupByFields.length}개 그룹 필드 발견`); + nodeData.groupByFields.forEach((groupField: any) => { + const fieldName = groupField.field || groupField.fieldName; + if (fieldName) { + fields.push({ + name: fieldName, + label: groupField.fieldLabel || fieldName, + sourcePath: currentPath, + }); + } + }); + } + + // 집계 결과 필드 추가 (aggregations 또는 aggregateFunctions 둘 다 지원) + const aggregations = nodeData.aggregations || nodeData.aggregateFunctions || []; + if (Array.isArray(aggregations)) { + console.log(` 📊 ${aggregations.length}개 집계 함수 발견`); + aggregations.forEach((aggFunc: any) => { + // outputField 또는 targetField 둘 다 지원 + const outputFieldName = aggFunc.outputField || aggFunc.targetField; + // function 또는 aggregateType 둘 다 지원 + const funcType = aggFunc.function || aggFunc.aggregateType; + if (outputFieldName) { + fields.push({ + name: outputFieldName, + label: aggFunc.outputFieldLabel || aggFunc.targetFieldLabel || `${funcType}(${aggFunc.sourceFieldLabel || aggFunc.sourceField})`, + sourcePath: currentPath, + }); + } + }); + } + + // 집계 노드는 상위 노드의 필드를 그대로 통과시키지 않음 (집계된 결과만 전달) + } + // 4️⃣ 테이블/외부DB 소스 노드 else if (node.type === "tableSource" || node.type === "externalDBSource") { const nodeFields = (node.data as any).fields || (node.data as any).outputFields; const displayName = (node.data as any).displayName || (node.data as any).tableName || node.id; @@ -266,7 +307,7 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP foundRestAPI = foundRestAPI || upperResult.hasRestAPI; } } - // 4️⃣ 통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색 + // 5️⃣ 통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색 else { console.log(`✅ 통과 노드 (${node.type}) → 상위 노드로 계속 탐색`); const upperResult = getAllSourceFields(node.id, visitedNodes, currentPath); diff --git a/frontend/components/dataflow/node-editor/panels/properties/UpdateActionProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/UpdateActionProperties.tsx index 7d6d2e5a..6d109d5b 100644 --- a/frontend/components/dataflow/node-editor/panels/properties/UpdateActionProperties.tsx +++ b/frontend/components/dataflow/node-editor/panels/properties/UpdateActionProperties.tsx @@ -212,7 +212,43 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP fields.push(...upperFields); } } - // 2️⃣ REST API 소스 노드 + // 2️⃣ 집계(Aggregate) 노드: 그룹 필드 + 집계 결과 필드 + else if (node.type === "aggregate") { + const nodeData = node.data as any; + + // 그룹 기준 필드 추가 (field 또는 fieldName 둘 다 지원) + if (nodeData.groupByFields && Array.isArray(nodeData.groupByFields)) { + nodeData.groupByFields.forEach((groupField: any) => { + const fieldName = groupField.field || groupField.fieldName; + if (fieldName) { + fields.push({ + name: fieldName, + label: groupField.fieldLabel || fieldName, + }); + } + }); + } + + // 집계 결과 필드 추가 (aggregations 또는 aggregateFunctions 둘 다 지원) + const aggregations = nodeData.aggregations || nodeData.aggregateFunctions || []; + if (Array.isArray(aggregations)) { + aggregations.forEach((aggFunc: any) => { + // outputField 또는 targetField 둘 다 지원 + const outputFieldName = aggFunc.outputField || aggFunc.targetField; + // function 또는 aggregateType 둘 다 지원 + const funcType = aggFunc.function || aggFunc.aggregateType; + if (outputFieldName) { + fields.push({ + name: outputFieldName, + label: aggFunc.outputFieldLabel || aggFunc.targetFieldLabel || `${funcType}(${aggFunc.sourceFieldLabel || aggFunc.sourceField})`, + }); + } + }); + } + + // 집계 노드는 상위 노드의 필드를 그대로 통과시키지 않음 (집계된 결과만 전달) + } + // 3️⃣ REST API 소스 노드 else if (node.type === "restAPISource") { foundRestAPI = true; const responseFields = (node.data as any).responseFields; @@ -229,7 +265,7 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP }); } } - // 3️⃣ 테이블/외부DB 소스 노드 + // 4️⃣ 테이블/외부DB 소스 노드 else if (node.type === "tableSource" || node.type === "externalDBSource") { const nodeFields = (node.data as any).fields || (node.data as any).outputFields; @@ -251,7 +287,7 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP foundRestAPI = foundRestAPI || upperResult.hasRestAPI; } } - // 4️⃣ 통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색 + // 5️⃣ 통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색 else { const upperResult = getAllSourceFields(node.id, visitedNodes); fields.push(...upperResult.fields); diff --git a/frontend/components/dataflow/node-editor/panels/properties/UpsertActionProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/UpsertActionProperties.tsx index 50a53603..57d5d4f2 100644 --- a/frontend/components/dataflow/node-editor/panels/properties/UpsertActionProperties.tsx +++ b/frontend/components/dataflow/node-editor/panels/properties/UpsertActionProperties.tsx @@ -212,7 +212,43 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP }); } } - // 3️⃣ 테이블/외부DB 소스 노드 + // 3️⃣ 집계(Aggregate) 노드: 그룹 필드 + 집계 결과 필드 + else if (node.type === "aggregate") { + const nodeData = node.data as any; + + // 그룹 기준 필드 추가 (field 또는 fieldName 둘 다 지원) + if (nodeData.groupByFields && Array.isArray(nodeData.groupByFields)) { + nodeData.groupByFields.forEach((groupField: any) => { + const fieldName = groupField.field || groupField.fieldName; + if (fieldName) { + fields.push({ + name: fieldName, + label: groupField.fieldLabel || fieldName, + }); + } + }); + } + + // 집계 결과 필드 추가 (aggregations 또는 aggregateFunctions 둘 다 지원) + const aggregations = nodeData.aggregations || nodeData.aggregateFunctions || []; + if (Array.isArray(aggregations)) { + aggregations.forEach((aggFunc: any) => { + // outputField 또는 targetField 둘 다 지원 + const outputFieldName = aggFunc.outputField || aggFunc.targetField; + // function 또는 aggregateType 둘 다 지원 + const funcType = aggFunc.function || aggFunc.aggregateType; + if (outputFieldName) { + fields.push({ + name: outputFieldName, + label: aggFunc.outputFieldLabel || aggFunc.targetFieldLabel || `${funcType}(${aggFunc.sourceFieldLabel || aggFunc.sourceField})`, + }); + } + }); + } + + // 집계 노드는 상위 노드의 필드를 그대로 통과시키지 않음 (집계된 결과만 전달) + } + // 4️⃣ 테이블/외부DB 소스 노드 else if (node.type === "tableSource" || node.type === "externalDBSource") { const nodeFields = (node.data as any).fields || (node.data as any).outputFields; @@ -234,7 +270,7 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP foundRestAPI = foundRestAPI || upperResult.hasRestAPI; } } - // 4️⃣ 통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색 + // 5️⃣ 통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색 else { const upperResult = getAllSourceFields(node.id, visitedNodes); fields.push(...upperResult.fields); diff --git a/frontend/components/dataflow/node-editor/sidebar/nodePaletteConfig.ts b/frontend/components/dataflow/node-editor/sidebar/nodePaletteConfig.ts index 97a5b19e..2ff31689 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: "#06B6D4", // 청록색 }, + { + type: "aggregate", + label: "집계", + icon: "", + description: "SUM, COUNT, AVG 등 집계 연산을 수행합니다", + category: "transform", + color: "#A855F7", // 보라색 + }, // ======================================================================== // 액션 diff --git a/frontend/types/node-editor.ts b/frontend/types/node-editor.ts index 8959a691..fc5adb89 100644 --- a/frontend/types/node-editor.ts +++ b/frontend/types/node-editor.ts @@ -15,6 +15,7 @@ export type NodeType = | "referenceLookup" // 참조 테이블 조회 (내부 DB 전용) | "condition" // 조건 분기 | "dataTransform" // 데이터 변환 + | "aggregate" // 집계 노드 (SUM, COUNT, AVG 등) | "insertAction" // INSERT 액션 | "updateAction" // UPDATE 액션 | "deleteAction" // DELETE 액션 @@ -194,6 +195,34 @@ export interface DataTransformNodeData { displayName?: string; } +// 집계 함수 타입 +export type AggregateFunction = "SUM" | "COUNT" | "AVG" | "MIN" | "MAX" | "FIRST" | "LAST"; + +// 집계 노드 데이터 +export interface AggregateNodeData { + displayName?: string; + // 그룹 기준 컬럼들 + groupByFields: Array<{ + field: string; // 컬럼명 + fieldLabel?: string; // 라벨 + }>; + // 집계 연산들 + aggregations: Array<{ + id: string; // 고유 ID + sourceField: string; // 집계할 소스 필드 + sourceFieldLabel?: string; // 소스 필드 라벨 + function: AggregateFunction; // 집계 함수 + outputField: string; // 출력 필드명 + outputFieldLabel?: string; // 출력 필드 라벨 + }>; + // 집계 후 필터링 (HAVING 절) + havingConditions?: Array<{ + field: string; // 집계 결과 필드 + operator: string; // 비교 연산자 + value: any; // 비교값 + }>; +} + // INSERT 액션 노드 export interface InsertActionNodeData { displayName?: string; @@ -406,6 +435,7 @@ export type NodeData = | ConditionNodeData | FieldMappingNodeData | DataTransformNodeData + | AggregateNodeData | InsertActionNodeData | UpdateActionNodeData | DeleteActionNodeData