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
|
// For pool products, check pricing strategy to determine merge behavior
|
||||||
$priceMatch = true;
|
$priceMatch = true;
|
||||||
if ($cartable instanceof Product && $cartable->isPool()) {
|
if ($cartable instanceof Product && $cartable->isPool()) {
|
||||||
// For pools, always check if prices match to allow merging items with same price
|
// For pools, use smart pricing that considers which tiers are used
|
||||||
$currentPrice = $cartable->getNextAvailablePoolPrice($currentQuantityInCart, null, $from, $until);
|
$currentPrice = $cartable->getNextAvailablePoolPriceConsideringCart($this, null, $from, $until);
|
||||||
if (!$currentPrice) {
|
if (!$currentPrice) {
|
||||||
// Fallback to getCurrentPrice if getNextAvailablePoolPrice returns null (no single items)
|
// Fallback to getCurrentPrice if method returns null
|
||||||
$currentPrice = $cartable->getCurrentPrice();
|
$currentPrice = $cartable->getCurrentPrice();
|
||||||
}
|
}
|
||||||
if ($from && $until) {
|
if ($from && $until) {
|
||||||
|
|
@ -365,10 +365,9 @@ 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
|
||||||
if ($cartable instanceof Product && $cartable->isPool()) {
|
if ($cartable instanceof Product && $cartable->isPool()) {
|
||||||
// Use the quantity we calculated earlier for consistency
|
// Use smarter pricing that considers which price tiers are used
|
||||||
// Get price for the next available item
|
$pricePerDay = $cartable->getNextAvailablePoolPriceConsideringCart($this, null, $from, $until);
|
||||||
$pricePerDay = $cartable->getNextAvailablePoolPrice($currentQuantityInCart, null, $from, $until);
|
$regularPricePerDay = $cartable->getNextAvailablePoolPriceConsideringCart($this, false, $from, $until) ?? $pricePerDay;
|
||||||
$regularPricePerDay = $cartable->getNextAvailablePoolPrice($currentQuantityInCart, false, $from, $until) ?? $pricePerDay;
|
|
||||||
|
|
||||||
// If no price found from pool items, try the pool's direct price as fallback
|
// If no price found from pool items, try the pool's direct price as fallback
|
||||||
if ($pricePerDay === null && $cartable->hasPrice()) {
|
if ($pricePerDay === null && $cartable->hasPrice()) {
|
||||||
|
|
@ -439,11 +438,31 @@ class Cart extends Model
|
||||||
int $quantity = 1,
|
int $quantity = 1,
|
||||||
array $parameters = []
|
array $parameters = []
|
||||||
): CartItem|true {
|
): 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_id', $cartable->getKey())
|
||||||
->where('purchasable_type', get_class($cartable))
|
->where('purchasable_type', get_class($cartable))
|
||||||
->get()
|
->get()
|
||||||
->first(function ($item) use ($parameters) {
|
->filter(function ($item) use ($parameters) {
|
||||||
$existingParams = is_array($item->parameters)
|
$existingParams = is_array($item->parameters)
|
||||||
? $item->parameters
|
? $item->parameters
|
||||||
: (array) $item->parameters;
|
: (array) $item->parameters;
|
||||||
|
|
@ -452,13 +471,21 @@ class Cart extends Model
|
||||||
return $existingParams === $parameters;
|
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) {
|
||||||
if ($item->quantity > $quantity) {
|
if ($item->quantity > $quantity) {
|
||||||
// Decrease quantity
|
// Decrease quantity
|
||||||
$newQuantity = $item->quantity - $quantity;
|
$newQuantity = $item->quantity - $quantity;
|
||||||
$item->update([
|
$item->update([
|
||||||
'quantity' => $newQuantity,
|
'quantity' => $newQuantity,
|
||||||
'subtotal' => ($cartable->getCurrentPrice()) * $newQuantity,
|
'subtotal' => $item->price * $newQuantity,
|
||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
// Remove item from cart
|
// Remove item from cart
|
||||||
|
|
|
||||||
|
|
@ -394,13 +394,8 @@ class Product extends Model implements Purchasable, Cartable
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($cart) {
|
if ($cart) {
|
||||||
// Cart-aware: Get price for next available item after what's in cart
|
// Cart-aware: Use smarter pricing that considers which price tiers are used
|
||||||
$currentQuantityInCart = $cart->items()
|
return $this->getNextAvailablePoolPriceConsideringCart($cart, $sales_price);
|
||||||
->where('purchasable_id', $this->getKey())
|
|
||||||
->where('purchasable_type', get_class($this))
|
|
||||||
->sum('quantity');
|
|
||||||
|
|
||||||
return $this->getNextAvailablePoolPrice($currentQuantityInCart, $sales_price);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// No cart and no user: Get inherited price based on strategy (lowest/highest/average of ALL available items)
|
// 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;
|
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
|
* Attach single items to this pool product
|
||||||
* Also creates reverse POOL relation from single items back to this pool
|
* Also creates reverse POOL relation from single items back to this pool
|
||||||
|
|
|
||||||
|
|
@ -1174,6 +1174,9 @@ class CartAddToCartPoolPricingTest extends TestCase
|
||||||
$spot3->id
|
$spot3->id
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$from = now()->addWeek();
|
||||||
|
$until = now()->addWeek()->addDays(5); // 5 days
|
||||||
|
|
||||||
// Pool should have unlimited availability
|
// Pool should have unlimited availability
|
||||||
$this->assertEquals(6, $pool->getAvailableQuantity());
|
$this->assertEquals(6, $pool->getAvailableQuantity());
|
||||||
|
|
||||||
|
|
@ -1192,27 +1195,32 @@ class CartAddToCartPoolPricingTest extends TestCase
|
||||||
$pool,
|
$pool,
|
||||||
3,
|
3,
|
||||||
[],
|
[],
|
||||||
now()->addWeek(),
|
$from,
|
||||||
now()->addWeek()->addDays(5)
|
$until
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->assertEquals(
|
$this->assertEquals(
|
||||||
(2000 * 2 * 5) + (5000 * 1 * 5),
|
(2000 * 2 * 5) + (5000 * 1 * 5),
|
||||||
$cart->getTotal()
|
$cart->getTotal()
|
||||||
);
|
);
|
||||||
|
$this->assertEquals(
|
||||||
|
5000,
|
||||||
|
$pool->getCurrentPrice()
|
||||||
|
);
|
||||||
|
|
||||||
$cart->addToCart(
|
$cart->addToCart(
|
||||||
$pool,
|
$pool,
|
||||||
3,
|
3,
|
||||||
[],
|
[],
|
||||||
now()->addWeek(),
|
$from,
|
||||||
now()->addWeek()->addDays(5)
|
$until
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->assertEquals(
|
$this->assertEquals(
|
||||||
(2000 * 2 * 5) + (5000 * 2 * 5) + (8000 * 2 * 5),
|
(2000 * 2 * 5) + (5000 * 2 * 5) + (8000 * 2 * 5),
|
||||||
$cart->getTotal()
|
$cart->getTotal()
|
||||||
);
|
);
|
||||||
|
$this->assertNull($pool->getCurrentPrice());
|
||||||
|
|
||||||
$this->assertEquals(3, $cart->items()->count());
|
$this->assertEquals(3, $cart->items()->count());
|
||||||
|
|
||||||
|
|
@ -1235,8 +1243,8 @@ class CartAddToCartPoolPricingTest extends TestCase
|
||||||
$pool,
|
$pool,
|
||||||
6,
|
6,
|
||||||
[],
|
[],
|
||||||
now()->addWeek(),
|
$from,
|
||||||
now()->addWeek()->addDays(5)
|
$until
|
||||||
),
|
),
|
||||||
\Blax\Shop\Exceptions\NotEnoughStockException::class
|
\Blax\Shop\Exceptions\NotEnoughStockException::class
|
||||||
);
|
);
|
||||||
|
|
@ -1245,8 +1253,79 @@ class CartAddToCartPoolPricingTest extends TestCase
|
||||||
$pool,
|
$pool,
|
||||||
5,
|
5,
|
||||||
[],
|
[],
|
||||||
now()->addWeek(),
|
$from,
|
||||||
now()->addWeek()->addDays(5)
|
$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(
|
$this->assertEquals(
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue