*/ public function attemptsBy(User $scanner, array $filters = []): LengthAwarePaginator { $search = trim((string) ($filters['q'] ?? '')); return ScanAttempt::query() ->with('ticket') ->where('tenant_code', $scanner->tenant_codigo) ->where('scanner_user_id', $scanner->getKey()) ->when($search !== '', function (Builder $query) use ($search): void { $attemptedAtDate = $this->parseSearchDate($search); $query->where(function (Builder $searchQuery) use ($search, $attemptedAtDate): void { $searchQuery->where('data', 'like', "%{$search}%"); if (ctype_digit($search)) { $searchQuery->orWhere('id', (int) $search); } if ($attemptedAtDate !== null) { $searchQuery->orWhereDate('created_at', $attemptedAtDate); } }); }) ->orderByDesc('created_at') ->orderByDesc('id') ->paginateFromRequest() ->withQueryString(); } private function parseSearchDate(string $search): ?string { if (preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $search, $matches) === 1) { [$year, $month, $day] = array_map('intval', array_slice($matches, 1)); if (checkdate($month, $day, $year)) { return sprintf('%04d-%02d-%02d', $year, $month, $day); } } if (preg_match('/^(\d{2})\/(\d{2})\/(\d{2}|\d{4})$/', $search, $matches) === 1) { $day = (int) $matches[1]; $month = (int) $matches[2]; $year = (int) $matches[3]; $year = strlen($matches[3]) === 2 ? 2000 + $year : $year; if (checkdate($month, $day, $year)) { return sprintf('%04d-%02d-%02d', $year, $month, $day); } } return null; } public function detail(User $scanner, string $ticketUuid): Ticket { $query = $this->baseQuery() ->where('tenant_code', $scanner->tenant_codigo) ->where('ticket', $ticketUuid); if ($this->requiresCategoryValidation($scanner)) { $categoryIds = $this->scannerCategoryIds($scanner); $query->where(function (Builder $query) use ($scanner, $categoryIds): void { $query ->where('scanner_user_id', $scanner->getKey()) ->orWhereHas( 'sourceCatalogItem', fn (Builder $catalogItemQuery): Builder => $catalogItemQuery ->whereIn('category_id', $categoryIds) ); }); } return $query->firstOrFail(); } public function scan(User $scanner, mixed $scannedData): ScanAttempt { $scanAttempt = ScanAttempt::query()->create([ 'tenant_code' => $scanner->tenant_codigo, 'scanner_user_id' => $scanner->getKey(), 'data' => $this->serializeScannedData($scannedData), 'result' => ScanAttemptResult::Processing, ]); if (! is_string($scannedData) || ! Str::isUuid($scannedData)) { $this->resolveScanAttempt($scanAttempt, ScanAttemptResult::InvalidQr); throw ValidationException::withMessages([ 'data' => self::INVALID_QR_MESSAGE, ]); } $ticketId = null; $failureResult = null; try { return DB::transaction(function () use ( $scanner, $scannedData, $scanAttempt, &$ticketId, &$failureResult, ): ScanAttempt { $ticket = $this->baseQuery() ->where('tenant_code', $scanner->tenant_codigo) ->where('ticket', $scannedData) ->lockForUpdate() ->firstOrFail(); $ticketId = (int) $ticket->getKey(); 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, ); $ticket = $ticket->refresh()->load($this->relations()); return $scanAttempt->refresh()->setRelation('ticket', $ticket); }); } 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 */ private function baseQuery(): Builder { return Ticket::query()->with($this->relations()); } /** @return array */ private function relations(): array { return [ ...TicketValidityResolver::RELATIONS, ...TicketPresentationResolver::RELATIONS, 'sourceCatalogItem.category', 'sourceVariant.eventDate', 'sourceVariant.catalogItem', 'user', ]; } /** @return array */ private function scannerCategoryIds(User $scanner): array { return $scanner->scanCategories() ->pluck('categorias.id') ->map(fn (mixed $id): int => (int) $id) ->all(); } private function scannerCanScan(User $scanner, Ticket $ticket): bool { if (! $this->requiresCategoryValidation($scanner)) { return true; } $categoryId = $ticket->sourceCatalogItem?->category_id; return $categoryId !== null && $scanner->scanCategories() ->where('categorias.id', $categoryId) ->exists(); } private function requiresCategoryValidation(User $scanner): bool { return $scanner->tenant()->firstOrFail()->requiresScannerCategoryValidation(); } }