feat(c3): consulta de pedidos — schema, api, web (OrdersModule + ClientDetailPage)

- Prisma: Order, OrderItem, OrderStatusHistory + migration
- Seed: 17 pedidos em 7 clientes com itens, histórico e desnorm de clientes
- @sar/api-interface: contratos Zod (OrderSummary, OrderDetail, OrderListQuery, etc.)
- API: GET /orders, GET /orders/:id, GET /clients/:id/orders (últimos 10)
- Web: OrdersPage (lista + filtro status/número + pending_approval highlighted)
- Web: ClientDetailPage (ficha completa + últimos 10 pedidos)
- Web: /pedidos e /pedidos/$id adicionados ao router; ClientDetailPage substitui placeholder

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 23:31:18 +00:00
parent 14c8350216
commit c36451dd33
15 changed files with 1494 additions and 71 deletions

View File

@@ -9,6 +9,7 @@ import { PingModule } from './ping/ping.module';
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 { ProblemDetailsFilter } from './filters/problem-details.filter';
@Module({
@@ -21,6 +22,7 @@ import { ProblemDetailsFilter } from './filters/problem-details.filter';
HealthModule,
PingModule,
ClientsModule,
OrdersModule,
],
providers: [
{ provide: APP_PIPE, useClass: ZodValidationPipe },

View File

@@ -6,9 +6,11 @@ import {
type ClientDetail,
type ClientListQuery,
type ClientListResponse,
type OrderSummary,
} from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types';
import { ClientsService } from './clients.service';
import { OrdersService } from '../orders/orders.service';
class ClientListQueryDto extends createZodDto(ClientListQuerySchema) {}
@@ -16,6 +18,7 @@ class ClientListQueryDto extends createZodDto(ClientListQuerySchema) {}
export class ClientsController {
constructor(
private readonly clients: ClientsService,
private readonly orders: OrdersService,
private readonly cls: ClsService<WorkspaceClsStore>,
) {}
@@ -30,4 +33,14 @@ export class ClientsController {
findOne(@Param('id', ParseUUIDPipe) id: string): Promise<ClientDetail> {
return this.clients.findOne(id, this.cls.get('userId') ?? '', this.cls.get('role') ?? 'rep');
}
// Últimos 10 pedidos do cliente — exibidos na ficha (FR-2.4).
@Get(':id/orders')
clientOrders(@Param('id', ParseUUIDPipe) id: string): Promise<OrderSummary[]> {
return this.orders.listByClient(
id,
this.cls.get('userId') ?? '',
this.cls.get('role') ?? 'rep',
);
}
}

View File

@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
import { ClientsController } from './clients.controller';
import { ClientsService } from './clients.service';
import { OrdersModule } from '../orders/orders.module';
@Module({
imports: [OrdersModule],
controllers: [ClientsController],
providers: [ClientsService],
})

View File

@@ -0,0 +1,32 @@
import { Controller, Get, Param, ParseUUIDPipe, Query } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import { createZodDto } from 'nestjs-zod';
import {
OrderListQuerySchema,
type OrderDetail,
type OrderListQuery,
type OrderListResponse,
} from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types';
import { OrdersService } from './orders.service';
class OrderListQueryDto extends createZodDto(OrderListQuerySchema) {}
@Controller({ path: 'orders' })
export class OrdersController {
constructor(
private readonly orders: OrdersService,
private readonly cls: ClsService<WorkspaceClsStore>,
) {}
@Get()
list(@Query() query: OrderListQueryDto): Promise<OrderListResponse> {
const parsed = OrderListQuerySchema.parse(query) as OrderListQuery;
return this.orders.list(parsed, this.cls.get('userId') ?? '', this.cls.get('role') ?? 'rep');
}
@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

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { OrdersController } from './orders.controller';
import { OrdersService } from './orders.service';
@Module({
controllers: [OrdersController],
providers: [OrdersService],
exports: [OrdersService],
})
export class OrdersModule {}

View File

@@ -0,0 +1,166 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import { Prisma } from '@prisma/client';
import type {
OrderDetail,
OrderListQuery,
OrderListResponse,
OrderSummary,
} from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types';
function decimalToString(v: Prisma.Decimal | null | undefined): string {
return v ? v.toString() : '0';
}
@Injectable()
export class OrdersService {
constructor(private readonly cls: ClsService<WorkspaceClsStore>) {}
async list(query: OrderListQuery, userId: string, role: string): Promise<OrderListResponse> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const { clientId, status, number, from, to, page, limit } = query;
const skip = (page - 1) * limit;
const repFilter: Prisma.OrderWhereInput = role === 'rep' ? { repId: userId } : {};
const where: Prisma.OrderWhereInput = {
deletedAt: null,
...repFilter,
...(clientId ? { clientId } : {}),
...(status ? { status } : {}),
...(number ? { number: { contains: number, mode: 'insensitive' } } : {}),
...(from || to
? {
issuedAt: {
...(from ? { gte: new Date(from) } : {}),
...(to ? { lte: new Date(to) } : {}),
},
}
: {}),
};
const [rows, total] = await Promise.all([
prisma.order.findMany({
where,
include: { client: { select: { name: true } } },
skip,
take: limit,
orderBy: { issuedAt: 'desc' },
}),
prisma.order.count({ where }),
]);
const data: OrderSummary[] = rows.map((o) => ({
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),
issuedAt: o.issuedAt.toISOString(),
approvedAt: o.approvedAt?.toISOString() ?? null,
invoicedAt: o.invoicedAt?.toISOString() ?? null,
cancelledAt: o.cancelledAt?.toISOString() ?? null,
}));
return { data, total, page, limit };
}
async findOne(id: string, userId: string, role: string): Promise<OrderDetail> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const repFilter: Prisma.OrderWhereInput = role === 'rep' ? { repId: userId } : {};
const o = await prisma.order.findFirst({
where: { id, deletedAt: null, ...repFilter },
include: {
client: { select: { name: true } },
items: true,
history: { orderBy: { changedAt: 'asc' } },
},
});
if (!o) throw new NotFoundException(`Pedido ${id} não encontrado`);
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,
userId: string,
role: string,
limit = 10,
): Promise<OrderSummary[]> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const repFilter: Prisma.OrderWhereInput = role === 'rep' ? { repId: userId } : {};
const rows = await prisma.order.findMany({
where: { clientId, deletedAt: null, ...repFilter },
include: { client: { select: { name: true } } },
orderBy: { issuedAt: 'desc' },
take: limit,
});
return rows.map((o) => ({
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),
issuedAt: o.issuedAt.toISOString(),
approvedAt: o.approvedAt?.toISOString() ?? null,
invoicedAt: o.invoicedAt?.toISOString() ?? null,
cancelledAt: o.cancelledAt?.toISOString() ?? null,
}));
}
}