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>
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "OrderItem" ADD COLUMN "productCategory" TEXT NOT NULL DEFAULT 'geral';
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Product" (
|
||||
"id" UUID NOT NULL,
|
||||
"code" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"category" TEXT NOT NULL DEFAULT 'geral',
|
||||
"unitPrice" DECIMAL(15,2) NOT NULL,
|
||||
"stock" DECIMAL(10,3),
|
||||
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"erpCode" TEXT,
|
||||
"syncedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"deletedAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "Product_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "RepDiscountLimit" (
|
||||
"repId" TEXT NOT NULL,
|
||||
"category" TEXT NOT NULL,
|
||||
"limit" DECIMAL(5,2) NOT NULL DEFAULT 5,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "RepDiscountLimit_pkey" PRIMARY KEY ("repId","category")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Product_code_key" ON "Product"("code");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Product_code_idx" ON "Product"("code");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Product_name_idx" ON "Product"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Product_category_idx" ON "Product"("category");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Product_active_idx" ON "Product"("active");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Product_deletedAt_idx" ON "Product"("deletedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "RepDiscountLimit_repId_idx" ON "RepDiscountLimit"("repId");
|
||||
@@ -119,26 +119,75 @@ model Order {
|
||||
@@index([deletedAt])
|
||||
}
|
||||
|
||||
// ─── OrderItem (C3) ──────────────────────────────────────────────────────────
|
||||
// ─── OrderItem (C3/C4) ───────────────────────────────────────────────────────
|
||||
//
|
||||
// Item do pedido. Produto desnormalizado (nome/código como string) — catálogo virá em 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
|
||||
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)
|
||||
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.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// Executado via: pnpm exec prisma db seed (apps/api/)
|
||||
// NUNCA rodar em staging/prod.
|
||||
|
||||
import { PrismaClient, FinancialStatus, OrderStatus } from '@prisma/client';
|
||||
import { PrismaClient, FinancialStatus, OrderStatus, type Prisma } from '@prisma/client';
|
||||
import { PrismaPg } from '@prisma/adapter-pg';
|
||||
import pg from 'pg';
|
||||
|
||||
@@ -226,6 +226,253 @@ const clientDefs = [
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Catálogo de produtos (25 produtos, 5 categorias) ────────────────────────
|
||||
|
||||
const products: Prisma.ProductCreateInput[] = [
|
||||
// grãos
|
||||
{
|
||||
code: 'ARR-001',
|
||||
name: 'Arroz Tipo 1 5kg cx10',
|
||||
category: 'grãos',
|
||||
unitPrice: 75.0,
|
||||
stock: 500,
|
||||
erpCode: 'P0001',
|
||||
},
|
||||
{
|
||||
code: 'FEI-001',
|
||||
name: 'Feijão Carioca 1kg cx10',
|
||||
category: 'grãos',
|
||||
unitPrice: 65.0,
|
||||
stock: 400,
|
||||
erpCode: 'P0002',
|
||||
},
|
||||
{
|
||||
code: 'FAR-001',
|
||||
name: 'Farinha de Trigo 25kg',
|
||||
category: 'grãos',
|
||||
unitPrice: 89.9,
|
||||
stock: 200,
|
||||
erpCode: 'P0003',
|
||||
},
|
||||
{
|
||||
code: 'ACU-001',
|
||||
name: 'Açúcar Cristal 50kg',
|
||||
category: 'grãos',
|
||||
unitPrice: 145.0,
|
||||
stock: 150,
|
||||
erpCode: 'P0004',
|
||||
},
|
||||
{
|
||||
code: 'MIL-001',
|
||||
name: 'Flocão de Milho 500g cx20',
|
||||
category: 'grãos',
|
||||
unitPrice: 48.0,
|
||||
stock: 300,
|
||||
erpCode: 'P0005',
|
||||
},
|
||||
// bebidas
|
||||
{
|
||||
code: 'OLE-001',
|
||||
name: 'Óleo de Soja 900ml cx18',
|
||||
category: 'bebidas',
|
||||
unitPrice: 98.0,
|
||||
stock: 600,
|
||||
erpCode: 'P0006',
|
||||
},
|
||||
{
|
||||
code: 'REF-001',
|
||||
name: 'Refrigerante 2L cx6',
|
||||
category: 'bebidas',
|
||||
unitPrice: 42.0,
|
||||
stock: 800,
|
||||
erpCode: 'P0007',
|
||||
},
|
||||
{
|
||||
code: 'AGU-001',
|
||||
name: 'Água Mineral 500ml cx12',
|
||||
category: 'bebidas',
|
||||
unitPrice: 18.0,
|
||||
stock: 1000,
|
||||
erpCode: 'P0008',
|
||||
},
|
||||
{
|
||||
code: 'SUC-001',
|
||||
name: 'Suco de Caixinha 200ml cx27',
|
||||
category: 'bebidas',
|
||||
unitPrice: 32.4,
|
||||
stock: 700,
|
||||
erpCode: 'P0009',
|
||||
},
|
||||
{
|
||||
code: 'CER-001',
|
||||
name: 'Cerveja Lata 350ml cx12',
|
||||
category: 'bebidas',
|
||||
unitPrice: 52.8,
|
||||
stock: 400,
|
||||
erpCode: 'P0010',
|
||||
},
|
||||
// laticínios
|
||||
{
|
||||
code: 'LEI-001',
|
||||
name: 'Leite UHT Integral 1L cx12',
|
||||
category: 'laticínios',
|
||||
unitPrice: 54.0,
|
||||
stock: 900,
|
||||
erpCode: 'P0011',
|
||||
},
|
||||
{
|
||||
code: 'QUE-001',
|
||||
name: 'Queijo Mussarela kg',
|
||||
category: 'laticínios',
|
||||
unitPrice: 38.0,
|
||||
stock: 200,
|
||||
erpCode: 'P0012',
|
||||
},
|
||||
{
|
||||
code: 'IOG-001',
|
||||
name: 'Iogurte Natural 170g cx12',
|
||||
category: 'laticínios',
|
||||
unitPrice: 28.8,
|
||||
stock: 350,
|
||||
erpCode: 'P0013',
|
||||
},
|
||||
{
|
||||
code: 'MAN-001',
|
||||
name: 'Manteiga com Sal 200g cx12',
|
||||
category: 'laticínios',
|
||||
unitPrice: 84.0,
|
||||
stock: 250,
|
||||
erpCode: 'P0014',
|
||||
},
|
||||
{
|
||||
code: 'REQ-001',
|
||||
name: 'Requeijão Cremoso 200g cx12',
|
||||
category: 'laticínios',
|
||||
unitPrice: 72.0,
|
||||
stock: 180,
|
||||
erpCode: 'P0015',
|
||||
},
|
||||
// perecíveis
|
||||
{
|
||||
code: 'CAR-001',
|
||||
name: 'Carne Bovina Contrafilé kg',
|
||||
category: 'perecíveis',
|
||||
unitPrice: 65.0,
|
||||
stock: 150,
|
||||
erpCode: 'P0016',
|
||||
},
|
||||
{
|
||||
code: 'FRA-001',
|
||||
name: 'Frango Inteiro Resfriado kg',
|
||||
category: 'perecíveis',
|
||||
unitPrice: 18.5,
|
||||
stock: 400,
|
||||
erpCode: 'P0017',
|
||||
},
|
||||
{
|
||||
code: 'PEI-001',
|
||||
name: 'Peixe Tilápia Filé kg',
|
||||
category: 'perecíveis',
|
||||
unitPrice: 32.0,
|
||||
stock: 100,
|
||||
erpCode: 'P0018',
|
||||
},
|
||||
{
|
||||
code: 'EMB-001',
|
||||
name: 'Embutidos Sortidos kg',
|
||||
category: 'perecíveis',
|
||||
unitPrice: 28.0,
|
||||
stock: 300,
|
||||
erpCode: 'P0019',
|
||||
},
|
||||
{
|
||||
code: 'LEG-001',
|
||||
name: 'Legumes Sortidos kg',
|
||||
category: 'perecíveis',
|
||||
unitPrice: 8.5,
|
||||
stock: 500,
|
||||
erpCode: 'P0020',
|
||||
},
|
||||
// higiene
|
||||
{
|
||||
code: 'SAB-001',
|
||||
name: 'Sabão em Pó 1kg cx12',
|
||||
category: 'higiene',
|
||||
unitPrice: 42.0,
|
||||
stock: 400,
|
||||
erpCode: 'P0021',
|
||||
},
|
||||
{
|
||||
code: 'DET-001',
|
||||
name: 'Detergente 500ml cx24',
|
||||
category: 'higiene',
|
||||
unitPrice: 38.4,
|
||||
stock: 600,
|
||||
erpCode: 'P0022',
|
||||
},
|
||||
{
|
||||
code: 'HIG-001',
|
||||
name: 'Shampoo 400ml cx12',
|
||||
category: 'higiene',
|
||||
unitPrice: 96.0,
|
||||
stock: 250,
|
||||
erpCode: 'P0023',
|
||||
},
|
||||
{
|
||||
code: 'LEV-001',
|
||||
name: 'Fermento Biológico 500g',
|
||||
category: 'grãos',
|
||||
unitPrice: 12.5,
|
||||
stock: 200,
|
||||
erpCode: 'P0024',
|
||||
},
|
||||
{
|
||||
code: 'CON-001',
|
||||
name: 'Conservas Sortidas cx24',
|
||||
category: 'grãos',
|
||||
unitPrice: 96.0,
|
||||
stock: 180,
|
||||
erpCode: 'P0025',
|
||||
},
|
||||
{
|
||||
code: 'MOL-001',
|
||||
name: 'Molho de Tomate 340g cx24',
|
||||
category: 'grãos',
|
||||
unitPrice: 72.0,
|
||||
stock: 220,
|
||||
erpCode: 'P0026',
|
||||
},
|
||||
{
|
||||
code: 'FRU-001',
|
||||
name: 'Frutas Sortidas cx',
|
||||
category: 'perecíveis',
|
||||
unitPrice: 45.0,
|
||||
stock: 80,
|
||||
erpCode: 'P0027',
|
||||
},
|
||||
{
|
||||
code: 'VER-001',
|
||||
name: 'Verduras Sortidas kg',
|
||||
category: 'perecíveis',
|
||||
unitPrice: 6.0,
|
||||
stock: 300,
|
||||
erpCode: 'P0028',
|
||||
},
|
||||
];
|
||||
|
||||
// Mapa code → category para popular productCategory nos OrderItems do seed C3
|
||||
const productCategoryMap: Record<string, string> = Object.fromEntries(
|
||||
products.map((p) => [p.code, p.category ?? 'geral']),
|
||||
);
|
||||
|
||||
// Alçadas de desconto de user-001: default 10%, bebidas 8%, perecíveis 5%
|
||||
const repDiscountLimits = [
|
||||
{ repId: DEV_REP_ID, category: '__default__', limit: 10 },
|
||||
{ repId: DEV_REP_ID, category: 'bebidas', limit: 8 },
|
||||
{ repId: DEV_REP_ID, category: 'perecíveis', limit: 5 },
|
||||
{ repId: DEV_REP2_ID, category: '__default__', limit: 5 },
|
||||
];
|
||||
|
||||
type OrderSeed = {
|
||||
num: number;
|
||||
status: OrderStatus;
|
||||
@@ -721,6 +968,26 @@ function buildHistoryForStatus(status: OrderStatus, repId: string, issuedDaysAgo
|
||||
async function main() {
|
||||
console.log('Seed iniciado...');
|
||||
|
||||
// Upsert catálogo de produtos
|
||||
for (const p of products) {
|
||||
await prisma.product.upsert({
|
||||
where: { code: p.code },
|
||||
create: { ...p, syncedAt: new Date() },
|
||||
update: { name: p.name, unitPrice: p.unitPrice, stock: p.stock, syncedAt: new Date() },
|
||||
});
|
||||
}
|
||||
console.log(`${products.length} produtos upserted.`);
|
||||
|
||||
// Upsert alçadas de desconto
|
||||
for (const r of repDiscountLimits) {
|
||||
await prisma.repDiscountLimit.upsert({
|
||||
where: { repId_category: { repId: r.repId, category: r.category } },
|
||||
create: r,
|
||||
update: { limit: r.limit },
|
||||
});
|
||||
}
|
||||
console.log(`${repDiscountLimits.length} alçadas configuradas.`);
|
||||
|
||||
// Upsert clients (sem lastOrderAt/openOrdersCount — calculados depois)
|
||||
for (const data of clientDefs) {
|
||||
await prisma.client.upsert({
|
||||
@@ -749,10 +1016,11 @@ async function main() {
|
||||
for (const o of orders) {
|
||||
const issuedAt = daysAgo(o.issuedDaysAgo);
|
||||
|
||||
// Build items with subtotals
|
||||
// Build items with subtotals + productCategory (desnorm do catálogo)
|
||||
const itemsData = o.items.map((it) => ({
|
||||
productCode: it.productCode,
|
||||
productName: it.productName,
|
||||
productCategory: productCategoryMap[it.productCode] ?? 'geral',
|
||||
quantity: it.qty,
|
||||
unitPrice: it.unitPrice,
|
||||
discountPct: it.discountPct,
|
||||
|
||||
Reference in New Issue
Block a user