diff --git a/app/code/Magento/Bundle/Model/Product/OriginalPrice.php b/app/code/Magento/Bundle/Model/Product/OriginalPrice.php new file mode 100644 index 0000000000000..ca3a616f3ee06 --- /dev/null +++ b/app/code/Magento/Bundle/Model/Product/OriginalPrice.php @@ -0,0 +1,133 @@ +hasCustomOptions()) { + return $price; + } + + $selectionIds = $this->getBundleSelectionIds($product); + + if (empty($selectionIds)) { + return $price; + } + + $selections = $product->getTypeInstance()->getSelectionsByIds($selectionIds, $product); + foreach ($selections->getItems() as $selection) { + if (!$selection->isSalable()) { + continue; + } + + $selectionQty = $product->getCustomOption('selection_qty_' . $selection->getSelectionId()); + if ($selectionQty) { + $price += $this->getSelectionOriginalTotalPrice( + $product, + $selection, + (float) $selectionQty->getValue() + ); + } + } + + return $price; + } + + /** + * Calculate total original price of selection + * + * @param Product $bundleProduct + * @param Product $selectionProduct + * @param float $selectionQty + * + * @return float + */ + private function getSelectionOriginalTotalPrice( + Product $bundleProduct, + Product $selectionProduct, + float $selectionQty + ): float { + $price = $this->getSelectionOriginalPrice($bundleProduct, $selectionProduct); + + return $price * $selectionQty; + } + + /** + * Calculate the original price of selection + * + * @param Product $bundleProduct + * @param Product $selectionProduct + * + * @return float + */ + public function getSelectionOriginalPrice(Product $bundleProduct, Product $selectionProduct): float + { + if ($bundleProduct->getPriceType() == Price::PRICE_TYPE_DYNAMIC) { + return (float) $selectionProduct->getPrice(); + } + if ($selectionProduct->getSelectionPriceType()) { + // percent + return $bundleProduct->getPrice() * ($selectionProduct->getSelectionPriceValue() / 100); + } + + // fixed + return (float) $selectionProduct->getSelectionPriceValue(); + } + + /** + * Retrieve array of bundle selection IDs + * + * @param Product $product + * @return array + */ + private function getBundleSelectionIds(Product $product): array + { + $customOption = $product->getCustomOption('bundle_selection_ids'); + if ($customOption) { + $selectionIds = $this->serializer->unserialize($customOption->getValue()); + if (is_array($selectionIds)) { + return $selectionIds; + } + } + return []; + } +} diff --git a/app/code/Magento/Bundle/Plugin/Quote/UpdateBundleQuoteItemBaseOriginalPrice.php b/app/code/Magento/Bundle/Plugin/Quote/UpdateBundleQuoteItemBaseOriginalPrice.php new file mode 100644 index 0000000000000..89b729d2a645a --- /dev/null +++ b/app/code/Magento/Bundle/Plugin/Quote/UpdateBundleQuoteItemBaseOriginalPrice.php @@ -0,0 +1,69 @@ +getAllVisibleItems() as $quoteItem) { + if ($quoteItem->getProductType() === Type::TYPE_CODE) { + $price = $quoteItem->getProduct()->getPrice(); + $price += $this->price->getTotalBundleItemsOriginalPrice($quoteItem->getProduct()); + $quoteItem->setBaseOriginalPrice($price); + } + } + return $result; + } +} diff --git a/app/code/Magento/Bundle/etc/di.xml b/app/code/Magento/Bundle/etc/di.xml index 3ddefc1a05596..210b0e091b898 100644 --- a/app/code/Magento/Bundle/etc/di.xml +++ b/app/code/Magento/Bundle/etc/di.xml @@ -284,4 +284,9 @@ + + + diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Bundle/BundleProductCartPricesTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Bundle/BundleProductCartPricesTest.php new file mode 100644 index 0000000000000..a00031f4b6335 --- /dev/null +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Bundle/BundleProductCartPricesTest.php @@ -0,0 +1,704 @@ +quoteIdToMaskedQuoteIdInterface = $objectManager->get(QuoteIdToMaskedQuoteIdInterface::class); + $this->fixtures = $objectManager->get(DataFixtureStorageManager::class)->getStorage(); + } + + #[ + DataFixture(ProductFixture::class, ['price' => 20], 'product1'), + DataFixture(ProductFixture::class, ['price' => 10], 'product2'), + DataFixture(BundleSelectionFixture::class, ['sku' => '$product1.sku$'], 'selection1'), + DataFixture(BundleSelectionFixture::class, ['sku' => '$product2.sku$'], 'selection2'), + DataFixture(BundleOptionFixture::class, ['product_links' => ['$selection1$']], 'opt1'), + DataFixture(BundleOptionFixture::class, ['product_links' => ['$selection2$']], 'opt2'), + DataFixture( + BundleProductFixture::class, + [ + 'sku' => 'bundle-product-fixed-price', + 'price' => 15, + 'price_type' => Price::PRICE_TYPE_FIXED, + '_options' => ['$opt1$', '$opt2$'] + ], + 'bundle_product_1' + ), + DataFixture( + BundleProductFixture::class, + [ + 'sku' => 'bundle-product-fixed-price-special-price', + 'price' => 15, + 'price_type' => Price::PRICE_TYPE_FIXED, + '_options' => ['$opt1$', '$opt2$'], + 'special_price' => 90 // it is the 90% of the original price + ], + 'bundle_product_2' + ), + DataFixture(GuestCartFixture::class, as: 'cart'), + DataFixture( + AddBundleProductToCart::class, + [ + 'cart_id' => '$cart.id$', + 'product_id' => '$bundle_product_1.id$', + 'selections' => [['$product1.id$'], ['$product2.id$']], + 'qty' => 2 + ] + ), + DataFixture( + AddBundleProductToCart::class, + [ + 'cart_id' => '$cart.id$', + 'product_id' => '$bundle_product_2.id$', + 'selections' => [['$product1.id$'], ['$product2.id$']], + 'qty' => 2 + ] + ) + ] + public function testBundleProductFixedPriceWithOptionsWithoutPrices() + { + $cart = $this->fixtures->get('cart'); + $maskedQuoteId = $this->quoteIdToMaskedQuoteIdInterface->execute((int) $cart->getId()); + $query = $this->getCartQuery($maskedQuoteId); + $response = $this->graphQlQuery($query); + + // price is the bundle product price as in this case the options don't have prices + // specialPrice is the bundle product price * bundle product special price % + $expectedResponse = $this->getExpectedResponse(15, 30, 30, 13.5, 27); + + $this->assertEquals($expectedResponse, $response); + } + + #[ + DataFixture(ProductFixture::class, ['price' => 20], 'product1'), + DataFixture(ProductFixture::class, ['price' => 10], 'product2'), + DataFixture(BundleSelectionFixture::class, ['sku' => '$product1.sku$'], 'selection1'), + DataFixture( + BundleSelectionFixture::class, + [ + 'sku' => '$product2.sku$', + 'price' => 10, + 'price_type' => LinkInterface::PRICE_TYPE_FIXED + ], + 'selection2' + ), + DataFixture(BundleOptionFixture::class, ['product_links' => ['$selection1$']], 'opt1'), + DataFixture(BundleOptionFixture::class, ['product_links' => ['$selection2$']], 'opt2'), + DataFixture( + BundleProductFixture::class, + [ + 'sku' => 'bundle-product-fixed-price', + 'price' => 15, + 'price_type' => Price::PRICE_TYPE_FIXED, + '_options' => ['$opt1$', '$opt2$'], + ], + 'bundle_product_1' + ), + DataFixture( + BundleProductFixture::class, + [ + 'sku' => 'bundle-product-fixed-price-special-price', + 'price' => 15, + 'price_type' => Price::PRICE_TYPE_FIXED, + '_options' => ['$opt1$', '$opt2$'], + 'special_price' => 90 // it is the 90% of the original price + ], + 'bundle_product_2' + ), + DataFixture(GuestCartFixture::class, as: 'cart'), + DataFixture( + AddBundleProductToCart::class, + [ + 'cart_id' => '$cart.id$', + 'product_id' => '$bundle_product_1.id$', + 'selections' => [['$product1.id$'], ['$product2.id$']], + 'qty' => 2 + ] + ), + DataFixture( + AddBundleProductToCart::class, + [ + 'cart_id' => '$cart.id$', + 'product_id' => '$bundle_product_2.id$', + 'selections' => [['$product1.id$'], ['$product2.id$']], + 'qty' => 2 + ] + ) + ] + public function testBundleProductFixedPriceWithOneOptionFixedPrice() + { + $cart = $this->fixtures->get('cart'); + $maskedQuoteId = $this->quoteIdToMaskedQuoteIdInterface->execute((int) $cart->getId()); + $query = $this->getCartQuery($maskedQuoteId); + $response = $this->graphQlQuery($query); + + // price is the bundle product price + option fixed price + // specialPrice is the bundle product price + option fixed price * bundle product special price % + $expectedResponse = $this->getExpectedResponse(25, 50, 50, 22.5, 45); + + $this->assertEquals($expectedResponse, $response); + } + + #[ + DataFixture(ProductFixture::class, ['price' => 20], 'product1'), + DataFixture(ProductFixture::class, ['price' => 10], 'product2'), + DataFixture( + BundleSelectionFixture::class, + [ + 'sku' => '$product1.sku$', + 'price' => 20, + 'price_type' => LinkInterface::PRICE_TYPE_FIXED + ], + 'selection1' + ), + DataFixture( + BundleSelectionFixture::class, + [ + 'sku' => '$product2.sku$', + 'price' => 10, + 'price_type' => LinkInterface::PRICE_TYPE_FIXED + ], + 'selection2' + ), + DataFixture(BundleOptionFixture::class, ['product_links' => ['$selection1$']], 'opt1'), + DataFixture(BundleOptionFixture::class, ['product_links' => ['$selection2$']], 'opt2'), + DataFixture( + BundleProductFixture::class, + [ + 'sku' => 'bundle-product-fixed-price', + 'price' => 15, + 'price_type' => Price::PRICE_TYPE_FIXED, + '_options' => ['$opt1$', '$opt2$'], + ], + 'bundle_product_1' + ), + DataFixture( + BundleProductFixture::class, + [ + 'sku' => 'bundle-product-fixed-price-special-price', + 'price' => 15, + 'price_type' => Price::PRICE_TYPE_FIXED, + '_options' => ['$opt1$', '$opt2$'], + 'special_price' => 90 // it is the 90% of the original price + ], + 'bundle_product_2' + ), + DataFixture(GuestCartFixture::class, as: 'cart'), + DataFixture( + AddBundleProductToCart::class, + [ + 'cart_id' => '$cart.id$', + 'product_id' => '$bundle_product_1.id$', + 'selections' => [['$product1.id$'], ['$product2.id$']], + 'qty' => 2 + ] + ), + DataFixture( + AddBundleProductToCart::class, + [ + 'cart_id' => '$cart.id$', + 'product_id' => '$bundle_product_2.id$', + 'selections' => [['$product1.id$'], ['$product2.id$']], + 'qty' => 2 + ] + ) + ] + public function testBundleProductFixedPriceWithBothOptionsFixedPrice() + { + $cart = $this->fixtures->get('cart'); + $maskedQuoteId = $this->quoteIdToMaskedQuoteIdInterface->execute((int) $cart->getId()); + $query = $this->getCartQuery($maskedQuoteId); + $response = $this->graphQlQuery($query); + + // price is the bundle product price + options fixed prices + // specialPrice is the bundle product price + options fixed prices * bundle product special price % + $expectedResponse = $this->getExpectedResponse(45, 90, 90, 40.50, 81); + + $this->assertEquals($expectedResponse, $response); + } + + #[ + DataFixture(ProductFixture::class, ['price' => 20], 'product1'), + DataFixture(ProductFixture::class, ['price' => 10], 'product2'), + DataFixture(BundleSelectionFixture::class, ['sku' => '$product1.sku$'], 'selection1'), + DataFixture( + BundleSelectionFixture::class, + [ + 'sku' => '$product2.sku$', + 'price' => 20, + 'price_type' => LinkInterface::PRICE_TYPE_PERCENT + ], + 'selection2' + ), + DataFixture(BundleOptionFixture::class, ['product_links' => ['$selection1$']], 'opt1'), + DataFixture(BundleOptionFixture::class, ['product_links' => ['$selection2$']], 'opt2'), + DataFixture( + BundleProductFixture::class, + [ + 'sku' => 'bundle-product-fixed-price', + 'price' => 15, + 'price_type' => Price::PRICE_TYPE_FIXED, + '_options' => ['$opt1$', '$opt2$'], + ], + 'bundle_product_1' + ), + DataFixture( + BundleProductFixture::class, + [ + 'sku' => 'bundle-product-fixed-price-special-price', + 'price' => 15, + 'price_type' => Price::PRICE_TYPE_FIXED, + '_options' => ['$opt1$', '$opt2$'], + 'special_price' => 90 // it is the 90% of the original price + ], + 'bundle_product_2' + ), + DataFixture(GuestCartFixture::class, as: 'cart'), + DataFixture( + AddBundleProductToCart::class, + [ + 'cart_id' => '$cart.id$', + 'product_id' => '$bundle_product_1.id$', + 'selections' => [['$product1.id$'], ['$product2.id$']], + 'qty' => 2 + ] + ), + DataFixture( + AddBundleProductToCart::class, + [ + 'cart_id' => '$cart.id$', + 'product_id' => '$bundle_product_2.id$', + 'selections' => [['$product1.id$'], ['$product2.id$']], + 'qty' => 2 + ] + ) + ] + public function testBundleProductFixedPriceWithOneOptionPercentPrice() + { + $cart = $this->fixtures->get('cart'); + $maskedQuoteId = $this->quoteIdToMaskedQuoteIdInterface->execute((int) $cart->getId()); + $query = $this->getCartQuery($maskedQuoteId); + $response = $this->graphQlQuery($query); + + // price is the (bundle product price * option percent price) + bundle product price + // specialPrice is the (bundle product price * option percent price) + + // bundle product price * bundle product special price % + $expectedResponse = $this->getExpectedResponse(18, 36, 36, 16.20, 32.40); + + $this->assertEquals($expectedResponse, $response); + } + + #[ + DataFixture(ProductFixture::class, ['price' => 20], 'product1'), + DataFixture(ProductFixture::class, ['price' => 10], 'product2'), + DataFixture( + BundleSelectionFixture::class, + [ + 'sku' => '$product1.sku$', + 'price' => 10, + 'price_type' => LinkInterface::PRICE_TYPE_PERCENT + ], + 'selection1' + ), + DataFixture( + BundleSelectionFixture::class, + [ + 'sku' => '$product2.sku$', + 'price' => 20, + 'price_type' => LinkInterface::PRICE_TYPE_PERCENT + ], + 'selection2' + ), + DataFixture(BundleOptionFixture::class, ['product_links' => ['$selection1$']], 'opt1'), + DataFixture(BundleOptionFixture::class, ['product_links' => ['$selection2$']], 'opt2'), + DataFixture( + BundleProductFixture::class, + [ + 'sku' => 'bundle-product-fixed-price', + 'price' => 15, + 'price_type' => Price::PRICE_TYPE_FIXED, + '_options' => ['$opt1$', '$opt2$'], + ], + 'bundle_product_1' + ), + DataFixture( + BundleProductFixture::class, + [ + 'sku' => 'bundle-product-fixed-price-special-price', + 'price' => 15, + 'price_type' => Price::PRICE_TYPE_FIXED, + '_options' => ['$opt1$', '$opt2$'], + 'special_price' => 90 // it is the 90% of the original price + ], + 'bundle_product_2' + ), + DataFixture(GuestCartFixture::class, as: 'cart'), + DataFixture( + AddBundleProductToCart::class, + [ + 'cart_id' => '$cart.id$', + 'product_id' => '$bundle_product_1.id$', + 'selections' => [['$product1.id$'], ['$product2.id$']], + 'qty' => 2 + ] + ), + DataFixture( + AddBundleProductToCart::class, + [ + 'cart_id' => '$cart.id$', + 'product_id' => '$bundle_product_2.id$', + 'selections' => [['$product1.id$'], ['$product2.id$']], + 'qty' => 2 + ] + ) + ] + public function testBundleProductFixedPriceWithBothOptionsPercentPrices() + { + $cart = $this->fixtures->get('cart'); + $maskedQuoteId = $this->quoteIdToMaskedQuoteIdInterface->execute((int) $cart->getId()); + $query = $this->getCartQuery($maskedQuoteId); + $response = $this->graphQlQuery($query); + + // price is the (bundle product price * options percent price) + bundle product price + // specialPrice is the (bundle product price * options percent price) + + // bundle product price * bundle product special price % + $expectedResponse = $this->getExpectedResponse(19.5, 39, 39, 17.55, 35.10); + + $this->assertEquals($expectedResponse, $response); + } + + #[ + DataFixture(ProductFixture::class, ['price' => 20], 'product1'), + DataFixture(ProductFixture::class, ['price' => 10], 'product2'), + DataFixture( + BundleSelectionFixture::class, + [ + 'sku' => '$product1.sku$', + 'price' => 10, + 'price_type' => LinkInterface::PRICE_TYPE_FIXED + ], + 'selection1' + ), + DataFixture( + BundleSelectionFixture::class, + [ + 'sku' => '$product2.sku$', + 'price' => 20, + 'price_type' => LinkInterface::PRICE_TYPE_PERCENT + ], + 'selection2' + ), + DataFixture(BundleOptionFixture::class, ['product_links' => ['$selection1$']], 'opt1'), + DataFixture(BundleOptionFixture::class, ['product_links' => ['$selection2$']], 'opt2'), + DataFixture( + BundleProductFixture::class, + [ + 'sku' => 'bundle-product-fixed-price', + 'price' => 15, + 'price_type' => Price::PRICE_TYPE_FIXED, + '_options' => ['$opt1$', '$opt2$'], + ], + 'bundle_product_1' + ), + DataFixture( + BundleProductFixture::class, + [ + 'sku' => 'bundle-product-fixed-price-special-price', + 'price' => 15, + 'price_type' => Price::PRICE_TYPE_FIXED, + '_options' => ['$opt1$', '$opt2$'], + 'special_price' => 90 // it is the 90% of the original price + ], + 'bundle_product_2' + ), + DataFixture(GuestCartFixture::class, as: 'cart'), + DataFixture( + AddBundleProductToCart::class, + [ + 'cart_id' => '$cart.id$', + 'product_id' => '$bundle_product_1.id$', + 'selections' => [['$product1.id$'], ['$product2.id$']], + 'qty' => 2 + ] + ), + DataFixture( + AddBundleProductToCart::class, + [ + 'cart_id' => '$cart.id$', + 'product_id' => '$bundle_product_2.id$', + 'selections' => [['$product1.id$'], ['$product2.id$']], + 'qty' => 2 + ] + ) + ] + public function testBundleProductFixedPriceWithOneOptionFixedAndOnePercentPrice() + { + $cart = $this->fixtures->get('cart'); + $maskedQuoteId = $this->quoteIdToMaskedQuoteIdInterface->execute((int) $cart->getId()); + $query = $this->getCartQuery($maskedQuoteId); + $response = $this->graphQlQuery($query); + + // price is the (bundle product price * option percent price) + bundle product price + option fixed price + // specialPrice is the (bundle product price * option percent price) + bundle product price + + // option fixed price * bundle product special price % + $expectedResponse = $this->getExpectedResponse(28, 56, 56, 25.20, 50.40); + + $this->assertEquals($expectedResponse, $response); + } + + #[ + DataFixture(ProductFixture::class, ['price' => 20], 'product1'), + DataFixture(ProductFixture::class, ['price' => 10], 'product2'), + DataFixture(BundleSelectionFixture::class, ['sku' => '$product1.sku$'], 'selection1'), + DataFixture(BundleSelectionFixture::class, ['sku' => '$product2.sku$'], 'selection2'), + DataFixture(BundleOptionFixture::class, ['product_links' => ['$selection1$']], 'opt1'), + DataFixture(BundleOptionFixture::class, ['product_links' => ['$selection2$']], 'opt2'), + DataFixture( + BundleProductFixture::class, + [ + 'sku' => 'bundle-product-dynamic-price', + '_options' => ['$opt1$', '$opt2$'], + ], + 'bundle_product_1' + ), + DataFixture(GuestCartFixture::class, as: 'cart'), + DataFixture( + AddBundleProductToCart::class, + [ + 'cart_id' => '$cart.id$', + 'product_id' => '$bundle_product_1.id$', + 'selections' => [['$product1.id$'], ['$product2.id$']], + 'qty' => 2 + ] + ) + ] + public function testBundleProductDynamicPriceWithoutSpecialPrice() + { + $cart = $this->fixtures->get('cart'); + $maskedQuoteId = $this->quoteIdToMaskedQuoteIdInterface->execute((int) $cart->getId()); + $query = $this->getCartQuery($maskedQuoteId); + $response = $this->graphQlQuery($query); + + $expectedResponse = [ + "cart" => [ + "items" => [ + 0 => [ + "prices" => [ + "price" => [ + "value" => 30, + "currency" => "USD" + ], + "row_total" => [ + "value" => 60, + "currency" => "USD" + ], + "original_row_total" => [ + "value" => 60, + "currency" => "USD" + ] + ] + ] + ] + ] + ]; + + $this->assertEquals($expectedResponse, $response); + } + + #[ + DataFixture(ProductFixture::class, ['price' => 20, 'special_price' => 15], 'product1'), + DataFixture(ProductFixture::class, ['price' => 10], 'product2'), + DataFixture(BundleSelectionFixture::class, ['sku' => '$product1.sku$'], 'selection1'), + DataFixture(BundleSelectionFixture::class, ['sku' => '$product2.sku$'], 'selection2'), + DataFixture(BundleOptionFixture::class, ['product_links' => ['$selection1$']], 'opt1'), + DataFixture(BundleOptionFixture::class, ['product_links' => ['$selection2$']], 'opt2'), + DataFixture( + BundleProductFixture::class, + [ + 'sku' => 'bundle-product-dynamic-price', + '_options' => ['$opt1$', '$opt2$'], + ], + 'bundle_product_1' + ), + DataFixture(GuestCartFixture::class, as: 'cart'), + DataFixture( + AddBundleProductToCart::class, + [ + 'cart_id' => '$cart.id$', + 'product_id' => '$bundle_product_1.id$', + 'selections' => [['$product1.id$'], ['$product2.id$']], + 'qty' => 2 + ] + ) + ] + public function testBundleProductDynamicPriceWithSpecialPrice() + { + $cart = $this->fixtures->get('cart'); + $maskedQuoteId = $this->quoteIdToMaskedQuoteIdInterface->execute((int) $cart->getId()); + $query = $this->getCartQuery($maskedQuoteId); + $response = $this->graphQlQuery($query); + + $expectedResponse = [ + "cart" => [ + "items" => [ + 0 => [ + "prices" => [ + "price" => [ + "value" => 25, + "currency" => "USD" + ], + "row_total" => [ + "value" => 50, + "currency" => "USD" + ], + "original_row_total" => [ + "value" => 60, + "currency" => "USD" + ] + ] + ] + ] + ] + ]; + + $this->assertEquals($expectedResponse, $response); + } + + /** + * Generates GraphQl query for get cart prices + * + * @param string $maskedQuoteId + * @return string + */ + private function getCartQuery(string $maskedQuoteId): string + { + return << [ + "items" => [ + 0 => [ + "prices" => [ + "price" => [ + "value" => $price, + "currency" => "USD" + ], + "row_total" => [ + "value" => $rowTotal, + "currency" => "USD" + ], + "original_row_total" => [ + "value" => $originalRowTotal, + "currency" => "USD" + ] + ] + ], + 1 => [ + "prices" => [ + "price" => [ + "value" => $specialPrice, + "currency" => "USD" + ], + "row_total" => [ + "value" => $specialRowTotal, + "currency" => "USD" + ], + "original_row_total" => [ + "value" => $originalRowTotal, + "currency" => "USD" + ] + ] + ] + ] + ] + ]; + } +}