Standard::getDescription()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

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

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

452
						$litem = $item->getListItem( 'product', 'bought-together', $pid, false ) ?: $manager->/** @scrutinizer ignore-call */ createListItem();

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
453
						$item->addListItem( 'product', $litem->setType( 'bought-together' )->setRefId( $pid ) );
454
						$listItems->remove( $litem->getId() );
455
					}
456
				}
457
458
				$item->deleteListItems( $listItems );
459
			}
460
		}
461
462
		$manager->save( $products );
463
	}
464
}
465