diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 87470dd6..104a7fbe 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -72,6 +72,7 @@ import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙 import entitySearchRoutes from "./routes/entitySearchRoutes"; // 엔티티 검색 import orderRoutes from "./routes/orderRoutes"; // 수주 관리 import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달 +import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리 import { BatchSchedulerService } from "./services/batchSchedulerService"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석 @@ -238,6 +239,7 @@ app.use("/api/numbering-rules", numberingRuleRoutes); // 채번 규칙 관리 app.use("/api/entity-search", entitySearchRoutes); // 엔티티 검색 app.use("/api/orders", orderRoutes); // 수주 관리 app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달 +app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리 // app.use("/api/collections", collectionRoutes); // 임시 주석 // app.use("/api/batch", batchRoutes); // 임시 주석 // app.use('/api/users', userRoutes); diff --git a/backend-node/src/controllers/DashboardController.ts b/backend-node/src/controllers/DashboardController.ts index 76b666f0..e324c332 100644 --- a/backend-node/src/controllers/DashboardController.ts +++ b/backend-node/src/controllers/DashboardController.ts @@ -708,6 +708,12 @@ export class DashboardController { }); } + // 기상청 API 등 EUC-KR 인코딩을 사용하는 경우 arraybuffer로 받아서 디코딩 + const isKmaApi = urlObj.hostname.includes('kma.go.kr'); + if (isKmaApi) { + requestConfig.responseType = 'arraybuffer'; + } + const response = await axios(requestConfig); if (response.status >= 400) { @@ -719,8 +725,24 @@ export class DashboardController { let data = response.data; const contentType = response.headers["content-type"]; + // 기상청 API 인코딩 처리 (UTF-8 우선, 실패 시 EUC-KR) + if (isKmaApi && Buffer.isBuffer(data)) { + const iconv = require('iconv-lite'); + const buffer = Buffer.from(data); + const utf8Text = buffer.toString('utf-8'); + + // UTF-8로 정상 디코딩되었는지 확인 + if (utf8Text.includes('특보') || utf8Text.includes('경보') || utf8Text.includes('주의보') || + (utf8Text.includes('#START7777') && !utf8Text.includes('�'))) { + data = { text: utf8Text, contentType, encoding: 'utf-8' }; + } else { + // EUC-KR로 디코딩 + const eucKrText = iconv.decode(buffer, 'EUC-KR'); + data = { text: eucKrText, contentType, encoding: 'euc-kr' }; + } + } // 텍스트 응답인 경우 포맷팅 - if (typeof data === "string") { + else if (typeof data === "string") { data = { text: data, contentType }; } diff --git a/backend-node/src/controllers/flowController.ts b/backend-node/src/controllers/flowController.ts index 85ad2259..9459e1f6 100644 --- a/backend-node/src/controllers/flowController.ts +++ b/backend-node/src/controllers/flowController.ts @@ -32,8 +32,17 @@ export class FlowController { */ createFlowDefinition = async (req: Request, res: Response): Promise => { try { - const { name, description, tableName, dbSourceType, dbConnectionId } = - req.body; + const { + name, + description, + tableName, + dbSourceType, + dbConnectionId, + // REST API 관련 필드 + restApiConnectionId, + restApiEndpoint, + restApiJsonPath, + } = req.body; const userId = (req as any).user?.userId || "system"; const userCompanyCode = (req as any).user?.companyCode; @@ -43,6 +52,9 @@ export class FlowController { tableName, dbSourceType, dbConnectionId, + restApiConnectionId, + restApiEndpoint, + restApiJsonPath, userCompanyCode, }); @@ -54,8 +66,12 @@ export class FlowController { return; } - // 테이블 이름이 제공된 경우에만 존재 확인 - if (tableName) { + // REST API 또는 다중 연결인 경우 테이블 존재 확인 스킵 + const isRestApi = dbSourceType === "restapi" || dbSourceType === "multi_restapi"; + const isMultiConnection = dbSourceType === "multi_restapi" || dbSourceType === "multi_external_db"; + + // 테이블 이름이 제공된 경우에만 존재 확인 (REST API 및 다중 연결 제외) + if (tableName && !isRestApi && !isMultiConnection && !tableName.startsWith("_restapi_") && !tableName.startsWith("_multi_restapi_") && !tableName.startsWith("_multi_external_db_")) { const tableExists = await this.flowDefinitionService.checkTableExists(tableName); if (!tableExists) { @@ -68,7 +84,17 @@ export class FlowController { } const flowDef = await this.flowDefinitionService.create( - { name, description, tableName, dbSourceType, dbConnectionId }, + { + name, + description, + tableName, + dbSourceType, + dbConnectionId, + restApiConnectionId, + restApiEndpoint, + restApiJsonPath, + restApiConnections: req.body.restApiConnections, // 다중 REST API 설정 + }, userId, userCompanyCode ); diff --git a/backend-node/src/controllers/screenManagementController.ts b/backend-node/src/controllers/screenManagementController.ts index 0ff80988..c7ecf75e 100644 --- a/backend-node/src/controllers/screenManagementController.ts +++ b/backend-node/src/controllers/screenManagementController.ts @@ -148,11 +148,42 @@ export const updateScreenInfo = async ( try { const { id } = req.params; const { companyCode } = req.user as any; - const { screenName, tableName, description, isActive } = req.body; + const { + screenName, + tableName, + description, + isActive, + // REST API 관련 필드 추가 + dataSourceType, + dbSourceType, + dbConnectionId, + restApiConnectionId, + restApiEndpoint, + restApiJsonPath, + } = req.body; + + console.log("화면 정보 수정 요청:", { + screenId: id, + dataSourceType, + restApiConnectionId, + restApiEndpoint, + restApiJsonPath, + }); await screenManagementService.updateScreenInfo( parseInt(id), - { screenName, tableName, description, isActive }, + { + screenName, + tableName, + description, + isActive, + dataSourceType, + dbSourceType, + dbConnectionId, + restApiConnectionId, + restApiEndpoint, + restApiJsonPath, + }, companyCode ); res.json({ success: true, message: "화면 정보가 수정되었습니다." }); diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index f552124f..4a80b007 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -870,6 +870,17 @@ export async function addTableData( const tableManagementService = new TableManagementService(); + // 🆕 멀티테넌시: company_code 자동 추가 (테이블에 company_code 컬럼이 있는 경우) + const companyCode = req.user?.companyCode; + if (companyCode && !data.company_code) { + // 테이블에 company_code 컬럼이 있는지 확인 + const hasCompanyCodeColumn = await tableManagementService.hasColumn(tableName, "company_code"); + if (hasCompanyCodeColumn) { + data.company_code = companyCode; + logger.info(`🔒 멀티테넌시: company_code 자동 추가 - ${companyCode}`); + } + } + // 데이터 추가 await tableManagementService.addTableData(tableName, data); diff --git a/backend-node/src/controllers/vehicleReportController.ts b/backend-node/src/controllers/vehicleReportController.ts new file mode 100644 index 00000000..db17dd24 --- /dev/null +++ b/backend-node/src/controllers/vehicleReportController.ts @@ -0,0 +1,206 @@ +/** + * 차량 운행 리포트 컨트롤러 + */ +import { Response } from "express"; +import { AuthenticatedRequest } from "../middleware/authMiddleware"; +import { vehicleReportService } from "../services/vehicleReportService"; + +/** + * 일별 통계 조회 + * GET /api/vehicle/reports/daily + */ +export const getDailyReport = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode } = req.user as any; + const { startDate, endDate, userId, vehicleId } = req.query; + + console.log("📊 [getDailyReport] 요청:", { companyCode, startDate, endDate }); + + const result = await vehicleReportService.getDailyReport(companyCode, { + startDate: startDate as string, + endDate: endDate as string, + userId: userId as string, + vehicleId: vehicleId ? parseInt(vehicleId as string) : undefined, + }); + + res.json({ + success: true, + data: result, + }); + } catch (error: any) { + console.error("❌ [getDailyReport] 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "일별 통계 조회에 실패했습니다.", + }); + } +}; + +/** + * 주별 통계 조회 + * GET /api/vehicle/reports/weekly + */ +export const getWeeklyReport = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode } = req.user as any; + const { year, month, userId, vehicleId } = req.query; + + console.log("📊 [getWeeklyReport] 요청:", { companyCode, year, month }); + + const result = await vehicleReportService.getWeeklyReport(companyCode, { + year: year ? parseInt(year as string) : new Date().getFullYear(), + month: month ? parseInt(month as string) : new Date().getMonth() + 1, + userId: userId as string, + vehicleId: vehicleId ? parseInt(vehicleId as string) : undefined, + }); + + res.json({ + success: true, + data: result, + }); + } catch (error: any) { + console.error("❌ [getWeeklyReport] 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "주별 통계 조회에 실패했습니다.", + }); + } +}; + +/** + * 월별 통계 조회 + * GET /api/vehicle/reports/monthly + */ +export const getMonthlyReport = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode } = req.user as any; + const { year, userId, vehicleId } = req.query; + + console.log("📊 [getMonthlyReport] 요청:", { companyCode, year }); + + const result = await vehicleReportService.getMonthlyReport(companyCode, { + year: year ? parseInt(year as string) : new Date().getFullYear(), + userId: userId as string, + vehicleId: vehicleId ? parseInt(vehicleId as string) : undefined, + }); + + res.json({ + success: true, + data: result, + }); + } catch (error: any) { + console.error("❌ [getMonthlyReport] 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "월별 통계 조회에 실패했습니다.", + }); + } +}; + +/** + * 요약 통계 조회 (대시보드용) + * GET /api/vehicle/reports/summary + */ +export const getSummaryReport = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode } = req.user as any; + const { period } = req.query; // today, week, month, year + + console.log("📊 [getSummaryReport] 요청:", { companyCode, period }); + + const result = await vehicleReportService.getSummaryReport( + companyCode, + (period as string) || "today" + ); + + res.json({ + success: true, + data: result, + }); + } catch (error: any) { + console.error("❌ [getSummaryReport] 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "요약 통계 조회에 실패했습니다.", + }); + } +}; + +/** + * 운전자별 통계 조회 + * GET /api/vehicle/reports/by-driver + */ +export const getDriverReport = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode } = req.user as any; + const { startDate, endDate, limit } = req.query; + + console.log("📊 [getDriverReport] 요청:", { companyCode, startDate, endDate }); + + const result = await vehicleReportService.getDriverReport(companyCode, { + startDate: startDate as string, + endDate: endDate as string, + limit: limit ? parseInt(limit as string) : 10, + }); + + res.json({ + success: true, + data: result, + }); + } catch (error: any) { + console.error("❌ [getDriverReport] 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "운전자별 통계 조회에 실패했습니다.", + }); + } +}; + +/** + * 구간별 통계 조회 + * GET /api/vehicle/reports/by-route + */ +export const getRouteReport = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode } = req.user as any; + const { startDate, endDate, limit } = req.query; + + console.log("📊 [getRouteReport] 요청:", { companyCode, startDate, endDate }); + + const result = await vehicleReportService.getRouteReport(companyCode, { + startDate: startDate as string, + endDate: endDate as string, + limit: limit ? parseInt(limit as string) : 10, + }); + + res.json({ + success: true, + data: result, + }); + } catch (error: any) { + console.error("❌ [getRouteReport] 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "구간별 통계 조회에 실패했습니다.", + }); + } +}; + diff --git a/backend-node/src/controllers/vehicleTripController.ts b/backend-node/src/controllers/vehicleTripController.ts new file mode 100644 index 00000000..d1604ede --- /dev/null +++ b/backend-node/src/controllers/vehicleTripController.ts @@ -0,0 +1,301 @@ +/** + * 차량 운행 이력 컨트롤러 + */ +import { Response } from "express"; +import { AuthenticatedRequest } from "../middleware/authMiddleware"; +import { vehicleTripService } from "../services/vehicleTripService"; + +/** + * 운행 시작 + * POST /api/vehicle/trip/start + */ +export const startTrip = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode, userId } = req.user as any; + const { vehicleId, departure, arrival, departureName, destinationName, latitude, longitude } = req.body; + + console.log("🚗 [startTrip] 요청:", { userId, companyCode, departure, arrival }); + + if (latitude === undefined || longitude === undefined) { + return res.status(400).json({ + success: false, + message: "위치 정보(latitude, longitude)가 필요합니다.", + }); + } + + const result = await vehicleTripService.startTrip({ + userId, + companyCode, + vehicleId, + departure, + arrival, + departureName, + destinationName, + latitude, + longitude, + }); + + console.log("✅ [startTrip] 성공:", result); + + res.json({ + success: true, + data: result, + message: "운행이 시작되었습니다.", + }); + } catch (error: any) { + console.error("❌ [startTrip] 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "운행 시작에 실패했습니다.", + }); + } +}; + +/** + * 운행 종료 + * POST /api/vehicle/trip/end + */ +export const endTrip = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode, userId } = req.user as any; + const { tripId, latitude, longitude } = req.body; + + console.log("🚗 [endTrip] 요청:", { userId, companyCode, tripId }); + + if (!tripId) { + return res.status(400).json({ + success: false, + message: "tripId가 필요합니다.", + }); + } + + if (latitude === undefined || longitude === undefined) { + return res.status(400).json({ + success: false, + message: "위치 정보(latitude, longitude)가 필요합니다.", + }); + } + + const result = await vehicleTripService.endTrip({ + tripId, + userId, + companyCode, + latitude, + longitude, + }); + + console.log("✅ [endTrip] 성공:", result); + + res.json({ + success: true, + data: result, + message: "운행이 종료되었습니다.", + }); + } catch (error: any) { + console.error("❌ [endTrip] 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "운행 종료에 실패했습니다.", + }); + } +}; + +/** + * 위치 기록 추가 (연속 추적) + * POST /api/vehicle/trip/location + */ +export const addTripLocation = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode, userId } = req.user as any; + const { tripId, latitude, longitude, accuracy, speed } = req.body; + + if (!tripId) { + return res.status(400).json({ + success: false, + message: "tripId가 필요합니다.", + }); + } + + if (latitude === undefined || longitude === undefined) { + return res.status(400).json({ + success: false, + message: "위치 정보(latitude, longitude)가 필요합니다.", + }); + } + + const result = await vehicleTripService.addLocation({ + tripId, + userId, + companyCode, + latitude, + longitude, + accuracy, + speed, + }); + + res.json({ + success: true, + data: result, + }); + } catch (error: any) { + console.error("❌ [addTripLocation] 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "위치 기록에 실패했습니다.", + }); + } +}; + +/** + * 운행 이력 목록 조회 + * GET /api/vehicle/trips + */ +export const getTripList = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode } = req.user as any; + const { userId, vehicleId, status, startDate, endDate, departure, arrival, limit, offset } = req.query; + + console.log("🚗 [getTripList] 요청:", { companyCode, userId, status, startDate, endDate }); + + const result = await vehicleTripService.getTripList(companyCode, { + userId: userId as string, + vehicleId: vehicleId ? parseInt(vehicleId as string) : undefined, + status: status as string, + startDate: startDate as string, + endDate: endDate as string, + departure: departure as string, + arrival: arrival as string, + limit: limit ? parseInt(limit as string) : 50, + offset: offset ? parseInt(offset as string) : 0, + }); + + res.json({ + success: true, + data: result.data, + total: result.total, + }); + } catch (error: any) { + console.error("❌ [getTripList] 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "운행 이력 조회에 실패했습니다.", + }); + } +}; + +/** + * 운행 상세 조회 (경로 포함) + * GET /api/vehicle/trips/:tripId + */ +export const getTripDetail = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode } = req.user as any; + const { tripId } = req.params; + + console.log("🚗 [getTripDetail] 요청:", { companyCode, tripId }); + + const result = await vehicleTripService.getTripDetail(tripId, companyCode); + + if (!result) { + return res.status(404).json({ + success: false, + message: "운행 정보를 찾을 수 없습니다.", + }); + } + + res.json({ + success: true, + data: result, + }); + } catch (error: any) { + console.error("❌ [getTripDetail] 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "운행 상세 조회에 실패했습니다.", + }); + } +}; + +/** + * 활성 운행 조회 (현재 진행 중) + * GET /api/vehicle/trip/active + */ +export const getActiveTrip = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode, userId } = req.user as any; + + const result = await vehicleTripService.getActiveTrip(userId, companyCode); + + res.json({ + success: true, + data: result, + hasActiveTrip: !!result, + }); + } catch (error: any) { + console.error("❌ [getActiveTrip] 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "활성 운행 조회에 실패했습니다.", + }); + } +}; + +/** + * 운행 취소 + * POST /api/vehicle/trip/cancel + */ +export const cancelTrip = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode } = req.user as any; + const { tripId } = req.body; + + if (!tripId) { + return res.status(400).json({ + success: false, + message: "tripId가 필요합니다.", + }); + } + + const result = await vehicleTripService.cancelTrip(tripId, companyCode); + + if (!result) { + return res.status(404).json({ + success: false, + message: "취소할 운행을 찾을 수 없습니다.", + }); + } + + res.json({ + success: true, + message: "운행이 취소되었습니다.", + }); + } catch (error: any) { + console.error("❌ [cancelTrip] 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "운행 취소에 실패했습니다.", + }); + } +}; + diff --git a/backend-node/src/routes/vehicleTripRoutes.ts b/backend-node/src/routes/vehicleTripRoutes.ts new file mode 100644 index 00000000..c70a7394 --- /dev/null +++ b/backend-node/src/routes/vehicleTripRoutes.ts @@ -0,0 +1,71 @@ +/** + * 차량 운행 이력 및 리포트 라우트 + */ +import { Router } from "express"; +import { + startTrip, + endTrip, + addTripLocation, + getTripList, + getTripDetail, + getActiveTrip, + cancelTrip, +} from "../controllers/vehicleTripController"; +import { + getDailyReport, + getWeeklyReport, + getMonthlyReport, + getSummaryReport, + getDriverReport, + getRouteReport, +} from "../controllers/vehicleReportController"; +import { authenticateToken } from "../middleware/authMiddleware"; + +const router = Router(); + +// 모든 라우트에 인증 적용 +router.use(authenticateToken); + +// === 운행 관리 === +// 운행 시작 +router.post("/trip/start", startTrip); + +// 운행 종료 +router.post("/trip/end", endTrip); + +// 위치 기록 추가 (연속 추적) +router.post("/trip/location", addTripLocation); + +// 활성 운행 조회 (현재 진행 중) +router.get("/trip/active", getActiveTrip); + +// 운행 취소 +router.post("/trip/cancel", cancelTrip); + +// 운행 이력 목록 조회 +router.get("/trips", getTripList); + +// 운행 상세 조회 (경로 포함) +router.get("/trips/:tripId", getTripDetail); + +// === 리포트 === +// 요약 통계 (대시보드용) +router.get("/reports/summary", getSummaryReport); + +// 일별 통계 +router.get("/reports/daily", getDailyReport); + +// 주별 통계 +router.get("/reports/weekly", getWeeklyReport); + +// 월별 통계 +router.get("/reports/monthly", getMonthlyReport); + +// 운전자별 통계 +router.get("/reports/by-driver", getDriverReport); + +// 구간별 통계 +router.get("/reports/by-route", getRouteReport); + +export default router; + diff --git a/backend-node/src/services/externalRestApiConnectionService.ts b/backend-node/src/services/externalRestApiConnectionService.ts index af37eff1..89096fbb 100644 --- a/backend-node/src/services/externalRestApiConnectionService.ts +++ b/backend-node/src/services/externalRestApiConnectionService.ts @@ -1091,4 +1091,150 @@ export class ExternalRestApiConnectionService { throw new Error("올바르지 않은 인증 타입입니다."); } } + + /** + * 다중 REST API 데이터 조회 및 병합 + * 여러 REST API의 응답을 병합하여 하나의 데이터셋으로 반환 + */ + static async fetchMultipleData( + configs: Array<{ + connectionId: number; + endpoint: string; + jsonPath: string; + alias: string; + }>, + userCompanyCode?: string + ): Promise; + total: number; + sources: Array<{ connectionId: number; connectionName: string; rowCount: number }>; + }>> { + try { + logger.info(`다중 REST API 데이터 조회 시작: ${configs.length}개 API`); + + // 각 API에서 데이터 조회 + const results = await Promise.all( + configs.map(async (config) => { + try { + const result = await this.fetchData( + config.connectionId, + config.endpoint, + config.jsonPath, + userCompanyCode + ); + + if (result.success && result.data) { + return { + success: true, + connectionId: config.connectionId, + connectionName: result.data.connectionInfo.connectionName, + alias: config.alias, + rows: result.data.rows, + columns: result.data.columns, + }; + } else { + logger.warn(`API ${config.connectionId} 조회 실패:`, result.message); + return { + success: false, + connectionId: config.connectionId, + connectionName: "", + alias: config.alias, + rows: [], + columns: [], + error: result.message, + }; + } + } catch (error) { + logger.error(`API ${config.connectionId} 조회 오류:`, error); + return { + success: false, + connectionId: config.connectionId, + connectionName: "", + alias: config.alias, + rows: [], + columns: [], + error: error instanceof Error ? error.message : "알 수 없는 오류", + }; + } + }) + ); + + // 성공한 결과만 필터링 + const successfulResults = results.filter(r => r.success); + + if (successfulResults.length === 0) { + return { + success: false, + message: "모든 REST API 조회에 실패했습니다.", + error: { + code: "ALL_APIS_FAILED", + details: results.map(r => ({ connectionId: r.connectionId, error: r.error })), + }, + }; + } + + // 컬럼 병합 (별칭 적용) + const mergedColumns: Array<{ columnName: string; columnLabel: string; dataType: string; sourceApi: string }> = []; + + for (const result of successfulResults) { + for (const col of result.columns) { + const prefixedColumnName = result.alias ? `${result.alias}${col.columnName}` : col.columnName; + mergedColumns.push({ + columnName: prefixedColumnName, + columnLabel: `${col.columnLabel} (${result.connectionName})`, + dataType: col.dataType, + sourceApi: result.connectionName, + }); + } + } + + // 데이터 병합 (가로 병합: 각 API의 첫 번째 행끼리 병합) + // 참고: 실제 사용 시에는 조인 키가 필요할 수 있음 + const maxRows = Math.max(...successfulResults.map(r => r.rows.length)); + const mergedRows: any[] = []; + + for (let i = 0; i < maxRows; i++) { + const mergedRow: any = {}; + + for (const result of successfulResults) { + const row = result.rows[i] || {}; + + for (const [key, value] of Object.entries(row)) { + const prefixedKey = result.alias ? `${result.alias}${key}` : key; + mergedRow[prefixedKey] = value; + } + } + + mergedRows.push(mergedRow); + } + + logger.info(`다중 REST API 데이터 병합 완료: ${mergedRows.length}개 행, ${mergedColumns.length}개 컬럼`); + + return { + success: true, + data: { + rows: mergedRows, + columns: mergedColumns, + total: mergedRows.length, + sources: successfulResults.map(r => ({ + connectionId: r.connectionId, + connectionName: r.connectionName, + rowCount: r.rows.length, + })), + }, + message: `${successfulResults.length}개 API에서 총 ${mergedRows.length}개 데이터를 조회했습니다.`, + }; + } catch (error) { + logger.error("다중 REST API 데이터 조회 오류:", error); + return { + success: false, + message: "다중 REST API 데이터 조회에 실패했습니다.", + error: { + code: "MULTI_FETCH_ERROR", + details: error instanceof Error ? error.message : "알 수 없는 오류", + }, + }; + } + } } diff --git a/backend-node/src/services/flowDefinitionService.ts b/backend-node/src/services/flowDefinitionService.ts index 759178c1..80c920ad 100644 --- a/backend-node/src/services/flowDefinitionService.ts +++ b/backend-node/src/services/flowDefinitionService.ts @@ -27,13 +27,21 @@ export class FlowDefinitionService { tableName: request.tableName, dbSourceType: request.dbSourceType, dbConnectionId: request.dbConnectionId, + restApiConnectionId: request.restApiConnectionId, + restApiEndpoint: request.restApiEndpoint, + restApiJsonPath: request.restApiJsonPath, + restApiConnections: request.restApiConnections, companyCode, userId, }); const query = ` - INSERT INTO flow_definition (name, description, table_name, db_source_type, db_connection_id, company_code, created_by) - VALUES ($1, $2, $3, $4, $5, $6, $7) + INSERT INTO flow_definition ( + name, description, table_name, db_source_type, db_connection_id, + rest_api_connection_id, rest_api_endpoint, rest_api_json_path, + rest_api_connections, company_code, created_by + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING * `; @@ -43,6 +51,10 @@ export class FlowDefinitionService { request.tableName || null, request.dbSourceType || "internal", request.dbConnectionId || null, + request.restApiConnectionId || null, + request.restApiEndpoint || null, + request.restApiJsonPath || "response", + request.restApiConnections ? JSON.stringify(request.restApiConnections) : null, companyCode, userId, ]; @@ -199,6 +211,19 @@ export class FlowDefinitionService { * DB 행을 FlowDefinition 객체로 변환 */ private mapToFlowDefinition(row: any): FlowDefinition { + // rest_api_connections 파싱 (JSONB → 배열) + let restApiConnections = undefined; + if (row.rest_api_connections) { + try { + restApiConnections = typeof row.rest_api_connections === 'string' + ? JSON.parse(row.rest_api_connections) + : row.rest_api_connections; + } catch (e) { + console.warn("Failed to parse rest_api_connections:", e); + restApiConnections = []; + } + } + return { id: row.id, name: row.name, @@ -206,6 +231,12 @@ export class FlowDefinitionService { tableName: row.table_name, dbSourceType: row.db_source_type || "internal", dbConnectionId: row.db_connection_id, + // REST API 관련 필드 (단일) + restApiConnectionId: row.rest_api_connection_id, + restApiEndpoint: row.rest_api_endpoint, + restApiJsonPath: row.rest_api_json_path, + // 다중 REST API 관련 필드 + restApiConnections: restApiConnections, companyCode: row.company_code || "*", isActive: row.is_active, createdBy: row.created_by, diff --git a/backend-node/src/services/riskAlertService.ts b/backend-node/src/services/riskAlertService.ts index f3561bbe..03a3fdf1 100644 --- a/backend-node/src/services/riskAlertService.ts +++ b/backend-node/src/services/riskAlertService.ts @@ -47,9 +47,24 @@ export class RiskAlertService { console.log('✅ 기상청 특보 현황 API 응답 수신 완료'); - // 텍스트 응답 파싱 (EUC-KR 인코딩) + // 텍스트 응답 파싱 (인코딩 자동 감지) const iconv = require('iconv-lite'); - const responseText = iconv.decode(Buffer.from(warningResponse.data), 'EUC-KR'); + const buffer = Buffer.from(warningResponse.data); + + // UTF-8 먼저 시도, 실패하면 EUC-KR 시도 + let responseText: string; + const utf8Text = buffer.toString('utf-8'); + + // UTF-8로 정상 디코딩되었는지 확인 (한글이 깨지지 않았는지) + if (utf8Text.includes('특보') || utf8Text.includes('경보') || utf8Text.includes('주의보') || + (utf8Text.includes('#START7777') && !utf8Text.includes('�'))) { + responseText = utf8Text; + console.log('📝 UTF-8 인코딩으로 디코딩'); + } else { + // EUC-KR로 디코딩 + responseText = iconv.decode(buffer, 'EUC-KR'); + console.log('📝 EUC-KR 인코딩으로 디코딩'); + } if (typeof responseText === 'string' && responseText.includes('#START7777')) { const lines = responseText.split('\n'); diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 71550fd6..007a39e7 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -326,7 +326,19 @@ export class ScreenManagementService { */ async updateScreenInfo( screenId: number, - updateData: { screenName: string; tableName?: string; description?: string; isActive: string }, + updateData: { + screenName: string; + tableName?: string; + description?: string; + isActive: string; + // REST API 관련 필드 추가 + dataSourceType?: string; + dbSourceType?: string; + dbConnectionId?: number; + restApiConnectionId?: number; + restApiEndpoint?: string; + restApiJsonPath?: string; + }, userCompanyCode: string ): Promise { // 권한 확인 @@ -348,24 +360,43 @@ export class ScreenManagementService { throw new Error("이 화면을 수정할 권한이 없습니다."); } - // 화면 정보 업데이트 (tableName 포함) + // 화면 정보 업데이트 (REST API 필드 포함) await query( `UPDATE screen_definitions SET screen_name = $1, table_name = $2, description = $3, is_active = $4, - updated_date = $5 - WHERE screen_id = $6`, + updated_date = $5, + data_source_type = $6, + db_source_type = $7, + db_connection_id = $8, + rest_api_connection_id = $9, + rest_api_endpoint = $10, + rest_api_json_path = $11 + WHERE screen_id = $12`, [ updateData.screenName, updateData.tableName || null, updateData.description || null, updateData.isActive, new Date(), + updateData.dataSourceType || "database", + updateData.dbSourceType || "internal", + updateData.dbConnectionId || null, + updateData.restApiConnectionId || null, + updateData.restApiEndpoint || null, + updateData.restApiJsonPath || null, screenId, ] ); + + console.log(`화면 정보 업데이트 완료: screenId=${screenId}`, { + dataSourceType: updateData.dataSourceType, + restApiConnectionId: updateData.restApiConnectionId, + restApiEndpoint: updateData.restApiEndpoint, + restApiJsonPath: updateData.restApiJsonPath, + }); } /** @@ -2016,37 +2047,40 @@ export class ScreenManagementService { // Advisory lock 획득 (다른 트랜잭션이 같은 회사 코드를 생성하는 동안 대기) await client.query('SELECT pg_advisory_xact_lock($1)', [lockId]); - // 해당 회사의 기존 화면 코드들 조회 + // 해당 회사의 기존 화면 코드들 조회 (모든 화면 - 삭제된 코드도 재사용 방지) + // LIMIT 제거하고 숫자 추출하여 최대값 찾기 const existingScreens = await client.query<{ screen_code: string }>( `SELECT screen_code FROM screen_definitions - WHERE company_code = $1 AND screen_code LIKE $2 - ORDER BY screen_code DESC - LIMIT 10`, - [companyCode, `${companyCode}%`] + WHERE screen_code LIKE $1 + ORDER BY screen_code DESC`, + [`${companyCode}_%`] ); // 회사 코드 뒤의 숫자 부분 추출하여 최대값 찾기 let maxNumber = 0; const pattern = new RegExp( - `^${companyCode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:_)?(\\d+)$` + `^${companyCode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}_(\\d+)$` ); + console.log(`🔍 화면 코드 생성 - 조회된 화면 수: ${existingScreens.rows.length}`); + console.log(`🔍 패턴: ${pattern}`); + for (const screen of existingScreens.rows) { const match = screen.screen_code.match(pattern); if (match) { const number = parseInt(match[1], 10); + console.log(`🔍 매칭: ${screen.screen_code} → 숫자: ${number}`); if (number > maxNumber) { maxNumber = number; } } } - // 다음 순번으로 화면 코드 생성 (3자리 패딩) + // 다음 순번으로 화면 코드 생성 const nextNumber = maxNumber + 1; - const paddedNumber = nextNumber.toString().padStart(3, "0"); - - const newCode = `${companyCode}_${paddedNumber}`; - console.log(`🔢 화면 코드 생성: ${companyCode} → ${newCode} (maxNumber: ${maxNumber})`); + // 숫자가 3자리 이상이면 패딩 없이, 아니면 3자리 패딩 + const newCode = `${companyCode}_${nextNumber}`; + console.log(`🔢 화면 코드 생성: ${companyCode} → ${newCode} (maxNumber: ${maxNumber}, nextNumber: ${nextNumber})`); return newCode; // Advisory lock은 트랜잭션 종료 시 자동으로 해제됨 diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 28da136e..64eb44c8 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -1516,6 +1516,26 @@ export class TableManagementService { columnName ); + // 🆕 배열 처리: IN 절 사용 + if (Array.isArray(value)) { + if (value.length === 0) { + // 빈 배열이면 항상 false 조건 + return { + whereClause: `1 = 0`, + values: [], + paramCount: 0, + }; + } + + // IN 절로 여러 값 검색 + const placeholders = value.map((_, idx) => `$${paramIndex + idx}`).join(", "); + return { + whereClause: `${columnName} IN (${placeholders})`, + values: value, + paramCount: value.length, + }; + } + if (!entityTypeInfo.isEntityType || !entityTypeInfo.referenceTable) { // 엔티티 타입이 아니면 기본 검색 return { @@ -4070,4 +4090,22 @@ export class TableManagementService { throw error; } } + + /** + * 테이블에 특정 컬럼이 존재하는지 확인 + */ + async hasColumn(tableName: string, columnName: string): Promise { + try { + const result = await query( + `SELECT column_name + FROM information_schema.columns + WHERE table_name = $1 AND column_name = $2`, + [tableName, columnName] + ); + return result.length > 0; + } catch (error) { + logger.error(`컬럼 존재 여부 확인 실패: ${tableName}.${columnName}`, error); + return false; + } + } } diff --git a/backend-node/src/services/vehicleReportService.ts b/backend-node/src/services/vehicleReportService.ts new file mode 100644 index 00000000..842dff19 --- /dev/null +++ b/backend-node/src/services/vehicleReportService.ts @@ -0,0 +1,403 @@ +/** + * 차량 운행 리포트 서비스 + */ +import { getPool } from "../database/db"; + +interface DailyReportFilters { + startDate?: string; + endDate?: string; + userId?: string; + vehicleId?: number; +} + +interface WeeklyReportFilters { + year: number; + month: number; + userId?: string; + vehicleId?: number; +} + +interface MonthlyReportFilters { + year: number; + userId?: string; + vehicleId?: number; +} + +interface DriverReportFilters { + startDate?: string; + endDate?: string; + limit?: number; +} + +interface RouteReportFilters { + startDate?: string; + endDate?: string; + limit?: number; +} + +class VehicleReportService { + private get pool() { + return getPool(); + } + + /** + * 일별 통계 조회 + */ + async getDailyReport(companyCode: string, filters: DailyReportFilters) { + const conditions: string[] = ["company_code = $1"]; + const params: any[] = [companyCode]; + let paramIndex = 2; + + // 기본값: 최근 30일 + const endDate = filters.endDate || new Date().toISOString().split("T")[0]; + const startDate = + filters.startDate || + new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split("T")[0]; + + conditions.push(`DATE(start_time) >= $${paramIndex++}`); + params.push(startDate); + conditions.push(`DATE(start_time) <= $${paramIndex++}`); + params.push(endDate); + + if (filters.userId) { + conditions.push(`user_id = $${paramIndex++}`); + params.push(filters.userId); + } + + if (filters.vehicleId) { + conditions.push(`vehicle_id = $${paramIndex++}`); + params.push(filters.vehicleId); + } + + const whereClause = conditions.join(" AND "); + + const query = ` + SELECT + DATE(start_time) as date, + COUNT(*) as trip_count, + COUNT(CASE WHEN status = 'completed' THEN 1 END) as completed_count, + COUNT(CASE WHEN status = 'cancelled' THEN 1 END) as cancelled_count, + COALESCE(SUM(CASE WHEN status = 'completed' THEN total_distance ELSE 0 END), 0) as total_distance, + COALESCE(SUM(CASE WHEN status = 'completed' THEN duration_minutes ELSE 0 END), 0) as total_duration, + COALESCE(AVG(CASE WHEN status = 'completed' THEN total_distance END), 0) as avg_distance, + COALESCE(AVG(CASE WHEN status = 'completed' THEN duration_minutes END), 0) as avg_duration + FROM vehicle_trip_summary + WHERE ${whereClause} + GROUP BY DATE(start_time) + ORDER BY DATE(start_time) DESC + `; + + const result = await this.pool.query(query, params); + + return { + startDate, + endDate, + data: result.rows.map((row) => ({ + date: row.date, + tripCount: parseInt(row.trip_count), + completedCount: parseInt(row.completed_count), + cancelledCount: parseInt(row.cancelled_count), + totalDistance: parseFloat(row.total_distance), + totalDuration: parseInt(row.total_duration), + avgDistance: parseFloat(row.avg_distance), + avgDuration: parseFloat(row.avg_duration), + })), + }; + } + + /** + * 주별 통계 조회 + */ + async getWeeklyReport(companyCode: string, filters: WeeklyReportFilters) { + const { year, month, userId, vehicleId } = filters; + + const conditions: string[] = ["company_code = $1"]; + const params: any[] = [companyCode]; + let paramIndex = 2; + + conditions.push(`EXTRACT(YEAR FROM start_time) = $${paramIndex++}`); + params.push(year); + conditions.push(`EXTRACT(MONTH FROM start_time) = $${paramIndex++}`); + params.push(month); + + if (userId) { + conditions.push(`user_id = $${paramIndex++}`); + params.push(userId); + } + + if (vehicleId) { + conditions.push(`vehicle_id = $${paramIndex++}`); + params.push(vehicleId); + } + + const whereClause = conditions.join(" AND "); + + const query = ` + SELECT + EXTRACT(WEEK FROM start_time) as week_number, + MIN(DATE(start_time)) as week_start, + MAX(DATE(start_time)) as week_end, + COUNT(*) as trip_count, + COUNT(CASE WHEN status = 'completed' THEN 1 END) as completed_count, + COALESCE(SUM(CASE WHEN status = 'completed' THEN total_distance ELSE 0 END), 0) as total_distance, + COALESCE(SUM(CASE WHEN status = 'completed' THEN duration_minutes ELSE 0 END), 0) as total_duration, + COALESCE(AVG(CASE WHEN status = 'completed' THEN total_distance END), 0) as avg_distance + FROM vehicle_trip_summary + WHERE ${whereClause} + GROUP BY EXTRACT(WEEK FROM start_time) + ORDER BY week_number + `; + + const result = await this.pool.query(query, params); + + return { + year, + month, + data: result.rows.map((row) => ({ + weekNumber: parseInt(row.week_number), + weekStart: row.week_start, + weekEnd: row.week_end, + tripCount: parseInt(row.trip_count), + completedCount: parseInt(row.completed_count), + totalDistance: parseFloat(row.total_distance), + totalDuration: parseInt(row.total_duration), + avgDistance: parseFloat(row.avg_distance), + })), + }; + } + + /** + * 월별 통계 조회 + */ + async getMonthlyReport(companyCode: string, filters: MonthlyReportFilters) { + const { year, userId, vehicleId } = filters; + + const conditions: string[] = ["company_code = $1"]; + const params: any[] = [companyCode]; + let paramIndex = 2; + + conditions.push(`EXTRACT(YEAR FROM start_time) = $${paramIndex++}`); + params.push(year); + + if (userId) { + conditions.push(`user_id = $${paramIndex++}`); + params.push(userId); + } + + if (vehicleId) { + conditions.push(`vehicle_id = $${paramIndex++}`); + params.push(vehicleId); + } + + const whereClause = conditions.join(" AND "); + + const query = ` + SELECT + EXTRACT(MONTH FROM start_time) as month, + COUNT(*) as trip_count, + COUNT(CASE WHEN status = 'completed' THEN 1 END) as completed_count, + COUNT(CASE WHEN status = 'cancelled' THEN 1 END) as cancelled_count, + COALESCE(SUM(CASE WHEN status = 'completed' THEN total_distance ELSE 0 END), 0) as total_distance, + COALESCE(SUM(CASE WHEN status = 'completed' THEN duration_minutes ELSE 0 END), 0) as total_duration, + COALESCE(AVG(CASE WHEN status = 'completed' THEN total_distance END), 0) as avg_distance, + COALESCE(AVG(CASE WHEN status = 'completed' THEN duration_minutes END), 0) as avg_duration, + COUNT(DISTINCT user_id) as driver_count + FROM vehicle_trip_summary + WHERE ${whereClause} + GROUP BY EXTRACT(MONTH FROM start_time) + ORDER BY month + `; + + const result = await this.pool.query(query, params); + + return { + year, + data: result.rows.map((row) => ({ + month: parseInt(row.month), + tripCount: parseInt(row.trip_count), + completedCount: parseInt(row.completed_count), + cancelledCount: parseInt(row.cancelled_count), + totalDistance: parseFloat(row.total_distance), + totalDuration: parseInt(row.total_duration), + avgDistance: parseFloat(row.avg_distance), + avgDuration: parseFloat(row.avg_duration), + driverCount: parseInt(row.driver_count), + })), + }; + } + + /** + * 요약 통계 조회 (대시보드용) + */ + async getSummaryReport(companyCode: string, period: string) { + let dateCondition = ""; + + switch (period) { + case "today": + dateCondition = "DATE(start_time) = CURRENT_DATE"; + break; + case "week": + dateCondition = "start_time >= CURRENT_DATE - INTERVAL '7 days'"; + break; + case "month": + dateCondition = "start_time >= CURRENT_DATE - INTERVAL '30 days'"; + break; + case "year": + dateCondition = "EXTRACT(YEAR FROM start_time) = EXTRACT(YEAR FROM CURRENT_DATE)"; + break; + default: + dateCondition = "DATE(start_time) = CURRENT_DATE"; + } + + const query = ` + SELECT + COUNT(*) as total_trips, + COUNT(CASE WHEN status = 'completed' THEN 1 END) as completed_trips, + COUNT(CASE WHEN status = 'active' THEN 1 END) as active_trips, + COUNT(CASE WHEN status = 'cancelled' THEN 1 END) as cancelled_trips, + COALESCE(SUM(CASE WHEN status = 'completed' THEN total_distance ELSE 0 END), 0) as total_distance, + COALESCE(SUM(CASE WHEN status = 'completed' THEN duration_minutes ELSE 0 END), 0) as total_duration, + COALESCE(AVG(CASE WHEN status = 'completed' THEN total_distance END), 0) as avg_distance, + COALESCE(AVG(CASE WHEN status = 'completed' THEN duration_minutes END), 0) as avg_duration, + COUNT(DISTINCT user_id) as active_drivers + FROM vehicle_trip_summary + WHERE company_code = $1 AND ${dateCondition} + `; + + const result = await this.pool.query(query, [companyCode]); + const row = result.rows[0]; + + // 완료율 계산 + const totalTrips = parseInt(row.total_trips) || 0; + const completedTrips = parseInt(row.completed_trips) || 0; + const completionRate = totalTrips > 0 ? (completedTrips / totalTrips) * 100 : 0; + + return { + period, + totalTrips, + completedTrips, + activeTrips: parseInt(row.active_trips) || 0, + cancelledTrips: parseInt(row.cancelled_trips) || 0, + completionRate: parseFloat(completionRate.toFixed(1)), + totalDistance: parseFloat(row.total_distance) || 0, + totalDuration: parseInt(row.total_duration) || 0, + avgDistance: parseFloat(row.avg_distance) || 0, + avgDuration: parseFloat(row.avg_duration) || 0, + activeDrivers: parseInt(row.active_drivers) || 0, + }; + } + + /** + * 운전자별 통계 조회 + */ + async getDriverReport(companyCode: string, filters: DriverReportFilters) { + const conditions: string[] = ["vts.company_code = $1"]; + const params: any[] = [companyCode]; + let paramIndex = 2; + + if (filters.startDate) { + conditions.push(`DATE(vts.start_time) >= $${paramIndex++}`); + params.push(filters.startDate); + } + + if (filters.endDate) { + conditions.push(`DATE(vts.start_time) <= $${paramIndex++}`); + params.push(filters.endDate); + } + + const whereClause = conditions.join(" AND "); + const limit = filters.limit || 10; + + const query = ` + SELECT + vts.user_id, + ui.user_name, + COUNT(*) as trip_count, + COUNT(CASE WHEN vts.status = 'completed' THEN 1 END) as completed_count, + COALESCE(SUM(CASE WHEN vts.status = 'completed' THEN vts.total_distance ELSE 0 END), 0) as total_distance, + COALESCE(SUM(CASE WHEN vts.status = 'completed' THEN vts.duration_minutes ELSE 0 END), 0) as total_duration, + COALESCE(AVG(CASE WHEN vts.status = 'completed' THEN vts.total_distance END), 0) as avg_distance + FROM vehicle_trip_summary vts + LEFT JOIN user_info ui ON vts.user_id = ui.user_id + WHERE ${whereClause} + GROUP BY vts.user_id, ui.user_name + ORDER BY total_distance DESC + LIMIT $${paramIndex} + `; + + params.push(limit); + const result = await this.pool.query(query, params); + + return result.rows.map((row) => ({ + userId: row.user_id, + userName: row.user_name || row.user_id, + tripCount: parseInt(row.trip_count), + completedCount: parseInt(row.completed_count), + totalDistance: parseFloat(row.total_distance), + totalDuration: parseInt(row.total_duration), + avgDistance: parseFloat(row.avg_distance), + })); + } + + /** + * 구간별 통계 조회 + */ + async getRouteReport(companyCode: string, filters: RouteReportFilters) { + const conditions: string[] = ["company_code = $1"]; + const params: any[] = [companyCode]; + let paramIndex = 2; + + if (filters.startDate) { + conditions.push(`DATE(start_time) >= $${paramIndex++}`); + params.push(filters.startDate); + } + + if (filters.endDate) { + conditions.push(`DATE(start_time) <= $${paramIndex++}`); + params.push(filters.endDate); + } + + // 출발지/도착지가 있는 것만 + conditions.push("departure IS NOT NULL"); + conditions.push("arrival IS NOT NULL"); + + const whereClause = conditions.join(" AND "); + const limit = filters.limit || 10; + + const query = ` + SELECT + departure, + arrival, + departure_name, + destination_name, + COUNT(*) as trip_count, + COUNT(CASE WHEN status = 'completed' THEN 1 END) as completed_count, + COALESCE(SUM(CASE WHEN status = 'completed' THEN total_distance ELSE 0 END), 0) as total_distance, + COALESCE(AVG(CASE WHEN status = 'completed' THEN total_distance END), 0) as avg_distance, + COALESCE(AVG(CASE WHEN status = 'completed' THEN duration_minutes END), 0) as avg_duration + FROM vehicle_trip_summary + WHERE ${whereClause} + GROUP BY departure, arrival, departure_name, destination_name + ORDER BY trip_count DESC + LIMIT $${paramIndex} + `; + + params.push(limit); + const result = await this.pool.query(query, params); + + return result.rows.map((row) => ({ + departure: row.departure, + arrival: row.arrival, + departureName: row.departure_name || row.departure, + destinationName: row.destination_name || row.arrival, + tripCount: parseInt(row.trip_count), + completedCount: parseInt(row.completed_count), + totalDistance: parseFloat(row.total_distance), + avgDistance: parseFloat(row.avg_distance), + avgDuration: parseFloat(row.avg_duration), + })); + } +} + +export const vehicleReportService = new VehicleReportService(); + diff --git a/backend-node/src/services/vehicleTripService.ts b/backend-node/src/services/vehicleTripService.ts new file mode 100644 index 00000000..ee640e24 --- /dev/null +++ b/backend-node/src/services/vehicleTripService.ts @@ -0,0 +1,456 @@ +/** + * 차량 운행 이력 서비스 + */ +import { getPool } from "../database/db"; +import { v4 as uuidv4 } from "uuid"; +import { calculateDistance } from "../utils/geoUtils"; + +interface StartTripParams { + userId: string; + companyCode: string; + vehicleId?: number; + departure?: string; + arrival?: string; + departureName?: string; + destinationName?: string; + latitude: number; + longitude: number; +} + +interface EndTripParams { + tripId: string; + userId: string; + companyCode: string; + latitude: number; + longitude: number; +} + +interface AddLocationParams { + tripId: string; + userId: string; + companyCode: string; + latitude: number; + longitude: number; + accuracy?: number; + speed?: number; +} + +interface TripListFilters { + userId?: string; + vehicleId?: number; + status?: string; + startDate?: string; + endDate?: string; + departure?: string; + arrival?: string; + limit?: number; + offset?: number; +} + +class VehicleTripService { + private get pool() { + return getPool(); + } + + /** + * 운행 시작 + */ + async startTrip(params: StartTripParams) { + const { + userId, + companyCode, + vehicleId, + departure, + arrival, + departureName, + destinationName, + latitude, + longitude, + } = params; + + const tripId = `TRIP-${Date.now()}-${uuidv4().substring(0, 8)}`; + + // 1. vehicle_trip_summary에 운행 기록 생성 + const summaryQuery = ` + INSERT INTO vehicle_trip_summary ( + trip_id, user_id, vehicle_id, departure, arrival, + departure_name, destination_name, start_time, status, company_code + ) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), 'active', $8) + RETURNING * + `; + + const summaryResult = await this.pool.query(summaryQuery, [ + tripId, + userId, + vehicleId || null, + departure || null, + arrival || null, + departureName || null, + destinationName || null, + companyCode, + ]); + + // 2. 시작 위치 기록 + const locationQuery = ` + INSERT INTO vehicle_location_history ( + trip_id, user_id, vehicle_id, latitude, longitude, + trip_status, departure, arrival, departure_name, destination_name, + recorded_at, company_code + ) VALUES ($1, $2, $3, $4, $5, 'start', $6, $7, $8, $9, NOW(), $10) + RETURNING id + `; + + await this.pool.query(locationQuery, [ + tripId, + userId, + vehicleId || null, + latitude, + longitude, + departure || null, + arrival || null, + departureName || null, + destinationName || null, + companyCode, + ]); + + return { + tripId, + summary: summaryResult.rows[0], + startLocation: { latitude, longitude }, + }; + } + + /** + * 운행 종료 + */ + async endTrip(params: EndTripParams) { + const { tripId, userId, companyCode, latitude, longitude } = params; + + // 1. 운행 정보 조회 + const tripQuery = ` + SELECT * FROM vehicle_trip_summary + WHERE trip_id = $1 AND company_code = $2 AND status = 'active' + `; + const tripResult = await this.pool.query(tripQuery, [tripId, companyCode]); + + if (tripResult.rows.length === 0) { + throw new Error("활성 운행을 찾을 수 없습니다."); + } + + const trip = tripResult.rows[0]; + + // 2. 마지막 위치 기록 + const locationQuery = ` + INSERT INTO vehicle_location_history ( + trip_id, user_id, vehicle_id, latitude, longitude, + trip_status, departure, arrival, departure_name, destination_name, + recorded_at, company_code + ) VALUES ($1, $2, $3, $4, $5, 'end', $6, $7, $8, $9, NOW(), $10) + RETURNING id + `; + + await this.pool.query(locationQuery, [ + tripId, + userId, + trip.vehicle_id, + latitude, + longitude, + trip.departure, + trip.arrival, + trip.departure_name, + trip.destination_name, + companyCode, + ]); + + // 3. 총 거리 및 위치 수 계산 + const statsQuery = ` + SELECT + COUNT(*) as location_count, + MIN(recorded_at) as start_time, + MAX(recorded_at) as end_time + FROM vehicle_location_history + WHERE trip_id = $1 AND company_code = $2 + `; + const statsResult = await this.pool.query(statsQuery, [tripId, companyCode]); + const stats = statsResult.rows[0]; + + // 4. 모든 위치 데이터로 총 거리 계산 + const locationsQuery = ` + SELECT latitude, longitude + FROM vehicle_location_history + WHERE trip_id = $1 AND company_code = $2 + ORDER BY recorded_at ASC + `; + const locationsResult = await this.pool.query(locationsQuery, [tripId, companyCode]); + + let totalDistance = 0; + const locations = locationsResult.rows; + for (let i = 1; i < locations.length; i++) { + const prev = locations[i - 1]; + const curr = locations[i]; + totalDistance += calculateDistance( + prev.latitude, + prev.longitude, + curr.latitude, + curr.longitude + ); + } + + // 5. 운행 시간 계산 (분) + const startTime = new Date(stats.start_time); + const endTime = new Date(stats.end_time); + const durationMinutes = Math.round((endTime.getTime() - startTime.getTime()) / 60000); + + // 6. 운행 요약 업데이트 + const updateQuery = ` + UPDATE vehicle_trip_summary + SET + end_time = NOW(), + total_distance = $1, + duration_minutes = $2, + location_count = $3, + status = 'completed' + WHERE trip_id = $4 AND company_code = $5 + RETURNING * + `; + + const updateResult = await this.pool.query(updateQuery, [ + totalDistance.toFixed(3), + durationMinutes, + stats.location_count, + tripId, + companyCode, + ]); + + return { + tripId, + summary: updateResult.rows[0], + totalDistance: parseFloat(totalDistance.toFixed(3)), + durationMinutes, + locationCount: parseInt(stats.location_count), + }; + } + + /** + * 위치 기록 추가 (연속 추적) + */ + async addLocation(params: AddLocationParams) { + const { tripId, userId, companyCode, latitude, longitude, accuracy, speed } = params; + + // 1. 운행 정보 조회 + const tripQuery = ` + SELECT * FROM vehicle_trip_summary + WHERE trip_id = $1 AND company_code = $2 AND status = 'active' + `; + const tripResult = await this.pool.query(tripQuery, [tripId, companyCode]); + + if (tripResult.rows.length === 0) { + throw new Error("활성 운행을 찾을 수 없습니다."); + } + + const trip = tripResult.rows[0]; + + // 2. 이전 위치 조회 (거리 계산용) + const prevLocationQuery = ` + SELECT latitude, longitude + FROM vehicle_location_history + WHERE trip_id = $1 AND company_code = $2 + ORDER BY recorded_at DESC + LIMIT 1 + `; + const prevResult = await this.pool.query(prevLocationQuery, [tripId, companyCode]); + + let distanceFromPrev = 0; + if (prevResult.rows.length > 0) { + const prev = prevResult.rows[0]; + distanceFromPrev = calculateDistance( + prev.latitude, + prev.longitude, + latitude, + longitude + ); + } + + // 3. 위치 기록 추가 + const locationQuery = ` + INSERT INTO vehicle_location_history ( + trip_id, user_id, vehicle_id, latitude, longitude, + accuracy, speed, distance_from_prev, + trip_status, departure, arrival, departure_name, destination_name, + recorded_at, company_code + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'tracking', $9, $10, $11, $12, NOW(), $13) + RETURNING id + `; + + const result = await this.pool.query(locationQuery, [ + tripId, + userId, + trip.vehicle_id, + latitude, + longitude, + accuracy || null, + speed || null, + distanceFromPrev > 0 ? distanceFromPrev.toFixed(3) : null, + trip.departure, + trip.arrival, + trip.departure_name, + trip.destination_name, + companyCode, + ]); + + // 4. 운행 요약의 위치 수 업데이트 + await this.pool.query( + `UPDATE vehicle_trip_summary SET location_count = location_count + 1 WHERE trip_id = $1`, + [tripId] + ); + + return { + locationId: result.rows[0].id, + distanceFromPrev: parseFloat(distanceFromPrev.toFixed(3)), + }; + } + + /** + * 운행 이력 목록 조회 + */ + async getTripList(companyCode: string, filters: TripListFilters) { + const conditions: string[] = ["company_code = $1"]; + const params: any[] = [companyCode]; + let paramIndex = 2; + + if (filters.userId) { + conditions.push(`user_id = $${paramIndex++}`); + params.push(filters.userId); + } + + if (filters.vehicleId) { + conditions.push(`vehicle_id = $${paramIndex++}`); + params.push(filters.vehicleId); + } + + if (filters.status) { + conditions.push(`status = $${paramIndex++}`); + params.push(filters.status); + } + + if (filters.startDate) { + conditions.push(`start_time >= $${paramIndex++}`); + params.push(filters.startDate); + } + + if (filters.endDate) { + conditions.push(`start_time <= $${paramIndex++}`); + params.push(filters.endDate + " 23:59:59"); + } + + if (filters.departure) { + conditions.push(`departure = $${paramIndex++}`); + params.push(filters.departure); + } + + if (filters.arrival) { + conditions.push(`arrival = $${paramIndex++}`); + params.push(filters.arrival); + } + + const whereClause = conditions.join(" AND "); + + // 총 개수 조회 + const countQuery = `SELECT COUNT(*) as total FROM vehicle_trip_summary WHERE ${whereClause}`; + const countResult = await this.pool.query(countQuery, params); + const total = parseInt(countResult.rows[0].total); + + // 목록 조회 + const limit = filters.limit || 50; + const offset = filters.offset || 0; + + const listQuery = ` + SELECT + vts.*, + ui.user_name, + v.vehicle_number + FROM vehicle_trip_summary vts + LEFT JOIN user_info ui ON vts.user_id = ui.user_id + LEFT JOIN vehicles v ON vts.vehicle_id = v.id + WHERE ${whereClause} + ORDER BY vts.start_time DESC + LIMIT $${paramIndex++} OFFSET $${paramIndex++} + `; + + params.push(limit, offset); + const listResult = await this.pool.query(listQuery, params); + + return { + data: listResult.rows, + total, + }; + } + + /** + * 운행 상세 조회 (경로 포함) + */ + async getTripDetail(tripId: string, companyCode: string) { + // 1. 운행 요약 조회 + const summaryQuery = ` + SELECT + vts.*, + ui.user_name, + v.vehicle_number + FROM vehicle_trip_summary vts + LEFT JOIN user_info ui ON vts.user_id = ui.user_id + LEFT JOIN vehicles v ON vts.vehicle_id = v.id + WHERE vts.trip_id = $1 AND vts.company_code = $2 + `; + const summaryResult = await this.pool.query(summaryQuery, [tripId, companyCode]); + + if (summaryResult.rows.length === 0) { + return null; + } + + // 2. 경로 데이터 조회 + const routeQuery = ` + SELECT + id, latitude, longitude, accuracy, speed, + distance_from_prev, trip_status, recorded_at + FROM vehicle_location_history + WHERE trip_id = $1 AND company_code = $2 + ORDER BY recorded_at ASC + `; + const routeResult = await this.pool.query(routeQuery, [tripId, companyCode]); + + return { + summary: summaryResult.rows[0], + route: routeResult.rows, + }; + } + + /** + * 활성 운행 조회 + */ + async getActiveTrip(userId: string, companyCode: string) { + const query = ` + SELECT * FROM vehicle_trip_summary + WHERE user_id = $1 AND company_code = $2 AND status = 'active' + ORDER BY start_time DESC + LIMIT 1 + `; + const result = await this.pool.query(query, [userId, companyCode]); + return result.rows[0] || null; + } + + /** + * 운행 취소 + */ + async cancelTrip(tripId: string, companyCode: string) { + const query = ` + UPDATE vehicle_trip_summary + SET status = 'cancelled', end_time = NOW() + WHERE trip_id = $1 AND company_code = $2 AND status = 'active' + RETURNING * + `; + const result = await this.pool.query(query, [tripId, companyCode]); + return result.rows[0] || null; + } +} + +export const vehicleTripService = new VehicleTripService(); diff --git a/backend-node/src/types/flow.ts b/backend-node/src/types/flow.ts index c127eccc..9f105a49 100644 --- a/backend-node/src/types/flow.ts +++ b/backend-node/src/types/flow.ts @@ -2,14 +2,38 @@ * 플로우 관리 시스템 타입 정의 */ +// 다중 REST API 연결 설정 +export interface RestApiConnectionConfig { + connectionId: number; + connectionName: string; + endpoint: string; + jsonPath: string; + alias: string; // 컬럼 접두어 (예: "api1_") +} + +// 다중 외부 DB 연결 설정 +export interface ExternalDbConnectionConfig { + connectionId: number; + connectionName: string; + dbType: string; + tableName: string; + alias: string; // 컬럼 접두어 (예: "db1_") +} + // 플로우 정의 export interface FlowDefinition { id: number; name: string; description?: string; tableName: string; - dbSourceType?: "internal" | "external"; // 데이터베이스 소스 타입 + dbSourceType?: "internal" | "external" | "restapi" | "multi_restapi" | "multi_external_db"; // 데이터 소스 타입 dbConnectionId?: number; // 외부 DB 연결 ID (external인 경우) + // REST API 관련 필드 (단일) + restApiConnectionId?: number; // REST API 연결 ID (restapi인 경우) + restApiEndpoint?: string; // REST API 엔드포인트 + restApiJsonPath?: string; // JSON 응답에서 데이터 경로 (기본: data) + // 다중 REST API 관련 필드 + restApiConnections?: RestApiConnectionConfig[]; // 다중 REST API 설정 배열 companyCode: string; // 회사 코드 (* = 공통) isActive: boolean; createdBy?: string; @@ -22,8 +46,14 @@ export interface CreateFlowDefinitionRequest { name: string; description?: string; tableName: string; - dbSourceType?: "internal" | "external"; // 데이터베이스 소스 타입 + dbSourceType?: "internal" | "external" | "restapi" | "multi_restapi" | "multi_external_db"; // 데이터 소스 타입 dbConnectionId?: number; // 외부 DB 연결 ID + // REST API 관련 필드 (단일) + restApiConnectionId?: number; // REST API 연결 ID + restApiEndpoint?: string; // REST API 엔드포인트 + restApiJsonPath?: string; // JSON 응답에서 데이터 경로 + // 다중 REST API 관련 필드 + restApiConnections?: RestApiConnectionConfig[]; // 다중 REST API 설정 배열 companyCode?: string; // 회사 코드 (미제공 시 사용자의 company_code 사용) } diff --git a/backend-node/src/utils/geoUtils.ts b/backend-node/src/utils/geoUtils.ts new file mode 100644 index 00000000..50f370ad --- /dev/null +++ b/backend-node/src/utils/geoUtils.ts @@ -0,0 +1,176 @@ +/** + * 지리 좌표 관련 유틸리티 함수 + */ + +/** + * Haversine 공식을 사용하여 두 좌표 간의 거리 계산 (km) + * + * @param lat1 - 첫 번째 지점의 위도 + * @param lon1 - 첫 번째 지점의 경도 + * @param lat2 - 두 번째 지점의 위도 + * @param lon2 - 두 번째 지점의 경도 + * @returns 두 지점 간의 거리 (km) + */ +export function calculateDistance( + lat1: number, + lon1: number, + lat2: number, + lon2: number +): number { + const R = 6371; // 지구 반경 (km) + + const dLat = toRadians(lat2 - lat1); + const dLon = toRadians(lon2 - lon1); + + const a = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(toRadians(lat1)) * + Math.cos(toRadians(lat2)) * + Math.sin(dLon / 2) * + Math.sin(dLon / 2); + + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + + return R * c; +} + +/** + * 각도를 라디안으로 변환 + */ +function toRadians(degrees: number): number { + return degrees * (Math.PI / 180); +} + +/** + * 라디안을 각도로 변환 + */ +export function toDegrees(radians: number): number { + return radians * (180 / Math.PI); +} + +/** + * 좌표 배열에서 총 거리 계산 + * + * @param coordinates - { latitude, longitude }[] 형태의 좌표 배열 + * @returns 총 거리 (km) + */ +export function calculateTotalDistance( + coordinates: Array<{ latitude: number; longitude: number }> +): number { + let totalDistance = 0; + + for (let i = 1; i < coordinates.length; i++) { + const prev = coordinates[i - 1]; + const curr = coordinates[i]; + totalDistance += calculateDistance( + prev.latitude, + prev.longitude, + curr.latitude, + curr.longitude + ); + } + + return totalDistance; +} + +/** + * 좌표가 특정 반경 내에 있는지 확인 + * + * @param centerLat - 중심점 위도 + * @param centerLon - 중심점 경도 + * @param pointLat - 확인할 지점의 위도 + * @param pointLon - 확인할 지점의 경도 + * @param radiusKm - 반경 (km) + * @returns 반경 내에 있으면 true + */ +export function isWithinRadius( + centerLat: number, + centerLon: number, + pointLat: number, + pointLon: number, + radiusKm: number +): boolean { + const distance = calculateDistance(centerLat, centerLon, pointLat, pointLon); + return distance <= radiusKm; +} + +/** + * 두 좌표 사이의 방위각(bearing) 계산 + * + * @param lat1 - 시작점 위도 + * @param lon1 - 시작점 경도 + * @param lat2 - 도착점 위도 + * @param lon2 - 도착점 경도 + * @returns 방위각 (0-360도) + */ +export function calculateBearing( + lat1: number, + lon1: number, + lat2: number, + lon2: number +): number { + const dLon = toRadians(lon2 - lon1); + const lat1Rad = toRadians(lat1); + const lat2Rad = toRadians(lat2); + + const x = Math.sin(dLon) * Math.cos(lat2Rad); + const y = + Math.cos(lat1Rad) * Math.sin(lat2Rad) - + Math.sin(lat1Rad) * Math.cos(lat2Rad) * Math.cos(dLon); + + let bearing = toDegrees(Math.atan2(x, y)); + bearing = (bearing + 360) % 360; // 0-360 범위로 정규화 + + return bearing; +} + +/** + * 좌표 배열의 경계 상자(bounding box) 계산 + * + * @param coordinates - 좌표 배열 + * @returns { minLat, maxLat, minLon, maxLon } + */ +export function getBoundingBox( + coordinates: Array<{ latitude: number; longitude: number }> +): { minLat: number; maxLat: number; minLon: number; maxLon: number } | null { + if (coordinates.length === 0) return null; + + let minLat = coordinates[0].latitude; + let maxLat = coordinates[0].latitude; + let minLon = coordinates[0].longitude; + let maxLon = coordinates[0].longitude; + + for (const coord of coordinates) { + minLat = Math.min(minLat, coord.latitude); + maxLat = Math.max(maxLat, coord.latitude); + minLon = Math.min(minLon, coord.longitude); + maxLon = Math.max(maxLon, coord.longitude); + } + + return { minLat, maxLat, minLon, maxLon }; +} + +/** + * 좌표 배열의 중심점 계산 + * + * @param coordinates - 좌표 배열 + * @returns { latitude, longitude } 중심점 + */ +export function getCenterPoint( + coordinates: Array<{ latitude: number; longitude: number }> +): { latitude: number; longitude: number } | null { + if (coordinates.length === 0) return null; + + let sumLat = 0; + let sumLon = 0; + + for (const coord of coordinates) { + sumLat += coord.latitude; + sumLon += coord.longitude; + } + + return { + latitude: sumLat / coordinates.length, + longitude: sumLon / coordinates.length, + }; +} diff --git a/frontend/app/(admin)/admin/vehicle-reports/page.tsx b/frontend/app/(admin)/admin/vehicle-reports/page.tsx new file mode 100644 index 00000000..ce84f584 --- /dev/null +++ b/frontend/app/(admin)/admin/vehicle-reports/page.tsx @@ -0,0 +1,30 @@ +"use client"; + +import dynamic from "next/dynamic"; + +const VehicleReport = dynamic( + () => import("@/components/vehicle/VehicleReport"), + { + ssr: false, + loading: () => ( +
+
로딩 중...
+
+ ), + } +); + +export default function VehicleReportsPage() { + return ( +
+
+

운행 리포트

+

+ 차량 운행 통계 및 분석 리포트를 확인합니다. +

+
+ +
+ ); +} + diff --git a/frontend/app/(admin)/admin/vehicle-trips/page.tsx b/frontend/app/(admin)/admin/vehicle-trips/page.tsx new file mode 100644 index 00000000..fea63166 --- /dev/null +++ b/frontend/app/(admin)/admin/vehicle-trips/page.tsx @@ -0,0 +1,29 @@ +"use client"; + +import dynamic from "next/dynamic"; + +const VehicleTripHistory = dynamic( + () => import("@/components/vehicle/VehicleTripHistory"), + { + ssr: false, + loading: () => ( +
+
로딩 중...
+
+ ), + } +); + +export default function VehicleTripsPage() { + return ( +
+
+

운행 이력 관리

+

+ 차량 운행 이력을 조회하고 관리합니다. +

+
+ +
+ ); +} diff --git a/frontend/app/(main)/admin/flow-management/[id]/page.tsx b/frontend/app/(main)/admin/flow-management/[id]/page.tsx index a311bc63..b8d14e19 100644 --- a/frontend/app/(main)/admin/flow-management/[id]/page.tsx +++ b/frontend/app/(main)/admin/flow-management/[id]/page.tsx @@ -319,6 +319,10 @@ export default function FlowEditorPage() { flowTableName={flowDefinition?.tableName} // 플로우 정의의 테이블명 전달 flowDbSourceType={flowDefinition?.dbSourceType} // DB 소스 타입 전달 flowDbConnectionId={flowDefinition?.dbConnectionId} // 외부 DB 연결 ID 전달 + flowRestApiConnectionId={flowDefinition?.restApiConnectionId} // REST API 연결 ID 전달 + flowRestApiEndpoint={flowDefinition?.restApiEndpoint} // REST API 엔드포인트 전달 + flowRestApiJsonPath={flowDefinition?.restApiJsonPath} // REST API JSON 경로 전달 + flowRestApiConnections={flowDefinition?.restApiConnections} // 다중 REST API 설정 전달 onClose={() => 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 bb2bf04a..d283f72d 100644 --- a/frontend/app/(main)/admin/flow-management/page.tsx +++ b/frontend/app/(main)/admin/flow-management/page.tsx @@ -34,6 +34,7 @@ import { formatErrorMessage } from "@/lib/utils/errorUtils"; import { tableManagementApi } from "@/lib/api/tableManagement"; import { ScrollToTop } from "@/components/common/ScrollToTop"; import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection"; +import { ExternalRestApiConnectionAPI, ExternalRestApiConnection } from "@/lib/api/externalRestApiConnection"; export default function FlowManagementPage() { const router = useRouter(); @@ -52,13 +53,42 @@ export default function FlowManagementPage() { ); const [loadingTables, setLoadingTables] = useState(false); const [openTableCombobox, setOpenTableCombobox] = useState(false); - const [selectedDbSource, setSelectedDbSource] = useState<"internal" | number>("internal"); // "internal" 또는 외부 DB connection ID + // 데이터 소스 타입: "internal" (내부 DB), "external_db_숫자" (외부 DB), "restapi_숫자" (REST API) + const [selectedDbSource, setSelectedDbSource] = useState("internal"); const [externalConnections, setExternalConnections] = useState< Array<{ id: number; connection_name: string; db_type: string }> >([]); const [externalTableList, setExternalTableList] = useState([]); const [loadingExternalTables, setLoadingExternalTables] = useState(false); + // REST API 연결 관련 상태 + const [restApiConnections, setRestApiConnections] = useState([]); + const [restApiEndpoint, setRestApiEndpoint] = useState(""); + const [restApiJsonPath, setRestApiJsonPath] = useState("response"); + + // 다중 REST API 선택 상태 + interface RestApiConfig { + connectionId: number; + connectionName: string; + endpoint: string; + jsonPath: string; + alias: string; // 컬럼 접두어 (예: "api1_") + } + const [selectedRestApis, setSelectedRestApis] = useState([]); + const [isMultiRestApi, setIsMultiRestApi] = useState(false); // 다중 REST API 모드 + + // 다중 외부 DB 선택 상태 + interface ExternalDbConfig { + connectionId: number; + connectionName: string; + dbType: string; + tableName: string; + alias: string; // 컬럼 접두어 (예: "db1_") + } + const [selectedExternalDbs, setSelectedExternalDbs] = useState([]); + const [isMultiExternalDb, setIsMultiExternalDb] = useState(false); // 다중 외부 DB 모드 + const [multiDbTableLists, setMultiDbTableLists] = useState>({}); // 각 DB별 테이블 목록 + // 생성 폼 상태 const [formData, setFormData] = useState({ name: "", @@ -135,75 +165,288 @@ export default function FlowManagementPage() { loadConnections(); }, []); + // REST API 연결 목록 로드 + useEffect(() => { + const loadRestApiConnections = async () => { + try { + const connections = await ExternalRestApiConnectionAPI.getConnections({ is_active: "Y" }); + setRestApiConnections(connections); + } catch (error) { + console.error("Failed to load REST API connections:", error); + setRestApiConnections([]); + } + }; + loadRestApiConnections(); + }, []); + // 외부 DB 테이블 목록 로드 useEffect(() => { - if (selectedDbSource === "internal" || !selectedDbSource) { + // REST API인 경우 테이블 목록 로드 불필요 + if (selectedDbSource === "internal" || !selectedDbSource || selectedDbSource.startsWith("restapi_")) { setExternalTableList([]); return; } - const loadExternalTables = async () => { - try { - setLoadingExternalTables(true); - const token = localStorage.getItem("authToken"); + // 외부 DB인 경우 + if (selectedDbSource.startsWith("external_db_")) { + const connectionId = selectedDbSource.replace("external_db_", ""); + + 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}`, - }, - }); + const response = await fetch(`/api/multi-connection/connections/${connectionId}/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: string | { tableName?: string; table_name?: string; tablename?: string; name?: string }) => - typeof t === "string" ? t : t.tableName || t.table_name || t.tablename || t.name, - ) - .filter(Boolean); - setExternalTableList(tableNames); + 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: string | { tableName?: string; table_name?: string; tablename?: string; name?: string }) => + typeof t === "string" ? t : t.tableName || t.table_name || t.tablename || t.name, + ) + .filter(Boolean); + setExternalTableList(tableNames); + } else { + setExternalTableList([]); + } } else { setExternalTableList([]); } - } else { + } catch (error) { + console.error("외부 DB 테이블 목록 조회 오류:", error); setExternalTableList([]); + } finally { + setLoadingExternalTables(false); } - } catch (error) { - console.error("외부 DB 테이블 목록 조회 오류:", error); - setExternalTableList([]); - } finally { - setLoadingExternalTables(false); - } - }; + }; - loadExternalTables(); + loadExternalTables(); + } }, [selectedDbSource]); + // 다중 외부 DB 추가 + const addExternalDbConfig = async (connectionId: number) => { + const connection = externalConnections.find(c => c.id === connectionId); + if (!connection) return; + + // 이미 추가된 경우 스킵 + if (selectedExternalDbs.some(db => db.connectionId === connectionId)) { + toast({ + title: "이미 추가됨", + description: "해당 외부 DB가 이미 추가되어 있습니다.", + variant: "destructive", + }); + return; + } + + // 해당 DB의 테이블 목록 로드 + try { + const data = await ExternalDbConnectionAPI.getTables(connectionId); + if (data.success && data.data) { + const tables = Array.isArray(data.data) ? data.data : []; + const tableNames = tables + .map((t: string | { tableName?: string; table_name?: string; tablename?: string; name?: string }) => + typeof t === "string" ? t : t.tableName || t.table_name || t.tablename || t.name, + ) + .filter(Boolean); + setMultiDbTableLists(prev => ({ ...prev, [connectionId]: tableNames })); + } + } catch (error) { + console.error("외부 DB 테이블 목록 조회 오류:", error); + } + + const newConfig: ExternalDbConfig = { + connectionId, + connectionName: connection.connection_name, + dbType: connection.db_type, + tableName: "", + alias: `db${selectedExternalDbs.length + 1}_`, // 자동 별칭 생성 + }; + + setSelectedExternalDbs([...selectedExternalDbs, newConfig]); + }; + + // 다중 외부 DB 삭제 + const removeExternalDbConfig = (connectionId: number) => { + setSelectedExternalDbs(selectedExternalDbs.filter(db => db.connectionId !== connectionId)); + }; + + // 다중 외부 DB 설정 업데이트 + const updateExternalDbConfig = (connectionId: number, field: keyof ExternalDbConfig, value: string) => { + setSelectedExternalDbs(selectedExternalDbs.map(db => + db.connectionId === connectionId ? { ...db, [field]: value } : db + )); + }; + + // 다중 REST API 추가 + const addRestApiConfig = (connectionId: number) => { + const connection = restApiConnections.find(c => c.id === connectionId); + if (!connection) return; + + // 이미 추가된 경우 스킵 + if (selectedRestApis.some(api => api.connectionId === connectionId)) { + toast({ + title: "이미 추가됨", + description: "해당 REST API가 이미 추가되어 있습니다.", + variant: "destructive", + }); + return; + } + + // 연결 테이블의 기본값 사용 + const newConfig: RestApiConfig = { + connectionId, + connectionName: connection.connection_name, + endpoint: connection.endpoint_path || "", // 연결 테이블의 기본 엔드포인트 + jsonPath: "response", // 기본값 + alias: `api${selectedRestApis.length + 1}_`, // 자동 별칭 생성 + }; + + setSelectedRestApis([...selectedRestApis, newConfig]); + }; + + // 다중 REST API 삭제 + const removeRestApiConfig = (connectionId: number) => { + setSelectedRestApis(selectedRestApis.filter(api => api.connectionId !== connectionId)); + }; + + // 다중 REST API 설정 업데이트 + const updateRestApiConfig = (connectionId: number, field: keyof RestApiConfig, value: string) => { + setSelectedRestApis(selectedRestApis.map(api => + api.connectionId === connectionId ? { ...api, [field]: value } : api + )); + }; + // 플로우 생성 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 }); + // REST API 또는 다중 선택인 경우 테이블 이름 검증 스킵 + const isRestApi = selectedDbSource.startsWith("restapi_") || isMultiRestApi; + const isMultiMode = isMultiRestApi || isMultiExternalDb; + + if (!formData.name || (!isRestApi && !isMultiMode && !formData.tableName)) { + console.log("❌ Validation failed:", { name: formData.name, tableName: formData.tableName, isRestApi, isMultiMode }); toast({ title: "입력 오류", - description: "플로우 이름과 테이블 이름은 필수입니다.", + description: (isRestApi || isMultiMode) ? "플로우 이름은 필수입니다." : "플로우 이름과 테이블 이름은 필수입니다.", + variant: "destructive", + }); + return; + } + + // 다중 REST API 모드인 경우 검증 + if (isMultiRestApi) { + if (selectedRestApis.length === 0) { + toast({ + title: "입력 오류", + description: "최소 하나의 REST API를 추가해주세요.", + variant: "destructive", + }); + return; + } + + // 각 API의 엔드포인트 검증 + const missingEndpoint = selectedRestApis.find(api => !api.endpoint); + if (missingEndpoint) { + toast({ + title: "입력 오류", + description: `${missingEndpoint.connectionName}의 엔드포인트를 입력해주세요.`, + variant: "destructive", + }); + return; + } + } else if (isMultiExternalDb) { + // 다중 외부 DB 모드인 경우 검증 + if (selectedExternalDbs.length === 0) { + toast({ + title: "입력 오류", + description: "최소 하나의 외부 DB를 추가해주세요.", + variant: "destructive", + }); + return; + } + + // 각 DB의 테이블 선택 검증 + const missingTable = selectedExternalDbs.find(db => !db.tableName); + if (missingTable) { + toast({ + title: "입력 오류", + description: `${missingTable.connectionName}의 테이블을 선택해주세요.`, + variant: "destructive", + }); + return; + } + } else if (isRestApi && !restApiEndpoint) { + // 단일 REST API인 경우 엔드포인트 검증 + toast({ + title: "입력 오류", + description: "REST API 엔드포인트는 필수입니다.", variant: "destructive", }); return; } try { - // DB 소스 정보 추가 - const requestData = { + // 데이터 소스 타입 및 ID 파싱 + let dbSourceType: "internal" | "external" | "restapi" | "multi_restapi" | "multi_external_db" = "internal"; + let dbConnectionId: number | undefined = undefined; + let restApiConnectionId: number | undefined = undefined; + + if (isMultiRestApi) { + dbSourceType = "multi_restapi"; + } else if (isMultiExternalDb) { + dbSourceType = "multi_external_db"; + } else if (selectedDbSource === "internal") { + dbSourceType = "internal"; + } else if (selectedDbSource.startsWith("external_db_")) { + dbSourceType = "external"; + dbConnectionId = parseInt(selectedDbSource.replace("external_db_", "")); + } else if (selectedDbSource.startsWith("restapi_")) { + dbSourceType = "restapi"; + restApiConnectionId = parseInt(selectedDbSource.replace("restapi_", "")); + } + + // 요청 데이터 구성 + const requestData: Record = { ...formData, - dbSourceType: selectedDbSource === "internal" ? "internal" : "external", - dbConnectionId: selectedDbSource === "internal" ? undefined : Number(selectedDbSource), + dbSourceType, + dbConnectionId, }; + // 다중 REST API인 경우 + if (dbSourceType === "multi_restapi") { + requestData.restApiConnections = selectedRestApis; + // 다중 REST API는 첫 번째 API의 ID를 기본으로 사용 + requestData.restApiConnectionId = selectedRestApis[0]?.connectionId; + requestData.restApiEndpoint = selectedRestApis[0]?.endpoint; + requestData.restApiJsonPath = selectedRestApis[0]?.jsonPath || "response"; + // 가상 테이블명: 모든 연결 ID를 조합 + requestData.tableName = `_multi_restapi_${selectedRestApis.map(a => a.connectionId).join("_")}`; + } else if (dbSourceType === "multi_external_db") { + // 다중 외부 DB인 경우 + requestData.externalDbConnections = selectedExternalDbs; + // 첫 번째 DB의 ID를 기본으로 사용 + requestData.dbConnectionId = selectedExternalDbs[0]?.connectionId; + // 가상 테이블명: 모든 연결 ID와 테이블명 조합 + requestData.tableName = `_multi_external_db_${selectedExternalDbs.map(db => `${db.connectionId}_${db.tableName}`).join("_")}`; + } else if (dbSourceType === "restapi") { + // 단일 REST API인 경우 + requestData.restApiConnectionId = restApiConnectionId; + requestData.restApiEndpoint = restApiEndpoint; + requestData.restApiJsonPath = restApiJsonPath || "response"; + // REST API는 가상 테이블명 사용 + requestData.tableName = `_restapi_${restApiConnectionId}`; + } + console.log("✅ Calling createFlowDefinition with:", requestData); - const response = await createFlowDefinition(requestData); + const response = await createFlowDefinition(requestData as Parameters[0]); if (response.success && response.data) { toast({ title: "생성 완료", @@ -212,6 +455,12 @@ export default function FlowManagementPage() { setIsCreateDialogOpen(false); setFormData({ name: "", description: "", tableName: "" }); setSelectedDbSource("internal"); + setRestApiEndpoint(""); + setRestApiJsonPath("response"); + setSelectedRestApis([]); + setSelectedExternalDbs([]); + setIsMultiRestApi(false); + setIsMultiExternalDb(false); loadFlows(); } else { toast({ @@ -415,125 +664,373 @@ export default function FlowManagementPage() { /> - {/* DB 소스 선택 */} + {/* 데이터 소스 선택 */}
- +

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

- {/* 테이블 선택 */} -
- - - - +
+ ))} + + )} +

+ 선택한 REST API들의 데이터가 자동으로 병합됩니다. +

+ + )} + + {/* 다중 외부 DB 선택 UI */} + {isMultiExternalDb && ( +
+
+ + +
+ + {selectedExternalDbs.length === 0 ? ( +
+

+ 위에서 외부 DB를 추가해주세요 +

+
+ ) : ( +
+ {selectedExternalDbs.map((db) => ( +
+
+ + {db.connectionName} ({db.dbType?.toUpperCase()}) + + +
+
+
+ + +
+
+ + updateExternalDbConfig(db.connectionId, "alias", e.target.value)} + placeholder="db1_" + className="h-7 text-xs" + /> +
+
+
+ ))} +
+ )} +

+ 선택한 외부 DB들의 데이터가 자동으로 병합됩니다. 각 DB별 테이블을 선택해주세요. +

+
+ )} + + {/* 단일 REST API인 경우 엔드포인트 설정 */} + {!isMultiRestApi && selectedDbSource.startsWith("restapi_") && ( + <> +
+ + setRestApiEndpoint(e.target.value)} + placeholder="예: /api/data/list" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +

+ 데이터를 조회할 API 엔드포인트 경로입니다 +

+
+
+ + setRestApiJsonPath(e.target.value)} + placeholder="예: data 또는 result.items" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +

+ 응답 JSON에서 데이터 배열의 경로입니다 (기본: data) +

+
+ + )} + + {/* 테이블 선택 (내부 DB 또는 단일 외부 DB - 다중 선택 모드가 아닌 경우만) */} + {!isMultiRestApi && !isMultiExternalDb && !selectedDbSource.startsWith("restapi_") && ( +
+ + + + + + + + + + 테이블을 찾을 수 없습니다. + + {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}
+
+ ))} +
+
+
+
+
+

+ 플로우의 모든 단계에서 사용할 기본 테이블입니다 (단계마다 상태 컬럼만 지정합니다) +

+
+ )}
)} + + {/* REST API 연동 설정 */} + {formData.integrationType === "rest_api" && ( +
+
+ + +
+ + {formData.integrationConfig?.connectionId && ( + <> +
+ + +
+ +
+ + + setFormData({ + ...formData, + integrationConfig: { + ...formData.integrationConfig!, + endpoint: e.target.value, + } as any, + }) + } + placeholder="/api/update" + /> +

+ 데이터 이동 시 호출할 API 엔드포인트 +

+
+ +
+ +