diff --git a/composer.json b/composer.json index 86c816b..9fd41ff 100644 --- a/composer.json +++ b/composer.json @@ -40,6 +40,7 @@ "scripts": { "docker-install-magento": [ "CURRENT_EXTENSION=\".\" FULL_INSTALL=1 COMPOSER_REQUIRE_EXTRA='tddwizard/magento2-fixtures' TWOFACTOR_ENABLED=1 UNIT_TESTS_PATH='dev/MagentoTests/Unit' INTEGRATION_TESTS_PATH='dev/MagentoTests/Integration' vendor/bin/mtest-make $TEST_GROUP", + "vendor/bin/mtest \"./vendor/ampersand/magento2-disable-stock-reservation/dev/MagentoTests/patches/apply.sh $TEST_GROUP\"", "vendor/bin/mtest 'php bin/magento setup:db-declaration:generate-whitelist --module-name=Ampersand_DisableStockReservation'" ], "docker-configure-magento": [ @@ -57,7 +58,7 @@ "vendor/bin/mtest 'vendor/bin/phpunit -c /var/www/html/dev/tests/unit/phpunit.xml.dist --testsuite Unit --debug'" ], "docker-run-integration-tests": [ - "vendor/bin/mtest 'vendor/bin/phpunit -c /var/www/html/dev/tests/integration/phpunit.xml.dist --testsuite Integration --debug'" + "vendor/bin/mtest 'vendor/bin/phpunit -c /var/www/html/dev/tests/integration/phpunit.xml.dist --testsuite Integration --debug '" ], "post-install-cmd": [ "([ $COMPOSER_DEV_MODE -eq 0 ] || vendor/bin/phpcs --config-set installed_paths ../../magento/magento-coding-standard/)" diff --git a/dev/MagentoTests/Integration/MultipleSourceInventoryTest.php b/dev/MagentoTests/Integration/MultipleSourceInventoryTest.php new file mode 100644 index 0000000..6e461ee --- /dev/null +++ b/dev/MagentoTests/Integration/MultipleSourceInventoryTest.php @@ -0,0 +1,392 @@ +objectManager = Bootstrap::getObjectManager(); + $this->searchCriteriaBuilder = $this->objectManager->get(SearchCriteriaBuilder::class); + $this->productRepository = $this->objectManager->get(ProductRepositoryInterface::class); + $this->getSourceItemsBySku = $this->objectManager->get(GetSourceItemsBySku::class); + $this->storeManager = $this->objectManager->get(StoreManagerInterface::class); + $this->getStockItemConfiguration = $this->objectManager->get(GetStockItemConfigurationInterface::class); + $this->saveStockItemConfiguration = $this->objectManager->get(SaveStockItemConfigurationInterface::class); + $this->storeRepository = $this->objectManager->get(StoreRepositoryInterface::class); + $this->cartItemFactory = $this->objectManager->get(CartItemInterfaceFactory::class); + $this->cartRepository = $this->objectManager->get(CartRepositoryInterface::class); + $this->cartManagement = $this->objectManager->get(CartManagementInterface::class); + $this->orderRepository = $this->objectManager->get(OrderRepositoryInterface::class); + } + + /** + * + * @dataProvider sourcesDataProvider + * + * @magentoDataFixture Magento_InventorySalesApi::Test/_files/websites_with_stores.php + * @magentoDataFixture Magento_InventoryApi::Test/_files/products.php + * @magentoDataFixture Magento_InventoryApi::Test/_files/sources.php + * @magentoDataFixture Magento_InventoryApi::Test/_files/stocks.php + * @magentoDataFixture Magento_InventoryApi::Test/_files/stock_source_links.php + * @magentoDataFixture Magento_InventoryApi::Test/_files/source_items.php + * @magentoDataFixture Magento_InventorySalesApi::Test/_files/stock_website_sales_channels.php + * @magentoDataFixture Magento_InventorySalesApi::Test/_files/quote.php + * @magentoDataFixture Magento_InventoryIndexer::Test/_files/reindex_inventory.php + * @magentoDbIsolation disabled + * @magentoAppIsolation enabled + * + * @throws LocalizedException + * @throws \Exception + */ + public function testPlaceOrderAndCancelWithMsi( + array $sourceData, + array $expectedSourceDataAfterPlace, + array $expectedSourceDataBeforePlace, + bool $expectException + ) { + $sku = $sourceData["sku"]; + $quoteItemQty = $sourceData["qty"]; + $stockId = $sourceData["stock_id"]; + + if ($expectException) { + $this->expectException(\Exception::class); + $this->expectExceptionMessage("The requested qty is not available"); + } + /* + * Additional magento and product configuration + */ + $this->setStockItemConfigIsDecimal($sku, $stockId); + $this->clearSalesChannelCache(); + + /* + * Verify the stock is as expected before any interactions + */ + $this->assertSourceStock( + $sku, + $expectedSourceDataBeforePlace, + 'Stock does not match what is expected before adding to basket' + ); + + /* + * Add to basket and assert the source data has not changed + */ + $cart = $this->getCart(); + /** @var Product $product */ + $product = $this->productRepository->get($sku); + + $cartItem = $this->getCartItem($product, $quoteItemQty, (int)$cart->getId()); + $cart->addItem($cartItem); + $this->cartRepository->save($cart); + + $this->assertEquals(1, $cart->getItemsCount(), "1 quote item should be added"); + $this->assertSourceStock( + $sku, + $expectedSourceDataBeforePlace, + 'Stock does not match what is expected before placing order' + ); + + /* + * Place the order and assert the source data has been reduced + */ + $orderId = $this->cartManagement->placeOrder($cart->getId()); + self::assertNotNull($orderId); + $this->assertSourceStock( + $sku, + $expectedSourceDataAfterPlace, + 'Stock does not match what is expected after placing order' + ); + + /* + * Cancel the order and assert the source data has been returned correctly + */ + $order = $this->orderRepository->get($orderId); + $order->cancel(); + $this->assertSourceStock( + $sku, + $expectedSourceDataBeforePlace, + 'Stock does not match what is after cancelling order' + ); + } + + /** + * @param string $sku + * @param int $stockId + */ + private function setStockItemConfigIsDecimal(string $sku, int $stockId): void + { + $stockItemConfiguration = $this->getStockItemConfiguration->execute($sku, $stockId); + $stockItemConfiguration->setIsQtyDecimal(true); + $this->saveStockItemConfiguration->execute($sku, $stockId, $stockItemConfiguration); + } + + /** + * Clear the GetStockBySalesChannelCache as it gets populated during fixture runtime and varies depending on the + * version of magento being tested. + * + * This way we can start our test with a clear cache after all the fixtures have run. + * + * @return void + * @throws \ReflectionException + */ + private function clearSalesChannelCache(): void + { + if (class_exists(GetStockBySalesChannelCache::class)) { + $getStockBySalesChannelCache = $this->objectManager->get(GetStockBySalesChannelCache::class); + $ref = new \ReflectionObject($getStockBySalesChannelCache); + try { + $refProperty = $ref->getProperty('channelCodes'); + } catch (\ReflectionException $exception) { + $refProperty = $ref->getParentClass()->getProperty('channelCodes'); + } + $refProperty->setAccessible(true); + $refProperty->setValue($getStockBySalesChannelCache, []); + } + + $stockId = $this->objectManager->get(StockResolverInterface::class) + ->execute(SalesChannelInterface::TYPE_WEBSITE, 'eu_website') + ->getStockId(); + $this->assertEquals(10, $stockId, 'The stock id for the eu_website should be 10'); + } + + /** + * @param ProductInterface $product + * @param float $quoteItemQty + * @param int $cartId + * @return CartItemInterface + */ + private function getCartItem(ProductInterface $product, float $quoteItemQty, int $cartId): CartItemInterface + { + /** @var CartItemInterface $cartItem */ + $cartItem = + $this->cartItemFactory->create( + [ + 'data' => [ + CartItemInterface::KEY_SKU => $product->getSku(), + CartItemInterface::KEY_QTY => $quoteItemQty, + CartItemInterface::KEY_QUOTE_ID => $cartId, + 'product_id' => $product->getId(), + 'product' => $product + ] + ] + ); + return $cartItem; + } + + /** + * @return CartInterface + * @throws NoSuchEntityException + */ + private function getCart(): CartInterface + { + // test_order_1 is set in vendor/magento/module-inventory-sales-api/Test/_files/quote.php + $searchCriteria = $this->searchCriteriaBuilder + ->addFilter('reserved_order_id', 'test_order_1') + ->setPageSize(1) + ->create(); + /** @var CartInterface $cart */ + $cart = current($this->cartRepository->getList($searchCriteria)->getItems()); + $storeCode = 'store_for_eu_website'; + + /** @var StoreInterface $store */ + $store = $this->storeRepository->get($storeCode); + $this->storeManager->setCurrentStore($store->getId()); + $cart->setStoreId($store->getId()); + return $cart; + } + + /** + * + * @param string $sku + * @param array $expected + * @param string $message + * @return void + */ + private function assertSourceStock(string $sku, array $expected, string $message = ''): void + { + $sources = $this->getSources($sku); + $this->assertEquals($expected, $sources, $message); + } + + /** + * Get source items by sku + * @param string $sku + * @return array + */ + private function getSources(string $sku): array + { + $sources = []; + $sourceItems = $this->getSourceItemsBySku->execute($sku); + foreach ($sourceItems as $sourceItem) { + $sources[$sourceItem->getSourceCode()] = $sourceItem->getQuantity(); + } + return $sources; + } + + /** + * @return array[] + */ + public function sourcesDataProvider(): array + { + return [ + 'purchase 8.5 from eu-1 and eu-2, then return on cancel' => [ + 'purchase_data' => [ + "sku" => "SKU-1", + "qty" => 8.5, + "stock_id" => 10 + ], + 'expected_source_data_after_place' => [ + "eu-1" => 0, + "eu-2" => 0, + "eu-3" => 10.0, + "eu-disabled" => 10.0, + ], + 'expected_source_data_before_place' => [ + "eu-1" => 5.5, + "eu-2" => 3.0, + "eu-3" => 10.0, + "eu-disabled" => 10.0, + ], + false + ], + 'purchase 2 from eu-1, then return on cancel' => [ + 'purchase_data' => [ + "sku" => "SKU-1", + "qty" => 2.0, + "stock_id" => 10 + ], + 'expected_source_data_after_place' => [ + "eu-1" => 3.5, + "eu-2" => 3, + "eu-3" => 10, + "eu-disabled" => 10, + ], + 'expected_source_data_before_place' => [ + "eu-1" => 5.5, + "eu-2" => 3.0, + "eu-3" => 10.0, + "eu-disabled" => 10.0, + ], + false + ], + 'purchase 18.5 from eu-1 and eu-2 and eu-3, then expect qty unavailable' => [ + 'purchase_data' => [ + "sku" => "SKU-1", + "qty" => 18.5, + "stock_id" => 10 + ], + 'expected_source_data_after_place' => [], + 'expected_source_data_before_place' => [ + "eu-1" => 5.5, + "eu-2" => 3.0, + "eu-3" => 10.0, + "eu-disabled" => 10.0, + ], + true + ], + 'Test cannot add out of stock and disabled source to cart' => [ + 'purchase_data' => [ + "sku" => "SKU-1", + "qty" => 25, + "stock_id" => 10 + ], + 'expected_source_data_after_place' => [], + 'expected_source_data_before_place' => [ + "eu-1" => 5.5, + "eu-2" => 3.0, + "eu-3" => 10.0, + "eu-disabled" => 10.0, + ], + true + ] + ]; + } +} diff --git a/dev/MagentoTests/patches/2-4-4/issue-35262/README.md b/dev/MagentoTests/patches/2-4-4/issue-35262/README.md new file mode 100644 index 0000000..ee9e0fe --- /dev/null +++ b/dev/MagentoTests/patches/2-4-4/issue-35262/README.md @@ -0,0 +1,11 @@ +https://github.com/magento/magento2/issues/35262 + +``` +Error in fixture: "\/var\/www\/html\/vendor\/magento\/module-inventory-api\/Test\/_files\/source_items.php". + SQLSTATE[42S02]: Base table or view not found: 1146 Table 'magento_integration_tests.trv_reservations_temp_for_stock_10' doesn't exist, query was: SELECT `source_item`.`sku`, SUM(IF(source_item.status = 0, 0, source_item.quantity)) AS `quantity`, IF((reservations.reservation_qty IS NULL OR (SUM(source_item.quantity) + reservations.reservation_qty) > 0) AND (((legacy_stock_item.use_config_backorders = 0 AND legacy_stock_item.backorders <> 0) AND (legacy_stock_item.min_qty >= 0 OR legacy_stock_item.qty > legacy_stock_item.min_qty) AND SUM(IF(source_item.status = 0, 0, 1))) OR ((legacy_stock_item.use_config_manage_stock = 0 AND legacy_stock_item.manage_stock = 0)) OR ((legacy_stock_item.use_config_min_qty = 1 AND SUM(IF(source_item.status = 0, 0, source_item.quantity)) > 0) OR (legacy_stock_item.use_config_min_qty = 0 AND SUM(IF(source_item.status = 0, 0, source_item.quantity)) > legacy_stock_item.min_qty)) OR (product.sku IS NULL)), 1, 0) AS `is_salable` FROM `trv_inventory_source_item` AS `source_item` + LEFT JOIN `trv_catalog_product_entity` AS `product` ON product.sku = source_item.sku + LEFT JOIN `trv_cataloginventory_stock_item` AS `legacy_stock_item` ON product.entity_id = legacy_stock_item.product_id + LEFT JOIN `trv_reservations_temp_for_stock_10` AS `reservations` ON source_item.sku = reservations.sku WHERE (source_item.source_code IN ('eu-1', 'eu-2', 'eu-3')) AND (source_item.sku IN ('SKU-1', 'SKU-6', 'SKU-3', 'SKU-4')) GROUP BY `source_item`.`sku` +``` + +Fixed in https://github.com/magento/inventory/commit/d236405c22d3005c642c70348cfd372dbcb98b76 \ No newline at end of file diff --git a/dev/MagentoTests/patches/2-4-4/issue-35262/inventory-reindex-with-table-prefix.patch b/dev/MagentoTests/patches/2-4-4/issue-35262/inventory-reindex-with-table-prefix.patch new file mode 100644 index 0000000..d9a8837 --- /dev/null +++ b/dev/MagentoTests/patches/2-4-4/issue-35262/inventory-reindex-with-table-prefix.patch @@ -0,0 +1,13 @@ +diff --git a/vendor/magento/module-inventory-indexer/Indexer/Stock/ReservationsIndexTable.php b/vendor/magento/module-inventory-indexer/Indexer/Stock/ReservationsIndexTable.php +index e18562828b5..013bac3c7a5 100644 +--- a/vendor/magento/module-inventory-indexer/Indexer/Stock/ReservationsIndexTable.php ++++ b/vendor/magento/module-inventory-indexer/Indexer/Stock/ReservationsIndexTable.php +@@ -77,7 +77,7 @@ public function createTable(int $stockId): void + */ + public function getTableName(int $stockId): string + { +- return 'reservations_temp_for_stock_' . $stockId; ++ return $this->resourceConnection->getTableName('reservations_temp_for_stock_' . $stockId); + } + + /** diff --git a/dev/MagentoTests/patches/apply.sh b/dev/MagentoTests/patches/apply.sh new file mode 100755 index 0000000..971d6c5 --- /dev/null +++ b/dev/MagentoTests/patches/apply.sh @@ -0,0 +1,17 @@ +#!/bin/bash +set -euo pipefail +DIR_BASE="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + +TEST_GROUP=$1 +echo "Test group is $TEST_GROUP" +PATCH_DIR="$DIR_BASE/$TEST_GROUP" +echo "Looking for patches in $PATCH_DIR" + +if ! test -d $PATCH_DIR; then + exit 0 +fi + +for i in `find $PATCH_DIR -name '*.patch'`; do + echo "Applying $i" + patch -p1 < $i +done diff --git a/src/Api/SourcesRepositoryInterface.php b/src/Api/SourcesRepositoryInterface.php index 35f4064..a773d56 100644 --- a/src/Api/SourcesRepositoryInterface.php +++ b/src/Api/SourcesRepositoryInterface.php @@ -28,8 +28,8 @@ public function save(SourcesInterface $model): SourcesInterface; * @param string $orderId * @param string $itemSku * - * @return SourceSelectionItem + * @return SourceSelectionItem [] * @throws NoSuchEntityException */ - public function getSourceItemBySku(string $orderId, string $itemSku): SourceSelectionItem; + public function getSourceItemBySku(string $orderId, string $itemSku): array; } diff --git a/src/Model/SourcesRepository.php b/src/Model/SourcesRepository.php index 1c09d52..dd2d462 100644 --- a/src/Model/SourcesRepository.php +++ b/src/Model/SourcesRepository.php @@ -79,20 +79,23 @@ public function getByOrderId(string $orderId): SourcesInterface * @param string $orderId * @param string $itemSku * - * @return SourceSelectionItem + * @return SourceSelectionItem [] * @throws NoSuchEntityException */ - public function getSourceItemBySku(string $orderId, string $itemSku): SourceSelectionItem + public function getSourceItemBySku(string $orderId, string $itemSku): array { $sourceSelectionItems = $this->sourcesConverter->convertSourcesJsonToSourceSelectionItems( $this->getByOrderId($orderId)->getSources() ); - if (!array_key_exists($itemSku, $sourceSelectionItems)) { - throw new NoSuchEntityException(__('Source model with the sku "%1" does not exist', $itemSku)); + $items = []; + foreach ($sourceSelectionItems as $sourceSelectionItem) { + if ($sourceSelectionItem->getSku() === $itemSku) { + $items[] = $sourceSelectionItem; + } } - return $sourceSelectionItems[$itemSku]; + return $items; } /** diff --git a/src/Service/ExecuteSourceDeductionForItems.php b/src/Service/ExecuteSourceDeductionForItems.php index 051b87d..dec4d8f 100644 --- a/src/Service/ExecuteSourceDeductionForItems.php +++ b/src/Service/ExecuteSourceDeductionForItems.php @@ -3,6 +3,7 @@ namespace Ampersand\DisableStockReservation\Service; use Magento\Framework\Exception\NoSuchEntityException; +use Magento\InventoryApi\Model\GetSourceCodesBySkusInterface; use Magento\InventorySalesApi\Api\Data\SalesChannelInterface; use Magento\InventorySalesApi\Api\Data\SalesEventInterface; use Magento\Sales\Api\Data\OrderInterface; @@ -68,6 +69,11 @@ class ExecuteSourceDeductionForItems */ protected $product; + /** + * @var GetSourceCodesBySkusInterface + */ + private $getSourceCodesBySkus; + /** * ExecuteSourceDeductionForItems constructor. * @param WebsiteRepositoryInterface $websiteRepository @@ -89,7 +95,8 @@ public function __construct( SourceDeductionServiceInterface $sourceDeductionService, SourcesRepositoryInterface $sourceRepository, Processor $priceIndexer, - Product $product + Product $product, + GetSourceCodesBySkusInterface $getSourceCodesBySkus ) { $this->websiteRepository = $websiteRepository; $this->salesEventFactory = $salesEventFactory; @@ -100,11 +107,13 @@ public function __construct( $this->sourceRepository = $sourceRepository; $this->priceIndexer = $priceIndexer; $this->product = $product; + $this->getSourceCodesBySkus = $getSourceCodesBySkus; } /** * @param OrderItem $orderItem * @param array $itemsToCancel + * @throws NoSuchEntityException */ public function executeSourceDeductionForItems(OrderItem $orderItem, array $itemsToCancel) { @@ -130,28 +139,32 @@ public function executeSourceDeductionForItems(OrderItem $orderItem, array $item /** @var OrderItem $item */ foreach ($itemsToCancel as $item) { $itemsSkus[] = $item->getSku(); - - try { - $sourceItem = $this->sourceRepository->getSourceItemBySku( - (string)$order->getId(), - $item->getSku() - ); + $sourceCodesBySku = $this->getSourceCodesBySkus->execute([$item->getSku()]); + $sourceItems = $this->sourceRepository->getSourceItemBySku( + (string)$order->getId(), + $item->getSku() + ); + foreach ($sourceItems as $sourceItem) { $sourceCode = $sourceItem->getSourceCode(); - } catch (NoSuchEntityException $exception) { - $sourceCode = 'default'; - } - $sourceDeductionRequest = $this->sourceDeductionRequestFactory->create([ - 'sourceCode' => $sourceCode, - 'items' => [$this->itemToDeductFactory->create([ - 'sku' => $item->getSku(), - 'qty' => -$item->getQuantity() - ])], - 'salesChannel' => $salesChannel, - 'salesEvent' => $salesEvent - ]); - - $this->sourceDeductionService->execute($sourceDeductionRequest); + // if source has been unassigned, return to default stock + if (!in_array($sourceCode, $sourceCodesBySku)) { + $sourceCode = 'default'; + } + $sourceDeductionRequest = $this->sourceDeductionRequestFactory->create([ + 'sourceCode' => $sourceCode, + 'items' => [ + $this->itemToDeductFactory->create([ + 'sku' => $item->getSku(), + 'qty' => -$sourceItem->getQtyToDeduct() + ]) + ], + 'salesChannel' => $salesChannel, + 'salesEvent' => $salesEvent + ]); + + $this->sourceDeductionService->execute($sourceDeductionRequest); + } } $itemsIds = $this->product->getProductsIdsBySkus($itemsSkus); diff --git a/src/Service/SourcesConverter.php b/src/Service/SourcesConverter.php index b4a8db4..97d90ef 100644 --- a/src/Service/SourcesConverter.php +++ b/src/Service/SourcesConverter.php @@ -72,7 +72,7 @@ public function convertSourcesJsonToSourceSelectionItems(string $sources): array ] ); - $sourceSelectionItems[$item['sku']] = $sourceSelectionItem; + $sourceSelectionItems[] = $sourceSelectionItem; } return $sourceSelectionItems;