diff --git a/apps/api/src/app/orders/orders.service.ts b/apps/api/src/app/orders/orders.service.ts index 0958a48..307bf28 100644 --- a/apps/api/src/app/orders/orders.service.ts +++ b/apps/api/src/app/orders/orders.service.ts @@ -330,8 +330,10 @@ export class OrdersService { return this.mapDetail(o); } - // Cria novo pedido SAR como ORÇAMENTO (situa 0). A validação de alçada e a - // notificação ao supervisor acontecem no transmit(), não aqui. + // 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. + // A notificação ao supervisor continua só no transmit(). // Idempotency-Key: retorna pedido existente se já processado (FR-4.3). async create(dto: CreatePedido): Promise { const prisma = this.cls.get('prisma'); @@ -365,6 +367,32 @@ export class OrdersService { if (existing) return this.mapDetail(existing); } + // 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, + ); + const itemsData = dto.itens.map((it) => { const descontoValor = Math.round(it.qtd * it.precoUnitario * (it.descontoPerc / 100) * 100) / 100; @@ -505,14 +533,14 @@ export class OrdersService { private async assertAlcadaItens( pedido: { codVendedor: number; - descontoPerc: Prisma.Decimal; + descontoPerc: Prisma.Decimal | number; idPauta: number | null; itens: { ordem: number; idProduto: number; codProduto: string | null; - precoUnitario: Prisma.Decimal; - descontoPerc: Prisma.Decimal; + precoUnitario: Prisma.Decimal | number; + descontoPerc: Prisma.Decimal | number; }[]; }, limitMap: Map, @@ -632,7 +660,7 @@ export class OrdersService { if (problemas.length > 0) { throw new BadRequestException( - `Desconto acima da sua alçada: ${problemas.join('; ')}. Ajuste preço/desconto para transmitir.`, + `Desconto acima da sua alçada: ${problemas.join('; ')}. Ajuste preço/desconto para continuar.`, ); } } diff --git a/apps/web/src/cockpits/rep/NewOrderPage.tsx b/apps/web/src/cockpits/rep/NewOrderPage.tsx index 133b1a5..a894b8f 100644 --- a/apps/web/src/cockpits/rep/NewOrderPage.tsx +++ b/apps/web/src/cockpits/rep/NewOrderPage.tsx @@ -1346,30 +1346,36 @@ export function NewOrderPage() { })(); const hasClientEnd = !!clientEndStr; + // Teto de desconto por item (alçada + promoção/regra) — aviso em tempo real + const tetoDesconto = useTetoDesconto(); + const tetoDe = (it: CartItem) => + tetoDesconto({ codigo: it.codProduto, codGrupo: it.codGrupo, codSubgrupo: it.codSubgrupo }); + const totalPedido = cart.reduce((acc, it) => acc + itemTotal(it), 0); const loteErrors = cart.filter((it) => { const lote = it.loteMulVenda ?? 1; return lote > 1 && it.qtd % lote !== 0; }); - const canSubmit = !!effectiveClient && cart.length > 0 && loteErrors.length === 0; + // Desconto acima do teto bloqueia o Concluir — o backend recusa a criação + // do orçamento de qualquer forma; aqui a mensagem chega antes do clique. + const tetoErrors = cart.filter((it) => it.descontoPerc > tetoDe(it) + 0.001); + const canSubmit = + !!effectiveClient && cart.length > 0 && loteErrors.length === 0 && tetoErrors.length === 0; const missingReason = !effectiveClient ? 'Selecione um cliente para continuar' : cart.length === 0 ? 'Adicione ao menos um produto ao pedido' : loteErrors.length > 0 ? 'Corrija as quantidades com lote múltiplo' - : undefined; + : tetoErrors.length > 0 + ? `Desconto acima do teto em: ${tetoErrors.map((it) => it.codProduto.trim()).join(', ')}` + : undefined; function clearClient() { setSelectedClient(null); setClientSearch(''); } - // Teto de desconto por item (alçada + promoção/regra) — aviso em tempo real - const tetoDesconto = useTetoDesconto(); - const tetoDe = (it: CartItem) => - tetoDesconto({ codigo: it.codProduto, codGrupo: it.codGrupo, codSubgrupo: it.codSubgrupo }); - // ── Handlers do carrinho ── // Item entra SEM desconto mesmo quando há promoção/regra vigente (selo // "Condição comercial" avisa o rep): a proposta é carta de negociação — o rep @@ -1451,6 +1457,13 @@ export function NewOrderPage() { `Quantidade inválida: ${invalidos.map((it) => `${it.codProduto.trim()} (lote múltiplo: ${it.loteMulVenda})`).join(', ')}`, ); } + // Cobre também o caminho offline (enqueueOrder não passa pelo backend) + const acimaTeto = cart.filter((it) => it.descontoPerc > tetoDe(it) + 0.001); + if (acimaTeto.length > 0) { + throw new Error( + `Desconto acima do teto: ${acimaTeto.map((it) => `${it.codProduto.trim()} (máx. ${tetoDe(it)}%)`).join(', ')}`, + ); + } const obsCompleta = [ contato ? `Contato: ${contato}` : null,