diff --git a/TODO.md b/TODO.md index 3319755..204a606 100644 --- a/TODO.md +++ b/TODO.md @@ -1540,6 +1540,22 @@ Usuários (agente.md secao 169) lista de callbacks, volta pra `READY`), lead de teste removido no final +## PHASE 42 — Relatórios > Consumo (tenant, agente.md secao 131-132, 169) +- [x] `GET /reports/consumo` — lê os 2 ledgers imutáveis (`UsageEvent` + + `AIUsageRecord`, os mesmos que o RatingEngine usa pra faturar) e + agrega por meter/tipo no período (default: mês corrente). Nunca + calcula valor em dinheiro — só quantidade bruta (chamadas, minutos, + dias ativos, bytes, tokens), decisão deliberada pra não duplicar o + trabalho do RatingEngine fora dele (docs/BILLING.md). Também + devolve os limites do plano (`maxMonthlyCalls`/ + `maxRecordingStorageGb`) pra comparação lado a lado +- [x] Tela `/app/relatorios/consumo` — reaproveita `InstrumentTile` (o + mesmo mostrador do dashboard), zeros honestos em vez de esconder + seção +- [x] Testado ponta a ponta: curl retornou os números reais do tenant + Acme (2 dias-tronco já ledgerados, resto zerado — nenhuma chamada + rodou ainda neste tenant), tela renderizando os mesmos números + --- ## Riscos conhecidos diff --git a/apps/api/src/reports/reports.controller.ts b/apps/api/src/reports/reports.controller.ts index 86162c2..764fb71 100644 --- a/apps/api/src/reports/reports.controller.ts +++ b/apps/api/src/reports/reports.controller.ts @@ -343,4 +343,67 @@ export class ReportsController { bottomAgents: agentRanking.slice(-5).reverse(), }; } + + /** "Relatórios > Consumo" (secao 169) — uso bruto (minutos, dias + * ativos, armazenamento, tokens de IA), nunca valor em dinheiro: isso é + * trabalho do RatingEngine/BillingStatement (packages/billing, + * platform-only), não deste endpoint. Sem `from`/`to`, olha o mês + * corrente (mesmo corte usado por `Tenant.billingCurrency`/período de + * fechamento) — é "quanto eu já usei este mês", não um relatório + * histórico livre. Os 2 ledgers imutáveis (`UsageEvent`+ + * `AIUsageRecord`) são a mesma fonte que o billing usa, então o número + * aqui bate exatamente com o que vira fatura depois. */ + @RequirePermission("reports.view") + @Get("consumo") + async consumo(@CurrentUser() user: AccessTokenClaims, @Query("from") from?: string, @Query("to") to?: string) { + const prisma = getPrismaClient(); + const tenantId = user.tenantId!; + + const now = new Date(); + const defaultFrom = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)); + const range = { gte: from ? new Date(from) : defaultFrom, lte: to ? new Date(to) : now }; + + const [usageByMeter, aiUsageByType, tenant] = await withTenantContext(prisma, tenantId, (tx) => + Promise.all([ + tx.usageEvent.groupBy({ by: ["meter"], where: { tenantId, occurredAt: range }, _sum: { quantity: true } }), + tx.aIUsageRecord.groupBy({ by: ["type"], where: { tenantId, occurredAt: range }, _sum: { quantity: true } }), + tx.tenant.findFirst({ where: { id: tenantId }, select: { plan: true } }), + ]), + ); + + const usage: Record = {}; + for (const row of usageByMeter) usage[row.meter] = row._sum.quantity ?? 0; + const aiUsage: Record = {}; + for (const row of aiUsageByType) aiUsage[row.type] = row._sum.quantity ?? 0; + + const plan = tenant?.plan ?? null; + const callCount = usage["CALL_COUNT"] ?? 0; + const recordingBytes = usage["RECORDING_BYTES"] ?? 0; + + return { + period: { from: range.gte.toISOString(), to: range.lte.toISOString() }, + usage: { + callCount, + callSeconds: usage["CALL_SECONDS"] ?? 0, + extensionActiveDays: usage["EXTENSION_ACTIVE_DAY"] ?? 0, + agentActiveDays: usage["AGENT_ACTIVE_DAY"] ?? 0, + trunkActiveDays: usage["TRUNK_ACTIVE_DAY"] ?? 0, + recordingBytes, + }, + aiUsage: { + transcriptionSeconds: aiUsage["AI_TRANSCRIPTION_SECONDS"] ?? 0, + analysisRequests: aiUsage["AI_ANALYSIS_REQUEST"] ?? 0, + inputTokens: aiUsage["AI_INPUT_TOKENS"] ?? 0, + outputTokens: aiUsage["AI_OUTPUT_TOKENS"] ?? 0, + }, + limits: plan && { + maxMonthlyCalls: plan.maxMonthlyCalls, + maxRecordingStorageGb: plan.maxRecordingStorageGb, + callCountUsedRatio: plan.maxMonthlyCalls ? callCount / plan.maxMonthlyCalls : null, + recordingStorageUsedRatio: plan.maxRecordingStorageGb + ? recordingBytes / (plan.maxRecordingStorageGb * 1024 ** 3) + : null, + }, + }; + } } diff --git a/apps/frontend/.impeccable/review/consumo-desktop.png b/apps/frontend/.impeccable/review/consumo-desktop.png new file mode 100644 index 0000000..f04b2b3 Binary files /dev/null and b/apps/frontend/.impeccable/review/consumo-desktop.png differ diff --git a/apps/frontend/src/app/app/relatorios/consumo/consumo-view.tsx b/apps/frontend/src/app/app/relatorios/consumo/consumo-view.tsx new file mode 100644 index 0000000..76cfd0c --- /dev/null +++ b/apps/frontend/src/app/app/relatorios/consumo/consumo-view.tsx @@ -0,0 +1,78 @@ +import { Panel, PanelHeader } from "@/components/ui/panel"; +import { InstrumentTile } from "@/components/ui/instrument-tile"; +import { formatBytes, formatDate, formatDuration, formatPercent } from "@/lib/format"; +import type { ConsumoReport } from "@/lib/report-types"; + +export function ConsumoView({ report }: { report: ConsumoReport }) { + const recording = formatBytes(report.usage.recordingBytes); + + return ( +
+
+

Consumo

+

+ Uso bruto deste tenant no período — mesmos 2 ledgers imutáveis que viram fatura depois (agente.md secao + 131-132), sem nenhum valor em dinheiro aqui (isso é Billing, exclusivo de plataforma). Período:{" "} + {formatDate(report.period.from)} a {formatDate(report.period.to)} (mês corrente). +

+
+ + + +
+ + + + + +
+
+ + + +
+ + {report.limits?.maxRecordingStorageGb != null && ( + + )} +
+
+ + + +
+ + + + +
+
+ + {report.limits?.maxMonthlyCalls != null && ( + + +
+ + +
+
+ )} +
+ ); +} diff --git a/apps/frontend/src/app/app/relatorios/consumo/page.tsx b/apps/frontend/src/app/app/relatorios/consumo/page.tsx new file mode 100644 index 0000000..f2c0ff1 --- /dev/null +++ b/apps/frontend/src/app/app/relatorios/consumo/page.tsx @@ -0,0 +1,10 @@ +import { requireSession } from "@/lib/session"; +import { apiFetch } from "@/lib/api"; +import type { ConsumoReport } from "@/lib/report-types"; +import { ConsumoView } from "./consumo-view"; + +export default async function ConsumoPage() { + const session = await requireSession(); + const report = await apiFetch("/reports/consumo", session.accessToken); + return ; +} diff --git a/apps/frontend/src/components/tenant-shell/nav-data.ts b/apps/frontend/src/components/tenant-shell/nav-data.ts index ff02904..bb1f10a 100644 --- a/apps/frontend/src/components/tenant-shell/nav-data.ts +++ b/apps/frontend/src/components/tenant-shell/nav-data.ts @@ -163,7 +163,11 @@ export const TENANT_NAV: NavSection[] = [ href: "/app/relatorios/campanhas", description: "Leads, tentativas, taxas de atendimento e contato", }, - { label: "Consumo" }, + { + label: "Consumo", + href: "/app/relatorios/consumo", + description: "Uso bruto do mês — minutos, dias ativos, armazenamento, tokens de IA", + }, ], }, { diff --git a/apps/frontend/src/lib/report-types.ts b/apps/frontend/src/lib/report-types.ts index 3f728f2..ab5e313 100644 --- a/apps/frontend/src/lib/report-types.ts +++ b/apps/frontend/src/lib/report-types.ts @@ -77,3 +77,27 @@ export interface AIDashboardReport { topAgents: { agentId: string; name: string; avgScore: number; calls: number }[]; bottomAgents: { agentId: string; name: string; avgScore: number; calls: number }[]; } + +export interface ConsumoReport { + period: { from: string; to: string }; + usage: { + callCount: number; + callSeconds: number; + extensionActiveDays: number; + agentActiveDays: number; + trunkActiveDays: number; + recordingBytes: number; + }; + aiUsage: { + transcriptionSeconds: number; + analysisRequests: number; + inputTokens: number; + outputTokens: number; + }; + limits: { + maxMonthlyCalls: number | null; + maxRecordingStorageGb: number | null; + callCountUsedRatio: number | null; + recordingStorageUsedRatio: number | null; + } | null; +}