From b1f42775d6c8a03215aca81589240b20b57f6ced Mon Sep 17 00:00:00 2001 From: ncoronel Date: Fri, 28 Aug 2026 17:00:47 -0300 Subject: [PATCH] feat(tickets): add TicketFormController, TicketFormService, and TicketFormResource with related routes and tests --- .../AdminApp/TicketFormController.php | 22 ++ .../Forms/Resources/TicketFormResource.php | 18 ++ .../Forms/Services/TicketFormService.php | 234 ++++++++++++++++++ app/Domains/Forms/documentacion/README.md | 2 + app/Domains/Forms/routes/adminapp.php | 5 + phpunit.xml | 3 +- .../AdminAppTicketFormControllerTest.php | 155 ++++++++++++ tests/TestCase.php | 23 +- 8 files changed, 460 insertions(+), 2 deletions(-) create mode 100644 app/Domains/Forms/Controllers/AdminApp/TicketFormController.php create mode 100644 app/Domains/Forms/Resources/TicketFormResource.php create mode 100644 app/Domains/Forms/Services/TicketFormService.php create mode 100644 tests/Feature/Forms/AdminAppTicketFormControllerTest.php diff --git a/app/Domains/Forms/Controllers/AdminApp/TicketFormController.php b/app/Domains/Forms/Controllers/AdminApp/TicketFormController.php new file mode 100644 index 0000000..5dc3936 --- /dev/null +++ b/app/Domains/Forms/Controllers/AdminApp/TicketFormController.php @@ -0,0 +1,22 @@ +ticketFormService->get( + $request->user('sanctum')->tenant()->firstOrFail() + ) + ); + } +} diff --git a/app/Domains/Forms/Resources/TicketFormResource.php b/app/Domains/Forms/Resources/TicketFormResource.php new file mode 100644 index 0000000..dcaa7a9 --- /dev/null +++ b/app/Domains/Forms/Resources/TicketFormResource.php @@ -0,0 +1,18 @@ + */ + public function toArray(Request $request): array + { + return [ + 'statuses' => $this->resource['statuses'], + 'categories' => $this->resource['categories'], + ]; + } +} diff --git a/app/Domains/Forms/Services/TicketFormService.php b/app/Domains/Forms/Services/TicketFormService.php new file mode 100644 index 0000000..c8690db --- /dev/null +++ b/app/Domains/Forms/Services/TicketFormService.php @@ -0,0 +1,234 @@ + + */ + private const CATEGORY_PRESENTATIONS = [ + 'entradas' => [ + 'label' => null, + 'product' => self::PRODUCT, + 'type' => null, + 'order' => 1, + ], + 'alojamientos' => [ + 'label' => 'Camping', + 'product' => 'tipo_alojamiento', + 'type' => null, + 'order' => 2, + ], + 'camping' => [ + 'label' => null, + 'product' => 'tipo_alojamiento', + 'type' => null, + 'order' => 2, + ], + 'comidas' => [ + 'label' => 'Comida', + 'product' => 'event_date', + 'type' => 'horario', + 'order' => 3, + ], + 'comida' => [ + 'label' => null, + 'product' => 'event_date', + 'type' => 'horario', + 'order' => 3, + ], + 'merchandising' => [ + 'label' => null, + 'product' => self::PRODUCT, + 'type' => 'color', + 'order' => 4, + ], + ]; + + /** + * @return array{ + * statuses: list, + * categories: list + * }> + * }> + * } + */ + public function get(Tenant $tenant): array + { + $categories = []; + + $items = CatalogItem::query() + ->where('tenant_code', $tenant->codigo) + ->where('has_tickets', true) + ->whereHas('category') + ->with([ + 'category', + 'itemAttributes.attribute.options', + 'variants.definitions.itemAttribute.attribute.options', + 'variants.eventDates', + 'variants.eventDate', + ]) + ->orderBy('group_order') + ->orderBy('nombre') + ->get(); + + foreach ($items as $item) { + $sourceCategory = trim((string) $item->category?->nombre); + $categoryValue = mb_strtolower($sourceCategory); + $presentation = self::CATEGORY_PRESENTATIONS[$categoryValue] ?? [ + 'label' => null, + 'product' => self::PRODUCT, + 'type' => null, + 'order' => PHP_INT_MAX, + ]; + + $categories[$categoryValue] ??= [ + 'value' => $categoryValue, + 'label' => $presentation['label'] ?? $sourceCategory, + 'order' => $presentation['order'], + 'products' => [], + ]; + + foreach ($this->products($item, $presentation['product'], $presentation['type']) as $product) { + $productValue = $product['value']; + $existingProduct = $categories[$categoryValue]['products'][$productValue] ?? [ + 'value' => $productValue, + 'label' => $product['label'], + 'types' => [], + ]; + + foreach ($product['types'] as $type) { + $existingProduct['types'][$type['value']] = $type; + } + + $categories[$categoryValue]['products'][$productValue] = $existingProduct; + } + } + + uasort($categories, fn (array $left, array $right): int => $left['order'] <=> $right['order'] + ?: $left['label'] <=> $right['label']); + + return [ + 'statuses' => [ + ['value' => Ticket::STATUS_ACTIVE, 'label' => 'Activo'], + ['value' => Ticket::STATUS_USED, 'label' => 'Usado'], + ['value' => Ticket::STATUS_EXPIRED, 'label' => 'Vencido'], + ], + 'categories' => array_values(array_map( + fn (array $category): array => [ + 'value' => $category['value'], + 'label' => $category['label'], + 'products' => array_values(array_map( + fn (array $product): array => [ + 'value' => $product['value'], + 'label' => $product['label'], + 'types' => array_values($product['types']), + ], + $category['products'], + )), + ], + $categories, + )), + ]; + } + + /** + * @return list + * }> + */ + private function products(CatalogItem $item, string $productCode, ?string $typeCode): array + { + if ($productCode === self::PRODUCT) { + return [[ + 'value' => $item->slug, + 'label' => $item->nombre, + 'types' => $this->types($item, $typeCode), + ]]; + } + + $products = []; + + foreach ($item->variants as $variant) { + foreach ($this->variantOptions($variant, $productCode) as $productOption) { + $productValue = $productOption['value']; + $products[$productValue] ??= [ + 'value' => $productValue, + 'label' => $this->optionLabel($productOption['label'], $productCode), + 'types' => [], + ]; + + foreach ($this->variantOptions($variant, $typeCode) as $typeOption) { + $products[$productValue]['types'][$typeOption['value']] = $typeOption; + } + } + } + + return array_values(array_map( + fn (array $product): array => [ + 'value' => $product['value'], + 'label' => $product['label'], + 'types' => array_values($product['types']), + ], + $products, + )); + } + + /** @return list */ + private function types(CatalogItem $item, ?string $typeCode): array + { + $types = []; + + foreach ($item->variants as $variant) { + foreach ($this->variantOptions($variant, $typeCode) as $typeOption) { + $types[$typeOption['value']] = $typeOption; + } + } + + return array_values($types); + } + + /** @return list */ + private function variantOptions(Variant $variant, ?string $attributeCode): array + { + if ($attributeCode === null) { + return []; + } + + $selection = $variant->selectionOptions($variant->catalogItem->itemAttributes) + ->get($attributeCode); + + if ($selection === null) { + return []; + } + + return array_is_list($selection) ? $selection : [$selection]; + } + + private function optionLabel(string $label, string $attributeCode): string + { + if ($attributeCode !== 'event_date') { + return $label; + } + + [$day, $month] = array_pad(explode('/', $label), 2, null); + + return $day !== null && $month !== null ? "{$day}/{$month}" : $label; + } +} diff --git a/app/Domains/Forms/documentacion/README.md b/app/Domains/Forms/documentacion/README.md index 8f3a84b..9a3782d 100644 --- a/app/Domains/Forms/documentacion/README.md +++ b/app/Domains/Forms/documentacion/README.md @@ -9,6 +9,7 @@ Provee catálogos y opciones auxiliares para construir formularios del panel adm - `EventFormService`: devuelve redes sociales disponibles y las URL configuradas para el tenant. - `SaleFormService`: expone los estados admitidos para compras con sus etiquetas de presentación. - `StaffFormService`: lista categorías raíz que pueden asignarse al personal del tenant. +- `TicketFormService`: expone estados y opciones anidadas de categoría, producto y tipo para los tickets de Fiesta Fútbol Infantil. Cada servicio tiene un controlador invocable y un `JsonResource` específico. `SocialMediaOptionResource` representa las opciones de redes sociales. @@ -19,6 +20,7 @@ Bajo `/v1/adminapp/forms`, con `auth:sanctum` y `adminapp.tenant`: - `GET /event`. - `GET /sale`. - `GET /staff`. +- `GET /fiesta-futbol-infantil/ticket`: estados y jerarquía categoría → producto → tipo para filtros de tickets. - `GET /fiesta-futbol-infantil/merchandise`: opciones de color y talle del tenant para merchandising. ## Dependencias diff --git a/app/Domains/Forms/routes/adminapp.php b/app/Domains/Forms/routes/adminapp.php index cb8683f..21a92d1 100644 --- a/app/Domains/Forms/routes/adminapp.php +++ b/app/Domains/Forms/routes/adminapp.php @@ -5,6 +5,7 @@ use App\Domains\Forms\Controllers\AdminApp\FoodFormController; use App\Domains\Forms\Controllers\AdminApp\MerchandiseFormController; use App\Domains\Forms\Controllers\AdminApp\SaleFormController; use App\Domains\Forms\Controllers\AdminApp\StaffFormController; +use App\Domains\Forms\Controllers\AdminApp\TicketFormController; use Illuminate\Support\Facades\Route; Route::prefix('v1/adminapp/forms') @@ -13,6 +14,10 @@ Route::prefix('v1/adminapp/forms') Route::get('event', EventFormController::class); Route::get('sale', SaleFormController::class); Route::get('staff', StaffFormController::class); + Route::get( + 'fiesta-futbol-infantil/ticket', + TicketFormController::class + ); Route::get( 'fiesta-futbol-infantil/merchandise', MerchandiseFormController::class diff --git a/phpunit.xml b/phpunit.xml index 642274a..0bce1ba 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -18,7 +18,8 @@ - + + diff --git a/tests/Feature/Forms/AdminAppTicketFormControllerTest.php b/tests/Feature/Forms/AdminAppTicketFormControllerTest.php new file mode 100644 index 0000000..5bb4ef3 --- /dev/null +++ b/tests/Feature/Forms/AdminAppTicketFormControllerTest.php @@ -0,0 +1,155 @@ +seed(AuthorizationSeeder::class); + } + + public function test_authentication_is_required(): void + { + $this->getJson('/api/v1/adminapp/forms/fiesta-futbol-infantil/ticket') + ->assertUnauthorized(); + } + + public function test_it_returns_nested_ticket_options_using_the_frontend_presentation_mapping(): void + { + $headerLogo = $this->createAttachment('header.png'); + $footerLogo = $this->createAttachment('footer.png'); + $tenant = Tenant::query()->create([ + 'codigo' => 'fiesta_futbol_infantil', + 'nombre' => 'Fiesta Fútbol Infantil', + 'dominio' => 'fiesta-futbol-infantil.test', + 'primary_color' => '#00973F', + 'secondary_color' => '#A0A0A0', + 'danger_color' => '#FF8888', + 'success_color' => '#198754', + 'header_bg_color' => '#ffffff', + 'footer_bg_color' => '#015327', + 'header_logo_id' => $headerLogo->id, + 'footer_logo_id' => $footerLogo->id, + ]); + $this->seed([AttributeSeeder::class, FiestaFutbolInfantilProductSeeder::class]); + + Sanctum::actingAs(User::factory()->create([ + 'rol_codigo' => RoleCode::AdminApp->value, + 'tenant_codigo' => $tenant->codigo, + ])); + + $response = $this->getJson('/api/v1/adminapp/forms/fiesta-futbol-infantil/ticket') + ->assertOk() + ->assertExactJson([ + 'data' => [ + 'statuses' => [ + ['value' => 'active', 'label' => 'Activo'], + ['value' => 'used', 'label' => 'Usado'], + ['value' => 'expired', 'label' => 'Vencido'], + ], + 'categories' => [ + [ + 'value' => 'entradas', + 'label' => 'Entradas', + 'products' => [[ + 'value' => 'abono', + 'label' => 'Abono', + 'types' => [], + ]], + ], + [ + 'value' => 'alojamientos', + 'label' => 'Camping', + 'products' => [ + ['value' => 'Carpa', 'label' => 'Carpa', 'types' => []], + ['value' => 'Motorhome', 'label' => 'Motorhome', 'types' => []], + ], + ], + [ + 'value' => 'comidas', + 'label' => 'Comida', + 'products' => [ + [ + 'value' => (string) $tenant->eventDates[0]->id, + 'label' => '09/10', + 'types' => [ + ['value' => 'Desayuno', 'label' => 'Desayuno'], + ['value' => 'Almuerzo', 'label' => 'Almuerzo'], + ['value' => 'Cena', 'label' => 'Cena'], + ], + ], + [ + 'value' => (string) $tenant->eventDates[1]->id, + 'label' => '10/10', + 'types' => [ + ['value' => 'Desayuno', 'label' => 'Desayuno'], + ['value' => 'Almuerzo', 'label' => 'Almuerzo'], + ['value' => 'Cena', 'label' => 'Cena'], + ], + ], + [ + 'value' => (string) $tenant->eventDates[2]->id, + 'label' => '11/10', + 'types' => [ + ['value' => 'Desayuno', 'label' => 'Desayuno'], + ['value' => 'Almuerzo', 'label' => 'Almuerzo'], + ['value' => 'Cena', 'label' => 'Cena'], + ], + ], + [ + 'value' => (string) $tenant->eventDates[3]->id, + 'label' => '12/10', + 'types' => [ + ['value' => 'Desayuno', 'label' => 'Desayuno'], + ['value' => 'Almuerzo', 'label' => 'Almuerzo'], + ['value' => 'Cena', 'label' => 'Cena'], + ], + ], + ], + ], + [ + 'value' => 'merchandising', + 'label' => 'Merchandising', + 'products' => [[ + 'value' => 'camiseta', + 'label' => 'Camiseta', + 'types' => [ + ['value' => 'Verde', 'label' => 'Verde'], + ['value' => 'Blanco', 'label' => 'Blanco'], + ], + ]], + ], + ], + ], + ]); + + $response->assertJsonMissingPath('data.categories.0.products.0.category'); + } + + private function createAttachment(string $filename): Attachment + { + return Attachment::query()->create([ + 'path' => "tests/{$filename}", + 'filename' => $filename, + 'type' => AttachmentType::Image, + 'mime_type' => 'image/png', + ]); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index fe1ffc2..19a12de 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,9 +2,30 @@ namespace Tests; +use Illuminate\Foundation\Application; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; +use RuntimeException; abstract class TestCase extends BaseTestCase { - // + /** + * Boot the application only when the test database is explicitly isolated. + */ + public function createApplication(): Application + { + $app = parent::createApplication(); + + $database = (string) $app['config']->get( + 'database.connections.'.$app['config']->get('database.default').'.database' + ); + + if (! preg_match('/^shopit_(?:test|testing)(?:_\d+)?$/', $database)) { + throw new RuntimeException(sprintf( + 'Refusing to run tests against database [%s]. Use [shopit_test] or [shopit_testing].', + $database !== '' ? $database : '(empty)' + )); + } + + return $app; + } }