} $data */ public function createPending(User $requester, array $data): Reservation { $startsAt = CarbonImmutable::parse($data['starts_at']); $endsAt = CarbonImmutable::parse($data['ends_at']); $this->validatePolicy($startsAt, $endsAt, count($data['asset_ids'])); [$bufferStartsAt, $bufferEndsAt] = $this->availability->bufferedPeriod($startsAt, $endsAt); $reservation = DB::transaction(function () use ($requester, $data, $startsAt, $endsAt, $bufferStartsAt, $bufferEndsAt): 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($this->policy->integer('reservation.approval_deadline_hours')), ]); foreach ($assets as $asset) { $reservation->items()->create([ 'snipe_asset_id' => $asset->id, 'starts_at' => $startsAt, 'ends_at' => $endsAt, 'buffer_starts_at' => $bufferStartsAt, 'buffer_ends_at' => $bufferEndsAt, '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('requester', 'items.asset'); }, 3); $approvers = User::query() ->where('active', true) ->whereHas('roles', fn ($query) => $query->whereIn('name', [ RoleName::Approver->value, RoleName::Administrator->value, ])) ->get(); Notification::send($approvers, new ReservationSubmittedNotification($reservation)); return $reservation; } 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 respondToInformation(Reservation $reservation, User $actor, string $response): Reservation { if ($reservation->requester_id !== $actor->id) { throw new AuthorizationException('Apenas o requisitante pode responder a este pedido.'); } $updated = DB::transaction(function () use ($reservation, $actor, $response): Reservation { $locked = Reservation::query()->whereKey($reservation->id)->lockForUpdate()->firstOrFail(); if (! $locked->status->canTransitionTo(ReservationStatus::PendingApproval) || $locked->status !== ReservationStatus::AwaitingInformation) { throw ValidationException::withMessages([ 'response' => 'Esta requisição não aguarda esclarecimentos.', ]); } $locked->update([ 'status' => ReservationStatus::PendingApproval, 'approval_deadline' => now()->addHours($this->policy->integer('reservation.approval_deadline_hours')), ]); ApprovalAction::create([ 'reservation_id' => $locked->id, 'actor_id' => $actor->id, 'action' => 'information_provided', 'previous_status' => ReservationStatus::AwaitingInformation->value, 'new_status' => ReservationStatus::PendingApproval->value, 'comment' => $response, ]); AuditLog::create([ 'actor_id' => $actor->id, 'event' => 'reservation.information_provided', 'auditable_type' => $locked->getMorphClass(), 'auditable_id' => $locked->id, 'before' => ['status' => ReservationStatus::AwaitingInformation->value], 'after' => ['status' => ReservationStatus::PendingApproval->value], 'metadata' => ['response' => $response], ]); return $locked->load('requester', 'items.asset'); }, 3); $approvers = User::query() ->where('active', true) ->whereHas('roles', fn ($query) => $query->whereIn('name', [ RoleName::Approver->value, RoleName::Administrator->value, ])) ->get(); Notification::send( $approvers, new ReservationSubmittedNotification($updated, 'information_provided'), ); return $updated; } 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; } /** * @param list $assetIds * @return array{ * occurrences: list}>, * available_assets: list, * }>, * total_occurrences: int, * conflicts_count: int, * } */ public function checkRecurringSeries( array $assetIds, CarbonImmutable $startsAt, CarbonImmutable $endsAt, CarbonImmutable $repeatUntil, ): array { $assets = SnipeAsset::query() ->whereKey($assetIds) ->get(); $occurrences = []; $curStartsAt = $startsAt; $curEndsAt = $endsAt; $durationMinutes = $startsAt->diffInMinutes($endsAt); $conflictsTotal = 0; $index = 0; while ($curStartsAt->lte($repeatUntil) && $index < 26) { $conflictedAssets = []; $availableAssets = []; foreach ($assets as $asset) { if ($this->availability->isAvailable($asset, $curStartsAt, $curEndsAt)) { $availableAssets[] = [ 'id' => $asset->id, 'name' => $asset->name, 'asset_tag' => $asset->asset_tag, 'model_name' => $asset->model_name, 'category_name' => $asset->category_name, ]; } else { $alternativesQuery = $this->availability->availableAssets($curStartsAt, $curEndsAt) ->whereNotIn('id', $assetIds); if ($asset->category_name !== null) { $alternativesQuery->where('category_name', $asset->category_name); } /** @var list $alternatives */ $alternatives = $alternativesQuery ->limit(8) ->get(['id', 'name', 'asset_tag', 'model_name', 'category_name']) ->map(fn (SnipeAsset $alt): array => [ 'id' => $alt->id, 'name' => $alt->name, 'asset_tag' => $alt->asset_tag, 'model_name' => $alt->model_name, 'category_name' => $alt->category_name, ]) ->values() ->all(); if (empty($alternatives)) { /** @var list $alternatives */ $alternatives = $this->availability->availableAssets($curStartsAt, $curEndsAt) ->whereNotIn('id', $assetIds) ->limit(6) ->get(['id', 'name', 'asset_tag', 'model_name', 'category_name']) ->map(fn (SnipeAsset $alt): array => [ 'id' => $alt->id, 'name' => $alt->name, 'asset_tag' => $alt->asset_tag, 'model_name' => $alt->model_name, 'category_name' => $alt->category_name, ]) ->values() ->all(); } $conflictedAssets[] = [ 'id' => $asset->id, 'name' => $asset->name, 'asset_tag' => $asset->asset_tag, 'model_name' => $asset->model_name, 'category_name' => $asset->category_name, 'alternatives' => $alternatives, ]; } } $hasConflict = count($conflictedAssets) > 0; if ($hasConflict) { $conflictsTotal++; } $dayOfWeekPt = match ($curStartsAt->dayOfWeek) { 0 => 'Domingo', 1 => 'Segunda-feira', 2 => 'Terça-feira', 3 => 'Quarta-feira', 4 => 'Quinta-feira', 5 => 'Sexta-feira', 6 => 'Sábado', default => 'Dia', }; $occurrences[] = [ 'index' => $index, 'date_formatted' => sprintf('%s, %s', $dayOfWeekPt, $curStartsAt->format('d/m/Y')), 'time_formatted' => sprintf('%s às %s', $curStartsAt->format('H:i'), $curEndsAt->format('H:i')), 'starts_at' => $curStartsAt->toIso8601String(), 'ends_at' => $curEndsAt->toIso8601String(), 'has_conflict' => $hasConflict, 'conflicted_assets' => $conflictedAssets, 'available_assets' => $availableAssets, ]; $curStartsAt = $curStartsAt->addWeek(); $curEndsAt = $curStartsAt->addMinutes($durationMinutes); $index++; } return [ 'occurrences' => $occurrences, 'total_occurrences' => count($occurrences), 'conflicts_count' => $conflictsTotal, ]; } /** * @param list $assetIds * @param list $weeklySlots * @return array{ * occurrences: list}>, * available_assets: list, * }>, * total_occurrences: int, * conflicts_count: int, * } */ public function checkFlexibleRecurringSeries( array $assetIds, array $weeklySlots, CarbonImmutable $repeatUntil, ): array { $assets = SnipeAsset::query() ->whereKey($assetIds) ->get(); $occurrences = []; $conflictsTotal = 0; $index = 0; $currentDate = now()->startOfDay(); $dayNames = [ 1 => 'Segunda-feira', 2 => 'Terça-feira', 3 => 'Quarta-feira', 4 => 'Quinta-feira', 5 => 'Sexta-feira', ]; while ($currentDate->lte($repeatUntil) && $index < 26) { foreach ($weeklySlots as $slot) { if ($currentDate->dayOfWeek !== $slot['day_of_week']) { continue; } $startsAt = $currentDate->setTimeFromTimeString($slot['start_time']); $endsAt = $currentDate->setTimeFromTimeString($slot['end_time']); if ($startsAt->lt(now()) || $startsAt->gt($repeatUntil)) { continue; } $conflictedAssets = []; $availableAssets = []; foreach ($assets as $asset) { if ($this->availability->isAvailable($asset, $startsAt, $endsAt)) { $availableAssets[] = [ 'id' => $asset->id, 'name' => $asset->name, 'asset_tag' => $asset->asset_tag, 'model_name' => $asset->model_name, 'category_name' => $asset->category_name, ]; } else { $alternativesQuery = $this->availability->availableAssets($startsAt, $endsAt) ->whereNotIn('id', $assetIds); if ($asset->category_name !== null) { $alternativesQuery->where('category_name', $asset->category_name); } /** @var list $alternatives */ $alternatives = $alternativesQuery ->limit(8) ->get(['id', 'name', 'asset_tag', 'model_name', 'category_name']) ->map(fn (SnipeAsset $alt): array => [ 'id' => $alt->id, 'name' => $alt->name, 'asset_tag' => $alt->asset_tag, 'model_name' => $alt->model_name, 'category_name' => $alt->category_name, ]) ->values() ->all(); if (empty($alternatives)) { /** @var list $alternatives */ $alternatives = $this->availability->availableAssets($startsAt, $endsAt) ->whereNotIn('id', $assetIds) ->limit(6) ->get(['id', 'name', 'asset_tag', 'model_name', 'category_name']) ->map(fn (SnipeAsset $alt): array => [ 'id' => $alt->id, 'name' => $alt->name, 'asset_tag' => $alt->asset_tag, 'model_name' => $alt->model_name, 'category_name' => $alt->category_name, ]) ->values() ->all(); } $conflictedAssets[] = [ 'id' => $asset->id, 'name' => $asset->name, 'asset_tag' => $asset->asset_tag, 'model_name' => $asset->model_name, 'category_name' => $asset->category_name, 'alternatives' => $alternatives, ]; } } $hasConflict = count($conflictedAssets) > 0; if ($hasConflict) { $conflictsTotal++; } $dayOfWeekPt = $dayNames[$slot['day_of_week']] ?? 'Dia'; $occurrences[] = [ 'index' => $index, 'date_formatted' => sprintf('%s, %s', $dayOfWeekPt, $startsAt->format('d/m/Y')), 'time_formatted' => sprintf('%s às %s', $startsAt->format('H:i'), $endsAt->format('H:i')), 'starts_at' => $startsAt->toIso8601String(), 'ends_at' => $endsAt->toIso8601String(), 'has_conflict' => $hasConflict, 'conflicted_assets' => $conflictedAssets, 'available_assets' => $availableAssets, ]; $index++; } $currentDate = $currentDate->addDay(); } return [ 'occurrences' => $occurrences, 'total_occurrences' => count($occurrences), 'conflicts_count' => $conflictsTotal, ]; } /** * @param array{purpose: string, usage_location: string} $baseData * @param list}> $occurrences * @return Collection */ public function createRecurringPending(User $requester, array $baseData, array $occurrences): Collection { if (empty($occurrences)) { throw ValidationException::withMessages(['occurrences' => 'Deve selecionar pelo menos um dia para a requisição recorrente.']); } if (count($occurrences) > 26) { throw ValidationException::withMessages(['occurrences' => 'A série recorrente não pode exceder 26 semanas.']); } foreach ($occurrences as $occ) { $startsAt = CarbonImmutable::parse($occ['starts_at']); $endsAt = CarbonImmutable::parse($occ['ends_at']); $this->validatePolicy($startsAt, $endsAt, count($occ['asset_ids'])); } $recurringGroupId = (string) Str::uuid(); /** @var Collection $reservations */ $reservations = DB::transaction(function () use ($requester, $baseData, $occurrences, $recurringGroupId): Collection { $created = collect(); foreach ($occurrences as $occ) { $startsAt = CarbonImmutable::parse($occ['starts_at']); $endsAt = CarbonImmutable::parse($occ['ends_at']); [$bufferStartsAt, $bufferEndsAt] = $this->availability->bufferedPeriod($startsAt, $endsAt); $assets = SnipeAsset::query() ->whereKey($occ['asset_ids']) ->lockForUpdate() ->get(); if ($assets->count() !== count(array_unique($occ['asset_ids']))) { throw ValidationException::withMessages(['occurrences' => "Um ou mais equipamentos na data {$startsAt->format('d/m/Y')} 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([ 'occurrences' => "Conflito na data {$startsAt->format('d/m/Y')}: {$unavailable->pluck('name')->join(', ')}.", ]); } $reservation = Reservation::create([ 'reference' => $this->nextReference(), 'requester_id' => $requester->id, 'recurring_group_id' => $recurringGroupId, 'recurrence_pattern' => 'weekly', 'purpose' => $baseData['purpose'], 'usage_location' => $baseData['usage_location'], 'starts_at' => $startsAt, 'ends_at' => $endsAt, 'status' => ReservationStatus::PendingApproval, 'approval_deadline' => now()->addHours($this->policy->integer('reservation.approval_deadline_hours')), ]); foreach ($assets as $asset) { $reservation->items()->create([ 'snipe_asset_id' => $asset->id, 'starts_at' => $startsAt, 'ends_at' => $endsAt, 'buffer_starts_at' => $bufferStartsAt, 'buffer_ends_at' => $bufferEndsAt, 'status' => ReservationItemStatus::Held, ]); } AuditLog::create([ 'actor_id' => $requester->id, 'event' => 'reservation.recurring_submitted', 'auditable_type' => $reservation->getMorphClass(), 'auditable_id' => $reservation->id, 'after' => $reservation->only(['reference', 'purpose', 'usage_location', 'starts_at', 'ends_at', 'status', 'recurring_group_id', 'recurrence_pattern']), 'metadata' => [ 'asset_ids' => $assets->modelKeys(), 'recurring_group_id' => $recurringGroupId, ], ]); $created->push($reservation->load('requester', 'items.asset')); } return $created; }, 3); $approvers = User::query() ->where('active', true) ->whereHas('roles', fn ($query) => $query->whereIn('name', [ RoleName::Approver->value, RoleName::Administrator->value, ])) ->get(); foreach ($reservations as $reservation) { Notification::send($approvers, new ReservationSubmittedNotification($reservation)); } return $reservations; } private function nextReference(): string { do { $reference = 'REQ-'.now()->format('Ymd').'-'.Str::upper(Str::random(6)); } while (Reservation::where('reference', $reference)->exists()); return $reference; } private function validatePolicy(CarbonImmutable $startsAt, CarbonImmutable $endsAt, int $assetCount): void { $minNotice = $this->policy->integer('reservation.min_notice_hours'); $maxDuration = $this->policy->integer('reservation.max_duration_hours'); $maxAssets = $this->policy->integer('reservation.max_assets'); if ($startsAt->lessThan(now()->addHours($minNotice))) { throw ValidationException::withMessages(['starts_at' => "A requisição exige pelo menos {$minNotice} horas de antecedência."]); } if ($startsAt->diffInMinutes($endsAt) > $maxDuration * 60) { throw ValidationException::withMessages(['ends_at' => "A duração máxima permitida é de {$maxDuration} horas."]); } if ($assetCount > $maxAssets) { throw ValidationException::withMessages(['asset_ids' => "Selecione no máximo {$maxAssets} equipamentos."]); } } }