From 368f9ab8cea07ee69e3641f40a9e683090b6ec02 Mon Sep 17 00:00:00 2001 From: Jeanmarcos Juarez Date: Sat, 11 Jul 2026 20:23:07 -0400 Subject: [PATCH] fix(inventory-configurable-product-indexer): exclude disabled child products from parent stock index [picked #3241] Port of magento/inventory#3241 (MC-38590): a configurable product stayed visible/salable on the storefront category page when its salable stock came from a disabled child. The stock-index SelectBuilder aggregated all children regardless of status. Add an inner join on catalog_product_entity_int filtering the child product status to STATUS_ENABLED, so disabled children no longer contribute to the parent's salable aggregation. Resolves the status attribute id via Magento\Eav\Model\Config (new dependency; declared in composer.json). Scope note: the upstream PR also patched InventoryBundleProductIndexer, but that indexer was rewritten in this baseline (option status now flows through OptionsStatusSelectBuilder), so the bundle hunk no longer applies and is dropped. The deadlock-fix `ORDER BY parent_product_entity.sku ASC` is preserved after the group. Unit test updated to provide the eavConfig dependency and keep asserting the deterministic order. Ships the upstream MFTF functional test. --- .../Indexer/SelectBuilder.php | 37 ++++- ...VisibleAfterDisablingChildProductsTest.xml | 140 ++++++++++++++++++ .../Test/Unit/Indexer/SelectBuilderTest.php | 11 ++ .../composer.json | 1 + 4 files changed, 188 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 5f8b7eedc0fd..4755a8c0ae47 100644 --- a/InventoryConfigurableProductIndexer/Indexer/SelectBuilder.php +++ b/InventoryConfigurableProductIndexer/Indexer/SelectBuilder.php @@ -9,6 +9,10 @@ use Exception; 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\IndexNameResolverInterface; use Magento\InventoryIndexer\Indexer\SelectBuilderInterface; +/** + * Get configurable product for given stock select builder + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ class SelectBuilder implements SelectBuilderInterface { /** @@ -46,25 +55,33 @@ class SelectBuilder implements SelectBuilderInterface */ private $defaultStockProvider; + /** + * @var Config + */ + private $eavConfig; + /** * @param ResourceConnection $resourceConnection * @param IndexNameBuilder $indexNameBuilder * @param IndexNameResolverInterface $indexNameResolver * @param MetadataPool $metadataPool * @param DefaultStockProviderInterface $defaultStockProvider + * @param Config $eavConfig */ public function __construct( ResourceConnection $resourceConnection, IndexNameBuilder $indexNameBuilder, IndexNameResolverInterface $indexNameResolver, MetadataPool $metadataPool, - DefaultStockProviderInterface $defaultStockProvider + DefaultStockProviderInterface $defaultStockProvider, + Config $eavConfig ) { $this->resourceConnection = $resourceConnection; $this->indexNameBuilder = $indexNameBuilder; $this->indexNameResolver = $indexNameResolver; $this->metadataPool = $metadataPool; $this->defaultStockProvider = $defaultStockProvider; + $this->eavConfig = $eavConfig; } /** @@ -88,6 +105,7 @@ public function execute(int $stockId): Select $metadata = $this->metadataPool->getMetadata(ProductInterface::class); $linkField = $metadata->getLinkField(); + $statusAttributeId = $this->getAttribute(ProductInterface::STATUS)->getId(); $select = $connection->select() ->from( @@ -114,10 +132,27 @@ public function execute(int $stockId): Select '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'); 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 2d5251dbf6e4..d45c1f351449 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; @@ -20,6 +22,9 @@ use Magento\InventoryMultiDimensionalIndexerApi\Model\IndexNameResolverInterface; use PHPUnit\Framework\TestCase; +/** + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ class SelectBuilderTest extends TestCase { public function testExecuteOrdersBySkuAscending(): void @@ -58,6 +63,11 @@ public function testExecuteOrdersBySkuAscending(): 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); + $selectBuilder = (new ObjectManager($this))->getObject( SelectBuilder::class, [ @@ -66,6 +76,7 @@ public function testExecuteOrdersBySkuAscending(): void 'indexNameResolver' => $indexNameResolver, 'metadataPool' => $metadataPool, 'defaultStockProvider' => $defaultStockProvider, + 'eavConfig' => $eavConfig, ] ); diff --git a/InventoryConfigurableProductIndexer/composer.json b/InventoryConfigurableProductIndexer/composer.json index 0efa94a6aa02..2fb23b761b1e 100644 --- a/InventoryConfigurableProductIndexer/composer.json +++ b/InventoryConfigurableProductIndexer/composer.json @@ -11,6 +11,7 @@ "php": "~8.2.0||~8.3.0||~8.4.0", "magento/framework": "*", "magento/module-catalog": "*", + "magento/module-eav": "*", "magento/module-inventory-api": "1.2.*", "magento/module-inventory-catalog-api": "1.3.*", "magento/module-inventory-indexer": "2.2.*",