BF pool cart
This commit is contained in:
parent
dbc297122e
commit
d13ac99725
|
|
@ -636,19 +636,28 @@ class Cart extends Model
|
|||
);
|
||||
}
|
||||
|
||||
// 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)
|
||||
// For pool products, check if we should merge with existing items
|
||||
// Pool items can ONLY merge if they are from the SAME single item
|
||||
// This is critical because different single items have their own stock limits
|
||||
// even if they happen to share the same price (e.g., via pool fallback price)
|
||||
$priceMatch = true;
|
||||
if ($cartable instanceof Product && $cartable->isPool()) {
|
||||
// Calculate expected price for this item
|
||||
$poolItemData = $cartable->getNextAvailablePoolItemWithPrice($this, null, $from, $until);
|
||||
$expectedPrice = $poolItemData['price'] ?? null;
|
||||
$expectedSingleItemId = $poolItemData['item']?->id ?? null;
|
||||
|
||||
// Only merge if price_id matches AND the price amount matches
|
||||
// Get the allocated single item ID from the existing cart item's meta
|
||||
$existingMeta = $item->getMeta();
|
||||
$existingAllocatedItemId = $existingMeta->allocated_single_item_id ?? null;
|
||||
|
||||
// Only merge if:
|
||||
// 1. price_id matches (same price source)
|
||||
// 2. actual price amount matches
|
||||
// 3. allocated single item matches (CRITICAL: same single item being used)
|
||||
$priceMatch = $poolPriceId && $item->price_id === $poolPriceId &&
|
||||
$expectedPrice !== null && $item->unit_amount === (int) round($expectedPrice);
|
||||
$expectedPrice !== null && $item->unit_amount === (int) round($expectedPrice) &&
|
||||
$expectedSingleItemId !== null && $existingAllocatedItemId === $expectedSingleItemId;
|
||||
}
|
||||
|
||||
return $paramsMatch && $datesMatch && $priceMatch;
|
||||
|
|
|
|||
|
|
@ -199,11 +199,18 @@ class CartAddToCartPoolPricingTest extends TestCase
|
|||
$from = Carbon::now()->addDays(1)->startOfDay();
|
||||
$until = Carbon::now()->addDays(6)->startOfDay(); // 5 days
|
||||
|
||||
// Adding 2 pool items creates separate cart items (one per single item)
|
||||
// because each single item has its own stock limit
|
||||
$cartItem = $this->cart->addToCart($this->poolProduct, 2, [], $from, $until);
|
||||
|
||||
$this->assertEquals(2, $cartItem->quantity);
|
||||
// Returns the last cart item created (quantity 1)
|
||||
$this->assertEquals(1, $cartItem->quantity);
|
||||
$this->assertEquals(12500, $cartItem->price); // 25.00 × 5 days per unit
|
||||
$this->assertEquals(25000, $cartItem->subtotal); // 125.00 × 2 units = 250.00
|
||||
$this->assertEquals(12500, $cartItem->subtotal); // 125.00 × 1 quantity
|
||||
|
||||
// But total cart should have 2 items with combined subtotal
|
||||
$this->assertEquals(2, $this->cart->fresh()->items->count());
|
||||
$this->assertEquals(25000, $this->cart->fresh()->getTotal()); // 125.00 × 2 units = 250.00
|
||||
}
|
||||
|
||||
/** @test */
|
||||
|
|
@ -324,7 +331,7 @@ class CartAddToCartPoolPricingTest extends TestCase
|
|||
}
|
||||
|
||||
/** @test */
|
||||
public function it_increases_quantity_when_adding_same_pool_product_with_same_dates()
|
||||
public function it_creates_separate_items_when_adding_same_pool_product_with_same_dates()
|
||||
{
|
||||
ProductPrice::factory()->create([
|
||||
'purchasable_id' => $this->poolProduct->id,
|
||||
|
|
@ -340,9 +347,17 @@ class CartAddToCartPoolPricingTest extends TestCase
|
|||
$cartItem1 = $this->cart->addToCart($this->poolProduct, 1, [], $from, $until);
|
||||
$cartItem2 = $this->cart->addToCart($this->poolProduct, 1, [], $from, $until);
|
||||
|
||||
$this->assertEquals($cartItem1->id, $cartItem2->id);
|
||||
$this->assertEquals(2, $cartItem2->quantity);
|
||||
$this->assertEquals(12000, $cartItem2->subtotal); // 3000 × 2 days × 2 units
|
||||
// Items from different single items don't merge, even with same dates
|
||||
$this->assertNotEquals($cartItem1->id, $cartItem2->id);
|
||||
$this->assertEquals(1, $cartItem1->quantity);
|
||||
$this->assertEquals(1, $cartItem2->quantity);
|
||||
|
||||
// Both items have the same price since they use pool fallback
|
||||
$this->assertEquals(6000, $cartItem1->subtotal); // 3000 × 2 days × 1 unit
|
||||
$this->assertEquals(6000, $cartItem2->subtotal); // 3000 × 2 days × 1 unit
|
||||
|
||||
// Total cart subtotal
|
||||
$this->assertEquals(12000, $this->cart->fresh()->getTotal()); // 6000 × 2 = 12000
|
||||
}
|
||||
|
||||
/** @test */
|
||||
|
|
@ -824,10 +839,13 @@ class CartAddToCartPoolPricingTest extends TestCase
|
|||
$availableQuantity = $this->poolProduct->getAvailableQuantity();
|
||||
$this->assertEquals(2, $availableQuantity);
|
||||
|
||||
// Adding 2 pool items should succeed (without dates)
|
||||
// Adding 2 pool items creates 2 cart items (one per single item)
|
||||
$cartItem = $this->cart->addToCart($this->poolProduct, 2);
|
||||
$this->assertNotNull($cartItem);
|
||||
$this->assertEquals(2, $cartItem->quantity);
|
||||
// Returns the last cart item (quantity 1)
|
||||
$this->assertEquals(1, $cartItem->quantity);
|
||||
// But total items should be 2
|
||||
$this->assertEquals(2, $this->cart->fresh()->items->sum('quantity'));
|
||||
|
||||
// Try to add 1 more (total would be 3, but only 2 available)
|
||||
$this->expectException(\Blax\Shop\Exceptions\NotEnoughStockException::class);
|
||||
|
|
@ -888,10 +906,14 @@ class CartAddToCartPoolPricingTest extends TestCase
|
|||
'customer_type' => get_class($this->user),
|
||||
]);
|
||||
|
||||
// Should be able to add 10 pool items
|
||||
// Adding 10 pool items creates multiple cart items (grouped by single item)
|
||||
// Since each single item stock is counted as 5+3+2=10
|
||||
$cartItem = $cart->addToCart($pool, 10);
|
||||
$this->assertNotNull($cartItem);
|
||||
$this->assertEquals(10, $cartItem->quantity);
|
||||
// Returns the last cart item (from VIP Spot with 2 stock)
|
||||
$this->assertEquals(2, $cartItem->quantity);
|
||||
// But total items in cart should sum to 10
|
||||
$this->assertEquals(10, $cart->fresh()->items->sum('quantity'));
|
||||
|
||||
// But not 11
|
||||
$this->expectException(\Blax\Shop\Exceptions\NotEnoughStockException::class);
|
||||
|
|
@ -944,10 +966,13 @@ class CartAddToCartPoolPricingTest extends TestCase
|
|||
'customer_type' => get_class($this->user),
|
||||
]);
|
||||
|
||||
// Should be able to book 5 pool items for the period
|
||||
// Adding 5 pool items creates multiple cart items (grouped by single item)
|
||||
$cartItem = $cart->addToCart($pool, 5, [], $from, $until);
|
||||
$this->assertNotNull($cartItem);
|
||||
$this->assertEquals(5, $cartItem->quantity);
|
||||
// Returns the last cart item (from Room B with 2 stock)
|
||||
$this->assertEquals(2, $cartItem->quantity);
|
||||
// But total items in cart should sum to 5
|
||||
$this->assertEquals(5, $cart->fresh()->items->sum('quantity'));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
|
|
|
|||
|
|
@ -0,0 +1,377 @@
|
|||
<?php
|
||||
|
||||
namespace Blax\Shop\Tests\Feature;
|
||||
|
||||
use Blax\Shop\Enums\ProductType;
|
||||
use Blax\Shop\Models\Cart;
|
||||
use Blax\Shop\Models\Product;
|
||||
use Blax\Shop\Models\ProductPrice;
|
||||
use Blax\Shop\Tests\TestCase;
|
||||
use Carbon\Carbon;
|
||||
use Workbench\App\Models\User;
|
||||
|
||||
/**
|
||||
* Test to reproduce and fix the production bug where:
|
||||
*
|
||||
* Pool (default price: 5000) with 6 singles:
|
||||
* 1. price: 50000
|
||||
* 2. price: none (should fallback to pool price 5000)
|
||||
* 3. price: none (should fallback to pool price 5000)
|
||||
* 4. price: none (should fallback to pool price 5000)
|
||||
* 5. price: 10001
|
||||
* 6. price: 10002
|
||||
*
|
||||
* When adding 7 items to cart, expected:
|
||||
* - 3x 5000 (from singles 2,3,4 using pool fallback price) = 15000
|
||||
* - 1x 10001 (from single 5) = 10001
|
||||
* - 1x 10002 (from single 6) = 10002
|
||||
* - 1x 50000 (from single 1) = 50000
|
||||
* Total: 85003
|
||||
*
|
||||
* But actual was:
|
||||
* - 7x 5000 = 35000
|
||||
*
|
||||
* Also: CartItems from/until and price/subtotal should be updated by cart->setDates
|
||||
*/
|
||||
class PoolProductionBugTest extends TestCase
|
||||
{
|
||||
protected User $user;
|
||||
protected Cart $cart;
|
||||
protected Product $pool;
|
||||
protected array $singles;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->user = User::factory()->create();
|
||||
auth()->login($this->user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the pool product matching production setup
|
||||
*/
|
||||
protected function createProductionPool(): void
|
||||
{
|
||||
// Create pool product with default price 5000
|
||||
$this->pool = Product::factory()->create([
|
||||
'name' => 'Production Pool',
|
||||
'type' => ProductType::POOL,
|
||||
'manage_stock' => false, // Pool doesn't manage stock - it's the responsibility of single items
|
||||
]);
|
||||
|
||||
// Pool default price: 5000
|
||||
ProductPrice::factory()->create([
|
||||
'purchasable_id' => $this->pool->id,
|
||||
'purchasable_type' => Product::class,
|
||||
'unit_amount' => 5000,
|
||||
'currency' => 'USD',
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
// Set pricing strategy to lowest
|
||||
$this->pool->setPoolPricingStrategy('lowest');
|
||||
|
||||
// Create 6 single items
|
||||
$this->singles = [];
|
||||
|
||||
// Single 1: price 50000
|
||||
$single1 = Product::factory()->create([
|
||||
'name' => 'Single 1 - 50000',
|
||||
'type' => ProductType::BOOKING,
|
||||
'manage_stock' => true,
|
||||
]);
|
||||
$single1->increaseStock(1);
|
||||
ProductPrice::factory()->create([
|
||||
'purchasable_id' => $single1->id,
|
||||
'purchasable_type' => Product::class,
|
||||
'unit_amount' => 50000,
|
||||
'currency' => 'USD',
|
||||
'is_default' => true,
|
||||
]);
|
||||
$this->singles[] = $single1;
|
||||
|
||||
// Single 2: NO price (should fallback to pool price 5000)
|
||||
$single2 = Product::factory()->create([
|
||||
'name' => 'Single 2 - No Price',
|
||||
'type' => ProductType::BOOKING,
|
||||
'manage_stock' => true,
|
||||
]);
|
||||
$single2->increaseStock(1);
|
||||
$this->singles[] = $single2;
|
||||
|
||||
// Single 3: NO price (should fallback to pool price 5000)
|
||||
$single3 = Product::factory()->create([
|
||||
'name' => 'Single 3 - No Price',
|
||||
'type' => ProductType::BOOKING,
|
||||
'manage_stock' => true,
|
||||
]);
|
||||
$single3->increaseStock(1);
|
||||
$this->singles[] = $single3;
|
||||
|
||||
// Single 4: NO price (should fallback to pool price 5000)
|
||||
$single4 = Product::factory()->create([
|
||||
'name' => 'Single 4 - No Price',
|
||||
'type' => ProductType::BOOKING,
|
||||
'manage_stock' => true,
|
||||
]);
|
||||
$single4->increaseStock(1);
|
||||
$this->singles[] = $single4;
|
||||
|
||||
// Single 5: price 10001
|
||||
$single5 = Product::factory()->create([
|
||||
'name' => 'Single 5 - 10001',
|
||||
'type' => ProductType::BOOKING,
|
||||
'manage_stock' => true,
|
||||
]);
|
||||
$single5->increaseStock(1);
|
||||
ProductPrice::factory()->create([
|
||||
'purchasable_id' => $single5->id,
|
||||
'purchasable_type' => Product::class,
|
||||
'unit_amount' => 10001,
|
||||
'currency' => 'USD',
|
||||
'is_default' => true,
|
||||
]);
|
||||
$this->singles[] = $single5;
|
||||
|
||||
// Single 6: price 10002
|
||||
$single6 = Product::factory()->create([
|
||||
'name' => 'Single 6 - 10002',
|
||||
'type' => ProductType::BOOKING,
|
||||
'manage_stock' => true,
|
||||
]);
|
||||
$single6->increaseStock(1);
|
||||
ProductPrice::factory()->create([
|
||||
'purchasable_id' => $single6->id,
|
||||
'purchasable_type' => Product::class,
|
||||
'unit_amount' => 10002,
|
||||
'currency' => 'USD',
|
||||
'is_default' => true,
|
||||
]);
|
||||
$this->singles[] = $single6;
|
||||
|
||||
// Attach all singles to pool
|
||||
$this->pool->attachSingleItems(array_map(fn($s) => $s->id, $this->singles));
|
||||
}
|
||||
|
||||
protected function createCart(): Cart
|
||||
{
|
||||
return Cart::factory()->create([
|
||||
'customer_id' => $this->user->id,
|
||||
'customer_type' => get_class($this->user),
|
||||
]);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function pool_max_quantity_returns_sum_of_single_item_stocks()
|
||||
{
|
||||
$this->createProductionPool();
|
||||
|
||||
// Total stock should be 6 (1 per single item)
|
||||
$maxQty = $this->pool->getPoolMaxQuantity();
|
||||
|
||||
$this->assertEquals(6, $maxQty);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function adding_7_items_should_throw_not_enough_stock_exception()
|
||||
{
|
||||
$this->createProductionPool();
|
||||
$this->cart = $this->createCart();
|
||||
|
||||
// Adding 7 items should throw exception since we only have 6 single items
|
||||
$this->expectException(\Blax\Shop\Exceptions\NotEnoughStockException::class);
|
||||
$this->cart->addToCart($this->pool, 7);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function adding_6_items_gives_correct_progressive_pricing()
|
||||
{
|
||||
$this->createProductionPool();
|
||||
$this->cart = $this->createCart();
|
||||
|
||||
// Add 6 items one at a time to verify progressive pricing
|
||||
// Expected order (LOWEST strategy):
|
||||
// 1. 5000 (single 2,3,4 using pool fallback - first one)
|
||||
// 2. 5000 (single 2,3,4 using pool fallback - second one)
|
||||
// 3. 5000 (single 2,3,4 using pool fallback - third one)
|
||||
// 4. 10001 (single 5)
|
||||
// 5. 10002 (single 6)
|
||||
// 6. 50000 (single 1)
|
||||
|
||||
$cartItem1 = $this->cart->addToCart($this->pool, 1);
|
||||
$this->assertEquals(5000, $cartItem1->price);
|
||||
$this->assertEquals(5000, $this->cart->fresh()->getTotal());
|
||||
|
||||
$cartItem2 = $this->cart->addToCart($this->pool, 1);
|
||||
$this->assertEquals(5000, $cartItem2->price);
|
||||
$this->assertEquals(10000, $this->cart->fresh()->getTotal());
|
||||
|
||||
$cartItem3 = $this->cart->addToCart($this->pool, 1);
|
||||
$this->assertEquals(5000, $cartItem3->price);
|
||||
$this->assertEquals(15000, $this->cart->fresh()->getTotal());
|
||||
|
||||
$cartItem4 = $this->cart->addToCart($this->pool, 1);
|
||||
$this->assertEquals(10001, $cartItem4->price);
|
||||
$this->assertEquals(25001, $this->cart->fresh()->getTotal());
|
||||
|
||||
$cartItem5 = $this->cart->addToCart($this->pool, 1);
|
||||
$this->assertEquals(10002, $cartItem5->price);
|
||||
$this->assertEquals(35003, $this->cart->fresh()->getTotal());
|
||||
|
||||
$cartItem6 = $this->cart->addToCart($this->pool, 1);
|
||||
$this->assertEquals(50000, $cartItem6->price);
|
||||
$this->assertEquals(85003, $this->cart->fresh()->getTotal());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function adding_6_items_at_once_gives_correct_pricing()
|
||||
{
|
||||
$this->createProductionPool();
|
||||
$this->cart = $this->createCart();
|
||||
|
||||
// Adding 6 items at once should give same total as adding one at a time
|
||||
// Expected: 3x5000 + 10001 + 10002 + 50000 = 85003
|
||||
$this->cart->addToCart($this->pool, 6);
|
||||
|
||||
$this->assertEquals(85003, $this->cart->fresh()->getTotal());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function cart_items_have_correct_allocated_single_items()
|
||||
{
|
||||
$this->createProductionPool();
|
||||
$this->cart = $this->createCart();
|
||||
|
||||
$this->cart->addToCart($this->pool, 6);
|
||||
|
||||
$items = $this->cart->fresh()->items->sortBy('price');
|
||||
|
||||
// Should have 4-6 cart items (depending on whether same-price items are merged)
|
||||
// The 3x 5000 items might be merged since they have the same price_id (pool price)
|
||||
// But different single items should NOT be merged
|
||||
|
||||
// Get all allocated single item names
|
||||
$allocatedNames = $items->map(fn($item) => [
|
||||
'name' => $item->getMeta()->allocated_single_item_name ?? 'unknown',
|
||||
'price' => $item->price,
|
||||
'quantity' => $item->quantity,
|
||||
])->toArray();
|
||||
|
||||
// Total quantity should be 6
|
||||
$totalQuantity = $items->sum('quantity');
|
||||
$this->assertEquals(6, $totalQuantity);
|
||||
|
||||
// Total price should be 85003
|
||||
$this->assertEquals(85003, $this->cart->getTotal());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function set_dates_updates_cart_item_dates_and_recalculates_prices()
|
||||
{
|
||||
$this->createProductionPool();
|
||||
$this->cart = $this->createCart();
|
||||
|
||||
$from1 = Carbon::tomorrow()->startOfDay();
|
||||
$until1 = Carbon::tomorrow()->addDay()->startOfDay(); // 1 day
|
||||
|
||||
// Add items with initial dates
|
||||
$this->cart->addToCart($this->pool, 3, [], $from1, $until1);
|
||||
|
||||
// Verify initial state - 3 items at 5000 each
|
||||
$initialTotal = $this->cart->fresh()->getTotal();
|
||||
$this->assertEquals(15000, $initialTotal);
|
||||
|
||||
// Change to 2 day booking
|
||||
$from2 = Carbon::tomorrow()->startOfDay();
|
||||
$until2 = Carbon::tomorrow()->addDays(2)->startOfDay(); // 2 days
|
||||
|
||||
$this->cart->setDates($from2, $until2);
|
||||
|
||||
// Reload cart
|
||||
$cart = $this->cart->fresh();
|
||||
$cart->load('items');
|
||||
|
||||
// Each cart item should now have:
|
||||
// - updated from/until dates
|
||||
// - doubled price (2 days instead of 1)
|
||||
foreach ($cart->items as $item) {
|
||||
$this->assertEquals($from2->format('Y-m-d H:i:s'), $item->from->format('Y-m-d H:i:s'));
|
||||
$this->assertEquals($until2->format('Y-m-d H:i:s'), $item->until->format('Y-m-d H:i:s'));
|
||||
// Price should be doubled (2 days)
|
||||
$this->assertEquals(10000, $item->price, "Item price should be 10000 (5000 * 2 days)");
|
||||
}
|
||||
|
||||
// Total should be doubled: 15000 * 2 = 30000
|
||||
$this->assertEquals(30000, $cart->getTotal());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function set_dates_updates_all_items_with_different_prices()
|
||||
{
|
||||
$this->createProductionPool();
|
||||
$this->cart = $this->createCart();
|
||||
|
||||
$from1 = Carbon::tomorrow()->startOfDay();
|
||||
$until1 = Carbon::tomorrow()->addDay()->startOfDay(); // 1 day
|
||||
|
||||
// Add 6 items with initial 1-day dates
|
||||
$this->cart->addToCart($this->pool, 6, [], $from1, $until1);
|
||||
|
||||
// Verify initial state
|
||||
$this->assertEquals(85003, $this->cart->fresh()->getTotal());
|
||||
|
||||
// Change to 2 day booking
|
||||
$from2 = Carbon::tomorrow()->startOfDay();
|
||||
$until2 = Carbon::tomorrow()->addDays(2)->startOfDay(); // 2 days
|
||||
|
||||
$this->cart->setDates($from2, $until2);
|
||||
|
||||
// Reload cart
|
||||
$cart = $this->cart->fresh();
|
||||
$cart->load('items');
|
||||
|
||||
// Each item should have updated dates
|
||||
foreach ($cart->items as $item) {
|
||||
$this->assertEquals($from2->format('Y-m-d H:i:s'), $item->from->format('Y-m-d H:i:s'));
|
||||
$this->assertEquals($until2->format('Y-m-d H:i:s'), $item->until->format('Y-m-d H:i:s'));
|
||||
}
|
||||
|
||||
// Total should be doubled: 85003 * 2 = 170006
|
||||
$this->assertEquals(170006, $cart->getTotal());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function adding_items_without_dates_then_setting_dates_works()
|
||||
{
|
||||
$this->createProductionPool();
|
||||
$this->cart = $this->createCart();
|
||||
|
||||
// Add items WITHOUT dates
|
||||
$this->cart->addToCart($this->pool, 3);
|
||||
|
||||
// Initial total should be 15000 (3x 5000)
|
||||
$this->assertEquals(15000, $this->cart->fresh()->getTotal());
|
||||
|
||||
// Now set dates for 2 days
|
||||
$from = Carbon::tomorrow()->startOfDay();
|
||||
$until = Carbon::tomorrow()->addDays(2)->startOfDay(); // 2 days
|
||||
|
||||
$this->cart->setDates($from, $until);
|
||||
|
||||
// Reload cart
|
||||
$cart = $this->cart->fresh();
|
||||
$cart->load('items');
|
||||
|
||||
// Each cart item should now have dates and doubled prices
|
||||
foreach ($cart->items as $item) {
|
||||
$this->assertEquals($from->format('Y-m-d H:i:s'), $item->from->format('Y-m-d H:i:s'));
|
||||
$this->assertEquals($until->format('Y-m-d H:i:s'), $item->until->format('Y-m-d H:i:s'));
|
||||
// Price should be doubled (2 days)
|
||||
$this->assertEquals(10000, $item->price, "Item price should be 10000 (5000 * 2 days)");
|
||||
}
|
||||
|
||||
// Total should be 30000 (3x 5000 x 2 days)
|
||||
$this->assertEquals(30000, $cart->getTotal());
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue