From 7360391581381349b1cd37a6d44824e3ac2b63ae Mon Sep 17 00:00:00 2001 From: "Fabian @ Blax Software" Date: Wed, 17 Dec 2025 12:47:18 +0100 Subject: [PATCH] BF pool priority claiming, IA date cast --- src/Casts/HtmlDateTimeCast.php | 70 +++++++ src/Models/Cart.php | 5 +- src/Models/CartItem.php | 5 +- src/Traits/MayBePoolProduct.php | 35 +++- tests/Feature/PoolClaimingPriorityTest.php | 228 +++++++++++++++++++++ tests/Unit/HtmlDateTimeCastTest.php | 155 ++++++++++++++ 6 files changed, 489 insertions(+), 9 deletions(-) create mode 100644 src/Casts/HtmlDateTimeCast.php create mode 100644 tests/Feature/PoolClaimingPriorityTest.php create mode 100644 tests/Unit/HtmlDateTimeCastTest.php diff --git a/src/Casts/HtmlDateTimeCast.php b/src/Casts/HtmlDateTimeCast.php new file mode 100644 index 0000000..44695e3 --- /dev/null +++ b/src/Casts/HtmlDateTimeCast.php @@ -0,0 +1,70 @@ +created_at->format('Y-m-d\TH:i') + */ +class HtmlDateTimeCast implements CastsAttributes +{ + /** + * Cast the given value. + * + * @param array $attributes + */ + public function get(Model $model, string $key, mixed $value, array $attributes): ?Carbon + { + if ($value === null) { + return null; + } + + // Convert timestamp to Carbon + return Carbon::createFromTimestamp($value); + } + + /** + * Prepare the given value for storage. + * + * @param array $attributes + */ + public function set(Model $model, string $key, mixed $value, array $attributes): ?int + { + if ($value === null) { + return null; + } + + // Handle Carbon instances + if ($value instanceof Carbon) { + return $value->timestamp; + } + + // Handle DateTimeInterface + if ($value instanceof \DateTimeInterface) { + return Carbon::instance($value)->timestamp; + } + + // Handle string input (including HTML5 datetime-local format) + if (is_string($value)) { + return Carbon::parse($value)->timestamp; + } + + // Handle numeric timestamp + if (is_numeric($value)) { + return (int) $value; + } + + throw new \InvalidArgumentException( + "Invalid datetime value for {$key}. Expected string, DateTimeInterface, Carbon, or timestamp." + ); + } +} diff --git a/src/Models/Cart.php b/src/Models/Cart.php index 3d3b9cf..4de90a1 100644 --- a/src/Models/Cart.php +++ b/src/Models/Cart.php @@ -2,6 +2,7 @@ namespace Blax\Shop\Models; +use Blax\Shop\Casts\HtmlDateTimeCast; use Blax\Shop\Contracts\Cartable; use Blax\Shop\Enums\CartStatus; use Blax\Shop\Enums\ProductType; @@ -40,8 +41,8 @@ class Cart extends Model 'converted_at' => 'datetime', 'last_activity_at' => 'datetime', 'meta' => 'object', - 'from_date' => 'datetime', - 'until_date' => 'datetime', + 'from_date' => HtmlDateTimeCast::class, + 'until_date' => HtmlDateTimeCast::class, ]; protected $appends = [ diff --git a/src/Models/CartItem.php b/src/Models/CartItem.php index d54c03e..6702a9c 100644 --- a/src/Models/CartItem.php +++ b/src/Models/CartItem.php @@ -2,6 +2,7 @@ namespace Blax\Shop\Models; +use Blax\Shop\Casts\HtmlDateTimeCast; use Blax\Shop\Exceptions\InvalidDateRangeException; use Blax\Workkit\Traits\HasMeta; use Illuminate\Database\Eloquent\Concerns\HasUuids; @@ -35,8 +36,8 @@ class CartItem extends Model 'subtotal' => 'decimal:2', 'parameters' => 'array', 'meta' => 'array', - 'from' => 'datetime', - 'until' => 'datetime', + 'from' => HtmlDateTimeCast::class, + 'until' => HtmlDateTimeCast::class, ]; protected $appends = [ diff --git a/src/Traits/MayBePoolProduct.php b/src/Traits/MayBePoolProduct.php index 00ebb41..67e7dde 100644 --- a/src/Traits/MayBePoolProduct.php +++ b/src/Traits/MayBePoolProduct.php @@ -107,7 +107,7 @@ trait MayBePoolProduct /** * Claim stock for a pool product - * This will claim stock from the available single items + * This will claim stock from the available single items, respecting the pricing strategy * * @param int $quantity Number of pool items to claim * @param mixed $reference Reference model @@ -134,13 +134,28 @@ trait MayBePoolProduct throw new \Exception('Pool product has no single items to claim'); } - // Get available single items for the period + // Get pricing strategy + $strategy = $this->getPricingStrategy(); + + // Build list of available single items with their prices $availableItems = []; foreach ($singleItems as $item) { if ($item->isAvailableForBooking($from, $until, 1)) { - $availableItems[] = $item; + // Get the price for this item + $price = $item->defaultPrice()->first()?->getCurrentPrice($item->isOnSale()); + + // If item has no price, use pool's fallback price + if ($price === null && $this->hasPrice()) { + $price = $this->defaultPrice()->first()?->getCurrentPrice($this->isOnSale()); + } + + $availableItems[] = [ + 'item' => $item, + 'price' => $price ?? PHP_FLOAT_MAX, // Items without prices go last + ]; } + // Early exit if we have enough if (count($availableItems) >= $quantity) { break; } @@ -150,9 +165,19 @@ trait MayBePoolProduct throw new \Exception("Only " . count($availableItems) . " items available, but {$quantity} requested"); } - // Claim stock from each selected single item + // Sort by pricing strategy + usort($availableItems, function ($a, $b) use ($strategy) { + return match ($strategy) { + \Blax\Shop\Enums\PricingStrategy::LOWEST => $a['price'] <=> $b['price'], + \Blax\Shop\Enums\PricingStrategy::HIGHEST => $b['price'] <=> $a['price'], + \Blax\Shop\Enums\PricingStrategy::AVERAGE => 0, // Keep original order for average + }; + }); + + // Claim stock from selected items in order $claimedItems = []; - foreach (array_slice($availableItems, 0, $quantity) as $item) { + foreach (array_slice($availableItems, 0, $quantity) as $itemData) { + $item = $itemData['item']; $item->claimStock(1, $reference, $from, $until, $note); $claimedItems[] = $item; } diff --git a/tests/Feature/PoolClaimingPriorityTest.php b/tests/Feature/PoolClaimingPriorityTest.php new file mode 100644 index 0000000..0901677 --- /dev/null +++ b/tests/Feature/PoolClaimingPriorityTest.php @@ -0,0 +1,228 @@ +create([ + 'name' => 'Parking Pool', + 'type' => ProductType::POOL, + 'manage_stock' => false, + ]); + + // Set pool pricing strategy to LOWEST + $pool->setPoolPricingStrategy(PricingStrategy::LOWEST); + + // Create fallback price on pool + ProductPrice::factory()->create([ + 'purchasable_id' => $pool->id, + 'purchasable_type' => Product::class, + 'type' => PriceType::RECURRING, + 'unit_amount' => 500, // $5.00 fallback + 'is_default' => true, + ]); + + // Create single parking spots with different prices + $spot1 = Product::factory()->create([ + 'name' => 'Parking Spot 1', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $spot1->increaseStock(1); + + ProductPrice::factory()->create([ + 'purchasable_id' => $spot1->id, + 'purchasable_type' => Product::class, + 'type' => PriceType::RECURRING, + 'unit_amount' => 500, // $5.00 + 'is_default' => true, + ]); + + $spot2 = Product::factory()->create([ + 'name' => 'Parking Spot 2', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $spot2->increaseStock(1); + + ProductPrice::factory()->create([ + 'purchasable_id' => $spot2->id, + 'purchasable_type' => Product::class, + 'type' => PriceType::RECURRING, + 'unit_amount' => 1000, // $10.00 (most expensive) + 'is_default' => true, + ]); + + $spot3 = Product::factory()->create([ + 'name' => 'Parking Spot 3', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $spot3->increaseStock(1); + + // Spot 3 has NO price - should use pool fallback (500) + + // Attach spots to pool + $pool->attachSingleItems([$spot1->id, $spot2->id, $spot3->id]); + + // Create cart and claim 3 spots + $cart = Cart::factory()->create(); + $from = Carbon::now()->addDays(1); + $until = Carbon::now()->addDays(3); + + $claimedItems = $pool->claimPoolStock(3, $cart, $from, $until); + + // Should claim in order: spot1 (500), spot3 (500 fallback), spot2 (1000) + $this->assertCount(3, $claimedItems); + + // Extract IDs for easier comparison + $claimedIds = array_map(fn($item) => $item->id, $claimedItems); + + // First two should be spot1 and spot3 (both $5.00) in some order + $this->assertContains($spot1->id, [$claimedIds[0], $claimedIds[1]]); + $this->assertContains($spot3->id, [$claimedIds[0], $claimedIds[1]]); + + // Last should be spot2 (most expensive) + $this->assertEquals($spot2->id, $claimedIds[2]); + } + + /** @test */ + public function it_claims_highest_priced_items_first_with_highest_strategy() + { + // Create pool product + $pool = Product::factory()->create([ + 'name' => 'Premium Parking Pool', + 'type' => ProductType::POOL, + 'manage_stock' => false, + ]); + + // Set pool pricing strategy to HIGHEST + $pool->setPoolPricingStrategy(PricingStrategy::HIGHEST); + + // Create fallback price on pool + ProductPrice::factory()->create([ + 'purchasable_id' => $pool->id, + 'purchasable_type' => Product::class, + 'type' => PriceType::RECURRING, + 'unit_amount' => 500, + 'is_default' => true, + ]); + + // Create spots with different prices + $cheapSpot = Product::factory()->create([ + 'name' => 'Cheap Spot', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $cheapSpot->increaseStock(1); + + ProductPrice::factory()->create([ + 'purchasable_id' => $cheapSpot->id, + 'purchasable_type' => Product::class, + 'type' => PriceType::RECURRING, + 'unit_amount' => 300, // Cheapest + 'is_default' => true, + ]); + + $expensiveSpot = Product::factory()->create([ + 'name' => 'Expensive Spot', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $expensiveSpot->increaseStock(1); + + ProductPrice::factory()->create([ + 'purchasable_id' => $expensiveSpot->id, + 'purchasable_type' => Product::class, + 'type' => PriceType::RECURRING, + 'unit_amount' => 1500, // Most expensive + 'is_default' => true, + ]); + + // Attach to pool + $pool->attachSingleItems([$cheapSpot->id, $expensiveSpot->id]); + + // Claim 2 spots + $cart = Cart::factory()->create(); + $from = Carbon::now()->addDays(1); + $until = Carbon::now()->addDays(3); + + $claimedItems = $pool->claimPoolStock(2, $cart, $from, $until); + + // Should claim expensive first, then cheap + $this->assertEquals($expensiveSpot->id, $claimedItems[0]->id); + $this->assertEquals($cheapSpot->id, $claimedItems[1]->id); + } + + /** @test */ + public function it_uses_fallback_price_for_items_without_price() + { + $pool = Product::factory()->create([ + 'name' => 'Parking Pool', + 'type' => ProductType::POOL, + 'manage_stock' => false, + ]); + + $pool->setPoolPricingStrategy(PricingStrategy::LOWEST); + + // Pool fallback price + ProductPrice::factory()->create([ + 'purchasable_id' => $pool->id, + 'purchasable_type' => Product::class, + 'type' => PriceType::RECURRING, + 'unit_amount' => 500, + 'is_default' => true, + ]); + + // Spot with specific price (higher than fallback) + $pricedSpot = Product::factory()->create([ + 'name' => 'Priced Spot', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $pricedSpot->increaseStock(1); + + ProductPrice::factory()->create([ + 'purchasable_id' => $pricedSpot->id, + 'purchasable_type' => Product::class, + 'type' => PriceType::RECURRING, + 'unit_amount' => 1000, + 'is_default' => true, + ]); + + // Spot without price (uses fallback) + $unpricedSpot = Product::factory()->create([ + 'name' => 'Unpriced Spot', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $unpricedSpot->increaseStock(1); + // No price created for this spot + + $pool->attachSingleItems([$pricedSpot->id, $unpricedSpot->id]); + + // Claim both + $cart = Cart::factory()->create(); + $from = Carbon::now()->addDays(1); + $until = Carbon::now()->addDays(3); + + $claimedItems = $pool->claimPoolStock(2, $cart, $from, $until); + + // Unpriced spot (using fallback 500) should be claimed first + $this->assertEquals($unpricedSpot->id, $claimedItems[0]->id); + $this->assertEquals($pricedSpot->id, $claimedItems[1]->id); + } +} diff --git a/tests/Unit/HtmlDateTimeCastTest.php b/tests/Unit/HtmlDateTimeCastTest.php new file mode 100644 index 0000000..6e83dc6 --- /dev/null +++ b/tests/Unit/HtmlDateTimeCastTest.php @@ -0,0 +1,155 @@ +create(); + $date = Carbon::parse('2025-12-25 14:30:00'); + + $cart->from_date = $date; + $cart->save(); + + // Reload from database + $cart->refresh(); + + // Should return Carbon instance + $this->assertInstanceOf(Carbon::class, $cart->from_date); + $this->assertEquals('2025-12-25 14:30:00', $cart->from_date->format('Y-m-d H:i:s')); + } + + /** @test */ + public function it_accepts_datetime_interface_and_stores_as_timestamp() + { + $cart = Cart::factory()->create(); + $date = new \DateTime('2025-12-25 14:30:00'); + + $cart->from_date = $date; + $cart->save(); + + $cart->refresh(); + + $this->assertInstanceOf(Carbon::class, $cart->from_date); + $this->assertEquals('2025-12-25 14:30:00', $cart->from_date->format('Y-m-d H:i:s')); + } + + /** @test */ + public function it_accepts_string_and_stores_as_timestamp() + { + $cart = Cart::factory()->create(); + + // Standard datetime string + $cart->from_date = '2025-12-25 14:30:00'; + $cart->save(); + + $cart->refresh(); + + $this->assertInstanceOf(Carbon::class, $cart->from_date); + $this->assertEquals('2025-12-25 14:30:00', $cart->from_date->format('Y-m-d H:i:s')); + } + + /** @test */ + public function it_accepts_html5_datetime_local_format() + { + $cart = Cart::factory()->create(); + + // HTML5 datetime-local format (YYYY-MM-DDTHH:MM) + $cart->from_date = '2025-12-25T14:30'; + $cart->save(); + + $cart->refresh(); + + $this->assertInstanceOf(Carbon::class, $cart->from_date); + $this->assertEquals('2025-12-25 14:30:00', $cart->from_date->format('Y-m-d H:i:s')); + } + + /** @test */ + public function it_can_format_for_html5_input() + { + $cart = Cart::factory()->create(); + $cart->from_date = Carbon::parse('2025-12-25 14:30:00'); + $cart->save(); + + $cart->refresh(); + + // Can format for HTML5 datetime-local input + $htmlFormat = $cart->from_date->format('Y-m-d\TH:i'); + $this->assertEquals('2025-12-25T14:30', $htmlFormat); + } + + /** @test */ + public function it_handles_null_values() + { + $cart = Cart::factory()->create(); + $cart->from_date = null; + $cart->save(); + + $cart->refresh(); + + $this->assertNull($cart->from_date); + } + + /** @test */ + public function it_works_with_cart_items() + { + $product = Product::factory()->create(); + $cart = Cart::factory()->create(); + + $item = $cart->items()->create([ + 'purchasable_id' => $product->id, + 'purchasable_type' => get_class($product), + 'quantity' => 1, + 'price' => 100, + 'subtotal' => 100, + 'from' => '2025-12-25T14:30', + 'until' => '2025-12-27T10:00', + ]); + + $item->refresh(); + + $this->assertInstanceOf(Carbon::class, $item->from); + $this->assertInstanceOf(Carbon::class, $item->until); + $this->assertEquals('2025-12-25T14:30', $item->from->format('Y-m-d\TH:i')); + $this->assertEquals('2025-12-27T10:00', $item->until->format('Y-m-d\TH:i')); + } + + /** @test */ + public function it_accepts_unix_timestamp() + { + $cart = Cart::factory()->create(); + $timestamp = Carbon::parse('2025-12-25 14:30:00')->timestamp; + + $cart->from_date = $timestamp; + $cart->save(); + + $cart->refresh(); + + $this->assertInstanceOf(Carbon::class, $cart->from_date); + $this->assertEquals('2025-12-25 14:30:00', $cart->from_date->format('Y-m-d H:i:s')); + } + + /** @test */ + public function it_maintains_carbon_methods() + { + $cart = Cart::factory()->create(); + $cart->from_date = Carbon::parse('2025-12-25 14:30:00'); + $cart->save(); + + $cart->refresh(); + + // All Carbon methods should be available + $this->assertTrue($cart->from_date->isAfter(Carbon::parse('2025-12-24'))); + $this->assertTrue($cart->from_date->isBefore(Carbon::parse('2025-12-26'))); + $this->assertEquals('December', $cart->from_date->format('F')); + $this->assertEquals('2025-12-25', $cart->from_date->toDateString()); + } +}