rules_shipping_framework.php 51.7 KB
Newer Older
1
2
<?php

3
4
5
if ( !defined( 'ABSPATH' ) && !defined('_JEXEC') ) { 
	die( 'Direct Access to ' . basename( __FILE__ ) . ' is not allowed.' );
}
6
7

/**
8
 * Shipping By Rules Framework for general, rules-based shipments, like regular postal services with complex shipping cost structures
9
 *
10
 * @package ShippingByRules e-commerce system-agnostic framework for shipping plugins.
11
12
13
14
 * @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
 *
15
 * @author Reinhold Kainhofer, Open Tools
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
 *
 */
// 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();
58
59
	protected $custom_functions = array ();
	protected $available_scopings = array();
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
	
	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;
	}
	
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
	/**
	 * Register all possible scopings to the framework in the form
	 *    array("skus" => "products" , "products" => "products")
	 * This registers functions evaluate_for_skus and evaluate_for_products,
	 * which both filter products (they are identical).
	 */
	 public function registerScopings($scopings) {
		$this->available_scopings = $scopings;
	}
	
	/**
	 * Get the list of all scopings the framework implementation claims to have
	 * implemented.
	 */
	public function getScopings() {
		return $this->available_scopings;
	}
	
	public function readableString($string) {
102
103
		switch ($string) {
			case "OTSHIPMENT_RULES_CUSTOMFUNCTIONS_ALREADY_DEFINED":
104
					return "Custom function %s already defined. Ignoring this definition and using previous one.";
105
			case "OTSHIPMENT_RULES_CUSTOMFUNCTIONS_NOARRAY":
106
					return "Definition of custom functions (returned by a plugin) is not a proper array. Ignoring.";
107
			case "OTSHIPMENT_RULES_EVALUATE_ASSIGNMENT_TOPLEVEL":
108
					return "Assignments are not allowed inside expressions (rule given was '%s')";
109
			case "OTSHIPMENT_RULES_EVALUATE_LISTFUNCTION_ARGS":
110
					return "List function '%s' requires all arguments to be lists. (Full rule: '%s')";
111
			case "OTSHIPMENT_RULES_EVALUATE_LISTFUNCTION_CONTAIN_ARGS":
112
					return "List function '%s' requires the first argument to be lists. (Full rule: '%s')";
113
			case "OTSHIPMENT_RULES_EVALUATE_LISTFUNCTION_UNKNOWN":
114
					return "Unknown list function '%s' encountered. (Full rule: '%s')";
115
			case "OTSHIPMENT_RULES_EVALUATE_SYNTAXERROR":
116
					return "Syntax error during evaluation, RPN is not well formed! (Full rule: '%s')";
117
			case "OTSHIPMENT_RULES_EVALUATE_UNKNOWN_ERROR":
118
					return "Unknown error occurred during evaluation of rule '%s'.";
119
			case "OTSHIPMENT_RULES_EVALUATE_UNKNOWN_FUNCTION":
120
					return "Unknown function '%s' encountered during evaluation of rule '%s'.";
121
			case "OTSHIPMENT_RULES_EVALUATE_UNKNOWN_VALUE":
122
					return "Evaluation yields unknown value while evaluating rule part '%s'.";
123
			case "OTSHIPMENT_RULES_NOSHIPPING_MESSAGE":
124
					return "%s";
125
			case "OTSHIPMENT_RULES_PARSE_FUNCTION_NOT_CLOSED":
126
					return "Error during parsing expression '%s': A function call was not closed properly!";
127
			case "OTSHIPMENT_RULES_PARSE_MISSING_PAREN":
128
					return "Error during parsing expression '%s': Opening parenthesis cannot be found!";
129
			case "OTSHIPMENT_RULES_PARSE_PAREN_NOT_CLOSED":
130
					return "Error during parsing expression '%s': A parenthesis was not closed properly!";
131
			case "OTSHIPMENT_RULES_UNKNOWN_OPERATOR":
132
					return "Unknown operator '%s' in shipment rule '%s'";
133
			case "OTSHIPMENT_RULES_UNKNOWN_TYPE":
134
135
136
					return "Unknown rule type '%s' encountered for rule '%s'";
			case "OTSHIPMENT_RULES_SCOPING_UNKNOWN":
					return "Unknown scoping function 'evaluate_for_%s' encountered in rule '%s'";
137
			case "OTSHIPMENT_RULES_UNKNOWN_VARIABLE":
138
139
140
					return "Unknown variable '%s' in rule '%s'";
			default:
					return $string;
141
		}
142
143
144
145
	}
	
	public function __($string) {
		$args = func_get_args();
146

147
		if (isset($this->callbacks["translate"])) {
148
			return call_user_func_array($this->callbacks["translate"], $args);
149
		} else {
150
151
152
153
154
			if (count($args)>1) {
				return call_user_func_array("sprintf", $args);
			} else {
				return $string;
			}
155
156
157
		}
	}

158
159
160
161
162
163
164
165
	/** @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')),
	 *               ...);
	 */
166
167
168
169
	function getCustomFunctions() {
		return array ();
	}
	
170
171
172
173
	/** @tag system-specific
	 *  @function printWarning()
	 *    Print a warning in the system-specific way.
	 *  @param $message the warning message to be printed (already properly translated)
174
	 */
175
176
	protected function printWarning($message) {
		echo($message);
177
178
	}
	
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
	/** @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() {
205
206
207
208
209
210
		$custfuncdefs = $this->getCustomFunctions();
		// Loop through the return values of all plugins:
		foreach ($custfuncdefs as $custfuncs) {
			if (empty($custfuncs))
				continue;
			if (!is_array($custfuncs)) {
211
				$this->warning('OTSHIPMENT_RULES_CUSTOMFUNCTIONS_NOARRAY');
212
213
214
215
216
			}
			// 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])) {
217
					$this->warning('OTSHIPMENT_RULES_CUSTOMFUNCTIONS_ALREADY_DEFINED', $fname);
218
				} else {
219
					$this->debug("Defining custom function $fname");
220
221
222
223
224
					$this->custom_functions[strtolower($fname)] = $func;
				}
			}
		}
	}
225
226
227
228
229
230
231
	
	protected function getMethodId($method) {
		return 0;
	}
	protected function getMethodName($method) {
		return '';
	}
232
233
234
235
236
237
238
239

	/**
	 * 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 */
240
	protected function getOrderCounts ($cart, $products, $method) {
241
242
243
244
245
246
		return array(
			'articles' => 0, 
			'products' => count($products),
			'minquantity' => 9999999999,
			'maxquantity' => 0,
		);
247
	}
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
	
	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,
		);
	}
269

270
	protected function getOrderDimensions ($cart, $products, $method) {
271
272
273
		return array();
	}
	
274
	protected function getOrderWeights ($cart, $products, $method) {
275
276
277
		return array();
	}
	
278
	protected function getOrderListProperties ($cart, $products, $method) {
279
280
281
		return array();
	}
	
282
	protected function getOrderAddress ($cart, $method) {
283
284
285
		return array();
	}
	
286
	protected function getOrderPrices ($cart, $products, $method) {
287
288
		return array();
	}
289
	
290
291
292
293
294
295
296
297
	protected function getDebugVariables ($cart, $products, $method) {
		
		return array(
			'debug_cart'=> print_r($cart,1),
			'debug_products' => print_r($products, 1),
		);
	}
	
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
	/** 
	 * 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;
	}
337
338
339

	/** Allow child classes to add additional variables for the rules or modify existing one
	 */
340
	protected function addCustomCartValues ($cart, $products, $method, &$values) {
341
		if (isset($this->callbacks['addCustomCartValues'])) {
342
			return $this->callbacks['addCustomCartValues']($cart, $products, $method, $values);
343
344
		}
	}
345
	protected function addPluginCartValues($cart, $products, $method, &$values) {
346
347
	}
	
348
	public function getCartValues ($cart, $products, $method) {
349
		$cartvals = array_merge (
350
			$this->getDateTimeVariables($cart, $products, $method),
351
			$this->getOrderCounts($cart, $products, $method),
352
			// Add the prices, optionally calculated from the products subset of the cart
353
			$this->getOrderPrices ($cart, $products, $method),
354
			// Add 'skus', 'categories', 'vendors' variables:
355
			$this->getOrderListProperties ($cart, $products, $method),
356
			// Add country / state variables:
357
			$this->getOrderAddress ($cart, $method),
358
			// Add Total/Min/Max weight and dimension variables:
359
			$this->getOrderWeights ($cart, $products, $method),
360
361
			$this->getOrderDimensions ($cart, $products, $method),
			$this->getDebugVariables ($cart, $products, $method)
362
363
		);
		// Let child classes update the $cartvals array, or add new variables
364
		$this->addCustomCartValues($cart, $products, $method, $cartvals);
365
		// Let custom plugins update the $cartvals array or add new variables
366
		$this->addPluginCartValues($cart, $products, $method, $cartvals);
367
368
369

		return $cartvals;
	}
370
371
372
373
	
	protected function getCartProducts($cart, $method) {
		return array();
	}
374
375
376
377

	/** 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 */
378
379
380
	protected function evaluateMethodRules ($cart, $method) {
		$id = $this->getMethodId($method);
		// $this->match will cache the matched rule and the modifiers
381
382
383
384
		if (isset($this->match[$id])) {
			return $this->match[$id];
		} else {
			// Evaluate all rules and find the matching ones (including modifiers and definitions!)
385
			$cartvals = $this->getCartValues ($cart, $this->getCartProducts($cart, $method), $method);
386
387
388
389
390
391
392
			$result = array(
				"rule" => Null,
				"rule_name" => "",
				"modifiers_add"=> array(),
				"modifiers_multiply" => array(),
				"cartvals" => $cartvals,
			);
393
394
			// Pass a callback function to the rules to obtain the cartvals for a subset of the products
			$this_class = $this;
395
			$cartvals_callback = function ($products) use ($this_class, $cart, $method) {
396
397
				return $this_class->getCartValues ($cart, $products, $method, NULL);
			};
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
			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!
424
425
426
427
428
429
430
431
432
					}
				}
			}
		}
		// None of the rules matched, so return NULL, but keep the evaluated results;
		$this->match[$id] = $result;
		return NULL;
	}

433
434
435
436
437
438
439
440
441
442
443
444
	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;
		}
	}
	
445
	/**
446
	 * @param $cart
447
448
449
	 * @param int             $method
	 * @return bool
	 */
450
451
452
453
	public function checkConditions ($cart, $method) {
		$id = $this->getMethodId($method);
		$name = $this->getMethodName($method);
		if (!isset($this->rules[$id])) 
454
			$this->parseMethodRules($method);
455
		// TODO: This needs to be redone sooner or later!
456
		$match = $this->evaluateMethodRules ($cart, $method);
457
		if ($match && !is_null ($match['rule'])) {
458
			$this->setMethodCosts($method, $match, null);
459
460
			// 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)
461
			if ($this->handleNoShipping($match, $method)) {
462
463
				return FALSE;
			}
464
			return TRUE;
465
		}
466
		$this->debug('checkConditions '.$name.' does not fulfill all conditions, no rule matches');
467
468
		return FALSE;
	}
469
470
471
472
473
474
475
	
	/**
	 * @tag system-specific
	 */
	protected function setMethodCosts($method, $match, $costs) {
		// Allow some system-specific code, e.g. setting some members of $method, etc.
	}
476
477

	/**
478
	 * @param $cart
479
480
481
	 * @param                $method
	 * @return int
	 */
482
	function getCosts ($cart, $method) {
483
		$results = array();
484
485
		$id = $this->getMethodId($method);
		if (!isset($this->rules[$id])) 
486
			$this->parseMethodRules($method);
487
		$match = $this->evaluateMethodRules ($cart, $method);
488
		if ($match) {
489
490
491
492
			if ($this->handleNoShipping($match, $method)) {
				return $results;
			}
		
493
			$r = $match["rule"];
494
495
			$this->debug('Rule ' . $match["rule_name"] . ' ('.$r->rulestring.') matched.');

496
497
498
			// Final shipping costs are calculated as:
			//   Shipping*ExtraShippingMultiplier + ExtraShippingCharge
			// with possibly multiple modifiers
499
			$cost = $r->getShippingCosts();
500
			foreach ($match['modifiers_multiply'] as $modifier) {
501
				$cost *= $modifier->getValue();
502
503
			}
			foreach ($match['modifiers_add'] as $modifier) {
504
				$cost += $modifier->getValue();
505
			}
506
507
			$this->setMethodCosts($method, $match, $cost);

508
509
510
511
512
513
514
515
			$res = array(
				'method' =>   $id,
				'name' =>     $this->getMethodName($method),
// 				'rulesetname'=>$match['ruleset_name'],
				'rulename' => $match["rule_name"],
				'cost' =>     $cost,
			);
			$results[] = $res;
516
517
		}
		
518
519
520
521
		if (empty($results)) {
			$this->debug('getCosts '.$this->getMethodName($method).' does not return shipping costs');
		}
		return $results;
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
	}
	
	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();
		}
	}

540
	protected function createMethodRule ($r, $countries, $ruleinfo) {
541
		if (isset($this->callbacks['initRule'])) {
542
			return $this->callbacks['initRule']($this, $r, $countries, $ruleinfo);
543
		} else {
544
			return new ShippingRule($this, $r, $countries, $ruleinfo);
545
546
547
548
		}
	}

	// 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!)
549
550
551
552
	protected function parseMethodRule ($rulestring, $countries, $ruleinfo, &$method) {
		$id = $this->getMethodId($method);
		foreach ($this->parseRuleSyntax($rulestring, $countries, $ruleinfo) as $r) {
			$this->rules[$id][] = $r;
553
554
555
		}
	}
	
556
	public function parseRuleSyntax($rulestring, $countries, $ruleinfo) {
557
558
559
560
561
		$result = array();
		$rules1 = preg_split("/(\r\n|\n|\r)/", $rulestring);
		foreach ($rules1 as $r) {
			// Ignore empty lines
			if (empty($r)) continue;
562
			$result[] = $this->createMethodRule ($r, $countries, $ruleinfo);
563
564
565
566
567
		}
		return $result;
	}
	
	protected function parseMethodRules (&$method) {
568
		$this->warning("parseMethodRules not reimplemented => No rules will be loaded!");
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
	}

	/** 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();
594
	var $ruleinfo = 0;
595
596
	var $includes_tax = 0;
	
597
598
	function __construct ($framework, $rule, $countries, $ruleinfo) {
		$this->framework = $framework;
599
600
601
602
603
		if (is_array($countries)) {
			$this->countries = $countries;
		} elseif (!empty($countries)) {
			$this->countries[0] = $countries;
		}
604
		$this->ruleinfo = $ruleinfo;
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
		$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;
628
			default:                $this->framework->warning('OTSHIPMENT_RULES_UNKNOWN_VARIABLE', $var, $rulepart);
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
		}
	}
	
	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);
647
		if (!isset($rulepart) || $rulepart==='') return;
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673

		
		// 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 {
674
					$this->framework->warning('OTSHIPMENT_RULES_UNKNOWN_OPERATOR', $atoms[1], $rulepart);
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
					$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:
710
					$this->framework->warning('OTSHIPMENT_RULES_UNKNOWN_OPERATOR', $terms[1], $this->rulestring);
711
712
713
714
715
716
717
718
719
720
					$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!
721
			$this->framework->warning('OTSHIPMENT_RULES_EVALUATE_UNKNOWN_ERROR', $this->rulestring);
722
723
724
725
726
727
728
729
730
731
732
733
734
			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) {
735
			$this->framework->warning('OTSHIPMENT_RULES_EVALUATE_LISTFUNCTION_ARGS', $function, $this->rulestring);
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
			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: 
752
				$this->framework->warning('OTSHIPMENT_RULES_EVALUATE_LISTFUNCTION_UNKNOWN', $function, $this->rulestring);
753
754
755
756
757
758
759
				return false;
		}
	}
	
	protected function evaluateListContainmentFunction ($function, $args) {
		# First make sure that the first argument is a list:
		if (!is_array($args[0])) {
760
			$this->framework->warning('OTSHIPMENT_RULES_EVALUATE_LISTFUNCTION_CONTAIN_ARGS', $function, $this->rulestring);
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
			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: 
792
				$this->framework->warning('OTSHIPMENT_RULES_EVALUATE_LISTFUNCTION_UNKNOWN', $function, $this->rulestring);
793
794
795
796
				return false;
		}
	}
	
797
798
799
800
801
802
803
804
805
806
	protected function normalizeScoping($scoping) {
		$scopings = $this->framework->getScopings();
// $this->framework->warning("<pre>normalizing Scoping $scoping. Registered scopings are: ".print_r($scopings,1)."</pre>");
		if (isset($scopings[$scoping])) {
			return $scopings[$scoping];
		} else {
			return false;
		}
	}
	
807
808
809
810
811
812
	/** 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);
		
813
814
815
816
817
818
819
820
821
// $this->framework->warning("<pre>evaluating scoping $scoping of expression ".print_r($expr,1)." with conditions ".print_r($conditionvals,1)."</pre>");
		// Normalize aliases (e.g. 'skus' and 'products' usually indicate the same scoping
		$normalizedScoping = $this->normalizeScoping($scoping);
		if (!$normalizedScoping) {
			$this->framework->warning('OTSHIPMENT_RULES_SCOPING_UNKNOWN', $scoping, $this->rulestring);
			return false;
		} else {
			$conditions = array($normalizedScoping => $conditionvals);
		}
822
823
824
825
826
827
828
829
830
831
832
833
834

		// 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])) {
835
836
			$this->framework->debug("Evaluating custom function $function, defined by a plugin");
			return call_user_func_array($this->plugin->custom_functions[$func], $args, $this);
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
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
		}

		// 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
906
		$this->framework->warning('OTSHIPMENT_RULES_EVALUATE_UNKNOWN_FUNCTION', $function, $this->rulestring);
907
908
909
910
911
912
913
914
915
916
917
		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;
918
919
920
921
922
		} elseif ($varname=='values_debug' || $varname='debug_values') {
			$tmpvals = $vals;
			unset($tmpvals['debug_cart']);
			unset($tmpvals['debug_products']);
			return print_r($tmpvals,1);
923
		} else {
924
			$this->framework->warning('OTSHIPMENT_RULES_EVALUATE_UNKNOWN_VALUE', $expr, $this->rulestring);
925
926
927
928
929
930
931
932
			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:
933
		$is_scoping = is_array($expr) && ($expr[0]=="FUNCTION") && (count($expr)>1) && (substr($expr[1], 0, 13)==="evaluate_for_");
934
935
936
937
938
939
940
941
942
943
944
945
946
947

		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"
948
			$scope = substr(array_shift($expr), 13); // The scoping function name with "evaluate_for_" cut off
949
			$expression = array_shift($expr); // The expression to be evaluated
950
951
952
953
954
			// 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);
			}
955
			return $this->evaluateScoping ($expression, $scope, $conditions, $vals, $products, $cartvals_callback);
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
			
		} 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':
For faster browsing, not all history is shown. View entire blame