Campanhas de Produtos
Uma Campanha é uma promoção especial multi-produto que concede desconto progressivo por quantidade.
Diferente de uma promoção convencional (um produto / uma promoção), uma campanha agrupa vários produtos
sob um GRUPODESCPROD, define produtos obrigatórios (AD_CODPROD / AD_QTDEOBRIG) e faixas de desconto
cumulativas (QTDPROMOCOES). A campanha tem prioridade visual sobre a promoção convencional: o badge $
é suprimido na Nova Venda quando a promoção do produto pertence a uma campanha.
Habilitação da feature
A feature é controlada por duas condições independentes — satisfazer qualquer uma delas já ativa:
| Mecanismo | Condição | Requer deploy? |
|---|---|---|
| Parâmetro Sankhya | ATIVACAMPANHA === 'S' | Não |
| Tenant hardcoded | isCertoDistribuidora() em permission.ts | Sim |
// src/utils/campaigns.utils.ts
export function isCampaignsFeatureEnabled(permission: any): boolean {
return (
permission?.ATIVACAMPANHA === "S" ||
permission?.permissionByCompany?.isCertoDistribuidora?.() === true
);
}
Configure o parâmetro ATIVACAMPANHA = S diretamente no Sankhya. Na próxima autenticação do usuário
a feature estará ativa automaticamente, sem nenhuma alteração de código.
Remova ou altere ATIVACAMPANHA para qualquer valor diferente de 'S'. Para tenants hardcoded, é
necessário deploy.
Parâmetros de configuração
| Parâmetro | Valores | Efeito |
|---|---|---|
ATIVACAMPANHA | 'S' / ausente | Liga / desliga a feature inteira |
ATIVACAMPANHAAPENASFILT | ausente ou 'S' (padrão) | Modo "Apenas filtro" — comportamento padrão |
ATIVACAMPANHAAPENASFILT | 'N' | Modo "Sempre ativa" — participação por pertencimento, sem depender do filtro |
Modos de operação
A feature funciona de formas bastante diferentes dependendo do parâmetro ATIVACAMPANHAAPENASFILT.
Modo "Apenas Filtro" (padrão)
Este é o comportamento mais rico e é ativado por padrão (quando ATIVACAMPANHAAPENASFILT está ausente ou 'S').
A participação de um item na campanha é determinada no momento em que ele entra no carrinho:
| Situação ao adicionar o item | campaignLock gravado | Resultado |
|---|---|---|
| Filtro de campanha inativo | null | Item nunca participa de campanha |
| Filtro ativo com campanha específica | GRUPODESCPROD da campanha | Item participa dessa campanha |
| Filtro ativo em "Todas as campanhas" | 'ALL' | Item participa de qualquer campanha à qual pertença |
Uma vez travado, o vínculo persiste mesmo se o usuário desligar o filtro de campanha. Item com campaignLock mantém badge e desconto.
Modo "Sempre Ativa"
Ativado com ATIVACAMPANHAAPENASFILT === 'N'. O filtro de campanha na tela ainda existe (para navegar), mas:
- Participação é determinada exclusivamente pelo pertencimento (
PROMOCOES), não porcampaignLock. - Apenas a campanha selecionada no filtro é processada (não "Todas as campanhas").
- Sem campanha selecionada → nenhum desconto aplicado.
Fluxo de dados
Carregamento das campanhas
A lista bruta (não agrupada) é armazenada em state.salesCreateUtils.campaigns. O agrupamento por GRUPODESCPROD (para o filtro de nível 2 da Nova Venda) é feito em tempo de seleção pelo selector selectAvailableCampaigns.
Inclusão de item no carrinho (modo "Apenas filtro")
Recálculo de descontos
O recalculateCampaignDiscounts é disparado por 3 eventos via listenerMiddleware:
changeQuantity— qualquer alteração de quantidadesetProducts.fulfilled/fetchProductsSales.fulfilled— recarga da lista de produtossetSelectedCampaign/clearSelectedCampaign— troca de campanha no filtro
Elegibilidade de uma campanha
A elegibilidade é calculada pela função getCampaignEligibility e requer duas condições simultâneas:
- Todos os obrigatórios estão no carrinho com quantidade ≥ mínimo (
AD_QTDEOBRIG) - A faixa com desconto > 0 foi atingida pela soma das quantidades de todos os participantes
Semântica das faixas ("Qtd até X")
Faixas da campanha "Bebidas Mix":
┌──────────┬──────────┐
│ Qtd até │ % Desc. │
├──────────┼──────────┤
│ 2 │ 0% │ ← ainda não atingiu
│ 5 │ 10,00% │ ← soma de 3 a 5 unidades
│ 10 │ 16,63% │ ← soma de 6 a 10 unidades
└──────────┴──────────┘
Regra: primeira faixa (ascendente) cujo QTDE >= totalQty.
Se totalQty exceder a maior faixa, usa a maior.
No modo "apenas filtro", um produto obrigatório adicionado ao carrinho sem o filtro de campanha ativo recebe campaignLock = null. Ele não conta para a elegibilidade — mesmo que seja o único obrigatório faltando. O modal de detalhes reflete exatamente esse estado (não mostra "Ativa" incorretamente).
computeCampaignState — fonte única da verdade
Esta função é o coração da feature. Ela é usada identicamente pelo:
recalculateCampaignDiscounts(thunk de desconto)CampaignDetailsModal(modal de detalhes)
Isso garante que o carrinho e o modal nunca divirjam.
// src/utils/campaigns.utils.ts
computeCampaignState(
products: Product[],
campaign: PromotionCampaign,
allCampaigns: PromotionCampaign[],
onlyFilter: boolean
): CampaignComputation
Retorna:
| Campo | Descrição |
|---|---|
participants | Itens que participam da campanha (lock-aware) com QTDNEG>0 ou PERCDESC>0 |
cartParticipants | Subconjunto de participants com QTDNEG>0 |
obligatory | Produtos obrigatórios da campanha |
faixas | Faixas de desconto da campanha |
eligibility | Resultado de getCampaignEligibility |
Badge de campanha

Regra de prevalência: Campanha vs. Promoção
Na Nova Venda, quando um produto tem uma promoção convencional que pertence a uma campanha, o badge $ é suprimido:
A supressão do badge $ só ocorre na Nova Venda. No Catálogo, promoções de campanha continuam sendo exibidas normalmente como promoção convencional.
Lógica do hook useProductCampaignBadge
O hook é a fonte única da decisão de badge para a coluna "Campanha" da lista de produtos:
Modal de Detalhes da Campanha
O modal abre ao clicar no badge "Campanha" e exibe o estado em tempo real da campanha para o carrinho atual.

Status "Ativa / Inativa" é determinado pelo mesmo computeCampaignState do carrinho — não por dados brutos da API.
Coluna "Preço (R$)" é calculada com base no CODPROD do produto que abriu o modal, usando o preço base (VLRUNIT) desse produto.
Arquitetura de componentes
Responsabilidades dos arquivos-chave
| Arquivo | Responsabilidade |
|---|---|
src/utils/campaigns.utils.ts | Todas as funções puras — elegibilidade, lock, pertencimento, computação de estado |
src/selectors/campaigns/campaigns.selector.ts | Seletores Redux derivados; fonte única para componentes |
src/store/sale-create-utils.slice.ts | Estado de campanhas (campaigns, selectedCampaign); fetchCampaigns |
src/store/sale-create.slice.ts | applyCampaignDiscounts; recalculateCampaignDiscounts |
src/store/listenerMiddleware.ts | Reações a eventos — trava lock, dispara recálculo |
src/components/Campaign/CampaignBadge.tsx | Badge visual puro (botão rosa); sem lógica de negócio |
src/screens/.../Campaign/useProductCampaignBadge.ts | Lógica de badge — qual campanha mostrar |
src/screens/.../Campaign/NewSaleProductsCampaign.tsx | Célula da tabela — conecta badge + modal |
src/screens/.../Filter/CampaignDetailsModal.tsx | Modal de detalhes — usa computeCampaignState |
src/screens/.../Promotion/NewSalesPromotion.tsx | Suprime badge $ quando produto é de campanha |
Estrutura de dados
PromotionCampaign — linha bruta da API
interface PromotionCampaign {
NUPROMOCAO: number; // ID da promoção
GRUPODESCPROD: string; // Chave de agrupamento da campanha
AD_CODPROD?: number; // CODPROD do produto obrigatório (linha)
AD_QTDEOBRIG?: number; // Quantidade mínima do obrigatório
QTDPROMOCOES?: CampaignFaixa[]; // Faixas de desconto
// ... outros campos herdados de PromotionBase
}
A API retorna N linhas por campanha — uma por produto obrigatório. O front agrupa por GRUPODESCPROD via groupCampaignsByGroupDescProd para uso nos filtros, mas mantém a lista bruta para consolidar os obrigatórios.
campaignLock no Product
interface Product {
// ...
/**
* Vínculo de campanha travado na inclusão no carrinho (modo "apenas filtro",
* padrão — ATIVACAMPANHAAPENASFILT !== 'N').
* null → item adicionado sem filtro de campanha ativo; nunca participa.
* 'ALL' → item adicionado com "Todas as campanhas"; participa por pertencimento.
* GRUPODESCPROD → item adicionado com campanha específica selecionada.
*/
campaignLock?: string | null;
}
Filtro da lista de produtos
Na Nova Venda, ao ativar o filtro "Campanhas", a lista de produtos é filtrada:
| Item | Modo "Apenas filtro" | Modo "Sempre ativa" |
|---|---|---|
| No carrinho, com lock de campanha | Aparece | Aparece (por pertencimento) |
| No carrinho, sem lock | Some da lista | Aparece se pertencer |
| Fora do carrinho, pertence à campanha | Aparece (navegação) | Aparece |
| Fora do carrinho, não pertence | Some | Some |
Resolução de problemas
Badge "Campanha" não aparece para o produto
- Feature ativa? Verifique
ATIVACAMPANHA === 'S'no Sankhya ou se é o tenant hardcoded. - Produto pertence à campanha? Cheque se
product.PROMOCOEScontém a promoção com oNUPROMOCAOouGRUPODESCPRODcorreto. - Modo "apenas filtro" sem filtro ativo? No modo padrão, sem o filtro "Campanhas" ativo na tela e sem
campaignLock, nenhum badge aparece para itens fora do carrinho. - Item no carrinho sem vínculo? Se o item foi adicionado ao carrinho sem o filtro ativo,
campaignLock = nulle o badge nunca aparece — mesmo com o filtro ligado depois.
Modal mostra "Ativa" mas o carrinho não tem desconto (ou vice-versa)
Isso não deveria acontecer pois o modal usa exatamente computeCampaignState — a mesma função do recálculo. Se ocorrer, verificar:
allCampaignspassado ao modal é a lista bruta (selectRawCampaigns), não agrupada?- O
onlyFilterdo modal está lendo o mesmo parâmetroATIVACAMPANHAAPENASFILTdo slice? - O produto abriu o modal com o
campaigncorreto (não um snapshot desatualizado)?
Desconto não é aplicado apesar de todos os obrigatórios no carrinho
Verifique a semântica das faixas: a soma das quantidades deve superar o limiar da faixa com PERCDESC > 0. Faixas com PERCDESC === 0 são marcos de referência, não elegíveis.
Exemplo: faixas [qtd 2 → 0%] [qtd 5 → 10%]
- totalQty = 3 → faixa "Qtd até 5" → PERCDESC = 10% ✅ elegível
- totalQty = 1 → faixa "Qtd até 2" → PERCDESC = 0% ❌ inelegível
Produto obrigatório adicionado mas campanha inelegível
No modo "apenas filtro": se o produto obrigatório foi adicionado sem o filtro de campanha ativo, seu campaignLock = null. Ele não conta para elegibilidade. A solução é remover o item do carrinho e readicioná-lo com o filtro ativo.
Promoção convencional sumiu do produto
Comportamento esperado: o badge $ é suprimido na Nova Venda quando isProductInAnyCampaign retorna true para o produto. A promoção convencional existe (dados preservados), mas não é exibida visualmente porque a campanha tem prioridade. No Catálogo o badge $ segue aparecendo normalmente.
Cobertura de testes
A feature possui 75 testes distribuídos em 7 arquivos:
| Arquivo | Testes | O que cobre |
|---|---|---|
campaigns.selector.test.ts | 8 | selectCampaignOnlyWithFilter (S/N/ausente); selectIsCampaignFilterActive (array/string/vazio) |
recalculateCampaignDiscounts.test.ts | 7 | No-op sem filtro; aplica a todos os vinculados; obrigatório fora da campanha → limpa; item zerado → limpa; "Todas as campanhas" multi-campanha; no-op sem campanha selecionada (modo N); sempre-ativa por pertencimento |
useProductCampaignBadge.test.tsx | 7 | Item vinculado no carrinho mantém badge; sem vínculo → null; sem filtro → null; navegação fora do carrinho; lock ALL; modo N por pertencimento; feature desabilitada → null |
CampaignBadge.test.tsx | 2 | Renderização; onClick disparado corretamente |
campaigns.utils.test.ts | ~25 | Funções puras: getCampaignEligibility, selectCampaignFaixa, isCampaignParticipant, computeCampaignState |
Executar todos os testes da feature:
npx vitest run src/utils/campaigns.utils.test.ts src/selectors/campaigns/campaigns.selector.test.ts src/store/recalculateCampaignDiscounts.test.ts src/screens/nova-venda/components/Products/components/List/components/Campaign/useProductCampaignBadge.test.tsx src/components/Campaign/CampaignBadge.test.tsx
Fluxo completo — exemplo passo a passo
Cenário: Modo "Apenas filtro". Campanha "Bebidas Mix" exige 1x produto A e 1x produto B obrigatórios. Faixas: [qtd 2 → 0%] [qtd 5 → 10%].
Pendências conhecidas
Quando um produto possui promoção convencional regular e promoção de campanha simultaneamente, a campanha sempre vence na exibição (badge e desconto). Para clientes que queiram o comportamento inverso ou configurável, será necessário um parâmetro dedicado.
Estado atual: campanha sempre prevalece — implementado e funcionando para todos os clientes ativos. Quando implementar: ao identificar o primeiro cliente com necessidade do comportamento alternativo.
Referências
- Ticket: CALL-295
- Branch:
feature/CALL-295-implementar-campanhas-no-vidya-call - Arquivo de utilidades:
src/utils/campaigns.utils.ts - Seletores:
src/selectors/campaigns/campaigns.selector.ts - Slice auxiliar:
src/store/sale-create-utils.slice.ts
Changelog
| Versão | Data | Mudança |
|---|---|---|
| 1.0.0 | 2026-06-11 | Feature inicial completa — badge, modal redesenhado, elegibilidade lock-aware, computeCampaignState como fonte única da verdade, 75 testes |