BFI stocks

This commit is contained in:
Fabian @ Blax Software 2025-12-05 10:23:47 +01:00
parent 4a303c0f3f
commit 7c3facc3f5
2 changed files with 441 additions and 61 deletions

View File

@ -38,13 +38,23 @@ use Illuminate\Support\Facades\DB;
trait HasStocks trait HasStocks
{ {
/** /**
* Get all stock entries for this product * Get all available stock entries for this product
*/ */
public function stocks(): HasMany public function stocks(): HasMany
{ {
return $this->hasMany(config('shop.models.product_stock', 'Blax\Shop\Models\ProductStock')); return $this->hasMany(config('shop.models.product_stock', 'Blax\Shop\Models\ProductStock'));
} }
/**
* Get all stock entries for this product including unavailable ones
*/
public function allStocks(): HasMany
{
return $this->hasMany(config('shop.models.product_stock', 'Blax\Shop\Models\ProductStock'))
->withExpired()
->where('status', 'LIKE', '%');
}
/** /**
* Attribute accessor: Get available physical stock * Attribute accessor: Get available physical stock
* *
@ -274,28 +284,44 @@ 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(): int public function getAvailableStock(?\DateTimeInterface $date = null): int
{ {
if (!$this->manage_stock) { if (!$this->manage_stock) {
return PHP_INT_MAX; return PHP_INT_MAX;
} }
// Base stock: all COMPLETED entries except CLAIMED, filtered by expiration $date = $date ?? now();
// Base stock: all COMPLETED entries except CLAIMED, filtered using the provided date
$baseStock = $this->stocks() $baseStock = $this->stocks()
->withoutGlobalScope('willExpire')
->where('status', StockStatus::COMPLETED->value) ->where('status', StockStatus::COMPLETED->value)
->where('type', '!=', StockType::CLAIMED->value) ->where('type', '!=', StockType::CLAIMED->value)
->willExpire() ->where(function ($query) use ($date) {
$query->whereNull('expires_at')
->orWhere('expires_at', '>', $date);
})
->sum('quantity'); ->sum('quantity');
// Add back expired claims (these had DECREASE entries that should no longer reduce stock) // Add back claims that should not reduce availability at the given date
$expiredClaims = $this->stocks() $inactiveClaims = $this->stocks()
->withoutGlobalScope('willExpire')
->where('type', StockType::CLAIMED->value) ->where('type', StockType::CLAIMED->value)
->where('status', StockStatus::PENDING->value) ->where('status', StockStatus::PENDING->value)
->whereNotNull('expires_at') ->where(function ($query) use ($date) {
->where('expires_at', '<=', now()) $query->where(function ($q) use ($date) {
// Claim has not started yet
$q->whereNotNull('claimed_from')
->where('claimed_from', '>', $date);
})->orWhere(function ($q) use ($date) {
// Claim expired before the date
$q->whereNotNull('expires_at')
->where('expires_at', '<=', $date);
});
})
->sum('quantity'); ->sum('quantity');
return max(0, $baseStock + $expiredClaims); return max(0, $baseStock + $inactiveClaims);
} }
/** /**
@ -307,7 +333,27 @@ trait HasStocks
* *
* @return int Total claimed quantity (always positive) * @return int Total claimed quantity (always positive)
*/ */
public function getClaimedStock(): int public function getCurrentlyClaimedStock(): int
{
return abs($this->stocks()
->where('type', StockType::CLAIMED->value)
->where('status', StockStatus::PENDING->value)
->willExpire()
->where(function ($query) {
$query->whereNull('claimed_from')
->orWhere('claimed_from', '<=', now());
})
->sum('quantity'));
}
/**
* Get total current and planned claimed stock
*
* Includes all PENDING claims, regardless of start date.
* Useful for understanding total reservations including future bookings.
* @return int Total current and future claimed quantity (always positive)
*/
public function getActiveAndPlannedClaimedStock(): int
{ {
return abs($this->stocks() return abs($this->stocks()
->where('type', StockType::CLAIMED->value) ->where('type', StockType::CLAIMED->value)
@ -316,6 +362,32 @@ trait HasStocks
->sum('quantity')); ->sum('quantity'));
} }
/**
* Get future claimed stock starting from a specific date or all where claimed_at is future
*
* @param \DateTimeInterface|null $from Optional start date to filter claims
* @return int Total future claimed quantity (always positive)
*/
public function getFutureClaimedStock(?\DateTimeInterface $from = null): int
{
$query = $this->stocks()
->where('type', StockType::CLAIMED->value)
->where('status', StockStatus::PENDING->value)
->willExpire();
if ($from) {
$query->where('claimed_from', '>=', $from);
} else {
$query->where(function ($q) {
$q->whereNotNull('claimed_from')
->where('claimed_from', '>', now());
});
}
return abs($query->sum('quantity'));
}
/** /**
* Log a stock change to the audit log * Log a stock change to the audit log
* *
@ -436,23 +508,6 @@ trait HasStocks
return PHP_INT_MAX; return PHP_INT_MAX;
} }
$stockModel = config('shop.models.product_stock', 'Blax\Shop\Models\ProductStock'); return $this->getAvailableStock($date);
// 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));
} }
} }

View File

@ -493,7 +493,7 @@ class ProductStockTest extends TestCase
$product->claimStock(quantity: 15); $product->claimStock(quantity: 15);
// Should return total claimed // Should return total claimed
$this->assertEquals(40, $product->getClaimedStock()); $this->assertEquals(40, $product->getCurrentlyClaimedStock());
} }
/** @test */ /** @test */
@ -714,7 +714,7 @@ class ProductStockTest extends TestCase
$product = Product::factory()->withStocks(100)->create(); $product = Product::factory()->withStocks(100)->create();
$this->assertEquals(100, $product->getAvailableStock()); $this->assertEquals(100, $product->getAvailableStock());
$this->assertEquals(0, $product->getClaimedStock()); $this->assertEquals(0, $product->getCurrentlyClaimedStock());
// Claim stock using adjustStock with CLAIMED type // Claim stock using adjustStock with CLAIMED type
// Note: adjustStock(CLAIMED) now delegates to claimStock() for consistency // Note: adjustStock(CLAIMED) now delegates to claimStock() for consistency
@ -728,7 +728,7 @@ class ProductStockTest extends TestCase
$this->assertEquals(75, $product->getAvailableStock()); $this->assertEquals(75, $product->getAvailableStock());
// Claimed stock shows the pending claim (always positive now) // Claimed stock shows the pending claim (always positive now)
$this->assertEquals(25, $product->getClaimedStock()); $this->assertEquals(25, $product->getCurrentlyClaimedStock());
} }
/** @test */ /** @test */
public function adjust_stock_with_until_parameter_expires_correctly() public function adjust_stock_with_until_parameter_expires_correctly()
@ -766,8 +766,8 @@ class ProductStockTest extends TestCase
until: now()->addDays(10) until: now()->addDays(10)
); );
// Current available stock is reduced by the DECREASE entry // Current available stock is unaffected until the claim starts
$this->assertEquals(70, $product->getAvailableStock()); $this->assertEquals(100, $product->getAvailableStock());
// Check availability on specific dates // Check availability on specific dates
$availableOnDay3 = $product->availableOnDate(now()->addDays(3)); $availableOnDay3 = $product->availableOnDate(now()->addDays(3));
@ -804,7 +804,7 @@ class ProductStockTest extends TestCase
$this->assertEquals(130, $product->getAvailableStock()); $this->assertEquals(130, $product->getAvailableStock());
// Total claimed stock (always positive) // Total claimed stock (always positive)
$this->assertEquals(70, $product->getClaimedStock()); $this->assertEquals(70, $product->getCurrentlyClaimedStock());
} }
/** @test */ /** @test */
public function adjust_stock_claimed_with_completed_status_does_not_count_as_claimed() public function adjust_stock_claimed_with_completed_status_does_not_count_as_claimed()
@ -824,7 +824,7 @@ class ProductStockTest extends TestCase
$this->assertEquals(100, $product->getAvailableStock()); $this->assertEquals(100, $product->getAvailableStock());
// Should NOT count as claimed stock (only PENDING claims count) // Should NOT count as claimed stock (only PENDING claims count)
$this->assertEquals(0, $product->getClaimedStock()); $this->assertEquals(0, $product->getCurrentlyClaimedStock());
} }
/** @test */ /** @test */
@ -842,22 +842,22 @@ class ProductStockTest extends TestCase
quantity: 30 quantity: 30
); );
$this->assertEquals(70, $product->getAvailableStock()); $this->assertEquals(70, $product->getAvailableStock());
$this->assertEquals(30, $product->getClaimedStock()); $this->assertEquals(30, $product->getCurrentlyClaimedStock());
// Decrease 20 (regular decrease with COMPLETED status) // Decrease 20 (regular decrease with COMPLETED status)
$product->adjustStock(type: StockType::DECREASE, quantity: 20); $product->adjustStock(type: StockType::DECREASE, quantity: 20);
$this->assertEquals(50, $product->getAvailableStock()); $this->assertEquals(50, $product->getAvailableStock());
$this->assertEquals(30, $product->getClaimedStock()); $this->assertEquals(30, $product->getCurrentlyClaimedStock());
// Return 10 (adds back to stock) // Return 10 (adds back to stock)
$product->adjustStock(type: StockType::RETURN, quantity: 10); $product->adjustStock(type: StockType::RETURN, quantity: 10);
$this->assertEquals(60, $product->getAvailableStock()); $this->assertEquals(60, $product->getAvailableStock());
$this->assertEquals(30, $product->getClaimedStock()); $this->assertEquals(30, $product->getCurrentlyClaimedStock());
// Increase 25 // Increase 25
$product->adjustStock(type: StockType::INCREASE, quantity: 25); $product->adjustStock(type: StockType::INCREASE, quantity: 25);
$this->assertEquals(85, $product->getAvailableStock()); $this->assertEquals(85, $product->getAvailableStock());
$this->assertEquals(30, $product->getClaimedStock()); $this->assertEquals(30, $product->getCurrentlyClaimedStock());
} }
/** @test */ /** @test */
public function adjust_stock_claimed_without_from_is_immediately_active() public function adjust_stock_claimed_without_from_is_immediately_active()
@ -920,6 +920,11 @@ class ProductStockTest extends TestCase
until: now()->addDays(15) until: now()->addDays(15)
); );
$this->assertEquals(100, $product->getAvailableStock());
$this->assertEquals(0, $product->getCurrentlyClaimedStock());
$this->assertEquals(20, $product->getActiveAndPlannedClaimedStock());
$this->assertEquals(20, $product->getFutureClaimedStock());
// Second claim: days 10-20 // Second claim: days 10-20
$product->adjustStock( $product->adjustStock(
type: StockType::CLAIMED, type: StockType::CLAIMED,
@ -928,6 +933,11 @@ class ProductStockTest extends TestCase
until: now()->addDays(20) until: now()->addDays(20)
); );
$this->assertEquals(100, $product->getAvailableStock());
$this->assertEquals(0, $product->getCurrentlyClaimedStock());
$this->assertEquals(50, $product->getActiveAndPlannedClaimedStock());
$this->assertEquals(50, $product->getFutureClaimedStock());
// Day 3: no claims active // Day 3: no claims active
$this->assertEquals(100, $product->availableOnDate(now()->addDays(3))); $this->assertEquals(100, $product->availableOnDate(now()->addDays(3)));
@ -943,11 +953,22 @@ class ProductStockTest extends TestCase
// Day 25: no claims active // Day 25: no claims active
$this->assertEquals(100, $product->availableOnDate(now()->addDays(25))); $this->assertEquals(100, $product->availableOnDate(now()->addDays(25)));
// Current available stock (both claims reduce it) // Current available stock (future claims do not reduce until active)
$this->assertEquals(50, $product->getAvailableStock()); $this->assertEquals(100, $product->getAvailableStock());
// Total claimed stock $this->travel(6)->days();
$this->assertEquals(50, $product->getClaimedStock());
$this->assertEquals(80, $product->getAvailableStock());
$this->assertEquals(20, $product->getCurrentlyClaimedStock());
$this->assertEquals(50, $product->getActiveAndPlannedClaimedStock());
$this->assertEquals(30, $product->getFutureClaimedStock());
$this->travel(6)->days();
$this->assertEquals(50, $product->getAvailableStock());
$this->assertEquals(50, $product->getCurrentlyClaimedStock());
$this->assertEquals(50, $product->getActiveAndPlannedClaimedStock());
$this->assertEquals(0, $product->getFutureClaimedStock());
} }
/** @test */ /** @test */
public function adjust_stock_with_note_and_reference_tracks_correctly() public function adjust_stock_with_note_and_reference_tracks_correctly()
@ -966,7 +987,7 @@ class ProductStockTest extends TestCase
// Available stock is reduced // Available stock is reduced
$this->assertEquals(75, $product->getAvailableStock()); $this->assertEquals(75, $product->getAvailableStock());
// Claimed stock shows positive value // Claimed stock shows positive value
$this->assertEquals(25, $product->getClaimedStock()); $this->assertEquals(25, $product->getCurrentlyClaimedStock());
// Verify note and reference are stored // Verify note and reference are stored
$claim = $product->stocks()->where('type', StockType::CLAIMED->value)->first(); $claim = $product->stocks()->where('type', StockType::CLAIMED->value)->first();
@ -992,7 +1013,7 @@ class ProductStockTest extends TestCase
$this->assertEquals(100, $product->getAvailableStock()); $this->assertEquals(100, $product->getAvailableStock());
// Claimed stock does NOT show expired claims (automatically excluded) // Claimed stock does NOT show expired claims (automatically excluded)
$this->assertEquals(0, $product->getClaimedStock()); $this->assertEquals(0, $product->getCurrentlyClaimedStock());
// Active claims (not expired) should be empty // Active claims (not expired) should be empty
$activeClaims = $product->claims()->get(); $activeClaims = $product->claims()->get();
@ -1011,7 +1032,7 @@ class ProductStockTest extends TestCase
); );
$this->assertEquals(60, $product->getAvailableStock()); $this->assertEquals(60, $product->getAvailableStock());
$this->assertEquals(40, $product->getClaimedStock()); $this->assertEquals(40, $product->getCurrentlyClaimedStock());
// Find and release the claim // Find and release the claim
$claim = $product->stocks() $claim = $product->stocks()
@ -1022,7 +1043,7 @@ class ProductStockTest extends TestCase
$claim->release(); $claim->release();
// Claimed stock should drop to 0 // Claimed stock should drop to 0
$this->assertEquals(0, $product->getClaimedStock()); $this->assertEquals(0, $product->getCurrentlyClaimedStock());
// Available stock is restored when claim is released // Available stock is restored when claim is released
$this->assertEquals(100, $product->getAvailableStock()); $this->assertEquals(100, $product->getAvailableStock());
@ -1069,7 +1090,7 @@ class ProductStockTest extends TestCase
// Initial state // Initial state
$this->assertEquals(100, $product->getAvailableStock()); $this->assertEquals(100, $product->getAvailableStock());
$this->assertEquals(0, $product->getClaimedStock()); $this->assertEquals(0, $product->getCurrentlyClaimedStock());
// Claim 30 units // Claim 30 units
$product->claimStock(quantity: 30, until: now()->addDays(5)); $product->claimStock(quantity: 30, until: now()->addDays(5));
@ -1078,7 +1099,7 @@ class ProductStockTest extends TestCase
$this->assertEquals(70, $product->getAvailableStock()); $this->assertEquals(70, $product->getAvailableStock());
// Claimed stock should show the claim // Claimed stock should show the claim
$this->assertEquals(30, $product->getClaimedStock()); $this->assertEquals(30, $product->getCurrentlyClaimedStock());
// Claim another 20 units // Claim another 20 units
$product->claimStock(quantity: 20, until: now()->addDays(3)); $product->claimStock(quantity: 20, until: now()->addDays(3));
@ -1087,7 +1108,7 @@ class ProductStockTest extends TestCase
$this->assertEquals(50, $product->getAvailableStock()); $this->assertEquals(50, $product->getAvailableStock());
// Claimed stock should show both claims // Claimed stock should show both claims
$this->assertEquals(50, $product->getClaimedStock()); $this->assertEquals(50, $product->getCurrentlyClaimedStock());
} }
/** @test */ /** @test */
@ -1100,13 +1121,13 @@ class ProductStockTest extends TestCase
// During claim period: available reduced, claimed shows 30 // During claim period: available reduced, claimed shows 30
$this->assertEquals(70, $product->getAvailableStock()); $this->assertEquals(70, $product->getAvailableStock());
$this->assertEquals(30, $product->getClaimedStock()); $this->assertEquals(30, $product->getCurrentlyClaimedStock());
// Travel to day 3 (still within claim period) // Travel to day 3 (still within claim period)
$this->travel(3)->days(); $this->travel(3)->days();
$this->assertEquals(70, $product->getAvailableStock()); $this->assertEquals(70, $product->getAvailableStock());
$this->assertEquals(30, $product->getClaimedStock()); $this->assertEquals(30, $product->getCurrentlyClaimedStock());
// Travel to day 6 (after expiration) // Travel to day 6 (after expiration)
$this->travel(3)->days(); $this->travel(3)->days();
@ -1115,7 +1136,7 @@ class ProductStockTest extends TestCase
$this->assertEquals(100, $product->getAvailableStock()); $this->assertEquals(100, $product->getAvailableStock());
// Claimed stock should be 0 (expired claims excluded) // Claimed stock should be 0 (expired claims excluded)
$this->assertEquals(0, $product->getClaimedStock()); $this->assertEquals(0, $product->getCurrentlyClaimedStock());
// No manual release() was called! // No manual release() was called!
} }
@ -1133,19 +1154,19 @@ class ProductStockTest extends TestCase
// Initial state: both claims active // Initial state: both claims active
$this->assertEquals(50, $product->getAvailableStock()); // 100 - 20 - 30 $this->assertEquals(50, $product->getAvailableStock()); // 100 - 20 - 30
$this->assertEquals(50, $product->getClaimedStock()); // 20 + 30 $this->assertEquals(50, $product->getCurrentlyClaimedStock()); // 20 + 30
// Travel to day 4 (first claim expired, second still active) // Travel to day 4 (first claim expired, second still active)
$this->travel(4)->days(); $this->travel(4)->days();
$this->assertEquals(70, $product->getAvailableStock()); // 100 - 30 (only second claim) $this->assertEquals(70, $product->getAvailableStock()); // 100 - 30 (only second claim)
$this->assertEquals(30, $product->getClaimedStock()); // Only second claim $this->assertEquals(30, $product->getCurrentlyClaimedStock()); // Only second claim
// Travel to day 8 (both claims expired) // Travel to day 8 (both claims expired)
$this->travel(4)->days(); $this->travel(4)->days();
$this->assertEquals(100, $product->getAvailableStock()); // All restored $this->assertEquals(100, $product->getAvailableStock()); // All restored
$this->assertEquals(0, $product->getClaimedStock()); // No claims $this->assertEquals(0, $product->getCurrentlyClaimedStock()); // No claims
} }
/** @test */ /** @test */
@ -1158,14 +1179,14 @@ class ProductStockTest extends TestCase
// Available stock reduced, claimed shows 25 // Available stock reduced, claimed shows 25
$this->assertEquals(75, $product->getAvailableStock()); $this->assertEquals(75, $product->getAvailableStock());
$this->assertEquals(25, $product->getClaimedStock()); $this->assertEquals(25, $product->getCurrentlyClaimedStock());
// Travel far into the future // Travel far into the future
$this->travel(100)->days(); $this->travel(100)->days();
// Permanent claim never expires // Permanent claim never expires
$this->assertEquals(75, $product->getAvailableStock()); $this->assertEquals(75, $product->getAvailableStock());
$this->assertEquals(25, $product->getClaimedStock()); $this->assertEquals(25, $product->getCurrentlyClaimedStock());
// Must manually release permanent claims // Must manually release permanent claims
$claim = $product->stocks() $claim = $product->stocks()
@ -1177,7 +1198,7 @@ class ProductStockTest extends TestCase
// Now stock is restored // Now stock is restored
$this->assertEquals(100, $product->getAvailableStock()); $this->assertEquals(100, $product->getAvailableStock());
$this->assertEquals(0, $product->getClaimedStock()); $this->assertEquals(0, $product->getCurrentlyClaimedStock());
} }
/** @test */ /** @test */
@ -1194,14 +1215,14 @@ class ProductStockTest extends TestCase
// During claim period // During claim period
$this->assertEquals(60, $product->getAvailableStock()); $this->assertEquals(60, $product->getAvailableStock());
$this->assertEquals(40, $product->getClaimedStock()); $this->assertEquals(40, $product->getCurrentlyClaimedStock());
// After expiration // After expiration
$this->travel(6)->days(); $this->travel(6)->days();
// Stock automatically restored // Stock automatically restored
$this->assertEquals(100, $product->getAvailableStock()); $this->assertEquals(100, $product->getAvailableStock());
$this->assertEquals(0, $product->getClaimedStock()); $this->assertEquals(0, $product->getCurrentlyClaimedStock());
} }
/** @test */ /** @test */
@ -1248,4 +1269,308 @@ class ProductStockTest extends TestCase
$this->assertEquals(35, $allClaimed); $this->assertEquals(35, $allClaimed);
} }
/** @test */
public function can_get_all_stocks_including_pending_and_expired()
{
$product = Product::factory()->withStocks(100)->create();
$product->adjustStock(type: StockType::INCREASE, quantity: 50);
$this->assertEquals(150, $product->getAvailableStock());
$this->assertEquals(0, $product->getCurrentlyClaimedStock());
$product->adjustStock(type: StockType::DECREASE, quantity: 20);
$this->assertEquals(130, $product->getAvailableStock());
$this->assertEquals(0, $product->getCurrentlyClaimedStock());
$product->adjustStock(type: StockType::CLAIMED, quantity: 30, until: now()->addDays(5));
$this->assertEquals(100, $product->getAvailableStock());
$this->assertEquals(30, $product->getCurrentlyClaimedStock());
$product->adjustStock(type: StockType::CLAIMED, quantity: 10, until: now()->subDays(1));
$this->assertEquals(100, $product->getAvailableStock());
$this->assertEquals(30, $product->getCurrentlyClaimedStock());
$product->adjustStock(type: StockType::CLAIMED, quantity: 10, from: now()->addDays(1), until: now()->addDays(5));
$this->assertEquals(100, $product->getAvailableStock());
$this->assertEquals(30, $product->getCurrentlyClaimedStock());
$allStocks = $product->allStocks()->get();
$types = $allStocks->pluck('type')->toArray();
$this->assertCount(9, $allStocks);
$this->assertContains(StockType::INCREASE, $types);
$this->assertContains(StockType::DECREASE, $types);
$this->assertEquals(3, count(array_filter($types, fn($t) => $t === StockType::CLAIMED)));
$this->travel(2)->days();
$this->assertEquals(90, $product->getAvailableStock());
$this->assertEquals(40, $product->getCurrentlyClaimedStock());
$this->travel(5)->days();
$this->assertEquals(130, $product->getAvailableStock());
$this->assertEquals(0, $product->getCurrentlyClaimedStock());
}
/** @test */
public function get_claimed_stock_returns_active_pending_claims_only()
{
$product = Product::factory()->withStocks(100)->create();
// Create active claims (immediately claimed)
$product->claimStock(quantity: 20);
$product->claimStock(quantity: 15);
// Create future claim (starts later)
$product->claimStock(quantity: 10, from: now()->addDays(5));
// Create expired claim
$product->claimStock(quantity: 25, until: now()->subDay());
// getCurrentlyClaimedStock should only count active claims: 20 + 15 = 35
$this->assertEquals(35, $product->getCurrentlyClaimedStock());
// Verify the future claim and expired claim are not counted
$allClaims = $product->stocks()
->where('type', StockType::CLAIMED->value)
->get();
$this->assertCount(4, $allClaims);
}
/** @test */
public function get_claimed_stock_excludes_released_claims()
{
$product = Product::factory()->withStocks(100)->create();
// Create claims
$claim1 = $product->claimStock(quantity: 20);
$claim2 = $product->claimStock(quantity: 30);
$this->assertEquals(50, $product->getCurrentlyClaimedStock());
// Release one claim
$claim1->release();
// Should only count remaining active claim
$this->assertEquals(30, $product->fresh()->getCurrentlyClaimedStock());
// Release the other claim
$claim2->release();
// Should be zero when all released
$this->assertEquals(0, $product->fresh()->getCurrentlyClaimedStock());
}
/** @test */
public function get_claimed_stock_accounts_for_claim_expiration()
{
$product = Product::factory()->withStocks(200)->create();
// Create claims with different expiration dates
$product->claimStock(quantity: 50, until: now()->addDays(2));
$product->claimStock(quantity: 75, until: now()->addDays(10));
$product->claimStock(quantity: 30); // permanent claim (no expiration)
// Initially, all should be claimed
$this->assertEquals(155, $product->getCurrentlyClaimedStock());
// Travel to day 3 - first claim should have expired
$this->travel(3)->days();
$this->assertEquals(105, $product->fresh()->getCurrentlyClaimedStock());
// Travel to day 11 - second claim should have expired
$this->travel(9)->days();
$this->assertEquals(30, $product->fresh()->getCurrentlyClaimedStock());
// Permanent claim should never expire
$this->travel(100)->days();
$this->assertEquals(30, $product->fresh()->getCurrentlyClaimedStock());
}
/** @test */
public function get_total_planned_claimed_stock_includes_future_claims()
{
$product = Product::factory()->withStocks(300)->create();
// Create active claims
$product->claimStock(quantity: 25);
// Create future claims (start later)
$product->claimStock(quantity: 50, from: now()->addDays(2));
$product->claimStock(quantity: 35, from: now()->addDays(7));
// Create permanent claim from specific date
$product->claimStock(quantity: 40, from: now()->addDays(15));
// getActiveAndPlannedClaimedStock should include all PENDING claims regardless of start date
// This includes: 25 + 50 + 35 + 40 = 150
$this->assertEquals(150, $product->getActiveAndPlannedClaimedStock());
// getCurrentlyClaimedStock should only include active ones (started)
$this->assertEquals(25, $product->getCurrentlyClaimedStock());
}
/** @test */
public function get_total_planned_claimed_stock_excludes_expired_claims()
{
$product = Product::factory()->withStocks(200)->create();
// Create various claims
$product->claimStock(quantity: 30, until: now()->subDay()); // already expired
$product->claimStock(quantity: 40, until: now()->addDays(5)); // will expire
$product->claimStock(quantity: 50); // permanent
// Should only count non-expired PENDING claims: 40 + 50 = 90
$this->assertEquals(90, $product->getActiveAndPlannedClaimedStock());
}
/** @test */
public function get_total_planned_claimed_stock_excludes_released_claims()
{
$product = Product::factory()->withStocks(200)->create();
// Create claims
$claim1 = $product->claimStock(quantity: 35, from: now()->addDays(3));
$claim2 = $product->claimStock(quantity: 45, from: now()->addDays(8));
$claim3 = $product->claimStock(quantity: 25);
$this->assertEquals(105, $product->getActiveAndPlannedClaimedStock());
// Release one future claim
$claim1->release();
// Should not count released claims: 45 + 25 = 70
$this->assertEquals(70, $product->fresh()->getActiveAndPlannedClaimedStock());
// Release another
$claim2->release();
// Should only count unreleased: 25
$this->assertEquals(25, $product->fresh()->getActiveAndPlannedClaimedStock());
}
/** @test */
public function get_future_claimed_stock_without_from_date_parameter()
{
$product = Product::factory()->withStocks(300)->create();
// Create immediate claim (no claimed_from)
$product->claimStock(quantity: 20);
// Create future claims
$product->claimStock(quantity: 30, from: now()->addDays(2));
$product->claimStock(quantity: 40, from: now()->addDays(5));
// Without parameter, getFutureClaimedStock should only count claims with claimed_from > now()
$this->assertEquals(70, $product->getFutureClaimedStock());
// Travel forward
$this->travel(3)->days();
// Now the first future claim (day 2) is active, so only the day 5 one remains
$this->assertEquals(40, $product->fresh()->getFutureClaimedStock());
// Travel more
$this->travel(3)->days();
// All future claims have started
$this->assertEquals(0, $product->fresh()->getFutureClaimedStock());
}
/** @test */
public function get_future_claimed_stock_with_from_date_parameter()
{
$product = Product::factory()->withStocks(300)->create();
// Create claims
$product->claimStock(quantity: 20); // no claimed_from
$product->claimStock(quantity: 30, from: now()->addDays(3));
$product->claimStock(quantity: 40, from: now()->addDays(8));
$product->claimStock(quantity: 25, from: now()->addDays(15));
// Get claims starting from day 5 onwards
$fromDay5 = $product->getFutureClaimedStock(now()->addDays(5));
$this->assertEquals(65, $fromDay5); // 40 + 25
// Get claims starting from day 10 onwards
$fromDay10 = $product->getFutureClaimedStock(now()->addDays(10));
$this->assertEquals(25, $fromDay10); // 25
// Get claims starting from future date with no claims
$fromDay20 = $product->getFutureClaimedStock(now()->addDays(20));
$this->assertEquals(0, $fromDay20);
}
/** @test */
public function get_future_claimed_stock_excludes_expired_claims()
{
$product = Product::factory()->withStocks(300)->create();
// Future claim that expires before being used
$product->claimStock(quantity: 30, from: now()->addDays(2), until: now()->addDays(3));
// Future claim with normal expiration
$product->claimStock(quantity: 50, from: now()->addDays(5), until: now()->addDays(10));
// Without parameter, both are future claims
$this->assertEquals(80, $product->getFutureClaimedStock());
// Travel to day 4 (first claim expired on day 3, second still active)
$this->travel(4)->days();
// getFutureClaimedStock uses willExpire() which filters out expired claims
// The first claim is expired, so only the second one counts
// The second claim still starts in the future (day 5 from original, which is tomorrow)
$this->assertEquals(50, $product->fresh()->getFutureClaimedStock());
}
/** @test */
public function claimed_stock_methods_work_together()
{
$product = Product::factory()->withStocks(500)->create();
// Create a mix of claims
$product->claimStock(quantity: 50); // active now
$product->claimStock(quantity: 75, from: now()->addDays(3)); // future
$product->claimStock(quantity: 40, from: now()->addDays(7), until: now()->addDays(10)); // future with expiry
$product->claimStock(quantity: 60); // another active now
// Test all three methods
$this->assertEquals(110, $product->getCurrentlyClaimedStock()); // 50 + 60 (active claims)
$this->assertEquals(225, $product->getActiveAndPlannedClaimedStock()); // all 4 claims
$this->assertEquals(115, $product->getFutureClaimedStock()); // 75 + 40 (future claims)
// Release one active claim
$activeClaimId = $product->stocks()
->where('type', StockType::CLAIMED->value)
->where('status', StockStatus::PENDING->value)
->whereNull('claimed_from')
->first()
->id;
$product->stocks()->find($activeClaimId)->release();
// Recalculate
$this->assertEquals(60, $product->fresh()->getCurrentlyClaimedStock()); // only one active now
$this->assertEquals(175, $product->fresh()->getActiveAndPlannedClaimedStock()); // 3 remaining
$this->assertEquals(115, $product->fresh()->getFutureClaimedStock()); // future ones unchanged
}
/** @test */
public function claimed_stock_methods_return_zero_for_unmanaged_stock()
{
$product = Product::factory()->create(['manage_stock' => false]);
// Try to claim stock (should return null)
$claim = $product->claimStock(quantity: 50);
$this->assertNull($claim);
// Methods should still return 0
$this->assertEquals(0, $product->getCurrentlyClaimedStock());
$this->assertEquals(0, $product->getActiveAndPlannedClaimedStock());
$this->assertEquals(0, $product->getFutureClaimedStock());
}
} }