BF pool priority claiming, IA date cast

This commit is contained in:
Fabian @ Blax Software 2025-12-17 12:47:18 +01:00
parent 1b2559b824
commit 7360391581
6 changed files with 489 additions and 9 deletions

View File

@ -0,0 +1,70 @@
<?php
namespace Blax\Shop\Casts;
use Carbon\Carbon;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
/**
* Cast for datetime fields that:
* - Accepts string, DateTimeInterface, or Carbon as input
* - Stores as Unix timestamp in database (integer)
* - Returns Carbon instance on get
*
* Usage for HTML5 datetime-local inputs:
* $model->created_at->format('Y-m-d\TH:i')
*/
class HtmlDateTimeCast implements CastsAttributes
{
/**
* Cast the given value.
*
* @param array<string, mixed> $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<string, mixed> $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."
);
}
}

View File

@ -2,6 +2,7 @@
namespace Blax\Shop\Models; namespace Blax\Shop\Models;
use Blax\Shop\Casts\HtmlDateTimeCast;
use Blax\Shop\Contracts\Cartable; use Blax\Shop\Contracts\Cartable;
use Blax\Shop\Enums\CartStatus; use Blax\Shop\Enums\CartStatus;
use Blax\Shop\Enums\ProductType; use Blax\Shop\Enums\ProductType;
@ -40,8 +41,8 @@ class Cart extends Model
'converted_at' => 'datetime', 'converted_at' => 'datetime',
'last_activity_at' => 'datetime', 'last_activity_at' => 'datetime',
'meta' => 'object', 'meta' => 'object',
'from_date' => 'datetime', 'from_date' => HtmlDateTimeCast::class,
'until_date' => 'datetime', 'until_date' => HtmlDateTimeCast::class,
]; ];
protected $appends = [ protected $appends = [

View File

@ -2,6 +2,7 @@
namespace Blax\Shop\Models; namespace Blax\Shop\Models;
use Blax\Shop\Casts\HtmlDateTimeCast;
use Blax\Shop\Exceptions\InvalidDateRangeException; use Blax\Shop\Exceptions\InvalidDateRangeException;
use Blax\Workkit\Traits\HasMeta; use Blax\Workkit\Traits\HasMeta;
use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Concerns\HasUuids;
@ -35,8 +36,8 @@ class CartItem extends Model
'subtotal' => 'decimal:2', 'subtotal' => 'decimal:2',
'parameters' => 'array', 'parameters' => 'array',
'meta' => 'array', 'meta' => 'array',
'from' => 'datetime', 'from' => HtmlDateTimeCast::class,
'until' => 'datetime', 'until' => HtmlDateTimeCast::class,
]; ];
protected $appends = [ protected $appends = [

View File

@ -107,7 +107,7 @@ trait MayBePoolProduct
/** /**
* Claim stock for a pool product * 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 int $quantity Number of pool items to claim
* @param mixed $reference Reference model * @param mixed $reference Reference model
@ -134,13 +134,28 @@ trait MayBePoolProduct
throw new \Exception('Pool product has no single items to claim'); 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 = []; $availableItems = [];
foreach ($singleItems as $item) { foreach ($singleItems as $item) {
if ($item->isAvailableForBooking($from, $until, 1)) { 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) { if (count($availableItems) >= $quantity) {
break; break;
} }
@ -150,9 +165,19 @@ trait MayBePoolProduct
throw new \Exception("Only " . count($availableItems) . " items available, but {$quantity} requested"); 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 = []; $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); $item->claimStock(1, $reference, $from, $until, $note);
$claimedItems[] = $item; $claimedItems[] = $item;
} }

View File

@ -0,0 +1,228 @@
<?php
namespace Blax\Shop\Tests\Feature;
use Blax\Shop\Enums\PriceType;
use Blax\Shop\Enums\PricingStrategy;
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;
class PoolClaimingPriorityTest extends TestCase
{
/** @test */
public function it_claims_lowest_priced_items_first_with_lowest_strategy()
{
// Create pool product
$pool = Product::factory()->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);
}
}

View File

@ -0,0 +1,155 @@
<?php
namespace Blax\Shop\Tests\Unit;
use Blax\Shop\Models\Cart;
use Blax\Shop\Models\CartItem;
use Blax\Shop\Models\Product;
use Blax\Shop\Tests\TestCase;
use Carbon\Carbon;
class HtmlDateTimeCastTest extends TestCase
{
/** @test */
public function it_accepts_carbon_instance_and_stores_as_timestamp()
{
$cart = Cart::factory()->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());
}
}