Files
service_manager/backend/app/api/v1/endpoints/reports.py
icamarillo bd21207aae v1.15.1 - modulo de reportes implementado
- Nuevo módulo de reportes: backend/app/api/v1/endpoints/reports.py
- Schemas de reportes: backend/app/api/schemas/reports.py
- Frontend: frontend-internal/src/routes/reports/
- Mejoras al módulo de auditoría (audit.py, audit_helpers.py)
- Modelo de auditoría actualizado
- Sidebar actualizado con enlace a reportes
2026-02-26 08:51:46 -07:00

762 lines
26 KiB
Python

"""
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
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
created_rows = (await db.execute(
select(
func.date_trunc("day", 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", Ticket.created_at))
.order_by(func.date_trunc("day", Ticket.created_at))
)).all()
# Tickets resueltos por día (según resolved_at)
resolved_rows = (await db.execute(
select(
func.date_trunc("day", 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", Ticket.resolved_at))
.order_by(func.date_trunc("day", 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,
)