IA booking checking & simplifications, A test/trait

This commit is contained in:
Fabian @ Blax Software 2025-12-18 16:54:33 +01:00
parent 7a26c9a965
commit c5b78071e7
6 changed files with 563 additions and 13 deletions

View File

@ -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).");
}

View File

@ -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;
}
/**

View File

@ -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);
}
/**

View File

@ -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(

View File

@ -0,0 +1,40 @@
<?php
namespace Blax\Shop\Traits;
use Blax\Shop\Enums\ProductType;
use Blax\Shop\Models\Product;
/**
* Trait to provide a unified way to check if something is booking-related.
*
* This trait provides the DRY principle for checking booking status across
* Product, CartItem, Cart, and ProductPurchase models.
*
* The rule is:
* - For regular products: is booking if type === ProductType::BOOKING
* - For pool products: is booking if at least one single item is a booking product
* - For cart items: is booking if the product is booking
* - For carts: is booking if at least one item is booking
* - For purchases: is booking if has from/until dates
*/
trait ChecksIfBooking
{
/**
* Check if a Product is a booking product.
* For pool products, checks if at least one single item is a booking product.
*
* @param Product $product
* @return bool
*/
protected function checkProductIsBooking(Product $product): bool
{
// For pool products, check if any single item is a booking product
if ($product->type === ProductType::POOL) {
return $product->hasBookingSingleItems();
}
// For regular products, check the type
return $product->type === ProductType::BOOKING;
}
}

View File

@ -0,0 +1,487 @@
<?php
namespace Blax\Shop\Tests\Feature;
use Blax\Shop\Enums\ProductRelationType;
use Blax\Shop\Enums\ProductType;
use Blax\Shop\Models\Cart;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductPrice;
use Blax\Shop\Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Workbench\App\Models\User;
class PoolBookingDetectionTest extends TestCase
{
use RefreshDatabase;
protected User $user;
protected function setUp(): void
{
parent::setUp();
$this->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()');
}
}