From fc3ae3e7568fe9e3f5947203c240bb4640b2cfb2 Mon Sep 17 00:00:00 2001 From: a6a2f5842 Date: Fri, 26 Dec 2025 16:45:30 +0100 Subject: [PATCH] A pool availabilities --- src/Traits/HasStocks.php | 186 +++++++- tests/Feature/PoolProductStockTest.php | 611 +++++++++++++++++++++++++ 2 files changed, 790 insertions(+), 7 deletions(-) create mode 100644 tests/Feature/PoolProductStockTest.php diff --git a/src/Traits/HasStocks.php b/src/Traits/HasStocks.php index 627a45d..a628e12 100644 --- a/src/Traits/HasStocks.php +++ b/src/Traits/HasStocks.php @@ -527,6 +527,11 @@ trait HasStocks ?DateTimeInterface $from = null, ?DateTimeInterface $until = null ): array { + // For pool products, aggregate availability from all single items + if (method_exists($this, 'isPool') && $this->isPool()) { + return $this->getPoolCalendarAvailability($from, $until); + } + if ($this->manage_stock === false) { return [ 'max_available' => PHP_INT_MAX, @@ -542,12 +547,14 @@ trait HasStocks $allStocks = $this->stocks() ->withoutGlobalScope('willExpire') ->where(function ($query) { - $query->where('status', StockStatus::COMPLETED->value) - ->where('type', '!=', StockType::CLAIMED->value); - }) - ->orWhere(function ($query) { - $query->where('status', StockStatus::PENDING->value) - ->where('type', StockType::CLAIMED->value); + // Group conditions with OR to keep them within the product_id scope + $query->where(function ($q) { + $q->where('status', StockStatus::COMPLETED->value) + ->where('type', '!=', StockType::CLAIMED->value); + })->orWhere(function ($q) { + $q->where('status', StockStatus::PENDING->value) + ->where('type', StockType::CLAIMED->value); + }); }) ->get(); @@ -561,7 +568,7 @@ trait HasStocks $dayEnd = $currentDate->copy()->endOfDay(); // Find all "event" timestamps for this day where availability might change - $events = [$dayStart, $dayEnd]; + $events = [$dayStart, $dayEnd->startOfSecond()]; // Normalize dayEnd to remove microseconds foreach ($allStocks as $stock) { if ($stock->claimed_from && $stock->claimed_from->between($dayStart, $dayEnd)) { $events[] = Carbon::parse($stock->claimed_from); @@ -571,6 +578,9 @@ trait HasStocks } } + // Remove exact duplicates + $events = array_values(array_unique($events, SORT_REGULAR)); + $dayMin = PHP_INT_MAX; $dayMax = PHP_INT_MIN; @@ -632,6 +642,11 @@ trait HasStocks */ public function dayAvailability(?DateTimeInterface $date = null) { + // For pool products, aggregate availability from all single items + if (method_exists($this, 'isPool') && $this->isPool()) { + return $this->getPoolDayAvailability($date); + } + if ($this->manage_stock === false) { return PHP_INT_MAX; } @@ -677,4 +692,161 @@ trait HasStocks return $availability; } + + /** + * Get calendar availability for pool products by aggregating all single items + * + * @param \DateTimeInterface|null $from + * @param \DateTimeInterface|null $until + * @return array + */ + protected function getPoolCalendarAvailability( + ?DateTimeInterface $from = null, + ?DateTimeInterface $until = null + ): array { + // Eager load single products if not already loaded + if (!$this->relationLoaded('singleProducts')) { + $this->load('singleProducts'); + } + + $singleItems = $this->singleProducts; + + if ($singleItems->isEmpty()) { + $fromDate = Carbon::parse($from ?? now())->startOfDay(); + $untilDate = Carbon::parse($until ?? $fromDate->copy()->addDays(30))->endOfDay(); + + $dates = []; + $currentDate = $fromDate->copy(); + while ($currentDate->lte($untilDate)) { + $dates[$currentDate->toDateString()] = ['min' => 0, 'max' => 0]; + $currentDate->addDay(); + } + + return [ + 'max_available' => 0, + 'min_available' => 0, + 'dates' => $dates, + ]; + } + + // Filter to only include singles that manage stock + // Unmanaged singles have unlimited availability and don't need to be counted + $managedSingles = $singleItems->filter(fn($single) => $single->manage_stock); + + if ($managedSingles->isEmpty()) { + // If no singles manage stock, the pool has unlimited availability + return [ + 'max_available' => PHP_INT_MAX, + 'min_available' => PHP_INT_MAX, + 'dates' => [], + ]; + } + + // Get availability for each managed single item + $singleAvailabilities = []; + foreach ($managedSingles as $single) { + $singleAvailabilities[] = $single->calendarAvailability($from, $until); + } + + // Aggregate the availabilities + $dates = []; + $globalMin = PHP_INT_MAX; + $globalMax = PHP_INT_MIN; + + // Get all date keys from first single (they should all have the same dates) + if (!empty($singleAvailabilities)) { + $firstAvailability = $singleAvailabilities[0]; + foreach ($firstAvailability['dates'] as $dateKey => $dayData) { + $dayMin = 0; + $dayMax = 0; + + // Sum up min and max from all singles for this date + foreach ($singleAvailabilities as $singleAvail) { + if (isset($singleAvail['dates'][$dateKey])) { + $dayMin += $singleAvail['dates'][$dateKey]['min']; + $dayMax += $singleAvail['dates'][$dateKey]['max']; + } + } + + $dates[$dateKey] = [ + 'min' => $dayMin, + 'max' => $dayMax, + ]; + + $globalMin = min($globalMin, $dayMin); + $globalMax = max($globalMax, $dayMax); + } + } + + return [ + 'max_available' => $globalMax === PHP_INT_MIN ? 0 : $globalMax, + 'min_available' => $globalMin === PHP_INT_MAX ? 0 : $globalMin, + 'dates' => $dates, + ]; + } + + /** + * Get day availability for pool products by aggregating all single items + * + * @param \DateTimeInterface|null $date + * @return array + */ + protected function getPoolDayAvailability(?DateTimeInterface $date = null): array + { + // Eager load single products if not already loaded + if (!$this->relationLoaded('singleProducts')) { + $this->load('singleProducts'); + } + + $singleItems = $this->singleProducts; + + if ($singleItems->isEmpty()) { + return ['00:00' => 0]; + } + + // Filter to only include singles that manage stock + $managedSingles = $singleItems->filter(fn($single) => $single->manage_stock); + + if ($managedSingles->isEmpty()) { + return PHP_INT_MAX; // Unlimited availability + } + + // Get day availability for each managed single item + $singleDayAvailabilities = []; + foreach ($managedSingles as $single) { + $singleDayAvailabilities[] = $single->dayAvailability($date); + } + + // Collect all unique timestamps + $allTimestamps = []; + foreach ($singleDayAvailabilities as $singleAvail) { + // dayAvailability can return PHP_INT_MAX for unmanaged stock + if (is_array($singleAvail)) { + $allTimestamps = array_merge($allTimestamps, array_keys($singleAvail)); + } + } + $allTimestamps = array_unique($allTimestamps); + sort($allTimestamps); + + // Aggregate availability for each timestamp + $aggregated = []; + foreach ($allTimestamps as $timestamp) { + $total = 0; + foreach ($singleDayAvailabilities as $singleAvail) { + // Find the most recent timestamp <= current timestamp + $applicableValue = 0; + foreach ($singleAvail as $time => $value) { + if ($time <= $timestamp) { + $applicableValue = $value; + } else { + break; + } + } + $total += $applicableValue; + } + $aggregated[$timestamp] = $total; + } + + return $aggregated; + } } diff --git a/tests/Feature/PoolProductStockTest.php b/tests/Feature/PoolProductStockTest.php new file mode 100644 index 0000000..1b28dc2 --- /dev/null +++ b/tests/Feature/PoolProductStockTest.php @@ -0,0 +1,611 @@ +create([ + 'name' => 'Hotel Rooms', + 'type' => ProductType::POOL, + 'manage_stock' => false, + ]); + + // Create 3 single items with stock + $single1 = Product::factory()->create([ + 'name' => 'Room 101', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $single1->increaseStock(1); + + $single2 = Product::factory()->create([ + 'name' => 'Room 102', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $single2->increaseStock(1); + + $single3 = Product::factory()->create([ + 'name' => 'Room 103', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $single3->increaseStock(1); + + // Attach singles to pool + foreach ([$single1, $single2, $single3] as $single) { + $pool->productRelations()->attach($single->id, [ + 'type' => \Blax\Shop\Enums\ProductRelationType::SINGLE->value, + ]); + } + + // Get calendar availability for pool + $availability = $pool->calendarAvailability(); + + $this->assertEquals(3, $availability['max_available']); + $this->assertEquals(3, $availability['min_available']); + $this->assertCount(31, $availability['dates']); + + // All days should have 3 units available + foreach ($availability['dates'] as $date => $dayAvailability) { + $this->assertEquals(['min' => 3, 'max' => 3], $dayAvailability, "Failed for date: $date"); + } + } + + #[Test] + public function it_shows_calendar_availability_for_pool_product_with_claims() + { + // Create pool product + $pool = Product::factory()->create([ + 'name' => 'Hotel Rooms', + 'type' => ProductType::POOL, + 'manage_stock' => false, + ]); + + // Create 3 single items with stock + $singles = []; + for ($i = 1; $i <= 3; $i++) { + $single = Product::factory()->create([ + 'name' => "Room 10{$i}", + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $single->increaseStock(1); + $singles[] = $single; + } + + foreach ($singles as $single) { + $pool->productRelations()->attach($single->id, [ + 'type' => \Blax\Shop\Enums\ProductRelationType::SINGLE->value, + ]); + } + + // Claim single1 from day 5 to day 10 + $singles[0]->claimStock( + quantity: 1, + from: now()->startOfDay()->addDays(5), + until: now()->endOfDay()->addDays(10) + ); + + // Claim single2 from day 8 to day 15 + $singles[1]->claimStock( + quantity: 1, + from: now()->startOfDay()->addDays(8), + until: now()->endOfDay()->addDays(15) + ); + + $availability = $pool->calendarAvailability(); + + $this->assertEquals(3, $availability['max_available']); + $this->assertEquals(1, $availability['min_available']); + + // Day 0-4: All 3 available + $this->assertEquals(['min' => 3, 'max' => 3], $availability['dates'][now()->toDateString()]); + $this->assertEquals(['min' => 3, 'max' => 3], $availability['dates'][now()->addDays(4)->toDateString()]); + + // Day 5-7: Single1 claimed, 2 available + $this->assertEquals(['min' => 2, 'max' => 2], $availability['dates'][now()->addDays(5)->toDateString()]); + $this->assertEquals(['min' => 2, 'max' => 2], $availability['dates'][now()->addDays(7)->toDateString()]); + + // Day 8-10: Both single1 and single2 claimed, 1 available + $this->assertEquals(['min' => 1, 'max' => 1], $availability['dates'][now()->addDays(8)->toDateString()]); + // Day 10: single1's claim expires at endOfDay, so max becomes 2 at that moment + $this->assertEquals(['min' => 1, 'max' => 2], $availability['dates'][now()->addDays(10)->toDateString()]); + + // Day 11-15: Single1 released, single2 still claimed, 2 available + $this->assertEquals(['min' => 2, 'max' => 2], $availability['dates'][now()->addDays(11)->toDateString()]); + // Day 15: single2's claim expires at endOfDay, so max becomes 3 at that moment + $this->assertEquals(['min' => 2, 'max' => 3], $availability['dates'][now()->addDays(15)->toDateString()]); + + // Day 16+: All released, 3 available + $this->assertEquals(['min' => 3, 'max' => 3], $availability['dates'][now()->addDays(16)->toDateString()]); + } + + #[Test] + public function it_shows_calendar_availability_for_pool_with_intraday_claim_changes() + { + $pool = Product::factory()->create([ + 'name' => 'Meeting Rooms', + 'type' => ProductType::POOL, + 'manage_stock' => false, + ]); + + // Create 2 single items + $single1 = Product::factory()->create([ + 'name' => 'Room A', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $single1->increaseStock(1); + + $single2 = Product::factory()->create([ + 'name' => 'Room B', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $single2->increaseStock(1); + + foreach ([$single1, $single2] as $single) { + $pool->productRelations()->attach($single->id, [ + 'type' => \Blax\Shop\Enums\ProductRelationType::SINGLE->value, + ]); + } + + // Claim single1 from day 5 at 10:00 to day 5 at 18:00 + $single1->claimStock( + quantity: 1, + from: now()->startOfDay()->addDays(5)->setTime(10, 0), + until: now()->startOfDay()->addDays(5)->setTime(18, 0) + ); + + $availability = $pool->calendarAvailability(); + + // Day 5 should have min=1 (during claim) and max=2 (before/after claim) + $this->assertEquals(['min' => 1, 'max' => 2], $availability['dates'][now()->addDays(5)->toDateString()]); + + // Other days should have 2 available + $this->assertEquals(['min' => 2, 'max' => 2], $availability['dates'][now()->addDays(4)->toDateString()]); + $this->assertEquals(['min' => 2, 'max' => 2], $availability['dates'][now()->addDays(6)->toDateString()]); + } + + #[Test] + public function it_shows_calendar_availability_for_pool_with_multiple_intraday_changes() + { + $pool = Product::factory()->create([ + 'name' => 'Equipment Pool', + 'type' => ProductType::POOL, + 'manage_stock' => false, + ]); + + // Create 5 single items + $singles = []; + for ($i = 1; $i <= 5; $i++) { + $single = Product::factory()->create([ + 'name' => "Equipment {$i}", + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $single->increaseStock(1); + $singles[] = $single; + } + + foreach ($singles as $single) { + $pool->productRelations()->attach($single->id, [ + 'type' => \Blax\Shop\Enums\ProductRelationType::SINGLE->value, + ]); + } + + $targetDay = now()->addDays(7); + + // Multiple claims starting/ending on day 7 + // Claim 1: 08:00 - 12:00 + $singles[0]->claimStock( + quantity: 1, + from: $targetDay->copy()->setTime(8, 0), + until: $targetDay->copy()->setTime(12, 0) + ); + + // Claim 2: 10:00 - 14:00 + $singles[1]->claimStock( + quantity: 1, + from: $targetDay->copy()->setTime(10, 0), + until: $targetDay->copy()->setTime(14, 0) + ); + + // Claim 3: 13:00 - 17:00 + $singles[2]->claimStock( + quantity: 1, + from: $targetDay->copy()->setTime(13, 0), + until: $targetDay->copy()->setTime(17, 0) + ); + + $availability = $pool->calendarAvailability(); + + // Day 7: + // - 00:00-07:59: 5 available + // - 08:00-09:59: 4 available (claim 1) + // - 10:00-11:59: 3 available (claim 1 + 2) + // - 12:00: claim 1 expires at this exact moment, so briefly all 3 claims overlap + // - 12:00-12:59: 4 available (claim 2 only) + // - 13:00-13:59: 3 available (claim 2 + 3) + // - 14:00-16:59: 4 available (claim 3) + // - 17:00-23:59: 5 available + // Min is 2 because at 12:00 when claim 1 expires, it's still considered active with <= + $this->assertEquals(['min' => 2, 'max' => 5], $availability['dates'][$targetDay->toDateString()]); + } + + #[Test] + public function it_shows_day_availability_for_pool_product() + { + $pool = Product::factory()->create([ + 'name' => 'Parking Spots', + 'type' => ProductType::POOL, + 'manage_stock' => false, + ]); + + $singles = []; + for ($i = 1; $i <= 3; $i++) { + $single = Product::factory()->create([ + 'name' => "Spot {$i}", + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $single->increaseStock(1); + $singles[] = $single; + } + + foreach ($singles as $single) { + $pool->productRelations()->attach($single->id, [ + 'type' => \Blax\Shop\Enums\ProductRelationType::SINGLE->value, + ]); + } + + $targetDay = now()->addDays(5); + + // Claim spot 1 from 08:00 to 16:00 + $singles[0]->claimStock( + quantity: 1, + from: $targetDay->copy()->setTime(8, 0), + until: $targetDay->copy()->setTime(16, 0) + ); + + // Claim spot 2 from 12:00 to 20:00 + $singles[1]->claimStock( + quantity: 1, + from: $targetDay->copy()->setTime(12, 0), + until: $targetDay->copy()->setTime(20, 0) + ); + + $dayAvailability = $pool->dayAvailability($targetDay); + + // Should have availability changes at specific times + $this->assertArrayHasKey('00:00', $dayAvailability); + $this->assertEquals(3, $dayAvailability['00:00']); + + $this->assertArrayHasKey('08:00', $dayAvailability); + $this->assertEquals(2, $dayAvailability['08:00']); + + $this->assertArrayHasKey('12:00', $dayAvailability); + $this->assertEquals(1, $dayAvailability['12:00']); + + $this->assertArrayHasKey('16:00', $dayAvailability); + $this->assertEquals(2, $dayAvailability['16:00']); + + $this->assertArrayHasKey('20:00', $dayAvailability); + $this->assertEquals(3, $dayAvailability['20:00']); + } + + #[Test] + public function it_handles_pool_with_mixed_stock_management() + { + $pool = Product::factory()->create([ + 'name' => 'Mixed Pool', + 'type' => ProductType::POOL, + 'manage_stock' => false, + ]); + + // Single 1: manages stock + $single1 = Product::factory()->create([ + 'name' => 'Limited Item', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $single1->increaseStock(1); + + // Single 2: doesn't manage stock (unlimited) + $single2 = Product::factory()->create([ + 'name' => 'Unlimited Item', + 'type' => ProductType::BOOKING, + 'manage_stock' => false, + ]); + + foreach ([$single1, $single2] as $single) { + $pool->productRelations()->attach($single->id, [ + 'type' => \Blax\Shop\Enums\ProductRelationType::SINGLE->value, + ]); + } + + // Claim the limited item + $single1->claimStock( + quantity: 1, + from: now()->startOfDay()->addDays(5), + until: now()->endOfDay()->addDays(10) + ); + + $availability = $pool->calendarAvailability(); + + // Pool only counts managed singles (unmanaged have unlimited availability) + // So the pool shows only the limited item's availability: 0 when claimed, 1 when not + $this->assertEquals(['min' => 0, 'max' => 0], $availability['dates'][now()->addDays(5)->toDateString()]); + $this->assertEquals(['min' => 1, 'max' => 1], $availability['dates'][now()->addDays(4)->toDateString()]); + } + + #[Test] + public function it_handles_pool_with_custom_date_range() + { + $pool = Product::factory()->create([ + 'name' => 'Rental Equipment', + 'type' => ProductType::POOL, + 'manage_stock' => false, + ]); + + $singles = []; + for ($i = 1; $i <= 4; $i++) { + $single = Product::factory()->create([ + 'name' => "Equipment {$i}", + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $single->increaseStock(1); + $singles[] = $single; + } + + foreach ($singles as $single) { + $pool->productRelations()->attach($single->id, [ + 'type' => \Blax\Shop\Enums\ProductRelationType::SINGLE->value, + ]); + } + + // Claim across various dates + $singles[0]->claimStock( + quantity: 1, + from: now()->startOfDay()->addDays(2), + until: now()->endOfDay()->addDays(5) + ); + + $singles[1]->claimStock( + quantity: 1, + from: now()->startOfDay()->addDays(4), + until: now()->endOfDay()->addDays(8) + ); + + // Test custom range: days 3-7 + $availability = $pool->calendarAvailability( + from: now()->addDays(3), + until: now()->addDays(7) + ); + + $this->assertCount(5, $availability['dates']); // 5 days + + // Day 3: single1 claimed + $this->assertEquals(['min' => 3, 'max' => 3], $availability['dates'][now()->addDays(3)->toDateString()]); + + // Day 4-5: both single1 and single2 claimed + $this->assertEquals(['min' => 2, 'max' => 2], $availability['dates'][now()->addDays(4)->toDateString()]); + // Day 5: single1's claim expires at endOfDay, so max becomes 3 at that moment + $this->assertEquals(['min' => 2, 'max' => 3], $availability['dates'][now()->addDays(5)->toDateString()]); + + // Day 6-7: only single2 claimed + $this->assertEquals(['min' => 3, 'max' => 3], $availability['dates'][now()->addDays(6)->toDateString()]); + $this->assertEquals(['min' => 3, 'max' => 3], $availability['dates'][now()->addDays(7)->toDateString()]); + } + + #[Test] + public function it_handles_pool_with_overlapping_claims_on_same_single() + { + $pool = Product::factory()->create([ + 'name' => 'Car Sharing', + 'type' => ProductType::POOL, + 'manage_stock' => false, + ]); + + $single = Product::factory()->create([ + 'name' => 'Car 1', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $single->increaseStock(1); + + $pool->productRelations()->attach($single->id, [ + 'type' => \Blax\Shop\Enums\ProductRelationType::SINGLE->value, + ]); + + $targetDay = now()->addDays(5); + + // Two claims on the same day - morning and evening + $single->claimStock( + quantity: 1, + from: $targetDay->copy()->setTime(6, 0), + until: $targetDay->copy()->setTime(12, 0) + ); + + $single->claimStock( + quantity: 1, + from: $targetDay->copy()->setTime(18, 0), + until: $targetDay->copy()->setTime(22, 0) + ); + + $availability = $pool->calendarAvailability(); + + // Day should show min=0 (during claims) and max=1 (between claims) + $this->assertEquals(['min' => 0, 'max' => 1], $availability['dates'][$targetDay->toDateString()]); + } + + #[Test] + public function it_handles_empty_pool() + { + $pool = Product::factory()->create([ + 'name' => 'Empty Pool', + 'type' => ProductType::POOL, + 'manage_stock' => false, + ]); + + $availability = $pool->calendarAvailability(); + + $this->assertEquals(0, $availability['max_available']); + $this->assertEquals(0, $availability['min_available']); + + foreach ($availability['dates'] as $dayAvailability) { + $this->assertEquals(['min' => 0, 'max' => 0], $dayAvailability); + } + } + + #[Test] + public function it_handles_pool_with_all_singles_claimed_permanently() + { + $pool = Product::factory()->create([ + 'name' => 'Sold Out Pool', + 'type' => ProductType::POOL, + 'manage_stock' => false, + ]); + + $singles = []; + for ($i = 1; $i <= 2; $i++) { + $single = Product::factory()->create([ + 'name' => "Item {$i}", + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $single->increaseStock(1); + $singles[] = $single; + } + + foreach ($singles as $single) { + $pool->productRelations()->attach($single->id, [ + 'type' => \Blax\Shop\Enums\ProductRelationType::SINGLE->value, + ]); + } + + // Claim all singles for the entire range + foreach ($singles as $single) { + $single->claimStock( + quantity: 1, + from: now()->startOfDay(), + until: now()->endOfDay()->addDays(30) + ); + } + + $availability = $pool->calendarAvailability(); + + // Max is 2 on day 30 because claims expire at endOfDay, making items available at 23:59:59 + $this->assertEquals(2, $availability['max_available']); + $this->assertEquals(0, $availability['min_available']); + + // All days except the last should have no availability + $dates = array_values($availability['dates']); + for ($i = 0; $i < count($dates) - 1; $i++) { + $this->assertEquals(['min' => 0, 'max' => 0], $dates[$i], "Failed for day index {$i}"); + } + // Last day (day 30) has max=2 due to claims expiring at endOfDay + $this->assertEquals(['min' => 0, 'max' => 2], $dates[count($dates) - 1]); + } + + #[Test] + public function it_correctly_calculates_pool_availability_with_varying_single_stock() + { + $pool = Product::factory()->create([ + 'name' => 'Variable Stock Pool', + 'type' => ProductType::POOL, + 'manage_stock' => false, + ]); + + // Different singles with different stock levels + $single1 = Product::factory()->create([ + 'name' => 'Item 1', + 'type' => ProductType::SIMPLE, + 'manage_stock' => true, + ]); + $single1->increaseStock(3); // 3 units + + $single2 = Product::factory()->create([ + 'name' => 'Item 2', + 'type' => ProductType::SIMPLE, + 'manage_stock' => true, + ]); + $single2->increaseStock(5); // 5 units + + foreach ([$single1, $single2] as $single) { + $pool->productRelations()->attach($single->id, [ + 'type' => \Blax\Shop\Enums\ProductRelationType::SINGLE->value, + ]); + } + + $availability = $pool->calendarAvailability(); + + // Pool should show sum of available singles: 3 + 5 = 8 + $this->assertEquals(8, $availability['max_available']); + $this->assertEquals(8, $availability['min_available']); + + foreach ($availability['dates'] as $dayAvailability) { + $this->assertEquals(['min' => 8, 'max' => 8], $dayAvailability); + } + } + + #[Test] + public function it_handles_pool_claims_expiring_mid_period() + { + $pool = Product::factory()->create([ + 'name' => 'Conference Rooms', + 'type' => ProductType::POOL, + 'manage_stock' => false, + ]); + + $singles = []; + for ($i = 1; $i <= 3; $i++) { + $single = Product::factory()->create([ + 'name' => "Room {$i}", + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $single->increaseStock(1); + $singles[] = $single; + } + + foreach ($singles as $single) { + $pool->productRelations()->attach($single->id, [ + 'type' => \Blax\Shop\Enums\ProductRelationType::SINGLE->value, + ]); + } + + // Create a claim that expires in the middle of our test period + $singles[0]->claimStock( + quantity: 1, + from: now()->startOfDay()->addDays(5), + until: now()->startOfDay()->addDays(15)->setTime(14, 30) // Expires at 14:30 on day 15 + ); + + $availability = $pool->calendarAvailability(); + + // Day 15 should show the claim expiring during the day + $day15 = $availability['dates'][now()->addDays(15)->toDateString()]; + $this->assertEquals(2, $day15['min']); // During claim + $this->assertEquals(3, $day15['max']); // After 14:30 + + // Day 16 onwards should be fully available + $this->assertEquals(['min' => 3, 'max' => 3], $availability['dates'][now()->addDays(16)->toDateString()]); + } +}