Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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');
};
}
}
1 change: 1 addition & 0 deletions InventoryConfigurableProduct/etc/frontend/di.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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"/>
Expand Down
12 changes: 9 additions & 3 deletions InventoryConfigurableProductIndexer/Indexer/SelectBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -122,14 +123,19 @@ 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],
[
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')],
Expand All @@ -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'])
Expand Down
Loading
Loading