Skip to content
advanced Phase 118 · Advanced Projects

Project - Inventory Synchronization

Build an inventory synchronization system across multiple sources with real-time updates, reservation handling, and stock management

2h
0 problems
Topic Progress 0%

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?

Question 1 options

2. How do you reserve stock for an order?

Question 2 options

3. What is a source in MSI?

Question 3 options

Flashcards

Question

What is a source?

Answer

Physical inventory location (warehouse, store)

Question

What is a stock?

Answer

Virtual group of sources for a sales channel

Question

What is a reservation?

Answer

Temporary stock hold for pending orders (negative qty)

Question

What is salable quantity?

Answer

Available quantity = source quantities - reservations

Question

How do you sync inventory?

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