feat(scan): implement scan attempt tracking and validation logic
This commit is contained in:
@@ -6,6 +6,7 @@ use App\Domains\Authorization\Enums\RoleCode;
|
||||
use App\Domains\Authorization\Models\Role;
|
||||
use App\Domains\Catalog\Models\Category;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Domains\Ticket\Models\ScanAttempt;
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||
@@ -46,6 +47,12 @@ class User extends Authenticatable
|
||||
return $this->hasMany(LoginAttempt::class);
|
||||
}
|
||||
|
||||
/** @return HasMany<ScanAttempt, $this> */
|
||||
public function scanAttempts(): HasMany
|
||||
{
|
||||
return $this->hasMany(ScanAttempt::class, 'scanner_user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Role, $this>
|
||||
*/
|
||||
|
||||
@@ -12,6 +12,7 @@ use App\Domains\Event\Models\EventDate;
|
||||
use App\Domains\Menu\Models\Menu;
|
||||
use App\Domains\Menu\Models\TenantMenu;
|
||||
use App\Domains\Tenant\Enums\CartEditingPolicy;
|
||||
use App\Domains\Ticket\Models\ScanAttempt;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@@ -195,6 +196,12 @@ class Tenant extends Model
|
||||
return $this->hasMany(Category::class, 'tenant_code', 'codigo');
|
||||
}
|
||||
|
||||
/** @return HasMany<ScanAttempt, $this> */
|
||||
public function scanAttempts(): HasMany
|
||||
{
|
||||
return $this->hasMany(ScanAttempt::class, 'tenant_code', 'codigo');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsToMany<SocialMedia, $this>
|
||||
*/
|
||||
|
||||
@@ -4,7 +4,6 @@ namespace App\Domains\Ticket\Controllers\Scanner;
|
||||
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Ticket\Requests\ScannerTicketIndexRequest;
|
||||
use App\Domains\Ticket\Requests\ScanTicketRequest;
|
||||
use App\Domains\Ticket\Resources\Scanner\ScannedTicketResource;
|
||||
use App\Domains\Ticket\Resources\TicketResource;
|
||||
use App\Domains\Ticket\Services\ScannerTicketService;
|
||||
@@ -36,13 +35,13 @@ class TicketController extends Controller
|
||||
);
|
||||
}
|
||||
|
||||
public function scan(ScanTicketRequest $request): TicketResource
|
||||
public function scan(Request $request): TicketResource
|
||||
{
|
||||
/** @var User $scanner */
|
||||
$scanner = $request->user();
|
||||
|
||||
return TicketResource::make(
|
||||
$this->ticketService->scan($scanner, $request->validated('data'))
|
||||
$this->ticketService->scan($scanner, $request->input('data'))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
16
app/Domains/Ticket/Enums/ScanAttemptResult.php
Normal file
16
app/Domains/Ticket/Enums/ScanAttemptResult.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Ticket\Enums;
|
||||
|
||||
enum ScanAttemptResult: string
|
||||
{
|
||||
case Processing = 'processing';
|
||||
case Accepted = 'accepted';
|
||||
case InvalidQr = 'invalid_qr';
|
||||
case TicketNotFound = 'ticket_not_found';
|
||||
case CategoryForbidden = 'category_forbidden';
|
||||
case AlreadyScanned = 'already_scanned';
|
||||
case Expired = 'expired';
|
||||
case NotValid = 'not_valid';
|
||||
case UnexpectedError = 'unexpected_error';
|
||||
}
|
||||
52
app/Domains/Ticket/Models/ScanAttempt.php
Normal file
52
app/Domains/Ticket/Models/ScanAttempt.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Ticket\Models;
|
||||
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Domains\Ticket\Enums\ScanAttemptResult;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Fillable([
|
||||
'tenant_code',
|
||||
'scanner_user_id',
|
||||
'ticket_id',
|
||||
'data',
|
||||
'result',
|
||||
'resolved_at',
|
||||
])]
|
||||
class ScanAttempt extends Model
|
||||
{
|
||||
public const UPDATED_AT = null;
|
||||
|
||||
/** @return BelongsTo<User, $this> */
|
||||
public function scanner(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'scanner_user_id')->withTrashed();
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Ticket, $this> */
|
||||
public function ticket(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Ticket::class);
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Tenant, $this> */
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo');
|
||||
}
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'scanner_user_id' => 'integer',
|
||||
'ticket_id' => 'integer',
|
||||
'result' => ScanAttemptResult::class,
|
||||
'created_at' => 'datetime',
|
||||
'resolved_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
#[Fillable([
|
||||
@@ -81,6 +82,12 @@ class Ticket extends Model
|
||||
return $this->belongsTo(User::class, 'scanner_user_id')->withTrashed();
|
||||
}
|
||||
|
||||
/** @return HasMany<ScanAttempt, $this> */
|
||||
public function scanAttempts(): HasMany
|
||||
{
|
||||
return $this->hasMany(ScanAttempt::class);
|
||||
}
|
||||
|
||||
/** @return BelongsTo<PurchaseItem, $this> */
|
||||
public function sourcePurchaseItem(): BelongsTo
|
||||
{
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Ticket\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class ScanTicketRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @return array<string, list<string>> */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'data' => ['required', 'uuid'],
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string, string> */
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'data.required' => 'El QR proporcionado es inválido.',
|
||||
'data.uuid' => 'El QR proporcionado es inválido.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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> */
|
||||
|
||||
Reference in New Issue
Block a user