Mejoras en Módulo de Tickets: - Implementado sistema de filtros funcional por estado y prioridad - Tabla compacta estilo auditoría (50% más espacio visible) - Backend actualizado: parámetros 'status' y 'priority' con validación - Interfaz más limpia con labels reducidos y 2 columnas de filtros - Eliminación de columna SLA duplicada en tabla Correcciones Backend: - Endpoint /v1/tickets/: filtros 'status' y 'priority' funcionan correctamente - Endpoint /v1/sla/violations: timezone UTC y eager loading con selectinload - Endpoint /v1/client-profile/: generación explícita de UUID - Migración fix_client_profiles_timestamps aplicada Mejoras UI Frontend: - Tabla tickets: encabezados uppercase text-xs, celdas px-3 py-2 - Toggle de estado activo/inactivo en gestión de tenants (tabla + modal) - Badges más compactos con rounded-full - Botones de acciones con separador visual y transiciones - Filtros con URLSearchParams para construcción correcta de queries Arquitectura: - SQLAlchemy: eager loading para evitar N+1 queries - Timezone handling: datetime.now(timezone.utc) para comparaciones - Svelte reactivity: keyed loops y spread operator para forzar updates - API client: endpoint con query string completo Estado del sistema: Totalmente funcional para producción MVP
657 lines
25 KiB
Python
657 lines
25 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 sqlalchemy.orm import selectinload
|
|
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 con carga de relaciones
|
|
query = select(Ticket).options(
|
|
selectinload(Ticket.created_by_user),
|
|
selectinload(Ticket.assigned_to_user),
|
|
selectinload(Ticket.category)
|
|
).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:
|
|
# Asegurar que los datetimes de BD sean timezone-aware
|
|
sla_response_due = ticket.sla_response_due.replace(tzinfo=timezone.utc) if ticket.sla_response_due and ticket.sla_response_due.tzinfo is None else ticket.sla_response_due
|
|
sla_resolution_due = ticket.sla_resolution_due.replace(tzinfo=timezone.utc) if ticket.sla_resolution_due and ticket.sla_resolution_due.tzinfo is None else ticket.sla_resolution_due
|
|
|
|
# Determinar tipo de violación
|
|
response_violated = ticket.first_response_at is None and sla_response_due and now > sla_response_due
|
|
resolution_violated = sla_resolution_due and now > sla_resolution_due
|
|
|
|
# Priorizar resolution si ambos están violados
|
|
if resolution_violated:
|
|
violation_type = SLATypeEnum.RESOLUTION
|
|
due_at = sla_resolution_due
|
|
else:
|
|
violation_type = SLATypeEnum.RESPONSE
|
|
due_at = sla_response_due
|
|
|
|
hours_overdue = (now - due_at).total_seconds() / 3600 if due_at else 0
|
|
|
|
# Las relaciones ya están cargadas por selectinload
|
|
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_user.id,
|
|
first_name=ticket.created_by_user.first_name,
|
|
last_name=ticket.created_by_user.last_name,
|
|
email=ticket.created_by_user.email
|
|
),
|
|
assigned_to=UserBasicInfo(
|
|
id=ticket.assigned_to_user.id,
|
|
first_name=ticket.assigned_to_user.first_name,
|
|
last_name=ticket.assigned_to_user.last_name,
|
|
email=ticket.assigned_to_user.email
|
|
) if ticket.assigned_to_user 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
|
|
]
|
|
)
|