BF cart items
This commit is contained in:
parent
1398fd0c27
commit
816e8661e2
|
|
@ -2,7 +2,7 @@ name: Tests
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ master ]
|
branches: [ testbranch ]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ master, dev ]
|
branches: [ master, dev ]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -316,6 +316,14 @@ class Cart extends Model
|
||||||
if ($validateAvailability) {
|
if ($validateAvailability) {
|
||||||
$this->validateDateAvailability($calcFrom, $calcUntil);
|
$this->validateDateAvailability($calcFrom, $calcUntil);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update cart items with new dates and recalculate prices
|
||||||
|
$this->applyDatesToItems(
|
||||||
|
$validateAvailability,
|
||||||
|
true,
|
||||||
|
$calcFrom,
|
||||||
|
$calcUntil
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->fresh();
|
return $this->fresh();
|
||||||
|
|
@ -353,6 +361,14 @@ class Cart extends Model
|
||||||
if ($validateAvailability) {
|
if ($validateAvailability) {
|
||||||
$this->validateDateAvailability($calcFrom, $calcUntil);
|
$this->validateDateAvailability($calcFrom, $calcUntil);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update cart items with new dates and recalculate prices
|
||||||
|
$this->applyDatesToItems(
|
||||||
|
$validateAvailability,
|
||||||
|
true,
|
||||||
|
$calcFrom,
|
||||||
|
$calcUntil
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->fresh();
|
return $this->fresh();
|
||||||
|
|
@ -502,34 +518,14 @@ class Cart extends Model
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build list of available items with prices for new dates
|
// Build list of available singles with their prices for new dates
|
||||||
$availableWithPrices = [];
|
$singlesWithPrices = [];
|
||||||
foreach ($singleItems as $single) {
|
foreach ($singleItems as $single) {
|
||||||
// Manually check if this single is available for the booking period
|
// Get available stock at the booking start date
|
||||||
$available = $single->getAvailableStock($from);
|
// This already accounts for claims via the DECREASE entries they create
|
||||||
|
$effectiveAvailable = $single->getAvailableStock($from);
|
||||||
|
|
||||||
// Check for overlapping claims - two periods overlap if:
|
if ($effectiveAvailable > 0) {
|
||||||
// claim.start < our.end AND claim.end > our.start
|
|
||||||
$overlaps = $single->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) {
|
|
||||||
// Claim starts before our period ends
|
|
||||||
$q->where(function ($subQ) use ($until) {
|
|
||||||
$subQ->where('claimed_from', '<', $until)
|
|
||||||
->orWhereNull('claimed_from'); // No start = starts immediately
|
|
||||||
})
|
|
||||||
// AND claim ends after our period starts
|
|
||||||
->where(function ($subQ) use ($from) {
|
|
||||||
$subQ->where('expires_at', '>', $from)
|
|
||||||
->orWhereNull('expires_at'); // No end = never expires
|
|
||||||
});
|
|
||||||
});
|
|
||||||
})
|
|
||||||
->exists();
|
|
||||||
|
|
||||||
if ($available > 0 && !$overlaps) {
|
|
||||||
$priceModel = $single->defaultPrice()->first();
|
$priceModel = $single->defaultPrice()->first();
|
||||||
$price = $priceModel?->getCurrentPrice($single->isOnSale());
|
$price = $priceModel?->getCurrentPrice($single->isOnSale());
|
||||||
|
|
||||||
|
|
@ -540,16 +536,17 @@ class Cart extends Model
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($price !== null) {
|
if ($price !== null) {
|
||||||
$availableWithPrices[] = [
|
$singlesWithPrices[] = [
|
||||||
'single' => $single,
|
'single' => $single,
|
||||||
'price' => $price,
|
'price' => $price,
|
||||||
'price_id' => $priceModel?->id,
|
'price_id' => $priceModel?->id,
|
||||||
|
'available' => $effectiveAvailable,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (empty($availableWithPrices)) {
|
if (empty($singlesWithPrices)) {
|
||||||
// No singles available for this period - mark ALL pool items as unavailable
|
// No singles available for this period - mark ALL pool items as unavailable
|
||||||
foreach ($items as $cartItem) {
|
foreach ($items as $cartItem) {
|
||||||
// Only update if we should overwrite or item has no dates yet
|
// Only update if we should overwrite or item has no dates yet
|
||||||
|
|
@ -570,7 +567,7 @@ class Cart extends Model
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by pricing strategy
|
// Sort by pricing strategy
|
||||||
usort($availableWithPrices, function ($a, $b) use ($strategy) {
|
usort($singlesWithPrices, function ($a, $b) use ($strategy) {
|
||||||
return match ($strategy) {
|
return match ($strategy) {
|
||||||
\Blax\Shop\Enums\PricingStrategy::LOWEST => $a['price'] <=> $b['price'],
|
\Blax\Shop\Enums\PricingStrategy::LOWEST => $a['price'] <=> $b['price'],
|
||||||
\Blax\Shop\Enums\PricingStrategy::HIGHEST => $b['price'] <=> $a['price'],
|
\Blax\Shop\Enums\PricingStrategy::HIGHEST => $b['price'] <=> $a['price'],
|
||||||
|
|
@ -579,45 +576,133 @@ class Cart extends Model
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reallocate cart items to optimal singles
|
// Reallocate cart items to optimal singles
|
||||||
// Each cart item gets one single - no single can be allocated twice
|
// Track usage per single to properly allocate considering quantities
|
||||||
$usedIndices = [];
|
// If a single can't accommodate a cart item's full quantity, split the cart item
|
||||||
|
$singleUsage = []; // single_id => quantity used
|
||||||
|
|
||||||
|
// Use singlesWithPrices directly as our ordered list
|
||||||
|
$orderedSingles = $singlesWithPrices;
|
||||||
|
|
||||||
foreach ($items as $cartItem) {
|
foreach ($items as $cartItem) {
|
||||||
// Only reallocate if we should overwrite or item has no dates yet
|
// Only reallocate if we should overwrite or item has no dates yet
|
||||||
if (!$overwrite && $cartItem->from && $cartItem->until) {
|
if (!$overwrite && $cartItem->from && $cartItem->until) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find next unused single from available list
|
$neededQty = $cartItem->quantity;
|
||||||
$allocated = false;
|
$allocated = false;
|
||||||
for ($i = 0; $i < count($availableWithPrices); $i++) {
|
|
||||||
if (!in_array($i, $usedIndices)) {
|
|
||||||
$allocation = $availableWithPrices[$i];
|
|
||||||
|
|
||||||
// Update cart item with new allocation
|
// Try to find a single that can accommodate the full quantity
|
||||||
$cartItem->updateMetaKey('allocated_single_item_id', $allocation['single']->id);
|
foreach ($orderedSingles as $singleInfo) {
|
||||||
$cartItem->updateMetaKey('allocated_single_item_name', $allocation['single']->name);
|
$single = $singleInfo['single'];
|
||||||
|
$usedFromSingle = $singleUsage[$single->id] ?? 0;
|
||||||
|
$remainingFromSingle = $singleInfo['available'] - $usedFromSingle;
|
||||||
|
|
||||||
|
if ($remainingFromSingle >= $neededQty) {
|
||||||
|
// This single can accommodate the cart item's full quantity
|
||||||
|
$cartItem->updateMetaKey('allocated_single_item_id', $single->id);
|
||||||
|
$cartItem->updateMetaKey('allocated_single_item_name', $single->name);
|
||||||
|
|
||||||
// Update price_id if changed
|
// Update price_id if changed
|
||||||
if ($allocation['price_id'] && $allocation['price_id'] !== $cartItem->price_id) {
|
if ($singleInfo['price_id'] && $singleInfo['price_id'] !== $cartItem->price_id) {
|
||||||
$cartItem->update(['price_id' => $allocation['price_id']]);
|
$cartItem->update(['price_id' => $singleInfo['price_id']]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$usedIndices[] = $i;
|
// Track usage
|
||||||
|
$singleUsage[$single->id] = $usedFromSingle + $neededQty;
|
||||||
$allocated = true;
|
$allocated = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we couldn't allocate (ran out of available singles), mark as unavailable
|
|
||||||
if (!$allocated) {
|
if (!$allocated) {
|
||||||
// Clear allocation and set price to null to indicate unavailable
|
// No single can accommodate the full quantity
|
||||||
$cartItem->updateMetaKey('allocated_single_item_id', null);
|
// Try to split: use as much as possible from the first available single,
|
||||||
$cartItem->updateMetaKey('allocated_single_item_name', null);
|
// then create new cart items for the rest
|
||||||
$cartItem->update([
|
$remainingQty = $neededQty;
|
||||||
'price' => null,
|
$firstAllocation = true;
|
||||||
'subtotal' => null,
|
|
||||||
'unit_amount' => null,
|
foreach ($orderedSingles as $singleInfo) {
|
||||||
]);
|
if ($remainingQty <= 0) break;
|
||||||
|
|
||||||
|
$single = $singleInfo['single'];
|
||||||
|
$usedFromSingle = $singleUsage[$single->id] ?? 0;
|
||||||
|
$availableFromSingle = $singleInfo['available'] - $usedFromSingle;
|
||||||
|
|
||||||
|
if ($availableFromSingle <= 0) continue;
|
||||||
|
|
||||||
|
$qtyToAllocate = min($remainingQty, $availableFromSingle);
|
||||||
|
|
||||||
|
if ($firstAllocation) {
|
||||||
|
// Update the original cart item with reduced quantity
|
||||||
|
// Also update subtotal to match the new quantity
|
||||||
|
$newSubtotal = $cartItem->price * $qtyToAllocate;
|
||||||
|
$cartItem->update([
|
||||||
|
'quantity' => $qtyToAllocate,
|
||||||
|
'subtotal' => $newSubtotal,
|
||||||
|
]);
|
||||||
|
$cartItem->refresh(); // Ensure model reflects database state
|
||||||
|
$cartItem->updateMetaKey('allocated_single_item_id', $single->id);
|
||||||
|
$cartItem->updateMetaKey('allocated_single_item_name', $single->name);
|
||||||
|
|
||||||
|
if ($singleInfo['price_id'] && $singleInfo['price_id'] !== $cartItem->price_id) {
|
||||||
|
$cartItem->update(['price_id' => $singleInfo['price_id']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$firstAllocation = false;
|
||||||
|
} else {
|
||||||
|
// Create a new cart item for the additional quantity
|
||||||
|
// Get price from the single
|
||||||
|
$priceModel = $single->defaultPrice()->first();
|
||||||
|
$singlePrice = $priceModel?->getCurrentPrice($single->isOnSale());
|
||||||
|
|
||||||
|
if ($singlePrice === null && $poolProduct->hasPrice()) {
|
||||||
|
$priceModel = $poolProduct->defaultPrice()->first();
|
||||||
|
$singlePrice = $priceModel?->getCurrentPrice($poolProduct->isOnSale());
|
||||||
|
}
|
||||||
|
|
||||||
|
$days = $this->calculateBookingDays($from, $until);
|
||||||
|
$pricePerUnit = (int) round($singlePrice * $days);
|
||||||
|
|
||||||
|
$newCartItem = $this->items()->create([
|
||||||
|
'purchasable_id' => $cartItem->purchasable_id,
|
||||||
|
'purchasable_type' => $cartItem->purchasable_type,
|
||||||
|
'price_id' => $priceModel?->id,
|
||||||
|
'quantity' => $qtyToAllocate,
|
||||||
|
'price' => $pricePerUnit,
|
||||||
|
'regular_price' => $pricePerUnit,
|
||||||
|
'unit_amount' => (int) round($singlePrice),
|
||||||
|
'subtotal' => $pricePerUnit * $qtyToAllocate,
|
||||||
|
'parameters' => $cartItem->parameters,
|
||||||
|
'from' => $from,
|
||||||
|
'until' => $until,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$newCartItem->updateMetaKey('allocated_single_item_id', $single->id);
|
||||||
|
$newCartItem->updateMetaKey('allocated_single_item_name', $single->name);
|
||||||
|
}
|
||||||
|
|
||||||
|
$singleUsage[$single->id] = $usedFromSingle + $qtyToAllocate;
|
||||||
|
$remainingQty -= $qtyToAllocate;
|
||||||
|
$allocated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we still have remaining quantity that couldn't be allocated
|
||||||
|
if ($remainingQty > 0) {
|
||||||
|
if ($firstAllocation) {
|
||||||
|
// Couldn't allocate anything - mark as unavailable
|
||||||
|
$cartItem->updateMetaKey('allocated_single_item_id', null);
|
||||||
|
$cartItem->updateMetaKey('allocated_single_item_name', null);
|
||||||
|
$cartItem->update([
|
||||||
|
'price' => null,
|
||||||
|
'subtotal' => null,
|
||||||
|
'unit_amount' => null,
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
// Partial allocation - the cart item was already updated with what we could allocate
|
||||||
|
// The remaining quantity is lost (over-capacity)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -780,6 +865,14 @@ class Cart extends Model
|
||||||
$until = is_string($parameters['until']) ? Carbon::parse($parameters['until']) : $parameters['until'];
|
$until = is_string($parameters['until']) ? Carbon::parse($parameters['until']) : $parameters['until'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fallback to cart dates if no dates provided
|
||||||
|
if (!$from && $this->from) {
|
||||||
|
$from = $this->from;
|
||||||
|
}
|
||||||
|
if (!$until && $this->until) {
|
||||||
|
$until = $this->until;
|
||||||
|
}
|
||||||
|
|
||||||
// For pool products with quantity > 1, add them one at a time to get progressive pricing
|
// For pool products with quantity > 1, add them one at a time to get progressive pricing
|
||||||
if ($cartable instanceof Product && $cartable->isPool() && $quantity > 1) {
|
if ($cartable instanceof Product && $cartable->isPool() && $quantity > 1) {
|
||||||
// Validate availability if dates are provided
|
// Validate availability if dates are provided
|
||||||
|
|
@ -860,11 +953,16 @@ class Cart extends Model
|
||||||
$maxQuantity = $cartable->getPoolMaxQuantity($from, $until);
|
$maxQuantity = $cartable->getPoolMaxQuantity($from, $until);
|
||||||
|
|
||||||
// Subtract items already in cart for the same period
|
// Subtract items already in cart for the same period
|
||||||
|
// Only count items that are actually valid (have a price allocated)
|
||||||
$itemsInCart = $this->items()
|
$itemsInCart = $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()
|
||||||
->filter(function ($item) use ($from, $until) {
|
->filter(function ($item) use ($from, $until) {
|
||||||
|
// Don't count items marked as unavailable (null price)
|
||||||
|
if ($item->price === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
// Only count items with overlapping dates
|
// Only count items with overlapping dates
|
||||||
if (!$item->from || !$item->until) {
|
if (!$item->from || !$item->until) {
|
||||||
return false;
|
return false;
|
||||||
|
|
|
||||||
|
|
@ -516,16 +516,21 @@ class CartDateManagementTest extends TestCase
|
||||||
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$cart = Cart::factory()->create([
|
// Create cart WITHOUT dates first (so addToCart doesn't validate)
|
||||||
'from' => Carbon::now()->addDays(1),
|
$cart = Cart::factory()->create();
|
||||||
'until' => Carbon::now()->addDays(3),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Add item that would exceed available stock
|
// Add item that would exceed available stock (qty=2 but stock=1)
|
||||||
|
// This succeeds because cart has no dates yet, so no availability validation
|
||||||
$item = $cart->addToCart($product, 2);
|
$item = $cart->addToCart($product, 2);
|
||||||
|
|
||||||
// Should NOT throw exception, instead mark items as unavailable
|
// Now set dates on the cart with validation enabled
|
||||||
$cart->applyDatesToItems(validateAvailability: true);
|
// This triggers applyDatesToItems which should mark items as unavailable
|
||||||
|
// rather than throwing an exception
|
||||||
|
$cart->setDates(
|
||||||
|
Carbon::now()->addDays(1),
|
||||||
|
Carbon::now()->addDays(3),
|
||||||
|
validateAvailability: true
|
||||||
|
);
|
||||||
|
|
||||||
// Item should be marked as unavailable (null price)
|
// Item should be marked as unavailable (null price)
|
||||||
$item->refresh();
|
$item->refresh();
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
namespace Blax\Shop\Tests\Feature;
|
namespace Blax\Shop\Tests\Feature;
|
||||||
|
|
||||||
use Blax\Shop\Enums\ProductType;
|
use Blax\Shop\Enums\ProductType;
|
||||||
|
use Blax\Shop\Enums\StockType;
|
||||||
use Blax\Shop\Models\Cart;
|
use Blax\Shop\Models\Cart;
|
||||||
use Blax\Shop\Models\Product;
|
use Blax\Shop\Models\Product;
|
||||||
use Blax\Shop\Models\ProductPrice;
|
use Blax\Shop\Models\ProductPrice;
|
||||||
|
|
@ -564,4 +565,533 @@ class PoolProductionBugTest extends TestCase
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_date_adjustment_with_one_item()
|
||||||
|
{
|
||||||
|
$this->createProductionPool();
|
||||||
|
|
||||||
|
$cart = $this->createCart();
|
||||||
|
|
||||||
|
$cart->addToCart(
|
||||||
|
$this->pool,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertEquals(5000, $cart->getTotal());
|
||||||
|
$this->assertFalse($cart->isReadyForCheckout());
|
||||||
|
$this->assertFalse($cart->items()->first()->is_ready_to_checkout);
|
||||||
|
|
||||||
|
$from = Carbon::tomorrow()->startOfDay();
|
||||||
|
$until = Carbon::tomorrow()->addDay()->startOfDay();
|
||||||
|
|
||||||
|
$cart->setDates($from, $until);
|
||||||
|
|
||||||
|
$this->assertEquals(5000, $cart->getTotal());
|
||||||
|
$this->assertTrue($cart->isReadyForCheckout());
|
||||||
|
$this->assertTrue($cart->items()->first()->is_ready_to_checkout);
|
||||||
|
|
||||||
|
$until->subHours(5);
|
||||||
|
$cart->setUntilDate($until);
|
||||||
|
$this->assertLessThan(5000, $cart->getTotal());
|
||||||
|
$this->assertTrue($cart->isReadyForCheckout());
|
||||||
|
$this->assertTrue($cart->items()->first()->is_ready_to_checkout);
|
||||||
|
|
||||||
|
$until->addHours(24);
|
||||||
|
$cart->setUntilDate($until);
|
||||||
|
$this->assertGreaterThan(5000, $cart->getTotal());
|
||||||
|
$this->assertTrue($cart->isReadyForCheckout());
|
||||||
|
$this->assertTrue($cart->items()->first()->is_ready_to_checkout);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_date_adjustment_with_one_item_day_adjustment()
|
||||||
|
{
|
||||||
|
// The hotel has parking plots (proxied with the pool)
|
||||||
|
$pool = Product::factory()->create([
|
||||||
|
'type' => ProductType::POOL,
|
||||||
|
'manage_stock' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// In this hotel we have 3 cheap parking plots far from the entrance
|
||||||
|
$single_1 = Product::factory()
|
||||||
|
->withStocks(3)
|
||||||
|
->withPrices(1, 1000)
|
||||||
|
->create([
|
||||||
|
'type' => ProductType::BOOKING,
|
||||||
|
'manage_stock' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 1 medium priced parking plots closer to the entrance
|
||||||
|
$single_2 = Product::factory()
|
||||||
|
->withStocks(1)
|
||||||
|
->withPrices(1, 10001)
|
||||||
|
->create([
|
||||||
|
'type' => ProductType::BOOKING,
|
||||||
|
'manage_stock' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 1 premium parking plot right at the entrance
|
||||||
|
$single_3 = Product::factory()
|
||||||
|
->withStocks(1)
|
||||||
|
->withPrices(1, 10002)
|
||||||
|
->create([
|
||||||
|
'type' => ProductType::BOOKING,
|
||||||
|
'manage_stock' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$pool->attachSingleItems([
|
||||||
|
$single_1->id,
|
||||||
|
$single_2->id,
|
||||||
|
$single_3->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$cart = $this->createCart();
|
||||||
|
|
||||||
|
// We check nothing is in the cart
|
||||||
|
$this->assertEquals(0, $cart->items()->count());
|
||||||
|
|
||||||
|
// We add the pool to the cart and expect the cheapest option to be added
|
||||||
|
$cart->addToCart(
|
||||||
|
$pool,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertEquals(1000, $cart->getTotal());
|
||||||
|
$this->assertFalse($cart->isReadyForCheckout());
|
||||||
|
$this->assertFalse($cart->items()->first()->is_ready_to_checkout);
|
||||||
|
|
||||||
|
$from = Carbon::tomorrow()->startOfDay();
|
||||||
|
|
||||||
|
$cart->setFromDate($from);
|
||||||
|
|
||||||
|
$until = Carbon::tomorrow()->addDay()->startOfDay();
|
||||||
|
$cart->setUntilDate($until);
|
||||||
|
|
||||||
|
$cart->refresh();
|
||||||
|
|
||||||
|
$this->assertEquals(24, $cart->from->diffInHours($cart->until));
|
||||||
|
|
||||||
|
$cart->setDates($cart->from, $cart->until);
|
||||||
|
|
||||||
|
// As dates are now set, we expect the cart to be ready for checkout and it shows the correct total (unit_amount of price is for one day and we check for a full day)
|
||||||
|
$this->assertEquals(1000, $cart->getTotal());
|
||||||
|
$this->assertTrue($cart->isReadyForCheckout());
|
||||||
|
$this->assertTrue($cart->items()->first()->is_ready_to_checkout);
|
||||||
|
|
||||||
|
$cart->setDates($cart->from->copy(), $cart->until->copy()->addHours(24));
|
||||||
|
|
||||||
|
$cart->refresh();
|
||||||
|
$this->assertEquals(48, $cart->from->diffInHours($cart->until));
|
||||||
|
|
||||||
|
// We expect the amount to be doubled now, as 2 days are booked
|
||||||
|
$this->assertEquals(2000, $cart->getTotal());
|
||||||
|
$this->assertTrue($cart->isReadyForCheckout());
|
||||||
|
$this->assertTrue($cart->items()->first()->is_ready_to_checkout);
|
||||||
|
|
||||||
|
$cart->addToCart(
|
||||||
|
$pool,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
// We have the 2000 2 times now, as we book 2 days with quantity of 2 with unit amount of 1000
|
||||||
|
$this->assertEquals(4000, $cart->getTotal());
|
||||||
|
$this->assertTrue($cart->isReadyForCheckout());
|
||||||
|
$this->assertTrue($cart->items()->first()->is_ready_to_checkout);
|
||||||
|
|
||||||
|
$cart->addToCart(
|
||||||
|
$pool,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertEquals(6000, $cart->getTotal());
|
||||||
|
$this->assertTrue($cart->isReadyForCheckout());
|
||||||
|
$this->assertTrue($cart->items()->first()->is_ready_to_checkout);
|
||||||
|
|
||||||
|
$cart->addToCart(
|
||||||
|
$pool,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
$cart->refresh();
|
||||||
|
|
||||||
|
// We expect to have 2 days booked and quantity of 3 and as the cheapest option is out of stock now,
|
||||||
|
// the next one is taken (unit amount of 10001)
|
||||||
|
$this->assertEquals(48, $cart->from->diffInHours($cart->until));
|
||||||
|
$this->assertEquals(6000 + (10001 * 2), $cart->getTotal());
|
||||||
|
$this->assertTrue($cart->isReadyForCheckout());
|
||||||
|
$this->assertTrue($cart->items()->first()->is_ready_to_checkout);
|
||||||
|
|
||||||
|
$cart->addToCart(
|
||||||
|
$pool,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertEquals(6000 + (10001 * 2) + (10002 * 2), $cart->getTotal());
|
||||||
|
|
||||||
|
$cart->removeFromCart(
|
||||||
|
$pool,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertEquals(6000 + (10001 * 2), $cart->getTotal());
|
||||||
|
|
||||||
|
$single_1->adjustStock(
|
||||||
|
StockType::CLAIMED,
|
||||||
|
1,
|
||||||
|
from: now()->subYear(),
|
||||||
|
until: now()->addYear(),
|
||||||
|
note: 'Booked'
|
||||||
|
);
|
||||||
|
|
||||||
|
// After claiming 1 stock from single_1, the capacity is reduced from 5 to 4.
|
||||||
|
// We currently have 4 items in cart (3 @ single_1, 1 @ single_2).
|
||||||
|
// When setDates is called, reallocation happens:
|
||||||
|
// - single_1 now only has 2 capacity (3-1 claim = 2)
|
||||||
|
// - 2 items can stay at single_1
|
||||||
|
// - 1 item must move to single_3 (the only one with capacity)
|
||||||
|
// - 1 item stays at single_2
|
||||||
|
// After reallocation: 2 @ single_1 (4000) + 1 @ single_2 (20002) + 1 @ single_3 (20004) = 44006
|
||||||
|
|
||||||
|
// Trigger reallocation by refreshing dates
|
||||||
|
$cart->setDates($cart->from, $cart->until);
|
||||||
|
$cart->refresh();
|
||||||
|
|
||||||
|
$this->assertEquals(4000 + (10001 * 2) + (10002 * 2), $cart->getTotal());
|
||||||
|
|
||||||
|
// Now try to add another item - this should fail because capacity is full (4 items, 4 capacity)
|
||||||
|
$this->expectException(\Blax\Shop\Exceptions\NotEnoughStockException::class);
|
||||||
|
$cart->addToCart(
|
||||||
|
$pool,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test that single item allocation is properly tracked when adding multiple pool items.
|
||||||
|
* Each single item should only be used up to its stock limit.
|
||||||
|
*/
|
||||||
|
public function test_single_item_allocation_respects_stock_limits()
|
||||||
|
{
|
||||||
|
$pool = Product::factory()->create([
|
||||||
|
'type' => ProductType::POOL,
|
||||||
|
'manage_stock' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// single_1: 3 stock @ 1000/day
|
||||||
|
$single_1 = Product::factory()
|
||||||
|
->withStocks(3)
|
||||||
|
->withPrices(1, 1000)
|
||||||
|
->create([
|
||||||
|
'type' => ProductType::BOOKING,
|
||||||
|
'manage_stock' => true,
|
||||||
|
'name' => 'Single1-Cheap',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// single_2: 1 stock @ 10001/day
|
||||||
|
$single_2 = Product::factory()
|
||||||
|
->withStocks(1)
|
||||||
|
->withPrices(1, 10001)
|
||||||
|
->create([
|
||||||
|
'type' => ProductType::BOOKING,
|
||||||
|
'manage_stock' => true,
|
||||||
|
'name' => 'Single2-Medium',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// single_3: 1 stock @ 10002/day
|
||||||
|
$single_3 = Product::factory()
|
||||||
|
->withStocks(1)
|
||||||
|
->withPrices(1, 10002)
|
||||||
|
->create([
|
||||||
|
'type' => ProductType::BOOKING,
|
||||||
|
'manage_stock' => true,
|
||||||
|
'name' => 'Single3-Premium',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$pool->attachSingleItems([
|
||||||
|
$single_1->id,
|
||||||
|
$single_2->id,
|
||||||
|
$single_3->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$cart = $this->createCart();
|
||||||
|
|
||||||
|
$from = Carbon::tomorrow()->startOfDay();
|
||||||
|
$until = Carbon::tomorrow()->addDays(2)->startOfDay(); // 2 days
|
||||||
|
$cart->setDates($from, $until);
|
||||||
|
|
||||||
|
// Add 4 items one by one, tracking each addition
|
||||||
|
$items = [];
|
||||||
|
for ($i = 1; $i <= 4; $i++) {
|
||||||
|
$item = $cart->addToCart($pool, 1);
|
||||||
|
$meta = $item->getMeta();
|
||||||
|
$items[$i] = [
|
||||||
|
'id' => $item->id,
|
||||||
|
'quantity' => $item->quantity,
|
||||||
|
'price' => $item->price,
|
||||||
|
'allocated_id' => $meta->allocated_single_item_id ?? null,
|
||||||
|
'allocated_name' => $meta->allocated_single_item_name ?? 'none',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$cart->refresh();
|
||||||
|
|
||||||
|
// Debug: check all cart items
|
||||||
|
$cartItems = $cart->items;
|
||||||
|
$cartItemDetails = [];
|
||||||
|
$totalQuantity = 0;
|
||||||
|
foreach ($cartItems as $item) {
|
||||||
|
$meta = $item->getMeta();
|
||||||
|
$cartItemDetails[] = [
|
||||||
|
'id' => $item->id,
|
||||||
|
'quantity' => $item->quantity,
|
||||||
|
'price' => $item->price,
|
||||||
|
'allocated_id' => $meta->allocated_single_item_id ?? null,
|
||||||
|
'allocated_name' => $meta->allocated_single_item_name ?? 'none',
|
||||||
|
];
|
||||||
|
$totalQuantity += $item->quantity;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Total quantity should be 4 (may be in fewer cart items if merged)
|
||||||
|
$this->assertEquals(
|
||||||
|
4,
|
||||||
|
$totalQuantity,
|
||||||
|
'Should have total quantity of 4. Cart items: ' . json_encode($cartItemDetails)
|
||||||
|
);
|
||||||
|
|
||||||
|
// The issue: when items are merged, the allocation tracking might not work correctly
|
||||||
|
// Each distinct single item should NOT be merged with others
|
||||||
|
// Items from the SAME single CAN be merged (they have same price and same allocated_single_item_id)
|
||||||
|
|
||||||
|
// Check that we have correct allocations:
|
||||||
|
// - 3 quantity allocated to single_1
|
||||||
|
// - 1 quantity allocated to single_2
|
||||||
|
$single1Quantity = 0;
|
||||||
|
$single2Quantity = 0;
|
||||||
|
$single3Quantity = 0;
|
||||||
|
|
||||||
|
// Verify EACH cart item has allocated_single_item_id set
|
||||||
|
foreach ($cartItems as $item) {
|
||||||
|
$meta = $item->getMeta();
|
||||||
|
$allocatedId = $meta->allocated_single_item_id ?? null;
|
||||||
|
$this->assertNotNull(
|
||||||
|
$allocatedId,
|
||||||
|
'Cart item id=' . $item->id . ' (qty=' . $item->quantity . ', price=' . $item->price .
|
||||||
|
') should have allocated_single_item_id but has: ' . json_encode($meta)
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($allocatedId == $single_1->id) {
|
||||||
|
$single1Quantity += $item->quantity;
|
||||||
|
} elseif ($allocatedId == $single_2->id) {
|
||||||
|
$single2Quantity += $item->quantity;
|
||||||
|
} elseif ($allocatedId == $single_3->id) {
|
||||||
|
$single3Quantity += $item->quantity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertEquals(
|
||||||
|
3,
|
||||||
|
$single1Quantity,
|
||||||
|
'Should have 3 quantity from single_1. Cart: ' . json_encode($cartItemDetails)
|
||||||
|
);
|
||||||
|
$this->assertEquals(
|
||||||
|
1,
|
||||||
|
$single2Quantity,
|
||||||
|
'Should have 1 quantity from single_2. Cart: ' . json_encode($cartItemDetails)
|
||||||
|
);
|
||||||
|
$this->assertEquals(
|
||||||
|
0,
|
||||||
|
$single3Quantity,
|
||||||
|
'Should have 0 quantity from single_3 before adding 5th. Cart: ' . json_encode($cartItemDetails)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Before adding item 5, test what getNextAvailablePoolItemWithPrice returns
|
||||||
|
$nextBefore = $pool->getNextAvailablePoolItemWithPrice($cart, null, $from, $until);
|
||||||
|
$this->assertNotNull($nextBefore, 'Should have next available before adding item 5');
|
||||||
|
$this->assertEquals(
|
||||||
|
$single_3->id,
|
||||||
|
$nextBefore['item']->id,
|
||||||
|
'Before adding item 5: Next should be single_3 (single_1 and single_2 exhausted). ' .
|
||||||
|
'Got: ' . $nextBefore['item']->name . ' (id=' . $nextBefore['item']->id . '). ' .
|
||||||
|
'Price: ' . $nextBefore['price'] . '. ' .
|
||||||
|
'Cart items: ' . json_encode($cartItemDetails)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check that after refreshing the pool model, we still get single_3
|
||||||
|
$pool->refresh();
|
||||||
|
$nextBeforeAfterRefresh = $pool->getNextAvailablePoolItemWithPrice($cart, null, $from, $until);
|
||||||
|
$this->assertEquals(
|
||||||
|
$single_3->id,
|
||||||
|
$nextBeforeAfterRefresh['item']->id,
|
||||||
|
'After pool refresh, should still get single_3'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Now add 5th item
|
||||||
|
$cartItemsBeforeItem5 = $cart->fresh()->items;
|
||||||
|
|
||||||
|
// Debug: Check what getNextAvailablePoolItemWithPrice returns INSIDE the addToCart flow
|
||||||
|
// by calling it right before on a fresh pool and cart
|
||||||
|
$freshCart = Cart::find($cart->id);
|
||||||
|
$freshPool = Product::find($pool->id);
|
||||||
|
$nextImmediate = $freshPool->getNextAvailablePoolItemWithPrice($freshCart, null, $from, $until);
|
||||||
|
$this->assertEquals(
|
||||||
|
$single_3->id,
|
||||||
|
$nextImmediate['item']->id,
|
||||||
|
'Immediately before addToCart (fresh models), should get single_3. Got: ' .
|
||||||
|
$nextImmediate['item']->name . ' (id=' . $nextImmediate['item']->id . ')'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Debug: Check what the addToCart flow sees for cart items
|
||||||
|
// This replicates the query inside getNextAvailablePoolItemWithPrice
|
||||||
|
$cartItemsAsSeenByPool = $freshCart->items()
|
||||||
|
->where('purchasable_id', $pool->getKey())
|
||||||
|
->where('purchasable_type', get_class($pool))
|
||||||
|
->get();
|
||||||
|
$usageMap = [];
|
||||||
|
foreach ($cartItemsAsSeenByPool as $ci) {
|
||||||
|
$meta = $ci->getMeta();
|
||||||
|
$allocatedId = $meta->allocated_single_item_id ?? null;
|
||||||
|
if ($allocatedId) {
|
||||||
|
$usageMap[$allocatedId] = ($usageMap[$allocatedId] ?? 0) + $ci->quantity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$this->assertEquals(
|
||||||
|
3,
|
||||||
|
$usageMap[$single_1->id] ?? 0,
|
||||||
|
'Usage map should show 3 for single_1. Map: ' . json_encode($usageMap)
|
||||||
|
);
|
||||||
|
$this->assertEquals(
|
||||||
|
1,
|
||||||
|
$usageMap[$single_2->id] ?? 0,
|
||||||
|
'Usage map should show 1 for single_2. Map: ' . json_encode($usageMap)
|
||||||
|
);
|
||||||
|
$this->assertEquals(
|
||||||
|
0,
|
||||||
|
$usageMap[$single_3->id] ?? 0,
|
||||||
|
'Usage map should show 0 for single_3. Map: ' . json_encode($usageMap)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use fresh cart AND fresh pool to call addToCart
|
||||||
|
$this->assertEquals($cart->id, $freshCart->id, 'Cart IDs should match');
|
||||||
|
|
||||||
|
// Debug: Check that freshCart can see the items
|
||||||
|
$freshCartItems = $freshCart->items()
|
||||||
|
->where('purchasable_id', $pool->getKey())
|
||||||
|
->where('purchasable_type', get_class($pool))
|
||||||
|
->get();
|
||||||
|
$this->assertCount(
|
||||||
|
2,
|
||||||
|
$freshCartItems,
|
||||||
|
'Fresh cart should have 2 cart item records. Cart ID: ' . $freshCart->id .
|
||||||
|
'. Items found: ' . $freshCartItems->pluck('id')->join(', ')
|
||||||
|
);
|
||||||
|
$freshCartQty = $freshCartItems->sum('quantity');
|
||||||
|
$this->assertEquals(
|
||||||
|
4,
|
||||||
|
$freshCartQty,
|
||||||
|
'Fresh cart should have total quantity 4. Got: ' . $freshCartQty
|
||||||
|
);
|
||||||
|
|
||||||
|
$item5 = $freshCart->addToCart($freshPool, 1);
|
||||||
|
$meta5 = $item5->getMeta();
|
||||||
|
|
||||||
|
// Verify item 5 is allocated to single_3 (the only one with remaining capacity)
|
||||||
|
$this->assertEquals(
|
||||||
|
$single_3->id,
|
||||||
|
$meta5->allocated_single_item_id,
|
||||||
|
'Item 5 should be allocated to single_3 (id=' . $single_3->id . ') since single_1 and single_2 are exhausted. ' .
|
||||||
|
'Got allocated_id: ' . ($meta5->allocated_single_item_id ?? 'null')
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if item 5 is actually a new item or a merged item
|
||||||
|
$isNewItem = !$cartItemsBeforeItem5->contains('id', $item5->id);
|
||||||
|
$cartItemsAfterItem5 = $cart->fresh()->items;
|
||||||
|
|
||||||
|
$this->assertTrue(
|
||||||
|
$isNewItem,
|
||||||
|
'Item 5 should be a NEW item, not merged. ' .
|
||||||
|
'Item5 id=' . $item5->id . ', quantity=' . $item5->quantity . '. ' .
|
||||||
|
'Cart items before: ' . $cartItemsBeforeItem5->pluck('id')->join(', ') . '. ' .
|
||||||
|
'Cart items after: ' . $cartItemsAfterItem5->pluck('id')->join(', ')
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertEquals(
|
||||||
|
$single_3->id,
|
||||||
|
$meta5->allocated_single_item_id,
|
||||||
|
'Item 5 should be from single_3 (id=' . $single_3->id . ', name=' . $single_3->name . '). ' .
|
||||||
|
'Got allocated_id: ' . ($meta5->allocated_single_item_id ?? 'null') . '. ' .
|
||||||
|
'For reference: single_1=' . $single_1->id . ', single_2=' . $single_2->id
|
||||||
|
);
|
||||||
|
$this->assertEquals(20004, $item5->price, 'Item 5 should cost 20004 (10002 * 2 days)');
|
||||||
|
|
||||||
|
// Total: 3*2000 + 20002 + 20004 = 46006
|
||||||
|
$this->assertEquals(46006, $cart->fresh()->getTotal());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test getNextAvailablePoolItemWithPrice correctly tracks cart item allocations.
|
||||||
|
*/
|
||||||
|
public function test_get_next_available_pool_item_tracks_allocations()
|
||||||
|
{
|
||||||
|
$pool = Product::factory()->create([
|
||||||
|
'type' => ProductType::POOL,
|
||||||
|
'manage_stock' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// single_1: 2 stock @ 1000/day
|
||||||
|
$single_1 = Product::factory()
|
||||||
|
->withStocks(2)
|
||||||
|
->withPrices(1, 1000)
|
||||||
|
->create([
|
||||||
|
'type' => ProductType::BOOKING,
|
||||||
|
'manage_stock' => true,
|
||||||
|
'name' => 'Single1',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// single_2: 1 stock @ 2000/day
|
||||||
|
$single_2 = Product::factory()
|
||||||
|
->withStocks(1)
|
||||||
|
->withPrices(1, 2000)
|
||||||
|
->create([
|
||||||
|
'type' => ProductType::BOOKING,
|
||||||
|
'manage_stock' => true,
|
||||||
|
'name' => 'Single2',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$pool->attachSingleItems([$single_1->id, $single_2->id]);
|
||||||
|
|
||||||
|
$cart = $this->createCart();
|
||||||
|
$from = Carbon::tomorrow()->startOfDay();
|
||||||
|
$until = Carbon::tomorrow()->addDay()->startOfDay();
|
||||||
|
$cart->setDates($from, $until);
|
||||||
|
|
||||||
|
// Before adding any items, next available should be single_1 (cheapest)
|
||||||
|
$next = $pool->getNextAvailablePoolItemWithPrice($cart, null, $from, $until);
|
||||||
|
$this->assertEquals($single_1->id, $next['item']->id, 'First available should be single_1');
|
||||||
|
$this->assertEquals(1000, $next['price']);
|
||||||
|
|
||||||
|
// Add first item - should get single_1
|
||||||
|
$item1 = $cart->addToCart($pool, 1);
|
||||||
|
$this->assertEquals($single_1->id, $item1->getMeta()->allocated_single_item_id);
|
||||||
|
|
||||||
|
// After 1 item, next should still be single_1 (has 2 stock)
|
||||||
|
$cart->refresh();
|
||||||
|
$next = $pool->getNextAvailablePoolItemWithPrice($cart, null, $from, $until);
|
||||||
|
$this->assertEquals($single_1->id, $next['item']->id, 'Second available should still be single_1');
|
||||||
|
|
||||||
|
// Add second item - should get single_1 again
|
||||||
|
$item2 = $cart->addToCart($pool, 1);
|
||||||
|
$this->assertEquals($single_1->id, $item2->getMeta()->allocated_single_item_id);
|
||||||
|
|
||||||
|
// After 2 items (both from single_1 which has stock=2), next should be single_2
|
||||||
|
$cart->refresh();
|
||||||
|
$next = $pool->getNextAvailablePoolItemWithPrice($cart, null, $from, $until);
|
||||||
|
$this->assertEquals($single_2->id, $next['item']->id, 'Third available should be single_2 (single_1 exhausted)');
|
||||||
|
$this->assertEquals(2000, $next['price']);
|
||||||
|
|
||||||
|
// Add third item - should get single_2
|
||||||
|
$item3 = $cart->addToCart($pool, 1);
|
||||||
|
$this->assertEquals($single_2->id, $item3->getMeta()->allocated_single_item_id);
|
||||||
|
|
||||||
|
// Total: 1000 + 1000 + 2000 = 4000
|
||||||
|
$this->assertEquals(4000, $cart->fresh()->getTotal());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue