A cart->calendarAvailability, product->has_more

This commit is contained in:
Fabian @ Blax Software 2025-12-28 10:29:23 +01:00
parent fc3ae3e756
commit 37b3e6bdc0
4 changed files with 1067 additions and 7 deletions

View File

@ -1284,6 +1284,201 @@ class Cart extends Model
return $item ?? true; return $item ?? true;
} }
/**
* Get calendar availability for all items in the cart.
*
* This method aggregates availability across all cart items and returns
* the minimum availability for each date. This is useful for booking systems
* where you need to know when ALL items in a cart can be booked together.
*
* For each date, it calculates the minimum number of complete cart "sets"
* that could be fulfilled. A set is fulfilled when all items have at least
* one unit available.
*
* Returns associative array with keys:
* - 'max_available' => Shows the peak available "sets" in the date range
* - 'min_available' => Shows the lowest available "sets" in the date range
* - 'dates' => An array of dates with their respective min/max availability
* - 'items' => Individual item availability data (for debugging)
*
* @param \DateTimeInterface|null $from Start date of the range (optional, defaults to today)
* @param \DateTimeInterface|null $until End date of the range (optional, defaults to 30 days)
* @return array Associative array with 'max_available', 'min_available', 'dates', and 'items'
*/
public function calendarAvailability(
?\DateTimeInterface $from = null,
?\DateTimeInterface $until = null
): array {
$fromDate = Carbon::parse($from ?? now())->startOfDay();
$untilDate = Carbon::parse($until ?? $fromDate->copy()->addDays(30))->endOfDay();
// Load items with their purchasable products
if (!$this->relationLoaded('items')) {
$this->load('items.purchasable');
}
$items = $this->items;
if ($items->isEmpty()) {
return [
'max_available' => PHP_INT_MAX,
'min_available' => PHP_INT_MAX,
'dates' => [],
'items' => [],
];
}
// Collect availability data for each unique product in the cart
$productAvailabilities = [];
$itemDetails = [];
// Group items by product to handle multiple quantities of the same product
$productQuantities = [];
foreach ($items as $item) {
$product = $item->purchasable;
if (!$product) {
continue;
}
$productKey = get_class($product) . '|' . $product->id;
if (!isset($productQuantities[$productKey])) {
$productQuantities[$productKey] = [
'product' => $product,
'quantity' => 0,
];
}
$productQuantities[$productKey]['quantity'] += $item->quantity;
}
// Get calendar availability for each unique product
foreach ($productQuantities as $productKey => $data) {
$product = $data['product'];
$requiredQuantity = $data['quantity'];
// Check if product has the calendarAvailability method (uses HasStocks trait)
if (method_exists($product, 'calendarAvailability')) {
$availability = $product->calendarAvailability($from, $until);
$productAvailabilities[$productKey] = [
'availability' => $availability,
'required_quantity' => $requiredQuantity,
];
$itemDetails[$productKey] = [
'product_id' => $product->id,
'product_name' => $product->name ?? 'Unknown',
'required_quantity' => $requiredQuantity,
'availability' => $availability,
];
} else {
// Product doesn't have stock management - treat as unlimited
$productAvailabilities[$productKey] = [
'availability' => [
'max_available' => PHP_INT_MAX,
'min_available' => PHP_INT_MAX,
'dates' => [],
],
'required_quantity' => $requiredQuantity,
];
$itemDetails[$productKey] = [
'product_id' => $product->id,
'product_name' => $product->name ?? 'Unknown',
'required_quantity' => $requiredQuantity,
'availability' => [
'max_available' => PHP_INT_MAX,
'min_available' => PHP_INT_MAX,
'dates' => [],
],
];
}
}
// If no products have availability data, return unlimited
if (empty($productAvailabilities)) {
return [
'max_available' => PHP_INT_MAX,
'min_available' => PHP_INT_MAX,
'dates' => [],
'items' => $itemDetails,
];
}
// Build the combined calendar
$dates = [];
$globalMin = PHP_INT_MAX;
$globalMax = PHP_INT_MIN;
$currentDate = $fromDate->copy();
while ($currentDate->lte($untilDate)) {
$dateKey = $currentDate->toDateString();
$dayMin = PHP_INT_MAX;
$dayMax = PHP_INT_MAX;
foreach ($productAvailabilities as $productKey => $data) {
$availability = $data['availability'];
$requiredQuantity = $data['required_quantity'];
// Get the availability for this date
if (isset($availability['dates'][$dateKey])) {
$productDayData = $availability['dates'][$dateKey];
$productDayMin = $productDayData['min'] ?? 0;
$productDayMax = $productDayData['max'] ?? 0;
} else {
// No specific date data - use overall availability
$productDayMin = $availability['min_available'] ?? 0;
$productDayMax = $availability['max_available'] ?? 0;
}
// Calculate how many "sets" of the required quantity are available
if ($productDayMin === PHP_INT_MAX) {
$setsMin = PHP_INT_MAX;
} else {
$setsMin = $requiredQuantity > 0 ? intdiv($productDayMin, $requiredQuantity) : PHP_INT_MAX;
}
if ($productDayMax === PHP_INT_MAX) {
$setsMax = PHP_INT_MAX;
} else {
$setsMax = $requiredQuantity > 0 ? intdiv($productDayMax, $requiredQuantity) : PHP_INT_MAX;
}
// The cart availability is limited by the product with the least availability
$dayMin = min($dayMin, $setsMin);
$dayMax = min($dayMax, $setsMax);
}
// Handle PHP_INT_MAX edge case
if ($dayMin === PHP_INT_MAX) {
$dayMin = PHP_INT_MAX;
}
if ($dayMax === PHP_INT_MAX) {
$dayMax = PHP_INT_MAX;
}
$dates[$dateKey] = [
'min' => $dayMin,
'max' => $dayMax,
];
if ($dayMin !== PHP_INT_MAX) {
$globalMin = min($globalMin, $dayMin);
}
if ($dayMax !== PHP_INT_MAX && $dayMax !== PHP_INT_MIN) {
$globalMax = max($globalMax, $dayMax);
} elseif ($dayMax === PHP_INT_MAX && $globalMax === PHP_INT_MIN) {
// All products have unlimited availability
$globalMax = PHP_INT_MAX;
}
$currentDate->addDay();
}
return [
'max_available' => $globalMax === PHP_INT_MIN ? 0 : $globalMax,
'min_available' => $globalMin === PHP_INT_MAX ? PHP_INT_MAX : $globalMin,
'dates' => $dates,
'items' => $itemDetails,
];
}
/** /**
* Validate cart for checkout without converting it * Validate cart for checkout without converting it
* *

View File

@ -178,8 +178,8 @@ trait HasStocks
public function adjustStock( public function adjustStock(
StockType $type, StockType $type,
int $quantity, int $quantity,
\DateTimeInterface|null $until = null, DateTimeInterface|null $until = null,
\DateTimeInterface|null $from = null, DateTimeInterface|null $from = null,
?StockStatus $status = null, ?StockStatus $status = null,
string|null $note = null, string|null $note = null,
Model|null $referencable = null Model|null $referencable = null
@ -252,8 +252,8 @@ trait HasStocks
public function claimStock( public function claimStock(
int $quantity, int $quantity,
$reference = null, $reference = null,
?\DateTimeInterface $from = null, ?DateTimeInterface $from = null,
?\DateTimeInterface $until = null, ?DateTimeInterface $until = null,
?string $note = null ?string $note = null
): ?\Blax\Shop\Models\ProductStock { ): ?\Blax\Shop\Models\ProductStock {
@ -285,7 +285,7 @@ trait HasStocks
* *
* @return int Available quantity (PHP_INT_MAX if stock management disabled) * @return int Available quantity (PHP_INT_MAX if stock management disabled)
*/ */
public function getAvailableStock(?\DateTimeInterface $date = null): int public function getAvailableStock(?DateTimeInterface $date = null): int
{ {
if (!$this->manage_stock) { if (!$this->manage_stock) {
return PHP_INT_MAX; return PHP_INT_MAX;
@ -369,7 +369,7 @@ trait HasStocks
* @param \DateTimeInterface|null $from Optional start date to filter claims * @param \DateTimeInterface|null $from Optional start date to filter claims
* @return int Total future claimed quantity (always positive) * @return int Total future claimed quantity (always positive)
*/ */
public function getFutureClaimedStock(?\DateTimeInterface $from = null): int public function getFutureClaimedStock(?DateTimeInterface $from = null): int
{ {
$query = $this->stocks() $query = $this->stocks()
->where('type', StockType::CLAIMED->value) ->where('type', StockType::CLAIMED->value)
@ -503,7 +503,7 @@ trait HasStocks
* @param \DateTimeInterface $date The date to check availability for * @param \DateTimeInterface $date The date to check availability for
* @return int Available stock on that date (PHP_INT_MAX if stock management disabled) * @return int Available stock on that date (PHP_INT_MAX if stock management disabled)
*/ */
public function availableOnDate(\DateTimeInterface $date): int public function availableOnDate(DateTimeInterface $date): int
{ {
if (!$this->manage_stock) { if (!$this->manage_stock) {
return PHP_INT_MAX; return PHP_INT_MAX;
@ -849,4 +849,43 @@ trait HasStocks
return $aggregated; return $aggregated;
} }
/**
* Accounts the current cart, from/until and also for pool products
* @return int
*/
public function getHasMoreAttribute(): int
{
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;
}
if ($this->manage_stock === false) {
return PHP_INT_MAX;
}
return $this->getAvailableStock();
}
} }

View File

@ -0,0 +1,512 @@
<?php
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 PHPUnit\Framework\Attributes\Test;
use Workbench\App\Models\User;
class CartCalendarAvailabilityTest extends TestCase
{
use RefreshDatabase;
protected function createUserWithCart(): array
{
$user = User::create([
'id' => \Illuminate\Support\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_unlimited_availability_for_empty_cart()
{
[$user, $cart] = $this->createUserWithCart();
$availability = $cart->calendarAvailability();
$this->assertEquals(PHP_INT_MAX, $availability['max_available']);
$this->assertEquals(PHP_INT_MAX, $availability['min_available']);
$this->assertEmpty($availability['dates']);
}
#[Test]
public function it_returns_availability_for_single_product_in_cart()
{
[$user, $cart] = $this->createUserWithCart();
$product = Product::factory()->withStocks(50)->withPrices(1, 1000)->create();
$cart->addToCart($product, 1);
$availability = $cart->calendarAvailability();
$this->assertEquals(50, $availability['max_available']);
$this->assertEquals(50, $availability['min_available']);
$this->assertCount(31, $availability['dates']);
// All dates should have 50 available
foreach ($availability['dates'] as $dateKey => $dayData) {
$this->assertEquals(['min' => 50, 'max' => 50], $dayData, "Failed for date: $dateKey");
}
}
#[Test]
public function it_returns_minimum_availability_across_multiple_products()
{
[$user, $cart] = $this->createUserWithCart();
$product1 = Product::factory()->withStocks(100)->withPrices(1, 1000)->create();
$product2 = Product::factory()->withStocks(30)->withPrices(1, 500)->create();
$cart->addToCart($product1, 1);
$cart->addToCart($product2, 1);
$availability = $cart->calendarAvailability();
// The cart availability should be limited by the product with less stock
$this->assertEquals(30, $availability['max_available']);
$this->assertEquals(30, $availability['min_available']);
}
#[Test]
public function it_considers_required_quantity_when_calculating_sets()
{
[$user, $cart] = $this->createUserWithCart();
// Product with 10 units in stock
$product = Product::factory()->withStocks(10)->withPrices(1, 1000)->create();
// Add 3 of this product to cart
$cart->addToCart($product, 3);
$availability = $cart->calendarAvailability();
// With 10 in stock and 3 required per cart, we can fulfill 3 complete sets (10 / 3 = 3)
$this->assertEquals(3, $availability['max_available']);
$this->assertEquals(3, $availability['min_available']);
}
#[Test]
public function it_shows_availability_for_cart_with_booking_products()
{
[$user, $cart] = $this->createUserWithCart();
$product = Product::factory()->create([
'type' => ProductType::BOOKING,
'manage_stock' => true,
]);
$product->increaseStock(5);
// Create a price for the product
$product->prices()->create([
'unit_amount' => 10000,
'currency' => 'usd',
'is_default' => true,
]);
$cart->addToCart($product, 1, [], now()->addDays(5), now()->addDays(10));
$availability = $cart->calendarAvailability();
$this->assertEquals(5, $availability['max_available']);
$this->assertEquals(5, $availability['min_available']);
}
#[Test]
public function it_shows_reduced_availability_when_stock_is_claimed()
{
[$user, $cart] = $this->createUserWithCart();
$product = Product::factory()->create([
'type' => ProductType::BOOKING,
'manage_stock' => true,
]);
$product->increaseStock(10);
// Create a price for the product
$product->prices()->create([
'unit_amount' => 10000,
'currency' => 'usd',
'is_default' => true,
]);
// Claim 3 units for days 5-10
$product->claimStock(
quantity: 3,
from: now()->startOfDay()->addDays(5),
until: now()->endOfDay()->addDays(10)
);
$cart->addToCart($product, 1, [], now()->addDays(5), now()->addDays(10));
$availability = $cart->calendarAvailability();
// Before claim period (days 0-4): 10 available
$this->assertEquals(['min' => 10, 'max' => 10], $availability['dates'][now()->toDateString()]);
$this->assertEquals(['min' => 10, 'max' => 10], $availability['dates'][now()->addDays(4)->toDateString()]);
// During claim period (days 5-10): 7 available (10 - 3)
// Day 5: claim starts at startOfDay, so min=max=7 for the whole day
$this->assertEquals(['min' => 7, 'max' => 7], $availability['dates'][now()->addDays(5)->toDateString()]);
$this->assertEquals(['min' => 7, 'max' => 7], $availability['dates'][now()->addDays(7)->toDateString()]);
// After claim period: 10 available
$this->assertEquals(['min' => 10, 'max' => 10], $availability['dates'][now()->addDays(15)->toDateString()]);
}
#[Test]
public function it_shows_availability_for_cart_with_pool_products()
{
[$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' => 15000,
'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);
// Create a price for each single
$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,
]);
}
$cart->addToCart($pool, 1, [], now()->addDays(5), now()->addDays(10));
$availability = $cart->calendarAvailability();
$this->assertEquals(3, $availability['max_available']);
$this->assertEquals(3, $availability['min_available']);
}
#[Test]
public function it_shows_reduced_pool_availability_with_claims()
{
[$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' => 15000,
'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' => 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 single1 from day 5 to day 10
$singles[0]->claimStock(
quantity: 1,
from: now()->startOfDay()->addDays(5),
until: now()->endOfDay()->addDays(10)
);
$cart->addToCart($pool, 1, [], now()->addDays(5), now()->addDays(10));
$availability = $cart->calendarAvailability();
// Before claim: 3 available
$this->assertEquals(['min' => 3, 'max' => 3], $availability['dates'][now()->addDays(4)->toDateString()]);
// During claim: 2 available
$this->assertEquals(['min' => 2, 'max' => 2], $availability['dates'][now()->addDays(7)->toDateString()]);
// After claim: 3 available
$this->assertEquals(['min' => 3, 'max' => 3], $availability['dates'][now()->addDays(15)->toDateString()]);
}
#[Test]
public function it_handles_custom_date_range()
{
[$user, $cart] = $this->createUserWithCart();
$product = Product::factory()->withStocks(25)->withPrices(1, 1000)->create();
$cart->addToCart($product, 1);
$from = now()->addDays(10);
$until = now()->addDays(20);
$availability = $cart->calendarAvailability($from, $until);
$this->assertCount(11, $availability['dates']); // 10 to 20 inclusive
$this->assertEquals(25, $availability['max_available']);
$this->assertEquals(25, $availability['min_available']);
}
#[Test]
public function it_returns_minimum_sets_across_multiple_products_with_different_quantities()
{
[$user, $cart] = $this->createUserWithCart();
// Product 1: 20 in stock, need 4 = 5 sets available
$product1 = Product::factory()->withStocks(20)->withPrices(1, 1000)->create();
// Product 2: 15 in stock, need 5 = 3 sets available
$product2 = Product::factory()->withStocks(15)->withPrices(1, 500)->create();
$cart->addToCart($product1, 4);
$cart->addToCart($product2, 5);
$availability = $cart->calendarAvailability();
// Cart can only be fulfilled 3 times (limited by product2: 15/5 = 3)
$this->assertEquals(3, $availability['max_available']);
$this->assertEquals(3, $availability['min_available']);
}
#[Test]
public function it_handles_products_without_stock_management()
{
[$user, $cart] = $this->createUserWithCart();
// Product without stock management (unlimited)
$product = Product::factory()->create([
'manage_stock' => false,
]);
$product->prices()->create([
'unit_amount' => 1000,
'currency' => 'usd',
'is_default' => true,
]);
$cart->addToCart($product, 1);
$availability = $cart->calendarAvailability();
// Unlimited availability
$this->assertEquals(PHP_INT_MAX, $availability['max_available']);
$this->assertEquals(PHP_INT_MAX, $availability['min_available']);
}
#[Test]
public function it_combines_limited_and_unlimited_products()
{
[$user, $cart] = $this->createUserWithCart();
// Limited product
$limitedProduct = Product::factory()->withStocks(10)->withPrices(1, 1000)->create();
// Unlimited product
$unlimitedProduct = Product::factory()->create([
'manage_stock' => false,
]);
$unlimitedProduct->prices()->create([
'unit_amount' => 500,
'currency' => 'usd',
'is_default' => true,
]);
$cart->addToCart($limitedProduct, 2);
$cart->addToCart($unlimitedProduct, 1);
$availability = $cart->calendarAvailability();
// Limited by the limited product: 10 / 2 = 5 sets
$this->assertEquals(5, $availability['max_available']);
$this->assertEquals(5, $availability['min_available']);
}
#[Test]
public function it_returns_item_details_for_debugging()
{
[$user, $cart] = $this->createUserWithCart();
$product1 = Product::factory()->withStocks(50)->withPrices(1, 1000)->create([
'name' => 'Product One',
]);
$product2 = Product::factory()->withStocks(30)->withPrices(1, 500)->create([
'name' => 'Product Two',
]);
$cart->addToCart($product1, 2);
$cart->addToCart($product2, 1);
$availability = $cart->calendarAvailability();
$this->assertArrayHasKey('items', $availability);
$this->assertCount(2, $availability['items']);
// Verify item details are included
$itemKeys = array_keys($availability['items']);
foreach ($itemKeys as $key) {
$item = $availability['items'][$key];
$this->assertArrayHasKey('product_id', $item);
$this->assertArrayHasKey('product_name', $item);
$this->assertArrayHasKey('required_quantity', $item);
$this->assertArrayHasKey('availability', $item);
}
}
#[Test]
public function it_handles_overlapping_claims_for_multiple_products()
{
[$user, $cart] = $this->createUserWithCart();
// Product 1: 100 stock, claim 30 on days 5-10
$product1 = Product::factory()->create([
'name' => 'Product 1',
'type' => ProductType::BOOKING,
'manage_stock' => true,
]);
$product1->increaseStock(100);
$product1->prices()->create([
'unit_amount' => 1000,
'currency' => 'usd',
'is_default' => true,
]);
$product1->claimStock(
quantity: 30,
from: now()->startOfDay()->addDays(5),
until: now()->endOfDay()->addDays(10)
);
// Product 2: 50 stock, claim 20 on days 8-15
$product2 = Product::factory()->create([
'name' => 'Product 2',
'type' => ProductType::BOOKING,
'manage_stock' => true,
]);
$product2->increaseStock(50);
$product2->prices()->create([
'unit_amount' => 500,
'currency' => 'usd',
'is_default' => true,
]);
$product2->claimStock(
quantity: 20,
from: now()->startOfDay()->addDays(8),
until: now()->endOfDay()->addDays(15)
);
$cart->addToCart($product1, 1, [], now()->addDays(5), now()->addDays(10));
$cart->addToCart($product2, 1, [], now()->addDays(5), now()->addDays(10));
$availability = $cart->calendarAvailability();
// Day 0-4: product1=100, product2=50 -> min(100, 50) = 50
$this->assertEquals(['min' => 50, 'max' => 50], $availability['dates'][now()->toDateString()]);
// Day 6-7: product1=70, product2=50 -> min(70, 50) = 50
$this->assertEquals(['min' => 50, 'max' => 50], $availability['dates'][now()->addDays(6)->toDateString()]);
// Day 9: product1=70, product2=30 -> min(70, 30) = 30
$this->assertEquals(['min' => 30, 'max' => 30], $availability['dates'][now()->addDays(9)->toDateString()]);
// Day 12: product1=100, product2=30 -> min(100, 30) = 30
$this->assertEquals(['min' => 30, 'max' => 30], $availability['dates'][now()->addDays(12)->toDateString()]);
// Day 20: product1=100, product2=50 -> min(100, 50) = 50
$this->assertEquals(['min' => 50, 'max' => 50], $availability['dates'][now()->addDays(20)->toDateString()]);
}
#[Test]
public function it_handles_same_product_added_multiple_times()
{
[$user, $cart] = $this->createUserWithCart();
$product = Product::factory()->withStocks(15)->withPrices(1, 1000)->create();
// Add the same product twice
$cart->addToCart($product, 2);
$cart->addToCart($product, 3);
$availability = $cart->calendarAvailability();
// Total required: 5, available: 15 -> 3 sets (15 / 5 = 3)
$this->assertEquals(3, $availability['max_available']);
$this->assertEquals(3, $availability['min_available']);
}
#[Test]
public function it_returns_zero_when_no_stock_available()
{
[$user, $cart] = $this->createUserWithCart();
$product = Product::factory()->create([
'manage_stock' => true,
]);
// No stock added
$product->prices()->create([
'unit_amount' => 1000,
'currency' => 'usd',
'is_default' => true,
]);
$cart->addToCart($product, 1);
$availability = $cart->calendarAvailability();
$this->assertEquals(0, $availability['max_available']);
$this->assertEquals(0, $availability['min_available']);
}
}

View File

@ -0,0 +1,314 @@
<?php
namespace Blax\Shop\Tests\Feature;
use Blax\Shop\Enums\ProductType;
use Blax\Shop\Models\Product;
use Blax\Shop\Tests\TestCase;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
class GetHasMoreAttributeTest extends TestCase
{
use RefreshDatabase;
#[Test]
public function it_returns_php_int_max_when_stock_management_is_disabled()
{
$product = Product::factory()->create([
'manage_stock' => false,
]);
$this->assertEquals(PHP_INT_MAX, $product->has_more);
}
#[Test]
public function it_returns_available_stock_for_simple_product()
{
$product = Product::factory()->withStocks(50)->create();
$this->assertEquals(50, $product->has_more);
}
#[Test]
public function it_returns_zero_when_no_stock_available()
{
$product = Product::factory()->create([
'manage_stock' => true,
]);
$this->assertEquals(0, $product->has_more);
}
#[Test]
public function it_returns_remaining_stock_after_claims()
{
$product = Product::factory()->withStocks(100)->create();
// Claim 30 units
$product->claimStock(
quantity: 30,
from: now(),
until: now()->addDays(5)
);
$this->assertEquals(70, $product->has_more);
}
#[Test]
public function it_returns_available_stock_for_booking_product()
{
$product = Product::factory()->create([
'type' => ProductType::BOOKING,
'manage_stock' => true,
]);
$product->increaseStock(10);
// Claim 3 units for a future period
$product->claimStock(
quantity: 3,
from: now()->addDays(5),
until: now()->addDays(10)
);
// Has more should reflect available stock at current time
$this->assertEquals(10, $product->has_more);
}
#[Test]
public function it_returns_aggregated_availability_for_pool_product()
{
// Create pool product
$pool = Product::factory()->create([
'name' => 'Hotel Rooms',
'type' => ProductType::POOL,
'manage_stock' => false,
]);
// Create 3 single items with stock
$single1 = Product::factory()->create([
'name' => 'Room 101',
'type' => ProductType::BOOKING,
'manage_stock' => true,
]);
$single1->increaseStock(1);
$single2 = Product::factory()->create([
'name' => 'Room 102',
'type' => ProductType::BOOKING,
'manage_stock' => true,
]);
$single2->increaseStock(1);
$single3 = Product::factory()->create([
'name' => 'Room 103',
'type' => ProductType::BOOKING,
'manage_stock' => true,
]);
$single3->increaseStock(1);
// Attach singles to pool
foreach ([$single1, $single2, $single3] as $single) {
$pool->productRelations()->attach($single->id, [
'type' => \Blax\Shop\Enums\ProductRelationType::SINGLE->value,
]);
}
// Pool should aggregate availability from all singles: 1 + 1 + 1 = 3
$this->assertEquals(3, $pool->has_more);
}
#[Test]
public function it_returns_aggregated_availability_for_pool_with_claims()
{
// Create pool product
$pool = Product::factory()->create([
'name' => 'Hotel Rooms',
'type' => ProductType::POOL,
'manage_stock' => false,
]);
// 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);
$singles[] = $single;
}
foreach ($singles as $single) {
$pool->productRelations()->attach($single->id, [
'type' => \Blax\Shop\Enums\ProductRelationType::SINGLE->value,
]);
}
// Claim single1 from now until next week (active claim)
$singles[0]->claimStock(
quantity: 1,
from: now(),
until: now()->addDays(7)
);
// Pool should show 2 available (singles 2 and 3)
$this->assertEquals(2, $pool->has_more);
}
#[Test]
public function it_returns_aggregated_availability_for_pool_with_future_claims()
{
// Create pool product
$pool = Product::factory()->create([
'name' => 'Rental Cars',
'type' => ProductType::POOL,
'manage_stock' => false,
]);
// Create 2 single items with stock
$single1 = Product::factory()->create([
'name' => 'Car 1',
'type' => ProductType::BOOKING,
'manage_stock' => true,
]);
$single1->increaseStock(1);
$single2 = Product::factory()->create([
'name' => 'Car 2',
'type' => ProductType::BOOKING,
'manage_stock' => true,
]);
$single2->increaseStock(1);
foreach ([$single1, $single2] as $single) {
$pool->productRelations()->attach($single->id, [
'type' => \Blax\Shop\Enums\ProductRelationType::SINGLE->value,
]);
}
// Claim single1 for a FUTURE period (not yet active)
$single1->claimStock(
quantity: 1,
from: now()->addDays(5),
until: now()->addDays(10)
);
// Pool should show 2 available (both cars available NOW, claim starts in future)
$this->assertEquals(2, $pool->has_more);
}
#[Test]
public function it_returns_php_int_max_for_pool_with_unmanaged_singles()
{
// Create pool product
$pool = Product::factory()->create([
'name' => 'Digital Products Pool',
'type' => ProductType::POOL,
'manage_stock' => false,
]);
// Create single items WITHOUT stock management (unlimited)
$single1 = Product::factory()->create([
'name' => 'Digital Item 1',
'type' => ProductType::SIMPLE,
'manage_stock' => false,
]);
$single2 = Product::factory()->create([
'name' => 'Digital Item 2',
'type' => ProductType::SIMPLE,
'manage_stock' => false,
]);
foreach ([$single1, $single2] as $single) {
$pool->productRelations()->attach($single->id, [
'type' => \Blax\Shop\Enums\ProductRelationType::SINGLE->value,
]);
}
// Pool with all unlimited singles should return a very large number
// (sum of PHP_INT_MAX values, which indicates unlimited availability)
$this->assertGreaterThanOrEqual(PHP_INT_MAX, $pool->has_more);
}
#[Test]
public function it_returns_zero_for_empty_pool()
{
$pool = Product::factory()->create([
'name' => 'Empty Pool',
'type' => ProductType::POOL,
'manage_stock' => false,
]);
$this->assertEquals(0, $pool->has_more);
}
#[Test]
public function it_returns_zero_when_all_stock_is_claimed()
{
$product = Product::factory()->withStocks(10)->create();
// Claim all 10 units
$product->claimStock(
quantity: 10,
from: now(),
until: now()->addDays(5)
);
$this->assertEquals(0, $product->has_more);
}
#[Test]
public function it_correctly_handles_mixed_managed_and_unmanaged_pool_singles()
{
$pool = Product::factory()->create([
'name' => 'Mixed Pool',
'type' => ProductType::POOL,
'manage_stock' => false,
]);
// Single 1: managed stock
$single1 = Product::factory()->create([
'name' => 'Limited Item',
'type' => ProductType::SIMPLE,
'manage_stock' => true,
]);
$single1->increaseStock(5);
// Single 2: unmanaged stock (unlimited)
$single2 = Product::factory()->create([
'name' => 'Unlimited Item',
'type' => ProductType::SIMPLE,
'manage_stock' => false,
]);
foreach ([$single1, $single2] as $single) {
$pool->productRelations()->attach($single->id, [
'type' => \Blax\Shop\Enums\ProductRelationType::SINGLE->value,
]);
}
// Pool should sum: 5 (limited) + PHP_INT_MAX (unlimited)
// Result will be very large, indicating effectively unlimited availability
$this->assertGreaterThanOrEqual(PHP_INT_MAX, $pool->has_more);
}
#[Test]
public function it_returns_correct_stock_after_multiple_increases_and_decreases()
{
$product = Product::factory()->create([
'manage_stock' => true,
]);
$product->increaseStock(100);
$product->decreaseStock(20);
$product->increaseStock(50);
$product->decreaseStock(30);
// 100 - 20 + 50 - 30 = 100
$this->assertEquals(100, $product->has_more);
}
}