wip: continue implementation

This commit is contained in:
Björn Fromme
2024-11-27 11:31:48 +01:00
parent b5d52b0053
commit 84ab6446ea
81 changed files with 9127 additions and 898 deletions
+22 -1
View File
@@ -17,7 +17,6 @@ use Psr\Log\LoggerInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Serializer\Encoder\XmlEncoder;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\Uid\Uuid;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class ApiClient
@@ -77,6 +76,28 @@ class ApiClient
return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data);
}
/**
* @throws ApiClientException
*/
public function updateNewsletterRegistration(string $email, string $password, PersonalData $personalData): Notification|PersonalData
{
$data = [
'user' => $this->config['bpn_username'],
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_CUSTOMER_DATA),
'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA],
'art' => 'Newsletter',
'email' => $email,
'passwort' => $password,
'idadresse' => $personalData->addressId,
'newsletter' => [
'email' => $email,
'anmeldung' => $personalData->communication->newsletter ? 'True' : 'False',
],
];
return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data);
}
/**
* @throws ApiClientException
*/
@@ -4,10 +4,11 @@ namespace App\BusProNet\ApiResponseParser;
use App\BusProNet\Model\Availability;
use App\BusProNet\Model\BaseData;
use App\BusProNet\TypeConversionTrait;
class AvailabilitiesResponseParser
{
use ResponseParserTrait;
use TypeConversionTrait;
public function parse(\SimpleXMLElement $xml): BaseData
{
@@ -10,10 +10,11 @@ use App\BusProNet\Model\Pickup;
use App\BusProNet\Model\Room;
use App\BusProNet\Model\Service;
use App\BusProNet\Model\Surcharge;
use App\BusProNet\TypeConversionTrait;
class BookingResponseParser
{
use ResponseParserTrait;
use TypeConversionTrait;
public function parse(\SimpleXMLElement $xml): Booking
{
@@ -39,7 +40,7 @@ class BookingResponseParser
$booking->applicant = $this->parsePersonalData($xml->anmelder);
$participantsStatus = $this->stringToArray((string) $xml->status_teilnehmer, '/');
$booking->participantsStatus = $this->arrayToOneBased($participantsStatus);
$booking->participantsStatus = array_combine(range(1, count($participantsStatus)), $participantsStatus);
$booking->participants = $this->parseParticipants($xml);
@@ -120,7 +121,7 @@ class BookingResponseParser
return $personalData;
}
private function parseServices(\SimpleXMLElement $xml, string $type): array
private function parseServices(\SimpleXMLElement $xml, string $category): array
{
$services = [];
@@ -130,6 +131,8 @@ class BookingResponseParser
$service = new Service();
$service->id = $id;
$service->category = $category;
$service->source = Service::SOURCE_BOOKING;
$service->label = (string) $attributes['leistung'];
$service->dateFrom = $attributes['termin'] ? $this->stringToDate((string) $attributes['termin']) : null;
$service->dateTo = $attributes['terminbis'] ? $this->stringToDate((string) $attributes['terminbis']) : null;
@@ -144,7 +147,7 @@ class BookingResponseParser
}, $this->stringToArray((string) $attributes['einzelpreis'], '/')
);
$service->individualPrice = array_combine($mapping, $individualPrices);
if (Service::CATEGORY_TRANSPORTATION === $type) {
if (Service::CATEGORY_TRANSPORTATION === $category) {
$service->direction = (string) $attributes['richtung'];
}
@@ -3,10 +3,11 @@
namespace App\BusProNet\ApiResponseParser;
use App\BusProNet\Model\BookingUpdate;
use App\BusProNet\TypeConversionTrait;
class BookingUpdateResponseParser
{
use ResponseParserTrait;
use TypeConversionTrait;
public function parse(\SimpleXMLElement $xml): BookingUpdate
{
@@ -4,10 +4,11 @@ namespace App\BusProNet\ApiResponseParser;
use App\BusProNet\Model\BaseData;
use App\BusProNet\Model\Booking;
use App\BusProNet\TypeConversionTrait;
class BookingsResponseParser
{
use ResponseParserTrait;
use TypeConversionTrait;
public function parse(\SimpleXMLElement $xml): BaseData
{
@@ -23,6 +24,7 @@ class BookingsResponseParser
$booking->price = $this->stringToFloat((string) $item->preis);
$booking->bookingDate = $this->stringToDateTime((string) $item->buchungsdatum);
$booking->travel = (string) $item->reise;
$booking->travelId = (int) $item->idreise;
$booking->travelDate = $this->stringToDate((string) $item->reisedatum);
$booking->document = $this->stringToBool((string) $item->reisedokument);
$booking->payment = $this->stringToFloat((string) $item->zahlung);
@@ -2,13 +2,14 @@
namespace App\BusProNet\ApiResponseParser;
use App\BusProNet\Model\CrmAttributes;
use App\BusProNet\Model\CrmSelection;
use App\BusProNet\Model\CrmSelectionGroup;
use App\BusProNet\Model\CrmAttributes;
use App\BusProNet\TypeConversionTrait;
class CrmAttributesResponseParser
{
use ResponseParserTrait;
use TypeConversionTrait;
private const BPN_CRM_ID_ADMIN = 1292;
private const BPN_CRM_ID_MANAGER = 1293;
@@ -4,10 +4,11 @@ namespace App\BusProNet\ApiResponseParser;
use App\BusProNet\Model\BaseData;
use App\BusProNet\Model\MutableData;
use App\BusProNet\TypeConversionTrait;
class MutableDataResponseParser
{
use ResponseParserTrait;
use TypeConversionTrait;
public function parse(\SimpleXMLElement $xml): BaseData
{
@@ -5,9 +5,12 @@ namespace App\BusProNet\ApiResponseParser;
use App\BusProNet\Model\Address;
use App\BusProNet\Model\Communication;
use App\BusProNet\Model\PersonalData;
use App\BusProNet\TypeConversionTrait;
class PersonalDataResponseParser
{
use TypeConversionTrait;
public function parse(\SimpleXMLElement $xml): PersonalData
{
$addressXml = $xml->adressdaten;
@@ -30,24 +33,25 @@ class PersonalDataResponseParser
$personalData->weight = $xml->sonstiges2 ? (int) $xml->sonstiges2 : null;
$personalData->shoeSize = $xml->sonstiges3 ? (int) $xml->sonstiges3 : null;
$postalXml = $addressXml->anschrift;
if ($postalXml = $addressXml->anschrift) {
$address = new Address();
$address->street = (string) $postalXml->strasse;
$address->postCode = (string) $postalXml->plz;
$address->city = (string) $postalXml->ort;
$address->country = (string) $postalXml->land;
$address = new Address();
$address->street = (string) $postalXml->strasse;
$address->postCode = (string) $postalXml->plz;
$address->city = (string) $postalXml->ort;
$address->country = (string) $postalXml->land;
$personalData->address = $address;
}
$personalData->address = $address;
if ($contactXml = $addressXml->kommunikation) {
$communication = new Communication();
$communication->phone = (string) $contactXml->telefonprivat;
$communication->mobile = (string) $contactXml->telefonmobil;
$communication->email = (string) $contactXml->email;
$communication->newsletter = $this->stringToBool((string) $contactXml->newsletter);
$contactXml = $addressXml->kommunikation;
$communication = new Communication();
$communication->phone = (string) $contactXml->telefonprivat;
$communication->mobile = (string) $contactXml->telefonmobil;
$communication->email = (string) $contactXml->email;
$personalData->communication = $communication;
$personalData->communication = $communication;
}
return $personalData;
}
@@ -33,6 +33,7 @@ class ResponseParser
switch ($subType) {
case 'Adressdaten':
case 'Adressdaten_Ändern':
case 'Newsletter':
return (new PersonalDataResponseParser())->parse($xml);
case 'SelektionCRM':
case 'SelektionCRM_Ändern':
@@ -2,12 +2,12 @@
namespace App\BusProNet\DataLoader;
use App\BusProNet\ApiResponseParser\ResponseParserTrait;
use App\BusProNet\TypeConversionTrait;
use Symfony\Contracts\Cache\CacheInterface;
abstract class AbstractDataLoader
{
use ResponseParserTrait;
use TypeConversionTrait;
public function __construct(protected readonly CacheInterface $cache, protected readonly ?string $xmlPath = null)
{
+77 -12
View File
@@ -11,38 +11,90 @@ use App\BusProNet\Model\Room;
use App\BusProNet\Model\Service;
use App\BusProNet\Model\Travel;
use Symfony\Component\Finder\Finder;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
class TravelDataLoader extends AbstractDataLoader
{
public function __construct(
private readonly HotelDataLoader $hotelDataLoader,
CacheInterface $cache,
?string $xmlPath = null
) {
parent::__construct($cache, $xmlPath);
}
public function generateMapping(): array
{
return $this->cache->get('bpn_travels_mapping', function (ItemInterface $item) {
$item->expiresAfter(3600);
$finder = new Finder();
$finder->files()->in($this->xmlPath)->name('Ziel_*.xml');
$mapping = [];
$hotels = $this->hotelDataLoader->loadAll();
foreach ($finder as $file) {
$xml = simplexml_load_file($file->getRealPath());
foreach ($xml->reise->termin as $travelXml) {
$attributes = $travelXml->attributes();
$travelId = (int) $attributes['idbuspro'];
$mapping[$travelId] = [
'id' => (int) $attributes['idbuspro'],
'label' => (string) $travelXml->text,
'dateFrom' => $this->stringToDate((string) $attributes['termin']),
'dateTo' => $this->stringToDate((string) $attributes['bis']),
'hotels' => [],
'file' => $file->getRealPath(),
];
foreach ($travelXml->hotel as $hotelXml) {
$hotelId = (int) $hotelXml->attributes()['idbuspro'];
$mapping[$travelId]['hotels'][$hotelId] = [
'id' => $hotelId,
'label' => $hotels[$hotelId]->name,
];
}
}
}
return $mapping;
});
}
public function loadById(int $id, ?string $filename = null): ?Travel
{
if (null !== $filename) {
return $this->loadXml($id, $filename);
}
$finder = new Finder();
$finder->files()->in($this->xmlPath)->name('Ziel_*.xml');
$mapping = $this->generateMapping();
foreach ($finder as $file) {
if (null !== $xml = $this->loadXml($id, $file->getRealPath())) {
return $xml;
}
if (false === isset($mapping[$id])) {
return null;
}
return null;
$file = $mapping[$id]['file'];
return $this->loadXml($id, $file);
}
private function loadXml(int $id, string $filename): ?Travel
{
$xml = simplexml_load_file($filename);
foreach ($xml->reise->termin as $travel) {
if ($id === (int) $travel->attributes()['idbuspro']) {
return $this->parseXml($travel);
}
$travelXml = $xml->xpath(sprintf('//reise/termin[@idbuspro="%d"]', $id));
if (0 === count($travelXml)) {
return null;
}
return null;
return $this->parseXml($travelXml[0]);
}
public function parseXml(\SimpleXMLElement $xml): Travel
@@ -100,6 +152,7 @@ class TravelDataLoader extends AbstractDataLoader
$serviceId = (int) $attributes['idbuspro'];
$service = new Service();
$service->source = Service::SOURCE_TRAVEL;
$service->category = Service::CATEGORY_ADDITIONAL;
$service->id = $serviceId;
$service->subType = (string) $attributes['unterart'];
@@ -125,6 +178,7 @@ class TravelDataLoader extends AbstractDataLoader
$serviceId = (int) $attributes['idbuspro'];
$service = new Service();
$service->source = Service::SOURCE_TRAVEL;
$service->category = Service::CATEGORY_TRANSPORTATION;
$service->id = $serviceId;
$service->subType = (string) $attributes['unterart'];
@@ -223,4 +277,15 @@ class TravelDataLoader extends AbstractDataLoader
}
}
}
public function patchBookings(BaseData $bookings): void
{
foreach ($bookings->getItems() as $booking) {
if (null === $travelId = $booking->travelId) {
continue;
}
$booking->travelData = $this->loadById($travelId);
}
}
}
+1
View File
@@ -16,6 +16,7 @@ class Booking
public ?int $travelId = null;
public ?string $travelCode = null;
public ?\DateTimeImmutable $travelDate = null;
public ?Travel $travelData = null;
public ?int $hotelId = null;
public ?string $hotelName = null;
public ?bool $document = null;
+2
View File
@@ -7,6 +7,7 @@ class Communication
public ?string $phone = null;
public ?string $mobile = null;
public ?string $email = null;
public bool $newsletter = false;
public function toPayload(): array
{
@@ -14,6 +15,7 @@ class Communication
'email' => $this->email,
'telefonmobil' => $this->mobile,
'telefonprivat' => $this->phone,
'newsletter' => $this->newsletter ? 'True' : 'False',
];
}
}
+5
View File
@@ -8,10 +8,13 @@ class PersonalData
{
public ?int $addressId = null;
public ?int $personId = null;
#[Assert\NotBlank(message: 'Bitte angeben')]
public ?string $name = null;
#[Assert\NotBlank(message: 'Bitte angeben')]
public ?string $firstName = null;
public ?string $salutation = null;
public ?string $title = null;
public ?string $gender = null;
@@ -20,8 +23,10 @@ class PersonalData
public ?string $shoeSize = null;
public ?string $weight = null;
public ?\DateTimeImmutable $dateOfBirth = null;
#[Assert\Valid()]
public ?Address $address = null;
#[Assert\Valid()]
public ?Communication $communication = null;
+4
View File
@@ -4,6 +4,9 @@ namespace App\BusProNet\Model;
class Service
{
public const SOURCE_TRAVEL = 'travel';
public const SOURCE_BOOKING = 'booking';
public const CATEGORY_ADDITIONAL = 'additional';
public const CATEGORY_TRANSPORTATION = 'transportation';
@@ -14,6 +17,7 @@ class Service
public const TOKEN_RENTALS = ['VER', 'VE2', 'VE3', 'VE4', 'VE5', 'VE6', 'VE7', 'VE8'];
public ?int $id = null;
public ?string $source = null;
public ?string $category = null;
public ?string $status = null;
public bool $mandatory = false;
@@ -1,19 +1,12 @@
<?php
namespace App\BusProNet\ApiResponseParser;
namespace App\BusProNet;
use Carbon\Carbon;
use Carbon\Exceptions\InvalidFormatException;
trait ResponseParserTrait
trait TypeConversionTrait
{
private function arrayToOneBased(array $data): array
{
$keys = range(1, count($data));
return array_combine($keys, $data);
}
protected function stringToArray(string $string, string $separator = ','): array
{
return explode($separator, $string);
@@ -24,11 +17,21 @@ trait ResponseParserTrait
return (float) str_replace(['.', ','], ['', '.'], $string);
}
protected function floatToString(float $float): string
{
return number_format($float, 2, ',', '');
}
protected function stringToBool(string $string): bool
{
return 'True' === $string;
}
protected function boolToString(bool $bool): string
{
return $bool ? 'True' : 'False';
}
protected function stringToDate(string $string): ?\DateTimeImmutable
{
try {
+16 -6
View File
@@ -7,13 +7,14 @@ use App\BusProNet\DataLoader\PickupDataLoader;
use App\BusProNet\DataLoader\TravelDataLoader;
use App\Form\BookingType;
use App\Form\Model\BookingData;
use App\Htmx\HxRedirectResponse;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
class EditController extends AbstractController
{
@@ -21,6 +22,7 @@ class EditController extends AbstractController
private readonly ApiClient $apiClient,
private readonly TravelDataLoader $travelDataLoader,
private readonly PickupDataLoader $pickupDataLoader,
private readonly CacheInterface $cache,
private readonly Security $security,
) {
}
@@ -35,8 +37,14 @@ class EditController extends AbstractController
return $this->security->logout();
}
// Fetch original bookingData data via API
$bookingData = $this->apiClient->getBooking($bpnUser->getEmail(), $bpnUser->getPassword(), $id);
// Fetch original bookingData data via API and cache result for a short ttl
$cacheKeyList = sprintf('bpn_bookings_%s', sha1($bpnUser->getUserIdentifier()));
$cacheKeySingle = sprintf('bpn_booking_%d', $id);
$bookingData = $this->cache->get($cacheKeySingle, function (ItemInterface $item) use ($bpnUser, $id) {
$item->expiresAfter(300);
return $this->apiClient->getBooking($bpnUser->getEmail(), $bpnUser->getPassword(), $id);
});
if (null === $bookingData) {
$this->addFlash('error', 'Buchungsdaten nicht (mehr) verfügbar');
@@ -68,10 +76,9 @@ class EditController extends AbstractController
$formData = BookingData::fromBooking($bookingData, $travelData);
$form = $this->createForm(BookingType::class, $formData, [
'attr' => ['novalidate' => 'novalidate'],
'hx_post' => $this->generateUrl('app_booking_edit', ['id' => $id]),
'hx_target' => '#app',
'hx_swap' => 'innerHTML show:top',
'attr' => ['novalidate' => 'novalidate'],
]);
$form->handleRequest($request);
@@ -79,9 +86,12 @@ class EditController extends AbstractController
if ($form->isSubmitted() && $form->isValid()) {
$this->apiClient->updateBooking($formData, false);
$this->cache->delete($cacheKeySingle);
$this->cache->delete($cacheKeyList);
$this->addFlash('success', 'Buchung erfolgreich aktualisiert');
return new HxRedirectResponse($this->generateUrl('app_booking_edit', ['id' => $id]));
return $this->redirectToRoute('app_booking_edit', ['id' => $id]);
}
return $this->render('booking/edit.html.twig', [
+15 -1
View File
@@ -3,17 +3,22 @@
namespace App\Controller\Booking;
use App\BusProNet\ApiClient;
use App\BusProNet\DataLoader\TravelDataLoader;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
class IndexController extends AbstractController
{
public function __construct(
private readonly ApiClient $apiClient,
private readonly TravelDataLoader $travelDataLoader,
private readonly CacheInterface $cache,
private readonly Security $security,
) {
}
@@ -28,7 +33,16 @@ class IndexController extends AbstractController
return $this->security->logout();
}
$bookings = $this->apiClient->getBookings($bpnUser->getEmail(), $bpnUser->getPassword());
// Cache bookings for a short ttl
$cacheKey = sprintf('bpn_bookings_%s', sha1($bpnUser->getUserIdentifier()));
$bookings = $this->cache->get($cacheKey, function (ItemInterface $item) use ($bpnUser) {
$item->expiresAfter(300);
$bookings = $this->apiClient->getBookings($bpnUser->getEmail(), $bpnUser->getPassword());
$this->travelDataLoader->patchBookings($bookings);
return $bookings;
});
return $this->render('booking/index.html.twig', [
'bookings' => $bookings->getItems(),
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class IndexController extends AbstractController
{
#[Route('/', name: 'app_index')]
#[IsGranted('PUBLIC_ACCESS')]
public function index(): Response
{
return $this->render('index/index.html.twig');
}
}
+28 -1
View File
@@ -42,7 +42,7 @@ class PersonalDataController extends AbstractController
if ($form->isSubmitted() && $form->isValid()) {
try {
$this->apiClient->updatePersonalData($bpnUser->getEmail(), $bpnUser->getPassword(), $personalData);
$this->addFlash('success', 'Personal data updated.');
$this->addFlash('success', 'Deine persönlichen Daten wurden aktualisiert');
} catch (ApiClientException $e) {
$this->addFlash('error', $e->getMessage());
}
@@ -55,4 +55,31 @@ class PersonalDataController extends AbstractController
'form' => $form->createView(),
]);
}
#[Route('/personal-data/newsletter', name: 'app_personal_data_newsletter', methods: ['POST'])]
#[IsGranted('ROLE_USER')]
public function newsletter(Request $request): Response
{
$bpnUser = $request->getSession()->get('bpn_user');
if (null === $bpnUser) {
return $this->security->logout();
}
$personalData = $this
->apiClient
->getPersonalData($bpnUser->getEmail(), $bpnUser->getPassword())
;
$personalData->communication->newsletter = !$personalData->communication->newsletter;
try {
$this->apiClient->updateNewsletterRegistration($bpnUser->getEmail(), $bpnUser->getPassword(), $personalData);
$this->addFlash('success', 'Deine Anmeldung zum Newsletter wurde aktualisiert');
} catch (ApiClientException $e) {
$this->addFlash('error', $e->getMessage());
}
return $this->redirectToRoute('app_personal_data');
}
}
+1 -1
View File
@@ -10,7 +10,7 @@ use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
class SecurityController extends AbstractController
{
#[Route('/', name: 'app_login')]
#[Route('/login', name: 'app_login')]
#[IsGranted('PUBLIC_ACCESS')]
public function login(AuthenticationUtils $authenticationUtils): Response
{
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace App\EventListener;
use App\Htmx\HxRedirectResponse;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Http\Event\LogoutEvent;
#[AsEventListener(event: LogoutEvent::class, method: 'onLogout')]
class LogoutListener
{
public function __construct(private readonly UrlGeneratorInterface $urlGenerator)
{
}
public function onLogout(LogoutEvent $event): void
{
$event->setResponse(new HxRedirectResponse($this->urlGenerator->generate('app_index')));
}
}
+26 -8
View File
@@ -24,11 +24,11 @@ final class BookingType extends AbstractType
$form->add('participants', CollectionType::class, [
'entry_type' => ParticipantType::class,
'entry_options' => [
'selectable_courses' => $travelData->getAdditionalServicesByGroup(Service::TOKEN_COURSES),
'selectable_ski_passes' => $travelData->getAdditionalServicesByGroup(Service::TOKEN_SKI_PASS),
'selectable_services' => $travelData->getAdditionalServicesByGroup(Service::TOKEN_ADDITIONAL),
'selectable_board' => $travelData->getAdditionalServicesByGroup(Service::TOKEN_BOARD),
'selectable_rentals' => $travelData->getAdditionalServicesByGroup(Service::TOKEN_RENTALS),
'selectable_courses' => $this->mergeSelectableServices($data, Service::TOKEN_COURSES),
'selectable_ski_passes' => $this->mergeSelectableServices($data, Service::TOKEN_SKI_PASS),
'selectable_services' => $this->mergeSelectableServices($data, Service::TOKEN_ADDITIONAL),
'selectable_board' => $this->mergeSelectableServices($data, Service::TOKEN_BOARD),
'selectable_rentals' => $this->mergeSelectableServices($data, Service::TOKEN_RENTALS),
'selectable_transportation_services_to' => $travelData
->getTransportationServicesByDirection('HIN'),
'selectable_transportation_services_fro' => $travelData
@@ -46,10 +46,28 @@ final class BookingType extends AbstractType
});
}
private function mergeSelectableServices(BookingData $data, mixed $subType): array
{
// combine selectable services from travel data with additional services
// from booking data
$selectableServices = $data->travel->getAdditionalServicesByGroup($subType);
$selectableServiceIds = array_map(function (Service $service) {
return $service->id;
}, $selectableServices);
foreach ($data->booking->getAdditionalServicesByGroup($subType) as $item) {
if (false === in_array($item->id, $selectableServiceIds)) {
$selectableServices[] = $item;
}
}
return $selectableServices;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver
->setDefaults(['data_class' => BookingData::class])
;
$resolver->setDefaults([
'data_class' => BookingData::class,
]);
}
}
+173 -125
View File
@@ -51,7 +51,7 @@ class ParticipantType extends AbstractType
'preferred_choices' => ['D', 'A', 'CH'],
])
->add('email', EmailType::class, [
'label' => 'Email',
'label' => 'E-Mail',
])
->add('mobile', TextType::class, [
'label' => 'Telefon (mobil)',
@@ -71,142 +71,190 @@ class ParticipantType extends AbstractType
;
}
$builder
->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) use ($options) {
/** @var ParticipantData $participantData */
$participantData = $event->getData();
$participantIndex = $participantData->index;
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) use ($options) {
/** @var ParticipantData $participantData */
$participantData = $event->getData();
$participantIndex = $participantData->index;
$form = $event->getForm();
$form = $event->getForm();
$commonChoiceFieldOptions = [
'multiple' => true,
'expanded' => true,
'choice_value' => 'id',
'choice_label' => function (?Service $service) use ($participantIndex) {
if (null === $service) {
return null;
}
$price = $service->individualPrice[$participantIndex] ?? $service->price;
if (null === $price || 0.0 === $price) {
return $service->label;
}
$commonChoiceFieldOptions = [
'required' => false,
'multiple' => true,
'expanded' => true,
'choice_value' => 'id',
'choice_label' => function (?Service $service) use ($participantIndex) {
if (null === $service) {
return null;
}
$price = $service->individualPrice[$participantIndex] ?? $service->price;
if (null === $price || 0.0 === $price) {
return $service->label;
}
return sprintf('%s (%s€)',
$service->label,
number_format($price, 2, ',', '.')
);
},
'choice_attr' => function (?Service $service) {
if (true === $service->mandatory) {
return sprintf('%s (%s €)',
$service->label,
number_format($price, 2, ',', '.')
);
},
'choice_attr' => function (?Service $service) {
$attributes = [
'data-available' => $service->available,
'data-availabilities-target' => 'field',
];
// forcibly select mandatory services
if (true === $service->mandatory) {
$attributes['checked'] = true;
$attributes['disabled'] = true;
}
// disable selection of services only available in booking data
if (Service::SOURCE_BOOKING === $service->source) {
$attributes['disabled'] = true;
}
return $attributes;
},
'choice_filter' => function (Service $service) use ($participantData) {
// remove choices only available in booking data and
// which are not mapped to the current participant
if (
Service::SOURCE_TRAVEL === $service->source ||
Service::CATEGORY_ADDITIONAL !== $service->category
) {
return true;
}
return in_array($participantData->index, $service->mapping);
},
];
// Additional services
if (true === $options['additional_services_mutable']) {
if (0 < count($options['selectable_courses'])) {
$form->add('courses', ChoiceType::class, [
...$commonChoiceFieldOptions,
'label' => 'Kurse',
'choices' => $options['selectable_courses'],
]);
}
if (0 < count($options['selectable_services'])) {
$form->add('additionalServices', ChoiceType::class, [
...$commonChoiceFieldOptions,
'label' => 'Zusatzleistungen',
'choices' => $options['selectable_services'],
]);
}
if (0 < count($options['selectable_ski_passes'])) {
$form->add('skiPass', ChoiceType::class, [
...$commonChoiceFieldOptions,
'label' => 'Skipass',
'choices' => $options['selectable_ski_passes'],
]);
}
if (0 < count($options['selectable_board'])) {
$form->add('board', ChoiceType::class, [
...$commonChoiceFieldOptions,
'label' => 'Verpflegung',
'choices' => $options['selectable_board'],
]);
}
if (0 < count($options['selectable_rentals'])) {
$form->add('rentals', ChoiceType::class, [
...$commonChoiceFieldOptions,
'label' => 'Verleih',
'choices' => $options['selectable_rentals'],
]);
}
}
// Transportation
if (true === $options['transportation_services_mutable']) {
$form
->add('transportationServiceTo', ChoiceType::class, [
...$commonChoiceFieldOptions,
'label' => 'Anreise',
'multiple' => false,
'choices' => $options['selectable_transportation_services_to'],
'choice_attr' => function (?Service $service) {
return [
'checked' => true,
'disabled' => true,
'data-available' => $service->available,
'data-availabilities-target' => 'field',
'data-action' => 'select-toggle#toggle',
'data-select-toggle-value' => $service->subType,
];
}
},
])
->add('transportationServiceFro', ChoiceType::class, [
...$commonChoiceFieldOptions,
'label' => 'Rückreise',
'multiple' => false,
'choices' => $options['selectable_transportation_services_fro'],
]);
}
return [];
},
];
// Pickup
if (true === $options['pickups_mutable']) {
$form
->add('pickup', ChoiceType::class, [
'label' => 'Zustieg',
'multiple' => false,
'expanded' => false,
'choices' => $options['selectable_pickups'],
'choice_value' => 'id',
'choice_label' => function (?Pickup $pickup) {
if (null === $pickup) {
return null;
}
// Additional services
if (true === $options['additional_services_mutable']) {
$price = $pickup->price;
if (0 < count($options['selectable_courses'])) {
$form->add('courses', ChoiceType::class, [
...$commonChoiceFieldOptions,
'label' => 'Kurse',
'choices' => $options['selectable_courses'],
]);
}
if (0 < count($options['selectable_services'])) {
$form->add('additionalServices', ChoiceType::class, [
...$commonChoiceFieldOptions,
'label' => 'Zusatzleistungen',
'choices' => $options['selectable_services'],
]);
}
if (0 < count($options['selectable_ski_passes'])) {
$form->add('skiPass', ChoiceType::class, [
...$commonChoiceFieldOptions,
'label' => 'Skipass',
'choices' => $options['selectable_ski_passes'],
]);
}
if (0 < count($options['selectable_board'])) {
$form->add('board', ChoiceType::class, [
...$commonChoiceFieldOptions,
'label' => 'Verpflegung',
'choices' => $options['selectable_board'],
]);
}
if (0 < count($options['selectable_rentals'])) {
$form->add('rentals', ChoiceType::class, [
...$commonChoiceFieldOptions,
'label' => 'Verleih',
'choices' => $options['selectable_rentals'],
]);
}
if (null === $price || 0.0 === $price) {
return $pickup->city;
}
return sprintf('%s (%s €)',
$pickup->city,
number_format($price, 2, ',', '.')
);
},
]);
}
})
->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event) use ($options) {
$data = $event->getData();
$form = $event->getForm();
$transportationId = $data['transportationServiceTo'];
$transportation = $options['travel']->pickups[$transportationId] ?? null;
if (null !== $transportation && 'PKW' === $transportation->subType) {
$form->remove('pickup');
unset($data['pickup']);
}
// forcibly select mandatory services that potentially have been disabled in PRE_SET_DATA
$mandatoryServices = array_filter($options['selectable_services'], function (Service $service) use ($data) {
return true === $service->mandatory ||
(Service::SOURCE_BOOKING === $service->source && in_array($data['index'], $service->mapping));
});
if (0 < count($mandatoryServices)) {
if (false === isset($data['additionalServices'])) {
$data['additionalServices'] = [];
}
// Transportation
if (true === $options['transportation_services_mutable']) {
$form
->add('transportationServiceTo', ChoiceType::class, [
...$commonChoiceFieldOptions,
'label' => 'Anreise',
'multiple' => false,
'choices' => $options['selectable_transportation_services_to'],
])
->add('transportationServiceFro', ChoiceType::class, [
...$commonChoiceFieldOptions,
'label' => 'Rückreise',
'multiple' => false,
'choices' => $options['selectable_transportation_services_fro'],
]);
}
$mandatoryServiceIds = array_map(function (Service $service) {
return $service->id;
}, $mandatoryServices);
// Pickup
if (true === $options['pickups_mutable']) {
$form
->add('pickup', ChoiceType::class, [
'label' => 'Zustieg (bei Busanreise)',
'multiple' => false,
'expanded' => false,
'choices' => $options['selectable_pickups'],
'choice_value' => 'id',
'choice_label' => function (?Pickup $pickup) {
if (null === $pickup) {
return null;
}
$allServiceIds = [...$mandatoryServiceIds, ...$data['additionalServices']];
$data['additionalServices'] = array_unique($allServiceIds);
}
$price = $pickup->price;
if (null === $price || 0.0 === $price) {
return $pickup->city;
}
return sprintf('%s (%s€)',
$pickup->city,
number_format($price, 2, ',', '.')
);
},
]);
}
})
->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event) use ($options) {
$data = $event->getData();
$form = $event->getForm();
$transportationId = $data['transportationServiceTo'];
$transportation = $options['travel']->pickups[$transportationId] ?? null;
if (null !== $transportation && 'PKW' === $transportation->subType) {
$form->remove('pickup');
unset($data['pickup']);
}
})
;
$event->setData($data);
});
}
public function configureOptions(OptionsResolver $resolver): void
+3 -3
View File
@@ -18,9 +18,9 @@ class PersonalDataType extends AbstractType
->add('gender', ChoiceType::class, [
'label' => 'Gender',
'choices' => [
'M' => 'M',
'W' => 'W',
'D' => 'D',
'männlich' => 'M',
'weiblich' => 'W',
'divers' => 'D',
],
])
->add('firstName', TextType::class, [
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace App\Menu;
use Knp\Menu\FactoryInterface;
use Knp\Menu\ItemInterface;
class MenuBuilder
{
public function __construct(
private readonly FactoryInterface $factory,
) {
}
public function createMainMenu(): ItemInterface
{
$menu = $this->factory->createItem('root', [
'childrenAttributes' => [
'class' => 'menu menu--main',
],
]);
$menu->addChild('Meine Daten', [
'route' => 'app_personal_data',
'linkAttributes' => [
'title' => 'Persönliche Daten',
],
]);
$menu->addChild('Meine Buchungen', [
'route' => 'app_bookings',
'linkAttributes' => [
'title' => 'Meine Buchungen',
],
'extras' => [
'routes' => [
'app_booking_edit',
],
],
]);
$menu->addChild('Logout', [
'route' => 'app_logout',
'linkAttributes' => [
'title' => 'Logout',
],
]);
return $menu;
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace App\Twig;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
use Twig\TwigFunction;
class AppExtension extends AbstractExtension
{
public function getFilters(): array
{
return [
new TwigFilter('file_size', [AppRuntime::class, 'formatBytes']),
new TwigFilter('file_icon', [AppRuntime::class, 'fileIconFilter'], ['is_safe' => ['html']]),
new TwigFilter('format_money', [AppRuntime::class, 'formatMoney']),
];
}
public function getFunctions(): array
{
return [
new TwigFunction('icon', [AppRuntime::class, 'renderIcon'], ['needs_environment' => true, 'is_safe' => ['html']]),
];
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
namespace App\Twig;
use Twig\Environment;
use Twig\Extension\RuntimeExtensionInterface;
use Twig\Extra\Intl\IntlExtension;
class AppRuntime implements RuntimeExtensionInterface
{
public function __construct(private readonly IntlExtension $intlExtension)
{
}
public function fileIconFilter(Environment $environment, string $mimeType, ?string $classes = 'w-4 h-4'): string
{
$icon = match ($mimeType) {
'application/pdf' => 'pdf',
'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'excel',
'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'word',
default => 'download',
};
return $this->renderIcon($environment, $icon, $classes);
}
public function formatBytes(int $bytes, ?int $precision = 2): string
{
$size = ['B', 'kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
$factor = floor((strlen($bytes) - 1) / 3);
return sprintf("%.{$precision}f", $bytes / (1024 ** $factor)) . @$size[$factor];
}
public function formatMoney(int $amount): string
{
// Amounts are stored as integers so divide by 100 first
$amount = $amount / 100;
return $this->intlExtension->formatCurrency($amount, 'EUR');
}
public function renderIcon(Environment $environment, string $icon, string $classes = 'w-5 h-5'): string
{
return $environment->render('_partials/_icon.html.twig', [
'icon' => $icon,
'class' => $classes,
]);
}
}