refactor(erp): integração direta com banco ERP — schema sar
Revoga ADR 0006 (BD-por-workspace separado). O SAR agora conecta ao banco PostgreSQL do ERP (módulo SIG) e usa o schema `sar` para tudo. PRISMA - Remove: Client, Product, Order, OrderItem, OrderStatusHistory, RepTarget, RepDiscountLimit, PushSubscription (modelos isolados) - Adiciona: Pedido, PedidoItem, HistoricoPedido, AlcadaDesconto, MetaRepresentante, PushSubscription (mapeados para sar.*) - IDs: id_cliente/cod_vendedor/id_empresa são INTEGER (ERP) - situa: Int (1=Pendente 2=Aprovado 3=Cancelado 4=Faturado) - JWT: workspace_id:string → id_empresa:number - URL: inclui ?schema=sar para Prisma rotear ao schema ERP SERVICES - ClientsService: $queryRawUnsafe contra sar.vw_clientes + sar.pedidos - CatalogService: $queryRawUnsafe contra sar.vw_produtos + sar.vw_estoque - OrdersService: Prisma models Pedido/PedidoItem/HistoricoPedido/AlcadaDesconto - DashboardService: MetaRepresentante + queries raw para inativos - NotificationsService: PushSubscription com codVendedor + idEmpresa CONTRATOS (api-interface) - client.contract: campos ERP (idCliente, nome, cgcpf, cod_vendedor…) - order.contract: PedidoSummary/PedidoDetail/CreatePedido + SITUA_LABEL - product.contract: ProdutoSummary/ProdutoDetail (vw_produtos) - auth.contract: workspaceId:string → idEmpresa:number WEB - Todos os cockpits e queries atualizados para os novos tipos Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,24 +13,24 @@ import {
|
||||
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,
|
||||
AprovarPedidoSchema,
|
||||
CreatePedidoSchema,
|
||||
PedidoListQuerySchema,
|
||||
RecusarPedidoSchema,
|
||||
type AprovarPedido,
|
||||
type CreatePedido,
|
||||
type PedidoDetail,
|
||||
type PedidoListQuery,
|
||||
type PedidoListResponse,
|
||||
type RecusarPedido,
|
||||
} 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) {}
|
||||
class PedidoListQueryDto extends createZodDto(PedidoListQuerySchema) {}
|
||||
class CreatePedidoDto extends createZodDto(CreatePedidoSchema) {}
|
||||
class AprovarPedidoDto extends createZodDto(AprovarPedidoSchema) {}
|
||||
class RecusarPedidoDto extends createZodDto(RecusarPedidoSchema) {}
|
||||
|
||||
@Controller({ path: 'orders' })
|
||||
export class OrdersController {
|
||||
@@ -40,42 +40,42 @@ export class OrdersController {
|
||||
) {}
|
||||
|
||||
@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');
|
||||
list(@Query() query: PedidoListQueryDto): Promise<PedidoListResponse> {
|
||||
const parsed = PedidoListQuerySchema.parse(query) as PedidoListQuery;
|
||||
return this.orders.list(parsed);
|
||||
}
|
||||
|
||||
@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') ?? '');
|
||||
create(@Body() body: CreatePedidoDto): Promise<PedidoDetail> {
|
||||
const parsed = CreatePedidoSchema.parse(body) as CreatePedido;
|
||||
return this.orders.create(parsed);
|
||||
}
|
||||
|
||||
@Patch(':id/approve')
|
||||
approve(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() body: ApproveOrderDto,
|
||||
): Promise<OrderDetail> {
|
||||
@Body() body: AprovarPedidoDto,
|
||||
): Promise<PedidoDetail> {
|
||||
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);
|
||||
const parsed = AprovarPedidoSchema.parse(body) as AprovarPedido;
|
||||
return this.orders.approve(id, parsed);
|
||||
}
|
||||
|
||||
@Patch(':id/reject')
|
||||
reject(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() body: RejectOrderDto,
|
||||
): Promise<OrderDetail> {
|
||||
@Body() body: RecusarPedidoDto,
|
||||
): Promise<PedidoDetail> {
|
||||
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);
|
||||
const parsed = RecusarPedidoSchema.parse(body) as RecusarPedido;
|
||||
return this.orders.reject(id, 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');
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string): Promise<PedidoDetail> {
|
||||
return this.orders.findOne(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { ClsService } from 'nestjs-cls';
|
||||
import { OrderStatus, Prisma } from '@prisma/client';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import type {
|
||||
ApproveOrder,
|
||||
CreateOrder,
|
||||
OrderDetail,
|
||||
OrderListQuery,
|
||||
OrderListResponse,
|
||||
OrderSummary,
|
||||
RejectOrder,
|
||||
AprovarPedido,
|
||||
CreatePedido,
|
||||
PedidoDetail,
|
||||
PedidoListQuery,
|
||||
PedidoListResponse,
|
||||
PedidoSummary,
|
||||
RecusarPedido,
|
||||
} from '@sar/api-interface';
|
||||
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
|
||||
// Situa: 1=Pendente Aprovação, 2=Aprovado, 3=Cancelado, 4=Faturado
|
||||
const SITUA_PENDENTE = 1;
|
||||
const SITUA_APROVADO = 2;
|
||||
const SITUA_CANCELADO = 3;
|
||||
|
||||
function decimalToString(v: Prisma.Decimal | null | undefined): string {
|
||||
return v ? v.toString() : '0';
|
||||
}
|
||||
@@ -24,24 +29,29 @@ export class OrdersService {
|
||||
private readonly notifications: NotificationsService,
|
||||
) {}
|
||||
|
||||
async list(query: OrderListQuery, userId: string, role: string): Promise<OrderListResponse> {
|
||||
async list(query: PedidoListQuery): Promise<PedidoListResponse> {
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
const idEmpresa = this.cls.get('idEmpresa');
|
||||
const role = this.cls.get('role');
|
||||
const userId = this.cls.get('userId');
|
||||
const codVendedor = userId ? parseInt(userId, 10) : 0;
|
||||
|
||||
const { clientId, status, number, from, to, page, limit } = query;
|
||||
const { idCliente, situa, numPedSar, from, to, page, limit } = query;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const repFilter: Prisma.OrderWhereInput = role === 'rep' ? { repId: userId } : {};
|
||||
const repFilter = role === 'rep' ? { codVendedor } : {};
|
||||
|
||||
const where: Prisma.OrderWhereInput = {
|
||||
deletedAt: null,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const where: any = {
|
||||
idEmpresa,
|
||||
...repFilter,
|
||||
...(clientId ? { clientId } : {}),
|
||||
...(status ? { status } : {}),
|
||||
...(number ? { number: { contains: number, mode: 'insensitive' } } : {}),
|
||||
...(idCliente != null ? { idCliente } : {}),
|
||||
...(situa != null ? { situa } : {}),
|
||||
...(numPedSar ? { numPedSar: { contains: numPedSar } } : {}),
|
||||
...(from || to
|
||||
? {
|
||||
issuedAt: {
|
||||
dtPedido: {
|
||||
...(from ? { gte: new Date(from) } : {}),
|
||||
...(to ? { lte: new Date(to) } : {}),
|
||||
},
|
||||
@@ -50,349 +60,267 @@ export class OrdersService {
|
||||
};
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
prisma.order.findMany({
|
||||
prisma.pedido.findMany({
|
||||
where,
|
||||
include: { client: { select: { name: true } } },
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { issuedAt: 'desc' },
|
||||
orderBy: { dtPedido: 'desc' },
|
||||
}),
|
||||
prisma.order.count({ where }),
|
||||
prisma.pedido.count({ where }),
|
||||
]);
|
||||
|
||||
const data: OrderSummary[] = rows.map((o) => ({
|
||||
const data: PedidoSummary[] = 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),
|
||||
numPedSar: o.numPedSar,
|
||||
idCliente: o.idCliente,
|
||||
codVendedor: o.codVendedor,
|
||||
situa: o.situa,
|
||||
dtPedido: o.dtPedido.toISOString(),
|
||||
total: decimalToString(o.total),
|
||||
issuedAt: o.issuedAt.toISOString(),
|
||||
approvedAt: o.approvedAt?.toISOString() ?? null,
|
||||
invoicedAt: o.invoicedAt?.toISOString() ?? null,
|
||||
cancelledAt: o.cancelledAt?.toISOString() ?? null,
|
||||
descontoPerc: decimalToString(o.descontoPerc),
|
||||
obs: o.obs,
|
||||
createdAt: o.createdAt.toISOString(),
|
||||
}));
|
||||
|
||||
return { data, total, page, limit };
|
||||
}
|
||||
|
||||
async findOne(id: string, userId: string, role: string): Promise<OrderDetail> {
|
||||
async findOne(id: string): Promise<PedidoDetail> {
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
const idEmpresa = this.cls.get('idEmpresa');
|
||||
const role = this.cls.get('role');
|
||||
const userId = this.cls.get('userId');
|
||||
const codVendedor = userId ? parseInt(userId, 10) : 0;
|
||||
|
||||
const repFilter: Prisma.OrderWhereInput = role === 'rep' ? { repId: userId } : {};
|
||||
const repFilter = role === 'rep' ? { codVendedor } : {};
|
||||
|
||||
const o = await prisma.order.findFirst({
|
||||
where: { id, deletedAt: null, ...repFilter },
|
||||
const o = await prisma.pedido.findFirst({
|
||||
where: { id, idEmpresa, ...repFilter },
|
||||
include: {
|
||||
client: { select: { name: true } },
|
||||
items: true,
|
||||
history: { orderBy: { changedAt: 'asc' } },
|
||||
itens: { orderBy: { ordem: 'asc' } },
|
||||
historico: { 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(),
|
||||
})),
|
||||
};
|
||||
return this.mapDetail(o);
|
||||
}
|
||||
|
||||
// Cria novo pedido. Valida alçada por linha de produto (OQ-2).
|
||||
// Cria novo pedido. Valida alçada por codGrupo (codGrupo=0 = default).
|
||||
// Idempotency-Key: retorna pedido existente se já processado (FR-4.3).
|
||||
async create(dto: CreateOrder, userId: string): Promise<OrderDetail> {
|
||||
async create(dto: CreatePedido): Promise<PedidoDetail> {
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
const idEmpresa = this.cls.get('idEmpresa');
|
||||
const userId = this.cls.get('userId') ?? '0';
|
||||
const codVendedor = parseInt(userId, 10);
|
||||
|
||||
// Idempotency-Key: retorna pedido existente sem re-processar
|
||||
if (dto.idempotencyKey) {
|
||||
const existing = await prisma.order.findUnique({
|
||||
const existing = await prisma.pedido.findUnique({
|
||||
where: { idempotencyKey: dto.idempotencyKey },
|
||||
include: {
|
||||
client: { select: { name: true } },
|
||||
items: true,
|
||||
history: { orderBy: { changedAt: 'asc' } },
|
||||
itens: { orderBy: { ordem: 'asc' } },
|
||||
historico: { 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 },
|
||||
// Resolve alçadas: (codVendedor, idEmpresa, codGrupo=0) = default
|
||||
const limitRows = await prisma.alcadaDesconto.findMany({
|
||||
where: { codVendedor, idEmpresa },
|
||||
});
|
||||
if (!client) throw new NotFoundException(`Cliente ${dto.clientId} não encontrado`);
|
||||
const limitMap = new Map(limitRows.map((r) => [r.codGrupo, Number(r.limitePerc)]));
|
||||
const getLimit = (codGrupo: number) => limitMap.get(codGrupo) ?? limitMap.get(0) ?? 5;
|
||||
|
||||
// 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;
|
||||
// Alçada global (codGrupo=0)
|
||||
const needsApproval = dto.descontoPerc > getLimit(0);
|
||||
|
||||
// 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;
|
||||
// Calcula totais dos itens
|
||||
const itemsData = dto.itens.map((it) => {
|
||||
const descontoValor =
|
||||
Math.round(it.qtd * it.precoUnitario * (it.descontoPerc / 100) * 100) / 100;
|
||||
const total = Math.round(it.qtd * it.precoUnitario * (1 - it.descontoPerc / 100) * 100) / 100;
|
||||
return {
|
||||
productCode: it.productCode,
|
||||
productName: it.productName,
|
||||
productCategory: it.productCategory ?? 'geral',
|
||||
quantity: it.quantity,
|
||||
unitPrice: it.unitPrice,
|
||||
discountPct: it.discountPct,
|
||||
subtotal,
|
||||
ordem: it.ordem,
|
||||
idProduto: it.idProduto,
|
||||
codProduto: it.codProduto ?? null,
|
||||
descProduto: it.descProduto,
|
||||
qtd: it.qtd,
|
||||
precoUnitario: it.precoUnitario,
|
||||
descontoPerc: it.descontoPerc,
|
||||
descontoValor,
|
||||
total,
|
||||
};
|
||||
});
|
||||
const itemsSubtotal = itemsData.reduce((acc, it) => acc + it.subtotal, 0);
|
||||
const total = Math.round(itemsSubtotal * (1 - dto.discountPct / 100) * 100) / 100;
|
||||
|
||||
const totalProdutos = itemsData.reduce((acc, it) => acc + it.total, 0);
|
||||
const descontoValorGlobal = Math.round(totalProdutos * (dto.descontoPerc / 100) * 100) / 100;
|
||||
const total = Math.round(totalProdutos * (1 - dto.descontoPerc / 100) * 100) / 100;
|
||||
|
||||
const situa = needsApproval ? SITUA_PENDENTE : SITUA_APROVADO;
|
||||
|
||||
// Gera número sequencial: SAR-NNNNN
|
||||
const lastOrder = await prisma.pedido.findFirst({
|
||||
where: { idEmpresa },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: { numPedSar: true },
|
||||
});
|
||||
const seq = lastOrder ? parseInt(lastOrder.numPedSar.replace('SAR-', ''), 10) + 1 : 1;
|
||||
const numPedSar = `SAR-${String(seq).padStart(5, '0')}`;
|
||||
|
||||
const now = new Date();
|
||||
const order = await prisma.order.create({
|
||||
const pedido = await prisma.pedido.create({
|
||||
data: {
|
||||
number,
|
||||
clientId: dto.clientId,
|
||||
repId: userId,
|
||||
status,
|
||||
discountPct: dto.discountPct,
|
||||
subtotal: itemsSubtotal,
|
||||
idEmpresa,
|
||||
numPedSar,
|
||||
idCliente: dto.idCliente,
|
||||
codVendedor,
|
||||
situa,
|
||||
dtPedido: now,
|
||||
idPauta: dto.idPauta ?? null,
|
||||
codFormapag: dto.codFormapag ?? null,
|
||||
totalProdutos,
|
||||
total,
|
||||
notes: dto.notes ?? null,
|
||||
descontoPerc: dto.descontoPerc,
|
||||
descontoValor: descontoValorGlobal,
|
||||
obs: dto.obs ?? null,
|
||||
idempotencyKey: dto.idempotencyKey ?? null,
|
||||
issuedAt: now,
|
||||
items: { create: itemsData },
|
||||
history: {
|
||||
itens: { create: itemsData },
|
||||
historico: {
|
||||
create: [
|
||||
{ fromStatus: null, toStatus: OrderStatus.budget, changedById: userId, changedAt: now },
|
||||
{
|
||||
situaAnterior: null,
|
||||
situaNova: situa,
|
||||
changedBy: codVendedor,
|
||||
changedAt: now,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
include: {
|
||||
client: { select: { name: true } },
|
||||
items: true,
|
||||
history: { orderBy: { changedAt: 'asc' } },
|
||||
itens: { orderBy: { ordem: 'asc' } },
|
||||
historico: { 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) {
|
||||
// FR-6.1: notifica supervisores que há pedido aguardando aprovação
|
||||
if (situa === SITUA_PENDENTE) {
|
||||
void this.notifications.notifySupervisors({
|
||||
title: 'Pedido aguardando aprovação',
|
||||
body: `${order.client.name} — ${order.number} — R$ ${order.total.toFixed(2).replace('.', ',')}`,
|
||||
url: `/pedidos/${order.id}`,
|
||||
body: `Pedido ${pedido.numPedSar} — R$ ${pedido.total.toFixed(2).replace('.', ',')}`,
|
||||
url: `/pedidos/${pedido.id}`,
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
return this.mapDetail(pedido);
|
||||
}
|
||||
|
||||
// Aprova pedido pending_approval. Supervisor pode ajustar discountPct global (FR-5.4).
|
||||
async approve(id: string, userId: string, dto: ApproveOrder): Promise<OrderDetail> {
|
||||
// Aprova pedido pendente. Supervisor pode ajustar descontoPerc global.
|
||||
async approve(id: string, dto: AprovarPedido): Promise<PedidoDetail> {
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
const idEmpresa = this.cls.get('idEmpresa');
|
||||
const userId = this.cls.get('userId') ?? '0';
|
||||
const codVendedor = parseInt(userId, 10);
|
||||
|
||||
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)
|
||||
const pedido = await prisma.pedido.findFirst({ where: { id, idEmpresa } });
|
||||
if (!pedido) throw new NotFoundException(`Pedido ${id} não encontrado`);
|
||||
if (pedido.situa !== SITUA_PENDENTE)
|
||||
throw new BadRequestException(
|
||||
`Pedido não está aguardando aprovação (status: ${order.status})`,
|
||||
`Pedido não está aguardando aprovação (situa: ${pedido.situa})`,
|
||||
);
|
||||
|
||||
const now = new Date();
|
||||
const newDiscountPct = dto.discountPct ?? Number(order.discountPct);
|
||||
const newTotal = Math.round(Number(order.subtotal) * (1 - newDiscountPct / 100) * 100) / 100;
|
||||
const newDescontoPerc = dto.descontoPerc ?? Number(pedido.descontoPerc);
|
||||
const newTotal =
|
||||
Math.round(Number(pedido.totalProdutos) * (1 - newDescontoPerc / 100) * 100) / 100;
|
||||
|
||||
await prisma.order.update({
|
||||
await prisma.pedido.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: OrderStatus.approved,
|
||||
discountPct: newDiscountPct,
|
||||
situa: SITUA_APROVADO,
|
||||
descontoPerc: newDescontoPerc,
|
||||
total: newTotal,
|
||||
approvedById: userId,
|
||||
approvedAt: now,
|
||||
aprovadoPor: codVendedor,
|
||||
aprovadoEm: now,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.orderStatusHistory.create({
|
||||
await prisma.historicoPedido.create({
|
||||
data: {
|
||||
orderId: id,
|
||||
fromStatus: OrderStatus.pending_approval,
|
||||
toStatus: OrderStatus.approved,
|
||||
changedById: userId,
|
||||
idPedido: id,
|
||||
situaAnterior: SITUA_PENDENTE,
|
||||
situaNova: SITUA_APROVADO,
|
||||
changedBy: codVendedor,
|
||||
changedAt: now,
|
||||
note: dto.note ?? null,
|
||||
nota: dto.nota ?? 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({
|
||||
const final = await prisma.pedido.findUniqueOrThrow({
|
||||
where: { id },
|
||||
include: {
|
||||
client: { select: { name: true } },
|
||||
items: true,
|
||||
history: { orderBy: { changedAt: 'asc' } },
|
||||
itens: { orderBy: { ordem: 'asc' } },
|
||||
historico: { orderBy: { changedAt: 'asc' } },
|
||||
},
|
||||
});
|
||||
|
||||
// FR-6.1: notifica o rep que o pedido foi aprovado
|
||||
void this.notifications.notifyUser(order.repId, {
|
||||
void this.notifications.notifyUser(String(pedido.codVendedor), {
|
||||
title: 'Pedido aprovado',
|
||||
body: `${final.number} — ${final.client.name} aprovado${dto.discountPct !== undefined ? ` com ${newDiscountPct}% de desconto` : ''}`,
|
||||
body: `${final.numPedSar} aprovado${dto.descontoPerc !== undefined ? ` com ${newDescontoPerc}% de desconto` : ''}`,
|
||||
url: `/pedidos/${id}`,
|
||||
});
|
||||
|
||||
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> {
|
||||
// Recusa pedido — muda situa para 3 (Cancelado) com motivo.
|
||||
async reject(id: string, dto: RecusarPedido): Promise<PedidoDetail> {
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
const idEmpresa = this.cls.get('idEmpresa');
|
||||
const userId = this.cls.get('userId') ?? '0';
|
||||
const codVendedor = parseInt(userId, 10);
|
||||
|
||||
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)
|
||||
const pedido = await prisma.pedido.findFirst({ where: { id, idEmpresa } });
|
||||
if (!pedido) throw new NotFoundException(`Pedido ${id} não encontrado`);
|
||||
if (pedido.situa !== SITUA_PENDENTE)
|
||||
throw new BadRequestException(
|
||||
`Pedido não está aguardando aprovação (status: ${order.status})`,
|
||||
`Pedido não está aguardando aprovação (situa: ${pedido.situa})`,
|
||||
);
|
||||
|
||||
const now = new Date();
|
||||
|
||||
await prisma.order.update({ where: { id }, data: { status: OrderStatus.budget } });
|
||||
await prisma.pedido.update({
|
||||
where: { id },
|
||||
data: { situa: SITUA_CANCELADO, motivoRecusa: dto.motivo },
|
||||
});
|
||||
|
||||
await prisma.orderStatusHistory.create({
|
||||
await prisma.historicoPedido.create({
|
||||
data: {
|
||||
orderId: id,
|
||||
fromStatus: OrderStatus.pending_approval,
|
||||
toStatus: OrderStatus.budget,
|
||||
changedById: userId,
|
||||
idPedido: id,
|
||||
situaAnterior: SITUA_PENDENTE,
|
||||
situaNova: SITUA_CANCELADO,
|
||||
changedBy: codVendedor,
|
||||
changedAt: now,
|
||||
note: dto.reason,
|
||||
nota: dto.motivo,
|
||||
},
|
||||
});
|
||||
|
||||
// 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({
|
||||
const final = await prisma.pedido.findUniqueOrThrow({
|
||||
where: { id },
|
||||
include: {
|
||||
client: { select: { name: true } },
|
||||
items: true,
|
||||
history: { orderBy: { changedAt: 'asc' } },
|
||||
itens: { orderBy: { ordem: 'asc' } },
|
||||
historico: { orderBy: { changedAt: 'asc' } },
|
||||
},
|
||||
});
|
||||
|
||||
// FR-6.1: notifica o rep que o pedido foi recusado
|
||||
void this.notifications.notifyUser(order.repId, {
|
||||
void this.notifications.notifyUser(String(pedido.codVendedor), {
|
||||
title: 'Pedido recusado',
|
||||
body: `${final.number} — ${final.client.name}: ${dto.reason}`,
|
||||
body: `${final.numPedSar}: ${dto.motivo}`,
|
||||
url: `/pedidos/${id}`,
|
||||
});
|
||||
|
||||
@@ -401,113 +329,89 @@ export class OrdersService {
|
||||
|
||||
private mapDetail(o: {
|
||||
id: string;
|
||||
number: string;
|
||||
clientId: string;
|
||||
client: { name: string };
|
||||
repId: string;
|
||||
status: OrderStatus;
|
||||
discountPct: Prisma.Decimal;
|
||||
subtotal: Prisma.Decimal;
|
||||
numPedSar: string;
|
||||
idCliente: number;
|
||||
codVendedor: number;
|
||||
situa: number;
|
||||
dtPedido: Date;
|
||||
total: Prisma.Decimal;
|
||||
notes: string | null;
|
||||
approvedById: string | null;
|
||||
descontoPerc: Prisma.Decimal;
|
||||
descontoValor: Prisma.Decimal;
|
||||
totalProdutos: Prisma.Decimal;
|
||||
totalIpi: Prisma.Decimal;
|
||||
totalIcmsst: Prisma.Decimal;
|
||||
acrescimo: Prisma.Decimal;
|
||||
comissao: Prisma.Decimal;
|
||||
pedFlex: Prisma.Decimal;
|
||||
aprovadoPor: number | null;
|
||||
aprovadoEm: Date | null;
|
||||
motivoRecusa: string | null;
|
||||
obs: string | null;
|
||||
idempotencyKey: string | null;
|
||||
issuedAt: Date;
|
||||
approvedAt: Date | null;
|
||||
invoicedAt: Date | null;
|
||||
cancelledAt: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
items: {
|
||||
itens: {
|
||||
id: string;
|
||||
productCode: string;
|
||||
productName: string;
|
||||
quantity: Prisma.Decimal;
|
||||
unitPrice: Prisma.Decimal;
|
||||
discountPct: Prisma.Decimal;
|
||||
subtotal: Prisma.Decimal;
|
||||
idProduto: number;
|
||||
codProduto: string | null;
|
||||
descProduto: string | null;
|
||||
ordem: number;
|
||||
qtd: Prisma.Decimal;
|
||||
precoUnitario: Prisma.Decimal;
|
||||
descontoPerc: Prisma.Decimal;
|
||||
total: Prisma.Decimal;
|
||||
}[];
|
||||
history: {
|
||||
historico: {
|
||||
id: string;
|
||||
fromStatus: OrderStatus | null;
|
||||
toStatus: OrderStatus;
|
||||
changedById: string;
|
||||
note: string | null;
|
||||
situaAnterior: number | null;
|
||||
situaNova: number;
|
||||
changedBy: number;
|
||||
nota: string | null;
|
||||
changedAt: Date;
|
||||
}[];
|
||||
}): OrderDetail {
|
||||
}): PedidoDetail {
|
||||
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),
|
||||
numPedSar: o.numPedSar,
|
||||
idCliente: o.idCliente,
|
||||
codVendedor: o.codVendedor,
|
||||
situa: o.situa,
|
||||
dtPedido: o.dtPedido.toISOString(),
|
||||
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,
|
||||
descontoPerc: decimalToString(o.descontoPerc),
|
||||
obs: o.obs,
|
||||
createdAt: o.createdAt.toISOString(),
|
||||
totalProdutos: decimalToString(o.totalProdutos),
|
||||
totalIpi: decimalToString(o.totalIpi),
|
||||
totalIcmsst: decimalToString(o.totalIcmsst),
|
||||
descontoValor: decimalToString(o.descontoValor),
|
||||
acrescimo: decimalToString(o.acrescimo),
|
||||
comissao: decimalToString(o.comissao),
|
||||
pedFlex: decimalToString(o.pedFlex),
|
||||
aprovadoPor: o.aprovadoPor,
|
||||
aprovadoEm: o.aprovadoEm?.toISOString() ?? null,
|
||||
motivoRecusa: o.motivoRecusa,
|
||||
idempotencyKey: o.idempotencyKey,
|
||||
updatedAt: o.updatedAt.toISOString(),
|
||||
items: o.items.map((it) => ({
|
||||
itens: o.itens.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),
|
||||
idProduto: it.idProduto,
|
||||
codProduto: it.codProduto,
|
||||
descProduto: it.descProduto,
|
||||
ordem: it.ordem,
|
||||
qtd: decimalToString(it.qtd),
|
||||
precoUnitario: decimalToString(it.precoUnitario),
|
||||
descontoPerc: decimalToString(it.descontoPerc),
|
||||
total: decimalToString(it.total),
|
||||
})),
|
||||
history: o.history.map((h) => ({
|
||||
historico: o.historico.map((h) => ({
|
||||
id: h.id,
|
||||
fromStatus: h.fromStatus,
|
||||
toStatus: h.toStatus,
|
||||
changedById: h.changedById,
|
||||
note: h.note,
|
||||
situaAnterior: h.situaAnterior,
|
||||
situaNova: h.situaNova,
|
||||
changedBy: h.changedBy,
|
||||
nota: h.nota,
|
||||
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