rules_shipping_framework.php 47 KB
Newer Older
1
2
3
4
5
<?php

defined ('_JEXEC') or die('Restricted access');

/**
6
 * Shipping By Rules Framework for general, rules-based shipments, like regular postal services with complex shipping cost structures
7
 *
8
 * @package ShippingByRules e-commerce system-agnostic framework for shipping plugins.
9
10
11
12
 * @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
 *
13
 * @author Reinhold Kainhofer, Open Tools
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
 *
 */
// 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 __($string) {
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
		$args = func_get_args();
		switch ($string) {
			case "OTSHIPMENT_RULES_CUSTOMFUNCTIONS_ALREADY_DEFINED":
					$args[0]=""; break;
			case "OTSHIPMENT_RULES_CUSTOMFUNCTIONS_NOARRAY":
					$args[0]=""; break;
			case "OTSHIPMENT_RULES_EVALUATE_ASSIGNMENT_TOPLEVEL":
					$args[0]="Assignments are not allowed inside expressions (rule given was '%s')"; break;
			case "OTSHIPMENT_RULES_EVALUATE_LISTFUNCTION_ARGS":
					$args[0]="List function '%s' requires all arguments to be lists. (Full rule: '%s')"; break;
			case "OTSHIPMENT_RULES_EVALUATE_LISTFUNCTION_CONTAIN_ARGS":
					$args[0]="List function '%s' requires the first argument to be lists. (Full rule: '%s')"; break;
			case "OTSHIPMENT_RULES_EVALUATE_LISTFUNCTION_UNKNOWN":
					$args[0]="Unknown list function '%s' encountered. (Full rule: '%s')"; break;
			case "OTSHIPMENT_RULES_EVALUATE_SYNTAXERROR":
					$args[0]="Syntax error during evaluation, RPN is not well formed! (Full rule: '%s')"; break;
			case "OTSHIPMENT_RULES_EVALUATE_UNKNOWN_ERROR":
					$args[0]="Unknown error occurred during evaluation of rule '%s'."; break;
			case "OTSHIPMENT_RULES_EVALUATE_UNKNOWN_FUNCTION":
					$args[0]="Unknown function '%s' encountered during evaluation of rule '%s'."; break;
			case "OTSHIPMENT_RULES_EVALUATE_UNKNOWN_VALUE":
					$args[0]="Evaluation yields unknown value while evaluating rule part '%s'."; break;
			case "OTSHIPMENT_RULES_NOSHIPPING_MESSAGE":
					$args[0]=""; break;
			case "OTSHIPMENT_RULES_PARSE_FUNCTION_NOT_CLOSED":
					$args[0]="Error during parsing expression '%s': A function call was not closed properly!"; break;
			case "OTSHIPMENT_RULES_PARSE_MISSING_PAREN":
					$args[0]="Error during parsing expression '%s': Opening parenthesis cannot be found!"; break;
			case "OTSHIPMENT_RULES_PARSE_PAREN_NOT_CLOSED":
					$args[0]="Error during parsing expression '%s': A parenthesis was not closed properly!"; break;
			case "OTSHIPMENT_RULES_UNKNOWN_OPERATOR":
					$args[0]="Unknown operator '%s' in shipment rule '%s'"; break;
			case "OTSHIPMENT_RULES_UNKNOWN_TYPE":
					$args[0]=""; break;
			case "OTSHIPMENT_RULES_UNKNOWN_VARIABLE":
					$args[0]="Unknown variable '%s' in rule '%s'"; break;
		}

119
		if (isset($this->callbacks["translate"])) {
120
			return call_user_func_array($this->callbacks["translate"], $args);
121
		} else {
122
123
124
125
126
			if (count($args)>1) {
				return call_user_func_array("sprintf", $args);
			} else {
				return $string;
			}
127
128
129
		}
	}

130
131
132
133
134
135
136
137
	/** @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')),
	 *               ...);
	 */
138
139
140
141
	function getCustomFunctions() {
		return array ();
	}
	
142
143
144
145
	/** @tag system-specific
	 *  @function printWarning()
	 *    Print a warning in the system-specific way.
	 *  @param $message the warning message to be printed (already properly translated)
146
	 */
147
148
	protected function printWarning($message) {
		echo($message);
149
150
	}
	
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
	/** @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() {
177
178
179
180
181
182
		$custfuncdefs = $this->getCustomFunctions();
		// Loop through the return values of all plugins:
		foreach ($custfuncdefs as $custfuncs) {
			if (empty($custfuncs))
				continue;
			if (!is_array($custfuncs)) {
183
				$this->warning('OTSHIPMENT_RULES_CUSTOMFUNCTIONS_NOARRAY');
184
185
186
187
188
			}
			// 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])) {
189
					$this->warning('OTSHIPMENT_RULES_CUSTOMFUNCTIONS_ALREADY_DEFINED', $fname);
190
				} else {
191
					$this->debug("Defining custom function $fname");
192
193
194
195
196
					$this->custom_functions[strtolower($fname)] = $func;
				}
			}
		}
	}
197
198
199
200
201
202
203
	
	protected function getMethodId($method) {
		return 0;
	}
	protected function getMethodName($method) {
		return '';
	}
204
205
206
207
208
209
210
211

	/**
	 * 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 */
212
213
	protected function getOrderCounts ($cart, $products, $method) {
		return array('articles' => 0, 'products' => count($products));
214
215
	}

216
	protected function getOrderDimensions ($cart, $products, $method) {
217
218
219
		return array();
	}
	
220
	protected function getOrderWeights ($cart, $products, $method) {
221
222
223
		return array();
	}
	
224
	protected function getOrderListProperties ($cart, $products, $method) {
225
226
227
		return array();
	}
	
228
	protected function getOrderAddress ($cart, $method) {
229
230
231
		return array();
	}
	
232
	protected function getOrderPrices ($cart, $products, $method) {
233
234
235
236
237
		return array();
	}

	/** Allow child classes to add additional variables for the rules or modify existing one
	 */
238
	protected function addCustomCartValues ($cart, $products, $method, &$values) {
239
		if (isset($this->callbacks['addCustomCartValues'])) {
240
			return $this->callbacks['addCustomCartValues']($cart, $products, $method, $values);
241
242
		}
	}
243
	protected function addPluginCartValues($cart, $products, $method, &$values) {
244
245
	}
	
246
	public function getCartValues ($cart, $products, $method) {
247
		$cartvals = array_merge (
248
			$this->getOrderCounts($cart, $products, $method),
249
			// Add the prices, optionally calculated from the products subset of the cart
250
			$this->getOrderPrices ($cart, $products, $method),
251
			// Add 'skus', 'categories', 'vendors' variables:
252
			$this->getOrderListProperties ($cart, $products, $method),
253
			// Add country / state variables:
254
			$this->getOrderAddress ($cart, $method),
255
			// Add Total/Min/Max weight and dimension variables:
256
257
			$this->getOrderWeights ($cart, $products, $method),
			$this->getOrderDimensions ($cart, $products, $method)
258
259
		);
		// Let child classes update the $cartvals array, or add new variables
260
		$this->addCustomCartValues($cart, $products, $method, $cartvals);
261
		// Let custom plugins update the $cartvals array or add new variables
262
		$this->addPluginCartValues($cart, $products, $method, $cartvals);
263
264
265

		return $cartvals;
	}
266
267
268
269
	
	protected function getCartProducts($cart, $method) {
		return array();
	}
270
271
272
273

	/** 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 */
274
275
276
	protected function evaluateMethodRules ($cart, $method) {
		$id = $this->getMethodId($method);
		// $this->match will cache the matched rule and the modifiers
277
278
279
280
		if (isset($this->match[$id])) {
			return $this->match[$id];
		} else {
			// Evaluate all rules and find the matching ones (including modifiers and definitions!)
281
			$cartvals = $this->getCartValues ($cart, $this->getCartProducts($cart, $method), $method);
282
283
284
285
286
287
288
			$result = array(
				"rule" => Null,
				"rule_name" => "",
				"modifiers_add"=> array(),
				"modifiers_multiply" => array(),
				"cartvals" => $cartvals,
			);
289
290
			// Pass a callback function to the rules to obtain the cartvals for a subset of the products
			$this_class = $this;
291
			$cartvals_callback = function ($products) use ($this_class, $cart, $method) {
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
				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:
312
								$this->warning('OTSHIPMENT_RULES_UNKNOWN_TYPE', $r->getType(), $r->rulestring);
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
								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;
	}

	/**
328
	 * @param $cart
329
330
331
	 * @param int             $method
	 * @return bool
	 */
332
333
334
335
	public function checkConditions ($cart, $method) {
		$id = $this->getMethodId($method);
		$name = $this->getMethodName($method);
		if (!isset($this->rules[$id])) 
336
			$this->parseMethodRules($method);
337
		$match = $this->evaluateMethodRules ($cart, $method);
338
		if ($match && !is_null ($match['rule'])) {
339
			$this->setMethodCosts($method, $match, null);
340
341
342
			// 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()) {
343
344
345
				if (!empty($match["rule_name"]))
					$this->warning('OTSHIPMENT_RULES_NOSHIPPING_MESSAGE', $match["rule_name"]);
				$this->debug('checkConditions '.$name.' indicates NoShipping for this method, specified by rule "'.$match["rule_name"].'" ('.$match['rule']->rulestring.').');
346
347
348
349
350
				return FALSE;
			} else {
				return TRUE;
			}
		}
351
		$this->debug('checkConditions '.$name.' does not fulfill all conditions, no rule matches');
352
353
		return FALSE;
	}
354
355
356
357
358
359
360
	
	/**
	 * @tag system-specific
	 */
	protected function setMethodCosts($method, $match, $costs) {
		// Allow some system-specific code, e.g. setting some members of $method, etc.
	}
361
362

	/**
363
	 * @param $cart
364
365
366
	 * @param                $method
	 * @return int
	 */
367
368
369
	function getCosts ($cart, $method) {
		$id = $this->getMethodId($method);
		if (!isset($this->rules[$id])) 
370
			$this->parseMethodRules($method);
371
		$match = $this->evaluateMethodRules ($cart, $method);
372
373
		if ($match) {
			$r = $match["rule"];
374
375
			$this->debug('Rule ' . $match["rule_name"] . ' ('.$r->rulestring.') matched.');

376
377
378
			// Final shipping costs are calculated as:
			//   Shipping*ExtraShippingMultiplier + ExtraShippingCharge
			// with possibly multiple modifiers
379
			$cost = $r->getShippingCosts();
380
			foreach ($match['modifiers_multiply'] as $modifier) {
381
				$cost *= $modifier->getValue();
382
383
			}
			foreach ($match['modifiers_add'] as $modifier) {
384
				$cost += $modifier->getValue();
385
			}
386
387
388
			$this->setMethodCosts($method, $match, $cost);

			return $cost;
389
390
		}
		
391
		$this->debug('getCosts '.$this->getMethodName($method).' does not return shipping costs');
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
		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();
		}
	}

411
	protected function createMethodRule ($r, $countries, $ruleinfo) {
412
		if (isset($this->callbacks['initRule'])) {
413
			return $this->callbacks['initRule']($this, $r, $countries, $ruleinfo);
414
		} else {
415
			return new ShippingRule($this, $r, $countries, $ruleinfo);
416
417
418
419
		}
	}

	// 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!)
420
421
422
423
	protected function parseMethodRule ($rulestring, $countries, $ruleinfo, &$method) {
		$id = $this->getMethodId($method);
		foreach ($this->parseRuleSyntax($rulestring, $countries, $ruleinfo) as $r) {
			$this->rules[$id][] = $r;
424
425
426
		}
	}
	
427
	public function parseRuleSyntax($rulestring, $countries, $ruleinfo) {
428
429
430
431
432
		$result = array();
		$rules1 = preg_split("/(\r\n|\n|\r)/", $rulestring);
		foreach ($rules1 as $r) {
			// Ignore empty lines
			if (empty($r)) continue;
433
			$result[] = $this->createMethodRule ($r, $countries, $ruleinfo);
434
435
436
437
438
		}
		return $result;
	}
	
	protected function parseMethodRules (&$method) {
439
		$this->warning("parseMethodRules not reimplemented => No rules will be loaded!");
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
	}

	/** 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();
465
	var $ruleinfo = 0;
466
467
	var $includes_tax = 0;
	
468
469
	function __construct ($framework, $rule, $countries, $ruleinfo) {
		$this->framework = $framework;
470
471
472
473
474
		if (is_array($countries)) {
			$this->countries = $countries;
		} elseif (!empty($countries)) {
			$this->countries[0] = $countries;
		}
475
		$this->ruleinfo = $ruleinfo;
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
		$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;
499
			default:                $this->framework->warning('OTSHIPMENT_RULES_UNKNOWN_VARIABLE', $var, $rulepart);
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
		}
	}
	
	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);
518
		if (!isset($rulepart) || $rulepart==='') return;
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544

		
		// 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 {
545
					$this->framework->warning('OTSHIPMENT_RULES_UNKNOWN_OPERATOR', $atoms[1], $rulepart);
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
					$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:
581
					$this->framework->warning('OTSHIPMENT_RULES_UNKNOWN_OPERATOR', $terms[1], $this->rulestring);
582
583
584
585
586
587
588
589
590
591
					$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!
592
			$this->framework->warning('OTSHIPMENT_RULES_EVALUATE_UNKNOWN_ERROR', $this->rulestring);
593
594
595
596
597
598
599
600
601
602
603
604
605
			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) {
606
			$this->framework->warning('OTSHIPMENT_RULES_EVALUATE_LISTFUNCTION_ARGS', $function, $this->rulestring);
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
			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: 
623
				$this->framework->warning('OTSHIPMENT_RULES_EVALUATE_LISTFUNCTION_UNKNOWN', $function, $this->rulestring);
624
625
626
627
628
629
630
				return false;
		}
	}
	
	protected function evaluateListContainmentFunction ($function, $args) {
		# First make sure that the first argument is a list:
		if (!is_array($args[0])) {
631
			$this->framework->warning('OTSHIPMENT_RULES_EVALUATE_LISTFUNCTION_CONTAIN_ARGS', $function, $this->rulestring);
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
			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: 
663
				$this->framework->warning('OTSHIPMENT_RULES_EVALUATE_LISTFUNCTION_UNKNOWN', $function, $this->rulestring);
664
665
666
667
668
669
670
671
672
673
674
675
				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',
676
677
			"evaluate_for_products" =>      'skus',
			"evaluate_for_skus" =>          'skus',
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
			"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])) {
698
699
			$this->framework->debug("Evaluating custom function $function, defined by a plugin");
			return call_user_func_array($this->plugin->custom_functions[$func], $args, $this);
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
		}

		// 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
769
		$this->framework->warning('OTSHIPMENT_RULES_EVALUATE_UNKNOWN_FUNCTION', $function, $this->rulestring);
770
771
772
773
774
775
776
777
778
779
780
781
782
783
		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 {
784
			$this->framework->warning('OTSHIPMENT_RULES_EVALUATE_UNKNOWN_VALUE', $expr, $this->rulestring);
785
786
787
788
789
790
791
792
			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:
793
		$scoping_functions = array("evaluate_for_categories", "evaluate_for_products", "evaluate_for_skus", "evaluate_for_vendors", "evaluate_for_manufacturers");
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
		$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
811
812
813
814
815
			// the remaining $expr list now contains the conditions. Evaluate them one by one:
			$conditions = array();
			foreach ($expr as $e) {
				$conditions[] = $this->evaluateTerm($e, $vals, $products, $cartvals_callback);
			}
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
			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;
			}
			
			return $res;
		} else {
			// Neither string nor numeric, nor operator...
885
			$this->framework->warning('OTSHIPMENT_RULES_EVALUATE_UNKNOWN_VALUE', $expr, $this->rulestring);
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
			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)) {
902
// 			$this->framework->debug('Rule::matches: Country check failed: countryid='.print_r($vals['countryid'],1).', countries are: '.print_r($this->countries,1).'...');
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
			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
920
921
		$matches = array();
		$name = $this->framework->__($this->name);
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
		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)
944
			$this->framework->debug('WARNING: getRuleName called without prior evaluation of the rule, e.g. by calling rule->matches(...)');
945
946
947
948
949
		return $this->rulename;
	}
	
	function getValue() {
		if (!$this->evaluated)
950
			$this->framework->debug('WARNING: getValue called without prior evaluation of the rule, e.g. by calling rule->matches(...)');
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
		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 {
967
968
	function __construct ($framework, $rule, $countries, $ruleinfo) {
		parent::__construct ($framework, $rule, $countries, $ruleinfo);
969
970
971
972
973
974
975
976
977
	}
	
	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);
978
		// $this->framework->warning("TOKENIZING '$expression' returns: <pre>".print_r($atoms,1)."</pre>");
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
		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 faster browsing, not all history is shown. View entire blame