From 9175df1d7f79dce5b783d1c8bf344013eacb0fba Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 31 Aug 2026 11:06:30 -0300 Subject: [PATCH] feat(tickets): enhance AdminAppTicketIndexRequest and AdminAppTicketService with additional filters and update tests for ticket filtering functionality --- .../Requests/AdminAppTicketIndexRequest.php | 15 +++ .../Ticket/Services/AdminAppTicketService.php | 102 +++++++++++++++- ...AdminAppTicketFilterFormControllerTest.php | 11 +- .../Ticket/AdminAppTicketControllerTest.php | 110 ++++++++++++++++++ 4 files changed, 234 insertions(+), 4 deletions(-) diff --git a/app/Domains/Ticket/Requests/AdminAppTicketIndexRequest.php b/app/Domains/Ticket/Requests/AdminAppTicketIndexRequest.php index e54dec3..e4e6d08 100644 --- a/app/Domains/Ticket/Requests/AdminAppTicketIndexRequest.php +++ b/app/Domains/Ticket/Requests/AdminAppTicketIndexRequest.php @@ -2,7 +2,9 @@ namespace App\Domains\Ticket\Requests; +use App\Domains\Ticket\Models\Ticket; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; class AdminAppTicketIndexRequest extends FormRequest { @@ -16,6 +18,19 @@ class AdminAppTicketIndexRequest extends FormRequest { return [ 'q' => ['sometimes', 'nullable', 'string', 'max:255'], + 'category' => ['sometimes', 'nullable', 'string', 'max:255'], + 'product' => ['sometimes', 'nullable', 'string', 'max:255'], + 'type' => ['sometimes', 'nullable', 'string', 'max:255'], + 'date' => ['sometimes', 'nullable', 'date_format:Y-m-d'], + 'status' => [ + 'sometimes', + 'nullable', + Rule::in([ + Ticket::STATUS_ACTIVE, + Ticket::STATUS_USED, + Ticket::STATUS_EXPIRED, + ]), + ], 'page' => ['sometimes', 'integer', 'min:1'], 'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'], ]; diff --git a/app/Domains/Ticket/Services/AdminAppTicketService.php b/app/Domains/Ticket/Services/AdminAppTicketService.php index 426c715..292c510 100644 --- a/app/Domains/Ticket/Services/AdminAppTicketService.php +++ b/app/Domains/Ticket/Services/AdminAppTicketService.php @@ -9,7 +9,7 @@ use Illuminate\Database\Eloquent\Builder; class AdminAppTicketService { /** - * @param array{q?: 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} $filters */ public function search(Tenant $tenant, array $filters = []): AdminAppTicketResult { @@ -36,14 +36,14 @@ class AdminAppTicketService } /** - * @param array{q?: 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} $filters * @return Builder */ private function baseQuery(Tenant $tenant, array $filters): Builder { $search = trim((string) ($filters['q'] ?? '')); - return Ticket::query() + $query = Ticket::query() ->where('tenant_code', $tenant->codigo) ->when($search !== '', function (Builder $query) use ($search): void { $query->when( @@ -53,6 +53,102 @@ class AdminAppTicketService fn (Builder $searchQuery): Builder => $searchQuery ->where('ticket', 'like', "%{$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, fn (Builder $query, string $date): Builder => $query + ->whereHas('sourcePurchase', fn (Builder $purchaseQuery): Builder => $purchaseQuery + ->whereDate('created_at', $date))); + + $this->applyStatusFilter($query, $filters['status'] ?? null); + + return $query; + } + + /** @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)) { + $query->whereHas('sourceVariant', function (Builder $variantQuery) use ($product): void { + $variantQuery->where(function (Builder $dateQuery) use ($product): void { + $dateQuery + ->where('event_date_id', $product) + ->orWhereHas('eventDates', fn (Builder $eventDateQuery): Builder => $eventDateQuery + ->whereKey($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' => 'horario', + 'merchandising' => 'color', + default => null, + }; + + if ($attribute !== null) { + $this->whereVariantDefinition($query, $attribute, $type); + } + } + + /** @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)); } } diff --git a/tests/Feature/Forms/AdminAppTicketFilterFormControllerTest.php b/tests/Feature/Forms/AdminAppTicketFilterFormControllerTest.php index 6a01eb6..b362c99 100644 --- a/tests/Feature/Forms/AdminAppTicketFilterFormControllerTest.php +++ b/tests/Feature/Forms/AdminAppTicketFilterFormControllerTest.php @@ -181,7 +181,7 @@ class AdminAppTicketFilterFormControllerTest extends TestCase }); $this->assertNotNull($historicalVariant); - Ticket::query()->create([ + $ticket = Ticket::query()->create([ 'tenant_code' => $tenant->codigo, 'ticket' => (string) Str::uuid(), 'user_id' => $admin->id, @@ -206,6 +206,15 @@ class AdminAppTicketFilterFormControllerTest extends TestCase ['Cena'], collect($products->first()['children']['options'])->pluck('label')->all(), ); + + $this->getJson('/api/v1/adminapp/tenant/tickets?'.http_build_query([ + 'category' => 'comidas', + 'product' => $products->first()['value'], + 'type' => 'Cena', + ])) + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.id', $ticket->id); } private function createFiestaFutbolInfantilTenant(): Tenant diff --git a/tests/Feature/Ticket/AdminAppTicketControllerTest.php b/tests/Feature/Ticket/AdminAppTicketControllerTest.php index 15f867f..ca130a0 100644 --- a/tests/Feature/Ticket/AdminAppTicketControllerTest.php +++ b/tests/Feature/Ticket/AdminAppTicketControllerTest.php @@ -17,7 +17,9 @@ use App\Domains\Shared\Enums\FieldType; use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\WebsiteType; use App\Domains\Ticket\Models\Ticket; +use Database\Seeders\AttributeSeeder; use Database\Seeders\AuthorizationSeeder; +use Database\Seeders\FiestaFutbolInfantilProductSeeder; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Str; use Laravel\Sanctum\Sanctum; @@ -188,6 +190,100 @@ class AdminAppTicketControllerTest extends TestCase ->assertJsonPath('data.0.variant_properties.0.values.0.label', 'XL'); } + public function test_it_applies_the_ticket_filter_form_values(): void + { + $tenant = $this->createTenant('fiesta_futbol_infantil'); + $admin = $this->createAdminAppUser($tenant); + $this->grantTicketsMenu($tenant); + $this->seed([AttributeSeeder::class, FiestaFutbolInfantilProductSeeder::class]); + Sanctum::actingAs($admin); + + $date = $tenant->eventDates()->orderByDesc('date')->firstOrFail(); + $food = CatalogItem::query() + ->where('tenant_code', $tenant->codigo) + ->where('slug', 'comida') + ->with([ + 'variants.definitions.itemAttribute.attribute.options', + 'variants.eventDates', + 'variants.eventDate', + ]) + ->firstOrFail(); + $dinner = $food->variants->first(fn (Variant $variant): bool => $variant + ->selectedEventDates()->contains('id', $date->id) + && $variant->selectionValues()->get('horario') === 'Cena'); + $lunch = $food->variants->first(fn (Variant $variant): bool => $variant + ->selectedEventDates()->contains('id', $date->id) + && $variant->selectionValues()->get('horario') === 'Almuerzo'); + $this->assertNotNull($dinner); + $this->assertNotNull($lunch); + + $matchingPurchase = $this->createPurchase($tenant, $admin, '2026-08-20 10:00:00'); + $otherPurchase = $this->createPurchase($tenant, $admin, '2026-08-21 10:00:00'); + $matching = $this->createTicket($tenant, $admin, [ + 'source_purchase_id' => $matchingPurchase->id, + 'source_catalog_item_id' => $food->id, + 'source_variant_id' => $dinner->id, + 'used_at' => now(), + ]); + $this->createTicket($tenant, $admin, [ + 'source_purchase_id' => $matchingPurchase->id, + 'source_catalog_item_id' => $food->id, + 'source_variant_id' => $lunch->id, + 'used_at' => now(), + ]); + $this->createTicket($tenant, $admin, [ + 'source_purchase_id' => $otherPurchase->id, + 'source_catalog_item_id' => $food->id, + 'source_variant_id' => $dinner->id, + 'used_at' => now(), + ]); + + $this->getJson('/api/v1/adminapp/tenant/tickets?'.http_build_query([ + 'category' => 'comidas', + 'product' => (string) $date->id, + 'type' => 'Cena', + 'date' => '2026-08-20', + 'status' => Ticket::STATUS_USED, + ])) + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.id', $matching->id) + ->assertJsonPath('scanned_tickets', 1) + ->assertJsonPath('total_tickets', 1); + } + + public function test_it_filters_computed_active_and_expired_statuses(): void + { + $tenant = $this->createTenant('fiesta_futbol_infantil'); + $admin = $this->createAdminAppUser($tenant); + $this->grantTicketsMenu($tenant); + $this->seed([AttributeSeeder::class, FiestaFutbolInfantilProductSeeder::class]); + Sanctum::actingAs($admin); + + $food = CatalogItem::query() + ->where('tenant_code', $tenant->codigo) + ->where('slug', 'comida') + ->with('variants') + ->firstOrFail(); + $expired = $this->createTicket($tenant, $admin, [ + 'source_catalog_item_id' => $food->id, + 'source_variant_id' => $food->variants->firstOrFail()->id, + ]); + $active = $this->createTicket($tenant, $admin); + + $this->travelTo('2026-10-20 12:00:00'); + + $this->getJson('/api/v1/adminapp/tenant/tickets?status=expired') + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.id', $expired->id); + + $this->getJson('/api/v1/adminapp/tenant/tickets?status=active') + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.id', $active->id); + } + private function createTenant(string $code): Tenant { $headerLogo = $this->createAttachment("{$code}-header.png"); @@ -227,6 +323,20 @@ class AdminAppTicketControllerTest extends TestCase ]); } + private function createPurchase(Tenant $tenant, User $user, string $createdAt): Purchase + { + $purchase = Purchase::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'user_id' => $user->id, + 'status' => Purchase::STATUS_PAID, + 'nombre_apellido' => $user->nombre_apellido, + 'total' => '0.00', + ]); + $purchase->forceFill(['created_at' => $createdAt, 'updated_at' => $createdAt])->saveQuietly(); + + return $purchase; + } + private function grantTicketsMenu(Tenant $tenant): void { $menu = Menu::query()->create([