Files
b2bcall/apps/dialer-worker/src/reconciliation.ts
B2BCall Bootstrap 0a8b830e2c 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
2026-08-27 16:09:22 -03:00

41 lines
1.5 KiB
TypeScript

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;
}