feat(tickets): add tenant-aware server-side sorting
This commit is contained in:
@@ -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<string, list<string>> */
|
||||
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'])],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ class AdminAppTicketColumnService
|
||||
{
|
||||
private const FIESTA_FUTBOL_INFANTIL = 'fiesta_futbol_infantil';
|
||||
|
||||
/** @return list<array{key: string, label: string, type: string, sortable: bool, width: string, excel_width: int}> */
|
||||
/** @return list<array{key: string, label: string, type: string, sortable: bool, sort_param: string, width: string, excel_width: int}> */
|
||||
public function columns(Tenant $tenant): array
|
||||
{
|
||||
$keys = $tenant->codigo === self::FIESTA_FUTBOL_INFANTIL
|
||||
@@ -38,7 +38,7 @@ class AdminAppTicketColumnService
|
||||
return $columns;
|
||||
}
|
||||
|
||||
/** @return list<array{key: string, label: string, type: string, sortable: bool, width: string}> */
|
||||
/** @return list<array{key: string, label: string, type: string, sortable: bool, sort_param: string, width: string}> */
|
||||
public function publicColumns(Tenant $tenant): array
|
||||
{
|
||||
return array_map(function (array $column): array {
|
||||
@@ -48,7 +48,16 @@ class AdminAppTicketColumnService
|
||||
}, $this->columns($tenant));
|
||||
}
|
||||
|
||||
/** @return array<string, array{key: string, label: string, type: string, sortable: bool, width: string, excel_width: int}> */
|
||||
/** @return list<string> */
|
||||
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<string, array{key: string, label: string, type: string, sortable: bool, sort_param: string, width: string, excel_width: int}> */
|
||||
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,
|
||||
];
|
||||
|
||||
@@ -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<int, Ticket>
|
||||
*/
|
||||
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<Ticket> $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<PurchaseItem> */
|
||||
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<int, Ticket> $tickets
|
||||
* @param array{sort_by?: string|null, sort_direction?: string|null} $filters
|
||||
* @return Collection<int, Ticket>
|
||||
*/
|
||||
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<int, Ticket> $tickets
|
||||
* @param array{page?: int, per_page?: int} $filters
|
||||
* @return LengthAwarePaginator<Ticket>
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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%'],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user