Files
B2BCall-dialer/docs/CDR.md
Matheus 56499f4b99 feat(cdr): call detail records, relatorios e disposicoes
Fecha agente.md secao 152-160. Registro duravel de chamadas — ate aqui o
estado de uma chamada so' vivia transitoriamente no canal Redis
b2bcall:events (pub/sub sem historico).

## Modelo

calls/call_legs/call_events (secao 153, tenant-scoped, RLS) +
dispositions (secao 89, "Call Center -> Disposicoes", personalizavel por
tenant, mesmo padrao de PauseReason). dial_attempts da especificacao nao
virou tabela nova — CallAttempt (fase Predictive Engine) ja cobre esse
conceito; Call.attemptId liga um Call a' sua tentativa de discagem.
Call.id = o proprio freeswitch_uuid da perna principal (sem suporte a
transferencia entre uuids nesta fase).

## apps/freeswitch-events/src/cdr.ts

Cada NormalizedEvent relevante faz upsert em Call + insere em call_events
(a trilha bruta). CALL_ENDED calcula os agregados em segundos (secao
155-156): ringTime/waitTime/talkTime/durationSeconds/billableSeconds.

## Dois bugs reais achados e corrigidos testando esta fase

- AGENT_OFFERED_CALL/AGENT_BRIDGE_FAILED disparam de uma thread interna
  do mod_callcenter (outbound_agent_thread_run), sem contexto de channel
  — nao tem header Unique-ID, entao callUuid ficava undefined e os dois
  eram descartados silenciosamente (Call.queueId/agentId nunca
  preenchidos mesmo com bridge/falha de bridge reais). Corrigido com
  fallback pro CC-Member-Session-UUID (data.memberSessionUuid), mesmo
  identificador ja usado pra correlacao equivalente no predictive-dialer.
- Corrida entre CALL_CREATED/CALL_ANSWERED (persistCallEvent roda sem
  await, cada evento abre sua propria transacao) podia fazer answerAt
  aparecer antes de createdAt quando o upsert que criava a linha usava
  now() do momento errado (nao do occurredAt do evento real). Corrigido
  setando createdAt explicito a partir de normalized.occurredAt.

## Relatorios (apps/api/src/reports)

GET /reports/queues (secao 159): recebidas/atendidas/abandonadas/TME/TMA/
Service Level/Abandon Rate por fila. GET /reports/agents (secao 158):
tempo logado/pausado/por estado (AgentStateEvent pareado) + chamadas
atendidas/TMA. GET /reports/campaigns (secao 160): leads/attempts/
answered/agent connected/busy/no answer/failed/callbacks/rates/TME/TMA —
"Valor Telefonia"/"Valor IA" ficam null (dependem de Billing, fase
propria).

## GET /calls e disposicao

Secao 157: filtros por data/ramal/agente/fila/campanha/trunk/telefone/
hangup cause/disposicao, sempre escopado ao tenant do JWT. PATCH
/calls/:id/disposition (secao 89): o proprio agente que atendeu marca
(compara Call.agentId contra o Agent do usuario autenticado, nunca um
agentId vindo do client), supervisor (agents.manage) pode marcar em nome
de outro agente.

Verificado ponta a ponta: campanha com 5 leads, 3 ANSWERED simulados
entrando na fila real, Call.queueId/agentId/hangupCause corretos
(confirmando a correcao da correlacao), createdAt<=answerAt em todos,
durationSeconds batendo com discard_abandoned_after; os 3 relatorios com
numeros internamente consistentes entre si e com os logs do discador
(received:3/abandoned:3/abandonRate:1, leads:5/attempts:5/answered:3/
answerRate:0.6); disposicao gravada com ownership check correto;
queue list do FreeSWITCH confirmou calls_abandoned=4 real ao final.

typecheck do workspace inteiro limpo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X1HxY46WGU4G1zmVDNKcWw
2026-08-28 14:10:16 -03:00

7.6 KiB

CDR (Call Detail Records)

Agente.md secao 152-160. Fecha o registro durável de chamadas e os relatórios que dependem dele — até aqui, o estado de uma chamada só vivia transitoriamente no canal Redis b2bcall:events (pub/sub sem histórico).

Modelo

calls/call_legs/call_events (secao 153), tenant-scoped, RLS. dial_attempts da especificação não virou uma tabela nova — CallAttempt (fase Predictive Engine) já cobre exatamente esse conceito (campanha, lead, timestamps, resultado); Call.attemptId liga um Call à sua tentativa de discagem quando aplicável.

Call.id = o próprio freeswitch_uuid da perna principal (sem suporte a transferência entre uuids nesta fase — call_id/freeswitch_uuid coincidem por enquanto, campos mantidos separados só pra já bater com o schema da especificação quando isso mudar).

dispositions (secao 89, "Call Center → Disposições"): personalizável por tenant, mesmo padrão de PauseReason. Call.dispositionId aponta pra lá.

Quem escreve: apps/freeswitch-events/src/cdr.ts

Cada NormalizedEvent relevante (CALL_CREATED, CALL_RINGING, CALL_ANSWERED, CALL_BRIDGED, CALL_ENDED, AGENT_OFFERED_CALL, AGENT_BRIDGE_FAILED, QUEUE_MEMBER_LEFT) faz um upsert em Call (cria se for o primeiro evento visto pra aquele uuid, atualiza os campos relevantes) e sempre insere uma linha em call_events — a trilha bruta. CALL_ENDED também dispara o cálculo dos agregados em segundos (secao 155-156): ringTime/waitTime/talkTime/durationSeconds/ billableSeconds (billableSeconds = talkTime por enquanto, sem regra de tarifação ainda — fase Billing).

Simplificação deliberada: quando a chamada tem fila associada, o primeiro CALL_BRIDGED é tratado como o agente atendendo (agentAnswerAt = bridgeAt) — não distingue bridge pro agente de um eventual bridge intermediário (IVR, AVMD). Reavaliar se/quando esses cenários existirem de verdade.

Achado real: AGENT_OFFERED_CALL/AGENT_BRIDGE_FAILED não têm Unique-ID

Descoberto testando esta fase: esses dois CC-Action disparam de dentro de uma thread interna do mod_callcenter (outbound_agent_thread_run), sem contexto de channel — o header Unique-ID (de onde normalizeEslEvent tira callUuid) simplesmente não existe nesses dois eventos (diferente de member-queue-end, que dispara no channel do member e tem Unique-ID normal). Sem correção, cdr.ts descartava os dois silenciosamente (guard if (!normalized.callUuid) return) — Call.queueId/agentId nunca eram preenchidos, mesmo com o bridge (ou a falha de bridge) acontecendo de verdade.

Corrigido usando CC-Member-Session-UUID (data.memberSessionUuid) como fallback quando callUuid não vem — é o mesmo uuid do channel member em todos os casos, já usado pra correlação equivalente em apps/predictive-dialer/src/event-listener.ts.

Achado real: corrida entre CALL_CREATED e CALL_ANSWERED

persistCallEvent é chamado sem await no loop de eventos (fire-and- forget, pra não travar o processamento dos próximos eventos ESL) — cada chamada abre sua própria transação. Pra uma perna null/dummy (auto- atende quase instantaneamente, sem ring de verdade), CALL_CREATED e CALL_ANSWERED podem chegar tão perto um do outro que suas transações concorrentes commitam fora de ordem, e quem quer que "ganhe" a corrida do upsert (criar a linha) usava now() daquele momento como createdAt — resultando em answerAt aparentemente antes de createdAt. Corrigido passando createdAt: new Date(normalized.occurredAt) explicitamente no branch de criação do upsert, em vez de depender do default @default(now()) do schema (que reflete "quando a linha foi inserida", não "quando o evento realmente aconteceu").

Relatórios (apps/api/src/reports)

  • GET /reports/queues (secao 159): recebidas/atendidas/abandonadas/TME/ TMA/Service Level/Abandon Rate por fila, a partir de Call agrupado por queueId. Service Level usa um limiar configurável (slThresholdSeconds, default 20s — a especificação não fixa um valor).
  • GET /reports/agents (secao 158): tempo logado (AgentSession), tempo pausado (AgentPauseEvent, timestamps explícitos), tempo em cada outro estado (AgentStateEvent, pareando eventos consecutivos do mesmo agente — sem uma tabela de "duração por estado" pronta, calculado on-the-fly), chamadas atendidas + TMA (Call).
  • GET /reports/campaigns (secao 160): leads/attempts/answered/agent connected/busy/no answer/failed/callbacks/answer rate/contact rate/ abandon rate a partir de CallAttempt + Lead; TME/TMA via Call (join por attemptId) e CallAttempt.talkTimeSeconds. "Valor Telefonia"/"Valor IA" ficam null — dependem de rating de uso, fase Billing, ainda não existe.

GET /calls (secao 157) e disposição (secao 89)

Filtros: data (from/to), ramal, agente, fila, campanha, trunk, telefone (contains em callerNumber/calledNumber), hangup cause, disposição. Sempre escopado ao tenant do JWT — "platform admin poderá filtrar tenant" (secao 157) ainda não existe (não há console cross-tenant ainda).

PATCH /calls/:id/disposition: o próprio agente que atendeu marca a disposição (compara Call.agentId contra o Agent do usuário autenticado, nunca um agentId vindo do client); um supervisor (agents.manage) pode marcar em nome de outro agente.

Verificado ponta a ponta

Campanha com 5 leads, 1 agente sem SIP registrado de verdade (mesma
limitação de sempre neste laboratório — USER_NOT_REGISTERED):

3 leads deram ANSWERED (simulado) -> entraram na fila real:
  Call.queueId preenchido (via AGENT_OFFERED_CALL ou QUEUE_MEMBER_LEFT)
  Call.agentId preenchido num deles (via AGENT_OFFERED_CALL, fallback
    memberSessionUuid) — confirma a correção da correlação
  Call.hangupCause = USER_NOT_REGISTERED (via AGENT_BRIDGE_FAILED) até o
    hangup agendado sobrescrever com NORMAL_CLEARING no fim
  createdAt <= answerAt em todos (corrige a inversão da corrida)
  durationSeconds calculado corretamente (~60s, bateu com
    discard_abandoned_after)

GET /reports/queues -> received:3, abandoned:3, abandonRate:1
GET /reports/agents -> loggedInSeconds/availableSeconds corretos,
  callsAnswered:0 (nenhum bridge de verdade aconteceu)
GET /reports/campaigns -> leads:5, attempts:5, answered:3, busy:1,
  noAnswer:1, answerRate:0.6, abandonRate:1 — todos os números batendo
  com o que os logs do discador mostraram

POST /dispositions {"name":"Sem Interesse","code":"no_interest"}
PATCH /calls/:id/disposition -> disposição gravada (agente dono da
  chamada, ownership check correto)

`queue list` do FreeSWITCH mostra calls_abandoned=4 ao final,
confirmando o pipeline real por trás dos números agregados.

typecheck do workspace inteiro limpo.

O que falta

  • extensionId/sipCallId/callerNumber/calledNumber — não populados ainda (nenhum evento atual carrega esses dados de forma confiável; precisa de inspeção de headers adicionais do canal, ver docs/REALTIME.md "O que falta" pro mesmo tipo de lacuna em ramais).
  • direction só distingue OUTBOUND (tem campanha) de INTERNAL (resto) — detecção de INBOUND de verdade precisa inspecionar Call-Direction/ contexto do dialplan, não feito ainda.
  • call_legs — schema existe, mas nada escreve nele ainda (só faz sentido de verdade com transferência entre uuids, que também não existe).
  • Cross-tenant reports pra platform admin (secao 157) — sem console de plataforma ainda.
  • Billing (billableSeconds sem tarifação, "Valor Telefonia"/"Valor IA" sempre null) — fase própria, ainda não iniciada.