feat: xlsx export of booking edit draft data for admins
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user