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:
23
backend/app/services/__init__.py
Normal file
23
backend/app/services/__init__.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""
|
||||
Módulo de servicios de lógica de negocio.
|
||||
"""
|
||||
|
||||
from app.services.ubicacion_service import UbicacionService
|
||||
from app.services.geocerca_service import GeocercaService
|
||||
from app.services.alerta_service import AlertaService
|
||||
from app.services.viaje_service import ViajeService
|
||||
from app.services.traccar_service import TraccarService
|
||||
from app.services.video_service import VideoService
|
||||
from app.services.reporte_service import ReporteService
|
||||
from app.services.notificacion_service import NotificacionService
|
||||
|
||||
__all__ = [
|
||||
"UbicacionService",
|
||||
"GeocercaService",
|
||||
"AlertaService",
|
||||
"ViajeService",
|
||||
"TraccarService",
|
||||
"VideoService",
|
||||
"ReporteService",
|
||||
"NotificacionService",
|
||||
]
|
||||
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,
|
||||
}
|
||||
351
backend/app/services/geocerca_service.py
Normal file
351
backend/app/services/geocerca_service.py
Normal file
@@ -0,0 +1,351 @@
|
||||
"""
|
||||
Servicio para gestión de geocercas.
|
||||
|
||||
Proporciona funcionalidades para verificar si un punto está dentro
|
||||
de una geocerca y calcular distancias.
|
||||
"""
|
||||
|
||||
import json
|
||||
import math
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.geocerca import Geocerca
|
||||
|
||||
|
||||
class GeocercaService:
|
||||
"""Servicio para operaciones con geocercas."""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
"""
|
||||
Inicializa el servicio.
|
||||
|
||||
Args:
|
||||
db: Sesión de base de datos async.
|
||||
"""
|
||||
self.db = db
|
||||
|
||||
async def verificar_punto_en_geocerca(
|
||||
self,
|
||||
lat: float,
|
||||
lng: float,
|
||||
geocerca_id: int,
|
||||
) -> Tuple[bool, Optional[float]]:
|
||||
"""
|
||||
Verifica si un punto está dentro de una geocerca.
|
||||
|
||||
Args:
|
||||
lat: Latitud del punto.
|
||||
lng: Longitud del punto.
|
||||
geocerca_id: ID de la geocerca.
|
||||
|
||||
Returns:
|
||||
Tupla (está_dentro, distancia_al_borde_metros).
|
||||
distancia es None si está dentro, o la distancia al borde si está fuera.
|
||||
"""
|
||||
result = await self.db.execute(
|
||||
select(Geocerca).where(Geocerca.id == geocerca_id)
|
||||
)
|
||||
geocerca = result.scalar_one_or_none()
|
||||
|
||||
if not geocerca:
|
||||
return False, None
|
||||
|
||||
if geocerca.tipo == "circular":
|
||||
return self._punto_en_circulo(
|
||||
lat, lng,
|
||||
geocerca.centro_lat, geocerca.centro_lng,
|
||||
geocerca.radio_metros
|
||||
)
|
||||
else:
|
||||
coordenadas = json.loads(geocerca.coordenadas_json) if geocerca.coordenadas_json else []
|
||||
return self._punto_en_poligono(lat, lng, coordenadas)
|
||||
|
||||
async def obtener_geocercas_activas_para_vehiculo(
|
||||
self,
|
||||
vehiculo_id: int,
|
||||
) -> List[Geocerca]:
|
||||
"""
|
||||
Obtiene las geocercas activas aplicables a un vehículo.
|
||||
|
||||
Args:
|
||||
vehiculo_id: ID del vehículo.
|
||||
|
||||
Returns:
|
||||
Lista de geocercas aplicables.
|
||||
"""
|
||||
# Geocercas sin vehículos asignados (aplican a todos)
|
||||
# o con este vehículo asignado
|
||||
result = await self.db.execute(
|
||||
select(Geocerca)
|
||||
.where(Geocerca.activa == True)
|
||||
)
|
||||
todas_geocercas = result.scalars().all()
|
||||
|
||||
geocercas_aplicables = []
|
||||
for g in todas_geocercas:
|
||||
# Si no tiene vehículos asignados, aplica a todos
|
||||
if not g.vehiculos_asignados:
|
||||
geocercas_aplicables.append(g)
|
||||
# Si tiene vehículos asignados, verificar si incluye este
|
||||
elif any(v.id == vehiculo_id for v in g.vehiculos_asignados):
|
||||
geocercas_aplicables.append(g)
|
||||
|
||||
return geocercas_aplicables
|
||||
|
||||
async def verificar_todas_geocercas(
|
||||
self,
|
||||
lat: float,
|
||||
lng: float,
|
||||
vehiculo_id: int,
|
||||
) -> List[dict]:
|
||||
"""
|
||||
Verifica un punto contra todas las geocercas aplicables.
|
||||
|
||||
Args:
|
||||
lat: Latitud del punto.
|
||||
lng: Longitud del punto.
|
||||
vehiculo_id: ID del vehículo.
|
||||
|
||||
Returns:
|
||||
Lista de geocercas con información de si está dentro o fuera.
|
||||
"""
|
||||
geocercas = await self.obtener_geocercas_activas_para_vehiculo(vehiculo_id)
|
||||
resultados = []
|
||||
|
||||
for g in geocercas:
|
||||
if g.tipo == "circular":
|
||||
dentro, distancia = self._punto_en_circulo(
|
||||
lat, lng,
|
||||
g.centro_lat, g.centro_lng,
|
||||
g.radio_metros
|
||||
)
|
||||
else:
|
||||
coordenadas = json.loads(g.coordenadas_json) if g.coordenadas_json else []
|
||||
dentro, distancia = self._punto_en_poligono(lat, lng, coordenadas)
|
||||
|
||||
resultados.append({
|
||||
"geocerca_id": g.id,
|
||||
"geocerca_nombre": g.nombre,
|
||||
"dentro": dentro,
|
||||
"distancia_metros": distancia,
|
||||
"alerta_entrada": g.alerta_entrada,
|
||||
"alerta_salida": g.alerta_salida,
|
||||
"velocidad_maxima": g.velocidad_maxima,
|
||||
})
|
||||
|
||||
return resultados
|
||||
|
||||
def _punto_en_circulo(
|
||||
self,
|
||||
lat: float,
|
||||
lng: float,
|
||||
centro_lat: float,
|
||||
centro_lng: float,
|
||||
radio_metros: float,
|
||||
) -> Tuple[bool, Optional[float]]:
|
||||
"""
|
||||
Verifica si un punto está dentro de un círculo.
|
||||
|
||||
Args:
|
||||
lat, lng: Coordenadas del punto.
|
||||
centro_lat, centro_lng: Centro del círculo.
|
||||
radio_metros: Radio del círculo.
|
||||
|
||||
Returns:
|
||||
(está_dentro, distancia_al_borde).
|
||||
"""
|
||||
distancia = self._distancia_haversine(lat, lng, centro_lat, centro_lng)
|
||||
distancia_metros = distancia * 1000 # km a metros
|
||||
|
||||
dentro = distancia_metros <= radio_metros
|
||||
|
||||
if dentro:
|
||||
return True, None
|
||||
else:
|
||||
return False, distancia_metros - radio_metros
|
||||
|
||||
def _punto_en_poligono(
|
||||
self,
|
||||
lat: float,
|
||||
lng: float,
|
||||
coordenadas: List[List[float]],
|
||||
) -> Tuple[bool, Optional[float]]:
|
||||
"""
|
||||
Verifica si un punto está dentro de un polígono.
|
||||
|
||||
Usa el algoritmo ray casting.
|
||||
|
||||
Args:
|
||||
lat, lng: Coordenadas del punto.
|
||||
coordenadas: Lista de coordenadas [[lat, lng], ...].
|
||||
|
||||
Returns:
|
||||
(está_dentro, distancia_al_borde).
|
||||
"""
|
||||
if not coordenadas or len(coordenadas) < 3:
|
||||
return False, None
|
||||
|
||||
n = len(coordenadas)
|
||||
dentro = False
|
||||
|
||||
j = n - 1
|
||||
for i in range(n):
|
||||
yi, xi = coordenadas[i][0], coordenadas[i][1]
|
||||
yj, xj = coordenadas[j][0], coordenadas[j][1]
|
||||
|
||||
if ((yi > lat) != (yj > lat)) and (
|
||||
lng < (xj - xi) * (lat - yi) / (yj - yi) + xi
|
||||
):
|
||||
dentro = not dentro
|
||||
|
||||
j = i
|
||||
|
||||
if dentro:
|
||||
return True, None
|
||||
else:
|
||||
# Calcular distancia al borde más cercano
|
||||
distancia_min = float('inf')
|
||||
for i in range(n):
|
||||
j = (i + 1) % n
|
||||
d = self._distancia_punto_segmento(
|
||||
lat, lng,
|
||||
coordenadas[i][0], coordenadas[i][1],
|
||||
coordenadas[j][0], coordenadas[j][1]
|
||||
)
|
||||
if d < distancia_min:
|
||||
distancia_min = d
|
||||
|
||||
return False, distancia_min * 1000 # km a metros
|
||||
|
||||
def _distancia_haversine(
|
||||
self,
|
||||
lat1: float,
|
||||
lng1: float,
|
||||
lat2: float,
|
||||
lng2: float,
|
||||
) -> float:
|
||||
"""
|
||||
Calcula la distancia entre dos puntos usando Haversine.
|
||||
|
||||
Args:
|
||||
lat1, lng1: Primer punto.
|
||||
lat2, lng2: Segundo punto.
|
||||
|
||||
Returns:
|
||||
Distancia en kilómetros.
|
||||
"""
|
||||
R = 6371 # Radio de la Tierra en km
|
||||
|
||||
lat1_rad = math.radians(lat1)
|
||||
lat2_rad = math.radians(lat2)
|
||||
dlat = math.radians(lat2 - lat1)
|
||||
dlng = math.radians(lng2 - lng1)
|
||||
|
||||
a = (
|
||||
math.sin(dlat / 2) ** 2
|
||||
+ math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin(dlng / 2) ** 2
|
||||
)
|
||||
c = 2 * math.asin(math.sqrt(a))
|
||||
|
||||
return R * c
|
||||
|
||||
def _distancia_punto_segmento(
|
||||
self,
|
||||
px: float,
|
||||
py: float,
|
||||
x1: float,
|
||||
y1: float,
|
||||
x2: float,
|
||||
y2: float,
|
||||
) -> float:
|
||||
"""
|
||||
Calcula la distancia de un punto a un segmento de línea.
|
||||
|
||||
Args:
|
||||
px, py: Punto.
|
||||
x1, y1, x2, y2: Extremos del segmento.
|
||||
|
||||
Returns:
|
||||
Distancia en kilómetros.
|
||||
"""
|
||||
# Longitud del segmento al cuadrado
|
||||
l2 = (x2 - x1) ** 2 + (y2 - y1) ** 2
|
||||
|
||||
if l2 == 0:
|
||||
# El segmento es un punto
|
||||
return self._distancia_haversine(px, py, x1, y1)
|
||||
|
||||
# Proyección del punto sobre la línea
|
||||
t = max(0, min(1, ((px - x1) * (x2 - x1) + (py - y1) * (y2 - y1)) / l2))
|
||||
|
||||
# Punto más cercano en el segmento
|
||||
proj_x = x1 + t * (x2 - x1)
|
||||
proj_y = y1 + t * (y2 - y1)
|
||||
|
||||
return self._distancia_haversine(px, py, proj_x, proj_y)
|
||||
|
||||
@staticmethod
|
||||
def calcular_area_poligono(coordenadas: List[List[float]]) -> float:
|
||||
"""
|
||||
Calcula el área de un polígono en metros cuadrados.
|
||||
|
||||
Args:
|
||||
coordenadas: Lista de coordenadas [[lat, lng], ...].
|
||||
|
||||
Returns:
|
||||
Área en metros cuadrados.
|
||||
"""
|
||||
if len(coordenadas) < 3:
|
||||
return 0.0
|
||||
|
||||
# Usar la fórmula del topógrafo (Shoelace) con conversión a metros
|
||||
n = len(coordenadas)
|
||||
area = 0.0
|
||||
|
||||
# Factor de conversión aproximado para grados a metros
|
||||
# (varía según la latitud)
|
||||
lat_media = sum(c[0] for c in coordenadas) / n
|
||||
m_per_deg_lat = 111132.92 - 559.82 * math.cos(2 * math.radians(lat_media))
|
||||
m_per_deg_lng = 111412.84 * math.cos(math.radians(lat_media))
|
||||
|
||||
for i in range(n):
|
||||
j = (i + 1) % n
|
||||
xi = coordenadas[i][1] * m_per_deg_lng
|
||||
yi = coordenadas[i][0] * m_per_deg_lat
|
||||
xj = coordenadas[j][1] * m_per_deg_lng
|
||||
yj = coordenadas[j][0] * m_per_deg_lat
|
||||
|
||||
area += xi * yj - xj * yi
|
||||
|
||||
return abs(area) / 2
|
||||
|
||||
@staticmethod
|
||||
def calcular_perimetro_poligono(coordenadas: List[List[float]]) -> float:
|
||||
"""
|
||||
Calcula el perímetro de un polígono en metros.
|
||||
|
||||
Args:
|
||||
coordenadas: Lista de coordenadas [[lat, lng], ...].
|
||||
|
||||
Returns:
|
||||
Perímetro en metros.
|
||||
"""
|
||||
if len(coordenadas) < 2:
|
||||
return 0.0
|
||||
|
||||
servicio = GeocercaService(None) # Solo para usar método estático
|
||||
perimetro = 0.0
|
||||
n = len(coordenadas)
|
||||
|
||||
for i in range(n):
|
||||
j = (i + 1) % n
|
||||
d = servicio._distancia_haversine(
|
||||
coordenadas[i][0], coordenadas[i][1],
|
||||
coordenadas[j][0], coordenadas[j][1]
|
||||
)
|
||||
perimetro += d * 1000 # km a metros
|
||||
|
||||
return perimetro
|
||||
348
backend/app/services/notificacion_service.py
Normal file
348
backend/app/services/notificacion_service.py
Normal file
@@ -0,0 +1,348 @@
|
||||
"""
|
||||
Servicio para envío de notificaciones.
|
||||
|
||||
Maneja el envío de notificaciones por email, push y SMS.
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from typing import List, Optional
|
||||
|
||||
import aiosmtplib
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.alerta import Alerta
|
||||
|
||||
|
||||
class NotificacionService:
|
||||
"""Servicio para envío de notificaciones."""
|
||||
|
||||
def __init__(self, db: AsyncSession = None):
|
||||
"""
|
||||
Inicializa el servicio.
|
||||
|
||||
Args:
|
||||
db: Sesión de base de datos async (opcional).
|
||||
"""
|
||||
self.db = db
|
||||
|
||||
async def enviar_notificacion_alerta(
|
||||
self,
|
||||
alerta: Alerta,
|
||||
destinatarios_email: List[str] = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Envía notificaciones para una alerta.
|
||||
|
||||
Args:
|
||||
alerta: Alerta a notificar.
|
||||
destinatarios_email: Lista de emails (opcional, usa config si no se especifica).
|
||||
|
||||
Returns:
|
||||
Resultado del envío.
|
||||
"""
|
||||
resultado = {
|
||||
"email_enviado": False,
|
||||
"push_enviado": False,
|
||||
"sms_enviado": False,
|
||||
}
|
||||
|
||||
# Determinar si enviar cada tipo de notificación
|
||||
tipo_alerta = alerta.tipo_alerta
|
||||
|
||||
if tipo_alerta.notificar_email:
|
||||
resultado["email_enviado"] = await self.enviar_email_alerta(
|
||||
alerta,
|
||||
destinatarios_email,
|
||||
)
|
||||
|
||||
if tipo_alerta.notificar_push:
|
||||
resultado["push_enviado"] = await self.enviar_push_alerta(alerta)
|
||||
|
||||
if tipo_alerta.notificar_sms:
|
||||
resultado["sms_enviado"] = await self.enviar_sms_alerta(alerta)
|
||||
|
||||
# Actualizar estado de notificaciones en la alerta
|
||||
if self.db:
|
||||
alerta.notificacion_email_enviada = resultado["email_enviado"]
|
||||
alerta.notificacion_push_enviada = resultado["push_enviado"]
|
||||
alerta.notificacion_sms_enviada = resultado["sms_enviado"]
|
||||
await self.db.commit()
|
||||
|
||||
return resultado
|
||||
|
||||
async def enviar_email_alerta(
|
||||
self,
|
||||
alerta: Alerta,
|
||||
destinatarios: List[str] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Envía notificación de alerta por email.
|
||||
|
||||
Args:
|
||||
alerta: Alerta a notificar.
|
||||
destinatarios: Lista de emails.
|
||||
|
||||
Returns:
|
||||
True si se envió correctamente.
|
||||
"""
|
||||
if not settings.SMTP_HOST or not settings.SMTP_USER:
|
||||
return False
|
||||
|
||||
destinatarios = destinatarios or [settings.SMTP_FROM_EMAIL]
|
||||
|
||||
# Crear mensaje
|
||||
mensaje = MIMEMultipart("alternative")
|
||||
mensaje["Subject"] = f"[{alerta.severidad.upper()}] {alerta.mensaje[:50]}"
|
||||
mensaje["From"] = f"{settings.SMTP_FROM_NAME} <{settings.SMTP_FROM_EMAIL}>"
|
||||
mensaje["To"] = ", ".join(destinatarios)
|
||||
|
||||
# Contenido HTML
|
||||
html_content = self._crear_html_alerta(alerta)
|
||||
mensaje.attach(MIMEText(html_content, "html"))
|
||||
|
||||
# Contenido texto plano
|
||||
text_content = self._crear_texto_alerta(alerta)
|
||||
mensaje.attach(MIMEText(text_content, "plain"))
|
||||
|
||||
try:
|
||||
async with aiosmtplib.SMTP(
|
||||
hostname=settings.SMTP_HOST,
|
||||
port=settings.SMTP_PORT,
|
||||
use_tls=settings.SMTP_TLS,
|
||||
) as smtp:
|
||||
await smtp.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
|
||||
await smtp.send_message(mensaje)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error enviando email: {e}")
|
||||
return False
|
||||
|
||||
async def enviar_push_alerta(
|
||||
self,
|
||||
alerta: Alerta,
|
||||
) -> bool:
|
||||
"""
|
||||
Envía notificación push de alerta.
|
||||
|
||||
Args:
|
||||
alerta: Alerta a notificar.
|
||||
|
||||
Returns:
|
||||
True si se envió correctamente.
|
||||
"""
|
||||
if not settings.FIREBASE_ENABLED:
|
||||
return False
|
||||
|
||||
# TODO: Implementar con Firebase Cloud Messaging
|
||||
# from firebase_admin import messaging
|
||||
#
|
||||
# message = messaging.Message(
|
||||
# notification=messaging.Notification(
|
||||
# title=f"Alerta: {alerta.tipo_alerta.nombre}",
|
||||
# body=alerta.mensaje,
|
||||
# ),
|
||||
# topic="alertas",
|
||||
# )
|
||||
# messaging.send(message)
|
||||
|
||||
return False
|
||||
|
||||
async def enviar_sms_alerta(
|
||||
self,
|
||||
alerta: Alerta,
|
||||
) -> bool:
|
||||
"""
|
||||
Envía notificación SMS de alerta.
|
||||
|
||||
Args:
|
||||
alerta: Alerta a notificar.
|
||||
|
||||
Returns:
|
||||
True si se envió correctamente.
|
||||
"""
|
||||
# TODO: Implementar con Twilio u otro proveedor SMS
|
||||
return False
|
||||
|
||||
async def enviar_email(
|
||||
self,
|
||||
destinatarios: List[str],
|
||||
asunto: str,
|
||||
contenido_html: str,
|
||||
contenido_texto: str = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Envía un email genérico.
|
||||
|
||||
Args:
|
||||
destinatarios: Lista de emails.
|
||||
asunto: Asunto del email.
|
||||
contenido_html: Contenido HTML.
|
||||
contenido_texto: Contenido texto plano (opcional).
|
||||
|
||||
Returns:
|
||||
True si se envió correctamente.
|
||||
"""
|
||||
if not settings.SMTP_HOST or not settings.SMTP_USER:
|
||||
return False
|
||||
|
||||
mensaje = MIMEMultipart("alternative")
|
||||
mensaje["Subject"] = asunto
|
||||
mensaje["From"] = f"{settings.SMTP_FROM_NAME} <{settings.SMTP_FROM_EMAIL}>"
|
||||
mensaje["To"] = ", ".join(destinatarios)
|
||||
|
||||
mensaje.attach(MIMEText(contenido_html, "html"))
|
||||
if contenido_texto:
|
||||
mensaje.attach(MIMEText(contenido_texto, "plain"))
|
||||
|
||||
try:
|
||||
async with aiosmtplib.SMTP(
|
||||
hostname=settings.SMTP_HOST,
|
||||
port=settings.SMTP_PORT,
|
||||
use_tls=settings.SMTP_TLS,
|
||||
) as smtp:
|
||||
await smtp.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
|
||||
await smtp.send_message(mensaje)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error enviando email: {e}")
|
||||
return False
|
||||
|
||||
def _crear_html_alerta(self, alerta: Alerta) -> str:
|
||||
"""Crea el contenido HTML para el email de alerta."""
|
||||
color_severidad = {
|
||||
"baja": "#10B981",
|
||||
"media": "#F59E0B",
|
||||
"alta": "#EF4444",
|
||||
"critica": "#DC2626",
|
||||
}
|
||||
|
||||
color = color_severidad.get(alerta.severidad, "#6B7280")
|
||||
|
||||
html = f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body {{ font-family: Arial, sans-serif; line-height: 1.6; }}
|
||||
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
|
||||
.header {{ background-color: {color}; color: white; padding: 20px; border-radius: 8px 8px 0 0; }}
|
||||
.content {{ background-color: #f9fafb; padding: 20px; border: 1px solid #e5e7eb; }}
|
||||
.footer {{ padding: 10px; text-align: center; color: #6b7280; font-size: 12px; }}
|
||||
.badge {{ display: inline-block; padding: 4px 8px; border-radius: 4px; font-size: 12px; }}
|
||||
.info-row {{ margin: 10px 0; }}
|
||||
.label {{ color: #6b7280; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h2 style="margin: 0;">Alerta: {alerta.tipo_alerta.nombre if alerta.tipo_alerta else 'Sistema'}</h2>
|
||||
<span class="badge" style="background-color: rgba(255,255,255,0.2);">
|
||||
{alerta.severidad.upper()}
|
||||
</span>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p><strong>{alerta.mensaje}</strong></p>
|
||||
|
||||
<div class="info-row">
|
||||
<span class="label">Fecha/Hora:</span>
|
||||
{alerta.creado_en.strftime('%Y-%m-%d %H:%M:%S')}
|
||||
</div>
|
||||
|
||||
{'<div class="info-row"><span class="label">Vehiculo ID:</span> ' + str(alerta.vehiculo_id) + '</div>' if alerta.vehiculo_id else ''}
|
||||
|
||||
{'<div class="info-row"><span class="label">Ubicacion:</span> ' + str(alerta.lat) + ', ' + str(alerta.lng) + '</div>' if alerta.lat else ''}
|
||||
|
||||
{'<div class="info-row"><span class="label">Velocidad:</span> ' + str(alerta.velocidad) + ' km/h</div>' if alerta.velocidad else ''}
|
||||
|
||||
{f'<div class="info-row"><span class="label">Descripcion:</span> {alerta.descripcion}</div>' if alerta.descripcion else ''}
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>Este es un mensaje automatico de {settings.APP_NAME}</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
return html
|
||||
|
||||
def _crear_texto_alerta(self, alerta: Alerta) -> str:
|
||||
"""Crea el contenido de texto plano para el email de alerta."""
|
||||
texto = f"""
|
||||
ALERTA: {alerta.tipo_alerta.nombre if alerta.tipo_alerta else 'Sistema'}
|
||||
Severidad: {alerta.severidad.upper()}
|
||||
|
||||
{alerta.mensaje}
|
||||
|
||||
Fecha/Hora: {alerta.creado_en.strftime('%Y-%m-%d %H:%M:%S')}
|
||||
"""
|
||||
if alerta.vehiculo_id:
|
||||
texto += f"Vehiculo ID: {alerta.vehiculo_id}\n"
|
||||
if alerta.lat and alerta.lng:
|
||||
texto += f"Ubicacion: {alerta.lat}, {alerta.lng}\n"
|
||||
if alerta.velocidad:
|
||||
texto += f"Velocidad: {alerta.velocidad} km/h\n"
|
||||
|
||||
texto += f"\n--\nEste es un mensaje automatico de {settings.APP_NAME}"
|
||||
return texto
|
||||
|
||||
async def enviar_recordatorio_mantenimiento(
|
||||
self,
|
||||
vehiculo_nombre: str,
|
||||
vehiculo_placa: str,
|
||||
tipo_mantenimiento: str,
|
||||
fecha_programada: str,
|
||||
destinatarios: List[str],
|
||||
) -> bool:
|
||||
"""
|
||||
Envía recordatorio de mantenimiento por email.
|
||||
|
||||
Args:
|
||||
vehiculo_nombre: Nombre del vehículo.
|
||||
vehiculo_placa: Placa del vehículo.
|
||||
tipo_mantenimiento: Tipo de mantenimiento.
|
||||
fecha_programada: Fecha programada.
|
||||
destinatarios: Lista de emails.
|
||||
|
||||
Returns:
|
||||
True si se envió correctamente.
|
||||
"""
|
||||
asunto = f"Recordatorio: Mantenimiento próximo - {vehiculo_placa}"
|
||||
|
||||
html = f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body {{ font-family: Arial, sans-serif; line-height: 1.6; }}
|
||||
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
|
||||
.header {{ background-color: #3B82F6; color: white; padding: 20px; border-radius: 8px 8px 0 0; }}
|
||||
.content {{ background-color: #f9fafb; padding: 20px; border: 1px solid #e5e7eb; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h2>Recordatorio de Mantenimiento</h2>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>Se aproxima la fecha de mantenimiento programado:</p>
|
||||
<ul>
|
||||
<li><strong>Vehiculo:</strong> {vehiculo_nombre} ({vehiculo_placa})</li>
|
||||
<li><strong>Tipo:</strong> {tipo_mantenimiento}</li>
|
||||
<li><strong>Fecha programada:</strong> {fecha_programada}</li>
|
||||
</ul>
|
||||
<p>Por favor, programe el mantenimiento con anticipacion.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
return await self.enviar_email(destinatarios, asunto, html)
|
||||
529
backend/app/services/reporte_service.py
Normal file
529
backend/app/services/reporte_service.py
Normal file
@@ -0,0 +1,529 @@
|
||||
"""
|
||||
Servicio para generación de reportes.
|
||||
|
||||
Genera reportes en PDF y Excel para diferentes tipos de datos.
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, 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.carga_combustible import CargaCombustible
|
||||
from app.models.mantenimiento import Mantenimiento
|
||||
from app.models.ubicacion import Ubicacion
|
||||
from app.models.vehiculo import Vehiculo
|
||||
from app.models.viaje import Viaje
|
||||
from app.schemas.reporte import (
|
||||
DashboardGrafico,
|
||||
DashboardResumen,
|
||||
ReporteRequest,
|
||||
ReporteResponse,
|
||||
)
|
||||
|
||||
|
||||
class ReporteService:
|
||||
"""Servicio para generación de reportes."""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
"""
|
||||
Inicializa el servicio.
|
||||
|
||||
Args:
|
||||
db: Sesión de base de datos async.
|
||||
"""
|
||||
self.db = db
|
||||
|
||||
async def obtener_dashboard_resumen(self) -> DashboardResumen:
|
||||
"""
|
||||
Obtiene el resumen para el dashboard principal.
|
||||
|
||||
Returns:
|
||||
Datos del dashboard.
|
||||
"""
|
||||
ahora = datetime.now(timezone.utc)
|
||||
inicio_hoy = ahora.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
# Contadores de vehículos
|
||||
result = await self.db.execute(
|
||||
select(func.count(Vehiculo.id)).where(Vehiculo.activo == True)
|
||||
)
|
||||
total_vehiculos = result.scalar()
|
||||
|
||||
result = await self.db.execute(
|
||||
select(func.count(Vehiculo.id))
|
||||
.where(Vehiculo.activo == True)
|
||||
.where(Vehiculo.en_servicio == True)
|
||||
)
|
||||
vehiculos_activos = result.scalar()
|
||||
|
||||
# Vehículos en movimiento (velocidad > 5 km/h, última ubicación < 5 min)
|
||||
tiempo_reciente = ahora - timedelta(minutes=5)
|
||||
result = await self.db.execute(
|
||||
select(func.count(Vehiculo.id))
|
||||
.where(Vehiculo.activo == True)
|
||||
.where(Vehiculo.ultima_velocidad > 5)
|
||||
.where(Vehiculo.ultima_ubicacion_tiempo >= tiempo_reciente)
|
||||
)
|
||||
vehiculos_en_movimiento = result.scalar()
|
||||
|
||||
# Vehículos detenidos (velocidad < 5, ubicación reciente)
|
||||
result = await self.db.execute(
|
||||
select(func.count(Vehiculo.id))
|
||||
.where(Vehiculo.activo == True)
|
||||
.where(Vehiculo.ultima_velocidad <= 5)
|
||||
.where(Vehiculo.ultima_ubicacion_tiempo >= tiempo_reciente)
|
||||
)
|
||||
vehiculos_detenidos = result.scalar()
|
||||
|
||||
# Sin señal (última ubicación > 30 min)
|
||||
tiempo_sin_señal = ahora - timedelta(minutes=30)
|
||||
result = await self.db.execute(
|
||||
select(func.count(Vehiculo.id))
|
||||
.where(Vehiculo.activo == True)
|
||||
.where(Vehiculo.en_servicio == True)
|
||||
.where(Vehiculo.ultima_ubicacion_tiempo < tiempo_sin_señal)
|
||||
)
|
||||
vehiculos_sin_señal = result.scalar()
|
||||
|
||||
# Conductores (simplificado)
|
||||
from app.models.conductor import Conductor
|
||||
result = await self.db.execute(
|
||||
select(func.count(Conductor.id)).where(Conductor.activo == True)
|
||||
)
|
||||
conductores_activos = result.scalar()
|
||||
|
||||
# Alertas
|
||||
result = await self.db.execute(
|
||||
select(func.count(Alerta.id)).where(Alerta.atendida == False)
|
||||
)
|
||||
alertas_pendientes = result.scalar()
|
||||
|
||||
result = await self.db.execute(
|
||||
select(func.count(Alerta.id))
|
||||
.where(Alerta.atendida == False)
|
||||
.where(Alerta.severidad == "critica")
|
||||
)
|
||||
alertas_criticas = result.scalar()
|
||||
|
||||
result = await self.db.execute(
|
||||
select(func.count(Alerta.id)).where(Alerta.creado_en >= inicio_hoy)
|
||||
)
|
||||
alertas_hoy = result.scalar()
|
||||
|
||||
# Viajes de hoy
|
||||
result = await self.db.execute(
|
||||
select(func.count(Viaje.id)).where(Viaje.inicio_tiempo >= inicio_hoy)
|
||||
)
|
||||
viajes_hoy = result.scalar()
|
||||
|
||||
result = await self.db.execute(
|
||||
select(func.coalesce(func.sum(Viaje.distancia_km), 0))
|
||||
.where(Viaje.inicio_tiempo >= inicio_hoy)
|
||||
)
|
||||
distancia_hoy = result.scalar() or 0
|
||||
|
||||
# Mantenimientos
|
||||
result = await self.db.execute(
|
||||
select(func.count(Mantenimiento.id))
|
||||
.where(Mantenimiento.estado == "vencido")
|
||||
)
|
||||
mantenimientos_vencidos = result.scalar()
|
||||
|
||||
proximos_7_dias = ahora + timedelta(days=7)
|
||||
result = await self.db.execute(
|
||||
select(func.count(Mantenimiento.id))
|
||||
.where(Mantenimiento.estado == "programado")
|
||||
.where(Mantenimiento.fecha_programada <= proximos_7_dias.date())
|
||||
)
|
||||
mantenimientos_proximos = result.scalar()
|
||||
|
||||
return DashboardResumen(
|
||||
total_vehiculos=total_vehiculos,
|
||||
vehiculos_activos=vehiculos_activos,
|
||||
vehiculos_en_movimiento=vehiculos_en_movimiento,
|
||||
vehiculos_detenidos=vehiculos_detenidos,
|
||||
vehiculos_sin_señal=vehiculos_sin_señal,
|
||||
total_conductores=conductores_activos,
|
||||
conductores_activos=conductores_activos,
|
||||
alertas_pendientes=alertas_pendientes,
|
||||
alertas_criticas=alertas_criticas,
|
||||
alertas_hoy=alertas_hoy,
|
||||
viajes_hoy=viajes_hoy,
|
||||
distancia_hoy_km=float(distancia_hoy),
|
||||
mantenimientos_vencidos=mantenimientos_vencidos,
|
||||
mantenimientos_proximos=mantenimientos_proximos,
|
||||
actualizado_en=ahora,
|
||||
)
|
||||
|
||||
async def obtener_dashboard_graficos(self) -> DashboardGrafico:
|
||||
"""
|
||||
Obtiene datos para gráficos del dashboard.
|
||||
|
||||
Returns:
|
||||
Datos para gráficos.
|
||||
"""
|
||||
ahora = datetime.now(timezone.utc)
|
||||
|
||||
# Distancia por día (últimos 7 días)
|
||||
distancia_diaria = []
|
||||
for i in range(6, -1, -1):
|
||||
fecha = ahora - timedelta(days=i)
|
||||
inicio_dia = fecha.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
fin_dia = inicio_dia + timedelta(days=1)
|
||||
|
||||
result = await self.db.execute(
|
||||
select(func.coalesce(func.sum(Viaje.distancia_km), 0))
|
||||
.where(Viaje.inicio_tiempo >= inicio_dia)
|
||||
.where(Viaje.inicio_tiempo < fin_dia)
|
||||
)
|
||||
km = result.scalar() or 0
|
||||
|
||||
distancia_diaria.append({
|
||||
"fecha": inicio_dia.strftime("%Y-%m-%d"),
|
||||
"km": float(km),
|
||||
})
|
||||
|
||||
# Viajes por día
|
||||
viajes_diarios = []
|
||||
for i in range(6, -1, -1):
|
||||
fecha = ahora - timedelta(days=i)
|
||||
inicio_dia = fecha.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
fin_dia = inicio_dia + timedelta(days=1)
|
||||
|
||||
result = await self.db.execute(
|
||||
select(func.count(Viaje.id))
|
||||
.where(Viaje.inicio_tiempo >= inicio_dia)
|
||||
.where(Viaje.inicio_tiempo < fin_dia)
|
||||
)
|
||||
cantidad = result.scalar() or 0
|
||||
|
||||
viajes_diarios.append({
|
||||
"fecha": inicio_dia.strftime("%Y-%m-%d"),
|
||||
"cantidad": cantidad,
|
||||
})
|
||||
|
||||
# Alertas por tipo (últimos 7 días)
|
||||
inicio_semana = ahora - timedelta(days=7)
|
||||
from app.models.tipo_alerta import TipoAlerta
|
||||
result = await self.db.execute(
|
||||
select(TipoAlerta.nombre, func.count(Alerta.id))
|
||||
.join(TipoAlerta, Alerta.tipo_alerta_id == TipoAlerta.id)
|
||||
.where(Alerta.creado_en >= inicio_semana)
|
||||
.group_by(TipoAlerta.nombre)
|
||||
.order_by(func.count(Alerta.id).desc())
|
||||
.limit(5)
|
||||
)
|
||||
alertas_por_tipo = [
|
||||
{"tipo": row[0], "cantidad": row[1]}
|
||||
for row in result.all()
|
||||
]
|
||||
|
||||
# Consumo de combustible (últimos 30 días)
|
||||
consumo_combustible = []
|
||||
for i in range(29, -1, -1):
|
||||
fecha = ahora - timedelta(days=i)
|
||||
inicio_dia = fecha.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
fin_dia = inicio_dia + timedelta(days=1)
|
||||
|
||||
result = await self.db.execute(
|
||||
select(func.coalesce(func.sum(CargaCombustible.litros), 0))
|
||||
.where(CargaCombustible.fecha >= inicio_dia)
|
||||
.where(CargaCombustible.fecha < fin_dia)
|
||||
)
|
||||
litros = result.scalar() or 0
|
||||
|
||||
consumo_combustible.append({
|
||||
"fecha": inicio_dia.strftime("%Y-%m-%d"),
|
||||
"litros": float(litros),
|
||||
})
|
||||
|
||||
return DashboardGrafico(
|
||||
distancia_diaria=distancia_diaria,
|
||||
viajes_diarios=viajes_diarios,
|
||||
alertas_por_tipo=alertas_por_tipo,
|
||||
consumo_combustible=consumo_combustible,
|
||||
)
|
||||
|
||||
async def generar_reporte(
|
||||
self,
|
||||
request: ReporteRequest,
|
||||
) -> ReporteResponse:
|
||||
"""
|
||||
Genera un reporte según los parámetros especificados.
|
||||
|
||||
Args:
|
||||
request: Parámetros del reporte.
|
||||
|
||||
Returns:
|
||||
Información del reporte generado.
|
||||
"""
|
||||
reporte_id = str(uuid.uuid4())
|
||||
|
||||
# Recopilar datos según el tipo de reporte
|
||||
datos = await self._recopilar_datos_reporte(request)
|
||||
|
||||
# Generar archivo según formato
|
||||
if request.formato == "pdf":
|
||||
archivo_url = await self._generar_pdf(reporte_id, request.tipo, datos)
|
||||
elif request.formato == "excel":
|
||||
archivo_url = await self._generar_excel(reporte_id, request.tipo, datos)
|
||||
else: # csv
|
||||
archivo_url = await self._generar_csv(reporte_id, request.tipo, datos)
|
||||
|
||||
return ReporteResponse(
|
||||
id=reporte_id,
|
||||
tipo=request.tipo,
|
||||
formato=request.formato,
|
||||
estado="completado",
|
||||
archivo_url=archivo_url,
|
||||
creado_en=datetime.now(timezone.utc),
|
||||
completado_en=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
async def _recopilar_datos_reporte(
|
||||
self,
|
||||
request: ReporteRequest,
|
||||
) -> Dict[str, Any]:
|
||||
"""Recopila los datos necesarios para el reporte."""
|
||||
datos = {
|
||||
"periodo_inicio": request.fecha_inicio,
|
||||
"periodo_fin": request.fecha_fin,
|
||||
}
|
||||
|
||||
if request.tipo == "viajes":
|
||||
datos["viajes"] = await self._obtener_datos_viajes(
|
||||
request.fecha_inicio,
|
||||
request.fecha_fin,
|
||||
request.vehiculos_ids,
|
||||
)
|
||||
elif request.tipo == "alertas":
|
||||
datos["alertas"] = await self._obtener_datos_alertas(
|
||||
request.fecha_inicio,
|
||||
request.fecha_fin,
|
||||
request.vehiculos_ids,
|
||||
)
|
||||
elif request.tipo == "combustible":
|
||||
datos["combustible"] = await self._obtener_datos_combustible(
|
||||
request.fecha_inicio,
|
||||
request.fecha_fin,
|
||||
request.vehiculos_ids,
|
||||
)
|
||||
elif request.tipo == "mantenimiento":
|
||||
datos["mantenimiento"] = await self._obtener_datos_mantenimiento(
|
||||
request.fecha_inicio,
|
||||
request.fecha_fin,
|
||||
request.vehiculos_ids,
|
||||
)
|
||||
|
||||
return datos
|
||||
|
||||
async def _obtener_datos_viajes(
|
||||
self,
|
||||
desde: datetime,
|
||||
hasta: datetime,
|
||||
vehiculos_ids: List[int] = None,
|
||||
) -> List[dict]:
|
||||
"""Obtiene datos de viajes para el reporte."""
|
||||
query = (
|
||||
select(Viaje)
|
||||
.where(Viaje.inicio_tiempo >= desde)
|
||||
.where(Viaje.inicio_tiempo <= hasta)
|
||||
.order_by(Viaje.inicio_tiempo)
|
||||
)
|
||||
|
||||
if vehiculos_ids:
|
||||
query = query.where(Viaje.vehiculo_id.in_(vehiculos_ids))
|
||||
|
||||
result = await self.db.execute(query)
|
||||
viajes = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": v.id,
|
||||
"vehiculo_id": v.vehiculo_id,
|
||||
"inicio": v.inicio_tiempo.isoformat(),
|
||||
"fin": v.fin_tiempo.isoformat() if v.fin_tiempo else None,
|
||||
"distancia_km": v.distancia_km,
|
||||
"duracion_segundos": v.duracion_segundos,
|
||||
"velocidad_promedio": v.velocidad_promedio,
|
||||
"velocidad_maxima": v.velocidad_maxima,
|
||||
"estado": v.estado,
|
||||
}
|
||||
for v in viajes
|
||||
]
|
||||
|
||||
async def _obtener_datos_alertas(
|
||||
self,
|
||||
desde: datetime,
|
||||
hasta: datetime,
|
||||
vehiculos_ids: List[int] = None,
|
||||
) -> List[dict]:
|
||||
"""Obtiene datos de alertas para el reporte."""
|
||||
query = (
|
||||
select(Alerta)
|
||||
.where(Alerta.creado_en >= desde)
|
||||
.where(Alerta.creado_en <= hasta)
|
||||
.order_by(Alerta.creado_en)
|
||||
)
|
||||
|
||||
if vehiculos_ids:
|
||||
query = query.where(Alerta.vehiculo_id.in_(vehiculos_ids))
|
||||
|
||||
result = await self.db.execute(query)
|
||||
alertas = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": a.id,
|
||||
"vehiculo_id": a.vehiculo_id,
|
||||
"tipo_alerta_id": a.tipo_alerta_id,
|
||||
"severidad": a.severidad,
|
||||
"mensaje": a.mensaje,
|
||||
"creado_en": a.creado_en.isoformat(),
|
||||
"atendida": a.atendida,
|
||||
}
|
||||
for a in alertas
|
||||
]
|
||||
|
||||
async def _obtener_datos_combustible(
|
||||
self,
|
||||
desde: datetime,
|
||||
hasta: datetime,
|
||||
vehiculos_ids: List[int] = None,
|
||||
) -> List[dict]:
|
||||
"""Obtiene datos de combustible para el reporte."""
|
||||
query = (
|
||||
select(CargaCombustible)
|
||||
.where(CargaCombustible.fecha >= desde)
|
||||
.where(CargaCombustible.fecha <= hasta)
|
||||
.order_by(CargaCombustible.fecha)
|
||||
)
|
||||
|
||||
if vehiculos_ids:
|
||||
query = query.where(CargaCombustible.vehiculo_id.in_(vehiculos_ids))
|
||||
|
||||
result = await self.db.execute(query)
|
||||
cargas = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": c.id,
|
||||
"vehiculo_id": c.vehiculo_id,
|
||||
"fecha": c.fecha.isoformat(),
|
||||
"litros": c.litros,
|
||||
"precio_litro": c.precio_litro,
|
||||
"total": c.total,
|
||||
"odometro": c.odometro,
|
||||
"estacion": c.estacion,
|
||||
}
|
||||
for c in cargas
|
||||
]
|
||||
|
||||
async def _obtener_datos_mantenimiento(
|
||||
self,
|
||||
desde: datetime,
|
||||
hasta: datetime,
|
||||
vehiculos_ids: List[int] = None,
|
||||
) -> List[dict]:
|
||||
"""Obtiene datos de mantenimiento para el reporte."""
|
||||
query = (
|
||||
select(Mantenimiento)
|
||||
.where(Mantenimiento.fecha_programada >= desde.date())
|
||||
.where(Mantenimiento.fecha_programada <= hasta.date())
|
||||
.order_by(Mantenimiento.fecha_programada)
|
||||
)
|
||||
|
||||
if vehiculos_ids:
|
||||
query = query.where(Mantenimiento.vehiculo_id.in_(vehiculos_ids))
|
||||
|
||||
result = await self.db.execute(query)
|
||||
mantenimientos = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": m.id,
|
||||
"vehiculo_id": m.vehiculo_id,
|
||||
"tipo_mantenimiento_id": m.tipo_mantenimiento_id,
|
||||
"estado": m.estado,
|
||||
"fecha_programada": m.fecha_programada.isoformat(),
|
||||
"fecha_realizada": m.fecha_realizada.isoformat() if m.fecha_realizada else None,
|
||||
"costo_real": m.costo_real,
|
||||
}
|
||||
for m in mantenimientos
|
||||
]
|
||||
|
||||
async def _generar_pdf(
|
||||
self,
|
||||
reporte_id: str,
|
||||
tipo: str,
|
||||
datos: Dict[str, Any],
|
||||
) -> str:
|
||||
"""Genera un reporte en PDF."""
|
||||
# Implementación simplificada
|
||||
# En producción se usaría WeasyPrint o similar
|
||||
archivo_path = f"{settings.REPORTS_DIR}/{reporte_id}.pdf"
|
||||
# TODO: Implementar generación de PDF con WeasyPrint
|
||||
return archivo_path
|
||||
|
||||
async def _generar_excel(
|
||||
self,
|
||||
reporte_id: str,
|
||||
tipo: str,
|
||||
datos: Dict[str, Any],
|
||||
) -> str:
|
||||
"""Genera un reporte en Excel."""
|
||||
try:
|
||||
from openpyxl import Workbook
|
||||
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = tipo.capitalize()
|
||||
|
||||
# Escribir datos según el tipo
|
||||
if tipo in datos:
|
||||
items = datos[tipo]
|
||||
if items:
|
||||
# Headers
|
||||
headers = list(items[0].keys())
|
||||
for col, header in enumerate(headers, 1):
|
||||
ws.cell(row=1, column=col, value=header)
|
||||
|
||||
# Data
|
||||
for row, item in enumerate(items, 2):
|
||||
for col, key in enumerate(headers, 1):
|
||||
ws.cell(row=row, column=col, value=item.get(key))
|
||||
|
||||
archivo_path = f"{settings.REPORTS_DIR}/{reporte_id}.xlsx"
|
||||
wb.save(archivo_path)
|
||||
return archivo_path
|
||||
|
||||
except ImportError:
|
||||
return ""
|
||||
|
||||
async def _generar_csv(
|
||||
self,
|
||||
reporte_id: str,
|
||||
tipo: str,
|
||||
datos: Dict[str, Any],
|
||||
) -> str:
|
||||
"""Genera un reporte en CSV."""
|
||||
import csv
|
||||
|
||||
archivo_path = f"{settings.REPORTS_DIR}/{reporte_id}.csv"
|
||||
|
||||
if tipo in datos:
|
||||
items = datos[tipo]
|
||||
if items:
|
||||
with open(archivo_path, 'w', newline='') as f:
|
||||
writer = csv.DictWriter(f, fieldnames=items[0].keys())
|
||||
writer.writeheader()
|
||||
writer.writerows(items)
|
||||
|
||||
return archivo_path
|
||||
286
backend/app/services/traccar_service.py
Normal file
286
backend/app/services/traccar_service.py
Normal file
@@ -0,0 +1,286 @@
|
||||
"""
|
||||
Servicio para integración con Traccar.
|
||||
|
||||
Recibe datos de ubicación desde Traccar via forward
|
||||
y los procesa en el sistema.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.dispositivo import Dispositivo
|
||||
from app.schemas.ubicacion import TraccarLocationCreate, UbicacionCreate
|
||||
from app.services.ubicacion_service import UbicacionService
|
||||
|
||||
|
||||
class TraccarService:
|
||||
"""Servicio para integración con Traccar GPS Server."""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
"""
|
||||
Inicializa el servicio.
|
||||
|
||||
Args:
|
||||
db: Sesión de base de datos async.
|
||||
"""
|
||||
self.db = db
|
||||
self.ubicacion_service = UbicacionService(db)
|
||||
self.api_url = settings.TRACCAR_API_URL
|
||||
self.username = settings.TRACCAR_USERNAME
|
||||
self.password = settings.TRACCAR_PASSWORD
|
||||
|
||||
async def procesar_posicion_traccar(
|
||||
self,
|
||||
posicion: TraccarLocationCreate,
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
Procesa una posición recibida desde Traccar.
|
||||
|
||||
Args:
|
||||
posicion: Datos de posición de Traccar.
|
||||
|
||||
Returns:
|
||||
Resultado del procesamiento o None.
|
||||
"""
|
||||
# Buscar dispositivo por ID de Traccar
|
||||
result = await self.db.execute(
|
||||
select(Dispositivo)
|
||||
.where(Dispositivo.identificador == str(posicion.deviceId))
|
||||
.where(Dispositivo.protocolo == "traccar")
|
||||
)
|
||||
dispositivo = result.scalar_one_or_none()
|
||||
|
||||
if not dispositivo:
|
||||
# Intentar buscar por IMEI en attributes
|
||||
if posicion.attributes and "imei" in posicion.attributes:
|
||||
result = await self.db.execute(
|
||||
select(Dispositivo)
|
||||
.where(Dispositivo.imei == posicion.attributes["imei"])
|
||||
)
|
||||
dispositivo = result.scalar_one_or_none()
|
||||
|
||||
if not dispositivo:
|
||||
return None
|
||||
|
||||
# Convertir velocidad de nudos a km/h
|
||||
velocidad = None
|
||||
if posicion.speed is not None:
|
||||
velocidad = posicion.speed * 1.852 # nudos a km/h
|
||||
|
||||
# Extraer datos adicionales de attributes
|
||||
bateria = None
|
||||
motor_encendido = None
|
||||
odometro = None
|
||||
|
||||
if posicion.attributes:
|
||||
bateria = posicion.attributes.get("batteryLevel")
|
||||
motor_encendido = posicion.attributes.get("ignition")
|
||||
# Odómetro puede venir en metros
|
||||
odometro_metros = posicion.attributes.get("totalDistance")
|
||||
if odometro_metros:
|
||||
odometro = odometro_metros / 1000 # a km
|
||||
|
||||
# Crear schema de ubicación
|
||||
ubicacion_data = UbicacionCreate(
|
||||
vehiculo_id=dispositivo.vehiculo_id,
|
||||
dispositivo_id=dispositivo.identificador,
|
||||
lat=posicion.latitude,
|
||||
lng=posicion.longitude,
|
||||
velocidad=velocidad,
|
||||
rumbo=posicion.course,
|
||||
altitud=posicion.altitude,
|
||||
precision=posicion.accuracy,
|
||||
tiempo=posicion.fixTime,
|
||||
fuente="traccar",
|
||||
bateria_dispositivo=bateria,
|
||||
motor_encendido=motor_encendido,
|
||||
odometro=odometro,
|
||||
)
|
||||
|
||||
# Procesar ubicación
|
||||
resultado = await self.ubicacion_service.procesar_ubicacion(ubicacion_data)
|
||||
|
||||
if resultado:
|
||||
return {
|
||||
"status": "processed",
|
||||
"vehiculo_id": dispositivo.vehiculo_id,
|
||||
"dispositivo_id": dispositivo.identificador,
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
async def sincronizar_dispositivos(self) -> dict:
|
||||
"""
|
||||
Sincroniza dispositivos desde Traccar.
|
||||
|
||||
Obtiene la lista de dispositivos de Traccar y los sincroniza
|
||||
con la base de datos local.
|
||||
|
||||
Returns:
|
||||
Resultado de la sincronización.
|
||||
"""
|
||||
if not self.username or not self.password:
|
||||
return {"error": "Credenciales de Traccar no configuradas"}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
f"{self.api_url}/devices",
|
||||
auth=(self.username, self.password),
|
||||
timeout=30.0,
|
||||
)
|
||||
response.raise_for_status()
|
||||
dispositivos_traccar = response.json()
|
||||
except httpx.HTTPError as e:
|
||||
return {"error": f"Error conectando a Traccar: {str(e)}"}
|
||||
|
||||
sincronizados = 0
|
||||
for d in dispositivos_traccar:
|
||||
# Verificar si ya existe
|
||||
result = await self.db.execute(
|
||||
select(Dispositivo)
|
||||
.where(Dispositivo.identificador == str(d["id"]))
|
||||
.where(Dispositivo.protocolo == "traccar")
|
||||
)
|
||||
dispositivo = result.scalar_one_or_none()
|
||||
|
||||
if not dispositivo:
|
||||
# Solo registrar, no crear vehículo automáticamente
|
||||
continue
|
||||
|
||||
# Actualizar información
|
||||
dispositivo.nombre = d.get("name", dispositivo.nombre)
|
||||
if d.get("lastUpdate"):
|
||||
dispositivo.ultimo_contacto = datetime.fromisoformat(
|
||||
d["lastUpdate"].replace("Z", "+00:00")
|
||||
)
|
||||
dispositivo.conectado = d.get("status", "") == "online"
|
||||
|
||||
sincronizados += 1
|
||||
|
||||
await self.db.commit()
|
||||
|
||||
return {
|
||||
"total_traccar": len(dispositivos_traccar),
|
||||
"sincronizados": sincronizados,
|
||||
}
|
||||
|
||||
async def obtener_posicion_actual(
|
||||
self,
|
||||
dispositivo_id: str,
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
Obtiene la posición actual de un dispositivo desde Traccar.
|
||||
|
||||
Args:
|
||||
dispositivo_id: ID del dispositivo en Traccar.
|
||||
|
||||
Returns:
|
||||
Posición actual o None.
|
||||
"""
|
||||
if not self.username or not self.password:
|
||||
return None
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
f"{self.api_url}/positions",
|
||||
params={"deviceId": dispositivo_id},
|
||||
auth=(self.username, self.password),
|
||||
timeout=10.0,
|
||||
)
|
||||
response.raise_for_status()
|
||||
posiciones = response.json()
|
||||
|
||||
if posiciones:
|
||||
return posiciones[0]
|
||||
|
||||
except httpx.HTTPError:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
async def enviar_comando(
|
||||
self,
|
||||
dispositivo_id: str,
|
||||
tipo_comando: str,
|
||||
data: dict = None,
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
Envía un comando a un dispositivo via Traccar.
|
||||
|
||||
Args:
|
||||
dispositivo_id: ID del dispositivo en Traccar.
|
||||
tipo_comando: Tipo de comando (ej: "engineStop", "engineResume").
|
||||
data: Datos adicionales del comando.
|
||||
|
||||
Returns:
|
||||
Respuesta de Traccar o None.
|
||||
"""
|
||||
if not self.username or not self.password:
|
||||
return None
|
||||
|
||||
comando = {
|
||||
"deviceId": int(dispositivo_id),
|
||||
"type": tipo_comando,
|
||||
"attributes": data or {},
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
f"{self.api_url}/commands/send",
|
||||
json=comando,
|
||||
auth=(self.username, self.password),
|
||||
timeout=30.0,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
async def obtener_reportes_traccar(
|
||||
self,
|
||||
dispositivo_id: str,
|
||||
desde: datetime,
|
||||
hasta: datetime,
|
||||
tipo: str = "route",
|
||||
) -> Optional[list]:
|
||||
"""
|
||||
Obtiene reportes desde Traccar.
|
||||
|
||||
Args:
|
||||
dispositivo_id: ID del dispositivo.
|
||||
desde: Fecha inicio.
|
||||
hasta: Fecha fin.
|
||||
tipo: Tipo de reporte (route, events, trips, stops).
|
||||
|
||||
Returns:
|
||||
Lista de datos del reporte.
|
||||
"""
|
||||
if not self.username or not self.password:
|
||||
return None
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
f"{self.api_url}/reports/{tipo}",
|
||||
params={
|
||||
"deviceId": dispositivo_id,
|
||||
"from": desde.isoformat(),
|
||||
"to": hasta.isoformat(),
|
||||
},
|
||||
auth=(self.username, self.password),
|
||||
timeout=60.0,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
except httpx.HTTPError:
|
||||
return None
|
||||
489
backend/app/services/ubicacion_service.py
Normal file
489
backend/app/services/ubicacion_service.py
Normal file
@@ -0,0 +1,489 @@
|
||||
"""
|
||||
Servicio para procesamiento de ubicaciones GPS.
|
||||
|
||||
Maneja la recepción, procesamiento y análisis de datos de ubicación.
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.dispositivo import Dispositivo
|
||||
from app.models.ubicacion import Ubicacion
|
||||
from app.models.vehiculo import Vehiculo
|
||||
from app.schemas.ubicacion import (
|
||||
HistorialUbicacionesResponse,
|
||||
UbicacionCreate,
|
||||
UbicacionResponse,
|
||||
)
|
||||
|
||||
|
||||
class UbicacionService:
|
||||
"""Servicio para gestión de ubicaciones GPS."""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
"""
|
||||
Inicializa el servicio.
|
||||
|
||||
Args:
|
||||
db: Sesión de base de datos async.
|
||||
"""
|
||||
self.db = db
|
||||
|
||||
async def procesar_ubicacion(
|
||||
self,
|
||||
ubicacion_data: UbicacionCreate,
|
||||
) -> Optional[UbicacionResponse]:
|
||||
"""
|
||||
Procesa una nueva ubicación recibida.
|
||||
|
||||
Args:
|
||||
ubicacion_data: Datos de la ubicación a procesar.
|
||||
|
||||
Returns:
|
||||
UbicacionResponse si se procesó correctamente, None si se descartó.
|
||||
"""
|
||||
# Determinar el vehículo
|
||||
vehiculo_id = ubicacion_data.vehiculo_id
|
||||
|
||||
if not vehiculo_id and ubicacion_data.dispositivo_id:
|
||||
# Buscar vehículo por identificador de dispositivo
|
||||
result = await self.db.execute(
|
||||
select(Dispositivo)
|
||||
.where(Dispositivo.identificador == ubicacion_data.dispositivo_id)
|
||||
.where(Dispositivo.activo == True)
|
||||
)
|
||||
dispositivo = result.scalar_one_or_none()
|
||||
if dispositivo:
|
||||
vehiculo_id = dispositivo.vehiculo_id
|
||||
|
||||
# Actualizar último contacto del dispositivo
|
||||
dispositivo.ultimo_contacto = datetime.now(timezone.utc)
|
||||
dispositivo.conectado = True
|
||||
if ubicacion_data.bateria_dispositivo:
|
||||
dispositivo.bateria = ubicacion_data.bateria_dispositivo
|
||||
if ubicacion_data.satelites:
|
||||
dispositivo.satelites = ubicacion_data.satelites
|
||||
|
||||
if not vehiculo_id:
|
||||
return None
|
||||
|
||||
# Usar timestamp del servidor si no viene
|
||||
tiempo = ubicacion_data.tiempo or datetime.now(timezone.utc)
|
||||
|
||||
# Crear registro de ubicación
|
||||
ubicacion = Ubicacion(
|
||||
tiempo=tiempo,
|
||||
vehiculo_id=vehiculo_id,
|
||||
lat=ubicacion_data.lat,
|
||||
lng=ubicacion_data.lng,
|
||||
velocidad=ubicacion_data.velocidad,
|
||||
rumbo=ubicacion_data.rumbo,
|
||||
altitud=ubicacion_data.altitud,
|
||||
precision=ubicacion_data.precision,
|
||||
hdop=ubicacion_data.hdop,
|
||||
satelites=ubicacion_data.satelites,
|
||||
fuente=ubicacion_data.fuente,
|
||||
bateria_dispositivo=ubicacion_data.bateria_dispositivo,
|
||||
bateria_vehiculo=ubicacion_data.bateria_vehiculo,
|
||||
motor_encendido=ubicacion_data.motor_encendido,
|
||||
odometro=ubicacion_data.odometro,
|
||||
rpm=ubicacion_data.rpm,
|
||||
temperatura_motor=ubicacion_data.temperatura_motor,
|
||||
nivel_combustible=ubicacion_data.nivel_combustible,
|
||||
)
|
||||
|
||||
self.db.add(ubicacion)
|
||||
|
||||
# Actualizar última ubicación conocida del vehículo
|
||||
result = await self.db.execute(
|
||||
select(Vehiculo).where(Vehiculo.id == vehiculo_id)
|
||||
)
|
||||
vehiculo = result.scalar_one_or_none()
|
||||
|
||||
if vehiculo:
|
||||
vehiculo.ultima_lat = ubicacion_data.lat
|
||||
vehiculo.ultima_lng = ubicacion_data.lng
|
||||
vehiculo.ultima_velocidad = ubicacion_data.velocidad
|
||||
vehiculo.ultimo_rumbo = ubicacion_data.rumbo
|
||||
vehiculo.ultima_ubicacion_tiempo = tiempo
|
||||
vehiculo.motor_encendido = ubicacion_data.motor_encendido
|
||||
|
||||
if ubicacion_data.odometro:
|
||||
vehiculo.odometro_actual = ubicacion_data.odometro
|
||||
|
||||
await self.db.commit()
|
||||
|
||||
return UbicacionResponse(
|
||||
tiempo=ubicacion.tiempo,
|
||||
vehiculo_id=ubicacion.vehiculo_id,
|
||||
lat=ubicacion.lat,
|
||||
lng=ubicacion.lng,
|
||||
velocidad=ubicacion.velocidad,
|
||||
rumbo=ubicacion.rumbo,
|
||||
altitud=ubicacion.altitud,
|
||||
precision=ubicacion.precision,
|
||||
satelites=ubicacion.satelites,
|
||||
fuente=ubicacion.fuente,
|
||||
bateria_dispositivo=ubicacion.bateria_dispositivo,
|
||||
motor_encendido=ubicacion.motor_encendido,
|
||||
odometro=ubicacion.odometro,
|
||||
)
|
||||
|
||||
async def obtener_historial(
|
||||
self,
|
||||
vehiculo_id: int,
|
||||
desde: datetime,
|
||||
hasta: datetime,
|
||||
simplificar: bool = True,
|
||||
intervalo_segundos: Optional[int] = None,
|
||||
) -> HistorialUbicacionesResponse:
|
||||
"""
|
||||
Obtiene el historial de ubicaciones de un vehículo.
|
||||
|
||||
Args:
|
||||
vehiculo_id: ID del vehículo.
|
||||
desde: Fecha/hora de inicio.
|
||||
hasta: Fecha/hora de fin.
|
||||
simplificar: Si simplificar la ruta (Douglas-Peucker).
|
||||
intervalo_segundos: Intervalo de muestreo opcional.
|
||||
|
||||
Returns:
|
||||
Historial de ubicaciones con estadísticas.
|
||||
"""
|
||||
query = (
|
||||
select(Ubicacion)
|
||||
.where(
|
||||
and_(
|
||||
Ubicacion.vehiculo_id == vehiculo_id,
|
||||
Ubicacion.tiempo >= desde,
|
||||
Ubicacion.tiempo <= hasta,
|
||||
)
|
||||
)
|
||||
.order_by(Ubicacion.tiempo)
|
||||
)
|
||||
|
||||
result = await self.db.execute(query)
|
||||
ubicaciones = result.scalars().all()
|
||||
|
||||
# Aplicar muestreo por intervalo si se especifica
|
||||
if intervalo_segundos and ubicaciones:
|
||||
ubicaciones = self._muestrear_por_intervalo(
|
||||
ubicaciones, intervalo_segundos
|
||||
)
|
||||
|
||||
# Calcular estadísticas
|
||||
distancia_km = self._calcular_distancia_total(ubicaciones)
|
||||
tiempo_movimiento = self._calcular_tiempo_movimiento(ubicaciones)
|
||||
velocidad_promedio = None
|
||||
velocidad_maxima = None
|
||||
|
||||
if ubicaciones:
|
||||
velocidades = [u.velocidad for u in ubicaciones if u.velocidad]
|
||||
if velocidades:
|
||||
velocidad_promedio = sum(velocidades) / len(velocidades)
|
||||
velocidad_maxima = max(velocidades)
|
||||
|
||||
# Simplificar ruta si se solicita
|
||||
if simplificar and len(ubicaciones) > 100:
|
||||
ubicaciones = self._simplificar_ruta(ubicaciones, epsilon=0.0001)
|
||||
|
||||
ubicaciones_response = [
|
||||
UbicacionResponse(
|
||||
tiempo=u.tiempo,
|
||||
vehiculo_id=u.vehiculo_id,
|
||||
lat=u.lat,
|
||||
lng=u.lng,
|
||||
velocidad=u.velocidad,
|
||||
rumbo=u.rumbo,
|
||||
altitud=u.altitud,
|
||||
precision=u.precision,
|
||||
satelites=u.satelites,
|
||||
fuente=u.fuente,
|
||||
bateria_dispositivo=u.bateria_dispositivo,
|
||||
motor_encendido=u.motor_encendido,
|
||||
odometro=u.odometro,
|
||||
)
|
||||
for u in ubicaciones
|
||||
]
|
||||
|
||||
return HistorialUbicacionesResponse(
|
||||
vehiculo_id=vehiculo_id,
|
||||
desde=desde,
|
||||
hasta=hasta,
|
||||
total_puntos=len(ubicaciones_response),
|
||||
distancia_km=distancia_km,
|
||||
tiempo_movimiento_segundos=tiempo_movimiento,
|
||||
velocidad_promedio=velocidad_promedio,
|
||||
velocidad_maxima=velocidad_maxima,
|
||||
ubicaciones=ubicaciones_response,
|
||||
)
|
||||
|
||||
async def obtener_ultima_ubicacion(
|
||||
self,
|
||||
vehiculo_id: int,
|
||||
) -> Optional[UbicacionResponse]:
|
||||
"""
|
||||
Obtiene la última ubicación conocida de un vehículo.
|
||||
|
||||
Args:
|
||||
vehiculo_id: ID del vehículo.
|
||||
|
||||
Returns:
|
||||
Última ubicación o None.
|
||||
"""
|
||||
result = await self.db.execute(
|
||||
select(Ubicacion)
|
||||
.where(Ubicacion.vehiculo_id == vehiculo_id)
|
||||
.order_by(Ubicacion.tiempo.desc())
|
||||
.limit(1)
|
||||
)
|
||||
ubicacion = result.scalar_one_or_none()
|
||||
|
||||
if not ubicacion:
|
||||
return None
|
||||
|
||||
return UbicacionResponse(
|
||||
tiempo=ubicacion.tiempo,
|
||||
vehiculo_id=ubicacion.vehiculo_id,
|
||||
lat=ubicacion.lat,
|
||||
lng=ubicacion.lng,
|
||||
velocidad=ubicacion.velocidad,
|
||||
rumbo=ubicacion.rumbo,
|
||||
altitud=ubicacion.altitud,
|
||||
precision=ubicacion.precision,
|
||||
satelites=ubicacion.satelites,
|
||||
fuente=ubicacion.fuente,
|
||||
bateria_dispositivo=ubicacion.bateria_dispositivo,
|
||||
motor_encendido=ubicacion.motor_encendido,
|
||||
odometro=ubicacion.odometro,
|
||||
)
|
||||
|
||||
async def obtener_ubicaciones_flota(
|
||||
self,
|
||||
) -> List[dict]:
|
||||
"""
|
||||
Obtiene las últimas ubicaciones de todos los vehículos activos.
|
||||
|
||||
Returns:
|
||||
Lista de ubicaciones actuales de la flota.
|
||||
"""
|
||||
result = await self.db.execute(
|
||||
select(Vehiculo)
|
||||
.where(Vehiculo.activo == True)
|
||||
.where(Vehiculo.ultima_lat.isnot(None))
|
||||
)
|
||||
vehiculos = result.scalars().all()
|
||||
|
||||
ubicaciones = []
|
||||
for v in vehiculos:
|
||||
# Determinar si está en movimiento
|
||||
en_movimiento = False
|
||||
if v.ultima_velocidad and v.ultima_velocidad > 5:
|
||||
en_movimiento = True
|
||||
|
||||
ubicaciones.append({
|
||||
"id": v.id,
|
||||
"nombre": v.nombre,
|
||||
"placa": v.placa,
|
||||
"color_marcador": v.color_marcador,
|
||||
"icono": v.icono,
|
||||
"lat": v.ultima_lat,
|
||||
"lng": v.ultima_lng,
|
||||
"velocidad": v.ultima_velocidad,
|
||||
"rumbo": v.ultimo_rumbo,
|
||||
"tiempo": v.ultima_ubicacion_tiempo,
|
||||
"motor_encendido": v.motor_encendido,
|
||||
"en_movimiento": en_movimiento,
|
||||
"conductor_nombre": v.conductor.nombre_completo if v.conductor else None,
|
||||
})
|
||||
|
||||
return ubicaciones
|
||||
|
||||
def _calcular_distancia_total(
|
||||
self,
|
||||
ubicaciones: List[Ubicacion],
|
||||
) -> float:
|
||||
"""
|
||||
Calcula la distancia total recorrida entre ubicaciones.
|
||||
|
||||
Usa la fórmula de Haversine para calcular distancias.
|
||||
|
||||
Args:
|
||||
ubicaciones: Lista de ubicaciones ordenadas por tiempo.
|
||||
|
||||
Returns:
|
||||
Distancia total en kilómetros.
|
||||
"""
|
||||
if len(ubicaciones) < 2:
|
||||
return 0.0
|
||||
|
||||
import math
|
||||
|
||||
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 # Radio de la Tierra en km
|
||||
|
||||
total_km += r * c
|
||||
|
||||
return round(total_km, 2)
|
||||
|
||||
def _calcular_tiempo_movimiento(
|
||||
self,
|
||||
ubicaciones: List[Ubicacion],
|
||||
) -> int:
|
||||
"""
|
||||
Calcula el tiempo en movimiento (velocidad > 5 km/h).
|
||||
|
||||
Args:
|
||||
ubicaciones: Lista de ubicaciones ordenadas.
|
||||
|
||||
Returns:
|
||||
Tiempo en movimiento en segundos.
|
||||
"""
|
||||
if len(ubicaciones) < 2:
|
||||
return 0
|
||||
|
||||
tiempo_total = 0
|
||||
for i in range(1, len(ubicaciones)):
|
||||
if (
|
||||
ubicaciones[i - 1].velocidad
|
||||
and ubicaciones[i - 1].velocidad > 5
|
||||
):
|
||||
delta = (
|
||||
ubicaciones[i].tiempo - ubicaciones[i - 1].tiempo
|
||||
).total_seconds()
|
||||
tiempo_total += delta
|
||||
|
||||
return int(tiempo_total)
|
||||
|
||||
def _muestrear_por_intervalo(
|
||||
self,
|
||||
ubicaciones: List[Ubicacion],
|
||||
intervalo_segundos: int,
|
||||
) -> List[Ubicacion]:
|
||||
"""
|
||||
Muestrea ubicaciones por intervalo de tiempo.
|
||||
|
||||
Args:
|
||||
ubicaciones: Lista de ubicaciones.
|
||||
intervalo_segundos: Intervalo de muestreo.
|
||||
|
||||
Returns:
|
||||
Lista filtrada de ubicaciones.
|
||||
"""
|
||||
if not ubicaciones:
|
||||
return []
|
||||
|
||||
resultado = [ubicaciones[0]]
|
||||
ultimo_tiempo = ubicaciones[0].tiempo
|
||||
|
||||
for u in ubicaciones[1:]:
|
||||
delta = (u.tiempo - ultimo_tiempo).total_seconds()
|
||||
if delta >= intervalo_segundos:
|
||||
resultado.append(u)
|
||||
ultimo_tiempo = u.tiempo
|
||||
|
||||
# Siempre incluir el último punto
|
||||
if resultado[-1] != ubicaciones[-1]:
|
||||
resultado.append(ubicaciones[-1])
|
||||
|
||||
return resultado
|
||||
|
||||
def _simplificar_ruta(
|
||||
self,
|
||||
ubicaciones: List[Ubicacion],
|
||||
epsilon: float = 0.0001,
|
||||
) -> List[Ubicacion]:
|
||||
"""
|
||||
Simplifica la ruta usando el algoritmo Douglas-Peucker.
|
||||
|
||||
Args:
|
||||
ubicaciones: Lista de ubicaciones.
|
||||
epsilon: Tolerancia de simplificación.
|
||||
|
||||
Returns:
|
||||
Lista simplificada de ubicaciones.
|
||||
"""
|
||||
if len(ubicaciones) <= 2:
|
||||
return ubicaciones
|
||||
|
||||
# Convertir a lista de puntos
|
||||
points = [(u.lat, u.lng, u) for u in ubicaciones]
|
||||
|
||||
# Douglas-Peucker
|
||||
simplified = self._douglas_peucker(points, epsilon)
|
||||
|
||||
return [p[2] for p in simplified]
|
||||
|
||||
def _douglas_peucker(
|
||||
self,
|
||||
points: List[Tuple],
|
||||
epsilon: float,
|
||||
) -> List[Tuple]:
|
||||
"""Implementación del algoritmo Douglas-Peucker."""
|
||||
if len(points) <= 2:
|
||||
return points
|
||||
|
||||
# Encontrar el punto más lejano de la línea
|
||||
dmax = 0
|
||||
index = 0
|
||||
end = len(points) - 1
|
||||
|
||||
for i in range(1, end):
|
||||
d = self._perpendicular_distance(
|
||||
points[i], points[0], points[end]
|
||||
)
|
||||
if d > dmax:
|
||||
index = i
|
||||
dmax = d
|
||||
|
||||
# Si la distancia máxima es mayor que epsilon, simplificar recursivamente
|
||||
if dmax > epsilon:
|
||||
# Dividir en dos segmentos
|
||||
rec1 = self._douglas_peucker(points[: index + 1], epsilon)
|
||||
rec2 = self._douglas_peucker(points[index:], epsilon)
|
||||
|
||||
# Combinar (evitar duplicar el punto medio)
|
||||
return rec1[:-1] + rec2
|
||||
else:
|
||||
return [points[0], points[end]]
|
||||
|
||||
def _perpendicular_distance(
|
||||
self,
|
||||
point: Tuple,
|
||||
line_start: Tuple,
|
||||
line_end: Tuple,
|
||||
) -> float:
|
||||
"""Calcula la distancia perpendicular de un punto a una línea."""
|
||||
import math
|
||||
|
||||
x, y = point[0], point[1]
|
||||
x1, y1 = line_start[0], line_start[1]
|
||||
x2, y2 = line_end[0], line_end[1]
|
||||
|
||||
# Caso especial: línea de longitud cero
|
||||
dx = x2 - x1
|
||||
dy = y2 - y1
|
||||
if dx == 0 and dy == 0:
|
||||
return math.sqrt((x - x1) ** 2 + (y - y1) ** 2)
|
||||
|
||||
# Distancia perpendicular
|
||||
numerator = abs(dy * x - dx * y + x2 * y1 - y2 * x1)
|
||||
denominator = math.sqrt(dx ** 2 + dy ** 2)
|
||||
|
||||
return numerator / denominator
|
||||
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,
|
||||
}
|
||||
411
backend/app/services/video_service.py
Normal file
411
backend/app/services/video_service.py
Normal file
@@ -0,0 +1,411 @@
|
||||
"""
|
||||
Servicio para gestión de video y cámaras.
|
||||
|
||||
Integración con MediaMTX para streaming de video.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.security import decrypt_sensitive_data, encrypt_sensitive_data
|
||||
from app.models.camara import Camara
|
||||
from app.models.grabacion import Grabacion
|
||||
from app.models.evento_video import EventoVideo
|
||||
from app.schemas.video import CamaraStreamURL
|
||||
|
||||
|
||||
class VideoService:
|
||||
"""Servicio para gestión de video y streaming."""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
"""
|
||||
Inicializa el servicio.
|
||||
|
||||
Args:
|
||||
db: Sesión de base de datos async.
|
||||
"""
|
||||
self.db = db
|
||||
self.mediamtx_api = f"http://{settings.MEDIAMTX_HOST}:{settings.MEDIAMTX_API_PORT}"
|
||||
|
||||
async def obtener_urls_stream(
|
||||
self,
|
||||
camara_id: int,
|
||||
) -> Optional[CamaraStreamURL]:
|
||||
"""
|
||||
Obtiene las URLs de streaming de una cámara.
|
||||
|
||||
Args:
|
||||
camara_id: ID de la cámara.
|
||||
|
||||
Returns:
|
||||
URLs de streaming disponibles.
|
||||
"""
|
||||
result = await self.db.execute(
|
||||
select(Camara).where(Camara.id == camara_id)
|
||||
)
|
||||
camara = result.scalar_one_or_none()
|
||||
|
||||
if not camara or not camara.activa:
|
||||
return None
|
||||
|
||||
# Construir URLs según el path de MediaMTX
|
||||
path = camara.mediamtx_path or f"cam{camara.id}"
|
||||
|
||||
rtsp_url = None
|
||||
hls_url = None
|
||||
webrtc_url = None
|
||||
|
||||
if camara.url_stream:
|
||||
# Usar URL directa de la cámara
|
||||
rtsp_url = camara.url_stream_completa
|
||||
else:
|
||||
# Usar MediaMTX como proxy
|
||||
rtsp_url = f"rtsp://{settings.MEDIAMTX_HOST}:{settings.MEDIAMTX_RTSP_PORT}/{path}"
|
||||
|
||||
# URLs de MediaMTX para diferentes protocolos
|
||||
hls_url = f"http://{settings.MEDIAMTX_HOST}:8888/{path}/index.m3u8"
|
||||
webrtc_url = f"http://{settings.MEDIAMTX_HOST}:{settings.MEDIAMTX_WEBRTC_PORT}/{path}"
|
||||
|
||||
return CamaraStreamURL(
|
||||
camara_id=camara.id,
|
||||
camara_nombre=camara.nombre,
|
||||
rtsp_url=rtsp_url,
|
||||
hls_url=hls_url,
|
||||
webrtc_url=webrtc_url,
|
||||
estado=camara.estado,
|
||||
)
|
||||
|
||||
async def verificar_estado_camaras(self) -> List[dict]:
|
||||
"""
|
||||
Verifica el estado de todas las cámaras activas.
|
||||
|
||||
Returns:
|
||||
Lista con estado de cada cámara.
|
||||
"""
|
||||
result = await self.db.execute(
|
||||
select(Camara).where(Camara.activa == True)
|
||||
)
|
||||
camaras = result.scalars().all()
|
||||
|
||||
estados = []
|
||||
for camara in camaras:
|
||||
estado = await self._verificar_stream(camara)
|
||||
estados.append({
|
||||
"camara_id": camara.id,
|
||||
"nombre": camara.nombre,
|
||||
"vehiculo_id": camara.vehiculo_id,
|
||||
"estado_anterior": camara.estado,
|
||||
"estado_actual": estado,
|
||||
"cambio": camara.estado != estado,
|
||||
})
|
||||
|
||||
# Actualizar estado si cambió
|
||||
if camara.estado != estado:
|
||||
camara.estado = estado
|
||||
if estado == "conectada":
|
||||
camara.ultima_conexion = datetime.now(timezone.utc)
|
||||
|
||||
await self.db.commit()
|
||||
return estados
|
||||
|
||||
async def _verificar_stream(
|
||||
self,
|
||||
camara: Camara,
|
||||
) -> str:
|
||||
"""
|
||||
Verifica si un stream está activo.
|
||||
|
||||
Args:
|
||||
camara: Cámara a verificar.
|
||||
|
||||
Returns:
|
||||
Estado del stream.
|
||||
"""
|
||||
if not camara.url_stream and not camara.mediamtx_path:
|
||||
return "desconectada"
|
||||
|
||||
path = camara.mediamtx_path or f"cam{camara.id}"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
# Verificar en MediaMTX API
|
||||
response = await client.get(
|
||||
f"{self.mediamtx_api}/v3/paths/get/{path}",
|
||||
timeout=5.0,
|
||||
)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if data.get("ready"):
|
||||
return "conectada"
|
||||
return "desconectada"
|
||||
return "desconectada"
|
||||
|
||||
except httpx.HTTPError:
|
||||
return "error"
|
||||
|
||||
async def iniciar_grabacion(
|
||||
self,
|
||||
camara_id: int,
|
||||
tipo: str = "manual",
|
||||
) -> Optional[Grabacion]:
|
||||
"""
|
||||
Inicia una grabación de una cámara.
|
||||
|
||||
Args:
|
||||
camara_id: ID de la cámara.
|
||||
tipo: Tipo de grabación.
|
||||
|
||||
Returns:
|
||||
Registro de grabación creado.
|
||||
"""
|
||||
result = await self.db.execute(
|
||||
select(Camara).where(Camara.id == camara_id)
|
||||
)
|
||||
camara = result.scalar_one_or_none()
|
||||
|
||||
if not camara or camara.estado != "conectada":
|
||||
return None
|
||||
|
||||
# Generar nombre de archivo
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
|
||||
archivo_nombre = f"cam{camara_id}_{timestamp}.mp4"
|
||||
archivo_url = f"{settings.UPLOAD_DIR}/videos/{camara.vehiculo_id}/{archivo_nombre}"
|
||||
|
||||
grabacion = Grabacion(
|
||||
camara_id=camara_id,
|
||||
vehiculo_id=camara.vehiculo_id,
|
||||
inicio_tiempo=datetime.now(timezone.utc),
|
||||
archivo_url=archivo_url,
|
||||
archivo_nombre=archivo_nombre,
|
||||
tipo=tipo,
|
||||
estado="grabando",
|
||||
)
|
||||
|
||||
self.db.add(grabacion)
|
||||
|
||||
# Actualizar estado de cámara
|
||||
camara.estado = "grabando"
|
||||
|
||||
await self.db.commit()
|
||||
await self.db.refresh(grabacion)
|
||||
|
||||
# Enviar comando a MediaMTX para iniciar grabación
|
||||
await self._iniciar_grabacion_mediamtx(camara, archivo_url)
|
||||
|
||||
return grabacion
|
||||
|
||||
async def detener_grabacion(
|
||||
self,
|
||||
grabacion_id: int,
|
||||
) -> Optional[Grabacion]:
|
||||
"""
|
||||
Detiene una grabación en curso.
|
||||
|
||||
Args:
|
||||
grabacion_id: ID de la grabación.
|
||||
|
||||
Returns:
|
||||
Grabación actualizada.
|
||||
"""
|
||||
result = await self.db.execute(
|
||||
select(Grabacion).where(Grabacion.id == grabacion_id)
|
||||
)
|
||||
grabacion = result.scalar_one_or_none()
|
||||
|
||||
if not grabacion or grabacion.estado != "grabando":
|
||||
return None
|
||||
|
||||
# Detener grabación en MediaMTX
|
||||
result_cam = await self.db.execute(
|
||||
select(Camara).where(Camara.id == grabacion.camara_id)
|
||||
)
|
||||
camara = result_cam.scalar_one_or_none()
|
||||
|
||||
if camara:
|
||||
await self._detener_grabacion_mediamtx(camara)
|
||||
camara.estado = "conectada"
|
||||
|
||||
# Actualizar registro
|
||||
grabacion.fin_tiempo = datetime.now(timezone.utc)
|
||||
grabacion.duracion_segundos = int(
|
||||
(grabacion.fin_tiempo - grabacion.inicio_tiempo).total_seconds()
|
||||
)
|
||||
grabacion.estado = "procesando" # Se procesará para generar thumbnail, etc.
|
||||
|
||||
await self.db.commit()
|
||||
await self.db.refresh(grabacion)
|
||||
|
||||
return grabacion
|
||||
|
||||
async def _iniciar_grabacion_mediamtx(
|
||||
self,
|
||||
camara: Camara,
|
||||
archivo_url: str,
|
||||
) -> bool:
|
||||
"""Envía comando a MediaMTX para iniciar grabación."""
|
||||
# MediaMTX usa configuración para grabación automática
|
||||
# o se puede usar ffmpeg para grabar el stream
|
||||
# Esta es una implementación simplificada
|
||||
try:
|
||||
# En una implementación real, se usaría la API de MediaMTX
|
||||
# o se ejecutaría ffmpeg como proceso
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def _detener_grabacion_mediamtx(
|
||||
self,
|
||||
camara: Camara,
|
||||
) -> bool:
|
||||
"""Envía comando a MediaMTX para detener grabación."""
|
||||
try:
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def registrar_evento_video(
|
||||
self,
|
||||
camara_id: int,
|
||||
tipo: str,
|
||||
severidad: str,
|
||||
lat: float = None,
|
||||
lng: float = None,
|
||||
velocidad: float = None,
|
||||
descripcion: str = None,
|
||||
confianza: float = None,
|
||||
snapshot_url: str = None,
|
||||
) -> EventoVideo:
|
||||
"""
|
||||
Registra un evento de video detectado.
|
||||
|
||||
Args:
|
||||
camara_id: ID de la cámara.
|
||||
tipo: Tipo de evento.
|
||||
severidad: Severidad del evento.
|
||||
lat, lng: Coordenadas.
|
||||
velocidad: Velocidad al momento del evento.
|
||||
descripcion: Descripción del evento.
|
||||
confianza: Confianza de la detección (0-100).
|
||||
snapshot_url: URL de la imagen del evento.
|
||||
|
||||
Returns:
|
||||
Evento creado.
|
||||
"""
|
||||
result = await self.db.execute(
|
||||
select(Camara).where(Camara.id == camara_id)
|
||||
)
|
||||
camara = result.scalar_one_or_none()
|
||||
|
||||
if not camara:
|
||||
raise ValueError(f"Cámara {camara_id} no encontrada")
|
||||
|
||||
evento = EventoVideo(
|
||||
camara_id=camara_id,
|
||||
vehiculo_id=camara.vehiculo_id,
|
||||
tipo=tipo,
|
||||
severidad=severidad,
|
||||
tiempo=datetime.now(timezone.utc),
|
||||
lat=lat,
|
||||
lng=lng,
|
||||
velocidad=velocidad,
|
||||
descripcion=descripcion,
|
||||
confianza=confianza,
|
||||
snapshot_url=snapshot_url,
|
||||
)
|
||||
|
||||
self.db.add(evento)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(evento)
|
||||
|
||||
# Iniciar grabación de evento si está configurado
|
||||
if camara.grabacion_evento:
|
||||
await self.iniciar_grabacion(camara_id, tipo="evento")
|
||||
|
||||
return evento
|
||||
|
||||
async def obtener_grabaciones(
|
||||
self,
|
||||
vehiculo_id: int = None,
|
||||
camara_id: int = None,
|
||||
desde: datetime = None,
|
||||
hasta: datetime = None,
|
||||
tipo: str = None,
|
||||
limite: int = 50,
|
||||
) -> List[Grabacion]:
|
||||
"""
|
||||
Obtiene grabaciones filtradas.
|
||||
|
||||
Args:
|
||||
vehiculo_id: Filtrar por vehículo.
|
||||
camara_id: Filtrar por cámara.
|
||||
desde: Fecha inicio.
|
||||
hasta: Fecha fin.
|
||||
tipo: Tipo de grabación.
|
||||
limite: Límite de resultados.
|
||||
|
||||
Returns:
|
||||
Lista de grabaciones.
|
||||
"""
|
||||
query = (
|
||||
select(Grabacion)
|
||||
.where(Grabacion.estado != "eliminado")
|
||||
.order_by(Grabacion.inicio_tiempo.desc())
|
||||
.limit(limite)
|
||||
)
|
||||
|
||||
if vehiculo_id:
|
||||
query = query.where(Grabacion.vehiculo_id == vehiculo_id)
|
||||
if camara_id:
|
||||
query = query.where(Grabacion.camara_id == camara_id)
|
||||
if desde:
|
||||
query = query.where(Grabacion.inicio_tiempo >= desde)
|
||||
if hasta:
|
||||
query = query.where(Grabacion.inicio_tiempo <= hasta)
|
||||
if tipo:
|
||||
query = query.where(Grabacion.tipo == tipo)
|
||||
|
||||
result = await self.db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
async def configurar_camara_mediamtx(
|
||||
self,
|
||||
camara: Camara,
|
||||
) -> bool:
|
||||
"""
|
||||
Configura una cámara en MediaMTX.
|
||||
|
||||
Args:
|
||||
camara: Cámara a configurar.
|
||||
|
||||
Returns:
|
||||
True si se configuró correctamente.
|
||||
"""
|
||||
if not camara.url_stream:
|
||||
return False
|
||||
|
||||
path = camara.mediamtx_path or f"cam{camara.id}"
|
||||
|
||||
# Construir configuración para MediaMTX
|
||||
config = {
|
||||
"name": path,
|
||||
"source": camara.url_stream_completa,
|
||||
"sourceOnDemand": True,
|
||||
"record": camara.grabacion_continua,
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
f"{self.mediamtx_api}/v3/config/paths/add/{path}",
|
||||
json=config,
|
||||
timeout=10.0,
|
||||
)
|
||||
return response.status_code in [200, 201]
|
||||
|
||||
except httpx.HTTPError:
|
||||
return False
|
||||
Reference in New Issue
Block a user