feat: establish reservation portal foundation

This commit is contained in:
2026-08-04 15:24:08 +01:00
commit c96264ae96
226 changed files with 32232 additions and 0 deletions

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Domain\Reservations\Enums;
enum ReservationItemStatus: string
{
case Held = 'held';
case Reserved = 'reserved';
case Ready = 'ready';
case Delivered = 'delivered';
case Returned = 'returned';
case Cancelled = 'cancelled';
case Failed = 'failed';
/** @return list<string> */
public static function blockingValues(): array
{
return [self::Held->value, self::Reserved->value, self::Ready->value, self::Delivered->value];
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Domain\Reservations\Enums;
enum ReservationStatus: string
{
case Draft = 'draft';
case PendingApproval = 'pending_approval';
case AwaitingInformation = 'awaiting_information';
case Approved = 'approved';
case Ready = 'ready';
case Delivered = 'delivered';
case InUse = 'in_use';
case Returned = 'returned';
case Completed = 'completed';
case Rejected = 'rejected';
case Cancelled = 'cancelled';
case Expired = 'expired';
case NoShow = 'no_show';
case Overdue = 'overdue';
case Incident = 'incident';
/** @return list<self> */
public function allowedTransitions(): array
{
return match ($this) {
self::Draft => [self::PendingApproval, self::Cancelled],
self::PendingApproval => [self::AwaitingInformation, self::Approved, self::Rejected, self::Cancelled, self::Expired],
self::AwaitingInformation => [self::PendingApproval, self::Rejected, self::Cancelled, self::Expired],
self::Approved => [self::Ready, self::Cancelled],
self::Ready => [self::Delivered, self::NoShow, self::Cancelled],
self::Delivered => [self::InUse, self::Returned, self::Overdue, self::Incident],
self::InUse, self::Overdue, self::Incident => [self::Returned],
self::Returned => [self::Completed, self::Incident],
self::Completed, self::Rejected, self::Cancelled, self::Expired, self::NoShow => [],
};
}
public function canTransitionTo(self $next): bool
{
return in_array($next, $this->allowedTransitions(), true);
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Domain\Reservations\Models;
use App\Domain\Inventory\Models\SnipeAsset;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AssetBlock extends Model
{
protected $fillable = ['snipe_asset_id', 'starts_at', 'ends_at', 'reason', 'created_by'];
protected function casts(): array
{
return ['starts_at' => 'immutable_datetime', 'ends_at' => 'immutable_datetime'];
}
/** @return BelongsTo<SnipeAsset, $this> */
public function asset(): BelongsTo
{
return $this->belongsTo(SnipeAsset::class, 'snipe_asset_id');
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Domain\Reservations\Models;
use App\Domain\Reservations\Enums\ReservationStatus;
use App\Models\User;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Reservation extends Model
{
use HasUuids;
protected $fillable = [
'uuid', 'reference', 'requester_id', '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',
];
/** @return list<string> */
public function uniqueIds(): array
{
return ['uuid'];
}
protected function casts(): array
{
return [
'status' => ReservationStatus::class,
'starts_at' => 'immutable_datetime',
'ends_at' => 'immutable_datetime',
'approval_deadline' => 'immutable_datetime',
'approved_at' => 'immutable_datetime',
'rejected_at' => 'immutable_datetime',
'cancelled_at' => 'immutable_datetime',
];
}
/** @return BelongsTo<User, $this> */
public function requester(): BelongsTo
{
return $this->belongsTo(User::class, 'requester_id');
}
/** @return HasMany<ReservationItem, $this> */
public function items(): HasMany
{
return $this->hasMany(ReservationItem::class);
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Domain\Reservations\Models;
use App\Domain\Inventory\Models\SnipeAsset;
use App\Domain\Reservations\Enums\ReservationItemStatus;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ReservationItem extends Model
{
protected $fillable = [
'reservation_id', 'snipe_asset_id', 'starts_at', 'ends_at', 'status',
'delivered_at', 'returned_at', 'checkout_reference', 'checkin_reference',
'condition_out', 'condition_in', 'notes',
];
protected function casts(): array
{
return [
'status' => ReservationItemStatus::class,
'starts_at' => 'immutable_datetime',
'ends_at' => 'immutable_datetime',
'delivered_at' => 'immutable_datetime',
'returned_at' => 'immutable_datetime',
];
}
/** @return BelongsTo<Reservation, $this> */
public function reservation(): BelongsTo
{
return $this->belongsTo(Reservation::class);
}
/** @return BelongsTo<SnipeAsset, $this> */
public function asset(): BelongsTo
{
return $this->belongsTo(SnipeAsset::class, 'snipe_asset_id');
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Domain\Reservations\Services;
use App\Domain\Inventory\Models\SnipeAsset;
use App\Domain\Reservations\Enums\ReservationItemStatus;
use DateTimeInterface;
class AvailabilityService
{
public function isAvailable(SnipeAsset $asset, DateTimeInterface $startsAt, DateTimeInterface $endsAt): bool
{
if (! $asset->active || ! $asset->requestable || $asset->assigned_id !== null) {
return false;
}
$hasReservationConflict = $asset->reservationItems()
->whereIn('status', ReservationItemStatus::blockingValues())
->where('starts_at', '<', $endsAt)
->where('ends_at', '>', $startsAt)
->exists();
if ($hasReservationConflict) {
return false;
}
return ! $asset->blocks()
->where('starts_at', '<', $endsAt)
->where('ends_at', '>', $startsAt)
->exists();
}
}

View File

@@ -0,0 +1,89 @@
<?php
namespace App\Domain\Reservations\Services;
use App\Domain\Audit\Models\AuditLog;
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 Carbon\CarbonImmutable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class ReservationService
{
public function __construct(private readonly AvailabilityService $availability) {}
/**
* @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']);
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);
}
private function nextReference(): string
{
do {
$reference = 'REQ-'.now()->format('Ymd').'-'.Str::upper(Str::random(6));
} while (Reservation::where('reference', $reference)->exists());
return $reference;
}
}