""" Servicio para gestión automática de viajes. Detecta automáticamente el inicio y fin de viajes basándose en el movimiento del vehículo. """ from datetime import datetime, timedelta, timezone from typing import List, Optional from sqlalchemy import and_, func, select from sqlalchemy.ext.asyncio import AsyncSession from app.core.config import settings from app.models.parada import Parada from app.models.ubicacion import Ubicacion from app.models.vehiculo import Vehiculo from app.models.viaje import Viaje from app.schemas.viaje import ViajeResponse class ViajeService: """Servicio para detección y gestión de viajes.""" # Configuración de detección VELOCIDAD_MINIMA_MOVIMIENTO = 5 # km/h MINUTOS_PARADA_FIN_VIAJE = 5 # minutos para considerar fin de viaje SEGUNDOS_MINIMOS_PARADA = 120 # segundos mínimos para registrar parada def __init__(self, db: AsyncSession): """ Inicializa el servicio. Args: db: Sesión de base de datos async. """ self.db = db async def procesar_ubicacion_viaje( self, vehiculo_id: int, lat: float, lng: float, velocidad: float, tiempo: datetime, ) -> Optional[dict]: """ Procesa una ubicación para detección de viajes. Args: vehiculo_id: ID del vehículo. lat: Latitud. lng: Longitud. velocidad: Velocidad en km/h. tiempo: Timestamp de la ubicación. Returns: Dict con información del evento de viaje si hubo cambio. """ # Obtener viaje en curso viaje_activo = await self._obtener_viaje_activo(vehiculo_id) en_movimiento = velocidad >= self.VELOCIDAD_MINIMA_MOVIMIENTO if not viaje_activo: # No hay viaje activo if en_movimiento: # Iniciar nuevo viaje viaje = await self._iniciar_viaje(vehiculo_id, lat, lng, tiempo) return { "evento": "viaje_iniciado", "viaje_id": viaje.id, "vehiculo_id": vehiculo_id, } else: # Hay viaje activo if en_movimiento: # Actualizar viaje (incrementar puntos GPS) viaje_activo.puntos_gps += 1 # Verificar si había parada en curso y cerrarla await self._cerrar_parada_en_curso(viaje_activo.id, vehiculo_id, tiempo) await self.db.commit() else: # Vehículo detenido resultado = await self._procesar_parada( viaje_activo, vehiculo_id, lat, lng, tiempo ) if resultado: return resultado return None async def _obtener_viaje_activo( self, vehiculo_id: int, ) -> Optional[Viaje]: """Obtiene el viaje activo de un vehículo.""" result = await self.db.execute( select(Viaje) .where(Viaje.vehiculo_id == vehiculo_id) .where(Viaje.estado == "en_curso") ) return result.scalar_one_or_none() async def _iniciar_viaje( self, vehiculo_id: int, lat: float, lng: float, tiempo: datetime, ) -> Viaje: """Inicia un nuevo viaje.""" # Obtener conductor asignado result = await self.db.execute( select(Vehiculo).where(Vehiculo.id == vehiculo_id) ) vehiculo = result.scalar_one_or_none() conductor_id = vehiculo.conductor_id if vehiculo else None # Obtener odómetro actual odometro_inicio = vehiculo.odometro_actual if vehiculo else None viaje = Viaje( vehiculo_id=vehiculo_id, conductor_id=conductor_id, inicio_tiempo=tiempo, inicio_lat=lat, inicio_lng=lng, odometro_inicio=odometro_inicio, estado="en_curso", puntos_gps=1, ) self.db.add(viaje) await self.db.commit() await self.db.refresh(viaje) return viaje async def _procesar_parada( self, viaje: Viaje, vehiculo_id: int, lat: float, lng: float, tiempo: datetime, ) -> Optional[dict]: """ Procesa una parada durante un viaje. Returns: Dict con evento si el viaje terminó. """ # Buscar parada en curso result = await self.db.execute( select(Parada) .where(Parada.vehiculo_id == vehiculo_id) .where(Parada.en_curso == True) ) parada = result.scalar_one_or_none() if not parada: # Iniciar nueva parada parada = Parada( viaje_id=viaje.id, vehiculo_id=vehiculo_id, inicio_tiempo=tiempo, lat=lat, lng=lng, en_curso=True, ) self.db.add(parada) await self.db.commit() return None # Calcular duración de la parada duracion_segundos = (tiempo - parada.inicio_tiempo).total_seconds() parada.duracion_segundos = int(duracion_segundos) # Verificar si la parada es suficientemente larga para terminar el viaje if duracion_segundos >= self.MINUTOS_PARADA_FIN_VIAJE * 60: # Terminar viaje return await self._finalizar_viaje(viaje, parada, tiempo) await self.db.commit() return None async def _cerrar_parada_en_curso( self, viaje_id: int, vehiculo_id: int, tiempo: datetime, ) -> None: """Cierra una parada en curso si existe.""" result = await self.db.execute( select(Parada) .where(Parada.vehiculo_id == vehiculo_id) .where(Parada.en_curso == True) ) parada = result.scalar_one_or_none() if parada: duracion = (tiempo - parada.inicio_tiempo).total_seconds() if duracion >= self.SEGUNDOS_MINIMOS_PARADA: # Registrar parada parada.fin_tiempo = tiempo parada.duracion_segundos = int(duracion) parada.en_curso = False else: # Parada muy corta, eliminar await self.db.delete(parada) async def _finalizar_viaje( self, viaje: Viaje, parada: Parada, tiempo: datetime, ) -> dict: """Finaliza un viaje.""" # Cerrar parada parada.fin_tiempo = tiempo parada.en_curso = False # Calcular estadísticas del viaje viaje.fin_tiempo = parada.inicio_tiempo # El viaje termina al inicio de la parada final viaje.fin_lat = parada.lat viaje.fin_lng = parada.lng viaje.estado = "completado" # Calcular duración viaje.duracion_segundos = int( (viaje.fin_tiempo - viaje.inicio_tiempo).total_seconds() ) # Calcular estadísticas desde ubicaciones await self._calcular_estadisticas_viaje(viaje) await self.db.commit() return { "evento": "viaje_finalizado", "viaje_id": viaje.id, "vehiculo_id": viaje.vehiculo_id, "distancia_km": viaje.distancia_km, "duracion_segundos": viaje.duracion_segundos, } async def _calcular_estadisticas_viaje( self, viaje: Viaje, ) -> None: """Calcula las estadísticas de un viaje finalizado.""" # Obtener ubicaciones del viaje result = await self.db.execute( select(Ubicacion) .where(Ubicacion.vehiculo_id == viaje.vehiculo_id) .where(Ubicacion.tiempo >= viaje.inicio_tiempo) .where(Ubicacion.tiempo <= viaje.fin_tiempo) .order_by(Ubicacion.tiempo) ) ubicaciones = result.scalars().all() if not ubicaciones: return # Distancia viaje.distancia_km = self._calcular_distancia(ubicaciones) # Velocidades velocidades = [u.velocidad for u in ubicaciones if u.velocidad is not None] if velocidades: viaje.velocidad_promedio = sum(velocidades) / len(velocidades) viaje.velocidad_maxima = max(velocidades) # Tiempo en movimiento tiempo_movimiento = 0 tiempo_parado = 0 for i in range(1, len(ubicaciones)): delta = (ubicaciones[i].tiempo - ubicaciones[i-1].tiempo).total_seconds() if ubicaciones[i-1].velocidad and ubicaciones[i-1].velocidad >= self.VELOCIDAD_MINIMA_MOVIMIENTO: tiempo_movimiento += delta else: tiempo_parado += delta viaje.tiempo_movimiento_segundos = int(tiempo_movimiento) viaje.tiempo_parado_segundos = int(tiempo_parado) # Odómetro final result = await self.db.execute( select(Vehiculo).where(Vehiculo.id == viaje.vehiculo_id) ) vehiculo = result.scalar_one_or_none() if vehiculo: viaje.odometro_fin = vehiculo.odometro_actual def _calcular_distancia( self, ubicaciones: List[Ubicacion], ) -> float: """Calcula la distancia total entre ubicaciones.""" import math if len(ubicaciones) < 2: return 0.0 total_km = 0.0 for i in range(1, len(ubicaciones)): lat1 = math.radians(ubicaciones[i - 1].lat) lat2 = math.radians(ubicaciones[i].lat) dlat = lat2 - lat1 dlon = math.radians(ubicaciones[i].lng - ubicaciones[i - 1].lng) a = ( math.sin(dlat / 2) ** 2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2 ) c = 2 * math.asin(math.sqrt(a)) r = 6371 total_km += r * c return round(total_km, 2) async def obtener_viajes_vehiculo( self, vehiculo_id: int, desde: datetime = None, hasta: datetime = None, limite: int = 50, ) -> List[Viaje]: """ Obtiene los viajes de un vehículo. Args: vehiculo_id: ID del vehículo. desde: Fecha inicio (opcional). hasta: Fecha fin (opcional). limite: Límite de resultados. Returns: Lista de viajes. """ query = ( select(Viaje) .where(Viaje.vehiculo_id == vehiculo_id) .order_by(Viaje.inicio_tiempo.desc()) .limit(limite) ) if desde: query = query.where(Viaje.inicio_tiempo >= desde) if hasta: query = query.where(Viaje.inicio_tiempo <= hasta) result = await self.db.execute(query) return result.scalars().all() async def obtener_replay_viaje( self, viaje_id: int, ) -> Optional[dict]: """ Obtiene los datos para replay de un viaje. Args: viaje_id: ID del viaje. Returns: Datos del viaje con ubicaciones y paradas. """ result = await self.db.execute( select(Viaje).where(Viaje.id == viaje_id) ) viaje = result.scalar_one_or_none() if not viaje: return None # Obtener ubicaciones result = await self.db.execute( select(Ubicacion) .where(Ubicacion.vehiculo_id == viaje.vehiculo_id) .where(Ubicacion.tiempo >= viaje.inicio_tiempo) .where( Ubicacion.tiempo <= (viaje.fin_tiempo or datetime.now(timezone.utc)) ) .order_by(Ubicacion.tiempo) ) ubicaciones = result.scalars().all() # Obtener paradas result = await self.db.execute( select(Parada) .where(Parada.viaje_id == viaje_id) .order_by(Parada.inicio_tiempo) ) paradas = result.scalars().all() return { "viaje": viaje, "ubicaciones": ubicaciones, "paradas": paradas, }