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.
|
- 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.
|
- 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.
|
- 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
|
### 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.
|
- 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.
|
- 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.
|
- 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
|
### Segurança
|
||||||
|
|
||||||
- Registo público desativado.
|
- Registo público desativado.
|
||||||
- Variáveis para tokens Snipe-IT separados e credenciais Entra ID documentadas sem valores.
|
- 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
|
### 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.
|
- 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.
|
- 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.
|
- 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
|
## 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.
|
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
|
## Comandos frequentes
|
||||||
|
|
||||||
```bash
|
```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 lint
|
||||||
docker compose exec app composer types:check
|
docker compose exec app composer types:check
|
||||||
docker compose exec node npm run 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
|
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:
|
Para repor apenas dados de desenvolvimento:
|
||||||
|
|
||||||
```bash
|
```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::PendingApproval => [self::AwaitingInformation, self::Approved, self::Rejected, self::Cancelled, self::Expired],
|
||||||
self::AwaitingInformation => [self::PendingApproval, self::Rejected, self::Cancelled, self::Expired],
|
self::AwaitingInformation => [self::PendingApproval, self::Rejected, self::Cancelled, self::Expired],
|
||||||
self::Approved => [self::Ready, self::Cancelled],
|
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::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::Returned => [self::Completed, self::Incident],
|
||||||
self::Completed, self::Rejected, self::Cancelled, self::Expired, self::NoShow => [],
|
self::Completed, self::Rejected, self::Cancelled, self::Expired, self::NoShow => [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,10 +3,12 @@
|
|||||||
namespace App\Domain\Reservations\Models;
|
namespace App\Domain\Reservations\Models;
|
||||||
|
|
||||||
use App\Domain\Inventory\Models\SnipeAsset;
|
use App\Domain\Inventory\Models\SnipeAsset;
|
||||||
|
use App\Domain\Operations\Models\HandoverRecord;
|
||||||
use App\Domain\Reservations\Enums\ReservationItemStatus;
|
use App\Domain\Reservations\Enums\ReservationItemStatus;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @property ReservationItemStatus $status
|
* @property ReservationItemStatus $status
|
||||||
@@ -44,4 +46,10 @@ class ReservationItem extends Model
|
|||||||
{
|
{
|
||||||
return $this->belongsTo(SnipeAsset::class, 'snipe_asset_id');
|
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' => [
|
'can' => [
|
||||||
'administer' => fn (): bool => $request->user()?->can('administer') ?? false,
|
'administer' => fn (): bool => $request->user()?->can('administer') ?? false,
|
||||||
'approveReservations' => fn (): bool => $request->user()?->can('approve-reservations') ?? 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,
|
'unreadNotifications' => fn (): int => $request->user()?->unreadNotifications()->count() ?? 0,
|
||||||
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
|
'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\ConnectionException;
|
||||||
use Illuminate\Http\Client\PendingRequest;
|
use Illuminate\Http\Client\PendingRequest;
|
||||||
use Illuminate\Http\Client\RequestException;
|
use Illuminate\Http\Client\RequestException;
|
||||||
|
use Illuminate\Http\Client\Response;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
@@ -20,6 +21,11 @@ final class SnipeItClient
|
|||||||
return filled(config('services.snipe_it.url')) && filled(config('services.snipe_it.read_token'));
|
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
|
public function configuredUrl(): ?string
|
||||||
{
|
{
|
||||||
$url = trim((string) config('services.snipe_it.url'));
|
$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 = $limit ?? (int) config('services.snipe_it.page_size', 100);
|
||||||
$pageSize = max(1, min(500, $pageSize));
|
$pageSize = max(1, min(500, $pageSize));
|
||||||
|
|
||||||
$response = $this->request()->get('/hardware', [
|
$response = $this->readRequest()->get('/hardware', [
|
||||||
'limit' => $pageSize,
|
'limit' => $pageSize,
|
||||||
'offset' => max(0, $offset),
|
'offset' => max(0, $offset),
|
||||||
'sort' => 'id',
|
'sort' => 'id',
|
||||||
@@ -92,7 +98,61 @@ final class SnipeItClient
|
|||||||
} while ($received > 0 && $offset < $page->total);
|
} 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()) {
|
if (! $this->isConfigured()) {
|
||||||
throw new SnipeItConfigurationException('A URL e o token de leitura do Snipe-IT ainda não estão configurados.');
|
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
|
private function apiBaseUrl(): string
|
||||||
{
|
{
|
||||||
$baseUrl = $this->configuredUrl();
|
$baseUrl = $this->configuredUrl();
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ use Laravel\Fortify\TwoFactorAuthenticatable;
|
|||||||
* @property Carbon|null $created_at
|
* @property Carbon|null $created_at
|
||||||
* @property Carbon|null $updated_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'])]
|
#[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])]
|
||||||
class User extends Authenticatable implements PasskeyUser
|
class User extends Authenticatable implements PasskeyUser
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
fn (User $user): bool => $user->active
|
fn (User $user): bool => $user->active
|
||||||
&& ($user->hasRole(RoleName::Approver) || $user->hasRole(RoleName::Administrator)),
|
&& ($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/Inventory`: cache local de ativos e consultas do catálogo.
|
||||||
- `app/Domain/Reservations`: disponibilidade, estados, regras e requisições.
|
- `app/Domain/Reservations`: disponibilidade, estados, regras e requisições.
|
||||||
- `app/Domain/Approvals`: decisões, histórico e concorrência de aprovação.
|
- `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/Domain/Audit`: logs imutáveis pela interface.
|
||||||
- `app/Integrations/SnipeIt`: cliente HTTP, DTOs, mapper e exceções da API Snipe-IT.
|
- `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.
|
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.
|
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.
|
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
|
## 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
|
## ADR-002 — PostgreSQL como garantia de conflitos
|
||||||
|
|
||||||
**Data:** 2026-08-04
|
**Data:** 2026-08-04
|
||||||
**Estado:** aceite
|
**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.
|
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
|
**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.
|
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
|
# Estado do projeto e handoff
|
||||||
|
|
||||||
**Atualizado em:** 4 de agosto de 2026
|
**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
|
**Último agente:** Codex
|
||||||
|
|
||||||
## Resultado disponível
|
## Resultado disponível
|
||||||
@@ -34,44 +34,51 @@
|
|||||||
- Resposta auditada do requisitante a esclarecimentos, com regresso a `pending_approval` e novo prazo de 48 horas.
|
- 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.
|
- 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.
|
- 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
|
## 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.
|
- 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.
|
- 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 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 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
|
## 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;
|
1. criar a área administrativa de utilizadores, perfis e associação ao ID Snipe-IT;
|
||||||
2. criar `app/Domain/Handovers/Services/HandoverService.php` com entrega e devolução transacionais;
|
2. tornar configuráveis as políticas de antecedência, duração, margens, estados e categorias;
|
||||||
3. criar área do operador para reservas do dia, pesquisa por referência/utilizador e confirmação do estado físico;
|
3. criar calendário global e relatórios de utilização, atrasos, incidentes e ativos mais requisitados;
|
||||||
4. tratar falhas parciais da API Snipe-IT sem declarar entrega/devolução concluída;
|
4. adicionar exportação CSV/XLSX e filtros por período, categoria, departamento e estado;
|
||||||
5. implementar incidentes, atrasos e bloqueios associados a dano;
|
5. criar consulta de auditoria apenas de leitura para o perfil `auditor`;
|
||||||
6. testar API simulada, autorização, idempotência e auditoria;
|
6. adicionar métricas operacionais e gestão de modelos de email;
|
||||||
7. atualizar este documento e o changelog.
|
7. cobrir autorização, exportações e agregações com testes e atualizar toda a documentação.
|
||||||
|
|
||||||
## Testes da última sessão
|
## Testes da última sessão
|
||||||
|
|
||||||
Validação concluída em 4 de agosto de 2026:
|
Validação concluída em 4 de agosto de 2026:
|
||||||
|
|
||||||
- `docker compose exec -T app composer test`: passou;
|
- `docker compose exec -T app composer test`: passou;
|
||||||
- Laravel Pint: 111 ficheiros sem desvios;
|
- Laravel Pint: 117 ficheiros sem desvios;
|
||||||
- PHPStan/Larastan nível 7: 84 ficheiros, zero erros;
|
- PHPStan/Larastan nível 7: 89 ficheiros, zero erros;
|
||||||
- Pest: 70 testes passaram, 340 asserções e 2 testes de registo ignorados por a funcionalidade estar deliberadamente desativada;
|
- Pest: 79 testes passaram, 412 asserções e 2 testes de registo ignorados por a funcionalidade estar deliberadamente desativada;
|
||||||
- TypeScript (`tsc --noEmit`): passou;
|
- TypeScript (`tsc --noEmit`): passou;
|
||||||
- ESLint: passou;
|
- ESLint: passou;
|
||||||
- Prettier: 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: `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: 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: 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 `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;
|
- 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`;
|
- 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;
|
- 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
|
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.
|
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:
|
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
|
```dotenv
|
||||||
SNIPE_IT_URL=https://inventario.exemplo.pt
|
SNIPE_IT_URL=https://inventario.exemplo.pt
|
||||||
SNIPE_IT_READ_TOKEN=
|
SNIPE_IT_READ_TOKEN=
|
||||||
|
SNIPE_IT_OPERATIONAL_TOKEN=
|
||||||
SNIPE_IT_TIMEOUT=10
|
SNIPE_IT_TIMEOUT=10
|
||||||
SNIPE_IT_PAGE_SIZE=100
|
SNIPE_IT_PAGE_SIZE=100
|
||||||
SNIPE_IT_RETRIES=3
|
SNIPE_IT_RETRIES=3
|
||||||
SNIPE_IT_RETRY_DELAY_MS=250
|
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:
|
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.
|
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
|
## 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:
|
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,
|
LayoutGrid,
|
||||||
ListChecks,
|
ListChecks,
|
||||||
PackageSearch,
|
PackageSearch,
|
||||||
|
PackageCheck,
|
||||||
PlusCircle,
|
PlusCircle,
|
||||||
ServerCog,
|
ServerCog,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
@@ -71,16 +72,26 @@ export function AppSidebar() {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
: userNavItems;
|
: userNavItems;
|
||||||
const visibleNavItems = can.administer
|
const operationalNavItems = can.operateInventory
|
||||||
? [
|
? [
|
||||||
...roleNavItems,
|
...roleNavItems,
|
||||||
|
{
|
||||||
|
title: 'Operações',
|
||||||
|
href: '/operacoes',
|
||||||
|
icon: PackageCheck,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: roleNavItems;
|
||||||
|
const visibleNavItems = can.administer
|
||||||
|
? [
|
||||||
|
...operationalNavItems,
|
||||||
{
|
{
|
||||||
title: 'Integração Snipe-IT',
|
title: 'Integração Snipe-IT',
|
||||||
href: '/administracao/snipe-it',
|
href: '/administracao/snipe-it',
|
||||||
icon: ServerCog,
|
icon: ServerCog,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: roleNavItems;
|
: operationalNavItems;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar collapsible="icon" variant="inset">
|
<Sidebar collapsible="icon" variant="inset">
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ const statuses: Record<string, { label: string; className: string }> = {
|
|||||||
label: 'Devolução atrasada',
|
label: 'Devolução atrasada',
|
||||||
className: 'bg-red-100 text-red-800',
|
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 }) {
|
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: {
|
can: {
|
||||||
administer: boolean;
|
administer: boolean;
|
||||||
approveReservations: boolean;
|
approveReservations: boolean;
|
||||||
|
operateInventory: boolean;
|
||||||
};
|
};
|
||||||
unreadNotifications: number;
|
unreadNotifications: number;
|
||||||
sidebarOpen: boolean;
|
sidebarOpen: boolean;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use App\Http\Controllers\ApprovalController;
|
|||||||
use App\Http\Controllers\AssetController;
|
use App\Http\Controllers\AssetController;
|
||||||
use App\Http\Controllers\DashboardController;
|
use App\Http\Controllers\DashboardController;
|
||||||
use App\Http\Controllers\NotificationController;
|
use App\Http\Controllers\NotificationController;
|
||||||
|
use App\Http\Controllers\OperationController;
|
||||||
use App\Http\Controllers\ReservationController;
|
use App\Http\Controllers\ReservationController;
|
||||||
use Illuminate\Support\Facades\Route;
|
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::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::middleware('can:administer')->prefix('administracao')->name('admin.')->group(function () {
|
||||||
Route::get('snipe-it', [SnipeItController::class, 'index'])->name('snipe-it.index');
|
Route::get('snipe-it', [SnipeItController::class, 'index'])->name('snipe-it.index');
|
||||||
Route::post('snipe-it/testar', [SnipeItController::class, 'testConnection'])->name('snipe-it.test');
|
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', [
|
config()->set('services.snipe_it', [
|
||||||
'url' => 'https://inventory.example.test',
|
'url' => 'https://inventory.example.test',
|
||||||
'read_token' => 'read-token-for-tests',
|
'read_token' => 'read-token-for-tests',
|
||||||
|
'operational_token' => 'operational-token-for-tests',
|
||||||
'timeout' => 2,
|
'timeout' => 2,
|
||||||
'page_size' => 2,
|
'page_size' => 2,
|
||||||
'retries' => 1,
|
'retries' => 1,
|
||||||
@@ -61,6 +62,56 @@ it('recusa erros transportados num HTTP 200', function () {
|
|||||||
->toThrow(SnipeItApiException::class, 'recusou a consulta');
|
->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> */
|
/** @return array<string, mixed> */
|
||||||
function snipeClientRow(int $id): array
|
function snipeClientRow(int $id): array
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user