Multi Source Inventory (MSI) to jeden z największych refaktoringów w historii Magento 2 – wprowadzony w wersji 2.3. Zamiast jednego globalnego stanu magazynowego, produkty mogą mieć stany w wielu źródłach (magazyny, sklepy stacjonarne, dropshipping). Algorytm doboru źródeł (Source Selection Algorithm) decyduje skąd realizować zamówienie. Pokażę architekturę MSI, jak działa domyślny algorytm i jak napisać własny.
Architektura MSI – kluczowe pojęcia
| Pojęcie | Opis | Przykład |
|---|---|---|
| Source | Fizyczna lokalizacja towaru | Magazyn Warszawa, Sklep Kraków |
| Stock | Wirtualna pula źródeł przypisana do kanału sprzedaży | Stock „Polska” = WA + KR + GD |
| Source Item | Stan konkretnego SKU w konkretnym źródle | SKU-001 w WA: qty=50, status=1 |
| Salable Quantity | Dostępna ilość po rezerwacjach | 50 – 3 (rezerwacje) = 47 |
| Reservation | Tymczasowe zablokowanie towaru przy złożeniu zamówienia | -3 przy order placed |
| SSA | Source Selection Algorithm – dobiera źródła do wysyłki | Priority, Distance, Custom |
Sprawdzanie stanu MSI programatycznie
<?php
declare(strict_types=1);
use Magento\InventorySalesApi\Api\GetProductSalableQtyInterface;
use Magento\InventorySalesApi\Api\IsProductSalableInterface;
use Magento\InventorySalesApi\Api\IsProductSalableForRequestedQtyInterface;
class StockChecker
{
public function __construct(
private GetProductSalableQtyInterface $getSalableQty,
private IsProductSalableInterface $isProductSalable,
private IsProductSalableForRequestedQtyInterface $isProductSalableForQty,
) {}
public function check(string $sku, int $stockId = 1): array
{
$salableQty = $this->getSalableQty->execute($sku, $stockId);
$isSalable = $this->isProductSalable->execute($sku, $stockId);
return [
'sku' => $sku,
'stock_id' => $stockId,
'salable_qty' => $salableQty,
'is_salable' => $isSalable,
];
}
public function canFulfil(string $sku, float $requestedQty, int $stockId = 1): bool
{
$result = $this->isProductSalableForQty->execute($sku, $stockId, $requestedQty);
return $result->isSalable();
}
}
Source Items – bezpośredni dostęp do stanów źródeł
<?php
declare(strict_types=1);
use Magento\InventoryApi\Api\GetSourceItemsBySkuInterface;
use Magento\InventoryApi\Api\SourceItemsSaveInterface;
use Magento\InventoryApi\Api\Data\SourceItemInterfaceFactory;
class SourceItemManager
{
public function __construct(
private GetSourceItemsBySkuInterface $getSourceItemsBySku,
private SourceItemsSaveInterface $sourceItemsSave,
private SourceItemInterfaceFactory $sourceItemFactory,
) {}
// Pobierz stany ze wszystkich źródeł dla SKU
public function getBySkus(array $skus): array
{
$result = [];
foreach ($skus as $sku) {
$sourceItems = $this->getSourceItemsBySku->execute($sku);
foreach ($sourceItems as $item) {
$result[$sku][$item->getSourceCode()] = [
'qty' => $item->getQuantity(),
'status' => $item->getStatus(),
];
}
}
return $result;
}
// Ustaw stan w konkretnym źródle
public function setQty(string $sku, string $sourceCode, float $qty): void
{
$sourceItem = $this->sourceItemFactory->create();
$sourceItem->setSku($sku);
$sourceItem->setSourceCode($sourceCode);
$sourceItem->setQuantity($qty);
$sourceItem->setStatus($qty > 0 ? 1 : 0);
$this->sourceItemsSave->execute([$sourceItem]);
}
// Masowa aktualizacja stanów - efektywna dla importu
public function batchUpdate(array $updates): void
{
// $updates = [['sku' => '...', 'source_code' => '...', 'qty' => 0.0], ...]
$sourceItems = [];
foreach ($updates as $update) {
$item = $this->sourceItemFactory->create();
$item->setSku($update['sku']);
$item->setSourceCode($update['source_code']);
$item->setQuantity($update['qty']);
$item->setStatus($update['qty'] > 0 ? 1 : 0);
$sourceItems[] = $item;
}
if (!empty($sourceItems)) {
$this->sourceItemsSave->execute($sourceItems);
}
}
}
Custom Source Selection Algorithm
Domyślny SSA „Priority” realizuje zamówienie ze źródeł według priorytetu (liczba w konfiguracji). Custom SSA pozwala na własną logikę – np. dobór najbliższego magazynu do adresu dostawy, minimalizacja kosztów wysyłki, lub preferowanie źródeł z nadwyżką towaru.
<?php
declare(strict_types=1);
namespace Vendor\Inventory\Model\Algorithms;
use Magento\InventorySourceSelectionApi\Api\Data\InventoryRequestInterface;
use Magento\InventorySourceSelectionApi\Api\Data\SourceSelectionResultInterface;
use Magento\InventorySourceSelectionApi\Api\Data\SourceSelectionResultInterfaceFactory;
use Magento\InventorySourceSelectionApi\Api\Data\SourceSelectionItemInterfaceFactory;
use Magento\InventorySourceSelectionApi\Model\SourceSelectionInterface;
use Magento\InventoryApi\Api\GetSourcesAssignedToStockOrderedByPriorityInterface;
class MinimizeSourcesAlgorithm implements SourceSelectionInterface
{
public function __construct(
private GetSourcesAssignedToStockOrderedByPriorityInterface $getSourcesOrderedByPriority,
private SourceSelectionResultInterfaceFactory $selectionResultFactory,
private SourceSelectionItemInterfaceFactory $selectionItemFactory,
private \Magento\InventorySourceSelectionApi\Model\GetInStockSourceItemsBySkusAndSortedSource $getInStockItems,
) {}
// Algorytm: realizuj zamówienie z jak najmniejszej liczby źródeł
// (minimalizuje koszty wysyłki przy wysyłce z wielu lokalizacji)
public function execute(InventoryRequestInterface $inventoryRequest): SourceSelectionResultInterface
{
$stockId = $inventoryRequest->getStockId();
$sources = $this->getSourcesOrderedByPriority->execute($stockId);
$requestItems = $inventoryRequest->getItems();
$result = [];
$unresolved = [];
// Pierwsza faza: sprawdź czy jedno źródło może obsłużyć całe zamówienie
foreach ($sources as $source) {
$sourceCode = $source->getSourceCode();
$canFulfil = true;
foreach ($requestItems as $requestItem) {
$inStockItems = $this->getInStockItems->execute(
[$requestItem->getSku()],
[$sourceCode]
);
$available = 0.0;
foreach ($inStockItems as $item) {
if ($item->getSourceCode() === $sourceCode) {
$available = $item->getQuantity();
break;
}
}
if ($available < $requestItem->getQty()) {
$canFulfil = false;
break;
}
}
if ($canFulfil) {
// Jedno źródło wystarczy - użyj go
foreach ($requestItems as $requestItem) {
$result[] = $this->selectionItemFactory->create([
'sourceCode' => $sourceCode,
'sku' => $requestItem->getSku(),
'qtyToDeduct' => $requestItem->getQty(),
'qtyAvailableToDeduct'=> $requestItem->getQty(),
]);
}
return $this->selectionResultFactory->create([
'sourceSelectionItems' => $result,
'shippable' => true,
]);
}
}
// Druga faza: podziel między źródła (fallback do priority SSA)
return $this->selectionResultFactory->create([
'sourceSelectionItems' => $result,
'shippable' => false,
]);
}
}
Rejestracja Custom SSA w di.xml
<?xml version="1.0"?>
<config>
<type name="Magento\InventorySourceSelectionApi\Model\Algorithms\Registry">
<arguments>
<argument name="algorithms" xsi:type="array">
<item name="minimize_sources" xsi:type="array">
<item name="label" xsi:type="string" translate="true">Minimize Sources</item>
<item name="class" xsi:type="string">Vendor\Inventory\Model\Algorithms\MinimizeSourcesAlgorithm</item>
</item>
</argument>
</arguments>
</type>
</config>
Rezerwacje – jak działają
<?php
// Rezerwacje to mechanizm "soft lock" na salable quantity
// Przy złożeniu zamówienia: reservation qty = -3
// Przy anulowaniu: reservation qty = +3 (kompensacja)
// Przy shipment: reservation usunięta, source item qty zmniejszona
use Magento\InventoryReservationsApi\Model\AppendReservationsInterface;
use Magento\InventoryReservationsApi\Model\ReservationBuilderInterface;
class ReservationManager
{
public function __construct(
private AppendReservationsInterface $appendReservations,
private ReservationBuilderInterface $reservationBuilder,
) {}
public function reserve(string $sku, float $qty, int $stockId, string $metadata): void
{
$reservation = $this->reservationBuilder
->setSku($sku)
->setQuantity(-$qty) // ujemna = rezerwacja
->setStockId($stockId)
->setMetadata($metadata)
->build();
$this->appendReservations->execute([$reservation]);
}
public function compensate(string $sku, float $qty, int $stockId, string $metadata): void
{
$reservation = $this->reservationBuilder
->setSku($sku)
->setQuantity($qty) // dodatnia = kompensata
->setStockId($stockId)
->setMetadata($metadata)
->build();
$this->appendReservations->execute([$reservation]);
}
}
Podsumowanie
MSI rozdziela fizyczne źródła towaru od wirtualnych puli (Stock) przypisanych do kanałów sprzedaży. Source Selection Algorithm to punkt rozszerzalności gdzie własna logika biznesowa decyduje skąd realizować zamówienie. Rezerwacje zapewniają spójność stanów bez blokowania bazy danych. Custom SSA rejestrowany przez di.xml pojawia się automatycznie w panelu admina przy tworzeniu shipmentu. Następny wpis: zaawansowane enumy w PHP 8.1+ – backed enums, metody, interfejsy.
