From 6fc50cd31559b6db82bbcc245e02a45f47daad50 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 20 Oct 2025 18:23:15 +0900 Subject: [PATCH 1/3] =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/controllers/flowController.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/backend-node/src/controllers/flowController.ts b/backend-node/src/controllers/flowController.ts index e555e6f7..b979858a 100644 --- a/backend-node/src/controllers/flowController.ts +++ b/backend-node/src/controllers/flowController.ts @@ -294,6 +294,13 @@ export class FlowController { color, positionX, positionY, + moveType, + statusColumn, + statusValue, + targetTable, + fieldMappings, + integrationType, + integrationConfig, } = req.body; const step = await this.flowStepService.update(id, { @@ -304,6 +311,13 @@ export class FlowController { color, positionX, positionY, + moveType, + statusColumn, + statusValue, + targetTable, + fieldMappings, + integrationType, + integrationConfig, }); if (!step) { -- 2.43.0 From efa2cbc53811550b23b68edabebd723ffb903319 Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 21 Oct 2025 10:44:09 +0900 Subject: [PATCH 2/3] =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/app/(main)/main/page.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/app/(main)/main/page.tsx b/frontend/app/(main)/main/page.tsx index 0c6eb73e..3784fb06 100644 --- a/frontend/app/(main)/main/page.tsx +++ b/frontend/app/(main)/main/page.tsx @@ -9,7 +9,7 @@ import { Badge } from "@/components/ui/badge"; */ export default function MainPage() { return ( -
+
{/* 메인 컨텐츠 */} {/* Welcome Message */} @@ -18,7 +18,7 @@ export default function MainPage() {

Vexolor에 오신 것을 환영합니다!

제품 수명 주기 관리 시스템을 통해 효율적인 업무를 시작하세요.

- Spring Boot + Node.js Next.js Shadcn/ui
-- 2.43.0 From 0d96ea566b2856540de28e2d6f293b7f3a7efeee Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 21 Oct 2025 13:19:18 +0900 Subject: [PATCH 3/3] =?UTF-8?q?=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EC=99=B8?= =?UTF-8?q?=EB=B6=80=EC=97=B0=EA=B2=B0=20=EC=A4=91=EA=B0=84=EC=BB=A4?= =?UTF-8?q?=EB=B0=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/flowController.ts | 30 +- backend-node/src/services/dbQueryBuilder.ts | 230 +++++++++ backend-node/src/services/externalDbHelper.ts | 477 ++++++++++++++++++ .../src/services/flowDataMoveService.ts | 351 ++++++++++++- .../src/services/flowDefinitionService.ts | 27 +- .../src/services/flowExecutionService.ts | 165 ++++-- backend-node/src/types/flow.ts | 4 + .../admin/flow-management/[id]/page.tsx | 7 +- .../app/(main)/admin/flow-management/page.tsx | 245 ++++++++- frontend/app/(main)/page.tsx | 5 +- .../components/flow/FlowConditionBuilder.tsx | 85 +++- frontend/components/flow/FlowStepPanel.tsx | 141 +++++- 12 files changed, 1667 insertions(+), 100 deletions(-) create mode 100644 backend-node/src/services/dbQueryBuilder.ts create mode 100644 backend-node/src/services/externalDbHelper.ts diff --git a/backend-node/src/controllers/flowController.ts b/backend-node/src/controllers/flowController.ts index b979858a..398dd414 100644 --- a/backend-node/src/controllers/flowController.ts +++ b/backend-node/src/controllers/flowController.ts @@ -34,23 +34,31 @@ export class FlowController { const { name, description, tableName } = req.body; const userId = (req as any).user?.userId || "system"; - if (!name || !tableName) { + console.log("🔍 createFlowDefinition called with:", { + name, + description, + tableName, + }); + + if (!name) { res.status(400).json({ success: false, - message: "Name and tableName are required", + message: "Name is required", }); return; } - // 테이블 존재 확인 - const tableExists = - await this.flowDefinitionService.checkTableExists(tableName); - if (!tableExists) { - res.status(400).json({ - success: false, - message: `Table '${tableName}' does not exist`, - }); - return; + // 테이블 이름이 제공된 경우에만 존재 확인 + if (tableName) { + const tableExists = + await this.flowDefinitionService.checkTableExists(tableName); + if (!tableExists) { + res.status(400).json({ + success: false, + message: `Table '${tableName}' does not exist`, + }); + return; + } } const flowDef = await this.flowDefinitionService.create( diff --git a/backend-node/src/services/dbQueryBuilder.ts b/backend-node/src/services/dbQueryBuilder.ts new file mode 100644 index 00000000..e7b8d9bc --- /dev/null +++ b/backend-node/src/services/dbQueryBuilder.ts @@ -0,0 +1,230 @@ +/** + * 데이터베이스별 쿼리 빌더 + * PostgreSQL, MySQL/MariaDB, MSSQL, Oracle 지원 + */ + +export type DbType = "postgresql" | "mysql" | "mariadb" | "mssql" | "oracle"; + +/** + * DB별 파라미터 플레이스홀더 생성 + */ +export function getPlaceholder(dbType: string, index: number): string { + const normalizedType = dbType.toLowerCase(); + + switch (normalizedType) { + case "postgresql": + return `$${index}`; + + case "mysql": + case "mariadb": + return "?"; + + case "mssql": + return `@p${index}`; + + case "oracle": + return `:${index}`; + + default: + // 기본값은 PostgreSQL + return `$${index}`; + } +} + +/** + * UPDATE 쿼리 생성 + */ +export function buildUpdateQuery( + dbType: string, + tableName: string, + updates: { column: string; value: any }[], + whereColumn: string = "id" +): { query: string; values: any[] } { + const normalizedType = dbType.toLowerCase(); + const values: any[] = []; + + // SET 절 생성 + const setClause = updates + .map((update, index) => { + values.push(update.value); + const placeholder = getPlaceholder(normalizedType, values.length); + return `${update.column} = ${placeholder}`; + }) + .join(", "); + + // WHERE 절 생성 + values.push(undefined); // whereValue는 나중에 설정 + const wherePlaceholder = getPlaceholder(normalizedType, values.length); + + // updated_at 처리 (DB별 NOW() 함수) + let updatedAtExpr = "NOW()"; + if (normalizedType === "mssql") { + updatedAtExpr = "GETDATE()"; + } else if (normalizedType === "oracle") { + updatedAtExpr = "SYSDATE"; + } + + const query = ` + UPDATE ${tableName} + SET ${setClause}, updated_at = ${updatedAtExpr} + WHERE ${whereColumn} = ${wherePlaceholder} + `; + + return { query, values }; +} + +/** + * INSERT 쿼리 생성 + */ +export function buildInsertQuery( + dbType: string, + tableName: string, + data: Record +): { query: string; values: any[]; returningClause: string } { + const normalizedType = dbType.toLowerCase(); + const columns = Object.keys(data); + const values = Object.values(data); + + // 플레이스홀더 생성 + const placeholders = columns + .map((_, index) => getPlaceholder(normalizedType, index + 1)) + .join(", "); + + let query = ` + INSERT INTO ${tableName} (${columns.join(", ")}) + VALUES (${placeholders}) + `; + + // RETURNING/OUTPUT 절 추가 (DB별로 다름) + let returningClause = ""; + if (normalizedType === "postgresql") { + query += " RETURNING id"; + returningClause = "RETURNING id"; + } else if (normalizedType === "mssql") { + // MSSQL은 OUTPUT 절을 INSERT와 VALUES 사이에 + const insertIndex = query.indexOf("VALUES"); + query = + query.substring(0, insertIndex) + + "OUTPUT INSERTED.id " + + query.substring(insertIndex); + returningClause = "OUTPUT INSERTED.id"; + } else if (normalizedType === "oracle") { + query += " RETURNING id INTO :out_id"; + returningClause = "RETURNING id INTO :out_id"; + } + // MySQL/MariaDB는 RETURNING 없음, LAST_INSERT_ID() 사용 + + return { query, values, returningClause }; +} + +/** + * SELECT 쿼리 생성 + */ +export function buildSelectQuery( + dbType: string, + tableName: string, + whereColumn: string = "id" +): { query: string; placeholder: string } { + const normalizedType = dbType.toLowerCase(); + const placeholder = getPlaceholder(normalizedType, 1); + + const query = `SELECT * FROM ${tableName} WHERE ${whereColumn} = ${placeholder}`; + + return { query, placeholder }; +} + +/** + * LIMIT/OFFSET 쿼리 생성 (페이징) + */ +export function buildPaginationClause( + dbType: string, + limit?: number, + offset?: number +): string { + const normalizedType = dbType.toLowerCase(); + + if (!limit) { + return ""; + } + + if ( + normalizedType === "postgresql" || + normalizedType === "mysql" || + normalizedType === "mariadb" + ) { + // PostgreSQL, MySQL, MariaDB: LIMIT ... OFFSET ... + let clause = ` LIMIT ${limit}`; + if (offset) { + clause += ` OFFSET ${offset}`; + } + return clause; + } else if (normalizedType === "mssql") { + // MSSQL: OFFSET ... ROWS FETCH NEXT ... ROWS ONLY + if (offset) { + return ` OFFSET ${offset} ROWS FETCH NEXT ${limit} ROWS ONLY`; + } else { + return ` OFFSET 0 ROWS FETCH NEXT ${limit} ROWS ONLY`; + } + } else if (normalizedType === "oracle") { + // Oracle: ROWNUM 또는 FETCH FIRST (12c+) + if (offset) { + return ` OFFSET ${offset} ROWS FETCH NEXT ${limit} ROWS ONLY`; + } else { + return ` FETCH FIRST ${limit} ROWS ONLY`; + } + } + + return ""; +} + +/** + * 트랜잭션 시작 + */ +export function getBeginTransactionQuery(dbType: string): string { + const normalizedType = dbType.toLowerCase(); + + if (normalizedType === "mssql") { + return "BEGIN TRANSACTION"; + } + + return "BEGIN"; +} + +/** + * 트랜잭션 커밋 + */ +export function getCommitQuery(dbType: string): string { + return "COMMIT"; +} + +/** + * 트랜잭션 롤백 + */ +export function getRollbackQuery(dbType: string): string { + return "ROLLBACK"; +} + +/** + * DB 연결 테스트 쿼리 + */ +export function getConnectionTestQuery(dbType: string): string { + const normalizedType = dbType.toLowerCase(); + + switch (normalizedType) { + case "postgresql": + return "SELECT 1"; + + case "mysql": + case "mariadb": + return "SELECT 1"; + + case "mssql": + return "SELECT 1"; + + case "oracle": + return "SELECT 1 FROM DUAL"; + + default: + return "SELECT 1"; + } +} diff --git a/backend-node/src/services/externalDbHelper.ts b/backend-node/src/services/externalDbHelper.ts new file mode 100644 index 00000000..2a774ef2 --- /dev/null +++ b/backend-node/src/services/externalDbHelper.ts @@ -0,0 +1,477 @@ +/** + * 외부 DB 연결 헬퍼 + * 플로우 데이터 이동 시 외부 DB 연결 관리 + * PostgreSQL, MySQL/MariaDB, MSSQL, Oracle 지원 + */ + +import { Pool as PgPool } from "pg"; +import * as mysql from "mysql2/promise"; +import db from "../database/db"; +import { CredentialEncryption } from "../utils/credentialEncryption"; +import { + getConnectionTestQuery, + getPlaceholder, + getBeginTransactionQuery, + getCommitQuery, + getRollbackQuery, +} from "./dbQueryBuilder"; + +interface ExternalDbConnection { + id: number; + connectionName: string; + dbType: string; + host: string; + port: number; + database: string; + username: string; + password: string; + isActive: boolean; +} + +// 외부 DB 연결 풀 캐시 (타입별로 다른 풀 객체) +const connectionPools = new Map(); + +// 비밀번호 복호화 유틸 +const credentialEncryption = new CredentialEncryption( + process.env.ENCRYPTION_SECRET_KEY || "default-secret-key-change-in-production" +); + +/** + * 외부 DB 연결 정보 조회 + */ +async function getExternalConnection( + connectionId: number +): Promise { + const query = ` + SELECT + id, connection_name, db_type, host, port, + database_name, username, encrypted_password, is_active + FROM external_db_connections + WHERE id = $1 AND is_active = true + `; + + const result = await db.query(query, [connectionId]); + + if (result.length === 0) { + return null; + } + + const row = result[0]; + + // 비밀번호 복호화 + let decryptedPassword = ""; + try { + decryptedPassword = credentialEncryption.decrypt(row.encrypted_password); + } catch (error) { + console.error(`비밀번호 복호화 실패 (ID: ${connectionId}):`, error); + throw new Error("외부 DB 비밀번호 복호화에 실패했습니다"); + } + + return { + id: row.id, + connectionName: row.connection_name, + dbType: row.db_type, + host: row.host, + port: row.port, + database: row.database_name, + username: row.username, + password: decryptedPassword, + isActive: row.is_active, + }; +} + +/** + * 외부 DB 연결 풀 생성 또는 재사용 + */ +export async function getExternalPool(connectionId: number): Promise { + // 캐시된 연결 풀 확인 + if (connectionPools.has(connectionId)) { + const poolInfo = connectionPools.get(connectionId)!; + const connection = await getExternalConnection(connectionId); + + // 연결이 유효한지 확인 + try { + const testQuery = getConnectionTestQuery(connection!.dbType); + await executePoolQuery(poolInfo.pool, connection!.dbType, testQuery, []); + return poolInfo; + } catch (error) { + console.warn( + `캐시된 외부 DB 연결 풀 무효화 (ID: ${connectionId}), 재생성합니다.` + ); + connectionPools.delete(connectionId); + await closePool(poolInfo.pool, connection!.dbType); + } + } + + // 새로운 연결 풀 생성 + const connection = await getExternalConnection(connectionId); + + if (!connection) { + throw new Error( + `외부 DB 연결 정보를 찾을 수 없습니다 (ID: ${connectionId})` + ); + } + + const dbType = connection.dbType.toLowerCase(); + let pool: any; + + try { + switch (dbType) { + case "postgresql": + pool = await createPostgreSQLPool(connection); + break; + + case "mysql": + case "mariadb": + pool = await createMySQLPool(connection); + break; + + case "mssql": + pool = await createMSSQLPool(connection); + break; + + case "oracle": + pool = await createOraclePool(connection); + break; + + default: + throw new Error(`지원하지 않는 DB 타입입니다: ${connection.dbType}`); + } + + // 연결 테스트 + const testQuery = getConnectionTestQuery(dbType); + await executePoolQuery(pool, dbType, testQuery, []); + + console.log( + `✅ 외부 DB 연결 풀 생성 성공 (ID: ${connectionId}, ${connection.connectionName}, ${connection.dbType})` + ); + + // 캐시에 저장 (dbType 정보 포함) + const poolInfo = { pool, dbType }; + connectionPools.set(connectionId, poolInfo); + + return poolInfo; + } catch (error) { + if (pool) { + await closePool(pool, dbType); + } + throw new Error( + `외부 DB 연결 실패 (${connection.connectionName}, ${connection.dbType}): ${error}` + ); + } +} + +/** + * PostgreSQL 연결 풀 생성 + */ +async function createPostgreSQLPool( + connection: ExternalDbConnection +): Promise { + return new PgPool({ + host: connection.host, + port: connection.port, + database: connection.database, + user: connection.username, + password: connection.password, + max: 5, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 5000, + }); +} + +/** + * MySQL/MariaDB 연결 풀 생성 + */ +async function createMySQLPool( + connection: ExternalDbConnection +): Promise { + return mysql.createPool({ + host: connection.host, + port: connection.port, + database: connection.database, + user: connection.username, + password: connection.password, + connectionLimit: 5, + waitForConnections: true, + queueLimit: 0, + }); +} + +/** + * MSSQL 연결 풀 생성 + */ +async function createMSSQLPool(connection: ExternalDbConnection): Promise { + // mssql 패키지를 동적으로 import (설치되어 있는 경우만) + try { + const sql = require("mssql"); + const config = { + user: connection.username, + password: connection.password, + server: connection.host, + port: connection.port, + database: connection.database, + options: { + encrypt: true, + trustServerCertificate: true, + enableArithAbort: true, + }, + pool: { + max: 5, + min: 0, + idleTimeoutMillis: 30000, + }, + }; + + const pool = await sql.connect(config); + return pool; + } catch (error) { + throw new Error( + `MSSQL 연결 실패: mssql 패키지가 설치되어 있는지 확인하세요. (${error})` + ); + } +} + +/** + * Oracle 연결 풀 생성 + */ +async function createOraclePool( + connection: ExternalDbConnection +): Promise { + try { + // oracledb를 동적으로 import + const oracledb = require("oracledb"); + + // Oracle 클라이언트 초기화 (최초 1회만) + if (!oracledb.oracleClientVersion) { + // Instant Client 경로 설정 (환경변수로 지정 가능) + const instantClientPath = process.env.ORACLE_INSTANT_CLIENT_PATH; + if (instantClientPath) { + oracledb.initOracleClient({ libDir: instantClientPath }); + } + } + + // 연결 문자열 생성 + const connectString = connection.database.includes("/") + ? connection.database // 이미 전체 연결 문자열인 경우 + : `${connection.host}:${connection.port}/${connection.database}`; + + const pool = await oracledb.createPool({ + user: connection.username, + password: connection.password, + connectString: connectString, + poolMin: 1, + poolMax: 5, + poolIncrement: 1, + poolTimeout: 60, // 60초 후 유휴 연결 해제 + queueTimeout: 5000, // 연결 대기 타임아웃 5초 + enableStatistics: true, + }); + + return pool; + } catch (error: any) { + throw new Error( + `Oracle 연결 실패: ${error.message}. oracledb 패키지와 Oracle Instant Client가 설치되어 있는지 확인하세요.` + ); + } +} + +/** + * 풀에서 쿼리 실행 (DB 타입별 처리) + */ +async function executePoolQuery( + pool: any, + dbType: string, + query: string, + params: any[] +): Promise { + const normalizedType = dbType.toLowerCase(); + + switch (normalizedType) { + case "postgresql": { + const result = await pool.query(query, params); + return { rows: result.rows, rowCount: result.rowCount }; + } + + case "mysql": + case "mariadb": { + const [rows] = await pool.query(query, params); + return { + rows: Array.isArray(rows) ? rows : [rows], + rowCount: rows.length, + }; + } + + case "mssql": { + const request = pool.request(); + // MSSQL은 명명된 파라미터 사용 + params.forEach((param, index) => { + request.input(`p${index + 1}`, param); + }); + const result = await request.query(query); + return { rows: result.recordset, rowCount: result.rowCount }; + } + + case "oracle": { + const oracledb = require("oracledb"); + const connection = await pool.getConnection(); + try { + // Oracle은 :1, :2 형식의 바인드 변수 사용 + const result = await connection.execute(query, params, { + autoCommit: false, // 트랜잭션 관리를 위해 false + outFormat: oracledb.OUT_FORMAT_OBJECT, // 객체 형식으로 반환 + }); + return { rows: result.rows || [], rowCount: result.rowCount || 0 }; + } finally { + await connection.close(); + } + } + + default: + throw new Error(`지원하지 않는 DB 타입: ${dbType}`); + } +} + +/** + * 연결 풀 종료 (DB 타입별 처리) + */ +async function closePool(pool: any, dbType: string): Promise { + const normalizedType = dbType.toLowerCase(); + + try { + switch (normalizedType) { + case "postgresql": + case "mysql": + case "mariadb": + await pool.end(); + break; + + case "mssql": + case "oracle": + await pool.close(); + break; + } + } catch (error) { + console.error(`풀 종료 오류 (${dbType}):`, error); + } +} + +/** + * 외부 DB 쿼리 실행 + */ +export async function executeExternalQuery( + connectionId: number, + query: string, + params: any[] = [] +): Promise { + const poolInfo = await getExternalPool(connectionId); + return await executePoolQuery(poolInfo.pool, poolInfo.dbType, query, params); +} + +/** + * 외부 DB 트랜잭션 실행 + */ +export async function executeExternalTransaction( + connectionId: number, + callback: (client: any, dbType: string) => Promise +): Promise { + const poolInfo = await getExternalPool(connectionId); + const { pool, dbType } = poolInfo; + const normalizedType = dbType.toLowerCase(); + + let client: any; + + try { + switch (normalizedType) { + case "postgresql": { + client = await pool.connect(); + await client.query(getBeginTransactionQuery(dbType)); + const result = await callback(client, dbType); + await client.query(getCommitQuery(dbType)); + return result; + } + + case "mysql": + case "mariadb": { + client = await pool.getConnection(); + await client.beginTransaction(); + const result = await callback(client, dbType); + await client.commit(); + return result; + } + + case "mssql": { + const transaction = new pool.constructor.Transaction(pool); + await transaction.begin(); + client = transaction; + const result = await callback(client, dbType); + await transaction.commit(); + return result; + } + + case "oracle": { + client = await pool.getConnection(); + // Oracle은 명시적 BEGIN 없이 트랜잭션 시작 + const result = await callback(client, dbType); + // 명시적 커밋 + await client.commit(); + return result; + } + + default: + throw new Error(`지원하지 않는 DB 타입: ${dbType}`); + } + } catch (error) { + console.error(`외부 DB 트랜잭션 오류 (ID: ${connectionId}):`, error); + + // 롤백 시도 + if (client) { + try { + switch (normalizedType) { + case "postgresql": + await client.query(getRollbackQuery(dbType)); + break; + + case "mysql": + case "mariadb": + await client.rollback(); + break; + + case "mssql": + case "oracle": + await client.rollback(); + break; + } + } catch (rollbackError) { + console.error("트랜잭션 롤백 오류:", rollbackError); + } + } + + throw error; + } finally { + // 연결 해제 + if (client) { + try { + switch (normalizedType) { + case "postgresql": + client.release(); + break; + + case "mysql": + case "mariadb": + client.release(); + break; + + case "oracle": + await client.close(); + break; + + case "mssql": + // MSSQL Transaction 객체는 자동으로 정리됨 + break; + } + } catch (releaseError) { + console.error("클라이언트 해제 오류:", releaseError); + } + } + } +} diff --git a/backend-node/src/services/flowDataMoveService.ts b/backend-node/src/services/flowDataMoveService.ts index 9ed99548..03fd18c6 100644 --- a/backend-node/src/services/flowDataMoveService.ts +++ b/backend-node/src/services/flowDataMoveService.ts @@ -6,10 +6,25 @@ */ import db from "../database/db"; -import { FlowAuditLog, FlowIntegrationContext } from "../types/flow"; +import { + FlowAuditLog, + FlowIntegrationContext, + FlowDefinition, +} from "../types/flow"; import { FlowDefinitionService } from "./flowDefinitionService"; import { FlowStepService } from "./flowStepService"; import { FlowExternalDbIntegrationService } from "./flowExternalDbIntegrationService"; +import { + getExternalPool, + executeExternalQuery, + executeExternalTransaction, +} from "./externalDbHelper"; +import { + getPlaceholder, + buildUpdateQuery, + buildInsertQuery, + buildSelectQuery, +} from "./dbQueryBuilder"; export class FlowDataMoveService { private flowDefinitionService: FlowDefinitionService; @@ -33,6 +48,28 @@ export class FlowDataMoveService { userId: string = "system", additionalData?: Record ): Promise<{ success: boolean; targetDataId?: any; message?: string }> { + // 0. 플로우 정의 조회 (DB 소스 확인) + const flowDefinition = await this.flowDefinitionService.findById(flowId); + if (!flowDefinition) { + throw new Error(`플로우를 찾을 수 없습니다 (ID: ${flowId})`); + } + + // 외부 DB인 경우 별도 처리 + if ( + flowDefinition.dbSourceType === "external" && + flowDefinition.dbConnectionId + ) { + return await this.moveDataToStepExternal( + flowDefinition.dbConnectionId, + fromStepId, + toStepId, + dataId, + userId, + additionalData + ); + } + + // 내부 DB 처리 (기존 로직) return await db.transaction(async (client) => { try { // 1. 단계 정보 조회 @@ -160,7 +197,14 @@ export class FlowDataMoveService { dataId: any, additionalData?: Record ): Promise { - const statusColumn = toStep.statusColumn || "flow_status"; + // 상태 컬럼이 지정되지 않은 경우 에러 + if (!toStep.statusColumn) { + throw new Error( + `단계 "${toStep.stepName}"의 상태 컬럼이 지정되지 않았습니다. 플로우 편집 화면에서 "상태 컬럼명"을 설정해주세요.` + ); + } + + const statusColumn = toStep.statusColumn; const tableName = fromStep.tableName; // 추가 필드 업데이트 준비 @@ -590,4 +634,307 @@ export class FlowDataMoveService { userId, ]); } + + /** + * 외부 DB 데이터 이동 처리 + */ + private async moveDataToStepExternal( + dbConnectionId: number, + fromStepId: number, + toStepId: number, + dataId: any, + userId: string = "system", + additionalData?: Record + ): Promise<{ success: boolean; targetDataId?: any; message?: string }> { + return await executeExternalTransaction( + dbConnectionId, + async (externalClient, dbType) => { + try { + // 1. 단계 정보 조회 (내부 DB에서) + const fromStep = await this.flowStepService.findById(fromStepId); + const toStep = await this.flowStepService.findById(toStepId); + + if (!fromStep || !toStep) { + throw new Error("유효하지 않은 단계입니다"); + } + + let targetDataId = dataId; + let sourceTable = fromStep.tableName; + let targetTable = toStep.tableName || fromStep.tableName; + + // 2. 이동 방식에 따라 처리 + switch (toStep.moveType || "status") { + case "status": + // 상태 변경 방식 + await this.moveByStatusChangeExternal( + externalClient, + dbType, + fromStep, + toStep, + dataId, + additionalData + ); + break; + + case "table": + // 테이블 이동 방식 + targetDataId = await this.moveByTableTransferExternal( + externalClient, + dbType, + fromStep, + toStep, + dataId, + additionalData + ); + targetTable = toStep.targetTable || toStep.tableName; + break; + + case "both": + // 하이브리드 방식: 둘 다 수행 + await this.moveByStatusChangeExternal( + externalClient, + dbType, + fromStep, + toStep, + dataId, + additionalData + ); + targetDataId = await this.moveByTableTransferExternal( + externalClient, + dbType, + fromStep, + toStep, + dataId, + additionalData + ); + targetTable = toStep.targetTable || toStep.tableName; + break; + + default: + throw new Error( + `지원하지 않는 이동 방식입니다: ${toStep.moveType}` + ); + } + + // 3. 외부 연동 처리는 생략 (외부 DB 자체가 외부이므로) + + // 4. 감사 로그 기록 (내부 DB에) + // 외부 DB는 내부 DB 트랜잭션 외부이므로 직접 쿼리 실행 + const auditQuery = ` + INSERT INTO flow_audit_log ( + flow_definition_id, from_step_id, to_step_id, + move_type, source_table, target_table, + source_data_id, target_data_id, + status_from, status_to, + changed_by, note + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + `; + + await db.query(auditQuery, [ + toStep.flowDefinitionId, + fromStep.id, + toStep.id, + toStep.moveType || "status", + sourceTable, + targetTable, + dataId, + targetDataId, + null, // statusFrom + toStep.statusValue || null, // statusTo + userId, + `외부 DB (${dbType}) 데이터 이동`, + ]); + + return { + success: true, + targetDataId, + message: `데이터 이동이 완료되었습니다 (외부 DB: ${dbType})`, + }; + } catch (error: any) { + console.error("외부 DB 데이터 이동 오류:", error); + throw error; + } + } + ); + } + + /** + * 외부 DB 상태 변경 방식으로 데이터 이동 + */ + private async moveByStatusChangeExternal( + externalClient: any, + dbType: string, + fromStep: any, + toStep: any, + dataId: any, + additionalData?: Record + ): Promise { + // 상태 컬럼이 지정되지 않은 경우 에러 + if (!toStep.statusColumn) { + throw new Error( + `단계 "${toStep.stepName}"의 상태 컬럼이 지정되지 않았습니다. 플로우 편집 화면에서 "상태 컬럼명"을 설정해주세요.` + ); + } + + const statusColumn = toStep.statusColumn; + const tableName = fromStep.tableName; + const normalizedDbType = dbType.toLowerCase(); + + // 업데이트할 필드 준비 + const updateFields: { column: string; value: any }[] = [ + { column: statusColumn, value: toStep.statusValue }, + ]; + + // 추가 데이터가 있으면 함께 업데이트 + if (additionalData) { + for (const [key, value] of Object.entries(additionalData)) { + updateFields.push({ column: key, value }); + } + } + + // DB별 쿼리 생성 + const { query: updateQuery, values } = buildUpdateQuery( + dbType, + tableName, + updateFields, + "id" + ); + + // WHERE 절 값 설정 (마지막 파라미터) + values[values.length - 1] = dataId; + + // 쿼리 실행 (DB 타입별 처리) + let result: any; + if (normalizedDbType === "postgresql") { + result = await externalClient.query(updateQuery, values); + } else if (normalizedDbType === "mysql" || normalizedDbType === "mariadb") { + [result] = await externalClient.query(updateQuery, values); + } else if (normalizedDbType === "mssql") { + const request = externalClient.request(); + values.forEach((val: any, idx: number) => { + request.input(`p${idx + 1}`, val); + }); + result = await request.query(updateQuery); + } else if (normalizedDbType === "oracle") { + result = await externalClient.execute(updateQuery, values, { + autoCommit: false, + }); + } + + // 결과 확인 + const affectedRows = + normalizedDbType === "postgresql" + ? result.rowCount + : normalizedDbType === "mssql" + ? result.rowsAffected[0] + : normalizedDbType === "oracle" + ? result.rowsAffected + : result.affectedRows; + + if (affectedRows === 0) { + throw new Error(`데이터를 찾을 수 없습니다: ${dataId}`); + } + } + + /** + * 외부 DB 테이블 이동 방식으로 데이터 이동 + */ + private async moveByTableTransferExternal( + externalClient: any, + dbType: string, + fromStep: any, + toStep: any, + dataId: any, + additionalData?: Record + ): Promise { + const sourceTable = fromStep.tableName; + const targetTable = toStep.targetTable || toStep.tableName; + const fieldMappings = toStep.fieldMappings || {}; + const normalizedDbType = dbType.toLowerCase(); + + // 1. 소스 데이터 조회 + const { query: selectQuery, placeholder } = buildSelectQuery( + dbType, + sourceTable, + "id" + ); + + let sourceResult: any; + if (normalizedDbType === "postgresql") { + sourceResult = await externalClient.query(selectQuery, [dataId]); + } else if (normalizedDbType === "mysql" || normalizedDbType === "mariadb") { + [sourceResult] = await externalClient.query(selectQuery, [dataId]); + } else if (normalizedDbType === "mssql") { + const request = externalClient.request(); + request.input("p1", dataId); + sourceResult = await request.query(selectQuery); + sourceResult = { rows: sourceResult.recordset }; + } else if (normalizedDbType === "oracle") { + sourceResult = await externalClient.execute(selectQuery, [dataId], { + autoCommit: false, + outFormat: 4001, // oracledb.OUT_FORMAT_OBJECT + }); + } + + const rows = sourceResult.rows || sourceResult; + if (!rows || rows.length === 0) { + throw new Error(`소스 데이터를 찾을 수 없습니다: ${dataId}`); + } + + const sourceData = rows[0]; + + // 2. 필드 매핑 적용 + const targetData: Record = {}; + + for (const [targetField, sourceField] of Object.entries(fieldMappings)) { + const sourceFieldKey = sourceField as string; + if (sourceData[sourceFieldKey] !== undefined) { + targetData[targetField] = sourceData[sourceFieldKey]; + } + } + + // 추가 데이터 병합 + if (additionalData) { + Object.assign(targetData, additionalData); + } + + // 3. 대상 테이블에 삽입 + const { query: insertQuery, values } = buildInsertQuery( + dbType, + targetTable, + targetData + ); + + let insertResult: any; + let newDataId: any; + + if (normalizedDbType === "postgresql") { + insertResult = await externalClient.query(insertQuery, values); + newDataId = insertResult.rows[0].id; + } else if (normalizedDbType === "mysql" || normalizedDbType === "mariadb") { + [insertResult] = await externalClient.query(insertQuery, values); + newDataId = insertResult.insertId; + } else if (normalizedDbType === "mssql") { + const request = externalClient.request(); + values.forEach((val: any, idx: number) => { + request.input(`p${idx + 1}`, val); + }); + insertResult = await request.query(insertQuery); + newDataId = insertResult.recordset[0].id; + } else if (normalizedDbType === "oracle") { + // Oracle RETURNING 절 처리 + const outBinds: any = { id: { dir: 3003, type: 2001 } }; // OUT, NUMBER + insertResult = await externalClient.execute(insertQuery, values, { + autoCommit: false, + outBinds: outBinds, + }); + newDataId = insertResult.outBinds.id[0]; + } + + // 4. 필요 시 소스 데이터 삭제 (옵션) + // const deletePlaceholder = getPlaceholder(dbType, 1); + // await externalClient.query(`DELETE FROM ${sourceTable} WHERE id = ${deletePlaceholder}`, [dataId]); + + return newDataId; + } } diff --git a/backend-node/src/services/flowDefinitionService.ts b/backend-node/src/services/flowDefinitionService.ts index 859e0792..f08f934d 100644 --- a/backend-node/src/services/flowDefinitionService.ts +++ b/backend-node/src/services/flowDefinitionService.ts @@ -17,18 +17,33 @@ export class FlowDefinitionService { request: CreateFlowDefinitionRequest, userId: string ): Promise { + console.log("🔥 flowDefinitionService.create called with:", { + name: request.name, + description: request.description, + tableName: request.tableName, + dbSourceType: request.dbSourceType, + dbConnectionId: request.dbConnectionId, + userId, + }); + const query = ` - INSERT INTO flow_definition (name, description, table_name, created_by) - VALUES ($1, $2, $3, $4) + INSERT INTO flow_definition (name, description, table_name, db_source_type, db_connection_id, created_by) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING * `; - const result = await db.query(query, [ + const values = [ request.name, request.description || null, - request.tableName, + request.tableName || null, + request.dbSourceType || "internal", + request.dbConnectionId || null, userId, - ]); + ]; + + console.log("💾 Executing INSERT with values:", values); + + const result = await db.query(query, values); return this.mapToFlowDefinition(result[0]); } @@ -162,6 +177,8 @@ export class FlowDefinitionService { name: row.name, description: row.description, tableName: row.table_name, + dbSourceType: row.db_source_type || "internal", + dbConnectionId: row.db_connection_id, isActive: row.is_active, createdBy: row.created_by, createdAt: row.created_at, diff --git a/backend-node/src/services/flowExecutionService.ts b/backend-node/src/services/flowExecutionService.ts index ae4f1369..9d9eb9c4 100644 --- a/backend-node/src/services/flowExecutionService.ts +++ b/backend-node/src/services/flowExecutionService.ts @@ -8,6 +8,8 @@ import { FlowStepDataCount, FlowStepDataList } from "../types/flow"; import { FlowDefinitionService } from "./flowDefinitionService"; import { FlowStepService } from "./flowStepService"; import { FlowConditionParser } from "./flowConditionParser"; +import { executeExternalQuery } from "./externalDbHelper"; +import { getPlaceholder, buildPaginationClause } from "./dbQueryBuilder"; export class FlowExecutionService { private flowDefinitionService: FlowDefinitionService; @@ -28,6 +30,13 @@ export class FlowExecutionService { throw new Error(`Flow definition not found: ${flowId}`); } + console.log("🔍 [getStepDataCount] Flow Definition:", { + flowId, + dbSourceType: flowDef.dbSourceType, + dbConnectionId: flowDef.dbConnectionId, + tableName: flowDef.tableName, + }); + // 2. 플로우 단계 조회 const step = await this.flowStepService.findById(stepId); if (!step) { @@ -46,11 +55,40 @@ export class FlowExecutionService { step.conditionJson ); - // 5. 카운트 쿼리 실행 + // 5. 카운트 쿼리 실행 (내부 또는 외부 DB) const query = `SELECT COUNT(*) as count FROM ${tableName} WHERE ${where}`; - const result = await db.query(query, params); - return parseInt(result[0].count); + console.log("🔍 [getStepDataCount] Query Info:", { + tableName, + query, + params, + isExternal: flowDef.dbSourceType === "external", + connectionId: flowDef.dbConnectionId, + }); + + let result: any; + if (flowDef.dbSourceType === "external" && flowDef.dbConnectionId) { + // 외부 DB 조회 + console.log( + "✅ [getStepDataCount] Using EXTERNAL DB:", + flowDef.dbConnectionId + ); + const externalResult = await executeExternalQuery( + flowDef.dbConnectionId, + query, + params + ); + console.log("📦 [getStepDataCount] External result:", externalResult); + result = externalResult.rows; + } else { + // 내부 DB 조회 + console.log("✅ [getStepDataCount] Using INTERNAL DB"); + result = await db.query(query, params); + } + + const count = parseInt(result[0].count || result[0].COUNT); + console.log("✅ [getStepDataCount] Final count:", count); + return count; } /** @@ -88,47 +126,98 @@ export class FlowExecutionService { const offset = (page - 1) * pageSize; + const isExternalDb = + flowDef.dbSourceType === "external" && flowDef.dbConnectionId; + // 5. 전체 카운트 const countQuery = `SELECT COUNT(*) as count FROM ${tableName} WHERE ${where}`; - const countResult = await db.query(countQuery, params); - const total = parseInt(countResult[0].count); + let countResult: any; + let total: number; - // 6. 테이블의 Primary Key 컬럼 찾기 - let orderByColumn = ""; - try { - const pkQuery = ` - SELECT a.attname - FROM pg_index i - JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) - WHERE i.indrelid = $1::regclass - AND i.indisprimary - LIMIT 1 - `; - const pkResult = await db.query(pkQuery, [tableName]); - if (pkResult.length > 0) { - orderByColumn = pkResult[0].attname; - } - } catch (err) { - // Primary Key를 찾지 못하면 ORDER BY 없이 진행 - console.warn(`Could not find primary key for table ${tableName}:`, err); + if (isExternalDb) { + const externalCountResult = await executeExternalQuery( + flowDef.dbConnectionId!, + countQuery, + params + ); + countResult = externalCountResult.rows; + total = parseInt(countResult[0].count || countResult[0].COUNT); + } else { + countResult = await db.query(countQuery, params); + total = parseInt(countResult[0].count); } - // 7. 데이터 조회 - const orderByClause = orderByColumn ? `ORDER BY ${orderByColumn} DESC` : ""; - const dataQuery = ` - SELECT * FROM ${tableName} - WHERE ${where} - ${orderByClause} - LIMIT $${params.length + 1} OFFSET $${params.length + 2} - `; - const dataResult = await db.query(dataQuery, [...params, pageSize, offset]); + // 6. 데이터 조회 (DB 타입별 페이징 처리) + let dataQuery: string; + let dataParams: any[]; - return { - records: dataResult, - total, - page, - pageSize, - }; + if (isExternalDb) { + // 외부 DB는 id 컬럼으로 정렬 (가정) + // DB 타입에 따른 페이징 절은 빌더에서 처리하지 않고 직접 작성 + // PostgreSQL, MySQL, MSSQL, Oracle 모두 지원하도록 단순화 + dataQuery = ` + SELECT * FROM ${tableName} + WHERE ${where} + ORDER BY id DESC + LIMIT ${pageSize} OFFSET ${offset} + `; + dataParams = params; + + const externalDataResult = await executeExternalQuery( + flowDef.dbConnectionId!, + dataQuery, + dataParams + ); + + return { + records: externalDataResult.rows, + total, + page, + pageSize, + }; + } else { + // 내부 DB (PostgreSQL) + // Primary Key 컬럼 찾기 + let orderByColumn = ""; + try { + const pkQuery = ` + SELECT a.attname + FROM pg_index i + JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) + WHERE i.indrelid = $1::regclass + AND i.indisprimary + LIMIT 1 + `; + const pkResult = await db.query(pkQuery, [tableName]); + if (pkResult.length > 0) { + orderByColumn = pkResult[0].attname; + } + } catch (err) { + console.warn(`Could not find primary key for table ${tableName}:`, err); + } + + const orderByClause = orderByColumn + ? `ORDER BY ${orderByColumn} DESC` + : ""; + dataQuery = ` + SELECT * FROM ${tableName} + WHERE ${where} + ${orderByClause} + LIMIT $${params.length + 1} OFFSET $${params.length + 2} + `; + const dataResult = await db.query(dataQuery, [ + ...params, + pageSize, + offset, + ]); + + return { + records: dataResult, + total, + page, + pageSize, + }; + } } /** diff --git a/backend-node/src/types/flow.ts b/backend-node/src/types/flow.ts index 3483b617..0c84fbeb 100644 --- a/backend-node/src/types/flow.ts +++ b/backend-node/src/types/flow.ts @@ -8,6 +8,8 @@ export interface FlowDefinition { name: string; description?: string; tableName: string; + dbSourceType?: "internal" | "external"; // 데이터베이스 소스 타입 + dbConnectionId?: number; // 외부 DB 연결 ID (external인 경우) isActive: boolean; createdBy?: string; createdAt: Date; @@ -19,6 +21,8 @@ export interface CreateFlowDefinitionRequest { name: string; description?: string; tableName: string; + dbSourceType?: "internal" | "external"; // 데이터베이스 소스 타입 + dbConnectionId?: number; // 외부 DB 연결 ID } // 플로우 정의 수정 요청 diff --git a/frontend/app/(main)/admin/flow-management/[id]/page.tsx b/frontend/app/(main)/admin/flow-management/[id]/page.tsx index 77d42718..a311bc63 100644 --- a/frontend/app/(main)/admin/flow-management/[id]/page.tsx +++ b/frontend/app/(main)/admin/flow-management/[id]/page.tsx @@ -73,7 +73,9 @@ export default function FlowEditorPage() { // 플로우 정의 로드 const flowRes = await getFlowDefinition(flowId); if (flowRes.success && flowRes.data) { - setFlowDefinition(flowRes.data); + console.log("🔍 Flow Definition loaded:", flowRes.data); + console.log("📋 Table Name:", flowRes.data.definition?.tableName); + setFlowDefinition(flowRes.data.definition); } // 단계 로드 @@ -314,6 +316,9 @@ export default function FlowEditorPage() { setSelectedStep(null)} onUpdate={loadFlowData} /> diff --git a/frontend/app/(main)/admin/flow-management/page.tsx b/frontend/app/(main)/admin/flow-management/page.tsx index 999fb6fa..f36bd5a2 100644 --- a/frontend/app/(main)/admin/flow-management/page.tsx +++ b/frontend/app/(main)/admin/flow-management/page.tsx @@ -9,7 +9,7 @@ import { useState, useEffect } from "react"; import { useRouter } from "next/navigation"; -import { Plus, Edit2, Trash2, Play, Workflow, Table, Calendar, User } from "lucide-react"; +import { Plus, Edit2, Trash2, Play, Workflow, Table, Calendar, User, Check, ChevronsUpDown } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; @@ -27,6 +27,11 @@ import { Textarea } from "@/components/ui/textarea"; import { useToast } from "@/hooks/use-toast"; import { getFlowDefinitions, createFlowDefinition, deleteFlowDefinition } from "@/lib/api/flow"; import { FlowDefinition } from "@/types/flow"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { cn } from "@/lib/utils"; +import { tableManagementApi } from "@/lib/api/tableManagement"; export default function FlowManagementPage() { const router = useRouter(); @@ -39,6 +44,15 @@ export default function FlowManagementPage() { const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [selectedFlow, setSelectedFlow] = useState(null); + // 테이블 목록 관련 상태 + const [tableList, setTableList] = useState([]); // 내부 DB 테이블 + const [loadingTables, setLoadingTables] = useState(false); + const [openTableCombobox, setOpenTableCombobox] = useState(false); + const [selectedDbSource, setSelectedDbSource] = useState<"internal" | number>("internal"); // "internal" 또는 외부 DB connection ID + const [externalConnections, setExternalConnections] = useState([]); + const [externalTableList, setExternalTableList] = useState([]); + const [loadingExternalTables, setLoadingExternalTables] = useState(false); + // 생성 폼 상태 const [formData, setFormData] = useState({ name: "", @@ -75,9 +89,107 @@ export default function FlowManagementPage() { loadFlows(); }, []); + // 테이블 목록 로드 (내부 DB) + useEffect(() => { + const loadTables = async () => { + try { + setLoadingTables(true); + const response = await tableManagementApi.getTableList(); + if (response.success && response.data) { + setTableList(response.data); + } + } catch (error) { + console.error("Failed to load tables:", error); + } finally { + setLoadingTables(false); + } + }; + loadTables(); + }, []); + + // 외부 DB 연결 목록 로드 + useEffect(() => { + const loadConnections = async () => { + try { + const token = localStorage.getItem("authToken"); + if (!token) { + console.warn("No auth token found"); + return; + } + + const response = await fetch("/api/external-db-connections/control/active", { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (response && response.ok) { + const data = await response.json(); + if (data.success && data.data) { + // 메인 데이터베이스(현재 시스템) 제외 - connection_name에 "메인" 또는 "현재 시스템"이 포함된 것 필터링 + const filtered = data.data.filter( + (conn: any) => !conn.connection_name.includes("메인") && !conn.connection_name.includes("현재 시스템"), + ); + setExternalConnections(filtered); + } + } + } catch (error) { + console.error("Failed to load external connections:", error); + setExternalConnections([]); + } + }; + loadConnections(); + }, []); + + // 외부 DB 테이블 목록 로드 + useEffect(() => { + if (selectedDbSource === "internal" || !selectedDbSource) { + setExternalTableList([]); + return; + } + + const loadExternalTables = async () => { + try { + setLoadingExternalTables(true); + const token = localStorage.getItem("authToken"); + + const response = await fetch(`/api/multi-connection/connections/${selectedDbSource}/tables`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (response && response.ok) { + const data = await response.json(); + if (data.success && data.data) { + const tables = Array.isArray(data.data) ? data.data : []; + const tableNames = tables + .map((t: any) => (typeof t === "string" ? t : t.tableName || t.table_name || t.tablename || t.name)) + .filter(Boolean); + setExternalTableList(tableNames); + } else { + setExternalTableList([]); + } + } else { + setExternalTableList([]); + } + } catch (error) { + console.error("외부 DB 테이블 목록 조회 오류:", error); + setExternalTableList([]); + } finally { + setLoadingExternalTables(false); + } + }; + + loadExternalTables(); + }, [selectedDbSource]); + // 플로우 생성 const handleCreate = async () => { + console.log("🚀 handleCreate called with formData:", formData); + if (!formData.name || !formData.tableName) { + console.log("❌ Validation failed:", { name: formData.name, tableName: formData.tableName }); toast({ title: "입력 오류", description: "플로우 이름과 테이블 이름은 필수입니다.", @@ -87,7 +199,15 @@ export default function FlowManagementPage() { } try { - const response = await createFlowDefinition(formData); + // DB 소스 정보 추가 + const requestData = { + ...formData, + dbSourceType: selectedDbSource === "internal" ? "internal" : "external", + dbConnectionId: selectedDbSource === "internal" ? undefined : Number(selectedDbSource), + }; + + console.log("✅ Calling createFlowDefinition with:", requestData); + const response = await createFlowDefinition(requestData); if (response.success && response.data) { toast({ title: "생성 완료", @@ -95,6 +215,7 @@ export default function FlowManagementPage() { }); setIsCreateDialogOpen(false); setFormData({ name: "", description: "", tableName: "" }); + setSelectedDbSource("internal"); loadFlows(); } else { toast({ @@ -277,19 +398,123 @@ export default function FlowManagementPage() { />
+ {/* DB 소스 선택 */} +
+ + +

+ 플로우에서 사용할 데이터베이스를 선택합니다 +

+
+ + {/* 테이블 선택 */}
- setFormData({ ...formData, tableName: e.target.value })} - placeholder="예: products" - className="h-8 text-xs sm:h-10 sm:text-sm" - /> + + + + + + + + + 테이블을 찾을 수 없습니다. + + {selectedDbSource === "internal" + ? // 내부 DB 테이블 목록 + tableList.map((table) => ( + { + console.log("📝 Internal table selected:", { + tableName: table.tableName, + currentValue, + }); + setFormData({ ...formData, tableName: currentValue }); + setOpenTableCombobox(false); + }} + className="text-xs sm:text-sm" + > + +
+ {table.displayName || table.tableName} + {table.description && ( + {table.description} + )} +
+
+ )) + : // 외부 DB 테이블 목록 + externalTableList.map((tableName, index) => ( + { + setFormData({ ...formData, tableName: currentValue }); + setOpenTableCombobox(false); + }} + className="text-xs sm:text-sm" + > + +
{tableName}
+
+ ))} +
+
+
+
+

- 플로우가 관리할 데이터 테이블 이름을 입력하세요 + 플로우의 모든 단계에서 사용할 기본 테이블입니다 (단계마다 상태 컬럼만 지정합니다)

diff --git a/frontend/app/(main)/page.tsx b/frontend/app/(main)/page.tsx index ccc4bba3..88f98b84 100644 --- a/frontend/app/(main)/page.tsx +++ b/frontend/app/(main)/page.tsx @@ -1,15 +1,12 @@ export default function MainHomePage() { return ( -
+
{/* 대시보드 컨텐츠 */}

WACE 솔루션에 오신 것을 환영합니다!

제품 수명 주기 관리 시스템을 통해 효율적인 업무를 시작하세요.

- - Spring Boot - Next.js diff --git a/frontend/components/flow/FlowConditionBuilder.tsx b/frontend/components/flow/FlowConditionBuilder.tsx index f6421eba..c3572dc8 100644 --- a/frontend/components/flow/FlowConditionBuilder.tsx +++ b/frontend/components/flow/FlowConditionBuilder.tsx @@ -16,6 +16,8 @@ import { getTableColumns } from "@/lib/api/tableManagement"; interface FlowConditionBuilderProps { flowId: number; tableName?: string; // 조회할 테이블명 + dbSourceType?: "internal" | "external"; // DB 소스 타입 + dbConnectionId?: number; // 외부 DB 연결 ID condition?: FlowConditionGroup; onChange: (condition: FlowConditionGroup | undefined) => void; } @@ -35,7 +37,14 @@ const OPERATORS: { value: ConditionOperator; label: string }[] = [ { value: "is_not_null", label: "NOT NULL" }, ]; -export function FlowConditionBuilder({ flowId, tableName, condition, onChange }: FlowConditionBuilderProps) { +export function FlowConditionBuilder({ + flowId, + tableName, + dbSourceType = "internal", + dbConnectionId, + condition, + onChange, +}: FlowConditionBuilderProps) { const [columns, setColumns] = useState([]); const [loadingColumns, setLoadingColumns] = useState(false); const [conditionType, setConditionType] = useState<"AND" | "OR">(condition?.type || "AND"); @@ -52,7 +61,7 @@ export function FlowConditionBuilder({ flowId, tableName, condition, onChange }: } }, [condition]); - // 테이블 컬럼 로드 + // 테이블 컬럼 로드 - 내부/외부 DB 모두 지원 useEffect(() => { if (!tableName) { setColumns([]); @@ -62,17 +71,69 @@ export function FlowConditionBuilder({ flowId, tableName, condition, onChange }: const loadColumns = async () => { try { setLoadingColumns(true); - console.log("🔍 Loading columns for table:", tableName); - const response = await getTableColumns(tableName); - console.log("📦 Column API response:", response); + console.log("🔍 [FlowConditionBuilder] Loading columns:", { + tableName, + dbSourceType, + dbConnectionId, + }); - if (response.success && response.data?.columns) { - const columnArray = Array.isArray(response.data.columns) ? response.data.columns : []; - console.log("✅ Setting columns:", columnArray.length, "items"); - setColumns(columnArray); + // 외부 DB인 경우 + if (dbSourceType === "external" && dbConnectionId) { + const token = localStorage.getItem("authToken"); + if (!token) { + console.warn("토큰이 없습니다. 외부 DB 컬럼 목록을 조회할 수 없습니다."); + setColumns([]); + return; + } + + const response = await fetch( + `/api/multi-connection/connections/${dbConnectionId}/tables/${tableName}/columns`, + { + credentials: "include", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }, + ).catch((err) => { + console.warn("외부 DB 컬럼 fetch 실패:", err); + return null; + }); + + if (response && response.ok) { + const result = await response.json(); + console.log("✅ [FlowConditionBuilder] External columns response:", result); + + if (result.success && result.data) { + const columnList = Array.isArray(result.data) + ? result.data.map((col: any) => ({ + column_name: col.column_name || col.columnName || col.name, + data_type: col.data_type || col.dataType || col.type, + })) + : []; + console.log("✅ Setting external columns:", columnList.length, "items"); + setColumns(columnList); + } else { + console.warn("❌ No data in external columns response"); + setColumns([]); + } + } else { + console.warn(`외부 DB 컬럼 조회 실패: ${response?.status}`); + setColumns([]); + } } else { - console.error("❌ Failed to load columns:", response.message); - setColumns([]); + // 내부 DB인 경우 (기존 로직) + const response = await getTableColumns(tableName); + console.log("📦 [FlowConditionBuilder] Internal columns response:", response); + + if (response.success && response.data?.columns) { + const columnArray = Array.isArray(response.data.columns) ? response.data.columns : []; + console.log("✅ Setting internal columns:", columnArray.length, "items"); + setColumns(columnArray); + } else { + console.error("❌ Failed to load internal columns:", response.message); + setColumns([]); + } } } catch (error) { console.error("❌ Exception loading columns:", error); @@ -83,7 +144,7 @@ export function FlowConditionBuilder({ flowId, tableName, condition, onChange }: }; loadColumns(); - }, [tableName]); + }, [tableName, dbSourceType, dbConnectionId]); // 조건 변경 시 부모에 전달 useEffect(() => { diff --git a/frontend/components/flow/FlowStepPanel.tsx b/frontend/components/flow/FlowStepPanel.tsx index 013a52aa..21a660bb 100644 --- a/frontend/components/flow/FlowStepPanel.tsx +++ b/frontend/components/flow/FlowStepPanel.tsx @@ -31,16 +31,35 @@ import { Textarea } from "@/components/ui/textarea"; interface FlowStepPanelProps { step: FlowStep; flowId: number; + flowTableName?: string; // 플로우 정의에서 선택한 테이블명 + flowDbSourceType?: "internal" | "external"; // 플로우의 DB 소스 타입 + flowDbConnectionId?: number; // 플로우의 외부 DB 연결 ID onClose: () => void; onUpdate: () => void; } -export function FlowStepPanel({ step, flowId, onClose, onUpdate }: FlowStepPanelProps) { +export function FlowStepPanel({ + step, + flowId, + flowTableName, + flowDbSourceType = "internal", + flowDbConnectionId, + onClose, + onUpdate, +}: FlowStepPanelProps) { const { toast } = useToast(); + console.log("🎯 FlowStepPanel Props:", { + stepTableName: step.tableName, + flowTableName, + flowDbSourceType, + flowDbConnectionId, + final: step.tableName || flowTableName || "", + }); + const [formData, setFormData] = useState({ stepName: step.stepName, - tableName: step.tableName || "", + tableName: step.tableName || flowTableName || "", // 플로우 테이블명 우선 사용 (신규 방식) conditionJson: step.conditionJson, // 하이브리드 모드 필드 moveType: step.moveType || "status", @@ -215,11 +234,12 @@ export function FlowStepPanel({ step, flowId, onClose, onUpdate }: FlowStepPanel stepName: step.stepName, statusColumn: step.statusColumn, statusValue: step.statusValue, + flowTableName, // 플로우 정의의 테이블명 }); const newFormData = { stepName: step.stepName, - tableName: step.tableName || "", + tableName: step.tableName || flowTableName || "", // 플로우 테이블명 우선 사용 conditionJson: step.conditionJson, // 하이브리드 모드 필드 moveType: step.moveType || "status", @@ -234,9 +254,9 @@ export function FlowStepPanel({ step, flowId, onClose, onUpdate }: FlowStepPanel console.log("✅ Setting formData:", newFormData); setFormData(newFormData); - }, [step.id]); // step 전체가 아닌 step.id만 의존성으로 설정 + }, [step.id, flowTableName]); // flowTableName도 의존성 추가 - // 테이블 선택 시 컬럼 로드 + // 테이블 선택 시 컬럼 로드 - 내부/외부 DB 모두 지원 useEffect(() => { const loadColumns = async () => { if (!formData.tableName) { @@ -246,16 +266,70 @@ export function FlowStepPanel({ step, flowId, onClose, onUpdate }: FlowStepPanel try { setLoadingColumns(true); - console.log("🔍 Loading columns for status column selector:", formData.tableName); - const response = await getTableColumns(formData.tableName); - console.log("📦 Columns response:", response); + console.log("🔍 Loading columns for status column selector:", { + tableName: formData.tableName, + flowDbSourceType, + flowDbConnectionId, + }); - if (response.success && response.data && response.data.columns) { - console.log("✅ Setting columns:", response.data.columns); - setColumns(response.data.columns); + // 외부 DB인 경우 + if (flowDbSourceType === "external" && flowDbConnectionId) { + const token = localStorage.getItem("authToken"); + if (!token) { + console.warn("토큰이 없습니다. 외부 DB 컬럼 목록을 조회할 수 없습니다."); + setColumns([]); + return; + } + + // 외부 DB 컬럼 조회 API + const response = await fetch( + `/api/multi-connection/connections/${flowDbConnectionId}/tables/${formData.tableName}/columns`, + { + credentials: "include", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }, + ).catch((err) => { + console.warn("외부 DB 컬럼 목록 fetch 실패:", err); + return null; + }); + + if (response && response.ok) { + const result = await response.json(); + console.log("✅ External columns API response:", result); + + if (result.success && result.data) { + // 컬럼 데이터 형식 통일 + const columnList = Array.isArray(result.data) + ? result.data.map((col: any) => ({ + column_name: col.column_name || col.columnName || col.name, + data_type: col.data_type || col.dataType || col.type, + })) + : []; + console.log("✅ Setting external columns:", columnList); + setColumns(columnList); + } else { + console.warn("❌ No data in external columns response"); + setColumns([]); + } + } else { + console.warn(`외부 DB 컬럼 목록 조회 실패: ${response?.status || "네트워크 오류"}`); + setColumns([]); + } } else { - console.log("❌ No columns in response"); - setColumns([]); + // 내부 DB인 경우 (기존 로직) + const response = await getTableColumns(formData.tableName); + console.log("📦 Internal columns response:", response); + + if (response.success && response.data && response.data.columns) { + console.log("✅ Setting internal columns:", response.data.columns); + setColumns(response.data.columns); + } else { + console.log("❌ No columns in response"); + setColumns([]); + } } } catch (error) { console.error("Failed to load columns:", error); @@ -266,7 +340,7 @@ export function FlowStepPanel({ step, flowId, onClose, onUpdate }: FlowStepPanel }; loadColumns(); - }, [formData.tableName]); + }, [formData.tableName, flowDbSourceType, flowDbConnectionId]); // formData의 최신 값을 항상 참조하기 위한 ref const formDataRef = useRef(formData); @@ -280,6 +354,27 @@ export function FlowStepPanel({ step, flowId, onClose, onUpdate }: FlowStepPanel const handleSave = useCallback(async () => { const currentFormData = formDataRef.current; console.log("🚀 handleSave called, formData:", JSON.stringify(currentFormData, null, 2)); + + // 상태 변경 방식일 때 필수 필드 검증 + if (currentFormData.moveType === "status") { + if (!currentFormData.statusColumn) { + toast({ + title: "입력 오류", + description: "상태 변경 방식을 사용하려면 '상태 컬럼명'을 반드시 지정해야 합니다.", + variant: "destructive", + }); + return; + } + if (!currentFormData.statusValue) { + toast({ + title: "입력 오류", + description: "상태 변경 방식을 사용하려면 '이 단계의 상태값'을 반드시 지정해야 합니다.", + variant: "destructive", + }); + return; + } + } + try { const response = await updateFlowStep(step.id, currentFormData); console.log("📡 API response:", response); @@ -368,8 +463,9 @@ export function FlowStepPanel({ step, flowId, onClose, onUpdate }: FlowStepPanel
+ {/* ===== 구버전: 단계별 테이블 선택 방식 (주석처리) ===== */} {/* DB 소스 선택 */} -
+ {/*

조회할 데이터베이스를 선택합니다

-
+
*/} {/* 테이블 선택 */} -
+ {/*
@@ -478,7 +574,16 @@ export function FlowStepPanel({ step, flowId, onClose, onUpdate }: FlowStepPanel ? "이 단계에서 조건을 적용할 테이블을 선택합니다" : "외부 데이터베이스의 테이블을 선택합니다"}

+
*/} + {/* ===== 구버전 끝 ===== */} + + {/* ===== 신버전: 플로우에서 선택한 테이블 표시만 ===== */} +
+ + +

플로우 생성 시 선택한 테이블입니다 (수정 불가)

+ {/* ===== 신버전 끝 ===== */} @@ -495,6 +600,8 @@ export function FlowStepPanel({ step, flowId, onClose, onUpdate }: FlowStepPanel setFormData({ ...formData, conditionJson: condition })} /> -- 2.43.0