feat(web+api): ficha do cliente — contatos, CTR, NF-e, pedidos e produtos

- Contatos: grid de cards 3 colunas, botão "Novo Contato" no topo da página
- Sync ERP↔SAR corrigido: vw_contatos aceita formato COR#<id>, trigger normaliza id_empresa (9001→1)
- CTR + NF-e: layout 50/50 — lista de títulos abertos com badge vencido/a vencer e lista de notas com botão copiar chave NF-e
- Histórico de pedidos: UNION SAR+ERP, top 5 + modal "Ver todos"
- Produtos mais comprados: top 5 com último preço + modal "Ver todos"
- Novos endpoints: ctr-list, notas, orders-history, top-produtos
- AppShell: overflow-x travado, sem scroll horizontal na aplicação

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-25 14:13:21 +00:00
parent a2bab75bad
commit 400fcb3360
23 changed files with 2532 additions and 356 deletions

View File

@@ -13,6 +13,7 @@ import { OrdersModule } from './orders/orders.module';
import { CatalogModule } from './catalog/catalog.module';
import { DashboardModule } from './dashboard/dashboard.module';
import { NotificationsModule } from './notifications/notifications.module';
import { ReportsModule } from './reports/reports.module';
import { ProblemDetailsFilter } from './filters/problem-details.filter';
@Module({
@@ -29,6 +30,7 @@ import { ProblemDetailsFilter } from './filters/problem-details.filter';
CatalogModule,
DashboardModule,
NotificationsModule,
ReportsModule,
],
providers: [
{ provide: APP_PIPE, useClass: ZodValidationPipe },

View File

@@ -10,8 +10,13 @@ import {
type ClientNovoResult,
type Contato,
type ContatoResult,
type CtrSummary,
type CreateClientNovo,
type CreateContato,
type CtrTitulo,
type NotaFiscal,
type PedidoSummary,
type TopProduto,
} from '@sar/api-interface';
import { ClientsService } from './clients.service';
@@ -35,6 +40,47 @@ export class ClientsController {
return this.clients.createNovo(parsed);
}
@Get(':id/notas')
listNotasFiscais(
@Param('id', ParseIntPipe) id: number,
@Query('limit') limit?: string,
): Promise<NotaFiscal[]> {
const n = limit ? Math.min(parseInt(limit, 10) || 30, 100) : 30;
return this.clients.listNotasFiscais(id, n);
}
@Get(':id/top-produtos')
listTopProdutos(
@Param('id', ParseIntPipe) id: number,
@Query('limit') limit?: string,
): Promise<TopProduto[]> {
const n = limit ? Math.min(parseInt(limit, 10) || 30, 100) : 30;
return this.clients.listTopProdutos(id, n);
}
@Get(':id/orders-history')
listOrdersHistory(
@Param('id', ParseIntPipe) id: number,
@Query('limit') limit?: string,
): Promise<PedidoSummary[]> {
const n = limit ? Math.min(parseInt(limit, 10) || 50, 200) : 50;
return this.clients.listOrdersHistory(id, n);
}
@Get(':id/ctr')
getCtrSummary(@Param('id', ParseIntPipe) id: number): Promise<CtrSummary> {
return this.clients.getCtrSummary(id);
}
@Get(':id/ctr-list')
listCtrTitulos(
@Param('id', ParseIntPipe) id: number,
@Query('limit') limit?: string,
): Promise<CtrTitulo[]> {
const n = limit ? Math.min(parseInt(limit, 10) || 60, 200) : 60;
return this.clients.listCtrTitulos(id, n);
}
@Get(':id/contacts')
listContacts(@Param('id', ParseIntPipe) id: number): Promise<Contato[]> {
return this.clients.listContacts(id);

View File

@@ -9,8 +9,13 @@ import type {
ClientSummary,
Contato,
ContatoResult,
CtrSummary,
CtrTitulo,
CreateClientNovo,
CreateContato,
NotaFiscal,
PedidoSummary,
TopProduto,
} from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types';
@@ -364,6 +369,161 @@ export class ClientsService {
};
}
async listOrdersHistory(idCliente: number, limit: number): Promise<PedidoSummary[]> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa');
interface Row {
id: string;
num_ped: string;
situa: number;
dt_pedido: Date;
total: string;
fonte: string;
}
const rows = await prisma.$queryRawUnsafe<Row[]>(`
SELECT id::text AS id,
num_ped_sar AS num_ped,
situa,
dt_pedido,
total::text,
'sar' AS fonte
FROM sar.pedidos
WHERE id_cliente = ${idCliente}
AND id_empresa = ${idEmpresa}
UNION ALL
SELECT ('erp-' || id_pedido::text) AS id,
COALESCE(NULLIF(TRIM(num_ped_sar::text),''), numero::text) AS num_ped,
CASE WHEN situa = 5 THEN 3 ELSE situa END AS situa,
dt_pedido,
total::text,
'erp' AS fonte
FROM sar.vw_pedidos_erp
WHERE id_cliente = ${idCliente}
AND id_empresa = ${idEmpresa}
AND situa != 5
ORDER BY dt_pedido DESC
LIMIT ${limit}
`);
return rows.map((r) => ({
id: r.id,
numPedSar: r.num_ped ?? r.id,
idCliente,
nomeCliente: null,
razaoCliente: null,
codVendedor: 0,
nomeVendedor: null,
situa: Number(r.situa),
dtPedido: r.dt_pedido.toISOString(),
total: r.total ?? '0',
descontoPerc: '0',
obs: null,
createdAt: r.dt_pedido.toISOString(),
fonte: r.fonte as 'sar' | 'erp',
}));
}
async listTopProdutos(idCliente: number, limit: number): Promise<TopProduto[]> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa');
// Normaliza empresa fiscal (9001) → gerencial (1) onde ficam os produtos
const idEmpresaMatriz = idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
interface Row {
id_produto: number;
descricao: string;
unidade: string | null;
qtd_total: string;
num_pedidos: string;
ultima_compra: Date;
ultimo_preco: string;
}
const rows = await prisma.$queryRawUnsafe<Row[]>(`
SELECT
i.produ AS id_produto,
TRIM(p.descricao) AS descricao,
TRIM(p.unidade) AS unidade,
SUM(i.qtd)::numeric(15,3)::text AS qtd_total,
COUNT(DISTINCT ped.id_pedido)::text AS num_pedidos,
MAX(ped.data) AS ultima_compra,
(
SELECT i2.pruni::numeric(15,2)::text
FROM sig.peditens i2
JOIN sig.pedidos p2 ON p2.id_pedido = i2.id_pedido
WHERE i2.produ = i.produ
AND p2.clien = ped.clien
AND p2.id_empresa = ${idEmpresa}
AND p2.situa NOT IN (5)
ORDER BY p2.data DESC, p2.id_pedido DESC
LIMIT 1
) AS ultimo_preco
FROM sig.pedidos ped
JOIN sig.peditens i ON i.id_pedido = ped.id_pedido
JOIN gestao.produto p ON p.id_erp = i.produ
AND p.id_empresa = ${idEmpresaMatriz}
WHERE ped.clien = ${idCliente}
AND ped.id_empresa = ${idEmpresa}
AND ped.situa NOT IN (5)
GROUP BY i.produ, p.descricao, p.unidade, ped.clien
ORDER BY SUM(i.qtd) DESC
LIMIT ${limit}
`);
return rows.map((r) => ({
idProduto: Number(r.id_produto),
descricao: r.descricao,
unidade: r.unidade,
qtdTotal: parseFloat(r.qtd_total ?? '0'),
numPedidos: parseInt(r.num_pedidos ?? '0', 10),
ultimaCompra: (r.ultima_compra as Date).toISOString().slice(0, 10),
ultimoPreco: parseFloat(r.ultimo_preco ?? '0'),
}));
}
async getCtrSummary(idCliente: number): Promise<CtrSummary> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
interface Row {
qtd_aberto: string;
total_aberto: string;
qtd_vencido: string;
total_vencido: string;
maior_atraso_dias: string;
}
const rows = await prisma.$queryRawUnsafe<Row[]>(`
SELECT
COUNT(*)::text AS qtd_aberto,
COALESCE(SUM(saldo), 0)::numeric(15,2)::text AS total_aberto,
(COUNT(*) FILTER (WHERE dt_vencimento < CURRENT_DATE))::text AS qtd_vencido,
COALESCE(SUM(saldo) FILTER (WHERE dt_vencimento < CURRENT_DATE), 0)::numeric(15,2)::text AS total_vencido,
COALESCE(MAX(CURRENT_DATE - dt_vencimento) FILTER (WHERE dt_vencimento < CURRENT_DATE), 0)::text AS maior_atraso_dias
FROM sar.vw_ctr
WHERE id_cliente = ${idCliente}
AND situacao = 'A'
AND saldo > 0
`);
const r = rows[0];
return {
qtdAberto: parseInt(r?.qtd_aberto ?? '0', 10),
totalAberto: parseFloat(r?.total_aberto ?? '0'),
qtdVencido: parseInt(r?.qtd_vencido ?? '0', 10),
totalVencido: parseFloat(r?.total_vencido ?? '0'),
maiorAtrasoDias: parseInt(r?.maior_atraso_dias ?? '0', 10),
};
}
async findOne(idCliente: number): Promise<ClientDetail> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
@@ -388,6 +548,7 @@ export class ClientsService {
return {
idCliente: Number(r.id_cliente),
idEmpresa: Number(r.id_empresa),
nome: r.nome,
razao: r.razao,
@@ -413,4 +574,106 @@ export class ClientsService {
dtAtual: r.dt_atual,
};
}
async listCtrTitulos(idCliente: number, limit: number): Promise<CtrTitulo[]> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
type CtrRow = {
id_ctr: number;
numero: string;
prefixo: string;
dt_emissao: Date;
dt_vencimento: Date;
saldo: string;
};
const rows = await prisma.$queryRawUnsafe<CtrRow[]>(
`
SELECT
id_ctr,
TRIM(numero) AS numero,
TRIM(prefixo) AS prefixo,
dt_emissao,
dt_vencimento,
saldo::text AS saldo
FROM sar.vw_ctr
WHERE id_cliente = $1
AND id_empresa = 1
AND situacao = 'A'
AND saldo > 0
ORDER BY dt_vencimento ASC
LIMIT $2
`,
idCliente,
limit,
);
const toDate = (d: Date | string): string =>
d instanceof Date ? d.toISOString().slice(0, 10) : String(d).slice(0, 10);
return rows.map((r: CtrRow) => ({
idCtr: Number(r.id_ctr),
numero: r.numero,
prefixo: r.prefixo,
dtEmissao: toDate(r.dt_emissao),
dtVencimento: toDate(r.dt_vencimento),
saldo: Number(r.saldo),
}));
}
async listNotasFiscais(idCliente: number, limit: number): Promise<NotaFiscal[]> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
type NfRow = {
id_nf: number;
numero: number;
serie: string;
dt_emissao: Date;
vl_nf: string;
chave_acesso_nfe: string | null;
};
const rows = await prisma.$queryRawUnsafe<NfRow[]>(
`
SELECT id_nf, numero, serie, dt_emissao, vl_nf, chave_acesso_nfe
FROM (
SELECT DISTINCT ON (nf.id_nf)
nf.id_nf,
nf.numero,
TRIM(nf.serie) AS serie,
nf.dt_emissao,
nf.vl_nf::text AS vl_nf,
TRIM(nf.chave_acesso_nfe) AS chave_acesso_nfe
FROM gestao.nf nf
JOIN sig.pedidos p
ON p.numero = nf.num_entrega
AND p.tipo = 'E'
AND p.id_empresa IN (1, 9001)
WHERE p.clien = $1
AND nf.id_empresa = 1
AND nf.status = 'E'
AND TRIM(COALESCE(nf.chave_acesso_nfe, '')) != ''
ORDER BY nf.id_nf
) sub
ORDER BY dt_emissao DESC
LIMIT $2
`,
idCliente,
limit,
);
return rows.map((r: NfRow) => ({
idNf: Number(r.id_nf),
numero: Number(r.numero),
serie: r.serie?.trim() ?? '',
dtEmissao:
(r.dt_emissao instanceof Date
? r.dt_emissao.toISOString().split('T')[0]
: String(r.dt_emissao)) ?? '',
vlNf: Number(r.vl_nf),
chaveAcessoNfe: r.chave_acesso_nfe?.trim() || null,
}));
}
}

View File

@@ -35,16 +35,14 @@ interface RealizadoGrupoRow {
peso: string;
}
interface RepRow {
taxa_com: string;
permitir_flex: number; // 0 ou 1 (char do ERP convertido)
}
interface InativoRow {
interface NaoPositivadoRow {
id_cliente: number;
nome: string;
dt_ultima_compra: Date | null;
ultima_compra_valor: string | null;
razao: string | null;
dt_ultimo_pedido: string | null; // YYYY-MM-DD ou null
dias_sem_pedido: number | null;
comprou_antes: boolean;
whatsapp: string | null;
}
interface InativosPorRepRow {
@@ -92,24 +90,7 @@ export class DashboardService {
: grRows.reduce((a, m) => a + Number(m.valor), 0);
const metaDimensao = grRows.length > 0 ? ('grupo' as const) : ('global' as const);
// 2. Taxas do representante — fonte: gestao.vendedor (via vw_representantes)
const repRows = await prisma.$queryRawUnsafe<RepRow[]>(`
SELECT taxa_com::text, COALESCE(permitir_flex, 0) AS permitir_flex
FROM vw_representantes
WHERE codigo = ${codVendedor}
LIMIT 1
`);
const commissionRate = repRows[0] ? Number(repRows[0].taxa_com) : 3;
const permitirFlex = (repRows[0]?.permitir_flex ?? 0) === 1;
// 3. Taxa flex — fonte: sar.meta_representante (override SAR; default 1%)
const flexOverride = await prisma.metaRepresentante.findUnique({
where: { codVendedor_idEmpresa_ano_mes: { codVendedor, idEmpresa, ano: year, mes: month } },
select: { taxaFlex: true },
});
const flexRate = flexOverride ? Number(flexOverride.taxaFlex) : 1;
// 4. Atingido do mês — realizado = tudo menos Cancelado(5) e Pendente/não-transmitido(1).
// 2. Atingido do mês — realizado = tudo menos Cancelado(5) e Pendente/não-transmitido(1).
// Inclui Liberado(2), Enviado(3,6,92,95,200) e Faturado(4). Base: data do pedido.
const monthStartStr = monthStart.toISOString().slice(0, 10);
const monthEndStr = monthEnd.toISOString().slice(0, 10);
@@ -198,32 +179,55 @@ export class DashboardService {
const pct = targetAmount > 0 ? Math.round((atingido / targetAmount) * 100) : 0;
const falta = Math.max(0, targetAmount - atingido);
const fixa = Math.round(atingido * commissionRate) / 100;
const flex =
permitirFlex && targetAmount > 0 && atingido >= targetAmount
? Math.round(atingido * flexRate) / 100
: 0;
// 7. Clientes inativos — sem pedido no ERP há >30 dias
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
const inactiveClients = await prisma.$queryRawUnsafe<InativoRow[]>(`
// Clientes não positivados no mês: ativos do representante sem pedido desde monthStart.
// Junta o último pedido histórico (ERP+SAR) e o primeiro whatsapp ativo dos contatos.
const naoPositivadosRows = await prisma.$queryRawUnsafe<NaoPositivadoRow[]>(`
WITH ultimo_pedido AS (
SELECT id_cliente,
MAX(dt_pedido) AS dt_max
FROM (
SELECT id_cliente, dt_pedido FROM vw_pedidos_erp
WHERE situa NOT IN (5) AND id_empresa = ${idEmpresa}
UNION ALL
SELECT id_cliente, dt_pedido FROM sar.pedidos
WHERE situa != 3 AND id_empresa = ${idEmpresa}
) t
GROUP BY id_cliente
),
pedido_mes AS (
SELECT DISTINCT id_cliente FROM (
SELECT id_cliente FROM vw_pedidos_erp
WHERE situa NOT IN (5) AND id_empresa = ${idEmpresa}
AND dt_pedido >= '${monthStartStr}'
UNION
SELECT id_cliente FROM sar.pedidos
WHERE situa != 3 AND id_empresa = ${idEmpresa}
AND dt_pedido >= '${monthStartStr}'
) t
)
SELECT
c.id_cliente,
c.nome,
MAX(p.dt_pedido) AS dt_ultima_compra,
MAX(p.total)::text AS ultima_compra_valor
TRIM(c.nome) AS nome,
TRIM(c.razao) AS razao,
TO_CHAR(up.dt_max, 'YYYY-MM-DD') AS dt_ultimo_pedido,
(CURRENT_DATE - up.dt_max::date) AS dias_sem_pedido,
(up.dt_max IS NOT NULL) AS comprou_antes,
ct.whatsapp
FROM vw_clientes c
LEFT JOIN vw_pedidos_erp p
ON p.id_cliente = c.id_cliente
AND p.id_empresa = ${idEmpresa}
AND p.situa != 5
LEFT JOIN ultimo_pedido up ON up.id_cliente = c.id_cliente
LEFT JOIN pedido_mes pm ON pm.id_cliente = c.id_cliente
LEFT JOIN LATERAL (
SELECT TRIM(whatsapp) AS whatsapp
FROM sar.vw_contatos
WHERE id_corrent = c.id_cliente
AND whatsapp IS NOT NULL AND TRIM(whatsapp) != ''
AND ativo = 1
LIMIT 1
) ct ON true
WHERE c.cod_vendedor = ${codVendedor}
AND c.ativo = 1
GROUP BY c.id_cliente, c.nome
HAVING MAX(p.dt_pedido) IS NULL
OR MAX(p.dt_pedido) < '${thirtyDaysAgo.toISOString().slice(0, 10)}'
ORDER BY dt_ultima_compra ASC NULLS FIRST
LIMIT 10
AND pm.id_cliente IS NULL
ORDER BY up.dt_max ASC NULLS FIRST
`);
// Metas por grupo: junta meta (GR) com realizado (itens), por cod_grupo.
@@ -257,11 +261,20 @@ export class DashboardService {
})
.sort((a, b) => b.valorMeta - a.valorMeta);
const naoPositivados = naoPositivadosRows.map((c) => ({
idCliente: Number(c.id_cliente),
nome: c.nome,
razao: c.razao ?? null,
diasSemPedido: c.dias_sem_pedido != null ? Number(c.dias_sem_pedido) : 999,
ultimoPedido: c.dt_ultimo_pedido ?? null,
comprouAntes: Boolean(c.comprou_antes),
whatsapp: c.whatsapp ?? null,
}));
return {
meta: { atingido, total: targetAmount, pct, falta },
metaDimensao,
metasPorGrupo,
comissao: { fixa, flex, total: fixa + flex },
pedidosMes,
pedidosRecentes: recentRows.map((o) => ({
id: `erp-${o.id_pedido}`,
@@ -281,14 +294,8 @@ export class DashboardService {
createdAt: new Date(o.dt_pedido).toISOString(),
fonte: 'erp' as const,
})),
clientesInativos: inactiveClients.map((c) => ({
idCliente: Number(c.id_cliente),
nome: c.nome,
diasSemCompra: c.dt_ultima_compra
? Math.floor((now.getTime() - c.dt_ultima_compra.getTime()) / 86_400_000)
: 999,
ultimaCompraValor: c.ultima_compra_valor,
})),
naoPositivados,
totalNaoPositivados: naoPositivados.length,
syncedAt: now.toISOString(),
};
}

View File

@@ -0,0 +1,33 @@
import { Controller, Get, Query } from '@nestjs/common';
import { createZodDto } from 'nestjs-zod';
import {
ReportMetaQuerySchema,
ReportAbcQuerySchema,
type ReportMetaResponse,
type ReportCarteiraResponse,
type ReportAbcResponse,
} from '@sar/api-interface';
import { ReportsService } from './reports.service';
class ReportMetaQueryDto extends createZodDto(ReportMetaQuerySchema) {}
class ReportAbcQueryDto extends createZodDto(ReportAbcQuerySchema) {}
@Controller({ path: 'reports' })
export class ReportsController {
constructor(private readonly reports: ReportsService) {}
@Get('meta')
meta(@Query() query: ReportMetaQueryDto): Promise<ReportMetaResponse> {
return this.reports.metaVsRealizado(ReportMetaQuerySchema.parse(query));
}
@Get('carteira')
carteira(): Promise<ReportCarteiraResponse> {
return this.reports.carteira();
}
@Get('abc')
abc(@Query() query: ReportAbcQueryDto): Promise<ReportAbcResponse> {
return this.reports.abcProdutos(ReportAbcQuerySchema.parse(query));
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { ReportsController } from './reports.controller';
import { ReportsService } from './reports.service';
@Module({
controllers: [ReportsController],
providers: [ReportsService],
})
export class ReportsModule {}

View File

@@ -0,0 +1,223 @@
import { Injectable } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import type {
ReportMetaQuery,
ReportMetaResponse,
ReportCarteiraResponse,
ReportAbcQuery,
ReportAbcResponse,
AbcProduto,
} from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types';
function d(v: unknown): string {
return v != null ? String(v) : '0';
}
function n(v: unknown): number {
return v != null ? Number(v) : 0;
}
@Injectable()
export class ReportsService {
constructor(private readonly cls: ClsService<WorkspaceClsStore>) {}
async metaVsRealizado(query: ReportMetaQuery): Promise<ReportMetaResponse> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa');
const userId = this.cls.get('userId');
const codVendedor = userId ? parseInt(userId, 10) : 0;
const { mes, ano } = query;
interface RealizadoRow {
realizado: unknown;
comissao: unknown;
total_pedidos: unknown;
}
const [realizadoRows, metaRows] = await Promise.all([
prisma.$queryRawUnsafe<RealizadoRow[]>(`
SELECT
COALESCE(SUM(total), 0) AS realizado,
COALESCE(SUM(comissao), 0) AS comissao,
COUNT(id) AS total_pedidos
FROM sar.pedidos
WHERE id_empresa = ${idEmpresa}
AND cod_vendedor = ${codVendedor}
AND situa IN (2, 4)
AND EXTRACT(MONTH FROM dt_pedido) = ${mes}
AND EXTRACT(YEAR FROM dt_pedido) = ${ano}
`),
prisma.metaRepresentante.findUnique({
where: { codVendedor_idEmpresa_ano_mes: { codVendedor, idEmpresa, ano, mes } },
}),
]);
const realizado = n(realizadoRows[0]?.realizado);
const comissao = n(realizadoRows[0]?.comissao);
const totalPedidos = n(realizadoRows[0]?.total_pedidos);
const meta = metaRows ? Number(metaRows.metaValor) : 0;
const percentual = meta > 0 ? (realizado / meta) * 100 : 0;
return {
mes,
ano,
realizado: realizado.toFixed(2),
meta: meta.toFixed(2),
percentual: percentual.toFixed(2),
comissaoEstimada: comissao.toFixed(2),
totalPedidos,
};
}
async carteira(): Promise<ReportCarteiraResponse> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa');
const userId = this.cls.get('userId');
const codVendedor = userId ? parseInt(userId, 10) : 0;
interface CarteiraRow {
id_cliente: unknown;
nome: string | null;
razao: string | null;
ativo: unknown;
ultimo_pedido: string | null;
dias_sem_pedido: unknown;
total_pedidos: unknown;
ticket_medio: 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)
ELSE 0
END AS ticket_medio
FROM sar.pedidos
WHERE id_empresa = ${idEmpresa}
AND cod_vendedor = ${codVendedor}
AND situa <> 3
GROUP BY id_cliente
)
SELECT
c.id_cliente,
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
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
`);
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),
}));
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 };
}
async abcProdutos(query: ReportAbcQuery): Promise<ReportAbcResponse> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa');
const userId = this.cls.get('userId');
const codVendedor = userId ? parseInt(userId, 10) : 0;
const { mes, ano } = query;
const mesFilter = mes != null ? `AND EXTRACT(MONTH FROM p.dt_pedido) = ${mes}` : '';
const anoFilter = ano != null ? `AND EXTRACT(YEAR FROM p.dt_pedido) = ${ano}` : '';
interface AbcRow {
cod_produto: string | null;
desc_produto: string | null;
total_faturado: unknown;
qtd_vendida: unknown;
desconto_medio: unknown;
comissao_total: unknown;
}
const rows = await prisma.$queryRawUnsafe<AbcRow[]>(`
SELECT
pi.cod_produto,
pi.desc_produto,
SUM(pi.total) AS total_faturado,
SUM(pi.qtd) AS qtd_vendida,
AVG(pi.desconto_perc) AS desconto_medio,
SUM(pi.comissao) AS comissao_total
FROM sar.pedido_itens pi
JOIN sar.pedidos p ON p.id = pi.id_pedido
WHERE p.id_empresa = ${idEmpresa}
AND p.cod_vendedor = ${codVendedor}
AND p.situa NOT IN (0, 3)
${mesFilter}
${anoFilter}
GROUP BY pi.cod_produto, pi.desc_produto
ORDER BY total_faturado DESC
`);
const totalGeral = rows.reduce((s, r) => s + n(r.total_faturado), 0);
let acumulado = 0;
const data: AbcProduto[] = rows.map((r) => {
const total = n(r.total_faturado);
const participacao = totalGeral > 0 ? (total / totalGeral) * 100 : 0;
acumulado += participacao;
const curva: 'A' | 'B' | 'C' = acumulado <= 80 ? 'A' : acumulado <= 95 ? 'B' : 'C';
return {
codProduto: r.cod_produto ?? null,
descProduto: r.desc_produto ?? '(sem descrição)',
totalFaturado: total.toFixed(2),
qtdVendida: d(r.qtd_vendida),
participacao: participacao.toFixed(2),
curva,
descontoMedio: n(r.desconto_medio).toFixed(2),
comissaoTotal: n(r.comissao_total).toFixed(2),
};
});
const meses = [
'Jan',
'Fev',
'Mar',
'Abr',
'Mai',
'Jun',
'Jul',
'Ago',
'Set',
'Out',
'Nov',
'Dez',
];
const periodo =
mes != null && ano != null
? `${meses[mes - 1]}/${ano}`
: ano != null
? String(ano)
: 'Acumulado';
return { data, totalFaturado: totalGeral.toFixed(2), periodo };
}
}