feat(api,web): detalhamento da carteira do representante
Tela dedicada que abre pelo botao "Detalhar carteira" na listagem de clientes, com leitura acionavel da carteira em vez de so uma tabela. - `/reports/carteira` passa a devolver `faturamentoTotal`, `participacaoPct` e `faturamentoRepTotal`, permitindo medir o peso de cada cliente no faturamento do rep. - `CarteirePage`: cards de resumo (ativos / em risco / inativos / sem pedido), faturamento total e InsightCards que apontam concentracao em poucos clientes, maior cliente em risco, maior inativo e clientes nunca positivados. - Clientes classificados por dias sem comprar (30d em risco, 60d inativo), com navegacao direta para a ficha. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -86,56 +86,83 @@ export class ReportsService {
|
||||
dias_sem_pedido: unknown;
|
||||
total_pedidos: unknown;
|
||||
ticket_medio: unknown;
|
||||
faturamento_total: unknown;
|
||||
}
|
||||
|
||||
const rows = await prisma.$queryRawUnsafe<CarteiraRow[]>(`
|
||||
WITH ultimos AS (
|
||||
SELECT
|
||||
id_cliente,
|
||||
MAX(dt_pedido) AS ultimo_pedido,
|
||||
COUNT(id) AS total_pedidos,
|
||||
CASE WHEN COUNT(id) > 0
|
||||
THEN SUM(total)::numeric / COUNT(id)
|
||||
MAX(dt_pedido) AS ultimo_pedido,
|
||||
COUNT(*) AS total_pedidos,
|
||||
CASE WHEN COUNT(*) > 0
|
||||
THEN SUM(total)::numeric / COUNT(*)
|
||||
ELSE 0
|
||||
END AS ticket_medio
|
||||
FROM sar.pedidos
|
||||
END AS ticket_medio,
|
||||
COALESCE(SUM(total), 0)::numeric AS faturamento_total
|
||||
FROM vw_pedidos_erp
|
||||
WHERE id_empresa = ${idEmpresa}
|
||||
AND cod_vendedor = ${codVendedor}
|
||||
AND situa <> 3
|
||||
AND situa NOT IN (1, 5)
|
||||
GROUP BY id_cliente
|
||||
)
|
||||
SELECT
|
||||
c.id_cliente,
|
||||
TRIM(c.nome) AS nome,
|
||||
TRIM(c.razao) AS razao,
|
||||
TRIM(c.nome) AS nome,
|
||||
TRIM(c.razao) AS razao,
|
||||
c.ativo,
|
||||
TO_CHAR(u.ultimo_pedido, 'YYYY-MM-DD') AS ultimo_pedido,
|
||||
(CURRENT_DATE - u.ultimo_pedido::date) AS dias_sem_pedido,
|
||||
COALESCE(u.total_pedidos, 0)::int AS total_pedidos,
|
||||
COALESCE(u.ticket_medio, 0)::numeric AS ticket_medio
|
||||
FROM sar.vw_clientes c
|
||||
TO_CHAR(u.ultimo_pedido, 'YYYY-MM-DD') AS ultimo_pedido,
|
||||
(CURRENT_DATE - u.ultimo_pedido::date) AS dias_sem_pedido,
|
||||
COALESCE(u.total_pedidos, 0)::int AS total_pedidos,
|
||||
COALESCE(u.ticket_medio, 0)::numeric AS ticket_medio,
|
||||
COALESCE(u.faturamento_total, 0)::numeric AS faturamento_total
|
||||
FROM vw_clientes c
|
||||
LEFT JOIN ultimos u ON u.id_cliente = c.id_cliente
|
||||
WHERE c.id_empresa = ${idEmpresa}
|
||||
AND c.cod_vendedor = ${codVendedor}
|
||||
ORDER BY u.ultimo_pedido ASC NULLS FIRST, c.nome ASC
|
||||
WHERE c.cod_vendedor = ${codVendedor}
|
||||
AND c.ativo = 1
|
||||
ORDER BY u.faturamento_total DESC NULLS LAST, c.nome ASC
|
||||
`);
|
||||
|
||||
const data = rows.map((r) => ({
|
||||
idCliente: n(r.id_cliente),
|
||||
nome: r.nome ?? null,
|
||||
razao: r.razao ?? null,
|
||||
ativo: n(r.ativo),
|
||||
ultimoPedido: r.ultimo_pedido ?? null,
|
||||
diasSemPedido: r.dias_sem_pedido != null ? n(r.dias_sem_pedido) : null,
|
||||
totalPedidos: n(r.total_pedidos),
|
||||
ticketMedio: d(r.ticket_medio),
|
||||
}));
|
||||
// Deduplica por id_cliente — vw_clientes pode retornar o mesmo cliente em mais de uma empresa.
|
||||
const seen = new Set<number>();
|
||||
const unique = rows.filter((r) => {
|
||||
const id = n(r.id_cliente);
|
||||
if (seen.has(id)) return false;
|
||||
seen.add(id);
|
||||
return true;
|
||||
});
|
||||
|
||||
const faturamentoRepTotal = unique.reduce((acc, r) => acc + n(r.faturamento_total), 0);
|
||||
|
||||
const data = unique.map((r) => {
|
||||
const fat = n(r.faturamento_total);
|
||||
return {
|
||||
idCliente: n(r.id_cliente),
|
||||
nome: r.nome ?? null,
|
||||
razao: r.razao ?? null,
|
||||
ativo: n(r.ativo),
|
||||
ultimoPedido: r.ultimo_pedido ?? null,
|
||||
diasSemPedido: r.dias_sem_pedido != null ? n(r.dias_sem_pedido) : null,
|
||||
totalPedidos: n(r.total_pedidos),
|
||||
ticketMedio: d(r.ticket_medio),
|
||||
faturamentoTotal: fat.toFixed(2),
|
||||
participacaoPct:
|
||||
faturamentoRepTotal > 0 ? Math.round((fat / faturamentoRepTotal) * 1000) / 10 : 0,
|
||||
};
|
||||
});
|
||||
|
||||
const inativos30 = data.filter((c) => c.diasSemPedido != null && c.diasSemPedido >= 30).length;
|
||||
const inativos60 = data.filter((c) => c.diasSemPedido != null && c.diasSemPedido >= 60).length;
|
||||
const semPedido = data.filter((c) => c.totalPedidos === 0).length;
|
||||
|
||||
return { data, total: data.length, inativos30, inativos60, semPedido };
|
||||
return {
|
||||
data,
|
||||
total: data.length,
|
||||
inativos30,
|
||||
inativos60,
|
||||
semPedido,
|
||||
faturamentoRepTotal: faturamentoRepTotal.toFixed(2),
|
||||
};
|
||||
}
|
||||
|
||||
async abcProdutos(query: ReportAbcQuery): Promise<ReportAbcResponse> {
|
||||
|
||||
Reference in New Issue
Block a user