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

153 lines
7.6 KiB
Markdown

# 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.