1
|
<?php
|
2
|
|
3
|
/**
|
4
|
* @file
|
5
|
* Enables Rules based product sell price calculation for dynamic product pricing.
|
6
|
*/
|
7
|
|
8
|
/**
|
9
|
* Implements hook_hook_info().
|
10
|
*/
|
11
|
function commerce_product_pricing_hook_info() {
|
12
|
$hooks = array(
|
13
|
'commerce_product_valid_pre_calculation_rule' => array(
|
14
|
'group' => 'commerce',
|
15
|
),
|
16
|
'commerce_product_valid_pre_calculation_product' => array(
|
17
|
'group' => 'commerce',
|
18
|
),
|
19
|
'commerce_product_calculate_sell_price_line_item_alter' => array(
|
20
|
'group' => 'commerce',
|
21
|
),
|
22
|
);
|
23
|
|
24
|
return $hooks;
|
25
|
}
|
26
|
|
27
|
/**
|
28
|
* Implements hook_commerce_price_field_calculation_options().
|
29
|
*
|
30
|
* To accommodate dynamic sell price calculation on the display level, we depend
|
31
|
* on display formatter settings to alert the module when to calculate a price.
|
32
|
* However, by default all price fields are set to show the original price as
|
33
|
* loaded with no option to change this. This module needs to add its own option
|
34
|
* to the list so it can know which prices should be calculated on display.
|
35
|
*
|
36
|
* @see commerce_product_pricing_commerce_price_field_formatter_prepare_view()
|
37
|
*/
|
38
|
function commerce_product_pricing_commerce_price_field_calculation_options($field, $instance, $view_mode) {
|
39
|
// If this is a single value purchase price field attached to a product...
|
40
|
if (($instance['entity_type'] == 'commerce_product' || $field['entity_types'] == array('commerce_product')) &&
|
41
|
$field['field_name'] == 'commerce_price' && $field['cardinality'] == 1) {
|
42
|
return array('calculated_sell_price' => t('Display the calculated sell price for the current user.'));
|
43
|
}
|
44
|
}
|
45
|
|
46
|
/**
|
47
|
* Implements hook_commerce_price_field_formatter_prepare_view().
|
48
|
*
|
49
|
* It isn't until the point of display that we know whether a particular price
|
50
|
* field should be altered to display the current user's purchase price of the
|
51
|
* product. Therefore, instead of trying to calculate dynamic prices on load,
|
52
|
* we calculate them prior to display but at the point where we know the full
|
53
|
* context of the display, including the display formatter settings for the
|
54
|
* pertinent view mode.
|
55
|
*
|
56
|
* The hook is invoked before a price field is formatted, so this implementation
|
57
|
* lets us swap in the calculated sell price of a product for a given point of
|
58
|
* display. The way it calculates the price is by creating a pseudo line item
|
59
|
* for the current product that is passed to Rules for transformation. Rule
|
60
|
* configurations may then use actions to set and alter the unit price of the
|
61
|
* line item, which, being an object, is passed by reference through all the
|
62
|
* various actions. Upon completion of the Rules execution, the unit price data
|
63
|
* is then swapped in for the data of the current field for display.
|
64
|
*/
|
65
|
function commerce_product_pricing_commerce_price_field_formatter_prepare_view($entity_type, $entities, $field, $instances, $langcode, &$items, $displays) {
|
66
|
// If this is a single value purchase price field attached to a product...
|
67
|
if ($entity_type == 'commerce_product' && $field['field_name'] == 'commerce_price' && $field['cardinality'] == 1) {
|
68
|
// Prepare the items for each entity passed in.
|
69
|
foreach ($entities as $product_id => $product) {
|
70
|
// If this price should be calculated...
|
71
|
if (!empty($displays[$product_id]['settings']['calculation']) &&
|
72
|
$displays[$product_id]['settings']['calculation'] == 'calculated_sell_price') {
|
73
|
// If this price has already been calculated, reset it to its original
|
74
|
// value so it can be recalculated afresh in the current context.
|
75
|
if (isset($items[$product_id][0]['original'])) {
|
76
|
$original = $items[$product_id][0]['original'];
|
77
|
$items[$product_id] = array(0 => $original);
|
78
|
|
79
|
// Reset the price field value on the product object used to perform
|
80
|
// the calculation.
|
81
|
foreach ($product->commerce_price as $langcode => $value) {
|
82
|
$product->commerce_price[$langcode] = $items[$product_id];
|
83
|
}
|
84
|
}
|
85
|
else {
|
86
|
// Save the original value for use in subsequent calculations.
|
87
|
$original = isset($items[$product_id][0]) ? $items[$product_id][0] : NULL;
|
88
|
}
|
89
|
|
90
|
// Replace the data being displayed with data from a calculated price.
|
91
|
$items[$product_id] = array();
|
92
|
$items[$product_id][0] = commerce_product_calculate_sell_price($product);
|
93
|
$items[$product_id][0]['original'] = $original;
|
94
|
}
|
95
|
}
|
96
|
}
|
97
|
}
|
98
|
|
99
|
/**
|
100
|
* Returns the calculated sell price for the given product.
|
101
|
*
|
102
|
* @param $product
|
103
|
* The product whose sell price will be calculated.
|
104
|
* @param $precalc
|
105
|
* Boolean indicating whether or not the pre-calculated sell price from the
|
106
|
* database should be requested before calculating it anew.
|
107
|
*
|
108
|
* @return
|
109
|
* A price field data array as returned by entity_metadata_wrapper().
|
110
|
*/
|
111
|
function commerce_product_calculate_sell_price($product, $precalc = FALSE) {
|
112
|
// First create a pseudo product line item that we will pass to Rules.
|
113
|
$line_item = commerce_product_line_item_new($product);
|
114
|
|
115
|
// Allow modules to prepare this as necessary.
|
116
|
drupal_alter('commerce_product_calculate_sell_price_line_item', $line_item);
|
117
|
|
118
|
// Attempt to fetch a database stored price if specified.
|
119
|
if ($precalc) {
|
120
|
$module_key = commerce_product_pre_calculation_key();
|
121
|
|
122
|
$result = db_select('commerce_calculated_price')
|
123
|
->fields('commerce_calculated_price', array('amount', 'currency_code', 'data'))
|
124
|
->condition('module', 'commerce_product_pricing')
|
125
|
->condition('module_key', $module_key)
|
126
|
->condition('entity_type', 'commerce_product')
|
127
|
->condition('entity_id', $product->product_id)
|
128
|
->condition('field_name', 'commerce_price')
|
129
|
->execute()
|
130
|
->fetchObject();
|
131
|
|
132
|
// If a pre-calculated price was found...
|
133
|
if (!empty($result)) {
|
134
|
// Wrap the line item, swap in the price, and return it.
|
135
|
$wrapper = entity_metadata_wrapper('commerce_line_item', $line_item);
|
136
|
|
137
|
$wrapper->commerce_unit_price->amount = $result->amount;
|
138
|
$wrapper->commerce_unit_price->currency_code = $result->currency_code;
|
139
|
|
140
|
// Unserialize the saved prices data array and initialize to an empty
|
141
|
// array if the column was empty.
|
142
|
$result->data = unserialize($result->data);
|
143
|
$wrapper->commerce_unit_price->data = !empty($result->data) ? $result->data : array();
|
144
|
|
145
|
return $wrapper->commerce_unit_price->value();
|
146
|
}
|
147
|
}
|
148
|
|
149
|
// Pass the line item to Rules.
|
150
|
rules_invoke_event('commerce_product_calculate_sell_price', $line_item);
|
151
|
|
152
|
return entity_metadata_wrapper('commerce_line_item', $line_item)->commerce_unit_price->value();
|
153
|
}
|
154
|
|
155
|
/**
|
156
|
* Generates a price pre-calculation module key indicating which pricing Rules
|
157
|
* currently apply.
|
158
|
*/
|
159
|
function commerce_product_pre_calculation_key() {
|
160
|
// Load the sell price calculation event.
|
161
|
$event = rules_get_cache('event_commerce_product_calculate_sell_price');
|
162
|
|
163
|
// If there are no rule configurations, use an empty key.
|
164
|
if (empty($event)) {
|
165
|
return '';
|
166
|
}
|
167
|
|
168
|
// Build an array of the names of all rule configurations that qualify for
|
169
|
// dynamic pre-calculation.
|
170
|
$rule_names = array();
|
171
|
|
172
|
$state = new RulesState();
|
173
|
|
174
|
foreach ($event as $rule) {
|
175
|
// Only add Rules with conditions that evaluate to TRUE.
|
176
|
if (count($rule->conditions()) > 0 &&
|
177
|
$rule->conditionContainer()->evaluate($state)) {
|
178
|
$rule_names[] = $rule->name;
|
179
|
}
|
180
|
}
|
181
|
|
182
|
// If no valid Rules were found, return an empty string.
|
183
|
if (empty($rule_names)) {
|
184
|
return '';
|
185
|
}
|
186
|
|
187
|
// Otherwise sort to ensure the names are in alphabetical order and return the
|
188
|
// imploded module key.
|
189
|
sort($rule_names);
|
190
|
|
191
|
return implode('|', $rule_names);
|
192
|
}
|
193
|
|
194
|
/**
|
195
|
* Returns an array of rule keys used for pre-calculating product sell prices.
|
196
|
*
|
197
|
* Rule keys represent every possible combination of rules that might alter any
|
198
|
* given product sell price. If no valid rules exist, an empty array is returned.
|
199
|
*/
|
200
|
function commerce_product_pre_calculation_rule_keys() {
|
201
|
static $rule_keys = NULL;
|
202
|
|
203
|
if (is_null($rule_keys)) {
|
204
|
// Load the sell price calculation event.
|
205
|
$event = rules_get_cache('event_commerce_product_calculate_sell_price');
|
206
|
|
207
|
// Build an array of the names of all rule configurations that qualify for
|
208
|
// dynamic pre-calculation.
|
209
|
$rule_names = array();
|
210
|
|
211
|
foreach ($event as $rule) {
|
212
|
if (commerce_product_valid_pre_calculation_rule($rule)) {
|
213
|
$rule_names[] = $rule->name;
|
214
|
}
|
215
|
}
|
216
|
|
217
|
// Sort to ensure the names are always in alphabetical order.
|
218
|
sort($rule_names);
|
219
|
|
220
|
// Using the array of names, generate an array that contains keys for every
|
221
|
// possible combination of these Rules applying (i.e. conditions all passing).
|
222
|
$rule_keys = array();
|
223
|
|
224
|
// First find the maximum number of combinations as a power of two.
|
225
|
$max = pow(2, count($rule_names));
|
226
|
|
227
|
// Loop through each combination expressed as an integer.
|
228
|
for ($i = 0; $i < $max; $i++) {
|
229
|
// Convert the integer to a string binary representation, reverse it (so the
|
230
|
// first bit is on the left instead of right), and split it into an array
|
231
|
// with each bit as its own value.
|
232
|
$bits = str_split(strrev(sprintf('%0' . count($rule_names) . 'b', $i)));
|
233
|
|
234
|
// Create a key of underscore delimited Rule IDs by assuming a 1 means the
|
235
|
// Rule ID in the $rule_ids array with the same key as the bit's position in
|
236
|
// the string should be assumed to have applied.
|
237
|
$key = implode('|', array_intersect_key($rule_names, array_intersect($bits, array('1'))));
|
238
|
|
239
|
$rule_keys[] = $key;
|
240
|
}
|
241
|
}
|
242
|
|
243
|
return $rule_keys;
|
244
|
}
|
245
|
|
246
|
/**
|
247
|
* Pre-calculates sell prices for qualifying products based on valid rule
|
248
|
* configurations on the "Calculating product sell price" event.
|
249
|
*
|
250
|
* @param $from
|
251
|
* For calculating prices for a limited number of products at a time, specify
|
252
|
* the range's "from" amount.
|
253
|
* @param $count
|
254
|
* For calculating prices for a limited number of products at a time, specify
|
255
|
* the range's "count" amount.
|
256
|
*
|
257
|
* @return
|
258
|
* The number of pre-calculated prices created.
|
259
|
*/
|
260
|
function commerce_product_pre_calculate_sell_prices($from = NULL, $count = 1) {
|
261
|
$total = 0;
|
262
|
|
263
|
// Load the sell price calculation event.
|
264
|
$event = rules_get_cache('event_commerce_product_calculate_sell_price');
|
265
|
|
266
|
// If there are no rule configurations, leave without further processing.
|
267
|
if (empty($event)) {
|
268
|
return array();
|
269
|
}
|
270
|
|
271
|
// If a range was specified, query just those products...
|
272
|
if (!is_null($from)) {
|
273
|
$query = db_query_range("SELECT product_id FROM {commerce_product}", $from, $count);
|
274
|
}
|
275
|
else {
|
276
|
// Otherwise load all products.
|
277
|
$query = db_query("SELECT product_id FROM {commerce_product}");
|
278
|
}
|
279
|
|
280
|
while ($product_id = $query->fetchField()) {
|
281
|
$product = commerce_product_load($product_id);
|
282
|
|
283
|
// If the product is valid for pre-calculation...
|
284
|
if (commerce_product_valid_pre_calculation_product($product)) {
|
285
|
// For each rule key (i.e. set of applicable rule configurations)...
|
286
|
foreach (commerce_product_pre_calculation_rule_keys() as $key) {
|
287
|
// Build a product line item and Rules state object.
|
288
|
$line_item = commerce_product_line_item_new($product);
|
289
|
|
290
|
$state = new RulesState();
|
291
|
$vars = $event->parameterInfo(TRUE);
|
292
|
$state->addVariable('commerce_line_item', $line_item, $vars['commerce_line_item']);
|
293
|
|
294
|
// For each Rule signified by the current key...
|
295
|
foreach (explode('|', $key) as $name) {
|
296
|
// Load the Rule and "fire" it, evaluating its actions without doing
|
297
|
// any condition evaluation.
|
298
|
if ($rule = rules_config_load($name)) {
|
299
|
$rule->fire($state);
|
300
|
}
|
301
|
}
|
302
|
|
303
|
// Also fire any Rules that weren't included in the key because they
|
304
|
// don't have any conditions.
|
305
|
foreach ($event as $rule) {
|
306
|
if (count($rule->conditions()) == 0) {
|
307
|
$rule->fire($state);
|
308
|
}
|
309
|
}
|
310
|
|
311
|
// Build the record of the pre-calculated price and write it.
|
312
|
$wrapper = entity_metadata_wrapper('commerce_line_item', $line_item);
|
313
|
|
314
|
if (!is_null($wrapper->commerce_unit_price->value())) {
|
315
|
$record = array(
|
316
|
'module' => 'commerce_product_pricing',
|
317
|
'module_key' => $key,
|
318
|
'entity_type' => 'commerce_product',
|
319
|
'entity_id' => $product_id,
|
320
|
'field_name' => 'commerce_price',
|
321
|
'language' => !empty($product->language) ? $product->language : LANGUAGE_NONE,
|
322
|
'delta' => 0,
|
323
|
'amount' => round($wrapper->commerce_unit_price->amount->value()),
|
324
|
'currency_code' => $wrapper->commerce_unit_price->currency_code->value(),
|
325
|
'data' => $wrapper->commerce_unit_price->data->value(),
|
326
|
'created' => time(),
|
327
|
);
|
328
|
|
329
|
// Save the price and increment the total if successful.
|
330
|
if (drupal_write_record('commerce_calculated_price', $record) == SAVED_NEW) {
|
331
|
$total++;
|
332
|
}
|
333
|
}
|
334
|
}
|
335
|
}
|
336
|
}
|
337
|
|
338
|
return $total;
|
339
|
}
|
340
|
|
341
|
/**
|
342
|
* Determines if a given rule configuration meets the requirements for price
|
343
|
* pre-calculation.
|
344
|
*
|
345
|
* @param $rule
|
346
|
* A rule configuration belonging to the commerce_product_calculate_sell_price
|
347
|
* event.
|
348
|
* @param $limit_validation
|
349
|
* A boolean indicating whether or not the validation check should limit
|
350
|
* itself to the conditions in this function, effectively skipping the
|
351
|
* invocation of hook_commerce_product_valid_pre_calculation_rule().
|
352
|
*
|
353
|
* @return
|
354
|
* TRUE or FALSE indicating whether or not the rule configuration is valid.
|
355
|
*/
|
356
|
function commerce_product_valid_pre_calculation_rule($rule, $limit_validation = FALSE) {
|
357
|
// If a rule configuration doesn't have any conditions, it doesn't need to
|
358
|
// unique consideration in pre-calculation, as its actions will always apply.
|
359
|
if (count($rule->conditions()) == 0) {
|
360
|
return FALSE;
|
361
|
}
|
362
|
|
363
|
// Inspect each condition on the rule configuration. This likely needs to be
|
364
|
// recursive for conditions in nested operator groups.
|
365
|
foreach ($rule->conditions() as $condition) {
|
366
|
// Look for line item usage in any selector in the condition settings.
|
367
|
foreach ($condition->settings as $key => $value) {
|
368
|
if (substr($key, -7) == ':select') {
|
369
|
// If the selector references either line-item or line-item-unchanged,
|
370
|
// the Rule is not valid for price pre-calculation.
|
371
|
if (strpos($value, 'line-item') === 0) {
|
372
|
return FALSE;
|
373
|
}
|
374
|
}
|
375
|
}
|
376
|
}
|
377
|
|
378
|
// Allow other modules to invalidate this rule configuration.
|
379
|
if (!$limit_validation) {
|
380
|
if (in_array(FALSE, module_invoke_all('commerce_product_valid_pre_calculation_rule', $rule))) {
|
381
|
return FALSE;
|
382
|
}
|
383
|
}
|
384
|
|
385
|
return TRUE;
|
386
|
}
|
387
|
|
388
|
/**
|
389
|
* Determines if a given product should be considered for price pre-calculation.
|
390
|
*
|
391
|
* @param $product
|
392
|
* The product being considered for sell price pre-calculation.
|
393
|
*
|
394
|
* @return
|
395
|
* TRUE or FALSE indicating whether or not the product is valid.
|
396
|
*/
|
397
|
function commerce_product_valid_pre_calculation_product($product) {
|
398
|
// Allow other modules to invalidate this product.
|
399
|
if (in_array(FALSE, module_invoke_all('commerce_product_valid_pre_calculation_product', $product))) {
|
400
|
return FALSE;
|
401
|
}
|
402
|
|
403
|
return TRUE;
|
404
|
}
|
405
|
|
406
|
/**
|
407
|
* Sets a batch operation to pre-calculate product sell prices.
|
408
|
*
|
409
|
* This function prepares and sets a batch to pre-calculate product sell prices
|
410
|
* based on the number of variations for each price and the number of products
|
411
|
* to calculate. It does not call batch_process(), so if you are not calling
|
412
|
* this function from a form submit handler, you must process yourself.
|
413
|
*/
|
414
|
function commerce_product_batch_pre_calculate_sell_prices() {
|
415
|
// Create the batch array.
|
416
|
$batch = array(
|
417
|
'title' => t('Pre-calculating product sell prices'),
|
418
|
'operations' => array(),
|
419
|
'finished' => 'commerce_product_batch_pre_calculate_sell_prices_finished',
|
420
|
);
|
421
|
|
422
|
// Add operations based on the number of rule combinations and number of
|
423
|
// products to be pre-calculated.
|
424
|
$rule_keys = commerce_product_pre_calculation_rule_keys();
|
425
|
|
426
|
// Count the number of products.
|
427
|
$product_count = db_select('commerce_product', 'cp')
|
428
|
->fields('cp', array('product_id'))
|
429
|
->countQuery()
|
430
|
->execute()
|
431
|
->fetchField();
|
432
|
|
433
|
// Create a "step" value based on the number of rule keys, i.e. how many
|
434
|
// products to calculate at a time. This will roughly limit the number of
|
435
|
// queries to 500 on any given batch operations.
|
436
|
if (count($rule_keys) > 500) {
|
437
|
$step = 1;
|
438
|
}
|
439
|
else {
|
440
|
$step = min(round(500 / count($rule_keys)) + 1, $product_count);
|
441
|
}
|
442
|
|
443
|
// Add batch operations to pre-calculate every price.
|
444
|
for ($i = 0; $i < $product_count; $i += $step) {
|
445
|
// Ensure the maximum step doesn't go over the total number of rows for
|
446
|
// accurate reporting later.
|
447
|
if ($i + $step > $product_count) {
|
448
|
$step = $product_count - $i;
|
449
|
}
|
450
|
|
451
|
$batch['operations'][] = array('_commerce_product_batch_pre_calculate_sell_prices', array($i, $step));
|
452
|
}
|
453
|
|
454
|
batch_set($batch);
|
455
|
}
|
456
|
|
457
|
/**
|
458
|
* Calculates pre-calculation using the given range values and updates the batch
|
459
|
* with processing information.
|
460
|
*/
|
461
|
function _commerce_product_batch_pre_calculate_sell_prices($from, $count, &$context) {
|
462
|
// Keep track of the total number of products covered and the total number of
|
463
|
// prices created in the results array.
|
464
|
if (empty($context['results'])) {
|
465
|
$context['results'] = array(0, 0);
|
466
|
}
|
467
|
|
468
|
// Increment the number of products processed.
|
469
|
$context['results'][0] += $count;
|
470
|
|
471
|
// Increment the number of actual prices created.
|
472
|
$context['results'][1] += commerce_product_pre_calculate_sell_prices($from, $count);
|
473
|
}
|
474
|
|
475
|
/**
|
476
|
* Displays a message upon completion of a batched sell price pre-calculation.
|
477
|
*/
|
478
|
function commerce_product_batch_pre_calculate_sell_prices_finished($success, $results, $operations) {
|
479
|
if ($success) {
|
480
|
$message = format_plural($results[0], "Sell prices pre-calculated for 1 product resulting in @total prices created.", "Sell prices pre-calculated for @count products resulting in @total prices created.", array('@total' => $results[1]));
|
481
|
}
|
482
|
else {
|
483
|
$message = t('Batch pre-calculation finished with an error.');
|
484
|
}
|
485
|
|
486
|
drupal_set_message($message);
|
487
|
}
|