feat(scan): implement scan attempt tracking and validation logic

This commit is contained in:
2026-09-07 16:39:14 -03:00
parent b607c1b673
commit 193e10dc48
10 changed files with 287 additions and 63 deletions

View File

@@ -3,14 +3,21 @@
namespace App\Domains\Ticket\Services;
use App\Domains\Auth\Models\User;
use App\Domains\Ticket\Enums\ScanAttemptResult;
use App\Domains\Ticket\Models\ScanAttempt;
use App\Domains\Ticket\Models\Ticket;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Throwable;
class ScannerTicketService
{
private const INVALID_QR_MESSAGE = 'El QR proporcionado es inválido.';
/**
* @param array{q?: string|null, page?: int, per_page?: int} $filters
* @return LengthAwarePaginator<Ticket>
@@ -90,42 +97,117 @@ class ScannerTicketService
return $query->firstOrFail();
}
public function scan(User $scanner, string $ticketUuid): Ticket
public function scan(User $scanner, mixed $scannedData): Ticket
{
return DB::transaction(function () use ($scanner, $ticketUuid): Ticket {
$ticket = $this->baseQuery()
->where('tenant_code', $scanner->tenant_codigo)
->where('ticket', $ticketUuid)
->lockForUpdate()
->firstOrFail();
$scanAttempt = ScanAttempt::query()->create([
'tenant_code' => $scanner->tenant_codigo,
'scanner_user_id' => $scanner->getKey(),
'data' => $this->serializeScannedData($scannedData),
'result' => ScanAttemptResult::Processing,
]);
if (! $this->scannerCanScan($scanner, $ticket)) {
throw ValidationException::withMessages([
'ticket' => __('api.ticket.scanner_category_forbidden'),
]);
}
if (! is_string($scannedData) || ! Str::isUuid($scannedData)) {
$this->resolveScanAttempt($scanAttempt, ScanAttemptResult::InvalidQr);
if ($ticket->is_used) {
throw ValidationException::withMessages([
'ticket' => __('api.ticket.already_scanned'),
]);
}
throw ValidationException::withMessages([
'data' => self::INVALID_QR_MESSAGE,
]);
}
if (! $ticket->is_valid) {
throw ValidationException::withMessages([
'ticket' => $ticket->is_expired
? __('api.ticket.expired_for_scan')
: __('api.ticket.not_valid_for_scan'),
]);
}
$ticketId = null;
$failureResult = null;
$ticket->forceFill([
'used_at' => now(),
'scanner_user_id' => $scanner->getKey(),
])->save();
try {
return DB::transaction(function () use (
$scanner,
$scannedData,
$scanAttempt,
&$ticketId,
&$failureResult,
): Ticket {
$ticket = $this->baseQuery()
->where('tenant_code', $scanner->tenant_codigo)
->where('ticket', $scannedData)
->lockForUpdate()
->firstOrFail();
$ticketId = (int) $ticket->getKey();
return $ticket->refresh()->load($this->relations());
});
if (! $this->scannerCanScan($scanner, $ticket)) {
$failureResult = ScanAttemptResult::CategoryForbidden;
throw ValidationException::withMessages([
'ticket' => __('api.ticket.scanner_category_forbidden'),
]);
}
if ($ticket->is_used) {
$failureResult = ScanAttemptResult::AlreadyScanned;
throw ValidationException::withMessages([
'ticket' => __('api.ticket.already_scanned'),
]);
}
if (! $ticket->is_valid) {
$failureResult = $ticket->is_expired
? ScanAttemptResult::Expired
: ScanAttemptResult::NotValid;
throw ValidationException::withMessages([
'ticket' => $ticket->is_expired
? __('api.ticket.expired_for_scan')
: __('api.ticket.not_valid_for_scan'),
]);
}
$ticket->forceFill([
'used_at' => now(),
'scanner_user_id' => $scanner->getKey(),
])->save();
$this->resolveScanAttempt(
$scanAttempt,
ScanAttemptResult::Accepted,
$ticketId,
);
return $ticket->refresh()->load($this->relations());
});
} catch (Throwable $exception) {
$result = $exception instanceof ModelNotFoundException
? ScanAttemptResult::TicketNotFound
: ($failureResult ?? ScanAttemptResult::UnexpectedError);
$this->resolveScanAttempt($scanAttempt, $result, $ticketId);
throw $exception;
}
}
private function resolveScanAttempt(
ScanAttempt $scanAttempt,
ScanAttemptResult $result,
?int $ticketId = null,
): void {
$scanAttempt->forceFill([
'ticket_id' => $ticketId,
'result' => $result,
'resolved_at' => now(),
])->save();
}
private function serializeScannedData(mixed $scannedData): ?string
{
if ($scannedData === null || is_string($scannedData)) {
return $scannedData;
}
$encoded = json_encode(
$scannedData,
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE,
);
return $encoded === false ? get_debug_type($scannedData) : $encoded;
}
/** @return Builder<Ticket> */