diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/CustomizableEnteredOptionValueUid.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/CustomizableEnteredOptionValueUid.php index 69fafc49c9137..2d6975bb8a4e2 100644 --- a/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/CustomizableEnteredOptionValueUid.php +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/CustomizableEnteredOptionValueUid.php @@ -45,6 +45,9 @@ public function resolve( array $value = null, array $args = null ) { + if (isset($value['uid'])) { + return $value['uid']; + } if (!isset($value['option_id']) || empty($value['option_id'])) { throw new GraphQlInputException(__('"option_id" value should be specified.')); } diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/CustomizableSelectedOptionValueUid.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/CustomizableSelectedOptionValueUid.php index 5fbd8a56bb570..795782d6e3718 100644 --- a/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/CustomizableSelectedOptionValueUid.php +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/CustomizableSelectedOptionValueUid.php @@ -45,6 +45,9 @@ public function resolve( array $value = null, array $args = null ) { + if (isset($value['uid'])) { + return $value['uid']; + } if (!isset($value['option_id']) || empty($value['option_id'])) { throw new GraphQlInputException(__('"option_id" value should be specified.')); } diff --git a/app/code/Magento/GraphQl/etc/schema.graphqls b/app/code/Magento/GraphQl/etc/schema.graphqls index 0212d32db0f2f..2595ad09c072a 100644 --- a/app/code/Magento/GraphQl/etc/schema.graphqls +++ b/app/code/Magento/GraphQl/etc/schema.graphqls @@ -277,3 +277,8 @@ enum CurrencyEnum @doc(description: "The list of available currency codes") { TRL XPF } + +input EnteredOptionInput @doc(description: "Defines a customer-entered option") { + uid: ID! @doc(description: "An encoded ID") + value: String! @doc(description: "Text the customer entered") +} diff --git a/app/code/Magento/Quote/Model/Cart/AddProductsToCart.php b/app/code/Magento/Quote/Model/Cart/AddProductsToCart.php new file mode 100644 index 0000000000000..2c5c3536d6682 --- /dev/null +++ b/app/code/Magento/Quote/Model/Cart/AddProductsToCart.php @@ -0,0 +1,225 @@ + self::ERROR_PRODUCT_NOT_FOUND, + 'The required options you selected are not available' => self::ERROR_NOT_SALABLE, + 'Product that you are trying to add is not available.' => self::ERROR_NOT_SALABLE, + 'This product is out of stock' => self::ERROR_INSUFFICIENT_STOCK, + 'There are no source items' => self::ERROR_NOT_SALABLE, + 'The fewest you may purchase is' => self::ERROR_INSUFFICIENT_STOCK, + 'The most you may purchase is' => self::ERROR_INSUFFICIENT_STOCK, + 'The requested qty is not available' => self::ERROR_INSUFFICIENT_STOCK, + ]; + + /** + * @var ProductRepositoryInterface + */ + private $productRepository; + + /** + * @var array + */ + private $errors = []; + + /** + * @var CartRepositoryInterface + */ + private $cartRepository; + + /** + * @var MaskedQuoteIdToQuoteIdInterface + */ + private $maskedQuoteIdToQuoteId; + + /** + * @var BuyRequestBuilder + */ + private $requestBuilder; + + /** + * @param ProductRepositoryInterface $productRepository + * @param CartRepositoryInterface $cartRepository + * @param MaskedQuoteIdToQuoteIdInterface $maskedQuoteIdToQuoteId + * @param BuyRequestBuilder $requestBuilder + */ + public function __construct( + ProductRepositoryInterface $productRepository, + CartRepositoryInterface $cartRepository, + MaskedQuoteIdToQuoteIdInterface $maskedQuoteIdToQuoteId, + BuyRequestBuilder $requestBuilder + ) { + $this->productRepository = $productRepository; + $this->cartRepository = $cartRepository; + $this->maskedQuoteIdToQuoteId = $maskedQuoteIdToQuoteId; + $this->requestBuilder = $requestBuilder; + } + + /** + * Add cart items to the cart + * + * @param string $maskedCartId + * @param Data\CartItem[] $cartItems + * @return AddProductsToCartOutput + * @throws NoSuchEntityException Could not find a Cart with provided $maskedCartId + */ + public function execute(string $maskedCartId, array $cartItems): AddProductsToCartOutput + { + $cartId = $this->maskedQuoteIdToQuoteId->execute($maskedCartId); + $cart = $this->cartRepository->get($cartId); + + foreach ($cartItems as $cartItemPosition => $cartItem) { + $this->addItemToCart($cart, $cartItem, $cartItemPosition); + } + + if ($cart->getData('has_error')) { + $errors = $cart->getErrors(); + + /** @var MessageInterface $error */ + foreach ($errors as $error) { + $this->addError($error->getText()); + } + } + + if (count($this->errors) !== 0) { + /* Revert changes introduced by add to cart processes in case of an error */ + $cart->getItemsCollection()->clear(); + } + + return $this->prepareErrorOutput($cart); + } + + /** + * Adds a particular item to the shopping cart + * + * @param CartInterface|Quote $cart + * @param Data\CartItem $cartItem + * @param int $cartItemPosition + */ + private function addItemToCart(CartInterface $cart, Data\CartItem $cartItem, int $cartItemPosition): void + { + $sku = $cartItem->getSku(); + + if ($cartItem->getQuantity() <= 0) { + $this->addError(__('The product quantity should be greater than 0')->render()); + + return; + } + + try { + $product = $this->productRepository->get($sku, false, null, true); + } catch (NoSuchEntityException $e) { + $this->addError( + __('Could not find a product with SKU "%sku"', ['sku' => $sku])->render(), + $cartItemPosition + ); + + return; + } + + try { + $result = $cart->addProduct($product, $this->requestBuilder->build($cartItem)); + $this->cartRepository->save($cart); + } catch (\Throwable $e) { + $this->addError( + __($e->getMessage())->render(), + $cartItemPosition + ); + $cart->setHasError(false); + + return; + } + + if (is_string($result)) { + $errors = array_unique(explode("\n", $result)); + foreach ($errors as $error) { + $this->addError(__($error)->render(), $cartItemPosition); + } + } + } + + /** + * Add order line item error + * + * @param string $message + * @param int $cartItemPosition + * @return void + */ + private function addError(string $message, int $cartItemPosition = 0): void + { + $this->errors[] = new Data\Error( + $message, + $this->getErrorCode($message), + $cartItemPosition + ); + } + + /** + * Get message error code. + * + * TODO: introduce a separate class for getting error code from a message + * + * @param string $message + * @return string + */ + private function getErrorCode(string $message): string + { + foreach (self::MESSAGE_CODES as $codeMessage => $code) { + if (false !== stripos($message, $codeMessage)) { + return $code; + } + } + + /* If no code was matched, return the default one */ + return self::ERROR_UNDEFINED; + } + + /** + * Creates a new output from existing errors + * + * @param CartInterface $cart + * @return AddProductsToCartOutput + */ + private function prepareErrorOutput(CartInterface $cart): AddProductsToCartOutput + { + $output = new AddProductsToCartOutput($cart, $this->errors); + $this->errors = []; + $cart->setHasError(false); + + return $output; + } +} diff --git a/app/code/Magento/Quote/Model/Cart/BuyRequest/BuyRequestBuilder.php b/app/code/Magento/Quote/Model/Cart/BuyRequest/BuyRequestBuilder.php new file mode 100644 index 0000000000000..13b19e4f79c9a --- /dev/null +++ b/app/code/Magento/Quote/Model/Cart/BuyRequest/BuyRequestBuilder.php @@ -0,0 +1,61 @@ +dataObjectFactory = $dataObjectFactory; + $this->providers = $providers; + } + + /** + * Build buy request for adding product to cart + * + * @see \Magento\Quote\Model\Quote::addProduct + * @param CartItem $cartItem + * @return DataObject + */ + public function build(CartItem $cartItem): DataObject + { + $requestData = [ + ['qty' => $cartItem->getQuantity()] + ]; + + /** @var BuyRequestDataProviderInterface $provider */ + foreach ($this->providers as $provider) { + $requestData[] = $provider->execute($cartItem); + } + + return $this->dataObjectFactory->create(['data' => array_merge(...$requestData)]); + } +} diff --git a/app/code/Magento/Quote/Model/Cart/BuyRequest/BuyRequestDataProviderInterface.php b/app/code/Magento/Quote/Model/Cart/BuyRequest/BuyRequestDataProviderInterface.php new file mode 100644 index 0000000000000..b9c41b18ee163 --- /dev/null +++ b/app/code/Magento/Quote/Model/Cart/BuyRequest/BuyRequestDataProviderInterface.php @@ -0,0 +1,24 @@ +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, $optionValue] = $optionData; + if ($optionType == self::OPTION_TYPE) { + $customizableOptionsData[$optionId][] = $optionValue; + } + } + + foreach ($cartItem->getEnteredOptions() as $option) { + // phpcs:ignore Magento2.Functions.DiscouragedFunction + $optionData = \explode('/', base64_decode($option->getUid())); + + if ($this->isProviderApplicable($optionData) === false) { + continue; + } + + [$optionType, $optionId] = $optionData; + if ($optionType == self::OPTION_TYPE) { + $customizableOptionsData[$optionId][] = $option->getValue(); + } + } + + return ['options' => $this->flattenOptionValues($customizableOptionsData)]; + } + + /** + * Flatten option values for non-multiselect customizable options + * + * @param array $customizableOptionsData + * @return array + */ + private function flattenOptionValues(array $customizableOptionsData): array + { + foreach ($customizableOptionsData as $optionId => $optionValue) { + if (count($optionValue) === 1) { + $customizableOptionsData[$optionId] = $optionValue[0]; + } + } + + return $customizableOptionsData; + } + + /** + * 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) !== 3) { + throw new LocalizedException( + __('Wrong format of the entered option data') + ); + } + } +} diff --git a/app/code/Magento/Quote/Model/Cart/Data/AddProductsToCartOutput.php b/app/code/Magento/Quote/Model/Cart/Data/AddProductsToCartOutput.php new file mode 100644 index 0000000000000..c12c02c0449f6 --- /dev/null +++ b/app/code/Magento/Quote/Model/Cart/Data/AddProductsToCartOutput.php @@ -0,0 +1,56 @@ +cart = $cart; + $this->errors = $errors; + } + + /** + * Get Shopping Cart + * + * @return CartInterface + */ + public function getCart(): CartInterface + { + return $this->cart; + } + + /** + * Get errors happened during adding item to the cart + * + * @return Error[] + */ + public function getErrors(): array + { + return $this->errors; + } +} diff --git a/app/code/Magento/Quote/Model/Cart/Data/CartItem.php b/app/code/Magento/Quote/Model/Cart/Data/CartItem.php new file mode 100644 index 0000000000000..9836247c56694 --- /dev/null +++ b/app/code/Magento/Quote/Model/Cart/Data/CartItem.php @@ -0,0 +1,110 @@ +sku = $sku; + $this->quantity = $quantity; + $this->parentSku = $parentSku; + $this->selectedOptions = $selectedOptions; + $this->enteredOptions = $enteredOptions; + } + + /** + * Returns cart item SKU + * + * @return string + */ + public function getSku(): string + { + return $this->sku; + } + + /** + * Returns cart item quantity + * + * @return float + */ + public function getQuantity(): float + { + return $this->quantity; + } + + /** + * Returns parent SKU + * + * @return string|null + */ + public function getParentSku(): ?string + { + return $this->parentSku; + } + + /** + * Returns selected options + * + * @return SelectedOption[]|null + */ + public function getSelectedOptions(): ?array + { + return $this->selectedOptions; + } + + /** + * Returns entered options + * + * @return EnteredOption[]|null + */ + public function getEnteredOptions(): ?array + { + return $this->enteredOptions; + } +} diff --git a/app/code/Magento/Quote/Model/Cart/Data/CartItemFactory.php b/app/code/Magento/Quote/Model/Cart/Data/CartItemFactory.php new file mode 100644 index 0000000000000..823f03b28229c --- /dev/null +++ b/app/code/Magento/Quote/Model/Cart/Data/CartItemFactory.php @@ -0,0 +1,74 @@ +createSelectedOptions($data['selected_options']) : [], + isset($data['entered_options']) ? $this->createEnteredOptions($data['entered_options']) : [] + ); + } + + /** + * Creates array of Entered Options + * + * @param array $options + * @return EnteredOption[] + */ + private function createEnteredOptions(array $options): array + { + return \array_map( + function (array $option) { + if (!isset($option['uid'], $option['value'])) { + throw new InputException( + __('Required fields are not present EnteredOption.uid, EnteredOption.value') + ); + } + return new EnteredOption($option['uid'], $option['value']); + }, + $options + ); + } + + /** + * Creates array of Selected Options + * + * @param string[] $options + * @return SelectedOption[] + */ + private function createSelectedOptions(array $options): array + { + return \array_map( + function ($option) { + return new SelectedOption($option); + }, + $options + ); + } +} diff --git a/app/code/Magento/Quote/Model/Cart/Data/EnteredOption.php b/app/code/Magento/Quote/Model/Cart/Data/EnteredOption.php new file mode 100644 index 0000000000000..ba55051d33805 --- /dev/null +++ b/app/code/Magento/Quote/Model/Cart/Data/EnteredOption.php @@ -0,0 +1,54 @@ +uid = $uid; + $this->value = $value; + } + + /** + * Returns entered option ID + * + * @return string + */ + public function getUid(): string + { + return $this->uid; + } + + /** + * Returns entered option value + * + * @return string + */ + public function getValue(): string + { + return $this->value; + } +} diff --git a/app/code/Magento/Quote/Model/Cart/Data/Error.php b/app/code/Magento/Quote/Model/Cart/Data/Error.php new file mode 100644 index 0000000000000..42b14b06d94aa --- /dev/null +++ b/app/code/Magento/Quote/Model/Cart/Data/Error.php @@ -0,0 +1,71 @@ +message = $message; + $this->code = $code; + $this->cartItemPosition = $cartItemPosition; + } + + /** + * Get error message + * + * @return string + */ + public function getMessage(): string + { + return $this->message; + } + + /** + * Get error code + * + * @return string + */ + public function getCode(): string + { + return $this->code; + } + + /** + * Get cart item position + * + * @return int + */ + public function getCartItemPosition(): int + { + return $this->cartItemPosition; + } +} diff --git a/app/code/Magento/Quote/Model/Cart/Data/SelectedOption.php b/app/code/Magento/Quote/Model/Cart/Data/SelectedOption.php new file mode 100644 index 0000000000000..70edd93cd8ef8 --- /dev/null +++ b/app/code/Magento/Quote/Model/Cart/Data/SelectedOption.php @@ -0,0 +1,37 @@ +id = $id; + } + + /** + * Get selected option ID + * + * @return string + */ + public function getId(): string + { + return $this->id; + } +} diff --git a/app/code/Magento/Quote/etc/graphql/di.xml b/app/code/Magento/Quote/etc/graphql/di.xml new file mode 100644 index 0000000000000..0e688d42ecb32 --- /dev/null +++ b/app/code/Magento/Quote/etc/graphql/di.xml @@ -0,0 +1,16 @@ + + + + + + + Magento\Quote\Model\Cart\BuyRequest\CustomizableOptionDataProvider + + + + diff --git a/app/code/Magento/QuoteBundleOptions/Model/Cart/BuyRequest/BundleDataProvider.php b/app/code/Magento/QuoteBundleOptions/Model/Cart/BuyRequest/BundleDataProvider.php new file mode 100644 index 0000000000000..575784c86ace1 --- /dev/null +++ b/app/code/Magento/QuoteBundleOptions/Model/Cart/BuyRequest/BundleDataProvider.php @@ -0,0 +1,94 @@ +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; + } + } + //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; + } + } + + 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/app/code/Magento/QuoteBundleOptions/README.md b/app/code/Magento/QuoteBundleOptions/README.md new file mode 100644 index 0000000000000..3207eeaf2b683 --- /dev/null +++ b/app/code/Magento/QuoteBundleOptions/README.md @@ -0,0 +1,3 @@ +# QuoteBundleOptions + +**QuoteBundleOptions** provides data provider for creating buy request for bundle products. diff --git a/app/code/Magento/QuoteBundleOptions/composer.json b/app/code/Magento/QuoteBundleOptions/composer.json new file mode 100644 index 0000000000000..a2651272018a8 --- /dev/null +++ b/app/code/Magento/QuoteBundleOptions/composer.json @@ -0,0 +1,22 @@ +{ + "name": "magento/module-quote-bundle-options", + "description": "Magento module provides data provider for creating buy request for bundle products", + "require": { + "php": "~7.3.0||~7.4.0", + "magento/framework": "*", + "magento/module-quote": "*" + }, + "type": "magento2-module", + "license": [ + "OSL-3.0", + "AFL-3.0" + ], + "autoload": { + "files": [ + "registration.php" + ], + "psr-4": { + "Magento\\QuoteBundleOptions\\": "" + } + } +} diff --git a/app/code/Magento/QuoteBundleOptions/etc/graphql/di.xml b/app/code/Magento/QuoteBundleOptions/etc/graphql/di.xml new file mode 100644 index 0000000000000..e15493e092e3b --- /dev/null +++ b/app/code/Magento/QuoteBundleOptions/etc/graphql/di.xml @@ -0,0 +1,16 @@ + + + + + + + Magento\QuoteBundleOptions\Model\Cart\BuyRequest\BundleDataProvider + + + + diff --git a/app/code/Magento/QuoteBundleOptions/etc/module.xml b/app/code/Magento/QuoteBundleOptions/etc/module.xml new file mode 100644 index 0000000000000..4dc531b561115 --- /dev/null +++ b/app/code/Magento/QuoteBundleOptions/etc/module.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/app/code/Magento/QuoteBundleOptions/registration.php b/app/code/Magento/QuoteBundleOptions/registration.php new file mode 100644 index 0000000000000..cf4c92fd929d9 --- /dev/null +++ b/app/code/Magento/QuoteBundleOptions/registration.php @@ -0,0 +1,10 @@ +getSelectedOptions() as $optionData) { + // phpcs:ignore Magento2.Functions.DiscouragedFunction + $optionData = \explode('/', base64_decode($optionData->getId())); + + if ($this->isProviderApplicable($optionData) === false) { + continue; + } + $this->validateInput($optionData); + + [$optionType, $attributeId, $valueIndex] = $optionData; + if ($optionType == self::OPTION_TYPE) { + $configurableProductData[$attributeId] = $valueIndex; + } + } + + return ['super_attribute' => $configurableProductData]; + } + + /** + * 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) !== 3) { + throw new LocalizedException( + __('Wrong format of the entered option data') + ); + } + } +} diff --git a/app/code/Magento/QuoteConfigurableOptions/README.md b/app/code/Magento/QuoteConfigurableOptions/README.md new file mode 100644 index 0000000000000..db47e2c37c3ff --- /dev/null +++ b/app/code/Magento/QuoteConfigurableOptions/README.md @@ -0,0 +1,3 @@ +# QuoteConfigurableOptions + +**QuoteConfigurableOptions** provides data provider for creating buy request for configurable products. diff --git a/app/code/Magento/QuoteConfigurableOptions/composer.json b/app/code/Magento/QuoteConfigurableOptions/composer.json new file mode 100644 index 0000000000000..51d6933d5c6d6 --- /dev/null +++ b/app/code/Magento/QuoteConfigurableOptions/composer.json @@ -0,0 +1,22 @@ +{ + "name": "magento/module-quote-configurable-options", + "description": "Magento module provides data provider for creating buy request for configurable products", + "require": { + "php": "~7.3.0||~7.4.0", + "magento/framework": "*", + "magento/module-quote": "*" + }, + "type": "magento2-module", + "license": [ + "OSL-3.0", + "AFL-3.0" + ], + "autoload": { + "files": [ + "registration.php" + ], + "psr-4": { + "Magento\\QuoteConfigurableOptions\\": "" + } + } +} diff --git a/app/code/Magento/QuoteConfigurableOptions/etc/graphql/di.xml b/app/code/Magento/QuoteConfigurableOptions/etc/graphql/di.xml new file mode 100644 index 0000000000000..c4fe6357a5689 --- /dev/null +++ b/app/code/Magento/QuoteConfigurableOptions/etc/graphql/di.xml @@ -0,0 +1,16 @@ + + + + + + + Magento\QuoteConfigurableOptions\Model\Cart\BuyRequest\SuperAttributeDataProvider + + + + diff --git a/app/code/Magento/QuoteConfigurableOptions/etc/module.xml b/app/code/Magento/QuoteConfigurableOptions/etc/module.xml new file mode 100644 index 0000000000000..e32489c1b2109 --- /dev/null +++ b/app/code/Magento/QuoteConfigurableOptions/etc/module.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/app/code/Magento/QuoteConfigurableOptions/registration.php b/app/code/Magento/QuoteConfigurableOptions/registration.php new file mode 100644 index 0000000000000..0b55a18a81fce --- /dev/null +++ b/app/code/Magento/QuoteConfigurableOptions/registration.php @@ -0,0 +1,10 @@ +getSelectedOptions() as $optionData) { + // phpcs:ignore Magento2.Functions.DiscouragedFunction + $optionData = \explode('/', base64_decode($optionData->getId())); + + if ($this->isProviderApplicable($optionData) === false) { + continue; + } + $this->validateInput($optionData); + + [$optionType, $linkId] = $optionData; + if ($optionType == self::OPTION_TYPE) { + $linksData[] = $linkId; + } + } + + return ['links' => $linksData]; + } + + /** + * 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) !== 2) { + throw new LocalizedException( + __('Wrong format of the entered option data') + ); + } + } +} diff --git a/app/code/Magento/QuoteDownloadableLinks/README.md b/app/code/Magento/QuoteDownloadableLinks/README.md new file mode 100644 index 0000000000000..68efffcea6fb8 --- /dev/null +++ b/app/code/Magento/QuoteDownloadableLinks/README.md @@ -0,0 +1,3 @@ +# QuoteDownloadableLinks + +**QuoteDownloadableLinks** provides data provider for creating buy request for links of downloadable products. diff --git a/app/code/Magento/QuoteDownloadableLinks/composer.json b/app/code/Magento/QuoteDownloadableLinks/composer.json new file mode 100644 index 0000000000000..ad120dea96263 --- /dev/null +++ b/app/code/Magento/QuoteDownloadableLinks/composer.json @@ -0,0 +1,22 @@ +{ + "name": "magento/module-quote-downloadable-links", + "description": "Magento module provides data provider for creating buy request for links of downloadable products", + "require": { + "php": "~7.3.0||~7.4.0", + "magento/framework": "*", + "magento/module-quote": "*" + }, + "type": "magento2-module", + "license": [ + "OSL-3.0", + "AFL-3.0" + ], + "autoload": { + "files": [ + "registration.php" + ], + "psr-4": { + "Magento\\QuoteDownloadableLinks\\": "" + } + } +} diff --git a/app/code/Magento/QuoteDownloadableLinks/etc/graphql/di.xml b/app/code/Magento/QuoteDownloadableLinks/etc/graphql/di.xml new file mode 100644 index 0000000000000..a932d199983a3 --- /dev/null +++ b/app/code/Magento/QuoteDownloadableLinks/etc/graphql/di.xml @@ -0,0 +1,16 @@ + + + + + + + Magento\QuoteDownloadableLinks\Model\Cart\BuyRequest\DownloadableLinkDataProvider + + + + diff --git a/app/code/Magento/QuoteDownloadableLinks/etc/module.xml b/app/code/Magento/QuoteDownloadableLinks/etc/module.xml new file mode 100644 index 0000000000000..a0cc652ab9188 --- /dev/null +++ b/app/code/Magento/QuoteDownloadableLinks/etc/module.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/app/code/Magento/QuoteDownloadableLinks/registration.php b/app/code/Magento/QuoteDownloadableLinks/registration.php new file mode 100644 index 0000000000000..8b766e7fde06c --- /dev/null +++ b/app/code/Magento/QuoteDownloadableLinks/registration.php @@ -0,0 +1,10 @@ +getCartForUser = $getCartForUser; + $this->addProductsToCartService = $addProductsToCart; + } + + /** + * @inheritdoc + */ + public function resolve(Field $field, $context, ResolveInfo $info, array $value = null, array $args = null) + { + if (empty($args['cartId'])) { + throw new GraphQlInputException(__('Required parameter "cartId" is missing')); + } + if (empty($args['cartItems']) || !is_array($args['cartItems']) + ) { + throw new GraphQlInputException(__('Required parameter "cartItems" is missing')); + } + + $maskedCartId = $args['cartId']; + $cartItemsData = $args['cartItems']; + $storeId = (int)$context->getExtensionAttributes()->getStore()->getId(); + + // Shopping Cart validation + $this->getCartForUser->execute($maskedCartId, $context->getUserId(), $storeId); + + $cartItems = []; + foreach ($cartItemsData as $cartItemData) { + $cartItems[] = (new CartItemFactory())->create($cartItemData); + } + + /** @var AddProductsToCartOutput $addProductsToCartOutput */ + $addProductsToCartOutput = $this->addProductsToCartService->execute($maskedCartId, $cartItems); + + return [ + 'cart' => [ + 'model' => $addProductsToCartOutput->getCart(), + ], + 'user_errors' => array_map( + function (Error $error) { + return [ + 'code' => $error->getCode(), + 'message' => $error->getMessage(), + 'path' => [$error->getCartItemPosition()] + ]; + }, + $addProductsToCartOutput->getErrors() + ) + ]; + } +} diff --git a/app/code/Magento/QuoteGraphQl/etc/schema.graphqls b/app/code/Magento/QuoteGraphQl/etc/schema.graphqls index 955ee1cc2429a..4e0e7ce5732be 100644 --- a/app/code/Magento/QuoteGraphQl/etc/schema.graphqls +++ b/app/code/Magento/QuoteGraphQl/etc/schema.graphqls @@ -22,6 +22,7 @@ type Mutation { setPaymentMethodAndPlaceOrder(input: SetPaymentMethodAndPlaceOrderInput): PlaceOrderOutput @deprecated(reason: "Should use setPaymentMethodOnCart and placeOrder mutations in single request.") @resolver(class: "\\Magento\\QuoteGraphQl\\Model\\Resolver\\SetPaymentAndPlaceOrder") mergeCarts(source_cart_id: String!, destination_cart_id: String!): Cart! @doc(description:"Merges the source cart into the destination cart") @resolver(class: "Magento\\QuoteGraphQl\\Model\\Resolver\\MergeCarts") placeOrder(input: PlaceOrderInput): PlaceOrderOutput @resolver(class: "\\Magento\\QuoteGraphQl\\Model\\Resolver\\PlaceOrder") + addProductsToCart(cartId: String!, cartItems: [CartItemInput!]!): AddProductsToCartOutput @doc(description:"Add any type of product to the cart") @resolver(class: "Magento\\QuoteGraphQl\\Model\\Resolver\\AddProductsToCart") } input createEmptyCartInput { @@ -51,6 +52,9 @@ input VirtualProductCartItemInput { input CartItemInput { sku: String! quantity: Float! + parent_sku: String @doc(description: "For child products, the SKU of its parent product") + selected_options: [ID!] @doc(description: "The selected options for the base product, such as color or size") + entered_options: [EnteredOptionInput!] @doc(description: "An array of entered options for the base product, such as personalization text") } input CustomizableOptionInput { @@ -368,3 +372,21 @@ type Order { order_number: String! order_id: String @deprecated(reason: "The order_id field is deprecated, use order_number instead.") } + +type CartUserInputError @doc(description:"An error encountered while adding an item to the the cart.") { + message: String! @doc(description: "A localized error message") + code: CartUserInputErrorType! @doc(description: "Cart-specific error code") +} + +type AddProductsToCartOutput { + cart: Cart! @doc(description: "The cart after products have been added") + user_errors: [CartUserInputError!]! @doc(description: "An error encountered while adding an item to the cart.") +} + +enum CartUserInputErrorType { + PRODUCT_NOT_FOUND + NOT_SALABLE + INSUFFICIENT_STOCK + UNDEFINED +} + diff --git a/app/code/Magento/Wishlist/Model/Wishlist/BuyRequest/CustomizableOptionDataProvider.php b/app/code/Magento/Wishlist/Model/Wishlist/BuyRequest/CustomizableOptionDataProvider.php index e8f5bf0654f64..8bf12206336a8 100644 --- a/app/code/Magento/Wishlist/Model/Wishlist/BuyRequest/CustomizableOptionDataProvider.php +++ b/app/code/Magento/Wishlist/Model/Wishlist/BuyRequest/CustomizableOptionDataProvider.php @@ -31,21 +31,25 @@ public function execute(WishlistItem $wishlistItemData, ?int $productId): array continue; } - [, $optionId, $optionValue] = $optionData; + [$optionType, $optionId, $optionValue] = $optionData; - $customizableOptionsData[$optionId][] = $optionValue; + if ($optionType == self::PROVIDER_OPTION_TYPE) { + $customizableOptionsData[$optionId][] = $optionValue; + } } foreach ($wishlistItemData->getEnteredOptions() as $option) { - $optionData = \explode('/', base64_decode($option->getId())); + $optionData = \explode('/', base64_decode($option->getUid())); if ($this->isProviderApplicable($optionData) === false) { continue; } - [, $optionId] = $optionData; + [$optionType, $optionId] = $optionData; - $customizableOptionsData[$optionId][] = $option->getValue(); + if ($optionType == self::PROVIDER_OPTION_TYPE) { + $customizableOptionsData[$optionId][] = $option->getValue(); + } } if (empty($customizableOptionsData)) { diff --git a/app/code/Magento/Wishlist/Model/Wishlist/Data/EnteredOption.php b/app/code/Magento/Wishlist/Model/Wishlist/Data/EnteredOption.php index 0d6b2a2302540..edbf84781da38 100644 --- a/app/code/Magento/Wishlist/Model/Wishlist/Data/EnteredOption.php +++ b/app/code/Magento/Wishlist/Model/Wishlist/Data/EnteredOption.php @@ -15,7 +15,7 @@ class EnteredOption /** * @var string */ - private $id; + private $uid; /** * @var string @@ -23,12 +23,12 @@ class EnteredOption private $value; /** - * @param string $id + * @param string $uid * @param string $value */ - public function __construct(string $id, string $value) + public function __construct(string $uid, string $value) { - $this->id = $id; + $this->uid = $uid; $this->value = $value; } @@ -37,9 +37,9 @@ public function __construct(string $id, string $value) * * @return string */ - public function getId(): string + public function getUid(): string { - return $this->id; + return $this->uid; } /** diff --git a/app/code/Magento/Wishlist/Model/Wishlist/Data/WishlistItemFactory.php b/app/code/Magento/Wishlist/Model/Wishlist/Data/WishlistItemFactory.php index 153e8451bae31..aef3cbf571ff6 100644 --- a/app/code/Magento/Wishlist/Model/Wishlist/Data/WishlistItemFactory.php +++ b/app/code/Magento/Wishlist/Model/Wishlist/Data/WishlistItemFactory.php @@ -45,12 +45,12 @@ private function createEnteredOptions(array $options): array { return \array_map( function (array $option) { - if (!isset($option['id'], $option['value'])) { + if (!isset($option['uid'], $option['value'])) { throw new InputException( - __('Required fields are not present EnteredOption.id, EnteredOption.value') + __('Required fields are not present EnteredOption.uid, EnteredOption.value') ); } - return new EnteredOption($option['id'], $option['value']); + return new EnteredOption($option['uid'], $option['value']); }, $options ); diff --git a/app/code/Magento/WishlistGraphQl/Model/Resolver/AddProductsToWishlist.php b/app/code/Magento/WishlistGraphQl/Model/Resolver/AddProductsToWishlist.php index 11c8446a72a9d..3489585cd17d7 100644 --- a/app/code/Magento/WishlistGraphQl/Model/Resolver/AddProductsToWishlist.php +++ b/app/code/Magento/WishlistGraphQl/Model/Resolver/AddProductsToWishlist.php @@ -105,7 +105,7 @@ public function resolve( return [ 'wishlist' => $this->wishlistDataMapper->map($wishlistOutput->getWishlist()), - 'userInputErrors' => array_map( + 'user_errors' => array_map( function (Error $error) { return [ 'code' => $error->getCode(), diff --git a/app/code/Magento/WishlistGraphQl/Model/Resolver/RemoveProductsFromWishlist.php b/app/code/Magento/WishlistGraphQl/Model/Resolver/RemoveProductsFromWishlist.php index 1c741361ea7b7..a59c5ccdb0f70 100644 --- a/app/code/Magento/WishlistGraphQl/Model/Resolver/RemoveProductsFromWishlist.php +++ b/app/code/Magento/WishlistGraphQl/Model/Resolver/RemoveProductsFromWishlist.php @@ -109,7 +109,7 @@ public function resolve( return [ 'wishlist' => $this->wishlistDataMapper->map($wishlistOutput->getWishlist()), - 'userInputErrors' => \array_map( + 'user_errors' => \array_map( function (Error $error) { return [ 'code' => $error->getCode(), diff --git a/app/code/Magento/WishlistGraphQl/Model/Resolver/UpdateProductsInWishlist.php b/app/code/Magento/WishlistGraphQl/Model/Resolver/UpdateProductsInWishlist.php index 50a56863596c0..c6ede66fc2b1b 100644 --- a/app/code/Magento/WishlistGraphQl/Model/Resolver/UpdateProductsInWishlist.php +++ b/app/code/Magento/WishlistGraphQl/Model/Resolver/UpdateProductsInWishlist.php @@ -110,7 +110,7 @@ public function resolve( return [ 'wishlist' => $this->wishlistDataMapper->map($wishlistOutput->getWishlist()), - 'userInputErrors' => \array_map( + 'user_errors' => \array_map( function (Error $error) { return [ 'code' => $error->getCode(), diff --git a/app/code/Magento/WishlistGraphQl/etc/schema.graphqls b/app/code/Magento/WishlistGraphQl/etc/schema.graphqls index 794e90ed9f9a9..430e77cc45e96 100644 --- a/app/code/Magento/WishlistGraphQl/etc/schema.graphqls +++ b/app/code/Magento/WishlistGraphQl/etc/schema.graphqls @@ -43,34 +43,39 @@ input WishlistItemInput @doc(description: "Defines the items to add to a wish li sku: String @doc(description: "The SKU of the product to add. For complex product types, specify the child product SKU") quantity: Float @doc(description: "The amount or number of items to add") parent_sku: String @doc(description: "For complex product types, the SKU of the parent product") - selected_options: [String!] @doc(description: "An array of strings corresponding to options the customer selected") + selected_options: [ID!] @doc(description: "An array of strings corresponding to options the customer selected") entered_options: [EnteredOptionInput!] @doc(description: "An array of options that the customer entered") } type AddProductsToWishlistOutput @doc(description: "Contains the customer's wish list and any errors encountered") { wishlist: Wishlist! @doc(description: "Contains the wish list with all items that were successfully added") - userInputErrors:[CheckoutUserInputError]! @doc(description: "An array of errors encountered while adding products to a wish list") -} - -input EnteredOptionInput @doc(description: "Defines a customer-entered option") { - id: String! @doc(description: "A base64 encoded ID") - value: String! @doc(description: "Text the customer entered") + user_errors:[WishListUserInputError!]! @doc(description: "An array of errors encountered while adding products to a wish list") } type RemoveProductsFromWishlistOutput @doc(description: "Contains the customer's wish list and any errors encountered") { wishlist: Wishlist! @doc(description: "Contains the wish list with after items were successfully deleted") - userInputErrors:[CheckoutUserInputError]! @doc(description:"An array of errors encountered while deleting products from a wish list") + user_errors:[WishListUserInputError!]! @doc(description:"An array of errors encountered while deleting products from a wish list") } input WishlistItemUpdateInput @doc(description: "Defines updates to items in a wish list") { wishlist_item_id: ID @doc(description: "The ID of the wishlist item to update") quantity: Float @doc(description: "The new amount or number of this item") description: String @doc(description: "Describes the update") - selected_options: [String!] @doc(description: "An array of strings corresponding to options the customer selected") + selected_options: [ID!] @doc(description: "An array of strings corresponding to options the customer selected") entered_options: [EnteredOptionInput!] @doc(description: "An array of options that the customer entered") } type UpdateProductsInWishlistOutput @doc(description: "Contains the customer's wish list and any errors encountered") { wishlist: Wishlist! @doc(description: "Contains the wish list with all items that were successfully updated") - userInputErrors:[CheckoutUserInputError]! @doc(description:"An array of errors encountered while updating products in a wish list") + user_errors: [WishListUserInputError!]! @doc(description:"An array of errors encountered while updating products in a wish list") +} + +type WishListUserInputError @doc(description:"An error encountered while performing operations with WishList.") { + message: String! @doc(description: "A localized error message") + code: WishListUserInputErrorType! @doc(description: "Wishlist-specific error code") +} + +enum WishListUserInputErrorType { + PRODUCT_NOT_FOUND + UNDEFINED } diff --git a/composer.json b/composer.json index 5b39c1b3f75ea..c270fa0017f7f 100644 --- a/composer.json +++ b/composer.json @@ -242,6 +242,9 @@ "magento/module-product-video": "*", "magento/module-quote": "*", "magento/module-quote-analytics": "*", + "magento/module-quote-bundle-options": "*", + "magento/module-quote-configurable-options": "*", + "magento/module-quote-downloadable-links": "*", "magento/module-quote-graph-ql": "*", "magento/module-related-product-graph-ql": "*", "magento/module-release-notification": "*", diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Bundle/AddBundleProductToCartSingleMutationTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Bundle/AddBundleProductToCartSingleMutationTest.php new file mode 100644 index 0000000000000..fc0fdcf71525f --- /dev/null +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Bundle/AddBundleProductToCartSingleMutationTest.php @@ -0,0 +1,351 @@ +quoteResource = $objectManager->get(QuoteResource::class); + $this->quote = $objectManager->create(Quote::class); + $this->quoteIdToMaskedId = $objectManager->get(QuoteIdToMaskedQuoteIdInterface::class); + $this->productRepository = $objectManager->get(ProductRepositoryInterface::class); + } + + /** + * @magentoApiDataFixture Magento/Bundle/_files/product_1.php + * @magentoApiDataFixture Magento/Checkout/_files/active_quote.php + */ + public function testAddBundleProductToCart() + { + $sku = 'bundle-product'; + + $this->quoteResource->load( + $this->quote, + 'test_order_1', + 'reserved_order_id' + ); + + $product = $this->productRepository->get($sku); + + /** @var $typeInstance \Magento\Bundle\Model\Product\Type */ + $typeInstance = $product->getTypeInstance(); + $typeInstance->setStoreFilter($product->getStoreId(), $product); + /** @var $option \Magento\Bundle\Model\Option */ + $option = $typeInstance->getOptionsCollection($product)->getFirstItem(); + /** @var \Magento\Catalog\Model\Product $selection */ + $selection = $typeInstance->getSelectionsCollection([$option->getId()], $product)->getFirstItem(); + $optionId = $option->getId(); + $selectionId = $selection->getSelectionId(); + + $bundleOptionIdV2 = $this->generateBundleOptionIdV2((int) $optionId, (int) $selectionId, 1); + $maskedQuoteId = $this->quoteIdToMaskedId->execute((int)$this->quote->getId()); + + $query = <<graphQlMutation($query); + + self::assertArrayHasKey('addProductsToCart', $response); + self::assertArrayHasKey('cart', $response['addProductsToCart']); + $cart = $response['addProductsToCart']['cart']; + $bundleItem = current($cart['items']); + self::assertEquals($sku, $bundleItem['product']['sku']); + $bundleItemOption = current($bundleItem['bundle_options']); + self::assertEquals($optionId, $bundleItemOption['id']); + self::assertEquals($option->getTitle(), $bundleItemOption['label']); + self::assertEquals($option->getType(), $bundleItemOption['type']); + $value = current($bundleItemOption['values']); + self::assertEquals($selection->getSelectionId(), $value['id']); + self::assertEquals((float) $selection->getSelectionPriceValue(), $value['price']); + self::assertEquals(1, $value['quantity']); + } + + /** + * @param int $optionId + * @param int $selectionId + * @param int $quantity + * @return string + */ + private function generateBundleOptionIdV2(int $optionId, int $selectionId, int $quantity): string + { + return base64_encode("bundle/$optionId/$selectionId/$quantity"); + } + + public function dataProviderTestUpdateBundleItemQuantity(): array + { + return [ + [2], + [0], + ]; + } + + /** + * @magentoApiDataFixture Magento/Bundle/_files/product_1.php + * @magentoApiDataFixture Magento/Checkout/_files/active_quote.php + * @expectedExceptionMessage Please select all required options + */ + public function testAddBundleToCartWithWrongBundleOptions() + { + $this->quoteResource->load( + $this->quote, + 'test_order_1', + 'reserved_order_id' + ); + + $bundleOptionIdV2 = $this->generateBundleOptionIdV2((int) 1, (int) 1, 1); + $maskedQuoteId = $this->quoteIdToMaskedId->execute((int)$this->quote->getId()); + + $query = <<graphQlMutation($query); + + self::assertEquals( + "Please select all required options.", + $response['addProductsToCart']['user_errors'][0]['message'] + ); + } + + /** + * @magentoApiDataFixture Magento/Bundle/_files/product_with_multiple_options_and_custom_quantity.php + * @magentoApiDataFixture Magento/Checkout/_files/active_quote.php + */ + public function testAddBundleItemWithCustomOptionQuantity() + { + + $this->quoteResource->load( + $this->quote, + 'test_order_1', + 'reserved_order_id' + ); + $maskedQuoteId = $this->quoteIdToMaskedId->execute((int)$this->quote->getId()); + $response = $this->graphQlQuery($this->getProductQuery("bundle-product")); + $bundleItem = $response['products']['items'][0]; + $sku = $bundleItem['sku']; + $bundleOptions = $bundleItem['items']; + + $uId0 = $bundleOptions[0]['options'][0]['uid']; + $uId1 = $bundleOptions[1]['options'][0]['uid']; + $response = $this->graphQlMutation( + $this->getMutationsQuery($maskedQuoteId, $uId0, $uId1, $sku) + ); + $bundleOptions = $response['addProductsToCart']['cart']['items'][0]['bundle_options']; + $this->assertEquals(5, $bundleOptions[0]['values'][0]['quantity']); + $this->assertEquals(1, $bundleOptions[1]['values'][0]['quantity']); + } + + /** + * Returns GraphQL query for retrieving a product with customizable options + * + * @param string $sku + * @return string + */ + private function getProductQuery(string $sku): string + { + return <<getMaskedQuoteIdByReservedOrderId = $objectManager->get(GetMaskedQuoteIdByReservedOrderId::class); + } + + /** + * @magentoApiDataFixture Magento/ConfigurableProduct/_files/product_configurable.php + * @magentoApiDataFixture Magento/Checkout/_files/active_quote.php + */ + public function testAddConfigurableProductToCart() + { + $product = $this->getConfigurableProductInfo(); + $quantity = 2; + $parentSku = $product['sku']; + $attributeId = (int) $product['configurable_options'][0]['attribute_id']; + $valueIndex = $product['configurable_options'][0]['values'][1]['value_index']; + + $selectedConfigurableOptionsQuery = $this->generateSuperAttributesUIDQuery($attributeId, $valueIndex); + $maskedQuoteId = $this->getMaskedQuoteIdByReservedOrderId->execute('test_order_1'); + + $query = $this->getQuery( + $maskedQuoteId, + $product['sku'], + 2, + $selectedConfigurableOptionsQuery + ); + + $response = $this->graphQlMutation($query); + + $cartItem = current($response['addProductsToCart']['cart']['items']); + self::assertEquals($quantity, $cartItem['quantity']); + self::assertEquals($parentSku, $cartItem['product']['sku']); + self::assertArrayHasKey('configurable_options', $cartItem); + + $option = current($cartItem['configurable_options']); + self::assertEquals($attributeId, $option['id']); + self::assertEquals($valueIndex, $option['value_id']); + self::assertArrayHasKey('option_label', $option); + self::assertArrayHasKey('value_label', $option); + } + + /** + * Generates UID for super configurable product super attributes + * + * @param int $attributeId + * @param int $valueIndex + * @return string + */ + private function generateSuperAttributesUIDQuery(int $attributeId, int $valueIndex): string + { + return 'selected_options: ["' . base64_encode("configurable/$attributeId/$valueIndex") . '"]'; + } + + /** + * @magentoApiDataFixture Magento/Catalog/_files/configurable_products_with_custom_attribute_layered_navigation.php + * @magentoApiDataFixture Magento/Checkout/_files/active_quote.php + */ + public function testAddConfigurableProductWithWrongSuperAttributes() + { + $product = $this->getConfigurableProductInfo(); + $quantity = 2; + $parentSku = $product['sku']; + + $selectedConfigurableOptionsQuery = $this->generateSuperAttributesUIDQuery(0, 0); + $maskedQuoteId = $this->getMaskedQuoteIdByReservedOrderId->execute('test_order_1'); + + $query = $this->getQuery( + $maskedQuoteId, + $parentSku, + $quantity, + $selectedConfigurableOptionsQuery + ); + + $response = $this->graphQlMutation($query); + + self::assertEquals( + 'You need to choose options for your item.', + $response['addProductsToCart']['user_errors'][0]['message'] + ); + } + + /** + * @magentoApiDataFixture Magento/ConfigurableProduct/_files/product_configurable_sku.php + * @magentoApiDataFixture Magento/Checkout/_files/active_quote.php + */ + public function testAddProductIfQuantityIsNotAvailable() + { + $product = $this->getConfigurableProductInfo(); + $parentSku = $product['sku']; + $attributeId = (int) $product['configurable_options'][0]['attribute_id']; + $valueIndex = $product['configurable_options'][0]['values'][1]['value_index']; + + $selectedConfigurableOptionsQuery = $this->generateSuperAttributesUIDQuery($attributeId, $valueIndex); + $maskedQuoteId = $this->getMaskedQuoteIdByReservedOrderId->execute('test_order_1'); + + $query = $this->getQuery( + $maskedQuoteId, + $parentSku, + 2000, + $selectedConfigurableOptionsQuery + ); + + $response = $this->graphQlMutation($query); + + self::assertEquals( + 'The requested qty is not available', + $response['addProductsToCart']['user_errors'][0]['message'] + ); + } + + /** + * @magentoApiDataFixture Magento/ConfigurableProduct/_files/product_configurable_sku.php + * @magentoApiDataFixture Magento/Checkout/_files/active_quote.php + */ + public function testAddNonExistentConfigurableProductParentToCart() + { + $maskedQuoteId = $this->getMaskedQuoteIdByReservedOrderId->execute('test_order_1'); + $parentSku = 'configurable_no_exist'; + + $query = $this->getQuery( + $maskedQuoteId, + $parentSku, + 1, + '' + ); + + $response = $this->graphQlMutation($query); + + self::assertEquals( + 'Could not find a product with SKU "configurable_no_exist"', + $response['addProductsToCart']['user_errors'][0]['message'] + ); + } + + /** + * @magentoApiDataFixture Magento/ConfigurableProduct/_files/product_configurable_zero_qty_first_child.php + * @magentoApiDataFixture Magento/Checkout/_files/active_quote.php + */ + public function testOutOfStockVariationToCart() + { + $product = $this->getConfigurableProductInfo(); + $attributeId = (int) $product['configurable_options'][0]['attribute_id']; + $valueIndex = $product['configurable_options'][0]['values'][0]['value_index']; + $parentSku = $product['sku']; + + $configurableOptionsQuery = $this->generateSuperAttributesUIDQuery($attributeId, $valueIndex); + $maskedQuoteId = $this->getMaskedQuoteIdByReservedOrderId->execute('test_order_1'); + + $query = $this->getQuery( + $maskedQuoteId, + $parentSku, + 1, + $configurableOptionsQuery + ); + + $response = $this->graphQlMutation($query); + + $expectedErrorMessages = [ + 'There are no source items with the in stock status', + 'This product is out of stock.' + ]; + $this->assertContains( + $response['addProductsToCart']['user_errors'][0]['message'], + $expectedErrorMessages + ); + } + + /** + * @param string $maskedQuoteId + * @param string $parentSku + * @param int $quantity + * @param string $selectedOptionsQuery + * @return string + */ + private function getQuery( + string $maskedQuoteId, + string $parentSku, + int $quantity, + string $selectedOptionsQuery + ): string { + return <<graphQlQuery($this->getFetchProductQuery('configurable')); + return current($searchResponse['products']['items']); + } + + /** + * Returns GraphQl query for fetching configurable product information + * + * @param string $term + * @return string + */ + private function getFetchProductQuery(string $term): string + { + return <<objectManager = Bootstrap::getObjectManager(); + $this->getMaskedQuoteIdByReservedOrderId = $this->objectManager->get(GetMaskedQuoteIdByReservedOrderId::class); + $this->getCartItemOptionsFromUID = $this->objectManager->get(GetCartItemOptionsFromUID::class); + $this->getCustomOptionsWithIDV2ForQueryBySku = + $this->objectManager->get(GetCustomOptionsWithUIDForQueryBySku::class); + } + + /** + * @magentoApiDataFixture Magento/Downloadable/_files/product_downloadable_with_custom_options.php + * @magentoApiDataFixture Magento/Checkout/_files/active_quote.php + */ + public function testAddDownloadableProductWithOptions() + { + $maskedQuoteId = $this->getMaskedQuoteIdByReservedOrderId->execute('test_order_1'); + + $sku = 'downloadable-product-with-purchased-separately-links'; + $qty = 1; + $links = $this->getProductsLinks($sku); + $linkId = key($links); + + $itemOptions = $this->getCustomOptionsWithIDV2ForQueryBySku->execute($sku); + $decodedItemOptions = $this->getCartItemOptionsFromUID->execute($itemOptions); + + /* The type field is only required for assertions, it should not be present in query */ + foreach ($itemOptions['entered_options'] as &$enteredOption) { + if (isset($enteredOption['type'])) { + unset($enteredOption['type']); + } + } + + /* Add downloadable product link data to the "selected_options" */ + $itemOptions['selected_options'][] = $this->generateProductLinkSelectedOptions($linkId); + + $productOptionsQuery = preg_replace( + '/"([^"]+)"\s*:\s*/', + '$1:', + json_encode($itemOptions) + ); + + $query = $this->getQuery($maskedQuoteId, $qty, $sku, trim($productOptionsQuery, '{}')); + $response = $this->graphQlMutation($query); + + self::assertArrayHasKey('items', $response['addProductsToCart']['cart']); + self::assertCount($qty, $response['addProductsToCart']['cart']); + self::assertEquals($linkId, $response['addProductsToCart']['cart']['items'][0]['links'][0]['id']); + + $customizableOptionsOutput = + $response['addProductsToCart']['cart']['items'][0]['customizable_options']; + + foreach ($customizableOptionsOutput as $customizableOptionOutput) { + $customizableOptionOutputValues = []; + foreach ($customizableOptionOutput['values'] as $customizableOptionOutputValue) { + $customizableOptionOutputValues[] = $customizableOptionOutputValue['value']; + } + if (count($customizableOptionOutputValues) === 1) { + $customizableOptionOutputValues = $customizableOptionOutputValues[0]; + } + + self::assertEquals( + $decodedItemOptions[$customizableOptionOutput['id']], + $customizableOptionOutputValues + ); + } + } + + /** + * Function returns array of all product's links + * + * @param string $sku + * @return array + */ + private function getProductsLinks(string $sku) : array + { + $result = []; + $productRepository = $this->objectManager->get(ProductRepositoryInterface::class); + + $product = $productRepository->get($sku, false, null, true); + + foreach ($product->getDownloadableLinks() as $linkObject) { + $result[$linkObject->getLinkId()] = [ + 'title' => $linkObject->getTitle(), + 'link_type' => null, //deprecated field + 'price' => $linkObject->getPrice(), + ]; + } + + return $result; + } + + /** + * Generates UID for downloadable links + * + * @param int $linkId + * @return string + */ + private function generateProductLinkSelectedOptions(int $linkId): string + { + return base64_encode("downloadable/$linkId"); + } + + /** + * Returns GraphQl query string + * + * @param string $maskedQuoteId + * @param int $qty + * @param string $sku + * @param string $customizableOptions + * @return string + */ + private function getQuery( + string $maskedQuoteId, + int $qty, + string $sku, + string $customizableOptions + ): string { + return <<getMaskedQuoteIdByReservedOrderId = $objectManager->get(GetMaskedQuoteIdByReservedOrderId::class); + $this->getCartItemOptionsFromUID = $objectManager->get(GetCartItemOptionsFromUID::class); + $this->getCustomOptionsWithIDV2ForQueryBySku = $objectManager->get( + GetCustomOptionsWithUIDForQueryBySku::class + ); + } + + /** + * Test adding a simple product to the shopping cart with all supported + * customizable options assigned + * + * @magentoApiDataFixture Magento/Catalog/_files/product_simple_with_options.php + * @magentoApiDataFixture Magento/Checkout/_files/active_quote.php + */ + public function testAddSimpleProductWithOptions() + { + $sku = 'simple'; + $maskedQuoteId = $this->getMaskedQuoteIdByReservedOrderId->execute('test_order_1'); + $qty = 1; + + $productOptionsData = $this->getProductOptionsViaQuery($sku); + + $itemOptionsQuery = preg_replace( + '/"([^"]+)"\s*:\s*/', + '$1:', + json_encode($productOptionsData['received_options']) + ); + + $query = $this->getAddToCartMutation($maskedQuoteId, $qty, $sku, trim($itemOptionsQuery, '{}')); + $response = $this->graphQlMutation($query); + + self::assertArrayHasKey('customizable_options', $response['addProductsToCart']['cart']['items'][0]); + + foreach ($response['addProductsToCart']['cart']['items'][0]['customizable_options'] as $option) { + self::assertEquals($productOptionsData['expected_options'][$option['id']], $option['values'][0]['value']); + } + } + + /** + * Get product data with customizable options using GraphQl query + * + * @param string $sku + * @return array + * @throws \Exception + */ + private function getProductOptionsViaQuery(string $sku): array + { + $query = $this->getProductQuery($sku); + $response = $this->graphQlQuery($query); + self::assertArrayHasKey('options', $response['products']['items'][0]); + + $expectedItemOptions = []; + $receivedItemOptions = [ + 'entered_options' => [], + 'selected_options' => [] + ]; + + foreach ($response['products']['items'][0]['options'] as $option) { + if (isset($option['entered_option'])) { + /* The date normalization is required since the attribute might value is formatted by the system */ + if ($option['title'] === 'date option') { + $value = '2012-12-12 00:00:00'; + $expectedItemOptions[$option['option_id']] = date('M d, Y', strtotime($value)); + } else { + $value = 'test'; + $expectedItemOptions[$option['option_id']] = $value; + } + $value = $option['title'] === 'date option' ? '2012-12-12 00:00:00' : 'test'; + + $receivedItemOptions['entered_options'][] = [ + 'uid' => $option['entered_option']['uid'], + 'value' => $value + ]; + + } elseif (isset($option['selected_option'])) { + $receivedItemOptions['selected_options'][] = reset($option['selected_option'])['uid']; + $expectedItemOptions[$option['option_id']] = reset($option['selected_option'])['option_type_id']; + } + } + + return [ + 'expected_options' => $expectedItemOptions, + 'received_options' => $receivedItemOptions + ]; + } + + /** + * Returns GraphQL query for retrieving a product with customizable options + * + * @param string $sku + * @return string + */ + private function getProductQuery(string $sku): string + { + return <<getMaskedQuoteIdByReservedOrderId = $objectManager->get(GetMaskedQuoteIdByReservedOrderId::class); + $this->getCartItemOptionsFromUID = $objectManager->get(GetCartItemOptionsFromUID::class); + $this->getCustomOptionsWithIDV2ForQueryBySku = $objectManager->get( + GetCustomOptionsWithUIDForQueryBySku::class + ); + } + + /** + * Test adding a simple product to the shopping cart with all supported + * customizable options assigned + * + * @magentoApiDataFixture Magento/Catalog/_files/product_simple_with_options.php + * @magentoApiDataFixture Magento/Checkout/_files/active_quote.php + */ + public function testAddSimpleProductWithOptions() + { + $maskedQuoteId = $this->getMaskedQuoteIdByReservedOrderId->execute('test_order_1'); + + $sku = 'simple'; + $qty = 1; + + $itemOptions = $this->getCustomOptionsWithIDV2ForQueryBySku->execute($sku); + $decodedItemOptions = $this->getCartItemOptionsFromUID->execute($itemOptions); + + /* The type field is only required for assertions, it should not be present in query */ + foreach ($itemOptions['entered_options'] as &$enteredOption) { + if (isset($enteredOption['type'])) { + unset($enteredOption['type']); + } + } + + $productOptionsQuery = preg_replace( + '/"([^"]+)"\s*:\s*/', + '$1:', + json_encode($itemOptions) + ); + + $query = $this->getAddToCartMutation($maskedQuoteId, $qty, $sku, trim($productOptionsQuery, '{}')); + $response = $this->graphQlMutation($query); + + self::assertArrayHasKey('items', $response['addProductsToCart']['cart']); + self::assertCount($qty, $response['addProductsToCart']['cart']['items']); + $customizableOptionsOutput = + $response['addProductsToCart']['cart']['items'][0]['customizable_options']; + + foreach ($customizableOptionsOutput as $customizableOptionOutput) { + $customizableOptionOutputValues = []; + foreach ($customizableOptionOutput['values'] as $customizableOptionOutputValue) { + $customizableOptionOutputValues[] = $customizableOptionOutputValue['value']; + } + if (count($customizableOptionOutputValues) === 1) { + $customizableOptionOutputValues = $customizableOptionOutputValues[0]; + } + + self::assertEquals( + $decodedItemOptions[$customizableOptionOutput['id']], + $customizableOptionOutputValues + ); + } + } + + /** + * @param string $sku + * @param string $message + * + * @dataProvider wrongSkuDataProvider + * + * @magentoApiDataFixture Magento/Checkout/_files/active_quote.php + */ + public function testAddProductWithWrongSku(string $sku, string $message) + { + $maskedQuoteId = $this->getMaskedQuoteIdByReservedOrderId->execute('test_order_1'); + + $query = $this->getAddToCartMutation($maskedQuoteId, 1, $sku, ''); + $response = $this->graphQlMutation($query); + + self::assertArrayHasKey('user_errors', $response['addProductsToCart']); + self::assertCount(1, $response['addProductsToCart']['user_errors']); + self::assertEquals( + $message, + $response['addProductsToCart']['user_errors'][0]['message'] + ); + } + + /** + * The test covers the case when upon adding available_qty + 1 to the shopping cart, the cart is being + * cleared + * + * @magentoApiDataFixture Magento/Catalog/_files/product_simple_without_custom_options.php + * @magentoApiDataFixture Magento/Checkout/_files/active_quote.php + */ + public function testAddToCartWithQtyPlusOne() + { + $maskedQuoteId = $this->getMaskedQuoteIdByReservedOrderId->execute('test_order_1'); + $sku = 'simple-2'; + + $query = $this->getAddToCartMutation($maskedQuoteId, 100, $sku, ''); + $response = $this->graphQlMutation($query); + + self::assertEquals(100, $response['addProductsToCart']['cart']['total_quantity']); + + $query = $this->getAddToCartMutation($maskedQuoteId, 1, $sku, ''); + $response = $this->graphQlMutation($query); + + self::assertArrayHasKey('user_errors', $response['addProductsToCart']); + self::assertEquals( + 'The requested qty is not available', + $response['addProductsToCart']['user_errors'][0]['message'] + ); + self::assertEquals(100, $response['addProductsToCart']['cart']['total_quantity']); + } + + /** + * @param int $quantity + * @param string $message + * + * @dataProvider wrongQuantityDataProvider + * + * @magentoApiDataFixture Magento/Catalog/_files/product_simple_without_custom_options.php + * @magentoApiDataFixture Magento/Checkout/_files/active_quote.php + */ + public function testAddProductWithWrongQuantity(int $quantity, string $message) + { + $maskedQuoteId = $this->getMaskedQuoteIdByReservedOrderId->execute('test_order_1'); + $sku = 'simple-2'; + + $query = $this->getAddToCartMutation($maskedQuoteId, $quantity, $sku, ''); + $response = $this->graphQlMutation($query); + self::assertArrayHasKey('user_errors', $response['addProductsToCart']); + self::assertCount(1, $response['addProductsToCart']['user_errors']); + + self::assertEquals( + $message, + $response['addProductsToCart']['user_errors'][0]['message'] + ); + } + + /** + * @return array + */ + public function wrongSkuDataProvider(): array + { + return [ + 'Non-existent SKU' => [ + 'non-existent', + 'Could not find a product with SKU "non-existent"' + ], + 'Empty SKU' => [ + '', + 'Could not find a product with SKU ""' + ] + ]; + } + + /** + * @return array + */ + public function wrongQuantityDataProvider(): array + { + return [ + 'More quantity than in stock' => [ + 101, + 'The requested qty is not available' + ], + 'Quantity equals zero' => [ + 0, + 'The product quantity should be greater than 0' + ] + ]; + } + + /** + * Returns GraphQl query string + * + * @param string $maskedQuoteId + * @param int $qty + * @param string $sku + * @param string $customizableOptions + * @return string + */ + private function getAddToCartMutation( + string $maskedQuoteId, + int $qty, + string $sku, + string $customizableOptions + ): string { + return <<productCustomOptionRepository = $productCustomOptionRepository; + } + + /** + * Returns array of custom options for the product + * + * @param string $sku + * @return array + */ + public function execute(string $sku): array + { + $customOptions = $this->productCustomOptionRepository->getList($sku); + $selectedOptions = []; + $enteredOptions = []; + + foreach ($customOptions as $customOption) { + $optionType = $customOption->getType(); + + switch ($optionType) { + case 'field': + case 'area': + $enteredOptions[] = [ + 'type' => 'field', + 'uid' => $this->encodeEnteredOption((int) $customOption->getOptionId()), + 'value' => 'test' + ]; + break; + case 'date': + $enteredOptions[] = [ + 'type' => 'date', + 'uid' => $this->encodeEnteredOption((int) $customOption->getOptionId()), + 'value' => '2012-12-12 00:00:00' + ]; + break; + case 'drop_down': + $optionSelectValues = $customOption->getValues(); + $selectedOptions[] = $this->encodeSelectedOption( + (int) $customOption->getOptionId(), + (int) reset($optionSelectValues)->getOptionTypeId() + ); + break; + case 'multiple': + foreach ($customOption->getValues() as $optionValue) { + $selectedOptions[] = $this->encodeSelectedOption( + (int) $customOption->getOptionId(), + (int) $optionValue->getOptionTypeId() + ); + } + break; + } + } + + return [ + 'selected_options' => $selectedOptions, + 'entered_options' => $enteredOptions + ]; + } + + /** + * Returns UID of the selected custom option + * + * @param int $optionId + * @param int $optionValueId + * @return string + */ + private function encodeSelectedOption(int $optionId, int $optionValueId): string + { + return base64_encode("custom-option/$optionId/$optionValueId"); + } + + /** + * Returns UID of the entered custom option + * + * @param int $optionId + * @return string + */ + private function encodeEnteredOption(int $optionId): string + { + return base64_encode("custom-option/$optionId"); + } +} diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/AddBundleProductToWishlistTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/AddBundleProductToWishlistTest.php index c0199e8908d0e..a81ec701b22a8 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/AddBundleProductToWishlistTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/AddBundleProductToWishlistTest.php @@ -140,7 +140,7 @@ private function getQuery( } ] ) { - userInputErrors { + user_errors { code message } diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/AddConfigurableProductToWishlistTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/AddConfigurableProductToWishlistTest.php index 386df99f0d211..d8d44541f899d 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/AddConfigurableProductToWishlistTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/AddConfigurableProductToWishlistTest.php @@ -126,7 +126,7 @@ private function getQuery( } ] ) { - userInputErrors { + user_errors { code message } diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/AddDownloadableProductToWishlistTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/AddDownloadableProductToWishlistTest.php index 389f4eae4c574..489a960056f1b 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/AddDownloadableProductToWishlistTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/AddDownloadableProductToWishlistTest.php @@ -181,7 +181,7 @@ private function getQuery( } ] ) { - userInputErrors { + user_errors { code message } diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/DeleteProductsFromWishlistTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/DeleteProductsFromWishlistTest.php index 2e203e3ff4228..ebe99289b8934 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/DeleteProductsFromWishlistTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/DeleteProductsFromWishlistTest.php @@ -90,7 +90,7 @@ private function getQuery( wishlistId: {$wishlistId}, wishlistItemsIds: [{$wishlistItemId}] ) { - userInputErrors { + user_errors { code message } diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/GetCustomOptionsWithIDV2ForQueryBySku.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/GetCustomOptionsWithIDV2ForQueryBySku.php index 6d54d9f0b4444..fcba7458f317a 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/GetCustomOptionsWithIDV2ForQueryBySku.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/GetCustomOptionsWithIDV2ForQueryBySku.php @@ -45,7 +45,7 @@ public function execute(string $sku): array if ($optionType === 'field' || $optionType === 'area' || $optionType === 'date') { $enteredOptions[] = [ - 'id' => $this->encodeEnteredOption((int)$customOption->getOptionId()), + 'uid' => $this->encodeEnteredOption((int)$customOption->getOptionId()), 'value' => '2012-12-12' ]; } elseif ($optionType === 'drop_down') { diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/UpdateProductsFromWishlistTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/UpdateProductsFromWishlistTest.php index 9e96bdc5d7079..9a9cd424e54ca 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/UpdateProductsFromWishlistTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/UpdateProductsFromWishlistTest.php @@ -102,7 +102,7 @@ private function getQuery( } ] ) { - userInputErrors { + user_errors { code message } diff --git a/dev/tests/integration/testsuite/Magento/Bundle/_files/product_with_multiple_options_and_custom_quantity.php b/dev/tests/integration/testsuite/Magento/Bundle/_files/product_with_multiple_options_and_custom_quantity.php new file mode 100644 index 0000000000000..a623c583fb599 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Bundle/_files/product_with_multiple_options_and_custom_quantity.php @@ -0,0 +1,137 @@ +requireDataFixture('Magento/Catalog/_files/multiple_products.php'); + +$objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager(); + +$productIds = range(10, 12, 1); +foreach ($productIds as $productId) { + /** @var \Magento\CatalogInventory\Model\Stock\Item $stockItem */ + $stockItem = $objectManager->create(\Magento\CatalogInventory\Model\Stock\Item::class); + $stockItem->load($productId, 'product_id'); + + if (!$stockItem->getProductId()) { + $stockItem->setProductId($productId); + } + $stockItem->setUseConfigManageStock(1); + $stockItem->setQty(1000); + $stockItem->setIsQtyDecimal(0); + $stockItem->setIsInStock(1); + $stockItem->save(); +} + +/** @var $product \Magento\Catalog\Model\Product */ +$product = $objectManager->create(\Magento\Catalog\Model\Product::class); +$product->setTypeId(\Magento\Catalog\Model\Product\Type::TYPE_BUNDLE) + ->setId(3) + ->setAttributeSetId(4) + ->setName('Bundle Product') + ->setSku('bundle-product') + ->setVisibility(\Magento\Catalog\Model\Product\Visibility::VISIBILITY_BOTH) + ->setStatus(\Magento\Catalog\Model\Product\Attribute\Source\Status::STATUS_ENABLED) + ->setStockData(['use_config_manage_stock' => 1, 'qty' => 100, 'is_qty_decimal' => 0, 'is_in_stock' => 1]) + ->setWebsiteIds([1]) + ->setPriceType(1) + ->setPrice(10.0) + ->setShipmentType(0) + ->setPriceView(1) + ->setBundleOptionsData( + [ + // Required "Drop-down" option + [ + 'title' => 'Option 1', + 'default_title' => 'Option 1', + 'type' => 'select', + 'required' => 1, + 'position' => 1, + 'delete' => '', + ], + // Required "Radio Buttons" option + [ + 'title' => 'Option 2', + 'default_title' => 'Option 2', + 'type' => 'radio', + 'required' => 1, + 'position' => 2, + 'delete' => '', + ], + ] + )->setBundleSelectionsData( + [ + [ + [ + 'product_id' => 10, + 'selection_qty' => 1, + 'selection_can_change_qty' => 1, + 'delete' => '', + 'option_id' => 1 + ], + [ + 'product_id' => 11, + 'selection_qty' => 1, + 'selection_can_change_qty' => 1, + 'delete' => '', + 'option_id' => 1 + ] + ], + [ + [ + 'product_id' => 10, + 'selection_qty' => 1, + 'selection_can_change_qty' => 0, + 'delete' => '', + 'option_id' => 2 + ], + [ + 'product_id' => 11, + 'selection_qty' => 1, + 'selection_can_change_qty' => 0, + 'delete' => '', + 'option_id' => 2 + ] + ] + ] + ); +$productRepository = $objectManager->create(\Magento\Catalog\Api\ProductRepositoryInterface::class); + +if ($product->getBundleOptionsData()) { + $options = []; + foreach ($product->getBundleOptionsData() as $key => $optionData) { + if (!(bool)$optionData['delete']) { + $option = $objectManager->create(\Magento\Bundle\Api\Data\OptionInterfaceFactory::class) + ->create(['data' => $optionData]); + $option->setSku($product->getSku()); + $option->setOptionId(null); + + $links = []; + $bundleLinks = $product->getBundleSelectionsData(); + if (!empty($bundleLinks[$key])) { + foreach ($bundleLinks[$key] as $linkData) { + if (!(bool)$linkData['delete']) { + $link = $objectManager->create(\Magento\Bundle\Api\Data\LinkInterfaceFactory::class) + ->create(['data' => $linkData]); + $linkProduct = $productRepository->getById($linkData['product_id']); + $link->setSku($linkProduct->getSku()); + $link->setQty($linkData['selection_qty']); + if (isset($linkData['selection_can_change_qty'])) { + $link->setCanChangeQuantity($linkData['selection_can_change_qty']); + } + $links[] = $link; + } + } + $option->setProductLinks($links); + $options[] = $option; + } + } + } + $extension = $product->getExtensionAttributes(); + $extension->setBundleProductOptions($options); + $product->setExtensionAttributes($extension); +} +$product->save(); diff --git a/dev/tests/integration/testsuite/Magento/Bundle/_files/product_with_multiple_options_and_custom_quantity_rollback.php b/dev/tests/integration/testsuite/Magento/Bundle/_files/product_with_multiple_options_and_custom_quantity_rollback.php new file mode 100644 index 0000000000000..9d702b4506551 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Bundle/_files/product_with_multiple_options_and_custom_quantity_rollback.php @@ -0,0 +1,30 @@ +requireDataFixture('Magento/Catalog/_files/multiple_products_rollback.php'); + +$objectManager = Bootstrap::getObjectManager(); +/** @var \Magento\Catalog\Api\ProductRepositoryInterface $productRepository */ +$productRepository = $objectManager->create(\Magento\Catalog\Api\ProductRepositoryInterface::class); +/** @var \Magento\Framework\Registry $registry */ +$registry = Bootstrap::getObjectManager()->get(\Magento\Framework\Registry::class); + + +$registry->unregister('isSecureArea'); +$registry->register('isSecureArea', true); + +try { + $product = $productRepository->get('bundle-product'); + $productRepository->delete($product); +} catch (\Magento\Framework\Exception\NoSuchEntityException $exception) { + //Product already removed +} + +$registry->unregister('isSecureArea'); +$registry->register('isSecureArea', false);