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:
203
apps/api/src/reports/reports.controller.ts
Normal file
203
apps/api/src/reports/reports.controller.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { Controller, Get, Query, UseGuards } from "@nestjs/common";
|
||||
import { getPrismaClient, withTenantContext, type Prisma } from "@b2bcall/database";
|
||||
import type { AccessTokenClaims } from "@b2bcall/auth";
|
||||
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
|
||||
import { PermissionGuard } from "../common/guards/permission.guard";
|
||||
import { RequirePermission } from "../common/decorators/require-permission.decorator";
|
||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||
|
||||
function dateRange(from?: string, to?: string): { gte?: Date; lte?: Date } {
|
||||
const range: { gte?: Date; lte?: Date } = {};
|
||||
if (from) range.gte = new Date(from);
|
||||
if (to) range.lte = new Date(to);
|
||||
return range;
|
||||
}
|
||||
|
||||
function average(values: number[]): number | null {
|
||||
if (values.length === 0) return null;
|
||||
return values.reduce((sum, v) => sum + v, 0) / values.length;
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||
@Controller("reports")
|
||||
export class ReportsController {
|
||||
/** Relatório de filas (secao 159): recebidas/atendidas/abandonadas/TME/
|
||||
* TMA/Service Level/Abandon Rate, agrupado por Queue. Service Level usa
|
||||
* um limiar configurável via query (`slThresholdSeconds`, default 20s —
|
||||
* a especificação não define um valor fixo). */
|
||||
@RequirePermission("reports.view")
|
||||
@Get("queues")
|
||||
async queues(
|
||||
@CurrentUser() user: AccessTokenClaims,
|
||||
@Query("from") from?: string,
|
||||
@Query("to") to?: string,
|
||||
@Query("slThresholdSeconds") slThresholdSecondsRaw?: string,
|
||||
) {
|
||||
const prisma = getPrismaClient();
|
||||
const tenantId = user.tenantId!;
|
||||
const slThresholdSeconds = slThresholdSecondsRaw ? Number(slThresholdSecondsRaw) : 20;
|
||||
|
||||
const calls = await withTenantContext(prisma, tenantId, (tx) =>
|
||||
tx.call.findMany({
|
||||
where: { tenantId, queueId: { not: null }, createdAt: dateRange(from, to) },
|
||||
select: { queueId: true, queueEnterAt: true, agentAnswerAt: true, endAt: true, waitTime: true, talkTime: true },
|
||||
}),
|
||||
);
|
||||
|
||||
const byQueue = new Map<string, typeof calls>();
|
||||
for (const call of calls) {
|
||||
const list = byQueue.get(call.queueId!) ?? [];
|
||||
list.push(call);
|
||||
byQueue.set(call.queueId!, list);
|
||||
}
|
||||
|
||||
return Array.from(byQueue.entries()).map(([queueId, rows]) => {
|
||||
const answered = rows.filter((r) => r.agentAnswerAt);
|
||||
const abandoned = rows.filter((r) => !r.agentAnswerAt && r.endAt);
|
||||
const waitTimes = answered.map((r) => r.waitTime).filter((v): v is number => v != null);
|
||||
const talkTimes = answered.map((r) => r.talkTime).filter((v): v is number => v != null);
|
||||
const withinSl = waitTimes.filter((w) => w <= slThresholdSeconds).length;
|
||||
|
||||
return {
|
||||
queueId,
|
||||
received: rows.length,
|
||||
answered: answered.length,
|
||||
abandoned: abandoned.length,
|
||||
tme: average(waitTimes),
|
||||
tma: average(talkTimes),
|
||||
serviceLevel: waitTimes.length > 0 ? withinSl / waitTimes.length : null,
|
||||
abandonRate: rows.length > 0 ? abandoned.length / rows.length : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Relatório de agentes (secao 158): tempo em cada estado (a partir de
|
||||
* agent_state_events, pareando eventos consecutivos), chamadas atendidas
|
||||
* e TMA (a partir de calls). */
|
||||
@RequirePermission("reports.view")
|
||||
@Get("agents")
|
||||
async agents(@CurrentUser() user: AccessTokenClaims, @Query("from") from?: string, @Query("to") to?: string) {
|
||||
const prisma = getPrismaClient();
|
||||
const tenantId = user.tenantId!;
|
||||
const range = dateRange(from, to);
|
||||
const now = new Date();
|
||||
|
||||
const agentsList = await withTenantContext(prisma, tenantId, (tx) =>
|
||||
tx.agent.findMany({ where: { tenantId, deletedAt: null }, select: { id: true, name: true } }),
|
||||
);
|
||||
|
||||
const [stateEvents, sessions, pauseEvents, calls] = await withTenantContext(prisma, tenantId, (tx) =>
|
||||
Promise.all([
|
||||
tx.agentStateEvent.findMany({
|
||||
where: { tenantId, occurredAt: range },
|
||||
orderBy: [{ agentId: "asc" }, { occurredAt: "asc" }],
|
||||
}),
|
||||
tx.agentSession.findMany({ where: { tenantId, startedAt: range } }),
|
||||
tx.agentPauseEvent.findMany({ where: { tenantId, startedAt: range } }),
|
||||
tx.call.findMany({
|
||||
where: { tenantId, agentId: { not: null }, agentAnswerAt: { not: null }, createdAt: range },
|
||||
select: { agentId: true, talkTime: true },
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
const secondsBetween = (a: Date, b: Date) => Math.max(0, (b.getTime() - a.getTime()) / 1000);
|
||||
|
||||
return agentsList.map((agent) => {
|
||||
const events = stateEvents.filter((e) => e.agentId === agent.id);
|
||||
const stateSeconds: Record<string, number> = {};
|
||||
for (let i = 0; i < events.length; i++) {
|
||||
const end = events[i + 1]?.occurredAt ?? now;
|
||||
stateSeconds[events[i].state] = (stateSeconds[events[i].state] ?? 0) + secondsBetween(events[i].occurredAt, end);
|
||||
}
|
||||
|
||||
const loggedInSeconds = sessions
|
||||
.filter((s) => s.agentId === agent.id)
|
||||
.reduce((sum, s) => sum + secondsBetween(s.startedAt, s.endedAt ?? now), 0);
|
||||
const pausedSeconds = pauseEvents
|
||||
.filter((p) => p.agentId === agent.id)
|
||||
.reduce((sum, p) => sum + secondsBetween(p.startedAt, p.endedAt ?? now), 0);
|
||||
|
||||
const agentCalls = calls.filter((c) => c.agentId === agent.id);
|
||||
const talkTimes = agentCalls.map((c) => c.talkTime).filter((v): v is number => v != null);
|
||||
|
||||
return {
|
||||
agentId: agent.id,
|
||||
name: agent.name,
|
||||
loggedInSeconds,
|
||||
availableSeconds: stateSeconds.AVAILABLE ?? 0,
|
||||
reservedSeconds: stateSeconds.RESERVED ?? 0,
|
||||
ringingSeconds: stateSeconds.RINGING ?? 0,
|
||||
inCallSeconds: stateSeconds.IN_CALL ?? 0,
|
||||
wrapUpSeconds: stateSeconds.WRAP_UP ?? 0,
|
||||
pausedSeconds,
|
||||
callsAnswered: agentCalls.length,
|
||||
tma: average(talkTimes),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Relatório de campanha (secao 160). "Valor Telefonia"/"Valor IA" ficam
|
||||
* null — dependem da fase Billing, ainda não existe rating de uso. */
|
||||
@RequirePermission("reports.view")
|
||||
@Get("campaigns")
|
||||
async campaigns(@CurrentUser() user: AccessTokenClaims, @Query("campaignId") campaignId?: string) {
|
||||
const prisma = getPrismaClient();
|
||||
const tenantId = user.tenantId!;
|
||||
|
||||
const where: Prisma.CampaignWhereInput = { tenantId, deletedAt: null };
|
||||
if (campaignId) where.id = campaignId;
|
||||
|
||||
const campaignsList = await withTenantContext(prisma, tenantId, (tx) => tx.campaign.findMany({ where }));
|
||||
|
||||
return Promise.all(
|
||||
campaignsList.map(async (campaign) => {
|
||||
const [leadCount, attempts, callsForAttempts] = await withTenantContext(prisma, tenantId, (tx) =>
|
||||
Promise.all([
|
||||
tx.lead.count({ where: { tenantId, campaignId: campaign.id } }),
|
||||
tx.callAttempt.findMany({ where: { tenantId, campaignId: campaign.id } }),
|
||||
tx.call.findMany({
|
||||
where: { tenantId, campaignId: campaign.id, attemptId: { not: null } },
|
||||
select: { attemptId: true, waitTime: true },
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
const answered = attempts.filter((a) =>
|
||||
["ANSWERED", "QUEUEING", "AGENT_CONNECTED", "COMPLETED", "ABANDONED"].includes(a.status),
|
||||
);
|
||||
const agentConnected = attempts.filter((a) => a.status === "COMPLETED" || a.status === "AGENT_CONNECTED");
|
||||
const busy = attempts.filter((a) => a.status === "BUSY").length;
|
||||
const noAnswer = attempts.filter((a) => a.status === "NO_ANSWER").length;
|
||||
const failed = attempts.filter((a) => a.status === "FAILED").length;
|
||||
const abandoned = attempts.filter((a) => a.status === "ABANDONED").length;
|
||||
const callbacks = await withTenantContext(prisma, tenantId, (tx) =>
|
||||
tx.lead.count({ where: { tenantId, campaignId: campaign.id, status: "CALLBACK" } }),
|
||||
);
|
||||
|
||||
const talkTimes = attempts.map((a) => a.talkTimeSeconds).filter((v): v is number => v != null);
|
||||
const waitTimes = callsForAttempts.map((c) => c.waitTime).filter((v): v is number => v != null);
|
||||
|
||||
return {
|
||||
campaignId: campaign.id,
|
||||
name: campaign.name,
|
||||
leads: leadCount,
|
||||
attempts: attempts.length,
|
||||
answered: answered.length,
|
||||
agentConnected: agentConnected.length,
|
||||
busy,
|
||||
noAnswer,
|
||||
failed,
|
||||
callbacks,
|
||||
answerRate: attempts.length > 0 ? answered.length / attempts.length : null,
|
||||
contactRate: leadCount > 0 ? answered.length / leadCount : null,
|
||||
abandonRate: answered.length > 0 ? abandoned / answered.length : null,
|
||||
tme: average(waitTimes),
|
||||
tma: average(talkTimes),
|
||||
telefoniaValor: null,
|
||||
iaValor: null,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
7
apps/api/src/reports/reports.module.ts
Normal file
7
apps/api/src/reports/reports.module.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ReportsController } from "./reports.controller";
|
||||
|
||||
@Module({
|
||||
controllers: [ReportsController],
|
||||
})
|
||||
export class ReportsModule {}
|
||||
Reference in New Issue
Block a user