diff --git a/documents/ShippingByRules_Framework_API.txt b/documents/ShippingByRules_Framework_API.txt new file mode 100644 index 0000000000000000000000000000000000000000..3d4c999ff28e5d0787b773ebd71481c48f8e2d40 --- /dev/null +++ b/documents/ShippingByRules_Framework_API.txt @@ -0,0 +1,43 @@ +RulesShippingFramework: +======================= + + +API functions: +~~~~~~~~~~~~~~ + -) setup() // Used in the plugin's constructor + -) registerCallback($callback, $func) + + -) parseRuleSyntax($rulestring, $countries, $tax) + -) checkConditions ($cart, $method, $cart_prices) + -) getCosts($cart, $method, $cart_prices, $variables=null) // The central function to calculate the shipping cost + -) getRuleName($methodid) + -) getRuleVariables($methodid) + + -) filterProducts($products, $conditions) // Used in the Rule's evaluateScoping + + +E-commerce specific functions (to be overridden in child classes!): +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + -) printWarning($message) + -) __($string) + -) getCustomFunctions() + -) addPluginCartValues($cart, $products, $method, $cart_prices, &$values) + + -) getOrderArticles($cart, $products) + -) getOrderProducts ($cart, $products) + -) getOrderDimensions ($cart, $products, $length_dimension) + -) getOrderWeights ($cart, $products, $weight_unit) + -) getOrderListProperties ($cart, $products) + -) getOrderAddress ($cart) + -) getOrderPrices ($cart, $products, $cart_prices) + + -) filterProducts($products, $conditions) // Used in the Rule's evaluateScoping + + + +Callback functions: +~~~~~~~~~~~~~~~~~~~ + -) translate($message) + -) initRule($framework, $rulestring, $countries, $tax) + -) addCustomCartValues ($cart, $products, $cart_prices, &$values) + diff --git a/library/rules_shipping_framework.php b/library/rules_shipping_framework.php new file mode 100644 index 0000000000000000000000000000000000000000..271664f075ffef9e80f2ecfa4f09a48b2e655522 --- /dev/null +++ b/library/rules_shipping_framework.php @@ -0,0 +1,1203 @@ +<?php + +defined ('_JEXEC') or die('Restricted access'); + +/** + * Shipment plugin for general, rules-based shipments, like regular postal services with complex shipping cost structures + * + * @package VirtueMart + * @subpackage Plugins - shipment + * @copyright Copyright (C) 2004-2012 VirtueMart Team - All rights reserved. + * @copyright Copyright (C) 2013 Reinhold Kainhofer, reinhold@kainhofer.com + * @license http://www.gnu.org/copyleft/gpl.html GNU/GPL, see LICENSE.txt + * VirtueMart is free software. This version may have been modified pursuant + * to the GNU General Public License, and as distributed it includes or + * is derivative of works licensed under the GNU General Public License or + * other free or open source software licenses. + * + * @author Reinhold Kainhofer, based on the weight_countries shipping plugin by Valerie Isaksen + * + */ +// Only declare the class once... +if (class_exists ('RulesShippingFramework')) { + return; +} + + +function print_array($obj) { + $res = ""; + if (is_array($obj)) { + $res .= "array("; + $sep = ""; + foreach ($obj as $e) { + $res .= $sep . print_array($e); + $sep = ", "; + } + $res .= ")"; + } elseif (is_string($obj)) { + $res .= "\"$obj\""; + } else { + $res .= (string)$obj; + } + return $res; +} + +function is_equal($a, $b) { + if (is_array($a) && is_array($b)) { + return !array_diff($a, $b) && !array_diff($b, $a); + } elseif (is_string($a) && is_string($b)) { + return strcmp($a,$b) == 0; + } else { + return $a == $b; + } +} + +class RulesShippingFramework { + static $_version = "0.1"; + protected $_callbacks = array(); + // Store the parsed and possibly evaluated rules for each method (method ID is used as key) + protected $rules = array(); + protected $match = array(); + protected $last_cartvals = array(); + protected $last_rulename = ''; + var $custom_functions = array (); + + function __construct() { +// $this->registerCallback('initRule', array($this, 'createMethodRule')); +// $this->registerCallback('addCustomCartValues', array($this, 'addCustomCartValues')); + } + + + + /* Callback handling */ + + /** + * Register a callback for one of the known callback hooks. + * Valid callbacks are (together with their arguments): + * - translate($string) + * @param string $callback + * The name of the callback hook (string) + * @param function $func + * The function (usually a member of the plugin object) for the callback + * @return none + */ + public function registerCallback($callback, $func) { + $this->callbacks[$callback] = $func; + } + + public function __($string) { + if (isset($this->callbacks["translate"])) { + return $this->callbacks["translate"]($string); + } else { + return $string; + } + } + + function getCustomFunctions() { + // Let other plugins add custom functions! + // This function is expected to return an array of the form: + // array ('functionname1' => 'function-to-be-called', + // 'functionname2' => array($classobject, 'memberfunc')), + // ...); + return array (); + } + + /** + * Function printWarning: This needs to be overwritten in derived classes! + */ + public function printWarning($message) { + // TODO! + } + + function setup() { + $custfuncdefs = $this->getCustomFunctions(); + // Loop through the return values of all plugins: + foreach ($custfuncdefs as $custfuncs) { + if (empty($custfuncs)) + continue; + if (!is_array($custfuncs)) { + $this->printWarning(JText::sprintf('VMSHIPMENT_RULES_CUSTOMFUNCTIONS_NOARRAY')); + } + // Now loop through all custom function definitions of this plugin + // If a function was registered before, print a warning and use the first definition + foreach ($custfuncs as $fname => $func) { + if (isset($this->custom_functions[$fname])) { + $this->printWarning(JText::sprintf('VMSHIPMENT_RULES_CUSTOMFUNCTIONS_ALREADY_DEFINED', $fname)); + } else { + vmDebug("Defining custom function $fname"); + $this->custom_functions[strtolower($fname)] = $func; + } + } + } + } + + /** + * Functions to calculate the cart variables: + * - getOrderArticles($cart, $products) + * - getOrderProducts + * - getOrderDimensions + */ + /** Functions to calculate all the different variables for the given cart and given (sub)set of products in the cart */ + protected function getOrderArticles ($cart, $products) { + return 0; + } + + protected function getOrderProducts ($cart, $products) { + return count($products); + } + + protected function getOrderDimensions ($cart, $products, $length_dimension) { + return array(); + } + + protected function getOrderWeights ($cart, $products, $weight_unit) { + return array(); + } + + protected function getOrderListProperties ($cart, $products) { + return array(); + } + + protected function getOrderAddress ($cart) { + return array(); + } + + protected function getOrderPrices ($cart, $products, $cart_prices) { + return array(); + } + + /** Allow child classes to add additional variables for the rules or modify existing one + */ + protected function addCustomCartValues ($cart, $products, $cart_prices, &$values) { + if (isset($this->callbacks['addCustomCartValues'])) { + return $this->callbacks['addCustomCartValues']($cart, $products, $cart_prices, $values); + } + } + protected function addPluginCartValues($cart, $products, $method, $cart_prices, &$values) { + } + + public function getCartValues ($cart, $products, $method, $cart_prices) { + $cartvals = array_merge ( + array( + 'articles'=>$this->getOrderArticles($cart, $products), + 'products'=>$this->getOrderProducts($cart, $products), + ), + // Add the prices, optionally calculated from the products subset of the cart + $this->getOrderPrices ($cart, $products, $cart_prices), + // Add 'skus', 'categories', 'vendors' variables: + $this->getOrderListProperties ($cart, $products), + // Add country / state variables: + $this->getOrderAddress ($cart), + // Add Total/Min/Max weight and dimension variables: + $this->getOrderWeights ($cart, $products, $method->weight_unit), + $this->getOrderDimensions ($cart, $products, $method->length_unit) + ); + // Let child classes update the $cartvals array, or add new variables + $this->addCustomCartValues($cart, $products, $cart_prices, $cartvals); + // Let custom plugins update the $cartvals array or add new variables + $this->addPluginCartValues($cart, $products, $method, $cart_prices, $cartvals); + + return $cartvals; + } + + /** This function evaluates all rules, one after the other until it finds a matching rule that + * defines shipping costs (or uses NoShipping). If a modifier or definition is encountered, + * its effect is stored, but the loop continues */ + protected function evaluateMethodRules ($cart, $method, $cart_prices) { + $id = $method->virtuemart_shipmentmethod_id; // TODO: Generalize to other ecommerce systems! + // $method->match will cache the matched rule and the modifiers + if (isset($this->match[$id])) { + return $this->match[$id]; + } else { + // Evaluate all rules and find the matching ones (including modifiers and definitions!) + $cartvals = $this->getCartValues ($cart, $cart->products, $method, $cart_prices); + $result = array("rule"=>Null, "rule_name"=>"", "modifiers_add"=>array(), "modifiers_multiply"=>array(), "cartvals"=>$cartvals); + // Pass a callback function to the rules to obtain the cartvals for a subset of the products + $this_class = $this; + $cartvals_callback = function ($products) use ($this_class, $cart, $method, $cart_prices) { + return $this_class->getCartValues ($cart, $products, $method, NULL); + }; + foreach ($this->rules[$id] as $r) { + if ($r->matches($cartvals, $cart->products, $cartvals_callback)) { + $rtype = $r->getType(); + switch ($rtype) { + case 'shipping': + case 'shippingwithtax': + case 'noshipping': + $result["rule"] = $r; + $result["rule_name"] = $r->getRuleName(); + break; + case 'modifiers_add': + case 'modifiers_multiply': + $result[$rtype][] = $r; + break; + case 'definition': // A definition updates the $cartvals, but has no other effects + $cartvals[strtolower($r->getRuleName())] = $r->getValue(); + break; + default: + $this->helper->printWarning(JText::sprintf('VMSHIPMENT_RULES_UNKNOWN_TYPE', $r->getType(), $r->rulestring)); + break; + } + } + if (!is_null($result["rule"])) { + $this->match[$id] = $result; + return $result; // <- This also breaks out of the foreach loop! + } + } + } + // None of the rules matched, so return NULL, but keep the evaluated results; + $this->match[$id] = $result; + return NULL; + } + + /** + * @param \VirtueMartCart $cart + * @param int $method + * @param array $cart_prices + * @return bool + */ + public function checkConditions ($cart, $method, $cart_prices) { + if (!isset($this->rules[$method->virtuemart_shipmentmethod_id])) + $this->parseMethodRules($method); + $match = $this->evaluateMethodRules ($cart, $method, $cart_prices); + if ($match && !is_null ($match['rule'])) { + $method->rule_name = $match["rule_name"]; + // If NoShipping is set, this method should NOT offer any shipping at all, so return FALSE, otherwise TRUE + // If the rule has a name, print it as warning (otherwise don't print anything) + if ($match['rule']->isNoShipping()) { + if (!empty($method->rule_name)) + $this->helper->pringWarning(JText::sprintf('VMSHIPMENT_RULES_NOSHIPPING_MESSAGE', $method->rule_name)); + vmdebug('checkConditions '.$method->shipment_name.' indicates NoShipping for this method, specified by rule "'.$method->rule_name.'" ('.$match['rule']->rulestring.').'); + return FALSE; + } else { + return TRUE; + } + } + vmdebug('checkConditions '.$method->shipment_name.' does not fulfill all conditions, no rule matches'); + return FALSE; + } + + /** + * @param VirtueMartCart $cart + * @param $method + * @param $cart_prices + * @return int + */ + function getCosts (VirtueMartCart $cart, $method, $cart_prices) { + 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(); + foreach ($match['modifiers_multiply'] as $modifier) { + $method->cost *= $modifier->getValue(); + } + foreach ($match['modifiers_add'] as $modifier) { + $method->cost += $modifier->getValue(); + } + $method->includes_tax = $r->includes_tax; + return $method->cost; + } + + vmdebug('getCosts '.$method->name.' does not return shipping costs'); + return 0; + } + + public function getRuleName($methodid) { + if (isset($this->match[$methodid])) { + return $this->match[$methodid]["rule_name"]; + } else { + return ''; + } + } + + public function getRuleVariables($methodid) { + if (isset($this->match[$methodid])) { + return $this->match[$methodid]["cartvals"]; + } else { + return array(); + } + } + + protected function createMethodRule ($r, $countries, $tax) { + if (isset($this->callbacks['initRule'])) { + return $this->callbacks['initRule']($this, $r, $countries, $tax); + } else { + return new ShippingRule($this, $r, $countries, $tax); + } + } + + // Parse the rule and append all rules to the rule set of the current shipment method (country/tax are already included in the rule itself!) + private function parseMethodRule ($rulestring, $countries, $tax, &$method) { + foreach ($this->parseRuleSyntax($rulestring, $countries, $tax) as $r) { + $this->rules[$method->virtuemart_shipmentmethod_id][] = $r; + } + } + + public function parseRuleSyntax($rulestring, $countries, $tax) { + $result = array(); + $rules1 = preg_split("/(\r\n|\n|\r)/", $rulestring); + foreach ($rules1 as $r) { + // Ignore empty lines + if (empty($r)) continue; + $result[] = $this->createMethodRule ($r, $countries, $tax); + } + return $result; + } + + protected function parseMethodRules (&$method) { + 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); + $this->parseMethodRule ($method->rules4, $method->countries4, $method->tax_id4, $method); + $this->parseMethodRule ($method->rules5, $method->countries5, $method->tax_id5, $method); + $this->parseMethodRule ($method->rules6, $method->countries6, $method->tax_id6, $method); + $this->parseMethodRule ($method->rules7, $method->countries7, $method->tax_id7, $method); + $this->parseMethodRule ($method->rules8, $method->countries8, $method->tax_id8, $method); + } + + /** 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 + */ + public function filterProducts($products, $filter_conditions) { + return array(); + } +} + +class ShippingRule { + var $framework = Null; + var $rulestring = ''; + var $name = ''; + var $ruletype = ''; + var $evaluated = False; + var $match = False; + var $value = Null; + + var $shipping = 0; + var $conditions = array(); + var $countries = array(); + var $tax_id = 0; + var $includes_tax = 0; + + function __construct ($framework, $rule, $countries, $tax_id) { + if (is_array($countries)) { + $this->countries = $countries; + } elseif (!empty($countries)) { + $this->countries[0] = $countries; + } + $this->tax_id = $tax_id; + $this->rulestring = $rule; + $this->parseRule($rule); + $this->framework=$framework; + } + + protected function parseRule($rule) { + $ruleparts=explode(';', $rule); + foreach ($ruleparts as $p) { + $this->parseRulePart($p); + } + } + + 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; + case 'shippingwithtax': $this->shipping = $value; $this->includes_tax = True; $this->ruletype='shipping'; break; + case 'variable': // Variable=... is the same as Definition=... + case 'definition': $this->name = strtolower($value); $this->ruletype = 'definition'; break; + case 'value': $this->shipping = $value; $this->ruletype = 'definition'; break; // definition values are also stored in the shipping member! + case 'extrashippingcharge': $this->shipping = $value; $this->ruletype = 'modifiers_add'; break; // modifiers are also stored in the shipping member! + case 'extrashippingmultiplier': $this->shipping = $value; $this->ruletype = 'modifiers_multiply'; break; // modifiers are also stored in the shipping member! + case 'comment': break; // Completely ignore all comments! + case 'condition': $this->conditions[] = $value; break; + default: JFactory::getApplication()->enqueueMessage(JText::sprintf('VMSHIPMENT_RULES_UNKNOWN_VARIABLE', $var, $rulepart), 'error'); + } + } + + 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 + // or followed by another letter) and then all arithmetic operators + $re = '/\s*("[^"]*"|\'[^\']*\'|<=|=>|>=|=<|<>|!=|==|<|=|>)\s*/i'; + $atoms = preg_split($re, $expression, -1, PREG_SPLIT_DELIM_CAPTURE|PREG_SPLIT_NO_EMPTY); + // JFactory::getApplication()->enqueueMessage("TOKENIZING '$expression' returns: <pre>".print_r($atoms,1)."</pre>", 'error'); + return $atoms; + } + + 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 */ + $rulepart = trim($rulepart); + if (empty($rulepart)) return; + + + // Special-case the name assignment, where we don't want to interpret the value as an arithmetic expression! + if (preg_match('/^\s*(name|variable|definition)\s*=\s*(["\']?)(.*)\2\s*$/i', $rulepart, $matches)) { + $this->handleAssignment ($matches[1], $matches[3], $rulepart); + return; + } + + // Split at all operators: + $atoms = $this->tokenize_expression ($rulepart); + + /* Starting from here, the advanced plugin is different! */ + $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 { + // Conditions, need at least three atoms! + while (count($atoms)>1) { + if (in_array ($atoms[1], $operators)) { + $this->conditions[] = array($atoms[1], $this->parseShippingTerm($atoms[0]), $this->parseShippingTerm($atoms[2])); + array_shift($atoms); + array_shift($atoms); + } else { + JFactory::getApplication()->enqueueMessage(JText::sprintf('VMSHIPMENT_RULES_UNKNOWN_OPERATOR', $atoms[1], $rulepart), 'error'); + $atoms = array(); + } + } + } + } + + 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! + if (substr($expr, 0, 1) === '"') { + return $expr; + } else { + return strtolower($expr); + } + } + + protected function evaluateComparison ($terms, $vals) { + while (count($terms)>2) { + $res = false; + switch ($terms[1]) { + case '<': $res = ($terms[0] < $terms[2]); break; + case '<=': + case '=<': $res = ($terms[0] <= $terms[2]); break; + case '==': $res = is_equal($terms[0], $terms[2]); break; + case '!=': + case '<>': $res = ($terms[0] != $terms[2]); break; + case '>=': + case '=>': $res = ($terms[0] >= $terms[2]); break; + case '>': $res = ($terms[0] > $terms[2]); break; + case '~': + $l=min(strlen($terms[0]), strlen($terms[2])); + $res = (strncmp ($terms[0], $terms[2], $l) == 0); + break; + default: + JFactory::getApplication()->enqueueMessage(JText::sprintf('VMSHIPMENT_RULES_UNKNOWN_OPERATOR', $terms[1], $this->rulestring), 'error'); + $res = false; + } + + if ($res==false) return false; + // Remove the first operand and the operator from the comparison: + array_shift($terms); + array_shift($terms); + } + if (count($terms)>1) { + // We do not have the correct number of terms for chained comparisons, i.e. two terms leftover instead of one! + JFactory::getApplication()->enqueueMessage(JText::sprintf('VMSHIPMENT_RULES_EVALUATE_UNKNOWN_ERROR', $this->rulestring), 'error'); + return false; + } + // All conditions were fulfilled, so we can return true + return true; + } + + protected function evaluateListFunction ($function, $args) { + # First make sure that all arguments are actually lists: + $allarrays = True; + foreach ($args as $a) { + $allarrays = $allarrays && is_array($a); + } + if (!$allarrays) { + JFactory::getApplication()->enqueueMessage(JText::sprintf('VMSHIPMENT_RULES_EVALUATE_LISTFUNCTION_ARGS', $function, $this->rulestring), 'error'); + return false; + + } + switch ($function) { + case "length": return count($args[0]); break; + case "union": + case "join": return call_user_func_array( "array_merge" , $args); break; + case "complement": return call_user_func_array( "array_diff" , $args); break; + case "intersection": return call_user_func_array( "array_intersect" , $args); break; + case "issubset": # Remove all of superset's elements to see if anything else is left: + return !array_diff($args[0], $args[1]); break; + case "contains": # Remove all of superset's elements to see if anything else is left: + # Notice the different argument order compared to issubset! + return !array_diff($args[1], $args[0]); break; + case "list_equal": return array_unique($args[0])==array_unique($args[1]); break; + default: + JFactory::getApplication()->enqueueMessage(JText::sprintf('VMSHIPMENT_RULES_EVALUATE_LISTFUNCTION_UNKNOWN', $function, $this->rulestring), 'error'); + return false; + } + } + + 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'); + return false; + } + // Extract the array from the args, the $args varialbe will now only contain the elements to be checked: + $array = array_shift($args); + switch ($function) { + case "contains_any": // return true if one of the $args is in the $array + foreach ($args as $a) { + if (in_array($a, $array)) + return true; + } + return false; + + case "contains_all": // return false if one of the $args is NOT in the $array + foreach ($args as $a) { + if (!in_array($a, $array)) + return false; + } + return true; + case "contains_only": // return false if one of the $array elements is NOT in $args + foreach ($array as $a) { + if (!in_array($a, $args)) + return false; + } + return true; + case "contains_none": // return false if one of the $args IS in the $array + foreach ($args as $a) { + if (in_array($a, $array)) + return false; + } + return true; + default: + JFactory::getApplication()->enqueueMessage(JText::sprintf('VMSHIPMENT_RULES_EVALUATE_LISTFUNCTION_UNKNOWN', $function, $this->rulestring), 'error'); + return false; + } + } + + /** Evaluate the given expression $expr only for the products that match the filter given by the scoping + * function and the corresponding conditions */ + protected function evaluateScoping($expr, $scoping, $conditionvals, $vals, $products, $cartvals_callback) { + if (count($conditionvals)<1) + return $this->evaluateTerm($expr, $vals, $products, $cartvals_callback); + + $filterkeys = array( + "evaluate_for_categories" => 'categories', + "evaluate_for_products" => 'products', + "evaluate_for_vendors" => 'vendors', + "evaluate_for_manufacturers" => 'manufacturers' + ); + + $conditions = array(); + if (isset($filterkeys[$scoping])) + $conditions[$filterkeys[$scoping]] = $conditionvals; + + // Pass the conditions to the parent plugin class to filter the current list of products: + $filteredproducts = $this->framework->filterProducts($products, $conditions); + // We have been handed a callback function to calculate the cartvals for the filtered list of products, so use it: + $filteredvals = $cartvals_callback($filteredproducts); + return $this->evaluateTerm ($expr, $filteredvals, $filteredproducts, $cartvals_callback); + } + + protected function evaluateFunction ($function, $args) { + $func = strtolower($function); + // Check if we have a custom function definition and use that if so. + // This is done first to allow plugins to override even built-in functions! + if (isset($this->plugin->custom_functions[$func])) { + vmDebug("Evaluating custom function $function, defined by a plugin"); + return call_user_func($this->plugin->custom_functions[$func], $args, $this); + } + + // Functions with no argument: + if (count($args) == 0) { + $dt = getdate(); + switch ($func) { + case "second": return $dt['seconds']; break; + case "minute": return $dt['minutes']; break; + case "hour": return $dt['hours']; break; + case "day": return $dt['mday']; break; + case "weekday":return $dt['wday']; break; + case "month": return $dt['mon']; break; + case "year": return $dt['year']; break; + case "yearday":return $dt['yday']; break; + } + } + // Functions with exactly one argument: + if (count($args) == 1) { + switch ($func) { + case "round": return round($args[0]); break; + case "ceil": return ceil ($args[0]); break; + case "floor": return floor($args[0]); break; + case "abs": return abs($args[0]); break; + case "not": return !$args[0]; break; + case "print_r": return print_r($args[0],1); break; + } + } + if (count($args) == 2) { + switch ($func) { + case "digit": return substr($args[0], $args[1]-1, 1); break; + case "round": return round($args[0]/$args[1])*$args[1]; break; + case "ceil": return ceil($args[0]/$args[1])*$args[1]; break; + case "floor": return floor($args[0]/$args[1])*$args[1]; break; + } + } + if (count($args) == 3) { + switch ($func) { + case "substring": return substr($args[0], $args[1]-1, $args[2]); break; + } + } + // Functions with variable number of args + switch ($func) { + case "max": + return max($args); + case "min": + return min($args); + case "list": + case "array": + return $args; + // List functions: + case "length": + case "complement": + case "issubset": + case "contains": + case "union": + case "join": + case "intersection": + case "list_equal": + return $this->evaluateListFunction ($func, $args); + case "contains_any": + case "contains_all": + case "contains_only": + case "contains_none": + 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; + } + + protected function evaluateVariable ($expr, $vals) { + $varname = strtolower($expr); + if (array_key_exists(strtolower($expr), $vals)) { + return $vals[strtolower($expr)]; + } elseif ($varname=='noshipping') { + return $varname; + } 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, $products, $cartvals_callback) { + // 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)) { + return $expr; + } elseif (is_string ($expr)) { + // Explicit strings are delimited by '...' or "..." + if (($expr[0]=='\'' || $expr[0]=='"') && ($expr[0]==substr($expr,-1)) ) { + return substr($expr,1,-1); + } else { + return $this->evaluateVariable($expr, $vals); + } + } elseif ($is_scoping) { + $op = array_shift($expr); // ignore the "FUNCTION" + $func = array_shift($expr); // The scoping function name + $expression = array_shift($expr); // The expression to be evaluated + $conditions = $expr; // the remaining $expr list now contains the conditions + return $this->evaluateScoping ($expression, $func, $conditions, $vals, $products, $cartvals_callback); + + } 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; + } + foreach ($expr as $e) { + $term = $evaluate ? ($this->evaluateTerm($e, $vals, $products, $cartvals_callback)) : $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") { + $evaluate = true; + } + if (is_null($term)) return null; + $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; + case '&&': + case 'AND': $res = true; foreach ($args as $a) { $res = ($res && $a); }; break; + case 'IN': $res = in_array($args[0], $args[1]); break; + + // Comparisons: + case '<': + case '<=': + case '=<': + case '==': + case '!=': + case '<>': + case '>=': + case '=>': + case '>': + case '~': + $res = $this->evaluateComparison(array($args[0], $op, $args[1]), $vals); break; + case 'COMPARISON': + $res = $this->evaluateComparison($args, $vals); break; + + // Unary operators: + case '.-': $res = -$args[0]; break; + case '.+': $res = $args[0]; break; + + // Binary operators + case "+": $res = ($args[0] + $args[1]); break; + case "-": $res = ($args[0] - $args[1]); break; + case "*": $res = ($args[0] * $args[1]); break; + case "/": $res = ($args[0] / $args[1]); break; + case "%": $res = (fmod($args[0], $args[1])); break; + case "^": $res = ($args[0] ^ $args[1]); break; + + // Functions: + case "FUNCTION": $func = array_shift($args); $res = $this->evaluateFunction($func, $args); break; + + default: $res = false; + } + +// JFactory::getApplication()->enqueueMessage("<pre>Result of ".print_r($expr,1)." is $res.</pre>", 'error'); + return $res; + } else { + // Neither string nor numeric, nor operator... + JFactory::getApplication()->enqueueMessage(JText::sprintf('VMSHIPMENT_RULES_EVALUATE_UNKNOWN_VALUE', $expr, $this->rulestring), 'error'); + return null; + } + } + + protected function calculateShipping ($vals, $products, $cartvals_callback) { + return $this->evaluateTerm($this->shipping, $vals, $products, $cartvals_callback); + } + + protected function evaluateRule (&$vals, $products, $cartvals_callback) { + if ($this->evaluated) + return; // Already evaluated + + $this->evaluated = True; + $this->match = False; // Default, set it to True below if all conditions match... + // First, check the country, if any conditions are given: + if (count ($this->countries) > 0 && !in_array ($vals['countryid'], $this->countries)) { +// vmdebug('Rule::matches: Country check failed: countryid='.print_r($vals['countryid'],1).', countries are: '.print_r($this->countries,1).'...'); + return; + } + + foreach ($this->conditions as $c) { + // All conditions have to match! + $ret = $this->evaluateTerm($c, $vals, $products, $cartvals_callback); + + if (is_null($ret) || (!$ret)) { + return; + } + } + // All conditions match + $this->match = True; + // Calculate the value (i.e. shipping cost or modifier) + $this->value = $this->calculateShipping($vals, $products, $cartvals_callback); + // 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) { + $val = $this->evaluateVariable($m, $vals); + if ($val !== null) { + $name = str_replace("{".$m."}", $val, $name); + } + } + $this->rulename = $name; + } + + function matches(&$vals, $products, $cartvals_callback) { + $this->evaluateRule($vals, $products, $cartvals_callback); + 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 getValue() { + if (!$this->evaluated) + vmDebug('WARNING: getValue called without prior evaluation of the rule, e.g. by calling rule->matches(...)'); + return $this->value; + } + function getShippingCosts() { + return $this->getValue(); + } + + function isNoShipping() { + // NoShipping is set, so if the rule matches, this method should not offer any shipping at all + return (is_string($this->shipping) && (strtolower($this->shipping)=="noshipping")); + } + +} + +/** Extend the shipping rules by allowing arbitrary mathematical expressions + */ +class ShippingRule_Advanced extends ShippingRule { + function __construct ($method, $rule, $countries, $tax_id) { + parent::__construct ($method, $rule, $countries, $tax_id); + } + + 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 + // or followed by another letter) and then all arithmetic operators + $re = '/\s*("[^"]*"|\'[^\']*\'|(?<![A-Za-z0-9])(?:OR|AND|IN)(?![A-Za-z0-9])|&&|<=|=>|>=|=<|<>|!=|==|<|=|>|~|\+|-|\*|\/|%|\(|\)|\^|,)\s*/i'; + $atoms = preg_split($re, $expression, -1, PREG_SPLIT_DELIM_CAPTURE|PREG_SPLIT_NO_EMPTY); + // JFactory::getApplication()->enqueueMessage("TOKENIZING '$expression' returns: <pre>".print_r($atoms,1)."</pre>", 'error'); + return $atoms; + } + + + /** parse the mathematical expressions using the Shunting Yard Algorithm by Dijkstra (with some extensions to allow arbitrary functions): + * First parse the string into an array of tokens (operators and operands) by a simple regexp with known operators as separators) + * TODO: Update this description to include unary operators and general function calls + * Then convert the infix notation into postfix (RPN), taking care of operator precedence + * 1) Initialize empty stack and empty result variable + * 2) Read infix expression from left to right, one atom at a time + * 3) If operand => Append to result + * 4) If operator: + * 4a) Pop operators from stack until opening parenthesis, operator of + * lower precedence or right-associative symbol of equal precedence. + * 4b) Push operator onto stack + * 5) If opening parenthesis => push onto stack + * 6) If closing parenthesis: + * 6a) Pop operators from stack until opening parenthesis is found + * 6b) push them to the result (not the opening parenthesis, of course) + * 7) At the end of the input, pop all operators from the stack and onto the result + * + * Afterwards, convert this RPN list into an expression tree to be evaluated + * + * For the full algorithm, including function parsing, see Wikipedia: + * http://en.wikipedia.org/wiki/Shunting_yard_algorithm + * + */ + 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 */ + $rulepart = trim($rulepart); + if (!isset($rulepart) || $rulepart==='') return; + + + // Special-case the name assignment, where we don't want to interpret the value as an arithmetic expression! + if (preg_match('/^\s*(name|variable|definition)\s*=\s*(["\']?)(.*)\2\s*$/i', $rulepart, $matches)) { + $this->handleAssignment ($matches[1], $matches[3], $rulepart); + return; + } + + // Split at all operators: + $atoms = $this->tokenize_expression ($rulepart); + + $operators = array( + ".-" => 100, ".+" => 100, + "IN" => 80, + "^" => 70, + "*" => 60, "/" => 60, "%" => 60, + "+" => 50, "-" => 50, + "<" => 40, "<=" => 40, ">" => 40, ">=" => 40, "=>" => 40, "=<" => 40, + "==" => 40, "!=" => 40, "<>" => 40, "~" => 40, + "&&" => 21, "AND" => 21, + "OR" => 20, + "=" => 10, + "(" => 0, ")" =>0 + ); + $unary_ops = array("-" => ".-", "+" => ".+"); + + // Any of these indicate a comparison and thus a condition: + $condition_ops = array('<', '<=', '=<', '<>', '!=', '==', '>', '>=', '=>', '~', 'OR', 'AND', '&&', 'IN'); + $comparison_ops = array('<', '<=', '=<', '<>', '!=', '==', '>', '>=', '=>', '~'); + $is_condition = false; + $is_assignment = false; + + $stack = array (); // 1)/ + $prev_token_operator = true; + $function_args = array(); + $out_stack = array(); + foreach ($atoms as $a) { // 2) + $aupper = strtoupper($a); # All operators are converted to uppercase! + + if ($a == ",") { // A function argument separator + // pop-and-apply all operators back to the left function paren + while (count($stack)>0) { // 4a) + $op = array_pop ($stack); + if ($op != "FUNCTION(") { + array_push ($out_stack, $op); + } else { + // No unary operator -> add it back to stack, exit loop + array_push ($stack, $op); + break; + } + } while (0); + $this_func = array_pop($function_args); + // Add current output stack as argument, reset temporary output stack + if (!empty($out_stack)) $this_func[] = $out_stack; + $function_args[] = $this_func; + $out_stack = array(); + $prev_token_operator = true; + + } elseif ($a == "(" and !$prev_token_operator) { // 5) parenthesis after operand -> FUNCTION CALL + array_push ($stack, "FUNCTION("); + // retrieve function name from RPN list (remove last entry from operand stack!) + $function = strtolower(array_pop ($out_stack)); + $new_stack = array(); + // Set up function call data structure on function_args stack: + $function_args[] = array(/* old operand stack: */$out_stack, $function); + // Use a the temporary operand stack until the closing paren restores the previous operand stack again + $out_stack = array(); + $prev_token_operator = true; + + } elseif ($a == "(" and $prev_token_operator) { // 5) real parenthesis + $stack[] = $a; + $prev_token_operator = true; + + } elseif ($a == ")") { // 6) parenthesis + do { + $op=array_pop($stack); // 6a) + if ($op == "(") { + break; // We have found the opening parenthesis + } elseif ($op =="FUNCTION(") { // Function call + // Remove function info from the functions stack; Format is array(PREVIOUS_OPERAND_STACK, FUNCTION, ARGS...) + $this_func = array_pop ($function_args); + // Append last argument (if not empty) + if (!empty($out_stack)) $this_func[] = $out_stack; + // restore old output/operand stack + $out_stack = array_shift($this_func); + // Function name is the next entry + $function = array_shift($this_func); + // All other entries are function arguments, so append them to the current operand stack + foreach ($this_func as $a) { + foreach ($a as $aa) { + $out_stack[] = $aa; + } + } + $out_stack[] = array("FUNCTION", $function, count($this_func)); + break; // We have found the opening parenthesis + } elseif (!is_null($op)) { + $out_stack[]=$op; // 6b) "normal" operators + } else { + // no ( and no operator, so the expression is wrong! + JFactory::getApplication()->enqueueMessage(JText::sprintf('VMSHIPMENT_RULES_PARSE_MISSING_PAREN', $rulepart), 'error'); + break; + } + } while (true); + $prev_token_operator = false; + + } elseif (isset($unary_ops[$aupper]) && $prev_token_operator) { // 4) UNARY operators + // Unary and binary operators need to be handled differently: + // Unary operators must only pop other unary operators, never any binary operator + $unary_op = $unary_ops[$aupper]; + // For unary operators, pop other unary operators from the stack until you reach an opening parenthesis, + // an operator of lower precedence, or a right associative symbol of equal precedence. + while (count($stack)>0) { // 4a) + $op = array_pop ($stack); + // Remove all other unary operators: + if (in_array ($op, $unary_ops)) { + array_push ($out_stack, $op); + } else { + // No unary operator -> add it back to stack, exit loop + array_push ($stack, $op); + break; + } + } while (0); + array_push ($stack, $unary_op); // 4b) + $prev_token_operator = true; + + } elseif (isset($operators[$aupper])) { // 4) BINARY operators + $prec = $operators[$aupper]; + $is_condition |= in_array($aupper, $condition_ops); + $is_assignment |= ($aupper == "="); + + // For operators, pop operators from the stack until you reach an opening parenthesis, + // an operator of lower precedence, or a right associative symbol of equal precedence. + while (count($stack)>0) { // 4a) + $op = array_pop ($stack); + // The only right-associative operator is =, which we allow at most once! + if ($op == "(" || $op == "FUNCTION(") { + // add it back to the stack! + array_push ($stack, $op); + break; + } elseif ($operators[$op]<$prec) { + // We found an operator with lower precedence, add it back to the stack! + array_push ($stack, $op); // 4b) + break; + } else { + array_push ($out_stack, $op); + } + } while (0); + array_push ($stack, $aupper); // 4b) + $prev_token_operator = true; + + } else { // 3) Everything else is an Operand + $out_stack[] = $a; + $prev_token_operator = false; + } + } + // Finally, pop all operators from the stack and append them to the result + while ($op=array_pop($stack)) { + // Opening parentheses should not be found on the stack any more. That would mean a closing paren is missing! + if ($op == "(") { + JFactory::getApplication()->enqueueMessage(JText::sprintf('VMSHIPMENT_RULES_PARSE_PAREN_NOT_CLOSED', $rulepart), 'error'); + } else { + array_push ($out_stack, $op); + } + } + if (!empty($function_args)) { + JFactory::getApplication()->enqueueMessage(JText::sprintf('VMSHIPMENT_RULES_PARSE_FUNCTION_NOT_CLOSED', $rulepart), 'error'); + } + + + /** Now, turn the RPN into an expression tree (i.e. "evaluate" it into a tree structure), according to Knuth: + * 1) Initialize an empty stack + * 2) Read the RPN from left to right + * 3) If operand, push it onto the stack + * 4) If operator: + * 4a) pop two operands + * 4b) perform operation + * 4c) push result onto stack + * 4d) (If less than two operands => ERROR, invalid syntax) + * 5) At the end of the RPN, pop the result from the stack. + * 5a) The stack should now be empty (otherwise, ERROR, invalid syntax) + */ + + $stack=array(); // 1) + foreach ($out_stack as $e) { // 2) + if (is_array($e) && $e[0]=="FUNCTION") { // A function call (#args is saved as $e[2], so remove that number of operands from the stack) + $function = $e[1]; + $argc = $e[2]; + $args = array(); + for ($i = $argc; $i > 0; $i--) { + $a = array_pop($stack); + array_unshift($args, $a); + } + array_unshift($args, $function); + array_unshift($args, "FUNCTION"); + $stack[] = $args; + } elseif (in_array($e, $unary_ops)) { // 4) unary operators + // Operator => apply to the last value on the stack + if (count($stack)<1) { // 4d) + JFactory::getApplication()->enqueueMessage(JText::sprintf('VMSHIPMENT_RULES_EVALUATE_SYNTAXERROR', $rulepart), 'error'); + array_push($stack, 0); + continue; + } + $o1 = array_pop($stack); + // Special-case chained comparisons: if e is a comparison, and operator(o1) is also a comparison, + // insert the arguments to the existing comparison instead of creating a new one + $op = array ($e, $o1); // 4b) + array_push ($stack, $op); // 4c) + } elseif (isset($operators[$e])) { // 4) binary operators + // Operator => apply to the last two values on the stack + if (count($stack)<2) { // 4d) + JFactory::getApplication()->enqueueMessage(JText::sprintf('VMSHIPMENT_RULES_EVALUATE_SYNTAXERROR', $rulepart), 'error'); + array_push($stack, 0); + continue; + } + $o2 = array_pop($stack); // 4a) + $o1 = array_pop($stack); + // Special-case chained comparisons, e.g. 1<=Amount<100: + // if e is a comparison, and operator(o1) is also a comparison, + // insert the arguments to the existing comparison instead of creating a new one + if (in_array ($e, $comparison_ops)) { + if ($o1[0]=='COMPARISON') { + $op = $o1; + // Append the new comparison to the existing one + array_push($op, $e, $o2); + } else { + $op = array ('COMPARISON', $o1, $e, $o2); + } + } else { + $op = array ($e, $o1, $o2); // 4b) + } + array_push ($stack, $op); // 4c) + } else { // 3) + // Operand => push onto stack + array_push ($stack, $e); + } + + } + // 5a) + if (count($stack) != 1) { + JFactory::getApplication()->enqueueMessage(JText::sprintf('VMSHIPMENT_RULES_EVALUATE_UNKNOWN_ERROR', $rulepart), 'error'); + JFactory::getApplication()->enqueueMessage(JText::sprintf('Outstack: <pre>%s</pre>', print_r($out_stack,1)), 'error'); + + $stack = array (0); + } + $res = array_pop($stack); // 5) + + if ($is_assignment) { // Assignments are handled first, so conditions can be assigned to variables + if ($res[0]=='=') { + $this->handleAssignment ($res[1], $res[2], $rulepart); + } else { + // Assignment has to be top-level! + JFactory::getApplication()->enqueueMessage(JText::sprintf('VMSHIPMENT_RULES_EVALUATE_ASSIGNMENT_TOPLEVEL', $rulepart), 'error'); + } + } elseif ($is_condition) { // Comparisons are conditions + $this->conditions[] = $res; + } 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' (type $this->ruletype) parsed into (condition=".print_r($is_condition,1).", assignment=".print_r($is_assignment,1)."): ".print_r($res,1)."</pre>", 'error'); + } + + +} + +// No closing tag diff --git a/rules_shipping_advanced.php b/rules_shipping_advanced.php index cfa5efb7ca3f10b908aff3f2e7035c6367b52d6c..b0e0653d17d92b4ecd38d4604c8ffc327347086e 100644 --- a/rules_shipping_advanced.php +++ b/rules_shipping_advanced.php @@ -27,23 +27,6 @@ if (!class_exists ('plgVmShipmentRules_Shipping_Base')) { } -function print_array($obj) { - $res = ""; - if (is_array($obj)) { - $res .= "array("; - $sep = ""; - foreach ($obj as $e) { - $res .= $sep . print_array($e); - $sep = ", "; - } - $res .= ")"; - } elseif (is_string($obj)) { - $res .= "\"$obj\""; - } else { - $res .= (string)$obj; - } - return $res; -} /** Shipping costs according to general rules. @@ -52,8 +35,10 @@ function print_array($obj) { class plgVmShipmentRules_Shipping_Advanced extends plgVmShipmentRules_Shipping_Base { function __construct (& $subject, $config) { parent::__construct ($subject, $config); + $this->helper->registerCallback('initRule', array($this, 'initRule')); + $this->helper->registerCallback('addCustomCartValues', array($this, 'addCustomCartValues')); } - protected function createMethodRule ($r, $countries, $tax) { + protected function initRule ($r, $countries, $tax) { return new ShippingRule_Advanced ($this, $r, $countries, $tax); } /** Allow child classes to add additional variables for the rules @@ -97,338 +82,3 @@ class plgVmShipmentRules_Shipping_Advanced extends plgVmShipmentRules_Shipping_B } } - - -/** Extend the shipping rules by allowing arbitrary mathematical expressions - */ -class ShippingRule_Advanced extends ShippingRule { - function __construct ($method, $rule, $countries, $tax_id) { - parent::__construct ($method, $rule, $countries, $tax_id); - } - - 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 - // or followed by another letter) and then all arithmetic operators - $re = '/\s*("[^"]*"|\'[^\']*\'|(?<![A-Za-z0-9])(?:OR|AND|IN)(?![A-Za-z0-9])|&&|<=|=>|>=|=<|<>|!=|==|<|=|>|~|\+|-|\*|\/|%|\(|\)|\^|,)\s*/i'; - $atoms = preg_split($re, $expression, -1, PREG_SPLIT_DELIM_CAPTURE|PREG_SPLIT_NO_EMPTY); - // JFactory::getApplication()->enqueueMessage("TOKENIZING '$expression' returns: <pre>".print_r($atoms,1)."</pre>", 'error'); - return $atoms; - } - - - /** parse the mathematical expressions using the Shunting Yard Algorithm by Dijkstra (with some extensions to allow arbitrary functions): - * First parse the string into an array of tokens (operators and operands) by a simple regexp with known operators as separators) - * TODO: Update this description to include unary operators and general function calls - * Then convert the infix notation into postfix (RPN), taking care of operator precedence - * 1) Initialize empty stack and empty result variable - * 2) Read infix expression from left to right, one atom at a time - * 3) If operand => Append to result - * 4) If operator: - * 4a) Pop operators from stack until opening parenthesis, operator of - * lower precedence or right-associative symbol of equal precedence. - * 4b) Push operator onto stack - * 5) If opening parenthesis => push onto stack - * 6) If closing parenthesis: - * 6a) Pop operators from stack until opening parenthesis is found - * 6b) push them to the result (not the opening parenthesis, of course) - * 7) At the end of the input, pop all operators from the stack and onto the result - * - * Afterwards, convert this RPN list into an expression tree to be evaluated - * - * For the full algorithm, including function parsing, see Wikipedia: - * http://en.wikipedia.org/wiki/Shunting_yard_algorithm - * - */ - 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 */ - $rulepart = trim($rulepart); - if (!isset($rulepart) || $rulepart==='') return; - - - // Special-case the name assignment, where we don't want to interpret the value as an arithmetic expression! - if (preg_match('/^\s*(name|variable|definition)\s*=\s*(["\']?)(.*)\2\s*$/i', $rulepart, $matches)) { - $this->handleAssignment ($matches[1], $matches[3], $rulepart); - return; - } - - // Split at all operators: - $atoms = $this->tokenize_expression ($rulepart); - - $operators = array( - ".-" => 100, ".+" => 100, - "IN" => 80, - "^" => 70, - "*" => 60, "/" => 60, "%" => 60, - "+" => 50, "-" => 50, - "<" => 40, "<=" => 40, ">" => 40, ">=" => 40, "=>" => 40, "=<" => 40, - "==" => 40, "!=" => 40, "<>" => 40, "~" => 40, - "&&" => 21, "AND" => 21, - "OR" => 20, - "=" => 10, - "(" => 0, ")" =>0 - ); - $unary_ops = array("-" => ".-", "+" => ".+"); - - // Any of these indicate a comparison and thus a condition: - $condition_ops = array('<', '<=', '=<', '<>', '!=', '==', '>', '>=', '=>', '~', 'OR', 'AND', '&&', 'IN'); - $comparison_ops = array('<', '<=', '=<', '<>', '!=', '==', '>', '>=', '=>', '~'); - $is_condition = false; - $is_assignment = false; - - $stack = array (); // 1)/ - $prev_token_operator = true; - $function_args = array(); - $out_stack = array(); - foreach ($atoms as $a) { // 2) - $aupper = strtoupper($a); # All operators are converted to uppercase! - - if ($a == ",") { // A function argument separator - // pop-and-apply all operators back to the left function paren - while (count($stack)>0) { // 4a) - $op = array_pop ($stack); - if ($op != "FUNCTION(") { - array_push ($out_stack, $op); - } else { - // No unary operator -> add it back to stack, exit loop - array_push ($stack, $op); - break; - } - } while (0); - $this_func = array_pop($function_args); - // Add current output stack as argument, reset temporary output stack - if (!empty($out_stack)) $this_func[] = $out_stack; - $function_args[] = $this_func; - $out_stack = array(); - $prev_token_operator = true; - - } elseif ($a == "(" and !$prev_token_operator) { // 5) parenthesis after operand -> FUNCTION CALL - array_push ($stack, "FUNCTION("); - // retrieve function name from RPN list (remove last entry from operand stack!) - $function = strtolower(array_pop ($out_stack)); - $new_stack = array(); - // Set up function call data structure on function_args stack: - $function_args[] = array(/* old operand stack: */$out_stack, $function); - // Use a the temporary operand stack until the closing paren restores the previous operand stack again - $out_stack = array(); - $prev_token_operator = true; - - } elseif ($a == "(" and $prev_token_operator) { // 5) real parenthesis - $stack[] = $a; - $prev_token_operator = true; - - } elseif ($a == ")") { // 6) parenthesis - do { - $op=array_pop($stack); // 6a) - if ($op == "(") { - break; // We have found the opening parenthesis - } elseif ($op =="FUNCTION(") { // Function call - // Remove function info from the functions stack; Format is array(PREVIOUS_OPERAND_STACK, FUNCTION, ARGS...) - $this_func = array_pop ($function_args); - // Append last argument (if not empty) - if (!empty($out_stack)) $this_func[] = $out_stack; - // restore old output/operand stack - $out_stack = array_shift($this_func); - // Function name is the next entry - $function = array_shift($this_func); - // All other entries are function arguments, so append them to the current operand stack - foreach ($this_func as $a) { - foreach ($a as $aa) { - $out_stack[] = $aa; - } - } - $out_stack[] = array("FUNCTION", $function, count($this_func)); - break; // We have found the opening parenthesis - } elseif (!is_null($op)) { - $out_stack[]=$op; // 6b) "normal" operators - } else { - // no ( and no operator, so the expression is wrong! - JFactory::getApplication()->enqueueMessage(JText::sprintf('VMSHIPMENT_RULES_PARSE_MISSING_PAREN', $rulepart), 'error'); - break; - } - } while (true); - $prev_token_operator = false; - - } elseif (isset($unary_ops[$aupper]) && $prev_token_operator) { // 4) UNARY operators - // Unary and binary operators need to be handled differently: - // Unary operators must only pop other unary operators, never any binary operator - $unary_op = $unary_ops[$aupper]; - // For unary operators, pop other unary operators from the stack until you reach an opening parenthesis, - // an operator of lower precedence, or a right associative symbol of equal precedence. - while (count($stack)>0) { // 4a) - $op = array_pop ($stack); - // Remove all other unary operators: - if (in_array ($op, $unary_ops)) { - array_push ($out_stack, $op); - } else { - // No unary operator -> add it back to stack, exit loop - array_push ($stack, $op); - break; - } - } while (0); - array_push ($stack, $unary_op); // 4b) - $prev_token_operator = true; - - } elseif (isset($operators[$aupper])) { // 4) BINARY operators - $prec = $operators[$aupper]; - $is_condition |= in_array($aupper, $condition_ops); - $is_assignment |= ($aupper == "="); - - // For operators, pop operators from the stack until you reach an opening parenthesis, - // an operator of lower precedence, or a right associative symbol of equal precedence. - while (count($stack)>0) { // 4a) - $op = array_pop ($stack); - // The only right-associative operator is =, which we allow at most once! - if ($op == "(" || $op == "FUNCTION(") { - // add it back to the stack! - array_push ($stack, $op); - break; - } elseif ($operators[$op]<$prec) { - // We found an operator with lower precedence, add it back to the stack! - array_push ($stack, $op); // 4b) - break; - } else { - array_push ($out_stack, $op); - } - } while (0); - array_push ($stack, $aupper); // 4b) - $prev_token_operator = true; - - } else { // 3) Everything else is an Operand - $out_stack[] = $a; - $prev_token_operator = false; - } - } - // Finally, pop all operators from the stack and append them to the result - while ($op=array_pop($stack)) { - // Opening parentheses should not be found on the stack any more. That would mean a closing paren is missing! - if ($op == "(") { - JFactory::getApplication()->enqueueMessage(JText::sprintf('VMSHIPMENT_RULES_PARSE_PAREN_NOT_CLOSED', $rulepart), 'error'); - } else { - array_push ($out_stack, $op); - } - } - if (!empty($function_args)) { - JFactory::getApplication()->enqueueMessage(JText::sprintf('VMSHIPMENT_RULES_PARSE_FUNCTION_NOT_CLOSED', $rulepart), 'error'); - } - - - /** Now, turn the RPN into an expression tree (i.e. "evaluate" it into a tree structure), according to Knuth: - * 1) Initialize an empty stack - * 2) Read the RPN from left to right - * 3) If operand, push it onto the stack - * 4) If operator: - * 4a) pop two operands - * 4b) perform operation - * 4c) push result onto stack - * 4d) (If less than two operands => ERROR, invalid syntax) - * 5) At the end of the RPN, pop the result from the stack. - * 5a) The stack should now be empty (otherwise, ERROR, invalid syntax) - */ - - $stack=array(); // 1) - foreach ($out_stack as $e) { // 2) - if (is_array($e) && $e[0]=="FUNCTION") { // A function call (#args is saved as $e[2], so remove that number of operands from the stack) - $function = $e[1]; - $argc = $e[2]; - $args = array(); - for ($i = $argc; $i > 0; $i--) { - $a = array_pop($stack); - array_unshift($args, $a); - } - array_unshift($args, $function); - array_unshift($args, "FUNCTION"); - $stack[] = $args; - } elseif (in_array($e, $unary_ops)) { // 4) unary operators - // Operator => apply to the last value on the stack - if (count($stack)<1) { // 4d) - JFactory::getApplication()->enqueueMessage(JText::sprintf('VMSHIPMENT_RULES_EVALUATE_SYNTAXERROR', $rulepart), 'error'); - array_push($stack, 0); - continue; - } - $o1 = array_pop($stack); - // Special-case chained comparisons: if e is a comparison, and operator(o1) is also a comparison, - // insert the arguments to the existing comparison instead of creating a new one - $op = array ($e, $o1); // 4b) - array_push ($stack, $op); // 4c) - } elseif (isset($operators[$e])) { // 4) binary operators - // Operator => apply to the last two values on the stack - if (count($stack)<2) { // 4d) - JFactory::getApplication()->enqueueMessage(JText::sprintf('VMSHIPMENT_RULES_EVALUATE_SYNTAXERROR', $rulepart), 'error'); - array_push($stack, 0); - continue; - } - $o2 = array_pop($stack); // 4a) - $o1 = array_pop($stack); - // Special-case chained comparisons, e.g. 1<=Amount<100: - // if e is a comparison, and operator(o1) is also a comparison, - // insert the arguments to the existing comparison instead of creating a new one - if (in_array ($e, $comparison_ops)) { - if ($o1[0]=='COMPARISON') { - $op = $o1; - // Append the new comparison to the existing one - array_push($op, $e, $o2); - } else { - $op = array ('COMPARISON', $o1, $e, $o2); - } - } else { - $op = array ($e, $o1, $o2); // 4b) - } - array_push ($stack, $op); // 4c) - } else { // 3) - // Operand => push onto stack - array_push ($stack, $e); - } - - } - // 5a) - if (count($stack) != 1) { - JFactory::getApplication()->enqueueMessage(JText::sprintf('VMSHIPMENT_RULES_EVALUATE_UNKNOWN_ERROR', $rulepart), 'error'); - JFactory::getApplication()->enqueueMessage(JText::sprintf('Outstack: <pre>%s</pre>', print_r($out_stack,1)), 'error'); - - $stack = array (0); - } - $res = array_pop($stack); // 5) - - if ($is_assignment) { // Assignments are handled first, so conditions can be assigned to variables - if ($res[0]=='=') { - $this->handleAssignment ($res[1], $res[2], $rulepart); - } else { - // Assignment has to be top-level! - JFactory::getApplication()->enqueueMessage(JText::sprintf('VMSHIPMENT_RULES_EVALUATE_ASSIGNMENT_TOPLEVEL', $rulepart), 'error'); - } - } elseif ($is_condition) { // Comparisons are conditions - $this->conditions[] = $res; - } 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' (type $this->ruletype) parsed into (condition=".print_r($is_condition,1).", assignment=".print_r($is_assignment,1)."): ".print_r($res,1)."</pre>", 'error'); - } - - -} - -// No closing tag -/*$rule = new ShippingRule_Advanced("", array(), 0); -$rp="a+b+(-1+d)"; -$rp="1+maxx(a,b)"; -$rp="f(1,2,+)"; -$rp="1+year()"; -$rp = "1+max( 1,2,3,4,5) + min(9,10,101)"; -$rp = "abs(0-1.9999)"; -$rp="max(1,min(0,5,7), 9)"; -// $rp="f()"; -print_r($rule->tokenize_expression($rp)); - -$rule->parseRulePart($rp); -print_r($rule->conditions); -print_r($rule->shipping); - -print_r($rule->evaluateTerm($rule->shipping, array())); -*/ \ No newline at end of file diff --git a/rules_shipping_base.php b/rules_shipping_base.php index 143ae09d73d51c5ef47d7e153548762e33abeec1..635eecc38d4c781de4775063722267f9c4c8e0b1 100644 --- a/rules_shipping_base.php +++ b/rules_shipping_base.php @@ -22,20 +22,11 @@ if (!class_exists ('vmPSPlugin')) { require(JPATH_VM_PLUGINS . DS . 'vmpsplugin.php'); } // Only declare the class once... -if (class_exists ('plgVmShipmentRules_Shipping_Base')) { - return; -} - - -function is_equal($a, $b) { - if (is_array($a) && is_array($b)) { - return !array_diff($a, $b) && !array_diff($b, $a); - } elseif (is_string($a) && is_string($b)) { - return strcmp($a,$b) == 0; - } else { - return $a == $b; - } -} +// if (class_exists ('plgVmShipmentRules_Shipping_Base')) { +// return; +// } +if (!class_exists('RulesShippingFrameworkJoomla')) + require_once (dirname(__FILE__) . DS . 'rules_shipping_framework_joomla.php'); /** Shipping costs according to general rules. @@ -43,10 +34,7 @@ function is_equal($a, $b) { * 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 (); + protected $helper = null; /** * @param object $subject @@ -61,34 +49,9 @@ class plgVmShipmentRules_Shipping_Base extends vmPSPlugin { $this->tableFields = array_keys ($this->getTableSQLFields ()); $varsToPush = $this->getVarsToPush (); $this->setConfigParameterable ($this->_configTableFieldName, $varsToPush); - - // PLUGIN FUNCTIONALITY: - // Let other plugins add custom functions! - // The onVmShippingRulesRegisterCustomFunctions() trigger is expected to return an array of the form: - // array ('functionname1' => 'function-to-be-called', - // 'functionname2' => array($classobject, 'memberfunc')), - // ...); - JPluginHelper::importPlugin('vmshipmentrules'); - $dispatcher = JDispatcher::getInstance(); - $custfuncdefs = $dispatcher->trigger('onVmShippingRulesRegisterCustomFunctions',array()); - // Loop through the return values of all plugins: - foreach ($custfuncdefs as $custfuncs) { - if (empty($custfuncs)) - continue; - if (!is_array($custfuncs)) { - $this->printWarning(JText::sprintf('VMSHIPMENT_RULES_CUSTOMFUNCTIONS_NOARRAY', $method->rule_name)); - } - // Now loop through all custom function definitions of this plugin - // If a function was registered before, print a warning and use the first definition - foreach ($custfuncs as $fname => $func) { - if (isset($this->custom_functions[$fname])) { - $this->printWarning(JText::sprintf('VMSHIPMENT_RULES_CUSTOMFUNCTIONS_ALREADY_DEFINED', $fname)); - } else { - vmDebug("Defining custom function $fname"); - $this->custom_functions[strtolower($fname)] = $func; - } - } - } + + $this->helper = new RulesShippingFrameworkJoomla(); + $this->helper->setup(); } /** @@ -100,17 +63,6 @@ class plgVmShipmentRules_Shipping_Base extends vmPSPlugin { return $this->createTableSQL ('Shipment Rules Table'); } - public function printWarning($message) { - // Keep track of warning messages, so we don't print them twice: - global $printed_warnings; - if (!isset($printed_warnings)) - $printed_warnings = array(); - if (!in_array($message, $printed_warnings)) { - JFactory::getApplication()->enqueueMessage($message, 'error'); - $printed_warnings[] = $message; - } - } - /** * @return array */ @@ -167,16 +119,17 @@ class plgVmShipmentRules_Shipping_Base extends vmPSPlugin { } // We need to call getCosts, because in J3 $method->rule_name and $method->cost as set in getCosts is no longer preserved. // Instead, we simply call getCosts again, which as a side-effect sets all those members of $method. - $costs = $this->getCosts($cart,$method,$cart->cartPrices); + $costs = $this->helper->getCosts($cart,$method,$cart->cartPrices); + $rulename = $this->helper->getRuleName($method->virtuemart_shipmentmethod_id); + $variables = $this->helper->getRuleVariables($method->virtuemart_shipmentmethod_id); $values['virtuemart_order_id'] = $order['details']['BT']->virtuemart_order_id; $values['order_number'] = $order['details']['BT']->order_number; $values['virtuemart_shipmentmethod_id'] = $order['details']['BT']->virtuemart_shipmentmethod_id; $values['shipment_name'] = $this->renderPluginName ($method); - $values['rule_name'] = $method->rule_name; - $weights = $this->getOrderWeights ($cart, $cart->products, $method->weight_unit); - $values['order_weight'] = $weights['weight']; - $values['order_articles'] = $this->getOrderArticles ($cart, $cart->products); - $values['order_products'] = $this->getOrderProducts ($cart, $cart->products); + $values['rule_name'] = $rulename; + $values['order_weight'] = $variables['weight']; + $values['order_articles'] = $variables['articles']; + $values['order_products'] = $variables['products']; $values['shipment_weight_unit'] = $method->weight_unit; $values['shipment_cost'] = $method->cost; $values['tax_id'] = $method->tax_id; @@ -184,7 +137,12 @@ class plgVmShipmentRules_Shipping_Base extends vmPSPlugin { return TRUE; } - + function getCosts (VirtueMartCart $cart, $method, $cart_prices) { + return $this->helper->getCosts($cart, $method, $cart_prices); + } + protected function checkConditions ($cart, $method, $cart_prices) { + return $this->helper->checkConditions($cart, $method, $cart_prices); + } /** * This method is fired when showing the order details in the backend. * It displays the shipment-specific data. @@ -265,118 +223,6 @@ class plgVmShipmentRules_Shipping_Base extends vmPSPlugin { return $pluginName; } - - /** This function evaluates all rules, one after the other until it finds a matching rule that - * defines shipping costs (or uses NoShipping). If a modifier or definition is encountered, - * its effect is stored, but the loop continues */ - protected function evaluateMethodRules ($cart, $method, $cart_prices) { - // $method->match will cache the matched rule and the modifiers - if (isset($this->match[$method->virtuemart_shipmentmethod_id])) { - return $this->match[$method->virtuemart_shipmentmethod_id]; - } else { - // 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); - // Pass a callback function to the rules to obtain the cartvals for a subset of the products - $this_class = $this; - $cartvals_callback = function ($products) use ($this_class, $cart, $method, $cart_prices) { - return $this_class->getCartValues ($cart, $products, $method, NULL); - }; - foreach ($this->rules[$method->virtuemart_shipmentmethod_id] as $r) { - if ($r->matches($cartvals, $cart->products, $cartvals_callback)) { - $rtype = $r->getType(); - switch ($rtype) { - case 'shipping': - case 'shippingwithtax': - case 'noshipping': - $result["rule"] = $r; - $result["rule_name"] = $r->getRuleName(); - break; - case 'modifiers_add': - case 'modifiers_multiply': - $result[$rtype][] = $r; - break; - case 'definition': // A definition updates the $cartvals, but has no other effects - $cartvals[strtolower($r->getRuleName())] = $r->getValue(); - break; - default: - $this->printWarning(JText::sprintf('VMSHIPMENT_RULES_UNKNOWN_TYPE', $r->getType(), $r->rulestring)); - break; - } - } - if (!is_null($result["rule"])) { - $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, but keep the evaluated results; - $this->match[$method->virtuemart_shipmentmethod_id] = $result; - return NULL; - } - - /** - * @param \VirtueMartCart $cart - * @param int $method - * @param array $cart_prices - * @return bool - */ - protected function checkConditions ($cart, $method, $cart_prices) { - if (!isset($this->rules[$method->virtuemart_shipmentmethod_id])) - $this->parseMethodRules($method); - $match = $this->evaluateMethodRules ($cart, $method, $cart_prices); - if ($match && !is_null ($match['rule'])) { - $method->rule_name = $match["rule_name"]; - // If NoShipping is set, this method should NOT offer any shipping at all, so return FALSE, otherwise TRUE - // If the rule has a name, print it as warning (otherwise don't print anything) - if ($match['rule']->isNoShipping()) { - if (!empty($method->rule_name)) - $this->printWarning(JText::sprintf('VMSHIPMENT_RULES_NOSHIPPING_MESSAGE', $method->rule_name)); - vmdebug('checkConditions '.$method->shipment_name.' indicates NoShipping for this method, specified by rule "'.$method->rule_name.'" ('.$match['rule']->rulestring.').'); - return FALSE; - } else { - return TRUE; - } - } - vmdebug('checkConditions '.$method->shipment_name.' does not fulfill all conditions, no rule matches'); - return FALSE; - } - - /** - * @param VirtueMartCart $cart - * @param $method - * @param $cart_prices - * @return int - */ - function getCosts (VirtueMartCart $cart, $method, $cart_prices) { - 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(); - foreach ($match['modifiers_multiply'] as $modifier) { - $method->cost *= $modifier->getValue(); - } - foreach ($match['modifiers_add'] as $modifier) { - $method->cost += $modifier->getValue(); - } - $method->includes_tax = $r->includes_tax; - return $method->cost; - } - - vmdebug('getCosts '.$method->name.' does not return shipping costs'); - return 0; - } - - /** * update the plugin cart_prices * @@ -469,7 +315,7 @@ class plgVmShipmentRules_Shipping_Base extends vmPSPlugin { } } else { // BEGIN_RK_CHANGES: VM change in VM3! - if (is_array($calculator->_cartData)) { // VM2: + if (isset($calculator->_cartData) && is_array($calculator->_cartData)) { // VM2: $taxrules = array_merge($calculator->_cartData['VatTax'],$calculator->_cartData['taxRulesBill']); } else { // VM3: $taxrules = array_merge($cart->cartData['VatTax'],$cart->cartData['taxRulesBill']); @@ -566,273 +412,6 @@ class plgVmShipmentRules_Shipping_Base extends vmPSPlugin { } - protected function createMethodRule ($r, $countries, $tax) { - return new ShippingRule($this, $r, $countries, $tax); - } - - private function parseMethodRule ($rulestring, $countries, $tax, &$method) { - $rules1 = preg_split("/(\r\n|\n|\r)/", $rulestring); - foreach ($rules1 as $r) { - // Ignore empty lines - if (empty($r)) continue; - $this->rules[$method->virtuemart_shipmentmethod_id][] = $this->createMethodRule ($r, $countries, $tax); - } - } - - protected function parseMethodRules (&$method) { - 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); - $this->parseMethodRule ($method->rules4, $method->countries4, $method->tax_id4, $method); - $this->parseMethodRule ($method->rules5, $method->countries5, $method->tax_id5, $method); - $this->parseMethodRule ($method->rules6, $method->countries6, $method->tax_id6, $method); - $this->parseMethodRule ($method->rules7, $method->countries7, $method->tax_id7, $method); - $this->parseMethodRule ($method->rules8, $method->countries8, $method->tax_id8, $method); - } - - /** Functions to calculate all the different variables for the given cart and given (sub)set of products in the cart */ - protected function getOrderArticles (VirtueMartCart $cart, $products) { - $articles = 0; - foreach ($products as $product) { - $articles += $product->quantity; - } - return $articles; - } - - protected function getOrderProducts (VirtueMartCart $cart, $products) { - return count($products); - } - - protected function getOrderDimensions (VirtueMartCart $cart, $products, $length_dimension) { - /* Cache the value in a static variable and calculate it only once! */ - $dimensions=array( - 'volume' => 0, - 'maxvolume' => 0, 'minvolume' => 9999999999, - 'maxlength' => 0, 'minlength' => 9999999999, 'totallength' => 0, - 'maxwidth' => 0, 'minwidth' => 9999999999, 'totalwidth' => 0, - 'maxheight' => 0, 'minheight' => 9999999999, 'totalheight' => 0, - 'maxpackaging' => 0, 'minpackaging' => 9999999999, 'totalpackaging' => 0, - ); - foreach ($products as $product) { - - $l = ShopFunctions::convertDimensionUnit ($product->product_length, $product->product_lwh_uom, $length_dimension); - $w = ShopFunctions::convertDimensionUnit ($product->product_width, $product->product_lwh_uom, $length_dimension); - $h = ShopFunctions::convertDimensionUnit ($product->product_height, $product->product_lwh_uom, $length_dimension); - - $volume = $l * $w * $h; - $dimensions['volume'] += $volume * $product->quantity; - $dimensions['maxvolume'] = max ($dimensions['maxvolume'], $volume); - $dimensions['minvolume'] = min ($dimensions['minvolume'], $volume); - - $dimensions['totallength'] += $l * $product->quantity; - $dimensions['maxlength'] = max ($dimensions['maxlength'], $l); - $dimensions['minlength'] = min ($dimensions['minlength'], $l); - $dimensions['totalwidth'] += $w * $product->quantity; - $dimensions['maxwidth'] = max ($dimensions['maxwidth'], $w); - $dimensions['minwidth'] = min ($dimensions['minwidth'], $w); - $dimensions['totalheight'] += $h * $product->quantity; - $dimensions['maxheight'] = max ($dimensions['maxheight'], $h); - $dimensions['minheight'] = min ($dimensions['minheight'], $h); - $dimensions['totalpackaging'] += $product->product_packaging * $product->quantity; - $dimensions['maxpackaging'] = max ($dimensions['maxpackaging'], $product->product_packaging); - $dimensions['minpackaging'] = min ($dimensions['minpackaging'], $product->product_packaging); - } - - return $dimensions; - } - - protected function getOrderWeights (VirtueMartCart $cart, $products, $weight_unit) { - $dimensions=array( - 'weight' => 0, - 'maxweight' => 0, 'minweight' => 9999999999, - ); - foreach ($products as $product) { - $w = ShopFunctions::convertWeigthUnit ($product->product_weight, $product->product_weight_uom, $weight_unit); - $dimensions['maxweight'] = max ($dimensions['maxweight'], $w); - $dimensions['minweight'] = min ($dimensions['minweight'], $w); - $dimensions['weight'] += $w * $product->quantity; - } - return $dimensions; - } - - protected function getOrderListProperties (VirtueMartCart $cart, $products) { - $categories = array(); - $vendors = array(); - $skus = array(); - $manufacturers = array(); - foreach ($products as $product) { - $skus[] = $product->product_sku; - $categories = array_merge ($categories, $product->categories); - $vendors[] = $product->virtuemart_vendor_id; - if (is_array($product->virtuemart_manufacturer_id)) { - $manufacturers = array_merge($manufacturers, $product->virtuemart_manufacturer_id); - } elseif ($product->virtuemart_manufacturer_id) { - $manufacturers[] = $product->virtuemart_manufacturer_id; - } - } - $skus = array_unique($skus); - $vendors = array_unique($vendors); - $categories = array_unique($categories); - $manufacturers = array_unique($manufacturers); - return array ('skus'=>$skus, - 'categories'=>$categories, - 'vendors'=>$vendors, - 'manufacturers'=>$manufacturers, - ); - } - - protected function getOrderCountryState (VirtueMartCart $cart, $address) { - $data = array ( - 'countryid' => 0, 'country' => '', 'country2' => '', 'country3' => '', - 'stateid' => 0, 'state' => '', 'state2' => '', 'state3' => '', - ); - - $countriesModel = VmModel::getModel('country'); - if (isset($address['virtuemart_country_id'])) { - $data['countryid'] = $address['virtuemart_country_id']; - // The following is a workaround to make sure the cache is invalidated - // because if some other extension meanwhile called $countriesModel->getCountries, - // the cache will be modified, but the model's id will not be changes, so the - // getData call will return the wrong cache. - $countriesModel->setId(0); - $countriesModel->setId($address['virtuemart_country_id']); - $country = $countriesModel->getData($address['virtuemart_country_id']); - if (!empty($country)) { - $data['country'] = $country->country_name; - $data['country2'] = $country->country_2_code; - $data['country3'] = $country->country_3_code; - } - } - - $statesModel = VmModel::getModel('state'); - if (isset($address['virtuemart_state_id'])) { - $data['stateid'] = $address['virtuemart_state_id']; - // The following is a workaround to make sure the cache is invalidated - // because if some other extension meanwhile called $countriesModel->getCountries, - // the cache will be modified, but the model's id will not be changes, so the - // getData call will return the wrong cache. - $statesModel->setId(0); - $statesModel->setId($address['virtuemart_state_id']); - $state = $statesModel->getData($address['virtuemart_state_id']); - if (!empty($state)) { - $data['state'] = $state->state_name; - $data['state2'] = $state->state_2_code; - $data['state3'] = $state->state_3_code; - } - } - - return $data; - - } - - protected function getOrderAddress (VirtueMartCart $cart, $address) { - $zip = isset($address['zip'])?trim($address['zip']):''; - $data = array('zip'=>$zip, - 'zip1'=>substr($zip,0,1), - 'zip2'=>substr($zip,0,2), - 'zip3'=>substr($zip,0,3), - 'zip4'=>substr($zip,0,4), - 'zip5'=>substr($zip,0,5), - 'zip6'=>substr($zip,0,6), - 'city'=>isset($address['city'])?trim($address['city']):'', - ); - $data['company'] = isset($address['company'])?$address['company']:''; - $data['title'] = isset($address['title'])?$address['title']:''; - $data['first_name'] = isset($address['title'])?$address['title']:''; - $data['middle_name'] = isset($address['middle_name'])?$address['middle_name']:''; - $data['last_name'] = isset($address['last_name'])?$address['last_name']:''; - $data['address1'] = isset($address['address_1'])?$address['address_1']:''; - $data['address2'] = isset($address['address_2'])?$address['address_2']:''; - $data['city'] = isset($address['city'])?$address['city']:''; - $data['phone1'] = isset($address['phone_1'])?$address['phone_1']:''; - $data['phone2'] = isset($address['phone_2'])?$address['phone_2']:''; - $data['fax'] = isset($address['fax'])?$address['fax']:''; - $data['email'] = isset($address['email'])?$address['email']:''; - return $data; - } - - protected function getOrderPrices (VirtueMartCart $cart, $products, $cart_prices) { - $data = array( - 'amount' => 0, - 'amountwithtax' => 0, - 'amountwithouttax' => 0, - 'baseprice' => 0, - 'basepricewithtax' => 0, - 'discountedpricewithouttax' => 0, - 'salesprice' => 0, - 'taxamount' => 0, - 'salespricewithdiscount' => 0, - 'discountamount' => 0, - 'pricewithouttax' => 0, - ); - if (!empty($cart_prices)) { - // get prices for the whole cart -> simply user the cart_prices - $data['amount'] = $cart_prices['salesPrice']; - $data['amountwithtax'] = $cart_prices['salesPrice']; - $data['amountwithouttax'] = $cart_prices['priceWithoutTax']; - $data['baseprice'] = $cart_prices['basePrice']; - $data['basepricewithtax'] = $cart_prices['basePriceWithTax']; - $data['discountedpricewithouttax'] = $cart_prices['discountedPriceWithoutTax']; - $data['salesprice'] = $cart_prices['salesPrice']; - $data['taxamount'] = $cart_prices['taxAmount']; - $data['salespricewithdiscount'] = $cart_prices['salesPriceWithDiscount']; - $data['discountamount'] = $cart_prices['discountAmount']; - $data['pricewithouttax'] = $cart_prices['priceWithoutTax']; - } else { - // Calculate the prices from the individual products! - // Possible problems are discounts on the order total - foreach ($products as $product) { - $data['amount'] += $product->quantity*$product->allPrices[$product->selectedPrice]['salesPrice']; - $data['amountwithtax'] += $product->quantity*$product->allPrices[$product->selectedPrice]['salesPrice']; - $data['amountwithouttax'] += $product->quantity*$product->allPrices[$product->selectedPrice]['priceWithoutTax']; - $data['baseprice'] += $product->quantity*$product->allPrices[$product->selectedPrice]['basePrice']; - $data['basepricewithtax'] += $product->quantity*$product->allPrices[$product->selectedPrice]['basePriceWithTax']; - $data['discountedpricewithouttax'] += $product->quantity*$product->allPrices[$product->selectedPrice]['discountedPriceWithoutTax']; - $data['salesprice'] += $product->quantity*$product->allPrices[$product->selectedPrice]['salesPrice']; - $data['taxamount'] += $product->quantity*$product->allPrices[$product->selectedPrice]['taxAmount']; - $data['salespricewithdiscount'] += $product->quantity*$product->allPrices[$product->selectedPrice]['salesPriceWithDiscount']; - $data['discountamount'] += $product->quantity*$product->allPrices[$product->selectedPrice]['discountAmount']; - $data['pricewithouttax'] += $product->quantity*$product->allPrices[$product->selectedPrice]['priceWithoutTax']; - } - } - return $data; - } - - /** Allow child classes to add additional variables for the rules or modify existing one - */ - protected function addCustomCartValues (VirtueMartCart $cart, $products, $cart_prices, &$values) { - } - - public function getCartValues (VirtueMartCart $cart, $products, $method, $cart_prices) { - $address = (($cart->ST == 0 || $cart->STsameAsBT == 1) ? $cart->BT : $cart->ST); - $cartvals = array_merge ( - array( - 'articles'=>$this->getOrderArticles($cart, $products), - 'products'=>$this->getOrderProducts($cart, $products), - ), - // Add the prices, optionally calculated from the products subset of the cart - $this->getOrderPrices ($cart, $products, $cart_prices), - // 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), - $this->getOrderDimensions ($cart, $products, $method->length_unit) - ); - // 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'); - JDispatcher::getInstance()->trigger('onVmShippingRulesGetCartValues',array(&$cartvals, $cart, $products, $method, $cart_prices)); - - return $cartvals; - } - /** * Create the table for this plugin if it does not yet exist. * This functions checks if the called plugin is active one. @@ -934,16 +513,14 @@ class plgVmShipmentRules_Shipping_Base extends vmPSPlugin { if (isset($data['rules1'])) { // Try to parse all rules (and spit out error) to inform the user. There is no other // reason to parse the rules here, it's really only to trigger warnings/errors in case of a syntax error. - $method = new StdClass (); - $method->virtuemart_shipmentmethod_id = $data['virtuemart_shipmentmethod_id']; - $this->parseMethodRule ($data['rules1'], isset($data['countries1'])?$data['countries1']:array(), $data['tax_id1'], $method); - $this->parseMethodRule ($data['rules2'], isset($data['countries2'])?$data['countries2']:array(), $data['tax_id2'], $method); - $this->parseMethodRule ($data['rules3'], isset($data['countries3'])?$data['countries3']:array(), $data['tax_id3'], $method); - $this->parseMethodRule ($data['rules4'], isset($data['countries4'])?$data['countries4']:array(), $data['tax_id4'], $method); - $this->parseMethodRule ($data['rules5'], isset($data['countries5'])?$data['countries5']:array(), $data['tax_id5'], $method); - $this->parseMethodRule ($data['rules6'], isset($data['countries6'])?$data['countries6']:array(), $data['tax_id6'], $method); - $this->parseMethodRule ($data['rules7'], isset($data['countries7'])?$data['countries7']:array(), $data['tax_id7'], $method); - $this->parseMethodRule ($data['rules8'], isset($data['countries8'])?$data['countries8']:array(), $data['tax_id8'], $method); + $this->helper->parseRuleSyntax ($data['rules1'], isset($data['countries1'])?$data['countries1']:array(), $data['tax_id1']); + $this->helper->parseRuleSyntax ($data['rules2'], isset($data['countries2'])?$data['countries2']:array(), $data['tax_id2']); + $this->helper->parseRuleSyntax ($data['rules3'], isset($data['countries3'])?$data['countries3']:array(), $data['tax_id3']); + $this->helper->parseRuleSyntax ($data['rules4'], isset($data['countries4'])?$data['countries4']:array(), $data['tax_id4']); + $this->helper->parseRuleSyntax ($data['rules5'], isset($data['countries5'])?$data['countries5']:array(), $data['tax_id5']); + $this->helper->parseRuleSyntax ($data['rules6'], isset($data['countries6'])?$data['countries6']:array(), $data['tax_id6']); + $this->helper->parseRuleSyntax ($data['rules7'], isset($data['countries7'])?$data['countries7']:array(), $data['tax_id7']); + $this->helper->parseRuleSyntax ($data['rules8'], isset($data['countries8'])?$data['countries8']:array(), $data['tax_id8']); } $ret=$this->setOnTablePluginParams ($name, $id, $table); return $ret; @@ -951,541 +528,4 @@ class plgVmShipmentRules_Shipping_Base extends vmPSPlugin { } -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) { -// JFactory::getApplication()->enqueueMessage("<pre>Product: ".print_r($p,1)."</pre>", 'error'); - 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->categories))==0) - continue; - if (!empty($filter_conditions['manufacturers']) && count(array_intersect($filter_conditions['manufacturers'], $p->virtuemart_manufacturer_id))==0) - continue; - if (!empty($filter_conditions['vendors']) && !in_array($p->virtuemart_vendor_id, $filter_conditions['vendors'])) - continue; - $result[] = $p; - } - return $result; -} - - -class ShippingRule { - var $plugin = Null; - var $rulestring = ''; - var $name = ''; - var $ruletype = ''; - var $evaluated = False; - var $match = False; - var $value = Null; - - var $shipping = 0; - var $conditions = array(); - var $countries = array(); - var $tax_id = 0; - var $includes_tax = 0; - - function __construct ($plugin, $rule, $countries, $tax_id) { - if (is_array($countries)) { - $this->countries = $countries; - } elseif (!empty($countries)) { - $this->countries[0] = $countries; - } - $this->tax_id = $tax_id; - $this->rulestring = $rule; - $this->parseRule($rule); - $this->plugin=$plugin; - } - - protected function parseRule($rule) { - $ruleparts=explode(';', $rule); - foreach ($ruleparts as $p) { - $this->parseRulePart($p); - } - } - - 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; - case 'shippingwithtax': $this->shipping = $value; $this->includes_tax = True; $this->ruletype='shipping'; break; - case 'variable': // Variable=... is the same as Definition=... - case 'definition': $this->name = strtolower($value); $this->ruletype = 'definition'; break; - case 'value': $this->shipping = $value; $this->ruletype = 'definition'; break; // definition values are also stored in the shipping member! - case 'extrashippingcharge': $this->shipping = $value; $this->ruletype = 'modifiers_add'; break; // modifiers are also stored in the shipping member! - case 'extrashippingmultiplier': $this->shipping = $value; $this->ruletype = 'modifiers_multiply'; break; // modifiers are also stored in the shipping member! - case 'comment': break; // Completely ignore all comments! - case 'condition': $this->conditions[] = $value; break; - default: JFactory::getApplication()->enqueueMessage(JText::sprintf('VMSHIPMENT_RULES_UNKNOWN_VARIABLE', $var, $rulepart), 'error'); - } - } - - 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 - // or followed by another letter) and then all arithmetic operators - $re = '/\s*("[^"]*"|\'[^\']*\'|<=|=>|>=|=<|<>|!=|==|<|=|>)\s*/i'; - $atoms = preg_split($re, $expression, -1, PREG_SPLIT_DELIM_CAPTURE|PREG_SPLIT_NO_EMPTY); - // JFactory::getApplication()->enqueueMessage("TOKENIZING '$expression' returns: <pre>".print_r($atoms,1)."</pre>", 'error'); - return $atoms; - } - - 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 */ - $rulepart = trim($rulepart); - if (empty($rulepart)) return; - - - // Special-case the name assignment, where we don't want to interpret the value as an arithmetic expression! - if (preg_match('/^\s*(name|variable|definition)\s*=\s*(["\']?)(.*)\2\s*$/i', $rulepart, $matches)) { - $this->handleAssignment ($matches[1], $matches[3], $rulepart); - return; - } - - // Split at all operators: - $atoms = $this->tokenize_expression ($rulepart); - - /* TODO: Starting from here, the advanced plugin is different! */ - $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 { - // Conditions, need at least three atoms! - while (count($atoms)>1) { - if (in_array ($atoms[1], $operators)) { - $this->conditions[] = array($atoms[1], $this->parseShippingTerm($atoms[0]), $this->parseShippingTerm($atoms[2])); - array_shift($atoms); - array_shift($atoms); - } else { - JFactory::getApplication()->enqueueMessage(JText::sprintf('VMSHIPMENT_RULES_UNKNOWN_OPERATOR', $atoms[1], $rulepart), 'error'); - $atoms = array(); - } - } - } - } - - 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! - if (substr($expr, 0, 1) === '"') { - return $expr; - } else { - return strtolower($expr); - } - } - - protected function evaluateComparison ($terms, $vals) { - while (count($terms)>2) { - $res = false; - switch ($terms[1]) { - case '<': $res = ($terms[0] < $terms[2]); break; - case '<=': - case '=<': $res = ($terms[0] <= $terms[2]); break; - case '==': $res = is_equal($terms[0], $terms[2]); break; - case '!=': - case '<>': $res = ($terms[0] != $terms[2]); break; - case '>=': - case '=>': $res = ($terms[0] >= $terms[2]); break; - case '>': $res = ($terms[0] > $terms[2]); break; - case '~': - $l=min(strlen($terms[0]), strlen($terms[2])); - $res = (strncmp ($terms[0], $terms[2], $l) == 0); - break; - default: - JFactory::getApplication()->enqueueMessage(JText::sprintf('VMSHIPMENT_RULES_UNKNOWN_OPERATOR', $terms[1], $this->rulestring), 'error'); - $res = false; - } - - if ($res==false) return false; - // Remove the first operand and the operator from the comparison: - array_shift($terms); - array_shift($terms); - } - if (count($terms)>1) { - // We do not have the correct number of terms for chained comparisons, i.e. two terms leftover instead of one! - JFactory::getApplication()->enqueueMessage(JText::sprintf('VMSHIPMENT_RULES_EVALUATE_UNKNOWN_ERROR', $this->rulestring), 'error'); - return false; - } - // All conditions were fulfilled, so we can return true - return true; - } - - protected function evaluateListFunction ($function, $args) { - # First make sure that all arguments are actually lists: - $allarrays = True; - foreach ($args as $a) { - $allarrays = $allarrays && is_array($a); - } - if (!$allarrays) { - JFactory::getApplication()->enqueueMessage(JText::sprintf('VMSHIPMENT_RULES_EVALUATE_LISTFUNCTION_ARGS', $function, $this->rulestring), 'error'); - return false; - - } - switch ($function) { - case "length": return count($args[0]); break; - case "union": - case "join": return call_user_func_array( "array_merge" , $args); break; - case "complement": return call_user_func_array( "array_diff" , $args); break; - case "intersection": return call_user_func_array( "array_intersect" , $args); break; - case "issubset": # Remove all of superset's elements to see if anything else is left: - return !array_diff($args[0], $args[1]); break; - case "contains": # Remove all of superset's elements to see if anything else is left: - # Notice the different argument order compared to issubset! - return !array_diff($args[1], $args[0]); break; - case "list_equal": return array_unique($args[0])==array_unique($args[1]); break; - default: - JFactory::getApplication()->enqueueMessage(JText::sprintf('VMSHIPMENT_RULES_EVALUATE_LISTFUNCTION_UNKNOWN', $function, $this->rulestring), 'error'); - return false; - } - } - - 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'); - return false; - } - // Extract the array from the args, the $args varialbe will now only contain the elements to be checked: - $array = array_shift($args); - switch ($function) { - case "contains_any": // return true if one of the $args is in the $array - foreach ($args as $a) { - if (in_array($a, $array)) - return true; - } - return false; - - case "contains_all": // return false if one of the $args is NOT in the $array - foreach ($args as $a) { - if (!in_array($a, $array)) - return false; - } - return true; - case "contains_only": // return false if one of the $array elements is NOT in $args - foreach ($array as $a) { - if (!in_array($a, $args)) - return false; - } - return true; - case "contains_none": // return false if one of the $args IS in the $array - foreach ($args as $a) { - if (in_array($a, $array)) - return false; - } - return true; - default: - JFactory::getApplication()->enqueueMessage(JText::sprintf('VMSHIPMENT_RULES_EVALUATE_LISTFUNCTION_UNKNOWN', $function, $this->rulestring), 'error'); - return false; - } - } - - /** Evaluate the given expression $expr only for the products that match the filter given by the scoping - * function and the corresponding conditions */ - protected function evaluateScoping($expr, $scoping, $conditionvals, $vals, $products, $cartvals_callback) { -// JFactory::getApplication()->enqueueMessage("<pre>Scoping, begin, scoping=$scoping, expression=".print_r($expr,1).", conditionvals=".print_r($conditionvals, 1)."</pre>", 'error'); - if (count($conditionvals)<1) - return $this->evaluateTerm($expr, $vals, $products, $cartvals_callback); - - $filterkeys = array( - "evaluate_for_categories" => 'categories', - "evaluate_for_products" => 'products', - "evaluate_for_vendors" => 'vendors', - "evaluate_for_manufacturers" => 'manufacturers' - ); - - $conditions = array(); - if (isset($filterkeys[$scoping])) - $conditions[$filterkeys[$scoping]] = $conditionvals; - - // Pass the conditions to the parent plugin class to filter the current list of products: - $filteredproducts = filterProducts($products, $conditions); - // We have been handed a callback function to calculate the cartvals for the filtered list of products, so use it: - $filteredvals = $cartvals_callback($filteredproducts); - return $this->evaluateTerm ($expr, $filteredvals, $filteredproducts, $cartvals_callback); - } - - protected function evaluateFunction ($function, $args) { - $func = strtolower($function); - // Check if we have a custom function definition and use that if so. - // This is done first to allow plugins to override even built-in functions! - if (isset($this->plugin->custom_functions[$func])) { - vmDebug("Evaluating custom function $function, defined by a plugin"); - return call_user_func($this->plugin->custom_functions[$func], $args, $this); - } - - // Functions with no argument: - if (count($args) == 0) { - $dt = getdate(); - switch ($func) { - case "second": return $dt['seconds']; break; - case "minute": return $dt['minutes']; break; - case "hour": return $dt['hours']; break; - case "day": return $dt['mday']; break; - case "weekday":return $dt['wday']; break; - case "month": return $dt['mon']; break; - case "year": return $dt['year']; break; - case "yearday":return $dt['yday']; break; - } - } - // Functions with exactly one argument: - if (count($args) == 1) { - switch ($func) { - case "round": return round($args[0]); break; - case "ceil": return ceil ($args[0]); break; - case "floor": return floor($args[0]); break; - case "abs": return abs($args[0]); break; - case "not": return !$args[0]; break; - case "print_r": return print_r($args[0],1); break; - } - } - if (count($args) == 2) { - switch ($func) { - case "digit": return substr($args[0], $args[1]-1, 1); break; - case "round": return round($args[0]/$args[1])*$args[1]; break; - case "ceil": return ceil($args[0]/$args[1])*$args[1]; break; - case "floor": return floor($args[0]/$args[1])*$args[1]; break; - } - } - if (count($args) == 3) { - switch ($func) { - case "substring": return substr($args[0], $args[1]-1, $args[2]); break; - } - } - // Functions with variable number of args - switch ($func) { - case "max": - return max($args); - case "min": - return min($args); - case "list": - case "array": - return $args; - // List functions: - case "length": - case "complement": - case "issubset": - case "contains": - case "union": - case "join": - case "intersection": - case "list_equal": - return $this->evaluateListFunction ($func, $args); - case "contains_any": - case "contains_all": - case "contains_only": - case "contains_none": - 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; - } - - protected function evaluateVariable ($expr, $vals) { - $varname = strtolower($expr); - if (array_key_exists(strtolower($expr), $vals)) { - return $vals[strtolower($expr)]; - } elseif ($varname=='noshipping') { - return $varname; - } 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, $products, $cartvals_callback) { - // 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)) { - return $expr; - } elseif (is_string ($expr)) { - // Explicit strings are delimited by '...' or "..." - if (($expr[0]=='\'' || $expr[0]=='"') && ($expr[0]==substr($expr,-1)) ) { - return substr($expr,1,-1); - } else { - return $this->evaluateVariable($expr, $vals); - } - } elseif ($is_scoping) { - $op = array_shift($expr); // ignore the "FUNCTION" - $func = array_shift($expr); // The scoping function name - $expression = array_shift($expr); // The expression to be evaluated - $conditions = $expr; // the remaining $expr list now contains the conditions - return $this->evaluateScoping ($expression, $func, $conditions, $vals, $products, $cartvals_callback); - - } 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; - } - foreach ($expr as $e) { - $term = $evaluate ? ($this->evaluateTerm($e, $vals, $products, $cartvals_callback)) : $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") { - $evaluate = true; - } - if (is_null($term)) return null; - $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; - case '&&': - case 'AND': $res = true; foreach ($args as $a) { $res = ($res && $a); }; break; - case 'IN': $res = in_array($args[0], $args[1]); break; - - // Comparisons: - case '<': - case '<=': - case '=<': - case '==': - case '!=': - case '<>': - case '>=': - case '=>': - case '>': - case '~': - $res = $this->evaluateComparison(array($args[0], $op, $args[1]), $vals); break; - case 'COMPARISON': - $res = $this->evaluateComparison($args, $vals); break; - - // Unary operators: - case '.-': $res = -$args[0]; break; - case '.+': $res = $args[0]; break; - - // Binary operators - case "+": $res = ($args[0] + $args[1]); break; - case "-": $res = ($args[0] - $args[1]); break; - case "*": $res = ($args[0] * $args[1]); break; - case "/": $res = ($args[0] / $args[1]); break; - case "%": $res = (fmod($args[0], $args[1])); break; - case "^": $res = ($args[0] ^ $args[1]); break; - - // Functions: - case "FUNCTION": $func = array_shift($args); $res = $this->evaluateFunction($func, $args); break; - - default: $res = false; - } - -// JFactory::getApplication()->enqueueMessage("<pre>Result of ".print_r($expr,1)." is $res.</pre>", 'error'); - return $res; - } else { - // Neither string nor numeric, nor operator... - JFactory::getApplication()->enqueueMessage(JText::sprintf('VMSHIPMENT_RULES_EVALUATE_UNKNOWN_VALUE', $expr, $this->rulestring), 'error'); - return null; - } - } - - protected function calculateShipping ($vals, $products, $cartvals_callback) { - return $this->evaluateTerm($this->shipping, $vals, $products, $cartvals_callback); - } - - protected function evaluateRule (&$vals, $products, $cartvals_callback) { - if ($this->evaluated) - return; // Already evaluated - - $this->evaluated = True; - $this->match = False; // Default, set it to True below if all conditions match... - // First, check the country, if any conditions are given: - if (count ($this->countries) > 0 && !in_array ($vals['countryid'], $this->countries)) { -// vmdebug('Rule::matches: Country check failed: countryid='.print_r($vals['countryid'],1).', countries are: '.print_r($this->countries,1).'...'); - return; - } - - foreach ($this->conditions as $c) { - // All conditions have to match! - $ret = $this->evaluateTerm($c, $vals, $products, $cartvals_callback); - - if (is_null($ret) || (!$ret)) { - return; - } - } - // All conditions match - $this->match = True; - // Calculate the value (i.e. shipping cost or modifier) - $this->value = $this->calculateShipping($vals, $products, $cartvals_callback); - // 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) { - $val = $this->evaluateVariable($m, $vals); - if ($val !== null) { - $name = str_replace("{".$m."}", $val, $name); - } - } - $this->rulename = $name; - } - - function matches(&$vals, $products, $cartvals_callback) { - $this->evaluateRule($vals, $products, $cartvals_callback); - 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 getValue() { - if (!$this->evaluated) - vmDebug('WARNING: getValue called without prior evaluation of the rule, e.g. by calling rule->matches(...)'); - return $this->value; - } - function getShippingCosts() { - return $this->getValue(); - } - - function isNoShipping() { - // NoShipping is set, so if the rule matches, this method should not offer any shipping at all - return (is_string($this->shipping) && (strtolower($this->shipping)=="noshipping")); - } - -} - // No closing tag diff --git a/rules_shipping_framework_joomla.php b/rules_shipping_framework_joomla.php new file mode 100644 index 0000000000000000000000000000000000000000..74053e8b5da7abadd17b7eb74b220668cd613b36 --- /dev/null +++ b/rules_shipping_framework_joomla.php @@ -0,0 +1,289 @@ +<?php +/** + * Shipping by Rules generic helper class (Joomla/VM-specific) + * Reinhold Kainhofer, Open Tools, office@open-tools.net + * @copyright (C) 2012-2015 - Reinhold Kainhofer + * @license GNU/GPLv3 http://www.gnu.org/licenses/gpl-3.0.html +**/ + +// defined('_JEXEC') or die( 'Direct Access to ' . basename( __FILE__ ) . ' is not allowed.' ) ; + +// if (!class_exists( 'VmConfig' )) +// require(JPATH_ROOT.DS.'administrator'.DS.'components'.DS.'com_virtuemart'.DS.'helpers'.DS.'config.php'); +// VmConfig::loadConfig(); + +if (!class_exists( 'RulesShippingFramework' )) + require_once (dirname(__FILE__) . DS . 'library' . DS . 'rules_shipping_framework.php'); + +// $test=new asdfasdsf(); +class RulesShippingFrameworkJoomla extends RulesShippingFramework { + function getCustomFunctions() { + // Let other plugins add custom functions! + // The onVmShippingRulesRegisterCustomFunctions() trigger is expected to return an array of the form: + // array ('functionname1' => 'function-to-be-called', + // 'functionname2' => array($classobject, 'memberfunc')), + // ...); + JPluginHelper::importPlugin('vmshipmentrules'); + $dispatcher = JDispatcher::getInstance(); + $custfuncdefs = $dispatcher->trigger('onVmShippingRulesRegisterCustomFunctions',array()); + + return array (); + } + + public function printWarning($message) { + // Keep track of warning messages, so we don't print them twice: + global $printed_warnings; + if (!isset($printed_warnings)) + $printed_warnings = array(); + if (!in_array($message, $printed_warnings)) { + JFactory::getApplication()->enqueueMessage($message, 'error'); + $printed_warnings[] = $message; + } + } + + /** + * Functions to calculate the cart variables: + * - getOrderArticles($cart, $products) + * - getOrderProducts + * - getOrderDimensions + */ + /** Functions to calculate all the different variables for the given cart and given (sub)set of products in the cart */ + protected function getOrderArticles ($cart, $products) { + $articles = 0; + foreach ($products as $product) { + $articles += $product->quantity; + } + return $articles; + } + + protected function getOrderProducts ($cart, $products) { + return count($products); + } + + protected function getOrderDimensions ($cart, $products, $length_dimension) { + /* Cache the value in a static variable and calculate it only once! */ + $dimensions=array( + 'volume' => 0, + 'maxvolume' => 0, 'minvolume' => 9999999999, + 'maxlength' => 0, 'minlength' => 9999999999, 'totallength' => 0, + 'maxwidth' => 0, 'minwidth' => 9999999999, 'totalwidth' => 0, + 'maxheight' => 0, 'minheight' => 9999999999, 'totalheight' => 0, + 'maxpackaging' => 0, 'minpackaging' => 9999999999, 'totalpackaging' => 0, + ); + foreach ($products as $product) { + + $l = ShopFunctions::convertDimensionUnit ($product->product_length, $product->product_lwh_uom, $length_dimension); + $w = ShopFunctions::convertDimensionUnit ($product->product_width, $product->product_lwh_uom, $length_dimension); + $h = ShopFunctions::convertDimensionUnit ($product->product_height, $product->product_lwh_uom, $length_dimension); + + $volume = $l * $w * $h; + $dimensions['volume'] += $volume * $product->quantity; + $dimensions['maxvolume'] = max ($dimensions['maxvolume'], $volume); + $dimensions['minvolume'] = min ($dimensions['minvolume'], $volume); + + $dimensions['totallength'] += $l * $product->quantity; + $dimensions['maxlength'] = max ($dimensions['maxlength'], $l); + $dimensions['minlength'] = min ($dimensions['minlength'], $l); + $dimensions['totalwidth'] += $w * $product->quantity; + $dimensions['maxwidth'] = max ($dimensions['maxwidth'], $w); + $dimensions['minwidth'] = min ($dimensions['minwidth'], $w); + $dimensions['totalheight'] += $h * $product->quantity; + $dimensions['maxheight'] = max ($dimensions['maxheight'], $h); + $dimensions['minheight'] = min ($dimensions['minheight'], $h); + $dimensions['totalpackaging'] += $product->product_packaging * $product->quantity; + $dimensions['maxpackaging'] = max ($dimensions['maxpackaging'], $product->product_packaging); + $dimensions['minpackaging'] = min ($dimensions['minpackaging'], $product->product_packaging); + } + + return $dimensions; + } + + protected function getOrderWeights ($cart, $products, $weight_unit) { + $dimensions=array( + 'weight' => 0, + 'maxweight' => 0, 'minweight' => 9999999999, + ); + foreach ($products as $product) { + $w = ShopFunctions::convertWeigthUnit ($product->product_weight, $product->product_weight_uom, $weight_unit); + $dimensions['maxweight'] = max ($dimensions['maxweight'], $w); + $dimensions['minweight'] = min ($dimensions['minweight'], $w); + $dimensions['weight'] += $w * $product->quantity; + } + return $dimensions; + } + + protected function getOrderListProperties ($cart, $products) { + $categories = array(); + $vendors = array(); + $skus = array(); + $manufacturers = array(); + foreach ($products as $product) { + $skus[] = $product->product_sku; + $categories = array_merge ($categories, $product->categories); + $vendors[] = $product->virtuemart_vendor_id; + if (is_array($product->virtuemart_manufacturer_id)) { + $manufacturers = array_merge($manufacturers, $product->virtuemart_manufacturer_id); + } elseif ($product->virtuemart_manufacturer_id) { + $manufacturers[] = $product->virtuemart_manufacturer_id; + } + } + $skus = array_unique($skus); + $vendors = array_unique($vendors); + $categories = array_unique($categories); + $manufacturers = array_unique($manufacturers); + return array ('skus'=>$skus, + 'categories'=>$categories, + 'vendors'=>$vendors, + 'manufacturers'=>$manufacturers, + ); + } + + protected function getOrderCountryState ($cart, $address) { + + } + + protected function getOrderAddress ($cart) { + $address = (($cart->ST == 0 || $cart->STsameAsBT == 1) ? $cart->BT : $cart->ST); + $zip = isset($address['zip'])?trim($address['zip']):''; + $data = array('zip'=>$zip, + 'zip1'=>substr($zip,0,1), + 'zip2'=>substr($zip,0,2), + 'zip3'=>substr($zip,0,3), + 'zip4'=>substr($zip,0,4), + 'zip5'=>substr($zip,0,5), + 'zip6'=>substr($zip,0,6), + 'city'=>isset($address['city'])?trim($address['city']):'', + 'countryid' => 0, 'country' => '', 'country2' => '', 'country3' => '', + 'stateid' => 0, 'state' => '', 'state2' => '', 'state3' => '', + ); + $data['company'] = isset($address['company'])?$address['company']:''; + $data['title'] = isset($address['title'])?$address['title']:''; + $data['first_name'] = isset($address['title'])?$address['title']:''; + $data['middle_name'] = isset($address['middle_name'])?$address['middle_name']:''; + $data['last_name'] = isset($address['last_name'])?$address['last_name']:''; + $data['address1'] = isset($address['address_1'])?$address['address_1']:''; + $data['address2'] = isset($address['address_2'])?$address['address_2']:''; + $data['city'] = isset($address['city'])?$address['city']:''; + $data['phone1'] = isset($address['phone_1'])?$address['phone_1']:''; + $data['phone2'] = isset($address['phone_2'])?$address['phone_2']:''; + $data['fax'] = isset($address['fax'])?$address['fax']:''; + $data['email'] = isset($address['email'])?$address['email']:''; + + // Country and State variables: + $countriesModel = VmModel::getModel('country'); + if (isset($address['virtuemart_country_id'])) { + $data['countryid'] = $address['virtuemart_country_id']; + // The following is a workaround to make sure the cache is invalidated + // because if some other extension meanwhile called $countriesModel->getCountries, + // the cache will be modified, but the model's id will not be changes, so the + // getData call will return the wrong cache. + $countriesModel->setId(0); + $countriesModel->setId($address['virtuemart_country_id']); + $country = $countriesModel->getData($address['virtuemart_country_id']); + if (!empty($country)) { + $data['country'] = $country->country_name; + $data['country2'] = $country->country_2_code; + $data['country3'] = $country->country_3_code; + } + } + + $statesModel = VmModel::getModel('state'); + if (isset($address['virtuemart_state_id'])) { + $data['stateid'] = $address['virtuemart_state_id']; + // The following is a workaround to make sure the cache is invalidated + // because if some other extension meanwhile called $countriesModel->getCountries, + // the cache will be modified, but the model's id will not be changes, so the + // getData call will return the wrong cache. + $statesModel->setId(0); + $statesModel->setId($address['virtuemart_state_id']); + $state = $statesModel->getData($address['virtuemart_state_id']); + if (!empty($state)) { + $data['state'] = $state->state_name; + $data['state2'] = $state->state_2_code; + $data['state3'] = $state->state_3_code; + } + } + + return $data; + } + + protected function getOrderPrices ($cart, $products, $cart_prices) { + $data = array( + 'amount' => 0, + 'amountwithtax' => 0, + 'amountwithouttax' => 0, + 'baseprice' => 0, + 'basepricewithtax' => 0, + 'discountedpricewithouttax' => 0, + 'salesprice' => 0, + 'taxamount' => 0, + 'salespricewithdiscount' => 0, + 'discountamount' => 0, + 'pricewithouttax' => 0, + ); + if (!empty($cart_prices)) { + // get prices for the whole cart -> simply user the cart_prices + $data['amount'] = $cart_prices['salesPrice']; + $data['amountwithtax'] = $cart_prices['salesPrice']; + $data['amountwithouttax'] = $cart_prices['priceWithoutTax']; + $data['baseprice'] = $cart_prices['basePrice']; + $data['basepricewithtax'] = $cart_prices['basePriceWithTax']; + $data['discountedpricewithouttax'] = $cart_prices['discountedPriceWithoutTax']; + $data['salesprice'] = $cart_prices['salesPrice']; + $data['taxamount'] = $cart_prices['taxAmount']; + $data['salespricewithdiscount'] = $cart_prices['salesPriceWithDiscount']; + $data['discountamount'] = $cart_prices['discountAmount']; + $data['pricewithouttax'] = $cart_prices['priceWithoutTax']; + } else { + // Calculate the prices from the individual products! + // Possible problems are discounts on the order total + foreach ($products as $product) { + $data['amount'] += $product->quantity*$product->allPrices[$product->selectedPrice]['salesPrice']; + $data['amountwithtax'] += $product->quantity*$product->allPrices[$product->selectedPrice]['salesPrice']; + $data['amountwithouttax'] += $product->quantity*$product->allPrices[$product->selectedPrice]['priceWithoutTax']; + $data['baseprice'] += $product->quantity*$product->allPrices[$product->selectedPrice]['basePrice']; + $data['basepricewithtax'] += $product->quantity*$product->allPrices[$product->selectedPrice]['basePriceWithTax']; + $data['discountedpricewithouttax'] += $product->quantity*$product->allPrices[$product->selectedPrice]['discountedPriceWithoutTax']; + $data['salesprice'] += $product->quantity*$product->allPrices[$product->selectedPrice]['salesPrice']; + $data['taxamount'] += $product->quantity*$product->allPrices[$product->selectedPrice]['taxAmount']; + $data['salespricewithdiscount'] += $product->quantity*$product->allPrices[$product->selectedPrice]['salesPriceWithDiscount']; + $data['discountamount'] += $product->quantity*$product->allPrices[$product->selectedPrice]['discountAmount']; + $data['pricewithouttax'] += $product->quantity*$product->allPrices[$product->selectedPrice]['priceWithoutTax']; + } + } + return $data; + } + + /** Allow child classes to add additional variables for the rules or modify existing one + */ + protected function addCustomCartValues ($cart, $products, $cart_prices, &$values) { + } + protected function addPluginCartValues($cart, $products, $method, $cart_prices, &$values) { + // Finally, call the triger of vmshipmentrules plugins to let them add/modify variables + JPluginHelper::importPlugin('vmshipmentrules'); + JDispatcher::getInstance()->trigger('onVmShippingRulesGetCartValues',array(&$cartvals, $cart, $products, $method, $cart_prices)); + } + + /** 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 entry altogether + */ + public 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->categories))==0) + continue; + if (!empty($filter_conditions['manufacturers']) && count(array_intersect($filter_conditions['manufacturers'], $p->virtuemart_manufacturer_id))==0) + continue; + if (!empty($filter_conditions['vendors']) && !in_array($p->virtuemart_vendor_id, $filter_conditions['vendors'])) + continue; + $result[] = $p; + } + return $result; + } + +}