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:
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