Files
reservas/app/Domain/Operations/Services/HandoverService.php

415 lines
19 KiB
PHP

<?php
namespace App\Domain\Operations\Services;
use App\Domain\Audit\Models\AuditLog;
use App\Domain\Identity\Enums\RoleName;
use App\Domain\Operations\Enums\HandoverType;
use App\Domain\Operations\Models\HandoverRecord;
use App\Domain\Reservations\Enums\ReservationItemStatus;
use App\Domain\Reservations\Enums\ReservationStatus;
use App\Domain\Reservations\Models\AssetBlock;
use App\Domain\Reservations\Models\Reservation;
use App\Domain\Reservations\Models\ReservationItem;
use App\Integrations\SnipeIt\SnipeItClient;
use App\Models\User;
use Carbon\CarbonImmutable;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Throwable;
final class HandoverService
{
public function __construct(private readonly SnipeItClient $snipeIt) {}
public function markReady(Reservation $reservation, User $operator): Reservation
{
$this->authorizeOperator($operator);
return DB::transaction(function () use ($reservation, $operator): Reservation {
$locked = Reservation::query()->whereKey($reservation->id)->lockForUpdate()->firstOrFail();
$this->assertTransition($locked, ReservationStatus::Ready, 'A requisição não pode ser preparada neste estado.');
$locked->update(['status' => ReservationStatus::Ready]);
$locked->items()->where('status', ReservationItemStatus::Reserved)->update([
'status' => ReservationItemStatus::Ready,
]);
$this->audit($locked, $operator, 'reservation.ready', ReservationStatus::Approved, ReservationStatus::Ready);
return $locked->load('requester', 'items.asset');
}, 3);
}
public function deliver(
Reservation $reservation,
User $operator,
int $snipeUserId,
string $condition,
?string $notes = null,
): Reservation {
$this->authorizeOperator($operator);
$this->assertOperationalConfiguration();
$reservation = Reservation::query()
->with(['requester', 'items.asset'])
->findOrFail($reservation->id);
if (! in_array($reservation->status, [ReservationStatus::Ready, ReservationStatus::Incident], true)) {
throw ValidationException::withMessages(['operation' => 'Apenas requisições preparadas podem ser entregues.']);
}
$items = $reservation->items->where('status', ReservationItemStatus::Ready);
if ($items->isEmpty()) {
throw ValidationException::withMessages(['operation' => 'A requisição não tem equipamentos preparados para entrega.']);
}
$completed = [];
foreach ($items as $item) {
try {
$asset = $item->asset;
if ($asset->status_id === null) {
throw ValidationException::withMessages(['operation' => "O equipamento {$asset->asset_tag} não tem estado Snipe-IT sincronizado."]);
}
$response = $this->snipeIt->checkoutHardware(
(int) $asset->snipe_asset_id,
(int) $asset->status_id,
$snipeUserId,
$reservation->ends_at->toDateString(),
$this->externalNote($reservation, $notes),
);
DB::transaction(function () use ($item, $reservation, $operator, $condition, $notes, $response): void {
$lockedItem = ReservationItem::query()->whereKey($item->id)->lockForUpdate()->firstOrFail();
if ($lockedItem->status !== ReservationItemStatus::Ready) {
throw ValidationException::withMessages(['operation' => 'O estado local do equipamento mudou durante a entrega.']);
}
$lockedItem->update([
'status' => ReservationItemStatus::Delivered,
'delivered_at' => now(),
'checkout_reference' => 'snipe-hardware:'.$lockedItem->asset->snipe_asset_id,
'condition_out' => $condition,
'notes' => $notes,
]);
HandoverRecord::create([
'reservation_item_id' => $lockedItem->id,
'type' => HandoverType::Checkout,
'operator_id' => $operator->id,
'recipient_id' => $reservation->requester_id,
'condition' => $condition,
'notes' => $notes,
'snipe_response' => $response,
]);
}, 3);
$completed[] = $item->id;
} catch (Throwable $exception) {
$this->recordPartialFailure($reservation, $operator, 'delivery', $completed, $item, $exception);
throw ValidationException::withMessages([
'operation' => "A entrega parou no equipamento {$item->asset->asset_tag}. ".$exception->getMessage(),
]);
}
}
return DB::transaction(function () use ($reservation, $operator, $snipeUserId, $completed): Reservation {
$locked = Reservation::query()->whereKey($reservation->id)->lockForUpdate()->firstOrFail();
if ($locked->items()->where('status', ReservationItemStatus::Ready)->exists()) {
throw ValidationException::withMessages(['operation' => 'Nem todos os equipamentos ficaram entregues.']);
}
$previous = $locked->status;
$locked->requester()->update(['snipe_user_id' => $snipeUserId]);
$locked->update(['status' => ReservationStatus::Delivered]);
$this->audit(
$locked,
$operator,
'reservation.delivered',
$previous,
ReservationStatus::Delivered,
['item_ids' => $completed, 'snipe_user_id' => $snipeUserId],
);
return $locked->load('requester', 'items.asset', 'items.handoverRecords');
}, 3);
}
public function returnEquipment(
Reservation $reservation,
User $operator,
string $condition,
?string $notes = null,
): Reservation {
$this->authorizeOperator($operator);
$this->assertOperationalConfiguration();
$reservation = Reservation::query()->with(['requester', 'items.asset'])->findOrFail($reservation->id);
if (! in_array($reservation->status, [
ReservationStatus::Delivered,
ReservationStatus::InUse,
ReservationStatus::Overdue,
ReservationStatus::Incident,
], true)) {
throw ValidationException::withMessages(['operation' => 'Esta requisição não está em condições de ser devolvida.']);
}
$items = $reservation->items->where('status', ReservationItemStatus::Delivered);
if ($items->isEmpty()) {
throw ValidationException::withMessages(['operation' => 'Não existem equipamentos entregues por devolver.']);
}
$completed = [];
foreach ($items as $item) {
try {
$asset = $item->asset;
if ($asset->status_id === null) {
throw ValidationException::withMessages(['operation' => "O equipamento {$asset->asset_tag} não tem estado Snipe-IT sincronizado."]);
}
if ($reservation->requester->snipe_user_id === null) {
throw ValidationException::withMessages(['operation' => 'O destinatário não tem associação Snipe-IT confirmada.']);
}
$hasPortalCheckout = $item->handoverRecords()
->where('type', HandoverType::Checkout->value)
->where('recipient_id', $reservation->requester_id)
->exists();
if (! $hasPortalCheckout) {
throw ValidationException::withMessages([
'operation' => "O equipamento {$asset->asset_tag} não tem um checkout confirmado pelo portal; o check-in foi bloqueado.",
]);
}
$response = $this->snipeIt->checkinHardware(
(int) $asset->snipe_asset_id,
(int) $asset->status_id,
(int) $reservation->requester->snipe_user_id,
$asset->location_id === null ? null : (int) $asset->location_id,
$this->externalNote($reservation, $notes),
);
DB::transaction(function () use ($item, $operator, $condition, $notes, $response): void {
$lockedItem = ReservationItem::query()->whereKey($item->id)->lockForUpdate()->firstOrFail();
if ($lockedItem->status !== ReservationItemStatus::Delivered) {
throw ValidationException::withMessages(['operation' => 'O estado local do equipamento mudou durante a devolução.']);
}
$lockedItem->update([
'status' => ReservationItemStatus::Returned,
'returned_at' => now(),
'checkin_reference' => 'snipe-hardware:'.$lockedItem->asset->snipe_asset_id,
'condition_in' => $condition,
'notes' => $notes,
]);
HandoverRecord::create([
'reservation_item_id' => $lockedItem->id,
'type' => HandoverType::Checkin,
'operator_id' => $operator->id,
'recipient_id' => null,
'condition' => $condition,
'notes' => $notes,
'snipe_response' => $response,
]);
}, 3);
$completed[] = $item->id;
} catch (Throwable $exception) {
$this->recordPartialFailure($reservation, $operator, 'return', $completed, $item, $exception);
throw ValidationException::withMessages([
'operation' => "A devolução parou no equipamento {$item->asset->asset_tag}. ".$exception->getMessage(),
]);
}
}
return DB::transaction(function () use ($reservation, $operator, $completed): Reservation {
$locked = Reservation::query()->whereKey($reservation->id)->lockForUpdate()->firstOrFail();
$previous = $locked->status;
if ($locked->items()->where('status', ReservationItemStatus::Delivered)->exists()) {
throw ValidationException::withMessages(['operation' => 'Nem todos os equipamentos ficaram devolvidos.']);
}
if ($previous === ReservationStatus::Incident) {
$locked->items()->where('status', ReservationItemStatus::Ready)->update([
'status' => ReservationItemStatus::Cancelled,
]);
}
$locked->update(['status' => ReservationStatus::Returned]);
$this->audit($locked, $operator, 'reservation.returned', $previous, ReservationStatus::Returned, ['item_ids' => $completed]);
return $locked->load('requester', 'items.asset', 'items.handoverRecords');
}, 3);
}
public function complete(Reservation $reservation, User $operator): Reservation
{
$this->authorizeOperator($operator);
return DB::transaction(function () use ($reservation, $operator): Reservation {
$locked = Reservation::query()->whereKey($reservation->id)->lockForUpdate()->firstOrFail();
$previous = $locked->status;
$this->assertTransition($locked, ReservationStatus::Completed, 'Apenas requisições devolvidas podem ser concluídas.');
if ($locked->items()->whereNotIn('status', [
ReservationItemStatus::Returned,
ReservationItemStatus::Cancelled,
])->exists()) {
throw ValidationException::withMessages(['operation' => 'Ainda existem equipamentos por devolver ou reconciliar.']);
}
$locked->update(['status' => ReservationStatus::Completed]);
$this->audit($locked, $operator, 'reservation.completed', $previous, ReservationStatus::Completed);
return $locked->load('requester', 'items.asset');
}, 3);
}
public function markNoShow(Reservation $reservation, User $operator, string $notes): Reservation
{
$this->authorizeOperator($operator);
return DB::transaction(function () use ($reservation, $operator, $notes): Reservation {
$locked = Reservation::query()->whereKey($reservation->id)->lockForUpdate()->firstOrFail();
$this->assertTransition($locked, ReservationStatus::NoShow, 'A requisição não pode ser marcada como falta neste estado.');
$locked->update(['status' => ReservationStatus::NoShow]);
$locked->items()->where('status', ReservationItemStatus::Ready)->update(['status' => ReservationItemStatus::Cancelled]);
$this->audit($locked, $operator, 'reservation.no_show', ReservationStatus::Ready, ReservationStatus::NoShow, ['notes' => $notes]);
return $locked->load('requester', 'items.asset');
}, 3);
}
public function markOverdue(Reservation $reservation, User $operator, string $notes): Reservation
{
$this->authorizeOperator($operator);
return DB::transaction(function () use ($reservation, $operator, $notes): Reservation {
$locked = Reservation::query()->whereKey($reservation->id)->lockForUpdate()->firstOrFail();
$previous = $locked->status;
$this->assertTransition($locked, ReservationStatus::Overdue, 'A requisição não pode ser marcada como atrasada neste estado.');
$locked->update(['status' => ReservationStatus::Overdue]);
$this->audit($locked, $operator, 'reservation.overdue', $previous, ReservationStatus::Overdue, ['notes' => $notes]);
return $locked->load('requester', 'items.asset');
}, 3);
}
public function reportIncident(
Reservation $reservation,
ReservationItem $item,
User $operator,
string $notes,
?CarbonImmutable $blockedUntil = null,
): Reservation {
$this->authorizeOperator($operator);
return DB::transaction(function () use ($reservation, $item, $operator, $notes, $blockedUntil): Reservation {
$locked = Reservation::query()->whereKey($reservation->id)->lockForUpdate()->firstOrFail();
if ($item->reservation_id !== $locked->id) {
throw ValidationException::withMessages(['operation' => 'O equipamento não pertence a esta requisição.']);
}
$previous = $locked->status;
if ($previous !== ReservationStatus::Incident) {
$this->assertTransition($locked, ReservationStatus::Incident, 'Não é possível registar um incidente neste estado.');
$locked->update(['status' => ReservationStatus::Incident]);
}
if ($blockedUntil !== null) {
if ($blockedUntil->lessThanOrEqualTo(now())) {
throw ValidationException::withMessages(['blocked_until' => 'O bloqueio tem de terminar no futuro.']);
}
AssetBlock::create([
'snipe_asset_id' => $item->snipe_asset_id,
'starts_at' => now(),
'ends_at' => $blockedUntil,
'reason' => $notes,
'created_by' => $operator->id,
]);
}
$this->audit($locked, $operator, 'reservation.incident_reported', $previous, ReservationStatus::Incident, [
'reservation_item_id' => $item->id,
'notes' => $notes,
'blocked_until' => $blockedUntil?->toIso8601String(),
]);
return $locked->load('requester', 'items.asset');
}, 3);
}
/** @param list<int> $completed */
private function recordPartialFailure(
Reservation $reservation,
User $operator,
string $operation,
array $completed,
ReservationItem $failedItem,
Throwable $exception,
): void {
DB::transaction(function () use ($reservation, $operator, $operation, $completed, $failedItem, $exception): void {
$locked = Reservation::query()->whereKey($reservation->id)->lockForUpdate()->firstOrFail();
$previous = $locked->status;
if ($completed !== [] && $previous !== ReservationStatus::Incident && $previous->canTransitionTo(ReservationStatus::Incident)) {
$locked->update(['status' => ReservationStatus::Incident]);
}
AuditLog::create([
'actor_id' => $operator->id,
'event' => "reservation.{$operation}_failed",
'auditable_type' => $locked->getMorphClass(),
'auditable_id' => $locked->id,
'before' => ['status' => $previous->value],
'after' => ['status' => $locked->fresh()->status->value],
'metadata' => [
'completed_item_ids' => $completed,
'failed_item_id' => $failedItem->id,
'error' => $exception->getMessage(),
],
]);
}, 3);
}
private function assertOperationalConfiguration(): void
{
if (! $this->snipeIt->isOperationallyConfigured()) {
throw ValidationException::withMessages(['operation' => 'Configure o token operacional do Snipe-IT antes de registar movimentos.']);
}
}
private function authorizeOperator(User $operator): void
{
if (! $operator->active || (! $operator->hasRole(RoleName::InventoryOperator) && ! $operator->hasRole(RoleName::Administrator))) {
throw new AuthorizationException('Esta operação exige o perfil de operador de inventário.');
}
}
private function assertTransition(Reservation $reservation, ReservationStatus $next, string $message): void
{
if (! $reservation->status->canTransitionTo($next)) {
throw ValidationException::withMessages(['operation' => $message]);
}
}
/** @param array<string, mixed> $metadata */
private function audit(
Reservation $reservation,
User $operator,
string $event,
ReservationStatus $before,
ReservationStatus $after,
array $metadata = [],
): void {
AuditLog::create([
'actor_id' => $operator->id,
'event' => $event,
'auditable_type' => $reservation->getMorphClass(),
'auditable_id' => $reservation->id,
'before' => ['status' => $before->value],
'after' => ['status' => $after->value],
'metadata' => $metadata,
]);
}
private function externalNote(Reservation $reservation, ?string $notes): string
{
return trim("Portal {$reservation->reference}".($notes === null ? '' : ": {$notes}"));
}
}