feat(tickets): add PDF and Excel export endpoints

This commit is contained in:
2026-08-31 12:03:25 -03:00
parent 457eba68a9
commit 5250d1a818
9 changed files with 575 additions and 1 deletions

View File

@@ -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'),
);
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Domains\Ticket\Requests;
use App\Domains\Shared\Rules\ValidTimezone;
class AdminAppTicketExportRequest extends AdminAppTicketIndexRequest
{
/** @return array<string, mixed> */
public function rules(): array
{
return [
...parent::rules(),
'timezone' => ['required', 'string', new ValidTimezone],
];
}
}

View File

@@ -0,0 +1,103 @@
<?php
namespace App\Domains\Ticket\Services;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Models\Ticket;
use Carbon\CarbonInterface;
use Illuminate\Support\Collection;
use PhpOffice\PhpSpreadsheet\Cell\DataType;
use PhpOffice\PhpSpreadsheet\Shared\Date;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Symfony\Component\HttpFoundation\StreamedResponse;
class AdminAppTicketExcelService
{
public function __construct(private readonly AdminAppTicketReportService $reportService) {}
/** @param Collection<int, Ticket> $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',
]);
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Domains\Ticket\Services;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Models\Ticket;
use Barryvdh\DomPDF\Facade\Pdf;
use Barryvdh\DomPDF\PDF as DomPdf;
use Illuminate\Http\Response;
use Illuminate\Support\Collection;
class AdminAppTicketPdfService
{
public function __construct(private readonly AdminAppTicketReportService $reportService) {}
/** @param Collection<int, Ticket> $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],
);
}
}

View File

@@ -0,0 +1,112 @@
<?php
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'],
];
/**
* @param Collection<int, Ticket> $tickets
* @return Collection<int, array<string, mixed>>
*/
public function rows(Collection $tickets): Collection
{
return $tickets->values()->map(fn (Ticket $ticket): array => $this->row($ticket));
}
/** @return array<string, mixed> */
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<string, mixed> $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<string, mixed> $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<string, mixed> $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',
};
}
}

View File

@@ -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');
});

View File

@@ -0,0 +1,78 @@
<!doctype html>
<html lang="es">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<style>
@page { margin: 28px 34px 58px; }
body { color: #17211b; font-family: DejaVu Sans, sans-serif; font-size: 8px; margin: 0; }
h1 { font-size: 21px; margin: 0 0 3px; }
.subtitle { color: #66736b; margin: 0 0 15px; }
.summary { background: #eef5f1; border-left: 4px solid #198754; margin-bottom: 14px; padding: 8px 11px; }
.summary strong { font-size: 12px; }
table { border-collapse: collapse; table-layout: fixed; width: 100%; }
thead { display: table-header-group; }
tr { page-break-inside: avoid; }
th { background: #26382e; color: #fff; font-size: 7px; letter-spacing: .25px; padding: 6px 5px; text-align: left; text-transform: uppercase; }
td { border-bottom: 1px solid #dfe7e2; overflow-wrap: break-word; padding: 6px 5px; vertical-align: top; }
tbody tr:nth-child(even) { background: #f7f9f8; }
.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%; }
</style>
</head>
<body>
<h1>Listado de tickets</h1>
<p class="subtitle">{{ $tenant->nombre }} · Generado el {{ $generatedAt->copy()->timezone($timeZone)->format('d/m/Y H:i') }}</p>
<div class="summary">
Tickets incluidos: <strong>{{ $tickets->count() }}</strong>
&nbsp;&nbsp;·&nbsp;&nbsp;
Escaneados: <strong>{{ $tickets->where('status', 'Usado')->count() }}</strong>
</div>
<table>
<thead>
<tr>
<th class="order"> de orden</th>
<th class="category">Categoría</th>
<th class="product">Producto</th>
<th class="type">Tipo</th>
<th class="amount number">Importe</th>
<th class="client">Cliente</th>
<th class="identifier">ID</th>
<th class="date">Fecha</th>
<th class="status">Estado</th>
<th class="scanner">Escaneado por</th>
</tr>
</thead>
<tbody>
@forelse ($tickets as $ticket)
<tr>
<td>{{ $ticket['order_number'] === null ? '-' : '#'.$ticket['order_number'] }}</td>
<td>{{ $ticket['category'] }}</td>
<td>{{ $ticket['product'] }}</td>
<td>{{ $ticket['type'] }}</td>
<td class="number">{{ $ticket['amount'] === null ? '-' : '$'.number_format($ticket['amount'], 2, ',', '.') }}</td>
<td>{{ $ticket['client'] }}</td>
<td class="ticket-id">{{ $ticket['ticket'] }}</td>
<td>{{ $ticket['date']?->copy()->timezone($timeZone)->format('d/m/Y H:i') ?? '-' }}</td>
<td>{{ $ticket['status'] }}</td>
<td>{{ $ticket['scanned_by'] }}</td>
</tr>
@empty
<tr><td class="empty" colspan="10">No hay tickets para los criterios seleccionados.</td></tr>
@endforelse
</tbody>
</table>
</body>
</html>

View File

@@ -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");

View File

@@ -0,0 +1,138 @@
<?php
namespace Tests\Unit\Ticket;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Services\AdminAppTicketExcelService;
use App\Domains\Ticket\Services\AdminAppTicketPdfService;
use App\Domains\Ticket\Services\AdminAppTicketReportService;
use Barryvdh\DomPDF\ServiceProvider;
use Illuminate\Support\Carbon;
use Mockery;
use PhpOffice\PhpSpreadsheet\IOFactory;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Tests\TestCase;
class AdminAppTicketExportServiceTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
$this->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<string, mixed> */
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;
}
}