feat(orders): fluxo de aprovação — approve/reject endpoints + UIs (C5)
PATCH /orders/:id/approve e /reject com alçada role-gated; OrderDetailPage com modais de aprovação e recusa; ApprovalQueuePage para Sandra; badge de pendências na Sidebar; DevLogin com 4 perfis (rep, supervisor, gerente). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,19 +1,36 @@
|
||||
import { Body, Controller, Get, HttpCode, Param, ParseUUIDPipe, Post, Query } from '@nestjs/common';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
HttpCode,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ClsService } from 'nestjs-cls';
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
import {
|
||||
ApproveOrderSchema,
|
||||
CreateOrderSchema,
|
||||
OrderListQuerySchema,
|
||||
RejectOrderSchema,
|
||||
type ApproveOrder,
|
||||
type CreateOrder,
|
||||
type OrderDetail,
|
||||
type OrderListQuery,
|
||||
type OrderListResponse,
|
||||
type RejectOrder,
|
||||
} from '@sar/api-interface';
|
||||
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||
import { OrdersService } from './orders.service';
|
||||
|
||||
class OrderListQueryDto extends createZodDto(OrderListQuerySchema) {}
|
||||
class CreateOrderDto extends createZodDto(CreateOrderSchema) {}
|
||||
class ApproveOrderDto extends createZodDto(ApproveOrderSchema) {}
|
||||
class RejectOrderDto extends createZodDto(RejectOrderSchema) {}
|
||||
|
||||
@Controller({ path: 'orders' })
|
||||
export class OrdersController {
|
||||
@@ -35,6 +52,28 @@ export class OrdersController {
|
||||
return this.orders.create(parsed, this.cls.get('userId') ?? '');
|
||||
}
|
||||
|
||||
@Patch(':id/approve')
|
||||
approve(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() body: ApproveOrderDto,
|
||||
): Promise<OrderDetail> {
|
||||
const role = this.cls.get('role') ?? 'rep';
|
||||
if (role === 'rep') throw new ForbiddenException('Apenas supervisores podem aprovar pedidos');
|
||||
const parsed = ApproveOrderSchema.parse(body) as ApproveOrder;
|
||||
return this.orders.approve(id, this.cls.get('userId') ?? '', parsed);
|
||||
}
|
||||
|
||||
@Patch(':id/reject')
|
||||
reject(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() body: RejectOrderDto,
|
||||
): Promise<OrderDetail> {
|
||||
const role = this.cls.get('role') ?? 'rep';
|
||||
if (role === 'rep') throw new ForbiddenException('Apenas supervisores podem recusar pedidos');
|
||||
const parsed = RejectOrderSchema.parse(body) as RejectOrder;
|
||||
return this.orders.reject(id, this.cls.get('userId') ?? '', parsed);
|
||||
}
|
||||
|
||||
@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,12 +1,14 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { ClsService } from 'nestjs-cls';
|
||||
import { OrderStatus, Prisma } from '@prisma/client';
|
||||
import type {
|
||||
ApproveOrder,
|
||||
CreateOrder,
|
||||
OrderDetail,
|
||||
OrderListQuery,
|
||||
OrderListResponse,
|
||||
OrderSummary,
|
||||
RejectOrder,
|
||||
} from '@sar/api-interface';
|
||||
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||
|
||||
@@ -264,6 +266,113 @@ export class OrdersService {
|
||||
return this.mapDetail(order);
|
||||
}
|
||||
|
||||
// Aprova pedido pending_approval. Supervisor pode ajustar discountPct global (FR-5.4).
|
||||
async approve(id: string, userId: string, dto: ApproveOrder): Promise<OrderDetail> {
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
|
||||
const order = await prisma.order.findFirst({ where: { id, deletedAt: null } });
|
||||
if (!order) throw new NotFoundException(`Pedido ${id} não encontrado`);
|
||||
if (order.status !== OrderStatus.pending_approval)
|
||||
throw new BadRequestException(
|
||||
`Pedido não está aguardando aprovação (status: ${order.status})`,
|
||||
);
|
||||
|
||||
const now = new Date();
|
||||
const newDiscountPct = dto.discountPct ?? Number(order.discountPct);
|
||||
const newTotal = Math.round(Number(order.subtotal) * (1 - newDiscountPct / 100) * 100) / 100;
|
||||
|
||||
await prisma.order.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: OrderStatus.approved,
|
||||
discountPct: newDiscountPct,
|
||||
total: newTotal,
|
||||
approvedById: userId,
|
||||
approvedAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.orderStatusHistory.create({
|
||||
data: {
|
||||
orderId: id,
|
||||
fromStatus: OrderStatus.pending_approval,
|
||||
toStatus: OrderStatus.approved,
|
||||
changedById: userId,
|
||||
changedAt: now,
|
||||
note: dto.note ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
// Atualiza desnorm openOrdersCount
|
||||
const openStatuses = [OrderStatus.budget, OrderStatus.pending_approval, OrderStatus.approved];
|
||||
const openCount = await prisma.order.count({
|
||||
where: { clientId: order.clientId, deletedAt: null, status: { in: openStatuses } },
|
||||
});
|
||||
await prisma.client.update({
|
||||
where: { id: order.clientId },
|
||||
data: { openOrdersCount: openCount },
|
||||
});
|
||||
|
||||
const final = await prisma.order.findUniqueOrThrow({
|
||||
where: { id },
|
||||
include: {
|
||||
client: { select: { name: true } },
|
||||
items: true,
|
||||
history: { orderBy: { changedAt: 'asc' } },
|
||||
},
|
||||
});
|
||||
return this.mapDetail(final);
|
||||
}
|
||||
|
||||
// Recusa pedido — retorna ao status budget com motivo no histórico (FR-5.4).
|
||||
async reject(id: string, userId: string, dto: RejectOrder): Promise<OrderDetail> {
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
|
||||
const order = await prisma.order.findFirst({ where: { id, deletedAt: null } });
|
||||
if (!order) throw new NotFoundException(`Pedido ${id} não encontrado`);
|
||||
if (order.status !== OrderStatus.pending_approval)
|
||||
throw new BadRequestException(
|
||||
`Pedido não está aguardando aprovação (status: ${order.status})`,
|
||||
);
|
||||
|
||||
const now = new Date();
|
||||
|
||||
await prisma.order.update({ where: { id }, data: { status: OrderStatus.budget } });
|
||||
|
||||
await prisma.orderStatusHistory.create({
|
||||
data: {
|
||||
orderId: id,
|
||||
fromStatus: OrderStatus.pending_approval,
|
||||
toStatus: OrderStatus.budget,
|
||||
changedById: userId,
|
||||
changedAt: now,
|
||||
note: dto.reason,
|
||||
},
|
||||
});
|
||||
|
||||
// Atualiza desnorm openOrdersCount
|
||||
const openStatuses = [OrderStatus.budget, OrderStatus.pending_approval, OrderStatus.approved];
|
||||
const openCount = await prisma.order.count({
|
||||
where: { clientId: order.clientId, deletedAt: null, status: { in: openStatuses } },
|
||||
});
|
||||
await prisma.client.update({
|
||||
where: { id: order.clientId },
|
||||
data: { openOrdersCount: openCount },
|
||||
});
|
||||
|
||||
const final = await prisma.order.findUniqueOrThrow({
|
||||
where: { id },
|
||||
include: {
|
||||
client: { select: { name: true } },
|
||||
items: true,
|
||||
history: { orderBy: { changedAt: 'asc' } },
|
||||
},
|
||||
});
|
||||
return this.mapDetail(final);
|
||||
}
|
||||
|
||||
private mapDetail(o: {
|
||||
id: string;
|
||||
number: string;
|
||||
|
||||
Reference in New Issue
Block a user