Skip to main content

Overview

Reorder alerts help you maintain optimal stock levels by automatically notifying you when product variants fall below their reorder thresholds. ShelfWise provides intelligent alerts, prioritization, and purchase order suggestions.

How Reorder Alerts Work

Each product variant has a reorder_level setting:
// app/Models/ProductVariant.php:64

$variant->reorder_level = 50; // Alert when stock drops to 50 or below
When the total stock across all locations for a variant reaches or falls below this level, it triggers a reorder alert.
Reorder alerts only trigger for variants with reorder_level > 0. Set to 0 to disable alerts for a variant.

ReorderAlertService

The core logic is in app/Services/ReorderAlertService.php:

Get Low Stock Variants

// app/Services/ReorderAlertService.php:16-55

use App\Services\ReorderAlertService;

$lowStock = app(ReorderAlertService::class)->getLowStockVariants(
    tenant: $tenant,
    shop: $shop // Optional - filter by specific shop
);

// Returns collection with:
[
    [
        'variant' => ProductVariant,
        'current_stock' => 35,
        'reorder_level' => 50,
        'shortage' => 15,           // How many units below reorder level
        'percentage' => 70.0,       // Current stock as % of reorder level
    ],
    // ... more variants
]

Alert Severity Levels

Variants are categorized by severity:
Less than 25% of reorder level
// Stock: 10 units
// Reorder level: 50 units
// Percentage: 20%
// Status: CRITICAL - needs immediate attention
Critical items should be reordered immediately to avoid stockouts.

Get Critical Stock

Quickly find items needing immediate attention:
// app/Services/ReorderAlertService.php:60-64

$critical = app(ReorderAlertService::class)->getCriticalStockVariants(
    tenant: $tenant,
    shop: $shop
);

// Returns only variants below 25% of reorder level

Get Alert Summary

For dashboard display:
// app/Services/ReorderAlertService.php:98-115

$summary = app(ReorderAlertService::class)->getAlertSummary(
    tenant: $tenant,
    shop: $shop
);

// Returns:
[
    'total_low_stock' => 23,      // Total items below reorder level
    'critical_count' => 5,         // Items below 25%
    'warning_count' => 8,          // Items between 25-50%
    'critical_items' => [...],     // Top 5 critical items
    'top_priority' => [...],       // Top 10 items by priority
]
The alert summary is cached for 15 minutes. Cache is automatically cleared after any stock movement.

Setting Reorder Levels

Set appropriate reorder levels based on:

Lead Time Method

// Daily sales: 10 units/day
// Supplier lead time: 5 days
// Safety stock: 3 days
// Reorder level = (10 × 5) + (10 × 3) = 80 units

$variant->reorder_level = 80;

Economic Order Quantity (EOQ) Method

// D = Annual demand: 3,650 units
// S = Ordering cost: $50 per order
// H = Holding cost: $2 per unit per year
// EOQ = sqrt((2 × D × S) / H) = 427 units
// Reorder at 50% of EOQ = 214 units

$variant->reorder_level = 214;

Historical Sales Method

// Average weekly sales: 50 units
// Set reorder level at 2 weeks supply = 100 units

$variant->reorder_level = 100;

Stock Checking

Check if a specific variant is low:
// app/Services/ReorderAlertService.php:84-93

$isLow = app(ReorderAlertService::class)->isLowStock(
    variant: $variant,
    shop: $shop // Optional
);

if ($isLow) {
    // Display warning badge
    // Send notification
    // Suggest reorder
}

Viewing Reorder Alerts

Implemented in app/Http/Controllers/ReorderAlertController.php:
// app/Http/Controllers/ReorderAlertController.php:18-34

// Navigate to: /reorder-alerts or /shops/{shop}/reorder-alerts

public function index(?Shop $shop = null): Response
{
    $tenant = auth()->user()->tenant;
    
    $lowStock = $this->reorderAlertService->getLowStockVariants($tenant, $shop);
    $summary = $this->reorderAlertService->getAlertSummary($tenant, $shop);
    
    return Inertia::render('ReorderAlerts/Index', [
        'shop' => $shop,
        'low_stock_items' => $lowStock,
        'summary' => $summary,
    ]);
}

Purchase Order Suggestions

ShelfWise can generate suggested purchase orders based on low stock:
// app/Services/ReorderAlertService.php:129-166

$suggestions = app(ReorderAlertService::class)->getSuggestedPurchaseOrders(
    tenant: $tenant,
    shop: $shop
);

// Returns suggestions grouped by supplier:
[
    'supplier_id' => 123,
    'items' => [
        [
            'variant_id' => 456,
            'sku' => 'PROD-001',
            'name' => 'Blue Shirt - Size M',
            'current_stock' => 10,
            'reorder_level' => 50,
            'suggested_quantity' => 100,  // Max of (shortage, reorder_level × 2)
            'cost_price' => 8.50,
            'total_cost' => 850.00,
        ],
        // ... more items
    ],
    'total_items' => 5,
    'total_cost' => 4250.00,
]

Suggested Quantity Logic

The system suggests ordering the greater of:
  1. The shortage amount (reorder level - current stock)
  2. Double the reorder level (to prevent frequent reorders)
// app/Services/ReorderAlertService.php:139-142

$suggestedQuantity = max(
    $shortage,                    // e.g., 40 units
    $variant->reorder_level * 2   // e.g., 100 units
);
// Result: 100 units

Cache Management

Reorder alerts are cached for performance:
// app/Services/ReorderAlertService.php:100-102

$cacheKey = "reorder_alerts_{$tenant->id}" . ($shop ? "_{$shop->id}" : '');

Cache::remember($cacheKey, now()->addMinutes(15), function () {
    // Calculate alerts
});

Cache Invalidation

Cache is automatically cleared after stock movements:
// app/Services/StockMovementService.php:87-88

$this->reorderAlertService->clearCache($user->tenant, null);
Manually clear cache:
// app/Services/ReorderAlertService.php:120-124

app(ReorderAlertService::class)->clearCache(
    tenant: $tenant,
    shop: $shop // Optional
);

Multi-Location Stock

Reorder alerts consider stock across all locations:
// app/Services/ReorderAlertService.php:69-79

private function getTotalStock(ProductVariant $variant, ?Shop $shop = null): int
{
    if ($shop) {
        // Stock at specific shop only
        return $variant->inventoryLocations
            ->where('location_type', 'App\\Models\\Shop')
            ->where('location_id', $shop->id)
            ->sum('quantity');
    }
    
    // Total across all locations
    return $variant->inventoryLocations->sum('quantity');
}
When viewing tenant-wide alerts, the system sums stock across all shops and warehouses. When filtering by a specific shop, only that location’s stock is considered.

Dashboard Widget Example

import { usePage } from '@inertiajs/react';

interface ReorderSummary {
  total_low_stock: number;
  critical_count: number;
  warning_count: number;
  critical_items: Array<{
    variant: ProductVariant;
    current_stock: number;
    reorder_level: number;
    percentage: number;
  }>;
}

export default function ReorderAlertWidget() {
  const { reorder_summary } = usePage<{ reorder_summary: ReorderSummary }>().props;

  return (
    <div className="card">
      <h3>Low Stock Alerts</h3>
      
      <div className="stats">
        <div className="stat critical">
          <span className="count">{reorder_summary.critical_count}</span>
          <span className="label">Critical</span>
        </div>
        
        <div className="stat warning">
          <span className="count">{reorder_summary.warning_count}</span>
          <span className="label">Warning</span>
        </div>
        
        <div className="stat low">
          <span className="count">{reorder_summary.total_low_stock}</span>
          <span className="label">Total Low</span>
        </div>
      </div>
      
      <div className="critical-items">
        <h4>Immediate Attention Needed</h4>
        {reorder_summary.critical_items.map((item) => (
          <div key={item.variant.id} className="alert-item">
            <span className="sku">{item.variant.sku}</span>
            <span className="name">{item.variant.product.name}</span>
            <span className="stock">
              {item.current_stock} / {item.reorder_level}
            </span>
            <span className="percentage" style={{ color: 'red' }}>
              {item.percentage}%
            </span>
          </div>
        ))}
      </div>
      
      <a href="/features/inventory/reorder-alerts" className="btn-view-all">
        View All Alerts
      </a>
    </div>
  );
}

Notifications

Set up notifications for low stock alerts:
use App\Notifications\LowStockAlert;

// Daily job to check and notify
Schedule::call(function () {
    $tenants = Tenant::where('is_active', true)->get();
    
    foreach ($tenants as $tenant) {
        $critical = app(ReorderAlertService::class)
            ->getCriticalStockVariants($tenant);
        
        if ($critical->count() > 0) {
            // Notify shop managers
            $managers = $tenant->users()
                ->whereIn('role', [UserRole::OWNER, UserRole::GENERAL_MANAGER])
                ->get();
            
            foreach ($managers as $manager) {
                $manager->notify(new LowStockAlert($critical));
            }
        }
    }
})->daily()->at('08:00');

Inventory Models and Reorder Behavior

ShelfWise supports three inventory models (from app/Enums/InventoryModel.php):
Best for: 70% of small shops, retail stores, convenience stores
  • Single unit pricing
  • Straightforward reorder levels in base units
  • Easy to understand alerts
$variant->base_unit_name = 'Piece';
$variant->reorder_level = 50; // Reorder when below 50 pieces

Best Practices

  • Review reorder levels quarterly
  • Adjust for seasonal demand changes
  • Factor in supplier lead times
  • Account for sales velocity trends
  • Set higher levels for high-value, slow-moving items

Multi-Tenant Isolation

CRITICAL: All reorder alert queries MUST be scoped to tenant
// ✅ CORRECT
$lowStock = app(ReorderAlertService::class)->getLowStockVariants($tenant, $shop);

// ✅ CORRECT - Service handles tenant isolation internally
ProductVariant::query()
    ->whereHas('product', fn ($q) => $q->where('tenant_id', $tenant->id))
    ->where('reorder_level', '>', 0)
    ->get();

// ❌ WRONG - Missing tenant filter
ProductVariant::where('reorder_level', '>', 0)->get();

Performance Considerations

For tenants with thousands of products:
  • Alerts are cached for 15 minutes
  • Use eager loading: with(['inventoryLocations', 'product'])
  • Consider background jobs for large calculations
  • Index the reorder_level column

Build docs developers (and LLMs) love