feat: correct determination of inquiry status and conditional voucher field display
This commit is contained in:
@@ -14,6 +14,7 @@ use App\BusProNet\Model\Notification;
|
|||||||
use App\BusProNet\Model\PersonalData;
|
use App\BusProNet\Model\PersonalData;
|
||||||
use App\BusProNet\Model\PromoVoucher;
|
use App\BusProNet\Model\PromoVoucher;
|
||||||
use App\BusProNet\Model\PurchaseVoucher;
|
use App\BusProNet\Model\PurchaseVoucher;
|
||||||
|
use App\BusProNet\Model\ServiceAvailabilityResponse;
|
||||||
use App\BusProNet\Model\Travel;
|
use App\BusProNet\Model\Travel;
|
||||||
use App\BusProNet\Traits\ApiClientTrait;
|
use App\BusProNet\Traits\ApiClientTrait;
|
||||||
use App\BusProNet\XmlParser\ApiResponseParser;
|
use App\BusProNet\XmlParser\ApiResponseParser;
|
||||||
@@ -226,8 +227,6 @@ class ApiClient
|
|||||||
*/
|
*/
|
||||||
public function createBookingInquiry(BookingDto $bookingDto, bool $debug = false): Notification|BookingResponse
|
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);
|
$payload = $this->bookingDataProcessor->createBookingRequestPayload($bookingDto, Constants::BOOKING_TYPE_INQUIRY);
|
||||||
|
|
||||||
$data = [
|
$data = [
|
||||||
@@ -285,7 +284,7 @@ class ApiClient
|
|||||||
/**
|
/**
|
||||||
* @throws ApiClientException
|
* @throws ApiClientException
|
||||||
*/
|
*/
|
||||||
public function getAvailabilities(int $dateId): Notification|BaseData
|
public function getAvailabilities(int $dateId): Notification|ServiceAvailabilityResponse
|
||||||
{
|
{
|
||||||
$data = [
|
$data = [
|
||||||
'user' => $this->config['bpn_username'],
|
'user' => $this->config['bpn_username'],
|
||||||
|
|||||||
@@ -52,6 +52,11 @@ final class Constants
|
|||||||
public const BOOKING_TYPE_INQUIRY = 'Anfrage';
|
public const BOOKING_TYPE_INQUIRY = 'Anfrage';
|
||||||
public const BOOKING_TYPE_BOOKING = 'Buchung';
|
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
|
// Payment methods
|
||||||
public const PAYMENT_METHOD_TRANSFER = 'transfer';
|
public const PAYMENT_METHOD_TRANSFER = 'transfer';
|
||||||
public const PAYMENT_METHOD_DEBIT = 'debit';
|
public const PAYMENT_METHOD_DEBIT = 'debit';
|
||||||
|
|||||||
@@ -893,10 +893,12 @@ class BookingDataProcessor
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add promotional voucher as aktionscode (allowed for inquiry bookings)
|
// Add promotional voucher as aktionscode (excluded from inquiry bookings)
|
||||||
$promotionalCode = $this->getPromoVoucherCodeForParticipant($participant);
|
if (false === $isInquiryBooking) {
|
||||||
if (null !== $promotionalCode) {
|
$promotionalCode = $this->getPromoVoucherCodeForParticipant($participant);
|
||||||
$participantData['aktionscode'] = $promotionalCode;
|
if (null !== $promotionalCode) {
|
||||||
|
$participantData['aktionscode'] = $promotionalCode;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add goodwill voucher as participant-level einloesecode
|
// Add goodwill voucher as participant-level einloesecode
|
||||||
@@ -926,15 +928,16 @@ class BookingDataProcessor
|
|||||||
$this->addServicesFromMap($payload, 'versicherungen', 'versicherung', '@idversicherung', $insuranceMap);
|
$this->addServicesFromMap($payload, 'versicherungen', 'versicherung', '@idversicherung', $insuranceMap);
|
||||||
|
|
||||||
// Add purchase vouchers (regular purchase vouchers, excluding goodwill vouchers)
|
// Add purchase vouchers (regular purchase vouchers, excluding goodwill vouchers)
|
||||||
// Purchase vouchers are allowed for both inquiry and final bookings
|
// All vouchers (purchase, promotional, goodwill) are excluded from inquiry bookings
|
||||||
// Goodwill vouchers are handled separately and excluded from inquiry bookings
|
if (false === $isInquiryBooking) {
|
||||||
$purchaseVouchers = $this->collectPurchaseVouchers($bookingDto);
|
$purchaseVouchers = $this->collectPurchaseVouchers($bookingDto);
|
||||||
if (false === empty($purchaseVouchers)) {
|
if (false === empty($purchaseVouchers)) {
|
||||||
$payload['gutscheine']['gutschein'] = [];
|
$payload['gutscheine']['gutschein'] = [];
|
||||||
foreach ($purchaseVouchers as $code) {
|
foreach ($purchaseVouchers as $code) {
|
||||||
$payload['gutscheine']['gutschein'][] = [
|
$payload['gutscheine']['gutschein'][] = [
|
||||||
'@einloesecode' => $code,
|
'@einloesecode' => $code,
|
||||||
];
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\BusProNet\Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response model for service availability API calls.
|
||||||
|
*
|
||||||
|
* Contains both service-level availability data and travel-level
|
||||||
|
* booking status information from the VERFUEGBARKEIT endpoint.
|
||||||
|
*/
|
||||||
|
class ServiceAvailabilityResponse
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<int, Availability> $services Service availability data indexed by service ID
|
||||||
|
* @param array<string> $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<int, Availability>
|
||||||
|
*/
|
||||||
|
public function getServices(): array
|
||||||
|
{
|
||||||
|
return $this->services;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,6 +56,15 @@ class Travel
|
|||||||
#[Groups(['api:single'])]
|
#[Groups(['api:single'])]
|
||||||
public ?string $status = null;
|
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<string>
|
||||||
|
*/
|
||||||
|
#[Groups(['api:single'])]
|
||||||
|
public array $allowedBookingStatus = [];
|
||||||
|
|
||||||
#[Groups(['api:list', 'api:single'])]
|
#[Groups(['api:list', 'api:single'])]
|
||||||
public ?float $priceFrom = null;
|
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
|
* Returns rooms that have availability greater than zero and a bookable status
|
||||||
* greater than zero and have an available status. This ensures only
|
* (either 'Frei' for regular booking or 'Anfrage' for inquiry booking).
|
||||||
* bookable rooms are returned for selection.
|
|
||||||
*
|
*
|
||||||
* @return array<int, Room> The filtered array of available rooms, indexed by room ID
|
* @return array<int, Room> The filtered array of bookable rooms, indexed by room ID
|
||||||
*/
|
*/
|
||||||
public function getAvailableRooms(): array
|
public function getAvailableRooms(): array
|
||||||
{
|
{
|
||||||
$result = [];
|
$result = [];
|
||||||
foreach ($this->rooms as $room) {
|
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;
|
$result[$room->id] = $room;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -243,6 +251,50 @@ class Travel
|
|||||||
return $result;
|
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.
|
* Retrieves a room by its ID.
|
||||||
*
|
*
|
||||||
@@ -260,4 +312,16 @@ class Travel
|
|||||||
|
|
||||||
return false === empty($rooms) ? reset($rooms) : null;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace App\BusProNet\XmlLoader;
|
|||||||
|
|
||||||
use App\BusProNet\Model\BaseData;
|
use App\BusProNet\Model\BaseData;
|
||||||
use App\BusProNet\Model\MutableData;
|
use App\BusProNet\Model\MutableData;
|
||||||
|
use App\BusProNet\Model\ServiceAvailabilityResponse;
|
||||||
use App\BusProNet\Model\Travel;
|
use App\BusProNet\Model\Travel;
|
||||||
use App\BusProNet\Utility\DateCodeUtility;
|
use App\BusProNet\Utility\DateCodeUtility;
|
||||||
use App\BusProNet\XmlParser\TravelParser;
|
use App\BusProNet\XmlParser\TravelParser;
|
||||||
@@ -278,20 +279,26 @@ class TravelLoader extends AbstractLoader
|
|||||||
* Apply availability data to travel services.
|
* Apply availability data to travel services.
|
||||||
*
|
*
|
||||||
* Updates the availability status of additional and transportation
|
* 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 Travel $travel The travel object to update
|
||||||
* @param BaseData $availabilities The availability data for services
|
* @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) {
|
foreach ([...$travel->additionalServices, ...$travel->transportationServices] as $service) {
|
||||||
if (array_key_exists($service->id, $serviceAvailabilities)) {
|
if (array_key_exists($service->id, $serviceAvailabilities)) {
|
||||||
$service->available = $serviceAvailabilities[$service->id]->available;
|
$service->available = $serviceAvailabilities[$service->id]->available;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Patch travel-level booking status from availability response
|
||||||
|
if ([] !== $availabilities->allowedBookingStatus) {
|
||||||
|
$travel->allowedBookingStatus = $availabilities->allowedBookingStatus;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -5,11 +5,12 @@ namespace App\BusProNet\XmlParser;
|
|||||||
use App\BusProNet\Model\Availability;
|
use App\BusProNet\Model\Availability;
|
||||||
use App\BusProNet\Model\BaseData;
|
use App\BusProNet\Model\BaseData;
|
||||||
use App\BusProNet\Model\Room;
|
use App\BusProNet\Model\Room;
|
||||||
|
use App\BusProNet\Model\ServiceAvailabilityResponse;
|
||||||
use Symfony\Component\DomCrawler\Crawler;
|
use Symfony\Component\DomCrawler\Crawler;
|
||||||
|
|
||||||
class AvailabilitiesParser extends AbstractParser
|
class AvailabilitiesParser extends AbstractParser
|
||||||
{
|
{
|
||||||
public function parseServices(Crawler $result): BaseData
|
public function parseServices(Crawler $result): ServiceAvailabilityResponse
|
||||||
{
|
{
|
||||||
$availabilities = [];
|
$availabilities = [];
|
||||||
|
|
||||||
@@ -26,7 +27,21 @@ class AvailabilitiesParser extends AbstractParser
|
|||||||
})
|
})
|
||||||
;
|
;
|
||||||
|
|
||||||
return new BaseData($availabilities);
|
// Parse travel-level booking status from <reise> 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
|
public function parseRooms(Crawler $result): BaseData
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ class TravelParser extends AbstractParser
|
|||||||
}
|
}
|
||||||
|
|
||||||
$travel->type = $node->attr('reiseart');
|
$travel->type = $node->attr('reiseart');
|
||||||
$travel->status = $this->getStringOrNullValue($node->filterXPath('//status_hin'));
|
|
||||||
$travel->priceFrom = $this->stringToFloat($this->getStringOrNullValue($node->filterXPath('//abpreis')));
|
$travel->priceFrom = $this->stringToFloat($this->getStringOrNullValue($node->filterXPath('//abpreis')));
|
||||||
$travel->selectionGroups = $this->getSelectionGroups($node->filterXPath('//selektiongruppe'));
|
$travel->selectionGroups = $this->getSelectionGroups($node->filterXPath('//selektiongruppe'));
|
||||||
$travel->additionalServices = $this
|
$travel->additionalServices = $this
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
|||||||
namespace App\Controller\Booking\Create;
|
namespace App\Controller\Booking\Create;
|
||||||
|
|
||||||
use App\BusProNet\XmlLoader\AgencyLoader;
|
use App\BusProNet\XmlLoader\AgencyLoader;
|
||||||
use App\Exception\BookingNotPossibleException;
|
|
||||||
use App\Exception\HotelNotFoundException;
|
use App\Exception\HotelNotFoundException;
|
||||||
use App\Exception\HotelNotInTravelException;
|
use App\Exception\HotelNotInTravelException;
|
||||||
use App\Exception\NoRoomsAvailableException;
|
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));
|
throw $this->createNotFoundException(sprintf('Hotel ID %d is not available for travel ID %d', $hotelId, $dateId));
|
||||||
} catch (NoRoomsAvailableException) {
|
} catch (NoRoomsAvailableException) {
|
||||||
throw $this->createNotFoundException('No rooms available for this travel.');
|
throw $this->createNotFoundException('No rooms available for this travel.');
|
||||||
} catch (BookingNotPossibleException) {
|
|
||||||
throw $this->createNotFoundException('Booking is not possible for this travel (Buchungsstop).');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,9 @@ class Step1Controller extends AbstractController
|
|||||||
$this->bookingService->resetParticipantAssignments($bookingCreateDto);
|
$this->bookingService->resetParticipantAssignments($bookingCreateDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if room selection forces inquiry mode
|
||||||
|
$this->bookingService->updateBookingStatusFromRoomSelection($bookingCreateDto);
|
||||||
|
|
||||||
$bookingCreateDto->currentStep = 2;
|
$bookingCreateDto->currentStep = 2;
|
||||||
$this->bookingService->saveBookingCreateDto($request, $bookingCreateDto);
|
$this->bookingService->saveBookingCreateDto($request, $bookingCreateDto);
|
||||||
|
|
||||||
|
|||||||
@@ -88,6 +88,24 @@ class Step3Controller extends AbstractController
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (false === $inquiryResponse->isInquiryValid()) {
|
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.';
|
$errorMessage = 'Buchung konnte nicht validiert werden.';
|
||||||
if (null !== $inquiryResponse->message && '' !== trim($inquiryResponse->message)) {
|
if (null !== $inquiryResponse->message && '' !== trim($inquiryResponse->message)) {
|
||||||
$errorMessage .= ' '.$inquiryResponse->message;
|
$errorMessage .= ' '.$inquiryResponse->message;
|
||||||
@@ -199,4 +217,31 @@ class Step3Controller extends AbstractController
|
|||||||
'groupedSelectedRooms' => $groupedSelectedRooms,
|
'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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ use Symfony\Component\Form\FormInterface;
|
|||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Contracts\Cache\CacheInterface;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles the fourth step of the booking creation process (confirmation).
|
* Handles the fourth step of the booking creation process (confirmation).
|
||||||
@@ -36,6 +37,7 @@ class Step4Controller extends AbstractController
|
|||||||
private readonly BookingSummaryDataService $summaryDataService,
|
private readonly BookingSummaryDataService $summaryDataService,
|
||||||
private readonly BookingPriceCalculatorService $priceCalculator,
|
private readonly BookingPriceCalculatorService $priceCalculator,
|
||||||
private readonly ApiClient $apiClient,
|
private readonly ApiClient $apiClient,
|
||||||
|
private readonly CacheInterface $cache,
|
||||||
private readonly LoggerInterface $logger,
|
private readonly LoggerInterface $logger,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
@@ -99,6 +101,7 @@ class Step4Controller extends AbstractController
|
|||||||
|
|
||||||
// Success: Store booking number in flash and clear session
|
// Success: Store booking number in flash and clear session
|
||||||
$this->addFlash('booking_number', $bookingResponse->transactionNumber);
|
$this->addFlash('booking_number', $bookingResponse->transactionNumber);
|
||||||
|
$this->clearTravelDataCache($bookingCreateDto);
|
||||||
$this->bookingService->clearBookingCreateDto($request);
|
$this->bookingService->clearBookingCreateDto($request);
|
||||||
|
|
||||||
return $this->hxRedirect($request, $this->generateUrl('app_booking_create_success'));
|
return $this->hxRedirect($request, $this->generateUrl('app_booking_create_success'));
|
||||||
@@ -151,4 +154,25 @@ class Step4Controller extends AbstractController
|
|||||||
'participantPrices' => $this->priceCalculator->calculateAllParticipantIndividualPrices($bookingCreateDto),
|
'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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Form\Service\Condition;
|
||||||
|
|
||||||
|
use App\Form\Model\BookingDto;
|
||||||
|
use App\Form\Service\Contract\FieldConditionInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Condition that checks if the booking is a final booking (not an inquiry).
|
||||||
|
*
|
||||||
|
* This condition is used to exclude certain fields (like vouchers) from inquiry
|
||||||
|
* bookings where they are not applicable. When a booking has inquiry status ('A'),
|
||||||
|
* fields with this condition will be excluded from the form.
|
||||||
|
*/
|
||||||
|
class FinalBookingOnlyCondition implements FieldConditionInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Evaluates if the booking is a final booking.
|
||||||
|
*
|
||||||
|
* @param BookingDto $bookingDto The current booking data (create or edit)
|
||||||
|
* @param int $participantIndex The index of the participant being evaluated
|
||||||
|
* @param array<string, mixed> $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)';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ use App\Form\Service\Condition\BulkInsuranceBookingCondition;
|
|||||||
use App\Form\Service\Condition\CompositeCondition;
|
use App\Form\Service\Condition\CompositeCondition;
|
||||||
use App\Form\Service\Condition\DateOfBirthProvidedCondition;
|
use App\Form\Service\Condition\DateOfBirthProvidedCondition;
|
||||||
use App\Form\Service\Condition\FieldValueCondition;
|
use App\Form\Service\Condition\FieldValueCondition;
|
||||||
|
use App\Form\Service\Condition\FinalBookingOnlyCondition;
|
||||||
use App\Form\Service\Condition\RentalSelectionCondition;
|
use App\Form\Service\Condition\RentalSelectionCondition;
|
||||||
use App\Form\Service\Condition\RoomSelectionCondition;
|
use App\Form\Service\Condition\RoomSelectionCondition;
|
||||||
use App\Form\Service\Condition\ServiceSubTypeCondition;
|
use App\Form\Service\Condition\ServiceSubTypeCondition;
|
||||||
@@ -267,9 +268,17 @@ class CreateFieldStateProvider extends AbstractFieldStateProvider
|
|||||||
'static_text' => new SingleRoomTypeCondition(),
|
'static_text' => new SingleRoomTypeCondition(),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Voucher fields (purchaseVoucherCode, promoVoucherCode) have no state conditions
|
// Voucher fields - hidden for inquiry bookings (status='A')
|
||||||
// They are enabled by default for all participants in create mode
|
// Vouchers cannot be redeemed or submitted for inquiry bookings
|
||||||
// No visibility rules - always shown to allow optional voucher entry
|
$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
|
// Example field state conditions would be registered here
|
||||||
// For demonstration purposes, here are some example patterns:
|
// For demonstration purposes, here are some example patterns:
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ namespace App\Service;
|
|||||||
use App\BusProNet\Constants;
|
use App\BusProNet\Constants;
|
||||||
use App\BusProNet\Model\Room;
|
use App\BusProNet\Model\Room;
|
||||||
use App\BusProNet\Model\Travel;
|
use App\BusProNet\Model\Travel;
|
||||||
use App\Exception\BookingNotPossibleException;
|
|
||||||
use App\Exception\BookingSessionNotFoundException;
|
use App\Exception\BookingSessionNotFoundException;
|
||||||
use App\Exception\NoRoomsAvailableException;
|
use App\Exception\NoRoomsAvailableException;
|
||||||
use App\Form\Model\BookingDto;
|
use App\Form\Model\BookingDto;
|
||||||
@@ -192,7 +191,6 @@ class BookingService
|
|||||||
* Handles three booking status types:
|
* Handles three booking status types:
|
||||||
* - 'Frei': Regular booking with availability checks
|
* - 'Frei': Regular booking with availability checks
|
||||||
* - 'Anfrage': Inquiry booking, allows booking even with 0 availability
|
* - '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
|
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));
|
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
|
// Fetch and patch availability data (includes allowedBookingStatus from API)
|
||||||
if ('Buchungsstop' === $travelData->status) {
|
$availabilities = $this->travelDataService->getAvailabilityData($dateId);
|
||||||
throw new BookingNotPossibleException($dateId, $hotelId);
|
if (null !== $availabilities) {
|
||||||
|
$this->travelDataService->patchAvailabilities($travelData, $availabilities);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine booking status based on travel status
|
// Get bookable rooms (Frei or Anfrage with available > 0)
|
||||||
$isInquiryBooking = 'Anfrage' === $travelData->status;
|
|
||||||
$bookingStatus = $isInquiryBooking ? 'A' : 'F';
|
|
||||||
|
|
||||||
// Get available rooms
|
|
||||||
$availableRooms = $travelData->getAvailableRooms();
|
$availableRooms = $travelData->getAvailableRooms();
|
||||||
|
|
||||||
// For regular bookings (Frei), prevent entry when no rooms are available
|
// No bookable rooms means booking is not possible
|
||||||
// For inquiry bookings (Anfrage), allow even with 0 availability
|
if ([] === $availableRooms) {
|
||||||
if (false === $isInquiryBooking && empty($availableRooms)) {
|
|
||||||
throw new NoRoomsAvailableException($dateId, $hotelId);
|
throw new NoRoomsAvailableException($dateId, $hotelId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// For inquiry bookings with 0 availability, get all rooms ignoring availability count
|
// Determine initial booking status based on room availability
|
||||||
if ($isInquiryBooking && empty($availableRooms)) {
|
// This is for early UI decisions (e.g., voucher visibility), final verdict is in step 3
|
||||||
$availableRooms = array_filter($travelData->rooms, function (Room $room) {
|
$bookingStatus = $this->determineInitialBookingStatus($travelData);
|
||||||
return Constants::STATUS_AVAILABLE === $room->status;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create room selections with zero quantities (user will set these in step 1)
|
// Create room selections with zero quantities (user will set these in step 1)
|
||||||
$roomSelections = array_map(
|
$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<int, Room> $rooms Rooms indexed by room ID
|
* @param array<int, Room> $rooms Rooms indexed by room ID
|
||||||
*
|
*
|
||||||
* @return array{by_pax: array<int, Room>, by_room: array<int, Room>}
|
* @return array{by_pax: array<int, Room>, by_room: array<int, Room>} Rooms grouped and sorted by maxPax (ascending)
|
||||||
*/
|
*/
|
||||||
public function groupRoomsBySelectionType(array $rooms): array
|
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;
|
return $groups;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -491,4 +486,63 @@ class BookingService
|
|||||||
$participant->additionalServices = $currentSelections;
|
$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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use App\BusProNet\ApiClient;
|
|||||||
use App\BusProNet\Exception\ApiClientException;
|
use App\BusProNet\Exception\ApiClientException;
|
||||||
use App\BusProNet\Model\BaseData;
|
use App\BusProNet\Model\BaseData;
|
||||||
use App\BusProNet\Model\Notification;
|
use App\BusProNet\Model\Notification;
|
||||||
|
use App\BusProNet\Model\ServiceAvailabilityResponse;
|
||||||
use App\BusProNet\Model\Travel;
|
use App\BusProNet\Model\Travel;
|
||||||
use App\BusProNet\XmlLoader\HotelLoader;
|
use App\BusProNet\XmlLoader\HotelLoader;
|
||||||
use App\BusProNet\XmlLoader\InsuranceLoader;
|
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 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)
|
* @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) {
|
if ($cached) {
|
||||||
$cacheKey = sprintf('availability_%d', $dateId);
|
$cacheKey = sprintf('availability_%d', $dateId);
|
||||||
@@ -556,7 +557,7 @@ class TravelDataService
|
|||||||
/**
|
/**
|
||||||
* Fetches availability data directly from the API without caching.
|
* Fetches availability data directly from the API without caching.
|
||||||
*/
|
*/
|
||||||
private function fetchAvailabilityData(int $dateId): ?BaseData
|
private function fetchAvailabilityData(int $dateId): ?ServiceAvailabilityResponse
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$availabilities = $this->apiClient->getAvailabilities($dateId);
|
$availabilities = $this->apiClient->getAvailabilities($dateId);
|
||||||
@@ -590,12 +591,13 @@ class TravelDataService
|
|||||||
* Apply availability data to travel services.
|
* Apply availability data to travel services.
|
||||||
*
|
*
|
||||||
* Updates the availability status of additional and transportation
|
* 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 Travel $travel The travel object to update
|
||||||
* @param BaseData $availabilities The availability data for services
|
* @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);
|
$this->travelLoader->patchAvailabilities($travel, $availabilities);
|
||||||
|
|
||||||
|
|||||||
@@ -421,7 +421,13 @@
|
|||||||
|
|
||||||
<div class="flex justify-between pt-6">
|
<div class="flex justify-between pt-6">
|
||||||
<a href="{{ path('app_booking_create_step_3') }}" class="button bg-button bg-button--secondary">Zurück</a>
|
<a href="{{ path('app_booking_create_step_3') }}" class="button bg-button bg-button--secondary">Zurück</a>
|
||||||
<button type="submit" class="button bg-button bg-button--primary" {{ stimulus_action('loading', 'toggle') }}>Verbindlich buchen</button>
|
<button type="submit" class="button bg-button bg-button--primary" {{ stimulus_action('loading', 'toggle') }}>
|
||||||
|
{% if bookingCreateDto.bookingStatus == 'A' %}
|
||||||
|
Anfragen
|
||||||
|
{% else %}
|
||||||
|
Verbindlich buchen
|
||||||
|
{% endif %}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{ form_end(form) }}
|
{{ form_end(form) }}
|
||||||
|
|||||||
@@ -185,81 +185,6 @@ class TravelParserTest extends TestCase
|
|||||||
$this->assertNull($service->description); // Empty hinweis should result in null description
|
$this->assertNull($service->description); // Empty hinweis should result in null description
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testParseTravelStatusFrei(): void
|
|
||||||
{
|
|
||||||
$xmlContent = '<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<reisen>
|
|
||||||
<reise id="1" idbuspro="2187" code="SSTMR">
|
|
||||||
<termin id="1" idbuspro="11946" termin="01.01.2030" bis="06.01.2030" reiseart="F">
|
|
||||||
<text>Test Travel</text>
|
|
||||||
<abpreis>659,00</abpreis>
|
|
||||||
<status_hin>Frei</status_hin>
|
|
||||||
<hotel id="1" idbuspro="157047">
|
|
||||||
<zimmer>
|
|
||||||
<preis zimmercode="4erDW" idbuspro_zimmer="95" zimmertext="4er Zimmer" MinPax="4" MaxPax="4" naechte="5" preis="689,00" status="Frei" verfuegbar="8" />
|
|
||||||
</zimmer>
|
|
||||||
</hotel>
|
|
||||||
</termin>
|
|
||||||
</reise>
|
|
||||||
</reisen>';
|
|
||||||
|
|
||||||
$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 = '<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<reisen>
|
|
||||||
<reise id="1" idbuspro="2187" code="SSTMR">
|
|
||||||
<termin id="1" idbuspro="11946" termin="01.01.2030" bis="06.01.2030" reiseart="F">
|
|
||||||
<text>Test Travel</text>
|
|
||||||
<abpreis>659,00</abpreis>
|
|
||||||
<status_hin>Anfrage</status_hin>
|
|
||||||
<hotel id="1" idbuspro="157047">
|
|
||||||
<zimmer>
|
|
||||||
<preis zimmercode="4erDW" idbuspro_zimmer="95" zimmertext="4er Zimmer" MinPax="4" MaxPax="4" naechte="5" preis="689,00" status="Frei" verfuegbar="0" />
|
|
||||||
</zimmer>
|
|
||||||
</hotel>
|
|
||||||
</termin>
|
|
||||||
</reise>
|
|
||||||
</reisen>';
|
|
||||||
|
|
||||||
$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 = '<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<reisen>
|
|
||||||
<reise id="1" idbuspro="2187" code="SSTMR">
|
|
||||||
<termin id="1" idbuspro="11946" termin="01.01.2030" bis="06.01.2030" reiseart="F">
|
|
||||||
<text>Test Travel</text>
|
|
||||||
<abpreis>659,00</abpreis>
|
|
||||||
<status_hin>Buchungsstop</status_hin>
|
|
||||||
<hotel id="1" idbuspro="157047">
|
|
||||||
<zimmer>
|
|
||||||
<preis zimmercode="4erDW" idbuspro_zimmer="95" zimmertext="4er Zimmer" MinPax="4" MaxPax="4" naechte="5" preis="689,00" status="Frei" verfuegbar="5" />
|
|
||||||
</zimmer>
|
|
||||||
</hotel>
|
|
||||||
</termin>
|
|
||||||
</reise>
|
|
||||||
</reisen>';
|
|
||||||
|
|
||||||
$crawler = new Crawler($xmlContent);
|
|
||||||
$travelNode = $crawler->filterXPath('//reise/termin')->first();
|
|
||||||
$travel = $this->parser->parse($travelNode);
|
|
||||||
|
|
||||||
$this->assertSame('Buchungsstop', $travel->status);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testParseTravelWithoutStatus(): void
|
public function testParseTravelWithoutStatus(): void
|
||||||
{
|
{
|
||||||
$xmlContent = '<?xml version="1.0" encoding="utf-8"?>
|
$xmlContent = '<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Tests\Service;
|
namespace App\Tests\Service;
|
||||||
|
|
||||||
|
use App\BusProNet\Constants;
|
||||||
use App\BusProNet\Model\Room;
|
use App\BusProNet\Model\Room;
|
||||||
use App\BusProNet\Model\Travel;
|
use App\BusProNet\Model\Travel;
|
||||||
use App\Exception\BookingNotPossibleException;
|
|
||||||
use App\Exception\NoRoomsAvailableException;
|
use App\Exception\NoRoomsAvailableException;
|
||||||
use App\Service\BookingPriceCalculatorService;
|
use App\Service\BookingPriceCalculatorService;
|
||||||
use App\Service\BookingService;
|
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
|
$this->travelDataService
|
||||||
->method('getTravelData')
|
->method('getTravelData')
|
||||||
@@ -51,9 +53,11 @@ class BookingServiceStatusTest extends TestCase
|
|||||||
$this->assertCount(1, $bookingDto->roomSelections);
|
$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
|
$this->travelDataService
|
||||||
->method('getTravelData')
|
->method('getTravelData')
|
||||||
@@ -66,9 +70,11 @@ class BookingServiceStatusTest extends TestCase
|
|||||||
$this->bookingService->startFreshBooking($request, 123, 456);
|
$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
|
$this->travelDataService
|
||||||
->method('getTravelData')
|
->method('getTravelData')
|
||||||
@@ -82,9 +88,69 @@ class BookingServiceStatusTest extends TestCase
|
|||||||
$this->assertCount(1, $bookingDto->roomSelections);
|
$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
|
$this->travelDataService
|
||||||
->method('getTravelData')
|
->method('getTravelData')
|
||||||
@@ -95,60 +161,36 @@ class BookingServiceStatusTest extends TestCase
|
|||||||
$bookingDto = $this->bookingService->startFreshBooking($request, 123, 456);
|
$bookingDto = $this->bookingService->startFreshBooking($request, 123, 456);
|
||||||
|
|
||||||
$this->assertSame('A', $bookingDto->bookingStatus);
|
$this->assertSame('A', $bookingDto->bookingStatus);
|
||||||
$this->assertCount(1, $bookingDto->roomSelections);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testStartFreshBookingWithBuchungsstopThrowsException(): void
|
/**
|
||||||
{
|
* @param array<array{status: string, available: int}> $roomsConfig
|
||||||
$travel = $this->createTravelWithStatus('Buchungsstop', 5);
|
*/
|
||||||
|
private function createTravelWithRooms(array $roomsConfig): Travel
|
||||||
$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
|
|
||||||
{
|
{
|
||||||
$travel = new Travel();
|
$travel = new Travel();
|
||||||
$travel->id = 123;
|
$travel->id = 123;
|
||||||
$travel->hotelId = 456;
|
$travel->hotelId = 456;
|
||||||
$travel->status = $status;
|
|
||||||
$travel->dateFrom = new \DateTimeImmutable('2030-01-01');
|
$travel->dateFrom = new \DateTimeImmutable('2030-01-01');
|
||||||
$travel->dateTo = new \DateTimeImmutable('2030-01-06');
|
$travel->dateTo = new \DateTimeImmutable('2030-01-06');
|
||||||
|
$travel->allowedBookingStatus = []; // Empty means no API restrictions
|
||||||
|
|
||||||
$room = new Room();
|
$rooms = [];
|
||||||
$room->id = 1;
|
foreach ($roomsConfig as $index => $config) {
|
||||||
$room->label = 'Test Room';
|
$room = new Room();
|
||||||
$room->price = 100.0;
|
$room->id = $index + 1;
|
||||||
$room->available = $availability;
|
$room->label = 'Test Room '.($index + 1);
|
||||||
$room->status = \App\BusProNet\Constants::STATUS_AVAILABLE;
|
$room->price = 100.0;
|
||||||
$room->minPax = 2;
|
$room->available = $config['available'];
|
||||||
$room->category = 'A';
|
$room->status = $config['status'];
|
||||||
$room->boardId = 1;
|
$room->minPax = 2;
|
||||||
|
$room->category = 'A';
|
||||||
|
$room->boardId = 1;
|
||||||
|
|
||||||
$travel->rooms = [1 => $room];
|
$rooms[$room->id] = $room;
|
||||||
|
}
|
||||||
|
|
||||||
|
$travel->rooms = $rooms;
|
||||||
|
|
||||||
return $travel;
|
return $travel;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user