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 AlcadaDescontosResponse,
|
||||||
type PromocoesResponse,
|
type PromocoesResponse,
|
||||||
type PromocoesVigentesResponse,
|
type PromocoesVigentesResponse,
|
||||||
|
type AlcadaRepResponse,
|
||||||
type RegrasDescontoResponse,
|
type RegrasDescontoResponse,
|
||||||
type RegrasVigentesResponse,
|
type RegrasVigentesResponse,
|
||||||
type GruposProdutoResponse,
|
type GruposProdutoResponse,
|
||||||
@@ -61,6 +62,11 @@ export class PoliticasController {
|
|||||||
return this.politicas.listPromocoesVigentes();
|
return this.politicas.listPromocoesVigentes();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('alcada')
|
||||||
|
minhaAlcada(): Promise<AlcadaRepResponse> {
|
||||||
|
return this.politicas.minhaAlcada();
|
||||||
|
}
|
||||||
|
|
||||||
@Post('promocoes')
|
@Post('promocoes')
|
||||||
createPromocao(@Body() body: CreatePromocaoDto): Promise<{ id: number }> {
|
createPromocao(@Body() body: CreatePromocaoDto): Promise<{ id: number }> {
|
||||||
return this.politicas.createPromocao(body as unknown as CreatePromocaoBody);
|
return this.politicas.createPromocao(body as unknown as CreatePromocaoBody);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
UpdateRegraDescontoBody,
|
UpdateRegraDescontoBody,
|
||||||
RegrasVigentesResponse,
|
RegrasVigentesResponse,
|
||||||
PromocoesVigentesResponse,
|
PromocoesVigentesResponse,
|
||||||
|
AlcadaRepResponse,
|
||||||
GruposProdutoResponse,
|
GruposProdutoResponse,
|
||||||
} from '@sar/api-interface';
|
} from '@sar/api-interface';
|
||||||
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||||
@@ -315,6 +316,25 @@ export class PoliticasService {
|
|||||||
await prisma.regraDesconto.delete({ where: { id } });
|
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
|
// Promoções vigentes hoje — qualquer usuário autenticado. Usadas pelo rep para
|
||||||
// sinalizar no catálogo que o produto tem condição comercial diferenciada.
|
// sinalizar no catálogo que o produto tem condição comercial diferenciada.
|
||||||
async listPromocoesVigentes(): Promise<PromocoesVigentesResponse> {
|
async listPromocoesVigentes(): Promise<PromocoesVigentesResponse> {
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ import type {
|
|||||||
} from '@sar/api-interface';
|
} from '@sar/api-interface';
|
||||||
import { useClientList, useClientDetail, useClientTopProdutos } from '../../lib/queries/clients';
|
import { useClientList, useClientDetail, useClientTopProdutos } from '../../lib/queries/clients';
|
||||||
import { useCatalog, useFormasPagamento, usePautas } from '../../lib/queries/catalog';
|
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 { CondicaoTag } from '../../components/CondicaoTag';
|
||||||
import { apiFetch } from '../../lib/api-client';
|
import { apiFetch } from '../../lib/api-client';
|
||||||
import { enqueueOrder } from '../../lib/offline/order-queue';
|
import { enqueueOrder } from '../../lib/offline/order-queue';
|
||||||
@@ -66,6 +66,9 @@ type CartItem = {
|
|||||||
precoUnitario: number;
|
precoUnitario: number;
|
||||||
descontoPerc: number;
|
descontoPerc: number;
|
||||||
loteMulVenda: number | null;
|
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 };
|
type SearchParams = { clientId?: string };
|
||||||
@@ -629,17 +632,20 @@ function CatalogBrowserModal({
|
|||||||
|
|
||||||
function MobileCartItem({
|
function MobileCartItem({
|
||||||
item,
|
item,
|
||||||
|
teto,
|
||||||
onQtyChange,
|
onQtyChange,
|
||||||
onDiscChange,
|
onDiscChange,
|
||||||
onRemove,
|
onRemove,
|
||||||
}: {
|
}: {
|
||||||
item: CartItem;
|
item: CartItem;
|
||||||
|
teto: number;
|
||||||
onQtyChange: (key: string, qty: number) => void;
|
onQtyChange: (key: string, qty: number) => void;
|
||||||
onDiscChange: (key: string, disc: number) => void;
|
onDiscChange: (key: string, disc: number) => void;
|
||||||
onRemove: (key: string) => void;
|
onRemove: (key: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const lote = item.loteMulVenda ?? 1;
|
const lote = item.loteMulVenda ?? 1;
|
||||||
const invalid = lote > 1 && item.qtd % lote !== 0;
|
const invalid = lote > 1 && item.qtd % lote !== 0;
|
||||||
|
const acimaTeto = item.descontoPerc > teto + 0.001;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -711,16 +717,29 @@ function MobileCartItem({
|
|||||||
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginBottom: 2 }}>
|
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginBottom: 2 }}>
|
||||||
Desc%
|
Desc%
|
||||||
</Text>
|
</Text>
|
||||||
<InputNumber
|
<Tooltip
|
||||||
min={0}
|
title={
|
||||||
max={100}
|
acimaTeto
|
||||||
step={0.5}
|
? `Acima do teto de ${teto}% — a transmissão será bloqueada`
|
||||||
value={item.descontoPerc}
|
: `Teto do item: ${teto}%`
|
||||||
size="small"
|
}
|
||||||
style={{ width: 88 }}
|
color={acimaTeto ? 'red' : 'blue'}
|
||||||
addonAfter="%"
|
>
|
||||||
onChange={(n) => onDiscChange(item.key, n ?? 0)}
|
<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>
|
||||||
<div style={{ marginLeft: 'auto', textAlign: 'right' }}>
|
<div style={{ marginLeft: 'auto', textAlign: 'right' }}>
|
||||||
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginBottom: 2 }}>
|
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginBottom: 2 }}>
|
||||||
@@ -969,11 +988,13 @@ function ClientHistoryPanel({
|
|||||||
|
|
||||||
function OrderItemsTable({
|
function OrderItemsTable({
|
||||||
items,
|
items,
|
||||||
|
tetoDe,
|
||||||
onQtyChange,
|
onQtyChange,
|
||||||
onDiscChange,
|
onDiscChange,
|
||||||
onRemove,
|
onRemove,
|
||||||
}: {
|
}: {
|
||||||
items: CartItem[];
|
items: CartItem[];
|
||||||
|
tetoDe: (item: CartItem) => number;
|
||||||
onQtyChange: (key: string, qty: number) => void;
|
onQtyChange: (key: string, qty: number) => void;
|
||||||
onDiscChange: (key: string, disc: number) => void;
|
onDiscChange: (key: string, disc: number) => void;
|
||||||
onRemove: (key: string) => void;
|
onRemove: (key: string) => void;
|
||||||
@@ -1014,6 +1035,7 @@ function OrderItemsTable({
|
|||||||
<MobileCartItem
|
<MobileCartItem
|
||||||
key={item.key}
|
key={item.key}
|
||||||
item={item}
|
item={item}
|
||||||
|
teto={tetoDe(item)}
|
||||||
onQtyChange={onQtyChange}
|
onQtyChange={onQtyChange}
|
||||||
onDiscChange={onDiscChange}
|
onDiscChange={onDiscChange}
|
||||||
onRemove={onRemove}
|
onRemove={onRemove}
|
||||||
@@ -1090,19 +1112,38 @@ function OrderItemsTable({
|
|||||||
{
|
{
|
||||||
title: 'Desc %',
|
title: 'Desc %',
|
||||||
dataIndex: 'descontoPerc',
|
dataIndex: 'descontoPerc',
|
||||||
width: 100,
|
width: 110,
|
||||||
render: (v: number, row: CartItem) => (
|
render: (v: number, row: CartItem) => {
|
||||||
<InputNumber
|
const teto = tetoDe(row);
|
||||||
min={0}
|
const acimaTeto = v > teto + 0.001;
|
||||||
max={100}
|
return (
|
||||||
step={0.5}
|
<div>
|
||||||
value={v}
|
<Tooltip
|
||||||
size="small"
|
title={
|
||||||
style={{ width: 76 }}
|
acimaTeto
|
||||||
addonAfter="%"
|
? `Acima do teto de ${teto}% — a transmissão será bloqueada`
|
||||||
onChange={(n) => onDiscChange(row.key, n ?? 0)}
|
: `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',
|
title: 'Total',
|
||||||
@@ -1324,6 +1365,11 @@ 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
|
||||||
@@ -1350,6 +1396,8 @@ export function NewOrderPage() {
|
|||||||
precoUnitario: Number(p.vlPreco1),
|
precoUnitario: Number(p.vlPreco1),
|
||||||
descontoPerc: 0,
|
descontoPerc: 0,
|
||||||
loteMulVenda: p.loteMulVenda,
|
loteMulVenda: p.loteMulVenda,
|
||||||
|
codGrupo: p.codGrupo,
|
||||||
|
codSubgrupo: p.codSubgrupo,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
@@ -1373,6 +1421,9 @@ export function NewOrderPage() {
|
|||||||
precoUnitario: p.ultimoPreco,
|
precoUnitario: p.ultimoPreco,
|
||||||
descontoPerc: 0,
|
descontoPerc: 0,
|
||||||
loteMulVenda: null,
|
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
|
<OrderItemsTable
|
||||||
items={cart}
|
items={cart}
|
||||||
|
tetoDe={tetoDe}
|
||||||
onQtyChange={setQty}
|
onQtyChange={setQty}
|
||||||
onDiscChange={setDisc}
|
onDiscChange={setDisc}
|
||||||
onRemove={removeItem}
|
onRemove={removeItem}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { PromocaoVigente, RegraVigente } from '@sar/api-interface';
|
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
|
// 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
|
// 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();
|
const { data: regras } = useRegrasVigentes();
|
||||||
return (p) => condicoesDoProduto(p, promos?.promocoes, regras?.regras);
|
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 {
|
import {
|
||||||
RegrasVigentesResponseSchema,
|
RegrasVigentesResponseSchema,
|
||||||
PromocoesVigentesResponseSchema,
|
PromocoesVigentesResponseSchema,
|
||||||
|
AlcadaRepResponseSchema,
|
||||||
type RegrasVigentesResponse,
|
type RegrasVigentesResponse,
|
||||||
type PromocoesVigentesResponse,
|
type PromocoesVigentesResponse,
|
||||||
|
type AlcadaRepResponse,
|
||||||
} from '@sar/api-interface';
|
} from '@sar/api-interface';
|
||||||
import { apiFetch } from '../api-client';
|
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
|
// Promoções vigentes hoje — sinalizam condição comercial no catálogo do rep
|
||||||
export function usePromocoesVigentes() {
|
export function usePromocoesVigentes() {
|
||||||
return useQuery<PromocoesVigentesResponse>({
|
return useQuery<PromocoesVigentesResponse>({
|
||||||
|
|||||||
@@ -97,6 +97,18 @@ export const UpdateRegraDescontoBodySchema = CreateRegraDescontoBodySchema.parti
|
|||||||
});
|
});
|
||||||
export type UpdateRegraDescontoBody = z.infer<typeof UpdateRegraDescontoBodySchema>;
|
export type UpdateRegraDescontoBody = z.infer<typeof UpdateRegraDescontoBodySchema>;
|
||||||
|
|
||||||
|
// Alçada do usuário logado — usada pelo carrinho para mostrar o teto por item
|
||||||
|
export const AlcadaRepItemSchema = z.object({
|
||||||
|
codGrupo: z.number().int(),
|
||||||
|
limitePerc: z.number(),
|
||||||
|
});
|
||||||
|
export type AlcadaRepItem = z.infer<typeof AlcadaRepItemSchema>;
|
||||||
|
|
||||||
|
export const AlcadaRepResponseSchema = z.object({
|
||||||
|
limites: z.array(AlcadaRepItemSchema),
|
||||||
|
});
|
||||||
|
export type AlcadaRepResponse = z.infer<typeof AlcadaRepResponseSchema>;
|
||||||
|
|
||||||
// Promoções vigentes hoje — visíveis ao rep para sinalizar condição no catálogo
|
// Promoções vigentes hoje — visíveis ao rep para sinalizar condição no catálogo
|
||||||
export const PromocaoVigenteSchema = z.object({
|
export const PromocaoVigenteSchema = z.object({
|
||||||
id: z.number().int(),
|
id: z.number().int(),
|
||||||
|
|||||||
Reference in New Issue
Block a user