← Voltar ao blog

Magento 2 Cart Price Rules: como criar uma condição customizada

Rodrigo Biazzi

As cart price rules cobrem muita coisa de fábrica: subtotal, quantidade, peso, método de envio, forma de pagamento, destino e atributos de produto dentro do carrinho. Mais cedo ou mais tarde o marketing pede algo que elas não cobrem, como "10% de desconto para clientes no primeiro pedido" ou "frete grátis se o cliente tiver conta empresarial". Você não precisa de uma extensão de promoções para isso. O Magento permite adicionar seu próprio tipo de condição ao editor de regras, e ela se comporta como as do core.

Este artigo mostra como adicionar uma condição customizada às cart price rules no Magento Open Source e no Adobe Commerce 2.4.8, e como mantê-la rápida, porque as conditions das regras rodam com mais frequência do que a maioria imagina.

Tente primeiro as opções nativas

Antes de escrever código, confira duas coisas.

Atributos de produto. Se a regra depende de um atributo de produto, defina "Use for Promo Rule Conditions" como Yes nas propriedades de storefront do atributo. O atributo passa a aparecer nas conditions "Product attribute combination" e "Products subselection" e na aba Actions. Sem código. O guia de atributos de produto mostra as outras flags que vale conferir.

Customer segments. No Adobe Commerce, customer segments podem ser usados como condição nas cart price rules, e os segments já cobrem histórico de pedidos, atributos do cliente e mais. Esse recurso existe só no Adobe Commerce. O Magento Open Source não tem segments, e é aí que uma condição customizada mais ajuda.

Como as conditions são montadas

A aba Conditions de uma cart price rule é uma árvore. A raiz é Magento\SalesRule\Model\Rule\Condition\Combine. O método getNewChildSelectOptions() dela monta o dropdown "Add condition" a partir das conditions de endereço, de produto e dos combines. Antes de retornar, ele dispara o evento salesrule_rule_condition_combine com um DataObject chamado additional. O que você colocar na chave conditions dele é mesclado ao dropdown.

Quando a regra é salva, a árvore é guardada como JSON na coluna conditions_serialized. Cada nó mantém o nome da classe PHP na chave type. Quando a regra é validada, o Magento reconstrói a árvore a partir desse JSON e chama validate() em cada nó, com o endereço da quote como model.

Então uma condição customizada precisa de duas coisas: um observer que a adicione ao dropdown e uma classe de condition que saiba se renderizar e se validar.

O observer

O dropdown só é montado no admin, então registre o observer em etc/adminhtml/events.xml:

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:Event/etc/events.xsd">
    <event name="salesrule_rule_condition_combine">
        <observer name="vendor_rules_add_order_count" instance="Vendor\Rules\Observer\AddOrderCountCondition"/>
    </event>
</config>
<?php
declare(strict_types=1);

namespace Vendor\Rules\Observer;

use Magento\Framework\Event\Observer;
use Magento\Framework\Event\ObserverInterface;
use Vendor\Rules\Model\Rule\Condition\OrderCount;

class AddOrderCountCondition implements ObserverInterface
{
    public function execute(Observer $observer): void
    {
        $additional = $observer->getEvent()->getData('additional');
        $conditions = (array) $additional->getConditions();

        $conditions[] = [
            'label' => __('Customer history'),
            'value' => [
                [
                    'value' => OrderCount::class . '|order_count',
                    'label' => __('Number of previous orders'),
                ],
            ],
        ];

        $additional->setConditions($conditions);
    }
}

Ler as conditions existentes primeiro é importante. Outros módulos escutam o mesmo evento, e sobrescrever o array remove as opções deles do dropdown. O formato de valor Class|attribute é o mesmo que as conditions de endereço do core usam.

A classe da condition

<?php
declare(strict_types=1);

namespace Vendor\Rules\Model\Rule\Condition;

use Magento\Framework\Model\AbstractModel;
use Magento\Rule\Model\Condition\AbstractCondition;
use Magento\Rule\Model\Condition\Context;
use Magento\Sales\Model\Order;
use Magento\Sales\Model\ResourceModel\Order\CollectionFactory;

class OrderCount extends AbstractCondition
{
    private array $countCache = [];

    public function __construct(
        Context $context,
        private readonly CollectionFactory $orderCollectionFactory,
        array $data = []
    ) {
        parent::__construct($context, $data);
    }

    public function loadAttributeOptions()
    {
        $this->setAttributeOption([
            'order_count' => __('Number of previous orders'),
        ]);

        return $this;
    }

    public function getInputType()
    {
        return 'numeric';
    }

    public function getValueElementType()
    {
        return 'text';
    }

    public function validate(AbstractModel $model)
    {
        $customerId = (int) $model->getQuote()?->getCustomerId();

        return $this->validateAttribute($this->getOrderCount($customerId));
    }

    private function getOrderCount(int $customerId): int
    {
        if ($customerId === 0) {
            return 0;
        }

        if (!isset($this->countCache[$customerId])) {
            $collection = $this->orderCollectionFactory->create();
            $collection->addFieldToFilter('customer_id', $customerId);
            $collection->addFieldToFilter('state', ['neq' => Order::STATE_CANCELED]);
            $this->countCache[$customerId] = $collection->getSize();
        }

        return $this->countCache[$customerId];
    }
}

Como o input type é numeric, o admin oferece os operadores numéricos: equals, is not, greater than, less than e as variantes com "or". Uma regra de "primeiro pedido" fica então "Number of previous orders equals 0". O validateAttribute() aplica o operador e o valor escolhidos no admin, então a classe só precisa retornar o número certo.

Nesta versão, visitantes sem login sempre têm contagem zero. Se pedidos de guest devem contar, busque por e-mail, mas saiba que o e-mail da quote costuma estar vazio até o cliente chegar ao checkout, então a regra pode mudar entre o carrinho e o checkout. Deixe esse comportamento explícito para o lojista.

Outras conditions feitas do mesmo jeito

Quando a primeira condition funciona, o padrão se repete com uma consulta diferente dentro do validate(). Algumas que acho úteis na prática:

  • Lifetime value do cliente, como uma condição numérica sobre a soma dos totais de pedidos anteriores.
  • Dias desde o último pedido, para campanhas de reativação.
  • Um atributo do cliente, como o tipo de conta B2B, como condição do tipo select. Sobrescreva getValueSelectOptions() para preencher o dropdown e retorne select nos dois métodos de input type.
  • A janela de horário atual, para promoções que só rodam em certas horas. Use o timezone da loja de Magento\Framework\Stdlib\DateTime\TimezoneInterface, não o relógio do servidor.

Performance

As conditions rodam toda vez que os totals são coletados, e isso acontece várias vezes por request no checkout. Um validate() lento soma rápido. Algumas regras:

  • Guarde em memória por request. A classe acima mantém as contagens em uma propriedade de array. Cada regra tem seu próprio objeto de condition, então quando várias regras usam a mesma consulta, mova o cache para um service compartilhado injetado na condition.
  • Use queries baratas. getSize() roda uma query COUNT em vez de carregar os pedidos. Garanta que as colunas usadas no filtro tenham índice. customer_id em sales_order tem.
  • Nunca chame APIs externas. Se o dado mora em outro sistema, sincronize para um atributo do cliente ou uma tabela local e leia de lá.
  • Restrinja o escopo da regra. O Magento pula a validação de regras que não estão ativas para o website e o customer group, então preencher esses campos corretamente evita rodar a sua condition.

Para ver o custo real, faça o profiling de um checkout com a sua regra ativa. Profiling com Blackfire mostra como.

Adicionando um atributo à condition de endereço existente

Às vezes o valor que você precisa já está no endereço da quote, ou pode ser colocado lá pelo seu código antes de as regras rodarem. Nesse caso basta um plugin em Magento\SalesRule\Model\Rule\Condition\Address::loadAttributeOptions(). Depois de chamar o método original, leia getAttributeOption(), adicione seu código e label e coloque de volta. O validate() do core na condition de endereço lê o atributo dos dados do endereço. Você também vai querer plugins para getInputType() e getValueElementType() se o input de string padrão não servir.

Isso mantém a opção no mesmo grupo "Cart Attribute" que o lojista já conhece, mas só funciona se o dado estiver no endereço antes de a coleta de descontos rodar.

Deploy e remoção

Como as regras guardam o nome da classe no banco, duas coisas pedem cuidado:

  1. Renomear a classe quebra toda regra salva que a usa. Se precisar renomear, escreva um data patch que reescreva os valores de type dentro de conditions_serialized e actions_serialized.
  2. Desabilitar o módulo enquanto regras ainda usam a condition causa erros quando essas regras carregam. Remova a condition de todas as regras antes. O artigo sobre remover um módulo com segurança cobre esse tipo de limpeza.

Escreva um teste de integração que crie uma regra com a sua condition, monte uma quote para um cliente com e sem pedidos anteriores e verifique que o desconto só se aplica quando deve. Ele pega erros na lógica do operador rapidamente.

FAQ

Posso usar uma condition customizada na aba Actions de uma cart price rule?

A aba Actions usa conditions de produto, que validam cada item do carrinho. Uma condition customizada em nível de item é construída de forma parecida, mas na condition combine de produto em vez da combine de endereço.

Por que minha condition não aparece no dropdown?

Confira se o observer está registrado para salesrule_rule_condition_combine na área adminhtml ou global, se ele mantém as conditions existentes, e limpe o cache depois do deploy.

Uma condition customizada funciona com cupons?

Funciona. O cupom só decide se a regra é considerada. Sua condition é validada como qualquer outra depois que o cupom é aplicado.

Uma condition customizada é lenta?

Só se o validate for lento. Ele roda a cada coleta de totals, então guarde resultados em cache por request e evite carregar collections inteiras ou chamar serviços externos.

Atendo como freelancer em Magento e Adobe Commerce, incluindo promoções e lógica de cart rules. Se precisar de ajuda com isso, fale comigo.

Artigos relacionados