stl_test/app/Services/SlotService.php
2026-05-17 21:48:34 +07:00

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();
}
}