diff --git a/src/BusProNet/ApiClient.php b/src/BusProNet/ApiClient.php index 66fb1c8..6f32a84 100644 --- a/src/BusProNet/ApiClient.php +++ b/src/BusProNet/ApiClient.php @@ -14,6 +14,7 @@ use App\BusProNet\Model\Notification; use App\BusProNet\Model\PersonalData; use App\BusProNet\Model\PromoVoucher; use App\BusProNet\Model\PurchaseVoucher; +use App\BusProNet\Model\ServiceAvailabilityResponse; use App\BusProNet\Model\Travel; use App\BusProNet\Traits\ApiClientTrait; use App\BusProNet\XmlParser\ApiResponseParser; @@ -226,8 +227,6 @@ class ApiClient */ public function createBookingInquiry(BookingDto $bookingDto, bool $debug = false): Notification|BookingResponse { - // Forcibly override booking status to generate inquiry payload - $bookingDto->bookingStatus = 'A'; $payload = $this->bookingDataProcessor->createBookingRequestPayload($bookingDto, Constants::BOOKING_TYPE_INQUIRY); $data = [ @@ -285,7 +284,7 @@ class ApiClient /** * @throws ApiClientException */ - public function getAvailabilities(int $dateId): Notification|BaseData + public function getAvailabilities(int $dateId): Notification|ServiceAvailabilityResponse { $data = [ 'user' => $this->config['bpn_username'], diff --git a/src/BusProNet/Constants.php b/src/BusProNet/Constants.php index 014a325..ecb4b6c 100644 --- a/src/BusProNet/Constants.php +++ b/src/BusProNet/Constants.php @@ -52,6 +52,11 @@ final class Constants public const BOOKING_TYPE_INQUIRY = 'Anfrage'; public const BOOKING_TYPE_BOOKING = 'Buchung'; + // Booking status codes from buchungstatusmoeglich attribute + public const BOOKING_STATUS_FREE = 'F'; + public const BOOKING_STATUS_INQUIRY = 'A'; + public const BOOKING_STATUS_OPEN = 'O'; // Optionsbuchung - not relevant for current implementation + // Payment methods public const PAYMENT_METHOD_TRANSFER = 'transfer'; public const PAYMENT_METHOD_DEBIT = 'debit'; diff --git a/src/BusProNet/DataProcessor/BookingDataProcessor.php b/src/BusProNet/DataProcessor/BookingDataProcessor.php index 5a4b96b..488e4fa 100644 --- a/src/BusProNet/DataProcessor/BookingDataProcessor.php +++ b/src/BusProNet/DataProcessor/BookingDataProcessor.php @@ -893,10 +893,12 @@ class BookingDataProcessor } } - // Add promotional voucher as aktionscode (allowed for inquiry bookings) - $promotionalCode = $this->getPromoVoucherCodeForParticipant($participant); - if (null !== $promotionalCode) { - $participantData['aktionscode'] = $promotionalCode; + // Add promotional voucher as aktionscode (excluded from inquiry bookings) + if (false === $isInquiryBooking) { + $promotionalCode = $this->getPromoVoucherCodeForParticipant($participant); + if (null !== $promotionalCode) { + $participantData['aktionscode'] = $promotionalCode; + } } // Add goodwill voucher as participant-level einloesecode @@ -926,15 +928,16 @@ class BookingDataProcessor $this->addServicesFromMap($payload, 'versicherungen', 'versicherung', '@idversicherung', $insuranceMap); // Add purchase vouchers (regular purchase vouchers, excluding goodwill vouchers) - // Purchase vouchers are allowed for both inquiry and final bookings - // Goodwill vouchers are handled separately and excluded from inquiry bookings - $purchaseVouchers = $this->collectPurchaseVouchers($bookingDto); - if (false === empty($purchaseVouchers)) { - $payload['gutscheine']['gutschein'] = []; - foreach ($purchaseVouchers as $code) { - $payload['gutscheine']['gutschein'][] = [ - '@einloesecode' => $code, - ]; + // All vouchers (purchase, promotional, goodwill) are excluded from inquiry bookings + if (false === $isInquiryBooking) { + $purchaseVouchers = $this->collectPurchaseVouchers($bookingDto); + if (false === empty($purchaseVouchers)) { + $payload['gutscheine']['gutschein'] = []; + foreach ($purchaseVouchers as $code) { + $payload['gutscheine']['gutschein'][] = [ + '@einloesecode' => $code, + ]; + } } } diff --git a/src/BusProNet/Model/ServiceAvailabilityResponse.php b/src/BusProNet/Model/ServiceAvailabilityResponse.php new file mode 100644 index 0000000..4cc78a0 --- /dev/null +++ b/src/BusProNet/Model/ServiceAvailabilityResponse.php @@ -0,0 +1,34 @@ + $services Service availability data indexed by service ID + * @param array $allowedBookingStatus Allowed booking status codes (e.g., ['F', 'O', 'A']) + * @param string|null $travelStatus Travel status from API (e.g., 'frei', 'anfrage') + */ + public function __construct( + private readonly array $services, + public readonly array $allowedBookingStatus = [], + public readonly ?string $travelStatus = null, + ) { + } + + /** + * @return array + */ + public function getServices(): array + { + return $this->services; + } +} diff --git a/src/BusProNet/Model/Travel.php b/src/BusProNet/Model/Travel.php index 869b372..1d23a2e 100644 --- a/src/BusProNet/Model/Travel.php +++ b/src/BusProNet/Model/Travel.php @@ -56,6 +56,15 @@ class Travel #[Groups(['api:single'])] public ?string $status = null; + /** + * Allowed booking status codes from buchungstatusmoeglich attribute. + * Array of status codes (F, A, O) indicating which booking types are permitted. + * + * @var array + */ + #[Groups(['api:single'])] + public array $allowedBookingStatus = []; + #[Groups(['api:list', 'api:single'])] public ?float $priceFrom = null; @@ -223,19 +232,18 @@ class Travel } /** - * Retrieves all available rooms for booking. + * Retrieves all bookable rooms (both regular and inquiry). * - * Filters the rooms collection to return only rooms that have availability - * greater than zero and have an available status. This ensures only - * bookable rooms are returned for selection. + * Returns rooms that have availability greater than zero and a bookable status + * (either 'Frei' for regular booking or 'Anfrage' for inquiry booking). * - * @return array The filtered array of available rooms, indexed by room ID + * @return array The filtered array of bookable rooms, indexed by room ID */ public function getAvailableRooms(): array { $result = []; foreach ($this->rooms as $room) { - if ($room->available > 0 && Constants::STATUS_AVAILABLE === $room->status) { + if ($room->available > 0 && $this->isBookableRoomStatus($room->status)) { $result[$room->id] = $room; } } @@ -243,6 +251,50 @@ class Travel return $result; } + /** + * Checks if the travel has any bookable rooms. + * + * @return bool True if at least one room is bookable + */ + public function hasBookableRooms(): bool + { + return [] !== $this->getAvailableRooms(); + } + + /** + * Checks if all bookable rooms require inquiry booking. + * + * Returns true if there are bookable rooms but none with 'Frei' status. + * This indicates the booking must be submitted as an inquiry. + * + * @return bool True if only inquiry rooms are available + */ + public function requiresInquiryBooking(): bool + { + $bookableRooms = $this->getAvailableRooms(); + + if ([] === $bookableRooms) { + return false; + } + + foreach ($bookableRooms as $room) { + if (Constants::STATUS_AVAILABLE === $room->status) { + return false; + } + } + + return true; + } + + /** + * Checks if a room status allows booking. + */ + private function isBookableRoomStatus(?string $status): bool + { + return Constants::STATUS_AVAILABLE === $status + || Constants::STATUS_ON_REQUEST === $status; + } + /** * Retrieves a room by its ID. * @@ -260,4 +312,16 @@ class Travel return false === empty($rooms) ? reset($rooms) : null; } + + /** + * Checks if a specific booking status code is allowed for this travel. + * + * @param string $statusCode The booking status code to check (F, A, or O) + * + * @return bool True if the status is allowed, false otherwise + */ + public function isBookingStatusAllowed(string $statusCode): bool + { + return in_array($statusCode, $this->allowedBookingStatus, true); + } } diff --git a/src/BusProNet/XmlLoader/TravelLoader.php b/src/BusProNet/XmlLoader/TravelLoader.php index b08b0b0..146c9e8 100644 --- a/src/BusProNet/XmlLoader/TravelLoader.php +++ b/src/BusProNet/XmlLoader/TravelLoader.php @@ -4,6 +4,7 @@ namespace App\BusProNet\XmlLoader; use App\BusProNet\Model\BaseData; use App\BusProNet\Model\MutableData; +use App\BusProNet\Model\ServiceAvailabilityResponse; use App\BusProNet\Model\Travel; use App\BusProNet\Utility\DateCodeUtility; use App\BusProNet\XmlParser\TravelParser; @@ -278,20 +279,26 @@ class TravelLoader extends AbstractLoader * Apply availability data to travel services. * * Updates the availability status of additional and transportation - * services based on the provided availability data. + * services, and the allowed booking status, based on the provided + * availability data. * - * @param Travel $travel The travel object to update - * @param BaseData $availabilities The availability data for services + * @param Travel $travel The travel object to update + * @param ServiceAvailabilityResponse $availabilities The availability data for services */ - public function patchAvailabilities(Travel $travel, BaseData $availabilities): void + public function patchAvailabilities(Travel $travel, ServiceAvailabilityResponse $availabilities): void { - $serviceAvailabilities = $availabilities->getItems(); + $serviceAvailabilities = $availabilities->getServices(); foreach ([...$travel->additionalServices, ...$travel->transportationServices] as $service) { if (array_key_exists($service->id, $serviceAvailabilities)) { $service->available = $serviceAvailabilities[$service->id]->available; } } + + // Patch travel-level booking status from availability response + if ([] !== $availabilities->allowedBookingStatus) { + $travel->allowedBookingStatus = $availabilities->allowedBookingStatus; + } } /** diff --git a/src/BusProNet/XmlParser/AvailabilitiesParser.php b/src/BusProNet/XmlParser/AvailabilitiesParser.php index 9fcd892..55b4102 100644 --- a/src/BusProNet/XmlParser/AvailabilitiesParser.php +++ b/src/BusProNet/XmlParser/AvailabilitiesParser.php @@ -5,11 +5,12 @@ namespace App\BusProNet\XmlParser; use App\BusProNet\Model\Availability; use App\BusProNet\Model\BaseData; use App\BusProNet\Model\Room; +use App\BusProNet\Model\ServiceAvailabilityResponse; use Symfony\Component\DomCrawler\Crawler; class AvailabilitiesParser extends AbstractParser { - public function parseServices(Crawler $result): BaseData + public function parseServices(Crawler $result): ServiceAvailabilityResponse { $availabilities = []; @@ -26,7 +27,21 @@ class AvailabilitiesParser extends AbstractParser }) ; - return new BaseData($availabilities); + // Parse travel-level booking status from node + $allowedBookingStatus = []; + $travelStatus = null; + $reiseNode = $result->filterXPath('//reise'); + + if ($reiseNode->count() > 0) { + $travelStatus = $reiseNode->attr('status'); + + $bookingStatusPossible = $reiseNode->attr('buchungstatusmoeglich') ?? ''; + if ('' !== $bookingStatusPossible) { + $allowedBookingStatus = str_split($bookingStatusPossible); + } + } + + return new ServiceAvailabilityResponse($availabilities, $allowedBookingStatus, $travelStatus); } public function parseRooms(Crawler $result): BaseData diff --git a/src/BusProNet/XmlParser/TravelParser.php b/src/BusProNet/XmlParser/TravelParser.php index 06c3f5f..b1e8fb4 100644 --- a/src/BusProNet/XmlParser/TravelParser.php +++ b/src/BusProNet/XmlParser/TravelParser.php @@ -62,7 +62,6 @@ class TravelParser extends AbstractParser } $travel->type = $node->attr('reiseart'); - $travel->status = $this->getStringOrNullValue($node->filterXPath('//status_hin')); $travel->priceFrom = $this->stringToFloat($this->getStringOrNullValue($node->filterXPath('//abpreis'))); $travel->selectionGroups = $this->getSelectionGroups($node->filterXPath('//selektiongruppe')); $travel->additionalServices = $this diff --git a/src/Controller/Booking/Create/IndexController.php b/src/Controller/Booking/Create/IndexController.php index 340634d..b61fcbd 100644 --- a/src/Controller/Booking/Create/IndexController.php +++ b/src/Controller/Booking/Create/IndexController.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace App\Controller\Booking\Create; use App\BusProNet\XmlLoader\AgencyLoader; -use App\Exception\BookingNotPossibleException; use App\Exception\HotelNotFoundException; use App\Exception\HotelNotInTravelException; use App\Exception\NoRoomsAvailableException; @@ -70,8 +69,6 @@ class IndexController extends AbstractController throw $this->createNotFoundException(sprintf('Hotel ID %d is not available for travel ID %d', $hotelId, $dateId)); } catch (NoRoomsAvailableException) { throw $this->createNotFoundException('No rooms available for this travel.'); - } catch (BookingNotPossibleException) { - throw $this->createNotFoundException('Booking is not possible for this travel (Buchungsstop).'); } } diff --git a/src/Controller/Booking/Create/Step1Controller.php b/src/Controller/Booking/Create/Step1Controller.php index 24b30de..f8b37f3 100644 --- a/src/Controller/Booking/Create/Step1Controller.php +++ b/src/Controller/Booking/Create/Step1Controller.php @@ -70,6 +70,9 @@ class Step1Controller extends AbstractController $this->bookingService->resetParticipantAssignments($bookingCreateDto); } + // Check if room selection forces inquiry mode + $this->bookingService->updateBookingStatusFromRoomSelection($bookingCreateDto); + $bookingCreateDto->currentStep = 2; $this->bookingService->saveBookingCreateDto($request, $bookingCreateDto); diff --git a/src/Controller/Booking/Create/Step3Controller.php b/src/Controller/Booking/Create/Step3Controller.php index cb6b797..0f496b8 100644 --- a/src/Controller/Booking/Create/Step3Controller.php +++ b/src/Controller/Booking/Create/Step3Controller.php @@ -88,6 +88,24 @@ class Step3Controller extends AbstractController } if (false === $inquiryResponse->isInquiryValid()) { + // Check if API suggests inquiry booking instead of showing error + if ($this->shouldFallbackToInquiryMode($inquiryResponse)) { + // Auto-switch to inquiry mode + $bookingCreateDto->bookingStatus = 'A'; + $bookingCreateDto->currentStep = 4; + $this->bookingService->saveBookingCreateDto($request, $bookingCreateDto); + + $message = 'Buchung konnte nicht validiert werden.'; + if (null !== $inquiryResponse->message && '' !== trim($inquiryResponse->message)) { + $message = $inquiryResponse->message; + } + + $this->addFlash('info', $message); + + return $this->hxRedirect($request, $this->generateUrl('app_booking_create_step_4')); + } + + // Not a fallback scenario - show validation error $errorMessage = 'Buchung konnte nicht validiert werden.'; if (null !== $inquiryResponse->message && '' !== trim($inquiryResponse->message)) { $errorMessage .= ' '.$inquiryResponse->message; @@ -199,4 +217,31 @@ class Step3Controller extends AbstractController 'groupedSelectedRooms' => $groupedSelectedRooms, ]); } + + /** + * Determines if validation failure should trigger inquiry mode fallback. + * + * Checks if the API response indicates that the booking should proceed as + * an inquiry rather than showing an error. This handles scenarios where + * availability changes between booking initialization and validation. + * + * @param \App\BusProNet\Model\BookingResponse $response The API response + * + * @return bool True if should fallback to inquiry mode, false if should show error + */ + private function shouldFallbackToInquiryMode(\App\BusProNet\Model\BookingResponse $response): bool + { + // Check for "nicht möglich" status + inquiry suggestion in message + if ('nicht möglich' !== $response->status) { + return false; + } + + $message = $response->message ?? ''; + if ('' === $message) { + return false; + } + + // Check if message suggests inquiry booking (case-insensitive) + return 1 === preg_match('/anfrage/i', $message); + } } diff --git a/src/Controller/Booking/Create/Step4Controller.php b/src/Controller/Booking/Create/Step4Controller.php index ce29fec..89a63e0 100644 --- a/src/Controller/Booking/Create/Step4Controller.php +++ b/src/Controller/Booking/Create/Step4Controller.php @@ -21,6 +21,7 @@ use Symfony\Component\Form\FormInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; +use Symfony\Contracts\Cache\CacheInterface; /** * Handles the fourth step of the booking creation process (confirmation). @@ -36,6 +37,7 @@ class Step4Controller extends AbstractController private readonly BookingSummaryDataService $summaryDataService, private readonly BookingPriceCalculatorService $priceCalculator, private readonly ApiClient $apiClient, + private readonly CacheInterface $cache, private readonly LoggerInterface $logger, ) { } @@ -99,6 +101,7 @@ class Step4Controller extends AbstractController // Success: Store booking number in flash and clear session $this->addFlash('booking_number', $bookingResponse->transactionNumber); + $this->clearTravelDataCache($bookingCreateDto); $this->bookingService->clearBookingCreateDto($request); return $this->hxRedirect($request, $this->generateUrl('app_booking_create_success')); @@ -151,4 +154,25 @@ class Step4Controller extends AbstractController 'participantPrices' => $this->priceCalculator->calculateAllParticipantIndividualPrices($bookingCreateDto), ]); } + + /** + * Clears travel data and availability cache after successful booking. + */ + private function clearTravelDataCache(BookingDto $bookingDto): void + { + $dateId = $bookingDto->travel->id; + $hotelId = $bookingDto->hotelId; + + // Clear availability cache + $this->cache->delete(sprintf('availability_%d', $dateId)); + + // Clear travel data cache (both local and remote variants) + $this->cache->delete(sprintf('travel_unified_%d_%d_local', $dateId, $hotelId)); + $this->cache->delete(sprintf('travel_unified_%d_%d_remote', $dateId, $hotelId)); + + $this->logger->info('Cleared travel data cache after successful booking', [ + 'dateId' => $dateId, + 'hotelId' => $hotelId, + ]); + } } diff --git a/src/Form/Service/Condition/FinalBookingOnlyCondition.php b/src/Form/Service/Condition/FinalBookingOnlyCondition.php new file mode 100644 index 0000000..3de1662 --- /dev/null +++ b/src/Form/Service/Condition/FinalBookingOnlyCondition.php @@ -0,0 +1,42 @@ + $formData Current form data (unused) + * + * @return bool True if booking status is 'F' (final), false if 'A' (inquiry) + */ + public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool + { + return 'F' === $bookingDto->bookingStatus; + } + + public function getDependentFields(): array + { + return []; + } + + public function getDescription(): string + { + return 'Only available for final bookings (not inquiry bookings)'; + } +} diff --git a/src/Form/Service/CreateFieldStateProvider.php b/src/Form/Service/CreateFieldStateProvider.php index c9098bc..82c4837 100644 --- a/src/Form/Service/CreateFieldStateProvider.php +++ b/src/Form/Service/CreateFieldStateProvider.php @@ -13,6 +13,7 @@ use App\Form\Service\Condition\BulkInsuranceBookingCondition; use App\Form\Service\Condition\CompositeCondition; use App\Form\Service\Condition\DateOfBirthProvidedCondition; use App\Form\Service\Condition\FieldValueCondition; +use App\Form\Service\Condition\FinalBookingOnlyCondition; use App\Form\Service\Condition\RentalSelectionCondition; use App\Form\Service\Condition\RoomSelectionCondition; use App\Form\Service\Condition\ServiceSubTypeCondition; @@ -267,9 +268,17 @@ class CreateFieldStateProvider extends AbstractFieldStateProvider 'static_text' => new SingleRoomTypeCondition(), ]; - // Voucher fields (purchaseVoucherCode, promoVoucherCode) have no state conditions - // They are enabled by default for all participants in create mode - // No visibility rules - always shown to allow optional voucher entry + // Voucher fields - hidden for inquiry bookings (status='A') + // Vouchers cannot be redeemed or submitted for inquiry bookings + $finalBookingOnlyCondition = new FinalBookingOnlyCondition(); + + $this->fieldStateConditions['purchaseVoucherCode'] = [ + 'hidden' => CompositeCondition::not($finalBookingOnlyCondition), + ]; + + $this->fieldStateConditions['promoVoucherCode'] = [ + 'hidden' => CompositeCondition::not($finalBookingOnlyCondition), + ]; // Example field state conditions would be registered here // For demonstration purposes, here are some example patterns: diff --git a/src/Service/BookingService.php b/src/Service/BookingService.php index e69e924..13819ab 100644 --- a/src/Service/BookingService.php +++ b/src/Service/BookingService.php @@ -5,7 +5,6 @@ namespace App\Service; use App\BusProNet\Constants; use App\BusProNet\Model\Room; use App\BusProNet\Model\Travel; -use App\Exception\BookingNotPossibleException; use App\Exception\BookingSessionNotFoundException; use App\Exception\NoRoomsAvailableException; use App\Form\Model\BookingDto; @@ -192,7 +191,6 @@ class BookingService * Handles three booking status types: * - 'Frei': Regular booking with availability checks * - 'Anfrage': Inquiry booking, allows booking even with 0 availability - * - 'Buchungsstop': Booking stopped, no bookings allowed */ public function startFreshBooking(Request $request, int $dateId, int $hotelId, ?int $agencyId = null): BookingDto { @@ -201,30 +199,23 @@ class BookingService throw new NotFoundHttpException(sprintf('Travel data not found for date ID %d and hotel ID %d', $dateId, $hotelId)); } - // Handle Buchungsstop - no bookings allowed at all - if ('Buchungsstop' === $travelData->status) { - throw new BookingNotPossibleException($dateId, $hotelId); + // Fetch and patch availability data (includes allowedBookingStatus from API) + $availabilities = $this->travelDataService->getAvailabilityData($dateId); + if (null !== $availabilities) { + $this->travelDataService->patchAvailabilities($travelData, $availabilities); } - // Determine booking status based on travel status - $isInquiryBooking = 'Anfrage' === $travelData->status; - $bookingStatus = $isInquiryBooking ? 'A' : 'F'; - - // Get available rooms + // Get bookable rooms (Frei or Anfrage with available > 0) $availableRooms = $travelData->getAvailableRooms(); - // For regular bookings (Frei), prevent entry when no rooms are available - // For inquiry bookings (Anfrage), allow even with 0 availability - if (false === $isInquiryBooking && empty($availableRooms)) { + // No bookable rooms means booking is not possible + if ([] === $availableRooms) { throw new NoRoomsAvailableException($dateId, $hotelId); } - // For inquiry bookings with 0 availability, get all rooms ignoring availability count - if ($isInquiryBooking && empty($availableRooms)) { - $availableRooms = array_filter($travelData->rooms, function (Room $room) { - return Constants::STATUS_AVAILABLE === $room->status; - }); - } + // Determine initial booking status based on room availability + // This is for early UI decisions (e.g., voucher visibility), final verdict is in step 3 + $bookingStatus = $this->determineInitialBookingStatus($travelData); // Create room selections with zero quantities (user will set these in step 1) $roomSelections = array_map( @@ -334,11 +325,11 @@ class BookingService } /** - * Groups available rooms by selection type ('by_pax' or 'by_room'). + * Groups available rooms by selection type ('by_pax' or 'by_room') and sorts them by maxPax. * * @param array $rooms Rooms indexed by room ID * - * @return array{by_pax: array, by_room: array} + * @return array{by_pax: array, by_room: array} Rooms grouped and sorted by maxPax (ascending) */ public function groupRoomsBySelectionType(array $rooms): array { @@ -354,6 +345,10 @@ class BookingService } } + // Sort each group by maxPax (ascending order - smallest capacity first) + uasort($groups[Room::SELECTION_TYPE_BY_PAX], fn (Room $a, Room $b): int => $a->maxPax <=> $b->maxPax); + uasort($groups[Room::SELECTION_TYPE_BY_ROOM], fn (Room $a, Room $b): int => $a->maxPax <=> $b->maxPax); + return $groups; } @@ -491,4 +486,63 @@ class BookingService $participant->additionalServices = $currentSelections; } } + + /** + * Determines the initial booking status based on travel configuration. + * + * This method implements a multi-stage check to determine if a booking + * should start as inquiry ('A') or final ('F') booking: + * 1. Checks if final bookings are allowed via buchungstatusmoeglich attribute from API + * 2. Checks if only inquiry rooms are available (no Frei rooms with available > 0) + * + * @param Travel $travelData The travel data to evaluate + * + * @return string 'A' for inquiry booking, 'F' for final booking + */ + private function determineInitialBookingStatus(Travel $travelData): string + { + // Check 1: Allowed booking status from buchungstatusmoeglich attribute (from availability API) + // Only applies if the API provided status restrictions + if ([] !== $travelData->allowedBookingStatus + && false === $travelData->isBookingStatusAllowed(Constants::BOOKING_STATUS_FREE)) { + return 'A'; + } + + // Check 2: Only inquiry rooms available (all rooms with available > 0 have status 'Anfrage') + if ($travelData->requiresInquiryBooking()) { + return 'A'; + } + + return 'F'; + } + + /** + * Updates booking status based on selected room requirements. + * + * Checks if any selected room has inquiry-only status. If so, forces + * the entire booking to inquiry mode. This override happens after room + * selection in Step 1 and respects the business rule: if ANY room requires + * inquiry, the whole booking becomes an inquiry. + * + * @param BookingDto $bookingDto The booking DTO to update + */ + public function updateBookingStatusFromRoomSelection(BookingDto $bookingDto): void + { + // Skip if already inquiry - no need to check + if ('A' === $bookingDto->bookingStatus) { + return; + } + + // Check selected rooms for inquiry-only status + foreach ($bookingDto->getSelectedRooms() as $roomSelection) { + $room = $bookingDto->travel->getRoomById($roomSelection->roomId); + if ($room + && Constants::STATUS_ON_REQUEST === $room->status + && $room->available > 0) { + $bookingDto->bookingStatus = 'A'; + + return; + } + } + } } diff --git a/src/Service/TravelDataService.php b/src/Service/TravelDataService.php index a81f95c..e16055b 100644 --- a/src/Service/TravelDataService.php +++ b/src/Service/TravelDataService.php @@ -8,6 +8,7 @@ use App\BusProNet\ApiClient; use App\BusProNet\Exception\ApiClientException; use App\BusProNet\Model\BaseData; use App\BusProNet\Model\Notification; +use App\BusProNet\Model\ServiceAvailabilityResponse; use App\BusProNet\Model\Travel; use App\BusProNet\XmlLoader\HotelLoader; use App\BusProNet\XmlLoader\InsuranceLoader; @@ -525,9 +526,9 @@ class TravelDataService * @param bool $cached Whether to use cached data (default: false, TTL when cached: 60 seconds) * @param int $ttl Cache TTL in seconds when $cached is true (default: 60 seconds) * - * @return BaseData|null The availability data or null if not available or error occurred + * @return ServiceAvailabilityResponse|null The availability data or null if not available or error occurred */ - public function getAvailabilityData(int $dateId, bool $cached = false, int $ttl = 60): ?BaseData + public function getAvailabilityData(int $dateId, bool $cached = false, int $ttl = 60): ?ServiceAvailabilityResponse { if ($cached) { $cacheKey = sprintf('availability_%d', $dateId); @@ -556,7 +557,7 @@ class TravelDataService /** * Fetches availability data directly from the API without caching. */ - private function fetchAvailabilityData(int $dateId): ?BaseData + private function fetchAvailabilityData(int $dateId): ?ServiceAvailabilityResponse { try { $availabilities = $this->apiClient->getAvailabilities($dateId); @@ -590,12 +591,13 @@ class TravelDataService * Apply availability data to travel services. * * Updates the availability status of additional and transportation - * services based on the provided availability data. + * services, and the allowed booking status, based on the provided + * availability data. * - * @param Travel $travel The travel object to update - * @param BaseData $availabilities The availability data for services + * @param Travel $travel The travel object to update + * @param ServiceAvailabilityResponse $availabilities The availability data for services */ - public function patchAvailabilities(Travel $travel, BaseData $availabilities): void + public function patchAvailabilities(Travel $travel, ServiceAvailabilityResponse $availabilities): void { $this->travelLoader->patchAvailabilities($travel, $availabilities); diff --git a/templates/booking/create/step_4.html.twig b/templates/booking/create/step_4.html.twig index 48a4bd7..5bdd4db 100644 --- a/templates/booking/create/step_4.html.twig +++ b/templates/booking/create/step_4.html.twig @@ -421,7 +421,13 @@
Zurück - +
{{ form_end(form) }} diff --git a/tests/BusProNet/XmlParser/TravelParserTest.php b/tests/BusProNet/XmlParser/TravelParserTest.php index ffd556f..582f466 100644 --- a/tests/BusProNet/XmlParser/TravelParserTest.php +++ b/tests/BusProNet/XmlParser/TravelParserTest.php @@ -185,81 +185,6 @@ class TravelParserTest extends TestCase $this->assertNull($service->description); // Empty hinweis should result in null description } - public function testParseTravelStatusFrei(): void - { - $xmlContent = ' - - - - Test Travel - 659,00 - Frei - - - - - - - -'; - - $crawler = new Crawler($xmlContent); - $travelNode = $crawler->filterXPath('//reise/termin')->first(); - $travel = $this->parser->parse($travelNode); - - $this->assertSame('Frei', $travel->status); - } - - public function testParseTravelStatusAnfrage(): void - { - $xmlContent = ' - - - - Test Travel - 659,00 - Anfrage - - - - - - - -'; - - $crawler = new Crawler($xmlContent); - $travelNode = $crawler->filterXPath('//reise/termin')->first(); - $travel = $this->parser->parse($travelNode); - - $this->assertSame('Anfrage', $travel->status); - } - - public function testParseTravelStatusBuchungsstop(): void - { - $xmlContent = ' - - - - Test Travel - 659,00 - Buchungsstop - - - - - - - -'; - - $crawler = new Crawler($xmlContent); - $travelNode = $crawler->filterXPath('//reise/termin')->first(); - $travel = $this->parser->parse($travelNode); - - $this->assertSame('Buchungsstop', $travel->status); - } - public function testParseTravelWithoutStatus(): void { $xmlContent = ' diff --git a/tests/Service/BookingServiceStatusTest.php b/tests/Service/BookingServiceStatusTest.php index be36d60..df8e539 100644 --- a/tests/Service/BookingServiceStatusTest.php +++ b/tests/Service/BookingServiceStatusTest.php @@ -4,9 +4,9 @@ declare(strict_types=1); namespace App\Tests\Service; +use App\BusProNet\Constants; use App\BusProNet\Model\Room; use App\BusProNet\Model\Travel; -use App\Exception\BookingNotPossibleException; use App\Exception\NoRoomsAvailableException; use App\Service\BookingPriceCalculatorService; use App\Service\BookingService; @@ -35,9 +35,11 @@ class BookingServiceStatusTest extends TestCase ); } - public function testStartFreshBookingWithFreiStatus(): void + public function testStartFreshBookingWithFreiRooms(): void { - $travel = $this->createTravelWithStatus('Frei', 5); + $travel = $this->createTravelWithRooms([ + ['status' => Constants::STATUS_AVAILABLE, 'available' => 5], + ]); $this->travelDataService ->method('getTravelData') @@ -51,9 +53,11 @@ class BookingServiceStatusTest extends TestCase $this->assertCount(1, $bookingDto->roomSelections); } - public function testStartFreshBookingWithFreiStatusAndNoRoomsThrowsException(): void + public function testStartFreshBookingWithFreiRoomsAndNoAvailabilityThrowsException(): void { - $travel = $this->createTravelWithStatus('Frei', 0); + $travel = $this->createTravelWithRooms([ + ['status' => Constants::STATUS_AVAILABLE, 'available' => 0], + ]); $this->travelDataService ->method('getTravelData') @@ -66,9 +70,11 @@ class BookingServiceStatusTest extends TestCase $this->bookingService->startFreshBooking($request, 123, 456); } - public function testStartFreshBookingWithAnfrageStatus(): void + public function testStartFreshBookingWithAnfrageRooms(): void { - $travel = $this->createTravelWithStatus('Anfrage', 5); + $travel = $this->createTravelWithRooms([ + ['status' => Constants::STATUS_ON_REQUEST, 'available' => 5], + ]); $this->travelDataService ->method('getTravelData') @@ -82,9 +88,69 @@ class BookingServiceStatusTest extends TestCase $this->assertCount(1, $bookingDto->roomSelections); } - public function testStartFreshBookingWithAnfrageStatusAndNoRoomsAllowsBooking(): void + public function testStartFreshBookingWithAnfrageRoomsAndNoAvailabilityThrowsException(): void { - $travel = $this->createTravelWithStatus('Anfrage', 0); + $travel = $this->createTravelWithRooms([ + ['status' => Constants::STATUS_ON_REQUEST, 'available' => 0], + ]); + + $this->travelDataService + ->method('getTravelData') + ->willReturn($travel); + + $request = $this->createRequestWithSession(); + + $this->expectException(NoRoomsAvailableException::class); + + $this->bookingService->startFreshBooking($request, 123, 456); + } + + public function testStartFreshBookingWithMixedRoomsUsesFreeStatus(): void + { + // When both Frei and Anfrage rooms with availability exist, booking status should be 'F' + $travel = $this->createTravelWithRooms([ + ['status' => Constants::STATUS_AVAILABLE, 'available' => 5], + ['status' => Constants::STATUS_ON_REQUEST, 'available' => 3], + ]); + + $this->travelDataService + ->method('getTravelData') + ->willReturn($travel); + + $request = $this->createRequestWithSession(); + + $bookingDto = $this->bookingService->startFreshBooking($request, 123, 456); + + $this->assertSame('F', $bookingDto->bookingStatus); + $this->assertCount(2, $bookingDto->roomSelections); + } + + public function testStartFreshBookingWithNoBookableRoomsThrowsException(): void + { + // Rooms exist but all have 0 availability + $travel = $this->createTravelWithRooms([ + ['status' => Constants::STATUS_AVAILABLE, 'available' => 0], + ['status' => Constants::STATUS_ON_REQUEST, 'available' => 0], + ]); + + $this->travelDataService + ->method('getTravelData') + ->willReturn($travel); + + $request = $this->createRequestWithSession(); + + $this->expectException(NoRoomsAvailableException::class); + + $this->bookingService->startFreshBooking($request, 123, 456); + } + + public function testStartFreshBookingWithApiRestrictionsForcesInquiry(): void + { + // Even with Frei rooms, if API says only 'A' allowed, booking status should be 'A' + $travel = $this->createTravelWithRooms([ + ['status' => Constants::STATUS_AVAILABLE, 'available' => 5], + ]); + $travel->allowedBookingStatus = ['A']; // API restricts to inquiry only $this->travelDataService ->method('getTravelData') @@ -95,60 +161,36 @@ class BookingServiceStatusTest extends TestCase $bookingDto = $this->bookingService->startFreshBooking($request, 123, 456); $this->assertSame('A', $bookingDto->bookingStatus); - $this->assertCount(1, $bookingDto->roomSelections); } - public function testStartFreshBookingWithBuchungsstopThrowsException(): void - { - $travel = $this->createTravelWithStatus('Buchungsstop', 5); - - $this->travelDataService - ->method('getTravelData') - ->willReturn($travel); - - $request = $this->createRequestWithSession(); - - $this->expectException(BookingNotPossibleException::class); - $this->expectExceptionMessage('Für diese Reise ist aktuell keine Buchung möglich'); - - $this->bookingService->startFreshBooking($request, 123, 456); - } - - public function testStartFreshBookingWithBuchungsstopAndNoRoomsThrowsException(): void - { - $travel = $this->createTravelWithStatus('Buchungsstop', 0); - - $this->travelDataService - ->method('getTravelData') - ->willReturn($travel); - - $request = $this->createRequestWithSession(); - - $this->expectException(BookingNotPossibleException::class); - - $this->bookingService->startFreshBooking($request, 123, 456); - } - - private function createTravelWithStatus(string $status, int $availability): Travel + /** + * @param array $roomsConfig + */ + private function createTravelWithRooms(array $roomsConfig): Travel { $travel = new Travel(); $travel->id = 123; $travel->hotelId = 456; - $travel->status = $status; $travel->dateFrom = new \DateTimeImmutable('2030-01-01'); $travel->dateTo = new \DateTimeImmutable('2030-01-06'); + $travel->allowedBookingStatus = []; // Empty means no API restrictions - $room = new Room(); - $room->id = 1; - $room->label = 'Test Room'; - $room->price = 100.0; - $room->available = $availability; - $room->status = \App\BusProNet\Constants::STATUS_AVAILABLE; - $room->minPax = 2; - $room->category = 'A'; - $room->boardId = 1; + $rooms = []; + foreach ($roomsConfig as $index => $config) { + $room = new Room(); + $room->id = $index + 1; + $room->label = 'Test Room '.($index + 1); + $room->price = 100.0; + $room->available = $config['available']; + $room->status = $config['status']; + $room->minPax = 2; + $room->category = 'A'; + $room->boardId = 1; - $travel->rooms = [1 => $room]; + $rooms[$room->id] = $room; + } + + $travel->rooms = $rooms; return $travel; }