From 2f0d0757ee24c7cff3dd7808b15ad5e0526d0350 Mon Sep 17 00:00:00 2001 From: "Fabian @ Blax Software" Date: Wed, 17 Dec 2025 10:41:52 +0100 Subject: [PATCH] BF cart item pool calculation --- src/Models/Cart.php | 47 ++++- src/Models/Product.php | 9 +- src/Traits/MayBePoolProduct.php | 175 +++++++++++++++++- .../Feature/CartAddToCartPoolPricingTest.php | 95 +++++++++- 4 files changed, 300 insertions(+), 26 deletions(-) diff --git a/src/Models/Cart.php b/src/Models/Cart.php index ca14b06..730681b 100644 --- a/src/Models/Cart.php +++ b/src/Models/Cart.php @@ -344,10 +344,10 @@ class Cart extends Model // For pool products, check pricing strategy to determine merge behavior $priceMatch = true; if ($cartable instanceof Product && $cartable->isPool()) { - // For pools, always check if prices match to allow merging items with same price - $currentPrice = $cartable->getNextAvailablePoolPrice($currentQuantityInCart, null, $from, $until); + // For pools, use smart pricing that considers which tiers are used + $currentPrice = $cartable->getNextAvailablePoolPriceConsideringCart($this, null, $from, $until); if (!$currentPrice) { - // Fallback to getCurrentPrice if getNextAvailablePoolPrice returns null (no single items) + // Fallback to getCurrentPrice if method returns null $currentPrice = $cartable->getCurrentPrice(); } if ($from && $until) { @@ -365,10 +365,9 @@ class Cart extends Model // Calculate price per day (base price) // For pool products, get price based on how many items are already in cart if ($cartable instanceof Product && $cartable->isPool()) { - // Use the quantity we calculated earlier for consistency - // Get price for the next available item - $pricePerDay = $cartable->getNextAvailablePoolPrice($currentQuantityInCart, null, $from, $until); - $regularPricePerDay = $cartable->getNextAvailablePoolPrice($currentQuantityInCart, false, $from, $until) ?? $pricePerDay; + // Use smarter pricing that considers which price tiers are used + $pricePerDay = $cartable->getNextAvailablePoolPriceConsideringCart($this, null, $from, $until); + $regularPricePerDay = $cartable->getNextAvailablePoolPriceConsideringCart($this, false, $from, $until) ?? $pricePerDay; // If no price found from pool items, try the pool's direct price as fallback if ($pricePerDay === null && $cartable->hasPrice()) { @@ -439,11 +438,31 @@ class Cart extends Model int $quantity = 1, array $parameters = [] ): CartItem|true { - $item = $this->items() + // If a CartItem is passed directly, handle it + if ($cartable instanceof CartItem) { + $item = $cartable; + + if ($item->quantity > $quantity) { + // Decrease quantity + $newQuantity = $item->quantity - $quantity; + $item->update([ + 'quantity' => $newQuantity, + 'subtotal' => $item->price * $newQuantity, + ]); + } else { + // Remove item from cart + $item->delete(); + } + + return $item; + } + + // Otherwise, find the cart item by purchasable + $items = $this->items() ->where('purchasable_id', $cartable->getKey()) ->where('purchasable_type', get_class($cartable)) ->get() - ->first(function ($item) use ($parameters) { + ->filter(function ($item) use ($parameters) { $existingParams = is_array($item->parameters) ? $item->parameters : (array) $item->parameters; @@ -452,13 +471,21 @@ class Cart extends Model return $existingParams === $parameters; }); + if ($items->isEmpty()) { + return true; + } + + // For pool products with multiple cart items at different prices, + // remove from the highest-priced item first (LIFO behavior) + $item = $items->sortByDesc('price')->first(); + if ($item) { if ($item->quantity > $quantity) { // Decrease quantity $newQuantity = $item->quantity - $quantity; $item->update([ 'quantity' => $newQuantity, - 'subtotal' => ($cartable->getCurrentPrice()) * $newQuantity, + 'subtotal' => $item->price * $newQuantity, ]); } else { // Remove item from cart diff --git a/src/Models/Product.php b/src/Models/Product.php index 97ed275..e12c112 100644 --- a/src/Models/Product.php +++ b/src/Models/Product.php @@ -394,13 +394,8 @@ class Product extends Model implements Purchasable, Cartable } if ($cart) { - // Cart-aware: Get price for next available item after what's in cart - $currentQuantityInCart = $cart->items() - ->where('purchasable_id', $this->getKey()) - ->where('purchasable_type', get_class($this)) - ->sum('quantity'); - - return $this->getNextAvailablePoolPrice($currentQuantityInCart, $sales_price); + // Cart-aware: Use smarter pricing that considers which price tiers are used + return $this->getNextAvailablePoolPriceConsideringCart($cart, $sales_price); } // No cart and no user: Get inherited price based on strategy (lowest/highest/average of ALL available items) diff --git a/src/Traits/MayBePoolProduct.php b/src/Traits/MayBePoolProduct.php index 2afcdb8..00ebb41 100644 --- a/src/Traits/MayBePoolProduct.php +++ b/src/Traits/MayBePoolProduct.php @@ -540,7 +540,7 @@ trait MayBePoolProduct }); }) ->sum('quantity'); - + $available = max(0, $item->getAvailableStock() - abs($overlappingClaims)); } } elseif (!$item->isBooking()) { @@ -610,6 +610,179 @@ trait MayBePoolProduct return null; } + /** + * 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; + } + /** * Attach single items to this pool product * Also creates reverse POOL relation from single items back to this pool diff --git a/tests/Feature/CartAddToCartPoolPricingTest.php b/tests/Feature/CartAddToCartPoolPricingTest.php index cd931ec..72b47aa 100644 --- a/tests/Feature/CartAddToCartPoolPricingTest.php +++ b/tests/Feature/CartAddToCartPoolPricingTest.php @@ -1174,6 +1174,9 @@ class CartAddToCartPoolPricingTest extends TestCase $spot3->id ]); + $from = now()->addWeek(); + $until = now()->addWeek()->addDays(5); // 5 days + // Pool should have unlimited availability $this->assertEquals(6, $pool->getAvailableQuantity()); @@ -1192,27 +1195,32 @@ class CartAddToCartPoolPricingTest extends TestCase $pool, 3, [], - now()->addWeek(), - now()->addWeek()->addDays(5) + $from, + $until ); $this->assertEquals( (2000 * 2 * 5) + (5000 * 1 * 5), $cart->getTotal() ); + $this->assertEquals( + 5000, + $pool->getCurrentPrice() + ); $cart->addToCart( $pool, 3, [], - now()->addWeek(), - now()->addWeek()->addDays(5) + $from, + $until ); $this->assertEquals( (2000 * 2 * 5) + (5000 * 2 * 5) + (8000 * 2 * 5), $cart->getTotal() ); + $this->assertNull($pool->getCurrentPrice()); $this->assertEquals(3, $cart->items()->count()); @@ -1235,8 +1243,8 @@ class CartAddToCartPoolPricingTest extends TestCase $pool, 6, [], - now()->addWeek(), - now()->addWeek()->addDays(5) + $from, + $until ), \Blax\Shop\Exceptions\NotEnoughStockException::class ); @@ -1245,8 +1253,79 @@ class CartAddToCartPoolPricingTest extends TestCase $pool, 5, [], - now()->addWeek(), - now()->addWeek()->addDays(5) + $from, + $until + ); + + $this->assertEquals( + (2000 * 2 * 5) + (5000 * 1 * 5) + (8000 * 2 * 5), + $cart->getTotal() + ); + + $cart->removeFromCart($pool, 1); + + $this->assertEquals( + (2000 * 2 * 5) + (5000 * 1 * 5) + (8000 * 1 * 5), + $cart->getTotal() + ); + $this->assertEquals(8000, $pool->getCurrentPrice()); + + $cart->removeFromCart($pool, 1); + + $this->assertEquals( + (2000 * 2 * 5) + (5000 * 1 * 5), + $cart->getTotal() + ); + + // Get cart item with price 2000 + $cartItem = $cart->items() + ->orderBy('price', 'asc') + ->first(); + + $cart->removeFromCart($cartItem, 1); + + $this->assertEquals( + (2000 * 1 * 5) + (5000 * 1 * 5), + $cart->getTotal() + ); + $this->assertEquals( + 2000, + $pool->getCurrentPrice() + ); + + $cart->addToCart( + $pool, + 1, + [], + $from, + $until + ); + + $this->assertEquals( + (2000 * 2 * 5) + (5000 * 1 * 5), + $cart->getTotal() + ); + + // Get cart item with price 2000 + $cartItem = $cart->items() + ->orderBy('price', 'asc') + ->first(); + + $cart->removeFromCart($cartItem, 2); + + $this->assertEquals( + (5000 * 1 * 5), + $cart->getTotal() + ); + + $this->assertEquals(2000, $pool->getCurrentPrice()); + + $cart->addToCart( + $pool, + 4, + [], + $from, + $until ); $this->assertEquals(