From c5b78071e7c286e1273e47cba878aacb5dd1ba43 Mon Sep 17 00:00:00 2001 From: "Fabian @ Blax Software" Date: Thu, 18 Dec 2025 16:54:33 +0100 Subject: [PATCH] IA booking checking & simplifications, A test/trait --- src/Models/Cart.php | 19 +- src/Models/CartItem.php | 15 +- src/Models/Product.php | 5 +- src/Services/CartService.php | 10 +- src/Traits/ChecksIfBooking.php | 40 ++ tests/Feature/PoolBookingDetectionTest.php | 487 +++++++++++++++++++++ 6 files changed, 563 insertions(+), 13 deletions(-) create mode 100644 src/Traits/ChecksIfBooking.php create mode 100644 tests/Feature/PoolBookingDetectionTest.php diff --git a/src/Models/Cart.php b/src/Models/Cart.php index c0f608b..19fa712 100644 --- a/src/Models/Cart.php +++ b/src/Models/Cart.php @@ -8,6 +8,7 @@ use Blax\Shop\Enums\ProductType; use Blax\Shop\Exceptions\InvalidDateRangeException; use Blax\Shop\Exceptions\NotEnoughAvailableInTimespanException; use Blax\Shop\Services\CartService; +use Blax\Shop\Traits\ChecksIfBooking; use Blax\Shop\Traits\HasBookingPriceCalculation; use Blax\Workkit\Traits\HasExpiration; use Carbon\Carbon; @@ -19,7 +20,7 @@ use Illuminate\Database\Eloquent\Relations\MorphTo; class Cart extends Model { - use HasUuids, HasExpiration, HasFactory, HasBookingPriceCalculation; + use HasUuids, HasExpiration, HasFactory, HasBookingPriceCalculation, ChecksIfBooking; protected $fillable = [ 'session_id', @@ -106,6 +107,18 @@ class Cart extends Model return $this->items->every(fn($item) => $item->is_booking); } + /** + * Check if the cart contains at least one booking item + */ + public function isBooking(): bool + { + if ($this->items->isEmpty()) { + return false; + } + + return $this->items->contains(fn($item) => $item->is_booking); + } + /** * Get count of booking items in the cart */ @@ -513,7 +526,7 @@ class Cart extends Model } // Check booking product availability if dates are provided - if ($cartable->isBooking() && !$cartable->isAvailableForBooking($from, $until, $quantity)) { + if ($cartable->isBooking() && !$cartable->isPool() && !$cartable->isAvailableForBooking($from, $until, $quantity)) { throw new \Blax\Shop\Exceptions\NotEnoughStockException( "Product '{$cartable->name}' is not available for the requested period ({$from->format('Y-m-d')} to {$until->format('Y-m-d')})." ); @@ -901,7 +914,7 @@ class Cart extends Model } // Validate booking products have required dates - if ($product instanceof Product && $product->isBooking() && (!$from || !$until)) { + if ($product instanceof Product && $product->isBooking() && !$product->isPool() && (!$from || !$until)) { throw new \Exception("Booking product '{$product->name}' requires a timespan (from/until dates)."); } diff --git a/src/Models/CartItem.php b/src/Models/CartItem.php index 0caa4c5..ab5e7c4 100644 --- a/src/Models/CartItem.php +++ b/src/Models/CartItem.php @@ -3,6 +3,7 @@ namespace Blax\Shop\Models; use Blax\Shop\Exceptions\InvalidDateRangeException; +use Blax\Shop\Traits\ChecksIfBooking; use Blax\Shop\Traits\HasBookingPriceCalculation; use Blax\Workkit\Traits\HasMeta; use Illuminate\Database\Eloquent\Concerns\HasUuids; @@ -11,7 +12,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; class CartItem extends Model { - use HasUuids, HasMeta, HasBookingPriceCalculation; + use HasUuids, HasMeta, HasBookingPriceCalculation, ChecksIfBooking; protected $fillable = [ 'cart_id', @@ -128,7 +129,7 @@ class CartItem extends Model // Fallback: check purchasable directly if no price_id if ($this->purchasable_type === config('shop.models.product', Product::class)) { $product = $this->purchasable; - return $product && $product->isBooking(); + return $product && $this->checkProductIsBooking($product); } return false; } @@ -144,7 +145,15 @@ class CartItem extends Model return false; } - return $product->isBooking(); + return $this->checkProductIsBooking($product); + } + + /** + * Check if this cart item is for a booking product (method alias) + */ + public function isBooking(): bool + { + return $this->is_booking; } /** diff --git a/src/Models/Product.php b/src/Models/Product.php index b3d88c2..66da406 100644 --- a/src/Models/Product.php +++ b/src/Models/Product.php @@ -17,6 +17,7 @@ use Blax\Shop\Exceptions\HasNoPriceException; use Blax\Shop\Exceptions\InvalidBookingConfigurationException; use Blax\Shop\Exceptions\InvalidPoolConfigurationException; use Blax\Shop\Services\CartService; +use Blax\Shop\Traits\ChecksIfBooking; use Blax\Shop\Traits\HasCategories; use Blax\Shop\Traits\HasPrices; use Blax\Shop\Traits\HasPricingStrategy; @@ -32,7 +33,7 @@ use Illuminate\Support\Facades\Cache; class Product extends Model implements Purchasable, Cartable { - use HasFactory, HasUuids, HasMetaTranslation, HasStocks, HasPrices, HasPricingStrategy, HasCategories, HasProductRelations, MayBePoolProduct; + use HasFactory, HasUuids, HasMetaTranslation, HasStocks, HasPrices, HasPricingStrategy, HasCategories, HasProductRelations, MayBePoolProduct, ChecksIfBooking; protected $fillable = [ 'slug', @@ -317,7 +318,7 @@ class Product extends Model implements Purchasable, Cartable */ public function isBooking(): bool { - return $this->type === ProductType::BOOKING; + return $this->checkProductIsBooking($this); } /** diff --git a/src/Services/CartService.php b/src/Services/CartService.php index ef42b9b..6e7643a 100644 --- a/src/Services/CartService.php +++ b/src/Services/CartService.php @@ -382,7 +382,7 @@ class CartService } // Check if booking product has timespan - if ($product->isBooking() && (!$item->from || !$item->until)) { + if ($product->isBooking() && !$product->isPool() && (!$item->from || !$item->until)) { $errors[] = "Booking product '{$product->name}' requires a timespan (from/until dates)."; continue; } @@ -408,7 +408,7 @@ class CartService } // Validate stock availability for booking period - if ($product->isBooking() && $item->from && $item->until) { + if ($product->isBooking() && !$product->isPool() && $item->from && $item->until) { if (!$product->isAvailableForBooking($item->from, $item->until, $item->quantity)) { $errors[] = "'{$product->name}' is not available for the selected period (insufficient stock)."; } @@ -493,8 +493,8 @@ class CartService // Validate pricing before adding to cart $product->validatePricing(throwExceptions: true); - // Validate booking product configuration - if ($product->isBooking()) { + // Validate booking product configuration (but not for pools) + if ($product->isBooking() && !$product->isPool()) { $product->validateBookingConfiguration(); } @@ -503,7 +503,7 @@ class CartService $product->validatePoolConfiguration(); } } // Check availability - if ($product instanceof Product && $product->isBooking()) { + if ($product instanceof Product && $product->isBooking() && !$product->isPool()) { if (!$product->isAvailableForBooking($from, $until, $quantity)) { $available = $product->getAvailableStock(); throw InvalidBookingConfigurationException::notAvailableForPeriod( diff --git a/src/Traits/ChecksIfBooking.php b/src/Traits/ChecksIfBooking.php new file mode 100644 index 0000000..d837c6d --- /dev/null +++ b/src/Traits/ChecksIfBooking.php @@ -0,0 +1,40 @@ +type === ProductType::POOL) { + return $product->hasBookingSingleItems(); + } + + // For regular products, check the type + return $product->type === ProductType::BOOKING; + } +} diff --git a/tests/Feature/PoolBookingDetectionTest.php b/tests/Feature/PoolBookingDetectionTest.php new file mode 100644 index 0000000..8da2034 --- /dev/null +++ b/tests/Feature/PoolBookingDetectionTest.php @@ -0,0 +1,487 @@ +user = User::factory()->create(); + } + + /** @test */ + public function pool_product_with_booking_items_is_detected_as_booking() + { + // Create pool + $pool = Product::factory()->create([ + 'name' => 'Parking Pool', + 'type' => ProductType::POOL, + 'manage_stock' => false, + ]); + + // Create booking single items + $spot1 = Product::factory()->create([ + 'name' => 'Spot 1', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $spot1->increaseStock(1); + + $spot2 = Product::factory()->create([ + 'name' => 'Spot 2', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $spot2->increaseStock(1); + + // Link booking items to pool + $pool->productRelations()->attach($spot1->id, [ + 'type' => ProductRelationType::SINGLE->value, + ]); + $pool->productRelations()->attach($spot2->id, [ + 'type' => ProductRelationType::SINGLE->value, + ]); + + // Test: Pool should be detected as booking + $this->assertTrue($pool->isBooking(), 'Pool with booking items should be detected as booking'); + $this->assertTrue($pool->hasBookingSingleItems(), 'Pool should have booking single items'); + } + + /** @test */ + public function pool_product_without_booking_items_is_not_booking() + { + // Create pool + $pool = Product::factory()->create([ + 'name' => 'Simple Pool', + 'type' => ProductType::POOL, + 'manage_stock' => false, + ]); + + // Create simple (non-booking) single items + $item1 = Product::factory()->create([ + 'name' => 'Item 1', + 'type' => ProductType::SIMPLE, + 'manage_stock' => true, + ]); + $item1->increaseStock(10); + + $item2 = Product::factory()->create([ + 'name' => 'Item 2', + 'type' => ProductType::SIMPLE, + 'manage_stock' => true, + ]); + $item2->increaseStock(10); + + // Link simple items to pool + $pool->productRelations()->attach($item1->id, [ + 'type' => ProductRelationType::SINGLE->value, + ]); + $pool->productRelations()->attach($item2->id, [ + 'type' => ProductRelationType::SINGLE->value, + ]); + + // Test: Pool should NOT be detected as booking + $this->assertFalse($pool->isBooking(), 'Pool without booking items should not be detected as booking'); + $this->assertFalse($pool->hasBookingSingleItems(), 'Pool should not have booking single items'); + } + + /** @test */ + public function pool_cart_item_with_booking_items_is_detected_as_booking() + { + // Create pool + $pool = Product::factory()->create([ + 'name' => 'Parking Pool', + 'type' => ProductType::POOL, + 'manage_stock' => false, + ]); + + // Create price for pool + $poolPrice = ProductPrice::factory()->create([ + 'purchasable_id' => $pool->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 2000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + // Create booking single items + $spot1 = Product::factory()->create([ + 'name' => 'Spot 1', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $spot1->increaseStock(1); + + // Link booking items to pool + $pool->productRelations()->attach($spot1->id, [ + 'type' => ProductRelationType::SINGLE->value, + ]); + + // Create cart and add pool product + $cart = Cart::factory()->create([ + 'customer_id' => $this->user->id, + 'customer_type' => get_class($this->user), + ]); + + $cartItem = $cart->items()->create([ + 'purchasable_id' => $pool->id, + 'purchasable_type' => Product::class, + 'price_id' => $poolPrice->id, + 'quantity' => 1, + 'price' => 2000, + 'regular_price' => 2000, + 'unit_amount' => 2000, + ]); + + // Test: Cart item should be detected as booking + $this->assertTrue($cartItem->is_booking, 'Cart item for pool with booking items should be detected as booking'); + } + + /** @test */ + public function pool_cart_item_without_booking_items_is_not_booking() + { + // Create pool + $pool = Product::factory()->create([ + 'name' => 'Simple Pool', + 'type' => ProductType::POOL, + 'manage_stock' => false, + ]); + + // Create price for pool + $poolPrice = ProductPrice::factory()->create([ + 'purchasable_id' => $pool->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 1000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + // Create simple (non-booking) single items + $item1 = Product::factory()->create([ + 'name' => 'Item 1', + 'type' => ProductType::SIMPLE, + 'manage_stock' => true, + ]); + $item1->increaseStock(10); + + // Link simple items to pool + $pool->productRelations()->attach($item1->id, [ + 'type' => ProductRelationType::SINGLE->value, + ]); + + // Create cart and add pool product + $cart = Cart::factory()->create([ + 'customer_id' => $this->user->id, + 'customer_type' => get_class($this->user), + ]); + + $cartItem = $cart->items()->create([ + 'purchasable_id' => $pool->id, + 'purchasable_type' => Product::class, + 'price_id' => $poolPrice->id, + 'quantity' => 1, + 'price' => 1000, + 'regular_price' => 1000, + 'unit_amount' => 1000, + ]); + + // Test: Cart item should NOT be detected as booking + $this->assertFalse($cartItem->is_booking, 'Cart item for pool without booking items should not be detected as booking'); + } + + /** @test */ + public function cart_with_pool_booking_items_detects_booking_correctly() + { + // Create pool with booking items + $pool = Product::factory()->create([ + 'name' => 'Parking Pool', + 'type' => ProductType::POOL, + 'manage_stock' => false, + ]); + + $poolPrice = ProductPrice::factory()->create([ + 'purchasable_id' => $pool->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 2000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + $spot1 = Product::factory()->create([ + 'name' => 'Spot 1', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $spot1->increaseStock(1); + + $pool->productRelations()->attach($spot1->id, [ + 'type' => ProductRelationType::SINGLE->value, + ]); + + // Create cart and add pool product + $cart = Cart::factory()->create([ + 'customer_id' => $this->user->id, + 'customer_type' => get_class($this->user), + ]); + + $cart->items()->create([ + 'purchasable_id' => $pool->id, + 'purchasable_type' => Product::class, + 'price_id' => $poolPrice->id, + 'quantity' => 1, + 'price' => 2000, + 'regular_price' => 2000, + 'unit_amount' => 2000, + ]); + + // Test: Cart should be detected as full booking + $this->assertTrue($cart->is_full_booking, 'Cart with pool booking items should be detected as full booking'); + $this->assertEquals(1, $cart->bookingItems(), 'Cart should have 1 booking item'); + } + + /** @test */ + public function mixed_cart_with_pool_and_regular_items_not_full_booking() + { + // Create pool with booking items + $pool = Product::factory()->create([ + 'name' => 'Parking Pool', + 'type' => ProductType::POOL, + 'manage_stock' => false, + ]); + + $poolPrice = ProductPrice::factory()->create([ + 'purchasable_id' => $pool->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 2000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + $spot1 = Product::factory()->create([ + 'name' => 'Spot 1', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $spot1->increaseStock(1); + + $pool->productRelations()->attach($spot1->id, [ + 'type' => ProductRelationType::SINGLE->value, + ]); + + // Create regular product + $regularProduct = Product::factory()->create([ + 'name' => 'Regular Product', + 'type' => ProductType::SIMPLE, + ]); + + $regularPrice = ProductPrice::factory()->create([ + 'purchasable_id' => $regularProduct->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 1000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + // Create cart and add both products + $cart = Cart::factory()->create([ + 'customer_id' => $this->user->id, + 'customer_type' => get_class($this->user), + ]); + + $cart->items()->create([ + 'purchasable_id' => $pool->id, + 'purchasable_type' => Product::class, + 'price_id' => $poolPrice->id, + 'quantity' => 1, + 'price' => 2000, + 'regular_price' => 2000, + 'unit_amount' => 2000, + ]); + + $cart->items()->create([ + 'purchasable_id' => $regularProduct->id, + 'purchasable_type' => Product::class, + 'price_id' => $regularPrice->id, + 'quantity' => 1, + 'price' => 1000, + 'regular_price' => 1000, + 'unit_amount' => 1000, + ]); + + // Test: Cart should NOT be full booking + $this->assertFalse($cart->is_full_booking, 'Cart with mixed products should not be full booking'); + $this->assertEquals(1, $cart->bookingItems(), 'Cart should have 1 booking item'); + } + + /** @test */ + public function cart_item_isBooking_method_works() + { + // Create pool with booking items + $pool = Product::factory()->create([ + 'name' => 'Parking Pool', + 'type' => ProductType::POOL, + 'manage_stock' => false, + ]); + + $poolPrice = ProductPrice::factory()->create([ + 'purchasable_id' => $pool->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 2000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + $spot1 = Product::factory()->create([ + 'name' => 'Spot 1', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $spot1->increaseStock(1); + + $pool->productRelations()->attach($spot1->id, [ + 'type' => ProductRelationType::SINGLE->value, + ]); + + // Create cart and add pool product + $cart = Cart::factory()->create([ + 'customer_id' => $this->user->id, + 'customer_type' => get_class($this->user), + ]); + + $cartItem = $cart->items()->create([ + 'purchasable_id' => $pool->id, + 'purchasable_type' => Product::class, + 'price_id' => $poolPrice->id, + 'quantity' => 1, + 'price' => 2000, + 'regular_price' => 2000, + 'unit_amount' => 2000, + ]); + + // Test: isBooking() method should work + $this->assertTrue($cartItem->isBooking(), 'CartItem isBooking() method should return true for booking items'); + $this->assertEquals($cartItem->is_booking, $cartItem->isBooking(), 'isBooking() should match is_booking attribute'); + } + + /** @test */ + public function cart_isBooking_method_works() + { + // Create pool with booking items + $pool = Product::factory()->create([ + 'name' => 'Parking Pool', + 'type' => ProductType::POOL, + 'manage_stock' => false, + ]); + + $poolPrice = ProductPrice::factory()->create([ + 'purchasable_id' => $pool->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 2000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + $spot1 = Product::factory()->create([ + 'name' => 'Spot 1', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $spot1->increaseStock(1); + + $pool->productRelations()->attach($spot1->id, [ + 'type' => ProductRelationType::SINGLE->value, + ]); + + // Create regular product + $regularProduct = Product::factory()->create([ + 'name' => 'Regular Product', + 'type' => ProductType::SIMPLE, + ]); + + $regularPrice = ProductPrice::factory()->create([ + 'purchasable_id' => $regularProduct->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 1000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + // Test with empty cart + $emptyCart = Cart::factory()->create([ + 'customer_id' => $this->user->id, + 'customer_type' => get_class($this->user), + ]); + $this->assertFalse($emptyCart->isBooking(), 'Empty cart should return false for isBooking()'); + + // Test with booking item + $bookingCart = Cart::factory()->create([ + 'customer_id' => $this->user->id, + 'customer_type' => get_class($this->user), + ]); + $bookingCart->items()->create([ + 'purchasable_id' => $pool->id, + 'purchasable_type' => Product::class, + 'price_id' => $poolPrice->id, + 'quantity' => 1, + 'price' => 2000, + 'regular_price' => 2000, + 'unit_amount' => 2000, + ]); + $this->assertTrue($bookingCart->isBooking(), 'Cart with booking items should return true for isBooking()'); + + // Test with mixed cart + $mixedCart = Cart::factory()->create([ + 'customer_id' => $this->user->id, + 'customer_type' => get_class($this->user), + ]); + $mixedCart->items()->create([ + 'purchasable_id' => $pool->id, + 'purchasable_type' => Product::class, + 'price_id' => $poolPrice->id, + 'quantity' => 1, + 'price' => 2000, + 'regular_price' => 2000, + 'unit_amount' => 2000, + ]); + $mixedCart->items()->create([ + 'purchasable_id' => $regularProduct->id, + 'purchasable_type' => Product::class, + 'price_id' => $regularPrice->id, + 'quantity' => 1, + 'price' => 1000, + 'regular_price' => 1000, + 'unit_amount' => 1000, + ]); + $this->assertTrue($mixedCart->isBooking(), 'Cart with mixed items should return true for isBooking() if it contains at least one booking'); + + // Test with only regular product + $regularCart = Cart::factory()->create([ + 'customer_id' => $this->user->id, + 'customer_type' => get_class($this->user), + ]); + $regularCart->items()->create([ + 'purchasable_id' => $regularProduct->id, + 'purchasable_type' => Product::class, + 'price_id' => $regularPrice->id, + 'quantity' => 1, + 'price' => 1000, + 'regular_price' => 1000, + 'unit_amount' => 1000, + ]); + $this->assertFalse($regularCart->isBooking(), 'Cart with only regular items should return false for isBooking()'); + } +}