refactor(erp): integração direta com banco ERP — schema sar
Revoga ADR 0006 (BD-por-workspace separado). O SAR agora conecta ao banco PostgreSQL do ERP (módulo SIG) e usa o schema `sar` para tudo. PRISMA - Remove: Client, Product, Order, OrderItem, OrderStatusHistory, RepTarget, RepDiscountLimit, PushSubscription (modelos isolados) - Adiciona: Pedido, PedidoItem, HistoricoPedido, AlcadaDesconto, MetaRepresentante, PushSubscription (mapeados para sar.*) - IDs: id_cliente/cod_vendedor/id_empresa são INTEGER (ERP) - situa: Int (1=Pendente 2=Aprovado 3=Cancelado 4=Faturado) - JWT: workspace_id:string → id_empresa:number - URL: inclui ?schema=sar para Prisma rotear ao schema ERP SERVICES - ClientsService: $queryRawUnsafe contra sar.vw_clientes + sar.pedidos - CatalogService: $queryRawUnsafe contra sar.vw_produtos + sar.vw_estoque - OrdersService: Prisma models Pedido/PedidoItem/HistoricoPedido/AlcadaDesconto - DashboardService: MetaRepresentante + queries raw para inativos - NotificationsService: PushSubscription com codVendedor + idEmpresa CONTRATOS (api-interface) - client.contract: campos ERP (idCliente, nome, cgcpf, cod_vendedor…) - order.contract: PedidoSummary/PedidoDetail/CreatePedido + SITUA_LABEL - product.contract: ProdutoSummary/ProdutoDetail (vw_produtos) - auth.contract: workspaceId:string → idEmpresa:number WEB - Todos os cockpits e queries atualizados para os novos tipos Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,26 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ClsService } from 'nestjs-cls';
|
||||
import { OrderStatus } from '@prisma/client';
|
||||
import type { RepDashboard, SupervisorDashboard } from '@sar/api-interface';
|
||||
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||
|
||||
// Situa: 1=Pendente, 2=Aprovado, 3=Cancelado, 4=Faturado
|
||||
const SITUA_PENDENTE = 1;
|
||||
const SITUA_APROVADO = 2;
|
||||
const SITUA_FATURADO = 4;
|
||||
const SITUA_CANCELADO = 3;
|
||||
|
||||
interface InativoRow {
|
||||
id_cliente: number;
|
||||
nome: string;
|
||||
dt_ultima_compra: Date | null;
|
||||
ultima_compra_valor: string | null;
|
||||
}
|
||||
|
||||
interface InativosPorRepRow {
|
||||
cod_vendedor: number;
|
||||
inativos_count: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DashboardService {
|
||||
constructor(private readonly cls: ClsService<WorkspaceClsStore>) {}
|
||||
@@ -11,6 +28,9 @@ export class DashboardService {
|
||||
async repDashboard(userId: string): Promise<RepDashboard> {
|
||||
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;
|
||||
@@ -19,22 +39,23 @@ export class DashboardService {
|
||||
const monthEnd = new Date(year, month, 0, 23, 59, 59, 999);
|
||||
|
||||
// Meta e taxas do mês
|
||||
const target = await prisma.repTarget.findUnique({
|
||||
where: { repId_year_month: { repId: userId, year, month } },
|
||||
});
|
||||
const targetAmount = target ? Number(target.targetAmount) : 0;
|
||||
const commissionRate = target ? Number(target.commissionRate) : 3;
|
||||
const flexRate = target ? Number(target.flexRate) : 1;
|
||||
|
||||
// Pedidos aprovados/faturados do mês (base do cálculo de meta e comissão)
|
||||
const approvedThisMonth = await prisma.order.findMany({
|
||||
const target = await prisma.metaRepresentante.findUnique({
|
||||
where: {
|
||||
repId: userId,
|
||||
deletedAt: null,
|
||||
status: { in: [OrderStatus.approved, OrderStatus.invoiced] },
|
||||
issuedAt: { gte: monthStart, lte: monthEnd },
|
||||
codVendedor_idEmpresa_ano_mes: { codVendedor, idEmpresa, ano: year, mes: month },
|
||||
},
|
||||
});
|
||||
const targetAmount = target ? Number(target.metaValor) : 0;
|
||||
const commissionRate = target ? Number(target.taxaComissao) : 3;
|
||||
const flexRate = target ? Number(target.taxaFlex) : 1;
|
||||
|
||||
// Pedidos aprovados/faturados do mês
|
||||
const approvedThisMonth = await prisma.pedido.findMany({
|
||||
where: {
|
||||
codVendedor,
|
||||
idEmpresa,
|
||||
situa: { in: [SITUA_APROVADO, SITUA_FATURADO] },
|
||||
dtPedido: { gte: monthStart, lte: monthEnd },
|
||||
},
|
||||
include: { client: { select: { name: true } } },
|
||||
});
|
||||
|
||||
const atingido = approvedThisMonth.reduce((s, o) => s + Number(o.total), 0);
|
||||
@@ -45,41 +66,47 @@ export class DashboardService {
|
||||
const flex =
|
||||
targetAmount > 0 && atingido >= targetAmount ? Math.round(atingido * flexRate) / 100 : 0;
|
||||
|
||||
// Contagem total de pedidos no mês (todos status exceto cancelado)
|
||||
const pedidosMes = await prisma.order.count({
|
||||
// Contagem total de pedidos no mês (exceto cancelado)
|
||||
const pedidosMes = await prisma.pedido.count({
|
||||
where: {
|
||||
repId: userId,
|
||||
deletedAt: null,
|
||||
status: { not: OrderStatus.cancelled },
|
||||
issuedAt: { gte: monthStart, lte: monthEnd },
|
||||
codVendedor,
|
||||
idEmpresa,
|
||||
situa: { not: SITUA_CANCELADO },
|
||||
dtPedido: { gte: monthStart, lte: monthEnd },
|
||||
},
|
||||
});
|
||||
|
||||
// Pedidos recentes — últimos 7 dias
|
||||
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
const recentOrders = await prisma.order.findMany({
|
||||
const recentOrders = await prisma.pedido.findMany({
|
||||
where: {
|
||||
repId: userId,
|
||||
deletedAt: null,
|
||||
status: { not: OrderStatus.cancelled },
|
||||
issuedAt: { gte: sevenDaysAgo },
|
||||
codVendedor,
|
||||
idEmpresa,
|
||||
situa: { not: SITUA_CANCELADO },
|
||||
dtPedido: { gte: sevenDaysAgo },
|
||||
},
|
||||
include: { client: { select: { name: true } } },
|
||||
orderBy: { issuedAt: 'desc' },
|
||||
orderBy: { dtPedido: 'desc' },
|
||||
take: 10,
|
||||
});
|
||||
|
||||
// Clientes inativos — sem compra há > 30 dias (ou nunca compraram)
|
||||
// Clientes inativos — sem compra há >30 dias (via view + pedidos SAR)
|
||||
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
const inactiveClients = await prisma.client.findMany({
|
||||
where: {
|
||||
repId: userId,
|
||||
deletedAt: null,
|
||||
OR: [{ lastOrderAt: null }, { lastOrderAt: { lt: thirtyDaysAgo } }],
|
||||
},
|
||||
orderBy: { lastOrderAt: { sort: 'asc', nulls: 'first' } },
|
||||
take: 10,
|
||||
});
|
||||
const inactiveClients = await prisma.$queryRawUnsafe<InativoRow[]>(`
|
||||
SELECT
|
||||
c.id_cliente,
|
||||
c.nome,
|
||||
MAX(p.dt_pedido) AS dt_ultima_compra,
|
||||
MAX(p.total)::text AS ultima_compra_valor
|
||||
FROM vw_clientes c
|
||||
LEFT JOIN pedidos p ON p.id_cliente = c.id_cliente AND p.id_empresa = c.id_empresa AND p.situa != ${SITUA_CANCELADO}
|
||||
WHERE c.id_empresa = ${idEmpresa}
|
||||
AND 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()}'
|
||||
ORDER BY dt_ultima_compra ASC NULLS FIRST
|
||||
LIMIT 10
|
||||
`);
|
||||
|
||||
return {
|
||||
meta: { atingido, total: targetAmount, pct, falta },
|
||||
@@ -87,26 +114,23 @@ export class DashboardService {
|
||||
pedidosMes,
|
||||
pedidosRecentes: recentOrders.map((o) => ({
|
||||
id: o.id,
|
||||
number: o.number,
|
||||
clientId: o.clientId,
|
||||
clientName: o.client.name,
|
||||
repId: o.repId,
|
||||
status: o.status,
|
||||
subtotal: String(o.subtotal),
|
||||
numPedSar: o.numPedSar,
|
||||
idCliente: o.idCliente,
|
||||
codVendedor: o.codVendedor,
|
||||
situa: o.situa,
|
||||
dtPedido: o.dtPedido.toISOString(),
|
||||
total: String(o.total),
|
||||
discountPct: String(o.discountPct),
|
||||
issuedAt: o.issuedAt.toISOString(),
|
||||
approvedAt: o.approvedAt?.toISOString() ?? null,
|
||||
invoicedAt: o.invoicedAt?.toISOString() ?? null,
|
||||
cancelledAt: o.cancelledAt?.toISOString() ?? null,
|
||||
descontoPerc: String(o.descontoPerc),
|
||||
obs: o.obs,
|
||||
createdAt: o.createdAt.toISOString(),
|
||||
})),
|
||||
clientesInativos: inactiveClients.map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
diasSemCompra: c.lastOrderAt
|
||||
? Math.floor((now.getTime() - c.lastOrderAt.getTime()) / 86_400_000)
|
||||
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.lastOrderValue !== null ? String(c.lastOrderValue) : null,
|
||||
ultimaCompraValor: c.ultima_compra_valor,
|
||||
})),
|
||||
syncedAt: now.toISOString(),
|
||||
};
|
||||
@@ -115,68 +139,67 @@ export class DashboardService {
|
||||
async supervisorDashboard(): Promise<SupervisorDashboard> {
|
||||
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 — mais antigos primeiro
|
||||
const approvalQueue = await prisma.order.findMany({
|
||||
where: { deletedAt: null, status: OrderStatus.pending_approval },
|
||||
include: { client: { select: { name: true } } },
|
||||
orderBy: { issuedAt: 'asc' },
|
||||
const approvalQueue = await prisma.pedido.findMany({
|
||||
where: { idEmpresa, situa: SITUA_PENDENTE },
|
||||
orderBy: { dtPedido: 'asc' },
|
||||
take: 50,
|
||||
});
|
||||
|
||||
// Pedidos do dia (hoje, meia-noite até agora)
|
||||
// Pedidos do dia
|
||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const todayOrders = await prisma.order.findMany({
|
||||
const todayOrders = await prisma.pedido.findMany({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
status: { not: OrderStatus.cancelled },
|
||||
issuedAt: { gte: todayStart },
|
||||
idEmpresa,
|
||||
situa: { not: SITUA_CANCELADO },
|
||||
dtPedido: { gte: todayStart },
|
||||
},
|
||||
});
|
||||
|
||||
// Mesmo dia da semana passada (comparativo)
|
||||
// Mesmo dia da semana passada
|
||||
const lastWeekStart = new Date(todayStart.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
const lastWeekEnd = new Date(lastWeekStart.getTime() + 24 * 60 * 60 * 1000 - 1);
|
||||
const lastWeekOrders = await prisma.order.findMany({
|
||||
const lastWeekOrders = await prisma.pedido.findMany({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
status: { not: OrderStatus.cancelled },
|
||||
issuedAt: { gte: lastWeekStart, lte: lastWeekEnd },
|
||||
idEmpresa,
|
||||
situa: { not: SITUA_CANCELADO },
|
||||
dtPedido: { gte: lastWeekStart, lte: lastWeekEnd },
|
||||
},
|
||||
});
|
||||
|
||||
// Inativos por rep — top 3 reps com mais clientes inativos (>30 dias)
|
||||
// Inativos por rep — top 3
|
||||
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
const inativosPorRep = await prisma.client.groupBy({
|
||||
by: ['repId'],
|
||||
where: {
|
||||
deletedAt: null,
|
||||
OR: [{ lastOrderAt: null }, { lastOrderAt: { lt: thirtyDaysAgo } }],
|
||||
},
|
||||
_count: { id: true },
|
||||
orderBy: { _count: { id: 'desc' } },
|
||||
take: 3,
|
||||
});
|
||||
const inativosPorRep = await prisma.$queryRawUnsafe<InativosPorRepRow[]>(`
|
||||
SELECT
|
||||
c.cod_vendedor,
|
||||
COUNT(c.id_cliente)::text AS inativos_count
|
||||
FROM vw_clientes c
|
||||
LEFT JOIN pedidos p ON p.id_cliente = c.id_cliente AND p.id_empresa = c.id_empresa AND p.situa != ${SITUA_CANCELADO}
|
||||
WHERE c.id_empresa = ${idEmpresa} AND c.ativo = 1
|
||||
GROUP BY c.cod_vendedor, c.id_cliente
|
||||
HAVING MAX(p.dt_pedido) IS NULL OR MAX(p.dt_pedido) < '${thirtyDaysAgo.toISOString()}'
|
||||
ORDER BY inativos_count DESC
|
||||
LIMIT 3
|
||||
`);
|
||||
|
||||
const mapOrder = (o: (typeof approvalQueue)[number]) => ({
|
||||
const mapPedido = (o: (typeof approvalQueue)[number]) => ({
|
||||
id: o.id,
|
||||
number: o.number,
|
||||
clientId: o.clientId,
|
||||
clientName: o.client.name,
|
||||
repId: o.repId,
|
||||
status: o.status,
|
||||
subtotal: String(o.subtotal),
|
||||
numPedSar: o.numPedSar,
|
||||
idCliente: o.idCliente,
|
||||
codVendedor: o.codVendedor,
|
||||
situa: o.situa,
|
||||
dtPedido: o.dtPedido.toISOString(),
|
||||
total: String(o.total),
|
||||
discountPct: String(o.discountPct),
|
||||
issuedAt: o.issuedAt.toISOString(),
|
||||
approvedAt: o.approvedAt?.toISOString() ?? null,
|
||||
invoicedAt: o.invoicedAt?.toISOString() ?? null,
|
||||
cancelledAt: o.cancelledAt?.toISOString() ?? null,
|
||||
descontoPerc: String(o.descontoPerc),
|
||||
obs: o.obs,
|
||||
createdAt: o.createdAt.toISOString(),
|
||||
});
|
||||
|
||||
return {
|
||||
approvalQueue: approvalQueue.map(mapOrder),
|
||||
approvalQueue: approvalQueue.map(mapPedido),
|
||||
pedidosDia: {
|
||||
count: todayOrders.length,
|
||||
total: todayOrders.reduce((s, o) => s + Number(o.total), 0),
|
||||
@@ -184,8 +207,8 @@ export class DashboardService {
|
||||
totalSemanaAnterior: lastWeekOrders.reduce((s, o) => s + Number(o.total), 0),
|
||||
},
|
||||
inativosPorRep: inativosPorRep.map((r) => ({
|
||||
repId: r.repId,
|
||||
inativosCount: r._count.id,
|
||||
codVendedor: Number(r.cod_vendedor),
|
||||
inativosCount: parseInt(r.inativos_count, 10),
|
||||
})),
|
||||
syncedAt: now.toISOString(),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user