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