I pool cart support, tests

This commit is contained in:
Fabian @ Blax Software 2025-12-19 10:57:26 +01:00
parent 06b45cc9b3
commit f20637770f
9 changed files with 108 additions and 60 deletions

View File

@ -218,7 +218,8 @@ class Cart extends Model
public function setDates( public function setDates(
\DateTimeInterface|string|int|float $from, \DateTimeInterface|string|int|float $from,
\DateTimeInterface|string|int|float $until, \DateTimeInterface|string|int|float $until,
bool $validateAvailability = true bool $validateAvailability = true,
bool $overwrite_item_dates = true
): self { ): self {
// Parse string dates using Carbon // Parse string dates using Carbon
if (is_string($from) || is_numeric($from)) { if (is_string($from) || is_numeric($from)) {
@ -243,7 +244,10 @@ class Cart extends Model
]); ]);
// Update cart items with from/until // Update cart items with from/until
$this->applyDatesToItems($validateAvailability); $this->applyDatesToItems(
$validateAvailability,
$overwrite_item_dates
);
return $this->fresh(); return $this->fresh();
} }
@ -587,12 +591,22 @@ class Cart extends Model
// For pool products, calculate current quantity in cart once to ensure consistency // For pool products, calculate current quantity in cart once to ensure consistency
// Force fresh query to get latest cart state (important for recursive calls) // Force fresh query to get latest cart state (important for recursive calls)
$currentQuantityInCart = null; $currentQuantityInCart = null;
$poolSingleItem = null;
$poolPriceId = null;
if ($cartable instanceof Product && $cartable->isPool()) { if ($cartable instanceof Product && $cartable->isPool()) {
$this->unsetRelation('items'); // Clear cached relationship $this->unsetRelation('items'); // Clear cached relationship
$currentQuantityInCart = $this->items() $currentQuantityInCart = $this->items()
->where('purchasable_id', $cartable->getKey()) ->where('purchasable_id', $cartable->getKey())
->where('purchasable_type', get_class($cartable)) ->where('purchasable_type', get_class($cartable))
->sum('quantity'); ->sum('quantity');
// Pre-calculate pool pricing info for use in merge logic
$poolItemData = $cartable->getNextAvailablePoolItemWithPrice($this, null, $from, $until);
if ($poolItemData) {
$poolSingleItem = $poolItemData['item'];
$poolPriceId = $poolItemData['price_id'];
}
} }
// Check if item already exists in cart with same parameters, dates, AND price // Check if item already exists in cart with same parameters, dates, AND price
@ -600,7 +614,7 @@ class Cart extends Model
->where('purchasable_id', $cartable->getKey()) ->where('purchasable_id', $cartable->getKey())
->where('purchasable_type', get_class($cartable)) ->where('purchasable_type', get_class($cartable))
->get() ->get()
->first(function ($item) use ($parameters, $from, $until, $cartable, $currentQuantityInCart) { ->first(function ($item) use ($parameters, $from, $until, $cartable, $poolPriceId) {
$existingParams = is_array($item->parameters) $existingParams = is_array($item->parameters)
? $item->parameters ? $item->parameters
: (array) $item->parameters; : (array) $item->parameters;
@ -621,22 +635,19 @@ class Cart extends Model
); );
} }
// For pool products, check pricing strategy to determine merge behavior // For pool products, check if price_id matches to allow proper merging
// Pool items with the same price_id (from the same single item) can merge
// but items from different single items (different price_id) should NOT merge
// Also check that the actual price matches (important for AVERAGE strategy where price can change)
$priceMatch = true; $priceMatch = true;
if ($cartable instanceof Product && $cartable->isPool()) { if ($cartable instanceof Product && $cartable->isPool()) {
// For pools, use smart pricing that considers which tiers are used // Calculate expected price for this item
$currentPrice = $cartable->getNextAvailablePoolPriceConsideringCart($this, null, $from, $until); $poolItemData = $cartable->getNextAvailablePoolItemWithPrice($this, null, $from, $until);
if (!$currentPrice) { $expectedPrice = $poolItemData['price'] ?? null;
// Fallback to getCurrentPrice if method returns null
$currentPrice = $cartable->getCurrentPrice(); // Only merge if price_id matches AND the price amount matches
} $priceMatch = $poolPriceId && $item->price_id === $poolPriceId &&
if ($from && $until) { $expectedPrice !== null && $item->unit_amount === (int) round($expectedPrice);
$days = $this->calculateBookingDays($from, $until);
$currentPrice *= $days;
}
// Compare prices - merge if prices match
$priceMatch = abs((float)$item->price - $currentPrice) < 0.01;
} }
return $paramsMatch && $datesMatch && $priceMatch; return $paramsMatch && $datesMatch && $priceMatch;
@ -644,8 +655,6 @@ class Cart extends Model
// Calculate price per day (base price) // Calculate price per day (base price)
// For pool products, get price based on how many items are already in cart // For pool products, get price based on how many items are already in cart
$poolSingleItem = null;
$poolPriceId = null;
if ($cartable instanceof Product && $cartable->isPool()) { if ($cartable instanceof Product && $cartable->isPool()) {
// Use smarter pricing that considers which price tiers are used // Use smarter pricing that considers which price tiers are used
$poolItemData = $cartable->getNextAvailablePoolItemWithPrice($this, null, $from, $until); $poolItemData = $cartable->getNextAvailablePoolItemWithPrice($this, null, $from, $until);

View File

@ -435,8 +435,9 @@ class CartItem extends Model
// Get current price per day // Get current price per day
// Pass dates to ensure accurate pricing for pool products during date updates // Pass dates to ensure accurate pricing for pool products during date updates
$pricePerDay = $product->getCurrentPrice(null, $this->cart, $from, $until); // Pass cart item ID to exclude this item from usage calculation
$regularPricePerDay = $product->getCurrentPrice(false, $this->cart, $from, $until) ?? $pricePerDay; $pricePerDay = $product->getCurrentPrice(null, $this->cart, $from, $until, $this->id);
$regularPricePerDay = $product->getCurrentPrice(false, $this->cart, $from, $until, $this->id) ?? $pricePerDay;
// Store the base unit_amount (price for 1 quantity, 1 day) in cents // Store the base unit_amount (price for 1 quantity, 1 day) in cents
$unitAmount = (int) round($pricePerDay); $unitAmount = (int) round($pricePerDay);

View File

@ -401,13 +401,15 @@ class Product extends Model implements Purchasable, Cartable
* @param mixed $cart Optional cart instance (auto-resolved from session/user if not provided) * @param mixed $cart Optional cart instance (auto-resolved from session/user if not provided)
* @param \DateTimeInterface|null $from Optional start date for booking calculations * @param \DateTimeInterface|null $from Optional start date for booking calculations
* @param \DateTimeInterface|null $until Optional end date for booking calculations * @param \DateTimeInterface|null $until Optional end date for booking calculations
* @param string|int|null $excludeCartItemId Cart item ID to exclude from usage calculation (for date updates)
* @return float|null The current price, or null if unavailable * @return float|null The current price, or null if unavailable
*/ */
public function getCurrentPrice( public function getCurrentPrice(
bool|null $sales_price = null, bool|null $sales_price = null,
mixed $cart = null, mixed $cart = null,
?\DateTimeInterface $from = null, ?\DateTimeInterface $from = null,
?\DateTimeInterface $until = null ?\DateTimeInterface $until = null,
string|int|null $excludeCartItemId = null
): ?float { ): ?float {
// If this is a pool product, use cart-aware pricing if cart is provided // If this is a pool product, use cart-aware pricing if cart is provided
if ($this->isPool()) { if ($this->isPool()) {
@ -432,7 +434,7 @@ class Product extends Model implements Purchasable, Cartable
if ($cart) { if ($cart) {
// Cart-aware: Use smarter pricing that considers which price tiers are used // Cart-aware: Use smarter pricing that considers which price tiers are used
// This returns null if no items are available (all sold out) // This returns null if no items are available (all sold out)
return $this->getNextAvailablePoolPriceConsideringCart($cart, $sales_price, $from, $until); return $this->getNextAvailablePoolPriceConsideringCart($cart, $sales_price, $from, $until, $excludeCartItemId);
} }
// No cart: Get inherited price from single items // No cart: Get inherited price from single items

View File

@ -139,6 +139,7 @@ trait MayBePoolProduct
$strategy = $this->getPricingStrategy(); $strategy = $this->getPricingStrategy();
// Build list of available single items with their prices // Build list of available single items with their prices
// IMPORTANT: Collect ALL available items first, then sort, to ensure correct pricing strategy order
$availableItems = []; $availableItems = [];
foreach ($singleItems as $item) { foreach ($singleItems as $item) {
if ($item->isAvailableForBooking($from, $until, 1)) { if ($item->isAvailableForBooking($from, $until, 1)) {
@ -155,11 +156,6 @@ trait MayBePoolProduct
'price' => $price ?? PHP_FLOAT_MAX, // Items without prices go last 'price' => $price ?? PHP_FLOAT_MAX, // Items without prices go last
]; ];
} }
// Early exit if we have enough
if (count($availableItems) >= $quantity) {
break;
}
} }
if (count($availableItems) < $quantity) { if (count($availableItems) < $quantity) {
@ -684,13 +680,15 @@ trait MayBePoolProduct
* @param bool|null $sales_price Whether to get sale price * @param bool|null $sales_price Whether to get sale price
* @param \DateTimeInterface|null $from Start date for availability check * @param \DateTimeInterface|null $from Start date for availability check
* @param \DateTimeInterface|null $until End date for availability check * @param \DateTimeInterface|null $until End date for availability check
* @param string|int|null $excludeCartItemId Cart item ID to exclude from usage calculation (for date updates)
* @return array|null ['price' => float, 'item' => Product, 'price_id' => string|null] * @return array|null ['price' => float, 'item' => Product, 'price_id' => string|null]
*/ */
public function getNextAvailablePoolItemWithPrice( public function getNextAvailablePoolItemWithPrice(
\Blax\Shop\Models\Cart $cart, \Blax\Shop\Models\Cart $cart,
bool|null $sales_price = null, bool|null $sales_price = null,
?\DateTimeInterface $from = null, ?\DateTimeInterface $from = null,
?\DateTimeInterface $until = null ?\DateTimeInterface $until = null,
string|int|null $excludeCartItemId = null
): ?array { ): ?array {
if (!$this->isPool()) { if (!$this->isPool()) {
return null; return null;
@ -724,12 +722,36 @@ trait MayBePoolProduct
$days = $this->calculateBookingDays($from, $until); $days = $this->calculateBookingDays($from, $until);
} }
// Build usage map: price => quantity used // Build usage map: track which single items have been allocated
$priceUsage = []; // Use allocated_single_item_id from meta to track actual single item usage
// ONLY count items that overlap with the current booking period
// Exclude the specified cart item (if updating dates on existing item)
$singleItemUsage = []; // item_id => quantity used
foreach ($cartItems as $item) { foreach ($cartItems as $item) {
$pricePerDay = $item->price / $days; // Skip the cart item being updated (if applicable)
$priceKey = round($pricePerDay, 2); // Round to avoid floating point issues if ($excludeCartItemId && $item->id === $excludeCartItemId) {
$priceUsage[$priceKey] = ($priceUsage[$priceKey] ?? 0) + $item->quantity; continue;
}
// Only count this cart item if it overlaps with the current booking period
$overlaps = true;
if ($from && $until && $item->from && $item->until) {
// Check if the cart item's booking period overlaps with the current period
// No overlap if: cart item ends before current starts, or cart item starts after current ends
$overlaps = !(
$item->until < $from || // Cart item ends before current booking starts
$item->from > $until // Cart item starts after current booking ends
);
}
if ($overlaps) {
$meta = $item->getMeta();
$allocatedItemId = $meta->allocated_single_item_id ?? null;
if ($allocatedItemId) {
$singleItemUsage[$allocatedItemId] = ($singleItemUsage[$allocatedItemId] ?? 0) + $item->quantity;
}
}
} }
// Build available items list // Build available items list
@ -749,16 +771,16 @@ trait MayBePoolProduct
} }
if ($price !== null) { if ($price !== null) {
$priceRounded = round($price, 2); // Subtract quantity already allocated from THIS specific single item
$usedFromThisItem = $singleItemUsage[$item->id] ?? 0;
$availableFromThisItem = $available === PHP_INT_MAX
? PHP_INT_MAX
: max(0, $available - $usedFromThisItem);
// Subtract quantity already used in cart at this price if ($availableFromThisItem > 0) {
$usedAtThisPrice = $priceUsage[$priceRounded] ?? 0;
$availableAtThisPrice = $available - $usedAtThisPrice;
if ($availableAtThisPrice > 0) {
$availableItems[] = [ $availableItems[] = [
'price' => $price, 'price' => $price,
'quantity' => $availableAtThisPrice, 'quantity' => $availableFromThisItem,
'item' => $item, 'item' => $item,
'price_id' => $priceModel?->id, 'price_id' => $priceModel?->id,
]; ];
@ -848,9 +870,10 @@ trait MayBePoolProduct
\Blax\Shop\Models\Cart $cart, \Blax\Shop\Models\Cart $cart,
bool|null $sales_price = null, bool|null $sales_price = null,
?\DateTimeInterface $from = null, ?\DateTimeInterface $from = null,
?\DateTimeInterface $until = null ?\DateTimeInterface $until = null,
string|int|null $excludeCartItemId = null
): ?float { ): ?float {
$result = $this->getNextAvailablePoolItemWithPrice($cart, $sales_price, $from, $until); $result = $this->getNextAvailablePoolItemWithPrice($cart, $sales_price, $from, $until, $excludeCartItemId);
return $result['price'] ?? null; return $result['price'] ?? null;
} }
@ -945,6 +968,14 @@ trait MayBePoolProduct
throw InvalidPoolConfigurationException::notAPoolProduct($this->name); throw InvalidPoolConfigurationException::notAPoolProduct($this->name);
} }
// Critical: Pool products should NEVER manage stock themselves
// Stock is managed by individual single items only
if ($this->manage_stock) {
throw new InvalidPoolConfigurationException(
"Pool product '{$this->name}' has manage_stock=true. Pool products should never manage stock directly - only their single items manage stock."
);
}
$singleItems = $this->singleProducts; $singleItems = $this->singleProducts;
// Critical: No single items // Critical: No single items
@ -962,16 +993,14 @@ trait MayBePoolProduct
} }
} }
// Check stock management on single items // Note: Single items may or may not manage stock
$itemsWithoutStock = $singleItems->filter(fn($item) => !$item->manage_stock); // Items without stock management are treated as having unlimited availability
if ($itemsWithoutStock->isNotEmpty()) { // This is acceptable - the pool just checks availability from each single item
$itemNames = $itemsWithoutStock->pluck('name')->toArray();
$errors[] = "Single items without stock management: " . implode(', ', $itemNames);
throw InvalidPoolConfigurationException::singleItemsWithoutStock($this->name, $itemNames);
}
// Check for items with zero stock // Check for items with zero stock (only for items that manage stock)
$itemsWithZeroStock = $singleItems->filter(fn($item) => $item->getAvailableStock() <= 0); $itemsWithZeroStock = $singleItems
->filter(fn($item) => $item->manage_stock) // Only check items that manage stock
->filter(fn($item) => $item->getAvailableStock() <= 0);
if ($itemsWithZeroStock->isNotEmpty()) { if ($itemsWithZeroStock->isNotEmpty()) {
$itemNames = $itemsWithZeroStock->pluck('name')->toArray(); $itemNames = $itemsWithZeroStock->pluck('name')->toArray();
$warnings[] = "Single items with zero stock: " . implode(', ', $itemNames); $warnings[] = "Single items with zero stock: " . implode(', ', $itemNames);

View File

@ -120,6 +120,7 @@ class BookingTimespanValidationTest extends TestCase
$poolProduct = Product::factory()->create([ $poolProduct = Product::factory()->create([
'name' => 'Kayak Fleet', 'name' => 'Kayak Fleet',
'type' => ProductType::POOL, 'type' => ProductType::POOL,
'manage_stock' => false, // Pool products never manage stock themselves
]); ]);
$singleItem = Product::factory()->create([ $singleItem = Product::factory()->create([
@ -174,6 +175,7 @@ class BookingTimespanValidationTest extends TestCase
// Create a pool product with 2 single items so both bookings can succeed // Create a pool product with 2 single items so both bookings can succeed
$poolProduct = Product::factory()->create([ $poolProduct = Product::factory()->create([
'type' => ProductType::POOL, 'type' => ProductType::POOL,
'manage_stock' => false,
]); ]);
ProductPrice::factory()->create([ ProductPrice::factory()->create([
@ -392,6 +394,7 @@ class BookingTimespanValidationTest extends TestCase
// Create pool with 2 single items // Create pool with 2 single items
$poolProduct = Product::factory()->create([ $poolProduct = Product::factory()->create([
'type' => ProductType::POOL, 'type' => ProductType::POOL,
'manage_stock' => false,
]); ]);
$singleItem1 = Product::factory()->create([ $singleItem1 = Product::factory()->create([

View File

@ -257,7 +257,7 @@ class CartDateManagementTest extends TestCase
$cartFromDate = Carbon::now()->addDays(1); $cartFromDate = Carbon::now()->addDays(1);
$cartUntilDate = Carbon::now()->addDays(3); $cartUntilDate = Carbon::now()->addDays(3);
$cart->setDates($cartFromDate, $cartUntilDate, validateAvailability: false); $cart->setDates($cartFromDate, $cartUntilDate, validateAvailability: false, overwrite_item_dates: false);
$cart->applyDatesToItems(validateAvailability: false, overwrite: false); $cart->applyDatesToItems(validateAvailability: false, overwrite: false);
$item->refresh(); $item->refresh();
@ -328,7 +328,7 @@ class CartDateManagementTest extends TestCase
$cartFromDate = Carbon::now()->addDays(1); $cartFromDate = Carbon::now()->addDays(1);
$cartUntilDate = Carbon::now()->addDays(3); $cartUntilDate = Carbon::now()->addDays(3);
$cart->setDates($cartFromDate, $cartUntilDate, validateAvailability: false); $cart->setDates($cartFromDate, $cartUntilDate, validateAvailability: false, overwrite_item_dates: false);
$cart->applyDatesToItems(validateAvailability: false, overwrite: false); $cart->applyDatesToItems(validateAvailability: false, overwrite: false);
$item->refresh(); $item->refresh();
@ -364,7 +364,7 @@ class CartDateManagementTest extends TestCase
$cartFromDate = Carbon::now()->addDays(5); $cartFromDate = Carbon::now()->addDays(5);
$cartUntilDate = Carbon::now()->addDays(7); $cartUntilDate = Carbon::now()->addDays(7);
$cart->setDates($cartFromDate, $cartUntilDate, validateAvailability: false); $cart->setDates($cartFromDate, $cartUntilDate, validateAvailability: false, overwrite_item_dates: false);
$cart->applyDatesToItems(validateAvailability: false, overwrite: false); $cart->applyDatesToItems(validateAvailability: false, overwrite: false);
$item->refresh(); $item->refresh();

View File

@ -220,11 +220,11 @@ class PoolPerMinutePricingTest extends TestCase
// First booking uses lowest pricing: 3000 cents * 0.25 = 750 cents ($7.50) // First booking uses lowest pricing: 3000 cents * 0.25 = 750 cents ($7.50)
$this->assertEquals(750, $cartItem1->price); $this->assertEquals(750, $cartItem1->price);
// Second booking may use next available pricing tier // Second booking is on a different day (non-overlapping) so also uses lowest pricing
$this->assertGreaterThanOrEqual(750, (int)$cartItem2->price); $this->assertEquals(750, (int)$cartItem2->price);
// Total should be reasonable for two 6-hour bookings // Total should be 750 + 750 = 1500 for two 6-hour bookings on different days
$this->assertGreaterThan(1500, $cart->getTotal()); $this->assertEquals(1500, $cart->getTotal());
} }
/** @test */ /** @test */

View File

@ -145,7 +145,10 @@ class PoolProductRelationsTest extends TestCase
public function legacy_manual_attach_still_works() public function legacy_manual_attach_still_works()
{ {
// Test that old way of attaching still works (without reverse relation) // Test that old way of attaching still works (without reverse relation)
$pool = Product::factory()->create(['type' => ProductType::POOL]); $pool = Product::factory()->create([
'type' => ProductType::POOL,
'manage_stock' => false,
]);
$spot = Product::factory()->create(['type' => ProductType::BOOKING]); $spot = Product::factory()->create(['type' => ProductType::BOOKING]);
// Old way using productRelations()->attach() directly // Old way using productRelations()->attach() directly

View File

@ -123,6 +123,7 @@ class PoolSeparateCartItemsTest extends TestCase
$item2 = $this->cart->addToCart($this->pool, 1, [], $from, $until); $item2 = $this->cart->addToCart($this->pool, 1, [], $from, $until);
// Should create separate cart items because price is different // Should create separate cart items because price is different
// (Note: With AVERAGE strategy, price_id may be the same but price differs)
$this->assertNotEquals($item1->id, $item2->id); $this->assertNotEquals($item1->id, $item2->id);
$this->assertEquals(2, $this->cart->items()->count()); $this->assertEquals(2, $this->cart->items()->count());
$this->assertNotEquals($price1, $item2->price); $this->assertNotEquals($price1, $item2->price);