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

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