diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 87470dd6..104a7fbe 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -72,6 +72,7 @@ import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙 import entitySearchRoutes from "./routes/entitySearchRoutes"; // 엔티티 검색 import orderRoutes from "./routes/orderRoutes"; // 수주 관리 import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달 +import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리 import { BatchSchedulerService } from "./services/batchSchedulerService"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석 @@ -238,6 +239,7 @@ app.use("/api/numbering-rules", numberingRuleRoutes); // 채번 규칙 관리 app.use("/api/entity-search", entitySearchRoutes); // 엔티티 검색 app.use("/api/orders", orderRoutes); // 수주 관리 app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달 +app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리 // app.use("/api/collections", collectionRoutes); // 임시 주석 // app.use("/api/batch", batchRoutes); // 임시 주석 // app.use('/api/users', userRoutes); diff --git a/backend-node/src/controllers/DashboardController.ts b/backend-node/src/controllers/DashboardController.ts index 76b666f0..e324c332 100644 --- a/backend-node/src/controllers/DashboardController.ts +++ b/backend-node/src/controllers/DashboardController.ts @@ -708,6 +708,12 @@ export class DashboardController { }); } + // 기상청 API 등 EUC-KR 인코딩을 사용하는 경우 arraybuffer로 받아서 디코딩 + const isKmaApi = urlObj.hostname.includes('kma.go.kr'); + if (isKmaApi) { + requestConfig.responseType = 'arraybuffer'; + } + const response = await axios(requestConfig); if (response.status >= 400) { @@ -719,8 +725,24 @@ export class DashboardController { let data = response.data; const contentType = response.headers["content-type"]; + // 기상청 API 인코딩 처리 (UTF-8 우선, 실패 시 EUC-KR) + if (isKmaApi && Buffer.isBuffer(data)) { + const iconv = require('iconv-lite'); + const buffer = Buffer.from(data); + const utf8Text = buffer.toString('utf-8'); + + // UTF-8로 정상 디코딩되었는지 확인 + if (utf8Text.includes('특보') || utf8Text.includes('경보') || utf8Text.includes('주의보') || + (utf8Text.includes('#START7777') && !utf8Text.includes('�'))) { + data = { text: utf8Text, contentType, encoding: 'utf-8' }; + } else { + // EUC-KR로 디코딩 + const eucKrText = iconv.decode(buffer, 'EUC-KR'); + data = { text: eucKrText, contentType, encoding: 'euc-kr' }; + } + } // 텍스트 응답인 경우 포맷팅 - if (typeof data === "string") { + else if (typeof data === "string") { data = { text: data, contentType }; } diff --git a/backend-node/src/controllers/vehicleReportController.ts b/backend-node/src/controllers/vehicleReportController.ts new file mode 100644 index 00000000..db17dd24 --- /dev/null +++ b/backend-node/src/controllers/vehicleReportController.ts @@ -0,0 +1,206 @@ +/** + * 차량 운행 리포트 컨트롤러 + */ +import { Response } from "express"; +import { AuthenticatedRequest } from "../middleware/authMiddleware"; +import { vehicleReportService } from "../services/vehicleReportService"; + +/** + * 일별 통계 조회 + * GET /api/vehicle/reports/daily + */ +export const getDailyReport = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode } = req.user as any; + const { startDate, endDate, userId, vehicleId } = req.query; + + console.log("📊 [getDailyReport] 요청:", { companyCode, startDate, endDate }); + + const result = await vehicleReportService.getDailyReport(companyCode, { + startDate: startDate as string, + endDate: endDate as string, + userId: userId as string, + vehicleId: vehicleId ? parseInt(vehicleId as string) : undefined, + }); + + res.json({ + success: true, + data: result, + }); + } catch (error: any) { + console.error("❌ [getDailyReport] 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "일별 통계 조회에 실패했습니다.", + }); + } +}; + +/** + * 주별 통계 조회 + * GET /api/vehicle/reports/weekly + */ +export const getWeeklyReport = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode } = req.user as any; + const { year, month, userId, vehicleId } = req.query; + + console.log("📊 [getWeeklyReport] 요청:", { companyCode, year, month }); + + const result = await vehicleReportService.getWeeklyReport(companyCode, { + year: year ? parseInt(year as string) : new Date().getFullYear(), + month: month ? parseInt(month as string) : new Date().getMonth() + 1, + userId: userId as string, + vehicleId: vehicleId ? parseInt(vehicleId as string) : undefined, + }); + + res.json({ + success: true, + data: result, + }); + } catch (error: any) { + console.error("❌ [getWeeklyReport] 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "주별 통계 조회에 실패했습니다.", + }); + } +}; + +/** + * 월별 통계 조회 + * GET /api/vehicle/reports/monthly + */ +export const getMonthlyReport = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode } = req.user as any; + const { year, userId, vehicleId } = req.query; + + console.log("📊 [getMonthlyReport] 요청:", { companyCode, year }); + + const result = await vehicleReportService.getMonthlyReport(companyCode, { + year: year ? parseInt(year as string) : new Date().getFullYear(), + userId: userId as string, + vehicleId: vehicleId ? parseInt(vehicleId as string) : undefined, + }); + + res.json({ + success: true, + data: result, + }); + } catch (error: any) { + console.error("❌ [getMonthlyReport] 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "월별 통계 조회에 실패했습니다.", + }); + } +}; + +/** + * 요약 통계 조회 (대시보드용) + * GET /api/vehicle/reports/summary + */ +export const getSummaryReport = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode } = req.user as any; + const { period } = req.query; // today, week, month, year + + console.log("📊 [getSummaryReport] 요청:", { companyCode, period }); + + const result = await vehicleReportService.getSummaryReport( + companyCode, + (period as string) || "today" + ); + + res.json({ + success: true, + data: result, + }); + } catch (error: any) { + console.error("❌ [getSummaryReport] 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "요약 통계 조회에 실패했습니다.", + }); + } +}; + +/** + * 운전자별 통계 조회 + * GET /api/vehicle/reports/by-driver + */ +export const getDriverReport = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode } = req.user as any; + const { startDate, endDate, limit } = req.query; + + console.log("📊 [getDriverReport] 요청:", { companyCode, startDate, endDate }); + + const result = await vehicleReportService.getDriverReport(companyCode, { + startDate: startDate as string, + endDate: endDate as string, + limit: limit ? parseInt(limit as string) : 10, + }); + + res.json({ + success: true, + data: result, + }); + } catch (error: any) { + console.error("❌ [getDriverReport] 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "운전자별 통계 조회에 실패했습니다.", + }); + } +}; + +/** + * 구간별 통계 조회 + * GET /api/vehicle/reports/by-route + */ +export const getRouteReport = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode } = req.user as any; + const { startDate, endDate, limit } = req.query; + + console.log("📊 [getRouteReport] 요청:", { companyCode, startDate, endDate }); + + const result = await vehicleReportService.getRouteReport(companyCode, { + startDate: startDate as string, + endDate: endDate as string, + limit: limit ? parseInt(limit as string) : 10, + }); + + res.json({ + success: true, + data: result, + }); + } catch (error: any) { + console.error("❌ [getRouteReport] 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "구간별 통계 조회에 실패했습니다.", + }); + } +}; + diff --git a/backend-node/src/controllers/vehicleTripController.ts b/backend-node/src/controllers/vehicleTripController.ts new file mode 100644 index 00000000..d1604ede --- /dev/null +++ b/backend-node/src/controllers/vehicleTripController.ts @@ -0,0 +1,301 @@ +/** + * 차량 운행 이력 컨트롤러 + */ +import { Response } from "express"; +import { AuthenticatedRequest } from "../middleware/authMiddleware"; +import { vehicleTripService } from "../services/vehicleTripService"; + +/** + * 운행 시작 + * POST /api/vehicle/trip/start + */ +export const startTrip = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode, userId } = req.user as any; + const { vehicleId, departure, arrival, departureName, destinationName, latitude, longitude } = req.body; + + console.log("🚗 [startTrip] 요청:", { userId, companyCode, departure, arrival }); + + if (latitude === undefined || longitude === undefined) { + return res.status(400).json({ + success: false, + message: "위치 정보(latitude, longitude)가 필요합니다.", + }); + } + + const result = await vehicleTripService.startTrip({ + userId, + companyCode, + vehicleId, + departure, + arrival, + departureName, + destinationName, + latitude, + longitude, + }); + + console.log("✅ [startTrip] 성공:", result); + + res.json({ + success: true, + data: result, + message: "운행이 시작되었습니다.", + }); + } catch (error: any) { + console.error("❌ [startTrip] 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "운행 시작에 실패했습니다.", + }); + } +}; + +/** + * 운행 종료 + * POST /api/vehicle/trip/end + */ +export const endTrip = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode, userId } = req.user as any; + const { tripId, latitude, longitude } = req.body; + + console.log("🚗 [endTrip] 요청:", { userId, companyCode, tripId }); + + if (!tripId) { + return res.status(400).json({ + success: false, + message: "tripId가 필요합니다.", + }); + } + + if (latitude === undefined || longitude === undefined) { + return res.status(400).json({ + success: false, + message: "위치 정보(latitude, longitude)가 필요합니다.", + }); + } + + const result = await vehicleTripService.endTrip({ + tripId, + userId, + companyCode, + latitude, + longitude, + }); + + console.log("✅ [endTrip] 성공:", result); + + res.json({ + success: true, + data: result, + message: "운행이 종료되었습니다.", + }); + } catch (error: any) { + console.error("❌ [endTrip] 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "운행 종료에 실패했습니다.", + }); + } +}; + +/** + * 위치 기록 추가 (연속 추적) + * POST /api/vehicle/trip/location + */ +export const addTripLocation = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode, userId } = req.user as any; + const { tripId, latitude, longitude, accuracy, speed } = req.body; + + if (!tripId) { + return res.status(400).json({ + success: false, + message: "tripId가 필요합니다.", + }); + } + + if (latitude === undefined || longitude === undefined) { + return res.status(400).json({ + success: false, + message: "위치 정보(latitude, longitude)가 필요합니다.", + }); + } + + const result = await vehicleTripService.addLocation({ + tripId, + userId, + companyCode, + latitude, + longitude, + accuracy, + speed, + }); + + res.json({ + success: true, + data: result, + }); + } catch (error: any) { + console.error("❌ [addTripLocation] 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "위치 기록에 실패했습니다.", + }); + } +}; + +/** + * 운행 이력 목록 조회 + * GET /api/vehicle/trips + */ +export const getTripList = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode } = req.user as any; + const { userId, vehicleId, status, startDate, endDate, departure, arrival, limit, offset } = req.query; + + console.log("🚗 [getTripList] 요청:", { companyCode, userId, status, startDate, endDate }); + + const result = await vehicleTripService.getTripList(companyCode, { + userId: userId as string, + vehicleId: vehicleId ? parseInt(vehicleId as string) : undefined, + status: status as string, + startDate: startDate as string, + endDate: endDate as string, + departure: departure as string, + arrival: arrival as string, + limit: limit ? parseInt(limit as string) : 50, + offset: offset ? parseInt(offset as string) : 0, + }); + + res.json({ + success: true, + data: result.data, + total: result.total, + }); + } catch (error: any) { + console.error("❌ [getTripList] 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "운행 이력 조회에 실패했습니다.", + }); + } +}; + +/** + * 운행 상세 조회 (경로 포함) + * GET /api/vehicle/trips/:tripId + */ +export const getTripDetail = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode } = req.user as any; + const { tripId } = req.params; + + console.log("🚗 [getTripDetail] 요청:", { companyCode, tripId }); + + const result = await vehicleTripService.getTripDetail(tripId, companyCode); + + if (!result) { + return res.status(404).json({ + success: false, + message: "운행 정보를 찾을 수 없습니다.", + }); + } + + res.json({ + success: true, + data: result, + }); + } catch (error: any) { + console.error("❌ [getTripDetail] 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "운행 상세 조회에 실패했습니다.", + }); + } +}; + +/** + * 활성 운행 조회 (현재 진행 중) + * GET /api/vehicle/trip/active + */ +export const getActiveTrip = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode, userId } = req.user as any; + + const result = await vehicleTripService.getActiveTrip(userId, companyCode); + + res.json({ + success: true, + data: result, + hasActiveTrip: !!result, + }); + } catch (error: any) { + console.error("❌ [getActiveTrip] 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "활성 운행 조회에 실패했습니다.", + }); + } +}; + +/** + * 운행 취소 + * POST /api/vehicle/trip/cancel + */ +export const cancelTrip = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode } = req.user as any; + const { tripId } = req.body; + + if (!tripId) { + return res.status(400).json({ + success: false, + message: "tripId가 필요합니다.", + }); + } + + const result = await vehicleTripService.cancelTrip(tripId, companyCode); + + if (!result) { + return res.status(404).json({ + success: false, + message: "취소할 운행을 찾을 수 없습니다.", + }); + } + + res.json({ + success: true, + message: "운행이 취소되었습니다.", + }); + } catch (error: any) { + console.error("❌ [cancelTrip] 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "운행 취소에 실패했습니다.", + }); + } +}; + diff --git a/backend-node/src/routes/vehicleTripRoutes.ts b/backend-node/src/routes/vehicleTripRoutes.ts new file mode 100644 index 00000000..c70a7394 --- /dev/null +++ b/backend-node/src/routes/vehicleTripRoutes.ts @@ -0,0 +1,71 @@ +/** + * 차량 운행 이력 및 리포트 라우트 + */ +import { Router } from "express"; +import { + startTrip, + endTrip, + addTripLocation, + getTripList, + getTripDetail, + getActiveTrip, + cancelTrip, +} from "../controllers/vehicleTripController"; +import { + getDailyReport, + getWeeklyReport, + getMonthlyReport, + getSummaryReport, + getDriverReport, + getRouteReport, +} from "../controllers/vehicleReportController"; +import { authenticateToken } from "../middleware/authMiddleware"; + +const router = Router(); + +// 모든 라우트에 인증 적용 +router.use(authenticateToken); + +// === 운행 관리 === +// 운행 시작 +router.post("/trip/start", startTrip); + +// 운행 종료 +router.post("/trip/end", endTrip); + +// 위치 기록 추가 (연속 추적) +router.post("/trip/location", addTripLocation); + +// 활성 운행 조회 (현재 진행 중) +router.get("/trip/active", getActiveTrip); + +// 운행 취소 +router.post("/trip/cancel", cancelTrip); + +// 운행 이력 목록 조회 +router.get("/trips", getTripList); + +// 운행 상세 조회 (경로 포함) +router.get("/trips/:tripId", getTripDetail); + +// === 리포트 === +// 요약 통계 (대시보드용) +router.get("/reports/summary", getSummaryReport); + +// 일별 통계 +router.get("/reports/daily", getDailyReport); + +// 주별 통계 +router.get("/reports/weekly", getWeeklyReport); + +// 월별 통계 +router.get("/reports/monthly", getMonthlyReport); + +// 운전자별 통계 +router.get("/reports/by-driver", getDriverReport); + +// 구간별 통계 +router.get("/reports/by-route", getRouteReport); + +export default router; + diff --git a/backend-node/src/services/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/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/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/components/screen/config-panels/ButtonConfigPanel.tsx b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx index 1ed37a49..ba88befd 100644 --- a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx +++ b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx @@ -503,7 +503,7 @@ export const ButtonConfigPanel: React.FC = ({ 엑셀 업로드 바코드 스캔 코드 병합 - 공차등록 + {/* 공차등록 */} 운행알림 및 종료 @@ -1664,190 +1664,12 @@ export const ButtonConfigPanel: React.FC = ({ )} - {/* 위치정보 가져오기 설정 */} - {(component.componentConfig?.action?.type || "save") === "empty_vehicle" && ( + {/* 공차등록 설정 - 운행알림으로 통합되어 주석 처리 */} + {/* {(component.componentConfig?.action?.type || "save") === "empty_vehicle" && (
-

🚛 공차등록 설정

- - {/* 테이블 선택 */} -
- - -
- -
-
- - onUpdateProperty("componentConfig.action.geolocationLatField", e.target.value)} - className="h-8 text-xs" - /> -
-
- - onUpdateProperty("componentConfig.action.geolocationLngField", e.target.value)} - className="h-8 text-xs" - /> -
-
- -
-
- - onUpdateProperty("componentConfig.action.geolocationAccuracyField", e.target.value)} - className="h-8 text-xs" - /> -
-
- - onUpdateProperty("componentConfig.action.geolocationTimestampField", e.target.value)} - className="h-8 text-xs" - /> -
-
- -
-
- -

GPS 사용 (배터리 소모 증가)

-
- onUpdateProperty("componentConfig.action.geolocationHighAccuracy", checked)} - /> -
- - {/* 자동 저장 옵션 */} -
-
-
- -

위치 정보를 바로 DB에 저장

-
- onUpdateProperty("componentConfig.action.geolocationAutoSave", checked)} - /> -
- - {config.action?.geolocationAutoSave && ( -
-
-
- - onUpdateProperty("componentConfig.action.geolocationKeyField", e.target.value)} - className="h-8 text-xs" - /> -
-
- - -
-
- - {/* 추가 필드 변경 (status 등) */} -
-
- - onUpdateProperty("componentConfig.action.geolocationExtraField", e.target.value)} - className="h-8 text-xs" - /> -
-
- - onUpdateProperty("componentConfig.action.geolocationExtraValue", e.target.value)} - className="h-8 text-xs" - /> -
-
-

- 위치 정보와 함께 status 같은 필드도 변경할 수 있습니다. -

-
- )} -
- -
-

- 참고: HTTPS 환경에서만 작동합니다. -

-
+ ... 공차등록 설정 UI 생략 ...
- )} + )} */} {/* 운행알림 및 종료 설정 */} {(component.componentConfig?.action?.type || "save") === "operation_control" && ( diff --git a/frontend/components/vehicle/VehicleReport.tsx b/frontend/components/vehicle/VehicleReport.tsx new file mode 100644 index 00000000..51773c98 --- /dev/null +++ b/frontend/components/vehicle/VehicleReport.tsx @@ -0,0 +1,660 @@ +"use client"; + +import React, { useState, useEffect, useCallback } from "react"; +import { + getSummaryReport, + getDailyReport, + getMonthlyReport, + getDriverReport, + getRouteReport, + formatDistance, + formatDuration, + SummaryReport, + DailyStat, + MonthlyStat, + DriverStat, + RouteStat, +} from "@/lib/api/vehicleTrip"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + RefreshCw, + Car, + Route, + Clock, + Users, + TrendingUp, + MapPin, +} from "lucide-react"; +import { format } from "date-fns"; +import { ko } from "date-fns/locale"; + +export default function VehicleReport() { + // 요약 통계 + const [summary, setSummary] = useState(null); + const [summaryPeriod, setSummaryPeriod] = useState("month"); + const [summaryLoading, setSummaryLoading] = useState(false); + + // 일별 통계 + const [dailyData, setDailyData] = useState([]); + const [dailyStartDate, setDailyStartDate] = useState( + new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split("T")[0] + ); + const [dailyEndDate, setDailyEndDate] = useState( + new Date().toISOString().split("T")[0] + ); + const [dailyLoading, setDailyLoading] = useState(false); + + // 월별 통계 + const [monthlyData, setMonthlyData] = useState([]); + const [monthlyYear, setMonthlyYear] = useState(new Date().getFullYear()); + const [monthlyLoading, setMonthlyLoading] = useState(false); + + // 운전자별 통계 + const [driverData, setDriverData] = useState([]); + const [driverLoading, setDriverLoading] = useState(false); + + // 구간별 통계 + const [routeData, setRouteData] = useState([]); + const [routeLoading, setRouteLoading] = useState(false); + + // 요약 로드 + const loadSummary = useCallback(async () => { + setSummaryLoading(true); + try { + const response = await getSummaryReport(summaryPeriod); + if (response.success) { + setSummary(response.data); + } + } catch (error) { + console.error("요약 통계 조회 실패:", error); + } finally { + setSummaryLoading(false); + } + }, [summaryPeriod]); + + // 일별 로드 + const loadDaily = useCallback(async () => { + setDailyLoading(true); + try { + const response = await getDailyReport({ + startDate: dailyStartDate, + endDate: dailyEndDate, + }); + if (response.success) { + setDailyData(response.data?.data || []); + } + } catch (error) { + console.error("일별 통계 조회 실패:", error); + } finally { + setDailyLoading(false); + } + }, [dailyStartDate, dailyEndDate]); + + // 월별 로드 + const loadMonthly = useCallback(async () => { + setMonthlyLoading(true); + try { + const response = await getMonthlyReport({ year: monthlyYear }); + if (response.success) { + setMonthlyData(response.data?.data || []); + } + } catch (error) { + console.error("월별 통계 조회 실패:", error); + } finally { + setMonthlyLoading(false); + } + }, [monthlyYear]); + + // 운전자별 로드 + const loadDrivers = useCallback(async () => { + setDriverLoading(true); + try { + const response = await getDriverReport({ limit: 20 }); + if (response.success) { + setDriverData(response.data || []); + } + } catch (error) { + console.error("운전자별 통계 조회 실패:", error); + } finally { + setDriverLoading(false); + } + }, []); + + // 구간별 로드 + const loadRoutes = useCallback(async () => { + setRouteLoading(true); + try { + const response = await getRouteReport({ limit: 20 }); + if (response.success) { + setRouteData(response.data || []); + } + } catch (error) { + console.error("구간별 통계 조회 실패:", error); + } finally { + setRouteLoading(false); + } + }, []); + + // 초기 로드 + useEffect(() => { + loadSummary(); + }, [loadSummary]); + + // 기간 레이블 + const getPeriodLabel = (period: string) => { + switch (period) { + case "today": + return "오늘"; + case "week": + return "최근 7일"; + case "month": + return "최근 30일"; + case "year": + return "올해"; + default: + return period; + } + }; + + return ( +
+ {/* 요약 통계 카드 */} +
+
+

요약 통계

+
+ + +
+
+ + {summary && ( +
+ + +
+ + 총 운행 +
+
+ {summary.totalTrips.toLocaleString()} +
+
+ {getPeriodLabel(summaryPeriod)} +
+
+
+ + + +
+ + 완료율 +
+
+ {summary.completionRate}% +
+
+ {summary.completedTrips} / {summary.totalTrips} +
+
+
+ + + +
+ + 총 거리 +
+
+ {formatDistance(summary.totalDistance)} +
+
+ 평균 {formatDistance(summary.avgDistance)} +
+
+
+ + + +
+ + 총 시간 +
+
+ {formatDuration(summary.totalDuration)} +
+
+ 평균 {formatDuration(Math.round(summary.avgDuration))} +
+
+
+ + + +
+ + 운전자 +
+
+ {summary.activeDrivers} +
+
활동 중
+
+
+ + + +
+ + 진행 중 +
+
+ {summary.activeTrips} +
+
현재 운행
+
+
+
+ )} +
+ + {/* 상세 통계 탭 */} + + + + 일별 통계 + + + 월별 통계 + + + 운전자별 + + + 구간별 + + + + {/* 일별 통계 */} + + + +
+ 일별 운행 통계 +
+
+ + setDailyStartDate(e.target.value)} + className="h-8 w-[130px]" + /> +
+
+ + setDailyEndDate(e.target.value)} + className="h-8 w-[130px]" + /> +
+ +
+
+
+ +
+ + + + 날짜 + 운행 수 + 완료 + 취소 + 총 거리 + 평균 거리 + 총 시간 + + + + {dailyLoading ? ( + + + 로딩 중... + + + ) : dailyData.length === 0 ? ( + + + 데이터가 없습니다. + + + ) : ( + dailyData.map((row) => ( + + + {format(new Date(row.date), "MM/dd (E)", { + locale: ko, + })} + + + {row.tripCount} + + + {row.completedCount} + + + {row.cancelledCount} + + + {formatDistance(row.totalDistance)} + + + {formatDistance(row.avgDistance)} + + + {formatDuration(row.totalDuration)} + + + )) + )} + +
+
+
+
+
+ + {/* 월별 통계 */} + + + +
+ 월별 운행 통계 +
+ + +
+
+
+ +
+ + + + + 운행 수 + 완료 + 취소 + 총 거리 + 평균 거리 + 운전자 수 + + + + {monthlyLoading ? ( + + + 로딩 중... + + + ) : monthlyData.length === 0 ? ( + + + 데이터가 없습니다. + + + ) : ( + monthlyData.map((row) => ( + + {row.month}월 + + {row.tripCount} + + + {row.completedCount} + + + {row.cancelledCount} + + + {formatDistance(row.totalDistance)} + + + {formatDistance(row.avgDistance)} + + + {row.driverCount} + + + )) + )} + +
+
+
+
+
+ + {/* 운전자별 통계 */} + + + +
+ 운전자별 통계 + +
+
+ +
+ + + + 운전자 + 운행 수 + 완료 + 총 거리 + 평균 거리 + 총 시간 + + + + {driverLoading ? ( + + + 로딩 중... + + + ) : driverData.length === 0 ? ( + + + 데이터가 없습니다. + + + ) : ( + driverData.map((row) => ( + + + {row.userName} + + + {row.tripCount} + + + {row.completedCount} + + + {formatDistance(row.totalDistance)} + + + {formatDistance(row.avgDistance)} + + + {formatDuration(row.totalDuration)} + + + )) + )} + +
+
+
+
+
+ + {/* 구간별 통계 */} + + + +
+ 구간별 통계 + +
+
+ +
+ + + + +
+ + 출발지 +
+
+ +
+ + 도착지 +
+
+ 운행 수 + 총 거리 + 평균 거리 + 평균 시간 +
+
+ + {routeLoading ? ( + + + 로딩 중... + + + ) : routeData.length === 0 ? ( + + + 데이터가 없습니다. + + + ) : ( + routeData.map((row, idx) => ( + + {row.departureName} + {row.destinationName} + + {row.tripCount} + + + {formatDistance(row.totalDistance)} + + + {formatDistance(row.avgDistance)} + + + {formatDuration(Math.round(row.avgDuration))} + + + )) + )} + +
+
+
+
+
+
+
+ ); +} + diff --git a/frontend/components/vehicle/VehicleTripHistory.tsx b/frontend/components/vehicle/VehicleTripHistory.tsx new file mode 100644 index 00000000..3c4bcb57 --- /dev/null +++ b/frontend/components/vehicle/VehicleTripHistory.tsx @@ -0,0 +1,531 @@ +"use client"; + +import React, { useState, useEffect, useCallback } from "react"; +import { + getTripList, + getTripDetail, + formatDistance, + formatDuration, + getStatusLabel, + getStatusColor, + TripSummary, + TripDetail, + TripListFilters, +} from "@/lib/api/vehicleTrip"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { + Search, + RefreshCw, + MapPin, + Clock, + Route, + ChevronLeft, + ChevronRight, + Eye, +} from "lucide-react"; +import { format } from "date-fns"; +import { ko } from "date-fns/locale"; + +const PAGE_SIZE = 20; + +export default function VehicleTripHistory() { + // 상태 + const [trips, setTrips] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [page, setPage] = useState(1); + + // 필터 + const [filters, setFilters] = useState({ + status: "", + startDate: "", + endDate: "", + departure: "", + arrival: "", + }); + + // 상세 모달 + const [selectedTrip, setSelectedTrip] = useState(null); + const [detailModalOpen, setDetailModalOpen] = useState(false); + const [detailLoading, setDetailLoading] = useState(false); + + // 데이터 로드 + const loadTrips = useCallback(async () => { + setLoading(true); + try { + const response = await getTripList({ + ...filters, + status: filters.status || undefined, + startDate: filters.startDate || undefined, + endDate: filters.endDate || undefined, + departure: filters.departure || undefined, + arrival: filters.arrival || undefined, + limit: PAGE_SIZE, + offset: (page - 1) * PAGE_SIZE, + }); + + if (response.success) { + setTrips(response.data || []); + setTotal(response.total || 0); + } + } catch (error) { + console.error("운행 이력 조회 실패:", error); + } finally { + setLoading(false); + } + }, [filters, page]); + + useEffect(() => { + loadTrips(); + }, [loadTrips]); + + // 상세 조회 + const handleViewDetail = async (tripId: string) => { + setDetailLoading(true); + setDetailModalOpen(true); + try { + const response = await getTripDetail(tripId); + if (response.success && response.data) { + setSelectedTrip(response.data); + } + } catch (error) { + console.error("운행 상세 조회 실패:", error); + } finally { + setDetailLoading(false); + } + }; + + // 필터 변경 + const handleFilterChange = (key: keyof TripListFilters, value: string) => { + setFilters((prev) => ({ ...prev, [key]: value })); + setPage(1); + }; + + // 검색 + const handleSearch = () => { + setPage(1); + loadTrips(); + }; + + // 초기화 + const handleReset = () => { + setFilters({ + status: "", + startDate: "", + endDate: "", + departure: "", + arrival: "", + }); + setPage(1); + }; + + // 페이지네이션 + const totalPages = Math.ceil(total / PAGE_SIZE); + + return ( +
+ {/* 필터 영역 */} + + + 검색 조건 + + +
+ {/* 상태 */} +
+ + +
+ + {/* 시작일 */} +
+ + handleFilterChange("startDate", e.target.value)} + className="h-9" + /> +
+ + {/* 종료일 */} +
+ + handleFilterChange("endDate", e.target.value)} + className="h-9" + /> +
+ + {/* 출발지 */} +
+ + handleFilterChange("departure", e.target.value)} + className="h-9" + /> +
+ + {/* 도착지 */} +
+ + handleFilterChange("arrival", e.target.value)} + className="h-9" + /> +
+
+ +
+ + +
+
+
+ + {/* 목록 */} + + +
+ + 운행 이력 ({total.toLocaleString()}건) + + +
+
+ +
+ + + + 운행ID + 운전자 + 출발지 + 도착지 + 시작 시간 + 종료 시간 + 거리 + 시간 + 상태 + + + + + {loading ? ( + + + 로딩 중... + + + ) : trips.length === 0 ? ( + + + 운행 이력이 없습니다. + + + ) : ( + trips.map((trip) => ( + + + {trip.trip_id.substring(0, 15)}... + + {trip.user_name || trip.user_id} + {trip.departure_name || trip.departure || "-"} + {trip.destination_name || trip.arrival || "-"} + + {format(new Date(trip.start_time), "MM/dd HH:mm", { + locale: ko, + })} + + + {trip.end_time + ? format(new Date(trip.end_time), "MM/dd HH:mm", { + locale: ko, + }) + : "-"} + + + {trip.total_distance + ? formatDistance(Number(trip.total_distance)) + : "-"} + + + {trip.duration_minutes + ? formatDuration(trip.duration_minutes) + : "-"} + + + + {getStatusLabel(trip.status)} + + + + + + + )) + )} + +
+
+ + {/* 페이지네이션 */} + {totalPages > 1 && ( +
+ + + {page} / {totalPages} + + +
+ )} +
+
+ + {/* 상세 모달 */} + + + + 운행 상세 정보 + + + {detailLoading ? ( +
+ 로딩 중... +
+ ) : selectedTrip ? ( +
+ {/* 요약 정보 */} +
+
+
+ + 출발지 +
+
+ {selectedTrip.summary.departure_name || + selectedTrip.summary.departure || + "-"} +
+
+
+
+ + 도착지 +
+
+ {selectedTrip.summary.destination_name || + selectedTrip.summary.arrival || + "-"} +
+
+
+
+ + 총 거리 +
+
+ {selectedTrip.summary.total_distance + ? formatDistance(Number(selectedTrip.summary.total_distance)) + : "-"} +
+
+
+
+ + 운행 시간 +
+
+ {selectedTrip.summary.duration_minutes + ? formatDuration(selectedTrip.summary.duration_minutes) + : "-"} +
+
+
+ + {/* 운행 정보 */} +
+

운행 정보

+
+
+ 운행 ID + + {selectedTrip.summary.trip_id} + +
+
+ 운전자 + + {selectedTrip.summary.user_name || + selectedTrip.summary.user_id} + +
+
+ 시작 시간 + + {format( + new Date(selectedTrip.summary.start_time), + "yyyy-MM-dd HH:mm:ss", + { locale: ko } + )} + +
+
+ 종료 시간 + + {selectedTrip.summary.end_time + ? format( + new Date(selectedTrip.summary.end_time), + "yyyy-MM-dd HH:mm:ss", + { locale: ko } + ) + : "-"} + +
+
+ 상태 + + {getStatusLabel(selectedTrip.summary.status)} + +
+
+ 위치 기록 수 + {selectedTrip.summary.location_count}개 +
+
+
+ + {/* 경로 데이터 */} + {selectedTrip.route && selectedTrip.route.length > 0 && ( +
+

+ 경로 데이터 ({selectedTrip.route.length}개 지점) +

+
+ + + + # + 위도 + 경도 + 정확도 + 이전 거리 + 기록 시간 + + + + {selectedTrip.route.map((loc, idx) => ( + + {idx + 1} + + {loc.latitude.toFixed(6)} + + + {loc.longitude.toFixed(6)} + + + {loc.accuracy ? `${loc.accuracy.toFixed(0)}m` : "-"} + + + {loc.distance_from_prev + ? formatDistance(Number(loc.distance_from_prev)) + : "-"} + + + {format(new Date(loc.recorded_at), "HH:mm:ss", { + locale: ko, + })} + + + ))} + +
+
+
+ )} +
+ ) : ( +
+ 데이터를 불러올 수 없습니다. +
+ )} +
+
+
+ ); +} + diff --git a/frontend/lib/api/vehicleTrip.ts b/frontend/lib/api/vehicleTrip.ts new file mode 100644 index 00000000..2e452dd6 --- /dev/null +++ b/frontend/lib/api/vehicleTrip.ts @@ -0,0 +1,368 @@ +/** + * 차량 운행 이력 API 클라이언트 + */ +import { apiClient } from "./client"; + +// 타입 정의 +export interface TripSummary { + id: number; + trip_id: string; + user_id: string; + user_name?: string; + vehicle_id?: number; + vehicle_number?: string; + departure?: string; + arrival?: string; + departure_name?: string; + destination_name?: string; + start_time: string; + end_time?: string; + total_distance: number; + duration_minutes?: number; + status: "active" | "completed" | "cancelled"; + location_count: number; + company_code: string; + created_at: string; +} + +export interface TripLocation { + id: number; + latitude: number; + longitude: number; + accuracy?: number; + speed?: number; + distance_from_prev?: number; + trip_status: "start" | "tracking" | "end"; + recorded_at: string; +} + +export interface TripDetail { + summary: TripSummary; + route: TripLocation[]; +} + +export interface TripListFilters { + userId?: string; + vehicleId?: number; + status?: string; + startDate?: string; + endDate?: string; + departure?: string; + arrival?: string; + limit?: number; + offset?: number; +} + +export interface StartTripParams { + vehicleId?: number; + departure?: string; + arrival?: string; + departureName?: string; + destinationName?: string; + latitude: number; + longitude: number; +} + +export interface EndTripParams { + tripId: string; + latitude: number; + longitude: number; +} + +export interface AddLocationParams { + tripId: string; + latitude: number; + longitude: number; + accuracy?: number; + speed?: number; +} + +// API 함수들 + +/** + * 운행 시작 + */ +export async function startTrip(params: StartTripParams) { + const response = await apiClient.post("/vehicle/trip/start", params); + return response.data; +} + +/** + * 운행 종료 + */ +export async function endTrip(params: EndTripParams) { + const response = await apiClient.post("/vehicle/trip/end", params); + return response.data; +} + +/** + * 위치 기록 추가 (연속 추적) + */ +export async function addTripLocation(params: AddLocationParams) { + const response = await apiClient.post("/vehicle/trip/location", params); + return response.data; +} + +/** + * 활성 운행 조회 + */ +export async function getActiveTrip() { + const response = await apiClient.get("/vehicle/trip/active"); + return response.data; +} + +/** + * 운행 취소 + */ +export async function cancelTrip(tripId: string) { + const response = await apiClient.post("/vehicle/trip/cancel", { tripId }); + return response.data; +} + +/** + * 운행 이력 목록 조회 + */ +export async function getTripList(filters?: TripListFilters) { + const params = new URLSearchParams(); + + if (filters) { + if (filters.userId) params.append("userId", filters.userId); + if (filters.vehicleId) params.append("vehicleId", String(filters.vehicleId)); + if (filters.status) params.append("status", filters.status); + if (filters.startDate) params.append("startDate", filters.startDate); + if (filters.endDate) params.append("endDate", filters.endDate); + if (filters.departure) params.append("departure", filters.departure); + if (filters.arrival) params.append("arrival", filters.arrival); + if (filters.limit) params.append("limit", String(filters.limit)); + if (filters.offset) params.append("offset", String(filters.offset)); + } + + const queryString = params.toString(); + const url = queryString ? `/vehicle/trips?${queryString}` : "/vehicle/trips"; + + const response = await apiClient.get(url); + return response.data; +} + +/** + * 운행 상세 조회 (경로 포함) + */ +export async function getTripDetail(tripId: string): Promise<{ success: boolean; data?: TripDetail; message?: string }> { + const response = await apiClient.get(`/vehicle/trips/${tripId}`); + return response.data; +} + +/** + * 거리 포맷팅 (km) + */ +export function formatDistance(distanceKm: number): string { + if (distanceKm < 1) { + return `${Math.round(distanceKm * 1000)}m`; + } + return `${distanceKm.toFixed(2)}km`; +} + +/** + * 운행 시간 포맷팅 + */ +export function formatDuration(minutes: number): string { + if (minutes < 60) { + return `${minutes}분`; + } + const hours = Math.floor(minutes / 60); + const mins = minutes % 60; + return mins > 0 ? `${hours}시간 ${mins}분` : `${hours}시간`; +} + +/** + * 상태 한글 변환 + */ +export function getStatusLabel(status: string): string { + switch (status) { + case "active": + return "운행 중"; + case "completed": + return "완료"; + case "cancelled": + return "취소됨"; + default: + return status; + } +} + +/** + * 상태별 색상 + */ +export function getStatusColor(status: string): string { + switch (status) { + case "active": + return "bg-green-100 text-green-800"; + case "completed": + return "bg-blue-100 text-blue-800"; + case "cancelled": + return "bg-gray-100 text-gray-800"; + default: + return "bg-gray-100 text-gray-800"; + } +} + +// ============== 리포트 API ============== + +export interface DailyStat { + date: string; + tripCount: number; + completedCount: number; + cancelledCount: number; + totalDistance: number; + totalDuration: number; + avgDistance: number; + avgDuration: number; +} + +export interface WeeklyStat { + weekNumber: number; + weekStart: string; + weekEnd: string; + tripCount: number; + completedCount: number; + totalDistance: number; + totalDuration: number; + avgDistance: number; +} + +export interface MonthlyStat { + month: number; + tripCount: number; + completedCount: number; + cancelledCount: number; + totalDistance: number; + totalDuration: number; + avgDistance: number; + avgDuration: number; + driverCount: number; +} + +export interface SummaryReport { + period: string; + totalTrips: number; + completedTrips: number; + activeTrips: number; + cancelledTrips: number; + completionRate: number; + totalDistance: number; + totalDuration: number; + avgDistance: number; + avgDuration: number; + activeDrivers: number; +} + +export interface DriverStat { + userId: string; + userName: string; + tripCount: number; + completedCount: number; + totalDistance: number; + totalDuration: number; + avgDistance: number; +} + +export interface RouteStat { + departure: string; + arrival: string; + departureName: string; + destinationName: string; + tripCount: number; + completedCount: number; + totalDistance: number; + avgDistance: number; + avgDuration: number; +} + +/** + * 요약 통계 조회 (대시보드용) + */ +export async function getSummaryReport(period?: string) { + const url = period ? `/vehicle/reports/summary?period=${period}` : "/vehicle/reports/summary"; + const response = await apiClient.get(url); + return response.data; +} + +/** + * 일별 통계 조회 + */ +export async function getDailyReport(filters?: { startDate?: string; endDate?: string; userId?: string }) { + const params = new URLSearchParams(); + if (filters?.startDate) params.append("startDate", filters.startDate); + if (filters?.endDate) params.append("endDate", filters.endDate); + if (filters?.userId) params.append("userId", filters.userId); + + const queryString = params.toString(); + const url = queryString ? `/vehicle/reports/daily?${queryString}` : "/vehicle/reports/daily"; + + const response = await apiClient.get(url); + return response.data; +} + +/** + * 주별 통계 조회 + */ +export async function getWeeklyReport(filters?: { year?: number; month?: number; userId?: string }) { + const params = new URLSearchParams(); + if (filters?.year) params.append("year", String(filters.year)); + if (filters?.month) params.append("month", String(filters.month)); + if (filters?.userId) params.append("userId", filters.userId); + + const queryString = params.toString(); + const url = queryString ? `/vehicle/reports/weekly?${queryString}` : "/vehicle/reports/weekly"; + + const response = await apiClient.get(url); + return response.data; +} + +/** + * 월별 통계 조회 + */ +export async function getMonthlyReport(filters?: { year?: number; userId?: string }) { + const params = new URLSearchParams(); + if (filters?.year) params.append("year", String(filters.year)); + if (filters?.userId) params.append("userId", filters.userId); + + const queryString = params.toString(); + const url = queryString ? `/vehicle/reports/monthly?${queryString}` : "/vehicle/reports/monthly"; + + const response = await apiClient.get(url); + return response.data; +} + +/** + * 운전자별 통계 조회 + */ +export async function getDriverReport(filters?: { startDate?: string; endDate?: string; limit?: number }) { + const params = new URLSearchParams(); + if (filters?.startDate) params.append("startDate", filters.startDate); + if (filters?.endDate) params.append("endDate", filters.endDate); + if (filters?.limit) params.append("limit", String(filters.limit)); + + const queryString = params.toString(); + const url = queryString ? `/vehicle/reports/by-driver?${queryString}` : "/vehicle/reports/by-driver"; + + const response = await apiClient.get(url); + return response.data; +} + +/** + * 구간별 통계 조회 + */ +export async function getRouteReport(filters?: { startDate?: string; endDate?: string; limit?: number }) { + const params = new URLSearchParams(); + if (filters?.startDate) params.append("startDate", filters.startDate); + if (filters?.endDate) params.append("endDate", filters.endDate); + if (filters?.limit) params.append("limit", String(filters.limit)); + + const queryString = params.toString(); + const url = queryString ? `/vehicle/reports/by-route?${queryString}` : "/vehicle/reports/by-route"; + + const response = await apiClient.get(url); + return response.data; +} + diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 2c7270a6..5be55b65 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -24,7 +24,7 @@ export type ButtonActionType = | "excel_upload" // 엑셀 업로드 | "barcode_scan" // 바코드 스캔 | "code_merge" // 코드 병합 - | "empty_vehicle" // 공차등록 (위치 수집 + 상태 변경) + // | "empty_vehicle" // 공차등록 (위치 수집 + 상태 변경) - 운행알림으로 통합 | "operation_control" // 운행알림 및 종료 (위치 수집 + 상태 변경 + 연속 추적) | "swap_fields" // 필드 값 교환 (출발지 ↔ 목적지) | "transferData"; // 데이터 전달 (컴포넌트 간 or 화면 간) @@ -163,6 +163,10 @@ export interface ButtonActionConfig { updateGeolocationAccuracyField?: string; // 정확도 저장 필드 (선택) updateGeolocationTimestampField?: string; // 타임스탬프 저장 필드 (선택) + // 🆕 공차등록 연속 위치 추적 설정 (empty_vehicle 액션에서 사용) + emptyVehicleTracking?: boolean; // 공차 상태에서 연속 위치 추적 여부 (기본: true) + emptyVehicleTrackingInterval?: number; // 위치 저장 주기 (ms, 기본: 10000 = 10초) + // 편집 관련 (수주관리 등 그룹별 다중 레코드 편집) editMode?: "modal" | "navigate" | "inline"; // 편집 모드 editModalTitle?: string; // 편집 모달 제목 @@ -350,8 +354,8 @@ export class ButtonActionExecutor { case "transferData": return await this.handleTransferData(config, context); - case "empty_vehicle": - return await this.handleEmptyVehicle(config, context); + // case "empty_vehicle": + // return await this.handleEmptyVehicle(config, context); case "operation_control": return await this.handleOperationControl(config, context); @@ -3501,6 +3505,7 @@ export class ButtonActionExecutor { /** * 위치 이력 테이블에 저장 (내부 헬퍼) + * + vehicles 테이블의 latitude/longitude도 함께 업데이트 */ private static async saveLocationToHistory( tripId: string | null, @@ -3517,13 +3522,15 @@ export class ButtonActionExecutor { try { const { apiClient } = await import("@/lib/api/client"); + const { latitude, longitude, accuracy, altitude, speed, heading } = position.coords; + const locationData = { - latitude: position.coords.latitude, - longitude: position.coords.longitude, - accuracy: position.coords.accuracy, - altitude: position.coords.altitude, - speed: position.coords.speed, - heading: position.coords.heading, + latitude, + longitude, + accuracy, + altitude, + speed, + heading, tripId, tripStatus, departure, @@ -3536,6 +3543,7 @@ export class ButtonActionExecutor { console.log("📍 [saveLocationToHistory] 위치 저장:", locationData); + // 1. vehicle_location_history에 저장 const response = await apiClient.post(`/dynamic-form/location-history`, locationData); if (response.data?.success) { @@ -3544,6 +3552,41 @@ export class ButtonActionExecutor { console.warn("⚠️ 위치 이력 저장 실패:", response.data); } + // 2. vehicles 테이블의 latitude/longitude도 업데이트 (실시간 위치 반영) + if (this.trackingContext && this.trackingConfig) { + const keyField = this.trackingConfig.trackingStatusKeyField || "user_id"; + const keySourceField = this.trackingConfig.trackingStatusKeySourceField || "__userId__"; + const keyValue = resolveSpecialKeyword(keySourceField, this.trackingContext); + const vehiclesTableName = this.trackingConfig.trackingStatusTableName || "vehicles"; + + if (keyValue) { + try { + // latitude 업데이트 + await apiClient.put(`/dynamic-form/update-field`, { + tableName: vehiclesTableName, + keyField, + keyValue, + updateField: "latitude", + updateValue: latitude, + }); + + // longitude 업데이트 + await apiClient.put(`/dynamic-form/update-field`, { + tableName: vehiclesTableName, + keyField, + keyValue, + updateField: "longitude", + updateValue: longitude, + }); + + console.log(`✅ vehicles 테이블 위치 업데이트: (${latitude.toFixed(6)}, ${longitude.toFixed(6)})`); + } catch (vehicleUpdateError) { + // 컬럼이 없으면 조용히 무시 + console.warn("⚠️ vehicles 테이블 위치 업데이트 실패 (무시):", vehicleUpdateError); + } + } + } + resolve(); } catch (error) { console.error("❌ 위치 이력 저장 오류:", error); @@ -3673,13 +3716,18 @@ export class ButtonActionExecutor { } } + // 공차 추적용 watchId 저장 + private static emptyVehicleWatchId: number | null = null; + private static emptyVehicleTripId: string | null = null; + /** * 공차등록 액션 처리 * - 위치 수집 + 상태 변경 (예: status → inactive) + * - 연속 위치 추적 시작 (vehicle_location_history에 저장) */ private static async handleEmptyVehicle(config: ButtonActionConfig, context: ButtonActionContext): Promise { try { - console.log("📍 위치정보 가져오기 액션 실행:", { config, context }); + console.log("📍 공차등록 액션 실행:", { config, context }); // 브라우저 Geolocation API 지원 확인 if (!navigator.geolocation) { @@ -3708,7 +3756,7 @@ export class ButtonActionExecutor { toast.dismiss(loadingToastId); - const { latitude, longitude, accuracy } = position.coords; + const { latitude, longitude, accuracy, speed, heading, altitude } = position.coords; const timestamp = new Date(position.timestamp); console.log("📍 위치정보 획득 성공:", { latitude, longitude, accuracy }); @@ -3777,8 +3825,15 @@ export class ButtonActionExecutor { } } console.log(`📍 DB UPDATE 완료: ${successCount}/${Object.keys(fieldsToUpdate).length} 필드 저장됨`); + + // 🆕 연속 위치 추적 시작 (공차 상태에서도 위치 기록) + if (config.emptyVehicleTracking !== false) { + await this.startEmptyVehicleTracking(config, context, { + latitude, longitude, accuracy, speed, heading, altitude + }); + } - toast.success(config.successMessage || "위치 정보가 저장되었습니다."); + toast.success(config.successMessage || "공차 등록이 완료되었습니다. 위치 추적을 시작합니다."); } catch (saveError) { console.error("❌ 위치정보 자동 저장 실패:", saveError); toast.error("위치 정보 저장에 실패했습니다."); @@ -3795,7 +3850,7 @@ export class ButtonActionExecutor { return true; } catch (error: any) { - console.error("❌ 위치정보 가져오기 실패:", error); + console.error("❌ 공차등록 실패:", error); toast.dismiss(); // GeolocationPositionError 처리 @@ -3821,6 +3876,122 @@ export class ButtonActionExecutor { } } + /** + * 공차 상태에서 연속 위치 추적 시작 + */ + private static async startEmptyVehicleTracking( + config: ButtonActionConfig, + context: ButtonActionContext, + initialPosition: { latitude: number; longitude: number; accuracy: number | null; speed: number | null; heading: number | null; altitude: number | null } + ): Promise { + try { + // 기존 추적이 있으면 중지 + if (this.emptyVehicleWatchId !== null) { + navigator.geolocation.clearWatch(this.emptyVehicleWatchId); + this.emptyVehicleWatchId = null; + } + + const { apiClient } = await import("@/lib/api/client"); + + // Trip ID 생성 (공차용) + const tripId = `EMPTY-${Date.now()}-${Math.random().toString(36).substring(2, 10)}`; + this.emptyVehicleTripId = tripId; + + const userId = context.userId || ""; + const companyCode = context.companyCode || ""; + const departure = context.formData?.departure || ""; + const arrival = context.formData?.arrival || ""; + const departureName = context.formData?.departure_name || ""; + const destinationName = context.formData?.destination_name || ""; + + // 시작 위치 기록 + try { + await apiClient.post("/dynamic-form/location-history", { + tripId, + userId, + latitude: initialPosition.latitude, + longitude: initialPosition.longitude, + accuracy: initialPosition.accuracy, + speed: initialPosition.speed, + heading: initialPosition.heading, + altitude: initialPosition.altitude, + tripStatus: "empty_start", // 공차 시작 + departure, + arrival, + departureName, + destinationName, + companyCode, + }); + console.log("📍 공차 시작 위치 기록 완료:", tripId); + } catch (err) { + console.warn("⚠️ 공차 시작 위치 기록 실패 (테이블 없을 수 있음):", err); + } + + // 추적 간격 (기본 10초) + const trackingInterval = config.emptyVehicleTrackingInterval || 10000; + + // watchPosition으로 연속 추적 + this.emptyVehicleWatchId = navigator.geolocation.watchPosition( + async (position) => { + const { latitude, longitude, accuracy, speed, heading, altitude } = position.coords; + + try { + await apiClient.post("/dynamic-form/location-history", { + tripId: this.emptyVehicleTripId, + userId, + latitude, + longitude, + accuracy, + speed, + heading, + altitude, + tripStatus: "empty_tracking", // 공차 추적 중 + departure, + arrival, + departureName, + destinationName, + companyCode, + }); + console.log("📍 공차 위치 기록:", { latitude: latitude.toFixed(6), longitude: longitude.toFixed(6) }); + } catch (err) { + console.warn("⚠️ 공차 위치 기록 실패:", err); + } + }, + (error) => { + console.error("❌ 공차 위치 추적 오류:", error.message); + }, + { + enableHighAccuracy: true, + timeout: trackingInterval, + maximumAge: 0, + } + ); + + console.log("🚗 공차 위치 추적 시작:", { tripId, watchId: this.emptyVehicleWatchId }); + } catch (error) { + console.error("❌ 공차 위치 추적 시작 실패:", error); + } + } + + /** + * 공차 위치 추적 중지 (운행 전환 시 호출) + */ + public static stopEmptyVehicleTracking(): void { + if (this.emptyVehicleWatchId !== null) { + navigator.geolocation.clearWatch(this.emptyVehicleWatchId); + console.log("🛑 공차 위치 추적 중지:", { tripId: this.emptyVehicleTripId, watchId: this.emptyVehicleWatchId }); + this.emptyVehicleWatchId = null; + this.emptyVehicleTripId = null; + } + } + + /** + * 현재 공차 추적 Trip ID 반환 + */ + public static getEmptyVehicleTripId(): string | null { + return this.emptyVehicleTripId; + } + /** * 필드 값 교환 액션 처리 (예: 출발지 ↔ 도착지) */ @@ -3885,7 +4056,13 @@ export class ButtonActionExecutor { */ private static async handleOperationControl(config: ButtonActionConfig, context: ButtonActionContext): Promise { try { - console.log("🔄 필드 값 변경 액션 실행:", { config, context }); + console.log("🔄 운행알림/종료 액션 실행:", { config, context }); + + // 🆕 공차 추적 중지 (운행 시작 시 공차 추적 종료) + if (this.emptyVehicleWatchId !== null) { + this.stopEmptyVehicleTracking(); + console.log("🛑 공차 추적 종료 후 운행 시작"); + } // 🆕 연속 위치 추적 모드 처리 if (config.updateWithTracking) {