From b0b60d7a14c0b988b718b4217e86476fa0662c75 Mon Sep 17 00:00:00 2001 From: julian Date: Thu, 28 May 2026 21:51:16 +0000 Subject: [PATCH] =?UTF-8?q?refactor(erp):=20integra=C3=A7=C3=A3o=20direta?= =?UTF-8?q?=20com=20banco=20ERP=20=E2=80=94=20schema=20sar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/api/prisma/schema.prisma | 314 ++++------ apps/api/src/app/auth/dev-auth.controller.ts | 5 +- apps/api/src/app/auth/jwt-auth.guard.ts | 21 +- apps/api/src/app/auth/jwt.types.ts | 7 +- .../api/src/app/catalog/catalog.controller.ts | 39 +- apps/api/src/app/catalog/catalog.service.ts | 259 ++++---- .../api/src/app/clients/clients.controller.ts | 29 +- apps/api/src/app/clients/clients.module.ts | 2 - apps/api/src/app/clients/clients.service.ts | 235 ++++--- .../src/app/dashboard/dashboard.service.ts | 211 ++++--- .../notifications/notifications.service.ts | 38 +- apps/api/src/app/orders/orders.controller.ts | 60 +- apps/api/src/app/orders/orders.service.ts | 572 ++++++++---------- apps/api/src/app/ping/ping.controller.ts | 2 +- .../workspace-prisma-pool.service.ts | 20 +- .../api/src/app/workspace/workspace.module.ts | 15 +- apps/api/src/app/workspace/workspace.types.ts | 7 +- apps/api/tsconfig.tsbuildinfo | 1 + .../src/cockpits/rafael/ClientDetailPage.tsx | 106 ++-- apps/web/src/cockpits/rafael/ClientsPage.tsx | 84 +-- apps/web/src/cockpits/rafael/NewOrderPage.tsx | 137 +++-- .../src/cockpits/rafael/OrderDetailPage.tsx | 184 +++--- apps/web/src/cockpits/rafael/OrdersPage.tsx | 83 ++- apps/web/src/cockpits/rafael/RafaelPainel.tsx | 37 +- .../src/cockpits/sandra/ApprovalQueuePage.tsx | 35 +- apps/web/src/cockpits/sandra/SandraPainel.tsx | 30 +- apps/web/src/components/dev/DevLogin.tsx | 11 +- .../components/layout/FoundationStatus.tsx | 8 +- apps/web/src/lib/queries/catalog.ts | 14 +- apps/web/src/lib/queries/clients.ts | 17 +- apps/web/src/lib/queries/orders.ts | 42 +- apps/web/tsconfig.tsbuildinfo | 1 + .../api-interface/src/lib/auth.contract.ts | 3 +- .../api-interface/src/lib/client.contract.ts | 61 +- .../src/lib/dashboard.contract.ts | 14 +- .../api-interface/src/lib/order.contract.ts | 183 +++--- .../api-interface/src/lib/ping.contract.ts | 2 +- .../api-interface/src/lib/product.contract.ts | 87 ++- .../shared/api-interface/tsconfig.tsbuildinfo | 1 + 39 files changed, 1433 insertions(+), 1544 deletions(-) create mode 100644 apps/api/tsconfig.tsbuildinfo create mode 100644 apps/web/tsconfig.tsbuildinfo create mode 100644 libs/shared/api-interface/tsconfig.tsbuildinfo diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index f792ff3..d739275 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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_). -// 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 // 1–12 - 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") } diff --git a/apps/api/src/app/auth/dev-auth.controller.ts b/apps/api/src/app/auth/dev-auth.controller.ts index 9046421..685a8cc 100644 --- a/apps/api/src/app/auth/dev-auth.controller.ts +++ b/apps/api/src/app/auth/dev-auth.controller.ts @@ -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' }) diff --git a/apps/api/src/app/auth/jwt-auth.guard.ts b/apps/api/src/app/auth/jwt-auth.guard.ts index 8f54b50..b1c299a 100644 --- a/apps/api/src/app/auth/jwt-auth.guard.ts +++ b/apps/api/src/app/auth/jwt-auth.guard.ts @@ -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 { diff --git a/apps/api/src/app/auth/jwt.types.ts b/apps/api/src/app/auth/jwt.types.ts index f74c258..4206a25 100644 --- a/apps/api/src/app/auth/jwt.types.ts +++ b/apps/api/src/app/auth/jwt.types.ts @@ -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; diff --git a/apps/api/src/app/catalog/catalog.controller.ts b/apps/api/src/app/catalog/catalog.controller.ts index a1179ae..98c844f 100644 --- a/apps/api/src/app/catalog/catalog.controller.ts +++ b/apps/api/src/app/catalog/catalog.controller.ts @@ -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 { - const parsed = ProductListQuerySchema.parse(query) as ProductListQuery; + list(@Query() query: ProdutoListQueryDto): Promise { + const parsed = ProdutoListQuerySchema.parse(query) as ProdutoListQuery; return this.catalog.list(parsed); } @Get(':id') - async findOne(@Param('id', ParseUUIDPipe) id: string): Promise { + async findOne(@Param('id', ParseIntPipe) id: number): Promise { 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 { - const parsed = ProductSyncRequestSchema.parse(body) as ProductSyncRequest; - return this.catalog.sync(parsed); - } } diff --git a/apps/api/src/app/catalog/catalog.service.ts b/apps/api/src/app/catalog/catalog.service.ts index 8c80bb8..679e720 100644 --- a/apps/api/src/app/catalog/catalog.service.ts +++ b/apps/api/src/app/catalog/catalog.service.ts @@ -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) {} - async list(query: ProductListQuery): Promise { + async list(query: ProdutoListQuery): Promise { const prisma = this.cls.get('prisma'); if (!prisma) throw new Error('prisma não disponível no CLS'); + const idEmpresa = this.cls.get('idEmpresa'); - const { 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(` + 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 { + async findOne(idErp: number): Promise { const prisma = this.cls.get('prisma'); if (!prisma) throw new Error('prisma não disponível no CLS'); + const idEmpresa = this.cls.get('idEmpresa'); - const p = await prisma.product.findFirst({ where: { id, deletedAt: null, active: true } }); + const rows = await prisma.$queryRawUnsafe(` + 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 { - 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() }; - } } diff --git a/apps/api/src/app/clients/clients.controller.ts b/apps/api/src/app/clients/clients.controller.ts index 94a131b..2716464 100644 --- a/apps/api/src/app/clients/clients.controller.ts +++ b/apps/api/src/app/clients/clients.controller.ts @@ -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, - ) {} + constructor(private readonly clients: ClientsService) {} @Get() list(@Query() query: ClientListQueryDto): Promise { - // 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 { - 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 { - return this.orders.listByClient( - id, - this.cls.get('userId') ?? '', - this.cls.get('role') ?? 'rep', - ); + findOne(@Param('id', ParseIntPipe) id: number): Promise { + return this.clients.findOne(id); } } diff --git a/apps/api/src/app/clients/clients.module.ts b/apps/api/src/app/clients/clients.module.ts index f89b8f9..e8b7c58 100644 --- a/apps/api/src/app/clients/clients.module.ts +++ b/apps/api/src/app/clients/clients.module.ts @@ -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], }) diff --git a/apps/api/src/app/clients/clients.service.ts b/apps/api/src/app/clients/clients.service.ts index 421edbe..2dfe343 100644 --- a/apps/api/src/app/clients/clients.service.ts +++ b/apps/api/src/app/clients/clients.service.ts @@ -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) {} - async list(query: ClientListQuery, userId: string, role: string): Promise { + async list(query: ClientListQuery): Promise { const prisma = this.cls.get('prisma'); if (!prisma) throw new Error('prisma não disponível no CLS'); + const idEmpresa = this.cls.get('idEmpresa'); + const 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(` + 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 { + async findOne(idCliente: number): Promise { const prisma = this.cls.get('prisma'); if (!prisma) throw new Error('prisma não disponível no CLS'); + const idEmpresa = this.cls.get('idEmpresa'); - const repFilter: Prisma.ClientWhereInput = role === 'rep' ? { repId: userId } : {}; + const rows = await prisma.$queryRawUnsafe(` + 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, }; } } diff --git a/apps/api/src/app/dashboard/dashboard.service.ts b/apps/api/src/app/dashboard/dashboard.service.ts index 4bff836..8523f63 100644 --- a/apps/api/src/app/dashboard/dashboard.service.ts +++ b/apps/api/src/app/dashboard/dashboard.service.ts @@ -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) {} @@ -11,6 +28,9 @@ export class DashboardService { async repDashboard(userId: string): Promise { const prisma = this.cls.get('prisma'); if (!prisma) throw new Error('prisma não disponível no CLS'); + const idEmpresa = this.cls.get('idEmpresa'); + const codVendedor = parseInt(userId, 10); + const now = new Date(); const year = now.getFullYear(); const month = now.getMonth() + 1; @@ -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(` + 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 { 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(` + 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(), }; diff --git a/apps/api/src/app/notifications/notifications.service.ts b/apps/api/src/app/notifications/notifications.service.ts index 87ee122..b0d5e18 100644 --- a/apps/api/src/app/notifications/notifications.service.ts +++ b/apps/api/src/app/notifications/notifications.service.ts @@ -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 { 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 { 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 { 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 { 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))); } } diff --git a/apps/api/src/app/orders/orders.controller.ts b/apps/api/src/app/orders/orders.controller.ts index dd7926c..4ebca3e 100644 --- a/apps/api/src/app/orders/orders.controller.ts +++ b/apps/api/src/app/orders/orders.controller.ts @@ -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 { - 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 { + const parsed = PedidoListQuerySchema.parse(query) as PedidoListQuery; + return this.orders.list(parsed); } @Post() @HttpCode(201) - create(@Body() body: CreateOrderDto): Promise { - const parsed = CreateOrderSchema.parse(body) as CreateOrder; - return this.orders.create(parsed, this.cls.get('userId') ?? ''); + create(@Body() body: CreatePedidoDto): Promise { + 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 { + @Body() body: AprovarPedidoDto, + ): Promise { 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 { + @Body() body: RecusarPedidoDto, + ): Promise { 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 { - return this.orders.findOne(id, this.cls.get('userId') ?? '', this.cls.get('role') ?? 'rep'); + findOne(@Param('id', ParseUUIDPipe) id: string): Promise { + return this.orders.findOne(id); } } diff --git a/apps/api/src/app/orders/orders.service.ts b/apps/api/src/app/orders/orders.service.ts index 984f9bb..427ec0b 100644 --- a/apps/api/src/app/orders/orders.service.ts +++ b/apps/api/src/app/orders/orders.service.ts @@ -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 { + async list(query: PedidoListQuery): Promise { const prisma = this.cls.get('prisma'); if (!prisma) throw new Error('prisma não disponível no CLS'); + const idEmpresa = this.cls.get('idEmpresa'); + const 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 { + async findOne(id: string): Promise { const prisma = this.cls.get('prisma'); if (!prisma) throw new Error('prisma não disponível no CLS'); + const idEmpresa = this.cls.get('idEmpresa'); + const 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 { + async create(dto: CreatePedido): Promise { const prisma = this.cls.get('prisma'); if (!prisma) throw new Error('prisma não disponível no CLS'); + const idEmpresa = this.cls.get('idEmpresa'); + const userId = this.cls.get('userId') ?? '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 { + // Aprova pedido pendente. Supervisor pode ajustar descontoPerc global. + async approve(id: string, dto: AprovarPedido): Promise { const prisma = this.cls.get('prisma'); if (!prisma) throw new Error('prisma não disponível no CLS'); + const idEmpresa = this.cls.get('idEmpresa'); + const userId = this.cls.get('userId') ?? '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 { + // Recusa pedido — muda situa para 3 (Cancelado) com motivo. + async reject(id: string, dto: RecusarPedido): Promise { const prisma = this.cls.get('prisma'); if (!prisma) throw new Error('prisma não disponível no CLS'); + const idEmpresa = this.cls.get('idEmpresa'); + const userId = this.cls.get('userId') ?? '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 { - 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, - })); - } } diff --git a/apps/api/src/app/ping/ping.controller.ts b/apps/api/src/app/ping/ping.controller.ts index 4e204d9..80cedf7 100644 --- a/apps/api/src/app/ping/ping.controller.ts +++ b/apps/api/src/app/ping/ping.controller.ts @@ -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(), diff --git a/apps/api/src/app/workspace/workspace-prisma-pool.service.ts b/apps/api/src/app/workspace/workspace-prisma-pool.service.ts index 368a968..9c67713 100644 --- a/apps/api/src/app/workspace/workspace-prisma-pool.service.ts +++ b/apps/api/src/app/workspace/workspace-prisma-pool.service.ts @@ -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(); - 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}`); } } diff --git a/apps/api/src/app/workspace/workspace.module.ts b/apps/api/src/app/workspace/workspace.module.ts index a80ea0d..da239df 100644 --- a/apps/api/src/app/workspace/workspace.module.ts +++ b/apps/api/src/app/workspace/workspace.module.ts @@ -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) => ({ + 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); }, }, }), diff --git a/apps/api/src/app/workspace/workspace.types.ts b/apps/api/src/app/workspace/workspace.types.ts index 7a6ec18..1dcc928 100644 --- a/apps/api/src/app/workspace/workspace.types.ts +++ b/apps/api/src/app/workspace/workspace.types.ts @@ -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 } diff --git a/apps/api/tsconfig.tsbuildinfo b/apps/api/tsconfig.tsbuildinfo new file mode 100644 index 0000000..587545f --- /dev/null +++ b/apps/api/tsconfig.tsbuildinfo @@ -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"} \ No newline at end of file diff --git a/apps/web/src/cockpits/rafael/ClientDetailPage.tsx b/apps/web/src/cockpits/rafael/ClientDetailPage.tsx index 15c96e0..35ea2a5 100644 --- a/apps/web/src/cockpits/rafael/ClientDetailPage.tsx +++ b/apps/web/src/cockpits/rafael/ClientDetailPage.tsx @@ -1,22 +1,13 @@ import { Button, Descriptions, Tag, Table, Typography, Spin, Alert, Space, Divider } from 'antd'; import type { TableColumnsType } from 'antd'; import { Link, useNavigate, useParams } from '@tanstack/react-router'; -import type { OrderSummary, OrderStatus } from '@sar/api-interface'; +import type { PedidoSummary } from '@sar/api-interface'; +import { SITUA_LABEL } from '@sar/api-interface'; import { useClientDetail } from '../../lib/queries/clients'; import { useClientOrders } from '../../lib/queries/orders'; const { Title } = Typography; -const FINANCIAL_COLOR: Record = { - regular: 'success', - attention: 'warning', - blocked: 'error', -}; -const FINANCIAL_LABEL: Record = { - regular: 'Regular', - attention: 'Atenção', - blocked: 'Bloqueado', -}; const ACTIVITY_COLOR: Record = { active: 'success', alert: 'warning', @@ -27,27 +18,13 @@ const ACTIVITY_LABEL: Record = { alert: 'Alerta', inactive: 'Inativo', }; -const STATUS_LABEL: Record = { - budget: 'Orçamento', - pending_approval: 'Ag. Aprovação', - approved: 'Aprovado', - invoiced: 'Faturado', - cancelled: 'Cancelado', -}; -const STATUS_COLOR: Record = { - budget: 'default', - pending_approval: 'warning', - approved: 'processing', - invoiced: 'success', - cancelled: 'error', -}; -const orderColumns: TableColumnsType = [ +const orderColumns: TableColumnsType = [ { title: 'Nº', - dataIndex: 'number', + dataIndex: 'numPedSar', width: 120, - render: (num: string, row: OrderSummary) => ( + render: (num: string, row: PedidoSummary) => ( {num} @@ -55,9 +32,17 @@ const orderColumns: TableColumnsType = [ }, { title: 'Status', - dataIndex: 'status', + dataIndex: 'situa', width: 140, - render: (s: OrderStatus) => {STATUS_LABEL[s]}, + render: (s: number) => { + const colorMap: Record = { + 1: 'warning', + 2: 'processing', + 3: 'error', + 4: 'success', + }; + return {SITUA_LABEL[s] ?? String(s)}; + }, }, { title: 'Total', @@ -68,8 +53,8 @@ const orderColumns: TableColumnsType = [ Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }), }, { - title: 'Emitido em', - dataIndex: 'issuedAt', + title: 'Data', + dataIndex: 'dtPedido', width: 130, render: (v: string) => new Date(v).toLocaleDateString('pt-BR'), }, @@ -77,85 +62,74 @@ const orderColumns: TableColumnsType = [ export function ClientDetailPage() { const { id } = useParams({ from: '/clientes/$id' }); + const idNum = Number(id); const navigate = useNavigate(); - const { data: client, isLoading: clientLoading, error: clientError } = useClientDetail(id); - const { data: orders, isLoading: ordersLoading } = useClientOrders(id); + const { data: client, isLoading: clientLoading, error: clientError } = useClientDetail(idNum); + const { data: orders, isLoading: ordersLoading } = useClientOrders(idNum); if (clientLoading) return ; if (clientError || !client) return ; - const addr = client.address; - return (
← Clientes - {client.tradeName ?? client.name} + {client.razao ?? client.nome} - - {FINANCIAL_LABEL[client.financialStatus]} - {ACTIVITY_LABEL[client.activityStatus]} - {client.name} - {client.taxId} + {client.nome} + {client.cgcpf ?? '—'} {client.email ?? '—'} - {client.phone ?? '—'} - {addr && ( + + {client.ddd ? `(${client.ddd}) ` : ''} + {client.telefone ?? '—'} + + {client.endereco && ( - {addr.street}, {addr.number} - {addr.complement ? `, ${addr.complement}` : ''} — {addr.district}, {addr.city}/ - {addr.state} — CEP {addr.zip} + {client.endereco} + {client.numEndereco ? `, ${client.numEndereco}` : ''} + {client.bairro ? ` — ${client.bairro}` : ''} + {client.cep ? ` — CEP ${client.cep}` : ''} )} - {client.creditLimit - ? Number(client.creditLimit).toLocaleString('pt-BR', { + {client.limiteCreditoStr + ? Number(client.limiteCreditoStr).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL', }) : '—'} - {client.openOrdersCount} - - {client.lastOrderAt ? new Date(client.lastOrderAt).toLocaleDateString('pt-BR') : '—'} - - - {client.lastOrderValue - ? Number(client.lastOrderValue).toLocaleString('pt-BR', { - style: 'currency', - currency: 'BRL', - }) + + {client.dtUltimaCompra + ? new Date(client.dtUltimaCompra).toLocaleDateString('pt-BR') : '—'} - {client.erpCode && ( - {client.erpCode} - )} - Últimos 10 Pedidos + Últimos Pedidos - + rowKey="id" columns={orderColumns} dataSource={orders ?? []} loading={ordersLoading} pagination={false} size="small" - rowClassName={(row) => (row.status === 'pending_approval' ? 'row-pending' : '')} + rowClassName={(row) => (row.situa === 1 ? 'row-pending' : '')} />
diff --git a/apps/web/src/cockpits/rafael/ClientsPage.tsx b/apps/web/src/cockpits/rafael/ClientsPage.tsx index b875d0a..5d90841 100644 --- a/apps/web/src/cockpits/rafael/ClientsPage.tsx +++ b/apps/web/src/cockpits/rafael/ClientsPage.tsx @@ -1,8 +1,8 @@ import { useState } from 'react'; -import { Badge, Input, Select, Space, Table, Tag, Tooltip, Typography } from 'antd'; +import { Badge, Input, Select, Space, Table, Typography } from 'antd'; import type { TableColumnsType } from 'antd'; import { useNavigate } from '@tanstack/react-router'; -import type { ActivityStatus, ClientSummary, FinancialStatus } from '@sar/api-interface'; +import type { ActivityStatus, ClientSummary } from '@sar/api-interface'; import { useClientList } from '../../lib/queries/clients'; const { Title } = Typography; @@ -16,31 +16,27 @@ const ACTIVITY_CONFIG: Record inactive: { color: 'error', label: 'Inativo' }, }; -const FINANCIAL_CONFIG: Record = { - regular: { color: 'success', label: 'Regular' }, - attention: { color: 'warning', label: 'Atenção' }, - blocked: { color: 'error', label: 'Bloqueado' }, -}; - // ─── Columns ────────────────────────────────────────────────────────────────── function buildColumns(navigate: ReturnType): TableColumnsType { return [ { title: 'Cliente', - dataIndex: 'name', - key: 'name', - render: (name: string, record: ClientSummary) => ( + dataIndex: 'nome', + key: 'nome', + render: (nome: string, record: ClientSummary) => ( navigate({ to: '/clientes/$id', params: { id: record.id } })} + onClick={() => + navigate({ to: '/clientes/$id', params: { id: String(record.idCliente) } }) + } > - {name} + {nome} - {record.tradeName && ( + {record.razao && ( - {record.tradeName} + {record.razao} )} @@ -49,12 +45,12 @@ function buildColumns(navigate: ReturnType): TableColumnsTyp }, { title: 'CNPJ / CPF', - dataIndex: 'taxId', - key: 'taxId', + dataIndex: 'cgcpf', + key: 'cgcpf', width: 160, - render: (v: string) => ( + render: (v: string | null) => ( - {v} + {v ?? '—'} ), }, @@ -68,49 +64,20 @@ function buildColumns(navigate: ReturnType): TableColumnsTyp return ; }, }, - { - title: 'Situação', - dataIndex: 'financialStatus', - key: 'financialStatus', - width: 110, - render: (v: FinancialStatus) => { - const cfg = FINANCIAL_CONFIG[v]; - return {cfg.label}; - }, - }, { title: 'Última compra', - dataIndex: 'lastOrderAt', - key: 'lastOrderAt', + dataIndex: 'dtUltimaCompra', + key: 'dtUltimaCompra', width: 140, - render: (v: string | null, record: ClientSummary) => { + render: (v: string | null) => { if (!v) return ; - const date = new Date(v).toLocaleDateString('pt-BR'); - const value = record.lastOrderValue - ? `R$ ${Number(record.lastOrderValue).toLocaleString('pt-BR', { minimumFractionDigits: 2 })}` - : ''; return ( - - {date} - + + {new Date(v).toLocaleDateString('pt-BR')} + ); }, }, - { - title: 'Pedidos abertos', - dataIndex: 'openOrdersCount', - key: 'openOrdersCount', - width: 120, - align: 'center', - render: (v: number) => - v > 0 ? ( - - {v} - - ) : ( - - ), - }, ]; } @@ -141,7 +108,7 @@ export function ClientsPage() { Carteira de Clientes - {data ? `${data.total} cliente${data.total !== 1 ? 's' : ''} na sua carteira` : ' '} + {data ? `${data.total} cliente${data.total !== 1 ? 's' : ''} na sua carteira` : ' '} @@ -179,7 +146,7 @@ export function ClientsPage() { columns={columns} dataSource={data?.data ?? []} - rowKey="id" + rowKey="idCliente" loading={isLoading || isFetching} pagination={{ current: page, @@ -189,11 +156,12 @@ export function ClientsPage() { showTotal: (total) => `${total} clientes`, onChange: (p) => setPage(p), }} - scroll={{ x: 900 }} + scroll={{ x: 700 }} size="middle" onRow={(record) => ({ style: { cursor: 'pointer' }, - onClick: () => navigate({ to: '/clientes/$id', params: { id: record.id } }), + onClick: () => + navigate({ to: '/clientes/$id', params: { id: String(record.idCliente) } }), })} /> diff --git a/apps/web/src/cockpits/rafael/NewOrderPage.tsx b/apps/web/src/cockpits/rafael/NewOrderPage.tsx index 84784bb..be5aa1b 100644 --- a/apps/web/src/cockpits/rafael/NewOrderPage.tsx +++ b/apps/web/src/cockpits/rafael/NewOrderPage.tsx @@ -18,7 +18,7 @@ import { import type { TableColumnsType } from 'antd'; import { DeleteOutlined, PlusOutlined } from '@ant-design/icons'; import { Link, useNavigate, useSearch } from '@tanstack/react-router'; -import type { CreateOrder, CreateOrderItem, ProductSummary } from '@sar/api-interface'; +import type { CreatePedido, CreatePedidoItem, ProdutoSummary } from '@sar/api-interface'; import { useClientDetail } from '../../lib/queries/clients'; import { useCatalog } from '../../lib/queries/catalog'; import { apiFetch } from '../../lib/api-client'; @@ -26,7 +26,7 @@ import { apiFetch } from '../../lib/api-client'; const { Title, Text } = Typography; const { Search } = Input; -type CartItem = CreateOrderItem & { key: string }; +type CartItem = CreatePedidoItem & { key: string }; function calcItemTotal(qty: number, price: number, disc: number): number { return Math.round(qty * price * (1 - disc / 100) * 100) / 100; @@ -46,7 +46,7 @@ function ProductStep({ onDiscChange, }: { cart: CartItem[]; - onAdd: (p: ProductSummary) => void; + onAdd: (p: ProdutoSummary) => void; onRemove: (key: string) => void; onQtyChange: (key: string, qty: number) => void; onDiscChange: (key: string, disc: number) => void; @@ -54,20 +54,20 @@ function ProductStep({ const [q, setQ] = useState(''); const { data, isLoading } = useCatalog({ q: q || undefined, limit: 20 }); - const cartKeys = new Set(cart.map((c) => c.productCode)); + const cartKeys = new Set(cart.map((c) => String(c.idProduto))); - const catalogColumns: TableColumnsType = [ - { title: 'Código', dataIndex: 'code', width: 100 }, - { title: 'Produto', dataIndex: 'name', ellipsis: true }, + const catalogColumns: TableColumnsType = [ + { title: 'Código', dataIndex: 'codigo', width: 100 }, + { title: 'Produto', dataIndex: 'descricao', ellipsis: true }, { - title: 'Categoria', - dataIndex: 'category', + title: 'Grupo', + dataIndex: 'grupo', width: 110, - render: (v: string) => {v}, + render: (v: string | null) => (v ? {v} : null), }, { title: 'Preço', - dataIndex: 'unitPrice', + dataIndex: 'vlPreco1', width: 110, align: 'right', render: (v: string) => fmt(Number(v)), @@ -75,11 +75,11 @@ function ProductStep({ { title: '', width: 80, - render: (_: unknown, row: ProductSummary) => ( + render: (_: unknown, row: ProdutoSummary) => (