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