Sistema completo para monitoreo y gestion de flotas de vehiculos con: - Backend FastAPI con PostgreSQL/TimescaleDB - Frontend React con TypeScript y TailwindCSS - App movil React Native con Expo - Soporte para dispositivos GPS, Meshtastic y celulares - Video streaming en vivo con MediaMTX - Geocercas, alertas, viajes y reportes - Autenticacion JWT y WebSockets en tiempo real Documentacion completa y guias de usuario incluidas.
406 lines
12 KiB
Python
406 lines
12 KiB
Python
"""
|
|
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,
|
|
}
|