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,495 @@
"""
Servicio para gestión y generación de alertas.
Motor de reglas que detecta y genera alertas basándose
en ubicaciones, velocidad, geocercas, batería, etc.
"""
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.alerta import Alerta
from app.models.tipo_alerta import TipoAlerta
from app.models.vehiculo import Vehiculo
from app.schemas.alerta import AlertaCreate, AlertaResponse
from app.services.geocerca_service import GeocercaService
class AlertaService:
"""Servicio para gestión de alertas."""
# Cache de tipos de alerta (código -> id)
_tipos_alerta_cache: dict = {}
def __init__(self, db: AsyncSession):
"""
Inicializa el servicio.
Args:
db: Sesión de base de datos async.
"""
self.db = db
self.geocerca_service = GeocercaService(db)
async def _obtener_tipo_alerta_id(self, codigo: str) -> Optional[int]:
"""
Obtiene el ID de un tipo de alerta por su código.
Args:
codigo: Código del tipo de alerta.
Returns:
ID del tipo de alerta o None.
"""
if codigo in self._tipos_alerta_cache:
return self._tipos_alerta_cache[codigo]
result = await self.db.execute(
select(TipoAlerta).where(TipoAlerta.codigo == codigo)
)
tipo = result.scalar_one_or_none()
if tipo:
self._tipos_alerta_cache[codigo] = tipo.id
return tipo.id
return None
async def verificar_velocidad(
self,
vehiculo_id: int,
velocidad: float,
lat: float,
lng: float,
limite_general: float = None,
) -> Optional[Alerta]:
"""
Verifica si la velocidad excede el límite.
Args:
vehiculo_id: ID del vehículo.
velocidad: Velocidad actual en km/h.
lat: Latitud actual.
lng: Longitud actual.
limite_general: Límite de velocidad general (si no, usa config).
Returns:
Alerta creada si excede el límite, None si no.
"""
limite = limite_general or settings.ALERT_SPEED_LIMIT_DEFAULT
if velocidad <= limite:
return None
tipo_alerta_id = await self._obtener_tipo_alerta_id("EXCESO_VELOCIDAD")
if not tipo_alerta_id:
return None
# Verificar si ya existe una alerta reciente (últimos 5 minutos)
tiempo_limite = datetime.now(timezone.utc) - timedelta(minutes=5)
result = await self.db.execute(
select(Alerta)
.where(
and_(
Alerta.vehiculo_id == vehiculo_id,
Alerta.tipo_alerta_id == tipo_alerta_id,
Alerta.creado_en >= tiempo_limite,
)
)
)
if result.scalar_one_or_none():
return None # Ya existe una alerta reciente
# Crear alerta
alerta = Alerta(
vehiculo_id=vehiculo_id,
tipo_alerta_id=tipo_alerta_id,
severidad="media" if velocidad < limite * 1.2 else "alta",
mensaje=f"Exceso de velocidad: {velocidad:.1f} km/h (límite: {limite} km/h)",
lat=lat,
lng=lng,
velocidad=velocidad,
valor=velocidad,
umbral=limite,
)
self.db.add(alerta)
await self.db.commit()
await self.db.refresh(alerta)
return alerta
async def verificar_geocercas(
self,
vehiculo_id: int,
lat: float,
lng: float,
estado_anterior: dict = None,
) -> List[Alerta]:
"""
Verifica transiciones de entrada/salida de geocercas.
Args:
vehiculo_id: ID del vehículo.
lat: Latitud actual.
lng: Longitud actual.
estado_anterior: Estado de geocercas anterior {geocerca_id: dentro}.
Returns:
Lista de alertas generadas.
"""
alertas = []
estado_anterior = estado_anterior or {}
resultados = await self.geocerca_service.verificar_todas_geocercas(
lat, lng, vehiculo_id
)
for r in resultados:
geocerca_id = r["geocerca_id"]
dentro = r["dentro"]
estaba_dentro = estado_anterior.get(geocerca_id, None)
# Entrada a geocerca
if dentro and not estaba_dentro and r["alerta_entrada"]:
tipo_id = await self._obtener_tipo_alerta_id("ENTRADA_GEOCERCA")
if tipo_id:
alerta = Alerta(
vehiculo_id=vehiculo_id,
tipo_alerta_id=tipo_id,
severidad="baja",
mensaje=f"Entrada a geocerca: {r['geocerca_nombre']}",
lat=lat,
lng=lng,
)
self.db.add(alerta)
alertas.append(alerta)
# Salida de geocerca
elif not dentro and estaba_dentro and r["alerta_salida"]:
tipo_id = await self._obtener_tipo_alerta_id("SALIDA_GEOCERCA")
if tipo_id:
alerta = Alerta(
vehiculo_id=vehiculo_id,
tipo_alerta_id=tipo_id,
severidad="media",
mensaje=f"Salida de geocerca: {r['geocerca_nombre']}",
lat=lat,
lng=lng,
)
self.db.add(alerta)
alertas.append(alerta)
if alertas:
await self.db.commit()
return alertas
async def verificar_bateria_baja(
self,
vehiculo_id: int,
bateria: float,
lat: float,
lng: float,
dispositivo_id: int = None,
) -> Optional[Alerta]:
"""
Verifica si la batería del dispositivo está baja.
Args:
vehiculo_id: ID del vehículo.
bateria: Porcentaje de batería.
lat: Latitud actual.
lng: Longitud actual.
dispositivo_id: ID del dispositivo (opcional).
Returns:
Alerta creada si la batería está baja.
"""
if bateria > settings.ALERT_BATTERY_LOW_PERCENT:
return None
tipo_alerta_id = await self._obtener_tipo_alerta_id("BATERIA_BAJA")
if not tipo_alerta_id:
return None
# Verificar si ya existe una alerta reciente (últimas 2 horas)
tiempo_limite = datetime.now(timezone.utc) - timedelta(hours=2)
result = await self.db.execute(
select(Alerta)
.where(
and_(
Alerta.vehiculo_id == vehiculo_id,
Alerta.tipo_alerta_id == tipo_alerta_id,
Alerta.creado_en >= tiempo_limite,
)
)
)
if result.scalar_one_or_none():
return None
severidad = "alta" if bateria < 10 else "media"
alerta = Alerta(
vehiculo_id=vehiculo_id,
dispositivo_id=dispositivo_id,
tipo_alerta_id=tipo_alerta_id,
severidad=severidad,
mensaje=f"Batería baja del dispositivo: {bateria:.0f}%",
lat=lat,
lng=lng,
valor=bateria,
umbral=settings.ALERT_BATTERY_LOW_PERCENT,
)
self.db.add(alerta)
await self.db.commit()
await self.db.refresh(alerta)
return alerta
async def verificar_sin_señal(self) -> List[Alerta]:
"""
Verifica vehículos que no han reportado ubicación.
Busca vehículos activos cuya última ubicación sea mayor
al tiempo configurado.
Returns:
Lista de alertas generadas.
"""
alertas = []
tiempo_limite = datetime.now(timezone.utc) - timedelta(
minutes=settings.ALERT_NO_SIGNAL_MINUTES
)
result = await self.db.execute(
select(Vehiculo)
.where(Vehiculo.activo == True)
.where(Vehiculo.en_servicio == True)
.where(Vehiculo.ultima_ubicacion_tiempo < tiempo_limite)
)
vehiculos = result.scalars().all()
tipo_alerta_id = await self._obtener_tipo_alerta_id("SIN_SEÑAL")
if not tipo_alerta_id:
return alertas
for v in vehiculos:
# Verificar si ya existe una alerta reciente (últimas 2 horas)
tiempo_alerta_limite = datetime.now(timezone.utc) - timedelta(hours=2)
result = await self.db.execute(
select(Alerta)
.where(
and_(
Alerta.vehiculo_id == v.id,
Alerta.tipo_alerta_id == tipo_alerta_id,
Alerta.creado_en >= tiempo_alerta_limite,
)
)
)
if result.scalar_one_or_none():
continue
minutos_sin_señal = int(
(datetime.now(timezone.utc) - v.ultima_ubicacion_tiempo).total_seconds() / 60
)
alerta = Alerta(
vehiculo_id=v.id,
tipo_alerta_id=tipo_alerta_id,
severidad="alta",
mensaje=f"Sin señal GPS por {minutos_sin_señal} minutos",
lat=v.ultima_lat,
lng=v.ultima_lng,
valor=minutos_sin_señal,
umbral=settings.ALERT_NO_SIGNAL_MINUTES,
)
self.db.add(alerta)
alertas.append(alerta)
if alertas:
await self.db.commit()
return alertas
async def crear_alerta(
self,
alerta_data: AlertaCreate,
) -> Alerta:
"""
Crea una alerta manualmente.
Args:
alerta_data: Datos de la alerta.
Returns:
Alerta creada.
"""
alerta = Alerta(
vehiculo_id=alerta_data.vehiculo_id,
conductor_id=alerta_data.conductor_id,
tipo_alerta_id=alerta_data.tipo_alerta_id,
dispositivo_id=alerta_data.dispositivo_id,
severidad=alerta_data.severidad,
mensaje=alerta_data.mensaje,
descripcion=alerta_data.descripcion,
lat=alerta_data.lat,
lng=alerta_data.lng,
direccion=alerta_data.direccion,
velocidad=alerta_data.velocidad,
valor=alerta_data.valor,
umbral=alerta_data.umbral,
datos_extra=alerta_data.datos_extra,
)
self.db.add(alerta)
await self.db.commit()
await self.db.refresh(alerta)
return alerta
async def marcar_atendida(
self,
alerta_id: int,
usuario_id: int,
notas: str = None,
) -> Optional[Alerta]:
"""
Marca una alerta como atendida.
Args:
alerta_id: ID de la alerta.
usuario_id: ID del usuario que atiende.
notas: Notas de atención (opcional).
Returns:
Alerta actualizada o None si no existe.
"""
result = await self.db.execute(
select(Alerta).where(Alerta.id == alerta_id)
)
alerta = result.scalar_one_or_none()
if not alerta:
return None
alerta.atendida = True
alerta.atendida_por_id = usuario_id
alerta.atendida_en = datetime.now(timezone.utc)
alerta.notas_atencion = notas
await self.db.commit()
await self.db.refresh(alerta)
return alerta
async def obtener_alertas_pendientes(
self,
vehiculo_id: int = None,
severidad: str = None,
limite: int = 50,
) -> List[Alerta]:
"""
Obtiene alertas pendientes de atender.
Args:
vehiculo_id: Filtrar por vehículo (opcional).
severidad: Filtrar por severidad (opcional).
limite: Límite de resultados.
Returns:
Lista de alertas pendientes.
"""
query = (
select(Alerta)
.where(Alerta.atendida == False)
.order_by(
Alerta.severidad.desc(), # Críticas primero
Alerta.creado_en.desc()
)
.limit(limite)
)
if vehiculo_id:
query = query.where(Alerta.vehiculo_id == vehiculo_id)
if severidad:
query = query.where(Alerta.severidad == severidad)
result = await self.db.execute(query)
return result.scalars().all()
async def obtener_estadisticas(
self,
desde: datetime = None,
hasta: datetime = None,
) -> dict:
"""
Obtiene estadísticas de alertas.
Args:
desde: Fecha inicio (opcional).
hasta: Fecha fin (opcional).
Returns:
Diccionario con estadísticas.
"""
desde = desde or (datetime.now(timezone.utc) - timedelta(days=30))
hasta = hasta or datetime.now(timezone.utc)
# Total de alertas
result = await self.db.execute(
select(func.count(Alerta.id))
.where(Alerta.creado_en >= desde)
.where(Alerta.creado_en <= hasta)
)
total = result.scalar()
# Pendientes
result = await self.db.execute(
select(func.count(Alerta.id))
.where(Alerta.atendida == False)
.where(Alerta.creado_en >= desde)
.where(Alerta.creado_en <= hasta)
)
pendientes = result.scalar()
# Por severidad
result = await self.db.execute(
select(Alerta.severidad, func.count(Alerta.id))
.where(Alerta.creado_en >= desde)
.where(Alerta.creado_en <= hasta)
.group_by(Alerta.severidad)
)
por_severidad = {row[0]: row[1] for row in result.all()}
# Por tipo
result = await self.db.execute(
select(TipoAlerta.codigo, TipoAlerta.nombre, func.count(Alerta.id))
.join(TipoAlerta, Alerta.tipo_alerta_id == TipoAlerta.id)
.where(Alerta.creado_en >= desde)
.where(Alerta.creado_en <= hasta)
.group_by(TipoAlerta.codigo, TipoAlerta.nombre)
.order_by(func.count(Alerta.id).desc())
)
por_tipo = [
{"codigo": row[0], "nombre": row[1], "cantidad": row[2]}
for row in result.all()
]
return {
"total": total,
"pendientes": pendientes,
"atendidas": total - pendientes,
"criticas": por_severidad.get("critica", 0),
"altas": por_severidad.get("alta", 0),
"medias": por_severidad.get("media", 0),
"bajas": por_severidad.get("baja", 0),
"por_tipo": por_tipo,
}