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

@@ -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");

View File

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

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,

View File

@@ -10,6 +10,7 @@ import { AuthModule } from './auth/auth.module';
import { JwtAuthGuard } from './auth/jwt-auth.guard';
import { ClientsModule } from './clients/clients.module';
import { OrdersModule } from './orders/orders.module';
import { CatalogModule } from './catalog/catalog.module';
import { ProblemDetailsFilter } from './filters/problem-details.filter';
@Module({
@@ -23,6 +24,7 @@ import { ProblemDetailsFilter } from './filters/problem-details.filter';
PingModule,
ClientsModule,
OrdersModule,
CatalogModule,
],
providers: [
{ provide: APP_PIPE, useClass: ZodValidationPipe },

View File

@@ -0,0 +1,46 @@
import {
Body,
Controller,
Get,
NotFoundException,
Param,
ParseUUIDPipe,
Post,
Query,
} from '@nestjs/common';
import { createZodDto } from 'nestjs-zod';
import {
ProductListQuerySchema,
ProductSyncRequestSchema,
type ProductDetail,
type ProductListQuery,
type ProductListResponse,
type ProductSyncResponse,
} from '@sar/api-interface';
import { CatalogService } from './catalog.service';
class ProductListQueryDto extends createZodDto(ProductListQuerySchema) {}
class ProductSyncRequestDto extends createZodDto(ProductSyncRequestSchema) {}
@Controller({ path: 'catalog' })
export class CatalogController {
constructor(private readonly catalog: CatalogService) {}
@Get()
list(@Query() query: ProductListQueryDto): Promise<ProductListResponse> {
const parsed = ProductListQuerySchema.parse(query) as ProductListQuery;
return this.catalog.list(parsed);
}
@Get(':id')
async findOne(@Param('id', ParseUUIDPipe) id: string): Promise<ProductDetail> {
const product = await this.catalog.findOne(id);
if (!product) throw new NotFoundException(`Produto ${id} não encontrado`);
return product;
}
@Post('sync')
sync(@Body() body: ProductSyncRequestDto): Promise<ProductSyncResponse> {
return this.catalog.sync(body);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { CatalogController } from './catalog.controller';
import { CatalogService } from './catalog.service';
@Module({
controllers: [CatalogController],
providers: [CatalogService],
exports: [CatalogService],
})
export class CatalogModule {}

View File

@@ -0,0 +1,135 @@
import { Injectable } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import { Prisma } from '@prisma/client';
import type {
ProductDetail,
ProductListQuery,
ProductListResponse,
ProductSummary,
ProductSyncRequest,
ProductSyncResponse,
} from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types';
function decimalToString(v: Prisma.Decimal | null | undefined): string | null {
return v ? v.toString() : null;
}
@Injectable()
export class CatalogService {
constructor(private readonly cls: ClsService<WorkspaceClsStore>) {}
async list(query: ProductListQuery): Promise<ProductListResponse> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const { q, category, page, limit } = query;
const skip = (page - 1) * limit;
const where: Prisma.ProductWhereInput = {
deletedAt: null,
active: true,
...(category ? { category } : {}),
...(q
? {
OR: [
{ name: { contains: q, mode: 'insensitive' } },
{ code: { contains: q, mode: 'insensitive' } },
],
}
: {}),
};
const [rows, total] = await Promise.all([
prisma.product.findMany({
where,
select: {
id: true,
code: true,
name: true,
category: true,
unitPrice: true,
stock: true,
active: true,
},
skip,
take: limit,
orderBy: [{ category: 'asc' }, { name: 'asc' }],
}),
prisma.product.count({ where }),
]);
const data: ProductSummary[] = rows.map((p) => ({
id: p.id,
code: p.code,
name: p.name,
category: p.category,
unitPrice: decimalToString(p.unitPrice) ?? '0',
stock: decimalToString(p.stock),
active: p.active,
}));
return { data, total, page, limit };
}
async findOne(id: string): Promise<ProductDetail | null> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const p = await prisma.product.findFirst({ where: { id, deletedAt: null, active: true } });
if (!p) return null;
return {
id: p.id,
code: p.code,
name: p.name,
description: p.description,
category: p.category,
unitPrice: decimalToString(p.unitPrice) ?? '0',
stock: decimalToString(p.stock),
active: p.active,
erpCode: p.erpCode,
syncedAt: p.syncedAt?.toISOString() ?? null,
createdAt: p.createdAt.toISOString(),
updatedAt: p.updatedAt.toISOString(),
};
}
async sync(req: ProductSyncRequest): Promise<ProductSyncResponse> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const syncedAt = new Date();
let upserted = 0;
for (const item of req.items) {
await prisma.product.upsert({
where: { code: item.code },
create: {
code: item.code,
name: item.name,
description: item.description ?? null,
category: item.category ?? 'geral',
unitPrice: item.unitPrice,
stock: item.stock ?? null,
active: item.active ?? true,
erpCode: item.erpCode ?? null,
syncedAt,
},
update: {
name: item.name,
description: item.description ?? null,
category: item.category ?? 'geral',
unitPrice: item.unitPrice,
stock: item.stock ?? null,
active: item.active ?? true,
erpCode: item.erpCode ?? null,
syncedAt,
},
});
upserted++;
}
return { upserted, syncedAt: syncedAt.toISOString() };
}
}

View File

@@ -1,8 +1,10 @@
import { Controller, Get, Param, ParseUUIDPipe, Query } from '@nestjs/common';
import { Body, Controller, Get, HttpCode, Param, ParseUUIDPipe, Post, Query } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import { createZodDto } from 'nestjs-zod';
import {
CreateOrderSchema,
OrderListQuerySchema,
type CreateOrder,
type OrderDetail,
type OrderListQuery,
type OrderListResponse,
@@ -11,6 +13,7 @@ import type { WorkspaceClsStore } from '../workspace/workspace.types';
import { OrdersService } from './orders.service';
class OrderListQueryDto extends createZodDto(OrderListQuerySchema) {}
class CreateOrderDto extends createZodDto(CreateOrderSchema) {}
@Controller({ path: 'orders' })
export class OrdersController {
@@ -25,6 +28,13 @@ export class OrdersController {
return this.orders.list(parsed, this.cls.get('userId') ?? '', this.cls.get('role') ?? 'rep');
}
@Post()
@HttpCode(201)
create(@Body() body: CreateOrderDto): Promise<OrderDetail> {
const parsed = CreateOrderSchema.parse(body) as CreateOrder;
return this.orders.create(parsed, this.cls.get('userId') ?? '');
}
@Get(':id')
findOne(@Param('id', ParseUUIDPipe) id: string): Promise<OrderDetail> {
return this.orders.findOne(id, this.cls.get('userId') ?? '', this.cls.get('role') ?? 'rep');

View File

@@ -1,7 +1,8 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import { Prisma } from '@prisma/client';
import { OrderStatus, Prisma } from '@prisma/client';
import type {
CreateOrder,
OrderDetail,
OrderListQuery,
OrderListResponse,
@@ -128,6 +129,217 @@ export class OrdersService {
};
}
// Cria novo pedido. Valida alçada por linha de produto (OQ-2).
// Idempotency-Key: retorna pedido existente se já processado (FR-4.3).
async create(dto: CreateOrder, userId: string): Promise<OrderDetail> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
// Idempotency-Key: retorna pedido existente sem re-processar
if (dto.idempotencyKey) {
const existing = await prisma.order.findUnique({
where: { idempotencyKey: dto.idempotencyKey },
include: {
client: { select: { name: true } },
items: true,
history: { orderBy: { changedAt: 'asc' } },
},
});
if (existing) return this.mapDetail(existing);
}
// Verifica que o cliente existe e pertence ao rep
const client = await prisma.client.findFirst({
where: { id: dto.clientId, repId: userId, deletedAt: null },
});
if (!client) throw new NotFoundException(`Cliente ${dto.clientId} não encontrado`);
// Resolve alçadas por categoria: (repId, category) → fallback (repId, '__default__') → 5%
const limitRows = await prisma.repDiscountLimit.findMany({ where: { repId: userId } });
const limitMap = new Map(limitRows.map((r) => [r.category, Number(r.limit)]));
const getLimit = (category: string) =>
limitMap.get(category) ?? limitMap.get('__default__') ?? 5;
// Valida alçada item a item
let needsApproval = false;
for (const item of dto.items) {
const lim = getLimit(item.productCategory ?? 'geral');
if (item.discountPct > lim) {
needsApproval = true;
break;
}
}
// Alçada global: compara desconto global do pedido com o default do rep
const globalLimit = getLimit('__default__');
if (dto.discountPct > globalLimit) needsApproval = true;
const status = needsApproval ? OrderStatus.pending_approval : OrderStatus.budget;
// Gera número sequencial: PED-NNNNN
const lastOrder = await prisma.order.findFirst({
orderBy: { createdAt: 'desc' },
select: { number: true },
});
const seq = lastOrder ? parseInt(lastOrder.number.replace('PED-', ''), 10) + 1 : 1;
const number = `PED-${String(seq).padStart(5, '0')}`;
// Calcula subtotais
const itemsData = dto.items.map((it) => {
const subtotal =
Math.round(it.quantity * it.unitPrice * (1 - it.discountPct / 100) * 100) / 100;
return {
productCode: it.productCode,
productName: it.productName,
productCategory: it.productCategory ?? 'geral',
quantity: it.quantity,
unitPrice: it.unitPrice,
discountPct: it.discountPct,
subtotal,
};
});
const itemsSubtotal = itemsData.reduce((acc, it) => acc + it.subtotal, 0);
const total = Math.round(itemsSubtotal * (1 - dto.discountPct / 100) * 100) / 100;
const now = new Date();
const order = await prisma.order.create({
data: {
number,
clientId: dto.clientId,
repId: userId,
status,
discountPct: dto.discountPct,
subtotal: itemsSubtotal,
total,
notes: dto.notes ?? null,
idempotencyKey: dto.idempotencyKey ?? null,
issuedAt: now,
items: { create: itemsData },
history: {
create: [
{ fromStatus: null, toStatus: OrderStatus.budget, changedById: userId, changedAt: now },
],
},
},
include: {
client: { select: { name: true } },
items: true,
history: { orderBy: { changedAt: 'asc' } },
},
});
if (status === OrderStatus.pending_approval) {
await prisma.orderStatusHistory.create({
data: {
orderId: order.id,
fromStatus: OrderStatus.budget,
toStatus: OrderStatus.pending_approval,
changedById: userId,
changedAt: now,
},
});
}
// Atualiza desnorm do cliente
const openStatuses = [OrderStatus.budget, OrderStatus.pending_approval, OrderStatus.approved];
const openCount = await prisma.order.count({
where: { clientId: dto.clientId, deletedAt: null, status: { in: openStatuses } },
});
await prisma.client.update({
where: { id: dto.clientId },
data: { lastOrderAt: now, lastOrderValue: total, openOrdersCount: openCount },
});
if (status === OrderStatus.pending_approval) {
// Buscar order com history atualizado
const updated = await prisma.order.findUniqueOrThrow({
where: { id: order.id },
include: {
client: { select: { name: true } },
items: true,
history: { orderBy: { changedAt: 'asc' } },
},
});
return this.mapDetail(updated);
}
return this.mapDetail(order);
}
private mapDetail(o: {
id: string;
number: string;
clientId: string;
client: { name: string };
repId: string;
status: OrderStatus;
discountPct: Prisma.Decimal;
subtotal: Prisma.Decimal;
total: Prisma.Decimal;
notes: string | null;
approvedById: string | null;
idempotencyKey: string | null;
issuedAt: Date;
approvedAt: Date | null;
invoicedAt: Date | null;
cancelledAt: Date | null;
createdAt: Date;
updatedAt: Date;
items: {
id: string;
productCode: string;
productName: string;
quantity: Prisma.Decimal;
unitPrice: Prisma.Decimal;
discountPct: Prisma.Decimal;
subtotal: Prisma.Decimal;
}[];
history: {
id: string;
fromStatus: OrderStatus | null;
toStatus: OrderStatus;
changedById: string;
note: string | null;
changedAt: Date;
}[];
}): OrderDetail {
return {
id: o.id,
number: o.number,
clientId: o.clientId,
clientName: o.client.name,
repId: o.repId,
status: o.status,
discountPct: decimalToString(o.discountPct),
subtotal: decimalToString(o.subtotal),
total: decimalToString(o.total),
notes: o.notes,
approvedById: o.approvedById,
idempotencyKey: o.idempotencyKey,
issuedAt: o.issuedAt.toISOString(),
approvedAt: o.approvedAt?.toISOString() ?? null,
invoicedAt: o.invoicedAt?.toISOString() ?? null,
cancelledAt: o.cancelledAt?.toISOString() ?? null,
createdAt: o.createdAt.toISOString(),
updatedAt: o.updatedAt.toISOString(),
items: o.items.map((it) => ({
id: it.id,
productCode: it.productCode,
productName: it.productName,
quantity: decimalToString(it.quantity),
unitPrice: decimalToString(it.unitPrice),
discountPct: decimalToString(it.discountPct),
subtotal: decimalToString(it.subtotal),
})),
history: o.history.map((h) => ({
id: h.id,
fromStatus: h.fromStatus,
toStatus: h.toStatus,
changedById: h.changedById,
note: h.note,
changedAt: h.changedAt.toISOString(),
})),
};
}
// Últimos N pedidos de um cliente — usado na ficha (FR-2.4).
async listByClient(
clientId: string,

View File

@@ -1,6 +1,6 @@
import { Descriptions, Tag, Table, Typography, Spin, Alert, Space, Divider } from 'antd';
import { Button, Descriptions, Tag, Table, Typography, Spin, Alert, Space, Divider } from 'antd';
import type { TableColumnsType } from 'antd';
import { Link, useParams } from '@tanstack/react-router';
import { Link, useNavigate, useParams } from '@tanstack/react-router';
import type { OrderSummary, OrderStatus } from '@sar/api-interface';
import { useClientDetail } from '../../lib/queries/clients';
import { useClientOrders } from '../../lib/queries/orders';
@@ -77,6 +77,7 @@ const orderColumns: TableColumnsType<OrderSummary> = [
export function ClientDetailPage() {
const { id } = useParams({ from: '/clientes/$id' });
const navigate = useNavigate();
const { data: client, isLoading: clientLoading, error: clientError } = useClientDetail(id);
const { data: orders, isLoading: ordersLoading } = useClientOrders(id);
@@ -88,7 +89,7 @@ export function ClientDetailPage() {
return (
<div style={{ padding: 24 }}>
<Space align="center" style={{ marginBottom: 16 }}>
<Space align="center" style={{ marginBottom: 16 }} wrap>
<Link to="/clientes"> Clientes</Link>
<Title level={3} style={{ margin: 0 }}>
{client.tradeName ?? client.name}
@@ -99,6 +100,13 @@ export function ClientDetailPage() {
<Tag color={ACTIVITY_COLOR[client.activityStatus]}>
{ACTIVITY_LABEL[client.activityStatus]}
</Tag>
<Button
type="primary"
onClick={() => void navigate({ to: '/pedidos/novo', search: { clientId: id } })}
disabled={client.financialStatus === 'blocked'}
>
Novo Pedido
</Button>
</Space>
<Descriptions bordered size="small" column={2} style={{ marginBottom: 24 }}>

View File

@@ -0,0 +1,436 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
Alert,
Button,
Descriptions,
Divider,
Form,
InputNumber,
Space,
Spin,
Steps,
Table,
Tag,
Typography,
Input,
} from 'antd';
import type { TableColumnsType } from 'antd';
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
import { Link, useNavigate, useSearch } from '@tanstack/react-router';
import type { CreateOrder, CreateOrderItem, ProductSummary } from '@sar/api-interface';
import { useClientDetail } from '../../lib/queries/clients';
import { useCatalog } from '../../lib/queries/catalog';
import { apiFetch } from '../../lib/api-client';
const { Title, Text } = Typography;
const { Search } = Input;
type CartItem = CreateOrderItem & { key: string };
function calcItemTotal(qty: number, price: number, disc: number): number {
return Math.round(qty * price * (1 - disc / 100) * 100) / 100;
}
function fmt(n: number): string {
return n.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
}
// ─── Step 1 — Selecionar Produtos ────────────────────────────────────────────
function ProductStep({
cart,
onAdd,
onRemove,
onQtyChange,
onDiscChange,
}: {
cart: CartItem[];
onAdd: (p: ProductSummary) => void;
onRemove: (key: string) => void;
onQtyChange: (key: string, qty: number) => void;
onDiscChange: (key: string, disc: number) => void;
}) {
const [q, setQ] = useState('');
const { data, isLoading } = useCatalog({ q: q || undefined, limit: 20 });
const cartKeys = new Set(cart.map((c) => c.productCode));
const catalogColumns: TableColumnsType<ProductSummary> = [
{ title: 'Código', dataIndex: 'code', width: 100 },
{ title: 'Produto', dataIndex: 'name', ellipsis: true },
{
title: 'Categoria',
dataIndex: 'category',
width: 110,
render: (v: string) => <Tag>{v}</Tag>,
},
{
title: 'Preço',
dataIndex: 'unitPrice',
width: 110,
align: 'right',
render: (v: string) => fmt(Number(v)),
},
{
title: '',
width: 80,
render: (_: unknown, row: ProductSummary) => (
<Button
size="small"
icon={<PlusOutlined />}
disabled={cartKeys.has(row.code)}
onClick={() => onAdd(row)}
>
Add
</Button>
),
},
];
const cartColumns: TableColumnsType<CartItem> = [
{ title: 'Produto', dataIndex: 'productName', ellipsis: true },
{
title: 'Qtd',
dataIndex: 'quantity',
width: 100,
render: (v: number, row: CartItem) => (
<InputNumber
min={0.001}
step={1}
value={v}
size="small"
style={{ width: 80 }}
onChange={(n) => onQtyChange(row.key, n ?? 1)}
/>
),
},
{
title: 'Desc %',
dataIndex: 'discountPct',
width: 100,
render: (v: number, row: CartItem) => (
<InputNumber
min={0}
max={100}
step={0.5}
value={v}
size="small"
style={{ width: 80 }}
onChange={(n) => onDiscChange(row.key, n ?? 0)}
/>
),
},
{
title: 'Subtotal',
width: 120,
align: 'right',
render: (_: unknown, row: CartItem) =>
fmt(calcItemTotal(row.quantity, row.unitPrice, row.discountPct)),
},
{
title: '',
width: 40,
render: (_: unknown, row: CartItem) => (
<Button danger size="small" icon={<DeleteOutlined />} onClick={() => onRemove(row.key)} />
),
},
];
return (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Search
placeholder="Buscar produto por nome ou código..."
allowClear
style={{ maxWidth: 400 }}
onSearch={setQ}
onChange={(e) => {
if (!e.target.value) setQ('');
}}
/>
<Table<ProductSummary>
rowKey="id"
columns={catalogColumns}
dataSource={data?.data ?? []}
loading={isLoading}
size="small"
pagination={false}
scroll={{ y: 220 }}
/>
<Divider orientation="left">Itens do Pedido ({cart.length})</Divider>
<Table<CartItem>
rowKey="key"
columns={cartColumns}
dataSource={cart}
size="small"
pagination={false}
locale={{ emptyText: 'Nenhum produto adicionado ainda.' }}
/>
</Space>
);
}
// ─── Step 2 — Desconto Global + Observações ───────────────────────────────────
function ReviewStep({
cart,
globalDisc,
notes,
creditLimit,
onDiscChange,
onNotesChange,
}: {
cart: CartItem[];
globalDisc: number;
notes: string;
creditLimit: string | null;
onDiscChange: (v: number) => void;
onNotesChange: (v: string) => void;
}) {
const itemsSubtotal = cart.reduce(
(acc, it) => acc + calcItemTotal(it.quantity, it.unitPrice, it.discountPct),
0,
);
const total = Math.round(itemsSubtotal * (1 - globalDisc / 100) * 100) / 100;
const limit = creditLimit ? Number(creditLimit) : null;
const creditOk = limit === null || total <= limit;
return (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Descriptions bordered size="small" column={2}>
<Descriptions.Item label="Subtotal dos itens" span={2}>
<Text strong>{fmt(itemsSubtotal)}</Text>
</Descriptions.Item>
<Descriptions.Item label="Desconto global do pedido">
<InputNumber
min={0}
max={100}
step={0.5}
value={globalDisc}
addonAfter="%"
style={{ width: 120 }}
onChange={(n) => onDiscChange(n ?? 0)}
/>
</Descriptions.Item>
<Descriptions.Item label="Total do pedido">
<Text strong style={{ fontSize: 16 }}>
{fmt(total)}
</Text>
</Descriptions.Item>
<Descriptions.Item label="Limite de crédito">{limit ? fmt(limit) : '—'}</Descriptions.Item>
<Descriptions.Item label="Situação crédito">
<Tag color={creditOk ? 'success' : 'error'}>{creditOk ? 'OK' : 'Acima do limite'}</Tag>
</Descriptions.Item>
</Descriptions>
<Form.Item label="Observações (opcional)">
<Input.TextArea
rows={3}
maxLength={500}
showCount
value={notes}
onChange={(e) => onNotesChange(e.target.value)}
placeholder="Instruções de entrega, referência do comprador, etc."
/>
</Form.Item>
</Space>
);
}
// ─── Step 3 — Confirmação ─────────────────────────────────────────────────────
function ConfirmStep({
cart,
globalDisc,
notes,
clientName,
}: {
cart: CartItem[];
globalDisc: number;
notes: string;
clientName: string;
}) {
const itemsSubtotal = cart.reduce(
(acc, it) => acc + calcItemTotal(it.quantity, it.unitPrice, it.discountPct),
0,
);
const total = Math.round(itemsSubtotal * (1 - globalDisc / 100) * 100) / 100;
return (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Descriptions bordered size="small" column={1}>
<Descriptions.Item label="Cliente">{clientName}</Descriptions.Item>
<Descriptions.Item label="Produtos">{cart.length} item(ns)</Descriptions.Item>
<Descriptions.Item label="Subtotal dos itens">{fmt(itemsSubtotal)}</Descriptions.Item>
<Descriptions.Item label="Desconto global">{globalDisc}%</Descriptions.Item>
<Descriptions.Item label="Total">
<Text strong style={{ fontSize: 18 }}>
{fmt(total)}
</Text>
</Descriptions.Item>
{notes && <Descriptions.Item label="Observações">{notes}</Descriptions.Item>}
</Descriptions>
<Alert
type="info"
message="O pedido será criado com status Orçamento ou Aguardando Aprovação, conforme a sua alçada de desconto."
showIcon
/>
</Space>
);
}
// ─── NewOrderPage ─────────────────────────────────────────────────────────────
type SearchParams = { clientId?: string };
export function NewOrderPage() {
const { clientId } = useSearch({ strict: false }) as SearchParams;
const navigate = useNavigate();
const qc = useQueryClient();
const { data: client, isLoading: clientLoading } = useClientDetail(clientId);
const [step, setStep] = useState(0);
const [cart, setCart] = useState<CartItem[]>([]);
const [globalDisc, setGlobalDisc] = useState(0);
const [notes, setNotes] = useState('');
const [error, setError] = useState<string | null>(null);
const mutation = useMutation({
mutationFn: async () => {
if (!clientId) throw new Error('clientId ausente');
const body: CreateOrder = {
clientId,
discountPct: globalDisc,
notes: notes || undefined,
idempotencyKey: crypto.randomUUID(),
items: cart.map((it) => ({
productCode: it.productCode,
productName: it.productName,
productCategory: it.productCategory,
quantity: it.quantity,
unitPrice: it.unitPrice,
discountPct: it.discountPct,
})),
};
const res = await apiFetch('/orders', {
method: 'POST',
body: JSON.stringify(body),
headers: { 'Content-Type': 'application/json' },
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error((err as { detail?: string }).detail ?? `Erro ${res.status}`);
}
return res.json();
},
onSuccess: (order: { id: string }) => {
void qc.invalidateQueries({ queryKey: ['orders'] });
void qc.invalidateQueries({ queryKey: ['clients', clientId] });
void navigate({ to: '/pedidos/$id', params: { id: order.id } });
},
onError: (e: Error) => setError(e.message),
});
const addToCart = (p: ProductSummary) => {
setCart((prev) => [
...prev,
{
key: p.code,
productCode: p.code,
productName: p.name,
productCategory: p.category,
quantity: 1,
unitPrice: Number(p.unitPrice),
discountPct: 0,
},
]);
};
const removeFromCart = (key: string) => setCart((prev) => prev.filter((it) => it.key !== key));
const setQty = (key: string, qty: number) =>
setCart((prev) => prev.map((it) => (it.key === key ? { ...it, quantity: qty } : it)));
const setDisc = (key: string, disc: number) =>
setCart((prev) => prev.map((it) => (it.key === key ? { ...it, discountPct: disc } : it)));
if (!clientId)
return <Alert type="error" message="Parâmetro clientId ausente." style={{ margin: 24 }} />;
if (clientLoading) return <Spin style={{ display: 'block', marginTop: 64 }} />;
if (!client)
return <Alert type="error" message="Cliente não encontrado." style={{ margin: 24 }} />;
const steps = [{ title: 'Produtos' }, { title: 'Desconto / Obs.' }, { title: 'Confirmar' }];
const canNext = (step === 0 && cart.length > 0) || step === 1;
return (
<div style={{ padding: 24, maxWidth: 900 }}>
<Space align="center" style={{ marginBottom: 16 }}>
<Link to="/clientes/$id" params={{ id: clientId }}>
{client.tradeName ?? client.name}
</Link>
<Title level={3} style={{ margin: 0 }}>
Novo Pedido
</Title>
</Space>
<Steps current={step} items={steps} style={{ marginBottom: 24 }} />
{step === 0 && (
<ProductStep
cart={cart}
onAdd={addToCart}
onRemove={removeFromCart}
onQtyChange={setQty}
onDiscChange={setDisc}
/>
)}
{step === 1 && (
<ReviewStep
cart={cart}
globalDisc={globalDisc}
notes={notes}
creditLimit={client.creditLimit}
onDiscChange={setGlobalDisc}
onNotesChange={setNotes}
/>
)}
{step === 2 && (
<ConfirmStep
cart={cart}
globalDisc={globalDisc}
notes={notes}
clientName={client.tradeName ?? client.name}
/>
)}
{error && (
<Alert
type="error"
message={error}
showIcon
style={{ marginTop: 16 }}
closable
onClose={() => setError(null)}
/>
)}
<Divider />
<Space>
{step > 0 && <Button onClick={() => setStep((s) => s - 1)}>Voltar</Button>}
{step < 2 && (
<Button type="primary" disabled={!canNext} onClick={() => setStep((s) => s + 1)}>
Próximo
</Button>
)}
{step === 2 && (
<Button type="primary" loading={mutation.isPending} onClick={() => mutation.mutate()}>
Confirmar Pedido
</Button>
)}
</Space>
</div>
);
}

View File

@@ -0,0 +1,26 @@
import { useQuery } from '@tanstack/react-query';
import {
ProductListResponseSchema,
type ProductListQuery,
type ProductListResponse,
} from '@sar/api-interface';
import { apiFetch } from '../api-client';
export function useCatalog(params: Partial<ProductListQuery> = {}) {
const search = new URLSearchParams();
if (params.q) search.set('q', params.q);
if (params.category) search.set('category', params.category);
if (params.page) search.set('page', String(params.page));
if (params.limit) search.set('limit', String(params.limit));
const qs = search.toString();
return useQuery<ProductListResponse>({
queryKey: ['catalog', params],
queryFn: async () => {
const res = await apiFetch(`/catalog${qs ? `?${qs}` : ''}`);
if (!res.ok) throw new Error(`catalog error ${res.status}`);
return ProductListResponseSchema.parse(await res.json());
},
staleTime: 4 * 60 * 60 * 1000, // TTL 4h — FR-4.4
});
}

View File

@@ -4,6 +4,7 @@ import { RafaelPainel } from '../cockpits/rafael/RafaelPainel';
import { ClientsPage } from '../cockpits/rafael/ClientsPage';
import { ClientDetailPage } from '../cockpits/rafael/ClientDetailPage';
import { OrdersPage } from '../cockpits/rafael/OrdersPage';
import { NewOrderPage } from '../cockpits/rafael/NewOrderPage';
const rootRoute = createRootRoute({
component: () => (
@@ -43,6 +44,12 @@ const pedidosRoute = createRoute({
component: OrdersPage,
});
const novoOrderRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/pedidos/novo',
component: NewOrderPage,
});
const pedidoDetailRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/pedidos/$id',
@@ -62,6 +69,7 @@ const routeTree = rootRoute.addChildren([
clientesRoute,
clienteDetailRoute,
pedidosRoute,
novoOrderRoute,
pedidoDetailRoute,
]);

View File

@@ -2,3 +2,4 @@ export * from './lib/ping.contract';
export * from './lib/auth.contract';
export * from './lib/client.contract';
export * from './lib/order.contract';
export * from './lib/product.contract';

View File

@@ -91,3 +91,24 @@ export const OrderListResponseSchema = z.object({
limit: z.number().int().positive(),
});
export type OrderListResponse = z.infer<typeof OrderListResponseSchema>;
// ─── Create Order (POST /orders) ──────────────────────────────────────────────
export const CreateOrderItemSchema = z.object({
productCode: z.string().min(1),
productName: z.string().min(1),
productCategory: z.string().default('geral'),
quantity: z.number().positive(),
unitPrice: z.number().positive(),
discountPct: z.number().min(0).max(100).default(0),
});
export type CreateOrderItem = z.infer<typeof CreateOrderItemSchema>;
export const CreateOrderSchema = z.object({
clientId: z.string().uuid(),
discountPct: z.number().min(0).max(100).default(0), // desconto global do pedido
notes: z.string().optional(),
idempotencyKey: z.string().optional(),
items: z.array(CreateOrderItemSchema).min(1),
});
export type CreateOrder = z.infer<typeof CreateOrderSchema>;

View File

@@ -0,0 +1,71 @@
import { z } from 'zod';
// Contratos canônicos de C4 — Catálogo de Produtos.
// Consumidos pela API (output tipado) e pela Web (TanStack Query + parse).
// ─── Product Summary (lista) ──────────────────────────────────────────────────
export const ProductSummarySchema = z.object({
id: z.string().uuid(),
code: z.string(),
name: z.string(),
category: z.string(),
unitPrice: z.string(), // Decimal serializado
stock: z.string().nullable(),
active: z.boolean(),
});
export type ProductSummary = z.infer<typeof ProductSummarySchema>;
// ─── Product Detail ───────────────────────────────────────────────────────────
export const ProductDetailSchema = ProductSummarySchema.extend({
description: z.string().nullable(),
erpCode: z.string().nullable(),
syncedAt: z.iso.datetime().nullable(),
createdAt: z.iso.datetime(),
updatedAt: z.iso.datetime(),
});
export type ProductDetail = z.infer<typeof ProductDetailSchema>;
// ─── List query + response ────────────────────────────────────────────────────
export const ProductListQuerySchema = z.object({
q: z.string().optional(), // busca nome/código
category: z.string().optional(), // filtra por categoria
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(200).default(50),
});
export type ProductListQuery = z.infer<typeof ProductListQuerySchema>;
export const ProductListResponseSchema = z.object({
data: z.array(ProductSummarySchema),
total: z.number().int().nonnegative(),
page: z.number().int().positive(),
limit: z.number().int().positive(),
});
export type ProductListResponse = z.infer<typeof ProductListResponseSchema>;
// ─── Sync (importação da view do ERP) ────────────────────────────────────────
export const ProductSyncItemSchema = z.object({
code: z.string().min(1),
name: z.string().min(1),
description: z.string().optional(),
category: z.string().default('geral'),
unitPrice: z.number().positive(),
stock: z.number().nonnegative().optional(),
active: z.boolean().default(true),
erpCode: z.string().optional(),
});
export type ProductSyncItem = z.infer<typeof ProductSyncItemSchema>;
export const ProductSyncRequestSchema = z.object({
items: z.array(ProductSyncItemSchema).min(1).max(5000),
});
export type ProductSyncRequest = z.infer<typeof ProductSyncRequestSchema>;
export const ProductSyncResponseSchema = z.object({
upserted: z.number().int().nonnegative(),
syncedAt: z.iso.datetime(),
});
export type ProductSyncResponse = z.infer<typeof ProductSyncResponseSchema>;