feat(frontend): Relatórios > Consumo (tenant)
GET /reports/consumo agrega os 2 ledgers imutáveis de uso (UsageEvent + AIUsageRecord — os mesmos que o RatingEngine usa pra faturar) por meter/tipo no mês corrente: chamadas, minutos, dias ativos, armazenamento de gravação, tokens de IA. Nunca calcula valor em dinheiro (isso é billing/RatingEngine, platform-only) — decisão deliberada pra não duplicar essa lógica fora dele. Devolve também os limites do plano (maxMonthlyCalls/maxRecordingStorageGb) pra comparação. Tela /app/relatorios/consumo reaproveita o InstrumentTile do dashboard, zeros honestos em vez de esconder seção. Testado ponta a ponta contra o tenant Acme real (2 dias-tronco já ledgerados aparecem certos, resto zerado por não ter chamada rodada ainda). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
This commit is contained in:
16
TODO.md
16
TODO.md
@@ -1540,6 +1540,22 @@ Usuários (agente.md secao 169)
|
|||||||
lista de callbacks, volta pra `READY`), lead de teste removido no
|
lista de callbacks, volta pra `READY`), lead de teste removido no
|
||||||
final
|
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
|
## Riscos conhecidos
|
||||||
|
|||||||
@@ -343,4 +343,67 @@ export class ReportsController {
|
|||||||
bottomAgents: agentRanking.slice(-5).reverse(),
|
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<string, number> = {};
|
||||||
|
for (const row of usageByMeter) usage[row.meter] = row._sum.quantity ?? 0;
|
||||||
|
const aiUsage: Record<string, number> = {};
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
apps/frontend/.impeccable/review/consumo-desktop.png
Normal file
BIN
apps/frontend/.impeccable/review/consumo-desktop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 119 KiB |
@@ -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 (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-semibold text-foreground">Consumo</h1>
|
||||||
|
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
|
||||||
|
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).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Panel className="p-5">
|
||||||
|
<PanelHeader title="Telefonia" description="Chamadas, minutos e dias ativos" />
|
||||||
|
<div className="mt-4 grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5">
|
||||||
|
<InstrumentTile label="Chamadas" value={report.usage.callCount} />
|
||||||
|
<InstrumentTile label="Tempo em chamada" value={formatDuration(report.usage.callSeconds)} />
|
||||||
|
<InstrumentTile label="Ramais ativos/dia" value={report.usage.extensionActiveDays} unit="dia-ramal" />
|
||||||
|
<InstrumentTile label="Agentes ativos/dia" value={report.usage.agentActiveDays} unit="dia-agente" />
|
||||||
|
<InstrumentTile label="Troncos ativos/dia" value={report.usage.trunkActiveDays} unit="dia-tronco" />
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel className="p-5">
|
||||||
|
<PanelHeader title="Armazenamento" description="Gravações de chamada" />
|
||||||
|
<div className="mt-4 grid grid-cols-2 gap-4 sm:grid-cols-3">
|
||||||
|
<InstrumentTile label="Gravações" value={recording.value} unit={recording.unit} />
|
||||||
|
{report.limits?.maxRecordingStorageGb != null && (
|
||||||
|
<InstrumentTile
|
||||||
|
label="Limite do plano"
|
||||||
|
value={report.limits.maxRecordingStorageGb}
|
||||||
|
unit="GB"
|
||||||
|
suffix={
|
||||||
|
report.limits.recordingStorageUsedRatio != null
|
||||||
|
? `${formatPercent(report.limits.recordingStorageUsedRatio)} usado`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel className="p-5">
|
||||||
|
<PanelHeader title="IA" description="Transcrição, análise e tokens — zerado se o tenant não usa IA" />
|
||||||
|
<div className="mt-4 grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||||
|
<InstrumentTile label="Transcrição" value={formatDuration(report.aiUsage.transcriptionSeconds)} />
|
||||||
|
<InstrumentTile label="Análises" value={report.aiUsage.analysisRequests} />
|
||||||
|
<InstrumentTile label="Tokens de entrada" value={report.aiUsage.inputTokens} />
|
||||||
|
<InstrumentTile label="Tokens de saída" value={report.aiUsage.outputTokens} />
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{report.limits?.maxMonthlyCalls != null && (
|
||||||
|
<Panel className="p-5">
|
||||||
|
<PanelHeader title="Limite do plano — chamadas/mês" />
|
||||||
|
<div className="mt-4 grid grid-cols-2 gap-4 sm:grid-cols-3">
|
||||||
|
<InstrumentTile
|
||||||
|
label="Chamadas usadas"
|
||||||
|
value={report.usage.callCount}
|
||||||
|
suffix={`de ${report.limits.maxMonthlyCalls}`}
|
||||||
|
/>
|
||||||
|
<InstrumentTile
|
||||||
|
label="% do limite"
|
||||||
|
value={report.limits.callCountUsedRatio != null ? formatPercent(report.limits.callCountUsedRatio) : null}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
apps/frontend/src/app/app/relatorios/consumo/page.tsx
Normal file
10
apps/frontend/src/app/app/relatorios/consumo/page.tsx
Normal file
@@ -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<ConsumoReport>("/reports/consumo", session.accessToken);
|
||||||
|
return <ConsumoView report={report} />;
|
||||||
|
}
|
||||||
@@ -163,7 +163,11 @@ export const TENANT_NAV: NavSection[] = [
|
|||||||
href: "/app/relatorios/campanhas",
|
href: "/app/relatorios/campanhas",
|
||||||
description: "Leads, tentativas, taxas de atendimento e contato",
|
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",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -77,3 +77,27 @@ export interface AIDashboardReport {
|
|||||||
topAgents: { agentId: string; name: string; avgScore: number; calls: number }[];
|
topAgents: { agentId: string; name: string; avgScore: number; calls: number }[];
|
||||||
bottomAgents: { 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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user