From 6beecf597ca806716085a70d29277870269e9f47 Mon Sep 17 00:00:00 2001 From: "Fabian @ Blax Software" Date: Sun, 28 Dec 2025 10:48:22 +0100 Subject: [PATCH] BF has_more, I hasprices->fromPrice --- src/Traits/HasPrices.php | 7 + src/Traits/HasStocks.php | 161 ++++++++++++--- tests/Feature/GetHasMoreAttributeTest.php | 234 ++++++++++++++++++++++ 3 files changed, 374 insertions(+), 28 deletions(-) diff --git a/src/Traits/HasPrices.php b/src/Traits/HasPrices.php index e14a4dc..7581405 100644 --- a/src/Traits/HasPrices.php +++ b/src/Traits/HasPrices.php @@ -70,4 +70,11 @@ trait HasPrices { return $this->prices()->exists(); } + + public static function fromPrice($price_id) + { + return static::whereHas('prices', function ($q) use ($price_id) { + $q->where('id', $price_id); + })->first(); + } } diff --git a/src/Traits/HasStocks.php b/src/Traits/HasStocks.php index 2a246e5..fe0344b 100644 --- a/src/Traits/HasStocks.php +++ b/src/Traits/HasStocks.php @@ -851,41 +851,146 @@ trait HasStocks } /** - * Accounts the current cart, from/until and also for pool products - * @return int + * Get remaining available stock, accounting for cart items and date range + * + * This method calculates how many more units can be added to a cart: + * - For pool products: aggregates availability from all single items minus cart items + * - For booking products: considers the date range for availability + * - Subtracts items already in the provided cart + * + * @param \Blax\Shop\Models\Cart|null $cart Optional cart to subtract items from + * @param \DateTimeInterface|null $from Optional start date for booking availability + * @param \DateTimeInterface|null $until Optional end date for booking availability + * @return int Available quantity (PHP_INT_MAX if unlimited) */ - public function getHasMoreAttribute(): int - { + public function getHasMore( + $cart = null, + ?\DateTimeInterface $from = null, + ?\DateTimeInterface $until = null + ): int { + // Try to get current cart from facade if not provided + if ($cart === null) { + try { + $cart = \Blax\Shop\Facades\Cart::current(); + } catch (\Exception $e) { + // No cart available, that's fine + $cart = null; + } + } + + // Get from/until from cart if not provided + if ($cart && $from === null && $until === null) { + $from = $cart->from; + $until = $cart->until; + } + if (method_exists($this, 'isPool') && $this->isPool()) { - // For pool products, check availability across all single items - if (!$this->relationLoaded('singleProducts')) { - $this->load('singleProducts'); - } - - $totalAvailable = 0; - foreach ($this->singleProducts as $single) { - $singleAvailable = $single->getHasMoreAttribute(); - - // If any single has unlimited availability, the pool effectively has unlimited - if ($singleAvailable === PHP_INT_MAX) { - return PHP_INT_MAX; - } - - $totalAvailable += $singleAvailable; - - // Prevent overflow - cap at PHP_INT_MAX - if ($totalAvailable >= PHP_INT_MAX || $totalAvailable < 0) { - return PHP_INT_MAX; - } - } - - return $totalAvailable; + return $this->getPoolHasMore($cart, $from, $until); } if ($this->manage_stock === false) { return PHP_INT_MAX; } - return $this->getAvailableStock(); + // Get base available stock (considering date range for bookings) + $baseAvailable = ($from && $until && method_exists($this, 'isBooking') && $this->isBooking()) + ? $this->getMinAvailableInRange($from, $until) + : $this->getAvailableStock(); + + // Subtract items already in cart for this product + if ($cart) { + $inCart = $cart->items() + ->where('purchasable_id', $this->getKey()) + ->where('purchasable_type', get_class($this)) + ->sum('quantity'); + + $baseAvailable = max(0, $baseAvailable - $inCart); + } + + return $baseAvailable; + } + + /** + * Get remaining availability for pool products, accounting for cart and dates + * + * @param \Blax\Shop\Models\Cart|null $cart + * @param \DateTimeInterface|null $from + * @param \DateTimeInterface|null $until + * @return int + */ + protected function getPoolHasMore( + $cart = null, + ?\DateTimeInterface $from = null, + ?\DateTimeInterface $until = null + ): int { + if (!$this->relationLoaded('singleProducts')) { + $this->load('singleProducts'); + } + + $totalAvailable = 0; + + foreach ($this->singleProducts as $single) { + $singleAvailable = $single->getHasMore(null, $from, $until); + + if ($singleAvailable === PHP_INT_MAX) { + return PHP_INT_MAX; + } + + $totalAvailable += $singleAvailable; + + if ($totalAvailable >= PHP_INT_MAX || $totalAvailable < 0) { + return PHP_INT_MAX; + } + } + + // Subtract pool items already in cart + if ($cart) { + $inCart = $cart->items() + ->where('purchasable_id', $this->getKey()) + ->where('purchasable_type', get_class($this)) + ->sum('quantity'); + + $totalAvailable = max(0, $totalAvailable - $inCart); + } + + return $totalAvailable; + } + + /** + * Get minimum available stock across a date range + * + * @param \DateTimeInterface $from + * @param \DateTimeInterface $until + * @return int + */ + protected function getMinAvailableInRange(\DateTimeInterface $from, \DateTimeInterface $until): int + { + $availability = $this->calendarAvailability($from, $until); + + if (empty($availability['dates'])) { + return $availability['min_available'] ?? 0; + } + + $minAvailable = PHP_INT_MAX; + foreach ($availability['dates'] as $dayData) { + $minAvailable = min($minAvailable, $dayData['min'] ?? 0); + } + + return $minAvailable === PHP_INT_MAX ? 0 : $minAvailable; + } + + /** + * Attribute accessor for has_more + * + * Returns available stock accounting for: + * - Current cart (from Cart facade) + * - Cart's from/until dates for bookings + * - Pool product aggregation + * + * @return int Available quantity (PHP_INT_MAX if unlimited) + */ + public function getHasMoreAttribute(): int + { + return $this->getHasMore(); } } diff --git a/tests/Feature/GetHasMoreAttributeTest.php b/tests/Feature/GetHasMoreAttributeTest.php index e077b0a..3522bdc 100644 --- a/tests/Feature/GetHasMoreAttributeTest.php +++ b/tests/Feature/GetHasMoreAttributeTest.php @@ -3,16 +3,33 @@ namespace Blax\Shop\Tests\Feature; use Blax\Shop\Enums\ProductType; +use Blax\Shop\Models\Cart; use Blax\Shop\Models\Product; use Blax\Shop\Tests\TestCase; use Carbon\Carbon; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Str; use PHPUnit\Framework\Attributes\Test; +use Workbench\App\Models\User; class GetHasMoreAttributeTest extends TestCase { use RefreshDatabase; + protected function createUserWithCart(): array + { + $user = User::create([ + 'id' => Str::uuid(), + 'name' => 'Test User', + 'email' => 'test@example.com', + 'password' => bcrypt('password'), + ]); + + $cart = Cart::factory()->forCustomer($user)->create(); + + return [$user, $cart]; + } + #[Test] public function it_returns_php_int_max_when_stock_management_is_disabled() { @@ -311,4 +328,221 @@ class GetHasMoreAttributeTest extends TestCase // 100 - 20 + 50 - 30 = 100 $this->assertEquals(100, $product->has_more); } + + #[Test] + public function it_subtracts_cart_items_from_available_stock() + { + [$user, $cart] = $this->createUserWithCart(); + + $product = Product::factory()->withStocks(10)->withPrices(1, 1000)->create(); + + // Add 3 to cart + $cart->addToCart($product, 3); + + // Use getHasMore with explicit cart - should show 7 remaining + $this->assertEquals(7, $product->getHasMore($cart)); + } + + #[Test] + public function it_subtracts_cart_items_from_pool_availability() + { + [$user, $cart] = $this->createUserWithCart(); + + // Create pool product + $pool = Product::factory()->create([ + 'name' => 'Hotel Rooms', + 'type' => ProductType::POOL, + 'manage_stock' => false, + ]); + + // Create a price for the pool + $pool->prices()->create([ + 'unit_amount' => 10000, + 'currency' => 'usd', + 'is_default' => true, + ]); + + // Create 3 single items with stock + $singles = []; + for ($i = 1; $i <= 3; $i++) { + $single = Product::factory()->create([ + 'name' => "Room 10{$i}", + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $single->increaseStock(1); + + $single->prices()->create([ + 'unit_amount' => 10000, + 'currency' => 'usd', + 'is_default' => true, + ]); + + $singles[] = $single; + } + + foreach ($singles as $single) { + $pool->productRelations()->attach($single->id, [ + 'type' => \Blax\Shop\Enums\ProductRelationType::SINGLE->value, + ]); + } + + // Pool should have 3 available initially + $this->assertEquals(3, $pool->getHasMore($cart)); + + // Add 2 rooms to cart + $cart->addToCart($pool, 1, [], now()->addDays(5), now()->addDays(10)); + $cart->addToCart($pool, 1, [], now()->addDays(5), now()->addDays(10)); + + // Now pool should show 1 remaining + $this->assertEquals(1, $pool->getHasMore($cart)); + + // Add the last room + $cart->addToCart($pool, 1, [], now()->addDays(5), now()->addDays(10)); + + // Now pool should show 0 remaining + $this->assertEquals(0, $pool->getHasMore($cart)); + } + + #[Test] + public function it_considers_date_range_for_booking_products() + { + [$user, $cart] = $this->createUserWithCart(); + + $product = Product::factory()->create([ + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $product->increaseStock(10); + + $product->prices()->create([ + 'unit_amount' => 5000, + 'currency' => 'usd', + 'is_default' => true, + ]); + + // Claim 5 units for days 5-10 + $product->claimStock( + quantity: 5, + from: now()->startOfDay()->addDays(5), + until: now()->endOfDay()->addDays(10) + ); + + // Without date range: should show current available (10) + $this->assertEquals(10, $product->getHasMore($cart)); + + // With date range during claim: should show 5 available + $this->assertEquals(5, $product->getHasMore($cart, now()->addDays(6), now()->addDays(8))); + + // With date range outside claim: should show 10 available + $this->assertEquals(10, $product->getHasMore($cart, now()->addDays(15), now()->addDays(20))); + } + + #[Test] + public function it_uses_cart_dates_when_from_until_not_provided() + { + [$user, $cart] = $this->createUserWithCart(); + + $product = Product::factory()->create([ + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $product->increaseStock(10); + + $product->prices()->create([ + 'unit_amount' => 5000, + 'currency' => 'usd', + 'is_default' => true, + ]); + + // Claim 4 units for days 5-10 + $product->claimStock( + quantity: 4, + from: now()->startOfDay()->addDays(5), + until: now()->endOfDay()->addDays(10) + ); + + // Set cart dates to be during the claim period + $cart->update([ + 'from' => now()->addDays(6), + 'until' => now()->addDays(8), + ]); + $cart->refresh(); + + // Should use cart's from/until to determine availability (6 available during claim) + $this->assertEquals(6, $product->getHasMore($cart)); + } + + #[Test] + public function it_combines_cart_items_and_date_range_for_pool_products() + { + [$user, $cart] = $this->createUserWithCart(); + + // Create pool product + $pool = Product::factory()->create([ + 'name' => 'Rental Cars', + 'type' => ProductType::POOL, + 'manage_stock' => false, + ]); + + $pool->prices()->create([ + 'unit_amount' => 15000, + 'currency' => 'usd', + 'is_default' => true, + ]); + + // Create 5 single items with stock + $singles = []; + for ($i = 1; $i <= 5; $i++) { + $single = Product::factory()->create([ + 'name' => "Car {$i}", + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $single->increaseStock(1); + + $single->prices()->create([ + 'unit_amount' => 15000, + 'currency' => 'usd', + 'is_default' => true, + ]); + + $singles[] = $single; + } + + foreach ($singles as $single) { + $pool->productRelations()->attach($single->id, [ + 'type' => \Blax\Shop\Enums\ProductRelationType::SINGLE->value, + ]); + } + + // Claim 2 cars for days 5-10 + $singles[0]->claimStock( + quantity: 1, + from: now()->startOfDay()->addDays(5), + until: now()->endOfDay()->addDays(10) + ); + $singles[1]->claimStock( + quantity: 1, + from: now()->startOfDay()->addDays(5), + until: now()->endOfDay()->addDays(10) + ); + + // Set cart dates during claim period + $cart->update([ + 'from' => now()->addDays(6), + 'until' => now()->addDays(8), + ]); + $cart->refresh(); + + // During claim: 3 cars available (5 - 2 claimed) + $this->assertEquals(3, $pool->getHasMore($cart)); + + // Add 2 cars to cart + $cart->addToCart($pool, 1, [], now()->addDays(6), now()->addDays(8)); + $cart->addToCart($pool, 1, [], now()->addDays(6), now()->addDays(8)); + + // Now should show 1 remaining (3 - 2 in cart) + $this->assertEquals(1, $pool->getHasMore($cart)); + } }