Passed
Push — master ( 2aa5de...efbb49 )
by Aimeos
10:32 queued 02:36
created

Base::copyAddresses()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 24
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 11
dl 0
loc 24
rs 9.9
c 0
b 0
f 0
cc 4
nc 4
nop 3
1
<?php
2
3
/**
4
 * @license LGPLv3, http://opensource.org/licenses/LGPL-3.0
5
 * @copyright Metaways Infosystems GmbH, 2012
6
 * @copyright Aimeos (aimeos.org), 2015-2022
7
 * @package Controller
8
 * @subpackage Frontend
9
 */
10
11
12
namespace Aimeos\Controller\Frontend\Basket;
13
14
15
/**
16
 * Base class for the basket frontend controller
17
 *
18
 * @package Controller
19
 * @subpackage Frontend
20
 */
21
abstract class Base extends \Aimeos\Controller\Frontend\Base implements Iface
22
{
23
	/**
24
	 * Calculates and returns the current price for the given order product and product prices.
25
	 *
26
	 * @param \Aimeos\MShop\Order\Item\Product\Iface $orderProduct Ordered product item
27
	 * @param \Aimeos\Map $prices List of price items implementing \Aimeos\MShop\Price\Item\Iface
28
	 * @param float $quantity New product quantity
29
	 * @return \Aimeos\MShop\Price\Item\Iface Price item with calculated price
30
	 */
31
	protected function calcPrice( \Aimeos\MShop\Order\Item\Product\Iface $orderProduct,
32
		\Aimeos\Map $prices, float $quantity ) : \Aimeos\MShop\Price\Item\Iface
33
	{
34
		$context = $this->context();
35
36
		$priceManager = \Aimeos\MShop::create( $context, 'price' );
37
		$price = $priceManager->getLowestPrice( $prices, $quantity );
38
39
		// customers can pay what they would like to pay
40
		if( ( $attr = $orderProduct->getAttributeItem( 'price', 'custom' ) ) !== null )
41
		{
42
			$amount = $attr->getValue();
43
44
			if( preg_match( '/^[0-9]*(\.[0-9]+)?$/', $amount ) !== 1 || ( (double) $amount ) < 0.01 )
0 ignored issues
show
Bug introduced by
It seems like $amount can also be of type array; however, parameter $subject of preg_match() 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

44
			if( preg_match( '/^[0-9]*(\.[0-9]+)?$/', /** @scrutinizer ignore-type */ $amount ) !== 1 || ( (double) $amount ) < 0.01 )
Loading history...
45
			{
46
				$msg = $context->translate( 'controller/frontend', 'Invalid price value "%1$s"' );
47
				throw new \Aimeos\Controller\Frontend\Basket\Exception( sprintf( $msg, $amount ) );
0 ignored issues
show
Bug introduced by
It seems like $amount can also be of type array; however, parameter $values of sprintf() does only seem to accept double|integer|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

47
				throw new \Aimeos\Controller\Frontend\Basket\Exception( sprintf( $msg, /** @scrutinizer ignore-type */ $amount ) );
Loading history...
48
			}
49
50
			$price = $price->setValue( $amount );
51
		}
52
53
		$orderAttributes = $orderProduct->getAttributeItems();
54
		$attrItems = $this->getAttributeItems( $orderAttributes );
55
56
		$siteId = $this->context()->locale()->getSiteId();
57
58
		// add prices of (optional) attributes
59
		foreach( $orderAttributes as $orderAttrItem )
60
		{
61
			if( !( $attrItem = $attrItems->get( $orderAttrItem->getAttributeId() ) ) ) {
62
				continue;
63
			}
64
65
			// use attribute prices from product site only
66
			$prices = $attrItem->getRefItems( 'price', 'default', 'default' )->filter( function( $price ) use ( $orderProduct ) {
67
				return $price->getSiteId() === $orderProduct->getSiteId();
68
			} );
69
70
			if( $prices->isEmpty() )
71
			{
72
				// if no prices left, use attribute prices from current site
73
				$prices = $attrItem->getRefItems( 'price', 'default', 'default' )->filter( function( $price ) use ( $siteId ) {
74
					return $price->getSiteId() === $siteId;
75
				} );
76
			}
77
78
			if( !$prices->isEmpty() )
79
			{
80
				$attrPrice = $priceManager->getLowestPrice( $prices, $orderAttrItem->getQuantity() );
81
				$price = $price->addItem( clone $attrPrice, $orderAttrItem->getQuantity() );
82
				$orderAttrItem->setPrice( $attrPrice->addItem( $attrPrice, $orderAttrItem->getQuantity() - 1 )->getValue() );
83
			}
84
		}
85
86
		// remove product rebate of original price in favor to rebates granted for the order
87
		return $price->setRebate( '0.00' );
88
	}
89
90
91
	/**
92
	 * Returns the allowed quantity for the given product
93
	 *
94
	 * @param \Aimeos\MShop\Product\Item\Iface $product Product item including referenced items
95
	 * @param float $quantity New product quantity
96
	 * @return float Updated quantity value
97
	 */
98
	protected function checkQuantity( \Aimeos\MShop\Product\Item\Iface $product, float $quantity ) : float
99
	{
100
		$scale = $product->getScale();
101
102
		if( fmod( $quantity, $scale ) >= 0.0005 ) {
103
			return round( ceil( $quantity / $scale ) * $scale, 4 );
104
		}
105
106
		return $quantity;
107
	}
108
109
110
	/**
111
	 * Checks if the attribute IDs are really associated to the product
112
	 *
113
	 * @param \Aimeos\MShop\Product\Item\Iface $product Product item with referenced items
114
	 * @param string $domain Domain the references must be of
115
	 * @param array $refMap Associative list of list type codes as keys and lists of reference IDs as values
116
	 * @throws \Aimeos\Controller\Frontend\Basket\Exception If one or more of the IDs are not associated
117
	 */
118
	protected function checkAttributes( array $products, string $listType, array $refIds )
119
	{
120
		$attrIds = map();
121
122
		foreach( $products as $product ) {
123
			$attrIds->merge( $product->getRefItems( 'attribute', null, $listType )->keys() );
124
		}
125
126
		if( $attrIds->intersect( $refIds )->count() !== count( $refIds ) )
127
		{
128
			$i18n = $this->context()->i18n();
129
			$prodIds = map( $products )->getId()->join( ', ' );
130
			$msg = $i18n->dt( 'controller/frontend', 'Invalid "%1$s" references for product with ID %2$s' );
131
			throw new \Aimeos\Controller\Frontend\Basket\Exception( sprintf( $msg, 'attribute', $prodIds ) );
132
		}
133
	}
134
135
136
	/**
137
	 * Checks for a locale mismatch and migrates the products to the new basket if necessary.
138
	 *
139
	 * @param \Aimeos\MShop\Locale\Item\Iface $locale Locale object from current basket
140
	 * @param string $type Basket type
141
	 */
142
	protected function checkLocale( \Aimeos\MShop\Locale\Item\Iface $locale, string $type )
143
	{
144
		$errors = [];
145
		$context = $this->context();
146
		$session = $context->session();
147
148
		$localeStr = $session->get( 'aimeos/basket/locale' );
149
		$localeKey = $locale->getSiteItem()->getCode() . '|' . $locale->getLanguageId() . '|' . $locale->getCurrencyId();
150
151
		if( $localeStr !== null && $localeStr !== $localeKey )
152
		{
153
			$locParts = explode( '|', $localeStr );
154
			$locSite = ( isset( $locParts[0] ) ? $locParts[0] : '' );
155
			$locLanguage = ( isset( $locParts[1] ) ? $locParts[1] : '' );
156
			$locCurrency = ( isset( $locParts[2] ) ? $locParts[2] : '' );
157
158
			$localeManager = \Aimeos\MShop::create( $context, 'locale' );
159
			$locale = $localeManager->bootstrap( $locSite, $locLanguage, $locCurrency, false );
160
161
			$context = clone $context;
162
			$context->setLocale( $locale );
163
164
			$manager = \Aimeos\MShop::create( $context, 'order' );
165
			$basket = $manager->getSession( $type )->off();
166
167
			$this->copyAddresses( $basket, $errors, $localeKey );
168
			$this->copyServices( $basket, $errors );
169
			$this->copyProducts( $basket, $errors, $localeKey );
170
			$this->copyCoupons( $basket, $errors, $localeKey );
171
172
			$this->object()->get()->setCustomerId( $basket->getCustomerId() )
173
				->setCustomerReference( $basket->getCustomerReference() )
174
				->setComment( $basket->getComment() )
175
				->setLocale( $locale );
176
177
			$manager->setSession( $basket, $type );
178
		}
179
180
		$session->set( 'aimeos/basket/locale', $localeKey );
181
	}
182
183
184
	/**
185
	 * Migrates the addresses from the old basket to the current one.
186
	 *
187
	 * @param \Aimeos\MShop\Order\Item\Iface $basket Basket object
188
	 * @param array $errors Associative list of previous errors
189
	 * @param string $localeKey Unique identifier of the site, language and currency
190
	 * @return array Associative list of errors occured
191
	 */
192
	protected function copyAddresses( \Aimeos\MShop\Order\Item\Iface $basket, array $errors, string $localeKey ) : array
193
	{
194
		foreach( $basket->getAddresses() as $type => $items )
195
		{
196
			foreach( $items as $pos => $item )
197
			{
198
				try
199
				{
200
					$this->object()->get()->addAddress( $item, $type, $pos );
201
				}
202
				catch( \Exception $e )
203
				{
204
					$logger = $this->context()->logger();
205
					$errors['address'][$type] = $e->getMessage();
206
207
					$str = 'Error migrating address with type "%1$s" in basket to locale "%2$s": %3$s';
208
					$logger->info( sprintf( $str, $type, $localeKey, $e->getMessage() ), 'controller/frontend' );
209
				}
210
			}
211
212
			$basket->deleteAddress( $type );
213
		}
214
215
		return $errors;
216
	}
217
218
219
	/**
220
	 * Migrates the coupons from the old basket to the current one.
221
	 *
222
	 * @param \Aimeos\MShop\Order\Item\Iface $basket Basket object
223
	 * @param array $errors Associative list of previous errors
224
	 * @param string $localeKey Unique identifier of the site, language and currency
225
	 * @return array Associative list of errors occured
226
	 */
227
	protected function copyCoupons( \Aimeos\MShop\Order\Item\Iface $basket, array $errors, string $localeKey ) : array
228
	{
229
		foreach( $basket->getCoupons() as $code => $list )
230
		{
231
			try
232
			{
233
				$this->object()->addCoupon( $code );
234
				$basket->deleteCoupon( $code );
235
			}
236
			catch( \Exception $e )
237
			{
238
				$logger = $this->context()->logger();
239
				$errors['coupon'][$code] = $e->getMessage();
240
241
				$str = 'Error migrating coupon with code "%1$s" in basket to locale "%2$s": %3$s';
242
				$logger->info( sprintf( $str, $code, $localeKey, $e->getMessage() ), 'controller/frontend' );
243
			}
244
		}
245
246
		return $errors;
247
	}
248
249
250
	/**
251
	 * Migrates the products from the old basket to the current one.
252
	 *
253
	 * @param \Aimeos\MShop\Order\Item\Iface $basket Basket object
254
	 * @param array $errors Associative list of previous errors
255
	 * @param string $localeKey Unique identifier of the site, language and currency
256
	 * @return array Associative list of errors occured
257
	 */
258
	protected function copyProducts( \Aimeos\MShop\Order\Item\Iface $basket, array $errors, string $localeKey ) : array
259
	{
260
		$context = $this->context();
261
		$manager = \Aimeos\MShop::create( $context, 'product' );
262
		$ruleManager = \Aimeos\MShop::create( $context, 'rule' );
263
		$domains = ['attribute', 'media', 'price', 'product', 'text'];
264
265
		foreach( $basket->getProducts() as $pos => $product )
266
		{
267
			if( $product->getFlags() & \Aimeos\MShop\Order\Item\Product\Base::FLAG_IMMUTABLE ) {
268
				continue;
269
			}
270
271
			try
272
			{
273
				$variantIds = $configIds = $customIds = [];
274
275
				foreach( $product->getAttributeItems() as $attrItem )
276
				{
277
					switch( $attrItem->getType() )
278
					{
279
						case 'variant': $variantIds[] = $attrItem->getAttributeId(); break;
280
						case 'config': $configIds[$attrItem->getAttributeId()] = $attrItem->getQuantity(); break;
281
						case 'custom': $customIds[$attrItem->getAttributeId()] = $attrItem->getValue(); break;
282
					}
283
				}
284
285
				$item = $manager->get( $product->getProductId(), $domains );
286
				$item = $ruleManager->apply( $item, 'catalog' );
287
				$qty = $product->getQuantity();
288
289
				$this->object()->addProduct( $item, $qty, $variantIds, $configIds, $customIds, $product->getStockType() );
290
291
				$basket->deleteProduct( $pos );
292
			}
293
			catch( \Exception $e )
294
			{
295
				$code = $product->getProductCode();
296
				$logger = $this->context()->logger();
297
				$errors['product'][$pos] = $e->getMessage();
298
299
				$str = 'Error migrating product with code "%1$s" in basket to locale "%2$s": %3$s';
300
				$logger->info( sprintf( $str, $code, $localeKey, $e->getMessage() ), 'controller/frontend' );
301
			}
302
		}
303
304
		return $errors;
305
	}
306
307
308
	/**
309
	 * Migrates the services from the old basket to the current one.
310
	 *
311
	 * @param \Aimeos\MShop\Order\Item\Iface $basket Basket object
312
	 * @param array $errors Associative list of previous errors
313
	 * @return array Associative list of errors occured
314
	 */
315
	protected function copyServices( \Aimeos\MShop\Order\Item\Iface $basket, array $errors ) : array
316
	{
317
		$newBasket = $this->object();
318
		$manager = \Aimeos\MShop::create( $this->context(), 'service' );
319
320
		foreach( $basket->getServices() as $type => $list )
321
		{
322
			foreach( $list as $item )
323
			{
324
				try
325
				{
326
					foreach( $newBasket->get()->getService( $type ) as $pos => $ordService )
327
					{
328
						if( $item->getCode() === $ordService->getCode() ) {
329
							$newBasket->get()->deleteService( $type, $pos );
330
						}
331
					}
332
333
					$attributes = [];
334
335
					foreach( $item->getAttributeItems() as $attrItem ) {
336
						$attributes[$attrItem->getCode()] = $attrItem->getValue();
337
					}
338
339
					$service = $manager->get( $item->getServiceId(), ['media', 'price', 'text'] );
340
					$newBasket->addService( $service, $attributes );
341
					$basket->deleteService( $type );
342
				}
343
				catch( \Exception $e ) { ; } // Don't notify the user as appropriate services can be added automatically
344
			}
345
		}
346
347
		return $errors;
348
	}
349
350
351
	/**
352
	 * Creates the subscription entries for the ordered products with interval attributes
353
	 *
354
	 * @param \Aimeos\MShop\Order\Item\Iface $order Basket object
355
	 */
356
	protected function createSubscriptions( \Aimeos\MShop\Order\Item\Iface $order )
357
	{
358
		$types = ['config', 'custom', 'hidden', 'variant'];
359
		$manager = \Aimeos\MShop::create( $this->context(), 'subscription' );
360
361
		foreach( $order->getProducts() as $orderProduct )
362
		{
363
			if( ( $interval = $orderProduct->getAttribute( 'interval', $types ) ) !== null )
364
			{
365
				$interval = is_array( $interval ) ? reset( $interval ) : $interval;
366
367
				$item = $manager->create()->setInterval( $interval )
368
					->setProductId( $orderProduct->getProductId() )
369
					->setOrderProductId( $orderProduct->getId() )
370
					->setOrderId( $order->getId() );
371
372
				if( ( $end = $orderProduct->getAttribute( 'intervalend', $types ) ) !== null ) {
373
					$item = $item->setDateEnd( $end );
374
				}
375
376
				$manager->save( $item, false );
377
			}
378
		}
379
	}
380
381
382
	/**
383
	 * Returns the attribute items for the given attribute IDs.
384
	 *
385
	 * @param array $attributeIds List of attribute IDs
386
	 * @param string[] $domains Names of the domain items that should be fetched too
387
	 * @return \Aimeos\Map List of items implementing \Aimeos\MShop\Attribute\Item\Iface
388
	 * @throws \Aimeos\Controller\Frontend\Basket\Exception If the actual attribute number doesn't match the expected one
389
	 */
390
	protected function getAttributes( array $attributeIds, array $domains = ['text'] ) : \Aimeos\Map
391
	{
392
		if( empty( $attributeIds ) ) {
393
			return map();
394
		}
395
396
		$attributeManager = \Aimeos\MShop::create( $this->context(), 'attribute' );
397
398
		$search = $attributeManager->filter( true )
399
			->add( ['attribute.id' => $attributeIds] )
400
			->slice( 0, count( $attributeIds ) );
401
402
		$attrItems = $attributeManager->search( $search, $domains );
403
404
		if( $attrItems->count() !== count( $attributeIds ) )
405
		{
406
			$i18n = $this->context()->i18n();
407
			$expected = implode( ',', $attributeIds );
408
			$actual = $attrItems->keys()->join( ',' );
409
			$msg = $i18n->dt( 'controller/frontend', 'Available attribute IDs "%1$s" do not match the given attribute IDs "%2$s"' );
410
411
			throw new \Aimeos\Controller\Frontend\Basket\Exception( sprintf( $msg, $actual, $expected ) );
412
		}
413
414
		return $attrItems;
415
	}
416
417
418
	/**
419
	 * Returns the attribute items using the given order attribute items.
420
	 *
421
	 * @param \Aimeos\Map $orderAttributes List of items implementing \Aimeos\MShop\Order\Item\Product\Attribute\Iface
422
	 * @return \Aimeos\Map List of attribute IDs as key and attribute items implementing \Aimeos\MShop\Attribute\Item\Iface
423
	 */
424
	protected function getAttributeItems( \Aimeos\Map $orderAttributes ) : \Aimeos\Map
425
	{
426
		if( $orderAttributes->isEmpty() ) {
427
			return map();
428
		}
429
430
		$attributeManager = \Aimeos\MShop::create( $this->context(), 'attribute' );
431
		$search = $attributeManager->filter( true );
432
		$expr = [];
433
434
		foreach( $orderAttributes as $item )
435
		{
436
			if( is_scalar( $item->getValue() ) )
437
			{
438
				$tmp = array(
439
					$search->compare( '==', 'attribute.domain', 'product' ),
440
					$search->compare( '==', 'attribute.code', $item->getValue() ),
441
					$search->compare( '==', 'attribute.type', $item->getCode() ),
442
					$search->compare( '>', 'attribute.status', 0 ),
443
					$search->getConditions(),
444
				);
445
				$expr[] = $search->and( $tmp );
446
			}
447
		}
448
449
		$search->setConditions( $search->or( $expr ) );
450
		return $attributeManager->search( $search, array( 'price' ) );
451
	}
452
453
454
	/**
455
	 * Returns the order product attribute items for the given IDs and values
456
	 *
457
	 * @param string $type Attribute type code
458
	 * @param array $ids List of attributes IDs of the given type
459
	 * @param array $values Associative list of attribute IDs as keys and their codes as values
460
	 * @param array $quantities Associative list of attribute IDs as keys and their quantities as values
461
	 * @return array List of items implementing \Aimeos\MShop\Order\Item\Product\Attribute\Iface
462
	 */
463
	protected function getOrderProductAttributes( string $type, array $ids, array $values = [], array $quantities = [] ) : array
464
	{
465
		if( empty( $ids ) ) {
466
			return [];
467
		}
468
469
		$list = [];
470
		$context = $this->context();
471
472
		$priceManager = \Aimeos\MShop::create( $context, 'price' );
473
		$manager = \Aimeos\MShop::create( $context, 'order/product/attribute' );
474
475
		foreach( $this->getAttributes( $ids, ['price', 'text'] ) as $id => $attrItem )
476
		{
477
			$qty = $quantities[$id] ?? 1;
478
			$item = $manager->create()->copyFrom( $attrItem )->setType( $type )
479
				->setValue( $values[$id] ?? $attrItem->getCode() )
480
				->setQuantity( $qty );
481
482
			if( !( $prices = $attrItem->getRefItems( 'price', 'default', 'default' ) )->isEmpty() )
483
			{
484
				$attrPrice = $priceManager->getLowestPrice( $prices, $qty );
485
				$item->setPrice( $attrPrice->addItem( $attrPrice, $qty - 1 )->getValue() );
486
			}
487
488
			$list[] = $item;
489
		}
490
491
		return $list;
492
	}
493
494
495
	/**
496
	 * Returns the site ID of the ordered product
497
	 *
498
	 * @param \Aimeos\MShop\Product\Item\Iface $product Product item
499
	 * @param string|null $siteId Unique ID of the site the product should be bought from or NULL for site the product is from
500
	 * @return string Site ID for the ordered product
501
	 */
502
	protected function getSiteId( \Aimeos\MShop\Product\Item\Iface $product, string $siteId = null ) : string
503
	{
504
		if( $siteId ) {
505
			return $siteId;
506
		}
507
508
		// if product is inherited, use site ID of current site
509
		$siteIds = $this->context()->locale()->getSitePath();
510
511
		return  in_array( $product->getSiteId(), $siteIds ) ? end( $siteIds ) : $product->getSiteId();
512
	}
513
}
514