/** * 차량 운행 이력 서비스 */ 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();