diff --git a/app/Domains/Ticketing/Desfile/Controllers/EntryReservationController.php b/app/Domains/Ticketing/Desfile/Controllers/EntryReservationController.php index 383a75b..591c45a 100644 --- a/app/Domains/Ticketing/Desfile/Controllers/EntryReservationController.php +++ b/app/Domains/Ticketing/Desfile/Controllers/EntryReservationController.php @@ -2,12 +2,17 @@ namespace App\Domains\Ticketing\Desfile\Controllers; +use App\Domains\Ticketing\Desfile\Requests\ExportEntryReservationsRequest; use App\Domains\Ticketing\Desfile\Requests\IndexEntryReservationsRequest; use App\Domains\Ticketing\Desfile\Requests\StoreEntryReservationsRequest; use App\Domains\Ticketing\Desfile\Resources\EntryReservationResource; +use App\Domains\Ticketing\Desfile\Services\EntryReservationExcelService; +use App\Domains\Ticketing\Desfile\Services\EntryReservationPdfService; use App\Domains\Ticketing\Desfile\Services\EntryReservationService; use App\Http\Controllers\Controller; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; +use Illuminate\Http\Response; +use Symfony\Component\HttpFoundation\StreamedResponse; class EntryReservationController extends Controller { @@ -18,6 +23,34 @@ class EntryReservationController extends Controller ); } + public function downloadPdf( + ExportEntryReservationsRequest $request, + EntryReservationService $service, + EntryReservationPdfService $pdf, + ): Response { + $user = $request->user(); + + return $pdf->download( + $user->tenant()->firstOrFail(), + $service->reservationsForExport($user, $request->validated()), + $request->validated('timezone'), + ); + } + + public function downloadExcel( + ExportEntryReservationsRequest $request, + EntryReservationService $service, + EntryReservationExcelService $excel, + ): StreamedResponse { + $user = $request->user(); + + return $excel->download( + $user->tenant()->firstOrFail(), + $service->reservationsForExport($user, $request->validated()), + $request->validated('timezone'), + ); + } + public function store(StoreEntryReservationsRequest $request, EntryReservationService $service): AnonymousResourceCollection { return EntryReservationResource::collection($service->reserve( diff --git a/app/Domains/Ticketing/Desfile/Requests/ExportEntryReservationsRequest.php b/app/Domains/Ticketing/Desfile/Requests/ExportEntryReservationsRequest.php new file mode 100644 index 0000000..45aa3cc --- /dev/null +++ b/app/Domains/Ticketing/Desfile/Requests/ExportEntryReservationsRequest.php @@ -0,0 +1,16 @@ + ['required', 'string', new ValidTimezone], + ]; + } +} diff --git a/app/Domains/Ticketing/Desfile/Services/EntryReservationExcelService.php b/app/Domains/Ticketing/Desfile/Services/EntryReservationExcelService.php new file mode 100644 index 0000000..fb213cb --- /dev/null +++ b/app/Domains/Ticketing/Desfile/Services/EntryReservationExcelService.php @@ -0,0 +1,91 @@ + $reservations */ + public function download(Tenant $tenant, Collection $reservations, string $timeZone): StreamedResponse + { + $generatedAt = now(); + $rows = $this->report->rows($reservations); + $spreadsheet = new Spreadsheet; + $spreadsheet->getProperties() + ->setCreator('Shopit') + ->setTitle('Reservas de entradas') + ->setSubject($tenant->nombre); + $sheet = $spreadsheet->getActiveSheet(); + $sheet->setTitle('Reservas'); + $sheet->fromArray([ + 'Tipo', + 'Sector', + 'Fila', + 'Asiento', + 'ID', + 'Fecha', + 'Importe', + 'Pago', + ], null, 'A1'); + + foreach ($rows as $index => $reservation) { + $row = $index + 2; + foreach (['A' => 'tipo', 'B' => 'sector', 'C' => 'fila', 'D' => 'asiento'] as $column => $key) { + $sheet->setCellValueExplicit("{$column}{$row}", $reservation[$key], DataType::TYPE_STRING); + } + $sheet->setCellValueExplicit( + "E{$row}", + $reservation['ticket_id'] === null ? '-' : (string) $reservation['ticket_id'], + DataType::TYPE_STRING, + ); + $sheet->setCellValue( + "F{$row}", + Date::dateTimeToExcel($reservation['fecha_reserva']->copy()->timezone($timeZone)), + ); + if ($reservation['importe'] !== null) { + $sheet->setCellValue("G{$row}", (float) $reservation['importe']); + } + $sheet->setCellValueExplicit("H{$row}", $reservation['pago'], DataType::TYPE_STRING); + } + + $lastRow = max(2, $rows->count() + 1); + $sheet->getStyle("F2:F{$lastRow}")->getNumberFormat()->setFormatCode('dd/mm/yyyy hh:mm'); + $sheet->getStyle("G2:G{$lastRow}")->getNumberFormat()->setFormatCode('$ #,##0.00'); + $sheet->getStyle('A1:H1')->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:H{$lastRow}"); + foreach (['A' => 20, 'B' => 22, 'C' => 12, 'D' => 12, 'E' => 16, 'F' => 20, 'G' => 16, 'H' => 18] as $column => $width) { + $sheet->getColumnDimension($column)->setWidth($width); + } + + $filename = 'reservas_entradas_'.$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/Ticketing/Desfile/Services/EntryReservationPdfService.php b/app/Domains/Ticketing/Desfile/Services/EntryReservationPdfService.php new file mode 100644 index 0000000..4df1da4 --- /dev/null +++ b/app/Domains/Ticketing/Desfile/Services/EntryReservationPdfService.php @@ -0,0 +1,51 @@ + $reservations */ + public function download(Tenant $tenant, Collection $reservations, string $timeZone): Response + { + $generatedAt = now(); + $rows = $this->report->rows($reservations); + $pdf = Pdf::loadView('pdf.adminapp.desfile-entry-reservations', [ + 'tenant' => $tenant, + 'reservations' => $rows, + 'generatedAt' => $generatedAt, + 'timeZone' => $timeZone, + ])->setPaper('a4', 'landscape'); + + $this->addPageNumbers($pdf); + + return $pdf->download( + 'reservas_entradas_'.$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( + 385, + 575, + 'Página {PAGE_NUM} de {PAGE_COUNT}', + $font, + 7, + [0.48, 0.52, 0.49], + ); + } +} diff --git a/app/Domains/Ticketing/Desfile/Services/EntryReservationReportService.php b/app/Domains/Ticketing/Desfile/Services/EntryReservationReportService.php new file mode 100644 index 0000000..e401c5a --- /dev/null +++ b/app/Domains/Ticketing/Desfile/Services/EntryReservationReportService.php @@ -0,0 +1,44 @@ + $reservations + * @return Collection> + */ + public function rows(Collection $reservations): Collection + { + return $reservations->values()->map(function (EntryReservation $reservation): array { + $selection = $reservation->variant->selectionOptions(); + + return [ + 'tipo' => $this->selectionLabel($selection->get('tipo')), + 'sector' => $this->selectionLabel($selection->get('sector')), + 'fila' => $this->selectionLabel($selection->get('fila')), + 'asiento' => $this->selectionLabel($selection->get('asiento')), + 'ticket_id' => $reservation->ticket_id, + 'fecha_reserva' => $reservation->fecha_reserva, + 'importe' => $reservation->importe, + 'pago' => $reservation->tipo_pago->label(), + ]; + }); + } + + private function selectionLabel(mixed $selection): string + { + if (! is_array($selection)) { + return '-'; + } + + if (array_is_list($selection)) { + return collect($selection)->pluck('label')->filter()->implode(', ') ?: '-'; + } + + return isset($selection['label']) ? (string) $selection['label'] : '-'; + } +} diff --git a/app/Domains/Ticketing/Desfile/Services/EntryReservationService.php b/app/Domains/Ticketing/Desfile/Services/EntryReservationService.php index c21a763..9d86b4d 100644 --- a/app/Domains/Ticketing/Desfile/Services/EntryReservationService.php +++ b/app/Domains/Ticketing/Desfile/Services/EntryReservationService.php @@ -29,6 +29,29 @@ class EntryReservationService { abort_unless($user->tenant_codigo === 'desfile_pura_tendencia', 403); + return $this->reservationsQuery($user, $filters) + ->paginate( + perPage: $filters['per_page'] ?? 15, + pageName: 'page', + page: $filters['page'] ?? 1, + ) + ->withQueryString(); + } + + /** + * @param array{tipo_pago?: string|null} $filters + * @return Collection + */ + public function reservationsForExport(User $user, array $filters = []): Collection + { + abort_unless($user->tenant_codigo === 'desfile_pura_tendencia', 403); + + return $this->reservationsQuery($user, $filters)->get(); + } + + /** @param array{tipo_pago?: string|null} $filters */ + private function reservationsQuery(User $user, array $filters = []): Builder + { return EntryReservation::query() ->whereHas('variant.catalogItem', fn (Builder $query): Builder => $query ->where('tenant_code', $user->tenant_codigo) @@ -44,13 +67,7 @@ class EntryReservationService 'variant.eventDate', ]) ->orderByDesc('fecha_reserva') - ->orderByDesc('id') - ->paginate( - perPage: $filters['per_page'] ?? 15, - pageName: 'page', - page: $filters['page'] ?? 1, - ) - ->withQueryString(); + ->orderByDesc('id'); } /** @param list $rows */ diff --git a/app/Domains/Ticketing/Desfile/routes/api.php b/app/Domains/Ticketing/Desfile/routes/api.php index 40c4305..224e686 100644 --- a/app/Domains/Ticketing/Desfile/routes/api.php +++ b/app/Domains/Ticketing/Desfile/routes/api.php @@ -7,6 +7,12 @@ use Illuminate\Support\Facades\Route; Route::get('v1/adminapp/tenant/desfile/entry-reservations', [EntryReservationController::class, 'index']) ->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:adminapp.desfile.reservas']) ->name('adminapp.desfile.entry-reservations.index'); +Route::get('v1/adminapp/tenant/desfile/entry-reservations/pdf', [EntryReservationController::class, 'downloadPdf']) + ->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:adminapp.desfile.reservas']) + ->name('adminapp.desfile.entry-reservations.pdf'); +Route::get('v1/adminapp/tenant/desfile/entry-reservations/excel', [EntryReservationController::class, 'downloadExcel']) + ->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:adminapp.desfile.reservas']) + ->name('adminapp.desfile.entry-reservations.excel'); Route::post('v1/adminapp/tenant/desfile/entry-reservations', [EntryReservationController::class, 'store']) ->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:adminapp.desfile.reservas']) ->name('adminapp.desfile.entry-reservations.store'); diff --git a/resources/views/pdf/adminapp/desfile-entry-reservations.blade.php b/resources/views/pdf/adminapp/desfile-entry-reservations.blade.php new file mode 100644 index 0000000..65387e4 --- /dev/null +++ b/resources/views/pdf/adminapp/desfile-entry-reservations.blade.php @@ -0,0 +1,61 @@ + + + + + + + +

Reservas de entradas

+

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

+ +
+ Reservas incluidas: {{ $reservations->count() }} +
+ + + + + + + + + + + + + + + + @forelse ($reservations as $reservation) + + + + + + + + + + + @empty + + @endforelse + +
TipoSectorFilaAsientoIDFechaImportePago
{{ $reservation['tipo'] }}{{ $reservation['sector'] }}{{ $reservation['fila'] }}{{ $reservation['asiento'] }}{{ $reservation['ticket_id'] ?? '-' }}{{ $reservation['fecha_reserva']->copy()->timezone($timeZone)->format('d/m/Y H:i') }}{{ $reservation['importe'] === null ? '-' : '$'.number_format((float) $reservation['importe'], 2, ',', '.') }}{{ $reservation['pago'] }}
No hay reservas para los criterios seleccionados.
+ + diff --git a/tests/Feature/Desfile/EntryReservationServiceTest.php b/tests/Feature/Desfile/EntryReservationServiceTest.php index a849bf8..3b4b442 100644 --- a/tests/Feature/Desfile/EntryReservationServiceTest.php +++ b/tests/Feature/Desfile/EntryReservationServiceTest.php @@ -4,14 +4,20 @@ namespace Tests\Feature\Desfile; use App\Domains\Commerce\Catalog\Models\Inventory; use App\Domains\Core\Auth\Models\User; +use App\Domains\Core\Tenant\Models\Tenant; +use App\Domains\Ticketing\Desfile\Requests\ExportEntryReservationsRequest; use App\Domains\Ticketing\Desfile\Requests\IndexEntryReservationsRequest; use App\Domains\Ticketing\Desfile\Requests\StoreEntryReservationsRequest; use App\Domains\Ticketing\Desfile\Resources\EntryReservationResource; +use App\Domains\Ticketing\Desfile\Services\EntryReservationExcelService; +use App\Domains\Ticketing\Desfile\Services\EntryReservationPdfService; +use App\Domains\Ticketing\Desfile\Services\EntryReservationReportService; use App\Domains\Ticketing\Desfile\Services\EntryReservationService; use App\Domains\Ticketing\Ticket\Models\Ticket; use App\Domains\Ticketing\Ticket\Services\ResolvedTicketValidity; use App\Domains\Ticketing\Ticket\Services\TicketGeneratorService; use App\Domains\Ticketing\Ticket\Services\TicketValidityResolver; +use Barryvdh\DomPDF\ServiceProvider as DomPdfServiceProvider; use Illuminate\Database\Schema\Blueprint; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; @@ -29,6 +35,7 @@ class EntryReservationServiceTest extends TestCase protected function setUp(): void { parent::setUp(); + $this->app->register(DomPdfServiceProvider::class); // Minimal domain schema isolates this workflow from unrelated legacy migrations. Schema::create('users', function (Blueprint $table): void { $table->id(); @@ -324,6 +331,48 @@ class EntryReservationServiceTest extends TestCase ); } + public function test_export_request_requires_a_valid_timezone(): void + { + $validator = Validator::make([ + 'tipo_pago' => 'sin_cargo', + 'timezone' => 'Invalid/Timezone', + ], (new ExportEntryReservationsRequest)->rules()); + + $this->assertTrue($validator->fails()); + $this->assertArrayHasKey('timezone', $validator->errors()->toArray()); + } + + public function test_exports_filtered_reservations_to_pdf_and_excel(): void + { + $service = $this->service(); + $user = User::findOrFail(1); + $service->reserve($user, (string) Str::uuid(), $this->rows()); + $reservations = $service->reservationsForExport($user, ['tipo_pago' => 'otro_metodo']); + $tenant = Tenant::query()->firstOrFail(); + $tenant->setAttribute('nombre', 'Desfile Pura Tendencia'); + $report = new EntryReservationReportService; + + $this->assertCount(1, $reservations); + $this->assertSame('Otro método', $report->rows($reservations)->sole()['pago']); + + $pdf = (new EntryReservationPdfService($report))->download( + $tenant, + $reservations, + 'America/Argentina/Buenos_Aires', + ); + $this->assertSame('application/pdf', $pdf->headers->get('content-type')); + + $excel = (new EntryReservationExcelService($report))->download( + $tenant, + $reservations, + 'America/Argentina/Buenos_Aires', + ); + $this->assertSame( + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + $excel->headers->get('content-type'), + ); + } + public function test_real_ticket_generator_links_admin_variant_and_reservation(): void { $validity = Mockery::mock(TicketValidityResolver::class);