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:
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
33
apps/api/src/app/reports/reports.controller.ts
Normal file
33
apps/api/src/app/reports/reports.controller.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
9
apps/api/src/app/reports/reports.module.ts
Normal file
9
apps/api/src/app/reports/reports.module.ts
Normal 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 {}
|
||||
223
apps/api/src/app/reports/reports.service.ts
Normal file
223
apps/api/src/app/reports/reports.service.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user