BF cart item pool calculation

This commit is contained in:
Fabian @ Blax Software 2025-12-17 10:41:52 +01:00
parent 620c0824de
commit 2f0d0757ee
4 changed files with 300 additions and 26 deletions

View File

@ -344,10 +344,10 @@ class Cart extends Model
// For pool products, check pricing strategy to determine merge behavior
$priceMatch = true;
if ($cartable instanceof Product && $cartable->isPool()) {
// For pools, always check if prices match to allow merging items with same price
$currentPrice = $cartable->getNextAvailablePoolPrice($currentQuantityInCart, null, $from, $until);
// For pools, use smart pricing that considers which tiers are used
$currentPrice = $cartable->getNextAvailablePoolPriceConsideringCart($this, null, $from, $until);
if (!$currentPrice) {
// Fallback to getCurrentPrice if getNextAvailablePoolPrice returns null (no single items)
// Fallback to getCurrentPrice if method returns null
$currentPrice = $cartable->getCurrentPrice();
}
if ($from && $until) {
@ -365,10 +365,9 @@ class Cart extends Model
// Calculate price per day (base price)
// For pool products, get price based on how many items are already in cart
if ($cartable instanceof Product && $cartable->isPool()) {
// Use the quantity we calculated earlier for consistency
// Get price for the next available item
$pricePerDay = $cartable->getNextAvailablePoolPrice($currentQuantityInCart, null, $from, $until);
$regularPricePerDay = $cartable->getNextAvailablePoolPrice($currentQuantityInCart, false, $from, $until) ?? $pricePerDay;
// Use smarter pricing that considers which price tiers are used
$pricePerDay = $cartable->getNextAvailablePoolPriceConsideringCart($this, null, $from, $until);
$regularPricePerDay = $cartable->getNextAvailablePoolPriceConsideringCart($this, false, $from, $until) ?? $pricePerDay;
// If no price found from pool items, try the pool's direct price as fallback
if ($pricePerDay === null && $cartable->hasPrice()) {
@ -439,11 +438,31 @@ class Cart extends Model
int $quantity = 1,
array $parameters = []
): CartItem|true {
$item = $this->items()
// If a CartItem is passed directly, handle it
if ($cartable instanceof CartItem) {
$item = $cartable;
if ($item->quantity > $quantity) {
// Decrease quantity
$newQuantity = $item->quantity - $quantity;
$item->update([
'quantity' => $newQuantity,
'subtotal' => $item->price * $newQuantity,
]);
} else {
// Remove item from cart
$item->delete();
}
return $item;
}
// Otherwise, find the cart item by purchasable
$items = $this->items()
->where('purchasable_id', $cartable->getKey())
->where('purchasable_type', get_class($cartable))
->get()
->first(function ($item) use ($parameters) {
->filter(function ($item) use ($parameters) {
$existingParams = is_array($item->parameters)
? $item->parameters
: (array) $item->parameters;
@ -452,13 +471,21 @@ class Cart extends Model
return $existingParams === $parameters;
});
if ($items->isEmpty()) {
return true;
}
// For pool products with multiple cart items at different prices,
// remove from the highest-priced item first (LIFO behavior)
$item = $items->sortByDesc('price')->first();
if ($item) {
if ($item->quantity > $quantity) {
// Decrease quantity
$newQuantity = $item->quantity - $quantity;
$item->update([
'quantity' => $newQuantity,
'subtotal' => ($cartable->getCurrentPrice()) * $newQuantity,
'subtotal' => $item->price * $newQuantity,
]);
} else {
// Remove item from cart

View File

@ -394,13 +394,8 @@ class Product extends Model implements Purchasable, Cartable
}
if ($cart) {
// Cart-aware: Get price for next available item after what's in cart
$currentQuantityInCart = $cart->items()
->where('purchasable_id', $this->getKey())
->where('purchasable_type', get_class($this))
->sum('quantity');
return $this->getNextAvailablePoolPrice($currentQuantityInCart, $sales_price);
// Cart-aware: Use smarter pricing that considers which price tiers are used
return $this->getNextAvailablePoolPriceConsideringCart($cart, $sales_price);
}
// No cart and no user: Get inherited price based on strategy (lowest/highest/average of ALL available items)

View File

@ -610,6 +610,179 @@ trait MayBePoolProduct
return null;
}
/**
* Get next available pool price considering which specific price tiers are already in the cart
* This is smarter than getNextAvailablePoolPrice because it tracks usage by price point
*
* @param \Blax\Shop\Models\Cart $cart The cart to check
* @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
* @return float|null
*/
public function getNextAvailablePoolPriceConsideringCart(
\Blax\Shop\Models\Cart $cart,
bool|null $sales_price = null,
?\DateTimeInterface $from = null,
?\DateTimeInterface $until = null
): ?float {
if (!$this->isPool()) {
return null;
}
$strategy = $this->getPricingStrategy();
$singleItems = $this->singleProducts;
if ($singleItems->isEmpty()) {
return null;
}
// Get cart items for this pool
$cartItems = $cart->items()
->where('purchasable_id', $this->getKey())
->where('purchasable_type', get_class($this))
->get();
// If no dates provided, try to extract from cart items
if (!$from && !$until) {
$firstItemWithDates = $cartItems->first(fn($item) => $item->from && $item->until);
if ($firstItemWithDates) {
$from = $firstItemWithDates->from;
$until = $firstItemWithDates->until;
}
}
// Calculate days for price normalization
$days = 1;
if ($from && $until) {
$days = max(1, $from->diff($until)->days);
}
// Build usage map: price => quantity used
$priceUsage = [];
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;
}
// Build available items list
$availableItems = [];
foreach ($singleItems as $item) {
$available = 0;
if ($from && $until) {
if ($item->isBooking()) {
if (!$item->manage_stock) {
$available = PHP_INT_MAX;
} else {
// Calculate overlapping claims
$overlappingClaims = $item->stocks()
->where('type', \Blax\Shop\Enums\StockType::CLAIMED->value)
->where('status', \Blax\Shop\Enums\StockStatus::PENDING->value)
->where(function ($query) use ($from, $until) {
$query->where(function ($q) use ($from, $until) {
$q->whereBetween('claimed_from', [$from, $until]);
})->orWhere(function ($q) use ($from, $until) {
$q->whereBetween('expires_at', [$from, $until]);
})->orWhere(function ($q) use ($from, $until) {
$q->where('claimed_from', '<=', $from)
->where('expires_at', '>=', $until);
})->orWhere(function ($q) use ($from, $until) {
$q->whereNull('claimed_from')
->where(function ($subQ) use ($from, $until) {
$subQ->whereNull('expires_at')
->orWhere('expires_at', '>=', $from);
});
});
})
->sum('quantity');
$available = max(0, $item->getAvailableStock() - abs($overlappingClaims));
}
} elseif (!$item->isBooking()) {
$available = $item->getAvailableStock();
}
} else {
if ($item->manage_stock) {
$available = $item->getAvailableStock();
} else {
$available = PHP_INT_MAX;
}
}
if ($available > 0) {
$price = $item->defaultPrice()->first()?->getCurrentPrice($sales_price ?? $item->isOnSale());
if ($price === null && $this->hasPrice()) {
$price = $this->defaultPrice()->first()?->getCurrentPrice($sales_price ?? $this->isOnSale());
}
if ($price !== null) {
$priceRounded = round($price, 2);
// Subtract quantity already used in cart at this price
$usedAtThisPrice = $priceUsage[$priceRounded] ?? 0;
$availableAtThisPrice = $available - $usedAtThisPrice;
if ($availableAtThisPrice > 0) {
$availableItems[] = [
'price' => $price,
'quantity' => $availableAtThisPrice,
'item' => $item,
];
}
}
}
}
// Also add pool's direct price if it has one
if ($this->hasPrice()) {
$poolPrice = $this->defaultPrice()->first()?->getCurrentPrice($sales_price ?? $this->isOnSale());
if ($poolPrice !== null) {
$poolPriceRounded = round($poolPrice, 2);
$usedAtPoolPrice = $priceUsage[$poolPriceRounded] ?? 0;
// Pool price is typically unlimited (doesn't manage stock)
if (!$this->manage_stock) {
$availableItems[] = [
'price' => $poolPrice,
'quantity' => PHP_INT_MAX,
'item' => $this,
];
}
}
}
if (empty($availableItems)) {
return null;
}
// For AVERAGE strategy, calculate weighted average of available items
if ($strategy === \Blax\Shop\Enums\PricingStrategy::AVERAGE) {
$totalPrice = 0;
$totalQuantity = 0;
foreach ($availableItems as $item) {
$qty = $item['quantity'] === PHP_INT_MAX ? 1 : $item['quantity'];
$totalPrice += $item['price'] * $qty;
$totalQuantity += $qty;
}
return $totalQuantity > 0 ? $totalPrice / $totalQuantity : null;
}
// Sort by strategy
usort($availableItems, function ($a, $b) use ($strategy) {
return match ($strategy) {
\Blax\Shop\Enums\PricingStrategy::LOWEST => $a['price'] <=> $b['price'],
\Blax\Shop\Enums\PricingStrategy::HIGHEST => $b['price'] <=> $a['price'],
\Blax\Shop\Enums\PricingStrategy::AVERAGE => 0,
};
});
// Return the first available item's price
return $availableItems[0]['price'] ?? null;
}
/**
* Attach single items to this pool product
* Also creates reverse POOL relation from single items back to this pool

View File

@ -1174,6 +1174,9 @@ class CartAddToCartPoolPricingTest extends TestCase
$spot3->id
]);
$from = now()->addWeek();
$until = now()->addWeek()->addDays(5); // 5 days
// Pool should have unlimited availability
$this->assertEquals(6, $pool->getAvailableQuantity());
@ -1192,27 +1195,32 @@ class CartAddToCartPoolPricingTest extends TestCase
$pool,
3,
[],
now()->addWeek(),
now()->addWeek()->addDays(5)
$from,
$until
);
$this->assertEquals(
(2000 * 2 * 5) + (5000 * 1 * 5),
$cart->getTotal()
);
$this->assertEquals(
5000,
$pool->getCurrentPrice()
);
$cart->addToCart(
$pool,
3,
[],
now()->addWeek(),
now()->addWeek()->addDays(5)
$from,
$until
);
$this->assertEquals(
(2000 * 2 * 5) + (5000 * 2 * 5) + (8000 * 2 * 5),
$cart->getTotal()
);
$this->assertNull($pool->getCurrentPrice());
$this->assertEquals(3, $cart->items()->count());
@ -1235,8 +1243,8 @@ class CartAddToCartPoolPricingTest extends TestCase
$pool,
6,
[],
now()->addWeek(),
now()->addWeek()->addDays(5)
$from,
$until
),
\Blax\Shop\Exceptions\NotEnoughStockException::class
);
@ -1245,8 +1253,79 @@ class CartAddToCartPoolPricingTest extends TestCase
$pool,
5,
[],
now()->addWeek(),
now()->addWeek()->addDays(5)
$from,
$until
);
$this->assertEquals(
(2000 * 2 * 5) + (5000 * 1 * 5) + (8000 * 2 * 5),
$cart->getTotal()
);
$cart->removeFromCart($pool, 1);
$this->assertEquals(
(2000 * 2 * 5) + (5000 * 1 * 5) + (8000 * 1 * 5),
$cart->getTotal()
);
$this->assertEquals(8000, $pool->getCurrentPrice());
$cart->removeFromCart($pool, 1);
$this->assertEquals(
(2000 * 2 * 5) + (5000 * 1 * 5),
$cart->getTotal()
);
// Get cart item with price 2000
$cartItem = $cart->items()
->orderBy('price', 'asc')
->first();
$cart->removeFromCart($cartItem, 1);
$this->assertEquals(
(2000 * 1 * 5) + (5000 * 1 * 5),
$cart->getTotal()
);
$this->assertEquals(
2000,
$pool->getCurrentPrice()
);
$cart->addToCart(
$pool,
1,
[],
$from,
$until
);
$this->assertEquals(
(2000 * 2 * 5) + (5000 * 1 * 5),
$cart->getTotal()
);
// Get cart item with price 2000
$cartItem = $cart->items()
->orderBy('price', 'asc')
->first();
$cart->removeFromCart($cartItem, 2);
$this->assertEquals(
(5000 * 1 * 5),
$cart->getTotal()
);
$this->assertEquals(2000, $pool->getCurrentPrice());
$cart->addToCart(
$pool,
4,
[],
$from,
$until
);
$this->assertEquals(