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
This commit is contained in:
253
backend/app/api/schemas/sla.py
Normal file
253
backend/app/api/schemas/sla.py
Normal file
@@ -0,0 +1,253 @@
|
||||
"""
|
||||
SLA Schemas - ServiceManagerWeb
|
||||
|
||||
Schemas para el sistema de gestión de SLAs
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
import uuid
|
||||
|
||||
|
||||
class SLATypeEnum(str, Enum):
|
||||
"""Tipos de SLA"""
|
||||
RESPONSE = "response"
|
||||
RESOLUTION = "resolution"
|
||||
|
||||
|
||||
class SLAStatusEnum(str, Enum):
|
||||
"""Estados de cumplimiento SLA"""
|
||||
MET = "met" # Cumplido
|
||||
VIOLATED = "violated" # Violado
|
||||
AT_RISK = "at_risk" # En riesgo (80%+ del tiempo)
|
||||
PENDING = "pending" # Pendiente (ticket aún abierto)
|
||||
|
||||
|
||||
# ===================================
|
||||
# DASHBOARD SCHEMAS
|
||||
# ===================================
|
||||
|
||||
class SLAComplianceMetrics(BaseModel):
|
||||
"""Métricas de cumplimiento SLA"""
|
||||
target_hours: int
|
||||
met_count: int
|
||||
violated_count: int
|
||||
at_risk_count: int
|
||||
total_count: int
|
||||
compliance_percentage: float
|
||||
avg_time_hours: Optional[float] = None
|
||||
|
||||
|
||||
class SLADashboardResponse(BaseModel):
|
||||
"""Response del dashboard principal de SLA"""
|
||||
tenant_id: uuid.UUID
|
||||
period_start: datetime
|
||||
period_end: datetime
|
||||
generated_at: datetime
|
||||
|
||||
# Métricas generales
|
||||
response_sla: SLAComplianceMetrics
|
||||
resolution_sla: SLAComplianceMetrics
|
||||
|
||||
# Contadores rápidos
|
||||
active_violations: int
|
||||
at_risk_tickets: int
|
||||
total_tickets_period: int
|
||||
|
||||
# Breakdown por categoría (top 5)
|
||||
by_category: List[Dict[str, Any]]
|
||||
|
||||
# Breakdown por prioridad
|
||||
by_priority: Dict[str, Dict[str, float]]
|
||||
|
||||
# Tendencias (comparación con período anterior)
|
||||
trends: Dict[str, str]
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# ===================================
|
||||
# VIOLATIONS SCHEMAS
|
||||
# ===================================
|
||||
|
||||
class TicketBasicInfo(BaseModel):
|
||||
"""Información básica del ticket"""
|
||||
id: uuid.UUID
|
||||
ticket_number: str
|
||||
subject: str
|
||||
priority: str
|
||||
status: str
|
||||
|
||||
|
||||
class UserBasicInfo(BaseModel):
|
||||
"""Información básica del usuario"""
|
||||
id: uuid.UUID
|
||||
first_name: str
|
||||
last_name: str
|
||||
email: str
|
||||
|
||||
|
||||
class CategoryBasicInfo(BaseModel):
|
||||
"""Información básica de categoría"""
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
sla_response_hours: int
|
||||
sla_resolution_hours: int
|
||||
|
||||
|
||||
class SLAViolationResponse(BaseModel):
|
||||
"""Detalle de una violación SLA"""
|
||||
ticket: TicketBasicInfo
|
||||
category: Optional[CategoryBasicInfo] = None
|
||||
created_by: UserBasicInfo
|
||||
assigned_to: Optional[UserBasicInfo] = None
|
||||
|
||||
sla_type: SLATypeEnum
|
||||
sla_due_at: datetime
|
||||
violated_at: datetime
|
||||
hours_overdue: float
|
||||
|
||||
# Contexto adicional
|
||||
first_response_at: Optional[datetime] = None
|
||||
resolved_at: Optional[datetime] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class SLAViolationsListResponse(BaseModel):
|
||||
"""Lista paginada de violaciones"""
|
||||
violations: List[SLAViolationResponse]
|
||||
total: int
|
||||
page: int
|
||||
per_page: int
|
||||
total_pages: int
|
||||
|
||||
|
||||
# ===================================
|
||||
# TICKETS AT RISK
|
||||
# ===================================
|
||||
|
||||
class SLATicketAtRisk(BaseModel):
|
||||
"""Ticket que está en riesgo de violar SLA"""
|
||||
ticket: TicketBasicInfo
|
||||
category: Optional[CategoryBasicInfo] = None
|
||||
assigned_to: Optional[UserBasicInfo] = None
|
||||
|
||||
sla_type: SLATypeEnum
|
||||
sla_due_at: datetime
|
||||
time_remaining_hours: float
|
||||
risk_percentage: float # 0-100, qué % del tiempo ha pasado
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class SLAAtRiskListResponse(BaseModel):
|
||||
"""Lista de tickets en riesgo"""
|
||||
tickets: List[SLATicketAtRisk]
|
||||
total: int
|
||||
|
||||
|
||||
# ===================================
|
||||
# METRICS & REPORTS SCHEMAS
|
||||
# ===================================
|
||||
|
||||
class SLAMetricsByCategory(BaseModel):
|
||||
"""Métricas SLA por categoría"""
|
||||
category_id: uuid.UUID
|
||||
category_name: str
|
||||
response_sla_compliance: float
|
||||
resolution_sla_compliance: float
|
||||
total_tickets: int
|
||||
response_violations: int
|
||||
resolution_violations: int
|
||||
avg_response_time_hours: Optional[float]
|
||||
avg_resolution_time_hours: Optional[float]
|
||||
|
||||
|
||||
class SLAMetricsByAgent(BaseModel):
|
||||
"""Métricas SLA por agente"""
|
||||
agent_id: uuid.UUID
|
||||
agent_name: str
|
||||
tickets_assigned: int
|
||||
response_sla_met: int
|
||||
resolution_sla_met: int
|
||||
response_compliance: float
|
||||
resolution_compliance: float
|
||||
avg_response_time_hours: Optional[float]
|
||||
avg_resolution_time_hours: Optional[float]
|
||||
|
||||
|
||||
class SLAMetricsByPriority(BaseModel):
|
||||
"""Métricas SLA por prioridad"""
|
||||
priority: str
|
||||
total_tickets: int
|
||||
response_sla_compliance: float
|
||||
resolution_sla_compliance: float
|
||||
avg_response_time_hours: Optional[float]
|
||||
avg_resolution_time_hours: Optional[float]
|
||||
|
||||
|
||||
class SLADetailedMetricsResponse(BaseModel):
|
||||
"""Response de métricas detalladas"""
|
||||
tenant_id: uuid.UUID
|
||||
date_from: datetime
|
||||
date_to: datetime
|
||||
group_by: str # 'category', 'agent', 'priority'
|
||||
|
||||
by_category: Optional[List[SLAMetricsByCategory]] = None
|
||||
by_agent: Optional[List[SLAMetricsByAgent]] = None
|
||||
by_priority: Optional[List[SLAMetricsByPriority]] = None
|
||||
|
||||
generated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# ===================================
|
||||
# HISTORICAL TRENDS
|
||||
# ===================================
|
||||
|
||||
class SLADailyTrend(BaseModel):
|
||||
"""Tendencia diaria de SLA"""
|
||||
date: str # YYYY-MM-DD
|
||||
response_compliance: float
|
||||
resolution_compliance: float
|
||||
total_tickets: int
|
||||
violations: int
|
||||
|
||||
|
||||
class SLATrendsResponse(BaseModel):
|
||||
"""Response de tendencias históricas"""
|
||||
tenant_id: uuid.UUID
|
||||
days: int
|
||||
daily_trends: List[SLADailyTrend]
|
||||
|
||||
# Promedios del período
|
||||
avg_response_compliance: float
|
||||
avg_resolution_compliance: float
|
||||
total_tickets: int
|
||||
total_violations: int
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# ===================================
|
||||
# CONFIGURATION
|
||||
# ===================================
|
||||
|
||||
class SLAConfigByCategoryResponse(BaseModel):
|
||||
"""Configuración SLA por categoría"""
|
||||
category_id: uuid.UUID
|
||||
category_name: str
|
||||
sla_response_hours: int
|
||||
sla_resolution_hours: int
|
||||
warning_threshold_percentage: int # % del tiempo para alertar
|
||||
is_active: bool
|
||||
|
||||
|
||||
class SLAConfigListResponse(BaseModel):
|
||||
"""Lista de configuraciones SLA"""
|
||||
tenant_id: uuid.UUID
|
||||
categories: List[SLAConfigByCategoryResponse]
|
||||
649
backend/app/api/v1/endpoints/sla.py
Normal file
649
backend/app/api/v1/endpoints/sla.py
Normal file
@@ -0,0 +1,649 @@
|
||||
"""
|
||||
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
|
||||
]
|
||||
)
|
||||
@@ -6,7 +6,7 @@ Router principal para la API v1
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1.endpoints import auth, health, tenants, users, systems, categories, tickets, client_profile, audit
|
||||
from app.api.v1.endpoints import auth, health, tenants, users, systems, categories, tickets, client_profile, audit, sla
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
@@ -66,4 +66,11 @@ api_router.include_router(
|
||||
audit.router,
|
||||
prefix="/audit",
|
||||
tags=["audit"]
|
||||
)
|
||||
|
||||
# SLA routes
|
||||
api_router.include_router(
|
||||
sla.router,
|
||||
prefix="/sla",
|
||||
tags=["sla"]
|
||||
)
|
||||
Reference in New Issue
Block a user