2025-12-15 13:10:59 +00:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace Blax\Shop\Traits;
|
|
|
|
|
|
|
|
|
|
use Blax\Shop\Enums\ProductRelationType;
|
|
|
|
|
use Blax\Shop\Enums\ProductType;
|
2025-12-16 12:58:03 +00:00
|
|
|
use Blax\Shop\Enums\PricingStrategy;
|
2025-12-15 13:10:59 +00:00
|
|
|
use Blax\Shop\Enums\StockStatus;
|
|
|
|
|
use Blax\Shop\Enums\StockType;
|
|
|
|
|
use Blax\Shop\Exceptions\InvalidPoolConfigurationException;
|
|
|
|
|
|
|
|
|
|
trait MayBePoolProduct
|
|
|
|
|
{
|
|
|
|
|
/**
|
|
|
|
|
* Check if this is a pool product
|
|
|
|
|
*/
|
|
|
|
|
public function isPool(): bool
|
|
|
|
|
{
|
|
|
|
|
return $this->type === ProductType::POOL;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get the available quantity for this product
|
|
|
|
|
* For pool products, returns the count of available single items
|
|
|
|
|
* For regular products, returns available stock
|
|
|
|
|
*/
|
|
|
|
|
public function getAvailableQuantity(\DateTimeInterface $from = null, \DateTimeInterface $until = null): int
|
|
|
|
|
{
|
|
|
|
|
if ($this->isPool()) {
|
|
|
|
|
return $this->getPoolMaxQuantity($from, $until);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $this->getAvailableStock();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get the maximum available quantity for a pool product based on single items
|
|
|
|
|
*/
|
|
|
|
|
public function getPoolMaxQuantity(\DateTimeInterface $from = null, \DateTimeInterface $until = null): int
|
|
|
|
|
{
|
|
|
|
|
if (!$this->isPool()) {
|
|
|
|
|
return $this->getAvailableStock();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$singleItems = $this->singleProducts;
|
|
|
|
|
|
|
|
|
|
if ($singleItems->isEmpty()) {
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If no dates provided, sum up available stock from all single items
|
|
|
|
|
if (!$from || !$until) {
|
|
|
|
|
$hasUnlimitedItem = false;
|
|
|
|
|
$total = 0;
|
|
|
|
|
|
|
|
|
|
foreach ($singleItems as $item) {
|
|
|
|
|
if (!$item->manage_stock) {
|
|
|
|
|
// Track if there's an unlimited item, but don't count it
|
|
|
|
|
$hasUnlimitedItem = true;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
$total += $item->getAvailableStock();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If ALL items are unlimited, pool is unlimited
|
|
|
|
|
if ($hasUnlimitedItem && $total === 0) {
|
|
|
|
|
return PHP_INT_MAX;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $total;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check availability for each single item during the timespan and sum their available quantities
|
|
|
|
|
$availableCount = 0;
|
|
|
|
|
$hasUnlimitedItem = false;
|
|
|
|
|
|
|
|
|
|
foreach ($singleItems as $item) {
|
|
|
|
|
// Track unlimited items but don't count them
|
|
|
|
|
if (!$item->manage_stock) {
|
|
|
|
|
$hasUnlimitedItem = true;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// For booking items, check how many units are available for the period
|
|
|
|
|
if ($item->isBooking()) {
|
|
|
|
|
$availableStock = $item->getAvailableStock();
|
|
|
|
|
// Check if any quantity is available for booking
|
|
|
|
|
for ($qty = $availableStock; $qty > 0; $qty--) {
|
|
|
|
|
if ($item->isAvailableForBooking($from, $until, $qty)) {
|
|
|
|
|
$availableCount += $qty;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// For non-booking items, just add their available stock
|
|
|
|
|
$availableCount += $item->getAvailableStock();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If ALL items are unlimited, pool is unlimited
|
|
|
|
|
if ($hasUnlimitedItem && $availableCount === 0) {
|
|
|
|
|
return PHP_INT_MAX;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $availableCount;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Claim stock for a pool product
|
|
|
|
|
* This will claim stock from the available single items
|
|
|
|
|
*
|
|
|
|
|
* @param int $quantity Number of pool items to claim
|
|
|
|
|
* @param mixed $reference Reference model
|
|
|
|
|
* @param \DateTimeInterface|null $from Start date
|
|
|
|
|
* @param \DateTimeInterface|null $until End date
|
|
|
|
|
* @param string|null $note Optional note
|
|
|
|
|
* @return array Array of claimed single item products
|
|
|
|
|
* @throws \Exception
|
|
|
|
|
*/
|
|
|
|
|
public function claimPoolStock(
|
|
|
|
|
int $quantity,
|
|
|
|
|
$reference = null,
|
|
|
|
|
?\DateTimeInterface $from = null,
|
|
|
|
|
?\DateTimeInterface $until = null,
|
|
|
|
|
?string $note = null
|
|
|
|
|
): array {
|
|
|
|
|
if (!$this->isPool()) {
|
|
|
|
|
throw new \Exception('This method is only for pool products');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$singleItems = $this->singleProducts;
|
|
|
|
|
|
|
|
|
|
if ($singleItems->isEmpty()) {
|
|
|
|
|
throw new \Exception('Pool product has no single items to claim');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get available single items for the period
|
|
|
|
|
$availableItems = [];
|
|
|
|
|
foreach ($singleItems as $item) {
|
|
|
|
|
if ($item->isAvailableForBooking($from, $until, 1)) {
|
|
|
|
|
$availableItems[] = $item;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (count($availableItems) >= $quantity) {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (count($availableItems) < $quantity) {
|
|
|
|
|
throw new \Exception("Only " . count($availableItems) . " items available, but {$quantity} requested");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Claim stock from each selected single item
|
|
|
|
|
$claimedItems = [];
|
|
|
|
|
foreach (array_slice($availableItems, 0, $quantity) as $item) {
|
|
|
|
|
$item->claimStock(1, $reference, $from, $until, $note);
|
|
|
|
|
$claimedItems[] = $item;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $claimedItems;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Release pool stock claims
|
|
|
|
|
*
|
|
|
|
|
* @param mixed $reference Reference model used when claiming
|
|
|
|
|
* @return int Number of claims released
|
|
|
|
|
*/
|
|
|
|
|
public function releasePoolStock($reference): int
|
|
|
|
|
{
|
|
|
|
|
if (!$this->isPool()) {
|
|
|
|
|
throw new \Exception('This method is only for pool products');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$singleItems = $this->singleProducts;
|
|
|
|
|
$released = 0;
|
|
|
|
|
|
|
|
|
|
foreach ($singleItems as $item) {
|
|
|
|
|
$referenceType = is_object($reference) ? get_class($reference) : null;
|
|
|
|
|
$referenceId = is_object($reference) ? $reference->id : null;
|
|
|
|
|
|
|
|
|
|
// Find and delete claims for this reference
|
|
|
|
|
$claims = $item->stocks()
|
|
|
|
|
->where('type', StockType::CLAIMED->value)
|
|
|
|
|
->where('status', StockStatus::PENDING->value)
|
|
|
|
|
->where('reference_type', $referenceType)
|
|
|
|
|
->where('reference_id', $referenceId)
|
|
|
|
|
->get();
|
|
|
|
|
|
|
|
|
|
foreach ($claims as $claim) {
|
|
|
|
|
$claim->release();
|
|
|
|
|
$released++;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $released;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check if any single item in pool is a booking product
|
|
|
|
|
*/
|
|
|
|
|
public function hasBookingSingleItems(): bool
|
|
|
|
|
{
|
|
|
|
|
if (!$this->isPool()) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $this->singleProducts()->where('products.type', ProductType::BOOKING->value)->exists();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get the current price with pool product inheritance support
|
|
|
|
|
*/
|
|
|
|
|
public function getPoolCurrentPrice(bool|null $sales_price = null): ?float
|
|
|
|
|
{
|
|
|
|
|
// If this is a pool product and it has no direct price, inherit from single items
|
|
|
|
|
if ($this->isPool() && !$this->hasPrice()) {
|
|
|
|
|
return $this->getInheritedPoolPrice($sales_price);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If pool has a direct price, use it
|
|
|
|
|
if ($this->isPool() && $this->hasPrice()) {
|
|
|
|
|
return $this->defaultPrice()->first()?->getCurrentPrice($sales_price ?? $this->isOnSale());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get inherited price from single items based on pricing strategy
|
2025-12-16 12:58:03 +00:00
|
|
|
* Gets prices from available (not yet claimed) single items
|
2025-12-15 13:10:59 +00:00
|
|
|
*/
|
2025-12-16 12:58:03 +00:00
|
|
|
public function getInheritedPoolPrice(bool|null $sales_price = null, ?\DateTimeInterface $from = null, ?\DateTimeInterface $until = null): ?float
|
2025-12-15 13:10:59 +00:00
|
|
|
{
|
|
|
|
|
if (!$this->isPool()) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-16 12:58:03 +00:00
|
|
|
$strategy = $this->getPricingStrategy();
|
2025-12-15 13:10:59 +00:00
|
|
|
|
|
|
|
|
$singleItems = $this->singleProducts;
|
|
|
|
|
|
|
|
|
|
if ($singleItems->isEmpty()) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-16 12:58:03 +00:00
|
|
|
// Get available prices from single items (filtering out claimed items)
|
|
|
|
|
$prices = $singleItems->map(function ($item) use ($sales_price, $from, $until) {
|
|
|
|
|
// Only get price if the item is available
|
|
|
|
|
if ($from && $until) {
|
|
|
|
|
if (!$item->isAvailableForBooking($from, $until, 1)) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
if ($item->getAvailableStock() <= 0 && $item->manage_stock) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-15 13:10:59 +00:00
|
|
|
return $item->defaultPrice()->first()?->getCurrentPrice($sales_price ?? $item->isOnSale());
|
|
|
|
|
})->filter()->values();
|
|
|
|
|
|
|
|
|
|
if ($prices->isEmpty()) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return match ($strategy) {
|
2025-12-16 12:58:03 +00:00
|
|
|
PricingStrategy::LOWEST => $prices->min(),
|
|
|
|
|
PricingStrategy::HIGHEST => $prices->max(),
|
|
|
|
|
PricingStrategy::AVERAGE => round($prices->avg()),
|
2025-12-15 13:10:59 +00:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2025-12-16 12:58:03 +00:00
|
|
|
* Get the lowest price from available single items
|
|
|
|
|
*/
|
|
|
|
|
public function getLowestAvailablePoolPrice(?\DateTimeInterface $from = null, ?\DateTimeInterface $until = null, mixed $cart = null): ?float
|
|
|
|
|
{
|
|
|
|
|
if (!$this->isPool()) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If no cart provided, try to get the current user's cart
|
|
|
|
|
if (!$cart && auth()->check()) {
|
|
|
|
|
$cart = auth()->user()->currentCart();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If cart is provided, use dynamic pricing based on cart state
|
|
|
|
|
if ($cart) {
|
|
|
|
|
$currentQuantityInCart = $cart->items()
|
|
|
|
|
->where('purchasable_id', $this->getKey())
|
|
|
|
|
->where('purchasable_type', get_class($this))
|
|
|
|
|
->sum('quantity');
|
|
|
|
|
|
|
|
|
|
return $this->getNextAvailablePoolPrice($currentQuantityInCart, null, $from, $until);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$singleItems = $this->singleProducts;
|
|
|
|
|
|
|
|
|
|
if ($singleItems->isEmpty()) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$prices = $singleItems->map(function ($item) use ($from, $until) {
|
|
|
|
|
// Only get price if the item is available
|
|
|
|
|
if ($from && $until) {
|
|
|
|
|
if (!$item->isAvailableForBooking($from, $until, 1)) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
if ($item->getAvailableStock() <= 0 && $item->manage_stock) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $item->defaultPrice()->first()?->getCurrentPrice($item->isOnSale());
|
|
|
|
|
})->filter()->values();
|
|
|
|
|
|
|
|
|
|
return $prices->isEmpty() ? null : $prices->min();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get the highest price from available single items
|
2025-12-15 13:10:59 +00:00
|
|
|
*/
|
2025-12-16 12:58:03 +00:00
|
|
|
public function getHighestAvailablePoolPrice(?\DateTimeInterface $from = null, ?\DateTimeInterface $until = null, mixed $cart = null): ?float
|
2025-12-15 13:10:59 +00:00
|
|
|
{
|
|
|
|
|
if (!$this->isPool()) {
|
2025-12-16 12:58:03 +00:00
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If no cart provided, try to get the current user's cart
|
|
|
|
|
if (!$cart && auth()->check()) {
|
|
|
|
|
$cart = auth()->user()->currentCart();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If cart is provided, get the highest price from remaining available items
|
|
|
|
|
if ($cart) {
|
|
|
|
|
$currentQuantityInCart = $cart->items()
|
|
|
|
|
->where('purchasable_id', $this->getKey())
|
|
|
|
|
->where('purchasable_type', get_class($this))
|
|
|
|
|
->sum('quantity');
|
|
|
|
|
|
|
|
|
|
// Get the pool's actual pricing strategy to determine allocation order
|
|
|
|
|
$strategy = $this->getPricingStrategy();
|
|
|
|
|
|
|
|
|
|
// Get available items
|
|
|
|
|
$singleItems = $this->singleProducts;
|
|
|
|
|
|
|
|
|
|
if ($singleItems->isEmpty()) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Build a list of all available item prices with their quantities
|
|
|
|
|
$availableItems = [];
|
|
|
|
|
|
|
|
|
|
foreach ($singleItems as $item) {
|
|
|
|
|
// Check if item is available
|
|
|
|
|
$available = 0;
|
|
|
|
|
|
|
|
|
|
if ($from && $until) {
|
|
|
|
|
if ($item->isBooking() && $item->isAvailableForBooking($from, $until, 1)) {
|
|
|
|
|
$available = $item->getAvailableStock();
|
|
|
|
|
} elseif (!$item->isBooking()) {
|
|
|
|
|
$available = $item->getAvailableStock();
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
if ($item->manage_stock) {
|
|
|
|
|
$available = $item->getAvailableStock();
|
|
|
|
|
} else {
|
|
|
|
|
$available = PHP_INT_MAX;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($available > 0) {
|
|
|
|
|
$price = $item->defaultPrice()->first()?->getCurrentPrice($item->isOnSale());
|
|
|
|
|
|
|
|
|
|
// If no price on single item but pool has direct price, use pool's price
|
|
|
|
|
if ($price === null && $this->hasPrice()) {
|
|
|
|
|
$price = $this->defaultPrice()->first()?->getCurrentPrice($this->isOnSale());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($price !== null) {
|
|
|
|
|
$availableItems[] = [
|
|
|
|
|
'price' => $price,
|
|
|
|
|
'quantity' => $available,
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (empty($availableItems)) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Sort items based on the pool's actual pricing strategy to determine allocation order
|
|
|
|
|
usort($availableItems, function ($a, $b) use ($strategy) {
|
|
|
|
|
return match ($strategy) {
|
|
|
|
|
PricingStrategy::LOWEST => $a['price'] <=> $b['price'],
|
|
|
|
|
PricingStrategy::HIGHEST => $b['price'] <=> $a['price'],
|
|
|
|
|
PricingStrategy::AVERAGE => $a['price'] <=> $b['price'],
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Skip through items based on allocation order, then get highest of remaining
|
|
|
|
|
$skipped = 0;
|
|
|
|
|
$remainingItems = [];
|
|
|
|
|
|
|
|
|
|
foreach ($availableItems as $item) {
|
|
|
|
|
if ($skipped >= $currentQuantityInCart) {
|
|
|
|
|
// All cart items have been accounted for, these are remaining
|
|
|
|
|
$remainingItems[] = $item;
|
|
|
|
|
} else {
|
|
|
|
|
$skipFromThis = min($item['quantity'], $currentQuantityInCart - $skipped);
|
|
|
|
|
$skipped += $skipFromThis;
|
|
|
|
|
|
|
|
|
|
// If there are items left in this batch after skipping
|
|
|
|
|
if ($item['quantity'] > $skipFromThis) {
|
|
|
|
|
$remainingItems[] = [
|
|
|
|
|
'price' => $item['price'],
|
|
|
|
|
'quantity' => $item['quantity'] - $skipFromThis,
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Return the highest price from remaining items
|
|
|
|
|
if (empty($remainingItems)) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return max(array_column($remainingItems, 'price'));
|
2025-12-15 13:10:59 +00:00
|
|
|
}
|
|
|
|
|
|
2025-12-16 12:58:03 +00:00
|
|
|
$singleItems = $this->singleProducts;
|
|
|
|
|
|
|
|
|
|
if ($singleItems->isEmpty()) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$prices = $singleItems->map(function ($item) use ($from, $until) {
|
|
|
|
|
// Only get price if the item is available
|
|
|
|
|
if ($from && $until) {
|
|
|
|
|
if (!$item->isAvailableForBooking($from, $until, 1)) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
if ($item->getAvailableStock() <= 0 && $item->manage_stock) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $item->defaultPrice()->first()?->getCurrentPrice($item->isOnSale());
|
|
|
|
|
})->filter()->values();
|
|
|
|
|
|
|
|
|
|
return $prices->isEmpty() ? null : $prices->max();
|
2025-12-15 13:10:59 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2025-12-16 12:58:03 +00:00
|
|
|
* Set the pool pricing strategy (for backwards compatibility)
|
2025-12-15 13:10:59 +00:00
|
|
|
*/
|
2025-12-16 12:58:03 +00:00
|
|
|
public function setPoolPricingStrategy(string|PricingStrategy $strategy): void
|
2025-12-15 13:10:59 +00:00
|
|
|
{
|
|
|
|
|
if (!$this->isPool()) {
|
|
|
|
|
throw new \Exception('This method is only for pool products');
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-16 12:58:03 +00:00
|
|
|
// Handle both string and enum inputs
|
|
|
|
|
if (is_string($strategy)) {
|
|
|
|
|
$strategyEnum = PricingStrategy::tryFrom($strategy);
|
|
|
|
|
if (!$strategyEnum) {
|
|
|
|
|
throw new \InvalidArgumentException("Invalid pricing strategy: {$strategy}");
|
|
|
|
|
}
|
|
|
|
|
$strategy = $strategyEnum;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$this->setPricingStrategy($strategy);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get the price for the next available item from the pool
|
|
|
|
|
* considering how many items have already been allocated/claimed
|
|
|
|
|
*
|
|
|
|
|
* This method simulates "picking" items from the pool in order of the pricing strategy
|
|
|
|
|
* and returns the price of the Nth item
|
|
|
|
|
*
|
|
|
|
|
* @param int $skipQuantity How many items to skip (already allocated)
|
|
|
|
|
* @param bool|null $sales_price Whether to get sale price
|
|
|
|
|
* @param \DateTimeInterface|null $from Start date for availability check
|
|
|
|
|
* @param \DateTimeInterface|null $until End date for availability check
|
|
|
|
|
* @return float|null Price of the next available item
|
|
|
|
|
*/
|
|
|
|
|
public function getNextAvailablePoolPrice(
|
|
|
|
|
int $skipQuantity = 0,
|
|
|
|
|
bool|null $sales_price = null,
|
|
|
|
|
?\DateTimeInterface $from = null,
|
|
|
|
|
?\DateTimeInterface $until = null
|
|
|
|
|
): ?float {
|
|
|
|
|
if (!$this->isPool()) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$strategy = $this->getPricingStrategy();
|
|
|
|
|
$singleItems = $this->singleProducts;
|
|
|
|
|
|
|
|
|
|
if ($singleItems->isEmpty()) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Build a list of all available item prices with their quantities
|
|
|
|
|
$availableItems = [];
|
|
|
|
|
|
|
|
|
|
foreach ($singleItems as $item) {
|
|
|
|
|
// Check if item is available
|
|
|
|
|
$available = 0;
|
|
|
|
|
|
|
|
|
|
if ($from && $until) {
|
|
|
|
|
if ($item->isBooking()) {
|
|
|
|
|
// For booking items, calculate actual available quantity during the period
|
|
|
|
|
if (!$item->manage_stock) {
|
|
|
|
|
$available = PHP_INT_MAX;
|
|
|
|
|
} else {
|
|
|
|
|
// Calculate overlapping claims for this specific period
|
|
|
|
|
$overlappingClaims = $item->stocks()
|
|
|
|
|
->where('type', \Blax\Shop\Enums\StockType::CLAIMED->value)
|
|
|
|
|
->where('status', \Blax\Shop\Enums\StockStatus::PENDING->value)
|
|
|
|
|
->where(function ($query) use ($from, $until) {
|
|
|
|
|
$query->where(function ($q) use ($from, $until) {
|
|
|
|
|
$q->whereBetween('claimed_from', [$from, $until]);
|
|
|
|
|
})->orWhere(function ($q) use ($from, $until) {
|
|
|
|
|
$q->whereBetween('expires_at', [$from, $until]);
|
|
|
|
|
})->orWhere(function ($q) use ($from, $until) {
|
|
|
|
|
$q->where('claimed_from', '<=', $from)
|
|
|
|
|
->where('expires_at', '>=', $until);
|
|
|
|
|
})->orWhere(function ($q) use ($from, $until) {
|
|
|
|
|
$q->whereNull('claimed_from')
|
|
|
|
|
->where(function ($subQ) use ($from, $until) {
|
|
|
|
|
$subQ->whereNull('expires_at')
|
|
|
|
|
->orWhere('expires_at', '>=', $from);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
})
|
|
|
|
|
->sum('quantity');
|
2025-12-17 09:41:52 +00:00
|
|
|
|
2025-12-16 12:58:03 +00:00
|
|
|
$available = max(0, $item->getAvailableStock() - abs($overlappingClaims));
|
|
|
|
|
}
|
|
|
|
|
} elseif (!$item->isBooking()) {
|
|
|
|
|
$available = $item->getAvailableStock();
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
if ($item->manage_stock) {
|
|
|
|
|
$available = $item->getAvailableStock();
|
|
|
|
|
} else {
|
|
|
|
|
$available = PHP_INT_MAX;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($available > 0) {
|
|
|
|
|
$price = $item->defaultPrice()->first()?->getCurrentPrice($sales_price ?? $item->isOnSale());
|
|
|
|
|
|
|
|
|
|
// If no price on single item but pool has direct price, use pool's price
|
|
|
|
|
if ($price === null && $this->hasPrice()) {
|
|
|
|
|
$price = $this->defaultPrice()->first()?->getCurrentPrice($sales_price ?? $this->isOnSale());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($price !== null) {
|
|
|
|
|
$availableItems[] = [
|
|
|
|
|
'price' => $price,
|
|
|
|
|
'quantity' => $available,
|
|
|
|
|
'item' => $item,
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (empty($availableItems)) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// For AVERAGE strategy, return the average price of all available items
|
|
|
|
|
if ($strategy === PricingStrategy::AVERAGE) {
|
|
|
|
|
$totalPrice = 0;
|
|
|
|
|
$totalQuantity = 0;
|
|
|
|
|
foreach ($availableItems as $item) {
|
|
|
|
|
$totalPrice += $item['price'] * $item['quantity'];
|
|
|
|
|
$totalQuantity += $item['quantity'];
|
|
|
|
|
}
|
|
|
|
|
return $totalQuantity > 0 ? $totalPrice / $totalQuantity : null;
|
2025-12-15 13:10:59 +00:00
|
|
|
}
|
|
|
|
|
|
2025-12-16 12:58:03 +00:00
|
|
|
// Sort items based on pricing strategy (for LOWEST and HIGHEST)
|
|
|
|
|
usort($availableItems, function ($a, $b) use ($strategy) {
|
|
|
|
|
return match ($strategy) {
|
|
|
|
|
PricingStrategy::LOWEST => $a['price'] <=> $b['price'],
|
|
|
|
|
PricingStrategy::HIGHEST => $b['price'] <=> $a['price'],
|
|
|
|
|
PricingStrategy::AVERAGE => 0, // Already handled above
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Skip through items based on $skipQuantity
|
|
|
|
|
$skipped = 0;
|
|
|
|
|
foreach ($availableItems as $item) {
|
|
|
|
|
if ($skipped + $item['quantity'] > $skipQuantity) {
|
|
|
|
|
// This is the item we want
|
|
|
|
|
return $item['price'];
|
|
|
|
|
}
|
|
|
|
|
$skipped += $item['quantity'];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If we've skipped past all items, return null
|
|
|
|
|
return null;
|
2025-12-15 13:10:59 +00:00
|
|
|
}
|
|
|
|
|
|
2025-12-17 09:41:52 +00:00
|
|
|
/**
|
|
|
|
|
* Get next available pool price considering which specific price tiers are already in the cart
|
|
|
|
|
* This is smarter than getNextAvailablePoolPrice because it tracks usage by price point
|
|
|
|
|
*
|
|
|
|
|
* @param \Blax\Shop\Models\Cart $cart The cart to check
|
|
|
|
|
* @param bool|null $sales_price Whether to get sale price
|
|
|
|
|
* @param \DateTimeInterface|null $from Start date for availability check
|
|
|
|
|
* @param \DateTimeInterface|null $until End date for availability check
|
|
|
|
|
* @return float|null
|
|
|
|
|
*/
|
|
|
|
|
public function getNextAvailablePoolPriceConsideringCart(
|
|
|
|
|
\Blax\Shop\Models\Cart $cart,
|
|
|
|
|
bool|null $sales_price = null,
|
|
|
|
|
?\DateTimeInterface $from = null,
|
|
|
|
|
?\DateTimeInterface $until = null
|
|
|
|
|
): ?float {
|
|
|
|
|
if (!$this->isPool()) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$strategy = $this->getPricingStrategy();
|
|
|
|
|
$singleItems = $this->singleProducts;
|
|
|
|
|
|
|
|
|
|
if ($singleItems->isEmpty()) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get cart items for this pool
|
|
|
|
|
$cartItems = $cart->items()
|
|
|
|
|
->where('purchasable_id', $this->getKey())
|
|
|
|
|
->where('purchasable_type', get_class($this))
|
|
|
|
|
->get();
|
|
|
|
|
|
|
|
|
|
// If no dates provided, try to extract from cart items
|
|
|
|
|
if (!$from && !$until) {
|
|
|
|
|
$firstItemWithDates = $cartItems->first(fn($item) => $item->from && $item->until);
|
|
|
|
|
if ($firstItemWithDates) {
|
|
|
|
|
$from = $firstItemWithDates->from;
|
|
|
|
|
$until = $firstItemWithDates->until;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Calculate days for price normalization
|
|
|
|
|
$days = 1;
|
|
|
|
|
if ($from && $until) {
|
|
|
|
|
$days = max(1, $from->diff($until)->days);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Build usage map: price => quantity used
|
|
|
|
|
$priceUsage = [];
|
|
|
|
|
foreach ($cartItems as $item) {
|
|
|
|
|
$pricePerDay = $item->price / $days;
|
|
|
|
|
$priceKey = round($pricePerDay, 2); // Round to avoid floating point issues
|
|
|
|
|
$priceUsage[$priceKey] = ($priceUsage[$priceKey] ?? 0) + $item->quantity;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Build available items list
|
|
|
|
|
$availableItems = [];
|
|
|
|
|
foreach ($singleItems as $item) {
|
|
|
|
|
$available = 0;
|
|
|
|
|
|
|
|
|
|
if ($from && $until) {
|
|
|
|
|
if ($item->isBooking()) {
|
|
|
|
|
if (!$item->manage_stock) {
|
|
|
|
|
$available = PHP_INT_MAX;
|
|
|
|
|
} else {
|
|
|
|
|
// Calculate overlapping claims
|
|
|
|
|
$overlappingClaims = $item->stocks()
|
|
|
|
|
->where('type', \Blax\Shop\Enums\StockType::CLAIMED->value)
|
|
|
|
|
->where('status', \Blax\Shop\Enums\StockStatus::PENDING->value)
|
|
|
|
|
->where(function ($query) use ($from, $until) {
|
|
|
|
|
$query->where(function ($q) use ($from, $until) {
|
|
|
|
|
$q->whereBetween('claimed_from', [$from, $until]);
|
|
|
|
|
})->orWhere(function ($q) use ($from, $until) {
|
|
|
|
|
$q->whereBetween('expires_at', [$from, $until]);
|
|
|
|
|
})->orWhere(function ($q) use ($from, $until) {
|
|
|
|
|
$q->where('claimed_from', '<=', $from)
|
|
|
|
|
->where('expires_at', '>=', $until);
|
|
|
|
|
})->orWhere(function ($q) use ($from, $until) {
|
|
|
|
|
$q->whereNull('claimed_from')
|
|
|
|
|
->where(function ($subQ) use ($from, $until) {
|
|
|
|
|
$subQ->whereNull('expires_at')
|
|
|
|
|
->orWhere('expires_at', '>=', $from);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
})
|
|
|
|
|
->sum('quantity');
|
|
|
|
|
|
|
|
|
|
$available = max(0, $item->getAvailableStock() - abs($overlappingClaims));
|
|
|
|
|
}
|
|
|
|
|
} elseif (!$item->isBooking()) {
|
|
|
|
|
$available = $item->getAvailableStock();
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
if ($item->manage_stock) {
|
|
|
|
|
$available = $item->getAvailableStock();
|
|
|
|
|
} else {
|
|
|
|
|
$available = PHP_INT_MAX;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($available > 0) {
|
|
|
|
|
$price = $item->defaultPrice()->first()?->getCurrentPrice($sales_price ?? $item->isOnSale());
|
|
|
|
|
|
|
|
|
|
if ($price === null && $this->hasPrice()) {
|
|
|
|
|
$price = $this->defaultPrice()->first()?->getCurrentPrice($sales_price ?? $this->isOnSale());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($price !== null) {
|
|
|
|
|
$priceRounded = round($price, 2);
|
|
|
|
|
|
|
|
|
|
// Subtract quantity already used in cart at this price
|
|
|
|
|
$usedAtThisPrice = $priceUsage[$priceRounded] ?? 0;
|
|
|
|
|
$availableAtThisPrice = $available - $usedAtThisPrice;
|
|
|
|
|
|
|
|
|
|
if ($availableAtThisPrice > 0) {
|
|
|
|
|
$availableItems[] = [
|
|
|
|
|
'price' => $price,
|
|
|
|
|
'quantity' => $availableAtThisPrice,
|
|
|
|
|
'item' => $item,
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Also add pool's direct price if it has one
|
|
|
|
|
if ($this->hasPrice()) {
|
|
|
|
|
$poolPrice = $this->defaultPrice()->first()?->getCurrentPrice($sales_price ?? $this->isOnSale());
|
|
|
|
|
if ($poolPrice !== null) {
|
|
|
|
|
$poolPriceRounded = round($poolPrice, 2);
|
|
|
|
|
$usedAtPoolPrice = $priceUsage[$poolPriceRounded] ?? 0;
|
|
|
|
|
|
|
|
|
|
// Pool price is typically unlimited (doesn't manage stock)
|
|
|
|
|
if (!$this->manage_stock) {
|
|
|
|
|
$availableItems[] = [
|
|
|
|
|
'price' => $poolPrice,
|
|
|
|
|
'quantity' => PHP_INT_MAX,
|
|
|
|
|
'item' => $this,
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (empty($availableItems)) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// For AVERAGE strategy, calculate weighted average of available items
|
|
|
|
|
if ($strategy === \Blax\Shop\Enums\PricingStrategy::AVERAGE) {
|
|
|
|
|
$totalPrice = 0;
|
|
|
|
|
$totalQuantity = 0;
|
|
|
|
|
foreach ($availableItems as $item) {
|
|
|
|
|
$qty = $item['quantity'] === PHP_INT_MAX ? 1 : $item['quantity'];
|
|
|
|
|
$totalPrice += $item['price'] * $qty;
|
|
|
|
|
$totalQuantity += $qty;
|
|
|
|
|
}
|
|
|
|
|
return $totalQuantity > 0 ? $totalPrice / $totalQuantity : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Sort by strategy
|
|
|
|
|
usort($availableItems, function ($a, $b) use ($strategy) {
|
|
|
|
|
return match ($strategy) {
|
|
|
|
|
\Blax\Shop\Enums\PricingStrategy::LOWEST => $a['price'] <=> $b['price'],
|
|
|
|
|
\Blax\Shop\Enums\PricingStrategy::HIGHEST => $b['price'] <=> $a['price'],
|
|
|
|
|
\Blax\Shop\Enums\PricingStrategy::AVERAGE => 0,
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Return the first available item's price
|
|
|
|
|
return $availableItems[0]['price'] ?? null;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-15 13:10:59 +00:00
|
|
|
/**
|
|
|
|
|
* Attach single items to this pool product
|
|
|
|
|
* Also creates reverse POOL relation from single items back to this pool
|
|
|
|
|
*
|
|
|
|
|
* @param array|int|string $singleItemIds Single product ID(s) to attach
|
|
|
|
|
* @param array $attributes Additional pivot attributes
|
|
|
|
|
* @return void
|
|
|
|
|
*/
|
|
|
|
|
public function attachSingleItems(array|int|string $singleItemIds, array $attributes = []): void
|
|
|
|
|
{
|
|
|
|
|
if (!$this->isPool()) {
|
|
|
|
|
throw new \Exception('This method is only for pool products');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$ids = is_array($singleItemIds) ? $singleItemIds : [$singleItemIds];
|
|
|
|
|
|
|
|
|
|
// Attach single items to pool with SINGLE type
|
|
|
|
|
$this->productRelations()->attach(
|
|
|
|
|
array_fill_keys($ids, array_merge(['type' => ProductRelationType::SINGLE->value], $attributes))
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Attach reverse POOL relation from each single item back to this pool
|
|
|
|
|
foreach ($ids as $singleItemId) {
|
|
|
|
|
$singleItem = static::find($singleItemId);
|
|
|
|
|
if ($singleItem) {
|
|
|
|
|
$singleItem->productRelations()->attach(
|
|
|
|
|
$this->id,
|
|
|
|
|
array_merge(['type' => ProductRelationType::POOL->value], $attributes)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get the lowest price from single items
|
|
|
|
|
*/
|
|
|
|
|
public function getLowestPoolPrice(): ?float
|
|
|
|
|
{
|
|
|
|
|
if (!$this->isPool()) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$singleItems = $this->singleProducts;
|
|
|
|
|
|
|
|
|
|
if ($singleItems->isEmpty()) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$prices = $singleItems->map(function ($item) {
|
|
|
|
|
return $item->defaultPrice()->first()?->getCurrentPrice($item->isOnSale());
|
|
|
|
|
})->filter()->values();
|
|
|
|
|
|
|
|
|
|
return $prices->isEmpty() ? null : $prices->min();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2025-12-16 12:58:03 +00:00
|
|
|
* Get the price range for pool products (from available items)
|
2025-12-15 13:10:59 +00:00
|
|
|
*/
|
2025-12-16 12:58:03 +00:00
|
|
|
public function getPoolPriceRange(?\DateTimeInterface $from = null, ?\DateTimeInterface $until = null): ?array
|
2025-12-15 13:10:59 +00:00
|
|
|
{
|
|
|
|
|
if (!$this->isPool()) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-16 12:58:03 +00:00
|
|
|
$lowest = $this->getLowestAvailablePoolPrice($from, $until);
|
|
|
|
|
$highest = $this->getHighestAvailablePoolPrice($from, $until);
|
2025-12-15 13:10:59 +00:00
|
|
|
|
|
|
|
|
if ($lowest === null || $highest === null) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
'min' => $lowest,
|
|
|
|
|
'max' => $highest,
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Validate pool product configuration and provide helpful error messages
|
|
|
|
|
*
|
|
|
|
|
* @throws InvalidPoolConfigurationException
|
|
|
|
|
*/
|
|
|
|
|
public function validatePoolConfiguration(bool $throwOnWarnings = false): array
|
|
|
|
|
{
|
|
|
|
|
$errors = [];
|
|
|
|
|
$warnings = [];
|
|
|
|
|
|
|
|
|
|
if (!$this->isPool()) {
|
|
|
|
|
throw InvalidPoolConfigurationException::notAPoolProduct($this->name);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$singleItems = $this->singleProducts;
|
|
|
|
|
|
|
|
|
|
// Critical: No single items
|
|
|
|
|
if ($singleItems->isEmpty()) {
|
|
|
|
|
throw InvalidPoolConfigurationException::noSingleItems($this->name);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check for mixed product types
|
|
|
|
|
$types = $singleItems->pluck('type')->unique();
|
|
|
|
|
if ($types->count() > 1) {
|
|
|
|
|
$warning = "Mixed single item types detected. This may cause unexpected behavior.";
|
|
|
|
|
$warnings[] = $warning;
|
|
|
|
|
if ($throwOnWarnings) {
|
|
|
|
|
throw InvalidPoolConfigurationException::mixedSingleItemTypes($this->name);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check stock management on single items
|
|
|
|
|
$itemsWithoutStock = $singleItems->filter(fn($item) => !$item->manage_stock);
|
|
|
|
|
if ($itemsWithoutStock->isNotEmpty()) {
|
|
|
|
|
$itemNames = $itemsWithoutStock->pluck('name')->toArray();
|
|
|
|
|
$errors[] = "Single items without stock management: " . implode(', ', $itemNames);
|
|
|
|
|
throw InvalidPoolConfigurationException::singleItemsWithoutStock($this->name, $itemNames);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check for items with zero stock
|
|
|
|
|
$itemsWithZeroStock = $singleItems->filter(fn($item) => $item->getAvailableStock() <= 0);
|
|
|
|
|
if ($itemsWithZeroStock->isNotEmpty()) {
|
|
|
|
|
$itemNames = $itemsWithZeroStock->pluck('name')->toArray();
|
|
|
|
|
$warnings[] = "Single items with zero stock: " . implode(', ', $itemNames);
|
|
|
|
|
if ($throwOnWarnings) {
|
|
|
|
|
throw InvalidPoolConfigurationException::singleItemsWithZeroStock($this->name, $itemNames);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
'valid' => empty($errors),
|
|
|
|
|
'errors' => $errors,
|
|
|
|
|
'warnings' => $warnings,
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get pool availability calendar showing how many items are available on each date.
|
|
|
|
|
* Returns an array with dates as keys and availability counts as values.
|
|
|
|
|
*
|
|
|
|
|
* Example usage:
|
|
|
|
|
* ```php
|
|
|
|
|
* $pool = Product::find($id);
|
|
|
|
|
* $availability = $pool->getPoolAvailabilityCalendar('2025-01-01', '2025-01-07', 2);
|
|
|
|
|
*
|
|
|
|
|
* foreach ($availability as $date => $count) {
|
|
|
|
|
* echo "$date: $count items available\n";
|
|
|
|
|
* }
|
|
|
|
|
* // Output:
|
|
|
|
|
* // 2025-01-01: 3 items available
|
|
|
|
|
* // 2025-01-02: 2 items available
|
|
|
|
|
* // 2025-01-03: 1 items available
|
|
|
|
|
* ```
|
|
|
|
|
*
|
|
|
|
|
* @param \DateTimeInterface|string $startDate Start date for availability check
|
|
|
|
|
* @param \DateTimeInterface|string $endDate End date for availability check
|
|
|
|
|
* @param int $quantity How many items are needed (default 1)
|
|
|
|
|
* @return array Array with dates as keys and availability counts as values
|
|
|
|
|
*/
|
|
|
|
|
public function getPoolAvailabilityCalendar($startDate, $endDate, int $quantity = 1): array
|
|
|
|
|
{
|
|
|
|
|
if (!$this->isPool()) {
|
|
|
|
|
throw new \Exception('This method is only for pool products');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$start = $startDate instanceof \DateTimeInterface ? $startDate : \Carbon\Carbon::parse($startDate);
|
|
|
|
|
$end = $endDate instanceof \DateTimeInterface ? $endDate : \Carbon\Carbon::parse($endDate);
|
|
|
|
|
|
|
|
|
|
$calendar = [];
|
|
|
|
|
$current = $start->copy();
|
|
|
|
|
|
|
|
|
|
while ($current <= $end) {
|
|
|
|
|
$dateStr = $current->format('Y-m-d');
|
|
|
|
|
$nextDay = $current->copy()->addDay();
|
|
|
|
|
|
|
|
|
|
// Check availability for this single day
|
|
|
|
|
$available = $this->getPoolMaxQuantity($current, $nextDay);
|
|
|
|
|
$calendar[$dateStr] = $available === PHP_INT_MAX ? 'unlimited' : $available;
|
|
|
|
|
|
|
|
|
|
$current->addDay();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $calendar;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get detailed availability for each single item in the pool.
|
|
|
|
|
* Shows which specific items are available and their quantities.
|
|
|
|
|
*
|
|
|
|
|
* Example usage:
|
|
|
|
|
* ```php
|
|
|
|
|
* $pool = Product::find($id);
|
|
|
|
|
* $items = $pool->getSingleItemsAvailability('2025-01-01', '2025-01-02');
|
|
|
|
|
*
|
|
|
|
|
* foreach ($items as $item) {
|
|
|
|
|
* echo "{$item['name']}: {$item['available']} available\n";
|
|
|
|
|
* }
|
|
|
|
|
* ```
|
|
|
|
|
*
|
|
|
|
|
* @param \DateTimeInterface|string|null $from Start date (optional)
|
|
|
|
|
* @param \DateTimeInterface|string|null $until End date (optional)
|
|
|
|
|
* @return array Array of single items with their availability
|
|
|
|
|
*/
|
|
|
|
|
public function getSingleItemsAvailability($from = null, $until = null): array
|
|
|
|
|
{
|
|
|
|
|
if (!$this->isPool()) {
|
|
|
|
|
throw new \Exception('This method is only for pool products');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$singleItems = $this->singleProducts;
|
|
|
|
|
$availability = [];
|
|
|
|
|
|
|
|
|
|
if ($from && $until) {
|
|
|
|
|
$fromDate = $from instanceof \DateTimeInterface ? $from : \Carbon\Carbon::parse($from);
|
|
|
|
|
$untilDate = $until instanceof \DateTimeInterface ? $until : \Carbon\Carbon::parse($until);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
foreach ($singleItems as $item) {
|
|
|
|
|
$available = 0;
|
|
|
|
|
|
|
|
|
|
if (!$item->manage_stock) {
|
|
|
|
|
$available = 'unlimited';
|
|
|
|
|
} elseif (isset($fromDate) && isset($untilDate)) {
|
|
|
|
|
// Check availability for the specific period
|
|
|
|
|
if ($item->isBooking()) {
|
|
|
|
|
$availableStock = $item->getAvailableStock();
|
|
|
|
|
// Check maximum available quantity for this period
|
|
|
|
|
for ($qty = $availableStock; $qty > 0; $qty--) {
|
|
|
|
|
if ($item->isAvailableForBooking($fromDate, $untilDate, $qty)) {
|
|
|
|
|
$available = $qty;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
$available = $item->getAvailableStock();
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// No dates specified, get general stock
|
|
|
|
|
$available = $item->getAvailableStock();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$availability[] = [
|
|
|
|
|
'id' => $item->id,
|
|
|
|
|
'name' => $item->name,
|
|
|
|
|
'type' => $item->type->value,
|
|
|
|
|
'available' => $available,
|
|
|
|
|
'manage_stock' => $item->manage_stock,
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $availability;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check if the pool is available for a specific date range and quantity.
|
|
|
|
|
* A pool is NOT available if at least one single item is not available.
|
|
|
|
|
*
|
|
|
|
|
* @param \DateTimeInterface $from Start date
|
|
|
|
|
* @param \DateTimeInterface $until End date
|
|
|
|
|
* @param int $quantity Required quantity
|
|
|
|
|
* @return bool True if pool is available for the period
|
|
|
|
|
*/
|
|
|
|
|
public function isPoolAvailable(\DateTimeInterface $from, \DateTimeInterface $until, int $quantity = 1): bool
|
|
|
|
|
{
|
|
|
|
|
if (!$this->isPool()) {
|
|
|
|
|
throw new \Exception('This method is only for pool products');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$maxQuantity = $this->getPoolMaxQuantity($from, $until);
|
|
|
|
|
|
|
|
|
|
// Unlimited availability
|
|
|
|
|
if ($maxQuantity === PHP_INT_MAX) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $maxQuantity >= $quantity;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get available date ranges for the pool with a specific quantity.
|
|
|
|
|
* Returns periods where the pool has the required availability.
|
|
|
|
|
*
|
|
|
|
|
* @param \DateTimeInterface|string $startDate Start of search period
|
|
|
|
|
* @param \DateTimeInterface|string $endDate End of search period
|
|
|
|
|
* @param int $quantity Required quantity
|
|
|
|
|
* @param int $minConsecutiveDays Minimum consecutive days needed (default 1)
|
|
|
|
|
* @return array Array of available periods
|
|
|
|
|
*/
|
|
|
|
|
public function getPoolAvailablePeriods($startDate, $endDate, int $quantity = 1, int $minConsecutiveDays = 1): array
|
|
|
|
|
{
|
|
|
|
|
if (!$this->isPool()) {
|
|
|
|
|
throw new \Exception('This method is only for pool products');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$start = $startDate instanceof \DateTimeInterface ? $startDate : \Carbon\Carbon::parse($startDate);
|
|
|
|
|
$end = $endDate instanceof \DateTimeInterface ? $endDate : \Carbon\Carbon::parse($endDate);
|
|
|
|
|
|
|
|
|
|
$calendar = $this->getPoolAvailabilityCalendar($start, $end, $quantity);
|
|
|
|
|
$periods = [];
|
|
|
|
|
$currentPeriod = null;
|
|
|
|
|
|
|
|
|
|
foreach ($calendar as $date => $available) {
|
|
|
|
|
$isAvailable = ($available === 'unlimited' || $available >= $quantity);
|
|
|
|
|
|
|
|
|
|
if ($isAvailable) {
|
|
|
|
|
if ($currentPeriod === null) {
|
|
|
|
|
$currentPeriod = [
|
|
|
|
|
'from' => $date,
|
|
|
|
|
'until' => $date,
|
|
|
|
|
'min_available' => $available,
|
|
|
|
|
];
|
|
|
|
|
} else {
|
|
|
|
|
$currentPeriod['until'] = $date;
|
|
|
|
|
if ($available !== 'unlimited' && $currentPeriod['min_available'] !== 'unlimited') {
|
|
|
|
|
$currentPeriod['min_available'] = min($currentPeriod['min_available'], $available);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
if ($currentPeriod !== null) {
|
|
|
|
|
// Check if period meets minimum days requirement
|
|
|
|
|
$from = \Carbon\Carbon::parse($currentPeriod['from']);
|
|
|
|
|
$until = \Carbon\Carbon::parse($currentPeriod['until']);
|
|
|
|
|
$days = $from->diffInDays($until) + 1; // +1 to include both start and end dates
|
|
|
|
|
|
|
|
|
|
if ($days >= $minConsecutiveDays) {
|
|
|
|
|
$periods[] = $currentPeriod;
|
|
|
|
|
}
|
|
|
|
|
$currentPeriod = null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Add final period if exists
|
|
|
|
|
if ($currentPeriod !== null) {
|
|
|
|
|
$from = \Carbon\Carbon::parse($currentPeriod['from']);
|
|
|
|
|
$until = \Carbon\Carbon::parse($currentPeriod['until']);
|
|
|
|
|
$days = $from->diffInDays($until) + 1;
|
|
|
|
|
|
|
|
|
|
if ($days >= $minConsecutiveDays) {
|
|
|
|
|
$periods[] = $currentPeriod;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $periods;
|
|
|
|
|
}
|
|
|
|
|
}
|