Skip to content
Open
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
9 changes: 2 additions & 7 deletions InventoryBundleProduct/Test/_files/source_items_bundle.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,15 @@
/** @var SourceItemsSaveInterface $sourceItemsSave */
$sourceItemsSave = Bootstrap::getObjectManager()->get(SourceItemsSaveInterface::class);

// 'bundle' is a composite product type: it does not hold its own source item, only its selection
// (child) products do. It was never valid to create one for it here.
$sourcesItemsData = [
[
SourceItemInterface::SOURCE_CODE => 'us-1',
SourceItemInterface::SKU => 'simple_10',
SourceItemInterface::QUANTITY => 100,
SourceItemInterface::STATUS => SourceItemInterface::STATUS_IN_STOCK,
],
[
SourceItemInterface::SOURCE_CODE => 'us-1',
SourceItemInterface::SKU => 'bundle',
SourceItemInterface::QUANTITY => 100,
SourceItemInterface::STATUS => SourceItemInterface::STATUS_IN_STOCK,
],

];

$sourceItems = [];
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php
/**
* Copyright 2026 Adobe
* All Rights Reserved.
*/
declare(strict_types=1);

namespace Magento\InventoryConfiguration\Model\SourceItem\Validator;

use Magento\Framework\Validation\ValidationResult;
use Magento\Framework\Validation\ValidationResultFactory;
use Magento\InventoryApi\Api\Data\SourceItemInterface;
use Magento\InventoryApi\Model\SourceItemValidatorInterface;
use Magento\InventoryCatalogApi\Model\GetProductTypesBySkusInterface;
use Magento\InventoryConfigurationApi\Model\IsSourceItemManagementAllowedForProductTypeInterface;

/**
* Reject source items for product types that don't support source-item management (e.g. configurable,
* bundle, grouped). Without this check, SourceItemsSave persists an inventory_source_item row for such
* a SKU unconditionally, an orphan row that has no admin UI surface for editing or deletion.
*/
class ProductTypeManagementAllowedValidator implements SourceItemValidatorInterface
{
/**
* @param GetProductTypesBySkusInterface $getProductTypesBySkus
* @param IsSourceItemManagementAllowedForProductTypeInterface $isSourceItemManagementAllowedForProductType
* @param ValidationResultFactory $validationResultFactory
*/
public function __construct(
private readonly GetProductTypesBySkusInterface $getProductTypesBySkus,
private readonly IsSourceItemManagementAllowedForProductTypeInterface
$isSourceItemManagementAllowedForProductType,
private readonly ValidationResultFactory $validationResultFactory
) {
}

/**
* @inheritdoc
*/
public function validate(SourceItemInterface $source): ValidationResult
{
$sku = (string)$source->getSku();
$productType = $this->getProductTypesBySkus->execute([$sku])[$sku] ?? null;

$errors = [];
if ($productType !== null && !$this->isSourceItemManagementAllowedForProductType->execute($productType)) {
$errors[] = __(
'Source items are not supported for product type "%1" (SKU "%2").',
$productType,
$sku
);
}

return $this->validationResultFactory->create(['errors' => $errors]);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
<?php
/**
* Copyright 2026 Adobe
* All Rights Reserved.
*/
declare(strict_types=1);

namespace Magento\InventoryConfiguration\Test\Unit\Model\SourceItem\Validator;

use Magento\Framework\Validation\ValidationResult;
use Magento\Framework\Validation\ValidationResultFactory;
use Magento\InventoryApi\Api\Data\SourceItemInterface;
use Magento\InventoryCatalogApi\Model\GetProductTypesBySkusInterface;
use Magento\InventoryConfiguration\Model\SourceItem\Validator\ProductTypeManagementAllowedValidator;
use Magento\InventoryConfigurationApi\Model\IsSourceItemManagementAllowedForProductTypeInterface;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;

class ProductTypeManagementAllowedValidatorTest extends TestCase
{
/**
* @var GetProductTypesBySkusInterface|MockObject
*/
private $getProductTypesBySkus;

/**
* @var IsSourceItemManagementAllowedForProductTypeInterface|MockObject
*/
private $isSourceItemManagementAllowedForProductType;

/**
* @var ValidationResultFactory|MockObject
*/
private $validationResultFactory;

/**
* @var ProductTypeManagementAllowedValidator
*/
private $validator;

protected function setUp(): void
{
$this->getProductTypesBySkus = $this->createMock(GetProductTypesBySkusInterface::class);
$this->isSourceItemManagementAllowedForProductType = $this->createMock(
IsSourceItemManagementAllowedForProductTypeInterface::class
);
$this->validationResultFactory = $this->createMock(ValidationResultFactory::class);
$this->validationResultFactory->method('create')
->willReturnCallback(fn (array $args) => new ValidationResult($args['errors']));

$this->validator = new ProductTypeManagementAllowedValidator(
$this->getProductTypesBySkus,
$this->isSourceItemManagementAllowedForProductType,
$this->validationResultFactory
);
}

/**
* A source item for a product type where management is not allowed is rejected.
*
* @return void
*/
public function testRejectsSourceItemForDisallowedProductType(): void
{
$sourceItem = $this->createMock(SourceItemInterface::class);
$sourceItem->method('getSku')->willReturn('configurable-sku');

$this->getProductTypesBySkus->method('execute')
->with(['configurable-sku'])
->willReturn(['configurable-sku' => 'configurable']);
$this->isSourceItemManagementAllowedForProductType->method('execute')
->with('configurable')
->willReturn(false);

$result = $this->validator->validate($sourceItem);

self::assertNotEmpty($result->getErrors());
}

/**
* A source item for a product type where management is allowed passes.
*
* @return void
*/
public function testAllowsSourceItemForAllowedProductType(): void
{
$sourceItem = $this->createMock(SourceItemInterface::class);
$sourceItem->method('getSku')->willReturn('simple-sku');

$this->getProductTypesBySkus->method('execute')
->with(['simple-sku'])
->willReturn(['simple-sku' => 'simple']);
$this->isSourceItemManagementAllowedForProductType->method('execute')
->with('simple')
->willReturn(true);

$result = $this->validator->validate($sourceItem);

self::assertEmpty($result->getErrors());
}

/**
* A SKU that cannot be resolved to a product type (e.g. not yet persisted) is not rejected -
* there is nothing to validate against yet, and other validators/consumers own that case.
*
* @return void
*/
public function testAllowsSourceItemWhenProductTypeCannotBeResolved(): void
{
$sourceItem = $this->createMock(SourceItemInterface::class);
$sourceItem->method('getSku')->willReturn('unknown-sku');

$this->getProductTypesBySkus->method('execute')
->with(['unknown-sku'])
->willReturn([]);
$this->isSourceItemManagementAllowedForProductType->expects(self::never())->method('execute');

$result = $this->validator->validate($sourceItem);

self::assertEmpty($result->getErrors());
}
}
9 changes: 9 additions & 0 deletions InventoryConfiguration/etc/di.xml
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,13 @@
type="Magento\InventoryConfiguration\Plugin\CatalogInventory\Model\System\Config\Backend\Minqty\AllowNegativeMinQtyInConfigPlugin"/>
</type>
<preference for="Magento\InventoryConfigurationApi\Model\GetStockItemConfigurationBySkuListCacheInterface" type="Magento\InventoryConfiguration\Model\GetStockItemConfigurationBySkuListCache"/>
<type name="Magento\InventoryApi\Model\SourceItemValidatorChain">
<arguments>
<argument name="validators" xsi:type="array">
<item name="productTypeManagementAllowed" xsi:type="object">
Magento\InventoryConfiguration\Model\SourceItem\Validator\ProductTypeManagementAllowedValidator
</item>
</argument>
</arguments>
</type>
</config>
32 changes: 32 additions & 0 deletions InventoryImportExport/Plugin/Import/SourceItemImporter.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@
use Magento\InventoryApi\Api\Data\SourceItemInterfaceFactory;
use Magento\InventoryApi\Api\SourceItemsSaveInterface;
use Magento\InventoryCatalogApi\Api\DefaultSourceProviderInterface;
use Magento\InventoryCatalogApi\Model\GetProductTypesBySkusInterface;
use Magento\InventoryCatalogApi\Model\IsSingleSourceModeInterface;
use Magento\InventoryConfigurationApi\Model\IsSourceItemManagementAllowedForProductTypeInterface;
use Magento\InventoryIndexer\Indexer\CompositeProductsIndexer;
use Magento\InventoryIndexer\Indexer\SourceItem\SourceItemIndexer;

Expand Down Expand Up @@ -53,6 +55,9 @@ class SourceItemImporter
* @param SourceItemResourceModel $sourceItemResourceModel
* @param SourceItemIndexer $sourceItemIndexer
* @param CompositeProductsIndexer $compositeProductsIndexer
* @param GetProductTypesBySkusInterface $getProductTypesBySkus
* @param IsSourceItemManagementAllowedForProductTypeInterface $isSourceItemManagementAllowedForProductType
* @SuppressWarnings(PHPMD.ExcessiveParameterList)
*/
public function __construct(
private readonly SourceItemsSaveInterface $sourceItemsSave,
Expand All @@ -63,6 +68,9 @@ public function __construct(
private readonly SourceItemResourceModel $sourceItemResourceModel,
private readonly SourceItemIndexer $sourceItemIndexer,
private readonly CompositeProductsIndexer $compositeProductsIndexer,
private readonly GetProductTypesBySkusInterface $getProductTypesBySkus,
private readonly IsSourceItemManagementAllowedForProductTypeInterface
$isSourceItemManagementAllowedForProductType,
) {
}

Expand Down Expand Up @@ -94,9 +102,18 @@ public function afterProcess(
$existingSourceItemsBySKU = $isSingleSourceMode ? [] : $this->getSourceItems(array_keys($stockData));
$defaultSourceCode = $this->defaultSourceProvider->getCode();
$sourceItemIds = [];
$productTypesBySku = $this->getProductTypesBySkus->execute(
array_map('strval', array_keys($stockData))
);
foreach ($stockData as $sku => $stockDatum) {
$sku = (string)$sku;
$skus[] = $sku;
// Composite product types (configurable, bundle, grouped) don't have their own source
// items; skip them here so the import doesn't create orphan inventory_source_item rows.
// They stay in $skus for the composite products reindex below.
if (!$this->isSourceItemManagementAllowed($productTypesBySku[$sku] ?? null)) {
continue;
}
$sources = $existingSourceItemsBySKU[$sku] ?? [];
$isQtyExplicitlySet = (bool) ($importedData[$sku]['qty'] ?? false);
$hasDefaultSource = isset($sources[$defaultSourceCode]);
Expand Down Expand Up @@ -135,6 +152,21 @@ public function afterProcess(
$this->compositeProductsIndexer->reindexList($skus);
}

/**
* Return whether source items may be written for the given product type
*
* An unresolved type (SKU not queryable yet) is not rejected; the source item validator chain
* owns that case.
*
* @param string|null $productType
* @return bool
*/
private function isSourceItemManagementAllowed(?string $productType): bool
{
return $productType === null
|| $this->isSourceItemManagementAllowedForProductType->execute($productType);
}

/**
* Checks whether default source item should be updated for the given SKU
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@
use Magento\InventoryApi\Api\Data\SourceItemInterfaceFactory;
use Magento\InventoryApi\Api\SourceItemsSaveInterface;
use Magento\InventoryCatalogApi\Api\DefaultSourceProviderInterface;
use Magento\InventoryCatalogApi\Model\GetProductTypesBySkusInterface;
use Magento\InventoryCatalogApi\Model\IsSingleSourceModeInterface;
use Magento\InventoryConfigurationApi\Model\IsSourceItemManagementAllowedForProductTypeInterface;
use Magento\InventoryImportExport\Plugin\Import\SourceItemImporter;
use Magento\InventoryIndexer\Indexer\CompositeProductsIndexer;
use Magento\InventoryIndexer\Indexer\SourceItem\SourceItemIndexer;
Expand Down Expand Up @@ -83,6 +85,16 @@ class SourceItemImporterTest extends TestCase
*/
private SkuStorage $skuStorageMock;

/**
* @var GetProductTypesBySkusInterface|MockObject
*/
private $getProductTypesBySkusMock;

/**
* @var IsSourceItemManagementAllowedForProductTypeInterface|MockObject
*/
private $isSourceItemManagementAllowedForProductTypeMock;

/**
* @inheritdoc
*/
Expand All @@ -99,6 +111,15 @@ protected function setUp(): void

$this->skuStorageMock = $this->createMock(SkuStorage::class);

$this->getProductTypesBySkusMock = $this->createMock(GetProductTypesBySkusInterface::class);
$this->getProductTypesBySkusMock->method('execute')
->willReturnCallback(fn (array $skus) => array_fill_keys($skus, 'simple'));
$this->isSourceItemManagementAllowedForProductTypeMock = $this->createMock(
IsSourceItemManagementAllowedForProductTypeInterface::class
);
$this->isSourceItemManagementAllowedForProductTypeMock->method('execute')
->willReturnCallback(fn (string $type) => $type === 'simple');

$this->plugin = new SourceItemImporter(
$this->sourceItemsSaveMock,
$this->sourceItemFactoryMock,
Expand All @@ -108,6 +129,8 @@ protected function setUp(): void
$this->sourceItemResourceModelMock,
$this->createMock(SourceItemIndexer::class),
$this->compositeProductsIndexerMock,
$this->getProductTypesBySkusMock,
$this->isSourceItemManagementAllowedForProductTypeMock,
);
}

Expand Down Expand Up @@ -182,6 +205,57 @@ public function testAfterImportForMultipleSource(
$this->plugin->afterProcess($this->stockItemProcessorMock, '', $stockData, []);
}

/**
* Composite product types (configurable, bundle, grouped) don't support source items: the
* import must not create inventory_source_item rows for them, but they must still take part
* in the composite products reindex.
*
* @return void
*/
public function testAfterImportSkipsSourceItemsForCompositeProductTypes(): void
{
$stockData = [
'configurable-sku' => ['qty' => 0, 'is_in_stock' => 1, 'product_id' => 1],
'simple-sku' => ['qty' => 10, 'is_in_stock' => 1, 'product_id' => 2],
];

$this->getProductTypesBySkusMock = $this->createMock(GetProductTypesBySkusInterface::class);
$this->getProductTypesBySkusMock->method('execute')
->willReturn(['configurable-sku' => 'configurable', 'simple-sku' => 'simple']);
$this->plugin = new SourceItemImporter(
$this->sourceItemsSaveMock,
$this->sourceItemFactoryMock,
$this->defaultSourceMock,
$this->isSingleSourceModeMock,
$this->skuStorageMock,
$this->sourceItemResourceModelMock,
$this->createMock(SourceItemIndexer::class),
$this->compositeProductsIndexerMock,
$this->getProductTypesBySkusMock,
$this->isSourceItemManagementAllowedForProductTypeMock,
);

$this->isSingleSourceModeMock->method('execute')->willReturn(true);
$this->skuStorageMock->method('has')->willReturn(false);
$this->defaultSourceMock->method('getCode')->willReturn('default');
$this->sourceItemMock->method('setSku')->willReturnSelf();
$this->sourceItemMock->method('setSourceCode')->willReturnSelf();
$this->sourceItemMock->method('setQuantity')->willReturnSelf();
$this->sourceItemMock->method('setStatus')->willReturnSelf();

// Only the simple product produces a source item.
$this->sourceItemFactoryMock->expects($this->once())->method('create')
->willReturn($this->sourceItemMock);
$this->sourceItemMock->expects($this->once())->method('setSku')->with('simple-sku');
$this->sourceItemsSaveMock->expects($this->once())->method('execute')
->with([$this->sourceItemMock]);
// Both SKUs still take part in the composite reindex.
$this->compositeProductsIndexerMock->expects($this->once())->method('reindexList')
->with(['configurable-sku', 'simple-sku']);

$this->plugin->afterProcess($this->stockItemProcessorMock, '', $stockData, []);
}

/**
* Source item data provider
*
Expand Down
Loading