feat: complete equipment handover workflow
This commit is contained in:
@@ -29,6 +29,12 @@ Todas as alterações materiais são registadas neste ficheiro. O formato segue
|
||||
- Resposta auditada a pedidos de esclarecimento e renovação do prazo de decisão.
|
||||
- Substituição controlada por equipamento disponível da mesma categoria, preservando o item original no histórico.
|
||||
- Filtros da fila de aprovação por categoria/período e histórico de ações apresentado na interface.
|
||||
- Cliente operacional Snipe-IT com token separado, checkout para utilizador, check-in para localização habitual e validação defensiva de respostas.
|
||||
- Domínio `Operations` com registos de entrega/devolução, condição física, operador, destinatário e resposta externa.
|
||||
- Área diária do operador com pesquisa/leitura por scanner, preparação, entrega, devolução, conclusão, faltas, atrasos e incidentes.
|
||||
- Associação do utilizador local ao ID Snipe-IT confirmada durante a entrega.
|
||||
- Bloqueios temporais de equipamento criados a partir de incidentes e considerados pelo motor de disponibilidade.
|
||||
- Tratamento e auditoria de falhas parciais em movimentos com vários equipamentos.
|
||||
|
||||
### Alterado
|
||||
|
||||
@@ -38,11 +44,13 @@ Todas as alterações materiais são registadas neste ficheiro. O formato segue
|
||||
- As mensagens flash globais recebem agora as propriedades diretamente de `withApp`, evitando o uso de `usePage()` fora do contexto Inertia que interrompia o arranque do React.
|
||||
- SSR de desenvolvimento ligado internamente de PHP para o serviço `node`, com o hostname Docker autorizado no Vite.
|
||||
- Catálogo sem período passa a distinguir equipamentos apenas requisitáveis; quando um período é selecionado, apresenta exclusivamente equipamentos livres nesse intervalo.
|
||||
- Máquina de estados passa a admitir incidentes durante preparação/utilização, atrasos e resolução por devolução.
|
||||
|
||||
### Segurança
|
||||
|
||||
- Registo público desativado.
|
||||
- Variáveis para tokens Snipe-IT separados e credenciais Entra ID documentadas sem valores.
|
||||
- Rotas operacionais protegidas por Gate e verificação de perfil também no serviço de domínio; escritas externas não fazem retry automático.
|
||||
|
||||
### Verificado
|
||||
|
||||
@@ -56,3 +64,4 @@ Todas as alterações materiais são registadas neste ficheiro. O formato segue
|
||||
- Comando de expiração executado em PostgreSQL e agendamento a cada cinco minutos confirmado.
|
||||
- Builds Vite cliente e SSR com as páginas de disponibilidade, cancelamento e calendário concluídos.
|
||||
- Migration da caixa interna de notificações aplicada em PostgreSQL 17.
|
||||
- Fluxo operacional: autorização, checkout/check-in, entrega parcial, auditoria, incidentes e bloqueios validados com API Snipe-IT simulada.
|
||||
|
||||
10
README.md
10
README.md
@@ -4,7 +4,7 @@ Portal interno para consultar, requisitar, aprovar, entregar e devolver equipame
|
||||
|
||||
## Estado atual
|
||||
|
||||
O projeto concluiu as **Fases 1 a 4**: fundação técnica, integração de leitura Snipe-IT, motor de reservas e aprovações. Estão disponíveis autenticação local, catálogo temporal, pré-reservas, conflitos, cancelamento e expiração, calendário pessoal, decisões transacionais, esclarecimentos, substituição equivalente, notificações por email/caixa interna, dashboard e diagnóstico administrativo. A próxima entrega implementa o fluxo físico de entrega e devolução.
|
||||
O projeto concluiu as **Fases 1 a 5**: fundação técnica, integração Snipe-IT, motor de reservas, aprovações e operações físicas. Estão disponíveis autenticação local, catálogo temporal, pré-reservas, conflitos, calendário pessoal, decisões e notificações, checkout/check-in Snipe-IT, área diária do operador, entrega/devolução auditada, incidentes, atrasos e bloqueios. A próxima entrega é a administração e os relatórios.
|
||||
|
||||
Consulte sempre [docs/PROJECT_STATUS.md](docs/PROJECT_STATUS.md) antes de alterar o projeto. Esse documento contém o ponto de situação, riscos e próxima tarefa recomendada.
|
||||
|
||||
@@ -34,7 +34,7 @@ Estas credenciais existem apenas no `DatabaseSeeder` e nunca devem ser usadas em
|
||||
## Comandos frequentes
|
||||
|
||||
```bash
|
||||
docker compose exec app php artisan test
|
||||
docker compose exec app composer test
|
||||
docker compose exec app composer lint
|
||||
docker compose exec app composer types:check
|
||||
docker compose exec node npm run types:check
|
||||
@@ -42,6 +42,12 @@ docker compose exec node npm run lint:check
|
||||
docker compose exec node npm run build
|
||||
```
|
||||
|
||||
Como o serviço Docker define variáveis de desenvolvimento, use o script Composer para testes. O script força de forma isolada `APP_ENV=testing`, SQLite em memória, cache/sessão em memória e fila síncrona, evitando locks Redis ou middleware CSRF do ambiente local:
|
||||
|
||||
```bash
|
||||
docker compose exec app composer test
|
||||
```
|
||||
|
||||
Para repor apenas dados de desenvolvimento:
|
||||
|
||||
```bash
|
||||
|
||||
9
app/Domain/Operations/Enums/HandoverType.php
Normal file
9
app/Domain/Operations/Enums/HandoverType.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Operations\Enums;
|
||||
|
||||
enum HandoverType: string
|
||||
{
|
||||
case Checkout = 'checkout';
|
||||
case Checkin = 'checkin';
|
||||
}
|
||||
46
app/Domain/Operations/Models/HandoverRecord.php
Normal file
46
app/Domain/Operations/Models/HandoverRecord.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Operations\Models;
|
||||
|
||||
use App\Domain\Operations\Enums\HandoverType;
|
||||
use App\Domain\Reservations\Models\ReservationItem;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class HandoverRecord extends Model
|
||||
{
|
||||
public const UPDATED_AT = null;
|
||||
|
||||
protected $fillable = [
|
||||
'reservation_item_id', 'type', 'operator_id', 'recipient_id',
|
||||
'condition', 'notes', 'snipe_response', 'created_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'type' => HandoverType::class,
|
||||
'snipe_response' => 'array',
|
||||
'created_at' => 'immutable_datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/** @return BelongsTo<ReservationItem, $this> */
|
||||
public function reservationItem(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ReservationItem::class);
|
||||
}
|
||||
|
||||
/** @return BelongsTo<User, $this> */
|
||||
public function operator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'operator_id');
|
||||
}
|
||||
|
||||
/** @return BelongsTo<User, $this> */
|
||||
public function recipient(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'recipient_id');
|
||||
}
|
||||
}
|
||||
401
app/Domain/Operations/Services/HandoverService.php
Normal file
401
app/Domain/Operations/Services/HandoverService.php
Normal file
@@ -0,0 +1,401 @@
|
||||
<?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('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."]);
|
||||
}
|
||||
|
||||
$response = $this->snipeIt->checkinHardware(
|
||||
(int) $asset->snipe_asset_id,
|
||||
(int) $asset->status_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}"));
|
||||
}
|
||||
}
|
||||
@@ -28,9 +28,11 @@ enum ReservationStatus: string
|
||||
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::Ready => [self::Delivered, self::Incident, self::NoShow, self::Cancelled],
|
||||
self::Delivered => [self::InUse, self::Returned, self::Overdue, self::Incident],
|
||||
self::InUse, self::Overdue, self::Incident => [self::Returned],
|
||||
self::InUse => [self::Returned, self::Overdue, self::Incident],
|
||||
self::Overdue => [self::Returned, self::Incident],
|
||||
self::Incident => [self::Delivered, self::Returned, self::Completed],
|
||||
self::Returned => [self::Completed, self::Incident],
|
||||
self::Completed, self::Rejected, self::Cancelled, self::Expired, self::NoShow => [],
|
||||
};
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
namespace App\Domain\Reservations\Models;
|
||||
|
||||
use App\Domain\Inventory\Models\SnipeAsset;
|
||||
use App\Domain\Operations\Models\HandoverRecord;
|
||||
use App\Domain\Reservations\Enums\ReservationItemStatus;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* @property ReservationItemStatus $status
|
||||
@@ -44,4 +46,10 @@ class ReservationItem extends Model
|
||||
{
|
||||
return $this->belongsTo(SnipeAsset::class, 'snipe_asset_id');
|
||||
}
|
||||
|
||||
/** @return HasMany<HandoverRecord, $this> */
|
||||
public function handoverRecords(): HasMany
|
||||
{
|
||||
return $this->hasMany(HandoverRecord::class);
|
||||
}
|
||||
}
|
||||
|
||||
138
app/Http/Controllers/OperationController.php
Normal file
138
app/Http/Controllers/OperationController.php
Normal file
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Domain\Operations\Services\HandoverService;
|
||||
use App\Domain\Reservations\Enums\ReservationStatus;
|
||||
use App\Domain\Reservations\Models\Reservation;
|
||||
use App\Domain\Reservations\Models\ReservationItem;
|
||||
use App\Integrations\SnipeIt\SnipeItClient;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class OperationController extends Controller
|
||||
{
|
||||
public function index(Request $request, SnipeItClient $snipeIt): Response
|
||||
{
|
||||
$start = now()->startOfDay();
|
||||
$end = now()->addDay()->startOfDay();
|
||||
|
||||
return Inertia::render('operations/index', [
|
||||
'operationallyConfigured' => $snipeIt->isOperationallyConfigured(),
|
||||
'reservations' => Reservation::query()
|
||||
->with([
|
||||
'requester:id,name,email,department,snipe_user_id',
|
||||
'items.asset:id,snipe_asset_id,name,asset_tag,status_id,status_name,location_id,location_name',
|
||||
'items.handoverRecords:id,reservation_item_id,type,operator_id,condition,notes,created_at',
|
||||
'items.handoverRecords.operator:id,name',
|
||||
])
|
||||
->whereIn('status', [
|
||||
ReservationStatus::Approved,
|
||||
ReservationStatus::Ready,
|
||||
ReservationStatus::Delivered,
|
||||
ReservationStatus::InUse,
|
||||
ReservationStatus::Returned,
|
||||
ReservationStatus::Overdue,
|
||||
ReservationStatus::Incident,
|
||||
])
|
||||
->where(function ($query) use ($start, $end) {
|
||||
$query->where(function ($query) use ($start, $end) {
|
||||
$query->where('starts_at', '<', $end)->where('ends_at', '>', $start);
|
||||
})->orWhereIn('status', [
|
||||
ReservationStatus::Delivered,
|
||||
ReservationStatus::InUse,
|
||||
ReservationStatus::Returned,
|
||||
ReservationStatus::Overdue,
|
||||
ReservationStatus::Incident,
|
||||
]);
|
||||
})
|
||||
->orderByRaw("CASE WHEN status IN ('overdue', 'incident') THEN 0 ELSE 1 END")
|
||||
->orderBy('starts_at')
|
||||
->limit(50)
|
||||
->get(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function prepare(Request $request, Reservation $reservation, HandoverService $service): RedirectResponse
|
||||
{
|
||||
$service->markReady($reservation, $request->user());
|
||||
|
||||
return back()->with('success', "Requisição {$reservation->reference} preparada.");
|
||||
}
|
||||
|
||||
public function deliver(Request $request, Reservation $reservation, HandoverService $service): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'snipe_user_id' => ['required', 'integer', 'min:1'],
|
||||
'condition' => ['required', 'string', 'min:3', 'max:500'],
|
||||
'notes' => ['nullable', 'string', 'max:1000'],
|
||||
]);
|
||||
$service->deliver(
|
||||
$reservation,
|
||||
$request->user(),
|
||||
(int) $data['snipe_user_id'],
|
||||
$data['condition'],
|
||||
$data['notes'] ?? null,
|
||||
);
|
||||
|
||||
return back()->with('success', "Entrega de {$reservation->reference} registada no portal e no Snipe-IT.");
|
||||
}
|
||||
|
||||
public function returnEquipment(Request $request, Reservation $reservation, HandoverService $service): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'condition' => ['required', 'string', 'min:3', 'max:500'],
|
||||
'notes' => ['nullable', 'string', 'max:1000'],
|
||||
]);
|
||||
$service->returnEquipment($reservation, $request->user(), $data['condition'], $data['notes'] ?? null);
|
||||
|
||||
return back()->with('success', "Devolução de {$reservation->reference} registada no portal e no Snipe-IT.");
|
||||
}
|
||||
|
||||
public function complete(Request $request, Reservation $reservation, HandoverService $service): RedirectResponse
|
||||
{
|
||||
$service->complete($reservation, $request->user());
|
||||
|
||||
return back()->with('success', "Requisição {$reservation->reference} concluída.");
|
||||
}
|
||||
|
||||
public function noShow(Request $request, Reservation $reservation, HandoverService $service): RedirectResponse
|
||||
{
|
||||
$data = $request->validate(['notes' => ['required', 'string', 'min:3', 'max:1000']]);
|
||||
$service->markNoShow($reservation, $request->user(), $data['notes']);
|
||||
|
||||
return back()->with('success', "Falta registada em {$reservation->reference}.");
|
||||
}
|
||||
|
||||
public function overdue(Request $request, Reservation $reservation, HandoverService $service): RedirectResponse
|
||||
{
|
||||
$data = $request->validate(['notes' => ['required', 'string', 'min:3', 'max:1000']]);
|
||||
$service->markOverdue($reservation, $request->user(), $data['notes']);
|
||||
|
||||
return back()->with('success', "Atraso registado em {$reservation->reference}.");
|
||||
}
|
||||
|
||||
public function incident(
|
||||
Request $request,
|
||||
Reservation $reservation,
|
||||
ReservationItem $item,
|
||||
HandoverService $service,
|
||||
): RedirectResponse {
|
||||
$data = $request->validate([
|
||||
'notes' => ['required', 'string', 'min:5', 'max:1000'],
|
||||
'blocked_until' => ['nullable', 'date', 'after:now'],
|
||||
]);
|
||||
$service->reportIncident(
|
||||
$reservation,
|
||||
$item,
|
||||
$request->user(),
|
||||
$data['notes'],
|
||||
isset($data['blocked_until']) ? CarbonImmutable::parse($data['blocked_until']) : null,
|
||||
);
|
||||
|
||||
return back()->with('success', "Incidente registado em {$reservation->reference}.");
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,7 @@ class HandleInertiaRequests extends Middleware
|
||||
'can' => [
|
||||
'administer' => fn (): bool => $request->user()?->can('administer') ?? false,
|
||||
'approveReservations' => fn (): bool => $request->user()?->can('approve-reservations') ?? false,
|
||||
'operateInventory' => fn (): bool => $request->user()?->can('operate-inventory') ?? false,
|
||||
],
|
||||
'unreadNotifications' => fn (): int => $request->user()?->unreadNotifications()->count() ?? 0,
|
||||
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
|
||||
|
||||
@@ -9,6 +9,7 @@ use Generator;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
use Throwable;
|
||||
@@ -20,6 +21,11 @@ final class SnipeItClient
|
||||
return filled(config('services.snipe_it.url')) && filled(config('services.snipe_it.read_token'));
|
||||
}
|
||||
|
||||
public function isOperationallyConfigured(): bool
|
||||
{
|
||||
return filled(config('services.snipe_it.url')) && filled(config('services.snipe_it.operational_token'));
|
||||
}
|
||||
|
||||
public function configuredUrl(): ?string
|
||||
{
|
||||
$url = trim((string) config('services.snipe_it.url'));
|
||||
@@ -32,7 +38,7 @@ final class SnipeItClient
|
||||
$pageSize = $limit ?? (int) config('services.snipe_it.page_size', 100);
|
||||
$pageSize = max(1, min(500, $pageSize));
|
||||
|
||||
$response = $this->request()->get('/hardware', [
|
||||
$response = $this->readRequest()->get('/hardware', [
|
||||
'limit' => $pageSize,
|
||||
'offset' => max(0, $offset),
|
||||
'sort' => 'id',
|
||||
@@ -92,7 +98,61 @@ final class SnipeItClient
|
||||
} while ($received > 0 && $offset < $page->total);
|
||||
}
|
||||
|
||||
private function request(): PendingRequest
|
||||
/** @return array<string, mixed> */
|
||||
public function checkoutHardware(
|
||||
int $hardwareId,
|
||||
int $statusId,
|
||||
int $assignedUserId,
|
||||
string $expectedCheckin,
|
||||
?string $note = null,
|
||||
): array {
|
||||
$this->assertPositiveIdentifiers($hardwareId, $statusId, $assignedUserId);
|
||||
|
||||
$payload = [
|
||||
'status_id' => $statusId,
|
||||
'checkout_to_type' => 'user',
|
||||
'assigned_user' => $assignedUserId,
|
||||
'expected_checkin' => $expectedCheckin,
|
||||
];
|
||||
|
||||
if (filled($note)) {
|
||||
$payload['note'] = $note;
|
||||
}
|
||||
|
||||
$response = $this->operationalRequest()->post("/hardware/{$hardwareId}/checkout", $payload);
|
||||
|
||||
return $this->operationPayload($response, 'entrega');
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function checkinHardware(
|
||||
int $hardwareId,
|
||||
int $statusId,
|
||||
?int $locationId = null,
|
||||
?string $note = null,
|
||||
): array {
|
||||
$this->assertPositiveIdentifiers($hardwareId, $statusId);
|
||||
|
||||
if ($locationId !== null && $locationId < 1) {
|
||||
throw new SnipeItConfigurationException('O identificador da localização do Snipe-IT tem de ser positivo.');
|
||||
}
|
||||
|
||||
$payload = ['status_id' => $statusId];
|
||||
|
||||
if ($locationId !== null) {
|
||||
$payload['location_id'] = $locationId;
|
||||
}
|
||||
|
||||
if (filled($note)) {
|
||||
$payload['note'] = $note;
|
||||
}
|
||||
|
||||
$response = $this->operationalRequest()->post("/hardware/{$hardwareId}/checkin", $payload);
|
||||
|
||||
return $this->operationPayload($response, 'devolução');
|
||||
}
|
||||
|
||||
private function readRequest(): PendingRequest
|
||||
{
|
||||
if (! $this->isConfigured()) {
|
||||
throw new SnipeItConfigurationException('A URL e o token de leitura do Snipe-IT ainda não estão configurados.');
|
||||
@@ -116,6 +176,54 @@ final class SnipeItClient
|
||||
);
|
||||
}
|
||||
|
||||
private function operationalRequest(): PendingRequest
|
||||
{
|
||||
if (! $this->isOperationallyConfigured()) {
|
||||
throw new SnipeItConfigurationException('A URL e o token operacional do Snipe-IT ainda não estão configurados.');
|
||||
}
|
||||
|
||||
return Http::baseUrl($this->apiBaseUrl())
|
||||
->acceptJson()
|
||||
->asJson()
|
||||
->withToken((string) config('services.snipe_it.operational_token'))
|
||||
->withUserAgent(Str::slug((string) config('app.name')).'/inventory-operations')
|
||||
->connectTimeout(min(5, max(1, (int) config('services.snipe_it.timeout', 10))))
|
||||
->timeout(max(1, (int) config('services.snipe_it.timeout', 10)));
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function operationPayload(Response $response, string $operation): array
|
||||
{
|
||||
if (! $response->successful()) {
|
||||
throw new SnipeItApiException(
|
||||
"O Snipe-IT respondeu com HTTP {$response->status()} durante a {$operation}.",
|
||||
$response->status(),
|
||||
);
|
||||
}
|
||||
|
||||
$payload = $response->json();
|
||||
|
||||
if (! is_array($payload)) {
|
||||
throw new SnipeItApiException("O Snipe-IT devolveu uma resposta inválida durante a {$operation}.");
|
||||
}
|
||||
|
||||
if (($payload['status'] ?? null) !== 'success') {
|
||||
throw new SnipeItApiException("O Snipe-IT recusou a {$operation} apesar de responder com HTTP 200.");
|
||||
}
|
||||
|
||||
/** @var array<string, mixed> $payload */
|
||||
return $payload;
|
||||
}
|
||||
|
||||
private function assertPositiveIdentifiers(int ...$identifiers): void
|
||||
{
|
||||
foreach ($identifiers as $identifier) {
|
||||
if ($identifier < 1) {
|
||||
throw new SnipeItConfigurationException('Os identificadores do Snipe-IT têm de ser positivos.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function apiBaseUrl(): string
|
||||
{
|
||||
$baseUrl = $this->configuredUrl();
|
||||
|
||||
@@ -33,7 +33,7 @@ use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
*/
|
||||
#[Fillable(['uuid', 'name', 'email', 'password', 'department', 'active', 'last_login_at'])]
|
||||
#[Fillable(['uuid', 'name', 'email', 'password', 'department', 'snipe_user_id', 'active', 'last_login_at'])]
|
||||
#[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])]
|
||||
class User extends Authenticatable implements PasskeyUser
|
||||
{
|
||||
|
||||
@@ -37,6 +37,11 @@ class AppServiceProvider extends ServiceProvider
|
||||
fn (User $user): bool => $user->active
|
||||
&& ($user->hasRole(RoleName::Approver) || $user->hasRole(RoleName::Administrator)),
|
||||
);
|
||||
Gate::define(
|
||||
'operate-inventory',
|
||||
fn (User $user): bool => $user->active
|
||||
&& ($user->hasRole(RoleName::InventoryOperator) || $user->hasRole(RoleName::Administrator)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->unsignedBigInteger('snipe_user_id')->nullable()->index()->after('department');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn('snipe_user_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -33,7 +33,7 @@ Nginx ──► Laravel + Inertia/React
|
||||
- `app/Domain/Inventory`: cache local de ativos e consultas do catálogo.
|
||||
- `app/Domain/Reservations`: disponibilidade, estados, regras e requisições.
|
||||
- `app/Domain/Approvals`: decisões, histórico e concorrência de aprovação.
|
||||
- `app/Domain/Handovers`: entrega, devolução e incidentes (a criar).
|
||||
- `app/Domain/Operations`: entrega, devolução, faltas, atrasos e incidentes.
|
||||
- `app/Domain/Audit`: logs imutáveis pela interface.
|
||||
- `app/Integrations/SnipeIt`: cliente HTTP, DTOs, mapper e exceções da API Snipe-IT.
|
||||
|
||||
@@ -65,9 +65,13 @@ Notificações implementam `ShouldQueue`, usam `afterCommit` e seguem dois canai
|
||||
5. Antes de aprovação ou entrega, o ativo é validado novamente.
|
||||
6. Checkout/check-in usam um token operacional separado e guardam apenas a resposta necessária para auditoria.
|
||||
|
||||
`HandoverService` mantém a ordem explícita “Snipe-IT primeiro, confirmação local depois” para cada equipamento. Uma requisição com vários itens não é apresentada como concluída se apenas parte dos movimentos for aceite: os itens confirmados preservam o seu `HandoverRecord`, os restantes mantêm o estado anterior e a requisição passa a `incident`. As escritas externas não têm retry automático porque um timeout pode ocorrer depois de o Snipe-IT executar a operação; repetir sem reconciliação poderia criar um resultado enganador.
|
||||
|
||||
O checkout associa o ativo a um utilizador Snipe-IT. O operador confirma o ID remoto na primeira entrega e o portal guarda-o em `users.snipe_user_id` para utilizações futuras. O check-in repõe o estado e a localização habitual sincronizados do ativo. As respostas externas ficam em `handover_records.snipe_response`; tokens nunca são persistidos.
|
||||
|
||||
Uma falha intermédia pode deixar upserts válidos das páginas anteriores, mas nunca executa a desativação global. A sincronização seguinte converge o cache. Um lock distribuído e um job único impedem duas sincronizações simultâneas.
|
||||
|
||||
Referências oficiais usadas para o contrato: [autenticação da API](https://snipe-it.readme.io/reference/authenticating-with-the-api), [listagem de hardware](https://snipe-it.readme.io/reference/hardware-list) e [limites/paginação](https://snipe-it.readme.io/reference/api-throttling).
|
||||
Referências oficiais usadas para o contrato: [autenticação da API](https://snipe-it.readme.io/reference/authenticating-with-the-api), [listagem de hardware](https://snipe-it.readme.io/reference/hardware-list), [limites/paginação](https://snipe-it.readme.io/reference/api-throttling), [checkout de hardware](https://snipe-it.readme.io/reference/hardware-checkout), [check-in de hardware](https://snipe-it.readme.io/reference/hardware-checkin) e [formato de erros da API](https://snipe-it.readme.io/reference/api-overview).
|
||||
|
||||
## Segurança
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ Usar Laravel 13 com React/TypeScript através de Inertia no mesmo projeto. Mant
|
||||
|
||||
## ADR-002 — PostgreSQL como garantia de conflitos
|
||||
|
||||
**Data:** 2026-08-04
|
||||
**Data:** 2026-08-04
|
||||
**Estado:** aceite
|
||||
|
||||
Usar PostgreSQL e períodos semiabertos `[início, fim)`. A lógica aplicacional melhora a mensagem ao utilizador; uma restrição de exclusão GiST garante que concorrência ou um erro aplicacional não cria sobreposição bloqueante.
|
||||
@@ -69,3 +69,12 @@ A autorização HTTP e a verificação no serviço exigem perfil de aprovador ou
|
||||
**Estado:** aceite
|
||||
|
||||
Notificações funcionais são colocadas na fila apenas depois da confirmação da transação e enviadas por email e caixa interna. A submissão e uma resposta a esclarecimentos notificam aprovadores ativos; decisões e substituições notificam o requisitante. Uma substituição não altera automaticamente a decisão: exige justificação, equipamento livre da mesma categoria, bloqueio de ambas as linhas, novo item `held` e preservação do item original como `cancelled` para auditoria.
|
||||
|
||||
## ADR-011 — Escritas Snipe-IT sem retry e reconciliação explícita
|
||||
|
||||
**Data:** 2026-08-04
|
||||
**Estado:** aceite
|
||||
|
||||
Checkout e check-in usam um token operacional distinto do token de leitura e nunca escrevem diretamente na base de dados do Snipe-IT. Cada equipamento é movimentado remotamente antes de o respetivo `reservation_item` e `handover_record` serem confirmados localmente. Escritas não têm retry automático: uma falha de ligação ou HTTP 5xx pode ser posterior à execução remota e a repetição cega não é segura. Numa operação com vários itens, sucessos confirmados são preservados e uma falha posterior coloca a requisição em `incident`, com IDs concluídos, item falhado e erro em auditoria. O operador reconcilia o ativo no Snipe-IT e repete apenas o movimento ainda pendente.
|
||||
|
||||
O checkout é dirigido a `checkout_to_type=user`; o ID remoto confirmado pelo operador é guardado em `users.snipe_user_id`. O check-in usa o estado e a localização habitual provenientes da última sincronização. Esta opção segue os contratos oficiais de [checkout](https://snipe-it.readme.io/reference/hardware-checkout), [check-in](https://snipe-it.readme.io/reference/hardware-checkin) e o comportamento documentado de [erros em HTTP 200](https://snipe-it.readme.io/reference/api-overview).
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Estado do projeto e handoff
|
||||
|
||||
**Atualizado em:** 4 de agosto de 2026
|
||||
**Fase:** Fases 1, 2, 3 e 4 concluídas
|
||||
**Fase:** Fases 1 a 5 concluídas; próxima entrega: Fase 6
|
||||
**Último agente:** Codex
|
||||
|
||||
## Resultado disponível
|
||||
@@ -34,44 +34,51 @@
|
||||
- Resposta auditada do requisitante a esclarecimentos, com regresso a `pending_approval` e novo prazo de 48 horas.
|
||||
- Substituição de equipamento apenas por ativo livre da mesma categoria, mantendo a pré-reserva e todo o histórico.
|
||||
- Fila de aprovação com filtros por referência, requisitante, categoria e período, incluindo histórico de ações.
|
||||
- Cliente Snipe-IT operacional com token separado, checkout para utilizador, check-in para a localização habitual e validação de erros transportados em HTTP 200.
|
||||
- Área diária do operador protegida pelo perfil `inventory_operator`/`administrator`, com leitura por scanner-teclado ou pesquisa de referência, requisitante, etiqueta e ID Snipe-IT.
|
||||
- Preparação, entrega, devolução e conclusão com máquina de estados, condição física, observações, operador, destinatário e resposta externa em `handover_records`.
|
||||
- Associação persistente entre o utilizador do portal e o respetivo ID no Snipe-IT, confirmada durante uma entrega bem-sucedida.
|
||||
- Tratamento explícito de falhas parciais: movimentos já aceites são preservados, a requisição passa a `incident` e a falha fica em `audit_logs` para reconciliação.
|
||||
- Registo de falta, atraso e incidente; um incidente pode criar um bloqueio temporal do equipamento que entra imediatamente no motor de disponibilidade.
|
||||
|
||||
## Limites conhecidos
|
||||
|
||||
- A integração está implementada e testada contra respostas simuladas; falta configurar e validar URL, versão e token da instância institucional. Até lá, o catálogo utiliza dados fictícios do seeder.
|
||||
- Microsoft Entra ID ainda não está implementado; as variáveis de ambiente já estão definidas.
|
||||
- Entrega e devolução têm tabelas preparadas, mas ainda não possuem ações ou interface.
|
||||
- As notificações estão implementadas com Mailpit em desenvolvimento; falta validar SMTP institucional antes do piloto.
|
||||
- As políticas de autorização finas ainda não foram ligadas às rotas; nesta fase todas as rotas funcionais exigem utilizador autenticado, verificado e ativo.
|
||||
- O calendário atual é a vista mensal pessoal do MVP; vistas diária, semanal, global e por equipamento/categoria/localização ficam para as áreas operacional e administrativa.
|
||||
- O leitor operacional aceita scanners QR/código de barras que funcionem como teclado. A leitura direta pela câmara do navegador ainda não foi adicionada.
|
||||
- Uma falha depois de o Snipe-IT confirmar um movimento e antes do commit local é uma transação distribuída ambígua; não existe retry automático de escritas. O operador deve reconciliar pelos registos Snipe-IT e `audit_logs` conforme o runbook.
|
||||
|
||||
## Próxima tarefa recomendada
|
||||
|
||||
Implementar a **Fase 5 — entrega e devolução**:
|
||||
Implementar a **Fase 6 — administração e relatórios**:
|
||||
|
||||
1. abrir `app/Integrations/SnipeIt/SnipeItClient.php` e acrescentar checkout/check-in com o token operacional separado;
|
||||
2. criar `app/Domain/Handovers/Services/HandoverService.php` com entrega e devolução transacionais;
|
||||
3. criar área do operador para reservas do dia, pesquisa por referência/utilizador e confirmação do estado físico;
|
||||
4. tratar falhas parciais da API Snipe-IT sem declarar entrega/devolução concluída;
|
||||
5. implementar incidentes, atrasos e bloqueios associados a dano;
|
||||
6. testar API simulada, autorização, idempotência e auditoria;
|
||||
7. atualizar este documento e o changelog.
|
||||
1. criar a área administrativa de utilizadores, perfis e associação ao ID Snipe-IT;
|
||||
2. tornar configuráveis as políticas de antecedência, duração, margens, estados e categorias;
|
||||
3. criar calendário global e relatórios de utilização, atrasos, incidentes e ativos mais requisitados;
|
||||
4. adicionar exportação CSV/XLSX e filtros por período, categoria, departamento e estado;
|
||||
5. criar consulta de auditoria apenas de leitura para o perfil `auditor`;
|
||||
6. adicionar métricas operacionais e gestão de modelos de email;
|
||||
7. cobrir autorização, exportações e agregações com testes e atualizar toda a documentação.
|
||||
|
||||
## Testes da última sessão
|
||||
|
||||
Validação concluída em 4 de agosto de 2026:
|
||||
|
||||
- `docker compose exec -T app composer test`: passou;
|
||||
- Laravel Pint: 111 ficheiros sem desvios;
|
||||
- PHPStan/Larastan nível 7: 84 ficheiros, zero erros;
|
||||
- Pest: 70 testes passaram, 340 asserções e 2 testes de registo ignorados por a funcionalidade estar deliberadamente desativada;
|
||||
- Laravel Pint: 117 ficheiros sem desvios;
|
||||
- PHPStan/Larastan nível 7: 89 ficheiros, zero erros;
|
||||
- Pest: 79 testes passaram, 412 asserções e 2 testes de registo ignorados por a funcionalidade estar deliberadamente desativada;
|
||||
- TypeScript (`tsc --noEmit`): passou;
|
||||
- ESLint: passou;
|
||||
- Prettier: passou;
|
||||
- Vite: builds cliente e SSR concluídos, 2308 módulos cliente e 97 módulos SSR transformados;
|
||||
- Vite: builds cliente e SSR concluídos, 2309 módulos cliente e 98 módulos SSR transformados;
|
||||
- PostgreSQL 17: `migrate:fresh --seed` concluído, incluindo extensão `btree_gist` e restrição de exclusão temporal;
|
||||
- PostgreSQL 17: migration incremental do marcador `last_seen_sync_run_id` aplicada com sucesso;
|
||||
- PostgreSQL 17: nenhuma migration pendente; comando de expiração executado e agendamentos horário/a cada cinco minutos confirmados;
|
||||
- PostgreSQL 17: migration `notifications` aplicada com sucesso;
|
||||
- PostgreSQL 17: migration `snipe_user_id` aplicada e fluxo operacional exercitado contra API simulada;
|
||||
- smoke test HTTP: `/login` respondeu com estado 200 através do Nginx;
|
||||
- Vite em Docker: URL pública, HMR e CORS verificados em `localhost:5173`; corrigida a página em branco causada pela publicação anterior de `0.0.0.0:5173`;
|
||||
- Inertia SSR: renderização do login validada através do serviço `node`; corrigido o componente de mensagens flash que usava `usePage()` fora do contexto Inertia;
|
||||
|
||||
@@ -30,6 +30,8 @@ curl -I -H 'Origin: http://localhost:8000' http://localhost:5173/@vite/client
|
||||
curl -I -H 'Origin: http://localhost:8000' http://localhost:5173/resources/js/app.tsx
|
||||
```
|
||||
|
||||
Para executar a validação backend use `docker compose exec app composer test`, não `php artisan test` diretamente. O script isola as variáveis de teste das variáveis `local` injetadas pelo Docker Compose e evita que jobs únicos deixem locks no Redis de desenvolvimento.
|
||||
|
||||
Ambos devem responder com estado 200 e `Access-Control-Allow-Origin: http://localhost:8000`. Reiniciar o serviço com `docker compose restart node` se o ficheiro `public/hot` estiver desatualizado.
|
||||
|
||||
Confirmar também que o HTML inicial já contém conteúdo SSR:
|
||||
@@ -57,13 +59,14 @@ Definir no ambiente, sem colocar tokens no repositório:
|
||||
```dotenv
|
||||
SNIPE_IT_URL=https://inventario.exemplo.pt
|
||||
SNIPE_IT_READ_TOKEN=
|
||||
SNIPE_IT_OPERATIONAL_TOKEN=
|
||||
SNIPE_IT_TIMEOUT=10
|
||||
SNIPE_IT_PAGE_SIZE=100
|
||||
SNIPE_IT_RETRIES=3
|
||||
SNIPE_IT_RETRY_DELAY_MS=250
|
||||
```
|
||||
|
||||
O token deve pertencer a uma conta técnica com acesso apenas de leitura aos ativos. A aplicação acrescenta `/api/v1` automaticamente quando a URL não o contém.
|
||||
O token de leitura deve pertencer a uma conta técnica com acesso apenas de leitura aos ativos. O token operacional deve ser separado e ter apenas as permissões necessárias para checkout/check-in. A aplicação acrescenta `/api/v1` automaticamente quando a URL não o contém.
|
||||
|
||||
Operações:
|
||||
|
||||
@@ -80,6 +83,28 @@ docker compose exec app php artisan schedule:list
|
||||
|
||||
Administradores podem testar a ligação, iniciar uma execução e consultar as últimas dez sincronizações em `http://localhost:8000/administracao/snipe-it`. Uma execução falhada mantém o último catálogo válido e não desativa registos ausentes. Consultar `sync_runs`, os logs do worker e o painel antes de repetir.
|
||||
|
||||
## Entregas, devoluções e reconciliação
|
||||
|
||||
Utilizadores com perfil `inventory_operator` ou `administrator` acedem a `http://localhost:8000/operacoes`. A fila inclui as reservas do dia e todos os processos entregues, atrasados, devolvidos ou com incidente ainda pendente. O campo inicial aceita referência, nome, email, etiqueta, ID Snipe-IT ou a saída de um leitor QR/código de barras configurado como teclado.
|
||||
|
||||
Procedimento de entrega:
|
||||
|
||||
1. preparar a requisição;
|
||||
2. confirmar fisicamente todos os equipamentos e o estado à saída;
|
||||
3. confirmar o ID do destinatário no Snipe-IT;
|
||||
4. registar a entrega e verificar a mensagem de sucesso;
|
||||
5. se existir erro, não voltar a submeter antes de consultar o estado do ativo no Snipe-IT.
|
||||
|
||||
Procedimento de devolução:
|
||||
|
||||
1. localizar a requisição ou ler a etiqueta;
|
||||
2. verificar equipamento, acessórios, danos e localização;
|
||||
3. registar a devolução;
|
||||
4. criar um incidente e bloqueio temporal quando o ativo não deva voltar ao catálogo;
|
||||
5. concluir o processo apenas depois de todos os itens estarem devolvidos.
|
||||
|
||||
Uma operação parcial fica em `incident`. Consultar `handover_records` para movimentos confirmados e os eventos `reservation.delivery_failed`/`reservation.return_failed` em `audit_logs`. Comparar cada item com o estado atual no Snipe-IT; corrigir apenas os itens ainda `ready` ou `delivered`. Não alterar estados diretamente na base de dados nem repetir em bloco, porque as escritas externas são deliberadamente não idempotentes e não têm retry automático.
|
||||
|
||||
## Ciclo de vida das pré-reservas
|
||||
|
||||
As requisições pendentes têm, por defeito, um prazo de decisão de 48 horas. O scheduler coloca a expiração na fila a cada cinco minutos. Para operar ou diagnosticar manualmente:
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
LayoutGrid,
|
||||
ListChecks,
|
||||
PackageSearch,
|
||||
PackageCheck,
|
||||
PlusCircle,
|
||||
ServerCog,
|
||||
ShieldCheck,
|
||||
@@ -71,16 +72,26 @@ export function AppSidebar() {
|
||||
},
|
||||
]
|
||||
: userNavItems;
|
||||
const visibleNavItems = can.administer
|
||||
const operationalNavItems = can.operateInventory
|
||||
? [
|
||||
...roleNavItems,
|
||||
{
|
||||
title: 'Operações',
|
||||
href: '/operacoes',
|
||||
icon: PackageCheck,
|
||||
},
|
||||
]
|
||||
: roleNavItems;
|
||||
const visibleNavItems = can.administer
|
||||
? [
|
||||
...operationalNavItems,
|
||||
{
|
||||
title: 'Integração Snipe-IT',
|
||||
href: '/administracao/snipe-it',
|
||||
icon: ServerCog,
|
||||
},
|
||||
]
|
||||
: roleNavItems;
|
||||
: operationalNavItems;
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon" variant="inset">
|
||||
|
||||
@@ -33,6 +33,11 @@ const statuses: Record<string, { label: string; className: string }> = {
|
||||
label: 'Devolução atrasada',
|
||||
className: 'bg-red-100 text-red-800',
|
||||
},
|
||||
no_show: {
|
||||
label: 'Não compareceu',
|
||||
className: 'bg-slate-100 text-slate-700',
|
||||
},
|
||||
incident: { label: 'Incidente', className: 'bg-red-100 text-red-800' },
|
||||
};
|
||||
|
||||
export function ReservationStatus({ status }: { status: string }) {
|
||||
|
||||
620
resources/js/pages/operations/index.tsx
Normal file
620
resources/js/pages/operations/index.tsx
Normal file
@@ -0,0 +1,620 @@
|
||||
import { Head, router, usePage } from '@inertiajs/react';
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
ClipboardCheck,
|
||||
PackageCheck,
|
||||
RotateCcw,
|
||||
ScanLine,
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import InputError from '@/components/input-error';
|
||||
import { PageHeading } from '@/components/page-heading';
|
||||
import { ReservationStatus } from '@/components/reservation-status';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
type HandoverRecord = {
|
||||
id: number;
|
||||
type: 'checkout' | 'checkin';
|
||||
condition: string | null;
|
||||
notes: string | null;
|
||||
created_at: string;
|
||||
operator: { name: string };
|
||||
};
|
||||
|
||||
type OperationReservation = {
|
||||
id: number;
|
||||
uuid: string;
|
||||
reference: string;
|
||||
purpose: string;
|
||||
usage_location: string;
|
||||
starts_at: string;
|
||||
ends_at: string;
|
||||
status: string;
|
||||
requester: {
|
||||
name: string;
|
||||
email: string;
|
||||
department: string | null;
|
||||
snipe_user_id: number | null;
|
||||
};
|
||||
items: {
|
||||
id: number;
|
||||
status: string;
|
||||
asset: {
|
||||
snipe_asset_id: number;
|
||||
name: string;
|
||||
asset_tag: string;
|
||||
status_name: string | null;
|
||||
location_name: string | null;
|
||||
};
|
||||
handover_records: HandoverRecord[];
|
||||
}[];
|
||||
};
|
||||
|
||||
type FormState = {
|
||||
snipeUserId: string;
|
||||
condition: string;
|
||||
notes: string;
|
||||
};
|
||||
|
||||
export default function OperationsIndex({
|
||||
reservations,
|
||||
operationallyConfigured,
|
||||
}: {
|
||||
reservations: OperationReservation[];
|
||||
operationallyConfigured: boolean;
|
||||
}) {
|
||||
const { errors } = usePage().props;
|
||||
const [forms, setForms] = useState<Record<number, FormState>>({});
|
||||
const [incidents, setIncidents] = useState<
|
||||
Record<number, { notes: string; blockedUntil: string }>
|
||||
>({});
|
||||
const [search, setSearch] = useState('');
|
||||
const normalizedSearch = search.trim().toLocaleLowerCase('pt-PT');
|
||||
const visibleReservations = reservations.filter((reservation) => {
|
||||
if (!normalizedSearch) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return [
|
||||
reservation.reference,
|
||||
reservation.requester.name,
|
||||
reservation.requester.email,
|
||||
...reservation.items.flatMap((item) => [
|
||||
item.asset.asset_tag,
|
||||
item.asset.snipe_asset_id.toString(),
|
||||
]),
|
||||
].some((value) =>
|
||||
value.toLocaleLowerCase('pt-PT').includes(normalizedSearch),
|
||||
);
|
||||
});
|
||||
|
||||
function formFor(reservation: OperationReservation): FormState {
|
||||
return (
|
||||
forms[reservation.id] ?? {
|
||||
snipeUserId:
|
||||
reservation.requester.snipe_user_id?.toString() ?? '',
|
||||
condition: 'Sem danos visíveis',
|
||||
notes: '',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function updateForm(
|
||||
reservation: OperationReservation,
|
||||
key: keyof FormState,
|
||||
value: string,
|
||||
) {
|
||||
setForms((current) => ({
|
||||
...current,
|
||||
[reservation.id]: { ...formFor(reservation), [key]: value },
|
||||
}));
|
||||
}
|
||||
|
||||
function post(
|
||||
reservation: OperationReservation,
|
||||
action: string,
|
||||
data = {},
|
||||
) {
|
||||
router.post(`/operacoes/${reservation.uuid}/${action}`, data, {
|
||||
preserveScroll: true,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Operações" />
|
||||
<main className="mx-auto flex w-full max-w-6xl flex-1 flex-col gap-7 p-5 sm:p-8">
|
||||
<PageHeading
|
||||
eyebrow="Área do operador"
|
||||
title="Entregas e devoluções"
|
||||
description="Fila diária com registo simultâneo no portal e no Snipe-IT. As ocorrências ficam associadas ao operador e ao equipamento."
|
||||
/>
|
||||
|
||||
<label className="relative block">
|
||||
<ScanLine className="absolute top-2.5 left-3 size-5 text-muted-foreground" />
|
||||
<Input
|
||||
autoFocus
|
||||
className="h-10 pl-11"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="Ler QR/etiqueta ou pesquisar referência, nome e email…"
|
||||
/>
|
||||
<span className="mt-1 block text-xs text-muted-foreground">
|
||||
Compatível com leitores QR/ código de barras que
|
||||
funcionam como teclado; também aceita o ID Snipe-IT.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{!operationallyConfigured && (
|
||||
<div className="flex gap-3 rounded-xl border border-amber-300 bg-amber-50 p-4 text-sm text-amber-900 dark:bg-amber-950/30 dark:text-amber-100">
|
||||
<AlertTriangle className="mt-0.5 size-5 shrink-0" />
|
||||
<div>
|
||||
<p className="font-semibold">
|
||||
Movimentos externos indisponíveis
|
||||
</p>
|
||||
<p>
|
||||
Configure SNIPE_IT_OPERATIONAL_TOKEN para ativar
|
||||
entregas e devoluções. A preparação continua
|
||||
disponível.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<InputError message={errors.operation} />
|
||||
|
||||
<section className="space-y-4">
|
||||
{visibleReservations.length === 0 && (
|
||||
<Card className="shadow-none">
|
||||
<CardContent className="flex flex-col items-center gap-3 py-10 text-center">
|
||||
<CheckCircle2 className="size-9 text-emerald-600" />
|
||||
<p className="font-medium">
|
||||
{search
|
||||
? 'Nenhuma operação corresponde à leitura ou pesquisa.'
|
||||
: 'Não há operações pendentes para hoje.'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{visibleReservations.map((reservation) => {
|
||||
const form = formFor(reservation);
|
||||
const canReturn =
|
||||
[
|
||||
'delivered',
|
||||
'in_use',
|
||||
'overdue',
|
||||
'incident',
|
||||
].includes(reservation.status) &&
|
||||
reservation.items.some(
|
||||
(item) => item.status === 'delivered',
|
||||
);
|
||||
const canDeliver = reservation.items.some(
|
||||
(item) => item.status === 'ready',
|
||||
);
|
||||
const canComplete =
|
||||
reservation.status === 'returned' ||
|
||||
(reservation.status === 'incident' &&
|
||||
reservation.items.every((item) =>
|
||||
['returned', 'cancelled'].includes(
|
||||
item.status,
|
||||
),
|
||||
));
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={reservation.id}
|
||||
className="gap-4 shadow-none"
|
||||
>
|
||||
<CardHeader className="flex-row items-start justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle className="font-mono text-base">
|
||||
{reservation.reference}
|
||||
</CardTitle>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{reservation.requester.name} ·{' '}
|
||||
{reservation.usage_location}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{formatPeriod(
|
||||
reservation.starts_at,
|
||||
reservation.ends_at,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<ReservationStatus
|
||||
status={reservation.status}
|
||||
/>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{reservation.items
|
||||
.filter(
|
||||
(item) =>
|
||||
item.status !== 'cancelled',
|
||||
)
|
||||
.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="rounded-lg border p-3 text-sm"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{
|
||||
item.asset
|
||||
.name
|
||||
}
|
||||
</p>
|
||||
<p className="font-mono text-xs text-muted-foreground">
|
||||
{
|
||||
item.asset
|
||||
.asset_tag
|
||||
}{' '}
|
||||
·{' '}
|
||||
{item.asset
|
||||
.location_name ??
|
||||
'Sem localização'}
|
||||
</p>
|
||||
</div>
|
||||
<span className="rounded bg-muted px-2 py-0.5 text-xs">
|
||||
{item.status}
|
||||
</span>
|
||||
</div>
|
||||
{item.handover_records
|
||||
.length > 0 && (
|
||||
<div className="mt-2 border-t pt-2 text-xs text-muted-foreground">
|
||||
{item.handover_records.map(
|
||||
(record) => (
|
||||
<p
|
||||
key={
|
||||
record.id
|
||||
}
|
||||
>
|
||||
{record.type ===
|
||||
'checkout'
|
||||
? 'Entregue'
|
||||
: 'Devolvido'}{' '}
|
||||
por{' '}
|
||||
{
|
||||
record
|
||||
.operator
|
||||
.name
|
||||
}{' '}
|
||||
·{' '}
|
||||
{
|
||||
record.condition
|
||||
}
|
||||
</p>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{[
|
||||
'ready',
|
||||
'delivered',
|
||||
'in_use',
|
||||
'overdue',
|
||||
'returned',
|
||||
].includes(
|
||||
reservation.status,
|
||||
) && (
|
||||
<IncidentForm
|
||||
itemId={item.id}
|
||||
value={
|
||||
incidents[
|
||||
item.id
|
||||
] ?? {
|
||||
notes: '',
|
||||
blockedUntil:
|
||||
'',
|
||||
}
|
||||
}
|
||||
onChange={(value) =>
|
||||
setIncidents(
|
||||
(
|
||||
current,
|
||||
) => ({
|
||||
...current,
|
||||
[item.id]:
|
||||
value,
|
||||
}),
|
||||
)
|
||||
}
|
||||
onSubmit={() => {
|
||||
const incident =
|
||||
incidents[
|
||||
item.id
|
||||
] ?? {
|
||||
notes: '',
|
||||
blockedUntil:
|
||||
'',
|
||||
};
|
||||
router.post(
|
||||
`/operacoes/${reservation.uuid}/itens/${item.id}/incidente`,
|
||||
{
|
||||
notes: incident.notes,
|
||||
blocked_until:
|
||||
incident.blockedUntil ||
|
||||
null,
|
||||
},
|
||||
{
|
||||
preserveScroll: true,
|
||||
},
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{reservation.status === 'approved' && (
|
||||
<Button
|
||||
onClick={() =>
|
||||
post(reservation, 'preparar')
|
||||
}
|
||||
>
|
||||
<ClipboardCheck /> Preparar
|
||||
equipamentos
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canDeliver && (
|
||||
<div className="grid gap-3 rounded-xl bg-muted/50 p-4 md:grid-cols-3">
|
||||
<label className="space-y-1 text-xs font-medium">
|
||||
ID do destinatário no Snipe-IT
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.snipeUserId}
|
||||
onChange={(event) =>
|
||||
updateForm(
|
||||
reservation,
|
||||
'snipeUserId',
|
||||
event.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1 text-xs font-medium">
|
||||
Estado à saída
|
||||
<Input
|
||||
value={form.condition}
|
||||
onChange={(event) =>
|
||||
updateForm(
|
||||
reservation,
|
||||
'condition',
|
||||
event.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1 text-xs font-medium">
|
||||
Observações
|
||||
<Input
|
||||
value={form.notes}
|
||||
onChange={(event) =>
|
||||
updateForm(
|
||||
reservation,
|
||||
'notes',
|
||||
event.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2 md:col-span-3">
|
||||
<Button
|
||||
disabled={
|
||||
!operationallyConfigured ||
|
||||
!form.snipeUserId
|
||||
}
|
||||
onClick={() =>
|
||||
post(
|
||||
reservation,
|
||||
'entregar',
|
||||
{
|
||||
snipe_user_id:
|
||||
Number(
|
||||
form.snipeUserId,
|
||||
),
|
||||
condition:
|
||||
form.condition,
|
||||
notes:
|
||||
form.notes ||
|
||||
null,
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
<PackageCheck /> Registar
|
||||
entrega
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={
|
||||
form.notes.trim()
|
||||
.length < 3
|
||||
}
|
||||
onClick={() =>
|
||||
post(
|
||||
reservation,
|
||||
'falta',
|
||||
{
|
||||
notes: form.notes,
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
Registar falta
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canReturn && (
|
||||
<div className="grid gap-3 rounded-xl bg-muted/50 p-4 md:grid-cols-2">
|
||||
<label className="space-y-1 text-xs font-medium">
|
||||
Estado à entrada
|
||||
<Input
|
||||
value={form.condition}
|
||||
onChange={(event) =>
|
||||
updateForm(
|
||||
reservation,
|
||||
'condition',
|
||||
event.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1 text-xs font-medium">
|
||||
Observações
|
||||
<Input
|
||||
value={form.notes}
|
||||
onChange={(event) =>
|
||||
updateForm(
|
||||
reservation,
|
||||
'notes',
|
||||
event.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2 md:col-span-2">
|
||||
<Button
|
||||
disabled={
|
||||
!operationallyConfigured
|
||||
}
|
||||
onClick={() =>
|
||||
post(
|
||||
reservation,
|
||||
'devolver',
|
||||
{
|
||||
condition:
|
||||
form.condition,
|
||||
notes:
|
||||
form.notes ||
|
||||
null,
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
<RotateCcw /> Registar
|
||||
devolução
|
||||
</Button>
|
||||
{[
|
||||
'delivered',
|
||||
'in_use',
|
||||
].includes(
|
||||
reservation.status,
|
||||
) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={
|
||||
form.notes.trim()
|
||||
.length < 3
|
||||
}
|
||||
onClick={() =>
|
||||
post(
|
||||
reservation,
|
||||
'atraso',
|
||||
{
|
||||
notes: form.notes,
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
Marcar atraso
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canComplete && (
|
||||
<Button
|
||||
onClick={() =>
|
||||
post(reservation, 'concluir')
|
||||
}
|
||||
>
|
||||
<CheckCircle2 /> Concluir processo
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function IncidentForm({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
}: {
|
||||
itemId: number;
|
||||
value: { notes: string; blockedUntil: string };
|
||||
onChange: (value: { notes: string; blockedUntil: string }) => void;
|
||||
onSubmit: () => void;
|
||||
}) {
|
||||
return (
|
||||
<details className="mt-3 border-t pt-2">
|
||||
<summary className="cursor-pointer text-xs font-medium text-amber-700">
|
||||
Registar incidente
|
||||
</summary>
|
||||
<div className="mt-2 space-y-2">
|
||||
<Input
|
||||
placeholder="Descrição do incidente"
|
||||
value={value.notes}
|
||||
onChange={(event) =>
|
||||
onChange({ ...value, notes: event.target.value })
|
||||
}
|
||||
/>
|
||||
<label className="block text-xs text-muted-foreground">
|
||||
Bloquear equipamento até (opcional)
|
||||
<Input
|
||||
className="mt-1"
|
||||
type="datetime-local"
|
||||
value={value.blockedUntil}
|
||||
onChange={(event) =>
|
||||
onChange({
|
||||
...value,
|
||||
blockedUntil: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={value.notes.trim().length < 5}
|
||||
onClick={onSubmit}
|
||||
>
|
||||
<AlertTriangle /> Registar
|
||||
</Button>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function formatPeriod(startsAt: string, endsAt: string) {
|
||||
const formatter = new Intl.DateTimeFormat('pt-PT', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
return `${formatter.format(new Date(startsAt))} — ${formatter.format(new Date(endsAt))}`;
|
||||
}
|
||||
|
||||
OperationsIndex.layout = {
|
||||
breadcrumbs: [{ title: 'Operações', href: '/operacoes' }],
|
||||
};
|
||||
1
resources/js/types/global.d.ts
vendored
1
resources/js/types/global.d.ts
vendored
@@ -19,6 +19,7 @@ declare module '@inertiajs/core' {
|
||||
can: {
|
||||
administer: boolean;
|
||||
approveReservations: boolean;
|
||||
operateInventory: boolean;
|
||||
};
|
||||
unreadNotifications: number;
|
||||
sidebarOpen: boolean;
|
||||
|
||||
@@ -5,6 +5,7 @@ use App\Http\Controllers\ApprovalController;
|
||||
use App\Http\Controllers\AssetController;
|
||||
use App\Http\Controllers\DashboardController;
|
||||
use App\Http\Controllers\NotificationController;
|
||||
use App\Http\Controllers\OperationController;
|
||||
use App\Http\Controllers\ReservationController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
@@ -31,6 +32,17 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
||||
Route::post('{reservation:uuid}/itens/{item}/substituir', [ApprovalController::class, 'substitute'])->name('substitute');
|
||||
});
|
||||
|
||||
Route::middleware('can:operate-inventory')->prefix('operacoes')->name('operations.')->group(function () {
|
||||
Route::get('/', [OperationController::class, 'index'])->name('index');
|
||||
Route::post('{reservation:uuid}/preparar', [OperationController::class, 'prepare'])->name('prepare');
|
||||
Route::post('{reservation:uuid}/entregar', [OperationController::class, 'deliver'])->name('deliver');
|
||||
Route::post('{reservation:uuid}/devolver', [OperationController::class, 'returnEquipment'])->name('return');
|
||||
Route::post('{reservation:uuid}/concluir', [OperationController::class, 'complete'])->name('complete');
|
||||
Route::post('{reservation:uuid}/falta', [OperationController::class, 'noShow'])->name('no-show');
|
||||
Route::post('{reservation:uuid}/atraso', [OperationController::class, 'overdue'])->name('overdue');
|
||||
Route::post('{reservation:uuid}/itens/{item}/incidente', [OperationController::class, 'incident'])->name('incident');
|
||||
});
|
||||
|
||||
Route::middleware('can:administer')->prefix('administracao')->name('admin.')->group(function () {
|
||||
Route::get('snipe-it', [SnipeItController::class, 'index'])->name('snipe-it.index');
|
||||
Route::post('snipe-it/testar', [SnipeItController::class, 'testConnection'])->name('snipe-it.test');
|
||||
|
||||
272
tests/Feature/OperationsWorkflowTest.php
Normal file
272
tests/Feature/OperationsWorkflowTest.php
Normal 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();
|
||||
});
|
||||
@@ -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
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user