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
16 changes: 16 additions & 0 deletions DISTRIBUTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,22 @@ This mirrors the target Magento version and does not collide with Adobe's own
- Concurrent orders on different stocks sharing a source are not serialized
against each other (the place-order lock is per stock); totals per stock
are always preserved.
- **Salable quantity broken down by source**: the *Product Salable Quantity*
section of the product form and the *Salable Quantity* column of the product
grid report, for every source of a stock, the quantity on hand, that source's
reservation balance and the salable quantity they add up to. The aggregate
Magento already showed becomes the total of that breakdown, so a salable
quantity lower than the quantity on hand can be traced to the source holding
the difference. Sources that contribute nothing are still listed and labelled
— a disabled source, or a source item set out of stock — so a zero reads as a
reason rather than a defect. Notes:
- Single-source mode is untouched: with one source the breakdown would only
repeat the aggregate, so the section renders exactly as before.
- Without source-level reservations the breakdown degrades to the quantity on
hand, since reservations are then held per stock and cannot be attributed
to a source.
- The grid resolves a whole page in one pair of queries and keeps the
breakdown collapsed until it is expanded, which costs no further request.
- **Storefront stock visualizer** (opt-in, default off): a product-page
*Availability* panel driven by MSI, shipped as the additive
`Magento_InventoryStockVisualizer` module (no core module is replaced). The
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?php
/**
* Copyright 2026 Jeanmarcos Juarez
* SPDX-License-Identifier: OSL-3.0 OR AFL-3.0
*/
declare(strict_types=1);

namespace Magento\InventorySales\Model\ResourceModel\SourceReservation;

use Magento\Framework\App\ResourceConnection;
use Magento\Inventory\Model\ResourceModel\SourceItem;
use Magento\InventoryApi\Api\Data\SourceItemInterface;

/**
* Load the quantity and stock status of the given SKUs on the given sources.
*
* Unlike GetSourceItemQuantityBySkusAndSources this keeps out-of-stock source items, so a caller
* can tell an empty source apart from one holding quantity that is not offered for sale.
*/
class GetSourceItemDataBySkusAndSources
{
/**
* @param ResourceConnection $resourceConnection
*/
public function __construct(
private readonly ResourceConnection $resourceConnection
) {
}

/**
* Get source item quantity and status indexed by source code and SKU.
*
* @param string[] $skus
* @param string[] $sourceCodes
* @return array<string, array<string, array<string, float|int>>> [source_code][sku] => [quantity, status]
*/
public function execute(array $skus, array $sourceCodes): array
{
if (empty($skus) || empty($sourceCodes)) {
return [];
}

$connection = $this->resourceConnection->getConnection();
$select = $connection->select()
->from(
$this->resourceConnection->getTableName(SourceItem::TABLE_NAME_SOURCE_ITEM),
[
SourceItemInterface::SOURCE_CODE,
SourceItemInterface::SKU,
SourceItemInterface::QUANTITY,
SourceItemInterface::STATUS,
]
)
->where(SourceItemInterface::SOURCE_CODE . ' IN (?)', $sourceCodes)
->where(SourceItemInterface::SKU . ' IN (?)', $skus);

$result = [];
foreach ($connection->fetchAll($select) as $row) {
$result[$row[SourceItemInterface::SOURCE_CODE]][$row[SourceItemInterface::SKU]] = [
'quantity' => (float) $row[SourceItemInterface::QUANTITY],
'status' => (int) $row[SourceItemInterface::STATUS],
];
}

return $result;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
<?php
/**
* Copyright 2026 Jeanmarcos Juarez
* SPDX-License-Identifier: OSL-3.0 OR AFL-3.0
*/
declare(strict_types=1);

namespace Magento\InventorySales\Model\SourceReservation;

use Magento\InventoryApi\Api\Data\SourceItemInterface;
use Magento\InventoryReservationsApi\Model\SourceReservationsConfig;
use Magento\InventorySales\Model\ResourceModel\SourceReservation\GetReservationsQuantityBySkusAndSources;
use Magento\InventorySales\Model\ResourceModel\SourceReservation\GetSourceItemDataBySkusAndSources;

/**
* Resolve the available quantity of the given SKUs at each of the given sources.
*
* The available quantity nets the physical source quantity against that source's reservation
* balance, degrading to the physical quantity when source-level reservations are off. Both the
* physical quantity and the reservation balance are reported alongside the net result so a caller
* can show how the net was reached.
*
* @api
*/
class GetSourceAvailabilityBySkus
{
/**
* @param GetSourceItemDataBySkusAndSources $getSourceItemData
* @param GetReservationsQuantityBySkusAndSources $getReservationsQuantity
* @param SourceReservationsConfig $sourceReservationsConfig
*/
public function __construct(
private readonly GetSourceItemDataBySkusAndSources $getSourceItemData,
private readonly GetReservationsQuantityBySkusAndSources $getReservationsQuantity,
private readonly SourceReservationsConfig $sourceReservationsConfig
) {
}

/**
* Get per-source availability indexed by source code and SKU.
*
* Resolves in two queries regardless of how many SKUs and sources are requested. Sources
* without a source item for a SKU are absent from the result.
*
* @param string[] $skus
* @param string[] $sourceCodes
* @return array<string, array<string, array<string, bool|float>>>
*/
public function execute(array $skus, array $sourceCodes): array
{
if (empty($skus) || empty($sourceCodes)) {
return [];
}

$sourceItems = $this->getSourceItemData->execute($skus, $sourceCodes);
$reservations = $this->sourceReservationsConfig->isEnabled()
? $this->getReservationsQuantity->execute($skus, $sourceCodes)
: [];

$availability = [];
foreach ($sourceItems as $sourceCode => $sourceItemsBySku) {
foreach ($sourceItemsBySku as $sku => $sourceItem) {
$availability[$sourceCode][$sku] = $this->buildRow(
(float) $sourceItem['quantity'],
(int) $sourceItem['status'],
(float) ($reservations[$sourceCode][$sku] ?? 0.0)
);
}
}

return $availability;
}

/**
* Build the availability row of a single source item.
*
* @param float $physical
* @param int $status
* @param float $reserved
* @return array<string, bool|float>
*/
private function buildRow(float $physical, int $status, float $reserved): array
{
$outOfStock = $status === SourceItemInterface::STATUS_OUT_OF_STOCK;

return [
'physical' => $physical,
'reserved' => $reserved,
'salable' => $outOfStock ? 0.0 : max(0.0, $physical + $reserved),
'source_item_out_of_stock' => $outOfStock,
];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
<?php
/**
* Copyright 2026 Jeanmarcos Juarez
* SPDX-License-Identifier: OSL-3.0 OR AFL-3.0
*/
declare(strict_types=1);

namespace Magento\InventorySales\Test\Unit\Model\SourceReservation;

use Magento\InventoryApi\Api\Data\SourceItemInterface;
use Magento\InventoryReservationsApi\Model\SourceReservationsConfig;
use Magento\InventorySales\Model\ResourceModel\SourceReservation\GetReservationsQuantityBySkusAndSources;
use Magento\InventorySales\Model\ResourceModel\SourceReservation\GetSourceItemDataBySkusAndSources;
use Magento\InventorySales\Model\SourceReservation\GetSourceAvailabilityBySkus;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;

class GetSourceAvailabilityBySkusTest extends TestCase
{
/**
* @var GetSourceItemDataBySkusAndSources|MockObject
*/
private $getSourceItemData;

/**
* @var GetReservationsQuantityBySkusAndSources|MockObject
*/
private $getReservationsQuantity;

/**
* @var SourceReservationsConfig|MockObject
*/
private $sourceReservationsConfig;

/**
* @var GetSourceAvailabilityBySkus
*/
private $getSourceAvailability;

protected function setUp(): void
{
$this->getSourceItemData = $this->createMock(GetSourceItemDataBySkusAndSources::class);
$this->getReservationsQuantity = $this->createMock(GetReservationsQuantityBySkusAndSources::class);
$this->sourceReservationsConfig = $this->createMock(SourceReservationsConfig::class);

$this->getSourceAvailability = new GetSourceAvailabilityBySkus(
$this->getSourceItemData,
$this->getReservationsQuantity,
$this->sourceReservationsConfig
);
}

public function testNetsThePhysicalQuantityAgainstTheSourceReservationBalance(): void
{
$this->sourceReservationsConfig->method('isEnabled')->willReturn(true);
$this->getSourceItemData->method('execute')->willReturn(
['src_a' => ['sku-1' => $this->sourceItem(5.0, SourceItemInterface::STATUS_IN_STOCK)]]
);
$this->getReservationsQuantity->method('execute')->willReturn(['src_a' => ['sku-1' => -2.0]]);

$result = $this->getSourceAvailability->execute(['sku-1'], ['src_a']);

self::assertSame(5.0, $result['src_a']['sku-1']['physical']);
self::assertSame(-2.0, $result['src_a']['sku-1']['reserved']);
self::assertSame(3.0, $result['src_a']['sku-1']['salable']);
self::assertFalse($result['src_a']['sku-1']['source_item_out_of_stock']);
}

public function testDegradesToThePhysicalQuantityWhenSourceReservationsAreDisabled(): void
{
$this->sourceReservationsConfig->method('isEnabled')->willReturn(false);
$this->getSourceItemData->method('execute')->willReturn(
['src_a' => ['sku-1' => $this->sourceItem(7.0, SourceItemInterface::STATUS_IN_STOCK)]]
);
$this->getReservationsQuantity->expects(self::never())->method('execute');

$result = $this->getSourceAvailability->execute(['sku-1'], ['src_a']);

self::assertSame(7.0, $result['src_a']['sku-1']['physical']);
self::assertSame(0.0, $result['src_a']['sku-1']['reserved']);
self::assertSame(7.0, $result['src_a']['sku-1']['salable']);
}

public function testReportsZeroSalableButKeepsThePhysicalQuantityWhenTheSourceItemIsOutOfStock(): void
{
$this->sourceReservationsConfig->method('isEnabled')->willReturn(true);
$this->getSourceItemData->method('execute')->willReturn(
['src_a' => ['sku-1' => $this->sourceItem(10.0, SourceItemInterface::STATUS_OUT_OF_STOCK)]]
);
$this->getReservationsQuantity->method('execute')->willReturn([]);

$result = $this->getSourceAvailability->execute(['sku-1'], ['src_a']);

self::assertSame(10.0, $result['src_a']['sku-1']['physical']);
self::assertSame(0.0, $result['src_a']['sku-1']['salable']);
self::assertTrue($result['src_a']['sku-1']['source_item_out_of_stock']);
}

public function testClampsTheSalableQuantityToZeroWhenReservationsExceedThePhysicalQuantity(): void
{
$this->sourceReservationsConfig->method('isEnabled')->willReturn(true);
$this->getSourceItemData->method('execute')->willReturn(
['src_a' => ['sku-1' => $this->sourceItem(3.0, SourceItemInterface::STATUS_IN_STOCK)]]
);
$this->getReservationsQuantity->method('execute')->willReturn(['src_a' => ['sku-1' => -8.0]]);

$result = $this->getSourceAvailability->execute(['sku-1'], ['src_a']);

self::assertSame(-8.0, $result['src_a']['sku-1']['reserved']);
self::assertSame(0.0, $result['src_a']['sku-1']['salable']);
}

public function testOmitsSourcesWithoutASourceItemForTheSku(): void
{
$this->sourceReservationsConfig->method('isEnabled')->willReturn(true);
$this->getSourceItemData->method('execute')->willReturn(
['src_a' => ['sku-1' => $this->sourceItem(1.0, SourceItemInterface::STATUS_IN_STOCK)]]
);
$this->getReservationsQuantity->method('execute')->willReturn(['src_b' => ['sku-1' => -4.0]]);

$result = $this->getSourceAvailability->execute(['sku-1'], ['src_a', 'src_b']);

self::assertArrayNotHasKey('src_b', $result);
}

public function testQueriesNothingWhenTheSkuListIsEmpty(): void
{
$this->getSourceItemData->expects(self::never())->method('execute');
$this->getReservationsQuantity->expects(self::never())->method('execute');

self::assertSame([], $this->getSourceAvailability->execute([], ['src_a']));
}

public function testQueriesNothingWhenTheSourceListIsEmpty(): void
{
$this->getSourceItemData->expects(self::never())->method('execute');
$this->getReservationsQuantity->expects(self::never())->method('execute');

self::assertSame([], $this->getSourceAvailability->execute(['sku-1'], []));
}

/**
* Build a source item row as returned by the resource model.
*
* @param float $quantity
* @param int $status
* @return array<string, float|int>
*/
private function sourceItem(float $quantity, int $status): array
{
return ['quantity' => $quantity, 'status' => $status];
}
}
Loading
Loading