Table of Contents
▼- Mengapa Multi Warehouse Inventory Itu Penting
- Arsitektur Database untuk Multi Warehouse
- Logika Warehouse Selection Otomatis
- Sistem Reserve dan Release Inventory
- Transfer Stock Antar Warehouse
- Low Stock Alert dan Auto Reorder Point
- Dashboard dan Reporting
- Handling Race Conditions dengan Queue
- Optimasi Query untuk Performa
- API Endpoints untuk Multi Warehouse
- Testing Strategy untuk Multi Warehouse System
- Kesimpulan
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.