From f4f76f2df3de36d09fe684d14068e844c0513887 Mon Sep 17 00:00:00 2001 From: Jeanmarcos Juarez Date: Fri, 10 Jul 2026 22:29:00 -0400 Subject: [PATCH 1/4] fix(inventory-indexer): sort stock index rows by sku to avoid deadlocks --- .../Indexer/SelectBuilder.php | 3 +- .../Test/Unit/Indexer/SelectBuilderTest.php | 74 +++++++++++++++ .../Indexer/SelectBuilder.php | 3 +- .../Test/Unit/Indexer/SelectBuilderTest.php | 89 +++++++++++++++++++ .../Indexer/SelectBuilder.php | 2 + .../Test/Unit/Indexer/SelectBuilderTest.php | 84 +++++++++++++++++ InventoryIndexer/Indexer/SelectBuilder.php | 3 +- .../Test/Unit/Indexer/SelectBuilderTest.php | 82 +++++++++++++++++ 8 files changed, 337 insertions(+), 3 deletions(-) create mode 100644 InventoryBundleProductIndexer/Test/Unit/Indexer/SelectBuilderTest.php create mode 100644 InventoryConfigurableProductIndexer/Test/Unit/Indexer/SelectBuilderTest.php create mode 100644 InventoryGroupedProductIndexer/Test/Unit/Indexer/SelectBuilderTest.php create mode 100644 InventoryIndexer/Test/Unit/Indexer/SelectBuilderTest.php diff --git a/InventoryBundleProductIndexer/Indexer/SelectBuilder.php b/InventoryBundleProductIndexer/Indexer/SelectBuilder.php index 40dc98e0a145..7cf4c27f77bb 100644 --- a/InventoryBundleProductIndexer/Indexer/SelectBuilder.php +++ b/InventoryBundleProductIndexer/Indexer/SelectBuilder.php @@ -99,7 +99,8 @@ public function getSelect(int $stockId, array $skuList = [], IndexAlias $indexAl '0', 'MAX(' . $isRequiredOptionUnavailable . ') = 0 AND MAX(options.stock_status) = 1' ), - ]); + ]) + ->order('product_entity.sku ASC'); if (!empty($skuList)) { $select->where('product_entity.sku IN (?)', $skuList); diff --git a/InventoryBundleProductIndexer/Test/Unit/Indexer/SelectBuilderTest.php b/InventoryBundleProductIndexer/Test/Unit/Indexer/SelectBuilderTest.php new file mode 100644 index 000000000000..f037d53de038 --- /dev/null +++ b/InventoryBundleProductIndexer/Test/Unit/Indexer/SelectBuilderTest.php @@ -0,0 +1,74 @@ +connection = $this->createMock(AdapterInterface::class); + $this->connection->method('getCheckSql')->willReturn('check_expression'); + $this->connection->method('getIfNullSql')->willReturn('ifnull_expression'); + + $resourceConnection = $this->createMock(ResourceConnection::class); + $resourceConnection->method('getConnection')->willReturn($this->connection); + $resourceConnection->method('getTableName')->willReturnArgument(0); + + $defaultStockProvider = $this->createMock(DefaultStockProviderInterface::class); + $defaultStockProvider->method('getId')->willReturn(1); + + $optionsStatusSelectBuilder = $this->createMock(OptionsStatusSelectBuilder::class); + $optionsStatusSelectBuilder->method('execute')->willReturn($this->createMock(Select::class)); + + $configuration = $this->createMock(InventoryConfigurationInterface::class); + $configuration->method('getManageStock')->willReturn(1); + + $this->selectBuilder = new SelectBuilder( + $resourceConnection, + $defaultStockProvider, + $optionsStatusSelectBuilder, + $configuration + ); + } + + public function testGetSelectOrdersBySkuAscending(): void + { + $select = $this->createMock(Select::class); + foreach (['from', 'joinLeft', 'where', 'group', 'columns'] as $method) { + $select->method($method)->willReturnSelf(); + } + $this->connection->method('select')->willReturn($select); + + $select->expects(self::once()) + ->method('order') + ->with('product_entity.sku ASC') + ->willReturnSelf(); + + $this->selectBuilder->getSelect(2, ['bundle_1']); + } +} diff --git a/InventoryConfigurableProductIndexer/Indexer/SelectBuilder.php b/InventoryConfigurableProductIndexer/Indexer/SelectBuilder.php index ebc6ac10a504..953c42632e63 100644 --- a/InventoryConfigurableProductIndexer/Indexer/SelectBuilder.php +++ b/InventoryConfigurableProductIndexer/Indexer/SelectBuilder.php @@ -87,7 +87,8 @@ public function getSelect(int $stockId, array $skuList = [], IndexAlias $indexAl . ' AND inventory_stock_item.stock_id = ' . $this->defaultStockProvider->getId(), [] ) - ->group(['parent_product_entity.sku']); + ->group(['parent_product_entity.sku']) + ->order('parent_product_entity.sku ASC'); if ($skuList) { $select->where('parent_product_entity.sku IN (?)', $skuList); diff --git a/InventoryConfigurableProductIndexer/Test/Unit/Indexer/SelectBuilderTest.php b/InventoryConfigurableProductIndexer/Test/Unit/Indexer/SelectBuilderTest.php new file mode 100644 index 000000000000..6ef8975559dd --- /dev/null +++ b/InventoryConfigurableProductIndexer/Test/Unit/Indexer/SelectBuilderTest.php @@ -0,0 +1,89 @@ +connection = $this->createMock(AdapterInterface::class); + + $resourceConnection = $this->createMock(ResourceConnection::class); + $resourceConnection->method('getConnection')->willReturn($this->connection); + $resourceConnection->method('getTableName')->willReturnArgument(0); + + $indexNameBuilder = $this->createMock(IndexNameBuilder::class); + $indexNameBuilder->method('setIndexId')->willReturnSelf(); + $indexNameBuilder->method('addDimension')->willReturnSelf(); + $indexNameBuilder->method('setAlias')->willReturnSelf(); + $indexNameBuilder->method('build')->willReturn($this->createMock(IndexName::class)); + + $indexNameResolver = $this->createMock(IndexNameResolverInterface::class); + $indexNameResolver->method('resolveName')->willReturn('inventory_stock_2'); + + $metadata = $this->createMock(EntityMetadataInterface::class); + $metadata->method('getLinkField')->willReturn('row_id'); + $metadataPool = $this->createMock(MetadataPool::class); + $metadataPool->method('getMetadata')->willReturn($metadata); + + $defaultStockProvider = $this->createMock(DefaultStockProviderInterface::class); + $defaultStockProvider->method('getId')->willReturn(1); + + $configuration = $this->createMock(InventoryConfigurationInterface::class); + $configuration->method('getManageStock')->willReturn(1); + + $this->selectBuilder = new SelectBuilder( + $resourceConnection, + $indexNameBuilder, + $indexNameResolver, + $metadataPool, + $defaultStockProvider, + $configuration + ); + } + + public function testGetSelectOrdersBySkuAscending(): void + { + $select = $this->createMock(Select::class); + foreach (['from', 'joinInner', 'joinLeft', 'where', 'group'] as $method) { + $select->method($method)->willReturnSelf(); + } + $this->connection->method('select')->willReturn($select); + + $select->expects(self::once()) + ->method('order') + ->with('parent_product_entity.sku ASC') + ->willReturnSelf(); + + $this->selectBuilder->getSelect(2, ['configurable_1']); + } +} diff --git a/InventoryGroupedProductIndexer/Indexer/SelectBuilder.php b/InventoryGroupedProductIndexer/Indexer/SelectBuilder.php index e6a586cce7ef..7acc4d507d20 100644 --- a/InventoryGroupedProductIndexer/Indexer/SelectBuilder.php +++ b/InventoryGroupedProductIndexer/Indexer/SelectBuilder.php @@ -99,6 +99,8 @@ public function getSelect(int $stockId, array $skuList = [], IndexAlias $indexAl 'parent_link.link_type_id = ' . Link::LINK_TYPE_GROUPED )->group( ['parent_product_entity.sku'] + )->order( + 'parent_product_entity.sku ASC' ); if ($skuList) { diff --git a/InventoryGroupedProductIndexer/Test/Unit/Indexer/SelectBuilderTest.php b/InventoryGroupedProductIndexer/Test/Unit/Indexer/SelectBuilderTest.php new file mode 100644 index 000000000000..928ed021ca14 --- /dev/null +++ b/InventoryGroupedProductIndexer/Test/Unit/Indexer/SelectBuilderTest.php @@ -0,0 +1,84 @@ +connection = $this->createMock(AdapterInterface::class); + + $resourceConnection = $this->createMock(ResourceConnection::class); + $resourceConnection->method('getConnection')->willReturn($this->connection); + $resourceConnection->method('getTableName')->willReturnArgument(0); + + $indexNameBuilder = $this->createMock(IndexNameBuilder::class); + $indexNameBuilder->method('setIndexId')->willReturnSelf(); + $indexNameBuilder->method('addDimension')->willReturnSelf(); + $indexNameBuilder->method('setAlias')->willReturnSelf(); + $indexNameBuilder->method('build')->willReturn($this->createMock(IndexName::class)); + + $indexNameResolver = $this->createMock(IndexNameResolverInterface::class); + $indexNameResolver->method('resolveName')->willReturn('inventory_stock_2'); + + $metadata = $this->createMock(EntityMetadataInterface::class); + $metadata->method('getLinkField')->willReturn('row_id'); + $metadataPool = $this->createMock(MetadataPool::class); + $metadataPool->method('getMetadata')->willReturn($metadata); + + $configuration = $this->createMock(InventoryConfigurationInterface::class); + $configuration->method('getManageStock')->willReturn(1); + + $this->selectBuilder = new SelectBuilder( + $resourceConnection, + $indexNameBuilder, + $indexNameResolver, + $metadataPool, + $configuration + ); + } + + public function testGetSelectOrdersBySkuAscending(): void + { + $select = $this->createMock(Select::class); + foreach (['from', 'joinInner', 'joinLeft', 'where', 'group'] as $method) { + $select->method($method)->willReturnSelf(); + } + $this->connection->method('select')->willReturn($select); + + $select->expects(self::once()) + ->method('order') + ->with('parent_product_entity.sku ASC') + ->willReturnSelf(); + + $this->selectBuilder->getSelect(2, ['grouped_1']); + } +} diff --git a/InventoryIndexer/Indexer/SelectBuilder.php b/InventoryIndexer/Indexer/SelectBuilder.php index a6b9dcba2b88..03c05cebf5f4 100644 --- a/InventoryIndexer/Indexer/SelectBuilder.php +++ b/InventoryIndexer/Indexer/SelectBuilder.php @@ -115,7 +115,8 @@ public function getSelect(int $stockId, array $skuList = []): Select ] ) ->where('source_item.' . SourceItemInterface::SOURCE_CODE . ' IN (?)', $sourceCodes) - ->group(['source_item.' .SourceItemInterface::SKU]); + ->group(['source_item.' .SourceItemInterface::SKU]) + ->order('source_item.' . SourceItemInterface::SKU . ' ASC'); if ($skuList) { $select->where('source_item.' . SourceItemInterface::SKU . ' IN (?)', $skuList); diff --git a/InventoryIndexer/Test/Unit/Indexer/SelectBuilderTest.php b/InventoryIndexer/Test/Unit/Indexer/SelectBuilderTest.php new file mode 100644 index 000000000000..635acff91189 --- /dev/null +++ b/InventoryIndexer/Test/Unit/Indexer/SelectBuilderTest.php @@ -0,0 +1,82 @@ +connection = $this->createMock(AdapterInterface::class); + $this->connection->method('getCheckSql')->willReturn('quantity_expression'); + $this->connection->method('fetchCol')->willReturn(['default']); + + $resourceConnection = $this->createMock(ResourceConnection::class); + $resourceConnection->method('getConnection')->willReturn($this->connection); + $resourceConnection->method('getTableName')->willReturnArgument(0); + + $salableCondition = $this->createMock(GetIsStockItemSalableConditionInterface::class); + $salableCondition->method('execute')->willReturn('is_salable_expression'); + + $reservationsIndexTable = $this->createMock(ReservationsIndexTable::class); + $reservationsIndexTable->method('getTableName')->willReturn('reservations_temp'); + + $this->selectBuilder = new SelectBuilder( + $resourceConnection, + $salableCondition, + 'catalog_product_entity', + $reservationsIndexTable + ); + } + + public function testGetSelectOrdersBySkuAscending(): void + { + $sourceCodesSelect = $this->createSelfReturningSelect(); + $indexSelect = $this->createSelfReturningSelect(); + $this->connection->method('select') + ->willReturnOnConsecutiveCalls($sourceCodesSelect, $indexSelect); + + $indexSelect->expects(self::once()) + ->method('order') + ->with('source_item.sku ASC') + ->willReturnSelf(); + + $this->selectBuilder->getSelect(2, ['sku1', 'sku2']); + } + + /** + * @return Select|MockObject + */ + private function createSelfReturningSelect() + { + $select = $this->createMock(Select::class); + foreach (['from', 'joinLeft', 'joinInner', 'where', 'group', 'columns'] as $method) { + $select->method($method)->willReturnSelf(); + } + + return $select; + } +} From d4a617b9c4ff7225d8abd3077854a04fcc142250 Mon Sep 17 00:00:00 2001 From: Jeanmarcos Juarez Date: Thu, 30 Jul 2026 10:25:44 -0400 Subject: [PATCH 2/4] fix(inventory-configurable-product-indexer): exclude disabled child products from parent stock index [picked #3241] --- .../Indexer/SelectBuilder.php | 31 +++- ...VisibleAfterDisablingChildProductsTest.xml | 140 ++++++++++++++++++ .../Test/Unit/Indexer/SelectBuilderTest.php | 11 ++ 3 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 InventoryConfigurableProductIndexer/Test/Mftf/Test/ConfigurableProductNotVisibleAfterDisablingChildProductsTest.xml diff --git a/InventoryConfigurableProductIndexer/Indexer/SelectBuilder.php b/InventoryConfigurableProductIndexer/Indexer/SelectBuilder.php index 953c42632e63..530f750ecc06 100644 --- a/InventoryConfigurableProductIndexer/Indexer/SelectBuilder.php +++ b/InventoryConfigurableProductIndexer/Indexer/SelectBuilder.php @@ -8,6 +8,10 @@ namespace Magento\InventoryConfigurableProductIndexer\Indexer; use Magento\Catalog\Api\Data\ProductInterface; +use Magento\Catalog\Model\Product; +use Magento\Catalog\Model\Product\Attribute\Source\Status as ProductStatus; +use Magento\Catalog\Model\ResourceModel\Eav\Attribute; +use Magento\Eav\Model\Config; use Magento\Framework\App\ResourceConnection; use Magento\Framework\DB\Select; use Magento\Framework\EntityManager\MetadataPool; @@ -20,6 +24,11 @@ use Magento\InventoryMultiDimensionalIndexerApi\Model\IndexNameBuilder; use Magento\InventoryMultiDimensionalIndexerApi\Model\IndexNameResolverInterface; +/** + * Get configurable product for given stock select builder + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ class SelectBuilder implements SiblingSelectBuilderInterface { /** @@ -28,6 +37,7 @@ class SelectBuilder implements SiblingSelectBuilderInterface * @param IndexNameResolverInterface $indexNameResolver * @param MetadataPool $metadataPool * @param DefaultStockProviderInterface $defaultStockProvider + * @param Config $eavConfig * @param InventoryConfigurationInterface $configuration */ public function __construct( @@ -36,6 +46,7 @@ public function __construct( private readonly IndexNameResolverInterface $indexNameResolver, private readonly MetadataPool $metadataPool, private readonly DefaultStockProviderInterface $defaultStockProvider, + private readonly Config $eavConfig, private readonly InventoryConfigurationInterface $configuration ) { } @@ -53,13 +64,14 @@ public function getSelect(int $stockId, array $skuList = [], IndexAlias $indexAl $indexTableName = $this->indexNameResolver->resolveName($indexName); $metadata = $this->metadataPool->getMetadata(ProductInterface::class); $linkField = $metadata->getLinkField(); + $statusAttributeId = $this->getAttribute(ProductInterface::STATUS)->getId(); $manageStock = '(inventory_stock_item.use_config_manage_stock = 0 AND inventory_stock_item.manage_stock = 1)'; if (((int)$this->configuration->getManageStock()) === 1) { $manageStock .= ' OR inventory_stock_item.use_config_manage_stock = 1'; $manageStock = "($manageStock)"; } - + $select = $connection->select() ->from( ['stock' => $indexTableName], @@ -86,6 +98,12 @@ public function getSelect(int $stockId, array $skuList = [], IndexAlias $indexAl 'inventory_stock_item.product_id = parent_product_entity.entity_id' . ' AND inventory_stock_item.stock_id = ' . $this->defaultStockProvider->getId(), [] + )->joinInner( + ['product_status' => $this->resourceConnection->getTableName('catalog_product_entity_int')], + "product_entity.$linkField = product_status.$linkField" + . " AND product_status.attribute_id = $statusAttributeId" + . ' AND product_status.value = ' . ProductStatus::STATUS_ENABLED, + [] ) ->group(['parent_product_entity.sku']) ->order('parent_product_entity.sku ASC'); @@ -96,4 +114,15 @@ public function getSelect(int $stockId, array $skuList = [], IndexAlias $indexAl return $select; } + + /** + * Retrieve catalog_product attribute instance by attribute code + * + * @param string $attributeCode + * @return Attribute + */ + private function getAttribute($attributeCode): Attribute + { + return $this->eavConfig->getAttribute(Product::ENTITY, $attributeCode); + } } diff --git a/InventoryConfigurableProductIndexer/Test/Mftf/Test/ConfigurableProductNotVisibleAfterDisablingChildProductsTest.xml b/InventoryConfigurableProductIndexer/Test/Mftf/Test/ConfigurableProductNotVisibleAfterDisablingChildProductsTest.xml new file mode 100644 index 000000000000..ee45d5db6cd8 --- /dev/null +++ b/InventoryConfigurableProductIndexer/Test/Mftf/Test/ConfigurableProductNotVisibleAfterDisablingChildProductsTest.xml @@ -0,0 +1,140 @@ + + + + + + + + + <description value="Verify, configurable product is not displayed on category page after disabling first child product and set out of stock to second"/> + <testCaseId value="MC-38896"/> + <useCaseId value="MC-38590"/> + <severity value="AVERAGE"/> + <group value="msi"/> + </annotations> + <before> + <!--Create test data.--> + <!-- Create the category to put the product in --> + <createData entity="ApiCategory" stepKey="createCategory"/> + <!-- Create the configurable product based on the data in the /data folder --> + <createData entity="ApiConfigurableProduct" stepKey="createConfigProduct"> + <requiredEntity createDataKey="createCategory"/> + </createData> + <!-- Make the configurable product have two options, that are children of the default attribute set --> + <createData entity="productAttributeWithTwoOptions" stepKey="createConfigProductAttribute"/> + <createData entity="productAttributeOption1" stepKey="createFirstConfigProductAttributeOption"> + <requiredEntity createDataKey="createConfigProductAttribute"/> + </createData> + <createData entity="productAttributeOption2" stepKey="createSecondConfigProductAttributeOption"> + <requiredEntity createDataKey="createConfigProductAttribute"/> + </createData> + <createData entity="AddToDefaultSet" stepKey="createConfigAddToAttributeSet"> + <requiredEntity createDataKey="createConfigProductAttribute"/> + </createData> + <getData entity="ProductAttributeOptionGetter" index="1" stepKey="getFirstConfigAttributeOption"> + <requiredEntity createDataKey="createConfigProductAttribute"/> + </getData> + <getData entity="ProductAttributeOptionGetter" index="2" stepKey="getSecondConfigAttributeOption"> + <requiredEntity createDataKey="createConfigProductAttribute"/> + </getData> + <!-- Create the 2 children that will be a part of the configurable product --> + <createData entity="ApiSimpleOne" stepKey="createFirstConfigChildProduct"> + <requiredEntity createDataKey="createConfigProductAttribute"/> + <requiredEntity createDataKey="getFirstConfigAttributeOption"/> + </createData> + <createData entity="ApiSimpleTwo" stepKey="createSecondConfigChildProduct"> + <requiredEntity createDataKey="createConfigProductAttribute"/> + <requiredEntity createDataKey="getSecondConfigAttributeOption"/> + </createData> + <!-- Assign the two products to the configurable product --> + <createData entity="ConfigurableProductTwoOptions" stepKey="createConfigProductOption"> + <requiredEntity createDataKey="createConfigProduct"/> + <requiredEntity createDataKey="createConfigProductAttribute"/> + <requiredEntity createDataKey="getFirstConfigAttributeOption"/> + <requiredEntity createDataKey="getSecondConfigAttributeOption"/> + </createData> + <createData entity="ConfigurableProductAddChild" stepKey="createFirstConfigProductAddChild"> + <requiredEntity createDataKey="createConfigProduct"/> + <requiredEntity createDataKey="createFirstConfigChildProduct"/> + </createData> + <createData entity="ConfigurableProductAddChild" stepKey="createSecondConfigProductAddChild"> + <requiredEntity createDataKey="createConfigProduct"/> + <requiredEntity createDataKey="createSecondConfigChildProduct"/> + </createData> + <createData entity="Simple_US_Customer" stepKey="customer"/> + <createData entity="_minimalSource" stepKey="createSource"/> + <createData entity="BasicMsiStockWithMainWebsite1" stepKey="stock"/> + <createData entity="SourceStockLinked1" stepKey="linkStockAndSource"> + <requiredEntity createDataKey="stock"/> + <requiredEntity createDataKey="createSource"/> + </createData> + <actionGroup ref="AdminLoginActionGroup" stepKey="loginToAdminArea"/> + <!--Assign additional source to configurable product.--> + <amOnPage url="{{AdminProductEditPage.url($createFirstConfigChildProduct.id$)}}" stepKey="openProductEditPage"/> + <actionGroup ref="UnassignSourceFromProductActionGroup" stepKey="unassignDefaultSourceFromProduct"> + <argument name="sourceCode" value="{{_defaultSource.name}}"/> + </actionGroup> + <actionGroup ref="AdminAssignSourceToProductAndSetSourceQuantityActionGroup" stepKey="assignCreatedSourceToFirstChildProduct"> + <argument name="sourceCode" value="$createSource.source[source_code]$"/> + </actionGroup> + <actionGroup ref="SaveProductFormActionGroup" stepKey="saveFirstChildProduct"/> + <!--Assign additional source to configurable product second.--> + <amOnPage url="{{AdminProductEditPage.url($createSecondConfigChildProduct.id$)}}" stepKey="openSecondProductEditPage"/> + <actionGroup ref="UnassignSourceFromProductActionGroup" stepKey="unassignDefaultSourceFromSecondProduct"> + <argument name="sourceCode" value="{{_defaultSource.name}}"/> + </actionGroup> + <actionGroup ref="AdminAssignSourceToProductAndSetSourceQuantityActionGroup" stepKey="assignCreatedSourceToSecondChildProduct"> + <argument name="sourceCode" value="$createSource.source[source_code]$"/> + </actionGroup> + <actionGroup ref="SaveProductFormActionGroup" stepKey="saveSecondChildProduct"/> + <actionGroup ref="AdminReindexAndFlushCache" stepKey="reindexAndFlushCache"/> + </before> + <after> + <deleteData createDataKey="createCategory" stepKey="deleteCategory"/> + <deleteData createDataKey="createConfigProduct" stepKey="deleteConfigProduct"/> + <deleteData createDataKey="createFirstConfigChildProduct" stepKey="deleteFirstConfigChildProduct"/> + <deleteData createDataKey="createSecondConfigChildProduct" stepKey="deleteSecondConfigChildProduct"/> + <deleteData createDataKey="createConfigProductAttribute" stepKey="deleteConfigProductAttribute"/> + <!--Assign Default Stock to Main Website.--> + <actionGroup ref="AssignWebsiteToStockActionGroup" stepKey="assignMainWebsiteToDefaultStock"> + <argument name="stockName" value="{{_defaultStock.name}}"/> + <argument name="websiteName" value="{{_defaultWebsite.name}}"/> + </actionGroup> + <deleteData createDataKey="stock" stepKey="deleteStock"/> + <!--Disable source.--> + <actionGroup ref="DisableAllSourcesActionGroup" stepKey="disableSources"/> + <actionGroup ref="AdminLogoutActionGroup" stepKey="logoutFromAdminArea"/> + <!-- Reindex invalidated indices after product attribute has been created/deleted --> + <magentoCron groups="index" stepKey="reindexInvalidatedIndices"/> + </after> + <!--Verify product is visible on storefront.--> + <actionGroup ref="StorefrontNavigateCategoryPageActionGroup" stepKey="openCategoryPageOnFrontend"> + <argument name="category" value="$createCategory$"/> + </actionGroup> + <actionGroup ref="AssertStorefrontProductIsPresentOnCategoryPageActionGroup" stepKey="checkProductOnCategoryPage"> + <argument name="productName" value="$$createConfigProduct.name$$"/> + </actionGroup> + <!--Open first child product in Admin. Make it disabled (Enable Product = No)--> + <amOnPage url="{{AdminProductEditPage.url($$createFirstConfigChildProduct.id$$)}}" stepKey="openProductEditPageForDisablingProduct"/> + <actionGroup ref="AdminSetProductDisabledActionGroup" stepKey="disableProduct"/> + <actionGroup ref="SaveProductFormActionGroup" stepKey="clickSaveProduct"/> + <!--Open second child product and set product stock status to out of stock--> + <amOnPage url="{{AdminProductEditPage.url($$createSecondConfigChildProduct.id$$)}}" stepKey="openProductEditPageForDisablingSource"/> + <actionGroup ref="AdminChangeSourceStockStatusActionGroup" stepKey="setProductStatusToOutOfStock"> + <argument name="sourceCode" value="$createSource.source[source_code]$"/> + <argument name="sourceStatus" value="{{SourceStatusOutOfStock.value}}"/> + </actionGroup> + <actionGroup ref="AdminFormSaveAndCloseActionGroup" stepKey="saveProduct"/> + <!--Verify product is not visible on storefront.--> + <actionGroup ref="AssertStorefrontProductAbsentOnCategoryPageActionGroup" stepKey="doNotSeeProductOnCategoryPage"> + <argument name="categoryUrlKey" value="$$createCategory.name$$"/> + <argument name="productName" value="$$createConfigProduct.name$$"/> + </actionGroup> + </test> +</tests> diff --git a/InventoryConfigurableProductIndexer/Test/Unit/Indexer/SelectBuilderTest.php b/InventoryConfigurableProductIndexer/Test/Unit/Indexer/SelectBuilderTest.php index 6ef8975559dd..8e49df9808f0 100644 --- a/InventoryConfigurableProductIndexer/Test/Unit/Indexer/SelectBuilderTest.php +++ b/InventoryConfigurableProductIndexer/Test/Unit/Indexer/SelectBuilderTest.php @@ -7,6 +7,8 @@ namespace Magento\InventoryConfigurableProductIndexer\Test\Unit\Indexer; +use Magento\Catalog\Model\ResourceModel\Eav\Attribute; +use Magento\Eav\Model\Config; use Magento\Framework\App\ResourceConnection; use Magento\Framework\DB\Adapter\AdapterInterface; use Magento\Framework\DB\Select; @@ -21,6 +23,9 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +/** + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ class SelectBuilderTest extends TestCase { /** @@ -58,6 +63,11 @@ protected function setUp(): void $defaultStockProvider = $this->createMock(DefaultStockProviderInterface::class); $defaultStockProvider->method('getId')->willReturn(1); + $statusAttribute = $this->createMock(Attribute::class); + $statusAttribute->method('getId')->willReturn(97); + $eavConfig = $this->createMock(Config::class); + $eavConfig->method('getAttribute')->willReturn($statusAttribute); + $configuration = $this->createMock(InventoryConfigurationInterface::class); $configuration->method('getManageStock')->willReturn(1); @@ -67,6 +77,7 @@ protected function setUp(): void $indexNameResolver, $metadataPool, $defaultStockProvider, + $eavConfig, $configuration ); } From 58a4a607388f773c9f35ec77e56b44d9bca20248 Mon Sep 17 00:00:00 2001 From: Jeanmarcos Juarez <janmarcoj@gmail.com> Date: Thu, 30 Jul 2026 10:35:33 -0400 Subject: [PATCH 3/4] fix(inventory-configurable-product-indexer): harden the child status join of the parent stock index --- .../Indexer/SelectBuilder.php | 12 ++++-- .../Test/Unit/Indexer/SelectBuilderTest.php | 38 +++++++++++++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/InventoryConfigurableProductIndexer/Indexer/SelectBuilder.php b/InventoryConfigurableProductIndexer/Indexer/SelectBuilder.php index 530f750ecc06..8be9cc1b7cd3 100644 --- a/InventoryConfigurableProductIndexer/Indexer/SelectBuilder.php +++ b/InventoryConfigurableProductIndexer/Indexer/SelectBuilder.php @@ -23,6 +23,7 @@ use Magento\InventoryMultiDimensionalIndexerApi\Model\IndexAlias; use Magento\InventoryMultiDimensionalIndexerApi\Model\IndexNameBuilder; use Magento\InventoryMultiDimensionalIndexerApi\Model\IndexNameResolverInterface; +use Magento\Store\Model\Store; /** * Get configurable product for given stock select builder @@ -72,6 +73,11 @@ public function getSelect(int $stockId, array $skuList = [], IndexAlias $indexAl $manageStock = "($manageStock)"; } + $enabledChildIsSalable = sprintf( + 'MAX(IF(product_status.value = %d, stock.is_salable, 0))', + ProductStatus::STATUS_ENABLED + ); + $select = $connection->select() ->from( ['stock' => $indexTableName], @@ -79,7 +85,7 @@ public function getSelect(int $stockId, array $skuList = [], IndexAlias $indexAl IndexStructure::SKU => 'parent_product_entity.sku', IndexStructure::QUANTITY => 'SUM(stock.quantity)', IndexStructure::IS_SALABLE => - "IF(inventory_stock_item.is_in_stock = 0 AND $manageStock, 0, MAX(stock.is_salable))", + "IF(inventory_stock_item.is_in_stock = 0 AND $manageStock, 0, $enabledChildIsSalable)", ] )->joinInner( ['product_entity' => $this->resourceConnection->getTableName('catalog_product_entity')], @@ -98,11 +104,11 @@ public function getSelect(int $stockId, array $skuList = [], IndexAlias $indexAl 'inventory_stock_item.product_id = parent_product_entity.entity_id' . ' AND inventory_stock_item.stock_id = ' . $this->defaultStockProvider->getId(), [] - )->joinInner( + )->joinLeft( ['product_status' => $this->resourceConnection->getTableName('catalog_product_entity_int')], "product_entity.$linkField = product_status.$linkField" . " AND product_status.attribute_id = $statusAttributeId" - . ' AND product_status.value = ' . ProductStatus::STATUS_ENABLED, + . ' AND product_status.store_id = ' . Store::DEFAULT_STORE_ID, [] ) ->group(['parent_product_entity.sku']) diff --git a/InventoryConfigurableProductIndexer/Test/Unit/Indexer/SelectBuilderTest.php b/InventoryConfigurableProductIndexer/Test/Unit/Indexer/SelectBuilderTest.php index 8e49df9808f0..b1b12c26bf80 100644 --- a/InventoryConfigurableProductIndexer/Test/Unit/Indexer/SelectBuilderTest.php +++ b/InventoryConfigurableProductIndexer/Test/Unit/Indexer/SelectBuilderTest.php @@ -7,6 +7,7 @@ namespace Magento\InventoryConfigurableProductIndexer\Test\Unit\Indexer; +use Magento\Catalog\Model\Product\Attribute\Source\Status as ProductStatus; use Magento\Catalog\Model\ResourceModel\Eav\Attribute; use Magento\Eav\Model\Config; use Magento\Framework\App\ResourceConnection; @@ -17,9 +18,11 @@ use Magento\InventoryCatalogApi\Api\DefaultStockProviderInterface; use Magento\InventoryConfigurableProductIndexer\Indexer\SelectBuilder; use Magento\InventoryConfigurationApi\Model\InventoryConfigurationInterface; +use Magento\InventoryIndexer\Indexer\IndexStructure; use Magento\InventoryMultiDimensionalIndexerApi\Model\IndexName; use Magento\InventoryMultiDimensionalIndexerApi\Model\IndexNameBuilder; use Magento\InventoryMultiDimensionalIndexerApi\Model\IndexNameResolverInterface; +use Magento\Store\Model\Store; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -97,4 +100,39 @@ public function testGetSelectOrdersBySkuAscending(): void $this->selectBuilder->getSelect(2, ['configurable_1']); } + + public function testDisabledChildrenAreIgnoredWithoutDroppingTheParentRow(): void + { + $columns = []; + $joinConditions = []; + + $select = $this->createMock(Select::class); + foreach (['joinInner', 'where', 'group', 'order'] as $method) { + $select->method($method)->willReturnSelf(); + } + $select->method('from') + ->willReturnCallback(function ($table, $cols) use ($select, &$columns) { + $columns = $cols; + return $select; + }); + $select->method('joinLeft') + ->willReturnCallback(function ($table, $condition) use ($select, &$joinConditions) { + $joinConditions[array_key_first($table)] = $condition; + return $select; + }); + $this->connection->method('select')->willReturn($select); + + $this->selectBuilder->getSelect(2); + + self::assertStringContainsString( + 'MAX(IF(product_status.value = ' . ProductStatus::STATUS_ENABLED . ', stock.is_salable, 0))', + $columns[IndexStructure::IS_SALABLE] + ); + self::assertArrayHasKey('product_status', $joinConditions); + self::assertStringContainsString( + 'product_status.store_id = ' . Store::DEFAULT_STORE_ID, + $joinConditions['product_status'] + ); + self::assertStringNotContainsString('product_status.value =', $joinConditions['product_status']); + } } From 2866dc69c76e0514b8c5b532f06f1c6834310418 Mon Sep 17 00:00:00 2001 From: Jeanmarcos Juarez <janmarcoj@gmail.com> Date: Thu, 30 Jul 2026 10:11:51 -0400 Subject: [PATCH 4/4] perf(inventory-configurable-product): resolve configurable salability from the stock index --- .../Type/Configurable/IsSalablePlugin.php | 78 ++++++++ .../Type/Configurable/IsSalablePluginTest.php | 176 ++++++++++++++++++ .../etc/frontend/di.xml | 1 + 3 files changed, 255 insertions(+) create mode 100644 InventoryConfigurableProduct/Plugin/Model/Product/Type/Configurable/IsSalablePlugin.php create mode 100644 InventoryConfigurableProduct/Test/Unit/Plugin/Model/Product/Type/Configurable/IsSalablePluginTest.php diff --git a/InventoryConfigurableProduct/Plugin/Model/Product/Type/Configurable/IsSalablePlugin.php b/InventoryConfigurableProduct/Plugin/Model/Product/Type/Configurable/IsSalablePlugin.php new file mode 100644 index 000000000000..92fc5bf605da --- /dev/null +++ b/InventoryConfigurableProduct/Plugin/Model/Product/Type/Configurable/IsSalablePlugin.php @@ -0,0 +1,78 @@ +<?php +/** + * Copyright 2026 Adobe + * All Rights Reserved. + */ +declare(strict_types=1); + +namespace Magento\InventoryConfigurableProduct\Plugin\Model\Product\Type\Configurable; + +use Magento\Catalog\Api\Data\ProductInterface; +use Magento\Catalog\Model\Product\Attribute\Source\Status; +use Magento\ConfigurableProduct\Model\Product\Type\Configurable; +use Magento\Store\Model\Store; +use Magento\Store\Model\StoreManagerInterface; + +/** + * Resolve configurable salability from the stock index instead of counting salable children per product. + */ +class IsSalablePlugin +{ + /** + * @param StoreManagerInterface $storeManager + */ + public function __construct(private readonly StoreManagerInterface $storeManager) + { + } + + /** + * Replace the per-product salable children count with the aggregate the stock index already holds. + * + * @param Configurable $subject + * @param callable $proceed + * @param ProductInterface $product + * @return bool + */ + public function aroundIsSalable(Configurable $subject, callable $proceed, $product): bool + { + try { + if (!$product->hasData('is_salable') || !$this->isCurrentStoreScope($subject, $product)) { + return (bool)$proceed($product); + } + } catch (\Throwable $exception) { + return (bool)$proceed($product); + } + + $salable = $product->getStatus() == Status::STATUS_ENABLED; + if ($salable) { + $salable = $product->getData('is_salable'); + } + + return (bool)(int)$salable; + } + + /** + * Whether the salability being asked for is the one of the current store. + * + * @param Configurable $subject + * @param ProductInterface $product + * @return bool + */ + private function isCurrentStoreScope(Configurable $subject, $product): bool + { + $storeFilter = $subject->getStoreFilter($product); + if ($storeFilter instanceof Store) { + $scopeStoreId = $storeFilter->getId(); + } elseif ($storeFilter !== null) { + $scopeStoreId = $storeFilter; + } else { + $scopeStoreId = $product->getStoreId(); + } + + if ($scopeStoreId === null || $scopeStoreId === '') { + return false; + } + + return (int)$scopeStoreId === (int)$this->storeManager->getStore()->getId(); + } +} diff --git a/InventoryConfigurableProduct/Test/Unit/Plugin/Model/Product/Type/Configurable/IsSalablePluginTest.php b/InventoryConfigurableProduct/Test/Unit/Plugin/Model/Product/Type/Configurable/IsSalablePluginTest.php new file mode 100644 index 000000000000..6cc1bf170155 --- /dev/null +++ b/InventoryConfigurableProduct/Test/Unit/Plugin/Model/Product/Type/Configurable/IsSalablePluginTest.php @@ -0,0 +1,176 @@ +<?php +/** + * Copyright 2026 Adobe + * All Rights Reserved. + */ +declare(strict_types=1); + +namespace Magento\InventoryConfigurableProduct\Test\Unit\Plugin\Model\Product\Type\Configurable; + +use Magento\Catalog\Model\Product; +use Magento\Catalog\Model\Product\Attribute\Source\Status; +use Magento\ConfigurableProduct\Model\Product\Type\Configurable; +use Magento\Framework\Exception\NoSuchEntityException; +use Magento\InventoryConfigurableProduct\Plugin\Model\Product\Type\Configurable\IsSalablePlugin; +use Magento\Store\Api\Data\StoreInterface; +use Magento\Store\Model\Store; +use Magento\Store\Model\StoreManagerInterface; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +class IsSalablePluginTest extends TestCase +{ + private const CURRENT_STORE_ID = 1; + + /** + * @var IsSalablePlugin + */ + private IsSalablePlugin $plugin; + + /** + * @var StoreManagerInterface|MockObject + */ + private $storeManagerMock; + + /** + * @var Configurable|MockObject + */ + private $configurableMock; + + /** + * @inheritdoc + */ + protected function setUp(): void + { + $this->storeManagerMock = $this->createMock(StoreManagerInterface::class); + $this->configurableMock = $this->createMock(Configurable::class); + + $storeMock = $this->createMock(StoreInterface::class); + $storeMock->method('getId')->willReturn(self::CURRENT_STORE_ID); + $this->storeManagerMock->method('getStore')->willReturn($storeMock); + + $this->plugin = new IsSalablePlugin($this->storeManagerMock); + } + + public function testLoadedIsSalableIsUsedWithoutTouchingTheCore(): void + { + $product = $this->createProduct(['status' => Status::STATUS_ENABLED, 'is_salable' => '1']); + + $this->assertTrue($this->plugin->aroundIsSalable($this->configurableMock, $this->failingProceed(), $product)); + } + + public function testLoadedIsSalableZeroMakesProductNotSalable(): void + { + $product = $this->createProduct(['status' => Status::STATUS_ENABLED, 'is_salable' => '0']); + + $this->assertFalse($this->plugin->aroundIsSalable($this->configurableMock, $this->failingProceed(), $product)); + } + + public function testLoadedIsSalableNullMakesProductNotSalable(): void + { + $product = $this->createProduct(['status' => Status::STATUS_ENABLED, 'is_salable' => null]); + + $this->assertFalse($this->plugin->aroundIsSalable($this->configurableMock, $this->failingProceed(), $product)); + } + + public function testDisabledProductIsNotSalable(): void + { + $product = $this->createProduct(['status' => Status::STATUS_DISABLED, 'is_salable' => '1']); + + $this->assertFalse($this->plugin->aroundIsSalable($this->configurableMock, $this->failingProceed(), $product)); + } + + public function testProductWithoutLoadedIsSalableIsDelegated(): void + { + $product = $this->createProduct(['status' => Status::STATUS_ENABLED]); + + $this->assertTrue( + $this->plugin->aroundIsSalable($this->configurableMock, static fn () => true, $product) + ); + } + + public function testStoreFilterOfAnotherStoreIsDelegated(): void + { + $product = $this->createProduct(['status' => Status::STATUS_ENABLED, 'is_salable' => '1']); + $otherStore = $this->createMock(Store::class); + $otherStore->method('getId')->willReturn(7); + $this->configurableMock->method('getStoreFilter')->willReturn($otherStore); + + $this->assertFalse( + $this->plugin->aroundIsSalable($this->configurableMock, static fn () => false, $product) + ); + } + + public function testStoreFilterOfCurrentStoreKeepsTheFastPath(): void + { + $product = $this->createProduct(['status' => Status::STATUS_ENABLED, 'is_salable' => '1']); + $currentStore = $this->createMock(Store::class); + $currentStore->method('getId')->willReturn(self::CURRENT_STORE_ID); + $this->configurableMock->method('getStoreFilter')->willReturn($currentStore); + + $this->assertTrue($this->plugin->aroundIsSalable($this->configurableMock, $this->failingProceed(), $product)); + } + + public function testIntegerStoreFilterOfCurrentStoreKeepsTheFastPath(): void + { + $product = $this->createProduct(['status' => Status::STATUS_ENABLED, 'is_salable' => '1']); + $this->configurableMock->method('getStoreFilter')->willReturn(self::CURRENT_STORE_ID); + + $this->assertTrue($this->plugin->aroundIsSalable($this->configurableMock, $this->failingProceed(), $product)); + } + + public function testMissingScopeIsDelegated(): void + { + $product = $this->createProduct(['status' => Status::STATUS_ENABLED, 'is_salable' => '1'], null); + $this->configurableMock->method('getStoreFilter')->willReturn(null); + + $this->assertFalse( + $this->plugin->aroundIsSalable($this->configurableMock, static fn () => false, $product) + ); + } + + public function testStoreResolutionFailureFallsBackToTheCore(): void + { + $product = $this->createProduct(['status' => Status::STATUS_ENABLED, 'is_salable' => '1']); + $this->configurableMock->method('getStoreFilter') + ->willThrowException(new NoSuchEntityException(__('no store'))); + + $this->assertTrue( + $this->plugin->aroundIsSalable($this->configurableMock, static fn () => true, $product) + ); + } + + /** + * Build a product stub carrying the given data. + * + * @param array $data + * @param int|null $storeId + * @return Product|MockObject + */ + private function createProduct(array $data, ?int $storeId = self::CURRENT_STORE_ID) + { + $product = $this->getMockBuilder(Product::class) + ->disableOriginalConstructor() + ->onlyMethods(['getStoreId', 'getSku', 'getStatus', 'hasData', 'getData']) + ->getMock(); + $product->method('getStoreId')->willReturn($storeId); + $product->method('getSku')->willReturn('sku-1'); + $product->method('getStatus')->willReturn($data['status']); + $product->method('hasData')->with('is_salable')->willReturn(array_key_exists('is_salable', $data)); + $product->method('getData')->with('is_salable')->willReturn($data['is_salable'] ?? null); + + return $product; + } + + /** + * A $proceed that must never be reached. + * + * @return callable + */ + private function failingProceed(): callable + { + return function () { + $this->fail('The core implementation must not be reached'); + }; + } +} diff --git a/InventoryConfigurableProduct/etc/frontend/di.xml b/InventoryConfigurableProduct/etc/frontend/di.xml index 4e381f44bd48..76c08a6a83e5 100644 --- a/InventoryConfigurableProduct/etc/frontend/di.xml +++ b/InventoryConfigurableProduct/etc/frontend/di.xml @@ -11,6 +11,7 @@ </type> <type name="Magento\ConfigurableProduct\Model\Product\Type\Configurable"> <plugin name="is_option_salable" type="Magento\InventoryConfigurableProduct\Plugin\Model\Product\Type\Configurable\IsSalableOptionPlugin"/> + <plugin name="is_salable_from_index" type="Magento\InventoryConfigurableProduct\Plugin\Model\Product\Type\Configurable\IsSalablePlugin"/> </type> <type name="Magento\CatalogInventory\Helper\Stock"> <plugin name="adapt_assign_stock_status_to_configurable_product" type="Magento\InventoryConfigurableProduct\Plugin\CatalogInventory\Helper\Stock\AdaptAssignStatusToProductPlugin"/>