feat: establish reservation portal foundation
This commit is contained in:
35
app/Domain/Audit/Models/AuditLog.php
Normal file
35
app/Domain/Audit/Models/AuditLog.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Audit\Models;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
class AuditLog extends Model
|
||||
{
|
||||
public const UPDATED_AT = null;
|
||||
|
||||
protected $fillable = [
|
||||
'actor_id', 'event', 'auditable_type', 'auditable_id', 'ip_address',
|
||||
'user_agent', 'before', 'after', 'metadata', 'created_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['before' => 'array', 'after' => 'array', 'metadata' => 'array', 'created_at' => 'datetime'];
|
||||
}
|
||||
|
||||
/** @return BelongsTo<User, $this> */
|
||||
public function actor(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'actor_id');
|
||||
}
|
||||
|
||||
/** @return MorphTo<Model, $this> */
|
||||
public function auditable(): MorphTo
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
}
|
||||
12
app/Domain/Identity/Enums/RoleName.php
Normal file
12
app/Domain/Identity/Enums/RoleName.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Identity\Enums;
|
||||
|
||||
enum RoleName: string
|
||||
{
|
||||
case Requester = 'requester';
|
||||
case Approver = 'approver';
|
||||
case InventoryOperator = 'inventory_operator';
|
||||
case Administrator = 'administrator';
|
||||
case Auditor = 'auditor';
|
||||
}
|
||||
17
app/Domain/Identity/Models/Permission.php
Normal file
17
app/Domain/Identity/Models/Permission.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Identity\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
|
||||
class Permission extends Model
|
||||
{
|
||||
protected $fillable = ['name', 'description'];
|
||||
|
||||
/** @return BelongsToMany<Role, $this> */
|
||||
public function roles(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Role::class);
|
||||
}
|
||||
}
|
||||
24
app/Domain/Identity/Models/Role.php
Normal file
24
app/Domain/Identity/Models/Role.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Identity\Models;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
|
||||
class Role extends Model
|
||||
{
|
||||
protected $fillable = ['name', 'description'];
|
||||
|
||||
/** @return BelongsToMany<User, $this> */
|
||||
public function users(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(User::class);
|
||||
}
|
||||
|
||||
/** @return BelongsToMany<Permission, $this> */
|
||||
public function permissions(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Permission::class);
|
||||
}
|
||||
}
|
||||
47
app/Domain/Inventory/Models/SnipeAsset.php
Normal file
47
app/Domain/Inventory/Models/SnipeAsset.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Inventory\Models;
|
||||
|
||||
use App\Domain\Reservations\Models\AssetBlock;
|
||||
use App\Domain\Reservations\Models\ReservationItem;
|
||||
use Database\Factories\SnipeAssetFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\UseFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[UseFactory(SnipeAssetFactory::class)]
|
||||
class SnipeAsset extends Model
|
||||
{
|
||||
/** @use HasFactory<SnipeAssetFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'snipe_asset_id', 'asset_tag', 'name', 'serial', 'model_id', 'model_name',
|
||||
'category_id', 'category_name', 'manufacturer', 'status_id', 'status_name',
|
||||
'location_id', 'location_name', 'requestable', 'assigned_type', 'assigned_id',
|
||||
'image_url', 'source_updated_at', 'synced_at', 'content_hash', 'active',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'requestable' => 'boolean',
|
||||
'active' => 'boolean',
|
||||
'source_updated_at' => 'datetime',
|
||||
'synced_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/** @return HasMany<ReservationItem, $this> */
|
||||
public function reservationItems(): HasMany
|
||||
{
|
||||
return $this->hasMany(ReservationItem::class);
|
||||
}
|
||||
|
||||
/** @return HasMany<AssetBlock, $this> */
|
||||
public function blocks(): HasMany
|
||||
{
|
||||
return $this->hasMany(AssetBlock::class);
|
||||
}
|
||||
}
|
||||
20
app/Domain/Reservations/Enums/ReservationItemStatus.php
Normal file
20
app/Domain/Reservations/Enums/ReservationItemStatus.php
Normal 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];
|
||||
}
|
||||
}
|
||||
43
app/Domain/Reservations/Enums/ReservationStatus.php
Normal file
43
app/Domain/Reservations/Enums/ReservationStatus.php
Normal 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);
|
||||
}
|
||||
}
|
||||
23
app/Domain/Reservations/Models/AssetBlock.php
Normal file
23
app/Domain/Reservations/Models/AssetBlock.php
Normal 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');
|
||||
}
|
||||
}
|
||||
53
app/Domain/Reservations/Models/Reservation.php
Normal file
53
app/Domain/Reservations/Models/Reservation.php
Normal 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);
|
||||
}
|
||||
}
|
||||
40
app/Domain/Reservations/Models/ReservationItem.php
Normal file
40
app/Domain/Reservations/Models/ReservationItem.php
Normal 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');
|
||||
}
|
||||
}
|
||||
32
app/Domain/Reservations/Services/AvailabilityService.php
Normal file
32
app/Domain/Reservations/Services/AvailabilityService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
89
app/Domain/Reservations/Services/ReservationService.php
Normal file
89
app/Domain/Reservations/Services/ReservationService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user