Commit ab939ef2 authored by Reinhold Kainhofer's avatar Reinhold Kainhofer

Initial import

parents
<?php
if ( !defined( 'ABSPATH' ) && !defined('_JEXEC') ) {
die( 'Direct Access to ' . basename( __FILE__ ) . ' is not allowed.' );
}
/**
* Shipping By Rules Framework for general, rules-based shipments, like regular postal services with complex shipping cost structures
*
* @package ShippingByRules e-commerce system-agnostic framework for shipping plugins.
* @subpackage Plugins - shipment
* @copyright Copyright (C) 2013 Reinhold Kainhofer, reinhold@kainhofer.com
* @license http://www.gnu.org/copyleft/gpl.html GNU/GPL, see LICENSE.txt
*
* @author Reinhold Kainhofer, Open Tools
*
*/
// 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();
var $custom_functions = array ();
function __construct() {
// $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 readableString($string) {
switch ($string) {
case "OTSHIPMENT_RULES_CUSTOMFUNCTIONS_ALREADY_DEFINED":
return "Custom function %s already defined. Ignoring this definition and using previous one.";
case "OTSHIPMENT_RULES_CUSTOMFUNCTIONS_NOARRAY":
return "Definition of custom functions (returned by a plugin) is not a proper array. Ignoring.";
case "OTSHIPMENT_RULES_EVALUATE_ASSIGNMENT_TOPLEVEL":
return "Assignments are not allowed inside expressions (rule given was '%s')";
case "OTSHIPMENT_RULES_EVALUATE_LISTFUNCTION_ARGS":
return "List function '%s' requires all arguments to be lists. (Full rule: '%s')";
case "OTSHIPMENT_RULES_EVALUATE_LISTFUNCTION_CONTAIN_ARGS":
return "List function '%s' requires the first argument to be lists. (Full rule: '%s')";
case "OTSHIPMENT_RULES_EVALUATE_LISTFUNCTION_UNKNOWN":
return "Unknown list function '%s' encountered. (Full rule: '%s')";
case "OTSHIPMENT_RULES_EVALUATE_SYNTAXERROR":
return "Syntax error during evaluation, RPN is not well formed! (Full rule: '%s')";
case "OTSHIPMENT_RULES_EVALUATE_UNKNOWN_ERROR":
return "Unknown error occurred during evaluation of rule '%s'.";
case "OTSHIPMENT_RULES_EVALUATE_UNKNOWN_FUNCTION":
return "Unknown function '%s' encountered during evaluation of rule '%s'.";
case "OTSHIPMENT_RULES_EVALUATE_UNKNOWN_VALUE":
return "Evaluation yields unknown value while evaluating rule part '%s'.";
case "OTSHIPMENT_RULES_NOSHIPPING_MESSAGE":
return "%s";
case "OTSHIPMENT_RULES_PARSE_FUNCTION_NOT_CLOSED":
return "Error during parsing expression '%s': A function call was not closed properly!";
case "OTSHIPMENT_RULES_PARSE_MISSING_PAREN":
return "Error during parsing expression '%s': Opening parenthesis cannot be found!";
case "OTSHIPMENT_RULES_PARSE_PAREN_NOT_CLOSED":
return "Error during parsing expression '%s': A parenthesis was not closed properly!";
case "OTSHIPMENT_RULES_UNKNOWN_OPERATOR":
return "Unknown operator '%s' in shipment rule '%s'";
case "OTSHIPMENT_RULES_UNKNOWN_TYPE":
return "Unknown rule type '%s' encountered for rule '%s'";
case "OTSHIPMENT_RULES_UNKNOWN_VARIABLE":
return "Unknown variable '%s' in rule '%s'";
default:
return $string;
}
}
public function __($string) {
$args = func_get_args();
if (isset($this->callbacks["translate"])) {
return call_user_func_array($this->callbacks["translate"], $args);
} else {
if (count($args)>1) {
return call_user_func_array("sprintf", $args);
} else {
return $string;
}
}
}
/** @tag system-specific
* @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')),
* ...);
*/
function getCustomFunctions() {
return array ();
}
/** @tag system-specific
* @function printWarning()
* Print a warning in the system-specific way.
* @param $message the warning message to be printed (already properly translated)
*/
protected function printWarning($message) {
echo($message);
}
/** @tag public-api
* @tag system-specific
* @function warning()
* Print a warning (to be translated) in the system-specific way.
* @param $message the warning message to be printed
* @param $args optional arguments to be inserted into the translated message in sprintf-style
*/
public function warning($message) {
$args = func_get_args();
$msg = call_user_func_array(array($this, "__"), $args);
$this->printWarning($msg);
}
/** @tag public-api
* @function debug()
* Print a debug message (untranslated) in the system-specific way.
* @param $message the debug message to be printed
*/
public function debug($message) {
}
/** @tag public-api
* @function setup
* Initialize the framework. Currently this only sets up plugin-defined custom functions
*/
public 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->warning('OTSHIPMENT_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->warning('OTSHIPMENT_RULES_CUSTOMFUNCTIONS_ALREADY_DEFINED', $fname);
} else {
$this->debug("Defining custom function $fname");
$this->custom_functions[strtolower($fname)] = $func;
}
}
}
}
protected function getMethodId($method) {
return 0;
}
protected function getMethodName($method) {
return '';
}
/**
* 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 getOrderCounts ($cart, $products, $method) {
return array(
'articles' => 0,
'products' => count($products),
'minquantity' => 9999999999,
'maxquantity' => 0,
);
}
protected function getDateTimeVariables($cart, $products, $method) {
$utime = microtime(true);
$milliseconds = (int)(1000*($utime - (int)$utime));
$millisecondsstring = sprintf('%03d', $milliseconds);
return array(
'year' => date("Y", $utime),
'year2' => date("y", $utime),
'month' => date("m", $utime),
'day' => date("d", $utime),
'weekday' => date("N", $utime),
'hour' => date("H", $utime),
'hour12' => date("h", $utime),
'ampm' => date("a", $utime),
'minute' => date("i", $utime),
'second' => date("s", $utime),
'decisecond' => $millisecondsstring[0],
'centisecond' => substr($millisecondsstring, 0, 2),
'millisecond' => $millisecondsstring,
);
}
protected function getOrderDimensions ($cart, $products, $method) {
return array();
}
protected function getOrderWeights ($cart, $products, $method) {
return array();
}
protected function getOrderListProperties ($cart, $products, $method) {
return array();
}
protected function getOrderAddress ($cart, $method) {
return array();
}
protected function getOrderPrices ($cart, $products, $method) {
return array();
}
/**
* Extract information about non-numerical zip codes (UK and Canada) from the postal code
*/
protected function getAddressZIP ($zip) {
$values = array();
// Postal code Check for UK postal codes: Use regexp to determine if ZIP structure matches and also to extract the parts.
// Also handle UK overseas areas/islands that use four-letter outward codes rather than "A{1,2}0{1,2}A{0,1} 0AA"
$zip=strtoupper($zip);
if (isset($zip) and preg_match('/^\s*(([A-Z]{1,2})(\d{1,2})([A-Z]?)|[A-Z]{4}|GIR)\s*(\d[A-Z]{2})\s*$/', $zip, $match)) {
$values['uk_outward'] = $match[1];
$values['uk_area'] = $match[2];
$values['uk_district'] = $match[3];
$values['uk_subdistrict'] = $match[4];
$values['uk_inward'] = $match[5];
} else {
$values['uk_outward'] = NULL;
$values['uk_area'] = NULL;
$values['uk_district'] = NULL;
$values['uk_subdistrict'] = NULL;
$values['uk_inward'] = NULL;
}
// Postal code Check for Canadian postal codes: Use regexp to determine if ZIP structure matches and also to extract the parts.
if (isset($zip) and preg_match('/^\s*(([A-Za-z])(\d)([A-Za-z]))\s*(\d[A-Za-z]\d)\s*$/', $zip, $match)) {
$values['canada_fsa'] = $match[1];
$values['canada_area'] = $match[2];
$values['canada_urban'] = $match[3];
$values['canada_subarea'] = $match[4];
$values['canada_ldu'] = $match[5];
} else {
$values['canada_fsa'] = NULL;
$values['canada_area'] = NULL;
$values['canada_urban'] = NULL;
$values['canada_subarea'] = NULL;
$values['canada_ldu'] = NULL;
}
// print("<pre>values: ".print_r($values,1)."</pre>");
return $values;
}
/** Allow child classes to add additional variables for the rules or modify existing one
*/
protected function addCustomCartValues ($cart, $products, $method, &$values) {
if (isset($this->callbacks['addCustomCartValues'])) {
return $this->callbacks['addCustomCartValues']($cart, $products, $method, $values);
}
}
protected function addPluginCartValues($cart, $products, $method, &$values) {
}
public function getCartValues ($cart, $products, $method) {
$cartvals = array_merge (
$this->getDateTimeVariables($cart, $products, $method),
$this->getOrderCounts($cart, $products, $method),
// Add the prices, optionally calculated from the products subset of the cart
$this->getOrderPrices ($cart, $products, $method),
// Add 'skus', 'categories', 'vendors' variables:
$this->getOrderListProperties ($cart, $products, $method),
// Add country / state variables:
$this->getOrderAddress ($cart, $method),
// Add Total/Min/Max weight and dimension variables:
$this->getOrderWeights ($cart, $products, $method),
$this->getOrderDimensions ($cart, $products, $method)
);
// Let child classes update the $cartvals array, or add new variables
$this->addCustomCartValues($cart, $products, $method, $cartvals);
// Let custom plugins update the $cartvals array or add new variables
$this->addPluginCartValues($cart, $products, $method, $cartvals);
return $cartvals;
}
protected function getCartProducts($cart, $method) {
return array();
}
/** 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) {
$id = $this->getMethodId($method);
// $this->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, $this->getCartProducts($cart, $method), $method);
$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) {
return $this_class->getCartValues ($cart, $products, $method, NULL);
};
if (isset($this->rules[$id])) {
foreach ($this->rules[$id] as $r) {
if ($r->matches($cartvals, $this->getCartProducts($cart, $method), $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->warning('OTSHIPMENT_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;
}
protected function handleNoShipping($match, $method) {
if ($match['rule']->isNoShipping()) {
if (!empty($match["rule_name"]))
$this->warning('OTSHIPMENT_RULES_NOSHIPPING_MESSAGE', $match["rule_name"]);
$name = $this->getMethodName($method);
$this->debug('checkConditions '.$name.' indicates NoShipping for this method, specified by rule "'.$match["rule_name"].'" ('.$match['rule']->rulestring.').');
return true;
} else {
return false;
}
}
/**
* @param $cart
* @param int $method
* @return bool
*/
public function checkConditions ($cart, $method) {
$id = $this->getMethodId($method);
$name = $this->getMethodName($method);
if (!isset($this->rules[$id]))
$this->parseMethodRules($method);
// TODO: This needs to be redone sooner or later!
$match = $this->evaluateMethodRules ($cart, $method);
if ($match && !is_null ($match['rule'])) {
$this->setMethodCosts($method, $match, null);
// 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 ($this->handleNoShipping($match, $method)) {
return FALSE;
}
return TRUE;
}
$this->debug('checkConditions '.$name.' does not fulfill all conditions, no rule matches');
return FALSE;
}
/**
* @tag system-specific
*/
protected function setMethodCosts($method, $match, $costs) {
// Allow some system-specific code, e.g. setting some members of $method, etc.
}
/**
* @param $cart
* @param $method
* @return int
*/
function getCosts ($cart, $method) {
$results = array();
$id = $this->getMethodId($method);
if (!isset($this->rules[$id]))
$this->parseMethodRules($method);
$match = $this->evaluateMethodRules ($cart, $method);
if ($match) {
if ($this->handleNoShipping($match, $method)) {
return $results;
}
$r = $match["rule"];
$this->debug('Rule ' . $match["rule_name"] . ' ('.$r->rulestring.') matched.');
// Final shipping costs are calculated as:
// Shipping*ExtraShippingMultiplier + ExtraShippingCharge
// with possibly multiple modifiers
$cost = $r->getShippingCosts();
foreach ($match['modifiers_multiply'] as $modifier) {
$cost *= $modifier->getValue();
}
foreach ($match['modifiers_add'] as $modifier) {
$cost += $modifier->getValue();
}
$this->setMethodCosts($method, $match, $cost);
$res = array(
'method' => $id,
'name' => $this->getMethodName($method),
// 'rulesetname'=>$match['ruleset_name'],
'rulename' => $match["rule_name"],
'cost' => $cost,
);
$results[] = $res;
}
if (empty($results)) {
$this->debug('getCosts '.$this->getMethodName($method).' does not return shipping costs');
}
return $results;
}
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, $ruleinfo) {
if (isset($this->callbacks['initRule'])) {
return $this->callbacks['initRule']($this, $r, $countries, $ruleinfo);
} else {
return new ShippingRule($this, $r, $countries, $ruleinfo);
}
}
// 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!)
protected function parseMethodRule ($rulestring, $countries, $ruleinfo, &$method) {
$id = $this->getMethodId($method);
foreach ($this->parseRuleSyntax($rulestring, $countries, $ruleinfo) as $r) {
$this->rules[$id][] = $r;
}
}
public function parseRuleSyntax($rulestring, $countries, $ruleinfo) {
$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, $ruleinfo);
}
return $result;
}
protected function parseMethodRules (&$method) {
$this->warning("parseMethodRules not reimplemented => No rules will be loaded!");
}
/** 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 $ruleinfo = 0;
var $includes_tax = 0;
function __construct ($framework, $rule, $countries, $ruleinfo) {
$this->framework = $framework;
if (is_array($countries)) {
$this->countries = $countries;
} elseif (!empty($countries)) {
$this->countries[0] = $countries;
}
$this->ruleinfo = $ruleinfo;
$this->rulestring = $rule;
$this->parseRule($rule);
}
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: $this->framework->warning('OTSHIPMENT_RULES_UNKNOWN_VARIABLE', $var, $rulepart);
}
}
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);
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)) {