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; + } +}