146 lines
4.6 KiB
PHP
146 lines
4.6 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Enums\HoldStatus;
|
|
use App\Exceptions\HoldNotAvailableException;
|
|
use App\Exceptions\SlotCapacityExceededException;
|
|
use App\Models\Hold;
|
|
use App\Models\Slot;
|
|
use Illuminate\Database\UniqueConstraintViolationException;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
class SlotService
|
|
{
|
|
private const string AVAILABILITY_CACHE_KEY = 'slots:availability';
|
|
|
|
/**
|
|
* @return list<array{slot_id: int, capacity: int, remaining: int}>
|
|
*/
|
|
public function getAvailability(): array
|
|
{
|
|
$fresh = config('slots.availability_cache_fresh_seconds');
|
|
$ttl = config('slots.availability_cache_ttl_seconds');
|
|
|
|
return Cache::flexible(self::AVAILABILITY_CACHE_KEY, [$fresh, $ttl], function (): array {
|
|
return $this->buildAvailabilityPayload();
|
|
});
|
|
}
|
|
|
|
private function buildAvailabilityPayload(): array
|
|
{
|
|
return Slot::query()
|
|
->orderBy('id')
|
|
->get()
|
|
->map(fn (Slot $slot): array => [
|
|
'slot_id' => $slot->id,
|
|
'capacity' => $slot->capacity,
|
|
'remaining' => $this->availableRemaining($slot),
|
|
])
|
|
->all();
|
|
}
|
|
|
|
public function createHold(Slot $slot, string $idempotencyKey): Hold
|
|
{
|
|
$existing = $this->findHoldByIdempotencyKey($idempotencyKey);
|
|
if ($existing !== null) {
|
|
return $existing->load('slot');
|
|
}
|
|
|
|
try {
|
|
return DB::transaction(function () use ($slot, $idempotencyKey): Hold {
|
|
$lockedSlot = Slot::query()->lockForUpdate()->findOrFail($slot->id);
|
|
|
|
if ($this->availableRemaining($lockedSlot) <= 0) {
|
|
throw new SlotCapacityExceededException('Slot capacity is exhausted.');
|
|
}
|
|
|
|
return Hold::query()->create([
|
|
'slot_id' => $lockedSlot->id,
|
|
'idempotency_key' => $idempotencyKey,
|
|
'status' => HoldStatus::Held,
|
|
'expires_at' => now()->addMinutes(config('slots.hold_ttl_minutes')),
|
|
])->load('slot');
|
|
});
|
|
} catch (UniqueConstraintViolationException) {
|
|
$existing = $this->findHoldByIdempotencyKey($idempotencyKey);
|
|
|
|
if ($existing === null) {
|
|
throw new SlotCapacityExceededException('Slot capacity is exhausted.');
|
|
}
|
|
|
|
return $existing->load('slot');
|
|
}
|
|
}
|
|
|
|
public function confirmHold(Hold $hold): Hold
|
|
{
|
|
return DB::transaction(function () use ($hold) {
|
|
$lockedHold = Hold::query()->lockForUpdate()->findOrFail($hold->id);
|
|
|
|
if ($lockedHold->status === HoldStatus::Confirmed) {
|
|
return $lockedHold->load('slot');
|
|
}
|
|
|
|
if ($lockedHold->status !== HoldStatus::Held || $lockedHold->isExpired()) {
|
|
throw new HoldNotAvailableException('Hold cannot be confirmed');
|
|
}
|
|
|
|
$slot = Slot::query()->lockForUpdate()->findOrFail($lockedHold->slot_id);
|
|
|
|
if ($slot->remaining < 1) {
|
|
throw new SlotCapacityExceededException('Slot capacity is exhausted.');
|
|
}
|
|
|
|
$slot->remaining--;
|
|
$slot->save();
|
|
|
|
$lockedHold->status = HoldStatus::Confirmed;
|
|
$lockedHold->save();
|
|
|
|
return $lockedHold->load('slot');
|
|
});
|
|
}
|
|
|
|
public function cancelHold(Hold $hold): Hold
|
|
{
|
|
return DB::transaction(function () use ($hold) {
|
|
$lockedHold = Hold::query()->lockForUpdate()->findOrFail($hold->id);
|
|
|
|
if ($lockedHold->status === HoldStatus::Cancelled) {
|
|
return $lockedHold->load('slot');
|
|
}
|
|
|
|
if ($lockedHold->status === HoldStatus::Confirmed) {
|
|
$slot = Slot::query()->lockForUpdate()->findOrFail($lockedHold->slot_id);
|
|
$slot->remaining = min($slot->capacity, $slot->remaining + 1);
|
|
$slot->save();
|
|
}
|
|
|
|
$lockedHold->status = HoldStatus::Cancelled;
|
|
$lockedHold->save();
|
|
|
|
return $lockedHold->load('slot');
|
|
});
|
|
}
|
|
|
|
private function availableRemaining(Slot $slot): int
|
|
{
|
|
$activeHolds = Hold::query()
|
|
->where('slot_id', $slot->id)
|
|
->where('status', HoldStatus::Held)
|
|
->where('expires_at', '>', now())
|
|
->count();
|
|
|
|
return max(0, $slot->remaining - $activeHolds);
|
|
}
|
|
|
|
private function findHoldByIdempotencyKey(string $idempotencyKey): ?Hold
|
|
{
|
|
return Hold::query()
|
|
->where('idempotency_key', $idempotencyKey)
|
|
->first();
|
|
}
|
|
}
|