Files
sar/apps/api/prisma/schema.prisma
julian a1a852c44d feat(c6): notificações e push — Web Push VAPID, badge dinâmico, Share API
FR-6.1/6.2: Sandra recebe push quando pedido entra em pending_approval;
Rafael recebe quando pedido é aprovado ou recusado. Service worker registrado
em background (PWA-ready via public/sw.js).

FR-6.3: Badge na Topbar busca GET /notifications/pending-count (supervisores
veem count de pending_approval; reps veem 0). Intervalo de 30s.

FR-6.4: Botão Compartilhar no OrderDetailPage para pedidos approved/invoiced
(apenas reps). Usa navigator.share() com texto formatado para WhatsApp.

Infra: modelo PushSubscription (Prisma), NotificationsModule (subscribe/
unsubscribe/pending-count + PushService VAPID), VAPID keys em .env,
integração no OrdersService (create → supervisores, approve/reject → repId).

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-28 12:31:13 +00:00

248 lines
9.3 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// SAR — Workspace Database Schema
// Stack canon: Prisma 7 · PostgreSQL 18 · BD-por-workspace (ADR 0006)
//
// Este schema roda em CADA workspace DB (sar_workspace_<id>).
// NÃO há workspaceId/tenantId em nenhum modelo — o isolamento é físico.
// O banco master (sar_master) é gerenciado pelo master-login (IdP JCS), não por este schema.
//
// CODING-RULES PGD-DB-004: moduleFormat = "cjs" (NestJS é CJS)
// CODING-RULES PGD-DB-001: MIGRATION_DATABASE_URL aponta direto ao PG (sem PgBouncer)
generator client {
provider = "prisma-client-js"
output = "../../../node_modules/.prisma/client"
moduleFormat = "cjs"
}
// Prisma 7: url removida do schema — conexão em prisma.config.ts (migrate)
// e no WorkspacePrismaPool via PrismaPg adapter (runtime).
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) ─────────────────────────────────────────────────────────────
//
// 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.
model Client {
id String @id @default(uuid()) @db.Uuid
name String
tradeName String?
taxId String @unique
email String?
phone String?
address Json?
financialStatus FinancialStatus @default(regular)
creditLimit Decimal? @db.Decimal(15, 2)
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])
}
// ─── Order (C3) ──────────────────────────────────────────────────────────────
//
// 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).
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?
// Idempotency key para lançamentos offline (C4, FR-4.12)
idempotencyKey String? @unique
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])
}
// ─── OrderItem (C3/C4) ───────────────────────────────────────────────────────
//
// 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).
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)
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
@@index([orderId])
}
// ─── Product (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.
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?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
@@index([code])
@@index([name])
@@index([category])
@@index([active])
@@index([deletedAt])
}
// ─── RepTarget (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.
model RepTarget {
repId String
year Int
month Int // 112
targetAmount Decimal @db.Decimal(15, 2)
commissionRate Decimal @default(3) @db.Decimal(5, 2)
flexRate Decimal @default(1) @db.Decimal(5, 2)
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])
}
// ─── PushSubscription (C6) ───────────────────────────────────────────────────
//
// Subscription VAPID Web Push por usuário. endpoint é único por dispositivo/browser.
// role desnormalizado do JWT para filtrar destinatários (notifySupervisors, notifyUser).
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
@@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])
}