Fase 7: CDR/métricas/relatórios, reconciliação, dashboard e compliance

- Reconciliação de tentativas órfãs após restart (reconciliation.ts),
  rodando a cada 60s.
- Vínculo real agente<->chamada (agent-call-binding.ts): claim atômico de
  agente disponível via FOR UPDATE SKIP LOCKED, DialAttempt.agentId
  populado no connect, ciclo AVAILABLE -> IN_CALL -> WRAP_UP -> AVAILABLE.
- Corrige abandonRate (EWMA) nunca atualizado pelo campaign-worker real —
  agora o fluxo QUEUED -> connect-or-abandon atualiza as estatísticas de
  fato usadas pelo predictive engine.
- Novo módulo de relatórios: /api/reports/calls (+export CSV), /metrics
  (TME/TMA/abandono), /agents/:id.
- Novo módulo de dashboard: /api/dashboard, /calls-by-hour,
  /campaigns/:id (Postgres + snapshot EWMA do Redis).
- Novo módulo de compliance: ComplianceSettings configurável +
  /api/compliance/settings e /indicators com contadores reais.
- Validado end-to-end contra containers reais (campanha de teste em modo
  simulação): agentId no connect, ciclo de estado do agente e abandonRate
  todos confirmados corrigidos com dados reais, não só no harness isolado.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QoVkLx1KsvtT1C88dRS3QW
This commit is contained in:
2026-08-27 16:09:22 -03:00
parent 66cc2058fd
commit 0a8b830e2c
22 changed files with 1072 additions and 34 deletions

View File

@@ -27,6 +27,9 @@ import { SuppressionModule } from './suppression/suppression.module';
import { DispositionsModule } from './dispositions/dispositions.module';
import { CampaignsModule } from './campaigns/campaigns.module';
import { LeadsModule } from './leads/leads.module';
import { ReportsModule } from './reports/reports.module';
import { DashboardModule } from './dashboard/dashboard.module';
import { ComplianceModule } from './compliance/compliance.module';
import { AuthGuard } from './common/guards/auth.guard';
import { PermissionsGuard } from './common/guards/permissions.guard';
import { GlobalExceptionFilter } from './common/filters/global-exception.filter';
@@ -80,6 +83,9 @@ import { GlobalExceptionFilter } from './common/filters/global-exception.filter'
DispositionsModule,
CampaignsModule,
LeadsModule,
ReportsModule,
DashboardModule,
ComplianceModule,
],
controllers: [AppController],
providers: [

View File

@@ -0,0 +1,37 @@
import { Body, Controller, Get, Patch, Req } from '@nestjs/common';
import type { FastifyRequest } from 'fastify';
import { RequirePermissions } from '../common/decorators/permissions.decorator';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../common/guards/auth.guard';
import { ComplianceService } from './compliance.service';
import { UpdateComplianceSettingsDto } from './dto/update-compliance-settings.dto';
@Controller('compliance')
export class ComplianceController {
constructor(private readonly complianceService: ComplianceService) {}
@Get('settings')
@RequirePermissions('settings.manage')
getSettings() {
return this.complianceService.getSettings();
}
@Patch('settings')
@RequirePermissions('settings.manage')
updateSettings(
@Body() dto: UpdateComplianceSettingsDto,
@CurrentUser() actor: AuthenticatedUser,
@Req() request: FastifyRequest,
) {
return this.complianceService.updateSettings(dto, actor, {
ip: request.ip,
userAgent: request.headers['user-agent'],
});
}
@Get('indicators')
@RequirePermissions('reports.view')
indicators() {
return this.complianceService.indicators();
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { ComplianceController } from './compliance.controller';
import { ComplianceService } from './compliance.service';
@Module({
controllers: [ComplianceController],
providers: [ComplianceService],
})
export class ComplianceModule {}

View File

@@ -0,0 +1,148 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
import type { RequestContext } from '../auth/auth.service';
import { UpdateComplianceSettingsDto } from './dto/update-compliance-settings.dto';
function startOfToday(): Date {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d;
}
function startOfMonth(): Date {
const d = new Date();
d.setDate(1);
d.setHours(0, 0, 0, 0);
return d;
}
// O sistema AJUDA a cumprir regras — nunca implementa nada para burlar
// antispam/autenticação/identificação de origem das operadoras (agente.md
// seção 34). Parâmetros configuráveis, não hardcoded para uma legislação.
@Injectable()
export class ComplianceService {
constructor(
private readonly prisma: PrismaService,
private readonly audit: AuditService,
) {}
async getSettings() {
const existing = await this.prisma.complianceSettings.findFirst();
if (existing) return existing;
return this.prisma.complianceSettings.create({ data: {} });
}
async updateSettings(
dto: UpdateComplianceSettingsDto,
actor: { id: string },
ctx: RequestContext,
) {
const current = await this.getSettings();
const updated = await this.prisma.complianceSettings.update({
where: { id: current.id },
data: dto,
});
await this.audit.log({
userId: actor.id,
action: 'compliance_settings_updated',
entityType: 'compliance_settings',
entityId: current.id,
before: current,
after: { ...dto },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
});
return updated;
}
async indicators() {
const settings = await this.getSettings();
const todayStart = startOfToday();
const monthStart = startOfMonth();
const [
totalCallsToday,
answeredCallsToday,
abandonedCallsToday,
totalCallsMonth,
completedAttempts,
] = await Promise.all([
this.prisma.dialAttempt.count({
where: { startedAt: { gte: todayStart } },
}),
this.prisma.dialAttempt.count({
where: { startedAt: { gte: todayStart }, state: 'COMPLETED' },
}),
this.prisma.dialAttempt.count({
where: { startedAt: { gte: todayStart }, hangupCause: 'ABANDONED' },
}),
this.prisma.dialAttempt.count({
where: { startedAt: { gte: monthStart } },
}),
this.prisma.dialAttempt.findMany({
where: {
startedAt: { gte: todayStart },
state: 'COMPLETED',
agentConnectedAt: { not: null },
},
select: { agentConnectedAt: true, endedAt: true },
}),
]);
const shortCallsToday = completedAttempts.filter(
(a) =>
a.endedAt &&
(a.endedAt.getTime() - a.agentConnectedAt!.getTime()) / 1000 <
settings.shortCallThresholdSeconds,
).length;
const callsPerNumberToday = await this.prisma.dialAttempt.groupBy({
by: ['calledNumber'],
where: { startedAt: { gte: todayStart } },
_count: { calledNumber: true },
orderBy: { _count: { calledNumber: 'desc' } },
take: 20,
});
const numbersOverDailyLimit = callsPerNumberToday.filter(
(c) => c._count.calledNumber > settings.maxAttemptsPerNumberPerDay,
);
const alerts: { level: 'warning' | 'critical'; message: string }[] = [];
if (numbersOverDailyLimit.length > 0) {
alerts.push({
level: 'warning',
message: `${numbersOverDailyLimit.length} número(s) excederam o limite de ${settings.maxAttemptsPerNumberPerDay} tentativas/dia.`,
});
}
if (totalCallsMonth > settings.highVolumeMonthlyThreshold) {
alerts.push({
level: 'critical',
message: `Volume mensal de chamadas (${totalCallsMonth}) ultrapassou o limiar configurado (${settings.highVolumeMonthlyThreshold}) — considere mecanismos adicionais de autenticação de chamadas junto à rede.`,
});
}
if (shortCallsToday / Math.max(1, answeredCallsToday) > 0.3) {
alerts.push({
level: 'warning',
message:
'Mais de 30% das chamadas atendidas hoje foram curtas — relevante para fiscalização de telemarketing.',
});
}
return {
settings,
totalCallsToday,
answeredCallsToday,
shortCallsToday,
abandonedCallsToday,
totalCallsMonth,
numbersOverDailyLimit: numbersOverDailyLimit.map((n) => ({
phone: n.calledNumber,
attempts: n._count.calledNumber,
})),
alerts,
};
}
}

View File

@@ -0,0 +1,23 @@
import { IsInt, IsOptional, Min } from 'class-validator';
export class UpdateComplianceSettingsDto {
@IsOptional()
@IsInt()
@Min(0)
shortCallThresholdSeconds?: number;
@IsOptional()
@IsInt()
@Min(1)
maxAttemptsPerNumberPerDay?: number;
@IsOptional()
@IsInt()
@Min(1)
maxAttemptsPerNumberPerMonth?: number;
@IsOptional()
@IsInt()
@Min(1)
highVolumeMonthlyThreshold?: number;
}

View File

@@ -0,0 +1,26 @@
import { Controller, Get, Param, ParseUUIDPipe } from '@nestjs/common';
import { RequirePermissions } from '../common/decorators/permissions.decorator';
import { DashboardService } from './dashboard.service';
@Controller('dashboard')
export class DashboardController {
constructor(private readonly dashboardService: DashboardService) {}
@Get()
@RequirePermissions('dashboard.view')
overview() {
return this.dashboardService.overview();
}
@Get('calls-by-hour')
@RequirePermissions('dashboard.view')
callsByHour() {
return this.dashboardService.callsByHourToday();
}
@Get('campaigns/:id')
@RequirePermissions('campaigns.view')
campaignDashboard(@Param('id', ParseUUIDPipe) id: string) {
return this.dashboardService.campaignDashboard(id);
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { DashboardController } from './dashboard.controller';
import { DashboardService } from './dashboard.service';
@Module({
controllers: [DashboardController],
providers: [DashboardService],
})
export class DashboardModule {}

View File

@@ -0,0 +1,203 @@
import { Inject, Injectable } from '@nestjs/common';
import type Redis from 'ioredis';
import { PrismaService } from '../prisma/prisma.service';
import { REDIS_CLIENT } from '../redis/redis.module';
interface CampaignStatsSnapshot {
pacingFactor: number;
answerProbability: number;
abandonRate: number;
avgTalkTimeSeconds: number;
}
function startOfToday(): Date {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d;
}
// Nunca gera número inventado (agente.md seção 72) — cada card aqui é uma
// contagem/agregação real sobre dial_attempts e agent_state_events.
@Injectable()
export class DashboardService {
constructor(
private readonly prisma: PrismaService,
@Inject(REDIS_CLIENT) private readonly redis: Redis,
) {}
async overview() {
const since = startOfToday();
const [
callsToday,
answeredToday,
inProgress,
waitingForAgent,
connectedAttempts,
abandonedToday,
] = await Promise.all([
this.prisma.dialAttempt.count({ where: { startedAt: { gte: since } } }),
this.prisma.dialAttempt.count({
where: { startedAt: { gte: since }, state: 'COMPLETED' },
}),
this.prisma.dialAttempt.count({
where: { state: { in: ['ORIGINATING', 'RINGING'] } },
}),
this.prisma.dialAttempt.count({ where: { state: 'QUEUED' } }),
this.prisma.dialAttempt.findMany({
where: {
startedAt: { gte: since },
agentConnectedAt: { not: null },
queuedAt: { not: null },
},
select: { queuedAt: true, agentConnectedAt: true, endedAt: true },
}),
this.prisma.dialAttempt.count({
where: { startedAt: { gte: since }, hangupCause: 'ABANDONED' },
}),
]);
const stateEvents = await this.prisma.agentStateEvent.findMany({
where: { endedAt: null },
select: { state: true },
});
const agentsByState = stateEvents.reduce<Record<string, number>>(
(acc, e) => {
acc[e.state] = (acc[e.state] ?? 0) + 1;
return acc;
},
{},
);
const waitTimes = connectedAttempts.map(
(a) => (a.agentConnectedAt!.getTime() - a.queuedAt!.getTime()) / 1000,
);
const talkTimes = connectedAttempts
.filter((a) => a.endedAt)
.map(
(a) => (a.endedAt!.getTime() - a.agentConnectedAt!.getTime()) / 1000,
);
return {
callsToday,
answeredToday,
inProgress,
waitingForAgent,
agentsAvailable: agentsByState.AVAILABLE ?? 0,
agentsBusy: (agentsByState.IN_CALL ?? 0) + (agentsByState.RINGING ?? 0),
agentsPaused: agentsByState.PAUSED ?? 0,
tmeSeconds: average(waitTimes),
tmaSeconds: average(talkTimes),
answerRate: callsToday > 0 ? answeredToday / callsToday : 0,
abandonRate:
answeredToday + abandonedToday > 0
? abandonedToday / (answeredToday + abandonedToday)
: 0,
};
}
// Chamadas por hora hoje (agente.md seção 46) — agregação real via SQL,
// não inventada no frontend.
async callsByHourToday() {
const since = startOfToday();
const rows = await this.prisma.$queryRaw<
{ hour: number; total: bigint; answered: bigint }[]
>`
SELECT
EXTRACT(HOUR FROM started_at)::int AS hour,
COUNT(*)::bigint AS total,
COUNT(*) FILTER (WHERE state = 'COMPLETED')::bigint AS answered
FROM dial_attempts
WHERE started_at >= ${since}
GROUP BY hour
ORDER BY hour
`;
return rows.map((r) => ({
hour: r.hour,
total: Number(r.total),
answered: Number(r.answered),
}));
}
// Painel por campanha (agente.md seção 47) — combina Postgres (contagens
// reais) com o Redis (pacing/EWMA calculados pelo dialer-worker).
async campaignDashboard(campaignId: string) {
const campaign = await this.prisma.campaign.findUniqueOrThrow({
where: { id: campaignId },
});
const [
dialing,
ringing,
answered,
queued,
connected,
leadsRemaining,
leadsProcessed,
] = await Promise.all([
this.prisma.dialAttempt.count({
where: { campaignId, state: 'ORIGINATING' },
}),
this.prisma.dialAttempt.count({
where: { campaignId, state: 'RINGING' },
}),
this.prisma.dialAttempt.count({
where: { campaignId, state: 'ANSWERED' },
}),
this.prisma.dialAttempt.count({ where: { campaignId, state: 'QUEUED' } }),
this.prisma.dialAttempt.count({
where: { campaignId, state: 'AGENT_CONNECTED' },
}),
this.prisma.lead.count({
where: {
campaignId,
status: {
in: ['NEW', 'READY', 'RESERVED', 'BUSY', 'NO_ANSWER', 'FAILED'],
},
},
}),
this.prisma.lead.count({
where: {
campaignId,
status: {
in: ['COMPLETED', 'MAX_ATTEMPTS', 'DO_NOT_CALL', 'INVALID'],
},
},
}),
]);
const statsRaw = await this.redis.get(`dialer:stats:${campaignId}`);
const stats: CampaignStatsSnapshot | null = statsRaw
? (JSON.parse(statsRaw) as CampaignStatsSnapshot)
: null;
// CPS atual: tentativas originadas no último segundo (aproximação —
// o token bucket exato vive só em memória transitória do Redis).
const oneSecondAgo = new Date(Date.now() - 1000);
const cpsAtual = await this.prisma.dialAttempt.count({
where: { campaignId, startedAt: { gte: oneSecondAgo } },
});
return {
status: campaign.status,
maxCps: campaign.maxCps,
cpsAtual,
dialing,
ringing,
answered,
queued,
connected,
leadsRemaining,
leadsProcessed,
pacingFactor: stats?.pacingFactor ?? null,
answerProbability: stats?.answerProbability ?? null,
abandonRate: stats?.abandonRate ?? null,
avgTalkTimeSeconds: stats?.avgTalkTimeSeconds ?? null,
};
}
}
function average(values: number[]): number {
if (values.length === 0) return 0;
return values.reduce((sum, v) => sum + v, 0) / values.length;
}

View File

@@ -0,0 +1,53 @@
import { Type } from 'class-transformer';
import {
IsDateString,
IsInt,
IsOptional,
IsString,
IsUUID,
Max,
Min,
} from 'class-validator';
export class QueryCallsReportDto {
@IsOptional()
@IsDateString()
from?: string;
@IsOptional()
@IsDateString()
to?: string;
@IsOptional()
@IsUUID('4')
campaignId?: string;
@IsOptional()
@IsUUID('4')
agentId?: string;
@IsOptional()
@IsUUID('4')
dispositionId?: string;
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@IsString()
state?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page: number = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(500)
pageSize: number = 50;
}

View File

@@ -0,0 +1,55 @@
import {
Controller,
Get,
Header,
Param,
ParseUUIDPipe,
Query,
Res,
} from '@nestjs/common';
import type { FastifyReply } from 'fastify';
import { RequirePermissions } from '../common/decorators/permissions.decorator';
import { ReportsService } from './reports.service';
import { QueryCallsReportDto } from './dto/query-calls-report.dto';
@Controller('reports')
export class ReportsController {
constructor(private readonly reportsService: ReportsService) {}
@Get('calls')
@RequirePermissions('reports.view')
queryCalls(@Query() query: QueryCallsReportDto) {
return this.reportsService.queryCalls(query);
}
@Get('calls/export')
@RequirePermissions('reports.export')
@Header('Content-Type', 'text/csv; charset=utf-8')
async exportCalls(
@Query() query: QueryCallsReportDto,
@Res({ passthrough: true }) reply: FastifyReply,
) {
const csv = await this.reportsService.exportCallsCsv(query);
reply.header(
'Content-Disposition',
'attachment; filename="relatorio-chamadas.csv"',
);
return csv;
}
@Get('metrics')
@RequirePermissions('reports.view')
metrics(@Query() query: QueryCallsReportDto) {
return this.reportsService.calculateMetrics(query);
}
@Get('agents/:agentId')
@RequirePermissions('reports.view')
agentReport(
@Param('agentId', ParseUUIDPipe) agentId: string,
@Query('from') from?: string,
@Query('to') to?: string,
) {
return this.reportsService.agentReport(agentId, from, to);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { ReportsController } from './reports.controller';
import { ReportsService } from './reports.service';
@Module({
controllers: [ReportsController],
providers: [ReportsService],
exports: [ReportsService],
})
export class ReportsModule {}

View File

@@ -0,0 +1,204 @@
import { Injectable } from '@nestjs/common';
import { stringify } from 'csv-stringify/sync';
import { Prisma, CallState } from '@b2bcall/database';
import { PrismaService } from '../prisma/prisma.service';
import { QueryCallsReportDto } from './dto/query-calls-report.dto';
function buildCallsWhere(
query: QueryCallsReportDto,
): Prisma.DialAttemptWhereInput {
return {
campaignId: query.campaignId,
agentId: query.agentId,
dispositionId: query.dispositionId,
state: query.state as CallState | undefined,
calledNumber: query.phone ? { contains: query.phone } : undefined,
startedAt: {
gte: query.from ? new Date(query.from) : undefined,
lte: query.to ? new Date(query.to) : undefined,
},
};
}
@Injectable()
export class ReportsService {
constructor(private readonly prisma: PrismaService) {}
// Paginação sempre server-side (agente.md seção 43) — dial_attempts
// cresce sem limite com o volume de discagem.
async queryCalls(query: QueryCallsReportDto) {
const where = buildCallsWhere(query);
const [total, items] = await this.prisma.$transaction([
this.prisma.dialAttempt.count({ where }),
this.prisma.dialAttempt.findMany({
where,
orderBy: { startedAt: 'desc' },
skip: (query.page - 1) * query.pageSize,
take: query.pageSize,
include: {
lead: { select: { name: true, phone: true } },
campaign: { select: { name: true } },
disposition: { select: { name: true } },
},
}),
]);
return { items, total, page: query.page, pageSize: query.pageSize };
}
async exportCallsCsv(query: QueryCallsReportDto): Promise<string> {
const where = buildCallsWhere(query);
const items = await this.prisma.dialAttempt.findMany({
where,
orderBy: { startedAt: 'desc' },
take: 100_000, // teto de segurança — nunca um export sem limite algum
include: {
lead: { select: { name: true, phone: true } },
campaign: { select: { name: true } },
disposition: { select: { name: true } },
},
});
const rows = items.map((item) => ({
data: item.startedAt.toISOString(),
origem: item.callerIdUsed ?? '',
destino: item.calledNumber,
campanha: item.campaign.name,
lead: item.lead.name ?? '',
espera_segundos:
item.agentConnectedAt && item.queuedAt
? Math.round(
(item.agentConnectedAt.getTime() - item.queuedAt.getTime()) /
1000,
)
: '',
conversacao_segundos:
item.endedAt && item.agentConnectedAt
? Math.round(
(item.endedAt.getTime() - item.agentConnectedAt.getTime()) / 1000,
)
: '',
resultado: item.hangupCause ?? item.state,
disposicao: item.disposition?.name ?? '',
}));
return stringify(rows, { header: true });
}
// Definições (agente.md seções 44/45) — nunca misturadas silenciosamente:
// TME = tempo até conectar a um agente (só chamadas conectadas).
// Tempo médio de abandono = tempo até desistir (só chamadas abandonadas).
// TMA = tempo total de conversação / chamadas atendidas.
async calculateMetrics(
query: Pick<QueryCallsReportDto, 'campaignId' | 'from' | 'to'>,
) {
const where: Prisma.DialAttemptWhereInput = {
campaignId: query.campaignId,
startedAt: {
gte: query.from ? new Date(query.from) : undefined,
lte: query.to ? new Date(query.to) : undefined,
},
};
const connected = await this.prisma.dialAttempt.findMany({
where: {
...where,
agentConnectedAt: { not: null },
queuedAt: { not: null },
},
select: { queuedAt: true, agentConnectedAt: true, endedAt: true },
});
const abandoned = await this.prisma.dialAttempt.findMany({
where: { ...where, hangupCause: 'ABANDONED', queuedAt: { not: null } },
select: { queuedAt: true, endedAt: true },
});
const tmeSeconds = average(
connected.map((c) => diffSeconds(c.queuedAt!, c.agentConnectedAt)),
);
const avgAbandonWaitSeconds = average(
abandoned.map((a) => diffSeconds(a.queuedAt!, a.endedAt)),
);
const talkTimes = connected
.filter((c) => c.endedAt)
.map((c) => diffSeconds(c.agentConnectedAt!, c.endedAt));
const tmaSeconds = average(talkTimes);
const totalAttempts = await this.prisma.dialAttempt.count({ where });
const answeredCount = connected.length;
const abandonedCount = abandoned.length;
return {
tmeSeconds,
avgAbandonWaitSeconds,
tmaSeconds,
totalAttempts,
answeredCount,
abandonedCount,
answerRate: totalAttempts > 0 ? answeredCount / totalAttempts : 0,
abandonRate:
answeredCount + abandonedCount > 0
? abandonedCount / (answeredCount + abandonedCount)
: 0,
};
}
// Relatório de Agentes (agente.md seção 49): tempo em cada estado a
// partir do histórico real (agent_state_events/agent_pause_events),
// nunca inferido de uma única variável do Asterisk (seção 48).
async agentReport(agentId: string, from?: string, to?: string) {
const dateFilter = {
gte: from ? new Date(from) : undefined,
lte: to ? new Date(to) : undefined,
};
const stateEvents = await this.prisma.agentStateEvent.findMany({
where: { agentId, startedAt: dateFilter },
});
const pauseEvents = await this.prisma.agentPauseEvent.findMany({
where: { agentId, startedAt: dateFilter },
include: { pauseReason: { select: { name: true } } },
});
const answeredAttempts = await this.prisma.dialAttempt.findMany({
where: {
agentId,
state: 'COMPLETED',
agentConnectedAt: { not: null },
startedAt: dateFilter,
},
select: { agentConnectedAt: true, endedAt: true },
});
const secondsByState: Record<string, number> = {};
for (const event of stateEvents) {
const end = event.endedAt ?? new Date();
secondsByState[event.state] =
(secondsByState[event.state] ?? 0) + diffSeconds(event.startedAt, end);
}
const talkTimes = answeredAttempts.map((a) =>
diffSeconds(a.agentConnectedAt!, a.endedAt),
);
return {
timeByStateSeconds: secondsByState,
callsAnswered: answeredAttempts.length,
tmaSeconds: average(talkTimes),
pauses: pauseEvents.map((p) => ({
reason: p.pauseReason.name,
startedAt: p.startedAt,
endedAt: p.endedAt,
durationSeconds: diffSeconds(p.startedAt, p.endedAt ?? new Date()),
})),
};
}
}
function diffSeconds(a: Date, b: Date | null): number {
if (!b) return 0;
return (b.getTime() - a.getTime()) / 1000;
}
function average(values: number[]): number {
if (values.length === 0) return 0;
return values.reduce((sum, v) => sum + v, 0) / values.length;
}

View File

@@ -0,0 +1,59 @@
import { PrismaClient } from '@b2bcall/database';
/**
* Vincula uma chamada conectada a um agente específico e movimenta a
* máquina de estados do agente (agente.md seção 48) em torno da chamada —
* sem isso, DialAttempt.agentId nunca é preenchido (relatório de agentes
* fica sem dado) e o agente "reivindicado" ficaria travado em IN_CALL para
* sempre, nunca voltando a contar como disponível para o motor preditivo.
*/
export async function claimAvailableAgent(prisma: PrismaClient, queueId: string): Promise<string | null> {
const members = await prisma.queueMember.findMany({ where: { queueId }, select: { agentId: true } });
const agentIds = members.map((m) => m.agentId);
if (agentIds.length === 0) return null;
// Atômico via FOR UPDATE SKIP LOCKED (mesmo padrão de reserva de leads —
// agente.md seção 35): duas campanhas que compartilham o mesmo agente
// nunca reivindicam o mesmo AVAILABLE simultaneamente.
const rows = await prisma.$queryRaw<{ agentId: string }[]>`
UPDATE agent_state_events
SET ended_at = now()
WHERE id = (
SELECT id FROM agent_state_events
WHERE agent_id = ANY(${agentIds}) AND state = 'AVAILABLE' AND ended_at IS NULL
ORDER BY started_at ASC
FOR UPDATE SKIP LOCKED
LIMIT 1
)
RETURNING agent_id AS "agentId"
`;
const claimedAgentId = rows[0]?.agentId;
if (!claimedAgentId) return null;
await prisma.agentStateEvent.create({ data: { agentId: claimedAgentId, state: 'IN_CALL' } });
return claimedAgentId;
}
export async function releaseAgentAfterCall(prisma: PrismaClient, agentId: string, wrapUpTimeSeconds: number): Promise<void> {
await prisma.agentStateEvent.updateMany({
where: { agentId, state: 'IN_CALL', endedAt: null },
data: { endedAt: new Date() },
});
if (wrapUpTimeSeconds <= 0) {
await prisma.agentStateEvent.create({ data: { agentId, state: 'AVAILABLE' } });
return;
}
await prisma.agentStateEvent.create({ data: { agentId, state: 'WRAP_UP' } });
setTimeout(() => {
void (async () => {
await prisma.agentStateEvent.updateMany({
where: { agentId, state: 'WRAP_UP', endedAt: null },
data: { endedAt: new Date() },
});
await prisma.agentStateEvent.create({ data: { agentId, state: 'AVAILABLE' } });
})();
}, wrapUpTimeSeconds * 1000);
}

View File

@@ -12,6 +12,7 @@ import { getLiveCounts } from './live-counts';
import { adjustPacingFactor, calculateCallsToOriginate, updateEwma, type PacingLimits } from './predictive-engine';
import { calculateNextAttemptAt, leadStatusForOutcome, type HangupOutcome } from './retry-rules';
import { DEFAULT_SIMULATION_PROFILE, randomInRange, simulateHangupCause } from './simulation';
import { claimAvailableAgent, releaseAgentAfterCall } from './agent-call-binding';
import { logger } from './logger';
const WORKER_ID = randomUUID();
@@ -109,7 +110,17 @@ export class CampaignWorker {
}
private async originateAttempt(
campaign: { id: string; callerId: string | null; context: string; ringTimeoutSeconds: number; retryRules: unknown; maxAttempts: number },
campaign: {
id: string;
queueId: string;
callerId: string | null;
context: string;
ringTimeoutSeconds: number;
maxWaitForAgentSeconds: number;
wrapUpTimeSeconds: number;
retryRules: unknown;
maxAttempts: number;
},
trunkName: string,
lead: { id: string; phone: string; phoneNormalized: string; attemptCount: number },
): Promise<void> {
@@ -166,7 +177,14 @@ export class CampaignWorker {
}
private simulateAttempt(
campaign: { id: string; retryRules: unknown; maxAttempts: number },
campaign: {
id: string;
queueId: string;
maxWaitForAgentSeconds: number;
wrapUpTimeSeconds: number;
retryRules: unknown;
maxAttempts: number;
},
lead: { id: string; attemptCount: number },
attemptId: string,
): void {
@@ -194,23 +212,71 @@ export class CampaignWorker {
setTimeout(() => {
void (async () => {
await this.prisma.dialAttempt.update({ where: { id: attemptId }, data: { state: 'ANSWERED', answeredAt: new Date() } });
await this.updateAnswerStats(campaign.id, answerDelayMs / 1000);
const talkTimeSeconds = randomInRange(profile.talkTimeSecondsRange);
await this.prisma.dialAttempt.update({ where: { id: attemptId }, data: { state: 'AGENT_CONNECTED', agentConnectedAt: new Date() } });
setTimeout(() => {
void this.finalizeAttempt(attemptId, campaign.id, lead.id, 'ANSWERED', {}, campaign.maxAttempts, lead.attemptCount + 1, talkTimeSeconds);
}, talkTimeSeconds * 1000);
// Atendida != conectada a um agente. Se ninguém está disponível
// agora, espera até maxWaitForAgentSeconds antes de considerar
// abandono (agente.md seções 32/33) — sem isso, abandonRate nunca
// reflete a realidade e o controle de abandono do motor preditivo
// fica sem efeito prático (bug corrigido nesta fase).
await this.prisma.dialAttempt.update({ where: { id: attemptId }, data: { state: 'QUEUED', queuedAt: new Date() } });
await this.tryConnectOrAbandon(campaign, lead, attemptId, profile, answerDelayMs / 1000);
})();
}, answerDelayMs);
}
private async updateAnswerStats(campaignId: string, answerDelaySeconds: number): Promise<void> {
private async tryConnectOrAbandon(
campaign: { id: string; queueId: string; maxWaitForAgentSeconds: number; wrapUpTimeSeconds: number; maxAttempts: number },
lead: { id: string; attemptCount: number },
attemptId: string,
profile: (typeof DEFAULT_SIMULATION_PROFILE),
answerDelaySeconds: number,
): Promise<void> {
const connect = async (agentId: string) => {
await this.updateAnswerStats(campaign.id, answerDelaySeconds, false);
const talkTimeSeconds = randomInRange(profile.talkTimeSecondsRange);
await this.prisma.dialAttempt.update({
where: { id: attemptId },
data: { state: 'AGENT_CONNECTED', agentConnectedAt: new Date(), agentId },
});
setTimeout(() => {
void (async () => {
await this.finalizeAttempt(attemptId, campaign.id, lead.id, 'ANSWERED', {}, campaign.maxAttempts, lead.attemptCount + 1, talkTimeSeconds);
await releaseAgentAfterCall(this.prisma, agentId, campaign.wrapUpTimeSeconds);
})();
}, talkTimeSeconds * 1000);
};
const agentId = await claimAvailableAgent(this.prisma, campaign.queueId);
if (agentId) {
await connect(agentId);
return;
}
// Nenhum agente livre no instante do atendimento — espera até o limite
// configurado e reavalia uma vez (poll único, suficiente para o
// propósito de simulação sem complicar com um loop de polling real).
setTimeout(() => {
void (async () => {
const retryAgentId = await claimAvailableAgent(this.prisma, campaign.queueId);
if (retryAgentId) {
await connect(retryAgentId);
return;
}
await this.updateAnswerStats(campaign.id, answerDelaySeconds, true);
await this.finalizeAttempt(attemptId, campaign.id, lead.id, 'ABANDONED' as HangupOutcome, {}, campaign.maxAttempts, lead.attemptCount + 1);
})();
}, campaign.maxWaitForAgentSeconds * 1000);
}
// `abandoned` alimenta o EWMA de abandonRate — é este sinal que
// `adjustPacingFactor` usa para reduzir o pacing (agente.md seção 33).
private async updateAnswerStats(campaignId: string, answerDelaySeconds: number, abandoned: boolean): Promise<void> {
const campaign = await this.prisma.campaign.findUnique({ where: { id: campaignId } });
if (!campaign) return;
const stats = await this.statsStore.load(campaignId, campaign.pacingInitial);
stats.answerProbability = updateEwma(stats.answerProbability, 1);
stats.avgAnswerDelaySeconds = updateEwma(stats.avgAnswerDelaySeconds, answerDelaySeconds);
stats.abandonRate = updateEwma(stats.abandonRate, abandoned ? 1 : 0, 0.05);
await this.statsStore.save(campaignId, stats);
}

View File

@@ -2,9 +2,11 @@ import { PrismaClient, CampaignStatus } from '@b2bcall/database';
import Redis from 'ioredis';
import { AsteriskTelephonyProvider } from '@b2bcall/telephony';
import { CampaignWorker } from './campaign-worker';
import { reconcileOrphanedAttempts } from './reconciliation';
import { logger } from './logger';
const TICK_INTERVAL_MS = 2000;
const RECONCILE_INTERVAL_MS = 60_000;
const DIALER_SIMULATION = process.env.DIALER_SIMULATION === 'true';
async function main() {
@@ -59,12 +61,17 @@ async function main() {
logger.info({ tickIntervalMs: TICK_INTERVAL_MS, simulation: DIALER_SIMULATION }, 'dialer-worker iniciado');
const interval = setInterval(() => void tickAllRunningCampaigns(), TICK_INTERVAL_MS);
const reconcileInterval = setInterval(
() => void reconcileOrphanedAttempts(prisma).catch((err) => logger.error({ err }, 'Erro na reconciliação')),
RECONCILE_INTERVAL_MS,
);
const shutdown = async () => {
if (!running) return;
running = false;
logger.info('Encerrando dialer-worker...');
clearInterval(interval);
clearInterval(reconcileInterval);
telephony.disconnect();
await redis.quit();
await prisma.$disconnect();

View File

@@ -0,0 +1,40 @@
import { PrismaClient } from '@b2bcall/database';
import { logger } from './logger';
// Reconciliação de estados órfãos (agente.md seção 51): tentativas que
// ficaram presas em estados transitórios (ORIGINATING/RINGING/ANSWERED/
// QUEUED/AGENT_CONNECTED) por tempo maior do que qualquer timeout normal
// explica — geralmente por queda do worker no meio de uma chamada. Nunca
// deixa uma tentativa presa para sempre, o que travaria o lead
// indefinidamente (nunca mais elegível para nova tentativa).
const STALE_ATTEMPT_MINUTES = 10;
export async function reconcileOrphanedAttempts(prisma: PrismaClient): Promise<number> {
const staleSince = new Date(Date.now() - STALE_ATTEMPT_MINUTES * 60_000);
const orphaned = await prisma.dialAttempt.findMany({
where: {
state: { in: ['ORIGINATING', 'RINGING', 'ANSWERED', 'QUEUED', 'AGENT_CONNECTED'] },
endedAt: null,
startedAt: { lt: staleSince },
},
});
if (orphaned.length === 0) return 0;
for (const attempt of orphaned) {
await prisma.$transaction([
prisma.dialAttempt.update({
where: { id: attempt.id },
data: { state: 'FAILED', hangupCause: 'RECONCILED_ORPHAN', endedAt: new Date() },
}),
prisma.lead.updateMany({
where: { id: attempt.leadId, status: { in: ['DIALING', 'RINGING', 'ANSWERED'] } },
data: { status: 'READY', nextAttemptAt: new Date() },
}),
]);
}
logger.warn({ count: orphaned.length }, 'Reconciliação: tentativas órfãs corrigidas após possível queda de worker');
return orphaned.length;
}

View File

@@ -20,6 +20,10 @@ describe('leadStatusForOutcome', () => {
expect(leadStatusForOutcome('NO_ANSWER')).toBe('NO_ANSWER');
});
it('trata ABANDONED como NO_ANSWER para fins de retentativa', () => {
expect(leadStatusForOutcome('ABANDONED')).toBe('NO_ANSWER');
});
it('converge CONGESTION e FAILED para FAILED', () => {
expect(leadStatusForOutcome('CONGESTION')).toBe('FAILED');
expect(leadStatusForOutcome('FAILED')).toBe('FAILED');

View File

@@ -2,13 +2,17 @@
// encerramento, configuráveis por campanha (Campaign.retryRules), nunca
// rediscagem infinita (Campaign.maxAttempts).
export type HangupOutcome = 'ANSWERED' | 'BUSY' | 'NO_ANSWER' | 'CONGESTION' | 'FAILED';
export type HangupOutcome = 'ANSWERED' | 'BUSY' | 'NO_ANSWER' | 'CONGESTION' | 'FAILED' | 'ABANDONED';
// ABANDONED (atendida mas nunca chegou a falar com um agente) segue a
// mesma janela de NO_ANSWER — a pessoa demonstrou disposição a atender,
// vale a pena tentar de novo mais rápido que um FAILED genérico.
const DEFAULT_RETRY_MINUTES: Record<Exclude<HangupOutcome, 'ANSWERED'>, number> = {
BUSY: 15,
NO_ANSWER: 60,
CONGESTION: 5,
FAILED: 30,
ABANDONED: 60,
};
export function calculateNextAttemptAt(
@@ -22,6 +26,6 @@ export function calculateNextAttemptAt(
export function leadStatusForOutcome(outcome: HangupOutcome): 'BUSY' | 'NO_ANSWER' | 'FAILED' {
if (outcome === 'BUSY') return 'BUSY';
if (outcome === 'NO_ANSWER') return 'NO_ANSWER';
if (outcome === 'NO_ANSWER' || outcome === 'ABANDONED') return 'NO_ANSWER';
return 'FAILED'; // CONGESTION e FAILED convergem para FAILED no lead
}