""" 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).replace(tzinfo=None) 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).replace(tzinfo=None) 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 # Obtener todos los tickets sin paginación primero (los ordenaremos por tiempo vencido después) result = await db.execute(query) tickets = result.scalars().all() # Formatear response violations = [] for ticket in tickets: # Todos los campos son timezone-naive (TIMESTAMP WITHOUT TIME ZONE) sla_response_due = ticket.sla_response_due sla_resolution_due = 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 )) # Ordenar por tiempo vencido (de mayor a menor) violations.sort(key=lambda v: v.hours_overdue, reverse=True) # Aplicar paginación en Python paginated_violations = violations[skip:skip + limit] total_pages = (total + limit - 1) // limit return SLAViolationsListResponse( violations=paginated_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).replace(tzinfo=None) db_now = func.now() threshold_decimal = threshold / 100.0 # Query para tickets en riesgo con relaciones precargadas # Un ticket está en riesgo si: (now - created_at) / (due_at - created_at) >= threshold query = select(Ticket).options( selectinload(Ticket.assigned_to_user), selectinload(Ticket.category) ).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 # Normalizar created_at a timezone-naive para evitar errores de comparación created_at = ticket.created_at.replace(tzinfo=None) if ticket.created_at.tzinfo else ticket.created_at time_remaining = (due_at - now).total_seconds() / 3600 total_time = (due_at - created_at).total_seconds() / 3600 elapsed_time = total_time - time_remaining risk_percentage = (elapsed_time / total_time * 100) if total_time > 0 else 0 # Las relaciones ya están cargadas por selectinload 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_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=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 ] )