diff --git a/commerce_price_table.install b/commerce_price_table.install index ef74f43..4743b7a 100644 --- a/commerce_price_table.install +++ b/commerce_price_table.install @@ -45,3 +45,93 @@ function commerce_price_table_field_schema($field) { ); } } + +/** + * This is not a real update, it just checks whether the price table + * entries as consistent, so the price calculations would not differ + * after removing the max_qty field. + */ +function commerce_price_table_update_7100() { + $fields = commerce_info_fields('commerce_price_table'); + foreach ($fields as $fieldName => $field) { + if ($data_table = key($field['storage']['details']['sql']['FIELD_LOAD_CURRENT'])) { + // Select alls product ranges, ordered by min_qty + $result = db_select($data_table, 't') + ->fields('t', array('entity_type', 'entity_id', $fieldName.'_min_qty', $fieldName.'_max_qty')) + ->orderBy('t.entity_type, t.entity_id, t.'.$fieldName.'_min_qty', 'ASC') + ->condition('t.deleted', '0', '=') + ->execute(); + $entity_type = null; + $entity_id = null; + $ranges = array(); + while ($row = $result->fetchAssoc()) { + if ($entity_type != $row['entity_type'] || $entity_id != $row['entity_id']) { + if (!empty($ranges)) { + // next entity, evaluate + _commerce_price_table_validate_min_max_ranges($entity_type, $entity_id, $ranges); + } + $entity_type = $row['entity_type']; + $entity_id = $row['entity_id']; + $ranges = array(); + } + $ranges[] = array($row[$fieldName.'_min_qty'], $row[$fieldName.'_max_qty']); + } + if (!empty($ranges)) { + // evaluate last sequence + _commerce_price_table_validate_min_max_ranges($entity_type, $entity_id, $ranges); + } + } + } +} + +/** + * Ensures that the quantities of the given product are strictly monotone increasing, + * starting at 0 (1 is allowed, too) and ending at -1. Ranges must be an array of + * min_qty, max_qty pairs, sorted by min_qty. + */ +function _commerce_price_table_validate_min_max_ranges($entity_type, $entity_id, $ranges) { + $result = true; + $qty = 0; + foreach ($ranges as $range) { + list($min, $max) = $range; + if ($qty == -1 || $min < 0) { + // Last element, or invalid value for min + $result = false; + break; + } + + if ($max <= $min && $max != -1) { + // Max may only be smaller than min if it is the last element + $result = false; + break; + } + + if ($qty == 0 && $min != 0 && $min != 1) { + // The sequence has to start at 0 or 1 + $result = false; + break; + } + + if ($min <= $qty) { + // The may not be two prices having the same min qty + $result = false; + break; + } + + $qty = $max; + } + + if ($qty > 0) { + // Missing last element -1 + $result = false; + } + + if (!$result) { + if ($entity = reset(entity_load($entity_type, array($entity_id)))) { + if ($uri = entity_uri($entity_type, $entity)) { + drupal_set_message('non monotonically increasing price table quantity range in product ' + . l($entity->title, $uri['path']) . '. Calculated prices may differ.', 'warning'); + } + } + } +} diff --git a/commerce_price_table.module b/commerce_price_table.module index 72abc15..e1d39c6 100644 --- a/commerce_price_table.module +++ b/commerce_price_table.module @@ -190,24 +190,127 @@ function commerce_price_table_field_load($entity_type, $entities, $field, $insta /** * Implements hook_field_validate(). */ -function commerce_price_table_field_validate($entity_type, $entity, $field, $instance, $langcode, &$items, &$errors) { - // Ensure only numeric values are entered in price fields. +function commerce_price_table_field_validate($entity_type, $entity, $field, $instance, $langcode, $items, &$errors) { + $lower_bound = 0; + $infinite = false; + $incomplete = false; + $overlapping = false; + $delta = 0; + uasort($items, 'commerce_price_table_sort_by_qty'); foreach ($items as $delta => $item) { - if (!empty($item['amount']) && !is_numeric($item['amount'])) { + if (empty($item['amount'])) { + continue; + } + + // Ensure only numeric values are entered in price fields. + if (!is_numeric($item['amount'])) { $errors[$field['field_name']][$langcode][$delta][] = array( 'error' => 'price_numeric', 'message' => t('%name: you must enter a numeric value for the price.', array('%name' => check_plain($instance['label']))), ); } + // min_qty may never be less than zero, and max_qty may never be less then min_qty (exception: -1) + if ($item['min_qty'] < 0) { + $errors[$field['field_name']][$langcode][$delta][] = array( + 'error' => 'price_table_quantity', + 'message' => t('%name: Min quantity must be greater or equal to zero.', array('%name' => check_plain($instance['label']))), + ); + } + if ($item['max_qty'] < $item['min_qty'] && $item['max_qty'] <> -1) { $errors[$field['field_name']][$langcode][$delta][] = array( 'error' => 'price_table_quantity', - 'message' => t('%name: Max quantity needs to be higher than min quantity.', array('%name' => check_plain($instance['label']))), + 'message' => t('%name: Max quantity needs to be higher or same than min quantity.', array('%name' => check_plain($instance['label']))), ); } - // @TODO Add extra validations, as no repeating qty and always force to have quantity for 1?. + // Check for overlapping intervals + if ($item['min_qty'] < $lower_bound) { + $overlapping = $delta; + } + + // Check for gaps + if ($item['min_qty'] > $lower_bound) { + // Exception: first item may start with 1. + if ($item['min_qty'] != 1 || $lower_bound != 0) { + $incomplete = $delta; + } + } + + // There may only be a single infinite item. + if ($item['max_qty'] == -1) { + if ($infinite) { + $errors[$field['field_name']][$langcode][$delta][] = array( + 'error' => 'price_table_quantity', + 'message' => t('%name: Only one infinite max quantity is allowed.', array('%name' => check_plain($instance['label']))), + ); + } + + // We have an infinite maximum value + $infinite = true; + $lower_bound = PHP_INT_MAX; + } + + // Get lower bound for next iteration + $lower_bound = max($item['max_qty'] + 1, $lower_bound); + + // @TODO Always force to have quantity for 1? + } + + // Check for overlapping or incomplete price table ranges. + if ($incomplete !== false || $overlapping !== false || !$infinite) { + // Determine bundle + $bundle = 'product'; + $info = entity_get_info($entity_type); + if (!empty($info['entity keys']['bundle'])) { + if (isset($entity->{$info['entity keys']['bundle']})) { + $bundle = $entity->{$info['entity keys']['bundle']}; + } + } + + // Fetch field configuration, see if overlapping or incomplete is allowed or not + $disallow_incomplete_intervals = false; + $disallow_overlapping_intervals = false; + foreach (commerce_price_table_get_field_instance_settings($entity_type, $bundle) as $setting) { + if (!empty($setting['settings']['commerce_price_table']['disallow_incomplete_intervals'])) { + $disallow_incomplete_intervals = true; + } + if (!empty($setting['settings']['commerce_price_table']['disallow_overlapping_intervals'])) { + $disallow_overlapping_intervals = true; + } + } + + if ($overlapping !== false) { + // Overlapping interval, print error or warning + if ($disallow_overlapping_intervals) { + $errors[$field['field_name']][$langcode][$overlapping][] = array( + 'error' => 'price_table_quantity', + 'message' => t('%name: Overlapping price table ranges are not allowed.', array('%name' => check_plain($instance['label']))), + ); + } else { + drupal_set_message('Overlapping intervals might cause unexpected pricing behaviours.', 'warning'); + } + } + + if ($incomplete !== false || !$infinite) { + // Incomplete interval, print error or warning + if ($disallow_incomplete_intervals) { + if (!$infinite) { + $errors[$field['field_name']][$langcode][$delta][] = array( + 'error' => 'price_table_quantity', + 'message' => t('%name: Price table intervals are incomplete (missing unlimited quantity).', array('%name' => check_plain($instance['label']))), + ); + } else { + $errors[$field['field_name']][$langcode][$overlapping][] = array( + 'error' => 'price_table_quantity', + 'message' => t('%name: Price table intervals are incomplete.', array('%name' => check_plain($instance['label']))), + ); + } + } else { + drupal_set_message('Incomplete intervals will fall back to the default price.', 'warning'); + } + } } } @@ -263,6 +366,18 @@ function commerce_price_table_field_instance_settings_form($field, $instance) { '#description' => t('Activate this checkbox to hide the default price and display the price table instead.'), '#default_value' => isset($settings['commerce_price_table']['hide_default_price']) ? $settings['commerce_price_table']['hide_default_price'] : NULL, ); + $form['commerce_price_table']['disallow_incomplete_intervals'] = array( + '#type' => 'checkbox', + '#title' => t('Disallow incomplete intervals'), + '#description' => t('Activate this checkbox to disallow gaps between price table intervals.'), + '#default_value' => isset($settings['commerce_price_table']['disallow_incomplete_intervals']) ? $settings['commerce_price_table']['disallow_incomplete_intervals'] : NULL, + ); + $form['commerce_price_table']['disallow_overlapping_intervals'] = array( + '#type' => 'checkbox', + '#title' => t('Disallow overlapping intervals'), + '#description' => t('Activate this checkbox to disallow overlapping price table intervals.'), + '#default_value' => isset($settings['commerce_price_table']['disallow_overlapping_intervals']) ? $settings['commerce_price_table']['disallow_overlapping_intervals'] : NULL, + ); return $form; } @@ -466,22 +581,47 @@ function commerce_price_table_get_amount_qty($product, $quantity = 1) { } // Sort the items by quantity and return the matching one. + $nearest = null; + $nearest_distance = PHP_INT_MAX; + $fallback = false; uasort($items, 'commerce_price_table_sort_by_qty'); foreach ($items as $item) { if ($quantity <= $item['max_qty'] && $quantity >= $item['min_qty']) { return $item; } + + if ($quantity < $item['min_qty']) { + // We have a gap. Fall back to default price. + $fallback = true; + } + + // Calculate distance for fallback + $distance = abs($quantity - $item['min_qty']); + if ($item['max_qty'] > 0) { + $distance = min($distance, abs($quantity - $item['max_qty'])); + if ($distance < $nearest_distance) { + $nearest = $item; + $nearest_distance = $distance; + } + } } - // Handle the unlimited qty. - foreach ($items as $item) { - if ($item['max_qty'] == -1) { - return $item; + if (!$fallback) { + // Handle the unlimited qty. + foreach ($items as $item) { + if ($item['max_qty'] == -1) { + return $item; + } } } - // We fallback to the higher one if no match was found. - return end($items); + // Fallback to default price (or nearest item as a last resort, + // which might be meaningful if the default price has been hidden). + if (isset($product->commerce_price[LANGUAGE_NONE][0]) && !empty($product->commerce_price[LANGUAGE_NONE][0]['amount'])) { + return $product->commerce_price[LANGUAGE_NONE][0]; + } else { + return $nearest; + } } /** @@ -490,9 +630,25 @@ function commerce_price_table_get_amount_qty($product, $quantity = 1) { function commerce_price_table_sort_by_qty($a, $b) { $a_qty = (is_array($a) && isset($a['min_qty'])) ? $a['min_qty'] : 0; $b_qty = (is_array($b) && isset($b['min_qty'])) ? $b['min_qty'] : 0; + if ($a_qty == $b_qty) { - return 0; + // Sort by max_qty + $a_qty = (is_array($a) && isset($a['max_qty'])) ? $a['max_qty'] : 0; + $b_qty = (is_array($b) && isset($b['max_qty'])) ? $b['max_qty'] : 0; + if ($a_qty == $b_qty) { + return 0; + } + + // Special cases: -1 means infinite + if ($a_qty == -1) { + return 1; + } + + if ($b_qty == 1) { + return -1; + } } + return ($a_qty < $b_qty) ? -1 : 1; }