Memuat...
👋 Selamat Pagi!

Cara Membangun Sistem Multi Warehouse Inventory untuk E-Commerce Scale Up

Punya cabang atau gudang lebih dari satu? Sistem inventory yang salah bisa bikin stok kacau dan pelanggan kecewa. Panduan lengkap membangun multi warehouse inve...

Cara Membangun Sistem Multi Warehouse Inventory untuk E-Commerce Scale Up

Bisnis e-commerce yang berkembang pasti menghadapi satu tantangan besar: mengelola stok di banyak gudang sekaligus.

Bayangkan pelanggan di Jakarta pesan produk, ternyata stok kosong di gudang Jakarta tapi ada di Surabaya. Ongkir jadi mahal, delivery jadi lama, pelanggan komplain.

Sistem inventory single warehouse sudah tidak cukup ketika bisnis Anda mulai ekspansi ke kota lain atau punya beberapa pusat distribusi.

Artikel ini akan membahas cara membangun sistem multi warehouse inventory yang efisien, scalable, dan mencegah stok chaos di bisnis e-commerce Anda.

Mengapa Multi Warehouse Inventory Itu Penting

Banyak pebisnis online yang mengabaikan pentingnya sistem gudang yang proper sampai mereka mengalami masalah stok besar-besaran.

Ketika Anda hanya punya satu gudang, semuanya mudah. Stok masuk, stok keluar, tinggal catat di spreadsheet atau sistem sederhana.

Masalah mulai muncul ketika Anda membuka gudang kedua untuk efisiensi pengiriman ke wilayah tertentu.

Tiba-tiba ada pertanyaan yang sulit dijawab: produk A ada berapa di gudang Jakarta? Berapa di Surabaya? Apakah perlu transfer stok antar gudang? Gudang mana yang harus fulfill order dari pelanggan di Bandung?

Tanpa sistem yang tepat, tim Anda akan menghabiskan waktu berjam-jam setiap hari hanya untuk ngecek stok manual via WhatsApp atau telepon ke setiap gudang.

Sistem multi warehouse yang baik menyelesaikan masalah ini dengan memberikan visibilitas real-time ke semua lokasi gudang sekaligus.

Arsitektur Database untuk Multi Warehouse

Fondasi utama sistem multi warehouse adalah database design yang tepat.

Kesalahan terbesar yang sering dilakukan adalah menambahkan kolom "warehouse_id" di tabel products. Ini akan jadi nightmare ketika satu produk ada di beberapa gudang dengan jumlah berbeda.

Struktur yang lebih baik adalah memisahkan master product dengan inventory per lokasi.

Tabel Master Products

Tabel ini menyimpan informasi produk yang bersifat global, tidak tergantung lokasi gudang.

CREATE TABLE products (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    sku VARCHAR(50) UNIQUE NOT NULL,
    name VARCHAR(255) NOT NULL,
    description TEXT,
    price DECIMAL(15,2) NOT NULL,
    weight DECIMAL(10,2),
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    INDEX idx_sku (sku)
);

Tabel Warehouses

Daftar semua gudang atau lokasi penyimpanan yang Anda miliki.

CREATE TABLE warehouses (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    code VARCHAR(20) UNIQUE NOT NULL,
    name VARCHAR(100) NOT NULL,
    address TEXT,
    city VARCHAR(100),
    province VARCHAR(100),
    phone VARCHAR(20),
    is_active BOOLEAN DEFAULT TRUE,
    priority INT DEFAULT 0,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

Kolom priority berguna untuk menentukan gudang mana yang diprioritaskan untuk fulfill order ketika stok tersedia di beberapa lokasi.

Tabel Inventory

Ini adalah tabel paling krusial yang menyimpan jumlah stok per produk per gudang.

CREATE TABLE inventory (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    product_id BIGINT NOT NULL,
    warehouse_id BIGINT NOT NULL,
    quantity INT NOT NULL DEFAULT 0,
    reserved_quantity INT NOT NULL DEFAULT 0,
    available_quantity INT GENERATED ALWAYS AS (quantity - reserved_quantity) STORED,
    min_stock_level INT DEFAULT 0,
    max_stock_level INT DEFAULT 0,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE,
    FOREIGN KEY (warehouse_id) REFERENCES warehouses(id) ON DELETE CASCADE,
    UNIQUE KEY unique_product_warehouse (product_id, warehouse_id),
    INDEX idx_warehouse (warehouse_id),
    INDEX idx_product (product_id)
);

Perhatikan kolom reserved_quantity. Ini sangat penting untuk menghindari overselling.

Ketika pelanggan checkout tapi belum bayar, stok harus di-reserve sementara. Kolom available_quantity otomatis calculated sebagai quantity dikurangi reserved.

Tabel Inventory Movements

Setiap perubahan stok harus tercatat untuk audit trail dan tracking.

CREATE TABLE inventory_movements (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    product_id BIGINT NOT NULL,
    warehouse_id BIGINT NOT NULL,
    movement_type ENUM('IN', 'OUT', 'TRANSFER_IN', 'TRANSFER_OUT', 'ADJUSTMENT', 'RESERVE', 'RELEASE') NOT NULL,
    quantity INT NOT NULL,
    reference_type VARCHAR(50),
    reference_id BIGINT,
    notes TEXT,
    created_by BIGINT,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (product_id) REFERENCES products(id),
    FOREIGN KEY (warehouse_id) REFERENCES warehouses(id),
    INDEX idx_product_warehouse (product_id, warehouse_id),
    INDEX idx_reference (reference_type, reference_id),
    INDEX idx_created_at (created_at)
);

Tabel ini mencatat setiap transaksi: stok masuk dari supplier, keluar karena penjualan, transfer antar gudang, adjustment karena stock opname, reserve untuk order pending, dan release ketika order dibatalkan.

Logika Warehouse Selection Otomatis

Salah satu fitur paling penting adalah memilih gudang mana yang harus fulfill order secara otomatis.

Algoritma sederhana yang bisa digunakan adalah proximity-based selection: pilih gudang terdekat dengan alamat pengiriman yang memiliki stok tersedia.

Implementasi di Laravel

class WarehouseSelector
{
    public function selectWarehouse($productId, $quantity, $destinationCity)
    {
        // Ambil semua warehouse yang punya stok cukup
        $warehouses = Inventory::where('product_id', $productId)
            ->where('available_quantity', '>=', $quantity)
            ->with('warehouse')
            ->get()
            ->pluck('warehouse')
            ->filter(fn($w) => $w->is_active);
        
        if ($warehouses->isEmpty()) {
            throw new InsufficientStockException();
        }
        
        // Jika hanya ada satu warehouse dengan stok, langsung return
        if ($warehouses->count() === 1) {
            return $warehouses->first();
        }
        
        // Prioritas berdasarkan kota yang sama
        $sameCity = $warehouses->firstWhere('city', $destinationCity);
        if ($sameCity) {
            return $sameCity;
        }
        
        // Fallback ke warehouse dengan priority tertinggi
        return $warehouses->sortByDesc('priority')->first();
    }
}

Untuk implementasi yang lebih advanced, Anda bisa integrasi dengan API shipping untuk calculate actual shipping cost dari setiap warehouse dan pilih yang paling murah.

Sistem Reserve dan Release Inventory

Overselling adalah mimpi buruk setiap e-commerce. Dua pelanggan checkout produk yang sama secara bersamaan, keduanya berhasil, padahal stok tinggal satu.

Solusinya adalah inventory reservation system.

Reserve Ketika Checkout

class InventoryService
{
    public function reserve($productId, $warehouseId, $quantity, $orderId)
    {
        DB::beginTransaction();
        
        try {
            $inventory = Inventory::where('product_id', $productId)
                ->where('warehouse_id', $warehouseId)
                ->lockForUpdate() // Penting untuk prevent race condition
                ->first();
            
            if (!$inventory || $inventory->available_quantity reserved_quantity += $quantity;
            $inventory->save();
            
            // Log movement
            InventoryMovement::create([
                'product_id' => $productId,
                'warehouse_id' => $warehouseId,
                'movement_type' => 'RESERVE',
                'quantity' => $quantity,
                'reference_type' => 'order',
                'reference_id' => $orderId,
            ]);
            
            DB::commit();
            return true;
            
        } catch (\Exception $e) {
            DB::rollBack();
            throw $e;
        }
    }
}

LockForUpdate() sangat penting di sini. Ini memastikan row di-lock selama transaction, mencegah dua request mengakses stok yang sama secara bersamaan.

Release Ketika Order Dibatalkan

public function release($productId, $warehouseId, $quantity, $orderId)
{
    DB::beginTransaction();
    
    try {
        $inventory = Inventory::where('product_id', $productId)
            ->where('warehouse_id', $warehouseId)
            ->lockForUpdate()
            ->first();
        
        $inventory->reserved_quantity -= $quantity;
        $inventory->save();
        
        InventoryMovement::create([
            'product_id' => $productId,
            'warehouse_id' => $warehouseId,
            'movement_type' => 'RELEASE',
            'quantity' => $quantity,
            'reference_type' => 'order',
            'reference_id' => $orderId,
        ]);
        
        DB::commit();
        
    } catch (\Exception $e) {
        DB::rollBack();
        throw $e;
    }
}

Deduct Ketika Order Dikonfirmasi

Setelah payment confirmed, ubah reserved menjadi actual deduction.

public function deduct($productId, $warehouseId, $quantity, $orderId)
{
    DB::beginTransaction();
    
    try {
        $inventory = Inventory::where('product_id', $productId)
            ->where('warehouse_id', $warehouseId)
            ->lockForUpdate()
            ->first();
        
        // Kurangi dari quantity aktual dan reserved
        $inventory->quantity -= $quantity;
        $inventory->reserved_quantity -= $quantity;
        $inventory->save();
        
        InventoryMovement::create([
            'product_id' => $productId,
            'warehouse_id' => $warehouseId,
            'movement_type' => 'OUT',
            'quantity' => $quantity,
            'reference_type' => 'order',
            'reference_id' => $orderId,
        ]);
        
        DB::commit();
        
    } catch (\Exception $e) {
        DB::rollBack();
        throw $e;
    }
}

Transfer Stock Antar Warehouse

Ketika satu gudang kehabisan stok tapi gudang lain masih banyak, Anda perlu transfer.

Transfer stock harus atomic dan traceable. Tidak boleh ada kondisi stok hilang di tengah jalan.

class WarehouseTransferService
{
    public function transfer($productId, $fromWarehouseId, $toWarehouseId, $quantity, $notes = null)
    {
        DB::beginTransaction();
        
        try {
            // Validasi stok asal
            $fromInventory = Inventory::where('product_id', $productId)
                ->where('warehouse_id', $fromWarehouseId)
                ->lockForUpdate()
                ->first();
            
            if (!$fromInventory || $fromInventory->available_quantity quantity -= $quantity;
            $fromInventory->save();
            
            InventoryMovement::create([
                'product_id' => $productId,
                'warehouse_id' => $fromWarehouseId,
                'movement_type' => 'TRANSFER_OUT',
                'quantity' => -$quantity,
                'notes' => $notes,
            ]);
            
            // Tambah ke gudang tujuan
            $toInventory = Inventory::firstOrCreate(
                [
                    'product_id' => $productId,
                    'warehouse_id' => $toWarehouseId
                ],
                ['quantity' => 0, 'reserved_quantity' => 0]
            );
            
            $toInventory->quantity += $quantity;
            $toInventory->save();
            
            InventoryMovement::create([
                'product_id' => $productId,
                'warehouse_id' => $toWarehouseId,
                'movement_type' => 'TRANSFER_IN',
                'quantity' => $quantity,
                'notes' => $notes,
            ]);
            
            DB::commit();
            
            return [
                'success' => true,
                'from_remaining' => $fromInventory->quantity,
                'to_total' => $toInventory->quantity
            ];
            
        } catch (\Exception $e) {
            DB::rollBack();
            throw $e;
        }
    }
}

Low Stock Alert dan Auto Reorder Point

Anda tidak ingin tahu-tahu gudang kehabisan stok untuk produk best seller.

Sistem alert otomatis bisa mengirim notifikasi ketika stok mendekati minimum level.

// Scheduled job yang jalan setiap hari
class CheckLowStockJob implements ShouldQueue
{
    public function handle()
    {
        $lowStockItems = Inventory::whereRaw('quantity where('min_stock_level', '>', 0)
            ->with(['product', 'warehouse'])
            ->get();
        
        foreach ($lowStockItems as $item) {
            // Kirim notifikasi ke purchasing team
            Notification::route('mail', '[email protected]')
                ->notify(new LowStockAlert($item));
            
            // Log untuk dashboard
            logger()->warning('Low stock detected', [
                'product' => $item->product->name,
                'warehouse' => $item->warehouse->name,
                'current_qty' => $item->quantity,
                'min_level' => $item->min_stock_level
            ]);
        }
    }
}

Dashboard dan Reporting

Tim Anda butuh visibilitas real-time tentang kondisi inventory di semua gudang.

Dashboard yang baik harus menampilkan minimal:

  • Total stok per produk across all warehouses
  • Stok per gudang dengan breakdown available vs reserved
  • Produk dengan stok di bawah minimum level
  • Movement history dengan filter tanggal dan tipe
  • Nilai inventory total per gudang

Query untuk Total Stock Across Warehouses

SELECT 
    p.id,
    p.sku,
    p.name,
    SUM(i.quantity) as total_quantity,
    SUM(i.reserved_quantity) as total_reserved,
    SUM(i.available_quantity) as total_available,
    COUNT(DISTINCT i.warehouse_id) as warehouse_count
FROM products p
LEFT JOIN inventory i ON p.id = i.product_id
LEFT JOIN warehouses w ON i.warehouse_id = w.id AND w.is_active = TRUE
GROUP BY p.id, p.sku, p.name
ORDER BY total_quantity ASC;

Query untuk Inventory Value per Warehouse

SELECT 
    w.name as warehouse_name,
    SUM(i.quantity * p.price) as inventory_value,
    COUNT(DISTINCT i.product_id) as product_count,
    SUM(i.quantity) as total_units
FROM warehouses w
JOIN inventory i ON w.id = i.warehouse_id
JOIN products p ON i.product_id = p.id
WHERE w.is_active = TRUE
GROUP BY w.id, w.name
ORDER BY inventory_value DESC;

Butuh jasa pembuatan website profesional? KerjaKode menyediakan layanan pembuatan website berkualitas tinggi dengan harga terjangkau. Kunjungi jasa pembuatan website KerjaKode untuk konsultasi gratis dan wujudkan website impian Anda.

Handling Race Conditions dengan Queue

Pada traffic tinggi, banyak request inventory bersamaan bisa menyebabkan race condition meski sudah pakai lockForUpdate().

Solusi yang lebih robust adalah memproses inventory operations via queue dengan single worker.

// Controller hanya dispatch job
public function checkout(Request $request)
{
    $order = Order::create([...]);
    
    // Dispatch ke queue
    ReserveInventoryJob::dispatch($order->id)
        ->onQueue('inventory');
    
    return response()->json([
        'order_id' => $order->id,
        'status' => 'processing'
    ]);
}

// Job yang diproses secara sequential
class ReserveInventoryJob implements ShouldQueue
{
    public $orderId;
    
    // Pastikan hanya 1 worker untuk queue inventory
    public function __construct($orderId)
    {
        $this->orderId = $orderId;
        $this->onQueue('inventory');
    }
    
    public function handle(InventoryService $service)
    {
        $order = Order::find($this->orderId);
        
        foreach ($order->items as $item) {
            try {
                $service->reserve(
                    $item->product_id,
                    $order->warehouse_id,
                    $item->quantity,
                    $order->id
                );
            } catch (InsufficientStockException $e) {
                $order->update(['status' => 'cancelled']);
                // Notify customer
                break;
            }
        }
        
        if ($order->status !== 'cancelled') {
            $order->update(['status' => 'confirmed']);
        }
    }
}

Konfigurasi di horizon atau supervisor untuk ensure hanya 1 worker memproses queue inventory:

'inventory' => [
    'connection' => 'redis',
    'queue' => ['inventory'],
    'balance' => 'simple',
    'processes' => 1, // Hanya 1 worker
    'tries' => 3,
],

Optimasi Query untuk Performa

Query inventory bisa jadi bottleneck ketika Anda punya ribuan produk dan banyak gudang.

Index yang Wajib Ada

  • Composite index pada (product_id, warehouse_id) di tabel inventory
  • Index pada warehouse_id untuk filter per gudang
  • Index pada created_at di inventory_movements untuk reporting
  • Index pada available_quantity untuk low stock checks

Caching untuk Product Availability

Untuk display di product listing, gunakan cache agar tidak query database setiap request.

class ProductRepository
{
    public function getAvailability($productId)
    {
        return Cache::remember(
            "product.{$productId}.availability",
            now()->addMinutes(5),
            function() use ($productId) {
                return Inventory::where('product_id', $productId)
                    ->sum('available_quantity');
            }
        );
    }
    
    // Clear cache setiap ada inventory update
    public function clearAvailabilityCache($productId)
    {
        Cache::forget("product.{$productId}.availability");
    }
}

API Endpoints untuk Multi Warehouse

Jika sistem Anda diakses dari berbagai channel (website, mobile app, marketplace), sediakan API yang konsisten.

Check Stock Availability

GET /api/products/{id}/availability

Response:
{
    "product_id": 123,
    "sku": "PROD-001",
    "total_available": 150,
    "warehouses": [
        {
            "warehouse_id": 1,
            "name": "Jakarta",
            "quantity": 80,
            "reserved": 10,
            "available": 70
        },
        {
            "warehouse_id": 2,
            "name": "Surabaya",
            "quantity": 90,
            "reserved": 10,
            "available": 80
        }
    ]
}

Reserve Stock

POST /api/inventory/reserve

Request:
{
    "order_id": 456,
    "items": [
        {
            "product_id": 123,
            "quantity": 2
        }
    ],
    "destination_city": "Bandung"
}

Response:
{
    "success": true,
    "reservations": [
        {
            "product_id": 123,
            "warehouse_id": 1,
            "quantity": 2,
            "expires_at": "2026-07-12T10:30:00Z"
        }
    ]
}

Testing Strategy untuk Multi Warehouse System

Sistem inventory rawan bug yang berdampak besar. Testing yang proper adalah must.

Unit Test untuk Inventory Service

class InventoryServiceTest extends TestCase
{
    public function test_reserve_reduces_available_quantity()
    {
        $product = Product::factory()->create();
        $warehouse = Warehouse::factory()->create();
        
        Inventory::create([
            'product_id' => $product->id,
            'warehouse_id' => $warehouse->id,
            'quantity' => 100,
            'reserved_quantity' => 0
        ]);
        
        $service = new InventoryService();
        $service->reserve($product->id, $warehouse->id, 10, 'order-123');
        
        $inventory = Inventory::where('product_id', $product->id)->first();
        
        $this->assertEquals(90, $inventory->available_quantity);
        $this->assertEquals(10, $inventory->reserved_quantity);
    }
    
    public function test_cannot_reserve_more_than_available()
    {
        $this->expectException(InsufficientStockException::class);
        
        $product = Product::factory()->create();
        $warehouse = Warehouse::factory()->create();
        
        Inventory::create([
            'product_id' => $product->id,
            'warehouse_id' => $warehouse->id,
            'quantity' => 5,
            'reserved_quantity' => 0
        ]);
        
        $service = new InventoryService();
        $service->reserve($product->id, $warehouse->id, 10, 'order-123');
    }
}

Integration Test untuk Concurrent Reservations

public function test_concurrent_reservations_do_not_oversell()
{
    $product = Product::factory()->create();
    $warehouse = Warehouse::factory()->create();
    
    Inventory::create([
        'product_id' => $product->id,
        'warehouse_id' => $warehouse->id,
        'quantity' => 10,
        'reserved_quantity' => 0
    ]);
    
    // Simulate 10 concurrent requests trying to reserve 2 units each
    $promises = [];
    for ($i = 0; $i post('/api/inventory/reserve', [
            'product_id' => $product->id,
            'warehouse_id' => $warehouse->id,
            'quantity' => 2,
            'order_id' => "order-{$i}"
        ]);
    }
    
    $responses = array_map(fn($p) => $p->wait(), $promises);
    $successCount = count(array_filter($responses, fn($r) => $r->successful()));
    
    // Hanya 5 yang berhasil (10 / 2 = 5)
    $this->assertEquals(5, $successCount);
    
    // Verify actual inventory
    $inventory = Inventory::where('product_id', $product->id)->first();
    $this->assertEquals(10, $inventory->reserved_quantity);
    $this->assertEquals(0, $inventory->available_quantity);
}

Kesimpulan

Membangun sistem multi warehouse inventory yang robust membutuhkan perhatian khusus pada database design, concurrency handling, dan business logic yang tepat.

Kunci utamanya adalah memisahkan master product dengan inventory per lokasi, menggunakan reservation system untuk prevent overselling, dan logging setiap movement untuk audit trail.

Dengan sistem yang proper, bisnis e-commerce Anda bisa scale ke banyak lokasi tanpa chaos dalam pengelolaan stok.

Mulai dari database schema yang benar, implementasikan inventory service dengan transaction yang atomic, tambahkan warehouse selection logic yang smart, dan jangan lupa testing yang menyeluruh.

Sistem ini mungkin terlihat kompleks di awal, tapi investasi waktu untuk build properly akan save you dari disaster besar di masa depan ketika volume transaksi meningkat.

Ajie Kusumadhany
Written by

Ajie Kusumadhany

Founder & Lead Developer KerjaKode. Berpengalaman dalam pengembangan web modern dengan Laravel, React.js, Vue.js, dan teknologi terkini. Passionate tentang coding, teknologi, dan berbagi pengetahuan melalui artikel.

Promo Spesial Hari Ini!

10% DISKON

Promo berakhir dalam:

00 Jam
:
00 Menit
:
00 Detik
Klaim Promo Sekarang!

*Promo berlaku untuk order hari ini

0
User Online
Halo! 👋
Kerjakode Support Online
×

👋 Hai! Pilih layanan yang kamu butuhkan:

Chat WhatsApp Sekarang