A stock methods calendarAvailability & dayAvailability

This commit is contained in:
a6a2f5842 2025-12-26 16:10:40 +01:00
parent 38e841a986
commit 5ac0229555
2 changed files with 241 additions and 0 deletions

View File

@ -6,6 +6,7 @@ use Blax\Shop\Enums\StockStatus;
use Blax\Shop\Enums\StockType; use Blax\Shop\Enums\StockType;
use Blax\Shop\Exceptions\NotEnoughStockException; use Blax\Shop\Exceptions\NotEnoughStockException;
use Carbon\Carbon; use Carbon\Carbon;
use DateTimeInterface;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@ -510,4 +511,170 @@ trait HasStocks
return $this->getAvailableStock($date); return $this->getAvailableStock($date);
} }
/**
* Gets the available amounts per date range, with $from and $until specified
* Returns associative array with keys
* - 'max_available' => Shows the peak available stock in the date range
* - 'min_available' => Shows the lowest available stock in the date range
* - 'dates' => An array of dates with their respective available stock
*
* @param \DateTimeInterface $from Start date of the range (optional, defaults to today)
* @param \DateTimeInterface $until End date of the range (optional, defaults to 30 days)
* @return array Associative array with 'max_available', 'min_available', and 'dates'
*/
public function calendarAvailability(
?DateTimeInterface $from = null,
?DateTimeInterface $until = null
): array {
if ($this->manage_stock === false) {
return [
'max_available' => PHP_INT_MAX,
'min_available' => PHP_INT_MAX,
'dates' => [],
];
}
$fromDate = Carbon::parse($from ?? now())->startOfDay();
$untilDate = Carbon::parse($until ?? $fromDate->copy()->addDays(30))->endOfDay();
// Fetch all relevant stocks once for performance
$allStocks = $this->stocks()
->withoutGlobalScope('willExpire')
->where(function ($query) {
$query->where('status', StockStatus::COMPLETED->value)
->where('type', '!=', StockType::CLAIMED->value);
})
->orWhere(function ($query) {
$query->where('status', StockStatus::PENDING->value)
->where('type', StockType::CLAIMED->value);
})
->get();
$dates = [];
$globalMax = PHP_INT_MIN;
$globalMin = PHP_INT_MAX;
$currentDate = $fromDate->copy();
while ($currentDate->lte($untilDate)) {
$dayStart = $currentDate->copy()->startOfDay();
$dayEnd = $currentDate->copy()->endOfDay();
// Find all "event" timestamps for this day where availability might change
$events = [$dayStart, $dayEnd];
foreach ($allStocks as $stock) {
if ($stock->claimed_from && $stock->claimed_from->between($dayStart, $dayEnd)) {
$events[] = Carbon::parse($stock->claimed_from);
}
if ($stock->expires_at && $stock->expires_at->between($dayStart, $dayEnd)) {
$events[] = Carbon::parse($stock->expires_at);
}
}
$dayMin = PHP_INT_MAX;
$dayMax = PHP_INT_MIN;
// Check availability at each event timestamp to find min/max for the day
foreach ($events as $eventTime) {
$available = 0;
foreach ($allStocks as $stock) {
if ($stock->status === StockStatus::COMPLETED && $stock->type !== StockType::CLAIMED) {
if (is_null($stock->expires_at) || $stock->expires_at > $eventTime) {
$available += $stock->quantity;
}
} elseif ($stock->status === StockStatus::PENDING && $stock->type === StockType::CLAIMED) {
// Add back if NOT active at this timestamp
$isNotStarted = $stock->claimed_from && $stock->claimed_from > $eventTime;
$isExpired = $stock->expires_at && $stock->expires_at <= $eventTime;
if ($isNotStarted || $isExpired) {
$available += $stock->quantity;
}
}
}
$available = max(0, $available);
$dayMin = min($dayMin, $available);
$dayMax = max($dayMax, $available);
}
$dates[$currentDate->toDateString()] = [
'min' => $dayMin,
'max' => $dayMax,
];
$globalMin = min($globalMin, $dayMin);
$globalMax = max($globalMax, $dayMax);
$currentDate->addDay();
}
return [
'max_available' => $globalMax === PHP_INT_MIN ? 0 : $globalMax,
'min_available' => $globalMin === PHP_INT_MAX ? 0 : $globalMin,
'dates' => $dates,
];
}
public function calendarAvailabilityDates(
?DateTimeInterface $from = null,
?DateTimeInterface $until = null
): array {
$availability = $this->calendarAvailability($from, $until);
return $availability['dates'];
}
/**
* Gets the availability on the day by time. 00:00 shows the availables at the start of the day.
* Every other timestamp shows what total current availability is at that time.
*
* @param null|DateTimeInterface $date
* @return array|int
*/
public function dayAvailability(?DateTimeInterface $date = null)
{
if ($this->manage_stock === false) {
return PHP_INT_MAX;
}
$date = Carbon::parse($date ?? now());
$startOfDay = $date->copy()->startOfDay();
$endOfDay = $date->copy()->endOfDay();
$availability = [
'00:00' => $this->availableOnDate($startOfDay),
];
$stocks = $this->stocks()
->withoutGlobalScope('willExpire')
->where(function ($query) use ($startOfDay, $endOfDay) {
$query->where(function ($q) use ($startOfDay, $endOfDay) {
$q->whereNotNull('claimed_from')
->whereBetween('claimed_from', [$startOfDay, $endOfDay]);
})->orWhere(function ($q) use ($startOfDay, $endOfDay) {
$q->whereNotNull('expires_at')
->whereBetween('expires_at', [$startOfDay, $endOfDay]);
});
})
->get();
foreach ($stocks as $stock) {
if ($stock->claimed_from && $stock->claimed_from->isSameDay($startOfDay)) {
$timeKey = $stock->claimed_from->format('H:i');
if (!isset($availability[$timeKey])) {
$availability[$timeKey] = $this->availableOnDate($stock->claimed_from);
}
}
if ($stock->expires_at && $stock->expires_at->isSameDay($startOfDay)) {
$timeKey = $stock->expires_at->format('H:i');
if (!isset($availability[$timeKey])) {
$availability[$timeKey] = $this->availableOnDate($stock->expires_at);
}
}
}
ksort($availability);
return $availability;
}
} }

View File

@ -431,4 +431,78 @@ class StockManagementTest extends TestCase
$this->assertFalse($result); $this->assertFalse($result);
$this->assertCount(0, $product->stocks); $this->assertCount(0, $product->stocks);
} }
#[Test]
public function it_shows_calendar_availability_correctly_with_claimed_stock()
{
$product = Product::factory()->withStocks(50)->create();
// Claim stock from day 3 to day 7
$product->claimStock(
quantity: 20,
from: now()->endOfDay()->addDays(3),
until: now()->endOfDay()->subHours(6)->addDays(7)
);
$product->claimStock(
quantity: 2,
from: now()->endOfDay()->addDays(1),
until: now()->endOfDay()->addDays(2)
);
$product->claimStock(
quantity: 5,
from: now()->endOfDay()->addDays(10),
until: now()->endOfDay()->addDays(22)
);
$availability = $product->calendarAvailability();
$this->assertEquals(50, $availability['max_available']);
$this->assertEquals(30, $availability['min_available']);
$this->assertCount(31, $availability['dates']);
// Check specific dates
$this->assertEquals(['min' => 50, 'max' => 50], $availability['dates'][now()->toDateString()]);
$this->assertEquals(['min' => 48, 'max' => 50], $availability['dates'][now()->addDays(1)->toDateString()]);
$this->assertEquals(['min' => 48, 'max' => 50], $availability['dates'][now()->addDays(2)->toDateString()]);
$this->assertEquals(['min' => 30, 'max' => 50], $availability['dates'][now()->addDays(3)->toDateString()]);
$this->assertEquals(['min' => 30, 'max' => 30], $availability['dates'][now()->addDays(4)->toDateString()]);
$this->assertEquals(['min' => 30, 'max' => 50], $availability['dates'][now()->addDays(7)->toDateString()]);
$this->assertEquals(['min' => 50, 'max' => 50], $availability['dates'][now()->addDays(8)->toDateString()]);
$this->assertEquals(['min' => 45, 'max' => 45], $availability['dates'][now()->addDays(11)->toDateString()]);
$this->assertEquals(['min' => 45, 'max' => 50], $availability['dates'][now()->addDays(22)->toDateString()]);
$this->assertEquals(['min' => 50, 'max' => 50], $availability['dates'][now()->addDays(23)->toDateString()]);
$minValues = array_column($availability['dates'], 'min');
$valueCounts = array_count_values($minValues);
$this->assertEquals(11, $valueCounts['50']);
$this->assertEquals(2, $valueCounts['48']);
$this->assertEquals(13, $valueCounts['45']);
$this->assertEquals(5, $valueCounts['30']);
// Test custom range
$customAvailability = $product->calendarAvailability(
from: now()->addDays(3),
until: now()->addDays(10)
);
$this->assertCount(8, $customAvailability['dates']); // Day 3 to Day 10 inclusive
$this->assertEquals(50, $customAvailability['max_available']);
$this->assertEquals(30, $customAvailability['min_available']);
$customMinValues = array_column($customAvailability['dates'], 'min');
$customValueCounts = array_count_values($customMinValues);
$this->assertEquals(5, $customValueCounts['30']); // Days 3, 4, 5, 6, 7
$this->assertEquals(2, $customValueCounts['50']); // Days 8, 9
$this->assertEquals(1, $customValueCounts['45']); // Day 10
// dayAvailability
$dayAvailability = $product->dayAvailability(now()->addDays(7));
$this->assertEquals(30, $dayAvailability['00:00']);
$this->assertArrayHasKey(now()->endOfDay()->subHours(6)->addDays(7)->format('H:i'), $dayAvailability);
$this->assertEquals(50, @$dayAvailability[now()->endOfDay()->subHours(6)->addDays(7)->format('H:i')]);
}
} }