765 lines
26 KiB
Python
765 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, 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,
|
|
)
|