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:
2026-05-28 21:51:16 +00:00
parent 246eb28bb1
commit b0b60d7a14
39 changed files with 1433 additions and 1544 deletions

View File

@@ -1,12 +1,10 @@
// SAR — Workspace Database Schema
// Stack canon: Prisma 7 · PostgreSQL 18 · BD-por-workspace (ADR 0006)
//
// Este schema roda em CADA workspace DB (sar_workspace_<id>).
// NÃO há workspaceId/tenantId em nenhum modelo — o isolamento é físico.
// O banco master (sar_master) é gerenciado pelo master-login (IdP JCS), não por este schema.
// SAR — Schema no banco ERP da JCS (schema `sar` dentro do PostgreSQL do SIG/gestao)
// ADR 0006 revogado: banco separado por workspace → schema `sar` no ERP JCS.
// O isolamento multi-tenant é por `id_empresa` em todas as tabelas.
//
// CODING-RULES PGD-DB-004: moduleFormat = "cjs" (NestJS é CJS)
// CODING-RULES PGD-DB-001: MIGRATION_DATABASE_URL aponta direto ao PG (sem PgBouncer)
// A URL de runtime deve incluir ?schema=sar (injetado pelo JwtAuthGuard via WorkspacePrismaPool)
generator client {
provider = "prisma-client-js"
@@ -16,232 +14,156 @@ generator client {
// Prisma 7: url removida do schema — conexão em prisma.config.ts (migrate)
// e no WorkspacePrismaPool via PrismaPg adapter (runtime).
// A URL de runtime inclui ?schema=sar para rotear ao schema correto.
datasource db {
provider = "postgresql"
}
// ─── Enums ───────────────────────────────────────────────────────────────────
// Situação financeira resumida do cliente — cacheável offline (FR-2.4, FR-2.5).
enum FinancialStatus {
regular
attention
blocked
}
// Status do pedido (FR-3.2). Transições: budget → pending_approval → approved → invoiced.
// Qualquer status pode ir para cancelled.
enum OrderStatus {
budget // orçamento
pending_approval // aprovação pendente
approved // aprovado
invoiced // faturado
cancelled // cancelado
}
// ─── Client (C2) ─────────────────────────────────────────────────────────────
// ─── Pedido (C3) ─────────────────────────────────────────────────────────────
//
// Cadastro sincronizado do ERP legado (FR-2.6). Rep não cria/edita no MVP.
// creditLimit: gerenciado no SAR (OQ-4 resolvido 2026-05-27).
// lastOrderAt/lastOrderValue/openOrdersCount: desnormalizados de Orders.
// Pedido emitido pelo Rep. Situa: 1=Pendente, 2=Aprovado, 3=Cancelado, 4=Faturado.
// idEmpresa: tenant (empresa no ERP). codVendedor: gestao.vendedor.codigo.
// idCliente: sig.corrent.id_corrent. numPedSar: sequencial SAR (SAR-NNNNN).
model Client {
id String @id @default(uuid()) @db.Uuid
name String
tradeName String?
taxId String @unique
email String?
phone String?
address Json?
model Pedido {
id String @id @default(uuid()) @db.Uuid
idEmpresa Int @map("id_empresa")
numPedSar String @unique @map("num_ped_sar")
idCliente Int @map("id_cliente")
codVendedor Int @map("cod_vendedor")
situa Int @default(1)
dtPedido DateTime @default(now()) @db.Date @map("dt_pedido")
idPauta Int? @map("id_pauta")
codFormapag Int? @map("cod_formapag")
totalProdutos Decimal @default(0) @db.Decimal(15, 2) @map("total_produtos")
totalIpi Decimal @default(0) @db.Decimal(15, 2) @map("total_ipi")
totalIcmsst Decimal @default(0) @db.Decimal(15, 2) @map("total_icmsst")
total Decimal @default(0) @db.Decimal(15, 2)
descontoPerc Decimal @default(0) @db.Decimal(5, 2) @map("desconto_perc")
descontoValor Decimal @default(0) @db.Decimal(15, 2) @map("desconto_valor")
acrescimo Decimal @default(0) @db.Decimal(15, 2)
comissao Decimal @default(0) @db.Decimal(15, 2)
pedFlex Decimal @default(0) @db.Decimal(15, 2) @map("ped_flex")
obs String?
aprovadoPor Int? @map("aprovado_por")
aprovadoEm DateTime? @map("aprovado_em")
motivoRecusa String? @map("motivo_recusa")
idempotencyKey String? @unique @map("idempotency_key")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
financialStatus FinancialStatus @default(regular)
creditLimit Decimal? @db.Decimal(15, 2)
itens PedidoItem[]
historico HistoricoPedido[]
repId String
lastOrderAt DateTime?
lastOrderValue Decimal? @db.Decimal(15, 2)
openOrdersCount Int @default(0)
erpCode String?
syncedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
orders Order[]
@@index([repId])
@@index([taxId])
@@index([name])
@@index([deletedAt])
@@index([idEmpresa])
@@index([codVendedor])
@@index([idCliente])
@@index([situa])
@@index([dtPedido])
@@map("pedidos")
}
// ─── Order (C3) ──────────────────────────────────────────────────────────────
// ─── PedidoItem (C3/C4) ──────────────────────────────────────────────────────
//
// Pedido emitido pelo Rep. Itens desnormalizados (produto sem FK — C4 traz catálogo).
// number: gerado pelo SAR (sequencial por workspace, ex: "PED-00042").
// discountPct: desconto global do pedido (além de descontos por item).
// approvedById: userId de quem aprovou (se status = approved ou invoiced).
// Item do pedido. Produto desnormalizado via idProduto (vw_produtos).
model Order {
id String @id @default(uuid()) @db.Uuid
number String @unique // "PED-00001"
clientId String @db.Uuid
repId String // userId do Rep que emitiu
status OrderStatus @default(budget)
discountPct Decimal @default(0) @db.Decimal(5, 2) // % desconto global
subtotal Decimal @db.Decimal(15, 2) // soma dos itens sem desconto global
total Decimal @db.Decimal(15, 2) // subtotal × (1 - discountPct/100)
notes String?
approvedById String? // userId de quem aprovou
approvedAt DateTime?
invoicedAt DateTime?
cancelledAt DateTime?
model PedidoItem {
id String @id @default(uuid()) @db.Uuid
idPedido String @db.Uuid @map("id_pedido")
ordem Int
idProduto Int @map("id_produto")
codProduto String? @map("cod_produto")
descProduto String? @map("desc_produto")
qtd Decimal @db.Decimal(10, 3)
precoUnitario Decimal @db.Decimal(15, 2) @map("preco_unitario")
descontoPerc Decimal @default(0) @db.Decimal(5, 2) @map("desconto_perc")
descontoValor Decimal @default(0) @db.Decimal(15, 2) @map("desconto_valor")
precoPauta Decimal @default(0) @db.Decimal(15, 2) @map("preco_pauta")
comissao Decimal @default(0) @db.Decimal(15, 2)
vlFlex Decimal @default(0) @db.Decimal(15, 2) @map("vl_flex")
precoComIpi Decimal @default(0) @db.Decimal(15, 2) @map("preco_com_ipi")
vlIpi Decimal @default(0) @db.Decimal(15, 2) @map("vl_ipi")
vlIcmsst Decimal @default(0) @db.Decimal(15, 2) @map("vl_icmsst")
total Decimal @db.Decimal(15, 2)
// Idempotency key para lançamentos offline (C4, FR-4.12)
idempotencyKey String? @unique
pedido Pedido @relation(fields: [idPedido], references: [id], onDelete: Cascade)
issuedAt DateTime @default(now()) // data de emissão pelo Rep
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
client Client @relation(fields: [clientId], references: [id])
items OrderItem[]
history OrderStatusHistory[]
@@index([clientId])
@@index([repId])
@@index([status])
@@index([issuedAt])
@@index([number])
@@index([deletedAt])
@@index([idPedido])
@@map("pedido_itens")
}
// ─── OrderItem (C3/C4) ───────────────────────────────────────────────────────
// ─── HistoricoPedido (C3) ────────────────────────────────────────────────────
//
// Item do pedido. Produto desnormalizado (nome/código/categoria) — snapshot no momento do pedido.
// productCategory: usado para validação de alçada por linha no POST /orders.
// discountPct: desconto por linha (além do desconto global do Order).
// Registro imutável de cada transição de situa. changedBy = cod_vendedor do ator.
model OrderItem {
id String @id @default(uuid()) @db.Uuid
orderId String @db.Uuid
productCode String // código no ERP / catálogo
productName String // desnormalizado para exibição offline
productCategory String @default("geral") // desnormalizado para alçada por linha
quantity Decimal @db.Decimal(10, 3)
unitPrice Decimal @db.Decimal(15, 2)
discountPct Decimal @default(0) @db.Decimal(5, 2)
subtotal Decimal @db.Decimal(15, 2) // qty × unitPrice × (1 - discountPct/100)
model HistoricoPedido {
id String @id @default(uuid()) @db.Uuid
idPedido String @db.Uuid @map("id_pedido")
situaAnterior Int? @map("situa_anterior")
situaNova Int @map("situa_nova")
changedBy Int @map("changed_by")
nota String?
changedAt DateTime @default(now()) @map("changed_at")
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
pedido Pedido @relation(fields: [idPedido], references: [id], onDelete: Cascade)
@@index([orderId])
@@index([idPedido])
@@map("historico_pedido")
}
// ─── Product (C4) ────────────────────────────────────────────────────────────
// ─── AlcadaDesconto (C4) ─────────────────────────────────────────────────────
//
// Catálogo sincronizado da view do ERP (FR-4.4). Rep usa para montar pedido.
// category: agrupa produtos por linha para validação de alçada por linha (OQ-2).
// unitPrice/stock: snapshot da última sync (TTL 4h — FR-4.4 [ASSUMPTION]).
// Produto inativo (active=false) não aparece no catálogo mas histórico de pedidos mantém referência.
// Alçada de desconto por vendedor, empresa e grupo de produto.
// codGrupo = 0 → limite global/default do rep.
model Product {
id String @id @default(uuid()) @db.Uuid
code String @unique
name String
description String?
category String @default("geral")
unitPrice Decimal @db.Decimal(15, 2)
stock Decimal? @db.Decimal(10, 3)
active Boolean @default(true)
erpCode String?
syncedAt DateTime?
model AlcadaDesconto {
codVendedor Int @map("cod_vendedor")
idEmpresa Int @map("id_empresa")
codGrupo Int @default(0) @map("cod_grupo")
limitePerc Decimal @default(5) @db.Decimal(5, 2) @map("limite_perc")
updatedAt DateTime @updatedAt @map("updated_at")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
@@index([code])
@@index([name])
@@index([category])
@@index([active])
@@index([deletedAt])
@@id([codVendedor, idEmpresa, codGrupo])
@@index([codVendedor, idEmpresa])
@@map("alcada_desconto")
}
// ─── RepTarget (C7) ──────────────────────────────────────────────────────────
// ─── MetaRepresentante (C7) ──────────────────────────────────────────────────
//
// Meta mensal e taxas de comissão por rep. Uma linha por rep/mês.
// commissionRate: % aplicada sobre o total aprovado+faturado do mês.
// flexRate: % bônus adicional quando atingido >= targetAmount.
// Meta mensal e taxas de comissão por rep. Uma linha por rep/empresa/mês.
model RepTarget {
repId String
year Int
month Int // 112
targetAmount Decimal @db.Decimal(15, 2)
commissionRate Decimal @default(3) @db.Decimal(5, 2)
flexRate Decimal @default(1) @db.Decimal(5, 2)
model MetaRepresentante {
codVendedor Int @map("cod_vendedor")
idEmpresa Int @map("id_empresa")
ano Int
mes Int
metaValor Decimal @db.Decimal(15, 2) @map("meta_valor")
taxaComissao Decimal @default(3) @db.Decimal(5, 2) @map("taxa_comissao")
taxaFlex Decimal @default(1) @db.Decimal(5, 2) @map("taxa_flex")
updatedAt DateTime @updatedAt @map("updated_at")
updatedAt DateTime @updatedAt
@@id([repId, year, month])
@@index([repId])
}
// ─── RepDiscountLimit (C4) ───────────────────────────────────────────────────
//
// Alçada de desconto por rep e por linha de produto (OQ-2 resolvida 2026-05-27).
// category = "__default__" → limite global do rep (fallback quando linha não tem override).
// Lookup: (repId, category) → se não encontrado → (repId, "__default__") → senão 5%.
model RepDiscountLimit {
repId String
category String // "__default__" para limite global; category string para override por linha
limit Decimal @default(5) @db.Decimal(5, 2)
updatedAt DateTime @updatedAt
@@id([repId, category])
@@index([repId])
@@id([codVendedor, idEmpresa, ano, mes])
@@index([codVendedor, idEmpresa])
@@map("meta_representante")
}
// ─── PushSubscription (C6) ───────────────────────────────────────────────────
//
// Subscription VAPID Web Push por usuário. endpoint é único por dispositivo/browser.
// role desnormalizado do JWT para filtrar destinatários (notifySupervisors, notifyUser).
// codVendedor desnormalizado do JWT para filtrar destinatários.
model PushSubscription {
id String @id @default(uuid()) @db.Uuid
userId String
role String // 'rep' | 'supervisor' | 'manager' | 'admin'
endpoint String @unique
p256dh String
auth String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(uuid()) @db.Uuid
codVendedor Int? @map("cod_vendedor")
idEmpresa Int @map("id_empresa")
role String
endpoint String @unique
p256dh String
auth String
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([userId])
@@index([role])
}
// ─── OrderStatusHistory (C3) ─────────────────────────────────────────────────
//
// Registro imutável de cada transição de status. changedById = userId do ator.
model OrderStatusHistory {
id String @id @default(uuid()) @db.Uuid
orderId String @db.Uuid
fromStatus OrderStatus?
toStatus OrderStatus
changedById String // userId
note String?
changedAt DateTime @default(now())
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
@@index([orderId])
@@index([changedAt])
@@index([idEmpresa])
@@index([codVendedor])
@@map("push_subscription")
}

View File

@@ -10,8 +10,9 @@ class DevTokenRequestDto extends createZodDto(DevTokenRequestSchema) {}
// Dev-only stub — emite JWT HS256 para smoke tests locais.
// CODING-RULES PGD-SEC-002: retorna 404 em produção.
// CODING-RULES PGD-AUTHZ-002: workspace_id vem do body aqui APENAS porque
// CODING-RULES PGD-AUTHZ-002: id_empresa vem do body aqui APENAS porque
// este endpoint É o gerador do token — nenhum outro handler pode fazer isso.
// ADR 0006 revogado: workspaceId → idEmpresa (Int da empresa no ERP)
@Public()
@Controller({ path: 'auth/dev' })
@@ -32,7 +33,7 @@ export class DevAuthController {
if (this.isProd) throw new NotFoundException();
const accessToken = await new SignJWT({
workspace_id: dto.workspaceId,
id_empresa: dto.idEmpresa,
role: dto.role,
})
.setProtectedHeader({ alg: 'HS256' })

View File

@@ -10,9 +10,10 @@ import { WorkspacePrismaPool } from '../workspace/workspace-prisma-pool.service'
import type { JwtPayload } from './jwt.types';
import { IS_PUBLIC_KEY } from './public.decorator';
// Guard global (APP_GUARD). Valida Bearer HS256 e atualiza CLS com workspace real.
// CODING-RULES PGD-AUTHZ-002: workspaceId sempre do JWT, nunca de body/param.
// Ordem NestJS: middleware CLS (workspace default) → este guard (workspace real).
// Guard global (APP_GUARD). Valida Bearer HS256 e atualiza CLS com idEmpresa real.
// CODING-RULES PGD-AUTHZ-002: idEmpresa sempre do JWT, nunca de body/param.
// Ordem NestJS: middleware CLS (idEmpresa default) → este guard (idEmpresa real).
// ADR 0006 revogado: workspace_id → id_empresa; URL inclui ?schema=sar
@Injectable()
export class JwtAuthGuard implements CanActivate {
@@ -44,16 +45,18 @@ export class JwtAuthGuard implements CanActivate {
(req as Request & { user: JwtPayload }).user = payload as JwtPayload;
// Sobrescreve CLS com workspace real do JWT (corre depois do middleware).
const workspaceId = payload.workspace_id;
this.cls.set('workspaceId', workspaceId);
// Sobrescreve CLS com idEmpresa real do JWT (corre depois do middleware).
const idEmpresa = payload.id_empresa;
this.cls.set('idEmpresa', idEmpresa);
this.cls.set('userId', payload.sub);
this.cls.set('role', payload.role);
const dbUrl =
// URL inclui ?schema=sar para o Prisma rotear ao schema correto no ERP
const baseUrl =
process.env['DATABASE_URL'] ??
`postgresql://sar:sar_dev_password@localhost:5432/sar_workspace_${workspaceId}`;
this.cls.set('prisma', this.pool.getOrCreate(workspaceId, dbUrl));
'postgresql://sar:sar_dev_password@localhost:5432/sar_workspace_dev';
const dbUrl = baseUrl.includes('?') ? `${baseUrl}&schema=sar` : `${baseUrl}?schema=sar`;
this.cls.set('prisma', this.pool.getOrCreate(idEmpresa, dbUrl));
return true;
} catch {

View File

@@ -1,11 +1,12 @@
// Claims do JWT emitido pelo master-login. Fonte da verdade para req.user.
// CODING-RULES PGD-AUTHZ-002: workspace_id vem sempre do token, nunca do body.
// CODING-RULES PGD-AUTHZ-002: id_empresa vem sempre do token, nunca do body.
// ADR 0006 revogado: workspace_id → id_empresa (Int, empresa no ERP).
export type JwtRole = 'rep' | 'supervisor' | 'manager' | 'admin';
export interface JwtPayload {
sub: string; // userId
workspace_id: string;
sub: string; // userId / cod_vendedor como string
id_empresa: number; // empresa no ERP (era workspace_id)
role: JwtRole;
iat?: number;
exp?: number;

View File

@@ -1,48 +1,31 @@
import {
Body,
Controller,
Get,
NotFoundException,
Param,
ParseUUIDPipe,
Post,
Query,
} from '@nestjs/common';
import { Controller, Get, NotFoundException, Param, ParseIntPipe, Query } from '@nestjs/common';
import { createZodDto } from 'nestjs-zod';
import {
ProductListQuerySchema,
ProductSyncRequestSchema,
type ProductDetail,
type ProductListQuery,
type ProductListResponse,
type ProductSyncRequest,
type ProductSyncResponse,
ProdutoListQuerySchema,
type ProdutoDetail,
type ProdutoListQuery,
type ProdutoListResponse,
} from '@sar/api-interface';
import { CatalogService } from './catalog.service';
class ProductListQueryDto extends createZodDto(ProductListQuerySchema) {}
class ProductSyncRequestDto extends createZodDto(ProductSyncRequestSchema) {}
class ProdutoListQueryDto extends createZodDto(ProdutoListQuerySchema) {}
// ADR 0006 revogado: UUID → Int para ID de produto. Sync removido (ERP direto via view).
@Controller({ path: 'catalog' })
export class CatalogController {
constructor(private readonly catalog: CatalogService) {}
@Get()
list(@Query() query: ProductListQueryDto): Promise<ProductListResponse> {
const parsed = ProductListQuerySchema.parse(query) as ProductListQuery;
list(@Query() query: ProdutoListQueryDto): Promise<ProdutoListResponse> {
const parsed = ProdutoListQuerySchema.parse(query) as ProdutoListQuery;
return this.catalog.list(parsed);
}
@Get(':id')
async findOne(@Param('id', ParseUUIDPipe) id: string): Promise<ProductDetail> {
async findOne(@Param('id', ParseIntPipe) id: number): Promise<ProdutoDetail> {
const product = await this.catalog.findOne(id);
if (!product) throw new NotFoundException(`Produto ${id} não encontrado`);
return product;
}
@Post('sync')
sync(@Body() body: ProductSyncRequestDto): Promise<ProductSyncResponse> {
const parsed = ProductSyncRequestSchema.parse(body) as ProductSyncRequest;
return this.catalog.sync(parsed);
}
}

View File

@@ -1,135 +1,188 @@
import { Injectable } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import { Prisma } from '@prisma/client';
import type {
ProductDetail,
ProductListQuery,
ProductListResponse,
ProductSummary,
ProductSyncRequest,
ProductSyncResponse,
ProdutoDetail,
ProdutoListQuery,
ProdutoListResponse,
ProdutoSummary,
} from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types';
function decimalToString(v: Prisma.Decimal | null | undefined): string | null {
return v ? v.toString() : null;
// ADR 0006 revogado: produtos lidos diretamente de vw_produtos (ERP) + vw_estoque.
// Sem sync — dados sempre frescos da view do ERP.
function escSql(s: string): string {
return s.replace(/'/g, "''");
}
interface ProdutoRow {
id_erp: number;
codigo: string;
descricao: string;
unidade: string | null;
vl_preco1: string;
cod_grupo: number | null;
grupo: string | null;
cod_subgrupo: number | null;
subgrupo: string | null;
marca: string | null;
ativo: number;
qtd_estoque: string | null;
lista_parauta: number | null;
referencia: string | null;
descricao_detalhada: string | null;
vl_preco2: string | null;
vl_preco3: string | null;
aliq_ipi: string | null;
peso_liquido: string | null;
qtd_volume: string | null;
lote_mul_venda: number | null;
preco_com_ipi: string | null;
preco_promocional: string | null;
}
@Injectable()
export class CatalogService {
constructor(private readonly cls: ClsService<WorkspaceClsStore>) {}
async list(query: ProductListQuery): Promise<ProductListResponse> {
async list(query: ProdutoListQuery): Promise<ProdutoListResponse> {
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 { q, category, page, limit } = query;
const skip = (page - 1) * limit;
const { q, codGrupo, page, limit } = query;
const offset = (page - 1) * limit;
const where: Prisma.ProductWhereInput = {
deletedAt: null,
active: true,
...(category ? { category } : {}),
...(q
? {
OR: [
{ name: { contains: q, mode: 'insensitive' } },
{ code: { contains: q, mode: 'insensitive' } },
],
}
: {}),
};
const grupoFilter = codGrupo != null ? `AND p.cod_grupo = ${codGrupo}` : '';
const searchFilter = q
? `AND (p.descricao ILIKE '%${escSql(q)}%' OR p.codigo ILIKE '%${escSql(q)}%')`
: '';
const [rows, total] = await Promise.all([
prisma.product.findMany({
where,
select: {
id: true,
code: true,
name: true,
category: true,
unitPrice: true,
stock: true,
active: true,
},
skip,
take: limit,
orderBy: [{ category: 'asc' }, { name: 'asc' }],
}),
prisma.product.count({ where }),
]);
const rows = await prisma.$queryRawUnsafe<ProdutoRow[]>(`
SELECT
p.id_erp,
p.codigo,
p.descricao,
p.unidade,
p.vl_preco1::text,
p.cod_grupo,
p.grupo,
p.cod_subgrupo,
p.subgrupo,
p.marca,
p.ativo,
e.qtd_estoque::text,
p.lista_parauta,
p.referencia,
p.descricao_detalhada,
p.vl_preco2::text,
p.vl_preco3::text,
p.aliq_ipi::text,
p.peso_liquido::text,
p.qtd_volume::text,
p.lote_mul_venda,
p.preco_com_ipi::text,
p.preco_promocional::text
FROM vw_produtos p
LEFT JOIN vw_estoque e ON e.id_erp = p.id_erp AND e.id_empresa = ${idEmpresa}
WHERE p.ativo = 1
${grupoFilter}
${searchFilter}
ORDER BY p.descricao
LIMIT ${limit} OFFSET ${offset}
`);
const data: ProductSummary[] = rows.map((p) => ({
id: p.id,
code: p.code,
name: p.name,
category: p.category,
unitPrice: decimalToString(p.unitPrice) ?? '0',
stock: decimalToString(p.stock),
active: p.active,
const totalRows = await prisma.$queryRawUnsafe<[{ count: string }]>(`
SELECT COUNT(*)::text AS count
FROM vw_produtos p
WHERE p.ativo = 1
${grupoFilter}
${searchFilter}
`);
const total = parseInt(totalRows[0]?.count ?? '0', 10);
const data: ProdutoSummary[] = rows.map((p) => ({
idErp: Number(p.id_erp),
codigo: p.codigo,
descricao: p.descricao,
unidade: p.unidade,
vlPreco1: p.vl_preco1,
codGrupo: p.cod_grupo !== null ? Number(p.cod_grupo) : null,
grupo: p.grupo,
codSubgrupo: p.cod_subgrupo !== null ? Number(p.cod_subgrupo) : null,
subgrupo: p.subgrupo,
marca: p.marca,
ativo: Number(p.ativo),
qtdEstoque: p.qtd_estoque,
listaParauta: p.lista_parauta !== null ? Number(p.lista_parauta) : null,
}));
return { data, total, page, limit };
}
async findOne(id: string): Promise<ProductDetail | null> {
async findOne(idErp: number): Promise<ProdutoDetail | null> {
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 p = await prisma.product.findFirst({ where: { id, deletedAt: null, active: true } });
const rows = await prisma.$queryRawUnsafe<ProdutoRow[]>(`
SELECT
p.id_erp,
p.codigo,
p.descricao,
p.unidade,
p.vl_preco1::text,
p.cod_grupo,
p.grupo,
p.cod_subgrupo,
p.subgrupo,
p.marca,
p.ativo,
e.qtd_estoque::text,
p.lista_parauta,
p.referencia,
p.descricao_detalhada,
p.vl_preco2::text,
p.vl_preco3::text,
p.aliq_ipi::text,
p.peso_liquido::text,
p.qtd_volume::text,
p.lote_mul_venda,
p.preco_com_ipi::text,
p.preco_promocional::text
FROM vw_produtos p
LEFT JOIN vw_estoque e ON e.id_erp = p.id_erp AND e.id_empresa = ${idEmpresa}
WHERE p.id_erp = ${idErp} AND p.ativo = 1
LIMIT 1
`);
const p = rows[0];
if (!p) return null;
return {
id: p.id,
code: p.code,
name: p.name,
description: p.description,
category: p.category,
unitPrice: decimalToString(p.unitPrice) ?? '0',
stock: decimalToString(p.stock),
active: p.active,
erpCode: p.erpCode,
syncedAt: p.syncedAt?.toISOString() ?? null,
createdAt: p.createdAt.toISOString(),
updatedAt: p.updatedAt.toISOString(),
idErp: Number(p.id_erp),
codigo: p.codigo,
descricao: p.descricao,
unidade: p.unidade,
vlPreco1: p.vl_preco1,
codGrupo: p.cod_grupo !== null ? Number(p.cod_grupo) : null,
grupo: p.grupo,
codSubgrupo: p.cod_subgrupo !== null ? Number(p.cod_subgrupo) : null,
subgrupo: p.subgrupo,
marca: p.marca,
ativo: Number(p.ativo),
qtdEstoque: p.qtd_estoque,
listaParauta: p.lista_parauta !== null ? Number(p.lista_parauta) : null,
referencia: p.referencia,
descricaoDetalhada: p.descricao_detalhada,
vlPreco2: p.vl_preco2,
vlPreco3: p.vl_preco3,
aliqIpi: p.aliq_ipi,
pesoLiquido: p.peso_liquido,
qtdVolume: p.qtd_volume,
loteMulVenda: p.lote_mul_venda !== null ? Number(p.lote_mul_venda) : null,
precoComIpi: p.preco_com_ipi,
precoPromocional: p.preco_promocional,
};
}
async sync(req: ProductSyncRequest): Promise<ProductSyncResponse> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const syncedAt = new Date();
let upserted = 0;
for (const item of req.items) {
await prisma.product.upsert({
where: { code: item.code },
create: {
code: item.code,
name: item.name,
description: item.description ?? null,
category: item.category ?? 'geral',
unitPrice: item.unitPrice,
stock: item.stock ?? null,
active: item.active ?? true,
erpCode: item.erpCode ?? null,
syncedAt,
},
update: {
name: item.name,
description: item.description ?? null,
category: item.category ?? 'geral',
unitPrice: item.unitPrice,
stock: item.stock ?? null,
active: item.active ?? true,
erpCode: item.erpCode ?? null,
syncedAt,
},
});
upserted++;
}
return { upserted, syncedAt: syncedAt.toISOString() };
}
}

View File

@@ -1,46 +1,27 @@
import { Controller, Get, Param, ParseUUIDPipe, Query } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import { Controller, Get, Param, ParseIntPipe, Query } from '@nestjs/common';
import { createZodDto } from 'nestjs-zod';
import {
ClientListQuerySchema,
type ClientDetail,
type ClientListQuery,
type ClientListResponse,
type OrderSummary,
} from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types';
import { ClientsService } from './clients.service';
import { OrdersService } from '../orders/orders.service';
class ClientListQueryDto extends createZodDto(ClientListQuerySchema) {}
@Controller({ path: 'clients' })
export class ClientsController {
constructor(
private readonly clients: ClientsService,
private readonly orders: OrdersService,
private readonly cls: ClsService<WorkspaceClsStore>,
) {}
constructor(private readonly clients: ClientsService) {}
@Get()
list(@Query() query: ClientListQueryDto): Promise<ClientListResponse> {
// parse aplica defaults (page=1, limit=50) definidos no schema
const parsed = ClientListQuerySchema.parse(query) as ClientListQuery;
return this.clients.list(parsed, this.cls.get('userId') ?? '', this.cls.get('role') ?? 'rep');
return this.clients.list(parsed);
}
@Get(':id')
findOne(@Param('id', ParseUUIDPipe) id: string): Promise<ClientDetail> {
return this.clients.findOne(id, this.cls.get('userId') ?? '', this.cls.get('role') ?? 'rep');
}
// Últimos 10 pedidos do cliente — exibidos na ficha (FR-2.4).
@Get(':id/orders')
clientOrders(@Param('id', ParseUUIDPipe) id: string): Promise<OrderSummary[]> {
return this.orders.listByClient(
id,
this.cls.get('userId') ?? '',
this.cls.get('role') ?? 'rep',
);
findOne(@Param('id', ParseIntPipe) id: number): Promise<ClientDetail> {
return this.clients.findOne(id);
}
}

View File

@@ -1,10 +1,8 @@
import { Module } from '@nestjs/common';
import { ClientsController } from './clients.controller';
import { ClientsService } from './clients.service';
import { OrdersModule } from '../orders/orders.module';
@Module({
imports: [OrdersModule],
controllers: [ClientsController],
providers: [ClientsService],
})

View File

@@ -1,6 +1,5 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import { Prisma } from '@prisma/client';
import type {
ClientDetail,
ClientListQuery,
@@ -10,123 +9,187 @@ import type {
} from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types';
// Thresholds de atividade (FR-2.3). Configuráveis por workspace futuramente.
// Thresholds de atividade (FR-2.3). Configuráveis por empresa futuramente.
const ALERT_DAYS = 30;
const INACTIVE_DAYS = 60;
function activityStatus(lastOrderAt: Date | null): ActivityStatus {
if (!lastOrderAt) return 'inactive';
const days = Math.floor((Date.now() - lastOrderAt.getTime()) / 86_400_000);
function activityStatus(dtUltimaCompra: Date | null): ActivityStatus {
if (!dtUltimaCompra) return 'inactive';
const days = Math.floor((Date.now() - dtUltimaCompra.getTime()) / 86_400_000);
if (days >= INACTIVE_DAYS) return 'inactive';
if (days >= ALERT_DAYS) return 'alert';
return 'active';
}
function decimalToString(v: Prisma.Decimal | null): string | null {
return v ? v.toString() : null;
function escSql(s: string): string {
return s.replace(/'/g, "''");
}
// Row bruta do $queryRawUnsafe
interface ClientRow {
id_cliente: number;
id_empresa: number;
nome: string;
razao: string | null;
cgcpf: string | null;
email: string | null;
telefone: string | null;
cod_vendedor: number;
limite_credito: string | null;
dt_ultima_compra: Date | null;
ativo: number;
pessoa: number | null;
inscricao_estadual: string | null;
endereco: string | null;
num_endereco: string | null;
bairro: string | null;
cep: string | null;
ddd: string | null;
obs: string | null;
cod_pauta: number | null;
dt_cadastro: string | null;
dt_atual: string | null;
}
@Injectable()
export class ClientsService {
constructor(private readonly cls: ClsService<WorkspaceClsStore>) {}
async list(query: ClientListQuery, userId: string, role: string): Promise<ClientListResponse> {
async list(query: ClientListQuery): Promise<ClientListResponse> {
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 role = this.cls.get('role');
const userId = this.cls.get('userId');
const codVendedor = userId ? parseInt(userId, 10) : 0;
const { q, status, financialStatus, page, limit } = query;
const skip = (page - 1) * limit;
const { q, status, page, limit } = query;
const offset = (page - 1) * limit;
// Rep vê apenas sua carteira; supervisor/manager/admin vê tudo (FR-2.1).
const repFilter: Prisma.ClientWhereInput = role === 'rep' ? { repId: userId } : {};
// Rep vê apenas sua carteira (cod_vendedor = seu código)
const vendedorFilter = role === 'rep' ? `AND c.cod_vendedor = ${codVendedor}` : '';
const searchFilter = q
? `AND (c.nome ILIKE '%${escSql(q)}%' OR c.cgcpf LIKE '%${escSql(q)}%')`
: '';
const searchFilter: Prisma.ClientWhereInput = q
? {
OR: [
{ name: { contains: q, mode: 'insensitive' } },
{ tradeName: { contains: q, mode: 'insensitive' } },
{ taxId: { contains: q } },
],
}
: {};
const rows = await prisma.$queryRawUnsafe<ClientRow[]>(`
SELECT
c.id_cliente,
c.id_empresa,
c.nome,
c.razao,
c.cgcpf,
c.email,
c.telefone,
c.cod_vendedor,
c.limite_credito::text,
c.ativo,
c.pessoa,
c.inscricao_estadual,
c.endereco,
c.num_endereco,
c.bairro,
c.cep,
c.ddd,
c.obs,
c.cod_pauta,
c.dt_cadastro::text,
c.dt_atual::text,
MAX(p.dt_pedido) AS dt_ultima_compra
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 != 3
WHERE c.id_empresa = ${idEmpresa}
AND c.ativo = 1
${vendedorFilter}
${searchFilter}
GROUP BY
c.id_cliente, c.id_empresa, c.nome, c.razao, c.cgcpf, c.email,
c.telefone, c.cod_vendedor, c.limite_credito, c.ativo, c.pessoa,
c.inscricao_estadual, c.endereco, c.num_endereco, c.bairro, c.cep,
c.ddd, c.obs, c.cod_pauta, c.dt_cadastro, c.dt_atual
ORDER BY c.nome
LIMIT ${limit} OFFSET ${offset}
`);
const financialFilter: Prisma.ClientWhereInput = financialStatus ? { financialStatus } : {};
const totalRows = await prisma.$queryRawUnsafe<[{ count: string }]>(`
SELECT COUNT(*)::text AS count
FROM vw_clientes c
WHERE c.id_empresa = ${idEmpresa}
AND c.ativo = 1
${vendedorFilter}
${searchFilter}
`);
const total = parseInt(totalRows[0]?.count ?? '0', 10);
const where: Prisma.ClientWhereInput = {
deletedAt: null,
...repFilter,
...searchFilter,
...financialFilter,
};
const [rows, total] = await Promise.all([
prisma.client.findMany({
where,
select: {
id: true,
name: true,
tradeName: true,
taxId: true,
financialStatus: true,
lastOrderAt: true,
lastOrderValue: true,
openOrdersCount: true,
},
skip,
take: limit,
orderBy: { name: 'asc' },
}),
prisma.client.count({ where }),
]);
// Filtra por activityStatus depois do fetch (computed field — não persiste no DB).
const mapped: ClientSummary[] = rows.map((r) => ({
id: r.id,
name: r.name,
tradeName: r.tradeName,
taxId: r.taxId,
financialStatus: r.financialStatus,
activityStatus: activityStatus(r.lastOrderAt),
lastOrderAt: r.lastOrderAt?.toISOString() ?? null,
lastOrderValue: decimalToString(r.lastOrderValue),
openOrdersCount: r.openOrdersCount,
let mapped: ClientSummary[] = rows.map((r) => ({
idCliente: Number(r.id_cliente),
idEmpresa: Number(r.id_empresa),
nome: r.nome,
razao: r.razao,
cgcpf: r.cgcpf,
email: r.email,
telefone: r.telefone,
codVendedor: Number(r.cod_vendedor),
limiteCreditoStr: r.limite_credito,
activityStatus: activityStatus(r.dt_ultima_compra),
dtUltimaCompra: r.dt_ultima_compra?.toISOString() ?? null,
}));
const filtered = status ? mapped.filter((c) => c.activityStatus === status) : mapped;
if (status) mapped = mapped.filter((c) => c.activityStatus === status);
return { data: filtered, total, page, limit };
return { data: mapped, total, page, limit };
}
async findOne(id: string, userId: string, role: string): Promise<ClientDetail> {
async findOne(idCliente: number): Promise<ClientDetail> {
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 repFilter: Prisma.ClientWhereInput = role === 'rep' ? { repId: userId } : {};
const rows = await prisma.$queryRawUnsafe<ClientRow[]>(`
SELECT
c.id_cliente, c.id_empresa, c.nome, c.razao, c.cgcpf, c.email,
c.telefone, c.cod_vendedor, c.limite_credito::text,
c.ativo, c.pessoa, c.inscricao_estadual, c.endereco, c.num_endereco,
c.bairro, c.cep, c.ddd, c.obs, c.cod_pauta,
c.dt_cadastro::text, c.dt_atual::text,
MAX(p.dt_pedido) AS dt_ultima_compra
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 != 3
WHERE c.id_empresa = ${idEmpresa} AND c.id_cliente = ${idCliente}
GROUP BY c.id_cliente, c.id_empresa, c.nome, c.razao, c.cgcpf, c.email,
c.telefone, c.cod_vendedor, c.limite_credito, c.ativo, c.pessoa,
c.inscricao_estadual, c.endereco, c.num_endereco, c.bairro, c.cep,
c.ddd, c.obs, c.cod_pauta, c.dt_cadastro, c.dt_atual
LIMIT 1
`);
const client = await prisma.client.findFirst({
where: { id, deletedAt: null, ...repFilter },
});
if (!client) throw new NotFoundException(`Cliente ${id} não encontrado`);
const r = rows[0];
if (!r) throw new NotFoundException(`Cliente ${idCliente} não encontrado`);
return {
id: client.id,
name: client.name,
tradeName: client.tradeName,
taxId: client.taxId,
email: client.email,
phone: client.phone,
address: client.address as ClientDetail['address'],
financialStatus: client.financialStatus,
activityStatus: activityStatus(client.lastOrderAt),
creditLimit: decimalToString(client.creditLimit),
lastOrderAt: client.lastOrderAt?.toISOString() ?? null,
lastOrderValue: decimalToString(client.lastOrderValue),
openOrdersCount: client.openOrdersCount,
erpCode: client.erpCode,
syncedAt: client.syncedAt?.toISOString() ?? null,
createdAt: client.createdAt.toISOString(),
updatedAt: client.updatedAt.toISOString(),
idCliente: Number(r.id_cliente),
idEmpresa: Number(r.id_empresa),
nome: r.nome,
razao: r.razao,
cgcpf: r.cgcpf,
email: r.email,
telefone: r.telefone,
codVendedor: Number(r.cod_vendedor),
limiteCreditoStr: r.limite_credito,
activityStatus: activityStatus(r.dt_ultima_compra),
dtUltimaCompra: r.dt_ultima_compra?.toISOString() ?? null,
ativo: Number(r.ativo),
pessoa: r.pessoa !== null ? Number(r.pessoa) : null,
inscricaoEstadual: r.inscricao_estadual,
endereco: r.endereco,
numEndereco: r.num_endereco,
bairro: r.bairro,
cep: r.cep,
ddd: r.ddd,
obs: r.obs,
codPauta: r.cod_pauta !== null ? Number(r.cod_pauta) : null,
dtCadastro: r.dt_cadastro,
dtAtual: r.dt_atual,
};
}
}

View File

@@ -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(),
};

View File

@@ -1,10 +1,12 @@
import { Injectable } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import { OrderStatus } from '@prisma/client';
import type { SubscribePayload, PendingCountResponse } from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types';
import { PushService, type PushPayload } from './push.service';
// Situa: 1=Pendente Aprovação
const SITUA_PENDENTE = 1;
@Injectable()
export class NotificationsService {
constructor(
@@ -15,12 +17,21 @@ export class NotificationsService {
async subscribe(userId: string, role: string, dto: SubscribePayload): Promise<void> {
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 = userId ? parseInt(userId, 10) : null;
await prisma.pushSubscription.upsert({
where: { endpoint: dto.endpoint },
update: { userId, role, p256dh: dto.keys.p256dh, auth: dto.keys.auth },
update: {
codVendedor,
idEmpresa,
role,
p256dh: dto.keys.p256dh,
auth: dto.keys.auth,
},
create: {
userId,
codVendedor,
idEmpresa,
role,
endpoint: dto.endpoint,
p256dh: dto.keys.p256dh,
@@ -39,10 +50,11 @@ export class NotificationsService {
async pendingCount(userId: string, role: string): Promise<PendingCountResponse> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa');
if (role === 'supervisor' || role === 'manager' || role === 'admin') {
const count = await prisma.order.count({
where: { status: OrderStatus.pending_approval, deletedAt: null },
const count = await prisma.pedido.count({
where: { situa: SITUA_PENDENTE, idEmpresa },
});
return { count };
}
@@ -50,24 +62,32 @@ export class NotificationsService {
return { count: 0 };
}
// Envia push para todos os supervisores/managers/admin do workspace.
// Envia push para todos os supervisores/managers/admin da empresa.
async notifySupervisors(payload: PushPayload): Promise<void> {
const prisma = this.cls.get('prisma');
if (!prisma) return;
const idEmpresa = this.cls.get('idEmpresa');
const subs = await prisma.pushSubscription.findMany({
where: { role: { in: ['supervisor', 'manager', 'admin'] } },
where: {
idEmpresa,
role: { in: ['supervisor', 'manager', 'admin'] },
},
});
await Promise.allSettled(subs.map((s) => this.push.send(s, payload)));
}
// Envia push para um userId específico (todos os dispositivos registrados).
// Envia push para um codVendedor específico (todos os dispositivos registrados).
async notifyUser(userId: string, payload: PushPayload): Promise<void> {
const prisma = this.cls.get('prisma');
if (!prisma) return;
const idEmpresa = this.cls.get('idEmpresa');
const codVendedor = parseInt(userId, 10);
const subs = await prisma.pushSubscription.findMany({ where: { userId } });
const subs = await prisma.pushSubscription.findMany({
where: { idEmpresa, codVendedor },
});
await Promise.allSettled(subs.map((s) => this.push.send(s, payload)));
}
}

View File

@@ -13,24 +13,24 @@ import {
import { ClsService } from 'nestjs-cls';
import { createZodDto } from 'nestjs-zod';
import {
ApproveOrderSchema,
CreateOrderSchema,
OrderListQuerySchema,
RejectOrderSchema,
type ApproveOrder,
type CreateOrder,
type OrderDetail,
type OrderListQuery,
type OrderListResponse,
type RejectOrder,
AprovarPedidoSchema,
CreatePedidoSchema,
PedidoListQuerySchema,
RecusarPedidoSchema,
type AprovarPedido,
type CreatePedido,
type PedidoDetail,
type PedidoListQuery,
type PedidoListResponse,
type RecusarPedido,
} from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types';
import { OrdersService } from './orders.service';
class OrderListQueryDto extends createZodDto(OrderListQuerySchema) {}
class CreateOrderDto extends createZodDto(CreateOrderSchema) {}
class ApproveOrderDto extends createZodDto(ApproveOrderSchema) {}
class RejectOrderDto extends createZodDto(RejectOrderSchema) {}
class PedidoListQueryDto extends createZodDto(PedidoListQuerySchema) {}
class CreatePedidoDto extends createZodDto(CreatePedidoSchema) {}
class AprovarPedidoDto extends createZodDto(AprovarPedidoSchema) {}
class RecusarPedidoDto extends createZodDto(RecusarPedidoSchema) {}
@Controller({ path: 'orders' })
export class OrdersController {
@@ -40,42 +40,42 @@ export class OrdersController {
) {}
@Get()
list(@Query() query: OrderListQueryDto): Promise<OrderListResponse> {
const parsed = OrderListQuerySchema.parse(query) as OrderListQuery;
return this.orders.list(parsed, this.cls.get('userId') ?? '', this.cls.get('role') ?? 'rep');
list(@Query() query: PedidoListQueryDto): Promise<PedidoListResponse> {
const parsed = PedidoListQuerySchema.parse(query) as PedidoListQuery;
return this.orders.list(parsed);
}
@Post()
@HttpCode(201)
create(@Body() body: CreateOrderDto): Promise<OrderDetail> {
const parsed = CreateOrderSchema.parse(body) as CreateOrder;
return this.orders.create(parsed, this.cls.get('userId') ?? '');
create(@Body() body: CreatePedidoDto): Promise<PedidoDetail> {
const parsed = CreatePedidoSchema.parse(body) as CreatePedido;
return this.orders.create(parsed);
}
@Patch(':id/approve')
approve(
@Param('id', ParseUUIDPipe) id: string,
@Body() body: ApproveOrderDto,
): Promise<OrderDetail> {
@Body() body: AprovarPedidoDto,
): Promise<PedidoDetail> {
const role = this.cls.get('role') ?? 'rep';
if (role === 'rep') throw new ForbiddenException('Apenas supervisores podem aprovar pedidos');
const parsed = ApproveOrderSchema.parse(body) as ApproveOrder;
return this.orders.approve(id, this.cls.get('userId') ?? '', parsed);
const parsed = AprovarPedidoSchema.parse(body) as AprovarPedido;
return this.orders.approve(id, parsed);
}
@Patch(':id/reject')
reject(
@Param('id', ParseUUIDPipe) id: string,
@Body() body: RejectOrderDto,
): Promise<OrderDetail> {
@Body() body: RecusarPedidoDto,
): Promise<PedidoDetail> {
const role = this.cls.get('role') ?? 'rep';
if (role === 'rep') throw new ForbiddenException('Apenas supervisores podem recusar pedidos');
const parsed = RejectOrderSchema.parse(body) as RejectOrder;
return this.orders.reject(id, this.cls.get('userId') ?? '', parsed);
const parsed = RecusarPedidoSchema.parse(body) as RecusarPedido;
return this.orders.reject(id, parsed);
}
@Get(':id')
findOne(@Param('id', ParseUUIDPipe) id: string): Promise<OrderDetail> {
return this.orders.findOne(id, this.cls.get('userId') ?? '', this.cls.get('role') ?? 'rep');
findOne(@Param('id', ParseUUIDPipe) id: string): Promise<PedidoDetail> {
return this.orders.findOne(id);
}
}

View File

@@ -1,18 +1,23 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import { OrderStatus, Prisma } from '@prisma/client';
import { Prisma } from '@prisma/client';
import type {
ApproveOrder,
CreateOrder,
OrderDetail,
OrderListQuery,
OrderListResponse,
OrderSummary,
RejectOrder,
AprovarPedido,
CreatePedido,
PedidoDetail,
PedidoListQuery,
PedidoListResponse,
PedidoSummary,
RecusarPedido,
} from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types';
import { NotificationsService } from '../notifications/notifications.service';
// Situa: 1=Pendente Aprovação, 2=Aprovado, 3=Cancelado, 4=Faturado
const SITUA_PENDENTE = 1;
const SITUA_APROVADO = 2;
const SITUA_CANCELADO = 3;
function decimalToString(v: Prisma.Decimal | null | undefined): string {
return v ? v.toString() : '0';
}
@@ -24,24 +29,29 @@ export class OrdersService {
private readonly notifications: NotificationsService,
) {}
async list(query: OrderListQuery, userId: string, role: string): Promise<OrderListResponse> {
async list(query: PedidoListQuery): Promise<PedidoListResponse> {
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 role = this.cls.get('role');
const userId = this.cls.get('userId');
const codVendedor = userId ? parseInt(userId, 10) : 0;
const { clientId, status, number, from, to, page, limit } = query;
const { idCliente, situa, numPedSar, from, to, page, limit } = query;
const skip = (page - 1) * limit;
const repFilter: Prisma.OrderWhereInput = role === 'rep' ? { repId: userId } : {};
const repFilter = role === 'rep' ? { codVendedor } : {};
const where: Prisma.OrderWhereInput = {
deletedAt: null,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const where: any = {
idEmpresa,
...repFilter,
...(clientId ? { clientId } : {}),
...(status ? { status } : {}),
...(number ? { number: { contains: number, mode: 'insensitive' } } : {}),
...(idCliente != null ? { idCliente } : {}),
...(situa != null ? { situa } : {}),
...(numPedSar ? { numPedSar: { contains: numPedSar } } : {}),
...(from || to
? {
issuedAt: {
dtPedido: {
...(from ? { gte: new Date(from) } : {}),
...(to ? { lte: new Date(to) } : {}),
},
@@ -50,349 +60,267 @@ export class OrdersService {
};
const [rows, total] = await Promise.all([
prisma.order.findMany({
prisma.pedido.findMany({
where,
include: { client: { select: { name: true } } },
skip,
take: limit,
orderBy: { issuedAt: 'desc' },
orderBy: { dtPedido: 'desc' },
}),
prisma.order.count({ where }),
prisma.pedido.count({ where }),
]);
const data: OrderSummary[] = rows.map((o) => ({
const data: PedidoSummary[] = rows.map((o) => ({
id: o.id,
number: o.number,
clientId: o.clientId,
clientName: o.client.name,
repId: o.repId,
status: o.status,
discountPct: decimalToString(o.discountPct),
subtotal: decimalToString(o.subtotal),
numPedSar: o.numPedSar,
idCliente: o.idCliente,
codVendedor: o.codVendedor,
situa: o.situa,
dtPedido: o.dtPedido.toISOString(),
total: decimalToString(o.total),
issuedAt: o.issuedAt.toISOString(),
approvedAt: o.approvedAt?.toISOString() ?? null,
invoicedAt: o.invoicedAt?.toISOString() ?? null,
cancelledAt: o.cancelledAt?.toISOString() ?? null,
descontoPerc: decimalToString(o.descontoPerc),
obs: o.obs,
createdAt: o.createdAt.toISOString(),
}));
return { data, total, page, limit };
}
async findOne(id: string, userId: string, role: string): Promise<OrderDetail> {
async findOne(id: string): Promise<PedidoDetail> {
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 role = this.cls.get('role');
const userId = this.cls.get('userId');
const codVendedor = userId ? parseInt(userId, 10) : 0;
const repFilter: Prisma.OrderWhereInput = role === 'rep' ? { repId: userId } : {};
const repFilter = role === 'rep' ? { codVendedor } : {};
const o = await prisma.order.findFirst({
where: { id, deletedAt: null, ...repFilter },
const o = await prisma.pedido.findFirst({
where: { id, idEmpresa, ...repFilter },
include: {
client: { select: { name: true } },
items: true,
history: { orderBy: { changedAt: 'asc' } },
itens: { orderBy: { ordem: 'asc' } },
historico: { orderBy: { changedAt: 'asc' } },
},
});
if (!o) throw new NotFoundException(`Pedido ${id} não encontrado`);
return {
id: o.id,
number: o.number,
clientId: o.clientId,
clientName: o.client.name,
repId: o.repId,
status: o.status,
discountPct: decimalToString(o.discountPct),
subtotal: decimalToString(o.subtotal),
total: decimalToString(o.total),
notes: o.notes,
approvedById: o.approvedById,
idempotencyKey: o.idempotencyKey,
issuedAt: o.issuedAt.toISOString(),
approvedAt: o.approvedAt?.toISOString() ?? null,
invoicedAt: o.invoicedAt?.toISOString() ?? null,
cancelledAt: o.cancelledAt?.toISOString() ?? null,
createdAt: o.createdAt.toISOString(),
updatedAt: o.updatedAt.toISOString(),
items: o.items.map((it) => ({
id: it.id,
productCode: it.productCode,
productName: it.productName,
quantity: decimalToString(it.quantity),
unitPrice: decimalToString(it.unitPrice),
discountPct: decimalToString(it.discountPct),
subtotal: decimalToString(it.subtotal),
})),
history: o.history.map((h) => ({
id: h.id,
fromStatus: h.fromStatus,
toStatus: h.toStatus,
changedById: h.changedById,
note: h.note,
changedAt: h.changedAt.toISOString(),
})),
};
return this.mapDetail(o);
}
// Cria novo pedido. Valida alçada por linha de produto (OQ-2).
// Cria novo pedido. Valida alçada por codGrupo (codGrupo=0 = default).
// Idempotency-Key: retorna pedido existente se já processado (FR-4.3).
async create(dto: CreateOrder, userId: string): Promise<OrderDetail> {
async create(dto: CreatePedido): Promise<PedidoDetail> {
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') ?? '0';
const codVendedor = parseInt(userId, 10);
// Idempotency-Key: retorna pedido existente sem re-processar
if (dto.idempotencyKey) {
const existing = await prisma.order.findUnique({
const existing = await prisma.pedido.findUnique({
where: { idempotencyKey: dto.idempotencyKey },
include: {
client: { select: { name: true } },
items: true,
history: { orderBy: { changedAt: 'asc' } },
itens: { orderBy: { ordem: 'asc' } },
historico: { orderBy: { changedAt: 'asc' } },
},
});
if (existing) return this.mapDetail(existing);
}
// Verifica que o cliente existe e pertence ao rep
const client = await prisma.client.findFirst({
where: { id: dto.clientId, repId: userId, deletedAt: null },
// Resolve alçadas: (codVendedor, idEmpresa, codGrupo=0) = default
const limitRows = await prisma.alcadaDesconto.findMany({
where: { codVendedor, idEmpresa },
});
if (!client) throw new NotFoundException(`Cliente ${dto.clientId} não encontrado`);
const limitMap = new Map(limitRows.map((r) => [r.codGrupo, Number(r.limitePerc)]));
const getLimit = (codGrupo: number) => limitMap.get(codGrupo) ?? limitMap.get(0) ?? 5;
// Resolve alçadas por categoria: (repId, category) → fallback (repId, '__default__') → 5%
const limitRows = await prisma.repDiscountLimit.findMany({ where: { repId: userId } });
const limitMap = new Map(limitRows.map((r) => [r.category, Number(r.limit)]));
const getLimit = (category: string) =>
limitMap.get(category) ?? limitMap.get('__default__') ?? 5;
// Alçada global (codGrupo=0)
const needsApproval = dto.descontoPerc > getLimit(0);
// Valida alçada item a item
let needsApproval = false;
for (const item of dto.items) {
const lim = getLimit(item.productCategory ?? 'geral');
if (item.discountPct > lim) {
needsApproval = true;
break;
}
}
// Alçada global: compara desconto global do pedido com o default do rep
const globalLimit = getLimit('__default__');
if (dto.discountPct > globalLimit) needsApproval = true;
const status = needsApproval ? OrderStatus.pending_approval : OrderStatus.budget;
// Gera número sequencial: PED-NNNNN
const lastOrder = await prisma.order.findFirst({
orderBy: { createdAt: 'desc' },
select: { number: true },
});
const seq = lastOrder ? parseInt(lastOrder.number.replace('PED-', ''), 10) + 1 : 1;
const number = `PED-${String(seq).padStart(5, '0')}`;
// Calcula subtotais
const itemsData = dto.items.map((it) => {
const subtotal =
Math.round(it.quantity * it.unitPrice * (1 - it.discountPct / 100) * 100) / 100;
// Calcula totais dos itens
const itemsData = dto.itens.map((it) => {
const descontoValor =
Math.round(it.qtd * it.precoUnitario * (it.descontoPerc / 100) * 100) / 100;
const total = Math.round(it.qtd * it.precoUnitario * (1 - it.descontoPerc / 100) * 100) / 100;
return {
productCode: it.productCode,
productName: it.productName,
productCategory: it.productCategory ?? 'geral',
quantity: it.quantity,
unitPrice: it.unitPrice,
discountPct: it.discountPct,
subtotal,
ordem: it.ordem,
idProduto: it.idProduto,
codProduto: it.codProduto ?? null,
descProduto: it.descProduto,
qtd: it.qtd,
precoUnitario: it.precoUnitario,
descontoPerc: it.descontoPerc,
descontoValor,
total,
};
});
const itemsSubtotal = itemsData.reduce((acc, it) => acc + it.subtotal, 0);
const total = Math.round(itemsSubtotal * (1 - dto.discountPct / 100) * 100) / 100;
const totalProdutos = itemsData.reduce((acc, it) => acc + it.total, 0);
const descontoValorGlobal = Math.round(totalProdutos * (dto.descontoPerc / 100) * 100) / 100;
const total = Math.round(totalProdutos * (1 - dto.descontoPerc / 100) * 100) / 100;
const situa = needsApproval ? SITUA_PENDENTE : SITUA_APROVADO;
// Gera número sequencial: SAR-NNNNN
const lastOrder = await prisma.pedido.findFirst({
where: { idEmpresa },
orderBy: { createdAt: 'desc' },
select: { numPedSar: true },
});
const seq = lastOrder ? parseInt(lastOrder.numPedSar.replace('SAR-', ''), 10) + 1 : 1;
const numPedSar = `SAR-${String(seq).padStart(5, '0')}`;
const now = new Date();
const order = await prisma.order.create({
const pedido = await prisma.pedido.create({
data: {
number,
clientId: dto.clientId,
repId: userId,
status,
discountPct: dto.discountPct,
subtotal: itemsSubtotal,
idEmpresa,
numPedSar,
idCliente: dto.idCliente,
codVendedor,
situa,
dtPedido: now,
idPauta: dto.idPauta ?? null,
codFormapag: dto.codFormapag ?? null,
totalProdutos,
total,
notes: dto.notes ?? null,
descontoPerc: dto.descontoPerc,
descontoValor: descontoValorGlobal,
obs: dto.obs ?? null,
idempotencyKey: dto.idempotencyKey ?? null,
issuedAt: now,
items: { create: itemsData },
history: {
itens: { create: itemsData },
historico: {
create: [
{ fromStatus: null, toStatus: OrderStatus.budget, changedById: userId, changedAt: now },
{
situaAnterior: null,
situaNova: situa,
changedBy: codVendedor,
changedAt: now,
},
],
},
},
include: {
client: { select: { name: true } },
items: true,
history: { orderBy: { changedAt: 'asc' } },
itens: { orderBy: { ordem: 'asc' } },
historico: { orderBy: { changedAt: 'asc' } },
},
});
if (status === OrderStatus.pending_approval) {
await prisma.orderStatusHistory.create({
data: {
orderId: order.id,
fromStatus: OrderStatus.budget,
toStatus: OrderStatus.pending_approval,
changedById: userId,
changedAt: now,
},
});
}
// Atualiza desnorm do cliente
const openStatuses = [OrderStatus.budget, OrderStatus.pending_approval, OrderStatus.approved];
const openCount = await prisma.order.count({
where: { clientId: dto.clientId, deletedAt: null, status: { in: openStatuses } },
});
await prisma.client.update({
where: { id: dto.clientId },
data: { lastOrderAt: now, lastOrderValue: total, openOrdersCount: openCount },
});
if (status === OrderStatus.pending_approval) {
// FR-6.1: notifica supervisores que há pedido aguardando aprovação
if (situa === SITUA_PENDENTE) {
void this.notifications.notifySupervisors({
title: 'Pedido aguardando aprovação',
body: `${order.client.name} ${order.number} — R$ ${order.total.toFixed(2).replace('.', ',')}`,
url: `/pedidos/${order.id}`,
body: `Pedido ${pedido.numPedSar} — R$ ${pedido.total.toFixed(2).replace('.', ',')}`,
url: `/pedidos/${pedido.id}`,
});
const updated = await prisma.order.findUniqueOrThrow({
where: { id: order.id },
include: {
client: { select: { name: true } },
items: true,
history: { orderBy: { changedAt: 'asc' } },
},
});
return this.mapDetail(updated);
}
return this.mapDetail(order);
return this.mapDetail(pedido);
}
// Aprova pedido pending_approval. Supervisor pode ajustar discountPct global (FR-5.4).
async approve(id: string, userId: string, dto: ApproveOrder): Promise<OrderDetail> {
// Aprova pedido pendente. Supervisor pode ajustar descontoPerc global.
async approve(id: string, dto: AprovarPedido): Promise<PedidoDetail> {
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') ?? '0';
const codVendedor = parseInt(userId, 10);
const order = await prisma.order.findFirst({ where: { id, deletedAt: null } });
if (!order) throw new NotFoundException(`Pedido ${id} não encontrado`);
if (order.status !== OrderStatus.pending_approval)
const pedido = await prisma.pedido.findFirst({ where: { id, idEmpresa } });
if (!pedido) throw new NotFoundException(`Pedido ${id} não encontrado`);
if (pedido.situa !== SITUA_PENDENTE)
throw new BadRequestException(
`Pedido não está aguardando aprovação (status: ${order.status})`,
`Pedido não está aguardando aprovação (situa: ${pedido.situa})`,
);
const now = new Date();
const newDiscountPct = dto.discountPct ?? Number(order.discountPct);
const newTotal = Math.round(Number(order.subtotal) * (1 - newDiscountPct / 100) * 100) / 100;
const newDescontoPerc = dto.descontoPerc ?? Number(pedido.descontoPerc);
const newTotal =
Math.round(Number(pedido.totalProdutos) * (1 - newDescontoPerc / 100) * 100) / 100;
await prisma.order.update({
await prisma.pedido.update({
where: { id },
data: {
status: OrderStatus.approved,
discountPct: newDiscountPct,
situa: SITUA_APROVADO,
descontoPerc: newDescontoPerc,
total: newTotal,
approvedById: userId,
approvedAt: now,
aprovadoPor: codVendedor,
aprovadoEm: now,
},
});
await prisma.orderStatusHistory.create({
await prisma.historicoPedido.create({
data: {
orderId: id,
fromStatus: OrderStatus.pending_approval,
toStatus: OrderStatus.approved,
changedById: userId,
idPedido: id,
situaAnterior: SITUA_PENDENTE,
situaNova: SITUA_APROVADO,
changedBy: codVendedor,
changedAt: now,
note: dto.note ?? null,
nota: dto.nota ?? null,
},
});
// Atualiza desnorm openOrdersCount
const openStatuses = [OrderStatus.budget, OrderStatus.pending_approval, OrderStatus.approved];
const openCount = await prisma.order.count({
where: { clientId: order.clientId, deletedAt: null, status: { in: openStatuses } },
});
await prisma.client.update({
where: { id: order.clientId },
data: { openOrdersCount: openCount },
});
const final = await prisma.order.findUniqueOrThrow({
const final = await prisma.pedido.findUniqueOrThrow({
where: { id },
include: {
client: { select: { name: true } },
items: true,
history: { orderBy: { changedAt: 'asc' } },
itens: { orderBy: { ordem: 'asc' } },
historico: { orderBy: { changedAt: 'asc' } },
},
});
// FR-6.1: notifica o rep que o pedido foi aprovado
void this.notifications.notifyUser(order.repId, {
void this.notifications.notifyUser(String(pedido.codVendedor), {
title: 'Pedido aprovado',
body: `${final.number}${final.client.name} aprovado${dto.discountPct !== undefined ? ` com ${newDiscountPct}% de desconto` : ''}`,
body: `${final.numPedSar} aprovado${dto.descontoPerc !== undefined ? ` com ${newDescontoPerc}% de desconto` : ''}`,
url: `/pedidos/${id}`,
});
return this.mapDetail(final);
}
// Recusa pedido — retorna ao status budget com motivo no histórico (FR-5.4).
async reject(id: string, userId: string, dto: RejectOrder): Promise<OrderDetail> {
// Recusa pedido — muda situa para 3 (Cancelado) com motivo.
async reject(id: string, dto: RecusarPedido): Promise<PedidoDetail> {
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') ?? '0';
const codVendedor = parseInt(userId, 10);
const order = await prisma.order.findFirst({ where: { id, deletedAt: null } });
if (!order) throw new NotFoundException(`Pedido ${id} não encontrado`);
if (order.status !== OrderStatus.pending_approval)
const pedido = await prisma.pedido.findFirst({ where: { id, idEmpresa } });
if (!pedido) throw new NotFoundException(`Pedido ${id} não encontrado`);
if (pedido.situa !== SITUA_PENDENTE)
throw new BadRequestException(
`Pedido não está aguardando aprovação (status: ${order.status})`,
`Pedido não está aguardando aprovação (situa: ${pedido.situa})`,
);
const now = new Date();
await prisma.order.update({ where: { id }, data: { status: OrderStatus.budget } });
await prisma.pedido.update({
where: { id },
data: { situa: SITUA_CANCELADO, motivoRecusa: dto.motivo },
});
await prisma.orderStatusHistory.create({
await prisma.historicoPedido.create({
data: {
orderId: id,
fromStatus: OrderStatus.pending_approval,
toStatus: OrderStatus.budget,
changedById: userId,
idPedido: id,
situaAnterior: SITUA_PENDENTE,
situaNova: SITUA_CANCELADO,
changedBy: codVendedor,
changedAt: now,
note: dto.reason,
nota: dto.motivo,
},
});
// Atualiza desnorm openOrdersCount
const openStatuses = [OrderStatus.budget, OrderStatus.pending_approval, OrderStatus.approved];
const openCount = await prisma.order.count({
where: { clientId: order.clientId, deletedAt: null, status: { in: openStatuses } },
});
await prisma.client.update({
where: { id: order.clientId },
data: { openOrdersCount: openCount },
});
const final = await prisma.order.findUniqueOrThrow({
const final = await prisma.pedido.findUniqueOrThrow({
where: { id },
include: {
client: { select: { name: true } },
items: true,
history: { orderBy: { changedAt: 'asc' } },
itens: { orderBy: { ordem: 'asc' } },
historico: { orderBy: { changedAt: 'asc' } },
},
});
// FR-6.1: notifica o rep que o pedido foi recusado
void this.notifications.notifyUser(order.repId, {
void this.notifications.notifyUser(String(pedido.codVendedor), {
title: 'Pedido recusado',
body: `${final.number}${final.client.name}: ${dto.reason}`,
body: `${final.numPedSar}: ${dto.motivo}`,
url: `/pedidos/${id}`,
});
@@ -401,113 +329,89 @@ export class OrdersService {
private mapDetail(o: {
id: string;
number: string;
clientId: string;
client: { name: string };
repId: string;
status: OrderStatus;
discountPct: Prisma.Decimal;
subtotal: Prisma.Decimal;
numPedSar: string;
idCliente: number;
codVendedor: number;
situa: number;
dtPedido: Date;
total: Prisma.Decimal;
notes: string | null;
approvedById: string | null;
descontoPerc: Prisma.Decimal;
descontoValor: Prisma.Decimal;
totalProdutos: Prisma.Decimal;
totalIpi: Prisma.Decimal;
totalIcmsst: Prisma.Decimal;
acrescimo: Prisma.Decimal;
comissao: Prisma.Decimal;
pedFlex: Prisma.Decimal;
aprovadoPor: number | null;
aprovadoEm: Date | null;
motivoRecusa: string | null;
obs: string | null;
idempotencyKey: string | null;
issuedAt: Date;
approvedAt: Date | null;
invoicedAt: Date | null;
cancelledAt: Date | null;
createdAt: Date;
updatedAt: Date;
items: {
itens: {
id: string;
productCode: string;
productName: string;
quantity: Prisma.Decimal;
unitPrice: Prisma.Decimal;
discountPct: Prisma.Decimal;
subtotal: Prisma.Decimal;
idProduto: number;
codProduto: string | null;
descProduto: string | null;
ordem: number;
qtd: Prisma.Decimal;
precoUnitario: Prisma.Decimal;
descontoPerc: Prisma.Decimal;
total: Prisma.Decimal;
}[];
history: {
historico: {
id: string;
fromStatus: OrderStatus | null;
toStatus: OrderStatus;
changedById: string;
note: string | null;
situaAnterior: number | null;
situaNova: number;
changedBy: number;
nota: string | null;
changedAt: Date;
}[];
}): OrderDetail {
}): PedidoDetail {
return {
id: o.id,
number: o.number,
clientId: o.clientId,
clientName: o.client.name,
repId: o.repId,
status: o.status,
discountPct: decimalToString(o.discountPct),
subtotal: decimalToString(o.subtotal),
numPedSar: o.numPedSar,
idCliente: o.idCliente,
codVendedor: o.codVendedor,
situa: o.situa,
dtPedido: o.dtPedido.toISOString(),
total: decimalToString(o.total),
notes: o.notes,
approvedById: o.approvedById,
idempotencyKey: o.idempotencyKey,
issuedAt: o.issuedAt.toISOString(),
approvedAt: o.approvedAt?.toISOString() ?? null,
invoicedAt: o.invoicedAt?.toISOString() ?? null,
cancelledAt: o.cancelledAt?.toISOString() ?? null,
descontoPerc: decimalToString(o.descontoPerc),
obs: o.obs,
createdAt: o.createdAt.toISOString(),
totalProdutos: decimalToString(o.totalProdutos),
totalIpi: decimalToString(o.totalIpi),
totalIcmsst: decimalToString(o.totalIcmsst),
descontoValor: decimalToString(o.descontoValor),
acrescimo: decimalToString(o.acrescimo),
comissao: decimalToString(o.comissao),
pedFlex: decimalToString(o.pedFlex),
aprovadoPor: o.aprovadoPor,
aprovadoEm: o.aprovadoEm?.toISOString() ?? null,
motivoRecusa: o.motivoRecusa,
idempotencyKey: o.idempotencyKey,
updatedAt: o.updatedAt.toISOString(),
items: o.items.map((it) => ({
itens: o.itens.map((it) => ({
id: it.id,
productCode: it.productCode,
productName: it.productName,
quantity: decimalToString(it.quantity),
unitPrice: decimalToString(it.unitPrice),
discountPct: decimalToString(it.discountPct),
subtotal: decimalToString(it.subtotal),
idProduto: it.idProduto,
codProduto: it.codProduto,
descProduto: it.descProduto,
ordem: it.ordem,
qtd: decimalToString(it.qtd),
precoUnitario: decimalToString(it.precoUnitario),
descontoPerc: decimalToString(it.descontoPerc),
total: decimalToString(it.total),
})),
history: o.history.map((h) => ({
historico: o.historico.map((h) => ({
id: h.id,
fromStatus: h.fromStatus,
toStatus: h.toStatus,
changedById: h.changedById,
note: h.note,
situaAnterior: h.situaAnterior,
situaNova: h.situaNova,
changedBy: h.changedBy,
nota: h.nota,
changedAt: h.changedAt.toISOString(),
})),
};
}
// Últimos N pedidos de um cliente — usado na ficha (FR-2.4).
async listByClient(
clientId: string,
userId: string,
role: string,
limit = 10,
): Promise<OrderSummary[]> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const repFilter: Prisma.OrderWhereInput = role === 'rep' ? { repId: userId } : {};
const rows = await prisma.order.findMany({
where: { clientId, deletedAt: null, ...repFilter },
include: { client: { select: { name: true } } },
orderBy: { issuedAt: 'desc' },
take: limit,
});
return rows.map((o) => ({
id: o.id,
number: o.number,
clientId: o.clientId,
clientName: o.client.name,
repId: o.repId,
status: o.status,
discountPct: decimalToString(o.discountPct),
subtotal: decimalToString(o.subtotal),
total: decimalToString(o.total),
issuedAt: o.issuedAt.toISOString(),
approvedAt: o.approvedAt?.toISOString() ?? null,
invoicedAt: o.invoicedAt?.toISOString() ?? null,
cancelledAt: o.cancelledAt?.toISOString() ?? null,
}));
}
}

View File

@@ -21,7 +21,7 @@ export class PingController {
status: 'ok',
service: 'sar-api',
version: process.env['npm_package_version'] ?? '0.1.0',
workspaceId: this.cls.get('workspaceId'),
idEmpresa: this.cls.get('idEmpresa'),
requestId: this.cls.get('requestId'),
uptimeSeconds: Math.round(process.uptime()),
now: new Date().toISOString(),

View File

@@ -3,7 +3,8 @@ import { PrismaClient } from '@prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';
import pg from 'pg';
// ADR 0006: BD-por-workspace — um PrismaClient por workspaceId, nunca singleton.
// ADR 0006 revogado: BD-por-workspace → schema `sar` no ERP.
// Pool keyed por idEmpresa (number). URL deve incluir ?schema=sar.
// CODING-RULES PGD-DB-009: callers obtêm o client via CLS, não injetando este serviço.
const MAX_ENTRIES = 10; // LRU cap; ajustável via env na próxima iteração
@@ -22,12 +23,13 @@ export class WorkspacePrismaPool implements OnModuleDestroy {
// Map preserves insertion order → LRU: primeiro = mais antigo, último = mais recente
private readonly cache = new Map<string, PoolEntry>();
getOrCreate(workspaceId: string, dbUrl: string): PrismaClient {
const hit = this.cache.get(workspaceId);
getOrCreate(idEmpresa: number, dbUrl: string): PrismaClient {
const key = String(idEmpresa);
const hit = this.cache.get(key);
if (hit) {
// Move para o fim (LRU refresh)
this.cache.delete(workspaceId);
this.cache.set(workspaceId, hit);
this.cache.delete(key);
this.cache.set(key, hit);
return hit.client;
}
@@ -38,13 +40,13 @@ export class WorkspacePrismaPool implements OnModuleDestroy {
const pgPool = new pg.Pool({ connectionString: dbUrl, max: PG_POOL_SIZE });
const adapter = new PrismaPg(pgPool);
const client = new PrismaClient({ adapter });
this.cache.set(workspaceId, { client, pgPool });
this.logger.log(`pool criado: workspace=${workspaceId} total=${this.cache.size}`);
this.cache.set(key, { client, pgPool });
this.logger.log(`pool criado: idEmpresa=${idEmpresa} total=${this.cache.size}`);
return client;
}
async health(k = 3): Promise<{ workspaceId: string; ok: boolean; latencyMs?: number }[]> {
// Verifica os k workspaces mais recentes
// Verifica os k empresas mais recentes
const entries = [...this.cache.entries()].slice(-k);
return Promise.all(
entries.map(async ([workspaceId, { pgPool }]) => {
@@ -75,6 +77,6 @@ export class WorkspacePrismaPool implements OnModuleDestroy {
void oldest.client.$disconnect().catch(noop);
void oldest.pgPool.end().catch(noop);
this.cache.delete(oldestId);
this.logger.log(`evicted LRU workspace=${oldestId}`);
this.logger.log(`evicted LRU idEmpresa=${oldestId}`);
}
}

View File

@@ -1,25 +1,22 @@
import { Module } from '@nestjs/common';
import { ClsModule } from 'nestjs-cls';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { randomUUID } from 'node:crypto';
import type { Request, Response } from 'express';
import type { WorkspaceClsStore } from './workspace.types';
import type { Env } from '../config/env.schema';
import { WorkspacePrismaPool } from './workspace-prisma-pool.service';
// CLS middleware roda ANTES dos guards (ordem NestJS).
// Aqui: apenas requestId + workspaceId default.
// JwtAuthGuard atualiza workspaceId, userId e prisma após validar o token.
// Aqui: apenas requestId + idEmpresa default (0 = não autenticado).
// JwtAuthGuard atualiza idEmpresa, userId e prisma após validar o token.
// CODING-RULES PGD-DB-009: prisma via cls.get('prisma'), nunca singleton.
// CODING-RULES PGD-AUTHZ-002: workspaceId real vem do JWT (guard), não do env.
// CODING-RULES PGD-AUTHZ-002: idEmpresa real vem do JWT (guard), não do env.
// ADR 0006 revogado: workspaceId: string → idEmpresa: number
@Module({
imports: [
ClsModule.forRootAsync({
global: true,
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService<Env, true>) => ({
useFactory: () => ({
middleware: {
mount: true,
generateId: true,
@@ -39,7 +36,7 @@ import { WorkspacePrismaPool } from './workspace-prisma-pool.service';
res.setHeader('x-request-id', requestId);
store.set('requestId', requestId);
// Fallback para rotas públicas (ping, health). Guard sobrescreve em rotas protegidas.
store.set('workspaceId', config.get('DEFAULT_WORKSPACE_ID', { infer: true }));
store.set('idEmpresa', 0);
},
},
}),

View File

@@ -4,12 +4,13 @@ import type { JwtRole } from '../auth/jwt.types';
// Forma do CLS store por request — fonte da verdade para qualquer caller.
// CODING-RULES PGD-DB-009: nunca importe PrismaClient diretamente; use cls.get('prisma').
// CODING-RULES PGD-AUTHZ-002: workspaceId vem sempre do JWT, nunca de body/param/query.
// CODING-RULES PGD-AUTHZ-002: idEmpresa vem sempre do JWT, nunca de body/param/query.
// ADR 0006 revogado: workspaceId: string → idEmpresa: number
export interface WorkspaceClsStore extends ClsStore {
requestId: string;
workspaceId: string;
userId?: string; // preenchido pelo JwtAuthGuard após validar o token
idEmpresa: number; // era workspaceId: string — agora Int da empresa no ERP
userId?: string; // cod_vendedor como string; preenchido pelo JwtAuthGuard
role?: JwtRole; // preenchido pelo JwtAuthGuard após validar o token
prisma?: PrismaClient; // preenchido pelo JwtAuthGuard via WorkspacePrismaPool
}

View File

@@ -0,0 +1 @@
{"fileNames":[],"fileInfos":[],"root":[],"options":{"allowSyntheticDefaultImports":true,"composite":false,"declaration":true,"declarationMap":true,"emitDecoratorMetadata":true,"esModuleInterop":true,"experimentalDecorators":true,"module":199,"noFallthroughCasesInSwitch":true,"noImplicitOverride":true,"noUncheckedIndexedAccess":true,"skipLibCheck":true,"sourceMap":true,"strict":true,"target":10,"useDefineForClassFields":false,"verbatimModuleSyntax":false},"version":"5.9.3"}