BF cart item pool calculation
This commit is contained in:
parent
620c0824de
commit
2f0d0757ee
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in New Issue