diff --git a/InventoryDataExporter/Model/Provider/StockStatus.php b/InventoryDataExporter/Model/Provider/StockStatus.php index fb9917cf..ad406776 100644 --- a/InventoryDataExporter/Model/Provider/StockStatus.php +++ b/InventoryDataExporter/Model/Provider/StockStatus.php @@ -80,18 +80,16 @@ public function get(array $values): array $output = []; try { - $processedSkus = []; $select = $this->query->getQuery($skus); // $select can be null if no stocks exists except default if ($select) { $cursor = $connection->query($select); while ($row = $cursor->fetch()) { - $processedSkus[] = $row['sku']; $output[] = $this->fillWithDefaultValues($row); } } - $select = $this->query->getQueryForDefaultStock(\array_diff($skus, $processedSkus)); + $select = $this->query->getQueryForDefaultStock($skus); $cursor = $connection->query($select); while ($row = $cursor->fetch()) { $output[] = $this->fillWithDefaultValues($row); diff --git a/InventoryDataExporter/Model/Query/InventoryStockQuery.php b/InventoryDataExporter/Model/Query/InventoryStockQuery.php index 19a9cafd..9e808b8f 100644 --- a/InventoryDataExporter/Model/Query/InventoryStockQuery.php +++ b/InventoryDataExporter/Model/Query/InventoryStockQuery.php @@ -21,13 +21,17 @@ class InventoryStockQuery * @var ResourceConnection */ private $resourceConnection; + /** * @var DefaultStockProviderInterface */ private $defaultStockProvider; + private const DEFAULT_STOCK_SOURCE = 'default'; + /** * @param ResourceConnection $resourceConnection + * @param DefaultStockProviderInterface $defaultStockProvider */ public function __construct( ResourceConnection $resourceConnection, @@ -131,6 +135,15 @@ public function getQueryForDefaultStock(array $skus): Select ], 'stock_item.product_id = isi.product_id', [] + )->joinInner( + [ + 'source_item' => 'inventory_source_item' + ], + $connection->quoteInto( + 'source_item.source_code = ? and source_item.sku = isi.sku', + self::DEFAULT_STOCK_SOURCE + ), + [] )->columns( [ 'qty' => "isi.quantity", diff --git a/InventoryDataExporter/Model/Query/StockStatusDeleteQuery.php b/InventoryDataExporter/Model/Query/StockStatusDeleteQuery.php index 06b79551..1b7f1734 100644 --- a/InventoryDataExporter/Model/Query/StockStatusDeleteQuery.php +++ b/InventoryDataExporter/Model/Query/StockStatusDeleteQuery.php @@ -9,6 +9,8 @@ use Magento\DataExporter\Model\Indexer\FeedIndexMetadata; use Magento\Framework\App\ResourceConnection; +use Magento\Framework\Serialize\SerializerInterface; +use Magento\Framework\Stdlib\DateTime; /** * Stock Status mark as deleted query builder @@ -25,16 +27,32 @@ class StockStatusDeleteQuery */ private $metadata; + /** + * @var SerializerInterface + */ + private $serializer; + + /** + * @var DateTime + */ + private $dateTime; + /** * @param ResourceConnection $resourceConnection * @param FeedIndexMetadata $metadata + * @param SerializerInterface $serializer + * @param DateTime $dateTime */ public function __construct( ResourceConnection $resourceConnection, - FeedIndexMetadata $metadata + FeedIndexMetadata $metadata, + SerializerInterface $serializer, + DateTime $dateTime ) { $this->resourceConnection = $resourceConnection; $this->metadata = $metadata; + $this->serializer = $serializer; + $this->dateTime = $dateTime; } /** @@ -49,10 +67,11 @@ public function getStocksAssignedToSkus(array $skus): array $select = $connection->select() ->from( ['source_item' => $this->resourceConnection->getTableName('inventory_source_item')], - ['source_item.sku', 'source_stock_link.stock_id'] + ['source_item.sku', 'source_stock_link.stock_id', 'source_stock_link.source_code'] )->joinLeft( ['source_stock_link' => $this->resourceConnection->getTableName('inventory_source_stock_link')], - 'source_item.source_code = source_stock_link.source_code' + 'source_item.source_code = source_stock_link.source_code', + [] )->where('source_item.sku IN (?)', $skus); $fetchedSourceItems = []; @@ -63,6 +82,39 @@ public function getStocksAssignedToSkus(array $skus): array return $fetchedSourceItems; } + /** + * Get stocks which are assigned to the list of provided SKUs + * + * @param array $sourceCodes + * @return array + */ + public function getStocksWithSources(array $sourceCodes): array + { + $connection = $this->resourceConnection->getConnection(); + $sourceLinkTableName = $this->resourceConnection->getTableName('inventory_source_stock_link'); + $select = $connection->select() + ->from( + ['source_stock_link' => $sourceLinkTableName], + ['source_stock_link.stock_id', 'source_stock_link_all_sources.source_code'] + )->joinInner( + ['source_stock_link_all_sources' => $sourceLinkTableName], + 'source_stock_link_all_sources.stock_id = source_stock_link.stock_id', + [] + )->where( + 'source_stock_link.source_code IN (?)', + $sourceCodes + )->group( + ['source_stock_link.stock_id', + 'source_stock_link_all_sources.source_code' + ] + ); + $stocks = []; + foreach ($connection->fetchAll($select) as $stockData) { + $stocks[$stockData['stock_id']][] = $stockData['source_code']; + } + return $stocks; + } + /** * Mark stock statuses as deleted * @@ -70,14 +122,52 @@ public function getStocksAssignedToSkus(array $skus): array */ public function markStockStatusesAsDeleted(array $idsToDelete): void { + $records = []; + foreach ($idsToDelete as $deletedItemId => $stockStatusData) { + $records[] = $this->buildFeedData($deletedItemId, $stockStatusData); + } $connection = $this->resourceConnection->getConnection(); $feedTableName = $this->resourceConnection->getTableName($this->metadata->getFeedTableName()); - $connection->update( + $connection->insertOnDuplicate( $feedTableName, - ['is_deleted' => new \Zend_Db_Expr('1')], - [ - 'id IN (?)' => $idsToDelete - ] + $records ); } + + /** + * @param string $stockStatusId + * @param array $stockIdAndSku + * @return array + */ + private function buildFeedData(string $stockStatusId, array $stockIdAndSku): array + { + if (!isset($stockIdAndSku['stock_id'], $stockIdAndSku['sku'])) { + throw new \RuntimeException( + sprintf( + "inventory_data_exporter_stock_status indexer error: cannot build unique id from %s", + \var_export($stockIdAndSku, true) + ) + ); + } + $feedData = [ + 'id' => $stockStatusId, + 'stockId' => $stockIdAndSku['stock_id'], + 'sku' => $stockIdAndSku['sku'], + 'qty' => 0, + 'qtyForSale' => 0, + 'infiniteStock' => false, + 'isSalable' => false, + 'updatedAt' => $this->dateTime->formatDate(time()) + + ]; + + return [ + 'id' => $stockStatusId, + 'stock_id' => $stockIdAndSku['stock_id'], + 'sku' => $stockIdAndSku['sku'], + 'feed_data' => $this->serializer->serialize($feedData), + 'is_deleted' => 1, + 'modified_at' => $this->dateTime->formatDate(time()) + ]; + } } diff --git a/InventoryDataExporter/Plugin/BulkSourceUnassign.php b/InventoryDataExporter/Plugin/BulkSourceUnassign.php index 5d9e1802..30870041 100644 --- a/InventoryDataExporter/Plugin/BulkSourceUnassign.php +++ b/InventoryDataExporter/Plugin/BulkSourceUnassign.php @@ -5,6 +5,7 @@ */ namespace Magento\InventoryDataExporter\Plugin; +use Magento\InventoryCatalogApi\Api\BulkSourceUnassignInterface; use Magento\InventoryDataExporter\Model\Provider\StockStatusIdBuilder; use Magento\InventoryDataExporter\Model\Query\StockStatusDeleteQuery; @@ -30,49 +31,69 @@ public function __construct( /** * Check which stocks will be unassigned from products and mark them as deleted in feed table * - * @param \Magento\InventoryCatalog\Model\ResourceModel\BulkSourceUnassign $subject + * * @param BulkSourceUnassignInterface $subject + * @param int $result * @param array $skus * @param array $sourceCodes - * @return void + * @return int * * @SuppressWarnings(PHPMD.UnusedFormalParameter) */ - public function beforeExecute( - \Magento\InventoryCatalog\Model\ResourceModel\BulkSourceUnassign $subject, + public function afterExecute( + BulkSourceUnassignInterface $subject, + int $result, array $skus, array $sourceCodes - ): void { - $fetchedSourceItems = $this->stockStatusDeleteQuery->getStocksAssignedToSkus($skus); - $stocksToDelete = $this->getStocksToDelete($skus, $sourceCodes, $fetchedSourceItems); + ): int { + $sourcesAssignedToProducts = $this->stockStatusDeleteQuery->getStocksAssignedToSkus($skus); + $sourcesByStocks = $this->stockStatusDeleteQuery->getStocksWithSources($sourceCodes); + $stocksToDelete = $this->getStocksToDelete($skus, $sourcesByStocks, $sourcesAssignedToProducts); if (!empty($stocksToDelete)) { $this->stockStatusDeleteQuery->markStockStatusesAsDeleted($stocksToDelete); } + + return $result; } /** * @param array $affectedSkus - * @param array $deletedSources - * @param $fetchedSourceItems + * @param array $sourcesByStocks + * @param array $sourcesAssignedToProducts * @return array */ - private function getStocksToDelete(array $affectedSkus, array $deletedSources, array $fetchedSourceItems): array - { + private function getStocksToDelete( + array $affectedSkus, + array $sourcesByStocks, + array $sourcesAssignedToProducts + ): array { $stocksToDelete = []; foreach ($affectedSkus as $deletedItemSku) { - if (!isset($fetchedSourceItems[$deletedItemSku])) { + foreach (array_keys($sourcesByStocks) as $stockId) { + $stockStatusId = StockStatusIdBuilder::build( + ['stockId' => (string)$stockId, 'sku' => $deletedItemSku] + ); + $stocksToDelete[$stockStatusId] = [ + 'stock_id' => (string)$stockId, + 'sku' => $deletedItemSku + ]; + } + if (!isset($sourcesAssignedToProducts[$deletedItemSku])) { continue ; } - foreach ($fetchedSourceItems[$deletedItemSku] as $fetchedItemStockId => $fetchedItemSources) { - if ($this->getContainsAllKeys($fetchedItemSources, $deletedSources)) { - $stocksToDelete[] = StockStatusIdBuilder::build( + + foreach ($sourcesAssignedToProducts[$deletedItemSku] as $fetchedItemStockId => $fetchedItemSources) { + if (isset($sourcesByStocks[$fetchedItemStockId]) + && $this->getContainsAllKeys($fetchedItemSources, $sourcesByStocks[$fetchedItemStockId])) { + $stockStatusId = StockStatusIdBuilder::build( ['stockId' => (string)$fetchedItemStockId, 'sku' => $deletedItemSku] ); + unset($stocksToDelete[$stockStatusId]); } } } - return $stocksToDelete; + return array_filter($stocksToDelete); } /** diff --git a/InventoryDataExporter/Plugin/MarkItemsAsDeleted.php b/InventoryDataExporter/Plugin/MarkItemsAsDeleted.php index 0e883038..e1f4b068 100644 --- a/InventoryDataExporter/Plugin/MarkItemsAsDeleted.php +++ b/InventoryDataExporter/Plugin/MarkItemsAsDeleted.php @@ -66,9 +66,13 @@ private function getStocksToDelete(array $deletedSourceItems, $fetchedSourceItem foreach ($deletedSourceItems as $deletedItemSku => $deletedItemSources) { foreach ($fetchedSourceItems[$deletedItemSku] as $fetchedItemStockId => $fetchedItemSources) { if ($this->getContainsAllKeys($fetchedItemSources, $deletedItemSources)) { - $stocksToDelete[] = StockStatusIdBuilder::build( + $stockStatusId = StockStatusIdBuilder::build( ['stockId' => (string)$fetchedItemStockId, 'sku' => $deletedItemSku] ); + $stocksToDelete[$stockStatusId] = [ + 'stock_id' => (string)$fetchedItemStockId, + 'sku' => $deletedItemSku + ]; } } } diff --git a/InventoryDataExporter/Plugin/SourceItem/SourceItemUpdate.php b/InventoryDataExporter/Plugin/SourceItem/SourceItemUpdate.php index 2dd1247a..c48b2d65 100644 --- a/InventoryDataExporter/Plugin/SourceItem/SourceItemUpdate.php +++ b/InventoryDataExporter/Plugin/SourceItem/SourceItemUpdate.php @@ -43,8 +43,7 @@ public function afterExecute( SourceItemsSaveInterface $subject, $result, array $sourceItems - ): void - { + ): void { $stockStatusIndexer = $this->indexer->load(self::STOCK_STATUS_FEED_INDEXER); if (!$stockStatusIndexer->isScheduled()) { $skus = \array_map( diff --git a/InventoryDataExporter/README.md b/InventoryDataExporter/README.md index a4a8fb66..d5391d02 100644 --- a/InventoryDataExporter/README.md +++ b/InventoryDataExporter/README.md @@ -1,9 +1,10 @@ -## Release notes +*Magento_InventoryDataExporter* module is responsible for collecting inventory data -*Magento_InventoryDataExporter* module +## Stock Status -https://docs.magento.com/user-guide/catalog/inventory-backorders.html?itm_source=devdocs&itm_medium=quick_search&itm_campaign=federated_search&itm_term=backorer - - -Zero -With Backorders enabled, entering 0 allows for infinite backorders. \ No newline at end of file +- Collects aggregated value of Stock Status described in [et_schema.xml](etc/et_schema.xml) +- Depends on Inventory indexer which is used to get `isSalable` status and `qty` in stock. +- `qtyForSale` calculated based on Reservations API +- Stock is considered as infinite in the following cases: + - Manage Stock disabled + - [Backorders](https://docs.magento.com/user-guide/catalog/inventory-backorders.html?itm_source=devdocs&itm_medium=quick_search&itm_campaign=federated_search&itm_term=backorer) enabled and Out-of-Stock threshold is set to 0. diff --git a/InventoryDataExporter/Test/Integration/ExportStockStatusTest.php b/InventoryDataExporter/Test/Integration/ExportStockStatusTest.php index 8d41eba1..daff1c1c 100644 --- a/InventoryDataExporter/Test/Integration/ExportStockStatusTest.php +++ b/InventoryDataExporter/Test/Integration/ExportStockStatusTest.php @@ -41,6 +41,7 @@ public function testExportStockStatuses() ['sku' => 'product_in_EU_stock_with_2_sources'], ['sku' => 'product_in_Global_stock_with_3_sources'], ['sku' => 'product_with_default_stock_only'], + ['sku' => 'product_in_default_and_2_EU_sources'], ['sku' => 'product_with_disabled_manage_stock'], ['sku' => 'product_with_enabled_backorders'], ['sku' => 'product_in_US_stock_with_disabled_source'], @@ -68,6 +69,45 @@ public function testExportStockStatuses() } } + /** + * @magentoDataFixture Magento_InventoryDataExporter::Test/_files/products_with_sources_and_reservations.php + */ + public function testExportStockStatusesWithReservations() + { + $actualStockStatus = $this->processor->process( + 'stock_statuses', + [ + ['sku' => 'product_in_EU_stock_with_2_sources'], + ['sku' => 'product_in_Global_stock_with_3_sources'], + ['sku' => 'product_with_default_stock_only'], + ['sku' => 'product_in_default_and_2_EU_sources'], + ['sku' => 'product_with_disabled_manage_stock'], + ['sku' => 'product_with_enabled_backorders'], + ['sku' => 'product_in_US_stock_with_disabled_source'], + ] + ); + + $actualStockStatusFormatted = []; + foreach ($actualStockStatus as $stockStatus) { + $actualStockStatusFormatted[$stockStatus['stockId']][$stockStatus['sku']] = $stockStatus; + } + foreach ($this->getExpectedStockStatusForReservations() as $stockId => $stockStatuses) { + foreach ($stockStatuses as $sku => $stockStatus) { + if (!isset($actualStockStatusFormatted[$stockId][$sku])) { + self::fail("Cannot find stock status for stock $stockId & sku $sku"); + } + $actualStockStatus = $actualStockStatusFormatted[$stockId][$sku]; + // ignore fields for now + unset($actualStockStatus['id'], $actualStockStatus['lowStock'], $actualStockStatus['updatedAt']); + self::assertEquals( + $stockStatus, + $actualStockStatus, + "Wrong stock status for stock $stockId & sku $sku" + ); + } + } + } + /** * @return \array[][] */ @@ -84,6 +124,14 @@ private function getExpectedStockStatus(): array 'infiniteStock' => false, 'isSalable' => true, ], + 'product_in_default_and_2_EU_sources' => [ + 'stockId' => '1', + 'sku' => 'product_in_default_and_2_EU_sources', + 'qty' => 2, + 'qtyForSale' => 2, + 'infiniteStock' => false, + 'isSalable' => true, + ], 'product_with_disabled_manage_stock' => [ 'stockId' => '1', 'sku' => 'product_with_disabled_manage_stock', @@ -119,6 +167,14 @@ private function getExpectedStockStatus(): array 'infiniteStock' => false, 'isSalable' => true, ], + 'product_in_default_and_2_EU_sources' => [ + 'stockId' => '10', + 'sku' => 'product_in_default_and_2_EU_sources', + 'qty' => 9.5, + 'qtyForSale' => 9.5, + 'infiniteStock' => false, + 'isSalable' => true, + ], ], // US Stock '20' => [ @@ -160,4 +216,115 @@ private function getExpectedStockStatus(): array ], ]; } + + /** + * @return \array[][] + */ + private function getExpectedStockStatusForReservations(): array + { + return [ + // default stock + '1' => [ + 'product_with_default_stock_only' => [ + 'stockId' => '1', + 'sku' => 'product_with_default_stock_only', + 'qty' => 8.5, + 'qtyForSale' => 6.3, + 'infiniteStock' => false, + 'isSalable' => true, + ], + 'product_in_default_and_2_EU_sources' => [ + 'stockId' => '1', + 'sku' => 'product_in_default_and_2_EU_sources', + 'qty' => 2.0, + 'qtyForSale' => 1, + 'infiniteStock' => false, + 'isSalable' => true, + ], + 'product_with_disabled_manage_stock' => [ + 'stockId' => '1', + 'sku' => 'product_with_disabled_manage_stock', + 'qty' => 0, + 'qtyForSale' => 0, + 'infiniteStock' => true, + 'isSalable' => true, + ], + 'product_with_enabled_backorders' => [ + 'stockId' => '1', + 'sku' => 'product_with_enabled_backorders', + 'qty' => 5.0, + // Uncomment it after qtyForSale will be fixed + //'qtyForSale' => 0, + 'qtyForSale' => -2.2, // JUST TEMPORARILY FIX. We should not allow negative values + 'infiniteStock' => true, + 'isSalable' => true, + ], + ], + // EU Stock + '10' => [ + 'product_in_EU_stock_with_2_sources' => [ + 'stockId' => '10', + 'sku' => 'product_in_EU_stock_with_2_sources', + 'qty' => 9.5, // 5.5 (eu-1) + 4 (eu-2) + 'qtyForSale' => 0, + 'infiniteStock' => false, + 'isSalable' => true, + ], + 'product_in_Global_stock_with_3_sources' => [ + 'stockId' => '10', + 'sku' => 'product_in_Global_stock_with_3_sources', + 'qty' => 3, // eu1 + eu2 + 'qtyForSale' => 0, + 'infiniteStock' => false, + 'isSalable' => true, + ], + 'product_in_default_and_2_EU_sources' => [ + 'stockId' => '10', + 'sku' => 'product_in_default_and_2_EU_sources', + 'qty' => 9.5, + 'qtyForSale' => 5.5, + 'infiniteStock' => false, + 'isSalable' => true, + ], + ], + // US Stock + '20' => [ + 'product_in_Global_stock_with_3_sources' => [ + 'stockId' => '20', + 'sku' => 'product_in_Global_stock_with_3_sources', + 'qty' => 4, // us-1 source assigned to both stocks: US & Global + 'qtyForSale' => 2, + 'infiniteStock' => false, + 'isSalable' => true, + ], + 'product_in_US_stock_with_disabled_source' => [ + 'stockId' => '20', + 'sku' => 'product_in_US_stock_with_disabled_source', + 'qty' => 0, + 'qtyForSale' => 0, + 'infiniteStock' => false, + 'isSalable' => false, + ], + ], + // Global Stock + '30' => [ + 'product_in_Global_stock_with_3_sources' => [ + 'stockId' => '30', + 'sku' => 'product_in_Global_stock_with_3_sources', + 'qty' => 5, // 1 (eu-1) + 4 (us-1) + 'qtyForSale' => 2.5, + 'infiniteStock' => false, + 'isSalable' => true, + ], + 'product_in_EU_stock_with_2_sources' => [ + 'stockId' => '30', + 'sku' => 'product_in_EU_stock_with_2_sources', + 'qty' => 5.5, // eu-1 only + 'qtyForSale' => 5.5, + 'infiniteStock' => false, + 'isSalable' => true, + ], + ], + ]; + } } diff --git a/InventoryDataExporter/Test/Integration/PartialReindexCheckTest.php b/InventoryDataExporter/Test/Integration/PartialReindexCheckTest.php index 8512b31c..b45c2dd9 100644 --- a/InventoryDataExporter/Test/Integration/PartialReindexCheckTest.php +++ b/InventoryDataExporter/Test/Integration/PartialReindexCheckTest.php @@ -102,7 +102,11 @@ public function testSourceItemQtyUpdated() */ public function testSourceBulkUnassign() { - $skus = ['product_in_EU_stock_with_2_sources', 'product_in_Global_stock_with_3_sources', 'product_with_default_stock_only']; + $skus = [ + 'product_in_EU_stock_with_2_sources', + 'product_in_Global_stock_with_3_sources', + 'product_with_default_stock_only' + ]; $this->bulkSourceUnassign->execute( $skus, diff --git a/InventoryDataExporter/Test/Integration/UnassignProductFromStockTest.php b/InventoryDataExporter/Test/Integration/UnassignProductFromStockTest.php new file mode 100644 index 00000000..56a1efbc --- /dev/null +++ b/InventoryDataExporter/Test/Integration/UnassignProductFromStockTest.php @@ -0,0 +1,580 @@ +stockStatusFeed = Bootstrap::getObjectManager()->get(FeedPool::class)->getFeed('stock_statuses'); + $this->sourceItemProcessor = Bootstrap::getObjectManager()->get(SourceItemsProcessorInterface::class); + $this->bulkSourceUnassign = Bootstrap::getObjectManager()->get(BulkSourceUnassignInterface::class); + } + + /** + * @dataProvider stocksUnassignDataProvider + * @param string $sku + * @param array $sourcesToLeave + * @param array $expectedData + * @throws InputException + * @throws \Zend_Db_Statement_Exception + * @magentoDataFixture Magento_InventoryDataExporter::Test/_files/products_with_sources.php + */ + public function testSourceItemStockUnassigned(string $sku, array $sourcesToLeave, array $expectedData) + { + $sourceItems = $this->getSourcesData($sku, $sourcesToLeave); + $this->sourceItemProcessor->execute($sku, $sourceItems); + + $feedData = $this->getFeedData([$sku]); + + $this->verifyResults($feedData, $sku, $expectedData); + } + + /** + * @dataProvider stocksBulkUnassignDataProvider + * @param array $skus + * @param array $sourcesToUnassign + * @param array $expectedData + * @throws InputException + * @throws \Zend_Db_Statement_Exception + * @throws \Magento\Framework\Validation\ValidationException + * @magentoDataFixture Magento_InventoryDataExporter::Test/_files/products_with_sources.php + */ + public function testSourceItemsBulkUnassign(array $skus, array $sourcesToUnassign, array $expectedData) + { + $this->bulkSourceUnassign->execute( + $skus, + $sourcesToUnassign + ); + + $feedData = $this->getFeedData($skus); + + foreach ($skus as $sku) { + $this->verifyResults($feedData, $sku, $expectedData[$sku]); + } + } + + /** + * @param array $skus + * @return array[stock][sku] + * @throws \Zend_Db_Statement_Exception + */ + private function getFeedData(array $skus): array + { + $output = []; + foreach ($this->stockStatusFeed->getFeedSince('1')['feed'] as $item) { + if (\in_array($item['sku'], $skus, true)) { + $output[$item['stockId']][$item['sku']] = $item; + } + } + return $output; + } + + /** + * @return array[] + */ + public function stocksUnassignDataProvider(): array + { + return [ + 'one_stock_unassign' => [ + 'sku' => 'product_in_EU_stock_with_2_sources', + 'sources_to_leave' => ['eu-2'], + 'expected_data' => [ + '10' => [ + 'sku' => 'product_in_EU_stock_with_2_sources', + 'stock_id' => '10', + 'qty' => 0, + 'qty_for_sale' => 0, + 'infinite_stock' => false, + 'is_salable' => false, + 'deleted' => false + ], + '30' => [ + 'sku' => 'product_in_EU_stock_with_2_sources', + 'stock_id' => '30', + 'qty' => 0, + 'qty_for_sale' => 0, + 'infinite_stock' => false, + 'is_salable' => false, + 'deleted' => true + ] + ] + ], + 'unassign_sources_from_multiple_stocks' => [ + 'sku' => 'product_in_Global_stock_with_3_sources', + 'sources_to_leave' => ['eu-2'], + 'expected_data' => [ + '10' => [ + 'sku' => 'product_in_Global_stock_with_3_sources', + 'stock_id' => '10', + 'qty' => 0, + 'qty_for_sale' => 0, + 'infinite_stock' => false, + 'is_salable' => false, + 'deleted' => false + ], + '20' => [ + 'sku' => 'product_in_Global_stock_with_3_sources', + 'stock_id' => '20', + 'qty' => 0, + 'qty_for_sale' => 0, + 'infinite_stock' => false, + 'is_salable' => false, + 'deleted' => true + ], + '30' => [ + 'sku' => 'product_in_Global_stock_with_3_sources', + 'stock_id' => '30', + 'qty' => 0, + 'qty_for_sale' => 0, + 'infinite_stock' => false, + 'is_salable' => false, + 'deleted' => true + ] + ] + ], + 'default_stock_unassign_from_only_default_stock_product' => [ + 'sku' => 'product_with_default_stock_only', + 'sources_to_leave' => [], + 'expected_data' => [ + '1' => [ + 'sku' => 'product_with_default_stock_only', + 'stock_id' => '1', + 'qty' => 0, + 'qty_for_sale' => 0, + 'infinite_stock' => false, + 'is_salable' => false, + 'deleted' => true + ] + ] + ], + 'default_stock_unassign_from_default_and_custom_stocks_product' => [ + 'sku' => 'product_in_default_and_2_EU_sources', + 'sources_to_leave' => ['eu-1', 'eu-2'], + 'expected_data' => [ + '1' => [ + 'sku' => 'product_in_default_and_2_EU_sources', + 'stock_id' => '1', + 'qty' => 0, + 'qty_for_sale' => 0, + 'infinite_stock' => false, + 'is_salable' => false, + 'deleted' => true + ], + '10' => [ + 'sku' => 'product_in_default_and_2_EU_sources', + 'stock_id' => '10', + 'qty' => 0, + 'qty_for_sale' => 0, + 'infinite_stock' => false, + 'is_salable' => false, + 'deleted' => false + ] + ] + ], + 'custom_stock_unassign_from_default_and_custom_stocks_product' => [ + 'sku' => 'product_in_default_and_2_EU_sources', + 'sources_to_leave' => ['default'], + 'expected_data' => [ + '1' => [ + 'sku' => 'product_in_default_and_2_EU_sources', + 'stock_id' => '1', + 'qty' => 0, + 'qty_for_sale' => 0, + 'infinite_stock' => false, + 'is_salable' => false, + 'deleted' => false + ], + '10' => [ + 'sku' => 'product_in_default_and_2_EU_sources', + 'stock_id' => '10', + 'qty' => 0, + 'qty_for_sale' => 0, + 'infinite_stock' => false, + 'is_salable' => false, + 'deleted' => true + ] + ] + ], + ]; + } + + /** + * @return array[] + */ + public function stocksBulkUnassignDataProvider(): array + { + return [ + 'one_stock_unassign' => [ + 'skus' => [ + 'product_in_EU_stock_with_2_sources', + 'product_in_Global_stock_with_3_sources', + 'product_with_default_stock_only', + 'product_in_default_and_2_EU_sources' + ], + 'sources_to_unassign' => ['eu-1', 'eu-2'], + 'expected_data' => [ + 'product_with_default_stock_only' => [ + '1' => [ + 'sku' => 'product_with_default_stock_only', + 'stock_id' => '1', + 'qty' => 8.5, + 'qty_for_sale' => 8.5, + 'infinite_stock' => false, + 'is_salable' => true, + 'deleted' => false + ], + ], + 'product_in_default_and_2_EU_sources' => [ + '1' => [ + 'sku' => 'product_in_default_and_2_EU_sources', + 'stock_id' => '1', + 'qty' => 2, + 'qty_for_sale' => 2, + 'infinite_stock' => false, + 'is_salable' => true, + 'deleted' => false + ], + '10' => [ + 'sku' => 'product_in_default_and_2_EU_sources', + 'stock_id' => '10', + 'qty' => 0, + 'qty_for_sale' => 0, + 'infinite_stock' => false, + 'is_salable' => false, + 'deleted' => true + ], + ], + 'product_in_EU_stock_with_2_sources' => [ + '10' => [ + 'sku' => 'product_in_EU_stock_with_2_sources', + 'stock_id' => '10', + 'qty' => 0, + 'qty_for_sale' => 0, + 'infinite_stock' => false, + 'is_salable' => false, + 'deleted' => true + ], + ], + 'product_in_Global_stock_with_3_sources' => [ + '10' => [ + 'sku' => 'product_in_Global_stock_with_3_sources', + 'stock_id' => '10', + 'qty' => 0, + 'qty_for_sale' => 0, + 'infinite_stock' => false, + 'is_salable' => false, + 'deleted' => true + ], + '30' => [ + 'sku' => 'product_in_Global_stock_with_3_sources', + 'stock_id' => '30', + 'qty' => 4, + 'qty_for_sale' => 4, + 'infinite_stock' => false, + 'is_salable' => true, + 'deleted' => false + ], + ] + ] + ], + 'two_stocks_unassign' => [ + 'skus' => [ + 'product_in_EU_stock_with_2_sources', + 'product_in_Global_stock_with_3_sources', + 'product_with_default_stock_only', + 'product_in_default_and_2_EU_sources' + ], + 'sources_to_unassign' => ['eu-1', 'eu-2', 'us-1'], + 'expected_data' => [ + 'product_with_default_stock_only' => [ + '1' => [ + 'sku' => 'product_with_default_stock_only', + 'stock_id' => '1', + 'qty' => 8.5, + 'qty_for_sale' => 8.5, + 'infinite_stock' => false, + 'is_salable' => true, + 'deleted' => false + ], + ], + 'product_in_default_and_2_EU_sources' => [ + '1' => [ + 'sku' => 'product_in_default_and_2_EU_sources', + 'stock_id' => '1', + 'qty' => 2, + 'qty_for_sale' => 2, + 'infinite_stock' => false, + 'is_salable' => true, + 'deleted' => false + ], + '10' => [ + 'sku' => 'product_in_default_and_2_EU_sources', + 'stock_id' => '10', + 'qty' => 0, + 'qty_for_sale' => 0, + 'infinite_stock' => false, + 'is_salable' => false, + 'deleted' => true + ], + ], + 'product_in_EU_stock_with_2_sources' => [ + '10' => [ + 'sku' => 'product_in_EU_stock_with_2_sources', + 'stock_id' => '10', + 'qty' => 0, + 'qty_for_sale' => 0, + 'infinite_stock' => false, + 'is_salable' => false, + 'deleted' => true + ], + ], + 'product_in_Global_stock_with_3_sources' => [ + '10' => [ + 'sku' => 'product_in_Global_stock_with_3_sources', + 'stock_id' => '10', + 'qty' => 0, + 'qty_for_sale' => 0, + 'infinite_stock' => false, + 'is_salable' => false, + 'deleted' => true + ], + '30' => [ + 'sku' => 'product_in_Global_stock_with_3_sources', + 'stock_id' => '30', + 'qty' => 0, + 'qty_for_sale' => 0, + 'infinite_stock' => false, + 'is_salable' => false, + 'deleted' => true + ], + ] + ] + ], + 'two_stocks_and_default_unassign' => [ + 'skus' => [ + 'product_in_EU_stock_with_2_sources', + 'product_in_Global_stock_with_3_sources', + 'product_with_default_stock_only', + 'product_in_default_and_2_EU_sources' + ], + 'sources_to_unassign' => ['eu-1', 'eu-2', 'us-1', 'default'], + 'expected_data' => [ + 'product_with_default_stock_only' => [ + '1' => [ + 'sku' => 'product_with_default_stock_only', + 'stock_id' => '1', + 'qty' => 0, + 'qty_for_sale' => 0, + 'infinite_stock' => false, + 'is_salable' => false, + 'deleted' => true + ], + ], + 'product_in_default_and_2_EU_sources' => [ + '1' => [ + 'sku' => 'product_in_default_and_2_EU_sources', + 'stock_id' => '1', + 'qty' => 0, + 'qty_for_sale' => 0, + 'infinite_stock' => false, + 'is_salable' => false, + 'deleted' => true + ], + '10' => [ + 'sku' => 'product_in_default_and_2_EU_sources', + 'stock_id' => '10', + 'qty' => 0, + 'qty_for_sale' => 0, + 'infinite_stock' => false, + 'is_salable' => false, + 'deleted' => true + ], + ], + 'product_in_EU_stock_with_2_sources' => [ + '10' => [ + 'sku' => 'product_in_EU_stock_with_2_sources', + 'stock_id' => '10', + 'qty' => 0, + 'qty_for_sale' => 0, + 'infinite_stock' => false, + 'is_salable' => false, + 'deleted' => true + ], + ], + 'product_in_Global_stock_with_3_sources' => [ + '10' => [ + 'sku' => 'product_in_Global_stock_with_3_sources', + 'stock_id' => '10', + 'qty' => 0, + 'qty_for_sale' => 0, + 'infinite_stock' => false, + 'is_salable' => false, + 'deleted' => true + ], + '30' => [ + 'sku' => 'product_in_Global_stock_with_3_sources', + 'stock_id' => '30', + 'qty' => 0, + 'qty_for_sale' => 0, + 'infinite_stock' => false, + 'is_salable' => false, + 'deleted' => true + ], + ] + ] + ], + 'only_default_stock_unassign' => [ + 'skus' => [ + 'product_in_EU_stock_with_2_sources', + 'product_in_Global_stock_with_3_sources', + 'product_with_default_stock_only', + 'product_in_default_and_2_EU_sources' + ], + 'sources_to_unassign' => ['default'], + 'expected_data' => [ + 'product_with_default_stock_only' => [ + '1' => [ + 'sku' => 'product_with_default_stock_only', + 'stock_id' => '1', + 'qty' => 0, + 'qty_for_sale' => 0, + 'infinite_stock' => false, + 'is_salable' => false, + 'deleted' => true + ], + ], + 'product_in_default_and_2_EU_sources' => [ + '1' => [ + 'sku' => 'product_in_default_and_2_EU_sources', + 'stock_id' => '1', + 'qty' => 0, + 'qty_for_sale' => 0, + 'infinite_stock' => false, + 'is_salable' => false, + 'deleted' => true + ], + '10' => [ + 'sku' => 'product_in_default_and_2_EU_sources', + 'stock_id' => '10', + 'qty' => 9.5, + 'qty_for_sale' => 9.5, + 'infinite_stock' => false, + 'is_salable' => true, + 'deleted' => false + ], + ], + 'product_in_EU_stock_with_2_sources' => [ + '10' => [ + 'sku' => 'product_in_EU_stock_with_2_sources', + 'stock_id' => '10', + 'qty' => 9.5, + 'qty_for_sale' => 9.5, + 'infinite_stock' => false, + 'is_salable' => true, + 'deleted' => false + ], + ], + 'product_in_Global_stock_with_3_sources' => [ + '10' => [ + 'sku' => 'product_in_Global_stock_with_3_sources', + 'stock_id' => '10', + 'qty' => 3, + 'qty_for_sale' => 3, + 'infinite_stock' => false, + 'is_salable' => true, + 'deleted' => false + ], + '30' => [ + 'sku' => 'product_in_Global_stock_with_3_sources', + 'stock_id' => '30', + 'qty' => 5, + 'qty_for_sale' => 5, + 'infinite_stock' => false, + 'is_salable' => true, + 'deleted' => false + ], + ] + ] + ], + ]; + } + + /** + * @param string $sku + * @param array $sourcesToUnassign + * @return array + */ + private function getSourcesData(string $sku, array $sourcesToUnassign): array + { + $sourcesData = []; + foreach ($sourcesToUnassign as $sourceCode) { + $sourcesData[] = [ + SourceItemInterface::SOURCE_CODE => $sourceCode, + SourceItemInterface::SKU => $sku + ]; + } + + return $sourcesData; + } + + /** + * @param array $feedData + * @param string $sku + * @param array $expectedData + */ + private function verifyResults(array $feedData, string $sku, array $expectedData): void + { + foreach ($expectedData as $expectedStockId => $expectedStockData) { + self::assertEquals( + $expectedStockData, + [ + 'sku' => $feedData[$expectedStockId][$sku]['sku'], + 'stock_id' => $feedData[$expectedStockId][$sku]['stockId'], + 'qty' => $feedData[$expectedStockId][$sku]['qty'], + 'qty_for_sale' => $feedData[$expectedStockId][$sku]['qtyForSale'], + 'infinite_stock' => $feedData[$expectedStockId][$sku]['infiniteStock'], + 'is_salable' => $feedData[$expectedStockId][$sku]['isSalable'], + 'deleted' => $feedData[$expectedStockId][$sku]['deleted'], + ] + ); + } + } +} diff --git a/InventoryDataExporter/Test/_files/products_with_sources.php b/InventoryDataExporter/Test/_files/products_with_sources.php index 4adb7817..99633636 100644 --- a/InventoryDataExporter/Test/_files/products_with_sources.php +++ b/InventoryDataExporter/Test/_files/products_with_sources.php @@ -230,6 +230,12 @@ 'manage_stock' => true, 'is_qty_decimal' => true ], + 'product_in_default_and_2_EU_sources' => [ + 'qty' => 11.5, + 'is_in_stock' => true, + 'manage_stock' => true, + 'is_qty_decimal' => true + ], 'product_with_disabled_manage_stock' => [ 'use_config_manage_stock' => false, 'manage_stock' => false, @@ -282,6 +288,24 @@ $sourceItemsSave = Bootstrap::getObjectManager()->get(SourceItemsSaveInterface::class); $sourcesItemsData = [ + [ + SourceItemInterface::SOURCE_CODE => 'default', + SourceItemInterface::SKU => 'product_in_default_and_2_EU_sources', + SourceItemInterface::QUANTITY => 2, + SourceItemInterface::STATUS => SourceItemInterface::STATUS_IN_STOCK, + ], + [ + SourceItemInterface::SOURCE_CODE => 'eu-1', + SourceItemInterface::SKU => 'product_in_default_and_2_EU_sources', + SourceItemInterface::QUANTITY => 5.5, + SourceItemInterface::STATUS => SourceItemInterface::STATUS_IN_STOCK, + ], + [ + SourceItemInterface::SOURCE_CODE => 'eu-2', + SourceItemInterface::SKU => 'product_in_default_and_2_EU_sources', + SourceItemInterface::QUANTITY => 4, + SourceItemInterface::STATUS => SourceItemInterface::STATUS_IN_STOCK, + ], [ SourceItemInterface::SOURCE_CODE => 'eu-1', SourceItemInterface::SKU => 'product_in_EU_stock_with_2_sources', diff --git a/InventoryDataExporter/Test/_files/products_with_sources_and_reservations.php b/InventoryDataExporter/Test/_files/products_with_sources_and_reservations.php new file mode 100644 index 00000000..0b30d917 --- /dev/null +++ b/InventoryDataExporter/Test/_files/products_with_sources_and_reservations.php @@ -0,0 +1,102 @@ +requireDataFixture( + 'Magento_InventoryDataExporter::Test/_files/products_with_sources.php' +); + +/** + * Create reservations + */ +$createReservations = static function (): void +{ + $productsList = [ + [ + 'sku' => 'product_with_default_stock_only', + 'qty_by_stocks' => [ + ['stock_id' => 1, 'qty' => -2.2] //default stock 5.5 left + ], + ], + [ + 'sku' => 'product_with_disabled_manage_stock', + 'qty_by_stocks' => [ + ['stock_id' => 1, 'qty' => -2.2] //unlimited + ] + ], + [ + 'sku' => 'product_with_enabled_backorders', + 'qty_by_stocks' => [ + ['stock_id' => 1, 'qty' => -7.2] //unlimited + ] + ], + [ + 'sku' => 'product_in_EU_stock_with_2_sources', + 'qty_by_stocks' => [ + ['stock_id' => 10, 'qty' => -9.5] //eu-1, eu-2 - 4.5 left + ] + ], + [ + 'sku' => 'product_in_default_and_2_EU_sources', + 'qty_by_stocks' => [ + ['stock_id' => 10, 'qty' => -4], //eu-1, eu-2 - 5.5 left + ['stock_id' => 1, 'qty' => -1], //default - 1 left + ] + ], + [ + 'sku' => 'product_in_Global_stock_with_3_sources', + 'qty_by_stocks' => [ + ['stock_id' => 10, 'qty' => -3], //eu-1, eu-2 - 4.5 left + ['stock_id' => 20, 'qty' => -2], //us-1 - 2 left + ['stock_id' => 30, 'qty' => -2.5], + ] + ] + ]; + + $objectManager = Bootstrap::getObjectManager(); + /** @var ProductRepositoryInterface $productRepository */ + $productRepository = $objectManager->create(ProductRepositoryInterface::class); + /** @var ReservationBuilderInterface $reservationBuilder */ + $reservationBuilder = $objectManager->get(ReservationBuilderInterface::class); + $isSourceItemManagementAllowedForProductType = $objectManager->get( + IsSourceItemManagementAllowedForProductTypeInterface::class + ); + /** @var AppendReservationsInterface $appendReservations */ + $appendReservations = $objectManager->get(AppendReservationsInterface::class); + $reservations = []; + foreach ($productsList as $productData) { + $product = $productRepository->get($productData['sku']); + $skusToReindex[] = $productData['sku']; + if ($isSourceItemManagementAllowedForProductType->execute($product->getTypeId())) { + foreach ($productData['qty_by_stocks'] as $stockData) { + $reservations[] = $reservationBuilder + ->setSku($productData['sku']) + ->setQuantity((float)$stockData['qty']) + ->setStockId($stockData['stock_id']) + ->setMetadata() + ->build(); + } + } + } + + $appendReservations->execute($reservations); +}; + +$createReservations(); diff --git a/InventoryDataExporter/Test/_files/products_with_sources_and_reservations_rollback.php b/InventoryDataExporter/Test/_files/products_with_sources_and_reservations_rollback.php new file mode 100644 index 00000000..b1fe3f1f --- /dev/null +++ b/InventoryDataExporter/Test/_files/products_with_sources_and_reservations_rollback.php @@ -0,0 +1,36 @@ +get(CleanupReservationsInterface::class); +$cleanupReservations->execute(); + +/** @var \Magento\Framework\App\ResourceConnection $resourceConnection */ +$resourceConnection = Bootstrap::getObjectManager()->create(\Magento\Framework\App\ResourceConnection::class); +$connection = $resourceConnection->getConnection(); +$reservationTable = $connection->getTableName('inventory_reservation'); + +$select = $connection->select() + ->from( + $reservationTable, + ['GROUP_CONCAT(' . ReservationInterface::RESERVATION_ID . ')'] + ); +$reservationIds = implode(',', $connection->fetchCol($select)); + +$condition = [ReservationInterface::RESERVATION_ID . ' IN (?)' => explode(',', $reservationIds)]; +$connection->delete($reservationTable, $condition); + +Resolver::getInstance()->requireDataFixture( + 'Magento_InventoryDataExporter::Test/_files/products_with_sources_rollback.php' +); diff --git a/InventoryDataExporter/Test/_files/products_with_sources_rollback.php b/InventoryDataExporter/Test/_files/products_with_sources_rollback.php index b2f90af5..5ff3032d 100644 --- a/InventoryDataExporter/Test/_files/products_with_sources_rollback.php +++ b/InventoryDataExporter/Test/_files/products_with_sources_rollback.php @@ -7,11 +7,7 @@ */ declare(strict_types=1); -use Magento\Catalog\Api\Data\ProductInterfaceFactory; use Magento\Catalog\Api\ProductRepositoryInterface; -use Magento\InventoryApi\Api\Data\SourceInterfaceFactory; -use Magento\InventoryApi\Api\Data\StockInterfaceFactory; -use Magento\InventoryApi\Api\Data\StockSourceLinkInterfaceFactory; use Magento\TestFramework\Helper\Bootstrap; use Magento\Framework\App\ResourceConnection; use Magento\InventoryApi\Api\Data\SourceInterface; @@ -21,7 +17,8 @@ // Delete products $skusToDelete = [ 'product_with_default_stock_only', 'product_with_disabled_manage_stock', 'product_with_enabled_backorders', - 'product_in_EU_stock_with_2_sources', 'product_in_Global_stock_with_3_sources' + 'product_in_EU_stock_with_2_sources', 'product_in_Global_stock_with_3_sources', + 'product_in_default_and_2_EU_sources' ]; $objectManager = Bootstrap::getObjectManager(); diff --git a/InventoryDataExporter/etc/di.xml b/InventoryDataExporter/etc/di.xml index 6634a0a3..c5987a3e 100644 --- a/InventoryDataExporter/etc/di.xml +++ b/InventoryDataExporter/etc/di.xml @@ -63,9 +63,10 @@ - + + type="Magento\InventoryDataExporter\Plugin\BulkSourceUnassign" + sortOrder="200"/> @@ -75,11 +76,12 @@ - - - + + + + - - + +