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
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 deCallagrupado porqueueId. 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 deCallAttempt+Lead; TME/TMA viaCall(join porattemptId) eCallAttempt.talkTimeSeconds. "Valor Telefonia"/"Valor IA" ficamnull— 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).directionsó distingue OUTBOUND (tem campanha) de INTERNAL (resto) — detecção de INBOUND de verdade precisa inspecionarCall-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 (
billableSecondssem tarifação, "Valor Telefonia"/"Valor IA" sempre null) — fase própria, ainda não iniciada.