feat: complete reservation lifecycle

This commit is contained in:
2026-08-04 16:30:11 +01:00
parent 077015f17a
commit e667d05bc1
24 changed files with 1096 additions and 46 deletions

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Console\Commands;
use App\Domain\Reservations\Services\ReservationService;
use App\Jobs\ExpirePendingReservations;
use Illuminate\Console\Command;
class ExpirePendingReservationsCommand extends Command
{
protected $signature = 'reservations:expire {--queue : Colocar a expiração na fila em vez de a executar agora}';
protected $description = 'Expira pré-reservas cujo prazo de aprovação terminou';
public function handle(ReservationService $reservations): int
{
if ($this->option('queue')) {
ExpirePendingReservations::dispatch();
$this->info('Expiração de pré-reservas colocada na fila.');
return self::SUCCESS;
}
$expired = $reservations->expirePending();
$this->info("{$expired} pré-reserva(s) expirada(s).");
return self::SUCCESS;
}
}

View File

@@ -4,11 +4,18 @@ namespace App\Domain\Reservations\Models;
use App\Domain\Reservations\Enums\ReservationStatus;
use App\Models\User;
use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* @property ReservationStatus $status
* @property CarbonImmutable $starts_at
* @property CarbonImmutable $ends_at
* @property CarbonImmutable|null $approval_deadline
*/
class Reservation extends Model
{
use HasUuids;

View File

@@ -5,9 +5,26 @@ namespace App\Domain\Reservations\Services;
use App\Domain\Inventory\Models\SnipeAsset;
use App\Domain\Reservations\Enums\ReservationItemStatus;
use DateTimeInterface;
use Illuminate\Database\Eloquent\Builder;
class AvailabilityService
{
/** @return Builder<SnipeAsset> */
public function availableAssets(DateTimeInterface $startsAt, DateTimeInterface $endsAt): Builder
{
return SnipeAsset::query()
->where('active', true)
->where('requestable', true)
->whereNull('assigned_id')
->whereDoesntHave('reservationItems', fn (Builder $query): Builder => $query
->whereIn('status', ReservationItemStatus::blockingValues())
->where('starts_at', '<', $endsAt)
->where('ends_at', '>', $startsAt))
->whereDoesntHave('blocks', fn (Builder $query): Builder => $query
->where('starts_at', '<', $endsAt)
->where('ends_at', '>', $startsAt));
}
public function isAvailable(SnipeAsset $asset, DateTimeInterface $startsAt, DateTimeInterface $endsAt): bool
{
if (! $asset->active || ! $asset->requestable || $asset->assigned_id !== null) {

View File

@@ -9,6 +9,7 @@ use App\Domain\Reservations\Enums\ReservationStatus;
use App\Domain\Reservations\Models\Reservation;
use App\Models\User;
use Carbon\CarbonImmutable;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
@@ -78,6 +79,107 @@ class ReservationService
}, 3);
}
public function cancel(Reservation $reservation, User $actor, string $reason): Reservation
{
if ($reservation->requester_id !== $actor->id) {
throw new AuthorizationException('Apenas o requisitante pode cancelar este pedido.');
}
return DB::transaction(function () use ($reservation, $actor, $reason): Reservation {
$lockedReservation = Reservation::query()
->whereKey($reservation->id)
->lockForUpdate()
->firstOrFail();
if (! $lockedReservation->status->canTransitionTo(ReservationStatus::Cancelled)) {
throw ValidationException::withMessages([
'reason' => 'Esta requisição já não pode ser cancelada.',
]);
}
$previousStatus = $lockedReservation->status->value;
$lockedReservation->update([
'status' => ReservationStatus::Cancelled,
'cancelled_by' => $actor->id,
'cancelled_at' => now(),
'cancellation_reason' => $reason,
]);
$lockedReservation->items()
->whereIn('status', ReservationItemStatus::blockingValues())
->update(['status' => ReservationItemStatus::Cancelled]);
AuditLog::create([
'actor_id' => $actor->id,
'event' => 'reservation.cancelled',
'auditable_type' => $lockedReservation->getMorphClass(),
'auditable_id' => $lockedReservation->id,
'before' => ['status' => $previousStatus],
'after' => [
'status' => ReservationStatus::Cancelled->value,
'cancellation_reason' => $reason,
],
]);
return $lockedReservation->load('items.asset');
}, 3);
}
public function expirePending(int $limit = 100): int
{
$reservationIds = Reservation::query()
->whereIn('status', [
ReservationStatus::PendingApproval,
ReservationStatus::AwaitingInformation,
])
->whereNotNull('approval_deadline')
->where('approval_deadline', '<=', now())
->oldest('approval_deadline')
->limit($limit)
->pluck('id');
$expired = 0;
foreach ($reservationIds as $reservationId) {
$didExpire = DB::transaction(function () use ($reservationId): bool {
$reservation = Reservation::query()
->whereKey($reservationId)
->lockForUpdate()
->first();
if ($reservation === null
|| $reservation->approval_deadline === null
|| $reservation->approval_deadline->isFuture()
|| ! $reservation->status->canTransitionTo(ReservationStatus::Expired)) {
return false;
}
$previousStatus = $reservation->status->value;
$reservation->update(['status' => ReservationStatus::Expired]);
$reservation->items()
->whereIn('status', ReservationItemStatus::blockingValues())
->update(['status' => ReservationItemStatus::Cancelled]);
AuditLog::create([
'actor_id' => null,
'event' => 'reservation.expired',
'auditable_type' => $reservation->getMorphClass(),
'auditable_id' => $reservation->id,
'before' => ['status' => $previousStatus],
'after' => ['status' => ReservationStatus::Expired->value],
'metadata' => ['approval_deadline' => $reservation->approval_deadline->toIso8601String()],
]);
return true;
}, 3);
if ($didExpire) {
$expired++;
}
}
return $expired;
}
private function nextReference(): string
{
do {

View File

@@ -3,23 +3,36 @@
namespace App\Http\Controllers;
use App\Domain\Inventory\Models\SnipeAsset;
use App\Domain\Reservations\Services\AvailabilityService;
use Carbon\CarbonImmutable;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class AssetController extends Controller
{
public function index(Request $request): Response
public function index(Request $request, AvailabilityService $availability): Response
{
$filters = $request->validate([
'search' => ['nullable', 'string', 'max:100'],
'category' => ['nullable', 'string', 'max:100'],
'location' => ['nullable', 'string', 'max:100'],
'starts_at' => ['nullable', 'date', 'required_with:ends_at', 'after:now'],
'ends_at' => ['nullable', 'date', 'required_with:starts_at', 'after:starts_at'],
]);
$assets = SnipeAsset::query()
->where('active', true)
->where('requestable', true)
$periodSelected = isset($filters['starts_at'], $filters['ends_at']);
$query = $periodSelected
? $availability->availableAssets(
CarbonImmutable::parse($filters['starts_at']),
CarbonImmutable::parse($filters['ends_at']),
)
: SnipeAsset::query()
->where('active', true)
->where('requestable', true)
->whereNull('assigned_id');
$assets = $query
->when($filters['search'] ?? null, function ($query, string $search) {
$query->where(function ($query) use ($search) {
$query->where('name', 'like', "%{$search}%")
@@ -36,8 +49,9 @@ class AssetController extends Controller
return Inertia::render('assets/index', [
'assets' => $assets,
'filters' => $filters,
'categories' => SnipeAsset::query()->where('active', true)->whereNotNull('category_name')->distinct()->orderBy('category_name')->pluck('category_name'),
'locations' => SnipeAsset::query()->where('active', true)->whereNotNull('location_name')->distinct()->orderBy('location_name')->pluck('location_name'),
'periodSelected' => $periodSelected,
'categories' => SnipeAsset::query()->where('active', true)->where('requestable', true)->whereNotNull('category_name')->distinct()->orderBy('category_name')->pluck('category_name'),
'locations' => SnipeAsset::query()->where('active', true)->where('requestable', true)->whereNotNull('location_name')->distinct()->orderBy('location_name')->pluck('location_name'),
'lastSyncedAt' => SnipeAsset::max('synced_at'),
]);
}

View File

@@ -3,8 +3,11 @@
namespace App\Http\Controllers;
use App\Domain\Inventory\Models\SnipeAsset;
use App\Domain\Reservations\Models\Reservation;
use App\Domain\Reservations\Services\AvailabilityService;
use App\Domain\Reservations\Services\ReservationService;
use App\Http\Requests\StoreReservationRequest;
use Carbon\CarbonImmutable;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
@@ -22,16 +25,54 @@ class ReservationController extends Controller
]);
}
public function create(Request $request): Response
public function calendar(Request $request): Response
{
return Inertia::render('reservations/create', [
'assets' => SnipeAsset::query()
$filters = $request->validate([
'month' => ['nullable', 'date_format:Y-m'],
]);
$month = isset($filters['month'])
? CarbonImmutable::createFromFormat('!Y-m', $filters['month'])
: now()->startOfMonth();
$windowEnd = $month->addMonth();
return Inertia::render('reservations/calendar', [
'month' => $month->format('Y-m'),
'reservations' => $request->user()->reservations()
->with('items.asset:id,name,asset_tag')
->where('starts_at', '<', $windowEnd)
->where('ends_at', '>', $month)
->orderBy('starts_at')
->get(),
]);
}
public function create(Request $request, AvailabilityService $availability): Response
{
$period = $request->validate([
'starts_at' => ['nullable', 'date', 'required_with:ends_at', 'after:now'],
'ends_at' => ['nullable', 'date', 'required_with:starts_at', 'after:starts_at'],
]);
$periodSelected = isset($period['starts_at'], $period['ends_at']);
$query = $periodSelected
? $availability->availableAssets(
CarbonImmutable::parse($period['starts_at']),
CarbonImmutable::parse($period['ends_at']),
)
: SnipeAsset::query()
->where('active', true)
->where('requestable', true)
->whereNull('assigned_id')
->orderBy('name')
->get(['id', 'name', 'asset_tag', 'model_name', 'category_name', 'location_name']),
'selectedAssetId' => $request->integer('asset'),
->whereNull('assigned_id');
$assets = $query
->orderBy('name')
->get(['id', 'name', 'asset_tag', 'model_name', 'category_name', 'location_name']);
$requestedAssetId = $request->integer('asset');
return Inertia::render('reservations/create', [
'assets' => $assets,
'selectedAssetId' => $assets->contains('id', $requestedAssetId) ? $requestedAssetId : 0,
'period' => $period,
'periodSelected' => $periodSelected,
]);
}
@@ -43,4 +84,15 @@ class ReservationController extends Controller
return to_route('dashboard')->with('success', "Requisição {$reservation->reference} submetida para aprovação.");
}
public function cancel(Request $request, Reservation $reservation, ReservationService $service): RedirectResponse
{
$data = $request->validate([
'reason' => ['required', 'string', 'min:5', 'max:1000'],
]);
$service->cancel($reservation, $request->user(), $data['reason']);
return back()->with('success', "Requisição {$reservation->reference} cancelada. Os equipamentos ficaram novamente disponíveis.");
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Jobs;
use App\Domain\Reservations\Services\ReservationService;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class ExpirePendingReservations implements ShouldBeUnique, ShouldQueue
{
use Queueable;
public int $tries = 3;
public int $timeout = 120;
public int $uniqueFor = 240;
/** @var list<int> */
public array $backoff = [10, 30, 60];
public function uniqueId(): string
{
return 'pending-reservations';
}
public function handle(ReservationService $reservations): void
{
$reservations->expirePending();
}
}