BF pool priority claiming, IA date cast
This commit is contained in:
parent
1b2559b824
commit
7360391581
|
|
@ -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."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue