diff -ru commerce_price_table-1.1/commerce_price_table.install commerce_price_table/commerce_price_table.install --- commerce_price_table-1.1/commerce_price_table.install 2012-05-04 09:54:12.000000000 +0200 +++ commerce_price_table/commerce_price_table.install 2013-01-24 20:42:29.950658485 +0100 @@ -45,3 +45,93 @@ ); } } + +/** + * 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 -ru commerce_price_table-1.1/commerce_price_table.module commerce_price_table/commerce_price_table.module --- commerce_price_table-1.1/commerce_price_table.module 2012-05-04 09:54:12.000000000 +0200 +++ commerce_price_table/commerce_price_table.module 2013-01-25 10:06:40.506493398 +0100 @@ -190,24 +190,94 @@ /** * 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; + 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?. + if ($item['max_qty'] == -1) { + // There may only be a single infinite item. + if ($infinite) { + $errors[$field['field_name']][$langcode][$delta][] = array( + 'error' => 'price_table_quantity', + 'message' => t('%name: Only one infinite may quantity is allowed.', array('%name' => check_plain($instance['label']))), + ); + } + $infinite = true; + } + + // Ensure that there are no overlapping intervals + if ($item['min_qty'] < $lower_bound) { + $errors[$field['field_name']][$langcode][$delta][] = array( + 'error' => 'price_table_quantity', + 'message' => t('%name: Overlapping intervals.', array('%name' => check_plain($instance['label']))), + ); + } + + // And finally, 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 = true; + } + } + + // If max_qty is -1, this should be the last iteration + $lower_bound = $item['max_qty'] + 1; + + // @TODO Always force to have quantity for 1? + } + + // Check if incomplete intervals are allowed or not + if ($incomplete || !$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']}; + } + } + + foreach (commerce_price_table_get_field_instance_settings($entity_type, $bundle) as $setting) { + if (isset($setting['settings']['commerce_price_table']['require_complete_intervals'])) { + if ($setting['settings']['commerce_price_table']['require_complete_intervals'] == TRUE) { + $errors[$field['field_name']][$langcode][0][] = array( + 'error' => 'price_table_quantity', + 'message' => t('%name: Price table intervals are incomplete.', array('%name' => check_plain($instance['label']))), + ); + } + break; + } + } } } @@ -263,6 +333,12 @@ '#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']['require_complete_intervals'] = array( + '#type' => 'checkbox', + '#title' => t('Require complete intervals'), + '#description' => t('Activate this checkbox to disallow gaps between price table intervals.'), + '#default_value' => isset($settings['commerce_price_table']['require_complete_intervals']) ? $settings['commerce_price_table']['require_complete_intervals'] : NULL, + ); return $form; } @@ -466,22 +542,47 @@ } // 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 +591,25 @@ 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; }