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:
FlotillasGPS Developer
2026-01-21 08:18:00 +00:00
commit 51d78bacf4
248 changed files with 50171 additions and 0 deletions

View 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,
}