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 @@
+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 @@
+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 e2e71d3e3cdf..233a1111cfd2 100644
--- a/InventoryConfigurableProduct/etc/frontend/di.xml
+++ b/InventoryConfigurableProduct/etc/frontend/di.xml
@@ -11,6 +11,7 @@
+
diff --git a/InventoryConfigurableProductIndexer/Indexer/SelectBuilder.php b/InventoryConfigurableProductIndexer/Indexer/SelectBuilder.php
index 02cd9b94e55b..406f19d0db44 100644
--- a/InventoryConfigurableProductIndexer/Indexer/SelectBuilder.php
+++ b/InventoryConfigurableProductIndexer/Indexer/SelectBuilder.php
@@ -24,6 +24,7 @@
use Magento\InventoryMultiDimensionalIndexerApi\Model\IndexNameBuilder;
use Magento\InventoryMultiDimensionalIndexerApi\Model\IndexNameResolverInterface;
use Magento\InventoryIndexer\Indexer\SelectBuilderInterface;
+use Magento\Store\Model\Store;
/**
* Get configurable product for given stock select builder
@@ -122,6 +123,11 @@ public function execute(int $stockId): Select
$manageStock = "($manageStock)";
}
+ $enabledChildIsSalable = sprintf(
+ 'MAX(IF(product_status.value = %d, stock.is_salable, 0))',
+ ProductStatus::STATUS_ENABLED
+ );
+
$select = $connection->select()
->from(
['stock' => $indexTableName],
@@ -129,7 +135,7 @@ public function execute(int $stockId): Select
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')],
@@ -148,11 +154,11 @@ 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(
+ )->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 d45c1f351449..41266b65b4de 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\Framework\TestFramework\Unit\Helper\ObjectManager;
use Magento\InventoryCatalogApi\Api\DefaultStockProviderInterface;
use Magento\InventoryConfigurableProductIndexer\Indexer\SelectBuilder;
+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\TestCase;
/**
@@ -82,4 +85,79 @@ public function testExecuteOrdersBySkuAscending(): void
$selectBuilder->execute(2);
}
+
+ public function testDisabledChildrenAreIgnoredWithoutDroppingTheParentRow(): void
+ {
+ $columns = [];
+ $joinConditions = [];
+
+ $connection = $this->createMock(AdapterInterface::class);
+
+ $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;
+ });
+ $connection->method('select')->willReturn($select);
+
+ $resourceConnection = $this->createMock(ResourceConnection::class);
+ $resourceConnection->method('getConnection')->willReturn($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);
+
+ $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,
+ [
+ 'resourceConnection' => $resourceConnection,
+ 'indexNameBuilder' => $indexNameBuilder,
+ 'indexNameResolver' => $indexNameResolver,
+ 'metadataPool' => $metadataPool,
+ 'defaultStockProvider' => $defaultStockProvider,
+ 'eavConfig' => $eavConfig,
+ ]
+ );
+
+ $selectBuilder->execute(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']);
+ }
}