diff --git a/src/Models/Cart.php b/src/Models/Cart.php index 8eb51d4..d83ed91 100644 --- a/src/Models/Cart.php +++ b/src/Models/Cart.php @@ -218,7 +218,8 @@ class Cart extends Model public function setDates( \DateTimeInterface|string|int|float $from, \DateTimeInterface|string|int|float $until, - bool $validateAvailability = true + bool $validateAvailability = true, + bool $overwrite_item_dates = true ): self { // Parse string dates using Carbon if (is_string($from) || is_numeric($from)) { @@ -243,7 +244,10 @@ class Cart extends Model ]); // Update cart items with from/until - $this->applyDatesToItems($validateAvailability); + $this->applyDatesToItems( + $validateAvailability, + $overwrite_item_dates + ); return $this->fresh(); } @@ -587,12 +591,22 @@ class Cart extends Model // For pool products, calculate current quantity in cart once to ensure consistency // Force fresh query to get latest cart state (important for recursive calls) $currentQuantityInCart = null; + $poolSingleItem = null; + $poolPriceId = null; + if ($cartable instanceof Product && $cartable->isPool()) { $this->unsetRelation('items'); // Clear cached relationship $currentQuantityInCart = $this->items() ->where('purchasable_id', $cartable->getKey()) ->where('purchasable_type', get_class($cartable)) ->sum('quantity'); + + // Pre-calculate pool pricing info for use in merge logic + $poolItemData = $cartable->getNextAvailablePoolItemWithPrice($this, null, $from, $until); + if ($poolItemData) { + $poolSingleItem = $poolItemData['item']; + $poolPriceId = $poolItemData['price_id']; + } } // Check if item already exists in cart with same parameters, dates, AND price @@ -600,7 +614,7 @@ class Cart extends Model ->where('purchasable_id', $cartable->getKey()) ->where('purchasable_type', get_class($cartable)) ->get() - ->first(function ($item) use ($parameters, $from, $until, $cartable, $currentQuantityInCart) { + ->first(function ($item) use ($parameters, $from, $until, $cartable, $poolPriceId) { $existingParams = is_array($item->parameters) ? $item->parameters : (array) $item->parameters; @@ -621,22 +635,19 @@ class Cart extends Model ); } - // For pool products, check pricing strategy to determine merge behavior + // For pool products, check if price_id matches to allow proper merging + // Pool items with the same price_id (from the same single item) can merge + // but items from different single items (different price_id) should NOT merge + // Also check that the actual price matches (important for AVERAGE strategy where price can change) $priceMatch = true; if ($cartable instanceof Product && $cartable->isPool()) { - // For pools, use smart pricing that considers which tiers are used - $currentPrice = $cartable->getNextAvailablePoolPriceConsideringCart($this, null, $from, $until); - if (!$currentPrice) { - // Fallback to getCurrentPrice if method returns null - $currentPrice = $cartable->getCurrentPrice(); - } - if ($from && $until) { - $days = $this->calculateBookingDays($from, $until); - $currentPrice *= $days; - } - - // Compare prices - merge if prices match - $priceMatch = abs((float)$item->price - $currentPrice) < 0.01; + // Calculate expected price for this item + $poolItemData = $cartable->getNextAvailablePoolItemWithPrice($this, null, $from, $until); + $expectedPrice = $poolItemData['price'] ?? null; + + // Only merge if price_id matches AND the price amount matches + $priceMatch = $poolPriceId && $item->price_id === $poolPriceId && + $expectedPrice !== null && $item->unit_amount === (int) round($expectedPrice); } return $paramsMatch && $datesMatch && $priceMatch; @@ -644,8 +655,6 @@ class Cart extends Model // Calculate price per day (base price) // For pool products, get price based on how many items are already in cart - $poolSingleItem = null; - $poolPriceId = null; if ($cartable instanceof Product && $cartable->isPool()) { // Use smarter pricing that considers which price tiers are used $poolItemData = $cartable->getNextAvailablePoolItemWithPrice($this, null, $from, $until); diff --git a/src/Models/CartItem.php b/src/Models/CartItem.php index caa6a64..2aa47fa 100644 --- a/src/Models/CartItem.php +++ b/src/Models/CartItem.php @@ -435,8 +435,9 @@ class CartItem extends Model // Get current price per day // Pass dates to ensure accurate pricing for pool products during date updates - $pricePerDay = $product->getCurrentPrice(null, $this->cart, $from, $until); - $regularPricePerDay = $product->getCurrentPrice(false, $this->cart, $from, $until) ?? $pricePerDay; + // Pass cart item ID to exclude this item from usage calculation + $pricePerDay = $product->getCurrentPrice(null, $this->cart, $from, $until, $this->id); + $regularPricePerDay = $product->getCurrentPrice(false, $this->cart, $from, $until, $this->id) ?? $pricePerDay; // Store the base unit_amount (price for 1 quantity, 1 day) in cents $unitAmount = (int) round($pricePerDay); diff --git a/src/Models/Product.php b/src/Models/Product.php index 66da406..2176cbf 100644 --- a/src/Models/Product.php +++ b/src/Models/Product.php @@ -401,13 +401,15 @@ class Product extends Model implements Purchasable, Cartable * @param mixed $cart Optional cart instance (auto-resolved from session/user if not provided) * @param \DateTimeInterface|null $from Optional start date for booking calculations * @param \DateTimeInterface|null $until Optional end date for booking calculations + * @param string|int|null $excludeCartItemId Cart item ID to exclude from usage calculation (for date updates) * @return float|null The current price, or null if unavailable */ public function getCurrentPrice( bool|null $sales_price = null, mixed $cart = null, ?\DateTimeInterface $from = null, - ?\DateTimeInterface $until = null + ?\DateTimeInterface $until = null, + string|int|null $excludeCartItemId = null ): ?float { // If this is a pool product, use cart-aware pricing if cart is provided if ($this->isPool()) { @@ -432,7 +434,7 @@ class Product extends Model implements Purchasable, Cartable if ($cart) { // Cart-aware: Use smarter pricing that considers which price tiers are used // This returns null if no items are available (all sold out) - return $this->getNextAvailablePoolPriceConsideringCart($cart, $sales_price, $from, $until); + return $this->getNextAvailablePoolPriceConsideringCart($cart, $sales_price, $from, $until, $excludeCartItemId); } // No cart: Get inherited price from single items diff --git a/src/Traits/MayBePoolProduct.php b/src/Traits/MayBePoolProduct.php index aaba45c..cd054a6 100644 --- a/src/Traits/MayBePoolProduct.php +++ b/src/Traits/MayBePoolProduct.php @@ -139,6 +139,7 @@ trait MayBePoolProduct $strategy = $this->getPricingStrategy(); // Build list of available single items with their prices + // IMPORTANT: Collect ALL available items first, then sort, to ensure correct pricing strategy order $availableItems = []; foreach ($singleItems as $item) { if ($item->isAvailableForBooking($from, $until, 1)) { @@ -155,11 +156,6 @@ trait MayBePoolProduct 'price' => $price ?? PHP_FLOAT_MAX, // Items without prices go last ]; } - - // Early exit if we have enough - if (count($availableItems) >= $quantity) { - break; - } } if (count($availableItems) < $quantity) { @@ -684,13 +680,15 @@ trait MayBePoolProduct * @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 + * @param string|int|null $excludeCartItemId Cart item ID to exclude from usage calculation (for date updates) * @return array|null ['price' => float, 'item' => Product, 'price_id' => string|null] */ public function getNextAvailablePoolItemWithPrice( \Blax\Shop\Models\Cart $cart, bool|null $sales_price = null, ?\DateTimeInterface $from = null, - ?\DateTimeInterface $until = null + ?\DateTimeInterface $until = null, + string|int|null $excludeCartItemId = null ): ?array { if (!$this->isPool()) { return null; @@ -724,12 +722,36 @@ trait MayBePoolProduct $days = $this->calculateBookingDays($from, $until); } - // Build usage map: price => quantity used - $priceUsage = []; + // Build usage map: track which single items have been allocated + // Use allocated_single_item_id from meta to track actual single item usage + // ONLY count items that overlap with the current booking period + // Exclude the specified cart item (if updating dates on existing item) + $singleItemUsage = []; // item_id => quantity used 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; + // Skip the cart item being updated (if applicable) + if ($excludeCartItemId && $item->id === $excludeCartItemId) { + continue; + } + + // Only count this cart item if it overlaps with the current booking period + $overlaps = true; + if ($from && $until && $item->from && $item->until) { + // Check if the cart item's booking period overlaps with the current period + // No overlap if: cart item ends before current starts, or cart item starts after current ends + $overlaps = !( + $item->until < $from || // Cart item ends before current booking starts + $item->from > $until // Cart item starts after current booking ends + ); + } + + if ($overlaps) { + $meta = $item->getMeta(); + $allocatedItemId = $meta->allocated_single_item_id ?? null; + + if ($allocatedItemId) { + $singleItemUsage[$allocatedItemId] = ($singleItemUsage[$allocatedItemId] ?? 0) + $item->quantity; + } + } } // Build available items list @@ -749,16 +771,16 @@ trait MayBePoolProduct } if ($price !== null) { - $priceRounded = round($price, 2); + // Subtract quantity already allocated from THIS specific single item + $usedFromThisItem = $singleItemUsage[$item->id] ?? 0; + $availableFromThisItem = $available === PHP_INT_MAX + ? PHP_INT_MAX + : max(0, $available - $usedFromThisItem); - // Subtract quantity already used in cart at this price - $usedAtThisPrice = $priceUsage[$priceRounded] ?? 0; - $availableAtThisPrice = $available - $usedAtThisPrice; - - if ($availableAtThisPrice > 0) { + if ($availableFromThisItem > 0) { $availableItems[] = [ 'price' => $price, - 'quantity' => $availableAtThisPrice, + 'quantity' => $availableFromThisItem, 'item' => $item, 'price_id' => $priceModel?->id, ]; @@ -848,9 +870,10 @@ trait MayBePoolProduct \Blax\Shop\Models\Cart $cart, bool|null $sales_price = null, ?\DateTimeInterface $from = null, - ?\DateTimeInterface $until = null + ?\DateTimeInterface $until = null, + string|int|null $excludeCartItemId = null ): ?float { - $result = $this->getNextAvailablePoolItemWithPrice($cart, $sales_price, $from, $until); + $result = $this->getNextAvailablePoolItemWithPrice($cart, $sales_price, $from, $until, $excludeCartItemId); return $result['price'] ?? null; } @@ -945,6 +968,14 @@ trait MayBePoolProduct throw InvalidPoolConfigurationException::notAPoolProduct($this->name); } + // Critical: Pool products should NEVER manage stock themselves + // Stock is managed by individual single items only + if ($this->manage_stock) { + throw new InvalidPoolConfigurationException( + "Pool product '{$this->name}' has manage_stock=true. Pool products should never manage stock directly - only their single items manage stock." + ); + } + $singleItems = $this->singleProducts; // Critical: No single items @@ -962,16 +993,14 @@ trait MayBePoolProduct } } - // 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); - } + // Note: Single items may or may not manage stock + // Items without stock management are treated as having unlimited availability + // This is acceptable - the pool just checks availability from each single item - // Check for items with zero stock - $itemsWithZeroStock = $singleItems->filter(fn($item) => $item->getAvailableStock() <= 0); + // Check for items with zero stock (only for items that manage stock) + $itemsWithZeroStock = $singleItems + ->filter(fn($item) => $item->manage_stock) // Only check items that manage stock + ->filter(fn($item) => $item->getAvailableStock() <= 0); if ($itemsWithZeroStock->isNotEmpty()) { $itemNames = $itemsWithZeroStock->pluck('name')->toArray(); $warnings[] = "Single items with zero stock: " . implode(', ', $itemNames); diff --git a/tests/Feature/BookingTimespanValidationTest.php b/tests/Feature/BookingTimespanValidationTest.php index e2f5792..a4b9503 100644 --- a/tests/Feature/BookingTimespanValidationTest.php +++ b/tests/Feature/BookingTimespanValidationTest.php @@ -120,6 +120,7 @@ class BookingTimespanValidationTest extends TestCase $poolProduct = Product::factory()->create([ 'name' => 'Kayak Fleet', 'type' => ProductType::POOL, + 'manage_stock' => false, // Pool products never manage stock themselves ]); $singleItem = Product::factory()->create([ @@ -174,6 +175,7 @@ class BookingTimespanValidationTest extends TestCase // Create a pool product with 2 single items so both bookings can succeed $poolProduct = Product::factory()->create([ 'type' => ProductType::POOL, + 'manage_stock' => false, ]); ProductPrice::factory()->create([ @@ -392,6 +394,7 @@ class BookingTimespanValidationTest extends TestCase // Create pool with 2 single items $poolProduct = Product::factory()->create([ 'type' => ProductType::POOL, + 'manage_stock' => false, ]); $singleItem1 = Product::factory()->create([ diff --git a/tests/Feature/CartDateManagementTest.php b/tests/Feature/CartDateManagementTest.php index def6fba..278507f 100644 --- a/tests/Feature/CartDateManagementTest.php +++ b/tests/Feature/CartDateManagementTest.php @@ -257,7 +257,7 @@ class CartDateManagementTest extends TestCase $cartFromDate = Carbon::now()->addDays(1); $cartUntilDate = Carbon::now()->addDays(3); - $cart->setDates($cartFromDate, $cartUntilDate, validateAvailability: false); + $cart->setDates($cartFromDate, $cartUntilDate, validateAvailability: false, overwrite_item_dates: false); $cart->applyDatesToItems(validateAvailability: false, overwrite: false); $item->refresh(); @@ -328,7 +328,7 @@ class CartDateManagementTest extends TestCase $cartFromDate = Carbon::now()->addDays(1); $cartUntilDate = Carbon::now()->addDays(3); - $cart->setDates($cartFromDate, $cartUntilDate, validateAvailability: false); + $cart->setDates($cartFromDate, $cartUntilDate, validateAvailability: false, overwrite_item_dates: false); $cart->applyDatesToItems(validateAvailability: false, overwrite: false); $item->refresh(); @@ -364,7 +364,7 @@ class CartDateManagementTest extends TestCase $cartFromDate = Carbon::now()->addDays(5); $cartUntilDate = Carbon::now()->addDays(7); - $cart->setDates($cartFromDate, $cartUntilDate, validateAvailability: false); + $cart->setDates($cartFromDate, $cartUntilDate, validateAvailability: false, overwrite_item_dates: false); $cart->applyDatesToItems(validateAvailability: false, overwrite: false); $item->refresh(); diff --git a/tests/Feature/PoolPerMinutePricingTest.php b/tests/Feature/PoolPerMinutePricingTest.php index 3ddfb02..9ac3233 100644 --- a/tests/Feature/PoolPerMinutePricingTest.php +++ b/tests/Feature/PoolPerMinutePricingTest.php @@ -220,11 +220,11 @@ class PoolPerMinutePricingTest extends TestCase // First booking uses lowest pricing: 3000 cents * 0.25 = 750 cents ($7.50) $this->assertEquals(750, $cartItem1->price); - // Second booking may use next available pricing tier - $this->assertGreaterThanOrEqual(750, (int)$cartItem2->price); + // Second booking is on a different day (non-overlapping) so also uses lowest pricing + $this->assertEquals(750, (int)$cartItem2->price); - // Total should be reasonable for two 6-hour bookings - $this->assertGreaterThan(1500, $cart->getTotal()); + // Total should be 750 + 750 = 1500 for two 6-hour bookings on different days + $this->assertEquals(1500, $cart->getTotal()); } /** @test */ diff --git a/tests/Feature/PoolProductRelationsTest.php b/tests/Feature/PoolProductRelationsTest.php index 04d5eb9..e1f4977 100644 --- a/tests/Feature/PoolProductRelationsTest.php +++ b/tests/Feature/PoolProductRelationsTest.php @@ -145,7 +145,10 @@ class PoolProductRelationsTest extends TestCase public function legacy_manual_attach_still_works() { // Test that old way of attaching still works (without reverse relation) - $pool = Product::factory()->create(['type' => ProductType::POOL]); + $pool = Product::factory()->create([ + 'type' => ProductType::POOL, + 'manage_stock' => false, + ]); $spot = Product::factory()->create(['type' => ProductType::BOOKING]); // Old way using productRelations()->attach() directly diff --git a/tests/Feature/PoolSeparateCartItemsTest.php b/tests/Feature/PoolSeparateCartItemsTest.php index 63b433c..f322eb6 100644 --- a/tests/Feature/PoolSeparateCartItemsTest.php +++ b/tests/Feature/PoolSeparateCartItemsTest.php @@ -123,6 +123,7 @@ class PoolSeparateCartItemsTest extends TestCase $item2 = $this->cart->addToCart($this->pool, 1, [], $from, $until); // Should create separate cart items because price is different + // (Note: With AVERAGE strategy, price_id may be the same but price differs) $this->assertNotEquals($item1->id, $item2->id); $this->assertEquals(2, $this->cart->items()->count()); $this->assertNotEquals($price1, $item2->price);