diff --git a/rules_shipping_advanced.php b/rules_shipping_advanced.php index 246602d5d1265a1dc375263482ba6fece87e8cab..23142a01c5c0a3efbbcb6acc8eecfd27b77982f0 100644 --- a/rules_shipping_advanced.php +++ b/rules_shipping_advanced.php @@ -56,18 +56,18 @@ class plgVmShipmentRules_Shipping_Advanced extends plgVmShipmentRules_Shipping_B parent::__construct ($subject, $config); } protected function createMethodRule ($r, $countries, $tax) { - return new ShippingRule_Advanced ($r, $countries, $tax); + return new ShippingRule_Advanced ($this, $r, $countries, $tax); } /** Allow child classes to add additional variables for the rules */ protected function addCustomCartValues (VirtueMartCart $cart, $cart_prices, &$values) { $values['coupon'] = $cart->couponCode; - if ($values['zip']) { - $zip=strtoupper($values['zip']); - } // Postal code Check for UK postal codes: Use regexp to determine if ZIP structure matches and also to extract the parts. // Also handle UK overseas areas/islands that use four-letter outward codes rather than "A{1,2}0{1,2}A{0,1} 0AA" + if ($values['zip']) { + $zip=strtoupper($values['zip']); + } if (isset($zip) and preg_match('/^\s*(([A-Z]{1,2})(\d{1,2})([A-Z]?)|[A-Z]{4}|GIR)\s*(\d[A-Z]{2})\s*$/', $zip, $match)) { $values['uk_outward'] = $match[1]; $values['uk_area'] = $match[2]; @@ -104,8 +104,8 @@ class plgVmShipmentRules_Shipping_Advanced extends plgVmShipmentRules_Shipping_B /** Extend the shipping rules by allowing arbitrary mathematical expressions */ class ShippingRule_Advanced extends ShippingRule { - function __construct ($rule, $countries, $tax_id) { - parent::__construct ($rule, $countries, $tax_id); + function __construct ($method, $rule, $countries, $tax_id) { + parent::__construct ($method, $rule, $countries, $tax_id); } function tokenize_expression ($expression) { @@ -407,6 +407,7 @@ class ShippingRule_Advanced extends ShippingRule { } else { // Terms without comparisons or assignments are shipping cost expressions $this->shipping = $res; + $this->ruletype = 'shipping'; $this->includes_tax = False; } // JFactory::getApplication()->enqueueMessage("<pre>Rule part '$rulepart' parsed into (condition=".print_r($is_condition,1).", assignment=".print_r($is_assignment,1)."): ".print_r($res,1)."</pre>", 'error'); diff --git a/rules_shipping_base.php b/rules_shipping_base.php index ab8a9a7700e1a16ac1561aeb12a19b70a40bb9e0..1771c263f88747289392c887755312d935e4dd26 100644 --- a/rules_shipping_base.php +++ b/rules_shipping_base.php @@ -39,11 +39,17 @@ function is_equal($a, $b) { return $a == $b; } } + + /** Shipping costs according to general rules. * Supported Variables: Weight, ZIP, Amount, Products (1 for each product, even if multiple ordered), Articles * Assignable variables: Shipping, Name */ class plgVmShipmentRules_Shipping_Base extends vmPSPlugin { + // Store the parsed and possibly evaluated rules for each method (method ID is used as key) + protected $rules = array(); + protected $match = array(); + var $custom_functions = array (); /** * @param object $subject @@ -234,43 +240,41 @@ class plgVmShipmentRules_Shipping_Base extends vmPSPlugin { protected function evaluateMethodRules ($cart, $method, $cart_prices) { // $method->match will cache the matched rule and the modifiers - if ($method->evaluated) { - return $method->match; + if (isset($this->match[$method->virtuemart_shipmentmethod_id])) { + return $this->match[$method->virtuemart_shipmentmethod_id]; } else { - $method->evaluated = True; - $method->match = Null; // Evaluate all rules and find the matching ones (including modifiers and definitions!) $result = array("rule"=>Null, "rule_name"=>"", "modifiers_add"=>array(), "modifiers_multiply"=>array()); $cartvals = $this->getCartValues ($cart, $cart->products, $method, $cart_prices); - foreach ($method->rules as $r) { + foreach ($this->rules[$method->virtuemart_shipmentmethod_id] as $r) { if ($r->matches($cartvals)) { - switch ($r->getType() { + $rtype = $r->getType(); + switch ($rtype) { case 'shipping': case 'shippingwithtax': case 'noshipping': $result["rule"] = $r; - $result["rule_name"] = $r->getRuleName($cartvals); - break; - case 'extrashippingcharge': - $result["modifiers_add"][] = $r; + $result["rule_name"] = $r->getRuleName(); break; - case 'extrashippingmultiplier': - $result["modifiers_multiply"][] = $r; + case 'modifiers_add': + case 'modifiers_multiply': + $result[$rtype][] = $r; break; case 'definition': // A definition has modified the $cartvals, but has no other effects break; default: - $this->printWarning(JText::sprintf('VMSHIPMENT_RULES_UNKNOWN_TYPE', $r->rulestring)); + $this->printWarning(JText::sprintf('VMSHIPMENT_RULES_UNKNOWN_TYPE', $r->getType(), $r->rulestring)); break; } } if (!is_null($result["rule"])) { - $method->match = $result; + $this->match[$method->virtuemart_shipmentmethod_id] = $result; return $result; // <- This also breaks out of the foreach loop! } } } - // None of the rules matched, so return NULL; + // None of the rules matched, so return NULL, but keep the evaluated results; + $this->match[$method->virtuemart_shipmentmethod_id] = $result; return NULL; } @@ -281,7 +285,7 @@ class plgVmShipmentRules_Shipping_Base extends vmPSPlugin { * @return bool */ protected function checkConditions ($cart, $method, $cart_prices) { - if (!isset($method->rules)) + if (!isset($this->rules[$method->virtuemart_shipmentmethod_id])) $this->parseMethodRules($method); $match = $this->evaluateMethodRules ($cart, $method, $cart_prices); if ($match) { @@ -308,22 +312,24 @@ class plgVmShipmentRules_Shipping_Base extends vmPSPlugin { * @return int */ function getCosts (VirtueMartCart $cart, $method, $cart_prices) { - if (!isset($method->rules)) $this->parseMethodRules($method); + if (!isset($this->rules[$method->virtuemart_shipmentmethod_id])) + $this->parseMethodRules($method); $match = $this->evaluateMethodRules ($cart, $method, $cart_prices); if ($match) { $r = $match["rule"]; vmdebug('Rule ' . $match["rule_name"] . ' ('.$r->rulestring.') matched.'); $method->tax_id = $r->tax_id; + // TODO: Shall we include the name of the modifiers, too? $method->rule_name = $match["rule_name"]; // Final shipping costs are calculated as: // Shipping*ExtraShippingMultiplier + ExtraShippingCharge // with possibly multiple modifiers - $method->cost = $r->getShippingCosts($cartvals); + $method->cost = $r->getShippingCosts(); foreach ($match['modifiers_multiply'] as $modifier) { - $method->cost *= $modifier->getShippingCosts($cartvals); + $method->cost *= $modifier->getShippingCosts(); } foreach ($match['modifiers_add'] as $modifier) { - $method->cost += $modifier->getShippingCosts($cartvals); + $method->cost += $modifier->getShippingCosts(); } $method->includes_tax = $r->includes_tax; return $method->cost; @@ -454,7 +460,7 @@ class plgVmShipmentRules_Shipping_Base extends vmPSPlugin { } protected function createMethodRule ($r, $countries, $tax) { - return new ShippingRule(this, $r, $countries, $tax); + return new ShippingRule($this, $r, $countries, $tax); } private function parseMethodRule ($rulestring, $countries, $tax, &$method) { @@ -462,12 +468,13 @@ class plgVmShipmentRules_Shipping_Base extends vmPSPlugin { foreach ($rules1 as $r) { // Ignore empty lines if (empty($r)) continue; - $method->rules[] = $this->createMethodRule ($r, $countries, $tax); + $this->rules[$method->virtuemart_shipmentmethod_id][] = $this->createMethodRule ($r, $countries, $tax); } } protected function parseMethodRules (&$method) { - if (!isset($method->rules)) $method->rules = array(); + if (!isset($this->rules[$method->virtuemart_shipmentmethod_id])) + $this->rules[$method->virtuemart_shipmentmethod_id] = array(); $this->parseMethodRule ($method->rules1, $method->countries1, $method->tax_id1, $method); $this->parseMethodRule ($method->rules2, $method->countries2, $method->tax_id2, $method); $this->parseMethodRule ($method->rules3, $method->countries3, $method->tax_id3, $method); @@ -639,7 +646,6 @@ class plgVmShipmentRules_Shipping_Base extends vmPSPlugin { 'discountamount' => 0, 'pricewithouttax' => 0, ); -// JFactory::getApplication()->enqueueMessage("<pre>Product: ".print_r($products, 1)."</pre>", 'error'); if (!empty($cart_prices)) { // get prices for the whole cart -> simply user the cart_prices $data['amount'] = $cart_prices['salesPrice']; @@ -679,7 +685,6 @@ class plgVmShipmentRules_Shipping_Base extends vmPSPlugin { } protected function getCartValues (VirtueMartCart $cart, $products, $method, $cart_prices) { -// JFactory::getApplication()->enqueueMessage("<pre>getCartValues, cart=".print_r($cart, 1)."</pre>", 'error'); $address = (($cart->ST == 0 || $cart->STSameAsBT == 1) ? $cart->BT : $cart->ST); $cartvals = array_merge ( array( @@ -691,6 +696,7 @@ class plgVmShipmentRules_Shipping_Base extends vmPSPlugin { // Add 'skus', 'categories', 'vendors' variables: $this->getOrderListProperties ($cart, $products), // Add country / state variables: + $this->getOrderAddress ($cart, $address), $this->getOrderCountryState ($cart, $address), // Add Total/Min/Max weight and dimension variables: $this->getOrderWeights ($cart, $products, $method->weight_unit), @@ -698,27 +704,15 @@ class plgVmShipmentRules_Shipping_Base extends vmPSPlugin { ); // Let child classes update the $cartvals array, or add new variables $this->addCustomCartValues($cart, $products, $cart_prices, $cartvals); + // Finally, call the triger of vmshipmentrules plugins to let them add/modify variables JPluginHelper::importPlugin('vmshipmentrules'); $dispatcher = JDispatcher::getInstance(); $dispatcher->trigger('onVmShippingRulesGetCartValues',array(&$cartvals, $cart, $products, $method, $cart_prices)); - - // Add the whole list of cart value to the values, so we can print them out as a debug statement! - $cartvals['values_debug'] = print_r($cartvals,1); - $cartvals['values'] = $cartvals; + return $cartvals; } - /** Filter the given array of products and return only those that belong to the categories, manufacturers, - * vendors or products given in the $filter_conditions. The $filter_conditions is an array of the form: - * array( 'skus'=>array(....), 'categories'=>array(1,2,3,42), 'manufacturers'=>array(77,78,83), 'vendors'=>array(1,2)) - * Notice that giving an empty array for any of the keys means "no restriction" and is exactly the same - * as leaving out the enty altogether - */ - function filterProducts($products, $filter_conditions) { - - - } /** * Create the table for this plugin if it does not yet exist. * This functions checks if the called plugin is active one. @@ -840,8 +834,31 @@ if (class_exists ('ShippingRule')) { return; } +/** Filter the given array of products and return only those that belong to the categories, manufacturers, + * vendors or products given in the $filter_conditions. The $filter_conditions is an array of the form: + * array( 'skus'=>array(....), 'categories'=>array(1,2,3,42), 'manufacturers'=>array(77,78,83), 'vendors'=>array(1,2)) + * Notice that giving an empty array for any of the keys means "no restriction" and is exactly the same + * as leaving out the enty altogether + */ +function filterProducts($products, $filter_conditions) { + $result = array(); + foreach ($products as $p) { + if (!empty($filter_conditions['skus']) && !in_array($p->product_sku, $filter_conditions['skus'])) + continue; + if (!empty($filter_conditions['categories']) && count(array_intersect($filter_conditions['categories'], $p->product_categories))==0) + continue; + if (!empty($filter_conditions['manufacturers']) && count(array_intersect($filter_conditions['manufacturers'], $p->product_manufacturers))==0) + continue; + if (!empty($filter_conditions['vendors']) && count(array_intersect($filter_conditions['vendors'], $p->product_vendors))==0) + continue; + $result[] = $p; + } + return $result; +} + + class ShippingRule { - var $method = Null; + var $plugin = Null; var $rulestring = ''; var $name = ''; var $ruletype = ''; @@ -855,7 +872,7 @@ class ShippingRule { var $tax_id = 0; var $includes_tax = 0; - function __construct ($method, $rule, $countries, $tax_id) { + function __construct ($plugin, $rule, $countries, $tax_id) { if (is_array($countries)) { $this->countries = $countries; } elseif (!empty($countries)) { @@ -864,17 +881,17 @@ class ShippingRule { $this->tax_id = $tax_id; $this->rulestring = $rule; $this->parseRule($rule); - $this->method=$method; + $this->plugin=$plugin; } - function parseRule($rule) { + protected function parseRule($rule) { $ruleparts=explode(';', $rule); foreach ($ruleparts as $p) { $this->parseRulePart($p); } } - function handleAssignment ($var, $value, $rulepart) { + protected function handleAssignment ($var, $value, $rulepart) { switch (strtolower($var)) { case 'name': $this->name = $value; break; case 'shipping': $this->shipping = $value; $this->includes_tax = False; $this->ruletype='shipping'; break; @@ -890,8 +907,7 @@ class ShippingRule { } } - - function tokenize_expression ($expression) { + protected function tokenize_expression ($expression) { // First, extract all strings, delimited by quotes, then all text operators // (OR, AND, in; but make sure we don't capture parts of words, so we need to // use lookbehind/lookahead patterns to exclude OR following another letter @@ -902,7 +918,7 @@ class ShippingRule { return $atoms; } - function parseRulePart($rulepart) { + protected function parseRulePart($rulepart) { /* In the basic version, we only split at the comparison operators and assume each term on the LHS and RHS is one variable or constant */ /* In the advanced version, all conditions and costs can be given as a full mathematical expression */ /* Both versions create an expression tree, which can be easily evaluated in evaluateTerm */ @@ -923,6 +939,7 @@ class ShippingRule { $operators = array('<', '<=', '=', '>', '>=', '=>', '=<', '<>', '!=', '=='); if (count($atoms)==1) { $this->shipping = $this->parseShippingTerm($atoms[0]); + $this->ruletype = 'shipping'; } elseif ($atoms[1]=='=') { $this->handleAssignment ($atoms[0], $atoms[2], $rulepart); } else { @@ -940,7 +957,7 @@ class ShippingRule { } } - function parseShippingTerm($expr) { + protected function parseShippingTerm($expr) { /* In the advanced version, shipping cost can be given as a full mathematical expression */ // If the shipping term starts with a double quote, it is a string, so don't turn it into lowercase. // All other expressions need to be turned into lowercase, because variable names are case-insensitive! @@ -951,7 +968,7 @@ class ShippingRule { } } - function evaluateComparison ($terms, $vals) { + protected function evaluateComparison ($terms, $vals) { while (count($terms)>2) { $res = false; switch ($terms[1]) { @@ -987,7 +1004,7 @@ class ShippingRule { return true; } - function evaluateListFunction ($function, $args) { + protected function evaluateListFunction ($function, $args) { # First make sure that all arguments are actually lists: $allarrays = True; foreach ($args as $a) { @@ -1016,7 +1033,7 @@ class ShippingRule { } } - function evaluateListContainmentFunction ($function, $args) { + protected function evaluateListContainmentFunction ($function, $args) { # First make sure that the first argument is a list: if (!is_array($args[0])) { JFactory::getApplication()->enqueueMessage(JText::sprintf('VMSHIPMENT_RULES_EVALUATE_LISTFUNCTION_CONTAIN_ARGS', $function, $this->rulestring), 'error'); @@ -1099,12 +1116,12 @@ class ShippingRule { // Functions with variable number of args switch ($func) { case "max": - return max($args); break; + return max($args); case "min": - return min($args); break; + return min($args); case "list": case "array": - return $args; break; + return $args; // List functions: case "length": case "complement": @@ -1114,19 +1131,42 @@ class ShippingRule { case "join": case "intersection": case "list_equal": - return $this->evaluateListFunction ($func, $args); break; + return $this->evaluateListFunction ($func, $args); case "contains_any": case "contains_all": case "contains_only": case "contains_none": - return $this->evaluateListContainmentFunction($func, $args); break; + return $this->evaluateListContainmentFunction($func, $args); + } + + // None of the built-in function // No known function matches => print an error, return 0 JFactory::getApplication()->enqueueMessage(JText::sprintf('VMSHIPMENT_RULES_EVALUATE_UNKNOWN_FUNCTION', $function, $this->rulestring), 'error'); return 0; } - - function evaluateTerm ($expr, $vals) { + + protected function evaluateVariable ($expr, $vals) { + $varname = strtolower($expr); + if (array_key_exists(strtolower($expr), $vals)) { + return $vals[strtolower($expr)]; + } elseif ($varname=='values') { + return $vals; + } elseif ($varname=='values_debug') { + return print_r($vals,1); + } else { + JFactory::getApplication()->enqueueMessage(JText::sprintf('VMSHIPMENT_RULES_EVALUATE_UNKNOWN_VALUE', $expr, $this->rulestring), 'error'); + return null; + } + } + + protected function evaluateTerm ($expr, $vals) { + // The scoping functions need to be handled differently, because they first need to adjust the cart variables to the filtered product list + // before evaluating its first argument. So even though parsing the rules handles scoping functions like any other function, their + // evaluation is fundamentally different and is special-cased here: + $scoping_functions = array("evaluate_for_categories", "evaluate_for_products", "evaluate_for_vendors", "evaluate_for_manufacturers"); + $is_scoping = is_array($expr) && ($expr[0]=="FUNCTION") && (count($expr)>1) && in_array($expr[1], $scoping_functions); + if (is_null($expr)) { return $expr; } elseif (is_numeric ($expr)) { @@ -1135,16 +1175,14 @@ class ShippingRule { // Explicit strings are delimited by '...' or "..." if (($expr[0]=='\'' || $expr[0]=='"') && ($expr[0]==substr($expr,-1)) ) { return substr($expr,1,-1); - } elseif (array_key_exists(strtolower($expr), $vals)) { - return $vals[strtolower($expr)]; } else { - JFactory::getApplication()->enqueueMessage(JText::sprintf('VMSHIPMENT_RULES_EVALUATE_UNKNOWN_VALUE', $expr, $this->rulestring), 'error'); - return null; + return $this->evaluateVariable($expr, $vals); } } elseif (is_array($expr)) { // Operator $op = array_shift($expr); $args = array(); + // First evaluate all operands and only after that apply the function / operator to the already evaluated arguments $evaluate = true; if ($op == "FUNCTION") { $evaluate = false; @@ -1153,6 +1191,7 @@ class ShippingRule { $term = $evaluate ? ($this->evaluateTerm($e, $vals)) : $e; if ($op == 'COMPARISON') { // For comparisons, we only evaluate every other term (the operators are NOT evaluated!) + // The data format for comparisons is: array('COMPARISON', $operand1, '<', $operand2, '<=', ....) $evaluate = !$evaluate; } if ($op == "FUNCTION") { @@ -1162,6 +1201,7 @@ class ShippingRule { $args[] = $term; } $res = false; + // Finally apply the operaton to the evaluated argument values: switch ($op) { // Logical operators: case 'OR': foreach ($args as $a) { $res = ($res || $a); }; break; @@ -1211,11 +1251,11 @@ class ShippingRule { } } - function calculateShipping ($vals) { + protected function calculateShipping ($vals) { return $this->evaluateTerm($this->shipping, $vals); } - function evaluateRule (&$vals) { + protected function evaluateRule (&$vals) { if ($this->evaluated) return; // Already evaluated @@ -1237,34 +1277,45 @@ class ShippingRule { } // All conditions match $this->match = True; + // Calculate the value (i.e. shipping cost or modifier) $this->value = $this->calculateShipping($vals); // For definitions add the variable to the vals if ($this->ruletype=='definition') { - $vals[$this->name] = $this->value; + $vals[strtolower($this->name)] = $this->value; } - } - function matches(&$vals) { - $this->evaluateRule($vals); - return $this->match; - } - - function getRuleName($vals) { + // Evaluate the rule name as a translatable string with variables inserted: // Replace all {variable} tags in the name by the variables from $vals $matches=array(); $name=JText::_($this->name); preg_match_all('/{([A-Za-z0-9_]+)}/', $name, $matches); foreach ($matches[1] as $m) { - $var=strtolower($m); - if (isset($vals[$var])) { - $name = str_replace("{".$m."}", strval($vals[$var]), $name); + $val = $this->evaluateVariable($m, $vals); + if ($val !== null) { + $name = str_replace("{".$m."}", $val, $name); } } - return $name; + $this->rulename = $name; } - - function getShippingCosts($vals) { + + function matches(&$vals) { $this->evaluateRule($vals); + return $this->match; + } + + function getType() { + return $this->ruletype; + } + + function getRuleName() { + if (!$this->evaluated) + vmDebug('WARNING: getRuleName called without prior evaluation of the rule, e.g. by calling rule->matches(...)'); + return $this->rulename; + } + + function getShippingCosts() { + if (!$this->evaluated) + vmDebug('WARNING: getShippingCosts called without prior evaluation of the rule, e.g. by calling rule->matches(...)'); return $this->value; }