laravel-shop/src/Traits/HasStocks.php

514 lines
18 KiB
PHP
Raw Normal View History

2025-12-03 12:21:23 +00:00
<?php
namespace Blax\Shop\Traits;
use Blax\Shop\Enums\StockStatus;
use Blax\Shop\Enums\StockType;
2025-12-03 12:21:23 +00:00
use Blax\Shop\Exceptions\NotEnoughStockException;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
2025-12-03 12:21:23 +00:00
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\DB;
/**
* HasStocks Trait
*
* Provides stock management functionality to Product models.
*
* Key Features:
* - Basic stock operations (increase, decrease, adjust)
* - Stock claims for bookings/reservations
* - Date-based availability checking
* - Low stock detection
* - Stock movement logging
*
* Usage:
* - Add 'manage_stock' boolean column to products table
* - Set manage_stock = true to enable stock tracking
* - Use increaseStock/decreaseStock for inventory changes
* - Use claimStock for reservations/bookings
* - Use availableOnDate for date-based availability
*
* Stock Calculation:
* - Physical Stock = Sum of all COMPLETED entries
* - Available Stock = Physical Stock (accounts for pending claims via their DECREASE entries)
* - Claimed Stock = Sum of PENDING claims
* - Available on Date = Available Stock + All Claims - Claims Active on Date
*/
2025-12-03 12:21:23 +00:00
trait HasStocks
{
/**
2025-12-05 09:23:47 +00:00
* Get all available stock entries for this product
*/
2025-12-03 12:21:23 +00:00
public function stocks(): HasMany
{
return $this->hasMany(config('shop.models.product_stock', 'Blax\Shop\Models\ProductStock'));
}
2025-12-05 09:23:47 +00:00
/**
* Get all stock entries for this product including unavailable ones
*/
public function allStocks(): HasMany
{
return $this->hasMany(config('shop.models.product_stock', 'Blax\Shop\Models\ProductStock'))
->withExpired()
->where('status', 'LIKE', '%');
}
/**
* Attribute accessor: Get available physical stock
*
* Sums all COMPLETED stock entries that haven't expired.
* This includes INCREASE (+), DECREASE (-), and released claims.
* Does NOT include PENDING claims (they're tracked separately).
*/
2025-12-03 12:21:23 +00:00
public function getAvailableStocksAttribute(): int
{
return $this->stocks()
->available()
->where('type', '!=', StockType::CLAIMED->value)
2025-12-04 09:51:45 +00:00
->willExpire()
2025-12-03 12:21:23 +00:00
->sum('quantity') ?? 0;
}
/**
* Check if product is in stock
*
* @return bool True if stock management is disabled OR available stock > 0
*/
2025-12-03 12:21:23 +00:00
public function isInStock(): bool
{
if (!$this->manage_stock) {
return true;
}
return $this->getAvailableStock() > 0;
}
/**
* Decrease physical stock (inventory reduction)
*
* Creates a DECREASE entry with negative quantity and COMPLETED status.
* This represents actual stock leaving the inventory (sold, damaged, etc.).
*
* @param int $quantity Amount to decrease
* @param Carbon|null $until Optional expiration (for temporary decreases)
* @return bool True if successful
* @throws NotEnoughStockException If insufficient stock available
*/
2025-12-03 12:21:23 +00:00
public function decreaseStock(int $quantity = 1, Carbon|null $until = null): bool
{
if (!$this->manage_stock) {
return true;
}
$available = $this->getAvailableStock();
if ($available < $quantity) {
2025-12-03 12:21:23 +00:00
return throw new NotEnoughStockException("Not enough stock available for product ID {$this->id}");
}
$this->stocks()->create([
'quantity' => -$quantity,
'type' => StockType::DECREASE,
'status' => StockStatus::COMPLETED,
2025-12-03 12:21:23 +00:00
'expires_at' => $until,
]);
$this->logStockChange(-$quantity, 'decrease');
$this->save();
return true;
}
/**
* Increase physical stock (inventory addition)
*
* Creates an INCREASE entry with positive quantity and COMPLETED status.
* This represents stock being added to inventory (purchased, returned, etc.).
*
* @param int $quantity Amount to increase
* @return bool True if successful, false if stock management disabled
*/
2025-12-03 12:21:23 +00:00
public function increaseStock(int $quantity = 1): bool
{
if (!$this->manage_stock) {
return false;
}
$this->stocks()->create([
'quantity' => $quantity,
'type' => StockType::INCREASE,
'status' => StockStatus::COMPLETED,
2025-12-03 12:21:23 +00:00
]);
$this->logStockChange($quantity, 'increase');
$this->save();
return true;
}
/**
* Adjust stock with custom type and status
*
* More flexible than increaseStock/decreaseStock, allows:
* - Custom stock type (INCREASE, DECREASE, RETURN, CLAIMED)
* - Custom status (defaults to COMPLETED)
* - Optional expiration date
* - Optional claim start date (for CLAIMED type)
* - Optional note for documentation
* - Optional reference to related model (Order, Cart, Booking, etc.)
*
* Note: CLAIMED type creates two entries like claimStock() for consistency:
* 1. DECREASE entry (COMPLETED) - reduces available stock
* 2. CLAIMED entry (PENDING) - tracks the claim
*
* @param StockType $type The type of adjustment (INCREASE/RETURN add stock, DECREASE/CLAIMED remove stock)
* @param int $quantity Amount to adjust (always positive, type determines direction)
* @param \DateTimeInterface|null $until Optional expiration date (when stock expires or claim ends)
* @param \DateTimeInterface|null $from Optional start date (used for CLAIMED type, defaults to now())
* @param StockStatus|null $status Optional status (defaults to COMPLETED, or PENDING for CLAIMED type)
* @param string|null $note Optional note for documentation purposes
* @param Model|null $referencable Optional polymorphic reference to related model
* @return bool|\Blax\Shop\Models\ProductStock True if successful for non-CLAIMED types, ProductStock instance for CLAIMED type, false if stock management disabled
* @throws NotEnoughStockException If insufficient stock available for DECREASE or CLAIMED types
*/
2025-12-03 14:45:11 +00:00
public function adjustStock(
StockType $type,
int $quantity,
\DateTimeInterface|null $until = null,
\DateTimeInterface|null $from = null,
2025-12-03 14:45:11 +00:00
?StockStatus $status = null,
string|null $note = null,
Model|null $referencable = null
2025-12-03 14:45:11 +00:00
) {
if (!$this->manage_stock) {
return false;
}
// For CLAIMED type, delegate to claimStock which handles the two-entry pattern
if ($type === StockType::CLAIMED) {
return $this->claimStock(
quantity: $quantity,
reference: $referencable,
from: $from,
until: $until,
note: $note
);
}
// Validate stock availability for types that reduce inventory
$isPositive = in_array($type, [StockType::INCREASE, StockType::RETURN]);
if (!$isPositive) {
// Only validate for COMPLETED status (PENDING doesn't affect available stock)
$effectiveStatus = $status ?? StockStatus::COMPLETED;
if ($effectiveStatus === StockStatus::COMPLETED && $this->getAvailableStock() < $quantity) {
throw new NotEnoughStockException("Not enough stock available for product ID {$this->id}");
}
}
$adjustedQuantity = $isPositive ? $quantity : -$quantity;
2025-12-03 14:45:11 +00:00
$this->stocks()->create([
'quantity' => $adjustedQuantity,
2025-12-03 14:45:11 +00:00
'type' => $type,
'status' => $status ?? StockStatus::COMPLETED,
'expires_at' => $until,
'note' => $note,
'reference_type' => $referencable ? get_class($referencable) : null,
'reference_id' => $referencable ? $referencable->id : null,
2025-12-03 14:45:11 +00:00
]);
$this->logStockChange($adjustedQuantity, 'adjust');
2025-12-03 14:45:11 +00:00
$this->save();
return true;
}
/**
* Claim stock for temporary use (reservation/booking)
*
* This is different from decreaseStock - it:
* 1. Removes stock from available inventory (via DECREASE entry)
* 2. Tracks it as a claim (via CLAIMED entry with PENDING status)
* 3. Can be released back later (changes CLAIMED to COMPLETED)
* 4. Supports date ranges for bookings (claimed_from to expires_at)
*
* Use cases:
* - Hotel room bookings (claimed_from = check-in, expires_at = check-out)
* - Equipment rentals (claimed_from = rental start, expires_at = return date)
* - Cart reservations (no claimed_from, expires_at = cart expiry)
*
* @param int $quantity Amount to claim
* @param mixed $reference Optional reference model (Order, Booking, Cart, etc.)
* @param \DateTimeInterface|null $from When claim starts (null = immediately)
* @param \DateTimeInterface|null $until When claim expires (null = permanent)
* @param string|null $note Optional note about the claim
* @return \Blax\Shop\Models\ProductStock|null The claim entry, or null if insufficient stock
*/
2025-12-04 10:06:09 +00:00
public function claimStock(
2025-12-03 12:21:23 +00:00
int $quantity,
$reference = null,
2025-12-04 10:06:09 +00:00
?\DateTimeInterface $from = null,
2025-12-03 12:21:23 +00:00
?\DateTimeInterface $until = null,
?string $note = null
): ?\Blax\Shop\Models\ProductStock {
if (!$this->manage_stock) {
return null;
}
$stockModel = config('shop.models.product_stock', 'Blax\Shop\Models\ProductStock');
2025-12-04 10:06:09 +00:00
return $stockModel::claim(
2025-12-03 12:21:23 +00:00
$this,
$quantity,
$reference,
2025-12-04 10:06:09 +00:00
$from,
2025-12-03 12:21:23 +00:00
$until,
$note
);
}
/**
* Get currently available stock
*
* This is the stock available for new orders/claims.
2025-12-04 11:58:34 +00:00
* Calculation:
* 1. Sum all COMPLETED stock entries (INCREASE, DECREASE, RETURN) that haven't expired
* 2. Add back expired CLAIMED stocks (their DECREASE entries are negated when claims expire)
*
* CLAIMED entries are excluded from the main sum as they track claims, not physical inventory.
*
* @return int Available quantity (PHP_INT_MAX if stock management disabled)
*/
2025-12-05 09:23:47 +00:00
public function getAvailableStock(?\DateTimeInterface $date = null): int
2025-12-03 12:21:23 +00:00
{
if (!$this->manage_stock) {
return PHP_INT_MAX;
}
2025-12-05 09:23:47 +00:00
$date = $date ?? now();
// Base stock: all COMPLETED entries except CLAIMED, filtered using the provided date
2025-12-04 11:58:34 +00:00
$baseStock = $this->stocks()
2025-12-05 09:23:47 +00:00
->withoutGlobalScope('willExpire')
->where('status', StockStatus::COMPLETED->value)
->where('type', '!=', StockType::CLAIMED->value)
2025-12-05 09:23:47 +00:00
->where(function ($query) use ($date) {
$query->whereNull('expires_at')
->orWhere('expires_at', '>', $date);
})
2025-12-04 11:58:34 +00:00
->sum('quantity');
2025-12-05 09:23:47 +00:00
// Add back claims that should not reduce availability at the given date
$inactiveClaims = $this->stocks()
->withoutGlobalScope('willExpire')
2025-12-04 11:58:34 +00:00
->where('type', StockType::CLAIMED->value)
->where('status', StockStatus::PENDING->value)
2025-12-05 09:23:47 +00:00
->where(function ($query) use ($date) {
$query->where(function ($q) use ($date) {
// Claim has not started yet
$q->whereNotNull('claimed_from')
->where('claimed_from', '>', $date);
})->orWhere(function ($q) use ($date) {
// Claim expired before the date
$q->whereNotNull('expires_at')
->where('expires_at', '<=', $date);
});
})
2025-12-04 11:58:34 +00:00
->sum('quantity');
2025-12-05 09:23:47 +00:00
return max(0, $baseStock + $inactiveClaims);
2025-12-03 12:21:23 +00:00
}
/**
* Get total currently claimed stock
*
2025-12-04 11:58:34 +00:00
* Sum of all active (PENDING) claims that haven't expired yet.
* This stock is unavailable but tracked separately from physical inventory.
* Returns absolute value to always show positive quantity.
*
* @return int Total claimed quantity (always positive)
*/
2025-12-05 09:23:47 +00:00
public function getCurrentlyClaimedStock(): int
{
return abs($this->stocks()
->where('type', StockType::CLAIMED->value)
->where('status', StockStatus::PENDING->value)
->willExpire()
->where(function ($query) {
$query->whereNull('claimed_from')
->orWhere('claimed_from', '<=', now());
})
->sum('quantity'));
}
/**
* Get total current and planned claimed stock
*
* Includes all PENDING claims, regardless of start date.
* Useful for understanding total reservations including future bookings.
* @return int Total current and future claimed quantity (always positive)
*/
public function getActiveAndPlannedClaimedStock(): int
2025-12-03 12:21:23 +00:00
{
return abs($this->stocks()
->where('type', StockType::CLAIMED->value)
->where('status', StockStatus::PENDING->value)
2025-12-04 11:58:34 +00:00
->willExpire()
->sum('quantity'));
2025-12-03 12:21:23 +00:00
}
2025-12-05 09:23:47 +00:00
/**
* Get future claimed stock starting from a specific date or all where claimed_at is future
*
* @param \DateTimeInterface|null $from Optional start date to filter claims
* @return int Total future claimed quantity (always positive)
*/
public function getFutureClaimedStock(?\DateTimeInterface $from = null): int
{
$query = $this->stocks()
->where('type', StockType::CLAIMED->value)
->where('status', StockStatus::PENDING->value)
->willExpire();
if ($from) {
$query->where('claimed_from', '>=', $from);
} else {
$query->where(function ($q) {
$q->whereNotNull('claimed_from')
->where('claimed_from', '>', now());
});
}
return abs($query->sum('quantity'));
}
/**
* Log a stock change to the audit log
*
* @param int $quantityChange The change in quantity (positive or negative)
* @param string $type The type of change (increase, decrease, adjust)
*/
2025-12-03 12:21:23 +00:00
protected function logStockChange(int $quantityChange, string $type): void
{
DB::table('product_stock_logs')->insert([
'product_id' => $this->id,
'quantity_change' => $quantityChange,
'quantity_after' => $this->getAvailableStock(),
2025-12-03 12:21:23 +00:00
'type' => $type,
'created_at' => now(),
'updated_at' => now(),
]);
}
/**
* Query scope: Get products that are in stock
*
* Includes products with:
* - Stock management disabled (always in stock), OR
* - Stock management enabled AND available stock > 0
*/
2025-12-03 12:21:23 +00:00
public function scopeInStock($query)
{
return $query->where(function ($q) {
$q->where('manage_stock', false)
->orWhere(function ($q2) {
$q2->where('manage_stock', true)
->whereRaw("(SELECT SUM(quantity) FROM " . config('shop.tables.product_stocks', 'product_stocks') . " WHERE product_id = " . config('shop.tables.products', 'products') . ".id) > 0");
});
});
}
/**
* Query scope: Get products with low stock
*
* Returns products where:
* - Stock management is enabled
* - low_stock_threshold is set
* - Available stock <= threshold
*/
2025-12-03 12:21:23 +00:00
public function scopeLowStock($query)
{
$stockTable = config('shop.tables.product_stocks', 'product_stocks');
$productTable = config('shop.tables.products', 'products');
return $query->where('manage_stock', true)
->whereNotNull('low_stock_threshold')
->whereRaw("(SELECT COALESCE(SUM(quantity), 0) FROM {$stockTable} WHERE {$stockTable}.product_id = {$productTable}.id AND {$stockTable}.status = 'completed' AND ({$stockTable}.expires_at IS NULL OR {$stockTable}.expires_at > ?)) <= {$productTable}.low_stock_threshold", [
2025-12-03 12:21:23 +00:00
now()
]);
}
/**
* Check if product stock is low
*
* @return bool True if stock management enabled, threshold set, and stock <= threshold
*/
2025-12-03 12:21:23 +00:00
public function isLowStock(): bool
{
if (!$this->manage_stock || !$this->low_stock_threshold) {
return false;
}
return $this->getAvailableStock() <= $this->low_stock_threshold;
}
/**
* Get active claims for this product
*
* Returns query builder for PENDING claims that haven't expired yet.
* Useful for:
* - Viewing current reservations
* - Checking what's claimed but not released
* - Managing active bookings
*
* @return \Illuminate\Database\Eloquent\Builder
*/
2025-12-04 10:06:09 +00:00
public function claims()
2025-12-03 12:21:23 +00:00
{
$stockModel = config('shop.models.product_stock', 'Blax\Shop\Models\ProductStock');
2025-12-04 10:06:09 +00:00
return $stockModel::claims()
2025-12-04 09:51:45 +00:00
->willExpire()
2025-12-03 12:21:23 +00:00
->where('product_id', $this->id);
}
2025-12-04 10:06:09 +00:00
/**
* Get available stock on a specific date
*
* This is crucial for booking/rental systems where you need to know:
* "How many units are available on date X?"
*
* Calculation:
* 1. Start with current available stock
* 2. Add back all currently pending claims (they reduce available stock)
* 3. Subtract only the claims that are active on the specific date
*
* Example with 100 units:
* - Claim 1: 20 units, days 5-10
* - Claim 2: 30 units, days 8-15
* - Current available: 50 (100 - 20 - 30)
* - Available on day 3: 100 (no claims active)
* - Available on day 6: 80 (only claim 1 active)
* - Available on day 9: 50 (both claims active)
* - Available on day 12: 70 (only claim 2 active)
* - Available on day 20: 100 (no claims active)
*
* @param \DateTimeInterface $date The date to check availability for
* @return int Available stock on that date (PHP_INT_MAX if stock management disabled)
*/
2025-12-04 10:06:09 +00:00
public function availableOnDate(\DateTimeInterface $date): int
{
if (!$this->manage_stock) {
return PHP_INT_MAX;
}
2025-12-05 09:23:47 +00:00
return $this->getAvailableStock($date);
2025-12-04 10:06:09 +00:00
}
2025-12-03 12:21:23 +00:00
}