diff --git a/src/Models/Cart.php b/src/Models/Cart.php index 964a50a..c241923 100644 --- a/src/Models/Cart.php +++ b/src/Models/Cart.php @@ -1284,6 +1284,201 @@ class Cart extends Model return $item ?? true; } + /** + * Get calendar availability for all items in the cart. + * + * This method aggregates availability across all cart items and returns + * the minimum availability for each date. This is useful for booking systems + * where you need to know when ALL items in a cart can be booked together. + * + * For each date, it calculates the minimum number of complete cart "sets" + * that could be fulfilled. A set is fulfilled when all items have at least + * one unit available. + * + * Returns associative array with keys: + * - 'max_available' => Shows the peak available "sets" in the date range + * - 'min_available' => Shows the lowest available "sets" in the date range + * - 'dates' => An array of dates with their respective min/max availability + * - 'items' => Individual item availability data (for debugging) + * + * @param \DateTimeInterface|null $from Start date of the range (optional, defaults to today) + * @param \DateTimeInterface|null $until End date of the range (optional, defaults to 30 days) + * @return array Associative array with 'max_available', 'min_available', 'dates', and 'items' + */ + public function calendarAvailability( + ?\DateTimeInterface $from = null, + ?\DateTimeInterface $until = null + ): array { + $fromDate = Carbon::parse($from ?? now())->startOfDay(); + $untilDate = Carbon::parse($until ?? $fromDate->copy()->addDays(30))->endOfDay(); + + // Load items with their purchasable products + if (!$this->relationLoaded('items')) { + $this->load('items.purchasable'); + } + + $items = $this->items; + + if ($items->isEmpty()) { + return [ + 'max_available' => PHP_INT_MAX, + 'min_available' => PHP_INT_MAX, + 'dates' => [], + 'items' => [], + ]; + } + + // Collect availability data for each unique product in the cart + $productAvailabilities = []; + $itemDetails = []; + + // Group items by product to handle multiple quantities of the same product + $productQuantities = []; + foreach ($items as $item) { + $product = $item->purchasable; + if (!$product) { + continue; + } + + $productKey = get_class($product) . '|' . $product->id; + if (!isset($productQuantities[$productKey])) { + $productQuantities[$productKey] = [ + 'product' => $product, + 'quantity' => 0, + ]; + } + $productQuantities[$productKey]['quantity'] += $item->quantity; + } + + // Get calendar availability for each unique product + foreach ($productQuantities as $productKey => $data) { + $product = $data['product']; + $requiredQuantity = $data['quantity']; + + // Check if product has the calendarAvailability method (uses HasStocks trait) + if (method_exists($product, 'calendarAvailability')) { + $availability = $product->calendarAvailability($from, $until); + $productAvailabilities[$productKey] = [ + 'availability' => $availability, + 'required_quantity' => $requiredQuantity, + ]; + $itemDetails[$productKey] = [ + 'product_id' => $product->id, + 'product_name' => $product->name ?? 'Unknown', + 'required_quantity' => $requiredQuantity, + 'availability' => $availability, + ]; + } else { + // Product doesn't have stock management - treat as unlimited + $productAvailabilities[$productKey] = [ + 'availability' => [ + 'max_available' => PHP_INT_MAX, + 'min_available' => PHP_INT_MAX, + 'dates' => [], + ], + 'required_quantity' => $requiredQuantity, + ]; + $itemDetails[$productKey] = [ + 'product_id' => $product->id, + 'product_name' => $product->name ?? 'Unknown', + 'required_quantity' => $requiredQuantity, + 'availability' => [ + 'max_available' => PHP_INT_MAX, + 'min_available' => PHP_INT_MAX, + 'dates' => [], + ], + ]; + } + } + + // If no products have availability data, return unlimited + if (empty($productAvailabilities)) { + return [ + 'max_available' => PHP_INT_MAX, + 'min_available' => PHP_INT_MAX, + 'dates' => [], + 'items' => $itemDetails, + ]; + } + + // Build the combined calendar + $dates = []; + $globalMin = PHP_INT_MAX; + $globalMax = PHP_INT_MIN; + + $currentDate = $fromDate->copy(); + while ($currentDate->lte($untilDate)) { + $dateKey = $currentDate->toDateString(); + $dayMin = PHP_INT_MAX; + $dayMax = PHP_INT_MAX; + + foreach ($productAvailabilities as $productKey => $data) { + $availability = $data['availability']; + $requiredQuantity = $data['required_quantity']; + + // Get the availability for this date + if (isset($availability['dates'][$dateKey])) { + $productDayData = $availability['dates'][$dateKey]; + $productDayMin = $productDayData['min'] ?? 0; + $productDayMax = $productDayData['max'] ?? 0; + } else { + // No specific date data - use overall availability + $productDayMin = $availability['min_available'] ?? 0; + $productDayMax = $availability['max_available'] ?? 0; + } + + // Calculate how many "sets" of the required quantity are available + if ($productDayMin === PHP_INT_MAX) { + $setsMin = PHP_INT_MAX; + } else { + $setsMin = $requiredQuantity > 0 ? intdiv($productDayMin, $requiredQuantity) : PHP_INT_MAX; + } + + if ($productDayMax === PHP_INT_MAX) { + $setsMax = PHP_INT_MAX; + } else { + $setsMax = $requiredQuantity > 0 ? intdiv($productDayMax, $requiredQuantity) : PHP_INT_MAX; + } + + // The cart availability is limited by the product with the least availability + $dayMin = min($dayMin, $setsMin); + $dayMax = min($dayMax, $setsMax); + } + + // Handle PHP_INT_MAX edge case + if ($dayMin === PHP_INT_MAX) { + $dayMin = PHP_INT_MAX; + } + if ($dayMax === PHP_INT_MAX) { + $dayMax = PHP_INT_MAX; + } + + $dates[$dateKey] = [ + 'min' => $dayMin, + 'max' => $dayMax, + ]; + + if ($dayMin !== PHP_INT_MAX) { + $globalMin = min($globalMin, $dayMin); + } + if ($dayMax !== PHP_INT_MAX && $dayMax !== PHP_INT_MIN) { + $globalMax = max($globalMax, $dayMax); + } elseif ($dayMax === PHP_INT_MAX && $globalMax === PHP_INT_MIN) { + // All products have unlimited availability + $globalMax = PHP_INT_MAX; + } + + $currentDate->addDay(); + } + + return [ + 'max_available' => $globalMax === PHP_INT_MIN ? 0 : $globalMax, + 'min_available' => $globalMin === PHP_INT_MAX ? PHP_INT_MAX : $globalMin, + 'dates' => $dates, + 'items' => $itemDetails, + ]; + } + /** * Validate cart for checkout without converting it * diff --git a/src/Traits/HasStocks.php b/src/Traits/HasStocks.php index a628e12..2a246e5 100644 --- a/src/Traits/HasStocks.php +++ b/src/Traits/HasStocks.php @@ -178,8 +178,8 @@ trait HasStocks public function adjustStock( StockType $type, int $quantity, - \DateTimeInterface|null $until = null, - \DateTimeInterface|null $from = null, + DateTimeInterface|null $until = null, + DateTimeInterface|null $from = null, ?StockStatus $status = null, string|null $note = null, Model|null $referencable = null @@ -252,8 +252,8 @@ trait HasStocks public function claimStock( int $quantity, $reference = null, - ?\DateTimeInterface $from = null, - ?\DateTimeInterface $until = null, + ?DateTimeInterface $from = null, + ?DateTimeInterface $until = null, ?string $note = null ): ?\Blax\Shop\Models\ProductStock { @@ -285,7 +285,7 @@ trait HasStocks * * @return int Available quantity (PHP_INT_MAX if stock management disabled) */ - public function getAvailableStock(?\DateTimeInterface $date = null): int + public function getAvailableStock(?DateTimeInterface $date = null): int { if (!$this->manage_stock) { return PHP_INT_MAX; @@ -369,7 +369,7 @@ trait HasStocks * @param \DateTimeInterface|null $from Optional start date to filter claims * @return int Total future claimed quantity (always positive) */ - public function getFutureClaimedStock(?\DateTimeInterface $from = null): int + public function getFutureClaimedStock(?DateTimeInterface $from = null): int { $query = $this->stocks() ->where('type', StockType::CLAIMED->value) @@ -503,7 +503,7 @@ trait HasStocks * @param \DateTimeInterface $date The date to check availability for * @return int Available stock on that date (PHP_INT_MAX if stock management disabled) */ - public function availableOnDate(\DateTimeInterface $date): int + public function availableOnDate(DateTimeInterface $date): int { if (!$this->manage_stock) { return PHP_INT_MAX; @@ -849,4 +849,43 @@ trait HasStocks return $aggregated; } + + /** + * Accounts the current cart, from/until and also for pool products + * @return int + */ + public function getHasMoreAttribute(): int + { + if (method_exists($this, 'isPool') && $this->isPool()) { + // For pool products, check availability across all single items + if (!$this->relationLoaded('singleProducts')) { + $this->load('singleProducts'); + } + + $totalAvailable = 0; + foreach ($this->singleProducts as $single) { + $singleAvailable = $single->getHasMoreAttribute(); + + // If any single has unlimited availability, the pool effectively has unlimited + if ($singleAvailable === PHP_INT_MAX) { + return PHP_INT_MAX; + } + + $totalAvailable += $singleAvailable; + + // Prevent overflow - cap at PHP_INT_MAX + if ($totalAvailable >= PHP_INT_MAX || $totalAvailable < 0) { + return PHP_INT_MAX; + } + } + + return $totalAvailable; + } + + if ($this->manage_stock === false) { + return PHP_INT_MAX; + } + + return $this->getAvailableStock(); + } } diff --git a/tests/Feature/CartCalendarAvailabilityTest.php b/tests/Feature/CartCalendarAvailabilityTest.php new file mode 100644 index 0000000..938f915 --- /dev/null +++ b/tests/Feature/CartCalendarAvailabilityTest.php @@ -0,0 +1,512 @@ + \Illuminate\Support\Str::uuid(), + 'name' => 'Test User', + 'email' => 'test@example.com', + 'password' => bcrypt('password'), + ]); + + $cart = Cart::factory()->forCustomer($user)->create(); + + return [$user, $cart]; + } + + #[Test] + public function it_returns_unlimited_availability_for_empty_cart() + { + [$user, $cart] = $this->createUserWithCart(); + + $availability = $cart->calendarAvailability(); + + $this->assertEquals(PHP_INT_MAX, $availability['max_available']); + $this->assertEquals(PHP_INT_MAX, $availability['min_available']); + $this->assertEmpty($availability['dates']); + } + + #[Test] + public function it_returns_availability_for_single_product_in_cart() + { + [$user, $cart] = $this->createUserWithCart(); + + $product = Product::factory()->withStocks(50)->withPrices(1, 1000)->create(); + $cart->addToCart($product, 1); + + $availability = $cart->calendarAvailability(); + + $this->assertEquals(50, $availability['max_available']); + $this->assertEquals(50, $availability['min_available']); + $this->assertCount(31, $availability['dates']); + + // All dates should have 50 available + foreach ($availability['dates'] as $dateKey => $dayData) { + $this->assertEquals(['min' => 50, 'max' => 50], $dayData, "Failed for date: $dateKey"); + } + } + + #[Test] + public function it_returns_minimum_availability_across_multiple_products() + { + [$user, $cart] = $this->createUserWithCart(); + + $product1 = Product::factory()->withStocks(100)->withPrices(1, 1000)->create(); + $product2 = Product::factory()->withStocks(30)->withPrices(1, 500)->create(); + + $cart->addToCart($product1, 1); + $cart->addToCart($product2, 1); + + $availability = $cart->calendarAvailability(); + + // The cart availability should be limited by the product with less stock + $this->assertEquals(30, $availability['max_available']); + $this->assertEquals(30, $availability['min_available']); + } + + #[Test] + public function it_considers_required_quantity_when_calculating_sets() + { + [$user, $cart] = $this->createUserWithCart(); + + // Product with 10 units in stock + $product = Product::factory()->withStocks(10)->withPrices(1, 1000)->create(); + + // Add 3 of this product to cart + $cart->addToCart($product, 3); + + $availability = $cart->calendarAvailability(); + + // With 10 in stock and 3 required per cart, we can fulfill 3 complete sets (10 / 3 = 3) + $this->assertEquals(3, $availability['max_available']); + $this->assertEquals(3, $availability['min_available']); + } + + #[Test] + public function it_shows_availability_for_cart_with_booking_products() + { + [$user, $cart] = $this->createUserWithCart(); + + $product = Product::factory()->create([ + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $product->increaseStock(5); + + // Create a price for the product + $product->prices()->create([ + 'unit_amount' => 10000, + 'currency' => 'usd', + 'is_default' => true, + ]); + + $cart->addToCart($product, 1, [], now()->addDays(5), now()->addDays(10)); + + $availability = $cart->calendarAvailability(); + + $this->assertEquals(5, $availability['max_available']); + $this->assertEquals(5, $availability['min_available']); + } + + #[Test] + public function it_shows_reduced_availability_when_stock_is_claimed() + { + [$user, $cart] = $this->createUserWithCart(); + + $product = Product::factory()->create([ + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $product->increaseStock(10); + + // Create a price for the product + $product->prices()->create([ + 'unit_amount' => 10000, + 'currency' => 'usd', + 'is_default' => true, + ]); + + // Claim 3 units for days 5-10 + $product->claimStock( + quantity: 3, + from: now()->startOfDay()->addDays(5), + until: now()->endOfDay()->addDays(10) + ); + + $cart->addToCart($product, 1, [], now()->addDays(5), now()->addDays(10)); + + $availability = $cart->calendarAvailability(); + + // Before claim period (days 0-4): 10 available + $this->assertEquals(['min' => 10, 'max' => 10], $availability['dates'][now()->toDateString()]); + $this->assertEquals(['min' => 10, 'max' => 10], $availability['dates'][now()->addDays(4)->toDateString()]); + + // During claim period (days 5-10): 7 available (10 - 3) + // Day 5: claim starts at startOfDay, so min=max=7 for the whole day + $this->assertEquals(['min' => 7, 'max' => 7], $availability['dates'][now()->addDays(5)->toDateString()]); + $this->assertEquals(['min' => 7, 'max' => 7], $availability['dates'][now()->addDays(7)->toDateString()]); + + // After claim period: 10 available + $this->assertEquals(['min' => 10, 'max' => 10], $availability['dates'][now()->addDays(15)->toDateString()]); + } + + #[Test] + public function it_shows_availability_for_cart_with_pool_products() + { + [$user, $cart] = $this->createUserWithCart(); + + // Create pool product + $pool = Product::factory()->create([ + 'name' => 'Hotel Rooms', + 'type' => ProductType::POOL, + 'manage_stock' => false, + ]); + + // Create a price for the pool + $pool->prices()->create([ + 'unit_amount' => 15000, + 'currency' => 'usd', + 'is_default' => true, + ]); + + // 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); + + // Create a price for each single + $single->prices()->create([ + 'unit_amount' => 15000, + 'currency' => 'usd', + 'is_default' => true, + ]); + + $singles[] = $single; + } + + foreach ($singles as $single) { + $pool->productRelations()->attach($single->id, [ + 'type' => \Blax\Shop\Enums\ProductRelationType::SINGLE->value, + ]); + } + + $cart->addToCart($pool, 1, [], now()->addDays(5), now()->addDays(10)); + + $availability = $cart->calendarAvailability(); + + $this->assertEquals(3, $availability['max_available']); + $this->assertEquals(3, $availability['min_available']); + } + + #[Test] + public function it_shows_reduced_pool_availability_with_claims() + { + [$user, $cart] = $this->createUserWithCart(); + + // Create pool product + $pool = Product::factory()->create([ + 'name' => 'Hotel Rooms', + 'type' => ProductType::POOL, + 'manage_stock' => false, + ]); + + // Create a price for the pool + $pool->prices()->create([ + 'unit_amount' => 15000, + 'currency' => 'usd', + 'is_default' => true, + ]); + + // 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); + + $single->prices()->create([ + 'unit_amount' => 15000, + 'currency' => 'usd', + 'is_default' => true, + ]); + + $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) + ); + + $cart->addToCart($pool, 1, [], now()->addDays(5), now()->addDays(10)); + + $availability = $cart->calendarAvailability(); + + // Before claim: 3 available + $this->assertEquals(['min' => 3, 'max' => 3], $availability['dates'][now()->addDays(4)->toDateString()]); + + // During claim: 2 available + $this->assertEquals(['min' => 2, 'max' => 2], $availability['dates'][now()->addDays(7)->toDateString()]); + + // After claim: 3 available + $this->assertEquals(['min' => 3, 'max' => 3], $availability['dates'][now()->addDays(15)->toDateString()]); + } + + #[Test] + public function it_handles_custom_date_range() + { + [$user, $cart] = $this->createUserWithCart(); + + $product = Product::factory()->withStocks(25)->withPrices(1, 1000)->create(); + $cart->addToCart($product, 1); + + $from = now()->addDays(10); + $until = now()->addDays(20); + + $availability = $cart->calendarAvailability($from, $until); + + $this->assertCount(11, $availability['dates']); // 10 to 20 inclusive + $this->assertEquals(25, $availability['max_available']); + $this->assertEquals(25, $availability['min_available']); + } + + #[Test] + public function it_returns_minimum_sets_across_multiple_products_with_different_quantities() + { + [$user, $cart] = $this->createUserWithCart(); + + // Product 1: 20 in stock, need 4 = 5 sets available + $product1 = Product::factory()->withStocks(20)->withPrices(1, 1000)->create(); + + // Product 2: 15 in stock, need 5 = 3 sets available + $product2 = Product::factory()->withStocks(15)->withPrices(1, 500)->create(); + + $cart->addToCart($product1, 4); + $cart->addToCart($product2, 5); + + $availability = $cart->calendarAvailability(); + + // Cart can only be fulfilled 3 times (limited by product2: 15/5 = 3) + $this->assertEquals(3, $availability['max_available']); + $this->assertEquals(3, $availability['min_available']); + } + + #[Test] + public function it_handles_products_without_stock_management() + { + [$user, $cart] = $this->createUserWithCart(); + + // Product without stock management (unlimited) + $product = Product::factory()->create([ + 'manage_stock' => false, + ]); + $product->prices()->create([ + 'unit_amount' => 1000, + 'currency' => 'usd', + 'is_default' => true, + ]); + + $cart->addToCart($product, 1); + + $availability = $cart->calendarAvailability(); + + // Unlimited availability + $this->assertEquals(PHP_INT_MAX, $availability['max_available']); + $this->assertEquals(PHP_INT_MAX, $availability['min_available']); + } + + #[Test] + public function it_combines_limited_and_unlimited_products() + { + [$user, $cart] = $this->createUserWithCart(); + + // Limited product + $limitedProduct = Product::factory()->withStocks(10)->withPrices(1, 1000)->create(); + + // Unlimited product + $unlimitedProduct = Product::factory()->create([ + 'manage_stock' => false, + ]); + $unlimitedProduct->prices()->create([ + 'unit_amount' => 500, + 'currency' => 'usd', + 'is_default' => true, + ]); + + $cart->addToCart($limitedProduct, 2); + $cart->addToCart($unlimitedProduct, 1); + + $availability = $cart->calendarAvailability(); + + // Limited by the limited product: 10 / 2 = 5 sets + $this->assertEquals(5, $availability['max_available']); + $this->assertEquals(5, $availability['min_available']); + } + + #[Test] + public function it_returns_item_details_for_debugging() + { + [$user, $cart] = $this->createUserWithCart(); + + $product1 = Product::factory()->withStocks(50)->withPrices(1, 1000)->create([ + 'name' => 'Product One', + ]); + $product2 = Product::factory()->withStocks(30)->withPrices(1, 500)->create([ + 'name' => 'Product Two', + ]); + + $cart->addToCart($product1, 2); + $cart->addToCart($product2, 1); + + $availability = $cart->calendarAvailability(); + + $this->assertArrayHasKey('items', $availability); + $this->assertCount(2, $availability['items']); + + // Verify item details are included + $itemKeys = array_keys($availability['items']); + foreach ($itemKeys as $key) { + $item = $availability['items'][$key]; + $this->assertArrayHasKey('product_id', $item); + $this->assertArrayHasKey('product_name', $item); + $this->assertArrayHasKey('required_quantity', $item); + $this->assertArrayHasKey('availability', $item); + } + } + + #[Test] + public function it_handles_overlapping_claims_for_multiple_products() + { + [$user, $cart] = $this->createUserWithCart(); + + // Product 1: 100 stock, claim 30 on days 5-10 + $product1 = Product::factory()->create([ + 'name' => 'Product 1', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $product1->increaseStock(100); + $product1->prices()->create([ + 'unit_amount' => 1000, + 'currency' => 'usd', + 'is_default' => true, + ]); + + $product1->claimStock( + quantity: 30, + from: now()->startOfDay()->addDays(5), + until: now()->endOfDay()->addDays(10) + ); + + // Product 2: 50 stock, claim 20 on days 8-15 + $product2 = Product::factory()->create([ + 'name' => 'Product 2', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $product2->increaseStock(50); + $product2->prices()->create([ + 'unit_amount' => 500, + 'currency' => 'usd', + 'is_default' => true, + ]); + + $product2->claimStock( + quantity: 20, + from: now()->startOfDay()->addDays(8), + until: now()->endOfDay()->addDays(15) + ); + + $cart->addToCart($product1, 1, [], now()->addDays(5), now()->addDays(10)); + $cart->addToCart($product2, 1, [], now()->addDays(5), now()->addDays(10)); + + $availability = $cart->calendarAvailability(); + + // Day 0-4: product1=100, product2=50 -> min(100, 50) = 50 + $this->assertEquals(['min' => 50, 'max' => 50], $availability['dates'][now()->toDateString()]); + + // Day 6-7: product1=70, product2=50 -> min(70, 50) = 50 + $this->assertEquals(['min' => 50, 'max' => 50], $availability['dates'][now()->addDays(6)->toDateString()]); + + // Day 9: product1=70, product2=30 -> min(70, 30) = 30 + $this->assertEquals(['min' => 30, 'max' => 30], $availability['dates'][now()->addDays(9)->toDateString()]); + + // Day 12: product1=100, product2=30 -> min(100, 30) = 30 + $this->assertEquals(['min' => 30, 'max' => 30], $availability['dates'][now()->addDays(12)->toDateString()]); + + // Day 20: product1=100, product2=50 -> min(100, 50) = 50 + $this->assertEquals(['min' => 50, 'max' => 50], $availability['dates'][now()->addDays(20)->toDateString()]); + } + + #[Test] + public function it_handles_same_product_added_multiple_times() + { + [$user, $cart] = $this->createUserWithCart(); + + $product = Product::factory()->withStocks(15)->withPrices(1, 1000)->create(); + + // Add the same product twice + $cart->addToCart($product, 2); + $cart->addToCart($product, 3); + + $availability = $cart->calendarAvailability(); + + // Total required: 5, available: 15 -> 3 sets (15 / 5 = 3) + $this->assertEquals(3, $availability['max_available']); + $this->assertEquals(3, $availability['min_available']); + } + + #[Test] + public function it_returns_zero_when_no_stock_available() + { + [$user, $cart] = $this->createUserWithCart(); + + $product = Product::factory()->create([ + 'manage_stock' => true, + ]); + // No stock added + $product->prices()->create([ + 'unit_amount' => 1000, + 'currency' => 'usd', + 'is_default' => true, + ]); + + $cart->addToCart($product, 1); + + $availability = $cart->calendarAvailability(); + + $this->assertEquals(0, $availability['max_available']); + $this->assertEquals(0, $availability['min_available']); + } +} diff --git a/tests/Feature/GetHasMoreAttributeTest.php b/tests/Feature/GetHasMoreAttributeTest.php new file mode 100644 index 0000000..e077b0a --- /dev/null +++ b/tests/Feature/GetHasMoreAttributeTest.php @@ -0,0 +1,314 @@ +create([ + 'manage_stock' => false, + ]); + + $this->assertEquals(PHP_INT_MAX, $product->has_more); + } + + #[Test] + public function it_returns_available_stock_for_simple_product() + { + $product = Product::factory()->withStocks(50)->create(); + + $this->assertEquals(50, $product->has_more); + } + + #[Test] + public function it_returns_zero_when_no_stock_available() + { + $product = Product::factory()->create([ + 'manage_stock' => true, + ]); + + $this->assertEquals(0, $product->has_more); + } + + #[Test] + public function it_returns_remaining_stock_after_claims() + { + $product = Product::factory()->withStocks(100)->create(); + + // Claim 30 units + $product->claimStock( + quantity: 30, + from: now(), + until: now()->addDays(5) + ); + + $this->assertEquals(70, $product->has_more); + } + + #[Test] + public function it_returns_available_stock_for_booking_product() + { + $product = Product::factory()->create([ + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $product->increaseStock(10); + + // Claim 3 units for a future period + $product->claimStock( + quantity: 3, + from: now()->addDays(5), + until: now()->addDays(10) + ); + + // Has more should reflect available stock at current time + $this->assertEquals(10, $product->has_more); + } + + #[Test] + public function it_returns_aggregated_availability_for_pool_product() + { + // Create pool product + $pool = Product::factory()->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, + ]); + } + + // Pool should aggregate availability from all singles: 1 + 1 + 1 = 3 + $this->assertEquals(3, $pool->has_more); + } + + #[Test] + public function it_returns_aggregated_availability_for_pool_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 now until next week (active claim) + $singles[0]->claimStock( + quantity: 1, + from: now(), + until: now()->addDays(7) + ); + + // Pool should show 2 available (singles 2 and 3) + $this->assertEquals(2, $pool->has_more); + } + + #[Test] + public function it_returns_aggregated_availability_for_pool_with_future_claims() + { + // Create pool product + $pool = Product::factory()->create([ + 'name' => 'Rental Cars', + 'type' => ProductType::POOL, + 'manage_stock' => false, + ]); + + // Create 2 single items with stock + $single1 = Product::factory()->create([ + 'name' => 'Car 1', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $single1->increaseStock(1); + + $single2 = Product::factory()->create([ + 'name' => 'Car 2', + '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 for a FUTURE period (not yet active) + $single1->claimStock( + quantity: 1, + from: now()->addDays(5), + until: now()->addDays(10) + ); + + // Pool should show 2 available (both cars available NOW, claim starts in future) + $this->assertEquals(2, $pool->has_more); + } + + #[Test] + public function it_returns_php_int_max_for_pool_with_unmanaged_singles() + { + // Create pool product + $pool = Product::factory()->create([ + 'name' => 'Digital Products Pool', + 'type' => ProductType::POOL, + 'manage_stock' => false, + ]); + + // Create single items WITHOUT stock management (unlimited) + $single1 = Product::factory()->create([ + 'name' => 'Digital Item 1', + 'type' => ProductType::SIMPLE, + 'manage_stock' => false, + ]); + + $single2 = Product::factory()->create([ + 'name' => 'Digital Item 2', + 'type' => ProductType::SIMPLE, + 'manage_stock' => false, + ]); + + foreach ([$single1, $single2] as $single) { + $pool->productRelations()->attach($single->id, [ + 'type' => \Blax\Shop\Enums\ProductRelationType::SINGLE->value, + ]); + } + + // Pool with all unlimited singles should return a very large number + // (sum of PHP_INT_MAX values, which indicates unlimited availability) + $this->assertGreaterThanOrEqual(PHP_INT_MAX, $pool->has_more); + } + + #[Test] + public function it_returns_zero_for_empty_pool() + { + $pool = Product::factory()->create([ + 'name' => 'Empty Pool', + 'type' => ProductType::POOL, + 'manage_stock' => false, + ]); + + $this->assertEquals(0, $pool->has_more); + } + + #[Test] + public function it_returns_zero_when_all_stock_is_claimed() + { + $product = Product::factory()->withStocks(10)->create(); + + // Claim all 10 units + $product->claimStock( + quantity: 10, + from: now(), + until: now()->addDays(5) + ); + + $this->assertEquals(0, $product->has_more); + } + + #[Test] + public function it_correctly_handles_mixed_managed_and_unmanaged_pool_singles() + { + $pool = Product::factory()->create([ + 'name' => 'Mixed Pool', + 'type' => ProductType::POOL, + 'manage_stock' => false, + ]); + + // Single 1: managed stock + $single1 = Product::factory()->create([ + 'name' => 'Limited Item', + 'type' => ProductType::SIMPLE, + 'manage_stock' => true, + ]); + $single1->increaseStock(5); + + // Single 2: unmanaged stock (unlimited) + $single2 = Product::factory()->create([ + 'name' => 'Unlimited Item', + 'type' => ProductType::SIMPLE, + 'manage_stock' => false, + ]); + + foreach ([$single1, $single2] as $single) { + $pool->productRelations()->attach($single->id, [ + 'type' => \Blax\Shop\Enums\ProductRelationType::SINGLE->value, + ]); + } + + // Pool should sum: 5 (limited) + PHP_INT_MAX (unlimited) + // Result will be very large, indicating effectively unlimited availability + $this->assertGreaterThanOrEqual(PHP_INT_MAX, $pool->has_more); + } + + #[Test] + public function it_returns_correct_stock_after_multiple_increases_and_decreases() + { + $product = Product::factory()->create([ + 'manage_stock' => true, + ]); + + $product->increaseStock(100); + $product->decreaseStock(20); + $product->increaseStock(50); + $product->decreaseStock(30); + + // 100 - 20 + 50 - 30 = 100 + $this->assertEquals(100, $product->has_more); + } +}