feat(api,web): alcada bloqueia ja a criacao do orcamento, nao so a transmissao

- create() valida desconto global e por item (mesma regra do transmit):
  Concluir com desconto acima do teto retorna 400 com a mensagem do
  item que passou; transmit segue validando (defesa em profundidade)
- Botao Concluir desabilita em tempo real com o motivo quando algum
  item esta acima do teto; caminho offline tambem barrado no submit
- Mensagem de alcada generalizada ("para continuar")

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 18:56:13 +00:00
parent 673130d60b
commit 35fd773f14
2 changed files with 54 additions and 13 deletions

View File

@@ -330,8 +330,10 @@ export class OrdersService {
return this.mapDetail(o); return this.mapDetail(o);
} }
// Cria novo pedido SAR como ORÇAMENTO (situa 0). A validação de alçada e a // Cria novo pedido SAR como ORÇAMENTO (situa 0). A alçada de desconto valida
// notificação ao supervisor acontecem no transmit(), não aqui. // 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). // Idempotency-Key: retorna pedido existente se já processado (FR-4.3).
async create(dto: CreatePedido): Promise<PedidoDetail> { async create(dto: CreatePedido): Promise<PedidoDetail> {
const prisma = this.cls.get('prisma'); const prisma = this.cls.get('prisma');
@@ -365,6 +367,32 @@ export class OrdersService {
if (existing) return this.mapDetail(existing); 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 itemsData = dto.itens.map((it) => {
const descontoValor = const descontoValor =
Math.round(it.qtd * it.precoUnitario * (it.descontoPerc / 100) * 100) / 100; Math.round(it.qtd * it.precoUnitario * (it.descontoPerc / 100) * 100) / 100;
@@ -505,14 +533,14 @@ export class OrdersService {
private async assertAlcadaItens( private async assertAlcadaItens(
pedido: { pedido: {
codVendedor: number; codVendedor: number;
descontoPerc: Prisma.Decimal; descontoPerc: Prisma.Decimal | number;
idPauta: number | null; idPauta: number | null;
itens: { itens: {
ordem: number; ordem: number;
idProduto: number; idProduto: number;
codProduto: string | null; codProduto: string | null;
precoUnitario: Prisma.Decimal; precoUnitario: Prisma.Decimal | number;
descontoPerc: Prisma.Decimal; descontoPerc: Prisma.Decimal | number;
}[]; }[];
}, },
limitMap: Map<number, number>, limitMap: Map<number, number>,
@@ -632,7 +660,7 @@ export class OrdersService {
if (problemas.length > 0) { if (problemas.length > 0) {
throw new BadRequestException( 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.`,
); );
} }
} }

View File

@@ -1346,18 +1346,29 @@ export function NewOrderPage() {
})(); })();
const hasClientEnd = !!clientEndStr; 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 totalPedido = cart.reduce((acc, it) => acc + itemTotal(it), 0);
const loteErrors = cart.filter((it) => { const loteErrors = cart.filter((it) => {
const lote = it.loteMulVenda ?? 1; const lote = it.loteMulVenda ?? 1;
return lote > 1 && it.qtd % lote !== 0; 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 const missingReason = !effectiveClient
? 'Selecione um cliente para continuar' ? 'Selecione um cliente para continuar'
: cart.length === 0 : cart.length === 0
? 'Adicione ao menos um produto ao pedido' ? 'Adicione ao menos um produto ao pedido'
: loteErrors.length > 0 : loteErrors.length > 0
? 'Corrija as quantidades com lote múltiplo' ? 'Corrija as quantidades com lote múltiplo'
: tetoErrors.length > 0
? `Desconto acima do teto em: ${tetoErrors.map((it) => it.codProduto.trim()).join(', ')}`
: undefined; : undefined;
function clearClient() { function clearClient() {
@@ -1365,11 +1376,6 @@ export function NewOrderPage() {
setClientSearch(''); 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 ── // ── Handlers do carrinho ──
// Item entra SEM desconto mesmo quando há promoção/regra vigente (selo // 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 // "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(', ')}`, `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 = [ const obsCompleta = [
contato ? `Contato: ${contato}` : null, contato ? `Contato: ${contato}` : null,