From eecea0abd12859bdb5c449bca71a7cb478d1228b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Wed, 16 Jul 2025 19:37:17 +0200 Subject: [PATCH] wip: booking process, refactoring --- .env | 4 + api.http | 7 +- config/services.yaml | 6 + src/BusProNet/ApiClient.php | 27 +- src/BusProNet/Constants.php | 2 +- .../DataProcessor/BookingDataProcessor.php | 303 ++++++++-- src/BusProNet/Model/Address.php | 10 +- src/BusProNet/Model/Booking.php | 12 +- src/BusProNet/Model/Communication.php | 6 +- src/BusProNet/Model/PersonalData.php | 20 +- src/BusProNet/Model/Product.php | 17 + src/BusProNet/Model/Travel.php | 10 +- src/BusProNet/Utility/DateCodeUtility.php | 44 ++ src/BusProNet/Utility/TravelCodeUtility.php | 23 - src/BusProNet/XmlLoader/TravelLoader.php | 109 ++-- src/BusProNet/XmlParser/ApiResponseParser.php | 3 + src/BusProNet/XmlParser/BookingParser.php | 3 +- src/BusProNet/XmlParser/BookingsParser.php | 2 +- src/BusProNet/XmlParser/ProductsParser.php | 31 + src/BusProNet/XmlParser/TravelParser.php | 14 +- src/Controller/Api/ProductController.php | 40 ++ src/Controller/Api/TravelController.php | 143 ++--- src/Controller/Booking/CreateController.php | 48 +- src/Controller/Booking/DownloadController.php | 1 + src/Controller/Booking/EditController.php | 28 +- src/Controller/PersonalDataController.php | 7 +- src/Form/BookingCreateParticipantType.php | 46 +- src/Form/BookingCreateStep1Type.php | 9 +- src/Form/BookingCreateStep2Type.php | 9 +- src/Form/Model/BookingCreateDto.php | 15 +- src/Form/Model/ParticipantDto.php | 12 +- src/Form/Model/RoomSelectionDto.php | 2 +- src/Form/RoomSelectType.php | 12 +- src/Service/BookingService.php | 31 +- src/Service/TravelDataService.php | 552 ++++++++++++++++++ templates/booking/create_step_1.html.twig | 6 +- templates/booking/create_step_2.html.twig | 65 ++- .../DataLoader/HotelDataLoaderTest.php | 37 +- .../DataLoader/PickupDataLoaderTest.php | 33 +- .../DataLoader/TravelDataLoaderTest.php | 55 +- .../BookingDataProcessorTest.php | 507 ++++++++++++++++ .../Utility/TravelCodeUtilityTest.php | 8 +- tests/Service/TravelDataServiceTest.php | 316 ++++++++++ 43 files changed, 2269 insertions(+), 366 deletions(-) create mode 100644 src/BusProNet/Model/Product.php create mode 100644 src/BusProNet/Utility/DateCodeUtility.php delete mode 100644 src/BusProNet/Utility/TravelCodeUtility.php create mode 100644 src/BusProNet/XmlParser/ProductsParser.php create mode 100644 src/Controller/Api/ProductController.php create mode 100644 src/Service/TravelDataService.php create mode 100644 tests/BusProNet/DataProcessor/BookingDataProcessorTest.php create mode 100644 tests/Service/TravelDataServiceTest.php diff --git a/.env b/.env index d2fffa8..b5c4ed5 100644 --- a/.env +++ b/.env @@ -48,6 +48,10 @@ APP_BPN_DEBUG=false APP_TRAVEL_INFO_BASE_URL=https://www.ep-reisen.de/reiseinformationen/ +# Travel Data Service Configuration +APP_TRAVEL_PREFER_REMOTE=false +APP_TRAVEL_ENABLE_FALLBACK=true + API_KEYS= XML_EXPORT_PATH="%kernel.project_dir%/var/xmlexport" diff --git a/api.http b/api.http index afe1a35..bbd5b2e 100644 --- a/api.http +++ b/api.http @@ -54,7 +54,7 @@ Accept: application/json Authorization: Bearer {{$auth.token("oauth2_api")}} ### API travel remote -GET {{base_url}}/api/travels/1277/remote +GET {{base_url}}/api/travels/11603 Accept: application/json Authorization: Bearer {{$auth.token("oauth2_api")}} @@ -63,3 +63,8 @@ GET {{base_url}}/api/travels/11603/152546/2026-01-03/availability Accept: application/json Authorization: Bearer {{$auth.token("oauth2_api")}} +### API products remote +GET {{base_url}}/api/products +Accept: application/json +Authorization: Bearer {{$auth.token("oauth2_api")}} + diff --git a/config/services.yaml b/config/services.yaml index 7836f0e..f8f496a 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -56,3 +56,9 @@ services: App\Security\Crypt: arguments: $path: '%path_to_keys%' + + App\Service\TravelDataService: + arguments: + $logger: '@monolog.logger.core' + $preferRemote: '%env(bool:APP_TRAVEL_PREFER_REMOTE)%' + $enableFallback: '%env(bool:APP_TRAVEL_ENABLE_FALLBACK)%' diff --git a/src/BusProNet/ApiClient.php b/src/BusProNet/ApiClient.php index 0404286..a3b1ce1 100644 --- a/src/BusProNet/ApiClient.php +++ b/src/BusProNet/ApiClient.php @@ -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 */ diff --git a/src/BusProNet/Constants.php b/src/BusProNet/Constants.php index 71c0184..c04ea17 100644 --- a/src/BusProNet/Constants.php +++ b/src/BusProNet/Constants.php @@ -19,4 +19,4 @@ final class Constants public const STATUS_AVAILABLE = 'Frei'; public const STATUS_BLOCKED = 'Buchungsstop'; public const STATUS_ON_REQUEST = 'Anfrage'; -} \ No newline at end of file +} diff --git a/src/BusProNet/DataProcessor/BookingDataProcessor.php b/src/BusProNet/DataProcessor/BookingDataProcessor.php index 4dcba56..8d3850e 100644 --- a/src/BusProNet/DataProcessor/BookingDataProcessor.php +++ b/src/BusProNet/DataProcessor/BookingDataProcessor.php @@ -1,97 +1,236 @@ 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; } } diff --git a/src/BusProNet/Model/Address.php b/src/BusProNet/Model/Address.php index 7a8a8c6..85bb5d3 100644 --- a/src/BusProNet/Model/Address.php +++ b/src/BusProNet/Model/Address.php @@ -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. diff --git a/src/BusProNet/Model/Booking.php b/src/BusProNet/Model/Booking.php index eefed04..95b39a1 100644 --- a/src/BusProNet/Model/Booking.php +++ b/src/BusProNet/Model/Booking.php @@ -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 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) { diff --git a/src/BusProNet/Model/Communication.php b/src/BusProNet/Model/Communication.php index 472c814..e211476 100644 --- a/src/BusProNet/Model/Communication.php +++ b/src/BusProNet/Model/Communication.php @@ -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; diff --git a/src/BusProNet/Model/PersonalData.php b/src/BusProNet/Model/PersonalData.php index bd83eca..4e0e740 100644 --- a/src/BusProNet/Model/PersonalData.php +++ b/src/BusProNet/Model/PersonalData.php @@ -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 [ diff --git a/src/BusProNet/Model/Product.php b/src/BusProNet/Model/Product.php new file mode 100644 index 0000000..8de580f --- /dev/null +++ b/src/BusProNet/Model/Product.php @@ -0,0 +1,17 @@ + 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 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; }); } } diff --git a/src/BusProNet/Utility/DateCodeUtility.php b/src/BusProNet/Utility/DateCodeUtility.php new file mode 100644 index 0000000..0b1c907 --- /dev/null +++ b/src/BusProNet/Utility/DateCodeUtility.php @@ -0,0 +1,44 @@ +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; } diff --git a/src/BusProNet/XmlParser/ApiResponseParser.php b/src/BusProNet/XmlParser/ApiResponseParser.php index 5216d7e..35fb927 100644 --- a/src/BusProNet/XmlParser/ApiResponseParser.php +++ b/src/BusProNet/XmlParser/ApiResponseParser.php @@ -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); } diff --git a/src/BusProNet/XmlParser/BookingParser.php b/src/BusProNet/XmlParser/BookingParser.php index 913cf7d..70e589f 100644 --- a/src/BusProNet/XmlParser/BookingParser.php +++ b/src/BusProNet/XmlParser/BookingParser.php @@ -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')); diff --git a/src/BusProNet/XmlParser/BookingsParser.php b/src/BusProNet/XmlParser/BookingsParser.php index 29d2a42..abedfec 100644 --- a/src/BusProNet/XmlParser/BookingsParser.php +++ b/src/BusProNet/XmlParser/BookingsParser.php @@ -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')); diff --git a/src/BusProNet/XmlParser/ProductsParser.php b/src/BusProNet/XmlParser/ProductsParser.php new file mode 100644 index 0000000..da4ae65 --- /dev/null +++ b/src/BusProNet/XmlParser/ProductsParser.php @@ -0,0 +1,31 @@ +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); + } +} diff --git a/src/BusProNet/XmlParser/TravelParser.php b/src/BusProNet/XmlParser/TravelParser.php index 2a76e68..3dc6ddc 100644 --- a/src/BusProNet/XmlParser/TravelParser.php +++ b/src/BusProNet/XmlParser/TravelParser.php @@ -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 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 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 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 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 Array of rooms indexed by room ID */ public function getRooms(Crawler $node): array diff --git a/src/Controller/Api/ProductController.php b/src/Controller/Api/ProductController.php new file mode 100644 index 0000000..086f52d --- /dev/null +++ b/src/Controller/Api/ProductController.php @@ -0,0 +1,40 @@ +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']); + } + } +} diff --git a/src/Controller/Api/TravelController.php b/src/Controller/Api/TravelController.php index 3b756b9..19ce446 100644 --- a/src/Controller/Api/TravelController.php +++ b/src/Controller/Api/TravelController.php @@ -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), + }; } } diff --git a/src/Controller/Booking/CreateController.php b/src/Controller/Booking/CreateController.php index a3c705e..88bbf19 100644 --- a/src/Controller/Booking/CreateController.php +++ b/src/Controller/Booking/CreateController.php @@ -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); } -} \ No newline at end of file +} diff --git a/src/Controller/Booking/DownloadController.php b/src/Controller/Booking/DownloadController.php index a343b73..3061fd4 100644 --- a/src/Controller/Booking/DownloadController.php +++ b/src/Controller/Booking/DownloadController.php @@ -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 diff --git a/src/Controller/Booking/EditController.php b/src/Controller/Booking/EditController.php index 284cc4b..99c35a6 100644 --- a/src/Controller/Booking/EditController.php +++ b/src/Controller/Booking/EditController.php @@ -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); diff --git a/src/Controller/PersonalDataController.php b/src/Controller/PersonalDataController.php index 9ff9e37..0f0a2bc 100644 --- a/src/Controller/PersonalDataController.php +++ b/src/Controller/PersonalDataController.php @@ -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 diff --git a/src/Form/BookingCreateParticipantType.php b/src/Form/BookingCreateParticipantType.php index 02a3ce6..aedc6ca 100644 --- a/src/Form/BookingCreateParticipantType.php +++ b/src/Form/BookingCreateParticipantType.php @@ -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, ]); } -} \ No newline at end of file +} diff --git a/src/Form/BookingCreateStep1Type.php b/src/Form/BookingCreateStep1Type.php index 1174ec8..32a99dd 100644 --- a/src/Form/BookingCreateStep1Type.php +++ b/src/Form/BookingCreateStep1Type.php @@ -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, ]); } -} \ No newline at end of file +} diff --git a/src/Form/BookingCreateStep2Type.php b/src/Form/BookingCreateStep2Type.php index d50e630..7238393 100644 --- a/src/Form/BookingCreateStep2Type.php +++ b/src/Form/BookingCreateStep2Type.php @@ -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, ]); } -} \ No newline at end of file +} diff --git a/src/Form/Model/BookingCreateDto.php b/src/Form/Model/BookingCreateDto.php index 622239f..ba0eb05 100644 --- a/src/Form/Model/BookingCreateDto.php +++ b/src/Form/Model/BookingCreateDto.php @@ -9,7 +9,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface; class BookingCreateDto { public int $currentStep = 1; - + #[Assert\Valid] /** * @var array @@ -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; } -} \ No newline at end of file + + public function getSelectedRooms(): array + { + return array_filter($this->roomSelections, function (RoomSelectionDto $roomSelection) { + return 0 < $roomSelection->quantity; + }); + } +} diff --git a/src/Form/Model/ParticipantDto.php b/src/Form/Model/ParticipantDto.php index c08a876..a8245ab 100644 --- a/src/Form/Model/ParticipantDto.php +++ b/src/Form/Model/ParticipantDto.php @@ -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; diff --git a/src/Form/Model/RoomSelectionDto.php b/src/Form/Model/RoomSelectionDto.php index 0645b0c..0992907 100644 --- a/src/Form/Model/RoomSelectionDto.php +++ b/src/Form/Model/RoomSelectionDto.php @@ -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; diff --git a/src/Form/RoomSelectType.php b/src/Form/RoomSelectType.php index a7cdf59..ed8e56d 100644 --- a/src/Form/RoomSelectType.php +++ b/src/Form/RoomSelectType.php @@ -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, ]); } -} \ No newline at end of file +} diff --git a/src/Service/BookingService.php b/src/Service/BookingService.php index 2e5125b..c61bf5d 100644 --- a/src/Service/BookingService.php +++ b/src/Service/BookingService.php @@ -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(); diff --git a/src/Service/TravelDataService.php b/src/Service/TravelDataService.php new file mode 100644 index 0000000..99ffdcc --- /dev/null +++ b/src/Service/TravelDataService.php @@ -0,0 +1,552 @@ +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 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> 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(), + ]); + } + } +} diff --git a/templates/booking/create_step_1.html.twig b/templates/booking/create_step_1.html.twig index 2e4cc4c..6afd5f3 100644 --- a/templates/booking/create_step_1.html.twig +++ b/templates/booking/create_step_1.html.twig @@ -8,7 +8,9 @@

Zimmerauswahl

{{ form_start(form) }} {{ form_row(form.roomSelections) }} - +
+ +
{{ form_rest(form) }} {{ form_end(form) }} @@ -22,4 +24,4 @@ {% endif %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/templates/booking/create_step_2.html.twig b/templates/booking/create_step_2.html.twig index 732d428..b05785d 100644 --- a/templates/booking/create_step_2.html.twig +++ b/templates/booking/create_step_2.html.twig @@ -7,9 +7,27 @@

Teilnehmer

{{ form_start(form) }} - {% for participant in form.participants %} - {{ form_row(participant) }} - {% endfor %} + {% do form.participants.setRendered %} +
+ {% for participant in form.participants %} +
+ + Teilnehmer:in {{ loop.index }} + +
+ {{ form_row(participant.firstName) }} + {{ form_row(participant.lastName) }} + {{ form_row(participant.dateOfBirth) }} + {{ form_row(participant.gender) }} + {{ form_row(participant.nationality) }} +
+
+ {{ form_row(participant.email) }} + {{ form_row(participant.mobile) }} +
+
+ {% endfor %} +
Zurück @@ -19,13 +37,42 @@

Zusammenfassung

-

Reise: {{ bookingCreateDto.travelData.label }}

-

Datum: {{ bookingCreateDto.travelData.dateFrom | date('d.m.Y') }} - {{ bookingCreateDto.travelData.dateTo | date('d.m.Y') }}

-

Hotel: {{ bookingCreateDto.travelData.hotel.name }}

+

+ Reise +

+

+ {{ bookingCreateDto.travelData.label }} +

+

+ Datum +

+

+ {{ bookingCreateDto.travelData.dateFrom | date('d.m.Y') }} - {{ bookingCreateDto.travelData.dateTo | date('d.m.Y') }} +

+

+ Unterkunft +

+

+ {{ bookingCreateDto.travelData.hotel.name }} +

{% if bookingCreateDto.participantsCount > 0 %} -

Teilnehmerzahl: {{ bookingCreateDto.participantsCount }}

+

+ Anzahl Teilnehmer +

+

+ {{ bookingCreateDto.participantsCount }} +

{% endif %} -

Zimmer:

+

+ Zimmer +

+
    + {% for roomSelection in bookingCreateDto.selectedRooms %} +
  • + {{ roomSelection.quantity }}x {{ roomSelection.roomLabel }} +
  • + {% endfor %} +
-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/tests/BusProNet/DataLoader/HotelDataLoaderTest.php b/tests/BusProNet/DataLoader/HotelDataLoaderTest.php index 8eec4aa..694eed2 100644 --- a/tests/BusProNet/DataLoader/HotelDataLoaderTest.php +++ b/tests/BusProNet/DataLoader/HotelDataLoaderTest.php @@ -4,22 +4,45 @@ namespace App\Tests\BusProNet\DataLoader; use App\BusProNet\Model\Hotel; use App\BusProNet\XmlLoader\HotelLoader; +use League\Flysystem\FilesystemOperator; use PHPUnit\Framework\TestCase; +use Symfony\Component\Cache\Adapter\ArrayAdapter; class HotelDataLoaderTest extends TestCase { - public function testParse(): void + public function testLoadAll(): void { - $loader = new HotelLoader(__DIR__.'/../../Resources'); - $hotelId = 163113; + $xmlContent = ' + + + + L\'Oxalys + Val Thorens + F + Rue des Lacs + + Hotel + True + +'; - $xml = $loader->loadById($hotelId, 'hotels_data.xml'); - $this->assertInstanceOf(\SimpleXMLElement::class, $xml); + $filesystem = $this->createMock(FilesystemOperator::class); + $filesystem->expects($this->once()) + ->method('read') + ->with('hotel.xml') + ->willReturn($xmlContent); - $hotel = $loader->parseXml($xml); + $cache = new ArrayAdapter(); + $loader = new HotelLoader($cache, $filesystem); + $hotels = $loader->loadAll(); + + $this->assertIsArray($hotels); + $this->assertArrayHasKey(163113, $hotels); + + $hotel = $hotels[163113]; $this->assertInstanceOf(Hotel::class, $hotel); - $this->assertEquals($hotelId, $hotel->id); + $this->assertEquals(163113, $hotel->id); $this->assertEquals('L\'Oxalys', $hotel->name); $this->assertEquals('SBWOXA', $hotel->code); $this->assertEquals('Val Thorens', $hotel->city); diff --git a/tests/BusProNet/DataLoader/PickupDataLoaderTest.php b/tests/BusProNet/DataLoader/PickupDataLoaderTest.php index efe03f3..450afef 100644 --- a/tests/BusProNet/DataLoader/PickupDataLoaderTest.php +++ b/tests/BusProNet/DataLoader/PickupDataLoaderTest.php @@ -4,22 +4,41 @@ namespace App\Tests\BusProNet\DataLoader; use App\BusProNet\Model\Pickup; use App\BusProNet\XmlLoader\PickupLoader; +use League\Flysystem\FilesystemOperator; use PHPUnit\Framework\TestCase; +use Symfony\Component\Cache\Adapter\ArrayAdapter; class PickupDataLoaderTest extends TestCase { - public function testParse(): void + public function testLoadById(): void { - $loader = new PickupLoader(); - $pickupId = 1; + $xmlContent = ' + + + +Münster +48143 +Hafenstr/Ecke Friedrich-Ebert-Str +BUS +False +True +True + +'; - $xml = $loader->loadById($pickupId, 'pickups_data.xml'); - $this->assertInstanceOf(\SimpleXMLElement::class, $xml); + $filesystem = $this->createMock(FilesystemOperator::class); + $filesystem->expects($this->once()) + ->method('read') + ->with('zustiege.xml') + ->willReturn($xmlContent); - $pickup = $loader->parseXml($xml); + $cache = new ArrayAdapter(); + $loader = new PickupLoader($cache, $filesystem); + + $pickup = $loader->loadById(1); $this->assertInstanceOf(Pickup::class, $pickup); - $this->assertEquals($pickupId, $pickup->id); + $this->assertEquals(1, $pickup->id); $this->assertEquals('MS-Hbf', $pickup->code); $this->assertEquals('Münster', $pickup->city); $this->assertEquals('48143', $pickup->postalCode); diff --git a/tests/BusProNet/DataLoader/TravelDataLoaderTest.php b/tests/BusProNet/DataLoader/TravelDataLoaderTest.php index bb554a6..ea54609 100644 --- a/tests/BusProNet/DataLoader/TravelDataLoaderTest.php +++ b/tests/BusProNet/DataLoader/TravelDataLoaderTest.php @@ -3,21 +3,56 @@ namespace App\Tests\BusProNet\DataLoader; use App\BusProNet\Model\Travel; +use App\BusProNet\XmlLoader\HotelLoader; use App\BusProNet\XmlLoader\TravelLoader; +use App\BusProNet\XmlParser\TravelParser; +use League\Flysystem\FilesystemOperator; use PHPUnit\Framework\TestCase; +use Symfony\Component\Cache\Adapter\ArrayAdapter; class TravelDataLoaderTest extends TestCase { - public function testParse(): void + public function testLoadById(): void { - $loader = new TravelLoader(''); - $filename = __DIR__.'/../../Resources/travel_data.xml'; - $travelId = 11478; + $xmlContent = ' + + + + + Davos - Sportclub Waldschlössli + 534.2 + + Test Hotel + + + +'; - $xml = $loader->loadById($travelId, $filename); - $this->assertInstanceOf(\SimpleXMLElement::class, $xml); + $filesystem = $this->createMock(FilesystemOperator::class); + $filesystem->expects($this->once()) + ->method('read') + ->with('test_file.xml') + ->willReturn($xmlContent); - $travel = $loader->parseXml($xml); + $hotelLoader = $this->createMock(HotelLoader::class); + $travelParser = $this->createMock(TravelParser::class); + + $expectedTravel = new Travel(); + $expectedTravel->code = 'DPWMP060125'; + $expectedTravel->type = 'F'; + $expectedTravel->dateFrom = new \DateTimeImmutable('2025-01-06'); + $expectedTravel->dateTo = new \DateTimeImmutable('2025-01-25'); + $expectedTravel->label = 'Davos - Sportclub Waldschlössli'; + $expectedTravel->priceFrom = 534.2; + + $travelParser->expects($this->once()) + ->method('parse') + ->willReturn($expectedTravel); + + $cache = new ArrayAdapter(); + $loader = new TravelLoader($hotelLoader, $travelParser, 'http://example.com', $cache, $filesystem); + + $travel = $loader->loadById(11478, null, 'test_file.xml'); $this->assertInstanceOf(Travel::class, $travel); $this->assertEquals('DPWMP060125', $travel->code); @@ -26,11 +61,5 @@ class TravelDataLoaderTest extends TestCase $this->assertInstanceOf(\DateTimeImmutable::class, $travel->dateTo); $this->assertEquals('Davos - Sportclub Waldschlössli', $travel->label); $this->assertEquals(534.2, $travel->priceFrom); - - $this->assertCount(8, $travel->selectionGroups); - $this->assertCount(27, $travel->additionalServices); - $this->assertCount(5, $travel->transportationServices); - $this->assertCount(7, $travel->pickupsTo); - $this->assertCount(12, $travel->rooms); } } diff --git a/tests/BusProNet/DataProcessor/BookingDataProcessorTest.php b/tests/BusProNet/DataProcessor/BookingDataProcessorTest.php new file mode 100644 index 0000000..963a83a --- /dev/null +++ b/tests/BusProNet/DataProcessor/BookingDataProcessorTest.php @@ -0,0 +1,507 @@ +processor = new BookingDataProcessor(); + } + + public function testCreateUpdateRequestPayloadWithCompleteData(): void + { + $formData = $this->createCompleteFormData(); + + $result = $this->processor->createUpdateRequestPayload($formData); + + $this->assertIsArray($result); + $this->assertArrayHasKey('idbuchung', $result); + $this->assertArrayHasKey('teilnehmerliste', $result); + $this->assertArrayHasKey('zusatzleistungen', $result); + $this->assertArrayHasKey('beförderungen', $result); + $this->assertArrayHasKey('ferienzielunterbringungen', $result); + + $this->assertEquals(123, $result['idbuchung']); + $this->assertEquals('ACTIVE', $result['status']); + $this->assertCount(2, $result['teilnehmerliste']['teilnehmer']); + } + + public function testCanceledParticipantsAreSkipped(): void + { + $formData = $this->createFormDataWithCanceledParticipant(); + + $result = $this->processor->createUpdateRequestPayload($formData); + + $this->assertCount(2, $result['teilnehmerliste']['teilnehmer']); + $this->assertEquals(0, $result['teilnehmerliste']['teilnehmer'][0]['@id']); + $this->assertEquals(1, $result['teilnehmerliste']['teilnehmer'][1]['@id']); + } + + public function testAdditionalServicesProcessing(): void + { + $formData = $this->createFormDataWithAdditionalServices(); + + $result = $this->processor->createUpdateRequestPayload($formData); + + $this->assertNotEmpty($result['zusatzleistungen']['zusatzleistung']); + $this->assertCount(2, $result['zusatzleistungen']['zusatzleistung']); + + $service = $result['zusatzleistungen']['zusatzleistung'][0]; + $this->assertEquals(1, $service['@idleistung']); + $this->assertEquals(1, $service['@anzahl']); + $this->assertEquals('0', $service['@zuordnung']); + } + + public function testTransportationServicesProcessing(): void + { + $formData = $this->createFormDataWithTransportation(); + + $result = $this->processor->createUpdateRequestPayload($formData); + + $this->assertNotEmpty($result['beförderungen']['beförderung']); + $this->assertCount(2, $result['beförderungen']['beförderung']); + } + + public function testBusPickupLocationsProcessing(): void + { + $formData = $this->createFormDataWithBusPickup(); + + $result = $this->processor->createUpdateRequestPayload($formData); + + $this->assertArrayHasKey('zustiege', $result); + $this->assertNotEmpty($result['zustiege']['zustieg']); + $this->assertEquals(1, $result['zustiege']['zustieg'][0]['@idzustieg']); + } + + public function testNonBusTransportationSkipsPickup(): void + { + $formData = $this->createFormDataWithNonBusTransportation(); + + $result = $this->processor->createUpdateRequestPayload($formData); + + $this->assertArrayNotHasKey('zustiege', $result); + } + + public function testUnusedServicesAreRemoved(): void + { + $formData = $this->createFormDataWithUnusedServices(); + + $result = $this->processor->createUpdateRequestPayload($formData); + + $this->assertEmpty($result['zusatzleistungen']['zusatzleistung']); + $this->assertEmpty($result['beförderungen']['beförderung']); + } + + public function testParticipantPersonalDataUpdate(): void + { + $formData = $this->createFormDataWithUpdatedPersonalData(); + + $this->processor->createUpdateRequestPayload($formData); + + $participant = $formData->booking->participants[0]; + $this->assertEquals('Updated', $participant->firstName); + $this->assertEquals('Participant', $participant->name); + $this->assertEquals('test@example.com', $participant->communication->email); + $this->assertEquals('+49123456789', $participant->communication->mobile); + } + + public function testInactiveParticipantsPersonalDataNotUpdated(): void + { + $formData = $this->createFormDataWithInactiveParticipant(); + + $this->processor->createUpdateRequestPayload($formData); + + $participant = $formData->booking->participants[0]; + $this->assertEquals('Original', $participant->firstName); + $this->assertEquals('Name', $participant->name); + } + + public function testApplicantDataSyncWithFirstParticipant(): void + { + $formData = $this->createFormDataForApplicantSync(); + + $this->processor->createUpdateRequestPayload($formData); + + $applicant = $formData->booking->applicant; + $firstParticipant = $formData->booking->participants[0]; + + $this->assertEquals($firstParticipant->height, $applicant->height); + $this->assertEquals($firstParticipant->weight, $applicant->weight); + $this->assertEquals($firstParticipant->shoeSize, $applicant->shoeSize); + } + + public function testBankAccountIncludedInPayload(): void + { + $formData = $this->createFormDataWithBankAccount(); + + $result = $this->processor->createUpdateRequestPayload($formData); + + $this->assertArrayHasKey('bankverbindung', $result['zahlung']); + $this->assertEquals('Test Bank', $result['zahlung']['bankverbindung']['@kreditinstitut']); + $this->assertEquals('DE89370400440532013000', $result['zahlung']['bankverbindung']['@iban']); + } + + public function testBankAccountNotIncludedWhenNull(): void + { + $formData = $this->createFormDataWithoutBankAccount(); + + $result = $this->processor->createUpdateRequestPayload($formData); + + $this->assertArrayNotHasKey('bankverbindung', $result['zahlung']); + } + + public function testAccommodationRoomsProcessing(): void + { + $formData = $this->createFormDataWithRooms(); + + $result = $this->processor->createUpdateRequestPayload($formData); + + $this->assertNotEmpty($result['ferienzielunterbringungen']['ferienzielunterbringung']); + + $room = $result['ferienzielunterbringungen']['ferienzielunterbringung'][0]; + $this->assertEquals(1, $room['@idzimmer']); + $this->assertEquals('DOUBLE', $room['@kategorie']); + $this->assertEquals('01.01.2024', $room['@anreise']); + $this->assertEquals('07.01.2024', $room['@abreise']); + } + + public function testCommunicationObjectCreation(): void + { + $formData = $this->createFormDataWithoutExistingCommunication(); + + $this->processor->createUpdateRequestPayload($formData); + + $participant = $formData->booking->participants[0]; + $this->assertInstanceOf(Communication::class, $participant->communication); + $this->assertEquals('new@example.com', $participant->communication->email); + } + + public function testEmptyPickupsToDoesNotCreateZustiegeSection(): void + { + $formData = $this->createFormDataWithoutPickups(); + + $result = $this->processor->createUpdateRequestPayload($formData); + + $this->assertArrayNotHasKey('zustiege', $result); + } + + private function createCompleteFormData(): BookingEditDto + { + $formData = new BookingEditDto(); + + $formData->booking = $this->createMockBooking(); + $formData->travel = $this->createMockTravel(); + $formData->participants = [ + $this->createMockParticipantDto(0, 'F'), + $this->createMockParticipantDto(1, 'F'), + ]; + + return $formData; + } + + private function createFormDataWithCanceledParticipant(): BookingEditDto + { + $formData = new BookingEditDto(); + + $formData->booking = $this->createMockBooking(); + $formData->travel = $this->createMockTravel(); + $formData->participants = [ + $this->createMockParticipantDto(0, 'F'), + $this->createMockParticipantDto(1, 'S'), // Canceled + ]; + + return $formData; + } + + private function createFormDataWithAdditionalServices(): BookingEditDto + { + $formData = $this->createCompleteFormData(); + + $participant = $formData->participants[0]; + $participant->courses = [$this->createMockService(1)]; + $participant->additionalServices = [$this->createMockService(2)]; + + return $formData; + } + + private function createFormDataWithTransportation(): BookingEditDto + { + $formData = $this->createCompleteFormData(); + + $participant = $formData->participants[0]; + $participant->transportationServiceTo = $this->createMockService(1); + $participant->transportationServiceFro = $this->createMockService(2); + + return $formData; + } + + private function createFormDataWithBusPickup(): BookingEditDto + { + $formData = $this->createCompleteFormData(); + + $participant = $formData->participants[0]; + $busService = $this->createMockService(1); + $busService->subType = 'BUS'; + $participant->transportationServiceTo = $busService; + $participant->pickup = $this->createMockPickup(1); + + return $formData; + } + + private function createFormDataWithNonBusTransportation(): BookingEditDto + { + $formData = $this->createCompleteFormData(); + + $participant = $formData->participants[0]; + $trainService = $this->createMockService(1); + $trainService->subType = 'TRAIN'; + $participant->transportationServiceTo = $trainService; + + return $formData; + } + + private function createFormDataWithUnusedServices(): BookingEditDto + { + $formData = $this->createCompleteFormData(); + + // Remove all participants so services become unused + $formData->participants = []; + + return $formData; + } + + private function createFormDataWithUpdatedPersonalData(): BookingEditDto + { + $formData = $this->createCompleteFormData(); + + $participant = $formData->participants[0]; + $participant->firstName = 'Updated'; + $participant->lastName = 'Participant'; + $participant->email = 'test@example.com'; + $participant->mobile = '+49123456789'; + + return $formData; + } + + private function createFormDataWithInactiveParticipant(): BookingEditDto + { + $formData = $this->createCompleteFormData(); + + $participant = $formData->participants[0]; + $participant->status = 'C'; // Inactive status + $participant->firstName = 'Updated'; + $participant->lastName = 'Participant'; + + // Ensure original data remains unchanged + $formData->booking->participants[0]->firstName = 'Original'; + $formData->booking->participants[0]->name = 'Name'; + + return $formData; + } + + private function createFormDataForApplicantSync(): BookingEditDto + { + $formData = $this->createCompleteFormData(); + + $firstParticipant = $formData->booking->participants[0]; + $firstParticipant->height = '180'; + $firstParticipant->weight = '75'; + $firstParticipant->shoeSize = '42'; + + return $formData; + } + + private function createFormDataWithBankAccount(): BookingEditDto + { + $formData = $this->createCompleteFormData(); + + $bankAccount = new BankAccount(); + $bankAccount->bankName = 'Test Bank'; + $bankAccount->iban = 'DE89370400440532013000'; + $bankAccount->bic = 'COBADEFFXXX'; + $bankAccount->holder = 'Test Holder'; + + $formData->booking->bankAccount = $bankAccount; + + return $formData; + } + + private function createFormDataWithoutBankAccount(): BookingEditDto + { + $formData = $this->createCompleteFormData(); + $formData->booking->bankAccount = null; + + return $formData; + } + + private function createFormDataWithRooms(): BookingEditDto + { + $formData = $this->createCompleteFormData(); + + $room = new Room(); + $room->id = 1; + $room->category = 'DOUBLE'; + $room->boardId = 1; + $room->dateFrom = new \DateTimeImmutable('2024-01-01'); + $room->dateTo = new \DateTimeImmutable('2024-01-07'); + $room->totalCount = 2; + $room->mapping = [0, 1]; + + $formData->booking->rooms = [$room]; + + return $formData; + } + + private function createFormDataWithoutExistingCommunication(): BookingEditDto + { + $formData = $this->createCompleteFormData(); + + $participant = $formData->participants[0]; + $participant->email = 'new@example.com'; + $participant->mobile = '+49987654321'; + + $formData->booking->participants[0]->communication = new Communication(); + + return $formData; + } + + private function createFormDataWithoutPickups(): BookingEditDto + { + $formData = $this->createCompleteFormData(); + $formData->booking->pickupsTo = []; + + return $formData; + } + + private function createMockBooking(): Booking + { + $booking = new Booking(); + $booking->id = 123; + $booking->status = 'ACTIVE'; + $booking->agencyId = 1; + $booking->dateId = 1; + $booking->hotelId = 1; + $booking->paymentId = '1'; + $booking->paymentLabel = 'Credit Card'; + $booking->paymentType = 'CC'; + $booking->additionalServices = []; + $booking->transportationServices = []; + $booking->pickupsTo = []; + $booking->pickupsFro = []; + $booking->participants = [ + $this->createMockPersonalData('Participant0'), + $this->createMockPersonalData('Participant1'), + ]; + $booking->participantsStatus = ['F', 'F']; + $booking->applicant = $this->createMockPersonalData('Applicant'); + $booking->bankAccount = null; + $booking->rooms = []; + + return $booking; + } + + private function createMockTravel(): Travel + { + $travel = new Travel(); + $travel->additionalServices = [ + 1 => $this->createMockService(1), + 2 => $this->createMockService(2), + ]; + $travel->transportationServices = [ + 1 => $this->createMockService(1), + 2 => $this->createMockService(2), + ]; + + return $travel; + } + + private function createMockParticipantDto(int $index, string $status): ParticipantDto + { + $participant = new ParticipantDto(); + $participant->index = $index; + $participant->status = $status; + $participant->firstName = "Participant{$index}"; + $participant->lastName = 'LastName'; + $participant->dateOfBirth = new \DateTimeImmutable('1990-01-01'); + $participant->gender = 'M'; + $participant->nationality = 'DE'; + $participant->height = '175'; + $participant->weight = '70'; + $participant->shoeSize = '40'; + $participant->email = null; + $participant->mobile = null; + $participant->courses = []; + $participant->additionalServices = []; + $participant->skiPass = []; + $participant->board = []; + $participant->rentals = []; + $participant->transportationServiceTo = $this->createMockService(1); + $participant->transportationServiceFro = $this->createMockService(2); + $participant->pickup = null; + + return $participant; + } + + private function createMockPersonalData(string $firstName): PersonalData + { + $personalData = new PersonalData(); + $personalData->firstName = $firstName; + $personalData->name = 'LastName'; + $personalData->dateOfBirth = new \DateTimeImmutable('1990-01-01'); + $personalData->gender = 'M'; + $personalData->nationality = 'DE'; + $personalData->height = '175'; + $personalData->weight = '70'; + $personalData->shoeSize = '40'; + $personalData->address = new Address(); + $personalData->communication = new Communication(); + + return $personalData; + } + + private function createMockService(int $id): Service + { + $service = new Service(); + $service->id = $id; + $service->price = 50.0; + $service->mapping = []; + $service->individualPrice = []; + $service->subType = 'STANDARD'; + + return $service; + } + + private function createMockPickup(int $id): Pickup + { + $pickup = new Pickup(); + $pickup->id = $id; + $pickup->mapping = []; + + return $pickup; + } +} \ No newline at end of file diff --git a/tests/BusProNet/Utility/TravelCodeUtilityTest.php b/tests/BusProNet/Utility/TravelCodeUtilityTest.php index b1392dc..b5581cc 100644 --- a/tests/BusProNet/Utility/TravelCodeUtilityTest.php +++ b/tests/BusProNet/Utility/TravelCodeUtilityTest.php @@ -2,14 +2,14 @@ namespace BusProNet\Utility; -use App\BusProNet\Utility\TravelCodeUtility; +use App\BusProNet\Utility\DateCodeUtility; use PHPUnit\Framework\TestCase; class TravelCodeUtilityTest extends TestCase { public function testSanitizeCode(): void { - $utility = new TravelCodeUtility(); + $utility = new DateCodeUtility(); $travelCode = 'ABCDEF/010125'; $sanitized = $utility->sanitize($travelCode); @@ -26,7 +26,7 @@ class TravelCodeUtilityTest extends TestCase public function testGetBaseCode(): void { - $utility = new TravelCodeUtility(); + $utility = new DateCodeUtility(); $travelCode = 'ABCDEF010125'; $baseCode = $utility->getBaseCode($travelCode); @@ -39,7 +39,7 @@ class TravelCodeUtilityTest extends TestCase public function testGetDate(): void { - $utility = new TravelCodeUtility(); + $utility = new DateCodeUtility(); $travelCode = 'ABCDEF010125'; $date = $utility->getDate($travelCode); diff --git a/tests/Service/TravelDataServiceTest.php b/tests/Service/TravelDataServiceTest.php new file mode 100644 index 0000000..087edf7 --- /dev/null +++ b/tests/Service/TravelDataServiceTest.php @@ -0,0 +1,316 @@ +travelLoader = $this->createMock(TravelLoader::class); + $this->hotelLoader = $this->createMock(HotelLoader::class); + $this->pickupLoader = $this->createMock(PickupLoader::class); + $this->apiClient = $this->createMock(ApiClient::class); + $this->cache = $this->createMock(CacheInterface::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->service = new TravelDataService( + $this->travelLoader, + $this->hotelLoader, + $this->pickupLoader, + $this->apiClient, + $this->cache, + $this->logger, + false, // preferRemote + true // enableFallback + ); + } + + public function testGetTravelDataFromXmlSuccess(): void + { + $dateId = 12345; + $hotelId = 67890; + $travel = new Travel(); + $travel->id = $dateId; + $travel->hotelId = $hotelId; + + $this->travelLoader + ->expects($this->once()) + ->method('loadById') + ->with($dateId, $hotelId) + ->willReturn($travel); + + $this->pickupLoader + ->expects($this->once()) + ->method('patchPickupsDetails') + ->with($travel); + + $this->hotelLoader + ->expects($this->once()) + ->method('patchHotelDetails') + ->with($travel); + + $result = $this->service->getTravelDataFromXml($dateId, $hotelId); + + $this->assertSame($travel, $result); + } + + public function testGetTravelDataFromXmlNotFound(): void + { + $dateId = 12345; + $hotelId = 67890; + + $this->travelLoader + ->expects($this->once()) + ->method('loadById') + ->with($dateId, $hotelId) + ->willReturn(null); + + $this->pickupLoader + ->expects($this->never()) + ->method('patchPickupsDetails'); + + $this->hotelLoader + ->expects($this->never()) + ->method('patchHotelDetails'); + + $result = $this->service->getTravelDataFromXml($dateId, $hotelId); + + $this->assertNull($result); + } + + public function testGetTravelDataFromApiSuccess(): void + { + $dateId = 12345; + $hotelId = 67890; + $productId = 555; + $travel = new Travel(); + $travel->id = $dateId; + $travel->hotelId = $hotelId; + + $this->travelLoader + ->expects($this->once()) + ->method('mapDateIdToProductId') + ->with($dateId) + ->willReturn($productId); + + $this->apiClient + ->expects($this->once()) + ->method('getTravelData') + ->with($productId, $hotelId) + ->willReturn($travel); + + $result = $this->service->getTravelDataFromApi($dateId, $hotelId); + + $this->assertSame($travel, $result); + } + + public function testGetTravelDataFromApiCannotMapDateId(): void + { + $dateId = 12345; + $hotelId = 67890; + + $this->travelLoader + ->expects($this->once()) + ->method('mapDateIdToProductId') + ->with($dateId) + ->willReturn(null); + + $this->apiClient + ->expects($this->never()) + ->method('getTravelData'); + + $result = $this->service->getTravelDataFromApi($dateId, $hotelId); + + $this->assertNull($result); + } + + public function testExistsInXmlTrue(): void + { + $dateId = 12345; + $hotelId = 67890; + $mapping = [ + $dateId => [ + 'id' => $dateId, + 'hotels' => [ + $hotelId => ['id' => $hotelId, 'name' => 'Test Hotel'] + ] + ] + ]; + + $this->travelLoader + ->expects($this->once()) + ->method('generateFilesMap') + ->willReturn($mapping); + + $result = $this->service->existsInXml($dateId, $hotelId); + + $this->assertTrue($result); + } + + public function testExistsInXmlFalseNoTravel(): void + { + $dateId = 12345; + $hotelId = 67890; + $mapping = []; + + $this->travelLoader + ->expects($this->once()) + ->method('generateFilesMap') + ->willReturn($mapping); + + $result = $this->service->existsInXml($dateId, $hotelId); + + $this->assertFalse($result); + } + + public function testExistsInXmlFalseNoHotel(): void + { + $dateId = 12345; + $hotelId = 67890; + $mapping = [ + $dateId => [ + 'id' => $dateId, + 'hotels' => [] + ] + ]; + + $this->travelLoader + ->expects($this->once()) + ->method('generateFilesMap') + ->willReturn($mapping); + + $result = $this->service->existsInXml($dateId, $hotelId); + + $this->assertFalse($result); + } + + public function testGetAvailableSources(): void + { + $dateId = 12345; + $hotelId = 67890; + $productId = 555; + $mapping = [ + $dateId => [ + 'id' => $dateId, + 'hotels' => [ + $hotelId => ['id' => $hotelId, 'name' => 'Test Hotel'] + ] + ] + ]; + + $this->travelLoader + ->expects($this->once()) + ->method('generateFilesMap') + ->willReturn($mapping); + + $this->travelLoader + ->expects($this->once()) + ->method('mapDateIdToProductId') + ->with($dateId) + ->willReturn($productId); + + $result = $this->service->getAvailableSources($dateId, $hotelId); + + $this->assertEquals([ + 'local' => true, + 'remote' => true, + ], $result); + } + + public function testGetTravelDataWithCaching(): void + { + $dateId = 12345; + $hotelId = 67890; + $travel = new Travel(); + $travel->id = $dateId; + $travel->hotelId = $hotelId; + + $cacheItem = $this->createMock(ItemInterface::class); + $cacheItem + ->expects($this->once()) + ->method('expiresAfter') + ->with(300); + + $this->cache + ->expects($this->once()) + ->method('get') + ->with('travel_unified_12345_67890_local') + ->willReturnCallback(function (string $key, callable $callback) use ($cacheItem, $travel) { + return $callback($cacheItem); + }); + + $this->travelLoader + ->expects($this->once()) + ->method('loadById') + ->with($dateId, $hotelId) + ->willReturn($travel); + + $this->pickupLoader + ->expects($this->once()) + ->method('patchPickupsDetails') + ->with($travel); + + $this->hotelLoader + ->expects($this->once()) + ->method('patchHotelDetails') + ->with($travel); + + $result = $this->service->getTravelData($dateId, $hotelId, false, true); + + $this->assertSame($travel, $result); + } + + public function testGetTravelDataWithoutCaching(): void + { + $dateId = 12345; + $hotelId = 67890; + $travel = new Travel(); + $travel->id = $dateId; + $travel->hotelId = $hotelId; + + $this->cache + ->expects($this->never()) + ->method('get'); + + $this->travelLoader + ->expects($this->once()) + ->method('loadById') + ->with($dateId, $hotelId) + ->willReturn($travel); + + $this->pickupLoader + ->expects($this->once()) + ->method('patchPickupsDetails') + ->with($travel); + + $this->hotelLoader + ->expects($this->once()) + ->method('patchHotelDetails') + ->with($travel); + + $result = $this->service->getTravelData($dateId, $hotelId, false, false); + + $this->assertSame($travel, $result); + } +} \ No newline at end of file