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:
495
backend/app/services/alerta_service.py
Normal file
495
backend/app/services/alerta_service.py
Normal 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,
|
||||
}
|
||||
Reference in New Issue
Block a user