From 836aa1afd70e26cf3ab2056dbafd01398ec082c5 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Tue, 1 Sep 2026 14:29:26 -0300 Subject: [PATCH] feat(tickets): refine FFI variant filters --- .../Services/TicketFilterFormService.php | 59 +++++++---- .../Forms/Services/TicketFormService.php | 90 ++++++++++++++--- .../Requests/AdminAppTicketIndexRequest.php | 1 + .../Ticket/Services/AdminAppTicketService.php | 58 ++++++++--- ...AdminAppTicketFilterFormControllerTest.php | 97 ++++++++++++++----- .../Ticket/AdminAppTicketControllerTest.php | 69 ++++++++++++- 6 files changed, 300 insertions(+), 74 deletions(-) diff --git a/app/Domains/Forms/Services/TicketFilterFormService.php b/app/Domains/Forms/Services/TicketFilterFormService.php index f9f22bc..72ccc65 100644 --- a/app/Domains/Forms/Services/TicketFilterFormService.php +++ b/app/Domains/Forms/Services/TicketFilterFormService.php @@ -23,7 +23,7 @@ class TicketFilterFormService if ($tenant->codigo === self::FIESTA_FUTBOL_INFANTIL) { $fields = [ ...$this->fiestaFutbolInfantilFields($tenant), - ...$fields, + ...$this->commonFields(includeDate: false), ]; } @@ -55,20 +55,40 @@ class TicketFilterFormService 'value' => $category['value'], 'label' => $category['label'], 'children' => [ - 'field' => 'product', - 'disabled' => $category['products'] === [], - 'options' => array_map( - fn (array $product): array => [ - 'value' => $product['value'], - 'label' => $product['label'], - 'children' => [ - 'field' => 'type', - 'disabled' => $product['types'] === [], - 'options' => $product['types'], + [ + 'field' => 'product', + 'disabled' => $category['products'] === [], + 'options' => array_map( + fn (array $product): array => [ + 'value' => $product['value'], + 'label' => $product['label'], + 'children' => [ + [ + 'field' => 'type', + 'disabled' => $product['types'] === [], + 'options' => array_map( + fn (array $type): array => [ + 'value' => $type['value'], + 'label' => $type['label'], + 'children' => [[ + 'field' => 'size', + 'disabled' => ($type['sizes'] ?? []) === [], + 'options' => $type['sizes'] ?? [], + ]], + ], + $product['types'], + ), + ], + ], ], - ], - $category['products'], - ), + $category['products'], + ), + ], + [ + 'field' => 'date', + 'disabled' => ! in_array($category['value'], ['comidas', 'comida'], true), + 'options' => [], + ], ], ], $form['categories'], @@ -76,21 +96,26 @@ class TicketFilterFormService ], $this->dependentSelect('product', 'Producto', 'category'), $this->dependentSelect('type', 'Tipo', 'product'), + $this->dependentSelect('size', 'Talle', 'type'), + [ + ...$this->dependentSelect('date', 'Fecha', 'category'), + 'type' => 'date', + ], ]; } /** @return list> */ - private function commonFields(): array + private function commonFields(bool $includeDate = true): array { return [ - [ + ...($includeDate ? [[ 'name' => 'date', 'query_param' => 'date', 'label' => 'Fecha', 'type' => 'date', 'required' => false, 'default' => null, - ], + ]] : []), [ 'name' => 'status', 'query_param' => 'status', diff --git a/app/Domains/Forms/Services/TicketFormService.php b/app/Domains/Forms/Services/TicketFormService.php index 9c644de..b9436d2 100644 --- a/app/Domains/Forms/Services/TicketFormService.php +++ b/app/Domains/Forms/Services/TicketFormService.php @@ -54,6 +54,18 @@ class TicketFormService ], ]; + /** + * @var array + */ + private const FILTER_CATEGORY_PRESENTATIONS = [ + 'entradas' => ['label' => null, 'product' => self::PRODUCT, 'type' => null, 'size' => null, 'order' => 1], + 'alojamientos' => ['label' => 'Camping', 'product' => 'tipo_alojamiento', 'type' => null, 'size' => null, 'order' => 2], + 'camping' => ['label' => null, 'product' => 'tipo_alojamiento', 'type' => null, 'size' => null, 'order' => 2], + 'comidas' => ['label' => 'Comida', 'product' => 'horario', 'type' => 'servicio', 'size' => null, 'order' => 3], + 'comida' => ['label' => null, 'product' => 'horario', 'type' => 'servicio', 'size' => null, 'order' => 3], + 'merchandising' => ['label' => null, 'product' => self::PRODUCT, 'type' => 'color', 'size' => 'talle', 'order' => 4], + ]; + /** * @return array{ * statuses: list, @@ -79,7 +91,7 @@ class TicketFormService ->orderBy('nombre') ->get(); - return $this->build($items); + return $this->build($items, self::CATEGORY_PRESENTATIONS); } /** @@ -154,7 +166,7 @@ class TicketFormService ->orderBy('nombre') ->get(); - return $this->build($items); + return $this->build($items, self::FILTER_CATEGORY_PRESENTATIONS, includeSizes: true); } /** @@ -172,17 +184,18 @@ class TicketFormService * }> * } */ - private function build(Collection $items): array + private function build(Collection $items, array $presentations, bool $includeSizes = false): array { $categories = []; foreach ($items as $item) { $sourceCategory = trim((string) $item->category?->nombre); $categoryValue = mb_strtolower($sourceCategory); - $presentation = self::CATEGORY_PRESENTATIONS[$categoryValue] ?? [ + $presentation = $presentations[$categoryValue] ?? [ 'label' => null, 'product' => self::PRODUCT, 'type' => null, + 'size' => null, 'order' => PHP_INT_MAX, ]; @@ -193,18 +206,28 @@ class TicketFormService 'products' => [], ]; - foreach ($this->products($item, $presentation['product'], $presentation['type']) as $product) { + foreach ($this->products( + $item, + $presentation['product'], + $presentation['type'], + $presentation['size'] ?? null, + ) as $product) { $productValue = $product['value']; $existingProduct = $categories[$categoryValue]['products'][$productValue] ?? [ 'value' => $productValue, 'label' => $product['label'], 'types' => [], + 'sizes' => [], ]; foreach ($product['types'] as $type) { $existingProduct['types'][$type['value']] = $type; } + foreach ($product['sizes'] as $size) { + $existingProduct['sizes'][$size['value']] = $size; + } + $categories[$categoryValue]['products'][$productValue] = $existingProduct; } } @@ -227,6 +250,7 @@ class TicketFormService 'value' => $product['value'], 'label' => $product['label'], 'types' => array_values($product['types']), + ...($includeSizes ? ['sizes' => array_values($product['sizes'])] : []), ], $category['products'], )), @@ -252,16 +276,18 @@ class TicketFormService * @return list + * types: list, + * sizes: list * }> */ - private function products(CatalogItem $item, string $productCode, ?string $typeCode): array + private function products(CatalogItem $item, string $productCode, ?string $typeCode, ?string $sizeCode): array { if ($productCode === self::PRODUCT) { return [[ 'value' => $item->slug, 'label' => $item->nombre, - 'types' => $this->types($item, $typeCode), + 'types' => $this->types($item, $typeCode, $sizeCode), + 'sizes' => $this->types($item, $sizeCode), ]]; } @@ -274,10 +300,20 @@ class TicketFormService 'value' => $productValue, 'label' => $this->optionLabel($productOption['label'], $productCode), 'types' => [], + 'sizes' => [], ]; foreach ($this->variantOptions($variant, $typeCode) as $typeOption) { - $products[$productValue]['types'][$typeOption['value']] = $typeOption; + $this->mergeTypeOption( + $products[$productValue]['types'], + $typeOption, + $variant, + $sizeCode, + ); + } + + foreach ($this->variantOptions($variant, $sizeCode) as $sizeOption) { + $products[$productValue]['sizes'][$sizeOption['value']] = $sizeOption; } } } @@ -287,23 +323,51 @@ class TicketFormService 'value' => $product['value'], 'label' => $product['label'], 'types' => array_values($product['types']), + 'sizes' => array_values($product['sizes']), ], $products, )); } - /** @return list */ - private function types(CatalogItem $item, ?string $typeCode): array + /** @return list> */ + private function types(CatalogItem $item, ?string $typeCode, ?string $sizeCode = null): array { $types = []; foreach ($item->variants as $variant) { foreach ($this->variantOptions($variant, $typeCode) as $typeOption) { - $types[$typeOption['value']] = $typeOption; + $this->mergeTypeOption($types, $typeOption, $variant, $sizeCode); } } - return array_values($types); + return array_values(array_map(function (array $type) use ($sizeCode): array { + if ($sizeCode !== null) { + $type['sizes'] = array_values($type['sizes']); + } + + return $type; + }, $types)); + } + + /** + * @param array> $types + * @param array{value: string, label: string} $typeOption + */ + private function mergeTypeOption( + array &$types, + array $typeOption, + Variant $variant, + ?string $sizeCode, + ): void { + $typeValue = $typeOption['value']; + $types[$typeValue] ??= [ + ...$typeOption, + ...($sizeCode !== null ? ['sizes' => []] : []), + ]; + + foreach ($this->variantOptions($variant, $sizeCode) as $sizeOption) { + $types[$typeValue]['sizes'][$sizeOption['value']] = $sizeOption; + } } /** @return list */ diff --git a/app/Domains/Ticket/Requests/AdminAppTicketIndexRequest.php b/app/Domains/Ticket/Requests/AdminAppTicketIndexRequest.php index fe58b87..5a80395 100644 --- a/app/Domains/Ticket/Requests/AdminAppTicketIndexRequest.php +++ b/app/Domains/Ticket/Requests/AdminAppTicketIndexRequest.php @@ -28,6 +28,7 @@ class AdminAppTicketIndexRequest extends FormRequest 'product' => ['sometimes', 'nullable', 'string', 'max:255'], 'type' => ['sometimes', 'nullable', 'string', 'max:255'], 'date' => ['sometimes', 'nullable', 'date_format:Y-m-d'], + 'size' => ['sometimes', 'nullable', 'string', 'max:255'], 'status' => [ 'sometimes', 'nullable', diff --git a/app/Domains/Ticket/Services/AdminAppTicketService.php b/app/Domains/Ticket/Services/AdminAppTicketService.php index 6a3436b..021c063 100644 --- a/app/Domains/Ticket/Services/AdminAppTicketService.php +++ b/app/Domains/Ticket/Services/AdminAppTicketService.php @@ -27,7 +27,7 @@ class AdminAppTicketService ) {} /** - * @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 + * @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, sort_by?: string|null, sort_direction?: string|null} $filters */ public function search(Tenant $tenant, array $filters = []): AdminAppTicketResult { @@ -60,7 +60,7 @@ class AdminAppTicketService } /** - * @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 + * @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 @@ -76,7 +76,7 @@ class AdminAppTicketService } /** - * @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, size?: string|null, status?: string|null, page?: int, per_page?: int} $filters * @return Builder */ private function baseQuery(Tenant $tenant, array $filters): Builder @@ -104,9 +104,19 @@ class AdminAppTicketService ->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))); + ->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('sourcePurchase', 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); @@ -125,14 +135,7 @@ class AdminAppTicketService } 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)); - }); - }); + $this->whereVariantDefinition($query, 'horario', $product); return; } @@ -145,7 +148,7 @@ class AdminAppTicketService private function applyTypeFilter(Builder $query, string $category, string $type): void { $attribute = match ($this->normalizedCategory($category)) { - 'comidas', 'comida' => 'horario', + 'comidas', 'comida' => 'servicio', 'merchandising' => 'color', default => null, }; @@ -155,6 +158,31 @@ class AdminAppTicketService } } + /** @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 { diff --git a/tests/Feature/Forms/AdminAppTicketFilterFormControllerTest.php b/tests/Feature/Forms/AdminAppTicketFilterFormControllerTest.php index e97730e..43d19da 100644 --- a/tests/Feature/Forms/AdminAppTicketFilterFormControllerTest.php +++ b/tests/Feature/Forms/AdminAppTicketFilterFormControllerTest.php @@ -102,17 +102,25 @@ class AdminAppTicketFilterFormControllerTest extends TestCase ->assertJsonPath('data.fields.2.name', 'type') ->assertJsonPath('data.fields.2.query_param', 'type') ->assertJsonPath('data.fields.2.depends_on', 'product') - ->assertJsonPath('data.fields.3.name', 'date') - ->assertJsonPath('data.fields.3.query_param', 'date') - ->assertJsonPath('data.fields.4.name', 'status') - ->assertJsonPath('data.fields.4.query_param', 'status') + ->assertJsonPath('data.fields.3.name', 'size') + ->assertJsonPath('data.fields.3.query_param', 'size') + ->assertJsonPath('data.fields.3.depends_on', 'type') + ->assertJsonPath('data.fields.4.name', 'date') + ->assertJsonPath('data.fields.4.query_param', 'date') + ->assertJsonPath('data.fields.4.depends_on', 'category') + ->assertJsonPath('data.fields.5.name', 'status') + ->assertJsonPath('data.fields.5.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.2.sortable', false) ->assertJsonPath('data.columns.3.key', 'type') - ->assertJsonPath('data.columns.3.sortable', false); + ->assertJsonPath('data.columns.3.sortable', false) + ->assertJsonPath('data.columns.4.key', 'date') + ->assertJsonPath('data.columns.4.sortable', false) + ->assertJsonPath('data.columns.5.key', 'size') + ->assertJsonPath('data.columns.5.sortable', false); $categories = collect($response->json('data.fields.0.options')); $this->assertSame( @@ -121,22 +129,63 @@ class AdminAppTicketFilterFormControllerTest extends TestCase ); $entries = $categories->firstWhere('value', 'entradas'); - $this->assertSame('Abono', $entries['children']['options'][0]['label']); - $this->assertTrue($entries['children']['options'][0]['children']['disabled']); + $this->assertSame('Abono', $entries['children'][0]['options'][0]['label']); + $this->assertTrue($entries['children'][0]['options'][0]['children'][0]['disabled']); + $this->assertTrue($entries['children'][1]['disabled']); $camping = $categories->firstWhere('value', 'alojamientos'); $this->assertSame( ['Carpa', 'Motorhome'], - collect($camping['children']['options'])->pluck('label')->all(), + collect($camping['children'][0]['options'])->pluck('label')->all(), ); - $this->assertTrue($camping['children']['options'][0]['children']['disabled']); + $this->assertTrue($camping['children'][0]['options'][0]['children'][0]['disabled']); $merchandise = $categories->firstWhere('value', 'merchandising'); - $this->assertSame('Camiseta', $merchandise['children']['options'][0]['label']); + $merchandiseProduct = $merchandise['children'][0]['options'][0]; + $this->assertSame('Camiseta', $merchandiseProduct['label']); $this->assertSame( ['Verde', 'Blanco'], - collect($merchandise['children']['options'][0]['children']['options'])->pluck('label')->all(), + collect($merchandiseProduct['children'][0]['options'])->pluck('label')->all(), ); + $this->assertSame( + ['14', 'S', 'M', 'L', 'XL', 'XXL'], + collect($merchandiseProduct['children'][0]['options'][0]['children'][0]['options']) + ->pluck('label') + ->all(), + ); + } + + public function test_merchandise_sizes_are_scoped_to_the_selected_product_and_color(): void + { + $tenant = $this->createFiestaFutbolInfantilTenant(); + $this->grantTicketsMenu($tenant); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $merchandise = CatalogItem::query() + ->where('tenant_code', $tenant->codigo) + ->where('slug', 'camiseta') + ->with([ + 'itemAttributes.attribute.options', + 'variants.definitions.itemAttribute.attribute.options', + ]) + ->firstOrFail(); + $whiteXxl = $merchandise->variants->first(fn ($variant): bool => $variant->selectionValues()->get('color') === 'Blanco' + && $variant->selectionValues()->get('talle') === 'XXL'); + $this->assertNotNull($whiteXxl); + app(CatalogService::class)->deleteVariant($whiteXxl); + + $response = $this->getJson('/api/v1/adminapp/forms/tickets-filter')->assertOk(); + $merchandiseCategory = collect($response->json('data.fields.0.options')) + ->firstWhere('value', 'merchandising'); + $product = $merchandiseCategory['children'][0]['options'][0]; + $colors = collect($product['children'][0]['options']); + $greenSizes = collect($colors->firstWhere('value', 'Verde')['children'][0]['options']) + ->pluck('value'); + $whiteSizes = collect($colors->firstWhere('value', 'Blanco')['children'][0]['options']) + ->pluck('value'); + + $this->assertContains('XXL', $greenSizes); + $this->assertNotContains('XXL', $whiteSizes); } public function test_a_food_schedule_is_only_returned_when_a_variant_exists_for_the_date(): void @@ -145,7 +194,6 @@ class AdminAppTicketFilterFormControllerTest extends TestCase $this->grantTicketsMenu($tenant); Sanctum::actingAs($this->createAdminAppUser($tenant)); - $date = $tenant->eventDates()->orderByDesc('date')->firstOrFail(); $food = CatalogItem::query() ->where('tenant_code', $tenant->codigo) ->where('slug', 'comida') @@ -158,15 +206,12 @@ class AdminAppTicketFilterFormControllerTest extends TestCase ->firstOrFail(); $food->variants - ->filter(fn ($variant): bool => $variant->selectedEventDates()->contains('id', $date->id) - && $variant->selectionValues()->get('horario') === 'Almuerzo') + ->filter(fn ($variant): bool => $variant->selectionValues()->get('horario') === 'Almuerzo') ->each->delete(); $response = $this->getJson('/api/v1/adminapp/forms/tickets-filter')->assertOk(); $foodCategory = collect($response->json('data.fields.0.options'))->firstWhere('value', 'comidas'); - $dateProduct = collect($foodCategory['children']['options']) - ->firstWhere('value', (string) $date->id); - $schedules = collect($dateProduct['children']['options'])->pluck('value'); + $schedules = collect($foodCategory['children'][0]['options'])->pluck('value'); $this->assertNotContains('Almuerzo', $schedules); $this->assertContains('Desayuno', $schedules); @@ -195,6 +240,7 @@ class AdminAppTicketFilterFormControllerTest extends TestCase && $variant->selectionValues()->get('horario') === 'Cena'; }); $this->assertNotNull($historicalVariant); + $historicalService = $historicalVariant->selectionValues()->get('servicio'); $ticket = Ticket::query()->create([ 'tenant_code' => $tenant->codigo, @@ -213,26 +259,29 @@ class AdminAppTicketFilterFormControllerTest extends TestCase $response = $this->getJson('/api/v1/adminapp/forms/tickets-filter')->assertOk(); $foodCategory = collect($response->json('data.fields.0.options'))->firstWhere('value', 'comidas'); - $products = collect($foodCategory['children']['options']); + $products = collect($foodCategory['children'][0]['options']); $this->assertCount(1, $products); - $this->assertSame('12/10', $products->first()['label']); + $this->assertSame('Cena', $products->first()['label']); $this->assertSame( - ['Cena'], - collect($products->first()['children']['options'])->pluck('label')->all(), + [$historicalService], + collect($products->first()['children'][0]['options'])->pluck('label')->all(), ); $this->getJson('/api/v1/adminapp/tenant/tickets?'.http_build_query([ 'category' => 'comidas', 'product' => $products->first()['value'], - 'type' => 'Cena', + 'type' => $historicalService, + 'date' => '2026-10-12', ])) ->assertOk() ->assertJsonCount(1, 'data') ->assertJsonPath('data.0.id', $ticket->id) ->assertJsonPath('data.0.values.category', 'Comida') - ->assertJsonPath('data.0.values.product', '12/10') - ->assertJsonPath('data.0.values.type', 'Cena'); + ->assertJsonPath('data.0.values.product', 'Cena') + ->assertJsonPath('data.0.values.type', $historicalService) + ->assertJsonPath('data.0.values.date', '12/10') + ->assertJsonPath('data.0.values.size', '-'); } private function createFiestaFutbolInfantilTenant(): Tenant diff --git a/tests/Feature/Ticket/AdminAppTicketControllerTest.php b/tests/Feature/Ticket/AdminAppTicketControllerTest.php index 0c1b63f..5886a2f 100644 --- a/tests/Feature/Ticket/AdminAppTicketControllerTest.php +++ b/tests/Feature/Ticket/AdminAppTicketControllerTest.php @@ -73,7 +73,8 @@ class AdminAppTicketControllerTest extends TestCase ->assertJsonPath('data.0.values.id', $ticket->id) ->assertJsonPath('data.0.values.status', Ticket::STATUS_ACTIVE) ->assertJsonMissingPath('data.0.values.ticket') - ->assertJsonMissingPath('data.0.values.date') + ->assertJsonPath('data.0.values.date', '-') + ->assertJsonPath('data.0.values.size', '-') ->assertJsonMissingPath('data.0.date') ->assertJsonPath('meta.total', 1); } @@ -278,6 +279,13 @@ class AdminAppTicketControllerTest extends TestCase && $variant->selectionValues()->get('horario') === 'Almuerzo'); $this->assertNotNull($dinner); $this->assertNotNull($lunch); + $dinnerService = $dinner->selectionValues()->get('servicio'); + $otherDate = $tenant->eventDates()->where('id', '!=', $date->id)->firstOrFail(); + $otherDinner = $food->variants->first(fn (Variant $variant): bool => $variant + ->selectedEventDates()->contains('id', $otherDate->id) + && $variant->selectionValues()->get('horario') === 'Cena' + && $variant->selectionValues()->get('servicio') === $dinnerService); + $this->assertNotNull($otherDinner); $matchingPurchase = $this->createPurchase($tenant, $admin, '2026-08-20 10:00:00'); $otherPurchase = $this->createPurchase($tenant, $admin, '2026-08-21 10:00:00'); @@ -296,24 +304,75 @@ class AdminAppTicketControllerTest extends TestCase $this->createTicket($tenant, $admin, [ 'source_purchase_id' => $otherPurchase->id, 'source_catalog_item_id' => $food->id, - 'source_variant_id' => $dinner->id, + 'source_variant_id' => $otherDinner->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', + 'product' => 'Cena', + 'type' => $dinnerService, + 'date' => $date->date->format('Y-m-d'), 'status' => Ticket::STATUS_USED, ])) ->assertOk() ->assertJsonCount(1, 'data') ->assertJsonPath('data.0.id', $matching->id) + ->assertJsonPath('data.0.values.product', 'Cena') + ->assertJsonPath('data.0.values.type', $dinnerService) + ->assertJsonPath('data.0.values.date', $date->date->format('d/m')) ->assertJsonPath('scanned_tickets', 1) ->assertJsonPath('total_tickets', 1); } + public function test_it_exposes_and_filters_merchandise_color_and_size(): void + { + $tenant = $this->createTenant('fiesta_futbol_infantil'); + $admin = $this->createAdminAppUser($tenant); + $this->grantTicketsMenu($tenant); + $this->seed([AttributeSeeder::class, FiestaFutbolInfantilProductSeeder::class]); + Sanctum::actingAs($admin); + + $merchandise = CatalogItem::query() + ->where('tenant_code', $tenant->codigo) + ->where('slug', 'camiseta') + ->with([ + 'variants.definitions.itemAttribute.attribute.options', + 'variants.eventDates', + 'variants.eventDate', + ]) + ->firstOrFail(); + $greenMedium = $merchandise->variants->first(fn (Variant $variant): bool => $variant->selectionValues()->get('color') === 'Verde' + && $variant->selectionValues()->get('talle') === 'M'); + $whiteMedium = $merchandise->variants->first(fn (Variant $variant): bool => $variant->selectionValues()->get('color') === 'Blanco' + && $variant->selectionValues()->get('talle') === 'M'); + $this->assertNotNull($greenMedium); + $this->assertNotNull($whiteMedium); + + $matching = $this->createTicket($tenant, $admin, [ + 'source_catalog_item_id' => $merchandise->id, + 'source_variant_id' => $greenMedium->id, + ]); + $this->createTicket($tenant, $admin, [ + 'source_catalog_item_id' => $merchandise->id, + 'source_variant_id' => $whiteMedium->id, + ]); + + $this->getJson('/api/v1/adminapp/tenant/tickets?'.http_build_query([ + 'category' => 'merchandising', + 'product' => 'camiseta', + 'type' => 'Verde', + 'size' => 'M', + ])) + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.id', $matching->id) + ->assertJsonPath('data.0.values.product', 'Camiseta') + ->assertJsonPath('data.0.values.type', 'Verde') + ->assertJsonPath('data.0.values.date', '-') + ->assertJsonPath('data.0.values.size', 'M'); + } + public function test_it_filters_computed_active_and_expired_statuses(): void { $tenant = $this->createTenant('fiesta_futbol_infantil');