R reserve to claim
This commit is contained in:
parent
5cc7b1f3f0
commit
c711afb570
|
|
@ -150,22 +150,24 @@ return new class extends Migration
|
|||
});
|
||||
}
|
||||
|
||||
// Product stocks table (reservations)
|
||||
// Product stocks table (claims)
|
||||
if (!Schema::hasTable(config('shop.tables.product_stocks', 'product_stocks'))) {
|
||||
Schema::create(config('shop.tables.product_stocks', 'product_stocks'), function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->uuid('product_id');
|
||||
$table->integer('quantity');
|
||||
$table->string('type')->default('reservation'); // reservation, adjustment, sale, return
|
||||
$table->string('type')->default('claimed'); // claimed, adjustment, sale, return
|
||||
$table->string('status')->default('pending'); // pending, completed, cancelled, expired
|
||||
$table->string('reference_type')->nullable();
|
||||
$table->string('reference_id')->nullable();
|
||||
$table->timestamp('claimed_from')->nullable();
|
||||
$table->timestamp('expires_at')->nullable();
|
||||
$table->text('note')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['product_id', 'status']);
|
||||
$table->index(['reference_type', 'reference_id']);
|
||||
$table->index(['claimed_from', 'expires_at']);
|
||||
$table->foreign('product_id')->references('id')->on(config('shop.tables.products', 'products'))->onDelete('cascade');
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ class ReleaseExpiredStocks extends Command
|
|||
{
|
||||
protected $signature = 'shop:release-expired-stocks';
|
||||
|
||||
protected $description = 'Release expired stock reservations back to inventory';
|
||||
protected $description = 'Release expired stock claims back to inventory';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
|
|
@ -18,11 +18,11 @@ class ReleaseExpiredStocks extends Command
|
|||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info('Checking for expired stock reservations...');
|
||||
$this->info('Checking for expired stock claims...');
|
||||
|
||||
$count = ProductStock::releaseExpired();
|
||||
|
||||
$this->info("Released {$count} expired stock reservation(s).");
|
||||
$this->info("Released {$count} expired stock claim(s).");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ namespace Blax\Shop\Enums;
|
|||
|
||||
enum StockType: string
|
||||
{
|
||||
case RESERVATION = 'reservation';
|
||||
case CLAIMED = 'claimed';
|
||||
case RETURN = 'return';
|
||||
case INCREASE = 'increase';
|
||||
case DECREASE = 'decrease';
|
||||
|
|
@ -12,7 +12,7 @@ enum StockType: string
|
|||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::RESERVATION => 'Reservation',
|
||||
self::CLAIMED => 'Claimed',
|
||||
self::RETURN => 'Return',
|
||||
self::INCREASE => 'Increase',
|
||||
self::DECREASE => 'Decrease',
|
||||
|
|
|
|||
|
|
@ -355,26 +355,33 @@ class Product extends Model implements Purchasable, Cartable
|
|||
return true;
|
||||
}
|
||||
|
||||
// Get stock reservations that overlap with the requested period
|
||||
$overlappingReservations = $this->stocks()
|
||||
->where('type', StockType::RESERVATION->value)
|
||||
// Get stock claims that overlap with the requested period
|
||||
$overlappingClaims = $this->stocks()
|
||||
->where('type', StockType::CLAIMED->value)
|
||||
->where('status', StockStatus::PENDING->value)
|
||||
->where(function ($query) use ($from, $until) {
|
||||
$query->where(function ($q) use ($from, $until) {
|
||||
// Reservation starts during the requested period
|
||||
$q->whereBetween('created_at', [$from, $until]);
|
||||
// Claim starts during the requested period
|
||||
$q->whereBetween('claimed_from', [$from, $until]);
|
||||
})->orWhere(function ($q) use ($from, $until) {
|
||||
// Reservation ends during the requested period
|
||||
// Claim ends during the requested period
|
||||
$q->whereBetween('expires_at', [$from, $until]);
|
||||
})->orWhere(function ($q) use ($from, $until) {
|
||||
// Reservation encompasses the entire requested period
|
||||
$q->where('created_at', '<=', $from)
|
||||
->where('expires_at', '>=', $until);
|
||||
// Claim encompasses the entire requested period
|
||||
$q->where('claimed_from', '<=', $from)
|
||||
->where('expires_at', '>=', $until);
|
||||
})->orWhere(function ($q) use ($from, $until) {
|
||||
// Claim without claimed_from (immediately claimed)
|
||||
$q->whereNull('claimed_from')
|
||||
->where(function ($subQ) use ($from, $until) {
|
||||
$subQ->whereNull('expires_at')
|
||||
->orWhere('expires_at', '>=', $from);
|
||||
});
|
||||
});
|
||||
})
|
||||
->sum('quantity');
|
||||
|
||||
$availableStock = $this->getAvailableStock() - abs($overlappingReservations);
|
||||
$availableStock = $this->getAvailableStock() - abs($overlappingClaims);
|
||||
|
||||
return $availableStock >= $quantity;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ class ProductStock extends Model
|
|||
'status',
|
||||
'reference_type',
|
||||
'reference_id',
|
||||
'claimed_from',
|
||||
'expires_at',
|
||||
'note',
|
||||
];
|
||||
|
|
@ -31,6 +32,7 @@ class ProductStock extends Model
|
|||
'quantity' => 'integer',
|
||||
'type' => StockType::class,
|
||||
'status' => StockStatus::class,
|
||||
'claimed_from' => 'datetime',
|
||||
'expires_at' => 'datetime',
|
||||
];
|
||||
|
||||
|
|
@ -88,14 +90,15 @@ class ProductStock extends Model
|
|||
return $this->expires_at;
|
||||
}
|
||||
|
||||
public static function reserve(
|
||||
public static function claim(
|
||||
Product $product,
|
||||
int $quantity,
|
||||
$reference = null,
|
||||
?\DateTimeInterface $from = null,
|
||||
?\DateTimeInterface $until = null,
|
||||
?string $note = null
|
||||
): ?self {
|
||||
return DB::transaction(function () use ($product, $quantity, $reference, $until, $note) {
|
||||
return DB::transaction(function () use ($product, $quantity, $reference, $from, $until, $note) {
|
||||
if (!$product->decreaseStock($quantity)) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -103,10 +106,11 @@ class ProductStock extends Model
|
|||
return self::create([
|
||||
'product_id' => $product->id,
|
||||
'quantity' => $quantity,
|
||||
'type' => StockType::RESERVATION,
|
||||
'type' => StockType::CLAIMED,
|
||||
'status' => StockStatus::PENDING,
|
||||
'reference_type' => $reference ? get_class($reference) : null,
|
||||
'reference_id' => $reference?->id,
|
||||
'claimed_from' => $from,
|
||||
'expires_at' => $until,
|
||||
'note' => $note,
|
||||
]);
|
||||
|
|
@ -187,13 +191,37 @@ class ProductStock extends Model
|
|||
return $query->where('status', StockStatus::COMPLETED->value);
|
||||
}
|
||||
|
||||
public static function scopeAvailableReservations($query)
|
||||
public static function scopeAvailableClaims($query)
|
||||
{
|
||||
return $query->where('type', StockType::RESERVATION->value)->where('status', StockStatus::PENDING->value);
|
||||
return $query->where('type', StockType::CLAIMED->value)->where('status', StockStatus::PENDING->value);
|
||||
}
|
||||
|
||||
public static function reservations()
|
||||
public static function claims()
|
||||
{
|
||||
return self::availableReservations();
|
||||
return self::availableClaims();
|
||||
}
|
||||
|
||||
public static function scopeAvailableOnDate($query, \DateTimeInterface $date)
|
||||
{
|
||||
return $query->where('type', StockType::CLAIMED->value)
|
||||
->where('status', StockStatus::PENDING->value)
|
||||
->where(function ($q) use ($date) {
|
||||
$q->where(function ($subQuery) use ($date) {
|
||||
// Claimed items with claimed_from set
|
||||
$subQuery->whereNotNull('claimed_from')
|
||||
->where('claimed_from', '<=', $date)
|
||||
->where(function ($dateQuery) use ($date) {
|
||||
$dateQuery->whereNull('expires_at')
|
||||
->orWhere('expires_at', '>=', $date);
|
||||
});
|
||||
})->orWhere(function ($subQuery) use ($date) {
|
||||
// Claimed items without claimed_from (always claimed)
|
||||
$subQuery->whereNull('claimed_from')
|
||||
->where(function ($dateQuery) use ($date) {
|
||||
$dateQuery->whereNull('expires_at')
|
||||
->orWhere('expires_at', '>=', $date);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,12 +58,12 @@ trait HasCart
|
|||
$product = $product_or_price->purchasable;
|
||||
|
||||
if ($product instanceof Product) {
|
||||
$product->reserveStock($quantity);
|
||||
$product->claimStock($quantity);
|
||||
}
|
||||
}
|
||||
|
||||
if ($product_or_price instanceof Product) {
|
||||
$product_or_price->reserveStock($quantity);
|
||||
$product_or_price->claimStock($quantity);
|
||||
|
||||
$default_prices = $product_or_price->defaultPrice()->count();
|
||||
|
||||
|
|
|
|||
|
|
@ -100,9 +100,10 @@ trait HasStocks
|
|||
return true;
|
||||
}
|
||||
|
||||
public function reserveStock(
|
||||
public function claimStock(
|
||||
int $quantity,
|
||||
$reference = null,
|
||||
?\DateTimeInterface $from = null,
|
||||
?\DateTimeInterface $until = null,
|
||||
?string $note = null
|
||||
): ?\Blax\Shop\Models\ProductStock {
|
||||
|
|
@ -113,10 +114,11 @@ trait HasStocks
|
|||
|
||||
$stockModel = config('shop.models.product_stock', 'Blax\Shop\Models\ProductStock');
|
||||
|
||||
return $stockModel::reserve(
|
||||
return $stockModel::claim(
|
||||
$this,
|
||||
$quantity,
|
||||
$reference,
|
||||
$from,
|
||||
$until,
|
||||
$note
|
||||
);
|
||||
|
|
@ -131,7 +133,7 @@ trait HasStocks
|
|||
return max(0, $this->AvailableStocks);
|
||||
}
|
||||
|
||||
public function getReservedStock(): int
|
||||
public function getClaimedStock(): int
|
||||
{
|
||||
return $this->activeStocks()->sum('quantity');
|
||||
}
|
||||
|
|
@ -180,12 +182,38 @@ trait HasStocks
|
|||
return $this->getAvailableStock() <= $this->low_stock_threshold;
|
||||
}
|
||||
|
||||
public function reservations()
|
||||
public function claims()
|
||||
{
|
||||
$stockModel = config('shop.models.product_stock', 'Blax\Shop\Models\ProductStock');
|
||||
|
||||
return $stockModel::reservations()
|
||||
return $stockModel::claims()
|
||||
->willExpire()
|
||||
->where('product_id', $this->id);
|
||||
}
|
||||
|
||||
public function availableOnDate(\DateTimeInterface $date): int
|
||||
{
|
||||
if (!$this->manage_stock) {
|
||||
return PHP_INT_MAX;
|
||||
}
|
||||
|
||||
$stockModel = config('shop.models.product_stock', 'Blax\Shop\Models\ProductStock');
|
||||
|
||||
// Get current available stock (includes all completed stocks minus all currently pending claims)
|
||||
$currentAvailable = $this->getAvailableStock();
|
||||
|
||||
// Get all currently pending claimed stocks (not date-filtered)
|
||||
$allClaimedStocks = $this->stocks()
|
||||
->where('type', StockType::CLAIMED->value)
|
||||
->where('status', StockStatus::PENDING->value)
|
||||
->sum('quantity');
|
||||
|
||||
// Get stocks claimed on this specific date
|
||||
$claimedOnDate = $stockModel::availableOnDate($date)
|
||||
->where('product_id', $this->id)
|
||||
->sum('quantity');
|
||||
|
||||
// Available on date = current available + all claims - claims active on date
|
||||
return max(0, $currentAvailable + abs($allClaimedStocks) - abs($claimedOnDate));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -124,14 +124,14 @@ class BookingFeatureTest extends TestCase
|
|||
$until
|
||||
);
|
||||
|
||||
// Find the stock reservation
|
||||
$reservation = $this->bookingProduct->stocks()
|
||||
// Find the stock claim
|
||||
$claim = $this->bookingProduct->stocks()
|
||||
->where('type', 'decrease')
|
||||
->where('expires_at', $until)
|
||||
->first();
|
||||
|
||||
$this->assertNotNull($reservation);
|
||||
$this->assertEquals($until->format('Y-m-d H:i:s'), $reservation->expires_at->format('Y-m-d H:i:s'));
|
||||
$this->assertNotNull($claim);
|
||||
$this->assertEquals($until->format('Y-m-d H:i:s'), $claim->expires_at->format('Y-m-d H:i:s'));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
|
|
|
|||
|
|
@ -79,120 +79,120 @@ class ProductStockTest extends TestCase
|
|||
}
|
||||
|
||||
/** @test */
|
||||
public function reservation_reduces_available_stock()
|
||||
public function claim_reduces_available_stock()
|
||||
{
|
||||
$product = Product::factory()->withStocks(100)->create();
|
||||
|
||||
$reservation = $product->reserveStock(25);
|
||||
$claim = $product->claimStock(25);
|
||||
|
||||
$this->assertEquals(75, $product->getAvailableStock());
|
||||
$this->assertNotNull($reservation);
|
||||
$this->assertNotNull($claim);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function releasing_reservation_increases_available_stock()
|
||||
public function releasing_claim_increases_available_stock()
|
||||
{
|
||||
$product = Product::factory()->withStocks(100)->create();
|
||||
|
||||
$reservation = $product->reserveStock(25);
|
||||
$claim = $product->claimStock(25);
|
||||
$this->assertEquals(75, $product->getAvailableStock());
|
||||
|
||||
$reservation->release();
|
||||
$claim->release();
|
||||
|
||||
$this->assertEquals(100, $product->refresh()->getAvailableStock());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function permanent_reservation_has_no_expiry()
|
||||
public function permanent_claim_has_no_expiry()
|
||||
{
|
||||
$product = Product::factory()->withStocks(50)->create();
|
||||
|
||||
$reservation = $product->reserveStock(10);
|
||||
$claim = $product->claimStock(10);
|
||||
|
||||
$this->assertNull($reservation->expires_at);
|
||||
$this->assertTrue($reservation->isPermanent());
|
||||
$this->assertNull($claim->expires_at);
|
||||
$this->assertTrue($claim->isPermanent());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function temporary_reservation_has_expiry()
|
||||
public function temporary_claim_has_expiry()
|
||||
{
|
||||
$product = Product::factory()->withStocks(50)->create();
|
||||
|
||||
$reservation = $product->reserveStock(
|
||||
$claim = $product->claimStock(
|
||||
quantity: 10,
|
||||
until: now()->addHours(2)
|
||||
);
|
||||
|
||||
$this->assertNotNull($reservation->expires_at);
|
||||
$this->assertTrue($reservation->isTemporary());
|
||||
$this->assertNotNull($claim->expires_at);
|
||||
$this->assertTrue($claim->isTemporary());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function reservation_can_have_note()
|
||||
public function claim_can_have_note()
|
||||
{
|
||||
$product = Product::factory()->withStocks(50)->create();
|
||||
|
||||
$note = 'Reserved for VIP customer';
|
||||
$reservation = $product->reserveStock(
|
||||
$note = 'Claimed for VIP customer';
|
||||
$claim = $product->claimStock(
|
||||
quantity: 10,
|
||||
note: $note
|
||||
);
|
||||
|
||||
$this->assertEquals($note, $reservation->note);
|
||||
$this->assertEquals($note, $claim->note);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function cannot_reserve_more_than_available()
|
||||
public function cannot_claim_more_than_available()
|
||||
{
|
||||
$product = Product::factory()->withStocks(10)->create();
|
||||
|
||||
$this->expectException(NotEnoughStockException::class);
|
||||
|
||||
$product->reserveStock(15);
|
||||
$product->claimStock(15);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function pending_scope_returns_unreleased_reservations()
|
||||
public function pending_scope_returns_unreleased_claims()
|
||||
{
|
||||
$product = Product::factory()->withStocks(100)->create();
|
||||
|
||||
$pending = $product->reserveStock(10);
|
||||
$released = $product->reserveStock(5);
|
||||
$pending = $product->claimStock(10);
|
||||
$released = $product->claimStock(5);
|
||||
$released->release();
|
||||
|
||||
$pendingReservations = ProductStock::pending()->get();
|
||||
$pendingClaims = ProductStock::pending()->get();
|
||||
|
||||
$this->assertTrue($pendingReservations->contains($pending));
|
||||
$this->assertFalse($pendingReservations->contains($released));
|
||||
$this->assertTrue($pendingClaims->contains($pending));
|
||||
$this->assertFalse($pendingClaims->contains($released));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function released_scope_returns_released_reservations()
|
||||
public function released_scope_returns_released_claims()
|
||||
{
|
||||
$product = Product::factory()->withStocks(100)->create();
|
||||
|
||||
$pending = $product->reserveStock(10);
|
||||
$released = $product->reserveStock(5);
|
||||
$pending = $product->claimStock(10);
|
||||
$released = $product->claimStock(5);
|
||||
$released->release();
|
||||
|
||||
$releasedReservations = ProductStock::released()->get();
|
||||
$releasedClaims = ProductStock::released()->get();
|
||||
|
||||
$this->assertFalse($releasedReservations->contains($pending));
|
||||
$this->assertTrue($releasedReservations->contains($released));
|
||||
$this->assertFalse($releasedClaims->contains($pending));
|
||||
$this->assertTrue($releasedClaims->contains($released));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function expired_reservations_dont_affect_available_stock()
|
||||
public function expired_claims_dont_affect_available_stock()
|
||||
{
|
||||
$product = Product::factory()->withStocks(100)->create();
|
||||
|
||||
$product->reserveStock(
|
||||
$product->claimStock(
|
||||
quantity: 20,
|
||||
until: now()->subHour()
|
||||
);
|
||||
|
||||
// Expired reservations should be counted in available stock
|
||||
$available = $product->reservations()->get();
|
||||
// Expired claims should be counted in available stock
|
||||
$available = $product->claims()->get();
|
||||
|
||||
$this->assertEquals(0, $available->count());
|
||||
}
|
||||
|
|
@ -202,10 +202,10 @@ class ProductStockTest extends TestCase
|
|||
{
|
||||
$product = Product::factory()->withStocks(50)->create();
|
||||
|
||||
$reservation = $product->reserveStock(10);
|
||||
$claim = $product->claimStock(10);
|
||||
|
||||
$this->assertTrue($reservation->release());
|
||||
$this->assertFalse($reservation->release());
|
||||
$this->assertTrue($claim->release());
|
||||
$this->assertFalse($claim->release());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
|
|
@ -242,13 +242,13 @@ class ProductStockTest extends TestCase
|
|||
}
|
||||
|
||||
/** @test */
|
||||
public function reservation_without_stock_management_returns_null()
|
||||
public function claim_without_stock_management_returns_null()
|
||||
{
|
||||
$product = Product::factory()->create(['manage_stock' => false]);
|
||||
|
||||
$reservation = $product->reserveStock(10);
|
||||
$claim = $product->claimStock(10);
|
||||
|
||||
$this->assertNull($reservation);
|
||||
$this->assertNull($claim);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
|
|
@ -263,17 +263,17 @@ class ProductStockTest extends TestCase
|
|||
}
|
||||
|
||||
/** @test */
|
||||
public function reservations_method_filters_active_only()
|
||||
public function claims_method_filters_active_only()
|
||||
{
|
||||
$product = Product::factory()->withStocks(100)->create();
|
||||
|
||||
$active = $product->reserveStock(10, until: now()->addDay());
|
||||
$expired = $product->reserveStock(5, until: now()->subDay());
|
||||
$active = $product->claimStock(10, until: now()->addDay());
|
||||
$expired = $product->claimStock(5, until: now()->subDay());
|
||||
|
||||
$reservations = $product->reservations()->get();
|
||||
$claims = $product->claims()->get();
|
||||
|
||||
$this->assertCount(1, $reservations);
|
||||
$this->assertEquals($active->id, $reservations->first()->id);
|
||||
$this->assertCount(1, $claims);
|
||||
$this->assertEquals($active->id, $claims->first()->id);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
|
|
@ -312,4 +312,173 @@ class ProductStockTest extends TestCase
|
|||
|
||||
$this->assertEquals(25, $product->getAvailableStock());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_can_claim_stock_with_claimed_from_date()
|
||||
{
|
||||
$product = Product::factory()->withStocks(100)->create();
|
||||
|
||||
$claimedFrom = now()->addDays(5);
|
||||
$until = now()->addDays(10);
|
||||
|
||||
$claim = $product->claimStock(
|
||||
quantity: 20,
|
||||
from: $claimedFrom,
|
||||
until: $until
|
||||
);
|
||||
|
||||
$this->assertNotNull($claim);
|
||||
$this->assertEquals($claimedFrom->format('Y-m-d H:i:s'), $claim->claimed_from->format('Y-m-d H:i:s'));
|
||||
$this->assertEquals($until->format('Y-m-d H:i:s'), $claim->expires_at->format('Y-m-d H:i:s'));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_can_check_available_stock_on_a_date()
|
||||
{
|
||||
$product = Product::factory()->withStocks(100)->create();
|
||||
|
||||
// Claim stock from day 5 to day 10
|
||||
$product->claimStock(
|
||||
quantity: 30,
|
||||
from: now()->addDays(5),
|
||||
until: now()->addDays(10)
|
||||
);
|
||||
|
||||
// Should have full stock available before claimed_from date
|
||||
$availableOnDay3 = $product->availableOnDate(now()->addDays(3));
|
||||
$this->assertEquals(100, $availableOnDay3);
|
||||
|
||||
// Should have reduced stock during claimed period
|
||||
$availableOnDay7 = $product->availableOnDate(now()->addDays(7));
|
||||
$this->assertEquals(70, $availableOnDay7);
|
||||
|
||||
// Should have full stock available after expires_at date
|
||||
$availableOnDay12 = $product->availableOnDate(now()->addDays(12));
|
||||
$this->assertEquals(100, $availableOnDay12);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_can_handle_multiple_overlapping_claims_on_date()
|
||||
{
|
||||
$product = Product::factory()->withStocks(100)->create();
|
||||
|
||||
// First claim: days 5-10
|
||||
$product->claimStock(
|
||||
quantity: 20,
|
||||
from: now()->addDays(5),
|
||||
until: now()->addDays(10)
|
||||
);
|
||||
|
||||
// Second claim: days 8-15
|
||||
$product->claimStock(
|
||||
quantity: 30,
|
||||
from: now()->addDays(8),
|
||||
until: now()->addDays(15)
|
||||
);
|
||||
|
||||
// Day 6: only first claim is active
|
||||
$availableOnDay6 = $product->availableOnDate(now()->addDays(6));
|
||||
$this->assertEquals(80, $availableOnDay6);
|
||||
|
||||
// Day 9: both claims are active
|
||||
$availableOnDay9 = $product->availableOnDate(now()->addDays(9));
|
||||
$this->assertEquals(50, $availableOnDay9);
|
||||
|
||||
// Day 13: only second claim is active
|
||||
$availableOnDay13 = $product->availableOnDate(now()->addDays(13));
|
||||
$this->assertEquals(70, $availableOnDay13);
|
||||
|
||||
// Day 20: no claims active
|
||||
$availableOnDay20 = $product->availableOnDate(now()->addDays(20));
|
||||
$this->assertEquals(100, $availableOnDay20);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_handles_claims_without_claimed_from_as_immediately_claimed()
|
||||
{
|
||||
$product = Product::factory()->withStocks(100)->create();
|
||||
|
||||
// Claim without claimed_from (immediately claimed)
|
||||
$product->claimStock(
|
||||
quantity: 25,
|
||||
until: now()->addDays(10)
|
||||
);
|
||||
|
||||
// Should be claimed immediately
|
||||
$availableNow = $product->availableOnDate(now());
|
||||
$this->assertEquals(75, $availableNow);
|
||||
|
||||
// Should still be claimed on day 7
|
||||
$availableOnDay7 = $product->availableOnDate(now()->addDays(7));
|
||||
$this->assertEquals(75, $availableOnDay7);
|
||||
|
||||
// Should be released after expiry
|
||||
$availableOnDay12 = $product->availableOnDate(now()->addDays(12));
|
||||
$this->assertEquals(100, $availableOnDay12);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_handles_permanent_claims_without_expires_at()
|
||||
{
|
||||
$product = Product::factory()->withStocks(100)->create();
|
||||
|
||||
// Permanent claim from day 5 onwards
|
||||
$product->claimStock(
|
||||
quantity: 40,
|
||||
from: now()->addDays(5)
|
||||
);
|
||||
|
||||
// Before claimed_from: full stock available
|
||||
$availableOnDay3 = $product->availableOnDate(now()->addDays(3));
|
||||
$this->assertEquals(100, $availableOnDay3);
|
||||
|
||||
// After claimed_from: reduced stock
|
||||
$availableOnDay10 = $product->availableOnDate(now()->addDays(10));
|
||||
$this->assertEquals(60, $availableOnDay10);
|
||||
|
||||
// Far future: still reduced (permanent claim)
|
||||
$availableOnDay100 = $product->availableOnDate(now()->addDays(100));
|
||||
$this->assertEquals(60, $availableOnDay100);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function available_on_date_scope_filters_correctly()
|
||||
{
|
||||
$product = Product::factory()->withStocks(100)->create();
|
||||
|
||||
// Create various claims
|
||||
$claim1 = $product->claimStock(
|
||||
quantity: 10,
|
||||
from: now()->addDays(5),
|
||||
until: now()->addDays(10)
|
||||
);
|
||||
|
||||
$claim2 = $product->claimStock(
|
||||
quantity: 15,
|
||||
from: now()->addDays(8),
|
||||
until: now()->addDays(15)
|
||||
);
|
||||
|
||||
$claim3 = $product->claimStock(
|
||||
quantity: 20,
|
||||
from: now()->addDays(20),
|
||||
until: now()->addDays(25)
|
||||
);
|
||||
|
||||
// Test scope on day 7 - should only include claim1
|
||||
$claimsOnDay7 = \Blax\Shop\Models\ProductStock::availableOnDate(now()->addDays(7))
|
||||
->where('product_id', $product->id)
|
||||
->get();
|
||||
|
||||
$this->assertCount(1, $claimsOnDay7);
|
||||
$this->assertEquals($claim1->id, $claimsOnDay7->first()->id);
|
||||
|
||||
// Test scope on day 12 - should only include claim2
|
||||
$claimsOnDay12 = \Blax\Shop\Models\ProductStock::availableOnDate(now()->addDays(12))
|
||||
->where('product_id', $product->id)
|
||||
->get();
|
||||
|
||||
$this->assertCount(1, $claimsOnDay12);
|
||||
$this->assertEquals($claim2->id, $claimsOnDay12->first()->id);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,55 +14,55 @@ class StockManagementTest extends TestCase
|
|||
use RefreshDatabase;
|
||||
|
||||
/** @test */
|
||||
public function it_can_reserve_stock_for_a_product()
|
||||
public function it_can_claim_stock_for_a_product()
|
||||
{
|
||||
$product = Product::factory()
|
||||
->withStocks(100)
|
||||
->create();
|
||||
|
||||
$reservation = $product->reserveStock(
|
||||
$claim = $product->claimStock(
|
||||
quantity: 10,
|
||||
until: now()->addHours(2)
|
||||
);
|
||||
|
||||
$this->assertNotNull($reservation);
|
||||
$this->assertEquals(10, $reservation->quantity);
|
||||
$this->assertNotNull($claim);
|
||||
$this->assertEquals(10, $claim->quantity);
|
||||
$this->assertEquals(90, $product->getAvailableStock());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_cannot_reserve_more_stock_than_available()
|
||||
public function it_cannot_claim_more_stock_than_available()
|
||||
{
|
||||
$product = Product::factory()
|
||||
->withStocks(5)
|
||||
->create();
|
||||
|
||||
$reservation = null;
|
||||
$claim = null;
|
||||
|
||||
$this->assertThrows(fn() => $reservation = $product->reserveStock(15), NotEnoughStockException::class);
|
||||
$this->assertThrows(fn() => $claim = $product->claimStock(15), NotEnoughStockException::class);
|
||||
|
||||
$this->assertNull($reservation);
|
||||
$this->assertNull($claim);
|
||||
$this->assertEquals(5, $product->getAvailableStock());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_can_release_reserved_stock()
|
||||
public function it_can_release_claimed_stock()
|
||||
{
|
||||
$product = Product::factory()
|
||||
->withStocks(100)
|
||||
->create();
|
||||
|
||||
$reservation = $product->reserveStock(
|
||||
$claim = $product->claimStock(
|
||||
quantity: 10,
|
||||
until: now()->addHours(2)
|
||||
);
|
||||
|
||||
$this->assertEquals(90, $product->getAvailableStock());
|
||||
|
||||
$reservation->release();
|
||||
$claim->release();
|
||||
|
||||
$this->assertEquals(100, $product->refresh()->getAvailableStock());
|
||||
$this->assertNotNull($reservation->fresh()->released_at);
|
||||
$this->assertNotNull($claim->fresh()->released_at);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
|
|
@ -70,9 +70,9 @@ class StockManagementTest extends TestCase
|
|||
{
|
||||
$product = Product::factory()->withStocks(10)->create();
|
||||
|
||||
$reservation = $product->reserveStock(5);
|
||||
$claim = $product->claimStock(5);
|
||||
|
||||
$pending = ProductStock::pending()->where('id', $reservation->id)->first();
|
||||
$pending = ProductStock::pending()->where('id', $claim->id)->first();
|
||||
|
||||
$this->assertNotNull($pending);
|
||||
$this->assertNull($pending->released_at);
|
||||
|
|
@ -83,35 +83,35 @@ class StockManagementTest extends TestCase
|
|||
{
|
||||
$product = Product::factory()->withStocks(50)->create();
|
||||
|
||||
$reservation = $product->reserveStock(5);
|
||||
$claim = $product->claimStock(5);
|
||||
|
||||
$reservation->release();
|
||||
$claim->release();
|
||||
|
||||
$released = ProductStock::released()->where('id', $reservation->id)->first();
|
||||
$released = ProductStock::released()->where('id', $claim->id)->first();
|
||||
|
||||
$this->assertNotNull($released);
|
||||
$this->assertNotNull($released->released_at);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_can_distinguish_temporary_and_permanent_reservations()
|
||||
public function it_can_distinguish_temporary_and_permanent_claims()
|
||||
{
|
||||
$product = Product::factory()->withStocks(100)->create();
|
||||
|
||||
$permanentReservation = $product->reserveStock(
|
||||
$permanentClaim = $product->claimStock(
|
||||
quantity: 10
|
||||
);
|
||||
|
||||
$temporaryReservation = $product->reserveStock(
|
||||
$temporaryClaim = $product->claimStock(
|
||||
quantity: 5,
|
||||
until: now()->addHours(1)
|
||||
);
|
||||
|
||||
$this->assertTrue($permanentReservation->isPermanent());
|
||||
$this->assertFalse($permanentReservation->isTemporary());
|
||||
$this->assertTrue($permanentClaim->isPermanent());
|
||||
$this->assertFalse($permanentClaim->isTemporary());
|
||||
|
||||
$this->assertTrue($temporaryReservation->isTemporary());
|
||||
$this->assertFalse($temporaryReservation->isPermanent());
|
||||
$this->assertTrue($temporaryClaim->isTemporary());
|
||||
$this->assertFalse($temporaryClaim->isPermanent());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
|
|
@ -119,10 +119,10 @@ class StockManagementTest extends TestCase
|
|||
{
|
||||
$product = Product::factory()->withStocks(20)->create();
|
||||
|
||||
$reservation = $product->reserveStock(5);
|
||||
$claim = $product->claimStock(5);
|
||||
|
||||
$this->assertInstanceOf(Product::class, $reservation->product);
|
||||
$this->assertEquals($product->id, $reservation->product->id);
|
||||
$this->assertInstanceOf(Product::class, $claim->product);
|
||||
$this->assertEquals($product->id, $claim->product->id);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
|
|
@ -140,24 +140,24 @@ class StockManagementTest extends TestCase
|
|||
}
|
||||
|
||||
/** @test */
|
||||
public function it_can_get_active_stock_reservations()
|
||||
public function it_can_get_active_stock_claims()
|
||||
{
|
||||
$product = Product::factory()->withStocks(100)->create();
|
||||
|
||||
$activeReservation = $product->reserveStock(
|
||||
$activeClaim = $product->claimStock(
|
||||
quantity: 10,
|
||||
until: now()->addHours(2)
|
||||
);
|
||||
|
||||
$expiredReservation = $product->reserveStock(
|
||||
$expiredClaim = $product->claimStock(
|
||||
quantity: 5,
|
||||
until: now()->subHours(1)
|
||||
);
|
||||
|
||||
$activeReservations = $product->reservations()->get();
|
||||
$activeClaims = $product->claims()->get();
|
||||
|
||||
$this->assertCount(1, $activeReservations);
|
||||
$this->assertEquals($activeReservation->id, $activeReservations->first()->id);
|
||||
$this->assertCount(1, $activeClaims);
|
||||
$this->assertEquals($activeClaim->id, $activeClaims->first()->id);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
|
|
@ -165,25 +165,25 @@ class StockManagementTest extends TestCase
|
|||
{
|
||||
$product = Product::factory()->withStocks()->create();
|
||||
|
||||
$reservation = $product->reserveStock(5);
|
||||
$claim = $product->claimStock(5);
|
||||
|
||||
$this->assertTrue($reservation->release());
|
||||
$this->assertFalse($reservation->release());
|
||||
$this->assertTrue($claim->release());
|
||||
$this->assertFalse($claim->release());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_can_store_reservation_note()
|
||||
public function it_can_store_claim_note()
|
||||
{
|
||||
$product = Product::factory()->withStocks()->create();
|
||||
|
||||
$note = "Customer requested to hold this item for 2 days.";
|
||||
|
||||
$reservation = $product->reserveStock(
|
||||
$claim = $product->claimStock(
|
||||
quantity: 5,
|
||||
note: $note
|
||||
);
|
||||
|
||||
$this->assertEquals($note, $reservation->note);
|
||||
$this->assertEquals($note, $claim->note);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
|
|
@ -191,18 +191,18 @@ class StockManagementTest extends TestCase
|
|||
{
|
||||
$product = Product::factory()->withStocks(100)->create();
|
||||
|
||||
$reservation1 = $product->reserveStock(
|
||||
$claim1 = $product->claimStock(
|
||||
quantity: 10,
|
||||
until: now()->addHours(2)
|
||||
);
|
||||
|
||||
$reservation2 = $product->reserveStock(
|
||||
$claim2 = $product->claimStock(
|
||||
quantity: 5,
|
||||
until: now()->addHours(1)
|
||||
);
|
||||
|
||||
$reservation1->refresh();
|
||||
$reservation2->refresh();
|
||||
$claim1->refresh();
|
||||
$claim2->refresh();
|
||||
|
||||
$this->assertEquals(85, $product->refresh()->getAvailableStock());
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue