DEV Community

Chris Brabender
Chris Brabender

Posted on

Custom Segmentation Condition in Adobe Commerce

Intro

Adobe Commerce (fka Magento Commerce, fka Magento Enterprise Edition) gives administrators the ability to create dynamic Customer Segments based on a predetermined set of available conditions. The condition builder is the same familiar interface as Catalog / Shopping Cart Price rule conditions allowing for combining multiple conditions, different input types and more flexibility.

Condition Builder image

Adobe Commerce User Guide Documentation for Customer Segments available here

This is great for data that is already available (order history, products in cart, customer attributes etc) but there is not necessarily an easy way to create a new condition based on a custom column or data source.


Example Requirement

We have a client that uses a store locator module that allows a guest or customer to select a Favourite Store for their session. Given this selection, we want to create a segment based on this and display an appropriate store banner to the user.

What is important to note in this example is that we need this to work for both Guest and Registered Customers, this eliminates customer attributes as our source of information, we'll need to reference this elsewhere.


How we solved it

It actually wasn't that difficult in the end to implement a custom condition! All it took was was a custom module with 5 files (including registration.php and module.xml) to achieve a custom segmentation condition.

Getting your condition in the list

The first thing you need to look at is where you want your condition to show up in the list, as you need to create a plugin. Looking through the conditions you will see an asterisks (*) next to several of them, this indicates the condition is applicable to both Visitor and Registered Customers so we opted to add to one of these, the Shopping Cart one.

You will need to create a plugin like below:

<type name="Magento\CustomerSegment\Model\Segment\Condition\Shoppingcart">
    <plugin name="myvendor_mymodule_segmentcondition_add_custom_customersegment_condition"
            type="MyVendor\MyModule\Plugin\CustomerSegment\Model\Segment\Condition\ShoppingcartPlugin" />
</type>
Enter fullscreen mode Exit fullscreen mode

Your ShoppingcartPlugin file should receive the array of results from the Shoppingcart conditions and add a new one to the array:

class ShoppingcartPlugin
{
    public function afterGetNewChildSelectOptions(
        \Magento\CustomerSegment\Model\Segment\Condition\Shoppingcart $subject,
        array $result
    ): array
    {
        $conditions = [
            'value' => [
                [
                    'value' => \Magento\CustomerSegment\Model\Segment\Condition\StoreLocator\SelectedStore::class,
                    'label' => __('Selected Store'),
                    'available_in_guest_mode' => true
                ]
            ]
        ];

        return array_merge_recursive($result, $conditions);
    }
}
Enter fullscreen mode Exit fullscreen mode

Our new condition should now show up in this list like so

Our new condition showing in the list


The Condition

Important

First we need to talk about the naming of the class above. As you might have seen we reference a class called \Magento\CustomerSegment\Model\Segment\Condition\StoreLocator\SelectedStore, but this is not in our namespace, so how does this work?

Well, if you look at the ConditionFactory class in the CustomerSegment module, Magento has placed a restriction on the class names that can be used to generate a condition:

$classNamePrefix = 'Magento\CustomerSegment\Model\Segment\Condition\\';
if (false === strpos($className, $classNamePrefix)) {
    $className = $classNamePrefix . $className;
}
$condition = $this->_objectManager->create($className, $data);
Enter fullscreen mode Exit fullscreen mode

This means anything in our namespace fails to load (an exception is thrown), so we use a virtualType to get around this. In our di.xml file we can add:

<virtualType name="Magento\CustomerSegment\Model\Segment\Condition\StoreLocator\SelectedStore"
     type="MyVendor\MyModule\Model\Condition\StoreLocator\SelectedStore" />
Enter fullscreen mode Exit fullscreen mode
The class

I am going to suggest referring to one of the existing condition classes in the CustomerSegment module as inspiration here, for example: vendor\magento\module-customer-segment\Model\Segment\Condition\Shoppingcart\Itemsquantity.php

Assuming you are going to copy+paste this class, we need to change a few things to get it to work for our setup. First up, the type in the contsructor needs to be adjusted:

public function __construct(
    \Magento\Rule\Model\Condition\Context $context,
    \Magento\CustomerSegment\Model\ResourceModel\Segment $resourceSegment,
    \Magento\Quote\Model\ResourceModel\Quote $quoteResource,
    array $data = []
) {
    parent::__construct($context, $resourceSegment, $data);
    $this->setType(\Magento\CustomerSegment\Model\Segment\Condition\StoreLocator\SelectedStore::class);
    $this->setValue(null);
    $this->quoteResource = $quoteResource;
}
Enter fullscreen mode Exit fullscreen mode

We then need to define which events are relevant for us, we'll create a new one.

public function getMatchedEvents()
{
    return ['store_locator_store_changed'];
}
Enter fullscreen mode Exit fullscreen mode

You'll want to update the select options and html output so the strings match what you are trying to create here, these are used once an administrator has selected the condition from the dropdown and are setting their requirements.

public function getNewChildSelectOptions(): array
{
    return [
        'value' => $this->getType(),
        'label' => __('Selected customer Store'),
        'available_in_guest_mode' => true
    ];
}

public function asHtml()
{
    return $this->getTypeElementHtml() . __(
        'Selected Customer Store %1 %2:',
        $this->getOperatorElementHtml(),
        $this->getValueElementHtml()
    ) . $this->getRemoveLinkHtml();
}
Enter fullscreen mode Exit fullscreen mode

Our html output for entering our conditions

SQL Matching

CustomerSegment conditions use SQL queries to select visitors/customers that match the defined rules. To allow our Selected Store to be selected via SQL, we have added a column to the quote table where we store the currently selected store. This means we can re-use the queries from the Itemsquantity condition with only a small tweak to query from our column, instead of the qty one.

You'll notice in the below snippet that $select->where("quote.store_locator_store {$operator} ?", $this->getValue()); is basically the only change we've made. We are keeping the {$operator} value there to ensure that standard rule configuration (equals, does not equal, is in, contains etc) work as expected.

public function getConditionsSql($customerId, $website, $isFiltered = true)
{
    $table = $this->getResource()->getTable('quote');
    $operator = $this->getResource()->getSqlOperator($this->getOperator());

    $select = $this->getResource()->createSelect();
    $select->from(['quote' => $table], [new \Zend_Db_Expr(1)])->where('quote.is_active=1');
    $this->_limitByStoreWebsite($select, $website, 'quote.store_id');
    $select->limit(1);
    $select->where("quote.store_locator_store {$operator} ?", $this->getValue());
    if ($customerId) {
        // Leave ability to check this condition not only by customer_id but also by quote_id
        $select->where('quote.customer_id = :customer_id OR quote.entity_id = :quote_id');
    } else {
        $select->where($this->_createCustomerFilter($customerId, 'quote.customer_id'));
    }

    return $select;
}

private function executeSql($customer, $websiteId, $params, $isFiltered = true)
{
    $table = $this->getResource()->getTable('quote');
    $operator = $this->getResource()->getSqlOperator($this->getOperator());

    $select = $this->getResource()->createSelect();

    if ($isFiltered) {
        $select->from(['quote' => $table], [new \Zend_Db_Expr(1)])->where('quote.is_active=1');
        $select->limit(1);
    } else {
        $select->from(['quote' => $table], ['customer_id'])->where('quote.is_active=1');
    }
    $select->where(
        'quote.store_id IN(?)',
        $this->getStoreByWebsite($websiteId)
    );

    $select->where("quote.store_locator_store {$operator} ?", $this->getValue());
    if ($isFiltered) {
        // Leave ability to check this condition not only by customer_id but also by quote_id
        $contextFilter = ['quote.entity_id = :quote_id'];
        if (!empty($params['customer_id'])) {
            $contextFilter[] = 'quote.customer_id = :customer_id';
        }
        $select->where(implode(' OR ', $contextFilter));
    } else {
        $select->where('customer_id IS NOT NULL');
    }
    $matchedParams = $this->matchParameters($select, $params);
    $result = $this->quoteResource->getConnection()->fetchCol($select, $matchedParams);
    return $result;
}
Enter fullscreen mode Exit fullscreen mode

Now, we can save our new segment in the admin panel and trigger a segment data refresh (assuming we have populated a store in the quotes table).

NOTE: Segment Data Refresh is queue based so you'll need to run the following queue:consumer command after you have clicked "Refresh Segment Data"

php bin/magento queue:consumers:start matchCustomerSegmentProcessor

Once that has processed, you should see your matched customer in the grid! NOTE: The grid will only show Registered customers, this does not mean it's not working for Visitors (they just won't even show here).

Screenshot 2021-06-21 132701


Keeping our matched users up to date

Now that we have our condition all built, we need to make sure that when a user (Visitor or Registered Customer) changes their store, they are added to the correct segment. This is handled via events, you can see the standard events Magento listens to here vendor\magento\module-customer-segment\etc\frontend\events.xml

We have a couple of ways we can implement our own, the simplest could be to dispatch our store_locator_store_changed (we defined earlier) when the customer changes their selected store and then listen to that event, sending it to the ProcessEventObserver class such as:

<event name="store_locator_store_changed">
    <observer name="mymodule_customersegment_store_changed" instance="Magento\CustomerSegment\Observer\ProcessEventObserver"/>
</event>
Enter fullscreen mode Exit fullscreen mode

Bonus tips

Re-save your segment when changing SQL

Segment SQL queries are saved in to the magento_customersegment_segment table when you create the segment, this means if you are in development of your new condition and have changed the SQL in the Class, you'll need to re-save the segment in the admin to pull down and save your latest SQL changes. You may not see your matched customers reflected correctly until you do the save.

Experiment

Segment conditions have A LOT of options. For example, we are just using a basic numeric input type here but you can use a select here instead and populate it with a list of your Stores from a data model

protected $_inputType = 'numeric';
Enter fullscreen mode Exit fullscreen mode

You can also create a Combine condition, which is more complicated in its setup but also in its capabilities.

Top comments (1)

Collapse
 
vpodorozh profile image
Vladyslav Podorozhnyi πŸ‡ΊπŸ‡¦ 🌻

Hi there :)

Have a question! Why does \Magento\CustomerSegment\Model\Segment\Condition\StoreLocator\SelectedStore::class work if it is only virtualType ?

Shouldn't it be real PHP class to be able to run ::class ?

Thank you!