feat: complete equipment handover workflow
This commit is contained in:
9
app/Domain/Operations/Enums/HandoverType.php
Normal file
9
app/Domain/Operations/Enums/HandoverType.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Operations\Enums;
|
||||
|
||||
enum HandoverType: string
|
||||
{
|
||||
case Checkout = 'checkout';
|
||||
case Checkin = 'checkin';
|
||||
}
|
||||
46
app/Domain/Operations/Models/HandoverRecord.php
Normal file
46
app/Domain/Operations/Models/HandoverRecord.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Operations\Models;
|
||||
|
||||
use App\Domain\Operations\Enums\HandoverType;
|
||||
use App\Domain\Reservations\Models\ReservationItem;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class HandoverRecord extends Model
|
||||
{
|
||||
public const UPDATED_AT = null;
|
||||
|
||||
protected $fillable = [
|
||||
'reservation_item_id', 'type', 'operator_id', 'recipient_id',
|
||||
'condition', 'notes', 'snipe_response', 'created_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'type' => HandoverType::class,
|
||||
'snipe_response' => 'array',
|
||||
'created_at' => 'immutable_datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/** @return BelongsTo<ReservationItem, $this> */
|
||||
public function reservationItem(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ReservationItem::class);
|
||||
}
|
||||
|
||||
/** @return BelongsTo<User, $this> */
|
||||
public function operator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'operator_id');
|
||||
}
|
||||
|
||||
/** @return BelongsTo<User, $this> */
|
||||
public function recipient(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'recipient_id');
|
||||
}
|
||||
}
|
||||
401
app/Domain/Operations/Services/HandoverService.php
Normal file
401
app/Domain/Operations/Services/HandoverService.php
Normal file
@@ -0,0 +1,401 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Operations\Services;
|
||||
|
||||
use App\Domain\Audit\Models\AuditLog;
|
||||
use App\Domain\Identity\Enums\RoleName;
|
||||
use App\Domain\Operations\Enums\HandoverType;
|
||||
use App\Domain\Operations\Models\HandoverRecord;
|
||||
use App\Domain\Reservations\Enums\ReservationItemStatus;
|
||||
use App\Domain\Reservations\Enums\ReservationStatus;
|
||||
use App\Domain\Reservations\Models\AssetBlock;
|
||||
use App\Domain\Reservations\Models\Reservation;
|
||||
use App\Domain\Reservations\Models\ReservationItem;
|
||||
use App\Integrations\SnipeIt\SnipeItClient;
|
||||
use App\Models\User;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Throwable;
|
||||
|
||||
final class HandoverService
|
||||
{
|
||||
public function __construct(private readonly SnipeItClient $snipeIt) {}
|
||||
|
||||
public function markReady(Reservation $reservation, User $operator): Reservation
|
||||
{
|
||||
$this->authorizeOperator($operator);
|
||||
|
||||
return DB::transaction(function () use ($reservation, $operator): Reservation {
|
||||
$locked = Reservation::query()->whereKey($reservation->id)->lockForUpdate()->firstOrFail();
|
||||
$this->assertTransition($locked, ReservationStatus::Ready, 'A requisição não pode ser preparada neste estado.');
|
||||
|
||||
$locked->update(['status' => ReservationStatus::Ready]);
|
||||
$locked->items()->where('status', ReservationItemStatus::Reserved)->update([
|
||||
'status' => ReservationItemStatus::Ready,
|
||||
]);
|
||||
$this->audit($locked, $operator, 'reservation.ready', ReservationStatus::Approved, ReservationStatus::Ready);
|
||||
|
||||
return $locked->load('requester', 'items.asset');
|
||||
}, 3);
|
||||
}
|
||||
|
||||
public function deliver(
|
||||
Reservation $reservation,
|
||||
User $operator,
|
||||
int $snipeUserId,
|
||||
string $condition,
|
||||
?string $notes = null,
|
||||
): Reservation {
|
||||
$this->authorizeOperator($operator);
|
||||
$this->assertOperationalConfiguration();
|
||||
|
||||
$reservation = Reservation::query()
|
||||
->with(['requester', 'items.asset'])
|
||||
->findOrFail($reservation->id);
|
||||
|
||||
if (! in_array($reservation->status, [ReservationStatus::Ready, ReservationStatus::Incident], true)) {
|
||||
throw ValidationException::withMessages(['operation' => 'Apenas requisições preparadas podem ser entregues.']);
|
||||
}
|
||||
|
||||
$items = $reservation->items->where('status', ReservationItemStatus::Ready);
|
||||
if ($items->isEmpty()) {
|
||||
throw ValidationException::withMessages(['operation' => 'A requisição não tem equipamentos preparados para entrega.']);
|
||||
}
|
||||
|
||||
$completed = [];
|
||||
foreach ($items as $item) {
|
||||
try {
|
||||
$asset = $item->asset;
|
||||
if ($asset->status_id === null) {
|
||||
throw ValidationException::withMessages(['operation' => "O equipamento {$asset->asset_tag} não tem estado Snipe-IT sincronizado."]);
|
||||
}
|
||||
|
||||
$response = $this->snipeIt->checkoutHardware(
|
||||
(int) $asset->snipe_asset_id,
|
||||
(int) $asset->status_id,
|
||||
$snipeUserId,
|
||||
$reservation->ends_at->toDateString(),
|
||||
$this->externalNote($reservation, $notes),
|
||||
);
|
||||
|
||||
DB::transaction(function () use ($item, $reservation, $operator, $condition, $notes, $response): void {
|
||||
$lockedItem = ReservationItem::query()->whereKey($item->id)->lockForUpdate()->firstOrFail();
|
||||
if ($lockedItem->status !== ReservationItemStatus::Ready) {
|
||||
throw ValidationException::withMessages(['operation' => 'O estado local do equipamento mudou durante a entrega.']);
|
||||
}
|
||||
|
||||
$lockedItem->update([
|
||||
'status' => ReservationItemStatus::Delivered,
|
||||
'delivered_at' => now(),
|
||||
'checkout_reference' => 'snipe-hardware:'.$lockedItem->asset->snipe_asset_id,
|
||||
'condition_out' => $condition,
|
||||
'notes' => $notes,
|
||||
]);
|
||||
HandoverRecord::create([
|
||||
'reservation_item_id' => $lockedItem->id,
|
||||
'type' => HandoverType::Checkout,
|
||||
'operator_id' => $operator->id,
|
||||
'recipient_id' => $reservation->requester_id,
|
||||
'condition' => $condition,
|
||||
'notes' => $notes,
|
||||
'snipe_response' => $response,
|
||||
]);
|
||||
}, 3);
|
||||
$completed[] = $item->id;
|
||||
} catch (Throwable $exception) {
|
||||
$this->recordPartialFailure($reservation, $operator, 'delivery', $completed, $item, $exception);
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'operation' => "A entrega parou no equipamento {$item->asset->asset_tag}. ".$exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($reservation, $operator, $snipeUserId, $completed): Reservation {
|
||||
$locked = Reservation::query()->whereKey($reservation->id)->lockForUpdate()->firstOrFail();
|
||||
if ($locked->items()->where('status', ReservationItemStatus::Ready)->exists()) {
|
||||
throw ValidationException::withMessages(['operation' => 'Nem todos os equipamentos ficaram entregues.']);
|
||||
}
|
||||
|
||||
$previous = $locked->status;
|
||||
$locked->requester()->update(['snipe_user_id' => $snipeUserId]);
|
||||
$locked->update(['status' => ReservationStatus::Delivered]);
|
||||
$this->audit(
|
||||
$locked,
|
||||
$operator,
|
||||
'reservation.delivered',
|
||||
$previous,
|
||||
ReservationStatus::Delivered,
|
||||
['item_ids' => $completed, 'snipe_user_id' => $snipeUserId],
|
||||
);
|
||||
|
||||
return $locked->load('requester', 'items.asset', 'items.handoverRecords');
|
||||
}, 3);
|
||||
}
|
||||
|
||||
public function returnEquipment(
|
||||
Reservation $reservation,
|
||||
User $operator,
|
||||
string $condition,
|
||||
?string $notes = null,
|
||||
): Reservation {
|
||||
$this->authorizeOperator($operator);
|
||||
$this->assertOperationalConfiguration();
|
||||
|
||||
$reservation = Reservation::query()->with('items.asset')->findOrFail($reservation->id);
|
||||
if (! in_array($reservation->status, [
|
||||
ReservationStatus::Delivered,
|
||||
ReservationStatus::InUse,
|
||||
ReservationStatus::Overdue,
|
||||
ReservationStatus::Incident,
|
||||
], true)) {
|
||||
throw ValidationException::withMessages(['operation' => 'Esta requisição não está em condições de ser devolvida.']);
|
||||
}
|
||||
|
||||
$items = $reservation->items->where('status', ReservationItemStatus::Delivered);
|
||||
if ($items->isEmpty()) {
|
||||
throw ValidationException::withMessages(['operation' => 'Não existem equipamentos entregues por devolver.']);
|
||||
}
|
||||
|
||||
$completed = [];
|
||||
foreach ($items as $item) {
|
||||
try {
|
||||
$asset = $item->asset;
|
||||
if ($asset->status_id === null) {
|
||||
throw ValidationException::withMessages(['operation' => "O equipamento {$asset->asset_tag} não tem estado Snipe-IT sincronizado."]);
|
||||
}
|
||||
|
||||
$response = $this->snipeIt->checkinHardware(
|
||||
(int) $asset->snipe_asset_id,
|
||||
(int) $asset->status_id,
|
||||
$asset->location_id === null ? null : (int) $asset->location_id,
|
||||
$this->externalNote($reservation, $notes),
|
||||
);
|
||||
|
||||
DB::transaction(function () use ($item, $operator, $condition, $notes, $response): void {
|
||||
$lockedItem = ReservationItem::query()->whereKey($item->id)->lockForUpdate()->firstOrFail();
|
||||
if ($lockedItem->status !== ReservationItemStatus::Delivered) {
|
||||
throw ValidationException::withMessages(['operation' => 'O estado local do equipamento mudou durante a devolução.']);
|
||||
}
|
||||
|
||||
$lockedItem->update([
|
||||
'status' => ReservationItemStatus::Returned,
|
||||
'returned_at' => now(),
|
||||
'checkin_reference' => 'snipe-hardware:'.$lockedItem->asset->snipe_asset_id,
|
||||
'condition_in' => $condition,
|
||||
'notes' => $notes,
|
||||
]);
|
||||
HandoverRecord::create([
|
||||
'reservation_item_id' => $lockedItem->id,
|
||||
'type' => HandoverType::Checkin,
|
||||
'operator_id' => $operator->id,
|
||||
'recipient_id' => null,
|
||||
'condition' => $condition,
|
||||
'notes' => $notes,
|
||||
'snipe_response' => $response,
|
||||
]);
|
||||
}, 3);
|
||||
$completed[] = $item->id;
|
||||
} catch (Throwable $exception) {
|
||||
$this->recordPartialFailure($reservation, $operator, 'return', $completed, $item, $exception);
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'operation' => "A devolução parou no equipamento {$item->asset->asset_tag}. ".$exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($reservation, $operator, $completed): Reservation {
|
||||
$locked = Reservation::query()->whereKey($reservation->id)->lockForUpdate()->firstOrFail();
|
||||
$previous = $locked->status;
|
||||
if ($locked->items()->where('status', ReservationItemStatus::Delivered)->exists()) {
|
||||
throw ValidationException::withMessages(['operation' => 'Nem todos os equipamentos ficaram devolvidos.']);
|
||||
}
|
||||
|
||||
if ($previous === ReservationStatus::Incident) {
|
||||
$locked->items()->where('status', ReservationItemStatus::Ready)->update([
|
||||
'status' => ReservationItemStatus::Cancelled,
|
||||
]);
|
||||
}
|
||||
|
||||
$locked->update(['status' => ReservationStatus::Returned]);
|
||||
$this->audit($locked, $operator, 'reservation.returned', $previous, ReservationStatus::Returned, ['item_ids' => $completed]);
|
||||
|
||||
return $locked->load('requester', 'items.asset', 'items.handoverRecords');
|
||||
}, 3);
|
||||
}
|
||||
|
||||
public function complete(Reservation $reservation, User $operator): Reservation
|
||||
{
|
||||
$this->authorizeOperator($operator);
|
||||
|
||||
return DB::transaction(function () use ($reservation, $operator): Reservation {
|
||||
$locked = Reservation::query()->whereKey($reservation->id)->lockForUpdate()->firstOrFail();
|
||||
$previous = $locked->status;
|
||||
$this->assertTransition($locked, ReservationStatus::Completed, 'Apenas requisições devolvidas podem ser concluídas.');
|
||||
if ($locked->items()->whereNotIn('status', [
|
||||
ReservationItemStatus::Returned,
|
||||
ReservationItemStatus::Cancelled,
|
||||
])->exists()) {
|
||||
throw ValidationException::withMessages(['operation' => 'Ainda existem equipamentos por devolver ou reconciliar.']);
|
||||
}
|
||||
$locked->update(['status' => ReservationStatus::Completed]);
|
||||
$this->audit($locked, $operator, 'reservation.completed', $previous, ReservationStatus::Completed);
|
||||
|
||||
return $locked->load('requester', 'items.asset');
|
||||
}, 3);
|
||||
}
|
||||
|
||||
public function markNoShow(Reservation $reservation, User $operator, string $notes): Reservation
|
||||
{
|
||||
$this->authorizeOperator($operator);
|
||||
|
||||
return DB::transaction(function () use ($reservation, $operator, $notes): Reservation {
|
||||
$locked = Reservation::query()->whereKey($reservation->id)->lockForUpdate()->firstOrFail();
|
||||
$this->assertTransition($locked, ReservationStatus::NoShow, 'A requisição não pode ser marcada como falta neste estado.');
|
||||
$locked->update(['status' => ReservationStatus::NoShow]);
|
||||
$locked->items()->where('status', ReservationItemStatus::Ready)->update(['status' => ReservationItemStatus::Cancelled]);
|
||||
$this->audit($locked, $operator, 'reservation.no_show', ReservationStatus::Ready, ReservationStatus::NoShow, ['notes' => $notes]);
|
||||
|
||||
return $locked->load('requester', 'items.asset');
|
||||
}, 3);
|
||||
}
|
||||
|
||||
public function markOverdue(Reservation $reservation, User $operator, string $notes): Reservation
|
||||
{
|
||||
$this->authorizeOperator($operator);
|
||||
|
||||
return DB::transaction(function () use ($reservation, $operator, $notes): Reservation {
|
||||
$locked = Reservation::query()->whereKey($reservation->id)->lockForUpdate()->firstOrFail();
|
||||
$previous = $locked->status;
|
||||
$this->assertTransition($locked, ReservationStatus::Overdue, 'A requisição não pode ser marcada como atrasada neste estado.');
|
||||
$locked->update(['status' => ReservationStatus::Overdue]);
|
||||
$this->audit($locked, $operator, 'reservation.overdue', $previous, ReservationStatus::Overdue, ['notes' => $notes]);
|
||||
|
||||
return $locked->load('requester', 'items.asset');
|
||||
}, 3);
|
||||
}
|
||||
|
||||
public function reportIncident(
|
||||
Reservation $reservation,
|
||||
ReservationItem $item,
|
||||
User $operator,
|
||||
string $notes,
|
||||
?CarbonImmutable $blockedUntil = null,
|
||||
): Reservation {
|
||||
$this->authorizeOperator($operator);
|
||||
|
||||
return DB::transaction(function () use ($reservation, $item, $operator, $notes, $blockedUntil): Reservation {
|
||||
$locked = Reservation::query()->whereKey($reservation->id)->lockForUpdate()->firstOrFail();
|
||||
if ($item->reservation_id !== $locked->id) {
|
||||
throw ValidationException::withMessages(['operation' => 'O equipamento não pertence a esta requisição.']);
|
||||
}
|
||||
|
||||
$previous = $locked->status;
|
||||
if ($previous !== ReservationStatus::Incident) {
|
||||
$this->assertTransition($locked, ReservationStatus::Incident, 'Não é possível registar um incidente neste estado.');
|
||||
$locked->update(['status' => ReservationStatus::Incident]);
|
||||
}
|
||||
|
||||
if ($blockedUntil !== null) {
|
||||
if ($blockedUntil->lessThanOrEqualTo(now())) {
|
||||
throw ValidationException::withMessages(['blocked_until' => 'O bloqueio tem de terminar no futuro.']);
|
||||
}
|
||||
AssetBlock::create([
|
||||
'snipe_asset_id' => $item->snipe_asset_id,
|
||||
'starts_at' => now(),
|
||||
'ends_at' => $blockedUntil,
|
||||
'reason' => $notes,
|
||||
'created_by' => $operator->id,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->audit($locked, $operator, 'reservation.incident_reported', $previous, ReservationStatus::Incident, [
|
||||
'reservation_item_id' => $item->id,
|
||||
'notes' => $notes,
|
||||
'blocked_until' => $blockedUntil?->toIso8601String(),
|
||||
]);
|
||||
|
||||
return $locked->load('requester', 'items.asset');
|
||||
}, 3);
|
||||
}
|
||||
|
||||
/** @param list<int> $completed */
|
||||
private function recordPartialFailure(
|
||||
Reservation $reservation,
|
||||
User $operator,
|
||||
string $operation,
|
||||
array $completed,
|
||||
ReservationItem $failedItem,
|
||||
Throwable $exception,
|
||||
): void {
|
||||
DB::transaction(function () use ($reservation, $operator, $operation, $completed, $failedItem, $exception): void {
|
||||
$locked = Reservation::query()->whereKey($reservation->id)->lockForUpdate()->firstOrFail();
|
||||
$previous = $locked->status;
|
||||
if ($completed !== [] && $previous !== ReservationStatus::Incident && $previous->canTransitionTo(ReservationStatus::Incident)) {
|
||||
$locked->update(['status' => ReservationStatus::Incident]);
|
||||
}
|
||||
AuditLog::create([
|
||||
'actor_id' => $operator->id,
|
||||
'event' => "reservation.{$operation}_failed",
|
||||
'auditable_type' => $locked->getMorphClass(),
|
||||
'auditable_id' => $locked->id,
|
||||
'before' => ['status' => $previous->value],
|
||||
'after' => ['status' => $locked->fresh()->status->value],
|
||||
'metadata' => [
|
||||
'completed_item_ids' => $completed,
|
||||
'failed_item_id' => $failedItem->id,
|
||||
'error' => $exception->getMessage(),
|
||||
],
|
||||
]);
|
||||
}, 3);
|
||||
}
|
||||
|
||||
private function assertOperationalConfiguration(): void
|
||||
{
|
||||
if (! $this->snipeIt->isOperationallyConfigured()) {
|
||||
throw ValidationException::withMessages(['operation' => 'Configure o token operacional do Snipe-IT antes de registar movimentos.']);
|
||||
}
|
||||
}
|
||||
|
||||
private function authorizeOperator(User $operator): void
|
||||
{
|
||||
if (! $operator->active || (! $operator->hasRole(RoleName::InventoryOperator) && ! $operator->hasRole(RoleName::Administrator))) {
|
||||
throw new AuthorizationException('Esta operação exige o perfil de operador de inventário.');
|
||||
}
|
||||
}
|
||||
|
||||
private function assertTransition(Reservation $reservation, ReservationStatus $next, string $message): void
|
||||
{
|
||||
if (! $reservation->status->canTransitionTo($next)) {
|
||||
throw ValidationException::withMessages(['operation' => $message]);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $metadata */
|
||||
private function audit(
|
||||
Reservation $reservation,
|
||||
User $operator,
|
||||
string $event,
|
||||
ReservationStatus $before,
|
||||
ReservationStatus $after,
|
||||
array $metadata = [],
|
||||
): void {
|
||||
AuditLog::create([
|
||||
'actor_id' => $operator->id,
|
||||
'event' => $event,
|
||||
'auditable_type' => $reservation->getMorphClass(),
|
||||
'auditable_id' => $reservation->id,
|
||||
'before' => ['status' => $before->value],
|
||||
'after' => ['status' => $after->value],
|
||||
'metadata' => $metadata,
|
||||
]);
|
||||
}
|
||||
|
||||
private function externalNote(Reservation $reservation, ?string $notes): string
|
||||
{
|
||||
return trim("Portal {$reservation->reference}".($notes === null ? '' : ": {$notes}"));
|
||||
}
|
||||
}
|
||||
@@ -28,9 +28,11 @@ enum ReservationStatus: string
|
||||
self::PendingApproval => [self::AwaitingInformation, self::Approved, self::Rejected, self::Cancelled, self::Expired],
|
||||
self::AwaitingInformation => [self::PendingApproval, self::Rejected, self::Cancelled, self::Expired],
|
||||
self::Approved => [self::Ready, self::Cancelled],
|
||||
self::Ready => [self::Delivered, self::NoShow, self::Cancelled],
|
||||
self::Ready => [self::Delivered, self::Incident, self::NoShow, self::Cancelled],
|
||||
self::Delivered => [self::InUse, self::Returned, self::Overdue, self::Incident],
|
||||
self::InUse, self::Overdue, self::Incident => [self::Returned],
|
||||
self::InUse => [self::Returned, self::Overdue, self::Incident],
|
||||
self::Overdue => [self::Returned, self::Incident],
|
||||
self::Incident => [self::Delivered, self::Returned, self::Completed],
|
||||
self::Returned => [self::Completed, self::Incident],
|
||||
self::Completed, self::Rejected, self::Cancelled, self::Expired, self::NoShow => [],
|
||||
};
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
namespace App\Domain\Reservations\Models;
|
||||
|
||||
use App\Domain\Inventory\Models\SnipeAsset;
|
||||
use App\Domain\Operations\Models\HandoverRecord;
|
||||
use App\Domain\Reservations\Enums\ReservationItemStatus;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* @property ReservationItemStatus $status
|
||||
@@ -44,4 +46,10 @@ class ReservationItem extends Model
|
||||
{
|
||||
return $this->belongsTo(SnipeAsset::class, 'snipe_asset_id');
|
||||
}
|
||||
|
||||
/** @return HasMany<HandoverRecord, $this> */
|
||||
public function handoverRecords(): HasMany
|
||||
{
|
||||
return $this->hasMany(HandoverRecord::class);
|
||||
}
|
||||
}
|
||||
|
||||
138
app/Http/Controllers/OperationController.php
Normal file
138
app/Http/Controllers/OperationController.php
Normal file
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Domain\Operations\Services\HandoverService;
|
||||
use App\Domain\Reservations\Enums\ReservationStatus;
|
||||
use App\Domain\Reservations\Models\Reservation;
|
||||
use App\Domain\Reservations\Models\ReservationItem;
|
||||
use App\Integrations\SnipeIt\SnipeItClient;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class OperationController extends Controller
|
||||
{
|
||||
public function index(Request $request, SnipeItClient $snipeIt): Response
|
||||
{
|
||||
$start = now()->startOfDay();
|
||||
$end = now()->addDay()->startOfDay();
|
||||
|
||||
return Inertia::render('operations/index', [
|
||||
'operationallyConfigured' => $snipeIt->isOperationallyConfigured(),
|
||||
'reservations' => Reservation::query()
|
||||
->with([
|
||||
'requester:id,name,email,department,snipe_user_id',
|
||||
'items.asset:id,snipe_asset_id,name,asset_tag,status_id,status_name,location_id,location_name',
|
||||
'items.handoverRecords:id,reservation_item_id,type,operator_id,condition,notes,created_at',
|
||||
'items.handoverRecords.operator:id,name',
|
||||
])
|
||||
->whereIn('status', [
|
||||
ReservationStatus::Approved,
|
||||
ReservationStatus::Ready,
|
||||
ReservationStatus::Delivered,
|
||||
ReservationStatus::InUse,
|
||||
ReservationStatus::Returned,
|
||||
ReservationStatus::Overdue,
|
||||
ReservationStatus::Incident,
|
||||
])
|
||||
->where(function ($query) use ($start, $end) {
|
||||
$query->where(function ($query) use ($start, $end) {
|
||||
$query->where('starts_at', '<', $end)->where('ends_at', '>', $start);
|
||||
})->orWhereIn('status', [
|
||||
ReservationStatus::Delivered,
|
||||
ReservationStatus::InUse,
|
||||
ReservationStatus::Returned,
|
||||
ReservationStatus::Overdue,
|
||||
ReservationStatus::Incident,
|
||||
]);
|
||||
})
|
||||
->orderByRaw("CASE WHEN status IN ('overdue', 'incident') THEN 0 ELSE 1 END")
|
||||
->orderBy('starts_at')
|
||||
->limit(50)
|
||||
->get(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function prepare(Request $request, Reservation $reservation, HandoverService $service): RedirectResponse
|
||||
{
|
||||
$service->markReady($reservation, $request->user());
|
||||
|
||||
return back()->with('success', "Requisição {$reservation->reference} preparada.");
|
||||
}
|
||||
|
||||
public function deliver(Request $request, Reservation $reservation, HandoverService $service): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'snipe_user_id' => ['required', 'integer', 'min:1'],
|
||||
'condition' => ['required', 'string', 'min:3', 'max:500'],
|
||||
'notes' => ['nullable', 'string', 'max:1000'],
|
||||
]);
|
||||
$service->deliver(
|
||||
$reservation,
|
||||
$request->user(),
|
||||
(int) $data['snipe_user_id'],
|
||||
$data['condition'],
|
||||
$data['notes'] ?? null,
|
||||
);
|
||||
|
||||
return back()->with('success', "Entrega de {$reservation->reference} registada no portal e no Snipe-IT.");
|
||||
}
|
||||
|
||||
public function returnEquipment(Request $request, Reservation $reservation, HandoverService $service): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'condition' => ['required', 'string', 'min:3', 'max:500'],
|
||||
'notes' => ['nullable', 'string', 'max:1000'],
|
||||
]);
|
||||
$service->returnEquipment($reservation, $request->user(), $data['condition'], $data['notes'] ?? null);
|
||||
|
||||
return back()->with('success', "Devolução de {$reservation->reference} registada no portal e no Snipe-IT.");
|
||||
}
|
||||
|
||||
public function complete(Request $request, Reservation $reservation, HandoverService $service): RedirectResponse
|
||||
{
|
||||
$service->complete($reservation, $request->user());
|
||||
|
||||
return back()->with('success', "Requisição {$reservation->reference} concluída.");
|
||||
}
|
||||
|
||||
public function noShow(Request $request, Reservation $reservation, HandoverService $service): RedirectResponse
|
||||
{
|
||||
$data = $request->validate(['notes' => ['required', 'string', 'min:3', 'max:1000']]);
|
||||
$service->markNoShow($reservation, $request->user(), $data['notes']);
|
||||
|
||||
return back()->with('success', "Falta registada em {$reservation->reference}.");
|
||||
}
|
||||
|
||||
public function overdue(Request $request, Reservation $reservation, HandoverService $service): RedirectResponse
|
||||
{
|
||||
$data = $request->validate(['notes' => ['required', 'string', 'min:3', 'max:1000']]);
|
||||
$service->markOverdue($reservation, $request->user(), $data['notes']);
|
||||
|
||||
return back()->with('success', "Atraso registado em {$reservation->reference}.");
|
||||
}
|
||||
|
||||
public function incident(
|
||||
Request $request,
|
||||
Reservation $reservation,
|
||||
ReservationItem $item,
|
||||
HandoverService $service,
|
||||
): RedirectResponse {
|
||||
$data = $request->validate([
|
||||
'notes' => ['required', 'string', 'min:5', 'max:1000'],
|
||||
'blocked_until' => ['nullable', 'date', 'after:now'],
|
||||
]);
|
||||
$service->reportIncident(
|
||||
$reservation,
|
||||
$item,
|
||||
$request->user(),
|
||||
$data['notes'],
|
||||
isset($data['blocked_until']) ? CarbonImmutable::parse($data['blocked_until']) : null,
|
||||
);
|
||||
|
||||
return back()->with('success', "Incidente registado em {$reservation->reference}.");
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,7 @@ class HandleInertiaRequests extends Middleware
|
||||
'can' => [
|
||||
'administer' => fn (): bool => $request->user()?->can('administer') ?? false,
|
||||
'approveReservations' => fn (): bool => $request->user()?->can('approve-reservations') ?? false,
|
||||
'operateInventory' => fn (): bool => $request->user()?->can('operate-inventory') ?? false,
|
||||
],
|
||||
'unreadNotifications' => fn (): int => $request->user()?->unreadNotifications()->count() ?? 0,
|
||||
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
|
||||
|
||||
@@ -9,6 +9,7 @@ use Generator;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
use Throwable;
|
||||
@@ -20,6 +21,11 @@ final class SnipeItClient
|
||||
return filled(config('services.snipe_it.url')) && filled(config('services.snipe_it.read_token'));
|
||||
}
|
||||
|
||||
public function isOperationallyConfigured(): bool
|
||||
{
|
||||
return filled(config('services.snipe_it.url')) && filled(config('services.snipe_it.operational_token'));
|
||||
}
|
||||
|
||||
public function configuredUrl(): ?string
|
||||
{
|
||||
$url = trim((string) config('services.snipe_it.url'));
|
||||
@@ -32,7 +38,7 @@ final class SnipeItClient
|
||||
$pageSize = $limit ?? (int) config('services.snipe_it.page_size', 100);
|
||||
$pageSize = max(1, min(500, $pageSize));
|
||||
|
||||
$response = $this->request()->get('/hardware', [
|
||||
$response = $this->readRequest()->get('/hardware', [
|
||||
'limit' => $pageSize,
|
||||
'offset' => max(0, $offset),
|
||||
'sort' => 'id',
|
||||
@@ -92,7 +98,61 @@ final class SnipeItClient
|
||||
} while ($received > 0 && $offset < $page->total);
|
||||
}
|
||||
|
||||
private function request(): PendingRequest
|
||||
/** @return array<string, mixed> */
|
||||
public function checkoutHardware(
|
||||
int $hardwareId,
|
||||
int $statusId,
|
||||
int $assignedUserId,
|
||||
string $expectedCheckin,
|
||||
?string $note = null,
|
||||
): array {
|
||||
$this->assertPositiveIdentifiers($hardwareId, $statusId, $assignedUserId);
|
||||
|
||||
$payload = [
|
||||
'status_id' => $statusId,
|
||||
'checkout_to_type' => 'user',
|
||||
'assigned_user' => $assignedUserId,
|
||||
'expected_checkin' => $expectedCheckin,
|
||||
];
|
||||
|
||||
if (filled($note)) {
|
||||
$payload['note'] = $note;
|
||||
}
|
||||
|
||||
$response = $this->operationalRequest()->post("/hardware/{$hardwareId}/checkout", $payload);
|
||||
|
||||
return $this->operationPayload($response, 'entrega');
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function checkinHardware(
|
||||
int $hardwareId,
|
||||
int $statusId,
|
||||
?int $locationId = null,
|
||||
?string $note = null,
|
||||
): array {
|
||||
$this->assertPositiveIdentifiers($hardwareId, $statusId);
|
||||
|
||||
if ($locationId !== null && $locationId < 1) {
|
||||
throw new SnipeItConfigurationException('O identificador da localização do Snipe-IT tem de ser positivo.');
|
||||
}
|
||||
|
||||
$payload = ['status_id' => $statusId];
|
||||
|
||||
if ($locationId !== null) {
|
||||
$payload['location_id'] = $locationId;
|
||||
}
|
||||
|
||||
if (filled($note)) {
|
||||
$payload['note'] = $note;
|
||||
}
|
||||
|
||||
$response = $this->operationalRequest()->post("/hardware/{$hardwareId}/checkin", $payload);
|
||||
|
||||
return $this->operationPayload($response, 'devolução');
|
||||
}
|
||||
|
||||
private function readRequest(): PendingRequest
|
||||
{
|
||||
if (! $this->isConfigured()) {
|
||||
throw new SnipeItConfigurationException('A URL e o token de leitura do Snipe-IT ainda não estão configurados.');
|
||||
@@ -116,6 +176,54 @@ final class SnipeItClient
|
||||
);
|
||||
}
|
||||
|
||||
private function operationalRequest(): PendingRequest
|
||||
{
|
||||
if (! $this->isOperationallyConfigured()) {
|
||||
throw new SnipeItConfigurationException('A URL e o token operacional do Snipe-IT ainda não estão configurados.');
|
||||
}
|
||||
|
||||
return Http::baseUrl($this->apiBaseUrl())
|
||||
->acceptJson()
|
||||
->asJson()
|
||||
->withToken((string) config('services.snipe_it.operational_token'))
|
||||
->withUserAgent(Str::slug((string) config('app.name')).'/inventory-operations')
|
||||
->connectTimeout(min(5, max(1, (int) config('services.snipe_it.timeout', 10))))
|
||||
->timeout(max(1, (int) config('services.snipe_it.timeout', 10)));
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function operationPayload(Response $response, string $operation): array
|
||||
{
|
||||
if (! $response->successful()) {
|
||||
throw new SnipeItApiException(
|
||||
"O Snipe-IT respondeu com HTTP {$response->status()} durante a {$operation}.",
|
||||
$response->status(),
|
||||
);
|
||||
}
|
||||
|
||||
$payload = $response->json();
|
||||
|
||||
if (! is_array($payload)) {
|
||||
throw new SnipeItApiException("O Snipe-IT devolveu uma resposta inválida durante a {$operation}.");
|
||||
}
|
||||
|
||||
if (($payload['status'] ?? null) !== 'success') {
|
||||
throw new SnipeItApiException("O Snipe-IT recusou a {$operation} apesar de responder com HTTP 200.");
|
||||
}
|
||||
|
||||
/** @var array<string, mixed> $payload */
|
||||
return $payload;
|
||||
}
|
||||
|
||||
private function assertPositiveIdentifiers(int ...$identifiers): void
|
||||
{
|
||||
foreach ($identifiers as $identifier) {
|
||||
if ($identifier < 1) {
|
||||
throw new SnipeItConfigurationException('Os identificadores do Snipe-IT têm de ser positivos.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function apiBaseUrl(): string
|
||||
{
|
||||
$baseUrl = $this->configuredUrl();
|
||||
|
||||
@@ -33,7 +33,7 @@ use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
*/
|
||||
#[Fillable(['uuid', 'name', 'email', 'password', 'department', 'active', 'last_login_at'])]
|
||||
#[Fillable(['uuid', 'name', 'email', 'password', 'department', 'snipe_user_id', 'active', 'last_login_at'])]
|
||||
#[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])]
|
||||
class User extends Authenticatable implements PasskeyUser
|
||||
{
|
||||
|
||||
@@ -37,6 +37,11 @@ class AppServiceProvider extends ServiceProvider
|
||||
fn (User $user): bool => $user->active
|
||||
&& ($user->hasRole(RoleName::Approver) || $user->hasRole(RoleName::Administrator)),
|
||||
);
|
||||
Gate::define(
|
||||
'operate-inventory',
|
||||
fn (User $user): bool => $user->active
|
||||
&& ($user->hasRole(RoleName::InventoryOperator) || $user->hasRole(RoleName::Administrator)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user