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