import { Injectable } from '@nestjs/common'; import { ClsService } from 'nestjs-cls'; import type { RepDashboard, SupervisorDashboard, ManagerDashboard, RankingRep, PositivacaoRep, } from '@sar/api-interface'; import type { WorkspaceClsStore } from '../workspace/workspace.types'; // Situa ERP: 2=Liberado, 4=Faturado, 5=Cancelado // Situa SAR (pedidos novos): 1=Pendente, 2=Aprovado, 3=Cancelado, 4=Faturado const SITUA_PENDENTE = 1; // vw_metas.tipo (gestao.metavenda): GL = meta global, GR = meta por grupo. const TIPO_META_GLOBAL = 'GL'; const TIPO_META_GRUPO = 'GR'; // Metas/produtos vivem na MATRIZ; pedidos usam idEmpresa cru (ex.: 9001 → matriz 1). function matrizEmpresa(idEmpresa: number): number { return idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa; } interface MetaErpRow { tipo: string; cod_grupo: number | null; desc_grupo: string | null; valor: string; qtdade: string; peso: string; vl_fator: string; } interface RealizadoGrupoRow { cod_grupo: number | null; grupo: string | null; pedidos: string; valor: string; qtd: string; peso: string; } interface NaoPositivadoRow { id_cliente: number; nome: string; 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 { cod_vendedor: number; nome_vendedor: string | null; inativos_count: string; } @Injectable() export class DashboardService { constructor(private readonly cls: ClsService) {} async repDashboard(userId: string): 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 codVendedor = parseInt(userId, 10); const now = new Date(); const year = now.getFullYear(); const month = now.getMonth() + 1; const monthStart = new Date(year, month - 1, 1); const monthEnd = new Date(year, month, 0, 23, 59, 59, 999); // 1. Metas do mês — vw_metas vive na matriz (normaliza 9001→1). // GL = meta global; GR = meta por grupo. A dimensão segue o que o ERP tiver. const idEmpresaMatriz = matrizEmpresa(idEmpresa); const metaErpRows = await prisma.$queryRawUnsafe(` SELECT TRIM(tipo) AS tipo, cod_grupo, TRIM(desc_grupo) AS desc_grupo, valor::text AS valor, qtdade::text AS qtdade, peso::text AS peso, vl_fator::text AS vl_fator FROM vw_metas WHERE id_empresa = ${idEmpresaMatriz} AND cod_vendedor = ${codVendedor} AND ano = ${year} AND mes = ${month} AND TRIM(tipo) IN ('${TIPO_META_GLOBAL}', '${TIPO_META_GRUPO}') `); const glRows = metaErpRows.filter((m) => m.tipo === TIPO_META_GLOBAL); const grRows = metaErpRows.filter((m) => m.tipo === TIPO_META_GRUPO); // Total global: usa GL se houver; senão soma as metas por grupo (GR). const targetAmount = glRows.length ? glRows.reduce((a, m) => a + Number(m.valor), 0) : grRows.reduce((a, m) => a + Number(m.valor), 0); const metaDimensao = grRows.length > 0 ? ('grupo' as const) : ('global' as const); // 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); interface TotalRow { total: string; } interface CountRow { count: string; } interface RecentRow { id_pedido: number; num_ped_sar: string; numero: number; id_cliente: number; nome_cliente: string | null; razao_cliente: string | null; cod_vendedor: number; nome_vendedor: string | null; situa: number; status_descr: string; dt_pedido: Date; total: string; desconto_perc: string; obs: string | null; } const [atingidoRows, pedidosMesRows, recentRows, realizadoGrupoRows] = await Promise.all([ prisma.$queryRawUnsafe(` SELECT COALESCE(SUM(total), 0)::text AS total FROM vw_pedidos_erp WHERE id_empresa = ${idEmpresa} AND cod_vendedor = ${codVendedor} AND situa NOT IN (1, 5) AND dt_pedido >= '${monthStartStr}' AND dt_pedido <= '${monthEndStr}' `), prisma.$queryRawUnsafe(` SELECT COUNT(*)::text AS count FROM vw_pedidos_erp WHERE id_empresa = ${idEmpresa} AND cod_vendedor = ${codVendedor} AND situa != 5 AND dt_pedido >= '${monthStartStr}' AND dt_pedido <= '${monthEndStr}' `), prisma.$queryRawUnsafe(` SELECT e.id_pedido, e.num_ped_sar, e.numero, e.id_cliente, e.cod_vendedor, e.situa, e.status_descr, e.dt_pedido, e.total::text, e.desconto_perc::text, e.obs, c.nome AS nome_cliente, c.razao AS razao_cliente, (SELECT r.nome FROM vw_representantes r WHERE r.codigo = e.cod_vendedor LIMIT 1) AS nome_vendedor FROM vw_pedidos_erp e LEFT JOIN vw_clientes c ON c.id_cliente = e.id_cliente WHERE e.id_empresa = ${idEmpresa} AND e.cod_vendedor = ${codVendedor} AND e.situa != 5 AND e.dt_pedido >= '${new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10)}' ORDER BY e.dt_pedido DESC LIMIT 10 `), // Realizado por grupo — itens faturados (situa 2/4) → produto (matriz) → cod_grupo. // peso = qtd × peso_líquido do produto; pedidos = nº de pedidos distintos. prisma.$queryRawUnsafe(` SELECT p.cod_grupo, COALESCE(NULLIF(TRIM(p.grupo), ''), p.cod_grupo::text) AS grupo, COUNT(DISTINCT pi.id_pedido)::text AS pedidos, COALESCE(SUM(pi.total), 0)::text AS valor, COALESCE(SUM(pi.qtd), 0)::text AS qtd, COALESCE(SUM(pi.qtd * COALESCE(p.peso_liquido, 0)), 0)::text AS peso FROM vw_peditens_erp pi JOIN vw_pedidos_erp e ON e.id_pedido = pi.id_pedido JOIN vw_produtos p ON p.id_erp = pi.id_produto AND p.id_empresa = ${idEmpresaMatriz} WHERE e.id_empresa = ${idEmpresa} AND e.cod_vendedor = ${codVendedor} AND e.situa NOT IN (1, 5) AND e.dt_pedido >= '${monthStartStr}' AND e.dt_pedido <= '${monthEndStr}' GROUP BY p.cod_grupo, grupo `), ]); const atingido = Number(atingidoRows[0]?.total ?? 0); const pedidosMes = Number(pedidosMesRows[0]?.count ?? 0); const pct = targetAmount > 0 ? Math.round((atingido / targetAmount) * 100) : 0; const falta = Math.max(0, targetAmount - atingido); // 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, 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 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 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. const realPorGrupo = new Map(); for (const r of realizadoGrupoRows) { if (r.cod_grupo != null) realPorGrupo.set(Number(r.cod_grupo), r); } const metasPorGrupo = grRows .map((m) => { const cod = m.cod_grupo != null ? Number(m.cod_grupo) : null; const real = cod != null ? realPorGrupo.get(cod) : undefined; const valorMeta = Number(m.valor); const valorReal = real ? Number(real.valor) : 0; const pesoMeta = Number(m.peso); const pesoReal = real ? Number(real.peso) : 0; return { codigo: cod, rotulo: m.desc_grupo || real?.grupo || `Grupo ${cod ?? '?'}`, pedidos: real ? Number(real.pedidos) : 0, valorMeta, valorReal, qtdMeta: Number(m.qtdade), qtdReal: real ? Number(real.qtd) : 0, pesoMeta, pesoReal, fatorMeta: Number(m.vl_fator), fatorReal: pesoReal > 0 ? valorReal / pesoReal : 0, pct: valorMeta > 0 ? Math.round((valorReal / valorMeta) * 100) : 0, falta: Math.max(0, valorMeta - valorReal), }; }) .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, pedidosMes, pedidosRecentes: recentRows.map((o) => ({ id: `erp-${o.id_pedido}`, numPedSar: (o.num_ped_sar ?? '').trim(), numero: Number(o.numero), idCliente: Number(o.id_cliente), nomeCliente: o.nome_cliente ?? null, razaoCliente: o.razao_cliente ?? null, codVendedor: Number(o.cod_vendedor), nomeVendedor: o.nome_vendedor ?? null, situa: Number(o.situa), statusDescr: o.status_descr, dtPedido: new Date(o.dt_pedido).toISOString(), total: o.total ?? '0', descontoPerc: o.desconto_perc ?? '0', obs: o.obs ?? null, createdAt: new Date(o.dt_pedido).toISOString(), fonte: 'erp' as const, })), naoPositivados, totalNaoPositivados: naoPositivados.length, syncedAt: now.toISOString(), }; } async managerDashboard(mes?: number, ano?: 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'); const now = new Date(); function matrizEmpresa(id: number): number { return id > 9000 ? id - 9000 : id; } const idEmpresaMatriz = matrizEmpresa(idEmpresa); const year = ano ?? now.getFullYear(); const month = mes ?? now.getMonth() + 1; const monthStartStr = new Date(year, month - 1, 1).toISOString().slice(0, 10); const monthEndStr = new Date(year, month, 0).toISOString().slice(0, 10); interface TotalRow { count: string; total: string; } interface RankingRow { cod_vendedor: number; nome: string | null; pedidos: string; clientes_atendidos: string; faturamento: string; meta_valor: string | null; } interface PromoRow { count: string; } interface MetaTotalRow { meta_total: string; } interface PositivacaoRow { cod_vendedor: number; nome_vendedor: string | null; total_clientes: string; clientes_positivados: string; } const [statsRows, rankingRows, promoRows, metaTotalRows, positivacaoRows] = await Promise.all([ prisma.$queryRawUnsafe(` SELECT COUNT(*)::text AS count, COALESCE(SUM(total), 0)::text AS total FROM vw_pedidos_erp WHERE id_empresa = ${idEmpresa} AND situa NOT IN (1, 5) AND dt_pedido >= '${monthStartStr}' AND dt_pedido <= '${monthEndStr}' `), prisma.$queryRawUnsafe(` SELECT p.cod_vendedor, (SELECT r.nome FROM vw_representantes r WHERE r.codigo = p.cod_vendedor LIMIT 1) AS nome, COUNT(*)::text AS pedidos, COUNT(DISTINCT p.id_cliente)::text AS clientes_atendidos, COALESCE(SUM(p.total), 0)::text AS faturamento, (SELECT SUM(m.valor)::text FROM sar.vw_metas m WHERE m.id_empresa = ${idEmpresaMatriz} AND m.cod_vendedor = p.cod_vendedor AND m.ano = ${year} AND m.mes = ${month} AND TRIM(m.tipo) = 'GR') AS meta_valor FROM vw_pedidos_erp p WHERE p.id_empresa = ${idEmpresa} AND p.situa NOT IN (1, 5) AND p.dt_pedido >= '${monthStartStr}' AND p.dt_pedido <= '${monthEndStr}' GROUP BY p.cod_vendedor ORDER BY SUM(p.total) DESC LIMIT 10 `), prisma.$queryRawUnsafe(` SELECT COUNT(*)::text AS count FROM sar.promocoes WHERE id_empresa = ${idEmpresa} AND ativa = true AND data_fim >= CURRENT_DATE `), prisma.$queryRawUnsafe(` SELECT COALESCE(SUM(valor), 0)::text AS meta_total FROM sar.vw_metas WHERE id_empresa = ${idEmpresaMatriz} AND ano = ${year} AND mes = ${month} AND TRIM(tipo) = 'GR' `), prisma.$queryRawUnsafe(` SELECT c.cod_vendedor, (SELECT r.nome FROM vw_representantes r WHERE r.codigo = c.cod_vendedor LIMIT 1) AS nome_vendedor, COUNT(DISTINCT c.id_cliente)::text AS total_clientes, COUNT(DISTINCT CASE WHEN ped.id_cliente IS NOT NULL THEN c.id_cliente END)::text AS clientes_positivados FROM vw_clientes c LEFT JOIN ( SELECT DISTINCT id_cliente, cod_vendedor FROM vw_pedidos_erp WHERE id_empresa = ${idEmpresa} AND situa NOT IN (1, 5) AND dt_pedido >= '${monthStartStr}' AND dt_pedido <= '${monthEndStr}' ) ped ON ped.id_cliente = c.id_cliente AND ped.cod_vendedor = c.cod_vendedor WHERE c.cod_vendedor > 0 GROUP BY c.cod_vendedor HAVING COUNT(DISTINCT c.id_cliente) > 0 ORDER BY COUNT(DISTINCT CASE WHEN ped.id_cliente IS NOT NULL THEN c.id_cliente END) DESC `), ]); const pedidosMes = Number(statsRows[0]?.count ?? 0); const faturamentoMes = Number(statsRows[0]?.total ?? 0); const ticketMedio = pedidosMes > 0 ? faturamentoMes / pedidosMes : 0; const rankingReps: RankingRep[] = rankingRows.map((r) => { const fat = Number(r.faturamento); const meta = r.meta_valor ? Number(r.meta_valor) : 0; return { codVendedor: Number(r.cod_vendedor), nomeVendedor: r.nome ?? null, pedidos: Number(r.pedidos), clientesAtendidos: Number(r.clientes_atendidos), faturamento: fat, pctMeta: meta > 0 ? Math.round((fat / meta) * 100) : 0, }; }); const totalReps = new Set(rankingRows.map((r) => Number(r.cod_vendedor))).size; const metaTotal = Number(metaTotalRows[0]?.meta_total ?? 0); const positivacaoReps: PositivacaoRep[] = positivacaoRows.map((r) => { const total = Number(r.total_clientes); const positivados = Number(r.clientes_positivados); return { codVendedor: Number(r.cod_vendedor), nomeVendedor: r.nome_vendedor ?? null, totalClientes: total, clientesPositivados: positivados, pctPositivacao: total > 0 ? Math.round((positivados / total) * 100) : 0, }; }); return { faturamentoMes, pedidosMes, ticketMedio, totalReps, promocoesAtivas: Number(promoRows[0]?.count ?? 0), metaTotal, rankingReps, positivacaoReps, syncedAt: now.toISOString(), }; } async supervisorDashboard(): 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 now = new Date(); // Fila de aprovações — pedidos SAR pendentes (novos, ainda não integrados ao ERP) const approvalQueue = await prisma.pedido.findMany({ where: { idEmpresa, situa: SITUA_PENDENTE }, orderBy: { dtPedido: 'asc' }, take: 50, }); // Pedidos do dia — lê do ERP (situa != 5=Cancelado) const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const todayStr = todayStart.toISOString().slice(0, 10); const lastWeekStr = new Date(todayStart.getTime() - 7 * 24 * 60 * 60 * 1000) .toISOString() .slice(0, 10); const lastWeekEndStr = new Date(todayStart.getTime() - 6 * 24 * 60 * 60 * 1000) .toISOString() .slice(0, 10); interface DayRow { count: string; total: string; } const [todayRows, lastWeekRows] = await Promise.all([ prisma.$queryRawUnsafe(` SELECT COUNT(*)::text AS count, COALESCE(SUM(total),0)::text AS total FROM vw_pedidos_erp WHERE id_empresa = ${idEmpresa} AND situa != 5 AND dt_pedido >= '${todayStr}' `), prisma.$queryRawUnsafe(` SELECT COUNT(*)::text AS count, COALESCE(SUM(total),0)::text AS total FROM vw_pedidos_erp WHERE id_empresa = ${idEmpresa} AND situa != 5 AND dt_pedido >= '${lastWeekStr}' AND dt_pedido < '${lastWeekEndStr}' `), ]); // Top 3 reps com mais clientes inativos (>30 dias sem compra no ERP) const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); const inativosPorRep = await prisma.$queryRawUnsafe(` SELECT inativos.cod_vendedor, (SELECT r.nome FROM vw_representantes r WHERE r.codigo = inativos.cod_vendedor LIMIT 1) AS nome_vendedor, COUNT(*)::text AS inativos_count FROM ( SELECT c.id_cliente, c.cod_vendedor 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 WHERE c.ativo = 1 GROUP BY c.id_cliente, c.cod_vendedor HAVING MAX(p.dt_pedido) IS NULL OR MAX(p.dt_pedido) < '${thirtyDaysAgo.toISOString().slice(0, 10)}' ) inativos GROUP BY inativos.cod_vendedor ORDER BY COUNT(*) DESC LIMIT 3 `); // Resolve nomes de cliente e representante da fila (pedidos SAR só têm os códigos) const repCods = [...new Set(approvalQueue.map((p) => p.codVendedor))]; const cliIds = [...new Set(approvalQueue.map((p) => p.idCliente))]; const [repNameRows, cliNameRows] = await Promise.all([ repCods.length ? prisma.$queryRawUnsafe<{ codigo: number; nome: string | null }[]>( `SELECT codigo, nome FROM vw_representantes WHERE codigo IN (${repCods.join(',')})`, ) : Promise.resolve([]), cliIds.length ? prisma.$queryRawUnsafe< { id_cliente: number; nome: string | null; razao: string | null }[] >( `SELECT id_cliente, nome, razao FROM vw_clientes WHERE id_cliente IN (${cliIds.join(',')})`, ) : Promise.resolve([]), ]); const repNameMap = new Map(repNameRows.map((r) => [Number(r.codigo), r.nome])); const cliNameMap = new Map( cliNameRows.map((c) => [Number(c.id_cliente), { nome: c.nome, razao: c.razao }]), ); const mapPedido = (o: (typeof approvalQueue)[number]) => ({ id: o.id, numPedSar: o.numPedSar, idCliente: o.idCliente, nomeCliente: cliNameMap.get(o.idCliente)?.nome ?? null, razaoCliente: cliNameMap.get(o.idCliente)?.razao ?? null, codVendedor: o.codVendedor, nomeVendedor: repNameMap.get(o.codVendedor) ?? null, situa: o.situa, dtPedido: o.dtPedido.toISOString(), total: String(o.total), descontoPerc: String(o.descontoPerc), obs: o.obs, createdAt: o.createdAt.toISOString(), fonte: 'sar' as const, }); return { approvalQueue: approvalQueue.map(mapPedido), pedidosDia: { count: Number(todayRows[0]?.count ?? 0), total: Number(todayRows[0]?.total ?? 0), countSemanaAnterior: Number(lastWeekRows[0]?.count ?? 0), totalSemanaAnterior: Number(lastWeekRows[0]?.total ?? 0), }, inativosPorRep: inativosPorRep.map((r) => ({ codVendedor: Number(r.cod_vendedor), nomeVendedor: r.nome_vendedor ?? null, inativosCount: parseInt(r.inativos_count, 10), })), syncedAt: now.toISOString(), }; } }