commerce_discount_extra.module in Commerce Discount Extra 7
Provides necessary inline conditions and support for extra discounts.
File
commerce_discount_extra.moduleView source
<?php
/**
* @file
* Provides necessary inline conditions and support for extra discounts.
*/
/**
* Implements hook_menu().
*/
function commerce_discount_extra_menu() {
$items = array();
$items['commerce_discount_extra/categories_autocomplete/%'] = array(
'title' => 'Product categories autocomplete',
'page callback' => 'commerce_discount_extra_offer_categories_autocomplete_callback',
'page arguments' => array(
2,
3,
),
'access callback' => TRUE,
'type' => MENU_CALLBACK,
);
return $items;
}
/**
* Implements hook_inline_conditions_info().
*/
function commerce_discount_extra_inline_conditions_info() {
// User has role: line item.
$conditions['commerce_discount_extra_line_item_user_has_role'] = array(
'label' => t('Role'),
'entity type' => 'commerce_line_item',
'callbacks' => array(
'configure' => 'commerce_discount_extra_user_has_role_configure',
'build' => 'commerce_discount_extra_user_has_role_build',
),
);
// User has role: order.
$conditions['commerce_discount_extra_order_user_has_role'] = array(
'label' => t('Role'),
'entity type' => 'commerce_order',
'callbacks' => array(
'configure' => 'commerce_discount_extra_user_has_role_configure',
'build' => 'commerce_discount_extra_user_has_role_build',
),
);
// Product price: line item.
$conditions['commerce_discount_extra_line_item_price_comp'] = array(
'label' => t('Product price'),
'entity type' => 'commerce_line_item',
'callbacks' => array(
'configure' => 'commerce_discount_extra_line_item_price_comp_configure',
'build' => 'commerce_discount_extra_line_item_price_comp_build',
),
);
// Total items in order: line item.
$conditions['commerce_discount_extra_line_item_items_in_order'] = array(
'label' => t('Total items in order'),
'entity type' => 'commerce_line_item',
'callbacks' => array(
'configure' => 'commerce_discount_extra_items_in_order_configure',
'build' => 'commerce_discount_extra_items_in_order_build',
),
);
// Total items in order: order.
$conditions['commerce_discount_extra_order_items_in_order'] = array(
'label' => t('Total items in order'),
'entity type' => 'commerce_order',
'callbacks' => array(
'configure' => 'commerce_discount_extra_items_in_order_configure',
'build' => 'commerce_discount_extra_items_in_order_build',
),
);
// Products and quantity: line item.
$conditions['commerce_discount_extra_line_item_has_specific_quantity_products'] = array(
'label' => t('Order has product(s) and quantity'),
'entity type' => 'commerce_line_item',
'callbacks' => array(
'configure' => 'commerce_order_has_specific_quantity_products_configure',
'build' => 'commerce_discount_extra_line_item_has_specific_quantity_products_build',
),
);
// Order total: line item.
$conditions['commerce_discount_extra_line_item_compare_order_amount'] = array(
'label' => t('Order amount'),
'entity type' => 'commerce_line_item',
'callbacks' => array(
'configure' => 'commerce_order_compare_order_amount_configure',
'build' => 'commerce_discount_extra_line_item_compare_order_amount_build',
),
);
// Product type: line item.
$conditions['commerce_discount_extra_line_item_has_product_type'] = array(
'label' => t('Product type'),
'entity type' => 'commerce_line_item',
'callbacks' => array(
'configure' => 'commerce_discount_extra_line_item_has_product_type_configure',
'build' => 'commerce_discount_extra_line_item_has_product_type_build',
),
);
return $conditions;
}
/**
* Inline condition build callback: compare line item product type.
*
* @param EntityDrupalWrapper $line_item_wrapper
* The wrapped entity given by the rule.
* @param array $product_types
* Array of product types.
*
* @return bool
* Returns TRUE if condition is valid, FALSE otherwise.
*/
function commerce_discount_extra_line_item_has_product_type_build(EntityDrupalWrapper $line_item_wrapper, $product_types) {
if (in_array($line_item_wrapper
->getBundle(), commerce_product_line_item_types()) && in_array($line_item_wrapper->commerce_product
->getBundle(), $product_types)) {
return TRUE;
}
return FALSE;
}
/**
* Inline condition build callback: compare order total for line items.
*
* @param EntityDrupalWrapper $line_item_wrapper
* The wrapped entity given by the rule.
* @param string $operator
* The comparison operator.
* @param array $total
* A commerce_price type array.
*
* @return bool
* Returns TRUE if condition is valid, FALSE otherwise.
*/
function commerce_discount_extra_line_item_compare_order_amount_build(EntityDrupalWrapper $line_item_wrapper, $operator, $total, $line_item_types) {
module_load_include('inc', 'commerce_discount', 'commerce_discount.rules');
if ($line_item_wrapper->order
->value() && $line_item_wrapper->order->order_id
->value()) {
$order = commerce_order_load($line_item_wrapper->order->order_id
->value());
if ($order) {
$order_wrapper = entity_metadata_wrapper('commerce_order', $order);
// Need to do this to trigger the total field update.
$line_item_wrapper
->save();
// Run the order amount comparison function.
return commerce_order_compare_order_amount_build($order_wrapper, $operator, $total, $line_item_types);
}
}
return FALSE;
}
/**
* Inline condition build callback: Order contains products.
*
* Determine whether the order associated with a line item, either by reference
* or by user's current cart, contains all of a list of skus.
*
* @param EntityDrupalWrapper $line_item_wrapper
* The wrapped entity given by the rule.
* @param array $skus
* Array of product SKUs given by the rule.
*
* @return bool
* Returns TRUE if condition is valid, FALSE otherwise.
*/
function commerce_discount_extra_line_item_order_contains_products_build(EntityDrupalWrapper $line_item_wrapper, $skus) {
$products_sku = explode(', ', (string) $skus);
// If the line item has an order, run the "order has products" logic with that
// as the argument.
if ($line_item_wrapper->order
->value()) {
$order_wrapper = $line_item_wrapper->order;
}
// If the line item does not have an order (the most common reason is that it
// is a line item spawned by a calculated price field display), use the user's
// current cart as the subject for the product search.
global $user;
$cart_order = commerce_cart_order_load($user->uid);
if ($cart_order) {
$order_wrapper = entity_metadata_wrapper('commerce_order', $cart_order);
}
if (isset($order_wrapper)) {
// Compare discount product ids with those of given order.
foreach ($order_wrapper->commerce_line_items as $wrapper_line_item) {
// Ensures that the type of current line item is product.
if ($wrapper_line_item
->getBundle() == 'product') {
if (($key = array_search($wrapper_line_item->commerce_product->sku
->value(), $products_sku)) !== FALSE) {
unset($products_sku[$key]);
}
}
}
return empty($products_sku);
}
return FALSE;
}
/**
* Inline conditions build callback: Determine if a user has a role.
*
* Discount-type agnostic.
*
* @param EntityDrupalWrapper $wrapper
* The wrapped entity given by the rule.
* @param array $roles
* Array of roles given by the rule.
*
* @return bool
* Returns TRUE if condition is valid, FALSE otherwise.
*/
function commerce_discount_extra_user_has_role_build(EntityDrupalWrapper $wrapper, $roles) {
global $user;
// Get the order's owner.
switch ($wrapper
->type()) {
case 'commerce_order':
if ($wrapper->owner
->value()) {
$account = $wrapper->owner
->value();
}
break;
case 'commerce_line_item':
if ($wrapper->order
->value() && $wrapper->order->owner
->value()) {
$account = $wrapper->order->owner
->value();
}
}
// If the account is not yet saved to the order, use the current user.
// This won't affect orders of anonymous users because they will have a 0 uid
// as their owner.
if (!isset($account)) {
$account = $user;
}
if (empty($account->roles)) {
return FALSE;
}
// Match role by name.
$user_roles = array_values($user->roles);
return (bool) array_intersect($roles, $user_roles);
}
/**
* Inline conditions build callback: line item product comparison.
*
* @param EntityDrupalWrapper $line_item_wrapper
* The wrapped entity given by the rule.
* @param array $price
* Price array structure.
* @param string $operator
* The comparison operator.
* @param string $method
* Calculation method.
*
* @return bool
* Returns TRUE if condition is valid, FALSE otherwise.
*/
function commerce_discount_extra_line_item_price_comp_build(EntityDrupalWrapper $line_item_wrapper, $price, $operator, $method) {
// Ensure that it is a product line item.
if (!in_array($line_item_wrapper
->getBundle(), commerce_product_line_item_types())) {
return FALSE;
}
// Select the appropriate line item price based on the comparison method.
switch ($method) {
case 'base':
// Get product base price.
$line_item_price = $line_item_wrapper->commerce_product->commerce_price
->value();
break;
case 'calculated':
// Get line item total.
$line_item_price = $line_item_wrapper->commerce_total
->value();
break;
// Invalid method.
default:
return FALSE;
}
// Convert the comparison amount to the line item's currency to make an even
// comparison.
$comparison_price_amount = commerce_currency_convert($price['amount'], $price['currency_code'], $line_item_price['currency_code']);
// Evaluate expression.
return _commerce_discount_extra_expression_eval($line_item_price['amount'], $comparison_price_amount, $operator);
}
/**
* Inline conditions build callback: evaluate items in order condition.
*
* @param EntityDrupalWrapper $entity_wrapper
* The wrapped entity given by the rule.
* @param int $number
* The number given by the rule.
* @param string $operator
* The comparison operator.
*
* @return bool
* Returns TRUE if condition is valid, FALSE otherwise.
*/
function commerce_discount_extra_items_in_order_build(EntityDrupalWrapper $entity_wrapper, $number, $operator) {
// Find the order depending on if the argument is an order or a line item.
if ($entity_wrapper
->type() == 'commerce_order') {
$order_wrapper = $entity_wrapper;
}
elseif ($entity_wrapper->order
->value()) {
$order_wrapper = $entity_wrapper->order;
}
$qty = 0;
// Determine total item count on order.
if (isset($order_wrapper)) {
foreach ($order_wrapper->commerce_line_items as $line_item_wrapper) {
if (in_array($line_item_wrapper
->getBundle(), commerce_product_line_item_types())) {
$qty += $line_item_wrapper->quantity
->value();
}
}
}
// Evaluate.
return _commerce_discount_extra_expression_eval($qty, $number, $operator);
}
/**
* Inline conditions build callback: evaluate product quantity conditions.
*
* @param EntityDrupalWrapper $line_item_wrapper
* The wrapped entity given by the rule.
* @param array $products
* The array of products given by the rule.
* @param string $operator
* The comparison operator.
* @param int $quantity
* The quantity given by the rule.
*
* @return bool
* Returns TRUE if condition is valid, FALSE otherwise.
*/
function commerce_discount_extra_line_item_has_specific_quantity_products_build(EntityDrupalWrapper $line_item_wrapper, $products, $operator, $quantity) {
module_load_include('inc', 'commerce_discount', 'commerce_discount.rules');
return commerce_order_has_specific_quantity_products_build($line_item_wrapper->order, $products, $operator, $quantity);
}
/**
* Inline conditions configure callback.
*
* Configure role selection form.
*
* @param array $settings
* An array of rules condition settings.
*
* @return array
* Return a form build array.
*/
function commerce_discount_extra_user_has_role_configure($settings) {
$form['roles'] = array(
'#type' => 'select',
'#title' => t('Select role(s)'),
'#options' => array_combine(user_roles(TRUE), user_roles(TRUE)),
'#default_value' => isset($settings['roles']) ? $settings['roles'] : array(),
'#required' => TRUE,
'#multiple' => TRUE,
);
return $form;
}
/**
* Inline conditions configure callback.
*
* Configure line item price comp form.
*
* @param array $settings
* An array of rules condition settings.
*
* @return array
* Return a form build array.
*/
function commerce_discount_extra_line_item_price_comp_configure($settings) {
module_load_include('inc', 'commerce_discount', 'commerce_discount.rules');
// Expression operator.
$form['operator'] = array(
'#type' => 'select',
'#title' => t('Operator'),
'#title_display' => 'invisible',
'#options' => _commerce_discount_operator_options(),
'#default_value' => !empty($settings['operator']) ? $settings['operator'] : '==',
'#required' => TRUE,
);
// Price container.
$form['price'] = array(
'#type' => 'container',
'#tree' => TRUE,
'#element_validate' => array(
'commerce_price_field_widget_validate',
),
'#suffix' => '<div class="condition-instructions">' . t('Enter a value to compare to the product price.') . '</div>',
);
// Price amount.
$form['price']['amount'] = array(
'#type' => 'textfield',
'#title' => t('Product price'),
'#title_display' => 'invisible',
'#default_value' => commerce_currency_amount_to_decimal($settings['price']['amount'], commerce_default_currency()),
'#size' => 10,
'#field_suffix' => commerce_default_currency(),
'#require' => TRUE,
);
// Price currency (hidden).
$form['price']['currency_code'] = array(
'#type' => 'value',
'#default_value' => commerce_default_currency(),
);
// Comparison method.
$form['method'] = array(
'#type' => 'radios',
'#title' => t('Compare using'),
'#options' => _commerce_discount_extra_comp_method_options(),
'#default_value' => !empty($settings['method']) ? $settings['method'] : 'base',
'#required' => TRUE,
);
return $form;
}
/**
* Implements hook_commerce_discount_offer_type_info().
*/
function commerce_discount_extra_commerce_discount_offer_type_info() {
$types['per_quantity_fixed'] = array(
'label' => t('Per-quantity product discount ($ off)'),
'action' => 'commerce_discount_extra_per_quantity_discount',
'entity types' => array(
'commerce_order',
),
);
$types['per_quantity_percentage'] = array(
'label' => t('Per-quantity product discount (% off)'),
'action' => 'commerce_discount_extra_per_quantity_discount',
'entity types' => array(
'commerce_order',
),
);
$types['fixed_product_price'] = array(
'label' => t('Fixed price'),
'action' => 'commerce_discount_extra_fixed_product_price',
'entity types' => array(
'commerce_line_item',
),
);
$types['per_quantity_category_fixed'] = array(
'label' => t('Per-quantity category discount ($ off)'),
'action' => 'commerce_discount_extra_per_quantity_category_discount',
'entity types' => array(
'commerce_order',
),
);
$types['per_quantity_category_percentage'] = array(
'label' => t('Per-quantity category discount (% off)'),
'action' => 'commerce_discount_extra_per_quantity_category_discount',
'entity types' => array(
'commerce_order',
),
);
return $types;
}
/**
* Inline conditions configure callback: configure items in order condition.
*
* @param array $settings
* An array of rules condition settings.
*
* @return array
* Return a form build array.
*/
function commerce_discount_extra_items_in_order_configure($settings) {
module_load_include('inc', 'commerce_discount', 'commerce_discount.rules');
// Expression operator.
$form['operator'] = array(
'#type' => 'select',
'#title' => t('Operator'),
'#title_display' => 'invisible',
'#options' => _commerce_discount_operator_options(),
'#default_value' => !empty($settings['operator']) ? $settings['operator'] : '==',
'#required' => TRUE,
);
// Comparison number.
$form['number'] = array(
'#type' => 'textfield',
'#title' => t('Number'),
'#default_value' => !empty($settings['number']) ? $settings['number'] : '',
'#element_validate' => array(
'element_validate_integer',
),
'#required' => TRUE,
);
return $form;
}
/**
* Inline conditions configure callback: configure product type condition.
*
* @param array $settings
* An array of rules condition settings.
*
* @return array
* Return a form build array.
*/
function commerce_discount_extra_line_item_has_product_type_configure($settings) {
$form['product_types'] = array(
'#type' => 'select',
'#multiple' => TRUE,
'#title' => t('Product types'),
'#description' => t('Select the product types that should get this discount'),
'#default_value' => !empty($settings['product_types']) ? $settings['product_types'] : array(),
'#options' => commerce_product_type_options_list(),
);
return $form;
}
/**
* Implements hook_flush_caches().
*/
function commerce_discount_extra_flush_caches() {
module_load_install('commerce_discount_extra');
commerce_discount_extra_install();
}
/**
* Implements hook_inline_conditions_build_alter().
*/
function commerce_discount_extra_inline_conditions_build_alter(&$value) {
switch ($value['condition_name']) {
case 'commerce_discount_extra_line_item_has_specific_quantity_products':
// See commerce_order_inline_conditions_build_alter().
$entity_ids = array();
foreach ($value['condition_settings']['products'] as $delta) {
$entity_ids[] = reset($delta);
}
$products = commerce_product_load_multiple($entity_ids);
$value['condition_settings']['products'] = '';
foreach ($products as $product) {
$value['condition_settings']['products'] .= $product->sku;
if ($product !== end($products)) {
$value['condition_settings']['products'] .= ', ';
}
}
break;
}
}
/**
* Returns a list of comparison method options.
*
* @return array
* Comparison menthod options.
*/
function _commerce_discount_extra_comp_method_options() {
return array(
'base' => t('Product base price'),
'calculated' => t('Calculated price'),
);
}
/**
* Get the quantity of a given product in an order.
*
* @param EntityDrupalWrapper $order_wrapper
* An order entity wrapper.
* @param array $product_ids
* Array of product IDs.
*
* @return int
* The quantity of a set of products on an order.
*/
function commerce_discount_extra_order_product_qty(EntityDrupalWrapper $order_wrapper, $product_ids) {
$qty = 0;
foreach ($order_wrapper->commerce_line_items as $line_item_wrapper) {
if ($line_item_wrapper
->value() && in_array($line_item_wrapper
->getBundle(), commerce_product_line_item_types()) && in_array($line_item_wrapper->commerce_product
->getIdentifier(), $product_ids)) {
$qty += $line_item_wrapper->quantity
->value();
}
}
return $qty;
}
/**
* Evalute a simple expression with two values and an operator.
*
* @param int $v1
* First value to compare.
* @param int $v2
* Second value to compare.
* @param string $operator
* The comparison operator.
*
* @return bool
* Returns TRUE if condition is valid, FALSE otherwise.
*/
function _commerce_discount_extra_expression_eval($v1, $v2, $operator) {
switch ($operator) {
case '>':
$result = $v1 > $v2;
break;
case '>=':
$result = $v1 >= $v2;
break;
case '<':
$result = $v1 < $v2;
break;
case '<=':
$result = $v1 <= $v2;
break;
case '==':
$result = $v1 == $v2;
break;
}
return $result;
}
/**
* Implements hook_form_FORM_ID_alter().
*/
function commerce_discount_extra_form_commerce_discount_form_alter(&$form, &$form_state) {
// Inline entity form has a problem where it interprets a blank integer field
// as a string rather than an integer. This causes a fatal error on write, as
// '' cannot be written to an integer column, even if it accepts NULL values.
if (isset($form['commerce_discount_fields']['commerce_discount_offer'][LANGUAGE_NONE]['form']['commerce_discount_offer_fields']['commerce_offer_limit'][LANGUAGE_NONE][0]['value'])) {
$element =& $form['commerce_discount_fields']['commerce_discount_offer'][LANGUAGE_NONE]['form']['commerce_discount_offer_fields']['commerce_offer_limit'][LANGUAGE_NONE][0]['value'];
// Add an element validate handler that converts empty values to 0 during
// the element validation step.
$element['#element_validate'][] = 'commerce_discount_extra_offer_limit_element_validate';
// Additionally, if the current default value of the element is 0, convert
// it to an empty string for dubious UX benefit.
// @todo Update the interface text to say use 0 or leave blank for no limit.
if (empty($element['#default_value'])) {
$element['#default_value'] = '';
}
}
// We use a "Long text" field to identify trigger and offer categories, but we
// alter it to an autocomplete textfield against the allowed categories for an
// improved user experience over a multi-value select list or similar field.
foreach (array(
'commerce_trigger_categories',
'commerce_offer_categories',
) as $field_name) {
if (isset($form['commerce_discount_fields']['commerce_discount_offer'][LANGUAGE_NONE]['form']['commerce_discount_offer_fields'][$field_name][LANGUAGE_NONE][0]['value'])) {
$element =& $form['commerce_discount_fields']['commerce_discount_offer'][LANGUAGE_NONE]['form']['commerce_discount_offer_fields'][$field_name][LANGUAGE_NONE][0]['value'];
$element['#type'] = 'textfield';
$element['#maxlength'] = 4096;
$element['#size'] = 120;
$element['#autocomplete_path'] = 'commerce_discount_extra/categories_autocomplete/0';
$element['#element_validate'][] = '_commerce_discount_extra_offer_categories_autocomplete_validate';
$element['#default_value'] = commerce_discount_extra_offer_categories_default_value($element['#default_value']);
}
}
}
/**
* Form validate callback: change empty string to integer for limit field.
*/
function commerce_discount_extra_offer_limit_element_validate(&$element, &$form_state) {
// If the offer limit field has an empty value, set it to 0 for save.
if (empty($element['#value'])) {
form_set_value($element, 0, $form_state);
}
}
/**
* Returns an offer categories default value including taxonomy term names.
*/
function commerce_discount_extra_offer_categories_default_value($default_value) {
$tids = explode(', ', $default_value);
$new_default = '';
// Loop over all loaded terms based on the current default value.
foreach (taxonomy_term_load_multiple($tids) as $tid => $term) {
if (!empty($new_default)) {
$new_default .= ', ';
}
$new_default .= $term->name . ' (' . $tid . ')';
}
return $new_default;
}
/**
* Menu callback: returns autocomplete values for BOGO offer categories defined
* as taxonomy terms from vocabularies referenced by product display node types.
*
* @param bool $single
* TRUE if the autocomplete only supports a single value; defaults to FALSE.
* @param string $string
* The current label string to search against.
*/
function commerce_discount_extra_offer_categories_autocomplete_callback($single = FALSE, $string = '') {
$matches = array();
// If the autocomplete should only search for a single value...
if (!empty($single)) {
// Use the currently entered string as the "last" label to search against.
$tag_last = $string;
}
else {
// Otherwise, we expect the user to enter a comma separated list of labels
// and only autocomplete against the last label.
$tags_typed = drupal_explode_tags($string);
$tag_last = drupal_strtolower(array_pop($tags_typed));
if (!empty($tag_last)) {
$prefix = count($tags_typed) ? implode(', ', $tags_typed) . ', ' : '';
}
}
// Limit the query to terms in vocabularies referenced by product displays.
$vocabularies = commerce_discount_extra_product_display_vocabularies();
if (!empty($tag_last) && !empty($vocabularies)) {
$entity_info = entity_get_info('taxonomy_term');
$query = new EntityFieldQuery();
$query
->entityCondition('entity_type', 'taxonomy_term')
->propertyCondition($entity_info['entity keys']['label'], $tag_last, 'CONTAINS')
->propertyCondition('vid', array_keys($vocabularies))
->addTag('taxonomy_term_access')
->addTag('commerce_discount_taxonomy_term_query')
->range(0, 10);
$results = $query
->execute();
if (!empty($results['taxonomy_term'])) {
$entities = entity_load('taxonomy_term', array_keys($results['taxonomy_term']));
foreach ($entities as $entity_id => $entity) {
$label = entity_label('taxonomy_term', $entity);
$label = check_plain($label);
$key = "{$label} ({$entity_id})";
// Strip things like starting/trailing white spaces,
// line breaks and tags.
$key = preg_replace('/\\s\\s+/', ' ', str_replace("\n", '', trim(decode_entities(strip_tags($key)))));
// Names containing commas or quotes must be wrapped in quotes.
if (strpos($key, ',') !== FALSE || strpos($key, '"') !== FALSE) {
$key = '"' . str_replace('"', '""', $key) . '"';
}
$matches[$prefix . $key] = '<div class="reference-autocomplete">' . $label . '</div>';
}
}
}
drupal_json_output($matches);
}
/**
* Element validate handler: converts a categories autocomplete value from term
* names and IDs just to a set of term IDs.
*/
function _commerce_discount_extra_offer_categories_autocomplete_validate($element, &$form_state, $form) {
$value = array();
// If a value was entered into the autocomplete...
if (!empty($element['#value'])) {
$tags = drupal_explode_tags($element['#value']);
$tids = array();
foreach ($tags as $tag) {
// Extract the term IDs from a tag formatted as "Label (term ID)".
if (preg_match("/.+\\((\\d+)\\)/", $tag, $matches)) {
$tids[] = $matches[1];
}
}
}
// Update the value of this element with only the term IDs.
form_set_value($element, implode(', ', $tids), $form_state);
}
/**
* Implements hook_commerce_cart_product_add().
*
* When a product is added to the cart, this function looks for any discounts
* that use per-quantity offers configured to automatically add their offer
* products to the cart when enough triggering products have been added.
*/
function commerce_discount_extra_commerce_cart_product_add($order, $product, $quantity, $line_item) {
// See if a product has already been added through this function. If so, bail
// as we only want to add the first available product to the cart.
$product_added =& drupal_static(__FUNCTION__);
if (isset($product_added)) {
return;
}
else {
$product_added = NULL;
}
// Build the base select query to find all per-quantity offer discounts.
$query = db_select('commerce_discount', 'cd', array(
'fetch' => PDO::FETCH_ASSOC,
))
->fields('cd', array(
'discount_id',
'name',
))
->orderBy('cd.discount_id')
->groupBy('cd.discount_id');
// Join to the relevant tables and retain their aliases.
$fcdo_alias = $query
->leftJoin('field_data_commerce_discount_offer', 'fcdo', 'cd.discount_id = %alias.entity_id');
$cdo_alias = $query
->leftJoin('commerce_discount_offer', 'cdo', $fcdo_alias . '.commerce_discount_offer_target_id = %alias.discount_offer_id');
$fcaab_alias = $query
->leftJoin('field_data_commerce_auto_add_behavior', 'fcaab', $cdo_alias . '.discount_offer_id = %alias.entity_id');
// Add fields from the joined tables.
$query
->fields($cdo_alias, array(
'type',
))
->fields($fcaab_alias, array(
'commerce_auto_add_behavior_value',
));
// Avoid `only_full_group_by` error.
// @see https://www.drupal.org/node/2782827 for more details.
$query
->groupBy($cdo_alias . '.type');
// Add the conditions for the various tables.
$query
->condition('cd.status', 1)
->condition($fcdo_alias . '.entity_type', 'commerce_discount')
->condition($fcaab_alias . '.entity_type', 'commerce_discount_offer')
->where($fcaab_alias . ".commerce_auto_add_behavior_value IN ('add_first_offer_product')")
->where($cdo_alias . ".type IN ('per_quantity_discount', 'per_quantity_percentage')");
// Fetch and loop over the results to find discount rules to evaluate.
$discount_rules = array();
foreach ($query
->execute() as $result) {
$discount_rules[$result['discount_id']] = 'commerce_discount_rule_' . $result['name'];
}
// Load the sell price calculation event.
$event = rules_get_cache('event_commerce_discount_order');
// Load all of the relevant discount rules.
$rules = rules_config_load_multiple($discount_rules);
$order_wrapper = entity_metadata_wrapper('commerce_order', $order);
foreach ($discount_rules as $discount_id => $rule_name) {
// Evaluate each rule's conditions to see if the discount would apply to the
// current shopping cart.
$state = new RulesState();
$vars = $event
->parameterInfo(TRUE);
$state
->addVariable('commerce_order', $order, $vars['commerce_order']);
if ($rules[$rule_name]
->conditionContainer()
->evaluate($state)) {
// Load the discount related to the Rule.
$discount = entity_load('commerce_discount', array(
$discount_id,
));
$discount = reset($discount);
$discount_wrapper = entity_metadata_wrapper('commerce_discount', $discount);
// Check to ensure the product that was added to the cart qualifies as a
// trigger product for the discount's offer.
$trigger_product_ids = $discount_wrapper->commerce_discount_offer->commerce_trigger_products
->raw();
if (!empty($trigger_product_ids) && !in_array($product->product_id, $trigger_product_ids)) {
continue;
}
// If any of the current discount's offer products have been added to the
// cart, continue to the next discount because we don't know for certain
// one of the products wasn't already added as a result of this discount.
foreach ($order_wrapper->commerce_line_items as $delta => $line_item_wrapper) {
$line_item_product_id = $line_item_wrapper->commerce_product
->raw();
if (in_array($line_item_product_id, $discount_wrapper->commerce_discount_offer->commerce_discount_products
->raw())) {
continue 2;
}
}
// If so, see if sufficient trigger products exist in the cart to activate
// the discount. We do this by maintaining a simplified reversal of the
// manifest in the discount action's callback: instead of removing items
// from an array, we add to it as the quantity of various line items on
// the current order are identified as trigger products and prevent this
// function from recognizing the same trigger products on multiple offers.
// Additionally, since we can't be assured the relevant offer product
// hasn't already been added to the cart, do not include any offer
// products in the trigger product count.
$trigger_quantity = $discount_wrapper->commerce_discount_offer->commerce_trigger_qty
->raw();
$offer_quantity = $discount_wrapper->commerce_discount_offer->commerce_offer_qty
->raw();
static $triggering_line_items = array();
$trigger_count = 0;
foreach ($order_wrapper->commerce_line_items as $delta => $line_item_wrapper) {
$line_item_id = $line_item_wrapper
->raw();
$line_item_quantity = $line_item_wrapper->quantity
->raw();
$line_item_product_id = $line_item_wrapper->commerce_product
->raw();
// If the line item has already been fully counted, skip it.
if (!empty($triggering_line_items[$line_item_id]) && $triggering_line_items[$line_item_id] == $line_item_quantity) {
continue;
}
// If any product may trigger the discount or if the current product is
// one of the specified trigger products, increment the count as much as
// needed and log that amount in the $triggering_line_items array.
$trigger_product_ids = $discount_wrapper->commerce_discount_offer->commerce_trigger_products
->raw();
if (empty($trigger_product_ids) || in_array($line_item_product_id, $trigger_product_ids)) {
if (empty($trigger_line_items[$line_item_id])) {
$triggering_line_items[$line_item_id] = 0;
}
$increment = min($line_item_quantity - $triggering_line_items[$line_item_id], $trigger_quantity - $trigger_count);
$triggering_line_items[$line_item_id] += $increment;
$trigger_count += $increment;
// If we've reached the trigger quantity, exit the loop.
if ($trigger_count == $trigger_quantity) {
break;
}
}
}
// If by this time, the trigger quantity hasn't been met, do not add any
// offer product to the cart.
if ($trigger_count < $trigger_quantity) {
break;
}
// Loop over the discount products looking for one to add.
foreach ($discount_wrapper->commerce_discount_offer->commerce_discount_products as $delta => $product_wrapper) {
// Check stock for the current product to see if the full offer amount
// is available for purchase.
if (module_exists('commerce_stock')) {
// Prepare the variables needed for a stock check.
$quantity_ordered = commerce_stock_check_cart_product_level($product_wrapper
->raw());
$stock_state = '';
$message = '';
// Fetch the stock check messages to see if it failed.
commerce_stock_check_product_rule($product_wrapper
->value(), $offer_quantity, $quantity_ordered, $stock_state, $message);
// If the $stock_state is non-empty, the product is not in stock, so
// we continue to pass on to the next discounted product.
if (!empty($stock_state)) {
continue;
}
}
// Create a line item for the offer product.
$offer_product_line_item = commerce_product_line_item_new($product_wrapper
->value(), $offer_quantity, FALSE);
// Store this discount's name in the data array.
$offer_product_line_item->data['discount_name'] = $discount_wrapper->name
->value();
// Add the new line item to the cart.
commerce_cart_product_add($order->uid, $offer_product_line_item);
// Reset $product_added so additional discount rules can activate an
// offer.
$product_added = NULL;
break;
}
}
}
}
/**
* Returns an array of vocabularies referenced through term reference fields on
* product display node types keyed by vid.
*/
function commerce_discount_extra_product_display_vocabularies() {
$vocabularies = array();
// Loop over all product display node types...
foreach (commerce_product_reference_node_types() as $bundle) {
// And then loop over all field instances on the node type...
foreach (field_info_instances('node', $bundle->type) as $instance) {
// Fetch the field info for the current instance.
$field = field_info_field($instance['field_name']);
// If it's a term reference field...
if ($field['type'] == 'taxonomy_term_reference') {
// Attempt to load the vocabularies and add them to the return value.
foreach ($field['settings']['allowed_values'] as $tree) {
if ($vocabulary = taxonomy_vocabulary_machine_name_load($tree['vocabulary'])) {
$vocabularies[$vocabulary->vid] = $vocabulary;
}
}
}
}
}
return $vocabularies;
}
/**
* Returns the taxonomy term IDs associated with a product via its display(s).
*/
function commerce_discount_extra_product_terms($product_id) {
// Term IDs will be cached keyed by $product_id.
$tids =& drupal_static(__FUNCTION__);
// Cache the product's term IDs if they haven't been set already.
if (isset($tids[$product_id])) {
return $tids[$product_id];
}
$tids[$product_id] = array();
// Loop over all known product display node types defined as node types with
// product reference fields.
foreach (commerce_product_reference_node_types() as $bundle) {
$instances = field_info_instances('node', $bundle->type);
// Look for taxonomy term reference fields on the node type.
foreach ($instances as $instance) {
$field = field_info_field($instance['field_name']);
// If we found one...
if ($field['type'] == 'taxonomy_term_reference') {
// Look for nodes of this type that reference our target product.
$product_reference = key(field_read_fields(array(
'entity_type' => 'node',
'type' => 'commerce_product_reference',
'bundle' => $bundle->type,
)));
// @todo Switch from using EFQ to using a direct query of term data.
$query = new EntityFieldQuery();
$result = $query
->entityCondition('entity_type', 'node')
->propertyCondition('type', $bundle->type)
->fieldCondition($product_reference, 'product_id', $product_id, '=')
->addTag('commerce_discount_node_query')
->execute();
// If we found matching nodes...
if ($result) {
// Loop over them and extract the IDs of any terms referenced by the
// node through the current term reference field.
foreach ($result['node'] as $node_data) {
$node = entity_load_single('node', $node_data->nid);
$term_wrapper = entity_metadata_wrapper('node', $node)->{$field['field_name']};
// If the term reference field is multi-value, iterate over the
// items array even if there's only a single referenced term.
if ($term_wrapper instanceof EntityListWrapper) {
foreach ($term_wrapper as $item_wrapper) {
if ($item_wrapper
->value()) {
$tids[$product_id][] = $item_wrapper->tid
->value();
}
}
}
elseif ($term_wrapper
->value()) {
$tids[$product_id][] = $term_wrapper->tid
->value();
}
}
}
}
}
}
return $tids[$product_id];
}
/**
* Implements hook_commerce_discount_per_quantity_discount_line_item_save().
*
* This function will be called when any "Per quantity" discount line item is
* saved. This is the point at which modules can influence the components of the
* unit price array for a BOGO discount to do things like manipulate taxes.
*
* Note: certain types of BOGO offers may actually require sales tax to be
* collected, but that's definitely the edge case. A site requiring that will
* need to implement hook_module_implements_alter() to deregister this hook for
* now and only call it in the cases when tax should not apply to free products.
*
* @see http://blog.taxjar.com/sales-tax-discounts-coupons-promotions/
*/
function commerce_discount_extra_commerce_discount_per_quantity_discount_line_item_save($discount_line_item, $discount_name, $offer_type) {
// Unset any tax price components from the discount line item's unit price.
// This allows us to re-add tax price components to this unit price based on
// the current taxes applied to the original discounted line item.
$discount_unit_price = $discount_line_item->commerce_unit_price[LANGUAGE_NONE][0];
foreach ($discount_unit_price['data']['components'] as $key => $component) {
// The two if statements here are copied from the Commerce Tax module.
if ($component_type = commerce_price_component_type_load($component['name'])) {
if (!empty($component_type['tax_rate']) && ($tax_rate = commerce_tax_rate_load($component_type['tax_rate']))) {
unset($discount_unit_price['data']['components'][$key]);
}
}
}
// Loop through all discounted line item amounts...
foreach ($discount_line_item->data['discounted_line_item_amounts'] as $discounted_line_item_id => $discounted_line_item_amount) {
// Load the discounted line item and extract the unit price.
$discounted_line_item = commerce_line_item_load($discounted_line_item_id);
$discounted_unit_price = $discounted_line_item->commerce_unit_price[LANGUAGE_NONE][0];
// Loop through the price components in the unit price of the original
// discounted line item looking for tax related price components.
foreach ($discounted_unit_price['data']['components'] as $component) {
if ($component_type = commerce_price_component_type_load($component['name'])) {
if (!empty($component_type['tax_rate']) && ($tax_rate = commerce_tax_rate_load($component_type['tax_rate']))) {
// If we found one, determine how much tax we need to take off of the
// discount line item by applying the tax rate to the total amount
// discounted from the original line item. This captures the scenario
// where the discounted amount doesn't precisely equal the unit price
// (e.g. a flat amount off, a percentage off less than the full unit
// price, or a case where the discount applied multiple times to the
// same line item).
$discount_tax_price = array(
'amount' => -($discounted_line_item_amount * $tax_rate['rate']),
'currency_code' => $discounted_unit_price['currency_code'],
);
// Determine whether or not the related tax is display inclusive.
$included = FALSE;
if (($tax_type = commerce_tax_type_load($tax_rate['type'])) && $tax_type['display_inclusive']) {
$included = TRUE;
}
// Add the tax price component to the discount line item's unit price.
$discount_unit_price['data'] = commerce_price_component_add($discount_unit_price, $tax_rate['price_component'], $discount_tax_price, $included);
}
}
}
}
$discount_line_item->commerce_unit_price[LANGUAGE_NONE][0] = $discount_unit_price;
}