Neste capítulo, você vai construir um projeto aplicado de análise de atendimento (suporte/CS/central de serviços) com foco em SLAs, filas, resolução e qualidade de serviço. A ideia é sair de métricas soltas e chegar a um conjunto de consultas que sustentem um relatório operacional: onde estão os gargalos, quais filas estouram SLA, como evolui a resolução e como medir qualidade com consistência.
Modelo mental: o que medir em atendimento
Em operações de atendimento, quase tudo gira em torno de tempo e status. Para transformar isso em métricas confiáveis, você precisa separar quatro dimensões:
- Demanda: quantos tickets entram (criados) por período, por fila, por canal, por prioridade.
- Capacidade e fluxo: quantos tickets são atendidos e resolvidos, quanto tempo ficam aguardando, quantas reaberturas e transferências acontecem.
- SLA: regras de tempo-alvo (primeira resposta, resolução) e se o ticket cumpriu ou não.
- Qualidade: satisfação (CSAT), auditoria, FCR (resolução no primeiro contato), retrabalho (reabertura), e consistência de categorização.
O ponto crítico é que “tempo” em atendimento não é um único número: existe tempo de espera, tempo em atendimento, tempo total, tempo útil (horário comercial) e tempo por fila. Se você não definir claramente o que está medindo, o relatório vira uma disputa de interpretações.
Conjunto de dados do projeto (tabelas e campos)
Considere um esquema típico (nomes ilustrativos). Ajuste para o seu banco:
- tickets: ticket_id, created_at, closed_at, status, channel, priority, customer_id, current_queue_id, assigned_agent_id
- ticket_events: event_id, ticket_id, event_at, event_type, from_status, to_status, from_queue_id, to_queue_id, agent_id
- sla_policy: policy_id, priority, channel, first_response_minutes_target, resolution_minutes_target
- sla_tracking: ticket_id, first_response_at, first_response_breached, resolution_breached, resolution_at (ou calculado), policy_id
- queues: queue_id, queue_name, queue_group
- agents: agent_id, agent_name, team
- csat_surveys: ticket_id, survey_sent_at, score (1–5), comment
Dois alertas práticos: (1) nem todo ticket tem pesquisa CSAT; (2) eventos podem ter granularidade alta e duplicidades (ex.: múltiplas mudanças de status em minutos). O projeto vai lidar com isso definindo “eventos relevantes” e regras de consolidação.
Continue em nosso aplicativo
Você poderá ouvir o audiobook com a tela desligada, ganhar gratuitamente o certificado deste curso e ainda ter acesso a outros 5.000 cursos online gratuitos.
ou continue lendo abaixo...Baixar o aplicativo
Definições operacionais (para não misturar conceitos)
SLA de primeira resposta
Tempo entre created_at e o primeiro momento em que o cliente recebeu uma resposta (ou o ticket saiu de “novo” para “em atendimento”, dependendo do seu sistema). Em muitos ambientes, isso é melhor representado por um campo calculado (first_response_at) ou por um evento específico (ex.: event_type = 'first_reply').
SLA de resolução
Tempo entre created_at e closed_at (ou o primeiro momento em que o ticket foi marcado como resolvido). Em operações com reabertura, você precisa decidir se o SLA “reinicia” ou se continua contando. Para relatórios executivos, é comum medir o tempo até a primeira resolução e também a taxa de reabertura.
Fila (queue) e responsabilidade
Fila é onde o ticket “está” aguardando ou sendo tratado. Como tickets podem ser transferidos, a análise por fila exige olhar eventos de mudança de fila e calcular tempo em cada etapa.
Qualidade
Qualidade pode ser aproximada por: CSAT médio, % de CSAT baixo (1–2), FCR (sem reabertura), e conformidade de categorização (ex.: motivo correto). Neste capítulo, vamos focar em CSAT e reabertura por serem comuns e mensuráveis.
Passo a passo prático: construir um relatório operacional de atendimento
Você vai montar o projeto em camadas: primeiro, uma base de tickets com tempos e flags; depois, métricas por fila e por agente; por fim, indicadores de qualidade e um “painel” em formato tabular.
Passo 1 — Base de tickets com tempos principais e SLA
Objetivo: ter uma linha por ticket com datas-chave, tempos (minutos) e status de SLA. Se você já tem uma tabela sla_tracking, aproveite. Se não tiver, você pode derivar first_response_at via eventos.
WITH first_response AS ( SELECT te.ticket_id, MIN(te.event_at) AS first_response_at FROM ticket_events te WHERE te.event_type IN ('first_reply','agent_reply','public_reply') GROUP BY te.ticket_id), ticket_base AS ( SELECT t.ticket_id, t.created_at, t.closed_at, t.status, t.channel, t.priority, t.current_queue_id, t.assigned_agent_id, fr.first_response_at, CASE WHEN fr.first_response_at IS NULL THEN NULL ELSE EXTRACT(EPOCH FROM (fr.first_response_at - t.created_at)) / 60.0 END AS first_response_minutes, CASE WHEN t.closed_at IS NULL THEN NULL ELSE EXTRACT(EPOCH FROM (t.closed_at - t.created_at)) / 60.0 END AS resolution_minutes FROM tickets t LEFT JOIN first_response fr ON fr.ticket_id = t.ticket_id), ticket_with_policy AS ( SELECT tb.*, sp.policy_id, sp.first_response_minutes_target, sp.resolution_minutes_target, CASE WHEN tb.first_response_minutes IS NULL THEN NULL WHEN tb.first_response_minutes <= sp.first_response_minutes_target THEN 0 ELSE 1 END AS first_response_breached, CASE WHEN tb.resolution_minutes IS NULL THEN NULL WHEN tb.resolution_minutes <= sp.resolution_minutes_target THEN 0 ELSE 1 END AS resolution_breached FROM ticket_base tb LEFT JOIN sla_policy sp ON sp.priority = tb.priority AND sp.channel = tb.channel) SELECT * FROM ticket_with_policy;Notas práticas:
- Se o seu banco não tiver
EXTRACT(EPOCH FROM ...), substitua pelo cálculo equivalente de diferença de tempo. - Se
first_response_atnão existir como evento, use o primeiro evento de “atribuição a agente” ou “mudança para em atendimento”, mas documente a definição. - Se a política de SLA depender também de cliente, contrato ou fila, inclua essas chaves no join da política.
Passo 2 — Volume, backlog e taxa de resolução por período
Objetivo: enxergar demanda (criados), saída (fechados) e backlog (abertos) para entender pressão operacional. Backlog é especialmente útil para explicar por que SLA piora mesmo com volume estável.
WITH daily AS ( SELECT DATE(created_at) AS day, COUNT(*) AS created_tickets FROM tickets GROUP BY DATE(created_at)), daily_closed AS ( SELECT DATE(closed_at) AS day, COUNT(*) AS closed_tickets FROM tickets WHERE closed_at IS NOT NULL GROUP BY DATE(closed_at)), daily_join AS ( SELECT d.day, d.created_tickets, COALESCE(dc.closed_tickets, 0) AS closed_tickets FROM daily d LEFT JOIN daily_closed dc ON dc.day = d.day) SELECT day, created_tickets, closed_tickets, (created_tickets - closed_tickets) AS net_inflow_estimate FROM daily_join ORDER BY day;Esse “net_inflow_estimate” é um proxy simples. Para backlog real por dia, você precisa contar tickets abertos em cada dia (ticket criado antes ou no dia e não fechado até o dia). Isso é mais pesado, mas viável em relatórios diários.
Passo 3 — SLA por fila: onde estoura e por quê
Objetivo: identificar filas com maior % de violação e também entender se o problema é primeira resposta ou resolução. Use a base do Passo 1 e agregue por fila.
WITH base AS ( SELECT * FROM ticket_with_policy) SELECT q.queue_name, COUNT(*) AS tickets, AVG(CASE WHEN first_response_breached = 1 THEN 1.0 ELSE 0.0 END) AS pct_first_response_breach, AVG(CASE WHEN resolution_breached = 1 THEN 1.0 ELSE 0.0 END) AS pct_resolution_breach, AVG(first_response_minutes) AS avg_first_response_minutes, AVG(resolution_minutes) AS avg_resolution_minutes FROM base b LEFT JOIN queues q ON q.queue_id = b.current_queue_id GROUP BY q.queue_name ORDER BY pct_resolution_breach DESC, tickets DESC;Interpretação: filas com poucos tickets podem aparecer no topo por variância. Em um relatório real, aplique um corte mínimo de volume (ex.: pelo menos 30 tickets no período) para evitar decisões com base em amostras pequenas.
Passo 4 — Tempo em fila (análise de gargalo por transferência)
Objetivo: medir quanto tempo o ticket ficou em cada fila ao longo do ciclo, não apenas na fila atual. Isso exige transformar eventos de mudança de fila em intervalos.
Estratégia: para cada ticket, ordene eventos de fila por tempo e calcule o tempo entre um evento e o próximo. O tempo é atribuído à fila “depois” da mudança (ou “antes”, dependendo da sua convenção). Aqui vamos atribuir o intervalo à fila em que o ticket ficou após a mudança.
WITH queue_changes AS ( SELECT te.ticket_id, te.event_at, te.to_queue_id AS queue_id FROM ticket_events te WHERE te.event_type = 'queue_changed' AND te.to_queue_id IS NOT NULL), ordered AS ( SELECT qc.*, LEAD(qc.event_at) OVER (PARTITION BY qc.ticket_id ORDER BY qc.event_at) AS next_event_at FROM queue_changes qc), intervals AS ( SELECT ticket_id, queue_id, event_at AS interval_start, next_event_at AS interval_end, CASE WHEN next_event_at IS NULL THEN NULL ELSE EXTRACT(EPOCH FROM (next_event_at - event_at)) / 60.0 END AS minutes_in_queue FROM ordered) SELECT q.queue_name, COUNT(*) AS intervals_count, AVG(minutes_in_queue) AS avg_minutes_in_queue, SUM(minutes_in_queue) AS total_minutes_in_queue FROM intervals i LEFT JOIN queues q ON q.queue_id = i.queue_id WHERE minutes_in_queue IS NOT NULL GROUP BY q.queue_name ORDER BY total_minutes_in_queue DESC;Pontos de atenção:
- Se o ticket não tiver evento de fila inicial, você pode criar um “evento sintético” em
created_atcom a fila inicial (ex.:current_queue_idno momento de criação, se existir historicamente). - O último intervalo (sem
next_event_at) pode ser fechado emclosed_atdo ticket para medir tempo até encerrar. Isso melhora muito a análise de gargalo.
Passo 5 — Produtividade e carga por agente (com cuidado)
Objetivo: entender volume resolvido por agente e tempos médios, sem cair na armadilha de comparar agentes com carteiras diferentes. O mínimo é segmentar por fila, prioridade ou tipo de ticket.
WITH base AS ( SELECT * FROM ticket_with_policy WHERE closed_at IS NOT NULL) SELECT a.team, a.agent_name, COUNT(*) AS resolved_tickets, AVG(resolution_minutes) AS avg_resolution_minutes, AVG(CASE WHEN resolution_breached = 1 THEN 1.0 ELSE 0.0 END) AS pct_resolution_breach FROM base b LEFT JOIN agents a ON a.agent_id = b.assigned_agent_id GROUP BY a.team, a.agent_name ORDER BY resolved_tickets DESC;Boas perguntas para complementar:
- O agente recebe mais tickets de alta prioridade?
- O agente atua em filas com maior complexidade?
- Há muitos tickets sem agente atribuído (indicando falha de roteamento)?
Passo 6 — Reabertura e FCR (First Contact Resolution)
Objetivo: medir retrabalho. Uma definição simples: ticket reaberto se, após fechado, volta para status aberto. FCR pode ser “tickets fechados sem reabertura em X dias”.
WITH status_events AS ( SELECT te.ticket_id, te.event_at, te.to_status FROM ticket_events te WHERE te.event_type = 'status_changed'), reopened AS ( SELECT se.ticket_id, MIN(se.event_at) AS first_reopen_at FROM status_events se WHERE se.to_status IN ('reopened','open') GROUP BY se.ticket_id), closed AS ( SELECT ticket_id, closed_at FROM tickets WHERE closed_at IS NOT NULL) SELECT COUNT(*) AS closed_tickets, SUM(CASE WHEN r.first_reopen_at IS NOT NULL AND r.first_reopen_at > c.closed_at THEN 1 ELSE 0 END) AS reopened_tickets, AVG(CASE WHEN r.first_reopen_at IS NOT NULL AND r.first_reopen_at > c.closed_at THEN 0.0 ELSE 1.0 END) AS fcr_rate_estimate FROM closed c LEFT JOIN reopened r ON r.ticket_id = c.ticket_id;Aprimoramento: para FCR com janela, exija que a reabertura ocorra até X dias após o fechamento. Isso evita penalizar casos raros muito tardios.
Passo 7 — Qualidade via CSAT: cobertura, média e detratores
Objetivo: medir satisfação sem ignorar viés de resposta. Sempre reporte também a cobertura: % de tickets com pesquisa respondida.
WITH closed AS ( SELECT ticket_id, closed_at, current_queue_id, assigned_agent_id FROM tickets WHERE closed_at IS NOT NULL), csat AS ( SELECT ticket_id, score FROM csat_surveys WHERE score IS NOT NULL) SELECT q.queue_name, COUNT(*) AS closed_tickets, COUNT(c.ticket_id) AS csat_responses, (COUNT(c.ticket_id) * 1.0 / COUNT(*)) AS csat_coverage, AVG(c.score * 1.0) AS csat_avg, AVG(CASE WHEN c.score IN (1,2) THEN 1.0 ELSE 0.0 END) AS pct_low_csat FROM closed t LEFT JOIN csat c ON c.ticket_id = t.ticket_id LEFT JOIN queues q ON q.queue_id = t.current_queue_id GROUP BY q.queue_name ORDER BY pct_low_csat DESC, csat_coverage DESC;Leitura correta: CSAT alto com cobertura baixa pode esconder problemas. CSAT baixo com cobertura alta é sinal mais confiável de dor real.
Passo 8 — Painel consolidado: SLA + fluxo + qualidade por fila
Objetivo: entregar uma tabela única por fila com os principais indicadores do período. Isso facilita consumo por gestores e vira base de dashboard.
WITH base AS ( SELECT * FROM ticket_with_policy), closed AS ( SELECT * FROM base WHERE closed_at IS NOT NULL), csat AS ( SELECT ticket_id, score FROM csat_surveys WHERE score IS NOT NULL), reopened_flag AS ( SELECT t.ticket_id, CASE WHEN EXISTS ( SELECT 1 FROM ticket_events te WHERE te.ticket_id = t.ticket_id AND te.event_type = 'status_changed' AND te.to_status IN ('reopened','open') AND te.event_at > t.closed_at ) THEN 1 ELSE 0 END AS reopened FROM tickets t WHERE t.closed_at IS NOT NULL) SELECT q.queue_name, COUNT(*) AS tickets_created, SUM(CASE WHEN b.closed_at IS NOT NULL THEN 1 ELSE 0 END) AS tickets_closed, AVG(CASE WHEN b.first_response_breached = 1 THEN 1.0 ELSE 0.0 END) AS pct_first_response_breach, AVG(CASE WHEN b.resolution_breached = 1 THEN 1.0 ELSE 0.0 END) AS pct_resolution_breach, AVG(b.first_response_minutes) AS avg_first_response_minutes, AVG(b.resolution_minutes) AS avg_resolution_minutes, AVG(CASE WHEN rf.reopened = 1 THEN 1.0 ELSE 0.0 END) AS pct_reopened, COUNT(c.score) AS csat_responses, (COUNT(c.score) * 1.0 / SUM(CASE WHEN b.closed_at IS NOT NULL THEN 1 ELSE 0 END)) AS csat_coverage_on_closed, AVG(c.score * 1.0) AS csat_avg FROM base b LEFT JOIN queues q ON q.queue_id = b.current_queue_id LEFT JOIN reopened_flag rf ON rf.ticket_id = b.ticket_id LEFT JOIN csat c ON c.ticket_id = b.ticket_id GROUP BY q.queue_name ORDER BY pct_resolution_breach DESC, tickets_created DESC;Como usar: essa tabela permite priorizar ações. Exemplo de leitura: uma fila com alta violação de primeira resposta pode precisar de triagem/roteamento; alta violação de resolução com reabertura alta pode indicar baixa qualidade ou falta de base de conhecimento; CSAT baixo com SLA bom pode indicar problema de comunicação ou solução inadequada.
Checklist de consistência específico para atendimento
1) Tickets sem datas-chave
Liste tickets sem first_response_at (quando esperado) e sem closed_at (quando deveriam estar fechados). Isso evita que médias fiquem artificialmente baixas/altas por muitos nulos.
2) Políticas de SLA não encontradas
Conte tickets que não casam com sla_policy. Normalmente é erro de cadastro (prioridade nova, canal novo) e gera NULL em metas e flags.
3) Eventos fora de ordem
Verifique se há eventos com event_at anterior ao created_at do ticket (problema de timezone/integração) ou múltiplos eventos idênticos (duplicidade de ingestão).
4) Transferências excessivas
Tickets com muitas mudanças de fila costumam correlacionar com SLA ruim e CSAT baixo. Uma consulta simples é contar queue_changed por ticket e segmentar por fila de origem/destino.
Extensões úteis (quando você quiser aprofundar)
- Horário comercial: recalcular tempos de SLA considerando apenas janelas úteis (ex.: 09:00–18:00, dias úteis). Isso geralmente exige tabela de calendário e lógica adicional.
- Segmentação por motivo: cruzar SLA/CSAT com categorias de contato (billing, técnico, cancelamento) para achar causas.
- Capacidade: combinar tickets com escalas/turnos para medir carga por hora e dimensionamento.
- Qualidade por auditoria: se houver tabela de auditoria (nota e checklist), integrar com SLA e reabertura para achar trade-offs.