diff --git a/app/code/Magento/Catalog/Test/Fixture/ProductStock.php b/app/code/Magento/Catalog/Test/Fixture/ProductStock.php new file mode 100644 index 0000000000000..ee3dde3ab4d87 --- /dev/null +++ b/app/code/Magento/Catalog/Test/Fixture/ProductStock.php @@ -0,0 +1,77 @@ + null, + 'prod_qty' => 1 + ]; + + /** + * @var DataObjectFactory + */ + protected DataObjectFactory $dataObjectFactory; + + /** + * @var StockRegistryInterface + */ + protected StockRegistryInterface $stockRegistry; + + /** + * @var DataMerger + */ + protected DataMerger $dataMerger; + + /** + * @param DataObjectFactory $dataObjectFactory + * @param StockRegistryInterface $stockRegistry + * @param DataMerger $dataMerger + */ + public function __construct( + DataObjectFactory $dataObjectFactory, + StockRegistryInterface $stockRegistry, + DataMerger $dataMerger + ) { + $this->dataObjectFactory = $dataObjectFactory; + $this->stockRegistry = $stockRegistry; + $this->dataMerger = $dataMerger; + } + + /** + * {@inheritdoc} + * @param array $data Parameters. Same format as ProductStock::DEFAULT_DATA + */ + public function apply(array $data = []): ?DataObject + { + $data = $this->dataMerger->merge(self::DEFAULT_DATA, $data); + $stockItem = $this->stockRegistry->getStockItem($data['prod_id']); + $stockItem->setData('is_in_stock', 1); + $stockItem->setData('qty', 90); + $stockItem->setData('manage_stock', 1); + $stockItem->save(); + + return $this->dataObjectFactory->create(['data' => [$data]]); + } +} diff --git a/app/code/Magento/Quote/Model/Quote/Item.php b/app/code/Magento/Quote/Model/Quote/Item.php index 580aafb15aed4..d05085d1d3cb4 100644 --- a/app/code/Magento/Quote/Model/Quote/Item.php +++ b/app/code/Magento/Quote/Model/Quote/Item.php @@ -139,7 +139,7 @@ class Item extends \Magento\Quote\Model\Quote\Item\AbstractItem implements \Mage protected $_optionsByCode = []; /** - * Not Represent options + * Not Represent option * * @var array */ @@ -148,6 +148,7 @@ class Item extends \Magento\Quote\Model\Quote\Item\AbstractItem implements \Mage /** * Flag stating that options were successfully saved * + * @var bool */ protected $_flagOptionsSaved; @@ -176,6 +177,7 @@ class Item extends \Magento\Quote\Model\Quote\Item\AbstractItem implements \Mage /** * @var \Magento\CatalogInventory\Api\StockRegistryInterface * @deprecated 101.0.0 + * @see nothing */ protected $stockRegistry; @@ -348,6 +350,7 @@ public function addQty($qty) if (!$this->getParentItem() || !$this->getId()) { $qty = $this->_prepareQty($qty); $this->setQtyToAdd($qty); + $this->setPreviousQty($this->getQty()); $this->setQty($this->getQty() + $qty); } return $this; diff --git a/app/code/Magento/QuoteGraphQl/Model/CartItem/ProductStock.php b/app/code/Magento/QuoteGraphQl/Model/CartItem/ProductStock.php new file mode 100644 index 0000000000000..a39666131b4e9 --- /dev/null +++ b/app/code/Magento/QuoteGraphQl/Model/CartItem/ProductStock.php @@ -0,0 +1,95 @@ +getQuote()->getItems() as $item) { + if ($item->getItemId() === $cartItem->getItemId()) { + $requestedQty = $item->getQtyToAdd() ?? $item->getQty(); + $previousQty = $item->getPreviousQty() ?? 0; + } + } + + if ($cartItem->getProductType() === self::PRODUCT_TYPE_BUNDLE) { + $qtyOptions = $cartItem->getQtyOptions(); + $totalRequestedQty = $previousQty + $requestedQty; + foreach ($qtyOptions as $qtyOption) { + $productId = (int) $qtyOption->getProductId(); + $requiredItemQty = (float) $qtyOption->getValue(); + if ($totalRequestedQty) { + $requiredItemQty = $requiredItemQty * $totalRequestedQty; + } + if (!$this->isStockAvailable($productId, $requiredItemQty)) { + return false; + } + } + } else { + $requiredItemQty = $requestedQty + $previousQty; + $productId = (int) $cartItem->getProduct()->getId(); + return $this->isStockAvailable($productId, $requiredItemQty); + } + return true; + } + + /** + * Check if is required product available in stock + * + * @param int $productId + * @param float $requiredQuantity + * @return bool + */ + private function isStockAvailable(int $productId, float $requiredQuantity): bool + { + $stock = $this->stockStatusRepository->get($productId); + return $stock->getQty() >= $requiredQuantity; + } +} diff --git a/app/code/Magento/QuoteGraphQl/Model/Resolver/CheckProductStockAvailability.php b/app/code/Magento/QuoteGraphQl/Model/Resolver/CheckProductStockAvailability.php new file mode 100644 index 0000000000000..1db3f5e3358c2 --- /dev/null +++ b/app/code/Magento/QuoteGraphQl/Model/Resolver/CheckProductStockAvailability.php @@ -0,0 +1,56 @@ +productStock->isProductAvailable($cartItem); + } +} diff --git a/app/code/Magento/QuoteGraphQl/etc/schema.graphqls b/app/code/Magento/QuoteGraphQl/etc/schema.graphqls index 89473e1554e11..e8653da785c97 100644 --- a/app/code/Magento/QuoteGraphQl/etc/schema.graphqls +++ b/app/code/Magento/QuoteGraphQl/etc/schema.graphqls @@ -360,6 +360,7 @@ interface CartItemInterface @typeResolver(class: "Magento\\QuoteGraphQl\\Model\\ id: String! @deprecated(reason: "Use `uid` instead.") uid: ID! @doc(description: "The unique ID for a `CartItemInterface` object.") quantity: Float! @doc(description: "The quantity of this item in the cart.") + is_available: Boolean! @resolver(class: "\\Magento\\QuoteGraphQl\\Model\\Resolver\\CheckProductStockAvailability") @doc(description: "True if requested quantity is less than available stock, false otherwise.") prices: CartItemPrices @resolver(class: "\\Magento\\QuoteGraphQl\\Model\\Resolver\\CartItemPrices") @doc(description: "Contains details about the price of the item, including taxes and discounts.") product: ProductInterface! @doc(description: "Details about an item in the cart.") errors: [CartItemError!] @resolver(class: "\\Magento\\QuoteGraphQl\\Model\\Resolver\\CartItemErrors") @doc(description: "An array of errors encountered while loading the cart item") diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/StockAvailabilityTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/StockAvailabilityTest.php new file mode 100644 index 0000000000000..bdf6423e1f8f3 --- /dev/null +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/StockAvailabilityTest.php @@ -0,0 +1,414 @@ +objectManager = Bootstrap::getObjectManager(); + $this->fixtures = DataFixtureStorageManager::getStorage(); + $this->productRepository = Bootstrap::getObjectManager()->get(ProductRepositoryInterface::class); + } + + #[ + DataFixture(ProductFixture::class, ['price' => 100.00], as: 'product'), + DataFixture(GuestCartFixture::class, as: 'cart'), + DataFixture(AddProductToCart::class, ['cart_id' => '$cart.id$', 'product_id' => '$product.id$', 'qty' => 100]), + DataFixture(QuoteMaskFixture::class, ['cart_id' => '$cart.id$'], 'quoteIdMask'), + DataFixture(ProductStockFixture::class, ['prod_id' => '$product.id$', 'prod_qty' => 90], 'prodStock') + ] + public function testStockStatusUnavailableSimpleProduct(): void + { + $maskedQuoteId = $this->fixtures->get('quoteIdMask')->getMaskedId(); + $query = $this->getQuery($maskedQuoteId); + $response = $this->graphQlMutation($query); + $responseDataObject = new DataObject($response); + + self::assertFalse( + $responseDataObject->getData('cart/items/0/is_available') + ); + } + + #[ + DataFixture(ProductFixture::class, ['price' => 100.00], as: 'product'), + DataFixture(GuestCartFixture::class, as: 'cart'), + DataFixture(AddProductToCart::class, ['cart_id' => '$cart.id$', 'product_id' => '$product.id$', 'qty' => 100]), + DataFixture(QuoteMaskFixture::class, ['cart_id' => '$cart.id$'], 'quoteIdMask') + ] + public function testStockStatusAvailableSimpleProduct(): void + { + $maskedQuoteId = $this->fixtures->get('quoteIdMask')->getMaskedId(); + $query = $this->getQuery($maskedQuoteId); + $response = $this->graphQlMutation($query); + $responseDataObject = new DataObject($response); + + self::assertTrue( + $responseDataObject->getData('cart/items/0/is_available') + ); + } + + #[ + DataFixture(ProductFixture::class, ['sku' => self::SKU, 'price' => 100.00], as: 'product'), + DataFixture(GuestCartFixture::class, as: 'cart'), + DataFixture(AddProductToCart::class, ['cart_id' => '$cart.id$', 'product_id' => '$product.id$', 'qty' => 99]), + DataFixture(QuoteMaskFixture::class, ['cart_id' => '$cart.id$'], 'quoteIdMask') + ] + public function testStockStatusAddSimpleProduct(): void + { + $maskedQuoteId = $this->fixtures->get('quoteIdMask')->getMaskedId(); + $query = $this->mutationAddSimpleProduct($maskedQuoteId, self::SKU, 1); + $response = $this->graphQlMutation($query); + $responseDataObject = new DataObject($response); + self::assertTrue( + $responseDataObject->getData('addProductsToCart/cart/items/0/is_available') + ); + $response = $this->graphQlMutation($query); + $responseDataObject = new DataObject($response); + self::assertFalse( + $responseDataObject->getData('addProductsToCart/cart/items/0/is_available') + ); + } + + #[ + DataFixture(ProductFixture::class, ['price' => 100.00], as: 'product'), + DataFixture( + BundleSelectionFixture::class, + [ + 'sku' => '$product.sku$', 'price' => 100, 'price_type' => 0 + ], + as:'link' + ), + DataFixture(BundleOptionFixture::class, ['title' => 'Checkbox Options', 'type' => 'checkbox', + 'required' => 1,'product_links' => ['$link$']], 'option'), + DataFixture( + BundleProductFixture::class, + ['price' => 90, '_options' => ['$option$']], + as:'bundleProduct' + ), + DataFixture(GuestCartFixture::class, as: 'cart'), + DataFixture( + AddBundleProductToCart::class, + [ + 'cart_id' => '$cart.id$', + 'product_id' => '$bundleProduct.id$', + 'selections' => [['$product.id$']], + 'qty' => 100 + ], + ), + DataFixture(QuoteMaskFixture::class, ['cart_id' => '$cart.id$'], 'quoteIdMask'), + DataFixture(ProductStockFixture::class, ['prod_id' => '$product.id$', 'prod_qty' => 90], 'prodStock') + ] + public function testStockStatusUnavailableBundleProduct(): void + { + $maskedQuoteId = $this->fixtures->get('quoteIdMask')->getMaskedId(); + $query = $this->getQuery($maskedQuoteId); + $response = $this->graphQlMutation($query); + $responseDataObject = new DataObject($response); + + self::assertFalse( + $responseDataObject->getData('cart/items/0/is_available') + ); + } + + #[ + DataFixture(ProductFixture::class, ['price' => 100.00], as: 'product'), + DataFixture( + BundleSelectionFixture::class, + [ + 'sku' => '$product.sku$', 'price' => 100, 'price_type' => 0 + ], + as:'link' + ), + DataFixture(BundleOptionFixture::class, ['title' => 'Checkbox Options', 'type' => 'checkbox', + 'required' => 1,'product_links' => ['$link$']], 'option'), + DataFixture( + BundleProductFixture::class, + ['sku' => self::PARENT_SKU_BUNDLE, 'price' => 90, '_options' => ['$option$']], + as:'bundleProduct' + ), + DataFixture(GuestCartFixture::class, as: 'cart'), + DataFixture( + AddBundleProductToCart::class, + [ + 'cart_id' => '$cart.id$', + 'product_id' => '$bundleProduct.id$', + 'selections' => [['$product.id$']], + 'qty' => 99 + ], + ), + DataFixture(QuoteMaskFixture::class, ['cart_id' => '$cart.id$'], 'quoteIdMask') + ] + public function testStockStatusAddBundleProduct(): void + { + $product = $this->productRepository->get(self::PARENT_SKU_BUNDLE); + + /** @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->fixtures->get('quoteIdMask')->getMaskedId(); + + $query = $this->mutationAddBundleProduct($maskedQuoteId, self::PARENT_SKU_BUNDLE, $bundleOptionIdV2); + $response = $this->graphQlMutation($query); + $responseDataObject = new DataObject($response); + self::assertTrue( + $responseDataObject->getData('addProductsToCart/cart/items/0/is_available') + ); + + $response = $this->graphQlMutation($query); + $responseDataObject = new DataObject($response); + + self::assertFalse( + $responseDataObject->getData('addProductsToCart/cart/items/0/is_available') + ); + } + + #[ + DataFixture(ProductFixture::class, as: 'product'), + DataFixture(AttributeFixture::class, as: 'attribute'), + DataFixture( + ConfigurableProductFixture::class, + ['_options' => ['$attribute$'], '_links' => ['$product$']], + 'configurable_product' + ), + DataFixture(GuestCartFixture::class, as: 'cart'), + DataFixture(QuoteMaskFixture::class, ['cart_id' => '$cart.id$'], 'quoteIdMask'), + DataFixture( + AddConfigurableProductToCartFixture::class, + [ + 'cart_id' => '$cart.id$', + 'product_id' => '$configurable_product.id$', + 'child_product_id' => '$product.id$', + 'qty' => 100 + ], + ), + DataFixture(ProductStockFixture::class, ['prod_id' => '$product.id$', 'prod_qty' => 90], 'prodStock') + ] + public function testStockStatusUnavailableConfigurableProduct(): void + { + $maskedQuoteId = $this->fixtures->get('quoteIdMask')->getMaskedId(); + $query = $this->getQuery($maskedQuoteId); + $response = $this->graphQlMutation($query); + $responseDataObject = new DataObject($response); + + self::assertFalse( + $responseDataObject->getData('cart/items/0/is_available') + ); + } + + #[ + DataFixture(ProductFixture::class, ['sku' => self::SKU], as: 'product'), + DataFixture(AttributeFixture::class, as: 'attribute'), + DataFixture( + ConfigurableProductFixture::class, + ['sku' => self::PARENT_SKU_CONFIGURABLE, '_options' => ['$attribute$'], '_links' => ['$product$']], + 'configurable_product' + ), + DataFixture(GuestCartFixture::class, as: 'cart'), + DataFixture(QuoteMaskFixture::class, ['cart_id' => '$cart.id$'], 'quoteIdMask'), + DataFixture( + AddConfigurableProductToCartFixture::class, + [ + 'cart_id' => '$cart.id$', + 'product_id' => '$configurable_product.id$', + 'child_product_id' => '$product.id$', + 'qty' => 100 + ], + ) + ] + public function testStockStatusAddConfigurableProduct(): void + { + $maskedQuoteId = $this->fixtures->get('quoteIdMask')->getMaskedId(); + $query = $this->mutationAddConfigurableProduct($maskedQuoteId, self::SKU, self::PARENT_SKU_CONFIGURABLE); + $response = $this->graphQlMutation($query); + $responseDataObject = new DataObject($response); + self::assertTrue( + $responseDataObject->getData('addProductsToCart/cart/items/1/is_available') + ); + $response = $this->graphQlMutation($query); + $responseDataObject = new DataObject($response); + self::assertFalse( + $responseDataObject->getData('addProductsToCart/cart/items/0/is_available') + ); + } + + /** + * @param string $cartId + * @return string + */ + private function getQuery(string $cartId): string + { + return <<