415 lines
19 KiB
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}"));
|
|
}
|
|
}
|