FlotillasGPS - Sistema completo de monitoreo de flotillas GPS
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.
This commit is contained in:
405
backend/app/services/viaje_service.py
Normal file
405
backend/app/services/viaje_service.py
Normal file
@@ -0,0 +1,405 @@
|
||||
"""
|
||||
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,
|
||||
}
|
||||
Reference in New Issue
Block a user