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:
@@ -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: [
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user