678 lines
29 KiB
PHP
678 lines
29 KiB
PHP
<?php
|
|
|
|
namespace App\Domain\Reservations\Services;
|
|
|
|
use App\Domain\Administration\Services\ReservationPolicy;
|
|
use App\Domain\Approvals\Models\ApprovalAction;
|
|
use App\Domain\Audit\Models\AuditLog;
|
|
use App\Domain\Identity\Enums\RoleName;
|
|
use App\Domain\Inventory\Models\SnipeAsset;
|
|
use App\Domain\Reservations\Enums\ReservationItemStatus;
|
|
use App\Domain\Reservations\Enums\ReservationStatus;
|
|
use App\Domain\Reservations\Models\Reservation;
|
|
use App\Models\User;
|
|
use App\Notifications\ReservationSubmittedNotification;
|
|
use Carbon\CarbonImmutable;
|
|
use Illuminate\Auth\Access\AuthorizationException;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Notification;
|
|
use Illuminate\Support\Str;
|
|
use Illuminate\Validation\ValidationException;
|
|
|
|
class ReservationService
|
|
{
|
|
public function __construct(
|
|
private readonly AvailabilityService $availability,
|
|
private readonly ReservationPolicy $policy,
|
|
) {}
|
|
|
|
/**
|
|
* @param array{purpose:string, usage_location:string, starts_at:string, ends_at:string, asset_ids:list<int>} $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<int> $assetIds
|
|
* @return array{
|
|
* occurrences: list<array{
|
|
* index: int,
|
|
* date_formatted: string,
|
|
* time_formatted: string,
|
|
* starts_at: string,
|
|
* ends_at: string,
|
|
* has_conflict: bool,
|
|
* conflicted_assets: list<array{id: int, name: string, asset_tag: string, model_name: string|null, category_name: string|null, alternatives: list<array{id: int, name: string, asset_tag: string, model_name: string|null, category_name: string|null}>}>,
|
|
* available_assets: list<array{id: int, name: string, asset_tag: string, model_name: string|null, category_name: string|null}>,
|
|
* }>,
|
|
* 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<array{id: int, name: string, asset_tag: string, model_name: string|null, category_name: string|null}> $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<array{id: int, name: string, asset_tag: string, model_name: string|null, category_name: string|null}> $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<int> $assetIds
|
|
* @param list<array{day_of_week: int, start_time: string, end_time: string}> $weeklySlots
|
|
* @return array{
|
|
* occurrences: list<array{
|
|
* index: int,
|
|
* date_formatted: string,
|
|
* time_formatted: string,
|
|
* starts_at: string,
|
|
* ends_at: string,
|
|
* has_conflict: bool,
|
|
* conflicted_assets: list<array{id: int, name: string, asset_tag: string, model_name: string|null, category_name: string|null, alternatives: list<array{id: int, name: string, asset_tag: string, model_name: string|null, category_name: string|null}>}>,
|
|
* available_assets: list<array{id: int, name: string, asset_tag: string, model_name: string|null, category_name: string|null}>,
|
|
* }>,
|
|
* 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<array{id: int, name: string, asset_tag: string, model_name: string|null, category_name: string|null}> $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<array{id: int, name: string, asset_tag: string, model_name: string|null, category_name: string|null}> $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<array{starts_at: string, ends_at: string, asset_ids: list<int>}> $occurrences
|
|
* @return Collection<int, Reservation>
|
|
*/
|
|
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<int, Reservation> $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."]);
|
|
}
|
|
}
|
|
}
|