diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 3b5e74da..d214c19a 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 driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리 import { BatchSchedulerService } from "./services/batchSchedulerService"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 @@ -240,6 +241,7 @@ app.use("/api/entity-search", entitySearchRoutes); // 엔티티 검색 app.use("/api/orders", orderRoutes); // 수주 관리 app.use("/api/driver", driverRoutes); // 공차중계 운전자 관리 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/adminController.ts b/backend-node/src/controllers/adminController.ts index da0ea772..3ac5d26b 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -1428,10 +1428,51 @@ export async function deleteMenu( } } + // 외래키 제약 조건이 있는 관련 테이블 데이터 먼저 정리 + const menuObjid = Number(menuId); + + // 1. category_column_mapping에서 menu_objid를 NULL로 설정 + await query( + `UPDATE category_column_mapping SET menu_objid = NULL WHERE menu_objid = $1`, + [menuObjid] + ); + + // 2. code_category에서 menu_objid를 NULL로 설정 + await query( + `UPDATE code_category SET menu_objid = NULL WHERE menu_objid = $1`, + [menuObjid] + ); + + // 3. code_info에서 menu_objid를 NULL로 설정 + await query( + `UPDATE code_info SET menu_objid = NULL WHERE menu_objid = $1`, + [menuObjid] + ); + + // 4. numbering_rules에서 menu_objid를 NULL로 설정 + await query( + `UPDATE numbering_rules SET menu_objid = NULL WHERE menu_objid = $1`, + [menuObjid] + ); + + // 5. rel_menu_auth에서 관련 권한 삭제 + await query( + `DELETE FROM rel_menu_auth WHERE menu_objid = $1`, + [menuObjid] + ); + + // 6. screen_menu_assignments에서 관련 할당 삭제 + await query( + `DELETE FROM screen_menu_assignments WHERE menu_objid = $1`, + [menuObjid] + ); + + logger.info("메뉴 관련 데이터 정리 완료", { menuObjid }); + // Raw Query를 사용한 메뉴 삭제 const [deletedMenu] = await query( `DELETE FROM menu_info WHERE objid = $1 RETURNING *`, - [Number(menuId)] + [menuObjid] ); logger.info("메뉴 삭제 성공", { deletedMenu }); diff --git a/backend-node/src/controllers/entitySearchController.ts b/backend-node/src/controllers/entitySearchController.ts index 880c54fc..4d911c57 100644 --- a/backend-node/src/controllers/entitySearchController.ts +++ b/backend-node/src/controllers/entitySearchController.ts @@ -32,10 +32,32 @@ export async function searchEntity(req: AuthenticatedRequest, res: Response) { const companyCode = req.user!.companyCode; // 검색 필드 파싱 - const fields = searchFields + const requestedFields = searchFields ? (searchFields as string).split(",").map((f) => f.trim()) : []; + // 🆕 테이블의 실제 컬럼 목록 조회 + const pool = getPool(); + const columnsResult = await pool.query( + `SELECT column_name FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = $1`, + [tableName] + ); + const existingColumns = new Set(columnsResult.rows.map((r: any) => r.column_name)); + + // 🆕 존재하는 컬럼만 필터링 + const fields = requestedFields.filter((field) => { + if (existingColumns.has(field)) { + return true; + } else { + logger.warn(`엔티티 검색: 테이블 "${tableName}"에 컬럼 "${field}"이(가) 존재하지 않아 제외`); + return false; + } + }); + + const existingColumnsArray = Array.from(existingColumns); + logger.info(`엔티티 검색 필드 확인 - 테이블: ${tableName}, 요청필드: [${requestedFields.join(", ")}], 유효필드: [${fields.join(", ")}], 테이블컬럼(샘플): [${existingColumnsArray.slice(0, 10).join(", ")}]`); + // WHERE 조건 생성 const whereConditions: string[] = []; const params: any[] = []; @@ -43,32 +65,57 @@ export async function searchEntity(req: AuthenticatedRequest, res: Response) { // 멀티테넌시 필터링 if (companyCode !== "*") { - whereConditions.push(`company_code = $${paramIndex}`); - params.push(companyCode); - paramIndex++; + // 🆕 company_code 컬럼이 있는 경우에만 필터링 + if (existingColumns.has("company_code")) { + whereConditions.push(`company_code = $${paramIndex}`); + params.push(companyCode); + paramIndex++; + } } // 검색 조건 - if (searchText && fields.length > 0) { - const searchConditions = fields.map((field) => { - const condition = `${field}::text ILIKE $${paramIndex}`; - paramIndex++; - return condition; - }); - whereConditions.push(`(${searchConditions.join(" OR ")})`); + if (searchText) { + // 유효한 검색 필드가 없으면 기본 텍스트 컬럼에서 검색 + let searchableFields = fields; + if (searchableFields.length === 0) { + // 기본 검색 컬럼: name, code, description 등 일반적인 컬럼명 + const defaultSearchColumns = [ + 'name', 'code', 'description', 'title', 'label', + 'item_name', 'item_code', 'item_number', + 'equipment_name', 'equipment_code', + 'inspection_item', 'consumable_name', // 소모품명 추가 + 'supplier_name', 'customer_name', 'product_name', + ]; + searchableFields = defaultSearchColumns.filter(col => existingColumns.has(col)); + + logger.info(`엔티티 검색: 기본 검색 필드 사용 - 테이블: ${tableName}, 검색필드: [${searchableFields.join(", ")}]`); + } + + if (searchableFields.length > 0) { + const searchConditions = searchableFields.map((field) => { + const condition = `${field}::text ILIKE $${paramIndex}`; + paramIndex++; + return condition; + }); + whereConditions.push(`(${searchConditions.join(" OR ")})`); - // 검색어 파라미터 추가 - fields.forEach(() => { - params.push(`%${searchText}%`); - }); + // 검색어 파라미터 추가 + searchableFields.forEach(() => { + params.push(`%${searchText}%`); + }); + } } - // 추가 필터 조건 + // 추가 필터 조건 (존재하는 컬럼만) const additionalFilter = JSON.parse(filterCondition as string); for (const [key, value] of Object.entries(additionalFilter)) { - whereConditions.push(`${key} = $${paramIndex}`); - params.push(value); - paramIndex++; + if (existingColumns.has(key)) { + whereConditions.push(`${key} = $${paramIndex}`); + params.push(value); + paramIndex++; + } else { + logger.warn("엔티티 검색: 필터 조건에 존재하지 않는 컬럼 제외", { tableName, key }); + } } // 페이징 @@ -78,8 +125,7 @@ export async function searchEntity(req: AuthenticatedRequest, res: Response) { ? `WHERE ${whereConditions.join(" AND ")}` : ""; - // 쿼리 실행 - const pool = getPool(); + // 쿼리 실행 (pool은 위에서 이미 선언됨) const countQuery = `SELECT COUNT(*) FROM ${tableName} ${whereClause}`; const dataQuery = ` SELECT * FROM ${tableName} ${whereClause} 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..5605031e 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: "화면 정보가 수정되었습니다." }); @@ -294,6 +325,53 @@ export const getDeletedScreens = async ( } }; +// 활성 화면 일괄 삭제 (휴지통으로 이동) +export const bulkDeleteScreens = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { companyCode, userId } = req.user as any; + const { screenIds, deleteReason, force } = req.body; + + if (!Array.isArray(screenIds) || screenIds.length === 0) { + return res.status(400).json({ + success: false, + message: "삭제할 화면 ID 목록이 필요합니다.", + }); + } + + const result = await screenManagementService.bulkDeleteScreens( + screenIds, + companyCode, + userId, + deleteReason, + force || false + ); + + let message = `${result.deletedCount}개 화면이 휴지통으로 이동되었습니다.`; + if (result.skippedCount > 0) { + message += ` (${result.skippedCount}개 화면은 삭제되지 않았습니다.)`; + } + + return res.json({ + success: true, + message, + result: { + deletedCount: result.deletedCount, + skippedCount: result.skippedCount, + errors: result.errors, + }, + }); + } catch (error) { + console.error("활성 화면 일괄 삭제 실패:", error); + return res.status(500).json({ + success: false, + message: "일괄 삭제에 실패했습니다.", + }); + } +}; + // 휴지통 화면 일괄 영구 삭제 export const bulkPermanentDeleteScreens = async ( req: AuthenticatedRequest, 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/screenManagementRoutes.ts b/backend-node/src/routes/screenManagementRoutes.ts index 4207c719..67263277 100644 --- a/backend-node/src/routes/screenManagementRoutes.ts +++ b/backend-node/src/routes/screenManagementRoutes.ts @@ -8,6 +8,7 @@ import { updateScreen, updateScreenInfo, deleteScreen, + bulkDeleteScreens, checkScreenDependencies, restoreScreen, permanentDeleteScreen, @@ -44,6 +45,7 @@ router.put("/screens/:id", updateScreen); router.put("/screens/:id/info", updateScreenInfo); // 화면 정보만 수정 router.get("/screens/:id/dependencies", checkScreenDependencies); // 의존성 체크 router.delete("/screens/:id", deleteScreen); // 휴지통으로 이동 +router.delete("/screens/bulk/delete", bulkDeleteScreens); // 활성 화면 일괄 삭제 (휴지통으로 이동) router.get("/screens/:id/linked-modals", detectLinkedScreens); // 연결된 모달 화면 감지 router.post("/screens/check-duplicate-name", checkDuplicateScreenName); // 화면명 중복 체크 router.post("/screens/:id/copy", copyScreen); // 단일 화면 복사 (하위 호환용) 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 63622951..2632a6e6 100644 --- a/backend-node/src/services/externalRestApiConnectionService.ts +++ b/backend-node/src/services/externalRestApiConnectionService.ts @@ -1094,4 +1094,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/menuCopyService.ts b/backend-node/src/services/menuCopyService.ts index 70b45af4..a0e707c1 100644 --- a/backend-node/src/services/menuCopyService.ts +++ b/backend-node/src/services/menuCopyService.ts @@ -53,6 +53,7 @@ interface ScreenDefinition { layout_metadata: any; db_source_type: string | null; db_connection_id: number | null; + source_screen_id: number | null; // 원본 화면 ID (복사 추적용) } /** @@ -234,6 +235,27 @@ export class MenuCopyService { } } } + + // 4) 화면 분할 패널 (screen-split-panel: leftScreenId, rightScreenId) + if (props?.componentConfig?.leftScreenId) { + const leftScreenId = props.componentConfig.leftScreenId; + const numId = + typeof leftScreenId === "number" ? leftScreenId : parseInt(leftScreenId); + if (!isNaN(numId) && numId > 0) { + referenced.push(numId); + logger.debug(` 📐 분할 패널 좌측 화면 참조 발견: ${numId}`); + } + } + + if (props?.componentConfig?.rightScreenId) { + const rightScreenId = props.componentConfig.rightScreenId; + const numId = + typeof rightScreenId === "number" ? rightScreenId : parseInt(rightScreenId); + if (!isNaN(numId) && numId > 0) { + referenced.push(numId); + logger.debug(` 📐 분할 패널 우측 화면 참조 발견: ${numId}`); + } + } } return referenced; @@ -431,14 +453,16 @@ export class MenuCopyService { const value = obj[key]; const currentPath = path ? `${path}.${key}` : key; - // screen_id, screenId, targetScreenId 매핑 (숫자 또는 숫자 문자열) + // screen_id, screenId, targetScreenId, leftScreenId, rightScreenId 매핑 (숫자 또는 숫자 문자열) if ( key === "screen_id" || key === "screenId" || - key === "targetScreenId" + key === "targetScreenId" || + key === "leftScreenId" || + key === "rightScreenId" ) { const numValue = typeof value === "number" ? value : parseInt(value); - if (!isNaN(numValue)) { + if (!isNaN(numValue) && numValue > 0) { const newId = screenIdMap.get(numValue); if (newId) { obj[key] = typeof value === "number" ? newId : String(newId); // 원래 타입 유지 @@ -856,7 +880,10 @@ export class MenuCopyService { } /** - * 화면 복사 + * 화면 복사 (업데이트 또는 신규 생성) + * - source_screen_id로 기존 복사본 찾기 + * - 변경된 내용이 있으면 업데이트 + * - 없으면 새로 복사 */ private async copyScreens( screenIds: Set, @@ -876,18 +903,19 @@ export class MenuCopyService { return screenIdMap; } - logger.info(`📄 화면 복사 중: ${screenIds.size}개`); + logger.info(`📄 화면 복사/업데이트 중: ${screenIds.size}개`); - // === 1단계: 모든 screen_definitions 먼저 복사 (screenIdMap 생성) === + // === 1단계: 모든 screen_definitions 처리 (screenIdMap 생성) === const screenDefsToProcess: Array<{ originalScreenId: number; - newScreenId: number; + targetScreenId: number; screenDef: ScreenDefinition; + isUpdate: boolean; // 업데이트인지 신규 생성인지 }> = []; for (const originalScreenId of screenIds) { try { - // 1) screen_definitions 조회 + // 1) 원본 screen_definitions 조회 const screenDefResult = await client.query( `SELECT * FROM screen_definitions WHERE screen_id = $1`, [originalScreenId] @@ -900,122 +928,198 @@ export class MenuCopyService { const screenDef = screenDefResult.rows[0]; - // 2) 중복 체크: 같은 screen_code가 대상 회사에 이미 있는지 확인 - const existingScreenResult = await client.query<{ screen_id: number }>( - `SELECT screen_id FROM screen_definitions - WHERE screen_code = $1 AND company_code = $2 AND deleted_date IS NULL + // 2) 기존 복사본 찾기: source_screen_id로 검색 + const existingCopyResult = await client.query<{ + screen_id: number; + screen_name: string; + updated_date: Date; + }>( + `SELECT screen_id, screen_name, updated_date + FROM screen_definitions + WHERE source_screen_id = $1 AND company_code = $2 AND deleted_date IS NULL LIMIT 1`, - [screenDef.screen_code, targetCompanyCode] + [originalScreenId, targetCompanyCode] ); - if (existingScreenResult.rows.length > 0) { - // 이미 존재하는 화면 - 복사하지 않고 기존 ID 매핑 - const existingScreenId = existingScreenResult.rows[0].screen_id; - screenIdMap.set(originalScreenId, existingScreenId); - logger.info( - ` ⏭️ 화면 이미 존재 (스킵): ${originalScreenId} → ${existingScreenId} (${screenDef.screen_code})` - ); - continue; // 레이아웃 복사도 스킵 - } - - // 3) 새 screen_code 생성 - const newScreenCode = await this.generateUniqueScreenCode( - targetCompanyCode, - client - ); - - // 4) 화면명 변환 적용 + // 3) 화면명 변환 적용 let transformedScreenName = screenDef.screen_name; if (screenNameConfig) { - // 1. 제거할 텍스트 제거 if (screenNameConfig.removeText?.trim()) { transformedScreenName = transformedScreenName.replace( new RegExp(screenNameConfig.removeText.trim(), "g"), "" ); - transformedScreenName = transformedScreenName.trim(); // 앞뒤 공백 제거 + transformedScreenName = transformedScreenName.trim(); } - - // 2. 접두사 추가 if (screenNameConfig.addPrefix?.trim()) { transformedScreenName = screenNameConfig.addPrefix.trim() + " " + transformedScreenName; } } - // 5) screen_definitions 복사 (deleted 필드는 NULL로 설정, 삭제된 화면도 활성화) - const newScreenResult = await client.query<{ screen_id: number }>( - `INSERT INTO screen_definitions ( - screen_name, screen_code, table_name, company_code, - description, is_active, layout_metadata, - db_source_type, db_connection_id, created_by, - deleted_date, deleted_by, delete_reason - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) - RETURNING screen_id`, - [ - transformedScreenName, // 변환된 화면명 - newScreenCode, // 새 화면 코드 - screenDef.table_name, - targetCompanyCode, // 새 회사 코드 - screenDef.description, - screenDef.is_active === "D" ? "Y" : screenDef.is_active, // 삭제된 화면은 활성화 - screenDef.layout_metadata, - screenDef.db_source_type, - screenDef.db_connection_id, - userId, - null, // deleted_date: NULL (새 화면은 삭제되지 않음) - null, // deleted_by: NULL - null, // delete_reason: NULL - ] - ); + if (existingCopyResult.rows.length > 0) { + // === 기존 복사본이 있는 경우: 업데이트 === + const existingScreen = existingCopyResult.rows[0]; + const existingScreenId = existingScreen.screen_id; - const newScreenId = newScreenResult.rows[0].screen_id; - screenIdMap.set(originalScreenId, newScreenId); + // 원본 레이아웃 조회 + const sourceLayoutsResult = await client.query( + `SELECT * FROM screen_layouts WHERE screen_id = $1 ORDER BY display_order`, + [originalScreenId] + ); - logger.info( - ` ✅ 화면 정의 복사: ${originalScreenId} → ${newScreenId} (${screenDef.screen_name})` - ); + // 대상 레이아웃 조회 + const targetLayoutsResult = await client.query( + `SELECT * FROM screen_layouts WHERE screen_id = $1 ORDER BY display_order`, + [existingScreenId] + ); - // 저장해서 2단계에서 처리 - screenDefsToProcess.push({ originalScreenId, newScreenId, screenDef }); + // 변경 여부 확인 (레이아웃 개수 또는 내용 비교) + const hasChanges = this.hasLayoutChanges( + sourceLayoutsResult.rows, + targetLayoutsResult.rows + ); + + if (hasChanges) { + // 변경 사항이 있으면 업데이트 + logger.info( + ` 🔄 화면 업데이트 필요: ${originalScreenId} → ${existingScreenId} (${screenDef.screen_name})` + ); + + // screen_definitions 업데이트 + await client.query( + `UPDATE screen_definitions SET + screen_name = $1, + table_name = $2, + description = $3, + is_active = $4, + layout_metadata = $5, + db_source_type = $6, + db_connection_id = $7, + updated_by = $8, + updated_date = NOW() + WHERE screen_id = $9`, + [ + transformedScreenName, + screenDef.table_name, + screenDef.description, + screenDef.is_active === "D" ? "Y" : screenDef.is_active, + screenDef.layout_metadata, + screenDef.db_source_type, + screenDef.db_connection_id, + userId, + existingScreenId, + ] + ); + + screenIdMap.set(originalScreenId, existingScreenId); + screenDefsToProcess.push({ + originalScreenId, + targetScreenId: existingScreenId, + screenDef, + isUpdate: true, + }); + } else { + // 변경 사항이 없으면 스킵 + screenIdMap.set(originalScreenId, existingScreenId); + logger.info( + ` ⏭️ 화면 변경 없음 (스킵): ${originalScreenId} → ${existingScreenId} (${screenDef.screen_name})` + ); + } + } else { + // === 기존 복사본이 없는 경우: 신규 생성 === + const newScreenCode = await this.generateUniqueScreenCode( + targetCompanyCode, + client + ); + + const newScreenResult = await client.query<{ screen_id: number }>( + `INSERT INTO screen_definitions ( + screen_name, screen_code, table_name, company_code, + description, is_active, layout_metadata, + db_source_type, db_connection_id, created_by, + deleted_date, deleted_by, delete_reason, source_screen_id + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) + RETURNING screen_id`, + [ + transformedScreenName, + newScreenCode, + screenDef.table_name, + targetCompanyCode, + screenDef.description, + screenDef.is_active === "D" ? "Y" : screenDef.is_active, + screenDef.layout_metadata, + screenDef.db_source_type, + screenDef.db_connection_id, + userId, + null, + null, + null, + originalScreenId, // source_screen_id 저장 + ] + ); + + const newScreenId = newScreenResult.rows[0].screen_id; + screenIdMap.set(originalScreenId, newScreenId); + + logger.info( + ` ✅ 화면 신규 복사: ${originalScreenId} → ${newScreenId} (${screenDef.screen_name})` + ); + + screenDefsToProcess.push({ + originalScreenId, + targetScreenId: newScreenId, + screenDef, + isUpdate: false, + }); + } } catch (error: any) { logger.error( - `❌ 화면 정의 복사 실패: screen_id=${originalScreenId}`, + `❌ 화면 처리 실패: screen_id=${originalScreenId}`, error ); throw error; } } - // === 2단계: screen_layouts 복사 (이제 screenIdMap이 완성됨) === + // === 2단계: screen_layouts 처리 (이제 screenIdMap이 완성됨) === logger.info( - `\n📐 레이아웃 복사 시작 (screenIdMap 완성: ${screenIdMap.size}개)` + `\n📐 레이아웃 처리 시작 (screenIdMap 완성: ${screenIdMap.size}개)` ); for (const { originalScreenId, - newScreenId, + targetScreenId, screenDef, + isUpdate, } of screenDefsToProcess) { try { - // screen_layouts 복사 + // 원본 레이아웃 조회 const layoutsResult = await client.query( `SELECT * FROM screen_layouts WHERE screen_id = $1 ORDER BY display_order`, [originalScreenId] ); - // 1단계: component_id 매핑 생성 (원본 → 새 ID) + if (isUpdate) { + // 업데이트: 기존 레이아웃 삭제 후 새로 삽입 + await client.query( + `DELETE FROM screen_layouts WHERE screen_id = $1`, + [targetScreenId] + ); + logger.info(` ↳ 기존 레이아웃 삭제 (업데이트 준비)`); + } + + // component_id 매핑 생성 (원본 → 새 ID) const componentIdMap = new Map(); for (const layout of layoutsResult.rows) { const newComponentId = `comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; componentIdMap.set(layout.component_id, newComponentId); } - // 2단계: screen_layouts 복사 (parent_id, zone_id도 매핑) + // 레이아웃 삽입 for (const layout of layoutsResult.rows) { const newComponentId = componentIdMap.get(layout.component_id)!; - // parent_id와 zone_id 매핑 (다른 컴포넌트를 참조하는 경우) const newParentId = layout.parent_id ? componentIdMap.get(layout.parent_id) || layout.parent_id : null; @@ -1023,7 +1127,6 @@ export class MenuCopyService { ? componentIdMap.get(layout.zone_id) || layout.zone_id : null; - // properties 내부 참조 업데이트 const updatedProperties = this.updateReferencesInProperties( layout.properties, screenIdMap, @@ -1037,38 +1140,94 @@ export class MenuCopyService { display_order, layout_type, layout_config, zones_config, zone_id ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`, [ - newScreenId, // 새 화면 ID + targetScreenId, layout.component_type, - newComponentId, // 새 컴포넌트 ID - newParentId, // 매핑된 parent_id + newComponentId, + newParentId, layout.position_x, layout.position_y, layout.width, layout.height, - updatedProperties, // 업데이트된 속성 + updatedProperties, layout.display_order, layout.layout_type, layout.layout_config, layout.zones_config, - newZoneId, // 매핑된 zone_id + newZoneId, ] ); } - logger.info(` ↳ 레이아웃 복사: ${layoutsResult.rows.length}개`); + const action = isUpdate ? "업데이트" : "복사"; + logger.info(` ↳ 레이아웃 ${action}: ${layoutsResult.rows.length}개`); } catch (error: any) { logger.error( - `❌ 레이아웃 복사 실패: screen_id=${originalScreenId}`, + `❌ 레이아웃 처리 실패: screen_id=${originalScreenId}`, error ); throw error; } } - logger.info(`\n✅ 화면 복사 완료: ${screenIdMap.size}개`); + // 통계 출력 + const newCount = screenDefsToProcess.filter((s) => !s.isUpdate).length; + const updateCount = screenDefsToProcess.filter((s) => s.isUpdate).length; + const skipCount = screenIds.size - screenDefsToProcess.length; + + logger.info(` +✅ 화면 처리 완료: + - 신규 복사: ${newCount}개 + - 업데이트: ${updateCount}개 + - 스킵 (변경 없음): ${skipCount}개 + - 총 매핑: ${screenIdMap.size}개 + `); + return screenIdMap; } + /** + * 레이아웃 변경 여부 확인 + */ + private hasLayoutChanges( + sourceLayouts: ScreenLayout[], + targetLayouts: ScreenLayout[] + ): boolean { + // 1. 레이아웃 개수가 다르면 변경됨 + if (sourceLayouts.length !== targetLayouts.length) { + return true; + } + + // 2. 각 레이아웃의 주요 속성 비교 + for (let i = 0; i < sourceLayouts.length; i++) { + const source = sourceLayouts[i]; + const target = targetLayouts[i]; + + // component_type이 다르면 변경됨 + if (source.component_type !== target.component_type) { + return true; + } + + // 위치/크기가 다르면 변경됨 + if ( + source.position_x !== target.position_x || + source.position_y !== target.position_y || + source.width !== target.width || + source.height !== target.height + ) { + return true; + } + + // properties의 JSON 문자열 비교 (깊은 비교) + const sourceProps = JSON.stringify(source.properties || {}); + const targetProps = JSON.stringify(target.properties || {}); + if (sourceProps !== targetProps) { + return true; + } + } + + return false; + } + /** * 메뉴 위상 정렬 (부모 먼저) */ 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..6628cf4c 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, + }); } /** @@ -861,6 +892,134 @@ export class ScreenManagementService { }; } + /** + * 활성 화면 일괄 삭제 (휴지통으로 이동) + */ + async bulkDeleteScreens( + screenIds: number[], + userCompanyCode: string, + deletedBy: string, + deleteReason?: string, + force: boolean = false + ): Promise<{ + deletedCount: number; + skippedCount: number; + errors: Array<{ screenId: number; error: string }>; + }> { + if (screenIds.length === 0) { + throw new Error("삭제할 화면을 선택해주세요."); + } + + let deletedCount = 0; + let skippedCount = 0; + const errors: Array<{ screenId: number; error: string }> = []; + + // 각 화면을 개별적으로 삭제 처리 + for (const screenId of screenIds) { + try { + // 권한 확인 (Raw Query) + const existingResult = await query<{ + company_code: string | null; + is_active: string; + screen_name: string; + }>( + `SELECT company_code, is_active, screen_name FROM screen_definitions WHERE screen_id = $1 LIMIT 1`, + [screenId] + ); + + if (existingResult.length === 0) { + skippedCount++; + errors.push({ + screenId, + error: "화면을 찾을 수 없습니다.", + }); + continue; + } + + const existingScreen = existingResult[0]; + + // 권한 확인 + if ( + userCompanyCode !== "*" && + existingScreen.company_code !== userCompanyCode + ) { + skippedCount++; + errors.push({ + screenId, + error: "이 화면을 삭제할 권한이 없습니다.", + }); + continue; + } + + // 이미 삭제된 화면인지 확인 + if (existingScreen.is_active === "D") { + skippedCount++; + errors.push({ + screenId, + error: "이미 삭제된 화면입니다.", + }); + continue; + } + + // 강제 삭제가 아닌 경우 의존성 체크 + if (!force) { + const dependencyCheck = await this.checkScreenDependencies( + screenId, + userCompanyCode + ); + if (dependencyCheck.hasDependencies) { + skippedCount++; + errors.push({ + screenId, + error: `다른 화면에서 사용 중 (${dependencyCheck.dependencies.length}개 참조)`, + }); + continue; + } + } + + // 트랜잭션으로 화면 삭제와 메뉴 할당 정리를 함께 처리 + await transaction(async (client) => { + const now = new Date(); + + // 소프트 삭제 (휴지통으로 이동) + await client.query( + `UPDATE screen_definitions + SET is_active = 'D', + deleted_date = $1, + deleted_by = $2, + delete_reason = $3, + updated_date = $4, + updated_by = $5 + WHERE screen_id = $6`, + [now, deletedBy, deleteReason || null, now, deletedBy, screenId] + ); + + // 메뉴 할당 정리 (삭제된 화면의 메뉴 할당 제거) + await client.query( + `DELETE FROM screen_menu_assignments WHERE screen_id = $1`, + [screenId] + ); + }); + + deletedCount++; + logger.info(`화면 삭제 완료: ${screenId} (${existingScreen.screen_name})`); + } catch (error) { + skippedCount++; + errors.push({ + screenId, + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + logger.error(`화면 삭제 실패: ${screenId}`, error); + } + } + + logger.info( + `일괄 삭제 완료: 성공 ${deletedCount}개, 실패 ${skippedCount}개` + ); + + return { deletedCount, skippedCount, errors }; + } + /** * 휴지통 화면 일괄 영구 삭제 */ @@ -1486,11 +1645,23 @@ export class ScreenManagementService { }; } + // 🔥 최신 inputType 정보 조회 (table_type_columns에서) + const inputTypeMap = await this.getLatestInputTypes(componentLayouts, companyCode); + const components: ComponentData[] = componentLayouts.map((layout) => { const properties = layout.properties as any; + + // 🔥 최신 inputType으로 widgetType 및 componentType 업데이트 + const tableName = properties?.tableName; + const columnName = properties?.columnName; + const latestTypeInfo = tableName && columnName + ? inputTypeMap.get(`${tableName}.${columnName}`) + : null; + const component = { id: layout.component_id, - type: layout.component_type as any, + // 🔥 최신 componentType이 있으면 type 덮어쓰기 + type: latestTypeInfo?.componentType || layout.component_type as any, position: { x: layout.position_x, y: layout.position_y, @@ -1499,6 +1670,17 @@ export class ScreenManagementService { size: { width: layout.width, height: layout.height }, parentId: layout.parent_id, ...properties, + // 🔥 최신 inputType이 있으면 widgetType, componentType 덮어쓰기 + ...(latestTypeInfo && { + widgetType: latestTypeInfo.inputType, + inputType: latestTypeInfo.inputType, + componentType: latestTypeInfo.componentType, + componentConfig: { + ...properties?.componentConfig, + type: latestTypeInfo.componentType, + inputType: latestTypeInfo.inputType, + }, + }), }; console.log(`로드된 컴포넌트:`, { @@ -1508,6 +1690,9 @@ export class ScreenManagementService { size: component.size, parentId: component.parentId, title: (component as any).title, + widgetType: (component as any).widgetType, + componentType: (component as any).componentType, + latestTypeInfo, }); return component; @@ -1527,6 +1712,112 @@ export class ScreenManagementService { }; } + /** + * 입력 타입에 해당하는 컴포넌트 ID 반환 + * (프론트엔드 webTypeMapping.ts와 동일한 매핑) + */ + private getComponentIdFromInputType(inputType: string): string { + const mapping: Record = { + // 텍스트 입력 + text: "text-input", + email: "text-input", + password: "text-input", + tel: "text-input", + // 숫자 입력 + number: "number-input", + decimal: "number-input", + // 날짜/시간 + date: "date-input", + datetime: "date-input", + time: "date-input", + // 텍스트 영역 + textarea: "textarea-basic", + // 선택 + select: "select-basic", + dropdown: "select-basic", + // 체크박스/라디오 + checkbox: "checkbox-basic", + radio: "radio-basic", + boolean: "toggle-switch", + // 파일 + file: "file-upload", + // 이미지 + image: "image-widget", + img: "image-widget", + picture: "image-widget", + photo: "image-widget", + // 버튼 + button: "button-primary", + // 기타 + label: "text-display", + code: "select-basic", + entity: "select-basic", + category: "select-basic", + }; + + return mapping[inputType] || "text-input"; + } + + /** + * 컴포넌트들의 최신 inputType 정보 조회 + * @param layouts - 레이아웃 목록 + * @param companyCode - 회사 코드 + * @returns Map<"tableName.columnName", { inputType, componentType }> + */ + private async getLatestInputTypes( + layouts: any[], + companyCode: string + ): Promise> { + const inputTypeMap = new Map(); + + // tableName과 columnName이 있는 컴포넌트들의 고유 조합 추출 + const tableColumnPairs = new Set(); + for (const layout of layouts) { + const properties = layout.properties as any; + if (properties?.tableName && properties?.columnName) { + tableColumnPairs.add(`${properties.tableName}|${properties.columnName}`); + } + } + + if (tableColumnPairs.size === 0) { + return inputTypeMap; + } + + // 각 테이블-컬럼 조합에 대해 최신 inputType 조회 + const pairs = Array.from(tableColumnPairs).map(pair => { + const [tableName, columnName] = pair.split('|'); + return { tableName, columnName }; + }); + + // 배치 쿼리로 한 번에 조회 + const placeholders = pairs.map((_, i) => `($${i * 2 + 1}, $${i * 2 + 2})`).join(', '); + const params = pairs.flatMap(p => [p.tableName, p.columnName]); + + try { + const results = await query<{ table_name: string; column_name: string; input_type: string }>( + `SELECT table_name, column_name, input_type + FROM table_type_columns + WHERE (table_name, column_name) IN (${placeholders}) + AND company_code = $${params.length + 1}`, + [...params, companyCode] + ); + + for (const row of results) { + const componentType = this.getComponentIdFromInputType(row.input_type); + inputTypeMap.set(`${row.table_name}.${row.column_name}`, { + inputType: row.input_type, + componentType: componentType, + }); + } + + console.log(`최신 inputType 조회 완료: ${results.length}개`); + } catch (error) { + console.warn(`최신 inputType 조회 실패 (무시됨):`, error); + } + + return inputTypeMap; + } + // ======================================== // 템플릿 관리 // ======================================== @@ -2016,37 +2307,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..8e01903b 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -797,6 +797,9 @@ export class TableManagementService { ] ); + // 🔥 해당 컬럼을 사용하는 화면 레이아웃의 widgetType도 업데이트 + await this.syncScreenLayoutsInputType(tableName, columnName, inputType, companyCode); + // 🔥 캐시 무효화: 해당 테이블의 컬럼 캐시 삭제 const cacheKeyPattern = `${CacheKeys.TABLE_COLUMNS(tableName, 1, 1000)}_${companyCode}`; cache.delete(cacheKeyPattern); @@ -816,6 +819,135 @@ export class TableManagementService { } } + /** + * 입력 타입에 해당하는 컴포넌트 ID 반환 + * (프론트엔드 webTypeMapping.ts와 동일한 매핑) + */ + private getComponentIdFromInputType(inputType: string): string { + const mapping: Record = { + // 텍스트 입력 + text: "text-input", + email: "text-input", + password: "text-input", + tel: "text-input", + // 숫자 입력 + number: "number-input", + decimal: "number-input", + // 날짜/시간 + date: "date-input", + datetime: "date-input", + time: "date-input", + // 텍스트 영역 + textarea: "textarea-basic", + // 선택 + select: "select-basic", + dropdown: "select-basic", + // 체크박스/라디오 + checkbox: "checkbox-basic", + radio: "radio-basic", + boolean: "toggle-switch", + // 파일 + file: "file-upload", + // 이미지 + image: "image-widget", + img: "image-widget", + picture: "image-widget", + photo: "image-widget", + // 버튼 + button: "button-primary", + // 기타 + label: "text-display", + code: "select-basic", + entity: "select-basic", + category: "select-basic", + }; + + return mapping[inputType] || "text-input"; + } + + /** + * 컬럼 입력 타입 변경 시 해당 컬럼을 사용하는 화면 레이아웃의 widgetType 및 componentType 동기화 + * @param tableName - 테이블명 + * @param columnName - 컬럼명 + * @param inputType - 새로운 입력 타입 + * @param companyCode - 회사 코드 + */ + private async syncScreenLayoutsInputType( + tableName: string, + columnName: string, + inputType: string, + companyCode: string + ): Promise { + try { + // 해당 컬럼을 사용하는 화면 레이아웃 조회 + const affectedLayouts = await query<{ + layout_id: number; + screen_id: number; + component_id: string; + component_type: string; + properties: any; + }>( + `SELECT sl.layout_id, sl.screen_id, sl.component_id, sl.component_type, sl.properties + FROM screen_layouts sl + JOIN screen_definitions sd ON sl.screen_id = sd.screen_id + WHERE sl.properties->>'tableName' = $1 + AND sl.properties->>'columnName' = $2 + AND (sd.company_code = $3 OR $3 = '*')`, + [tableName, columnName, companyCode] + ); + + if (affectedLayouts.length === 0) { + logger.info( + `화면 레이아웃 동기화: ${tableName}.${columnName}을 사용하는 화면 없음` + ); + return; + } + + logger.info( + `화면 레이아웃 동기화 시작: ${affectedLayouts.length}개 컴포넌트 발견` + ); + + // 새로운 componentType 계산 + const newComponentType = this.getComponentIdFromInputType(inputType); + + // 각 레이아웃의 widgetType, componentType 업데이트 + for (const layout of affectedLayouts) { + const updatedProperties = { + ...layout.properties, + widgetType: inputType, + inputType: inputType, + // componentConfig 내부의 type도 업데이트 + componentConfig: { + ...layout.properties?.componentConfig, + type: newComponentType, + inputType: inputType, + }, + }; + + await query( + `UPDATE screen_layouts + SET properties = $1, component_type = $2 + WHERE layout_id = $3`, + [JSON.stringify(updatedProperties), newComponentType, layout.layout_id] + ); + + logger.info( + `화면 레이아웃 업데이트: screen_id=${layout.screen_id}, component_id=${layout.component_id}, widgetType=${inputType}, componentType=${newComponentType}` + ); + } + + logger.info( + `화면 레이아웃 동기화 완료: ${affectedLayouts.length}개 컴포넌트 업데이트됨` + ); + } catch (error) { + // 화면 레이아웃 동기화 실패는 치명적이지 않으므로 로그만 남기고 계속 진행 + logger.warn( + `화면 레이아웃 동기화 실패 (무시됨): ${tableName}.${columnName}`, + error + ); + } + } + /** * 입력 타입별 기본 상세 설정 생성 */ @@ -1516,6 +1648,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 +4222,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}
+
+ ))} +
+
+
+
+
+

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

+
+ )}