From 57d278336dc2eca16a45d683ef2de2918b3932cd Mon Sep 17 00:00:00 2001 From: ncoronel Date: Fri, 25 Sep 2026 11:15:52 -0300 Subject: [PATCH] feat(desfile): associate tickets menu with desfile tenant and add migration test --- .../Requests/AdminAppTicketIndexRequest.php | 16 ++- .../AdminAppTicketAttributeService.php | 92 +++++++++++++++ .../Services/AdminAppTicketColumnService.php | 33 ++++++ .../Services/AdminAppTicketRowService.php | 9 ++ .../Ticket/Services/AdminAppTicketService.php | 10 +- .../Services/TicketFilterFormService.php | 26 ++++ ...ate_adminapp_tickets_menu_with_desfile.php | 43 +++++++ database/seeders/MenuSeeder.php | 8 +- ...iateAdminAppTicketsMenuWithDesfileTest.php | 81 +++++++++++++ .../Ticket/AdminAppTicketControllerTest.php | 111 ++++++++++++++++-- .../AdminAppTicketExportServiceTest.php | 3 +- 11 files changed, 421 insertions(+), 11 deletions(-) create mode 100644 app/Domains/Ticketing/Ticket/Services/AdminAppTicketAttributeService.php create mode 100644 database/migrations/2026_09_25_000000_associate_adminapp_tickets_menu_with_desfile.php create mode 100644 tests/Feature/Migrations/AssociateAdminAppTicketsMenuWithDesfileTest.php diff --git a/app/Domains/Ticketing/Ticket/Requests/AdminAppTicketIndexRequest.php b/app/Domains/Ticketing/Ticket/Requests/AdminAppTicketIndexRequest.php index 4bcf8ed4..6e55b156 100644 --- a/app/Domains/Ticketing/Ticket/Requests/AdminAppTicketIndexRequest.php +++ b/app/Domains/Ticketing/Ticket/Requests/AdminAppTicketIndexRequest.php @@ -3,6 +3,7 @@ namespace App\Domains\Ticketing\Ticket\Requests; use App\Domains\Ticketing\Ticket\Models\Ticket; +use App\Domains\Ticketing\Ticket\Services\AdminAppTicketAttributeService; use App\Domains\Ticketing\Ticket\Services\AdminAppTicketColumnService; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -22,7 +23,7 @@ class AdminAppTicketIndexRequest extends FormRequest ? [] : app(AdminAppTicketColumnService::class)->sortableKeys($tenant); - return [ + $rules = [ 'q' => ['sometimes', 'nullable', 'string', 'max:255'], 'category' => ['sometimes', 'nullable', 'string', 'max:255'], 'product' => ['sometimes', 'nullable', 'string', 'max:255'], @@ -39,5 +40,18 @@ class AdminAppTicketIndexRequest extends FormRequest 'sort_by' => ['sometimes', 'nullable', 'string', Rule::in($sortableKeys)], 'sort_direction' => ['sometimes', 'nullable', 'string', Rule::in(['asc', 'desc'])], ]; + + if ($tenant !== null) { + foreach (app(AdminAppTicketAttributeService::class)->attributes($tenant) as $attribute) { + $rules[$attribute['code']] = [ + 'sometimes', + 'nullable', + 'string', + Rule::in(array_column($attribute['options'], 'value')), + ]; + } + } + + return $rules; } } diff --git a/app/Domains/Ticketing/Ticket/Services/AdminAppTicketAttributeService.php b/app/Domains/Ticketing/Ticket/Services/AdminAppTicketAttributeService.php new file mode 100644 index 00000000..46f56e76 --- /dev/null +++ b/app/Domains/Ticketing/Ticket/Services/AdminAppTicketAttributeService.php @@ -0,0 +1,92 @@ + + * }> + */ + public function attributes(Tenant $tenant): array + { + if ($tenant->codigo !== self::DESFILE_PURA_TENDENCIA) { + return []; + } + + $items = CatalogItem::withTrashed() + ->where('tenant_code', $tenant->codigo) + ->where(function (Builder $query): void { + $query + ->where(function (Builder $activeQuery): void { + $activeQuery + ->whereNull('catalog_items.deleted_at') + ->where('has_tickets', true); + }) + ->orWhereHas('sourceTickets'); + }) + ->with([ + 'itemAttributes' => fn ($query) => $query->orderBy('sort_order')->orderBy('id'), + 'itemAttributes.attribute.options', + 'variants' => fn ($query) => $query + ->withTrashed() + ->where(fn (Builder $variantQuery): Builder => $variantQuery + ->whereNull('variantes.deleted_at') + ->orWhereHas('sourceTickets')) + ->orderBy('id'), + 'variants.definitions.itemAttribute.attribute.options', + ]) + ->get(); + + $attributes = []; + + foreach ($items as $item) { + foreach ($item->itemAttributes as $itemAttribute) { + $attribute = $itemAttribute->attribute; + if ($attribute === null) { + continue; + } + + $code = $attribute->codigo; + $attributes[$code] ??= [ + 'code' => $code, + 'label' => $itemAttribute->ticket_label ?: $attribute->nombre, + 'sort_order' => $itemAttribute->sort_order, + 'options' => [], + ]; + + foreach ($item->variants as $variant) { + $definition = $variant->definitions->firstWhere('item_attribute_id', $itemAttribute->id); + if ($definition === null) { + continue; + } + + $value = (string) $definition->value; + $option = $attribute->options->firstWhere('value', $value); + $attributes[$code]['options'][$value] = [ + 'value' => $value, + 'label' => (string) ($option?->label ?: $value), + ]; + } + } + } + + uasort($attributes, fn (array $left, array $right): int => $left['sort_order'] <=> $right['sort_order'] + ?: $left['label'] <=> $right['label']); + + return array_values(array_map(fn (array $attribute): array => [ + 'code' => $attribute['code'], + 'label' => $attribute['label'], + 'options' => array_values($attribute['options']), + ], $attributes)); + } +} diff --git a/app/Domains/Ticketing/Ticket/Services/AdminAppTicketColumnService.php b/app/Domains/Ticketing/Ticket/Services/AdminAppTicketColumnService.php index ba2f8b0a..2441c283 100644 --- a/app/Domains/Ticketing/Ticket/Services/AdminAppTicketColumnService.php +++ b/app/Domains/Ticketing/Ticket/Services/AdminAppTicketColumnService.php @@ -8,9 +8,17 @@ class AdminAppTicketColumnService { private const FIESTA_FUTBOL_INFANTIL = 'fiesta_futbol_infantil'; + private const DESFILE_PURA_TENDENCIA = 'desfile_pura_tendencia'; + + public function __construct(private readonly AdminAppTicketAttributeService $attributeService) {} + /** @return list */ public function columns(Tenant $tenant): array { + if ($tenant->codigo === self::DESFILE_PURA_TENDENCIA) { + return $this->desfileColumns($tenant); + } + $keys = $tenant->codigo === self::FIESTA_FUTBOL_INFANTIL ? ['order_number', 'category', 'product', 'type', 'date', 'size', 'amount', 'client', 'id', 'status', 'scanned_by'] : ['order_number', 'product', 'amount', 'client', 'id', 'status', 'scanned_by']; @@ -45,6 +53,31 @@ class AdminAppTicketColumnService return $columns; } + /** @return list */ + private function desfileColumns(Tenant $tenant): array + { + $attributeColumns = array_map(fn (array $attribute): array => [ + 'key' => $attribute['code'], + 'label' => $attribute['label'], + 'type' => 'text', + 'sortable' => false, + 'sort_param' => $attribute['code'], + 'width' => '8%', + 'excel_width' => 15, + ], $this->attributeService->attributes($tenant)); + + return [ + $this->definitions()['order_number'], + $this->definitions()['product'], + ...$attributeColumns, + $this->definitions()['amount'], + $this->definitions()['client'], + $this->definitions()['id'], + $this->definitions()['status'], + $this->definitions()['scanned_by'], + ]; + } + /** @return list */ public function publicColumns(Tenant $tenant): array { diff --git a/app/Domains/Ticketing/Ticket/Services/AdminAppTicketRowService.php b/app/Domains/Ticketing/Ticket/Services/AdminAppTicketRowService.php index 23dc41f3..d734b820 100644 --- a/app/Domains/Ticketing/Ticket/Services/AdminAppTicketRowService.php +++ b/app/Domains/Ticketing/Ticket/Services/AdminAppTicketRowService.php @@ -48,8 +48,17 @@ class AdminAppTicketRowService { $details ??= $this->details($ticket); $presentation = $this->presentation($ticket, $details); + $attributeValues = collect($details['variant_properties'] ?? []) + ->mapWithKeys(fn (array $property): array => [ + $property['code'] => collect($property['values'] ?? []) + ->pluck('label') + ->filter() + ->implode(', ') ?: '-', + ]) + ->all(); return [ + ...$attributeValues, 'order_number' => $details['order_number'], 'category' => $presentation['category'], 'product' => $presentation['product'], diff --git a/app/Domains/Ticketing/Ticket/Services/AdminAppTicketService.php b/app/Domains/Ticketing/Ticket/Services/AdminAppTicketService.php index 6c3f3f0e..f2c9a038 100644 --- a/app/Domains/Ticketing/Ticket/Services/AdminAppTicketService.php +++ b/app/Domains/Ticketing/Ticket/Services/AdminAppTicketService.php @@ -2,12 +2,12 @@ namespace App\Domains\Ticketing\Ticket\Services; -use App\Domains\Core\Auth\Models\User; use App\Domains\Commerce\Catalog\Enums\InventoryPolicy; use App\Domains\Commerce\Catalog\Models\Inventory; use App\Domains\Commerce\Catalog\Models\Variant; use App\Domains\Commerce\Purchase\Models\PurchaseItem; use App\Domains\Commerce\Purchase\Services\PurchaseRefundSummaryService; +use App\Domains\Core\Auth\Models\User; use App\Domains\Core\Tenant\Models\Tenant; use App\Domains\Ticketing\Ticket\Models\Ticket; use App\Domains\Ticketing\Ticket\Models\TicketRefund; @@ -32,6 +32,7 @@ class AdminAppTicketService public function __construct( private readonly AdminAppTicketColumnService $columnService, + private readonly AdminAppTicketAttributeService $attributeService, private readonly AdminAppTicketRowService $rowService, private readonly PurchaseRefundSummaryService $refundSummaryService, ) {} @@ -347,6 +348,13 @@ class AdminAppTicketService $this->applyStatusFilter($query, $filters['status'] ?? null); + foreach ($this->attributeService->attributes($tenant) as $attribute) { + $value = $filters[$attribute['code']] ?? null; + if ($value !== null && $value !== '') { + $this->whereVariantDefinition($query, $attribute['code'], (string) $value); + } + } + return $query; } diff --git a/app/Shared/Forms/Services/TicketFilterFormService.php b/app/Shared/Forms/Services/TicketFilterFormService.php index 52cae672..0a2e0006 100644 --- a/app/Shared/Forms/Services/TicketFilterFormService.php +++ b/app/Shared/Forms/Services/TicketFilterFormService.php @@ -4,15 +4,19 @@ namespace App\Shared\Forms\Services; use App\Domains\Core\Tenant\Models\Tenant; use App\Domains\Ticketing\Ticket\Models\Ticket; +use App\Domains\Ticketing\Ticket\Services\AdminAppTicketAttributeService; use App\Domains\Ticketing\Ticket\Services\AdminAppTicketColumnService; class TicketFilterFormService { private const FIESTA_FUTBOL_INFANTIL = 'fiesta_futbol_infantil'; + private const DESFILE_PURA_TENDENCIA = 'desfile_pura_tendencia'; + public function __construct( private readonly TicketFormService $ticketFormService, private readonly AdminAppTicketColumnService $columnService, + private readonly AdminAppTicketAttributeService $attributeService, ) {} /** @return array */ @@ -27,6 +31,13 @@ class TicketFilterFormService ]; } + if ($tenant->codigo === self::DESFILE_PURA_TENDENCIA) { + $fields = [ + ...$this->desfileFields($tenant), + ...$this->commonFields(), + ]; + } + return [ 'code' => 'tickets_filter', 'action' => '/api/v1/adminapp/tenant/tickets', @@ -36,6 +47,21 @@ class TicketFilterFormService ]; } + /** @return list> */ + private function desfileFields(Tenant $tenant): array + { + return array_map(fn (array $attribute): array => [ + 'name' => $attribute['code'], + 'query_param' => $attribute['code'], + 'label' => $attribute['label'], + 'type' => 'select', + 'required' => false, + 'default' => null, + 'placeholder' => $attribute['label'], + 'options' => $attribute['options'], + ], $this->attributeService->attributes($tenant)); + } + /** @return list> */ private function fiestaFutbolInfantilFields(Tenant $tenant): array { diff --git a/database/migrations/2026_09_25_000000_associate_adminapp_tickets_menu_with_desfile.php b/database/migrations/2026_09_25_000000_associate_adminapp_tickets_menu_with_desfile.php new file mode 100644 index 00000000..b485ed1d --- /dev/null +++ b/database/migrations/2026_09_25_000000_associate_adminapp_tickets_menu_with_desfile.php @@ -0,0 +1,43 @@ +where('code', self::MENU_CODE)->exists() + || ! DB::table('tenants')->where('codigo', self::TENANT_CODE)->exists() + ) { + return; + } + + $now = now(); + + DB::table('tenants_menues')->updateOrInsert( + [ + 'tenant_code' => self::TENANT_CODE, + 'menu_code' => self::MENU_CODE, + ], + [ + 'static_content' => null, + 'created_at' => $now, + 'updated_at' => $now, + ], + ); + } + + public function down(): void + { + DB::table('tenants_menues') + ->where('tenant_code', self::TENANT_CODE) + ->where('menu_code', self::MENU_CODE) + ->delete(); + } +}; diff --git a/database/seeders/MenuSeeder.php b/database/seeders/MenuSeeder.php index 183ec639..c589f7a2 100644 --- a/database/seeders/MenuSeeder.php +++ b/database/seeders/MenuSeeder.php @@ -297,8 +297,10 @@ class MenuSeeder extends Seeder 'sonder', 'fiesta_futbol_infantil', ]; - $fiestaCategoryMenuCodes = [ + $ticketAdminMenuCodes = [ 'adminapp.tickets', + ]; + $fiestaCategoryMenuCodes = [ 'adminapp.fiesta-futbol-infantil.entradas', 'adminapp.fiesta-futbol-infantil.alojamientos', 'adminapp.fiesta-futbol-infantil.merchandising', @@ -379,6 +381,10 @@ class MenuSeeder extends Seeder $menuCodes = array_diff($menuCodes, $helpMenuCodes); } + if (! in_array($tenant->codigo, ['fiesta_futbol_infantil', 'desfile_pura_tendencia'], true)) { + $menuCodes = array_diff($menuCodes, $ticketAdminMenuCodes); + } + if ($tenant->codigo !== 'fiesta_futbol_infantil') { $menuCodes = array_diff($menuCodes, $fiestaCategoryMenuCodes); } else { diff --git a/tests/Feature/Migrations/AssociateAdminAppTicketsMenuWithDesfileTest.php b/tests/Feature/Migrations/AssociateAdminAppTicketsMenuWithDesfileTest.php new file mode 100644 index 00000000..1fd4568d --- /dev/null +++ b/tests/Feature/Migrations/AssociateAdminAppTicketsMenuWithDesfileTest.php @@ -0,0 +1,81 @@ +string('code')->primary(); + }); + Schema::create('tenants', function (Blueprint $table): void { + $table->string('codigo')->primary(); + }); + Schema::create('tenants_menues', function (Blueprint $table): void { + $table->id(); + $table->string('tenant_code'); + $table->string('menu_code'); + $table->json('static_content')->nullable(); + $table->timestamps(); + $table->unique(['tenant_code', 'menu_code']); + }); + + DB::table('menues')->insert(['code' => 'adminapp.tickets']); + DB::table('tenants')->insert([ + ['codigo' => 'desfile_pura_tendencia'], + ['codigo' => 'fiesta_futbol_infantil'], + ]); + DB::table('tenants_menues')->insert([ + 'tenant_code' => 'fiesta_futbol_infantil', + 'menu_code' => 'adminapp.tickets', + ]); + } + + protected function tearDown(): void + { + Schema::dropIfExists('tenants_menues'); + Schema::dropIfExists('tenants'); + Schema::dropIfExists('menues'); + + parent::tearDown(); + } + + public function test_it_associates_the_tickets_menu_only_with_desfile_and_rolls_back_that_association(): void + { + $migration = require database_path( + 'migrations/2026_09_25_000000_associate_adminapp_tickets_menu_with_desfile.php' + ); + + $migration->down(); + $migration->up(); + $migration->up(); + + $this->assertSame(1, DB::table('tenants_menues') + ->where('tenant_code', 'desfile_pura_tendencia') + ->where('menu_code', 'adminapp.tickets') + ->count()); + $this->assertDatabaseHas('tenants_menues', [ + 'tenant_code' => 'fiesta_futbol_infantil', + 'menu_code' => 'adminapp.tickets', + ]); + + $migration->down(); + + $this->assertDatabaseMissing('tenants_menues', [ + 'tenant_code' => 'desfile_pura_tendencia', + 'menu_code' => 'adminapp.tickets', + ]); + $this->assertDatabaseHas('tenants_menues', [ + 'tenant_code' => 'fiesta_futbol_infantil', + 'menu_code' => 'adminapp.tickets', + ]); + } +} diff --git a/tests/Feature/Ticket/AdminAppTicketControllerTest.php b/tests/Feature/Ticket/AdminAppTicketControllerTest.php index b97b0416..012560c5 100644 --- a/tests/Feature/Ticket/AdminAppTicketControllerTest.php +++ b/tests/Feature/Ticket/AdminAppTicketControllerTest.php @@ -2,22 +2,22 @@ namespace Tests\Feature\Ticket; -use App\Shared\Attachable\Enums\AttachmentType; -use App\Shared\Attachable\Models\Attachment; -use App\Domains\Core\Auth\Models\User; -use App\Domains\Core\Authorization\Enums\RoleCode; use App\Domains\Commerce\Catalog\Models\Attribute; use App\Domains\Commerce\Catalog\Models\CatalogItem; use App\Domains\Commerce\Catalog\Models\Inventory; use App\Domains\Commerce\Catalog\Models\Variant; -use App\Domains\Core\Menu\Models\Menu; use App\Domains\Commerce\Purchase\Models\Purchase; use App\Domains\Commerce\Purchase\Models\PurchaseItem; -use App\Shared\Enums\FieldType; -use App\Domains\Core\Tenant\Models\Tenant; +use App\Domains\Core\Auth\Models\User; +use App\Domains\Core\Authorization\Enums\RoleCode; +use App\Domains\Core\Menu\Models\Menu; use App\Domains\Core\Tenant\Models\AdminWebsiteType; +use App\Domains\Core\Tenant\Models\Tenant; use App\Domains\Ticketing\Ticket\Models\Ticket; use App\Domains\Ticketing\Ticket\Models\TicketRefund; +use App\Shared\Attachable\Enums\AttachmentType; +use App\Shared\Attachable\Models\Attachment; +use App\Shared\Enums\FieldType; use Barryvdh\DomPDF\ServiceProvider as DomPdfServiceProvider; use Database\Seeders\AttributeSeeder; use Database\Seeders\AuthorizationSeeder; @@ -840,6 +840,103 @@ class AdminAppTicketControllerTest extends TestCase ->assertJsonPath('data.0.variant_properties.0.values.0.label', 'XL'); } + public function test_desfile_exposes_and_applies_filters_and_columns_from_ticket_product_attributes(): void + { + $tenant = $this->createTenant('desfile_pura_tendencia'); + $admin = $this->createAdminAppUser($tenant); + $this->grantTicketsMenu($tenant); + Sanctum::actingAs($admin); + + $item = CatalogItem::query()->create([ + 'tenant_code' => $tenant->codigo, + 'slug' => 'entrada', + 'nombre' => 'Entrada', + 'precio' => '1000.00', + 'has_tickets' => true, + ]); + $type = Attribute::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'codigo' => 'tipo', + 'nombre' => 'Tipo', + 'type' => FieldType::Select, + ]); + $type->options()->create(['value' => 'vip', 'label' => 'VIP']); + $sector = Attribute::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'codigo' => 'sector', + 'nombre' => 'Sector', + 'type' => FieldType::Select, + ]); + $sector->options()->createMany([ + ['value' => 'a', 'label' => 'A'], + ['value' => 'b', 'label' => 'B'], + ]); + $typeItemAttribute = $item->itemAttributes()->create([ + 'attribute_id' => $type->id, + 'sort_order' => 1, + ]); + $sectorItemAttribute = $item->itemAttributes()->create([ + 'attribute_id' => $sector->id, + 'sort_order' => 2, + 'ticket_label' => 'Lado', + ]); + + $variants = collect(['a', 'b'])->map(function (string $sectorValue) use ( + $item, + $typeItemAttribute, + $sectorItemAttribute, + ): Variant { + $variant = Variant::query()->create([ + 'catalog_item_id' => $item->id, + 'inventory_id' => Inventory::query()->create()->id, + ]); + $variant->definitions()->createMany([ + ['item_attribute_id' => $typeItemAttribute->id, 'value' => 'vip'], + ['item_attribute_id' => $sectorItemAttribute->id, 'value' => $sectorValue], + ]); + + return $variant; + }); + + $purchase = $this->createPurchase($tenant, $admin, '2026-09-25 10:00:00'); + $matchingItem = $this->createPurchaseItem($purchase, $item, $variants[0]); + $otherItem = $this->createPurchaseItem($purchase, $item, $variants[1]); + $matching = $this->createTicket($tenant, $admin, [ + 'source_purchase_item_id' => $matchingItem->id, + 'source_catalog_item_id' => $item->id, + 'source_variant_id' => $variants[0]->id, + ]); + $this->createTicket($tenant, $admin, [ + 'source_purchase_item_id' => $otherItem->id, + 'source_catalog_item_id' => $item->id, + 'source_variant_id' => $variants[1]->id, + ]); + + $this->getJson('/api/v1/adminapp/forms/tickets-filter') + ->assertOk() + ->assertJsonPath('data.fields.0.name', 'tipo') + ->assertJsonPath('data.fields.0.label', 'Tipo') + ->assertJsonPath('data.fields.0.options.0', ['value' => 'vip', 'label' => 'VIP']) + ->assertJsonPath('data.fields.1.name', 'sector') + ->assertJsonPath('data.fields.1.label', 'Lado') + ->assertJsonPath('data.columns.1.key', 'product') + ->assertJsonPath('data.columns.2.key', 'tipo') + ->assertJsonPath('data.columns.2.sortable', false) + ->assertJsonPath('data.columns.3.key', 'sector') + ->assertJsonPath('data.columns.3.label', 'Lado'); + + $this->getJson('/api/v1/adminapp/tenant/tickets?tipo=vip§or=a') + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.id', $matching->id) + ->assertJsonPath('data.0.values.tipo', 'VIP') + ->assertJsonPath('data.0.values.sector', 'A'); + + $this->getJson('/api/v1/adminapp/tenant/tickets?sector=invalid') + ->assertUnprocessable() + ->assertJsonValidationErrors('sector'); + } + public function test_it_applies_the_ticket_filter_form_values(): void { $tenant = $this->createTenant('fiesta_futbol_infantil'); diff --git a/tests/Unit/Ticket/AdminAppTicketExportServiceTest.php b/tests/Unit/Ticket/AdminAppTicketExportServiceTest.php index c61011fc..8545bc7e 100644 --- a/tests/Unit/Ticket/AdminAppTicketExportServiceTest.php +++ b/tests/Unit/Ticket/AdminAppTicketExportServiceTest.php @@ -3,6 +3,7 @@ namespace Tests\Unit\Ticket; use App\Domains\Core\Tenant\Models\Tenant; +use App\Domains\Ticketing\Ticket\Services\AdminAppTicketAttributeService; use App\Domains\Ticketing\Ticket\Services\AdminAppTicketColumnService; use App\Domains\Ticketing\Ticket\Services\AdminAppTicketExcelService; use App\Domains\Ticketing\Ticket\Services\AdminAppTicketPdfService; @@ -149,7 +150,7 @@ class AdminAppTicketExportServiceTest extends TestCase private function columnService(): AdminAppTicketColumnService { - return new AdminAppTicketColumnService; + return new AdminAppTicketColumnService(new AdminAppTicketAttributeService); } private function spreadsheetPath(StreamedResponse $response): string