Pular para o conteúdo principal

Campanhas de Produtos

Resumo executivo

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:

MecanismoCondiçãoRequer deploy?
Parâmetro SankhyaATIVACAMPANHA === 'S'Não
Tenant hardcodedisCertoDistribuidora() em permission.tsSim
// src/utils/campaigns.utils.ts
export function isCampaignsFeatureEnabled(permission: any): boolean {
return (
permission?.ATIVACAMPANHA === "S" ||
permission?.permissionByCompany?.isCertoDistribuidora?.() === true
);
}
Habilitar para novos clientes sem deploy

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.

Desabilitar

Remova ou altere ATIVACAMPANHA para qualquer valor diferente de 'S'. Para tenants hardcoded, é necessário deploy.


Parâmetros de configuração

ParâmetroValoresEfeito
ATIVACAMPANHA'S' / ausenteLiga / desliga a feature inteira
ATIVACAMPANHAAPENASFILTausente 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 itemcampaignLock gravadoResultado
Filtro de campanha inativonullItem nunca participa de campanha
Filtro ativo com campanha específicaGRUPODESCPROD da campanhaItem 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 por campaignLock.
  • 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:

  1. changeQuantity — qualquer alteração de quantidade
  2. setProducts.fulfilled / fetchProductsSales.fulfilled — recarga da lista de produtos
  3. setSelectedCampaign / clearSelectedCampaign — troca de campanha no filtro

Elegibilidade de uma campanha

A elegibilidade é calculada pela função getCampaignEligibility e requer duas condições simultâneas:

  1. Todos os obrigatórios estão no carrinho com quantidade ≥ mínimo (AD_QTDEOBRIG)
  2. 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.
Obrigatório incluído fora da campanha

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:

CampoDescrição
participantsItens que participam da campanha (lock-aware) com QTDNEG>0 ou PERCDESC>0
cartParticipantsSubconjunto de participants com QTDNEG>0
obligatoryProdutos obrigatórios da campanha
faixasFaixas de desconto da campanha
eligibilityResultado de getCampaignEligibility

Badge de campanha

Badge de campanha: exemplo de suprimido na Nova Venda

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:

Catálogo não é afetado

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:


O modal abre ao clicar no badge "Campanha" e exibe o estado em tempo real da campanha para o carrinho atual.

Exemplo de Campanha na Nova Venda

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

ArquivoResponsabilidade
src/utils/campaigns.utils.tsTodas as funções puras — elegibilidade, lock, pertencimento, computação de estado
src/selectors/campaigns/campaigns.selector.tsSeletores Redux derivados; fonte única para componentes
src/store/sale-create-utils.slice.tsEstado de campanhas (campaigns, selectedCampaign); fetchCampaigns
src/store/sale-create.slice.tsapplyCampaignDiscounts; recalculateCampaignDiscounts
src/store/listenerMiddleware.tsReações a eventos — trava lock, dispara recálculo
src/components/Campaign/CampaignBadge.tsxBadge visual puro (botão rosa); sem lógica de negócio
src/screens/.../Campaign/useProductCampaignBadge.tsLógica de badge — qual campanha mostrar
src/screens/.../Campaign/NewSaleProductsCampaign.tsxCélula da tabela — conecta badge + modal
src/screens/.../Filter/CampaignDetailsModal.tsxModal de detalhes — usa computeCampaignState
src/screens/.../Promotion/NewSalesPromotion.tsxSuprime 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:

ItemModo "Apenas filtro"Modo "Sempre ativa"
No carrinho, com lock de campanhaApareceAparece (por pertencimento)
No carrinho, sem lockSome da listaAparece se pertencer
Fora do carrinho, pertence à campanhaAparece (navegação)Aparece
Fora do carrinho, não pertenceSomeSome

Resolução de problemas

Badge "Campanha" não aparece para o produto

  1. Feature ativa? Verifique ATIVACAMPANHA === 'S' no Sankhya ou se é o tenant hardcoded.
  2. Produto pertence à campanha? Cheque se product.PROMOCOES contém a promoção com o NUPROMOCAO ou GRUPODESCPROD correto.
  3. 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.
  4. Item no carrinho sem vínculo? Se o item foi adicionado ao carrinho sem o filtro ativo, campaignLock = null e o badge nunca aparece — mesmo com o filtro ligado depois.

Isso não deveria acontecer pois o modal usa exatamente computeCampaignState — a mesma função do recálculo. Se ocorrer, verificar:

  1. allCampaigns passado ao modal é a lista bruta (selectRawCampaigns), não agrupada?
  2. O onlyFilter do modal está lendo o mesmo parâmetro ATIVACAMPANHAAPENASFILT do slice?
  3. O produto abriu o modal com o campaign correto (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:

ArquivoTestesO que cobre
campaigns.selector.test.ts8selectCampaignOnlyWithFilter (S/N/ausente); selectIsCampaignFilterActive (array/string/vazio)
recalculateCampaignDiscounts.test.ts7No-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.tsx7Item 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.tsx2Renderização; onClick disparado corretamente
campaigns.utils.test.ts~25Funçõ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

Parâmetro de prioridade campanha × promoção (decisão futura)

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ãoDataMudança
1.0.02026-06-11Feature inicial completa — badge, modal redesenhado, elegibilidade lock-aware, computeCampaignState como fonte única da verdade, 75 testes