} $data */ public function createPending(User $requester, array $data): Reservation { $startsAt = CarbonImmutable::parse($data['starts_at']); $endsAt = CarbonImmutable::parse($data['ends_at']); return DB::transaction(function () use ($requester, $data, $startsAt, $endsAt): Reservation { $assets = SnipeAsset::query() ->whereKey($data['asset_ids']) ->lockForUpdate() ->get(); if ($assets->count() !== count(array_unique($data['asset_ids']))) { throw ValidationException::withMessages(['asset_ids' => 'Um ou mais equipamentos já não estão disponíveis.']); } $unavailable = $assets->filter( fn (SnipeAsset $asset): bool => ! $this->availability->isAvailable($asset, $startsAt, $endsAt) ); if ($unavailable->isNotEmpty()) { throw ValidationException::withMessages([ 'asset_ids' => 'Conflito de disponibilidade: '.$unavailable->pluck('name')->join(', ').'.', ]); } $reservation = Reservation::create([ 'reference' => $this->nextReference(), 'requester_id' => $requester->id, 'purpose' => $data['purpose'], 'usage_location' => $data['usage_location'], 'starts_at' => $startsAt, 'ends_at' => $endsAt, 'status' => ReservationStatus::PendingApproval, 'approval_deadline' => now()->addHours(48), ]); foreach ($assets as $asset) { $reservation->items()->create([ 'snipe_asset_id' => $asset->id, 'starts_at' => $startsAt, 'ends_at' => $endsAt, 'status' => ReservationItemStatus::Held, ]); } AuditLog::create([ 'actor_id' => $requester->id, 'event' => 'reservation.submitted', 'auditable_type' => $reservation->getMorphClass(), 'auditable_id' => $reservation->id, 'after' => $reservation->only(['reference', 'purpose', 'usage_location', 'starts_at', 'ends_at', 'status']), 'metadata' => ['asset_ids' => $assets->modelKeys()], ]); return $reservation->load('items.asset'); }, 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 { $reference = 'REQ-'.now()->format('Ymd').'-'.Str::upper(Str::random(6)); } while (Reservation::where('reference', $reference)->exists()); return $reference; } }