vendor/isotope/isotope-core/system/modules/isotope/library/Isotope/Model/ProductCollection.php line 131

Open in your IDE?
  1. <?php
  2. /*
  3.  * Isotope eCommerce for Contao Open Source CMS
  4.  *
  5.  * Copyright (C) 2009 - 2019 terminal42 gmbh & Isotope eCommerce Workgroup
  6.  *
  7.  * @link       https://isotopeecommerce.org
  8.  * @license    https://opensource.org/licenses/lgpl-3.0.html
  9.  */
  10. namespace Isotope\Model;
  11. use Contao\Controller;
  12. use Contao\Database;
  13. use Contao\MemberModel;
  14. use Contao\StringUtil;
  15. use Contao\System;
  16. use Contao\Template;
  17. use Haste\Generator\RowClass;
  18. use Haste\Units\Mass\Scale;
  19. use Haste\Units\Mass\Weighable;
  20. use Haste\Units\Mass\WeightAggregate;
  21. use Haste\Util\Format;
  22. use Isotope\Frontend;
  23. use Isotope\Interfaces\IsotopeAttribute;
  24. use Isotope\Interfaces\IsotopeOrderableCollection;
  25. use Isotope\Interfaces\IsotopePayment;
  26. use Isotope\Interfaces\IsotopeProduct;
  27. use Isotope\Interfaces\IsotopeProductCollection;
  28. use Isotope\Interfaces\IsotopeShipping;
  29. use Isotope\Isotope;
  30. use Isotope\Message;
  31. use Isotope\Model\Gallery\Standard as StandardGallery;
  32. use Isotope\Model\ProductCollectionSurcharge\Tax;
  33. use Model\Registry;
  34. /**
  35.  * Class ProductCollection
  36.  *
  37.  * @property int    $id
  38.  * @property int    $tstamp
  39.  * @property string $type
  40.  * @property int    $member
  41.  * @property int    $store_id
  42.  * @property int    $locked
  43.  * @property mixed  $settings
  44.  * @property int    $source_collection_id
  45.  * @property string $uniqid
  46.  * @property int    $config_id
  47.  * @property int    $payment_id
  48.  * @property int    $shipping_id
  49.  * @property int    $billing_address_id
  50.  * @property int    $shipping_address_id
  51.  * @property float  $subtotal
  52.  * @property float  $tax_free_subtotal
  53.  * @property float  $total
  54.  * @property float  $tax_free_total
  55.  * @property string $currency
  56.  * @property string $language
  57.  *
  58.  * @property int|array $nc_notification
  59.  * @property bool      $iso_addToAddressbook
  60.  * @property array     $iso_checkout_skippable
  61.  * @property array     $email_data
  62.  * @property int       $orderdetails_page
  63.  */
  64. abstract class ProductCollection extends TypeAgent implements IsotopeProductCollection
  65. {
  66.     /**
  67.      * Name of the current table
  68.      * @var string
  69.      */
  70.     protected static $strTable 'tl_iso_product_collection';
  71.     /**
  72.      * Interface to validate product collection
  73.      * @var string
  74.      */
  75.     protected static $strInterface '\Isotope\Interfaces\IsotopeProductCollection';
  76.     /**
  77.      * List of types (classes) for this model
  78.      * @var array
  79.      */
  80.     protected static $arrModelTypes = array();
  81.     /**
  82.      * Cache
  83.      * @var array
  84.      */
  85.     protected $arrCache;
  86.     /**
  87.      * Cache product items in this collection
  88.      * @var ProductCollectionItem[]
  89.      */
  90.     protected $arrItems;
  91.     /**
  92.      * Cache surcharges in this collection
  93.      * @var array
  94.      */
  95.     protected $arrSurcharges;
  96.     /**
  97.      * Errors
  98.      * @var array
  99.      */
  100.     protected $arrErrors = array();
  101.     /**
  102.      * Shipping method for this collection, if shipping is required
  103.      * @var IsotopeShipping
  104.      */
  105.     protected $objShipping false;
  106.     /**
  107.      * Payment method for this collection, if payment is required
  108.      * @var IsotopePayment
  109.      */
  110.     protected $objPayment false;
  111.     /**
  112.      * Constructor
  113.      *
  114.      * @param \Database\Result $objResult
  115.      */
  116.     public function __construct(\Database\Result $objResult null)
  117.     {
  118.         parent::__construct($objResult);
  119.         $this->arrData['uniqid'] = $this->generateUniqueId();
  120.         // Do not use __destruct, because Database object might be destructed first
  121.         // see http://github.com/contao/core/issues/2236
  122.         if ('FE' === TL_MODE) {
  123.             register_shutdown_function(array($this'updateDatabase'), false);
  124.         }
  125.     }
  126.     /**
  127.      * Prevent cloning because we can't copy items etc.
  128.      *
  129.      * @throws \LogicException because ProductCollection cannot be cloned
  130.      */
  131.     /** @noinspection MagicMethodsValidityInspection */
  132.     public function __clone()
  133.     {
  134.         throw new \LogicException(
  135.             'Product collections can\'t be cloned, you should probably use ProductCollection::createFromCollection'
  136.         );
  137.     }
  138.     /**
  139.      * Shutdown function to update prices of items and collection
  140.      *
  141.      * @param boolean $blnCreate If true create Model even if not in registry or not saved at all
  142.      */
  143.     public function updateDatabase($blnCreate true)
  144.     {
  145.         if (!$this->blnPreventSaving
  146.             && !$this->isLocked()
  147.             && (Registry::getInstance()->isRegistered($this) || $blnCreate)
  148.         ) {
  149.             foreach ($this->getItems() as $objItem) {
  150.                 if (!$objItem->hasProduct()) {
  151.                     continue;
  152.                 }
  153.                 $objItem->price          $objItem->getPrice();
  154.                 $objItem->tax_free_price $objItem->getTaxFreePrice();
  155.                 $objItem->save();
  156.             }
  157.             // First call to __set for tstamp will truncate the cache
  158.             $this->tstamp            time();
  159.             $this->subtotal          $this->getSubtotal();
  160.             $this->tax_free_subtotal $this->getTaxFreeSubtotal();
  161.             $this->total             $this->getTotal();
  162.             $this->tax_free_total    $this->getTaxFreeTotal();
  163.             $this->currency          = (string) $this->getConfig()->currency;
  164.             $this->save();
  165.         }
  166.     }
  167.     /**
  168.      * Mark a field as modified
  169.      *
  170.      * @param string $strKey The field key
  171.      */
  172.     public function markModified($strKey)
  173.     {
  174.         if ('locked' === $strKey) {
  175.             throw new \InvalidArgumentException('Cannot change lock status of collection');
  176.         }
  177.         if ('document_number' === $strKey) {
  178.             throw new \InvalidArgumentException(
  179.                 'Cannot change document number of a collection, must be generated using generateDocumentNumber()'
  180.             );
  181.         }
  182.         $this->clearCache();
  183.         parent::markModified($strKey);
  184.     }
  185.     /**
  186.      * @inheritdoc
  187.      */
  188.     public function getId()
  189.     {
  190.         return (int) $this->id;
  191.     }
  192.     /**
  193.      * @inheritdoc
  194.      */
  195.     public function getUniqueId()
  196.     {
  197.         return $this->uniqid;
  198.     }
  199.     /**
  200.      * @inheritdoc
  201.      */
  202.     public function getMember()
  203.     {
  204.         if (=== (int) $this->member) {
  205.             return null;
  206.         }
  207.         return MemberModel::findByPk($this->member);
  208.     }
  209.     /**
  210.      * @inheritdoc
  211.      */
  212.     public function getStoreId()
  213.     {
  214.         return (int) $this->store_id;
  215.     }
  216.     /**
  217.      * @inheritdoc
  218.      */
  219.     public function getConfig()
  220.     {
  221.         try {
  222.             return $this->getRelated('config_id');
  223.         } catch (\Exception $e) {
  224.             return null;
  225.         }
  226.     }
  227.     /**
  228.      * @inheritdoc
  229.      */
  230.     public function isLocked()
  231.     {
  232.         return null !== $this->locked;
  233.     }
  234.     /**
  235.      * @inheritdoc
  236.      */
  237.     public function getLockTime()
  238.     {
  239.         return $this->locked;
  240.     }
  241.     /**
  242.      * @inheritdoc
  243.      */
  244.     public function isEmpty()
  245.     {
  246.         return === \count($this->getItems());
  247.     }
  248.     /**
  249.      * Return payment method for this collection
  250.      *
  251.      * @return IsotopePayment|null
  252.      */
  253.     public function getPaymentMethod()
  254.     {
  255.         if (false === $this->objPayment) {
  256.             try {
  257.                 $this->objPayment $this->getRelated('payment_id');
  258.             } catch (\Exception $e) {
  259.                 $this->objPayment null;
  260.             }
  261.         }
  262.         return $this->objPayment;
  263.     }
  264.     /**
  265.      * Set payment method for this collection
  266.      *
  267.      * @param IsotopePayment $objPayment
  268.      */
  269.     public function setPaymentMethod(IsotopePayment $objPayment null)
  270.     {
  271.         $this->payment_id = (null === $objPayment $objPayment->getId());
  272.         $this->objPayment $objPayment;
  273.     }
  274.     /**
  275.      * Return surcharge for current payment method
  276.      *
  277.      * @return ProductCollectionSurcharge|null
  278.      */
  279.     public function getPaymentSurcharge()
  280.     {
  281.         return $this->hasPayment() ? $this->getPaymentMethod()->getSurcharge($this) : null;
  282.     }
  283.     /**
  284.      * Return boolean whether collection has payment
  285.      *
  286.      * @return bool
  287.      */
  288.     public function hasPayment()
  289.     {
  290.         return null !== $this->getPaymentMethod();
  291.     }
  292.     /**
  293.      * Return boolean whether collection requires payment
  294.      *
  295.      * @return bool
  296.      */
  297.     public function requiresPayment()
  298.     {
  299.         return $this->getTotal() > 0;
  300.     }
  301.     /**
  302.      * Return shipping method for this collection
  303.      *
  304.      * @return IsotopeShipping|null
  305.      */
  306.     public function getShippingMethod()
  307.     {
  308.         if (false === $this->objShipping) {
  309.             try {
  310.                 $this->objShipping $this->getRelated('shipping_id');
  311.             } catch (\Exception $e) {
  312.                 $this->objShipping null;
  313.             }
  314.         }
  315.         return $this->objShipping;
  316.     }
  317.     /**
  318.      * Set shipping method for this collection
  319.      *
  320.      * @param IsotopeShipping $objShipping
  321.      */
  322.     public function setShippingMethod(IsotopeShipping $objShipping null)
  323.     {
  324.         $this->shipping_id = (null === $objShipping $objShipping->getId());
  325.         $this->objShipping $objShipping;
  326.     }
  327.     /**
  328.      * Return surcharge for current shipping method
  329.      *
  330.      * @return ProductCollectionSurcharge|null
  331.      */
  332.     public function getShippingSurcharge()
  333.     {
  334.         return $this->hasShipping() ? $this->getShippingMethod()->getSurcharge($this) : null;
  335.     }
  336.     /**
  337.      * Return boolean whether collection has shipping
  338.      *
  339.      * @return bool
  340.      */
  341.     public function hasShipping()
  342.     {
  343.         return null !== $this->getShippingMethod();
  344.     }
  345.     /**
  346.      * Return boolean whether collection requires shipping
  347.      *
  348.      * @return bool
  349.      */
  350.     public function requiresShipping()
  351.     {
  352.         if (!isset($this->arrCache['requiresShipping'])) {
  353.             $this->arrCache['requiresShipping'] = false;
  354.             $arrItems $this->getItems();
  355.             foreach ($arrItems as $objItem) {
  356.                 if ($objItem->hasProduct() && !$objItem->getProduct()->isExemptFromShipping()) {
  357.                     $this->arrCache['requiresShipping'] = true;
  358.                     break;
  359.                 }
  360.             }
  361.         }
  362.         return $this->arrCache['requiresShipping'];
  363.     }
  364.     /**
  365.      * Get billing address for collection
  366.      *
  367.      * @return  \Isotope\Model\Address|null
  368.      */
  369.     public function getBillingAddress()
  370.     {
  371.         if (!$this->billing_address_id) {
  372.             return null;
  373.         }
  374.         return $this->getRelated('billing_address_id');
  375.     }
  376.     /**
  377.      * Set billing address for collection
  378.      *
  379.      * @param Address $objAddress
  380.      */
  381.     public function setBillingAddress(Address $objAddress null)
  382.     {
  383.         if (null === $objAddress || $objAddress->id 1) {
  384.             $this->billing_address_id 0;
  385.         } else {
  386.             $this->billing_address_id $objAddress->id;
  387.         }
  388.     }
  389.     /**
  390.      * Return boolean whether collection requires a shipping address
  391.      *
  392.      * @return bool
  393.      */
  394.     public function requiresShippingAddress()
  395.     {
  396.         if (!$this->requiresShipping()) {
  397.             return false;
  398.         }
  399.         if (!isset($this->arrCache['requiresShippingAddress'])) {
  400.             $this->arrCache['requiresShippingAddress'] = true;
  401.             $arrItems $this->getItems();
  402.             foreach ($arrItems as $objItem) {
  403.                 $product $objItem->getProduct();
  404.                 if ($product instanceof IsotopeProduct && \method_exists($product'isPickupOnly') && $product->isPickupOnly()) {
  405.                     $this->arrCache['requiresShippingAddress'] = false;
  406.                     break;
  407.                 }
  408.             }
  409.         }
  410.         return $this->arrCache['requiresShippingAddress'];
  411.     }
  412.     /**
  413.      * Get shipping address for collection
  414.      *
  415.      * @return  Address|null
  416.      */
  417.     public function getShippingAddress()
  418.     {
  419.         if (!$this->shipping_address_id || !$this->requiresShippingAddress()) {
  420.             return null;
  421.         }
  422.         return $this->getRelated('shipping_address_id');
  423.     }
  424.     /**
  425.      * Set shipping address for collection
  426.      *
  427.      * @param Address $objAddress
  428.      */
  429.     public function setShippingAddress(Address $objAddress null)
  430.     {
  431.         if (null === $objAddress || $objAddress->id 1) {
  432.             $this->shipping_address_id 0;
  433.         } else {
  434.             $this->shipping_address_id $objAddress->id;
  435.         }
  436.     }
  437.     /**
  438.      * Returns the generated document number or empty string if not available.
  439.      *
  440.      * @return string
  441.      */
  442.     public function getDocumentNumber()
  443.     {
  444.         return (string) $this->arrData['document_number'];
  445.     }
  446.     /**
  447.      * Return customer email address for the collection
  448.      *
  449.      * @return string
  450.      */
  451.     public function getEmailRecipient()
  452.     {
  453.         $strName            '';
  454.         $strEmail           '';
  455.         $objBillingAddress  $this->getBillingAddress();
  456.         $objShippingAddress $this->getShippingAddress();
  457.         if ($objBillingAddress->email != '') {
  458.             $strName  $objBillingAddress->firstname ' ' $objBillingAddress->lastname;
  459.             $strEmail $objBillingAddress->email;
  460.         } elseif ($objShippingAddress->email != '') {
  461.             $strName  $objShippingAddress->firstname ' ' $objShippingAddress->lastname;
  462.             $strEmail $objShippingAddress->email;
  463.         } elseif ($this->member 0
  464.             && ($objMember MemberModel::findByPk($this->member)) !== null
  465.             && $objMember->email != ''
  466.         ) {
  467.             $strName  $objMember->firstname ' ' $objMember->lastname;
  468.             $strEmail $objMember->email;
  469.         }
  470.         if (trim($strName) != '') {
  471.             // Romanize friendly name to prevent email issues
  472.             $strName html_entity_decode($strNameENT_QUOTES$GLOBALS['TL_CONFIG']['characterSet']);
  473.             $strName StringUtil::stripInsertTags($strName);
  474.             $strName utf8_romanize($strName);
  475.             $strName preg_replace('/[^A-Za-z0-9.!#$%&\'*+-\/=?^_ `{|}~]+/i''_'$strName);
  476.             $strEmail sprintf('"%s" <%s>'$strName$strEmail);
  477.         }
  478.         // !HOOK: determine email recipient for collection
  479.         if (isset($GLOBALS['ISO_HOOKS']['emailRecipientForCollection'])
  480.             && \is_array($GLOBALS['ISO_HOOKS']['emailRecipientForCollection'])
  481.         ) {
  482.             foreach ($GLOBALS['ISO_HOOKS']['emailRecipientForCollection'] as $callback) {
  483.                 $strEmail System::importStatic($callback[0])->{$callback[1]}($strEmail$this);
  484.             }
  485.         }
  486.         return $strEmail;
  487.     }
  488.     /**
  489.      * Return number of items in the collection
  490.      *
  491.      * @return int
  492.      */
  493.     public function countItems()
  494.     {
  495.         if (!isset($this->arrCache['countItems'])) {
  496.             $this->arrCache['countItems'] = ProductCollectionItem::countBy('pid', (int) $this->id);
  497.         }
  498.         return $this->arrCache['countItems'];
  499.     }
  500.     /**
  501.      * Return summary of item quantity in collection
  502.      *
  503.      * @return int
  504.      */
  505.     public function sumItemsQuantity()
  506.     {
  507.         if (!isset($this->arrCache['sumItemsQuantity'])) {
  508.             $this->arrCache['sumItemsQuantity'] = ProductCollectionItem::sumBy('quantity''pid', (int) $this->id);
  509.         }
  510.         return $this->arrCache['sumItemsQuantity'];
  511.     }
  512.     /**
  513.      * Load settings from database field
  514.      *
  515.      * @param array $arrData
  516.      *
  517.      * @return $this
  518.      */
  519.     public function setRow(array $arrData)
  520.     {
  521.         parent::setRow($arrData);
  522.         // Merge settings into arrData, save() will move the values back
  523.         $this->arrData array_merge(StringUtil::deserialize($arrData['settings'] ?? [], true), $this->arrData);
  524.         return $this;
  525.     }
  526.     /**
  527.      * Save all non-database fields in the settings array
  528.      *
  529.      * @return $this
  530.      */
  531.     public function save()
  532.     {
  533.         // The instance cannot be saved
  534.         if ($this->blnPreventSaving) {
  535.             throw new \LogicException('The model instance has been detached and cannot be saved');
  536.         }
  537.         // !HOOK: additional functionality when saving a collection
  538.         if (isset($GLOBALS['ISO_HOOKS']['saveCollection']) && \is_array($GLOBALS['ISO_HOOKS']['saveCollection'])) {
  539.             foreach ($GLOBALS['ISO_HOOKS']['saveCollection'] as $callback) {
  540.                 System::importStatic($callback[0])->{$callback[1]}($this);
  541.             }
  542.         }
  543.         $arrDbFields Database::getInstance()->getFieldNames(static::$strTable);
  544.         $arrModified array_diff_key($this->arrModifiedarray_flip($arrDbFields));
  545.         if (!empty($arrModified)) {
  546.             $arrSettings StringUtil::deserialize($this->settingstrue);
  547.             $arrSettings array_merge($arrSettingsarray_intersect_key($this->arrData$arrModified));
  548.             $this->settings serialize($arrSettings);
  549.         }
  550.         return parent::save();
  551.     }
  552.     /**
  553.      * Also delete child table records when dropping this collection
  554.      *
  555.      * @param bool $blnForce Force to delete the collection even if it's locked
  556.      *
  557.      * @return int Number of rows affected
  558.      *
  559.      * @throws \BadMethodCallException if the product collection is locked.
  560.      */
  561.     public function delete($blnForce false)
  562.     {
  563.         if (!$blnForce) {
  564.             $this->ensureNotLocked();
  565.             // !HOOK: additional functionality when deleting a collection
  566.             if (isset($GLOBALS['ISO_HOOKS']['deleteCollection'])
  567.                 && \is_array($GLOBALS['ISO_HOOKS']['deleteCollection'])
  568.             ) {
  569.                 foreach ($GLOBALS['ISO_HOOKS']['deleteCollection'] as $callback) {
  570.                     $blnRemove System::importStatic($callback[0])->{$callback[1]}($this);
  571.                     if ($blnRemove === false) {
  572.                         return 0;
  573.                     }
  574.                 }
  575.             }
  576.         }
  577.         $intPid          $this->id;
  578.         $intAffectedRows parent::delete();
  579.         if ($intAffectedRows && $intPid 0) {
  580.             Database::getInstance()->query("
  581.                 DELETE FROM tl_iso_product_collection_download
  582.                 WHERE pid IN (SELECT id FROM tl_iso_product_collection_item WHERE pid=$intPid)
  583.             ");
  584.             Database::getInstance()->query(
  585.                 "DELETE FROM tl_iso_product_collection_item WHERE pid=$intPid"
  586.             );
  587.             Database::getInstance()->query(
  588.                 "DELETE FROM tl_iso_product_collection_surcharge WHERE pid=$intPid"
  589.             );
  590.             Database::getInstance()->query(
  591.                 "DELETE FROM tl_iso_address WHERE ptable='" . static::$strTable "' AND pid=$intPid"
  592.             );
  593.         }
  594.         $this->arrCache      = array();
  595.         $this->arrItems      null;
  596.         $this->arrSurcharges null;
  597.         // !HOOK: additional functionality when deleting a collection
  598.         if (isset($GLOBALS['ISO_HOOKS']['postDeleteCollection'])
  599.             && \is_array($GLOBALS['ISO_HOOKS']['postDeleteCollection'])
  600.         ) {
  601.             foreach ($GLOBALS['ISO_HOOKS']['postDeleteCollection'] as $callback) {
  602.                 System::importStatic($callback[0])->{$callback[1]}($this$intPid);
  603.             }
  604.         }
  605.         return $intAffectedRows;
  606.     }
  607.     /**
  608.      * Delete all products in the collection
  609.      *
  610.      * @throws \BadMethodCallException if the product collection is locked.
  611.      */
  612.     public function purge()
  613.     {
  614.         $this->ensureNotLocked();
  615.         foreach ($this->getItems() as $objItem) {
  616.             $this->deleteItem($objItem);
  617.         }
  618.         foreach ($this->getSurcharges() as $objSurcharge) {
  619.             if ($objSurcharge->id) {
  620.                 $objSurcharge->delete();
  621.             }
  622.         }
  623.         $this->clearCache();
  624.     }
  625.     /**
  626.      * Lock collection from begin modified
  627.      *
  628.      * @throws \BadMethodCallException if the product collection is locked.
  629.      */
  630.     public function lock()
  631.     {
  632.         $this->ensureNotLocked();
  633.         global $objPage;
  634.         $time time();
  635.         $this->pageId = (int) $objPage->id;
  636.         $this->language = (string) $GLOBALS['TL_LANGUAGE'];
  637.         $this->createPrivateAddresses();
  638.         $this->updateDatabase();
  639.         // Add surcharges to the collection
  640.         $sorting 128;
  641.         foreach ($this->getSurcharges() as $objSurcharge) {
  642.             $objSurcharge->pid     $this->id;
  643.             $objSurcharge->tstamp  $time;
  644.             $objSurcharge->sorting $sorting;
  645.             $objSurcharge->save();
  646.             $sorting += 128;
  647.         }
  648.         // Add downloads from products to the collection
  649.         foreach (ProductCollectionDownload::createForProductsInCollection($this) as $objDownload) {
  650.             $objDownload->save();
  651.         }
  652.         // Can't use model, it would not save as soon as it's locked
  653.         Database::getInstance()->query(
  654.             "UPDATE tl_iso_product_collection SET locked=$time WHERE id=" $this->id
  655.         );
  656.         $this->arrData['locked'] = $time;
  657.         // !HOOK: pre-process checkout
  658.         if (isset($GLOBALS['ISO_HOOKS']['collectionLocked']) && \is_array($GLOBALS['ISO_HOOKS']['collectionLocked'])) {
  659.             foreach ($GLOBALS['ISO_HOOKS']['collectionLocked'] as $callback) {
  660.                 System::importStatic($callback[0])->{$callback[1]}($this);
  661.             }
  662.         }
  663.         $this->clearCache();
  664.     }
  665.     /**
  666.      * Sum total price of all items in the collection
  667.      *
  668.      * @return float
  669.      */
  670.     public function getSubtotal()
  671.     {
  672.         if ($this->isLocked()) {
  673.             return $this->subtotal;
  674.         }
  675.         if (!isset($this->arrCache['subtotal'])) {
  676.             $fltAmount 0;
  677.             $arrItems  $this->getItems();
  678.             foreach ($arrItems as $objItem) {
  679.                 $varPrice $objItem->getTotalPrice();
  680.                 if ($varPrice !== null) {
  681.                     $fltAmount += $varPrice;
  682.                 }
  683.             }
  684.             $this->arrCache['subtotal'] = $fltAmount;
  685.         }
  686.         return $this->arrCache['subtotal'];
  687.     }
  688.     /**
  689.      * Sum total tax free price of all items in the collection
  690.      *
  691.      * @return float
  692.      */
  693.     public function getTaxFreeSubtotal()
  694.     {
  695.         if ($this->isLocked()) {
  696.             return $this->tax_free_subtotal;
  697.         }
  698.         if (!isset($this->arrCache['taxFreeSubtotal'])) {
  699.             $fltAmount 0;
  700.             $arrItems  $this->getItems();
  701.             foreach ($arrItems as $objItem) {
  702.                 $varPrice $objItem->getTaxFreeTotalPrice();
  703.                 if ($varPrice !== null) {
  704.                     $fltAmount += $varPrice;
  705.                 }
  706.             }
  707.             $this->arrCache['taxFreeSubtotal'] = $fltAmount;
  708.         }
  709.         return $this->arrCache['taxFreeSubtotal'];
  710.     }
  711.     /**
  712.      * Sum total price of items and surcharges
  713.      *
  714.      * @return float
  715.      */
  716.     public function getTotal()
  717.     {
  718.         if ($this->isLocked()) {
  719.             return $this->total;
  720.         }
  721.         if (!isset($this->arrCache['total'])) {
  722.             $fltAmount     $this->getSubtotal();
  723.             $arrSurcharges $this->getSurcharges();
  724.             foreach ($arrSurcharges as $objSurcharge) {
  725.                 if ($objSurcharge->addToTotal) {
  726.                     $fltAmount += $objSurcharge->total_price;
  727.                 }
  728.             }
  729.             $this->arrCache['total'] = $fltAmount $fltAmount 0;
  730.         }
  731.         return $this->arrCache['total'];
  732.     }
  733.     /**
  734.      * Sum tax free total of items and surcharges
  735.      *
  736.      * @return float
  737.      */
  738.     public function getTaxFreeTotal()
  739.     {
  740.         if ($this->isLocked()) {
  741.             return $this->tax_free_total;
  742.         }
  743.         if (!isset($this->arrCache['taxFreeTotal'])) {
  744.             $arrSurcharges $this->getSurcharges();
  745.             if (Config::PRICE_DISPLAY_GROSS === $this->getConfig()->priceDisplay) {
  746.                 $fltAmount $this->getTotal();
  747.                 foreach ($arrSurcharges as $objSurcharge) {
  748.                     if ($objSurcharge instanceof Tax) {
  749.                         $fltAmount -= $objSurcharge->total_price;
  750.                     }
  751.                 }
  752.             } else {
  753.                 $fltAmount $this->getTaxFreeSubtotal();
  754.                 foreach ($arrSurcharges as $objSurcharge) {
  755.                     if ($objSurcharge->addToTotal) {
  756.                         $fltAmount += $objSurcharge->tax_free_total_price;
  757.                     }
  758.                 }
  759.             }
  760.             $this->arrCache['taxFreeTotal'] = $fltAmount Isotope::roundPrice($fltAmount) : 0;
  761.         }
  762.         return $this->arrCache['taxFreeTotal'];
  763.     }
  764.     /**
  765.      * @inheritdoc
  766.      */
  767.     public function getCurrency()
  768.     {
  769.         return $this->currency;
  770.     }
  771.     /**
  772.      * Return the item with the latest timestamp (e.g. the latest added item)
  773.      *
  774.      * @return ProductCollectionItem|null
  775.      */
  776.     public function getLatestItem()
  777.     {
  778.         if (!isset($this->arrCache['latestItem'])) {
  779.             $latest   0;
  780.             $arrItems $this->getItems();
  781.             foreach ($arrItems as $objItem) {
  782.                 if ($objItem->tstamp $latest) {
  783.                     $this->arrCache['latestItem'] = $objItem;
  784.                     $latest                       $objItem->tstamp;
  785.                 }
  786.             }
  787.         }
  788.         return $this->arrCache['latestItem'];
  789.     }
  790.     /**
  791.      * Return timestamp when this collection was created
  792.      * This is relevant for price calculation
  793.      *
  794.      * @return int
  795.      */
  796.     public function getLastModification()
  797.     {
  798.         if ($this->isLocked()) {
  799.             return $this->locked;
  800.         }
  801.         return $this->tstamp ? : time();
  802.     }
  803.     /**
  804.      * Return all items in the collection
  805.      *
  806.      * @param callable $varCallable
  807.      * @param bool     $blnNoCache
  808.      *
  809.      * @return ProductCollectionItem[]
  810.      */
  811.     public function getItems($varCallable null$blnNoCache false)
  812.     {
  813.         if (null === $this->arrItems || $blnNoCache) {
  814.             $this->arrItems = array();
  815.             if (($objItems ProductCollectionItem::findBy('pid'$this->id)) !== null) {
  816.                 /** @var ProductCollectionItem $objItem */
  817.                 foreach ($objItems as $objItem) {
  818.                     if ($this->isLocked()) {
  819.                         $objItem->lock();
  820.                     }
  821.                     // Add error message for items no longer available
  822.                     if (!$objItem->isAvailable() && !$objItem->hasErrors()) {
  823.                         $objItem->addError($GLOBALS['TL_LANG']['ERR']['collectionItemNotAvailable']);
  824.                     }
  825.                     $this->arrItems[$objItem->id] = $objItem;
  826.                 }
  827.             }
  828.         }
  829.         if ($varCallable === null) {
  830.             return $this->arrItems;
  831.         }
  832.         // not allowed to chance items
  833.         $arrItems $this->arrItems;
  834.         return \call_user_func($varCallable$arrItems);
  835.     }
  836.     /**
  837.      * Search item for a specific product
  838.      *
  839.      * @param IsotopeProduct $objProduct
  840.      *
  841.      * @return ProductCollectionItem|null
  842.      */
  843.     public function getItemForProduct(IsotopeProduct $objProduct)
  844.     {
  845.         $strClass array_search(\get_class($objProduct), Product::getModelTypes(), true);
  846.         $objItem ProductCollectionItem::findOneBy(
  847.             array('pid=?''type=?''product_id=?''configuration=?'),
  848.             array($this->id$strClass$objProduct->getId(), serialize($objProduct->getOptions()))
  849.         );
  850.         return $objItem;
  851.     }
  852.     /**
  853.      * Gets the product collection with given ID if it belongs to this collection.
  854.      *
  855.      * @param int $id
  856.      *
  857.      * @return ProductCollectionItem|null
  858.      */
  859.     public function getItemById($id)
  860.     {
  861.         $items $this->getItems();
  862.         if (!isset($items[$id])) {
  863.             return null;
  864.         }
  865.         return $items[$id];
  866.     }
  867.     /**
  868.      * Check if a given product is already in the collection
  869.      *
  870.      * @param IsotopeProduct $objProduct
  871.      * @param bool           $blnIdentical
  872.      *
  873.      * @return bool
  874.      */
  875.     public function hasProduct(IsotopeProduct $objProduct$blnIdentical true)
  876.     {
  877.         if (true === $blnIdentical) {
  878.             return null !== $this->getItemForProduct($objProduct);
  879.         }
  880.         $intId $objProduct->getProductId();
  881.         foreach ($this->getItems() as $objItem) {
  882.             if ($objItem->hasProduct()
  883.                 && ($objItem->getProduct()->getId() == $intId || $objItem->getProduct()->getProductId() == $intId)
  884.             ) {
  885.                 return true;
  886.             }
  887.         }
  888.         return false;
  889.     }
  890.     /**
  891.      * Add a product to the collection
  892.      *
  893.      * @param IsotopeProduct $objProduct
  894.      * @param int            $intQuantity
  895.      * @param array          $arrConfig
  896.      *
  897.      * @return ProductCollectionItem|false
  898.      */
  899.     public function addProduct(IsotopeProduct $objProduct$intQuantity, array $arrConfig = array())
  900.     {
  901.         // !HOOK: additional functionality when adding product to collection
  902.         if (isset($GLOBALS['ISO_HOOKS']['addProductToCollection'])
  903.             && \is_array($GLOBALS['ISO_HOOKS']['addProductToCollection'])
  904.         ) {
  905.             foreach ($GLOBALS['ISO_HOOKS']['addProductToCollection'] as $callback) {
  906.                 $intQuantity System::importStatic($callback[0])->{$callback[1]}($objProduct$intQuantity$this$arrConfig);
  907.             }
  908.         }
  909.         if ($intQuantity == 0) {
  910.             return false;
  911.         }
  912.         $time         time();
  913.         $this->tstamp $time;
  914.         // Make sure collection is in DB before adding product
  915.         if (!Registry::getInstance()->isRegistered($this)) {
  916.             $this->save();
  917.         }
  918.         // Remove uploaded files from session so they are not added to the next product (see #646)
  919.         unset($_SESSION['FILES']);
  920.         $objItem            $this->getItemForProduct($objProduct);
  921.         $intMinimumQuantity $objProduct->getMinimumQuantity();
  922.         if (null !== $objItem) {
  923.             if (($objItem->quantity $intQuantity) < $intMinimumQuantity) {
  924.                 Message::addInfo(sprintf(
  925.                     $GLOBALS['TL_LANG']['ERR']['productMinimumQuantity'],
  926.                     $objProduct->getName(),
  927.                     $intMinimumQuantity
  928.                 ));
  929.                 $intQuantity            $intMinimumQuantity $objItem->quantity;
  930.             }
  931.             $objItem->increaseQuantityBy($intQuantity);
  932.         } else {
  933.             if ($intQuantity $intMinimumQuantity) {
  934.                 Message::addInfo(sprintf(
  935.                     $GLOBALS['TL_LANG']['ERR']['productMinimumQuantity'],
  936.                     $objProduct->getName(),
  937.                     $intMinimumQuantity
  938.                 ));
  939.                 $intQuantity            $intMinimumQuantity;
  940.             }
  941.             $objItem           = new ProductCollectionItem();
  942.             $objItem->pid      $this->id;
  943.             $objItem->jumpTo   = isset($arrConfig['jumpTo']) ? (int) $arrConfig['jumpTo']->id 0;
  944.             $this->setProductForItem($objProduct$objItem$intQuantity);
  945.             $objItem->save();
  946.             // Add the new item to our cache
  947.             $this->arrItems[$objItem->id] = $objItem;
  948.         }
  949.         // !HOOK: additional functionality when adding product to collection
  950.         if (isset($GLOBALS['ISO_HOOKS']['postAddProductToCollection'])
  951.             && \is_array($GLOBALS['ISO_HOOKS']['postAddProductToCollection'])
  952.         ) {
  953.             foreach ($GLOBALS['ISO_HOOKS']['postAddProductToCollection'] as $callback) {
  954.                 System::importStatic($callback[0])->{$callback[1]}($objItem$intQuantity$this$arrConfig);
  955.             }
  956.         }
  957.         return $objItem;
  958.     }
  959.     /**
  960.      * Update product details for a collection item.
  961.      *
  962.      * @param IsotopeProduct        $objProduct
  963.      * @param ProductCollectionItem $objItem
  964.      *
  965.      * @return bool
  966.      */
  967.     public function updateProduct(IsotopeProduct $objProductProductCollectionItem $objItem)
  968.     {
  969.         if ($objItem->pid != $this->id) {
  970.             throw new \InvalidArgumentException('Item does not belong to this collection');
  971.         }
  972.         // !HOOK: additional functionality when updating product in collection
  973.         if (isset($GLOBALS['ISO_HOOKS']['updateProductInCollection'])
  974.             && \is_array($GLOBALS['ISO_HOOKS']['updateProductInCollection'])
  975.         ) {
  976.             foreach ($GLOBALS['ISO_HOOKS']['updateProductInCollection'] as $callback) {
  977.                 if (false === System::importStatic($callback[0])->{$callback[1]}($objProduct$objItem$this)) {
  978.                     return false;
  979.                 }
  980.             }
  981.         }
  982.         $this->setProductForItem($objProduct$objItem$objItem->quantity);
  983.         $objItem->save();
  984.         // !HOOK: additional functionality when adding product to collection
  985.         if (isset($GLOBALS['ISO_HOOKS']['postUpdateProductInCollection'])
  986.             && \is_array($GLOBALS['ISO_HOOKS']['postUpdateProductInCollection'])
  987.         ) {
  988.             foreach ($GLOBALS['ISO_HOOKS']['postUpdateProductInCollection'] as $callback) {
  989.                 System::importStatic($callback[0])->{$callback[1]}($objProduct$objItem$this);
  990.             }
  991.         }
  992.         return true;
  993.     }
  994.     /**
  995.      * Update a product collection item
  996.      *
  997.      * @param ProductCollectionItem $objItem The product object
  998.      * @param array                 $arrSet  The property(ies) to adjust
  999.      *
  1000.      * @return bool
  1001.      */
  1002.     public function updateItem(ProductCollectionItem $objItem$arrSet)
  1003.     {
  1004.         return $this->updateItemById($objItem->id$arrSet);
  1005.     }
  1006.     /**
  1007.      * Update product collection item with given ID
  1008.      *
  1009.      * @param int   $intId
  1010.      * @param array $arrSet
  1011.      *
  1012.      * @return bool
  1013.      */
  1014.     public function updateItemById($intId$arrSet)
  1015.     {
  1016.         $this->ensureNotLocked();
  1017.         $arrItems $this->getItems();
  1018.         if (!isset($arrItems[$intId])) {
  1019.             return false;
  1020.         }
  1021.         /** @var ProductCollectionItem $objItem */
  1022.         $objItem $arrItems[$intId];
  1023.         // !HOOK: additional functionality when updating a product in the collection
  1024.         if (isset($GLOBALS['ISO_HOOKS']['updateItemInCollection'])
  1025.             && \is_array($GLOBALS['ISO_HOOKS']['updateItemInCollection'])
  1026.         ) {
  1027.             foreach ($GLOBALS['ISO_HOOKS']['updateItemInCollection'] as $callback) {
  1028.                 $arrSet System::importStatic($callback[0])->{$callback[1]}($objItem$arrSet$this);
  1029.                 if (!\is_array($arrSet) || === \count($arrSet)) {
  1030.                     return false;
  1031.                 }
  1032.             }
  1033.         }
  1034.         // Quantity set to 0, delete item
  1035.         if (isset($arrSet['quantity']) && $arrSet['quantity'] == 0) {
  1036.             return $this->deleteItemById($intId);
  1037.         }
  1038.         if (isset($arrSet['quantity']) && $objItem->hasProduct()) {
  1039.             // Set product quantity so we can determine the correct minimum price
  1040.             $objProduct         $objItem->getProduct();
  1041.             $intMinimumQuantity $objProduct->getMinimumQuantity();
  1042.             if ($arrSet['quantity'] < $intMinimumQuantity) {
  1043.                 Message::addInfo(sprintf(
  1044.                     $GLOBALS['TL_LANG']['ERR']['productMinimumQuantity'],
  1045.                     $objProduct->getName(),
  1046.                     $intMinimumQuantity
  1047.                 ));
  1048.                 $arrSet['quantity']     = $intMinimumQuantity;
  1049.             }
  1050.         }
  1051.         $arrSet['tstamp'] = time();
  1052.         foreach ($arrSet as $k => $v) {
  1053.             $objItem->$k $v;
  1054.         }
  1055.         $objItem->save();
  1056.         $this->tstamp time();
  1057.         // !HOOK: additional functionality when adding product to collection
  1058.         if (isset($GLOBALS['ISO_HOOKS']['postUpdateItemInCollection'])
  1059.             && \is_array($GLOBALS['ISO_HOOKS']['postUpdateItemInCollection'])
  1060.         ) {
  1061.             foreach ($GLOBALS['ISO_HOOKS']['postUpdateItemInCollection'] as $callback) {
  1062.                 System::importStatic($callback[0])->{$callback[1]}($objItem$arrSet['quantity'], $this);
  1063.             }
  1064.         }
  1065.         return true;
  1066.     }
  1067.     /**
  1068.      * Remove item from collection
  1069.      *
  1070.      * @param ProductCollectionItem $objItem
  1071.      *
  1072.      * @return bool
  1073.      */
  1074.     public function deleteItem(ProductCollectionItem $objItem)
  1075.     {
  1076.         return $this->deleteItemById($objItem->id);
  1077.     }
  1078.     /**
  1079.      * Remove item with given ID from collection
  1080.      *
  1081.      * @param int $intId
  1082.      *
  1083.      * @return bool
  1084.      *
  1085.      * @throws \BadMethodCallException if the product collection is locked.
  1086.      */
  1087.     public function deleteItemById($intId)
  1088.     {
  1089.         $this->ensureNotLocked();
  1090.         $arrItems $this->getItems();
  1091.         if (!isset($arrItems[$intId])) {
  1092.             return false;
  1093.         }
  1094.         $objItem $arrItems[$intId];
  1095.         // !HOOK: additional functionality when a product is removed from the collection
  1096.         if (isset($GLOBALS['ISO_HOOKS']['deleteItemFromCollection'])
  1097.             && \is_array($GLOBALS['ISO_HOOKS']['deleteItemFromCollection'])
  1098.         ) {
  1099.             foreach ($GLOBALS['ISO_HOOKS']['deleteItemFromCollection'] as $callback) {
  1100.                 $blnRemove System::importStatic($callback[0])->{$callback[1]}($objItem$this);
  1101.                 if ($blnRemove === false) {
  1102.                     return false;
  1103.                 }
  1104.             }
  1105.         }
  1106.         $objItem->delete();
  1107.         unset($this->arrItems[$intId]);
  1108.         $this->tstamp time();
  1109.         // !HOOK: additional functionality when adding product to collection
  1110.         if (isset($GLOBALS['ISO_HOOKS']['postDeleteItemFromCollection'])
  1111.             && \is_array($GLOBALS['ISO_HOOKS']['postDeleteItemFromCollection'])
  1112.         ) {
  1113.             foreach ($GLOBALS['ISO_HOOKS']['postDeleteItemFromCollection'] as $callback) {
  1114.                 System::importStatic($callback[0])->{$callback[1]}($objItem$this);
  1115.             }
  1116.         }
  1117.         return true;
  1118.     }
  1119.     /**
  1120.      * Find surcharges for the current collection
  1121.      *
  1122.      * @return ProductCollectionSurcharge[]
  1123.      */
  1124.     public function getSurcharges()
  1125.     {
  1126.         if (null === $this->arrSurcharges) {
  1127.             if ($this->isLocked()) {
  1128.                 $this->arrSurcharges = [];
  1129.                 if (($objSurcharges ProductCollectionSurcharge::findBy('pid'$this->id)) !== null) {
  1130.                     $this->arrSurcharges $objSurcharges->getModels();
  1131.                 }
  1132.             } else {
  1133.                 $this->arrSurcharges ProductCollectionSurcharge::findForCollection($this);
  1134.             }
  1135.         }
  1136.         return $this->arrSurcharges;
  1137.     }
  1138.     /**
  1139.      * Copy product collection items from another collection to this one (e.g. Cart to Order)
  1140.      *
  1141.      * @param IsotopeProductCollection $objSource
  1142.      *
  1143.      * @return int[]
  1144.      *
  1145.      * @throws \BadMethodCallException if the product collection is locked.
  1146.      */
  1147.     public function copyItemsFrom(IsotopeProductCollection $objSource)
  1148.     {
  1149.         $this->ensureNotLocked();
  1150.         $this->updateDatabase();
  1151.         // Make sure database table has the latest prices
  1152.         $objSource->updateDatabase();
  1153.         $time        time();
  1154.         $arrIds      = [];
  1155.         $arrOldItems $objSource->getItems();
  1156.         foreach ($arrOldItems as $objOldItem) {
  1157.             // !HOOK: additional functionality when copying product to collection
  1158.             if (isset($GLOBALS['ISO_HOOKS']['copyCollectionItem'])
  1159.                 && \is_array($GLOBALS['ISO_HOOKS']['copyCollectionItem'])
  1160.             ) {
  1161.                 foreach ($GLOBALS['ISO_HOOKS']['copyCollectionItem'] as $callback) {
  1162.                     if (System::importStatic($callback[0])->{$callback[1]}($objOldItem$objSource$this) === false) {
  1163.                         continue;
  1164.                     }
  1165.                 }
  1166.             }
  1167.             if ($objOldItem->hasProduct() && $this->hasProduct($objOldItem->getProduct())) {
  1168.                 $objNewItem $this->getItemForProduct($objOldItem->getProduct());
  1169.                 $objNewItem->increaseQuantityBy($objOldItem->quantity);
  1170.             } else {
  1171.                 $objNewItem         = clone $objOldItem;
  1172.                 $objNewItem->pid    $this->id;
  1173.                 $objNewItem->tstamp $time;
  1174.                 $objNewItem->save();
  1175.             }
  1176.             $arrIds[$objOldItem->id] = $objNewItem->id;
  1177.         }
  1178.         if (\count($arrIds) > 0) {
  1179.             $this->tstamp $time;
  1180.         }
  1181.         // !HOOK: additional functionality when adding product to collection
  1182.         if (isset($GLOBALS['ISO_HOOKS']['copiedCollectionItems'])
  1183.             && \is_array($GLOBALS['ISO_HOOKS']['copiedCollectionItems'])
  1184.         ) {
  1185.             foreach ($GLOBALS['ISO_HOOKS']['copiedCollectionItems'] as $callback) {
  1186.                 System::importStatic($callback[0])->{$callback[1]}($objSource$this$arrIds);
  1187.             }
  1188.         }
  1189.         $this->clearCache();
  1190.         return $arrIds;
  1191.     }
  1192.     /**
  1193.      * Copy product collection surcharges from another collection to this one (e.g. Cart to Order)
  1194.      *
  1195.      * @param IsotopeProductCollection $objSource
  1196.      * @param array                    $arrItemMap
  1197.      *
  1198.      * @return int[]
  1199.      *
  1200.      * @deprecated Deprecated since version 2.2, to be removed in 3.0.
  1201.      *             Surcharges are calculated on the fly, so it does not make sense to copy them from another one.
  1202.      *
  1203.      * @throws \BadMethodCallException if the product collection is locked.
  1204.      */
  1205.     public function copySurchargesFrom(IsotopeProductCollection $objSource, array $arrItemMap = array())
  1206.     {
  1207.         $this->ensureNotLocked();
  1208.         $arrIds  = array();
  1209.         $time    time();
  1210.         $sorting 128;
  1211.         foreach ($objSource->getSurcharges() as $objSourceSurcharge) {
  1212.             $objSurcharge          = clone $objSourceSurcharge;
  1213.             $objSurcharge->pid     $this->id;
  1214.             $objSurcharge->tstamp  $time;
  1215.             $objSurcharge->sorting $sorting;
  1216.             // Convert surcharge amount for individual product IDs
  1217.             $objSurcharge->convertCollectionItemIds($arrItemMap);
  1218.             $objSurcharge->save();
  1219.             $arrIds[$sorting] = $objSurcharge->id;
  1220.             $sorting += 128;
  1221.         }
  1222.         // Empty cache
  1223.         $this->arrSurcharges null;
  1224.         $this->arrCache null;
  1225.         return $arrIds;
  1226.     }
  1227.     /**
  1228.      * @inheritdoc
  1229.      */
  1230.     public function addToScale(Scale $objScale null)
  1231.     {
  1232.         if (null === $objScale) {
  1233.             $objScale = new Scale();
  1234.         }
  1235.         foreach ($this->getItems() as $objItem) {
  1236.             if (!$objItem->hasProduct()) {
  1237.                 continue;
  1238.             }
  1239.             $objProduct $objItem->getProduct();
  1240.             if ($objProduct instanceof WeightAggregate) {
  1241.                 $objWeight $objProduct->getWeight();
  1242.                 if (null !== $objWeight) {
  1243.                     for ($i 0$i $objItem->quantity$i++) {
  1244.                         $objScale->add($objWeight);
  1245.                     }
  1246.                 }
  1247.             } elseif ($objProduct instanceof Weighable) {
  1248.                 for ($i 0$i $objItem->quantity$i++) {
  1249.                     $objScale->add($objProduct);
  1250.                 }
  1251.             }
  1252.         }
  1253.         return $objScale;
  1254.     }
  1255.     /**
  1256.      * @inheritdoc
  1257.      */
  1258.     public function addToTemplate(Template $objTemplate, array $arrConfig = [])
  1259.     {
  1260.         $arrGalleries = array();
  1261.         $objConfig    $this->getRelated('config_id') ?: Isotope::getConfig();
  1262.         $arrItems     $this->addItemsToTemplate($objTemplate$arrConfig['sorting']);
  1263.         $objTemplate->id                $this->id;
  1264.         $objTemplate->collection        $this;
  1265.         $objTemplate->config            $objConfig;
  1266.         $objTemplate->surcharges        Frontend::formatSurcharges($this->getSurcharges(), $objConfig->currency);
  1267.         $objTemplate->subtotal          Isotope::formatPriceWithCurrency($this->getSubtotal(), true$objConfig->currency);
  1268.         $objTemplate->total             Isotope::formatPriceWithCurrency($this->getTotal(), true$objConfig->currency);
  1269.         $objTemplate->tax_free_subtotal Isotope::formatPriceWithCurrency($this->getTaxFreeSubtotal(), true$objConfig->currency);
  1270.         $objTemplate->tax_free_total    Isotope::formatPriceWithCurrency($this->getTaxFreeTotal(), true$objConfig->currency);
  1271.         $objTemplate->hasAttribute = function ($strAttributeProductCollectionItem $objItem) {
  1272.             if (!$objItem->hasProduct()) {
  1273.                 return false;
  1274.             }
  1275.             $objProduct $objItem->getProduct();
  1276.             return \in_array($strAttribute$objProduct->getAttributes(), true)
  1277.                 || \in_array($strAttribute$objProduct->getVariantAttributes(), true);
  1278.         };
  1279.         $objTemplate->generateAttribute = function (
  1280.             $strAttribute,
  1281.             ProductCollectionItem $objItem,
  1282.             array $arrOptions = array()
  1283.         ) {
  1284.             if (!$objItem->hasProduct()) {
  1285.                 return '';
  1286.             }
  1287.             $objAttribute $GLOBALS['TL_DCA']['tl_iso_product']['attributes'][$strAttribute];
  1288.             if (!($objAttribute instanceof IsotopeAttribute)) {
  1289.                 throw new \InvalidArgumentException($strAttribute ' is not a valid attribute');
  1290.             }
  1291.             return $objAttribute->generate($objItem->getProduct(), $arrOptions);
  1292.         };
  1293.         $objTemplate->getGallery = function (
  1294.             $strAttribute,
  1295.             ProductCollectionItem $objItem
  1296.         ) use (
  1297.             $arrConfig,
  1298.             &$arrGalleries
  1299.         ) {
  1300.             if (!$objItem->hasProduct()) {
  1301.                 return new StandardGallery();
  1302.             }
  1303.             $strCacheKey         'product' $objItem->product_id '_' $strAttribute;
  1304.             $arrConfig['jumpTo'] = $objItem->getRelated('jumpTo');
  1305.             if (!isset($arrGalleries[$strCacheKey])) {
  1306.                 $arrGalleries[$strCacheKey] = Gallery::createForProductAttribute(
  1307.                     $objItem->getProduct(),
  1308.                     $strAttribute,
  1309.                     $arrConfig
  1310.                 );
  1311.             }
  1312.             return $arrGalleries[$strCacheKey];
  1313.         };
  1314.         $objTemplate->attributeLabel = function ($name, array $options = []) {
  1315.             /** @var Attribute $attribute */
  1316.             $attribute $GLOBALS['TL_DCA']['tl_iso_product']['attributes'][$name] ?? null;
  1317.             if (!$attribute instanceof IsotopeAttribute) {
  1318.                 return Format::dcaLabel('tl_iso_product'$name);
  1319.             }
  1320.             return $attribute->getLabel($options);
  1321.         };
  1322.         $objTemplate->attributeValue = function ($name$value, array $options = []) {
  1323.             /** @var Attribute $attribute */
  1324.             $attribute $GLOBALS['TL_DCA']['tl_iso_product']['attributes'][$name] ?? null;
  1325.             if (!$attribute instanceof IsotopeAttribute) {
  1326.                 return Format::dcaValue('tl_iso_product'$name$value);
  1327.             }
  1328.             return $attribute->generateValue($value$options);
  1329.         };
  1330.         // !HOOK: allow overriding of the template
  1331.         if (isset($GLOBALS['ISO_HOOKS']['addCollectionToTemplate'])
  1332.             && \is_array($GLOBALS['ISO_HOOKS']['addCollectionToTemplate'])
  1333.         ) {
  1334.             foreach ($GLOBALS['ISO_HOOKS']['addCollectionToTemplate'] as $callback) {
  1335.                 System::importStatic($callback[0])->{$callback[1]}($objTemplate$arrItems$this$arrConfig);
  1336.             }
  1337.         }
  1338.     }
  1339.     /**
  1340.      * @inheritdoc
  1341.      */
  1342.     public function addError($message)
  1343.     {
  1344.         $this->arrErrors[] = $message;
  1345.     }
  1346.     /**
  1347.      * @inheritdoc
  1348.      */
  1349.     public function hasErrors()
  1350.     {
  1351.         if (\count($this->arrErrors) > 0) {
  1352.             return true;
  1353.         }
  1354.         foreach ($this->getItems() as $objItem) {
  1355.             if ($objItem->hasErrors()) {
  1356.                 return true;
  1357.             }
  1358.         }
  1359.         return false;
  1360.     }
  1361.     /**
  1362.      * @inheritdoc
  1363.      */
  1364.     public function getErrors()
  1365.     {
  1366.         $arrErrors $this->arrErrors;
  1367.         foreach ($this->getItems() as $objItem) {
  1368.             if ($objItem->hasErrors()) {
  1369.                 array_unshift($arrErrors$this->getMessageIfErrorsInItems());
  1370.                 break;
  1371.             }
  1372.         }
  1373.         return $arrErrors;
  1374.     }
  1375.     /**
  1376.      * Loop over items and add them to template
  1377.      *
  1378.      * @param Callable  $varCallable
  1379.      *
  1380.      * @return array
  1381.      */
  1382.     protected function addItemsToTemplate(Template $objTemplate$varCallable null)
  1383.     {
  1384.         $taxIds   = array();
  1385.         $arrItems = array();
  1386.         foreach ($this->getItems($varCallable) as $objItem) {
  1387.             $item $this->generateItem($objItem);
  1388.             $taxIds[]   = $item['tax_id'];
  1389.             $arrItems[] = $item;
  1390.         }
  1391.         RowClass::withKey('rowClass')->addCount('row_')->addFirstLast('row_')->addEvenOdd('row_')->applyTo($arrItems);
  1392.         $objTemplate->items         $arrItems;
  1393.         $objTemplate->total_tax_ids = \count(array_unique($taxIds));
  1394.         return $arrItems;
  1395.     }
  1396.     /**
  1397.      * Generate item array for template
  1398.      *
  1399.      * @param ProductCollectionItem $objItem
  1400.      *
  1401.      * @return array
  1402.      */
  1403.     protected function generateItem(ProductCollectionItem $objItem)
  1404.     {
  1405.         $blnHasProduct $objItem->hasProduct();
  1406.         $objProduct    $objItem->getProduct();
  1407.         $objConfig     $this->getRelated('config_id') ?: Isotope::getConfig();
  1408.         $arrCSS        = ($blnHasProduct StringUtil::deserialize($objProduct->cssIDtrue) : array());
  1409.         // Set the active product for insert tags replacement
  1410.         if ($blnHasProduct) {
  1411.             Product::setActive($objProduct);
  1412.         }
  1413.         $arrItem = array(
  1414.             'id'                => $objItem->id,
  1415.             'sku'               => $objItem->getSku(),
  1416.             'name'              => $objItem->getName(),
  1417.             'options'           => Isotope::formatOptions($objItem->getOptions()),
  1418.             'configuration'     => $objItem->getConfiguration(),
  1419.             'attributes'        => $objItem->getAttributes(),
  1420.             'quantity'          => $objItem->quantity,
  1421.             'price'             => Isotope::formatPriceWithCurrency($objItem->getPrice(), true$objConfig->currency),
  1422.             'tax_free_price'    => Isotope::formatPriceWithCurrency($objItem->getTaxFreePrice(), true$objConfig->currency),
  1423.             'original_price'    => Isotope::formatPriceWithCurrency($objItem->getOriginalPrice(), true$objConfig->currency),
  1424.             'total'             => Isotope::formatPriceWithCurrency($objItem->getTotalPrice(), true$objConfig->currency),
  1425.             'tax_free_total'    => Isotope::formatPriceWithCurrency($objItem->getTaxFreeTotalPrice(), true$objConfig->currency),
  1426.             'original_total'    => Isotope::formatPriceWithCurrency($objItem->getTotalOriginalPrice(), true$objConfig->currency),
  1427.             'tax_id'            => $objItem->tax_id,
  1428.             'href'              => false,
  1429.             'hasProduct'        => $blnHasProduct,
  1430.             'product'           => $objProduct,
  1431.             'item'              => $objItem,
  1432.             'raw'               => $objItem->row(),
  1433.             'rowClass'          => trim('product ' . (($blnHasProduct && $objProduct->isNew()) ? 'new ' '') . ($arrCSS[1] ?? ''))
  1434.         );
  1435.         if ($blnHasProduct && null !== $objItem->getRelated('jumpTo') && $objProduct->isAvailableInFrontend()) {
  1436.             $arrItem['href'] = $objProduct->generateUrl($objItem->getRelated('jumpTo'));
  1437.         }
  1438.         Product::unsetActive();
  1439.         return $arrItem;
  1440.     }
  1441.     /**
  1442.      * Get a collection-specific error message for items with errors
  1443.      *
  1444.      * @return string
  1445.      */
  1446.     protected function getMessageIfErrorsInItems()
  1447.     {
  1448.         return $GLOBALS['TL_LANG']['ERR']['collectionErrorInItems'];
  1449.     }
  1450.     /**
  1451.      * Generate the next higher Document Number based on existing records
  1452.      *
  1453.      * @param string $strPrefix
  1454.      * @param int    $intDigits
  1455.      *
  1456.      * @return string
  1457.      * @throws \Exception
  1458.      */
  1459.     protected function generateDocumentNumber($strPrefix$intDigits)
  1460.     {
  1461.         if ($this->arrData['document_number'] != '') {
  1462.             return $this->arrData['document_number'];
  1463.         }
  1464.         // !HOOK: generate a custom order ID
  1465.         if (isset($GLOBALS['ISO_HOOKS']['generateDocumentNumber'])
  1466.             && \is_array($GLOBALS['ISO_HOOKS']['generateDocumentNumber'])
  1467.         ) {
  1468.             foreach ($GLOBALS['ISO_HOOKS']['generateDocumentNumber'] as $callback) {
  1469.                 $strOrderId  System::importStatic($callback[0])->{$callback[1]}($this$strPrefix$intDigits);
  1470.                 if ($strOrderId !== false) {
  1471.                     $this->arrData['document_number'] = $strOrderId;
  1472.                     break;
  1473.                 }
  1474.             }
  1475.         }
  1476.         try {
  1477.             if ($this->arrData['document_number'] == '') {
  1478.                 $strPrefix Controller::replaceInsertTags($strPrefixfalse);
  1479.                 $intPrefix utf8_strlen($strPrefix);
  1480.                 // Lock tables so no other order can get the same ID
  1481.                 Database::getInstance()->lockTables(array(static::$strTable => 'WRITE'));
  1482.                 $prefixCondition = ($strPrefix != '' " AND document_number LIKE '$strPrefix%'" '');
  1483.                 // Retrieve the highest available order ID
  1484.                 $objMax Database::getInstance()
  1485.                     ->prepare("
  1486.                         SELECT document_number
  1487.                         FROM tl_iso_product_collection
  1488.                         WHERE
  1489.                             type=?
  1490.                             $prefixCondition
  1491.                             AND store_id=?
  1492.                         ORDER BY CAST(" . ($strPrefix != '' 'SUBSTRING(document_number, ' . ($intPrefix 1) . ')' 'document_number') . ' AS UNSIGNED) DESC
  1493.                     ')
  1494.                     ->limit(1)
  1495.                     ->execute(
  1496.                         array_search(\get_called_class(), static::getModelTypes(), true),
  1497.                         $this->store_id
  1498.                     )
  1499.                 ;
  1500.                 $intMax = (int) substr($objMax->document_number$intPrefix);
  1501.                 $this->arrData['document_number'] = $strPrefix str_pad($intMax 1$intDigits'0'STR_PAD_LEFT);
  1502.             }
  1503.             Database::getInstance()
  1504.                 ->prepare('UPDATE tl_iso_product_collection SET document_number=? WHERE id=?')
  1505.                 ->execute($this->arrData['document_number'], $this->id)
  1506.             ;
  1507.             Database::getInstance()->unlockTables();
  1508.         } catch (\Exception $e) {
  1509.             // Make sure tables are always unlocked
  1510.             Database::getInstance()->unlockTables();
  1511.             throw $e;
  1512.         }
  1513.         return $this->arrData['document_number'];
  1514.     }
  1515.     /**
  1516.      * Generate a unique ID for this collection
  1517.      *
  1518.      * @return string
  1519.      */
  1520.     protected function generateUniqueId()
  1521.     {
  1522.         if (!empty($this->arrData['uniqid'])) {
  1523.             return $this->arrData['uniqid'];
  1524.         }
  1525.         return uniqid(''true);
  1526.     }
  1527.     /**
  1528.      * Prevent modifying a locked collection
  1529.      *
  1530.      * @throws \BadMethodCallException if the collection is locked.
  1531.      */
  1532.     protected function ensureNotLocked()
  1533.     {
  1534.         if ($this->isLocked()) {
  1535.             throw new \BadMethodCallException('Product collection is locked');
  1536.         }
  1537.     }
  1538.     /**
  1539.      * Make sure the addresses belong to this collection only, so they will never be modified
  1540.      *
  1541.      * @throws \UnderflowException if collection is not saved (not in DB)
  1542.      * @throws \BadMethodCallException if the product collection is locked.
  1543.      */
  1544.     protected function createPrivateAddresses()
  1545.     {
  1546.         $this->ensureNotLocked();
  1547.         if (!$this->id) {
  1548.             throw new \UnderflowException('Product collection must be saved before creating unique addresses.');
  1549.         }
  1550.         $canSkip StringUtil::deserialize($this->iso_checkout_skippabletrue);
  1551.         $objBillingAddress  $this->getBillingAddress();
  1552.         $objShippingAddress $this->getShippingAddress();
  1553.         // Store address in address book
  1554.         if ($this->iso_addToAddressbook && $this->member 0) {
  1555.             if (null !== $objBillingAddress
  1556.                 && $objBillingAddress->ptable != MemberModel::getTable()
  1557.                 && !\in_array('billing_address'$canSkiptrue)
  1558.             ) {
  1559.                 $objAddress         = clone $objBillingAddress;
  1560.                 $objAddress->pid    $this->member;
  1561.                 $objAddress->tstamp time();
  1562.                 $objAddress->ptable MemberModel::getTable();
  1563.                 $objAddress->store_id $this->store_id;
  1564.                 $objAddress->save();
  1565.                 $this->updateDefaultAddress($objAddress);
  1566.             }
  1567.             if (null !== $objBillingAddress
  1568.                 && null !== $objShippingAddress
  1569.                 && $objBillingAddress->id != $objShippingAddress->id
  1570.                 && $objShippingAddress->ptable != MemberModel::getTable()
  1571.                 && !\in_array('shipping_address'$canSkiptrue)
  1572.             ) {
  1573.                 $objAddress         = clone $objShippingAddress;
  1574.                 $objAddress->pid    $this->member;
  1575.                 $objAddress->tstamp time();
  1576.                 $objAddress->ptable MemberModel::getTable();
  1577.                 $objAddress->store_id $this->store_id;
  1578.                 $objAddress->save();
  1579.                 $this->updateDefaultAddress($objAddress);
  1580.             }
  1581.         }
  1582.         /** @var Config $config */
  1583.         $config         $this->getRelated('config_id');
  1584.         $billingFields  = (null === $config) ? array() : $config->getBillingFields();
  1585.         $shippingFields = (null === $config) ? array() : $config->getShippingFields();
  1586.         if (null !== $objBillingAddress
  1587.             && ($objBillingAddress->ptable != static::$strTable || $objBillingAddress->pid != $this->id)
  1588.         ) {
  1589.             $arrData array_intersect_key(
  1590.                 $objBillingAddress->row(),
  1591.                 array_merge(array_flip($billingFields), ['country' => ''])
  1592.             );
  1593.             $objNew = new Address();
  1594.             $objNew->setRow($arrData);
  1595.             $objNew->pid      $this->id;
  1596.             $objNew->tstamp   time();
  1597.             $objNew->ptable   = static::$strTable;
  1598.             $objNew->store_id $this->store_id;
  1599.             $objNew->save();
  1600.             $this->setBillingAddress($objNew);
  1601.             if (null !== $objShippingAddress && $objBillingAddress->id == $objShippingAddress->id) {
  1602.                 $this->setShippingAddress($objNew);
  1603.                 // Stop here, we do not need to check shipping address
  1604.                 return;
  1605.             }
  1606.         }
  1607.         if (null !== $objShippingAddress
  1608.             && ($objShippingAddress->ptable != static::$strTable || $objShippingAddress->pid != $this->id)
  1609.         ) {
  1610.             $arrData array_intersect_key(
  1611.                 $objShippingAddress->row(),
  1612.                 array_merge(array_flip($shippingFields), ['country' => ''])
  1613.             );
  1614.             $objNew = new Address();
  1615.             $objNew->setRow($arrData);
  1616.             $objNew->pid      $this->id;
  1617.             $objNew->tstamp   time();
  1618.             $objNew->ptable   = static::$strTable;
  1619.             $objNew->store_id $this->store_id;
  1620.             $objNew->save();
  1621.             $this->setShippingAddress($objNew);
  1622.         } elseif (null === $objShippingAddress) {
  1623.             // Make sure to set the shipping address to null if collection has no shipping
  1624.             // see isotope/core#2014
  1625.             $this->setShippingAddress(null);
  1626.         }
  1627.     }
  1628.     /**
  1629.      * Mark existing addresses as not default if the new address is default
  1630.      *
  1631.      * @param Address $objAddress
  1632.      */
  1633.     protected function updateDefaultAddress(Address $objAddress)
  1634.     {
  1635.         $arrSet = array();
  1636.         if ($objAddress->isDefaultBilling) {
  1637.             $arrSet['isDefaultBilling'] = '';
  1638.         }
  1639.         if ($objAddress->isDefaultShipping) {
  1640.             $arrSet['isDefaultShipping'] = '';
  1641.         }
  1642.         if (\count($arrSet) > 0) {
  1643.             Database::getInstance()
  1644.                 ->prepare('UPDATE tl_iso_address %s WHERE pid=? AND ptable=? AND store_id=? AND id!=?')
  1645.                 ->set($arrSet)
  1646.                 ->execute($this->memberMemberModel::getTable(), $this->store_id$objAddress->id)
  1647.             ;
  1648.         }
  1649.     }
  1650.     /**
  1651.      * Clear all cache properties
  1652.      */
  1653.     protected function clearCache()
  1654.     {
  1655.         $this->arrItems null;
  1656.         $this->arrSurcharges null;
  1657.         $this->arrCache null;
  1658.         $this->arrErrors = array();
  1659.         $this->objPayment false;
  1660.         $this->objShipping false;
  1661.     }
  1662.     /**
  1663.      * Initialize a new collection and duplicate everything from the source
  1664.      *
  1665.      * @param IsotopeProductCollection $objSource
  1666.      *
  1667.      * @return static
  1668.      */
  1669.     public static function createFromCollection(IsotopeProductCollection $objSource)
  1670.     {
  1671.         $objCollection = new static();
  1672.         $objConfig $objSource->getConfig();
  1673.         if (null === $objConfig) {
  1674.             $objConfig Isotope::getConfig();
  1675.         }
  1676.         $member $objSource->getMember();
  1677.         $objCollection->source_collection_id $objSource->getId();
  1678.         $objCollection->config_id            = (int) $objConfig->id;
  1679.         $objCollection->store_id             = (int) $objSource->getStoreId();
  1680.         $objCollection->member               = (null === $member $member->id);
  1681.         if ($objCollection instanceof IsotopeOrderableCollection
  1682.             && $objSource instanceof  IsotopeOrderableCollection)
  1683.         {
  1684.             $objCollection->setShippingMethod($objSource->getShippingMethod());
  1685.             $objCollection->setPaymentMethod($objSource->getPaymentMethod());
  1686.             $objCollection->setShippingAddress($objSource->getShippingAddress());
  1687.             $objCollection->setBillingAddress($objSource->getBillingAddress());
  1688.         }
  1689.         $arrItemIds $objCollection->copyItemsFrom($objSource);
  1690.         $objCollection->updateDatabase();
  1691.         // HOOK: order status has been updated
  1692.         if (isset($GLOBALS['ISO_HOOKS']['createFromProductCollection'])
  1693.             && \is_array($GLOBALS['ISO_HOOKS']['createFromProductCollection'])
  1694.         ) {
  1695.             foreach ($GLOBALS['ISO_HOOKS']['createFromProductCollection'] as $callback) {
  1696.                 System::importStatic($callback[0])->{$callback[1]}($objCollection$objSource$arrItemIds);
  1697.             }
  1698.         }
  1699.         return $objCollection;
  1700.     }
  1701.     /**
  1702.      * Method that returns a closure to sort product collection items
  1703.      *
  1704.      * @param string $strOrderBy
  1705.      *
  1706.      * @return \Closure|null
  1707.      */
  1708.     public static function getItemsSortingCallable($strOrderBy 'asc_id')
  1709.     {
  1710.         [$direction$attribute] = explode('_'$strOrderBy2) + [nullnull];
  1711.         if ('asc' === $direction) {
  1712.             return function ($arrItems) use ($attribute) {
  1713.                 uasort($arrItems, function ($objItem1$objItem2) use ($attribute) {
  1714.                     if ($objItem1->$attribute == $objItem2->$attribute) {
  1715.                         return 0;
  1716.                     }
  1717.                     return $objItem1->$attribute $objItem2->$attribute ? -1;
  1718.                 });
  1719.                 return $arrItems;
  1720.             };
  1721.         }
  1722.         if ('desc' === $direction) {
  1723.             return function ($arrItems) use ($attribute) {
  1724.                 uasort($arrItems, function ($objItem1$objItem2) use ($attribute) {
  1725.                     if ($objItem1->$attribute == $objItem2->$attribute) {
  1726.                         return 0;
  1727.                     }
  1728.                     return $objItem1->$attribute $objItem2->$attribute ? -1;
  1729.                 });
  1730.                 return $arrItems;
  1731.             };
  1732.         }
  1733.         return null;
  1734.     }
  1735.     /**
  1736.      * @param IsotopeProduct        $product
  1737.      * @param ProductCollectionItem $item
  1738.      * @param int                   $quantity
  1739.      */
  1740.     private function setProductForItem(IsotopeProduct $productProductCollectionItem $item$quantity)
  1741.     {
  1742.         $item->tstamp         time();
  1743.         $item->type           array_search(\get_class($product), Product::getModelTypes(), true);
  1744.         $item->product_id     = (int) $product->getId();
  1745.         $item->sku            = (string) $product->getSku();
  1746.         $item->name           = (string) $product->getName();
  1747.         $item->configuration  $product->getOptions();
  1748.         $item->quantity       = (int) $quantity;
  1749.         $item->price          = (float) ($product->getPrice($this) ? $product->getPrice($this)->getAmount((int) $quantity) : 0);
  1750.         $item->tax_free_price = (float) ($product->getPrice($this) ? $product->getPrice($this)->getNetAmount((int) $quantity) : 0);
  1751.     }
  1752.     /**
  1753.      * Check if product collection has tax
  1754.      *
  1755.      * @return bool
  1756.      */
  1757.     public function hasTax()
  1758.     {
  1759.         foreach ($this->getSurcharges() as $surcharge) {
  1760.             if ($surcharge instanceof Tax) {
  1761.                 return true;
  1762.             }
  1763.         }
  1764.         return false;
  1765.     }
  1766. }