Magento 2 Cart Price Rules: como criar uma condição customizada
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 retorneselectnos 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 queryCOUNTem vez de carregar os pedidos. Garanta que as colunas usadas no filtro tenham índice.customer_idemsales_ordertem. - 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:
- Renomear a classe quebra toda regra salva que a usa. Se precisar renomear, escreva um data patch que reescreva os valores de
typedentro deconditions_serializedeactions_serialized. - 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
Shipping method customizado no Magento 2: crie um módulo de carrier
Como criar um shipping method customizado no Magento 2.4: config.xml, system.xml, um carrier model com collectRates, regras de frete grátis e APIs externas de tarifa.
Método de pagamento customizado no Magento 2 com a Payment Gateway API
Crie um método de pagamento customizado no Magento 2.4 com a Payment Gateway API: virtual type de facade, config, commands e o renderer JS do checkout, passo a passo.
Taxa customizada no Magento 2: um totals collector que funciona
Adicione uma taxa customizada no Magento 2 com um quote totals collector: sales.xml, collect e fetch, exibição no carrinho e checkout, cópia para pedidos, invoices e PDFs.
Magento 2 Page Builder: criando um content type customizado
Passo a passo de um content type customizado do Page Builder no Magento 2.4: XML do content type, formulário, templates de preview e master, saída no storefront e armadilhas.