From fa1e11a69fa96fca0dbe3c223f3226a5e37d249d Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 31 Aug 2026 13:45:57 -0300 Subject: [PATCH] feat(tickets): add tenant-aware server-side sorting --- .../Requests/AdminAppTicketIndexRequest.php | 8 + .../Services/AdminAppTicketColumnService.php | 18 +- .../Ticket/Services/AdminAppTicketService.php | 189 +++++++++++++++++- ...AdminAppTicketFilterFormControllerTest.php | 17 +- .../Ticket/AdminAppTicketControllerTest.php | 41 ++++ 5 files changed, 251 insertions(+), 22 deletions(-) diff --git a/app/Domains/Ticket/Requests/AdminAppTicketIndexRequest.php b/app/Domains/Ticket/Requests/AdminAppTicketIndexRequest.php index e4e6d08..fe58b87 100644 --- a/app/Domains/Ticket/Requests/AdminAppTicketIndexRequest.php +++ b/app/Domains/Ticket/Requests/AdminAppTicketIndexRequest.php @@ -3,6 +3,7 @@ namespace App\Domains\Ticket\Requests; use App\Domains\Ticket\Models\Ticket; +use App\Domains\Ticket\Services\AdminAppTicketColumnService; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -16,6 +17,11 @@ class AdminAppTicketIndexRequest extends FormRequest /** @return array> */ public function rules(): array { + $tenant = $this->user()?->tenant()->first(); + $sortableKeys = $tenant === null + ? [] + : app(AdminAppTicketColumnService::class)->sortableKeys($tenant); + return [ 'q' => ['sometimes', 'nullable', 'string', 'max:255'], 'category' => ['sometimes', 'nullable', 'string', 'max:255'], @@ -33,6 +39,8 @@ class AdminAppTicketIndexRequest extends FormRequest ], 'page' => ['sometimes', 'integer', 'min:1'], 'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'], + 'sort_by' => ['sometimes', 'nullable', 'string', Rule::in($sortableKeys)], + 'sort_direction' => ['sometimes', 'nullable', 'string', Rule::in(['asc', 'desc'])], ]; } } diff --git a/app/Domains/Ticket/Services/AdminAppTicketColumnService.php b/app/Domains/Ticket/Services/AdminAppTicketColumnService.php index a935e90..4b86aea 100644 --- a/app/Domains/Ticket/Services/AdminAppTicketColumnService.php +++ b/app/Domains/Ticket/Services/AdminAppTicketColumnService.php @@ -8,7 +8,7 @@ class AdminAppTicketColumnService { private const FIESTA_FUTBOL_INFANTIL = 'fiesta_futbol_infantil'; - /** @return list */ + /** @return list */ public function columns(Tenant $tenant): array { $keys = $tenant->codigo === self::FIESTA_FUTBOL_INFANTIL @@ -38,7 +38,7 @@ class AdminAppTicketColumnService return $columns; } - /** @return list */ + /** @return list */ public function publicColumns(Tenant $tenant): array { return array_map(function (array $column): array { @@ -48,7 +48,16 @@ class AdminAppTicketColumnService }, $this->columns($tenant)); } - /** @return array */ + /** @return list */ + public function sortableKeys(Tenant $tenant): array + { + return array_values(array_map( + fn (array $column): string => $column['sort_param'], + array_filter($this->columns($tenant), fn (array $column): bool => $column['sortable']), + )); + } + + /** @return array */ private function definitions(): array { return [ @@ -65,7 +74,7 @@ class AdminAppTicketColumnService ]; } - /** @return array{key: string, label: string, type: string, sortable: bool, width: string, excel_width: int} */ + /** @return array{key: string, label: string, type: string, sortable: bool, sort_param: string, width: string, excel_width: int} */ private function column( string $key, string $label, @@ -78,6 +87,7 @@ class AdminAppTicketColumnService 'label' => $label, 'type' => $type, 'sortable' => true, + 'sort_param' => $key, 'width' => $width, 'excel_width' => $excelWidth, ]; diff --git a/app/Domains/Ticket/Services/AdminAppTicketService.php b/app/Domains/Ticket/Services/AdminAppTicketService.php index 5f91dc8..87ee13f 100644 --- a/app/Domains/Ticket/Services/AdminAppTicketService.php +++ b/app/Domains/Ticket/Services/AdminAppTicketService.php @@ -2,9 +2,13 @@ namespace App\Domains\Ticket\Services; +use App\Domains\Auth\Models\User; +use App\Domains\Purchase\Models\Purchase; +use App\Domains\Purchase\Models\PurchaseItem; use App\Domains\Tenant\Models\Tenant; use App\Domains\Ticket\Models\Ticket; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Collection; class AdminAppTicketService @@ -18,36 +22,58 @@ class AdminAppTicketService 'sourcePurchase.items', ]; + public function __construct( + private readonly AdminAppTicketColumnService $columnService, + private readonly AdminAppTicketRowService $rowService, + ) {} + /** - * @param array{q?: string|null, category?: string|null, product?: string|null, type?: string|null, date?: string|null, status?: string|null, page?: int, per_page?: int} $filters + * @param array{q?: string|null, category?: string|null, product?: string|null, type?: string|null, date?: string|null, status?: string|null, page?: int, per_page?: int, sort_by?: string|null, sort_direction?: string|null} $filters */ public function search(Tenant $tenant, array $filters = []): AdminAppTicketResult { $query = $this->baseQuery($tenant, $filters); + $countQuery = clone $query; - $tickets = (clone $query) - ->with(self::RELATIONS) - ->orderByDesc('id') - ->paginateFromRequest() - ->withQueryString(); + $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: (clone $query)->whereNotNull('used_at')->count(), + scannedTickets: $scannedTickets, totalTickets: $tickets->total(), ); } /** - * @param array{q?: string|null, category?: string|null, product?: string|null, type?: string|null, date?: string|null, status?: string|null} $filters + * @param array{q?: string|null, category?: string|null, product?: string|null, type?: string|null, date?: string|null, status?: string|null, sort_by?: string|null, sort_direction?: string|null} $filters * @return Collection */ public function ticketsForExport(Tenant $tenant, array $filters = []): Collection { - return $this->baseQuery($tenant, $filters) + $query = $this->baseQuery($tenant, $filters); + $databaseSorted = $this->applyDatabaseSort($query, $tenant, $filters); + $tickets = $query ->with(self::RELATIONS) - ->orderByDesc('id') + ->when(! $databaseSorted, fn (Builder $query): Builder => $query->orderByDesc('id')) ->get(); + + return $databaseSorted ? $tickets : $this->sortTickets($tickets, $tenant, $filters); } /** @@ -166,4 +192,147 @@ class AdminAppTicketService { 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' => 'tickets.source_purchase_id', + 'ticket' => 'tickets.ticket', + 'date' => Purchase::query() + ->select('created_at') + ->whereColumn('compras.id', 'tickets.source_purchase_id'), + 'amount' => $this->purchaseItemSortQuery('precio_unitario'), + 'scanned_by' => User::query() + ->select('nombre_apellido') + ->whereColumn('users.id', 'tickets.scanner_user_id'), + 'product' => $tenant->codigo === 'fiesta_futbol_infantil' + ? null + : $this->purchaseItemSortQuery('item_nombre'), + default => null, + }; + + if ($sortExpression === null) { + return false; + } + + $query->orderBy($sortExpression, $direction)->orderByDesc('tickets.id'); + + return true; + } + + /** @return Builder */ + private function purchaseItemSortQuery(string $column): Builder + { + return PurchaseItem::query() + ->select($column) + ->whereColumn('compra_items.compra_id', 'tickets.source_purchase_id') + ->where(function (Builder $query): void { + $query + ->where(function (Builder $variantQuery): void { + $variantQuery + ->whereNotNull('tickets.source_variant_id') + ->whereColumn('compra_items.source_variant_id', 'tickets.source_variant_id'); + }) + ->orWhere(function (Builder $itemQuery): void { + $itemQuery + ->whereNull('tickets.source_variant_id') + ->whereNull('compra_items.source_variant_id') + ->whereColumn('compra_items.source_catalog_item_id', 'tickets.source_catalog_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(); + } } diff --git a/tests/Feature/Forms/AdminAppTicketFilterFormControllerTest.php b/tests/Feature/Forms/AdminAppTicketFilterFormControllerTest.php index 80c5b92..a1f2873 100644 --- a/tests/Feature/Forms/AdminAppTicketFilterFormControllerTest.php +++ b/tests/Feature/Forms/AdminAppTicketFilterFormControllerTest.php @@ -107,6 +107,7 @@ class AdminAppTicketFilterFormControllerTest extends TestCase ->assertJsonPath('data.fields.4.name', 'status') ->assertJsonPath('data.fields.4.query_param', 'status') ->assertJsonPath('data.columns.0.key', 'order_number') + ->assertJsonPath('data.columns.0.sort_param', 'order_number') ->assertJsonPath('data.columns.1.key', 'category') ->assertJsonPath('data.columns.2.key', 'product') ->assertJsonPath('data.columns.3.key', 'type'); @@ -244,14 +245,14 @@ class AdminAppTicketFilterFormControllerTest extends TestCase private function commonColumns(): array { return [ - ['key' => 'order_number', 'label' => 'N° de orden', 'type' => 'order_number', 'sortable' => true, 'width' => '11%'], - ['key' => 'product', 'label' => 'Producto', 'type' => 'text', 'sortable' => true, 'width' => '15%'], - ['key' => 'amount', 'label' => 'Importe', 'type' => 'currency', 'sortable' => true, 'width' => '10%'], - ['key' => 'client', 'label' => 'Cliente', 'type' => 'text', 'sortable' => true, 'width' => '15%'], - ['key' => 'ticket', 'label' => 'ID', 'type' => 'text', 'sortable' => true, 'width' => '19%'], - ['key' => 'date', 'label' => 'Fecha', 'type' => 'date', 'sortable' => true, 'width' => '11%'], - ['key' => 'status', 'label' => 'Estado', 'type' => 'status', 'sortable' => true, 'width' => '8%'], - ['key' => 'scanned_by', 'label' => 'Escaneado por', 'type' => 'text', 'sortable' => true, 'width' => '11%'], + ['key' => 'order_number', 'label' => 'N° de orden', 'type' => 'order_number', 'sortable' => true, 'sort_param' => 'order_number', 'width' => '11%'], + ['key' => 'product', 'label' => 'Producto', 'type' => 'text', 'sortable' => true, 'sort_param' => 'product', 'width' => '15%'], + ['key' => 'amount', 'label' => 'Importe', 'type' => 'currency', 'sortable' => true, 'sort_param' => 'amount', 'width' => '10%'], + ['key' => 'client', 'label' => 'Cliente', 'type' => 'text', 'sortable' => true, 'sort_param' => 'client', 'width' => '15%'], + ['key' => 'ticket', 'label' => 'ID', 'type' => 'text', 'sortable' => true, 'sort_param' => 'ticket', 'width' => '19%'], + ['key' => 'date', 'label' => 'Fecha', 'type' => 'date', 'sortable' => true, 'sort_param' => 'date', 'width' => '11%'], + ['key' => 'status', 'label' => 'Estado', 'type' => 'status', 'sortable' => true, 'sort_param' => 'status', 'width' => '8%'], + ['key' => 'scanned_by', 'label' => 'Escaneado por', 'type' => 'text', 'sortable' => true, 'sort_param' => 'scanned_by', 'width' => '11%'], ]; } diff --git a/tests/Feature/Ticket/AdminAppTicketControllerTest.php b/tests/Feature/Ticket/AdminAppTicketControllerTest.php index 5ecbce3..9f76b30 100644 --- a/tests/Feature/Ticket/AdminAppTicketControllerTest.php +++ b/tests/Feature/Ticket/AdminAppTicketControllerTest.php @@ -96,6 +96,47 @@ class AdminAppTicketControllerTest extends TestCase ->assertJsonPath('data.0.ticket', $matching->ticket); } + public function test_it_sorts_the_complete_filtered_result_before_paginating(): void + { + $tenant = $this->createTenant('fiesta_futbol_infantil'); + $admin = $this->createAdminAppUser($tenant); + $this->grantTicketsMenu($tenant); + Sanctum::actingAs($admin); + + foreach (['ticket-c', 'ticket-a', 'ticket-d', 'ticket-b'] as $value) { + $this->createTicket($tenant, $admin)->update(['ticket' => $value]); + } + + $query = http_build_query([ + 'sort_by' => 'ticket', + 'sort_direction' => 'asc', + 'per_page' => 2, + ]); + + $this->getJson('/api/v1/adminapp/tenant/tickets?'.$query) + ->assertOk() + ->assertJsonPath('data.0.values.ticket', 'ticket-a') + ->assertJsonPath('data.1.values.ticket', 'ticket-b') + ->assertJsonPath('meta.total', 4) + ->assertJsonPath('meta.last_page', 2); + + $this->getJson('/api/v1/adminapp/tenant/tickets?'.$query.'&page=2') + ->assertOk() + ->assertJsonPath('data.0.values.ticket', 'ticket-c') + ->assertJsonPath('data.1.values.ticket', 'ticket-d'); + } + + public function test_it_rejects_sort_columns_not_enabled_for_the_tenant(): void + { + $tenant = $this->createTenant('other'); + $this->grantTicketsMenu($tenant); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $this->getJson('/api/v1/adminapp/tenant/tickets?sort_by=category&sort_direction=sideways') + ->assertUnprocessable() + ->assertJsonValidationErrors(['sort_by', 'sort_direction']); + } + public function test_it_includes_tenant_scanned_and_total_ticket_counts(): void { $tenant = $this->createTenant('fiesta_futbol_infantil');