Passed
Push — master ( 153942...a6851b )
by Aimeos
08:33
created

Standard::total()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 9
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 5
c 1
b 0
f 0
nc 1
nop 0
dl 0
loc 9
rs 10
1
<?php
2
3
/**
4
 * @license LGPLv3, http://opensource.org/licenses/LGPL-3.0
5
 * @copyright Metaways Infosystems GmbH, 2014
6
 * @copyright Aimeos (aimeos.org), 2015-2022
7
 * @package Controller
8
 * @subpackage Jobs
9
 */
10
11
12
namespace Aimeos\Controller\Jobs\Product\Bought;
13
14
15
/**
16
 * Job controller for bought together products.
17
 *
18
 * @package Controller
19
 * @subpackage Jobs
20
 */
21
class Standard
22
	extends \Aimeos\Controller\Jobs\Base
23
	implements \Aimeos\Controller\Jobs\Iface
24
{
25
	/** controller/jobs/product/bought/name
26
	 * Class name of the used product suggestions scheduler controller implementation
27
	 *
28
	 * Each default job controller can be replace by an alternative imlementation.
29
	 * To use this implementation, you have to set the last part of the class
30
	 * name as configuration value so the controller factory knows which class it
31
	 * has to instantiate.
32
	 *
33
	 * For example, if the name of the default class is
34
	 *
35
	 *  \Aimeos\Controller\Jobs\Product\Bought\Standard
36
	 *
37
	 * and you want to replace it with your own version named
38
	 *
39
	 *  \Aimeos\Controller\Jobs\Product\Bought\Myalgorithm
40
	 *
41
	 * then you have to set the this configuration option:
42
	 *
43
	 *  controller/jobs/product/bought/name = Myalgorithm
44
	 *
45
	 * The value is the last part of your own class name and it's case sensitive,
46
	 * so take care that the configuration value is exactly named like the last
47
	 * part of the class name.
48
	 *
49
	 * The allowed characters of the class name are A-Z, a-z and 0-9. No other
50
	 * characters are possible! You should always start the last part of the class
51
	 * name with an upper case character and continue only with lower case characters
52
	 * or numbers. Avoid chamel case names like "MyOptimizer"!
53
	 *
54
	 * @param string Last part of the class name
55
	 * @since 2014.03
56
	 * @category Developer
57
	 */
58
59
	/** controller/jobs/product/bought/decorators/excludes
60
	 * Excludes decorators added by the "common" option from the product bought job controller
61
	 *
62
	 * Decorators extend the functionality of a class by adding new aspects
63
	 * (e.g. log what is currently done), executing the methods of the underlying
64
	 * class only in certain conditions (e.g. only for logged in users) or
65
	 * modify what is returned to the caller.
66
	 *
67
	 * This option allows you to remove a decorator added via
68
	 * "controller/jobs/common/decorators/default" before they are wrapped
69
	 * around the job controller.
70
	 *
71
	 *  controller/jobs/product/bought/decorators/excludes = array( 'decorator1' )
72
	 *
73
	 * This would remove the decorator named "decorator1" from the list of
74
	 * common decorators ("\Aimeos\Controller\Jobs\Common\Decorator\*") added via
75
	 * "controller/jobs/common/decorators/default" to the job controller.
76
	 *
77
	 * @param array List of decorator names
78
	 * @since 2014.03
79
	 * @category Developer
80
	 * @see controller/jobs/common/decorators/default
81
	 * @see controller/jobs/product/bought/decorators/global
82
	 * @see controller/jobs/product/bought/decorators/local
83
	 */
84
85
	/** controller/jobs/product/bought/decorators/global
86
	 * Adds a list of globally available decorators only to the product bought job controller
87
	 *
88
	 * Decorators extend the functionality of a class by adding new aspects
89
	 * (e.g. log what is currently done), executing the methods of the underlying
90
	 * class only in certain conditions (e.g. only for logged in users) or
91
	 * modify what is returned to the caller.
92
	 *
93
	 * This option allows you to wrap global decorators
94
	 * ("\Aimeos\Controller\Jobs\Common\Decorator\*") around the job controller.
95
	 *
96
	 *  controller/jobs/product/bought/decorators/global = array( 'decorator1' )
97
	 *
98
	 * This would add the decorator named "decorator1" defined by
99
	 * "\Aimeos\Controller\Jobs\Common\Decorator\Decorator1" only to the job controller.
100
	 *
101
	 * @param array List of decorator names
102
	 * @since 2014.03
103
	 * @category Developer
104
	 * @see controller/jobs/common/decorators/default
105
	 * @see controller/jobs/product/bought/decorators/excludes
106
	 * @see controller/jobs/product/bought/decorators/local
107
	 */
108
109
	/** controller/jobs/product/bought/decorators/local
110
	 * Adds a list of local decorators only to the product bought job controller
111
	 *
112
	 * Decorators extend the functionality of a class by adding new aspects
113
	 * (e.g. log what is currently done), executing the methods of the underlying
114
	 * class only in certain conditions (e.g. only for logged in users) or
115
	 * modify what is returned to the caller.
116
	 *
117
	 * This option allows you to wrap local decorators
118
	 * ("\Aimeos\Controller\Jobs\Product\Bought\Decorator\*") around the job
119
	 * controller.
120
	 *
121
	 *  controller/jobs/product/bought/decorators/local = array( 'decorator2' )
122
	 *
123
	 * This would add the decorator named "decorator2" defined by
124
	 * "\Aimeos\Controller\Jobs\Product\Bought\Decorator\Decorator2"
125
	 * only to the job controller.
126
	 *
127
	 * @param array List of decorator names
128
	 * @since 2014.03
129
	 * @category Developer
130
	 * @see controller/jobs/common/decorators/default
131
	 * @see controller/jobs/product/bought/decorators/excludes
132
	 * @see controller/jobs/product/bought/decorators/global
133
	 */
134
135
136
	/**
137
	 * Returns the localized name of the job.
138
	 *
139
	 * @return string Name of the job
140
	 */
141
	public function getName() : string
142
	{
143
		return $this->context()->translate( 'controller/jobs', 'Products bought together' );
144
	}
145
146
147
	/**
148
	 * Returns the localized description of the job.
149
	 *
150
	 * @return string Description of the job
151
	 */
152
	public function getDescription() : string
153
	{
154
		return $this->context()->translate( 'controller/jobs', 'Creates bought together product suggestions' );
155
	}
156
157
158
	/**
159
	 * Executes the job.
160
	 *
161
	 * @throws \Aimeos\Controller\Jobs\Exception If an error occurs
162
	 */
163
	public function run()
164
	{
165
		if( empty( $total = $this->total() ) ) {
166
			return;
167
		}
168
169
		$manager = \Aimeos\MShop::create( $this->context(), 'product' );
170
		$domains = $this->domains();
171
		$size = $this->size();
172
173
		$counts = $this->counts();
174
		$ids = $counts->keys();
175
		$start = 0;
176
177
		while( !( $prodIds = $ids->slice( $start, $size ) )->isEmpty() )
178
		{
179
			$filter = $manager->filter()->add( 'product.id', '==', $prodIds )->slice( 0, 0x7fffffff );
180
			$products = $manager->search( $filter, $domains );
181
182
			foreach( $counts as $id => $count )
183
			{
184
				if( $item = $products->get( $id ) ) {
185
					$this->update( $item, $prodIds, $total, $count );
186
				}
187
			}
188
189
			$manager->save( $products );
190
			$start += $size;
191
		}
192
	}
193
194
195
	/**
196
	 * Returns the minimum confidence value for high quality suggestions
197
	 *
198
	 * @return float Minimum confidence value from 0 to 1
199
	 */
200
	protected function confidence() : float
201
	{
202
		/** controller/jobs/product/bought/min-confidence
203
		 * Minimum confidence value for high quality suggestions
204
		 *
205
		 * The confidence value is used to remove low quality suggestions. Using
206
		 * a confidence value of 0.95 would only suggest product combinations
207
		 * that are almost always bought together. Contrary, a value of 0.1 would
208
		 * yield a lot of combinations that are bought together only in very rare
209
		 * cases.
210
		 *
211
		 * To get good product suggestions, the value should be at least above
212
		 * 0.5 and the higher the value, the better the suggestions. You can
213
		 * either increase the default value to get better suggestions or lower
214
		 * the value to get more suggestions per product if you have only a few
215
		 * ones in total.
216
		 *
217
		 * @param float Minimum confidence value from 0 to 1
218
		 * @since 2014.09
219
		 * @category Developer
220
		 * @category User
221
		 * @see controller/jobs/product/bought/max-items
222
		 * @see controller/jobs/product/bought/min-support
223
		 * @see controller/jobs/product/bought/limit-days
224
		 * @see controller/jobs/product/bought/size
225
		 */
226
		return $this->context()->config()->get( 'controller/jobs/product/bought/min-confidence', 0.66 );
227
	}
228
229
230
	/**
231
	 * Returns how often the product has been bought
232
	 *
233
	 * @return \Aimeos\Map Map of product IDs as keys and count as values
234
	 */
235
	protected function counts() : \Aimeos\Map
236
	{
237
		$manager = \Aimeos\MShop::create( $this->context(), 'order/base/product' );
238
		$filter = $manager->filter()->add( 'order.base.product.ctime', '>', $this->ctime() )->slice( 0, 0x7fffffff );
239
240
		return $manager->aggregate( $filter, 'order.base.product.productid' );
241
	}
242
243
244
	/**
245
	 * Returns the date of the oldest ordered product to use
246
	 *
247
	 * @return string Date of the oldest ordered product to use
248
	 */
249
	protected function ctime() : string
250
	{
251
		/** controller/jobs/product/bought/limit-days
252
		 * Only use orders placed in the past within the configured number of days for calculating bought together products
253
		 *
254
		 * This option limits the orders that are evaluated for calculating the
255
		 * bought together products. Only ordered products that were bought by
256
		 * customers within the configured number of days are used.
257
		 *
258
		 * Limiting the orders taken into account to the last ones increases the
259
		 * quality of suggestions if customer interests shifts to new products.
260
		 * If you only have a few orders per month, you can also increase this
261
		 * value to several years to get enough suggestions. Please keep in mind
262
		 * that the more orders are evaluated, the longer the it takes to
263
		 * calculate the product combinations.
264
		 *
265
		 * @param integer Number of days
266
		 * @since 2014.09
267
		 * @category User
268
		 * @category Developer
269
		 * @see controller/jobs/product/bought/max-items
270
		 * @see controller/jobs/product/bought/min-support
271
		 * @see controller/jobs/product/bought/min-confidence
272
		 * @see controller/jobs/product/bought/size
273
		 */
274
		$days = $this->context()->config()->get( 'controller/jobs/product/bought/limit-days', 360 );
275
		return date( 'Y-m-d H:i:s', time() - $days * 86400 );
276
	}
277
278
279
	/**
280
	 * Returns the domain names to fetch for each product
281
	 *
282
	 * @return array Domain names to fetch for each product
283
	 */
284
	protected function domains() : array
285
	{
286
		return $this->context()->config()->get( 'mshop/product/manager/domains', [] ) + ['product'];
287
	}
288
289
290
	/**
291
	 * Returns the maximum number of suggested items per product
292
	 *
293
	 * @return int Number of suggested products
294
	 */
295
	protected function max() : int
296
	{
297
		/** controller/jobs/product/bought/max-items
298
		 * Maximum number of suggested items per product
299
		 *
300
		 * Each product can contain zero or more suggested products based on
301
		 * the used algorithm. The maximum number of items limits the quantity
302
		 * of products that are associated as suggestions to one product.
303
		 * Usually, you don't need more products than shown in the product
304
		 * detail view as suggested products.
305
		 *
306
		 * @param integer Number of suggested products
307
		 * @since 2014.09
308
		 * @category Developer
309
		 * @category User
310
		 * @see controller/jobs/product/bought/min-support
311
		 * @see controller/jobs/product/bought/min-confidence
312
		 * @see controller/jobs/product/bought/limit-days
313
		 * @see controller/jobs/product/bought/size
314
		 */
315
		return $this->context()->config()->get( 'controller/jobs/product/bought/max-items', 5 );
316
	}
317
318
319
	/**
320
	 * Returns the relative counts for the given product IDs
321
	 *
322
	 * @param string $id Product ID to calculate the suggestions for
323
	 * @param iterable $prodIds List of product IDs to create suggestions for
324
	 * @return \Aimeos\Map Map with the product IDs as keys and the relative counts as values
325
	 */
326
	protected function relative( string $id, iterable $prodIds ) : \Aimeos\Map
327
	{
328
		$manager = \Aimeos\MShop::create( $this->context(), 'order/base/product' );
329
330
		$search = $manager->filter();
331
		$search->add( $search->and( [
332
			$search->is( 'order.base.product.productid', '==', $prodIds ),
333
			$search->is( 'order.base.product.ctime', '>', $this->ctime() ),
334
			$search->is( $search->make( 'order.base.product.count', [$id] ), '==', 1 ),
335
		] ) );
336
337
		return $manager->aggregate( $search, 'order.base.product.productid' )->remove( $id );
338
	}
339
340
341
	/**
342
	 * Returns the number of items processed at once
343
	 *
344
	 * @return int Number of items processed at once
345
	 */
346
	protected function size() : int
347
	{
348
		/** controller/jobs/product/bought/size
349
		 * Number of items processed at once
350
		 *
351
		 * The items which are bought together are processed in batches to reduce
352
		 * the time needed for associating all items. Higher numbers can improve
353
		 * the speed while requiring more memory.
354
		 *
355
		 * @param integer Number of items processed at once
356
		 * @since 2023.01
357
		 * @see controller/jobs/product/bought/max-items
358
		 * @see controller/jobs/product/bought/min-support
359
		 * @see controller/jobs/product/bought/min-confidence
360
		 * @see controller/jobs/product/bought/limit-days
361
		 */
362
		return $this->context()->config()->get( 'controller/jobs/product/bought/size', 100 );
363
	}
364
365
366
	/**
367
	 * Returns the IDs of the suggested products.
368
	 *
369
	 * @param string $id Product ID to calculate the suggestions for
370
	 * @param iterable $prodIds List of product IDs to create suggestions for
371
	 * @param int $count Number of ordered products
372
	 * @param int $total Total number of orders
373
	 * @return \Aimeos\Map List of suggested product IDs
374
	 */
375
	protected function suggest( string $id, iterable $prodIds, int $count, int $total ) : \Aimeos\Map
376
	{
377
		$products = [];
378
		$supportA = $count / $total;
379
380
		$minSupport = $this->support();
381
		$minConfidence = $this->confidence();
382
383
		foreach( $this->relative( $id, $prodIds ) as $prodId => $relCnt )
384
		{
385
			$supportAB = $relCnt / $total;
386
387
			if( $supportAB > $minSupport && ( $conf = ( $supportAB / $supportA ) ) > $minConfidence ) {
388
				$products[$prodId] = $conf;
389
			}
390
		}
391
392
		return map( $products )->arsort()->keys();
393
	}
394
395
396
	/**
397
	 * Returns the minimum support value to sort out irrelevant combinations
398
	 *
399
	 * @return float Minimum support value from 0 to 1
400
	 */
401
	protected function support() : float
402
	{
403
		/** controller/jobs/product/bought/min-support
404
		 * Minimum support value to sort out all irrelevant combinations
405
		 *
406
		 * A minimum support value of 0.02 requires the combination of two
407
		 * products to be in at least 2% of all orders to be considered relevant
408
		 * enough as product suggestion.
409
		 *
410
		 * You can tune this value for your needs, e.g. if you sell several
411
		 * thousands different products and you have only a few suggestions for
412
		 * all products, a lower value might work better for you. The other way
413
		 * round, if you sell less than thousand different products, you may
414
		 * have a lot of product suggestions of low quality. In this case it's
415
		 * better to increase this value, e.g. to 0.05 or higher.
416
		 *
417
		 * Caution: Decreasing the support to lower values than 0.01 exponentially
418
		 * increases the time for generating the suggestions. If your database
419
		 * contains a lot of orders, the time to complete the job may rise from
420
		 * hours to days!
421
		 *
422
		 * @param float Minimum support value from 0 to 1
423
		 * @since 2014.09
424
		 * @category Developer
425
		 * @category User
426
		 * @see controller/jobs/product/bought/max-items
427
		 * @see controller/jobs/product/bought/min-confidence
428
		 * @see controller/jobs/product/bought/limit-days
429
		 * @see controller/jobs/product/bought/size
430
		 */
431
		return $this->context()->config()->get( 'controller/jobs/product/bought/min-support', 0.02 );
432
	}
433
434
435
	/**
436
	 * Returns the total number of orders available
437
	 *
438
	 * @return int Total number of orders
439
	 */
440
	protected function total() : int
441
	{
442
		$total = 0;
443
444
		$manager = \Aimeos\MShop::create( $this->context(), 'order/base' );
445
		$filter = $manager->filter()->add( 'order.base.ctime', '>', $this->ctime() )->slice( 0, 0 );
446
		$manager->search( $filter, [], $total )->all();
447
448
		return $total;
449
	}
450
451
452
	/**
453
	 * Updates the products bought together for the given item
454
	 *
455
	 * @param \Aimeos\MShop\Product\Item\Iface $item Product item to update
456
	 * @param iterable $prodIds List of product IDs to create suggestions for
457
	 * @param int $count Number of ordered products
458
	 * @param int $total Total number of orders
459
	 * return \Aimeos\MShop\Product\Item\Iface Updated product item
460
	 */
461
	protected function update( \Aimeos\MShop\Product\Item\Iface $item, iterable $prodIds, int $count, int $total ) : \Aimeos\MShop\Product\Item\Iface
462
	{
463
		$listItems = $item->getListItems( 'product', 'bought-together' );
464
465
		if( $count / $total > $this->support() )
466
		{
467
			$productIds = $this->suggest( $item->getId(), $prodIds, $count, $total )->slice( 0, $this->max() );
0 ignored issues
show
Bug introduced by
It seems like $item->getId() can also be of type null; however, parameter $id of Aimeos\Controller\Jobs\P...ght\Standard::suggest() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

467
			$productIds = $this->suggest( /** @scrutinizer ignore-type */ $item->getId(), $prodIds, $count, $total )->slice( 0, $this->max() );
Loading history...
468
469
			foreach( $productIds as $pid )
470
			{
471
				$litem = $item->getListItem( 'product', 'bought-together', $pid, false ) ?: $manager->createListItem();
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $manager seems to be never defined.
Loading history...
472
				$item->addListItem( 'product', $litem->setType( 'bought-together' )->setRefId( $pid ) );
473
				$listItems->remove( $litem->getId() );
0 ignored issues
show
Bug introduced by
It seems like $litem->getId() can also be of type string; however, parameter $keys of Aimeos\Map::remove() does only seem to accept iterable, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

473
				$listItems->remove( /** @scrutinizer ignore-type */ $litem->getId() );
Loading history...
474
			}
475
		}
476
477
		return $item->deleteListItems( $listItems );
478
	}
479
}
480