Skip to main content

Overview

Every change to inventory in ShelfWise is recorded as a Stock Movement. This provides a complete audit trail for compliance, debugging, and inventory analysis.
CRITICAL: All stock changes MUST go through StockMovementService to maintain data integrity and audit trail.

Stock Movement Types

ShelfWise supports 14 different movement types, defined in app/Enums/StockMovementType.php:
PURCHASE
StockMovementType
Stock received from supplier or purchase order. Updates weighted average cost.
ADJUSTMENT_IN
StockMovementType
Manual increase in stock quantity (found inventory, correction)
TRANSFER_IN
StockMovementType
Stock received from another location within the tenant
RETURN
StockMovementType
Stock returned from customer
PURCHASE_ORDER_RECEIVED
StockMovementType
Stock received from supplier via purchase order system

Movement Direction

Each movement type has a direction:
// app/Enums/StockMovementType.php:62-84

$type->isIncrease();  // Returns true for PURCHASE, ADJUSTMENT_IN, etc.
$type->isDecrease();  // Returns true for SALE, ADJUSTMENT_OUT, etc.

Stock Movement Model

Each movement is recorded in app/Models/StockMovement.php:
reference_number
string
required
Unique reference like PUR-01HQXXX, SAL-01HQYYY
product_variant_id
integer
required
The variant being moved
type
StockMovementType
required
Type of movement (purchase, sale, adjustment, etc.)
quantity
integer
required
Number of base units moved (always positive)
quantity_before
integer
Stock level before this movement
quantity_after
integer
Stock level after this movement
from_location_id
integer
Source inventory location (for transfers and outbound)
to_location_id
integer
Destination inventory location (for transfers and inbound)
product_packaging_type_id
integer
Packaging type used (e.g., Box, Carton, Loose)
package_quantity
integer
Number of packages moved (if using packaging)
cost_per_package
decimal
Cost per package (for purchases)
cost_per_base_unit
decimal
Cost per base unit (calculated from package cost)
reason
string
Human-readable reason for the movement
notes
text
Additional notes or context
created_by
integer
required
User who created this movement

StockMovementService

All stock operations go through app/Services/StockMovementService.php:

Adjust Stock

Manually increase or decrease stock:
// app/Services/StockMovementService.php:27-99

use App\Services\StockMovementService;
use App\Enums\StockMovementType;

$movement = app(StockMovementService::class)->adjustStock(
    variant: $variant,
    location: $inventoryLocation,
    quantity: 50,
    type: StockMovementType::ADJUSTMENT_IN,
    user: auth()->user(),
    reason: 'Found inventory in storage',
    notes: 'Located 50 units in back room during cleanup'
);
Concurrency Safety: This method uses pessimistic locking (lockForUpdate) to prevent race conditions during concurrent stock adjustments.

Transfer Stock

Move stock between locations:
// app/Services/StockMovementService.php:104-224

$movements = app(StockMovementService::class)->transferStock(
    variant: $variant,
    fromLocation: $warehouseLocation,
    toLocation: $storeLocation,
    quantity: 100,
    user: auth()->user(),
    reason: 'Restocking store from warehouse',
    notes: 'Delivery truck #4521'
);

// Returns array with both movements:
// ['out' => StockMovement, 'in' => StockMovement]
Transfers create two movement records:
  1. TRANSFER_OUT from source location
  2. TRANSFER_IN to destination location
Both share the same reference_number for easy tracking.

Record Purchase

Record stock received from supplier with automatic cost calculation:
// app/Services/StockMovementService.php:231-310

$movement = app(StockMovementService::class)->recordPurchase(
    variant: $variant,
    location: $warehouseLocation,
    packageQuantity: 10,              // Buying 10 boxes
    packagingType: $boxPackaging,     // Each box has 24 units
    costPerPackage: 120.00,           // $120 per box
    user: auth()->user(),
    notes: 'Invoice #INV-2024-001'
);

// Automatically calculates:
// - Base units: 10 boxes × 24 units = 240 units
// - Cost per unit: $120 / 24 = $5.00 per unit
// - Updates variant's weighted average cost
The recordPurchase method automatically:
  1. Calculates base units from package quantity
  2. Updates the variant’s weighted average cost
  3. Increases inventory at the specified location
  4. Creates an audit record with full cost details

Record Sale

Record stock sold to customers:
// app/Services/StockMovementService.php:317-399

$movement = app(StockMovementService::class)->recordSale(
    variant: $variant,
    location: $storeLocation,
    quantity: 5,                    // Selling 5 units
    packagingType: $loosePackaging, // Selling loose units
    user: auth()->user(),
    referenceNumber: 'ORD-001',     // Link to order
    notes: 'Order #ORD-001',
    releaseReservation: true        // Release reserved stock
);
releaseReservation
boolean
default:"false"
If true, reduces reserved_quantity in addition to quantity. Use this when fulfilling orders that had previously reserved stock.

Stock Take

Compare physical count with system count and adjust:
// app/Services/StockMovementService.php:404-472

$movement = app(StockMovementService::class)->stockTake(
    variant: $variant,
    location: $storeLocation,
    actualQuantity: 95,              // Physical count
    user: auth()->user(),
    notes: 'Monthly stock take - Jan 2024'
);

// If system showed 100 units:
// - Creates STOCK_TAKE movement for 5 units
// - Sets location quantity to 95
// - Records the discrepancy

// If counts match, returns null (no movement needed)

Inventory Locations

Stock is tracked at specific locations using app/Models/InventoryLocation.php:
location_type
string
required
Polymorphic type (e.g., App\Models\Shop, App\Models\Warehouse)
location_id
integer
required
ID of the location (shop_id, warehouse_id, etc.)
product_variant_id
integer
required
The variant being tracked
quantity
integer
default:"0"
Total units at this location
reserved_quantity
integer
default:"0"
Units reserved for pending orders
available_quantity
computed
Available for sale: quantity - reserved_quantity

Reserved Stock

Reserved quantity prevents overselling:
// app/Models/InventoryLocation.php:67-70

$location->available_quantity; // quantity - reserved_quantity

// Validation ensures reserved never exceeds quantity
$location->reserved_quantity = 150;
$location->quantity = 100; // Throws InvalidArgumentException
The model automatically validates that reserved_quantity never exceeds quantity. Attempting to save an invalid state throws an exception.

Stock Availability

Check if sufficient stock is available:
// app/Services/StockMovementService.php:523-539

$available = app(StockMovementService::class)->checkStockAvailability(
    variant: $variant,
    quantity: 50,
    shopId: $shop->id // Optional - checks specific shop
);

if ($available) {
    // Can fulfill order
} else {
    // Insufficient stock
}

Get Available Stock

// app/Services/StockMovementService.php:544-555

$stock = app(StockMovementService::class)->getAvailableStock(
    variant: $variant,
    shopId: $shop->id // Optional
);

// Returns integer or PHP_INT_MAX if track_stock is false

Viewing Stock Movements

List All Movements

Display movements with filtering:
// app/Http/Controllers/StockMovementController.php:38-83

$movements = StockMovement::query()
    ->where('tenant_id', $tenantId)
    ->with([
        'shop:id,name',
        'productVariant.product',
        'packagingType',
        'fromLocation.location',
        'toLocation.location',
        'createdBy:id,first_name',
    ])
    ->forShop($shopId) // Optional filter
    ->latest()
    ->paginate(50);

View Movement History for a Variant

// app/Http/Controllers/StockMovementController.php:232-253

$movements = StockMovement::forVariant($variant->id)
    ->with([
        'fromLocation.location',
        'toLocation.location',
        'createdBy:id,first_name',
    ])
    ->latest()
    ->paginate(20);

Export Stock Movements

Export movements to CSV:
// app/Http/Controllers/StockMovementController.php:325-398

// GET /stock-movements/export?variant_id=123

// Downloads CSV with columns:
// Date, Reference, Product, SKU, Variant, Type, Quantity,
// Before, After, From Location, To Location, Reason, Notes, Created By

Reference Number Format

Movements automatically generate unique references:
// app/Services/StockMovementService.php:574-591

// Format: {PREFIX}-{ULID}
PUR-01HQXXXXXXXXXXXXXXXXXXX    // Purchase
SAL-01HQXXXXXXXXXXXXXXXXXXX    // Sale
ADJ-IN-01HQXXXXXXXXXXXXXXXX    // Adjustment In
ADJ-OUT-01HQXXXXXXXXXXXXXXX    // Adjustment Out
TRF-01HQXXXXXXXXXXXXXXXXXXX    // Transfer (both IN and OUT share same ref)
STK-01HQXXXXXXXXXXXXXXXXXXX    // Stock Take
PO-SHIP-01HQXXXXXXXXXXXXXXX    // Purchase Order Shipped
PO-RCV-01HQXXXXXXXXXXXXXXXX    // Purchase Order Received

Multi-Tenant Isolation

CRITICAL: All stock movement queries MUST filter by tenant_id
// ✅ CORRECT - Service layer handles tenant isolation
$movement = app(StockMovementService::class)->adjustStock(...);

// ✅ CORRECT - Manual query with tenant filter
$movements = StockMovement::query()
    ->where('tenant_id', auth()->user()->tenant_id)
    ->get();

// ❌ WRONG - Missing tenant filter
$movements = StockMovement::all();

Concurrency and Race Conditions

The service uses pessimistic locking to prevent race conditions:
// app/Services/StockMovementService.php:46-48

$location = InventoryLocation::where('id', $location->id)
    ->lockForUpdate()  // Locks row until transaction completes
    ->firstOrFail();
For transfers, locations are locked in ID order to prevent deadlocks:
// app/Services/StockMovementService.php:132-138

$lockIds = [$fromLocation->id, $toLocation->id];
sort($lockIds); // Always lock in same order

$lockedLocations = InventoryLocation::whereIn('id', $lockIds)
    ->lockForUpdate()
    ->get()
    ->keyBy('id');

Reorder Alerts

After each stock operation, reorder alert cache is cleared:
// app/Services/StockMovementService.php:87-88

$this->reorderAlertService->clearCache($user->tenant, null);
This ensures the reorder alerts page reflects the latest stock levels.

Best Practices

  • Never directly update InventoryLocation.quantity
  • Always use StockMovementService methods
  • Use database transactions for multi-step operations
  • Let the service handle locking and race conditions

Build docs developers (and LLMs) love