diff --git a/README.md b/README.md index b040355..f0d6c7a 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,42 @@ cd ../frontend-internal npm test ``` +## Troubleshooting + +### Error 500 en Login / Proxy Error + +**Síntoma**: Error 500 al intentar hacer login, o error de proxy de Vite "connect ECONNREFUSED". + +**Causa**: Configuración incorrecta de la comunicación entre servicios de Docker. + +**Solución**: +1. En desarrollo con Docker, los servicios usan nombres de servicio (no `localhost`) +2. Verificar `vite.config.js`: el proxy debe apuntar a `http://backend:8000` +3. Verificar `docker-compose.yml`: `PUBLIC_API_URL` debe ser `http://backend:8000` +4. Después de cambios, reiniciar contenedor: `docker-compose restart frontend-internal` + +**Nota**: Para desarrollo local sin Docker, cambiar el proxy a `http://localhost:8000`. + +### Tenant Slug Incorrecto + +**Síntoma**: Error de autenticación incluso con credenciales correctas. + +**Causa**: El `tenant_slug` en el login no coincide con los tenants en la BD. + +**Solución**: +1. Verificar tenants existentes: `docker exec servicemanager-backend python check_tenants.py` +2. Actualizar el tenant_slug en el código de login +3. Tenants por defecto: `aduanasoft-demo`, `test-tenant` + +### Credenciales de Prueba + +``` +Email: admin@aduanasoft.com +Password: admin123 +Tenant: aduanasoft-demo +Role: ADMIN +``` + ## Contribución 1. Fork del proyecto diff --git a/backend/app/api/schemas/sla.py b/backend/app/api/schemas/sla.py new file mode 100644 index 0000000..ddeafa3 --- /dev/null +++ b/backend/app/api/schemas/sla.py @@ -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] diff --git a/backend/app/api/v1/endpoints/sla.py b/backend/app/api/v1/endpoints/sla.py new file mode 100644 index 0000000..2144db6 --- /dev/null +++ b/backend/app/api/v1/endpoints/sla.py @@ -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 + ] + ) diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index f280cce..e361920 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -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"] ) \ No newline at end of file diff --git a/backend/check_tenants.py b/backend/check_tenants.py new file mode 100644 index 0000000..2a0fda0 --- /dev/null +++ b/backend/check_tenants.py @@ -0,0 +1,35 @@ +""" +Utility script to list all tenants in the database +""" +import asyncio +from sqlalchemy import select +from app.core.database import AsyncSessionLocal +from app.models.tenant import Tenant + +async def list_tenants(): + """List all tenants with their details.""" + async with AsyncSessionLocal() as db: + result = await db.execute(select(Tenant)) + tenants = result.scalars().all() + + print("\n" + "="*60) + print("📋 TENANTS EN LA BASE DE DATOS") + print("="*60 + "\n") + + if not tenants: + print("⚠️ No hay tenants en la base de datos\n") + print("💡 Ejecuta las migraciones o crea un tenant manualmente") + return + + for tenant in tenants: + print(f"Slug: {tenant.slug}") + print(f"Nombre: {tenant.name}") + print(f"Status: {tenant.status}") + print(f"Email: {tenant.contact_email or 'N/A'}") + print(f"ID: {tenant.id}") + print("-" * 60) + + print(f"\nTotal: {len(tenants)} tenant(s)\n") + +if __name__ == "__main__": + asyncio.run(list_tenants()) diff --git a/docker-compose.yml b/docker-compose.yml index 7a296c7..0c399e8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -161,7 +161,7 @@ services: restart: unless-stopped environment: - NODE_ENV=${ENVIRONMENT:-development} - - PUBLIC_API_URL=${API_BASE_URL:-http://localhost:8000} + - PUBLIC_API_URL=http://backend:8000 - PUBLIC_APP_NAME=ServiceManager Cliente volumes: - ./frontend-client:/app @@ -186,7 +186,7 @@ services: restart: unless-stopped environment: - NODE_ENV=${ENVIRONMENT:-development} - - PUBLIC_API_URL=${API_BASE_URL:-http://localhost:8000} + - PUBLIC_API_URL=http://backend:8000 - PUBLIC_APP_NAME=ServiceManager Admin volumes: - ./frontend-internal:/app diff --git a/docs/changelog-2026-02-17.md b/docs/changelog-2026-02-17.md new file mode 100644 index 0000000..54db5ac --- /dev/null +++ b/docs/changelog-2026-02-17.md @@ -0,0 +1,119 @@ +# Changelog - 17 de Febrero 2026 +## Versión 1.7.0 - Corrección Sistema de SLA Dashboard + +### 🐛 Bugs Corregidos + +#### Error 500 en Endpoint `/api/v1/sla/dashboard` + +**Problema Identificado:** +El endpoint de SLA Dashboard estaba generando errores 500 (Internal Server Error) al intentar cargar las métricas. Se identificaron dos problemas críticos en las queries SQL: + +1. **Error de Sintaxis SQL - "missing FROM-clause entry for table 'ticket'"** + - **Causa:** Uso incorrecto de `text()` con referencias al modelo SQLAlchemy dentro de expresiones SQL sin formato + - **Ubicación:** Cálculo de tickets "at risk" en queries de Response y Resolution SLA + - **Expresión problemática:** + ```python + text("INTERVAL '20%' * (Ticket.sla_response_due - Ticket.created_at)") + ``` + +2. **Error de Timezone - "can't subtract offset-naive and offset-aware datetimes"** + - **Causa:** Comparación entre `datetime.now(timezone.utc)` (timezone-aware) y campos de base de datos `TIMESTAMP WITHOUT TIME ZONE` (timezone-naive) + - **Ubicación:** Todas las comparaciones temporales en queries SLA + +### ✅ Solución Implementada + +#### Archivo Modificado: +- `backend/app/api/v1/endpoints/sla.py` + +#### Cambios Realizados: + +1. **Eliminación de SQL Crudo con `text()`** + - Se reemplazaron todas las expresiones `text()` con funciones nativas de SQLAlchemy + - Se utilizó `func.extract('epoch', ...)` para cálculos temporales seguros + +2. **Corrección de Timezone** + - Se introdujo `db_now = func.now()` para usar la función `NOW()` de PostgreSQL directamente + - `now = datetime.now(timezone.utc)` se mantiene solo para cálculos en Python (ej: `period_start`) + - Se reemplazaron todas las comparaciones `now > Ticket.sla_response_due` por `db_now > Ticket.sla_response_due` + +3. **Cálculo de Tickets "At Risk" Mejorado** + - **Lógica:** Un ticket está "en riesgo" cuando ha consumido más del 80% del tiempo disponible + - **Nueva expresión segura:** + ```python + func.extract('epoch', db_now - Ticket.created_at) > + (func.extract('epoch', Ticket.sla_response_due - Ticket.created_at) * 0.8) + ``` + +#### Secciones del Código Corregidas: + +1. **Dashboard Principal** (líneas 93-280) + - Query de Response SLA + - Query de Resolution SLA + - Contadores de violaciones activas + +2. **Lista de Violaciones** (líneas 355-395) + - Filtro por tipo de SLA (response/resolution) + - Queries con timezone corregido + +3. **Tickets en Riesgo** (líneas 505-540) + - Cálculo del umbral de riesgo + - Filtrado de tickets según porcentaje de tiempo consumido + +### 🧪 Validación + +**Pruebas Realizadas:** +- ✅ Endpoint `/api/v1/sla/dashboard?days=30` responde correctamente (200 OK) +- ✅ Backend reiniciado sin errores de sintaxis +- ✅ Logs del backend sin excepciones de SQLAlchemy +- ✅ Frontend carga el dashboard de SLA sin errores 500 + +**Estado del Servicio:** +``` +servicemanager-backend: Up and healthy +servicemanager-db: Up and healthy +servicemanager-redis: Up and healthy +``` + +### 📊 Impacto + +**Alta Prioridad:** Este fix desbloquea una funcionalidad crítica del sistema de gestión de SLAs, permitiendo a los equipos de soporte visualizar: +- Métricas de cumplimiento de Response SLA +- Métricas de cumplimiento de Resolution SLA +- Tickets en riesgo de violar SLA +- Violaciones activas +- Tendencias por categoría y prioridad + +### 🔍 Detalles Técnicos + +**Stack Tecnológico:** +- Python 3.11 +- FastAPI (async) +- SQLAlchemy 2.0 (async ORM) +- PostgreSQL +- Docker + +**Patrón de Solución:** +- Uso de funciones SQL nativas a través de SQLAlchemy ORM +- Separación entre datetime Python (timezone-aware) y SQL timestamps (timezone-naive) +- Eliminación de strings SQL dinámicos en favor de expresiones type-safe + +### 📝 Notas para Desarrollo Futuro + +**Lecciones Aprendidas:** +1. Siempre usar `func.now()` para comparaciones temporales en queries SQL +2. Evitar `text()` cuando sea posible; preferir funciones SQLAlchemy +3. Los campos `TIMESTAMP WITHOUT TIME ZONE` en PostgreSQL deben compararse con valores timezone-naive o funciones SQL + +**Recomendaciones:** +- Considerar migración de campos timestamp a `TIMESTAMP WITH TIME ZONE` en futuras versiones +- Agregar tests de integración para endpoints SLA +- Implementar monitoreo de queries SQL lentas + +--- + +**Desarrollador:** GitHub Copilot +**Fecha:** 17 de Febrero 2026 +**Tipo:** Bug Fix +**Severidad:** Alta +**Branch:** main +**Versión:** 1.7.0 diff --git a/frontend-client/vite.config.js b/frontend-client/vite.config.js index 152d435..c0a6d4b 100644 --- a/frontend-client/vite.config.js +++ b/frontend-client/vite.config.js @@ -8,7 +8,7 @@ export default defineConfig({ host: '0.0.0.0', proxy: { '/api': { - target: 'http://servicemanager-backend:8000', + target: process.env.PUBLIC_API_URL || 'http://backend:8000', changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, '') } diff --git a/frontend-internal/src/lib/components/sla/ComplianceGauge.svelte b/frontend-internal/src/lib/components/sla/ComplianceGauge.svelte new file mode 100644 index 0000000..5847d76 --- /dev/null +++ b/frontend-internal/src/lib/components/sla/ComplianceGauge.svelte @@ -0,0 +1,71 @@ + + +
{label}
+ {/if} +