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