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:
2026-05-27 23:45:11 +00:00
parent c36451dd33
commit 6769a0d82a
16 changed files with 1372 additions and 17 deletions

View File

@@ -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,