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:
@@ -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 },
|
||||
|
||||
46
apps/api/src/app/catalog/catalog.controller.ts
Normal file
46
apps/api/src/app/catalog/catalog.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
apps/api/src/app/catalog/catalog.module.ts
Normal file
10
apps/api/src/app/catalog/catalog.module.ts
Normal 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 {}
|
||||
135
apps/api/src/app/catalog/catalog.service.ts
Normal file
135
apps/api/src/app/catalog/catalog.service.ts
Normal 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() };
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user