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:
59
TODO.md
59
TODO.md
@@ -220,14 +220,57 @@ simulação de NO_ANSWER (retry agendado) e ANSWERED (AGENT_CONNECTED, EWMA
|
|||||||
atualizada ao vivo no Redis) → parada sem derrubar chamadas em andamento.
|
atualizada ao vivo no Redis) → parada sem derrubar chamadas em andamento.
|
||||||
|
|
||||||
## Fase 7 — CDR, métricas e relatórios
|
## Fase 7 — CDR, métricas e relatórios
|
||||||
- [ ] Modelo consolidado de chamadas (CDR+CEL+AMI+queue_log)
|
- [x] Modelo consolidado de chamadas — `DialAttempt` já concentra os timestamps
|
||||||
- [ ] Reconciliação de estados órfãos após restart
|
reais (started/ringing/answered/queued/agentConnected/ended) e
|
||||||
- [ ] TME / TMA (definições documentadas, cálculo correto)
|
hangupCause; CDR/CEL do Asterisk seguem gravados via `cdr_adaptive_odbc`/
|
||||||
- [ ] Relatório de Chamadas (filtros, paginação server-side, export CSV)
|
`cel_odbc` na base `asterisk` (não duplicados no domínio da app). Não há
|
||||||
- [ ] Relatório de Agentes (tempos, pausas detalhadas)
|
correlação automática CDR↔DialAttempt para chamadas reais (não
|
||||||
- [ ] Dashboard geral (cards + gráficos reais)
|
simuladas) além do timeout de segurança — ver nota em
|
||||||
- [ ] Dashboard do discador (por campanha, tempo real)
|
docs/PREDICTIVE_DIALER.md.
|
||||||
- [ ] Compliance de Chamadas (parâmetros configuráveis, alertas, contadores)
|
- [x] Reconciliação de estados órfãos após restart — `reconciliation.ts`
|
||||||
|
(`reconcileOrphanedAttempts`), rodando a cada 60s via `main.ts`; força
|
||||||
|
`DialAttempt`s presos há >10min para `FAILED`/`RECONCILED_ORPHAN` e
|
||||||
|
devolve o lead para `READY`. Testado por unit tests; não exercitado
|
||||||
|
neste ciclo via kill real do worker (fica para Fase 10).
|
||||||
|
- [x] TME / TMA — cálculo real em `reports.service.ts#calculateMetrics` e
|
||||||
|
`dashboard.service.ts#overview/campaignDashboard`, a partir de
|
||||||
|
`queuedAt`/`agentConnectedAt`/`endedAt`. Validado end-to-end: campanha
|
||||||
|
de teste "Campanha F7" retornou `tmeSeconds: 0.023`, `tmaSeconds: 136.6`
|
||||||
|
batendo com os timestamps reais da chamada simulada conectada.
|
||||||
|
- [x] Relatório de Chamadas — `GET /api/reports/calls` (filtros
|
||||||
|
campaignId/agentId/dispositionId/phone/state/from/to, paginação
|
||||||
|
server-side) e `GET /api/reports/calls/export` (CSV via
|
||||||
|
`csv-stringify`, teto de 100k linhas). Testado contra as 10 tentativas
|
||||||
|
reais da campanha de teste — paginação e filtros funcionando.
|
||||||
|
- [x] Relatório de Agentes — `GET /api/reports/agents/:agentId`, tempo real
|
||||||
|
por estado a partir de `agent_state_events`/`agent_pause_events`.
|
||||||
|
Validado: agente de teste retornou `timeByStateSeconds` com
|
||||||
|
LOGGED_IN/AVAILABLE/IN_CALL/WRAP_UP batendo com o ciclo real observado.
|
||||||
|
- [x] Dashboard geral — `GET /api/dashboard` e `/api/dashboard/calls-by-hour`,
|
||||||
|
cards/gráficos 100% derivados de `dial_attempts`/`agent_state_events`
|
||||||
|
reais (sem números inventados, seção 72). Validado com dados reais.
|
||||||
|
- [x] Dashboard do discador — `GET /api/dashboard/campaigns/:id`, combina
|
||||||
|
contagens Postgres com snapshot EWMA do Redis
|
||||||
|
(`dialer:stats:{campaignId}`). Validado: `abandonRate` e
|
||||||
|
`pacingFactor` refletiram corretamente o abandono real ocorrido no
|
||||||
|
teste.
|
||||||
|
- [x] Compliance de Chamadas — `ComplianceSettings` (singleton configurável:
|
||||||
|
limiar de chamada curta, limites de tentativas por número/dia/mês,
|
||||||
|
threshold de alto volume) + `GET/PATCH /api/compliance/settings` e
|
||||||
|
`GET /api/compliance/indicators` (contadores reais de chamadas
|
||||||
|
hoje/mês, chamadas curtas, abandonadas, números acima do limite
|
||||||
|
diário, alertas). Validado com dados reais da campanha de teste.
|
||||||
|
|
||||||
|
**Teste E2E executado (2026-08-27):** criados trunk/fila/ramal/agente/usuário/
|
||||||
|
campanha de teste ("Campanha F7"), 10 leads importados via CSV, campanha
|
||||||
|
rodada em `DIALER_SIMULATION=true`. Confirmado em produção real (não só no
|
||||||
|
harness isolado): `DialAttempt.agentId` populado corretamente ao conectar
|
||||||
|
(bug da Fase 7 corrigido em `agent-call-binding.ts`); ciclo completo de
|
||||||
|
`AgentStateEvent` `AVAILABLE → IN_CALL → WRAP_UP → AVAILABLE`; `abandonRate`
|
||||||
|
no Redis atualizado de `0` para `0.05` após abandono real (bug do EWMA não
|
||||||
|
conectado ao `campaign-worker.ts` corrigido); todos os endpoints de
|
||||||
|
relatórios/dashboard/compliance retornando dados reais e coerentes com o
|
||||||
|
banco. Todos os fixtures de teste foram removidos/desativados ao final.
|
||||||
|
|
||||||
## Fase 8 — Frontend completo
|
## Fase 8 — Frontend completo
|
||||||
- [ ] Bootstrap Next.js + Tailwind + shadcn/ui + TanStack Query + WS client
|
- [ ] Bootstrap Next.js + Tailwind + shadcn/ui + TanStack Query + WS client
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ import { SuppressionModule } from './suppression/suppression.module';
|
|||||||
import { DispositionsModule } from './dispositions/dispositions.module';
|
import { DispositionsModule } from './dispositions/dispositions.module';
|
||||||
import { CampaignsModule } from './campaigns/campaigns.module';
|
import { CampaignsModule } from './campaigns/campaigns.module';
|
||||||
import { LeadsModule } from './leads/leads.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 { AuthGuard } from './common/guards/auth.guard';
|
||||||
import { PermissionsGuard } from './common/guards/permissions.guard';
|
import { PermissionsGuard } from './common/guards/permissions.guard';
|
||||||
import { GlobalExceptionFilter } from './common/filters/global-exception.filter';
|
import { GlobalExceptionFilter } from './common/filters/global-exception.filter';
|
||||||
@@ -80,6 +83,9 @@ import { GlobalExceptionFilter } from './common/filters/global-exception.filter'
|
|||||||
DispositionsModule,
|
DispositionsModule,
|
||||||
CampaignsModule,
|
CampaignsModule,
|
||||||
LeadsModule,
|
LeadsModule,
|
||||||
|
ReportsModule,
|
||||||
|
DashboardModule,
|
||||||
|
ComplianceModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
37
apps/api/src/compliance/compliance.controller.ts
Normal file
37
apps/api/src/compliance/compliance.controller.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/api/src/compliance/compliance.module.ts
Normal file
9
apps/api/src/compliance/compliance.module.ts
Normal 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 {}
|
||||||
148
apps/api/src/compliance/compliance.service.ts
Normal file
148
apps/api/src/compliance/compliance.service.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
26
apps/api/src/dashboard/dashboard.controller.ts
Normal file
26
apps/api/src/dashboard/dashboard.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/api/src/dashboard/dashboard.module.ts
Normal file
9
apps/api/src/dashboard/dashboard.module.ts
Normal 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 {}
|
||||||
203
apps/api/src/dashboard/dashboard.service.ts
Normal file
203
apps/api/src/dashboard/dashboard.service.ts
Normal 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;
|
||||||
|
}
|
||||||
53
apps/api/src/reports/dto/query-calls-report.dto.ts
Normal file
53
apps/api/src/reports/dto/query-calls-report.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
55
apps/api/src/reports/reports.controller.ts
Normal file
55
apps/api/src/reports/reports.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/api/src/reports/reports.module.ts
Normal file
10
apps/api/src/reports/reports.module.ts
Normal 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 {}
|
||||||
204
apps/api/src/reports/reports.service.ts
Normal file
204
apps/api/src/reports/reports.service.ts
Normal 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;
|
||||||
|
}
|
||||||
59
apps/dialer-worker/src/agent-call-binding.ts
Normal file
59
apps/dialer-worker/src/agent-call-binding.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import { getLiveCounts } from './live-counts';
|
|||||||
import { adjustPacingFactor, calculateCallsToOriginate, updateEwma, type PacingLimits } from './predictive-engine';
|
import { adjustPacingFactor, calculateCallsToOriginate, updateEwma, type PacingLimits } from './predictive-engine';
|
||||||
import { calculateNextAttemptAt, leadStatusForOutcome, type HangupOutcome } from './retry-rules';
|
import { calculateNextAttemptAt, leadStatusForOutcome, type HangupOutcome } from './retry-rules';
|
||||||
import { DEFAULT_SIMULATION_PROFILE, randomInRange, simulateHangupCause } from './simulation';
|
import { DEFAULT_SIMULATION_PROFILE, randomInRange, simulateHangupCause } from './simulation';
|
||||||
|
import { claimAvailableAgent, releaseAgentAfterCall } from './agent-call-binding';
|
||||||
import { logger } from './logger';
|
import { logger } from './logger';
|
||||||
|
|
||||||
const WORKER_ID = randomUUID();
|
const WORKER_ID = randomUUID();
|
||||||
@@ -109,7 +110,17 @@ export class CampaignWorker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async originateAttempt(
|
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,
|
trunkName: string,
|
||||||
lead: { id: string; phone: string; phoneNormalized: string; attemptCount: number },
|
lead: { id: string; phone: string; phoneNormalized: string; attemptCount: number },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -166,7 +177,14 @@ export class CampaignWorker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private simulateAttempt(
|
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 },
|
lead: { id: string; attemptCount: number },
|
||||||
attemptId: string,
|
attemptId: string,
|
||||||
): void {
|
): void {
|
||||||
@@ -194,23 +212,71 @@ export class CampaignWorker {
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
await this.prisma.dialAttempt.update({ where: { id: attemptId }, data: { state: 'ANSWERED', answeredAt: new Date() } });
|
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);
|
// Atendida != conectada a um agente. Se ninguém está disponível
|
||||||
await this.prisma.dialAttempt.update({ where: { id: attemptId }, data: { state: 'AGENT_CONNECTED', agentConnectedAt: new Date() } });
|
// agora, espera até maxWaitForAgentSeconds antes de considerar
|
||||||
setTimeout(() => {
|
// abandono (agente.md seções 32/33) — sem isso, abandonRate nunca
|
||||||
void this.finalizeAttempt(attemptId, campaign.id, lead.id, 'ANSWERED', {}, campaign.maxAttempts, lead.attemptCount + 1, talkTimeSeconds);
|
// reflete a realidade e o controle de abandono do motor preditivo
|
||||||
}, talkTimeSeconds * 1000);
|
// 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);
|
}, 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 } });
|
const campaign = await this.prisma.campaign.findUnique({ where: { id: campaignId } });
|
||||||
if (!campaign) return;
|
if (!campaign) return;
|
||||||
const stats = await this.statsStore.load(campaignId, campaign.pacingInitial);
|
const stats = await this.statsStore.load(campaignId, campaign.pacingInitial);
|
||||||
stats.answerProbability = updateEwma(stats.answerProbability, 1);
|
stats.answerProbability = updateEwma(stats.answerProbability, 1);
|
||||||
stats.avgAnswerDelaySeconds = updateEwma(stats.avgAnswerDelaySeconds, answerDelaySeconds);
|
stats.avgAnswerDelaySeconds = updateEwma(stats.avgAnswerDelaySeconds, answerDelaySeconds);
|
||||||
|
stats.abandonRate = updateEwma(stats.abandonRate, abandoned ? 1 : 0, 0.05);
|
||||||
await this.statsStore.save(campaignId, stats);
|
await this.statsStore.save(campaignId, stats);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ import { PrismaClient, CampaignStatus } from '@b2bcall/database';
|
|||||||
import Redis from 'ioredis';
|
import Redis from 'ioredis';
|
||||||
import { AsteriskTelephonyProvider } from '@b2bcall/telephony';
|
import { AsteriskTelephonyProvider } from '@b2bcall/telephony';
|
||||||
import { CampaignWorker } from './campaign-worker';
|
import { CampaignWorker } from './campaign-worker';
|
||||||
|
import { reconcileOrphanedAttempts } from './reconciliation';
|
||||||
import { logger } from './logger';
|
import { logger } from './logger';
|
||||||
|
|
||||||
const TICK_INTERVAL_MS = 2000;
|
const TICK_INTERVAL_MS = 2000;
|
||||||
|
const RECONCILE_INTERVAL_MS = 60_000;
|
||||||
const DIALER_SIMULATION = process.env.DIALER_SIMULATION === 'true';
|
const DIALER_SIMULATION = process.env.DIALER_SIMULATION === 'true';
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
@@ -59,12 +61,17 @@ async function main() {
|
|||||||
logger.info({ tickIntervalMs: TICK_INTERVAL_MS, simulation: DIALER_SIMULATION }, 'dialer-worker iniciado');
|
logger.info({ tickIntervalMs: TICK_INTERVAL_MS, simulation: DIALER_SIMULATION }, 'dialer-worker iniciado');
|
||||||
|
|
||||||
const interval = setInterval(() => void tickAllRunningCampaigns(), TICK_INTERVAL_MS);
|
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 () => {
|
const shutdown = async () => {
|
||||||
if (!running) return;
|
if (!running) return;
|
||||||
running = false;
|
running = false;
|
||||||
logger.info('Encerrando dialer-worker...');
|
logger.info('Encerrando dialer-worker...');
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
|
clearInterval(reconcileInterval);
|
||||||
telephony.disconnect();
|
telephony.disconnect();
|
||||||
await redis.quit();
|
await redis.quit();
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
|
|||||||
40
apps/dialer-worker/src/reconciliation.ts
Normal file
40
apps/dialer-worker/src/reconciliation.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -20,6 +20,10 @@ describe('leadStatusForOutcome', () => {
|
|||||||
expect(leadStatusForOutcome('NO_ANSWER')).toBe('NO_ANSWER');
|
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', () => {
|
it('converge CONGESTION e FAILED para FAILED', () => {
|
||||||
expect(leadStatusForOutcome('CONGESTION')).toBe('FAILED');
|
expect(leadStatusForOutcome('CONGESTION')).toBe('FAILED');
|
||||||
expect(leadStatusForOutcome('FAILED')).toBe('FAILED');
|
expect(leadStatusForOutcome('FAILED')).toBe('FAILED');
|
||||||
|
|||||||
@@ -2,13 +2,17 @@
|
|||||||
// encerramento, configuráveis por campanha (Campaign.retryRules), nunca
|
// encerramento, configuráveis por campanha (Campaign.retryRules), nunca
|
||||||
// rediscagem infinita (Campaign.maxAttempts).
|
// 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> = {
|
const DEFAULT_RETRY_MINUTES: Record<Exclude<HangupOutcome, 'ANSWERED'>, number> = {
|
||||||
BUSY: 15,
|
BUSY: 15,
|
||||||
NO_ANSWER: 60,
|
NO_ANSWER: 60,
|
||||||
CONGESTION: 5,
|
CONGESTION: 5,
|
||||||
FAILED: 30,
|
FAILED: 30,
|
||||||
|
ABANDONED: 60,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function calculateNextAttemptAt(
|
export function calculateNextAttemptAt(
|
||||||
@@ -22,6 +26,6 @@ export function calculateNextAttemptAt(
|
|||||||
|
|
||||||
export function leadStatusForOutcome(outcome: HangupOutcome): 'BUSY' | 'NO_ANSWER' | 'FAILED' {
|
export function leadStatusForOutcome(outcome: HangupOutcome): 'BUSY' | 'NO_ANSWER' | 'FAILED' {
|
||||||
if (outcome === 'BUSY') return 'BUSY';
|
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
|
return 'FAILED'; // CONGESTION e FAILED convergem para FAILED no lead
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -159,23 +159,26 @@ chamadas em andamento (seção 76).
|
|||||||
- **AMD**: campo `Campaign.amdEnabled` existe no schema/DTO, mas a
|
- **AMD**: campo `Campaign.amdEnabled` existe no schema/DTO, mas a
|
||||||
detecção de secretária eletrônica em si (app `AMD()` do Asterisk ou
|
detecção de secretária eletrônica em si (app `AMD()` do Asterisk ou
|
||||||
ARI) ainda não está integrada ao fluxo de originação real. Pendente.
|
ARI) ainda não está integrada ao fluxo de originação real. Pendente.
|
||||||
- **Wrap-up automático**: `Campaign.wrapUpTimeSeconds` existe, mas a
|
- **Wrap-up automático (simulação)**: desde a Fase 7, `claimAvailableAgent`/
|
||||||
transição automática do agente para o estado `WRAP_UP` ao final de uma
|
`releaseAgentAfterCall` (`agent-call-binding.ts`) implementam o ciclo
|
||||||
chamada real (via eventos `AgentComplete` do Asterisk) ainda não está
|
completo `AVAILABLE → IN_CALL → WRAP_UP → AVAILABLE` (usando
|
||||||
implementada em `apps/asterisk-events` — hoje o agente só muda de estado
|
`Campaign.wrapUpTimeSeconds`) e `DialAttempt.agentId` é populado
|
||||||
manualmente pela tela do agente (Fase 5). Isso significa que, em uma
|
corretamente ao conectar — validado end-to-end em modo simulação (ver
|
||||||
campanha com chamadas REAIS (não simuladas), a contagem de
|
TODO.md Fase 7). **Isso ainda só é acionado pelo fluxo simulado
|
||||||
`availableAgents`/`agentsLikelyToFreeSoon` não reflete automaticamente
|
(`tryConnectOrAbandon`/`connect` em `campaign-worker.ts`).** Para
|
||||||
agentes que entraram em uma chamada real — só funciona corretamente hoje
|
campanhas com chamadas REAIS (`DIALER_SIMULATION=false`), a transição via
|
||||||
no modo de simulação (que modela isso internamente) e para as
|
eventos `AgentConnect`/`AgentComplete` do Asterisk ainda não está
|
||||||
transições manuais já cobertas pela Fase 5 (login/disponível/pausa/
|
implementada em `apps/asterisk-events` — hoje o agente em chamada real só
|
||||||
logout). Wiring de `AgentConnect`/`AgentComplete` -> transição de estado
|
muda de estado manualmente pela tela do agente (Fase 5). Pendente para
|
||||||
fica para consolidação junto da Fase 7 (reconciliação de estados).
|
quando houver troncos/chamadas reais para testar (Fase 9/10).
|
||||||
- **Correlação de eventos reais**: quando `DIALER_SIMULATION=false`, a
|
- **Correlação de eventos reais**: quando `DIALER_SIMULATION=false`, a
|
||||||
chamada é originada de verdade via AMI, mas a resolução fina
|
chamada é originada de verdade via AMI, mas a resolução fina
|
||||||
(atendida/ocupada/sem resposta) depende de reconciliação com
|
(atendida/ocupada/sem resposta) ainda depende de reconciliação fina com
|
||||||
CDR/CEL/queue_log — planejada explicitamente para a Fase 7 (seção 51).
|
CDR/CEL/queue_log, que não está implementada — a Fase 7 entregou apenas a
|
||||||
Por ora, uma chamada real sem eventos correlacionados expira em
|
reconciliação de *estados órfãos* (`reconciliation.ts`, tentativas presas
|
||||||
|
por >10min são forçadas a `FAILED`/`RECONCILED_ORPHAN`), não a
|
||||||
|
correlação de eventos AMI em tempo real para o resultado exato da
|
||||||
|
chamada. Por ora, uma chamada real sem eventos correlacionados expira em
|
||||||
`FAILED` após o timeout de segurança, o que é seguro (nunca fica presa
|
`FAILED` após o timeout de segurança, o que é seguro (nunca fica presa
|
||||||
para sempre) mas não tão preciso quanto a resolução via simulação.
|
para sempre) mas não tão preciso quanto a resolução via simulação.
|
||||||
- **Disposições/Callback**: adiados para consolidar junto da tela do
|
- **Disposições/Callback**: adiados para consolidar junto da tela do
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "compliance_settings" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"short_call_threshold_seconds" INTEGER NOT NULL DEFAULT 3,
|
||||||
|
"max_attempts_per_number_per_day" INTEGER NOT NULL DEFAULT 3,
|
||||||
|
"max_attempts_per_number_per_month" INTEGER NOT NULL DEFAULT 10,
|
||||||
|
"high_volume_monthly_threshold" INTEGER NOT NULL DEFAULT 1000000,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "compliance_settings_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
@@ -635,3 +635,21 @@ model SuppressionEntry {
|
|||||||
|
|
||||||
@@map("suppression_list")
|
@@map("suppression_list")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Fase 7 — Compliance (agente.md seção 34). Parâmetros configuráveis, nunca
|
||||||
|
// hardcoded para uma legislação específica — o sistema AJUDA a cumprir
|
||||||
|
// regras, nunca burla mecanismos antispam/autenticação de operadora.
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
// Linha única (singleton lógico) de parâmetros de compliance.
|
||||||
|
model ComplianceSettings {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
shortCallThresholdSeconds Int @default(3) @map("short_call_threshold_seconds")
|
||||||
|
maxAttemptsPerNumberPerDay Int @default(3) @map("max_attempts_per_number_per_day")
|
||||||
|
maxAttemptsPerNumberPerMonth Int @default(10) @map("max_attempts_per_number_per_month")
|
||||||
|
highVolumeMonthlyThreshold Int @default(1000000) @map("high_volume_monthly_threshold")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@map("compliance_settings")
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user