baseQuery($tenant, $filters); $countQuery = clone $query; $databaseSorted = $this->applyDatabaseSort($query, $tenant, $filters); if (($filters['sort_by'] ?? null) && ! $databaseSorted) { $matchingTickets = (clone $query) ->with(self::RELATIONS) ->get(); $matchingTickets = $this->sortTickets($matchingTickets, $tenant, $filters); $tickets = $this->paginate($matchingTickets, $filters); $scannedTickets = $matchingTickets->whereNotNull('used_at')->count(); } else { $tickets = (clone $query) ->with(self::RELATIONS) ->when(! $databaseSorted, fn (Builder $query): Builder => $query->orderByDesc('id')) ->paginateFromRequest() ->withQueryString(); $scannedTickets = $countQuery->whereNotNull('used_at')->count(); } return new AdminAppTicketResult( tickets: $tickets, scannedTickets: $scannedTickets, totalTickets: $tickets->total(), ); } /** * @param array{q?: string|null, category?: string|null, product?: string|null, type?: string|null, date?: string|null, size?: string|null, status?: string|null, sort_by?: string|null, sort_direction?: string|null} $filters * @return Collection */ public function ticketsForExport(Tenant $tenant, array $filters = []): Collection { $query = $this->baseQuery($tenant, $filters); $databaseSorted = $this->applyDatabaseSort($query, $tenant, $filters); $tickets = $query ->with(self::RELATIONS) ->when(! $databaseSorted, fn (Builder $query): Builder => $query->orderByDesc('id')) ->get(); return $databaseSorted ? $tickets : $this->sortTickets($tickets, $tenant, $filters); } /** * @param array{q?: string|null, category?: string|null, product?: string|null, type?: string|null, date?: string|null, size?: string|null, status?: string|null, page?: int, per_page?: int} $filters * @return Builder */ private function baseQuery(Tenant $tenant, array $filters): Builder { $search = trim((string) ($filters['q'] ?? '')); $query = Ticket::query() ->where('tenant_code', $tenant->codigo) ->when($search !== '', function (Builder $query) use ($search): void { $this->applySearchFilter($query, $search); }) ->when($filters['category'] ?? null, function (Builder $query, string $category): void { $query->whereHas('sourceCatalogItem.category', fn (Builder $categoryQuery): Builder => $categoryQuery ->whereRaw('LOWER(nombre) = ?', [mb_strtolower(trim($category))])); }) ->when($filters['product'] ?? null, function (Builder $query, string $product) use ($filters): void { $this->applyProductFilter($query, (string) ($filters['category'] ?? ''), $product); }) ->when($filters['type'] ?? null, function (Builder $query, string $type) use ($filters): void { $this->applyTypeFilter($query, (string) ($filters['category'] ?? ''), $type); }) ->when($filters['date'] ?? null, function (Builder $query, string $date) use ($tenant): void { if ($tenant->codigo === 'fiesta_futbol_infantil') { $this->applyEventDateFilter($query, $date); return; } $query->whereHas('sourcePurchaseItem.purchase', fn (Builder $purchaseQuery): Builder => $purchaseQuery ->whereDate('created_at', $date)); }) ->when($filters['size'] ?? null, function (Builder $query, string $size) use ($filters): void { $this->applySizeFilter($query, (string) ($filters['category'] ?? ''), $size); }); $this->applyStatusFilter($query, $filters['status'] ?? null); return $query; } /** @param Builder $query */ private function applySearchFilter(Builder $query, string $search): void { $containsPattern = '%'.mb_strtolower($search).'%'; $amount = $this->searchAmount($search); $query->where(function (Builder $searchQuery) use ($search, $containsPattern, $amount): void { $searchQuery ->where(function (Builder $clientQuery) use ($containsPattern): void { $clientQuery ->whereHas('sourcePurchaseItem.purchase', fn (Builder $purchaseQuery): Builder => $purchaseQuery ->whereRaw('LOWER(nombre_apellido) LIKE ?', [$containsPattern])) ->orWhere(function (Builder $fallbackClientQuery) use ($containsPattern): void { $fallbackClientQuery ->where(function (Builder $missingPurchaseClientQuery): void { $missingPurchaseClientQuery ->whereDoesntHave('sourcePurchaseItem.purchase') ->orWhereHas('sourcePurchaseItem.purchase', fn (Builder $purchaseQuery): Builder => $purchaseQuery ->whereNull('nombre_apellido')); }) ->whereHas('user', fn (Builder $userQuery): Builder => $userQuery ->whereRaw('LOWER(nombre_apellido) LIKE ?', [$containsPattern])); }); }) ->orWhereHas('scannerUser', fn (Builder $scannerQuery): Builder => $scannerQuery ->whereRaw('LOWER(nombre_apellido) LIKE ?', [$containsPattern])); if (ctype_digit($search)) { $searchQuery ->orWhere('tickets.id', (int) $search) ->orWhereHas('sourcePurchaseItem', fn (Builder $purchaseItemQuery): Builder => $purchaseItemQuery ->where('compra_id', (int) $search)); } if ($amount !== null) { $searchQuery->orWhereHas('sourcePurchaseItem', fn (Builder $purchaseItemQuery): Builder => $purchaseItemQuery ->where('precio_unitario', $amount)); } }); } private function searchAmount(string $search): ?string { $value = preg_replace('/[\s$]/u', '', trim($search)); if ($value === null || $value === '') { return null; } if (preg_match('/^\d{1,3}(?:\.\d{3})+(?:,\d{1,2})?$/', $value) === 1) { $value = str_replace(['.', ','], ['', '.'], $value); } elseif (preg_match('/^\d{1,3}(?:,\d{3})+(?:\.\d{1,2})?$/', $value) === 1) { $value = str_replace(',', '', $value); } elseif (preg_match('/^\d+(?:[.,]\d{1,2})?$/', $value) === 1) { $value = str_replace(',', '.', $value); } else { return null; } return number_format((float) $value, 2, '.', ''); } /** @param Builder $query */ private function applyProductFilter(Builder $query, string $category, string $product): void { $category = $this->normalizedCategory($category); if (in_array($category, ['alojamientos', 'camping'], true)) { $this->whereVariantDefinition($query, 'tipo_alojamiento', $product); return; } if (in_array($category, ['comidas', 'comida'], true)) { $this->whereVariantDefinition($query, 'horario', $product); return; } $query->whereHas('sourceCatalogItem', fn (Builder $itemQuery): Builder => $itemQuery ->where('slug', $product)); } /** @param Builder $query */ private function applyTypeFilter(Builder $query, string $category, string $type): void { $attribute = match ($this->normalizedCategory($category)) { 'comidas', 'comida' => 'servicio', 'merchandising' => 'color', default => null, }; if ($attribute !== null) { $this->whereVariantDefinition($query, $attribute, $type); } } /** @param Builder $query */ private function applyEventDateFilter(Builder $query, string $date): void { $query ->whereHas('sourceCatalogItem.category', fn (Builder $categoryQuery): Builder => $categoryQuery ->whereRaw('LOWER(nombre) IN (?, ?)', ['comidas', 'comida'])) ->whereHas('sourceVariant', function (Builder $variantQuery) use ($date): void { $variantQuery->where(function (Builder $dateQuery) use ($date): void { $dateQuery ->whereHas('eventDate', fn (Builder $eventDateQuery): Builder => $eventDateQuery ->whereDate('date', $date)) ->orWhereHas('eventDates', fn (Builder $eventDateQuery): Builder => $eventDateQuery ->whereDate('date', $date)); }); }); } /** @param Builder $query */ private function applySizeFilter(Builder $query, string $category, string $size): void { if ($this->normalizedCategory($category) === 'merchandising') { $this->whereVariantDefinition($query, 'talle', $size); } } /** @param Builder $query */ private function whereVariantDefinition(Builder $query, string $attribute, string $value): void { $query->whereHas('sourceVariant.definitions', fn (Builder $definitionQuery): Builder => $definitionQuery ->where('value', $value) ->whereHas('itemAttribute.attribute', fn (Builder $attributeQuery): Builder => $attributeQuery ->where('codigo', $attribute))); } /** @param Builder $query */ private function applyStatusFilter(Builder $query, ?string $status): void { if ($status === null || $status === '') { return; } if ($status === Ticket::STATUS_USED) { $query->whereNotNull('used_at'); return; } $matchingIds = (clone $query) ->whereNull('used_at') ->with(TicketValidityResolver::RELATIONS) ->get() ->filter(fn (Ticket $ticket): bool => $ticket->status === $status) ->pluck('id'); $query->whereIn('tickets.id', $matchingIds); } private function normalizedCategory(string $category): string { return mb_strtolower(trim($category)); } /** * @param Builder $query * @param array{sort_by?: string|null, sort_direction?: string|null} $filters */ private function applyDatabaseSort(Builder $query, Tenant $tenant, array $filters): bool { $sortBy = (string) ($filters['sort_by'] ?? ''); if ($sortBy === '') { return false; } $direction = ($filters['sort_direction'] ?? 'asc') === 'desc' ? 'desc' : 'asc'; $sortExpression = match ($sortBy) { 'order_number' => $this->purchaseItemColumnQuery('compra_id'), 'id' => 'tickets.id', 'amount' => $this->purchaseItemColumnQuery('precio_unitario'), 'scanned_by' => User::query() ->withTrashed() ->select('nombre_apellido') ->whereColumn('users.id', 'tickets.scanner_user_id'), 'product' => $tenant->codigo === 'fiesta_futbol_infantil' ? null : $this->purchaseItemColumnQuery('item_nombre'), default => null, }; if ($sortExpression === null) { return false; } $query->orderBy($sortExpression, $direction)->orderByDesc('tickets.id'); return true; } /** @return Builder */ private function purchaseItemColumnQuery(string $column): Builder { return PurchaseItem::query() ->select($column) ->whereColumn('compra_items.id', 'tickets.source_purchase_item_id') ->limit(1); } /** * @param Collection $tickets * @param array{sort_by?: string|null, sort_direction?: string|null} $filters * @return Collection */ private function sortTickets(Collection $tickets, Tenant $tenant, array $filters): Collection { $sortBy = (string) ($filters['sort_by'] ?? ''); if ($sortBy === '') { return $tickets; } $column = collect($this->columnService->columns($tenant)) ->firstWhere('sort_param', $sortBy); if ($column === null) { return $tickets; } $direction = ($filters['sort_direction'] ?? 'asc') === 'desc' ? -1 : 1; $values = $tickets->mapWithKeys(fn (Ticket $ticket): array => [ $ticket->getKey() => $this->rowService->values($ticket)[$column['key']] ?? null, ]); return $tickets->sort(function (Ticket $left, Ticket $right) use ($column, $direction, $values): int { $leftValue = $values->get($left->getKey()); $rightValue = $values->get($right->getKey()); if ($leftValue === null || $leftValue === '') { return $rightValue === null || $rightValue === '' ? $right->id <=> $left->id : 1; } if ($rightValue === null || $rightValue === '') { return -1; } $comparison = $this->compareValues($leftValue, $rightValue, $column['type']); return $comparison === 0 ? $right->id <=> $left->id : $comparison * $direction; })->values(); } private function compareValues(mixed $left, mixed $right, string $type): int { if (in_array($type, ['currency', 'order_number'], true)) { return (float) $left <=> (float) $right; } if ($type === 'date') { $leftTimestamp = $left instanceof \DateTimeInterface ? $left->getTimestamp() : strtotime((string) $left); $rightTimestamp = $right instanceof \DateTimeInterface ? $right->getTimestamp() : strtotime((string) $right); return $leftTimestamp <=> $rightTimestamp; } if ($type === 'status') { $left = $this->rowService->displayValue($left, $type, 'UTC'); $right = $this->rowService->displayValue($right, $type, 'UTC'); } return strnatcasecmp((string) $left, (string) $right); } /** * @param Collection $tickets * @param array{page?: int, per_page?: int} $filters * @return LengthAwarePaginator */ private function paginate(Collection $tickets, array $filters): LengthAwarePaginator { $page = (int) ($filters['page'] ?? 1); $perPage = (int) ($filters['per_page'] ?? 15); return (new LengthAwarePaginator( $tickets->forPage($page, $perPage)->values(), $tickets->count(), $perPage, $page, ['path' => request()->url()], ))->withQueryString(); } }