Merge pull request '데이터 저장 구현' (#29) from dataflowMng into dev
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/29
This commit is contained in:
commit
8817eb685e
|
|
@ -5359,6 +5359,12 @@ model dataflow_diagrams {
|
|||
diagram_name String @db.VarChar(255)
|
||||
relationships Json // 모든 관계 정보를 JSON으로 저장
|
||||
node_positions Json? // 테이블 노드의 캔버스 위치 정보 (JSON 형태)
|
||||
|
||||
// 조건부 연결 관련 컬럼들
|
||||
control Json? // 조건 설정 (트리거 타입, 조건 트리)
|
||||
category Json? // 연결 종류 배열 (["simple-key", "data-save", "external-call"])
|
||||
plan Json? // 실행 계획 (대상 액션들)
|
||||
|
||||
company_code String @db.VarChar(50)
|
||||
created_at DateTime? @default(now()) @db.Timestamp(6)
|
||||
updated_at DateTime? @default(now()) @updatedAt @db.Timestamp(6)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,146 @@
|
|||
import { Request, Response } from "express";
|
||||
import { logger } from "../utils/logger";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { ApiResponse } from "../types/common";
|
||||
import { EventTriggerService } from "../services/eventTriggerService";
|
||||
|
||||
/**
|
||||
* 조건부 연결 조건 테스트
|
||||
*/
|
||||
export async function testConditionalConnection(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
logger.info("=== 조건부 연결 조건 테스트 시작 ===");
|
||||
|
||||
const { diagramId } = req.params;
|
||||
const { testData } = req.body;
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
if (!companyCode) {
|
||||
const response: ApiResponse<null> = {
|
||||
success: false,
|
||||
message: "회사 코드가 필요합니다.",
|
||||
error: {
|
||||
code: "MISSING_COMPANY_CODE",
|
||||
details: "인증된 사용자의 회사 코드를 찾을 수 없습니다.",
|
||||
},
|
||||
};
|
||||
res.status(400).json(response);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!diagramId || !testData) {
|
||||
const response: ApiResponse<null> = {
|
||||
success: false,
|
||||
message: "다이어그램 ID와 테스트 데이터가 필요합니다.",
|
||||
error: {
|
||||
code: "MISSING_REQUIRED_FIELDS",
|
||||
details: "diagramId와 testData가 필요합니다.",
|
||||
},
|
||||
};
|
||||
res.status(400).json(response);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await EventTriggerService.testConditionalConnection(
|
||||
parseInt(diagramId),
|
||||
testData,
|
||||
companyCode
|
||||
);
|
||||
|
||||
const response: ApiResponse<any> = {
|
||||
success: true,
|
||||
message: "조건부 연결 테스트를 성공적으로 완료했습니다.",
|
||||
data: result,
|
||||
};
|
||||
|
||||
res.status(200).json(response);
|
||||
} catch (error) {
|
||||
logger.error("조건부 연결 테스트 실패:", error);
|
||||
const response: ApiResponse<null> = {
|
||||
success: false,
|
||||
message: "조건부 연결 테스트에 실패했습니다.",
|
||||
error: {
|
||||
code: "CONDITIONAL_CONNECTION_TEST_FAILED",
|
||||
details:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "알 수 없는 오류가 발생했습니다.",
|
||||
},
|
||||
};
|
||||
res.status(500).json(response);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 조건부 연결 액션 수동 실행
|
||||
*/
|
||||
export async function executeConditionalActions(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
logger.info("=== 조건부 연결 액션 수동 실행 시작 ===");
|
||||
|
||||
const { diagramId } = req.params;
|
||||
const { triggerType, tableName, data } = req.body;
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
if (!companyCode) {
|
||||
const response: ApiResponse<null> = {
|
||||
success: false,
|
||||
message: "회사 코드가 필요합니다.",
|
||||
error: {
|
||||
code: "MISSING_COMPANY_CODE",
|
||||
details: "인증된 사용자의 회사 코드를 찾을 수 없습니다.",
|
||||
},
|
||||
};
|
||||
res.status(400).json(response);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!diagramId || !triggerType || !tableName || !data) {
|
||||
const response: ApiResponse<null> = {
|
||||
success: false,
|
||||
message: "필수 필드가 누락되었습니다.",
|
||||
error: {
|
||||
code: "MISSING_REQUIRED_FIELDS",
|
||||
details: "diagramId, triggerType, tableName, data가 모두 필요합니다.",
|
||||
},
|
||||
};
|
||||
res.status(400).json(response);
|
||||
return;
|
||||
}
|
||||
|
||||
const results = await EventTriggerService.executeEventTriggers(
|
||||
triggerType,
|
||||
tableName,
|
||||
data,
|
||||
companyCode
|
||||
);
|
||||
|
||||
const response: ApiResponse<any[]> = {
|
||||
success: true,
|
||||
message: "조건부 연결 액션을 성공적으로 실행했습니다.",
|
||||
data: results,
|
||||
};
|
||||
|
||||
res.status(200).json(response);
|
||||
} catch (error) {
|
||||
logger.error("조건부 연결 액션 실행 실패:", error);
|
||||
const response: ApiResponse<null> = {
|
||||
success: false,
|
||||
message: "조건부 연결 액션 실행에 실패했습니다.",
|
||||
error: {
|
||||
code: "CONDITIONAL_ACTION_EXECUTION_FAILED",
|
||||
details:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "알 수 없는 오류가 발생했습니다.",
|
||||
},
|
||||
};
|
||||
res.status(500).json(response);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import { logger } from "../utils/logger";
|
|||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { ApiResponse } from "../types/common";
|
||||
import { DataflowService } from "../services/dataflowService";
|
||||
import { EventTriggerService } from "../services/eventTriggerService";
|
||||
|
||||
/**
|
||||
* 테이블 관계 생성
|
||||
|
|
|
|||
|
|
@ -93,10 +93,20 @@ export const createDataflowDiagram = async (req: Request, res: Response) => {
|
|||
diagram_name,
|
||||
relationships,
|
||||
node_positions,
|
||||
category,
|
||||
control,
|
||||
plan,
|
||||
company_code,
|
||||
created_by,
|
||||
updated_by,
|
||||
} = req.body;
|
||||
|
||||
logger.info(`새 관계도 생성 요청:`, { diagram_name, company_code });
|
||||
logger.info(`node_positions:`, node_positions);
|
||||
logger.info(`category:`, category);
|
||||
logger.info(`control:`, control);
|
||||
logger.info(`plan:`, plan);
|
||||
logger.info(`전체 요청 Body:`, JSON.stringify(req.body, null, 2));
|
||||
const companyCode =
|
||||
company_code ||
|
||||
(req.query.companyCode as string) ||
|
||||
|
|
@ -115,10 +125,27 @@ export const createDataflowDiagram = async (req: Request, res: Response) => {
|
|||
});
|
||||
}
|
||||
|
||||
// 🔍 백엔드에서 받은 실제 데이터 로깅
|
||||
console.log(
|
||||
"🔍 백엔드에서 받은 control 데이터:",
|
||||
JSON.stringify(control, null, 2)
|
||||
);
|
||||
console.log(
|
||||
"🔍 백엔드에서 받은 plan 데이터:",
|
||||
JSON.stringify(plan, null, 2)
|
||||
);
|
||||
console.log(
|
||||
"🔍 백엔드에서 받은 category 데이터:",
|
||||
JSON.stringify(category, null, 2)
|
||||
);
|
||||
|
||||
const newDiagram = await createDataflowDiagramService({
|
||||
diagram_name,
|
||||
relationships,
|
||||
node_positions,
|
||||
category,
|
||||
control,
|
||||
plan,
|
||||
company_code: companyCode,
|
||||
created_by: userId,
|
||||
updated_by: userId,
|
||||
|
|
@ -162,6 +189,14 @@ export const updateDataflowDiagram = async (req: Request, res: Response) => {
|
|||
const userId =
|
||||
updated_by || (req.headers["x-user-id"] as string) || "SYSTEM";
|
||||
|
||||
logger.info(`관계도 수정 요청 - ID: ${diagramId}, Company: ${companyCode}`);
|
||||
logger.info(`요청 Body:`, JSON.stringify(req.body, null, 2));
|
||||
logger.info(`node_positions:`, req.body.node_positions);
|
||||
logger.info(`요청 Body 키들:`, Object.keys(req.body));
|
||||
logger.info(`요청 Body 타입:`, typeof req.body);
|
||||
logger.info(`node_positions 타입:`, typeof req.body.node_positions);
|
||||
logger.info(`node_positions 값:`, req.body.node_positions);
|
||||
|
||||
if (isNaN(diagramId)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
|
|
|
|||
|
|
@ -17,6 +17,10 @@ import {
|
|||
copyDiagram,
|
||||
deleteDiagram,
|
||||
} from "../controllers/dataflowController";
|
||||
import {
|
||||
testConditionalConnection,
|
||||
executeConditionalActions,
|
||||
} from "../controllers/conditionalConnectionController";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
|
|
@ -128,4 +132,18 @@ router.get(
|
|||
getDiagramRelationshipsByRelationshipId
|
||||
);
|
||||
|
||||
// ==================== 조건부 연결 관리 라우트 ====================
|
||||
|
||||
/**
|
||||
* 조건부 연결 조건 테스트
|
||||
* POST /api/dataflow/diagrams/:diagramId/test-conditions
|
||||
*/
|
||||
router.post("/diagrams/:diagramId/test-conditions", testConditionalConnection);
|
||||
|
||||
/**
|
||||
* 조건부 연결 액션 수동 실행
|
||||
* POST /api/dataflow/diagrams/:diagramId/execute-actions
|
||||
*/
|
||||
router.post("/diagrams/:diagramId/execute-actions", executeConditionalActions);
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { PrismaClient } from "@prisma/client";
|
||||
import { PrismaClient, Prisma } from "@prisma/client";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
|
@ -6,8 +6,14 @@ const prisma = new PrismaClient();
|
|||
// 타입 정의
|
||||
interface CreateDataflowDiagramData {
|
||||
diagram_name: string;
|
||||
relationships: any; // JSON 데이터
|
||||
node_positions?: any; // JSON 데이터 (노드 위치 정보)
|
||||
relationships: Record<string, unknown>; // JSON 데이터
|
||||
node_positions?: Record<string, unknown> | null; // JSON 데이터 (노드 위치 정보)
|
||||
|
||||
// 🔥 수정: 배열 구조로 변경된 조건부 연결 관련 필드
|
||||
control?: Array<Record<string, unknown>> | null; // JSON 배열 (각 관계별 조건 설정)
|
||||
category?: Array<Record<string, unknown>> | null; // JSON 배열 (각 관계별 연결 종류)
|
||||
plan?: Array<Record<string, unknown>> | null; // JSON 배열 (각 관계별 실행 계획)
|
||||
|
||||
company_code: string;
|
||||
created_by: string;
|
||||
updated_by: string;
|
||||
|
|
@ -15,8 +21,14 @@ interface CreateDataflowDiagramData {
|
|||
|
||||
interface UpdateDataflowDiagramData {
|
||||
diagram_name?: string;
|
||||
relationships?: any; // JSON 데이터
|
||||
node_positions?: any; // JSON 데이터 (노드 위치 정보)
|
||||
relationships?: Record<string, unknown>; // JSON 데이터
|
||||
node_positions?: Record<string, unknown> | null; // JSON 데이터 (노드 위치 정보)
|
||||
|
||||
// 🔥 수정: 배열 구조로 변경된 조건부 연결 관련 필드
|
||||
control?: Array<Record<string, unknown>> | null; // JSON 배열 (각 관계별 조건 설정)
|
||||
category?: Array<Record<string, unknown>> | null; // JSON 배열 (각 관계별 연결 종류)
|
||||
plan?: Array<Record<string, unknown>> | null; // JSON 배열 (각 관계별 실행 계획)
|
||||
|
||||
updated_by: string;
|
||||
}
|
||||
|
||||
|
|
@ -33,7 +45,13 @@ export const getDataflowDiagrams = async (
|
|||
const offset = (page - 1) * size;
|
||||
|
||||
// 검색 조건 구성
|
||||
const whereClause: any = {};
|
||||
const whereClause: {
|
||||
company_code?: string;
|
||||
diagram_name?: {
|
||||
contains: string;
|
||||
mode: "insensitive";
|
||||
};
|
||||
} = {};
|
||||
|
||||
// company_code가 '*'가 아닌 경우에만 필터링
|
||||
if (companyCode !== "*") {
|
||||
|
|
@ -87,7 +105,10 @@ export const getDataflowDiagramById = async (
|
|||
companyCode: string
|
||||
) => {
|
||||
try {
|
||||
const whereClause: any = {
|
||||
const whereClause: {
|
||||
diagram_id: number;
|
||||
company_code?: string;
|
||||
} = {
|
||||
diagram_id: diagramId,
|
||||
};
|
||||
|
||||
|
|
@ -117,8 +138,15 @@ export const createDataflowDiagram = async (
|
|||
const newDiagram = await prisma.dataflow_diagrams.create({
|
||||
data: {
|
||||
diagram_name: data.diagram_name,
|
||||
relationships: data.relationships,
|
||||
node_positions: data.node_positions || null,
|
||||
relationships: data.relationships as Prisma.InputJsonValue,
|
||||
node_positions: data.node_positions as
|
||||
| Prisma.InputJsonValue
|
||||
| undefined,
|
||||
category: data.category
|
||||
? (data.category as Prisma.InputJsonValue)
|
||||
: undefined,
|
||||
control: data.control as Prisma.InputJsonValue | undefined,
|
||||
plan: data.plan as Prisma.InputJsonValue | undefined,
|
||||
company_code: data.company_code,
|
||||
created_by: data.created_by,
|
||||
updated_by: data.updated_by,
|
||||
|
|
@ -141,8 +169,15 @@ export const updateDataflowDiagram = async (
|
|||
companyCode: string
|
||||
) => {
|
||||
try {
|
||||
logger.info(
|
||||
`관계도 수정 서비스 시작 - ID: ${diagramId}, Company: ${companyCode}`
|
||||
);
|
||||
|
||||
// 먼저 해당 관계도가 존재하는지 확인
|
||||
const whereClause: any = {
|
||||
const whereClause: {
|
||||
diagram_id: number;
|
||||
company_code?: string;
|
||||
} = {
|
||||
diagram_id: diagramId,
|
||||
};
|
||||
|
||||
|
|
@ -155,7 +190,15 @@ export const updateDataflowDiagram = async (
|
|||
where: whereClause,
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`기존 관계도 조회 결과:`,
|
||||
existingDiagram ? `ID ${existingDiagram.diagram_id} 발견` : "관계도 없음"
|
||||
);
|
||||
|
||||
if (!existingDiagram) {
|
||||
logger.warn(
|
||||
`관계도 ID ${diagramId}를 찾을 수 없음 - Company: ${companyCode}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -166,9 +209,24 @@ export const updateDataflowDiagram = async (
|
|||
},
|
||||
data: {
|
||||
...(data.diagram_name && { diagram_name: data.diagram_name }),
|
||||
...(data.relationships && { relationships: data.relationships }),
|
||||
...(data.relationships && {
|
||||
relationships: data.relationships as Prisma.InputJsonValue,
|
||||
}),
|
||||
...(data.node_positions !== undefined && {
|
||||
node_positions: data.node_positions,
|
||||
node_positions: data.node_positions
|
||||
? (data.node_positions as Prisma.InputJsonValue)
|
||||
: Prisma.JsonNull,
|
||||
}),
|
||||
...(data.category !== undefined && {
|
||||
category: data.category
|
||||
? (data.category as Prisma.InputJsonValue)
|
||||
: undefined,
|
||||
}),
|
||||
...(data.control !== undefined && {
|
||||
control: data.control as Prisma.InputJsonValue | undefined,
|
||||
}),
|
||||
...(data.plan !== undefined && {
|
||||
plan: data.plan as Prisma.InputJsonValue | undefined,
|
||||
}),
|
||||
updated_by: data.updated_by,
|
||||
updated_at: new Date(),
|
||||
|
|
@ -191,7 +249,10 @@ export const deleteDataflowDiagram = async (
|
|||
) => {
|
||||
try {
|
||||
// 먼저 해당 관계도가 존재하는지 확인
|
||||
const whereClause: any = {
|
||||
const whereClause: {
|
||||
diagram_id: number;
|
||||
company_code?: string;
|
||||
} = {
|
||||
diagram_id: diagramId,
|
||||
};
|
||||
|
||||
|
|
@ -233,7 +294,10 @@ export const copyDataflowDiagram = async (
|
|||
) => {
|
||||
try {
|
||||
// 원본 관계도 조회
|
||||
const whereClause: any = {
|
||||
const whereClause: {
|
||||
diagram_id: number;
|
||||
company_code?: string;
|
||||
} = {
|
||||
diagram_id: diagramId,
|
||||
};
|
||||
|
||||
|
|
@ -262,7 +326,12 @@ export const copyDataflowDiagram = async (
|
|||
: originalDiagram.diagram_name;
|
||||
|
||||
// 같은 패턴의 이름들을 찾아서 가장 큰 번호 찾기
|
||||
const copyWhereClause: any = {
|
||||
const copyWhereClause: {
|
||||
diagram_name: {
|
||||
startsWith: string;
|
||||
};
|
||||
company_code?: string;
|
||||
} = {
|
||||
diagram_name: {
|
||||
startsWith: baseName,
|
||||
},
|
||||
|
|
@ -298,7 +367,11 @@ export const copyDataflowDiagram = async (
|
|||
const copiedDiagram = await prisma.dataflow_diagrams.create({
|
||||
data: {
|
||||
diagram_name: copyName,
|
||||
relationships: originalDiagram.relationships as any,
|
||||
relationships: originalDiagram.relationships as Prisma.InputJsonValue,
|
||||
node_positions: originalDiagram.node_positions
|
||||
? (originalDiagram.node_positions as Prisma.InputJsonValue)
|
||||
: Prisma.JsonNull,
|
||||
category: originalDiagram.category || undefined,
|
||||
company_code: companyCode,
|
||||
created_by: userId,
|
||||
updated_by: userId,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import prisma from "../config/database";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { EventTriggerService } from "./eventTriggerService";
|
||||
|
||||
export interface FormDataResult {
|
||||
id: number;
|
||||
|
|
@ -247,6 +248,22 @@ export class DynamicFormService {
|
|||
// 결과를 표준 형식으로 변환
|
||||
const insertedRecord = Array.isArray(result) ? result[0] : result;
|
||||
|
||||
// 🔥 조건부 연결 실행 (INSERT 트리거)
|
||||
try {
|
||||
if (company_code) {
|
||||
await EventTriggerService.executeEventTriggers(
|
||||
"insert",
|
||||
tableName,
|
||||
insertedRecord as Record<string, any>,
|
||||
company_code
|
||||
);
|
||||
console.log("🚀 조건부 연결 트리거 실행 완료 (INSERT)");
|
||||
}
|
||||
} catch (triggerError) {
|
||||
console.error("⚠️ 조건부 연결 트리거 실행 오류:", triggerError);
|
||||
// 트리거 오류는 로그만 남기고 메인 저장 프로세스는 계속 진행
|
||||
}
|
||||
|
||||
return {
|
||||
id: insertedRecord.id || insertedRecord.objid || 0,
|
||||
screenId: screenId,
|
||||
|
|
@ -343,6 +360,22 @@ export class DynamicFormService {
|
|||
|
||||
const updatedRecord = Array.isArray(result) ? result[0] : result;
|
||||
|
||||
// 🔥 조건부 연결 실행 (UPDATE 트리거)
|
||||
try {
|
||||
if (company_code) {
|
||||
await EventTriggerService.executeEventTriggers(
|
||||
"update",
|
||||
tableName,
|
||||
updatedRecord as Record<string, any>,
|
||||
company_code
|
||||
);
|
||||
console.log("🚀 조건부 연결 트리거 실행 완료 (UPDATE)");
|
||||
}
|
||||
} catch (triggerError) {
|
||||
console.error("⚠️ 조건부 연결 트리거 실행 오류:", triggerError);
|
||||
// 트리거 오류는 로그만 남기고 메인 업데이트 프로세스는 계속 진행
|
||||
}
|
||||
|
||||
return {
|
||||
id: updatedRecord.id || updatedRecord.objid || id,
|
||||
screenId: 0, // 실제 테이블에는 screenId가 없으므로 0으로 설정
|
||||
|
|
@ -362,7 +395,11 @@ export class DynamicFormService {
|
|||
/**
|
||||
* 폼 데이터 삭제 (실제 테이블에서 직접 삭제)
|
||||
*/
|
||||
async deleteFormData(id: number, tableName: string): Promise<void> {
|
||||
async deleteFormData(
|
||||
id: number,
|
||||
tableName: string,
|
||||
companyCode?: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
console.log("🗑️ 서비스: 실제 테이블에서 폼 데이터 삭제 시작:", {
|
||||
id,
|
||||
|
|
@ -382,6 +419,28 @@ export class DynamicFormService {
|
|||
const result = await prisma.$queryRawUnsafe(deleteQuery, id);
|
||||
|
||||
console.log("✅ 서비스: 실제 테이블 삭제 성공:", result);
|
||||
|
||||
// 🔥 조건부 연결 실행 (DELETE 트리거)
|
||||
try {
|
||||
if (
|
||||
companyCode &&
|
||||
result &&
|
||||
Array.isArray(result) &&
|
||||
result.length > 0
|
||||
) {
|
||||
const deletedRecord = result[0] as Record<string, any>;
|
||||
await EventTriggerService.executeEventTriggers(
|
||||
"delete",
|
||||
tableName,
|
||||
deletedRecord,
|
||||
companyCode
|
||||
);
|
||||
console.log("🚀 조건부 연결 트리거 실행 완료 (DELETE)");
|
||||
}
|
||||
} catch (triggerError) {
|
||||
console.error("⚠️ 조건부 연결 트리거 실행 오류:", triggerError);
|
||||
// 트리거 오류는 로그만 남기고 메인 삭제 프로세스는 계속 진행
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ 서비스: 실제 테이블 삭제 실패:", error);
|
||||
throw new Error(`실제 테이블 삭제 실패: ${error}`);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,714 @@
|
|||
import { PrismaClient } from "@prisma/client";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// 조건 노드 타입 정의
|
||||
interface ConditionNode {
|
||||
id: string; // 고유 ID
|
||||
type: "condition" | "group-start" | "group-end";
|
||||
field?: string;
|
||||
operator_type?: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE";
|
||||
value?: any;
|
||||
dataType?: string;
|
||||
logicalOperator?: "AND" | "OR"; // 다음 조건과의 논리 연산자
|
||||
groupId?: string; // 그룹 ID (group-start와 group-end가 같은 groupId를 가짐)
|
||||
groupLevel?: number; // 중첩 레벨 (0, 1, 2, ...)
|
||||
}
|
||||
|
||||
// 조건 제어 정보
|
||||
interface ConditionControl {
|
||||
triggerType: "insert" | "update" | "delete" | "insert_update";
|
||||
conditionTree: ConditionNode | ConditionNode[] | null;
|
||||
}
|
||||
|
||||
// 연결 카테고리 정보
|
||||
interface ConnectionCategory {
|
||||
type: "simple-key" | "data-save" | "external-call" | "conditional-link";
|
||||
rollbackOnError?: boolean;
|
||||
enableLogging?: boolean;
|
||||
maxRetryCount?: number;
|
||||
}
|
||||
|
||||
// 대상 액션
|
||||
interface TargetAction {
|
||||
id: string;
|
||||
actionType: "insert" | "update" | "delete" | "upsert";
|
||||
targetTable: string;
|
||||
enabled: boolean;
|
||||
fieldMappings: FieldMapping[];
|
||||
conditions?: Array<{
|
||||
field: string;
|
||||
operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE";
|
||||
value: string;
|
||||
logicalOperator?: "AND" | "OR";
|
||||
}>;
|
||||
splitConfig?: {
|
||||
sourceField: string;
|
||||
delimiter: string;
|
||||
targetField: string;
|
||||
};
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// 필드 매핑
|
||||
interface FieldMapping {
|
||||
sourceField: string;
|
||||
targetField: string;
|
||||
transformFunction?: string;
|
||||
defaultValue?: string;
|
||||
}
|
||||
|
||||
// 실행 계획
|
||||
interface ExecutionPlan {
|
||||
sourceTable: string;
|
||||
targetActions: TargetAction[];
|
||||
}
|
||||
|
||||
// 실행 결과
|
||||
interface ExecutionResult {
|
||||
success: boolean;
|
||||
executedActions: number;
|
||||
failedActions: number;
|
||||
errors: string[];
|
||||
executionTime: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 조건부 연결 실행을 위한 이벤트 트리거 서비스
|
||||
*/
|
||||
export class EventTriggerService {
|
||||
/**
|
||||
* 특정 테이블에 대한 이벤트 트리거 실행
|
||||
*/
|
||||
static async executeEventTriggers(
|
||||
triggerType: "insert" | "update" | "delete",
|
||||
tableName: string,
|
||||
data: Record<string, any>,
|
||||
companyCode: string
|
||||
): Promise<ExecutionResult[]> {
|
||||
const startTime = Date.now();
|
||||
const results: ExecutionResult[] = [];
|
||||
|
||||
try {
|
||||
// 🔥 수정: Raw SQL을 사용하여 JSON 배열 검색
|
||||
const diagrams = (await prisma.$queryRaw`
|
||||
SELECT * FROM dataflow_diagrams
|
||||
WHERE company_code = ${companyCode}
|
||||
AND (
|
||||
category::text = '"data-save"' OR
|
||||
category::jsonb ? 'data-save' OR
|
||||
category::jsonb @> '["data-save"]'
|
||||
)
|
||||
`) as any[];
|
||||
|
||||
// 각 관계도에서 해당 테이블을 소스로 하는 데이터 저장 관계들 필터링
|
||||
const matchingDiagrams = diagrams.filter((diagram) => {
|
||||
// category 배열에서 data-save 연결이 있는지 확인
|
||||
const categories = diagram.category as any[];
|
||||
const hasDataSave = Array.isArray(categories)
|
||||
? categories.some((cat) => cat.category === "data-save")
|
||||
: false;
|
||||
|
||||
if (!hasDataSave) return false;
|
||||
|
||||
// plan 배열에서 해당 테이블을 소스로 하는 항목이 있는지 확인
|
||||
const plans = diagram.plan as any[];
|
||||
const hasMatchingPlan = Array.isArray(plans)
|
||||
? plans.some((plan) => plan.sourceTable === tableName)
|
||||
: false;
|
||||
|
||||
// control 배열에서 해당 트리거 타입이 있는지 확인
|
||||
const controls = diagram.control as any[];
|
||||
const hasMatchingControl = Array.isArray(controls)
|
||||
? controls.some((control) => control.triggerType === triggerType)
|
||||
: false;
|
||||
|
||||
return hasDataSave && hasMatchingPlan && hasMatchingControl;
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`Found ${matchingDiagrams.length} matching data-save connections for table ${tableName} with trigger ${triggerType}`
|
||||
);
|
||||
|
||||
// 각 다이어그램에 대해 조건부 연결 실행
|
||||
for (const diagram of matchingDiagrams) {
|
||||
try {
|
||||
const result = await this.executeDiagramTrigger(
|
||||
diagram,
|
||||
data,
|
||||
companyCode
|
||||
);
|
||||
results.push(result);
|
||||
} catch (error) {
|
||||
logger.error(`Error executing diagram ${diagram.diagram_id}:`, error);
|
||||
results.push({
|
||||
success: false,
|
||||
executedActions: 0,
|
||||
failedActions: 1,
|
||||
errors: [error instanceof Error ? error.message : "Unknown error"],
|
||||
executionTime: Date.now() - startTime,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
logger.error("Error in executeEventTriggers:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 다이어그램의 트리거 실행
|
||||
*/
|
||||
private static async executeDiagramTrigger(
|
||||
diagram: any,
|
||||
data: Record<string, any>,
|
||||
companyCode: string
|
||||
): Promise<ExecutionResult> {
|
||||
const startTime = Date.now();
|
||||
let executedActions = 0;
|
||||
let failedActions = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
try {
|
||||
const control = diagram.control as unknown as ConditionControl;
|
||||
const category = diagram.category as unknown as ConnectionCategory;
|
||||
const plan = diagram.plan as unknown as ExecutionPlan;
|
||||
|
||||
logger.info(
|
||||
`Executing diagram ${diagram.diagram_id} (${diagram.diagram_name})`
|
||||
);
|
||||
|
||||
// 조건 평가
|
||||
if (control.conditionTree) {
|
||||
const conditionMet = await this.evaluateCondition(
|
||||
control.conditionTree,
|
||||
data
|
||||
);
|
||||
if (!conditionMet) {
|
||||
logger.info(
|
||||
`Conditions not met for diagram ${diagram.diagram_id}, skipping execution`
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
executedActions: 0,
|
||||
failedActions: 0,
|
||||
errors: [],
|
||||
executionTime: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 대상 액션들 실행
|
||||
for (const action of plan.targetActions) {
|
||||
if (!action.enabled) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.executeTargetAction(action, data, companyCode);
|
||||
executedActions++;
|
||||
|
||||
if (category.enableLogging) {
|
||||
logger.info(
|
||||
`Successfully executed action ${action.id} on table ${action.targetTable}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
failedActions++;
|
||||
const errorMsg =
|
||||
error instanceof Error ? error.message : "Unknown error";
|
||||
errors.push(`Action ${action.id}: ${errorMsg}`);
|
||||
|
||||
logger.error(`Failed to execute action ${action.id}:`, error);
|
||||
|
||||
// 오류 시 롤백 처리
|
||||
if (category.rollbackOnError) {
|
||||
logger.warn(`Rolling back due to error in action ${action.id}`);
|
||||
// TODO: 롤백 로직 구현
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: failedActions === 0,
|
||||
executedActions,
|
||||
failedActions,
|
||||
errors,
|
||||
executionTime: Date.now() - startTime,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`Error executing diagram ${diagram.diagram_id}:`, error);
|
||||
return {
|
||||
success: false,
|
||||
executedActions: 0,
|
||||
failedActions: 1,
|
||||
errors: [error instanceof Error ? error.message : "Unknown error"],
|
||||
executionTime: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 조건 평가 (플랫 구조 + 그룹핑 지원)
|
||||
*/
|
||||
private static async evaluateCondition(
|
||||
condition: ConditionNode | ConditionNode[],
|
||||
data: Record<string, any>
|
||||
): Promise<boolean> {
|
||||
// 단일 조건인 경우 (하위 호환성)
|
||||
if (!Array.isArray(condition)) {
|
||||
if (condition.type === "condition") {
|
||||
return this.evaluateSingleCondition(condition, data);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// 조건 배열인 경우 (새로운 그룹핑 시스템)
|
||||
return this.evaluateConditionList(condition, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 조건 리스트 평가 (괄호 그룹핑 지원)
|
||||
*/
|
||||
private static async evaluateConditionList(
|
||||
conditions: ConditionNode[],
|
||||
data: Record<string, any>
|
||||
): Promise<boolean> {
|
||||
if (conditions.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 조건을 평가 가능한 표현식으로 변환
|
||||
const expression = await this.buildConditionExpression(conditions, data);
|
||||
|
||||
// 표현식 평가
|
||||
return this.evaluateExpression(expression);
|
||||
}
|
||||
|
||||
/**
|
||||
* 조건들을 평가 가능한 표현식으로 변환
|
||||
*/
|
||||
private static async buildConditionExpression(
|
||||
conditions: ConditionNode[],
|
||||
data: Record<string, any>
|
||||
): Promise<string> {
|
||||
const tokens: string[] = [];
|
||||
|
||||
for (let i = 0; i < conditions.length; i++) {
|
||||
const condition = conditions[i];
|
||||
|
||||
if (condition.type === "group-start") {
|
||||
// 이전 조건과의 논리 연산자 추가
|
||||
if (i > 0 && condition.logicalOperator) {
|
||||
tokens.push(condition.logicalOperator);
|
||||
}
|
||||
tokens.push("(");
|
||||
} else if (condition.type === "group-end") {
|
||||
tokens.push(")");
|
||||
} else if (condition.type === "condition") {
|
||||
// 이전 조건과의 논리 연산자 추가
|
||||
if (i > 0 && condition.logicalOperator) {
|
||||
tokens.push(condition.logicalOperator);
|
||||
}
|
||||
|
||||
// 조건 평가 결과를 토큰으로 추가
|
||||
const result = await this.evaluateSingleCondition(condition, data);
|
||||
tokens.push(result.toString());
|
||||
}
|
||||
}
|
||||
|
||||
return tokens.join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* 논리 표현식 평가 (괄호 우선순위 지원)
|
||||
*/
|
||||
private static evaluateExpression(expression: string): boolean {
|
||||
try {
|
||||
// 안전한 논리 표현식 평가
|
||||
// true/false와 AND/OR/괄호만 포함된 표현식을 평가
|
||||
const sanitizedExpression = expression
|
||||
.replace(/\bAND\b/g, "&&")
|
||||
.replace(/\bOR\b/g, "||")
|
||||
.replace(/\btrue\b/g, "true")
|
||||
.replace(/\bfalse\b/g, "false");
|
||||
|
||||
// 보안을 위해 허용된 문자만 확인
|
||||
if (!/^[true|false|\s|&|\||\(|\)]+$/.test(sanitizedExpression)) {
|
||||
logger.warn(`Invalid expression: ${expression}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Function constructor를 사용한 안전한 평가
|
||||
const result = new Function(`return ${sanitizedExpression}`)();
|
||||
return Boolean(result);
|
||||
} catch (error) {
|
||||
logger.error(`Error evaluating expression: ${expression}`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 액션별 조건들 평가 (AND/OR 연산자 지원)
|
||||
*/
|
||||
private static async evaluateActionConditions(
|
||||
conditions: Array<{
|
||||
field: string;
|
||||
operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE";
|
||||
value: string;
|
||||
logicalOperator?: "AND" | "OR";
|
||||
}>,
|
||||
data: Record<string, any>
|
||||
): Promise<boolean> {
|
||||
if (conditions.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let result = await this.evaluateActionCondition(conditions[0], data);
|
||||
|
||||
for (let i = 1; i < conditions.length; i++) {
|
||||
const prevCondition = conditions[i - 1];
|
||||
const currentCondition = conditions[i];
|
||||
const currentResult = await this.evaluateActionCondition(
|
||||
currentCondition,
|
||||
data
|
||||
);
|
||||
|
||||
if (prevCondition.logicalOperator === "OR") {
|
||||
result = result || currentResult;
|
||||
} else {
|
||||
// 기본값은 AND
|
||||
result = result && currentResult;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 액션 단일 조건 평가
|
||||
*/
|
||||
private static async evaluateActionCondition(
|
||||
condition: {
|
||||
field: string;
|
||||
operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE";
|
||||
value: string;
|
||||
},
|
||||
data: Record<string, any>
|
||||
): Promise<boolean> {
|
||||
const fieldValue = data[condition.field];
|
||||
const conditionValue = condition.value;
|
||||
|
||||
switch (condition.operator) {
|
||||
case "=":
|
||||
return fieldValue == conditionValue;
|
||||
case "!=":
|
||||
return fieldValue != conditionValue;
|
||||
case ">":
|
||||
return Number(fieldValue) > Number(conditionValue);
|
||||
case "<":
|
||||
return Number(fieldValue) < Number(conditionValue);
|
||||
case ">=":
|
||||
return Number(fieldValue) >= Number(conditionValue);
|
||||
case "<=":
|
||||
return Number(fieldValue) <= Number(conditionValue);
|
||||
case "LIKE":
|
||||
return String(fieldValue).includes(String(conditionValue));
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 조건 평가
|
||||
*/
|
||||
private static evaluateSingleCondition(
|
||||
condition: ConditionNode,
|
||||
data: Record<string, any>
|
||||
): boolean {
|
||||
const { field, operator_type, value } = condition;
|
||||
|
||||
if (!field || !operator_type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const fieldValue = data[field];
|
||||
|
||||
switch (operator_type) {
|
||||
case "=":
|
||||
return fieldValue == value;
|
||||
case "!=":
|
||||
return fieldValue != value;
|
||||
case ">":
|
||||
return Number(fieldValue) > Number(value);
|
||||
case "<":
|
||||
return Number(fieldValue) < Number(value);
|
||||
case ">=":
|
||||
return Number(fieldValue) >= Number(value);
|
||||
case "<=":
|
||||
return Number(fieldValue) <= Number(value);
|
||||
case "LIKE":
|
||||
return String(fieldValue).includes(String(value));
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 대상 액션 실행
|
||||
*/
|
||||
private static async executeTargetAction(
|
||||
action: TargetAction,
|
||||
sourceData: Record<string, any>,
|
||||
companyCode: string
|
||||
): Promise<void> {
|
||||
// 액션별 조건 평가
|
||||
if (action.conditions && action.conditions.length > 0) {
|
||||
const conditionMet = await this.evaluateActionConditions(
|
||||
action.conditions,
|
||||
sourceData
|
||||
);
|
||||
if (!conditionMet) {
|
||||
logger.info(
|
||||
`Action conditions not met for action ${action.id}, skipping execution`
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 필드 매핑을 통해 대상 데이터 생성
|
||||
const targetData: Record<string, any> = {};
|
||||
|
||||
for (const mapping of action.fieldMappings) {
|
||||
let value = sourceData[mapping.sourceField];
|
||||
|
||||
// 변환 함수 적용
|
||||
if (mapping.transformFunction) {
|
||||
value = this.applyTransformFunction(value, mapping.transformFunction);
|
||||
}
|
||||
|
||||
// 기본값 설정
|
||||
if (value === undefined || value === null) {
|
||||
value = mapping.defaultValue;
|
||||
}
|
||||
|
||||
targetData[mapping.targetField] = value;
|
||||
}
|
||||
|
||||
// 회사 코드 추가
|
||||
targetData.company_code = companyCode;
|
||||
|
||||
// 액션 타입별 실행
|
||||
switch (action.actionType) {
|
||||
case "insert":
|
||||
await this.executeInsertAction(action.targetTable, targetData);
|
||||
break;
|
||||
case "update":
|
||||
await this.executeUpdateAction(
|
||||
action.targetTable,
|
||||
targetData,
|
||||
undefined // 액션별 조건은 이미 평가했으므로 WHERE 조건은 undefined
|
||||
);
|
||||
break;
|
||||
case "delete":
|
||||
await this.executeDeleteAction(
|
||||
action.targetTable,
|
||||
targetData,
|
||||
undefined // 액션별 조건은 이미 평가했으므로 WHERE 조건은 undefined
|
||||
);
|
||||
break;
|
||||
case "upsert":
|
||||
await this.executeUpsertAction(action.targetTable, targetData);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported action type: ${action.actionType}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* INSERT 액션 실행
|
||||
*/
|
||||
private static async executeInsertAction(
|
||||
tableName: string,
|
||||
data: Record<string, any>
|
||||
): Promise<void> {
|
||||
// 동적 테이블 INSERT 실행
|
||||
const sql = `INSERT INTO ${tableName} (${Object.keys(data).join(", ")}) VALUES (${Object.keys(
|
||||
data
|
||||
)
|
||||
.map(() => "?")
|
||||
.join(", ")})`;
|
||||
|
||||
await prisma.$executeRawUnsafe(sql, ...Object.values(data));
|
||||
logger.info(`Inserted data into ${tableName}:`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* UPDATE 액션 실행
|
||||
*/
|
||||
private static async executeUpdateAction(
|
||||
tableName: string,
|
||||
data: Record<string, any>,
|
||||
conditions?: ConditionNode
|
||||
): Promise<void> {
|
||||
// 조건이 없으면 실행하지 않음 (안전장치)
|
||||
if (!conditions) {
|
||||
throw new Error(
|
||||
"UPDATE action requires conditions to prevent accidental mass updates"
|
||||
);
|
||||
}
|
||||
|
||||
// 동적 테이블 UPDATE 실행
|
||||
const setClause = Object.keys(data)
|
||||
.map((key) => `${key} = ?`)
|
||||
.join(", ");
|
||||
const whereClause = this.buildWhereClause(conditions);
|
||||
|
||||
const sql = `UPDATE ${tableName} SET ${setClause} WHERE ${whereClause}`;
|
||||
|
||||
await prisma.$executeRawUnsafe(sql, ...Object.values(data));
|
||||
logger.info(`Updated data in ${tableName}:`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE 액션 실행
|
||||
*/
|
||||
private static async executeDeleteAction(
|
||||
tableName: string,
|
||||
data: Record<string, any>,
|
||||
conditions?: ConditionNode
|
||||
): Promise<void> {
|
||||
// 조건이 없으면 실행하지 않음 (안전장치)
|
||||
if (!conditions) {
|
||||
throw new Error(
|
||||
"DELETE action requires conditions to prevent accidental mass deletions"
|
||||
);
|
||||
}
|
||||
|
||||
// 동적 테이블 DELETE 실행
|
||||
const whereClause = this.buildWhereClause(conditions);
|
||||
const sql = `DELETE FROM ${tableName} WHERE ${whereClause}`;
|
||||
|
||||
await prisma.$executeRawUnsafe(sql);
|
||||
logger.info(`Deleted data from ${tableName} with conditions`);
|
||||
}
|
||||
|
||||
/**
|
||||
* UPSERT 액션 실행
|
||||
*/
|
||||
private static async executeUpsertAction(
|
||||
tableName: string,
|
||||
data: Record<string, any>
|
||||
): Promise<void> {
|
||||
// PostgreSQL UPSERT 구현
|
||||
const columns = Object.keys(data);
|
||||
const values = Object.values(data);
|
||||
const conflictColumns = ["id", "company_code"]; // 기본 충돌 컬럼
|
||||
|
||||
const sql = `
|
||||
INSERT INTO ${tableName} (${columns.join(", ")})
|
||||
VALUES (${columns.map(() => "?").join(", ")})
|
||||
ON CONFLICT (${conflictColumns.join(", ")})
|
||||
DO UPDATE SET ${columns.map((col) => `${col} = EXCLUDED.${col}`).join(", ")}
|
||||
`;
|
||||
|
||||
await prisma.$executeRawUnsafe(sql, ...values);
|
||||
logger.info(`Upserted data into ${tableName}:`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* WHERE 절 구성
|
||||
*/
|
||||
private static buildWhereClause(conditions: ConditionNode): string {
|
||||
// 간단한 WHERE 절 구성 (실제 구현에서는 더 복잡한 로직 필요)
|
||||
if (
|
||||
conditions.type === "condition" &&
|
||||
conditions.field &&
|
||||
conditions.operator_type
|
||||
) {
|
||||
return `${conditions.field} ${conditions.operator_type} '${conditions.value}'`;
|
||||
}
|
||||
|
||||
return "1=1"; // 기본값
|
||||
}
|
||||
|
||||
/**
|
||||
* 변환 함수 적용
|
||||
*/
|
||||
private static applyTransformFunction(
|
||||
value: any,
|
||||
transformFunction: string
|
||||
): any {
|
||||
try {
|
||||
// 안전한 변환 함수들만 허용
|
||||
switch (transformFunction) {
|
||||
case "UPPER":
|
||||
return String(value).toUpperCase();
|
||||
case "LOWER":
|
||||
return String(value).toLowerCase();
|
||||
case "TRIM":
|
||||
return String(value).trim();
|
||||
case "NOW":
|
||||
return new Date();
|
||||
case "UUID":
|
||||
return require("crypto").randomUUID();
|
||||
default:
|
||||
logger.warn(`Unknown transform function: ${transformFunction}`);
|
||||
return value;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Error applying transform function ${transformFunction}:`,
|
||||
error
|
||||
);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 조건부 연결 테스트 (개발/디버깅용)
|
||||
*/
|
||||
static async testConditionalConnection(
|
||||
diagramId: number,
|
||||
testData: Record<string, any>,
|
||||
companyCode: string
|
||||
): Promise<{ conditionMet: boolean; result?: ExecutionResult }> {
|
||||
try {
|
||||
const diagram = await prisma.dataflow_diagrams.findUnique({
|
||||
where: { diagram_id: diagramId },
|
||||
});
|
||||
|
||||
if (!diagram) {
|
||||
throw new Error(`Diagram ${diagramId} not found`);
|
||||
}
|
||||
|
||||
const control = diagram.control as unknown as ConditionControl;
|
||||
|
||||
// 조건 평가만 수행
|
||||
const conditionMet = control.conditionTree
|
||||
? await this.evaluateCondition(control.conditionTree, testData)
|
||||
: true;
|
||||
|
||||
if (conditionMet) {
|
||||
// 실제 실행 (테스트 모드)
|
||||
const result = await this.executeDiagramTrigger(
|
||||
diagram,
|
||||
testData,
|
||||
companyCode
|
||||
);
|
||||
return { conditionMet: true, result };
|
||||
}
|
||||
|
||||
return { conditionMet: false };
|
||||
} catch (error) {
|
||||
logger.error("Error testing conditional connection:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default EventTriggerService;
|
||||
|
|
@ -0,0 +1,294 @@
|
|||
# 🔗 조건부 연결 기능 구현 계획
|
||||
|
||||
## 📋 프로젝트 개요
|
||||
|
||||
현재 DataFlow 시스템에서 3가지 연결 종류를 지원하고 있으며, 이 중 **데이터 저장**과 **외부 호출** 기능에 실행 조건 로직을 추가해야 합니다.
|
||||
|
||||
### 현재 연결 종류
|
||||
|
||||
1. **단순 키값 연결** - 조건 설정 불필요 (기존 방식 유지)
|
||||
2. **데이터 저장** - 실행 조건 설정 필요 ✨
|
||||
3. **외부 호출** - 실행 조건 설정 필요 ✨
|
||||
|
||||
## 🎯 기능 요구사항
|
||||
|
||||
### 데이터 저장 기능
|
||||
|
||||
```
|
||||
"from 테이블의 컬럼이 특정 조건을 만족하면 to 테이블에 특정 액션을 취할 것"
|
||||
```
|
||||
|
||||
**예시 시나리오:**
|
||||
|
||||
- `work_order` 테이블의 `status = 'APPROVED'` 이고 `quantity > 0` 일 때
|
||||
- `material_requirement` 테이블에 자재 소요량 데이터 INSERT
|
||||
|
||||
### 외부 호출 기능
|
||||
|
||||
```
|
||||
"from테이블의 컬럼이 특정 조건을 만족하면 외부 api호출이나 이메일 발송 등의 동작을 취해야 함"
|
||||
```
|
||||
|
||||
**예시 시나리오:**
|
||||
|
||||
- `employee_master` 테이블의 `employment_status = 'APPROVED'` 일 때
|
||||
- 외부 이메일 API 호출하여 환영 메일 발송
|
||||
|
||||
## 🗄️ 데이터베이스 스키마 변경
|
||||
|
||||
### 1. 컬럼 추가
|
||||
|
||||
```sql
|
||||
-- 기존 데이터 삭제 후 dataflow_diagrams 테이블에 3개 컬럼 추가
|
||||
DELETE FROM dataflow_diagrams; -- 기존 데이터 전체 삭제
|
||||
|
||||
ALTER TABLE dataflow_diagrams
|
||||
ADD COLUMN control JSONB, -- 조건 설정
|
||||
ADD COLUMN category JSONB, -- 연결 종류 설정
|
||||
ADD COLUMN plan JSONB; -- 실행 계획 설정
|
||||
|
||||
-- 인덱스 추가
|
||||
CREATE INDEX idx_dataflow_control_trigger ON dataflow_diagrams USING GIN ((control->'triggerType'));
|
||||
CREATE INDEX idx_dataflow_category_type ON dataflow_diagrams USING GIN ((category->'type'));
|
||||
```
|
||||
|
||||
### 2. 데이터 구조 설계
|
||||
|
||||
#### `control` 컬럼 - 조건 설정
|
||||
|
||||
```json
|
||||
{
|
||||
"triggerType": "insert",
|
||||
"conditionTree": {
|
||||
"type": "group",
|
||||
"operator": "AND",
|
||||
"children": [
|
||||
{
|
||||
"type": "condition",
|
||||
"field": "status",
|
||||
"operator": "=",
|
||||
"value": "APPROVED"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### `category` 컬럼 - 연결 종류
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "data-save" // "simple-key" | "data-save" | "external-call"
|
||||
}
|
||||
```
|
||||
|
||||
#### `plan` 컬럼 - 실행 계획
|
||||
|
||||
```json
|
||||
{
|
||||
"sourceTable": "work_order",
|
||||
"targetActions": [
|
||||
{
|
||||
"id": "action_1",
|
||||
"actionType": "insert",
|
||||
"targetTable": "material_requirement",
|
||||
"enabled": true,
|
||||
"fieldMappings": [
|
||||
{
|
||||
"sourceField": "work_order_id",
|
||||
"targetField": "order_id"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 🎨 프론트엔드 UI 개선
|
||||
|
||||
### ConnectionSetupModal.tsx 재설계
|
||||
|
||||
#### 현재 구조 문제점
|
||||
|
||||
- 모든 연결 종류에 동일한 UI 적용
|
||||
- 조건 설정 기능 없음
|
||||
- 단순 키값 연결과 조건부 연결의 구분 없음
|
||||
|
||||
#### 개선 방안
|
||||
|
||||
##### 1. 연결 종류별 UI 분기
|
||||
|
||||
```tsx
|
||||
// 연결 종류 선택 후 조건부 렌더링
|
||||
{
|
||||
config.connectionType === "simple-key" && <SimpleKeyConnectionSettings />;
|
||||
}
|
||||
|
||||
{
|
||||
(config.connectionType === "data-save" ||
|
||||
config.connectionType === "external-call") && (
|
||||
<ConditionalConnectionSettings />
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
##### 2. 조건 설정 섹션 추가
|
||||
|
||||
```tsx
|
||||
// control.html의 제어 조건 설정 섹션을 참조하여 구현
|
||||
<div className="control-conditions">
|
||||
<h4>📋 실행 조건 설정</h4>
|
||||
<ConditionBuilder
|
||||
conditions={conditions}
|
||||
onConditionsChange={setConditions}
|
||||
availableFields={fromTableColumns}
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
##### 3. 액션 설정 섹션
|
||||
|
||||
```tsx
|
||||
<div className="control-actions">
|
||||
<h4>⚡ 실행 액션</h4>
|
||||
{config.connectionType === "data-save" && <DataSaveActionSettings />}
|
||||
{config.connectionType === "external-call" && <ExternalCallActionSettings />}
|
||||
</div>
|
||||
```
|
||||
|
||||
### 새로운 컴포넌트 구조
|
||||
|
||||
```
|
||||
ConnectionSetupModal.tsx
|
||||
├── BasicConnectionInfo (공통)
|
||||
├── ConnectionTypeSelector (공통)
|
||||
├── SimpleKeyConnectionSettings (단순 키값 전용)
|
||||
└── ConditionalConnectionSettings (조건부 연결 전용)
|
||||
├── ConditionBuilder (조건 설정)
|
||||
├── DataSaveActionSettings (데이터 저장 액션)
|
||||
└── ExternalCallActionSettings (외부 호출 액션)
|
||||
```
|
||||
|
||||
## ⚙️ 백엔드 서비스 구현
|
||||
|
||||
### 1. EventTriggerService 생성
|
||||
|
||||
```typescript
|
||||
// backend-node/src/services/eventTriggerService.ts
|
||||
export class EventTriggerService {
|
||||
static async executeEventTriggers(
|
||||
triggerType: "insert" | "update" | "delete",
|
||||
tableName: string,
|
||||
data: Record<string, any>,
|
||||
companyCode: string
|
||||
): Promise<void>;
|
||||
|
||||
static async executeDataSaveAction(
|
||||
action: TargetAction,
|
||||
sourceData: Record<string, any>
|
||||
): Promise<void>;
|
||||
|
||||
static async executeExternalCallAction(
|
||||
action: ExternalCallAction,
|
||||
sourceData: Record<string, any>
|
||||
): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. DynamicFormService 연동
|
||||
|
||||
```typescript
|
||||
// 기존 saveFormData 메서드에 트리거 실행 추가
|
||||
async saveFormData(screenId: number, tableName: string, data: Record<string, any>) {
|
||||
// 기존 저장 로직
|
||||
const result = await this.saveToDatabase(data);
|
||||
|
||||
// 🔥 조건부 연결 실행
|
||||
await EventTriggerService.executeEventTriggers("insert", tableName, data, companyCode);
|
||||
|
||||
return result;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. API 엔드포인트 추가
|
||||
|
||||
```typescript
|
||||
// backend-node/src/routes/dataflowRoutes.ts
|
||||
router.post("/diagrams/:id/test-conditions", async (req, res) => {
|
||||
// 조건 테스트 실행
|
||||
});
|
||||
|
||||
router.post("/diagrams/:id/execute-actions", async (req, res) => {
|
||||
// 액션 수동 실행
|
||||
});
|
||||
```
|
||||
|
||||
## 📝 구현 단계별 계획
|
||||
|
||||
### Phase 1: 데이터베이스 준비
|
||||
|
||||
- [ ] dataflow_diagrams 테이블 컬럼 추가 (기존 데이터 삭제 후 진행)
|
||||
- [ ] Prisma 스키마 업데이트
|
||||
|
||||
### Phase 2: 프론트엔드 UI 개선
|
||||
|
||||
- [ ] ConnectionSetupModal.tsx 재구조화
|
||||
- [ ] ConditionBuilder 컴포넌트 개발
|
||||
- [ ] 연결 종류별 설정 컴포넌트 분리
|
||||
- [ ] control.html 참조하여 조건 설정 UI 구현
|
||||
|
||||
### Phase 3: 백엔드 서비스 개발
|
||||
|
||||
- [ ] EventTriggerService 기본 구조 생성
|
||||
- [ ] 조건 평가 엔진 구현
|
||||
- [ ] 데이터 저장 액션 실행 로직
|
||||
- [ ] DynamicFormService 연동
|
||||
|
||||
### Phase 4: 외부 호출 기능
|
||||
|
||||
- [ ] 외부 API 호출 서비스
|
||||
- [ ] 이메일 발송 기능
|
||||
- [ ] 웹훅 지원
|
||||
- [ ] 오류 처리 및 재시도 로직
|
||||
|
||||
## 🔧 기술적 고려사항
|
||||
|
||||
### 1. 성능 최적화
|
||||
|
||||
- 조건 평가 시 인덱스 활용
|
||||
- 대량 데이터 처리 시 배치 처리
|
||||
- 비동기 실행으로 메인 로직 블로킹 방지
|
||||
|
||||
### 2. 오류 처리
|
||||
|
||||
- 트랜잭션 롤백 지원
|
||||
- 부분 실패 시 복구 메커니즘
|
||||
|
||||
### 3. 보안
|
||||
|
||||
- SQL 인젝션 방지
|
||||
- 외부 API 호출 시 인증 처리
|
||||
- 민감 데이터 마스킹
|
||||
|
||||
### 4. 확장성
|
||||
|
||||
- 새로운 액션 타입 추가 용이성
|
||||
- 복잡한 조건문 지원
|
||||
- 다양한 외부 서비스 연동
|
||||
|
||||
## 📚 참고 자료
|
||||
|
||||
- [control.html](../control.html) - 제어 조건 설정 UI 참조
|
||||
- [ConnectionSetupModal.tsx](../frontend/components/dataflow/ConnectionSetupModal.tsx) - 현재 구현
|
||||
- [화면간*데이터*관계*설정*시스템\_설계.md](./화면간_데이터_관계_설정_시스템_설계.md) - 전체 시스템 설계
|
||||
|
||||
## 🚀 다음 단계
|
||||
|
||||
1. **데이터베이스 스키마 업데이트** 부터 시작
|
||||
2. **UI 재설계** - control.html 참조하여 조건 설정 UI 구현
|
||||
3. **백엔드 서비스** 단계별 구현
|
||||
4. **외부 호출 기능** 구현
|
||||
|
||||
---
|
||||
|
||||
_이 문서는 조건부 연결 기능 구현을 위한 전체적인 로드맵을 제시합니다. 각 단계별로 상세한 구현 계획을 수립하여 진행할 예정입니다._
|
||||
|
|
@ -43,6 +43,11 @@ export default function DataFlowEditPage() {
|
|||
router.push("/admin/dataflow");
|
||||
};
|
||||
|
||||
// 관계도 이름 업데이트 핸들러
|
||||
const handleDiagramNameUpdate = (newDiagramName: string) => {
|
||||
setDiagramName(newDiagramName);
|
||||
};
|
||||
|
||||
if (!diagramId || !diagramName) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
|
|
@ -74,7 +79,12 @@ export default function DataFlowEditPage() {
|
|||
|
||||
{/* 데이터플로우 디자이너 */}
|
||||
<div className="rounded-lg border border-gray-200 bg-white">
|
||||
<DataFlowDesigner selectedDiagram={diagramName} diagramId={diagramId} onBackToList={handleBackToList} />
|
||||
<DataFlowDesigner
|
||||
selectedDiagram={diagramName}
|
||||
diagramId={diagramId}
|
||||
onBackToList={handleBackToList}
|
||||
onDiagramNameUpdate={handleDiagramNameUpdate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -2,6 +2,63 @@ import { apiClient, ApiResponse } from "./client";
|
|||
|
||||
// 테이블 간 데이터 관계 설정 관련 타입 정의
|
||||
|
||||
// 조건부 연결 관련 타입들
|
||||
export interface ConditionControl {
|
||||
triggerType: "insert" | "update" | "delete" | "insert_update";
|
||||
conditionTree: ConditionNode | ConditionNode[] | null;
|
||||
}
|
||||
|
||||
export interface ConditionNode {
|
||||
id: string; // 고유 ID
|
||||
type: "condition" | "group-start" | "group-end";
|
||||
field?: string;
|
||||
operator_type?: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE";
|
||||
value?: string | number | boolean;
|
||||
dataType?: string;
|
||||
logicalOperator?: "AND" | "OR"; // 다음 조건과의 논리 연산자
|
||||
groupId?: string; // 그룹 ID (group-start와 group-end가 같은 groupId를 가짐)
|
||||
groupLevel?: number; // 중첩 레벨 (0, 1, 2, ...)
|
||||
}
|
||||
|
||||
export interface ConnectionCategory {
|
||||
type: "simple-key" | "data-save" | "external-call" | "conditional-link";
|
||||
rollbackOnError?: boolean;
|
||||
enableLogging?: boolean;
|
||||
maxRetryCount?: number;
|
||||
}
|
||||
|
||||
export interface ExecutionPlan {
|
||||
sourceTable: string;
|
||||
targetActions: TargetAction[];
|
||||
}
|
||||
|
||||
export interface TargetAction {
|
||||
id: string;
|
||||
actionType: "insert" | "update" | "delete" | "upsert";
|
||||
targetTable: string;
|
||||
enabled: boolean;
|
||||
fieldMappings: FieldMapping[];
|
||||
conditions?: Array<{
|
||||
field: string;
|
||||
operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE";
|
||||
value: string;
|
||||
logicalOperator?: "AND" | "OR"; // 다음 조건과의 논리 연산자
|
||||
}>;
|
||||
splitConfig?: {
|
||||
sourceField: string;
|
||||
delimiter: string;
|
||||
targetField: string;
|
||||
};
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface FieldMapping {
|
||||
sourceField: string;
|
||||
targetField: string;
|
||||
transformFunction?: string;
|
||||
defaultValue?: string;
|
||||
}
|
||||
|
||||
export interface ColumnInfo {
|
||||
columnName: string;
|
||||
columnLabel?: string;
|
||||
|
|
@ -45,8 +102,7 @@ export interface TableRelationship {
|
|||
from_column_name: string;
|
||||
to_table_name: string;
|
||||
to_column_name: string;
|
||||
relationship_type: "one-to-one" | "one-to-many" | "many-to-one" | "many-to-many";
|
||||
connection_type: "simple-key" | "data-save" | "external-call";
|
||||
connection_type: "simple-key" | "data-save" | "external-call" | "conditional-link";
|
||||
settings?: Record<string, unknown>;
|
||||
company_code: string;
|
||||
is_active?: string;
|
||||
|
|
@ -98,6 +154,11 @@ export interface DataFlowDiagram {
|
|||
relationshipCount: number;
|
||||
tables: string[];
|
||||
companyCode: string; // 회사 코드 추가
|
||||
|
||||
// 조건부 연결 관련 필드
|
||||
control?: ConditionControl; // 조건 설정
|
||||
category?: ConnectionCategory; // 연결 종류
|
||||
plan?: ExecutionPlan; // 실행 계획
|
||||
createdAt: Date;
|
||||
createdBy: string;
|
||||
updatedAt: Date;
|
||||
|
|
@ -134,6 +195,7 @@ export interface JsonDataFlowDiagram {
|
|||
tables: string[];
|
||||
};
|
||||
node_positions?: NodePositions;
|
||||
category?: string; // 연결 종류 ("simple-key", "data-save", "external-call")
|
||||
company_code: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
|
|
@ -148,9 +210,8 @@ export interface JsonRelationship {
|
|||
toTable: string;
|
||||
fromColumns: string[];
|
||||
toColumns: string[];
|
||||
relationshipType: string;
|
||||
connectionType: string;
|
||||
settings?: any;
|
||||
connectionType: "simple-key" | "data-save" | "external-call";
|
||||
settings?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface CreateDiagramRequest {
|
||||
|
|
@ -160,6 +221,48 @@ export interface CreateDiagramRequest {
|
|||
tables: string[];
|
||||
};
|
||||
node_positions?: NodePositions;
|
||||
// 🔥 수정: 각 관계별 정보를 배열로 저장
|
||||
category?: Array<{
|
||||
id: string;
|
||||
category: "simple-key" | "data-save" | "external-call";
|
||||
}>;
|
||||
// 🔥 전체 실행 조건 - relationships의 id와 동일한 id 사용
|
||||
control?: Array<{
|
||||
id: string; // relationships의 id와 동일
|
||||
triggerType: "insert" | "update" | "delete";
|
||||
conditions?: Array<{
|
||||
field: string;
|
||||
operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE";
|
||||
value: unknown;
|
||||
logicalOperator?: "AND" | "OR";
|
||||
}>;
|
||||
}>;
|
||||
// 🔥 저장 액션 - relationships의 id와 동일한 id 사용
|
||||
plan?: Array<{
|
||||
id: string; // relationships의 id와 동일
|
||||
sourceTable: string;
|
||||
actions: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
actionType: "insert" | "update" | "delete" | "upsert";
|
||||
fieldMappings: Array<{
|
||||
sourceTable?: string;
|
||||
sourceField: string;
|
||||
targetTable?: string;
|
||||
targetField: string;
|
||||
defaultValue?: string;
|
||||
transformFunction?: string;
|
||||
}>;
|
||||
conditions?: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
field: string;
|
||||
operator_type: string;
|
||||
value: unknown;
|
||||
logicalOperator?: string;
|
||||
}>;
|
||||
}>;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface JsonDataFlowDiagramsResponse {
|
||||
|
|
@ -241,7 +344,7 @@ export class DataFlowAPI {
|
|||
* 테이블 관계 생성
|
||||
*/
|
||||
static async createRelationship(
|
||||
relationship: any, // 백엔드 API 형식 (camelCase)
|
||||
relationship: Omit<TableRelationship, "relationship_id">, // 백엔드 API 형식 (camelCase)
|
||||
): Promise<TableRelationship> {
|
||||
try {
|
||||
const response = await apiClient.post<ApiResponse<TableRelationship>>(
|
||||
|
|
@ -526,8 +629,7 @@ export class DataFlowAPI {
|
|||
to_table_name: rel.toTable,
|
||||
from_column_name: rel.fromColumns.join(","),
|
||||
to_column_name: rel.toColumns.join(","),
|
||||
relationship_type: rel.relationshipType as "one-to-one" | "one-to-many" | "many-to-one" | "many-to-many",
|
||||
connection_type: rel.connectionType as "simple-key" | "data-save" | "external-call",
|
||||
connection_type: (jsonDiagram.category as "simple-key" | "data-save" | "external-call") || "simple-key", // 관계도의 category 사용
|
||||
company_code: companyCode, // 실제 사용자 회사 코드 사용
|
||||
settings: rel.settings || {},
|
||||
created_at: jsonDiagram.created_at,
|
||||
|
|
|
|||
Loading…
Reference in New Issue