Files
sar/apps/api/prisma/schema.prisma
julian 6769a0d82a feat(c4): lançamento de pedido — catálogo, alçada por linha, POST /orders
- Prisma: Product + RepDiscountLimit + productCategory em OrderItem + migration
- Seed: 28 produtos (5 categorias) + alçadas user-001 (default 10%, bebidas 8%, perecíveis 5%)
- @sar/api-interface: ProductSummarySchema, ProductDetailSchema, ProductSyncRequestSchema, CreateOrderSchema
- API: CatalogModule (GET /catalog, GET /catalog/:id, POST /catalog/sync)
- API: POST /orders — valida alçada por linha/produto (OQ-2), idempotency-key (FR-4.3), desnorm cliente
- Web: NewOrderPage (3 steps: catálogo → desconto/obs → confirmação)
- Web: botão Novo Pedido na ClientDetailPage (desabilitado se financialStatus=blocked)
- Web: rota /pedidos/novo com search param clientId

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 23:45:11 +00:00

209 lines
7.9 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])
}
// ─── 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])
}
// ─── 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])
}