Drupal Commerce: building Commerce Discount rules based on node categories

This is a follow-up post on Where to put category field in Drupal Commerce, where I was trying to show up pros and cons about assigning taxnomy terms in Drupal Commerce either to nodes or commerce_product entities. Whereas nodes are commonly to prefer, I mentioned that you may run into big problems, if you want to use the Commerce Discount module for easier discount administration and define discount rules based on categories. Out of the box, you only can build discount rules based on taxonomy terms that are directly set on the product entities, not on their product displays. This restriction is obvious because finding a generic solution for that would be far more complicated. But as this module is well architectured and designed to be extendable, this should not embarass us. I'll show you, how we have built our own Discount rules to support discounts by taxnomy terms of product display nodes.

first some words about the set up

We have done this on a recent project, which has some special characteristics: the shop consists of three areas that are fully independent from each other in terms of content, search, etc. But the behaviour and functionality is the same. I am referring to them as "shop contexts". In many parts of our custom functions we have the current shop context as function parameter, etc. We don't have a single global category taxonomy vocabulary, instead we have one per each of the three shop contexts. In a addition to categories, we also have brands. There is only one single brands vocabulary, as a brand may be used in different shop contexts - instead there is a field for setting the shop context(s) in this vocabulary. That's why we ended up in defining six different conditions - for each shop context one especially for categories and one for brands. I'll pick out one of them and present it in this post. That's just some background info, if you wonder, what this shop context stuff mean in the code. For a simpler shop setup, you can skip that parts and simplify code even more.

Solution process

Basically we have to do three things:

  1. Implement our own Inline condition(s)
  2. Implement our own Rules condition(s) including bulid callback
  3. Implement a database query for getting the terms indirectly referenced by a given product id (via its node display)

Building our Inline Condition(s)

First, we implement hook_inline_conditions_info() to define our Inline condition and its configuration callback:


/**
 * Implements hook_inline_conditions_info().
 */
function agorashop_inline_conditions_info() {
  $conditions = array();
  
  $conditions['agorashop_product_has_cosmetic_categories'] = array(
    'label' => t('Categories (cosmetic)'),
    'entity type' => 'commerce_line_item',
    'callbacks' => array(
      'configure' => 'agorashop_product_has_cosmetic_categories_configure',
      'build' => 'agorashop_product_has_related_categories_build',
    ),
  );
  
  return $conditions;
}

/**
 * Configuration callback for agorashop_product_has_cosmetic_categories on product.
 *
 * @param array $settings
 *   Values for the form element.
 *
 * @return array
 *   Return a form element.
 */
function agorashop_product_has_cosmetic_categories_configure($settings) {
  return _agorashop_product_has_related_categories_configure($settings, AGORASHOP_CONTEXT_COSMETIC, FALSE);
}

function _agorashop_product_has_related_categories_configure($settings, $shop_context, $brand_mode) {
  $form = array();

  $default_value = '';
  if (!empty($settings['terms'])) {
    foreach ($settings['terms'] as $delta => $term) {
      $default_value .= taxonomy_term_load($term['target_id'])->name . ' (' . $term['target_id'] . ')';
      if ($term != end($settings['terms'])) {
        $default_value .= ', ';
      }
    }
  }
  
  $brand_mode = $brand_mode ? 1 : 0;
  
  $form['terms'] = array(
    '#type' => 'textfield',
    '#title' => t('Terms'),
    '#title_display' => 'invisible',
    '#required' => TRUE,
    '#maxlength' => 4096,
    '#default_value' => $default_value,
    '#autocomplete_path' => "agorashop/autocomplete/$shop_context/$brand_mode",
    '#element_validate' => array('_agorashop_autocomplete_validate'),
    '#suffix' => '
' . t('The discount is active if the product has the selected term(s).') . '
', ); return $form; }

I will not include the autocomplete callback from above, as this was only necessary to implement to be able to differentiate between shop contexts and categories/brands. For less complex setups, you can re-use the inline_conditions_autocomplete_callback() function of the Inline Condition module.

Next, we also implement hook_inline_conditions_build_alter() in order to re-populate the autocomplete field from previously set terms (convert from array to comma separated list):



/**
 * Implements hook_inline_conditions_build_alter().
 */
function agorashop_inline_conditions_build_alter(&$value) {
  switch ($value['condition_name']) {
    case 'agorashop_product_has_cosmetic_categories':
      $terms = $value['condition_settings']['terms'];

      $value['condition_settings']['terms'] = '';
      foreach ($terms as $term) {
        $value['condition_settings']['terms'] .= reset($term);
        if ($term !== end($terms)) {
          $value['condition_settings']['terms'] .= ', ';
        }
      }
      break;
  }
}

Build our Rules condition(s)

Next, we implement hook_rules_condition_info(). That will define the Rules condition, that will be created by Inline Condition module according to the definition we have done above.

/**
 * Implements hook_rules_condition_info().
 */
function agorashop_rules_condition_info() {
  $inline_conditions = inline_conditions_get_info();
  $conditions = array();

  if (module_exists('taxonomy') && module_exists('commerce_product')) {
    $conditions['agorashop_product_has_cosmetic_categories'] = array(
      'label' => t('Line item product contains specific cosmetic categories'),
      'parameter' => array(
        'commerce_line_item' => array(
          'type' => 'commerce_line_item',
          'label' => t('Line item'),
          'description' => t('The line item.'),
          'wrapped' => TRUE,
        ),
        'terms' => array(
          'type' => 'text',
          'label' => t('Terms ID'),
          'description' => t('Enter a comma-separated list of term ID to compare against the node display of the passed product line item.'),
        ),
      ),
      'module' => 'inline_conditions',
      'group' => t('Commerce Line Item'),
      'callbacks' => array(
        'execute' => $inline_conditions['agorashop_product_has_cosmetic_categories']['callbacks']['build'],
      ),
    );
  }

  return $conditions;
}

As you can see, the execute callback of this Rule condition is setting the previously defined build callback of the inline condition. This is an important step. Apropos build callback, this function is still missing - here it is:

/**
 * Build callback for agorashop_product_has_cosmetic_categories on product.
 *
 * @param EntityDrupalWrapper $wrapper
 *   Wrapped entity type given by the rule.
 * @param array $terms
 *   Values for the condition settings.
 *
 * @return bool
 *   True is condition is valid. false otherwise.
 */
function agorashop_product_has_related_categories_build(EntityDrupalWrapper $wrapper, $terms) {
  $terms_name = explode(', ', $terms);

  if (in_array('commerce_product', array_keys($wrapper->getPropertyInfo()))) {
    $product_id = $wrapper->commerce_product->product_id->value();
    $related_tids = agorashop_get_related_term_ids_by_product_id($product_id);
    $terms_name = array_diff($terms_name, $related_tids);
  }

  return empty($terms_name);
}

This function takes the entity and terms to check (provided by Rules) and first checks, if the given entity is a commere_product entity anyway. If yes, it queries the related taxonomy term ids of the given product (call to agorashop_get_related_term_ids_by_product_id(), which will be explained later). By calling array_diff() we can check, if all of our required terms are matched. If the result of array_diff() is empty, our condition is met.

Implement a database query for getting by a given product id (via its node display)

From a given product id, we need to query all term ids, that are referenced by the product's display node. Optionally, we also include the parent terms (this paramter defaults to TRUE):

/**
 * Returns an array of taxonomy_term IDs of product display nodes that references the given product_id.
 */
function agorashop_get_related_term_ids_by_product_id($product_id, $include_parents = TRUE) {
  $tids = array();
  
  // We don't use EFQ but more low-level querying to avoid too much DB joins in this query
  
  $query = db_select('taxonomy_index', 'ti');
  $query->fields('ti', array('tid'));
  if ($include_parents) {
    $query->join('taxonomy_term_hierarchy', 'tth', "ti.tid = tth.tid");
    $query->fields('tth', array('parent'));
  }
  $query->join('field_data_field_product', 'fdfp', "fdfp.entity_type ='node' AND fdfp.entity_id = ti.nid");
  $query->condition('fdfp.field_product_product_id', $product_id);
  
  $result = $query->execute()->fetchAllAssoc('tid');
  if (!empty($result)) {
    if ($include_parents) {
      foreach ($result as $tid => $row) {
        $tids[$tid] = $tid;
        if ($row->parent) {
          $tids[$row->parent] = $row->parent;
        }
      }
      $tids = array_keys($tids);
    } else {
      $tids = array_keys($result);
    }
  }
  
  return $tids;
}

Note, that we don't use EntityFieldQuery here, as this would get too complex and would result in a far more heavy-weight query in this case. First, the hierarchy part would be a problem with EFQ. Furthermore, we decided to directly query the product reference field table, relinquishing additional joins via the node table.

Attention: your product reference field name may vary from this example. Adjust the query, if necessary (field_data_field_product, field_product_product_id)!

Ready!

That's it! It was worth the trouble, because now we can easily define discounts with Commerce Discount based on taxnomy terms of product display nodes. What do you think? Do like this solution? Tell us in your comment :)

Kommentare

Thank you very much for sharing this. I implemented the code in a site for a client.

I took the opportunity to make a module out of it that is usable in any site. It still needs cleaning up and caching needs to be added, but I think it would be worth sharing on drupal.org. 

If you (or anyone else) is interested in this, you can find the module here: https://dl.dropboxusercontent.com/u/7553560/Drupal/commerce_discount_nod...

- Maarten De Block
https://drupal.org/user/358753

I'm happy that this post helped you with your project because that's the power that makes the Drupal community so great :)

I was already playing with the idea of to make a general-purpose module out of it, but I didn't have the time so far. If you want to apply for a drupal.org module, I will try my best to support you, if you need help (or write a review, etc).

I took a quick look at your module. It looks very good. At first sight, I've found two things that should be corrected:

  • The product reference field's name is hardcoded to "field_data_field_product"
  • The functions still have names with "has_cosmetic_categories" in it, which was part of my project to only query categories of beauty care products. You should rename these things to "has_related_categories" or similar...

Hey guys. 

First of all, thank you for this excellent post about how to get discount on taxonomies.

I tried downloading the module that was made, afterwards i edited the 2 points u told him that needed editing.

When i activate the module and set a discount on a sale category, it works brilliant. But the discount dissapears from the cart step and onwards in the checkout.

I tried doing this both with the unedited module and the edited. Do you have any idea why? i figured out you got it working on everything since u said u had it on a client website.

 

Regards Rene

Hi Rene,

I didn't experience these problems yet. I would recommend to first check, if this is really related to the category-based discounts from our module, or if it happens to all discounts. I would try setting a different dicount rule, e.g. on a specific SKU. Do you experience the same problem? If yes, check all your enabled Rules, if something's kicking out your discounts. Check your Commerce-related custom hook implementations, if there are any...

Hi !

Thanks for nice  article. I activated the modules but did not know where to set discount for category.

can you please  guide how to set discount on category ?

 

 Guidance appreciated .

Regards

 

Hi John,

You should see them on the add discount page: admin/commerce/store/discounts/add. Choose "Product discount" as discount type, and you should see it in the "Apply to" list.

If it's not in there, try to clear the Drupal cache. If it's still not there, the module may not work as expected - maybe you may have overseen some steps or have some copy & paste errors, etc

But the good news is, that in the meantime someone has created a Drupal module, that essentially does the same as my example: https://www.drupal.org/project/commerce_discount_product_category

I recommend using that module instead of creating a similar one by yourself

Neuen Kommentar schreiben