Inventory Sync Architecture
Architecture Overview
┌─────────────────────────────────────────────────────────â”
│ Inventory Sources │
│ ┌──────────┠┌──────────┠┌──────────┠┌──────────â”│
│ │Warehouse │ │Warehouse │ │ Store A │ │ Store B ││
│ │ East │ │ West │ │ (Retail) │ │ (Retail) ││
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘│
│ │ │ │ │ │
└───────┼──────────────┼──────────────┼──────────────┼──────┘
│ │ │ │
┌────┴──────────────┴──────────────┴──────────────┴────â”
│ Inventory Sync Service │
│ ┌──────────┠┌──────────┠┌──────────┠│
│ │ Source │ │ Stock │ │Reservation│ │
│ │Manager │ │Manager │ │ Manager │ │
│ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────┬───────────────────────────────┘
│
┌──────────┴──────────â”
│ Message Queue │
│ (Inventory Sync) │
└──────────┬──────────┘
│
┌──────────┴──────────â”
│ Redis Cache │
│ (Stock Counts) │
└─────────────────────┘
Module Structure
app/code/Vendor/InventorySync/
├── registration.php
├── etc/
│ ├── module.xml
│ ├── di.xml
│ ├── communication.xml
│ └── queue_consumer.xml
├── Api/
│ ├── InventorySyncInterface.php
│ ├── StockManagerInterface.php
│ └── SourceItemManagerInterface.php
├── Model/
│ ├── Sync/
│ │ ├── InventorySyncService.php
│ │ ├── StockBalancer.php
│ │ └── ReservationManager.php
│ ├── Source/
│ │ ├── SourceRegistry.php
│ │ └── SourceItemUpdater.php
│ └── Alert/
│ │ ├── LowStockAlert.php
│ │ └── OutOfStockAlert.php
├── Queue/
│ ├── Publisher/
│ │ └── InventoryPublisher.php
│ └── Consumer/
│ └── InventoryConsumer.php
├── Observer/
│ ├── StockChangeObserver.php
│ └── OrderPlacedObserver.php
└── Cron/
└── SyncInventoryCron.php
Inventory Sync Interface
<?php
namespace Vendor\InventorySync\Api;
interface InventorySyncInterface
{
public function syncSourceItem(string $sourceCode, string $sku, float $quantity): bool;
public function syncBulk(array $items): array;
public function getSalableQuantity(string $sku, int $stockId): float;
public function reserveStock(string $sku, int $stockId, int $quantity, string $orderId): bool;
public function releaseReservation(string $sku, int $stockId, string $orderId): bool;
public function getStockStatus(string $sku, int $stockId): string;
}
Inventory Sync Service Implementation
Core Sync Service
<?php
namespace Vendor\InventorySync\Model\Sync;
use Magento\InventoryApi\Api\SourceItemsSaveInterface;
use Magento\InventoryApi\Api\GetSourceItemsBySkuInterface;
use Magento\InventoryApi\Api\GetStockItemConfigurationInterface;
use Magento\InventoryApi\Api\AppendReservationsInterface;
use Magento\InventoryApi\Api\Data\ReservationInterfaceFactory;
use Magento\InventoryApi\Api\GetStockItemsBySkuInterface;
use Vendor\InventorySync\Api\InventorySyncInterface;
use Psr\Log\LoggerInterface;
class InventorySyncService implements InventorySyncInterface
{
public function __construct(
private SourceItemsSaveInterface $sourceItemsSave,
private GetSourceItemsBySkuInterface $getSourceItems,
private GetStockItemConfigurationInterface $getStockConfig,
private AppendReservationsInterface $appendReservations,
private ReservationInterfaceFactory $reservationFactory,
private GetStockItemsBySkuInterface $getStockItems,
private LoggerInterface $logger,
private \Magento\Framework\Cache\CacheInterface $cache,
private \Magento\Framework\App\ResourceConnection $resource,
) {
}
public function syncSourceItem(string $sourceCode, string $sku, float $quantity): bool
{
try {
$sourceItems = $this->getSourceItems->execute($sku);
$found = false;
foreach ($sourceItems as $item) {
if ($item->getSourceCode() === $sourceCode) {
$item->setQuantity($quantity);
$item->setStatus($quantity > 0 ? 1 : 0);
$found = true;
break;
}
}
if (!$found) {
$item = $this->createSourceItem($sourceCode, $sku, $quantity);
$sourceItems[] = $item;
}
$this->sourceItemsSave->execute($sourceItems);
// Invalidate cache
$this->cache->clean(['inventory_stock_' . $sku]);
// Log sync
$this->logSync($sourceCode, $sku, $quantity, 'success');
return true;
} catch (\Exception $e) {
$this->logger->error('Inventory sync failed', [
'source' => $sourceCode,
'sku' => $sku,
'error' => $e->getMessage(),
]);
$this->logSync($sourceCode, $sku, $quantity, 'failed');
return false;
}
}
public function syncBulk(array $items): array
{
$results = ['success' => 0, 'failed' => 0, 'errors' => []];
foreach ($items as $item) {
$success = $this->syncSourceItem(
$item['source_code'],
$item['sku'],
$item['quantity']
);
if ($success) {
$results['success']++;
} else {
$results['failed']++;
$results['errors'][] = $item;
}
}
return $results;
}
public function getSalableQuantity(string $sku, int $stockId): float
{
$cacheKey = 'salable_qty_' . $sku . '_' . $stockId;
$cached = $this->cache->load($cacheKey);
if ($cached !== false) {
return (float) $cached;
}
$stockItems = $this->getStockItems->execute([$sku], $stockId);
$salable = 0;
foreach ($stockItems as $stockItem) {
$salable = (float) $stockItem->getQty();
}
$this->cache->save($salable, $cacheKey, ['inventory_stock_' . $sku], 60);
return $salable;
}
public function reserveStock(string $sku, int $stockId, int $quantity, string $orderId): bool
{
try {
$reservation = $this->reservationFactory->create();
$reservation->setSku($sku);
$reservation->setQuantity(-abs($quantity));
$reservation->setStockId($stockId);
$reservation->setMetadata('order_' . $orderId);
$this->appendReservations->execute([$reservation]);
$this->logger->info('Stock reserved', [
'sku' => $sku,
'qty' => $quantity,
'order' => $orderId,
]);
return true;
} catch (\Exception $e) {
$this->logger->error('Stock reservation failed', [
'sku' => $sku,
'error' => $e->getMessage(),
]);
return false;
}
}
public function releaseReservation(string $sku, int $stockId, string $orderId): bool
{
try {
$reservation = $this->reservationFactory->create();
$reservation->setSku($sku);
$reservation->setQuantity(abs($this->getReservedQuantity($sku, $orderId)));
$reservation->setStockId($stockId);
$reservation->setMetadata('order_' . $orderId . '_cancel');
$this->appendReservations->execute([$reservation]);
$this->logger->info('Reservation released', [
'sku' => $sku,
'order' => $orderId,
]);
return true;
} catch (\Exception $e) {
$this->logger->error('Reservation release failed', [
'sku' => $sku,
'error' => $e->getMessage(),
]);
return false;
}
}
public function getStockStatus(string $sku, int $stockId): string
{
$config = $this->getStockConfig->execute($sku, $stockId);
return $config->isSalable() ? 'in_stock' : 'out_of_stock';
}
private function createSourceItem(string $sourceCode, string $sku, float $quantity)
{
$itemFactory = \Magento\InventoryApi\Api\Data\SourceItemInterfaceFactory::class;
$item = (new \Magento\Framework\ObjectManager\Factory\DeclaredTest())->create($itemFactory);
$item->setSourceCode($sourceCode);
$item->setSku($sku);
$item->setQuantity($quantity);
$item->setStatus($quantity > 0 ? 1 : 0);
return $item;
}
private function getReservedQuantity(string $sku, string $orderId): float
{
$connection = $this->resource->getConnection();
$tableName = $this->resource->getTableName('inventory_reservation');
return (float) $connection->fetchOne(
"SELECT COALESCE(SUM(quantity), 0) FROM {$tableName} WHERE sku = ? AND metadata LIKE ?",
[$sku, '%order_' . $orderId . '%']
);
}
private function logSync(string $sourceCode, string $sku, float $quantity, string $status): void
{
$connection = $this->resource->getConnection();
$tableName = $this->resource->getTableName('inventory_sync_log');
$connection->insert($tableName, [
'source_code' => $sourceCode,
'sku' => $sku,
'quantity' => $quantity,
'status' => $status,
'created_at' => (new \DateTime())->format('Y-m-d H:i:s'),
]);
}
}
Stock Balancing and Alerts
Stock Balancer
<?php
namespace Vendor\InventorySync\Model\Sync;
use Magento\InventoryApi\Api\GetSourceItemsBySkuInterface;
use Magento\InventoryApi\Api\SourceItemsSaveInterface;
use Psr\Log\LoggerInterface;
class StockBalancer
{
private float $safetyStockThreshold = 10;
public function __construct(
private GetSourceItemsBySkuInterface $getSourceItems,
private SourceItemsSaveInterface $sourceItemsSave,
private LoggerInterface $logger,
) {
}
public function rebalanceStock(string $sku, array $sourcePriorities): array
{
$sourceItems = $this->getSourceItems->execute($sku);
$sourceStock = [];
$totalStock = 0;
foreach ($sourceItems as $item) {
$sourceStock[$item->getSourceCode()] = [
'quantity' => $item->getQuantity(),
'priority' => $sourcePriorities[$item->getSourceCode()] ?? 999,
];
$totalStock += $item->getQuantity();
}
// Sort by priority
uasort($sourceStock, fn($a, $b) => $a['priority'] <=> $b['priority']);
// Ensure no source goes below safety threshold
$rebalanced = [];
foreach ($sourceStock as $sourceCode => &$data) {
if ($data['quantity'] < $this->safetyStockThreshold && $totalStock > $this->safetyStockThreshold) {
$transferQty = $this->safetyStockThreshold - $data['quantity'];
// Find source with excess stock
foreach ($sourceStock as $otherCode => &$otherData) {
if ($otherCode !== $sourceCode && $otherData['quantity'] > $this->safetyStockThreshold * 2) {
$transfer = min($transferQty, $otherData['quantity'] - $this->safetyStockThreshold);
$otherData['quantity'] -= $transfer;
$data['quantity'] += $transfer;
$transferQty -= $transfer;
$rebalanced[] = [
'from' => $otherCode,
'to' => $sourceCode,
'quantity' => $transfer,
];
if ($transferQty <= 0) break;
}
}
}
}
return $rebalanced;
}
public function calculateOptimalDistribution(string $sku, array $salesVelocity): array
{
$sourceItems = $this->getSourceItems->execute($sku);
$distribution = [];
foreach ($sourceItems as $item) {
$velocity = $salesVelocity[$item->getSourceCode()] ?? 0;
$daysOfStock = $velocity > 0 ? $item->getQuantity() / $velocity : 999;
$distribution[$item->getSourceCode()] = [
'current_qty' => $item->getQuantity(),
'velocity' => $velocity,
'days_of_stock' => round($daysOfStock, 1),
'needs_restock' => $daysOfStock < 7,
];
}
return $distribution;
}
}
Low Stock Alert
<?php
namespace Vendor\InventorySync\Model\Alert;
use Magento\InventoryApi\Api\GetSourceItemsBySkuInterface;
use Magento\InventoryApi\Api\GetStockItemConfigurationInterface;
use Psr\Log\LoggerInterface;
class LowStockAlert
{
public function __construct(
private GetSourceItemsBySkuInterface $getSourceItems,
private GetStockItemConfigurationInterface $getStockConfig,
private \Magento\Framework\Mail\TransportInterface $mailTransport,
private \Magento\Framework\App\Config\ScopeConfigInterface $scopeConfig,
private LoggerInterface $logger,
) {
}
public function checkAndAlert(string $sku, int $stockId): void
{
$sourceItems = $this->getSourceItems->execute($sku);
$config = $this->getStockConfig->execute($sku, $stockId);
$salable = $config->isSalable();
$totalQty = 0;
$lowSources = [];
foreach ($sourceItems as $item) {
$totalQty += $item->getQuantity();
if ($item->getQuantity() < 10) {
$lowSources[] = $item->getSourceCode();
}
}
if ($totalQty < 5) {
$this->sendAlert('critical', $sku, $totalQty, $lowSources);
} elseif ($totalQty < 20) {
$this->sendAlert('warning', $sku, $totalQty, $lowSources);
}
if (!$salable) {
$this->sendOutOfStockAlert($sku);
}
}
private function sendAlert(string $level, string $sku, float $qty, array $sources): void
{
$adminEmail = $this->scopeConfig->getValue('trans_email/ident_general/email');
$subject = match($level) {
'critical' => "CRITICAL: {$sku} stock critically low ({$qty} units)",
'warning' => "WARNING: {$sku} stock low ({$qty} units)",
};
$body = "Stock Alert for {$sku}\n";
$body .= "Current quantity: {$qty}\n";
$body .= "Low sources: " . implode(', ', $sources) . "\n";
$body .= "Action required: Reorder immediately\n";
$this->sendEmail($adminEmail, $subject, $body);
}
private function sendOutOfStockAlert(string $sku): void
{
$adminEmail = $this->scopeConfig->getValue('trans_email/ident_general/email');
$subject = "OUT OF STOCK: {$sku} is no longer available";
$body = "{$sku} has gone out of stock across all sources.";
$this->sendEmail($adminEmail, $subject, $body);
}
private function sendEmail(string $to, string $subject, string $body): void
{
$mail = new \Zend_Mail();
$mail->setBodyText($body);
$mail->setFrom('alerts@store.com', 'Inventory Alerts');
$mail->addTo($to);
$mail->setSubject($subject);
try {
$this->mailTransport->send($mail);
} catch (\Exception $e) {
$this->logger->error('Failed to send alert', ['error' => $e->getMessage()]);
}
}
}
Event Handling and Cron Jobs
Stock Change Observer
<?php
namespace Vendor\InventorySync\Observer;
use Magento\Framework\Event\ObserverInterface;
use Magento\Framework\Event\Observer;
use Vendor\InventorySync\Api\InventorySyncInterface;
class StockChangeObserver implements ObserverInterface
{
public function __construct(
private InventorySyncInterface $inventorySync,
private \Psr\Log\LoggerInterface $logger,
) {
}
public function execute(Observer $observer): void
{
$item = $observer->getEvent()->getItem();
$this->logger->info('Stock change detected', [
'sku' => $item->getSku(),
'source' => $item->getSourceCode(),
'qty' => $item->getQuantity(),
]);
// Trigger sync to external systems
$this->publishStockUpdate($item);
}
private function publishStockUpdate($item): void
{
$publisher = $this->objectManager->create(
\Magento\Framework\MessageQueue\PublisherInterface::class
);
$publisher->publish(
'inventory.sync.update',
json_encode([
'sku' => $item->getSku(),
'source_code' => $item->getSourceCode(),
'quantity' => $item->getQuantity(),
'timestamp' => time(),
])
);
}
}
Order Placed Observer
<?php
namespace Vendor\InventorySync\Observer;
use Magento\Framework\Event\ObserverInterface;
use Magento\Framework\Event\Observer;
use Vendor\InventorySync\Api\InventorySyncInterface;
class OrderPlacedObserver implements ObserverInterface
{
public function __construct(
private InventorySyncInterface $inventorySync,
) {
}
public function execute(Observer $observer): void
{
/** @var \Magento\Sales\Model\Order $order */
$order = $observer->getEvent()->getOrder();
if ($order->getState() === \Magento\Sales\Model\Order::STATE_NEW) {
foreach ($order->getAllItems() as $item) {
$this->inventorySync->reserveStock(
$item->getSku(),
1, // Default stock ID
$item->getQtyOrdered(),
$order->getIncrementId()
);
}
}
}
}
Cron Job for Periodic Sync
<?php
namespace Vendor\InventorySync\Cron;
use Vendor\InventorySync\Api\InventorySyncInterface;
use Vendor\InventorySync\Model\Alert\LowStockAlert;
use Psr\Log\LoggerInterface;
class SyncInventoryCron
{
public function __construct(
private InventorySyncInterface $inventorySync,
private LowStockAlert $lowStockAlert,
private LoggerInterface $logger,
private \Magento\Catalog\Api\ProductRepositoryInterface $productRepository,
) {
}
public function execute(): void
{
$this->logger->info('Inventory sync cron started');
try {
// Get products that need sync
$products = $this->getProductsForSync();
foreach ($products as $product) {
$this->lowStockAlert->checkAndAlert(
$product->getSku(),
1
);
}
$this->logger->info('Inventory sync cron completed', [
'products_checked' => count($products),
]);
} catch (\Exception $e) {
$this->logger->error('Inventory sync cron failed', [
'error' => $e->getMessage(),
]);
}
}
private function getProductsForSync(): array
{
// Get products modified in last 5 minutes
$fromDate = date('Y-m-d H:i:s', strtotime('-5 minutes'));
$collection = $this->productCollectionFactory->create();
$collection->addFieldToFilter('updated_at', ['gteq' => $fromDate]);
$collection->addFieldToFilter('status', ['eq' => 1]);
return $collection->getItems();
}
}
Events Configuration
<!-- etc/events.xml -->
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Event/etc/events.xsd">
<event name="inventory_source_item_save_after">
<observer name="inventory_sync_stock_change" instance="Vendor\InventorySync\Observer\StockChangeObserver"/>
</event>
<event name="sales_order_place_after">
<observer name="inventory_sync_order_placed" instance="Vendor\InventorySync\Observer\OrderPlacedObserver"/>
</event>
</config>
Quiz
1. What is salable quantity in MSI?
2. How do you reserve stock for an order?
3. What is a source in MSI?
Flashcards
Question
What is a source?
Click to reveal answer
Answer
Physical inventory location (warehouse, store)
Question
What is a stock?
Click to reveal answer
Answer
Virtual group of sources for a sales channel
Question
What is a reservation?
Click to reveal answer
Answer
Temporary stock hold for pending orders (negative qty)
Question
What is salable quantity?
Click to reveal answer
Answer
Available quantity = source quantities - reservations
Question
How do you sync inventory?
Click to reveal answer
Answer
Use SourceItemsSaveInterface to update source items
Revision Notes
Key Takeaways
- 1. MSI provides multi-location inventory management
- 2. Reservations are temporary stock holds converted on invoice
- 3. Salable quantity = source quantities - reservations
- 4. Stock balancing ensures no source goes below safety threshold
- 5. Alerts notify when stock levels drop below thresholds
Interview Tips
- • Explain the reservation lifecycle in MSI
- • Describe how to implement custom stock balancing
- • Discuss real-time vs batch inventory sync approaches
- • Talk about handling stock across multiple sales channels
Cheat Sheet
Inventory Sync:
Sources → Physical locations
Stocks → Virtual groups
Reservations → Pending order holds
Salable = Sources - Reservations
APIs:
SourceItemsSaveInterface → Save source items
AppendReservationsInterface → Create reservations
GetStockItemsBySkuInterface → Get salable qty
Stock Balancing:
Safety threshold → Min qty per source
Rebalance → Transfer between sources
Velocity → Sales rate per source
Alerts:
Critical → <5 units
Warning → <20 units
Out of stock → 0 salable
Cron:
Periodic sync check
Low stock alerts
Cache invalidation