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,