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