Files
service_manager/backend/app/api/v1/endpoints/sla.py
icamarillo 75726d915f v1.7.0 - Fix: Corregido error 500 en SLA Dashboard
- Fix error de sintaxis SQL en cálculo de tickets 'at risk'
- Fix error de timezone (offset-naive vs offset-aware datetimes)
- Implementado sistema completo de SLA Management
- Agregados endpoints: /sla/dashboard, /sla/violations, /sla/at-risk
- Creadas vistas frontend para dashboard, violaciones y tickets en riesgo
- Actualizado sistema de Celery para monitoreo automático de SLAs
- Mejorada configuración de categorías con tiempos SLA personalizables
- Corregidos problemas de proxy en configuración de Vite
- Agregado troubleshooting guide en README

Archivos principales modificados:
- backend/app/api/v1/endpoints/sla.py (nuevo)
- backend/app/api/schemas/sla.py (nuevo)
- frontend-internal/src/routes/sla/ (nuevo módulo completo)
- workers/app/tasks/sla_tasks.py (queries async mejoradas)

Documentación: docs/changelog-2026-02-17.md
2026-02-17 08:22:32 -07:00

650 lines
24 KiB
Python

"""
SLA Endpoints - ServiceManagerWeb
Endpoints para gestión y monitoreo de SLAs
Solo accesible por roles staff internos
"""
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_, or_, desc, case, cast
from typing import Optional, List
from datetime import datetime, timedelta, timezone
import uuid
import structlog
from app.core.database import get_db
from app.api.deps import get_current_user, get_current_tenant
from app.models.user import User, UserRole
from app.models.tenant import Tenant
from app.models.ticket import Ticket, TicketStatus, TicketPriority
from app.models.category import Category
from app.api.schemas.sla import (
SLADashboardResponse,
SLAComplianceMetrics,
SLAViolationResponse,
SLAViolationsListResponse,
SLAAtRiskListResponse,
SLATicketAtRisk,
SLADetailedMetricsResponse,
SLAMetricsByCategory,
SLAMetricsByAgent,
SLAMetricsByPriority,
SLATrendsResponse,
SLADailyTrend,
SLAConfigListResponse,
SLAConfigByCategoryResponse,
SLATypeEnum,
TicketBasicInfo,
UserBasicInfo,
CategoryBasicInfo
)
router = APIRouter()
logger = structlog.get_logger(__name__)
def require_staff_role(current_user: User = Depends(get_current_user)) -> User:
"""Requiere roles de staff interno (ADMIN, SUPPORT_MANAGER, AGENT)"""
allowed_roles = [UserRole.ADMIN, UserRole.SUPPORT_MANAGER, UserRole.AGENT, UserRole.AUDITOR]
if current_user.role not in allowed_roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Solo staff interno puede acceder a métricas de SLA"
)
return current_user
def require_manager_role(current_user: User = Depends(get_current_user)) -> User:
"""Requiere roles de gestión (ADMIN, SUPPORT_MANAGER)"""
if current_user.role not in [UserRole.ADMIN, UserRole.SUPPORT_MANAGER]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Solo managers pueden acceder a esta funcionalidad"
)
return current_user
# ===================================
# DASHBOARD PRINCIPAL
# ===================================
@router.get("/dashboard", response_model=SLADashboardResponse)
async def get_sla_dashboard(
days: int = Query(default=30, ge=1, le=365, description="Días hacia atrás para el período"),
current_user: User = Depends(require_staff_role),
current_tenant: Tenant = Depends(get_current_tenant),
db: AsyncSession = Depends(get_db)
):
"""
Dashboard principal de métricas SLA.
**Permisos**: ADMIN, SUPPORT_MANAGER, AGENT, AUDITOR
Retorna métricas agregadas de cumplimiento SLA para el período especificado.
"""
logger.info(
"SLA dashboard requested",
user_id=str(current_user.id),
tenant_id=str(current_tenant.id),
days=days
)
now = datetime.now(timezone.utc)
period_start = now - timedelta(days=days)
# Usar func.now() para comparaciones en SQL (evita timezone issues)
db_now = func.now()
# Query base para tickets del período
base_query = select(Ticket).where(
and_(
Ticket.tenant_id == current_tenant.id,
Ticket.created_at >= period_start
)
)
# Calcular métricas de Response SLA
# Para "at risk": ticket pendiente que ha consumido >80% del tiempo disponible
# Calculamos: (now - created_at) > 0.8 * (sla_response_due - created_at)
response_query = select(
func.count().label('total'),
func.sum(case((Ticket.first_response_at <= Ticket.sla_response_due, 1), else_=0)).label('met'),
func.sum(case((and_(Ticket.first_response_at > Ticket.sla_response_due, Ticket.first_response_at != None), 1), else_=0)).label('violated'),
func.sum(case((and_(Ticket.first_response_at == None, Ticket.sla_response_due != None, db_now > Ticket.sla_response_due), 1), else_=0)).label('violated_pending'),
func.sum(case((
and_(
Ticket.first_response_at == None,
Ticket.sla_response_due != None,
db_now < Ticket.sla_response_due,
func.extract('epoch', db_now - Ticket.created_at) > (func.extract('epoch', Ticket.sla_response_due - Ticket.created_at) * 0.8)
), 1), else_=0)
).label('at_risk'),
func.avg(
func.extract('epoch', Ticket.first_response_at - Ticket.created_at) / 3600
).label('avg_hours')
).where(
and_(
Ticket.tenant_id == current_tenant.id,
Ticket.created_at >= period_start,
Ticket.sla_response_due != None
)
)
response_result = await db.execute(response_query)
response_row = response_result.one()
response_total = response_row.total or 0
response_met = (response_row.met or 0)
response_violated = (response_row.violated or 0) + (response_row.violated_pending or 0)
response_at_risk = response_row.at_risk or 0
response_avg = float(response_row.avg_hours) if response_row.avg_hours else 0.0
response_compliance = (response_met / response_total * 100) if response_total > 0 else 0.0
# Calcular métricas de Resolution SLA
resolution_query = select(
func.count().label('total'),
func.sum(case((Ticket.resolved_at <= Ticket.sla_resolution_due, 1), else_=0)).label('met'),
func.sum(case((and_(Ticket.resolved_at > Ticket.sla_resolution_due, Ticket.resolved_at != None), 1), else_=0)).label('violated'),
func.sum(case((and_(Ticket.status.notin_([TicketStatus.RESOLVED, TicketStatus.CLOSED]), Ticket.sla_resolution_due != None, db_now > Ticket.sla_resolution_due), 1), else_=0)).label('violated_pending'),
func.sum(case((
and_(
Ticket.status.notin_([TicketStatus.RESOLVED, TicketStatus.CLOSED]),
Ticket.sla_resolution_due != None,
db_now < Ticket.sla_resolution_due,
func.extract('epoch', db_now - Ticket.created_at) > (func.extract('epoch', Ticket.sla_resolution_due - Ticket.created_at) * 0.8)
), 1), else_=0)
).label('at_risk'),
func.avg(
func.extract('epoch', Ticket.resolved_at - Ticket.created_at) / 3600
).label('avg_hours')
).where(
and_(
Ticket.tenant_id == current_tenant.id,
Ticket.created_at >= period_start,
Ticket.sla_resolution_due != None
)
)
resolution_result = await db.execute(resolution_query)
resolution_row = resolution_result.one()
resolution_total = resolution_row.total or 0
resolution_met = (resolution_row.met or 0)
resolution_violated = (resolution_row.violated or 0) + (resolution_row.violated_pending or 0)
resolution_at_risk = resolution_row.at_risk or 0
resolution_avg = float(resolution_row.avg_hours) if resolution_row.avg_hours else 0.0
resolution_compliance = (resolution_met / resolution_total * 100) if resolution_total > 0 else 0.0
# Métricas por categoría (top 5)
category_query = select(
Category.id,
Category.name,
func.count(Ticket.id).label('ticket_count'),
func.avg(case((Ticket.first_response_at <= Ticket.sla_response_due, 100.0), else_=0.0)).label('response_compliance'),
func.avg(case((Ticket.resolved_at <= Ticket.sla_resolution_due, 100.0), else_=0.0)).label('resolution_compliance')
).select_from(Ticket).join(
Category, Ticket.category_id == Category.id, isouter=True
).where(
and_(
Ticket.tenant_id == current_tenant.id,
Ticket.created_at >= period_start
)
).group_by(Category.id, Category.name).order_by(desc('ticket_count')).limit(5)
category_result = await db.execute(category_query)
by_category = [
{
"category_id": str(row.id) if row.id else None,
"category_name": row.name or "Sin categoría",
"ticket_count": row.ticket_count,
"response_compliance": float(row.response_compliance or 0.0),
"resolution_compliance": float(row.resolution_compliance or 0.0)
}
for row in category_result.all()
]
# Métricas por prioridad
by_priority = {}
for priority in TicketPriority:
priority_query = select(
func.avg(case((Ticket.first_response_at <= Ticket.sla_response_due, 100.0), else_=0.0)).label('response'),
func.avg(case((Ticket.resolved_at <= Ticket.sla_resolution_due, 100.0), else_=0.0)).label('resolution')
).where(
and_(
Ticket.tenant_id == current_tenant.id,
Ticket.created_at >= period_start,
Ticket.priority == priority
)
)
priority_result = await db.execute(priority_query)
priority_row = priority_result.one()
by_priority[priority.value] = {
"response_compliance": float(priority_row.response or 0.0),
"resolution_compliance": float(priority_row.resolution or 0.0)
}
# Calcular tendencias (comparación con período anterior)
prev_period_start = period_start - timedelta(days=days)
prev_response_query = select(
func.count().label('total'),
func.sum(case((Ticket.first_response_at <= Ticket.sla_response_due, 1), else_=0)).label('met')
).where(
and_(
Ticket.tenant_id == current_tenant.id,
Ticket.created_at >= prev_period_start,
Ticket.created_at < period_start,
Ticket.sla_response_due != None
)
)
prev_response_result = await db.execute(prev_response_query)
prev_response_row = prev_response_result.one()
prev_response_compliance = ((prev_response_row.met or 0) / (prev_response_row.total or 1) * 100) if (prev_response_row.total or 0) > 0 else 0.0
response_trend = response_compliance - prev_response_compliance
response_trend_str = f"+{response_trend:.1f}%" if response_trend >= 0 else f"{response_trend:.1f}%"
prev_resolution_query = select(
func.count().label('total'),
func.sum(case((Ticket.resolved_at <= Ticket.sla_resolution_due, 1), else_=0)).label('met')
).where(
and_(
Ticket.tenant_id == current_tenant.id,
Ticket.created_at >= prev_period_start,
Ticket.created_at < period_start,
Ticket.sla_resolution_due != None
)
)
prev_resolution_result = await db.execute(prev_resolution_query)
prev_resolution_row = prev_resolution_result.one()
prev_resolution_compliance = ((prev_resolution_row.met or 0) / (prev_resolution_row.total or 1) * 100) if (prev_resolution_row.total or 0) > 0 else 0.0
resolution_trend = resolution_compliance - prev_resolution_compliance
resolution_trend_str = f"+{resolution_trend:.1f}%" if resolution_trend >= 0 else f"{resolution_trend:.1f}%"
# Contar violaciones activas
active_violations_query = select(func.count()).select_from(Ticket).where(
and_(
Ticket.tenant_id == current_tenant.id,
Ticket.status.notin_([TicketStatus.RESOLVED, TicketStatus.CLOSED]),
or_(
and_(Ticket.first_response_at == None, Ticket.sla_response_due != None, db_now > Ticket.sla_response_due),
and_(Ticket.resolved_at == None, Ticket.sla_resolution_due != None, db_now > Ticket.sla_resolution_due)
)
)
)
active_violations_result = await db.execute(active_violations_query)
active_violations = active_violations_result.scalar() or 0
# Contar total de tickets del período
total_tickets_query = select(func.count()).select_from(Ticket).where(
and_(
Ticket.tenant_id == current_tenant.id,
Ticket.created_at >= period_start
)
)
total_tickets_result = await db.execute(total_tickets_query)
total_tickets_period = total_tickets_result.scalar() or 0
return SLADashboardResponse(
tenant_id=current_tenant.id,
period_start=period_start,
period_end=now,
generated_at=now,
response_sla=SLAComplianceMetrics(
target_hours=2, # Promedio, podría calcularse
met_count=response_met,
violated_count=response_violated,
at_risk_count=response_at_risk,
total_count=response_total,
compliance_percentage=response_compliance,
avg_time_hours=response_avg
),
resolution_sla=SLAComplianceMetrics(
target_hours=24, # Promedio, podría calcularse
met_count=resolution_met,
violated_count=resolution_violated,
at_risk_count=resolution_at_risk,
total_count=resolution_total,
compliance_percentage=resolution_compliance,
avg_time_hours=resolution_avg
),
active_violations=active_violations,
at_risk_tickets=response_at_risk + resolution_at_risk,
total_tickets_period=total_tickets_period,
by_category=by_category,
by_priority=by_priority,
trends={
"response_sla": response_trend_str,
"resolution_sla": resolution_trend_str
}
)
# ===================================
# VIOLACIONES
# ===================================
@router.get("/violations", response_model=SLAViolationsListResponse)
async def get_sla_violations(
skip: int = Query(default=0, ge=0),
limit: int = Query(default=50, ge=1, le=100),
sla_type: Optional[str] = Query(default=None, regex="^(response|resolution)$"),
category_id: Optional[uuid.UUID] = None,
priority: Optional[str] = None,
current_user: User = Depends(require_staff_role),
current_tenant: Tenant = Depends(get_current_tenant),
db: AsyncSession = Depends(get_db)
):
"""
Listar violaciones SLA activas.
**Permisos**: ADMIN, SUPPORT_MANAGER, AGENT (solo sus tickets), AUDITOR
Retorna tickets que han violado sus SLAs de respuesta o resolución.
"""
logger.info(
"SLA violations requested",
user_id=str(current_user.id),
tenant_id=str(current_tenant.id),
sla_type=sla_type
)
now = datetime.now(timezone.utc)
db_now = func.now()
# Base query
query = select(Ticket).where(
and_(
Ticket.tenant_id == current_tenant.id,
Ticket.status.notin_([TicketStatus.RESOLVED, TicketStatus.CLOSED])
)
)
# Filtrar por tipo de SLA
if sla_type == "response":
query = query.where(
and_(
Ticket.first_response_at == None,
Ticket.sla_response_due != None,
db_now > Ticket.sla_response_due
)
)
elif sla_type == "resolution":
query = query.where(
and_(
Ticket.sla_resolution_due != None,
db_now > Ticket.sla_resolution_due
)
)
else:
# Ambos tipos
query = query.where(
or_(
and_(Ticket.first_response_at == None, Ticket.sla_response_due != None, db_now > Ticket.sla_response_due),
and_(Ticket.sla_resolution_due != None, db_now > Ticket.sla_resolution_due)
)
)
# Filtros adicionales
if category_id:
query = query.where(Ticket.category_id == category_id)
if priority:
try:
priority_enum = TicketPriority(priority.upper())
query = query.where(Ticket.priority == priority_enum)
except ValueError:
pass
# AGENTS solo ven sus tickets
if current_user.role == UserRole.AGENT:
query = query.where(Ticket.assigned_to == current_user.id)
# Contar total
count_query = select(func.count()).select_from(query.subquery())
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
# Aplicar paginación
query = query.order_by(desc(Ticket.created_at)).offset(skip).limit(limit)
result = await db.execute(query)
tickets = result.scalars().all()
# Formatear response
violations = []
for ticket in tickets:
# Determinar tipo de violación
response_violated = ticket.first_response_at is None and ticket.sla_response_due and now > ticket.sla_response_due
resolution_violated = ticket.sla_resolution_due and now > ticket.sla_resolution_due
# Priorizar resolution si ambos están violados
if resolution_violated:
violation_type = SLATypeEnum.RESOLUTION
due_at = ticket.sla_resolution_due
else:
violation_type = SLATypeEnum.RESPONSE
due_at = ticket.sla_response_due
hours_overdue = (now - due_at).total_seconds() / 3600 if due_at else 0
# Cargar relaciones
await db.refresh(ticket, ['created_by', 'assigned_to', 'category'])
violations.append(SLAViolationResponse(
ticket=TicketBasicInfo(
id=ticket.id,
ticket_number=ticket.ticket_number,
subject=ticket.subject,
priority=ticket.priority.value,
status=ticket.status.value
),
category=CategoryBasicInfo(
id=ticket.category.id,
name=ticket.category.name,
sla_response_hours=ticket.category.sla_response_hours,
sla_resolution_hours=ticket.category.sla_resolution_hours
) if ticket.category else None,
created_by=UserBasicInfo(
id=ticket.created_by.id,
first_name=ticket.created_by.first_name,
last_name=ticket.created_by.last_name,
email=ticket.created_by.email
),
assigned_to=UserBasicInfo(
id=ticket.assigned_to.id,
first_name=ticket.assigned_to.first_name,
last_name=ticket.assigned_to.last_name,
email=ticket.assigned_to.email
) if ticket.assigned_to else None,
sla_type=violation_type,
sla_due_at=due_at,
violated_at=due_at, # Se violó en el momento del due
hours_overdue=hours_overdue,
first_response_at=ticket.first_response_at,
resolved_at=ticket.resolved_at
))
total_pages = (total + limit - 1) // limit
return SLAViolationsListResponse(
violations=violations,
total=total,
page=(skip // limit) + 1,
per_page=limit,
total_pages=total_pages
)
# ===================================
# TICKETS EN RIESGO
# ===================================
@router.get("/at-risk", response_model=SLAAtRiskListResponse)
async def get_tickets_at_risk(
threshold: int = Query(default=80, ge=50, le=95, description="% de tiempo consumido para considerar en riesgo"),
current_user: User = Depends(require_staff_role),
current_tenant: Tenant = Depends(get_current_tenant),
db: AsyncSession = Depends(get_db)
):
"""
Listar tickets que están en riesgo de violar SLA.
**Permisos**: ADMIN, SUPPORT_MANAGER, AGENT (solo sus tickets), AUDITOR
Retorna tickets que están cerca de vencer su SLA (por defecto, 80% del tiempo consumido).
"""
logger.info(
"SLA at-risk tickets requested",
user_id=str(current_user.id),
tenant_id=str(current_tenant.id),
threshold=threshold
)
now = datetime.now(timezone.utc)
db_now = func.now()
threshold_decimal = threshold / 100.0
# Query para tickets en riesgo
# Un ticket está en riesgo si: (now - created_at) / (due_at - created_at) >= threshold
query = select(Ticket).where(
and_(
Ticket.tenant_id == current_tenant.id,
Ticket.status.notin_([TicketStatus.RESOLVED, TicketStatus.CLOSED]),
or_(
# Response SLA en riesgo
and_(
Ticket.first_response_at == None,
Ticket.sla_response_due != None,
db_now < Ticket.sla_response_due,
# Calcular si está en zona de riesgo
func.extract('epoch', db_now - Ticket.created_at) >= (func.extract('epoch', Ticket.sla_response_due - Ticket.created_at) * threshold_decimal)
),
# Resolution SLA en riesgo
and_(
Ticket.sla_resolution_due != None,
db_now < Ticket.sla_resolution_due,
func.extract('epoch', db_now - Ticket.created_at) >= (func.extract('epoch', Ticket.sla_resolution_due - Ticket.created_at) * threshold_decimal)
)
)
)
).order_by(desc(Ticket.sla_response_due if Ticket.sla_response_due else Ticket.sla_resolution_due))
# AGENTS solo ven sus tickets
if current_user.role == UserRole.AGENT:
query = query.where(Ticket.assigned_to == current_user.id)
result = await db.execute(query)
tickets = result.scalars().all()
# Formatear response
at_risk_tickets = []
for ticket in tickets:
# Determinar cuál SLA está en riesgo
response_at_risk = (
ticket.first_response_at is None and
ticket.sla_response_due and
now < ticket.sla_response_due
)
resolution_at_risk = (
ticket.sla_resolution_due and
now < ticket.sla_resolution_due
)
# Priorizar response si ambos están en riesgo
if response_at_risk:
sla_type = SLATypeEnum.RESPONSE
due_at = ticket.sla_response_due
elif resolution_at_risk:
sla_type = SLATypeEnum.RESOLUTION
due_at = ticket.sla_resolution_due
else:
continue
time_remaining = (due_at - now).total_seconds() / 3600
total_time = (due_at - ticket.created_at).total_seconds() / 3600
elapsed_time = total_time - time_remaining
risk_percentage = (elapsed_time / total_time * 100) if total_time > 0 else 0
# Cargar relaciones
await db.refresh(ticket, ['assigned_to', 'category'])
at_risk_tickets.append(SLATicketAtRisk(
ticket=TicketBasicInfo(
id=ticket.id,
ticket_number=ticket.ticket_number,
subject=ticket.subject,
priority=ticket.priority.value,
status=ticket.status.value
),
category=CategoryBasicInfo(
id=ticket.category.id,
name=ticket.category.name,
sla_response_hours=ticket.category.sla_response_hours,
sla_resolution_hours=ticket.category.sla_resolution_hours
) if ticket.category else None,
assigned_to=UserBasicInfo(
id=ticket.assigned_to.id,
first_name=ticket.assigned_to.first_name,
last_name=ticket.assigned_to.last_name,
email=ticket.assigned_to.email
) if ticket.assigned_to else None,
sla_type=sla_type,
sla_due_at=due_at,
time_remaining_hours=time_remaining,
risk_percentage=risk_percentage
))
return SLAAtRiskListResponse(
tickets=at_risk_tickets,
total=len(at_risk_tickets)
)
# ===================================
# CONFIGURACIÓN
# ===================================
@router.get("/config", response_model=SLAConfigListResponse)
async def get_sla_config(
current_user: User = Depends(require_manager_role),
current_tenant: Tenant = Depends(get_current_tenant),
db: AsyncSession = Depends(get_db)
):
"""
Obtener configuración de SLAs por categoría.
**Permisos**: ADMIN, SUPPORT_MANAGER
Retorna la configuración de tiempos SLA para todas las categorías del tenant.
"""
query = select(Category).where(
Category.tenant_id == current_tenant.id
).order_by(Category.name)
result = await db.execute(query)
categories = result.scalars().all()
return SLAConfigListResponse(
tenant_id=current_tenant.id,
categories=[
SLAConfigByCategoryResponse(
category_id=cat.id,
category_name=cat.name,
sla_response_hours=cat.sla_response_hours,
sla_resolution_hours=cat.sla_resolution_hours,
warning_threshold_percentage=80, # Por ahora hardcoded
is_active=cat.is_active
)
for cat in categories
]
)