wip: booking process, refactoring

This commit is contained in:
Björn Fromme
2025-07-16 19:37:17 +02:00
parent 47faa6b08e
commit eecea0abd1
43 changed files with 2269 additions and 366 deletions
+21 -6
View File
@@ -34,6 +34,7 @@ class ApiClient
public const TYPE_AVAILABILITY = 'VERFUEGBARKEIT';
public const TYPE_AVAILABILITY_HOTEL = 'VERFUEGBARKEITHOTEL';
public const TYPE_BOOKING_UPDATE = 'BUCHUNGAENDERUNG';
public const TYPE_PRODUCTS = 'PRODUKTE';
public const TYPE_PRODUCT_DATA = 'PRODUKTDATEN';
private array $config;
@@ -205,13 +206,13 @@ class ApiClient
/**
* @throws ApiClientException
*/
public function getMutableData(int $travelId): Notification|BaseData
public function getMutableData(int $dateId): Notification|BaseData
{
$data = [
'user' => $this->config['bpn_username'],
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_MUTABLE_DATA),
'satz' => ['@typ' => static::TYPE_MUTABLE_DATA],
'idreise' => $travelId,
'idreise' => $dateId,
];
return $this->sendRequest(static::TYPE_MUTABLE_DATA, $data);
@@ -220,13 +221,13 @@ class ApiClient
/**
* @throws ApiClientException
*/
public function getAvailabilities(int $travelId): Notification|BaseData
public function getAvailabilities(int $dateId): Notification|BaseData
{
$data = [
'user' => $this->config['bpn_username'],
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_AVAILABILITY),
'satz' => ['@typ' => static::TYPE_AVAILABILITY],
'idreise' => $travelId,
'idreise' => $dateId,
];
return $this->sendRequest(static::TYPE_AVAILABILITY, $data);
@@ -235,13 +236,13 @@ class ApiClient
/**
* @throws ApiClientException
*/
public function getHotelAvailability(int $travelId, int $hotelId, \DateTimeInterface $dateTo): Notification|BaseData
public function getHotelAvailability(int $dateId, int $hotelId, \DateTimeInterface $dateTo): Notification|BaseData
{
$data = [
'user' => $this->config['bpn_username'],
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_AVAILABILITY_HOTEL),
'satz' => ['@typ' => static::TYPE_AVAILABILITY_HOTEL],
'idreise' => $travelId,
'idreise' => $dateId,
'idpartner' => $hotelId,
'terminbis' => $dateTo->format('d.m.Y'),
];
@@ -313,6 +314,20 @@ class ApiClient
return $this->sendRequest(static::TYPE_PRODUCT_DATA, $data, ['hotelId' => $hotelId]);
}
/**
* @throws ApiClientException
*/
public function getProducts(): Notification|BaseData
{
$data = [
'user' => $this->config['bpn_username'],
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_PRODUCTS),
'satz' => ['@typ' => static::TYPE_PRODUCTS],
];
return $this->sendRequest(static::TYPE_PRODUCTS, $data);
}
/**
* @throws ApiClientException
*/
+1 -1
View File
@@ -19,4 +19,4 @@ final class Constants
public const STATUS_AVAILABLE = 'Frei';
public const STATUS_BLOCKED = 'Buchungsstop';
public const STATUS_ON_REQUEST = 'Anfrage';
}
}
@@ -1,97 +1,236 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\DataProcessor;
use App\BusProNet\Model\Communication;
use App\Form\Model\BookingEditDto;
/**
* Processes booking form data and converts it into BusProNet API payload format.
*
* This processor handles the complex transformation of booking edit form data into the structured
* array format required by the BusProNet API. It manages service mappings between participants
* and various booking components like additional services, transportation, and accommodations.
* The processor ensures data consistency by resetting and rebuilding participant-to-service
* mappings based on form selections, while maintaining proper API payload structure.
*/
class BookingDataProcessor
{
/**
* Creates an update request payload for the BusProNet API from booking form data.
*
* This method processes booking edit form data and transforms it into the structured array
* format expected by the BusProNet XML API. It handles service mappings, participant data
* updates, and generates the complete payload structure including booking metadata,
* participant information, services, transportation, and accommodation details.
*
* The process involves:
* - Resetting existing service-to-participant mappings
* - Rebuilding mappings based on current form selections
* - Adding new services from travel data when participants select them
* - Removing services with no participant mappings
* - Updating participant personal data from form input
* - Synchronizing applicant data with first participant details
* - Building the final API payload structure
*
* @param BookingEditDto|null $formData The booking edit form data containing updated participant and service selections
*
* @return array The structured payload array ready for BusProNet API submission
*/
public function createUpdateRequestPayload(?BookingEditDto $formData): array
{
$bookingData = $formData->booking;
$travelData = $formData->travel;
// Reset mappings
$this->resetServiceMappings($bookingData);
foreach ($formData->participants as $participant) {
$this->processParticipantServices($participant, $bookingData, $travelData);
}
$this->removeUnusedServices($bookingData);
$this->updateParticipantPersonalData($formData->participants, $bookingData);
$this->syncApplicantData($bookingData);
$payload = $this->buildBasePayload($bookingData);
$this->addBankAccountToPayload($payload, $bookingData);
$this->buildParticipantPayload($payload, $bookingData);
$this->buildServicesPayload($payload, $bookingData);
$this->buildPickupPayload($payload, $bookingData);
return $payload;
}
/**
* Resets all existing participant-to-service mappings to start with a clean slate.
*
* This ensures that service assignments are rebuilt from scratch based on current form selections.
*
* @param object $bookingData The booking data object containing services to reset
*/
private function resetServiceMappings(object $bookingData): void
{
$servicesToReset = [
...$bookingData->additionalServices,
...$bookingData->transportationServices,
...$bookingData->pickupsTo,
...$bookingData->pickupsFro,
];
foreach ($servicesToReset as $service) {
$service->mapping = [];
}
}
// Update mappings, add services and pickups
foreach ($formData->participants as $participant) {
// skip canceled participants
if (true === $participant->isCanceled()) {
continue;
}
$servicesToMap = [
...$participant->courses,
...$participant->additionalServices,
...$participant->skiPass,
...$participant->board,
...$participant->rentals,
];
foreach ($servicesToMap as $service) {
if (false === isset($this->additionalServices[$service->id])) {
$serviceToAdd = $travelData->additionalServices[$service->id];
if (null !== $serviceToAdd) {
$bookingData->additionalServices[$service->id] = $serviceToAdd;
$bookingData->additionalServices[$service->id]->individualPrice[$participant->index] = $serviceToAdd->price;
}
}
$bookingData->additionalServices[$service->id]->mapping[] = $participant->index;
}
foreach ([$participant->transportationServiceTo, $participant->transportationServiceFro] as $service) {
if (false === isset($this->transportationServices[$service->id])) {
$serviceToAdd = $travelData->transportationServices[$service->id];
if (null !== $serviceToAdd) {
$bookingData->transportationServices[$service->id] = $serviceToAdd;
$bookingData->transportationServices[$service->id]->individualPrice[$participant->index] = $serviceToAdd->price;
}
}
$bookingData->transportationServices[$service->id]->mapping[] = $participant->index;
}
if ('BUS' === $participant->transportationServiceTo->subType && null !== $selectedPickup = $participant->pickup) {
if (false === isset($bookingData->pickupsTo[$selectedPickup->id])) {
$bookingData->pickupsTo[$selectedPickup->id] = $selectedPickup;
}
$bookingData->pickupsTo[$selectedPickup->id]->mapping[] = $participant->index;
}
/**
* Processes all services for a single participant.
*
* This orchestrator method handles the complete service assignment workflow for one participant,
* including additional services, transportation services, and pickup locations.
*
* @param object $participant The participant data from the form
* @param object $bookingData The booking data object to update
* @param object $travelData The travel data containing available services
*/
private function processParticipantServices(object $participant, object $bookingData, object $travelData): void
{
if (true === $participant->isCanceled()) {
return;
}
// Remove services/pickups with empty mappings
$this->processAdditionalServices($participant, $bookingData, $travelData);
$this->processTransportationServices($participant, $bookingData, $travelData);
$this->processPickupLocations($participant, $bookingData);
}
/**
* Processes additional services for a participant.
*
* Maps additional services (courses, ski passes, board options, rentals) to the participant.
* Adds new services to the booking if they don't already exist and sets individual pricing.
*
* @param object $participant The participant data from the form
* @param object $bookingData The booking data object to update
* @param object $travelData The travel data containing available services
*/
private function processAdditionalServices(object $participant, object $bookingData, object $travelData): void
{
$servicesToMap = [
...$participant->courses,
...$participant->additionalServices,
...$participant->skiPass,
...$participant->board,
...$participant->rentals,
];
foreach ($servicesToMap as $service) {
if (false === isset($bookingData->additionalServices[$service->id])) {
$serviceToAdd = $travelData->additionalServices[$service->id] ?? null;
if (null !== $serviceToAdd) {
$bookingData->additionalServices[$service->id] = $serviceToAdd;
$bookingData->additionalServices[$service->id]->individualPrice[$participant->index] = $serviceToAdd->price;
}
}
$bookingData->additionalServices[$service->id]->mapping[] = $participant->index;
}
}
/**
* Processes transportation services for a participant.
*
* Maps transportation services (both directions: to and from destination) to the participant.
* Adds new transportation services to the booking if they don't already exist.
*
* @param object $participant The participant data from the form
* @param object $bookingData The booking data object to update
* @param object $travelData The travel data containing available services
*/
private function processTransportationServices(object $participant, object $bookingData, object $travelData): void
{
foreach ([$participant->transportationServiceTo, $participant->transportationServiceFro] as $service) {
if (false === isset($bookingData->transportationServices[$service->id])) {
$serviceToAdd = $travelData->transportationServices[$service->id] ?? null;
if (null !== $serviceToAdd) {
$bookingData->transportationServices[$service->id] = $serviceToAdd;
$bookingData->transportationServices[$service->id]->individualPrice[$participant->index] = $serviceToAdd->price;
}
}
$bookingData->transportationServices[$service->id]->mapping[] = $participant->index;
}
}
/**
* Processes pickup locations for participants using bus transportation.
*
* Only processes pickup locations for bus transportation services and maps the participant
* to their selected pickup location.
*
* @param object $participant The participant data from the form
* @param object $bookingData The booking data object to update
*/
private function processPickupLocations(object $participant, object $bookingData): void
{
if ('BUS' === $participant->transportationServiceTo->subType && null !== $selectedPickup = $participant->pickup) {
if (false === isset($bookingData->pickupsTo[$selectedPickup->id])) {
$bookingData->pickupsTo[$selectedPickup->id] = $selectedPickup;
}
$bookingData->pickupsTo[$selectedPickup->id]->mapping[] = $participant->index;
}
}
/**
* Removes services and pickups with no participant mappings.
*
* Cleans up unused services to prevent empty services from being sent to the API.
* This includes additional services, transportation services, and pickup locations.
*
* @param object $bookingData The booking data object to clean up
*/
private function removeUnusedServices(object $bookingData): void
{
foreach ($bookingData->additionalServices as $service) {
if (0 === count($service->mapping)) {
unset($bookingData->additionalServices[$service->id]);
}
}
foreach ($bookingData->transportationServices as $service) {
if (0 === count($service->mapping)) {
unset($bookingData->transportationServices[$service->id]);
}
}
foreach ($bookingData->pickupsTo as $pickup) {
if (0 === count($pickup->mapping)) {
unset($bookingData->pickupsTo[$pickup->id]);
}
}
foreach ($bookingData->pickupsFro as $pickup) {
if (0 === count($pickup->mapping)) {
unset($bookingData->pickupsFro[$pickup->id]);
}
}
}
// Update participants' personal data
foreach ($formData->participants as $participant) {
// skip canceled participants
/**
* Updates participant personal data from form input.
*
* Only processes participants with status 'F' (active/confirmed participants).
* Updates all personal data fields and communication information.
*
* @param array $participants The participants array from the form
* @param object $bookingData The booking data object to update
*/
private function updateParticipantPersonalData(array $participants, object $bookingData): void
{
foreach ($participants as $participant) {
if ('F' !== $participant->status) {
continue;
}
$bookingData->participants[$participant->index]->firstName = $participant->firstName;
$bookingData->participants[$participant->index]->name = $participant->lastName;
$bookingData->participants[$participant->index]->dateOfBirth = $participant->dateOfBirth;
@@ -109,19 +248,40 @@ class BookingDataProcessor
$bookingData->participants[$participant->index]->communication->mobile = $participant->mobile;
}
}
}
// Update applicant's data with personal data of first participant
/**
* Synchronizes applicant data with first participant's physical characteristics.
*
* The applicant (booking holder) inherits physical data from the first participant.
*
* @param object $bookingData The booking data object to update
*/
private function syncApplicantData(object $bookingData): void
{
if (false !== $firstParticipant = reset($bookingData->participants)) {
$bookingData->applicant->height = $firstParticipant->height;
$bookingData->applicant->weight = $firstParticipant->weight;
$bookingData->applicant->shoeSize = $firstParticipant->shoeSize;
}
}
$payload = [
/**
* Builds the base API payload structure with booking information.
*
* Creates the main structure that will be populated with detailed data sections.
*
* @param object $bookingData The booking data object
*
* @return array The base payload structure
*/
private function buildBasePayload(object $bookingData): array
{
return [
'idbuchung' => $bookingData->id,
'status' => $bookingData->status,
'idagentur' => $bookingData->agencyId,
'idreise' => $bookingData->travelId,
'idreise' => $bookingData->dateId,
'idpartner' => $bookingData->hotelId,
'anmelder' => $bookingData->applicant->toPayload(),
'zahlung' => [
@@ -142,7 +302,18 @@ class BookingDataProcessor
'ferienzielunterbringung' => [],
],
];
}
/**
* Adds bank account information to the payload if present.
*
* Bank account information is required for direct debit payments.
*
* @param array $payload The payload array to modify
* @param object $bookingData The booking data object
*/
private function addBankAccountToPayload(array &$payload, object $bookingData): void
{
if (null !== $bookingData->bankAccount) {
$payload['zahlung']['bankverbindung'] = [
'@kreditinstitut' => $bookingData->bankAccount->bankName,
@@ -151,7 +322,18 @@ class BookingDataProcessor
'@kontoinhaber' => $bookingData->bankAccount->holder,
];
}
}
/**
* Builds the participant list section of the payload.
*
* Includes status and personal data for each participant.
*
* @param array $payload The payload array to modify
* @param object $bookingData The booking data object
*/
private function buildParticipantPayload(array &$payload, object $bookingData): void
{
foreach ($bookingData->participants as $index => $participant) {
$payload['teilnehmerliste']['teilnehmer'][] = [
'@id' => $index,
@@ -159,7 +341,19 @@ class BookingDataProcessor
...$participant->toPayload(),
];
}
}
/**
* Builds the services sections of the payload.
*
* Includes additional services, transportation services, and accommodation details
* with participant mappings and quantities.
*
* @param array $payload The payload array to modify
* @param object $bookingData The booking data object
*/
private function buildServicesPayload(array &$payload, object $bookingData): void
{
foreach ($bookingData->additionalServices as $service) {
$payload['zusatzleistungen']['zusatzleistung'][] = [
'@idleistung' => $service->id,
@@ -187,7 +381,18 @@ class BookingDataProcessor
'@zuordnung' => implode(',', $room->mapping),
];
}
}
/**
* Builds the pickup locations section of the payload.
*
* Only included in payload if there are actual pickup assignments for bus transportation.
*
* @param array $payload The payload array to modify
* @param object $bookingData The booking data object
*/
private function buildPickupPayload(array &$payload, object $bookingData): void
{
if (0 < count($bookingData->pickupsTo)) {
$payload['zustiege']['zustieg'] = [];
foreach ($bookingData->pickupsTo as $pickup) {
@@ -198,7 +403,5 @@ class BookingDataProcessor
];
}
}
return $payload;
}
}
+5 -5
View File
@@ -16,18 +16,18 @@ use Symfony\Component\Validator\Constraints as Assert;
class Address
{
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data'])]
public string $street = '';
public ?string $street = null;
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data'])]
public string $postCode = '';
public ?string $postCode = null;
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data'])]
public string $city = '';
public ?string $city = null;
public string $district = '';
public ?string $district = null;
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data'])]
public string $country = '';
public ?string $country = null;
/**
* Converts the address to API payload format.
+6 -6
View File
@@ -22,7 +22,7 @@ class Booking
public ?float $price = null;
public ?\DateTimeImmutable $bookingDate = null;
public ?string $travelName = null;
public ?int $travelId = null;
public ?int $dateId = null;
public ?string $travelCode = null;
public ?\DateTimeImmutable $travelDate = null;
public ?Travel $travelData = null;
@@ -88,8 +88,8 @@ class Booking
* Filters additional services by group and participant index mapping.
* Returns services that are assigned to the specified participant.
*
* @param int $participantIndex The participant index to filter by
* @param mixed $group The service group(s) to filter by
* @param int $participantIndex The participant index to filter by
* @param mixed $group The service group(s) to filter by
*
* @return array<Service> The filtered services for the participant
*/
@@ -108,14 +108,14 @@ class Booking
* Finds the transportation service that matches the participant index
* and travel direction (e.g., 'H' for outbound, 'R' for return).
*
* @param int $participantIndex The participant index to search for
* @param string $direction The travel direction ('H' or 'R')
* @param int $participantIndex The participant index to search for
* @param string $direction The travel direction ('H' or 'R')
*
* @return Service|null The matching transportation service or null if not found
*/
public function getTransportationServiceForParticipantAndDirection(
int $participantIndex,
string $direction
string $direction,
): ?Service {
foreach ($this->transportationServices as $service) {
if ($service->direction !== $direction) {
+3 -3
View File
@@ -15,14 +15,14 @@ use Symfony\Component\Validator\Constraints as Assert;
*/
class Communication
{
public string $phone = '';
public ?string $phone = null;
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data'])]
public string $mobile = '';
public ?string $mobile = null;
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data'])]
#[Assert\Email(message: 'Bitte eine gültige Adresse angeben', mode: 'strict', groups: ['personal_data'])]
public string $email = '';
public ?string $email = null;
public bool $newsletter = false;
+10 -10
View File
@@ -24,20 +24,20 @@ class PersonalData
public ?string $status = null;
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['Default', 'personal_data'])]
public string $name = '';
public ?string $name = null;
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['Default', 'personal_data'])]
public string $firstName = '';
public string $salutation = '';
public string $title = '';
public string $gender = '';
public string $nationality = '';
public string $height = '';
public string $shoeSize = '';
public string $weight = '';
public ?string $salutation = null;
public ?string $title = null;
public ?string $gender = null;
public ?string $nationality = null;
public ?string $height = null;
public ?string $shoeSize = null;
public ?string $weight = null;
public ?\DateTimeImmutable $dateOfBirth = null;
public string $remarks = '';
public ?string $remarks = null;
#[Assert\Valid(groups: ['personal_data'])]
public Address $address;
@@ -63,7 +63,7 @@ class PersonalData
{
// Ensure date of birth is populated
if (null === $dob = $this->dateOfBirth) {
$dob = new \DateTimeImmutable(self::DEFAULT_AGE_YEARS . ' years ago');
$dob = new \DateTimeImmutable(self::DEFAULT_AGE_YEARS.' years ago');
}
return [
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace App\BusProNet\Model;
use Symfony\Component\Serializer\Attribute\Groups;
class Product
{
#[Groups(['api:list'])]
public ?int $id = null;
#[Groups(['api:list'])]
public ?string $code = null;
#[Groups(['api:list'])]
public ?string $name = null;
}
+5 -5
View File
@@ -92,8 +92,8 @@ class Travel
* Filters additional services based on the provided group(s) and optionally
* by availability. Services are sorted alphabetically by label.
*
* @param mixed $group The service group(s) to filter by
* @param bool $availableOnly Whether to include only available services
* @param mixed $group The service group(s) to filter by
* @param bool $availableOnly Whether to include only available services
*
* @return array<Service> The filtered and sorted services array
*/
@@ -118,8 +118,8 @@ class Travel
* Filters transportation services based on travel direction and optionally
* by availability. Services are sorted by subtype.
*
* @param string $direction The travel direction to filter by
* @param bool $availableOnly Whether to include only available services
* @param string $direction The travel direction to filter by
* @param bool $availableOnly Whether to include only available services
*
* @return array<Service> The filtered and sorted transportation services
*/
@@ -193,7 +193,7 @@ class Travel
public function getAvailableRooms(): array
{
return array_filter($this->rooms, function (Room $room) {
return $room->available > 0 && $room->status === Constants::STATUS_AVAILABLE;
return $room->available > 0 && Constants::STATUS_AVAILABLE === $room->status;
});
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace App\BusProNet\Utility;
class DateCodeUtility
{
/**
* Sanitizes the travel code by removing dashes and slashes, and converting to uppercase.
*
* @param string $travelCode the travel code to sanitize
*
* @return string the sanitized travel code
*/
public function sanitize(string $travelCode): string
{
return str_replace(['-', '/'], '', strtoupper($travelCode));
}
/**
* Gets the base code (without the date part) from the travel code.
*
* @param string $travelCode the travel code
*
* @return string the base code in uppercase
*/
public function getBaseCode(string $travelCode): string
{
return strtoupper(substr($travelCode, 0, -6));
}
/**
* Extracts the date from the travel code and returns it as a DateTimeImmutable object.
*
* @param string $travelCode the travel code containing the date
*
* @return \DateTimeImmutable the extracted date, or null if invalid
*/
public function getDate(string $travelCode): \DateTimeImmutable
{
$datePart = substr($travelCode, -6);
return \DateTimeImmutable::createFromFormat('dmy', $datePart);
}
}
@@ -1,23 +0,0 @@
<?php
namespace App\BusProNet\Utility;
class TravelCodeUtility
{
public function sanitize(string $travelCode): string
{
return str_replace(['-', '/'], '', strtoupper($travelCode));
}
public function getBaseCode(string $travelCode): string
{
return strtoupper(substr($travelCode, 0, -6));
}
public function getDate(string $travelCode): \DateTimeImmutable
{
$datePart = substr($travelCode, -6);
return \DateTimeImmutable::createFromFormat('dmy', $datePart);
}
}
+73 -36
View File
@@ -5,8 +5,9 @@ namespace App\BusProNet\XmlLoader;
use App\BusProNet\Model\BaseData;
use App\BusProNet\Model\MutableData;
use App\BusProNet\Model\Travel;
use App\BusProNet\Utility\TravelCodeUtility;
use App\BusProNet\Utility\DateCodeUtility;
use App\BusProNet\XmlParser\TravelParser;
use League\Flysystem\FilesystemException;
use League\Flysystem\FilesystemOperator;
use League\Flysystem\StorageAttributes;
use Psr\Cache\InvalidArgumentException;
@@ -24,11 +25,11 @@ use Symfony\Contracts\Cache\ItemInterface;
class TravelLoader extends AbstractLoader
{
/**
* @param HotelLoader $hotelDataLoader Hotel data loader for hotel information
* @param TravelParser $travelParser Parser for XML travel data
* @param string $travelInfoBaseUrl Base URL for travel information pages
* @param CacheInterface $cache Cache interface for performance optimization
* @param FilesystemOperator $xmlExport Filesystem operator for XML file access
* @param HotelLoader $hotelDataLoader Hotel data loader for hotel information
* @param TravelParser $travelParser Parser for XML travel data
* @param string $travelInfoBaseUrl Base URL for travel information pages
* @param CacheInterface $cache Cache interface for performance optimization
* @param FilesystemOperator $xmlExport Filesystem operator for XML file access
*/
public function __construct(
private readonly HotelLoader $hotelDataLoader,
@@ -103,25 +104,55 @@ class TravelLoader extends AbstractLoader
}
/**
* Map a travel code to its corresponding travel ID.
* Map a date code to its corresponding date ID.
*
* Uses the cached files mapping to find the travel ID associated with
* the given travel code. Returns null if no matching travel is found.
* Uses the cached files mapping to find the date ID associated with
* the given date code. Returns null if no matching date is found.
*
* @param string $travelCode The travel code to look up
* @return int|null The travel ID or null if not found
* @param string $dateCode The date code to look up
*
* @return int|null The date ID or null if not found
*/
public function mapCodeToId(string $travelCode): ?int
public function mapCodeToId(string $dateCode): ?int
{
$mapping = $this->generateFilesMap();
$travelCodes = array_column($mapping, 'code', 'id');
$dateCodes = array_column($mapping, 'code', 'id');
if (false === $travelId = array_search($travelCode, $travelCodes)) {
if (false === $dateId = array_search($dateCode, $dateCodes)) {
return null;
}
return $travelId;
return $dateId;
}
/**
* Map a date ID to its corresponding product ID.
*
* Finds the product ID by looking up which XML file contains the given date ID.
* Since XML files are named with the product ID pattern (Ziel_x.xml), this method
* extracts the product ID from the file path.
*
* @param int $dateId The date/term ID to look up
*
* @return int|null The product ID or null if not found
*/
public function mapDateIdToProductId(int $dateId): ?int
{
$mapping = $this->generateFilesMap();
if (!isset($mapping[$dateId])) {
return null;
}
$filename = $mapping[$dateId]['file'];
// Extract product ID from filename (e.g., "Ziel_12345.xml" -> 12345)
if (preg_match('/Ziel_(\d+)\.xml$/', $filename, $matches)) {
return (int) $matches[1];
}
return null;
}
/**
@@ -130,26 +161,27 @@ class TravelLoader extends AbstractLoader
* Retrieves travel data from XML exports. If filename is provided, loads directly
* from that file. Otherwise, uses the cached mapping to find the appropriate file.
*
* @param int $travelId The travel ID to load
* @param int|null $hotelId Optional hotel ID for specific hotel data
* @param int $dateId The travel date ID to load
* @param int|null $hotelId Optional hotel ID for specific hotel data
* @param string|null $filename Optional filename to load from directly
*
* @return Travel|null The loaded travel object or null if not found
*/
public function loadById(int $travelId, ?int $hotelId = null, ?string $filename = null): ?Travel
public function loadById(int $dateId, ?int $hotelId = null, ?string $filename = null): ?Travel
{
if (null !== $filename) {
return $this->loadXml($travelId, $hotelId, $filename);
return $this->loadXml($dateId, $hotelId, $filename);
}
$mapping = $this->generateFilesMap();
if (false === isset($mapping[$travelId])) {
if (false === isset($mapping[$dateId])) {
return null;
}
$filename = $mapping[$travelId]['file'];
$filename = $mapping[$dateId]['file'];
return $this->loadXml($travelId, $hotelId, $filename);
return $this->loadXml($dateId, $hotelId, $filename);
}
/**
@@ -158,23 +190,28 @@ class TravelLoader extends AbstractLoader
* Reads and parses XML file content to extract travel information for
* the specified travel ID and optional hotel ID.
*
* @param int $travelId The travel ID to load
* @param int|null $hotelId Optional hotel ID for specific hotel data
* @param string $filename The XML filename to load from
* @param int $dateId The travel date ID to load
* @param int|null $hotelId Optional hotel ID for specific hotel data
* @param string $filename The XML filename to load from
*
* @return Travel|null The loaded travel object or null if not found
*/
private function loadXml(int $travelId, ?int $hotelId, string $filename): ?Travel
private function loadXml(int $dateId, ?int $hotelId, string $filename): ?Travel
{
$xml = $this->xmlExport->read($filename);
$crawler = new Crawler($xml);
try {
$xml = $this->xmlExport->read($filename);
$crawler = new Crawler($xml);
$travelNode = $crawler->filterXPath(sprintf('//reise/termin[@idbuspro="%d"]', $travelId));
$travelNode = $crawler->filterXPath(sprintf('//reise/termin[@idbuspro="%d"]', $dateId));
if (0 === $travelNode->count()) {
if (0 === $travelNode->count()) {
return null;
}
return $this->travelParser->parse($travelNode->first(), $hotelId);
} catch (FilesystemException $e) {
return null;
}
return $this->travelParser->parse($travelNode->first(), $hotelId);
}
/**
@@ -184,7 +221,7 @@ class TravelLoader extends AbstractLoader
* mutable data configuration. This controls which travel components
* can be modified during booking.
*
* @param Travel $travel The travel object to update
* @param Travel $travel The travel object to update
* @param BaseData $mutableData The mutability configuration data
*/
public function patchMutability(Travel $travel, BaseData $mutableData): void
@@ -220,7 +257,7 @@ class TravelLoader extends AbstractLoader
* Updates the availability status of additional and transportation
* services based on the provided availability data.
*
* @param Travel $travel The travel object to update
* @param Travel $travel The travel object to update
* @param BaseData $availabilities The availability data for services
*/
public function patchAvailabilities(Travel $travel, BaseData $availabilities): void
@@ -244,10 +281,10 @@ class TravelLoader extends AbstractLoader
*/
public function patchBookings(BaseData $bookings): void
{
$travelCodeUtility = new TravelCodeUtility();
$travelCodeUtility = new DateCodeUtility();
foreach ($bookings->getItems() as $booking) {
if (null === $travelId = $booking->travelId) {
if (null === $travelId = $booking->dateId) {
continue;
}
@@ -59,8 +59,11 @@ class ApiResponseParser extends AbstractParser
return (new AvailabilitiesParser())->parseRooms($resultNode);
case ApiClient::TYPE_BOOKING_UPDATE:
return (new BookingUpdateParser())->parse($resultNode);
case ApiClient::TYPE_PRODUCTS:
return (new ProductsParser())->parse($resultNode);
case ApiClient::TYPE_PRODUCT_DATA:
$travelNode = $crawler->filterXPath('//reise/termin');
return (new TravelParser())->parse($travelNode->first(), ...$additionalArgs);
}
+1 -2
View File
@@ -8,7 +8,6 @@ use App\BusProNet\Model\BankAccount;
use App\BusProNet\Model\Booking;
use App\BusProNet\Model\Communication;
use App\BusProNet\Model\PersonalData;
use App\BusProNet\Model\Service;
use Symfony\Component\DomCrawler\Crawler;
class BookingParser extends AbstractParser
@@ -40,7 +39,7 @@ class BookingParser extends AbstractParser
$travelData = $node->filterXPath('//reise');
$booking->travelName = $travelData->attr('bezeichnung');
$booking->travelId = $this->getIntOrNullValue($node->filterXPath('//idreise'));
$booking->dateId = $this->getIntOrNullValue($node->filterXPath('//idreise'));
$booking->travelCode = $travelData->attr('code');
$booking->travelDate = $this->stringToDate($travelData->attr('termin'));
+1 -1
View File
@@ -27,7 +27,7 @@ class BookingsParser extends AbstractParser
$booking->price = $this->getFloatOrNullValue($node->filterXPath('//preis'));
$booking->bookingDate = $this->getDateTimeOrNullValue($node->filterXPath('//buchungsdatum'));
$booking->travelName = $this->getStringOrNullValue($node->filterXPath('//reise'));
$booking->travelId = $this->getIntOrNullValue($node->filterXPath('//idreise'));
$booking->dateId = $this->getIntOrNullValue($node->filterXPath('//idreise'));
$booking->travelDate = $this->getDateOrNullValue($node->filterXPath('//reisedatum'));
$booking->hasDocuments = $this->getBoolValue($node->filterXPath('//reisedokument'));
$booking->payment = $this->getFloatOrNullValue($node->filterXPath('//zahlung'));
@@ -0,0 +1,31 @@
<?php
namespace App\BusProNet\XmlParser;
use App\BusProNet\Model\BaseData;
use App\BusProNet\Model\Product;
use Symfony\Component\DomCrawler\Crawler;
class ProductsParser extends AbstractParser
{
public function parse(Crawler $result): BaseData
{
$products = [];
$result
->filterXPath('//produkte/produkt')
->each(function (Crawler $node) use (&$products) {
if (false === empty($node->attr('code'))) {
$product = new Product();
$product->id = (int) $node->attr('id');
$product->code = (string) $node->attr('code');
$product->name = (string) $node->attr('bezeichnung');
$products[$product->id] = $product;
}
})
;
return new BaseData($products);
}
}
+11 -3
View File
@@ -28,8 +28,9 @@ class TravelParser extends AbstractParser
* Extracts all travel-related data from the XML node including dates,
* pricing, services, rooms, pickups, and guide information.
*
* @param Crawler $node The XML node containing travel data
* @param Crawler $node The XML node containing travel data
* @param int|null $hotelId Optional hotel ID for specific hotel data
*
* @return Travel The parsed travel object
*/
public function parse(Crawler $node, ?int $hotelId = null): Travel
@@ -67,6 +68,7 @@ class TravelParser extends AbstractParser
* the XML structure. Each group contains multiple selection options.
*
* @param Crawler $node The XML node containing selection group data
*
* @return array<int, CrmSelectionGroup> Array of selection groups indexed by ID
*/
public function getSelectionGroups(Crawler $node): array
@@ -104,6 +106,7 @@ class TravelParser extends AbstractParser
* from the XML structure with pricing and availability information.
*
* @param Crawler $node The XML node containing additional service data
*
* @return array<int, Service> Array of additional services indexed by ID
*/
public function getAdditionalServices(Crawler $node): array
@@ -139,6 +142,7 @@ class TravelParser extends AbstractParser
* with scheduling, pricing, and direction information.
*
* @param Crawler $node The XML node containing transportation service data
*
* @return array<int, Service> Array of transportation services indexed by ID
*/
public function getTransportationServices(Crawler $node): array
@@ -177,6 +181,7 @@ class TravelParser extends AbstractParser
* and extracts name and phone contact details.
*
* @param Crawler $travelNode The XML node containing travel data
*
* @return Guide|null The guide object or null if no guide found
*/
public function getGuide(Crawler $travelNode): ?Guide
@@ -204,8 +209,9 @@ class TravelParser extends AbstractParser
* Date and time parsing is only performed for outbound journeys when
* a default date is provided.
*
* @param Crawler $node The XML node containing pickup data
* @param Crawler $node The XML node containing pickup data
* @param \DateTimeImmutable|null $defaultDate Default date for time parsing (outbound only)
*
* @return array<int, Pickup> Array of pickup locations indexed by ID
*/
public function getPickups(Crawler $node, ?\DateTimeImmutable $defaultDate = null): array
@@ -237,8 +243,9 @@ class TravelParser extends AbstractParser
* Retrieves the hotel node either by specific hotel ID or returns
* the first hotel node if no ID is specified.
*
* @param Crawler $node The XML node containing hotel data
* @param Crawler $node The XML node containing hotel data
* @param int|null $hotelId Optional hotel ID to filter by
*
* @return Crawler The hotel XML node
*/
public function getHotelNode(Crawler $node, ?int $hotelId): Crawler
@@ -260,6 +267,7 @@ class TravelParser extends AbstractParser
* and board options from the hotel XML structure.
*
* @param Crawler $node The XML node containing room data
*
* @return array<int, Room> Array of rooms indexed by room ID
*/
public function getRooms(Crawler $node): array
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace App\Controller\Api;
use App\BusProNet\ApiClient;
use App\BusProNet\Exception\ApiClientException;
use Psr\Cache\InvalidArgumentException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
#[Route('/api')]
#[IsGranted('ROLE_OAUTH2_API')]
class ProductController extends AbstractController
{
public function __construct(
private readonly ApiClient $apiClient,
private readonly CacheInterface $cache,
) {
}
#[Route(path: '/products', name: 'api_products')]
public function index(): JsonResponse
{
try {
$products = $this->cache->get('api_products', function (ItemInterface $item) {
$item->expiresAfter(3600); // 1 hour cache
return $this->apiClient->getProducts();
});
return $this->json($products);
} catch (InvalidArgumentException|ApiClientException $e) {
return $this->json(['error' => $e->getMessage(), 'type' => 'api_error']);
}
}
}
+56 -87
View File
@@ -5,116 +5,91 @@ namespace App\Controller\Api;
use App\BusProNet\ApiClient;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Model\Travel;
use App\BusProNet\Utility\TravelCodeUtility;
use App\BusProNet\XmlLoader\HotelLoader;
use App\BusProNet\XmlLoader\PickupLoader;
use App\BusProNet\XmlLoader\TravelLoader;
use Psr\Cache\InvalidArgumentException;
use App\BusProNet\Utility\DateCodeUtility;
use App\Service\TravelDataService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapDateTime;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
#[Route('/api')]
#[IsGranted('ROLE_OAUTH2_API')]
class TravelController extends AbstractController
{
public function __construct(
private readonly TravelLoader $travelXmlLoader,
private readonly HotelLoader $hotelXmlLoader,
private readonly PickupLoader $pickupXmlLoader,
private readonly ApiClient $apiClient,
private readonly CacheInterface $cache,
private readonly TravelDataService $travelDataService,
) {
}
#[Route(path: '/travels', name: 'api_travel_mapping')]
public function mapping(): JsonResponse
{
$mapping = $this->travelXmlLoader->generateFilesMap();
$mapping = $this->travelDataService->generateFilesMap();
return $this->json($mapping);
}
#[Route(
path: '/travels/{travelId}/{hotelId}',
path: '/travels/{dateId}/{hotelId}',
name: 'api_travel_single_id',
requirements: ['travelId' => '\d+', 'hotelId' => '\d+'],
requirements: ['dateId' => '\d+', 'hotelId' => '\d+'],
defaults: ['hotelId' => null],
)]
public function singleById(int $travelId, ?int $hotelId = null): JsonResponse
public function byId(Request $request, int $dateId, ?int $hotelId = null): JsonResponse
{
$travel = $this->loadCached($travelId, $hotelId);
$source = $request->query->get('source');
$preferRemote = $request->query->getBoolean('prefer_remote');
$travel = $this->loadTravelData($dateId, $hotelId, $source, $preferRemote);
if (null === $travel) {
return $this->json(['message' => 'Travel not found'], Response::HTTP_NOT_FOUND);
}
return $this->json($travel, Response::HTTP_OK, [], ['groups' => ['api:list', 'api:single']]);
}
#[Route(
path: '/travels/{travelId}/remote',
name: 'api_travel_single_id_remote',
requirements: ['travelId' => '\d+'],
)]
public function singleByIdRemote(int $travelId): JsonResponse
{
$cacheKey = sprintf('bpn_travel_remote_%d', $travelId);
try {
$result = $this->cache->get($cacheKey, function (ItemInterface $item) use ($travelId) {
$item->expiresAfter(300); // 5 minutes cache for remote API calls
try {
return $this->apiClient->getTravelData($travelId);
} catch (ApiClientException $e) {
// Return error info instead of throwing to avoid cache wrapping issues
return ['error' => $e->getMessage(), 'type' => 'api_error'];
}
});
// Check if result is an error
if (is_array($result) && isset($result['error'], $result['type'])) {
return $this->json($result['error'], Response::HTTP_INTERNAL_SERVER_ERROR);
}
return $this->json($result, Response::HTTP_OK, [], ['groups' => ['api:list', 'api:single']]);
} catch (InvalidArgumentException) {
return $this->json('Cache error occurred', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
#[Route(
path: '/travels/{travelCode}/{hotelCode}',
path: '/travels/{dateCode}/{hotelCode}',
name: 'api_travel_single_code',
defaults: ['hotelCode' => null],
)]
public function singleByCode(string $travelCode, ?string $hotelCode = null): JsonResponse
public function byCode(Request $request, $dateCode, ?string $hotelCode = null): JsonResponse
{
// sanitize travel code by removing potential dividers
$travelCode = (new TravelCodeUtility())->sanitize($travelCode);
// sanitize date code by removing potential dividers
$dateCode = (new DateCodeUtility())->sanitize($dateCode);
$travelId = $this->travelXmlLoader->mapCodeToId($travelCode);
$hotelId = $hotelCode ? $this->hotelXmlLoader->mapCodeToId($travelCode) : null;
$dateId = $this->travelDataService->mapDateCodeToId($dateCode);
$hotelId = $hotelCode ? $this->travelDataService->mapHotelCodeToId($hotelCode) : null;
if (null === $travelId) {
if (null === $dateId) {
return $this->json(['message' => 'Not found'], Response::HTTP_NOT_FOUND);
}
$travel = $this->loadCached($travelId, $hotelId);
$source = $request->query->get('source');
$preferRemote = $request->query->getBoolean('prefer_remote');
$travel = $this->loadTravelData($dateId, $hotelId, $source, $preferRemote);
if (null === $travel) {
return $this->json(['message' => 'Travel not found'], Response::HTTP_NOT_FOUND);
}
return $this->json($travel, Response::HTTP_OK, [], ['groups' => ['api:list', 'api:single']]);
}
#[Route('/travels/{travelId}/{hotelId}/{dateTo}/availability', name: 'api_travel_hotel_availability')]
#[Route('/travels/{dateId}/{hotelId}/{dateTo}/availability', name: 'api_travel_hotel_availability')]
public function hotelAvailability(
int $travelId,
int $dateId,
int $hotelId,
#[MapDateTime(format: 'Y-m-d')] \DateTimeImmutable $dateTo
#[MapDateTime(format: 'Y-m-d')] \DateTimeImmutable $dateTo,
): JsonResponse {
try {
$result = $this->apiClient->getHotelAvailability($travelId, $hotelId, $dateTo);
$result = $this->apiClient->getHotelAvailability($dateId, $hotelId, $dateTo);
return $this->json($result);
} catch (ApiClientException $e) {
@@ -122,32 +97,26 @@ class TravelController extends AbstractController
}
}
private function loadCached(int $travelId, ?int $hotelId = null): ?Travel
{
$cacheKey = sprintf('bpn_travel_%d_%d', $travelId, $hotelId ?? 0);
try {
return $this->cache->get($cacheKey, function (ItemInterface $item) use ($travelId, $hotelId) {
$item->expiresAfter(60);
try {
$travel = $this->travelXmlLoader->loadById($travelId, $hotelId);
if (null === $travel) {
return null;
}
$this->pickupXmlLoader->patchPickupsDetails($travel);
$this->hotelXmlLoader->patchHotelDetails($travel);
return $travel;
} catch (\Exception) {
// Return null for any loader exceptions to avoid cache wrapping issues
return null;
}
});
} catch (InvalidArgumentException) {
return null;
}
/**
* Loads travel data based on the provided parameters.
*
* @param int $dateId the ID of the date for which travel data is requested
* @param int|null $hotelId the ID of the hotel for which travel data is requested (optional)
* @param string|null $source the source of the travel data ('local', 'remote', or null for default behavior)
* @param bool $preferRemote whether to prefer remote data when the source is not explicitly specified
*
* @return Travel|null returns a Travel object if data is found, or null if no data is available
*/
private function loadTravelData(
int $dateId,
?int $hotelId = null,
?string $source = null,
bool $preferRemote = false,
): ?Travel {
return match ($source) {
'local' => $this->travelDataService->getTravelDataFromXml($dateId, $hotelId),
'remote' => $this->travelDataService->getTravelDataFromApi($dateId, $hotelId),
default => $this->travelDataService->getTravelData($dateId, $hotelId, $preferRemote),
};
}
}
+31 -17
View File
@@ -2,19 +2,20 @@
namespace App\Controller\Booking;
use App\Service\BookingService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Request;
use App\Form\BookingCreateStep1Type;
use App\Form\BookingCreateStep2Type;
use App\Form\Model\ParticipantDto;
use App\Service\BookingService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class CreateController extends AbstractController
{
public function __construct(
private readonly BookingService $bookingCreateService,
) {
) {
}
#[Route('/bookings/create', name: 'app_booking_create_step_1')]
@@ -25,14 +26,16 @@ class CreateController extends AbstractController
// Validate step access - allow step 1 or redirect to current step
$this->validateStepAccess($bookingCreateDto, 1);
$form = $this->createForm(BookingCreateStep1Type::class, $bookingCreateDto);
$form = $this->createForm(BookingCreateStep1Type::class, $bookingCreateDto, [
'validation_groups' => ['booking_create_step_1'],
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$bookingCreateDto->currentStep = 2;
$this->bookingCreateService->saveBookingCreateDto($request, $bookingCreateDto);
return $this->redirectToRoute('app_booking_create_step_2');
}
@@ -46,18 +49,30 @@ class CreateController extends AbstractController
public function participants(Request $request): Response
{
$bookingCreateDto = $this->bookingCreateService->getOrCreateBookingCreateDto($request);
// Validate step access
$this->validateStepAccess($bookingCreateDto, 2);
$form = $this->createForm(BookingCreateStep2Type::class, $bookingCreateDto);
// Ensure correct number of participants
if ($bookingCreateDto->getParticipantsCount() !== count($bookingCreateDto->participants)) {
$participants = $bookingCreateDto->participants;
$bookingCreateDto->participants = [];
for ($i = 0; $i < $bookingCreateDto->getParticipantsCount(); ++$i) {
$bookingCreateDto->participants[] = $participants[$i] ?? new ParticipantDto();
}
}
$form = $this->createForm(BookingCreateStep2Type::class, $bookingCreateDto, [
'attr' => ['novalidate' => 'novalidate'],
'validation_groups' => ['booking_create_step_2'],
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$bookingCreateDto->currentStep = 3;
$this->bookingCreateService->saveBookingCreateDto($request, $bookingCreateDto);
return $this->redirectToRoute('app_booking_create_step_3');
}
@@ -74,7 +89,7 @@ class CreateController extends AbstractController
// Validate step access
$this->validateStepAccess($bookingCreateDto, 3);
return $this->render('booking/confirm.html.twig', [
'bookingCreateDto' => $bookingCreateDto,
]);
@@ -84,14 +99,13 @@ class CreateController extends AbstractController
* Validates step access and redirects if necessary.
*
* @param \App\Form\Model\BookingCreateDto $bookingCreateDto
* @param int $expectedStep
*/
private function validateStepAccess($bookingCreateDto, int $expectedStep): void
{
// Allow access to current step or any previous step
if ($expectedStep > $bookingCreateDto->currentStep) {
$this->addFlash('error', 'Bitte erst die vorherigen Schritte abschließen.');
$this->redirectToCurrentStep($bookingCreateDto);
}
}
@@ -102,17 +116,17 @@ class CreateController extends AbstractController
private function redirectToCurrentStep($bookingCreateDto): void
{
$routeParams = [
'travel_id' => $bookingCreateDto->travelData->id,
'date_id' => $bookingCreateDto->travelData->id,
'hotel_id' => $bookingCreateDto->travelData->hotelId,
];
$route = match ($bookingCreateDto->currentStep) {
1 => 'app_booking_create_step_1',
2 => 'app_booking_create_step_2',
2 => 'app_booking_create_step_2',
3 => 'app_booking_create_step_3',
default => 'app_booking_create_step_1',
};
$this->redirectToRoute($route, $routeParams);
}
}
}
@@ -16,6 +16,7 @@ use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Contracts\Cache\CacheInterface;
use function Symfony\Component\String\u;
class DownloadController extends AbstractController
+10 -18
View File
@@ -6,12 +6,12 @@ use App\BusProNet\ApiClient;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Model\Notification;
use App\BusProNet\XmlLoader\PickupLoader;
use App\BusProNet\XmlLoader\TravelLoader;
use App\Controller\Traits\BookingDataTrait;
use App\Entity\User;
use App\Form\BookingEditType;
use App\Form\Model\BookingEditDto;
use App\Security\Crypt;
use App\Service\TravelDataService;
use Psr\Cache\InvalidArgumentException;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -28,7 +28,7 @@ class EditController extends AbstractController
public function __construct(
private readonly ApiClient $apiClient,
private readonly TravelLoader $travelDataLoader,
private readonly TravelDataService $travelDataService,
private readonly PickupLoader $pickupDataLoader,
private readonly CacheInterface $cache,
private readonly Security $security,
@@ -58,7 +58,7 @@ class EditController extends AbstractController
$this->denyAccessUnlessGranted('EDIT', $bookingData);
// Load according travel data
$travelData = $this->travelDataLoader->loadById($bookingData->travelId);
$travelData = $this->travelDataService->getTravelData($bookingData->dateId);
if (null === $travelData) {
$this->addFlash('error', 'Reisedaten nicht (mehr) verfügbar');
@@ -66,34 +66,26 @@ class EditController extends AbstractController
return $this->redirectToRoute('app_bookings');
}
// Fetch mutability information via API
try {
$mutableData = $this->apiClient->getMutableData($bookingData->travelId);
} catch (ApiClientException $e) {
$this->addFlash('error', 'Reisedaten nicht (mehr) verfügbar');
// Fetch mutability and availability information via service
$mutableData = $this->travelDataService->getMutabilityData($bookingData->dateId);
$availabilities = $this->travelDataService->getAvailabilityData($bookingData->dateId);
return $this->redirectToRoute('app_bookings');
}
// Fetch availability information via API
try {
$availabilities = $this->apiClient->getAvailabilities($bookingData->travelId);
} catch (ApiClientException $e) {
if (null === $mutableData || null === $availabilities) {
$this->addFlash('error', 'Reisedaten nicht (mehr) verfügbar');
return $this->redirectToRoute('app_bookings');
}
// Patch travel data with additional information from above
$this->travelDataLoader->patchAvailabilities($travelData, $availabilities);
$this->travelDataLoader->patchMutability($travelData, $mutableData);
$this->pickupDataLoader->patchPickupsDetails($travelData);
$this->travelDataService->patchAvailabilities($travelData, $availabilities);
$this->travelDataService->patchMutability($travelData, $mutableData);
// Create DTO for form
$formData = BookingEditDto::fromBooking($bookingData, $travelData);
$form = $this->createForm(BookingEditType::class, $formData, [
'attr' => ['novalidate' => 'novalidate'],
'validation_groups' => ['booking_edit'],
]);
$form->handleRequest($request);
+4 -3
View File
@@ -26,9 +26,9 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
class PersonalDataController extends AbstractController
{
/**
* @param ApiClient $apiClient BusProNet API client for data operations
* @param Crypt $crypt Encryption service for password handling
* @param LoggerInterface $logger Logger for audit trails and debugging
* @param ApiClient $apiClient BusProNet API client for data operations
* @param Crypt $crypt Encryption service for password handling
* @param LoggerInterface $logger Logger for audit trails and debugging
*/
public function __construct(
private readonly ApiClient $apiClient,
@@ -45,6 +45,7 @@ class PersonalDataController extends AbstractController
* communication details. Uses the Post-Redirect-Get pattern for form processing.
*
* @param Request $request The HTTP request containing form data
*
* @return Response The rendered personal data page or redirect response
*
* @throws ApiClientException When BusProNet API communication fails
+44 -2
View File
@@ -2,15 +2,57 @@
namespace App\Form;
use App\BusProNet\Form\CountryType;
use App\Form\Model\ParticipantDto;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\BirthdayType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use App\Form\Model\ParticipantDto;
class BookingCreateParticipantType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('firstName', TextType::class, [
'label' => 'Vorname',
])
->add('lastName', TextType::class, [
'label' => 'Nachname',
])
->add('dateOfBirth', BirthdayType::class, [
'label' => 'Geburtsdatum',
'html5' => true,
'widget' => 'single_text',
'input' => 'datetime_immutable',
])
->add('gender', ChoiceType::class, [
'label' => 'Geschlecht',
'required' => false,
'placeholder' => 'keine Angabe',
'choices' => [
'männlich' => 'M',
'weiblich' => 'W',
'divers' => 'D',
],
])
->add('nationality', CountryType::class, [
'label' => 'Nationalität',
'property' => 'nationality',
'preferred_choices' => ['D', 'A', 'CH'],
])
->add('email', EmailType::class, [
'label' => 'E-Mail',
'required' => false,
])
->add('mobile', TextType::class, [
'label' => 'Telefon (mobil)',
'required' => false,
])
;
}
public function configureOptions(OptionsResolver $resolver): void
@@ -19,4 +61,4 @@ class BookingCreateParticipantType extends AbstractType
'data_class' => ParticipantDto::class,
]);
}
}
}
+4 -5
View File
@@ -2,12 +2,11 @@
namespace App\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\OptionsResolver\OptionsResolver;
use App\Form\Model\BookingCreateDto;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use App\Form\RoomSelectType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class BookingCreateStep1Type extends AbstractType
{
@@ -34,4 +33,4 @@ class BookingCreateStep1Type extends AbstractType
'data_class' => BookingCreateDto::class,
]);
}
}
}
+6 -3
View File
@@ -2,11 +2,11 @@
namespace App\Form;
use App\Form\Model\BookingCreateDto;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use App\Form\Model\BookingCreateDto;
class BookingCreateStep2Type extends AbstractType
{
@@ -15,6 +15,9 @@ class BookingCreateStep2Type extends AbstractType
$builder
->add('participants', CollectionType::class, [
'entry_type' => BookingCreateParticipantType::class,
'allow_add' => false,
'allow_delete' => false,
'by_reference' => false,
]);
}
@@ -24,4 +27,4 @@ class BookingCreateStep2Type extends AbstractType
'data_class' => BookingCreateDto::class,
]);
}
}
}
+11 -4
View File
@@ -9,7 +9,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
class BookingCreateDto
{
public int $currentStep = 1;
#[Assert\Valid]
/**
* @var array<int, RoomSelectionDto>
@@ -26,7 +26,7 @@ class BookingCreateDto
{
}
#[Assert\Callback]
#[Assert\Callback(groups: ['booking_create_step_1'])]
public function assertValidRoomSelections(ExecutionContextInterface $context): void
{
$participantsCount = $this->getParticipantsCount();
@@ -57,8 +57,15 @@ class BookingCreateDto
foreach ($this->roomSelections as $roomSelection) {
$room = $rooms[$roomSelection->roomId];
$participantsCount += $room->minPax * $roomSelection->quantity;
}
}
return $participantsCount;
}
}
public function getSelectedRooms(): array
{
return array_filter($this->roomSelections, function (RoomSelectionDto $roomSelection) {
return 0 < $roomSelection->quantity;
});
}
}
+6 -6
View File
@@ -8,7 +8,7 @@ use App\BusProNet\Model\Service;
use App\Validator\Constraints as AppAssert;
use Symfony\Component\Validator\Constraints as Assert;
#[AppAssert\Participant]
#[AppAssert\Participant(groups: ['booking_edit'])]
class ParticipantDto
{
public ?int $index = null;
@@ -17,10 +17,10 @@ class ParticipantDto
public ?string $status = null;
public bool $mutable = false;
#[Assert\NotBlank(message: 'Bitte angeben')]
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['booking_edit', 'booking_create_step_2'])]
public ?string $firstName = null;
#[Assert\NotBlank(message: 'Bitte angeben')]
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['booking_edit', 'booking_create_step_2'])]
public ?string $lastName = null;
public ?string $title = null;
public ?string $gender = null;
@@ -29,11 +29,11 @@ class ParticipantDto
public ?string $shoeSize = null;
public ?string $weight = null;
#[Assert\NotNull(message: 'Bitte angeben')]
#[Assert\NotNull(message: 'Bitte angeben', groups: ['booking_edit', 'booking_create_step_2'])]
public ?\DateTimeImmutable $dateOfBirth = null;
#[Assert\NotBlank(message: 'Bitte angeben')]
#[Assert\Email(message: 'Bitte eine gültige E-Mail Adresse angeben', mode: 'strict')]
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['booking_edit', 'booking_create_step_2'])]
#[Assert\Email(message: 'Bitte eine gültige E-Mail Adresse angeben', mode: 'strict', groups: ['booking_edit', 'booking_create_step_2'])]
public ?string $email = null;
public ?string $mobile = null;
+1 -1
View File
@@ -6,7 +6,7 @@ use Symfony\Component\Validator\Constraints as Assert;
class RoomSelectionDto
{
#[Assert\NotNull(message: 'Bitte eine Zimmerkategorie auswählen')]
#[Assert\NotNull(message: 'Bitte eine Zimmerkategorie auswählen', groups: ['booking_create_step_1'])]
public ?int $roomId = null;
public ?string $roomLabel = null;
+6 -6
View File
@@ -2,14 +2,14 @@
namespace App\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use App\Form\Model\RoomSelectionDto;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;
class RoomSelectType extends AbstractType
{
@@ -37,4 +37,4 @@ class RoomSelectType extends AbstractType
'data_class' => RoomSelectionDto::class,
]);
}
}
}
+11 -20
View File
@@ -3,18 +3,16 @@
namespace App\Service;
use App\BusProNet\Model\Room;
use App\BusProNet\XmlLoader\HotelLoader;
use App\BusProNet\XmlLoader\TravelLoader;
use App\Form\Model\BookingCreateDto;
use App\Form\Model\RoomSelectionDto;
use App\Service\TravelDataService;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class BookingService
{
public function __construct(
private readonly TravelLoader $travelDataLoader,
private readonly HotelLoader $hotelDataLoader,
private readonly TravelDataService $travelDataService,
) {
}
@@ -23,30 +21,23 @@ class BookingService
$bookingUuid = $request->query->get('uid');
$bookingCreateDto = $request->getSession()->get('booking_create');
// No UID parameter - try to get existing DTO from session
if (null === $bookingUuid) {
return $bookingCreateDto ?? throw new NotFoundHttpException('No booking data found. Please start from the beginning.');
// No UID parameter - return existing DTO from session if available
if (null === $bookingUuid && null !== $bookingCreateDto) {
return $bookingCreateDto;
}
// Create a new DTO - we need travel_id and hotel_id for this
$travelId = $request->query->getInt('travel_id');
// Create a new DTO - we need date_id and hotel_id for this
$dateId = $request->query->getInt('date_id');
$hotelId = $request->query->getInt('hotel_id');
if (0 === $travelId || 0 === $hotelId) {
throw new NotFoundHttpException('Missing travel_id or hotel_id parameters');
if (0 === $dateId || 0 === $hotelId) {
throw new NotFoundHttpException('Missing date_id or hotel_id parameters');
}
$travelData = $this->travelDataLoader->loadById($travelId, $hotelId);
$travelData = $this->travelDataService->getTravelData($dateId, $hotelId);
if (null === $travelData) {
throw new NotFoundHttpException('Travel data not found');
throw new NotFoundHttpException(sprintf('Travel data not found for date ID %d and hotel ID %d', $dateId, $hotelId));
}
$hotelData = $this->hotelDataLoader->loadById($hotelId);
if (null === $hotelData) {
throw new NotFoundHttpException('Hotel data not found');
}
$travelData->hotel = $hotelData;
$roomsIdsAndQuantities = $this->processRoomQuantities($request);
$availableRooms = $travelData->getAvailableRooms();
+552
View File
@@ -0,0 +1,552 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\BusProNet\ApiClient;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Model\BaseData;
use App\BusProNet\Model\Notification;
use App\BusProNet\Model\Travel;
use App\BusProNet\XmlLoader\HotelLoader;
use App\BusProNet\XmlLoader\PickupLoader;
use App\BusProNet\XmlLoader\TravelLoader;
use Psr\Cache\InvalidArgumentException;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
/**
* Unified service for retrieving travel data from both local XML files and remote API.
*
* This service provides a unified interface for accessing travel data regardless of source,
* supporting automatic fallback between local XML files and remote API calls. It handles caching,
* error recovery, and data enrichment for both data sources.
*/
class TravelDataService
{
public const SOURCE_LOCAL = 'local';
public const SOURCE_REMOTE = 'remote';
public function __construct(
private readonly TravelLoader $travelLoader,
private readonly HotelLoader $hotelLoader,
private readonly PickupLoader $pickupLoader,
private readonly ApiClient $apiClient,
private readonly CacheInterface $cache,
private readonly LoggerInterface $logger,
private readonly bool $preferRemote = false,
private readonly bool $enableFallback = true,
) {
}
/**
* Retrieve travel data with automatic source selection and fallback.
*
* Attempts to load travel data from the preferred source first, then falls back
* to the alternative source if the primary fails. Handles caching and enrichment
* of data from both sources.
*
* @param int $dateId The travel date ID to retrieve
* @param int|null $hotelId Optional hotel ID for specific hotel data
* @param bool $preferRemote Whether to prefer remote API over XML for this call
* @param bool $enableCache Whether to use caching for this request
*
* @return Travel|null The travel data or null if not found in any source
*/
public function getTravelData(
int $dateId,
?int $hotelId = null,
?bool $preferRemote = null,
bool $enableCache = true,
): ?Travel {
$preferRemote = $preferRemote ?? $this->preferRemote;
$cacheKey = sprintf('travel_unified_%d_%d_%s', $dateId, $hotelId ?? 0, $preferRemote ? 'remote' : 'local');
if (!$enableCache) {
return $this->loadTravelDataUncached($dateId, $hotelId, $preferRemote);
}
try {
return $this->cache->get($cacheKey, function (ItemInterface $item) use ($dateId, $hotelId, $preferRemote) {
$item->expiresAfter(300); // 5 minutes cache
return $this->loadTravelDataUncached($dateId, $hotelId, $preferRemote);
});
} catch (InvalidArgumentException $e) {
$this->logger->error('Cache error in TravelDataService', [
'dateId' => $dateId,
'hotelId' => $hotelId,
'error' => $e->getMessage(),
]);
return $this->loadTravelDataUncached($dateId, $hotelId, $preferRemote);
}
}
/**
* Retrieve travel data specifically from XML files.
*
* Loads travel data from local XML files with full data enrichment including
* hotel details and pickup information.
*
* @param int $dateId The travel date ID to retrieve
* @param int|null $hotelId Optional hotel ID for specific hotel data
*
* @return Travel|null The travel data or null if not found in XML
*/
public function getTravelDataFromXml(int $dateId, ?int $hotelId = null): ?Travel
{
try {
$travel = $this->travelLoader->loadById($dateId, $hotelId);
if (null === $travel) {
$this->logger->debug('Travel not found in XML', [
'dateId' => $dateId,
'hotelId' => $hotelId,
]);
return null;
}
$this->enrichTravelData($travel);
$this->logger->debug('Travel data loaded from XML', [
'dateId' => $dateId,
'hotelId' => $hotelId,
'travelId' => $travel->id,
]);
return $travel;
} catch (\Exception $e) {
$this->logger->error('Failed to load travel data from XML', [
'dateId' => $dateId,
'hotelId' => $hotelId,
'error' => $e->getMessage(),
]);
return null;
}
}
/**
* Retrieve travel data specifically from remote API.
*
* Loads travel data from the remote BusProNet API. Note that the API uses
* product IDs rather than date IDs, so mapping is performed internally.
*
* @param int $dateId The travel date ID to retrieve
* @param int|null $hotelId Optional hotel ID for specific hotel data
*
* @return Travel|null The travel data or null if not found via API
*/
public function getTravelDataFromApi(int $dateId, ?int $hotelId = null): ?Travel
{
try {
// Map dateId to productId for API call
$productId = $this->mapDateIdToProductId($dateId);
if (null === $productId) {
$this->logger->debug('Cannot map dateId to productId for API call', [
'dateId' => $dateId,
'hotelId' => $hotelId,
]);
return null;
}
$result = $this->apiClient->getTravelData($productId, $hotelId);
if (!$result instanceof Travel) {
$this->logger->debug('API returned non-travel result', [
'dateId' => $dateId,
'hotelId' => $hotelId,
'productId' => $productId,
'resultType' => get_class($result),
]);
return null;
}
$this->logger->debug('Travel data loaded from API', [
'dateId' => $dateId,
'hotelId' => $hotelId,
'productId' => $productId,
'travelId' => $result->id,
]);
return $result;
} catch (ApiClientException $e) {
$this->logger->error('Failed to load travel data from API', [
'dateId' => $dateId,
'hotelId' => $hotelId,
'error' => $e->getMessage(),
]);
return null;
}
}
/**
* Check if travel data exists in XML files.
*
* Performs a lightweight check to determine if travel data exists in XML
* files without loading the full travel object.
*
* @param int $dateId The travel date ID to check
* @param int|null $hotelId Optional hotel ID for specific hotel data
*
* @return bool True if travel data exists in XML files
*/
public function existsInXml(int $dateId, ?int $hotelId = null): bool
{
$mapping = $this->generateFilesMap();
if (false === isset($mapping[$dateId])) {
return false;
}
// If hotelId is specified, check if it exists in the travel's hotels
if (null !== $hotelId && false === isset($mapping[$dateId]['hotels'][$hotelId])) {
return false;
}
return true;
}
/**
* Get information about available data sources for a travel.
*
* Returns information about which data sources (local XML, remote API, or both) have
* data available for the specified travel.
*
* @param int $dateId The travel date ID to check
* @param int|null $hotelId Optional hotel ID for specific hotel data
*
* @return array<string, bool> Array with 'local' and 'remote' keys indicating availability
*/
public function getAvailableSources(int $dateId, ?int $hotelId = null): array
{
return [
static::SOURCE_LOCAL => $this->existsInXml($dateId, $hotelId),
static::SOURCE_REMOTE => null !== $this->mapDateIdToProductId($dateId),
];
}
/**
* Load travel data directly without caching.
*
* Internal method that handles the actual loading logic with fallback support.
* Tries the preferred source first, then falls back to the alternative if enabled.
*
* @param int $dateId The travel date ID to retrieve
* @param int|null $hotelId Optional hotel ID for specific hotel data
* @param bool $preferRemote Whether to prefer remote API over XML
*
* @return Travel|null The travel data or null if not found
*/
private function loadTravelDataUncached(int $dateId, ?int $hotelId = null, bool $preferRemote = false): ?Travel
{
$primarySource = $preferRemote ? self::SOURCE_REMOTE : self::SOURCE_LOCAL;
$fallbackSource = $preferRemote ? self::SOURCE_LOCAL : self::SOURCE_REMOTE;
// Try primary source first
$travel = $this->loadFromSource($dateId, $hotelId, $primarySource);
if (null !== $travel) {
return $travel;
}
// Try fallback source if enabled
if (true === $this->enableFallback) {
$this->logger->debug('Fallback to alternative source', [
'dateId' => $dateId,
'hotelId' => $hotelId,
'primarySource' => $primarySource,
'fallbackSource' => $fallbackSource,
]);
$travel = $this->loadFromSource($dateId, $hotelId, $fallbackSource);
}
return $travel;
}
/**
* Load travel data from a specific source.
*
* Internal method that routes to the appropriate loader based on source type.
*
* @param int $dateId The travel date ID to retrieve
* @param int|null $hotelId Optional hotel ID for specific hotel data
* @param string $source The source type (SOURCE_LOCAL or SOURCE_REMOTE)
*
* @return Travel|null The travel data or null if not found
*/
private function loadFromSource(int $dateId, ?int $hotelId, string $source): ?Travel
{
return match ($source) {
self::SOURCE_LOCAL => $this->getTravelDataFromXml($dateId, $hotelId),
self::SOURCE_REMOTE => $this->getTravelDataFromApi($dateId, $hotelId),
default => null,
};
}
/**
* Map date code to date ID.
*
* Converts a date code string to its corresponding date ID using the
* date loader's mapping functionality.
*
* @param string $dateCode The date code to map
*
* @return int|null The corresponding date ID or null if not found
*/
public function mapDateCodeToId(string $dateCode): ?int
{
try {
$dateId = $this->travelLoader->mapCodeToId($dateCode);
$this->logger->debug('Date code mapping', [
'dateCode' => $dateCode,
'dateId' => $dateId,
]);
return $dateId;
} catch (\Exception $e) {
$this->logger->error('Failed to map date code to ID', [
'dateCode' => $dateCode,
'error' => $e->getMessage(),
]);
return null;
}
}
/**
* Map hotel code to hotel ID.
*
* Converts a hotel code string to its corresponding hotel ID using the
* hotel loader's mapping functionality.
*
* @param string $hotelCode The hotel code to map
*
* @return int|null The corresponding hotel ID or null if not found
*/
public function mapHotelCodeToId(string $hotelCode): ?int
{
try {
$hotelId = $this->hotelLoader->mapCodeToId($hotelCode);
$this->logger->debug('Hotel code mapping', [
'hotelCode' => $hotelCode,
'hotelId' => $hotelId,
]);
return $hotelId;
} catch (\Exception $e) {
$this->logger->error('Failed to map hotel code to ID', [
'hotelCode' => $hotelCode,
'error' => $e->getMessage(),
]);
return null;
}
}
/**
* Map date ID to product ID for API calls.
*
* Converts a date ID to its corresponding product ID for use with the
* remote API. This method exposes the existing loader functionality
* through the service layer.
*
* @param int $dateId The date ID to map
*
* @return int|null The corresponding product ID or null if not found
*/
public function mapDateIdToProductId(int $dateId): ?int
{
try {
$productId = $this->travelLoader->mapDateIdToProductId($dateId);
$this->logger->debug('Date ID to product ID mapping', [
'dateId' => $dateId,
'productId' => $productId,
]);
return $productId;
} catch (\Exception $e) {
$this->logger->error('Failed to map date ID to product ID', [
'dateId' => $dateId,
'error' => $e->getMessage(),
]);
return null;
}
}
/**
* Generate files mapping for available travel data.
*
* Creates a mapping of all available travel data files with their
* corresponding travel and hotel information. This method exposes
* the existing loader functionality through the service layer.
*
* @return array<int, array<string, mixed>> Array mapping of travel data files
*/
public function generateFilesMap(): array
{
try {
$mapping = $this->travelLoader->generateFilesMap();
$this->logger->debug('Generated files mapping', [
'count' => count($mapping),
]);
return $mapping;
} catch (\Exception $e) {
$this->logger->error('Failed to generate files mapping', [
'error' => $e->getMessage(),
]);
return [];
}
}
/**
* Fetch mutability data from API.
*
* Retrieves mutability configuration data from the API for a specific travel date.
* Handles API errors and notification responses gracefully.
*
* @param int $dateId The travel date ID for API call
*
* @return BaseData|null The mutability data or null if not available or error occurred
*/
public function getMutabilityData(int $dateId): ?BaseData
{
try {
$mutableData = $this->apiClient->getMutableData($dateId);
if ($mutableData instanceof Notification) {
$this->logger->warning('API returned notification for mutability data', [
'dateId' => $dateId,
'message' => $mutableData->message,
'isError' => $mutableData->isError(),
]);
return null;
}
$this->logger->debug('Successfully fetched mutability data', [
'dateId' => $dateId,
]);
return $mutableData;
} catch (ApiClientException $e) {
$this->logger->error('Failed to fetch mutability data from API', [
'dateId' => $dateId,
'error' => $e->getMessage(),
]);
return null;
}
}
/**
* Apply mutability data to travel services.
*
* Updates the mutability status of various travel services based on the
* provided mutability configuration data.
*
* @param Travel $travel The travel object to update
* @param BaseData $mutableData The mutability configuration data
*/
public function patchMutability(Travel $travel, BaseData $mutableData): void
{
$this->travelLoader->patchMutability($travel, $mutableData);
$this->logger->debug('Successfully patched mutability data', [
'travelId' => $travel->id,
]);
}
/**
* Fetch availability data from API.
*
* Retrieves availability information from the API for a specific travel date.
* Handles API errors and notification responses gracefully.
*
* @param int $dateId The travel date ID for API call
*
* @return BaseData|null The availability data or null if not available or error occurred
*/
public function getAvailabilityData(int $dateId): ?BaseData
{
try {
$availabilities = $this->apiClient->getAvailabilities($dateId);
if ($availabilities instanceof Notification) {
$this->logger->warning('API returned notification for availability data', [
'dateId' => $dateId,
'message' => $availabilities->message,
'isError' => $availabilities->isError(),
]);
return null;
}
$this->logger->debug('Successfully fetched availability data', [
'dateId' => $dateId,
]);
return $availabilities;
} catch (ApiClientException $e) {
$this->logger->error('Failed to fetch availability data from API', [
'dateId' => $dateId,
'error' => $e->getMessage(),
]);
return null;
}
}
/**
* Apply availability data to travel services.
*
* Updates the availability status of additional and transportation
* services based on the provided availability data.
*
* @param Travel $travel The travel object to update
* @param BaseData $availabilities The availability data for services
*/
public function patchAvailabilities(Travel $travel, BaseData $availabilities): void
{
$this->travelLoader->patchAvailabilities($travel, $availabilities);
$this->logger->debug('Successfully patched availability data', [
'travelId' => $travel->id,
]);
}
/**
* Enrich travel data with additional information.
*
* Adds hotel details and pickup information to travel data loaded from XML.
* This enrichment is necessary for complete travel information.
*
* @param Travel $travel The travel object to enrich
*/
private function enrichTravelData(Travel $travel): void
{
try {
$this->pickupLoader->patchPickupsDetails($travel);
$this->hotelLoader->patchHotelDetails($travel);
} catch (\Exception $e) {
$this->logger->warning('Failed to enrich travel data', [
'travelId' => $travel->id,
'error' => $e->getMessage(),
]);
}
}
}