*/ public function attemptsBy(User $scanner, array $filters = []): LengthAwarePaginator { $search = trim((string) ($filters['q'] ?? '')); return ScanAttempt::query() ->with('ticket.sourceCatalogItem.category') ->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(); } /** * @param array{q?: string|null, page?: int, per_page?: int} $filters * @return LengthAwarePaginator */ public function attemptsByStaff(User $scanner, array $filters = []): LengthAwarePaginator { $search = trim((string) ($filters['q'] ?? '')); return ScanAttempt::query() ->with('ticket.sourceCatalogItem.category') ->where('tenant_code', $scanner->tenant_codigo) ->where('scanner_user_id', $scanner->getKey()) ->when($search !== '', function (Builder $query) use ($search): void { $attemptedAtDate = $this->parseSearchDate($search); $attemptedAtDayMonth = $this->parseSearchDayMonth($search); $query->where(function (Builder $searchQuery) use ( $search, $attemptedAtDate, $attemptedAtDayMonth, ): void { $searchQuery ->whereHas( 'ticket.sourceCatalogItem.category', fn (Builder $categoryQuery): Builder => $categoryQuery ->where('nombre', 'like', "%{$search}%") ) ->orWhere('created_at', 'like', "%{$search}%"); if (ctype_digit($search)) { $searchQuery->orWhere('ticket_id', (int) $search); } if ($attemptedAtDate !== null) { $searchQuery->orWhereDate('created_at', $attemptedAtDate); } if ($attemptedAtDayMonth !== null) { $searchQuery->orWhere(function (Builder $dateQuery) use ($attemptedAtDayMonth): void { $dateQuery ->whereDay('created_at', $attemptedAtDayMonth['day']) ->whereMonth('created_at', $attemptedAtDayMonth['month']); }); } }); }) ->orderByDesc('created_at') ->orderByDesc('id') ->paginateFromRequest() ->withQueryString(); } public function scanAttemptDetail(User $scanner, int $scanAttemptId): ScanAttempt { $scanAttempt = ScanAttempt::query() ->with('ticket') ->where('tenant_code', $scanner->tenant_codigo) ->where('scanner_user_id', $scanner->getKey()) ->findOrFail($scanAttemptId); $scanAttempt->ticket?->loadMissing($this->relations()); return $scanAttempt; } 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; } /** @return array{day: int, month: int}|null */ private function parseSearchDayMonth(string $search): ?array { if (preg_match('/^(\d{1,2})\/(\d{1,2})$/', $search, $matches) !== 1) { return null; } $day = (int) $matches[1]; $month = (int) $matches[2]; return checkdate($month, $day, 2000) ? compact('day', 'month') : 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); return $scanAttempt->refresh(); } $ticketId = null; try { return DB::transaction(function () use ( $scanner, $scannedData, $scanAttempt, &$ticketId, ): ScanAttempt { $ticket = $this->baseQuery() ->where('tenant_code', $scanner->tenant_codigo) ->where('ticket', $scannedData) ->lockForUpdate() ->firstOrFail(); $ticketId = (int) $ticket->getKey(); if (! $this->scannerCanScan($scanner, $ticket)) { $this->resolveScanAttempt( $scanAttempt, ScanAttemptResult::CategoryForbidden, $ticketId, ); return $scanAttempt->refresh()->setRelation('ticket', $ticket); } if ($ticket->is_used) { $this->resolveScanAttempt( $scanAttempt, ScanAttemptResult::AlreadyScanned, $ticketId, ); return $scanAttempt->refresh()->setRelation('ticket', $ticket); } if (! $ticket->is_valid) { $result = $ticket->is_expired ? ScanAttemptResult::Expired : ScanAttemptResult::NotValid; $this->resolveScanAttempt($scanAttempt, $result, $ticketId); return $scanAttempt->refresh()->setRelation('ticket', $ticket); } $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 (ModelNotFoundException) { $this->resolveScanAttempt($scanAttempt, ScanAttemptResult::TicketNotFound); return $scanAttempt->refresh(); } catch (Throwable $exception) { report($exception); $this->resolveScanAttempt($scanAttempt, ScanAttemptResult::UnexpectedError, $ticketId); return $scanAttempt->refresh(); } } 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(); } }