feat: Add procedure and function management in flow controller
- Introduced new endpoints in FlowController for listing procedures and retrieving procedure parameters, enhancing the flow management capabilities. - Updated FlowDataMoveService to support procedure calls during data movement, ensuring seamless integration with external and internal databases. - Enhanced NodeFlowExecutionService to execute procedure call actions, allowing for dynamic execution of stored procedures within flow nodes. - Updated frontend components to support procedure selection and parameter management, improving user experience in configuring flow steps. - Added necessary types and API functions for handling procedure-related data, ensuring type safety and clarity in implementation.
This commit is contained in:
parent
fd5c61b12a
commit
f697e1e897
|
|
@ -9,6 +9,7 @@ import { FlowStepService } from "../services/flowStepService";
|
||||||
import { FlowConnectionService } from "../services/flowConnectionService";
|
import { FlowConnectionService } from "../services/flowConnectionService";
|
||||||
import { FlowExecutionService } from "../services/flowExecutionService";
|
import { FlowExecutionService } from "../services/flowExecutionService";
|
||||||
import { FlowDataMoveService } from "../services/flowDataMoveService";
|
import { FlowDataMoveService } from "../services/flowDataMoveService";
|
||||||
|
import { FlowProcedureService } from "../services/flowProcedureService";
|
||||||
|
|
||||||
export class FlowController {
|
export class FlowController {
|
||||||
private flowDefinitionService: FlowDefinitionService;
|
private flowDefinitionService: FlowDefinitionService;
|
||||||
|
|
@ -16,6 +17,7 @@ export class FlowController {
|
||||||
private flowConnectionService: FlowConnectionService;
|
private flowConnectionService: FlowConnectionService;
|
||||||
private flowExecutionService: FlowExecutionService;
|
private flowExecutionService: FlowExecutionService;
|
||||||
private flowDataMoveService: FlowDataMoveService;
|
private flowDataMoveService: FlowDataMoveService;
|
||||||
|
private flowProcedureService: FlowProcedureService;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.flowDefinitionService = new FlowDefinitionService();
|
this.flowDefinitionService = new FlowDefinitionService();
|
||||||
|
|
@ -23,6 +25,7 @@ export class FlowController {
|
||||||
this.flowConnectionService = new FlowConnectionService();
|
this.flowConnectionService = new FlowConnectionService();
|
||||||
this.flowExecutionService = new FlowExecutionService();
|
this.flowExecutionService = new FlowExecutionService();
|
||||||
this.flowDataMoveService = new FlowDataMoveService();
|
this.flowDataMoveService = new FlowDataMoveService();
|
||||||
|
this.flowProcedureService = new FlowProcedureService();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 플로우 정의 ====================
|
// ==================== 플로우 정의 ====================
|
||||||
|
|
@ -936,4 +939,94 @@ export class FlowController {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ==================== 프로시저/함수 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프로시저/함수 목록 조회
|
||||||
|
*/
|
||||||
|
listProcedures = async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const dbSource = (req.query.dbSource as string) || "internal";
|
||||||
|
const connectionId = req.query.connectionId
|
||||||
|
? parseInt(req.query.connectionId as string)
|
||||||
|
: undefined;
|
||||||
|
const schema = req.query.schema as string | undefined;
|
||||||
|
|
||||||
|
if (dbSource !== "internal" && dbSource !== "external") {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "dbSource는 internal 또는 external이어야 합니다",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dbSource === "external" && !connectionId) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "외부 DB 조회 시 connectionId가 필요합니다",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const procedures = await this.flowProcedureService.listProcedures(
|
||||||
|
dbSource,
|
||||||
|
connectionId,
|
||||||
|
schema
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ success: true, data: procedures });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("프로시저 목록 조회 실패:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: error.message || "프로시저 목록 조회에 실패했습니다",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프로시저/함수 파라미터 조회
|
||||||
|
*/
|
||||||
|
getProcedureParameters = async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { name } = req.params;
|
||||||
|
const dbSource = (req.query.dbSource as string) || "internal";
|
||||||
|
const connectionId = req.query.connectionId
|
||||||
|
? parseInt(req.query.connectionId as string)
|
||||||
|
: undefined;
|
||||||
|
const schema = req.query.schema as string | undefined;
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "프로시저 이름이 필요합니다",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dbSource !== "internal" && dbSource !== "external") {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "dbSource는 internal 또는 external이어야 합니다",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parameters = await this.flowProcedureService.getProcedureParameters(
|
||||||
|
name,
|
||||||
|
dbSource as "internal" | "external",
|
||||||
|
connectionId,
|
||||||
|
schema
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ success: true, data: parameters });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("프로시저 파라미터 조회 실패:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: error.message || "프로시저 파라미터 조회에 실패했습니다",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,4 +50,8 @@ router.put("/:flowId/step/:stepId/data/:recordId", flowController.updateStepData
|
||||||
router.get("/audit/:flowId/:recordId", flowController.getAuditLogs);
|
router.get("/audit/:flowId/:recordId", flowController.getAuditLogs);
|
||||||
router.get("/audit/:flowId", flowController.getFlowAuditLogs);
|
router.get("/audit/:flowId", flowController.getFlowAuditLogs);
|
||||||
|
|
||||||
|
// ==================== 프로시저/함수 ====================
|
||||||
|
router.get("/procedures", flowController.listProcedures);
|
||||||
|
router.get("/procedures/:name/parameters", flowController.getProcedureParameters);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -26,16 +26,20 @@ import {
|
||||||
buildSelectQuery,
|
buildSelectQuery,
|
||||||
} from "./dbQueryBuilder";
|
} from "./dbQueryBuilder";
|
||||||
import { FlowConditionParser } from "./flowConditionParser";
|
import { FlowConditionParser } from "./flowConditionParser";
|
||||||
|
import { FlowProcedureService } from "./flowProcedureService";
|
||||||
|
import { FlowProcedureConfig } from "../types/flow";
|
||||||
|
|
||||||
export class FlowDataMoveService {
|
export class FlowDataMoveService {
|
||||||
private flowDefinitionService: FlowDefinitionService;
|
private flowDefinitionService: FlowDefinitionService;
|
||||||
private flowStepService: FlowStepService;
|
private flowStepService: FlowStepService;
|
||||||
private externalDbIntegrationService: FlowExternalDbIntegrationService;
|
private externalDbIntegrationService: FlowExternalDbIntegrationService;
|
||||||
|
private flowProcedureService: FlowProcedureService;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.flowDefinitionService = new FlowDefinitionService();
|
this.flowDefinitionService = new FlowDefinitionService();
|
||||||
this.flowStepService = new FlowStepService();
|
this.flowStepService = new FlowStepService();
|
||||||
this.externalDbIntegrationService = new FlowExternalDbIntegrationService();
|
this.externalDbIntegrationService = new FlowExternalDbIntegrationService();
|
||||||
|
this.flowProcedureService = new FlowProcedureService();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -90,6 +94,64 @@ export class FlowDataMoveService {
|
||||||
let sourceTable = fromStep.tableName;
|
let sourceTable = fromStep.tableName;
|
||||||
let targetTable = toStep.tableName || fromStep.tableName;
|
let targetTable = toStep.tableName || fromStep.tableName;
|
||||||
|
|
||||||
|
// 1.5. 프로시저 호출 (스텝 이동 전 실행, 실패 시 전체 롤백)
|
||||||
|
if (
|
||||||
|
toStep.integrationType === "procedure" &&
|
||||||
|
toStep.integrationConfig &&
|
||||||
|
(toStep.integrationConfig as FlowProcedureConfig).type === "procedure"
|
||||||
|
) {
|
||||||
|
const procConfig = toStep.integrationConfig as FlowProcedureConfig;
|
||||||
|
// 레코드 데이터 조회 (파라미터 매핑용)
|
||||||
|
let recordData: Record<string, any> = {};
|
||||||
|
try {
|
||||||
|
const recordTable = FlowConditionParser.sanitizeTableName(
|
||||||
|
sourceTable || flowDefinition.tableName
|
||||||
|
);
|
||||||
|
const recordResult = await client.query(
|
||||||
|
`SELECT * FROM ${recordTable} WHERE id = $1 LIMIT 1`,
|
||||||
|
[dataId]
|
||||||
|
);
|
||||||
|
if (recordResult.rows && recordResult.rows.length > 0) {
|
||||||
|
recordData = recordResult.rows[0];
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.warn("프로시저 파라미터용 레코드 조회 실패:", err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`프로시저 호출 시작: ${procConfig.procedureName}`, {
|
||||||
|
flowId,
|
||||||
|
fromStepId,
|
||||||
|
toStepId,
|
||||||
|
dataId,
|
||||||
|
dbSource: procConfig.dbSource,
|
||||||
|
});
|
||||||
|
|
||||||
|
const procResult = await this.flowProcedureService.executeProcedure(
|
||||||
|
procConfig,
|
||||||
|
recordData,
|
||||||
|
procConfig.dbSource === "internal" ? client : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`프로시저 호출 완료: ${procConfig.procedureName}`, {
|
||||||
|
success: procResult.success,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 프로시저 실행 로그 기록
|
||||||
|
await this.logIntegration(
|
||||||
|
flowId,
|
||||||
|
toStep.id,
|
||||||
|
dataId,
|
||||||
|
"procedure",
|
||||||
|
procConfig.connectionId,
|
||||||
|
procConfig,
|
||||||
|
procResult.result,
|
||||||
|
"success",
|
||||||
|
undefined,
|
||||||
|
0,
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 2. 이동 방식에 따라 처리
|
// 2. 이동 방식에 따라 처리
|
||||||
switch (toStep.moveType || "status") {
|
switch (toStep.moveType || "status") {
|
||||||
case "status":
|
case "status":
|
||||||
|
|
@ -603,18 +665,19 @@ export class FlowDataMoveService {
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case "procedure":
|
||||||
|
// 프로시저는 데이터 이동 전에 이미 실행됨 (step 1.5)
|
||||||
|
break;
|
||||||
|
|
||||||
case "rest_api":
|
case "rest_api":
|
||||||
// REST API 연동 (추후 구현)
|
|
||||||
console.warn("REST API 연동은 아직 구현되지 않았습니다");
|
console.warn("REST API 연동은 아직 구현되지 않았습니다");
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "webhook":
|
case "webhook":
|
||||||
// Webhook 연동 (추후 구현)
|
|
||||||
console.warn("Webhook 연동은 아직 구현되지 않았습니다");
|
console.warn("Webhook 연동은 아직 구현되지 않았습니다");
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "hybrid":
|
case "hybrid":
|
||||||
// 복합 연동 (추후 구현)
|
|
||||||
console.warn("복합 연동은 아직 구현되지 않았습니다");
|
console.warn("복합 연동은 아직 구현되지 않았습니다");
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|
@ -716,6 +779,40 @@ export class FlowDataMoveService {
|
||||||
let sourceTable = fromStep.tableName;
|
let sourceTable = fromStep.tableName;
|
||||||
let targetTable = toStep.tableName || fromStep.tableName;
|
let targetTable = toStep.tableName || fromStep.tableName;
|
||||||
|
|
||||||
|
// 1.5. 프로시저 호출 (외부 DB 경로 - 스텝 이동 전)
|
||||||
|
if (
|
||||||
|
toStep.integrationType === "procedure" &&
|
||||||
|
toStep.integrationConfig &&
|
||||||
|
(toStep.integrationConfig as FlowProcedureConfig).type === "procedure"
|
||||||
|
) {
|
||||||
|
const procConfig = toStep.integrationConfig as FlowProcedureConfig;
|
||||||
|
let recordData: Record<string, any> = {};
|
||||||
|
try {
|
||||||
|
const recordTable = FlowConditionParser.sanitizeTableName(
|
||||||
|
sourceTable || ""
|
||||||
|
);
|
||||||
|
if (recordTable) {
|
||||||
|
const placeholder = getPlaceholder(dbType, 1);
|
||||||
|
const recordResult = await externalClient.query(
|
||||||
|
`SELECT * FROM ${recordTable} WHERE id = ${placeholder}`,
|
||||||
|
[dataId]
|
||||||
|
);
|
||||||
|
const rows = recordResult.rows || recordResult;
|
||||||
|
if (Array.isArray(rows) && rows.length > 0) {
|
||||||
|
recordData = rows[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.warn("프로시저 파라미터용 레코드 조회 실패 (외부):", err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.flowProcedureService.executeProcedure(
|
||||||
|
procConfig,
|
||||||
|
recordData,
|
||||||
|
procConfig.dbSource === "external" ? undefined : undefined
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 2. 이동 방식에 따라 처리
|
// 2. 이동 방식에 따라 처리
|
||||||
switch (toStep.moveType || "status") {
|
switch (toStep.moveType || "status") {
|
||||||
case "status":
|
case "status":
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,429 @@
|
||||||
|
/**
|
||||||
|
* 플로우 프로시저 호출 서비스
|
||||||
|
* 내부/외부 DB의 프로시저/함수 목록 조회, 파라미터 조회, 실행을 담당
|
||||||
|
*/
|
||||||
|
|
||||||
|
import db from "../database/db";
|
||||||
|
import {
|
||||||
|
getExternalPool,
|
||||||
|
executeExternalQuery,
|
||||||
|
} from "./externalDbHelper";
|
||||||
|
import { getPlaceholder } from "./dbQueryBuilder";
|
||||||
|
import {
|
||||||
|
FlowProcedureConfig,
|
||||||
|
FlowProcedureParam,
|
||||||
|
ProcedureListItem,
|
||||||
|
ProcedureParameterInfo,
|
||||||
|
} from "../types/flow";
|
||||||
|
|
||||||
|
export class FlowProcedureService {
|
||||||
|
/**
|
||||||
|
* 프로시저/함수 목록 조회
|
||||||
|
* information_schema.routines에서 사용 가능한 프로시저/함수를 가져온다
|
||||||
|
*/
|
||||||
|
async listProcedures(
|
||||||
|
dbSource: "internal" | "external",
|
||||||
|
connectionId?: number,
|
||||||
|
schema?: string
|
||||||
|
): Promise<ProcedureListItem[]> {
|
||||||
|
if (dbSource === "external" && connectionId) {
|
||||||
|
return this.listExternalProcedures(connectionId, schema);
|
||||||
|
}
|
||||||
|
return this.listInternalProcedures(schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async listInternalProcedures(
|
||||||
|
schema?: string
|
||||||
|
): Promise<ProcedureListItem[]> {
|
||||||
|
const targetSchema = schema || "public";
|
||||||
|
// 트리거 함수(data_type='trigger')는 직접 호출 대상이 아니므로 제외
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
routine_name AS name,
|
||||||
|
routine_schema AS schema,
|
||||||
|
routine_type AS type,
|
||||||
|
data_type AS return_type
|
||||||
|
FROM information_schema.routines
|
||||||
|
WHERE routine_schema = $1
|
||||||
|
AND routine_type IN ('PROCEDURE', 'FUNCTION')
|
||||||
|
AND data_type != 'trigger'
|
||||||
|
ORDER BY routine_type, routine_name
|
||||||
|
`;
|
||||||
|
const rows = await db.query(query, [targetSchema]);
|
||||||
|
return rows.map((r: any) => ({
|
||||||
|
name: r.name,
|
||||||
|
schema: r.schema,
|
||||||
|
type: r.type as "PROCEDURE" | "FUNCTION",
|
||||||
|
returnType: r.return_type || undefined,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async listExternalProcedures(
|
||||||
|
connectionId: number,
|
||||||
|
schema?: string
|
||||||
|
): Promise<ProcedureListItem[]> {
|
||||||
|
const poolInfo = await getExternalPool(connectionId);
|
||||||
|
const dbType = poolInfo.dbType.toLowerCase();
|
||||||
|
|
||||||
|
let query: string;
|
||||||
|
let params: any[];
|
||||||
|
|
||||||
|
switch (dbType) {
|
||||||
|
case "postgresql": {
|
||||||
|
const targetSchema = schema || "public";
|
||||||
|
query = `
|
||||||
|
SELECT
|
||||||
|
routine_name AS name,
|
||||||
|
routine_schema AS schema,
|
||||||
|
routine_type AS type,
|
||||||
|
data_type AS return_type
|
||||||
|
FROM information_schema.routines
|
||||||
|
WHERE routine_schema = $1
|
||||||
|
AND routine_type IN ('PROCEDURE', 'FUNCTION')
|
||||||
|
AND data_type != 'trigger'
|
||||||
|
ORDER BY routine_type, routine_name
|
||||||
|
`;
|
||||||
|
params = [targetSchema];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "mysql":
|
||||||
|
case "mariadb": {
|
||||||
|
query = `
|
||||||
|
SELECT
|
||||||
|
ROUTINE_NAME AS name,
|
||||||
|
ROUTINE_SCHEMA AS \`schema\`,
|
||||||
|
ROUTINE_TYPE AS type,
|
||||||
|
DATA_TYPE AS return_type
|
||||||
|
FROM information_schema.ROUTINES
|
||||||
|
WHERE ROUTINE_SCHEMA = DATABASE()
|
||||||
|
AND ROUTINE_TYPE IN ('PROCEDURE', 'FUNCTION')
|
||||||
|
ORDER BY ROUTINE_TYPE, ROUTINE_NAME
|
||||||
|
`;
|
||||||
|
params = [];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "mssql": {
|
||||||
|
query = `
|
||||||
|
SELECT
|
||||||
|
ROUTINE_NAME AS name,
|
||||||
|
ROUTINE_SCHEMA AS [schema],
|
||||||
|
ROUTINE_TYPE AS type,
|
||||||
|
DATA_TYPE AS return_type
|
||||||
|
FROM INFORMATION_SCHEMA.ROUTINES
|
||||||
|
WHERE ROUTINE_TYPE IN ('PROCEDURE', 'FUNCTION')
|
||||||
|
ORDER BY ROUTINE_TYPE, ROUTINE_NAME
|
||||||
|
`;
|
||||||
|
params = [];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new Error(`프로시저 목록 조회 미지원 DB: ${dbType}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await executeExternalQuery(connectionId, query, params);
|
||||||
|
return (result.rows || []).map((r: any) => ({
|
||||||
|
name: r.name || r.NAME,
|
||||||
|
schema: r.schema || r.SCHEMA || "",
|
||||||
|
type: (r.type || r.TYPE || "FUNCTION").toUpperCase() as "PROCEDURE" | "FUNCTION",
|
||||||
|
returnType: r.return_type || r.RETURN_TYPE || undefined,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프로시저/함수 파라미터 정보 조회
|
||||||
|
*/
|
||||||
|
async getProcedureParameters(
|
||||||
|
procedureName: string,
|
||||||
|
dbSource: "internal" | "external",
|
||||||
|
connectionId?: number,
|
||||||
|
schema?: string
|
||||||
|
): Promise<ProcedureParameterInfo[]> {
|
||||||
|
if (dbSource === "external" && connectionId) {
|
||||||
|
return this.getExternalProcedureParameters(
|
||||||
|
connectionId,
|
||||||
|
procedureName,
|
||||||
|
schema
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.getInternalProcedureParameters(procedureName, schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getInternalProcedureParameters(
|
||||||
|
procedureName: string,
|
||||||
|
schema?: string
|
||||||
|
): Promise<ProcedureParameterInfo[]> {
|
||||||
|
const targetSchema = schema || "public";
|
||||||
|
// PostgreSQL의 specific_name은 routine_name + OID 형태이므로 서브쿼리로 매칭
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
p.parameter_name AS name,
|
||||||
|
p.ordinal_position AS position,
|
||||||
|
p.data_type,
|
||||||
|
p.parameter_mode AS mode,
|
||||||
|
p.parameter_default AS default_value
|
||||||
|
FROM information_schema.parameters p
|
||||||
|
WHERE p.specific_schema = $1
|
||||||
|
AND p.specific_name IN (
|
||||||
|
SELECT r.specific_name FROM information_schema.routines r
|
||||||
|
WHERE r.routine_schema = $1 AND r.routine_name = $2
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
AND p.parameter_name IS NOT NULL
|
||||||
|
ORDER BY p.ordinal_position
|
||||||
|
`;
|
||||||
|
const rows = await db.query(query, [targetSchema, procedureName]);
|
||||||
|
return rows.map((r: any) => ({
|
||||||
|
name: r.name,
|
||||||
|
position: parseInt(r.position, 10),
|
||||||
|
dataType: r.data_type,
|
||||||
|
mode: this.normalizeParamMode(r.mode),
|
||||||
|
defaultValue: r.default_value || undefined,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getExternalProcedureParameters(
|
||||||
|
connectionId: number,
|
||||||
|
procedureName: string,
|
||||||
|
schema?: string
|
||||||
|
): Promise<ProcedureParameterInfo[]> {
|
||||||
|
const poolInfo = await getExternalPool(connectionId);
|
||||||
|
const dbType = poolInfo.dbType.toLowerCase();
|
||||||
|
|
||||||
|
let query: string;
|
||||||
|
let params: any[];
|
||||||
|
|
||||||
|
switch (dbType) {
|
||||||
|
case "postgresql": {
|
||||||
|
const targetSchema = schema || "public";
|
||||||
|
query = `
|
||||||
|
SELECT
|
||||||
|
p.parameter_name AS name,
|
||||||
|
p.ordinal_position AS position,
|
||||||
|
p.data_type,
|
||||||
|
p.parameter_mode AS mode,
|
||||||
|
p.parameter_default AS default_value
|
||||||
|
FROM information_schema.parameters p
|
||||||
|
WHERE p.specific_schema = $1
|
||||||
|
AND p.specific_name IN (
|
||||||
|
SELECT r.specific_name FROM information_schema.routines r
|
||||||
|
WHERE r.routine_schema = $1 AND r.routine_name = $2
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
AND p.parameter_name IS NOT NULL
|
||||||
|
ORDER BY p.ordinal_position
|
||||||
|
`;
|
||||||
|
params = [targetSchema, procedureName];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "mysql":
|
||||||
|
case "mariadb": {
|
||||||
|
query = `
|
||||||
|
SELECT
|
||||||
|
PARAMETER_NAME AS name,
|
||||||
|
ORDINAL_POSITION AS position,
|
||||||
|
DATA_TYPE AS data_type,
|
||||||
|
PARAMETER_MODE AS mode,
|
||||||
|
'' AS default_value
|
||||||
|
FROM information_schema.PARAMETERS
|
||||||
|
WHERE SPECIFIC_SCHEMA = DATABASE()
|
||||||
|
AND SPECIFIC_NAME = ?
|
||||||
|
AND PARAMETER_NAME IS NOT NULL
|
||||||
|
ORDER BY ORDINAL_POSITION
|
||||||
|
`;
|
||||||
|
params = [procedureName];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "mssql": {
|
||||||
|
query = `
|
||||||
|
SELECT
|
||||||
|
PARAMETER_NAME AS name,
|
||||||
|
ORDINAL_POSITION AS position,
|
||||||
|
DATA_TYPE AS data_type,
|
||||||
|
PARAMETER_MODE AS mode,
|
||||||
|
'' AS default_value
|
||||||
|
FROM INFORMATION_SCHEMA.PARAMETERS
|
||||||
|
WHERE SPECIFIC_NAME = @p1
|
||||||
|
AND PARAMETER_NAME IS NOT NULL
|
||||||
|
ORDER BY ORDINAL_POSITION
|
||||||
|
`;
|
||||||
|
params = [procedureName];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new Error(`파라미터 조회 미지원 DB: ${dbType}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await executeExternalQuery(connectionId, query, params);
|
||||||
|
return (result.rows || []).map((r: any) => ({
|
||||||
|
name: (r.name || r.NAME || "").replace(/^@/, ""),
|
||||||
|
position: parseInt(r.position || r.POSITION || "0", 10),
|
||||||
|
dataType: r.data_type || r.DATA_TYPE || "unknown",
|
||||||
|
mode: this.normalizeParamMode(r.mode || r.MODE),
|
||||||
|
defaultValue: r.default_value || r.DEFAULT_VALUE || undefined,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프로시저/함수 실행
|
||||||
|
* 내부 DB는 기존 트랜잭션 client를 사용, 외부 DB는 별도 연결
|
||||||
|
*/
|
||||||
|
async executeProcedure(
|
||||||
|
config: FlowProcedureConfig,
|
||||||
|
recordData: Record<string, any>,
|
||||||
|
client?: any
|
||||||
|
): Promise<{ success: boolean; result?: any; error?: string }> {
|
||||||
|
const paramValues = this.resolveParameters(config.parameters, recordData);
|
||||||
|
|
||||||
|
if (config.dbSource === "internal") {
|
||||||
|
return this.executeInternalProcedure(config, paramValues, client);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.connectionId) {
|
||||||
|
throw new Error("외부 DB 프로시저 호출에 connectionId가 필요합니다");
|
||||||
|
}
|
||||||
|
return this.executeExternalProcedure(config, paramValues);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 내부 DB 프로시저 실행 (트랜잭션 client 공유)
|
||||||
|
*/
|
||||||
|
private async executeInternalProcedure(
|
||||||
|
config: FlowProcedureConfig,
|
||||||
|
paramValues: any[],
|
||||||
|
client?: any
|
||||||
|
): Promise<{ success: boolean; result?: any; error?: string }> {
|
||||||
|
const schema = config.procedureSchema || "public";
|
||||||
|
const safeName = this.sanitizeName(config.procedureName);
|
||||||
|
const safeSchema = this.sanitizeName(schema);
|
||||||
|
const qualifiedName = `${safeSchema}.${safeName}`;
|
||||||
|
|
||||||
|
const placeholders = paramValues.map((_, i) => `$${i + 1}`).join(", ");
|
||||||
|
|
||||||
|
let sql: string;
|
||||||
|
if (config.callType === "function") {
|
||||||
|
// SELECT * FROM fn()을 사용하여 OUT 파라미터를 개별 컬럼으로 반환
|
||||||
|
sql = `SELECT * FROM ${qualifiedName}(${placeholders})`;
|
||||||
|
} else {
|
||||||
|
sql = `CALL ${qualifiedName}(${placeholders})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const executor = client || db;
|
||||||
|
const result = client
|
||||||
|
? await client.query(sql, paramValues)
|
||||||
|
: await db.query(sql, paramValues);
|
||||||
|
|
||||||
|
const rows = client ? result.rows : result;
|
||||||
|
return { success: true, result: rows };
|
||||||
|
} catch (error: any) {
|
||||||
|
throw new Error(
|
||||||
|
`프로시저 실행 실패 [${qualifiedName}]: ${error.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 외부 DB 프로시저 실행
|
||||||
|
*/
|
||||||
|
private async executeExternalProcedure(
|
||||||
|
config: FlowProcedureConfig,
|
||||||
|
paramValues: any[]
|
||||||
|
): Promise<{ success: boolean; result?: any; error?: string }> {
|
||||||
|
const connectionId = config.connectionId!;
|
||||||
|
const poolInfo = await getExternalPool(connectionId);
|
||||||
|
const dbType = poolInfo.dbType.toLowerCase();
|
||||||
|
const safeName = this.sanitizeName(config.procedureName);
|
||||||
|
const safeSchema = config.procedureSchema
|
||||||
|
? this.sanitizeName(config.procedureSchema)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
let sql: string;
|
||||||
|
|
||||||
|
switch (dbType) {
|
||||||
|
case "postgresql": {
|
||||||
|
const qualifiedName = safeSchema
|
||||||
|
? `${safeSchema}.${safeName}`
|
||||||
|
: safeName;
|
||||||
|
const placeholders = paramValues.map((_, i) => `$${i + 1}`).join(", ");
|
||||||
|
sql =
|
||||||
|
config.callType === "function"
|
||||||
|
? `SELECT * FROM ${qualifiedName}(${placeholders})`
|
||||||
|
: `CALL ${qualifiedName}(${placeholders})`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "mysql":
|
||||||
|
case "mariadb": {
|
||||||
|
const placeholders = paramValues.map(() => "?").join(", ");
|
||||||
|
sql = `CALL ${safeName}(${placeholders})`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "mssql": {
|
||||||
|
const paramList = paramValues
|
||||||
|
.map((_, i) => `@p${i + 1}`)
|
||||||
|
.join(", ");
|
||||||
|
sql = `EXEC ${safeName} ${paramList}`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new Error(`프로시저 실행 미지원 DB: ${dbType}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await executeExternalQuery(connectionId, sql, paramValues);
|
||||||
|
return { success: true, result: result.rows };
|
||||||
|
} catch (error: any) {
|
||||||
|
throw new Error(
|
||||||
|
`외부 프로시저 실행 실패 [${safeName}]: ${error.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 설정된 파라미터 매핑에서 실제 값을 추출
|
||||||
|
*/
|
||||||
|
private resolveParameters(
|
||||||
|
params: FlowProcedureParam[],
|
||||||
|
recordData: Record<string, any>
|
||||||
|
): any[] {
|
||||||
|
const inParams = params.filter((p) => p.mode === "IN" || p.mode === "INOUT");
|
||||||
|
return inParams.map((param) => {
|
||||||
|
switch (param.source) {
|
||||||
|
case "record_field":
|
||||||
|
if (!param.field) {
|
||||||
|
throw new Error(`파라미터 ${param.name}: 레코드 필드가 지정되지 않았습니다`);
|
||||||
|
}
|
||||||
|
return recordData[param.field] ?? null;
|
||||||
|
|
||||||
|
case "static":
|
||||||
|
return param.value ?? null;
|
||||||
|
|
||||||
|
case "step_variable":
|
||||||
|
return recordData[param.field || param.name] ?? param.value ?? null;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이름(스키마/프로시저) SQL Injection 방지용 검증
|
||||||
|
*/
|
||||||
|
private sanitizeName(name: string): string {
|
||||||
|
if (!/^[a-zA-Z0-9_]+$/.test(name)) {
|
||||||
|
throw new Error(`유효하지 않은 이름: ${name}`);
|
||||||
|
}
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 파라미터 모드 정규화
|
||||||
|
*/
|
||||||
|
private normalizeParamMode(mode: string | null): "IN" | "OUT" | "INOUT" {
|
||||||
|
if (!mode) return "IN";
|
||||||
|
const upper = mode.toUpperCase();
|
||||||
|
if (upper === "OUT") return "OUT";
|
||||||
|
if (upper === "INOUT") return "INOUT";
|
||||||
|
return "IN";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
import { query, queryOne, transaction } from "../database/db";
|
import { query, queryOne, transaction } from "../database/db";
|
||||||
import { logger } from "../utils/logger";
|
import { logger } from "../utils/logger";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
import { FlowProcedureService } from "./flowProcedureService";
|
||||||
|
|
||||||
// ===== 타입 정의 =====
|
// ===== 타입 정의 =====
|
||||||
|
|
||||||
|
|
@ -36,6 +37,7 @@ export type NodeType =
|
||||||
| "emailAction" // 이메일 발송 액션
|
| "emailAction" // 이메일 발송 액션
|
||||||
| "scriptAction" // 스크립트 실행 액션
|
| "scriptAction" // 스크립트 실행 액션
|
||||||
| "httpRequestAction" // HTTP 요청 액션
|
| "httpRequestAction" // HTTP 요청 액션
|
||||||
|
| "procedureCallAction" // 프로시저/함수 호출 액션
|
||||||
| "comment"
|
| "comment"
|
||||||
| "log";
|
| "log";
|
||||||
|
|
||||||
|
|
@ -663,6 +665,9 @@ export class NodeFlowExecutionService {
|
||||||
case "httpRequestAction":
|
case "httpRequestAction":
|
||||||
return this.executeHttpRequestAction(node, inputData, context);
|
return this.executeHttpRequestAction(node, inputData, context);
|
||||||
|
|
||||||
|
case "procedureCallAction":
|
||||||
|
return this.executeProcedureCallAction(node, inputData, context, client);
|
||||||
|
|
||||||
case "comment":
|
case "comment":
|
||||||
case "log":
|
case "log":
|
||||||
// 로그/코멘트는 실행 없이 통과
|
// 로그/코멘트는 실행 없이 통과
|
||||||
|
|
@ -4856,4 +4861,105 @@ export class NodeFlowExecutionService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프로시저/함수 호출 액션 노드 실행
|
||||||
|
*/
|
||||||
|
private static async executeProcedureCallAction(
|
||||||
|
node: FlowNode,
|
||||||
|
inputData: any,
|
||||||
|
context: ExecutionContext,
|
||||||
|
client?: any
|
||||||
|
): Promise<any> {
|
||||||
|
const {
|
||||||
|
dbSource = "internal",
|
||||||
|
connectionId,
|
||||||
|
procedureName,
|
||||||
|
procedureSchema = "public",
|
||||||
|
callType = "function",
|
||||||
|
parameters = [],
|
||||||
|
} = node.data;
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`🔧 프로시저 호출 노드 실행: ${node.data.displayName || node.id}`
|
||||||
|
);
|
||||||
|
logger.info(
|
||||||
|
` 프로시저: ${procedureSchema}.${procedureName} (${callType}), DB: ${dbSource}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!procedureName) {
|
||||||
|
throw new Error("프로시저/함수가 선택되지 않았습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataArray = Array.isArray(inputData)
|
||||||
|
? inputData
|
||||||
|
: inputData
|
||||||
|
? [inputData]
|
||||||
|
: [{}];
|
||||||
|
|
||||||
|
const procedureService = new FlowProcedureService();
|
||||||
|
const results: any[] = [];
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
type: "procedure" as const,
|
||||||
|
dbSource: dbSource as "internal" | "external",
|
||||||
|
connectionId,
|
||||||
|
procedureName,
|
||||||
|
procedureSchema,
|
||||||
|
callType: callType as "procedure" | "function",
|
||||||
|
parameters: parameters.map((p: any) => ({
|
||||||
|
name: p.name,
|
||||||
|
dataType: p.dataType,
|
||||||
|
mode: p.mode || "IN",
|
||||||
|
source: p.source || "static",
|
||||||
|
field: p.field,
|
||||||
|
value: p.value,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const record of dataArray) {
|
||||||
|
try {
|
||||||
|
logger.info(` 입력 레코드 키: ${Object.keys(record).join(", ")}`);
|
||||||
|
|
||||||
|
const execResult = await procedureService.executeProcedure(
|
||||||
|
config,
|
||||||
|
record,
|
||||||
|
dbSource === "internal" ? client : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(` ✅ 프로시저 실행 성공: ${procedureName}`);
|
||||||
|
|
||||||
|
// 프로시저 반환값을 레코드에 평탄화하여 다음 노드에서 필드로 참조 가능하게 함
|
||||||
|
let flatResult: Record<string, any> = {};
|
||||||
|
if (Array.isArray(execResult.result) && execResult.result.length > 0) {
|
||||||
|
const row = execResult.result[0];
|
||||||
|
for (const [key, val] of Object.entries(row)) {
|
||||||
|
// 함수명과 동일한 키(SELECT fn() 결과)는 _procedureReturn으로 매핑
|
||||||
|
if (key === procedureName) {
|
||||||
|
flatResult["_procedureReturn"] = val;
|
||||||
|
} else {
|
||||||
|
flatResult[key] = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.info(` 반환 필드: ${Object.keys(flatResult).join(", ")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
...record,
|
||||||
|
...flatResult,
|
||||||
|
_procedureResult: execResult.result,
|
||||||
|
_procedureSuccess: true,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(` ❌ 프로시저 실행 실패: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`🔧 프로시저 호출 완료: ${results.length}건 처리`
|
||||||
|
);
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -278,6 +278,7 @@ export interface SqlWhereResult {
|
||||||
export type FlowIntegrationType =
|
export type FlowIntegrationType =
|
||||||
| "internal" // 내부 DB (기본값)
|
| "internal" // 내부 DB (기본값)
|
||||||
| "external_db" // 외부 DB
|
| "external_db" // 외부 DB
|
||||||
|
| "procedure" // 프로시저/함수 호출
|
||||||
| "rest_api" // REST API (추후 구현)
|
| "rest_api" // REST API (추후 구현)
|
||||||
| "webhook" // Webhook (추후 구현)
|
| "webhook" // Webhook (추후 구현)
|
||||||
| "hybrid"; // 복합 연동 (추후 구현)
|
| "hybrid"; // 복합 연동 (추후 구현)
|
||||||
|
|
@ -341,8 +342,48 @@ export interface FlowExternalDbIntegrationConfig {
|
||||||
customQuery?: string; // operation이 'custom'인 경우 사용
|
customQuery?: string; // operation이 'custom'인 경우 사용
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 프로시저 호출 파라미터 정의
|
||||||
|
export interface FlowProcedureParam {
|
||||||
|
name: string;
|
||||||
|
dataType: string;
|
||||||
|
mode: "IN" | "OUT" | "INOUT";
|
||||||
|
source: "record_field" | "static" | "step_variable";
|
||||||
|
field?: string; // source가 record_field인 경우: 레코드 컬럼명
|
||||||
|
value?: string; // source가 static인 경우: 고정값
|
||||||
|
}
|
||||||
|
|
||||||
|
// 프로시저 호출 설정 (integration_config JSON)
|
||||||
|
export interface FlowProcedureConfig {
|
||||||
|
type: "procedure";
|
||||||
|
dbSource: "internal" | "external";
|
||||||
|
connectionId?: number; // 외부 DB인 경우 external_db_connections.id
|
||||||
|
procedureName: string;
|
||||||
|
procedureSchema?: string; // 스키마명 (기본: public)
|
||||||
|
callType: "procedure" | "function"; // CALL vs SELECT
|
||||||
|
parameters: FlowProcedureParam[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 프로시저/함수 목록 항목
|
||||||
|
export interface ProcedureListItem {
|
||||||
|
name: string;
|
||||||
|
schema: string;
|
||||||
|
type: "PROCEDURE" | "FUNCTION";
|
||||||
|
returnType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 프로시저 파라미터 정보
|
||||||
|
export interface ProcedureParameterInfo {
|
||||||
|
name: string;
|
||||||
|
position: number;
|
||||||
|
dataType: string;
|
||||||
|
mode: "IN" | "OUT" | "INOUT";
|
||||||
|
defaultValue?: string;
|
||||||
|
}
|
||||||
|
|
||||||
// 연동 설정 통합 타입
|
// 연동 설정 통합 타입
|
||||||
export type FlowIntegrationConfig = FlowExternalDbIntegrationConfig; // 나중에 다른 타입 추가
|
export type FlowIntegrationConfig =
|
||||||
|
| FlowExternalDbIntegrationConfig
|
||||||
|
| FlowProcedureConfig;
|
||||||
|
|
||||||
// 연동 실행 컨텍스트
|
// 연동 실행 컨텍스트
|
||||||
export interface FlowIntegrationContext {
|
export interface FlowIntegrationContext {
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,8 @@ export default function FlowEditorPage() {
|
||||||
tableName: step.tableName,
|
tableName: step.tableName,
|
||||||
count: stepCounts[step.id] || 0,
|
count: stepCounts[step.id] || 0,
|
||||||
condition: step.conditionJson,
|
condition: step.conditionJson,
|
||||||
|
integrationType: (step as any).integrationType,
|
||||||
|
procedureName: (step as any).integrationConfig?.procedureName,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ import { LogNode } from "./nodes/LogNode";
|
||||||
import { EmailActionNode } from "./nodes/EmailActionNode";
|
import { EmailActionNode } from "./nodes/EmailActionNode";
|
||||||
import { ScriptActionNode } from "./nodes/ScriptActionNode";
|
import { ScriptActionNode } from "./nodes/ScriptActionNode";
|
||||||
import { HttpRequestActionNode } from "./nodes/HttpRequestActionNode";
|
import { HttpRequestActionNode } from "./nodes/HttpRequestActionNode";
|
||||||
|
import { ProcedureCallActionNode } from "./nodes/ProcedureCallActionNode";
|
||||||
import { validateFlow } from "@/lib/utils/flowValidation";
|
import { validateFlow } from "@/lib/utils/flowValidation";
|
||||||
import type { FlowValidation } from "@/lib/utils/flowValidation";
|
import type { FlowValidation } from "@/lib/utils/flowValidation";
|
||||||
|
|
||||||
|
|
@ -55,6 +56,7 @@ const nodeTypes = {
|
||||||
emailAction: EmailActionNode,
|
emailAction: EmailActionNode,
|
||||||
scriptAction: ScriptActionNode,
|
scriptAction: ScriptActionNode,
|
||||||
httpRequestAction: HttpRequestActionNode,
|
httpRequestAction: HttpRequestActionNode,
|
||||||
|
procedureCallAction: ProcedureCallActionNode,
|
||||||
// 유틸리티
|
// 유틸리티
|
||||||
comment: CommentNode,
|
comment: CommentNode,
|
||||||
log: LogNode,
|
log: LogNode,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,121 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프로시저/함수 호출 액션 노드
|
||||||
|
* 내부 또는 외부 DB의 프로시저/함수를 호출하는 노드
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { memo } from "react";
|
||||||
|
import { Handle, Position, NodeProps } from "reactflow";
|
||||||
|
import { Database, Workflow } from "lucide-react";
|
||||||
|
import type { ProcedureCallActionNodeData } from "@/types/node-editor";
|
||||||
|
|
||||||
|
export const ProcedureCallActionNode = memo(
|
||||||
|
({ data, selected }: NodeProps<ProcedureCallActionNodeData>) => {
|
||||||
|
const hasProcedure = !!data.procedureName;
|
||||||
|
const inParams = data.parameters?.filter((p) => p.mode === "IN" || p.mode === "INOUT") ?? [];
|
||||||
|
const outParams = data.parameters?.filter((p) => p.mode === "OUT" || p.mode === "INOUT") ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||||
|
selected ? "border-violet-500 shadow-lg" : "border-gray-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* 입력 핸들 */}
|
||||||
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={Position.Left}
|
||||||
|
className="!h-3 !w-3 !border-2 !border-white !bg-violet-500"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center gap-2 rounded-t-lg bg-violet-500 px-3 py-2 text-white">
|
||||||
|
<Workflow className="h-4 w-4" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-sm font-semibold">
|
||||||
|
{data.displayName || "프로시저 호출"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 본문 */}
|
||||||
|
<div className="space-y-2 p-3">
|
||||||
|
{/* DB 소스 */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Database className="h-3 w-3 text-gray-400" />
|
||||||
|
<span className="text-xs text-gray-600">
|
||||||
|
{data.dbSource === "external" ? (
|
||||||
|
<span className="rounded bg-amber-100 px-2 py-0.5 text-amber-700">
|
||||||
|
{data.connectionName || "외부 DB"}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="rounded bg-blue-100 px-2 py-0.5 text-blue-700">
|
||||||
|
내부 DB
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`ml-auto rounded px-2 py-0.5 text-xs font-medium ${
|
||||||
|
data.callType === "function"
|
||||||
|
? "bg-cyan-100 text-cyan-700"
|
||||||
|
: "bg-violet-100 text-violet-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{data.callType === "function" ? "FUNCTION" : "PROCEDURE"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 프로시저명 */}
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<Workflow className="h-3 w-3 text-gray-400" />
|
||||||
|
{hasProcedure ? (
|
||||||
|
<span className="font-mono text-green-600 truncate">
|
||||||
|
{data.procedureSchema && data.procedureSchema !== "public"
|
||||||
|
? `${data.procedureSchema}.`
|
||||||
|
: ""}
|
||||||
|
{data.procedureName}()
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-orange-500">프로시저 선택 필요</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 파라미터 수 */}
|
||||||
|
{hasProcedure && inParams.length > 0 && (
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
입력 파라미터: {inParams.length}개
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 반환 필드 */}
|
||||||
|
{hasProcedure && outParams.length > 0 && (
|
||||||
|
<div className="mt-1 space-y-1 border-t border-gray-100 pt-1">
|
||||||
|
<div className="text-[10px] font-medium text-green-600">
|
||||||
|
반환 필드:
|
||||||
|
</div>
|
||||||
|
{outParams.map((p) => (
|
||||||
|
<div
|
||||||
|
key={p.name}
|
||||||
|
className="flex items-center justify-between rounded bg-green-50 px-2 py-0.5 text-[10px]"
|
||||||
|
>
|
||||||
|
<span className="font-mono text-green-700">{p.name}</span>
|
||||||
|
<span className="text-gray-400">{p.dataType}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 출력 핸들 */}
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Right}
|
||||||
|
className="!h-3 !w-3 !border-2 !border-white !bg-violet-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
ProcedureCallActionNode.displayName = "ProcedureCallActionNode";
|
||||||
|
|
@ -23,6 +23,7 @@ import { LogProperties } from "./properties/LogProperties";
|
||||||
import { EmailActionProperties } from "./properties/EmailActionProperties";
|
import { EmailActionProperties } from "./properties/EmailActionProperties";
|
||||||
import { ScriptActionProperties } from "./properties/ScriptActionProperties";
|
import { ScriptActionProperties } from "./properties/ScriptActionProperties";
|
||||||
import { HttpRequestActionProperties } from "./properties/HttpRequestActionProperties";
|
import { HttpRequestActionProperties } from "./properties/HttpRequestActionProperties";
|
||||||
|
import { ProcedureCallActionProperties } from "./properties/ProcedureCallActionProperties";
|
||||||
import type { NodeType } from "@/types/node-editor";
|
import type { NodeType } from "@/types/node-editor";
|
||||||
|
|
||||||
export function PropertiesPanel() {
|
export function PropertiesPanel() {
|
||||||
|
|
@ -147,6 +148,9 @@ function NodePropertiesRenderer({ node }: { node: any }) {
|
||||||
case "httpRequestAction":
|
case "httpRequestAction":
|
||||||
return <HttpRequestActionProperties nodeId={node.id} data={node.data} />;
|
return <HttpRequestActionProperties nodeId={node.id} data={node.data} />;
|
||||||
|
|
||||||
|
case "procedureCallAction":
|
||||||
|
return <ProcedureCallActionProperties nodeId={node.id} data={node.data} />;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
|
|
@ -185,6 +189,7 @@ function getNodeTypeLabel(type: NodeType): string {
|
||||||
emailAction: "메일 발송",
|
emailAction: "메일 발송",
|
||||||
scriptAction: "스크립트 실행",
|
scriptAction: "스크립트 실행",
|
||||||
httpRequestAction: "HTTP 요청",
|
httpRequestAction: "HTTP 요청",
|
||||||
|
procedureCallAction: "프로시저 호출",
|
||||||
comment: "주석",
|
comment: "주석",
|
||||||
log: "로그",
|
log: "로그",
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import { tableTypeApi } from "@/lib/api/screen";
|
||||||
import { getTestedExternalConnections, getExternalTables, getExternalColumns } from "@/lib/api/nodeExternalConnections";
|
import { getTestedExternalConnections, getExternalTables, getExternalColumns } from "@/lib/api/nodeExternalConnections";
|
||||||
import { getNumberingRules } from "@/lib/api/numberingRule";
|
import { getNumberingRules } from "@/lib/api/numberingRule";
|
||||||
import type { NumberingRuleConfig } from "@/types/numbering-rule";
|
import type { NumberingRuleConfig } from "@/types/numbering-rule";
|
||||||
|
import { getFlowProcedureParameters } from "@/lib/api/flow";
|
||||||
import type { InsertActionNodeData } from "@/types/node-editor";
|
import type { InsertActionNodeData } from "@/types/node-editor";
|
||||||
import type { ExternalConnection, ExternalTable, ExternalColumn } from "@/lib/api/nodeExternalConnections";
|
import type { ExternalConnection, ExternalTable, ExternalColumn } from "@/lib/api/nodeExternalConnections";
|
||||||
|
|
||||||
|
|
@ -171,10 +172,19 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
||||||
|
|
||||||
// 연결된 소스 노드에서 필드 가져오기 (재귀적으로 모든 상위 노드 탐색)
|
// 연결된 소스 노드에서 필드 가져오기 (재귀적으로 모든 상위 노드 탐색)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// 프로시저 노드 정보를 수집하여 비동기 파라미터 조회에 사용
|
||||||
|
const procedureNodes: Array<{
|
||||||
|
procedureName: string;
|
||||||
|
dbSource: "internal" | "external";
|
||||||
|
connectionId?: number;
|
||||||
|
schema?: string;
|
||||||
|
sourcePath: string[];
|
||||||
|
}> = [];
|
||||||
|
|
||||||
const getAllSourceFields = (
|
const getAllSourceFields = (
|
||||||
targetNodeId: string,
|
targetNodeId: string,
|
||||||
visitedNodes = new Set<string>(),
|
visitedNodes = new Set<string>(),
|
||||||
sourcePath: string[] = [], // 🔥 소스 경로 추적
|
sourcePath: string[] = [],
|
||||||
): { fields: Array<{ name: string; label?: string; sourcePath?: string[] }>; hasRestAPI: boolean } => {
|
): { fields: Array<{ name: string; label?: string; sourcePath?: string[] }>; hasRestAPI: boolean } => {
|
||||||
if (visitedNodes.has(targetNodeId)) {
|
if (visitedNodes.has(targetNodeId)) {
|
||||||
console.log(`⚠️ 순환 참조 감지: ${targetNodeId} (이미 방문함)`);
|
console.log(`⚠️ 순환 참조 감지: ${targetNodeId} (이미 방문함)`);
|
||||||
|
|
@ -366,7 +376,48 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
||||||
foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
|
foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 5️⃣ 통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색
|
// 5️⃣ 프로시저 호출 노드: 상위 필드 + OUT 파라미터(반환 필드) 추가
|
||||||
|
else if (node.type === "procedureCallAction") {
|
||||||
|
console.log("✅ 프로시저 호출 노드 발견");
|
||||||
|
const upperResult = getAllSourceFields(node.id, visitedNodes, currentPath);
|
||||||
|
fields.push(...upperResult.fields);
|
||||||
|
foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
|
||||||
|
|
||||||
|
const nodeData = node.data as any;
|
||||||
|
const procParams = nodeData.parameters;
|
||||||
|
let hasOutParams = false;
|
||||||
|
|
||||||
|
if (Array.isArray(procParams)) {
|
||||||
|
for (const p of procParams) {
|
||||||
|
if (p.mode === "OUT" || p.mode === "INOUT") {
|
||||||
|
hasOutParams = true;
|
||||||
|
fields.push({
|
||||||
|
name: p.name,
|
||||||
|
label: `${p.name} (프로시저 반환)`,
|
||||||
|
sourcePath: currentPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OUT 파라미터가 저장되어 있지 않으면 API로 동적 조회 예약
|
||||||
|
if (!hasOutParams && nodeData.procedureName) {
|
||||||
|
procedureNodes.push({
|
||||||
|
procedureName: nodeData.procedureName,
|
||||||
|
dbSource: nodeData.dbSource || "internal",
|
||||||
|
connectionId: nodeData.connectionId,
|
||||||
|
schema: nodeData.procedureSchema || "public",
|
||||||
|
sourcePath: currentPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fields.push({
|
||||||
|
name: "_procedureReturn",
|
||||||
|
label: "프로시저 반환값",
|
||||||
|
sourcePath: currentPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 6️⃣ 통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색
|
||||||
else {
|
else {
|
||||||
console.log(`✅ 통과 노드 (${node.type}) → 상위 노드로 계속 탐색`);
|
console.log(`✅ 통과 노드 (${node.type}) → 상위 노드로 계속 탐색`);
|
||||||
const upperResult = getAllSourceFields(node.id, visitedNodes, currentPath);
|
const upperResult = getAllSourceFields(node.id, visitedNodes, currentPath);
|
||||||
|
|
@ -386,31 +437,66 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
||||||
console.log(` - 총 필드 수: ${result.fields.length}개`);
|
console.log(` - 총 필드 수: ${result.fields.length}개`);
|
||||||
console.log(` - REST API 포함: ${result.hasRestAPI}`);
|
console.log(` - REST API 포함: ${result.hasRestAPI}`);
|
||||||
|
|
||||||
// 🔥 중복 제거 개선: 필드명이 같아도 소스가 다르면 모두 표시
|
const applyFields = (allFields: typeof result.fields) => {
|
||||||
const fieldMap = new Map<string, (typeof result.fields)[number]>();
|
const fieldMap = new Map<string, (typeof result.fields)[number]>();
|
||||||
const duplicateFields = new Set<string>();
|
const duplicateFields = new Set<string>();
|
||||||
|
|
||||||
result.fields.forEach((field) => {
|
allFields.forEach((field) => {
|
||||||
const key = `${field.name}`;
|
const key = `${field.name}`;
|
||||||
if (fieldMap.has(key)) {
|
if (fieldMap.has(key)) {
|
||||||
duplicateFields.add(field.name);
|
duplicateFields.add(field.name);
|
||||||
}
|
}
|
||||||
// 중복이면 마지막 값으로 덮어씀 (기존 동작 유지)
|
|
||||||
fieldMap.set(key, field);
|
fieldMap.set(key, field);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (duplicateFields.size > 0) {
|
if (duplicateFields.size > 0) {
|
||||||
console.warn(`⚠️ 중복 필드명 감지: ${Array.from(duplicateFields).join(", ")}`);
|
console.warn(`⚠️ 중복 필드명 감지: ${Array.from(duplicateFields).join(", ")}`);
|
||||||
console.warn(" → 마지막으로 발견된 필드만 표시됩니다.");
|
|
||||||
console.warn(" → 다중 소스 사용 시 필드명이 겹치지 않도록 주의하세요!");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const uniqueFields = Array.from(fieldMap.values());
|
const uniqueFields = Array.from(fieldMap.values());
|
||||||
|
|
||||||
setSourceFields(uniqueFields);
|
setSourceFields(uniqueFields);
|
||||||
setHasRestAPISource(result.hasRestAPI);
|
setHasRestAPISource(result.hasRestAPI);
|
||||||
console.log("✅ 최종 소스 필드 목록:", uniqueFields);
|
console.log("✅ 최종 소스 필드 목록:", uniqueFields);
|
||||||
console.log("✅ REST API 소스 연결:", result.hasRestAPI);
|
};
|
||||||
|
|
||||||
|
// 프로시저 노드에 OUT 파라미터가 저장되지 않은 경우, API로 동적 조회
|
||||||
|
if (procedureNodes.length > 0) {
|
||||||
|
console.log(`🔄 프로시저 ${procedureNodes.length}개의 반환 필드를 API로 조회`);
|
||||||
|
applyFields(result.fields);
|
||||||
|
|
||||||
|
Promise.all(
|
||||||
|
procedureNodes.map(async (pn) => {
|
||||||
|
try {
|
||||||
|
const res = await getFlowProcedureParameters(
|
||||||
|
pn.procedureName,
|
||||||
|
pn.dbSource,
|
||||||
|
pn.connectionId,
|
||||||
|
pn.schema
|
||||||
|
);
|
||||||
|
if (res.success && res.data) {
|
||||||
|
return res.data
|
||||||
|
.filter((p: any) => p.mode === "OUT" || p.mode === "INOUT")
|
||||||
|
.map((p: any) => ({
|
||||||
|
name: p.name,
|
||||||
|
label: `${p.name} (프로시저 반환)`,
|
||||||
|
sourcePath: pn.sourcePath,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("프로시저 파라미터 조회 실패:", e);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
})
|
||||||
|
).then((extraFieldArrays) => {
|
||||||
|
const extraFields = extraFieldArrays.flat();
|
||||||
|
if (extraFields.length > 0) {
|
||||||
|
console.log(`✅ 프로시저 반환 필드 ${extraFields.length}개 추가 발견`);
|
||||||
|
applyFields([...result.fields, ...extraFields]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
applyFields(result.fields);
|
||||||
|
}
|
||||||
}, [nodeId, nodes, edges]);
|
}, [nodeId, nodes, edges]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,641 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프로시저/함수 호출 노드 속성 편집
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from "react";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Database, Workflow, RefreshCw, Loader2 } from "lucide-react";
|
||||||
|
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||||
|
import {
|
||||||
|
getFlowProcedures,
|
||||||
|
getFlowProcedureParameters,
|
||||||
|
} from "@/lib/api/flow";
|
||||||
|
import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection";
|
||||||
|
import type { ProcedureCallActionNodeData } from "@/types/node-editor";
|
||||||
|
import type { ProcedureListItem, ProcedureParameterInfo } from "@/types/flowExternalDb";
|
||||||
|
|
||||||
|
interface ExternalConnection {
|
||||||
|
id: number;
|
||||||
|
connection_name: string;
|
||||||
|
db_type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProcedureCallActionPropertiesProps {
|
||||||
|
nodeId: string;
|
||||||
|
data: ProcedureCallActionNodeData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProcedureCallActionProperties({
|
||||||
|
nodeId,
|
||||||
|
data,
|
||||||
|
}: ProcedureCallActionPropertiesProps) {
|
||||||
|
const { updateNode, nodes, edges } = useFlowEditorStore();
|
||||||
|
|
||||||
|
const [displayName, setDisplayName] = useState(
|
||||||
|
data.displayName || "프로시저 호출"
|
||||||
|
);
|
||||||
|
const [dbSource, setDbSource] = useState<"internal" | "external">(
|
||||||
|
data.dbSource || "internal"
|
||||||
|
);
|
||||||
|
const [connectionId, setConnectionId] = useState<number | undefined>(
|
||||||
|
data.connectionId
|
||||||
|
);
|
||||||
|
const [procedureName, setProcedureName] = useState(
|
||||||
|
data.procedureName || ""
|
||||||
|
);
|
||||||
|
const [procedureSchema, setProcedureSchema] = useState(
|
||||||
|
data.procedureSchema || "public"
|
||||||
|
);
|
||||||
|
const [callType, setCallType] = useState<"procedure" | "function">(
|
||||||
|
data.callType || "function"
|
||||||
|
);
|
||||||
|
const [parameters, setParameters] = useState(data.parameters || []);
|
||||||
|
|
||||||
|
const [connections, setConnections] = useState<ExternalConnection[]>([]);
|
||||||
|
const [procedures, setProcedures] = useState<ProcedureListItem[]>([]);
|
||||||
|
const [loadingProcedures, setLoadingProcedures] = useState(false);
|
||||||
|
const [loadingParams, setLoadingParams] = useState(false);
|
||||||
|
const [sourceFields, setSourceFields] = useState<
|
||||||
|
Array<{ name: string; label?: string }>
|
||||||
|
>([]);
|
||||||
|
|
||||||
|
// 이전 노드에서 소스 필드 목록 수집 (재귀)
|
||||||
|
useEffect(() => {
|
||||||
|
const getUpstreamFields = (
|
||||||
|
targetId: string,
|
||||||
|
visited = new Set<string>()
|
||||||
|
): Array<{ name: string; label?: string }> => {
|
||||||
|
if (visited.has(targetId)) return [];
|
||||||
|
visited.add(targetId);
|
||||||
|
|
||||||
|
const inEdges = edges.filter((e) => e.target === targetId);
|
||||||
|
const parentNodes = nodes.filter((n) =>
|
||||||
|
inEdges.some((e) => e.source === n.id)
|
||||||
|
);
|
||||||
|
const fields: Array<{ name: string; label?: string }> = [];
|
||||||
|
|
||||||
|
for (const pNode of parentNodes) {
|
||||||
|
if (
|
||||||
|
pNode.type === "tableSource" ||
|
||||||
|
pNode.type === "externalDBSource"
|
||||||
|
) {
|
||||||
|
const nodeFields =
|
||||||
|
(pNode.data as any).fields ||
|
||||||
|
(pNode.data as any).outputFields ||
|
||||||
|
[];
|
||||||
|
if (Array.isArray(nodeFields)) {
|
||||||
|
for (const f of nodeFields) {
|
||||||
|
const name =
|
||||||
|
typeof f === "string"
|
||||||
|
? f
|
||||||
|
: f.name || f.columnName || f.field;
|
||||||
|
if (name) {
|
||||||
|
fields.push({
|
||||||
|
name,
|
||||||
|
label: f.label || f.columnLabel || name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (pNode.type === "dataTransform") {
|
||||||
|
const upper = getUpstreamFields(pNode.id, visited);
|
||||||
|
fields.push(...upper);
|
||||||
|
const transforms = (pNode.data as any).transformations;
|
||||||
|
if (Array.isArray(transforms)) {
|
||||||
|
for (const t of transforms) {
|
||||||
|
if (t.targetField) {
|
||||||
|
fields.push({
|
||||||
|
name: t.targetField,
|
||||||
|
label: t.targetFieldLabel || t.targetField,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (pNode.type === "formulaTransform") {
|
||||||
|
const upper = getUpstreamFields(pNode.id, visited);
|
||||||
|
fields.push(...upper);
|
||||||
|
const transforms = (pNode.data as any).transformations;
|
||||||
|
if (Array.isArray(transforms)) {
|
||||||
|
for (const t of transforms) {
|
||||||
|
if (t.outputField) {
|
||||||
|
fields.push({
|
||||||
|
name: t.outputField,
|
||||||
|
label: t.outputFieldLabel || t.outputField,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fields.push(...getUpstreamFields(pNode.id, visited));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fields;
|
||||||
|
};
|
||||||
|
|
||||||
|
const collected = getUpstreamFields(nodeId);
|
||||||
|
const unique = Array.from(
|
||||||
|
new Map(collected.map((f) => [f.name, f])).values()
|
||||||
|
);
|
||||||
|
setSourceFields(unique);
|
||||||
|
}, [nodeId, nodes, edges]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDisplayName(data.displayName || "프로시저 호출");
|
||||||
|
setDbSource(data.dbSource || "internal");
|
||||||
|
setConnectionId(data.connectionId);
|
||||||
|
setProcedureName(data.procedureName || "");
|
||||||
|
setProcedureSchema(data.procedureSchema || "public");
|
||||||
|
setCallType(data.callType || "function");
|
||||||
|
setParameters(data.parameters || []);
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
// 외부 DB 연결 목록 조회
|
||||||
|
useEffect(() => {
|
||||||
|
if (dbSource === "external") {
|
||||||
|
ExternalDbConnectionAPI.getConnections({ is_active: "true" })
|
||||||
|
.then((list) =>
|
||||||
|
setConnections(
|
||||||
|
list.map((c: any) => ({
|
||||||
|
id: c.id,
|
||||||
|
connection_name: c.connection_name,
|
||||||
|
db_type: c.db_type,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.catch(console.error);
|
||||||
|
}
|
||||||
|
}, [dbSource]);
|
||||||
|
|
||||||
|
const updateNodeData = useCallback(
|
||||||
|
(updates: Partial<ProcedureCallActionNodeData>) => {
|
||||||
|
updateNode(nodeId, { ...data, ...updates });
|
||||||
|
},
|
||||||
|
[nodeId, data, updateNode]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 프로시저 목록 조회
|
||||||
|
const fetchProcedures = useCallback(async () => {
|
||||||
|
if (dbSource === "external" && !connectionId) return;
|
||||||
|
setLoadingProcedures(true);
|
||||||
|
try {
|
||||||
|
const res = await getFlowProcedures(
|
||||||
|
dbSource,
|
||||||
|
connectionId,
|
||||||
|
procedureSchema || undefined
|
||||||
|
);
|
||||||
|
if (res.success && res.data) {
|
||||||
|
setProcedures(res.data);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("프로시저 목록 조회 실패:", e);
|
||||||
|
} finally {
|
||||||
|
setLoadingProcedures(false);
|
||||||
|
}
|
||||||
|
}, [dbSource, connectionId, procedureSchema]);
|
||||||
|
|
||||||
|
// dbSource/connectionId 변경 시 프로시저 목록 자동 조회
|
||||||
|
useEffect(() => {
|
||||||
|
if (dbSource === "internal" || (dbSource === "external" && connectionId)) {
|
||||||
|
fetchProcedures();
|
||||||
|
}
|
||||||
|
}, [dbSource, connectionId, fetchProcedures]);
|
||||||
|
|
||||||
|
// 프로시저 선택 시 파라미터 조회
|
||||||
|
const handleProcedureSelect = useCallback(
|
||||||
|
async (name: string) => {
|
||||||
|
setProcedureName(name);
|
||||||
|
|
||||||
|
const selected = procedures.find((p) => p.name === name);
|
||||||
|
const newCallType =
|
||||||
|
selected?.type === "PROCEDURE" ? "procedure" : "function";
|
||||||
|
setCallType(newCallType);
|
||||||
|
|
||||||
|
updateNodeData({
|
||||||
|
procedureName: name,
|
||||||
|
callType: newCallType,
|
||||||
|
procedureSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
setLoadingParams(true);
|
||||||
|
try {
|
||||||
|
const res = await getFlowProcedureParameters(
|
||||||
|
name,
|
||||||
|
dbSource,
|
||||||
|
connectionId,
|
||||||
|
procedureSchema || undefined
|
||||||
|
);
|
||||||
|
if (res.success && res.data) {
|
||||||
|
const newParams = res.data.map((p: ProcedureParameterInfo) => ({
|
||||||
|
name: p.name,
|
||||||
|
dataType: p.dataType,
|
||||||
|
mode: p.mode,
|
||||||
|
source: "record_field" as const,
|
||||||
|
field: "",
|
||||||
|
value: "",
|
||||||
|
}));
|
||||||
|
setParameters(newParams);
|
||||||
|
updateNodeData({
|
||||||
|
procedureName: name,
|
||||||
|
callType: newCallType,
|
||||||
|
procedureSchema,
|
||||||
|
parameters: newParams,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("파라미터 조회 실패:", e);
|
||||||
|
} finally {
|
||||||
|
setLoadingParams(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[dbSource, connectionId, procedureSchema, procedures, updateNodeData]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleParamChange = (
|
||||||
|
index: number,
|
||||||
|
field: string,
|
||||||
|
value: string
|
||||||
|
) => {
|
||||||
|
const newParams = [...parameters];
|
||||||
|
(newParams[index] as any)[field] = value;
|
||||||
|
setParameters(newParams);
|
||||||
|
updateNodeData({ parameters: newParams });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 p-4">
|
||||||
|
{/* 표시명 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">표시명</Label>
|
||||||
|
<Input
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => {
|
||||||
|
setDisplayName(e.target.value);
|
||||||
|
updateNodeData({ displayName: e.target.value });
|
||||||
|
}}
|
||||||
|
placeholder="프로시저 호출"
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* DB 소스 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">DB 소스</Label>
|
||||||
|
<Select
|
||||||
|
value={dbSource}
|
||||||
|
onValueChange={(v: "internal" | "external") => {
|
||||||
|
setDbSource(v);
|
||||||
|
setConnectionId(undefined);
|
||||||
|
setProcedureName("");
|
||||||
|
setParameters([]);
|
||||||
|
setProcedures([]);
|
||||||
|
updateNodeData({
|
||||||
|
dbSource: v,
|
||||||
|
connectionId: undefined,
|
||||||
|
connectionName: undefined,
|
||||||
|
procedureName: "",
|
||||||
|
parameters: [],
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="internal">내부 DB (PostgreSQL)</SelectItem>
|
||||||
|
<SelectItem value="external">외부 DB</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 외부 DB 연결 선택 */}
|
||||||
|
{dbSource === "external" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">외부 DB 연결</Label>
|
||||||
|
<Select
|
||||||
|
value={connectionId?.toString() || ""}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
const id = parseInt(v);
|
||||||
|
setConnectionId(id);
|
||||||
|
setProcedureName("");
|
||||||
|
setParameters([]);
|
||||||
|
const conn = connections.find((c) => c.id === id);
|
||||||
|
updateNodeData({
|
||||||
|
connectionId: id,
|
||||||
|
connectionName: conn?.connection_name,
|
||||||
|
procedureName: "",
|
||||||
|
parameters: [],
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-sm">
|
||||||
|
<SelectValue placeholder="연결 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{connections.map((c) => (
|
||||||
|
<SelectItem key={c.id} value={c.id.toString()}>
|
||||||
|
{c.connection_name} ({c.db_type})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 스키마 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">스키마</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={procedureSchema}
|
||||||
|
onChange={(e) => setProcedureSchema(e.target.value)}
|
||||||
|
onBlur={() => {
|
||||||
|
updateNodeData({ procedureSchema });
|
||||||
|
fetchProcedures();
|
||||||
|
}}
|
||||||
|
placeholder="public"
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 shrink-0 p-0"
|
||||||
|
onClick={fetchProcedures}
|
||||||
|
disabled={loadingProcedures}
|
||||||
|
>
|
||||||
|
{loadingProcedures ? (
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 프로시저 선택 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">프로시저/함수 선택</Label>
|
||||||
|
{loadingProcedures ? (
|
||||||
|
<div className="flex items-center gap-2 rounded border p-2 text-xs text-gray-500">
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
목록 조회 중...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Select
|
||||||
|
value={procedureName}
|
||||||
|
onValueChange={handleProcedureSelect}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-sm">
|
||||||
|
<SelectValue placeholder="프로시저 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{procedures.map((p) => (
|
||||||
|
<SelectItem key={`${p.schema}.${p.name}`} value={p.name}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`rounded px-1 py-0.5 text-[10px] font-medium ${
|
||||||
|
p.type === "FUNCTION"
|
||||||
|
? "bg-cyan-100 text-cyan-700"
|
||||||
|
: "bg-violet-100 text-violet-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{p.type === "FUNCTION" ? "FN" : "SP"}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-xs">{p.name}</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
{procedures.length === 0 && (
|
||||||
|
<SelectItem value="" disabled>
|
||||||
|
프로시저가 없습니다
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 호출 타입 */}
|
||||||
|
{procedureName && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">호출 타입</Label>
|
||||||
|
<Select
|
||||||
|
value={callType}
|
||||||
|
onValueChange={(v: "procedure" | "function") => {
|
||||||
|
setCallType(v);
|
||||||
|
updateNodeData({ callType: v });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="function">SELECT (함수)</SelectItem>
|
||||||
|
<SelectItem value="procedure">CALL (프로시저)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 파라미터 매핑 */}
|
||||||
|
{procedureName && parameters.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{loadingParams ? (
|
||||||
|
<div className="flex items-center gap-2 rounded border p-2 text-xs text-gray-500">
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
파라미터 조회 중...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* IN 파라미터 */}
|
||||||
|
{parameters.filter((p) => p.mode === "IN" || p.mode === "INOUT")
|
||||||
|
.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="flex items-center gap-1.5 text-xs font-medium">
|
||||||
|
<span className="rounded bg-blue-100 px-1.5 py-0.5 text-[10px] text-blue-700">
|
||||||
|
IN
|
||||||
|
</span>
|
||||||
|
입력 파라미터
|
||||||
|
</Label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{parameters.map((param, idx) => {
|
||||||
|
if (param.mode !== "IN" && param.mode !== "INOUT")
|
||||||
|
return null;
|
||||||
|
return (
|
||||||
|
<Card key={idx} className="bg-gray-50">
|
||||||
|
<CardContent className="space-y-2 p-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-mono text-xs font-medium">
|
||||||
|
{param.name}
|
||||||
|
</span>
|
||||||
|
<span className="rounded bg-gray-200 px-1.5 py-0.5 text-[10px]">
|
||||||
|
{param.dataType}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
value={param.source}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
handleParamChange(idx, "source", v)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="record_field">
|
||||||
|
레코드 필드
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="static">고정값</SelectItem>
|
||||||
|
<SelectItem value="step_variable">
|
||||||
|
스텝 변수
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{param.source === "record_field" &&
|
||||||
|
(sourceFields.length > 0 ? (
|
||||||
|
<Select
|
||||||
|
value={param.field || ""}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
handleParamChange(idx, "field", v)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue placeholder="필드 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{sourceFields.map((f) => (
|
||||||
|
<SelectItem
|
||||||
|
key={f.name}
|
||||||
|
value={f.name}
|
||||||
|
>
|
||||||
|
<span className="font-mono text-xs">
|
||||||
|
{f.name}
|
||||||
|
</span>
|
||||||
|
{f.label && f.label !== f.name && (
|
||||||
|
<span className="ml-1 text-[10px] text-gray-400">
|
||||||
|
({f.label})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
value={param.field || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleParamChange(
|
||||||
|
idx,
|
||||||
|
"field",
|
||||||
|
e.target.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
placeholder="컬럼명 (이전 노드를 먼저 연결하세요)"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{param.source === "static" && (
|
||||||
|
<Input
|
||||||
|
value={param.value || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleParamChange(
|
||||||
|
idx,
|
||||||
|
"value",
|
||||||
|
e.target.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
placeholder="고정값 입력"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{param.source === "step_variable" && (
|
||||||
|
<Input
|
||||||
|
value={param.field || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleParamChange(
|
||||||
|
idx,
|
||||||
|
"field",
|
||||||
|
e.target.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
placeholder="변수명"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* OUT 파라미터 (반환 필드) */}
|
||||||
|
{parameters.filter((p) => p.mode === "OUT" || p.mode === "INOUT")
|
||||||
|
.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="flex items-center gap-1.5 text-xs font-medium">
|
||||||
|
<span className="rounded bg-green-100 px-1.5 py-0.5 text-[10px] text-green-700">
|
||||||
|
OUT
|
||||||
|
</span>
|
||||||
|
반환 필드
|
||||||
|
<span className="text-[10px] font-normal text-gray-400">
|
||||||
|
(다음 노드에서 사용 가능)
|
||||||
|
</span>
|
||||||
|
</Label>
|
||||||
|
<div className="rounded-md border border-green-200 bg-green-50 p-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
{parameters
|
||||||
|
.filter(
|
||||||
|
(p) => p.mode === "OUT" || p.mode === "INOUT"
|
||||||
|
)
|
||||||
|
.map((param, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="flex items-center justify-between rounded bg-white px-2 py-1.5"
|
||||||
|
>
|
||||||
|
<span className="font-mono text-xs font-medium text-green-700">
|
||||||
|
{param.name}
|
||||||
|
</span>
|
||||||
|
<span className="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-500">
|
||||||
|
{param.dataType}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 안내 메시지 */}
|
||||||
|
<Card className="bg-violet-50">
|
||||||
|
<CardContent className="p-3 text-xs text-violet-700">
|
||||||
|
<div className="mb-1 flex items-center gap-1 font-medium">
|
||||||
|
<Workflow className="h-3 w-3" />
|
||||||
|
프로시저 실행 안내
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
이 노드에 연결된 이전 노드의 데이터가 프로시저의 입력 파라미터로
|
||||||
|
전달됩니다. 프로시저 실행이 실패하면 전체 트랜잭션이 롤백됩니다.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -17,6 +17,7 @@ import { cn } from "@/lib/utils";
|
||||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||||
import { tableTypeApi } from "@/lib/api/screen";
|
import { tableTypeApi } from "@/lib/api/screen";
|
||||||
import { getTestedExternalConnections, getExternalTables, getExternalColumns } from "@/lib/api/nodeExternalConnections";
|
import { getTestedExternalConnections, getExternalTables, getExternalColumns } from "@/lib/api/nodeExternalConnections";
|
||||||
|
import { getFlowProcedureParameters } from "@/lib/api/flow";
|
||||||
import type { UpdateActionNodeData } from "@/types/node-editor";
|
import type { UpdateActionNodeData } from "@/types/node-editor";
|
||||||
import type { ExternalConnection, ExternalTable, ExternalColumn } from "@/lib/api/nodeExternalConnections";
|
import type { ExternalConnection, ExternalTable, ExternalColumn } from "@/lib/api/nodeExternalConnections";
|
||||||
|
|
||||||
|
|
@ -165,6 +166,13 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
|
||||||
|
|
||||||
// 연결된 소스 노드에서 필드 가져오기 (재귀적으로 모든 상위 노드 탐색)
|
// 연결된 소스 노드에서 필드 가져오기 (재귀적으로 모든 상위 노드 탐색)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const procedureNodes: Array<{
|
||||||
|
procedureName: string;
|
||||||
|
dbSource: "internal" | "external";
|
||||||
|
connectionId?: number;
|
||||||
|
schema?: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
const getAllSourceFields = (
|
const getAllSourceFields = (
|
||||||
targetNodeId: string,
|
targetNodeId: string,
|
||||||
visitedNodes = new Set<string>(),
|
visitedNodes = new Set<string>(),
|
||||||
|
|
@ -310,7 +318,33 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
|
||||||
foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
|
foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 5️⃣ 통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색
|
// 5️⃣ 프로시저 호출 노드: 상위 필드 + OUT 파라미터 추가
|
||||||
|
else if (node.type === "procedureCallAction") {
|
||||||
|
const upperResult = getAllSourceFields(node.id, visitedNodes);
|
||||||
|
fields.push(...upperResult.fields);
|
||||||
|
foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
|
||||||
|
const nodeData = node.data as any;
|
||||||
|
const procParams = nodeData.parameters;
|
||||||
|
let hasOutParams = false;
|
||||||
|
if (Array.isArray(procParams)) {
|
||||||
|
for (const p of procParams) {
|
||||||
|
if (p.mode === "OUT" || p.mode === "INOUT") {
|
||||||
|
hasOutParams = true;
|
||||||
|
fields.push({ name: p.name, label: `${p.name} (프로시저 반환)` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!hasOutParams && nodeData.procedureName) {
|
||||||
|
procedureNodes.push({
|
||||||
|
procedureName: nodeData.procedureName,
|
||||||
|
dbSource: nodeData.dbSource || "internal",
|
||||||
|
connectionId: nodeData.connectionId,
|
||||||
|
schema: nodeData.procedureSchema || "public",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
fields.push({ name: "_procedureReturn", label: "프로시저 반환값" });
|
||||||
|
}
|
||||||
|
// 6️⃣ 통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색
|
||||||
else {
|
else {
|
||||||
const upperResult = getAllSourceFields(node.id, visitedNodes);
|
const upperResult = getAllSourceFields(node.id, visitedNodes);
|
||||||
fields.push(...upperResult.fields);
|
fields.push(...upperResult.fields);
|
||||||
|
|
@ -323,11 +357,33 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
|
||||||
|
|
||||||
const result = getAllSourceFields(nodeId);
|
const result = getAllSourceFields(nodeId);
|
||||||
|
|
||||||
// 중복 제거
|
const applyFields = (allFields: typeof result.fields) => {
|
||||||
const uniqueFields = Array.from(new Map(result.fields.map((field) => [field.name, field])).values());
|
const uniqueFields = Array.from(new Map(allFields.map((field) => [field.name, field])).values());
|
||||||
|
|
||||||
setSourceFields(uniqueFields);
|
setSourceFields(uniqueFields);
|
||||||
setHasRestAPISource(result.hasRestAPI);
|
setHasRestAPISource(result.hasRestAPI);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (procedureNodes.length > 0) {
|
||||||
|
applyFields(result.fields);
|
||||||
|
Promise.all(
|
||||||
|
procedureNodes.map(async (pn) => {
|
||||||
|
try {
|
||||||
|
const res = await getFlowProcedureParameters(pn.procedureName, pn.dbSource, pn.connectionId, pn.schema);
|
||||||
|
if (res.success && res.data) {
|
||||||
|
return res.data
|
||||||
|
.filter((p: any) => p.mode === "OUT" || p.mode === "INOUT")
|
||||||
|
.map((p: any) => ({ name: p.name, label: `${p.name} (프로시저 반환)` }));
|
||||||
|
}
|
||||||
|
} catch (e) { console.error("프로시저 파라미터 조회 실패:", e); }
|
||||||
|
return [];
|
||||||
|
})
|
||||||
|
).then((extraFieldArrays) => {
|
||||||
|
const extraFields = extraFieldArrays.flat();
|
||||||
|
if (extraFields.length > 0) applyFields([...result.fields, ...extraFields]);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
applyFields(result.fields);
|
||||||
|
}
|
||||||
}, [nodeId, nodes, edges]);
|
}, [nodeId, nodes, edges]);
|
||||||
|
|
||||||
const loadTables = async () => {
|
const loadTables = async () => {
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import { cn } from "@/lib/utils";
|
||||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||||
import { tableTypeApi } from "@/lib/api/screen";
|
import { tableTypeApi } from "@/lib/api/screen";
|
||||||
import { getTestedExternalConnections, getExternalTables, getExternalColumns } from "@/lib/api/nodeExternalConnections";
|
import { getTestedExternalConnections, getExternalTables, getExternalColumns } from "@/lib/api/nodeExternalConnections";
|
||||||
|
import { getFlowProcedureParameters } from "@/lib/api/flow";
|
||||||
import type { UpsertActionNodeData } from "@/types/node-editor";
|
import type { UpsertActionNodeData } from "@/types/node-editor";
|
||||||
import type { ExternalConnection, ExternalTable, ExternalColumn } from "@/lib/api/nodeExternalConnections";
|
import type { ExternalConnection, ExternalTable, ExternalColumn } from "@/lib/api/nodeExternalConnections";
|
||||||
|
|
||||||
|
|
@ -148,6 +149,13 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
||||||
|
|
||||||
// 연결된 소스 노드에서 필드 가져오기 (재귀적으로 모든 상위 노드 탐색)
|
// 연결된 소스 노드에서 필드 가져오기 (재귀적으로 모든 상위 노드 탐색)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const procedureNodes: Array<{
|
||||||
|
procedureName: string;
|
||||||
|
dbSource: "internal" | "external";
|
||||||
|
connectionId?: number;
|
||||||
|
schema?: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
const getAllSourceFields = (
|
const getAllSourceFields = (
|
||||||
targetNodeId: string,
|
targetNodeId: string,
|
||||||
visitedNodes = new Set<string>(),
|
visitedNodes = new Set<string>(),
|
||||||
|
|
@ -293,7 +301,33 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
||||||
foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
|
foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 5️⃣ 통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색
|
// 5️⃣ 프로시저 호출 노드: 상위 필드 + OUT 파라미터 추가
|
||||||
|
else if (node.type === "procedureCallAction") {
|
||||||
|
const upperResult = getAllSourceFields(node.id, visitedNodes);
|
||||||
|
fields.push(...upperResult.fields);
|
||||||
|
foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
|
||||||
|
const nodeData = node.data as any;
|
||||||
|
const procParams = nodeData.parameters;
|
||||||
|
let hasOutParams = false;
|
||||||
|
if (Array.isArray(procParams)) {
|
||||||
|
for (const p of procParams) {
|
||||||
|
if (p.mode === "OUT" || p.mode === "INOUT") {
|
||||||
|
hasOutParams = true;
|
||||||
|
fields.push({ name: p.name, label: `${p.name} (프로시저 반환)` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!hasOutParams && nodeData.procedureName) {
|
||||||
|
procedureNodes.push({
|
||||||
|
procedureName: nodeData.procedureName,
|
||||||
|
dbSource: nodeData.dbSource || "internal",
|
||||||
|
connectionId: nodeData.connectionId,
|
||||||
|
schema: nodeData.procedureSchema || "public",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
fields.push({ name: "_procedureReturn", label: "프로시저 반환값" });
|
||||||
|
}
|
||||||
|
// 6️⃣ 통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색
|
||||||
else {
|
else {
|
||||||
const upperResult = getAllSourceFields(node.id, visitedNodes);
|
const upperResult = getAllSourceFields(node.id, visitedNodes);
|
||||||
fields.push(...upperResult.fields);
|
fields.push(...upperResult.fields);
|
||||||
|
|
@ -306,11 +340,33 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
||||||
|
|
||||||
const result = getAllSourceFields(nodeId);
|
const result = getAllSourceFields(nodeId);
|
||||||
|
|
||||||
// 중복 제거
|
const applyFields = (allFields: typeof result.fields) => {
|
||||||
const uniqueFields = Array.from(new Map(result.fields.map((field) => [field.name, field])).values());
|
const uniqueFields = Array.from(new Map(allFields.map((field) => [field.name, field])).values());
|
||||||
|
|
||||||
setSourceFields(uniqueFields);
|
setSourceFields(uniqueFields);
|
||||||
setHasRestAPISource(result.hasRestAPI);
|
setHasRestAPISource(result.hasRestAPI);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (procedureNodes.length > 0) {
|
||||||
|
applyFields(result.fields);
|
||||||
|
Promise.all(
|
||||||
|
procedureNodes.map(async (pn) => {
|
||||||
|
try {
|
||||||
|
const res = await getFlowProcedureParameters(pn.procedureName, pn.dbSource, pn.connectionId, pn.schema);
|
||||||
|
if (res.success && res.data) {
|
||||||
|
return res.data
|
||||||
|
.filter((p: any) => p.mode === "OUT" || p.mode === "INOUT")
|
||||||
|
.map((p: any) => ({ name: p.name, label: `${p.name} (프로시저 반환)` }));
|
||||||
|
}
|
||||||
|
} catch (e) { console.error("프로시저 파라미터 조회 실패:", e); }
|
||||||
|
return [];
|
||||||
|
})
|
||||||
|
).then((extraFieldArrays) => {
|
||||||
|
const extraFields = extraFieldArrays.flat();
|
||||||
|
if (extraFields.length > 0) applyFields([...result.fields, ...extraFields]);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
applyFields(result.fields);
|
||||||
|
}
|
||||||
}, [nodeId, nodes, edges]);
|
}, [nodeId, nodes, edges]);
|
||||||
|
|
||||||
// 🔥 외부 커넥션 로딩 함수
|
// 🔥 외부 커넥션 로딩 함수
|
||||||
|
|
|
||||||
|
|
@ -132,6 +132,14 @@ export const NODE_PALETTE: NodePaletteItem[] = [
|
||||||
category: "external",
|
category: "external",
|
||||||
color: "#06B6D4", // 시안
|
color: "#06B6D4", // 시안
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: "procedureCallAction",
|
||||||
|
label: "프로시저 호출",
|
||||||
|
icon: "",
|
||||||
|
description: "DB 프로시저/함수를 호출합니다",
|
||||||
|
category: "external",
|
||||||
|
color: "#8B5CF6", // 보라색
|
||||||
|
},
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
// 유틸리티
|
// 유틸리티
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,11 @@ export const FlowNodeComponent = memo(({ data }: NodeProps<FlowNodeData>) => {
|
||||||
<Badge variant="outline" className="text-xs">
|
<Badge variant="outline" className="text-xs">
|
||||||
단계 {data.stepOrder}
|
단계 {data.stepOrder}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{data.integrationType === "procedure" && (
|
||||||
|
<Badge variant="secondary" className="text-[10px] font-mono">
|
||||||
|
SP
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-foreground mb-2 text-sm font-semibold">{data.label}</div>
|
<div className="text-foreground mb-2 text-sm font-semibold">{data.label}</div>
|
||||||
|
|
@ -75,6 +80,13 @@ export const FlowNodeComponent = memo(({ data }: NodeProps<FlowNodeData>) => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 프로시저 정보 */}
|
||||||
|
{data.integrationType === "procedure" && data.procedureName && (
|
||||||
|
<div className="bg-muted text-muted-foreground mb-2 flex items-center gap-1 rounded-md px-2 py-1 text-xs">
|
||||||
|
<span className="font-mono truncate">{data.procedureName}()</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 데이터 건수 */}
|
{/* 데이터 건수 */}
|
||||||
{data.count !== undefined && (
|
{data.count !== undefined && (
|
||||||
<Badge variant="secondary" className="mb-2 text-xs">
|
<Badge variant="secondary" className="mb-2 text-xs">
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { updateFlowStep, deleteFlowStep } from "@/lib/api/flow";
|
import { updateFlowStep, deleteFlowStep, getFlowProcedures, getFlowProcedureParameters } from "@/lib/api/flow";
|
||||||
import { FlowStep } from "@/types/flow";
|
import { FlowStep } from "@/types/flow";
|
||||||
import { FlowConditionBuilder } from "./FlowConditionBuilder";
|
import { FlowConditionBuilder } from "./FlowConditionBuilder";
|
||||||
import { tableManagementApi, getTableColumns } from "@/lib/api/tableManagement";
|
import { tableManagementApi, getTableColumns } from "@/lib/api/tableManagement";
|
||||||
|
|
@ -23,6 +23,10 @@ import { flowExternalDbApi } from "@/lib/api/flowExternalDb";
|
||||||
import {
|
import {
|
||||||
FlowExternalDbConnection,
|
FlowExternalDbConnection,
|
||||||
FlowExternalDbIntegrationConfig,
|
FlowExternalDbIntegrationConfig,
|
||||||
|
FlowProcedureConfig,
|
||||||
|
FlowProcedureParam,
|
||||||
|
ProcedureListItem,
|
||||||
|
ProcedureParameterInfo,
|
||||||
INTEGRATION_TYPE_OPTIONS,
|
INTEGRATION_TYPE_OPTIONS,
|
||||||
OPERATION_OPTIONS,
|
OPERATION_OPTIONS,
|
||||||
} from "@/types/flowExternalDb";
|
} from "@/types/flowExternalDb";
|
||||||
|
|
@ -118,6 +122,13 @@ export function FlowStepPanel({
|
||||||
const [availableColumns, setAvailableColumns] = useState<string[]>([]);
|
const [availableColumns, setAvailableColumns] = useState<string[]>([]);
|
||||||
const [loadingAvailableColumns, setLoadingAvailableColumns] = useState(false);
|
const [loadingAvailableColumns, setLoadingAvailableColumns] = useState(false);
|
||||||
|
|
||||||
|
// 프로시저 관련 상태
|
||||||
|
const [procedureList, setProcedureList] = useState<ProcedureListItem[]>([]);
|
||||||
|
const [loadingProcedures, setLoadingProcedures] = useState(false);
|
||||||
|
const [procedureParams, setProcedureParams] = useState<ProcedureParameterInfo[]>([]);
|
||||||
|
const [loadingProcedureParams, setLoadingProcedureParams] = useState(false);
|
||||||
|
const [openProcedureCombobox, setOpenProcedureCombobox] = useState(false);
|
||||||
|
|
||||||
// 테이블 목록 조회
|
// 테이블 목록 조회
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadTables = async () => {
|
const loadTables = async () => {
|
||||||
|
|
@ -943,7 +954,7 @@ export function FlowStepPanel({
|
||||||
<SelectItem
|
<SelectItem
|
||||||
key={opt.value}
|
key={opt.value}
|
||||||
value={opt.value}
|
value={opt.value}
|
||||||
disabled={opt.value !== "internal" && opt.value !== "external_db" && opt.value !== "rest_api"}
|
disabled={opt.value !== "internal" && opt.value !== "external_db" && opt.value !== "procedure" && opt.value !== "rest_api"}
|
||||||
>
|
>
|
||||||
{opt.label}
|
{opt.label}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|
@ -1262,6 +1273,370 @@ export function FlowStepPanel({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 프로시저/함수 호출 설정 */}
|
||||||
|
{formData.integrationType === "procedure" && (
|
||||||
|
<div className="space-y-4 rounded-lg border p-4">
|
||||||
|
{/* DB 소스 선택 */}
|
||||||
|
<div>
|
||||||
|
<Label>DB 소스</Label>
|
||||||
|
<Select
|
||||||
|
value={
|
||||||
|
(formData.integrationConfig as FlowProcedureConfig)?.dbSource || "internal"
|
||||||
|
}
|
||||||
|
onValueChange={async (value: "internal" | "external") => {
|
||||||
|
const newConfig: FlowProcedureConfig = {
|
||||||
|
type: "procedure",
|
||||||
|
dbSource: value,
|
||||||
|
connectionId: undefined,
|
||||||
|
procedureName: "",
|
||||||
|
procedureSchema: "public",
|
||||||
|
callType: "function",
|
||||||
|
parameters: [],
|
||||||
|
};
|
||||||
|
setFormData({ ...formData, integrationConfig: newConfig });
|
||||||
|
setProcedureList([]);
|
||||||
|
setProcedureParams([]);
|
||||||
|
|
||||||
|
if (value === "internal") {
|
||||||
|
setLoadingProcedures(true);
|
||||||
|
try {
|
||||||
|
const res = await getFlowProcedures("internal");
|
||||||
|
if (res.success && res.data) setProcedureList(res.data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("프로시저 목록 조회 실패:", e);
|
||||||
|
} finally {
|
||||||
|
setLoadingProcedures(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="internal">내부 DB</SelectItem>
|
||||||
|
<SelectItem value="external">외부 DB</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 외부 DB 연결 선택 */}
|
||||||
|
{(formData.integrationConfig as FlowProcedureConfig)?.dbSource === "external" && (
|
||||||
|
<div>
|
||||||
|
<Label>외부 DB 연결</Label>
|
||||||
|
{externalConnections.length === 0 ? (
|
||||||
|
<div className="rounded-md bg-yellow-50 p-3">
|
||||||
|
<p className="text-sm text-yellow-900">
|
||||||
|
등록된 외부 DB 연결이 없습니다. 먼저 외부 DB 연결을 추가해주세요.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Select
|
||||||
|
value={
|
||||||
|
(formData.integrationConfig as FlowProcedureConfig)?.connectionId?.toString() || ""
|
||||||
|
}
|
||||||
|
onValueChange={async (value) => {
|
||||||
|
const connId = parseInt(value);
|
||||||
|
const newConfig: FlowProcedureConfig = {
|
||||||
|
...(formData.integrationConfig as FlowProcedureConfig),
|
||||||
|
connectionId: connId,
|
||||||
|
procedureName: "",
|
||||||
|
parameters: [],
|
||||||
|
};
|
||||||
|
setFormData({ ...formData, integrationConfig: newConfig });
|
||||||
|
setProcedureParams([]);
|
||||||
|
|
||||||
|
setLoadingProcedures(true);
|
||||||
|
try {
|
||||||
|
const res = await getFlowProcedures("external", connId);
|
||||||
|
if (res.success && res.data) setProcedureList(res.data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("외부 프로시저 목록 조회 실패:", e);
|
||||||
|
} finally {
|
||||||
|
setLoadingProcedures(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="연결 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{externalConnections.map((conn) => (
|
||||||
|
<SelectItem key={conn.id} value={conn.id.toString()}>
|
||||||
|
{conn.name} ({conn.dbType})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 프로시저 선택 */}
|
||||||
|
{((formData.integrationConfig as FlowProcedureConfig)?.dbSource === "internal" ||
|
||||||
|
(formData.integrationConfig as FlowProcedureConfig)?.connectionId) && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<Label>프로시저/함수 선택</Label>
|
||||||
|
{loadingProcedures ? (
|
||||||
|
<div className="py-2 text-sm text-muted-foreground">로딩 중...</div>
|
||||||
|
) : (
|
||||||
|
<Popover open={openProcedureCombobox} onOpenChange={setOpenProcedureCombobox}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={openProcedureCombobox}
|
||||||
|
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||||
|
>
|
||||||
|
{(formData.integrationConfig as FlowProcedureConfig)?.procedureName || "프로시저 선택"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className="p-0"
|
||||||
|
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||||
|
align="start"
|
||||||
|
>
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="프로시저 검색..." className="text-xs sm:text-sm" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="text-xs sm:text-sm">
|
||||||
|
프로시저를 찾을 수 없습니다.
|
||||||
|
</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{procedureList.map((proc) => (
|
||||||
|
<CommandItem
|
||||||
|
key={`${proc.schema}.${proc.name}`}
|
||||||
|
value={proc.name}
|
||||||
|
onSelect={async () => {
|
||||||
|
const procConfig = formData.integrationConfig as FlowProcedureConfig;
|
||||||
|
const newConfig: FlowProcedureConfig = {
|
||||||
|
...procConfig,
|
||||||
|
procedureName: proc.name,
|
||||||
|
procedureSchema: proc.schema,
|
||||||
|
callType: proc.type === "PROCEDURE" ? "procedure" : "function",
|
||||||
|
parameters: [],
|
||||||
|
};
|
||||||
|
setFormData({ ...formData, integrationConfig: newConfig });
|
||||||
|
setOpenProcedureCombobox(false);
|
||||||
|
|
||||||
|
setLoadingProcedureParams(true);
|
||||||
|
try {
|
||||||
|
const res = await getFlowProcedureParameters(
|
||||||
|
proc.name,
|
||||||
|
procConfig.dbSource,
|
||||||
|
procConfig.connectionId,
|
||||||
|
proc.schema,
|
||||||
|
);
|
||||||
|
if (res.success && res.data) {
|
||||||
|
setProcedureParams(res.data);
|
||||||
|
const mappedParams: FlowProcedureParam[] = res.data.map((p) => ({
|
||||||
|
name: p.name,
|
||||||
|
dataType: p.dataType,
|
||||||
|
mode: p.mode,
|
||||||
|
source: "record_field" as const,
|
||||||
|
field: "",
|
||||||
|
value: "",
|
||||||
|
}));
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
integrationConfig: {
|
||||||
|
...(prev.integrationConfig as FlowProcedureConfig),
|
||||||
|
procedureName: proc.name,
|
||||||
|
procedureSchema: proc.schema,
|
||||||
|
callType: proc.type === "PROCEDURE" ? "procedure" : "function",
|
||||||
|
parameters: mappedParams,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("파라미터 조회 실패:", e);
|
||||||
|
} finally {
|
||||||
|
setLoadingProcedureParams(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="text-xs sm:text-sm"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
(formData.integrationConfig as FlowProcedureConfig)?.procedureName === proc.name
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{proc.name}</span>
|
||||||
|
<span className="text-[10px] text-gray-500">
|
||||||
|
{proc.type} | {proc.schema}
|
||||||
|
{proc.returnType ? ` | 반환: ${proc.returnType}` : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
|
{procedureList.length === 0 && !loadingProcedures && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="mt-2"
|
||||||
|
onClick={async () => {
|
||||||
|
const procConfig = formData.integrationConfig as FlowProcedureConfig;
|
||||||
|
setLoadingProcedures(true);
|
||||||
|
try {
|
||||||
|
const res = await getFlowProcedures(
|
||||||
|
procConfig.dbSource,
|
||||||
|
procConfig.connectionId,
|
||||||
|
);
|
||||||
|
if (res.success && res.data) setProcedureList(res.data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("프로시저 목록 조회 실패:", e);
|
||||||
|
} finally {
|
||||||
|
setLoadingProcedures(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
프로시저 목록 새로고침
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 호출 타입 */}
|
||||||
|
{(formData.integrationConfig as FlowProcedureConfig)?.procedureName && (
|
||||||
|
<div>
|
||||||
|
<Label>호출 타입</Label>
|
||||||
|
<Select
|
||||||
|
value={(formData.integrationConfig as FlowProcedureConfig)?.callType || "function"}
|
||||||
|
onValueChange={(value: "procedure" | "function") => {
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
integrationConfig: {
|
||||||
|
...(formData.integrationConfig as FlowProcedureConfig),
|
||||||
|
callType: value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="procedure">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">CALL (프로시저)</div>
|
||||||
|
<div className="text-xs text-gray-500">CALL procedure_name()</div>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="function">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">SELECT (함수)</div>
|
||||||
|
<div className="text-xs text-gray-500">SELECT function_name()</div>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 파라미터 매핑 테이블 */}
|
||||||
|
{(formData.integrationConfig as FlowProcedureConfig)?.procedureName && (
|
||||||
|
<div>
|
||||||
|
<Label>파라미터 매핑</Label>
|
||||||
|
{loadingProcedureParams ? (
|
||||||
|
<div className="py-2 text-sm text-muted-foreground">파라미터 로딩 중...</div>
|
||||||
|
) : (formData.integrationConfig as FlowProcedureConfig)?.parameters?.length === 0 ? (
|
||||||
|
<div className="rounded-md bg-gray-50 p-3">
|
||||||
|
<p className="text-sm text-gray-600">파라미터가 없는 프로시저입니다.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{(formData.integrationConfig as FlowProcedureConfig)?.parameters?.map((param, idx) => (
|
||||||
|
<div key={idx} className="rounded-md border p-3 space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">{param.name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{param.dataType} | {param.mode}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{param.mode !== "OUT" && (
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">소스</Label>
|
||||||
|
<Select
|
||||||
|
value={param.source}
|
||||||
|
onValueChange={(value: "record_field" | "static" | "step_variable") => {
|
||||||
|
const params = [...(formData.integrationConfig as FlowProcedureConfig).parameters];
|
||||||
|
params[idx] = { ...params[idx], source: value };
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
integrationConfig: {
|
||||||
|
...(formData.integrationConfig as FlowProcedureConfig),
|
||||||
|
parameters: params,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="record_field">레코드 필드</SelectItem>
|
||||||
|
<SelectItem value="static">고정값</SelectItem>
|
||||||
|
<SelectItem value="step_variable">스텝 변수</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">
|
||||||
|
{param.source === "static" ? "값" : "필드명"}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
className="h-8 text-xs"
|
||||||
|
placeholder={param.source === "static" ? "고정값 입력" : "컬럼명 입력"}
|
||||||
|
value={param.source === "static" ? param.value || "" : param.field || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const params = [...(formData.integrationConfig as FlowProcedureConfig).parameters];
|
||||||
|
if (param.source === "static") {
|
||||||
|
params[idx] = { ...params[idx], value: e.target.value };
|
||||||
|
} else {
|
||||||
|
params[idx] = { ...params[idx], field: e.target.value };
|
||||||
|
}
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
integrationConfig: {
|
||||||
|
...(formData.integrationConfig as FlowProcedureConfig),
|
||||||
|
parameters: params,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="rounded-md bg-blue-50 p-3">
|
||||||
|
<p className="text-sm text-blue-900">
|
||||||
|
프로시저는 데이터 이동 전에 실행됩니다.
|
||||||
|
<br />실패 시 데이터 이동도 함께 롤백됩니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -561,3 +561,61 @@ export async function updateFlowStepData(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 프로시저/함수 API
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
import type { ProcedureListItem, ProcedureParameterInfo } from "@/types/flowExternalDb";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프로시저/함수 목록 조회
|
||||||
|
*/
|
||||||
|
export async function getFlowProcedures(
|
||||||
|
dbSource: "internal" | "external",
|
||||||
|
connectionId?: number,
|
||||||
|
schema?: string,
|
||||||
|
): Promise<ApiResponse<ProcedureListItem[]>> {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ dbSource });
|
||||||
|
if (connectionId) params.set("connectionId", String(connectionId));
|
||||||
|
if (schema) params.set("schema", schema);
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE}/flow/procedures?${params.toString()}`, {
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error: any) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프로시저/함수 파라미터 조회
|
||||||
|
*/
|
||||||
|
export async function getFlowProcedureParameters(
|
||||||
|
name: string,
|
||||||
|
dbSource: "internal" | "external",
|
||||||
|
connectionId?: number,
|
||||||
|
schema?: string,
|
||||||
|
): Promise<ApiResponse<ProcedureParameterInfo[]>> {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ dbSource });
|
||||||
|
if (connectionId) params.set("connectionId", String(connectionId));
|
||||||
|
if (schema) params.set("schema", schema);
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`${API_BASE}/flow/procedures/${encodeURIComponent(name)}/parameters?${params.toString()}`,
|
||||||
|
{
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
credentials: "include",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error: any) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -269,6 +269,8 @@ export interface FlowNodeData {
|
||||||
tableName?: string;
|
tableName?: string;
|
||||||
count?: number;
|
count?: number;
|
||||||
condition?: FlowConditionGroup;
|
condition?: FlowConditionGroup;
|
||||||
|
integrationType?: string;
|
||||||
|
procedureName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FlowEdgeData {
|
export interface FlowEdgeData {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
|
|
||||||
// ==================== 연동 타입 ====================
|
// ==================== 연동 타입 ====================
|
||||||
|
|
||||||
export type FlowIntegrationType = "internal" | "external_db" | "rest_api" | "webhook" | "hybrid";
|
export type FlowIntegrationType = "internal" | "external_db" | "procedure" | "rest_api" | "webhook" | "hybrid";
|
||||||
|
|
||||||
// ==================== 외부 DB 연결 ====================
|
// ==================== 외부 DB 연결 ====================
|
||||||
|
|
||||||
|
|
@ -66,8 +66,48 @@ export interface FlowExternalDbIntegrationConfig {
|
||||||
customQuery?: string; // 커스텀 쿼리
|
customQuery?: string; // 커스텀 쿼리
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 프로시저 호출 파라미터 정의
|
||||||
|
export interface FlowProcedureParam {
|
||||||
|
name: string;
|
||||||
|
dataType: string;
|
||||||
|
mode: "IN" | "OUT" | "INOUT";
|
||||||
|
source: "record_field" | "static" | "step_variable";
|
||||||
|
field?: string;
|
||||||
|
value?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 프로시저 호출 설정
|
||||||
|
export interface FlowProcedureConfig {
|
||||||
|
type: "procedure";
|
||||||
|
dbSource: "internal" | "external";
|
||||||
|
connectionId?: number;
|
||||||
|
procedureName: string;
|
||||||
|
procedureSchema?: string;
|
||||||
|
callType: "procedure" | "function";
|
||||||
|
parameters: FlowProcedureParam[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 프로시저/함수 목록 항목
|
||||||
|
export interface ProcedureListItem {
|
||||||
|
name: string;
|
||||||
|
schema: string;
|
||||||
|
type: "PROCEDURE" | "FUNCTION";
|
||||||
|
returnType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 프로시저 파라미터 정보
|
||||||
|
export interface ProcedureParameterInfo {
|
||||||
|
name: string;
|
||||||
|
position: number;
|
||||||
|
dataType: string;
|
||||||
|
mode: "IN" | "OUT" | "INOUT";
|
||||||
|
defaultValue?: string;
|
||||||
|
}
|
||||||
|
|
||||||
// 연동 설정 통합 타입
|
// 연동 설정 통합 타입
|
||||||
export type FlowIntegrationConfig = FlowExternalDbIntegrationConfig;
|
export type FlowIntegrationConfig =
|
||||||
|
| FlowExternalDbIntegrationConfig
|
||||||
|
| FlowProcedureConfig;
|
||||||
|
|
||||||
// ==================== 연동 로그 ====================
|
// ==================== 연동 로그 ====================
|
||||||
|
|
||||||
|
|
@ -126,6 +166,7 @@ export const OPERATION_OPTIONS = [
|
||||||
export const INTEGRATION_TYPE_OPTIONS = [
|
export const INTEGRATION_TYPE_OPTIONS = [
|
||||||
{ value: "internal", label: "내부 DB (기본)" },
|
{ value: "internal", label: "내부 DB (기본)" },
|
||||||
{ value: "external_db", label: "외부 DB 연동" },
|
{ value: "external_db", label: "외부 DB 연동" },
|
||||||
|
{ value: "procedure", label: "프로시저/함수 호출" },
|
||||||
{ value: "rest_api", label: "REST API 연동" },
|
{ value: "rest_api", label: "REST API 연동" },
|
||||||
{ value: "webhook", label: "Webhook (추후 지원)" },
|
{ value: "webhook", label: "Webhook (추후 지원)" },
|
||||||
{ value: "hybrid", label: "복합 연동 (추후 지원)" },
|
{ value: "hybrid", label: "복합 연동 (추후 지원)" },
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ export type NodeType =
|
||||||
| "emailAction" // 메일 발송 액션
|
| "emailAction" // 메일 발송 액션
|
||||||
| "scriptAction" // 스크립트 실행 액션
|
| "scriptAction" // 스크립트 실행 액션
|
||||||
| "httpRequestAction" // HTTP 요청 액션
|
| "httpRequestAction" // HTTP 요청 액션
|
||||||
|
| "procedureCallAction" // 프로시저/함수 호출 액션
|
||||||
| "comment" // 주석
|
| "comment" // 주석
|
||||||
| "log"; // 로그
|
| "log"; // 로그
|
||||||
|
|
||||||
|
|
@ -705,6 +706,31 @@ export interface HttpRequestActionNodeData {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 프로시저/함수 호출 노드
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface ProcedureCallActionNodeData {
|
||||||
|
displayName?: string;
|
||||||
|
|
||||||
|
dbSource: "internal" | "external";
|
||||||
|
connectionId?: number;
|
||||||
|
connectionName?: string;
|
||||||
|
|
||||||
|
procedureName?: string;
|
||||||
|
procedureSchema?: string;
|
||||||
|
callType: "procedure" | "function";
|
||||||
|
|
||||||
|
parameters?: {
|
||||||
|
name: string;
|
||||||
|
dataType: string;
|
||||||
|
mode: "IN" | "OUT" | "INOUT";
|
||||||
|
source: "record_field" | "static" | "step_variable";
|
||||||
|
field?: string;
|
||||||
|
value?: string;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// 통합 노드 데이터 타입
|
// 통합 노드 데이터 타입
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -725,6 +751,7 @@ export type NodeData =
|
||||||
| EmailActionNodeData
|
| EmailActionNodeData
|
||||||
| ScriptActionNodeData
|
| ScriptActionNodeData
|
||||||
| HttpRequestActionNodeData
|
| HttpRequestActionNodeData
|
||||||
|
| ProcedureCallActionNodeData
|
||||||
| CommentNodeData
|
| CommentNodeData
|
||||||
| LogNodeData;
|
| LogNodeData;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue