From 381416714c8d051a1535a1f93fc66136eb4007f1 Mon Sep 17 00:00:00 2001 From: Rihards Abolins Date: Fri, 3 Sep 2021 11:32:34 +0300 Subject: [PATCH] Bundle multi-option select fix --- src/Model/Bundle/Type.php | 359 ++++++++++++++++++ .../Cart/BuyRequest/BundleDataProvider.php | 99 +++++ src/Model/Quote/Item/Processor.php | 52 +++ src/etc/di.xml | 3 + 4 files changed, 513 insertions(+) create mode 100644 src/Model/Bundle/Type.php create mode 100644 src/Model/Cart/BuyRequest/BundleDataProvider.php create mode 100644 src/Model/Quote/Item/Processor.php diff --git a/src/Model/Bundle/Type.php b/src/Model/Bundle/Type.php new file mode 100644 index 0000000..04baefb --- /dev/null +++ b/src/Model/Bundle/Type.php @@ -0,0 +1,359 @@ +arrayUtility = $arrayUtility; + parent::__construct($catalogProductOption, $eavConfig, $catalogProductType, $eventManager, $fileStorageDb, $filesystem, $coreRegistry, $logger, $productRepository, $catalogProduct, $catalogData, $bundleModelSelection, $bundleFactory, $bundleCollection, $config, $bundleSelection, $bundleOption, $storeManager, $priceCurrency, $stockRegistry, $stockState, $serializer, $metadataPool, $selectionCollectionFilterApplier, $arrayUtility, $uploaderFactory); + } + + /** + * Prepare product and its configuration to be added to some products list. + * + * Perform standard preparation process and then prepare of bundle selections options. + * + * @param \Magento\Framework\DataObject $buyRequest + * @param \Magento\Catalog\Model\Product $product + * @param string $processMode + * @return \Magento\Framework\Phrase|array|string + * @SuppressWarnings(PHPMD.CyclomaticComplexity) + * @SuppressWarnings(PHPMD.NPathComplexity) + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + */ + protected function _prepareProduct(\Magento\Framework\DataObject $buyRequest, $product, $processMode) + { + $result = parent::_prepareProduct($buyRequest, $product, $processMode); + + try { + if (is_string($result)) { + throw new \Magento\Framework\Exception\LocalizedException(__($result)); + } + + $selections = []; + $isStrictProcessMode = $this->_isStrictProcessMode($processMode); + + $skipSaleableCheck = $this->_catalogProduct->getSkipSaleableCheck(); + $_appendAllSelections = (bool)$product->getSkipCheckRequiredOption() || $skipSaleableCheck; + + $options = []; + if ($buyRequest->getBundleOptionsData()) { + $options = $this->getPreparedOptions($buyRequest->getBundleOptionsData()); + } else { + $options = $buyRequest->getBundleOption(); + } + + if (is_array($options)) { + $options = $this->recursiveIntval($options); + $optionIds = array_keys($options); + + if (empty($optionIds) && $isStrictProcessMode) { + throw new \Magento\Framework\Exception\LocalizedException(__('Please specify product option(s).')); + } + + $product->getTypeInstance() + ->setStoreFilter($product->getStoreId(), $product); + $optionsCollection = $this->getOptionsCollection($product); + $this->checkIsAllRequiredOptions( + $product, + $isStrictProcessMode, + $optionsCollection, + $options + ); + + $this->validateRadioAndSelectOptions( + $optionsCollection, + $options + ); + + $selectionIds = array_values($this->arrayUtility->flatten($options)); + // If product has not been configured yet then $selections array should be empty + if (!empty($selectionIds)) { + $selections = $this->getSelectionsByIds($selectionIds, $product); + + if (count($selections->getItems()) !== count($selectionIds)) { + throw new \Magento\Framework\Exception\LocalizedException( + __('The options you selected are not available.') + ); + } + + // Check if added selections are still on sale + $this->checkSelectionsIsSale( + $selections, + $skipSaleableCheck, + $optionsCollection, + $options + ); + + $optionsCollection->appendSelections($selections, true, $_appendAllSelections); + + $selections = $selections->getItems(); + } else { + $selections = []; + } + } else { + $product->setOptionsValidationFail(true); + $product->getTypeInstance() + ->setStoreFilter($product->getStoreId(), $product); + + $optionCollection = $product->getTypeInstance() + ->getOptionsCollection($product); + $optionIds = $product->getTypeInstance() + ->getOptionsIds($product); + $selectionCollection = $product->getTypeInstance() + ->getSelectionsCollection($optionIds, $product); + $options = $optionCollection->appendSelections($selectionCollection, true, $_appendAllSelections); + + $selections = $this->mergeSelectionsWithOptions($options, $selections); + } + if ((is_array($selections) && count($selections) > 0) || !$isStrictProcessMode) { + $uniqueKey = [$product->getId()]; + $selectionIds = []; + if ($buyRequest->getBundleOptionsData()) { + $qtys = $buyRequest->getBundleOptionsData(); + } else { + $qtys = $buyRequest->getBundleOptionQty(); + } + + // Shuffle selection array by option position + usort($selections, [$this, 'shakeSelections']); + + foreach ($selections as $selection) { + $selectionOptionId = $selection->getOptionId(); + $qty = $this->getQty($selection, $qtys, $selectionOptionId); + + $selectionId = $selection->getSelectionId(); + $product->addCustomOption('selection_qty_' . $selectionId, $qty, $selection); + $selection->addCustomOption('selection_id', $selectionId); + + $beforeQty = $this->getBeforeQty($product, $selection); + $product->addCustomOption('product_qty_' . $selection->getId(), $qty, $selection); + + /* + * Create extra attributes that will be converted to product options in order item + * for selection (not for all bundle) + */ + $price = $product->getPriceModel() + ->getSelectionFinalTotalPrice($product, $selection, 0, 1); + $attributes = [ + 'price' => $price, + 'qty' => $qty, + 'option_label' => $selection->getOption() + ->getTitle(), + 'option_id' => $selection->getOption() + ->getId(), + ]; + + $_result = $selection->getTypeInstance() + ->prepareForCart($buyRequest, $selection); + $this->checkIsResult($_result); + + $result[] = $_result[0]->setParentProductId($product->getId()) + ->addCustomOption( + 'bundle_option_ids', + $this->serializer->serialize(array_map('intval', $optionIds)) + ) + ->addCustomOption( + 'bundle_selection_attributes', + $this->serializer->serialize($attributes) + ); + + if ($isStrictProcessMode) { + $_result[0]->setCartQty($qty); + } + + $resultSelectionId = $_result[0]->getSelectionId(); + $selectionIds[] = $resultSelectionId; + $uniqueKey[] = $resultSelectionId; + $uniqueKey[] = $qty; + } + + // "unique" key for bundle selection and add it to selections and bundle for selections + $uniqueKey = implode('_', $uniqueKey); + foreach ($result as $item) { + $item->addCustomOption('bundle_identity', $uniqueKey); + } + $product->addCustomOption( + 'bundle_option_ids', + $this->serializer->serialize( + array_map('intval', $optionIds) + ) + ); + $product->addCustomOption('bundle_selection_ids', $this->serializer->serialize($selectionIds)); + + return $result; + } + } catch (\Magento\Framework\Exception\LocalizedException $e) { + return $e->getMessage(); + } + + return $this->getSpecifyOptionMessage(); + } + + /** + * Cast array values to int + * + * @param array $array + * @return int[]|int[][] + */ + protected function recursiveIntval(array $array) + { + foreach ($array as $key => $value) { + if (is_array($value)) { + $array[$key] = $this->recursiveIntval($value); + } elseif (is_numeric($value) && (int)$value != 0) { + $array[$key] = (int)$value; + } else { + unset($array[$key]); + } + } + + return $array; + } + + /** + * Validate Options for Radio and Select input types + * + * @param Collection $optionsCollection + * @param int[] $options + * @return void + * @throws \Magento\Framework\Exception\LocalizedException + */ + protected function validateRadioAndSelectOptions($optionsCollection, $options): void + { + $errorTypes = []; + + if (is_array($optionsCollection->getItems())) { + foreach ($optionsCollection->getItems() as $option) { + if ($this->isSelectedOptionValid($option, $options)) { + $errorTypes[] = $option->getType(); + } + } + } + + if (!empty($errorTypes)) { + throw new \Magento\Framework\Exception\LocalizedException( + __( + 'Option type (%types) should have only one element.', + ['types' => implode(", ", $errorTypes)] + ) + ); + } + } + + /** + * Check if selected option is valid + * + * @param Option $option + * @param array $options + * @return bool + */ + protected function isSelectedOptionValid($option, $options): bool + { + return ( + ($option->getType() == 'radio' || $option->getType() == 'select') && + isset($options[$option->getOptionId()]) && + is_array($options[$option->getOptionId()]) && + count($options[$option->getOptionId()]) > 1 + ); + } + + + /** + * Returns selection qty + * + * @param \Magento\Framework\DataObject $selection + * @param int[] $qtys + * @param int $selectionOptionId + * @return float + */ + protected function getQty($selection, $qtys, $selectionOptionId) + { + if ($selection->getSelectionCanChangeQty() && isset($qtys[$selectionOptionId])) { + if (is_array($qtys[$selectionOptionId]) && isset($qtys[$selectionOptionId][$selection->getSelectionId()])) { + $selectionQty = $qtys[$selectionOptionId][$selection->getSelectionId()]; + $qty = (float)$selectionQty > 0 ? $selectionQty : 1; + } else { + $qty = (float)$qtys[$selectionOptionId] > 0 ? $qtys[$selectionOptionId] : 1; + } + } else { + $qty = (float)$selection->getSelectionQty() ? $selection->getSelectionQty() : 1; + } + + $qty = (float)$qty; + + return $qty; + } + + /** + * Get prepared options with selection ids + * + * @param array $options + * @return array + */ + private function getPreparedOptions(array $options): array + { + foreach ($options as $optionId => $option) { + foreach ($option as $selectionId => $optionQty) { + $options[$optionId][$selectionId] = $selectionId; + } + } + + return $options; + } +} diff --git a/src/Model/Cart/BuyRequest/BundleDataProvider.php b/src/Model/Cart/BuyRequest/BundleDataProvider.php new file mode 100644 index 0000000..dfcfe3f --- /dev/null +++ b/src/Model/Cart/BuyRequest/BundleDataProvider.php @@ -0,0 +1,99 @@ +getSelectedOptions() as $optionData) { + // phpcs:ignore Magento2.Functions.DiscouragedFunction + $optionData = \explode('/', base64_decode($optionData->getId())); + + if ($this->isProviderApplicable($optionData) === false) { + continue; + } + $this->validateInput($optionData); + + [$optionType, $optionId, $optionValueId, $optionQuantity] = $optionData; + if ($optionType == self::OPTION_TYPE) { + $bundleOptionsData['bundle_option'][$optionId] = $optionValueId; + $bundleOptionsData['bundle_option_qty'][$optionId] = $optionQuantity; + $bundleOptionsData['bundle_options_data'][$optionId][$optionValueId] = $optionQuantity; + } + } + //for bundle options with custom quantity + foreach ($cartItem->getEnteredOptions() as $option) { + // phpcs:ignore Magento2.Functions.DiscouragedFunction + $optionData = \explode('/', base64_decode($option->getUid())); + + if ($this->isProviderApplicable($optionData) === false) { + continue; + } + $this->validateInput($optionData); + + [$optionType, $optionId, $optionValueId] = $optionData; + if ($optionType == self::OPTION_TYPE) { + $optionQuantity = $option->getValue(); + $bundleOptionsData['bundle_option'][$optionId] = $optionValueId; + $bundleOptionsData['bundle_option_qty'][$optionId] = $optionQuantity; + $bundleOptionsData['bundle_options_data'][$optionId][$optionValueId] = $optionQuantity; + } + } + + return $bundleOptionsData; + } + + /** + * Checks whether this provider is applicable for the current option + * + * @param array $optionData + * @return bool + */ + private function isProviderApplicable(array $optionData): bool + { + if ($optionData[0] !== self::OPTION_TYPE) { + return false; + } + + return true; + } + + /** + * Validates the provided options structure + * + * @param array $optionData + * @throws LocalizedException + */ + private function validateInput(array $optionData): void + { + if (count($optionData) !== 4) { + $errorMessage = __('Wrong format of the entered option data'); + throw new LocalizedException($errorMessage); + } + } +} diff --git a/src/Model/Quote/Item/Processor.php b/src/Model/Quote/Item/Processor.php new file mode 100644 index 0000000..162f175 --- /dev/null +++ b/src/Model/Quote/Item/Processor.php @@ -0,0 +1,52 @@ +getResetCount() && !$candidate->getStickWithinParent() && $item->getId() == $request->getId()) { + $item->setData(CartItemInterface::KEY_QTY, 0); + } + $item->setQty($candidate->getCartQty()); + + if (!$item->getParentItem() || $item->getParentItem()->isChildrenCalculated()) { + $item->setPrice($candidate->getFinalPrice()); + } + + $customPrice = $request->getCustomPrice(); + if (!empty($customPrice) && !$candidate->getParentProductId()) { + $item->setCustomPrice($customPrice); + $item->setOriginalCustomPrice($customPrice); + } + } +} diff --git a/src/etc/di.xml b/src/etc/di.xml index 3517ecb..b87dcf7 100644 --- a/src/etc/di.xml +++ b/src/etc/di.xml @@ -41,5 +41,8 @@ + + +