diff --git a/api/IsotopeDatabaseHandler.php b/api/IsotopeDatabaseHandler.php
index 6fa17ae..0a491a9 100644
--- a/api/IsotopeDatabaseHandler.php
+++ b/api/IsotopeDatabaseHandler.php
@@ -71,8 +71,6 @@ class IsotopeDatabaseHandler
$sql =
"UPDATE `tl_iso_product` SET
tstamp = ".time().",
- name = '".$product->getDescription()."',
- description = '".$product->getExtraDescription()."',
shipping_weight = '$shippingWeight'
WHERE `id` = ".$productDbId;
$this->dbHandle->query($sql);
diff --git a/api/Product.php b/api/Product.php
index bdc5e5f..6ca9620 100644
--- a/api/Product.php
+++ b/api/Product.php
@@ -1,6 +1,6 @@
- * @date 12.07.22
- */
namespace App\EventListener;
+
use Contao\CoreBundle\ServiceAnnotation\Hook;
use Contao\FrontendTemplate;
use Contao\Module;
+use App\ExactApi\ApiExact;
+use Isotope\Model\Address;
+use Isotope\Model\Product;
+use Isotope\Model\ProductCollection\Order;
+use Isotope\Model\ProductCollectionItem;
class PostCheckoutListener
{
+ const WAREHOUSE_ID = 'd8a6a9b8-d0ac-4d36-8d00-bd420d0d81f5';
+
/**
* @Hook("postCheckout")
*/
- public function __invoke(int $userId, array $userData, Module $module): void
+ // public function __invoke(int $userId, array $userData, Module $module): void
+ public function __invoke(Order $order, $item): void
+ {
+ $apiExact = new ApiExact();
+
+ //ob_start();
+ $salesOrderItems = [];
+ /** @var ProductCollectionItem $orderItem */
+ foreach($order->getItems() as $orderItem) {
+ $salesOrderItems[] = $this->createOrderItem($orderItem);
+// /** @var Product $orderItem */
+// $product = $orderItem->getProduct();
+// var_dump($orderItem->id);
+// var_dump($orderItem->sku);
+// var_dump($orderItem->quantity);
+// var_dump($orderItem->tax_free_price);
+//
+// var_dump($product->id);
+// var_dump($product->name);
+// var_dump($product->is_set);
+
+ }
+ //var_dump($apiExact->test());
+// file_put_contents('dan.txt', ob_get_contents());
+// ob_end_clean();
+
+
+
+ $shippingCustomer = null;
+// if ($this->compareAddresses() === false) {
+// $shippingCustomer = $apiExact->createCustomer(
+// $this->createCustomerApiData($order->getShippingAddress())
+// );
+// }
+//
+ $billingCustomer = $apiExact->createCustomer(
+ $this->createCustomerApiData($order->getBillingAddress())
+ );
+ $apiExact->createSalesOrder($this->createSalesOrderData($order, $billingCustomer, $shippingCustomer));
+
+ }
+
+ private function compareAddresses(Order $order)
{
- throw new \Exception('ARRRRRRRGGGGGGGGGG');
+ return
+ $order->getBillingAddress()->firstname === $order->getShippingAddress()->firstname &&
+ $order->getBillingAddress()->lastname === $order->getShippingAddress()->lastname &&
+ $order->getBillingAddress()->street_1 === $order->getShippingAddress()->street_1 &&
+ $order->getBillingAddress()->postal === $order->getShippingAddress()->postal &&
+ $order->getBillingAddress()->city === $order->getShippingAddress()->city &&
+ $order->getBillingAddress()->company === $order->getShippingAddress()->company &&
+ $order->getBillingAddress()->phone === $order->getShippingAddress()->phone &&
+ $order->getBillingAddress()->email === $order->getShippingAddress()->email;
+ }
+
+ private function createCustomerApiData(Address $address)
+ {
+ return [
+ 'Name' => $address->firstname . ' ' . $address->lastname,
+ 'AddressLine1' => $address->street_1,
+ 'AddressLine2' => $address->company,
+ 'City' => $address->city,
+ 'Postcode' => $address->postal,
+ 'Email' => $address->email,
+ 'Country' => 'DE',
+ 'Phone' => $address->phone,
+ 'Status' => 'C',
+ ];
+ }
+
+ private function createOrderItem(ProductCollectionItem $orderItem)
+ {
+ /** @var Product $orderItem */
+ $product = $orderItem->getProduct();
+ return [
+ 'Description' => $product->name,
+ 'Item' => $product->exact_id,
+ 'UnitPrice' => (float) $orderItem->tax_free_price / (int) $orderItem->quantity,
+ 'Quantity' => $orderItem->quantity
+ ];
+ }
+
+ private function createSalesOrderData(Order $order, $billingCustomer, $salesOrderItems, $shippingCustomer = null)
+ {
+ $date = new \DateTime('now');
+ $res = [
+ 'Description' => $billingCustomer['Name'],
+ 'OrderDate' => $date->format('m/d/Y H:i:s'),
+ 'OrderedBy' => $billingCustomer['ID'],
+ 'WarehouseID' => self::WAREHOUSE_ID,
+ 'SalesOrderLines' => [
+ $salesOrderItems
+ ]
+ ];
+ if ($shippingCustomer !== null) {
+ $res['DeliverTo'] = $shippingCustomer['ID'];
+ }
+
+ return $res;
}
}
\ No newline at end of file
diff --git a/api/listener/tokenData b/api/listener/tokenData
new file mode 100644
index 0000000..f708ca3
--- /dev/null
+++ b/api/listener/tokenData
@@ -0,0 +1 @@
+{"access_token":"stampDE001.gAAAADRG83iY-fhuf-2rSWvPzCjE4DL5hlcUcyDpkEk4rP6B5FF9PnD3qKjveOit6vWjnxeM0UzXu1kUOX0cR0AV0jf16XSQy_-3nQ6XsTx5wIucuNmT52WpjP7-LU2E3TzSvjVilP-ufMtgTLnmuLvtC7ALRoAcoiNrHuU6sKACxco0VAIAAIAAAABYJx_THjmOsX2jvj-lBpx0NXCBxY4506xGpS8bmvY0JoYWsebqFuORCSupfTU3VebYy-Vn_oyv7nUkBwPpsmLUVmJWJB-VX5X9dTj23-tJMYkUxjNzvdrbbTdnwuNbIFYws_5bwsJ-1NiP9nZJYllsGstqbvUyfGmsnXrBSyX6eujzHH7eWOEfL7wHIfVbl2Eg2HwP1w3vMzUgG9h-P7OCw69-EpsfJVaOKYWL_F39zic6yBf0oWDSEBvsbIeL2o4KdkCSJD0NY0bqGViDfd_vJVfXP9lossW9fu-rtQNehko5GSkEybe4x30kDbUZHqB_LhL8a5_b9zqPF79-iDxN_vuY_FDCopvBBNUjcyeDGZcn_GDpzqEhK4BxIqJFWlw2umG4qd61OB3WkSXv952cPKBsVQJWoZWrudQymHa6WLy27DXNoyS3WJiO2VnyZybszl0Mk1G6_ZHso4PmgEsOdM2RyrsGaN7_pOoIyOG7c2ZlBLJTM_q_lDVVaAfPrNcrBon2UTFDqUtexlrTpt50nEjBUlxOyrDva1KOGyfHrHdMGEpetr6YoEmAbcMM6jId72OHLy2ztfoYL91kzXScnEAJB3PL3-btD85hU8rBaFYAgZemoPY0T4g6ZqBcYLXoMDT5ltW2McI74u819Q1V7ZrAPkVB2oICaFppnKzOe9ot7DChNk5nrNFUaK49hosQpPtTUzPyfPb2oX3Z5GhjHe2X4b6wT4DiSORY6gtu7ZdPyJeDWnoyCE_c1m9PLj_v4i6cyfWMJHbIaCHLuZGU","token_type":"bearer","expires_in":"600","refresh_token":"stampDE001.vhK0!IAAAAG4XGo_RV3d60tiy1gRnVAQrHp9xnEP4_CuBLQ_D5Z0C8QEAAAFnedzeXXOKto6NMohmWMQOWm7Qptgu2o0z6LYlWxTRWf9IPpRYC-fa5XigKwDbB5lquTf8gj90Fg0YkuniSwf9jIBUjBOzul0ZdAPWdolHZLAQtHQr3a53ylG4d7yLyrQCMctPl8yUHNWk1e5PH4YCOZH9lKTW7qWlt1SJAfLcgPa2F0I-9XnS-n-AZK7bxpCL4coztVGCA6vP9igYofpArxYc7W-9rNbzTOyF-fxp_hXzTtCaPetA5Zpg8v-4sx3Mvx1AY_NscwCcG-V1mJnolxlLowgL8fBeUdv9WrWrx5uHVqcKyhAYCB7BR_QqZfgl5mlv_62hbK2s_HKNcQsG591qymyG5KYjOlpSlKyEe3h-hGYaP3PaD-xiUx87C9Z3iRPKWE79i_9B6H4rCcltIJARKp3UGUJbJtYikzeexB7wCM3uHkcljXziwXkBO9PIVWTJhsauv7BTSAmyODuuSSbL1PTGHPIJJdRCQUn8U0xOlSvWWngpLD9CenpAiMRt8-uFfC3zAOBxIHZ5FNDuNlcvKhgEG0Q-94qFySKFt1J5xCUCKpKZ1VQyDz33uylXzb30SoGLmAIPVPhMFdJo08xIAFZaPRQKsvqOEj7M4t3KeGYkehyAv7ugQcJrlWo5TD9kYlHA1CRZs5YiK2a7","expiry_time":1657816808}
\ No newline at end of file
diff --git a/api/tokenData b/api/tokenData
index 67a3864..c674512 100644
--- a/api/tokenData
+++ b/api/tokenData
@@ -1 +1 @@
-{"access_token":"stampDE001.gAAAAKLeomGUJnO_59CX7KmwE4tkt1bPxLR7EfU5E2eHjj6LeU2dAPfkRstKdlJJoudf3y6ehyKwF5wQQ1SljbA5sRrGwQsw4M876o0RRtxVHwyR6_QSoM7kP3qj7HfJLEDWjY-KrlJf_rlKtZZ2sRGJkX9jbd-4XDA_uqQECkf2NnIfVAIAAIAAAAC2ADvWhSjIjtyvs4_rhH9HzqaVKUbQthUoKf6StMSX4O5GzilHhM2cQCQJly_7hbpKHJMe4o167cPYIQLTeOilQdlQh21nhGykWfOKtXG31h210WtT2bg8Kc0MtrHQGi_f161wE_xINPXr-H7GTcXLe4klIGUkv0kmmmUacNoDUDfU1fPRTCCFvmkRnE7iBzd116aY9XMVw6mDucFExI9U-dLOD8IFWCPa9U6DSd50T7WoglDHC8Enh-J92C1c8kAhCCiGyA5GHyb9S1eJAnHFDYzOl9y0pwtIHekBJq-LxpzAbVieBUZrXEsTJnD7eiHvKoy9g_Bl-jOVgPTJT29n0dFDUClE_iQ6MGydAiEVKoyhQO20W7EJg64G8l6ZHZrd8r1J36TZ4jmdZ8E8_wqw3MzYGZm4vEXK4G0Lf2-j5uD0eh3aKPQCCdn_jEiCaEABQ-bsvd9sQazNNHbDwzl82GZdlhY8sqln14NpNloahOxhY9-jKu5nT9IBN2EftT-VoLNWp_E4ydPltM_7SS0Xro9j8nf7xNjpBDa__GeR2QSg_He5VR6u2n4lUbU1Qzks2W38RiCA3jXrQ-T6FCtiiaf5rhMzudqeq2c94Ggbn1g7BD_3S9xUFeA5kmSCkn1NZ3RzSqmkMq4IVPcbsJFYeoCiuZ1gYcvSOoBMHN8mjZ8j3xpPEvzJSX39arD5IeNqrLZDXuEU5aORiRnqjN6kPC4w1GQbbMsEL94r8tYGy8VkW7bstxkar7vb2J8vOPhLamN0mcxJdTZCCJ8tj18e","token_type":"bearer","expires_in":"600","refresh_token":"stampDE001.vhK0!IAAAANKu8TC2-MBN5wFyro7uwanpxXBsRFy7YkYSPaM9jMCm8QEAAAEVEJUYfzoc3Q1hatgTMAOm__pmwjTgdkOyzb7qUj7oRvir1XftD_cb5eE7J-06Q0SxWegFPfULR65PDc6Id0pDXZbugqaKhK24Gg9Gr0x74g4EW8HMYJ0PsW1uqvpOb_ph9mQxYllDQf598_uPZvuvxAMN4NOipKiHEXn9tExbn-rmJdrV9bwa3fAm5b2ALbzfBEKCuzuwLkrjH3cheaMC2TaksJ6M2we9IeAezapsyOVEl6QtnhY67L3Irp_bUV_SCZ6H-gbPDX3mFujxyw-lvRoC-ZcgIpM8EuWC3pN24abFp7Qaj8wE2X9_oAgvoJgn42lHl-x6M-QChdf78f6wiCPquk3OwwuBYsjmiJrHFQt4a6nRS-Y_A-J8IOdwq-_KZkvNTY5UI1ZmiGH5pvIDfaXTcvPn6r3C-sQecdImJM43Y_tgZ9a6p2gwPi8zNZSkXfwQH5bfal6tBcH43_8eGSp_M-VbLb7TotZfJgEIePSlZ--VV1aTaRMJ78_JBloFWvQabvhYEvvuvRaePbdGRgXzj95KgQeW8DzBXE6_zHSmhwkUEv11xgXimH3BFToSV_F25ZND7H3OkVM1-M8Jhbvrzh4SR5wRV5WfzC83Mk58HmDtI7xR0owYAu-9mtidFsK8mRC3nJzZSOWdGenZ","expiry_time":1657726518}
\ No newline at end of file
+{"access_token":"stampDE001.gAAAAATOqLu_FvySDkT1YxBYQOSbyTqVC-jQWfCBmfomFxKEuiSUp2H5XYFwHOjuZ_cbURYObp_CuoijjuXPHYAvgR5GjxZVLpnI33116cIBqeKkdnQ2617l-DfjsiPlSO9ilA5CtrV75A1ZhxtWJqJ5uZynH0eT0ZB8LytI81Fg2p4tVAIAAIAAAAAqO6Xcq6ab64TEJDE2QyPHpfueoxrCkLlFVkxbiXgoANGrrSTu9qQtOTYyszkuc3sGZSdg1zD8eJLx8sL0zlTy7BYRYgTmiUhLZhECjG_7PfpObMuaz_Q4NOlIeyGOX23B6p-lbSZ_HHcVx1XzDgSry7MSIcFZGIqUWqr4jWW6EDqU0Q9A1ypbkKAPxh77V68LrHAzIm8yjoQell4GBnNSVOwNWKcLgmqkznUIsywr-Kq6dvfs_Ziy0nu5DR6PbpYLIVY4g1eCE4ncfgtPYsndKKuSIOisf8pYn_7NaLfSxvYWVwFxuTHw3roRufszbE-toAsUzfBVv2mQjKdyop9xRri3Adh3vfsEAKEyksZdsduCjFEPu6KoSv_pgQfExSURkHKj0gOX8_GjPtcbT4OWZ3JuZ7n9-4U5uSV-HHOvd7T2ojgQ0lFcVukuwHq086KTMNGzcBPfM-_vcBYAx1nRRpupQL2Np0fm85iycZwycSKAFydpcMeB1VaP5PYkVLiLOsXBPy4SMM0_-c51NykUm338m8SwAVeVQXN3VYcT0kJwUsyMdOtYoDvXUL-5p_NafKwwPwdrgpIwnjjO3s37O43RsGWaY8kp42y2I4lQbMwcqTde_bOcPCRQWium4OB__fkFaiCJ5eE5EGcbPUVzjEfW_Lhr8yrkRacFfut9YqCTVuGxZepi0eyX3FHk_-qAtXTBav11EZ40G5slokVlyPu63pQfhpSrsNIGZv-j5YIsN5MqjHn7HgtbS0UMRUAQERHvDkz6ddfbkqwjOgYE","token_type":"bearer","expires_in":"600","refresh_token":"stampDE001.vhK0!IAAAAIMZSdkYxeTLXpkXHeNZCQAgV_DZ3JM_sqN9Y81kYggu8QEAAAE1Ecbn6_tJNnyZQn4ktg02ypEuBII4NQc3bgVgWB1lM0IZaxRyKB5C9AVOCqh9ewKECN3eNMP0C0P2QVzNyoBsOHrK_QHqqypFPBfs2fPG2psKUngqf4nTWfGWpjnaeevGqll88v4_Cka58o-Kmlrv2Ho_uc3Tjl4gE6GeLF870gyWnW1Qx3AJUTx4730yswUr1YwEODVTl0WBSJ2LEqELKvx4MQ603b642QkevFCrH6ttZBef-ufEKcRpT7Fic9JHcSdUEJCGrhsGe__Ah0dS2WtxNfhD3m7YgGpxBqSjCJXWvXzBpBd9UsZ6vCuF0CP2EVvSZ_HS3iTgD5UOBDoQkTn8kt65TixrpQWZE5j2-vfgoQy9CbPv8aO75XT4gZGPtjij7GAdXNc17ZH-P_3N5CFmLnycGyfPNdnS58PP4nkYcl6LzVF5pBOBj2mIZa9Ic7W1fcSWDePaGC_WjVQvuV18tjvsMxp738XNTPdZfta5V769cSq1tuRblIyLYIJhhYhmprqg1KADYkcNC7RHYSVOHbds83VORW9gvgjFLCWt15TLhJaGbydgaMMUGHlZ4gb8YFMCzbRiq_20_K8iP_J5BdP2omIeHCNnH6360wmJybbgcFlYv0FZNDXUwkRxaTsREpfSvDUzZHCsk0Od","expiry_time":1657818416}
\ No newline at end of file
diff --git a/api2/Address.php b/api2/Address.php
new file mode 100644
index 0000000..d9b5e6c
--- /dev/null
+++ b/api2/Address.php
@@ -0,0 +1,441 @@
+generate();
+ } catch (\Exception $e) {
+ return '';
+ }
+ }
+
+ /**
+ * Check if the address has a valid VAT number
+ *
+ * @param Config $config
+ *
+ * @return bool
+ *
+ * @throws \LogicException if a validator does not implement the correct interface
+ * @throws \RuntimeException if a validators reports an error about the VAT number
+ */
+ public function hasValidVatNo(Config $config = null)
+ {
+ if (null === $config) {
+ $config = Isotope::getConfig();
+ }
+
+ $validators = StringUtil::deserialize($config->vatNoValidators);
+
+ // if no validators are enabled, the VAT No is always valid
+ if (!\is_array($validators) || 0 === \count($validators)) {
+ return true;
+ }
+
+ foreach ($validators as $class) {
+ $service = new $class();
+
+ if (!($service instanceof IsotopeVatNoValidator)) {
+ throw new \LogicException($class . ' does not implement IsotopeVatNoValidator interface');
+ }
+
+ $result = $service->validate($this);
+
+ if (true === $result) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Return formatted address (hCard)
+ *
+ * @param array $arrFields
+ *
+ * @return string
+ *
+ * @throws \Exception on error parsing simple tokens
+ */
+ public function generate($arrFields = null)
+ {
+ // We need a country to format the address, use default country if none is available
+ $strCountry = $this->country ?: Isotope::getConfig()->country;
+
+ // Use generic format if no country specific format is available
+ $strFormat = $GLOBALS['ISO_ADR'][$strCountry] ?? $GLOBALS['ISO_ADR']['generic'];
+
+ $arrTokens = $this->getTokens($arrFields);
+
+ return StringUtil::parseSimpleTokens($strFormat, $arrTokens);
+ }
+
+ /**
+ * Return this address formatted as text
+ *
+ * @param array $arrFields
+ *
+ * @return string
+ *
+ * @deprecated use Address::generate() and strip_tags
+ * @throws \Exception on invalid simple tokens
+ */
+ public function generateText($arrFields = null)
+ {
+ return strip_tags($this->generate($arrFields));
+ }
+
+ /**
+ * Return an address formatted with HTML (hCard)
+ *
+ * @param array $arrFields
+ *
+ * @return string
+ *
+ * @deprecated use Address::generate()
+ * @throws \Exception on invalid simple tokens
+ */
+ public function generateHtml($arrFields = null)
+ {
+ return $this->generate($arrFields);
+ }
+
+ /**
+ * Compile the list of hCard tokens for this address
+ *
+ * @param array $arrFields
+ *
+ * @return array
+ */
+ public function getTokens($arrFields = null)
+ {
+ if (!\is_array($arrFields)) {
+ $arrFields = Isotope::getConfig()->getBillingFieldsConfig();
+ }
+
+ $arrTokens = array('outputFormat' => 'html');
+
+ foreach ($arrFields as $arrField) {
+ $strField = $arrField['value'];
+
+ // Set an empty value for disabled fields, otherwise the token would not be replaced
+ if (!$arrField['enabled']) {
+ $arrTokens[$strField] = '';
+ continue;
+ }
+
+ if ('subdivision' === $strField && $this->subdivision != '') {
+ [$country, $subdivision] = explode('-', $this->subdivision);
+
+ $arrTokens['subdivision_abbr'] = $subdivision;
+ $arrTokens['subdivision'] = Backend::getLabelForSubdivision($country, $this->subdivision);
+
+ continue;
+ }
+
+ $arrTokens[$strField] = Format::dcaValue(static::$strTable, $strField, $this->$strField);
+ }
+
+
+ /**
+ * Generate hCard fields
+ * See http://microformats.org/wiki/hcard
+ */
+
+ // Set "fn" (full name) to company if no first- and lastname is given
+ if ($arrTokens['company'] != '') {
+ $fn = $arrTokens['company'];
+ $fnCompany = ' fn';
+ } else {
+ $fn = trim($arrTokens['firstname'] . ' ' . $arrTokens['lastname']);
+ $fnCompany = '';
+ }
+
+ $street = implode('
', array_filter([$this->street_1, $this->street_2, $this->street_3]));
+
+ $arrTokens += [
+ 'hcard_fn' => $fn ? '' . $fn . '' : '',
+ 'hcard_n' => ($arrTokens['firstname'] || $arrTokens['lastname']) ? '1' : '',
+ 'hcard_honorific_prefix' => $arrTokens['salutation'] ? '' . $arrTokens['salutation'] . '' : '',
+ 'hcard_given_name' => $arrTokens['firstname'] ? '' . $arrTokens['firstname'] . '' : '',
+ 'hcard_family_name' => $arrTokens['lastname'] ? '' . $arrTokens['lastname'] . '' : '',
+ 'hcard_org' => $arrTokens['company'] ? '
' . $arrTokens['company'] . '
' : '',
+ 'hcard_email' => $arrTokens['email'] ? '' . $arrTokens['email'] . '' : '',
+ 'hcard_tel' => $arrTokens['phone'] ? '' . $arrTokens['phone'] . '
' : '',
+ 'hcard_adr' => ($street || $arrTokens['city'] || $arrTokens['postal'] || $arrTokens['subdivision'] || $arrTokens['country']) ? '1' : '',
+ 'hcard_street_address' => $street ? '' . $street . '
' : '',
+ 'hcard_locality' => $arrTokens['city'] ? '' . $arrTokens['city'] . '' : '',
+ 'hcard_region' => $arrTokens['subdivision'] ? '' . $arrTokens['subdivision'] . '' : '',
+ 'hcard_region_abbr' => !empty($arrTokens['subdivision_abbr']) ? '' . $arrTokens['subdivision_abbr'] . '' : '',
+ 'hcard_postal_code' => $arrTokens['postal'] ? '' . $arrTokens['postal'] . '' : '',
+ 'hcard_country_name' => $arrTokens['country'] ? '' . $arrTokens['country'] . '
' : '',
+ ];
+
+ return $arrTokens;
+ }
+
+ /**
+ * Find address for member, automatically checking the current store ID and tl_member parent table
+ *
+ * @param int $intMember
+ * @param array $arrOptions
+ *
+ * @return \Model\Collection|null
+ */
+ public static function findForMember($intMember, array $arrOptions = array())
+ {
+ return static::findBy(
+ array('pid=?', 'ptable=?', 'store_id=?'),
+ array($intMember, 'tl_member', Isotope::getCart()->store_id),
+ $arrOptions
+ );
+ }
+
+ /**
+ * Find address by ID and member, automatically checking the current store ID and tl_member parent table
+ *
+ * @param int $intId
+ * @param int $intMember
+ * @param array $arrOptions
+ *
+ * @return Address|null
+ */
+ public static function findOneForMember($intId, $intMember, array $arrOptions = array())
+ {
+ return static::findOneBy(
+ array('id=?', 'pid=?', 'ptable=?', 'store_id=?'),
+ array($intId, $intMember, 'tl_member', Isotope::getCart()->store_id),
+ $arrOptions
+ );
+ }
+
+ /**
+ * Find default billing adddress for a member, automatically checking the current store ID and tl_member parent table
+ * @param int
+ * @param array
+ * @return static|null
+ */
+ public static function findDefaultBillingForMember($intMember, array $arrOptions = array())
+ {
+ return static::findOneBy(
+ array('pid=?', 'ptable=?', 'store_id=?', 'isDefaultBilling=?'),
+ array($intMember, 'tl_member', Isotope::getCart()->store_id, '1'),
+ $arrOptions
+ );
+ }
+
+ /**
+ * Find default shipping adddress for a member, automatically checking the current store ID and tl_member parent table
+ * @param int
+ * @param array
+ * @return static|null
+ */
+ public static function findDefaultShippingForMember($intMember, array $arrOptions = array())
+ {
+ return static::findOneBy(array('pid=?', 'ptable=?', 'store_id=?', 'isDefaultShipping=?'), array($intMember, 'tl_member', Isotope::getCart()->store_id, '1'), $arrOptions);
+ }
+
+ /**
+ * Find default billing address for a product collection
+ *
+ * @param int $intCollection
+ * @param array $arrOptions
+ *
+ * @return static|null
+ */
+ public static function findDefaultBillingForProductCollection($intCollection, array $arrOptions = array())
+ {
+ return static::findOneBy(
+ array('pid=?', 'ptable=?', 'isDefaultBilling=?'),
+ array($intCollection, 'tl_iso_product_collection', '1'),
+ $arrOptions
+ );
+ }
+
+ /**
+ * Find default shipping address for a product collection
+ *
+ * @param int $intCollection
+ * @param array $arrOptions
+ *
+ * @return static|null
+ */
+ public static function findDefaultShippingForProductCollection($intCollection, array $arrOptions = array())
+ {
+ return static::findOneBy(
+ array('pid=?', 'ptable=?', 'isDefaultShipping=?'),
+ array($intCollection, 'tl_iso_product_collection', '1'),
+ $arrOptions
+ );
+ }
+
+ /**
+ * Create a new address for a member and automatically set default properties
+ *
+ * @param int $intMember
+ * @param array|null $arrFill
+ *
+ * @return static
+ */
+ public static function createForMember($intMember, $arrFill = null)
+ {
+ $objAddress = new static();
+
+ $arrData = array(
+ 'pid' => $intMember,
+ 'ptable' => 'tl_member',
+ 'tstamp' => time(),
+ 'store_id' => (int) Isotope::getCart()->store_id,
+ );
+
+ if (!empty($arrFill) && \is_array($arrFill) && ($objMember = MemberModel::findByPk($intMember)) !== null) {
+ $arrData = array_merge(static::getAddressDataForMember($objMember, $arrFill), $arrData);
+ }
+
+ $objAddress->setRow($arrData);
+
+ return $objAddress;
+ }
+
+ /**
+ * Create a new address for a product collection
+ *
+ * @param IsotopeProductCollection $objCollection
+ * @param array|null $arrFill an array of member fields to inherit
+ * @param bool $blnDefaultBilling
+ * @param bool $blnDefaultShipping
+ *
+ * @return static
+ */
+ public static function createForProductCollection(
+ IsotopeProductCollection $objCollection,
+ $arrFill = null,
+ $blnDefaultBilling = false,
+ $blnDefaultShipping = false
+ ) {
+ $objAddress = new static();
+
+ $arrData = array(
+ 'pid' => $objCollection->getId(),
+ 'ptable' => 'tl_iso_product_collection',
+ 'tstamp' => time(),
+ 'store_id' => $objCollection->getStoreId(),
+ 'isDefaultBilling' => $blnDefaultBilling ? '1' : '',
+ 'isDefaultShipping' => $blnDefaultShipping ? '1' : '',
+ );
+
+ if (!empty($arrFill) && \is_array($arrFill) && ($objMember = $objCollection->getMember()) !== null) {
+ $arrData = array_merge(static::getAddressDataForMember($objMember, $arrFill), $arrData);
+ }
+
+ if (empty($arrData['country']) && null !== ($objConfig = $objCollection->getConfig())) {
+ if ($blnDefaultBilling) {
+ $arrData['country'] = $objConfig->billing_country ?: $objConfig->country;
+ } elseif ($blnDefaultShipping) {
+ $arrData['country'] = $objConfig->shipping_country ?: $objConfig->country;
+ }
+ }
+
+ $objAddress->setRow($arrData);
+
+ return $objAddress;
+ }
+
+ /**
+ * Generate address data from tl_member, limit to fields enabled in the shop configuration
+ */
+ public static function getAddressDataForMember(MemberModel $member, array $fields)
+ {
+ return array_intersect_key(
+ array_merge(
+ $member->row(),
+ array(
+ 'street_1' => $member->street,
+
+ // Trying to guess subdivision by country and state
+ 'subdivision' => strtoupper($member->country . '-' . $member->state)
+ )
+ ),
+ array_flip($fields)
+ );
+ }
+}
diff --git a/api2/ApiExact.php b/api2/ApiExact.php
new file mode 100644
index 0000000..92852cd
--- /dev/null
+++ b/api2/ApiExact.php
@@ -0,0 +1,178 @@
+expiry_time < time()) {
+ $postData = array(
+ 'grant_type' => 'refresh_token',
+ 'refresh_token' => $tokenData->refresh_token,
+ 'client_id' => self::CLIENT_ID,
+ 'client_secret' => self::CLIENT_SECRET
+ );
+
+ $ch = curl_init();
+ curl_setopt_array($ch, array(
+ CURLOPT_URL => self::API_URL . '/oauth2/token',
+ CURLOPT_RETURNTRANSFER => TRUE,
+ CURLOPT_HTTPHEADER => array('Content-Type: application/x-www-form-urlencoded'),
+ CURLOPT_POSTFIELDS => http_build_query($postData)
+ ));
+
+ $response = curl_exec($ch);
+ curl_close($ch);
+ $jsonResult = json_decode($response);
+
+ if (property_exists($jsonResult, 'error')) {
+ throw new \Exception($jsonResult);
+ }
+ $jsonResult->expiry_time = time() + self::ACCESS_TOKEN_VALID_DURATION;
+ file_put_contents('./tokenData', json_encode($jsonResult));
+
+ $tokenData = json_decode(file_get_contents('./tokenData', true));
+ }
+ return $tokenData->access_token;
+ }
+
+ public function getProducts()
+ {
+ $baseUrl = self::API_URL_WNR . "/bulk/Logistics/Items?";
+ $fields = [
+ 'ID',
+ 'StandardSalesPrice',
+ 'SalesVatCode',
+ 'SalesVatCodeDescription',
+ 'Description',
+ 'ExtraDescription',
+ 'IsWebshopItem',
+ 'ItemGroup',
+ 'ItemGroupCode',
+ 'Stock',
+ 'NetWeight',
+ 'GrossWeight',
+ 'Code'
+ ];
+ $filter = "\$filter=ItemGroup eq guid'".self::ITEM_GROUP_UUID."'";
+ $select = "&\$select=".$this->getFieldString($fields);
+ $requestUrl = $baseUrl . $filter . $select;
+ $response = $this->getApiData($requestUrl);
+ $responseArr = json_decode($response, 1)['d']['results'];
+
+ $res = [];
+ foreach ($responseArr as $productItem) {
+ if (
+ array_key_exists('IsWebshopItem', $productItem) &&
+ $productItem['IsWebshopItem'] === 1 &&
+ array_key_exists('Code', $productItem) &&
+ stripos(strtoupper($productItem['Code']), self::PRODUCT_CODE_PREFIX) === 0
+ ) {
+ $res[] = Product::createProductFromApiItem($productItem);
+ }
+ }
+ return $res;
+ }
+
+ public function createCustomer($customerData)
+ {
+ $url = self::API_URL_WNR . "/crm/Accounts";
+ return json_decode($this->postApiData($url, $customerData));
+ }
+
+ public function createSalesOrder($salesOrderData)
+ {
+ $url = self::API_URL_WNR . "/salesorder/SalesOrders";
+// $parameters = [
+// 'Description' => 'Daniel Knudsen',
+// 'OrderDate' => '07/13/2022 17:00:00',
+// 'OrderedBy' => '9ba706a3-d6f5-40c8-8d4f-e55a37692cef',
+// 'WarehouseID' => 'd8a6a9b8-d0ac-4d36-8d00-bd420d0d81f5',
+// 'SalesOrderLines' => [
+// [
+// 'Description' => 'TEST: Reinigungstuch grün',
+// 'Item' => '9dcd8b50-5de6-4f15-a51a-c51282292383',
+// 'UnitPrice' => '2.45',
+// 'Quantity' => '3'
+// ],
+// [
+// 'Description' => 'TEST: McQuade´s E-Bike Kettenrückführ - Tool',
+// 'Item' => '0e04113f-1299-4d3c-b65e-01e83ca5b3b2',
+// 'UnitPrice' => '476.00',
+// 'Quantity' => '1'
+// ],
+// ]
+// ];
+ return json_decode($this->postApiData($url, $salesOrderData));
+ }
+
+ private function getApiData($url)
+ {
+ $ch = curl_init();
+ curl_setopt_array($ch, array(
+ CURLOPT_URL => $url,
+ CURLOPT_HTTPHEADER => array('Accept: application/json' , "Authorization: Bearer " . $this->getAccessToken() ),
+ CURLOPT_RETURNTRANSFER => TRUE,
+ ));
+ $res = curl_exec($ch);
+ curl_close($ch);
+ return $res;
+ }
+
+ private function postApiData($url, $data)
+ {
+ $ch = curl_init();
+ curl_setopt_array($ch, array(
+ CURLOPT_URL => $url,
+ CURLOPT_HTTPHEADER => array(
+ 'Content-Type: application/json',
+ 'Accept: application/json',
+ "Authorization: Bearer " . $this->getAccessToken() ),
+ CURLOPT_RETURNTRANSFER => TRUE,
+ CURLOPT_POST => TRUE,
+ CURLOPT_POSTFIELDS => json_encode($data)
+ ));
+ $res = curl_exec($ch);
+ curl_close($ch);
+ return $res;
+ }
+
+ private function getFieldString($fields)
+ {
+ $res = '';
+ $cnt = count($fields);
+ $i = 1;
+ foreach ($fields as $field) {
+ $res .= $cnt !== $i ? $field . ',' : $field;
+ $i++;
+ }
+ return $res;
+ }
+
+ public function test()
+ {
+ return 'fuck off contao';
+ }
+}
\ No newline at end of file
diff --git a/api2/IsotopeDatabaseHandler.php b/api2/IsotopeDatabaseHandler.php
new file mode 100644
index 0000000..559065c
--- /dev/null
+++ b/api2/IsotopeDatabaseHandler.php
@@ -0,0 +1,197 @@
+dbHandle = new PDO('mysql:host='.self::HOST.';dbname='.self::DB_NAME.';charset=utf8',self::USER, self::PASSWORD);
+ $this->dbHandle->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
+
+ } catch (PDOException $e) {
+ print "Error!: " . $e->getMessage() . "
";
+ die();
+ }
+ }
+
+ public function processProducts(array $products)
+ {
+ $this->dbHandle->beginTransaction();
+
+ $sql = "SELECT * FROM `tl_iso_product`";
+ $stmt = $this->dbHandle->query($sql);
+ $dbProducts = $stmt->fetchAll(PDO::FETCH_ASSOC);
+
+ $sql = "SELECT * FROM `tl_iso_product_price`";
+ $stmt = $this->dbHandle->query($sql);
+ $dbProductPrices = $stmt->fetchAll(PDO::FETCH_ASSOC);
+
+ $dbProductsBySku = [];
+ foreach ($dbProducts as $prod) {
+ $dbProductsBySku[$prod['sku']] = $prod;
+ }
+
+ $dbProductsPricesByPid = [];
+ foreach ($dbProductPrices as $price) {
+ $dbProductsPricesByPid[$price['pid']] = $price;
+ }
+
+ /** @var Product $product */
+ foreach ($products as $product) {
+
+ if (array_key_exists($product->getCode(), $dbProductsBySku)) {
+ $dbProduct = $dbProductsBySku[$product->getCode()];
+ $dbPrice = $dbProductsPricesByPid[$dbProduct['id']];
+ $this->updateProduct($product, $dbProduct['id']);
+ $this->updatePriceTier($product, $dbPrice['id']);
+
+ } else {
+ $productId = $this->createProduct($product);
+ $productPriceId = $this->createPrice($product, $productId);
+ $this->createPriceTier($product, $productPriceId);
+ }
+ }
+ $this->dbHandle->commit();
+ }
+
+ private function updateProduct(Product $product, $productDbId)
+ {
+ $shippingWeight = $product->getGrossWeight() !== '' ?
+ ($product->getNetWeight() !== '' ? $product->getNetWeight() : '') : '';
+
+ $sql =
+ "UPDATE `tl_iso_product` SET
+ tstamp = ".time().",
+ shipping_weight = '$shippingWeight'
+ WHERE `id` = ".$productDbId;
+ $this->dbHandle->query($sql);
+ }
+
+ private function updatePriceTier(Product $product, $priceDbId)
+ {
+ $price = $product->getStandardSalesPrice() * (1 + Product::VAT);
+ $sql =
+ "UPDATE `tl_iso_product_pricetier` SET
+ tstamp = ".time().",
+ price = $price
+ WHERE `pid` = ".$priceDbId;
+ $this->dbHandle->query($sql);
+ }
+
+ private function createProduct(Product $product)
+ {
+ $alias = 'exact-product-'.$product->getCode();
+ $shippingWeight = $product->getGrossWeight() !== '' ?
+ ($product->getNetWeight() !== '' ? $product->getNetWeight() : '') : '';
+ $sql =
+ "INSERT into `tl_iso_product` (
+ pid,
+ gid,
+ tstamp,
+ language,
+ dateAdded,
+ type,
+ fallback,
+ alias,
+ gtin,
+ sku,
+ name,
+ description,
+ meta_title,
+ baseprice,
+ shipping_weight,
+ shipping_exempt,
+ shipping_pickup,
+ shipping_price,
+ protected,
+ guests,
+ cssID,
+ published,
+ start,
+ stop,
+ exact_id
+ ) VALUES (
+ 0,
+ 0,
+ ".time().",
+ '',
+ ".time().",
+ 2,
+ '',
+ '$alias',
+ '',
+ '".$product->getCode()."',
+ '".$product->getDescription()."',
+ '".$product->getExtraDescription()."',
+ '',
+ '',
+ '$shippingWeight',
+ '',
+ '',
+ 0.00,
+ '',
+ '',
+ '',
+ '0',
+ '',
+ '',
+ '".$product->getId()."'
+ )";
+ $this->dbHandle->query($sql);
+ return $this->dbHandle->lastInsertId();
+ }
+
+ private function createPrice(Product $product, $productDbId)
+ {
+ $sql =
+ "INSERT INTO `tl_iso_product_price` (
+ pid,
+ tstamp,
+ tax_class,
+ config_id,
+ member_group,
+ start,
+ stop
+ ) VALUES (
+ $productDbId,
+ ".time().",
+ 1,
+ 0,
+ 0,
+ '',
+ ''
+ )";
+ $this->dbHandle->query($sql);
+ return $this->dbHandle->lastInsertId();
+ }
+
+ private function createPriceTier(Product $product, $productPriceDbId)
+ {
+ $price = $product->getStandardSalesPrice() * (1 + Product::VAT);
+ $sql =
+ "INSERT INTO `tl_iso_product_pricetier` (
+ pid,
+ tstamp,
+ min,
+ price
+ ) VALUES (
+ $productPriceDbId,
+ ".time().",
+ 1,
+ $price
+ )";
+ $this->dbHandle->query($sql);
+ return $this->dbHandle->lastInsertId();
+ }
+}
\ No newline at end of file
diff --git a/api2/Order.php b/api2/Order.php
new file mode 100644
index 0000000..3ddd429
--- /dev/null
+++ b/api2/Order.php
@@ -0,0 +1,614 @@
+date_paid && $this->date_paid <= time()) {
+ return true;
+ }
+
+ // Otherwise we check the orderstatus checkbox
+ try {
+ /** @var OrderStatus $objStatus */
+ $objStatus = $this->getRelated('order_status');
+
+ return (null !== $objStatus && $objStatus->isPaid());
+ } catch (\Exception $e) {
+ return false;
+ }
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function getDatePaid()
+ {
+ return $this->date_paid;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function setDatePaid($timestamp = null)
+ {
+ $this->date_paid = $timestamp;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function isShipped()
+ {
+ return null !== $this->date_shipped;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function getDateShipped()
+ {
+ return $this->date_shipped;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function setDateShipped($timestamp = null)
+ {
+ $this->date_shipped = $timestamp;
+ }
+
+ /**
+ * Returns true if checkout has been completed
+ *
+ * @return bool
+ */
+ public function isCheckoutComplete()
+ {
+ return (bool) $this->checkout_complete;
+ }
+
+ /**
+ * Get label for current order status
+ *
+ * @return string
+ */
+ public function getStatusLabel()
+ {
+ try {
+ /** @var OrderStatus $objStatus */
+ $objStatus = $this->getRelated('order_status');
+
+ return (null === $objStatus) ? '' : $objStatus->getName();
+ } catch (\Exception $e) {
+ return '';
+ }
+ }
+
+ /**
+ * Get the alias for current order status
+ *
+ * @return string
+ */
+ public function getStatusAlias()
+ {
+ try {
+ /** @var OrderStatus $objStatus */
+ $objStatus = $this->getRelated('order_status');
+
+ return null === $objStatus ? $this->order_status : $objStatus->getAlias();
+ } catch (\Exception $e) {
+ return $this->order_status;
+ }
+ }
+
+ /**
+ * Process the order checkout
+ *
+ * @return bool
+ */
+ public function checkout()
+ {
+ if ($this->isCheckoutComplete()) {
+ return true;
+ }
+
+ // Finish and lock the order
+ // (do this now, because otherwise surcharges etc. will not be loaded form the database)
+ $this->checkout_complete = true;
+ $this->generateDocumentNumber(
+ $this->getConfig()->orderPrefix,
+ (int) $this->getConfig()->orderDigits
+ );
+
+ if (!$this->isLocked()) {
+ $this->lock();
+ }
+
+ System::log('New order ID ' . $this->id . ' has been placed', __METHOD__, TL_ACCESS);
+
+ // Delete cart after migrating to order
+ if (($objCart = Cart::findByPk($this->source_collection_id)) !== null) {
+ $objCart->delete();
+ }
+
+ // Delete all other orders that relate to the current cart
+ if (($objOrders = static::findSiblingsBy('source_collection_id', $this)) !== null) {
+
+ /** @var static $objOrder */
+ foreach ($objOrders as $objOrder) {
+ if (!$objOrder->isCheckoutComplete()) {
+ $objOrder->delete(true);
+ }
+ }
+ }
+
+ $notificationIds = array_filter(explode(',', $this->nc_notification));
+
+ // Send the notifications
+ if (\count($notificationIds) > 0) {
+ foreach ($notificationIds as $notificationId) {
+ // Generate tokens
+ $arrTokens = $this->getNotificationTokens($notificationId);
+
+ // Send notification
+ $blnNotificationError = true;
+
+ /** @var Notification $objNotification */
+ if (($objNotification = Notification::findByPk($notificationId)) !== null) {
+ $arrResult = $objNotification->send($arrTokens, $this->language);
+
+ if (\count($arrResult) > 0 && !\in_array(false, $arrResult, true)) {
+ $blnNotificationError = false;
+ }
+ }
+
+ if ($blnNotificationError === true) {
+ System::log('Error sending new order notification for order ID ' . $this->id, __METHOD__, TL_ERROR);
+ }
+ }
+ }
+
+ // Set order status only if a payment module has not already set it
+ if ($this->order_status == 0) {
+ $this->updateOrderStatus($this->getRelated('config_id')->orderstatus_new);
+ }
+
+ // !HOOK: post-process checkout
+ if (isset($GLOBALS['ISO_HOOKS']['postCheckout']) && \is_array($GLOBALS['ISO_HOOKS']['postCheckout'])) {
+ // Generate the default notification tokens if none set yet
+ if (!isset($arrTokens)) {
+ $arrTokens = $this->getNotificationTokens(0);
+ }
+
+ foreach ($GLOBALS['ISO_HOOKS']['postCheckout'] as $callback) {
+ System::importStatic($callback[0])->{$callback[1]}($this, $arrTokens);
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Complete order if the checkout has been made. This will cleanup session data
+ *
+ * @return bool
+ */
+ public function complete()
+ {
+ if ($this->isCheckoutComplete()) {
+ unset($_SESSION['CHECKOUT_DATA'], $_SESSION['FILES']);
+
+ // Retain custom config ID
+ if (($objCart = Isotope::getCart()) !== null && $objCart->config_id != $this->config_id) {
+ $objCart->config_id = $this->config_id;
+ }
+
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Update the status of this order and trigger actions (email & hook)
+ *
+ * @param int|array $updates
+ * @param int $flags Order::STATUS_UPDATE_SKIP_NOTIFICATION and/or Order::STATUS_UPDATE_SKIP_LOG
+ *
+ * @return bool
+ */
+ public function updateOrderStatus($updates, $flags = 0)
+ {
+ // For BC reasons the parameter can be the new order status ID
+ if (!is_array($updates)) {
+ $updates = ['order_status' => $updates];
+ }
+
+ $previous = [];
+ $hasChanges = false;
+ foreach ($updates as $k => $v) {
+ $previous[$k] = $this->{$k};
+ $hasChanges = $hasChanges ?: $v !== $previous[$k];
+ }
+
+ if (!isset($updates['order_status'])) {
+ throw new \LogicException('You must update the order status when calling Order::updateOrderStatus()');
+ }
+
+ if (!$hasChanges) {
+ return true;
+ }
+
+ /** @var OrderStatus $objNewStatus */
+ $objNewStatus = OrderStatus::findByPk($updates['order_status']);
+
+ if (null === $objNewStatus) {
+ return false;
+ }
+
+ // !HOOK: allow to cancel a status update
+ if (isset($GLOBALS['ISO_HOOKS']['preOrderStatusUpdate'])
+ && \is_array($GLOBALS['ISO_HOOKS']['preOrderStatusUpdate'])
+ ) {
+ foreach ($GLOBALS['ISO_HOOKS']['preOrderStatusUpdate'] as $callback) {
+ $blnCancel = System::importStatic($callback[0])->{$callback[1]}($this, $objNewStatus, $updates);
+
+ if ($blnCancel === true) {
+ return false;
+ }
+ }
+ }
+
+ // Add the payment date if there is none
+ if ($objNewStatus->isPaid() && $this->date_paid == '' && !isset($updates['date_paid'])) {
+ $updates['date_paid'] = time();
+ }
+
+ // Store old status and set the new one
+ $oldStatusId = $this->order_status;
+ $oldStatusLabel = $this->getStatusLabel();
+ foreach ($updates as $k => $v) {
+ $this->{$k} = $v;
+ }
+ $this->save();
+
+ if (!($flags & static::STATUS_UPDATE_SKIP_NOTIFICATION)) {
+ // Trigger notification
+ $blnNotificationError = null;
+ foreach (array_filter(explode(',', $objNewStatus->notification)) as $notificationId) {
+ $arrTokens = $this->getNotificationTokens($notificationId);
+
+ // Override order status and save the old one to the tokens too
+ $arrTokens['order_status_id'] = $objNewStatus->id;
+ $arrTokens['order_status'] = $objNewStatus->getName();
+ $arrTokens['order_status_old'] = $oldStatusLabel;
+ $arrTokens['order_status_id_old'] = $oldStatusId;
+
+ $blnNotificationError = true;
+
+ /** @var Notification $objNotification */
+ if (($objNotification = Notification::findByPk($notificationId)) !== null) {
+ $arrResult = $objNotification->send($arrTokens, $this->language);
+
+ if (\in_array(false, $arrResult, true)) {
+ $blnNotificationError = true;
+ System::log(
+ 'Error sending status update notification for order ID ' . $this->id,
+ __METHOD__,
+ TL_ERROR
+ );
+ } elseif (\count($arrResult) > 0) {
+ $blnNotificationError = false;
+ }
+ } else {
+ System::log('Invalid notification for order status ID ' . $objNewStatus->id, __METHOD__, TL_ERROR);
+ }
+ }
+
+ if ('BE' === TL_MODE) {
+ Message::addConfirmation($GLOBALS['TL_LANG']['tl_iso_product_collection']['orderStatusUpdate']);
+
+ if ($blnNotificationError === true) {
+ Message::addError($GLOBALS['TL_LANG']['tl_iso_product_collection']['orderStatusNotificationError']);
+ } elseif ($blnNotificationError === false) {
+ Message::addConfirmation($GLOBALS['TL_LANG']['tl_iso_product_collection']['orderStatusNotificationSuccess']);
+ }
+ }
+ }
+
+ // Add a log entry
+ if (!($flags & static::STATUS_UPDATE_SKIP_LOG)) {
+ $log = new ProductCollectionLog();
+ $log->pid = $this->id;
+ $log->tstamp = time();
+ $log->setData([
+ 'order_status' => $this->order_status,
+ 'date_paid' => $this->date_paid,
+ 'date_shipped' => $this->date_shipped,
+ ]);
+ $log->save();
+ }
+
+ // !HOOK: order status has been updated
+ if (isset($GLOBALS['ISO_HOOKS']['postOrderStatusUpdate'])
+ && \is_array($GLOBALS['ISO_HOOKS']['postOrderStatusUpdate'])
+ ) {
+ foreach ($GLOBALS['ISO_HOOKS']['postOrderStatusUpdate'] as $callback) {
+ System::importStatic($callback[0])->{$callback[1]}($this, $oldStatusId, $objNewStatus);
+ }
+ }
+
+ // Trigger payment and shipping methods that implement the interface
+ if (($objPayment = $this->getPaymentMethod()) !== null && $objPayment instanceof IsotopeOrderStatusAware) {
+ $objPayment->onOrderStatusUpdate($this, $oldStatusId, $objNewStatus);
+ }
+ if (($objShipping = $this->getShippingMethod()) !== null && $objShipping instanceof IsotopeOrderStatusAware) {
+ $objShipping->onOrderStatusUpdate($this, $oldStatusId, $objNewStatus);
+ }
+
+ return true;
+ }
+
+ /**
+ * Retrieve the array of notification data for parsing simple tokens
+ *
+ * @param int $intNotification
+ *
+ * @return array
+ */
+ public function getNotificationTokens($intNotification)
+ {
+ $objConfig = $this->getRelated('config_id') ?: Isotope::getConfig();
+ Isotope::setConfig($objConfig);
+
+ $arrTokens = StringUtil::deserialize($this->email_data, true);
+ $arrTokens['uniqid'] = $this->uniqid;
+ $arrTokens['order_status_id'] = $this->order_status;
+ $arrTokens['order_status'] = $this->getStatusLabel();
+ $arrTokens['recipient_email'] = $this->getEmailRecipient();
+ $arrTokens['order_id'] = $this->id;
+ $arrTokens['order_items'] = $this->sumItemsQuantity();
+ $arrTokens['order_products'] = $this->countItems();
+ $arrTokens['order_subtotal'] = Isotope::formatPriceWithCurrency($this->getSubtotal(), false, $objConfig->currency);
+ $arrTokens['order_total'] = Isotope::formatPriceWithCurrency($this->getTotal(), false, $objConfig->currency);
+ $arrTokens['document_number'] = $this->document_number;
+ $arrTokens['bank_name'] = $objConfig->bankName;
+ $arrTokens['bank_account'] = $objConfig->bankAccount;
+ $arrTokens['bank_code'] = $objConfig->bankCode;
+ $arrTokens['tax_number'] = $objConfig->taxNumber;
+ $arrTokens['cart_html'] = '';
+ $arrTokens['cart_text'] = '';
+ $arrTokens['document'] = '';
+
+ // Add all the collection fields
+ foreach ($this->row() as $k => $v) {
+ $arrTokens['collection_' . $k] = $v;
+ }
+
+ // Add billing/customer address fields
+ if (($objAddress = $this->getBillingAddress()) !== null) {
+ foreach ($objAddress->row() as $k => $v) {
+ $arrTokens['billing_address_' . $k] = Format::dcaValue(Address::getTable(), $k, $v);
+
+ // @deprecated (use ##billing_address_*##)
+ $arrTokens['billing_' . $k] = $arrTokens['billing_address_' . $k];
+ }
+
+ $arrTokens['billing_address'] = $objAddress->generate($objConfig->getBillingFieldsConfig());
+
+ // @deprecated (use ##billing_address##)
+ $arrTokens['billing_address_text'] = $arrTokens['billing_address'];
+ }
+
+ // Add shipping address fields
+ if (($objAddress = $this->getShippingAddress()) !== null) {
+ foreach ($objAddress->row() as $k => $v) {
+ $arrTokens['shipping_address_' . $k] = Format::dcaValue(Address::getTable(), $k, $v);
+
+ // @deprecated (use ##billing_address_*##)
+ $arrTokens['shipping_' . $k] = $arrTokens['shipping_address_' . $k];
+ }
+
+ $arrTokens['shipping_address'] = $objAddress->generate($objConfig->getShippingFieldsConfig());
+
+ // Shipping address equals billing address
+ // @deprecated (use ##shipping_address##)
+ if ($objAddress->id == $this->getBillingAddress()->id) {
+ $arrTokens['shipping_address_text'] = ($this->requiresPayment() ? $GLOBALS['TL_LANG']['MSC']['useBillingAddress'] : $GLOBALS['TL_LANG']['MSC']['useCustomerAddress']);
+ } else {
+ $arrTokens['shipping_address_text'] = $arrTokens['shipping_address'];
+ }
+ }
+
+ // Add payment method info
+ if ($this->hasPayment() && ($objPayment = $this->getPaymentMethod()) !== null) {
+ $arrTokens['payment_id'] = $objPayment->getId();
+ $arrTokens['payment_label'] = $objPayment->getLabel();
+ $arrTokens['payment_note'] = $objPayment->getNote();
+
+ if ($objPayment instanceof IsotopeNotificationTokens) {
+ $arrTokens = array_merge($arrTokens, $objPayment->getNotificationTokens($this));
+ }
+ }
+
+ // Add shipping method info
+ if ($this->hasShipping() && ($objShipping = $this->getShippingMethod()) !== null) {
+ $arrTokens['shipping_id'] = $objShipping->getId();
+ $arrTokens['shipping_label'] = $objShipping->getLabel();
+ $arrTokens['shipping_note'] = $objShipping->getNote();
+
+ if ($objShipping instanceof IsotopeNotificationTokens) {
+ $arrTokens = array_merge($arrTokens, $objShipping->getNotificationTokens($this));
+ }
+ }
+
+ // Add config fields
+ if ($this->getRelated('config_id') !== null) {
+ foreach ($this->getRelated('config_id')->row() as $k => $v) {
+ $arrTokens['config_' . $k] = Format::dcaValue($this->getRelated('config_id')->getTable(), $k, $v);
+ }
+ }
+
+ // Add member fields
+ if ($this->member > 0 && $this->getRelated('member') !== null) {
+ foreach ($this->getRelated('member')->row() as $k => $v) {
+ $arrTokens['member_' . $k] = Format::dcaValue($this->getRelated('member')->getTable(), $k, $v);
+ }
+ }
+
+ /** @var Notification|object $objNotification */
+ if ($intNotification > 0 && ($objNotification = Notification::findByPk($intNotification)) !== null) {
+ /** @var \Isotope\Template|object $objTemplate */
+ $objTemplate = new \Isotope\Template($objNotification->iso_collectionTpl);
+ $objTemplate->isNotification = true;
+
+ $this->addToTemplate(
+ $objTemplate,
+ array(
+ 'gallery' => $objNotification->iso_gallery,
+ 'sorting' => static::getItemsSortingCallable($objNotification->iso_orderCollectionBy),
+ )
+ );
+
+ $arrTokens['cart_html'] = Controller::replaceInsertTags($objTemplate->parse(), false);
+ $objTemplate->textOnly = true;
+ $arrTokens['cart_text'] = strip_tags(Controller::replaceInsertTags($objTemplate->parse(), true));
+
+ // Generate and "attach" document
+ /** @var \Isotope\Interfaces\IsotopeDocument $objDocument */
+ if ($objNotification->iso_document > 0
+ && (($objDocument = Document::findByPk($objNotification->iso_document)) !== null)
+ ) {
+ $strFilePath = $objDocument->outputToFile($this, TL_ROOT . '/system/tmp');
+ $arrTokens['document'] = str_replace(TL_ROOT . '/', '', $strFilePath);
+ }
+ }
+
+ // !HOOK: add custom email tokens
+ if (isset($GLOBALS['ISO_HOOKS']['getOrderNotificationTokens'])
+ && \is_array($GLOBALS['ISO_HOOKS']['getOrderNotificationTokens'])
+ ) {
+ foreach ($GLOBALS['ISO_HOOKS']['getOrderNotificationTokens'] as $callback) {
+ $arrTokens = System::importStatic($callback[0])->{$callback[1]}($this, $arrTokens);
+ }
+ }
+
+ return $arrTokens;
+ }
+
+ /**
+ * Include downloads when adding items to template
+ *
+ * @return array
+ */
+ protected function addItemsToTemplate(Template $objTemplate, $varCallable = null)
+ {
+ $taxIds = [];
+ $arrItems = [];
+ $arrAllDownloads = [];
+
+ foreach ($this->getItems($varCallable) as $objItem) {
+ $arrDownloads = [];
+ $arrItem = $this->generateItem($objItem);
+
+ foreach ($objItem->getDownloads() as $objDownload) {
+ $arrDownloads = array_merge($arrDownloads, $objDownload->getForTemplate($this->isPaid(), $this->orderdetails_page));
+ }
+
+ $arrItem['downloads'] = $arrDownloads;
+ $arrAllDownloads = array_merge($arrAllDownloads, $arrDownloads);
+
+ $taxIds[] = $arrItem['tax_id'];
+ $arrItems[] = $arrItem;
+ }
+
+ RowClass::withKey('rowClass')->addCount('row_')->addFirstLast('row_')->addEvenOdd('row_')->applyTo($arrItems);
+
+ $objTemplate->items = $arrItems;
+ $objTemplate->downloads = $arrAllDownloads;
+ $objTemplate->total_tax_ids = \count(array_count_values($taxIds));
+
+ return $arrItems;
+ }
+
+ /**
+ * Generate unique order ID including the order prefix
+ *
+ * @return string
+ */
+ protected function generateUniqueId()
+ {
+ if (!empty($this->arrData['uniqid'])) {
+ return $this->arrData['uniqid'];
+ }
+
+ $objConfig = $this->getConfig();
+
+ if (null === $objConfig) {
+ $objConfig = Isotope::getConfig();
+ }
+
+ return uniqid(
+ Controller::replaceInsertTags((string) $objConfig->orderPrefix, false),
+ true
+ );
+ }
+}
diff --git a/api2/PostCheckoutListener.php b/api2/PostCheckoutListener.php
new file mode 100644
index 0000000..bfdfee0
--- /dev/null
+++ b/api2/PostCheckoutListener.php
@@ -0,0 +1,147 @@
+getBillingAddress();
+
+// ob_start();
+// var_dump($order);
+// file_put_contents('flo.txt', ob_get_contents());
+// ob_end_clean();
+//
+// ob_start();
+// var_dump($item);
+// file_put_contents('dan.txt', ob_get_contents());
+// ob_end_clean();
+
+ // throw new \Exception('ARRRRRRRGGGGGGGGGG');
+ $apiExact = new ApiExact();
+
+ ob_start();
+ /** @var ProductCollectionItem $orderItem */
+ foreach($order->getItems() as $orderItem) {
+ /** @var Product $orderItem */
+ $product = $orderItem->getProduct();
+ var_dump($orderItem->id);
+ var_dump($orderItem->sku);
+ var_dump($product->id);
+ var_dump($product->name);
+ var_dump($product->is_set);
+ }
+ var_dump($apiExact->test());
+// var_dump($order->getItems());
+ //var_dump($order);
+ file_put_contents('dan.txt', ob_get_contents());
+ ob_end_clean();
+
+
+
+// $shippingCustomer = null;
+// if ($this->compareAddresses() === false) {
+// $shippingCustomer = $apiExact->createCustomer(
+// $this->createCustomerApiData($order->getShippingAddress())
+// );
+// }
+//
+// $billingCustomer = $apiExact->createCustomer(
+// $this->createCustomerApiData($order->getBillingAddress())
+// );
+//
+// $this->createSalesOrderData($order, $billingCustomer, $shippingCustomer);
+
+// $billingFirstName = $order->getBillingAddress()->firstname;
+// $billingLastName = $order->getBillingAddress()->lastname;
+// $billingStreet = $order->getBillingAddress()->street_1;
+// $billingPostcode = $order->getBillingAddress()->postal;
+// $billingCity = $order->getBillingAddress()->city;
+// $billingCompany = $order->getBillingAddress()->company;
+// $billingPhone = $order->getBillingAddress()->phone;
+// $billingEmail = $order->getBillingAddress()->email;
+//
+// $shippingFirstName = $order->getShippingAddress()->firstname;
+// $shippingLastName = $order->getShippingAddress()->lastname;
+// $shippingStreet = $order->getShippingAddress()->street_1;
+// $shippingPostcode = $order->getShippingAddress()->postal;
+// $shippingCity = $order->getShippingAddress()->city;
+// $shippingCompany = $order->getShippingAddress()->company;
+// $shippingPhone = $order->getShippingAddress()->phone;
+// $shippingEmail = $order->getShippingAddress()->email;
+
+ }
+
+ private function compareAddresses(Order $order)
+ {
+ return
+ $order->getBillingAddress()->firstname === $order->getShippingAddress()->firstname &&
+ $order->getBillingAddress()->lastname === $order->getShippingAddress()->lastname &&
+ $order->getBillingAddress()->street_1 === $order->getShippingAddress()->street_1 &&
+ $order->getBillingAddress()->postal === $order->getShippingAddress()->postal &&
+ $order->getBillingAddress()->city === $order->getShippingAddress()->city &&
+ $order->getBillingAddress()->company === $order->getShippingAddress()->company &&
+ $order->getBillingAddress()->phone === $order->getShippingAddress()->phone &&
+ $order->getBillingAddress()->email === $order->getShippingAddress()->email;
+ }
+
+ private function createCustomerApiData(Address $address)
+ {
+ return [
+ 'Name' => $address->firstname . ' ' . $address->lastname,
+ 'AddressLine1' => $address->street_1,
+ 'AddressLine2' => $address->company,
+ 'City' => $address->city,
+ 'Postcode' => $address->postal,
+ 'Email' => $address->email,
+ 'Country' => 'DE',
+ 'Phone' => $address->phone,
+ 'Status' => 'C',
+ ];
+ }
+
+ private function createSalesOrderData(Order $order, $billingCustomer, $shippingCustomer)
+ {
+ $date = new \DateTime('now');
+
+ $orderItems = $order->getItems();
+
+ return [
+ 'Description' => $billingCustomer['Name'],
+ 'OrderDate' => $date->format('m/d/Y H:i:s'),
+ 'OrderedBy' => $billingCustomer['ID'],
+ 'WarehouseID' => self::WAREHOUSE_ID,
+ 'SalesOrderLines' => [
+ [
+ 'Description' => 'TEST: Reinigungstuch grün',
+ 'Item' => '9dcd8b50-5de6-4f15-a51a-c51282292383',
+ 'UnitPrice' => '2.45',
+ 'Quantity' => '3'
+ ],
+ [
+ 'Description' => 'TEST: McQuade´s E-Bike Kettenrückführ - Tool',
+ 'Item' => '0e04113f-1299-4d3c-b65e-01e83ca5b3b2',
+ 'UnitPrice' => '476.00',
+ 'Quantity' => '1'
+ ],
+ ]
+ ];
+ }
+}
\ No newline at end of file
diff --git a/api2/Product.php b/api2/Product.php
new file mode 100644
index 0000000..f507f25
--- /dev/null
+++ b/api2/Product.php
@@ -0,0 +1,809 @@
+'" . ($time + 60) . "')"
+ );
+ }
+
+ return static::findBy($arrColumns, $arrValues, $arrOptions);
+ }
+
+ /**
+ * Find a single product by primary key
+ *
+ * @param int $intId
+ * @param array $arrOptions
+ *
+ * @return static|null
+ */
+ public static function findPublishedByPk($intId, array $arrOptions = array())
+ {
+ $arrOptions = array_merge(
+ array(
+ 'return' => 'Model'
+ ),
+ $arrOptions
+ );
+
+ return static::findPublishedBy(static::$strPk, (int) $intId, $arrOptions);
+ }
+
+ /**
+ * Find a single product by its ID or alias
+ *
+ * @param mixed $varId The ID or alias
+ * @param array $arrOptions An optional options array
+ *
+ * @return static|null The model or null if the result is empty
+ */
+ public static function findPublishedByIdOrAlias($varId, array $arrOptions = array())
+ {
+ $t = static::$strTable;
+
+ $arrColumns = array("($t.id=? OR $t.alias=?)");
+ $arrValues = array(is_numeric($varId) ? $varId : 0, $varId);
+
+ $arrOptions = array_merge(
+ array(
+ 'limit' => 1,
+ 'return' => 'Model'
+ ),
+ $arrOptions
+ );
+
+ return static::findPublishedBy($arrColumns, $arrValues, $arrOptions);
+ }
+
+ /**
+ * Find products by IDs
+ *
+ * @param array $arrIds
+ * @param array $arrOptions
+ *
+ * @return Product[]|Collection
+ */
+ public static function findPublishedByIds(array $arrIds, array $arrOptions = array())
+ {
+ if (0 === \count($arrIds)) {
+ return null;
+ }
+
+ return static::findPublishedBy(
+ array(static::$strTable . '.id IN (' . implode(',', array_map('intval', $arrIds)) . ')'),
+ null,
+ $arrOptions
+ );
+ }
+
+ /**
+ * Return collection of published product variants by product PID
+ *
+ * @param int $intPid
+ * @param array $arrOptions
+ *
+ * @return Collection|Product[]|null
+ */
+ public static function findPublishedByPid($intPid, array $arrOptions = array())
+ {
+ return static::findPublishedBy('pid', (int) $intPid, $arrOptions);
+ }
+
+ /**
+ * Return collection of published products by categories
+ *
+ * @param array $arrCategories
+ * @param array $arrOptions
+ *
+ * @return Collection|Product[]|null
+ */
+ public static function findPublishedByCategories(array $arrCategories, array $arrOptions = array())
+ {
+ return static::findPublishedBy(
+ array('c.page_id IN (' . implode(',', array_map('intval', $arrCategories)) . ')'),
+ null,
+ $arrOptions
+ );
+ }
+
+ /**
+ * Find a single frontend-available product by primary key
+ *
+ * @param int $intId
+ * @param array $arrOptions
+ *
+ * @return static|null
+ */
+ public static function findAvailableByPk($intId, array $arrOptions = array())
+ {
+ $objProduct = static::findPublishedByPk($intId, $arrOptions);
+
+ if (null === $objProduct || !$objProduct->isAvailableInFrontend()) {
+ return null;
+ }
+
+ return $objProduct;
+ }
+
+ /**
+ * Find a single frontend-available product by its ID or alias
+ *
+ * @param mixed $varId The ID or alias
+ * @param array $arrOptions An optional options array
+ *
+ * @return Product|null The model or null if the result is empty
+ */
+ public static function findAvailableByIdOrAlias($varId, array $arrOptions = array())
+ {
+ $objProduct = static::findPublishedByIdOrAlias($varId, $arrOptions);
+
+ if (null === $objProduct || !$objProduct->isAvailableInFrontend()) {
+ return null;
+ }
+
+ return $objProduct;
+ }
+
+ /**
+ * Find frontend-available products by IDs
+ *
+ * @param array $arrIds
+ * @param array $arrOptions
+ *
+ * @return Collection|Product[]|null
+ */
+ public static function findAvailableByIds(array $arrIds, array $arrOptions = array())
+ {
+ $objProducts = static::findPublishedByIds($arrIds, $arrOptions);
+
+ if (null === $objProducts) {
+ return null;
+ }
+
+ $arrProducts = [];
+ foreach ($objProducts as $objProduct) {
+ if ($objProduct->isAvailableInFrontend()) {
+ $arrProducts[] = $objProduct;
+ }
+ }
+
+ if (0 === \count($arrProducts)) {
+ return null;
+ }
+
+ return new Collection($arrProducts, static::$strTable);
+ }
+
+ /**
+ * Find frontend-available products by condition
+ *
+ * @param mixed $arrColumns
+ * @param mixed $arrValues
+ * @param array $arrOptions
+ *
+ * @return Collection
+ */
+ public static function findAvailableBy($arrColumns, $arrValues, array $arrOptions = array())
+ {
+ $objProducts = static::findPublishedBy($arrColumns, $arrValues, $arrOptions);
+
+ if (null === $objProducts) {
+ return null;
+ }
+
+ $arrProducts = [];
+ foreach ($objProducts as $objProduct) {
+ if ($objProduct->isAvailableInFrontend()) {
+ $arrProducts[] = $objProduct;
+ }
+ }
+
+ if (0 === \count($arrProducts)) {
+ return null;
+ }
+
+ return new Collection($arrProducts, static::$strTable);
+ }
+
+ /**
+ * Find variant of a product
+ *
+ * @param IsotopeProduct $objProduct
+ * @param array $arrVariant
+ * @param array $arrOptions
+ *
+ * @return Model|null
+ */
+ public static function findVariantOfProduct(
+ IsotopeProduct $objProduct,
+ array $arrVariant,
+ array $arrOptions = array()
+ ) {
+ $t = static::$strTable;
+
+ $arrColumns = array(
+ "$t.id IN (" . implode(',', $objProduct->getVariantIds()) . ')',
+ "$t." . implode("=? AND $t.", array_keys($arrVariant)) . '=?'
+ );
+
+ $arrOptions = array_merge(
+ array(
+ 'limit' => 1,
+ 'column' => $arrColumns,
+ 'value' => $arrVariant,
+ 'return' => 'Model'
+ ),
+ $arrOptions
+ );
+
+ return static::find($arrOptions);
+ }
+
+ /**
+ * Finds the default variant of a product.
+ *
+ * @param IsotopeProduct $objProduct
+ * @param array $arrOptions
+ *
+ * @return static|null
+ */
+ public static function findDefaultVariantOfProduct(IsotopeProduct $objProduct, array $arrOptions = array())
+ {
+ static $cache;
+
+ if (null === $cache) {
+ $cache = [];
+ $data = Database::getInstance()->execute(
+ "SELECT id, pid FROM tl_iso_product WHERE pid>0 AND language='' AND fallback='1'"
+ );
+
+ while ($data->next()) {
+ $cache[$data->pid] = $data->id;
+ }
+ }
+
+ $defaultId = $cache[$objProduct->getProductId()];
+
+ if ($defaultId < 1 || !\in_array($defaultId, $objProduct->getVariantIds())) {
+ return null;
+ }
+
+ return static::findByPk($defaultId, $arrOptions);
+ }
+
+ /**
+ * Returns the number of published products.
+ *
+ * @param array $arrOptions
+ *
+ * @return int
+ */
+ public static function countPublished(array $arrOptions = array())
+ {
+ return static::countPublishedBy(array(), array(), $arrOptions);
+ }
+
+ /**
+ * Return the number of products matching certain criteria
+ *
+ * @param mixed $arrColumns
+ * @param mixed $arrValues
+ * @param array $arrOptions
+ *
+ * @return int
+ */
+ public static function countPublishedBy($arrColumns, $arrValues, array $arrOptions = array())
+ {
+ $t = static::$strTable;
+
+ $arrValues = (array) $arrValues;
+
+ if (!\is_array($arrColumns)) {
+ $arrColumns = array(static::$strTable . '.' . $arrColumns . '=?');
+ }
+
+ // Add publish check to $arrColumns as the first item to enable SQL keys
+ if (BE_USER_LOGGED_IN !== true) {
+ $time = Date::floorToMinute();
+ array_unshift(
+ $arrColumns,
+ "
+ $t.published='1'
+ AND ($t.start='' OR $t.start<'$time')
+ AND ($t.stop='' OR $t.stop>'" . ($time + 60) . "')
+ "
+ );
+ }
+
+ return static::countBy($arrColumns, $arrValues, $arrOptions);
+ }
+
+ /**
+ * Gets the number of translation records in the product table.
+ * Mostly useful to see if there are any translations at all to optimize queries.
+ *
+ * @return int
+ */
+ public static function countTranslatedProducts()
+ {
+ static $result;
+
+ if (null === $result) {
+ $result = Database::getInstance()->query(
+ "SELECT COUNT(*) AS total FROM tl_iso_product WHERE language!=''"
+ )->total;
+ }
+
+ return $result;
+ }
+
+ /**
+ * Return a model or collection based on the database result type
+ *
+ * @param array $arrOptions
+ *
+ * @return Product|Product[]|Collection|null
+ */
+ protected static function find(array $arrOptions)
+ {
+ $arrOptions['group'] = static::getTable() . '.id' . (null === ($arrOptions['group'] ?? null) ? '' : ', '.$arrOptions['group']);
+
+ $objProducts = parent::find($arrOptions);
+
+ if (null === $objProducts) {
+ return null;
+ }
+
+ /** @var Filter[] $arrFilters */
+ $arrFilters = $arrOptions['filters'] ?? null;
+ $arrSorting = $arrOptions['sorting'] ?? null;
+
+ $hasFilters = \is_array($arrFilters) && 0 !== \count($arrFilters);
+ $hasSorting = \is_array($arrSorting) && 0 !== \count($arrSorting);
+
+ if ($hasFilters || $hasSorting) {
+
+ /** @var static[] $arrProducts */
+ $arrProducts = $objProducts->getModels();
+
+ if ($hasFilters) {
+ $arrProducts = array_filter($arrProducts, function ($objProduct) use ($arrFilters) {
+ $arrGroups = [];
+
+ foreach ($arrFilters as $objFilter) {
+ $blnMatch = $objFilter->matches($objProduct);
+
+ if ($objFilter->hasGroup()) {
+ $arrGroups[$objFilter->getGroup()] = $arrGroups[$objFilter->getGroup()] ? : $blnMatch;
+ } elseif (!$blnMatch) {
+ return false;
+ }
+ }
+
+ return !\in_array(false, $arrGroups, true);
+ });
+ }
+
+ // $arrProducts can be empty if the filter removed all records
+ if ($hasSorting && 0 !== \count($arrProducts)) {
+ $arrParam = array();
+ $arrData = array();
+
+ foreach ($arrSorting as $strField => $arrConfig) {
+ foreach ($arrProducts as $objProduct) {
+
+ // Both SORT_STRING and SORT_REGULAR are case sensitive, strings starting with a capital letter
+ // will come before strings starting with a lowercase letter. To perform a case insensitive
+ // search, force the sorting order to be determined by a lowercase copy of the original value.
+
+ // Temporary fix for price attribute (see #945)
+ if ('price' === $strField) {
+ if (null !== $objProduct->getPrice()) {
+ $arrData[$strField][$objProduct->id] = $objProduct->getPrice()->getAmount();
+ } else {
+ $arrData[$strField][$objProduct->id] = 0;
+ }
+ } else {
+ $arrData[$strField][$objProduct->id] = strtolower(
+ str_replace('"', '', $objProduct->$strField)
+ );
+ }
+ }
+
+ $arrParam[] = &$arrData[$strField];
+ $arrParam[] = $arrConfig[0];
+ $arrParam[] = $arrConfig[1];
+ }
+
+ // Add product array as the last item.
+ // This will sort the products array based on the sorting of the passed in arguments.
+ $arrParam[] = &$arrProducts;
+ \call_user_func_array('array_multisort', $arrParam);
+ }
+
+ $objProducts = new Collection($arrProducts, static::$strTable);
+ }
+
+ return $objProducts;
+ }
+
+ /**
+ * Return select statement to load product data including multilingual fields
+ *
+ * @param array $arrOptions an array of columns
+ *
+ * @return string
+ */
+ protected static function buildFindQuery(array $arrOptions)
+ {
+ $objBase = DcaExtractor::getInstance($arrOptions['table']);
+ $hasTranslations = (static::countTranslatedProducts() > 0);
+ $hasVariants = (ProductType::countByVariants() > 0);
+
+ $arrJoins = array();
+ $arrFields = array(
+ $arrOptions['table'] . '.*',
+ "'" . str_replace('-', '_', $GLOBALS['TL_LANGUAGE']) . "' AS language",
+ );
+
+ if ($hasVariants) {
+ $arrFields[] = sprintf(
+ 'IF(%s.pid>0, parent.type, %s.type) AS type',
+ $arrOptions['table'],
+ $arrOptions['table']
+ );
+ }
+
+ if ($hasTranslations) {
+ foreach (Attribute::getMultilingualFields() as $attribute) {
+ $arrFields[] = "IFNULL(translation.$attribute, " . $arrOptions['table'] . ".$attribute) AS $attribute";
+ }
+ }
+
+ foreach (Attribute::getFetchFallbackFields() as $attribute) {
+ $arrFields[] = "{$arrOptions['table']}.$attribute AS {$attribute}_fallback";
+ }
+
+ if ($hasTranslations) {
+ $arrJoins[] = sprintf(
+ " LEFT OUTER JOIN %s translation ON %s.id=translation.pid AND translation.language='%s'",
+ $arrOptions['table'],
+ $arrOptions['table'],
+ str_replace('-', '_', $GLOBALS['TL_LANGUAGE'])
+ );
+
+ $arrOptions['group'] = (null === $arrOptions['group'] ? '' : $arrOptions['group'].', ') . 'translation.id';
+ }
+
+ if ($hasVariants) {
+ $arrJoins[] = sprintf(
+ ' LEFT OUTER JOIN %s parent ON %s.pid=parent.id',
+ $arrOptions['table'],
+ $arrOptions['table']
+ );
+ }
+
+ $arrFields[] = 'GROUP_CONCAT(c.page_id) AS product_categories';
+ $arrJoins[] = sprintf(
+ ' LEFT OUTER JOIN %s c ON %s=c.pid',
+ ProductCategory::getTable(),
+ ($hasVariants ? "IFNULL(parent.id, {$arrOptions['table']}.id)" : "{$arrOptions['table']}.id")
+ );
+
+ if ('c.sorting' === ($arrOptions['order'] ?? '')) {
+ $arrFields[] = 'c.sorting';
+
+ $arrOptions['group'] = (null === $arrOptions['group'] ? '' : $arrOptions['group'].', ') . 'c.id';
+ }
+
+ if ($objBase->hasRelations()) {
+ $intCount = 0;
+
+ foreach ($objBase->getRelations() as $strKey => $arrConfig) {
+ // Automatically join the single-relation records
+ if (('eager' === $arrConfig['load'] || ($arrOptions['eager'] ?? false))
+ && ('hasOne' === $arrConfig['type'] || 'belongsTo' === $arrConfig['type'])
+ ) {
+ if (\is_array($arrOptions['joinAliases'] ?? null)
+ && ($key = array_search($arrConfig['table'], $arrOptions['joinAliases'], true)) !== false
+ ) {
+ $strJoinAlias = $key;
+ unset($arrOptions['joinAliases'][$key]);
+ } else {
+ ++$intCount;
+ $strJoinAlias = 'j' . $intCount;
+ }
+
+ $objRelated = DcaExtractor::getInstance($arrConfig['table']);
+
+ foreach ($objRelated->getFields() as $strField => $config) {
+ $arrFields[] = $strJoinAlias . '.' . $strField . ' AS ' . $strKey . '__' . $strField;
+ }
+
+ $arrJoins[] = sprintf(
+ ' LEFT JOIN %s %s ON %s.%s=%s.id',
+ $arrConfig['table'],
+ $strJoinAlias,
+ $arrOptions['table'],
+ $strKey,
+ $strJoinAlias
+ );
+ }
+ }
+ }
+
+ // Generate the query
+ $strQuery = 'SELECT ' . implode(', ', $arrFields) . ' FROM ' . $arrOptions['table'] . implode('', $arrJoins);
+
+ // Where condition
+ if (!\is_array($arrOptions['column'] ?? null)) {
+ $arrOptions['column'] = array($arrOptions['table'] . '.' . $arrOptions['column'] . '=?');
+ }
+
+ // The model must never find a language record
+ $strQuery .= " WHERE {$arrOptions['table']}.language='' AND " . implode(' AND ', $arrOptions['column']);
+
+ // Group by
+ if (($arrOptions['group'] ?? null) !== null) {
+ $strQuery .= ' GROUP BY ' . $arrOptions['group'];
+ }
+
+ // Order by
+ if (($arrOptions['order'] ?? null) !== null) {
+ $strQuery .= ' ORDER BY ' . $arrOptions['order'];
+ }
+
+ return $strQuery;
+ }
+
+ /**
+ * Build a query based on the given options to count the number of products.
+ *
+ * @param array $arrOptions The options array
+ *
+ * @return string
+ */
+ protected static function buildCountQuery(array $arrOptions)
+ {
+ $hasTranslations = (static::countTranslatedProducts() > 0);
+ $hasVariants = (ProductType::countByVariants() > 0);
+
+ $arrJoins = array();
+ $arrFields = array(
+ $arrOptions['table'] . '.id',
+ "'" . str_replace('-', '_', $GLOBALS['TL_LANGUAGE']) . "' AS language",
+ );
+
+ if ($hasVariants) {
+ $arrFields[] = sprintf(
+ 'IF(%s.pid>0, parent.type, %s.type) AS type',
+ $arrOptions['table'],
+ $arrOptions['table']
+ );
+ }
+
+ if ($hasTranslations) {
+ foreach (Attribute::getMultilingualFields() as $attribute) {
+ $arrFields[] = "IFNULL(translation.$attribute, " . $arrOptions['table'] . ".$attribute) AS $attribute";
+ }
+ }
+
+ if ($hasTranslations) {
+ $arrJoins[] = sprintf(
+ " LEFT OUTER JOIN %s translation ON %s.id=translation.pid AND translation.language='%s'",
+ $arrOptions['table'],
+ $arrOptions['table'],
+ str_replace('-', '_', $GLOBALS['TL_LANGUAGE'])
+ );
+
+ $arrOptions['group'] = (null === $arrOptions['group'] ? '' : $arrOptions['group'].', ') . 'translation.id, tl_iso_product.id';
+ }
+
+ if ($hasVariants) {
+ $arrJoins[] = sprintf(
+ ' LEFT OUTER JOIN %s parent ON %s.pid=parent.id',
+ $arrOptions['table'],
+ $arrOptions['table']
+ );
+ }
+
+ $arrJoins[] = sprintf(
+ ' LEFT OUTER JOIN %s c ON %s=c.pid',
+ ProductCategory::getTable(),
+ ($hasVariants ? "IFNULL(parent.id, {$arrOptions['table']}.id)" : "{$arrOptions['table']}.id")
+ );
+
+ // Generate the query
+ $strWhere = '';
+ $strQuery = '
+ SELECT
+ ' . implode(', ', $arrFields) . '
+ FROM ' . $arrOptions['table'] . implode('', $arrJoins);
+
+ // Where condition
+ if (!empty($arrOptions['column'])) {
+ if (!\is_array($arrOptions['column'])) {
+ $arrOptions['column'] = array($arrOptions['table'] . '.' . $arrOptions['column'] . '=?');
+ }
+
+ $strWhere = ' AND ' . implode(' AND ', $arrOptions['column']);
+ }
+
+ // The model must never find a language record
+ $strQuery .= " WHERE {$arrOptions['table']}.language=''" . $strWhere;
+
+ // Group by
+ if ($arrOptions['group'] !== null) {
+ $strQuery .= ' GROUP BY ' . $arrOptions['group'];
+ }
+
+ return 'SELECT COUNT(*) AS count FROM ('.$strQuery.') c1';
+ }
+
+ /**
+ * Return select statement to load product data including multilingual fields
+ *
+ * @param array $arrOptions an array of columns
+ * @param array $arrJoinAliases an array of table join aliases
+ *
+ * @return string
+ *
+ * @deprecated use buildFindQuery introduced in Contao 3.3
+ */
+ protected static function buildQueryString($arrOptions, $arrJoinAliases = array('t' => 'tl_iso_producttype'))
+ {
+ $arrOptions['joinAliases'] = $arrJoinAliases;
+
+ return static::buildFindQuery((array) $arrOptions);
+ }
+}
diff --git a/api2/ProductCollection.php b/api2/ProductCollection.php
new file mode 100644
index 0000000..0e8b41d
--- /dev/null
+++ b/api2/ProductCollection.php
@@ -0,0 +1,2097 @@
+arrData['uniqid'] = $this->generateUniqueId();
+
+ // Do not use __destruct, because Database object might be destructed first
+ // see http://github.com/contao/core/issues/2236
+ if ('FE' === TL_MODE) {
+ register_shutdown_function(array($this, 'updateDatabase'), false);
+ }
+ }
+
+ /**
+ * Prevent cloning because we can't copy items etc.
+ *
+ * @throws \LogicException because ProductCollection cannot be cloned
+ */
+ /** @noinspection MagicMethodsValidityInspection */
+ public function __clone()
+ {
+ throw new \LogicException(
+ 'Product collections can\'t be cloned, you should probably use ProductCollection::createFromCollection'
+ );
+ }
+
+ /**
+ * Shutdown function to update prices of items and collection
+ *
+ * @param boolean $blnCreate If true create Model even if not in registry or not saved at all
+ */
+ public function updateDatabase($blnCreate = true)
+ {
+ if (!$this->blnPreventSaving
+ && !$this->isLocked()
+ && (Registry::getInstance()->isRegistered($this) || $blnCreate)
+ ) {
+ foreach ($this->getItems() as $objItem) {
+ if (!$objItem->hasProduct()) {
+ continue;
+ }
+
+ $objItem->price = $objItem->getPrice();
+ $objItem->tax_free_price = $objItem->getTaxFreePrice();
+ $objItem->save();
+ }
+
+ // First call to __set for tstamp will truncate the cache
+ $this->tstamp = time();
+ $this->subtotal = $this->getSubtotal();
+ $this->tax_free_subtotal = $this->getTaxFreeSubtotal();
+ $this->total = $this->getTotal();
+ $this->tax_free_total = $this->getTaxFreeTotal();
+ $this->currency = (string) $this->getConfig()->currency;
+
+ $this->save();
+ }
+ }
+
+ /**
+ * Mark a field as modified
+ *
+ * @param string $strKey The field key
+ */
+ public function markModified($strKey)
+ {
+ if ('locked' === $strKey) {
+ throw new \InvalidArgumentException('Cannot change lock status of collection');
+ }
+
+ if ('document_number' === $strKey) {
+ throw new \InvalidArgumentException(
+ 'Cannot change document number of a collection, must be generated using generateDocumentNumber()'
+ );
+ }
+
+ $this->clearCache();
+
+ parent::markModified($strKey);
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function getId()
+ {
+ return (int) $this->id;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function getUniqueId()
+ {
+ return $this->uniqid;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function getMember()
+ {
+ if (0 === (int) $this->member) {
+ return null;
+ }
+
+ return MemberModel::findByPk($this->member);
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function getStoreId()
+ {
+ return (int) $this->store_id;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function getConfig()
+ {
+ try {
+ return $this->getRelated('config_id');
+ } catch (\Exception $e) {
+ return null;
+ }
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function isLocked()
+ {
+ return null !== $this->locked;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function getLockTime()
+ {
+ return $this->locked;
+ }
+
+
+ /**
+ * @inheritdoc
+ */
+ public function isEmpty()
+ {
+ return 0 === \count($this->getItems());
+ }
+
+ /**
+ * Return payment method for this collection
+ *
+ * @return IsotopePayment|null
+ */
+ public function getPaymentMethod()
+ {
+ if (false === $this->objPayment) {
+ try {
+ $this->objPayment = $this->getRelated('payment_id');
+ } catch (\Exception $e) {
+ $this->objPayment = null;
+ }
+ }
+
+ return $this->objPayment;
+ }
+
+ /**
+ * Set payment method for this collection
+ *
+ * @param IsotopePayment $objPayment
+ */
+ public function setPaymentMethod(IsotopePayment $objPayment = null)
+ {
+ $this->payment_id = (null === $objPayment ? 0 : $objPayment->getId());
+ $this->objPayment = $objPayment;
+ }
+
+ /**
+ * Return surcharge for current payment method
+ *
+ * @return ProductCollectionSurcharge|null
+ */
+ public function getPaymentSurcharge()
+ {
+ return $this->hasPayment() ? $this->getPaymentMethod()->getSurcharge($this) : null;
+ }
+
+ /**
+ * Return boolean whether collection has payment
+ *
+ * @return bool
+ */
+ public function hasPayment()
+ {
+ return null !== $this->getPaymentMethod();
+ }
+
+ /**
+ * Return boolean whether collection requires payment
+ *
+ * @return bool
+ */
+ public function requiresPayment()
+ {
+ return $this->getTotal() > 0;
+ }
+
+ /**
+ * Return shipping method for this collection
+ *
+ * @return IsotopeShipping|null
+ */
+ public function getShippingMethod()
+ {
+ if (false === $this->objShipping) {
+ try {
+ $this->objShipping = $this->getRelated('shipping_id');
+ } catch (\Exception $e) {
+ $this->objShipping = null;
+ }
+ }
+
+ return $this->objShipping;
+ }
+
+ /**
+ * Set shipping method for this collection
+ *
+ * @param IsotopeShipping $objShipping
+ */
+ public function setShippingMethod(IsotopeShipping $objShipping = null)
+ {
+ $this->shipping_id = (null === $objShipping ? 0 : $objShipping->getId());
+ $this->objShipping = $objShipping;
+ }
+
+ /**
+ * Return surcharge for current shipping method
+ *
+ * @return ProductCollectionSurcharge|null
+ */
+ public function getShippingSurcharge()
+ {
+ return $this->hasShipping() ? $this->getShippingMethod()->getSurcharge($this) : null;
+ }
+
+ /**
+ * Return boolean whether collection has shipping
+ *
+ * @return bool
+ */
+ public function hasShipping()
+ {
+ return null !== $this->getShippingMethod();
+ }
+
+ /**
+ * Return boolean whether collection requires shipping
+ *
+ * @return bool
+ */
+ public function requiresShipping()
+ {
+ if (!isset($this->arrCache['requiresShipping'])) {
+ $this->arrCache['requiresShipping'] = false;
+ $arrItems = $this->getItems();
+
+ foreach ($arrItems as $objItem) {
+ if ($objItem->hasProduct() && !$objItem->getProduct()->isExemptFromShipping()) {
+ $this->arrCache['requiresShipping'] = true;
+ break;
+ }
+ }
+ }
+
+ return $this->arrCache['requiresShipping'];
+ }
+
+ /**
+ * Get billing address for collection
+ *
+ * @return \Isotope\Model\Address|null
+ */
+ public function getBillingAddress()
+ {
+ if (!$this->billing_address_id) {
+ return null;
+ }
+
+ return $this->getRelated('billing_address_id');
+ }
+
+ /**
+ * Set billing address for collection
+ *
+ * @param Address $objAddress
+ */
+ public function setBillingAddress(Address $objAddress = null)
+ {
+ if (null === $objAddress || $objAddress->id < 1) {
+ $this->billing_address_id = 0;
+ } else {
+ $this->billing_address_id = $objAddress->id;
+ }
+ }
+
+ /**
+ * Return boolean whether collection requires a shipping address
+ *
+ * @return bool
+ */
+ public function requiresShippingAddress()
+ {
+ if (!$this->requiresShipping()) {
+ return false;
+ }
+
+ if (!isset($this->arrCache['requiresShippingAddress'])) {
+ $this->arrCache['requiresShippingAddress'] = true;
+ $arrItems = $this->getItems();
+
+ foreach ($arrItems as $objItem) {
+ $product = $objItem->getProduct();
+ if ($product instanceof IsotopeProduct && \method_exists($product, 'isPickupOnly') && $product->isPickupOnly()) {
+ $this->arrCache['requiresShippingAddress'] = false;
+ break;
+ }
+ }
+ }
+
+ return $this->arrCache['requiresShippingAddress'];
+ }
+
+ /**
+ * Get shipping address for collection
+ *
+ * @return Address|null
+ */
+ public function getShippingAddress()
+ {
+ if (!$this->shipping_address_id || !$this->requiresShippingAddress()) {
+ return null;
+ }
+
+ return $this->getRelated('shipping_address_id');
+ }
+
+ /**
+ * Set shipping address for collection
+ *
+ * @param Address $objAddress
+ */
+ public function setShippingAddress(Address $objAddress = null)
+ {
+ if (null === $objAddress || $objAddress->id < 1) {
+ $this->shipping_address_id = 0;
+ } else {
+ $this->shipping_address_id = $objAddress->id;
+ }
+ }
+
+ /**
+ * Returns the generated document number or empty string if not available.
+ *
+ * @return string
+ */
+ public function getDocumentNumber()
+ {
+ return (string) $this->arrData['document_number'];
+ }
+
+ /**
+ * Return customer email address for the collection
+ *
+ * @return string
+ */
+ public function getEmailRecipient()
+ {
+ $strName = '';
+ $strEmail = '';
+ $objBillingAddress = $this->getBillingAddress();
+ $objShippingAddress = $this->getShippingAddress();
+
+ if ($objBillingAddress->email != '') {
+ $strName = $objBillingAddress->firstname . ' ' . $objBillingAddress->lastname;
+ $strEmail = $objBillingAddress->email;
+ } elseif ($objShippingAddress->email != '') {
+ $strName = $objShippingAddress->firstname . ' ' . $objShippingAddress->lastname;
+ $strEmail = $objShippingAddress->email;
+ } elseif ($this->member > 0
+ && ($objMember = MemberModel::findByPk($this->member)) !== null
+ && $objMember->email != ''
+ ) {
+ $strName = $objMember->firstname . ' ' . $objMember->lastname;
+ $strEmail = $objMember->email;
+ }
+
+ if (trim($strName) != '') {
+ // Romanize friendly name to prevent email issues
+ $strName = html_entity_decode($strName, ENT_QUOTES, $GLOBALS['TL_CONFIG']['characterSet']);
+ $strName = StringUtil::stripInsertTags($strName);
+ $strName = utf8_romanize($strName);
+ $strName = preg_replace('/[^A-Za-z0-9.!#$%&\'*+-\/=?^_ `{|}~]+/i', '_', $strName);
+
+ $strEmail = sprintf('"%s" <%s>', $strName, $strEmail);
+ }
+
+ // !HOOK: determine email recipient for collection
+ if (isset($GLOBALS['ISO_HOOKS']['emailRecipientForCollection'])
+ && \is_array($GLOBALS['ISO_HOOKS']['emailRecipientForCollection'])
+ ) {
+ foreach ($GLOBALS['ISO_HOOKS']['emailRecipientForCollection'] as $callback) {
+ $strEmail = System::importStatic($callback[0])->{$callback[1]}($strEmail, $this);
+ }
+ }
+
+ return $strEmail;
+ }
+
+ /**
+ * Return number of items in the collection
+ *
+ * @return int
+ */
+ public function countItems()
+ {
+ if (!isset($this->arrCache['countItems'])) {
+ $this->arrCache['countItems'] = ProductCollectionItem::countBy('pid', (int) $this->id);
+ }
+
+ return $this->arrCache['countItems'];
+ }
+
+ /**
+ * Return summary of item quantity in collection
+ *
+ * @return int
+ */
+ public function sumItemsQuantity()
+ {
+ if (!isset($this->arrCache['sumItemsQuantity'])) {
+ $this->arrCache['sumItemsQuantity'] = ProductCollectionItem::sumBy('quantity', 'pid', (int) $this->id);
+ }
+
+ return $this->arrCache['sumItemsQuantity'];
+ }
+
+ /**
+ * Load settings from database field
+ *
+ * @param array $arrData
+ *
+ * @return $this
+ */
+ public function setRow(array $arrData)
+ {
+ parent::setRow($arrData);
+
+ // Merge settings into arrData, save() will move the values back
+ $this->arrData = array_merge(StringUtil::deserialize($arrData['settings'] ?? [], true), $this->arrData);
+
+ return $this;
+ }
+
+ /**
+ * Save all non-database fields in the settings array
+ *
+ * @return $this
+ */
+ public function save()
+ {
+ // The instance cannot be saved
+ if ($this->blnPreventSaving) {
+ throw new \LogicException('The model instance has been detached and cannot be saved');
+ }
+
+ // !HOOK: additional functionality when saving a collection
+ if (isset($GLOBALS['ISO_HOOKS']['saveCollection']) && \is_array($GLOBALS['ISO_HOOKS']['saveCollection'])) {
+ foreach ($GLOBALS['ISO_HOOKS']['saveCollection'] as $callback) {
+ System::importStatic($callback[0])->{$callback[1]}($this);
+ }
+ }
+
+ $arrDbFields = Database::getInstance()->getFieldNames(static::$strTable);
+ $arrModified = array_diff_key($this->arrModified, array_flip($arrDbFields));
+
+ if (!empty($arrModified)) {
+ $arrSettings = StringUtil::deserialize($this->settings, true);
+ $arrSettings = array_merge($arrSettings, array_intersect_key($this->arrData, $arrModified));
+
+ $this->settings = serialize($arrSettings);
+ }
+
+ return parent::save();
+ }
+
+ /**
+ * Also delete child table records when dropping this collection
+ *
+ * @param bool $blnForce Force to delete the collection even if it's locked
+ *
+ * @return int Number of rows affected
+ *
+ * @throws \BadMethodCallException if the product collection is locked.
+ */
+ public function delete($blnForce = false)
+ {
+ if (!$blnForce) {
+ $this->ensureNotLocked();
+
+ // !HOOK: additional functionality when deleting a collection
+ if (isset($GLOBALS['ISO_HOOKS']['deleteCollection'])
+ && \is_array($GLOBALS['ISO_HOOKS']['deleteCollection'])
+ ) {
+ foreach ($GLOBALS['ISO_HOOKS']['deleteCollection'] as $callback) {
+ $blnRemove = System::importStatic($callback[0])->{$callback[1]}($this);
+
+ if ($blnRemove === false) {
+ return 0;
+ }
+ }
+ }
+ }
+
+ $intPid = $this->id;
+ $intAffectedRows = parent::delete();
+
+ if ($intAffectedRows > 0 && $intPid > 0) {
+ Database::getInstance()->query("
+ DELETE FROM tl_iso_product_collection_download
+ WHERE pid IN (SELECT id FROM tl_iso_product_collection_item WHERE pid=$intPid)
+ ");
+ Database::getInstance()->query(
+ "DELETE FROM tl_iso_product_collection_item WHERE pid=$intPid"
+ );
+ Database::getInstance()->query(
+ "DELETE FROM tl_iso_product_collection_surcharge WHERE pid=$intPid"
+ );
+ Database::getInstance()->query(
+ "DELETE FROM tl_iso_address WHERE ptable='" . static::$strTable . "' AND pid=$intPid"
+ );
+ }
+
+ $this->arrCache = array();
+ $this->arrItems = null;
+ $this->arrSurcharges = null;
+
+ // !HOOK: additional functionality when deleting a collection
+ if (isset($GLOBALS['ISO_HOOKS']['postDeleteCollection'])
+ && \is_array($GLOBALS['ISO_HOOKS']['postDeleteCollection'])
+ ) {
+ foreach ($GLOBALS['ISO_HOOKS']['postDeleteCollection'] as $callback) {
+ System::importStatic($callback[0])->{$callback[1]}($this, $intPid);
+ }
+ }
+
+ return $intAffectedRows;
+ }
+
+ /**
+ * Delete all products in the collection
+ *
+ * @throws \BadMethodCallException if the product collection is locked.
+ */
+ public function purge()
+ {
+ $this->ensureNotLocked();
+
+ foreach ($this->getItems() as $objItem) {
+ $this->deleteItem($objItem);
+ }
+
+ foreach ($this->getSurcharges() as $objSurcharge) {
+ if ($objSurcharge->id) {
+ $objSurcharge->delete();
+ }
+ }
+
+ $this->clearCache();
+ }
+
+ /**
+ * Lock collection from begin modified
+ *
+ * @throws \BadMethodCallException if the product collection is locked.
+ */
+ public function lock()
+ {
+ $this->ensureNotLocked();
+
+ global $objPage;
+ $time = time();
+
+ $this->pageId = (int) $objPage->id;
+ $this->language = (string) $GLOBALS['TL_LANGUAGE'];
+
+ $this->createPrivateAddresses();
+ $this->updateDatabase();
+
+ // Add surcharges to the collection
+ $sorting = 128;
+ foreach ($this->getSurcharges() as $objSurcharge) {
+ $objSurcharge->pid = $this->id;
+ $objSurcharge->tstamp = $time;
+ $objSurcharge->sorting = $sorting;
+ $objSurcharge->save();
+
+ $sorting += 128;
+ }
+
+ // Add downloads from products to the collection
+ foreach (ProductCollectionDownload::createForProductsInCollection($this) as $objDownload) {
+ $objDownload->save();
+ }
+
+ // Can't use model, it would not save as soon as it's locked
+ Database::getInstance()->query(
+ "UPDATE tl_iso_product_collection SET locked=$time WHERE id=" . $this->id
+ );
+ $this->arrData['locked'] = $time;
+
+ // !HOOK: pre-process checkout
+ if (isset($GLOBALS['ISO_HOOKS']['collectionLocked']) && \is_array($GLOBALS['ISO_HOOKS']['collectionLocked'])) {
+ foreach ($GLOBALS['ISO_HOOKS']['collectionLocked'] as $callback) {
+ System::importStatic($callback[0])->{$callback[1]}($this);
+ }
+ }
+
+ $this->clearCache();
+ }
+
+ /**
+ * Sum total price of all items in the collection
+ *
+ * @return float
+ */
+ public function getSubtotal()
+ {
+ if ($this->isLocked()) {
+ return $this->subtotal;
+ }
+
+ if (!isset($this->arrCache['subtotal'])) {
+ $fltAmount = 0;
+ $arrItems = $this->getItems();
+
+ foreach ($arrItems as $objItem) {
+ $varPrice = $objItem->getTotalPrice();
+
+ if ($varPrice !== null) {
+ $fltAmount += $varPrice;
+ }
+ }
+
+ $this->arrCache['subtotal'] = $fltAmount;
+ }
+
+ return $this->arrCache['subtotal'];
+ }
+
+ /**
+ * Sum total tax free price of all items in the collection
+ *
+ * @return float
+ */
+ public function getTaxFreeSubtotal()
+ {
+ if ($this->isLocked()) {
+ return $this->tax_free_subtotal;
+ }
+
+ if (!isset($this->arrCache['taxFreeSubtotal'])) {
+ $fltAmount = 0;
+ $arrItems = $this->getItems();
+
+ foreach ($arrItems as $objItem) {
+ $varPrice = $objItem->getTaxFreeTotalPrice();
+
+ if ($varPrice !== null) {
+ $fltAmount += $varPrice;
+ }
+ }
+
+ $this->arrCache['taxFreeSubtotal'] = $fltAmount;
+ }
+
+ return $this->arrCache['taxFreeSubtotal'];
+ }
+
+ /**
+ * Sum total price of items and surcharges
+ *
+ * @return float
+ */
+ public function getTotal()
+ {
+ if ($this->isLocked()) {
+ return $this->total;
+ }
+
+ if (!isset($this->arrCache['total'])) {
+ $fltAmount = $this->getSubtotal();
+ $arrSurcharges = $this->getSurcharges();
+
+ foreach ($arrSurcharges as $objSurcharge) {
+ if ($objSurcharge->addToTotal) {
+ $fltAmount += $objSurcharge->total_price;
+ }
+ }
+
+ $this->arrCache['total'] = $fltAmount > 0 ? $fltAmount : 0;
+ }
+
+ return $this->arrCache['total'];
+ }
+
+ /**
+ * Sum tax free total of items and surcharges
+ *
+ * @return float
+ */
+ public function getTaxFreeTotal()
+ {
+ if ($this->isLocked()) {
+ return $this->tax_free_total;
+ }
+
+ if (!isset($this->arrCache['taxFreeTotal'])) {
+ $arrSurcharges = $this->getSurcharges();
+
+ if (Config::PRICE_DISPLAY_GROSS === $this->getConfig()->priceDisplay) {
+ $fltAmount = $this->getTotal();
+
+ foreach ($arrSurcharges as $objSurcharge) {
+ if ($objSurcharge instanceof Tax) {
+ $fltAmount -= $objSurcharge->total_price;
+ }
+ }
+ } else {
+ $fltAmount = $this->getTaxFreeSubtotal();
+
+ foreach ($arrSurcharges as $objSurcharge) {
+ if ($objSurcharge->addToTotal) {
+ $fltAmount += $objSurcharge->tax_free_total_price;
+ }
+ }
+ }
+
+ $this->arrCache['taxFreeTotal'] = $fltAmount > 0 ? Isotope::roundPrice($fltAmount) : 0;
+ }
+
+ return $this->arrCache['taxFreeTotal'];
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function getCurrency()
+ {
+ return $this->currency;
+ }
+
+ /**
+ * Return the item with the latest timestamp (e.g. the latest added item)
+ *
+ * @return ProductCollectionItem|null
+ */
+ public function getLatestItem()
+ {
+ if (!isset($this->arrCache['latestItem'])) {
+ $latest = 0;
+ $arrItems = $this->getItems();
+
+ foreach ($arrItems as $objItem) {
+ if ($objItem->tstamp > $latest) {
+ $this->arrCache['latestItem'] = $objItem;
+ $latest = $objItem->tstamp;
+ }
+ }
+ }
+
+ return $this->arrCache['latestItem'];
+ }
+
+ /**
+ * Return timestamp when this collection was created
+ * This is relevant for price calculation
+ *
+ * @return int
+ */
+ public function getLastModification()
+ {
+ if ($this->isLocked()) {
+ return $this->locked;
+ }
+
+ return $this->tstamp ? : time();
+ }
+
+ /**
+ * Return all items in the collection
+ *
+ * @param callable $varCallable
+ * @param bool $blnNoCache
+ *
+ * @return ProductCollectionItem[]
+ */
+ public function getItems($varCallable = null, $blnNoCache = false)
+ {
+ if (null === $this->arrItems || $blnNoCache) {
+ $this->arrItems = array();
+
+ if (($objItems = ProductCollectionItem::findBy('pid', $this->id)) !== null) {
+ /** @var ProductCollectionItem $objItem */
+ foreach ($objItems as $objItem) {
+ if ($this->isLocked()) {
+ $objItem->lock();
+ }
+
+ // Add error message for items no longer available
+ if (!$objItem->isAvailable() && !$objItem->hasErrors()) {
+ $objItem->addError($GLOBALS['TL_LANG']['ERR']['collectionItemNotAvailable']);
+ }
+
+ $this->arrItems[$objItem->id] = $objItem;
+ }
+ }
+ }
+
+ if ($varCallable === null) {
+ return $this->arrItems;
+ }
+
+ // not allowed to chance items
+ $arrItems = $this->arrItems;
+
+ return \call_user_func($varCallable, $arrItems);
+ }
+
+ /**
+ * Search item for a specific product
+ *
+ * @param IsotopeProduct $objProduct
+ *
+ * @return ProductCollectionItem|null
+ */
+ public function getItemForProduct(IsotopeProduct $objProduct)
+ {
+ $strClass = array_search(\get_class($objProduct), Product::getModelTypes(), true);
+
+ $objItem = ProductCollectionItem::findOneBy(
+ array('pid=?', 'type=?', 'product_id=?', 'configuration=?'),
+ array($this->id, $strClass, $objProduct->getId(), serialize($objProduct->getOptions()))
+ );
+
+ return $objItem;
+ }
+
+ /**
+ * Gets the product collection with given ID if it belongs to this collection.
+ *
+ * @param int $id
+ *
+ * @return ProductCollectionItem|null
+ */
+ public function getItemById($id)
+ {
+ $items = $this->getItems();
+
+ if (!isset($items[$id])) {
+ return null;
+ }
+
+ return $items[$id];
+ }
+
+ /**
+ * Check if a given product is already in the collection
+ *
+ * @param IsotopeProduct $objProduct
+ * @param bool $blnIdentical
+ *
+ * @return bool
+ */
+ public function hasProduct(IsotopeProduct $objProduct, $blnIdentical = true)
+ {
+ if (true === $blnIdentical) {
+ return null !== $this->getItemForProduct($objProduct);
+ }
+
+ $intId = $objProduct->getProductId();
+
+ foreach ($this->getItems() as $objItem) {
+ if ($objItem->hasProduct()
+ && ($objItem->getProduct()->getId() == $intId || $objItem->getProduct()->getProductId() == $intId)
+ ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Add a product to the collection
+ *
+ * @param IsotopeProduct $objProduct
+ * @param int $intQuantity
+ * @param array $arrConfig
+ *
+ * @return ProductCollectionItem|false
+ */
+ public function addProduct(IsotopeProduct $objProduct, $intQuantity, array $arrConfig = array())
+ {
+ // !HOOK: additional functionality when adding product to collection
+ if (isset($GLOBALS['ISO_HOOKS']['addProductToCollection'])
+ && \is_array($GLOBALS['ISO_HOOKS']['addProductToCollection'])
+ ) {
+ foreach ($GLOBALS['ISO_HOOKS']['addProductToCollection'] as $callback) {
+ $intQuantity = System::importStatic($callback[0])->{$callback[1]}($objProduct, $intQuantity, $this, $arrConfig);
+ }
+ }
+
+ if ($intQuantity == 0) {
+ return false;
+ }
+
+ $time = time();
+ $this->tstamp = $time;
+
+ // Make sure collection is in DB before adding product
+ if (!Registry::getInstance()->isRegistered($this)) {
+ $this->save();
+ }
+
+ // Remove uploaded files from session so they are not added to the next product (see #646)
+ unset($_SESSION['FILES']);
+
+ $objItem = $this->getItemForProduct($objProduct);
+ $intMinimumQuantity = $objProduct->getMinimumQuantity();
+
+ if (null !== $objItem) {
+ if (($objItem->quantity + $intQuantity) < $intMinimumQuantity) {
+ Message::addInfo(sprintf(
+ $GLOBALS['TL_LANG']['ERR']['productMinimumQuantity'],
+ $objProduct->getName(),
+ $intMinimumQuantity
+ ));
+ $intQuantity = $intMinimumQuantity - $objItem->quantity;
+ }
+
+ $objItem->increaseQuantityBy($intQuantity);
+ } else {
+ if ($intQuantity < $intMinimumQuantity) {
+ Message::addInfo(sprintf(
+ $GLOBALS['TL_LANG']['ERR']['productMinimumQuantity'],
+ $objProduct->getName(),
+ $intMinimumQuantity
+ ));
+ $intQuantity = $intMinimumQuantity;
+ }
+
+ $objItem = new ProductCollectionItem();
+ $objItem->pid = $this->id;
+ $objItem->jumpTo = isset($arrConfig['jumpTo']) ? (int) $arrConfig['jumpTo']->id : 0;
+
+ $this->setProductForItem($objProduct, $objItem, $intQuantity);
+ $objItem->save();
+
+ // Add the new item to our cache
+ $this->arrItems[$objItem->id] = $objItem;
+ }
+
+ // !HOOK: additional functionality when adding product to collection
+ if (isset($GLOBALS['ISO_HOOKS']['postAddProductToCollection'])
+ && \is_array($GLOBALS['ISO_HOOKS']['postAddProductToCollection'])
+ ) {
+ foreach ($GLOBALS['ISO_HOOKS']['postAddProductToCollection'] as $callback) {
+ System::importStatic($callback[0])->{$callback[1]}($objItem, $intQuantity, $this, $arrConfig);
+ }
+ }
+
+ return $objItem;
+ }
+
+ /**
+ * Update product details for a collection item.
+ *
+ * @param IsotopeProduct $objProduct
+ * @param ProductCollectionItem $objItem
+ *
+ * @return bool
+ */
+ public function updateProduct(IsotopeProduct $objProduct, ProductCollectionItem $objItem)
+ {
+ if ($objItem->pid != $this->id) {
+ throw new \InvalidArgumentException('Item does not belong to this collection');
+ }
+
+ // !HOOK: additional functionality when updating product in collection
+ if (isset($GLOBALS['ISO_HOOKS']['updateProductInCollection'])
+ && \is_array($GLOBALS['ISO_HOOKS']['updateProductInCollection'])
+ ) {
+ foreach ($GLOBALS['ISO_HOOKS']['updateProductInCollection'] as $callback) {
+ if (false === System::importStatic($callback[0])->{$callback[1]}($objProduct, $objItem, $this)) {
+ return false;
+ }
+ }
+ }
+
+ $this->setProductForItem($objProduct, $objItem, $objItem->quantity);
+ $objItem->save();
+
+ // !HOOK: additional functionality when adding product to collection
+ if (isset($GLOBALS['ISO_HOOKS']['postUpdateProductInCollection'])
+ && \is_array($GLOBALS['ISO_HOOKS']['postUpdateProductInCollection'])
+ ) {
+ foreach ($GLOBALS['ISO_HOOKS']['postUpdateProductInCollection'] as $callback) {
+ System::importStatic($callback[0])->{$callback[1]}($objProduct, $objItem, $this);
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Update a product collection item
+ *
+ * @param ProductCollectionItem $objItem The product object
+ * @param array $arrSet The property(ies) to adjust
+ *
+ * @return bool
+ */
+ public function updateItem(ProductCollectionItem $objItem, $arrSet)
+ {
+ return $this->updateItemById($objItem->id, $arrSet);
+ }
+
+ /**
+ * Update product collection item with given ID
+ *
+ * @param int $intId
+ * @param array $arrSet
+ *
+ * @return bool
+ */
+ public function updateItemById($intId, $arrSet)
+ {
+ $this->ensureNotLocked();
+
+ $arrItems = $this->getItems();
+
+ if (!isset($arrItems[$intId])) {
+ return false;
+ }
+
+ /** @var ProductCollectionItem $objItem */
+ $objItem = $arrItems[$intId];
+
+ // !HOOK: additional functionality when updating a product in the collection
+ if (isset($GLOBALS['ISO_HOOKS']['updateItemInCollection'])
+ && \is_array($GLOBALS['ISO_HOOKS']['updateItemInCollection'])
+ ) {
+ foreach ($GLOBALS['ISO_HOOKS']['updateItemInCollection'] as $callback) {
+ $arrSet = System::importStatic($callback[0])->{$callback[1]}($objItem, $arrSet, $this);
+
+ if (!\is_array($arrSet) || 0 === \count($arrSet)) {
+ return false;
+ }
+ }
+ }
+
+ // Quantity set to 0, delete item
+ if (isset($arrSet['quantity']) && $arrSet['quantity'] == 0) {
+ return $this->deleteItemById($intId);
+ }
+
+ if (isset($arrSet['quantity']) && $objItem->hasProduct()) {
+
+ // Set product quantity so we can determine the correct minimum price
+ $objProduct = $objItem->getProduct();
+ $intMinimumQuantity = $objProduct->getMinimumQuantity();
+
+ if ($arrSet['quantity'] < $intMinimumQuantity) {
+ Message::addInfo(sprintf(
+ $GLOBALS['TL_LANG']['ERR']['productMinimumQuantity'],
+ $objProduct->getName(),
+ $intMinimumQuantity
+ ));
+ $arrSet['quantity'] = $intMinimumQuantity;
+ }
+ }
+
+ $arrSet['tstamp'] = time();
+
+ foreach ($arrSet as $k => $v) {
+ $objItem->$k = $v;
+ }
+
+ $objItem->save();
+ $this->tstamp = time();
+
+ // !HOOK: additional functionality when adding product to collection
+ if (isset($GLOBALS['ISO_HOOKS']['postUpdateItemInCollection'])
+ && \is_array($GLOBALS['ISO_HOOKS']['postUpdateItemInCollection'])
+ ) {
+ foreach ($GLOBALS['ISO_HOOKS']['postUpdateItemInCollection'] as $callback) {
+ System::importStatic($callback[0])->{$callback[1]}($objItem, $arrSet['quantity'], $this);
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Remove item from collection
+ *
+ * @param ProductCollectionItem $objItem
+ *
+ * @return bool
+ */
+ public function deleteItem(ProductCollectionItem $objItem)
+ {
+ return $this->deleteItemById($objItem->id);
+ }
+
+ /**
+ * Remove item with given ID from collection
+ *
+ * @param int $intId
+ *
+ * @return bool
+ *
+ * @throws \BadMethodCallException if the product collection is locked.
+ */
+ public function deleteItemById($intId)
+ {
+ $this->ensureNotLocked();
+
+ $arrItems = $this->getItems();
+
+ if (!isset($arrItems[$intId])) {
+ return false;
+ }
+
+ $objItem = $arrItems[$intId];
+
+ // !HOOK: additional functionality when a product is removed from the collection
+ if (isset($GLOBALS['ISO_HOOKS']['deleteItemFromCollection'])
+ && \is_array($GLOBALS['ISO_HOOKS']['deleteItemFromCollection'])
+ ) {
+ foreach ($GLOBALS['ISO_HOOKS']['deleteItemFromCollection'] as $callback) {
+ $blnRemove = System::importStatic($callback[0])->{$callback[1]}($objItem, $this);
+
+ if ($blnRemove === false) {
+ return false;
+ }
+ }
+ }
+
+ $objItem->delete();
+
+ unset($this->arrItems[$intId]);
+
+ $this->tstamp = time();
+
+ // !HOOK: additional functionality when adding product to collection
+ if (isset($GLOBALS['ISO_HOOKS']['postDeleteItemFromCollection'])
+ && \is_array($GLOBALS['ISO_HOOKS']['postDeleteItemFromCollection'])
+ ) {
+ foreach ($GLOBALS['ISO_HOOKS']['postDeleteItemFromCollection'] as $callback) {
+ System::importStatic($callback[0])->{$callback[1]}($objItem, $this);
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Find surcharges for the current collection
+ *
+ * @return ProductCollectionSurcharge[]
+ */
+ public function getSurcharges()
+ {
+ if (null === $this->arrSurcharges) {
+ if ($this->isLocked()) {
+ $this->arrSurcharges = [];
+
+ if (($objSurcharges = ProductCollectionSurcharge::findBy('pid', $this->id)) !== null) {
+ $this->arrSurcharges = $objSurcharges->getModels();
+ }
+ } else {
+ $this->arrSurcharges = ProductCollectionSurcharge::findForCollection($this);
+ }
+ }
+
+ return $this->arrSurcharges;
+ }
+
+ /**
+ * Copy product collection items from another collection to this one (e.g. Cart to Order)
+ *
+ * @param IsotopeProductCollection $objSource
+ *
+ * @return int[]
+ *
+ * @throws \BadMethodCallException if the product collection is locked.
+ */
+ public function copyItemsFrom(IsotopeProductCollection $objSource)
+ {
+ $this->ensureNotLocked();
+
+ $this->updateDatabase();
+
+ // Make sure database table has the latest prices
+ $objSource->updateDatabase();
+
+ $time = time();
+ $arrIds = [];
+ $arrOldItems = $objSource->getItems();
+
+ foreach ($arrOldItems as $objOldItem) {
+
+ // !HOOK: additional functionality when copying product to collection
+ if (isset($GLOBALS['ISO_HOOKS']['copyCollectionItem'])
+ && \is_array($GLOBALS['ISO_HOOKS']['copyCollectionItem'])
+ ) {
+ foreach ($GLOBALS['ISO_HOOKS']['copyCollectionItem'] as $callback) {
+ if (System::importStatic($callback[0])->{$callback[1]}($objOldItem, $objSource, $this) === false) {
+ continue;
+ }
+ }
+ }
+
+ if ($objOldItem->hasProduct() && $this->hasProduct($objOldItem->getProduct())) {
+
+ $objNewItem = $this->getItemForProduct($objOldItem->getProduct());
+ $objNewItem->increaseQuantityBy($objOldItem->quantity);
+
+ } else {
+
+ $objNewItem = clone $objOldItem;
+ $objNewItem->pid = $this->id;
+ $objNewItem->tstamp = $time;
+ $objNewItem->save();
+ }
+
+ $arrIds[$objOldItem->id] = $objNewItem->id;
+ }
+
+ if (\count($arrIds) > 0) {
+ $this->tstamp = $time;
+ }
+
+ // !HOOK: additional functionality when adding product to collection
+ if (isset($GLOBALS['ISO_HOOKS']['copiedCollectionItems'])
+ && \is_array($GLOBALS['ISO_HOOKS']['copiedCollectionItems'])
+ ) {
+ foreach ($GLOBALS['ISO_HOOKS']['copiedCollectionItems'] as $callback) {
+ System::importStatic($callback[0])->{$callback[1]}($objSource, $this, $arrIds);
+ }
+ }
+
+ $this->clearCache();
+
+ return $arrIds;
+ }
+
+ /**
+ * Copy product collection surcharges from another collection to this one (e.g. Cart to Order)
+ *
+ * @param IsotopeProductCollection $objSource
+ * @param array $arrItemMap
+ *
+ * @return int[]
+ *
+ * @deprecated Deprecated since version 2.2, to be removed in 3.0.
+ * Surcharges are calculated on the fly, so it does not make sense to copy them from another one.
+ *
+ * @throws \BadMethodCallException if the product collection is locked.
+ */
+ public function copySurchargesFrom(IsotopeProductCollection $objSource, array $arrItemMap = array())
+ {
+ $this->ensureNotLocked();
+
+ $arrIds = array();
+ $time = time();
+ $sorting = 128;
+
+ foreach ($objSource->getSurcharges() as $objSourceSurcharge) {
+ $objSurcharge = clone $objSourceSurcharge;
+ $objSurcharge->pid = $this->id;
+ $objSurcharge->tstamp = $time;
+ $objSurcharge->sorting = $sorting;
+
+ // Convert surcharge amount for individual product IDs
+ $objSurcharge->convertCollectionItemIds($arrItemMap);
+
+ $objSurcharge->save();
+
+ $arrIds[$sorting] = $objSurcharge->id;
+
+ $sorting += 128;
+ }
+
+ // Empty cache
+ $this->arrSurcharges = null;
+ $this->arrCache = null;
+
+ return $arrIds;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function addToScale(Scale $objScale = null)
+ {
+ if (null === $objScale) {
+ $objScale = new Scale();
+ }
+
+ foreach ($this->getItems() as $objItem) {
+ if (!$objItem->hasProduct()) {
+ continue;
+ }
+
+ $objProduct = $objItem->getProduct();
+
+ if ($objProduct instanceof WeightAggregate) {
+ $objWeight = $objProduct->getWeight();
+
+ if (null !== $objWeight) {
+ for ($i = 0; $i < $objItem->quantity; $i++) {
+ $objScale->add($objWeight);
+ }
+ }
+
+ } elseif ($objProduct instanceof Weighable) {
+ for ($i = 0; $i < $objItem->quantity; $i++) {
+ $objScale->add($objProduct);
+ }
+ }
+ }
+
+ return $objScale;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function addToTemplate(Template $objTemplate, array $arrConfig = [])
+ {
+ $arrGalleries = array();
+ $objConfig = $this->getRelated('config_id') ?: Isotope::getConfig();
+ $arrItems = $this->addItemsToTemplate($objTemplate, $arrConfig['sorting']);
+
+ $objTemplate->id = $this->id;
+ $objTemplate->collection = $this;
+ $objTemplate->config = $objConfig;
+ $objTemplate->surcharges = Frontend::formatSurcharges($this->getSurcharges(), $objConfig->currency);
+ $objTemplate->subtotal = Isotope::formatPriceWithCurrency($this->getSubtotal(), true, $objConfig->currency);
+ $objTemplate->total = Isotope::formatPriceWithCurrency($this->getTotal(), true, $objConfig->currency);
+ $objTemplate->tax_free_subtotal = Isotope::formatPriceWithCurrency($this->getTaxFreeSubtotal(), true, $objConfig->currency);
+ $objTemplate->tax_free_total = Isotope::formatPriceWithCurrency($this->getTaxFreeTotal(), true, $objConfig->currency);
+
+ $objTemplate->hasAttribute = function ($strAttribute, ProductCollectionItem $objItem) {
+ if (!$objItem->hasProduct()) {
+ return false;
+ }
+
+ $objProduct = $objItem->getProduct();
+
+ return \in_array($strAttribute, $objProduct->getAttributes(), true)
+ || \in_array($strAttribute, $objProduct->getVariantAttributes(), true);
+ };
+
+ $objTemplate->generateAttribute = function (
+ $strAttribute,
+ ProductCollectionItem $objItem,
+ array $arrOptions = array()
+ ) {
+ if (!$objItem->hasProduct()) {
+ return '';
+ }
+
+ $objAttribute = $GLOBALS['TL_DCA']['tl_iso_product']['attributes'][$strAttribute];
+
+ if (!($objAttribute instanceof IsotopeAttribute)) {
+ throw new \InvalidArgumentException($strAttribute . ' is not a valid attribute');
+ }
+
+ return $objAttribute->generate($objItem->getProduct(), $arrOptions);
+ };
+
+ $objTemplate->getGallery = function (
+ $strAttribute,
+ ProductCollectionItem $objItem
+ ) use (
+ $arrConfig,
+ &$arrGalleries
+ ) {
+ if (!$objItem->hasProduct()) {
+ return new StandardGallery();
+ }
+
+ $strCacheKey = 'product' . $objItem->product_id . '_' . $strAttribute;
+ $arrConfig['jumpTo'] = $objItem->getRelated('jumpTo');
+
+ if (!isset($arrGalleries[$strCacheKey])) {
+ $arrGalleries[$strCacheKey] = Gallery::createForProductAttribute(
+ $objItem->getProduct(),
+ $strAttribute,
+ $arrConfig
+ );
+ }
+
+ return $arrGalleries[$strCacheKey];
+ };
+
+ $objTemplate->attributeLabel = function ($name, array $options = []) {
+ /** @var Attribute $attribute */
+ $attribute = $GLOBALS['TL_DCA']['tl_iso_product']['attributes'][$name] ?? null;
+
+ if (!$attribute instanceof IsotopeAttribute) {
+ return Format::dcaLabel('tl_iso_product', $name);
+ }
+
+ return $attribute->getLabel($options);
+ };
+
+ $objTemplate->attributeValue = function ($name, $value, array $options = []) {
+ /** @var Attribute $attribute */
+ $attribute = $GLOBALS['TL_DCA']['tl_iso_product']['attributes'][$name] ?? null;
+
+ if (!$attribute instanceof IsotopeAttribute) {
+ return Format::dcaValue('tl_iso_product', $name, $value);
+ }
+
+ return $attribute->generateValue($value, $options);
+ };
+
+ // !HOOK: allow overriding of the template
+ if (isset($GLOBALS['ISO_HOOKS']['addCollectionToTemplate'])
+ && \is_array($GLOBALS['ISO_HOOKS']['addCollectionToTemplate'])
+ ) {
+ foreach ($GLOBALS['ISO_HOOKS']['addCollectionToTemplate'] as $callback) {
+ System::importStatic($callback[0])->{$callback[1]}($objTemplate, $arrItems, $this, $arrConfig);
+ }
+ }
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function addError($message)
+ {
+ $this->arrErrors[] = $message;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function hasErrors()
+ {
+ if (\count($this->arrErrors) > 0) {
+ return true;
+ }
+
+ foreach ($this->getItems() as $objItem) {
+ if ($objItem->hasErrors()) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function getErrors()
+ {
+ $arrErrors = $this->arrErrors;
+
+ foreach ($this->getItems() as $objItem) {
+ if ($objItem->hasErrors()) {
+ array_unshift($arrErrors, $this->getMessageIfErrorsInItems());
+ break;
+ }
+ }
+
+ return $arrErrors;
+ }
+
+ /**
+ * Loop over items and add them to template
+ *
+ * @param Callable $varCallable
+ *
+ * @return array
+ */
+ protected function addItemsToTemplate(Template $objTemplate, $varCallable = null)
+ {
+ $taxIds = array();
+ $arrItems = array();
+
+ foreach ($this->getItems($varCallable) as $objItem) {
+ $item = $this->generateItem($objItem);
+
+ $taxIds[] = $item['tax_id'];
+ $arrItems[] = $item;
+ }
+
+ RowClass::withKey('rowClass')->addCount('row_')->addFirstLast('row_')->addEvenOdd('row_')->applyTo($arrItems);
+
+ $objTemplate->items = $arrItems;
+ $objTemplate->total_tax_ids = \count(array_unique($taxIds));
+
+ return $arrItems;
+ }
+
+ /**
+ * Generate item array for template
+ *
+ * @param ProductCollectionItem $objItem
+ *
+ * @return array
+ */
+ protected function generateItem(ProductCollectionItem $objItem)
+ {
+ $blnHasProduct = $objItem->hasProduct();
+ $objProduct = $objItem->getProduct();
+ $objConfig = $this->getRelated('config_id') ?: Isotope::getConfig();
+ $arrCSS = ($blnHasProduct ? StringUtil::deserialize($objProduct->cssID, true) : array());
+
+ // Set the active product for insert tags replacement
+ if ($blnHasProduct) {
+ Product::setActive($objProduct);
+ }
+
+ $arrItem = array(
+ 'id' => $objItem->id,
+ 'sku' => $objItem->getSku(),
+ 'name' => $objItem->getName(),
+ 'options' => Isotope::formatOptions($objItem->getOptions()),
+ 'configuration' => $objItem->getConfiguration(),
+ 'attributes' => $objItem->getAttributes(),
+ 'quantity' => $objItem->quantity,
+ 'price' => Isotope::formatPriceWithCurrency($objItem->getPrice(), true, $objConfig->currency),
+ 'tax_free_price' => Isotope::formatPriceWithCurrency($objItem->getTaxFreePrice(), true, $objConfig->currency),
+ 'original_price' => Isotope::formatPriceWithCurrency($objItem->getOriginalPrice(), true, $objConfig->currency),
+ 'total' => Isotope::formatPriceWithCurrency($objItem->getTotalPrice(), true, $objConfig->currency),
+ 'tax_free_total' => Isotope::formatPriceWithCurrency($objItem->getTaxFreeTotalPrice(), true, $objConfig->currency),
+ 'original_total' => Isotope::formatPriceWithCurrency($objItem->getTotalOriginalPrice(), true, $objConfig->currency),
+ 'tax_id' => $objItem->tax_id,
+ 'href' => false,
+ 'hasProduct' => $blnHasProduct,
+ 'product' => $objProduct,
+ 'item' => $objItem,
+ 'raw' => $objItem->row(),
+ 'rowClass' => trim('product ' . (($blnHasProduct && $objProduct->isNew()) ? 'new ' : '') . ($arrCSS[1] ?? ''))
+ );
+
+ if ($blnHasProduct && null !== $objItem->getRelated('jumpTo') && $objProduct->isAvailableInFrontend()) {
+ $arrItem['href'] = $objProduct->generateUrl($objItem->getRelated('jumpTo'));
+ }
+
+ Product::unsetActive();
+
+ return $arrItem;
+ }
+
+ /**
+ * Get a collection-specific error message for items with errors
+ *
+ * @return string
+ */
+ protected function getMessageIfErrorsInItems()
+ {
+ return $GLOBALS['TL_LANG']['ERR']['collectionErrorInItems'];
+ }
+
+ /**
+ * Generate the next higher Document Number based on existing records
+ *
+ * @param string $strPrefix
+ * @param int $intDigits
+ *
+ * @return string
+ * @throws \Exception
+ */
+ protected function generateDocumentNumber($strPrefix, $intDigits)
+ {
+ if ($this->arrData['document_number'] != '') {
+ return $this->arrData['document_number'];
+ }
+
+ // !HOOK: generate a custom order ID
+ if (isset($GLOBALS['ISO_HOOKS']['generateDocumentNumber'])
+ && \is_array($GLOBALS['ISO_HOOKS']['generateDocumentNumber'])
+ ) {
+ foreach ($GLOBALS['ISO_HOOKS']['generateDocumentNumber'] as $callback) {
+ $strOrderId = System::importStatic($callback[0])->{$callback[1]}($this, $strPrefix, $intDigits);
+
+ if ($strOrderId !== false) {
+ $this->arrData['document_number'] = $strOrderId;
+ break;
+ }
+ }
+ }
+
+ try {
+ if ($this->arrData['document_number'] == '') {
+ $strPrefix = Controller::replaceInsertTags($strPrefix, false);
+ $intPrefix = utf8_strlen($strPrefix);
+
+ // Lock tables so no other order can get the same ID
+ Database::getInstance()->lockTables(array(static::$strTable => 'WRITE'));
+
+ $prefixCondition = ($strPrefix != '' ? " AND document_number LIKE '$strPrefix%'" : '');
+
+ // Retrieve the highest available order ID
+ $objMax = Database::getInstance()
+ ->prepare("
+ SELECT document_number
+ FROM tl_iso_product_collection
+ WHERE
+ type=?
+ $prefixCondition
+ AND store_id=?
+ ORDER BY CAST(" . ($strPrefix != '' ? 'SUBSTRING(document_number, ' . ($intPrefix + 1) . ')' : 'document_number') . ' AS UNSIGNED) DESC
+ ')
+ ->limit(1)
+ ->execute(
+ array_search(\get_called_class(), static::getModelTypes(), true),
+ $this->store_id
+ )
+ ;
+
+ $intMax = (int) substr($objMax->document_number, $intPrefix);
+
+ $this->arrData['document_number'] = $strPrefix . str_pad($intMax + 1, $intDigits, '0', STR_PAD_LEFT);
+ }
+
+ Database::getInstance()
+ ->prepare('UPDATE tl_iso_product_collection SET document_number=? WHERE id=?')
+ ->execute($this->arrData['document_number'], $this->id)
+ ;
+
+ Database::getInstance()->unlockTables();
+
+ } catch (\Exception $e) {
+ // Make sure tables are always unlocked
+ Database::getInstance()->unlockTables();
+
+ throw $e;
+ }
+
+ return $this->arrData['document_number'];
+ }
+
+ /**
+ * Generate a unique ID for this collection
+ *
+ * @return string
+ */
+ protected function generateUniqueId()
+ {
+ if (!empty($this->arrData['uniqid'])) {
+ return $this->arrData['uniqid'];
+ }
+
+ return uniqid('', true);
+ }
+
+ /**
+ * Prevent modifying a locked collection
+ *
+ * @throws \BadMethodCallException if the collection is locked.
+ */
+ protected function ensureNotLocked()
+ {
+ if ($this->isLocked()) {
+ throw new \BadMethodCallException('Product collection is locked');
+ }
+ }
+
+ /**
+ * Make sure the addresses belong to this collection only, so they will never be modified
+ *
+ * @throws \UnderflowException if collection is not saved (not in DB)
+ * @throws \BadMethodCallException if the product collection is locked.
+ */
+ protected function createPrivateAddresses()
+ {
+ $this->ensureNotLocked();
+
+ if (!$this->id) {
+ throw new \UnderflowException('Product collection must be saved before creating unique addresses.');
+ }
+
+ $canSkip = StringUtil::deserialize($this->iso_checkout_skippable, true);
+ $objBillingAddress = $this->getBillingAddress();
+ $objShippingAddress = $this->getShippingAddress();
+
+ // Store address in address book
+ if ($this->iso_addToAddressbook && $this->member > 0) {
+ if (null !== $objBillingAddress
+ && $objBillingAddress->ptable != MemberModel::getTable()
+ && !\in_array('billing_address', $canSkip, true)
+ ) {
+ $objAddress = clone $objBillingAddress;
+ $objAddress->pid = $this->member;
+ $objAddress->tstamp = time();
+ $objAddress->ptable = MemberModel::getTable();
+ $objAddress->store_id = $this->store_id;
+ $objAddress->save();
+
+ $this->updateDefaultAddress($objAddress);
+ }
+
+ if (null !== $objBillingAddress
+ && null !== $objShippingAddress
+ && $objBillingAddress->id != $objShippingAddress->id
+ && $objShippingAddress->ptable != MemberModel::getTable()
+ && !\in_array('shipping_address', $canSkip, true)
+ ) {
+ $objAddress = clone $objShippingAddress;
+ $objAddress->pid = $this->member;
+ $objAddress->tstamp = time();
+ $objAddress->ptable = MemberModel::getTable();
+ $objAddress->store_id = $this->store_id;
+ $objAddress->save();
+
+ $this->updateDefaultAddress($objAddress);
+ }
+ }
+
+ /** @var Config $config */
+ $config = $this->getRelated('config_id');
+ $billingFields = (null === $config) ? array() : $config->getBillingFields();
+ $shippingFields = (null === $config) ? array() : $config->getShippingFields();
+
+ if (null !== $objBillingAddress
+ && ($objBillingAddress->ptable != static::$strTable || $objBillingAddress->pid != $this->id)
+ ) {
+ $arrData = array_intersect_key(
+ $objBillingAddress->row(),
+ array_merge(array_flip($billingFields), ['country' => ''])
+ );
+
+ $objNew = new Address();
+ $objNew->setRow($arrData);
+
+ $objNew->pid = $this->id;
+ $objNew->tstamp = time();
+ $objNew->ptable = static::$strTable;
+ $objNew->store_id = $this->store_id;
+ $objNew->save();
+
+ $this->setBillingAddress($objNew);
+
+ if (null !== $objShippingAddress && $objBillingAddress->id == $objShippingAddress->id) {
+ $this->setShippingAddress($objNew);
+
+ // Stop here, we do not need to check shipping address
+ return;
+ }
+ }
+
+ if (null !== $objShippingAddress
+ && ($objShippingAddress->ptable != static::$strTable || $objShippingAddress->pid != $this->id)
+ ) {
+ $arrData = array_intersect_key(
+ $objShippingAddress->row(),
+ array_merge(array_flip($shippingFields), ['country' => ''])
+ );
+
+ $objNew = new Address();
+ $objNew->setRow($arrData);
+
+ $objNew->pid = $this->id;
+ $objNew->tstamp = time();
+ $objNew->ptable = static::$strTable;
+ $objNew->store_id = $this->store_id;
+ $objNew->save();
+
+ $this->setShippingAddress($objNew);
+ } elseif (null === $objShippingAddress) {
+ // Make sure to set the shipping address to null if collection has no shipping
+ // see isotope/core#2014
+ $this->setShippingAddress(null);
+ }
+ }
+
+ /**
+ * Mark existing addresses as not default if the new address is default
+ *
+ * @param Address $objAddress
+ */
+ protected function updateDefaultAddress(Address $objAddress)
+ {
+ $arrSet = array();
+
+ if ($objAddress->isDefaultBilling) {
+ $arrSet['isDefaultBilling'] = '';
+ }
+
+ if ($objAddress->isDefaultShipping) {
+ $arrSet['isDefaultShipping'] = '';
+ }
+
+ if (\count($arrSet) > 0) {
+ Database::getInstance()
+ ->prepare('UPDATE tl_iso_address %s WHERE pid=? AND ptable=? AND store_id=? AND id!=?')
+ ->set($arrSet)
+ ->execute($this->member, MemberModel::getTable(), $this->store_id, $objAddress->id)
+ ;
+ }
+ }
+
+ /**
+ * Clear all cache properties
+ */
+ protected function clearCache()
+ {
+ $this->arrItems = null;
+ $this->arrSurcharges = null;
+ $this->arrCache = null;
+ $this->arrErrors = array();
+ $this->objPayment = false;
+ $this->objShipping = false;
+ }
+
+ /**
+ * Initialize a new collection and duplicate everything from the source
+ *
+ * @param IsotopeProductCollection $objSource
+ *
+ * @return static
+ */
+ public static function createFromCollection(IsotopeProductCollection $objSource)
+ {
+ $objCollection = new static();
+ $objConfig = $objSource->getConfig();
+
+ if (null === $objConfig) {
+ $objConfig = Isotope::getConfig();
+ }
+
+ $member = $objSource->getMember();
+
+ $objCollection->source_collection_id = $objSource->getId();
+ $objCollection->config_id = (int) $objConfig->id;
+ $objCollection->store_id = (int) $objSource->getStoreId();
+ $objCollection->member = (null === $member ? 0 : $member->id);
+
+ if ($objCollection instanceof IsotopeOrderableCollection
+ && $objSource instanceof IsotopeOrderableCollection)
+ {
+ $objCollection->setShippingMethod($objSource->getShippingMethod());
+ $objCollection->setPaymentMethod($objSource->getPaymentMethod());
+
+ $objCollection->setShippingAddress($objSource->getShippingAddress());
+ $objCollection->setBillingAddress($objSource->getBillingAddress());
+ }
+
+ $arrItemIds = $objCollection->copyItemsFrom($objSource);
+
+ $objCollection->updateDatabase();
+
+ // HOOK: order status has been updated
+ if (isset($GLOBALS['ISO_HOOKS']['createFromProductCollection'])
+ && \is_array($GLOBALS['ISO_HOOKS']['createFromProductCollection'])
+ ) {
+ foreach ($GLOBALS['ISO_HOOKS']['createFromProductCollection'] as $callback) {
+ System::importStatic($callback[0])->{$callback[1]}($objCollection, $objSource, $arrItemIds);
+ }
+ }
+
+ return $objCollection;
+ }
+
+ /**
+ * Method that returns a closure to sort product collection items
+ *
+ * @param string $strOrderBy
+ *
+ * @return \Closure|null
+ */
+ public static function getItemsSortingCallable($strOrderBy = 'asc_id')
+ {
+ [$direction, $attribute] = explode('_', $strOrderBy, 2) + [null, null];
+
+ if ('asc' === $direction) {
+ return function ($arrItems) use ($attribute) {
+ uasort($arrItems, function ($objItem1, $objItem2) use ($attribute) {
+ if ($objItem1->$attribute == $objItem2->$attribute) {
+ return 0;
+ }
+
+ return $objItem1->$attribute < $objItem2->$attribute ? -1 : 1;
+ });
+
+ return $arrItems;
+ };
+
+ }
+
+ if ('desc' === $direction) {
+ return function ($arrItems) use ($attribute) {
+ uasort($arrItems, function ($objItem1, $objItem2) use ($attribute) {
+ if ($objItem1->$attribute == $objItem2->$attribute) {
+ return 0;
+ }
+
+ return $objItem1->$attribute > $objItem2->$attribute ? -1 : 1;
+ });
+
+ return $arrItems;
+ };
+ }
+
+ return null;
+ }
+
+ /**
+ * @param IsotopeProduct $product
+ * @param ProductCollectionItem $item
+ * @param int $quantity
+ */
+ private function setProductForItem(IsotopeProduct $product, ProductCollectionItem $item, $quantity)
+ {
+ $item->tstamp = time();
+ $item->type = array_search(\get_class($product), Product::getModelTypes(), true);
+ $item->product_id = (int) $product->getId();
+ $item->sku = (string) $product->getSku();
+ $item->name = (string) $product->getName();
+ $item->configuration = $product->getOptions();
+ $item->quantity = (int) $quantity;
+ $item->price = (float) ($product->getPrice($this) ? $product->getPrice($this)->getAmount((int) $quantity) : 0);
+ $item->tax_free_price = (float) ($product->getPrice($this) ? $product->getPrice($this)->getNetAmount((int) $quantity) : 0);
+ }
+
+ /**
+ * Check if product collection has tax
+ *
+ * @return bool
+ */
+ public function hasTax()
+ {
+ foreach ($this->getSurcharges() as $surcharge) {
+ if ($surcharge instanceof Tax) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/api2/ProductCollectionItem.php b/api2/ProductCollectionItem.php
new file mode 100644
index 0000000..261a52a
--- /dev/null
+++ b/api2/ProductCollectionItem.php
@@ -0,0 +1,489 @@
+isLocked()) {
+ return true;
+ }
+
+ if (isset($GLOBALS['ISO_HOOKS']['itemIsAvailable']) && \is_array($GLOBALS['ISO_HOOKS']['itemIsAvailable'])) {
+ foreach ($GLOBALS['ISO_HOOKS']['itemIsAvailable'] as $callback) {
+ $available = System::importStatic($callback[0])->{$callback[1]}($this);
+
+ // If return value is boolean then we accept it as result
+ if (true === $available || false === $available) {
+ return $available;
+ }
+ }
+ }
+
+ if (!$this->hasProduct() || !$this->getProduct()->isAvailableForCollection($this->getRelated('pid'))) {
+ return false;
+ }
+
+ $arrConfig = $this->getOptions();
+ foreach ($this->getProduct()->getOptions() as $k => $v) {
+ if ($arrConfig[$k] !== $v) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Return true if product collection item is locked
+ */
+ public function isLocked()
+ {
+ return $this->blnLocked;
+ }
+
+ /**
+ * Lock item, necessary if product collection is locked
+ */
+ public function lock()
+ {
+ $this->blnLocked = true;
+ }
+
+ /**
+ * Delete downloads when deleting product collection item
+ *
+ * @return int
+ */
+ public function delete()
+ {
+ $intId = $this->id;
+ $intAffected = parent::delete();
+
+ if ($intAffected) {
+ Database::getInstance()->query("DELETE FROM tl_iso_product_collection_download WHERE pid=$intId");
+ }
+
+ return $intAffected;
+ }
+
+ /**
+ * Get the product related to this item
+ *
+ * @param bool $blnNoCache
+ *
+ * @return IsotopeProduct|null
+ */
+ public function getProduct($blnNoCache = false)
+ {
+ if (false === $this->objProduct || true === $blnNoCache) {
+
+ $this->objProduct = null;
+
+ /** @var string|\Isotope\Model\Product $strClass */
+ $strClass = Product::getClassForModelType($this->type);
+
+ if ($strClass == '' || !class_exists($strClass)) {
+ System::log('Error creating product object of type "' . $this->type . '"', __METHOD__, TL_ERROR);
+
+ return null;
+ }
+
+ try {
+ $this->objProduct = $strClass::findByPk($this->product_id);
+ } catch (\Exception $e) {
+ $this->objProduct = null;
+ $this->addError($e->getMessage());
+ }
+
+ if (null !== $this->objProduct && $this->objProduct instanceof IsotopeProductWithOptions) {
+ try {
+ if ($this->objProduct instanceof Model) {
+ $this->objProduct = clone $this->objProduct;
+ $this->objProduct->preventSaving(false);
+ $this->objProduct->id = $this->product_id;
+ }
+
+ $this->objProduct->setOptions($this->getOptions());
+ } catch (\RuntimeException $e) {
+ $this->addError($GLOBALS['TL_LANG']['ERR']['collectionItemNotAvailable']);
+ }
+ }
+ }
+
+ return $this->objProduct;
+ }
+
+ /**
+ * Return boolean flag if product could be loaded
+ *
+ * @return bool
+ */
+ public function hasProduct()
+ {
+ return (null !== $this->getProduct());
+ }
+
+ /**
+ * Get product SKU. Automatically falls back to the collection item table if product is not found.
+ *
+ * @return string
+ */
+ public function getSku()
+ {
+ return (string) ($this->isLocked() || !$this->hasProduct()) ? $this->sku : $this->getProduct()->getSku();
+ }
+
+ /**
+ * Get product name. Automatically falls back to the collection item table if product is not found.
+ *
+ * @return string
+ */
+ public function getName()
+ {
+ return (string) ($this->isLocked() || !$this->hasProduct()) ? $this->name : $this->getProduct()->getName();
+ }
+
+ /**
+ * Returns key-value array for variant-enabled and customer editable attributes.
+ *
+ * @return array
+ *
+ * @deprecated Use getOptions()
+ */
+ public function getAttributes()
+ {
+ return $this->getOptions();
+ }
+
+ /**
+ * Returns key-value array for variant-enabled and customer editable attributes.
+ *
+ * @return array
+ */
+ public function getOptions()
+ {
+ $arrConfig = StringUtil::deserialize($this->configuration);
+
+ return \is_array($arrConfig) ? $arrConfig : [];
+ }
+
+ /**
+ * Get product configuration
+ *
+ * @return array
+ *
+ * @deprecated Deprecated since Isotope 2.4, to be removed in Isotope 3.0. Use getOptions() instead.
+ */
+ public function getConfiguration()
+ {
+ $arrConfig = StringUtil::deserialize($this->configuration);
+
+ if (empty($arrConfig) || !\is_array($arrConfig)) {
+ return array();
+ }
+
+ if ($this->hasProduct()) {
+ return Isotope::formatProductConfiguration($arrConfig, $this->getProduct());
+
+ } else {
+ foreach ($arrConfig as $k => $v) {
+ $arrConfig[$k] = new Plain($v, $k);
+ }
+
+ return $arrConfig;
+ }
+ }
+
+ /**
+ * Get product price. Automatically falls back to the collection item table if product is not found.
+ *
+ * @return string
+ */
+ public function getPrice()
+ {
+ if ($this->isLocked() || !$this->hasProduct()) {
+ return $this->price;
+ }
+
+ $objPrice = $this->getProduct()->getPrice($this->getRelated('pid'));
+
+ if (null === $objPrice) {
+ return '';
+ }
+
+ return $objPrice->getAmount((int) $this->quantity, $this->getOptions());
+ }
+
+ /**
+ * Get tax free product price. Automatically falls back to the collection item table if product is not found.
+ *
+ * @return string
+ */
+ public function getTaxFreePrice()
+ {
+ if ($this->isLocked() || !$this->hasProduct()) {
+ return $this->tax_free_price;
+ }
+
+ $objPrice = $this->getProduct()->getPrice($this->getRelated('pid'));
+
+ if (null === $objPrice) {
+ return '';
+ }
+
+ return $objPrice->getNetAmount((int) $this->quantity, $this->getOptions());
+ }
+
+ /**
+ * Get original product price. Automatically falls back to the collection item table if product is not found.
+ *
+ * @return string
+ */
+ public function getOriginalPrice()
+ {
+ if ($this->isLocked() || !$this->hasProduct()) {
+ return $this->price;
+ }
+
+ $objPrice = $this->getProduct()->getPrice($this->getRelated('pid'));
+
+ if (null === $objPrice) {
+ return '';
+ }
+
+ return $objPrice->getOriginalAmount((int) $this->quantity, $this->getOptions());
+ }
+
+ /**
+ * Get product price multiplied by the requested product quantity
+ *
+ * @return string
+ */
+ public function getTotalPrice()
+ {
+ return (string) ($this->getPrice() * (int) $this->quantity);
+ }
+
+ /**
+ * Get original product price multiplied by the requested product quantity
+ *
+ * @return string
+ */
+ public function getTotalOriginalPrice()
+ {
+ return (string) ($this->getOriginalPrice() * (int) $this->quantity);
+ }
+
+ /**
+ * Get tax free product price multiplied by the requested product quantity
+ *
+ * @return string
+ */
+ public function getTaxFreeTotalPrice()
+ {
+ return (string) ($this->getTaxFreePrice() * (int) $this->quantity);
+ }
+
+ /**
+ * Return downloads associated with this product collection item
+ *
+ * @return ProductCollectionDownload[]
+ */
+ public function getDownloads()
+ {
+ if (null === $this->arrDownloads) {
+ $this->arrDownloads = array();
+
+ $objDownloads = ProductCollectionDownload::findBy('pid', $this->id);
+
+ if (null !== $objDownloads) {
+ while ($objDownloads->next()) {
+ $this->arrDownloads[] = $objDownloads->current();
+ }
+ }
+ }
+
+ return $this->arrDownloads;
+ }
+
+ /**
+ * Increase quantity of product collection item
+ *
+ * @param int $intQuantity
+ *
+ * @return self
+ */
+ public function increaseQuantityBy($intQuantity)
+ {
+ $time = time();
+
+ Database::getInstance()->query("
+ UPDATE tl_iso_product_collection_item
+ SET tstamp=$time, quantity=(quantity+" . (int) $intQuantity . ')
+ WHERE id=' . $this->id
+ );
+
+ $this->tstamp = $time;
+ $this->quantity = Database::getInstance()
+ ->query("SELECT quantity FROM tl_iso_product_collection_item WHERE id=" . $this->id)
+ ->quantity
+ ;
+
+ return $this;
+ }
+
+ /**
+ * Decrease quantity of product collection item
+ *
+ * @param int $intQuantity
+ *
+ * @return self
+ */
+ public function decreaseQuantityBy($intQuantity)
+ {
+ if (($this->quantity - $intQuantity) < 1) {
+ throw new \UnderflowException('Quantity of product collection item cannot be less than 1.');
+ }
+
+ $time = time();
+
+ Database::getInstance()->query("
+ UPDATE tl_iso_product_collection_item
+ SET tstamp=$time, quantity=(quantity-" . (int) $intQuantity . ')
+ WHERE id=' . $this->id
+ );
+
+ $this->tstamp = $time;
+ $this->quantity = Database::getInstance()
+ ->query('SELECT quantity FROM tl_iso_product_collection_item WHERE id=' . $this->id)
+ ->quantity
+ ;
+
+ return $this;
+ }
+
+ /**
+ * Calculate the sum of a database column
+ *
+ * @param string $strField
+ * @param mixed $strColumn
+ * @param mixed $varValue
+ *
+ * @return int
+ */
+ public static function sumBy($strField, $strColumn = null, $varValue = null)
+ {
+ $strQuery = "SELECT SUM($strField) AS sum FROM tl_iso_product_collection_item";
+
+ if ($strColumn !== null) {
+ $strQuery .= ' WHERE ' . (\is_array($strColumn) ? implode(' AND ', $strColumn) : static::$strTable . '.' . $strColumn . "=?");
+ }
+
+ return (int) Database::getInstance()->prepare($strQuery)->execute($varValue)->sum;
+ }
+
+ /**
+ * Add an error message
+ *
+ * @param string $strError
+ */
+ public function addError($strError)
+ {
+ $this->arrErrors[] = $strError;
+ }
+
+ /**
+ * Return true if the collection item has errors
+ *
+ * @return bool
+ */
+ public function hasErrors()
+ {
+ return 0 !== \count($this->arrErrors);
+ }
+
+ /**
+ * Return the errors array
+ *
+ * @return array
+ */
+ public function getErrors()
+ {
+ return $this->arrErrors;
+ }
+}
diff --git a/api2/apiTestCreateCustomer.php b/api2/apiTestCreateCustomer.php
new file mode 100644
index 0000000..7236c47
--- /dev/null
+++ b/api2/apiTestCreateCustomer.php
@@ -0,0 +1,7 @@
+createCustomer());
diff --git a/api2/apiTestCreateSalesOrder.php b/api2/apiTestCreateSalesOrder.php
new file mode 100644
index 0000000..6749043
--- /dev/null
+++ b/api2/apiTestCreateSalesOrder.php
@@ -0,0 +1,7 @@
+createSalesOrder());
diff --git a/api2/apiTestGetAccessToken.php b/api2/apiTestGetAccessToken.php
new file mode 100644
index 0000000..de9dc09
--- /dev/null
+++ b/api2/apiTestGetAccessToken.php
@@ -0,0 +1,9 @@
+getAccessToken() . "\n";
diff --git a/api2/apiUpdateProducts.php b/api2/apiUpdateProducts.php
new file mode 100644
index 0000000..327b88e
--- /dev/null
+++ b/api2/apiUpdateProducts.php
@@ -0,0 +1,10 @@
+getProducts();
+$databaseHandler->processProducts($products);
+
diff --git a/api2/dan.txt b/api2/dan.txt
new file mode 100644
index 0000000..f31e9f6
--- /dev/null
+++ b/api2/dan.txt
@@ -0,0 +1,11 @@
+string(3) "586"
+string(8) "WR000400"
+string(2) "41"
+string(24) "Schaum-Sprüher 1,5Liter"
+NULL
+string(3) "587"
+string(8) "WR000250"
+string(2) "42"
+string(11) "Tragebeutel"
+NULL
+string(15) "fuck off contao"
diff --git a/api2/tokenData b/api2/tokenData
new file mode 100644
index 0000000..67a3864
--- /dev/null
+++ b/api2/tokenData
@@ -0,0 +1 @@
+{"access_token":"stampDE001.gAAAAKLeomGUJnO_59CX7KmwE4tkt1bPxLR7EfU5E2eHjj6LeU2dAPfkRstKdlJJoudf3y6ehyKwF5wQQ1SljbA5sRrGwQsw4M876o0RRtxVHwyR6_QSoM7kP3qj7HfJLEDWjY-KrlJf_rlKtZZ2sRGJkX9jbd-4XDA_uqQECkf2NnIfVAIAAIAAAAC2ADvWhSjIjtyvs4_rhH9HzqaVKUbQthUoKf6StMSX4O5GzilHhM2cQCQJly_7hbpKHJMe4o167cPYIQLTeOilQdlQh21nhGykWfOKtXG31h210WtT2bg8Kc0MtrHQGi_f161wE_xINPXr-H7GTcXLe4klIGUkv0kmmmUacNoDUDfU1fPRTCCFvmkRnE7iBzd116aY9XMVw6mDucFExI9U-dLOD8IFWCPa9U6DSd50T7WoglDHC8Enh-J92C1c8kAhCCiGyA5GHyb9S1eJAnHFDYzOl9y0pwtIHekBJq-LxpzAbVieBUZrXEsTJnD7eiHvKoy9g_Bl-jOVgPTJT29n0dFDUClE_iQ6MGydAiEVKoyhQO20W7EJg64G8l6ZHZrd8r1J36TZ4jmdZ8E8_wqw3MzYGZm4vEXK4G0Lf2-j5uD0eh3aKPQCCdn_jEiCaEABQ-bsvd9sQazNNHbDwzl82GZdlhY8sqln14NpNloahOxhY9-jKu5nT9IBN2EftT-VoLNWp_E4ydPltM_7SS0Xro9j8nf7xNjpBDa__GeR2QSg_He5VR6u2n4lUbU1Qzks2W38RiCA3jXrQ-T6FCtiiaf5rhMzudqeq2c94Ggbn1g7BD_3S9xUFeA5kmSCkn1NZ3RzSqmkMq4IVPcbsJFYeoCiuZ1gYcvSOoBMHN8mjZ8j3xpPEvzJSX39arD5IeNqrLZDXuEU5aORiRnqjN6kPC4w1GQbbMsEL94r8tYGy8VkW7bstxkar7vb2J8vOPhLamN0mcxJdTZCCJ8tj18e","token_type":"bearer","expires_in":"600","refresh_token":"stampDE001.vhK0!IAAAANKu8TC2-MBN5wFyro7uwanpxXBsRFy7YkYSPaM9jMCm8QEAAAEVEJUYfzoc3Q1hatgTMAOm__pmwjTgdkOyzb7qUj7oRvir1XftD_cb5eE7J-06Q0SxWegFPfULR65PDc6Id0pDXZbugqaKhK24Gg9Gr0x74g4EW8HMYJ0PsW1uqvpOb_ph9mQxYllDQf598_uPZvuvxAMN4NOipKiHEXn9tExbn-rmJdrV9bwa3fAm5b2ALbzfBEKCuzuwLkrjH3cheaMC2TaksJ6M2we9IeAezapsyOVEl6QtnhY67L3Irp_bUV_SCZ6H-gbPDX3mFujxyw-lvRoC-ZcgIpM8EuWC3pN24abFp7Qaj8wE2X9_oAgvoJgn42lHl-x6M-QChdf78f6wiCPquk3OwwuBYsjmiJrHFQt4a6nRS-Y_A-J8IOdwq-_KZkvNTY5UI1ZmiGH5pvIDfaXTcvPn6r3C-sQecdImJM43Y_tgZ9a6p2gwPi8zNZSkXfwQH5bfal6tBcH43_8eGSp_M-VbLb7TotZfJgEIePSlZ--VV1aTaRMJ78_JBloFWvQabvhYEvvuvRaePbdGRgXzj95KgQeW8DzBXE6_zHSmhwkUEv11xgXimH3BFToSV_F25ZND7H3OkVM1-M8Jhbvrzh4SR5wRV5WfzC83Mk58HmDtI7xR0owYAu-9mtidFsK8mRC3nJzZSOWdGenZ","expiry_time":1657726518}
\ No newline at end of file