feat(api,web): edicao de orcamento e cancelamento com motivo
- PATCH /orders/:id edita orcamento (situa 0): substitui itens, recalcula totais, valida alcada do dono do pedido; depois de transmitido nao e mais editavel (400) - Menu "Editar orcamento" abre o lancamento pre-carregado (/pedidos/novo?editar=id) com cliente, itens, pauta, forma de pagamento, obs e endereco; salvar faz PATCH em vez de POST - Detalhe do pedido passa a incluir idPauta/codFormapag e codGrupo/codSubgrupo por item (teto de desconto na edicao) - Cancelar pedido abre card com motivo (select) + descricao livre; gravado em motivo_recusa e no historico (ASCII: banco ERP e LATIN1, em-dash quebrava o UPDATE) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -15,12 +15,16 @@ import { ClsService } from 'nestjs-cls';
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
import {
|
||||
AprovarPedidoSchema,
|
||||
CancelPedidoSchema,
|
||||
CreatePedidoSchema,
|
||||
UpdatePedidoSchema,
|
||||
PedidoErpConsultaQuerySchema,
|
||||
PedidoListQuerySchema,
|
||||
RecusarPedidoSchema,
|
||||
type AprovarPedido,
|
||||
type CancelPedido,
|
||||
type CreatePedido,
|
||||
type UpdatePedido,
|
||||
type PedidoDetail,
|
||||
type PedidoErpConsultaQuery,
|
||||
type PedidoErpConsultaResponse,
|
||||
@@ -33,6 +37,8 @@ import { OrdersService } from './orders.service';
|
||||
|
||||
class PedidoListQueryDto extends createZodDto(PedidoListQuerySchema) {}
|
||||
class CreatePedidoDto extends createZodDto(CreatePedidoSchema) {}
|
||||
class UpdatePedidoDto extends createZodDto(UpdatePedidoSchema) {}
|
||||
class CancelPedidoDto extends createZodDto(CancelPedidoSchema) {}
|
||||
class AprovarPedidoDto extends createZodDto(AprovarPedidoSchema) {}
|
||||
class RecusarPedidoDto extends createZodDto(RecusarPedidoSchema) {}
|
||||
class PedidoErpConsultaQueryDto extends createZodDto(PedidoErpConsultaQuerySchema) {}
|
||||
@@ -85,8 +91,22 @@ export class OrdersController {
|
||||
}
|
||||
|
||||
@Patch(':id/cancel')
|
||||
cancel(@Param('id', ParseUUIDPipe) id: string): Promise<PedidoDetail> {
|
||||
return this.orders.cancel(id);
|
||||
cancel(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() body: CancelPedidoDto,
|
||||
): Promise<PedidoDetail> {
|
||||
const parsed = CancelPedidoSchema.parse(body) as CancelPedido;
|
||||
return this.orders.cancel(id, parsed);
|
||||
}
|
||||
|
||||
// Edição de orçamento (situa 0) — depois de transmitido não é mais editável
|
||||
@Patch(':id')
|
||||
update(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() body: UpdatePedidoDto,
|
||||
): Promise<PedidoDetail> {
|
||||
const parsed = UpdatePedidoSchema.parse(body) as UpdatePedido;
|
||||
return this.orders.update(id, parsed);
|
||||
}
|
||||
|
||||
@Get('erp/:idPedido')
|
||||
|
||||
@@ -3,7 +3,9 @@ import { ClsService } from 'nestjs-cls';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import type {
|
||||
AprovarPedido,
|
||||
CancelPedido,
|
||||
CreatePedido,
|
||||
UpdatePedido,
|
||||
PedidoDetail,
|
||||
PedidoErpConsultaItem,
|
||||
PedidoErpConsultaQuery,
|
||||
@@ -330,6 +332,52 @@ export class OrdersService {
|
||||
return this.mapDetail(o);
|
||||
}
|
||||
|
||||
// Valida a alçada de um payload de criação/edição (mesma regra do transmit):
|
||||
// desconto global e por item contra alcada + promoção/regra do dono do pedido.
|
||||
private async assertAlcadaDto(
|
||||
codVendedor: number,
|
||||
dto: {
|
||||
descontoPerc: number;
|
||||
idPauta?: number;
|
||||
itens: {
|
||||
ordem: number;
|
||||
idProduto: number;
|
||||
codProduto?: string;
|
||||
precoUnitario: number;
|
||||
descontoPerc: number;
|
||||
}[];
|
||||
},
|
||||
): Promise<void> {
|
||||
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 limitRows = await prisma.alcadaDesconto.findMany({ where: { codVendedor, idEmpresa } });
|
||||
const limitMap = new Map(limitRows.map((r) => [r.codGrupo, Number(r.limitePerc)]));
|
||||
const limiteDefault = limitMap.get(0) ?? 5;
|
||||
if (dto.descontoPerc > limiteDefault) {
|
||||
throw new BadRequestException(
|
||||
`Desconto de ${dto.descontoPerc}% acima do máximo permitido para você (${limiteDefault}%). Reduza o desconto para continuar.`,
|
||||
);
|
||||
}
|
||||
await this.assertAlcadaItens(
|
||||
{
|
||||
codVendedor,
|
||||
descontoPerc: dto.descontoPerc,
|
||||
idPauta: dto.idPauta ?? null,
|
||||
itens: dto.itens.map((it) => ({
|
||||
ordem: it.ordem,
|
||||
idProduto: it.idProduto,
|
||||
codProduto: it.codProduto ?? null,
|
||||
precoUnitario: it.precoUnitario,
|
||||
descontoPerc: it.descontoPerc,
|
||||
})),
|
||||
},
|
||||
limitMap,
|
||||
limiteDefault,
|
||||
);
|
||||
}
|
||||
|
||||
// Cria novo pedido SAR como ORÇAMENTO (situa 0). A alçada de desconto valida
|
||||
// JÁ AQUI (bloqueia o Concluir com a mensagem do que passou) e de novo no
|
||||
// transmit() — defesa em profundidade, pois aprovação/ERP podem mudar valores.
|
||||
@@ -368,30 +416,7 @@ export class OrdersService {
|
||||
}
|
||||
|
||||
// Alçada: bloqueia a criação do orçamento se algum desconto passa do teto
|
||||
const limitRows = await prisma.alcadaDesconto.findMany({ where: { codVendedor, idEmpresa } });
|
||||
const limitMap = new Map(limitRows.map((r) => [r.codGrupo, Number(r.limitePerc)]));
|
||||
const limiteDefault = limitMap.get(0) ?? 5;
|
||||
if (dto.descontoPerc > limiteDefault) {
|
||||
throw new BadRequestException(
|
||||
`Desconto de ${dto.descontoPerc}% acima do máximo permitido para você (${limiteDefault}%). Reduza o desconto para concluir o pedido.`,
|
||||
);
|
||||
}
|
||||
await this.assertAlcadaItens(
|
||||
{
|
||||
codVendedor,
|
||||
descontoPerc: dto.descontoPerc,
|
||||
idPauta: dto.idPauta ?? null,
|
||||
itens: dto.itens.map((it) => ({
|
||||
ordem: it.ordem,
|
||||
idProduto: it.idProduto,
|
||||
codProduto: it.codProduto ?? null,
|
||||
precoUnitario: it.precoUnitario,
|
||||
descontoPerc: it.descontoPerc,
|
||||
})),
|
||||
},
|
||||
limitMap,
|
||||
limiteDefault,
|
||||
);
|
||||
await this.assertAlcadaDto(codVendedor, dto);
|
||||
|
||||
const itemsData = dto.itens.map((it) => {
|
||||
const descontoValor =
|
||||
@@ -774,7 +799,98 @@ export class OrdersService {
|
||||
return this.mapDetail(final);
|
||||
}
|
||||
|
||||
async cancel(id: string): Promise<PedidoDetail> {
|
||||
// Edita um orçamento (situa 0). Depois de transmitido não é mais editável.
|
||||
// Substitui itens e recalcula totais; alçada valida contra o dono do pedido.
|
||||
async update(id: string, dto: UpdatePedido): 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') ?? '0';
|
||||
const codVendedor = parseInt(userId, 10);
|
||||
|
||||
const repFilter =
|
||||
role === 'rep'
|
||||
? { codVendedor }
|
||||
: role === 'supervisor'
|
||||
? { codVendedor: { in: await getTeamCodes(prisma, codVendedor) } }
|
||||
: {};
|
||||
const pedido = await prisma.pedido.findFirst({ where: { id, idEmpresa, ...repFilter } });
|
||||
if (!pedido) throw new NotFoundException(`Pedido ${id} não encontrado`);
|
||||
if (pedido.situa !== SITUA_ORCAMENTO)
|
||||
throw new BadRequestException(
|
||||
'Apenas orçamentos podem ser editados — este pedido já foi transmitido',
|
||||
);
|
||||
|
||||
// PGD-AUTHZ: rep só aponta o pedido para cliente da própria carteira.
|
||||
if (role === 'rep' && dto.idCliente !== pedido.idCliente) {
|
||||
const cliRows = await prisma.$queryRawUnsafe<{ cod_vendedor: number }[]>(
|
||||
`SELECT cod_vendedor FROM vw_clientes WHERE id_cliente = $1 LIMIT 1`,
|
||||
dto.idCliente,
|
||||
);
|
||||
if (!cliRows[0] || Number(cliRows[0].cod_vendedor) !== codVendedor) {
|
||||
throw new NotFoundException(`Cliente ${dto.idCliente} não encontrado`);
|
||||
}
|
||||
}
|
||||
|
||||
// Alçada do DONO do pedido (supervisor editando usa o limite do rep)
|
||||
await this.assertAlcadaDto(pedido.codVendedor, dto);
|
||||
|
||||
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 {
|
||||
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 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 final = await prisma.pedido.update({
|
||||
where: { id },
|
||||
data: {
|
||||
idCliente: dto.idCliente,
|
||||
idPauta: dto.idPauta ?? null,
|
||||
codFormapag: dto.codFormapag ?? null,
|
||||
totalProdutos,
|
||||
total,
|
||||
descontoPerc: dto.descontoPerc,
|
||||
descontoValor: descontoValorGlobal,
|
||||
obs: dto.obs ?? null,
|
||||
endEntrega: dto.endEntrega ?? null,
|
||||
itens: { deleteMany: {}, create: itemsData },
|
||||
historico: {
|
||||
create: [
|
||||
{
|
||||
situaAnterior: SITUA_ORCAMENTO,
|
||||
situaNova: SITUA_ORCAMENTO,
|
||||
changedBy: codVendedor,
|
||||
changedAt: new Date(),
|
||||
nota: 'Orçamento editado',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
include: {
|
||||
itens: { orderBy: { ordem: 'asc' } },
|
||||
historico: { orderBy: { changedAt: 'asc' } },
|
||||
},
|
||||
});
|
||||
|
||||
return this.mapDetail(final);
|
||||
}
|
||||
|
||||
async cancel(id: string, dto: CancelPedido): 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');
|
||||
@@ -788,8 +904,15 @@ export class OrdersService {
|
||||
throw new BadRequestException('Apenas orçamentos podem ser cancelados pelo representante');
|
||||
|
||||
const now = new Date();
|
||||
// ATENCAO: banco ERP e LATIN1 - sem em-dash/caracteres fora do LATIN1 aqui
|
||||
const motivoCompleto = dto.descricao?.trim()
|
||||
? `${dto.motivo} - ${dto.descricao.trim()}`
|
||||
: dto.motivo;
|
||||
|
||||
await prisma.pedido.update({ where: { id }, data: { situa: SITUA_CANCELADO } });
|
||||
await prisma.pedido.update({
|
||||
where: { id },
|
||||
data: { situa: SITUA_CANCELADO, motivoRecusa: motivoCompleto },
|
||||
});
|
||||
await prisma.historicoPedido.create({
|
||||
data: {
|
||||
idPedido: id,
|
||||
@@ -797,7 +920,7 @@ export class OrdersService {
|
||||
situaNova: SITUA_CANCELADO,
|
||||
changedBy: codVendedor,
|
||||
changedAt: now,
|
||||
nota: 'Cancelado pelo representante',
|
||||
nota: `Cancelado pelo representante - ${motivoCompleto}`,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -849,6 +972,8 @@ export class OrdersService {
|
||||
codVendedor: number;
|
||||
situa: number;
|
||||
dtPedido: Date;
|
||||
idPauta: number | null;
|
||||
codFormapag: number | null;
|
||||
total: Prisma.Decimal;
|
||||
descontoPerc: Prisma.Decimal;
|
||||
descontoValor: Prisma.Decimal;
|
||||
@@ -887,6 +1012,24 @@ export class OrdersService {
|
||||
}[];
|
||||
}): Promise<PedidoDetail> {
|
||||
const names = await this.lookupNames(o.idCliente, o.codVendedor);
|
||||
|
||||
// Grupo/subgrupo dos produtos (vw_produtos, empresa matriz) — a edição do
|
||||
// orçamento usa para calcular o teto de desconto por item no carrinho.
|
||||
const prisma = this.cls.get('prisma');
|
||||
const idEmpresa = this.cls.get('idEmpresa');
|
||||
const idMatriz = idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
|
||||
const prodIds = [...new Set(o.itens.map((i) => i.idProduto))];
|
||||
const grupoRows =
|
||||
prisma && prodIds.length > 0
|
||||
? await prisma.$queryRawUnsafe<
|
||||
{ id_erp: number; cod_grupo: number | null; cod_subgrupo: number | null }[]
|
||||
>(
|
||||
`SELECT id_erp, cod_grupo, cod_subgrupo FROM vw_produtos
|
||||
WHERE id_empresa = ${idMatriz} AND id_erp IN (${prodIds.join(',')})`,
|
||||
)
|
||||
: [];
|
||||
const grupoMap = new Map(grupoRows.map((g) => [Number(g.id_erp), g]));
|
||||
|
||||
return {
|
||||
id: o.id,
|
||||
numPedSar: o.numPedSar,
|
||||
@@ -897,6 +1040,8 @@ export class OrdersService {
|
||||
nomeVendedor: names.nomeVendedor,
|
||||
situa: o.situa,
|
||||
dtPedido: o.dtPedido.toISOString(),
|
||||
idPauta: o.idPauta,
|
||||
codFormapag: o.codFormapag,
|
||||
total: decimalToString(o.total),
|
||||
descontoPerc: decimalToString(o.descontoPerc),
|
||||
obs: o.obs,
|
||||
@@ -925,6 +1070,14 @@ export class OrdersService {
|
||||
precoUnitario: decimalToString(it.precoUnitario),
|
||||
descontoPerc: decimalToString(it.descontoPerc),
|
||||
total: decimalToString(it.total),
|
||||
codGrupo:
|
||||
grupoMap.get(it.idProduto)?.cod_grupo != null
|
||||
? Number(grupoMap.get(it.idProduto)?.cod_grupo)
|
||||
: null,
|
||||
codSubgrupo:
|
||||
grupoMap.get(it.idProduto)?.cod_subgrupo != null
|
||||
? Number(grupoMap.get(it.idProduto)?.cod_subgrupo)
|
||||
: null,
|
||||
})),
|
||||
historico: o.historico.map((h) => ({
|
||||
id: h.id,
|
||||
|
||||
Reference in New Issue
Block a user