diff --git a/app/code/Magento/AdvancedSearch/Model/ResourceModel/Index.php b/app/code/Magento/AdvancedSearch/Model/ResourceModel/Index.php index 420f7b1e0e72a..cacebfe600765 100644 --- a/app/code/Magento/AdvancedSearch/Model/ResourceModel/Index.php +++ b/app/code/Magento/AdvancedSearch/Model/ResourceModel/Index.php @@ -3,20 +3,22 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ +declare(strict_types=1); + namespace Magento\AdvancedSearch\Model\ResourceModel; -use Magento\Framework\Model\ResourceModel\Db\AbstractDb; -use Magento\Framework\Search\Request\IndexScopeResolverInterface; -use Magento\Store\Model\StoreManagerInterface; -use Magento\Framework\Model\ResourceModel\Db\Context; -use Magento\Framework\EntityManager\MetadataPool; use Magento\Catalog\Api\Data\CategoryInterface; +use Magento\Catalog\Model\Indexer\Category\Product\AbstractAction; +use Magento\Catalog\Model\Indexer\Product\Price\DimensionCollectionFactory; use Magento\Framework\App\ObjectManager; +use Magento\Framework\EntityManager\MetadataPool; +use Magento\Framework\Model\ResourceModel\Db\AbstractDb; +use Magento\Framework\Model\ResourceModel\Db\Context; use Magento\Framework\Search\Request\Dimension; -use Magento\Catalog\Model\Indexer\Category\Product\AbstractAction; +use Magento\Framework\Search\Request\IndexScopeResolverInterface; use Magento\Framework\Search\Request\IndexScopeResolverInterface as TableResolver; -use Magento\Catalog\Model\Indexer\Product\Price\DimensionCollectionFactory; use Magento\Store\Model\Indexer\WebsiteDimensionProvider; +use Magento\Store\Model\StoreManagerInterface; /** * @api @@ -150,6 +152,63 @@ public function getPriceIndexData($productIds, $storeId) return $priceProductsIndexData[$websiteId]; } + /** + * Return array of inventory data products + * + * @param null|array $productIds + * @return array + * @since 100.1.0 + */ + private function getCatalogProductInventoryData($productIds = null) + { + $connection = $this->getConnection(); + $catalogProductIndexInventorySelect = []; + + foreach ($this->dimensionCollectionFactory->create() as $dimensions) { + if (!isset($dimensions[WebsiteDimensionProvider::DIMENSION_NAME]) || + $this->websiteId === null || + $dimensions[WebsiteDimensionProvider::DIMENSION_NAME]->getValue() === $this->websiteId) { + $select = $connection->select()->from( + $this->tableResolver->resolve('cataloginventory_stock_status', $dimensions), + ['product_id', 'website_id', 'stock_status'] + ); + if ($productIds) { + $select->where('product_id IN (?)', $productIds); + } + $catalogProductIndexInventorySelect[] = $select; + } + } + + $catalogProductIndexInventoryUnionSelect = $connection->select()->union($catalogProductIndexInventorySelect); + + $result = []; + foreach ($connection->fetchAll($catalogProductIndexInventoryUnionSelect) as $row) { + $result[$row['website_id']][$row['product_id']] = (int) $row['stock_status']; + } + + return $result; + } + + /** + * Retrieve inventory data for product + * + * @param null|array $productIds + * @param int $websiteId + * @return array + */ + public function getInventoryIndexData($productIds, $websiteId = null) + { + $this->websiteId = $websiteId; + $inventoryProductsIndexData = $this->getCatalogProductInventoryData($productIds); + $this->websiteId = null; + + if (!isset($inventoryProductsIndexData[(int) $websiteId])) { + return []; + } + + return $inventoryProductsIndexData[(int) $websiteId]; + } + /** * Prepare system index data for products. * diff --git a/app/code/Magento/Elasticsearch/Model/Adapter/BatchDataMapper/InventoryFieldsProvider.php b/app/code/Magento/Elasticsearch/Model/Adapter/BatchDataMapper/InventoryFieldsProvider.php new file mode 100644 index 0000000000000..f1f0fba7e20b1 --- /dev/null +++ b/app/code/Magento/Elasticsearch/Model/Adapter/BatchDataMapper/InventoryFieldsProvider.php @@ -0,0 +1,117 @@ +resourceIndex = $resourceIndex; + $this->storeManager = $storeManager; + $this->attributeAdapterProvider = $attributeAdapterProvider; + $this->fieldNameResolver = $fieldNameResolver; + $this->scopeConfig = $scopeConfig; + } + + /** + * @inheritdoc + */ + public function getFields(array $productIds, $storeId) + { + $fields = []; + + if ($this->hasShowOutOfStockStatus()) { + $inventoryData = $this->resourceIndex->getInventoryIndexData($productIds); + foreach ($productIds as $productId) { + $fields[$productId] = $this->getProductInventoryData($productId, $inventoryData); + } + } + return $fields; + } + + /** + * Prepare inventory index for product. + * + * @param int $productId + * @param array $inventoryData + * @return array + */ + private function getProductInventoryData($productId, array $inventoryData) + { + $result = []; + if (array_key_exists($productId, $inventoryData)) { + $inStockAttribute = $this->attributeAdapterProvider->getByAttributeCode(self::IS_SALABLE); + $fieldName = $this->fieldNameResolver->getFieldName($inStockAttribute); + $result[$fieldName] = $inventoryData[$productId]; + } + + return $result; + } + + /** + * Returns if display out of stock status set or not in catalog inventory + * + * @return bool + */ + private function hasShowOutOfStockStatus(): bool + { + return (bool) $this->scopeConfig->getValue( + \Magento\CatalogInventory\Model\Configuration::XML_PATH_SHOW_OUT_OF_STOCK, + \Magento\Store\Model\ScopeInterface::SCOPE_STORE + ); + } +} diff --git a/app/code/Magento/Elasticsearch/Model/Adapter/FieldMapper/Product/FieldProvider/FieldName/Resolver/IsSalable.php b/app/code/Magento/Elasticsearch/Model/Adapter/FieldMapper/Product/FieldProvider/FieldName/Resolver/IsSalable.php new file mode 100644 index 0000000000000..5e217bf961be0 --- /dev/null +++ b/app/code/Magento/Elasticsearch/Model/Adapter/FieldMapper/Product/FieldProvider/FieldName/Resolver/IsSalable.php @@ -0,0 +1,31 @@ +getAttributeCode() === InventoryFieldsProvider::IS_SALABLE) { + return $attribute->getAttributeCode(); + } + + return null; + } +} diff --git a/app/code/Magento/Elasticsearch/Model/ResourceModel/Fulltext/Collection/SearchResultApplier.php b/app/code/Magento/Elasticsearch/Model/ResourceModel/Fulltext/Collection/SearchResultApplier.php index 97cb92ab3b06d..8933e03be1c88 100644 --- a/app/code/Magento/Elasticsearch/Model/ResourceModel/Fulltext/Collection/SearchResultApplier.php +++ b/app/code/Magento/Elasticsearch/Model/ResourceModel/Fulltext/Collection/SearchResultApplier.php @@ -3,12 +3,13 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ +declare(strict_types=1); namespace Magento\Elasticsearch\Model\ResourceModel\Fulltext\Collection; use Magento\Catalog\Api\Data\ProductInterface; -use Magento\CatalogInventory\Model\StockStatusApplierInterface; use Magento\CatalogInventory\Model\ResourceModel\StockStatusFilterInterface; +use Magento\CatalogInventory\Model\StockStatusApplierInterface; use Magento\CatalogSearch\Model\ResourceModel\Fulltext\Collection\SearchResultApplierInterface; use Magento\Framework\Api\Search\SearchResultInterface; use Magento\Framework\App\Config\ScopeConfigInterface; @@ -105,14 +106,12 @@ public function apply() return; } - $ids = $this->getProductIdsBySaleability(); - - if (count($ids) == 0) { - $items = $this->sliceItems($this->searchResult->getItems(), $this->size, $this->currentPage); - foreach ($items as $item) { - $ids[] = (int)$item->getId(); - } + $ids = []; + $items = $this->sliceItems($this->searchResult->getItems(), $this->size, $this->currentPage); + foreach ($items as $item) { + $ids[] = (int)$item->getId(); } + $orderList = implode(',', $ids); $this->collection->getSelect() ->where('e.entity_id IN (?)', $ids) @@ -139,7 +138,7 @@ private function sliceItems(array $items, int $size, int $currentPage): array $currentPage = 1; } if ($currentPage > $maxAllowedPageNumber) { - $currentPage = $maxAllowedPageNumber; + $currentPage = (int) $maxAllowedPageNumber; } $offset = $this->getOffset($currentPage, $size); @@ -164,6 +163,9 @@ private function getOffset(int $pageNumber, int $pageSize): int * Fetch filtered product ids sorted by the saleability and other applied sort orders * * @return array + * @deprecated + * @see Not use anymore + * @SuppressWarnings(PHPMD.UnusedPrivateMethod) */ private function getProductIdsBySaleability(): array { @@ -204,6 +206,8 @@ private function getProductIdsBySaleability(): array * @return array * @throws \Magento\Framework\Exception\LocalizedException * @throws \Exception + * @deprecated + * @see Not use anymore */ private function categoryProductByCustomSortOrder(int $categoryId): array { @@ -275,6 +279,8 @@ private function categoryProductByCustomSortOrder(int $categoryId): array * Returns if display out of stock status set or not in catalog inventory * * @return bool + * @deprecated + * @see Not use anymore */ private function hasShowOutOfStockStatus(): bool { diff --git a/app/code/Magento/Elasticsearch/SearchAdapter/Query/Builder/Sort.php b/app/code/Magento/Elasticsearch/SearchAdapter/Query/Builder/Sort.php index 7d41d54fb22a5..23b7f861dc761 100644 --- a/app/code/Magento/Elasticsearch/SearchAdapter/Query/Builder/Sort.php +++ b/app/code/Magento/Elasticsearch/SearchAdapter/Query/Builder/Sort.php @@ -1,15 +1,21 @@ attributeAdapterProvider = $attributeAdapterProvider; $this->fieldNameResolver = $fieldNameResolver; $this->skippedFields = array_merge(self::DEFAULT_SKIPPED_FIELDS, $skippedFields); $this->map = array_merge(self::DEFAULT_MAP, $map); + $this->scopeConfig = $scopeConfig ?: ObjectManager::getInstance()->get(ScopeConfigInterface::class); } /** @@ -80,7 +94,8 @@ public function __construct( */ public function getSort(RequestInterface $request) { - $sorts = []; + $sorts = $this->getSortBySaleability(); + /** * Temporary solution for an existing interface of a fulltext search request in Backward compatibility purposes. * Scope to split Search request interface on two different 'Search' and 'Fulltext Search' contains in MC-16461. @@ -124,4 +139,39 @@ public function getSort(RequestInterface $request) return $sorts; } + + /** + * Prepare sort by saleability. + * + * @return array + */ + private function getSortBySaleability() + { + $sorts = []; + + if ($this->hasShowOutOfStockStatus()) { + $attribute = $this->attributeAdapterProvider->getByAttributeCode(InventoryFieldsProvider::IS_SALABLE); + $fieldName = $this->fieldNameResolver->getFieldName($attribute); + $sorts[] = [ + $fieldName => [ + 'order' => Collection::SORT_ORDER_DESC + ] + ]; + } + + return $sorts; + } + + /** + * Returns if display out of stock status set or not in catalog inventory + * + * @return bool + */ + private function hasShowOutOfStockStatus(): bool + { + return (bool) $this->scopeConfig->getValue( + \Magento\CatalogInventory\Model\Configuration::XML_PATH_SHOW_OUT_OF_STOCK, + \Magento\Store\Model\ScopeInterface::SCOPE_STORE + ); + } } diff --git a/app/code/Magento/Elasticsearch/Test/Unit/SearchAdapter/Query/Builder/SortTest.php b/app/code/Magento/Elasticsearch/Test/Unit/SearchAdapter/Query/Builder/SortTest.php index 689384f95a896..e6515384419dd 100644 --- a/app/code/Magento/Elasticsearch/Test/Unit/SearchAdapter/Query/Builder/SortTest.php +++ b/app/code/Magento/Elasticsearch/Test/Unit/SearchAdapter/Query/Builder/SortTest.php @@ -7,11 +7,13 @@ namespace Magento\Elasticsearch\Test\Unit\SearchAdapter\Query\Builder; +use Magento\Elasticsearch\Model\Adapter\BatchDataMapper\InventoryFieldsProvider; use Magento\Elasticsearch\Model\Adapter\FieldMapper\Product\AttributeAdapter; use Magento\Elasticsearch\Model\Adapter\FieldMapper\Product\AttributeProvider; use Magento\Elasticsearch\Model\Adapter\FieldMapper\Product\FieldProvider\FieldName\ResolverInterface as FieldNameResolver; use Magento\Elasticsearch\SearchAdapter\Query\Builder\Sort; +use Magento\Framework\App\Config\ScopeConfigInterface; use Magento\Framework\Search\RequestInterface; use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; use PHPUnit\Framework\MockObject\MockObject; @@ -34,6 +36,11 @@ class SortTest extends TestCase */ private $sortBuilder; + /** + * @var ScopeConfigInterface + */ + private $scopeConfig; + /** * @inheritdoc */ @@ -48,11 +55,15 @@ protected function setUp(): void ->setMethods(['getFieldName']) ->getMock(); + $this->scopeConfig = $this->getMockBuilder(ScopeConfigInterface::class) + ->getMock(); + $this->sortBuilder = (new ObjectManager($this))->getObject( Sort::class, [ 'attributeAdapterProvider' => $this->attributeAdapterProvider, 'fieldNameResolver' => $this->fieldNameResolver, + 'scopeConfig' => $this->scopeConfig, ] ); } @@ -66,6 +77,7 @@ protected function setUp(): void * @param $isFloatType * @param $isIntegerType * @param $fieldName + * @param $showOutStock * @param array $expected */ public function testGetSort( @@ -75,6 +87,7 @@ public function testGetSort( $isIntegerType, $isComplexType, $fieldName, + $showOutStock, array $expected ) { /** @var MockObject|RequestInterface $request */ @@ -87,7 +100,7 @@ public function testGetSort( ->willReturn($sortItems); $attributeMock = $this->getMockBuilder(AttributeAdapter::class) ->disableOriginalConstructor() - ->setMethods(['isSortable', 'isFloatType', 'isIntegerType', 'isComplexType']) + ->setMethods(['isSortable', 'isFloatType', 'isIntegerType', 'isComplexType', 'getAttributeCode']) ->getMock(); $attributeMock->expects($this->any()) ->method('isSortable') @@ -101,22 +114,55 @@ public function testGetSort( $attributeMock->expects($this->any()) ->method('isComplexType') ->willReturn($isComplexType); + $attributeMock->expects($this->any()) + ->method('getAttributeCode') + ->willReturn((string) $fieldName); + + $salesAttributeMock = $this->getMockBuilder(AttributeAdapter::class) + ->disableOriginalConstructor() + ->setMethods(['getAttributeCode']) + ->getMock(); + $salesAttributeMock->expects($this->any()) + ->method('getAttributeCode') + ->willReturn(InventoryFieldsProvider::IS_SALABLE); + + $maps = [ + [null, $attributeMock], + [ InventoryFieldsProvider::IS_SALABLE, $salesAttributeMock ], + [ $fieldName, $attributeMock ], + ]; + foreach ($sortItems as $item) { + $maps[] = [$item['field'], $attributeMock]; + } $this->attributeAdapterProvider->expects($this->any()) ->method('getByAttributeCode') - ->with($this->anything()) - ->willReturn($attributeMock); + ->will( + $this->returnValueMap( + $maps + ) + ); + $this->fieldNameResolver->expects($this->any()) ->method('getFieldName') ->with($this->anything()) ->willReturnCallback( function ($attribute, $context) use ($fieldName) { - if (empty($context)) { - return $fieldName; - } elseif ($context['type'] === 'sort') { - return 'sort_' . $fieldName; + if ($attribute->getAttributeCode() === InventoryFieldsProvider::IS_SALABLE) { + return InventoryFieldsProvider::IS_SALABLE; + } + if ($attribute->getAttributeCode() === $fieldName) { + if (empty($context)) { + return $fieldName; + } elseif ($context['type'] === 'sort') { + return 'sort_' . $fieldName; + } } } ); + $this->scopeConfig->expects($this->any()) + ->method('getValue') + ->with($this->anything()) + ->willReturn($showOutStock); $this->assertEquals( $expected, @@ -143,6 +189,7 @@ public function getSortProvider() false, false, null, + false, [] ], [ @@ -161,6 +208,7 @@ public function getSortProvider() false, false, 'price', + false, [ [ 'price' => [ @@ -185,6 +233,7 @@ public function getSortProvider() true, false, 'price', + false, [ [ 'price' => [ @@ -209,6 +258,7 @@ public function getSortProvider() false, false, 'name', + false, [ [ 'name.sort_name' => [ @@ -233,6 +283,7 @@ public function getSortProvider() false, false, 'not_eav_attribute', + false, [ [ 'not_eav_attribute' => [ @@ -257,6 +308,7 @@ public function getSortProvider() false, true, 'color', + false, [ [ 'color_value.sort_color' => [ @@ -264,7 +316,178 @@ public function getSortProvider() ] ] ] - ] + ], + [ + [ + [ + 'field' => 'entity_id', + 'direction' => 'DESC' + ] + ], + false, + false, + false, + false, + null, + true, + [ + [ + InventoryFieldsProvider::IS_SALABLE => [ + 'order' => 'DESC' + ] + ] + ] + ], + [ + [ + [ + 'field' => 'entity_id', + 'direction' => 'DESC' + ], + [ + 'field' => 'price', + 'direction' => 'DESC' + ], + ], + false, + false, + false, + false, + 'price', + true, + [ + [ + InventoryFieldsProvider::IS_SALABLE => [ + 'order' => 'DESC' + ] + ], + [ + 'price' => [ + 'order' => 'desc' + ] + ] + ] + ], + [ + [ + [ + 'field' => 'entity_id', + 'direction' => 'DESC' + ], + [ + 'field' => 'price', + 'direction' => 'DESC' + ], + ], + true, + true, + true, + false, + 'price', + true, + [ + [ + InventoryFieldsProvider::IS_SALABLE => [ + 'order' => 'DESC' + ] + ], + [ + 'price' => [ + 'order' => 'desc' + ] + ] + ] + ], + [ + [ + [ + 'field' => 'entity_id', + 'direction' => 'DESC' + ], + [ + 'field' => 'name', + 'direction' => 'DESC' + ], + ], + true, + false, + false, + false, + 'name', + true, + [ + [ + InventoryFieldsProvider::IS_SALABLE => [ + 'order' => 'DESC' + ] + ], + [ + 'name.sort_name' => [ + 'order' => 'desc' + ] + ] + ] + ], + [ + [ + [ + 'field' => 'entity_id', + 'direction' => 'DESC' + ], + [ + 'field' => 'not_eav_attribute', + 'direction' => 'DESC' + ], + ], + false, + false, + false, + false, + 'not_eav_attribute', + true, + [ + [ + InventoryFieldsProvider::IS_SALABLE => [ + 'order' => 'DESC' + ] + ], + [ + 'not_eav_attribute' => [ + 'order' => 'desc' + ] + ] + ] + ], + [ + [ + [ + 'field' => 'entity_id', + 'direction' => 'DESC' + ], + [ + 'field' => 'color', + 'direction' => 'DESC' + ], + ], + true, + false, + false, + true, + 'color', + true, + [ + [ + InventoryFieldsProvider::IS_SALABLE => [ + 'order' => 'DESC' + ] + ], + [ + 'color_value.sort_color' => [ + 'order' => 'desc' + ] + ] + ] + ], ]; } } diff --git a/app/code/Magento/Elasticsearch/etc/di.xml b/app/code/Magento/Elasticsearch/etc/di.xml index 95aec47dbf1f6..24c10f840252a 100644 --- a/app/code/Magento/Elasticsearch/etc/di.xml +++ b/app/code/Magento/Elasticsearch/etc/di.xml @@ -148,6 +148,7 @@ Magento\Elasticsearch\Elasticsearch5\Model\Adapter\BatchDataMapper\CategoryFieldsProviderProxy Magento\Elasticsearch\Model\Adapter\BatchDataMapper\PriceFieldsProvider + Magento\Elasticsearch\Model\Adapter\BatchDataMapper\InventoryFieldsProvider @@ -363,6 +364,7 @@ \Magento\Elasticsearch\Model\Adapter\FieldMapper\Product\FieldProvider\FieldName\Resolver\Price \Magento\Elasticsearch\Model\Adapter\FieldMapper\Product\FieldProvider\FieldName\Resolver\CategoryName \Magento\Elasticsearch\Model\Adapter\FieldMapper\Product\FieldProvider\FieldName\Resolver\Position + \Magento\Elasticsearch\Model\Adapter\FieldMapper\Product\FieldProvider\FieldName\Resolver\IsSalable \Magento\Elasticsearch\Model\Adapter\FieldMapper\Product\FieldProvider\FieldName\Resolver\DefaultResolver @@ -375,6 +377,7 @@ \Magento\Elasticsearch\Model\Adapter\FieldMapper\Product\FieldProvider\FieldName\Resolver\Price \Magento\Elasticsearch\Model\Adapter\FieldMapper\Product\FieldProvider\FieldName\Resolver\CategoryName \Magento\Elasticsearch\Model\Adapter\FieldMapper\Product\FieldProvider\FieldName\Resolver\Position + \Magento\Elasticsearch\Model\Adapter\FieldMapper\Product\FieldProvider\FieldName\Resolver\IsSalable elasticsearch5FieldNameDefaultResolver