feat: xlsx export of booking edit draft data for admins

This commit is contained in:
Björn Fromme
2026-01-13 10:22:03 +01:00
parent 5221cea53b
commit 3d4d88e986
7 changed files with 912 additions and 84 deletions
@@ -1,24 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin;
use App\Admin\Field\JsonDataField;
use App\Entity\BookingEditDraft;
use App\Entity\User;
use App\Service\BookingExportService;
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField;
use EasyCorp\Bundle\EasyAdminBundle\Field\DateTimeField;
use EasyCorp\Bundle\EasyAdminBundle\Field\IdField;
use EasyCorp\Bundle\EasyAdminBundle\Field\IntegerField;
use EasyCorp\Bundle\EasyAdminBundle\Field\TextEditorField;
use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
use Symfony\Bundle\MakerBundle\Doctrine\RelationManyToOne;
use EasyCorp\Bundle\EasyAdminBundle\Router\AdminUrlGenerator;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class BookingEditDraftCrudController extends AbstractCrudController
{
public function __construct(
private readonly BookingExportService $exportService,
private readonly AdminUrlGenerator $adminUrlGenerator,
) {
}
public static function getEntityFqcn(): string
{
return BookingEditDraft::class;
@@ -26,14 +34,51 @@ class BookingEditDraftCrudController extends AbstractCrudController
public function configureActions(Actions $actions): Actions
{
$exportAction = Action::new('exportExcel', 'Excel Export', 'fa fa-file-excel')
->linkToRoute(
'admin_booking_draft_export',
static fn (BookingEditDraft $entity): array => ['id' => $entity->getId()]
)
->displayIf(static fn (BookingEditDraft $entity): bool => $entity->hasExportData());
return $actions
->add(Crud::PAGE_INDEX, Action::DETAIL)
->add(Crud::PAGE_INDEX, $exportAction)
->add(Crud::PAGE_DETAIL, $exportAction)
->remove(Crud::PAGE_INDEX, Action::NEW)
->remove(Crud::PAGE_INDEX, Action::EDIT)
->remove(Crud::PAGE_DETAIL, Action::EDIT)
;
}
#[Route('/admin/booking-draft/{id}/export', name: 'admin_booking_draft_export', requirements: ['id' => '\d+'])]
public function export(BookingEditDraft $draft): Response
{
if (false === $draft->hasExportData()) {
$this->addFlash('danger', 'Export nicht möglich: Reisedaten fehlen');
$url = $this->adminUrlGenerator
->setController(self::class)
->setAction(Action::INDEX)
->generateUrl();
return $this->redirect($url);
}
try {
return $this->exportService->createExportResponse($draft);
} catch (\RuntimeException $e) {
$this->addFlash('danger', 'Export fehlgeschlagen: ' . $e->getMessage());
$url = $this->adminUrlGenerator
->setController(self::class)
->setAction(Action::INDEX)
->generateUrl();
return $this->redirect($url);
}
}
public function configureCrud(Crud $crud): Crud
{
return $crud
+44
View File
@@ -37,6 +37,18 @@ class BookingEditDraft
#[ORM\Column(type: 'date_immutable')]
private \DateTimeImmutable $travelDate;
/**
* BusProNet travel date ID (termin idbuspro) for loading travel data.
*/
#[ORM\Column(type: 'integer', nullable: true)]
private ?int $dateId = null;
/**
* BusProNet hotel ID for loading travel data.
*/
#[ORM\Column(type: 'integer', nullable: true)]
private ?int $hotelId = null;
#[ORM\Column(type: 'json')]
private array $formData = [];
@@ -95,6 +107,38 @@ class BookingEditDraft
return $this;
}
public function getDateId(): ?int
{
return $this->dateId;
}
public function setDateId(?int $dateId): static
{
$this->dateId = $dateId;
return $this;
}
public function getHotelId(): ?int
{
return $this->hotelId;
}
public function setHotelId(?int $hotelId): static
{
$this->hotelId = $hotelId;
return $this;
}
/**
* Checks if the draft has the required travel IDs for export functionality.
*/
public function hasExportData(): bool
{
return null !== $this->dateId && null !== $this->hotelId;
}
public function getFormData(): array
{
return $this->formData;
+12
View File
@@ -63,6 +63,8 @@ class BookingEditDraftService
$existingDraft = $this->findDraft($user, $bookingId);
$bookingNumber = $bookingDto->booking?->bookingNumber;
$dateId = $bookingDto->travel->id;
$hotelId = $bookingDto->travel->hotelId;
if (null !== $existingDraft) {
$existingDraft->setFormData($formData);
@@ -70,9 +72,19 @@ class BookingEditDraftService
if (null === $existingDraft->getBookingNumber() && null !== $bookingNumber) {
$existingDraft->setBookingNumber($bookingNumber);
}
if (null === $existingDraft->getDateId() && null !== $dateId) {
$existingDraft->setDateId($dateId);
}
if (null === $existingDraft->getHotelId() && null !== $hotelId) {
$existingDraft->setHotelId($hotelId);
}
} else {
$draft = new BookingEditDraft($user, $bookingId, $travelDate, $formData);
$draft->setBookingNumber($bookingNumber);
$draft->setDateId($dateId);
$draft->setHotelId($hotelId);
$this->entityManager->persist($draft);
}
+353
View File
@@ -0,0 +1,353 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\BookingEditDraft;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* Generates Excel exports from booking edit drafts.
*
* This service transforms draft booking data into a downloadable Excel file,
* converting all service, room, and pickup IDs to human-readable labels using
* travel data loaded from XML files.
*/
class BookingExportService
{
private const COLUMN_HEADERS = [
'Vorname',
'Nachname',
'Geburtsdatum',
'E-Mail',
'Mobil',
'Geschlecht',
'Nationalität',
'Straße',
'PLZ',
'Ort',
'Land',
'Größe',
'Gewicht',
'Schuhgröße',
'Zimmer',
'Zimmerwünsche',
'Kennzeichen',
'Skipass',
'Kurse',
'Verpflegung',
'Leihmaterial',
'Leihmaterial-Versicherung',
'Zusatzleistungen',
'Hinfahrt',
'Rückfahrt',
'Zustieg',
'Parkplatz',
'Versicherung',
'Sammelversicherung',
'Kaufgutschein',
'Aktionsgutschein',
];
public function __construct(
private readonly TravelDataService $travelDataService,
) {
}
/**
* Creates a StreamedResponse with the Excel export for the given draft.
*
* @param BookingEditDraft $draft The draft to export
*
* @return StreamedResponse The response containing the Excel file
*
* @throws \RuntimeException If travel data cannot be loaded
*/
public function createExportResponse(BookingEditDraft $draft): StreamedResponse
{
if (false === $draft->hasExportData()) {
throw new \RuntimeException('Draft is missing required travel IDs for export');
}
$travel = $this->travelDataService->getTravelData(
$draft->getDateId(),
$draft->getHotelId()
);
if (null === $travel) {
throw new \RuntimeException('Could not load travel data for export');
}
$lookups = $this->buildLookups($travel);
$spreadsheet = $this->createSpreadsheet($draft, $lookups);
$filename = sprintf(
'buchung_%d_%s.xlsx',
$draft->getBookingNumber() ?? $draft->getBookingId(),
$draft->getTravelDate()->format('Y-m-d')
);
$response = new StreamedResponse(function () use ($spreadsheet): void {
$writer = new Xlsx($spreadsheet);
$writer->save('php://output');
});
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
$response->headers->set('Content-Disposition', sprintf('attachment; filename="%s"', $filename));
$response->headers->set('Cache-Control', 'max-age=0');
return $response;
}
/**
* Builds lookup arrays for converting IDs to labels.
*
* @param \App\BusProNet\Model\Travel $travel The travel data
*
* @return array{services: array<int, string>, rooms: array<int, string>, pickups: array<int, string>}
*/
private function buildLookups(\App\BusProNet\Model\Travel $travel): array
{
$services = [];
$rooms = [];
$pickups = [];
foreach ($travel->additionalServices as $service) {
if (null !== $service->id && null !== $service->label) {
$services[$service->id] = $service->label;
}
}
foreach ($travel->transportationServices as $service) {
if (null !== $service->id && null !== $service->label) {
$services[$service->id] = $service->label;
}
}
foreach ($travel->rooms as $room) {
if (null !== $room->id && null !== $room->label) {
$rooms[$room->id] = $room->label;
}
}
foreach ($travel->pickupsOutbound as $pickup) {
if (null !== $pickup->id) {
$pickups[$pickup->id] = $pickup->getLabel();
}
}
foreach ($travel->pickupsInbound as $pickup) {
if (null !== $pickup->id) {
$pickups[$pickup->id] = $pickup->getLabel();
}
}
return [
'services' => $services,
'rooms' => $rooms,
'pickups' => $pickups,
];
}
/**
* Creates the spreadsheet with participant data.
*
* @param BookingEditDraft $draft The draft containing form data
* @param array $lookups The ID to label lookup arrays
*/
private function createSpreadsheet(BookingEditDraft $draft, array $lookups): Spreadsheet
{
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('Teilnehmer');
// Write headers
foreach (self::COLUMN_HEADERS as $col => $header) {
$sheet->setCellValue([$col + 1, 1], $header);
}
// Style header row
$headerStyle = [
'font' => ['bold' => true],
'fill' => [
'fillType' => \PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID,
'startColor' => ['rgb' => 'E0E0E0'],
],
];
$lastColumn = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex(count(self::COLUMN_HEADERS));
$sheet->getStyle('A1:' . $lastColumn . '1')->applyFromArray($headerStyle);
// Write participant data
$formData = $draft->getFormData();
$participants = $formData['participants'] ?? [];
$row = 2;
foreach ($participants as $participant) {
$this->writeParticipantRow($sheet, $row, $participant, $lookups);
++$row;
}
// Auto-size columns
foreach (range(1, count(self::COLUMN_HEADERS)) as $col) {
$colLetter = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($col);
$sheet->getColumnDimension($colLetter)->setAutoSize(true);
}
return $spreadsheet;
}
/**
* Writes a single participant row to the spreadsheet.
*/
private function writeParticipantRow(
\PhpOffice\PhpSpreadsheet\Worksheet\Worksheet $sheet,
int $row,
array $participant,
array $lookups
): void {
$personal = $participant['personalData'] ?? [];
$address = $participant['address'] ?? [];
$body = $participant['bodyDimensions'] ?? [];
$room = $participant['roomAssignment'] ?? [];
$services = $participant['services'] ?? [];
$vouchers = $participant['vouchers'] ?? [];
$data = [
$personal['firstName'] ?? '',
$personal['lastName'] ?? '',
$this->formatDate($personal['dateOfBirth'] ?? null),
$personal['email'] ?? '',
$personal['mobile'] ?? '',
$this->mapGender($personal['gender'] ?? null),
$personal['nationality'] ?? '',
$address['street'] ?? '',
$address['postCode'] ?? '',
$address['city'] ?? '',
$address['country'] ?? '',
$body['height'] ?? '',
$body['weight'] ?? '',
$body['shoeSize'] ?? '',
$this->resolveRoom($room['assignedRoomId'] ?? null, $lookups['rooms']),
$room['remarksRoom'] ?? '',
$participant['licensePlate'] ?? '',
$this->resolveService($services['skiPass'] ?? null, $lookups['services']),
$this->resolveServiceArray($services['courses'] ?? [], $lookups['services']),
$this->resolveServiceArray($services['board'] ?? [], $lookups['services']),
$this->resolveServiceArray($services['rentals'] ?? [], $lookups['services']),
$this->resolveService($services['rentalInsurance'] ?? null, $lookups['services']),
$this->resolveServiceArray($services['additionalServices'] ?? [], $lookups['services']),
$this->resolveService($services['transportationOutbound'] ?? null, $lookups['services']),
$this->resolveService($services['transportationInbound'] ?? null, $lookups['services']),
$this->resolvePickup($services['pickup'] ?? null, $lookups['pickups']),
$this->formatBoolean($services['parking'] ?? false),
$this->resolveService($services['insurance'] ?? null, $lookups['services']),
$this->formatBoolean($services['bulkInsuranceBooking'] ?? false),
$vouchers['purchaseVoucherCode'] ?? '',
$vouchers['promoVoucherCode'] ?? '',
];
foreach ($data as $col => $value) {
$sheet->setCellValue([$col + 1, $row], $value);
}
}
/**
* Formats a date string to German format.
*/
private function formatDate(?string $date): string
{
if (null === $date || '' === $date) {
return '';
}
try {
$dateTime = new \DateTimeImmutable($date);
return $dateTime->format('d.m.Y');
} catch (\Exception) {
return $date;
}
}
/**
* Maps gender code to German label.
*/
private function mapGender(?string $gender): string
{
return match ($gender) {
'M' => 'männlich',
'W' => 'weiblich',
'D' => 'divers',
default => $gender ?? '',
};
}
/**
* Formats a boolean value to German text.
*/
private function formatBoolean(bool $value): string
{
return $value ? 'Ja' : 'Nein';
}
/**
* Resolves a single service ID to its label.
*/
private function resolveService(int|string|null $serviceId, array $lookup): string
{
if (null === $serviceId || '' === $serviceId) {
return '';
}
$id = (int) $serviceId;
return $lookup[$id] ?? (string) $serviceId;
}
/**
* Resolves an array of service IDs to labels, joined by pipe.
*/
private function resolveServiceArray(array $serviceIds, array $lookup): string
{
if (true === empty($serviceIds)) {
return '';
}
$labels = [];
foreach ($serviceIds as $serviceId) {
if (null !== $serviceId && '' !== $serviceId) {
$id = (int) $serviceId;
$labels[] = $lookup[$id] ?? (string) $serviceId;
}
}
return implode(', ', $labels);
}
/**
* Resolves a room ID to its label.
*/
private function resolveRoom(?int $roomId, array $lookup): string
{
if (null === $roomId) {
return '';
}
return $lookup[$roomId] ?? (string) $roomId;
}
/**
* Resolves a pickup ID to its label.
*/
private function resolvePickup(?int $pickupId, array $lookup): string
{
if (null === $pickupId) {
return '';
}
return $lookup[$pickupId] ?? (string) $pickupId;
}
}