wip: submit booking to api
This commit is contained in:
@@ -7,6 +7,7 @@ use App\BusProNet\Exception\ApiClientException;
|
||||
use App\BusProNet\Exception\ResponseParserException;
|
||||
use App\BusProNet\Model\BaseData;
|
||||
use App\BusProNet\Model\Booking;
|
||||
use App\BusProNet\Model\BookingResponse;
|
||||
use App\BusProNet\Model\BookingUpdate;
|
||||
use App\BusProNet\Model\CrmAttributes;
|
||||
use App\BusProNet\Model\Notification;
|
||||
@@ -14,6 +15,7 @@ use App\BusProNet\Model\PersonalData;
|
||||
use App\BusProNet\Model\Travel;
|
||||
use App\BusProNet\Traits\ApiClientTrait;
|
||||
use App\BusProNet\XmlParser\ApiResponseParser;
|
||||
use App\Form\Model\BookingCreateDto;
|
||||
use App\Form\Model\BookingEditDto;
|
||||
use App\Form\Model\RegistrationDto;
|
||||
use League\Flysystem\FilesystemException;
|
||||
@@ -34,8 +36,10 @@ class ApiClient
|
||||
public const TYPE_AVAILABILITY = 'VERFUEGBARKEIT';
|
||||
public const TYPE_AVAILABILITY_HOTEL = 'VERFUEGBARKEITHOTEL';
|
||||
public const TYPE_BOOKING_UPDATE = 'BUCHUNGAENDERUNG';
|
||||
public const TYPE_BOOKING = 'BUCHUNG';
|
||||
public const TYPE_PRODUCTS = 'PRODUKTE';
|
||||
public const TYPE_PRODUCT_DATA = 'PRODUKTDATEN';
|
||||
public const TYPE_AGENCIES = 'AGENTUREN';
|
||||
|
||||
private array $config;
|
||||
|
||||
@@ -203,6 +207,60 @@ class ApiClient
|
||||
return $this->sendRequest(static::TYPE_BOOKING_UPDATE, $data, [], $debug);
|
||||
}
|
||||
|
||||
/**
|
||||
* Submits a booking inquiry for validation.
|
||||
*
|
||||
* First phase of the two-phase booking process. Validates all booking data
|
||||
* and returns pricing information without creating an actual booking.
|
||||
*
|
||||
* @param BookingCreateDto $bookingDto The booking creation form data
|
||||
* @param bool $debug Enable debug mode (XML dumps)
|
||||
*
|
||||
* @return Notification|BookingResponse Notification on error, BookingResponse on success
|
||||
*
|
||||
* @throws ApiClientException If the API request fails
|
||||
*/
|
||||
public function createBookingInquiry(BookingCreateDto $bookingDto, bool $debug = false): Notification|BookingResponse
|
||||
{
|
||||
$payload = (new BookingDataProcessor())->createBookingRequestPayload($bookingDto, 'Anfrage');
|
||||
|
||||
$data = [
|
||||
'user' => $this->config['bpn_username'],
|
||||
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_BOOKING),
|
||||
'satz' => ['@typ' => static::TYPE_BOOKING],
|
||||
...$payload,
|
||||
];
|
||||
|
||||
return $this->sendRequest(static::TYPE_BOOKING, $data, [], $debug);
|
||||
}
|
||||
|
||||
/**
|
||||
* Submits the final booking request.
|
||||
*
|
||||
* Second phase of the two-phase booking process. Creates the actual booking
|
||||
* after successful inquiry validation.
|
||||
*
|
||||
* @param BookingCreateDto $bookingDto The booking creation form data
|
||||
* @param bool $debug Enable debug mode (XML dumps)
|
||||
*
|
||||
* @return Notification|BookingResponse Notification on error, BookingResponse with booking number on success
|
||||
*
|
||||
* @throws ApiClientException If the API request fails
|
||||
*/
|
||||
public function createBooking(BookingCreateDto $bookingDto, bool $debug = false): Notification|BookingResponse
|
||||
{
|
||||
$payload = (new BookingDataProcessor())->createBookingRequestPayload($bookingDto, 'Buchung');
|
||||
|
||||
$data = [
|
||||
'user' => $this->config['bpn_username'],
|
||||
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_BOOKING),
|
||||
'satz' => ['@typ' => static::TYPE_BOOKING],
|
||||
...$payload,
|
||||
];
|
||||
|
||||
return $this->sendRequest(static::TYPE_BOOKING, $data, [], $debug);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ApiClientException
|
||||
*/
|
||||
@@ -328,6 +386,27 @@ class ApiClient
|
||||
return $this->sendRequest(static::TYPE_PRODUCTS, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches all available agencies from the BusProNet API.
|
||||
*
|
||||
* Returns a list of all agencies with their contact information.
|
||||
* This data is typically cached for long periods as it changes infrequently.
|
||||
*
|
||||
* @return Agency[]|Notification Array of Agency objects on success, Notification on error
|
||||
*
|
||||
* @throws ApiClientException If the API request fails
|
||||
*/
|
||||
public function getAgencies(): array|Notification
|
||||
{
|
||||
$data = [
|
||||
'user' => $this->config['bpn_username'],
|
||||
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_AGENCIES),
|
||||
'satz' => ['@typ' => static::TYPE_AGENCIES],
|
||||
];
|
||||
|
||||
return $this->sendRequest(static::TYPE_AGENCIES, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ApiClientException
|
||||
*/
|
||||
|
||||
@@ -51,4 +51,8 @@ final class Constants
|
||||
// Payment methods
|
||||
public const PAYMENT_METHOD_TRANSFER = 'transfer';
|
||||
public const PAYMENT_METHOD_DEBIT = 'debit';
|
||||
|
||||
// Payment type IDs for API
|
||||
public const PAYMENT_TYPE_ID_TRANSFER = 2;
|
||||
public const PAYMENT_TYPE_ID_DEBIT = 5;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\BusProNet\DataProcessor;
|
||||
|
||||
use App\BusProNet\Constants;
|
||||
use App\BusProNet\Model\Communication;
|
||||
use App\Form\Model\BookingCreateDto;
|
||||
use App\Form\Model\BookingEditDto;
|
||||
|
||||
/**
|
||||
@@ -408,4 +410,365 @@ class BookingDataProcessor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a booking request payload for new bookings (inquiry or final booking).
|
||||
*
|
||||
* Generates the array payload structure for creating new bookings through the BusProNet API.
|
||||
* This includes all participant data, room selections, services (including insurance), and
|
||||
* payment information. The booking type determines whether this is an inquiry validation
|
||||
* ('Anfrage') or a final booking commit ('Buchung').
|
||||
*
|
||||
* @param BookingCreateDto $bookingDto The booking creation form data
|
||||
* @param string $bookingType Either 'Anfrage' (inquiry) or 'Buchung' (final booking)
|
||||
*
|
||||
* @return array The structured payload array for BusProNet API submission
|
||||
*/
|
||||
public function createBookingRequestPayload(BookingCreateDto $bookingDto, string $bookingType): array
|
||||
{
|
||||
$firstParticipant = $bookingDto->participants[0];
|
||||
|
||||
$payload = [
|
||||
'buchungsart' => $bookingType,
|
||||
'status' => 'F',
|
||||
'idreise' => $bookingDto->travel->id,
|
||||
'idpartner' => $bookingDto->travel->hotelId,
|
||||
'idagentur' => $bookingDto->agencyId,
|
||||
];
|
||||
|
||||
// Add applicant (first participant data)
|
||||
$payload['anmelder'] = [
|
||||
'name' => $firstParticipant->lastName,
|
||||
'vorname' => $firstParticipant->firstName,
|
||||
'geschlecht' => $firstParticipant->gender ?? '',
|
||||
'nationalitaet' => $firstParticipant->nationality ?? '',
|
||||
];
|
||||
|
||||
if (null !== $firstParticipant->dateOfBirth) {
|
||||
$payload['anmelder']['geburtsdatum'] = $firstParticipant->dateOfBirth->format('d.m.Y');
|
||||
}
|
||||
|
||||
// Add address for applicant
|
||||
if (null !== $firstParticipant->address) {
|
||||
$payload['anmelder']['anschrift'] = $firstParticipant->address->toPayload();
|
||||
}
|
||||
|
||||
if (null !== $firstParticipant->email || null !== $firstParticipant->mobile) {
|
||||
$payload['anmelder']['kommunikation'] = [];
|
||||
if (null !== $firstParticipant->email) {
|
||||
$payload['anmelder']['kommunikation']['email'] = $firstParticipant->email;
|
||||
}
|
||||
if (null !== $firstParticipant->mobile) {
|
||||
$payload['anmelder']['kommunikation']['telefonmobil'] = $firstParticipant->mobile;
|
||||
}
|
||||
}
|
||||
|
||||
// Add participants
|
||||
$payload['teilnehmerliste']['teilnehmer'] = [];
|
||||
foreach ($bookingDto->participants as $index => $participant) {
|
||||
$participantData = [
|
||||
'@id' => $index + 1,
|
||||
'name' => $participant->lastName,
|
||||
'vorname' => $participant->firstName,
|
||||
'geschlecht' => $participant->gender ?? '',
|
||||
'nationalitaet' => $participant->nationality ?? '',
|
||||
];
|
||||
|
||||
if (null !== $participant->dateOfBirth) {
|
||||
$participantData['geburtsdatum'] = $participant->dateOfBirth->format('d.m.Y');
|
||||
}
|
||||
|
||||
// Add address (always include structure, even if empty)
|
||||
$participantData['anschrift'] = $participant->address?->toPayload() ?? [
|
||||
'strasse' => null,
|
||||
'plz' => null,
|
||||
'ort' => null,
|
||||
'ortsteil' => null,
|
||||
'land' => null,
|
||||
];
|
||||
|
||||
// Add contact info for all participants
|
||||
if (null !== $participant->email || null !== $participant->mobile) {
|
||||
$participantData['kommunikation'] = [];
|
||||
if (null !== $participant->email) {
|
||||
$participantData['kommunikation']['email'] = $participant->email;
|
||||
}
|
||||
if (null !== $participant->mobile) {
|
||||
$participantData['kommunikation']['telefonmobil'] = $participant->mobile;
|
||||
}
|
||||
}
|
||||
|
||||
// Add wishes (room remarks and license plate)
|
||||
if (null !== $participant->remarksRoom || null !== $participant->licensePlate) {
|
||||
$participantData['wünsche'] = [];
|
||||
if (null !== $participant->remarksRoom && '' !== trim($participant->remarksRoom)) {
|
||||
$participantData['wünsche']['unterbringungswunsch'] = $participant->remarksRoom;
|
||||
}
|
||||
if (null !== $participant->licensePlate && '' !== trim($participant->licensePlate)) {
|
||||
$participantData['wünsche']['beförderungswunsch'] = $participant->licensePlate;
|
||||
}
|
||||
}
|
||||
|
||||
$payload['teilnehmerliste']['teilnehmer'][] = $participantData;
|
||||
}
|
||||
|
||||
// Collect and group all services by ID with participant mappings
|
||||
$serviceMap = $this->collectServiceMappings($bookingDto);
|
||||
$transportationMap = $this->collectTransportationMappings($bookingDto);
|
||||
$roomMap = $this->collectRoomMappings($bookingDto);
|
||||
$pickupMap = $this->collectPickupMappings($bookingDto);
|
||||
$insuranceMap = $this->collectInsuranceMappings($bookingDto);
|
||||
|
||||
// Add services using reusable helper methods
|
||||
$this->addServicesFromMap($payload, 'beförderungen', 'beförderung', '@idleistung', $transportationMap);
|
||||
$this->addRoomMappingsToPayload($payload, $roomMap, $bookingDto);
|
||||
$this->addServicesFromMap($payload, 'zusatzleistungen', 'zusatzleistung', '@idleistung', $serviceMap);
|
||||
$this->addServicesFromMap($payload, 'zustiege', 'zustieg', '@idzustieg', $pickupMap);
|
||||
$this->addServicesFromMap($payload, 'versicherungen', 'versicherung', '@idversicherung', $insuranceMap);
|
||||
|
||||
// Add payment information
|
||||
$payload['zahlung'] = [
|
||||
'@idzahlungsart' => Constants::PAYMENT_METHOD_DEBIT === $bookingDto->paymentMethod
|
||||
? Constants::PAYMENT_TYPE_ID_DEBIT
|
||||
: Constants::PAYMENT_TYPE_ID_TRANSFER,
|
||||
'@art' => Constants::PAYMENT_METHOD_DEBIT === $bookingDto->paymentMethod ? 'EINZUG' : 'UEBERWEISUNG',
|
||||
];
|
||||
|
||||
if (Constants::PAYMENT_METHOD_DEBIT === $bookingDto->paymentMethod && null !== $bookingDto->bankAccount) {
|
||||
$payload['zahlung']['bankverbindung'] = [
|
||||
'@iban' => $bookingDto->bankAccount->iban,
|
||||
'@kontoinhaber' => $bookingDto->bankAccount->accountHolder,
|
||||
];
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds services from a mapping to the payload.
|
||||
*
|
||||
* Generic helper method that converts service ID => participant IDs mappings
|
||||
* into XML payload structure.
|
||||
*
|
||||
* @param array $payload The payload array to modify
|
||||
* @param string $sectionKey The section key (e.g., 'beförderungen', 'versicherungen')
|
||||
* @param string $itemKey The item key (e.g., 'beförderung', 'versicherung')
|
||||
* @param string $idAttributeName The ID attribute name (e.g., '@idleistung', '@idversicherung')
|
||||
* @param array $serviceMap Map of service ID to participant IDs
|
||||
*/
|
||||
private function addServicesFromMap(
|
||||
array &$payload,
|
||||
string $sectionKey,
|
||||
string $itemKey,
|
||||
string $idAttributeName,
|
||||
array $serviceMap,
|
||||
): void {
|
||||
if (false === empty($serviceMap)) {
|
||||
$payload[$sectionKey][$itemKey] = [];
|
||||
foreach ($serviceMap as $serviceId => $participantIds) {
|
||||
$payload[$sectionKey][$itemKey][] = [
|
||||
$idAttributeName => $serviceId,
|
||||
'@anzahl' => count($participantIds),
|
||||
'@zuordnung' => implode(',', $participantIds),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds room mappings with detailed attributes to the payload.
|
||||
*
|
||||
* Rooms require special attributes beyond simple service mapping:
|
||||
* - kategorie (room category code)
|
||||
* - idverpflegung (board type ID)
|
||||
* - anreise (arrival date)
|
||||
* - abreise (departure date)
|
||||
* - anzahl (number of rooms of this type booked)
|
||||
*
|
||||
* @param array $payload The payload array to modify
|
||||
* @param array $roomMap Map of room ID to participant IDs
|
||||
* @param BookingCreateDto $bookingDto The booking data for accessing room details and quantities
|
||||
*/
|
||||
private function addRoomMappingsToPayload(array &$payload, array $roomMap, BookingCreateDto $bookingDto): void
|
||||
{
|
||||
if (empty($roomMap)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$availableRooms = $bookingDto->travel->getAvailableRooms();
|
||||
$payload['ferienzielunterbringungen']['ferienzielunterbringung'] = [];
|
||||
|
||||
// Build room selection quantity lookup
|
||||
$roomQuantities = [];
|
||||
foreach ($bookingDto->roomSelections as $selection) {
|
||||
if ($selection->quantity > 0) {
|
||||
$roomQuantities[$selection->roomId] = $selection->quantity;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($roomMap as $roomId => $participantIds) {
|
||||
$room = $availableRooms[$roomId] ?? null;
|
||||
if (null === $room) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$quantity = $roomQuantities[$roomId] ?? 1;
|
||||
|
||||
$payload['ferienzielunterbringungen']['ferienzielunterbringung'][] = [
|
||||
'@idzimmer' => $room->id,
|
||||
'@kategorie' => $room->category,
|
||||
'@idverpflegung' => $room->boardId,
|
||||
'@anreise' => $bookingDto->travel->dateFrom->format('d.m.Y'),
|
||||
'@abreise' => $bookingDto->travel->dateTo->format('d.m.Y'),
|
||||
'@anzahl' => $quantity,
|
||||
'@zuordnung' => implode(',', $participantIds),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects room mappings.
|
||||
*
|
||||
* Groups participants by their assigned room ID.
|
||||
*
|
||||
* @return array<string, array<int>> Map of room ID to participant IDs
|
||||
*/
|
||||
private function collectRoomMappings(BookingCreateDto $bookingDto): array
|
||||
{
|
||||
$roomMap = [];
|
||||
|
||||
foreach ($bookingDto->participants as $index => $participant) {
|
||||
$participantId = $index + 1;
|
||||
|
||||
if (null !== $participant->assignedRoomId) {
|
||||
$roomMap[$participant->assignedRoomId][] = $participantId;
|
||||
}
|
||||
}
|
||||
|
||||
return $roomMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects service mappings for the booking request.
|
||||
*
|
||||
* Groups board, ski passes, rentals, rental insurance, courses, parking, and additional services
|
||||
* by service ID with their participant assignments (1-based).
|
||||
*
|
||||
* @return array<string, array<int>> Map of service ID to participant IDs
|
||||
*/
|
||||
private function collectServiceMappings(BookingCreateDto $bookingDto): array
|
||||
{
|
||||
$serviceMap = [];
|
||||
|
||||
foreach ($bookingDto->participants as $index => $participant) {
|
||||
$participantId = $index + 1;
|
||||
|
||||
// Board services
|
||||
foreach ($participant->board as $board) {
|
||||
$serviceMap[$board->id][] = $participantId;
|
||||
}
|
||||
|
||||
// Ski pass
|
||||
if (null !== $participant->skiPass) {
|
||||
$serviceMap[$participant->skiPass->id][] = $participantId;
|
||||
}
|
||||
|
||||
// Rentals
|
||||
foreach ($participant->rentals as $rental) {
|
||||
$serviceMap[$rental->id][] = $participantId;
|
||||
}
|
||||
|
||||
// Rental insurance
|
||||
if (null !== $participant->rentalInsurance) {
|
||||
$serviceMap[$participant->rentalInsurance->id][] = $participantId;
|
||||
}
|
||||
|
||||
// Courses
|
||||
foreach ($participant->courses as $course) {
|
||||
$serviceMap[$course->id][] = $participantId;
|
||||
}
|
||||
|
||||
// Parking service (for self-organized transportation)
|
||||
if (true === $participant->parking && null !== $participant->parkingService) {
|
||||
$serviceMap[$participant->parkingService->id][] = $participantId;
|
||||
}
|
||||
|
||||
// Additional services
|
||||
foreach ($participant->additionalServices as $service) {
|
||||
$serviceMap[$service->id][] = $participantId;
|
||||
}
|
||||
}
|
||||
|
||||
return $serviceMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects transportation service mappings.
|
||||
*
|
||||
* @return array<string, array<int>> Map of transportation service ID to participant IDs
|
||||
*/
|
||||
private function collectTransportationMappings(BookingCreateDto $bookingDto): array
|
||||
{
|
||||
$transportationMap = [];
|
||||
|
||||
foreach ($bookingDto->participants as $index => $participant) {
|
||||
$participantId = $index + 1;
|
||||
|
||||
if (null !== $participant->transportationOutbound) {
|
||||
$transportationMap[$participant->transportationOutbound->id][] = $participantId;
|
||||
}
|
||||
|
||||
if (null !== $participant->transportationInbound) {
|
||||
$transportationMap[$participant->transportationInbound->id][] = $participantId;
|
||||
}
|
||||
}
|
||||
|
||||
return $transportationMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects pickup location mappings.
|
||||
*
|
||||
* Only collects outbound pickups as the API doesn't support different pickups
|
||||
* for inbound direction. Both directions use the same pickup location.
|
||||
*
|
||||
* @return array<string, array<int>> Map of pickup ID to participant IDs
|
||||
*/
|
||||
private function collectPickupMappings(BookingCreateDto $bookingDto): array
|
||||
{
|
||||
$pickupMap = [];
|
||||
|
||||
foreach ($bookingDto->participants as $index => $participant) {
|
||||
$participantId = $index + 1;
|
||||
|
||||
// Only use outbound pickups (inbound uses same location)
|
||||
if (null !== $participant->pickupOutbound) {
|
||||
$pickupMap[$participant->pickupOutbound->id][] = $participantId;
|
||||
}
|
||||
}
|
||||
|
||||
return $pickupMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects insurance mappings.
|
||||
*
|
||||
* CRITICAL: Insurance data is only included in CREATE flow, not in UPDATE flow.
|
||||
*
|
||||
* @return array<string, array<int>> Map of insurance ID to participant IDs
|
||||
*/
|
||||
private function collectInsuranceMappings(BookingCreateDto $bookingDto): array
|
||||
{
|
||||
$insuranceMap = [];
|
||||
|
||||
foreach ($bookingDto->participants as $index => $participant) {
|
||||
$participantId = $index + 1;
|
||||
|
||||
if (null !== $participant->insurance) {
|
||||
$insuranceMap[$participant->insurance->id][] = $participantId;
|
||||
}
|
||||
}
|
||||
|
||||
return $insuranceMap;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,18 +15,18 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
*/
|
||||
class Address
|
||||
{
|
||||
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data'])]
|
||||
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data', 'applicant_address'])]
|
||||
public ?string $street = null;
|
||||
|
||||
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data'])]
|
||||
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data', 'applicant_address'])]
|
||||
public ?string $postCode = null;
|
||||
|
||||
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data'])]
|
||||
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data', 'applicant_address'])]
|
||||
public ?string $city = null;
|
||||
|
||||
public ?string $district = null;
|
||||
|
||||
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data'])]
|
||||
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data', 'applicant_address'])]
|
||||
public ?string $country = null;
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\BusProNet\Model;
|
||||
|
||||
final readonly class Agency
|
||||
{
|
||||
public function __construct(
|
||||
public int $id,
|
||||
public string $name,
|
||||
public string $code,
|
||||
public ?string $street = null,
|
||||
public ?string $postCode = null,
|
||||
public ?string $city = null,
|
||||
public ?string $phone = null,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\BusProNet\Model;
|
||||
|
||||
/**
|
||||
* Represents a successful API response from a booking request.
|
||||
*
|
||||
* Response structure for successful requests:
|
||||
* - <buchung>möglich</buchung> = inquiry validation successful
|
||||
* - <buchung>erfolgt</buchung> = booking creation successful
|
||||
*
|
||||
* Error responses return Notification objects instead (typ="HINWEIS").
|
||||
*/
|
||||
class BookingResponse
|
||||
{
|
||||
/**
|
||||
* @param string $status Booking status (möglich|erfolgt)
|
||||
* @param string|null $transactionNumber Transaction number (vorgang)
|
||||
* @param array<int, PriceItem> $priceItems Individual price items from response
|
||||
* @param float|null $totalPrice Total price (gesamtpreis)
|
||||
* @param PaymentTerms|null $paymentTerms Payment terms (anzahlung/restzahlung)
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $status,
|
||||
public readonly ?string $transactionNumber = null,
|
||||
public readonly array $priceItems = [],
|
||||
public readonly ?float $totalPrice = null,
|
||||
public readonly ?PaymentTerms $paymentTerms = null,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the inquiry validation was successful.
|
||||
*/
|
||||
public function isInquiryValid(): bool
|
||||
{
|
||||
return 'möglich' === $this->status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the booking was successfully created.
|
||||
*/
|
||||
public function isBookingSuccessful(): bool
|
||||
{
|
||||
return 'erfolgt' === $this->status;
|
||||
}
|
||||
}
|
||||
@@ -175,4 +175,4 @@ class Insurance
|
||||
|
||||
return array_values(array_unique($urls));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\BusProNet\Model;
|
||||
|
||||
/**
|
||||
* Represents payment terms from the booking response.
|
||||
*/
|
||||
class PaymentTerms
|
||||
{
|
||||
public function __construct(
|
||||
public readonly ?float $depositAmount = null,
|
||||
public readonly ?string $depositDate = null,
|
||||
public readonly ?float $finalPaymentAmount = null,
|
||||
public readonly ?string $finalPaymentDate = null,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\BusProNet\Model;
|
||||
|
||||
/**
|
||||
* Represents a single price item from the booking response.
|
||||
*/
|
||||
class PriceItem
|
||||
{
|
||||
public function __construct(
|
||||
public readonly int $position,
|
||||
public readonly string $type,
|
||||
public readonly ?string $subType,
|
||||
public readonly string $label,
|
||||
public readonly ?string $dateFrom,
|
||||
public readonly ?string $dateTo,
|
||||
public readonly int $quantity,
|
||||
public readonly ?string $assignment,
|
||||
public readonly float $unitPrice,
|
||||
public readonly float $totalPrice,
|
||||
public readonly ?string $id,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\BusProNet\XmlLoader;
|
||||
|
||||
use App\BusProNet\ApiClient;
|
||||
use App\BusProNet\Model\Agency;
|
||||
use App\BusProNet\Model\Notification;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
use Symfony\Contracts\Cache\ItemInterface;
|
||||
|
||||
class AgencyLoader
|
||||
{
|
||||
public const DEFAULT_AGENCY_CODE = '0001';
|
||||
|
||||
public function __construct(
|
||||
private readonly CacheInterface $cache,
|
||||
private readonly ApiClient $apiClient,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Agency[]
|
||||
*/
|
||||
public function loadAll(): array
|
||||
{
|
||||
try {
|
||||
return $this->cache->get('bpn_agencies', function (ItemInterface $item) {
|
||||
// Cache for 24 hours (agencies change infrequently)
|
||||
$item->expiresAfter(24 * 60 * 60);
|
||||
|
||||
$result = $this->apiClient->getAgencies();
|
||||
|
||||
if ($result instanceof Notification) {
|
||||
$this->logger->error('Failed to fetch agencies from BPN API', [
|
||||
'message' => $result->message,
|
||||
]);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
return $result;
|
||||
});
|
||||
} catch (InvalidArgumentException $e) {
|
||||
$this->logger->error('Cache error while loading agencies', [
|
||||
'exception' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public function loadById(int $id): ?Agency
|
||||
{
|
||||
$agencies = $this->loadAll();
|
||||
|
||||
foreach ($agencies as $agency) {
|
||||
if ($agency->id === $id) {
|
||||
return $agency;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function loadByCode(string $code): ?Agency
|
||||
{
|
||||
$agencies = $this->loadAll();
|
||||
|
||||
foreach ($agencies as $agency) {
|
||||
if ($agency->code === $code) {
|
||||
return $agency;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function loadDefault(): ?Agency
|
||||
{
|
||||
return $this->loadByCode(self::DEFAULT_AGENCY_CODE);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\BusProNet\XmlParser;
|
||||
|
||||
use App\BusProNet\Model\Agency;
|
||||
use Symfony\Component\DomCrawler\Crawler;
|
||||
|
||||
final class AgencyParser extends AbstractParser
|
||||
{
|
||||
/**
|
||||
* @return Agency[]
|
||||
*/
|
||||
public function parse(Crawler $node): array
|
||||
{
|
||||
$agencies = [];
|
||||
|
||||
$node->filterXPath('//agenturen/agentur')->each(function (Crawler $agencyNode) use (&$agencies): void {
|
||||
$id = (int) $agencyNode->attr('id');
|
||||
$name = $this->getStringOrNullValue($agencyNode->filterXPath('//name')) ?? '';
|
||||
$code = $this->getStringOrNullValue($agencyNode->filterXPath('//code')) ?? '';
|
||||
$street = $this->getStringOrNullValue($agencyNode->filterXPath('//strasse'));
|
||||
$postCode = $this->getStringOrNullValue($agencyNode->filterXPath('//plz'));
|
||||
$city = $this->getStringOrNullValue($agencyNode->filterXPath('//ort'));
|
||||
$phone = $this->getStringOrNullValue($agencyNode->filterXPath('//telefon'));
|
||||
|
||||
$agencies[] = new Agency(
|
||||
id: $id,
|
||||
name: $name,
|
||||
code: $code,
|
||||
street: $street,
|
||||
postCode: $postCode,
|
||||
city: $city,
|
||||
phone: $phone
|
||||
);
|
||||
});
|
||||
|
||||
return $agencies;
|
||||
}
|
||||
}
|
||||
@@ -59,12 +59,16 @@ class ApiResponseParser extends AbstractParser
|
||||
return (new AvailabilitiesParser())->parseRooms($resultNode);
|
||||
case ApiClient::TYPE_BOOKING_UPDATE:
|
||||
return (new BookingUpdateParser())->parse($resultNode);
|
||||
case ApiClient::TYPE_BOOKING:
|
||||
return (new BookingResponseParser())->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);
|
||||
case ApiClient::TYPE_AGENCIES:
|
||||
return (new AgencyParser())->parse($resultNode);
|
||||
}
|
||||
|
||||
throw new ResponseParserException('Unable to parse XML response');
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\BusProNet\XmlParser;
|
||||
|
||||
use App\BusProNet\Model\BookingResponse;
|
||||
use App\BusProNet\Model\PaymentTerms;
|
||||
use App\BusProNet\Model\PriceItem;
|
||||
use Symfony\Component\DomCrawler\Crawler;
|
||||
|
||||
/**
|
||||
* Parses XML responses from booking requests (inquiry and final booking).
|
||||
*
|
||||
* Expected XML structure:
|
||||
* <ergebnis>
|
||||
* <satz typ="BUCHUNG" />
|
||||
* <buchung>möglich|erfolgt</buchung>
|
||||
* <vorgang>321530</vorgang>
|
||||
* <preise>
|
||||
* <preis position="1" art="BEF" unterart="BUS" bezeichnung="..." ... />
|
||||
* </preise>
|
||||
* <gesamtpreis>671,78</gesamtpreis>
|
||||
* <zahlungsbedingungen>
|
||||
* <anzahlung betrag="128,00" termin="21.03.2017" />
|
||||
* <restzahlung betrag="543,78" termin="12.11.2017" />
|
||||
* </zahlungsbedingungen>
|
||||
* </ergebnis>
|
||||
*/
|
||||
class BookingResponseParser extends AbstractParser
|
||||
{
|
||||
public function parse(Crawler $node): BookingResponse
|
||||
{
|
||||
$status = $node->filterXPath('//buchung')->text();
|
||||
$transactionNumber = $this->getStringOrNullValue($node->filterXPath('//vorgang'));
|
||||
$totalPrice = $this->getFloatOrNullValue($node->filterXPath('//gesamtpreis'));
|
||||
|
||||
$priceItems = $this->parsePriceItems($node);
|
||||
$paymentTerms = $this->parsePaymentTerms($node);
|
||||
|
||||
return new BookingResponse(
|
||||
status: $status,
|
||||
transactionNumber: $transactionNumber,
|
||||
priceItems: $priceItems,
|
||||
totalPrice: $totalPrice,
|
||||
paymentTerms: $paymentTerms
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses individual price items from the response.
|
||||
*
|
||||
* @return array<int, PriceItem>
|
||||
*/
|
||||
private function parsePriceItems(Crawler $node): array
|
||||
{
|
||||
$priceItems = [];
|
||||
|
||||
$node->filterXPath('//preise/preis')->each(function (Crawler $priceNode) use (&$priceItems): void {
|
||||
$priceItems[] = new PriceItem(
|
||||
position: (int) $priceNode->attr('position'),
|
||||
type: $priceNode->attr('art'),
|
||||
subType: $priceNode->attr('unterart'),
|
||||
label: $priceNode->attr('bezeichnung'),
|
||||
dateFrom: $priceNode->attr('terminvon'),
|
||||
dateTo: $priceNode->attr('terminbis'),
|
||||
quantity: (int) $priceNode->attr('anzahl'),
|
||||
assignment: $priceNode->attr('zuordnung'),
|
||||
unitPrice: $this->stringToFloat($priceNode->attr('preis')),
|
||||
totalPrice: $this->stringToFloat($priceNode->attr('gesamtpreis')),
|
||||
id: $priceNode->attr('id')
|
||||
);
|
||||
});
|
||||
|
||||
return $priceItems;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses payment terms from the response.
|
||||
*/
|
||||
private function parsePaymentTerms(Crawler $node): ?PaymentTerms
|
||||
{
|
||||
$paymentNode = $node->filterXPath('//zahlungsbedingungen');
|
||||
|
||||
if (0 === $paymentNode->count()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$depositAmount = null;
|
||||
$depositDate = null;
|
||||
$finalPaymentAmount = null;
|
||||
$finalPaymentDate = null;
|
||||
|
||||
$depositNode = $paymentNode->filterXPath('//anzahlung');
|
||||
if ($depositNode->count() > 0) {
|
||||
$depositAmount = $this->stringToFloat($depositNode->attr('betrag'));
|
||||
$depositDate = $depositNode->attr('termin');
|
||||
}
|
||||
|
||||
$finalPaymentNode = $paymentNode->filterXPath('//restzahlung');
|
||||
if ($finalPaymentNode->count() > 0) {
|
||||
$finalPaymentAmount = $this->stringToFloat($finalPaymentNode->attr('betrag'));
|
||||
$finalPaymentDate = $finalPaymentNode->attr('termin');
|
||||
}
|
||||
|
||||
return new PaymentTerms(
|
||||
depositAmount: $depositAmount,
|
||||
depositDate: $depositDate,
|
||||
finalPaymentAmount: $finalPaymentAmount,
|
||||
finalPaymentDate: $finalPaymentDate
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ class InsuranceParser extends AbstractParser
|
||||
// Second pass: Parse individual insurances with conditional filtering
|
||||
$individualInsurances = [];
|
||||
$xmlContent->filterXPath('//versicherungen/versicherung')
|
||||
->each(function (Crawler $node) use (&$individualInsurances, &$insurances, $referencedIds) {
|
||||
->each(function (Crawler $node) use (&$individualInsurances, &$insurances) {
|
||||
$id = (int) $node->attr('idbuspro'); // Individual insurances have int IDs
|
||||
$isComplementary = $this->getBoolAttributeValue($node->attr('zusatzversicherung'));
|
||||
|
||||
|
||||
@@ -33,18 +33,23 @@ trait BookingExceptionHandlerTrait
|
||||
return $bookingService->getOrCreateBookingCreateDto($request);
|
||||
} catch (BookingSessionNotFoundException $e) {
|
||||
$this->addFlash('error', 'Ihre Buchungssitzung ist abgelaufen. Bitte starten Sie eine neue Buchung.');
|
||||
|
||||
return $this->redirectToRoute('app_booking_create_error');
|
||||
} catch (TravelNotFoundException $e) {
|
||||
$this->addFlash('error', 'Die angeforderte Reise wurde nicht gefunden.');
|
||||
|
||||
return $this->redirectToRoute('app_booking_create_error');
|
||||
} catch (HotelNotFoundException $e) {
|
||||
$this->addFlash('error', 'Das angeforderte Hotel wurde nicht gefunden.');
|
||||
|
||||
return $this->redirectToRoute('app_booking_create_error');
|
||||
} catch (HotelNotInTravelException $e) {
|
||||
$this->addFlash('error', 'Das Hotel ist für diese Reise nicht verfügbar.');
|
||||
|
||||
return $this->redirectToRoute('app_booking_create_error');
|
||||
} catch (NoRoomsAvailableException $e) {
|
||||
$this->addFlash('error', 'Für diese Reise sind aktuell keine Zimmer verfügbar.');
|
||||
|
||||
return $this->redirectToRoute('app_booking_create_error');
|
||||
}
|
||||
}
|
||||
@@ -59,8 +64,8 @@ trait BookingExceptionHandlerTrait
|
||||
{
|
||||
try {
|
||||
return $bookingService->getOrCreateBookingCreateDto($request);
|
||||
} catch (BookingSessionNotFoundException | TravelNotFoundException | HotelNotFoundException | HotelNotInTravelException | NoRoomsAvailableException $e) {
|
||||
} catch (BookingSessionNotFoundException|TravelNotFoundException|HotelNotFoundException|HotelNotInTravelException|NoRoomsAvailableException $e) {
|
||||
return new Response('', 400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Booking;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
class BookingSuccessController extends AbstractController
|
||||
{
|
||||
#[Route('/bookings/success', name: 'app_booking_success')]
|
||||
public function success(Request $request): Response
|
||||
{
|
||||
$bookingNumber = $request->getSession()->getFlashBag()->get('booking_number')[0] ?? null;
|
||||
|
||||
// Redirect to homepage if no booking number (direct access or refresh)
|
||||
if (null === $bookingNumber) {
|
||||
return $this->redirect('https://www.ep-reisen.de');
|
||||
}
|
||||
|
||||
return $this->render('booking/success.html.twig', [
|
||||
'bookingNumber' => $bookingNumber,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Booking;
|
||||
|
||||
use App\BusProNet\XmlLoader\AgencyLoader;
|
||||
use App\Exception\HotelNotFoundException;
|
||||
use App\Exception\HotelNotInTravelException;
|
||||
use App\Exception\NoRoomsAvailableException;
|
||||
@@ -25,6 +26,7 @@ class CreateInitController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly BookingService $bookingService,
|
||||
private readonly AgencyLoader $agencyLoader,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -34,6 +36,10 @@ class CreateInitController extends AbstractController
|
||||
* This endpoint provides a clean way to start the booking flow with just
|
||||
* dateId and hotelId parameters. It clears any existing booking session
|
||||
* and creates a fresh BookingCreateDto before redirecting to step 1.
|
||||
*
|
||||
* Optionally accepts an agency code parameter. If provided and valid, the
|
||||
* corresponding agency ID is stored in the booking. If not provided or invalid,
|
||||
* defaults to agency code '0001'.
|
||||
*/
|
||||
#[Route('/bookings/create/{dateId}/{hotelId}', name: 'app_booking_create_init', requirements: ['dateId' => '\d+', 'hotelId' => '\d+'])]
|
||||
public function init(Request $request, int $dateId, int $hotelId): Response
|
||||
@@ -42,8 +48,11 @@ class CreateInitController extends AbstractController
|
||||
// Clear any existing booking session to ensure fresh start
|
||||
$this->bookingService->clearBookingSession($request);
|
||||
|
||||
// Determine agency ID from optional query parameter
|
||||
$agencyId = $this->resolveAgencyId($request->query->get('agency'));
|
||||
|
||||
// Create fresh booking session with the provided parameters
|
||||
$this->bookingService->startFreshBooking($request, $dateId, $hotelId);
|
||||
$this->bookingService->startFreshBooking($request, $dateId, $hotelId, $agencyId);
|
||||
|
||||
// Redirect to step 1 of the booking flow
|
||||
return $this->redirectToRoute('app_booking_create_step_1');
|
||||
@@ -58,6 +67,37 @@ class CreateInitController extends AbstractController
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the agency ID from the provided agency code.
|
||||
*
|
||||
* If the code is null or the agency is not found, returns the default agency ID.
|
||||
*
|
||||
* @param string|null $agencyCode The agency code from the request parameter
|
||||
*
|
||||
* @return int|null The agency ID, or null if default agency not found
|
||||
*/
|
||||
private function resolveAgencyId(?string $agencyCode): ?int
|
||||
{
|
||||
// Use default agency if no code provided
|
||||
if (null === $agencyCode || '' === trim($agencyCode)) {
|
||||
$defaultAgency = $this->agencyLoader->loadDefault();
|
||||
|
||||
return $defaultAgency?->id;
|
||||
}
|
||||
|
||||
// Try to find agency by provided code
|
||||
$agency = $this->agencyLoader->loadByCode($agencyCode);
|
||||
|
||||
// Fall back to default agency if code not found
|
||||
if (null === $agency) {
|
||||
$defaultAgency = $this->agencyLoader->loadDefault();
|
||||
|
||||
return $defaultAgency?->id;
|
||||
}
|
||||
|
||||
return $agency->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays user-friendly error messages for booking initialization failures.
|
||||
*
|
||||
|
||||
@@ -4,15 +4,17 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Booking;
|
||||
|
||||
use App\BusProNet\Constants;
|
||||
use App\BusProNet\ApiClient;
|
||||
use App\BusProNet\Model\Notification;
|
||||
use App\Controller\Traits\HtmxControllerTrait;
|
||||
use App\Form\BookingCreateStep3Type;
|
||||
use App\Service\BookingPriceCalculatorService;
|
||||
use App\Service\BookingService;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* Handles the third step of the booking creation process (payment method selection).
|
||||
@@ -25,6 +27,9 @@ class CreateStep3Controller extends AbstractController
|
||||
|
||||
public function __construct(
|
||||
private readonly BookingService $bookingService,
|
||||
private readonly ApiClient $apiClient,
|
||||
private readonly BookingPriceCalculatorService $priceCalculator,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -49,10 +54,74 @@ class CreateStep3Controller extends AbstractController
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$bookingCreateDto->currentStep = 4;
|
||||
$this->bookingService->saveBookingCreateDto($request, $bookingCreateDto);
|
||||
try {
|
||||
// Validate booking data with API (inquiry)
|
||||
$inquiryResponse = $this->apiClient->createBookingInquiry($bookingCreateDto);
|
||||
|
||||
return $this->redirectToRoute('app_booking_create_step_4');
|
||||
if ($inquiryResponse instanceof Notification) {
|
||||
$this->logger->error('Booking inquiry failed', [
|
||||
'message' => $inquiryResponse->message,
|
||||
]);
|
||||
$this->addFlash('error', 'Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.');
|
||||
|
||||
return $this->render('booking/create_step_3.html.twig', [
|
||||
'bookingCreateDto' => $bookingCreateDto,
|
||||
'form' => $form->createView(),
|
||||
...$this->getSummaryVariables($bookingCreateDto),
|
||||
]);
|
||||
}
|
||||
|
||||
if (false === $inquiryResponse->isInquiryValid()) {
|
||||
$this->logger->error('Booking inquiry validation failed', [
|
||||
'status' => $inquiryResponse->status,
|
||||
]);
|
||||
$this->addFlash('error', 'Buchung konnte nicht validiert werden.');
|
||||
|
||||
return $this->render('booking/create_step_3.html.twig', [
|
||||
'bookingCreateDto' => $bookingCreateDto,
|
||||
'form' => $form->createView(),
|
||||
...$this->getSummaryVariables($bookingCreateDto),
|
||||
]);
|
||||
}
|
||||
|
||||
// Validate price match (exact comparison)
|
||||
$apiTotal = $inquiryResponse->totalPrice ?? 0.0;
|
||||
$calculatedTotal = $this->priceCalculator->calculateGrandTotal($bookingCreateDto);
|
||||
|
||||
if ($apiTotal !== $calculatedTotal) {
|
||||
$this->logger->error('Price mismatch detected - payload incomplete', [
|
||||
'apiTotal' => $apiTotal,
|
||||
'calculatedTotal' => $calculatedTotal,
|
||||
'difference' => abs($apiTotal - $calculatedTotal),
|
||||
]);
|
||||
$this->addFlash('error', 'Ein technischer Fehler ist aufgetreten.');
|
||||
|
||||
return $this->render('booking/create_step_3.html.twig', [
|
||||
'bookingCreateDto' => $bookingCreateDto,
|
||||
'form' => $form->createView(),
|
||||
...$this->getSummaryVariables($bookingCreateDto),
|
||||
]);
|
||||
}
|
||||
|
||||
// Validation successful - proceed to confirmation step
|
||||
$bookingCreateDto->currentStep = 4;
|
||||
$this->bookingService->saveBookingCreateDto($request, $bookingCreateDto);
|
||||
|
||||
return $this->redirectToRoute('app_booking_create_step_4');
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Booking inquiry exception', [
|
||||
'exception' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
$this->addFlash('error', 'Ein technischer Fehler ist aufgetreten.');
|
||||
|
||||
return $this->render('booking/create_step_3.html.twig', [
|
||||
'bookingCreateDto' => $bookingCreateDto,
|
||||
'form' => $form->createView(),
|
||||
...$this->getSummaryVariables($bookingCreateDto),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('booking/create_step_3.html.twig', [
|
||||
|
||||
@@ -4,13 +4,17 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Booking;
|
||||
|
||||
use App\BusProNet\ApiClient;
|
||||
use App\BusProNet\Model\Notification;
|
||||
use App\Controller\Traits\HtmxControllerTrait;
|
||||
use App\Form\BookingCreateStep4Type;
|
||||
use App\Service\BookingService;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
/**
|
||||
* Handles the fourth step of the booking creation process (confirmation).
|
||||
*/
|
||||
@@ -22,6 +26,8 @@ class CreateStep4Controller extends AbstractController
|
||||
|
||||
public function __construct(
|
||||
private readonly BookingService $bookingService,
|
||||
private readonly ApiClient $apiClient,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -46,13 +52,49 @@ class CreateStep4Controller extends AbstractController
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
// TODO: Perform inquiry API call
|
||||
// TODO: If inquiry successful, perform booking API call
|
||||
// TODO: Clear session and redirect to success page
|
||||
try {
|
||||
// Submit final booking (already validated in Step 3)
|
||||
$bookingResponse = $this->apiClient->createBooking($bookingCreateDto);
|
||||
|
||||
$this->addFlash('success', 'Buchung erfolgreich abgeschlossen.');
|
||||
if ($bookingResponse instanceof Notification) {
|
||||
$this->addFlash('error', $bookingResponse->message);
|
||||
|
||||
return $this->redirectToRoute('app_booking_create_step_4');
|
||||
return $this->render('booking/create_step_4.html.twig', [
|
||||
'bookingCreateDto' => $bookingCreateDto,
|
||||
'form' => $form->createView(),
|
||||
...$this->getSummaryVariables($bookingCreateDto),
|
||||
]);
|
||||
}
|
||||
|
||||
if (false === $bookingResponse->isBookingSuccessful()) {
|
||||
$this->addFlash('error', 'Buchung konnte nicht erstellt werden.');
|
||||
|
||||
return $this->render('booking/create_step_4.html.twig', [
|
||||
'bookingCreateDto' => $bookingCreateDto,
|
||||
'form' => $form->createView(),
|
||||
...$this->getSummaryVariables($bookingCreateDto),
|
||||
]);
|
||||
}
|
||||
|
||||
// Success: Store booking number in flash and clear session
|
||||
$this->addFlash('booking_number', $bookingResponse->transactionNumber);
|
||||
$this->bookingService->clearBookingCreateDto($request);
|
||||
|
||||
return $this->redirectToRoute('app_booking_success');
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Booking creation failed', [
|
||||
'exception' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
$this->addFlash('error', 'Ein technischer Fehler ist aufgetreten.');
|
||||
|
||||
return $this->render('booking/create_step_4.html.twig', [
|
||||
'bookingCreateDto' => $bookingCreateDto,
|
||||
'form' => $form->createView(),
|
||||
...$this->getSummaryVariables($bookingCreateDto),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('booking/create_step_4.html.twig', [
|
||||
@@ -61,4 +103,4 @@ class CreateStep4Controller extends AbstractController
|
||||
...$this->getSummaryVariables($bookingCreateDto),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\BusProNet\Form\CountryType;
|
||||
use App\BusProNet\Model\Address;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class AddressType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('street', TextType::class, [
|
||||
'label' => 'Straße',
|
||||
'required' => $options['required'],
|
||||
'sanitize_html' => true,
|
||||
])
|
||||
->add('postCode', TextType::class, [
|
||||
'label' => 'PLZ',
|
||||
'required' => $options['required'],
|
||||
'sanitize_html' => true,
|
||||
])
|
||||
->add('city', TextType::class, [
|
||||
'label' => 'Ort',
|
||||
'required' => $options['required'],
|
||||
'sanitize_html' => true,
|
||||
])
|
||||
->add('country', CountryType::class, [
|
||||
'label' => 'Land',
|
||||
'property' => 'country',
|
||||
'required' => $options['required'],
|
||||
'preferred_choices' => ['DE', 'AT', 'CH'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => Address::class,
|
||||
'required' => false,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -148,14 +148,16 @@ class BookingParticipantType extends AbstractType
|
||||
], $getFieldState('nationality')))
|
||||
->add('email', EmailType::class, $this->mergeFieldState([
|
||||
'label' => 'E-Mail',
|
||||
'required' => false,
|
||||
'sanitize_html' => true,
|
||||
], $getFieldState('email')))
|
||||
->add('mobile', TextType::class, $this->mergeFieldState([
|
||||
'label' => 'Telefon (mobil)',
|
||||
'required' => false,
|
||||
'required' => 0 === $participantIndex,
|
||||
'sanitize_html' => true,
|
||||
], $getFieldState('mobile')));
|
||||
], $getFieldState('mobile')))
|
||||
->add('address', AddressType::class, $this->mergeFieldState([
|
||||
'label' => 'Adresse',
|
||||
'required' => 0 === $participantIndex,
|
||||
], $getFieldState('address')));
|
||||
|
||||
// Add body dimensions with state handling - use shouldIncludeField method
|
||||
if ($this->fieldStateProvider->shouldIncludeField('bodyDimensions', $bookingDto, $participantIndex)) {
|
||||
@@ -194,7 +196,7 @@ class BookingParticipantType extends AbstractType
|
||||
// Clear the form and rebuild from scratch with updated states
|
||||
|
||||
// Rebuild base fields with updated states
|
||||
$baseFields = ['firstName', 'lastName', 'dateOfBirth', 'gender', 'nationality', 'email', 'mobile', 'bodyDimensions'];
|
||||
$baseFields = ['firstName', 'lastName', 'dateOfBirth', 'gender', 'nationality', 'email', 'mobile', 'address', 'bodyDimensions'];
|
||||
foreach ($baseFields as $fieldName) {
|
||||
if ($form->has($fieldName)) {
|
||||
$form->remove($fieldName);
|
||||
|
||||
@@ -31,6 +31,8 @@ class BookingCreateDto implements BookingDtoInterface
|
||||
|
||||
public ?BankAccountDto $bankAccount = null;
|
||||
|
||||
public ?int $agencyId = null;
|
||||
|
||||
public function __construct(public Travel $travel, public int $hotelId)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Form\Model;
|
||||
|
||||
use App\BusProNet\Model\Address;
|
||||
use App\BusProNet\Model\Insurance;
|
||||
use App\BusProNet\Model\PersonalData;
|
||||
use App\BusProNet\Model\Pickup;
|
||||
@@ -56,11 +57,16 @@ class ParticipantDto
|
||||
#[Assert\NotNull(message: 'Bitte angeben', groups: ['booking_edit', 'booking_create_step_2'])]
|
||||
public ?\DateTimeImmutable $dateOfBirth = null;
|
||||
|
||||
#[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;
|
||||
|
||||
#[Assert\Valid(groups: ['booking_edit', 'booking_create_step_2'])]
|
||||
#[Assert\NotNull(message: 'Bitte Adresse angeben', groups: ['applicant_address'])]
|
||||
public ?Address $address = null;
|
||||
|
||||
#[Assert\NotNull(message: 'Bitte ein Zimmer auswählen', groups: ['booking_create_step_2'])]
|
||||
public ?int $assignedRoomId = null;
|
||||
|
||||
@@ -104,6 +110,11 @@ class ParticipantDto
|
||||
*/
|
||||
public array $notifications = [];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->address = new Address();
|
||||
}
|
||||
|
||||
public static function fromPersonalData(PersonalData $personalData): static
|
||||
{
|
||||
$instance = new static();
|
||||
|
||||
@@ -85,8 +85,9 @@ class ParticipantBulkInsuranceFieldHandler extends AbstractParticipantFieldHandl
|
||||
$this->applyBulkInsuranceToAllParticipants($bookingDto, $participant->insurance);
|
||||
}
|
||||
|
||||
// If bulk insurance is disabled, clear dependent participants' insurances
|
||||
if (false === $isBulkEnabled) {
|
||||
// If bulk insurance was CHANGED from enabled to disabled, clear dependent participants' insurances
|
||||
// Don't clear if it was never enabled (to allow independent insurance selection)
|
||||
if (false === $isBulkEnabled && $this->wasBulkInsurancePreviouslyEnabled($bookingDto)) {
|
||||
$this->clearDependentParticipantsInsurance($bookingDto);
|
||||
}
|
||||
}
|
||||
@@ -97,8 +98,8 @@ class ParticipantBulkInsuranceFieldHandler extends AbstractParticipantFieldHandl
|
||||
* Uses InsuranceMatchingService to find the appropriate price tier for each participant
|
||||
* based on their individual travel price and eligibility criteria.
|
||||
*
|
||||
* @param BookingCreateDto $bookingDto The booking DTO with all participants
|
||||
* @param object $applicantInsurance The insurance selected by the applicant
|
||||
* @param BookingCreateDto $bookingDto The booking DTO with all participants
|
||||
* @param object $applicantInsurance The insurance selected by the applicant
|
||||
*/
|
||||
private function applyBulkInsuranceToAllParticipants(BookingCreateDto $bookingDto, object $applicantInsurance): void
|
||||
{
|
||||
@@ -139,4 +140,38 @@ class ParticipantBulkInsuranceFieldHandler extends AbstractParticipantFieldHandl
|
||||
$participant->insurance = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if bulk insurance was previously enabled by checking if dependent participants
|
||||
* have the same insurance type as the applicant.
|
||||
*
|
||||
* This prevents clearing independent insurance selections when the checkbox is simply unchecked
|
||||
* without ever having been enabled.
|
||||
*
|
||||
* @param BookingCreateDto $bookingDto The booking DTO with all participants
|
||||
*
|
||||
* @return bool True if bulk was previously active (dependent participants have matching insurance)
|
||||
*/
|
||||
private function wasBulkInsurancePreviouslyEnabled(BookingCreateDto $bookingDto): bool
|
||||
{
|
||||
$applicant = $bookingDto->participants[0] ?? null;
|
||||
if (null === $applicant || null === $applicant->insurance) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if any dependent participant has insurance that matches the applicant
|
||||
// If so, bulk was likely previously enabled
|
||||
foreach ($bookingDto->participants as $index => $participant) {
|
||||
if (0 === $index) {
|
||||
continue; // Skip applicant
|
||||
}
|
||||
|
||||
if (null !== $participant->insurance) {
|
||||
// If any dependent has insurance, assume bulk was previously enabled
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +95,17 @@ class BookingService
|
||||
$request->getSession()->set(self::BOOKING_CREATE_KEY, $bookingCreateDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the booking creation DTO from the session.
|
||||
*
|
||||
* This method removes only the booking DTO while preserving other session data.
|
||||
* Used after successful booking submission to clear the booking flow state.
|
||||
*/
|
||||
public function clearBookingCreateDto(Request $request): void
|
||||
{
|
||||
$request->getSession()->remove(self::BOOKING_CREATE_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all booking-related session data.
|
||||
*
|
||||
@@ -115,7 +126,7 @@ class BookingService
|
||||
* and saves it to the session. It's designed to be called from the clean
|
||||
* booking entry point without requiring UID parameters.
|
||||
*/
|
||||
public function startFreshBooking(Request $request, int $dateId, int $hotelId): BookingCreateDto
|
||||
public function startFreshBooking(Request $request, int $dateId, int $hotelId, ?int $agencyId = null): BookingCreateDto
|
||||
{
|
||||
$travelData = $this->travelDataService->getTravelData($dateId, $hotelId);
|
||||
if (null === $travelData) {
|
||||
@@ -138,6 +149,7 @@ class BookingService
|
||||
$bookingCreateDto = new BookingCreateDto($travelData, $hotelId);
|
||||
$bookingCreateDto->roomSelections = $roomSelections;
|
||||
$bookingCreateDto->currentStep = 1;
|
||||
$bookingCreateDto->agencyId = $agencyId;
|
||||
|
||||
$this->saveBookingCreateDto($request, $bookingCreateDto);
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ class ParticipantValidator extends ConstraintValidator
|
||||
$this->assertBodyMeasurementsValid($participant);
|
||||
$this->assertTransportationSelected($participant);
|
||||
$this->assertPickupSelected($participant);
|
||||
$this->assertApplicantAddressValid($participant);
|
||||
}
|
||||
|
||||
public function assertBodyMeasurementsValid(ParticipantDto $participant): void
|
||||
@@ -64,4 +65,49 @@ class ParticipantValidator extends ConstraintValidator
|
||||
;
|
||||
}
|
||||
}
|
||||
|
||||
public function assertApplicantAddressValid(ParticipantDto $participant): void
|
||||
{
|
||||
// Address is only mandatory for the applicant (first participant)
|
||||
if (false === $participant->isApplicant()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check each required address field for applicant
|
||||
if (null === $participant->address->street || '' === trim($participant->address->street)) {
|
||||
$this->context->buildViolation('Bitte angeben')
|
||||
->atPath('address.street')
|
||||
->addViolation()
|
||||
;
|
||||
}
|
||||
|
||||
if (null === $participant->address->postCode || '' === trim($participant->address->postCode)) {
|
||||
$this->context->buildViolation('Bitte angeben')
|
||||
->atPath('address.postCode')
|
||||
->addViolation()
|
||||
;
|
||||
}
|
||||
|
||||
if (null === $participant->address->city || '' === trim($participant->address->city)) {
|
||||
$this->context->buildViolation('Bitte angeben')
|
||||
->atPath('address.city')
|
||||
->addViolation()
|
||||
;
|
||||
}
|
||||
|
||||
if (null === $participant->address->country || '' === trim($participant->address->country)) {
|
||||
$this->context->buildViolation('Bitte angeben')
|
||||
->atPath('address.country')
|
||||
->addViolation()
|
||||
;
|
||||
}
|
||||
|
||||
// Mobile/phone is mandatory for applicant
|
||||
if (null === $participant->mobile || '' === trim($participant->mobile)) {
|
||||
$this->context->buildViolation('Bitte angeben')
|
||||
->atPath('mobile')
|
||||
->addViolation()
|
||||
;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user