From 816e8661e24ef7459ed8104ec4906c576da47a2a Mon Sep 17 00:00:00 2001 From: "Fabian @ Blax Software" Date: Sat, 20 Dec 2025 15:08:08 +0100 Subject: [PATCH] BF cart items --- .github/workflows/tests.yml | 2 +- src/Models/Cart.php | 198 ++++++--- tests/Feature/CartDateManagementTest.php | 19 +- tests/Feature/PoolProductionBugTest.php | 534 ++++++++++++++++++++++- 4 files changed, 693 insertions(+), 60 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 01f6928..5a137c0 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -2,7 +2,7 @@ name: Tests on: push: - branches: [ master ] + branches: [ testbranch ] pull_request: branches: [ master, dev ] diff --git a/src/Models/Cart.php b/src/Models/Cart.php index f5f449e..cccb9f9 100644 --- a/src/Models/Cart.php +++ b/src/Models/Cart.php @@ -316,6 +316,14 @@ class Cart extends Model if ($validateAvailability) { $this->validateDateAvailability($calcFrom, $calcUntil); } + + // Update cart items with new dates and recalculate prices + $this->applyDatesToItems( + $validateAvailability, + true, + $calcFrom, + $calcUntil + ); } return $this->fresh(); @@ -353,6 +361,14 @@ class Cart extends Model if ($validateAvailability) { $this->validateDateAvailability($calcFrom, $calcUntil); } + + // Update cart items with new dates and recalculate prices + $this->applyDatesToItems( + $validateAvailability, + true, + $calcFrom, + $calcUntil + ); } return $this->fresh(); @@ -502,34 +518,14 @@ class Cart extends Model continue; } - // Build list of available items with prices for new dates - $availableWithPrices = []; + // Build list of available singles with their prices for new dates + $singlesWithPrices = []; foreach ($singleItems as $single) { - // Manually check if this single is available for the booking period - $available = $single->getAvailableStock($from); + // Get available stock at the booking start date + // This already accounts for claims via the DECREASE entries they create + $effectiveAvailable = $single->getAvailableStock($from); - // Check for overlapping claims - two periods overlap if: - // claim.start < our.end AND claim.end > our.start - $overlaps = $single->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) { - // Claim starts before our period ends - $q->where(function ($subQ) use ($until) { - $subQ->where('claimed_from', '<', $until) - ->orWhereNull('claimed_from'); // No start = starts immediately - }) - // AND claim ends after our period starts - ->where(function ($subQ) use ($from) { - $subQ->where('expires_at', '>', $from) - ->orWhereNull('expires_at'); // No end = never expires - }); - }); - }) - ->exists(); - - if ($available > 0 && !$overlaps) { + if ($effectiveAvailable > 0) { $priceModel = $single->defaultPrice()->first(); $price = $priceModel?->getCurrentPrice($single->isOnSale()); @@ -540,16 +536,17 @@ class Cart extends Model } if ($price !== null) { - $availableWithPrices[] = [ + $singlesWithPrices[] = [ 'single' => $single, 'price' => $price, 'price_id' => $priceModel?->id, + 'available' => $effectiveAvailable, ]; } } } - if (empty($availableWithPrices)) { + if (empty($singlesWithPrices)) { // No singles available for this period - mark ALL pool items as unavailable foreach ($items as $cartItem) { // Only update if we should overwrite or item has no dates yet @@ -570,7 +567,7 @@ class Cart extends Model } // Sort by pricing strategy - usort($availableWithPrices, function ($a, $b) use ($strategy) { + usort($singlesWithPrices, 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'], @@ -579,45 +576,133 @@ class Cart extends Model }); // Reallocate cart items to optimal singles - // Each cart item gets one single - no single can be allocated twice - $usedIndices = []; + // Track usage per single to properly allocate considering quantities + // If a single can't accommodate a cart item's full quantity, split the cart item + $singleUsage = []; // single_id => quantity used + + // Use singlesWithPrices directly as our ordered list + $orderedSingles = $singlesWithPrices; + foreach ($items as $cartItem) { // Only reallocate if we should overwrite or item has no dates yet if (!$overwrite && $cartItem->from && $cartItem->until) { continue; } - // Find next unused single from available list + $neededQty = $cartItem->quantity; $allocated = false; - for ($i = 0; $i < count($availableWithPrices); $i++) { - if (!in_array($i, $usedIndices)) { - $allocation = $availableWithPrices[$i]; - // Update cart item with new allocation - $cartItem->updateMetaKey('allocated_single_item_id', $allocation['single']->id); - $cartItem->updateMetaKey('allocated_single_item_name', $allocation['single']->name); + // Try to find a single that can accommodate the full quantity + foreach ($orderedSingles as $singleInfo) { + $single = $singleInfo['single']; + $usedFromSingle = $singleUsage[$single->id] ?? 0; + $remainingFromSingle = $singleInfo['available'] - $usedFromSingle; + + if ($remainingFromSingle >= $neededQty) { + // This single can accommodate the cart item's full quantity + $cartItem->updateMetaKey('allocated_single_item_id', $single->id); + $cartItem->updateMetaKey('allocated_single_item_name', $single->name); // Update price_id if changed - if ($allocation['price_id'] && $allocation['price_id'] !== $cartItem->price_id) { - $cartItem->update(['price_id' => $allocation['price_id']]); + if ($singleInfo['price_id'] && $singleInfo['price_id'] !== $cartItem->price_id) { + $cartItem->update(['price_id' => $singleInfo['price_id']]); } - $usedIndices[] = $i; + // Track usage + $singleUsage[$single->id] = $usedFromSingle + $neededQty; $allocated = true; break; } } - // If we couldn't allocate (ran out of available singles), mark as unavailable if (!$allocated) { - // Clear allocation and set price to null to indicate unavailable - $cartItem->updateMetaKey('allocated_single_item_id', null); - $cartItem->updateMetaKey('allocated_single_item_name', null); - $cartItem->update([ - 'price' => null, - 'subtotal' => null, - 'unit_amount' => null, - ]); + // No single can accommodate the full quantity + // Try to split: use as much as possible from the first available single, + // then create new cart items for the rest + $remainingQty = $neededQty; + $firstAllocation = true; + + foreach ($orderedSingles as $singleInfo) { + if ($remainingQty <= 0) break; + + $single = $singleInfo['single']; + $usedFromSingle = $singleUsage[$single->id] ?? 0; + $availableFromSingle = $singleInfo['available'] - $usedFromSingle; + + if ($availableFromSingle <= 0) continue; + + $qtyToAllocate = min($remainingQty, $availableFromSingle); + + if ($firstAllocation) { + // Update the original cart item with reduced quantity + // Also update subtotal to match the new quantity + $newSubtotal = $cartItem->price * $qtyToAllocate; + $cartItem->update([ + 'quantity' => $qtyToAllocate, + 'subtotal' => $newSubtotal, + ]); + $cartItem->refresh(); // Ensure model reflects database state + $cartItem->updateMetaKey('allocated_single_item_id', $single->id); + $cartItem->updateMetaKey('allocated_single_item_name', $single->name); + + if ($singleInfo['price_id'] && $singleInfo['price_id'] !== $cartItem->price_id) { + $cartItem->update(['price_id' => $singleInfo['price_id']]); + } + + $firstAllocation = false; + } else { + // Create a new cart item for the additional quantity + // Get price from the single + $priceModel = $single->defaultPrice()->first(); + $singlePrice = $priceModel?->getCurrentPrice($single->isOnSale()); + + if ($singlePrice === null && $poolProduct->hasPrice()) { + $priceModel = $poolProduct->defaultPrice()->first(); + $singlePrice = $priceModel?->getCurrentPrice($poolProduct->isOnSale()); + } + + $days = $this->calculateBookingDays($from, $until); + $pricePerUnit = (int) round($singlePrice * $days); + + $newCartItem = $this->items()->create([ + 'purchasable_id' => $cartItem->purchasable_id, + 'purchasable_type' => $cartItem->purchasable_type, + 'price_id' => $priceModel?->id, + 'quantity' => $qtyToAllocate, + 'price' => $pricePerUnit, + 'regular_price' => $pricePerUnit, + 'unit_amount' => (int) round($singlePrice), + 'subtotal' => $pricePerUnit * $qtyToAllocate, + 'parameters' => $cartItem->parameters, + 'from' => $from, + 'until' => $until, + ]); + + $newCartItem->updateMetaKey('allocated_single_item_id', $single->id); + $newCartItem->updateMetaKey('allocated_single_item_name', $single->name); + } + + $singleUsage[$single->id] = $usedFromSingle + $qtyToAllocate; + $remainingQty -= $qtyToAllocate; + $allocated = true; + } + + // If we still have remaining quantity that couldn't be allocated + if ($remainingQty > 0) { + if ($firstAllocation) { + // Couldn't allocate anything - mark as unavailable + $cartItem->updateMetaKey('allocated_single_item_id', null); + $cartItem->updateMetaKey('allocated_single_item_name', null); + $cartItem->update([ + 'price' => null, + 'subtotal' => null, + 'unit_amount' => null, + ]); + } else { + // Partial allocation - the cart item was already updated with what we could allocate + // The remaining quantity is lost (over-capacity) + } + } } } } @@ -780,6 +865,14 @@ class Cart extends Model $until = is_string($parameters['until']) ? Carbon::parse($parameters['until']) : $parameters['until']; } + // Fallback to cart dates if no dates provided + if (!$from && $this->from) { + $from = $this->from; + } + if (!$until && $this->until) { + $until = $this->until; + } + // For pool products with quantity > 1, add them one at a time to get progressive pricing if ($cartable instanceof Product && $cartable->isPool() && $quantity > 1) { // Validate availability if dates are provided @@ -860,11 +953,16 @@ class Cart extends Model $maxQuantity = $cartable->getPoolMaxQuantity($from, $until); // Subtract items already in cart for the same period + // Only count items that are actually valid (have a price allocated) $itemsInCart = $this->items() ->where('purchasable_id', $cartable->getKey()) ->where('purchasable_type', get_class($cartable)) ->get() ->filter(function ($item) use ($from, $until) { + // Don't count items marked as unavailable (null price) + if ($item->price === null) { + return false; + } // Only count items with overlapping dates if (!$item->from || !$item->until) { return false; diff --git a/tests/Feature/CartDateManagementTest.php b/tests/Feature/CartDateManagementTest.php index fe60215..d605de9 100644 --- a/tests/Feature/CartDateManagementTest.php +++ b/tests/Feature/CartDateManagementTest.php @@ -516,16 +516,21 @@ class CartDateManagementTest extends TestCase ]); - $cart = Cart::factory()->create([ - 'from' => Carbon::now()->addDays(1), - 'until' => Carbon::now()->addDays(3), - ]); + // Create cart WITHOUT dates first (so addToCart doesn't validate) + $cart = Cart::factory()->create(); - // Add item that would exceed available stock + // Add item that would exceed available stock (qty=2 but stock=1) + // This succeeds because cart has no dates yet, so no availability validation $item = $cart->addToCart($product, 2); - // Should NOT throw exception, instead mark items as unavailable - $cart->applyDatesToItems(validateAvailability: true); + // Now set dates on the cart with validation enabled + // This triggers applyDatesToItems which should mark items as unavailable + // rather than throwing an exception + $cart->setDates( + Carbon::now()->addDays(1), + Carbon::now()->addDays(3), + validateAvailability: true + ); // Item should be marked as unavailable (null price) $item->refresh(); diff --git a/tests/Feature/PoolProductionBugTest.php b/tests/Feature/PoolProductionBugTest.php index abf6aee..9711a27 100644 --- a/tests/Feature/PoolProductionBugTest.php +++ b/tests/Feature/PoolProductionBugTest.php @@ -3,6 +3,7 @@ namespace Blax\Shop\Tests\Feature; use Blax\Shop\Enums\ProductType; +use Blax\Shop\Enums\StockType; use Blax\Shop\Models\Cart; use Blax\Shop\Models\Product; use Blax\Shop\Models\ProductPrice; @@ -192,7 +193,7 @@ class PoolProductionBugTest extends TestCase // Exception should only be thrown when DATES are provided and there isn't enough stock $from = now()->addDays(10); $until = now()->addDays(12); - + // Adding 7 items with dates should throw exception since we only have 6 single items $this->expectException(\Blax\Shop\Exceptions\NotEnoughStockException::class); $this->cart->addToCart($this->pool, 7, [], $from, $until); @@ -431,7 +432,7 @@ class PoolProductionBugTest extends TestCase // Setting dates to a fully booked period should NOT throw, // but mark items as unavailable instead $secondCart->setDates($from1, $until1); - + // All items should be marked as unavailable $secondCart->refresh(); $secondCart->load('items'); @@ -564,4 +565,533 @@ class PoolProductionBugTest extends TestCase } } } + + public function test_date_adjustment_with_one_item() + { + $this->createProductionPool(); + + $cart = $this->createCart(); + + $cart->addToCart( + $this->pool, + 1 + ); + + $this->assertEquals(5000, $cart->getTotal()); + $this->assertFalse($cart->isReadyForCheckout()); + $this->assertFalse($cart->items()->first()->is_ready_to_checkout); + + $from = Carbon::tomorrow()->startOfDay(); + $until = Carbon::tomorrow()->addDay()->startOfDay(); + + $cart->setDates($from, $until); + + $this->assertEquals(5000, $cart->getTotal()); + $this->assertTrue($cart->isReadyForCheckout()); + $this->assertTrue($cart->items()->first()->is_ready_to_checkout); + + $until->subHours(5); + $cart->setUntilDate($until); + $this->assertLessThan(5000, $cart->getTotal()); + $this->assertTrue($cart->isReadyForCheckout()); + $this->assertTrue($cart->items()->first()->is_ready_to_checkout); + + $until->addHours(24); + $cart->setUntilDate($until); + $this->assertGreaterThan(5000, $cart->getTotal()); + $this->assertTrue($cart->isReadyForCheckout()); + $this->assertTrue($cart->items()->first()->is_ready_to_checkout); + } + + public function test_date_adjustment_with_one_item_day_adjustment() + { + // The hotel has parking plots (proxied with the pool) + $pool = Product::factory()->create([ + 'type' => ProductType::POOL, + 'manage_stock' => true, + ]); + + // In this hotel we have 3 cheap parking plots far from the entrance + $single_1 = Product::factory() + ->withStocks(3) + ->withPrices(1, 1000) + ->create([ + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + + // 1 medium priced parking plots closer to the entrance + $single_2 = Product::factory() + ->withStocks(1) + ->withPrices(1, 10001) + ->create([ + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + + // 1 premium parking plot right at the entrance + $single_3 = Product::factory() + ->withStocks(1) + ->withPrices(1, 10002) + ->create([ + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + + $pool->attachSingleItems([ + $single_1->id, + $single_2->id, + $single_3->id, + ]); + + $cart = $this->createCart(); + + // We check nothing is in the cart + $this->assertEquals(0, $cart->items()->count()); + + // We add the pool to the cart and expect the cheapest option to be added + $cart->addToCart( + $pool, + 1 + ); + + $this->assertEquals(1000, $cart->getTotal()); + $this->assertFalse($cart->isReadyForCheckout()); + $this->assertFalse($cart->items()->first()->is_ready_to_checkout); + + $from = Carbon::tomorrow()->startOfDay(); + + $cart->setFromDate($from); + + $until = Carbon::tomorrow()->addDay()->startOfDay(); + $cart->setUntilDate($until); + + $cart->refresh(); + + $this->assertEquals(24, $cart->from->diffInHours($cart->until)); + + $cart->setDates($cart->from, $cart->until); + + // As dates are now set, we expect the cart to be ready for checkout and it shows the correct total (unit_amount of price is for one day and we check for a full day) + $this->assertEquals(1000, $cart->getTotal()); + $this->assertTrue($cart->isReadyForCheckout()); + $this->assertTrue($cart->items()->first()->is_ready_to_checkout); + + $cart->setDates($cart->from->copy(), $cart->until->copy()->addHours(24)); + + $cart->refresh(); + $this->assertEquals(48, $cart->from->diffInHours($cart->until)); + + // We expect the amount to be doubled now, as 2 days are booked + $this->assertEquals(2000, $cart->getTotal()); + $this->assertTrue($cart->isReadyForCheckout()); + $this->assertTrue($cart->items()->first()->is_ready_to_checkout); + + $cart->addToCart( + $pool, + 1 + ); + + // We have the 2000 2 times now, as we book 2 days with quantity of 2 with unit amount of 1000 + $this->assertEquals(4000, $cart->getTotal()); + $this->assertTrue($cart->isReadyForCheckout()); + $this->assertTrue($cart->items()->first()->is_ready_to_checkout); + + $cart->addToCart( + $pool, + 1 + ); + + $this->assertEquals(6000, $cart->getTotal()); + $this->assertTrue($cart->isReadyForCheckout()); + $this->assertTrue($cart->items()->first()->is_ready_to_checkout); + + $cart->addToCart( + $pool, + 1 + ); + + $cart->refresh(); + + // We expect to have 2 days booked and quantity of 3 and as the cheapest option is out of stock now, + // the next one is taken (unit amount of 10001) + $this->assertEquals(48, $cart->from->diffInHours($cart->until)); + $this->assertEquals(6000 + (10001 * 2), $cart->getTotal()); + $this->assertTrue($cart->isReadyForCheckout()); + $this->assertTrue($cart->items()->first()->is_ready_to_checkout); + + $cart->addToCart( + $pool, + 1 + ); + + $this->assertEquals(6000 + (10001 * 2) + (10002 * 2), $cart->getTotal()); + + $cart->removeFromCart( + $pool, + 1 + ); + + $this->assertEquals(6000 + (10001 * 2), $cart->getTotal()); + + $single_1->adjustStock( + StockType::CLAIMED, + 1, + from: now()->subYear(), + until: now()->addYear(), + note: 'Booked' + ); + + // After claiming 1 stock from single_1, the capacity is reduced from 5 to 4. + // We currently have 4 items in cart (3 @ single_1, 1 @ single_2). + // When setDates is called, reallocation happens: + // - single_1 now only has 2 capacity (3-1 claim = 2) + // - 2 items can stay at single_1 + // - 1 item must move to single_3 (the only one with capacity) + // - 1 item stays at single_2 + // After reallocation: 2 @ single_1 (4000) + 1 @ single_2 (20002) + 1 @ single_3 (20004) = 44006 + + // Trigger reallocation by refreshing dates + $cart->setDates($cart->from, $cart->until); + $cart->refresh(); + + $this->assertEquals(4000 + (10001 * 2) + (10002 * 2), $cart->getTotal()); + + // Now try to add another item - this should fail because capacity is full (4 items, 4 capacity) + $this->expectException(\Blax\Shop\Exceptions\NotEnoughStockException::class); + $cart->addToCart( + $pool, + 1 + ); + } + + /** + * Test that single item allocation is properly tracked when adding multiple pool items. + * Each single item should only be used up to its stock limit. + */ + public function test_single_item_allocation_respects_stock_limits() + { + $pool = Product::factory()->create([ + 'type' => ProductType::POOL, + 'manage_stock' => true, + ]); + + // single_1: 3 stock @ 1000/day + $single_1 = Product::factory() + ->withStocks(3) + ->withPrices(1, 1000) + ->create([ + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + 'name' => 'Single1-Cheap', + ]); + + // single_2: 1 stock @ 10001/day + $single_2 = Product::factory() + ->withStocks(1) + ->withPrices(1, 10001) + ->create([ + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + 'name' => 'Single2-Medium', + ]); + + // single_3: 1 stock @ 10002/day + $single_3 = Product::factory() + ->withStocks(1) + ->withPrices(1, 10002) + ->create([ + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + 'name' => 'Single3-Premium', + ]); + + $pool->attachSingleItems([ + $single_1->id, + $single_2->id, + $single_3->id, + ]); + + $cart = $this->createCart(); + + $from = Carbon::tomorrow()->startOfDay(); + $until = Carbon::tomorrow()->addDays(2)->startOfDay(); // 2 days + $cart->setDates($from, $until); + + // Add 4 items one by one, tracking each addition + $items = []; + for ($i = 1; $i <= 4; $i++) { + $item = $cart->addToCart($pool, 1); + $meta = $item->getMeta(); + $items[$i] = [ + 'id' => $item->id, + 'quantity' => $item->quantity, + 'price' => $item->price, + 'allocated_id' => $meta->allocated_single_item_id ?? null, + 'allocated_name' => $meta->allocated_single_item_name ?? 'none', + ]; + } + + $cart->refresh(); + + // Debug: check all cart items + $cartItems = $cart->items; + $cartItemDetails = []; + $totalQuantity = 0; + foreach ($cartItems as $item) { + $meta = $item->getMeta(); + $cartItemDetails[] = [ + 'id' => $item->id, + 'quantity' => $item->quantity, + 'price' => $item->price, + 'allocated_id' => $meta->allocated_single_item_id ?? null, + 'allocated_name' => $meta->allocated_single_item_name ?? 'none', + ]; + $totalQuantity += $item->quantity; + } + + // Total quantity should be 4 (may be in fewer cart items if merged) + $this->assertEquals( + 4, + $totalQuantity, + 'Should have total quantity of 4. Cart items: ' . json_encode($cartItemDetails) + ); + + // The issue: when items are merged, the allocation tracking might not work correctly + // Each distinct single item should NOT be merged with others + // Items from the SAME single CAN be merged (they have same price and same allocated_single_item_id) + + // Check that we have correct allocations: + // - 3 quantity allocated to single_1 + // - 1 quantity allocated to single_2 + $single1Quantity = 0; + $single2Quantity = 0; + $single3Quantity = 0; + + // Verify EACH cart item has allocated_single_item_id set + foreach ($cartItems as $item) { + $meta = $item->getMeta(); + $allocatedId = $meta->allocated_single_item_id ?? null; + $this->assertNotNull( + $allocatedId, + 'Cart item id=' . $item->id . ' (qty=' . $item->quantity . ', price=' . $item->price . + ') should have allocated_single_item_id but has: ' . json_encode($meta) + ); + + if ($allocatedId == $single_1->id) { + $single1Quantity += $item->quantity; + } elseif ($allocatedId == $single_2->id) { + $single2Quantity += $item->quantity; + } elseif ($allocatedId == $single_3->id) { + $single3Quantity += $item->quantity; + } + } + + $this->assertEquals( + 3, + $single1Quantity, + 'Should have 3 quantity from single_1. Cart: ' . json_encode($cartItemDetails) + ); + $this->assertEquals( + 1, + $single2Quantity, + 'Should have 1 quantity from single_2. Cart: ' . json_encode($cartItemDetails) + ); + $this->assertEquals( + 0, + $single3Quantity, + 'Should have 0 quantity from single_3 before adding 5th. Cart: ' . json_encode($cartItemDetails) + ); + + // Before adding item 5, test what getNextAvailablePoolItemWithPrice returns + $nextBefore = $pool->getNextAvailablePoolItemWithPrice($cart, null, $from, $until); + $this->assertNotNull($nextBefore, 'Should have next available before adding item 5'); + $this->assertEquals( + $single_3->id, + $nextBefore['item']->id, + 'Before adding item 5: Next should be single_3 (single_1 and single_2 exhausted). ' . + 'Got: ' . $nextBefore['item']->name . ' (id=' . $nextBefore['item']->id . '). ' . + 'Price: ' . $nextBefore['price'] . '. ' . + 'Cart items: ' . json_encode($cartItemDetails) + ); + + // Check that after refreshing the pool model, we still get single_3 + $pool->refresh(); + $nextBeforeAfterRefresh = $pool->getNextAvailablePoolItemWithPrice($cart, null, $from, $until); + $this->assertEquals( + $single_3->id, + $nextBeforeAfterRefresh['item']->id, + 'After pool refresh, should still get single_3' + ); + + // Now add 5th item + $cartItemsBeforeItem5 = $cart->fresh()->items; + + // Debug: Check what getNextAvailablePoolItemWithPrice returns INSIDE the addToCart flow + // by calling it right before on a fresh pool and cart + $freshCart = Cart::find($cart->id); + $freshPool = Product::find($pool->id); + $nextImmediate = $freshPool->getNextAvailablePoolItemWithPrice($freshCart, null, $from, $until); + $this->assertEquals( + $single_3->id, + $nextImmediate['item']->id, + 'Immediately before addToCart (fresh models), should get single_3. Got: ' . + $nextImmediate['item']->name . ' (id=' . $nextImmediate['item']->id . ')' + ); + + // Debug: Check what the addToCart flow sees for cart items + // This replicates the query inside getNextAvailablePoolItemWithPrice + $cartItemsAsSeenByPool = $freshCart->items() + ->where('purchasable_id', $pool->getKey()) + ->where('purchasable_type', get_class($pool)) + ->get(); + $usageMap = []; + foreach ($cartItemsAsSeenByPool as $ci) { + $meta = $ci->getMeta(); + $allocatedId = $meta->allocated_single_item_id ?? null; + if ($allocatedId) { + $usageMap[$allocatedId] = ($usageMap[$allocatedId] ?? 0) + $ci->quantity; + } + } + $this->assertEquals( + 3, + $usageMap[$single_1->id] ?? 0, + 'Usage map should show 3 for single_1. Map: ' . json_encode($usageMap) + ); + $this->assertEquals( + 1, + $usageMap[$single_2->id] ?? 0, + 'Usage map should show 1 for single_2. Map: ' . json_encode($usageMap) + ); + $this->assertEquals( + 0, + $usageMap[$single_3->id] ?? 0, + 'Usage map should show 0 for single_3. Map: ' . json_encode($usageMap) + ); + + // Use fresh cart AND fresh pool to call addToCart + $this->assertEquals($cart->id, $freshCart->id, 'Cart IDs should match'); + + // Debug: Check that freshCart can see the items + $freshCartItems = $freshCart->items() + ->where('purchasable_id', $pool->getKey()) + ->where('purchasable_type', get_class($pool)) + ->get(); + $this->assertCount( + 2, + $freshCartItems, + 'Fresh cart should have 2 cart item records. Cart ID: ' . $freshCart->id . + '. Items found: ' . $freshCartItems->pluck('id')->join(', ') + ); + $freshCartQty = $freshCartItems->sum('quantity'); + $this->assertEquals( + 4, + $freshCartQty, + 'Fresh cart should have total quantity 4. Got: ' . $freshCartQty + ); + + $item5 = $freshCart->addToCart($freshPool, 1); + $meta5 = $item5->getMeta(); + + // Verify item 5 is allocated to single_3 (the only one with remaining capacity) + $this->assertEquals( + $single_3->id, + $meta5->allocated_single_item_id, + 'Item 5 should be allocated to single_3 (id=' . $single_3->id . ') since single_1 and single_2 are exhausted. ' . + 'Got allocated_id: ' . ($meta5->allocated_single_item_id ?? 'null') + ); + + // Check if item 5 is actually a new item or a merged item + $isNewItem = !$cartItemsBeforeItem5->contains('id', $item5->id); + $cartItemsAfterItem5 = $cart->fresh()->items; + + $this->assertTrue( + $isNewItem, + 'Item 5 should be a NEW item, not merged. ' . + 'Item5 id=' . $item5->id . ', quantity=' . $item5->quantity . '. ' . + 'Cart items before: ' . $cartItemsBeforeItem5->pluck('id')->join(', ') . '. ' . + 'Cart items after: ' . $cartItemsAfterItem5->pluck('id')->join(', ') + ); + + $this->assertEquals( + $single_3->id, + $meta5->allocated_single_item_id, + 'Item 5 should be from single_3 (id=' . $single_3->id . ', name=' . $single_3->name . '). ' . + 'Got allocated_id: ' . ($meta5->allocated_single_item_id ?? 'null') . '. ' . + 'For reference: single_1=' . $single_1->id . ', single_2=' . $single_2->id + ); + $this->assertEquals(20004, $item5->price, 'Item 5 should cost 20004 (10002 * 2 days)'); + + // Total: 3*2000 + 20002 + 20004 = 46006 + $this->assertEquals(46006, $cart->fresh()->getTotal()); + } + + /** + * Test getNextAvailablePoolItemWithPrice correctly tracks cart item allocations. + */ + public function test_get_next_available_pool_item_tracks_allocations() + { + $pool = Product::factory()->create([ + 'type' => ProductType::POOL, + 'manage_stock' => true, + ]); + + // single_1: 2 stock @ 1000/day + $single_1 = Product::factory() + ->withStocks(2) + ->withPrices(1, 1000) + ->create([ + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + 'name' => 'Single1', + ]); + + // single_2: 1 stock @ 2000/day + $single_2 = Product::factory() + ->withStocks(1) + ->withPrices(1, 2000) + ->create([ + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + 'name' => 'Single2', + ]); + + $pool->attachSingleItems([$single_1->id, $single_2->id]); + + $cart = $this->createCart(); + $from = Carbon::tomorrow()->startOfDay(); + $until = Carbon::tomorrow()->addDay()->startOfDay(); + $cart->setDates($from, $until); + + // Before adding any items, next available should be single_1 (cheapest) + $next = $pool->getNextAvailablePoolItemWithPrice($cart, null, $from, $until); + $this->assertEquals($single_1->id, $next['item']->id, 'First available should be single_1'); + $this->assertEquals(1000, $next['price']); + + // Add first item - should get single_1 + $item1 = $cart->addToCart($pool, 1); + $this->assertEquals($single_1->id, $item1->getMeta()->allocated_single_item_id); + + // After 1 item, next should still be single_1 (has 2 stock) + $cart->refresh(); + $next = $pool->getNextAvailablePoolItemWithPrice($cart, null, $from, $until); + $this->assertEquals($single_1->id, $next['item']->id, 'Second available should still be single_1'); + + // Add second item - should get single_1 again + $item2 = $cart->addToCart($pool, 1); + $this->assertEquals($single_1->id, $item2->getMeta()->allocated_single_item_id); + + // After 2 items (both from single_1 which has stock=2), next should be single_2 + $cart->refresh(); + $next = $pool->getNextAvailablePoolItemWithPrice($cart, null, $from, $until); + $this->assertEquals($single_2->id, $next['item']->id, 'Third available should be single_2 (single_1 exhausted)'); + $this->assertEquals(2000, $next['price']); + + // Add third item - should get single_2 + $item3 = $cart->addToCart($pool, 1); + $this->assertEquals($single_2->id, $item3->getMeta()->allocated_single_item_id); + + // Total: 1000 + 1000 + 2000 = 4000 + $this->assertEquals(4000, $cart->fresh()->getTotal()); + } }