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.
530 lines
17 KiB
Python
530 lines
17 KiB
Python
"""
|
|
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
|