feat: complete administration and reporting

This commit is contained in:
2026-08-04 17:40:52 +01:00
parent d829f34580
commit 7a4ac48aaf
42 changed files with 2595 additions and 76 deletions

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Domain\Administration\Models;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PortalSetting extends Model
{
public $incrementing = false;
protected $primaryKey = 'key';
protected $keyType = 'string';
protected $fillable = ['key', 'value', 'description', 'updated_by'];
protected function casts(): array
{
return ['value' => 'json'];
}
/** @return BelongsTo<User, $this> */
public function updatedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'updated_by');
}
}

View File

@@ -0,0 +1,122 @@
<?php
namespace App\Domain\Administration\Services;
use App\Domain\Administration\Models\PortalSetting;
use App\Domain\Audit\Models\AuditLog;
use App\Domain\Reservations\Models\Reservation;
use App\Models\User;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
final class NotificationTemplateService
{
/** @return array<string, array{label:string,subject:string,message:string}> */
public function definitions(): array
{
return [
'submitted' => [
'label' => 'Nova requisição',
'subject' => 'Nova requisição {reference}',
'message' => '{requester} submeteu {reference} para decisão. Período: {starts_at} — {ends_at}.',
],
'information_provided' => [
'label' => 'Esclarecimentos prestados',
'subject' => 'Resposta recebida para {reference}',
'message' => '{requester} prestou esclarecimentos para {reference}.',
],
'approved' => [
'label' => 'Requisição aprovada',
'subject' => 'Requisição {reference} aprovada',
'message' => 'A sua requisição {reference} foi aprovada.',
],
'rejected' => [
'label' => 'Requisição rejeitada',
'subject' => 'Requisição {reference} rejeitada',
'message' => 'A sua requisição {reference} foi rejeitada.',
],
'information_requested' => [
'label' => 'Pedido de esclarecimentos',
'subject' => 'Esclarecimentos necessários para {reference}',
'message' => 'O aprovador pediu informações adicionais para {reference}.',
],
'asset_substituted' => [
'label' => 'Equipamento substituído',
'subject' => 'Equipamento alterado em {reference}',
'message' => 'Um equipamento de {reference} foi substituído por outro equivalente.',
],
];
}
/** @return array<string, array{subject:string,message:string}> */
public function templates(): array
{
return Cache::remember('portal.notification_templates', now()->addHour(), function (): array {
$keys = [];
foreach (array_keys($this->definitions()) as $event) {
$keys[] = "notification.{$event}.subject";
$keys[] = "notification.{$event}.message";
}
$stored = PortalSetting::query()
->whereIn('key', $keys)
->get(['key', 'value'])
->mapWithKeys(fn (PortalSetting $setting): array => [$setting->key => $setting->value]);
return collect($this->definitions())->mapWithKeys(function (array $definition, string $event) use ($stored): array {
return [$event => [
'subject' => (string) ($stored["notification.{$event}.subject"] ?? $definition['subject']),
'message' => (string) ($stored["notification.{$event}.message"] ?? $definition['message']),
]];
})->all();
});
}
/** @return array{subject:string,message:string} */
public function render(string $event, Reservation $reservation, ?string $comment = null): array
{
$template = $this->templates()[$event] ?? [
'subject' => 'Atualização da requisição {reference}',
'message' => 'O estado da requisição {reference} foi atualizado.',
];
$replacements = [
'{reference}' => $reservation->reference,
'{requester}' => $reservation->requester->name,
'{starts_at}' => $reservation->starts_at->format('d/m/Y H:i'),
'{ends_at}' => $reservation->ends_at->format('d/m/Y H:i'),
'{comment}' => $comment ?? '',
];
return [
'subject' => strtr($template['subject'], $replacements),
'message' => strtr($template['message'], $replacements),
];
}
/** @param array<string, array{subject:string,message:string}> $templates */
public function update(array $templates, User $actor): void
{
DB::transaction(function () use ($templates, $actor): void {
$before = $this->templates();
foreach ($templates as $event => $template) {
foreach (['subject', 'message'] as $field) {
PortalSetting::query()->updateOrCreate(
['key' => "notification.{$event}.{$field}"],
[
'value' => $template[$field],
'description' => "Modelo {$field} para {$event}",
'updated_by' => $actor->id,
],
);
}
}
AuditLog::create([
'actor_id' => $actor->id,
'event' => 'settings.notification_templates_updated',
'before' => $before,
'after' => $templates,
]);
}, 3);
Cache::forget('portal.notification_templates');
}
}

View File

@@ -0,0 +1,100 @@
<?php
namespace App\Domain\Administration\Services;
use App\Domain\Administration\Models\PortalSetting;
use App\Domain\Audit\Models\AuditLog;
use App\Models\User;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
final class ReservationPolicy
{
/** @return array<string, array{label:string,description:string,default:int,min:int,max:int,unit:string}> */
public function definitions(): array
{
return [
'reservation.min_notice_hours' => [
'label' => 'Antecedência mínima',
'description' => 'Horas mínimas entre a submissão e o início da utilização.',
'default' => 0, 'min' => 0, 'max' => 720, 'unit' => 'horas',
],
'reservation.max_duration_hours' => [
'label' => 'Duração máxima',
'description' => 'Duração máxima permitida para uma requisição.',
'default' => 168, 'min' => 1, 'max' => 2160, 'unit' => 'horas',
],
'reservation.turnaround_minutes' => [
'label' => 'Margem entre reservas',
'description' => 'Tempo bloqueado antes e depois da utilização para preparação e conferência.',
'default' => 0, 'min' => 0, 'max' => 1440, 'unit' => 'minutos',
],
'reservation.approval_deadline_hours' => [
'label' => 'Prazo de aprovação',
'description' => 'Tempo até uma pré-reserva pendente expirar.',
'default' => 48, 'min' => 1, 'max' => 336, 'unit' => 'horas',
],
'reservation.max_assets' => [
'label' => 'Equipamentos por requisição',
'description' => 'Número máximo de equipamentos no mesmo pedido.',
'default' => 10, 'min' => 1, 'max' => 50, 'unit' => 'equipamentos',
],
];
}
public function integer(string $key): int
{
$definitions = $this->definitions();
if (! isset($definitions[$key])) {
throw new \InvalidArgumentException("Política desconhecida: {$key}");
}
return $this->values()[$key] ?? $definitions[$key]['default'];
}
/** @return array<string, int> */
public function values(): array
{
return Cache::remember('portal.reservation_policy', now()->addHour(), function (): array {
/** @var array<string, int> $stored */
$stored = PortalSetting::query()
->whereIn('key', array_keys($this->definitions()))
->pluck('value', 'key')
->map(fn (mixed $value): int => (int) $value)
->all();
return collect($this->definitions())
->mapWithKeys(fn (array $definition, string $key): array => [
$key => $stored[$key] ?? $definition['default'],
])
->all();
});
}
/** @param array<string, int> $values */
public function update(array $values, User $actor): void
{
DB::transaction(function () use ($values, $actor): void {
$before = $this->values();
foreach ($this->definitions() as $key => $definition) {
PortalSetting::query()->updateOrCreate(
['key' => $key],
[
'value' => $values[$key],
'description' => $definition['description'],
'updated_by' => $actor->id,
],
);
}
AuditLog::create([
'actor_id' => $actor->id,
'event' => 'settings.reservation_policy_updated',
'before' => $before,
'after' => $values,
]);
}, 3);
Cache::forget('portal.reservation_policy');
}
}

View File

@@ -141,6 +141,8 @@ class ApprovalService
'snipe_asset_id' => $newAsset->id,
'starts_at' => $lockedItem->starts_at,
'ends_at' => $lockedItem->ends_at,
'buffer_starts_at' => $lockedItem->buffer_starts_at,
'buffer_ends_at' => $lockedItem->buffer_ends_at,
'status' => ReservationItemStatus::Held,
'notes' => "Substitui {$original->asset_tag}: {$reason}",
]);

View File

@@ -0,0 +1,135 @@
<?php
namespace App\Domain\Identity\Services;
use App\Domain\Audit\Models\AuditLog;
use App\Domain\Identity\Enums\RoleName;
use App\Domain\Identity\Models\Role;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
final class UserAdministrationService
{
/**
* @param array{name:string,email:string,department:?string,snipe_user_id:?int,password:string,active:bool,roles:list<string>} $data
*/
public function create(User $actor, array $data): User
{
return DB::transaction(function () use ($actor, $data): User {
$user = User::create([
'name' => $data['name'],
'email' => $data['email'],
'email_verified_at' => now(),
'department' => $data['department'],
'snipe_user_id' => $data['snipe_user_id'],
'password' => $data['password'],
'active' => $data['active'],
]);
$user->roles()->sync($this->roleIds($data['roles']));
AuditLog::create([
'actor_id' => $actor->id,
'event' => 'user.created',
'auditable_type' => $user->getMorphClass(),
'auditable_id' => $user->id,
'after' => $this->snapshot($user),
]);
return $user->load('roles');
}, 3);
}
/**
* @param array{name:string,email:string,department:?string,snipe_user_id:?int,password:?string,active:bool,roles:list<string>} $data
*/
public function update(User $user, User $actor, array $data): User
{
return DB::transaction(function () use ($user, $actor, $data): User {
$locked = User::query()->whereKey($user->id)->lockForUpdate()->firstOrFail();
$before = $this->snapshot($locked);
$removesAdministrator = ! in_array(RoleName::Administrator->value, $data['roles'], true);
if ($locked->id === $actor->id && (! $data['active'] || $removesAdministrator)) {
throw ValidationException::withMessages([
'roles' => 'Não pode desativar a própria conta nem remover o seu perfil de administrador.',
]);
}
if ((! $data['active'] || $removesAdministrator)
&& $locked->hasRole(RoleName::Administrator)
&& $this->activeAdministratorCount() <= 1) {
throw ValidationException::withMessages([
'roles' => 'O portal tem de manter pelo menos um administrador ativo.',
]);
}
$attributes = [
'name' => $data['name'],
'email' => $data['email'],
'department' => $data['department'],
'snipe_user_id' => $data['snipe_user_id'],
'active' => $data['active'],
];
if (filled($data['password'])) {
$attributes['password'] = $data['password'];
}
$locked->update($attributes);
$locked->roles()->sync($this->roleIds($data['roles']));
$locked->load('roles');
AuditLog::create([
'actor_id' => $actor->id,
'event' => 'user.updated',
'auditable_type' => $locked->getMorphClass(),
'auditable_id' => $locked->id,
'before' => $before,
'after' => $this->snapshot($locked),
'metadata' => ['password_changed' => filled($data['password'])],
]);
return $locked;
}, 3);
}
/**
* @param list<string> $roles
* @return list<int>
*/
private function roleIds(array $roles): array
{
$ids = array_values(Role::query()
->whereIn('name', $roles)
->pluck('id')
->map(fn (int $id): int => $id)
->all());
if (count($ids) !== count(array_unique($roles))) {
throw ValidationException::withMessages(['roles' => 'Um ou mais perfis selecionados não existem.']);
}
return $ids;
}
private function activeAdministratorCount(): int
{
return User::query()
->where('active', true)
->whereHas('roles', fn ($query) => $query->where('name', RoleName::Administrator->value))
->count();
}
/** @return array<string, mixed> */
private function snapshot(User $user): array
{
return [
'name' => $user->name,
'email' => $user->email,
'department' => $user->department,
'snipe_user_id' => $user->snipe_user_id,
'active' => $user->active,
'roles' => $user->roles()->orderBy('name')->pluck('name')->all(),
];
}
}

View File

@@ -19,7 +19,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
class ReservationItem extends Model
{
protected $fillable = [
'reservation_id', 'snipe_asset_id', 'starts_at', 'ends_at', 'status',
'reservation_id', 'snipe_asset_id', 'starts_at', 'ends_at', 'buffer_starts_at', 'buffer_ends_at', 'status',
'delivered_at', 'returned_at', 'checkout_reference', 'checkin_reference',
'condition_out', 'condition_in', 'notes',
];
@@ -30,6 +30,8 @@ class ReservationItem extends Model
'status' => ReservationItemStatus::class,
'starts_at' => 'immutable_datetime',
'ends_at' => 'immutable_datetime',
'buffer_starts_at' => 'immutable_datetime',
'buffer_ends_at' => 'immutable_datetime',
'delivered_at' => 'immutable_datetime',
'returned_at' => 'immutable_datetime',
];

View File

@@ -2,24 +2,30 @@
namespace App\Domain\Reservations\Services;
use App\Domain\Administration\Services\ReservationPolicy;
use App\Domain\Inventory\Models\SnipeAsset;
use App\Domain\Reservations\Enums\ReservationItemStatus;
use Carbon\CarbonImmutable;
use DateTimeInterface;
use Illuminate\Database\Eloquent\Builder;
class AvailabilityService
{
public function __construct(private readonly ReservationPolicy $policy) {}
/** @return Builder<SnipeAsset> */
public function availableAssets(DateTimeInterface $startsAt, DateTimeInterface $endsAt): Builder
{
[$bufferStartsAt, $bufferEndsAt] = $this->bufferedPeriod($startsAt, $endsAt);
return SnipeAsset::query()
->where('active', true)
->where('requestable', true)
->whereNull('assigned_id')
->whereDoesntHave('reservationItems', fn (Builder $query): Builder => $query
->whereIn('status', ReservationItemStatus::blockingValues())
->where('starts_at', '<', $endsAt)
->where('ends_at', '>', $startsAt))
->whereRaw('COALESCE(buffer_starts_at, starts_at) < ?', [$bufferEndsAt])
->whereRaw('COALESCE(buffer_ends_at, ends_at) > ?', [$bufferStartsAt]))
->whereDoesntHave('blocks', fn (Builder $query): Builder => $query
->where('starts_at', '<', $endsAt)
->where('ends_at', '>', $startsAt));
@@ -35,11 +41,12 @@ class AvailabilityService
return false;
}
[$bufferStartsAt, $bufferEndsAt] = $this->bufferedPeriod($startsAt, $endsAt);
$hasReservationConflict = $asset->reservationItems()
->whereIn('status', ReservationItemStatus::blockingValues())
->when($exceptReservationId, fn (Builder $query): Builder => $query->where('reservation_id', '!=', $exceptReservationId))
->where('starts_at', '<', $endsAt)
->where('ends_at', '>', $startsAt)
->whereRaw('COALESCE(buffer_starts_at, starts_at) < ?', [$bufferEndsAt])
->whereRaw('COALESCE(buffer_ends_at, ends_at) > ?', [$bufferStartsAt])
->exists();
if ($hasReservationConflict) {
@@ -51,4 +58,15 @@ class AvailabilityService
->where('ends_at', '>', $startsAt)
->exists();
}
/** @return array{DateTimeInterface, DateTimeInterface} */
public function bufferedPeriod(DateTimeInterface $startsAt, DateTimeInterface $endsAt): array
{
$margin = $this->policy->integer('reservation.turnaround_minutes');
return [
CarbonImmutable::instance($startsAt)->subMinutes($margin),
CarbonImmutable::instance($endsAt)->addMinutes($margin),
];
}
}

View File

@@ -2,6 +2,7 @@
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;
@@ -20,7 +21,10 @@ use Illuminate\Validation\ValidationException;
class ReservationService
{
public function __construct(private readonly AvailabilityService $availability) {}
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
@@ -29,8 +33,10 @@ class ReservationService
{
$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): Reservation {
$reservation = DB::transaction(function () use ($requester, $data, $startsAt, $endsAt, $bufferStartsAt, $bufferEndsAt): Reservation {
$assets = SnipeAsset::query()
->whereKey($data['asset_ids'])
->lockForUpdate()
@@ -58,7 +64,7 @@ class ReservationService
'starts_at' => $startsAt,
'ends_at' => $endsAt,
'status' => ReservationStatus::PendingApproval,
'approval_deadline' => now()->addHours(48),
'approval_deadline' => now()->addHours($this->policy->integer('reservation.approval_deadline_hours')),
]);
foreach ($assets as $asset) {
@@ -66,6 +72,8 @@ class ReservationService
'snipe_asset_id' => $asset->id,
'starts_at' => $startsAt,
'ends_at' => $endsAt,
'buffer_starts_at' => $bufferStartsAt,
'buffer_ends_at' => $bufferEndsAt,
'status' => ReservationItemStatus::Held,
]);
}
@@ -156,7 +164,7 @@ class ReservationService
$locked->update([
'status' => ReservationStatus::PendingApproval,
'approval_deadline' => now()->addHours(48),
'approval_deadline' => now()->addHours($this->policy->integer('reservation.approval_deadline_hours')),
]);
ApprovalAction::create([
'reservation_id' => $locked->id,
@@ -258,4 +266,21 @@ class ReservationService
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."]);
}
}
}