feat: complete equipment handover workflow

This commit is contained in:
2026-08-04 17:15:51 +01:00
parent 809eab9d4a
commit d829f34580
24 changed files with 1800 additions and 28 deletions

View File

@@ -0,0 +1,272 @@
<?php
use App\Domain\Identity\Enums\RoleName;
use App\Domain\Identity\Models\Role;
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 Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Inertia\Testing\AssertableInertia as Assert;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('services.snipe_it', [
'url' => 'https://inventory.example.test',
'read_token' => 'read-token-for-tests',
'operational_token' => 'operational-token-for-tests',
'timeout' => 2,
'page_size' => 100,
'retries' => 1,
'retry_delay_ms' => 0,
]);
});
function inventoryOperator(): User
{
$operator = User::factory()->create();
$role = Role::firstOrCreate(['name' => RoleName::InventoryOperator->value]);
$operator->roles()->attach($role);
return $operator;
}
/** @param list<SnipeAsset> $assets */
function approvedOperationReservation(User $requester, array $assets, string $reference): Reservation
{
$reservation = Reservation::create([
'reference' => $reference,
'requester_id' => $requester->id,
'purpose' => 'Aula prática com equipamento audiovisual.',
'usage_location' => 'Sala 12',
'starts_at' => now()->subHour(),
'ends_at' => now()->addHours(3),
'status' => ReservationStatus::Approved,
'approved_at' => now()->subDay(),
]);
foreach ($assets as $asset) {
$reservation->items()->create([
'snipe_asset_id' => $asset->id,
'starts_at' => $reservation->starts_at,
'ends_at' => $reservation->ends_at,
'status' => ReservationItemStatus::Reserved,
]);
}
return $reservation;
}
it('restringe e apresenta a fila diária aos operadores', function () {
$reservation = approvedOperationReservation(
User::factory()->create(),
[SnipeAsset::factory()->create()],
'REQ-OPS-QUEUE',
);
$this->actingAs(User::factory()->create())->get('/operacoes')->assertForbidden();
$this->actingAs(inventoryOperator())
->get('/operacoes')
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('operations/index')
->where('operationallyConfigured', true)
->has('reservations', 1)
->where('reservations.0.reference', $reservation->reference));
});
it('prepara e entrega todos os equipamentos com registo auditável', function () {
$operator = inventoryOperator();
$requester = User::factory()->create();
$asset = SnipeAsset::factory()->create(['snipe_asset_id' => 501, 'status_id' => 2]);
$reservation = approvedOperationReservation($requester, [$asset], 'REQ-OPS-DELIVER');
$this->actingAs($operator)
->post("/operacoes/{$reservation->uuid}/preparar")
->assertRedirect()
->assertSessionHas('success');
expect($reservation->fresh()->status)->toBe(ReservationStatus::Ready)
->and($reservation->items()->firstOrFail()->status)->toBe(ReservationItemStatus::Ready);
Http::fake(['*' => Http::response(['status' => 'success', 'payload' => ['id' => 501]])]);
$this->actingAs($operator)
->post("/operacoes/{$reservation->uuid}/entregar", [
'snipe_user_id' => 77,
'condition' => 'Sem danos visíveis',
'notes' => 'Carregador incluído.',
])
->assertRedirect()
->assertSessionHas('success');
expect($reservation->fresh()->status)->toBe(ReservationStatus::Delivered)
->and($reservation->items()->firstOrFail()->status)->toBe(ReservationItemStatus::Delivered)
->and($requester->fresh()->snipe_user_id)->toBe(77);
$this->assertDatabaseHas('handover_records', [
'reservation_item_id' => $reservation->items()->firstOrFail()->id,
'type' => 'checkout',
'operator_id' => $operator->id,
'recipient_id' => $requester->id,
]);
$this->assertDatabaseHas('audit_logs', ['event' => 'reservation.delivered']);
Http::assertSent(fn (Request $request): bool => str_ends_with($request->url(), '/hardware/501/checkout'));
$this->actingAs($operator)
->post("/operacoes/{$reservation->uuid}/entregar", [
'snipe_user_id' => 77,
'condition' => 'Sem danos visíveis',
])
->assertSessionHasErrors('operation');
Http::assertSentCount(1);
});
it('regista a devolução no Snipe-IT e conclui o processo', function () {
$operator = inventoryOperator();
$asset = SnipeAsset::factory()->create([
'snipe_asset_id' => 601,
'status_id' => 2,
'location_id' => 9,
]);
$reservation = approvedOperationReservation(User::factory()->create(), [$asset], 'REQ-OPS-RETURN');
$reservation->update(['status' => ReservationStatus::Delivered]);
$reservation->items()->update([
'status' => ReservationItemStatus::Delivered,
'delivered_at' => now()->subHours(2),
]);
Http::fake(['*' => Http::response(['status' => 'success', 'payload' => ['id' => 601]])]);
$this->actingAs($operator)
->post("/operacoes/{$reservation->uuid}/devolver", [
'condition' => 'Bom estado',
'notes' => 'Conferido pelo operador.',
])
->assertRedirect()
->assertSessionHas('success');
expect($reservation->fresh()->status)->toBe(ReservationStatus::Returned)
->and($reservation->items()->firstOrFail()->status)->toBe(ReservationItemStatus::Returned);
$this->assertDatabaseHas('handover_records', ['type' => 'checkin', 'operator_id' => $operator->id]);
Http::assertSent(fn (Request $request): bool => str_ends_with($request->url(), '/hardware/601/checkin')
&& $request['location_id'] === 9);
$this->actingAs($operator)
->post("/operacoes/{$reservation->uuid}/concluir")
->assertRedirect();
expect($reservation->fresh()->status)->toBe(ReservationStatus::Completed);
$this->assertDatabaseHas('audit_logs', ['event' => 'reservation.completed']);
});
it('preserva sucessos e sinaliza incidente quando uma entrega fica parcial', function () {
$operator = inventoryOperator();
$first = SnipeAsset::factory()->create(['snipe_asset_id' => 701, 'status_id' => 2]);
$second = SnipeAsset::factory()->create(['snipe_asset_id' => 702, 'status_id' => 2]);
$reservation = approvedOperationReservation(User::factory()->create(), [$first, $second], 'REQ-OPS-PARTIAL');
$reservation->update(['status' => ReservationStatus::Ready]);
$reservation->items()->update(['status' => ReservationItemStatus::Ready]);
Http::fakeSequence()
->push(['status' => 'success', 'payload' => ['id' => 701]])
->push(['status' => 'error', 'messages' => 'Asset is not deployable'])
->push(['status' => 'success', 'payload' => ['id' => 702]]);
$this->actingAs($operator)
->from('/operacoes')
->post("/operacoes/{$reservation->uuid}/entregar", [
'snipe_user_id' => 88,
'condition' => 'Verificado',
])
->assertRedirect('/operacoes')
->assertSessionHasErrors('operation');
expect($reservation->fresh()->status)->toBe(ReservationStatus::Incident)
->and($reservation->items()->orderBy('id')->pluck('status')->all())->toBe([
ReservationItemStatus::Delivered,
ReservationItemStatus::Ready,
]);
$this->assertDatabaseCount('handover_records', 1);
$this->assertDatabaseHas('audit_logs', ['event' => 'reservation.delivery_failed']);
$this->actingAs($operator)
->post("/operacoes/{$reservation->uuid}/entregar", [
'snipe_user_id' => 88,
'condition' => 'Verificado após reconciliação',
])
->assertRedirect()
->assertSessionHas('success');
expect($reservation->fresh()->status)->toBe(ReservationStatus::Delivered)
->and($reservation->items()->where('status', ReservationItemStatus::Delivered)->count())->toBe(2);
$this->assertDatabaseCount('handover_records', 2);
});
it('regista incidentes e bloqueia o equipamento pelo período indicado', function () {
$operator = inventoryOperator();
$asset = SnipeAsset::factory()->create();
$reservation = approvedOperationReservation(User::factory()->create(), [$asset], 'REQ-OPS-INCIDENT');
$reservation->update(['status' => ReservationStatus::Delivered]);
$item = $reservation->items()->firstOrFail();
$item->update(['status' => ReservationItemStatus::Delivered]);
$this->actingAs($operator)
->post("/operacoes/{$reservation->uuid}/itens/{$item->id}/incidente", [
'notes' => 'Objetiva danificada durante a utilização.',
'blocked_until' => now()->addDays(7)->toDateTimeLocalString(),
])
->assertRedirect()
->assertSessionHas('success');
expect($reservation->fresh()->status)->toBe(ReservationStatus::Incident);
$this->assertDatabaseHas('asset_blocks', [
'snipe_asset_id' => $asset->id,
'created_by' => $operator->id,
'reason' => 'Objetiva danificada durante a utilização.',
]);
$this->assertDatabaseHas('audit_logs', ['event' => 'reservation.incident_reported']);
});
it('regista faltas e atrasos sem movimentos externos', function () {
$operator = inventoryOperator();
$requester = User::factory()->create();
$noShow = approvedOperationReservation(
$requester,
[SnipeAsset::factory()->create()],
'REQ-OPS-NO-SHOW',
);
$noShow->update(['status' => ReservationStatus::Ready]);
$noShow->items()->update(['status' => ReservationItemStatus::Ready]);
$this->actingAs($operator)
->post("/operacoes/{$noShow->uuid}/falta", ['notes' => 'O requisitante não compareceu.'])
->assertRedirect()
->assertSessionHas('success');
expect($noShow->fresh()->status)->toBe(ReservationStatus::NoShow)
->and($noShow->items()->firstOrFail()->status)->toBe(ReservationItemStatus::Cancelled);
$this->assertDatabaseHas('audit_logs', ['event' => 'reservation.no_show']);
$overdue = approvedOperationReservation(
$requester,
[SnipeAsset::factory()->create()],
'REQ-OPS-OVERDUE',
);
$overdue->update(['status' => ReservationStatus::Delivered]);
$overdue->items()->update(['status' => ReservationItemStatus::Delivered]);
$this->actingAs($operator)
->post("/operacoes/{$overdue->uuid}/atraso", ['notes' => 'Prazo de devolução ultrapassado.'])
->assertRedirect()
->assertSessionHas('success');
expect($overdue->fresh()->status)->toBe(ReservationStatus::Overdue);
$this->assertDatabaseHas('audit_logs', ['event' => 'reservation.overdue']);
Http::assertNothingSent();
});

View File

@@ -9,6 +9,7 @@ beforeEach(function () {
config()->set('services.snipe_it', [
'url' => 'https://inventory.example.test',
'read_token' => 'read-token-for-tests',
'operational_token' => 'operational-token-for-tests',
'timeout' => 2,
'page_size' => 2,
'retries' => 1,
@@ -61,6 +62,56 @@ it('recusa erros transportados num HTTP 200', function () {
->toThrow(SnipeItApiException::class, 'recusou a consulta');
});
it('regista uma entrega com o token operacional e o destinatário Snipe-IT', function () {
Http::fake([
'*' => Http::response(['status' => 'success', 'payload' => ['id' => 41]], 200),
]);
$payload = app(SnipeItClient::class)->checkoutHardware(41, 2, 73, '2026-08-10', 'Reserva RR-001');
expect($payload['status'])->toBe('success');
Http::assertSent(function (Request $request): bool {
return $request->method() === 'POST'
&& $request->url() === 'https://inventory.example.test/api/v1/hardware/41/checkout'
&& $request->hasHeader('Authorization', 'Bearer operational-token-for-tests')
&& $request->data() === [
'status_id' => 2,
'checkout_to_type' => 'user',
'assigned_user' => 73,
'expected_checkin' => '2026-08-10',
'note' => 'Reserva RR-001',
];
});
});
it('regista uma devolução com estado e localização no Snipe-IT', function () {
Http::fake([
'*' => Http::response(['status' => 'success', 'payload' => ['id' => 41]], 200),
]);
app(SnipeItClient::class)->checkinHardware(41, 2, 9, 'Devolvido sem danos');
Http::assertSent(function (Request $request): bool {
return $request->method() === 'POST'
&& $request->url() === 'https://inventory.example.test/api/v1/hardware/41/checkin'
&& $request->data() === [
'status_id' => 2,
'location_id' => 9,
'note' => 'Devolvido sem danos',
];
});
});
it('recusa erros operacionais transportados num HTTP 200', function () {
Http::fake([
'*' => Http::response(['status' => 'error', 'messages' => 'Asset is not deployable'], 200),
]);
expect(fn () => app(SnipeItClient::class)->checkoutHardware(41, 2, 73, '2026-08-10'))
->toThrow(SnipeItApiException::class, 'recusou a entrega');
});
/** @return array<string, mixed> */
function snipeClientRow(int $id): array
{