Files
myep/src/BusProNet/DataProcessor/BookingDataProcessor.php
T
2026-08-03 19:52:10 +02:00

448 lines
22 KiB
PHP

<?php
declare(strict_types=1);
namespace App\BusProNet\DataProcessor;
use App\BusProNet\Constants;
use App\BusProNet\Model\Booking;
use App\BusProNet\Model\Travel;
use App\BusProNet\Utility\DirectionMapper;
use App\BusProNet\XmlLoader\AgencyLoader;
use App\Form\Model\BankAccountDto;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Form\Model\RoomSelectionDto;
use App\Service\BookingPriceCalculator;
use App\Service\InsuranceManager;
use function Symfony\Component\String\u;
/**
* Processes booking form data and converts it into BusProNet API payload format.
*
* This processor orchestrates the transformation of booking data between DTO and API formats.
* It delegates to specialized classes for service mapping, participant processing,
* payload building, and personal data synchronization.
*/
class BookingDataProcessor
{
public function __construct(
private readonly InsuranceManager $insuranceService,
private readonly BookingPriceCalculator $priceCalculatorService,
private readonly ServiceMappingCollector $mappingCollector,
private readonly ParticipantServiceProcessor $serviceProcessor,
private readonly BookingPayloadBuilder $payloadBuilder,
private readonly PersonalDataSynchronizer $personalDataSynchronizer,
) {
}
/**
* Creates a BookingDto from an existing Booking entity (for edit mode).
*
* Extracts all service selections from the booking entity and assigns them
* to participant DTOs, creating a unified data structure identical to create mode.
*
* IMPORTANT: Address handling for edit mode
* - ParticipantDto::fromPersonalData() clones address objects to prevent shared references
* - For normal bookings, participant[0]'s missing address/dimensions are filled from the
* applicant (same person in normal bookings, but BPN may omit fields in <teilnehmer>)
* - For internal agency bookings, this copy is skipped: the applicant is a customer, not
* the first participant, so copying applicant data into participant[0] would be incorrect
*/
public function createBookingDtoFromBooking(Booking $booking, Travel $travel, bool $isInternalAgencyBooking = false): BookingDto
{
$dto = new BookingDto($travel, $booking->hotelId);
$dto->booking = $booking;
$dto->agencyId = $booking->agencyId;
// Map payment ID from API to form payment method
$dto->paymentMethod = match ($booking->paymentId) {
(string) Constants::PAYMENT_TYPE_ID_DEBIT => Constants::PAYMENT_METHOD_DEBIT,
(string) Constants::PAYMENT_TYPE_ID_TRANSFER => Constants::PAYMENT_METHOD_TRANSFER,
default => Constants::PAYMENT_METHOD_TRANSFER,
};
if (null !== $booking->bankAccount) {
$dto->bankAccount = BankAccountDto::fromBankAccount($booking->bankAccount);
}
foreach ($booking->participants as $index => $participant) {
$participantData = ParticipantDto::fromPersonalData($participant);
$participantData->index = $index;
// For normal bookings, applicant === participant[0]: copy address and body dimensions
// from applicant when BPN returns full data only in <anmelder> and minimal data in
// <teilnehmer id="1">. Skipped for internal agency bookings where the applicant is
// a customer (a different entity from the first traveler).
if (0 === $index && false === $isInternalAgencyBooking) {
// Copy address if first participant has no street (indicating empty/incomplete address)
$isEmpty = null === $participantData->address
|| null === $participantData->address->street
|| u($participantData->address->street)->trim()->isEmpty();
if ($isEmpty) {
$participantData->address = clone $booking->applicant->address;
}
// Copy body dimensions from applicant if not present in participant
if (null === $participantData->height && null !== $booking->applicant->height) {
$participantData->height = $booking->applicant->height;
}
if (null === $participantData->weight && null !== $booking->applicant->weight) {
$participantData->weight = $booking->applicant->weight;
}
if (null === $participantData->shoeSize && null !== $booking->applicant->shoeSize) {
$participantData->shoeSize = $booking->applicant->shoeSize;
}
}
// Extract service selections from booking and assign to participant DTO
$participantData->courses = $booking
->getAdditionalServicesForParticipantByGroup($index, Constants::TOKEN_COURSES);
$participantData->skiPass = $booking->getSkiPassForParticipant($index);
$participantData->additionalServices = $booking
->getAdditionalServicesForParticipantByGroup($index, Constants::TOKEN_ADDITIONAL);
$participantData->board = $booking
->getAdditionalServicesForParticipantByGroup($index, Constants::TOKEN_BOARD);
$vegServices = $booking->getAdditionalServicesForParticipantByGroup($index, Constants::TOKEN_VEG);
$participantData->veg = false === empty($vegServices) ? reset($vegServices) : null;
$participantData->rentals = $booking
->getAdditionalServicesForParticipantByGroup($index, Constants::TOKEN_RENTALS);
// Rental insurance (single service, not array)
$participantData->rentalInsurance = $booking->getRentalInsuranceForParticipant($index);
$participantData->rentalInsuranceSelected = null !== $participantData->rentalInsurance;
// Transportation services
$participantData->transportationOutbound = $booking
->getTransportationServiceForParticipantAndDirection($index, DirectionMapper::OUTBOUND_BOOKING);
$participantData->transportationInbound = $booking
->getTransportationServiceForParticipantAndDirection($index, DirectionMapper::INBOUND_BOOKING);
// Pickup location
$participantData->pickup = $booking->getPickupForParticipant($index);
// Drop-off location
$participantData->dropOff = $booking->getDropOffForParticipant($index);
$participantData->differentDropOff = null !== $participantData->dropOff;
// Parking service (for self-organized PKW transportation)
$parkingServices = $booking->getAdditionalServicesForParticipantByGroup($index, Constants::TOKEN_PARKING);
if (false === empty($parkingServices)) {
$participantData->parking = true;
$participantData->parkingService = reset($parkingServices);
} else {
$participantData->parking = false;
$participantData->parkingService = null;
}
// License plate (not stored in booking entity)
$participantData->licensePlate = null;
// Insurance
$participantData->insurance = $booking->getInsuranceForParticipant($index);
// Room assignment
$room = $booking->getRoomForParticipant($index);
$participantData->assignedRoomId = $room?->id;
// Enrich services with data from travel (especially prices)
$this->enrichParticipantServicesFromTravel($participantData, $travel);
// Normalize loaded participant data so the edit flow sees canonical values.
$participantData->normalizeLoadedData();
$dto->participants[$index] = $participantData;
}
// Rebuild roomSelections array from booking room data
// This is needed for conditions like SingleRoomTypeCondition that rely on roomSelections
// The booking already contains rooms with actual quantities (anzahl from XML)
$availableRooms = $travel->getAvailableRooms();
foreach ($booking->rooms as $bookingRoom) {
$room = $availableRooms[$bookingRoom->id] ?? null;
if (null !== $room && null !== $bookingRoom->totalCount && $bookingRoom->totalCount > 0) {
$roomSelection = new RoomSelectionDto();
$roomSelection->id = $bookingRoom->id;
$roomSelection->label = $room->label;
$roomSelection->price = $room->price;
$roomSelection->quantity = $bookingRoom->totalCount; // Actual quantity from booking XML (anzahl)
$roomSelection->capacity = $room->minPax ?? 1;
$roomSelection->maxQuantity = $bookingRoom->totalCount + ($room->available ?? 0);
$dto->roomSelections[] = $roomSelection;
}
}
return $dto;
}
/**
* Enriches participant service selections with data from travel model.
*
* Services extracted from booking API responses might not include all necessary data
* (especially prices). This method looks up each service in the travel data and copies
* over missing properties to ensure proper pricing calculations.
*
* @param ParticipantDto $participant The participant with service selections
* @param Travel $travel The travel data containing full service information
*/
private function enrichParticipantServicesFromTravel(ParticipantDto $participant, Travel $travel): void
{
// Enrich courses
foreach ($participant->courses as $key => $course) {
if (isset($travel->additionalServices[$course->id])) {
$participant->courses[$key] = $travel->additionalServices[$course->id];
}
}
// Enrich ski pass
if (null !== $participant->skiPass && isset($travel->additionalServices[$participant->skiPass->id])) {
$participant->skiPass = $travel->additionalServices[$participant->skiPass->id];
}
// Enrich additional services
foreach ($participant->additionalServices as $key => $service) {
if (isset($travel->additionalServices[$service->id])) {
$participant->additionalServices[$key] = $travel->additionalServices[$service->id];
}
}
// Enrich board
foreach ($participant->board as $key => $board) {
if (isset($travel->additionalServices[$board->id])) {
$participant->board[$key] = $travel->additionalServices[$board->id];
}
}
// Enrich veg
if (null !== $participant->veg && isset($travel->additionalServices[$participant->veg->id])) {
$participant->veg = $travel->additionalServices[$participant->veg->id];
}
// Enrich rentals
foreach ($participant->rentals as $key => $rental) {
if (isset($travel->additionalServices[$rental->id])) {
$participant->rentals[$key] = $travel->additionalServices[$rental->id];
}
}
// Enrich rental insurance
if (null !== $participant->rentalInsurance && isset($travel->additionalServices[$participant->rentalInsurance->id])) {
$participant->rentalInsurance = $travel->additionalServices[$participant->rentalInsurance->id];
}
// Enrich parking service
if (null !== $participant->parkingService && isset($travel->additionalServices[$participant->parkingService->id])) {
$participant->parkingService = $travel->additionalServices[$participant->parkingService->id];
}
// Enrich transportation services
if (null !== $participant->transportationOutbound && isset($travel->transportationServices[$participant->transportationOutbound->id])) {
$participant->transportationOutbound = $travel->transportationServices[$participant->transportationOutbound->id];
}
if (null !== $participant->transportationInbound && isset($travel->transportationServices[$participant->transportationInbound->id])) {
$participant->transportationInbound = $travel->transportationServices[$participant->transportationInbound->id];
}
// Enrich pickup with data from travel (includes both outbound and inbound prices)
// The pickups already have inbound prices merged in from TravelParser
if (null !== $participant->pickup && isset($travel->pickups[$participant->pickup->id])) {
$enrichedPickup = $travel->pickups[$participant->pickup->id];
// Copy all properties from enriched pickup to participant's pickup
$participant->pickup->price = $enrichedPickup->price;
$participant->pickup->priceOutbound = $enrichedPickup->priceOutbound;
$participant->pickup->priceInbound = $enrichedPickup->priceInbound;
$participant->pickup->time = $enrichedPickup->time;
$participant->pickup->city = $enrichedPickup->city;
$participant->pickup->street = $enrichedPickup->street;
$participant->pickup->postalCode = $enrichedPickup->postalCode;
$participant->pickup->code = $enrichedPickup->code;
}
// Enrich drop-off with data from travel
if (null !== $participant->dropOff && isset($travel->dropOffs[$participant->dropOff->id])) {
$enrichedDropOff = $travel->dropOffs[$participant->dropOff->id];
$participant->dropOff->price = $enrichedDropOff->price;
$participant->dropOff->priceOutbound = $enrichedDropOff->priceOutbound;
$participant->dropOff->priceInbound = $enrichedDropOff->priceInbound;
$participant->dropOff->time = $enrichedDropOff->time;
$participant->dropOff->city = $enrichedDropOff->city;
$participant->dropOff->street = $enrichedDropOff->street;
$participant->dropOff->postalCode = $enrichedDropOff->postalCode;
$participant->dropOff->code = $enrichedDropOff->code;
}
// Enrich insurance
if (null !== $participant->insurance && isset($travel->insurances[$participant->insurance->id])) {
$participant->insurance = $travel->insurances[$participant->insurance->id];
}
}
/**
* Creates an update request payload for the BusProNet API from booking form data.
*
* This method processes booking edit form data and transforms it into the structured array
* format expected by the BusProNet XML API. It handles service mappings, participant data
* updates, and generates the complete payload structure including booking metadata,
* participant information, services, transportation, and accommodation details.
*
* @param BookingDto|null $formData The booking edit form data containing updated participant and service selections
*
* @return array<string, mixed> The structured payload array ready for BusProNet API submission
*/
public function createUpdateRequestPayload(?BookingDto $formData): array
{
// Apply bulk or family insurance if applicable (modifies DTO in place)
$this->applyApplicantInsuranceToParticipants($formData);
$bookingData = $formData->booking;
$travelData = $formData->travel;
// CRITICAL: The applicant address may have been lost during form binding because ParticipantDto::fromPersonalData()
// stores a reference to the address object, and Symfony's form binding can modify it in place.
// Since we can't easily restore it here without the original booking, we rely on the EditController
// to preserve the original booking's applicant data by fetching fresh data before calling updateBooking().
// The applicant data should NEVER be modified in edit mode.
$this->serviceProcessor->resetServiceMappings($bookingData);
foreach ($formData->participants as $participant) {
$this->serviceProcessor->processParticipantServices($participant, $bookingData, $travelData);
}
$this->serviceProcessor->removeUnusedServices($bookingData);
$this->personalDataSynchronizer->updateParticipantPersonalData(
$formData->participants,
$bookingData,
$formData->isInternalAgencyBooking()
);
$payload = $this->payloadBuilder->buildBasePayload($bookingData);
$this->payloadBuilder->addBankAccountToPayload($payload, $bookingData);
$this->payloadBuilder->buildParticipantPayload($payload, $bookingData, $formData->participants);
$this->payloadBuilder->buildServicesPayload($payload, $bookingData);
$this->payloadBuilder->buildPickupPayload($payload, $bookingData);
$this->payloadBuilder->buildDropOffPayload($payload, $bookingData);
// Add purchase vouchers if any exist
$purchaseVouchers = $this->mappingCollector->collectPurchaseVouchers($formData);
if (false === empty($purchaseVouchers)) {
$payload['gutscheine']['gutschein'] = [];
foreach ($purchaseVouchers as $code) {
$payload['gutscheine']['gutschein'][] = [
'@einloesecode' => $code,
];
}
}
return $payload;
}
/**
* 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 BookingDto $bookingDto The booking creation form data
* @param string $bookingType Either 'Anfrage' (inquiry) or 'Buchung' (final booking)
*
* @return array<string, mixed> The structured payload array for BusProNet API submission
*/
public function createBookingRequestPayload(BookingDto $bookingDto, string $bookingType): array
{
// Apply bulk or family insurance if applicable (modifies DTO in place)
$this->applyApplicantInsuranceToParticipants($bookingDto);
return $this->payloadBuilder->buildCreatePayload($bookingDto, $bookingType);
}
/**
* Applies the applicant's insurance to other participants if their selection requires it.
*
* Runs when either:
* - Bulk insurance is active (applicant's bulkInsuranceBooking = true): assigns the
* applicant's insurance TYPE to all dependent participants with automatic price tier
* adjustment based on each participant's individual cost.
* - The applicant selected a family insurance: family insurance covers the whole family
* under a single policy, so dependents must never hold their own insurance, regardless
* of whether the bulk checkbox was checked.
*
* IMPORTANT: In edit mode, non-family bulk insurance only applies to participants who:
* 1. Currently have NO insurance assigned (insurance === null)
* 2. OR whose insurance was already assigned via previous bulk operation
*
* This prevents overriding individually selected insurances that are locked. Family
* insurance is exempt from this protection: dependents can never legitimately hold
* their own policy alongside it, so their insurance is always cleared, in edit mode too.
*
* @param BookingDto $bookingDto The booking DTO (create or edit flow)
*/
private function applyApplicantInsuranceToParticipants(BookingDto $bookingDto): void
{
$applicant = $bookingDto->getParticipant(0);
if (null === $applicant || null === $applicant->insurance) {
return;
}
$isFamilyInsurance = true === $applicant->insurance->familyInsurance;
// Check if bulk insurance is enabled OR the applicant's selection is a family
// insurance (which always applies to dependents, independent of the bulk checkbox)
if (false === $applicant->bulkInsuranceBooking && false === $isFamilyInsurance) {
return;
}
// Get selectable (non-complementary) insurances with caching
$selectableInsurances = $this->insuranceService->getSelectableInsurances($bookingDto->travel);
// Calculate travel prices for all participants (excluding insurance)
$participantPrices = [];
foreach ($bookingDto->getParticipants() as $index => $participant) {
$participantPrices[$index] = $this->priceCalculatorService->calculateIndividualParticipantPriceExcludingInsurance($bookingDto, $index);
}
// Use InsuranceManager for proper type-based assignment with price tier matching
$assignments = $this->insuranceService->batchAssignInsuranceToParticipants(
$selectableInsurances,
$applicant->insurance,
$bookingDto,
$participantPrices
);
// Apply assignments to all participants; for non-family insurance skip the applicant
// (they already hold the insurance they selected themselves)
foreach ($assignments as $index => $insurance) {
if (0 === $index && false === $isFamilyInsurance) {
continue;
}
$participant = $bookingDto->getParticipant($index);
if (null === $participant) {
continue;
}
// In edit mode: non-family bulk assignment must not overwrite an individually
// locked insurance. Family insurance always overrides, since dependents can
// never legitimately keep their own policy alongside it.
if (BookingDto::MODE_EDIT === $bookingDto->getMode() && false === $isFamilyInsurance && null !== $participant->insurance) {
continue; // Skip participants with existing insurance assignment
}
$participant->insurance = $insurance;
}
}
}