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

View File

@ -435,8 +435,9 @@ class CartItem extends Model
// Get current price per day
// Pass dates to ensure accurate pricing for pool products during date updates
$pricePerDay = $product->getCurrentPrice(null, $this->cart, $from, $until);
$regularPricePerDay = $product->getCurrentPrice(false, $this->cart, $from, $until) ?? $pricePerDay;
// Pass cart item ID to exclude this item from usage calculation
$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
$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 \DateTimeInterface|null $from Optional start 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
*/
public function getCurrentPrice(
bool|null $sales_price = null,
mixed $cart = null,
?\DateTimeInterface $from = null,
?\DateTimeInterface $until = null
?\DateTimeInterface $until = null,
string|int|null $excludeCartItemId = null
): ?float {
// If this is a pool product, use cart-aware pricing if cart is provided
if ($this->isPool()) {
@ -432,7 +434,7 @@ class Product extends Model implements Purchasable, Cartable
if ($cart) {
// Cart-aware: Use smarter pricing that considers which price tiers are used
// 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

View File

@ -139,6 +139,7 @@ trait MayBePoolProduct
$strategy = $this->getPricingStrategy();
// Build list of available single items with their prices
// IMPORTANT: Collect ALL available items first, then sort, to ensure correct pricing strategy order
$availableItems = [];
foreach ($singleItems as $item) {
if ($item->isAvailableForBooking($from, $until, 1)) {
@ -155,11 +156,6 @@ trait MayBePoolProduct
'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) {
@ -684,13 +680,15 @@ trait MayBePoolProduct
* @param bool|null $sales_price Whether to get sale price
* @param \DateTimeInterface|null $from Start 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]
*/
public function getNextAvailablePoolItemWithPrice(
\Blax\Shop\Models\Cart $cart,
bool|null $sales_price = null,
?\DateTimeInterface $from = null,
?\DateTimeInterface $until = null
?\DateTimeInterface $until = null,
string|int|null $excludeCartItemId = null
): ?array {
if (!$this->isPool()) {
return null;
@ -724,12 +722,36 @@ trait MayBePoolProduct
$days = $this->calculateBookingDays($from, $until);
}
// Build usage map: price => quantity used
$priceUsage = [];
// Build usage map: track which single items have been allocated
// 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) {
$pricePerDay = $item->price / $days;
$priceKey = round($pricePerDay, 2); // Round to avoid floating point issues
$priceUsage[$priceKey] = ($priceUsage[$priceKey] ?? 0) + $item->quantity;
// Skip the cart item being updated (if applicable)
if ($excludeCartItemId && $item->id === $excludeCartItemId) {
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
@ -749,16 +771,16 @@ trait MayBePoolProduct
}
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
$usedAtThisPrice = $priceUsage[$priceRounded] ?? 0;
$availableAtThisPrice = $available - $usedAtThisPrice;
if ($availableAtThisPrice > 0) {
if ($availableFromThisItem > 0) {
$availableItems[] = [
'price' => $price,
'quantity' => $availableAtThisPrice,
'quantity' => $availableFromThisItem,
'item' => $item,
'price_id' => $priceModel?->id,
];
@ -848,9 +870,10 @@ trait MayBePoolProduct
\Blax\Shop\Models\Cart $cart,
bool|null $sales_price = null,
?\DateTimeInterface $from = null,
?\DateTimeInterface $until = null
?\DateTimeInterface $until = null,
string|int|null $excludeCartItemId = null
): ?float {
$result = $this->getNextAvailablePoolItemWithPrice($cart, $sales_price, $from, $until);
$result = $this->getNextAvailablePoolItemWithPrice($cart, $sales_price, $from, $until, $excludeCartItemId);
return $result['price'] ?? null;
}
@ -945,6 +968,14 @@ trait MayBePoolProduct
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;
// Critical: No single items
@ -962,16 +993,14 @@ trait MayBePoolProduct
}
}
// Check stock management on single items
$itemsWithoutStock = $singleItems->filter(fn($item) => !$item->manage_stock);
if ($itemsWithoutStock->isNotEmpty()) {
$itemNames = $itemsWithoutStock->pluck('name')->toArray();
$errors[] = "Single items without stock management: " . implode(', ', $itemNames);
throw InvalidPoolConfigurationException::singleItemsWithoutStock($this->name, $itemNames);
}
// Note: Single items may or may not manage stock
// Items without stock management are treated as having unlimited availability
// This is acceptable - the pool just checks availability from each single item
// Check for items with zero stock
$itemsWithZeroStock = $singleItems->filter(fn($item) => $item->getAvailableStock() <= 0);
// Check for items with zero stock (only for items that manage stock)
$itemsWithZeroStock = $singleItems
->filter(fn($item) => $item->manage_stock) // Only check items that manage stock
->filter(fn($item) => $item->getAvailableStock() <= 0);
if ($itemsWithZeroStock->isNotEmpty()) {
$itemNames = $itemsWithZeroStock->pluck('name')->toArray();
$warnings[] = "Single items with zero stock: " . implode(', ', $itemNames);

View File

@ -120,6 +120,7 @@ class BookingTimespanValidationTest extends TestCase
$poolProduct = Product::factory()->create([
'name' => 'Kayak Fleet',
'type' => ProductType::POOL,
'manage_stock' => false, // Pool products never manage stock themselves
]);
$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
$poolProduct = Product::factory()->create([
'type' => ProductType::POOL,
'manage_stock' => false,
]);
ProductPrice::factory()->create([
@ -392,6 +394,7 @@ class BookingTimespanValidationTest extends TestCase
// Create pool with 2 single items
$poolProduct = Product::factory()->create([
'type' => ProductType::POOL,
'manage_stock' => false,
]);
$singleItem1 = Product::factory()->create([

View File

@ -257,7 +257,7 @@ class CartDateManagementTest extends TestCase
$cartFromDate = Carbon::now()->addDays(1);
$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);
$item->refresh();
@ -328,7 +328,7 @@ class CartDateManagementTest extends TestCase
$cartFromDate = Carbon::now()->addDays(1);
$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);
$item->refresh();
@ -364,7 +364,7 @@ class CartDateManagementTest extends TestCase
$cartFromDate = Carbon::now()->addDays(5);
$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);
$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)
$this->assertEquals(750, $cartItem1->price);
// Second booking may use next available pricing tier
$this->assertGreaterThanOrEqual(750, (int)$cartItem2->price);
// Second booking is on a different day (non-overlapping) so also uses lowest pricing
$this->assertEquals(750, (int)$cartItem2->price);
// Total should be reasonable for two 6-hour bookings
$this->assertGreaterThan(1500, $cart->getTotal());
// Total should be 750 + 750 = 1500 for two 6-hour bookings on different days
$this->assertEquals(1500, $cart->getTotal());
}
/** @test */

View File

@ -145,7 +145,10 @@ class PoolProductRelationsTest extends TestCase
public function legacy_manual_attach_still_works()
{
// 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]);
// 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);
// 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->assertEquals(2, $this->cart->items()->count());
$this->assertNotEquals($price1, $item2->price);