From 1e11de7f313e45f081f477522ac35d9695a6bad9 Mon Sep 17 00:00:00 2001 From: julian Date: Wed, 22 Jul 2026 19:16:55 +0000 Subject: [PATCH] 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 --- apps/api/src/app/orders/orders.controller.ts | 24 +- apps/api/src/app/orders/orders.service.ts | 207 +++++++++++++++--- apps/web/src/cockpits/rep/NewOrderPage.tsx | 73 +++++- apps/web/src/cockpits/rep/OrdersPage.tsx | 88 ++++++-- .../api-interface/src/lib/order.contract.ts | 18 ++ 5 files changed, 361 insertions(+), 49 deletions(-) diff --git a/apps/api/src/app/orders/orders.controller.ts b/apps/api/src/app/orders/orders.controller.ts index fae0129..9cf490f 100644 --- a/apps/api/src/app/orders/orders.controller.ts +++ b/apps/api/src/app/orders/orders.controller.ts @@ -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 { - return this.orders.cancel(id); + cancel( + @Param('id', ParseUUIDPipe) id: string, + @Body() body: CancelPedidoDto, + ): Promise { + 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 { + const parsed = UpdatePedidoSchema.parse(body) as UpdatePedido; + return this.orders.update(id, parsed); } @Get('erp/:idPedido') diff --git a/apps/api/src/app/orders/orders.service.ts b/apps/api/src/app/orders/orders.service.ts index 307bf28..d54025f 100644 --- a/apps/api/src/app/orders/orders.service.ts +++ b/apps/api/src/app/orders/orders.service.ts @@ -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 { + 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 { + // 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 { + 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 { 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 { 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, diff --git a/apps/web/src/cockpits/rep/NewOrderPage.tsx b/apps/web/src/cockpits/rep/NewOrderPage.tsx index a894b8f..0de1e01 100644 --- a/apps/web/src/cockpits/rep/NewOrderPage.tsx +++ b/apps/web/src/cockpits/rep/NewOrderPage.tsx @@ -44,6 +44,7 @@ import type { TopProduto, } from '@sar/api-interface'; import { useClientList, useClientDetail, useClientTopProdutos } from '../../lib/queries/clients'; +import { useOrderDetail } from '../../lib/queries/orders'; import { useCatalog, useFormasPagamento, usePautas } from '../../lib/queries/catalog'; import { useCondicoesComerciais, useTetoDesconto } from '../../lib/condicoes-comerciais'; import { CondicaoTag } from '../../components/CondicaoTag'; @@ -71,7 +72,7 @@ type CartItem = { codSubgrupo: number | null; }; -type SearchParams = { clientId?: string }; +type SearchParams = { clientId?: string; editar?: string }; // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -1291,11 +1292,16 @@ function OrderSummaryFooter({ // ─── NewOrderPage ───────────────────────────────────────────────────────────── export function NewOrderPage() { - const { clientId: clientIdParam } = useSearch({ strict: false }) as SearchParams; + const { clientId: clientIdParam, editar: editarId } = useSearch({ + strict: false, + }) as SearchParams; const navigate = useNavigate(); const qc = useQueryClient(); const { message } = App.useApp(); + // ── Modo edição: orçamento (situa 0) carregado para alteração ── + const { data: editOrder } = useOrderDetail(editarId); + // ── Dados do cliente ── const [clientSearch, setClientSearch] = useState(''); const [selectedClient, setSelectedClient] = useState(null); @@ -1307,8 +1313,11 @@ export function NewOrderPage() { const { data: selectedClientDetail } = useClientDetail( !clientIdParam && selectedClient ? selectedClient.idCliente : undefined, ); - const clientDetail = selectedClientDetail ?? preloadedClient ?? null; - const effectiveClient = selectedClient ?? preloadedClient ?? null; + const { data: editClient } = useClientDetail( + editarId && editOrder ? editOrder.idCliente : undefined, + ); + const clientDetail = selectedClientDetail ?? preloadedClient ?? editClient ?? null; + const effectiveClient = selectedClient ?? preloadedClient ?? editClient ?? null; // ── Campos comerciais ── const { data: pautas = [] } = usePautas(); @@ -1333,6 +1342,39 @@ export function NewOrderPage() { const [error, setError] = useState(null); const [catalogOpen, setCatalogOpen] = useState(false); + // Pré-carrega o formulário quando entra em modo edição (uma vez só) + const [editLoaded, setEditLoaded] = useState(false); + useEffect(() => { + if (!editarId || !editOrder || editLoaded) return; + setEditLoaded(true); + if (editOrder.situa !== 0) { + setError('Este pedido já foi transmitido e não pode mais ser editado.'); + return; + } + setCart( + editOrder.itens.map((it) => ({ + key: String(it.idProduto), + idProduto: it.idProduto, + codProduto: it.codProduto ?? '', + descProduto: it.descProduto ?? '', + unidade: '', + qtd: Number(it.qtd), + precoUnitario: Number(it.precoUnitario), + descontoPerc: Number(it.descontoPerc), + loteMulVenda: null, + codGrupo: it.codGrupo ?? null, + codSubgrupo: it.codSubgrupo ?? null, + })), + ); + setIdPauta(editOrder.idPauta ?? undefined); + setCodFormapag(editOrder.codFormapag ?? undefined); + setObs(editOrder.obs ?? ''); + if (editOrder.endEntrega) { + setEndTipo('outro'); + setEndCustom(editOrder.endEntrega); + } + }, [editarId, editOrder, editLoaded]); + // Endereço cadastrado do cliente — usa Detail (tem endereco/bairro/cep), não Summary. const clientEndStr = (() => { if (!clientDetail) return ''; @@ -1483,7 +1525,7 @@ export function NewOrderPage() { descontoPerc: 0, obs: obsCompleta || undefined, endEntrega: endEntregaValue, - idempotencyKey: randomUUID(), + idempotencyKey: editarId ? undefined : randomUUID(), itens: cart.map((it, idx) => ({ idProduto: it.idProduto, codProduto: it.codProduto, @@ -1495,6 +1537,16 @@ export function NewOrderPage() { })), }; + // Edição altera o orçamento existente — requer conexão + if (editarId) { + if (!navigator.onLine) { + throw new Error('Edição de orçamento requer conexão. Tente novamente online.'); + } + return apiFetch(`/orders/${editarId}`, { method: 'PATCH', body }) as Promise<{ + id: string; + }>; + } + if (!navigator.onLine) { await enqueueOrder(body, effectiveClient.nome); window.dispatchEvent(new CustomEvent('sar:order-queued')); @@ -1506,6 +1558,11 @@ export function NewOrderPage() { onSuccess: (created) => { void qc.invalidateQueries({ queryKey: ['orders'] }); void qc.invalidateQueries({ queryKey: ['clients'] }); + if (editarId && created) { + message.success('Orçamento atualizado'); + void navigate({ to: '/pedidos/$id', params: { id: created.id } }); + return; + } if (!created) { setError(null); setCart([]); @@ -1523,7 +1580,7 @@ export function NewOrderPage() { } void navigate({ to: '/pedidos/$id', params: { id: created.id } }); }, - onError: (e: unknown) => setError(e instanceof Error ? e.message : 'Erro ao criar pedido'), + onError: (e: unknown) => setError(e instanceof Error ? e.message : 'Erro ao salvar pedido'), }); return ( @@ -1538,7 +1595,9 @@ export function NewOrderPage() { }} > - Lançamento de Pedido + {editarId + ? `Editar Orçamento${editOrder ? ` ${editOrder.numPedSar}` : ''}` + : 'Lançamento de Pedido'}