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:
@@ -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.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1346,30 +1346,36 @@ 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'
|
||||||
: undefined;
|
: tetoErrors.length > 0
|
||||||
|
? `Desconto acima do teto em: ${tetoErrors.map((it) => it.codProduto.trim()).join(', ')}`
|
||||||
|
: undefined;
|
||||||
|
|
||||||
function clearClient() {
|
function clearClient() {
|
||||||
setSelectedClient(null);
|
setSelectedClient(null);
|
||||||
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,
|
||||||
|
|||||||
Reference in New Issue
Block a user