first commit
Some checks failed
tests / ci (push) Has been cancelled

This commit is contained in:
2026-10-27 11:01:11 +00:00
parent 9b8b21c853
commit fb04d71a8d
29 changed files with 9733 additions and 326 deletions

View File

@@ -22,7 +22,8 @@ class Reservation extends Model
use HasUuids;
protected $fillable = [
'uuid', 'reference', 'requester_id', 'purpose', 'usage_location', 'starts_at',
'uuid', 'reference', 'requester_id', 'recurring_group_id', 'recurrence_pattern',
'purpose', 'usage_location', 'starts_at',
'ends_at', 'status', 'approval_deadline', 'approved_by', 'approved_at',
'rejected_by', 'rejected_at', 'decision_reason', 'cancelled_by',
'cancelled_at', 'cancellation_reason',

View File

@@ -14,6 +14,7 @@ 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;
@@ -258,6 +259,396 @@ class ReservationService
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 {