From 6ee3f22e41c5a509df214e82f55aa3565c948c88 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Fri, 28 Aug 2026 14:44:55 -0300 Subject: [PATCH 01/23] feat(menu): add tickets menu and update related seeder and tests --- ..._futbol_infantil_tickets_adminapp_menu.php | 82 +++++++++++++++++++ database/seeders/MenuSeeder.php | 7 ++ tests/Feature/Seeders/MenuSeederTest.php | 7 +- 3 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 database/migrations/2026_08_28_000000_add_fiesta_futbol_infantil_tickets_adminapp_menu.php diff --git a/database/migrations/2026_08_28_000000_add_fiesta_futbol_infantil_tickets_adminapp_menu.php b/database/migrations/2026_08_28_000000_add_fiesta_futbol_infantil_tickets_adminapp_menu.php new file mode 100644 index 0000000..87c41fb --- /dev/null +++ b/database/migrations/2026_08_28_000000_add_fiesta_futbol_infantil_tickets_adminapp_menu.php @@ -0,0 +1,82 @@ +where('code', 'main.adminapp')->exists()) { + // Reference data is added by seeders on fresh installations. + return; + } + + $now = now(); + + DB::transaction(function () use ($now): void { + DB::table('menues')->updateOrInsert( + ['code' => self::MENU_CODE], + [ + 'label' => 'Tickets', + 'parent_menu_code' => 'main.adminapp', + 'content_type' => 'dynamic', + 'static_content_schema' => null, + 'route' => '/admin/tickets', + 'created_at' => $now, + 'updated_at' => $now, + ], + ); + + DB::table('tenants_menues') + ->where('menu_code', self::MENU_CODE) + ->where('tenant_code', '!=', self::TENANT_CODE) + ->delete(); + + if (DB::table('tenants')->where('codigo', self::TENANT_CODE)->exists()) { + DB::table('tenants_menues')->updateOrInsert( + [ + 'tenant_code' => self::TENANT_CODE, + 'menu_code' => self::MENU_CODE, + ], + [ + 'static_content' => null, + 'created_at' => $now, + 'updated_at' => $now, + ], + ); + } + + DB::table('roles') + ->whereIn('codigo', ['admin', 'adminapp']) + ->pluck('codigo') + ->each(function (string $roleCode): void { + DB::table('roles_menues')->updateOrInsert([ + 'rol_codigo' => $roleCode, + 'menu_codigo' => self::MENU_CODE, + ]); + }); + }); + } + + public function down(): void + { + DB::transaction(function (): void { + DB::table('tenants_menues') + ->where('menu_code', self::MENU_CODE) + ->delete(); + + DB::table('roles_menues') + ->where('menu_codigo', self::MENU_CODE) + ->delete(); + + DB::table('menues') + ->where('code', self::MENU_CODE) + ->delete(); + }); + } +}; diff --git a/database/seeders/MenuSeeder.php b/database/seeders/MenuSeeder.php index dbab420..11885b5 100644 --- a/database/seeders/MenuSeeder.php +++ b/database/seeders/MenuSeeder.php @@ -78,6 +78,12 @@ class MenuSeeder extends Seeder 'parent_menu_code' => 'main.adminapp', 'route' => '/admin/staff', ], + [ + 'code' => 'adminapp.tickets', + 'label' => 'Tickets', + 'parent_menu_code' => 'main.adminapp', + 'route' => '/admin/tickets', + ], [ 'code' => 'adminapp.fiesta-futbol-infantil.entradas', 'label' => 'Entradas', @@ -270,6 +276,7 @@ class MenuSeeder extends Seeder 'fiesta_futbol_infantil', ]; $fiestaCategoryMenuCodes = [ + 'adminapp.tickets', 'adminapp.fiesta-futbol-infantil.entradas', 'adminapp.fiesta-futbol-infantil.alojamientos', 'adminapp.fiesta-futbol-infantil.merchandising', diff --git a/tests/Feature/Seeders/MenuSeederTest.php b/tests/Feature/Seeders/MenuSeederTest.php index 6f579d0..d253c8d 100644 --- a/tests/Feature/Seeders/MenuSeederTest.php +++ b/tests/Feature/Seeders/MenuSeederTest.php @@ -34,6 +34,7 @@ class MenuSeederTest extends TestCase 'adminapp.ventas' => ['Ventas', '/admin/ventas'], ]; $fiestaCategoryMenus = [ + 'adminapp.tickets' => ['Tickets', '/admin/tickets'], 'adminapp.fiesta-futbol-infantil.entradas' => ['Entradas', '/admin/entradas'], 'adminapp.fiesta-futbol-infantil.alojamientos' => ['Alojamientos', '/admin/alojamientos'], 'adminapp.fiesta-futbol-infantil.merchandising' => ['Merchandising', '/admin/merchandising'], @@ -49,7 +50,11 @@ class MenuSeederTest extends TestCase $this->assertSame(Menu::CONTENT_TYPE_DYNAMIC, $adminApp->content_type); $this->assertSame('/', $adminApp->route); $this->assertSame( - array_keys([...$expectedMenus, ...$fiestaCategoryMenus]), + collect(array_keys([ + ...$expectedMenus, + ...$fiestaCategoryMenus, + 'adminapp.desfile.entradas' => ['Entradas', '/admin/desfile/entradas'], + ]))->sort()->values()->all(), $adminApp->children->pluck('code')->sort()->values()->all() ); $this->assertTrue( From c792e7d30666a4b34b2c2ab3758795d2678b5868 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Fri, 28 Aug 2026 15:10:23 -0300 Subject: [PATCH 02/23] feat(tickets): implement admin app ticket management with listing and search functionality --- .../Controllers/AdminApp/TicketController.php | 23 +++ .../Requests/AdminAppTicketIndexRequest.php | 23 +++ .../Ticket/Services/AdminAppTicketService.php | 52 ++++++ app/Domains/Ticket/documentacion/README.md | 6 + app/Domains/Ticket/routes/adminapp.php | 12 ++ app/Domains/Ticket/routes/api.php | 1 + .../Ticket/AdminAppTicketControllerTest.php | 163 ++++++++++++++++++ 7 files changed, 280 insertions(+) create mode 100644 app/Domains/Ticket/Controllers/AdminApp/TicketController.php create mode 100644 app/Domains/Ticket/Requests/AdminAppTicketIndexRequest.php create mode 100644 app/Domains/Ticket/Services/AdminAppTicketService.php create mode 100644 app/Domains/Ticket/routes/adminapp.php create mode 100644 tests/Feature/Ticket/AdminAppTicketControllerTest.php diff --git a/app/Domains/Ticket/Controllers/AdminApp/TicketController.php b/app/Domains/Ticket/Controllers/AdminApp/TicketController.php new file mode 100644 index 0000000..2fa6b10 --- /dev/null +++ b/app/Domains/Ticket/Controllers/AdminApp/TicketController.php @@ -0,0 +1,23 @@ +user()->tenant()->firstOrFail(); + + return TicketResource::collection( + $this->ticketService->list($tenant, $request->validated()) + )->additional($this->ticketService->counts($tenant)); + } +} diff --git a/app/Domains/Ticket/Requests/AdminAppTicketIndexRequest.php b/app/Domains/Ticket/Requests/AdminAppTicketIndexRequest.php new file mode 100644 index 0000000..e54dec3 --- /dev/null +++ b/app/Domains/Ticket/Requests/AdminAppTicketIndexRequest.php @@ -0,0 +1,23 @@ +> */ + public function rules(): array + { + return [ + 'q' => ['sometimes', 'nullable', 'string', 'max:255'], + '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 new file mode 100644 index 0000000..6d7c9c2 --- /dev/null +++ b/app/Domains/Ticket/Services/AdminAppTicketService.php @@ -0,0 +1,52 @@ +where('tenant_code', $tenant->codigo); + + return [ + 'scanned_tickets' => (clone $query)->whereNotNull('used_at')->count(), + 'total_tickets' => $query->count(), + ]; + } + + /** + * @param array{q?: string|null, page?: int, per_page?: int} $filters + * @return LengthAwarePaginator + */ + public function list(Tenant $tenant, array $filters = []): LengthAwarePaginator + { + $search = trim((string) ($filters['q'] ?? '')); + + return Ticket::query() + ->where('tenant_code', $tenant->codigo) + ->when($search !== '', function (Builder $query) use ($search): void { + $query->when( + ctype_digit($search), + fn (Builder $searchQuery): Builder => $searchQuery + ->where('tickets.id', (int) $search), + fn (Builder $searchQuery): Builder => $searchQuery + ->where('ticket', 'like', "%{$search}%"), + ); + }) + ->with([ + ...TicketValidityResolver::RELATIONS, + ...TicketPresentationResolver::RELATIONS, + 'user', + 'sourceCatalogItem.category', + ]) + ->orderByDesc('id') + ->paginateFromRequest() + ->withQueryString(); + } +} diff --git a/app/Domains/Ticket/documentacion/README.md b/app/Domains/Ticket/documentacion/README.md index a266d30..c43a9bd 100644 --- a/app/Domains/Ticket/documentacion/README.md +++ b/app/Domains/Ticket/documentacion/README.md @@ -30,6 +30,12 @@ Bajo `/tenants/{tenant:codigo}`, protegidos por `auth:sanctum`: - `GET /tickets`. - `POST /tickets/pdf`. +Bajo `/v1/adminapp/tenant`, protegido por `auth:sanctum`, `adminapp.tenant` y el menú +`adminapp.tickets`: + +- `GET /tickets`, paginado y con búsqueda opcional mediante `q`. La respuesta incluye + `scanned_tickets` y `total_tickets` para el tenant autenticado. + `TicketPdfService` genera la descarga y `TicketResource`/`ValidityTimeResource` definen las respuestas. ## Dependencias y reglas diff --git a/app/Domains/Ticket/routes/adminapp.php b/app/Domains/Ticket/routes/adminapp.php new file mode 100644 index 0000000..64af50e --- /dev/null +++ b/app/Domains/Ticket/routes/adminapp.php @@ -0,0 +1,12 @@ +middleware(['auth:sanctum', 'adminapp.tenant']) + ->group(function (): void { + Route::get('tickets', [TicketController::class, 'index']) + ->middleware('tenant.menu:adminapp.tickets') + ->name('adminapp.tickets.index'); + }); diff --git a/app/Domains/Ticket/routes/api.php b/app/Domains/Ticket/routes/api.php index da5305b..8bcdb8e 100644 --- a/app/Domains/Ticket/routes/api.php +++ b/app/Domains/Ticket/routes/api.php @@ -11,3 +11,4 @@ Route::prefix('tenants/{tenant:codigo}') }); require __DIR__.'/scanner.php'; +require __DIR__.'/adminapp.php'; diff --git a/tests/Feature/Ticket/AdminAppTicketControllerTest.php b/tests/Feature/Ticket/AdminAppTicketControllerTest.php new file mode 100644 index 0000000..2cbea72 --- /dev/null +++ b/tests/Feature/Ticket/AdminAppTicketControllerTest.php @@ -0,0 +1,163 @@ +seed(AuthorizationSeeder::class); + WebsiteType::query()->create(['codigo' => 'onticket', 'nombre' => 'OnTicket']); + } + + public function test_authentication_is_required(): void + { + $this->getJson('/api/v1/adminapp/tenant/tickets')->assertUnauthorized(); + } + + public function test_the_tenant_must_have_the_tickets_menu(): void + { + $tenant = $this->createTenant('fiesta_futbol_infantil'); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $this->getJson('/api/v1/adminapp/tenant/tickets')->assertNotFound(); + } + + public function test_it_lists_only_tickets_from_the_authenticated_tenant(): void + { + $tenant = $this->createTenant('fiesta_futbol_infantil'); + $otherTenant = $this->createTenant('other'); + $admin = $this->createAdminAppUser($tenant); + $otherUser = $this->createAdminAppUser($otherTenant); + $this->grantTicketsMenu($tenant); + Sanctum::actingAs($admin); + + $ticket = $this->createTicket($tenant, $admin); + $this->createTicket($otherTenant, $otherUser); + + $this->getJson('/api/v1/adminapp/tenant/tickets') + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.id', $ticket->id) + ->assertJsonPath('data.0.tenant_code', $tenant->codigo) + ->assertJsonPath('meta.total', 1); + } + + public function test_it_supports_id_and_uuid_search(): void + { + $tenant = $this->createTenant('fiesta_futbol_infantil'); + $admin = $this->createAdminAppUser($tenant); + $this->grantTicketsMenu($tenant); + Sanctum::actingAs($admin); + + $matching = $this->createTicket($tenant, $admin); + $this->createTicket($tenant, $admin); + + $this->getJson("/api/v1/adminapp/tenant/tickets?q={$matching->id}") + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.id', $matching->id); + + $this->getJson('/api/v1/adminapp/tenant/tickets?q='.substr($matching->ticket, 0, 8)) + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.ticket', $matching->ticket); + } + + public function test_it_includes_tenant_scanned_and_total_ticket_counts(): void + { + $tenant = $this->createTenant('fiesta_futbol_infantil'); + $otherTenant = $this->createTenant('other'); + $admin = $this->createAdminAppUser($tenant); + $otherUser = $this->createAdminAppUser($otherTenant); + $this->grantTicketsMenu($tenant); + Sanctum::actingAs($admin); + + $this->createTicket($tenant, $admin)->update(['used_at' => now()]); + $this->createTicket($tenant, $admin); + $this->createTicket($otherTenant, $otherUser)->update(['used_at' => now()]); + + $this->getJson('/api/v1/adminapp/tenant/tickets?q=does-not-match') + ->assertOk() + ->assertJsonCount(0, 'data') + ->assertJsonPath('scanned_tickets', 1) + ->assertJsonPath('total_tickets', 2); + } + + private function createTenant(string $code): Tenant + { + $headerLogo = $this->createAttachment("{$code}-header.png"); + $footerLogo = $this->createAttachment("{$code}-footer.png"); + + return Tenant::query()->create([ + 'codigo' => $code, + 'nombre' => ucfirst($code), + 'dominio' => "{$code}.test", + 'website_type_code' => 'onticket', + 'primary_color' => '#111111', + 'secondary_color' => '#222222', + 'danger_color' => '#333333', + 'success_color' => '#444444', + 'header_bg_color' => '#ffffff', + 'footer_bg_color' => '#ffffff', + 'header_logo_id' => $headerLogo->id, + 'footer_logo_id' => $footerLogo->id, + ]); + } + + private function createAttachment(string $filename): Attachment + { + return Attachment::query()->create([ + 'path' => "test/{$filename}", + 'filename' => $filename, + 'type' => AttachmentType::Image, + 'mime_type' => 'image/png', + ]); + } + + private function createAdminAppUser(Tenant $tenant): User + { + return User::factory()->create([ + 'rol_codigo' => RoleCode::AdminApp->value, + 'tenant_codigo' => $tenant->codigo, + ]); + } + + private function grantTicketsMenu(Tenant $tenant): void + { + $menu = Menu::query()->create([ + 'code' => 'adminapp.tickets', + 'label' => 'Tickets', + 'route' => '/admin/tickets', + ]); + + $tenant->menues()->attach($menu->code); + } + + private function createTicket(Tenant $tenant, User $user): Ticket + { + return Ticket::query()->create([ + 'tenant_code' => $tenant->codigo, + 'ticket' => (string) Str::uuid(), + 'user_id' => $user->id, + ]); + } +} From ba0e2dd00c0bf3d7bfe405425926355afcbf2cff Mon Sep 17 00:00:00 2001 From: ncoronel Date: Fri, 28 Aug 2026 15:14:11 -0300 Subject: [PATCH 03/23] feat(tickets): enhance ticket search functionality and add ticket counts to response --- .../Controllers/AdminApp/TicketController.php | 11 +++-- .../AdminApp/AdminAppTicketCollection.php | 35 +++++++++++++++ .../Ticket/Services/AdminAppTicketResult.php | 16 +++++++ .../Ticket/Services/AdminAppTicketService.php | 44 ++++++++++--------- .../Ticket/AdminAppTicketControllerTest.php | 5 +++ 5 files changed, 85 insertions(+), 26 deletions(-) create mode 100644 app/Domains/Ticket/Resources/AdminApp/AdminAppTicketCollection.php create mode 100644 app/Domains/Ticket/Services/AdminAppTicketResult.php diff --git a/app/Domains/Ticket/Controllers/AdminApp/TicketController.php b/app/Domains/Ticket/Controllers/AdminApp/TicketController.php index 2fa6b10..beb9fac 100644 --- a/app/Domains/Ticket/Controllers/AdminApp/TicketController.php +++ b/app/Domains/Ticket/Controllers/AdminApp/TicketController.php @@ -3,21 +3,20 @@ namespace App\Domains\Ticket\Controllers\AdminApp; use App\Domains\Ticket\Requests\AdminAppTicketIndexRequest; -use App\Domains\Ticket\Resources\TicketResource; +use App\Domains\Ticket\Resources\AdminApp\AdminAppTicketCollection; use App\Domains\Ticket\Services\AdminAppTicketService; use App\Http\Controllers\Controller; -use Illuminate\Http\Resources\Json\AnonymousResourceCollection; class TicketController extends Controller { public function __construct(private readonly AdminAppTicketService $ticketService) {} - public function index(AdminAppTicketIndexRequest $request): AnonymousResourceCollection + public function index(AdminAppTicketIndexRequest $request): AdminAppTicketCollection { $tenant = $request->user()->tenant()->firstOrFail(); - return TicketResource::collection( - $this->ticketService->list($tenant, $request->validated()) - )->additional($this->ticketService->counts($tenant)); + return new AdminAppTicketCollection( + $this->ticketService->search($tenant, $request->validated()) + ); } } diff --git a/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketCollection.php b/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketCollection.php new file mode 100644 index 0000000..4355ad6 --- /dev/null +++ b/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketCollection.php @@ -0,0 +1,35 @@ + */ + public $collects = TicketResource::class; + + private readonly int $scannedTickets; + + private readonly int $totalTickets; + + public function __construct(AdminAppTicketResult $result) + { + parent::__construct($result->tickets); + + $this->scannedTickets = $result->scannedTickets; + $this->totalTickets = $result->totalTickets; + } + + /** @return array{scanned_tickets: int, total_tickets: int} */ + public function with(Request $request): array + { + return [ + 'scanned_tickets' => $this->scannedTickets, + 'total_tickets' => $this->totalTickets, + ]; + } +} diff --git a/app/Domains/Ticket/Services/AdminAppTicketResult.php b/app/Domains/Ticket/Services/AdminAppTicketResult.php new file mode 100644 index 0000000..e9f65f4 --- /dev/null +++ b/app/Domains/Ticket/Services/AdminAppTicketResult.php @@ -0,0 +1,16 @@ + $tickets */ + public function __construct( + public LengthAwarePaginator $tickets, + public int $scannedTickets, + public int $totalTickets, + ) {} +} diff --git a/app/Domains/Ticket/Services/AdminAppTicketService.php b/app/Domains/Ticket/Services/AdminAppTicketService.php index 6d7c9c2..1180843 100644 --- a/app/Domains/Ticket/Services/AdminAppTicketService.php +++ b/app/Domains/Ticket/Services/AdminAppTicketService.php @@ -5,26 +5,39 @@ namespace App\Domains\Ticket\Services; use App\Domains\Tenant\Models\Tenant; use App\Domains\Ticket\Models\Ticket; use Illuminate\Database\Eloquent\Builder; -use Illuminate\Pagination\LengthAwarePaginator; class AdminAppTicketService { - /** @return array{scanned_tickets: int, total_tickets: int} */ - public function counts(Tenant $tenant): array + /** + * @param array{q?: string|null, page?: int, per_page?: int} $filters + */ + public function search(Tenant $tenant, array $filters = []): AdminAppTicketResult { - $query = Ticket::query()->where('tenant_code', $tenant->codigo); + $query = $this->baseQuery($tenant, $filters); - return [ - 'scanned_tickets' => (clone $query)->whereNotNull('used_at')->count(), - 'total_tickets' => $query->count(), - ]; + $tickets = (clone $query) + ->with([ + ...TicketValidityResolver::RELATIONS, + ...TicketPresentationResolver::RELATIONS, + 'user', + 'sourceCatalogItem.category', + ]) + ->orderByDesc('id') + ->paginateFromRequest() + ->withQueryString(); + + return new AdminAppTicketResult( + tickets: $tickets, + scannedTickets: (clone $query)->whereNotNull('used_at')->count(), + totalTickets: $tickets->total(), + ); } /** * @param array{q?: string|null, page?: int, per_page?: int} $filters - * @return LengthAwarePaginator + * @return Builder */ - public function list(Tenant $tenant, array $filters = []): LengthAwarePaginator + private function baseQuery(Tenant $tenant, array $filters): Builder { $search = trim((string) ($filters['q'] ?? '')); @@ -38,15 +51,6 @@ class AdminAppTicketService fn (Builder $searchQuery): Builder => $searchQuery ->where('ticket', 'like', "%{$search}%"), ); - }) - ->with([ - ...TicketValidityResolver::RELATIONS, - ...TicketPresentationResolver::RELATIONS, - 'user', - 'sourceCatalogItem.category', - ]) - ->orderByDesc('id') - ->paginateFromRequest() - ->withQueryString(); + }); } } diff --git a/tests/Feature/Ticket/AdminAppTicketControllerTest.php b/tests/Feature/Ticket/AdminAppTicketControllerTest.php index 2cbea72..9705e65 100644 --- a/tests/Feature/Ticket/AdminAppTicketControllerTest.php +++ b/tests/Feature/Ticket/AdminAppTicketControllerTest.php @@ -98,6 +98,11 @@ class AdminAppTicketControllerTest extends TestCase $this->getJson('/api/v1/adminapp/tenant/tickets?q=does-not-match') ->assertOk() ->assertJsonCount(0, 'data') + ->assertJsonPath('scanned_tickets', 0) + ->assertJsonPath('total_tickets', 0); + + $this->getJson('/api/v1/adminapp/tenant/tickets') + ->assertOk() ->assertJsonPath('scanned_tickets', 1) ->assertJsonPath('total_tickets', 2); } From 1ec7ab3e3518c31b26581683eefa24408de52438 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Fri, 28 Aug 2026 15:17:35 -0300 Subject: [PATCH 04/23] feat(tickets): implement AdminAppTicketResource and update ticket collection to use it --- .../AdminApp/AdminAppTicketCollection.php | 5 +- .../AdminApp/AdminAppTicketResource.php | 62 +++++++++++++++++++ .../Ticket/AdminAppTicketControllerTest.php | 59 +++++++++++++++++- 3 files changed, 120 insertions(+), 6 deletions(-) create mode 100644 app/Domains/Ticket/Resources/AdminApp/AdminAppTicketResource.php diff --git a/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketCollection.php b/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketCollection.php index 4355ad6..3af312c 100644 --- a/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketCollection.php +++ b/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketCollection.php @@ -2,15 +2,14 @@ namespace App\Domains\Ticket\Resources\AdminApp; -use App\Domains\Ticket\Resources\TicketResource; use App\Domains\Ticket\Services\AdminAppTicketResult; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\ResourceCollection; class AdminAppTicketCollection extends ResourceCollection { - /** @var class-string */ - public $collects = TicketResource::class; + /** @var class-string */ + public $collects = AdminAppTicketResource::class; private readonly int $scannedTickets; diff --git a/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketResource.php b/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketResource.php new file mode 100644 index 0000000..8a7b9e2 --- /dev/null +++ b/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketResource.php @@ -0,0 +1,62 @@ + */ + public function toArray(Request $request): array + { + return [ + ...parent::toArray($request), + 'variant_properties' => $this->variantProperties(), + ]; + } + + /** + * @return list + * }> + */ + private function variantProperties(): array + { + $variant = $this->sourceVariant; + + if ($variant === null) { + return []; + } + + $itemAttributes = $variant->definitions + ->map(fn ($definition) => $definition->itemAttribute) + ->filter() + ->merge($variant->catalogItem?->itemAttributes ?? collect()) + ->unique('id') + ->values(); + + return $variant->selectionOptions($itemAttributes) + ->map(function (array $selection, string $attributeCode) use ($itemAttributes): array { + $itemAttribute = $itemAttributes->first( + fn (ItemAttribute $itemAttribute): bool => $itemAttribute->attribute?->codigo + === $attributeCode, + ); + $values = array_is_list($selection) ? $selection : [$selection]; + + return [ + 'code' => $attributeCode, + 'label' => $itemAttribute?->attribute?->nombre + ?? ($attributeCode === 'event_date' ? 'Fecha' : $attributeCode), + 'values' => array_values($values), + ]; + }) + ->values() + ->all(); + } +} diff --git a/tests/Feature/Ticket/AdminAppTicketControllerTest.php b/tests/Feature/Ticket/AdminAppTicketControllerTest.php index 9705e65..c8da04b 100644 --- a/tests/Feature/Ticket/AdminAppTicketControllerTest.php +++ b/tests/Feature/Ticket/AdminAppTicketControllerTest.php @@ -6,7 +6,12 @@ use App\Domains\Attachable\Enums\AttachmentType; use App\Domains\Attachable\Models\Attachment; use App\Domains\Auth\Models\User; use App\Domains\Authorization\Enums\RoleCode; +use App\Domains\Catalog\Models\Attribute; +use App\Domains\Catalog\Models\CatalogItem; +use App\Domains\Catalog\Models\Inventory; +use App\Domains\Catalog\Models\Variant; use App\Domains\Menu\Models\Menu; +use App\Domains\Shared\Enums\FieldType; use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\WebsiteType; use App\Domains\Ticket\Models\Ticket; @@ -107,6 +112,53 @@ class AdminAppTicketControllerTest extends TestCase ->assertJsonPath('total_tickets', 2); } + public function test_it_returns_structured_variant_properties(): void + { + $tenant = $this->createTenant('fiesta_futbol_infantil'); + $admin = $this->createAdminAppUser($tenant); + $this->grantTicketsMenu($tenant); + Sanctum::actingAs($admin); + + $item = CatalogItem::query()->create([ + 'tenant_code' => $tenant->codigo, + 'slug' => 'remera', + 'nombre' => 'Remera', + 'precio' => '8000.00', + 'has_tickets' => true, + ]); + $attribute = Attribute::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'codigo' => 'size', + 'nombre' => 'Talle', + 'type' => FieldType::Select, + ]); + $attribute->options()->create(['value' => 'xl', 'label' => 'XL']); + $itemAttribute = $item->itemAttributes()->create([ + 'attribute_id' => $attribute->id, + 'sort_order' => 1, + ]); + $variant = Variant::query()->create([ + 'catalog_item_id' => $item->id, + 'inventory_id' => Inventory::query()->create()->id, + ]); + $variant->definitions()->create([ + 'item_attribute_id' => $itemAttribute->id, + 'value' => 'xl', + ]); + $ticket = $this->createTicket($tenant, $admin, [ + 'source_catalog_item_id' => $item->id, + 'source_variant_id' => $variant->id, + ]); + + $this->getJson('/api/v1/adminapp/tenant/tickets') + ->assertOk() + ->assertJsonPath('data.0.id', $ticket->id) + ->assertJsonPath('data.0.variant_properties.0.code', 'size') + ->assertJsonPath('data.0.variant_properties.0.label', 'Talle') + ->assertJsonPath('data.0.variant_properties.0.values.0.value', 'xl') + ->assertJsonPath('data.0.variant_properties.0.values.0.label', 'XL'); + } + private function createTenant(string $code): Tenant { $headerLogo = $this->createAttachment("{$code}-header.png"); @@ -157,12 +209,13 @@ class AdminAppTicketControllerTest extends TestCase $tenant->menues()->attach($menu->code); } - private function createTicket(Tenant $tenant, User $user): Ticket + /** @param array $attributes */ + private function createTicket(Tenant $tenant, User $user, array $attributes = []): Ticket { - return Ticket::query()->create([ + return Ticket::query()->create(array_merge([ 'tenant_code' => $tenant->codigo, 'ticket' => (string) Str::uuid(), 'user_id' => $user->id, - ]); + ], $attributes)); } } From 0f6f39cce716fe3c5de84696aa88c19d71dd31fb Mon Sep 17 00:00:00 2001 From: ncoronel Date: Fri, 28 Aug 2026 16:05:56 -0300 Subject: [PATCH 05/23] feat(tickets): enhance AdminAppTicketResource and service with purchase details and update tests --- .../AdminApp/AdminAppTicketResource.php | 25 +++++++++++++++ .../Ticket/Services/AdminAppTicketService.php | 2 ++ .../Ticket/AdminAppTicketControllerTest.php | 31 ++++++++++++++++++- 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketResource.php b/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketResource.php index 8a7b9e2..14ffd08 100644 --- a/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketResource.php +++ b/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketResource.php @@ -3,6 +3,7 @@ namespace App\Domains\Ticket\Resources\AdminApp; use App\Domains\Catalog\Models\ItemAttribute; +use App\Domains\Purchase\Models\PurchaseItem; use App\Domains\Ticket\Models\Ticket; use App\Domains\Ticket\Resources\TicketResource; use Illuminate\Http\Request; @@ -13,12 +14,36 @@ class AdminAppTicketResource extends TicketResource /** @return array */ public function toArray(Request $request): array { + $purchaseItem = $this->sourcePurchaseItem(); + return [ ...parent::toArray($request), + 'source_purchase_id' => $this->source_purchase_id, + 'order_number' => $this->source_purchase_id, + 'product' => $purchaseItem?->item_nombre + ?? $this->sourceCatalogItem?->nombre + ?? $this->name, + 'amount' => $purchaseItem?->precio_unitario, + 'client' => $this->sourcePurchase?->nombre_apellido ?? $this->user?->nombre_apellido, + 'date' => $this->sourcePurchase?->created_at, + 'status' => $this->status, + 'scanned_by' => $this->scannerUser?->nombre_apellido, 'variant_properties' => $this->variantProperties(), ]; } + private function sourcePurchaseItem(): ?PurchaseItem + { + return $this->sourcePurchase?->items->first(function (PurchaseItem $item): bool { + if ($this->source_variant_id !== null) { + return $item->source_variant_id === $this->source_variant_id; + } + + return $item->source_catalog_item_id === $this->source_catalog_item_id + && $item->source_variant_id === null; + }); + } + /** * @return listorderByDesc('id') ->paginateFromRequest() diff --git a/tests/Feature/Ticket/AdminAppTicketControllerTest.php b/tests/Feature/Ticket/AdminAppTicketControllerTest.php index c8da04b..15f867f 100644 --- a/tests/Feature/Ticket/AdminAppTicketControllerTest.php +++ b/tests/Feature/Ticket/AdminAppTicketControllerTest.php @@ -11,6 +11,8 @@ use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Inventory; use App\Domains\Catalog\Models\Variant; use App\Domains\Menu\Models\Menu; +use App\Domains\Purchase\Models\Purchase; +use App\Domains\Purchase\Models\PurchaseItem; use App\Domains\Shared\Enums\FieldType; use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\WebsiteType; @@ -145,14 +147,41 @@ class AdminAppTicketControllerTest extends TestCase 'item_attribute_id' => $itemAttribute->id, 'value' => 'xl', ]); - $ticket = $this->createTicket($tenant, $admin, [ + $purchase = Purchase::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'user_id' => $admin->id, + 'status' => Purchase::STATUS_PAID, + 'nombre_apellido' => 'Nombre Apellido', + 'total' => '8000.00', + ]); + PurchaseItem::query()->create([ + 'compra_id' => $purchase->id, 'source_catalog_item_id' => $item->id, 'source_variant_id' => $variant->id, + 'nombre' => 'Remera', + 'descripcion' => '', + 'slug' => 'remera', + 'item_nombre' => 'Remera', + 'cantidad' => 1, + 'precio_unitario' => '8000.00', + 'total' => '8000.00', + ]); + $ticket = $this->createTicket($tenant, $admin, [ + 'source_purchase_id' => $purchase->id, + 'source_catalog_item_id' => $item->id, + 'source_variant_id' => $variant->id, + 'scanner_user_id' => $admin->id, + 'used_at' => now(), ]); $this->getJson('/api/v1/adminapp/tenant/tickets') ->assertOk() ->assertJsonPath('data.0.id', $ticket->id) + ->assertJsonPath('data.0.order_number', $purchase->id) + ->assertJsonPath('data.0.product', 'Remera') + ->assertJsonPath('data.0.amount', '8000.00') + ->assertJsonPath('data.0.status', Ticket::STATUS_USED) + ->assertJsonPath('data.0.scanned_by', $admin->nombre_apellido) ->assertJsonPath('data.0.variant_properties.0.code', 'size') ->assertJsonPath('data.0.variant_properties.0.label', 'Talle') ->assertJsonPath('data.0.variant_properties.0.values.0.value', 'xl') From b1f42775d6c8a03215aca81589240b20b57f6ced Mon Sep 17 00:00:00 2001 From: ncoronel Date: Fri, 28 Aug 2026 17:00:47 -0300 Subject: [PATCH 06/23] 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; + } } From c209e716e3f06e68d7112a8d5eb39d65f17388ae Mon Sep 17 00:00:00 2001 From: ncoronel Date: Fri, 28 Aug 2026 14:44:55 -0300 Subject: [PATCH 07/23] feat(menu): add tickets menu and update related seeder and tests --- ..._futbol_infantil_tickets_adminapp_menu.php | 82 +++++++++++++++++++ database/seeders/MenuSeeder.php | 7 ++ tests/Feature/Seeders/MenuSeederTest.php | 7 +- 3 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 database/migrations/2026_08_28_000000_add_fiesta_futbol_infantil_tickets_adminapp_menu.php diff --git a/database/migrations/2026_08_28_000000_add_fiesta_futbol_infantil_tickets_adminapp_menu.php b/database/migrations/2026_08_28_000000_add_fiesta_futbol_infantil_tickets_adminapp_menu.php new file mode 100644 index 0000000..87c41fb --- /dev/null +++ b/database/migrations/2026_08_28_000000_add_fiesta_futbol_infantil_tickets_adminapp_menu.php @@ -0,0 +1,82 @@ +where('code', 'main.adminapp')->exists()) { + // Reference data is added by seeders on fresh installations. + return; + } + + $now = now(); + + DB::transaction(function () use ($now): void { + DB::table('menues')->updateOrInsert( + ['code' => self::MENU_CODE], + [ + 'label' => 'Tickets', + 'parent_menu_code' => 'main.adminapp', + 'content_type' => 'dynamic', + 'static_content_schema' => null, + 'route' => '/admin/tickets', + 'created_at' => $now, + 'updated_at' => $now, + ], + ); + + DB::table('tenants_menues') + ->where('menu_code', self::MENU_CODE) + ->where('tenant_code', '!=', self::TENANT_CODE) + ->delete(); + + if (DB::table('tenants')->where('codigo', self::TENANT_CODE)->exists()) { + DB::table('tenants_menues')->updateOrInsert( + [ + 'tenant_code' => self::TENANT_CODE, + 'menu_code' => self::MENU_CODE, + ], + [ + 'static_content' => null, + 'created_at' => $now, + 'updated_at' => $now, + ], + ); + } + + DB::table('roles') + ->whereIn('codigo', ['admin', 'adminapp']) + ->pluck('codigo') + ->each(function (string $roleCode): void { + DB::table('roles_menues')->updateOrInsert([ + 'rol_codigo' => $roleCode, + 'menu_codigo' => self::MENU_CODE, + ]); + }); + }); + } + + public function down(): void + { + DB::transaction(function (): void { + DB::table('tenants_menues') + ->where('menu_code', self::MENU_CODE) + ->delete(); + + DB::table('roles_menues') + ->where('menu_codigo', self::MENU_CODE) + ->delete(); + + DB::table('menues') + ->where('code', self::MENU_CODE) + ->delete(); + }); + } +}; diff --git a/database/seeders/MenuSeeder.php b/database/seeders/MenuSeeder.php index dbab420..11885b5 100644 --- a/database/seeders/MenuSeeder.php +++ b/database/seeders/MenuSeeder.php @@ -78,6 +78,12 @@ class MenuSeeder extends Seeder 'parent_menu_code' => 'main.adminapp', 'route' => '/admin/staff', ], + [ + 'code' => 'adminapp.tickets', + 'label' => 'Tickets', + 'parent_menu_code' => 'main.adminapp', + 'route' => '/admin/tickets', + ], [ 'code' => 'adminapp.fiesta-futbol-infantil.entradas', 'label' => 'Entradas', @@ -270,6 +276,7 @@ class MenuSeeder extends Seeder 'fiesta_futbol_infantil', ]; $fiestaCategoryMenuCodes = [ + 'adminapp.tickets', 'adminapp.fiesta-futbol-infantil.entradas', 'adminapp.fiesta-futbol-infantil.alojamientos', 'adminapp.fiesta-futbol-infantil.merchandising', diff --git a/tests/Feature/Seeders/MenuSeederTest.php b/tests/Feature/Seeders/MenuSeederTest.php index 6f579d0..d253c8d 100644 --- a/tests/Feature/Seeders/MenuSeederTest.php +++ b/tests/Feature/Seeders/MenuSeederTest.php @@ -34,6 +34,7 @@ class MenuSeederTest extends TestCase 'adminapp.ventas' => ['Ventas', '/admin/ventas'], ]; $fiestaCategoryMenus = [ + 'adminapp.tickets' => ['Tickets', '/admin/tickets'], 'adminapp.fiesta-futbol-infantil.entradas' => ['Entradas', '/admin/entradas'], 'adminapp.fiesta-futbol-infantil.alojamientos' => ['Alojamientos', '/admin/alojamientos'], 'adminapp.fiesta-futbol-infantil.merchandising' => ['Merchandising', '/admin/merchandising'], @@ -49,7 +50,11 @@ class MenuSeederTest extends TestCase $this->assertSame(Menu::CONTENT_TYPE_DYNAMIC, $adminApp->content_type); $this->assertSame('/', $adminApp->route); $this->assertSame( - array_keys([...$expectedMenus, ...$fiestaCategoryMenus]), + collect(array_keys([ + ...$expectedMenus, + ...$fiestaCategoryMenus, + 'adminapp.desfile.entradas' => ['Entradas', '/admin/desfile/entradas'], + ]))->sort()->values()->all(), $adminApp->children->pluck('code')->sort()->values()->all() ); $this->assertTrue( From a93b260043dd93ec4e8f64d0464a814739851a62 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Fri, 28 Aug 2026 15:10:23 -0300 Subject: [PATCH 08/23] feat(tickets): implement admin app ticket management with listing and search functionality --- .../Controllers/AdminApp/TicketController.php | 23 +++ .../Requests/AdminAppTicketIndexRequest.php | 23 +++ .../Ticket/Services/AdminAppTicketService.php | 52 ++++++ app/Domains/Ticket/documentacion/README.md | 6 + app/Domains/Ticket/routes/adminapp.php | 12 ++ app/Domains/Ticket/routes/api.php | 1 + .../Ticket/AdminAppTicketControllerTest.php | 163 ++++++++++++++++++ 7 files changed, 280 insertions(+) create mode 100644 app/Domains/Ticket/Controllers/AdminApp/TicketController.php create mode 100644 app/Domains/Ticket/Requests/AdminAppTicketIndexRequest.php create mode 100644 app/Domains/Ticket/Services/AdminAppTicketService.php create mode 100644 app/Domains/Ticket/routes/adminapp.php create mode 100644 tests/Feature/Ticket/AdminAppTicketControllerTest.php diff --git a/app/Domains/Ticket/Controllers/AdminApp/TicketController.php b/app/Domains/Ticket/Controllers/AdminApp/TicketController.php new file mode 100644 index 0000000..2fa6b10 --- /dev/null +++ b/app/Domains/Ticket/Controllers/AdminApp/TicketController.php @@ -0,0 +1,23 @@ +user()->tenant()->firstOrFail(); + + return TicketResource::collection( + $this->ticketService->list($tenant, $request->validated()) + )->additional($this->ticketService->counts($tenant)); + } +} diff --git a/app/Domains/Ticket/Requests/AdminAppTicketIndexRequest.php b/app/Domains/Ticket/Requests/AdminAppTicketIndexRequest.php new file mode 100644 index 0000000..e54dec3 --- /dev/null +++ b/app/Domains/Ticket/Requests/AdminAppTicketIndexRequest.php @@ -0,0 +1,23 @@ +> */ + public function rules(): array + { + return [ + 'q' => ['sometimes', 'nullable', 'string', 'max:255'], + '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 new file mode 100644 index 0000000..6d7c9c2 --- /dev/null +++ b/app/Domains/Ticket/Services/AdminAppTicketService.php @@ -0,0 +1,52 @@ +where('tenant_code', $tenant->codigo); + + return [ + 'scanned_tickets' => (clone $query)->whereNotNull('used_at')->count(), + 'total_tickets' => $query->count(), + ]; + } + + /** + * @param array{q?: string|null, page?: int, per_page?: int} $filters + * @return LengthAwarePaginator + */ + public function list(Tenant $tenant, array $filters = []): LengthAwarePaginator + { + $search = trim((string) ($filters['q'] ?? '')); + + return Ticket::query() + ->where('tenant_code', $tenant->codigo) + ->when($search !== '', function (Builder $query) use ($search): void { + $query->when( + ctype_digit($search), + fn (Builder $searchQuery): Builder => $searchQuery + ->where('tickets.id', (int) $search), + fn (Builder $searchQuery): Builder => $searchQuery + ->where('ticket', 'like', "%{$search}%"), + ); + }) + ->with([ + ...TicketValidityResolver::RELATIONS, + ...TicketPresentationResolver::RELATIONS, + 'user', + 'sourceCatalogItem.category', + ]) + ->orderByDesc('id') + ->paginateFromRequest() + ->withQueryString(); + } +} diff --git a/app/Domains/Ticket/documentacion/README.md b/app/Domains/Ticket/documentacion/README.md index a266d30..c43a9bd 100644 --- a/app/Domains/Ticket/documentacion/README.md +++ b/app/Domains/Ticket/documentacion/README.md @@ -30,6 +30,12 @@ Bajo `/tenants/{tenant:codigo}`, protegidos por `auth:sanctum`: - `GET /tickets`. - `POST /tickets/pdf`. +Bajo `/v1/adminapp/tenant`, protegido por `auth:sanctum`, `adminapp.tenant` y el menú +`adminapp.tickets`: + +- `GET /tickets`, paginado y con búsqueda opcional mediante `q`. La respuesta incluye + `scanned_tickets` y `total_tickets` para el tenant autenticado. + `TicketPdfService` genera la descarga y `TicketResource`/`ValidityTimeResource` definen las respuestas. ## Dependencias y reglas diff --git a/app/Domains/Ticket/routes/adminapp.php b/app/Domains/Ticket/routes/adminapp.php new file mode 100644 index 0000000..64af50e --- /dev/null +++ b/app/Domains/Ticket/routes/adminapp.php @@ -0,0 +1,12 @@ +middleware(['auth:sanctum', 'adminapp.tenant']) + ->group(function (): void { + Route::get('tickets', [TicketController::class, 'index']) + ->middleware('tenant.menu:adminapp.tickets') + ->name('adminapp.tickets.index'); + }); diff --git a/app/Domains/Ticket/routes/api.php b/app/Domains/Ticket/routes/api.php index da5305b..8bcdb8e 100644 --- a/app/Domains/Ticket/routes/api.php +++ b/app/Domains/Ticket/routes/api.php @@ -11,3 +11,4 @@ Route::prefix('tenants/{tenant:codigo}') }); require __DIR__.'/scanner.php'; +require __DIR__.'/adminapp.php'; diff --git a/tests/Feature/Ticket/AdminAppTicketControllerTest.php b/tests/Feature/Ticket/AdminAppTicketControllerTest.php new file mode 100644 index 0000000..2cbea72 --- /dev/null +++ b/tests/Feature/Ticket/AdminAppTicketControllerTest.php @@ -0,0 +1,163 @@ +seed(AuthorizationSeeder::class); + WebsiteType::query()->create(['codigo' => 'onticket', 'nombre' => 'OnTicket']); + } + + public function test_authentication_is_required(): void + { + $this->getJson('/api/v1/adminapp/tenant/tickets')->assertUnauthorized(); + } + + public function test_the_tenant_must_have_the_tickets_menu(): void + { + $tenant = $this->createTenant('fiesta_futbol_infantil'); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $this->getJson('/api/v1/adminapp/tenant/tickets')->assertNotFound(); + } + + public function test_it_lists_only_tickets_from_the_authenticated_tenant(): void + { + $tenant = $this->createTenant('fiesta_futbol_infantil'); + $otherTenant = $this->createTenant('other'); + $admin = $this->createAdminAppUser($tenant); + $otherUser = $this->createAdminAppUser($otherTenant); + $this->grantTicketsMenu($tenant); + Sanctum::actingAs($admin); + + $ticket = $this->createTicket($tenant, $admin); + $this->createTicket($otherTenant, $otherUser); + + $this->getJson('/api/v1/adminapp/tenant/tickets') + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.id', $ticket->id) + ->assertJsonPath('data.0.tenant_code', $tenant->codigo) + ->assertJsonPath('meta.total', 1); + } + + public function test_it_supports_id_and_uuid_search(): void + { + $tenant = $this->createTenant('fiesta_futbol_infantil'); + $admin = $this->createAdminAppUser($tenant); + $this->grantTicketsMenu($tenant); + Sanctum::actingAs($admin); + + $matching = $this->createTicket($tenant, $admin); + $this->createTicket($tenant, $admin); + + $this->getJson("/api/v1/adminapp/tenant/tickets?q={$matching->id}") + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.id', $matching->id); + + $this->getJson('/api/v1/adminapp/tenant/tickets?q='.substr($matching->ticket, 0, 8)) + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.ticket', $matching->ticket); + } + + public function test_it_includes_tenant_scanned_and_total_ticket_counts(): void + { + $tenant = $this->createTenant('fiesta_futbol_infantil'); + $otherTenant = $this->createTenant('other'); + $admin = $this->createAdminAppUser($tenant); + $otherUser = $this->createAdminAppUser($otherTenant); + $this->grantTicketsMenu($tenant); + Sanctum::actingAs($admin); + + $this->createTicket($tenant, $admin)->update(['used_at' => now()]); + $this->createTicket($tenant, $admin); + $this->createTicket($otherTenant, $otherUser)->update(['used_at' => now()]); + + $this->getJson('/api/v1/adminapp/tenant/tickets?q=does-not-match') + ->assertOk() + ->assertJsonCount(0, 'data') + ->assertJsonPath('scanned_tickets', 1) + ->assertJsonPath('total_tickets', 2); + } + + private function createTenant(string $code): Tenant + { + $headerLogo = $this->createAttachment("{$code}-header.png"); + $footerLogo = $this->createAttachment("{$code}-footer.png"); + + return Tenant::query()->create([ + 'codigo' => $code, + 'nombre' => ucfirst($code), + 'dominio' => "{$code}.test", + 'website_type_code' => 'onticket', + 'primary_color' => '#111111', + 'secondary_color' => '#222222', + 'danger_color' => '#333333', + 'success_color' => '#444444', + 'header_bg_color' => '#ffffff', + 'footer_bg_color' => '#ffffff', + 'header_logo_id' => $headerLogo->id, + 'footer_logo_id' => $footerLogo->id, + ]); + } + + private function createAttachment(string $filename): Attachment + { + return Attachment::query()->create([ + 'path' => "test/{$filename}", + 'filename' => $filename, + 'type' => AttachmentType::Image, + 'mime_type' => 'image/png', + ]); + } + + private function createAdminAppUser(Tenant $tenant): User + { + return User::factory()->create([ + 'rol_codigo' => RoleCode::AdminApp->value, + 'tenant_codigo' => $tenant->codigo, + ]); + } + + private function grantTicketsMenu(Tenant $tenant): void + { + $menu = Menu::query()->create([ + 'code' => 'adminapp.tickets', + 'label' => 'Tickets', + 'route' => '/admin/tickets', + ]); + + $tenant->menues()->attach($menu->code); + } + + private function createTicket(Tenant $tenant, User $user): Ticket + { + return Ticket::query()->create([ + 'tenant_code' => $tenant->codigo, + 'ticket' => (string) Str::uuid(), + 'user_id' => $user->id, + ]); + } +} From 0d85c3df197a17a9be87c09f0f150ac46a3999e4 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Fri, 28 Aug 2026 15:14:11 -0300 Subject: [PATCH 09/23] feat(tickets): enhance ticket search functionality and add ticket counts to response --- .../Controllers/AdminApp/TicketController.php | 11 +++-- .../AdminApp/AdminAppTicketCollection.php | 35 +++++++++++++++ .../Ticket/Services/AdminAppTicketResult.php | 16 +++++++ .../Ticket/Services/AdminAppTicketService.php | 44 ++++++++++--------- .../Ticket/AdminAppTicketControllerTest.php | 5 +++ 5 files changed, 85 insertions(+), 26 deletions(-) create mode 100644 app/Domains/Ticket/Resources/AdminApp/AdminAppTicketCollection.php create mode 100644 app/Domains/Ticket/Services/AdminAppTicketResult.php diff --git a/app/Domains/Ticket/Controllers/AdminApp/TicketController.php b/app/Domains/Ticket/Controllers/AdminApp/TicketController.php index 2fa6b10..beb9fac 100644 --- a/app/Domains/Ticket/Controllers/AdminApp/TicketController.php +++ b/app/Domains/Ticket/Controllers/AdminApp/TicketController.php @@ -3,21 +3,20 @@ namespace App\Domains\Ticket\Controllers\AdminApp; use App\Domains\Ticket\Requests\AdminAppTicketIndexRequest; -use App\Domains\Ticket\Resources\TicketResource; +use App\Domains\Ticket\Resources\AdminApp\AdminAppTicketCollection; use App\Domains\Ticket\Services\AdminAppTicketService; use App\Http\Controllers\Controller; -use Illuminate\Http\Resources\Json\AnonymousResourceCollection; class TicketController extends Controller { public function __construct(private readonly AdminAppTicketService $ticketService) {} - public function index(AdminAppTicketIndexRequest $request): AnonymousResourceCollection + public function index(AdminAppTicketIndexRequest $request): AdminAppTicketCollection { $tenant = $request->user()->tenant()->firstOrFail(); - return TicketResource::collection( - $this->ticketService->list($tenant, $request->validated()) - )->additional($this->ticketService->counts($tenant)); + return new AdminAppTicketCollection( + $this->ticketService->search($tenant, $request->validated()) + ); } } diff --git a/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketCollection.php b/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketCollection.php new file mode 100644 index 0000000..4355ad6 --- /dev/null +++ b/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketCollection.php @@ -0,0 +1,35 @@ + */ + public $collects = TicketResource::class; + + private readonly int $scannedTickets; + + private readonly int $totalTickets; + + public function __construct(AdminAppTicketResult $result) + { + parent::__construct($result->tickets); + + $this->scannedTickets = $result->scannedTickets; + $this->totalTickets = $result->totalTickets; + } + + /** @return array{scanned_tickets: int, total_tickets: int} */ + public function with(Request $request): array + { + return [ + 'scanned_tickets' => $this->scannedTickets, + 'total_tickets' => $this->totalTickets, + ]; + } +} diff --git a/app/Domains/Ticket/Services/AdminAppTicketResult.php b/app/Domains/Ticket/Services/AdminAppTicketResult.php new file mode 100644 index 0000000..e9f65f4 --- /dev/null +++ b/app/Domains/Ticket/Services/AdminAppTicketResult.php @@ -0,0 +1,16 @@ + $tickets */ + public function __construct( + public LengthAwarePaginator $tickets, + public int $scannedTickets, + public int $totalTickets, + ) {} +} diff --git a/app/Domains/Ticket/Services/AdminAppTicketService.php b/app/Domains/Ticket/Services/AdminAppTicketService.php index 6d7c9c2..1180843 100644 --- a/app/Domains/Ticket/Services/AdminAppTicketService.php +++ b/app/Domains/Ticket/Services/AdminAppTicketService.php @@ -5,26 +5,39 @@ namespace App\Domains\Ticket\Services; use App\Domains\Tenant\Models\Tenant; use App\Domains\Ticket\Models\Ticket; use Illuminate\Database\Eloquent\Builder; -use Illuminate\Pagination\LengthAwarePaginator; class AdminAppTicketService { - /** @return array{scanned_tickets: int, total_tickets: int} */ - public function counts(Tenant $tenant): array + /** + * @param array{q?: string|null, page?: int, per_page?: int} $filters + */ + public function search(Tenant $tenant, array $filters = []): AdminAppTicketResult { - $query = Ticket::query()->where('tenant_code', $tenant->codigo); + $query = $this->baseQuery($tenant, $filters); - return [ - 'scanned_tickets' => (clone $query)->whereNotNull('used_at')->count(), - 'total_tickets' => $query->count(), - ]; + $tickets = (clone $query) + ->with([ + ...TicketValidityResolver::RELATIONS, + ...TicketPresentationResolver::RELATIONS, + 'user', + 'sourceCatalogItem.category', + ]) + ->orderByDesc('id') + ->paginateFromRequest() + ->withQueryString(); + + return new AdminAppTicketResult( + tickets: $tickets, + scannedTickets: (clone $query)->whereNotNull('used_at')->count(), + totalTickets: $tickets->total(), + ); } /** * @param array{q?: string|null, page?: int, per_page?: int} $filters - * @return LengthAwarePaginator + * @return Builder */ - public function list(Tenant $tenant, array $filters = []): LengthAwarePaginator + private function baseQuery(Tenant $tenant, array $filters): Builder { $search = trim((string) ($filters['q'] ?? '')); @@ -38,15 +51,6 @@ class AdminAppTicketService fn (Builder $searchQuery): Builder => $searchQuery ->where('ticket', 'like', "%{$search}%"), ); - }) - ->with([ - ...TicketValidityResolver::RELATIONS, - ...TicketPresentationResolver::RELATIONS, - 'user', - 'sourceCatalogItem.category', - ]) - ->orderByDesc('id') - ->paginateFromRequest() - ->withQueryString(); + }); } } diff --git a/tests/Feature/Ticket/AdminAppTicketControllerTest.php b/tests/Feature/Ticket/AdminAppTicketControllerTest.php index 2cbea72..9705e65 100644 --- a/tests/Feature/Ticket/AdminAppTicketControllerTest.php +++ b/tests/Feature/Ticket/AdminAppTicketControllerTest.php @@ -98,6 +98,11 @@ class AdminAppTicketControllerTest extends TestCase $this->getJson('/api/v1/adminapp/tenant/tickets?q=does-not-match') ->assertOk() ->assertJsonCount(0, 'data') + ->assertJsonPath('scanned_tickets', 0) + ->assertJsonPath('total_tickets', 0); + + $this->getJson('/api/v1/adminapp/tenant/tickets') + ->assertOk() ->assertJsonPath('scanned_tickets', 1) ->assertJsonPath('total_tickets', 2); } From 7fb4c9674aad113256216267b4809f99787c26f1 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Fri, 28 Aug 2026 15:17:35 -0300 Subject: [PATCH 10/23] feat(tickets): implement AdminAppTicketResource and update ticket collection to use it --- .../AdminApp/AdminAppTicketCollection.php | 5 +- .../AdminApp/AdminAppTicketResource.php | 62 +++++++++++++++++++ .../Ticket/AdminAppTicketControllerTest.php | 59 +++++++++++++++++- 3 files changed, 120 insertions(+), 6 deletions(-) create mode 100644 app/Domains/Ticket/Resources/AdminApp/AdminAppTicketResource.php diff --git a/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketCollection.php b/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketCollection.php index 4355ad6..3af312c 100644 --- a/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketCollection.php +++ b/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketCollection.php @@ -2,15 +2,14 @@ namespace App\Domains\Ticket\Resources\AdminApp; -use App\Domains\Ticket\Resources\TicketResource; use App\Domains\Ticket\Services\AdminAppTicketResult; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\ResourceCollection; class AdminAppTicketCollection extends ResourceCollection { - /** @var class-string */ - public $collects = TicketResource::class; + /** @var class-string */ + public $collects = AdminAppTicketResource::class; private readonly int $scannedTickets; diff --git a/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketResource.php b/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketResource.php new file mode 100644 index 0000000..8a7b9e2 --- /dev/null +++ b/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketResource.php @@ -0,0 +1,62 @@ + */ + public function toArray(Request $request): array + { + return [ + ...parent::toArray($request), + 'variant_properties' => $this->variantProperties(), + ]; + } + + /** + * @return list + * }> + */ + private function variantProperties(): array + { + $variant = $this->sourceVariant; + + if ($variant === null) { + return []; + } + + $itemAttributes = $variant->definitions + ->map(fn ($definition) => $definition->itemAttribute) + ->filter() + ->merge($variant->catalogItem?->itemAttributes ?? collect()) + ->unique('id') + ->values(); + + return $variant->selectionOptions($itemAttributes) + ->map(function (array $selection, string $attributeCode) use ($itemAttributes): array { + $itemAttribute = $itemAttributes->first( + fn (ItemAttribute $itemAttribute): bool => $itemAttribute->attribute?->codigo + === $attributeCode, + ); + $values = array_is_list($selection) ? $selection : [$selection]; + + return [ + 'code' => $attributeCode, + 'label' => $itemAttribute?->attribute?->nombre + ?? ($attributeCode === 'event_date' ? 'Fecha' : $attributeCode), + 'values' => array_values($values), + ]; + }) + ->values() + ->all(); + } +} diff --git a/tests/Feature/Ticket/AdminAppTicketControllerTest.php b/tests/Feature/Ticket/AdminAppTicketControllerTest.php index 9705e65..c8da04b 100644 --- a/tests/Feature/Ticket/AdminAppTicketControllerTest.php +++ b/tests/Feature/Ticket/AdminAppTicketControllerTest.php @@ -6,7 +6,12 @@ use App\Domains\Attachable\Enums\AttachmentType; use App\Domains\Attachable\Models\Attachment; use App\Domains\Auth\Models\User; use App\Domains\Authorization\Enums\RoleCode; +use App\Domains\Catalog\Models\Attribute; +use App\Domains\Catalog\Models\CatalogItem; +use App\Domains\Catalog\Models\Inventory; +use App\Domains\Catalog\Models\Variant; use App\Domains\Menu\Models\Menu; +use App\Domains\Shared\Enums\FieldType; use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\WebsiteType; use App\Domains\Ticket\Models\Ticket; @@ -107,6 +112,53 @@ class AdminAppTicketControllerTest extends TestCase ->assertJsonPath('total_tickets', 2); } + public function test_it_returns_structured_variant_properties(): void + { + $tenant = $this->createTenant('fiesta_futbol_infantil'); + $admin = $this->createAdminAppUser($tenant); + $this->grantTicketsMenu($tenant); + Sanctum::actingAs($admin); + + $item = CatalogItem::query()->create([ + 'tenant_code' => $tenant->codigo, + 'slug' => 'remera', + 'nombre' => 'Remera', + 'precio' => '8000.00', + 'has_tickets' => true, + ]); + $attribute = Attribute::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'codigo' => 'size', + 'nombre' => 'Talle', + 'type' => FieldType::Select, + ]); + $attribute->options()->create(['value' => 'xl', 'label' => 'XL']); + $itemAttribute = $item->itemAttributes()->create([ + 'attribute_id' => $attribute->id, + 'sort_order' => 1, + ]); + $variant = Variant::query()->create([ + 'catalog_item_id' => $item->id, + 'inventory_id' => Inventory::query()->create()->id, + ]); + $variant->definitions()->create([ + 'item_attribute_id' => $itemAttribute->id, + 'value' => 'xl', + ]); + $ticket = $this->createTicket($tenant, $admin, [ + 'source_catalog_item_id' => $item->id, + 'source_variant_id' => $variant->id, + ]); + + $this->getJson('/api/v1/adminapp/tenant/tickets') + ->assertOk() + ->assertJsonPath('data.0.id', $ticket->id) + ->assertJsonPath('data.0.variant_properties.0.code', 'size') + ->assertJsonPath('data.0.variant_properties.0.label', 'Talle') + ->assertJsonPath('data.0.variant_properties.0.values.0.value', 'xl') + ->assertJsonPath('data.0.variant_properties.0.values.0.label', 'XL'); + } + private function createTenant(string $code): Tenant { $headerLogo = $this->createAttachment("{$code}-header.png"); @@ -157,12 +209,13 @@ class AdminAppTicketControllerTest extends TestCase $tenant->menues()->attach($menu->code); } - private function createTicket(Tenant $tenant, User $user): Ticket + /** @param array $attributes */ + private function createTicket(Tenant $tenant, User $user, array $attributes = []): Ticket { - return Ticket::query()->create([ + return Ticket::query()->create(array_merge([ 'tenant_code' => $tenant->codigo, 'ticket' => (string) Str::uuid(), 'user_id' => $user->id, - ]); + ], $attributes)); } } From 781e282f167e456570112ea133c4261859c64ca0 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Fri, 28 Aug 2026 16:05:56 -0300 Subject: [PATCH 11/23] feat(tickets): enhance AdminAppTicketResource and service with purchase details and update tests --- .../AdminApp/AdminAppTicketResource.php | 25 +++++++++++++++ .../Ticket/Services/AdminAppTicketService.php | 2 ++ .../Ticket/AdminAppTicketControllerTest.php | 31 ++++++++++++++++++- 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketResource.php b/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketResource.php index 8a7b9e2..14ffd08 100644 --- a/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketResource.php +++ b/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketResource.php @@ -3,6 +3,7 @@ namespace App\Domains\Ticket\Resources\AdminApp; use App\Domains\Catalog\Models\ItemAttribute; +use App\Domains\Purchase\Models\PurchaseItem; use App\Domains\Ticket\Models\Ticket; use App\Domains\Ticket\Resources\TicketResource; use Illuminate\Http\Request; @@ -13,12 +14,36 @@ class AdminAppTicketResource extends TicketResource /** @return array */ public function toArray(Request $request): array { + $purchaseItem = $this->sourcePurchaseItem(); + return [ ...parent::toArray($request), + 'source_purchase_id' => $this->source_purchase_id, + 'order_number' => $this->source_purchase_id, + 'product' => $purchaseItem?->item_nombre + ?? $this->sourceCatalogItem?->nombre + ?? $this->name, + 'amount' => $purchaseItem?->precio_unitario, + 'client' => $this->sourcePurchase?->nombre_apellido ?? $this->user?->nombre_apellido, + 'date' => $this->sourcePurchase?->created_at, + 'status' => $this->status, + 'scanned_by' => $this->scannerUser?->nombre_apellido, 'variant_properties' => $this->variantProperties(), ]; } + private function sourcePurchaseItem(): ?PurchaseItem + { + return $this->sourcePurchase?->items->first(function (PurchaseItem $item): bool { + if ($this->source_variant_id !== null) { + return $item->source_variant_id === $this->source_variant_id; + } + + return $item->source_catalog_item_id === $this->source_catalog_item_id + && $item->source_variant_id === null; + }); + } + /** * @return listorderByDesc('id') ->paginateFromRequest() diff --git a/tests/Feature/Ticket/AdminAppTicketControllerTest.php b/tests/Feature/Ticket/AdminAppTicketControllerTest.php index c8da04b..15f867f 100644 --- a/tests/Feature/Ticket/AdminAppTicketControllerTest.php +++ b/tests/Feature/Ticket/AdminAppTicketControllerTest.php @@ -11,6 +11,8 @@ use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Inventory; use App\Domains\Catalog\Models\Variant; use App\Domains\Menu\Models\Menu; +use App\Domains\Purchase\Models\Purchase; +use App\Domains\Purchase\Models\PurchaseItem; use App\Domains\Shared\Enums\FieldType; use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\WebsiteType; @@ -145,14 +147,41 @@ class AdminAppTicketControllerTest extends TestCase 'item_attribute_id' => $itemAttribute->id, 'value' => 'xl', ]); - $ticket = $this->createTicket($tenant, $admin, [ + $purchase = Purchase::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'user_id' => $admin->id, + 'status' => Purchase::STATUS_PAID, + 'nombre_apellido' => 'Nombre Apellido', + 'total' => '8000.00', + ]); + PurchaseItem::query()->create([ + 'compra_id' => $purchase->id, 'source_catalog_item_id' => $item->id, 'source_variant_id' => $variant->id, + 'nombre' => 'Remera', + 'descripcion' => '', + 'slug' => 'remera', + 'item_nombre' => 'Remera', + 'cantidad' => 1, + 'precio_unitario' => '8000.00', + 'total' => '8000.00', + ]); + $ticket = $this->createTicket($tenant, $admin, [ + 'source_purchase_id' => $purchase->id, + 'source_catalog_item_id' => $item->id, + 'source_variant_id' => $variant->id, + 'scanner_user_id' => $admin->id, + 'used_at' => now(), ]); $this->getJson('/api/v1/adminapp/tenant/tickets') ->assertOk() ->assertJsonPath('data.0.id', $ticket->id) + ->assertJsonPath('data.0.order_number', $purchase->id) + ->assertJsonPath('data.0.product', 'Remera') + ->assertJsonPath('data.0.amount', '8000.00') + ->assertJsonPath('data.0.status', Ticket::STATUS_USED) + ->assertJsonPath('data.0.scanned_by', $admin->nombre_apellido) ->assertJsonPath('data.0.variant_properties.0.code', 'size') ->assertJsonPath('data.0.variant_properties.0.label', 'Talle') ->assertJsonPath('data.0.variant_properties.0.values.0.value', 'xl') From 495515b1f3f9a06cf703c5221f06b0ed6a76a81c Mon Sep 17 00:00:00 2001 From: ncoronel Date: Fri, 28 Aug 2026 17:00:47 -0300 Subject: [PATCH 12/23] 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 | 2 +- .../AdminAppTicketFormControllerTest.php | 155 ++++++++++++ tests/TestCase.php | 19 +- 8 files changed, 449 insertions(+), 8 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 a3b9b10..0bce1ba 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -18,7 +18,7 @@ - + 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 9b31cc8..19a12de 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -8,17 +8,22 @@ 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(); - $connection = (string) $app['config']->get('database.default'); - $database = (string) $app['config']->get("database.connections.{$connection}.database"); - $usesInMemorySqlite = $connection === 'sqlite' && $database === ':memory:'; - if (! $usesInMemorySqlite && ! str_ends_with(strtolower($database), '_test')) { - throw new RuntimeException( - "Unsafe test database [{$database}]. Tests may only use an in-memory SQLite database or a database ending in _test.", - ); + $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; From 1ca8fb20af91582d709956826b176e83053f8b90 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 31 Aug 2026 10:57:05 -0300 Subject: [PATCH 13/23] feat(tickets): add TicketFilterFormController, TicketFilterFormService, and TicketFilterFormResource with routes and tests --- .../AdminApp/TicketFilterFormController.php | 20 ++ .../Resources/TicketFilterFormResource.php | 20 ++ .../Services/TicketFilterFormService.php | 118 ++++++++++ app/Domains/Forms/routes/adminapp.php | 4 + ...AdminAppTicketFilterFormControllerTest.php | 213 ++++++++++++++++++ 5 files changed, 375 insertions(+) create mode 100644 app/Domains/Forms/Controllers/AdminApp/TicketFilterFormController.php create mode 100644 app/Domains/Forms/Resources/TicketFilterFormResource.php create mode 100644 app/Domains/Forms/Services/TicketFilterFormService.php create mode 100644 tests/Feature/Forms/AdminAppTicketFilterFormControllerTest.php diff --git a/app/Domains/Forms/Controllers/AdminApp/TicketFilterFormController.php b/app/Domains/Forms/Controllers/AdminApp/TicketFilterFormController.php new file mode 100644 index 0000000..d6c1201 --- /dev/null +++ b/app/Domains/Forms/Controllers/AdminApp/TicketFilterFormController.php @@ -0,0 +1,20 @@ +user('sanctum')->tenant()->firstOrFail(); + + return TicketFilterFormResource::make($this->formService->get($tenant)); + } +} diff --git a/app/Domains/Forms/Resources/TicketFilterFormResource.php b/app/Domains/Forms/Resources/TicketFilterFormResource.php new file mode 100644 index 0000000..f1f4aa7 --- /dev/null +++ b/app/Domains/Forms/Resources/TicketFilterFormResource.php @@ -0,0 +1,20 @@ + */ + public function toArray(Request $request): array + { + return [ + 'code' => $this->resource['code'], + 'action' => $this->resource['action'], + 'method' => $this->resource['method'], + 'fields' => $this->resource['fields'], + ]; + } +} diff --git a/app/Domains/Forms/Services/TicketFilterFormService.php b/app/Domains/Forms/Services/TicketFilterFormService.php new file mode 100644 index 0000000..e87611a --- /dev/null +++ b/app/Domains/Forms/Services/TicketFilterFormService.php @@ -0,0 +1,118 @@ + */ + public function get(Tenant $tenant): array + { + $fields = $this->commonFields(); + + if ($tenant->codigo === self::FIESTA_FUTBOL_INFANTIL) { + $fields = [ + ...$this->fiestaFutbolInfantilFields($tenant), + ...$fields, + ]; + } + + return [ + 'code' => 'tickets_filter', + 'action' => '/api/v1/adminapp/tenant/tickets', + 'method' => 'GET', + 'fields' => $fields, + ]; + } + + /** @return list> */ + private function fiestaFutbolInfantilFields(Tenant $tenant): array + { + $form = $this->ticketFormService->get($tenant); + + return [ + [ + 'name' => 'category', + 'label' => 'Categoría', + 'type' => 'select', + 'required' => false, + 'default' => null, + 'placeholder' => 'Categoría', + 'options' => array_map( + fn (array $category): array => [ + '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'], + ], + ], + $category['products'], + ), + ], + ], + $form['categories'], + ), + ], + $this->dependentSelect('product', 'Producto', 'category'), + $this->dependentSelect('type', 'Tipo', 'product'), + ]; + } + + /** @return list> */ + private function commonFields(): array + { + return [ + [ + 'name' => 'date', + 'label' => 'Fecha', + 'type' => 'date', + 'required' => false, + 'default' => null, + ], + [ + 'name' => 'status', + 'label' => 'Estado', + 'type' => 'select', + 'required' => false, + 'default' => null, + 'placeholder' => 'Estado', + 'options' => [ + ['value' => Ticket::STATUS_ACTIVE, 'label' => 'Activo'], + ['value' => Ticket::STATUS_USED, 'label' => 'Usado'], + ['value' => Ticket::STATUS_EXPIRED, 'label' => 'Vencido'], + ], + ], + ]; + } + + /** @return array */ + private function dependentSelect(string $name, string $label, string $dependency): array + { + return [ + 'name' => $name, + 'label' => $label, + 'type' => 'select', + 'required' => false, + 'default' => null, + 'placeholder' => $label, + 'depends_on' => $dependency, + 'disabled' => true, + 'options' => [], + ]; + } +} diff --git a/app/Domains/Forms/routes/adminapp.php b/app/Domains/Forms/routes/adminapp.php index 21a92d1..70f9ee3 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\TicketFilterFormController; use App\Domains\Forms\Controllers\AdminApp\TicketFormController; use Illuminate\Support\Facades\Route; @@ -14,6 +15,9 @@ Route::prefix('v1/adminapp/forms') Route::get('event', EventFormController::class); Route::get('sale', SaleFormController::class); Route::get('staff', StaffFormController::class); + Route::get('tickets-filter', TicketFilterFormController::class) + ->middleware('tenant.menu:adminapp.tickets') + ->name('adminapp.forms.tickets-filter'); Route::get( 'fiesta-futbol-infantil/ticket', TicketFormController::class diff --git a/tests/Feature/Forms/AdminAppTicketFilterFormControllerTest.php b/tests/Feature/Forms/AdminAppTicketFilterFormControllerTest.php new file mode 100644 index 0000000..55f8035 --- /dev/null +++ b/tests/Feature/Forms/AdminAppTicketFilterFormControllerTest.php @@ -0,0 +1,213 @@ +seed(AuthorizationSeeder::class); + } + + public function test_authentication_is_required(): void + { + $this->getJson('/api/v1/adminapp/forms/tickets-filter')->assertUnauthorized(); + } + + public function test_the_tenant_must_have_the_tickets_menu(): void + { + $tenant = $this->createTenant('tenant_without_tickets'); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $this->getJson('/api/v1/adminapp/forms/tickets-filter')->assertNotFound(); + } + + public function test_it_returns_the_common_ticket_filter_fields_for_other_tenants(): void + { + $tenant = $this->createTenant('another_tenant'); + $this->grantTicketsMenu($tenant); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $this->getJson('/api/v1/adminapp/forms/tickets-filter') + ->assertOk() + ->assertExactJson([ + 'data' => [ + 'code' => 'tickets_filter', + 'action' => '/api/v1/adminapp/tenant/tickets', + 'method' => 'GET', + 'fields' => [ + [ + 'name' => 'date', + 'label' => 'Fecha', + 'type' => 'date', + 'required' => false, + 'default' => null, + ], + [ + 'name' => 'status', + 'label' => 'Estado', + 'type' => 'select', + 'required' => false, + 'default' => null, + 'placeholder' => 'Estado', + 'options' => [ + ['value' => 'active', 'label' => 'Activo'], + ['value' => 'used', 'label' => 'Usado'], + ['value' => 'expired', 'label' => 'Vencido'], + ], + ], + ], + ], + ]); + } + + public function test_it_returns_the_complete_nested_form_for_fiesta_futbol_infantil(): void + { + $tenant = $this->createFiestaFutbolInfantilTenant(); + $this->grantTicketsMenu($tenant); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $response = $this->getJson('/api/v1/adminapp/forms/tickets-filter') + ->assertOk() + ->assertJsonPath('data.fields.0.name', 'category') + ->assertJsonPath('data.fields.1.name', 'product') + ->assertJsonPath('data.fields.1.depends_on', 'category') + ->assertJsonPath('data.fields.2.name', 'type') + ->assertJsonPath('data.fields.2.depends_on', 'product') + ->assertJsonPath('data.fields.3.name', 'date') + ->assertJsonPath('data.fields.4.name', 'status'); + + $categories = collect($response->json('data.fields.0.options')); + $this->assertSame( + ['Entradas', 'Camping', 'Comida', 'Merchandising'], + $categories->pluck('label')->all(), + ); + + $entries = $categories->firstWhere('value', 'entradas'); + $this->assertSame('Abono', $entries['children']['options'][0]['label']); + $this->assertTrue($entries['children']['options'][0]['children']['disabled']); + + $camping = $categories->firstWhere('value', 'alojamientos'); + $this->assertSame( + ['Carpa', 'Motorhome'], + collect($camping['children']['options'])->pluck('label')->all(), + ); + $this->assertTrue($camping['children']['options'][0]['children']['disabled']); + + $merchandise = $categories->firstWhere('value', 'merchandising'); + $this->assertSame('Camiseta', $merchandise['children']['options'][0]['label']); + $this->assertSame( + ['Verde', 'Blanco'], + collect($merchandise['children']['options'][0]['children']['options'])->pluck('label')->all(), + ); + } + + public function test_a_food_schedule_is_only_returned_when_a_variant_exists_for_the_date(): void + { + $tenant = $this->createFiestaFutbolInfantilTenant(); + $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') + ->with([ + 'itemAttributes.attribute.options', + 'variants.definitions.itemAttribute.attribute.options', + 'variants.eventDates', + 'variants.eventDate', + ]) + ->firstOrFail(); + + $food->variants + ->filter(fn ($variant): bool => $variant->selectedEventDates()->contains('id', $date->id) + && $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'); + + $this->assertNotContains('Almuerzo', $schedules); + $this->assertContains('Desayuno', $schedules); + $this->assertContains('Cena', $schedules); + } + + private function createFiestaFutbolInfantilTenant(): Tenant + { + $tenant = $this->createTenant('fiesta_futbol_infantil'); + $this->seed([AttributeSeeder::class, FiestaFutbolInfantilProductSeeder::class]); + + return $tenant->refresh(); + } + + private function createTenant(string $code): Tenant + { + $headerLogo = $this->createAttachment("{$code}-header.png"); + $footerLogo = $this->createAttachment("{$code}-footer.png"); + + return Tenant::query()->create([ + 'codigo' => $code, + 'nombre' => ucfirst($code), + 'dominio' => "{$code}.test", + 'primary_color' => '#111111', + 'secondary_color' => '#222222', + 'danger_color' => '#333333', + 'success_color' => '#444444', + 'header_bg_color' => '#ffffff', + 'footer_bg_color' => '#ffffff', + 'header_logo_id' => $headerLogo->id, + 'footer_logo_id' => $footerLogo->id, + ]); + } + + private function createAttachment(string $filename): Attachment + { + return Attachment::query()->create([ + 'path' => "tests/{$filename}", + 'filename' => $filename, + 'type' => AttachmentType::Image, + 'mime_type' => 'image/png', + ]); + } + + private function createAdminAppUser(Tenant $tenant): User + { + return User::factory()->create([ + 'rol_codigo' => RoleCode::AdminApp->value, + 'tenant_codigo' => $tenant->codigo, + ]); + } + + private function grantTicketsMenu(Tenant $tenant): void + { + $menu = Menu::query()->create([ + 'code' => 'adminapp.tickets', + 'label' => 'Tickets', + 'route' => '/admin/tickets', + ]); + + $tenant->menues()->attach($menu->code); + } +} From a71c80248c27c4f97af1ddb480cddeaf81d9a053 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 31 Aug 2026 10:57:25 -0300 Subject: [PATCH 14/23] feat(tickets): update TicketFilterFormService to use getForFilters method and enhance TicketFormService with historical data handling --- .../Services/TicketFilterFormService.php | 2 +- .../Forms/Services/TicketFormService.php | 106 +++++++++++++++++- ...AdminAppTicketFilterFormControllerTest.php | 56 ++++++++- 3 files changed, 160 insertions(+), 4 deletions(-) diff --git a/app/Domains/Forms/Services/TicketFilterFormService.php b/app/Domains/Forms/Services/TicketFilterFormService.php index e87611a..b10753c 100644 --- a/app/Domains/Forms/Services/TicketFilterFormService.php +++ b/app/Domains/Forms/Services/TicketFilterFormService.php @@ -34,7 +34,7 @@ class TicketFilterFormService /** @return list> */ private function fiestaFutbolInfantilFields(Tenant $tenant): array { - $form = $this->ticketFormService->get($tenant); + $form = $this->ticketFormService->getForFilters($tenant); return [ [ diff --git a/app/Domains/Forms/Services/TicketFormService.php b/app/Domains/Forms/Services/TicketFormService.php index c8690db..9c644de 100644 --- a/app/Domains/Forms/Services/TicketFormService.php +++ b/app/Domains/Forms/Services/TicketFormService.php @@ -6,6 +6,7 @@ use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Variant; use App\Domains\Tenant\Models\Tenant; use App\Domains\Ticket\Models\Ticket; +use Illuminate\Database\Eloquent\Collection; class TicketFormService { @@ -69,15 +70,82 @@ class TicketFormService */ public function get(Tenant $tenant): array { - $categories = []; - $items = CatalogItem::query() ->where('tenant_code', $tenant->codigo) ->where('has_tickets', true) ->whereHas('category') + ->with($this->relations()) + ->orderBy('group_order') + ->orderBy('nombre') + ->get(); + + return $this->build($items); + } + + /** + * Return active catalog options plus soft-deleted sources still referenced by + * tickets, so historical tickets never become impossible to filter. + * + * @return array{ + * statuses: list, + * categories: list + * }> + * }> + * } + */ + public function getForFilters(Tenant $tenant): array + { + $historicalVariantIds = Ticket::query() + ->where('tenant_code', $tenant->codigo) + ->whereNotNull('source_variant_id') + ->distinct() + ->pluck('source_variant_id') + ->map(fn ($id): int => (int) $id) + ->all(); + $historicalCatalogItemIds = Ticket::query() + ->where('tenant_code', $tenant->codigo) + ->whereNotNull('source_catalog_item_id') + ->distinct() + ->pluck('source_catalog_item_id') + ->map(fn ($id): int => (int) $id) + ->merge( + Variant::withTrashed() + ->whereKey($historicalVariantIds) + ->pluck('catalog_item_id') + ->map(fn ($id): int => (int) $id), + ) + ->unique() + ->values() + ->all(); + + $items = CatalogItem::withTrashed() + ->where('tenant_code', $tenant->codigo) + ->whereHas('category') + ->where(function ($query) use ($historicalCatalogItemIds): void { + $query + ->where(function ($activeQuery): void { + $activeQuery + ->whereNull('catalog_items.deleted_at') + ->where('has_tickets', true); + }) + ->orWhereIn('catalog_items.id', $historicalCatalogItemIds); + }) ->with([ 'category', 'itemAttributes.attribute.options', + 'variants' => fn ($query) => $query + ->withTrashed() + ->where(function ($variantQuery) use ($historicalVariantIds): void { + $variantQuery + ->whereNull('variantes.deleted_at') + ->orWhereIn('variantes.id', $historicalVariantIds); + }), 'variants.definitions.itemAttribute.attribute.options', 'variants.eventDates', 'variants.eventDate', @@ -86,6 +154,28 @@ class TicketFormService ->orderBy('nombre') ->get(); + return $this->build($items); + } + + /** + * @param Collection $items + * @return array{ + * statuses: list, + * categories: list + * }> + * }> + * } + */ + private function build(Collection $items): array + { + $categories = []; + foreach ($items as $item) { $sourceCategory = trim((string) $item->category?->nombre); $categoryValue = mb_strtolower($sourceCategory); @@ -146,6 +236,18 @@ class TicketFormService ]; } + /** @return list */ + private function relations(): array + { + return [ + 'category', + 'itemAttributes.attribute.options', + 'variants.definitions.itemAttribute.attribute.options', + 'variants.eventDates', + 'variants.eventDate', + ]; + } + /** * @return listcreateFiestaFutbolInfantilTenant(); $this->grantTicketsMenu($tenant); - Sanctum::actingAs($this->createAdminAppUser($tenant)); + $admin = $this->createAdminAppUser($tenant); + Sanctum::actingAs($admin); $response = $this->getJson('/api/v1/adminapp/forms/tickets-filter') ->assertOk() @@ -154,6 +158,56 @@ class AdminAppTicketFilterFormControllerTest extends TestCase $this->assertContains('Cena', $schedules); } + public function test_a_deleted_food_variant_remains_in_the_filter_when_a_ticket_references_it(): void + { + $tenant = $this->createFiestaFutbolInfantilTenant(); + $this->grantTicketsMenu($tenant); + $admin = $this->createAdminAppUser($tenant); + Sanctum::actingAs($admin); + + $food = CatalogItem::query() + ->where('tenant_code', $tenant->codigo) + ->where('slug', 'comida') + ->with([ + 'itemAttributes.attribute.options', + 'variants.definitions.itemAttribute.attribute.options', + 'variants.eventDates', + 'variants.eventDate', + ]) + ->firstOrFail(); + $historicalVariant = $food->variants->first(function ($variant): bool { + return $variant->selectedEventDates()->first()?->date->format('d/m') === '12/10' + && $variant->selectionValues()->get('horario') === 'Cena'; + }); + $this->assertNotNull($historicalVariant); + + Ticket::query()->create([ + 'tenant_code' => $tenant->codigo, + 'ticket' => (string) Str::uuid(), + 'user_id' => $admin->id, + 'source_catalog_item_id' => $food->id, + 'source_variant_id' => $historicalVariant->id, + ]); + + $catalogService = app(CatalogService::class); + foreach ($food->variants as $variant) { + $catalogService->deleteVariant($variant); + } + + $this->assertTrue(CatalogItem::withTrashed()->findOrFail($food->id)->trashed()); + + $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']); + + $this->assertCount(1, $products); + $this->assertSame('12/10', $products->first()['label']); + $this->assertSame( + ['Cena'], + collect($products->first()['children']['options'])->pluck('label')->all(), + ); + } + private function createFiestaFutbolInfantilTenant(): Tenant { $tenant = $this->createTenant('fiesta_futbol_infantil'); From 27544a23be2c04f9a264d888047d0429cd276341 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 31 Aug 2026 11:06:30 -0300 Subject: [PATCH 15/23] 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([ From 63012838a42bf36938896add005858e69e480955 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 31 Aug 2026 11:14:41 -0300 Subject: [PATCH 16/23] feat(tickets): add query_param to ticket filter fields in TicketFilterFormService and update related tests --- app/Domains/Forms/Services/TicketFilterFormService.php | 4 ++++ .../Forms/AdminAppTicketFilterFormControllerTest.php | 9 ++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/app/Domains/Forms/Services/TicketFilterFormService.php b/app/Domains/Forms/Services/TicketFilterFormService.php index b10753c..856fe9c 100644 --- a/app/Domains/Forms/Services/TicketFilterFormService.php +++ b/app/Domains/Forms/Services/TicketFilterFormService.php @@ -39,6 +39,7 @@ class TicketFilterFormService return [ [ 'name' => 'category', + 'query_param' => 'category', 'label' => 'Categoría', 'type' => 'select', 'required' => false, @@ -79,6 +80,7 @@ class TicketFilterFormService return [ [ 'name' => 'date', + 'query_param' => 'date', 'label' => 'Fecha', 'type' => 'date', 'required' => false, @@ -86,6 +88,7 @@ class TicketFilterFormService ], [ 'name' => 'status', + 'query_param' => 'status', 'label' => 'Estado', 'type' => 'select', 'required' => false, @@ -105,6 +108,7 @@ class TicketFilterFormService { return [ 'name' => $name, + 'query_param' => $name, 'label' => $label, 'type' => 'select', 'required' => false, diff --git a/tests/Feature/Forms/AdminAppTicketFilterFormControllerTest.php b/tests/Feature/Forms/AdminAppTicketFilterFormControllerTest.php index b362c99..6a74c3b 100644 --- a/tests/Feature/Forms/AdminAppTicketFilterFormControllerTest.php +++ b/tests/Feature/Forms/AdminAppTicketFilterFormControllerTest.php @@ -59,6 +59,7 @@ class AdminAppTicketFilterFormControllerTest extends TestCase 'fields' => [ [ 'name' => 'date', + 'query_param' => 'date', 'label' => 'Fecha', 'type' => 'date', 'required' => false, @@ -66,6 +67,7 @@ class AdminAppTicketFilterFormControllerTest extends TestCase ], [ 'name' => 'status', + 'query_param' => 'status', 'label' => 'Estado', 'type' => 'select', 'required' => false, @@ -92,12 +94,17 @@ class AdminAppTicketFilterFormControllerTest extends TestCase $response = $this->getJson('/api/v1/adminapp/forms/tickets-filter') ->assertOk() ->assertJsonPath('data.fields.0.name', 'category') + ->assertJsonPath('data.fields.0.query_param', 'category') ->assertJsonPath('data.fields.1.name', 'product') + ->assertJsonPath('data.fields.1.query_param', 'product') ->assertJsonPath('data.fields.1.depends_on', 'category') ->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.4.name', 'status'); + ->assertJsonPath('data.fields.3.query_param', 'date') + ->assertJsonPath('data.fields.4.name', 'status') + ->assertJsonPath('data.fields.4.query_param', 'status'); $categories = collect($response->json('data.fields.0.options')); $this->assertSame( From 9da92c880a7b61d92e4bf80525338a1b3e596160 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 31 Aug 2026 12:03:11 -0300 Subject: [PATCH 17/23] refactor(tickets): prepare filtered query for exports --- .../Ticket/Services/AdminAppTicketService.php | 31 ++++++++++++++----- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/app/Domains/Ticket/Services/AdminAppTicketService.php b/app/Domains/Ticket/Services/AdminAppTicketService.php index 292c510..5f91dc8 100644 --- a/app/Domains/Ticket/Services/AdminAppTicketService.php +++ b/app/Domains/Ticket/Services/AdminAppTicketService.php @@ -5,9 +5,19 @@ namespace App\Domains\Ticket\Services; use App\Domains\Tenant\Models\Tenant; use App\Domains\Ticket\Models\Ticket; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Support\Collection; class AdminAppTicketService { + private const RELATIONS = [ + ...TicketValidityResolver::RELATIONS, + ...TicketPresentationResolver::RELATIONS, + 'user', + 'scannerUser', + 'sourceCatalogItem.category', + 'sourcePurchase.items', + ]; + /** * @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 */ @@ -16,14 +26,7 @@ class AdminAppTicketService $query = $this->baseQuery($tenant, $filters); $tickets = (clone $query) - ->with([ - ...TicketValidityResolver::RELATIONS, - ...TicketPresentationResolver::RELATIONS, - 'user', - 'scannerUser', - 'sourceCatalogItem.category', - 'sourcePurchase.items', - ]) + ->with(self::RELATIONS) ->orderByDesc('id') ->paginateFromRequest() ->withQueryString(); @@ -35,6 +38,18 @@ class AdminAppTicketService ); } + /** + * @param array{q?: string|null, category?: string|null, product?: string|null, type?: string|null, date?: string|null, status?: string|null} $filters + * @return Collection + */ + public function ticketsForExport(Tenant $tenant, array $filters = []): Collection + { + return $this->baseQuery($tenant, $filters) + ->with(self::RELATIONS) + ->orderByDesc('id') + ->get(); + } + /** * @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 From 1b6303237ac1ed86ca06f7ce3e354465adb65394 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 31 Aug 2026 12:03:25 -0300 Subject: [PATCH 18/23] feat(tickets): add PDF and Excel export endpoints --- .../Controllers/AdminApp/TicketController.php | 33 ++++- .../Requests/AdminAppTicketExportRequest.php | 17 +++ .../Services/AdminAppTicketExcelService.php | 103 +++++++++++++ .../Services/AdminAppTicketPdfService.php | 50 +++++++ .../Services/AdminAppTicketReportService.php | 112 ++++++++++++++ app/Domains/Ticket/routes/adminapp.php | 6 + .../views/pdf/adminapp/tickets.blade.php | 78 ++++++++++ .../Ticket/AdminAppTicketControllerTest.php | 39 +++++ .../AdminAppTicketExportServiceTest.php | 138 ++++++++++++++++++ 9 files changed, 575 insertions(+), 1 deletion(-) create mode 100644 app/Domains/Ticket/Requests/AdminAppTicketExportRequest.php create mode 100644 app/Domains/Ticket/Services/AdminAppTicketExcelService.php create mode 100644 app/Domains/Ticket/Services/AdminAppTicketPdfService.php create mode 100644 app/Domains/Ticket/Services/AdminAppTicketReportService.php create mode 100644 resources/views/pdf/adminapp/tickets.blade.php create mode 100644 tests/Unit/Ticket/AdminAppTicketExportServiceTest.php diff --git a/app/Domains/Ticket/Controllers/AdminApp/TicketController.php b/app/Domains/Ticket/Controllers/AdminApp/TicketController.php index beb9fac..00194f4 100644 --- a/app/Domains/Ticket/Controllers/AdminApp/TicketController.php +++ b/app/Domains/Ticket/Controllers/AdminApp/TicketController.php @@ -2,14 +2,23 @@ namespace App\Domains\Ticket\Controllers\AdminApp; +use App\Domains\Ticket\Requests\AdminAppTicketExportRequest; use App\Domains\Ticket\Requests\AdminAppTicketIndexRequest; use App\Domains\Ticket\Resources\AdminApp\AdminAppTicketCollection; +use App\Domains\Ticket\Services\AdminAppTicketExcelService; +use App\Domains\Ticket\Services\AdminAppTicketPdfService; use App\Domains\Ticket\Services\AdminAppTicketService; use App\Http\Controllers\Controller; +use Illuminate\Http\Response; +use Symfony\Component\HttpFoundation\StreamedResponse; class TicketController extends Controller { - public function __construct(private readonly AdminAppTicketService $ticketService) {} + public function __construct( + private readonly AdminAppTicketService $ticketService, + private readonly AdminAppTicketPdfService $ticketPdfService, + private readonly AdminAppTicketExcelService $ticketExcelService, + ) {} public function index(AdminAppTicketIndexRequest $request): AdminAppTicketCollection { @@ -19,4 +28,26 @@ class TicketController extends Controller $this->ticketService->search($tenant, $request->validated()) ); } + + public function downloadPdf(AdminAppTicketExportRequest $request): Response + { + $tenant = $request->user()->tenant()->firstOrFail(); + + return $this->ticketPdfService->download( + $tenant, + $this->ticketService->ticketsForExport($tenant, $request->validated()), + $request->validated('timezone'), + ); + } + + public function downloadExcel(AdminAppTicketExportRequest $request): StreamedResponse + { + $tenant = $request->user()->tenant()->firstOrFail(); + + return $this->ticketExcelService->download( + $tenant, + $this->ticketService->ticketsForExport($tenant, $request->validated()), + $request->validated('timezone'), + ); + } } diff --git a/app/Domains/Ticket/Requests/AdminAppTicketExportRequest.php b/app/Domains/Ticket/Requests/AdminAppTicketExportRequest.php new file mode 100644 index 0000000..a55a60c --- /dev/null +++ b/app/Domains/Ticket/Requests/AdminAppTicketExportRequest.php @@ -0,0 +1,17 @@ + */ + public function rules(): array + { + return [ + ...parent::rules(), + 'timezone' => ['required', 'string', new ValidTimezone], + ]; + } +} diff --git a/app/Domains/Ticket/Services/AdminAppTicketExcelService.php b/app/Domains/Ticket/Services/AdminAppTicketExcelService.php new file mode 100644 index 0000000..50a6517 --- /dev/null +++ b/app/Domains/Ticket/Services/AdminAppTicketExcelService.php @@ -0,0 +1,103 @@ + $tickets */ + public function download(Tenant $tenant, Collection $tickets, string $timeZone): StreamedResponse + { + $generatedAt = now(); + $rows = $this->reportService->rows($tickets); + $spreadsheet = new Spreadsheet; + $spreadsheet->getProperties() + ->setCreator('Shopit') + ->setTitle('Listado de tickets') + ->setSubject($tenant->nombre); + $sheet = $spreadsheet->getActiveSheet(); + $sheet->setTitle('Tickets'); + $sheet->fromArray([ + 'N° de orden', + 'Categoría', + 'Producto', + 'Tipo', + 'Importe', + 'Cliente', + 'ID', + 'Fecha', + 'Estado', + 'Escaneado por', + ], null, 'A1'); + + foreach ($rows as $index => $ticket) { + $row = $index + 2; + $sheet->setCellValueExplicit( + "A{$row}", + $ticket['order_number'] === null ? '-' : '#'.$ticket['order_number'], + DataType::TYPE_STRING, + ); + $sheet->setCellValueExplicit("B{$row}", $ticket['category'], DataType::TYPE_STRING); + $sheet->setCellValueExplicit("C{$row}", $ticket['product'], DataType::TYPE_STRING); + $sheet->setCellValueExplicit("D{$row}", $ticket['type'], DataType::TYPE_STRING); + if ($ticket['amount'] !== null) { + $sheet->setCellValue("E{$row}", $ticket['amount']); + } + $sheet->setCellValueExplicit("F{$row}", $ticket['client'], DataType::TYPE_STRING); + $sheet->setCellValueExplicit("G{$row}", $ticket['ticket'], DataType::TYPE_STRING); + if ($ticket['date'] instanceof CarbonInterface) { + $sheet->setCellValue( + "H{$row}", + Date::dateTimeToExcel($ticket['date']->copy()->timezone($timeZone)), + ); + } + $sheet->setCellValueExplicit("I{$row}", $ticket['status'], DataType::TYPE_STRING); + $sheet->setCellValueExplicit("J{$row}", $ticket['scanned_by'], DataType::TYPE_STRING); + } + + $lastRow = max(2, $rows->count() + 1); + $sheet->getStyle("E2:E{$lastRow}")->getNumberFormat()->setFormatCode('$ #,##0.00'); + $sheet->getStyle("H2:H{$lastRow}")->getNumberFormat()->setFormatCode('dd/mm/yyyy hh:mm'); + $sheet->getStyle('A1:J1')->applyFromArray([ + 'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']], + 'fill' => [ + 'fillType' => Fill::FILL_SOLID, + 'startColor' => ['rgb' => '26382E'], + ], + 'alignment' => ['vertical' => Alignment::VERTICAL_CENTER], + ]); + $sheet->getRowDimension(1)->setRowHeight(24); + $sheet->freezePane('A2'); + $sheet->setAutoFilter("A1:J{$lastRow}"); + + foreach ([ + 'A' => 14, 'B' => 18, 'C' => 22, 'D' => 18, 'E' => 15, + 'F' => 30, 'G' => 39, 'H' => 20, 'I' => 13, 'J' => 28, + ] as $column => $width) { + $sheet->getColumnDimension($column)->setWidth($width); + } + + $filename = 'tickets_'.$tenant->codigo.'_' + .$generatedAt->copy()->timezone($timeZone)->format('Ymd_His').'.xlsx'; + + return response()->streamDownload(function () use ($spreadsheet): void { + (new Xlsx($spreadsheet))->save('php://output'); + $spreadsheet->disconnectWorksheets(); + }, $filename, [ + 'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ]); + } +} diff --git a/app/Domains/Ticket/Services/AdminAppTicketPdfService.php b/app/Domains/Ticket/Services/AdminAppTicketPdfService.php new file mode 100644 index 0000000..d6a92ef --- /dev/null +++ b/app/Domains/Ticket/Services/AdminAppTicketPdfService.php @@ -0,0 +1,50 @@ + $tickets */ + public function download(Tenant $tenant, Collection $tickets, string $timeZone): Response + { + $generatedAt = now(); + $pdf = Pdf::loadView('pdf.adminapp.tickets', [ + 'tenant' => $tenant, + 'tickets' => $this->reportService->rows($tickets), + 'generatedAt' => $generatedAt, + 'timeZone' => $timeZone, + ])->setPaper('a3', 'landscape'); + + $this->addPageNumbers($pdf); + + return $pdf->download( + 'tickets_'.$tenant->codigo.'_' + .$generatedAt->copy()->timezone($timeZone)->format('Ymd_His').'.pdf' + ); + } + + private function addPageNumbers(DomPdf $pdf): void + { + $pdf->render(); + $domPdf = $pdf->getDomPDF(); + $font = $domPdf->getFontMetrics()->getFont('DejaVu Sans'); + + $domPdf->getCanvas()->page_text( + 565, + 805, + 'Página {PAGE_NUM} de {PAGE_COUNT}', + $font, + 7, + [0.48, 0.52, 0.49], + ); + } +} diff --git a/app/Domains/Ticket/Services/AdminAppTicketReportService.php b/app/Domains/Ticket/Services/AdminAppTicketReportService.php new file mode 100644 index 0000000..d8c64e7 --- /dev/null +++ b/app/Domains/Ticket/Services/AdminAppTicketReportService.php @@ -0,0 +1,112 @@ + ['category' => 'Camping', 'product' => 'tipo_alojamiento', 'type' => null], + 'camping' => ['category' => null, 'product' => 'tipo_alojamiento', 'type' => null], + 'entradas' => ['category' => null, 'product' => 'product', 'type' => null], + 'comidas' => ['category' => 'Comida', 'product' => 'event_date', 'type' => 'horario'], + 'comida' => ['category' => null, 'product' => 'event_date', 'type' => 'horario'], + 'merchandising' => ['category' => null, 'product' => 'product', 'type' => 'color'], + ]; + + /** + * @param Collection $tickets + * @return Collection> + */ + public function rows(Collection $tickets): Collection + { + return $tickets->values()->map(fn (Ticket $ticket): array => $this->row($ticket)); + } + + /** @return array */ + private function row(Ticket $ticket): array + { + $data = (new AdminAppTicketResource($ticket))->resolve(); + $presentation = $this->presentation($data); + + return [ + 'order_number' => $data['order_number'], + 'category' => $presentation['category'], + 'product' => $presentation['product'], + 'type' => $presentation['type'], + 'amount' => $data['amount'] === null ? null : (float) $data['amount'], + 'client' => $data['client'] ?? 'Sin nombre', + 'ticket' => $data['ticket'], + 'date' => $data['date'], + 'status' => $this->statusLabel($data['status']), + 'scanned_by' => $data['scanned_by'] ?? '-', + ]; + } + + /** + * @param array $ticket + * @return array{category: string, product: string, type: string} + */ + private function presentation(array $ticket): array + { + $sourceCategory = trim((string) ($ticket['category'] ?? '')) ?: '-'; + $configuration = self::CATEGORY_PRESENTATIONS[mb_strtolower($sourceCategory)] ?? null; + + if ($configuration === null) { + return [ + 'category' => $sourceCategory, + 'product' => (string) ($ticket['product'] ?: $ticket['name'] ?: '-'), + 'type' => $this->allPropertyLabels($ticket) ?: '-', + ]; + } + + return [ + 'category' => $configuration['category'] ?? $sourceCategory, + 'product' => $configuration['product'] === 'product' + ? (string) ($ticket['product'] ?: $ticket['name'] ?: '-') + : ($this->propertyLabels($ticket, $configuration['product']) ?: '-'), + 'type' => $configuration['type'] === null + ? '-' + : ($this->propertyLabels($ticket, $configuration['type']) ?: '-'), + ]; + } + + /** @param array $ticket */ + private function propertyLabels(array $ticket, string $code): string + { + $property = collect($ticket['variant_properties'] ?? [])->firstWhere('code', $code); + $labels = collect($property['values'] ?? [])->pluck('label')->filter(); + + if ($code === 'event_date') { + $labels = $labels->map(function (string $label): string { + [$day, $month] = array_pad(explode('/', $label), 2, null); + + return $day !== null && $month !== null ? "{$day}/{$month}" : $label; + }); + } + + return $labels->implode(', '); + } + + /** @param array $ticket */ + private function allPropertyLabels(array $ticket): string + { + return collect($ticket['variant_properties'] ?? []) + ->flatMap(fn (array $property): array => $property['values'] ?? []) + ->pluck('label') + ->filter() + ->implode(', '); + } + + private function statusLabel(string $status): string + { + return match ($status) { + Ticket::STATUS_USED => 'Usado', + Ticket::STATUS_EXPIRED => 'Vencido', + default => 'Activo', + }; + } +} diff --git a/app/Domains/Ticket/routes/adminapp.php b/app/Domains/Ticket/routes/adminapp.php index 64af50e..f2602cc 100644 --- a/app/Domains/Ticket/routes/adminapp.php +++ b/app/Domains/Ticket/routes/adminapp.php @@ -9,4 +9,10 @@ Route::prefix('v1/adminapp/tenant') Route::get('tickets', [TicketController::class, 'index']) ->middleware('tenant.menu:adminapp.tickets') ->name('adminapp.tickets.index'); + Route::get('tickets/pdf', [TicketController::class, 'downloadPdf']) + ->middleware('tenant.menu:adminapp.tickets') + ->name('adminapp.tickets.pdf'); + Route::get('tickets/excel', [TicketController::class, 'downloadExcel']) + ->middleware('tenant.menu:adminapp.tickets') + ->name('adminapp.tickets.excel'); }); diff --git a/resources/views/pdf/adminapp/tickets.blade.php b/resources/views/pdf/adminapp/tickets.blade.php new file mode 100644 index 0000000..0ce5bdd --- /dev/null +++ b/resources/views/pdf/adminapp/tickets.blade.php @@ -0,0 +1,78 @@ + + + + + + + +

Listado de tickets

+

{{ $tenant->nombre }} · Generado el {{ $generatedAt->copy()->timezone($timeZone)->format('d/m/Y H:i') }}

+ +
+ Tickets incluidos: {{ $tickets->count() }} +   ·   + Escaneados: {{ $tickets->where('status', 'Usado')->count() }} +
+ + + + + + + + + + + + + + + + + + @forelse ($tickets as $ticket) + + + + + + + + + + + + + @empty + + @endforelse + +
N° de ordenCategoríaProductoTipoImporteClienteIDFechaEstadoEscaneado por
{{ $ticket['order_number'] === null ? '-' : '#'.$ticket['order_number'] }}{{ $ticket['category'] }}{{ $ticket['product'] }}{{ $ticket['type'] }}{{ $ticket['amount'] === null ? '-' : '$'.number_format($ticket['amount'], 2, ',', '.') }}{{ $ticket['client'] }}{{ $ticket['ticket'] }}{{ $ticket['date']?->copy()->timezone($timeZone)->format('d/m/Y H:i') ?? '-' }}{{ $ticket['status'] }}{{ $ticket['scanned_by'] }}
No hay tickets para los criterios seleccionados.
+ + diff --git a/tests/Feature/Ticket/AdminAppTicketControllerTest.php b/tests/Feature/Ticket/AdminAppTicketControllerTest.php index ca130a0..6d2e849 100644 --- a/tests/Feature/Ticket/AdminAppTicketControllerTest.php +++ b/tests/Feature/Ticket/AdminAppTicketControllerTest.php @@ -17,6 +17,7 @@ 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 Barryvdh\DomPDF\ServiceProvider as DomPdfServiceProvider; use Database\Seeders\AttributeSeeder; use Database\Seeders\AuthorizationSeeder; use Database\Seeders\FiestaFutbolInfantilProductSeeder; @@ -33,6 +34,8 @@ class AdminAppTicketControllerTest extends TestCase { parent::setUp(); + $this->app->register(DomPdfServiceProvider::class); + $this->seed(AuthorizationSeeder::class); WebsiteType::query()->create(['codigo' => 'onticket', 'nombre' => 'OnTicket']); } @@ -284,6 +287,42 @@ class AdminAppTicketControllerTest extends TestCase ->assertJsonPath('data.0.id', $active->id); } + public function test_it_downloads_filtered_ticket_reports(): void + { + $tenant = $this->createTenant('fiesta_futbol_infantil'); + $admin = $this->createAdminAppUser($tenant); + $this->grantTicketsMenu($tenant); + Sanctum::actingAs($admin); + + $ticket = $this->createTicket($tenant, $admin); + $query = http_build_query([ + 'q' => (string) $ticket->id, + 'timezone' => 'America/Argentina/Buenos_Aires', + ]); + + $this->get('/api/v1/adminapp/tenant/tickets/pdf?'.$query) + ->assertOk() + ->assertHeader('content-type', 'application/pdf'); + + $this->get('/api/v1/adminapp/tenant/tickets/excel?'.$query) + ->assertOk() + ->assertHeader( + 'content-type', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ); + } + + public function test_ticket_reports_require_a_valid_timezone(): void + { + $tenant = $this->createTenant('fiesta_futbol_infantil'); + $this->grantTicketsMenu($tenant); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $this->getJson('/api/v1/adminapp/tenant/tickets/pdf')->assertUnprocessable(); + $this->getJson('/api/v1/adminapp/tenant/tickets/excel?timezone=Invalid') + ->assertUnprocessable(); + } + private function createTenant(string $code): Tenant { $headerLogo = $this->createAttachment("{$code}-header.png"); diff --git a/tests/Unit/Ticket/AdminAppTicketExportServiceTest.php b/tests/Unit/Ticket/AdminAppTicketExportServiceTest.php new file mode 100644 index 0000000..c238222 --- /dev/null +++ b/tests/Unit/Ticket/AdminAppTicketExportServiceTest.php @@ -0,0 +1,138 @@ +app->register(ServiceProvider::class); + Carbon::setTestNow(Carbon::parse('2026-08-24 17:53:00', 'UTC')); + } + + protected function tearDown(): void + { + Carbon::setTestNow(); + + parent::tearDown(); + } + + public function test_it_downloads_the_ticket_report_as_an_excel_file(): void + { + $response = (new AdminAppTicketExcelService($this->reportService())) + ->download($this->tenant(), collect(), 'America/La_Paz'); + + $this->assertSame( + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + $response->headers->get('content-type'), + ); + $this->assertStringContainsString( + 'attachment; filename=tickets_acme_20260824_135300.xlsx', + (string) $response->headers->get('content-disposition'), + ); + + $path = $this->spreadsheetPath($response); + try { + $sheet = IOFactory::load($path)->getActiveSheet(); + + $this->assertSame('Tickets', $sheet->getTitle()); + $this->assertSame('N° de orden', $sheet->getCell('A1')->getValue()); + $this->assertSame('#15', $sheet->getCell('A2')->getValue()); + $this->assertSame('Cena', $sheet->getCell('D2')->getValue()); + $this->assertSame(8000.0, $sheet->getCell('E2')->getValue()); + $this->assertSame('00000000-0000-0000-0000-000000000001', $sheet->getCell('G2')->getValue()); + $this->assertSame('Usado', $sheet->getCell('I2')->getValue()); + } finally { + @unlink($path); + } + } + + public function test_it_downloads_the_ticket_report_as_a_pdf(): void + { + $response = (new AdminAppTicketPdfService($this->reportService())) + ->download($this->tenant(), collect(), 'America/La_Paz'); + + $this->assertSame('application/pdf', $response->headers->get('content-type')); + $this->assertStringContainsString( + 'attachment; filename=tickets_acme_20260824_135300.pdf', + (string) $response->headers->get('content-disposition'), + ); + $this->assertStringStartsWith('%PDF', $response->getContent()); + } + + public function test_the_pdf_view_contains_the_report_data_and_requested_timezone(): void + { + $html = view('pdf.adminapp.tickets', [ + 'tenant' => $this->tenant(), + 'tickets' => collect([$this->row()]), + 'generatedAt' => now(), + 'timeZone' => 'America/La_Paz', + ])->render(); + + $this->assertStringContainsString('Generado el 24/08/2026 13:53', $html); + $this->assertStringContainsString('00000000-0000-0000-0000-000000000001', $html); + $this->assertStringContainsString('Cena', $html); + $this->assertStringContainsString('24/08/2026 13:53', $html); + } + + private function reportService(): AdminAppTicketReportService + { + $service = Mockery::mock(AdminAppTicketReportService::class); + $service->shouldReceive('rows')->once()->andReturn(collect([$this->row()])); + + return $service; + } + + /** @return array */ + private function row(): array + { + return [ + 'order_number' => 15, + 'category' => 'Comida', + 'product' => '12/10', + 'type' => 'Cena', + 'amount' => 8000.0, + 'client' => 'Cliente Test', + 'ticket' => '00000000-0000-0000-0000-000000000001', + 'date' => now(), + 'status' => 'Usado', + 'scanned_by' => 'Admin Test', + ]; + } + + private function tenant(): Tenant + { + return (new Tenant)->forceFill([ + 'codigo' => 'acme', + 'nombre' => 'Acme Eventos', + ]); + } + + private function spreadsheetPath(StreamedResponse $response): string + { + ob_start(); + ($response->getCallback())(); + $contents = ob_get_clean(); + $this->assertIsString($contents); + $this->assertStringStartsWith('PK', $contents); + + $path = tempnam(sys_get_temp_dir(), 'shopit_ticket_excel_'); + $this->assertNotFalse($path); + file_put_contents($path, $contents); + + return $path; + } +} From bfb16ef60ea873ce001473647d8a62aca100c605 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 31 Aug 2026 12:31:13 -0300 Subject: [PATCH 19/23] feat(tickets): define tenant-specific table columns --- .../Resources/TicketFilterFormResource.php | 1 + .../Services/TicketFilterFormService.php | 7 +- .../Services/AdminAppTicketColumnService.php | 85 +++++++++++++++++++ ...AdminAppTicketFilterFormControllerTest.php | 22 ++++- 4 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 app/Domains/Ticket/Services/AdminAppTicketColumnService.php diff --git a/app/Domains/Forms/Resources/TicketFilterFormResource.php b/app/Domains/Forms/Resources/TicketFilterFormResource.php index f1f4aa7..f82513c 100644 --- a/app/Domains/Forms/Resources/TicketFilterFormResource.php +++ b/app/Domains/Forms/Resources/TicketFilterFormResource.php @@ -15,6 +15,7 @@ class TicketFilterFormResource extends JsonResource 'action' => $this->resource['action'], 'method' => $this->resource['method'], 'fields' => $this->resource['fields'], + 'columns' => $this->resource['columns'], ]; } } diff --git a/app/Domains/Forms/Services/TicketFilterFormService.php b/app/Domains/Forms/Services/TicketFilterFormService.php index 856fe9c..f9f22bc 100644 --- a/app/Domains/Forms/Services/TicketFilterFormService.php +++ b/app/Domains/Forms/Services/TicketFilterFormService.php @@ -4,12 +4,16 @@ namespace App\Domains\Forms\Services; use App\Domains\Tenant\Models\Tenant; use App\Domains\Ticket\Models\Ticket; +use App\Domains\Ticket\Services\AdminAppTicketColumnService; class TicketFilterFormService { private const FIESTA_FUTBOL_INFANTIL = 'fiesta_futbol_infantil'; - public function __construct(private readonly TicketFormService $ticketFormService) {} + public function __construct( + private readonly TicketFormService $ticketFormService, + private readonly AdminAppTicketColumnService $columnService, + ) {} /** @return array */ public function get(Tenant $tenant): array @@ -28,6 +32,7 @@ class TicketFilterFormService 'action' => '/api/v1/adminapp/tenant/tickets', 'method' => 'GET', 'fields' => $fields, + 'columns' => $this->columnService->publicColumns($tenant), ]; } diff --git a/app/Domains/Ticket/Services/AdminAppTicketColumnService.php b/app/Domains/Ticket/Services/AdminAppTicketColumnService.php new file mode 100644 index 0000000..a935e90 --- /dev/null +++ b/app/Domains/Ticket/Services/AdminAppTicketColumnService.php @@ -0,0 +1,85 @@ + */ + public function columns(Tenant $tenant): array + { + $keys = $tenant->codigo === self::FIESTA_FUTBOL_INFANTIL + ? ['order_number', 'category', 'product', 'type', 'amount', 'client', 'ticket', 'date', 'status', 'scanned_by'] + : ['order_number', 'product', 'amount', 'client', 'ticket', 'date', 'status', 'scanned_by']; + + $columns = array_map(fn (string $key): array => $this->definitions()[$key], $keys); + + if ($tenant->codigo !== self::FIESTA_FUTBOL_INFANTIL) { + $widths = [ + 'order_number' => '11%', + 'product' => '15%', + 'amount' => '10%', + 'client' => '15%', + 'ticket' => '19%', + 'date' => '11%', + 'status' => '8%', + 'scanned_by' => '11%', + ]; + $columns = array_map(function (array $column) use ($widths): array { + $column['width'] = $widths[$column['key']]; + + return $column; + }, $columns); + } + + return $columns; + } + + /** @return list */ + public function publicColumns(Tenant $tenant): array + { + return array_map(function (array $column): array { + unset($column['excel_width']); + + return $column; + }, $this->columns($tenant)); + } + + /** @return array */ + private function definitions(): array + { + return [ + 'order_number' => $this->column('order_number', 'N° de orden', 'order_number', '10.5%', 14), + 'category' => $this->column('category', 'Categoría', 'text', '11%', 18), + 'product' => $this->column('product', 'Producto', 'text', '11%', 22), + 'type' => $this->column('type', 'Tipo', 'text', '8%', 18), + 'amount' => $this->column('amount', 'Importe', 'currency', '9%', 15), + 'client' => $this->column('client', 'Cliente', 'text', '13%', 30), + 'ticket' => $this->column('ticket', 'ID', 'text', '14.5%', 39), + 'date' => $this->column('date', 'Fecha', 'date', '9.5%', 20), + 'status' => $this->column('status', 'Estado', 'status', '7%', 13), + 'scanned_by' => $this->column('scanned_by', 'Escaneado por', 'text', '9%', 28), + ]; + } + + /** @return array{key: string, label: string, type: string, sortable: bool, width: string, excel_width: int} */ + private function column( + string $key, + string $label, + string $type, + string $width, + int $excelWidth, + ): array { + return [ + 'key' => $key, + 'label' => $label, + 'type' => $type, + 'sortable' => true, + 'width' => $width, + 'excel_width' => $excelWidth, + ]; + } +} diff --git a/tests/Feature/Forms/AdminAppTicketFilterFormControllerTest.php b/tests/Feature/Forms/AdminAppTicketFilterFormControllerTest.php index 6a74c3b..296187b 100644 --- a/tests/Feature/Forms/AdminAppTicketFilterFormControllerTest.php +++ b/tests/Feature/Forms/AdminAppTicketFilterFormControllerTest.php @@ -56,6 +56,7 @@ class AdminAppTicketFilterFormControllerTest extends TestCase 'code' => 'tickets_filter', 'action' => '/api/v1/adminapp/tenant/tickets', 'method' => 'GET', + 'columns' => $this->commonColumns(), 'fields' => [ [ 'name' => 'date', @@ -104,7 +105,11 @@ class AdminAppTicketFilterFormControllerTest extends TestCase ->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.4.query_param', 'status') + ->assertJsonPath('data.columns.0.key', 'order_number') + ->assertJsonPath('data.columns.1.key', 'category') + ->assertJsonPath('data.columns.2.key', 'product') + ->assertJsonPath('data.columns.3.key', 'type'); $categories = collect($response->json('data.fields.0.options')); $this->assertSame( @@ -232,6 +237,21 @@ class AdminAppTicketFilterFormControllerTest extends TestCase return $tenant->refresh(); } + /** @return list> */ + 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%'], + ]; + } + private function createTenant(string $code): Tenant { $headerLogo = $this->createAttachment("{$code}-header.png"); From bda94d02afac31fb8ce936bcf9ade73518180a4b Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 31 Aug 2026 12:31:24 -0300 Subject: [PATCH 20/23] feat(tickets): expose normalized values for dynamic columns --- .../AdminApp/AdminAppTicketResource.php | 72 +----- .../Services/AdminAppTicketReportService.php | 95 +------- .../Services/AdminAppTicketRowService.php | 217 ++++++++++++++++++ ...AdminAppTicketFilterFormControllerTest.php | 5 +- .../Ticket/AdminAppTicketControllerTest.php | 2 + 5 files changed, 237 insertions(+), 154 deletions(-) create mode 100644 app/Domains/Ticket/Services/AdminAppTicketRowService.php diff --git a/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketResource.php b/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketResource.php index 14ffd08..feda27c 100644 --- a/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketResource.php +++ b/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketResource.php @@ -2,10 +2,9 @@ namespace App\Domains\Ticket\Resources\AdminApp; -use App\Domains\Catalog\Models\ItemAttribute; -use App\Domains\Purchase\Models\PurchaseItem; use App\Domains\Ticket\Models\Ticket; use App\Domains\Ticket\Resources\TicketResource; +use App\Domains\Ticket\Services\AdminAppTicketRowService; use Illuminate\Http\Request; /** @mixin Ticket */ @@ -14,74 +13,13 @@ class AdminAppTicketResource extends TicketResource /** @return array */ public function toArray(Request $request): array { - $purchaseItem = $this->sourcePurchaseItem(); + $rowService = app(AdminAppTicketRowService::class); + $details = $rowService->details($this->resource); return [ ...parent::toArray($request), - 'source_purchase_id' => $this->source_purchase_id, - 'order_number' => $this->source_purchase_id, - 'product' => $purchaseItem?->item_nombre - ?? $this->sourceCatalogItem?->nombre - ?? $this->name, - 'amount' => $purchaseItem?->precio_unitario, - 'client' => $this->sourcePurchase?->nombre_apellido ?? $this->user?->nombre_apellido, - 'date' => $this->sourcePurchase?->created_at, - 'status' => $this->status, - 'scanned_by' => $this->scannerUser?->nombre_apellido, - 'variant_properties' => $this->variantProperties(), + ...$details, + 'values' => $rowService->values($this->resource, $details), ]; } - - private function sourcePurchaseItem(): ?PurchaseItem - { - return $this->sourcePurchase?->items->first(function (PurchaseItem $item): bool { - if ($this->source_variant_id !== null) { - return $item->source_variant_id === $this->source_variant_id; - } - - return $item->source_catalog_item_id === $this->source_catalog_item_id - && $item->source_variant_id === null; - }); - } - - /** - * @return list - * }> - */ - private function variantProperties(): array - { - $variant = $this->sourceVariant; - - if ($variant === null) { - return []; - } - - $itemAttributes = $variant->definitions - ->map(fn ($definition) => $definition->itemAttribute) - ->filter() - ->merge($variant->catalogItem?->itemAttributes ?? collect()) - ->unique('id') - ->values(); - - return $variant->selectionOptions($itemAttributes) - ->map(function (array $selection, string $attributeCode) use ($itemAttributes): array { - $itemAttribute = $itemAttributes->first( - fn (ItemAttribute $itemAttribute): bool => $itemAttribute->attribute?->codigo - === $attributeCode, - ); - $values = array_is_list($selection) ? $selection : [$selection]; - - return [ - 'code' => $attributeCode, - 'label' => $itemAttribute?->attribute?->nombre - ?? ($attributeCode === 'event_date' ? 'Fecha' : $attributeCode), - 'values' => array_values($values), - ]; - }) - ->values() - ->all(); - } } diff --git a/app/Domains/Ticket/Services/AdminAppTicketReportService.php b/app/Domains/Ticket/Services/AdminAppTicketReportService.php index d8c64e7..4c2accc 100644 --- a/app/Domains/Ticket/Services/AdminAppTicketReportService.php +++ b/app/Domains/Ticket/Services/AdminAppTicketReportService.php @@ -3,19 +3,11 @@ namespace App\Domains\Ticket\Services; use App\Domains\Ticket\Models\Ticket; -use App\Domains\Ticket\Resources\AdminApp\AdminAppTicketResource; use Illuminate\Support\Collection; class AdminAppTicketReportService { - private const CATEGORY_PRESENTATIONS = [ - 'alojamientos' => ['category' => 'Camping', 'product' => 'tipo_alojamiento', 'type' => null], - 'camping' => ['category' => null, 'product' => 'tipo_alojamiento', 'type' => null], - 'entradas' => ['category' => null, 'product' => 'product', 'type' => null], - 'comidas' => ['category' => 'Comida', 'product' => 'event_date', 'type' => 'horario'], - 'comida' => ['category' => null, 'product' => 'event_date', 'type' => 'horario'], - 'merchandising' => ['category' => null, 'product' => 'product', 'type' => 'color'], - ]; + public function __construct(private readonly AdminAppTicketRowService $rowService) {} /** * @param Collection $tickets @@ -23,90 +15,21 @@ class AdminAppTicketReportService */ public function rows(Collection $tickets): Collection { - return $tickets->values()->map(fn (Ticket $ticket): array => $this->row($ticket)); - } - - /** @return array */ - private function row(Ticket $ticket): array - { - $data = (new AdminAppTicketResource($ticket))->resolve(); - $presentation = $this->presentation($data); - - return [ - 'order_number' => $data['order_number'], - 'category' => $presentation['category'], - 'product' => $presentation['product'], - 'type' => $presentation['type'], - 'amount' => $data['amount'] === null ? null : (float) $data['amount'], - 'client' => $data['client'] ?? 'Sin nombre', - 'ticket' => $data['ticket'], - 'date' => $data['date'], - 'status' => $this->statusLabel($data['status']), - 'scanned_by' => $data['scanned_by'] ?? '-', - ]; + return $this->rowService->rows($tickets); } /** - * @param array $ticket - * @return array{category: string, product: string, type: string} + * @param Collection> $rows + * @param list> $columns + * @return Collection> */ - private function presentation(array $ticket): array + public function displayRows(Collection $rows, array $columns, string $timeZone): Collection { - $sourceCategory = trim((string) ($ticket['category'] ?? '')) ?: '-'; - $configuration = self::CATEGORY_PRESENTATIONS[mb_strtolower($sourceCategory)] ?? null; - - if ($configuration === null) { - return [ - 'category' => $sourceCategory, - 'product' => (string) ($ticket['product'] ?: $ticket['name'] ?: '-'), - 'type' => $this->allPropertyLabels($ticket) ?: '-', - ]; - } - - return [ - 'category' => $configuration['category'] ?? $sourceCategory, - 'product' => $configuration['product'] === 'product' - ? (string) ($ticket['product'] ?: $ticket['name'] ?: '-') - : ($this->propertyLabels($ticket, $configuration['product']) ?: '-'), - 'type' => $configuration['type'] === null - ? '-' - : ($this->propertyLabels($ticket, $configuration['type']) ?: '-'), - ]; + return $this->rowService->displayRows($rows, $columns, $timeZone); } - /** @param array $ticket */ - private function propertyLabels(array $ticket, string $code): string + public function displayValue(mixed $value, string $type, string $timeZone): string { - $property = collect($ticket['variant_properties'] ?? [])->firstWhere('code', $code); - $labels = collect($property['values'] ?? [])->pluck('label')->filter(); - - if ($code === 'event_date') { - $labels = $labels->map(function (string $label): string { - [$day, $month] = array_pad(explode('/', $label), 2, null); - - return $day !== null && $month !== null ? "{$day}/{$month}" : $label; - }); - } - - return $labels->implode(', '); - } - - /** @param array $ticket */ - private function allPropertyLabels(array $ticket): string - { - return collect($ticket['variant_properties'] ?? []) - ->flatMap(fn (array $property): array => $property['values'] ?? []) - ->pluck('label') - ->filter() - ->implode(', '); - } - - private function statusLabel(string $status): string - { - return match ($status) { - Ticket::STATUS_USED => 'Usado', - Ticket::STATUS_EXPIRED => 'Vencido', - default => 'Activo', - }; + return $this->rowService->displayValue($value, $type, $timeZone); } } diff --git a/app/Domains/Ticket/Services/AdminAppTicketRowService.php b/app/Domains/Ticket/Services/AdminAppTicketRowService.php new file mode 100644 index 0000000..b8de06b --- /dev/null +++ b/app/Domains/Ticket/Services/AdminAppTicketRowService.php @@ -0,0 +1,217 @@ + ['category' => 'Camping', 'product' => 'tipo_alojamiento', 'type' => null], + 'camping' => ['category' => null, 'product' => 'tipo_alojamiento', 'type' => null], + 'entradas' => ['category' => null, 'product' => 'product', 'type' => null], + 'comidas' => ['category' => 'Comida', 'product' => 'event_date', 'type' => 'horario'], + 'comida' => ['category' => null, 'product' => 'event_date', 'type' => 'horario'], + 'merchandising' => ['category' => null, 'product' => 'product', 'type' => 'color'], + ]; + + /** @return array */ + public function details(Ticket $ticket): array + { + $purchaseItem = $this->sourcePurchaseItem($ticket); + + return [ + 'source_purchase_id' => $ticket->source_purchase_id, + 'order_number' => $ticket->source_purchase_id, + 'product' => $purchaseItem?->item_nombre + ?? $ticket->sourceCatalogItem?->nombre + ?? $ticket->name, + 'amount' => $purchaseItem?->precio_unitario, + 'client' => $ticket->sourcePurchase?->nombre_apellido ?? $ticket->user?->nombre_apellido, + 'date' => $ticket->sourcePurchase?->created_at, + 'status' => $ticket->status, + 'scanned_by' => $ticket->scannerUser?->nombre_apellido, + 'variant_properties' => $this->variantProperties($ticket), + ]; + } + + /** @param array|null $details */ + public function values(Ticket $ticket, ?array $details = null): array + { + $details ??= $this->details($ticket); + $presentation = $this->presentation($ticket, $details); + + return [ + 'order_number' => $details['order_number'], + 'category' => $presentation['category'], + 'product' => $presentation['product'], + 'type' => $presentation['type'], + 'amount' => $details['amount'] === null ? null : (float) $details['amount'], + 'client' => $details['client'] ?? 'Sin nombre', + 'ticket' => $ticket->ticket, + 'date' => $details['date'], + 'status' => $details['status'], + 'scanned_by' => $details['scanned_by'] ?? '-', + ]; + } + + /** + * @param Collection $tickets + * @return Collection> + */ + public function rows(Collection $tickets): Collection + { + return $tickets->values()->map(fn (Ticket $ticket): array => $this->values($ticket)); + } + + /** + * @param Collection> $rows + * @param list> $columns + * @return Collection> + */ + public function displayRows(Collection $rows, array $columns, string $timeZone): Collection + { + return $rows->map(fn (array $row): array => collect($columns) + ->mapWithKeys(fn (array $column): array => [ + $column['key'] => $this->displayValue( + $row[$column['key']] ?? null, + $column['type'], + $timeZone, + ), + ]) + ->all()); + } + + public function displayValue(mixed $value, string $type, string $timeZone): string + { + if ($value === null || $value === '') { + return '-'; + } + + return match ($type) { + 'order_number' => '#'.$value, + 'currency' => '$'.number_format((float) $value, 2, ',', '.'), + 'date' => ($value instanceof CarbonInterface ? $value : Carbon::parse((string) $value)) + ->copy()->timezone($timeZone)->format('d/m/Y H:i'), + 'status' => match ((string) $value) { + Ticket::STATUS_USED => 'Usado', + Ticket::STATUS_EXPIRED => 'Vencido', + default => 'Activo', + }, + default => (string) $value, + }; + } + + /** @param array $details */ + private function presentation(Ticket $ticket, array $details): array + { + $sourceCategory = trim((string) ($ticket->sourceCatalogItem?->category?->nombre ?? '')) ?: '-'; + + if ($ticket->tenant_code !== self::FIESTA_FUTBOL_INFANTIL) { + return [ + 'category' => $sourceCategory, + 'product' => (string) ($details['product'] ?: $ticket->name ?: '-'), + 'type' => $this->allPropertyLabels($details) ?: '-', + ]; + } + + $configuration = self::CATEGORY_PRESENTATIONS[mb_strtolower($sourceCategory)] ?? null; + if ($configuration === null) { + return [ + 'category' => $sourceCategory, + 'product' => (string) ($details['product'] ?: $ticket->name ?: '-'), + 'type' => $this->allPropertyLabels($details) ?: '-', + ]; + } + + return [ + 'category' => $configuration['category'] ?? $sourceCategory, + 'product' => $configuration['product'] === 'product' + ? (string) ($details['product'] ?: $ticket->name ?: '-') + : ($this->propertyLabels($details, $configuration['product']) ?: '-'), + 'type' => $configuration['type'] === null + ? '-' + : ($this->propertyLabels($details, $configuration['type']) ?: '-'), + ]; + } + + /** @param array $details */ + private function propertyLabels(array $details, string $code): string + { + $property = collect($details['variant_properties'] ?? [])->firstWhere('code', $code); + $labels = collect($property['values'] ?? [])->pluck('label')->filter(); + + if ($code === 'event_date') { + $labels = $labels->map(function (string $label): string { + [$day, $month] = array_pad(explode('/', $label), 2, null); + + return $day !== null && $month !== null ? "{$day}/{$month}" : $label; + }); + } + + return $labels->implode(', '); + } + + /** @param array $details */ + private function allPropertyLabels(array $details): string + { + return collect($details['variant_properties'] ?? []) + ->flatMap(fn (array $property): array => $property['values'] ?? []) + ->pluck('label') + ->filter() + ->implode(', '); + } + + private function sourcePurchaseItem(Ticket $ticket): ?PurchaseItem + { + return $ticket->sourcePurchase?->items->first(function (PurchaseItem $item) use ($ticket): bool { + if ($ticket->source_variant_id !== null) { + return $item->source_variant_id === $ticket->source_variant_id; + } + + return $item->source_catalog_item_id === $ticket->source_catalog_item_id + && $item->source_variant_id === null; + }); + } + + /** @return list}> */ + private function variantProperties(Ticket $ticket): array + { + $variant = $ticket->sourceVariant; + if ($variant === null) { + return []; + } + + $itemAttributes = $variant->definitions + ->map(fn ($definition) => $definition->itemAttribute) + ->filter() + ->merge($variant->catalogItem?->itemAttributes ?? collect()) + ->unique('id') + ->values(); + + return $variant->selectionOptions($itemAttributes) + ->map(function (array $selection, string $attributeCode) use ($itemAttributes): array { + $itemAttribute = $itemAttributes->first( + fn (ItemAttribute $itemAttribute): bool => $itemAttribute->attribute?->codigo + === $attributeCode, + ); + $values = array_is_list($selection) ? $selection : [$selection]; + + return [ + 'code' => $attributeCode, + 'label' => $itemAttribute?->attribute?->nombre + ?? ($attributeCode === 'event_date' ? 'Fecha' : $attributeCode), + 'values' => array_values($values), + ]; + }) + ->values() + ->all(); + } +} diff --git a/tests/Feature/Forms/AdminAppTicketFilterFormControllerTest.php b/tests/Feature/Forms/AdminAppTicketFilterFormControllerTest.php index 296187b..80c5b92 100644 --- a/tests/Feature/Forms/AdminAppTicketFilterFormControllerTest.php +++ b/tests/Feature/Forms/AdminAppTicketFilterFormControllerTest.php @@ -226,7 +226,10 @@ class AdminAppTicketFilterFormControllerTest extends TestCase ])) ->assertOk() ->assertJsonCount(1, 'data') - ->assertJsonPath('data.0.id', $ticket->id); + ->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'); } private function createFiestaFutbolInfantilTenant(): Tenant diff --git a/tests/Feature/Ticket/AdminAppTicketControllerTest.php b/tests/Feature/Ticket/AdminAppTicketControllerTest.php index 6d2e849..5ecbce3 100644 --- a/tests/Feature/Ticket/AdminAppTicketControllerTest.php +++ b/tests/Feature/Ticket/AdminAppTicketControllerTest.php @@ -70,6 +70,8 @@ class AdminAppTicketControllerTest extends TestCase ->assertJsonCount(1, 'data') ->assertJsonPath('data.0.id', $ticket->id) ->assertJsonPath('data.0.tenant_code', $tenant->codigo) + ->assertJsonPath('data.0.values.ticket', $ticket->ticket) + ->assertJsonPath('data.0.values.status', Ticket::STATUS_ACTIVE) ->assertJsonPath('meta.total', 1); } From 636e39e98e67f1fcc50ded9b850906f1948ffd1f Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 31 Aug 2026 12:31:32 -0300 Subject: [PATCH 21/23] refactor(tickets): reuse dynamic columns in exports --- .../Services/AdminAppTicketExcelService.php | 88 ++++++++++--------- .../Services/AdminAppTicketPdfService.php | 10 ++- .../views/pdf/adminapp/tickets.blade.php | 44 +++------- .../AdminAppTicketExportServiceTest.php | 35 +++++--- 4 files changed, 90 insertions(+), 87 deletions(-) diff --git a/app/Domains/Ticket/Services/AdminAppTicketExcelService.php b/app/Domains/Ticket/Services/AdminAppTicketExcelService.php index 50a6517..c34496e 100644 --- a/app/Domains/Ticket/Services/AdminAppTicketExcelService.php +++ b/app/Domains/Ticket/Services/AdminAppTicketExcelService.php @@ -6,6 +6,7 @@ use App\Domains\Tenant\Models\Tenant; use App\Domains\Ticket\Models\Ticket; use Carbon\CarbonInterface; use Illuminate\Support\Collection; +use PhpOffice\PhpSpreadsheet\Cell\Coordinate; use PhpOffice\PhpSpreadsheet\Cell\DataType; use PhpOffice\PhpSpreadsheet\Shared\Date; use PhpOffice\PhpSpreadsheet\Spreadsheet; @@ -16,13 +17,17 @@ use Symfony\Component\HttpFoundation\StreamedResponse; class AdminAppTicketExcelService { - public function __construct(private readonly AdminAppTicketReportService $reportService) {} + public function __construct( + private readonly AdminAppTicketReportService $reportService, + private readonly AdminAppTicketColumnService $columnService, + ) {} /** @param Collection $tickets */ public function download(Tenant $tenant, Collection $tickets, string $timeZone): StreamedResponse { $generatedAt = now(); $rows = $this->reportService->rows($tickets); + $columns = $this->columnService->columns($tenant); $spreadsheet = new Spreadsheet; $spreadsheet->getProperties() ->setCreator('Shopit') @@ -30,48 +35,52 @@ class AdminAppTicketExcelService ->setSubject($tenant->nombre); $sheet = $spreadsheet->getActiveSheet(); $sheet->setTitle('Tickets'); - $sheet->fromArray([ - 'N° de orden', - 'Categoría', - 'Producto', - 'Tipo', - 'Importe', - 'Cliente', - 'ID', - 'Fecha', - 'Estado', - 'Escaneado por', - ], null, 'A1'); + $sheet->fromArray([array_column($columns, 'label')], null, 'A1'); foreach ($rows as $index => $ticket) { $row = $index + 2; - $sheet->setCellValueExplicit( - "A{$row}", - $ticket['order_number'] === null ? '-' : '#'.$ticket['order_number'], - DataType::TYPE_STRING, - ); - $sheet->setCellValueExplicit("B{$row}", $ticket['category'], DataType::TYPE_STRING); - $sheet->setCellValueExplicit("C{$row}", $ticket['product'], DataType::TYPE_STRING); - $sheet->setCellValueExplicit("D{$row}", $ticket['type'], DataType::TYPE_STRING); - if ($ticket['amount'] !== null) { - $sheet->setCellValue("E{$row}", $ticket['amount']); - } - $sheet->setCellValueExplicit("F{$row}", $ticket['client'], DataType::TYPE_STRING); - $sheet->setCellValueExplicit("G{$row}", $ticket['ticket'], DataType::TYPE_STRING); - if ($ticket['date'] instanceof CarbonInterface) { - $sheet->setCellValue( - "H{$row}", - Date::dateTimeToExcel($ticket['date']->copy()->timezone($timeZone)), + foreach ($columns as $columnIndex => $column) { + $coordinate = Coordinate::stringFromColumnIndex($columnIndex + 1).$row; + $value = $ticket[$column['key']] ?? null; + + if ($column['type'] === 'currency' && $value !== null) { + $sheet->setCellValue($coordinate, (float) $value); + + continue; + } + + if ($column['type'] === 'date' && $value instanceof CarbonInterface) { + $sheet->setCellValue( + $coordinate, + Date::dateTimeToExcel($value->copy()->timezone($timeZone)), + ); + + continue; + } + + $sheet->setCellValueExplicit( + $coordinate, + $this->reportService->displayValue($value, $column['type'], $timeZone), + DataType::TYPE_STRING, ); } - $sheet->setCellValueExplicit("I{$row}", $ticket['status'], DataType::TYPE_STRING); - $sheet->setCellValueExplicit("J{$row}", $ticket['scanned_by'], DataType::TYPE_STRING); } $lastRow = max(2, $rows->count() + 1); - $sheet->getStyle("E2:E{$lastRow}")->getNumberFormat()->setFormatCode('$ #,##0.00'); - $sheet->getStyle("H2:H{$lastRow}")->getNumberFormat()->setFormatCode('dd/mm/yyyy hh:mm'); - $sheet->getStyle('A1:J1')->applyFromArray([ + $lastColumn = Coordinate::stringFromColumnIndex(count($columns)); + foreach ($columns as $columnIndex => $column) { + $letter = Coordinate::stringFromColumnIndex($columnIndex + 1); + if ($column['type'] === 'currency') { + $sheet->getStyle("{$letter}2:{$letter}{$lastRow}") + ->getNumberFormat()->setFormatCode('$ #,##0.00'); + } + if ($column['type'] === 'date') { + $sheet->getStyle("{$letter}2:{$letter}{$lastRow}") + ->getNumberFormat()->setFormatCode('dd/mm/yyyy hh:mm'); + } + $sheet->getColumnDimension($letter)->setWidth($column['excel_width']); + } + $sheet->getStyle("A1:{$lastColumn}1")->applyFromArray([ 'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']], 'fill' => [ 'fillType' => Fill::FILL_SOLID, @@ -81,14 +90,7 @@ class AdminAppTicketExcelService ]); $sheet->getRowDimension(1)->setRowHeight(24); $sheet->freezePane('A2'); - $sheet->setAutoFilter("A1:J{$lastRow}"); - - foreach ([ - 'A' => 14, 'B' => 18, 'C' => 22, 'D' => 18, 'E' => 15, - 'F' => 30, 'G' => 39, 'H' => 20, 'I' => 13, 'J' => 28, - ] as $column => $width) { - $sheet->getColumnDimension($column)->setWidth($width); - } + $sheet->setAutoFilter("A1:{$lastColumn}{$lastRow}"); $filename = 'tickets_'.$tenant->codigo.'_' .$generatedAt->copy()->timezone($timeZone)->format('Ymd_His').'.xlsx'; diff --git a/app/Domains/Ticket/Services/AdminAppTicketPdfService.php b/app/Domains/Ticket/Services/AdminAppTicketPdfService.php index d6a92ef..83e85ac 100644 --- a/app/Domains/Ticket/Services/AdminAppTicketPdfService.php +++ b/app/Domains/Ticket/Services/AdminAppTicketPdfService.php @@ -11,15 +11,21 @@ use Illuminate\Support\Collection; class AdminAppTicketPdfService { - public function __construct(private readonly AdminAppTicketReportService $reportService) {} + public function __construct( + private readonly AdminAppTicketReportService $reportService, + private readonly AdminAppTicketColumnService $columnService, + ) {} /** @param Collection $tickets */ public function download(Tenant $tenant, Collection $tickets, string $timeZone): Response { $generatedAt = now(); + $columns = $this->columnService->columns($tenant); + $rows = $this->reportService->rows($tickets); $pdf = Pdf::loadView('pdf.adminapp.tickets', [ 'tenant' => $tenant, - 'tickets' => $this->reportService->rows($tickets), + 'columns' => $columns, + 'tickets' => $this->reportService->displayRows($rows, $columns, $timeZone), 'generatedAt' => $generatedAt, 'timeZone' => $timeZone, ])->setPaper('a3', 'landscape'); diff --git a/resources/views/pdf/adminapp/tickets.blade.php b/resources/views/pdf/adminapp/tickets.blade.php index 0ce5bdd..2baa4af 100644 --- a/resources/views/pdf/adminapp/tickets.blade.php +++ b/resources/views/pdf/adminapp/tickets.blade.php @@ -18,16 +18,6 @@ .number { text-align: right; } .ticket-id { font-size: 6.8px; word-break: break-all; } .empty { color: #66736b; padding: 24px; text-align: center; } - .order { width: 7%; } - .category { width: 9%; } - .product { width: 10%; } - .type { width: 9%; } - .amount { width: 8%; } - .client { width: 14%; } - .identifier { width: 17%; } - .date { width: 10%; } - .status { width: 7%; } - .scanner { width: 9%; } @@ -43,34 +33,26 @@ - - - - - - - - - - + @foreach ($columns as $column) + + @endforeach @forelse ($tickets as $ticket) - - - - - - - - - - + @foreach ($columns as $column) + + @endforeach @empty - + @endforelse
N° de ordenCategoríaProductoTipoImporteClienteIDFechaEstadoEscaneado por $column['type'] === 'currency']) + >{{ $column['label'] }}
{{ $ticket['order_number'] === null ? '-' : '#'.$ticket['order_number'] }}{{ $ticket['category'] }}{{ $ticket['product'] }}{{ $ticket['type'] }}{{ $ticket['amount'] === null ? '-' : '$'.number_format($ticket['amount'], 2, ',', '.') }}{{ $ticket['client'] }}{{ $ticket['ticket'] }}{{ $ticket['date']?->copy()->timezone($timeZone)->format('d/m/Y H:i') ?? '-' }}{{ $ticket['status'] }}{{ $ticket['scanned_by'] }} $column['type'] === 'currency', + 'ticket-id' => $column['key'] === 'ticket', + ])>{{ $ticket[$column['key']] }}
No hay tickets para los criterios seleccionados.
No hay tickets para los criterios seleccionados.
diff --git a/tests/Unit/Ticket/AdminAppTicketExportServiceTest.php b/tests/Unit/Ticket/AdminAppTicketExportServiceTest.php index c238222..33e674a 100644 --- a/tests/Unit/Ticket/AdminAppTicketExportServiceTest.php +++ b/tests/Unit/Ticket/AdminAppTicketExportServiceTest.php @@ -3,9 +3,11 @@ namespace Tests\Unit\Ticket; use App\Domains\Tenant\Models\Tenant; +use App\Domains\Ticket\Services\AdminAppTicketColumnService; use App\Domains\Ticket\Services\AdminAppTicketExcelService; use App\Domains\Ticket\Services\AdminAppTicketPdfService; use App\Domains\Ticket\Services\AdminAppTicketReportService; +use App\Domains\Ticket\Services\AdminAppTicketRowService; use Barryvdh\DomPDF\ServiceProvider; use Illuminate\Support\Carbon; use Mockery; @@ -32,7 +34,7 @@ class AdminAppTicketExportServiceTest extends TestCase public function test_it_downloads_the_ticket_report_as_an_excel_file(): void { - $response = (new AdminAppTicketExcelService($this->reportService())) + $response = (new AdminAppTicketExcelService($this->reportService(), $this->columnService())) ->download($this->tenant(), collect(), 'America/La_Paz'); $this->assertSame( @@ -40,7 +42,7 @@ class AdminAppTicketExportServiceTest extends TestCase $response->headers->get('content-type'), ); $this->assertStringContainsString( - 'attachment; filename=tickets_acme_20260824_135300.xlsx', + 'attachment; filename=tickets_fiesta_futbol_infantil_20260824_135300.xlsx', (string) $response->headers->get('content-disposition'), ); @@ -62,12 +64,12 @@ class AdminAppTicketExportServiceTest extends TestCase public function test_it_downloads_the_ticket_report_as_a_pdf(): void { - $response = (new AdminAppTicketPdfService($this->reportService())) + $response = (new AdminAppTicketPdfService($this->reportService(), $this->columnService())) ->download($this->tenant(), collect(), 'America/La_Paz'); $this->assertSame('application/pdf', $response->headers->get('content-type')); $this->assertStringContainsString( - 'attachment; filename=tickets_acme_20260824_135300.pdf', + 'attachment; filename=tickets_fiesta_futbol_infantil_20260824_135300.pdf', (string) $response->headers->get('content-disposition'), ); $this->assertStringStartsWith('%PDF', $response->getContent()); @@ -75,9 +77,15 @@ class AdminAppTicketExportServiceTest extends TestCase public function test_the_pdf_view_contains_the_report_data_and_requested_timezone(): void { + $columns = $this->columnService()->columns($this->tenant()); $html = view('pdf.adminapp.tickets', [ 'tenant' => $this->tenant(), - 'tickets' => collect([$this->row()]), + 'columns' => $columns, + 'tickets' => (new AdminAppTicketRowService)->displayRows( + collect([$this->row()]), + $columns, + 'America/La_Paz', + ), 'generatedAt' => now(), 'timeZone' => 'America/La_Paz', ])->render(); @@ -90,10 +98,10 @@ class AdminAppTicketExportServiceTest extends TestCase private function reportService(): AdminAppTicketReportService { - $service = Mockery::mock(AdminAppTicketReportService::class); - $service->shouldReceive('rows')->once()->andReturn(collect([$this->row()])); + $rowService = Mockery::mock(AdminAppTicketRowService::class)->makePartial(); + $rowService->shouldReceive('rows')->once()->andReturn(collect([$this->row()])); - return $service; + return new AdminAppTicketReportService($rowService); } /** @return array */ @@ -108,7 +116,7 @@ class AdminAppTicketExportServiceTest extends TestCase 'client' => 'Cliente Test', 'ticket' => '00000000-0000-0000-0000-000000000001', 'date' => now(), - 'status' => 'Usado', + 'status' => 'used', 'scanned_by' => 'Admin Test', ]; } @@ -116,11 +124,16 @@ class AdminAppTicketExportServiceTest extends TestCase private function tenant(): Tenant { return (new Tenant)->forceFill([ - 'codigo' => 'acme', - 'nombre' => 'Acme Eventos', + 'codigo' => 'fiesta_futbol_infantil', + 'nombre' => 'Fiesta del Fútbol Infantil', ]); } + private function columnService(): AdminAppTicketColumnService + { + return new AdminAppTicketColumnService; + } + private function spreadsheetPath(StreamedResponse $response): string { ob_start(); From d179a0f74ec1843ec08c108ceeaf1438b1cbe178 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 31 Aug 2026 13:45:57 -0300 Subject: [PATCH 22/23] feat(tickets): add tenant-aware server-side sorting --- .../Requests/AdminAppTicketIndexRequest.php | 8 + .../Services/AdminAppTicketColumnService.php | 18 +- .../Ticket/Services/AdminAppTicketService.php | 189 +++++++++++++++++- ...AdminAppTicketFilterFormControllerTest.php | 17 +- .../Ticket/AdminAppTicketControllerTest.php | 41 ++++ 5 files changed, 251 insertions(+), 22 deletions(-) diff --git a/app/Domains/Ticket/Requests/AdminAppTicketIndexRequest.php b/app/Domains/Ticket/Requests/AdminAppTicketIndexRequest.php index e4e6d08..fe58b87 100644 --- a/app/Domains/Ticket/Requests/AdminAppTicketIndexRequest.php +++ b/app/Domains/Ticket/Requests/AdminAppTicketIndexRequest.php @@ -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> */ 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'])], ]; } } diff --git a/app/Domains/Ticket/Services/AdminAppTicketColumnService.php b/app/Domains/Ticket/Services/AdminAppTicketColumnService.php index a935e90..4b86aea 100644 --- a/app/Domains/Ticket/Services/AdminAppTicketColumnService.php +++ b/app/Domains/Ticket/Services/AdminAppTicketColumnService.php @@ -8,7 +8,7 @@ class AdminAppTicketColumnService { private const FIESTA_FUTBOL_INFANTIL = 'fiesta_futbol_infantil'; - /** @return list */ + /** @return list */ public function columns(Tenant $tenant): array { $keys = $tenant->codigo === self::FIESTA_FUTBOL_INFANTIL @@ -38,7 +38,7 @@ class AdminAppTicketColumnService return $columns; } - /** @return list */ + /** @return list */ public function publicColumns(Tenant $tenant): array { return array_map(function (array $column): array { @@ -48,7 +48,16 @@ class AdminAppTicketColumnService }, $this->columns($tenant)); } - /** @return array */ + /** @return list */ + 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 */ 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, ]; diff --git a/app/Domains/Ticket/Services/AdminAppTicketService.php b/app/Domains/Ticket/Services/AdminAppTicketService.php index 5f91dc8..87ee13f 100644 --- a/app/Domains/Ticket/Services/AdminAppTicketService.php +++ b/app/Domains/Ticket/Services/AdminAppTicketService.php @@ -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 */ 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 $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 */ + 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 $tickets + * @param array{sort_by?: string|null, sort_direction?: string|null} $filters + * @return Collection + */ + 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 $tickets + * @param array{page?: int, per_page?: int} $filters + * @return LengthAwarePaginator + */ + 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(); + } } diff --git a/tests/Feature/Forms/AdminAppTicketFilterFormControllerTest.php b/tests/Feature/Forms/AdminAppTicketFilterFormControllerTest.php index 80c5b92..a1f2873 100644 --- a/tests/Feature/Forms/AdminAppTicketFilterFormControllerTest.php +++ b/tests/Feature/Forms/AdminAppTicketFilterFormControllerTest.php @@ -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%'], ]; } diff --git a/tests/Feature/Ticket/AdminAppTicketControllerTest.php b/tests/Feature/Ticket/AdminAppTicketControllerTest.php index 5ecbce3..9f76b30 100644 --- a/tests/Feature/Ticket/AdminAppTicketControllerTest.php +++ b/tests/Feature/Ticket/AdminAppTicketControllerTest.php @@ -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'); From 24983439b1f1918c94cabed6e43c3511532207c6 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 31 Aug 2026 13:46:24 -0300 Subject: [PATCH 23/23] fix(tickets): disable inconsistent FFI product and type sorting --- .../Ticket/Services/AdminAppTicketColumnService.php | 10 +++++++++- .../AdminAppTicketFilterFormControllerTest.php | 4 +++- .../Feature/Ticket/AdminAppTicketControllerTest.php | 13 +++++++++++++ 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/app/Domains/Ticket/Services/AdminAppTicketColumnService.php b/app/Domains/Ticket/Services/AdminAppTicketColumnService.php index 4b86aea..be99085 100644 --- a/app/Domains/Ticket/Services/AdminAppTicketColumnService.php +++ b/app/Domains/Ticket/Services/AdminAppTicketColumnService.php @@ -17,7 +17,15 @@ class AdminAppTicketColumnService $columns = array_map(fn (string $key): array => $this->definitions()[$key], $keys); - if ($tenant->codigo !== self::FIESTA_FUTBOL_INFANTIL) { + if ($tenant->codigo === self::FIESTA_FUTBOL_INFANTIL) { + $columns = array_map(function (array $column): array { + if (in_array($column['key'], ['product', 'type'], true)) { + $column['sortable'] = false; + } + + return $column; + }, $columns); + } else { $widths = [ 'order_number' => '11%', 'product' => '15%', diff --git a/tests/Feature/Forms/AdminAppTicketFilterFormControllerTest.php b/tests/Feature/Forms/AdminAppTicketFilterFormControllerTest.php index a1f2873..1333edc 100644 --- a/tests/Feature/Forms/AdminAppTicketFilterFormControllerTest.php +++ b/tests/Feature/Forms/AdminAppTicketFilterFormControllerTest.php @@ -110,7 +110,9 @@ class AdminAppTicketFilterFormControllerTest extends TestCase ->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'); + ->assertJsonPath('data.columns.2.sortable', false) + ->assertJsonPath('data.columns.3.key', 'type') + ->assertJsonPath('data.columns.3.sortable', false); $categories = collect($response->json('data.fields.0.options')); $this->assertSame( diff --git a/tests/Feature/Ticket/AdminAppTicketControllerTest.php b/tests/Feature/Ticket/AdminAppTicketControllerTest.php index 9f76b30..5b40e48 100644 --- a/tests/Feature/Ticket/AdminAppTicketControllerTest.php +++ b/tests/Feature/Ticket/AdminAppTicketControllerTest.php @@ -137,6 +137,19 @@ class AdminAppTicketControllerTest extends TestCase ->assertJsonValidationErrors(['sort_by', 'sort_direction']); } + public function test_it_rejects_product_and_type_sorting_for_fiesta_futbol_infantil(): void + { + $tenant = $this->createTenant('fiesta_futbol_infantil'); + $this->grantTicketsMenu($tenant); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + foreach (['product', 'type'] as $column) { + $this->getJson('/api/v1/adminapp/tenant/tickets?sort_by='.$column) + ->assertUnprocessable() + ->assertJsonValidationErrors('sort_by'); + } + } + public function test_it_includes_tenant_scanned_and_total_ticket_counts(): void { $tenant = $this->createTenant('fiesta_futbol_infantil');