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