feat(web,api): teto de desconto em tempo real no carrinho do pedido
- GET /politicas/alcada: limites de desconto do proprio usuario logado - Carrinho (desktop e mobile) mostra "teto: X%" sob o campo de desconto de cada item - max(alcada do grupo, promocao, regra vigente), espelho do assertAlcadaItens; acima do teto o campo fica vermelho com aviso de que a transmissao sera bloqueada Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,7 @@ import {
|
||||
type AlcadaDescontosResponse,
|
||||
type PromocoesResponse,
|
||||
type PromocoesVigentesResponse,
|
||||
type AlcadaRepResponse,
|
||||
type RegrasDescontoResponse,
|
||||
type RegrasVigentesResponse,
|
||||
type GruposProdutoResponse,
|
||||
@@ -61,6 +62,11 @@ export class PoliticasController {
|
||||
return this.politicas.listPromocoesVigentes();
|
||||
}
|
||||
|
||||
@Get('alcada')
|
||||
minhaAlcada(): Promise<AlcadaRepResponse> {
|
||||
return this.politicas.minhaAlcada();
|
||||
}
|
||||
|
||||
@Post('promocoes')
|
||||
createPromocao(@Body() body: CreatePromocaoDto): Promise<{ id: number }> {
|
||||
return this.politicas.createPromocao(body as unknown as CreatePromocaoBody);
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
UpdateRegraDescontoBody,
|
||||
RegrasVigentesResponse,
|
||||
PromocoesVigentesResponse,
|
||||
AlcadaRepResponse,
|
||||
GruposProdutoResponse,
|
||||
} from '@sar/api-interface';
|
||||
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||
@@ -315,6 +316,25 @@ export class PoliticasService {
|
||||
await prisma.regraDesconto.delete({ where: { id } });
|
||||
}
|
||||
|
||||
// Alçada do próprio usuário logado — o carrinho usa para mostrar o teto de
|
||||
// desconto por item (codGrupo 0 = default; sem linha 0, o backend assume 5%).
|
||||
async minhaAlcada(): Promise<AlcadaRepResponse> {
|
||||
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 codVendedor = parseInt(this.cls.get('userId') ?? '0', 10);
|
||||
|
||||
const rows = await prisma.alcadaDesconto.findMany({
|
||||
where: { codVendedor, idEmpresa },
|
||||
});
|
||||
return {
|
||||
limites: rows.map((r) => ({
|
||||
codGrupo: r.codGrupo,
|
||||
limitePerc: Number(r.limitePerc),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// Promoções vigentes hoje — qualquer usuário autenticado. Usadas pelo rep para
|
||||
// sinalizar no catálogo que o produto tem condição comercial diferenciada.
|
||||
async listPromocoesVigentes(): Promise<PromocoesVigentesResponse> {
|
||||
|
||||
@@ -45,7 +45,7 @@ import type {
|
||||
} from '@sar/api-interface';
|
||||
import { useClientList, useClientDetail, useClientTopProdutos } from '../../lib/queries/clients';
|
||||
import { useCatalog, useFormasPagamento, usePautas } from '../../lib/queries/catalog';
|
||||
import { useCondicoesComerciais } from '../../lib/condicoes-comerciais';
|
||||
import { useCondicoesComerciais, useTetoDesconto } from '../../lib/condicoes-comerciais';
|
||||
import { CondicaoTag } from '../../components/CondicaoTag';
|
||||
import { apiFetch } from '../../lib/api-client';
|
||||
import { enqueueOrder } from '../../lib/offline/order-queue';
|
||||
@@ -66,6 +66,9 @@ type CartItem = {
|
||||
precoUnitario: number;
|
||||
descontoPerc: number;
|
||||
loteMulVenda: number | null;
|
||||
// Grupo/subgrupo do produto — usados para calcular o teto de desconto do item
|
||||
codGrupo: number | null;
|
||||
codSubgrupo: number | null;
|
||||
};
|
||||
|
||||
type SearchParams = { clientId?: string };
|
||||
@@ -629,17 +632,20 @@ function CatalogBrowserModal({
|
||||
|
||||
function MobileCartItem({
|
||||
item,
|
||||
teto,
|
||||
onQtyChange,
|
||||
onDiscChange,
|
||||
onRemove,
|
||||
}: {
|
||||
item: CartItem;
|
||||
teto: number;
|
||||
onQtyChange: (key: string, qty: number) => void;
|
||||
onDiscChange: (key: string, disc: number) => void;
|
||||
onRemove: (key: string) => void;
|
||||
}) {
|
||||
const lote = item.loteMulVenda ?? 1;
|
||||
const invalid = lote > 1 && item.qtd % lote !== 0;
|
||||
const acimaTeto = item.descontoPerc > teto + 0.001;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -711,16 +717,29 @@ function MobileCartItem({
|
||||
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginBottom: 2 }}>
|
||||
Desc%
|
||||
</Text>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.5}
|
||||
value={item.descontoPerc}
|
||||
size="small"
|
||||
style={{ width: 88 }}
|
||||
addonAfter="%"
|
||||
onChange={(n) => onDiscChange(item.key, n ?? 0)}
|
||||
/>
|
||||
<Tooltip
|
||||
title={
|
||||
acimaTeto
|
||||
? `Acima do teto de ${teto}% — a transmissão será bloqueada`
|
||||
: `Teto do item: ${teto}%`
|
||||
}
|
||||
color={acimaTeto ? 'red' : 'blue'}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.5}
|
||||
value={item.descontoPerc}
|
||||
size="small"
|
||||
style={{ width: 88 }}
|
||||
addonAfter="%"
|
||||
status={acimaTeto ? 'error' : undefined}
|
||||
onChange={(n) => onDiscChange(item.key, n ?? 0)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<div style={{ fontSize: 10, color: acimaTeto ? '#f5222d' : '#94A3B8', marginTop: 2 }}>
|
||||
{acimaTeto ? `acima do teto ${teto}%` : `teto: ${teto}%`}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginLeft: 'auto', textAlign: 'right' }}>
|
||||
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginBottom: 2 }}>
|
||||
@@ -969,11 +988,13 @@ function ClientHistoryPanel({
|
||||
|
||||
function OrderItemsTable({
|
||||
items,
|
||||
tetoDe,
|
||||
onQtyChange,
|
||||
onDiscChange,
|
||||
onRemove,
|
||||
}: {
|
||||
items: CartItem[];
|
||||
tetoDe: (item: CartItem) => number;
|
||||
onQtyChange: (key: string, qty: number) => void;
|
||||
onDiscChange: (key: string, disc: number) => void;
|
||||
onRemove: (key: string) => void;
|
||||
@@ -1014,6 +1035,7 @@ function OrderItemsTable({
|
||||
<MobileCartItem
|
||||
key={item.key}
|
||||
item={item}
|
||||
teto={tetoDe(item)}
|
||||
onQtyChange={onQtyChange}
|
||||
onDiscChange={onDiscChange}
|
||||
onRemove={onRemove}
|
||||
@@ -1090,19 +1112,38 @@ function OrderItemsTable({
|
||||
{
|
||||
title: 'Desc %',
|
||||
dataIndex: 'descontoPerc',
|
||||
width: 100,
|
||||
render: (v: number, row: CartItem) => (
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.5}
|
||||
value={v}
|
||||
size="small"
|
||||
style={{ width: 76 }}
|
||||
addonAfter="%"
|
||||
onChange={(n) => onDiscChange(row.key, n ?? 0)}
|
||||
/>
|
||||
),
|
||||
width: 110,
|
||||
render: (v: number, row: CartItem) => {
|
||||
const teto = tetoDe(row);
|
||||
const acimaTeto = v > teto + 0.001;
|
||||
return (
|
||||
<div>
|
||||
<Tooltip
|
||||
title={
|
||||
acimaTeto
|
||||
? `Acima do teto de ${teto}% — a transmissão será bloqueada`
|
||||
: `Teto do item: ${teto}%`
|
||||
}
|
||||
color={acimaTeto ? 'red' : 'blue'}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.5}
|
||||
value={v}
|
||||
size="small"
|
||||
style={{ width: 76 }}
|
||||
addonAfter="%"
|
||||
status={acimaTeto ? 'error' : undefined}
|
||||
onChange={(n) => onDiscChange(row.key, n ?? 0)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<div style={{ fontSize: 10, color: acimaTeto ? '#f5222d' : '#94A3B8', marginTop: 2 }}>
|
||||
{acimaTeto ? `acima do teto ${teto}%` : `teto: ${teto}%`}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Total',
|
||||
@@ -1324,6 +1365,11 @@ export function NewOrderPage() {
|
||||
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
|
||||
@@ -1350,6 +1396,8 @@ export function NewOrderPage() {
|
||||
precoUnitario: Number(p.vlPreco1),
|
||||
descontoPerc: 0,
|
||||
loteMulVenda: p.loteMulVenda,
|
||||
codGrupo: p.codGrupo,
|
||||
codSubgrupo: p.codSubgrupo,
|
||||
},
|
||||
];
|
||||
});
|
||||
@@ -1373,6 +1421,9 @@ export function NewOrderPage() {
|
||||
precoUnitario: p.ultimoPreco,
|
||||
descontoPerc: 0,
|
||||
loteMulVenda: null,
|
||||
// TopProduto não traz grupo/subgrupo — teto considera só alçada default
|
||||
codGrupo: null,
|
||||
codSubgrupo: null,
|
||||
},
|
||||
];
|
||||
});
|
||||
@@ -1684,6 +1735,7 @@ export function NewOrderPage() {
|
||||
|
||||
<OrderItemsTable
|
||||
items={cart}
|
||||
tetoDe={tetoDe}
|
||||
onQtyChange={setQty}
|
||||
onDiscChange={setDisc}
|
||||
onRemove={removeItem}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { PromocaoVigente, RegraVigente } from '@sar/api-interface';
|
||||
import { usePromocoesVigentes, useRegrasVigentes } from './queries/politicas';
|
||||
import { usePromocoesVigentes, useRegrasVigentes, useMinhaAlcada } from './queries/politicas';
|
||||
|
||||
// Condição comercial vigente que alcança um produto: promoção (por produto ou
|
||||
// grupo) ou regra de desconto (por grupo/subgrupo/rep). Usada para sinalizar no
|
||||
@@ -62,3 +62,17 @@ export function useCondicoesComerciais(): (p: ProdutoRef) => CondicaoComercial[]
|
||||
const { data: regras } = useRegrasVigentes();
|
||||
return (p) => condicoesDoProduto(p, promos?.promocoes, regras?.regras);
|
||||
}
|
||||
|
||||
// Teto de desconto do item — espelha assertAlcadaItens do backend: vale o MAIOR
|
||||
// entre a alçada do grupo (codGrupo 0 = default; sem linha, 5%) e a melhor
|
||||
// promoção/regra vigente que alcança o produto.
|
||||
export function useTetoDesconto(): (p: ProdutoRef) => number {
|
||||
const condicoesDe = useCondicoesComerciais();
|
||||
const { data: alcada } = useMinhaAlcada();
|
||||
return (p) => {
|
||||
const limites = new Map((alcada?.limites ?? []).map((l) => [l.codGrupo, l.limitePerc]));
|
||||
const base = (p.codGrupo != null ? limites.get(p.codGrupo) : undefined) ?? limites.get(0) ?? 5;
|
||||
const cond = condicoesDe(p).reduce((max, c) => Math.max(max, c.descPct), 0);
|
||||
return Math.max(base, cond);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
RegrasVigentesResponseSchema,
|
||||
PromocoesVigentesResponseSchema,
|
||||
AlcadaRepResponseSchema,
|
||||
type RegrasVigentesResponse,
|
||||
type PromocoesVigentesResponse,
|
||||
type AlcadaRepResponse,
|
||||
} from '@sar/api-interface';
|
||||
import { apiFetch } from '../api-client';
|
||||
|
||||
@@ -20,6 +22,18 @@ export function useRegrasVigentes() {
|
||||
});
|
||||
}
|
||||
|
||||
// Alçada do usuário logado — teto de desconto por item no carrinho
|
||||
export function useMinhaAlcada() {
|
||||
return useQuery<AlcadaRepResponse>({
|
||||
queryKey: ['politicas', 'minha-alcada'],
|
||||
queryFn: async () => {
|
||||
const raw = await apiFetch('/politicas/alcada');
|
||||
return AlcadaRepResponseSchema.parse(raw);
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
// Promoções vigentes hoje — sinalizam condição comercial no catálogo do rep
|
||||
export function usePromocoesVigentes() {
|
||||
return useQuery<PromocoesVigentesResponse>({
|
||||
|
||||
Reference in New Issue
Block a user