2025-12-03 12:21:23 +00:00
< ? php
namespace Blax\Shop\Traits ;
2025-12-03 12:59:01 +00:00
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 ;
2025-12-26 15:10:40 +00:00
use DateTimeInterface ;
2025-12-04 11:35:39 +00:00
use Illuminate\Database\Eloquent\Model ;
2025-12-03 12:21:23 +00:00
use Illuminate\Database\Eloquent\Relations\HasMany ;
use Illuminate\Support\Facades\DB ;
2025-12-04 10:16:38 +00:00
/**
* 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-04 10:16:38 +00:00
/**
2025-12-05 09:23:47 +00:00
* Get all available stock entries for this product
2025-12-04 10:16:38 +00:00
*/
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' , '%' );
}
2025-12-04 10:16:38 +00:00
/**
* 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 ()
2025-12-04 11:35:39 +00:00
-> 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 ;
}
2025-12-28 11:05:05 +00:00
/**
* Get max stock ( the ceiling - total capacity as if no claims existed )
*
* This shows the maximum possible stock by summing :
* - INCREASE entries ( stock added )
* - RETURN entries ( stock returned )
* And ignoring DECREASE and CLAIMED entries entirely .
*
* @ return int Maximum capacity ( PHP_INT_MAX if stock management disabled )
*/
public function getMaxStocksAttribute () : int
{
if ( $this -> manage_stock === false ) {
return PHP_INT_MAX ;
}
// Sum only INCREASE and RETURN entries to get the "ceiling"
return ( int ) $this -> stocks ()
-> withoutGlobalScope ( 'willExpire' )
-> where ( 'status' , StockStatus :: COMPLETED -> value )
-> whereIn ( 'type' , [ StockType :: INCREASE -> value , StockType :: RETURN -> value ])
-> sum ( 'quantity' );
}
2025-12-04 10:16:38 +00:00
/**
* 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 ;
}
2025-12-04 10:16:38 +00:00
/**
* 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 ;
}
2025-12-04 11:35:39 +00:00
$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 ,
2025-12-03 12:59:01 +00:00
'type' => StockType :: DECREASE ,
'status' => StockStatus :: COMPLETED ,
2025-12-03 12:21:23 +00:00
'expires_at' => $until ,
]);
2025-12-29 10:11:27 +00:00
// Pass pre-calculated quantity to avoid extra query
$this -> logStockChange ( - $quantity , 'decrease' , $available - $quantity );
2025-12-03 12:21:23 +00:00
$this -> save ();
return true ;
}
2025-12-04 10:16:38 +00:00
/**
* 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 ,
2025-12-03 12:59:01 +00:00
'type' => StockType :: INCREASE ,
'status' => StockStatus :: COMPLETED ,
2025-12-03 12:21:23 +00:00
]);
2025-12-29 10:11:27 +00:00
// Log stock change - getAvailableStock will be called by logStockChange
// This is acceptable since we need the accurate quantity after
2025-12-03 12:21:23 +00:00
$this -> logStockChange ( $quantity , 'increase' );
$this -> save ();
return true ;
}
2025-12-04 10:16:38 +00:00
/**
* Adjust stock with custom type and status
*
* More flexible than increaseStock / decreaseStock , allows :
2025-12-04 11:35:39 +00:00
* - Custom stock type ( INCREASE , DECREASE , RETURN , CLAIMED )
2025-12-04 10:16:38 +00:00
* - Custom status ( defaults to COMPLETED )
* - Optional expiration date
2025-12-04 11:35:39 +00:00
* - Optional claim start date ( for CLAIMED type )
* - Optional note for documentation
* - Optional reference to related model ( Order , Cart , Booking , etc . )
2025-12-04 10:16:38 +00:00
*
2025-12-04 11:35:39 +00:00
* 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 )
2025-12-04 10:16:38 +00:00
* @ param int $quantity Amount to adjust ( always positive , type determines direction )
2025-12-28 10:12:58 +00:00
* @ 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 ())
2025-12-04 11:35:39 +00:00
* @ 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-04 10:16:38 +00:00
*/
2025-12-03 14:45:11 +00:00
public function adjustStock (
StockType $type ,
int $quantity ,
2025-12-28 09:29:23 +00:00
DateTimeInterface | null $until = null ,
DateTimeInterface | null $from = null ,
2025-12-03 14:45:11 +00:00
? StockStatus $status = null ,
2025-12-04 11:35:39 +00:00
string | null $note = null ,
Model | null $referencable = null
2025-12-03 14:45:11 +00:00
) {
if ( ! $this -> manage_stock ) {
return false ;
}
2025-12-04 11:35:39 +00:00
// 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
2025-12-04 10:16:38 +00:00
$isPositive = in_array ( $type , [ StockType :: INCREASE , StockType :: RETURN ]);
2025-12-04 11:35:39 +00:00
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 } " );
}
}
2025-12-04 10:16:38 +00:00
$adjustedQuantity = $isPositive ? $quantity : - $quantity ;
2025-12-03 14:45:11 +00:00
$this -> stocks () -> create ([
2025-12-04 10:16:38 +00:00
'quantity' => $adjustedQuantity ,
2025-12-03 14:45:11 +00:00
'type' => $type ,
'status' => $status ? ? StockStatus :: COMPLETED ,
'expires_at' => $until ,
2025-12-04 11:35:39 +00:00
'note' => $note ,
'reference_type' => $referencable ? get_class ( $referencable ) : null ,
'reference_id' => $referencable ? $referencable -> id : null ,
2025-12-03 14:45:11 +00:00
]);
2025-12-04 10:16:38 +00:00
$this -> logStockChange ( $adjustedQuantity , 'adjust' );
2025-12-03 14:45:11 +00:00
$this -> save ();
return true ;
}
2025-12-04 10:16:38 +00:00
/**
* 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 . )
2025-12-28 10:12:58 +00:00
* @ param DateTimeInterface | null $from When claim starts ( null = immediately )
* @ param DateTimeInterface | null $until When claim expires ( null = permanent )
2025-12-04 10:16:38 +00:00
* @ 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-28 09:29:23 +00:00
? DateTimeInterface $from = null ,
? DateTimeInterface $until = null ,
2025-12-03 12:21:23 +00:00
? 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
);
}
2025-12-04 10:16:38 +00:00
/**
* 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 .
2025-12-04 10:16:38 +00:00
*
* @ return int Available quantity ( PHP_INT_MAX if stock management disabled )
*/
2025-12-28 09:29:23 +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' )
2025-12-04 11:35:39 +00:00
-> 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
}
2025-12-04 10:16:38 +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 .
2025-12-04 10:16:38 +00:00
* This stock is unavailable but tracked separately from physical inventory .
2025-12-04 11:35:39 +00:00
* Returns absolute value to always show positive quantity .
2025-12-04 10:16:38 +00:00
*
2025-12-04 11:35:39 +00:00
* @ return int Total claimed quantity ( always positive )
2025-12-04 10:16:38 +00:00
*/
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
{
2025-12-04 11:35:39 +00:00
return abs ( $this -> stocks ()
2025-12-04 10:16:38 +00:00
-> where ( 'type' , StockType :: CLAIMED -> value )
-> where ( 'status' , StockStatus :: PENDING -> value )
2025-12-04 11:58:34 +00:00
-> willExpire ()
2025-12-04 11:35:39 +00:00
-> 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
*
2025-12-28 10:12:58 +00:00
* @ param DateTimeInterface | null $from Optional start date to filter claims
2025-12-05 09:23:47 +00:00
* @ return int Total future claimed quantity ( always positive )
*/
2025-12-28 09:29:23 +00:00
public function getFutureClaimedStock ( ? DateTimeInterface $from = null ) : int
2025-12-05 09:23:47 +00:00
{
$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' ));
}
2025-12-04 10:16:38 +00:00
/**
* 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-29 10:11:27 +00:00
* @ param int | null $quantityAfter Optional pre - calculated quantity after change ( avoids extra query )
2025-12-04 10:16:38 +00:00
*/
2025-12-29 10:11:27 +00:00
protected function logStockChange ( int $quantityChange , string $type , ? int $quantityAfter = null ) : void
2025-12-03 12:21:23 +00:00
{
DB :: table ( 'product_stock_logs' ) -> insert ([
'product_id' => $this -> id ,
'quantity_change' => $quantityChange ,
2025-12-29 10:11:27 +00:00
'quantity_after' => $quantityAfter ? ? $this -> getAvailableStock (),
2025-12-03 12:21:23 +00:00
'type' => $type ,
'created_at' => now (),
'updated_at' => now (),
]);
}
2025-12-04 10:16:38 +00:00
/**
* 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 " );
});
});
}
2025-12-04 10:16:38 +00:00
/**
* 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' )
2025-12-04 11:35:39 +00:00
-> 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 ()
]);
}
2025-12-04 10:16:38 +00:00
/**
* 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 ;
}
2025-12-04 10:16:38 +00:00
/**
* 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
2025-12-04 10:16:38 +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 )
*
2025-12-28 10:12:58 +00:00
* @ param DateTimeInterface $date The date to check availability for
2025-12-04 10:16:38 +00:00
* @ return int Available stock on that date ( PHP_INT_MAX if stock management disabled )
*/
2025-12-28 09:29:23 +00:00
public function availableOnDate ( DateTimeInterface $date ) : int
2025-12-04 10:06:09 +00:00
{
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-26 15:10:40 +00:00
/**
* Gets the available amounts per date range , with $from and $until specified
* Returns associative array with keys
* - 'max_available' => Shows the peak available stock in the date range
* - 'min_available' => Shows the lowest available stock in the date range
* - 'dates' => An array of dates with their respective available stock
*
2025-12-28 10:12:58 +00:00
* @ param DateTimeInterface $from Start date of the range ( optional , defaults to today )
* @ param DateTimeInterface $until End date of the range ( optional , defaults to 30 days )
2025-12-26 15:10:40 +00:00
* @ return array Associative array with 'max_available' , 'min_available' , and 'dates'
*/
public function calendarAvailability (
? DateTimeInterface $from = null ,
? DateTimeInterface $until = null
) : array {
2025-12-26 15:45:30 +00:00
// For pool products, aggregate availability from all single items
if ( method_exists ( $this , 'isPool' ) && $this -> isPool ()) {
return $this -> getPoolCalendarAvailability ( $from , $until );
}
2025-12-26 15:10:40 +00:00
if ( $this -> manage_stock === false ) {
return [
'max_available' => PHP_INT_MAX ,
'min_available' => PHP_INT_MAX ,
'dates' => [],
];
}
$fromDate = Carbon :: parse ( $from ? ? now ()) -> startOfDay ();
$untilDate = Carbon :: parse ( $until ? ? $fromDate -> copy () -> addDays ( 30 )) -> endOfDay ();
// Fetch all relevant stocks once for performance
$allStocks = $this -> stocks ()
-> withoutGlobalScope ( 'willExpire' )
-> where ( function ( $query ) {
2025-12-26 15:45:30 +00:00
// Group conditions with OR to keep them within the product_id scope
$query -> where ( function ( $q ) {
$q -> where ( 'status' , StockStatus :: COMPLETED -> value )
-> where ( 'type' , '!=' , StockType :: CLAIMED -> value );
}) -> orWhere ( function ( $q ) {
$q -> where ( 'status' , StockStatus :: PENDING -> value )
-> where ( 'type' , StockType :: CLAIMED -> value );
});
2025-12-26 15:10:40 +00:00
})
-> get ();
$dates = [];
$globalMax = PHP_INT_MIN ;
$globalMin = PHP_INT_MAX ;
$currentDate = $fromDate -> copy ();
while ( $currentDate -> lte ( $untilDate )) {
$dayStart = $currentDate -> copy () -> startOfDay ();
$dayEnd = $currentDate -> copy () -> endOfDay ();
// Find all "event" timestamps for this day where availability might change
2025-12-26 15:45:30 +00:00
$events = [ $dayStart , $dayEnd -> startOfSecond ()]; // Normalize dayEnd to remove microseconds
2025-12-26 15:10:40 +00:00
foreach ( $allStocks as $stock ) {
if ( $stock -> claimed_from && $stock -> claimed_from -> between ( $dayStart , $dayEnd )) {
$events [] = Carbon :: parse ( $stock -> claimed_from );
}
if ( $stock -> expires_at && $stock -> expires_at -> between ( $dayStart , $dayEnd )) {
$events [] = Carbon :: parse ( $stock -> expires_at );
}
}
2025-12-26 15:45:30 +00:00
// Remove exact duplicates
$events = array_values ( array_unique ( $events , SORT_REGULAR ));
2025-12-26 15:10:40 +00:00
$dayMin = PHP_INT_MAX ;
$dayMax = PHP_INT_MIN ;
// Check availability at each event timestamp to find min/max for the day
foreach ( $events as $eventTime ) {
$available = 0 ;
foreach ( $allStocks as $stock ) {
if ( $stock -> status === StockStatus :: COMPLETED && $stock -> type !== StockType :: CLAIMED ) {
if ( is_null ( $stock -> expires_at ) || $stock -> expires_at > $eventTime ) {
$available += $stock -> quantity ;
}
} elseif ( $stock -> status === StockStatus :: PENDING && $stock -> type === StockType :: CLAIMED ) {
// Add back if NOT active at this timestamp
$isNotStarted = $stock -> claimed_from && $stock -> claimed_from > $eventTime ;
$isExpired = $stock -> expires_at && $stock -> expires_at <= $eventTime ;
if ( $isNotStarted || $isExpired ) {
$available += $stock -> quantity ;
}
}
}
$available = max ( 0 , $available );
$dayMin = min ( $dayMin , $available );
$dayMax = max ( $dayMax , $available );
}
$dates [ $currentDate -> toDateString ()] = [
'min' => $dayMin ,
'max' => $dayMax ,
];
$globalMin = min ( $globalMin , $dayMin );
$globalMax = max ( $globalMax , $dayMax );
$currentDate -> addDay ();
}
return [
'max_available' => $globalMax === PHP_INT_MIN ? 0 : $globalMax ,
'min_available' => $globalMin === PHP_INT_MAX ? 0 : $globalMin ,
'dates' => $dates ,
];
}
public function calendarAvailabilityDates (
? DateTimeInterface $from = null ,
? DateTimeInterface $until = null
) : array {
$availability = $this -> calendarAvailability ( $from , $until );
return $availability [ 'dates' ];
}
/**
* Gets the availability on the day by time . 00 : 00 shows the availables at the start of the day .
* Every other timestamp shows what total current availability is at that time .
*
* @ param null | DateTimeInterface $date
* @ return array | int
*/
public function dayAvailability ( ? DateTimeInterface $date = null )
{
2025-12-26 15:45:30 +00:00
// For pool products, aggregate availability from all single items
if ( method_exists ( $this , 'isPool' ) && $this -> isPool ()) {
return $this -> getPoolDayAvailability ( $date );
}
2025-12-26 15:10:40 +00:00
if ( $this -> manage_stock === false ) {
return PHP_INT_MAX ;
}
$date = Carbon :: parse ( $date ? ? now ());
$startOfDay = $date -> copy () -> startOfDay ();
$endOfDay = $date -> copy () -> endOfDay ();
$availability = [
'00:00' => $this -> availableOnDate ( $startOfDay ),
];
$stocks = $this -> stocks ()
-> withoutGlobalScope ( 'willExpire' )
-> where ( function ( $query ) use ( $startOfDay , $endOfDay ) {
$query -> where ( function ( $q ) use ( $startOfDay , $endOfDay ) {
$q -> whereNotNull ( 'claimed_from' )
-> whereBetween ( 'claimed_from' , [ $startOfDay , $endOfDay ]);
}) -> orWhere ( function ( $q ) use ( $startOfDay , $endOfDay ) {
$q -> whereNotNull ( 'expires_at' )
-> whereBetween ( 'expires_at' , [ $startOfDay , $endOfDay ]);
});
})
-> get ();
foreach ( $stocks as $stock ) {
if ( $stock -> claimed_from && $stock -> claimed_from -> isSameDay ( $startOfDay )) {
$timeKey = $stock -> claimed_from -> format ( 'H:i' );
if ( ! isset ( $availability [ $timeKey ])) {
$availability [ $timeKey ] = $this -> availableOnDate ( $stock -> claimed_from );
}
}
if ( $stock -> expires_at && $stock -> expires_at -> isSameDay ( $startOfDay )) {
$timeKey = $stock -> expires_at -> format ( 'H:i' );
if ( ! isset ( $availability [ $timeKey ])) {
$availability [ $timeKey ] = $this -> availableOnDate ( $stock -> expires_at );
}
}
}
ksort ( $availability );
return $availability ;
}
2025-12-26 15:45:30 +00:00
/**
* Get calendar availability for pool products by aggregating all single items
*
2025-12-28 10:12:58 +00:00
* @ param DateTimeInterface | null $from
* @ param DateTimeInterface | null $until
2025-12-26 15:45:30 +00:00
* @ return array
*/
protected function getPoolCalendarAvailability (
? DateTimeInterface $from = null ,
? DateTimeInterface $until = null
) : array {
// Eager load single products if not already loaded
if ( ! $this -> relationLoaded ( 'singleProducts' )) {
$this -> load ( 'singleProducts' );
}
$singleItems = $this -> singleProducts ;
if ( $singleItems -> isEmpty ()) {
$fromDate = Carbon :: parse ( $from ? ? now ()) -> startOfDay ();
$untilDate = Carbon :: parse ( $until ? ? $fromDate -> copy () -> addDays ( 30 )) -> endOfDay ();
$dates = [];
$currentDate = $fromDate -> copy ();
while ( $currentDate -> lte ( $untilDate )) {
$dates [ $currentDate -> toDateString ()] = [ 'min' => 0 , 'max' => 0 ];
$currentDate -> addDay ();
}
return [
'max_available' => 0 ,
'min_available' => 0 ,
'dates' => $dates ,
];
}
// Filter to only include singles that manage stock
// Unmanaged singles have unlimited availability and don't need to be counted
$managedSingles = $singleItems -> filter ( fn ( $single ) => $single -> manage_stock );
if ( $managedSingles -> isEmpty ()) {
// If no singles manage stock, the pool has unlimited availability
return [
'max_available' => PHP_INT_MAX ,
'min_available' => PHP_INT_MAX ,
'dates' => [],
];
}
// Get availability for each managed single item
$singleAvailabilities = [];
foreach ( $managedSingles as $single ) {
$singleAvailabilities [] = $single -> calendarAvailability ( $from , $until );
}
// Aggregate the availabilities
$dates = [];
$globalMin = PHP_INT_MAX ;
$globalMax = PHP_INT_MIN ;
// Get all date keys from first single (they should all have the same dates)
if ( ! empty ( $singleAvailabilities )) {
$firstAvailability = $singleAvailabilities [ 0 ];
foreach ( $firstAvailability [ 'dates' ] as $dateKey => $dayData ) {
$dayMin = 0 ;
$dayMax = 0 ;
// Sum up min and max from all singles for this date
foreach ( $singleAvailabilities as $singleAvail ) {
if ( isset ( $singleAvail [ 'dates' ][ $dateKey ])) {
$dayMin += $singleAvail [ 'dates' ][ $dateKey ][ 'min' ];
$dayMax += $singleAvail [ 'dates' ][ $dateKey ][ 'max' ];
}
}
$dates [ $dateKey ] = [
'min' => $dayMin ,
'max' => $dayMax ,
];
$globalMin = min ( $globalMin , $dayMin );
$globalMax = max ( $globalMax , $dayMax );
}
}
return [
'max_available' => $globalMax === PHP_INT_MIN ? 0 : $globalMax ,
'min_available' => $globalMin === PHP_INT_MAX ? 0 : $globalMin ,
'dates' => $dates ,
];
}
/**
* Get day availability for pool products by aggregating all single items
*
2025-12-28 10:12:58 +00:00
* @ param DateTimeInterface | null $date
2025-12-26 15:45:30 +00:00
* @ return array
*/
protected function getPoolDayAvailability ( ? DateTimeInterface $date = null ) : array
{
// Eager load single products if not already loaded
if ( ! $this -> relationLoaded ( 'singleProducts' )) {
$this -> load ( 'singleProducts' );
}
$singleItems = $this -> singleProducts ;
if ( $singleItems -> isEmpty ()) {
return [ '00:00' => 0 ];
}
// Filter to only include singles that manage stock
$managedSingles = $singleItems -> filter ( fn ( $single ) => $single -> manage_stock );
if ( $managedSingles -> isEmpty ()) {
return PHP_INT_MAX ; // Unlimited availability
}
// Get day availability for each managed single item
$singleDayAvailabilities = [];
foreach ( $managedSingles as $single ) {
$singleDayAvailabilities [] = $single -> dayAvailability ( $date );
}
// Collect all unique timestamps
$allTimestamps = [];
foreach ( $singleDayAvailabilities as $singleAvail ) {
// dayAvailability can return PHP_INT_MAX for unmanaged stock
if ( is_array ( $singleAvail )) {
$allTimestamps = array_merge ( $allTimestamps , array_keys ( $singleAvail ));
}
}
$allTimestamps = array_unique ( $allTimestamps );
sort ( $allTimestamps );
// Aggregate availability for each timestamp
$aggregated = [];
foreach ( $allTimestamps as $timestamp ) {
$total = 0 ;
foreach ( $singleDayAvailabilities as $singleAvail ) {
// Find the most recent timestamp <= current timestamp
$applicableValue = 0 ;
foreach ( $singleAvail as $time => $value ) {
if ( $time <= $timestamp ) {
$applicableValue = $value ;
} else {
break ;
}
}
$total += $applicableValue ;
}
$aggregated [ $timestamp ] = $total ;
}
return $aggregated ;
}
2025-12-28 09:29:23 +00:00
/**
2025-12-28 10:12:58 +00:00
* Get remaining available stock that can be added to cart
2025-12-28 09:48:22 +00:00
*
* This method calculates how many more units can be added to a cart :
2025-12-28 10:12:58 +00:00
* - For pool products : total capacity minus cart items ( NOT date - restricted )
* - For booking products : total stock minus cart items ( NOT date - restricted )
* - The idea is that users can add items freely and adjust dates later
* - Date - based validation happens at checkout , not when adding to cart
2025-12-28 09:48:22 +00:00
*
* @ param \Blax\Shop\Models\Cart | null $cart Optional cart to subtract items from
* @ return int Available quantity ( PHP_INT_MAX if unlimited )
2025-12-28 09:29:23 +00:00
*/
2025-12-28 10:12:58 +00:00
public function getHasMore ( $cart = null ) : int
{
2025-12-28 09:48:22 +00:00
// Try to get current cart from facade if not provided
if ( $cart === null ) {
try {
$cart = \Blax\Shop\Facades\Cart :: current ();
} catch ( \Exception $e ) {
// No cart available, that's fine
$cart = null ;
2025-12-28 09:29:23 +00:00
}
2025-12-28 09:48:22 +00:00
}
2025-12-28 09:29:23 +00:00
2025-12-28 09:48:22 +00:00
if ( method_exists ( $this , 'isPool' ) && $this -> isPool ()) {
2025-12-28 10:12:58 +00:00
return $this -> getPoolHasMore ( $cart );
2025-12-28 09:48:22 +00:00
}
2025-12-28 09:29:23 +00:00
2025-12-28 09:48:22 +00:00
if ( $this -> manage_stock === false ) {
return PHP_INT_MAX ;
}
2025-12-28 09:29:23 +00:00
2025-12-28 10:12:58 +00:00
// Get total stock capacity (not date-restricted)
// This allows users to add items and adjust dates later
$baseAvailable = $this -> getAvailableStock ();
2025-12-28 09:48:22 +00:00
// Subtract items already in cart for this product
if ( $cart ) {
$inCart = $cart -> items ()
-> where ( 'purchasable_id' , $this -> getKey ())
-> where ( 'purchasable_type' , get_class ( $this ))
-> sum ( 'quantity' );
$baseAvailable = max ( 0 , $baseAvailable - $inCart );
}
return $baseAvailable ;
}
/**
2025-12-28 10:12:58 +00:00
* Get remaining availability for pool products
*
* Returns total pool capacity minus items already in cart .
* Does NOT consider date - based availability - that ' s validated at checkout .
2025-12-28 09:48:22 +00:00
*
* @ param \Blax\Shop\Models\Cart | null $cart
* @ return int
*/
2025-12-28 10:12:58 +00:00
protected function getPoolHasMore ( $cart = null ) : int
{
// Get total pool capacity (NOT date-restricted)
if ( method_exists ( $this , 'getPoolTotalCapacity' )) {
$totalCapacity = $this -> getPoolTotalCapacity ();
} else {
// Fallback if method doesn't exist
if ( ! $this -> relationLoaded ( 'singleProducts' )) {
$this -> load ( 'singleProducts' );
}
$totalCapacity = 0 ;
foreach ( $this -> singleProducts as $single ) {
if ( ! $single -> manage_stock ) {
return PHP_INT_MAX ;
}
$totalCapacity += $single -> getAvailableStock ();
}
}
if ( $totalCapacity === PHP_INT_MAX ) {
return PHP_INT_MAX ;
2025-12-28 09:48:22 +00:00
}
2025-12-28 10:12:58 +00:00
// Subtract pool items already in cart
if ( $cart ) {
$inCart = $cart -> items ()
-> where ( 'purchasable_id' , $this -> getKey ())
-> where ( 'purchasable_type' , get_class ( $this ))
-> sum ( 'quantity' );
2025-12-28 09:48:22 +00:00
2025-12-28 10:12:58 +00:00
$totalCapacity = max ( 0 , $totalCapacity - $inCart );
}
2025-12-28 09:48:22 +00:00
2025-12-28 10:12:58 +00:00
return $totalCapacity ;
}
2025-12-28 09:29:23 +00:00
2025-12-28 10:12:58 +00:00
/**
* Get available stock for a specific date range
*
* Use this method when you need to check date - based availability
* ( e . g . , for showing a calendar , or at checkout validation )
*
* @ param DateTimeInterface $from
* @ param DateTimeInterface $until
* @ param \Blax\Shop\Models\Cart | null $cart Optional cart to subtract items from
* @ return int
*/
public function getAvailableForDateRange (
DateTimeInterface $from ,
DateTimeInterface $until ,
$cart = null
) : int {
if ( $this -> manage_stock === false ) {
return PHP_INT_MAX ;
}
2025-12-28 09:48:22 +00:00
2025-12-28 10:12:58 +00:00
if ( method_exists ( $this , 'isPool' ) && $this -> isPool ()) {
// For pools, get min availability across all singles for the date range
if ( method_exists ( $this , 'getPoolMaxQuantity' )) {
$available = $this -> getPoolMaxQuantity ( $from , $until );
} else {
$available = $this -> getMinAvailableInRange ( $from , $until );
2025-12-28 09:48:22 +00:00
}
2025-12-28 10:12:58 +00:00
} else {
$available = $this -> getMinAvailableInRange ( $from , $until );
2025-12-28 09:29:23 +00:00
}
2025-12-28 10:12:58 +00:00
// Subtract items already in cart for this product
2025-12-28 09:48:22 +00:00
if ( $cart ) {
$inCart = $cart -> items ()
-> where ( 'purchasable_id' , $this -> getKey ())
-> where ( 'purchasable_type' , get_class ( $this ))
-> sum ( 'quantity' );
2025-12-28 10:12:58 +00:00
$available = max ( 0 , $available - $inCart );
2025-12-28 09:29:23 +00:00
}
2025-12-28 10:12:58 +00:00
return $available ;
2025-12-28 09:48:22 +00:00
}
/**
* Get minimum available stock across a date range
*
2025-12-28 10:12:58 +00:00
* @ param DateTimeInterface $from
* @ param DateTimeInterface $until
2025-12-28 09:48:22 +00:00
* @ return int
*/
2025-12-28 10:12:58 +00:00
protected function getMinAvailableInRange ( DateTimeInterface $from , DateTimeInterface $until ) : int
2025-12-28 09:48:22 +00:00
{
$availability = $this -> calendarAvailability ( $from , $until );
if ( empty ( $availability [ 'dates' ])) {
return $availability [ 'min_available' ] ? ? 0 ;
}
$minAvailable = PHP_INT_MAX ;
foreach ( $availability [ 'dates' ] as $dayData ) {
$minAvailable = min ( $minAvailable , $dayData [ 'min' ] ? ? 0 );
}
return $minAvailable === PHP_INT_MAX ? 0 : $minAvailable ;
}
/**
* Attribute accessor for has_more
*
2025-12-28 10:12:58 +00:00
* Returns available stock that can still be added to cart :
* - Total capacity minus items already in cart
* - Does NOT consider date - based restrictions
* - Date validation happens at checkout
2025-12-28 09:48:22 +00:00
*
* @ return int Available quantity ( PHP_INT_MAX if unlimited )
*/
public function getHasMoreAttribute () : int
{
return $this -> getHasMore ();
2025-12-28 09:29:23 +00:00
}
2025-12-03 12:21:23 +00:00
}