feature/batch-testing-updates #72

Merged
hjjeong merged 4 commits from feature/batch-testing-updates into main 2025-09-29 17:09:14 +09:00
31 changed files with 4722 additions and 90 deletions
Showing only changes of commit 9dfd0cb40f - Show all commits

View File

@ -0,0 +1,34 @@
const { PrismaClient } = require("@prisma/client");
const prisma = new PrismaClient();
async function addDataMappingColumn() {
try {
console.log(
"🔄 external_call_configs 테이블에 data_mapping_config 컬럼 추가 중..."
);
// data_mapping_config JSONB 컬럼 추가
await prisma.$executeRaw`
ALTER TABLE external_call_configs
ADD COLUMN IF NOT EXISTS data_mapping_config JSONB
`;
console.log("✅ data_mapping_config 컬럼이 성공적으로 추가되었습니다.");
// 기존 레코드에 기본값 설정
await prisma.$executeRaw`
UPDATE external_call_configs
SET data_mapping_config = '{"direction": "none"}'::jsonb
WHERE data_mapping_config IS NULL
`;
console.log("✅ 기존 레코드에 기본값이 설정되었습니다.");
} catch (error) {
console.error("❌ 컬럼 추가 실패:", error);
} finally {
await prisma.$disconnect();
}
}
addDataMappingColumn();

View File

@ -42,6 +42,7 @@ import ddlRoutes from "./routes/ddlRoutes";
import entityReferenceRoutes from "./routes/entityReferenceRoutes";
import externalCallRoutes from "./routes/externalCallRoutes";
import externalCallConfigRoutes from "./routes/externalCallConfigRoutes";
import dataflowExecutionRoutes from "./routes/dataflowExecutionRoutes";
import { BatchSchedulerService } from "./services/batchSchedulerService";
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
@ -96,7 +97,7 @@ app.use(
// Rate Limiting (개발 환경에서는 완화)
const limiter = rateLimit({
windowMs: 1 * 60 * 1000, // 1분
max: config.nodeEnv === "development" ? 10000 : 1000, // 개발환경에서는 10000으로 증가, 운영환경에서는 100
max: config.nodeEnv === "development" ? 10000 : 100, // 개발환경에서는 10000으로 증가, 운영환경에서는 100
message: {
error: "너무 많은 요청이 발생했습니다. 잠시 후 다시 시도해주세요.",
},
@ -156,6 +157,7 @@ app.use("/api/ddl", ddlRoutes);
app.use("/api/entity-reference", entityReferenceRoutes);
app.use("/api/external-calls", externalCallRoutes);
app.use("/api/external-call-configs", externalCallConfigRoutes);
app.use("/api/dataflow", dataflowExecutionRoutes);
// app.use("/api/collections", collectionRoutes); // 임시 주석
// app.use("/api/batch", batchRoutes); // 임시 주석
// app.use('/api/users', userRoutes);

View File

@ -727,3 +727,35 @@ function processDataflowInBackground(
}
}, 1000); // 1초 후 실행 시뮬레이션
}
/**
* 🔥 ( )
*/
export async function getAllRelationships(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const companyCode = req.user?.companyCode || "*";
logger.info(`전체 관계 목록 조회 요청 - companyCode: ${companyCode}`);
// 모든 관계도에서 관계 목록을 가져옴
const allRelationships = await dataflowDiagramService.getAllRelationshipsForButtonControl(companyCode);
logger.info(`전체 관계 ${allRelationships.length}개 조회 완료`);
res.json({
success: true,
data: allRelationships,
message: `전체 관계 ${allRelationships.length}개 조회 완료`,
});
} catch (error) {
logger.error("전체 관계 목록 조회 실패:", error);
res.status(500).json({
success: false,
message: error instanceof Error ? error.message : "전체 관계 목록 조회 실패",
errorCode: "GET_ALL_RELATIONSHIPS_ERROR",
});
}
}

View File

@ -0,0 +1,235 @@
/**
* 🔥
*
*
*/
import { Request, Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { PrismaClient } from "@prisma/client";
import logger from "../utils/logger";
const prisma = new PrismaClient();
/**
*
*/
export async function executeDataAction(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName, data, actionType, connection } = req.body;
const companyCode = req.user?.companyCode || "*";
logger.info(`데이터 액션 실행 시작: ${actionType} on ${tableName}`, {
tableName,
actionType,
dataKeys: Object.keys(data),
connection: connection?.name,
});
// 연결 정보에 따라 다른 데이터베이스에 저장
let result;
if (connection && connection.id !== 0) {
// 외부 데이터베이스 연결
result = await executeExternalDatabaseAction(tableName, data, actionType, connection);
} else {
// 메인 데이터베이스 (현재 시스템)
result = await executeMainDatabaseAction(tableName, data, actionType, companyCode);
}
logger.info(`데이터 액션 실행 완료: ${actionType} on ${tableName}`, result);
res.json({
success: true,
message: `데이터 액션 실행 완료: ${actionType}`,
data: result,
});
} catch (error: any) {
logger.error("데이터 액션 실행 실패:", error);
res.status(500).json({
success: false,
message: `데이터 액션 실행 실패: ${error.message}`,
errorCode: "DATA_ACTION_EXECUTION_ERROR",
});
}
}
/**
*
*/
async function executeMainDatabaseAction(
tableName: string,
data: Record<string, any>,
actionType: string,
companyCode: string
): Promise<any> {
try {
// 회사 코드 추가
const dataWithCompany = {
...data,
company_code: companyCode,
};
switch (actionType.toLowerCase()) {
case 'insert':
return await executeInsert(tableName, dataWithCompany);
case 'update':
return await executeUpdate(tableName, dataWithCompany);
case 'upsert':
return await executeUpsert(tableName, dataWithCompany);
case 'delete':
return await executeDelete(tableName, dataWithCompany);
default:
throw new Error(`지원하지 않는 액션 타입: ${actionType}`);
}
} catch (error) {
logger.error(`메인 DB 액션 실행 오류 (${actionType}):`, error);
throw error;
}
}
/**
*
*/
async function executeExternalDatabaseAction(
tableName: string,
data: Record<string, any>,
actionType: string,
connection: any
): Promise<any> {
try {
// TODO: 외부 데이터베이스 연결 및 실행 로직 구현
// 현재는 로그만 출력하고 성공으로 처리
logger.info(`외부 DB 액션 실행: ${connection.name} (${connection.host}:${connection.port})`);
logger.info(`테이블: ${tableName}, 액션: ${actionType}`, data);
// 임시 성공 응답
return {
success: true,
message: `외부 DB 액션 실행 완료: ${actionType} on ${tableName}`,
connection: connection.name,
affectedRows: 1,
};
} catch (error) {
logger.error(`외부 DB 액션 실행 오류 (${actionType}):`, error);
throw error;
}
}
/**
* INSERT
*/
async function executeInsert(tableName: string, data: Record<string, any>): Promise<any> {
try {
// 동적 테이블 접근을 위한 raw query 사용
const columns = Object.keys(data).join(', ');
const values = Object.values(data);
const placeholders = values.map((_, index) => `$${index + 1}`).join(', ');
const query = `INSERT INTO ${tableName} (${columns}) VALUES (${placeholders}) RETURNING *`;
logger.info(`INSERT 쿼리 실행:`, { query, values });
const result = await prisma.$queryRawUnsafe(query, ...values);
return {
success: true,
action: 'insert',
tableName,
data: result,
affectedRows: Array.isArray(result) ? result.length : 1,
};
} catch (error) {
logger.error(`INSERT 실행 오류:`, error);
throw error;
}
}
/**
* UPDATE
*/
async function executeUpdate(tableName: string, data: Record<string, any>): Promise<any> {
try {
// ID 또는 기본키를 기준으로 업데이트
const { id, ...updateData } = data;
if (!id) {
throw new Error('UPDATE를 위한 ID가 필요합니다');
}
const setClause = Object.keys(updateData)
.map((key, index) => `${key} = $${index + 1}`)
.join(', ');
const values = Object.values(updateData);
const query = `UPDATE ${tableName} SET ${setClause} WHERE id = $${values.length + 1} RETURNING *`;
logger.info(`UPDATE 쿼리 실행:`, { query, values: [...values, id] });
const result = await prisma.$queryRawUnsafe(query, ...values, id);
return {
success: true,
action: 'update',
tableName,
data: result,
affectedRows: Array.isArray(result) ? result.length : 1,
};
} catch (error) {
logger.error(`UPDATE 실행 오류:`, error);
throw error;
}
}
/**
* UPSERT
*/
async function executeUpsert(tableName: string, data: Record<string, any>): Promise<any> {
try {
// 먼저 INSERT를 시도하고, 실패하면 UPDATE
try {
return await executeInsert(tableName, data);
} catch (insertError) {
// INSERT 실패 시 UPDATE 시도
logger.info(`INSERT 실패, UPDATE 시도:`, insertError);
return await executeUpdate(tableName, data);
}
} catch (error) {
logger.error(`UPSERT 실행 오류:`, error);
throw error;
}
}
/**
* DELETE
*/
async function executeDelete(tableName: string, data: Record<string, any>): Promise<any> {
try {
const { id } = data;
if (!id) {
throw new Error('DELETE를 위한 ID가 필요합니다');
}
const query = `DELETE FROM ${tableName} WHERE id = $1 RETURNING *`;
logger.info(`DELETE 쿼리 실행:`, { query, values: [id] });
const result = await prisma.$queryRawUnsafe(query, id);
return {
success: true,
action: 'delete',
tableName,
data: result,
affectedRows: Array.isArray(result) ? result.length : 1,
};
} catch (error) {
logger.error(`DELETE 실행 오류:`, error);
throw error;
}
}

View File

@ -0,0 +1,19 @@
/**
* 🔥
*
* API
*/
import express from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import { executeDataAction } from "../controllers/dataflowExecutionController";
const router = express.Router();
// 🔥 모든 라우트에 인증 미들웨어 적용
router.use(authenticateToken);
// 데이터 액션 실행
router.post("/execute-data-action", executeDataAction);
export default router;

View File

@ -249,4 +249,80 @@ router.post("/:id/test", async (req: Request, res: Response) => {
}
});
/**
* 🔥 ( )
* POST /api/external-call-configs/:id/execute
*/
router.post("/:id/execute", async (req: Request, res: Response) => {
try {
const id = parseInt(req.params.id);
if (isNaN(id)) {
return res.status(400).json({
success: false,
message: "유효하지 않은 설정 ID입니다.",
errorCode: "INVALID_CONFIG_ID",
});
}
const { requestData, contextData } = req.body;
// 사용자 정보 가져오기
const userInfo = (req as any).user;
const userId = userInfo?.userId || "SYSTEM";
const companyCode = userInfo?.companyCode || "*";
const executionResult = await externalCallConfigService.executeConfigWithDataMapping(
id,
requestData || {},
{
...contextData,
userId,
companyCode,
executedAt: new Date().toISOString(),
}
);
return res.json({
success: executionResult.success,
message: executionResult.message,
data: executionResult.data,
executionTime: executionResult.executionTime,
error: executionResult.error,
});
} catch (error) {
logger.error("외부호출 실행 API 오류:", error);
return res.status(500).json({
success: false,
message: error instanceof Error ? error.message : "외부호출 실행 실패",
errorCode: "EXTERNAL_CALL_EXECUTE_ERROR",
});
}
});
/**
* 🔥 ( )
* GET /api/external-call-configs/for-button-control
*/
router.get("/for-button-control", async (req: Request, res: Response) => {
try {
const userInfo = (req as any).user;
const companyCode = userInfo?.companyCode || "*";
const configs = await externalCallConfigService.getConfigsForButtonControl(companyCode);
return res.json({
success: true,
data: configs,
message: `버튼 제어용 외부호출 설정 ${configs.length}개 조회 완료`,
});
} catch (error) {
logger.error("버튼 제어용 외부호출 설정 조회 API 오류:", error);
return res.status(500).json({
success: false,
message: error instanceof Error ? error.message : "외부호출 설정 조회 실패",
errorCode: "EXTERNAL_CALL_BUTTON_CONTROL_LIST_ERROR",
});
}
});
export default router;

View File

@ -14,6 +14,7 @@ import {
executeOptimizedButton,
executeSimpleDataflow,
getJobStatus,
getAllRelationships,
} from "../controllers/buttonDataflowController";
import { AuthenticatedRequest } from "../types/auth";
import config from "../config/environment";
@ -52,6 +53,9 @@ if (config.nodeEnv !== "production") {
// 특정 관계도의 관계 목록 조회
router.get("/diagrams/:diagramId/relationships", getDiagramRelationships);
// 🔥 전체 관계 목록 조회 (버튼 제어용)
router.get("/relationships/all", getAllRelationships);
// 관계 미리보기 정보 조회
router.get(
"/diagrams/:diagramId/relationships/:relationshipId/preview",

View File

@ -0,0 +1,575 @@
import { PrismaClient } from "@prisma/client";
import {
DataMappingConfig,
InboundMapping,
OutboundMapping,
FieldMapping,
DataMappingResult,
MappingValidationResult,
FieldTransform,
DataType,
} from "../types/dataMappingTypes";
export class DataMappingService {
private prisma: PrismaClient;
constructor() {
this.prisma = new PrismaClient();
}
/**
* Inbound ( )
*/
async processInboundData(
externalData: any,
mapping: InboundMapping
): Promise<DataMappingResult> {
const startTime = Date.now();
const result: DataMappingResult = {
success: false,
direction: "inbound",
recordsProcessed: 0,
recordsInserted: 0,
recordsUpdated: 0,
recordsSkipped: 0,
errors: [],
executionTime: 0,
timestamp: new Date().toISOString(),
};
try {
console.log(`📥 [DataMappingService] Inbound 매핑 시작:`, {
targetTable: mapping.targetTable,
insertMode: mapping.insertMode,
fieldMappings: mapping.fieldMappings.length,
});
// 데이터 배열로 변환
const dataArray = Array.isArray(externalData)
? externalData
: [externalData];
result.recordsProcessed = dataArray.length;
// 각 레코드 처리
for (const record of dataArray) {
try {
const mappedData = await this.mapInboundRecord(record, mapping);
if (Object.keys(mappedData).length === 0) {
result.recordsSkipped!++;
continue;
}
// 데이터베이스에 저장
await this.saveInboundRecord(mappedData, mapping);
if (mapping.insertMode === "insert") {
result.recordsInserted!++;
} else {
result.recordsUpdated!++;
}
} catch (error) {
console.error(`❌ [DataMappingService] 레코드 처리 실패:`, error);
result.errors!.push(
`레코드 처리 실패: ${error instanceof Error ? error.message : String(error)}`
);
result.recordsSkipped!++;
}
}
result.success =
result.errors!.length === 0 ||
result.recordsInserted! > 0 ||
result.recordsUpdated! > 0;
} catch (error) {
console.error(`❌ [DataMappingService] Inbound 매핑 실패:`, error);
result.errors!.push(
`매핑 처리 실패: ${error instanceof Error ? error.message : String(error)}`
);
}
result.executionTime = Date.now() - startTime;
console.log(`✅ [DataMappingService] Inbound 매핑 완료:`, result);
return result;
}
/**
* Outbound ( )
*/
async processOutboundData(
mapping: OutboundMapping,
filter?: any
): Promise<any> {
console.log(`📤 [DataMappingService] Outbound 매핑 시작:`, {
sourceTable: mapping.sourceTable,
fieldMappings: mapping.fieldMappings.length,
filter,
});
try {
// 소스 데이터 조회
const sourceData = await this.getSourceData(mapping, filter);
if (
!sourceData ||
(Array.isArray(sourceData) && sourceData.length === 0)
) {
console.log(`⚠️ [DataMappingService] 소스 데이터가 없습니다.`);
return null;
}
// 데이터 매핑
const mappedData = Array.isArray(sourceData)
? await Promise.all(
sourceData.map((record) => this.mapOutboundRecord(record, mapping))
)
: await this.mapOutboundRecord(sourceData, mapping);
console.log(`✅ [DataMappingService] Outbound 매핑 완료:`, {
recordCount: Array.isArray(mappedData) ? mappedData.length : 1,
});
return mappedData;
} catch (error) {
console.error(`❌ [DataMappingService] Outbound 매핑 실패:`, error);
throw error;
}
}
/**
* Inbound
*/
private async mapInboundRecord(
sourceRecord: any,
mapping: InboundMapping
): Promise<Record<string, any>> {
const mappedRecord: Record<string, any> = {};
for (const fieldMapping of mapping.fieldMappings) {
try {
const sourceValue = sourceRecord[fieldMapping.sourceField];
// 필수 필드 체크
if (
fieldMapping.required &&
(sourceValue === undefined || sourceValue === null)
) {
if (fieldMapping.defaultValue !== undefined) {
mappedRecord[fieldMapping.targetField] = fieldMapping.defaultValue;
} else {
throw new Error(
`필수 필드 '${fieldMapping.sourceField}'가 누락되었습니다.`
);
}
continue;
}
// 값이 없으면 기본값 사용
if (sourceValue === undefined || sourceValue === null) {
if (fieldMapping.defaultValue !== undefined) {
mappedRecord[fieldMapping.targetField] = fieldMapping.defaultValue;
}
continue;
}
// 데이터 변환 적용
const transformedValue = await this.transformFieldValue(
sourceValue,
fieldMapping.dataType,
fieldMapping.transform
);
mappedRecord[fieldMapping.targetField] = transformedValue;
} catch (error) {
console.error(
`❌ [DataMappingService] 필드 매핑 실패 (${fieldMapping.sourceField}${fieldMapping.targetField}):`,
error
);
throw error;
}
}
return mappedRecord;
}
/**
* Outbound
*/
private async mapOutboundRecord(
sourceRecord: any,
mapping: OutboundMapping
): Promise<Record<string, any>> {
const mappedRecord: Record<string, any> = {};
for (const fieldMapping of mapping.fieldMappings) {
try {
const sourceValue = sourceRecord[fieldMapping.sourceField];
// 데이터 변환 적용
const transformedValue = await this.transformFieldValue(
sourceValue,
fieldMapping.dataType,
fieldMapping.transform
);
mappedRecord[fieldMapping.targetField] = transformedValue;
} catch (error) {
console.error(
`❌ [DataMappingService] 필드 매핑 실패 (${fieldMapping.sourceField}${fieldMapping.targetField}):`,
error
);
throw error;
}
}
return mappedRecord;
}
/**
*
*/
private async transformFieldValue(
value: any,
targetDataType: DataType,
transform?: FieldTransform
): Promise<any> {
let transformedValue = value;
// 1. 변환 함수 적용
if (transform) {
switch (transform.type) {
case "constant":
transformedValue = transform.value;
break;
case "format":
if (targetDataType === "date" && transform.format) {
transformedValue = this.formatDate(value, transform.format);
}
break;
case "function":
if (transform.functionName) {
transformedValue = await this.applyCustomFunction(
value,
transform.functionName
);
}
break;
}
}
// 2. 데이터 타입 변환
return this.convertDataType(transformedValue, targetDataType);
}
/**
*
*/
private convertDataType(value: any, targetType: DataType): any {
if (value === null || value === undefined) return value;
switch (targetType) {
case "string":
return String(value);
case "number":
const num = Number(value);
return isNaN(num) ? null : num;
case "boolean":
if (typeof value === "boolean") return value;
if (typeof value === "string") {
return (
value.toLowerCase() === "true" || value === "1" || value === "Y"
);
}
return Boolean(value);
case "date":
return new Date(value);
case "json":
return typeof value === "string" ? JSON.parse(value) : value;
default:
return value;
}
}
/**
*
*/
private formatDate(value: any, format: string): string {
const date = new Date(value);
if (isNaN(date.getTime())) return value;
// 간단한 날짜 포맷 변환
switch (format) {
case "YYYY-MM-DD":
return date.toISOString().split("T")[0];
case "YYYY-MM-DD HH:mm:ss":
return date
.toISOString()
.replace("T", " ")
.replace(/\.\d{3}Z$/, "");
default:
return date.toISOString();
}
}
/**
*
*/
private async applyCustomFunction(
value: any,
functionName: string
): Promise<any> {
// 추후 확장 가능한 커스텀 함수들
switch (functionName) {
case "upperCase":
return String(value).toUpperCase();
case "lowerCase":
return String(value).toLowerCase();
case "trim":
return String(value).trim();
default:
console.warn(
`⚠️ [DataMappingService] 알 수 없는 함수: ${functionName}`
);
return value;
}
}
/**
* Inbound
*/
private async saveInboundRecord(
mappedData: Record<string, any>,
mapping: InboundMapping
): Promise<void> {
const tableName = mapping.targetTable;
try {
switch (mapping.insertMode) {
case "insert":
await this.executeInsert(tableName, mappedData);
break;
case "upsert":
await this.executeUpsert(
tableName,
mappedData,
mapping.keyFields || []
);
break;
case "update":
await this.executeUpdate(
tableName,
mappedData,
mapping.keyFields || []
);
break;
}
} catch (error) {
console.error(
`❌ [DataMappingService] 데이터 저장 실패 (${tableName}):`,
error
);
throw error;
}
}
/**
*
*/
private async getSourceData(
mapping: OutboundMapping,
filter?: any
): Promise<any> {
const tableName = mapping.sourceTable;
try {
// 동적 테이블 쿼리 (Prisma의 경우 런타임에서 제한적)
// 실제 구현에서는 각 테이블별 모델을 사용하거나 Raw SQL을 사용해야 함
let whereClause = {};
if (mapping.sourceFilter) {
// 간단한 필터 파싱 (실제로는 더 정교한 파싱 필요)
console.log(
`🔍 [DataMappingService] 필터 조건: ${mapping.sourceFilter}`
);
// TODO: 필터 조건 파싱 및 적용
}
if (filter) {
whereClause = { ...whereClause, ...filter };
}
// Raw SQL을 사용한 동적 쿼리
const query = `SELECT * FROM ${tableName}${mapping.sourceFilter ? ` WHERE ${mapping.sourceFilter}` : ""}`;
console.log(`🔍 [DataMappingService] 쿼리 실행: ${query}`);
const result = await this.prisma.$queryRawUnsafe(query);
return result;
} catch (error) {
console.error(
`❌ [DataMappingService] 소스 데이터 조회 실패 (${tableName}):`,
error
);
throw error;
}
}
/**
* INSERT
*/
private async executeInsert(
tableName: string,
data: Record<string, any>
): Promise<void> {
const columns = Object.keys(data);
const values = Object.values(data);
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
const query = `INSERT INTO ${tableName} (${columns.join(", ")}) VALUES (${placeholders})`;
console.log(`📝 [DataMappingService] INSERT 실행:`, {
table: tableName,
columns,
query,
});
await this.prisma.$executeRawUnsafe(query, ...values);
}
/**
* UPSERT
*/
private async executeUpsert(
tableName: string,
data: Record<string, any>,
keyFields: string[]
): Promise<void> {
if (keyFields.length === 0) {
throw new Error("UPSERT 모드에서는 키 필드가 필요합니다.");
}
const columns = Object.keys(data);
const values = Object.values(data);
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
const updateClauses = columns
.filter((col) => !keyFields.includes(col))
.map((col) => `${col} = EXCLUDED.${col}`)
.join(", ");
const query = `
INSERT INTO ${tableName} (${columns.join(", ")})
VALUES (${placeholders})
ON CONFLICT (${keyFields.join(", ")})
DO UPDATE SET ${updateClauses}
`;
console.log(`🔄 [DataMappingService] UPSERT 실행:`, {
table: tableName,
keyFields,
query,
});
await this.prisma.$executeRawUnsafe(query, ...values);
}
/**
* UPDATE
*/
private async executeUpdate(
tableName: string,
data: Record<string, any>,
keyFields: string[]
): Promise<void> {
if (keyFields.length === 0) {
throw new Error("UPDATE 모드에서는 키 필드가 필요합니다.");
}
const updateColumns = Object.keys(data).filter(
(col) => !keyFields.includes(col)
);
const updateClauses = updateColumns
.map((col, i) => `${col} = $${i + 1}`)
.join(", ");
const whereConditions = keyFields
.map((field, i) => `${field} = $${updateColumns.length + i + 1}`)
.join(" AND ");
const values = [
...updateColumns.map((col) => data[col]),
...keyFields.map((field) => data[field]),
];
const query = `UPDATE ${tableName} SET ${updateClauses} WHERE ${whereConditions}`;
console.log(`✏️ [DataMappingService] UPDATE 실행:`, {
table: tableName,
keyFields,
query,
});
await this.prisma.$executeRawUnsafe(query, ...values);
}
/**
*
*/
validateMappingConfig(config: DataMappingConfig): MappingValidationResult {
const result: MappingValidationResult = {
isValid: true,
errors: [],
warnings: [],
};
if (config.direction === "none") {
return result;
}
// Inbound 매핑 검증
if (
(config.direction === "inbound" ||
config.direction === "bidirectional") &&
config.inboundMapping
) {
if (!config.inboundMapping.targetTable) {
result.errors.push("Inbound 매핑에 대상 테이블이 필요합니다.");
}
if (config.inboundMapping.fieldMappings.length === 0) {
result.errors.push("Inbound 매핑에 필드 매핑이 필요합니다.");
}
if (
config.inboundMapping.insertMode !== "insert" &&
(!config.inboundMapping.keyFields ||
config.inboundMapping.keyFields.length === 0)
) {
result.errors.push("UPSERT/UPDATE 모드에서는 키 필드가 필요합니다.");
}
}
// Outbound 매핑 검증
if (
(config.direction === "outbound" ||
config.direction === "bidirectional") &&
config.outboundMapping
) {
if (!config.outboundMapping.sourceTable) {
result.errors.push("Outbound 매핑에 소스 테이블이 필요합니다.");
}
if (config.outboundMapping.fieldMappings.length === 0) {
result.errors.push("Outbound 매핑에 필드 매핑이 필요합니다.");
}
}
result.isValid = result.errors.length === 0;
return result;
}
/**
*
*/
async disconnect(): Promise<void> {
await this.prisma.$disconnect();
}
}

View File

@ -384,3 +384,66 @@ export const copyDataflowDiagram = async (
throw error;
}
};
/**
* 🔥 ( )
* dataflow_diagrams ( )
*/
export const getAllRelationshipsForButtonControl = async (
companyCode: string
): Promise<Array<{
id: string;
name: string;
sourceTable: string;
targetTable: string;
category: string;
}>> => {
try {
logger.info(`전체 관계 목록 조회 시작 - companyCode: ${companyCode}`);
// dataflow_diagrams 테이블에서 관계도들을 조회
const diagrams = await prisma.dataflow_diagrams.findMany({
where: {
company_code: companyCode,
},
select: {
diagram_id: true,
diagram_name: true,
relationships: true,
},
orderBy: {
updated_at: "desc",
},
});
const allRelationships = diagrams.map((diagram) => {
// relationships 구조에서 테이블 정보 추출
const relationships = diagram.relationships as any || {};
// 테이블 정보 추출
let sourceTable = "";
let targetTable = "";
if (relationships.fromTable?.tableName) {
sourceTable = relationships.fromTable.tableName;
}
if (relationships.toTable?.tableName) {
targetTable = relationships.toTable.tableName;
}
return {
id: diagram.diagram_id.toString(),
name: diagram.diagram_name || `관계 ${diagram.diagram_id}`,
sourceTable: sourceTable,
targetTable: targetTable,
category: "데이터 흐름",
};
});
logger.info(`전체 관계 ${allRelationships.length}개 조회 완료`);
return allRelationships;
} catch (error) {
logger.error("전체 관계 목록 조회 서비스 오류:", error);
throw error;
}
};

View File

@ -308,6 +308,265 @@ export class ExternalCallConfigService {
};
}
}
/**
* 🔥
*/
async executeConfigWithDataMapping(
configId: number,
requestData: Record<string, any>,
contextData: Record<string, any>
): Promise<{
success: boolean;
message: string;
data?: any;
executionTime: number;
error?: string;
}> {
const startTime = performance.now();
try {
logger.info(`=== 외부호출 실행 시작 (ID: ${configId}) ===`);
// 1. 설정 조회
const config = await this.getConfigById(configId);
if (!config) {
throw new Error(`외부호출 설정을 찾을 수 없습니다: ${configId}`);
}
// 2. 데이터 매핑 처리 (있는 경우)
let processedData = requestData;
const configData = config.config_data as any;
if (configData?.dataMappingConfig?.outboundMapping) {
logger.info("Outbound 데이터 매핑 처리 중...");
processedData = await this.processOutboundMapping(
configData.dataMappingConfig.outboundMapping,
requestData
);
}
// 3. 외부 API 호출
const callResult = await this.executeExternalCall(config, processedData, contextData);
// 4. Inbound 데이터 매핑 처리 (있는 경우)
if (
callResult.success &&
configData?.dataMappingConfig?.inboundMapping
) {
logger.info("Inbound 데이터 매핑 처리 중...");
await this.processInboundMapping(
configData.dataMappingConfig.inboundMapping,
callResult.data
);
}
const executionTime = performance.now() - startTime;
logger.info(`외부호출 실행 완료: ${executionTime.toFixed(2)}ms`);
return {
success: callResult.success,
message: callResult.success
? `외부호출 '${config.config_name}' 실행 완료`
: `외부호출 '${config.config_name}' 실행 실패`,
data: callResult.data,
executionTime,
error: callResult.error,
};
} catch (error) {
const executionTime = performance.now() - startTime;
logger.error("외부호출 실행 실패:", error);
const errorMessage = error instanceof Error ? error.message : "알 수 없는 오류";
return {
success: false,
message: `외부호출 실행 실패: ${errorMessage}`,
executionTime,
error: errorMessage,
};
}
}
/**
* 🔥 ( )
*/
async getConfigsForButtonControl(companyCode: string): Promise<Array<{
id: string;
name: string;
description?: string;
apiUrl: string;
method: string;
hasDataMapping: boolean;
}>> {
try {
const configs = await prisma.external_call_configs.findMany({
where: {
company_code: companyCode,
is_active: "Y",
},
select: {
id: true,
config_name: true,
description: true,
config_data: true,
},
orderBy: {
config_name: "asc",
},
});
return configs.map((config) => {
const configData = config.config_data as any;
return {
id: config.id.toString(),
name: config.config_name,
description: config.description || undefined,
apiUrl: configData?.restApiSettings?.apiUrl || "",
method: configData?.restApiSettings?.httpMethod || "GET",
hasDataMapping: !!(configData?.dataMappingConfig),
};
});
} catch (error) {
logger.error("버튼 제어용 외부호출 설정 조회 실패:", error);
throw error;
}
}
/**
* 🔥 API
*/
private async executeExternalCall(
config: ExternalCallConfig,
requestData: Record<string, any>,
contextData: Record<string, any>
): Promise<{ success: boolean; data?: any; error?: string }> {
try {
const configData = config.config_data as any;
const restApiSettings = configData?.restApiSettings;
if (!restApiSettings) {
throw new Error("REST API 설정이 없습니다.");
}
const { apiUrl, httpMethod, headers = {}, timeout = 30000 } = restApiSettings;
// 요청 헤더 준비
const requestHeaders = {
"Content-Type": "application/json",
...headers,
};
// 인증 처리
if (restApiSettings.authentication?.type === "basic") {
const { username, password } = restApiSettings.authentication;
const credentials = Buffer.from(`${username}:${password}`).toString("base64");
requestHeaders["Authorization"] = `Basic ${credentials}`;
} else if (restApiSettings.authentication?.type === "bearer") {
const { token } = restApiSettings.authentication;
requestHeaders["Authorization"] = `Bearer ${token}`;
}
// 요청 본문 준비
let requestBody = undefined;
if (["POST", "PUT", "PATCH"].includes(httpMethod.toUpperCase())) {
requestBody = JSON.stringify({
...requestData,
_context: contextData, // 컨텍스트 정보 추가
});
}
logger.info(`외부 API 호출: ${httpMethod} ${apiUrl}`);
// 실제 HTTP 요청 (여기서는 간단한 예시)
// 실제 구현에서는 axios나 fetch를 사용
const response = await fetch(apiUrl, {
method: httpMethod,
headers: requestHeaders,
body: requestBody,
signal: AbortSignal.timeout(timeout),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const responseData = await response.json();
return {
success: true,
data: responseData,
};
} catch (error) {
logger.error("외부 API 호출 실패:", error);
const errorMessage = error instanceof Error ? error.message : "알 수 없는 오류";
return {
success: false,
error: errorMessage,
};
}
}
/**
* 🔥 Outbound
*/
private async processOutboundMapping(
mapping: any,
sourceData: Record<string, any>
): Promise<Record<string, any>> {
try {
// 간단한 매핑 로직 (실제로는 더 복잡한 변환 로직 필요)
const mappedData: Record<string, any> = {};
if (mapping.fieldMappings) {
for (const fieldMapping of mapping.fieldMappings) {
const { sourceField, targetField, transformation } = fieldMapping;
let value = sourceData[sourceField];
// 변환 로직 적용
if (transformation) {
switch (transformation.type) {
case "format":
// 포맷 변환 로직
break;
case "calculate":
// 계산 로직
break;
default:
// 기본값 그대로 사용
break;
}
}
mappedData[targetField] = value;
}
}
return mappedData;
} catch (error) {
logger.error("Outbound 데이터 매핑 처리 실패:", error);
return sourceData; // 실패 시 원본 데이터 반환
}
}
/**
* 🔥 Inbound
*/
private async processInboundMapping(
mapping: any,
responseData: any
): Promise<void> {
try {
// Inbound 매핑 로직 (응답 데이터를 내부 시스템에 저장)
logger.info("Inbound 데이터 매핑 처리:", mapping);
// 실제 구현에서는 응답 데이터를 파싱하여 내부 테이블에 저장하는 로직 필요
// 예: 외부 API에서 받은 사용자 정보를 내부 사용자 테이블에 업데이트
} catch (error) {
logger.error("Inbound 데이터 매핑 처리 실패:", error);
// Inbound 매핑 실패는 전체 플로우를 중단하지 않음
}
}
}
export default new ExternalCallConfigService();

View File

@ -10,6 +10,11 @@ import {
SupportedExternalCallSettings,
TemplateOptions,
} from "../types/externalCallTypes";
import { DataMappingService } from "./dataMappingService";
import {
DataMappingConfig,
DataMappingResult,
} from "../types/dataMappingTypes";
/**
*
@ -18,10 +23,149 @@ import {
export class ExternalCallService {
private readonly DEFAULT_TIMEOUT = 30000; // 30초
private readonly DEFAULT_RETRY_COUNT = 3;
private dataMappingService: DataMappingService;
private readonly DEFAULT_RETRY_DELAY = 1000; // 1초
constructor() {
this.dataMappingService = new DataMappingService();
}
/**
*
*
*/
async executeWithDataMapping(
config: ExternalCallConfig,
dataMappingConfig?: DataMappingConfig,
triggerData?: any
): Promise<{
callResult: ExternalCallResult;
mappingResult?: DataMappingResult;
}> {
const startTime = Date.now();
console.log(`🚀 [ExternalCallService] 데이터 매핑 포함 외부 호출 시작:`, {
callType: config.callType,
hasMappingConfig: !!dataMappingConfig,
mappingDirection: dataMappingConfig?.direction,
});
try {
let requestData = config;
// Outbound 매핑 처리 (내부 → 외부)
if (
dataMappingConfig?.direction === "outbound" &&
dataMappingConfig.outboundMapping
) {
console.log(`📤 [ExternalCallService] Outbound 매핑 처리 시작`);
const outboundData = await this.dataMappingService.processOutboundData(
dataMappingConfig.outboundMapping,
triggerData
);
// API 요청 바디에 매핑된 데이터 포함
if (config.callType === "rest-api") {
// GenericApiSettings로 타입 캐스팅
const apiConfig = config as GenericApiSettings;
const bodyTemplate = apiConfig.body || "{}";
// 템플릿에 데이터 삽입
const processedBody = this.processTemplate(bodyTemplate, {
mappedData: outboundData,
triggerData,
...outboundData,
});
requestData = {
...config,
body: processedBody,
} as GenericApiSettings;
}
}
// 외부 호출 실행
const callRequest: ExternalCallRequest = {
diagramId: 0, // 임시값
relationshipId: "data-mapping", // 임시값
settings: requestData,
templateData: triggerData,
};
const callResult = await this.executeExternalCall(callRequest);
let mappingResult: DataMappingResult | undefined;
// Inbound 매핑 처리 (외부 → 내부)
if (
callResult.success &&
dataMappingConfig?.direction === "inbound" &&
dataMappingConfig.inboundMapping
) {
console.log(`📥 [ExternalCallService] Inbound 매핑 처리 시작`);
try {
// 응답 데이터 파싱
let responseData = callResult.response;
if (typeof responseData === "string") {
try {
responseData = JSON.parse(responseData);
} catch {
console.warn(
`⚠️ [ExternalCallService] 응답 데이터 JSON 파싱 실패, 문자열로 처리`
);
}
}
mappingResult = await this.dataMappingService.processInboundData(
responseData,
dataMappingConfig.inboundMapping
);
console.log(`✅ [ExternalCallService] Inbound 매핑 완료:`, {
recordsProcessed: mappingResult.recordsProcessed,
recordsInserted: mappingResult.recordsInserted,
});
} catch (error) {
console.error(`❌ [ExternalCallService] Inbound 매핑 실패:`, error);
mappingResult = {
success: false,
direction: "inbound",
errors: [error instanceof Error ? error.message : String(error)],
executionTime: Date.now() - startTime,
timestamp: new Date().toISOString(),
};
}
}
// 양방향 매핑 처리
if (dataMappingConfig?.direction === "bidirectional") {
// 필요한 경우 양방향 매핑 로직 구현
console.log(`🔄 [ExternalCallService] 양방향 매핑은 향후 구현 예정`);
}
const result = {
callResult,
mappingResult,
};
console.log(`✅ [ExternalCallService] 데이터 매핑 포함 외부 호출 완료:`, {
callSuccess: callResult.success,
mappingSuccess: mappingResult?.success,
totalExecutionTime: Date.now() - startTime,
});
return result;
} catch (error) {
console.error(
`❌ [ExternalCallService] 데이터 매핑 포함 외부 호출 실패:`,
error
);
throw error;
}
}
/**
* ( )
*/
async executeExternalCall(
request: ExternalCallRequest

View File

@ -0,0 +1,82 @@
/**
*
*/
export type DataDirection = "none" | "inbound" | "outbound" | "bidirectional";
export type InsertMode = "insert" | "upsert" | "update";
export type TransformType = "none" | "constant" | "format" | "function";
export type DataType = "string" | "number" | "boolean" | "date" | "json";
export interface FieldTransform {
type: TransformType;
value?: any;
format?: string;
functionName?: string;
}
export interface FieldMapping {
id: string;
sourceField: string;
targetField: string;
dataType: DataType;
transform?: FieldTransform;
required?: boolean;
defaultValue?: any;
}
export interface InboundMapping {
targetTable: string;
targetSchema?: string;
fieldMappings: FieldMapping[];
insertMode: InsertMode;
keyFields?: string[];
batchSize?: number;
}
export interface OutboundMapping {
sourceTable: string;
sourceSchema?: string;
sourceFilter?: string;
fieldMappings: FieldMapping[];
triggerCondition?: string;
}
export interface DataMappingConfig {
direction: DataDirection;
inboundMapping?: InboundMapping;
outboundMapping?: OutboundMapping;
}
export interface TableInfo {
name: string;
schema?: string;
displayName?: string;
fields: FieldInfo[];
}
export interface FieldInfo {
name: string;
dataType: DataType;
nullable: boolean;
isPrimaryKey?: boolean;
displayName?: string;
description?: string;
}
export interface DataMappingResult {
success: boolean;
direction: DataDirection;
recordsProcessed?: number;
recordsInserted?: number;
recordsUpdated?: number;
recordsSkipped?: number;
errors?: string[];
executionTime: number;
timestamp: string;
}
export interface MappingValidationResult {
isValid: boolean;
errors: string[];
warnings: string[];
}

View File

@ -105,7 +105,7 @@ export default function DataFlowPage() {
{/* 페이지 제목 */}
<div className="flex items-center justify-between rounded-lg border bg-white p-4 shadow-sm">
<div>
<h1 className="text-3xl font-bold text-gray-900"> </h1>
<h1 className="text-3xl font-bold text-gray-900"> </h1>
<p className="mt-2 text-gray-600"> </p>
</div>
</div>

View File

@ -89,7 +89,7 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({
return;
}, []);
// 편집 모드일 때 관계 데이터 로드
// 편집 모드일 때 관계 데이터 로드
useEffect(() => {
const loadDiagramData = async () => {
if (diagramId && diagramId > 0) {
@ -99,7 +99,7 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({
const jsonDiagram = await DataFlowAPI.getJsonDataFlowDiagramById(diagramId, companyCode);
if (jsonDiagram) {
// 관계 이름 설정
// 관계 이름 설정
if (jsonDiagram.diagram_name) {
setCurrentDiagramName(jsonDiagram.diagram_name);
}

View File

@ -96,14 +96,14 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
setTotal(response.pagination.total || 0);
setTotalPages(Math.max(1, Math.ceil((response.pagination.total || 0) / 20)));
} catch (error) {
console.error("관계 목록 조회 실패", error);
toast.error("관계 목록을 불러오는데 실패했습니다.");
console.error("관계 목록 조회 실패", error);
toast.error("관계 목록을 불러오는데 실패했습니다.");
} finally {
setLoading(false);
}
}, [currentPage, searchTerm, companyCode]);
// 관계 목록 로드
// 관계 목록 로드
useEffect(() => {
loadDiagrams();
}, [loadDiagrams]);
@ -130,13 +130,13 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
undefined,
user?.userId || "SYSTEM",
);
toast.success(`관계가 성공적으로 복사되었습니다: ${copiedDiagram.diagram_name}`);
toast.success(`관계가 성공적으로 복사되었습니다: ${copiedDiagram.diagram_name}`);
// 목록 새로고침
await loadDiagrams();
} catch (error) {
console.error("관계 복사 실패:", error);
toast.error("관계 복사에 실패했습니다.");
console.error("관계 복사 실패:", error);
toast.error("관계 복사에 실패했습니다.");
} finally {
setLoading(false);
setShowCopyModal(false);
@ -151,13 +151,13 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
try {
setLoading(true);
await DataFlowAPI.deleteJsonDataFlowDiagram(selectedDiagramForAction.diagramId, companyCode);
toast.success(`관계가 삭제되었습니다: ${selectedDiagramForAction.diagramName}`);
toast.success(`관계가 삭제되었습니다: ${selectedDiagramForAction.diagramName}`);
// 목록 새로고침
await loadDiagrams();
} catch (error) {
console.error("관계 삭제 실패:", error);
toast.error("관계 삭제에 실패했습니다.");
console.error("관계 삭제 실패:", error);
toast.error("관계 삭제에 실패했습니다.");
} finally {
setLoading(false);
setShowDeleteModal(false);
@ -181,7 +181,7 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
<div className="relative">
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform text-gray-400" />
<Input
placeholder="관계명, 테이블명으로 검색..."
placeholder="관계명, 테이블명으로 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-80 pl-10"
@ -189,17 +189,17 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
</div>
</div>
<Button className="bg-blue-600 hover:bg-blue-700" onClick={() => onDesignDiagram(null)}>
<Plus className="mr-2 h-4 w-4" />
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
{/* 관계 목록 테이블 */}
{/* 관계 목록 테이블 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span className="flex items-center">
<Network className="mr-2 h-5 w-5" />
({total})
({total})
</span>
</CardTitle>
</CardHeader>
@ -207,7 +207,7 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead> </TableHead>
<TableHead> </TableHead>
<TableHead> </TableHead>
@ -284,8 +284,8 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
{diagrams.length === 0 && (
<div className="py-8 text-center text-gray-500">
<Network className="mx-auto mb-4 h-12 w-12 text-gray-300" />
<div className="mb-2 text-lg font-medium"></div>
<div className="text-sm"> .</div>
<div className="mb-2 text-lg font-medium"></div>
<div className="text-sm"> .</div>
</div>
)}
</CardContent>
@ -320,11 +320,11 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
<Dialog open={showCopyModal} onOpenChange={setShowCopyModal}>
<DialogContent>
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogTitle> </DialogTitle>
<DialogDescription>
&ldquo;{selectedDiagramForAction?.diagramName}&rdquo; ?
&ldquo;{selectedDiagramForAction?.diagramName}&rdquo; ?
<br />
(1), (2), (3)... .
(1), (2), (3)... .
</DialogDescription>
</DialogHeader>
<DialogFooter>
@ -342,9 +342,9 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
<Dialog open={showDeleteModal} onOpenChange={setShowDeleteModal}>
<DialogContent>
<DialogHeader>
<DialogTitle className="text-red-600"> </DialogTitle>
<DialogTitle className="text-red-600"> </DialogTitle>
<DialogDescription>
&ldquo;{selectedDiagramForAction?.diagramName}&rdquo; ?
&ldquo;{selectedDiagramForAction?.diagramName}&rdquo; ?
<br />
<span className="font-medium text-red-600">
, .

View File

@ -65,7 +65,7 @@ export const DataFlowSidebar: React.FC<DataFlowSidebarProps> = ({
hasUnsavedChanges ? "animate-pulse" : ""
}`}
>
💾 {tempRelationships.length > 0 && `(${tempRelationships.length})`}
💾 {tempRelationships.length > 0 && `(${tempRelationships.length})`}
</button>
</div>

View File

@ -622,7 +622,57 @@ const DataConnectionDesigner: React.FC<DataConnectionDesignerProps> = ({
company_code: "*", // 기본값
};
const configResult = await ExternalCallConfigAPI.createConfig(configData);
let configResult;
if (diagramId) {
// 수정 모드: 기존 설정이 있는지 확인하고 업데이트 또는 생성
console.log("🔄 수정 모드 - 외부호출 설정 처리");
try {
// 먼저 기존 설정 조회 시도
const existingConfigs = await ExternalCallConfigAPI.getConfigs({
company_code: "*",
is_active: "Y",
});
const existingConfig = existingConfigs.data?.find(
(config: any) => config.config_name === (state.relationshipName || "외부호출 설정")
);
if (existingConfig) {
// 기존 설정 업데이트
console.log("📝 기존 외부호출 설정 업데이트:", existingConfig.id);
configResult = await ExternalCallConfigAPI.updateConfig(existingConfig.id, configData);
} else {
// 기존 설정이 없으면 새로 생성
console.log("🆕 새 외부호출 설정 생성 (수정 모드)");
configResult = await ExternalCallConfigAPI.createConfig(configData);
}
} catch (updateError) {
// 중복 생성 오류인 경우 무시하고 계속 진행
if (updateError.message && updateError.message.includes("이미 존재합니다")) {
console.log("⚠️ 외부호출 설정이 이미 존재함 - 기존 설정 사용");
configResult = { success: true, message: "기존 외부호출 설정 사용" };
} else {
console.warn("⚠️ 외부호출 설정 처리 실패:", updateError);
throw updateError;
}
}
} else {
// 신규 생성 모드
console.log("🆕 신규 생성 모드 - 외부호출 설정 생성");
try {
configResult = await ExternalCallConfigAPI.createConfig(configData);
} catch (createError) {
// 중복 생성 오류인 경우 무시하고 계속 진행
if (createError.message && createError.message.includes("이미 존재합니다")) {
console.log("⚠️ 외부호출 설정이 이미 존재함 - 기존 설정 사용");
configResult = { success: true, message: "기존 외부호출 설정 사용" };
} else {
throw createError;
}
}
}
if (!configResult.success) {
throw new Error(configResult.error || "외부호출 설정 저장 실패");

View File

@ -0,0 +1,394 @@
"use client";
import React, { useState, useCallback, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { Plus, Trash2, Database, ArrowRight, Settings } from "lucide-react";
import {
DataMappingConfig,
DataDirection,
TableInfo,
FieldMapping,
InboundMapping,
OutboundMapping,
DATA_DIRECTION_OPTIONS,
INSERT_MODE_OPTIONS,
} from "@/types/external-call/DataMappingTypes";
import { FieldMappingEditor } from "./FieldMappingEditor";
interface DataMappingSettingsProps {
config: DataMappingConfig;
onConfigChange: (config: DataMappingConfig) => void;
httpMethod: string;
availableTables?: TableInfo[];
readonly?: boolean;
tablesLoading?: boolean;
}
export const DataMappingSettings: React.FC<DataMappingSettingsProps> = ({
config,
onConfigChange,
httpMethod,
availableTables = [],
readonly = false,
tablesLoading = false,
}) => {
const [localConfig, setLocalConfig] = useState<DataMappingConfig>(config);
// 컴포넌트 변경 시 로컬 상태 동기화
useEffect(() => {
setLocalConfig(config);
}, [config]);
// HTTP 메서드에 따른 권장 방향 결정
const getRecommendedDirection = useCallback((method: string): DataDirection => {
const upperMethod = method.toUpperCase();
if (upperMethod === "GET") return "inbound";
if (["POST", "PUT", "PATCH"].includes(upperMethod)) return "outbound";
return "none";
}, []);
// 방향 변경 핸들러
const handleDirectionChange = useCallback(
(direction: DataDirection) => {
const newConfig = {
...localConfig,
direction,
// 방향에 따라 불필요한 매핑 제거
inboundMapping:
direction === "inbound" || direction === "bidirectional"
? localConfig.inboundMapping || {
targetTable: "",
fieldMappings: [],
insertMode: "insert" as const,
}
: undefined,
outboundMapping:
direction === "outbound" || direction === "bidirectional"
? localConfig.outboundMapping || {
sourceTable: "",
fieldMappings: [],
}
: undefined,
};
setLocalConfig(newConfig);
onConfigChange(newConfig);
},
[localConfig, onConfigChange],
);
// Inbound 매핑 업데이트
const handleInboundMappingChange = useCallback(
(mapping: Partial<InboundMapping>) => {
const newConfig = {
...localConfig,
inboundMapping: {
...localConfig.inboundMapping!,
...mapping,
},
};
setLocalConfig(newConfig);
onConfigChange(newConfig);
},
[localConfig, onConfigChange],
);
// Outbound 매핑 업데이트
const handleOutboundMappingChange = useCallback(
(mapping: Partial<OutboundMapping>) => {
const newConfig = {
...localConfig,
outboundMapping: {
...localConfig.outboundMapping!,
...mapping,
},
};
setLocalConfig(newConfig);
onConfigChange(newConfig);
},
[localConfig, onConfigChange],
);
// 필드 매핑 업데이트 (Inbound)
const handleInboundFieldMappingsChange = useCallback(
(fieldMappings: FieldMapping[]) => {
handleInboundMappingChange({ fieldMappings });
},
[handleInboundMappingChange],
);
// 필드 매핑 업데이트 (Outbound)
const handleOutboundFieldMappingsChange = useCallback(
(fieldMappings: FieldMapping[]) => {
handleOutboundMappingChange({ fieldMappings });
},
[handleOutboundMappingChange],
);
// 검증 함수
const isConfigValid = useCallback(() => {
if (localConfig.direction === "none") return true;
if (
(localConfig.direction === "inbound" || localConfig.direction === "bidirectional") &&
localConfig.inboundMapping
) {
if (!localConfig.inboundMapping.targetTable) return false;
if (localConfig.inboundMapping.fieldMappings.length === 0) return false;
}
if (
(localConfig.direction === "outbound" || localConfig.direction === "bidirectional") &&
localConfig.outboundMapping
) {
if (!localConfig.outboundMapping.sourceTable) return false;
if (localConfig.outboundMapping.fieldMappings.length === 0) return false;
}
return true;
}, [localConfig]);
const recommendedDirection = getRecommendedDirection(httpMethod);
return (
<Card className="w-full">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2">
<Database className="h-5 w-5" />
{!isConfigValid() && <Badge variant="destructive"> </Badge>}
{isConfigValid() && localConfig.direction !== "none" && <Badge variant="default"> </Badge>}
</CardTitle>
<p className="text-muted-foreground text-sm"> API와 .</p>
</CardHeader>
<CardContent className="space-y-4">
{/* 매핑 방향 선택 */}
<div className="space-y-2">
<Label> </Label>
<Select value={localConfig.direction} onValueChange={handleDirectionChange} disabled={readonly}>
<SelectTrigger>
<SelectValue placeholder="매핑 방향을 선택하세요" />
</SelectTrigger>
<SelectContent>
{DATA_DIRECTION_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
<div className="flex items-center gap-2">
{option.label}
{option.value === recommendedDirection && (
<Badge variant="secondary" className="text-xs">
</Badge>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{localConfig.direction !== recommendedDirection && recommendedDirection !== "none" && (
<p className="text-xs text-amber-600">
💡 {httpMethod} "{DATA_DIRECTION_OPTIONS.find((o) => o.value === recommendedDirection)?.label}"
.
</p>
)}
</div>
{/* 매핑 설정 탭 */}
{localConfig.direction !== "none" && (
<Tabs
defaultValue={localConfig.direction === "bidirectional" ? "inbound" : localConfig.direction}
className="w-full"
>
<TabsList className="grid w-full grid-cols-2">
{(localConfig.direction === "inbound" || localConfig.direction === "bidirectional") && (
<TabsTrigger value="inbound">
<ArrowRight className="mr-1 h-4 w-4" />
</TabsTrigger>
)}
{(localConfig.direction === "outbound" || localConfig.direction === "bidirectional") && (
<TabsTrigger value="outbound">
<ArrowRight className="mr-1 h-4 w-4 rotate-180" />
</TabsTrigger>
)}
</TabsList>
{/* Inbound 매핑 설정 */}
{(localConfig.direction === "inbound" || localConfig.direction === "bidirectional") && (
<TabsContent value="inbound" className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label> </Label>
<Select
value={localConfig.inboundMapping?.targetTable || ""}
onValueChange={(value) => handleInboundMappingChange({ targetTable: value })}
disabled={readonly || tablesLoading}
>
<SelectTrigger>
<SelectValue placeholder={tablesLoading ? "테이블 목록 로딩 중..." : "저장할 테이블을 선택하세요"} />
</SelectTrigger>
<SelectContent>
{tablesLoading ? (
<SelectItem value="" disabled>
...
</SelectItem>
) : availableTables.length === 0 ? (
<SelectItem value="" disabled>
</SelectItem>
) : (
availableTables.map((table) => (
<SelectItem key={table.name} value={table.name}>
{table.displayName || table.name}
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label> </Label>
<Select
value={localConfig.inboundMapping?.insertMode || "insert"}
onValueChange={(value) => handleInboundMappingChange({ insertMode: value as any })}
disabled={readonly}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{INSERT_MODE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 키 필드 설정 (upsert/update 모드일 때) */}
{localConfig.inboundMapping?.insertMode !== "insert" && (
<div className="space-y-2">
<Label> </Label>
<Input
value={localConfig.inboundMapping?.keyFields?.join(", ") || ""}
onChange={(e) =>
handleInboundMappingChange({
keyFields: e.target.value
.split(",")
.map((s) => s.trim())
.filter(Boolean),
})
}
placeholder="id, code"
disabled={readonly}
/>
<p className="text-muted-foreground text-xs">
/ .
</p>
</div>
)}
{/* 필드 매핑 에디터 */}
{localConfig.inboundMapping?.targetTable && (
<FieldMappingEditor
mappings={localConfig.inboundMapping.fieldMappings}
onMappingsChange={handleInboundFieldMappingsChange}
direction="inbound"
targetTable={availableTables.find((t) => t.name === localConfig.inboundMapping?.targetTable)}
readonly={readonly}
/>
)}
</TabsContent>
)}
{/* Outbound 매핑 설정 */}
{(localConfig.direction === "outbound" || localConfig.direction === "bidirectional") && (
<TabsContent value="outbound" className="space-y-4">
<div className="grid grid-cols-1 gap-4">
<div className="space-y-2">
<Label> </Label>
<Select
value={localConfig.outboundMapping?.sourceTable || ""}
onValueChange={(value) => handleOutboundMappingChange({ sourceTable: value })}
disabled={readonly}
>
<SelectTrigger>
<SelectValue placeholder="데이터를 가져올 테이블을 선택하세요" />
</SelectTrigger>
<SelectContent>
{availableTables.map((table) => (
<SelectItem key={table.name} value={table.name}>
{table.displayName || table.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 소스 필터 조건 */}
<div className="space-y-2">
<Label> ()</Label>
<Textarea
value={localConfig.outboundMapping?.sourceFilter || ""}
onChange={(e) => handleOutboundMappingChange({ sourceFilter: e.target.value })}
placeholder="status = 'active' AND created_at >= '2024-01-01'"
disabled={readonly}
rows={2}
/>
<p className="text-muted-foreground text-xs">
WHERE . .
</p>
</div>
{/* 필드 매핑 에디터 */}
{localConfig.outboundMapping?.sourceTable && (
<FieldMappingEditor
mappings={localConfig.outboundMapping.fieldMappings}
onMappingsChange={handleOutboundFieldMappingsChange}
direction="outbound"
sourceTable={availableTables.find((t) => t.name === localConfig.outboundMapping?.sourceTable)}
readonly={readonly}
/>
)}
</TabsContent>
)}
</Tabs>
)}
{/* 설정 요약 */}
{localConfig.direction !== "none" && (
<div className="bg-muted mt-4 rounded-lg p-3">
<h4 className="mb-2 text-sm font-medium"> </h4>
<div className="text-muted-foreground space-y-1 text-xs">
<div>: {DATA_DIRECTION_OPTIONS.find((o) => o.value === localConfig.direction)?.label}</div>
{localConfig.inboundMapping && (
<div>
{localConfig.inboundMapping.targetTable}({localConfig.inboundMapping.fieldMappings.length}
)
</div>
)}
{localConfig.outboundMapping && (
<div>
{localConfig.outboundMapping.sourceTable} ({localConfig.outboundMapping.fieldMappings.length}
)
</div>
)}
</div>
</div>
)}
</CardContent>
</Card>
);
};

View File

@ -15,10 +15,16 @@ import {
RestApiSettings as RestApiSettingsType,
ApiTestResult,
} from "@/types/external-call/ExternalCallTypes";
import { DataMappingConfig, TableInfo } from "@/types/external-call/DataMappingTypes";
// API import
import { DataFlowAPI } from "@/lib/api/dataflow";
import { toast } from "sonner";
// 하위 컴포넌트 import
import RestApiSettings from "./RestApiSettings";
import ExternalCallTestPanel from "./ExternalCallTestPanel";
import { DataMappingSettings } from "./DataMappingSettings";
/**
* 🌐
@ -39,8 +45,14 @@ const ExternalCallPanel: React.FC<ExternalCallPanelProps> = ({
});
// 상태 관리
const [config, setConfig] = useState<ExternalCallConfig>(
() =>
initialSettings || {
() => {
if (initialSettings) {
console.log("🔄 [ExternalCallPanel] 기존 설정 로드:", initialSettings);
return initialSettings;
}
console.log("🔄 [ExternalCallPanel] 기본 설정 사용");
return {
callType: "rest-api",
restApiSettings: {
apiUrl: "",
@ -61,13 +73,79 @@ const ExternalCallPanel: React.FC<ExternalCallPanelProps> = ({
timeout: 30000, // 30초
retryCount: 3,
},
},
};
},
);
const [activeTab, setActiveTab] = useState<string>("settings");
const [lastTestResult, setLastTestResult] = useState<ApiTestResult | null>(null);
const [isConfigValid, setIsConfigValid] = useState<boolean>(false);
// 데이터 매핑 상태
const [dataMappingConfig, setDataMappingConfig] = useState<DataMappingConfig>(() => {
// initialSettings에서 데이터 매핑 정보 불러오기
if (initialSettings?.dataMappingConfig) {
console.log("🔄 [ExternalCallPanel] 기존 데이터 매핑 설정 로드:", initialSettings.dataMappingConfig);
return initialSettings.dataMappingConfig;
}
console.log("🔄 [ExternalCallPanel] 기본 데이터 매핑 설정 사용");
return {
direction: "none",
};
});
// 사용 가능한 테이블 목록 (실제 API에서 로드)
const [availableTables, setAvailableTables] = useState<TableInfo[]>([]);
const [tablesLoading, setTablesLoading] = useState(false);
// 테이블 목록 로드
useEffect(() => {
const loadTables = async () => {
try {
setTablesLoading(true);
const tables = await DataFlowAPI.getTables();
// 테이블 정보를 TableInfo 형식으로 변환
const tableInfos: TableInfo[] = await Promise.all(
tables.map(async (table) => {
try {
const columns = await DataFlowAPI.getTableColumns(table.tableName);
return {
name: table.tableName,
displayName: table.displayName || table.tableName,
fields: columns.map((col) => ({
name: col.columnName,
dataType: col.dataType,
nullable: col.nullable,
isPrimaryKey: col.isPrimaryKey || false,
})),
};
} catch (error) {
console.warn(`테이블 ${table.tableName} 컬럼 정보 로드 실패:`, error);
return {
name: table.tableName,
displayName: table.displayName || table.tableName,
fields: [],
};
}
})
);
setAvailableTables(tableInfos);
} catch (error) {
console.error("테이블 목록 로드 실패:", error);
toast.error("테이블 목록을 불러오는데 실패했습니다.");
// 실패 시 빈 배열로 설정
setAvailableTables([]);
} finally {
setTablesLoading(false);
}
};
loadTables();
}, []);
// 설정 변경 핸들러
const handleRestApiSettingsChange = useCallback(
(newSettings: RestApiSettingsType) => {
@ -82,7 +160,26 @@ const ExternalCallPanel: React.FC<ExternalCallPanelProps> = ({
};
setConfig(updatedConfig);
onSettingsChange(updatedConfig);
onSettingsChange({
...updatedConfig,
dataMappingConfig,
});
},
[config, onSettingsChange, dataMappingConfig],
);
// 데이터 매핑 설정 변경 핸들러
const handleDataMappingConfigChange = useCallback(
(newMappingConfig: DataMappingConfig) => {
console.log("🔄 [ExternalCallPanel] 데이터 매핑 설정 변경:", newMappingConfig);
setDataMappingConfig(newMappingConfig);
// 전체 설정에 데이터 매핑 정보 포함하여 상위로 전달
onSettingsChange({
...config,
dataMappingConfig: newMappingConfig,
});
},
[config, onSettingsChange],
);
@ -141,10 +238,13 @@ const ExternalCallPanel: React.FC<ExternalCallPanelProps> = ({
{/* 메인 탭 컨텐츠 */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex w-full flex-1 flex-col overflow-hidden">
<TabsList className="grid w-full grid-cols-4">
<TabsList className="grid w-full grid-cols-5">
<TabsTrigger value="settings" className="flex items-center gap-2">
<Settings className="h-4 w-4" />
API
</TabsTrigger>
<TabsTrigger value="mapping" className="flex items-center gap-2">
🔄
</TabsTrigger>
<TabsTrigger value="test" className="flex items-center gap-2">
<TestTube className="h-4 w-4" />
@ -160,7 +260,7 @@ const ExternalCallPanel: React.FC<ExternalCallPanelProps> = ({
</TabsTrigger>
</TabsList>
{/* 설정 탭 */}
{/* API 설정 탭 */}
<TabsContent value="settings" className="flex-1 space-y-2 overflow-y-auto">
<RestApiSettings
settings={config.restApiSettings}
@ -169,6 +269,18 @@ const ExternalCallPanel: React.FC<ExternalCallPanelProps> = ({
/>
</TabsContent>
{/* 데이터 매핑 탭 */}
<TabsContent value="mapping" className="flex-1 space-y-2 overflow-y-auto">
<DataMappingSettings
config={dataMappingConfig}
onConfigChange={handleDataMappingConfigChange}
httpMethod={config.restApiSettings?.httpMethod || "GET"}
availableTables={availableTables}
readonly={readonly}
tablesLoading={tablesLoading}
/>
</TabsContent>
{/* 테스트 탭 */}
<TabsContent value="test" className="flex-1 space-y-4 overflow-y-auto">
{isConfigValid ? (

View File

@ -0,0 +1,400 @@
"use client";
import React, { useState, useCallback } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import { Badge } from "@/components/ui/badge";
import { Plus, Trash2, ArrowRight, Settings, Eye, EyeOff, RefreshCw, Database, Globe } from "lucide-react";
import {
FieldMapping,
TableInfo,
FieldInfo,
DataDirection,
DATA_TYPE_OPTIONS,
TRANSFORM_TYPE_OPTIONS,
} from "@/types/external-call/DataMappingTypes";
interface FieldMappingEditorProps {
mappings: FieldMapping[];
onMappingsChange: (mappings: FieldMapping[]) => void;
direction: "inbound" | "outbound";
sourceTable?: TableInfo; // outbound용
targetTable?: TableInfo; // inbound용
readonly?: boolean;
}
export const FieldMappingEditor: React.FC<FieldMappingEditorProps> = ({
mappings,
onMappingsChange,
direction,
sourceTable,
targetTable,
readonly = false,
}) => {
const [showAdvanced, setShowAdvanced] = useState(false);
const [sampleApiData, setSampleApiData] = useState("");
// 새 매핑 추가
const addMapping = useCallback(() => {
const newMapping: FieldMapping = {
id: `mapping-${Date.now()}`,
sourceField: "",
targetField: "",
dataType: "string",
required: false,
};
onMappingsChange([...mappings, newMapping]);
}, [mappings, onMappingsChange]);
// 매핑 삭제
const removeMapping = useCallback(
(id: string) => {
onMappingsChange(mappings.filter((m) => m.id !== id));
},
[mappings, onMappingsChange],
);
// 매핑 업데이트
const updateMapping = useCallback(
(id: string, updates: Partial<FieldMapping>) => {
onMappingsChange(mappings.map((m) => (m.id === id ? { ...m, ...updates } : m)));
},
[mappings, onMappingsChange],
);
// 자동 매핑 (이름 기반)
const autoMapFields = useCallback(() => {
const currentTable = direction === "inbound" ? targetTable : sourceTable;
if (!currentTable) return;
const newMappings: FieldMapping[] = [];
currentTable.fields.forEach((field) => {
// 이미 매핑된 필드는 건너뛰기
const existingMapping = mappings.find((m) =>
direction === "inbound" ? m.targetField === field.name : m.sourceField === field.name,
);
if (existingMapping) return;
const mapping: FieldMapping = {
id: `auto-${field.name}-${Date.now()}`,
sourceField: direction === "inbound" ? field.name : field.name,
targetField: direction === "inbound" ? field.name : field.name,
dataType: field.dataType,
required: !field.nullable,
};
newMappings.push(mapping);
});
onMappingsChange([...mappings, ...newMappings]);
}, [direction, targetTable, sourceTable, mappings, onMappingsChange]);
// 샘플 데이터에서 필드 추출
const extractFieldsFromSample = useCallback(() => {
if (!sampleApiData.trim()) return;
try {
const parsed = JSON.parse(sampleApiData);
const fields = Object.keys(parsed);
const newMappings: FieldMapping[] = [];
fields.forEach((fieldName) => {
// 이미 매핑된 필드는 건너뛰기
const existingMapping = mappings.find((m) =>
direction === "inbound" ? m.sourceField === fieldName : m.targetField === fieldName,
);
if (existingMapping) return;
// 데이터 타입 추론
const value = parsed[fieldName];
let dataType: any = "string";
if (typeof value === "number") dataType = "number";
else if (typeof value === "boolean") dataType = "boolean";
else if (value instanceof Date || /^\d{4}-\d{2}-\d{2}/.test(value)) dataType = "date";
const mapping: FieldMapping = {
id: `sample-${fieldName}-${Date.now()}`,
sourceField: direction === "inbound" ? fieldName : "",
targetField: direction === "inbound" ? "" : fieldName,
dataType,
required: false,
};
newMappings.push(mapping);
});
onMappingsChange([...mappings, ...newMappings]);
setSampleApiData("");
} catch (error) {
console.error("샘플 데이터 파싱 실패:", error);
}
}, [sampleApiData, direction, mappings, onMappingsChange]);
const currentTable = direction === "inbound" ? targetTable : sourceTable;
return (
<Card className="w-full">
<CardHeader className="pb-3">
<CardTitle className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Settings className="h-4 w-4" />
<Badge variant="outline">{mappings.length} </Badge>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setShowAdvanced(!showAdvanced)}>
{showAdvanced ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
{showAdvanced ? "간단히" : "고급"}
</Button>
{!readonly && (
<Button variant="outline" size="sm" onClick={autoMapFields} disabled={!currentTable}>
<RefreshCw className="h-3 w-3" />
</Button>
)}
</div>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 샘플 데이터 입력 (고급 모드) */}
{showAdvanced && !readonly && (
<div className="bg-muted space-y-2 rounded-lg p-3">
<Label className="text-sm"> API (JSON)</Label>
<Textarea
value={sampleApiData}
onChange={(e) => setSampleApiData(e.target.value)}
placeholder='{"name": "홍길동", "age": 30, "email": "test@example.com"}'
rows={3}
/>
<Button size="sm" onClick={extractFieldsFromSample} disabled={!sampleApiData.trim()}>
</Button>
</div>
)}
{/* 매핑 목록 */}
<div className="space-y-3">
{mappings.map((mapping) => (
<Card key={mapping.id} className="p-3">
<div className="grid grid-cols-12 items-center gap-2">
{/* 소스 필드 */}
<div className="col-span-4">
<Label className="text-xs">{direction === "inbound" ? "외부 필드" : "내부 필드"}</Label>
<div className="mt-1 flex items-center gap-1">
{direction === "inbound" ? (
<Globe className="h-3 w-3 text-blue-500" />
) : (
<Database className="h-3 w-3 text-green-500" />
)}
<Input
value={mapping.sourceField}
onChange={(e) => updateMapping(mapping.id, { sourceField: e.target.value })}
placeholder={direction === "inbound" ? "API 필드명" : "테이블 컬럼명"}
size="sm"
disabled={readonly}
/>
</div>
</div>
{/* 화살표 */}
<div className="col-span-1 flex justify-center">
<ArrowRight className="text-muted-foreground h-4 w-4" />
</div>
{/* 타겟 필드 */}
<div className="col-span-4">
<Label className="text-xs">{direction === "inbound" ? "내부 필드" : "외부 필드"}</Label>
<div className="mt-1 flex items-center gap-1">
{direction === "inbound" ? (
<Database className="h-3 w-3 text-green-500" />
) : (
<Globe className="h-3 w-3 text-blue-500" />
)}
{direction === "inbound" && currentTable ? (
<Select
value={mapping.targetField}
onValueChange={(value) => {
const field = currentTable.fields.find((f) => f.name === value);
updateMapping(mapping.id, {
targetField: value,
dataType: field?.dataType || mapping.dataType,
});
}}
disabled={readonly}
>
<SelectTrigger size="sm">
<SelectValue placeholder="테이블 컬럼 선택" />
</SelectTrigger>
<SelectContent>
{currentTable.fields.map((field) => (
<SelectItem key={field.name} value={field.name}>
<div className="flex items-center gap-2">
{field.name}
<Badge variant="outline" className="text-xs">
{field.dataType}
</Badge>
{field.isPrimaryKey && (
<Badge variant="default" className="text-xs">
PK
</Badge>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={mapping.targetField}
onChange={(e) => updateMapping(mapping.id, { targetField: e.target.value })}
placeholder={direction === "inbound" ? "테이블 컬럼명" : "API 필드명"}
size="sm"
disabled={readonly}
/>
)}
</div>
</div>
{/* 데이터 타입 */}
<div className="col-span-2">
<Label className="text-xs"></Label>
<Select
value={mapping.dataType}
onValueChange={(value: any) => updateMapping(mapping.id, { dataType: value })}
disabled={readonly}
>
<SelectTrigger size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DATA_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 삭제 버튼 */}
<div className="col-span-1">
{!readonly && (
<Button variant="ghost" size="sm" onClick={() => removeMapping(mapping.id)} className="h-8 w-8 p-0">
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
</div>
{/* 고급 설정 */}
{showAdvanced && (
<div className="mt-3 grid grid-cols-2 gap-3 border-t pt-3">
<div className="flex items-center space-x-2">
<Checkbox
id={`required-${mapping.id}`}
checked={mapping.required || false}
onCheckedChange={(checked) => updateMapping(mapping.id, { required: checked as boolean })}
disabled={readonly}
/>
<Label htmlFor={`required-${mapping.id}`} className="text-xs">
</Label>
</div>
<div className="space-y-1">
<Label className="text-xs"></Label>
<Input
value={mapping.defaultValue || ""}
onChange={(e) => updateMapping(mapping.id, { defaultValue: e.target.value })}
placeholder="기본값"
size="sm"
disabled={readonly}
/>
</div>
{/* 변환 설정 */}
<div className="col-span-2 space-y-2">
<Label className="text-xs"> </Label>
<div className="grid grid-cols-3 gap-2">
<Select
value={mapping.transform?.type || "none"}
onValueChange={(value: any) =>
updateMapping(mapping.id, {
transform: { ...mapping.transform, type: value },
})
}
disabled={readonly}
>
<SelectTrigger size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TRANSFORM_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{mapping.transform?.type === "constant" && (
<Input
value={mapping.transform.value || ""}
onChange={(e) =>
updateMapping(mapping.id, {
transform: { ...mapping.transform, value: e.target.value },
})
}
placeholder="상수값"
size="sm"
disabled={readonly}
/>
)}
{mapping.transform?.type === "format" && (
<Input
value={mapping.transform.format || ""}
onChange={(e) =>
updateMapping(mapping.id, {
transform: { ...mapping.transform, format: e.target.value },
})
}
placeholder="YYYY-MM-DD"
size="sm"
disabled={readonly}
/>
)}
</div>
</div>
</div>
)}
</Card>
))}
</div>
{/* 매핑 추가 버튼 */}
{!readonly && (
<Button variant="outline" onClick={addMapping} className="w-full">
<Plus className="mr-2 h-4 w-4" />
</Button>
)}
{/* 매핑 상태 */}
{mappings.length === 0 && (
<div className="text-muted-foreground py-6 text-center">
<Database className="mx-auto mb-2 h-8 w-8 opacity-50" />
<p className="text-sm"> .</p>
<p className="text-xs"> .</p>
</div>
)}
</CardContent>
</Card>
);
};

View File

@ -12,6 +12,7 @@ import { cn } from "@/lib/utils";
import { ComponentData } from "@/types/screen";
import { apiClient } from "@/lib/api/client";
import { ButtonDataflowConfigPanel } from "./ButtonDataflowConfigPanel";
import { ImprovedButtonControlConfigPanel } from "./ImprovedButtonControlConfigPanel";
interface ButtonConfigPanelProps {
component: ComponentData;
@ -526,7 +527,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
<p className="mt-1 text-sm text-gray-600"> </p>
</div>
<ButtonDataflowConfigPanel component={component} onUpdateProperty={onUpdateProperty} />
<ImprovedButtonControlConfigPanel component={component} onUpdateProperty={onUpdateProperty} />
</div>
</div>
);

View File

@ -37,7 +37,7 @@ interface RelationshipOption {
* 🔥 (Phase 1: 간편 )
*
* :
* -
* -
* - "after"
* - Phase 2
*/
@ -57,14 +57,14 @@ export const ButtonDataflowConfigPanel: React.FC<ButtonDataflowConfigPanelProps>
const [relationshipOpen, setRelationshipOpen] = useState(false);
const [previewData, setPreviewData] = useState<any>(null);
// 🔥 관계 목록 로딩
// 🔥 관계 목록 로딩
useEffect(() => {
if (config.enableDataflowControl) {
loadDiagrams();
}
}, [config.enableDataflowControl]);
// 🔥 관계 변경 시 관계 목록 로딩
// 🔥 관계 변경 시 관계 목록 로딩
useEffect(() => {
if (dataflowConfig.selectedDiagramId) {
loadRelationships(dataflowConfig.selectedDiagramId);
@ -72,12 +72,12 @@ export const ButtonDataflowConfigPanel: React.FC<ButtonDataflowConfigPanelProps>
}, [dataflowConfig.selectedDiagramId]);
/**
* 🔥 ( )
* 🔥 ( )
*/
const loadDiagrams = async () => {
try {
setDiagramsLoading(true);
console.log("🔍 데이터플로우 관계 목록 로딩...");
console.log("🔍 데이터플로우 관계 목록 로딩...");
const response = await apiClient.get("/test-button-dataflow/diagrams");
@ -90,10 +90,10 @@ export const ButtonDataflowConfigPanel: React.FC<ButtonDataflowConfigPanelProps>
}));
setDiagrams(diagramList);
console.log(`✅ 관계 ${diagramList.length}개 로딩 완료`);
console.log(`✅ 관계 ${diagramList.length}개 로딩 완료`);
}
} catch (error) {
console.error("❌ 관계 목록 로딩 실패:", error);
console.error("❌ 관계 목록 로딩 실패:", error);
setDiagrams([]);
} finally {
setDiagramsLoading(false);
@ -106,7 +106,7 @@ export const ButtonDataflowConfigPanel: React.FC<ButtonDataflowConfigPanelProps>
const loadRelationships = async (diagramId: number) => {
try {
setRelationshipsLoading(true);
console.log(`🔍 관계 ${diagramId} 관계 목록 로딩...`);
console.log(`🔍 관계 ${diagramId} 관계 목록 로딩...`);
const response = await apiClient.get(`/test-button-dataflow/diagrams/${diagramId}/relationships`);
@ -216,7 +216,7 @@ export const ButtonDataflowConfigPanel: React.FC<ButtonDataflowConfigPanelProps>
}
};
// 선택된 관계 정보
// 선택된 관계 정보
const selectedDiagram = diagrams.find((d) => d.id === dataflowConfig.selectedDiagramId);
const selectedRelationship = relationships.find((r) => r.id === dataflowConfig.selectedRelationshipId);
@ -324,7 +324,7 @@ export const ButtonDataflowConfigPanel: React.FC<ButtonDataflowConfigPanelProps>
<SelectValue placeholder="제어 모드를 선택하세요" />
</SelectTrigger>
<SelectContent>
<SelectItem value="simple"> ( )</SelectItem>
<SelectItem value="simple"> ( )</SelectItem>
<SelectItem value="advanced" disabled>
()
</SelectItem>
@ -335,11 +335,11 @@ export const ButtonDataflowConfigPanel: React.FC<ButtonDataflowConfigPanelProps>
{/* 간편 모드 설정 */}
{(dataflowConfig.controlMode === "simple" || !dataflowConfig.controlMode) && (
<div className="space-y-3 rounded border bg-gray-50 p-3">
<h4 className="text-sm font-medium text-gray-700"> </h4>
<h4 className="text-sm font-medium text-gray-700"> </h4>
{/* 관계 선택 */}
{/* 관계 선택 */}
<div>
<Label className="text-xs"></Label>
<Label className="text-xs"></Label>
<Popover open={diagramOpen} onOpenChange={setDiagramOpen}>
<PopoverTrigger asChild>
<Button
@ -357,7 +357,7 @@ export const ButtonDataflowConfigPanel: React.FC<ButtonDataflowConfigPanelProps>
</Badge>
</div>
) : (
"관계를 선택하세요"
"관계를 선택하세요"
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
@ -365,9 +365,9 @@ export const ButtonDataflowConfigPanel: React.FC<ButtonDataflowConfigPanelProps>
<PopoverContent className="w-80 p-0">
<div className="p-2">
{diagramsLoading ? (
<div className="p-4 text-center text-sm text-gray-500"> ...</div>
<div className="p-4 text-center text-sm text-gray-500"> ...</div>
) : diagrams.length === 0 ? (
<div className="p-4 text-center text-sm text-gray-500"> </div>
<div className="p-4 text-center text-sm text-gray-500"> </div>
) : (
<div className="max-h-60 overflow-y-auto">
{diagrams.map((diagram) => (
@ -377,7 +377,7 @@ export const ButtonDataflowConfigPanel: React.FC<ButtonDataflowConfigPanelProps>
className="h-auto w-full justify-start p-2"
onClick={() => {
onUpdateProperty("webTypeConfig.dataflowConfig.selectedDiagramId", diagram.id);
// 관계 변경 시 기존 관계 선택 초기화
// 관계 변경 시 기존 관계 선택 초기화
onUpdateProperty("webTypeConfig.dataflowConfig.selectedRelationshipId", null);
setDiagramOpen(false);
}}
@ -435,7 +435,7 @@ export const ButtonDataflowConfigPanel: React.FC<ButtonDataflowConfigPanelProps>
<div className="p-4 text-center text-sm text-gray-500"> ...</div>
) : relationships.length === 0 ? (
<div className="p-4 text-center text-sm text-gray-500">
</div>
) : (
<div className="max-h-60 overflow-y-auto">

View File

@ -0,0 +1,281 @@
"use client";
import React, { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import {
Settings,
GitBranch,
Clock,
Zap,
Info
} from "lucide-react";
import { ComponentData, ButtonDataflowConfig } from "@/types/screen";
import { apiClient } from "@/lib/api/client";
interface ImprovedButtonControlConfigPanelProps {
component: ComponentData;
onUpdateProperty: (path: string, value: any) => void;
}
interface RelationshipOption {
id: string;
name: string;
sourceTable: string;
targetTable: string;
category: string;
}
/**
* 🔥
*
* :
* -
* - /
*/
export const ImprovedButtonControlConfigPanel: React.FC<ImprovedButtonControlConfigPanelProps> = ({
component,
onUpdateProperty,
}) => {
const config = component.webTypeConfig || {};
const dataflowConfig = config.dataflowConfig || {};
// 🔥 State 관리
const [relationships, setRelationships] = useState<RelationshipOption[]>([]);
const [loading, setLoading] = useState(false);
// 🔥 관계 목록 로딩
useEffect(() => {
if (config.enableDataflowControl) {
loadRelationships();
}
}, [config.enableDataflowControl]);
/**
* 🔥 ( )
*/
const loadRelationships = async () => {
try {
setLoading(true);
console.log("🔍 전체 관계 목록 로딩...");
const response = await apiClient.get("/test-button-dataflow/relationships/all");
if (response.data.success && Array.isArray(response.data.data)) {
const relationshipList = response.data.data.map((rel: any) => ({
id: rel.id,
name: rel.name || `${rel.sourceTable}${rel.targetTable}`,
sourceTable: rel.sourceTable,
targetTable: rel.targetTable,
category: rel.category || "데이터 흐름",
}));
setRelationships(relationshipList);
console.log(`✅ 관계 ${relationshipList.length}개 로딩 완료`);
}
} catch (error) {
console.error("❌ 관계 목록 로딩 실패:", error);
setRelationships([]);
} finally {
setLoading(false);
}
};
/**
* 🔥
*/
const handleRelationshipSelect = (relationshipId: string) => {
const selectedRelationship = relationships.find(r => r.id === relationshipId);
if (selectedRelationship) {
onUpdateProperty("webTypeConfig.dataflowConfig.relationshipConfig", {
relationshipId: selectedRelationship.id,
relationshipName: selectedRelationship.name,
executionTiming: "after", // 기본값
contextData: {},
});
}
};
/**
* 🔥
*/
const handleControlTypeChange = (controlType: string) => {
// 기존 설정 초기화
onUpdateProperty("webTypeConfig.dataflowConfig", {
controlMode: controlType,
relationshipConfig: controlType === "relationship" ? undefined : null,
});
};
return (
<div className="space-y-6">
{/* 🔥 제어관리 활성화 스위치 */}
<div className="flex items-center justify-between rounded-lg border bg-blue-50 p-4">
<div className="flex items-center space-x-2">
<Settings className="h-4 w-4 text-blue-600" />
<div>
<Label className="text-sm font-medium">🎮 </Label>
<p className="mt-1 text-xs text-gray-600"> </p>
</div>
</div>
<Switch
checked={config.enableDataflowControl || false}
onCheckedChange={(checked) => onUpdateProperty("webTypeConfig.enableDataflowControl", checked)}
/>
</div>
{/* 🔥 제어관리가 활성화된 경우에만 설정 표시 */}
{config.enableDataflowControl && (
<Card>
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent>
<Tabs
value={dataflowConfig.controlMode || "none"}
onValueChange={handleControlTypeChange}
>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="none"> </TabsTrigger>
<TabsTrigger value="relationship"> </TabsTrigger>
</TabsList>
<TabsContent value="none" className="mt-4">
<div className="text-center py-8 text-gray-500">
<Zap className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p> .</p>
</div>
</TabsContent>
<TabsContent value="relationship" className="mt-4">
<RelationshipSelector
relationships={relationships}
selectedRelationshipId={dataflowConfig.relationshipConfig?.relationshipId}
onSelect={handleRelationshipSelect}
loading={loading}
/>
{dataflowConfig.relationshipConfig && (
<div className="mt-4 space-y-4">
<Separator />
<ExecutionTimingSelector
value={dataflowConfig.relationshipConfig.executionTiming}
onChange={(timing) =>
onUpdateProperty("webTypeConfig.dataflowConfig.relationshipConfig.executionTiming", timing)
}
/>
<div className="rounded bg-blue-50 p-3">
<div className="flex items-start space-x-2">
<Info className="h-4 w-4 text-blue-600 mt-0.5" />
<div className="text-xs text-blue-800">
<p className="font-medium"> :</p>
<p className="mt-1"> , .</p>
</div>
</div>
</div>
</div>
)}
</TabsContent>
</Tabs>
</CardContent>
</Card>
)}
</div>
);
};
/**
* 🔥
*/
const RelationshipSelector: React.FC<{
relationships: RelationshipOption[];
selectedRelationshipId?: string;
onSelect: (relationshipId: string) => void;
loading: boolean;
}> = ({ relationships, selectedRelationshipId, onSelect, loading }) => {
return (
<div className="space-y-4">
<div className="flex items-center space-x-2">
<GitBranch className="h-4 w-4 text-blue-600" />
<Label> </Label>
</div>
<Select value={selectedRelationshipId || ""} onValueChange={onSelect}>
<SelectTrigger>
<SelectValue placeholder="관계를 선택하세요" />
</SelectTrigger>
<SelectContent>
{loading ? (
<div className="p-4 text-center text-sm text-gray-500"> ...</div>
) : relationships.length === 0 ? (
<div className="p-4 text-center text-sm text-gray-500"> </div>
) : (
relationships.map((rel) => (
<SelectItem key={rel.id} value={rel.id}>
<div className="flex flex-col">
<span className="font-medium">{rel.name}</span>
<span className="text-xs text-muted-foreground">
{rel.sourceTable} {rel.targetTable}
</span>
</div>
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
);
};
/**
* 🔥
*/
const ExecutionTimingSelector: React.FC<{
value: string;
onChange: (timing: "before" | "after" | "replace") => void;
}> = ({ value, onChange }) => {
return (
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Clock className="h-4 w-4 text-orange-600" />
<Label> </Label>
</div>
<Select value={value} onValueChange={onChange}>
<SelectTrigger>
<SelectValue placeholder="실행 타이밍을 선택하세요" />
</SelectTrigger>
<SelectContent>
<SelectItem value="before">
<div className="flex flex-col">
<span className="font-medium">Before ( )</span>
<span className="text-xs text-muted-foreground"> </span>
</div>
</SelectItem>
<SelectItem value="after">
<div className="flex flex-col">
<span className="font-medium">After ( )</span>
<span className="text-xs text-muted-foreground"> </span>
</div>
</SelectItem>
<SelectItem value="replace">
<div className="flex flex-col">
<span className="font-medium">Replace ( )</span>
<span className="text-xs text-muted-foreground"> </span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
);
};

View File

@ -3,7 +3,7 @@
import { toast } from "sonner";
import { screenApi } from "@/lib/api/screen";
import { DynamicFormApi } from "@/lib/api/dynamicForm";
import { OptimizedButtonDataflowService, ExtendedControlContext } from "@/lib/services/optimizedButtonDataflowService";
import { ImprovedButtonActionExecutor } from "@/lib/utils/improvedButtonActionExecutor";
/**
*
@ -781,40 +781,56 @@ export class ButtonActionExecutor {
extendedContext,
});
// 🔥 실제 제어 조건 검증 수행
const validationResult = await OptimizedButtonDataflowService.executeExtendedValidation(
config.dataflowConfig,
extendedContext,
);
// 🔥 새로운 버튼 액션 실행 시스템 사용
if (config.dataflowConfig?.controlMode === "relationship" && config.dataflowConfig?.relationshipConfig) {
console.log("🔗 관계 기반 제어 실행:", config.dataflowConfig.relationshipConfig);
// 새로운 ImprovedButtonActionExecutor 사용
const buttonConfig = {
actionType: config.type,
dataflowConfig: config.dataflowConfig,
enableDataflowControl: true, // 관계 기반 제어가 설정된 경우 활성화
};
if (validationResult.success) {
console.log("✅ 제어 조건 만족 - 액션 실행 시작:", {
actions: validationResult.actions,
context,
});
const executionResult = await ImprovedButtonActionExecutor.executeButtonAction(
buttonConfig,
context.formData || {},
{
buttonId: context.buttonId || "unknown",
screenId: context.screenId || "unknown",
userId: context.userId || "unknown",
companyCode: context.companyCode || "*",
startTime: Date.now(),
contextData: context,
}
);
// 🔥 조건을 만족했으므로 실제 액션 실행
if (validationResult.actions && validationResult.actions.length > 0) {
console.log("🚀 액션 실행 시작:", validationResult.actions);
await this.executeRelationshipActions(validationResult.actions, context);
if (executionResult.success) {
console.log("✅ 관계 실행 완료:", executionResult);
toast.success(config.successMessage || "관계 실행이 완료되었습니다.");
// 새로고침이 필요한 경우
if (context.onRefresh) {
context.onRefresh();
}
return true;
} else {
console.warn("⚠️ 실행할 액션이 없습니다:", {
hasActions: !!validationResult.actions,
actionsLength: validationResult.actions?.length,
validationResult,
});
toast.success(config.successMessage || "제어 조건을 만족합니다. (실행할 액션 없음)");
console.error("❌ 관계 실행 실패:", executionResult);
toast.error(config.errorMessage || "관계 실행 중 오류가 발생했습니다.");
return false;
}
} else {
// 제어 없음 - 메인 액션만 실행
console.log("⚡ 제어 없음 - 메인 액션 실행");
await this.executeMainAction(config, context);
// 새로고침이 필요한 경우
if (context.onRefresh) {
context.onRefresh();
}
return true;
} else {
toast.error(validationResult.message || "제어 조건을 만족하지 않습니다.");
return false;
}
} catch (error) {
console.error("제어 조건 검증 중 오류:", error);

View File

@ -0,0 +1,928 @@
/**
* 🔥
*
* :
* 1. Before
* 2. (replace가 )
* 3. After
*/
import { toast } from "sonner";
import { apiClient } from "@/lib/api/client";
import { ExtendedButtonTypeConfig, ButtonDataflowConfig } from "@/types/control-management";
import { ButtonActionType } from "@/types/unified-core";
// ===== 인터페이스 정의 =====
export interface ButtonExecutionContext {
buttonId: string;
screenId: string;
userId: string;
companyCode: string;
startTime: number;
formData?: Record<string, any>;
selectedRows?: any[];
tableData?: any[];
}
export interface ExecutionResult {
success: boolean;
message: string;
executionTime: number;
data?: any;
error?: string;
}
export interface ButtonExecutionResult {
success: boolean;
results: ExecutionResult[];
executionTime: number;
error?: string;
}
interface ControlConfig {
type: "relationship";
relationshipConfig: {
relationshipId: string;
relationshipName: string;
executionTiming: "before" | "after" | "replace";
contextData?: Record<string, any>;
};
}
interface ExecutionPlan {
beforeControls: ControlConfig[];
afterControls: ControlConfig[];
hasReplaceControl: boolean;
}
// ===== 메인 실행기 클래스 =====
export class ImprovedButtonActionExecutor {
/**
* 🔥
*/
static async executeButtonAction(
buttonConfig: ExtendedButtonTypeConfig,
formData: Record<string, any>,
context: ButtonExecutionContext
): Promise<ButtonExecutionResult> {
console.log("🔥 ImprovedButtonActionExecutor 시작:", {
buttonConfig,
formData,
context,
});
const executionPlan = this.createExecutionPlan(buttonConfig);
const results: ExecutionResult[] = [];
console.log("📋 생성된 실행 계획:", {
beforeControls: executionPlan.beforeControls,
afterControls: executionPlan.afterControls,
hasReplaceControl: executionPlan.hasReplaceControl,
});
try {
console.log("🚀 버튼 액션 실행 시작:", {
actionType: buttonConfig.actionType,
hasControls: executionPlan.beforeControls.length + executionPlan.afterControls.length > 0,
hasReplace: executionPlan.hasReplaceControl,
});
// 1. Before 타이밍 제어 실행
if (executionPlan.beforeControls.length > 0) {
console.log("⏰ Before 제어 실행 시작");
const beforeResults = await this.executeControls(
executionPlan.beforeControls,
formData,
context
);
results.push(...beforeResults);
// Before 제어 중 실패가 있으면 중단
const hasFailure = beforeResults.some(r => !r.success);
if (hasFailure) {
throw new Error("Before 제어 실행 중 오류가 발생했습니다.");
}
}
// 2. 메인 액션 실행 (replace가 아닌 경우에만)
if (!executionPlan.hasReplaceControl) {
console.log("⚡ 메인 액션 실행:", buttonConfig.actionType);
const mainResult = await this.executeMainAction(
buttonConfig,
formData,
context
);
results.push(mainResult);
if (!mainResult.success) {
throw new Error("메인 액션 실행 중 오류가 발생했습니다.");
}
} else {
console.log("🔄 Replace 모드: 메인 액션 건너뜀");
}
// 3. After 타이밍 제어 실행
if (executionPlan.afterControls.length > 0) {
console.log("⏰ After 제어 실행 시작");
const afterResults = await this.executeControls(
executionPlan.afterControls,
formData,
context
);
results.push(...afterResults);
}
const totalExecutionTime = Date.now() - context.startTime;
console.log("✅ 버튼 액션 실행 완료:", `${totalExecutionTime}ms`);
return {
success: true,
results,
executionTime: totalExecutionTime,
};
} catch (error) {
console.error("❌ 버튼 액션 실행 실패:", error);
// 롤백 처리
await this.handleExecutionError(error, results, buttonConfig);
return {
success: false,
results,
executionTime: Date.now() - context.startTime,
error: error.message,
};
}
}
/**
* 🔥
*/
private static createExecutionPlan(buttonConfig: ExtendedButtonTypeConfig): ExecutionPlan {
const plan: ExecutionPlan = {
beforeControls: [],
afterControls: [],
hasReplaceControl: false,
};
const dataflowConfig = buttonConfig.dataflowConfig;
if (!dataflowConfig) {
console.log("⚠️ dataflowConfig가 없습니다");
return plan;
}
// enableDataflowControl 체크를 제거하고 dataflowConfig만 있으면 실행
console.log("📋 실행 계획 생성:", {
controlMode: dataflowConfig.controlMode,
hasRelationshipConfig: !!dataflowConfig.relationshipConfig,
enableDataflowControl: buttonConfig.enableDataflowControl,
});
// 관계 기반 제어만 지원
if (dataflowConfig.controlMode === "relationship" && dataflowConfig.relationshipConfig) {
const control: ControlConfig = {
type: "relationship",
relationshipConfig: dataflowConfig.relationshipConfig,
};
switch (dataflowConfig.relationshipConfig.executionTiming) {
case "before":
plan.beforeControls.push(control);
break;
case "after":
plan.afterControls.push(control);
break;
case "replace":
plan.afterControls.push(control); // Replace는 after로 처리하되 플래그 설정
plan.hasReplaceControl = true;
break;
}
}
return plan;
}
/**
* 🔥 ( )
*/
private static async executeControls(
controls: ControlConfig[],
formData: Record<string, any>,
context: ButtonExecutionContext
): Promise<ExecutionResult[]> {
const results: ExecutionResult[] = [];
for (const control of controls) {
try {
// 관계 실행만 지원
const result = await this.executeRelationship(
control.relationshipConfig,
formData,
context
);
results.push(result);
// 제어 실행 실패 시 중단
if (!result.success) {
throw new Error(result.message);
}
} catch (error) {
console.error(`제어 실행 실패 (${control.type}):`, error);
results.push({
success: false,
message: `${control.type} 제어 실행 실패: ${error.message}`,
executionTime: 0,
error: error.message,
});
throw error;
}
}
return results;
}
/**
* 🔥
*/
private static async executeRelationship(
config: {
relationshipId: string;
relationshipName: string;
executionTiming: "before" | "after" | "replace";
contextData?: Record<string, any>;
},
formData: Record<string, any>,
context: ButtonExecutionContext
): Promise<ExecutionResult> {
try {
console.log(`🔗 관계 실행 시작: ${config.relationshipName} (ID: ${config.relationshipId})`);
// 1. 관계 정보 조회
const relationshipData = await this.getRelationshipData(config.relationshipId);
if (!relationshipData) {
throw new Error(`관계 정보를 찾을 수 없습니다: ${config.relationshipId}`);
}
console.log(`📋 관계 데이터 로드 완료:`, relationshipData);
// 2. 관계 타입에 따른 실행
const relationships = relationshipData.relationships;
const connectionType = relationships.connectionType;
console.log(`🔍 관계 상세 정보:`, {
connectionType,
hasExternalCallConfig: !!relationships.externalCallConfig,
externalCallConfig: relationships.externalCallConfig,
hasDataSaveConfig: !!relationships.dataSaveConfig,
dataSaveConfig: relationships.dataSaveConfig,
});
let result: ExecutionResult;
if (connectionType === "external_call") {
// 외부 호출 실행
result = await this.executeExternalCall(relationships, formData, context);
} else if (connectionType === "data_save") {
// 데이터 저장 실행
result = await this.executeDataSave(relationships, formData, context);
} else {
throw new Error(`지원하지 않는 연결 타입: ${connectionType}`);
}
console.log(`✅ 관계 실행 완료: ${config.relationshipName}`, result);
if (result.success) {
toast.success(`관계 '${config.relationshipName}' 실행 완료`);
} else {
toast.error(`관계 '${config.relationshipName}' 실행 실패: ${result.message}`);
}
return result;
} catch (error: any) {
console.error(`❌ 관계 실행 실패: ${config.relationshipName}`, error);
const errorResult = {
success: false,
message: `관계 '${config.relationshipName}' 실행 실패: ${error.message}`,
executionTime: 0,
error: error.message,
};
toast.error(errorResult.message);
return errorResult;
}
}
/**
*
*/
private static async getRelationshipData(relationshipId: string): Promise<any> {
try {
console.log(`🔍 관계 데이터 조회 시작: ${relationshipId}`);
const response = await apiClient.get(`/dataflow-diagrams/${relationshipId}`);
console.log(`✅ 관계 데이터 조회 성공:`, response.data);
if (!response.data.success) {
throw new Error(response.data.message || '관계 데이터 조회 실패');
}
return response.data.data;
} catch (error) {
console.error('관계 데이터 조회 오류:', error);
throw error;
}
}
/**
*
*/
private static async executeExternalCall(
relationships: any,
formData: Record<string, any>,
context: ButtonExecutionContext
): Promise<ExecutionResult> {
try {
console.log(`🔍 외부 호출 실행 시작 - relationships 구조:`, relationships);
const externalCallConfig = relationships.externalCallConfig;
console.log(`🔍 externalCallConfig:`, externalCallConfig);
if (!externalCallConfig) {
console.error('❌ 외부 호출 설정이 없습니다. relationships 구조:', relationships);
throw new Error('외부 호출 설정이 없습니다');
}
const restApiSettings = externalCallConfig.restApiSettings;
if (!restApiSettings) {
throw new Error('REST API 설정이 없습니다');
}
console.log(`🌐 외부 API 호출: ${restApiSettings.apiUrl}`);
// API 호출 준비
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...restApiSettings.headers,
};
// 인증 처리
if (restApiSettings.authentication?.type === 'api-key') {
headers['Authorization'] = `Bearer ${restApiSettings.authentication.apiKey}`;
}
// 요청 바디 준비 (템플릿 처리)
let requestBody = restApiSettings.bodyTemplate || '';
if (requestBody) {
// 간단한 템플릿 치환 ({{변수명}} 형태)
requestBody = requestBody.replace(/\{\{(\w+)\}\}/g, (match: string, key: string) => {
return formData[key] || (context as any).contextData?.[key] || new Date().toISOString();
});
}
// 백엔드 프록시를 통한 외부 API 호출 (CORS 문제 해결)
console.log(`🌐 백엔드 프록시를 통한 외부 API 호출 준비:`, {
originalUrl: restApiSettings.apiUrl,
method: restApiSettings.httpMethod || 'GET',
headers,
body: restApiSettings.httpMethod !== 'GET' ? requestBody : undefined,
});
// 백엔드 프록시 API 호출 - GenericApiSettings 형식에 맞게 전달
const requestPayload = {
diagramId: relationships.diagramId || 45, // 관계 ID 사용
relationshipId: relationships.relationshipId || "relationship-45",
settings: {
callType: "rest-api",
apiType: "generic",
url: restApiSettings.apiUrl,
method: restApiSettings.httpMethod || 'POST',
headers: restApiSettings.headers || {},
body: requestBody,
authentication: restApiSettings.authentication || { type: 'none' },
timeout: restApiSettings.timeout || 30000,
retryCount: restApiSettings.retryCount || 3,
},
templateData: restApiSettings.httpMethod !== 'GET' && requestBody ? JSON.parse(requestBody) : formData,
};
console.log(`📤 백엔드로 전송할 데이터:`, requestPayload);
const proxyResponse = await apiClient.post(`/external-calls/execute`, requestPayload);
console.log(`📡 백엔드 프록시 응답:`, proxyResponse.data);
if (!proxyResponse.data.success) {
throw new Error(`프록시 API 호출 실패: ${proxyResponse.data.error || proxyResponse.data.message}`);
}
const responseData = proxyResponse.data.result;
console.log(`✅ 외부 API 호출 성공 (프록시):`, responseData);
// 데이터 매핑 처리 (inbound mapping)
if (externalCallConfig.dataMappingConfig?.inboundMapping) {
console.log(`📥 데이터 매핑 설정 발견 - HTTP 메서드: ${restApiSettings.httpMethod}`);
console.log(`📥 매핑 설정:`, externalCallConfig.dataMappingConfig.inboundMapping);
console.log(`📥 응답 데이터:`, responseData);
await this.processInboundMapping(
externalCallConfig.dataMappingConfig.inboundMapping,
responseData,
context
);
} else {
console.log(` 데이터 매핑 설정이 없습니다 - HTTP 메서드: ${restApiSettings.httpMethod}`);
}
return {
success: true,
message: '외부 호출 실행 완료',
executionTime: Date.now() - context.startTime,
data: responseData,
};
} catch (error: any) {
console.error('외부 호출 실행 오류:', error);
return {
success: false,
message: `외부 호출 실행 실패: ${error.message}`,
executionTime: Date.now() - context.startTime,
error: error.message,
};
}
}
/**
*
*/
private static async executeDataSave(
relationships: any,
formData: Record<string, any>,
context: ButtonExecutionContext
): Promise<ExecutionResult> {
try {
console.log(`💾 데이터 저장 실행 시작`);
// 제어 조건 확인
const controlConditions = relationships.controlConditions || [];
if (controlConditions.length > 0) {
const conditionsMet = this.evaluateConditions(controlConditions, formData, context);
if (!conditionsMet) {
return {
success: false,
message: '제어 조건을 만족하지 않아 데이터 저장을 건너뜁니다',
executionTime: Date.now() - context.startTime,
};
}
}
// 액션 그룹 실행
const actionGroups = relationships.actionGroups || [];
const results = [];
for (const actionGroup of actionGroups) {
if (!actionGroup.isEnabled) {
console.log(`⏭️ 비활성화된 액션 그룹 건너뜀: ${actionGroup.name}`);
continue;
}
console.log(`🎯 액션 그룹 실행: ${actionGroup.name}`);
for (const action of actionGroup.actions) {
if (!action.isEnabled) {
console.log(`⏭️ 비활성화된 액션 건너뜀: ${action.name}`);
continue;
}
const actionResult = await this.executeDataAction(
action,
relationships,
formData,
context
);
results.push(actionResult);
if (!actionResult.success) {
console.error(`❌ 액션 실행 실패: ${action.name}`, actionResult);
}
}
}
const successCount = results.filter(r => r.success).length;
const totalCount = results.length;
return {
success: successCount > 0,
message: `데이터 저장 완료: ${successCount}/${totalCount} 액션 성공`,
executionTime: Date.now() - context.startTime,
data: {
results,
successCount,
totalCount,
},
};
} catch (error: any) {
console.error('데이터 저장 실행 오류:', error);
return {
success: false,
message: `데이터 저장 실행 실패: ${error.message}`,
executionTime: Date.now() - context.startTime,
error: error.message,
};
}
}
/**
*
*/
private static async executeDataAction(
action: any,
relationships: any,
formData: Record<string, any>,
context: ButtonExecutionContext
): Promise<ExecutionResult> {
try {
console.log(`🔧 데이터 액션 실행: ${action.name} (${action.actionType})`);
// 필드 매핑 처리
const mappedData: Record<string, any> = {};
for (const mapping of action.fieldMappings) {
if (mapping.valueType === 'static') {
// 정적 값 처리
let value = mapping.value;
if (value === '#NOW') {
value = new Date().toISOString();
}
mappedData[mapping.targetField] = value;
} else {
// 필드 매핑 처리
const sourceField = mapping.fromField?.columnName;
if (sourceField && formData[sourceField] !== undefined) {
mappedData[mapping.toField.columnName] = formData[sourceField];
}
}
}
console.log(`📋 매핑된 데이터:`, mappedData);
// 대상 연결 정보
const toConnection = relationships.toConnection;
const targetTable = relationships.toTable?.tableName;
if (!targetTable) {
throw new Error('대상 테이블이 지정되지 않았습니다');
}
// 데이터 저장 API 호출
const saveResult = await this.saveDataToTable(
targetTable,
mappedData,
action.actionType,
toConnection
);
return {
success: true,
message: `데이터 액션 "${action.name}" 실행 완료`,
executionTime: Date.now() - context.startTime,
data: saveResult,
};
} catch (error: any) {
console.error(`데이터 액션 실행 오류: ${action.name}`, error);
return {
success: false,
message: `데이터 액션 실행 실패: ${error.message}`,
executionTime: Date.now() - context.startTime,
error: error.message,
};
}
}
/**
*
*/
private static async saveDataToTable(
tableName: string,
data: Record<string, any>,
actionType: string,
connection?: any
): Promise<any> {
try {
console.log(`💾 테이블 데이터 저장 시작: ${tableName}`, {
actionType,
data,
connection
});
// 데이터 저장 API 호출 (apiClient 사용)
const response = await apiClient.post('/dataflow/execute-data-action', {
tableName,
data,
actionType,
connection,
});
console.log(`✅ 테이블 데이터 저장 성공: ${tableName}`, response.data);
return response.data;
} catch (error) {
console.error('테이블 데이터 저장 오류:', error);
throw error;
}
}
/**
*
*/
private static evaluateConditions(
conditions: any[],
formData: Record<string, any>,
context: ButtonExecutionContext
): boolean {
for (const condition of conditions) {
const fieldValue = formData[condition.field];
const conditionValue = condition.value;
const operator = condition.operator;
let conditionMet = false;
switch (operator) {
case '=':
conditionMet = fieldValue === conditionValue;
break;
case '!=':
conditionMet = fieldValue !== conditionValue;
break;
case '>':
conditionMet = Number(fieldValue) > Number(conditionValue);
break;
case '<':
conditionMet = Number(fieldValue) < Number(conditionValue);
break;
case '>=':
conditionMet = Number(fieldValue) >= Number(conditionValue);
break;
case '<=':
conditionMet = Number(fieldValue) <= Number(conditionValue);
break;
default:
console.warn(`지원하지 않는 연산자: ${operator}`);
conditionMet = true;
}
if (!conditionMet) {
console.log(`❌ 조건 불만족: ${condition.field} ${operator} ${conditionValue} (실제값: ${fieldValue})`);
return false;
}
}
console.log(`✅ 모든 조건 만족`);
return true;
}
/**
* API
*/
private static extractActualData(responseData: any): any {
console.log(`🔍 데이터 추출 시작 - 원본 타입: ${typeof responseData}`);
// null이나 undefined인 경우
if (!responseData) {
console.log(`⚠️ 응답 데이터가 null 또는 undefined`);
return [];
}
// 이미 배열인 경우 (직접 배열 응답)
if (Array.isArray(responseData)) {
console.log(`✅ 직접 배열 응답 감지`);
return responseData;
}
// 문자열인 경우 JSON 파싱 시도
if (typeof responseData === 'string') {
console.log(`🔄 JSON 문자열 파싱 시도`);
try {
const parsed = JSON.parse(responseData);
console.log(`✅ JSON 파싱 성공, 재귀 호출`);
return this.extractActualData(parsed);
} catch (error) {
console.log(`⚠️ JSON 파싱 실패, 원본 문자열 반환:`, error);
return [responseData];
}
}
// 객체가 아닌 경우 (숫자 등)
if (typeof responseData !== 'object') {
console.log(`⚠️ 객체가 아닌 응답: ${typeof responseData}`);
return [responseData];
}
// 일반적인 데이터 필드명들을 우선순위대로 확인
const commonDataFields = [
'data', // { data: [...] }
'result', // { result: [...] }
'results', // { results: [...] }
'items', // { items: [...] }
'list', // { list: [...] }
'records', // { records: [...] }
'rows', // { rows: [...] }
'content', // { content: [...] }
'payload', // { payload: [...] }
'response', // { response: [...] }
];
for (const field of commonDataFields) {
if (responseData[field] !== undefined) {
console.log(`✅ '${field}' 필드에서 데이터 추출`);
const extractedData = responseData[field];
// 추출된 데이터가 문자열인 경우 JSON 파싱 시도
if (typeof extractedData === 'string') {
console.log(`🔄 추출된 데이터가 JSON 문자열, 파싱 시도`);
try {
const parsed = JSON.parse(extractedData);
console.log(`✅ JSON 파싱 성공, 재귀 호출`);
return this.extractActualData(parsed);
} catch (error) {
console.log(`⚠️ JSON 파싱 실패, 원본 문자열 반환:`, error);
return [extractedData];
}
}
// 추출된 데이터가 객체이고 또 다른 중첩 구조일 수 있으므로 재귀 호출
if (typeof extractedData === 'object' && !Array.isArray(extractedData)) {
console.log(`🔄 중첩된 객체 감지, 재귀 추출 시도`);
return this.extractActualData(extractedData);
}
return extractedData;
}
}
// 특별한 필드가 없는 경우, 객체의 값들 중에서 배열을 찾기
const objectValues = Object.values(responseData);
const arrayValue = objectValues.find(value => Array.isArray(value));
if (arrayValue) {
console.log(`✅ 객체 값 중 배열 발견`);
return arrayValue;
}
// 객체의 값들 중에서 객체를 찾아서 재귀 탐색
for (const value of objectValues) {
if (value && typeof value === 'object' && !Array.isArray(value)) {
console.log(`🔄 객체 값에서 재귀 탐색`);
const nestedResult = this.extractActualData(value);
if (Array.isArray(nestedResult) && nestedResult.length > 0) {
return nestedResult;
}
}
}
// 모든 시도가 실패한 경우, 원본 객체를 단일 항목 배열로 반환
console.log(`📦 원본 객체를 단일 항목으로 처리`);
return [responseData];
}
/**
*
*/
private static async processInboundMapping(
inboundMapping: any,
responseData: any,
context: ButtonExecutionContext
): Promise<void> {
try {
console.log(`📥 인바운드 데이터 매핑 처리 시작`);
console.log(`📥 원본 응답 데이터:`, responseData);
const targetTable = inboundMapping.targetTable;
const fieldMappings = inboundMapping.fieldMappings || [];
const insertMode = inboundMapping.insertMode || 'insert';
console.log(`📥 매핑 설정:`, {
targetTable,
fieldMappings,
insertMode
});
// 응답 데이터에서 실제 데이터 추출 (다양한 구조 지원)
let actualData = this.extractActualData(responseData);
console.log(`📥 추출된 실제 데이터:`, actualData);
// 배열이 아닌 경우 배열로 변환
const dataArray = Array.isArray(actualData) ? actualData : [actualData];
console.log(`📥 처리할 데이터 배열:`, dataArray);
if (dataArray.length === 0) {
console.log(`⚠️ 처리할 데이터가 없습니다`);
return;
}
for (const item of dataArray) {
const mappedData: Record<string, any> = {};
console.log(`📥 개별 아이템 처리:`, item);
// 필드 매핑 적용
for (const mapping of fieldMappings) {
const sourceValue = item[mapping.sourceField];
console.log(`📥 필드 매핑: ${mapping.sourceField} -> ${mapping.targetField} = ${sourceValue}`);
if (sourceValue !== undefined && sourceValue !== null) {
mappedData[mapping.targetField] = sourceValue;
}
}
console.log(`📋 매핑된 데이터:`, mappedData);
// 매핑된 데이터가 비어있지 않은 경우에만 저장
if (Object.keys(mappedData).length > 0) {
await this.saveDataToTable(targetTable, mappedData, insertMode);
} else {
console.log(`⚠️ 매핑된 데이터가 비어있어 저장을 건너뜁니다`);
}
}
console.log(`✅ 인바운드 데이터 매핑 완료`);
} catch (error) {
console.error('인바운드 데이터 매핑 오류:', error);
throw error;
}
}
/**
* 🔥
*/
private static async executeMainAction(
buttonConfig: ExtendedButtonTypeConfig,
formData: Record<string, any>,
context: ButtonExecutionContext
): Promise<ExecutionResult> {
try {
// 기존 ButtonActionExecutor 로직을 여기서 호출하거나
// 간단한 액션들을 직접 구현
const startTime = performance.now();
// 임시 구현 - 실제로는 기존 ButtonActionExecutor를 호출해야 함
const result = {
success: true,
message: `${buttonConfig.actionType} 액션 실행 완료`,
executionTime: performance.now() - startTime,
data: { actionType: buttonConfig.actionType, formData },
};
console.log("✅ 메인 액션 실행 완료:", result.message);
return result;
} catch (error) {
console.error("메인 액션 실행 오류:", error);
return {
success: false,
message: `${buttonConfig.actionType} 액션 실행 실패: ${error.message}`,
executionTime: 0,
error: error.message,
};
}
}
/**
* 🔥
*/
private static async handleExecutionError(
error: Error,
results: ExecutionResult[],
buttonConfig: ExtendedButtonTypeConfig
): Promise<void> {
console.error("🔄 실행 오류 처리 시작:", error.message);
// 롤백이 필요한 경우 처리
const rollbackNeeded = buttonConfig.dataflowConfig?.executionOptions?.rollbackOnError;
if (rollbackNeeded) {
console.log("🔄 롤백 처리 시작...");
// 성공한 결과들을 역순으로 롤백
const successfulResults = results.filter(r => r.success).reverse();
for (const result of successfulResults) {
try {
// 롤백 로직 구현 (필요시)
console.log("🔄 롤백:", result.message);
} catch (rollbackError) {
console.error("롤백 실패:", rollbackError);
}
}
}
// 오류 토스트 표시
toast.error(error.message || "작업 중 오류가 발생했습니다.");
}
}

View File

@ -56,24 +56,34 @@ export interface ExtendedButtonTypeConfig {
}
/**
*
* 🔥
*/
export interface ButtonDataflowConfig {
// 제어 방식 선택
controlMode: "simple" | "advanced";
// 제어 방식 선택 (관계 실행만)
controlMode: "relationship" | "none";
// 관계도 방식 (diagram 기반)
selectedDiagramId?: number;
selectedRelationshipId?: number;
// 관계 기반 제어
relationshipConfig?: {
relationshipId: string; // 관계 직접 선택
relationshipName: string; // 관계명 표시
executionTiming: "before" | "after" | "replace";
contextData?: Record<string, any>; // 실행 시 전달할 컨텍스트
};
// 직접 설정 방식
directControl?: DirectControlConfig;
// 제어 데이터 소스
// 제어 데이터 소스 (기존 호환성 유지)
controlDataSource?: ControlDataSource;
// 실행 옵션
executionOptions?: ExecutionOptions;
// 🔧 기존 호환성을 위한 필드들 (deprecated)
selectedDiagramId?: number;
selectedRelationshipId?: number;
directControl?: DirectControlConfig;
// 🔧 제거된 필드들 (하위 호환성을 위해 optional로 유지)
externalCallConfig?: any; // deprecated
customConfig?: any; // deprecated
}
/**

View File

@ -0,0 +1,150 @@
/**
*
*/
export type DataDirection = "none" | "inbound" | "outbound" | "bidirectional";
export type InsertMode = "insert" | "upsert" | "update";
export type TransformType = "none" | "constant" | "format" | "function";
export type DataType = "string" | "number" | "boolean" | "date" | "json";
/**
*
*/
export interface FieldTransform {
type: TransformType;
value?: any;
format?: string; // 날짜 포맷 등 (예: "YYYY-MM-DD")
functionName?: string; // 커스텀 변환 함수명
}
/**
*
*/
export interface FieldMapping {
id: string; // 매핑 고유 ID
sourceField: string; // 소스 필드명 (외부 API 또는 내부 테이블)
targetField: string; // 타겟 필드명 (내부 테이블 또는 외부 API)
dataType: DataType;
transform?: FieldTransform;
required?: boolean;
defaultValue?: any;
}
/**
* Inbound ( )
*/
export interface InboundMapping {
targetTable: string;
targetSchema?: string;
fieldMappings: FieldMapping[];
insertMode: InsertMode;
keyFields?: string[]; // upsert/update 시 키 필드
batchSize?: number; // 배치 처리 크기
}
/**
* Outbound ( )
*/
export interface OutboundMapping {
sourceTable: string;
sourceSchema?: string;
sourceFilter?: string; // WHERE 조건
fieldMappings: FieldMapping[];
triggerCondition?: string; // 트리거 조건
}
/**
*
*/
export interface DataMappingConfig {
direction: DataDirection;
inboundMapping?: InboundMapping;
outboundMapping?: OutboundMapping;
}
/**
*
*/
export interface TableInfo {
name: string;
schema?: string;
displayName?: string;
fields: FieldInfo[];
}
/**
*
*/
export interface FieldInfo {
name: string;
dataType: DataType;
nullable: boolean;
isPrimaryKey?: boolean;
displayName?: string;
description?: string;
}
/**
*
*/
export interface DataMappingResult {
success: boolean;
direction: DataDirection;
recordsProcessed?: number;
recordsInserted?: number;
recordsUpdated?: number;
recordsSkipped?: number;
errors?: string[];
executionTime: number;
timestamp: string;
}
/**
*
*/
export interface MappingValidationResult {
isValid: boolean;
errors: string[];
warnings: string[];
}
/**
* 릿
*/
export interface MappingTemplate {
id: string;
name: string;
description: string;
direction: DataDirection;
config: DataMappingConfig;
tags?: string[];
}
// 상수 정의
export const DATA_DIRECTION_OPTIONS = [
{ value: "none", label: "매핑 없음" },
{ value: "inbound", label: "외부 → 내부 (GET)" },
{ value: "outbound", label: "내부 → 외부 (POST)" },
{ value: "bidirectional", label: "양방향" },
] as const;
export const INSERT_MODE_OPTIONS = [
{ value: "insert", label: "삽입만" },
{ value: "upsert", label: "삽입/업데이트" },
{ value: "update", label: "업데이트만" },
] as const;
export const TRANSFORM_TYPE_OPTIONS = [
{ value: "none", label: "변환 없음" },
{ value: "constant", label: "상수값" },
{ value: "format", label: "포맷 변환" },
{ value: "function", label: "커스텀 함수" },
] as const;
export const DATA_TYPE_OPTIONS = [
{ value: "string", label: "문자열" },
{ value: "number", label: "숫자" },
{ value: "boolean", label: "불린" },
{ value: "date", label: "날짜" },
{ value: "json", label: "JSON" },
] as const;

View File

@ -3,10 +3,13 @@
*
*/
import { DataMappingConfig } from "./DataMappingTypes";
// 외부호출 메인 설정 타입
export interface ExternalCallConfig {
callType: "rest-api"; // 향후 "webhook", "email", "ftp" 등 확장 가능
restApiSettings: RestApiSettings;
dataMappingConfig?: DataMappingConfig; // 데이터 매핑 설정 추가
metadata?: {
createdAt: string;
updatedAt: string;

Binary file not shown.

View File

@ -0,0 +1,293 @@
# 외부호출 데이터 매핑 시스템 설계서
## 1. 개요
외부 API 호출 시 데이터를 송수신하고, 이를 내부 테이블과 매핑하는 시스템을 구현합니다.
## 2. 현재 상황 분석
### 2.1 기존 기능
- ✅ REST API 호출 기본 기능
- ✅ 인증 처리 (API Key, Basic, Bearer 등)
- ✅ 요청/응답 테스트 기능
- ✅ 외부호출 설정 저장
### 2.2 필요한 확장 기능
- 🔄 GET 요청 시 응답 데이터를 내부 테이블에 저장
- 🔄 POST 요청 시 내부 테이블 데이터를 외부로 전송
- 🔄 필드 매핑 설정 (외부 필드 ↔ 내부 필드)
- 🔄 데이터 변환 및 검증
## 3. 시스템 아키텍처
### 3.1 데이터 플로우
```
GET 요청 플로우:
내부 이벤트 → 외부 API 호출 → 응답 데이터 → 필드 매핑 → 내부 테이블 저장
POST 요청 플로우:
내부 이벤트 → 내부 테이블 조회 → 필드 매핑 → 외부 API 전송 → 응답 처리
```
### 3.2 컴포넌트 구조
```
ExternalCallPanel
├── RestApiSettings (기존)
├── DataMappingSettings (신규)
│ ├── SourceTableSelector
│ ├── TargetTableSelector
│ ├── FieldMappingEditor
│ └── DataTransformEditor
└── ExternalCallTestPanel (확장)
```
## 4. 데이터베이스 스키마 확장
### 4.1 external_call_configs 테이블 확장
```sql
ALTER TABLE external_call_configs ADD COLUMN IF NOT EXISTS data_mapping_config JSONB;
```
### 4.2 data_mapping_config JSON 구조
```typescript
interface DataMappingConfig {
direction: "inbound" | "outbound" | "bidirectional";
// GET 요청용 - 외부 → 내부
inboundMapping?: {
targetTable: string;
targetSchema?: string;
fieldMappings: FieldMapping[];
insertMode: "insert" | "upsert" | "update";
keyFields?: string[]; // upsert/update 시 키 필드
};
// POST 요청용 - 내부 → 외부
outboundMapping?: {
sourceTable: string;
sourceSchema?: string;
sourceFilter?: string; // WHERE 조건
fieldMappings: FieldMapping[];
};
}
interface FieldMapping {
sourceField: string; // 외부 API 필드명 또는 내부 테이블 컬럼명
targetField: string; // 내부 테이블 컬럼명 또는 외부 API 필드명
dataType: "string" | "number" | "boolean" | "date" | "json";
transform?: {
type: "none" | "constant" | "format" | "function";
value?: any;
format?: string; // 날짜 포맷 등
functionName?: string; // 커스텀 변환 함수
};
required?: boolean;
defaultValue?: any;
}
```
## 5. 프론트엔드 컴포넌트 설계
### 5.1 DataMappingSettings.tsx
```typescript
interface DataMappingSettingsProps {
config: DataMappingConfig;
onConfigChange: (config: DataMappingConfig) => void;
httpMethod: string;
availableTables: TableInfo[];
}
// 주요 기능:
// - 방향 선택 (inbound/outbound/bidirectional)
// - 소스/타겟 테이블 선택
// - 필드 매핑 에디터
// - 데이터 변환 설정
```
### 5.2 FieldMappingEditor.tsx
```typescript
interface FieldMappingEditorProps {
mappings: FieldMapping[];
sourceFields: FieldInfo[];
targetFields: FieldInfo[];
onMappingsChange: (mappings: FieldMapping[]) => void;
}
// 주요 기능:
// - 드래그 앤 드롭으로 필드 매핑
// - 데이터 타입 자동 추론
// - 변환 함수 설정
// - 필수 필드 검증
```
### 5.3 DataTransformEditor.tsx
```typescript
// 데이터 변환 규칙 설정
// - 상수값 할당
// - 날짜 포맷 변환
// - 문자열 변환 (대소문자, 트림 등)
// - 커스텀 함수 적용
```
## 6. 백엔드 서비스 확장
### 6.1 ExternalCallExecutor 확장
```typescript
class ExternalCallExecutor {
async executeWithDataMapping(
config: ExternalCallConfig,
triggerData?: any
): Promise<ExternalCallResult> {
const result = await this.executeApiCall(config);
if (result.success && config.dataMappingConfig) {
if (config.restApiSettings.httpMethod === "GET") {
await this.processInboundData(result, config.dataMappingConfig);
}
}
return result;
}
private async processInboundData(
result: ExternalCallResult,
mappingConfig: DataMappingConfig
) {
// 1. 응답 데이터 파싱
// 2. 필드 매핑 적용
// 3. 데이터 변환
// 4. 데이터베이스 저장
}
private async prepareOutboundData(
mappingConfig: DataMappingConfig,
triggerData?: any
): Promise<any> {
// 1. 소스 테이블 조회
// 2. 필드 매핑 적용
// 3. 데이터 변환
// 4. API 요청 바디 생성
}
}
```
### 6.2 DataMappingService.ts (신규)
```typescript
class DataMappingService {
async mapInboundData(
sourceData: any,
mapping: InboundMapping
): Promise<any[]> {
// 외부 데이터 → 내부 테이블 매핑
}
async mapOutboundData(
sourceTable: string,
mapping: OutboundMapping,
filter?: any
): Promise<any> {
// 내부 테이블 → 외부 API 매핑
}
private transformFieldValue(value: any, transform: FieldTransform): any {
// 필드 변환 로직
}
}
```
## 7. 구현 단계
### Phase 1: 기본 매핑 시스템 (1-2주)
1. 데이터베이스 스키마 확장
2. DataMappingSettings 컴포넌트 개발
3. 기본 필드 매핑 기능
4. GET 요청 응답 데이터 저장
### Phase 2: 고급 매핑 기능 (1-2주)
1. POST 요청 데이터 송신
2. 필드 변환 기능
3. upsert/update 모드
4. 배치 처리
### Phase 3: UI/UX 개선 (1주)
1. 드래그 앤 드롭 매핑 에디터
2. 실시간 미리보기
3. 매핑 템플릿
4. 에러 처리 및 로깅
## 8. 사용 시나리오
### 8.1 외부 API에서 데이터 가져오기 (GET)
```
고객사 API → 우리 customer 테이블
- 고객 정보 동기화
- 주문 정보 수집
- 재고 정보 업데이트
```
### 8.2 외부 API로 데이터 보내기 (POST)
```
우리 order 테이블 → 배송사 API
- 주문 정보 전달
- 재고 변동 알림
- 상태 업데이트 전송
```
## 9. 기술적 고려사항
### 9.1 데이터 일관성
- 트랜잭션 처리
- 롤백 메커니즘
- 중복 데이터 처리
### 9.2 성능 최적화
- 배치 처리
- 비동기 처리
- 캐싱 전략
### 9.3 보안
- 데이터 검증
- SQL 인젝션 방지
- 민감 데이터 마스킹
### 9.4 모니터링
- 매핑 실행 로그
- 에러 추적
- 성능 메트릭
## 10. 성공 지표
- ✅ 외부 API 응답 데이터를 내부 테이블에 정확히 저장
- ✅ 내부 테이블 데이터를 외부 API로 정확히 전송
- ✅ 필드 매핑 설정이 직관적이고 사용하기 쉬움
- ✅ 데이터 변환이 정확하고 안정적
- ✅ 에러 발생 시 적절한 처리 및 알림
## 11. 다음 단계
1. **우선순위 결정**: GET/POST 중 어느 것부터 구현할지
2. **테이블 선택**: 매핑할 주요 테이블들 식별
3. **프로토타입**: 간단한 매핑 시나리오로 POC 개발
4. **점진적 확장**: 기본 → 고급 기능 순서로 개발
이 설계서를 바탕으로 단계별로 구현해 나가면 됩니다. 어떤 부분부터 시작하고 싶으신가요?

View File

@ -0,0 +1,469 @@
# 🔄 제어관리 시스템 개선 계획서
## 📋 개요
데이터 매핑 시스템이 추가되면서 기존 제어관리 로직과 버튼 연동 방식의 개선이 필요합니다.
## 🎯 주요 개선사항
### 1. 명칭 변경: "관계도" → "관계"
#### 1.1 변경 이유
- **기존**: "관계도"는 다이어그램 전체를 의미하는 용어
- **현재**: 실제로는 개별 "관계"를 설정하고 관리
- **개선**: 사용자 이해도 향상 및 용어 일관성 확보
#### 1.2 변경 대상 파일들
```typescript
// UI 컴포넌트들
frontend / components / screen / config -
panels / ButtonDataflowConfigPanel.tsx;
frontend / components / dataflow / DataFlowDesigner.tsx;
frontend / components / dataflow / SaveDiagramModal.tsx;
frontend / components / dataflow / RelationshipListModal.tsx;
frontend /
components /
dataflow /
connection /
redesigned /
RightPanel /
ConnectionStep.tsx;
// API 및 서비스
frontend / lib / api / dataflow.ts;
frontend / hooks / useDataFlowDesigner.ts;
// 타입 정의
frontend / types / control - management.ts;
```
### 2. 버튼 제어관리 로직 개선
#### 2.1 현재 문제점
```typescript
// 🔴 기존: 복잡한 관계도 선택 방식
interface ButtonDataflowConfig {
controlMode: "simple" | "advanced";
selectedDiagramId?: number; // 관계도 전체 선택
selectedRelationshipId?: string; // 개별 관계 선택
// ...
}
```
#### 2.2 개선 방향
```typescript
// 🟢 개선: 단순화된 관계 직접 선택
interface ButtonDataflowConfig {
controlMode: "relationship" | "external_call" | "custom";
// 관계 기반 제어
relationshipConfig?: {
relationshipId: string; // 관계 직접 선택
relationshipName: string; // 관계명 표시
executionTiming: "before" | "after" | "replace";
contextData?: Record<string, any>; // 실행 시 전달할 컨텍스트
};
// 외부호출 제어
externalCallConfig?: {
configId: string; // external_call_configs ID
configName: string; // 설정명 표시
executionTiming: "before" | "after" | "replace";
dataMappingEnabled: boolean; // 데이터 매핑 사용 여부
};
// 커스텀 제어
customConfig?: {
actionType: string;
parameters: Record<string, any>;
};
}
```
### 3. 외부호출 연동 개선
#### 3.1 현재 외부호출 설정 방식
```typescript
// 🔴 현재: 복잡한 설정 구조
interface ExternalCallConfig {
callType: "rest-api";
restApiSettings: {
apiUrl: string;
httpMethod: string;
// ... 많은 설정들
};
}
```
#### 3.2 개선된 연동 방식
```typescript
// 🟢 개선: 단순화된 참조 구조
interface ButtonExternalCallConfig {
// 1단계: 저장된 외부호출 설정 선택
externalCallConfigId: string; // external_call_configs 테이블 ID
configName: string; // 설정명 (UI 표시용)
// 2단계: 실행 시점 설정
executionTiming: "before" | "after" | "replace";
// 3단계: 데이터 전달 방식
dataMapping: {
enabled: boolean; // 데이터 매핑 사용 여부
sourceMode: "form" | "table" | "custom"; // 데이터 소스
sourceConfig?: {
tableName?: string; // table 모드용
customData?: Record<string, any>; // custom 모드용
};
};
// 4단계: 실행 옵션
executionOptions: {
rollbackOnError: boolean; // 실패 시 롤백
showLoadingIndicator: boolean; // 로딩 표시
successMessage?: string; // 성공 메시지
errorMessage?: string; // 실패 메시지
};
}
```
### 4. 버튼 액션 실행 로직 개선
#### 4.1 현재 실행 플로우
```mermaid
graph TD
A[버튼 클릭] --> B[기존 액션 실행]
B --> C[제어관리 확인]
C --> D[관계도 조회]
D --> E[관계 찾기]
E --> F[조건 검증]
F --> G[액션 실행]
```
#### 4.2 개선된 실행 플로우
```mermaid
graph TD
A[버튼 클릭] --> B[제어 설정 확인]
B --> C{제어 타입}
C -->|관계| D[관계 직접 실행]
C -->|외부호출| E[외부호출 실행]
C -->|없음| F[기존 액션만 실행]
D --> G[조건 검증]
G --> H[관계 액션 실행]
E --> I[데이터 매핑]
I --> J[외부 API 호출]
J --> K[응답 처리]
H --> L[완료]
K --> L
F --> L
```
#### 4.3 개선된 ButtonActionExecutor
```typescript
export class ButtonActionExecutor {
/**
* 🔥 개선된 버튼 액션 실행
*/
static async executeButtonAction(
buttonConfig: ExtendedButtonTypeConfig,
formData: Record<string, any>,
context: ButtonExecutionContext
): Promise<ButtonExecutionResult> {
const executionPlan = this.createExecutionPlan(buttonConfig);
const results: ExecutionResult[] = [];
try {
// 1. Before 타이밍 제어 실행
if (executionPlan.beforeControls.length > 0) {
const beforeResults = await this.executeControls(
executionPlan.beforeControls,
formData,
context
);
results.push(...beforeResults);
}
// 2. 메인 액션 실행 (replace가 아닌 경우에만)
if (!executionPlan.hasReplaceControl) {
const mainResult = await this.executeMainAction(
buttonConfig,
formData,
context
);
results.push(mainResult);
}
// 3. After 타이밍 제어 실행
if (executionPlan.afterControls.length > 0) {
const afterResults = await this.executeControls(
executionPlan.afterControls,
formData,
context
);
results.push(...afterResults);
}
return {
success: true,
results,
executionTime: Date.now() - context.startTime,
};
} catch (error) {
// 롤백 처리
await this.handleExecutionError(error, results, buttonConfig);
throw error;
}
}
/**
* 🔥 제어 실행 (관계 또는 외부호출)
*/
private static async executeControls(
controls: ControlConfig[],
formData: Record<string, any>,
context: ButtonExecutionContext
): Promise<ExecutionResult[]> {
const results: ExecutionResult[] = [];
for (const control of controls) {
switch (control.type) {
case "relationship":
const relationshipResult = await this.executeRelationship(
control.relationshipConfig!,
formData,
context
);
results.push(relationshipResult);
break;
case "external_call":
const externalCallResult = await this.executeExternalCall(
control.externalCallConfig!,
formData,
context
);
results.push(externalCallResult);
break;
}
}
return results;
}
/**
* 🔥 관계 실행
*/
private static async executeRelationship(
config: RelationshipConfig,
formData: Record<string, any>,
context: ButtonExecutionContext
): Promise<ExecutionResult> {
// 1. 관계 정보 조회
const relationship = await RelationshipAPI.getRelationshipById(
config.relationshipId
);
// 2. 컨텍스트 데이터 준비
const contextData = {
...formData,
...config.contextData,
buttonId: context.buttonId,
screenId: context.screenId,
userId: context.userId,
companyCode: context.companyCode,
};
// 3. 관계 실행
return await EventTriggerService.executeSpecificRelationship(
relationship,
contextData,
context.companyCode
);
}
/**
* 🔥 외부호출 실행
*/
private static async executeExternalCall(
config: ExternalCallConfig,
formData: Record<string, any>,
context: ButtonExecutionContext
): Promise<ExecutionResult> {
// 1. 외부호출 설정 조회
const externalCallConfig = await ExternalCallConfigAPI.getConfigById(
config.configId
);
// 2. 데이터 매핑 처리
let mappedData = formData;
if (config.dataMappingEnabled && externalCallConfig.dataMappingConfig) {
mappedData = await DataMappingService.processOutboundData(
externalCallConfig.dataMappingConfig.outboundMapping,
formData
);
}
// 3. 외부 API 호출
const callResult = await ExternalCallService.executeWithDataMapping(
externalCallConfig.configData,
externalCallConfig.dataMappingConfig,
mappedData
);
// 4. 응답 데이터 처리 (Inbound 매핑)
if (
callResult.success &&
config.dataMappingEnabled &&
externalCallConfig.dataMappingConfig?.direction === "inbound"
) {
await DataMappingService.processInboundData(
callResult.response,
externalCallConfig.dataMappingConfig.inboundMapping!
);
}
return {
success: callResult.success,
message: callResult.success ? "외부호출 성공" : callResult.error,
executionTime: callResult.executionTime,
data: callResult,
};
}
}
```
### 5. UI/UX 개선 사항
#### 5.1 버튼 설정 패널 개선
```typescript
// 🟢 단순화된 제어 설정 UI
const ButtonControlConfigPanel = () => {
return (
<Card>
<CardHeader>
<CardTitle>버튼 제어 설정</CardTitle>
</CardHeader>
<CardContent>
<Tabs value={controlType} onValueChange={setControlType}>
<TabsList>
<TabsTrigger value="none">제어 없음</TabsTrigger>
<TabsTrigger value="relationship">관계 실행</TabsTrigger>
<TabsTrigger value="external_call">외부 호출</TabsTrigger>
</TabsList>
<TabsContent value="relationship">
<RelationshipSelector
selectedRelationshipId={config.relationshipId}
onSelect={handleRelationshipSelect}
/>
</TabsContent>
<TabsContent value="external_call">
<ExternalCallSelector
selectedConfigId={config.externalCallConfigId}
onSelect={handleExternalCallSelect}
/>
</TabsContent>
</Tabs>
</CardContent>
</Card>
);
};
```
#### 5.2 관계 선택 컴포넌트
```typescript
const RelationshipSelector = ({ onSelect }) => {
const [relationships, setRelationships] = useState([]);
useEffect(() => {
// 전체 관계 목록 로드 (관계도별 구분 없이)
loadAllRelationships();
}, []);
return (
<div className="space-y-4">
<Label>실행할 관계 선택</Label>
<Select onValueChange={onSelect}>
<SelectTrigger>
<SelectValue placeholder="관계를 선택하세요" />
</SelectTrigger>
<SelectContent>
{relationships.map((rel) => (
<SelectItem key={rel.id} value={rel.id}>
<div className="flex flex-col">
<span className="font-medium">{rel.name}</span>
<span className="text-xs text-muted-foreground">
{rel.sourceTable} → {rel.targetTable}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
};
```
### 6. 구현 우선순위
#### Phase 1: 명칭 변경 (1일)
1. **UI 텍스트 변경**: "관계도" → "관계"
2. **변수명 정리**: `diagram``relationship` 관련
3. **API 엔드포인트 정리**: 일관성 있는 명명
#### Phase 2: 버튼 제어 로직 개선 (2-3일)
1. **ButtonDataflowConfig 타입 개선**
2. **RelationshipSelector 컴포넌트 개발**
3. **ExternalCallSelector 컴포넌트 개발**
4. **ButtonActionExecutor 로직 개선**
#### Phase 3: 외부호출 통합 (1-2일)
1. **외부호출 설정 참조 방식 개선**
2. **데이터 매핑 통합**
3. **실행 플로우 최적화**
#### Phase 4: 테스트 및 최적화 (1일)
1. **전체 플로우 테스트**
2. **성능 최적화**
3. **사용자 가이드 업데이트**
### 7. 기대 효과
#### 7.1 사용자 경험 개선
- **직관적인 용어**: "관계도" → "관계"로 이해도 향상
- **단순화된 설정**: 복잡한 관계도 탐색 → 직접 관계 선택
- **통합된 제어**: 관계 실행과 외부호출을 동일한 방식으로 관리
#### 7.2 개발 효율성 향상
- **명확한 책임 분리**: 관계 관리와 외부호출 관리 분리
- **재사용성 증대**: 외부호출 설정의 재사용성 향상
- **유지보수성 개선**: 단순화된 로직으로 디버깅 용이
#### 7.3 시스템 확장성
- **새로운 제어 타입 추가 용이**: 플러그인 방식으로 확장 가능
- **데이터 매핑 시스템 완전 활용**: 외부 시스템과의 유연한 연동
- **모니터링 및 로깅 강화**: 각 단계별 상세한 실행 로그
이 개선 계획을 통해 제어관리 시스템이 더욱 직관적이고 강력한 기능을 제공할 수 있을 것입니다.