""" Reports Endpoints - ServiceManagerWeb Módulo de reportes y estadísticas del sistema. Accesible por ADMIN y SUPPORT_MANAGER. """ from fastapi import APIRouter, Depends, Query, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func, and_, case, text, literal_column from typing import Optional, List from datetime import datetime, timedelta, timezone import uuid from app.core.database import get_db from app.api.deps import get_current_user from app.models.user import User, UserRole from app.models.ticket import Ticket, TicketStatus, TicketPriority from app.models.category import Category from app.models.system import System from app.models.tenant import Tenant, TenantStatus from app.api.schemas.reports import ( ReportSummaryResponse, TicketsByStatus, TicketsByPriority, AgentReportResponse, AgentReportRow, CategoryReportResponse, CategoryReportRow, ClientReportResponse, ClientReportRow, TrendsReportResponse, TrendDataPoint, CSATReportResponse, CSATDistribution, SystemReportResponse, SystemReportRow, ) router = APIRouter() CLOSED_STATUSES = {TicketStatus.RESOLVED, TicketStatus.CLOSED} # =================================== # HELPERS # =================================== def require_reports_access(current_user: User = Depends(get_current_user)) -> User: """ADMIN, SUPPORT_MANAGER y AUDITOR pueden leer reportes.""" allowed = [UserRole.ADMIN, UserRole.SUPPORT_MANAGER, UserRole.AUDITOR] if current_user.role not in allowed: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Solo ADMIN, SUPPORT_MANAGER y AUDITOR pueden acceder a los reportes.", ) return current_user def require_admin(current_user: User = Depends(get_current_user)) -> User: """Solo ADMIN puede ver reportes entre tenants.""" if current_user.role != UserRole.ADMIN: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Solo ADMIN puede ver reportes de todos los clientes.", ) return current_user def _period_dates(days: int) -> tuple[datetime, datetime]: """Devuelve (inicio, fin) del período solicitado en UTC.""" end = datetime.now(timezone.utc) start = end - timedelta(days=days) return start, end # =================================== # 1. RESUMEN GENERAL # =================================== @router.get("/summary", response_model=ReportSummaryResponse) async def get_report_summary( days: int = Query(default=30, ge=1, le=365, description="Días hacia atrás del período"), db: AsyncSession = Depends(get_db), current_user: User = Depends(require_reports_access), ): """ Resumen ejecutivo del período seleccionado. Incluye: - Total de tickets creados - Tickets abiertos vs resueltos - Tiempo promedio de resolución - Calificación promedio (CSAT) - Desglose por estado y prioridad - Comparación con el período anterior """ period_start, period_end = _period_dates(days) prev_start = period_start - timedelta(days=days) tenant_filter = Ticket.tenant_id == current_user.tenant_id # ── Conteos por estado ── status_rows = (await db.execute( select(Ticket.status, func.count(Ticket.id).label("cnt")) .where(and_(tenant_filter, Ticket.created_at >= period_start)) .group_by(Ticket.status) )).all() by_status = TicketsByStatus() for row in status_rows: s = row.status.value if hasattr(row.status, "value") else str(row.status) setattr(by_status, s.lower(), row.cnt) by_status.total = sum( [by_status.new, by_status.triage, by_status.in_progress, by_status.waiting_customer, by_status.resolved, by_status.closed, by_status.reopened] ) # ── Conteos por prioridad ── priority_rows = (await db.execute( select(Ticket.priority, func.count(Ticket.id).label("cnt")) .where(and_(tenant_filter, Ticket.created_at >= period_start)) .group_by(Ticket.priority) )).all() by_priority = TicketsByPriority() for row in priority_rows: p = row.priority.value if hasattr(row.priority, "value") else str(row.priority) setattr(by_priority, p.lower(), row.cnt) by_priority.total = sum([by_priority.low, by_priority.medium, by_priority.high, by_priority.urgent]) total_tickets = by_status.total resolved_tickets = by_status.resolved + by_status.closed open_tickets = total_tickets - resolved_tickets # ── Promedio de tiempo de resolución (segundos → horas) ── res_time_row = (await db.execute( select(func.avg( func.extract("epoch", Ticket.resolved_at - Ticket.created_at) ).label("avg_seconds")) .where(and_( tenant_filter, Ticket.created_at >= period_start, Ticket.resolved_at.isnot(None), )) )).scalar_one_or_none() avg_resolution_hours = round(res_time_row / 3600, 2) if res_time_row else None # ── Promedio de primera respuesta ── resp_time_row = (await db.execute( select(func.avg( func.extract("epoch", Ticket.first_response_at - Ticket.created_at) ).label("avg_seconds")) .where(and_( tenant_filter, Ticket.created_at >= period_start, Ticket.first_response_at.isnot(None), )) )).scalar_one_or_none() avg_first_response_hours = round(resp_time_row / 3600, 2) if resp_time_row else None # ── CSAT ── csat_row = (await db.execute( select(func.avg(Ticket.rating).label("avg"), func.count(Ticket.rating).label("cnt")) .where(and_(tenant_filter, Ticket.created_at >= period_start, Ticket.rating.isnot(None))) )).one() avg_rating = round(float(csat_row.avg), 2) if csat_row.avg else None total_rated = csat_row.cnt or 0 # ── Comparación con período anterior ── prev_total = (await db.execute( select(func.count(Ticket.id)) .where(and_(tenant_filter, Ticket.created_at >= prev_start, Ticket.created_at < period_start)) )).scalar_one_or_none() or 0 prev_resolved = (await db.execute( select(func.count(Ticket.id)) .where(and_( tenant_filter, Ticket.created_at >= prev_start, Ticket.created_at < period_start, Ticket.status.in_([TicketStatus.RESOLVED, TicketStatus.CLOSED]), )) )).scalar_one_or_none() or 0 tickets_change_pct = None if prev_total > 0: tickets_change_pct = round(((total_tickets - prev_total) / prev_total) * 100, 1) resolution_change_pct = None if prev_total > 0 and total_tickets > 0: cur_rate = resolved_tickets / total_tickets * 100 prev_rate = prev_resolved / prev_total * 100 if prev_total > 0 else 0 resolution_change_pct = round(cur_rate - prev_rate, 1) return ReportSummaryResponse( period_start=period_start, period_end=period_end, generated_at=datetime.now(timezone.utc), total_tickets=total_tickets, open_tickets=open_tickets, resolved_tickets=resolved_tickets, avg_resolution_hours=avg_resolution_hours, avg_first_response_hours=avg_first_response_hours, avg_rating=avg_rating, total_rated=total_rated, by_status=by_status, by_priority=by_priority, tickets_change_pct=tickets_change_pct, resolution_change_pct=resolution_change_pct, ) # =================================== # 2. RENDIMIENTO POR AGENTE # =================================== @router.get("/by-agent", response_model=AgentReportResponse) async def get_report_by_agent( days: int = Query(default=30, ge=1, le=365), db: AsyncSession = Depends(get_db), current_user: User = Depends(require_reports_access), ): """ Rendimiento de cada agente en el período: - Tickets asignados y resueltos - Tasa de resolución - Tiempo promedio de resolución - Calificación promedio (CSAT) """ period_start, period_end = _period_dates(days) tenant_filter = and_( Ticket.tenant_id == current_user.tenant_id, Ticket.created_at >= period_start, Ticket.assigned_to.isnot(None), ) # Obtener todos los agentes del tenant agents_result = await db.execute( select(User).where( and_( User.tenant_id == current_user.tenant_id, User.role.in_([UserRole.AGENT, UserRole.SUPPORT_MANAGER, UserRole.ADMIN]), User.is_active == True, ) ) ) agents = agents_result.scalars().all() rows: List[AgentReportRow] = [] for agent in agents: agent_filter = and_(tenant_filter, Ticket.assigned_to == agent.id) total_assigned = (await db.execute( select(func.count(Ticket.id)).where(agent_filter) )).scalar_one_or_none() or 0 if total_assigned == 0: continue # omitir agentes sin tickets en el período resolved = (await db.execute( select(func.count(Ticket.id)).where( and_(agent_filter, Ticket.status.in_([TicketStatus.RESOLVED, TicketStatus.CLOSED])) ) )).scalar_one_or_none() or 0 avg_res_seconds = (await db.execute( select(func.avg(func.extract("epoch", Ticket.resolved_at - Ticket.created_at))) .where(and_(agent_filter, Ticket.resolved_at.isnot(None))) )).scalar_one_or_none() csat = (await db.execute( select(func.avg(Ticket.rating), func.count(Ticket.rating)) .where(and_(agent_filter, Ticket.rating.isnot(None))) )).one() urgent_handled = (await db.execute( select(func.count(Ticket.id)).where( and_(agent_filter, Ticket.priority == TicketPriority.URGENT) ) )).scalar_one_or_none() or 0 rows.append(AgentReportRow( agent_id=str(agent.id), agent_name=f"{agent.first_name} {agent.last_name}", agent_email=agent.email, total_assigned=total_assigned, resolved=resolved, open=total_assigned - resolved, resolution_rate=round((resolved / total_assigned * 100), 1) if total_assigned else 0, avg_resolution_hours=round(float(avg_res_seconds) / 3600, 2) if avg_res_seconds else None, avg_rating=round(float(csat[0]), 2) if csat[0] else None, total_rated=csat[1] or 0, urgent_handled=urgent_handled, )) rows.sort(key=lambda r: r.resolved, reverse=True) return AgentReportResponse( period_start=period_start, period_end=period_end, generated_at=datetime.now(timezone.utc), agents=rows, total_agents=len(rows), ) # =================================== # 3. TICKETS POR CATEGORÍA # =================================== @router.get("/by-category", response_model=CategoryReportResponse) async def get_report_by_category( days: int = Query(default=30, ge=1, le=365), db: AsyncSession = Depends(get_db), current_user: User = Depends(require_reports_access), ): """ Tickets agrupados por categoría con tasa de cumplimiento SLA. """ period_start, _ = _period_dates(days) period_end = datetime.now(timezone.utc) tenant_filter = and_( Ticket.tenant_id == current_user.tenant_id, Ticket.created_at >= period_start, ) categories_result = await db.execute( select(Category).where( and_(Category.tenant_id == current_user.tenant_id, Category.is_active == True) ) ) categories = categories_result.scalars().all() rows: List[CategoryReportRow] = [] for cat in categories: cat_filter = and_(tenant_filter, Ticket.category_id == cat.id) total = (await db.execute( select(func.count(Ticket.id)).where(cat_filter) )).scalar_one_or_none() or 0 if total == 0: continue resolved = (await db.execute( select(func.count(Ticket.id)).where( and_(cat_filter, Ticket.status.in_([TicketStatus.RESOLVED, TicketStatus.CLOSED])) ) )).scalar_one_or_none() or 0 avg_res_seconds = (await db.execute( select(func.avg(func.extract("epoch", Ticket.resolved_at - Ticket.created_at))) .where(and_(cat_filter, Ticket.resolved_at.isnot(None))) )).scalar_one_or_none() # SLA compliance: tickets resueltos ANTES del deadline sla_met = (await db.execute( select(func.count(Ticket.id)).where( and_( cat_filter, Ticket.resolved_at.isnot(None), Ticket.sla_resolution_due.isnot(None), Ticket.resolved_at <= Ticket.sla_resolution_due, ) ) )).scalar_one_or_none() or 0 tickets_with_sla = (await db.execute( select(func.count(Ticket.id)).where( and_(cat_filter, Ticket.sla_resolution_due.isnot(None), Ticket.resolved_at.isnot(None)) ) )).scalar_one_or_none() or 0 sla_compliance_pct = round((sla_met / tickets_with_sla * 100), 1) if tickets_with_sla else 0.0 rows.append(CategoryReportRow( category_id=str(cat.id), category_name=cat.name, total_tickets=total, open_tickets=total - resolved, resolved_tickets=resolved, avg_resolution_hours=round(float(avg_res_seconds) / 3600, 2) if avg_res_seconds else None, sla_response_hours=cat.sla_response_hours, sla_resolution_hours=cat.sla_resolution_hours, sla_compliance_pct=sla_compliance_pct, )) # Sin categoría uncategorized = (await db.execute( select(func.count(Ticket.id)).where( and_(tenant_filter, Ticket.category_id.is_(None)) ) )).scalar_one_or_none() or 0 rows.sort(key=lambda r: r.total_tickets, reverse=True) return CategoryReportResponse( period_start=period_start, period_end=period_end, generated_at=datetime.now(timezone.utc), categories=rows, uncategorized_count=uncategorized, ) # =================================== # 4. TICKETS POR CLIENTE (solo ADMIN) # =================================== @router.get("/by-client", response_model=ClientReportResponse) async def get_report_by_client( days: int = Query(default=30, ge=1, le=365), db: AsyncSession = Depends(get_db), current_user: User = Depends(require_admin), ): """ Tickets agrupados por cliente (tenant). Solo accesible por ADMIN. Útil para ver qué clientes generan más trabajo. """ period_start, period_end = _period_dates(days) tenants_result = await db.execute(select(Tenant).where(Tenant.status == TenantStatus.ACTIVE)) tenants = tenants_result.scalars().all() rows: List[ClientReportRow] = [] for tenant in tenants: t_filter = and_( Ticket.tenant_id == tenant.id, Ticket.created_at >= period_start, ) total = (await db.execute( select(func.count(Ticket.id)).where(t_filter) )).scalar_one_or_none() or 0 if total == 0: continue resolved = (await db.execute( select(func.count(Ticket.id)).where( and_(t_filter, Ticket.status.in_([TicketStatus.RESOLVED, TicketStatus.CLOSED])) ) )).scalar_one_or_none() or 0 urgent = (await db.execute( select(func.count(Ticket.id)).where( and_(t_filter, Ticket.priority == TicketPriority.URGENT) ) )).scalar_one_or_none() or 0 csat_row = (await db.execute( select(func.avg(Ticket.rating)) .where(and_(t_filter, Ticket.rating.isnot(None))) )).scalar_one_or_none() avg_res_seconds = (await db.execute( select(func.avg(func.extract("epoch", Ticket.resolved_at - Ticket.created_at))) .where(and_(t_filter, Ticket.resolved_at.isnot(None))) )).scalar_one_or_none() last_ticket = (await db.execute( select(func.max(Ticket.created_at)).where(t_filter) )).scalar_one_or_none() rows.append(ClientReportRow( tenant_id=str(tenant.id), tenant_name=tenant.name, total_tickets=total, open_tickets=total - resolved, resolved_tickets=resolved, urgent_tickets=urgent, avg_resolution_hours=round(float(avg_res_seconds) / 3600, 2) if avg_res_seconds else None, avg_rating=round(float(csat_row), 2) if csat_row else None, last_ticket_at=last_ticket, )) rows.sort(key=lambda r: r.total_tickets, reverse=True) return ClientReportResponse( period_start=period_start, period_end=period_end, generated_at=datetime.now(timezone.utc), clients=rows, total_clients=len(rows), ) # =================================== # 5. TENDENCIAS (TICKETS EN EL TIEMPO) # =================================== @router.get("/trends", response_model=TrendsReportResponse) async def get_report_trends( days: int = Query(default=30, ge=7, le=90, description="Número de días (7-90)"), db: AsyncSession = Depends(get_db), current_user: User = Depends(require_reports_access), ): """ Evolución diaria de tickets creados y resueltos. Útil para detectar picos de trabajo. """ period_start, period_end = _period_dates(days) tenant_filter = Ticket.tenant_id == current_user.tenant_id # Tickets creados por día # literal_column("'day'") evita que SQLAlchemy genere múltiples parámetros # ($1, $4, $5) para 'day', lo que confunde a PostgreSQL en el GROUP BY. _day_lit = literal_column("'day'") created_rows = (await db.execute( select( func.date_trunc(_day_lit, Ticket.created_at).label("day"), func.count(Ticket.id).label("cnt"), ) .where(and_(tenant_filter, Ticket.created_at >= period_start)) .group_by(func.date_trunc(_day_lit, Ticket.created_at)) .order_by(func.date_trunc(_day_lit, Ticket.created_at)) )).all() # Tickets resueltos por día (según resolved_at) resolved_rows = (await db.execute( select( func.date_trunc(_day_lit, Ticket.resolved_at).label("day"), func.count(Ticket.id).label("cnt"), ) .where(and_( tenant_filter, Ticket.resolved_at >= period_start, Ticket.resolved_at.isnot(None), )) .group_by(func.date_trunc(_day_lit, Ticket.resolved_at)) .order_by(func.date_trunc(_day_lit, Ticket.resolved_at)) )).all() created_map: dict[str, int] = {r.day.strftime("%Y-%m-%d"): r.cnt for r in created_rows} resolved_map: dict[str, int] = {r.day.strftime("%Y-%m-%d"): r.cnt for r in resolved_rows} # Un punto por cada día del período data_points: List[TrendDataPoint] = [] current = period_start while current <= period_end: date_str = current.strftime("%Y-%m-%d") c = created_map.get(date_str, 0) r = resolved_map.get(date_str, 0) data_points.append(TrendDataPoint(date=date_str, created=c, resolved=r, net_open=c - r)) current += timedelta(days=1) return TrendsReportResponse( period_start=period_start, period_end=period_end, generated_at=datetime.now(timezone.utc), data_points=data_points, total_days=len(data_points), ) # =================================== # 6. SATISFACCIÓN DEL CLIENTE (CSAT) # =================================== @router.get("/csat", response_model=CSATReportResponse) async def get_report_csat( days: int = Query(default=30, ge=1, le=365), db: AsyncSession = Depends(get_db), current_user: User = Depends(require_reports_access), ): """ Reporte de satisfacción del cliente (calificaciones 1-5). Incluye distribución, promedio por categoría y por agente. """ period_start, period_end = _period_dates(days) tenant_filter = and_( Ticket.tenant_id == current_user.tenant_id, Ticket.created_at >= period_start, ) # Total y promedio general general = (await db.execute( select(func.avg(Ticket.rating).label("avg"), func.count(Ticket.rating).label("rated")) .where(and_(tenant_filter, Ticket.rating.isnot(None))) )).one() total_tickets = (await db.execute( select(func.count(Ticket.id)).where(tenant_filter) )).scalar_one_or_none() or 0 # Distribución por estrellas dist_rows = (await db.execute( select(Ticket.rating, func.count(Ticket.id).label("cnt")) .where(and_(tenant_filter, Ticket.rating.isnot(None))) .group_by(Ticket.rating) )).all() dist = CSATDistribution() for row in dist_rows: setattr(dist, f"rating_{row.rating}", row.cnt) # Promedio por categoría cat_rows = (await db.execute( select( Category.name.label("cat_name"), func.avg(Ticket.rating).label("avg"), func.count(Ticket.rating).label("cnt"), ) .join(Category, Ticket.category_id == Category.id, isouter=True) .where(and_(tenant_filter, Ticket.rating.isnot(None))) .group_by(Category.name) .order_by(func.avg(Ticket.rating).desc()) )).all() by_category = [ { "category": row.cat_name or "Sin categoría", "avg_rating": round(float(row.avg), 2) if row.avg else None, "total_rated": row.cnt, } for row in cat_rows ] # Promedio por agente agent_rows = (await db.execute( select( User.first_name.label("fname"), User.last_name.label("lname"), func.avg(Ticket.rating).label("avg"), func.count(Ticket.rating).label("cnt"), ) .join(User, Ticket.assigned_to == User.id, isouter=True) .where(and_(tenant_filter, Ticket.rating.isnot(None))) .group_by(User.first_name, User.last_name) .order_by(func.avg(Ticket.rating).desc()) )).all() by_agent = [ { "agent": f"{row.fname or ''} {row.lname or ''}".strip() or "Sin asignar", "avg_rating": round(float(row.avg), 2) if row.avg else None, "total_rated": row.cnt, } for row in agent_rows ] # Últimos comentarios de calificación (rating_comment) comment_rows = (await db.execute( select(Ticket.rating, Ticket.rating_comment, Ticket.rated_at) .where(and_( tenant_filter, Ticket.rating.isnot(None), Ticket.rating_comment.isnot(None), Ticket.rating_comment != "", )) .order_by(Ticket.rated_at.desc()) .limit(10) )).all() recent_comments = [ { "rating": row.rating, "comment": row.rating_comment, "rated_at": row.rated_at.isoformat() if row.rated_at else None, } for row in comment_rows ] total_rated = general.rated or 0 response_rate = round((total_rated / total_tickets * 100), 1) if total_tickets else 0.0 return CSATReportResponse( period_start=period_start, period_end=period_end, generated_at=datetime.now(timezone.utc), avg_rating=round(float(general.avg), 2) if general.avg else None, total_rated=total_rated, total_tickets=total_tickets, response_rate=response_rate, distribution=dist, by_category=by_category, by_agent=by_agent, recent_comments=recent_comments, ) # =================================== # 7. TICKETS POR SISTEMA AFECTADO # =================================== @router.get("/by-system", response_model=SystemReportResponse) async def get_report_by_system( days: int = Query(default=30, ge=1, le=365), db: AsyncSession = Depends(get_db), current_user: User = Depends(require_reports_access), ): """ Tickets agrupados por sistema afectado. Útil para detectar qué sistemas generan más incidentes. """ period_start, period_end = _period_dates(days) tenant_filter = and_( Ticket.tenant_id == current_user.tenant_id, Ticket.created_at >= period_start, ) systems_result = await db.execute( select(System).where( and_(System.tenant_id == current_user.tenant_id, System.is_active == True) ) ) systems = systems_result.scalars().all() rows: List[SystemReportRow] = [] for sys in systems: sys_filter = and_(tenant_filter, Ticket.affected_system_id == sys.id) total = (await db.execute( select(func.count(Ticket.id)).where(sys_filter) )).scalar_one_or_none() or 0 if total == 0: continue resolved = (await db.execute( select(func.count(Ticket.id)).where( and_(sys_filter, Ticket.status.in_([TicketStatus.RESOLVED, TicketStatus.CLOSED])) ) )).scalar_one_or_none() or 0 urgent = (await db.execute( select(func.count(Ticket.id)).where( and_(sys_filter, Ticket.priority == TicketPriority.URGENT) ) )).scalar_one_or_none() or 0 avg_res_seconds = (await db.execute( select(func.avg(func.extract("epoch", Ticket.resolved_at - Ticket.created_at))) .where(and_(sys_filter, Ticket.resolved_at.isnot(None))) )).scalar_one_or_none() rows.append(SystemReportRow( system_id=str(sys.id), system_name=sys.name, total_tickets=total, open_tickets=total - resolved, resolved_tickets=resolved, urgent_tickets=urgent, avg_resolution_hours=round(float(avg_res_seconds) / 3600, 2) if avg_res_seconds else None, )) # Sin sistema asignado no_system = (await db.execute( select(func.count(Ticket.id)).where( and_(tenant_filter, Ticket.affected_system_id.is_(None)) ) )).scalar_one_or_none() or 0 rows.sort(key=lambda r: r.total_tickets, reverse=True) return SystemReportResponse( period_start=period_start, period_end=period_end, generated_at=datetime.now(timezone.utc), systems=rows, no_system_count=no_system, )