diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index 13dcb88..fd581d1 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -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 }, diff --git a/apps/api/src/app/clients/clients.controller.ts b/apps/api/src/app/clients/clients.controller.ts index fb19822..346c470 100644 --- a/apps/api/src/app/clients/clients.controller.ts +++ b/apps/api/src/app/clients/clients.controller.ts @@ -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 { + 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 { + 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 { + 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 { + return this.clients.getCtrSummary(id); + } + + @Get(':id/ctr-list') + listCtrTitulos( + @Param('id', ParseIntPipe) id: number, + @Query('limit') limit?: string, + ): Promise { + 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 { return this.clients.listContacts(id); diff --git a/apps/api/src/app/clients/clients.service.ts b/apps/api/src/app/clients/clients.service.ts index 8e689ff..5b06f88 100644 --- a/apps/api/src/app/clients/clients.service.ts +++ b/apps/api/src/app/clients/clients.service.ts @@ -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 { + 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(` + 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 { + 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(` + 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 { + 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(` + 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 { 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 { + 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( + ` + 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 { + 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( + ` + 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, + })); + } } diff --git a/apps/api/src/app/dashboard/dashboard.service.ts b/apps/api/src/app/dashboard/dashboard.service.ts index 55546d1..6485671 100644 --- a/apps/api/src/app/dashboard/dashboard.service.ts +++ b/apps/api/src/app/dashboard/dashboard.service.ts @@ -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(` - 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(` + // 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(` + 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(), }; } diff --git a/apps/api/src/app/reports/reports.controller.ts b/apps/api/src/app/reports/reports.controller.ts new file mode 100644 index 0000000..91cde81 --- /dev/null +++ b/apps/api/src/app/reports/reports.controller.ts @@ -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 { + return this.reports.metaVsRealizado(ReportMetaQuerySchema.parse(query)); + } + + @Get('carteira') + carteira(): Promise { + return this.reports.carteira(); + } + + @Get('abc') + abc(@Query() query: ReportAbcQueryDto): Promise { + return this.reports.abcProdutos(ReportAbcQuerySchema.parse(query)); + } +} diff --git a/apps/api/src/app/reports/reports.module.ts b/apps/api/src/app/reports/reports.module.ts new file mode 100644 index 0000000..73377e6 --- /dev/null +++ b/apps/api/src/app/reports/reports.module.ts @@ -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 {} diff --git a/apps/api/src/app/reports/reports.service.ts b/apps/api/src/app/reports/reports.service.ts new file mode 100644 index 0000000..2fb0589 --- /dev/null +++ b/apps/api/src/app/reports/reports.service.ts @@ -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) {} + + async metaVsRealizado(query: ReportMetaQuery): Promise { + 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(` + 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 { + 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(` + 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 { + 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(` + 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 }; + } +} diff --git a/apps/web/src/cockpits/rep/ClientDetailPage.tsx b/apps/web/src/cockpits/rep/ClientDetailPage.tsx index 46922d2..1b4bfdf 100644 --- a/apps/web/src/cockpits/rep/ClientDetailPage.tsx +++ b/apps/web/src/cockpits/rep/ClientDetailPage.tsx @@ -1,13 +1,35 @@ -import { Button, Descriptions, Tag, Table, Typography, Spin, Alert, Space, Divider } from 'antd'; +import { + Button, + Descriptions, + Tag, + Table, + Typography, + Spin, + Alert, + Space, + Divider, + Modal, + Tooltip, + message, + Badge, +} from 'antd'; +import { useState } from 'react'; import type { TableColumnsType } from 'antd'; +import { CopyOutlined, UserAddOutlined } from '@ant-design/icons'; import { Link, useNavigate, useParams } from '@tanstack/react-router'; -import type { PedidoSummary } from '@sar/api-interface'; +import type { CtrTitulo, NotaFiscal, PedidoSummary, TopProduto } from '@sar/api-interface'; import { SITUA_LABEL } from '@sar/api-interface'; -import { useClientDetail } from '../../lib/queries/clients'; -import { useClientOrders } from '../../lib/queries/orders'; +import { + useClientDetail, + useClientCtr, + useClientCtrList, + useClientNotasFiscais, + useClientOrdersHistory, + useClientTopProdutos, +} from '../../lib/queries/clients'; import { ClientContacts } from '../../components/contacts/ClientContacts'; -const { Title } = Typography; +const { Title, Text } = Typography; const ACTIVITY_COLOR: Record = { active: 'success', @@ -20,60 +42,182 @@ const ACTIVITY_LABEL: Record = { inactive: 'Inativo', }; -const orderColumns: TableColumnsType = [ - { - title: 'Nº', - dataIndex: 'numPedSar', - width: 120, - render: (num: string, row: PedidoSummary) => ( - - {num} - - ), - }, - { - title: 'Status', - dataIndex: 'situa', - width: 140, - render: (s: number) => { - const colorMap: Record = { - 1: 'warning', - 2: 'processing', - 3: 'error', - 4: 'success', - }; - return {SITUA_LABEL[s] ?? String(s)}; +const SITUA_COLOR: Record = { + 0: 'default', + 1: 'warning', + 2: 'processing', + 3: 'error', + 4: 'success', +}; + +function orderColumns(withFonte: boolean): TableColumnsType { + const cols: TableColumnsType = [ + { + title: 'Nº Pedido', + dataIndex: 'numPedSar', + render: (num: string, row: PedidoSummary) => ( + + {num} + + ), }, - }, - { - title: 'Total', - dataIndex: 'total', - width: 130, - align: 'right', - render: (v: string) => - Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }), - }, - { - title: 'Data', - dataIndex: 'dtPedido', - width: 130, - render: (v: string) => new Date(v).toLocaleDateString('pt-BR'), - }, -]; + { + title: 'Status', + dataIndex: 'situa', + width: 130, + render: (s: number) => ( + {SITUA_LABEL[s] ?? String(s)} + ), + }, + { + title: 'Total', + dataIndex: 'total', + width: 130, + align: 'right' as const, + render: (v: string) => + Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }), + }, + { + title: 'Data', + dataIndex: 'dtPedido', + width: 110, + render: (v: string) => new Date(v).toLocaleDateString('pt-BR'), + }, + ]; + + if (withFonte) { + cols.push({ + title: 'Origem', + dataIndex: 'fonte', + width: 70, + render: (f: string) => ( + + {f === 'sar' ? 'SAR' : 'ERP'} + + ), + }); + } + + return cols; +} export function ClientDetailPage() { const { id } = useParams({ from: '/clientes/$id' }); const idNum = Number(id); const navigate = useNavigate(); + const [addContactOpen, setAddContactOpen] = useState(false); + const [ordersModalOpen, setOrdersModalOpen] = useState(false); + const [produtosModalOpen, setProdutosModalOpen] = useState(false); + const [notasModalOpen, setNotasModalOpen] = useState(false); + const { data: client, isLoading: clientLoading, error: clientError } = useClientDetail(idNum); - const { data: orders, isLoading: ordersLoading } = useClientOrders(idNum); + const { data: ctr } = useClientCtr(idNum); + const { data: ctrList = [], isLoading: ctrListLoading } = useClientCtrList(idNum, 60); + const { data: orders = [], isLoading: ordersLoading } = useClientOrdersHistory(idNum, 50); + const { data: topProdutos = [], isLoading: produtosLoading } = useClientTopProdutos(idNum, 30); + const { data: notas = [], isLoading: notasLoading } = useClientNotasFiscais(idNum, 30); + + const [msgApi, msgCtx] = message.useMessage(); if (clientLoading) return ; if (clientError || !client) - return ; + return ; + + const today: string = new Date().toISOString().slice(0, 10); + const ordersPreview = orders.slice(0, 5); + const produtosPreview = topProdutos.slice(0, 5); + const notasPreview = notas.slice(0, 5); + const ctrPreview = ctrList.slice(0, 8); + + const notaColumns: TableColumnsType = [ + { + title: 'NF', + dataIndex: 'numero', + width: 90, + render: (v: number, r: NotaFiscal) => `${v}-${r.serie}`, + }, + { + title: 'Emissão', + dataIndex: 'dtEmissao', + width: 100, + render: (v: string) => new Date(v + 'T12:00:00').toLocaleDateString('pt-BR'), + }, + { + title: 'Valor', + dataIndex: 'vlNf', + width: 130, + align: 'right' as const, + render: (v: number) => v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }), + }, + { + title: 'Chave NF-e', + dataIndex: 'chaveAcessoNfe', + render: (chave: string | null) => + chave ? ( + + + {chave} + + + + {c.whatsapp && ( + + )} + {expandido && total > PAGE && ( + + )} + + )} + + ); +} + +// ─── RepPainel ──────────────────────────────────────────────────────────────── + export function RepPainel() { const { data, isLoading } = useRepDashboard(); const { data: user } = useCurrentUser(); @@ -140,13 +321,10 @@ export function RepPainel() { - + - - - - + @@ -157,10 +335,10 @@ export function RepPainel() { const { meta, metasPorGrupo = [], - comissao, pedidosMes, pedidosRecentes = [], - clientesInativos = [], + naoPositivados = [], + totalNaoPositivados = 0, syncedAt, } = data; @@ -173,21 +351,21 @@ export function RepPainel() { {today()} - {clientesInativos.length > 0 && ( + {totalNaoPositivados > 0 && ( <> {' '} ·{' '} - {clientesInativos.length} clientes inativos + {totalNaoPositivados} clientes sem pedido no mês )} - {/* Linha 1 — Meta + KPIs */} + {/* Linha 1 — Meta + Pedidos do mês */} - + @@ -228,8 +406,8 @@ export function RepPainel() { - - + + PEDIDOS NO MÊS @@ -238,32 +416,14 @@ export function RepPainel() { {pedidosMes} - últimos 30 dias + mês corrente - - - - - - COMISSÃO ACUMULADA - - - {fmt(comissao.total)} - - {comissao.flex > 0 && ( - - FLEX: {fmt(comissao.flex)} - - )} - - - - {/* Metas por Grupo — acompanhamento multi-medida do mês */} + {/* Metas por Grupo */} {metasPorGrupo.length > 0 && ( )} - {/* Linha 2 — Clientes inativos + Pedidos recentes */} + {/* Linha 2 — Não positivados + Pedidos recentes */} - - - - Clientes esfriando - - } - extra={ - clientesInativos.length > 0 ? ( - {clientesInativos.length} clientes - ) : null - } - > - {clientesInativos.length === 0 ? ( - Nenhum cliente inativo. Ótimo trabalho! - ) : ( - - {clientesInativos.map((c) => ( - 60 ? '#fff7e6' : 'var(--bg-surface-alt)', - }} - > - - - {c.nome} - - {c.ultimaCompraValor && ( - - Última compra:{' '} - - {Number(c.ultimaCompraValor).toLocaleString('pt-BR', { - style: 'currency', - currency: 'BRL', - })} - - - )} - - 60 ? 'orange' : 'default'} - className="tabular-nums" - > - {c.diasSemCompra >= 999 ? 'nunca comprou' : `${c.diasSemCompra}d`} - - - ))} - - )} - + + - + diff --git a/apps/web/src/components/contacts/ClientContacts.tsx b/apps/web/src/components/contacts/ClientContacts.tsx index 833bd2f..09495ce 100644 --- a/apps/web/src/components/contacts/ClientContacts.tsx +++ b/apps/web/src/components/contacts/ClientContacts.tsx @@ -1,20 +1,20 @@ -import { useState } from 'react'; import { App, - Button, + Card, + Col, DatePicker, Divider, Form, Input, Modal, + Row, Space, - Table, + Spin, Tag, Tooltip, Typography, } from 'antd'; -import type { TableColumnsType } from 'antd'; -import { MailOutlined, PhoneOutlined, PlusOutlined, WhatsAppOutlined } from '@ant-design/icons'; +import { MailOutlined, PhoneOutlined, WhatsAppOutlined } from '@ant-design/icons'; import type { Contato } from '@sar/api-interface'; import { useClientContacts, useCreateContato } from '../../lib/queries/clients'; @@ -26,7 +26,7 @@ function ContactActions({ contato }: { contato: Contato }) { const mail = contato.email?.trim(); return ( - + {wa && ( @@ -51,7 +51,7 @@ function ContactActions({ contato }: { contato: Contato }) { } color="default" style={{ margin: 0 }}> - {mail.length > 24 ? mail.slice(0, 22) + '…' : mail} + {mail.length > 22 ? mail.slice(0, 20) + '…' : mail} @@ -60,45 +60,18 @@ function ContactActions({ contato }: { contato: Contato }) { ); } -const columns: TableColumnsType = [ - { - title: 'Nome', - dataIndex: 'nome', - render: (nome: string, r: Contato) => ( -
- - {nome} - - {(r.cargo || r.departamento) && ( -
- - {[r.cargo, r.departamento].filter(Boolean).join(' · ')} - -
- )} -
- ), - }, - { - title: 'Contato', - key: 'contato', - render: (_: unknown, r: Contato) => , - }, - { - title: 'Aniversário', - dataIndex: 'dtAniversario', - width: 120, - render: (v: string | null) => (v ? new Date(v + 'T12:00:00').toLocaleDateString('pt-BR') : '—'), - }, -]; - interface Props { idCliente: number; + modalOpen?: boolean; + onModalClose?: () => void; } -export function ClientContacts({ idCliente }: Props) { +export function ClientContacts({ idCliente, modalOpen = false, onModalClose }: Props) { const { message } = App.useApp(); - const [open, setOpen] = useState(false); + const open = modalOpen; + const setOpen = (v: boolean) => { + if (!v) onModalClose?.(); + }; const [form] = Form.useForm(); const { data: contacts = [], isLoading } = useClientContacts(idCliente); @@ -134,38 +107,53 @@ export function ClientContacts({ idCliente }: Props) { return ( <> -
- - Contatos - - -
+ + Contatos + - - rowKey="idContato" - columns={columns} - dataSource={contacts} - loading={isLoading} - pagination={false} - size="small" - locale={{ emptyText: 'Nenhum contato cadastrado.' }} - style={{ marginBottom: 24 }} - /> + {isLoading ? ( + + ) : contacts.length === 0 ? ( + + Nenhum contato cadastrado. + + ) : ( +
+ {contacts.map((c: Contato) => ( + + + {c.nome} + + {(c.cargo || c.departamento) && ( + + {[c.cargo, c.departamento].filter(Boolean).join(' · ')} + + )} + + {c.dtAniversario && ( + + Aniversário: {new Date(c.dtAniversario + 'T12:00:00').toLocaleDateString('pt-BR')} + + )} + + ))} +
+ )}
@@ -190,40 +178,63 @@ export function ClientContacts({ idCliente }: Props) { - - - + + + + + + + + + + + + - - - + + + + } + /> + + + + + + + + - - } - /> - + + + + + + + + + + + + - - - - - - - - - - - - - - - + + + + + + + - +
diff --git a/apps/web/src/components/layout/AppShell.tsx b/apps/web/src/components/layout/AppShell.tsx index 18e0fea..c826ec8 100644 --- a/apps/web/src/components/layout/AppShell.tsx +++ b/apps/web/src/components/layout/AppShell.tsx @@ -23,7 +23,7 @@ export function AppShell({ children }: AppShellProps) { useOfflineSync(); return ( - + {!isOnline && ( )} setSidebarOpen((v) => !v)} /> - +
{children} diff --git a/apps/web/src/components/layout/Sidebar.tsx b/apps/web/src/components/layout/Sidebar.tsx index 5ff1a02..037a75f 100644 --- a/apps/web/src/components/layout/Sidebar.tsx +++ b/apps/web/src/components/layout/Sidebar.tsx @@ -6,11 +6,8 @@ import { faClipboardList, faClipboardCheck, faFunnelDollar, - faCalendarDays, faUsers, faBoxesStacked, - faGear, - faPercent, faFileInvoiceDollar, } from '@fortawesome/free-solid-svg-icons'; import type { ItemType } from 'antd/es/menu/interface'; @@ -28,11 +25,6 @@ export function Sidebar() { icon: , label: 'Painel', }, - { - key: '/agenda', - icon: , - label: 'Agenda e Rotas', - }, { key: '/clientes', icon: , @@ -58,24 +50,11 @@ export function Sidebar() { icon: , label: 'Consulta ERP', }, - { - key: '/comissao', - icon: , - label: 'Comissão / FLEX', - }, - { - type: 'divider', - }, { key: '/relatorios', icon: , label: 'Relatórios', }, - { - key: '/configuracoes', - icon: , - label: 'Configurações', - }, ]; return ( diff --git a/apps/web/src/lib/queries/clients.ts b/apps/web/src/lib/queries/clients.ts index 771654e..dd47f64 100644 --- a/apps/web/src/lib/queries/clients.ts +++ b/apps/web/src/lib/queries/clients.ts @@ -5,14 +5,24 @@ import { ClientNovoResultSchema, ContatoResultSchema, ContatoSchema, + CtrSummarySchema, + CtrTituloSchema, + NotaFiscalSchema, + PedidoSummarySchema, + TopProdutoSchema, type ClientDetail, type ClientListQuery, type ClientListResponse, type ClientNovoResult, type Contato, type ContatoResult, + type CtrSummary, + type CtrTitulo, type CreateClientNovo, type CreateContato, + type NotaFiscal, + type PedidoSummary, + type TopProduto, } from '@sar/api-interface'; import { z } from 'zod'; import { apiFetch } from '../api-client'; @@ -52,6 +62,61 @@ export function useClientDetail(id: number | string | undefined) { }); } +export function useClientTopProdutos(idCliente: number | undefined, limit = 30) { + return useQuery({ + queryKey: ['clients', 'top-produtos', idCliente, limit], + queryFn: async () => { + const res = await apiFetch(`/clients/${idCliente}/top-produtos?limit=${limit}`); + return z.array(TopProdutoSchema).parse(res); + }, + enabled: !!idCliente, + }); +} + +export function useClientOrdersHistory(idCliente: number | undefined, limit = 50) { + return useQuery({ + queryKey: ['clients', 'orders-history', idCliente, limit], + queryFn: async () => { + const res = await apiFetch(`/clients/${idCliente}/orders-history?limit=${limit}`); + return z.array(PedidoSummarySchema).parse(res); + }, + enabled: !!idCliente, + }); +} + +export function useClientCtrList(idCliente: number | undefined, limit = 60) { + return useQuery({ + queryKey: ['clients', 'ctr-list', idCliente, limit], + queryFn: async () => { + const res = await apiFetch(`/clients/${idCliente}/ctr-list?limit=${limit}`); + return z.array(CtrTituloSchema).parse(res); + }, + enabled: !!idCliente, + }); +} + +export function useClientNotasFiscais(idCliente: number | undefined, limit = 30) { + return useQuery({ + queryKey: ['clients', 'notas', idCliente, limit], + queryFn: async () => { + const res = await apiFetch(`/clients/${idCliente}/notas?limit=${limit}`); + return z.array(NotaFiscalSchema).parse(res); + }, + enabled: !!idCliente, + }); +} + +export function useClientCtr(idCliente: number | undefined) { + return useQuery({ + queryKey: ['clients', 'ctr', idCliente], + queryFn: async () => { + const res = await apiFetch(`/clients/${idCliente}/ctr`); + return CtrSummarySchema.parse(res); + }, + enabled: !!idCliente, + }); +} + export function useClientContacts(idCliente: number | undefined) { return useQuery({ queryKey: ['clients', 'contacts', idCliente], diff --git a/apps/web/src/lib/queries/reports.ts b/apps/web/src/lib/queries/reports.ts new file mode 100644 index 0000000..143db95 --- /dev/null +++ b/apps/web/src/lib/queries/reports.ts @@ -0,0 +1,37 @@ +import { useQuery } from '@tanstack/react-query'; +import { + ReportMetaResponseSchema, + ReportCarteiraResponseSchema, + ReportAbcResponseSchema, + type ReportMetaQuery, + type ReportAbcQuery, + type ReportMetaResponse, + type ReportCarteiraResponse, + type ReportAbcResponse, +} from '@sar/api-interface'; +import { apiFetch } from '../api-client'; + +export function useReportMeta(params: ReportMetaQuery) { + const qs = new URLSearchParams({ mes: String(params.mes), ano: String(params.ano) }); + return useQuery({ + queryKey: ['reports', 'meta', params], + queryFn: async () => ReportMetaResponseSchema.parse(await apiFetch(`/reports/meta?${qs}`)), + }); +} + +export function useReportCarteira() { + return useQuery({ + queryKey: ['reports', 'carteira'], + queryFn: async () => ReportCarteiraResponseSchema.parse(await apiFetch('/reports/carteira')), + }); +} + +export function useReportAbc(params: ReportAbcQuery) { + const qs = new URLSearchParams(); + if (params.mes != null) qs.set('mes', String(params.mes)); + if (params.ano != null) qs.set('ano', String(params.ano)); + return useQuery({ + queryKey: ['reports', 'abc', params], + queryFn: async () => ReportAbcResponseSchema.parse(await apiFetch(`/reports/abc?${qs}`)), + }); +} diff --git a/apps/web/src/lib/router.tsx b/apps/web/src/lib/router.tsx index 5863129..8319396 100644 --- a/apps/web/src/lib/router.tsx +++ b/apps/web/src/lib/router.tsx @@ -17,6 +17,7 @@ import { NewClientPage } from '../cockpits/rep/NewClientPage'; import { NewOrderPage } from '../cockpits/rep/NewOrderPage'; import { CatalogPage } from '../cockpits/rep/CatalogPage'; import { OrdersErpPage } from '../cockpits/rep/OrdersErpPage'; +import { RelatoriosPage } from '../cockpits/rep/RelatoriosPage'; import { ApprovalQueuePage } from '../cockpits/supervisor/ApprovalQueuePage'; import { SupervisorPainel } from '../cockpits/supervisor/SupervisorPainel'; import { authStore } from './auth-store'; @@ -125,6 +126,12 @@ const pedidosErpRoute = createRoute({ component: OrdersErpPage, }); +const relatoriosRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/relatorios', + component: RelatoriosPage, +}); + const aprovacoes = createRoute({ getParentRoute: () => rootRoute, path: '/aprovacoes', @@ -143,6 +150,7 @@ const routeTree = rootRoute.addChildren([ pedidoPrintRoute, catalogoRoute, pedidosErpRoute, + relatoriosRoute, aprovacoes, ]); diff --git a/docs/plano-migracao-modelo-canonico.md b/docs/plano-migracao-modelo-canonico.md new file mode 100644 index 0000000..ce39b84 --- /dev/null +++ b/docs/plano-migracao-modelo-canonico.md @@ -0,0 +1,265 @@ +# Plano de Migração — Modelo de Dados Canônico SAR + +**Data:** 2026-06-22 +**Autor:** Julian (Product Owner SAR) +**Status:** Proposta para avaliação + +--- + +## 1. Contexto e Problema + +### Situação atual + +O SAR foi iniciado consultando diretamente as **views do ERP JCS** (`vw_clientes`, `vw_pedidos_erp`, `vw_produtos`, etc.) que existem dentro do banco de dados do cliente (`libreplast@192.168.0.43`). Isso funcionou para o cliente piloto, mas cria uma dependência estrutural que inviabiliza a expansão do produto. + +### Problema identificado + +| Sintoma | Consequência | +|---|---| +| SAR acessa views do ERP via SQL raw | Cada novo ERP exige reescrever queries | +| Schema `sar` vive *dentro* do banco ERP | Sem isolamento; SAR depende de acesso ao servidor do cliente | +| Views são específicas do ERP JCS | ERP diferente = produto diferente | +| Sem camada de abstração | Impossível oferecer o SAR como SaaS multi-tenant real | + +### Diagnóstico técnico (levantamento 2026-06-22) + +``` +Módulos 100% acoplados ao ERP: auth, catalog, clients +Módulos parcialmente acoplados: orders (leitura), dashboard +Módulos já independentes: notifications, workspace +Views ERP consumidas: 11 views + 1 tabela direta +Tabelas próprias SAR existentes: 6 models Prisma +``` + +--- + +## 2. Solução Proposta + +### Modelo Canônico + Connectors + +``` +┌─────────────────────────────────────────────────────┐ +│ APP SAR │ +│ (frontend + API NestJS) │ +│ Lê APENAS tabelas próprias do SAR │ +└──────────────────────┬──────────────────────────────┘ + │ Prisma ORM + ▼ +┌─────────────────────────────────────────────────────┐ +│ Banco de Dados SAR │ +│ (PostgreSQL isolado por workspace) │ +│ tabelas canônicas: clientes, produtos, pedidos... │ +└──────────┬──────────────────────┬───────────────────┘ + │ │ + ▼ ▼ +┌──────────────────┐ ┌──────────────────────────────┐ +│ Connector │ │ Connector │ +│ ERP JCS │ │ ERP X (futuro) │ +│ (PostgreSQL) │ │ (REST API / CSV / outro) │ +└──────────────────┘ └──────────────────────────────┘ +``` + +**Princípio:** O ERP é uma **fonte de dados**, não o sistema de registro do SAR. Cada cliente tem um connector específico para seu ERP. O SAR opera exclusivamente sobre seu próprio modelo de dados. + +### O que não muda + +Os dados transacionais do SAR já estão corretos e **permanecem sem alteração**: +- `pedidos`, `pedido_itens`, `historico_pedido` +- `alcada_desconto`, `meta_representante` +- `push_subscription` + +--- + +## 3. Tabelas Canônicas a Criar + +### 3.1 Entidades mestres (sincronizadas do ERP) + +| Tabela SAR | Substitui | Campos principais | +|---|---|---| +| `sar.clientes` | `vw_clientes` | codigo, razao_social, nome_fantasia, cnpj, cidade, uf, ativo, representante_codigo | +| `sar.representantes` | `vw_representantes` | codigo, nome, email, senha_hash, tipo (rep/supervisor), ativo | +| `sar.produtos` | `vw_produtos` | codigo, descricao, unidade, ncm, ativo | +| `sar.estoques` | `vw_estoque` | produto_codigo, deposito, saldo (snapshot com timestamp) | +| `sar.pautas` | `vw_pautas` | codigo, descricao, ativa | +| `sar.pauta_produtos` | `vw_pauta_produtos` | pauta_codigo, produto_codigo, preco, desconto_max | +| `sar.formas_pagamento` | `vw_formas_pagamento` | codigo, descricao, parcelas, ativa | +| `sar.municipios` | `vw_municipios` | ibge, nome, uf | +| `sar.empresa` | `gestao.empresa` | cnpj, razao_social, nome_fantasia, endereco, logo_url | + +### 3.2 Histórico ERP (read-only, sincronizado) + +| Tabela SAR | Substitui | Propósito | +|---|---|---| +| `sar.pedidos_erp` | `vw_pedidos_erp` | Histórico de pedidos anteriores ao SAR | +| `sar.pedido_itens_erp` | `vw_peditens_erp` | Itens dos pedidos ERP | + +### 3.3 Controle de sincronização + +| Tabela SAR | Propósito | +|---|---| +| `sar.sync_log` | Registro de cada execução de sync (início, fim, erros, registros processados) | +| `sar.sync_config` | Configuração do connector por workspace (tipo ERP, credenciais, frequência) | + +--- + +## 4. Plano de Execução por Fases + +### Fase 0 — Banco de Dados Isolado `[Fundação]` + +**Objetivo:** Separar o banco SAR do banco ERP do cliente. + +**Ações:** +- Provisionar banco PostgreSQL exclusivo para o SAR (`sar_db`) +- Configurar variável `DATABASE_URL` apontando para o novo banco +- Manter `ERP_DB_*` apenas no connector (não no app principal) +- Estrutura multi-tenant: um schema por workspace ou banco por workspace (já previsto na stack) + +**Resultado:** SAR pode funcionar mesmo sem acesso ao ERP; connector roda separado. + +**Estimativa:** 2–3 dias + +--- + +### Fase 1 — Schema Canônico `[Estrutura]` + +**Objetivo:** Criar todos os models Prisma das tabelas canônicas. + +**Ações:** +- Adicionar 11 models ao `schema.prisma` (seção 3.1 e 3.2) +- Adicionar 2 models de controle de sync (seção 3.3) +- Gerar e aplicar migrations +- Nenhuma mudança no código da API ainda + +**Resultado:** Tabelas existem no banco, prontas para receber dados. + +**Estimativa:** 3–4 dias + +--- + +### Fase 2 — Sync Service (Connector ERP JCS) `[Motor]` + +**Objetivo:** Criar a camada que lê o ERP e popula as tabelas canônicas. + +**Ações:** +- Criar módulo `SyncModule` no NestJS +- Implementar `ErpJcsConnector` com leitura das 11 views existentes +- Sync completo na inicialização do workspace +- Sync incremental por cron (ex: a cada 5 minutos para estoque, 30 min para cadastros) +- Registrar execuções em `sync_log` +- Endpoint admin `/api/v1/sync/trigger` para forçar sync manual + +**Estratégia de sync:** + +``` +┌────────────────────────────────────────────────────┐ +│ Frequências recomendadas │ +│ │ +│ A cada 5 min: estoques (alta volatilidade) │ +│ A cada 30 min: produtos, pautas, preços │ +│ A cada 60 min: clientes, representantes │ +│ A cada 6h: pedidos_erp (histórico) │ +│ 1x por dia: municipios, empresa, formas_pgto │ +└────────────────────────────────────────────────────┘ +``` + +**Resultado:** Tabelas SAR ficam populadas e atualizadas automaticamente. + +**Estimativa:** 5–8 dias + +--- + +### Fase 3 — Migração dos Módulos `[Execução]` + +**Objetivo:** Substituir todos os `$queryRaw` por queries Prisma nas tabelas canônicas. + +**Prioridade e ordem:** + +| Ordem | Módulo | Complexidade | Motivo da prioridade | +|---|---|---|---| +| 1 | `catalog` | Baixa | Somente leitura, sem lógica de negócio complexa | +| 2 | `clients` | Média | Base do dashboard e pedidos | +| 3 | `auth` | Baixa | Bem delimitado; lê apenas `representantes` | +| 4 | `dashboard` | Média | Depende de clients migrado | +| 5 | `orders` (leitura) | Média | Parte escrita já usa Prisma corretamente | + +**Processo por módulo:** +1. Com o sync rodando, os dados já estão nas tabelas canônicas +2. Substituir `$queryRaw` por `prisma.cliente.findMany(...)` etc. +3. Manter o contrato de resposta da API (frontend não muda) +4. Teste comparativo: mesmo resultado via ERP vs. via tabela SAR +5. Remover a query antiga após validação + +**Resultado:** Zero dependência de views ERP no código da API. + +**Estimativa:** 5–8 dias + +--- + +### Fase 4 — Segundo Connector (Validação da Abstração) `[Prova]` + +**Objetivo:** Onboarding de um cliente com ERP diferente do JCS. + +**Ações:** +- Identificar segundo ERP (ex: TOTVS, Sankhya, outro) +- Criar `ErpXConnector` implementando a mesma interface do `ErpJcsConnector` +- Mapear campos do ERP X para o schema canônico SAR +- Sem nenhuma mudança no app SAR + +**Resultado:** Prova de conceito real de que a arquitetura é ERP-agnóstica. + +**Estimativa:** 4–6 dias (variável conforme ERP) + +--- + +## 5. Cronograma Estimado + +``` +Semana 1: Fase 0 (banco isolado) + Fase 1 (schema Prisma) +Semana 2: Fase 2 (Sync Service — maior esforço) +Semana 3: Fase 3 (migração dos módulos) +Semana 4: Fase 4 (segundo connector) + testes + documentação + +Total estimado: 4 semanas (19–25 dias úteis) +``` + +--- + +## 6. Riscos e Mitigações + +| Risco | Probabilidade | Impacto | Mitigação | +|---|---|---|---| +| Divergência de dados entre ERP e tabela SAR durante sync | Médio | Alto | Sync comparativo com log de diferenças; alertas | +| Latência do sync afeta experiência do rep | Baixo | Médio | Sync de estoque a cada 5 min é suficiente para força de vendas | +| ERP do cliente sem conectividade temporária | Médio | Baixo | SAR continua funcionando com dados do último sync | +| Campos do segundo ERP sem equivalência no schema canônico | Médio | Médio | Schema canônico flexível com campo `metadata jsonb` por entidade | +| Custo de manutenção de dois bancos | Baixo | Baixo | Banco SAR é pequeno; ERP continua como está | + +--- + +## 7. Benefícios Esperados + +| Benefício | Impacto | +|---|---| +| **Multi-ERP real** | Onboarding de qualquer cliente independente do ERP | +| **Isolamento de falhas** | Queda do ERP não derruba o SAR | +| **Performance** | Queries no banco SAR são mais rápidas (schema otimizado) | +| **Funciona offline** | Rep consulta dados mesmo sem VPN/internet no cliente | +| **Multi-tenant limpo** | Banco SAR separado por workspace desde o início | +| **Evolução independente** | SAR pode adicionar campos sem depender do ERP | + +--- + +## 8. Decisão + +O projeto SAR **não precisa ser refeito do zero**. A infraestrutura (NestJS, autenticação, frontend React, ciclo de pedidos, alçadas, metas, push notifications) está correta e é reaproveitada integralmente. + +A mudança é **cirúrgica e incremental**: +- Adicionar banco e tabelas canônicas (Fases 0 e 1) +- Construir o motor de sync (Fase 2) +- Substituir queries raw módulo a módulo (Fase 3) + +O risco de **não fazer** essa migração agora é maior: quanto mais o produto crescer sobre as views do ERP JCS, mais caro fica refatorar depois. + +--- + +*Documento gerado em 2026-06-22. Para dúvidas: jcsinfo@gmail.com* diff --git a/libs/shared/api-interface/src/index.ts b/libs/shared/api-interface/src/index.ts index 6cda01b..85f7146 100644 --- a/libs/shared/api-interface/src/index.ts +++ b/libs/shared/api-interface/src/index.ts @@ -6,3 +6,4 @@ export * from './lib/product.contract'; export * from './lib/dashboard.contract'; export * from './lib/notifications.contract'; export * from './lib/company.contract'; +export * from './lib/report.contract'; diff --git a/libs/shared/api-interface/src/lib/client.contract.ts b/libs/shared/api-interface/src/lib/client.contract.ts index 1df341f..f426dab 100644 --- a/libs/shared/api-interface/src/lib/client.contract.ts +++ b/libs/shared/api-interface/src/lib/client.contract.ts @@ -111,6 +111,54 @@ export const ContatoResultSchema = z.object({ }); export type ContatoResult = z.infer; +// ─── Produtos mais comprados por cliente ───────────────────────────────────── + +export const TopProdutoSchema = z.object({ + idProduto: z.number().int(), + descricao: z.string(), + unidade: z.string().nullable(), + qtdTotal: z.number(), + numPedidos: z.number().int(), + ultimaCompra: z.string(), + ultimoPreco: z.number(), +}); +export type TopProduto = z.infer; + +// ─── Contas a Receber (resumo por cliente) ─────────────────────────────────── + +export const CtrSummarySchema = z.object({ + qtdAberto: z.number().int(), + totalAberto: z.number(), + qtdVencido: z.number().int(), + totalVencido: z.number(), + maiorAtrasoDias: z.number().int(), +}); +export type CtrSummary = z.infer; + +// ─── Contas a Receber (lista de títulos em aberto) ──────────────────────────── + +export const CtrTituloSchema = z.object({ + idCtr: z.number().int(), + numero: z.string(), + prefixo: z.string(), + dtEmissao: z.string(), + dtVencimento: z.string(), + saldo: z.number(), +}); +export type CtrTitulo = z.infer; + +// ─── Notas Fiscais por cliente ─────────────────────────────────────────────── + +export const NotaFiscalSchema = z.object({ + idNf: z.number().int(), + numero: z.number().int(), + serie: z.string(), + dtEmissao: z.string(), + vlNf: z.number(), + chaveAcessoNfe: z.string().nullable(), +}); +export type NotaFiscal = z.infer; + // ─── Municipio ─────────────────────────────────────────────────────────────── export const MunicipioSchema = z.object({ diff --git a/libs/shared/api-interface/src/lib/dashboard.contract.ts b/libs/shared/api-interface/src/lib/dashboard.contract.ts index d9c951a..836743d 100644 --- a/libs/shared/api-interface/src/lib/dashboard.contract.ts +++ b/libs/shared/api-interface/src/lib/dashboard.contract.ts @@ -3,13 +3,16 @@ import { PedidoSummarySchema } from './order.contract'; // ADR 0006 revogado: OrderSummary → PedidoSummary, ids numéricos. -export const ClienteInativoSchema = z.object({ +export const ClienteNaoPositivadoSchema = z.object({ idCliente: z.number().int(), nome: z.string(), - diasSemCompra: z.number().int(), - ultimaCompraValor: z.string().nullable(), + razao: z.string().nullable(), + diasSemPedido: z.number().int(), // 999 = nunca comprou + ultimoPedido: z.string().nullable(), // ISO date YYYY-MM-DD + comprouAntes: z.boolean(), + whatsapp: z.string().nullable(), }); -export type ClienteInativo = z.infer; +export type ClienteNaoPositivado = z.infer; // Dimensão de meta. O ERP (vw_metas.tipo) define como o cliente acompanha metas: // GL = global, GR = por grupo. Motor único; outras dimensões (marca/subgrupo/ @@ -22,7 +25,7 @@ export type MetaDimensao = z.infer; export const MetaItemSchema = z.object({ codigo: z.number().int().nullable(), rotulo: z.string(), - pedidos: z.number().int(), // qtd de pedidos faturados no grupo (realizado) + pedidos: z.number().int(), valorMeta: z.number(), valorReal: z.number(), qtdMeta: z.number(), @@ -31,8 +34,8 @@ export const MetaItemSchema = z.object({ pesoReal: z.number(), fatorMeta: z.number(), fatorReal: z.number(), - pct: z.number(), // % de valor (real/meta) — base da barra de progresso - falta: z.number(), // valor faltante p/ a meta + pct: z.number(), + falta: z.number(), }); export type MetaItem = z.infer; @@ -43,17 +46,12 @@ export const RepDashboardSchema = z.object({ pct: z.number(), falta: z.number(), }), - // Dimensão detectada do ERP e detalhamento por grupo (vazio quando global). metaDimensao: MetaDimensaoSchema.default('global'), metasPorGrupo: z.array(MetaItemSchema).default([]), - comissao: z.object({ - fixa: z.number(), - flex: z.number(), - total: z.number(), - }), pedidosMes: z.number().int(), pedidosRecentes: z.array(PedidoSummarySchema), - clientesInativos: z.array(ClienteInativoSchema), + naoPositivados: z.array(ClienteNaoPositivadoSchema), + totalNaoPositivados: z.number().int(), syncedAt: z.iso.datetime(), }); export type RepDashboard = z.infer; diff --git a/libs/shared/api-interface/src/lib/report.contract.ts b/libs/shared/api-interface/src/lib/report.contract.ts new file mode 100644 index 0000000..05a8450 --- /dev/null +++ b/libs/shared/api-interface/src/lib/report.contract.ts @@ -0,0 +1,70 @@ +import { z } from 'zod'; + +// ─── Desempenho vs. Meta ───────────────────────────────────────────────────── + +export const ReportMetaQuerySchema = z.object({ + mes: z.coerce.number().int().min(1).max(12), + ano: z.coerce.number().int().min(2000).max(2100), +}); +export type ReportMetaQuery = z.infer; + +export const ReportMetaResponseSchema = z.object({ + mes: z.number(), + ano: z.number(), + realizado: z.string(), + meta: z.string(), + percentual: z.string(), + comissaoEstimada: z.string(), + totalPedidos: z.number(), +}); +export type ReportMetaResponse = z.infer; + +// ─── Carteira de Clientes ──────────────────────────────────────────────────── + +export const CarteiraClienteSchema = z.object({ + idCliente: z.number(), + nome: z.string().nullable(), + razao: z.string().nullable(), + ativo: z.number(), + ultimoPedido: z.string().nullable(), + diasSemPedido: z.number().nullable(), + totalPedidos: z.number(), + ticketMedio: z.string(), +}); +export type CarteiraCliente = z.infer; + +export const ReportCarteiraResponseSchema = z.object({ + data: z.array(CarteiraClienteSchema), + total: z.number(), + inativos30: z.number(), + inativos60: z.number(), + semPedido: z.number(), +}); +export type ReportCarteiraResponse = z.infer; + +// ─── Curva ABC de Produtos ─────────────────────────────────────────────────── + +export const ReportAbcQuerySchema = z.object({ + mes: z.coerce.number().int().min(1).max(12).optional(), + ano: z.coerce.number().int().min(2000).max(2100).optional(), +}); +export type ReportAbcQuery = z.infer; + +export const AbcProdutoSchema = z.object({ + codProduto: z.string().nullable(), + descProduto: z.string(), + totalFaturado: z.string(), + qtdVendida: z.string(), + participacao: z.string(), + curva: z.enum(['A', 'B', 'C']), + descontoMedio: z.string(), + comissaoTotal: z.string(), +}); +export type AbcProduto = z.infer; + +export const ReportAbcResponseSchema = z.object({ + data: z.array(AbcProdutoSchema), + totalFaturado: z.string(), + periodo: z.string(), +}); +export type ReportAbcResponse = z.infer; diff --git a/scripts/sar-erp-schema.sql b/scripts/sar-erp-schema.sql index 4b0dd68..284fe38 100644 --- a/scripts/sar-erp-schema.sql +++ b/scripts/sar-erp-schema.sql @@ -505,14 +505,18 @@ FROM gestao.marca; -- ----------------------------------------------------------------------------- -- Contatos vinculados a clientes (gestao.contato) --- id_entidade = id_corrent::text (CHAR 20, auto-pad) --- Usado para envio via WhatsApp / ChatWoot futuramente +-- O ERP grava id_entidade como 'COR#' (formato nativo). +-- O SAR legado gravava apenas o número puro. Ambos os formatos são aceitos. -- ----------------------------------------------------------------------------- CREATE OR REPLACE VIEW sar.vw_contatos AS SELECT c.id_contato, c.id_empresa, - TRIM(c.id_entidade)::integer AS id_corrent, + CASE + WHEN TRIM(c.id_entidade) ~ '^COR#\d+$' + THEN SUBSTRING(TRIM(c.id_entidade) FROM 5)::integer + ELSE TRIM(c.id_entidade)::integer + END AS id_corrent, c.id_chatwoot, TRIM(c.nome) AS nome, TRIM(c.empresa) AS empresa, @@ -530,9 +534,13 @@ SELECT TRIM(cr.razao::text) AS razao_cliente, cr.cod_vendedor FROM gestao.contato c -LEFT JOIN sig.corrent cr - ON cr.id_corrent = TRIM(c.id_entidade)::integer -WHERE TRIM(c.id_entidade) ~ '^\d+$'; +LEFT JOIN sig.corrent cr ON cr.id_corrent = CASE + WHEN TRIM(c.id_entidade) ~ '^COR#\d+$' + THEN SUBSTRING(TRIM(c.id_entidade) FROM 5)::integer + ELSE TRIM(c.id_entidade)::integer + END +WHERE TRIM(c.id_entidade) ~ '^\d+$' + OR TRIM(c.id_entidade) ~ '^COR#\d+$'; -- ============================================================================= -- PARTE 2 — TABELAS DE ESCRITA DO SAR diff --git a/scripts/sar-triggers.sql b/scripts/sar-triggers.sql index 1b5e2c6..1bf0bea 100644 --- a/scripts/sar-triggers.sql +++ b/scripts/sar-triggers.sql @@ -489,8 +489,14 @@ CREATE INDEX IF NOT EXISTS idx_sar_ctnov_sync ON sar.contatos_novos(sincroniz CREATE OR REPLACE FUNCTION sar.fn_sync_contato_to_erp() RETURNS TRIGGER LANGUAGE plpgsql AS $fn$ DECLARE - v_id_contato INTEGER; + v_id_contato INTEGER; + v_id_empresa INTEGER; BEGIN + -- Normaliza empresa fiscal (9001) → empresa gerencial (1), padrão do ERP + v_id_empresa := CASE WHEN NEW.id_empresa > 9000 + THEN NEW.id_empresa - 9000 + ELSE NEW.id_empresa END; + BEGIN INSERT INTO gestao.contato ( id_empresa, id_entidade, @@ -499,8 +505,8 @@ BEGIN telefone, ramal, celular, whatsapp, email, ativo, anotacoes ) VALUES ( - NEW.id_empresa, - NEW.id_corrent::text, + v_id_empresa, + 'COR#' || NEW.id_corrent::text, -- formato nativo do ERP COALESCE(LEFT(NEW.nome, 100), ''), LEFT(NEW.empresa, 100), LEFT(NEW.cargo, 100),