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:
Stock received from supplier or purchase order. Updates weighted average cost.
Manual increase in stock quantity (found inventory, correction)
Stock received from another location within the tenant
Stock returned from customer
Stock received from supplier via purchase order system
Manual decrease in stock quantity (loss, damage, correction)
Stock sent to another location within the tenant
Stock damaged or defective
Stock shipped to buyer via purchase order system
Physical inventory count adjustment (can increase or decrease)
Stock reserved for approved purchase order (doesn’t change quantity)
PURCHASE_ORDER_RESERVATION_RELEASED
Stock reservation released for cancelled purchase order
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:
Unique reference like PUR-01HQXXX, SAL-01HQYYY
type
StockMovementType
required
Type of movement (purchase, sale, adjustment, etc.)
Number of base units moved (always positive)
Stock level before this movement
Stock level after this movement
Source inventory location (for transfers and outbound)
Destination inventory location (for transfers and inbound)
product_packaging_type_id
Packaging type used (e.g., Box, Carton, Loose)
Number of packages moved (if using packaging)
Cost per package (for purchases)
Cost per base unit (calculated from package cost)
Human-readable reason for the movement
Additional notes or context
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:
TRANSFER_OUT from source location
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:
- Calculates base units from package quantity
- Updates the variant’s weighted average cost
- Increases inventory at the specified location
- 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
);
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:
Polymorphic type (e.g., App\Models\Shop, App\Models\Warehouse)
ID of the location (shop_id, warehouse_id, etc.)
The variant being tracked
Total units at this location
Units reserved for pending orders
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
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
Data Integrity
Performance
Audit Trail
Security
- Never directly update
InventoryLocation.quantity
- Always use
StockMovementService methods
- Use database transactions for multi-step operations
- Let the service handle locking and race conditions
- Eager load relationships:
with(['productVariant.product', 'fromLocation.location'])
- Use query scopes:
->forShop($shopId), ->forVariant($variantId)
- Cache frequently accessed movement summaries
- Always provide meaningful
reason and notes
- Include reference numbers (invoice, order ID, etc.)
- Record who performed the action (
created_by)
- Always filter by
tenant_id
- Use Laravel Policies for authorization
- Validate location ownership before transfers