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
This commit is contained in:
152
docs/CDR.md
Normal file
152
docs/CDR.md
Normal file
@@ -0,0 +1,152 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user